From f0a158686efd084d16a220ab66f29d9c1f43b465 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 18 Jun 2026 16:27:21 +0800 Subject: [PATCH] fix(risk): allow journal to reduce 4h cooloff to 1h without pending trade id Hub closes and late journal saves now shorten active manual cooloffs when exit trigger and note are filled in. Co-authored-by: Cursor --- account_risk_lib.py | 81 +++++++++++++++++++++++++++++++--- docs/account-risk-cooldown.md | 7 ++- tests/test_account_risk_lib.py | 49 ++++++++++++++++++++ 3 files changed, 130 insertions(+), 7 deletions(-) diff --git a/account_risk_lib.py b/account_risk_lib.py index 36a7ea0..77083de 100644 --- a/account_risk_lib.py +++ b/account_risk_lib.py @@ -174,6 +174,69 @@ def _set_cooloff( ) +def _set_cooloff_until( + conn, + *, + trading_day: str, + until_ms: int, + hours: float, + now: Optional[datetime] = None, +) -> None: + _sync_trading_day(conn, trading_day, now=now) + h = max(0.0, float(hours)) + conn.execute( + """UPDATE account_risk_state SET + cooloff_until_ms=?, + cooloff_hours=?, + updated_at=? + WHERE id=1""", + ( + int(until_ms), + int(h) if h == int(h) else int(round(h)), + (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + ), + ) + + +def _cooloff_until_ms(row) -> Optional[int]: + raw = _row_get(row, "cooloff_until_ms") + try: + return int(raw) if raw is not None else None + except (TypeError, ValueError): + return None + + +def _in_active_manual_cooloff(row, now_ms: int) -> bool: + if int(_row_get(row, "daily_frozen") or 0) == 1: + return False + if int(_row_get(row, "manual_close_count") or 0) < 1: + return False + until_ms = _cooloff_until_ms(row) + return until_ms is not None and until_ms > now_ms + + +def _journal_can_reduce_cooloff(row, pending, now_ms: int) -> bool: + if pending is not None: + try: + if int(pending) != 0: + return True + except (TypeError, ValueError): + return True + return _in_active_manual_cooloff(row, now_ms) + + +def _journal_cooloff_until_ms(row, now_ms: int, journal_hours: float) -> int: + journal_ms = int(max(0.0, float(journal_hours)) * 3600 * 1000) + last_close_ms = _row_get(row, "last_close_at_ms") + base_ms = int(last_close_ms) if last_close_ms else now_ms + until_from_close = base_ms + journal_ms + until_ms = until_from_close if until_from_close > now_ms else now_ms + journal_ms + current_until = _cooloff_until_ms(row) + if current_until is not None and current_until > now_ms and until_ms > current_until: + until_ms = current_until + return until_ms + + def _set_daily_frozen(conn, *, trading_day: str, now: Optional[datetime] = None) -> None: _sync_trading_day(conn, trading_day, now=now) conn.execute( @@ -299,14 +362,20 @@ def on_journal_saved( pending = _row_get(row, "pending_journal_trade_id") trigger = (early_exit_trigger or "").strip() note = (early_exit_note or "").strip() - if pending and trigger == "手动平仓" and note: - last_close_ms = _row_get(row, "last_close_at_ms") - base_ms = int(last_close_ms) if last_close_ms else _now_ms(now) - _set_cooloff( + now_ms = _now_ms(now) + if ( + trigger == "手动平仓" + and note + and int(_row_get(row, "daily_frozen") or 0) != 1 + and _journal_can_reduce_cooloff(row, pending, now_ms) + ): + journal_h = cooling_hours_manual_journal() + until_ms = _journal_cooloff_until_ms(row, now_ms, journal_h) + _set_cooloff_until( conn, trading_day=trading_day, - close_at_ms=base_ms, - hours=cooling_hours_manual_journal(), + until_ms=until_ms, + hours=journal_h, now=now, ) conn.execute( diff --git a/docs/account-risk-cooldown.md b/docs/account-risk-cooldown.md index 960beaa..fbceb3b 100644 --- a/docs/account-risk-cooldown.md +++ b/docs/account-risk-cooldown.md @@ -39,10 +39,15 @@ | 第 1 次用户主动平仓 | 默认 **4h** 冷静期 | | 第 2 次用户主动平仓(同一交易日) | **日冻结** | | 复盘勾选任意情绪标签 | **日冻结** | -| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须存在 pending 关联交易记录) | +| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须处于手动平仓冷静期中;有 pending 关联交易记录时同样生效) | 情绪标签:怕踏空、报复开仓、盈利飘了、拿不住单、扛单、重仓违规。 +**复盘缩短 1h 说明**: +- 复盘表单须选 **离场触发 = 手动平仓**,并在 **离场补充** 填写说明(不是下方「备注」栏)。 +- 中控全平/实例手动平仓后,只要账户仍在 4h 冷静期内,完成上述复盘即可降为 1h。 +- 若在平仓后超过 1h 才复盘,则从复盘保存时刻起再计 1h(不会延长原 4h 窗口)。 + ## 环境变量 ```env diff --git a/tests/test_account_risk_lib.py b/tests/test_account_risk_lib.py index c5d69f2..9832fdd 100644 --- a/tests/test_account_risk_lib.py +++ b/tests/test_account_risk_lib.py @@ -128,6 +128,55 @@ class AccountRiskLibTests(unittest.TestCase): st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) self.assertEqual(st["status"], STATUS_FREEZE_1H) + def test_journal_hub_close_without_pending_reduces_to_1h(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000) + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_HUB, + closed_at_ms=close_ms, + trading_day="2026-06-14", + now=now, + ) + on_journal_saved( + conn, + early_exit_trigger="手动平仓", + early_exit_note="中控全平后复盘说明", + mood_issues_raw="", + trading_day="2026-06-14", + now=now, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertEqual(st["status"], STATUS_FREEZE_1H) + + def test_journal_late_save_still_gets_1h_from_now(self): + conn = _mem_conn() + close_at = datetime(2026, 6, 14, 12, 0, 0) + close_ms = int(close_at.replace(tzinfo=timezone.utc).timestamp() * 1000) + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + closed_at_ms=close_ms, + trading_day="2026-06-14", + now=close_at, + ) + journal_at = datetime(2026, 6, 14, 14, 0, 0) + on_journal_saved( + conn, + early_exit_trigger="手动平仓", + early_exit_note="补写复盘说明", + mood_issues_raw="", + trading_day="2026-06-14", + now=journal_at, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=journal_at) + self.assertEqual(st["status"], STATUS_FREEZE_1H) + self.assertEqual( + st["cooloff_until_ms"], + int(journal_at.replace(tzinfo=timezone.utc).timestamp() * 1000) + 3600 * 1000, + ) + def test_journal_mood_issues_daily_freeze(self): conn = _mem_conn() now = datetime(2026, 6, 14, 12, 0, 0)