From 6ffae02d307e85109b8383a6b88515477544dd4d Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 24 Jun 2026 02:08:44 +0800 Subject: [PATCH] Allow roll add-ons while position-limit freeze is active. Co-authored-by: Cursor --- account_risk_lib.py | 17 +++++++++++- crypto_monitor_binance/app.py | 23 ++++++++++------ crypto_monitor_gate/app.py | 23 ++++++++++------ crypto_monitor_gate_bot/app.py | 23 ++++++++++------ crypto_monitor_okx/app.py | 29 ++++++++++++++------- docs/account-risk-cooldown.md | 4 ++- strategy_register.py | 1 + strategy_templates/strategy_roll_panel.html | 1 + strategy_trade_labels.py | 23 ++-------------- tests/test_account_risk_lib.py | 3 ++- tests/test_position_limit_count.py | 14 ++-------- 11 files changed, 91 insertions(+), 70 deletions(-) diff --git a/account_risk_lib.py b/account_risk_lib.py index 58feff4..3fe848a 100644 --- a/account_risk_lib.py +++ b/account_risk_lib.py @@ -93,6 +93,19 @@ def max_active_positions_from_env(default: int = 1) -> int: return max(1, default) +def position_limit_reached( + conn, + *, + max_active_positions: Optional[int] = None, +) -> tuple[bool, int, int]: + """(已达上限, 计入上限的活跃数, 上限值)。""" + from strategy_trade_labels import count_position_limit_active_monitors + + mx = max(1, int(max_active_positions if max_active_positions is not None else max_active_positions_from_env())) + ac = count_position_limit_active_monitors(conn) + return ac >= mx, ac, mx + + def mood_issues_daily_freeze_enabled() -> bool: return _env_bool("RISK_MOOD_ISSUES_DAILY_FREEZE", True) @@ -721,12 +734,14 @@ def apply_position_limit_risk( out["status"] = STATUS_FREEZE_POSITION out["status_label"] = STATUS_LABELS[STATUS_FREEZE_POSITION] out["can_trade"] = False - out["reason"] = f"已达最大持仓数({ac}/{mx},不含趋势回调/顺势加仓),平仓前不可新开" + out["can_roll"] = True + out["reason"] = f"已达最大持仓数({ac}/{mx}),新开仓已冻结,顺势加仓仍可用" out["position_limit_frozen"] = True out["freeze_until_ms"] = None out["freeze_remaining_sec"] = 0 else: out["position_limit_frozen"] = False + out["can_roll"] = True return out diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 732ca0c..82c38cb 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -3173,9 +3173,11 @@ def precheck_risk(conn, symbol, direction): return False, risk_reason if not trading_day_reset_allows_new_open(now): return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" - active_count = get_active_position_count(conn) - if active_count >= MAX_ACTIVE_POSITIONS: - return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})" + from account_risk_lib import position_limit_reached + + reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) + if reached: + return False, f"已达最大持仓数({active_count}/{mx})" ok_daily, daily_reason, _opens = check_daily_open_hard_limit( conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR ) @@ -6912,11 +6914,14 @@ def render_main_page(page="trade", embed_mode=None): records = [] total = miss_count = rate = occupied_miss_total = 0 active_count = len(order_list) + from strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), - active_count=active_count, + active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, @@ -6982,7 +6987,7 @@ def render_main_page(page="trade", embed_mode=None): auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, price_refresh_seconds=PRICE_REFRESH_SECONDS, - active_count=active_count, + active_count=position_limit_count, can_trade=can_trade, opens_today=opens_today, daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, @@ -7075,13 +7080,15 @@ def api_account_snapshot(): funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None 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) + from strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) conn.close() can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), - active_count=active_count, + active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, @@ -7093,7 +7100,7 @@ def api_account_snapshot(): "current_capital": current_capital, "available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None, "recommended_capital": recommended_capital, - "active_count": active_count, + "active_count": position_limit_count, "max_active_positions": MAX_ACTIVE_POSITIONS, "can_trade": can_trade, "opens_today": opens_today, diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 7dff966..11bfdf3 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -2862,9 +2862,11 @@ def precheck_risk(conn, symbol, direction): return False, risk_reason if not trading_day_reset_allows_new_open(now): return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" - active_count = get_active_position_count(conn) - if active_count >= MAX_ACTIVE_POSITIONS: - return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})" + from account_risk_lib import position_limit_reached + + reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) + if reached: + return False, f"已达最大持仓数({active_count}/{mx})" ok_daily, daily_reason, _opens = check_daily_open_hard_limit( conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR ) @@ -6795,11 +6797,14 @@ def render_main_page(page="trade", embed_mode=None): records = [] total = miss_count = rate = occupied_miss_total = 0 active_count = len(order_list) + from strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), - active_count=active_count, + active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, @@ -6862,7 +6867,7 @@ def render_main_page(page="trade", embed_mode=None): transfer_amount_fmt=format_usdt(AUTO_TRANSFER_AMOUNT), full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, price_refresh_seconds=PRICE_REFRESH_SECONDS, - active_count=active_count, + active_count=position_limit_count, can_trade=can_trade, opens_today=opens_today, daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, @@ -6975,13 +6980,15 @@ def api_account_snapshot(): funding_usdt = round(funding_capital, 2) if funding_capital is not None else None 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) + from strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) conn.close() can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), - active_count=active_count, + active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, @@ -6993,7 +7000,7 @@ def api_account_snapshot(): "current_capital": current_capital, "available_trading_usdt": round(available_trading_usdt, 2) if available_trading_usdt is not None else None, "recommended_capital": recommended_capital, - "active_count": active_count, + "active_count": position_limit_count, "max_active_positions": MAX_ACTIVE_POSITIONS, "can_trade": can_trade, "opens_today": opens_today, diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index dcb0ed2..9789716 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -2862,9 +2862,11 @@ def precheck_risk(conn, symbol, direction): return False, risk_reason if not trading_day_reset_allows_new_open(now): return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" - active_count = get_active_position_count(conn) - if active_count >= MAX_ACTIVE_POSITIONS: - return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})" + from account_risk_lib import position_limit_reached + + reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) + if reached: + return False, f"已达最大持仓数({active_count}/{mx})" ok_daily, daily_reason, _opens = check_daily_open_hard_limit( conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR ) @@ -6795,11 +6797,14 @@ def render_main_page(page="trade", embed_mode=None): records = [] total = miss_count = rate = occupied_miss_total = 0 active_count = len(order_list) + from strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), - active_count=active_count, + active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, @@ -6862,7 +6867,7 @@ def render_main_page(page="trade", embed_mode=None): transfer_amount_fmt=format_usdt(AUTO_TRANSFER_AMOUNT), full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, price_refresh_seconds=PRICE_REFRESH_SECONDS, - active_count=active_count, + active_count=position_limit_count, can_trade=can_trade, opens_today=opens_today, daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, @@ -6971,13 +6976,15 @@ def api_account_snapshot(): funding_usdt = round(funding_capital, 2) if funding_capital is not None else None 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) + from strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) conn.close() can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), - active_count=active_count, + active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, @@ -6989,7 +6996,7 @@ def api_account_snapshot(): "current_capital": current_capital, "available_trading_usdt": round(available_trading_usdt, 2) if available_trading_usdt is not None else None, "recommended_capital": recommended_capital, - "active_count": active_count, + "active_count": position_limit_count, "max_active_positions": MAX_ACTIVE_POSITIONS, "can_trade": can_trade, "opens_today": opens_today, diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index c4a65e1..7f8d77f 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -2574,9 +2574,11 @@ def precheck_risk(conn, symbol, direction): return False, risk_reason if not trading_day_reset_allows_new_open(now): return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" - active_count = get_active_position_count(conn) - if active_count >= MAX_ACTIVE_POSITIONS: - return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})" + from account_risk_lib import position_limit_reached + + reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) + if reached: + return False, f"已达最大持仓数({active_count}/{mx})" ok_daily, daily_reason, _opens = check_daily_open_hard_limit( conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR ) @@ -6299,13 +6301,16 @@ def render_main_page(page="trade", embed_mode=None): records = [] total = miss_count = rate = occupied_miss_total = 0 active_count = len(order_list) + from strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) 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 opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now, conn), - active_count=active_count, + active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, @@ -6368,7 +6373,7 @@ def render_main_page(page="trade", embed_mode=None): auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, price_refresh_seconds=PRICE_REFRESH_SECONDS, - active_count=active_count, + active_count=position_limit_count, can_trade=can_trade, opens_today=opens_today, daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, @@ -6476,7 +6481,9 @@ def api_account_snapshot(): funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None 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) + from strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) opens_today = count_opens_for_trading_day(conn, trading_day) risk_status = hub_account_risk_status(conn) @@ -6484,7 +6491,7 @@ def api_account_snapshot(): open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), - active_count=active_count, + active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, @@ -6496,7 +6503,7 @@ def api_account_snapshot(): "current_capital": current_capital, "available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None, "recommended_capital": recommended_capital, - "active_count": active_count, + "active_count": position_limit_count, "max_active_positions": MAX_ACTIVE_POSITIONS, "can_trade": can_trade, "opens_today": opens_today, @@ -6525,13 +6532,15 @@ def api_settings_open_guard(): now = app_now() conn = get_db() trading_day = get_trading_day(now) - active_count = get_active_position_count(conn) + from strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(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 = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), - active_count=active_count, + active_count=position_limit_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, diff --git a/docs/account-risk-cooldown.md b/docs/account-risk-cooldown.md index ddfaca8..ffce723 100644 --- a/docs/account-risk-cooldown.md +++ b/docs/account-risk-cooldown.md @@ -106,7 +106,9 @@ APP_TIMEZONE=Asia/Shanghai | `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) | | `freeze_remaining_sec` | 服务端计算的剩余秒数(供调试) | -**仓位上限冻结**:当 **计入上限的** 活跃持仓数(不含趋势回调、顺势加仓)≥ 实例 `.env` 的 `MAX_ACTIVE_POSITIONS`(默认 1)且账户无时间类冻结时,徽章显示 **仓位上限冻结**;相关策略单平仓后或仅存在策略持仓时不会触发该冻结。时间冻结(1h/4h/日)优先展示。 +**仓位上限冻结**:当 **计入上限的** 活跃持仓数(不含趋势回调)≥ 实例 `.env` 的 `MAX_ACTIVE_POSITIONS`(默认 1)且账户无时间类冻结时,徽章显示 **仓位上限冻结**;此时 **新开仓** 被禁止,但 **顺势加仓**(在已有同向监控持仓上加仓)仍可用。仅存在趋势回调持仓时不触发该冻结。时间冻结(1h/4h/日)优先展示。 + +`risk_status.can_roll`:仓位上限冻结时为 `true`,表示顺势加仓不受该冻结限制。 ## 前端倒计时 diff --git a/strategy_register.py b/strategy_register.py index edf6ec2..849c949 100644 --- a/strategy_register.py +++ b/strategy_register.py @@ -130,6 +130,7 @@ def _count_active_trends(conn, cfg: dict) -> int: def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> dict: + """顺势加仓不占用 MAX_ACTIVE_POSITIONS 新仓名额,故不校验仓位上限冻结。""" m = cfg.get("app_module") if m is not None: try: diff --git a/strategy_templates/strategy_roll_panel.html b/strategy_templates/strategy_roll_panel.html index 58b7b26..856f78d 100644 --- a/strategy_templates/strategy_roll_panel.html +++ b/strategy_templates/strategy_roll_panel.html @@ -6,6 +6,7 @@ 仅人工加仓,程序不会自动触发。须先在「实盘下单」有同向持仓。
做多最多滚仓 3 次;止盈锁定首仓不变;每次填写止损偏移%(相对合并均价,默认 1%),总风险%按「合并持仓打到新止损≈账户风险」反推张数。
斐波限价:上沿 H、下沿 L 仅用于算 0.618/0.786 加仓价(多:下沿=止损侧;空:上沿=止损侧)。
+ 仓位上限冻结时仍可顺势加仓(在已有同向监控持仓上操作,不占用新仓名额)。
{% if roll_trend_active %}当前有运行中的趋势回调计划,请先结束后再滚仓。{% endif %} diff --git a/strategy_trade_labels.py b/strategy_trade_labels.py index f80bbb3..4c696d5 100644 --- a/strategy_trade_labels.py +++ b/strategy_trade_labels.py @@ -142,27 +142,8 @@ def entry_reason_for_monitor_type(monitor_type: str | None) -> str: def order_monitor_excluded_from_position_limit(conn, row) -> bool: - """趋势回调 / 顺势加仓不计入 MAX_ACTIVE_POSITIONS 与仓位上限冻结。""" - if order_monitor_source_type(row) in (MONITOR_TYPE_TREND_PULLBACK, MONITOR_TYPE_ROLL): - return True - oid = None - try: - keys = row.keys() if hasattr(row, "keys") else [] - if "id" in keys and row["id"] is not None: - oid = int(row["id"]) - except Exception: - oid = None - if oid and oid > 0: - try: - hit = conn.execute( - "SELECT 1 FROM roll_groups WHERE order_monitor_id=? AND status='active' LIMIT 1", - (oid,), - ).fetchone() - if hit is not None: - return True - except Exception: - pass - return False + """趋势回调不计入 MAX_ACTIVE_POSITIONS;顺势加仓在已有持仓上操作,单独放行。""" + return order_monitor_source_type(row) == MONITOR_TYPE_TREND_PULLBACK def count_position_limit_active_monitors(conn) -> int: diff --git a/tests/test_account_risk_lib.py b/tests/test_account_risk_lib.py index 746002d..9882f7d 100644 --- a/tests/test_account_risk_lib.py +++ b/tests/test_account_risk_lib.py @@ -499,7 +499,8 @@ class AccountRiskLibTests(unittest.TestCase): self.assertEqual(st["status_label"], "仓位上限冻结") self.assertFalse(st["can_trade"]) self.assertIn("2/2", st["reason"]) - self.assertIn("不含趋势回调", st["reason"]) + self.assertIn("顺势加仓", st["reason"]) + self.assertTrue(st.get("can_roll")) self.assertEqual(st["max_active_positions"], 2) def test_position_limit_normal_when_under_cap(self): diff --git a/tests/test_position_limit_count.py b/tests/test_position_limit_count.py index 38e315c..8c68437 100644 --- a/tests/test_position_limit_count.py +++ b/tests/test_position_limit_count.py @@ -3,7 +3,6 @@ import unittest from strategy_db import init_strategy_tables from strategy_trade_labels import ( - MONITOR_TYPE_ROLL, MONITOR_TYPE_TREND_PULLBACK, count_position_limit_active_monitors, ) @@ -47,16 +46,7 @@ class PositionLimitCountTests(unittest.TestCase): conn.commit() self.assertEqual(count_position_limit_active_monitors(conn), 0) - def test_roll_monitor_type_excluded(self): - conn = _mem_conn() - conn.execute( - "INSERT INTO order_monitors (symbol, status, monitor_type) VALUES ('ETH/USDT', 'active', ?)", - (MONITOR_TYPE_ROLL,), - ) - conn.commit() - self.assertEqual(count_position_limit_active_monitors(conn), 0) - - def test_active_roll_group_excludes_monitor(self): + def test_active_roll_group_still_counts_regular_monitor(self): conn = _mem_conn() conn.execute( "INSERT INTO order_monitors (id, symbol, status, monitor_type) VALUES (1, 'ETH/USDT', 'active', '下单监控')" @@ -67,7 +57,7 @@ class PositionLimitCountTests(unittest.TestCase): VALUES (1, 'ETH/USDT', 'long', 'active')""" ) conn.commit() - self.assertEqual(count_position_limit_active_monitors(conn), 0) + self.assertEqual(count_position_limit_active_monitors(conn), 1) def test_mixed_monitors(self): conn = _mem_conn()