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 }}
+
+ · {{ nm(x.ts_code) }}:{{ x.why }}
+
✗ {{ e }}
+
+ 有 {{ autoScanRes.unknown_states.length }} 只票的吸筹说法不在约定词表里(按无标志处理),
+ 样本:{{ autoScanRes.unknown_states.slice(0,3).map(u=>u.state).join('、') }}——需与决策系统核对。
+
+
+ 今天没有想动的:持仓里暂时没有够格的信号,这是正常的克制。
+
+
还没有挂任何交易方案。到「我的持仓」某行「更多 ▾ → 挂交易方案」给它挂一个。
{{ nm(s.row.ts_code) }} {{ s.row.ts_code }}
@@ -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__)))