From e03e41a32b01b38f484c2ba140ac8405f9851877 Mon Sep 17 00:00:00 2001 From: zlt Date: Wed, 26 Aug 2026 10:01:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=A4=E6=98=93=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVLOG.md | 3 +++ app/services/strategy_advisor.py | 40 ++++++++++++++++++++++++++++- app/web/static/index.html | 18 ++++++++++++- scripts/run_tests.py | 8 +++--- scripts/test_batch17_units.py | 43 ++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 6 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 32e3b89..e64db81 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -50,6 +50,9 @@ app/services/strategy_advisor.py(新增);app/services/strategy_service.py **真机判收** 部分判收(2026-08-25 盘中)。桥机 make deploy + make test 见 ALL SUITES PASS;dry-run 试算真机四票读数全部合理:688802.SH 想挂网格但「按投入比例折出 47,250 元买不起一手」被挡(科创板高价票,判定正确);002179.SZ 有在途指令主动让路;其余两票无信号静默跳过;unknown_states 为空(子串归类修对了)。**尚未判收的**:真挂一条(等在途清了重扫或次日 09:40)、边三/边四真机走一遍、strategy_runner 对自动网格的逐档发单。 +**次日上午第四批(2026-08-26 盘中):每票阶段可视化** +用户拍板把状态机补成看得见的:扫描结果新增 stages,对**每只**持仓票各出一行——处在五阶段(吸筹震荡、启动拉升、高位派发、深亏修复、中性)的哪一段、一句话理由、热度、浮盈、定性原文;已挂自动策略的票阶段以策略为准(网格即吸筹震荡进行中、止盈即启动拉升保护中),人工策略票按信号判并注明「自动挂载不碰」;信号判定次序与连边一致,派发优先于深亏(保守方向)。页面按钮的结果顶部渲染五阶段视图,不动的票也说清为什么不动。判定函数 stage_of 纯逻辑可测,批十七扩到 40 例,总数 559。同日运维记录:桥的计划触发已在北京 07:10 跑通(计划日 T-1,判收通过);deploy 恰好压掉 09:40 调度位,用试算端点的正式模式手动补跑(补跑与调度位同一份实现);清掉两个三周前 compose run 未带 --rm 的残留容器。 + **当日盘中第三批(赶进度拍板后加的两件)** 一,科创板取整从「挂载侧兜」补到「执行侧真修」:strategy_runner 新增 _min_lot(688/689 按 200 股),网格手工挂的 100 股档在评估时抬成合法数量、卖出量不足 200 股原地等不推进档位;跟踪止盈的部分卖低于 200 股时量够就抬到 200(方向是保利润,偏保守)、可卖本身不足 200 时部分卖放弃,全清路径按交易所例外允许不足 200 股一次性全卖。主板行为一字未变,单测把两侧都钉住(批十七扩到 38 例,总数 557)。做T是命令授权的手工策略,科创板做T的开仓与平回数量怎么处理需要单独拍板,本次不动。 二,管理页「我的策略」加了「自动挂载 · 看看今天想挂什么」按钮:调 09:40 调度位同一份实现的试算模式,把想挂、想换挂、想停买入、被挡、跳过的每一条连原因渲染成可读文字,不再需要敲命令;空结果明说「没有想动的是正常克制」。 diff --git a/app/services/strategy_advisor.py b/app/services/strategy_advisor.py index 411ffa8..80057b4 100644 --- a/app/services/strategy_advisor.py +++ b/app/services/strategy_advisor.py @@ -62,6 +62,10 @@ NOTE_AUTO = "自动挂载: " NOTE_HANDOFF = "自动挂载(接力): " MARK_HANDOFF_OUT = "[接力撤下]" +# 阶段名 (方案第三节五阶段; 页面显示与判分同用这份字面量) +STG_ACCUM, STG_LAUNCH = "吸筹震荡", "启动拉升" +STG_DISTRIB, STG_REPAIR, STG_NEUTRAL = "高位派发", "深亏修复", "中性" + ACCUM_WINDOW_DAYS = 45 # 每票取近 45 自然日内最新一条结论 HEAT_MAX_AGE_DAYS = 4 # 热度表末日落后超此自然日 → 热度信号本轮不可用 _RULE_OF_TYPE = {"GRID": R_GRID, "TRAIL": R_TRAIL} @@ -151,6 +155,30 @@ def plan_edge(*, cls, fresh, heat, cushion, prm): return None, "" +def stage_of(*, cls, fresh, heat, heat_th, cushion, stype=None, is_auto=False): + """判定一只持仓票当前处在哪个阶段 (方案第三节的五阶段)。返回 (阶段, 一句话理由)。 + + 已挂自动策略的票, 阶段以策略为准 —— 策略本身就是阶段判断落了地。无策略的票按 + 信号判, 次序与连边判断一致, 保守方向优先: 派发最先, 然后吸筹、拉升、深亏, 都 + 不是就是中性。这个函数只读不写, 每次扫描对全部持仓各出一行, 页面按钮直接显示。 + """ + t = str(stype or "").upper() + if is_auto and t == "GRID": + return STG_ACCUM, "自动网格进行中, 区间内低买高卖" + if is_auto and t == "TRAIL": + return STG_LAUNCH, "自动跟踪止盈保护中, 创新高抬线回落即卖" + if cls == CLS_DISTRIB: + return STG_DISTRIB, "决策系统定性为高位派发" + if cls == CLS_CLEAR and fresh: + return STG_ACCUM, "明确吸筹且结论新鲜" + hot = heat is not None and _f(heat, 0.0) >= heat_th + if hot and cushion is not None and _f(cushion, 0.0) > 0: + return STG_LAUNCH, f"热度 {_f(heat):.2f} 达标且有浮盈" + if cushion is not None and _f(cushion, 0.0) < 0: + return STG_REPAIR, "安全垫为负, 交给补仓评估与清弱票, 不配策略" + return STG_NEUTRAL, "无明确信号, 留给动作引擎的常规动作" + + def count_auto_today(rows, today_ymd: int) -> int: """今天已常规自动挂载几条 (接力挂出的不算 —— 它是换不是增)。rows=策略行 (含已归档)。""" n = 0 @@ -340,7 +368,7 @@ def scan(*, dry_run: bool = False, now=None) -> dict: out = {"ok": True, "enabled": prm["enabled"], "dry_run": dry_run, "checked": 0, "attached": [], "handoffs": [], "paused": [], "resumed": [], "blocked": [], "skipped": [], "errors": [], - "unknown_states": []} + "unknown_states": [], "stages": []} if not prm["enabled"]: out["skipped"].append({"why": "策略自动挂载总开关关闭 (PMS_AUTO_STRATEGY_ENABLED)"}) return out @@ -438,6 +466,16 @@ def scan(*, dry_run: bool = False, now=None) -> dict: and a["age_days"] <= prm["accum_stale_tdays"] * 2) hv = heat.get(code) st = strat_by_code.get(code) + is_auto = bool(st and str(st.get("note") or "").startswith("自动挂载")) + stg, stg_why = stage_of(cls=a.get("cls"), fresh=fresh, heat=hv, + heat_th=prm["heat_th"], cushion=p.get("cushion_pct"), + stype=(st or {}).get("type"), is_auto=is_auto) + if st and not is_auto: + stg_why += " (挂着人工策略, 自动挂载不碰)" + out["stages"].append({"ts_code": code, "stage": stg, "why": stg_why, + "accum_state": a.get("state"), "heat": hv, + "cushion_pct": p.get("cushion_pct"), + "strategy": (st or {}).get("type")}) if st: _tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool, today, dry_run, out, strategy_service) diff --git a/app/web/static/index.html b/app/web/static/index.html index 83f192f..ea33ee3 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -665,6 +665,17 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius: 试算只判断、不挂载不留痕;真跑在每个交易日 09:40 由调度自动执行。本轮查了 {{ autoScanRes.checked }} 只持仓票。 + +
+ {{ x.stage }} + {{ nm(x.ts_code) }} + {{ x.why }} + + + + +
+
想挂 {{ nm(x.ts_code) }} {{ tx('stype', x.type) }} —— {{ x.why }}
@@ -2382,6 +2393,11 @@ createApp({ strategies.value = d.strategies || (d.data && d.data.strategies) || []; stratEnabled.value = !!(d.enabled != null ? d.enabled : (d.data && d.data.enabled)); } + // 五阶段标签配色 (阶段字面量与 strategy_advisor.stage_of 是同一份契约) + function stageTagType(s) { + return { '吸筹震荡': 'primary', '启动拉升': 'success', '高位派发': 'danger', + '深亏修复': 'warning', '中性': 'info' }[s] || 'info'; + } // 自动挂载试算: 只判断不落任何东西 (与 09:40 调度位同一份实现, 带 dry_run) async function autoScanPreview() { autoScanBusy.value = true; @@ -2544,7 +2560,7 @@ createApp({ pctOf, tgtPos, posMoveValid, posMovePreview, doPosMove, heldSectors, secSel, doSectorExit, pmap, pval, dcaOn, sumPosition, sumDca, sumAutonomy, strategies, stratEnabled, opLog, stratDlg, stratQty, loadStrategies, loadOpLog, - autoScanRes, autoScanBusy, autoScanPreview, + autoScanRes, autoScanBusy, autoScanPreview, stageTagType, openStrategy, validateStrategy, attachStrategy, setStrategyStatus, stratStateText, resumeBuy, showLedger, onPosExpand, openScan, insTab, todayYmd, insLive, insDone, insEnd, dispOf, loadOpenScan, denyToday, diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 990a816..c817d72 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -28,11 +28,11 @@ test_batch16_units.py 软归档 archived_at: 单表守卫/只归终态/列表默认排除 (5 例) test_batch17_units.py 策略自动挂载: 定性归类(子串+保守优先)/网格参数生成/ 科创板一手200(advisor 与 runner 两侧)/连边矩阵/ - note 约定与冷却推导/接力判定/clear_buypause/ - 编排冒烟(dry_run 滴水不写/名额/暂停买入/接力全链)/ - 判分脚本聚合与对照分组 (38 例) + 五阶段判定与逐票阶段返回/note 约定与冷却推导/ + 接力判定/clear_buypause/编排冒烟(dry_run 滴水不写/ + 名额/暂停买入/接力全链)/判分脚本聚合与对照分组 (40 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 557 例 + 共 559 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch17_units.py b/scripts/test_batch17_units.py index 70b1073..d1cfc07 100644 --- a/scripts/test_batch17_units.py +++ b/scripts/test_batch17_units.py @@ -679,6 +679,49 @@ def _(): param_store.get_bool, adv._params = orig, orig_p +@case("[阶段] stage_of 五阶段判定矩阵: 策略优先于信号, 派发优先于深亏, 都不是归中性") +def _(): + def s(**kw): + d = dict(cls=None, fresh=False, heat=None, heat_th=0.80, cushion=None, + stype=None, is_auto=False) + d.update(kw) + return adv.stage_of(**d)[0] + # 已挂自动策略: 阶段以策略为准, 信号什么样都不改判 + assert s(stype="GRID", is_auto=True, cls=adv.CLS_DISTRIB) == adv.STG_ACCUM + assert s(stype="TRAIL", is_auto=True, cushion=-0.1) == adv.STG_LAUNCH + # 人工策略不算数 (is_auto=False), 按信号判 + assert s(stype="GRID", is_auto=False, cls=adv.CLS_DISTRIB) == adv.STG_DISTRIB + # 信号判定次序: 派发 > 吸筹 > 拉升 > 深亏 > 中性 + assert s(cls=adv.CLS_DISTRIB, cushion=-0.2) == adv.STG_DISTRIB + assert s(cls=adv.CLS_CLEAR, fresh=True, cushion=-0.05) == adv.STG_ACCUM + assert s(cls=adv.CLS_CLEAR, fresh=False, cushion=-0.05) == adv.STG_REPAIR + assert s(heat=0.9, cushion=0.03) == adv.STG_LAUNCH + assert s(heat=0.9, cushion=-0.03) == adv.STG_REPAIR # 热但亏, 不算拉升 + assert s(heat=0.5, cushion=0.03) == adv.STG_NEUTRAL + assert s() == adv.STG_NEUTRAL + + +@case("[阶段] 扫描给每只持仓各出一行阶段, 人工策略票注明不碰") +def _(): + held = [_pos(ts_code="600000.SH"), # 明确吸筹 → 吸筹震荡 + _pos(ts_code="600519.SH", cushion_pct=-0.06), # 深亏修复 + _pos(ts_code="000001.SZ"), # 无信号 → 中性 + _pos(ts_code="002415.SZ")] # 人工策略 + accum = {"600000.SH": {"cls": adv.CLS_CLEAR, "state": "明确吸筹", "score": 80, + "ymd": TODAY, "age_days": 1}} + manual = [{"strategy_id": "S_M", "ts_code": "002415.SZ", "type": "T0", + "note": "手工做T", "params": {}, "state": {}}] + rec = _Rec() + out = _scan_stubbed(rec, held=held, accum=accum, heat={}, strategies=manual, + dry_run=True) + stg = {x["ts_code"]: x for x in out["stages"]} + assert len(out["stages"]) == 4, out["stages"] + assert stg["600000.SH"]["stage"] == adv.STG_ACCUM + assert stg["600519.SH"]["stage"] == adv.STG_REPAIR + assert stg["000001.SZ"]["stage"] == adv.STG_NEUTRAL + assert "人工策略" in stg["002415.SZ"]["why"], stg["002415.SZ"] + + @case("[科创板] runner 止盈卖出: 200 股起, 不足 200 只许一次性全清, 主板行为一字不变") def _(): from app.services import strategy_runner as srun