diff --git a/app/services/command_service.py b/app/services/command_service.py
index 723efb8..84f9a8d 100644
--- a/app/services/command_service.py
+++ b/app/services/command_service.py
@@ -76,6 +76,51 @@ def effective_stock_params() -> dict:
return out
+def _shown_price(v):
+ """显示用价格归一: 取不成数就是 None, 小于等于 0 也是 None (等于没设)。
+
+ 「小于等于 0 当作没设」不是这里新立的规矩, 是照抄两个求值处的现成口径:
+ 规则闸 rule_gate._check_stop_price 判的是 stop <= 0 就什么都不做, 动作引擎
+ action_engine.eval_target 判的是 target <= 0 就当这条动作不存在 (设定某股目标价
+ 这条命令的取消办法就是再设一次、价格填 0)。页面必须跟这两处看到同一件事,
+ 否则用户填 0 取消了目标价, 页面还挂着一个数, 又是一次「说的和做的不一样」。
+ """
+ try:
+ f = float(v)
+ except (TypeError, ValueError):
+ return None
+ return f if f > 0 else None
+
+
+def attach_user_prices(positions: list, stock_params: dict = None) -> list:
+ """把用户命令设的止损价与目标价挂到持仓行上, 只供页面显示, 不参与任何判断。
+
+ 为什么持仓行上非要多这两个字段
+ ------------------------------
+ 页面参考位那一列读的是持仓行上的 stop_ref, 而 stop_ref 是系统自己算的参考位:
+ ledger_service.premarket() 每个交易日盘前都会用参考位取数, 把 support_ref、
+ pressure_ref、stop_ref、ref_source 整批重写一遍。用户下达设定某股止损价这条命令时,
+ 命令会把价投影到 stop_ref 上, 到第二天早上这份投影就被系统参考位盖掉了。
+
+ 盖掉这件事本身是对的, 系统参考位本来就该每天重算, 所以这次不动 premarket 的行为。
+ 判断那一侧也早就不吃这份投影了: 规则闸的止损价告警读命令表, 动作引擎的目标价到价
+ 也读命令表, 两处的事实源都是命令表。真正没治的只有显示 —— 用户设完止损价,
+ 第二天在页面上只看得到系统算的那个数, 会以为自己设的价没生效或者被系统改掉了。
+
+ 所以这里从命令表把这两个价单独取出来, 挂成 user_stop_price 与 user_target_price
+ 两个字段, 页面把它们与系统参考位分列显示, 两个数各归各位。
+
+ 就地改传进来的那批行 (positions_view 返回的 positions 与 held 是同一批对象,
+ 改一次两处都带上), 并把同一个列表原样返回, 便于串写。
+ """
+ sp = stock_params or {}
+ for row in positions or []:
+ d = sp.get(row.get("ts_code")) or {}
+ row["user_stop_price"] = _shown_price(d.get("stop_price"))
+ row["user_target_price"] = _shown_price(d.get("target_price"))
+ return positions
+
+
def blacklist() -> set:
return {c for c, d in effective_stock_params().items() if d.get("black")}
diff --git a/app/services/param_store.py b/app/services/param_store.py
index 7d4b0bd..d50a5e6 100644
--- a/app/services/param_store.py
+++ b/app/services/param_store.py
@@ -120,11 +120,14 @@ FAIL_CLOSED = {
# 设定某股目标价 SET_TARGET_PRICE 2026-09-03 已接通 —— 动作引擎 eval_target 到价产出
# 清仓提议, 必定交人拍板。接通前它只存进命令表没人读。
# 设定某股止损价 SET_STOP_PRICE 2026-09-03 已接进规则闸的告警层 (只披露不拦截,
-# 不自动卖)。但它投影到持仓行的 stop_ref 每天盘前会被
-# ledger_service.premarket() 的参考位取数覆盖掉 ——
-# 页面参考位列第二天显示的就不再是你设的那个价了。
-# 所以判断一律读命令表 (事实源), 别读投影。这处覆盖
-# 本身还没修, 见交接里的遗留问题。
+# 不自动卖)。它投影到持仓行的 stop_ref 每天盘前会被
+# ledger_service.premarket() 的参考位取数覆盖掉,
+# 所以判断一律读命令表 (事实源), 别读投影。
+# 这处覆盖是系统自算参考位的正常行为, 保留不动;
+# 由此带来的页面错位已在 2026-09-03 修掉显示那一侧:
+# 用户设的止损价与目标价由 command_service
+# .attach_user_prices 从命令表单独取出来, 与系统参考位
+# 分列显示, 两张持仓表都看得到自己设的价。
# 做T授权 T0_ENABLE 只把 t0_enabled / t0_ratio 投影到持仓行给页面显示,
# 没有任何代码把它当成「可以做T」的授权来检查 (真正的
# 做T由单独挂的交易方案驱动)。要不要把它变成硬授权,
diff --git a/app/web/main.py b/app/web/main.py
index f785ae6..38ed4f0 100644
--- a/app/web/main.py
+++ b/app/web/main.py
@@ -366,8 +366,16 @@ def api_plans(command_id: str = Query(None), status: str = Query(None), limit: i
# ================================================================ ③ 持仓与账本
@app.get("/api/positions")
def api_positions():
- return ok(lambda: {"ok": True, **portfolio.positions_view(),
- "stock_params": command_service.effective_stock_params()})
+ def _view():
+ v = portfolio.positions_view()
+ sp = command_service.effective_stock_params()
+ # 用户设的止损价与目标价, 事实源在命令表, 与持仓行上系统每天早上重算的参考位
+ # (support_ref / pressure_ref / stop_ref) 是两回事。在这里合到一起, 是为了让页面
+ # 把两个数分列显示 —— 只是显示, 判断那一侧照旧各读各的事实源, 见
+ # command_service.attach_user_prices 的说明。
+ command_service.attach_user_prices(v["positions"], sp)
+ return {"ok": True, **v, "stock_params": sp}
+ return ok(_view)
@app.get("/api/positions/{ts_code}/lots")
diff --git a/app/web/static/index.html b/app/web/static/index.html
index d449852..af96e3d 100644
--- a/app/web/static/index.html
+++ b/app/web/static/index.html
@@ -817,7 +817,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
我的持仓 · {{ heldPositions.length }} 只
安全垫?
看的是浮盈厚不厚:厚垫可以考虑加仓,薄垫先放着,浮亏无垫不加仓。
- 成本是各批买入价的加权平均,占比是占总操作规模的百分比。
+ 成本是各批买入价的加权平均,占比是占总操作规模的百分比。
+ 「我设的价」这一列是你自己下命令定的止损价与目标价,一直在,你改了才变;
+ 系统每天早上自己算的参考位是另一回事,在管理视图的持仓总览里看。
@@ -851,6 +853,18 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
{{ s.row.price }} 估
{{ s.row.avg_cost||'—' }}
+
+
+
+ 止损 {{ s.row.user_stop_price }}未设
+
+ 目标 {{ s.row.user_target_price }}未设
+
+
{{ pct(s.row.price/s.row.avg_cost-1) }}
@@ -1537,9 +1551,19 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
{{ pct(s.row.pct_of_scale) }}
{{ money(s.row.market_value) }}
-
+
+
{{ s.row.support_ref||'-' }}/{{ s.row.pressure_ref||'-' }}/{{ s.row.stop_ref||'-' }}
- {{ s.row.ref_source||'' }}
+
{{ tx('refSource', s.row.ref_source) }}
+
+
+
+ {{ s.row.user_stop_price!=null ? s.row.user_stop_price : '-' }}/{{ s.row.user_target_price!=null ? s.row.user_target_price : '-' }}
+ 未设
+
@@ -2188,6 +2212,10 @@ createApp({
CONFIRMED:'已成交', REJECTED:'被挡下', EXPIRED:'到期作废', CANCELLED:'已撤销' },
arbiter: { rule:'合规检查', judge:'决策系统', user:'你' },
verdict: { PASS:'放行', REJECT:'驳回', NOTE:'记一笔', UNAVAILABLE:'决策系统现在读不到' },
+ // 参考位这一列的数是谁给的。系统每个交易日盘前重算一遍并把整列重写, 所以它基本上
+ // 永远是系统口径; 用户设的止损价只在下达当天投影到这一列上, 第二天早上就换回系统值了。
+ // 用户自己设的两个价另有「我设的」一列, 那一列读命令表, 不会被盖。
+ refSource: { bionic:'决策系统给的', self_calc:'系统自算', user:'你今天设的', none:'取不到' },
stype: { T0:'做T', GRID:'网格', TRAIL:'跟踪止盈' },
stratStatus: { ACTIVE:'运行中', PAUSED:'已暂停', CANCELLED:'已撤下', DONE:'已完成' },
};
@@ -2390,14 +2418,19 @@ createApp({
+ '(占规模 ' + curPct + ')。减到目标仓位(占总规模,%)', '减仓 ' + nm(row.ts_code));
if (v != null) quickCmd('REDUCE_STOCK', { ts_code: row.ts_code, target_pct: v + '%' });
}
+ // 弹框里先把「你现在设的是多少」摆出来, 再让人填新值 —— 不写的话只能凭记忆改价。
+ // 读的是 user_stop_price / user_target_price (来自命令表), 不是持仓行上的 stop_ref。
+ const curSet = v => (v != null ? '你现在设的是 ' + v + ' 元。' : '你还没设过。');
async function actStop(row) {
- const v = await promptNum('现价 ' + row.price + ',成本 ' + (row.avg_cost || '—')
+ const v = await promptNum(curSet(row.user_stop_price)
+ + '现价 ' + row.price + ',成本 ' + (row.avg_cost || '—')
+ '。止损价只做提醒:跌破了会在合规检查里留一条记录,系统不会替你挂单卖出。'
+ '真要卖请下清仓或减持命令。填多少元?', '设止损 ' + nm(row.ts_code));
if (v != null) quickCmd('SET_STOP_PRICE', { ts_code: row.ts_code, price: v });
}
async function actTargetPrice(row) {
- const v = await promptNum('现价 ' + row.price + ',成本 ' + (row.avg_cost || '—')
+ const v = await promptNum(curSet(row.user_target_price)
+ + '现价 ' + row.price + ',成本 ' + (row.avg_cost || '—')
+ '。目标价(元):到价后生成一条清仓提议放进「等我拍板」,卖不卖由你点头,'
+ '系统不自动卖。不想要了就再设一次、填 0 即取消。', '设目标价 ' + nm(row.ts_code));
if (v != null) quickCmd('SET_TARGET_PRICE', { ts_code: row.ts_code, price: v });
diff --git a/scripts/run_tests.py b/scripts/run_tests.py
index 905dc05..c96e938 100644
--- a/scripts/run_tests.py
+++ b/scripts/run_tests.py
@@ -51,8 +51,10 @@
强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 例)
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) +
- 目标价到价必定入队 (档位 full 也不自动卖) (69 例)
- 共 649 例
+ 目标价到价必定入队 (档位 full 也不自动卖) +
+ 用户设的止损价与目标价单独成列显示 (70 例)
+ 共 651 例
+ (上面这个总数此前写的 649 是笔误: 各批例数加起来一直是 650, 本次 +1 例后是 651)
任一子集失败即整体失败 (退出码 1)。
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
diff --git a/scripts/test_wiring.py b/scripts/test_wiring.py
index a7f7d90..0f19b65 100644
--- a/scripts/test_wiring.py
+++ b/scripts/test_wiring.py
@@ -1351,6 +1351,48 @@ def _():
assert caps["cash_source"] == "ws" and caps["cash_avail"] == t["cash_avail"], caps
+@case("页面显示·用户设的止损价与目标价单独成列 (每天重算的参考位盖不到它们)")
+def _():
+ """止损价设了却在页面上看不见的那处显示错位 (2026-09-03 修)。
+
+ 病灶不在判断: 规则闸的止损价告警与动作引擎的目标价到价都读命令表, 都是对的。
+ 病灶在页面只有参考位一列, 而持仓行上的 stop_ref 是系统参考位, 每个交易日盘前被
+ ledger_service.premarket() 整列重写 —— 用户设的价第二天就被系统值顶掉, 看着像没生效。
+ 修法是把用户设的两个价单独带出来显示, premarket 的覆盖行为一个字节都不动。
+ """
+ import inspect
+
+ from app.services import command_service as csvc
+ from app.web import main as web
+
+ rows = [{"ts_code": "600000.SH", "stop_ref": 8.88, "ref_source": "self_calc"},
+ {"ts_code": "000001.SZ", "stop_ref": 9.10, "ref_source": "bionic"},
+ {"ts_code": "600519.SH", "stop_ref": None, "ref_source": "none"}]
+ sp = {"600000.SH": {"stop_price": 9.5, "target_price": 12.0},
+ "000001.SZ": {"stop_price": None, "target_price": 0}}
+ out = csvc.attach_user_prices(rows, sp)
+ assert out is rows, "要就地改同一批行 —— positions 与 held 是同一批对象, 改一次两处都带上"
+ assert rows[0]["user_stop_price"] == 9.5 and rows[0]["user_target_price"] == 12.0, rows[0]
+ # 系统参考位一个字节都不许动: 这次只加显示字段, 不改盘前覆盖行为
+ assert rows[0]["stop_ref"] == 8.88 and rows[0]["ref_source"] == "self_calc", rows[0]
+ # 目标价填 0 就是取消, 与 action_engine.eval_target 的 target <= 0 同一口径
+ assert rows[1]["user_target_price"] is None and rows[1]["user_stop_price"] is None, rows[1]
+ # 没下过个股参数命令的票也要有这两个键, 页面模板不必再判「有没有这个字段」
+ assert rows[2]["user_stop_price"] is None and "user_target_price" in rows[2], rows[2]
+
+ # 接线: 持仓接口真的挂上去了, 否则页面根本拿不到这两个字段
+ assert "attach_user_prices" in inspect.getsource(web.api_positions), \
+ "/api/positions 没把用户设的价挂到持仓行上"
+
+ # 页面两处显示位: 两张持仓表都显示用户设的价, 参考位列标明这一列的数是谁给的
+ with open(os.path.join(web.STATIC_DIR, "index.html"), encoding="utf-8") as f:
+ html = f.read()
+ assert html.count("user_stop_price") >= 2 and html.count("user_target_price") >= 2, \
+ "交易员视图与管理视图两张持仓表都要显示用户设的止损价与目标价"
+ assert "refSource:" in html and "tx('refSource'" in html, \
+ "参考位列要标明这一列是系统算的还是用户当天设的"
+
+
@case("对账事实源·ws 快照新鲜 → 以 ws 为准建账 (表空集不再当事实)")
def _():
from app.services import ledger_service as ls