From 43ccc9bf0fddf2e70df4e395c48a345f5b2ff370 Mon Sep 17 00:00:00 2001 From: zlt Date: Tue, 25 Aug 2026 14:25:45 +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 | 4 +++ app/services/strategy_runner.py | 33 +++++++++++++++++++--- app/web/static/index.html | 48 ++++++++++++++++++++++++++++++++ scripts/run_tests.py | 9 +++--- scripts/test_batch17_units.py | 49 +++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 8 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 84b4f3f..32e3b89 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -50,6 +50,10 @@ 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 对自动网格的逐档发单。 +**当日盘中第三批(赶进度拍板后加的两件)** +一,科创板取整从「挂载侧兜」补到「执行侧真修」:strategy_runner 新增 _min_lot(688/689 按 200 股),网格手工挂的 100 股档在评估时抬成合法数量、卖出量不足 200 股原地等不推进档位;跟踪止盈的部分卖低于 200 股时量够就抬到 200(方向是保利润,偏保守)、可卖本身不足 200 时部分卖放弃,全清路径按交易所例外允许不足 200 股一次性全卖。主板行为一字未变,单测把两侧都钉住(批十七扩到 38 例,总数 557)。做T是命令授权的手工策略,科创板做T的开仓与平回数量怎么处理需要单独拍板,本次不动。 +二,管理页「我的策略」加了「自动挂载 · 看看今天想挂什么」按钮:调 09:40 调度位同一份实现的试算模式,把想挂、想换挂、想停买入、被挡、跳过的每一条连原因渲染成可读文字,不再需要敲命令;空结果明说「没有想动的是正常克制」。 + **当日盘中补两处(第二次交付)** 一,科创板一手口径:dry-run 里 688802.SH 暴露全库按 100 股一手,而 688/689 最小申报 200 股——自动网格若生成 per_lot=100 会被券商拒单。advisor 补 lot_of()(688/689→200),买得起一手与 per_lot 下限都按它算(200 仍是 100 的整数倍,runner 的取整不会磨掉)。runner 侧卖出数量对科创板的整百取整仍不完美,见欠账。二,判分读数脚本 scripts/report_strategy_score.py 交付:四段只读统计(样本盘点 / 网格差价按策略内均价配对 / 止盈「卖点之后又跌多少=保住的钱」/ 挂上 vs 名额挡下的对照组涨跌),样本不足三条只报数不下结论,连不上库或取不到现价都说人话。batch17 扩到 36 例,总数 555。 diff --git a/app/services/strategy_runner.py b/app/services/strategy_runner.py index 90208f5..193561b 100644 --- a/app/services/strategy_runner.py +++ b/app/services/strategy_runner.py @@ -89,6 +89,16 @@ def _round_lot(qty) -> int: return (n // LOT) * LOT +def _min_lot(code) -> int: + """最小申报数量: 科创板 (688/689 开头) 买卖都是 200 股起, 其余 100 股。 + + 2026-08-25 补: 持仓里出现科创板票后发现全库取整都按 100 股, 而科创板 100 股的申报 + 会被券商直接拒掉。规则还有一条例外: 持仓不足 200 股时允许**一次性全部卖出**, + 网格与止盈的各卖出落点分别处理了这一条。做T是命令授权的手工策略, 科创板做T的 + 开腿与平回数量怎么处理需要单独拍板, 本次不动 (见 DEVLOG)。""" + return 200 if str(code or "").startswith(("688", "689")) else 100 + + def _today() -> int: return td.ymd() @@ -354,7 +364,10 @@ def _eval_grid(st, pos, day, now, ctx): if not levels: return None lo = levels[0] + mlot = _min_lot(st.get("ts_code")) per_lot = _round_lot(params.get("per_lot")) or LOT + if per_lot < mlot: + per_lot = mlot # 科创板 200 股起: 手工挂的 100 股档在这里抬成合法数量 max_capital = _f(params.get("max_capital")) filled = {int(k): dict(v) for k, v in (state.get("filled_levels") or {}).items()} invested = _f(state.get("invested")) @@ -403,7 +416,7 @@ def _eval_grid(st, pos, day, now, ctx): k = last if k in filled and avail >= LOT and actual_add >= LOT: q = min(per_lot, _round_lot(avail)) - if q >= LOT: + if q >= mlot: # 科创板部分卖低于 200 股不合法, 量不足先不推进 state["last_band"] = last + 1 info = filled[k] return {"side": "sell", "action": A_SELL, "qty": q, "leg": f"grid_sell:{k}", @@ -467,16 +480,28 @@ def _eval_trail(st, pos, day, now, ctx): if avail < LOT: return None # 无 T+1 可卖, 只更新高水位 + mlot = _min_lot(st.get("ts_code")) + + def _all_out(): + # 全清数量: 科创板持仓不足 200 股时按交易所例外一次性全卖, 否则整百 + return avail if avail < mlot else (_round_lot(avail) or avail) # 硬止盈目标: 直接全清 if hard_target > 0 and profit >= hard_target: - return {"side": "sell", "action": A_EXIT, "qty": _round_lot(avail) or avail, "leg": "trail_hard", + return {"side": "sell", "action": A_EXIT, "qty": _all_out(), "leg": "trail_hard", "reason": f"跟踪止盈-硬目标: 浮盈 {profit:.1%} ≥ {hard_target:.1%}, 全清 avail {avail} 股"} # 已武装且从高点回落到设定比例 → 卖 if state.get("armed") and hw > 0 and price <= hw * (1 - giveback) and giveback > 0: - q = _round_lot(avail * sell_ratio) if sell_ratio < 1 else (_round_lot(avail) or avail) - if q >= LOT or (sell_ratio >= 1 and q > 0): + if sell_ratio < 1: + q = _round_lot(avail * sell_ratio) + if mlot > LOT: + # 科创板: 部分卖低于 200 股不合法 —— 量够就抬到 200 (方向是保利润, 多卖 + # 一点偏保守), 可卖的本来就不足 200 则这条部分卖路径放弃, 等硬目标或人工 + q = 0 if avail < mlot else min(max(q, mlot), _round_lot(avail)) + else: + q = _all_out() + if q >= mlot or (sell_ratio >= 1 and q > 0): act = A_EXIT if sell_ratio >= 1 else A_SELL return {"side": "sell", "action": act, "qty": q, "leg": "trail_sell", "reason": f"跟踪止盈: 现价 {price} 自高点 {hw} 回落 {1 - price / hw:.1%} ≥ {giveback:.1%}, 卖 {q} 股"} diff --git a/app/web/static/index.html b/app/web/static/index.html index ae022d5..83f192f 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -654,9 +654,45 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius: {{ stratEnabled ? '策略层已启用' : '策略层总开关未开(PMS_STRATEGY_ENABLED)' }} 显示已撤下/已完成 + + 自动挂载 · 看看今天想挂什么 + +
+
+ 试算只判断、不挂载不留痕;真跑在每个交易日 09:40 由调度自动执行。本轮查了 + {{ autoScanRes.checked }} 只持仓票。 +
+
+ 想挂 + {{ nm(x.ts_code) }} {{ tx('stype', x.type) }} —— {{ x.why }}
+
+ 想换挂 + {{ nm(x.ts_code) }} 网格换跟踪止盈 —— {{ x.why }}
+
+ 想停买入 + {{ nm(x.ts_code) }} —— {{ x.why }}
+
+ 想恢复买入 + {{ nm(x.ts_code) }}
+
+ 被挡 + {{ nm(x.ts_code) }} —— {{ x.why }}
+
+ · {{ x.why }}
+
✗ {{ e }}
+
+ 有 {{ autoScanRes.unknown_states.length }} 只票的吸筹说法不在约定词表里(按无标志处理), + 样本:{{ autoScanRes.unknown_states.slice(0,3).map(u=>u.state).join('、') }}——需与决策系统核对。 +
+
+ 今天没有想动的:持仓里暂时没有够格的信号,这是正常的克制。 +
+
还没有挂任何交易方案。到「我的持仓」某行「更多 ▾ → 挂交易方案」给它挂一个。
@@ -1576,6 +1612,7 @@ createApp({ const positions = ref([]), lots = ref([]), lotsOf = ref(''); const instructions = ref([]), ledger = ref([]), proposals = ref([]); const strategies = ref([]), stratEnabled = ref(false), opLog = ref([]); + const autoScanRes = ref(null), autoScanBusy = ref(false); // 自动挂载试算 (2026-08-25) // 「显示已完成/已移除」开关 (默认关: 只看进行中)。命令与策略勾选时会拉全量含已归档 (loadX 传 // include_archived); 在途指令纯前端过滤, 不重拉。 const showAllCommands = ref(false), showAllStrategies = ref(false), showAllInstr = ref(false); @@ -2345,6 +2382,16 @@ createApp({ strategies.value = d.strategies || (d.data && d.data.strategies) || []; stratEnabled.value = !!(d.enabled != null ? d.enabled : (d.data && d.data.enabled)); } + // 自动挂载试算: 只判断不落任何东西 (与 09:40 调度位同一份实现, 带 dry_run) + async function autoScanPreview() { + autoScanBusy.value = true; + try { + const d = await call('post', '/api/ops/strategy-attach-scan?dry_run=true', {}) || {}; + if (d.checked == null) d.checked = 0; // 后端不可达时也别渲染出 undefined + if (d.error && !(d.errors || []).length) d.errors = [d.error]; + autoScanRes.value = d; + } finally { autoScanBusy.value = false; } + } // 软归档: 把终态记录从在办视图移除 (on=true) 或恢复显示 (on=false)。kind = commands/strategies/instructions/proposals async function archiveRow(kind, id, on) { const d = await call('post', '/api/' + kind + '/' + id + '/archive', { archived: on }); @@ -2497,6 +2544,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, 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 1ee1ac8..990a816 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -27,11 +27,12 @@ 在途与配额文案拆分; 减仓不掐策略腿 (12 例) test_batch16_units.py 软归档 archived_at: 单表守卫/只归终态/列表默认排除 (5 例) test_batch17_units.py 策略自动挂载: 定性归类(子串+保守优先)/网格参数生成/ - 科创板一手200/连边矩阵/note 约定与冷却推导/接力判定/ - clear_buypause/编排冒烟(dry_run 滴水不写/名额/边三/ - 边四全链)/判分脚本聚合与对照分组 (36 例) + 科创板一手200(advisor 与 runner 两侧)/连边矩阵/ + note 约定与冷却推导/接力判定/clear_buypause/ + 编排冒烟(dry_run 滴水不写/名额/暂停买入/接力全链)/ + 判分脚本聚合与对照分组 (38 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 555 例 + 共 557 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch17_units.py b/scripts/test_batch17_units.py index 489103f..70b1073 100644 --- a/scripts/test_batch17_units.py +++ b/scripts/test_batch17_units.py @@ -679,6 +679,55 @@ def _(): param_store.get_bool, adv._params = orig, orig_p +@case("[科创板] runner 止盈卖出: 200 股起, 不足 200 只许一次性全清, 主板行为一字不变") +def _(): + from app.services import strategy_runner as srun + assert srun._min_lot("688802.SH") == 200 and srun._min_lot("600000.SH") == 100 + + def trail(code, avail, ratio, avg=8.0, price=10.0): + st = {"ts_code": code, "params": {"giveback": 0.05, "sell_ratio": ratio}} + pos = {"avg_cost": avg, "avail_qty": avail, "cushion_pct": price / avg - 1} + ctx = {"state": {"armed": True, "high_water": 12.0}, "notes": []} + return srun._eval_trail(st, pos, {"price": price}, None, ctx) + + d = trail("688802.SH", 150, 1.0) # 全清: 不足 200 按例外一次性全卖 + assert d and d["qty"] == 150 and d["action"] == srun.A_EXIT, d + d = trail("688802.SH", 700, 0.25) # 部分卖 175→整百 100 不合法 → 抬到 200 + assert d and d["qty"] == 200, d + assert trail("688802.SH", 150, 0.5) is None # 可卖不足 200, 部分卖放弃 + d = trail("600000.SH", 700, 0.25) # 主板照旧 100 + assert d and d["qty"] == 100, d + d = trail("600000.SH", 150, 1.0) # 主板全清照旧整百 + assert d and d["qty"] == 100, d + + +@case("[科创板] runner 网格: 手工 100 股档抬到 200 / 卖出量不足 200 不推进档位") +def _(): + from app.services import strategy_runner as srun + prm = {"lower": 9.0, "upper": 11.0, "center": 10.0, "step_pct": 0.02, + "per_lot": 100, "max_capital": 50000} + + def grid(code, price, avail, state, add_qty=None): + st = {"ts_code": code, "params": prm} + pos = {"avail_qty": avail, "add_qty": avail if add_qty is None else add_qty} + ctx = {"state": state, "notes": [], "buy_paused": False} + return srun._eval_grid(st, pos, {"price": price}, None, ctx) + + lv = srun._grid_levels(prm) + # 下行跌破一档 → 买: 科创板把 100 股/档抬成 200 + b0 = srun._band(lv, 9.5) + d = grid("688802.SH", 9.5, 0, {"last_band": b0 + 1, "filled_levels": {}}) + assert d and d["side"] == "buy" and d["qty"] == 200, d + d = grid("600000.SH", 9.5, 0, {"last_band": b0 + 1, "filled_levels": {}}) + assert d and d["qty"] == 100, d # 主板照旧 + # 上行涨破 → 卖: 网格股有 200 但今天可卖只有 100, 科创板卖 100 不合法, 原地等 + b1 = srun._band(lv, 10.5) + st1 = {"last_band": b1 - 1, + "filled_levels": {str(b1 - 1): {"qty": 200, "price": 9.8}}} + assert grid("688802.SH", 10.5, 100, st1, add_qty=200) is None + assert st1.get("last_band") == b1 - 1, st1 # 档位没被推进, 下跳凑够了还能卖 + + @case("[判分] report_strategy_score: 成交聚合按边分侧 / 对照只收名额挡下的 / 涨跌口径空值安全") def _(): sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))