From 7d25be41c9db31d23c302e617c0706a2b21cf966 Mon Sep 17 00:00:00 2001 From: zlt Date: Tue, 25 Aug 2026 11:09:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=89=8D=E7=AB=AF=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E9=83=A8=E5=88=86=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repo/pms_repo.py | 87 +++++++++++++++++-- app/services/judge.py | 39 ++++++--- app/web/main.py | 69 +++++++++++++-- app/web/static/index.html | 101 ++++++++++++++++------ ddl_pms_v1.sql | 4 + scripts/migrate_archived_at.py | 106 +++++++++++++++++++++++ scripts/run_tests.py | 7 +- scripts/test_batch12_units.py | 34 ++++++++ scripts/test_batch16_units.py | 150 +++++++++++++++++++++++++++++++++ 9 files changed, 541 insertions(+), 56 deletions(-) create mode 100644 scripts/migrate_archived_at.py create mode 100644 scripts/test_batch16_units.py diff --git a/app/repo/pms_repo.py b/app/repo/pms_repo.py index 4e1282d..b54bbea 100644 --- a/app/repo/pms_repo.py +++ b/app/repo/pms_repo.py @@ -91,7 +91,8 @@ def get_command(command_id: str): return _cmd_row(r) if r else None -def list_commands(*, statuses=None, cmd_class=None, limit: int = 200) -> list: +def list_commands(*, statuses=None, cmd_class=None, limit: int = 200, + include_archived: bool = False) -> list: where, p = [], {"n": int(limit)} if statuses: keys = [] @@ -102,6 +103,8 @@ def list_commands(*, statuses=None, cmd_class=None, limit: int = 200) -> list: if cmd_class: where.append("cmd_class = :cls") p["cls"] = cmd_class + if not include_archived: + where.append("archived_at IS NULL") sql = "SELECT * FROM pms_command" if where: sql += " WHERE " + " AND ".join(where) @@ -351,7 +354,8 @@ def insert_instruction(*, instruction_id, origin_type, origin_id, ts_code, actio "w": int(window_tdays), "st": status, "pg": _dumps(progress), "ts": now}) -def list_instructions(*, statuses=None, side=None, ts_code=None, limit: int = 300) -> list: +def list_instructions(*, statuses=None, side=None, ts_code=None, limit: int = 300, + include_archived: bool = False) -> list: where, p = [], {"n": int(limit)} if statuses: keys = [] @@ -365,6 +369,8 @@ def list_instructions(*, statuses=None, side=None, ts_code=None, limit: int = 30 if ts_code: where.append("ts_code = :code") p["code"] = ts_code + if not include_archived: + where.append("archived_at IS NULL") sql = "SELECT * FROM pms_instruction" if where: sql += " WHERE " + " AND ".join(where) @@ -422,14 +428,19 @@ def insert_proposal(*, proposal_id, ts_code, action, qty, hard_numbers, expire_a "st": status, "exp": expire_at, "ts": _NOW()}) -def list_proposals(*, statuses=("WAIT_USER",), limit: int = 200) -> list: - p, keys = {"n": int(limit)}, [] +def list_proposals(*, statuses=("WAIT_USER",), limit: int = 200, + include_archived: bool = False) -> list: + where, p, keys = [], {"n": int(limit)}, [] for i, s in enumerate(statuses or ()): keys.append(f":s{i}") p[f"s{i}"] = s - sql = "SELECT * FROM pms_proposal" if keys: - sql += f" WHERE status IN ({', '.join(keys)})" + where.append(f"status IN ({', '.join(keys)})") + if not include_archived: + where.append("archived_at IS NULL") + sql = "SELECT * FROM pms_proposal" + if where: + sql += " WHERE " + " AND ".join(where) sql += " ORDER BY id DESC LIMIT :n" rows = fetch_all(sql, p) for r in rows: @@ -737,7 +748,8 @@ def _snapshot_row(r: dict) -> dict: STRATEGY_COLS = {"status", "autonomy", "params_json", "state_json", "note"} -def list_strategies(*, ts_code=None, statuses=None, limit: int = 500) -> list: +def list_strategies(*, ts_code=None, statuses=None, limit: int = 500, + include_archived: bool = False) -> list: where, p = [], {"n": int(limit)} if ts_code: where.append("ts_code = :code") @@ -748,6 +760,8 @@ def list_strategies(*, ts_code=None, statuses=None, limit: int = 500) -> list: keys.append(f":st{i}") p[f"st{i}"] = s where.append(f"status IN ({', '.join(keys)})") + if not include_archived: + where.append("archived_at IS NULL") sql = "SELECT * FROM pms_strategy" if where: sql += " WHERE " + " AND ".join(where) @@ -805,6 +819,65 @@ def update_strategy(strategy_id: str, **fields) -> int: f"UPDATE pms_strategy SET {clause}, updated_at = :ts WHERE strategy_id = :sid", p) +# ================================================================ 软归档 (从在办/在途视图移除) +# 只标记 archived_at, **绝不删行** —— 评审账本、批次、成交链、审计都还在库里。 +# archive_* 只对**终态**记录生效 (status 在终态集内 且 archived_at 仍为空), 返回影响行数 +# (0 = 该记录不是终态、不存在、或早已归档, 属正常, 调用方据此提示); unarchive_* 清掉标记、 +# 恢复到默认视图, 不限状态。各表的 list_* 默认 include_archived=False, 已归档的自动不出现。 +_ARCHIVE_TERMINAL = { + "pms_command": ("DONE", "CANCELLED", "SUPERSEDED"), + "pms_strategy": ("CANCELLED", "DONE"), + "pms_instruction": ("CONFIRMED", "EXPIRED", "CANCELLED", "REJECTED"), + "pms_proposal": ("ACCEPTED", "DECLINED", "EXPIRED"), +} + + +def _archive(table: str, id_col: str, rid: str) -> int: + vals = _ARCHIVE_TERMINAL[table] # table 是本模块写死的常量, 非外部输入 + keys = ", ".join(f":st{i}" for i in range(len(vals))) + p = {"rid": rid, "ts": _NOW()} + p.update({f"st{i}": v for i, v in enumerate(vals)}) + return execute( + f"UPDATE {table} SET archived_at = :ts WHERE {id_col} = :rid " + f"AND archived_at IS NULL AND status IN ({keys})", p) + + +def _unarchive(table: str, id_col: str, rid: str) -> int: + return execute(f"UPDATE {table} SET archived_at = NULL WHERE {id_col} = :rid", {"rid": rid}) + + +def archive_command(command_id: str) -> int: + return _archive("pms_command", "command_id", command_id) + + +def unarchive_command(command_id: str) -> int: + return _unarchive("pms_command", "command_id", command_id) + + +def archive_instruction(instruction_id: str) -> int: + return _archive("pms_instruction", "instruction_id", instruction_id) + + +def unarchive_instruction(instruction_id: str) -> int: + return _unarchive("pms_instruction", "instruction_id", instruction_id) + + +def archive_proposal(proposal_id: str) -> int: + return _archive("pms_proposal", "proposal_id", proposal_id) + + +def unarchive_proposal(proposal_id: str) -> int: + return _unarchive("pms_proposal", "proposal_id", proposal_id) + + +def archive_strategy(strategy_id: str) -> int: + return _archive("pms_strategy", "strategy_id", strategy_id) + + +def unarchive_strategy(strategy_id: str) -> int: + return _unarchive("pms_strategy", "strategy_id", strategy_id) + + # ================================================================ pms_op_log (交易员操作日志) def insert_op_log(*, op, result, by="user", ts_code=None, reason=None, params=None, ref=None) -> int: return execute( diff --git a/app/services/judge.py b/app/services/judge.py index 70a431a..5db0c16 100644 --- a/app/services/judge.py +++ b/app/services/judge.py @@ -112,6 +112,33 @@ def status() -> dict: return {"available": True, "base": base_url(), "actions": sorted(judged_actions())} +def _map_verdict(data: dict) -> dict: + """把决策系统应答的 verdict 归一到 PASS / REJECT / UNAVAILABLE, 并带出原因。 + + 契约见 BIONIC_PMS_INTERFACE §2.3: verdict 合法取值是 PASS / REJECT / UNAVAILABLE, + UNAVAILABLE 还会带一句原因。UNAVAILABLE 是设计内的正常降级 (该股无昨夜结论、裁决 + 越界或解析失败、队列超时等), **必须把决策系统给的真实原因带出来**, 而不是把它当成 + 协议出错、拼一句「研判答复无法识别」盖掉真原因。早先没有这个分支时, 一条正常的 + UNAVAILABLE 会落进最后那道兜底, 页面显示成「研判答复无法识别: UNAVAILABLE」—— + 读起来像协议 bug, 其实只是决策系统说「这只票我没有昨夜结论」。 + 只有既不是三种合法值、又解析不出的乱码, 才真算「无法识别」。 + """ + data = data or {} + verdict = str(data.get("verdict") or data.get("decision") or "").upper() + reason = data.get("reason") or data.get("rationale") or "" + if verdict in ("PASS", "APPROVE", "APPROVED", "ALLOW", "通过"): + return {"verdict": PASS, "reason": reason, "degraded": False, "raw": data} + if verdict in ("REJECT", "DENY", "DENIED", "BLOCK", "驳回"): + return {"verdict": REJECT, "reason": reason, "degraded": False, "raw": data} + if verdict in ("UNAVAILABLE", "PMS_UNAVAILABLE", "NA", "N/A", "不可用"): + logger.warning("[研判闸] 决策系统回不可用, 降级人工确认: %s", reason or "(无原因)") + return {"verdict": UNAVAILABLE, "reason": reason or "决策系统研判不可用", + "degraded": True, "raw": data} + logger.error("[研判闸] 答复无法识别 (%s), 按不可用降级", verdict or data) + return {"verdict": UNAVAILABLE, "reason": f"研判答复无法识别: {verdict or data}", + "degraded": True, "raw": data} + + def request(candidate: dict, context: dict = None, *, timeout: int = None) -> dict: """请求一次研判。任何异常/超时/未接通都返回 UNAVAILABLE (绝不把提议当成通过)。""" action = str(candidate.get("action") or "").upper() @@ -147,14 +174,4 @@ def request(candidate: dict, context: dict = None, *, timeout: int = None) -> di return {"verdict": UNAVAILABLE, "reason": f"研判请求失败: {type(e).__name__}: {e}", "degraded": True, "raw": None} - verdict = str(data.get("verdict") or data.get("decision") or "").upper() - if verdict in ("PASS", "APPROVE", "APPROVED", "ALLOW", "通过"): - v = PASS - elif verdict in ("REJECT", "DENY", "DENIED", "BLOCK", "驳回"): - v = REJECT - else: - logger.error("[研判闸] 答复无法识别 (%s), 按不可用降级", verdict or data) - return {"verdict": UNAVAILABLE, "reason": f"研判答复无法识别: {verdict or data}", - "degraded": True, "raw": data} - return {"verdict": v, "reason": data.get("reason") or data.get("rationale") or "", - "degraded": False, "raw": data} + return _map_verdict(data) diff --git a/app/web/main.py b/app/web/main.py index 7aa3093..ece54f7 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -181,9 +181,11 @@ def api_commands_active(): @app.get("/api/commands") -def api_commands(status: str = Query(None), limit: int = Query(100)): +def api_commands(status: str = Query(None), limit: int = Query(100), + include_archived: bool = Query(False)): statuses = [s for s in (status or "").split(",") if s] or None - return ok(pms_repo.list_commands, statuses=statuses, limit=limit) + return ok(pms_repo.list_commands, statuses=statuses, limit=limit, + include_archived=include_archived) @app.get("/api/commands/{command_id}") @@ -246,9 +248,11 @@ def api_lots(ts_code: str, status: str = Query("OPEN")): @app.get("/api/instructions") -def api_instructions(status: str = Query(None), limit: int = 200): +def api_instructions(status: str = Query(None), limit: int = 200, + include_archived: bool = Query(False)): statuses = [s for s in (status or "").split(",") if s] or None - return ok(pms_repo.list_instructions, statuses=statuses, limit=limit) + return ok(pms_repo.list_instructions, statuses=statuses, limit=limit, + include_archived=include_archived) @app.get("/api/ledger") @@ -264,9 +268,11 @@ def api_report(ymd: int = Query(None)): # ================================================================ ④ 提议确认 @app.get("/api/proposals") -def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100): +def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100, + include_archived: bool = Query(False)): statuses = tuple(s for s in (status or "").split(",") if s) or ("WAIT_USER",) - return ok(pms_repo.list_proposals, statuses=statuses, limit=limit) + return ok(pms_repo.list_proposals, statuses=statuses, limit=limit, + include_archived=include_archived) @app.post("/api/proposals/{proposal_id}/decide") @@ -636,12 +642,13 @@ def api_industry_import(payload: dict = Body(...)): # ================================================================ 个股交易方案 (策略) + 操作日志 @app.get("/api/strategies") -def api_strategies(status: str = Query(None)): +def api_strategies(status: str = Query(None), include_archived: bool = Query(False)): statuses = [s for s in (status or "").split(",") if s] or None from app.services import strategy_service def _load(): - rows = pms_repo.list_strategies(statuses=statuses, limit=300) + rows = pms_repo.list_strategies(statuses=statuses, limit=300, + include_archived=include_archived) try: bp = strategy_service.buypause_map() except Exception: @@ -681,6 +688,52 @@ def api_strategy_status(strategy_id: str, payload: dict = Body(...)): by=payload.get("by") or "user") +# ================================ 软归档: 把终态记录从在办/在途/待确认/策略列表移除 (不删行, 可恢复) +def _arch_result(n: int, on: bool) -> dict: + if on: + note = "已移除, 从视图隐藏 (行仍在库, 对账与审计不受影响)" if n > 0 \ + else "未改动: 该记录不是终态、不存在、或早已移除" + else: + note = "已恢复显示" if n > 0 else "未改动: 不存在或本就未移除" + return {"ok": n > 0, "affected": n, "archived": on, "note": note} + + +@app.post("/api/commands/{command_id}/archive") +def api_archive_command(command_id: str, payload: dict = Body(default={})): + on = bool(payload.get("archived", True)) + return ok_logged("archive_command" if on else "unarchive_command", + lambda: _arch_result( + (pms_repo.archive_command if on else pms_repo.unarchive_command)(command_id), on), + params={"command_id": command_id, "archived": on}) + + +@app.post("/api/instructions/{instruction_id}/archive") +def api_archive_instruction(instruction_id: str, payload: dict = Body(default={})): + on = bool(payload.get("archived", True)) + return ok_logged("archive_instruction" if on else "unarchive_instruction", + lambda: _arch_result( + (pms_repo.archive_instruction if on else pms_repo.unarchive_instruction)(instruction_id), on), + params={"instruction_id": instruction_id, "archived": on}) + + +@app.post("/api/proposals/{proposal_id}/archive") +def api_archive_proposal(proposal_id: str, payload: dict = Body(default={})): + on = bool(payload.get("archived", True)) + return ok_logged("archive_proposal" if on else "unarchive_proposal", + lambda: _arch_result( + (pms_repo.archive_proposal if on else pms_repo.unarchive_proposal)(proposal_id), on), + params={"proposal_id": proposal_id, "archived": on}) + + +@app.post("/api/strategies/{strategy_id}/archive") +def api_archive_strategy(strategy_id: str, payload: dict = Body(default={})): + on = bool(payload.get("archived", True)) + return ok_logged("archive_strategy" if on else "unarchive_strategy", + lambda: _arch_result( + (pms_repo.archive_strategy if on else pms_repo.unarchive_strategy)(strategy_id), on), + params={"strategy_id": strategy_id, "archived": on}) + + @app.get("/api/op-log") def api_op_log(limit: int = Query(200)): """交易员操作日志 (每个页面写操作一行, 含 OK/BLOCKED 与原因)。""" diff --git a/app/web/static/index.html b/app/web/static/index.html index 19d3b20..c7e6cf4 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -78,6 +78,7 @@ body{margin:0;background:var(--plane);color:var(--ink); .disp.deny{background:var(--stop-weak);color:var(--stop);} .disp.would{background:var(--surface);color:var(--accent);border:1px solid var(--accent);} .disp.wait{background:rgba(138,136,130,.14);color:var(--ink-2);} +.disp.strong{background:var(--accent-weak);color:var(--accent);} .why{color:var(--ink-2);font-size:12.5px;margin-top:3px;line-height:1.45;} .pool-note{margin:0 0 12px;padding:9px 12px;background:var(--surface-2);border:1px solid var(--hair);border-radius:9px;color:var(--ink-2);font-size:12.5px;} .tabn{color:var(--muted);font-weight:600;font-size:12px;margin-left:3px;} @@ -158,7 +159,7 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius: .alert-bar .ab-toggle{flex:none;color:var(--accent);font-size:12.5px;font-weight:560;cursor:pointer;background:none;border:0;padding:4px 4px;} .alert-bar .ab-empty{flex:1;color:var(--muted);font-size:12.5px;} .alert-dot{width:7px;height:7px;border-radius:50%;flex:none;background:var(--muted);} -.alert-dot.lv-danger{background:var(--stop);} .alert-dot.lv-warn{background:var(--warn);} +.alert-dot.lv-danger{background:var(--stop);} .alert-dot.lv-warn{background:var(--warn);} .alert-dot.lv-strong{background:var(--accent);} .alert-panel{margin-top:0;} .alert-wins{display:grid;grid-template-columns:repeat(auto-fit,minmax(430px,1fr));gap:14px;margin:2px 0 16px;} .alert-win{border:1px solid var(--hair);border-radius:12px;padding:10px 14px 12px;background:var(--surface);box-shadow:var(--shadow);} @@ -173,7 +174,7 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius: .alert-age{color:var(--muted);font-size:10.5px;margin-left:5px;} /* 上游信号中心: 最新告警细提示 + 级别筛选 chip + 分节标题 */ .sig-ticker{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--surface);border:1px solid var(--hair);border-left:3px solid var(--hair);border-radius:12px;padding:8px 14px;margin:16px 0 8px;box-shadow:var(--shadow);font-size:13px;} -.sig-ticker.lv-danger{border-left-color:var(--stop);} .sig-ticker.lv-warn{border-left-color:var(--warn);} +.sig-ticker.lv-danger{border-left-color:var(--stop);} .sig-ticker.lv-warn{border-left-color:var(--warn);} .sig-ticker.lv-strong{border-left-color:var(--accent);} .sig-ticker .sig-tk-label{font-weight:640;color:var(--ink-2);white-space:nowrap;} .sig-filter{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin:2px 0 12px;} .lv-chip{font-size:12px;font-weight:560;padding:2px 11px;border-radius:999px;cursor:pointer;border:1px solid var(--hair);user-select:none;color:var(--ink-2);background:var(--surface-2);} @@ -181,6 +182,7 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius: .lv-chip.on.lv-danger{color:var(--stop);background:var(--stop-weak);border-color:var(--stop-weak);} .lv-chip.on.lv-warn{color:var(--warn);background:var(--warn-weak);border-color:var(--warn-weak);} .lv-chip.on.lv-info{color:var(--ink-2);background:rgba(138,136,130,.14);} +.lv-chip.on.lv-strong{color:var(--accent);background:var(--accent-weak);border-color:var(--accent-weak);} .sig-sep{font-size:12px;color:var(--muted);font-weight:600;margin:16px 0 8px;padding-top:9px;border-top:1px dashed var(--hair);} /* 宏观择时面板 (MACRO_TIMING_PLAN.md) */ .mz-bars{display:flex;align-items:flex-end;gap:2px;height:34px;margin:7px 0 3px;} @@ -646,11 +648,12 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:

我的策略 · {{ strategies.length }} 个 {{ stratEnabled ? '策略层已启用' : '策略层总开关未开(PMS_STRATEGY_ENABLED)' }} + 显示已撤下/已完成

还没有挂任何交易方案。到「我的持仓」某行「更多 ▾ → 挂交易方案」给它挂一个。
- + @@ -659,11 +662,13 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
决策系统风控:{{ s.row.buy_paused.reason || '资金异动' }}(卖出/平回不受影响;确认无碍点右侧「恢复买入」)
-