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)