diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example
index 95e109f..101cc51 100644
--- a/crypto_monitor_binance/.env.example
+++ b/crypto_monitor_binance/.env.example
@@ -122,6 +122,10 @@ MAX_ACTIVE_POSITIONS=1
MANUAL_MIN_PLANNED_RR=1.4
# 【关键位连开计仓】true=已有持仓时关键位自动单仍按「无仓时」资金快照算保证金基数
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true
+# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单)
+DAILY_OPEN_ALERT_THRESHOLD=5
+# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
+DAILY_OPEN_HARD_LIMIT=0
# 资金与仓位刷新周期(秒)
BALANCE_REFRESH_SECONDS=60
@@ -177,7 +181,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
# 以损定仓(按交易账户资金的百分比)
# RISK_PERCENT=2
# 移动保本触发(达到多少R触发)与偏移(百分比)
diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py
index 463dd79..799ce39 100644
--- a/crypto_monitor_binance/app.py
+++ b/crypto_monitor_binance/app.py
@@ -299,7 +299,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"))
@@ -3053,6 +3064,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"):
@@ -5374,7 +5390,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:
@@ -5383,11 +5399,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]}")
@@ -6092,7 +6116,14 @@ def render_main_page(page="trade"):
)
rate = round(win/total*100,2) if total else 0
active_count = len(order_list)
- can_trade = trading_day_reset_allows_new_open(now) 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),
+ 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}|"
@@ -6141,6 +6172,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,
@@ -6223,8 +6257,15 @@ def api_account_snapshot():
current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS)
recommended_capital = get_recommended_capital(current_capital)
active_count = get_active_position_count(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,
+ )
available_trading_usdt = get_available_trading_usdt()
return jsonify({
"funding_usdt": funding_usdt,
@@ -6234,6 +6275,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,
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
"trading_day": trading_day
})
@@ -7345,7 +7389,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}")
@@ -7354,17 +7400,25 @@ def add_order():
flash_lines = [
f"实盘开单成功:风格 {trade_style};风险 {risk_display};基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约数量 {amount}(折算标的 {base_amount}),"
f"计划RR {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]}")
diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html
index f143a05..f58102d 100644
--- a/crypto_monitor_binance/templates/index.html
+++ b/crypto_monitor_binance/templates/index.html
@@ -442,7 +442,8 @@
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
- {% if can_trade %}可开仓{% else %}不可开仓(持仓已满或未到北京时间 {{ reset_hour }}:00){% endif %};
+ 本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
+ {% if can_trade %}可开仓{% else %}不可开仓(持仓已满、单日开仓达上限,或未到北京时间 {{ reset_hour }}:00){% endif %};
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
@@ -2058,11 +2059,29 @@ function refreshAccountSnapshot(){
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt);
}
- const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}} 或未到北京时间 {{ reset_hour }}:00)`;
+ let canTradeText = "可开仓";
+ if (!data.can_trade) {
+ const parts = [];
+ const ac = Number(data.active_count || 0);
+ const max = Number(data.max_active_positions || {{ max_active_positions }});
+ if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
+ 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 (!parts.length) parts.push(`未到北京时间 {{ reset_hour }}:00`);
+ else parts.push(`或未到北京时间 {{ reset_hour }}:00`);
+ canTradeText = `不可开仓(${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`;
}
}).catch(()=>{});
}
diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example
index e0cd8e3..4f20088 100644
--- a/crypto_monitor_gate/.env.example
+++ b/crypto_monitor_gate/.env.example
@@ -124,6 +124,10 @@ MAX_ACTIVE_POSITIONS=1
MANUAL_MIN_PLANNED_RR=1.4
# 【关键位连开计仓】已有持仓时按无仓时资金快照算基数
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true
+# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单)
+DAILY_OPEN_ALERT_THRESHOLD=5
+# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
+DAILY_OPEN_HARD_LIMIT=0
# 资金与仓位刷新周期(秒)
BALANCE_REFRESH_SECONDS=60
@@ -181,7 +185,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
# 以损定仓(按交易账户资金的百分比)
# RISK_PERCENT=2
# 移动保本触发(达到多少R触发)与偏移(百分比)
diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py
index 3230e5f..71d094f 100644
--- a/crypto_monitor_gate/app.py
+++ b/crypto_monitor_gate/app.py
@@ -297,7 +297,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"))
@@ -2745,6 +2756,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"):
@@ -5210,7 +5226,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:
@@ -5219,11 +5235,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]}")
@@ -6064,7 +6088,14 @@ def render_main_page(page="trade"):
)
rate = round(win/total*100,2) if total else 0
active_count = len(order_list)
- can_trade = trading_day_reset_allows_new_open(now) 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),
+ 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}|"
@@ -6114,6 +6145,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,
@@ -6212,8 +6246,15 @@ def api_account_snapshot():
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
recommended_capital = round(float(get_recommended_capital(current_capital)), 2)
active_count = get_active_position_count(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,
+ )
available_trading_usdt = get_available_trading_usdt()
return jsonify({
"funding_usdt": funding_usdt,
@@ -6223,6 +6264,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,
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
"trading_day": trading_day
})
@@ -7420,7 +7464,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}")
@@ -7429,17 +7475,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,基数{round(float(margin_capital), 2)}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,基数{round(float(margin_capital), 2)}U。",
+ )
)
if advice:
send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}")
diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html
index 2fba849..b378fcf 100644
--- a/crypto_monitor_gate/templates/index.html
+++ b/crypto_monitor_gate/templates/index.html
@@ -443,7 +443,8 @@
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
- {% if can_trade %}可开仓{% else %}不可开仓(持仓已满或未到北京时间 {{ reset_hour }}:00){% endif %};
+ 本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
+ {% if can_trade %}可开仓{% else %}不可开仓(持仓已满、单日开仓达上限,或未到北京时间 {{ reset_hour }}:00){% endif %};
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
@@ -2042,11 +2043,29 @@ function refreshAccountSnapshot(){
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt);
}
- const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}} 或未到北京时间 {{ reset_hour }}:00)`;
+ let canTradeText = "可开仓";
+ if (!data.can_trade) {
+ const parts = [];
+ const ac = Number(data.active_count || 0);
+ const max = Number(data.max_active_positions || {{ max_active_positions }});
+ if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
+ 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 (!parts.length) parts.push(`未到北京时间 {{ reset_hour }}:00`);
+ else parts.push(`或未到北京时间 {{ reset_hour }}:00`);
+ canTradeText = `不可开仓(${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`;
}
}).catch(()=>{});
}
diff --git a/crypto_monitor_gate_bot/.env.example b/crypto_monitor_gate_bot/.env.example
index 018cb09..e02b152 100644
--- a/crypto_monitor_gate_bot/.env.example
+++ b/crypto_monitor_gate_bot/.env.example
@@ -90,6 +90,10 @@ GATE_TPSL_PRICE_TYPE=0
# =============================================================================
# 【最大同时持仓】active 下单监控数达到该值后禁止再开仓(默认 1=单仓)
MAX_ACTIVE_POSITIONS=1
+# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单)
+DAILY_OPEN_ALERT_THRESHOLD=5
+# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
+DAILY_OPEN_HARD_LIMIT=0
# 整点前禁止新开仓:true=启用(默认),false=关闭(交易日划分仍用 TRADING_DAY_RESET_HOUR)
# TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
@@ -150,7 +154,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
# 以损定仓(按交易账户资金的百分比)
# RISK_PERCENT=2
# 移动保本触发(达到多少R触发)与偏移(百分比)
diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py
index dadf55a..8f84b58 100644
--- a/crypto_monitor_gate_bot/app.py
+++ b/crypto_monitor_gate_bot/app.py
@@ -252,7 +252,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"))
MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0"))
@@ -2696,6 +2707,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
trend_n = conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
@@ -2717,6 +2733,11 @@ def precheck_trend_pullback_start(conn):
now = app_now()
if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
+ 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
active_count = get_active_position_count(conn)
if active_count >= MAX_ACTIVE_POSITIONS:
return False, (
@@ -5383,10 +5404,14 @@ def render_main_page(page="trade"):
preview_snapshots.append(sd)
except Exception as e:
print(f"[records] trend_pullback_preview_snapshots: {e}")
- can_trade = (
- trading_day_reset_allows_new_open(now)
- and active_count < MAX_ACTIVE_POSITIONS
- and int(trend_active or 0) == 0
+ 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),
+ active_count=active_count,
+ max_active_positions=MAX_ACTIVE_POSITIONS,
+ opens_today=opens_today,
+ hard_limit=DAILY_OPEN_HARD_LIMIT,
+ extra_blocks=int(trend_active or 0) != 0,
)
strategy_extra = {}
if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"):
@@ -5440,6 +5465,9 @@ def render_main_page(page="trade"):
max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
can_trade=can_trade,
+ opens_today=opens_today,
+ daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT,
+ daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD,
live_trading_enabled=LIVE_TRADING_ENABLED,
preview_snapshots=preview_snapshots,
exchange_sync_from_label=(EXCHANGE_POSITION_SYNC_FROM_BJ or "最近90天"),
@@ -5538,11 +5566,15 @@ def api_account_snapshot():
trend_active = conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
+ 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
- and int(trend_active or 0) == 0
+ 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,
+ extra_blocks=int(trend_active or 0) != 0,
)
available_trading_usdt = get_available_trading_usdt()
return jsonify({
@@ -5553,6 +5585,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,
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
"trading_day": trading_day
})
@@ -6557,7 +6592,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}")
@@ -6566,17 +6603,25 @@ def add_order():
flash_lines = [
f"机器人开单成功:风格 {trade_style};风险 {risk_display};基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约张数 {amount}(折算标的 {base_amount}),"
f"计划RR {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]}")
diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html
index f8d8935..ac8aaac 100644
--- a/crypto_monitor_gate_bot/templates/index.html
+++ b/crypto_monitor_gate_bot/templates/index.html
@@ -381,7 +381,8 @@
规则:最大同时持仓 {{ max_active_positions }}(当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
- {% if can_trade %}可开仓{% else %}不可开仓(持仓达上限、有趋势回调计划,或未到北京时间 {{ reset_hour }}:00){% endif %};
+ 本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
+ {% if can_trade %}可开仓{% else %}不可开仓(持仓达上限、单日开仓达上限、有趋势回调计划,或未到北京时间 {{ reset_hour }}:00){% endif %};
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
@@ -1974,12 +1975,30 @@ function refreshAccountSnapshot(){
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt);
}
- const canTradeText = data.can_trade ? "可开仓" : `不可开仓(持仓 ${data.active_count||0}/${data.max_active_positions||{{ max_active_positions }}}、有趋势回调计划,或未到北京时间 {{ reset_hour }}:00)`;
+ let canTradeText = "可开仓";
+ if (!data.can_trade) {
+ const parts = [];
+ const ac = Number(data.active_count || 0);
+ const max = Number(data.max_active_positions || {{ max_active_positions }});
+ if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
+ 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} 已达上限`);
+ parts.push("有趋势回调计划");
+ parts.push(`或未到北京时间 {{ reset_hour }}:00`);
+ canTradeText = `不可开仓(${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` : "";
const minRr = data.manual_min_planned_rr != null ? data.manual_min_planned_rr : MANUAL_MIN_PLANNED_RR;
if(tip){
- tip.innerText = `规则:最大同时持仓 ${data.max_active_positions || {{ max_active_positions }}}(当前 active ${data.active_count||0});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${canTradeText}${avail};人工开仓盈亏比不得低于 ${minRr}:1`;
+ tip.innerText = `规则:最大同时持仓 ${data.max_active_positions || {{ max_active_positions }}}(当前 active ${data.active_count||0});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${openCntTxt ? openCntTxt + ";" : ""}${canTradeText}${avail};人工开仓盈亏比不得低于 ${minRr}:1`;
}
}).catch(()=>{});
}
diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example
index 311e543..0790ff2 100644
--- a/crypto_monitor_okx/.env.example
+++ b/crypto_monitor_okx/.env.example
@@ -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
diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py
index bb390fc..921bf5d 100644
--- a/crypto_monitor_okx/app.py
+++ b/crypto_monitor_okx/app.py
@@ -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]}")
diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html
index 3503bde..f6f81ab 100644
--- a/crypto_monitor_okx/templates/index.html
+++ b/crypto_monitor_okx/templates/index.html
@@ -451,7 +451,8 @@
规则:最多 {{ 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
@@ -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");
diff --git a/crypto_monitor_okx/部署文档.md b/crypto_monitor_okx/部署文档.md
index cbf2b12..1dcd531 100644
--- a/crypto_monitor_okx/部署文档.md
+++ b/crypto_monitor_okx/部署文档.md
@@ -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
diff --git a/daily_open_limit_lib.py b/daily_open_limit_lib.py
new file mode 100644
index 0000000..f90c9c9
--- /dev/null
+++ b/daily_open_limit_lib.py
@@ -0,0 +1,140 @@
+"""单日开仓次数:软提醒阈值 + 硬上限(四所实例共用)。"""
+from __future__ import annotations
+
+import os
+from typing import Any, Optional
+
+
+def parse_daily_open_alert_threshold(raw: Any = None, *, default: int = 5) -> int:
+ """AI 克制提醒阈值;至少 1。"""
+ try:
+ v = int(raw if raw is not None and str(raw).strip() != "" else default)
+ except (TypeError, ValueError):
+ v = default
+ return max(1, v)
+
+
+def parse_daily_open_hard_limit(raw: Any = None, *, default: int = 0) -> int:
+ """硬上限;0 表示不启用。至少 0。"""
+ try:
+ v = int(raw if raw is not None and str(raw).strip() != "" else default)
+ except (TypeError, ValueError):
+ v = default
+ return max(0, v)
+
+
+def load_daily_open_limits_from_env(
+ env: Optional[dict[str, str]] = None,
+) -> tuple[int, int]:
+ """从环境变量读取 (alert_threshold, hard_limit)。"""
+ src = env if env is not None else os.environ
+ alert = parse_daily_open_alert_threshold(src.get("DAILY_OPEN_ALERT_THRESHOLD"))
+ hard = parse_daily_open_hard_limit(src.get("DAILY_OPEN_HARD_LIMIT"))
+ return alert, hard
+
+
+def count_opens_for_trading_day(conn, trading_day: str) -> int:
+ """本交易日已成功写入 order_monitors 的开仓次数。"""
+ td = (trading_day or "").strip()
+ if not td:
+ return 0
+ row = conn.execute(
+ "SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
+ (td,),
+ ).fetchone()
+ return int(row[0] if row else 0)
+
+
+def daily_open_hard_limit_blocks(opens_today: int, hard_limit: int) -> bool:
+ return int(hard_limit) > 0 and int(opens_today) >= int(hard_limit)
+
+
+def hard_limit_block_reason(opens_today: int, hard_limit: int, reset_hour: int) -> str:
+ return (
+ f"本交易日开仓次数已达上限({int(opens_today)}/{int(hard_limit)}),"
+ f"次日北京时间 {int(reset_hour)}:00 后恢复"
+ )
+
+
+def check_daily_open_hard_limit(
+ conn,
+ trading_day: str,
+ hard_limit: int,
+ reset_hour: int,
+) -> tuple[bool, str, int]:
+ """返回 (允许继续开仓, 拒绝原因, 当日已开次数)。"""
+ opens_today = count_opens_for_trading_day(conn, trading_day)
+ if daily_open_hard_limit_blocks(opens_today, hard_limit):
+ return False, hard_limit_block_reason(opens_today, hard_limit, reset_hour), opens_today
+ return True, "", opens_today
+
+
+def can_trade_new_open(
+ *,
+ time_allows: bool,
+ active_count: int,
+ max_active_positions: int,
+ opens_today: int,
+ hard_limit: int,
+ extra_blocks: bool = False,
+) -> bool:
+ if extra_blocks:
+ return False
+ if not time_allows:
+ return False
+ if int(active_count) >= int(max_active_positions):
+ return False
+ if daily_open_hard_limit_blocks(opens_today, hard_limit):
+ return False
+ return True
+
+
+def should_send_daily_open_alert(before: int, after: int, alert_threshold: int) -> bool:
+ return int(before) < int(alert_threshold) <= int(after)
+
+
+def build_daily_open_alert_prompt(
+ trading_day: str,
+ opens_after: int,
+ alert_threshold: int,
+ *,
+ hard_limit: int = 0,
+ detail_line: str = "",
+) -> str:
+ hard_txt = (
+ f"硬上限 {hard_limit} 次(已达后将禁止新开仓直至下一交易日)。"
+ if int(hard_limit) > 0
+ else "未配置单日硬上限。"
+ )
+ extra = f" {detail_line}" if detail_line else ""
+ return (
+ f"用户在北京时间交易日 {trading_day} 已累计开仓 {opens_after} 次"
+ f"(AI 提醒阈值 {alert_threshold};{hard_txt})"
+ f"{extra}"
+ f"用户自述“上头了”。请给克制提醒。"
+ )
+
+
+def format_daily_open_counter_line(
+ opens_today: int,
+ alert_threshold: int,
+ hard_limit: int,
+) -> str:
+ if int(hard_limit) > 0:
+ return (
+ f"📅 当日开仓次数:{int(opens_today)} / 硬上限 {int(hard_limit)} 次"
+ f"(AI 提醒阈值 {int(alert_threshold)})"
+ )
+ return (
+ f"📅 当日开仓次数:{int(opens_today)} / AI 提醒阈值 {int(alert_threshold)} 次"
+ )
+
+
+def format_daily_open_summary_short(
+ opens_today: int,
+ alert_threshold: int,
+ hard_limit: int,
+) -> str:
+ if int(hard_limit) > 0:
+ return f"本交易日累计开仓:{int(opens_today)}(硬上限 {int(hard_limit)},提醒 {int(alert_threshold)})"
+ return f"本交易日累计开仓:{int(opens_today)}(提醒阈值 {int(alert_threshold)})"
diff --git a/docs/daily-open-limit.md b/docs/daily-open-limit.md
new file mode 100644
index 0000000..f855069
--- /dev/null
+++ b/docs/daily-open-limit.md
@@ -0,0 +1,81 @@
+# 单日开仓次数限制(四所统一)
+
+各交易实例(Binance / OKX / Gate / Gate_bot)在 `.env` 中独立配置,互不影响。
+
+## 交易日口径
+
+- 以 **北京时间** `TRADING_DAY_RESET_HOUR`(默认 **8:00**)切分交易日,与统计、顶栏「交易日」一致。
+- **次日恢复**:过了切日时刻后 `session_date` 变为新日期,计数自动归零,无需清库。
+
+## 计数口径
+
+每成功新建一条 `order_monitors` 记录计 **1 次**,包括:
+
+- 人工「实盘下单」
+- 关键位自动开仓
+- 其他写入 `order_monitors` 的成功开仓
+
+平仓后再开仍算新的一单。当日总次数到硬上限后 **当天不再允许新开**(即使已空仓)。
+
+## 环境变量
+
+在「交易执行 / 人工风控」段配置:
+
+```env
+# 【单日开仓 AI 提醒】本交易日开仓次数达到该值时,企业微信推送 AI 克制提醒(不拦单)
+DAILY_OPEN_ALERT_THRESHOLD=5
+
+# 【单日开仓硬上限】本交易日开仓次数 >= 该值后,禁止一切新开仓直至下一交易日;0=不启用
+DAILY_OPEN_HARD_LIMIT=0
+```
+
+### 配置示例
+
+```env
+# 保守户:3 次提醒,5 次封死
+DAILY_OPEN_ALERT_THRESHOLD=3
+DAILY_OPEN_HARD_LIMIT=5
+
+# 仅提醒、不封(与旧版行为接近)
+DAILY_OPEN_ALERT_THRESHOLD=5
+DAILY_OPEN_HARD_LIMIT=0
+
+# 严格户:到 3 次即封
+DAILY_OPEN_ALERT_THRESHOLD=2
+DAILY_OPEN_HARD_LIMIT=3
+```
+
+建议 `DAILY_OPEN_ALERT_THRESHOLD <= DAILY_OPEN_HARD_LIMIT`(硬上限为 0 时除外)。
+
+## 程序行为
+
+| 次数 | 行为 |
+|------|------|
+| 未达提醒阈值 | 正常开仓 |
+| 达到 `DAILY_OPEN_ALERT_THRESHOLD` | 成功开仓后 AI 企业微信提醒 |
+| 达到 `DAILY_OPEN_HARD_LIMIT`(>0) | `precheck_risk` 拒绝人工/关键位开仓;顶栏 `can_trade=false` |
+
+硬限制与以下规则 **同时生效**(取交集):
+
+- `TRADING_DAY_RESET_OPEN_GUARD_ENABLED`:切日前禁止新开
+- `MAX_ACTIVE_POSITIONS`:同时持仓上限
+- Gate_bot:`precheck_trend_pullback_start` 同样校验单日硬上限
+
+## 页面与接口
+
+- 顶栏 / `api/account_snapshot` 返回 `opens_today`、`daily_open_hard_limit`、`daily_open_alert_threshold`。
+- 达硬上限时提示:`本交易日开仓 N/M 已达上限,次日 8:00 后恢复`(`M` 为配置的硬上限)。
+
+## 部署
+
+修改各实例 `.env` 后重启对应 pm2 进程,例如:
+
+```bash
+pm2 restart crypto_binance crypto_okx crypto_gate crypto_gate_bot
+```
+
+## 实现位置
+
+- 共享逻辑:`daily_open_limit_lib.py`
+- 四所 `app.py`:`precheck_risk`、`can_trade`、`api/account_snapshot`、开仓成功后的 AI 提醒文案
+- 单元测试:`tests/test_daily_open_limit_lib.py`
diff --git a/strategy_trend_register.py b/strategy_trend_register.py
index ba32346..be4654a 100644
--- a/strategy_trend_register.py
+++ b/strategy_trend_register.py
@@ -1484,10 +1484,18 @@ def load_trend_page_context(conn, request_obj, cfg: dict) -> dict[str, Any]:
pass
now = m.app_now()
active_count = m.get_active_position_count(conn)
- can_trade_trend = (
- m.trading_day_reset_allows_new_open(now)
- and active_count < cfg["max_active_positions"]
- and trend_active == 0
+ from daily_open_limit_lib import can_trade_new_open, count_opens_for_trading_day
+
+ trading_day = m.get_trading_day(now)
+ opens_today = count_opens_for_trading_day(conn, trading_day)
+ hard_limit = int(getattr(m, "DAILY_OPEN_HARD_LIMIT", 0) or 0)
+ can_trade_trend = can_trade_new_open(
+ time_allows=m.trading_day_reset_allows_new_open(now),
+ active_count=active_count,
+ max_active_positions=cfg["max_active_positions"],
+ opens_today=opens_today,
+ hard_limit=hard_limit,
+ extra_blocks=trend_active != 0,
)
trend_preview = None
trend_preview_levels = []
diff --git a/tests/test_daily_open_limit_lib.py b/tests/test_daily_open_limit_lib.py
new file mode 100644
index 0000000..00d0c52
--- /dev/null
+++ b/tests/test_daily_open_limit_lib.py
@@ -0,0 +1,90 @@
+import unittest
+
+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,
+ daily_open_hard_limit_blocks,
+ format_daily_open_counter_line,
+ hard_limit_block_reason,
+ load_daily_open_limits_from_env,
+ parse_daily_open_hard_limit,
+ should_send_daily_open_alert,
+)
+
+
+class _FakeConn:
+ def __init__(self, count: int):
+ self._count = count
+
+ def execute(self, _sql, _params):
+ return self
+
+ def fetchone(self):
+ return (self._count,)
+
+
+class DailyOpenLimitLibTests(unittest.TestCase):
+ def test_parse_hard_limit_zero_disables(self):
+ self.assertEqual(parse_daily_open_hard_limit("0"), 0)
+ self.assertEqual(parse_daily_open_hard_limit(None, default=0), 0)
+
+ def test_load_from_env(self):
+ alert, hard = load_daily_open_limits_from_env(
+ {"DAILY_OPEN_ALERT_THRESHOLD": "3", "DAILY_OPEN_HARD_LIMIT": "8"}
+ )
+ self.assertEqual(alert, 3)
+ self.assertEqual(hard, 8)
+
+ def test_hard_limit_blocks(self):
+ self.assertFalse(daily_open_hard_limit_blocks(4, 0))
+ self.assertFalse(daily_open_hard_limit_blocks(4, 5))
+ self.assertTrue(daily_open_hard_limit_blocks(5, 5))
+
+ def test_check_daily_open_hard_limit(self):
+ conn = _FakeConn(5)
+ ok, reason, n = check_daily_open_hard_limit(conn, "2026-06-07", 5, 8)
+ self.assertFalse(ok)
+ self.assertEqual(n, 5)
+ self.assertIn("已达上限", reason)
+ self.assertIn("8:00", reason)
+
+ def test_count_opens(self):
+ self.assertEqual(count_opens_for_trading_day(_FakeConn(3), "2026-06-07"), 3)
+
+ def test_can_trade_new_open(self):
+ self.assertTrue(
+ can_trade_new_open(
+ time_allows=True,
+ active_count=0,
+ max_active_positions=1,
+ opens_today=2,
+ hard_limit=5,
+ )
+ )
+ self.assertFalse(
+ can_trade_new_open(
+ time_allows=True,
+ active_count=0,
+ max_active_positions=1,
+ opens_today=5,
+ hard_limit=5,
+ )
+ )
+
+ def test_alert_crossing(self):
+ self.assertTrue(should_send_daily_open_alert(4, 5, 5))
+ self.assertFalse(should_send_daily_open_alert(5, 6, 5))
+
+ def test_prompt_includes_hard_limit(self):
+ txt = build_daily_open_alert_prompt("2026-06-07", 5, 5, hard_limit=8)
+ self.assertIn("硬上限 8", txt)
+
+ def test_counter_line(self):
+ line = format_daily_open_counter_line(3, 5, 8)
+ self.assertIn("3 / 硬上限 8", line)
+
+
+if __name__ == "__main__":
+ unittest.main()