PMS 页面显示:用户设的止损价与目标价单独成列,参考位列标明来源

问题是系统每天早上重算参考位会盖掉用户设的止损价,而命令状态仍显示生效,页面那一列因此误导人。修法只动显示不动重算:把用户设的两个价作为独立字段从命令表带到页面,两张持仓表各加一列,参考位列的来源改用中文说明而不是内部取值;设价弹框先摆出当前已设的值。字段名有意与动作引擎并到持仓快照上的名字区分,避免页面用的行被误喂进引擎。价格小于等于零当作未设,与规则检查和到价判定同口径。
测试:全套 ALL SUITES PASS,例数 651。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-03 16:30:49 +08:00
parent c6034a6220
commit 4589539a0c
6 changed files with 147 additions and 14 deletions

View File

@ -76,6 +76,51 @@ def effective_stock_params() -> dict:
return out 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_refstop_refref_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: def blacklist() -> set:
return {c for c, d in effective_stock_params().items() if d.get("black")} return {c for c, d in effective_stock_params().items() if d.get("black")}

View File

@ -120,11 +120,14 @@ FAIL_CLOSED = {
# 设定某股目标价 SET_TARGET_PRICE 2026-09-03 已接通 —— 动作引擎 eval_target 到价产出 # 设定某股目标价 SET_TARGET_PRICE 2026-09-03 已接通 —— 动作引擎 eval_target 到价产出
# 清仓提议, 必定交人拍板。接通前它只存进命令表没人读。 # 清仓提议, 必定交人拍板。接通前它只存进命令表没人读。
# 设定某股止损价 SET_STOP_PRICE 2026-09-03 已接进规则闸的告警层 (只披露不拦截, # 设定某股止损价 SET_STOP_PRICE 2026-09-03 已接进规则闸的告警层 (只披露不拦截,
# 不自动卖)。但它投影到持仓行的 stop_ref 每天盘前会被 # 不自动卖)。它投影到持仓行的 stop_ref 每天盘前会被
# ledger_service.premarket() 的参考位取数覆盖掉 —— # ledger_service.premarket() 的参考位取数覆盖掉,
# 页面参考位列第二天显示的就不再是你设的那个价了。 # 所以判断一律读命令表 (事实源), 别读投影。
# 所以判断一律读命令表 (事实源), 别读投影。这处覆盖 # 这处覆盖是系统自算参考位的正常行为, 保留不动;
# 本身还没修, 见交接里的遗留问题。 # 由此带来的页面错位已在 2026-09-03 修掉显示那一侧:
# 用户设的止损价与目标价由 command_service
# .attach_user_prices 从命令表单独取出来, 与系统参考位
# 分列显示, 两张持仓表都看得到自己设的价。
# 做T授权 T0_ENABLE 只把 t0_enabled / t0_ratio 投影到持仓行给页面显示, # 做T授权 T0_ENABLE 只把 t0_enabled / t0_ratio 投影到持仓行给页面显示,
# 没有任何代码把它当成「可以做T」的授权来检查 (真正的 # 没有任何代码把它当成「可以做T」的授权来检查 (真正的
# 做T由单独挂的交易方案驱动)。要不要把它变成硬授权, # 做T由单独挂的交易方案驱动)。要不要把它变成硬授权,

View File

@ -366,8 +366,16 @@ def api_plans(command_id: str = Query(None), status: str = Query(None), limit: i
# ================================================================ ③ 持仓与账本 # ================================================================ ③ 持仓与账本
@app.get("/api/positions") @app.get("/api/positions")
def api_positions(): def api_positions():
return ok(lambda: {"ok": True, **portfolio.positions_view(), def _view():
"stock_params": command_service.effective_stock_params()}) 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") @app.get("/api/positions/{ts_code}/lots")

View File

@ -817,7 +817,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<h3>我的持仓 <span class="muted" style="font-weight:400">· {{ heldPositions.length }} 只</span></h3> <h3>我的持仓 <span class="muted" style="font-weight:400">· {{ heldPositions.length }} 只</span></h3>
<div class="sub2">安全垫<span class="help" data-tip="现在的浮盈够不够厚,够不够扛一次回撤。厚垫=赚得多,可以考虑加仓;薄垫=刚开始赚;浮亏无垫=还在亏,不加仓。">?</span> <div class="sub2">安全垫<span class="help" data-tip="现在的浮盈够不够厚,够不够扛一次回撤。厚垫=赚得多,可以考虑加仓;薄垫=刚开始赚;浮亏无垫=还在亏,不加仓。">?</span>
看的是浮盈厚不厚:厚垫可以考虑加仓,薄垫先放着,浮亏无垫不加仓。 看的是浮盈厚不厚:厚垫可以考虑加仓,薄垫先放着,浮亏无垫不加仓。
成本是各批买入价的加权平均,占比是占总操作规模的百分比。</div> 成本是各批买入价的加权平均,占比是占总操作规模的百分比。
「我设的价」这一列是你自己下命令定的止损价与目标价,一直在,你改了才变;
系统每天早上自己算的参考位是另一回事,在管理视图的持仓总览里看。</div>
<el-table :data="heldPositions" size="small" border max-height="480" @expand-change="onPosExpand"> <el-table :data="heldPositions" size="small" border max-height="480" @expand-change="onPosExpand">
<el-table-column type="expand" width="40"> <el-table-column type="expand" width="40">
<template #default="s"> <template #default="s">
@ -851,6 +853,18 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-table-column label="现价 / 成本" width="96"> <el-table-column label="现价 / 成本" width="96">
<template #default="s">{{ s.row.price }}<span v-if="!s.row.price_ok" class="muted"></span><br><span class="muted">{{ s.row.avg_cost||'—' }}</span></template> <template #default="s">{{ s.row.price }}<span v-if="!s.row.price_ok" class="muted"></span><br><span class="muted">{{ s.row.avg_cost||'—' }}</span></template>
</el-table-column> </el-table-column>
<!-- 我设的止损价与目标价: 读命令表带出来的 user_stop_price / user_target_price,
不是持仓行上的 stop_ref。stop_ref 每天早上会被系统重算的参考位整列盖掉,
拿它显示会让人以为自己设的价没生效。 -->
<el-table-column label="我设的价" width="118">
<template #default="s">
<span class="muted">止损 </span><span v-if="s.row.user_stop_price!=null">{{ s.row.user_stop_price }}</span><span
v-else class="muted">未设</span>
<br>
<span class="muted">目标 </span><span v-if="s.row.user_target_price!=null">{{ s.row.user_target_price }}</span><span
v-else class="muted">未设</span>
</template>
</el-table-column>
<el-table-column label="浮动盈亏" width="76"> <el-table-column label="浮动盈亏" width="76">
<template #default="s"> <template #default="s">
<span v-if="s.row.price_ok && s.row.avg_cost" :class="(s.row.price/s.row.avg_cost-1)>=0?'clr-up':'clr-down'">{{ pct(s.row.price/s.row.avg_cost-1) }}</span> <span v-if="s.row.price_ok && s.row.avg_cost" :class="(s.row.price/s.row.avg_cost-1)>=0?'clr-up':'clr-down'">{{ pct(s.row.price/s.row.avg_cost-1) }}</span>
@ -1537,9 +1551,19 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<template #default="s">{{ pct(s.row.pct_of_scale) }}</template></el-table-column> <template #default="s">{{ pct(s.row.pct_of_scale) }}</template></el-table-column>
<el-table-column label="市值" width="110"> <el-table-column label="市值" width="110">
<template #default="s">{{ money(s.row.market_value) }}</template></el-table-column> <template #default="s">{{ money(s.row.market_value) }}</template></el-table-column>
<el-table-column label="参考位(支/压/止损)" width="180"> <!-- 参考位是系统每天早上盘前重算并整列重写的数, 来源逐行标出来 (原来直接印
bionic / self_calc 这种内部值, 看不出是谁给的)。用户自己设的止损价与目标价
不在这一列, 在右边「我设的」那一列 —— 两个数不是一回事, 分开摆。 -->
<el-table-column label="参考位·系统算的(支/压/止损)" width="220">
<template #default="s"><span class="mono nowrap">{{ s.row.support_ref||'-' }}/{{ s.row.pressure_ref||'-' }}/{{ s.row.stop_ref||'-' }}</span> <template #default="s"><span class="mono nowrap">{{ s.row.support_ref||'-' }}/{{ s.row.pressure_ref||'-' }}/{{ s.row.stop_ref||'-' }}</span>
<span class="muted"> {{ s.row.ref_source||'' }}</span></template> <br><span class="muted">{{ tx('refSource', s.row.ref_source) }}</span></template>
</el-table-column>
<el-table-column label="我设的(止损/目标)" width="150">
<template #default="s">
<span class="mono nowrap"
v-if="s.row.user_stop_price!=null || s.row.user_target_price!=null">{{ s.row.user_stop_price!=null ? s.row.user_stop_price : '-' }}/{{ s.row.user_target_price!=null ? s.row.user_target_price : '-' }}</span>
<span v-else class="muted">未设</span>
</template>
</el-table-column> </el-table-column>
<el-table-column label="做T" width="80"> <el-table-column label="做T" width="80">
<template #default="s"> <template #default="s">
@ -2188,6 +2212,10 @@ createApp({
CONFIRMED:'已成交', REJECTED:'被挡下', EXPIRED:'到期作废', CANCELLED:'已撤销' }, CONFIRMED:'已成交', REJECTED:'被挡下', EXPIRED:'到期作废', CANCELLED:'已撤销' },
arbiter: { rule:'合规检查', judge:'决策系统', user:'你' }, arbiter: { rule:'合规检查', judge:'决策系统', user:'你' },
verdict: { PASS:'放行', REJECT:'驳回', NOTE:'记一笔', UNAVAILABLE:'决策系统现在读不到' }, verdict: { PASS:'放行', REJECT:'驳回', NOTE:'记一笔', UNAVAILABLE:'决策系统现在读不到' },
// 参考位这一列的数是谁给的。系统每个交易日盘前重算一遍并把整列重写, 所以它基本上
// 永远是系统口径; 用户设的止损价只在下达当天投影到这一列上, 第二天早上就换回系统值了。
// 用户自己设的两个价另有「我设的」一列, 那一列读命令表, 不会被盖。
refSource: { bionic:'决策系统给的', self_calc:'系统自算', user:'你今天设的', none:'取不到' },
stype: { T0:'做T', GRID:'网格', TRAIL:'跟踪止盈' }, stype: { T0:'做T', GRID:'网格', TRAIL:'跟踪止盈' },
stratStatus: { ACTIVE:'运行中', PAUSED:'已暂停', CANCELLED:'已撤下', DONE:'已完成' }, stratStatus: { ACTIVE:'运行中', PAUSED:'已暂停', CANCELLED:'已撤下', DONE:'已完成' },
}; };
@ -2390,14 +2418,19 @@ createApp({
+ '(占规模 ' + curPct + ')。减到目标仓位(占总规模,%', '减仓 ' + nm(row.ts_code)); + '(占规模 ' + curPct + ')。减到目标仓位(占总规模,%', '减仓 ' + nm(row.ts_code));
if (v != null) quickCmd('REDUCE_STOCK', { ts_code: row.ts_code, target_pct: v + '%' }); 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) { 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)); + '真要卖请下清仓或减持命令。填多少元?', '设止损 ' + nm(row.ts_code));
if (v != null) quickCmd('SET_STOP_PRICE', { ts_code: row.ts_code, price: v }); if (v != null) quickCmd('SET_STOP_PRICE', { ts_code: row.ts_code, price: v });
} }
async function actTargetPrice(row) { 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)); + '系统不自动卖。不想要了就再设一次、填 0 即取消。', '设目标价 ' + nm(row.ts_code));
if (v != null) quickCmd('SET_TARGET_PRICE', { ts_code: row.ts_code, price: v }); if (v != null) quickCmd('SET_TARGET_PRICE', { ts_code: row.ts_code, price: v });

View File

@ -51,8 +51,10 @@
强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱 强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 ) 来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) + test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) (69 ) 目标价到价必定入队 (档位 full 也不自动卖) +
649 用户设的止损价与目标价单独成列显示 (70 )
651
(上面这个总数此前写的 649 是笔误: 各批例数加起来一直是 650, 本次 +1 例后是 651)
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):

View File

@ -1351,6 +1351,48 @@ def _():
assert caps["cash_source"] == "ws" and caps["cash_avail"] == t["cash_avail"], caps 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 为准建账 (表空集不再当事实)") @case("对账事实源·ws 快照新鲜 → 以 ws 为准建账 (表空集不再当事实)")
def _(): def _():
from app.services import ledger_service as ls from app.services import ledger_service as ls