feat: add per-account daily open hard limit across all exchanges

Enforce optional DAILY_OPEN_HARD_LIMIT in precheck_risk and can_trade, keep AI alerts at DAILY_OPEN_ALERT_THRESHOLD, and document env setup for all four instances.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-09 15:46:18 +08:00
parent f7d94f67d7
commit 24a86a710c
17 changed files with 698 additions and 77 deletions
+5 -1
View File
@@ -140,7 +140,7 @@ AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
# ORDER_CHART_TFS=4h,1h,15m,5m
# ORDER_CHART_LIMIT=100
# ORDER_CHART_DIR=static/images/order_charts
# DAILY_OPEN_ALERT_THRESHOLD=5
# 详见 DAILY_OPEN_ALERT_THRESHOLD / DAILY_OPEN_HARD_LIMIT;说明文档 docs/daily-open-limit.md
# 关键位:标准方案止损外侧%、趋势单方案止损外侧%(默认 0.5 / 1)
# KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5
# KEY_TREND_STOP_OUTSIDE_PCT=1
@@ -162,6 +162,10 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
MAX_ACTIVE_POSITIONS=1
MANUAL_MIN_PLANNED_RR=1.4
# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单)
DAILY_OPEN_ALERT_THRESHOLD=5
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
DAILY_OPEN_HARD_LIMIT=0
KEY_CONFIRM_BREAKOUT_BAR=-2
KEY_CONFIRM_BAR=-1
+79 -15
View File
@@ -274,7 +274,18 @@ ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100"))
ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts"))
DAILY_OPEN_ALERT_THRESHOLD = int(os.getenv("DAILY_OPEN_ALERT_THRESHOLD", "5"))
from daily_open_limit_lib import (
build_daily_open_alert_prompt,
can_trade_new_open,
check_daily_open_hard_limit,
count_opens_for_trading_day,
format_daily_open_counter_line,
format_daily_open_summary_short,
load_daily_open_limits_from_env,
should_send_daily_open_alert,
)
DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT = load_daily_open_limits_from_env()
RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2"))
BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0"))
BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02"))
@@ -2456,6 +2467,11 @@ def precheck_risk(conn, symbol, direction):
active_count = get_active_position_count(conn)
if active_count >= MAX_ACTIVE_POSITIONS:
return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS}"
ok_daily, daily_reason, _opens = check_daily_open_hard_limit(
conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR
)
if not ok_daily:
return False, daily_reason
if direction not in ("long", "short"):
return False, "方向必须为 long 或 short"
if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"):
@@ -5130,7 +5146,7 @@ def check_key_monitors():
f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}",
f"- **{tpsl_txt}**",
f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}",
f"- 当日开仓次数:**{det.get('opens_today_after')}** / {DAILY_OPEN_ALERT_THRESHOLD}(提醒阈值)",
f"- {format_daily_open_summary_short(det.get('opens_today_after'), DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT)}",
]
succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines])
if risk_tip:
@@ -5139,11 +5155,19 @@ def check_key_monitors():
send_wechat_msg(succ_msg)
_finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened")
if det.get("opens_today_before", 0) < DAILY_OPEN_ALERT_THRESHOLD <= det.get("opens_today_after", 0):
if should_send_daily_open_alert(
det.get("opens_today_before", 0),
det.get("opens_today_after", 0),
DAILY_OPEN_ALERT_THRESHOLD,
):
advice = ai_short_advice(
f"用户在北京时间交易日 {det['trading_day']} 已累计开仓 {det['opens_today_after']} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。"
f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。"
f"用户自述“上头了”。请给克制提醒。"
build_daily_open_alert_prompt(
det["trading_day"],
det.get("opens_today_after", 0),
DAILY_OPEN_ALERT_THRESHOLD,
hard_limit=DAILY_OPEN_HARD_LIMIT,
detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。",
)
)
if advice:
send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}")
@@ -5731,7 +5755,14 @@ def render_main_page(page="trade"):
active_count = len(order_list)
open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn)
open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR
can_trade = trading_day_reset_allows_new_open(now, conn) and active_count < MAX_ACTIVE_POSITIONS
opens_today = count_opens_for_trading_day(conn, trading_day)
can_trade = can_trade_new_open(
time_allows=trading_day_reset_allows_new_open(now, conn),
active_count=active_count,
max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT,
)
key_gate_rule_text = (
f"【箱体/收敛】{KLINE_TIMEFRAME} 两根闭合K|突破越过关键位 > {KEY_BREAKOUT_AMP_MIN_PCT}%"
f"确认K收于箱外|量能>前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}"
@@ -5781,6 +5812,9 @@ def render_main_page(page="trade"):
price_refresh_seconds=PRICE_REFRESH_SECONDS,
active_count=active_count,
can_trade=can_trade,
opens_today=opens_today,
daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT,
daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD,
focus_key_id=(key_list[0]["id"] if key_list else None),
focus_order_id=(order_list[0]["id"] if order_list else None),
data_export_version=3,
@@ -5879,9 +5913,16 @@ def api_account_snapshot():
recommended_capital = get_recommended_capital(current_capital)
active_count = get_active_position_count(conn)
open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn)
opens_today = count_opens_for_trading_day(conn, trading_day)
conn.close()
open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
can_trade = can_trade_new_open(
time_allows=trading_day_reset_allows_new_open(now),
active_count=active_count,
max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT,
)
available_trading_usdt = get_available_trading_usdt()
return jsonify({
"funding_usdt": funding_usdt,
@@ -5891,6 +5932,9 @@ def api_account_snapshot():
"active_count": active_count,
"max_active_positions": MAX_ACTIVE_POSITIONS,
"can_trade": can_trade,
"opens_today": opens_today,
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
"daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD,
"open_guard_enabled": open_guard_enabled,
"open_guard_blocks_now": open_guard_blocks_now,
"reset_hour": TRADING_DAY_RESET_HOUR,
@@ -5912,15 +5956,25 @@ def api_settings_open_guard():
set_trading_day_reset_open_guard_enabled(enabled)
now = app_now()
conn = get_db()
trading_day = get_trading_day(now)
active_count = get_active_position_count(conn)
guard_on = get_trading_day_reset_open_guard_enabled(conn)
opens_today = count_opens_for_trading_day(conn, trading_day)
conn.close()
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
can_trade = can_trade_new_open(
time_allows=trading_day_reset_allows_new_open(now),
active_count=active_count,
max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT,
)
return jsonify(
{
"ok": True,
"open_guard_enabled": guard_on,
"can_trade": can_trade,
"opens_today": opens_today,
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
"reset_hour": TRADING_DAY_RESET_HOUR,
}
)
@@ -7068,7 +7122,9 @@ def add_order():
f"移动保本位:{breakeven_rr_trigger}R → {be_wx}",
"📌 状态统计",
f"✅ 条件委托:{order_state_text}",
f"📅 当日开仓次数:{opens_today_after} / {DAILY_OPEN_ALERT_THRESHOLD} 次(风控阈值提醒)",
format_daily_open_counter_line(
opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT
),
]
if chart_url:
wx_lines.append(f"多周期K线图:{chart_url}")
@@ -7077,17 +7133,25 @@ def add_order():
flash_lines = [
f"实盘开单成功:风格 {trade_style};风险 {risk_display};基数 {round(float(margin_capital), 2)}U,杠杆 {leverage}x,名义仓位 {format_wechat_scalar_2dp(notional_value)}U,仓位占比 {position_ratio}%,合约张数 {format_wechat_scalar_2dp(amount)}(折算标的 {base_amount}),"
f"计划RR {format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)",
f"本交易日累计开仓:{opens_today_after}",
format_daily_open_summary_short(
opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT
),
]
if chart_url:
flash_lines.append(f"已生成多周期K线图:{chart_url}")
flash(" ".join(flash_lines))
if opens_today_before < DAILY_OPEN_ALERT_THRESHOLD <= opens_today_after:
if should_send_daily_open_alert(
opens_today_before, opens_today_after, DAILY_OPEN_ALERT_THRESHOLD
):
advice = ai_short_advice(
f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_today_after} 次(阈值 {DAILY_OPEN_ALERT_THRESHOLD})。"
f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。"
f"用户自述“上头了”。请给克制提醒。"
build_daily_open_alert_prompt(
trading_day,
opens_today_after,
DAILY_OPEN_ALERT_THRESHOLD,
hard_limit=DAILY_OPEN_HARD_LIMIT,
detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。",
)
)
if advice:
send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}")
+12 -2
View File
@@ -451,7 +451,8 @@
</div>
<div class="rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x
{% if can_trade %}可开仓{% else %}不可开仓{% if active_count >= max_active_positions %}(持仓 {{ active_count }}/{{ max_active_positions }}{% endif %}{% if open_guard_blocks_now %}(未到北京时间 {{ reset_hour }}:00{% endif %}{% endif %}
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}AI 提醒 {{ daily_open_alert_threshold }}
{% if can_trade %}可开仓{% else %}不可开仓{% if active_count >= max_active_positions %}(持仓 {{ active_count }}/{{ max_active_positions }}{% endif %}{% if daily_open_hard_limit > 0 and opens_today >= daily_open_hard_limit %}(单日开仓达上限){% endif %}{% if open_guard_blocks_now %}(未到北京时间 {{ reset_hour }}:00{% endif %}{% endif %}
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
<div class="rule-tip">
@@ -2072,13 +2073,22 @@ function refreshAccountSnapshot(){
if(!data.can_trade){
const parts = [];
if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`);
const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
const opens = Number(data.opens_today);
if (hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`);
if(data.open_guard_blocks_now) parts.push(`未到北京时间 ${data.reset_hour||{{ reset_hour }}}:00`);
canTradeText = parts.length ? `不可开仓(${parts.join("")}` : "不可开仓";
}
const opensToday = Number(data.opens_today);
const hardLim = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
const alertLim = Number(data.daily_open_alert_threshold != null ? data.daily_open_alert_threshold : {{ daily_open_alert_threshold }});
const openCntTxt = !Number.isNaN(opensToday)
? `本交易日开仓 ${opensToday}${hardLim > 0 ? ` / 硬上限 ${hardLim}` : ""}AI 提醒 ${alertLim}`
: "";
const tip = document.getElementById("order-rule-tip");
const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt.toFixed(2)}U` : "";
if(tip){
tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x${canTradeText}${avail}`;
tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x${openCntTxt ? openCntTxt + "" : ""}${canTradeText}${avail};人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1`;
}
const allowEl = document.getElementById("allow-open-before-reset");
const guardStatus = document.getElementById("open-guard-status");
+2
View File
@@ -182,6 +182,8 @@ OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080
# ORDER_CHART_LIMIT=100
# ORDER_CHART_DIR=static/images/order_charts
# DAILY_OPEN_ALERT_THRESHOLD=5
# DAILY_OPEN_HARD_LIMIT=0
# 说明见仓库 docs/daily-open-limit.md
# AI 复盘(默认 OpenAI 兼容网关;与 Ollama 二选一)
AI_PROVIDER=openai