From 5ae2791dd3c16b399d299acfdb76df1379214aa1 Mon Sep 17 00:00:00 2001 From: leefer Date: Wed, 22 Jul 2026 23:55:33 +0800 Subject: [PATCH] feat: track screener candidates across five trading days --- api_access.py | 2 + database.py | 105 ++++++++++++++++++++++++++++++++ server.py | 54 +++++++++++++++- static/app.js | 76 +++++++++++++++++++++++ static/index.html | 15 +++++ static/styles.css | 49 +++++++++++++++ strategy_tracking.py | 104 +++++++++++++++++++++++++++++++ tests/test_api_access.py | 2 + tests/test_strategy_tracking.py | 104 +++++++++++++++++++++++++++++++ 9 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 strategy_tracking.py create mode 100644 tests/test_strategy_tracking.py diff --git a/api_access.py b/api_access.py index ad0c004..d53f373 100644 --- a/api_access.py +++ b/api_access.py @@ -9,6 +9,7 @@ AccessRole = Literal["authenticated", "member", "admin"] MEMBER_GET_PATHS = frozenset( { "/api/screener/setup", + "/api/screener/tracking", "/api/mentors/setup", "/api/mentors/messages", "/api/heaven/setup", @@ -21,6 +22,7 @@ MEMBER_POST_PATHS = frozenset( "/api/screener/compile", "/api/screener/strategies", "/api/screener/run", + "/api/screener/tracking/refresh", "/api/mentors/chat", "/api/heaven/hexagram", "/api/heaven/personal", diff --git a/database.py b/database.py index 73bd4cd..e84a2fa 100644 --- a/database.py +++ b/database.py @@ -256,6 +256,27 @@ class ReviewDatabase: CREATE INDEX IF NOT EXISTS idx_mentor_messages_conversation ON mentor_messages(user_id, mentor_id, trade_date, id DESC); + + CREATE TABLE IF NOT EXISTS strategy_tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + run_id INTEGER NOT NULL, + selection_date TEXT NOT NULL, + strategy_name TEXT NOT NULL, + ts_code TEXT NOT NULL, + code TEXT NOT NULL, + name TEXT NOT NULL, + sector TEXT NOT NULL DEFAULT '', + entry_price REAL NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, run_id, ts_code), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (run_id) REFERENCES screener_runs(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_strategy_tracks_user_run + ON strategy_tracks(user_id, run_id DESC, id); """ ) user_columns = { @@ -1321,6 +1342,90 @@ class ReviewDatabase: ) return int(cursor.rowcount) + def save_strategy_tracks( + self, + user_id: int, + run_id: int, + selection_date: str, + strategy_name: str, + candidates: list[dict[str, Any]], + ) -> int: + now = datetime.now().astimezone().isoformat(timespec="seconds") + values = [] + for item in candidates: + ts_code = str(item.get("ts_code") or "").strip() + code = str(item.get("code") or ts_code.split(".")[0]).strip() + entry_price = float(item.get("price") or 0) + if not ts_code or not code or entry_price <= 0: + continue + values.append( + ( + int(user_id), int(run_id), selection_date, strategy_name, ts_code, code, + str(item.get("name") or "--"), str(item.get("sector") or "其他"), + entry_price, now, now, + ) + ) + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO strategy_tracks + (user_id, run_id, selection_date, strategy_name, ts_code, code, + name, sector, entry_price, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, run_id, ts_code) DO UPDATE SET + name=excluded.name, sector=excluded.sector, + entry_price=excluded.entry_price, updated_at=excluded.updated_at + """, + values, + ) + return len(values) + + def list_strategy_tracks(self, user_id: int, limit_batches: int = 12) -> list[dict[str, Any]]: + limit_batches = max(1, min(50, int(limit_batches))) + with self.connect() as connection: + rows = connection.execute( + """ + SELECT * FROM strategy_tracks + WHERE user_id = ? AND run_id IN ( + SELECT run_id FROM strategy_tracks WHERE user_id = ? + GROUP BY run_id ORDER BY run_id DESC LIMIT ? + ) + ORDER BY run_id DESC, id + """, + (int(user_id), int(user_id), limit_batches), + ).fetchall() + return [dict(row) for row in rows] + + def load_tracking_bars( + self, targets: list[tuple[str, str]], limit: int = 5 + ) -> dict[tuple[str, str], list[dict[str, Any]]]: + unique_targets = set(targets) + if not unique_targets: + return {} + codes = sorted({ts_code for ts_code, _ in unique_targets}) + earliest_date = min(selection_date for _, selection_date in unique_targets) + placeholders = ",".join("?" for _ in codes) + with self.connect() as connection: + rows = connection.execute( + f""" + SELECT ts_code, trade_date, open, high, low, close FROM daily_bars + WHERE ts_code IN ({placeholders}) AND trade_date > ? + ORDER BY ts_code, trade_date + """, + [*codes, earliest_date], + ).fetchall() + by_code: dict[str, list[dict[str, Any]]] = {} + for row in rows: + item = dict(row) + by_code.setdefault(str(item["ts_code"]), []).append(item) + row_limit = max(1, min(20, int(limit))) + return { + (ts_code, selection_date): [ + row for row in by_code.get(ts_code, []) if row["trade_date"] > selection_date + ][:row_limit] + for ts_code, selection_date in unique_targets + } + def start_sync(self, trade_date: str, source: str) -> int: started_at = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: diff --git a/server.py b/server.py index a8fa75f..a2359e2 100644 --- a/server.py +++ b/server.py @@ -62,6 +62,7 @@ from sentiment_engine import ( build_sentiment_history, latest_contiguous_history, ) +from strategy_tracking import StrategyTrackingService from tushare_client import TushareClient, TushareError @@ -127,6 +128,7 @@ class DashboardService: self._request_context = threading.local() self._system_credentials = self._load_system_credentials(environment_credentials) self.screener = ScreenerEngine(self.database) + self.strategy_tracking = StrategyTrackingService(self.database) self.mentor_skills = MentorSkillRegistry(MENTOR_SKILLS_DIR) self.realtime_aggregator = WebRealtimeAggregator() self.screener.ensure_builtin_strategies() @@ -1082,6 +1084,26 @@ class DashboardService: "latest_result": self.database.latest_screener_run(self.current_user_id, normalized_date), } + def screener_tracking(self, limit: int = 12) -> dict[str, Any]: + return self.strategy_tracking.list_tracking(self.current_user_id, limit) + + def refresh_screener_tracking(self, trade_date: str) -> dict[str, Any]: + normalized_date = normalize_date(trade_date) + notice = "" + if self.configured: + try: + FactorDataService(self.database, TushareClient(self.token)).sync( + normalized_date, 15 + ) + except TushareError: + notice = "最新日线暂未补齐,已按现有数据更新跟踪。" + else: + notice = "公共行情尚未配置,已按现有数据更新跟踪。" + return { + "tracking": self.screener_tracking(), + "notice": notice, + } + def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]: if not self.configured: raise ValueError("请先配置 Tushare Token。") @@ -2570,11 +2592,19 @@ class DashboardService: realtime_snapshot = TushareClient(self.token).realtime_factor_snapshot(trade_date) except TushareError as exc: raise ValueError(f"实时选股行情不可用,已停止筛选:{exc}") from exc - return self.screener.screen( + result = self.screener.screen( self.current_user_id, trade_date, formula, regime, strategy_name, bool(payload.get("run_backtest", True)), realtime_snapshot, ) + self.strategy_tracking.record_run( + self.current_user_id, + int(result.get("meta", {}).get("run_id") or 0), + normalize_date(str(result.get("meta", {}).get("trade_date") or trade_date)), + strategy_name, + list(result.get("candidates") or []), + ) + return result def get_dragon_tiger(self, trade_date: str, force: bool = False) -> dict[str, Any]: normalized_date = normalize_date(trade_date) @@ -3465,6 +3495,15 @@ class RequestHandler(BaseHTTPRequestHandler): except ValueError as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) return + if parsed.path == "/api/screener/tracking": + query = parse_qs(parsed.query) + try: + self.send_json( + SERVICE.screener_tracking(int(query.get("limit", ["12"])[0])) + ) + except (TypeError, ValueError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + return if parsed.path == "/api/mentors/setup": query = parse_qs(parsed.query) trade_date = query.get("trade_date", [date.today().isoformat()])[0] @@ -3577,6 +3616,9 @@ class RequestHandler(BaseHTTPRequestHandler): if parsed.path == "/api/screener/run": self.run_screener() return + if parsed.path == "/api/screener/tracking/refresh": + self.refresh_screener_tracking() + return if parsed.path == "/api/mentors/chat": self.mentor_chat() return @@ -4010,6 +4052,16 @@ class RequestHandler(BaseHTTPRequestHandler): except Exception as exc: self.send_json({"error": f"选股执行失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR) + def refresh_screener_tracking(self) -> None: + try: + body = self.read_json_body(True) + trade_date = str(body.get("trade_date") or date.today().isoformat()) + self.send_json({"ok": True, **SERVICE.refresh_screener_tracking(trade_date)}) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + except Exception as exc: + self.send_json({"error": f"跟踪刷新失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR) + def mentor_chat(self) -> None: try: body = self.read_json_body() diff --git a/static/app.js b/static/app.js index 70c511b..8af24a2 100644 --- a/static/app.js +++ b/static/app.js @@ -43,6 +43,7 @@ const state = { selectedRegime: "", selectedStrategy: null, screenerResult: null, + screenerTracking: null, screenerMobileView: "strategy", sentimentHistory: null, sentimentRange: 20, @@ -485,6 +486,7 @@ function bindEvents() { document.querySelector("#backfillButton").addEventListener("click", backfillData); document.querySelector("#factorSyncButton").addEventListener("click", syncFactorData); document.querySelector("#screenerRunButton").addEventListener("click", runScreener); + document.querySelector("#refreshTrackingButton").addEventListener("click", refreshScreenerTracking); document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => { button.addEventListener("click", () => selectScreenerMobileView(button.dataset.screenerMobileView)); }); @@ -1590,6 +1592,7 @@ async function loadScreenerSetup() { || null; renderScreenerSetup(); if (state.screenerResult) renderScreenerResult(); + await loadScreenerTracking(); } catch (error) { showToast(error.message || "选股配置加载失败"); } @@ -1805,6 +1808,7 @@ async function runScreener() { }); state.screenerResult = payload.result; renderScreenerResult(); + await loadScreenerTracking(true); setText("screenerRunStatus", `完成 · ${payload.result.candidates.length} 只`); updateBacktestTaskStatus(); if (window.innerWidth <= 720) selectScreenerMobileView("results"); @@ -3557,6 +3561,78 @@ function renderScreenerResult() { updateBacktestTaskStatus(); } +async function loadScreenerTracking(force = false) { + if (state.screenerTracking && !force) { + renderScreenerTracking(); + return; + } + try { + state.screenerTracking = await apiRequest("/api/screener/tracking?limit=12"); + renderScreenerTracking(); + } catch (error) { + showToast(error.message || "策略跟踪加载失败"); + } +} + +async function refreshScreenerTracking() { + const button = document.querySelector("#refreshTrackingButton"); + button.disabled = true; + setStatus("正在更新策略跟踪"); + try { + const payload = await apiRequest("/api/screener/tracking/refresh", "POST", { + trade_date: elements.tradeDate.value, + }); + state.screenerTracking = payload.tracking; + renderScreenerTracking(); + if (payload.notice) showToast(payload.notice); + setStatus("策略跟踪已更新"); + } catch (error) { + showToast(error.message || "策略跟踪刷新失败"); + setStatus("策略跟踪刷新失败"); + } finally { + button.disabled = false; + } +} + +function renderScreenerTracking() { + const payload = state.screenerTracking || { batches: [], summary: {} }; + const batches = payload.batches || []; + const rows = batches.flatMap((batch) => (batch.items || []).map((item) => ({ + ...item, + selection_date: batch.selection_date, + strategy_name: batch.strategy_name, + }))); + setText("trackingBatchCount", `${batches.length} 批`); + const summary = payload.summary || {}; + document.querySelector("#trackingSummary").innerHTML = [ + ["跟踪标的", `${number(summary.total)} 只`], + ["已有 T+1", `${number(summary.observed)} 只`], + ["T+1 胜率", trackingPercent(summary.t1_win_rate)], + ["T+5 胜率", trackingPercent(summary.t5_win_rate)], + ["T+5 平均", trackingReturn(summary.average_t5)], + ].map(([label, value]) => `
${label}${value}
`).join(""); + document.querySelector("#trackingEmpty").hidden = rows.length > 0; + document.querySelector("#trackingTableBody").innerHTML = rows.map((row) => ` + + ${displayCompactDate(row.selection_date)} + ${escapeHtml(row.strategy_name)} + ${escapeHtml(row.name)}${escapeHtml(row.code)} + ${formatNumber(row.entry_price, 2)} + ${["t1_open", "t1_close", "t3_close", "t5_close", "max_gain", "max_drawdown"].map((key) => `${trackingReturn(row[key])}`).join("")} + ${escapeHtml(row.status)} + + `).join(""); + bindStockRows(document.querySelector("#trackingTableBody")); +} + +function trackingReturn(value) { + return value == null ? "--" : `${signed(value)}%`; +} + +function trackingPercent(value) { + return value == null ? "--" : `${formatNumber(value, 1)}%`; +} + function renderBacktest(backtest) { const panel = document.querySelector("#backtestPanel"); panel.hidden = !backtest; diff --git a/static/index.html b/static/index.html index 67a6e3e..5b7fced 100644 --- a/static/index.html +++ b/static/index.html @@ -681,6 +681,7 @@
+
@@ -768,6 +769,20 @@
尚未执行选股
+
+
+

策略持续跟踪

0 批
+ 以入选价为基准,跟踪后续五个交易日 +
+
+
+ + + +
入选日策略股票入选价T+1 开T+1 收T+3T+5最大涨幅最大回撤状态
+
执行选股后自动开始跟踪
+
+
diff --git a/static/styles.css b/static/styles.css index b64ccfb..3510778 100644 --- a/static/styles.css +++ b/static/styles.css @@ -11358,3 +11358,52 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin transition: none; } } + +.strategy-tracking-panel { + margin-top: 18px; + border-top: 1px solid var(--border); + background: var(--surface); +} + +.tracking-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: var(--surface-muted); +} + +.tracking-summary > div { + min-width: 0; + padding: 11px 16px; + border-right: 1px solid var(--border); +} + +.tracking-summary > div:last-child { border-right: 0; } +.tracking-summary span { display: block; color: var(--text-secondary); font-size: 11px; } +.tracking-summary strong { display: block; margin-top: 4px; font-size: 15px; font-variant-numeric: tabular-nums; } +.tracking-table-frame { border: 0; border-radius: 0; } +.tracking-table { min-width: 1080px; } +.tracking-strategy { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.tracking-status { + display: inline-flex; + min-height: 24px; + align-items: center; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-secondary); + font-size: 11px; + white-space: nowrap; +} + +.tracking-status.active { border-color: #c5d7ed; background: var(--action-soft); color: var(--action); } +.tracking-status.complete { border-color: #b9dfcf; background: var(--market-down-soft); color: var(--market-down); } + +@media (max-width: 720px) { + .tracking-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .tracking-summary > div { border-bottom: 1px solid var(--border); } + .tracking-summary > div:nth-child(2n) { border-right: 0; } + .strategy-tracking-panel .section-toolbar { align-items: flex-start; } +} diff --git a/strategy_tracking.py b/strategy_tracking.py new file mode 100644 index 0000000..7c79109 --- /dev/null +++ b/strategy_tracking.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import Any + +from database import ReviewDatabase + + +class StrategyTrackingService: + def __init__(self, database: ReviewDatabase) -> None: + self.database = database + + def record_run( + self, + user_id: int, + run_id: int, + selection_date: str, + strategy_name: str, + candidates: list[dict[str, Any]], + ) -> int: + return self.database.save_strategy_tracks( + user_id, run_id, selection_date, strategy_name, candidates + ) + + def list_tracking(self, user_id: int, limit_batches: int = 12) -> dict[str, Any]: + tracks = self.database.list_strategy_tracks(user_id, limit_batches) + if not tracks: + return {"batches": [], "summary": self._summary([])} + + bars = self.database.load_tracking_bars( + [(item["ts_code"], item["selection_date"]) for item in tracks], 5 + ) + batches: dict[int, dict[str, Any]] = {} + all_items: list[dict[str, Any]] = [] + for track in tracks: + key = (track["ts_code"], track["selection_date"]) + metrics = self.calculate_metrics(float(track["entry_price"]), bars.get(key, [])) + item = { + "id": track["id"], + "code": track["code"], + "name": track["name"], + "sector": track["sector"], + "entry_price": round(float(track["entry_price"]), 2), + **metrics, + } + all_items.append(item) + batch = batches.setdefault( + int(track["run_id"]), + { + "run_id": int(track["run_id"]), + "selection_date": track["selection_date"], + "strategy_name": track["strategy_name"], + "items": [], + }, + ) + batch["items"].append(item) + + ordered = list(batches.values()) + for batch in ordered: + batch["summary"] = self._summary(batch["items"]) + return {"batches": ordered, "summary": self._summary(all_items)} + + @staticmethod + def calculate_metrics(entry_price: float, bars: list[dict[str, Any]]) -> dict[str, Any]: + valid = [row for row in bars[:5] if float(row.get("close") or 0) > 0] + if entry_price <= 0 or not valid: + return { + "observed_days": 0, + "status": "等待 T+1", + "t1_open": None, + "t1_close": None, + "t3_close": None, + "t5_close": None, + "max_gain": None, + "max_drawdown": None, + } + + def change(price: Any) -> float: + return round((float(price or 0) / entry_price - 1) * 100, 2) + + observed = len(valid) + return { + "observed_days": observed, + "status": "已完成" if observed >= 5 else f"跟踪中 {observed}/5", + "t1_open": change(valid[0]["open"]), + "t1_close": change(valid[0]["close"]), + "t3_close": change(valid[2]["close"]) if observed >= 3 else None, + "t5_close": change(valid[4]["close"]) if observed >= 5 else None, + "max_gain": max(change(row["high"]) for row in valid), + "max_drawdown": min(change(row["low"]) for row in valid), + } + + @staticmethod + def _summary(items: list[dict[str, Any]]) -> dict[str, Any]: + completed = [item for item in items if item.get("t5_close") is not None] + t1 = [float(item["t1_close"]) for item in items if item.get("t1_close") is not None] + t5 = [float(item["t5_close"]) for item in completed] + return { + "total": len(items), + "observed": len(t1), + "completed": len(completed), + "t1_win_rate": round(sum(value > 0 for value in t1) / len(t1) * 100, 1) if t1 else None, + "t5_win_rate": round(sum(value > 0 for value in t5) / len(t5) * 100, 1) if t5 else None, + "average_t5": round(sum(t5) / len(t5), 2) if t5 else None, + } diff --git a/tests/test_api_access.py b/tests/test_api_access.py index 0508633..a577eaf 100644 --- a/tests/test_api_access.py +++ b/tests/test_api_access.py @@ -9,9 +9,11 @@ class ApiAccessPolicyTests(unittest.TestCase): def test_member_workspaces_are_consistently_protected(self): cases = { ("GET", "/api/screener/setup"): "member", + ("GET", "/api/screener/tracking"): "member", ("GET", "/api/mentors/messages"): "member", ("GET", "/api/heaven/setup"): "member", ("POST", "/api/screener/run"): "member", + ("POST", "/api/screener/tracking/refresh"): "member", ("POST", "/api/mentors/chat"): "member", ("POST", "/api/heaven/interpret"): "member", ("DELETE", "/api/screener/strategies/42"): "member", diff --git a/tests/test_strategy_tracking.py b/tests/test_strategy_tracking.py new file mode 100644 index 0000000..d151691 --- /dev/null +++ b/tests/test_strategy_tracking.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from database import ReviewDatabase +from strategy_tracking import StrategyTrackingService + + +class StrategyTrackingTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.database = ReviewDatabase(Path(self.temp.name) / "review.db") + self.owner = self.database.create_user("track_owner", "salt", "hash") + self.other = self.database.create_user("track_other", "salt", "hash") + self.service = StrategyTrackingService(self.database) + self.run_id = self.database.save_screener_run( + self.owner["id"], "20260710", "repair", "修复策略", {}, {"meta": {}} + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_run_is_recorded_once_and_private_to_owner(self): + candidates = [ + { + "ts_code": "002141.SZ", + "code": "002141", + "name": "贤丰控股", + "sector": "油气开采", + "price": 10, + } + ] + self.assertEqual( + self.service.record_run( + self.owner["id"], self.run_id, "20260710", "修复策略", candidates + ), + 1, + ) + self.service.record_run( + self.owner["id"], self.run_id, "20260710", "修复策略", candidates + ) + self.assertEqual(len(self.database.list_strategy_tracks(self.owner["id"])), 1) + self.assertEqual(self.database.list_strategy_tracks(self.other["id"]), []) + + def test_tracking_uses_next_five_trading_bars(self): + self.service.record_run( + self.owner["id"], + self.run_id, + "20260710", + "修复策略", + [{"ts_code": "002141.SZ", "code": "002141", "name": "贤丰控股", "sector": "油气开采", "price": 10}], + ) + rows = [] + values = ( + ("20260713", 10.2, 10.8, 9.8, 10.5), + ("20260714", 10.5, 11.0, 10.1, 10.8), + ("20260715", 10.8, 11.5, 10.4, 11.2), + ("20260716", 11.2, 11.4, 9.5, 9.8), + ("20260717", 9.8, 10.3, 9.0, 10.0), + ("20260720", 10.0, 20.0, 1.0, 19.0), + ) + for trade_date, open_price, high, low, close in values: + rows.append( + { + "trade_date": trade_date, + "ts_code": "002141.SZ", + "open": open_price, + "high": high, + "low": low, + "close": close, + } + ) + self.database.upsert_daily_bars(rows) + + payload = self.service.list_tracking(self.owner["id"]) + item = payload["batches"][0]["items"][0] + self.assertEqual(item["status"], "已完成") + self.assertEqual(item["t1_open"], 2.0) + self.assertEqual(item["t1_close"], 5.0) + self.assertEqual(item["t3_close"], 12.0) + self.assertEqual(item["t5_close"], 0.0) + self.assertEqual(item["max_gain"], 15.0) + self.assertEqual(item["max_drawdown"], -10.0) + self.assertEqual(payload["summary"]["t1_win_rate"], 100.0) + self.assertEqual(payload["summary"]["t5_win_rate"], 0.0) + + def test_partial_tracking_reports_available_days(self): + metrics = self.service.calculate_metrics( + 20, + [ + {"open": 20, "high": 21, "low": 19, "close": 20.5}, + {"open": 20.5, "high": 22, "low": 20, "close": 21}, + ], + ) + self.assertEqual(metrics["status"], "跟踪中 2/5") + self.assertIsNone(metrics["t3_close"]) + self.assertEqual(metrics["max_gain"], 10.0) + self.assertEqual(metrics["max_drawdown"], -5.0) + + +if __name__ == "__main__": + unittest.main()