diff --git a/DEVLOG.md b/DEVLOG.md
index 843bf9a..0b6cfc7 100644
--- a/DEVLOG.md
+++ b/DEVLOG.md
@@ -2016,3 +2016,16 @@ bafecb7 经 45 策略用例、44 批本地及候选镜像禁网回归通过,11
四个新增反例在旧实现失败,修复后30专项及44批本地隔离禁网回归通过,Node交互替身通过。部署与真机状态待本节后续补记。只部署155模拟仓,188不动。未改交易时段、准入参数、数量算法或研判结论。
- 14:45部署补记:60de23e在14:39:51于155统一重建,镜像befb6246、指纹3777e125a88a。候选镜像44批禁网回归通过。14:40正常扫描撤下已保存明确拒绝,账本6858唯一、零目标指令,14:45复查无重复。浏览器待确认0条、无错误;未代点采纳。15参数及账务保持,对账正常。24条历史指令仅一条正常WAIT时刻前进一分钟,数量和修复审计不变。188未操作。完整备份与回退见提议采纳失败排查14:45节及四仓交接。
+
+
+## 2026-10-19 买卖方向冲突修复(台账076)
+
+**做了什么。** 科士达旧建仓与风控清仓同时保持活跃。统一在执行前停止清仓票的旧买入,普通减仓期间暂停买入。停止事实留在追加账本,旧进度回写不能复活指令。出口先撤后发且发送前核对。原始audit_reason补回,页面按实际委托状态显示。
+
+**动了哪些文件。** execution_conflicts服务、executor、dispatcher、proposal_service、strategy_service、signal_rules、两仓储、ws runner、web与页面。增加batch41并登记测试与替身。
+
+**部署方式。** 本地22项专项与45批禁网回归通过。155需候选镜像断网验证后统一重建web、worker、beat、ws。当前此节尚未部署;后续追加真实读数。
+
+**真机判收。** 修复前14:10仅科士达存在买卖重叠。已保存指令、成交、账务与参数原值,详见规格。未改成交,不恢复七条历史暂停指令,不操作188。
+
+**还欠什么。** 候选镜像测试、部署、生效与页面核验,按真实证据追加。
diff --git a/app/core/signal_rules.py b/app/core/signal_rules.py
index 1896b2a..dc19864 100644
--- a/app/core/signal_rules.py
+++ b/app/core/signal_rules.py
@@ -113,7 +113,7 @@ def parse_intraday(fields: dict, *, msg_id: str = None) -> dict:
"confidence": _norm_conf(f.get("confidence")),
"minute_qrs": _num(scores.get("minute_qrs")),
"suggested_price": _num(f.get("suggested_price")) or None,
- "reason": f.get("reason") or f.get("llm_reason") or "",
+ "reason": f.get("reason") or f.get("llm_reason") or f.get("audit_reason") or scores.get("reason") or "",
"dominant_signal": f.get("dominant_signal") or "",
# 发送方标识 (2026-09-03 起保留): 缺就是 unknown, 不猜是谁发的
"producer_id": str(f.get("producer_id") or "").strip() or PRODUCER_UNKNOWN}
diff --git a/app/repo/pms_repo.py b/app/repo/pms_repo.py
index 7533e18..ebf07be 100644
--- a/app/repo/pms_repo.py
+++ b/app/repo/pms_repo.py
@@ -439,6 +439,13 @@ def list_instructions(*, statuses=None, side=None, ts_code=None, limit: int = 30
return rows
+def instruction_buy_stop(instruction_id: str):
+ # 停止依据留在只追加的动作账本,避免并发的进度更新覆盖 JSON 标记。
+ return fetch_one("SELECT id, reason, ref_id FROM pms_action_ledger "
+ "WHERE ref_id = :iid AND action = 'BUY_STOP' AND verdict = 'PASS' "
+ "ORDER BY id DESC LIMIT 1", {'iid': instruction_id})
+
+
def get_instruction(instruction_id: str):
r = fetch_one("SELECT * FROM pms_instruction WHERE instruction_id = :iid",
{"iid": instruction_id})
@@ -465,7 +472,9 @@ def update_instruction(instruction_id: str, *, status=None, exec_qty=None, exec_
if progress is not None:
sets.append("progress_json = :pg")
p["pg"] = _dumps(progress)
- return execute(f"UPDATE pms_instruction SET {', '.join(sets)} WHERE instruction_id = :iid", p)
+ guard = " AND status IN ('PROPOSED','RULE_PASSED','JUDGE_PASSED','DISPATCHED')" if status == 'DISPATCHED' else ''
+ # 下发后的旧进度回写不能把已撤销指令复活。真实迟到成交仍由成交入口记账。
+ return execute(f"UPDATE pms_instruction SET {', '.join(sets)} WHERE instruction_id = :iid" + guard, p)
def add_instruction_exec(instruction_id: str, qty: int) -> int:
diff --git a/app/repo/qmt_repo.py b/app/repo/qmt_repo.py
index 3ee71a6..bd6be52 100644
--- a/app/repo/qmt_repo.py
+++ b/app/repo/qmt_repo.py
@@ -389,6 +389,10 @@ def list_orders(*, statuses=None, parent_id=None, ts_code=None, limit: int = 200
return fetch_all(sql, p)
+def orders_for_parent(parent_id: str) -> list:
+ return fetch_all('SELECT * FROM pms_qmt_order WHERE parent_id = :pid ORDER BY id', {'pid': parent_id})
+
+
def next_queued(limit: int = 20, side=None) -> list:
"""取待发委托。按 id 升序 = 先进先发, 保证同一只票的分笔不乱序 (§1「不开第二条连接」
是为了避免乱序, 出口这一端也得守住)。"""
@@ -408,7 +412,7 @@ def claim_order(instruction_id: str) -> bool:
而不是靠"我们约定只起一个"。
"""
n = execute("UPDATE pms_qmt_order SET status = 'SENDING', updated_at = :ts "
- "WHERE instruction_id = :iid AND status = 'QUEUED'",
+ "WHERE instruction_id = :iid AND status = 'QUEUED' AND cancel_state = 'NONE'",
{"iid": instruction_id, "ts": _NOW()})
return bool(n)
diff --git a/app/services/dispatcher.py b/app/services/dispatcher.py
index 744f9b6..42a0651 100644
--- a/app/services/dispatcher.py
+++ b/app/services/dispatcher.py
@@ -140,6 +140,17 @@ def dispatch(*, instruction_id: str, ts_code: str, side: str, qty: int, limit_pr
"""递一手给下游。返回 {ok, ref, mode, note, error}; ok=False 时 executor 不改指令状态。"""
m = mode()
try:
+ if str(side).lower() == 'buy':
+ from app.services.execution_conflicts import buy_blocker
+ from app.repo import pms_repo
+ pid = _parent_of(instruction_id, parent_id)
+ parent = pms_repo.get_instruction(pid)
+ stopped = pms_repo.instruction_buy_stop(pid)
+ blocked = ('清仓已终止原买入安排' if stopped or ((parent or {}).get('progress') or {}).get('stopped_by_exit') else buy_blocker(ts_code))
+ if parent and parent.get('status') not in ('PROPOSED', 'RULE_PASSED', 'JUDGE_PASSED', 'DISPATCHED'):
+ blocked = '来源买入指令已结束,不再发单'
+ if blocked:
+ return {'ok': False, 'ref': None, 'mode': m, 'note': '', 'error': blocked}
if m == MODE_WS:
return _ws(instruction_id=instruction_id, ts_code=ts_code, side=side, qty=qty,
limit_price=limit_price, valid_until=valid_until,
diff --git a/app/services/execution_conflicts.py b/app/services/execution_conflicts.py
new file mode 100644
index 0000000..f094671
--- /dev/null
+++ b/app/services/execution_conflicts.py
@@ -0,0 +1,108 @@
+"""统一执行方向检查。只消解既有指令冲突,不重做研究或择时。"""
+from app.repo import pms_repo, qmt_repo
+
+LIVE = ('PROPOSED', 'RULE_PASSED', 'JUDGE_PASSED', 'DISPATCHED')
+BUY_ACTIONS = ('OPEN', 'FILL', 'ADD', 'DCA')
+
+
+def buy_blocker(ts_code):
+ # 查询失败必须抛给调用者,不能当作无冲突。按票查,不受全局列表裁剪影响。
+ rows = pms_repo.list_instructions(statuses=list(LIVE), side='sell', ts_code=ts_code, limit=500)
+ for row in rows:
+ if row.get('action') in ('EXIT', 'TRIM') and int(row.get('qty') or 0) > int(row.get('exec_qty') or 0):
+ verb = '清仓' if row['action'] == 'EXIT' else '减仓'
+ return f'该股正在{verb},暂停买入'
+ return None
+
+
+def queued_buy_blocker(row):
+ """发出前再次检查。异常由通道保留队列等待,不按无冲突继续。"""
+ if row.get('side') != 'buy':
+ return None
+ parent = pms_repo.get_instruction(row.get('parent_id'))
+ if not parent:
+ raise RuntimeError('买入来源指令无法核实,暂不发送')
+ if pms_repo.instruction_buy_stop(row.get('parent_id')) or (parent.get('progress') or {}).get('stopped_by_exit'):
+ return '清仓已终止原买入安排'
+ if parent.get('status') not in LIVE:
+ return '来源买入指令已结束,未发出的委托作废'
+ return buy_blocker(row['ts_code'])
+
+
+def enforce_exit(ins):
+ """完整清仓停止旧买入。每跳重试,撤单失败不会阻断风控卖出。"""
+ out = {'ok': True, 'instructions': [], 'plans': [], 'errors': []}
+ if ins.get('action') != 'EXIT' or ins.get('side') != 'sell':
+ return out
+ from app.services import strategy_service
+ why = '清仓指令已生效,停止该股原有买入安排'
+ # 先持久化停止依据。即使下游撤单暂时失败、清仓随后成交,也不能恢复原买入。
+ buys = pms_repo.list_instructions(statuses=list(LIVE), side='buy', ts_code=ins['ts_code'], limit=500)
+ for buy in buys:
+ if not pms_repo.instruction_buy_stop(buy['instruction_id']):
+ if not pms_repo.insert_ledger(ts_code=ins['ts_code'], action='BUY_STOP', arbiter='system',
+ verdict='PASS', price_at=0, ref_id=buy['instruction_id'], reason=why,
+ hard_numbers={'exit_instruction_id': ins['instruction_id'],
+ 'qty': buy.get('qty'), 'exec_qty': buy.get('exec_qty')}):
+ raise RuntimeError('清仓停止依据未写入动作账本')
+ progress = dict(buy.get('progress') or {})
+ if not progress.get('stopped_by_exit'):
+ progress['stopped_by_exit'] = ins['instruction_id']
+ progress['last_decision'] = {'action': 'WAIT', 'reason': why}
+ if not pms_repo.update_instruction(buy['instruction_id'], progress=progress):
+ raise RuntimeError('清仓停止买入依据未写入')
+ result = strategy_service.cancel_by_code(ins['ts_code'], reason=why)
+ out.update(result)
+ # 未解锁与尚未转指令的命令批也要收尾,否则下一跳又生出买单。
+ try:
+ plans = pms_repo.list_plans(ts_code=ins['ts_code'], statuses=['PENDING', 'GATED', 'EXECUTING'])
+ for plan in plans:
+ if plan.get('action') not in BUY_ACTIONS:
+ continue
+ pms_repo.insert_ledger(ts_code=ins['ts_code'], action='CLEANUP', arbiter='system',
+ verdict='PASS', price_at=0, ref_id=plan['plan_id'],
+ reason=why, hard_numbers={'exit_instruction_id': ins['instruction_id']})
+ if not pms_repo.update_plan(plan['plan_id'], status='CANCELLED'):
+ raise RuntimeError('停止买入批次未写入')
+ out['plans'].append(plan['plan_id'])
+ except Exception as exc:
+ out['ok'] = False
+ out['errors'].append(str(exc))
+ return out
+
+
+def instruction_progress(rows):
+ """只读展示。父指令发过单,不代表当前还有未成交委托。"""
+ from app.services import dispatcher
+ ws = dispatcher.mode() == 'ws'
+ for row in rows:
+ if row.get('status') not in LIVE and row.get('status') != 'CANCELLED':
+ continue
+ if not ws:
+ row['execution_label'] = '影子执行待核对'
+ continue
+ try:
+ orders = qmt_repo.orders_for_parent(row['instruction_id'])
+ active = [o for o in orders if o.get('status') in qmt_repo.LIVE]
+ if any(o.get('cancel_state') in ('REQUESTED', 'SENT') for o in active):
+ label = '撤单处理中'
+ elif any(o.get('status') not in ('QUEUED', 'SENDING') for o in active):
+ label = '委托待成交'
+ elif active:
+ label = '委托待发送'
+ else:
+ reason = ((row.get('progress') or {}).get('last_decision') or {}).get('reason') or ''
+ if row.get('status') == 'CANCELLED':
+ label = '已撤销'
+ elif '可卖量不足' in reason:
+ label = '等待可卖'
+ elif '当日配额' in reason:
+ label = '等待下一批'
+ elif '暂停买入' in reason:
+ label = '买入已暂停'
+ else:
+ label = '等待执行'
+ row['execution_label'] = label
+ except Exception:
+ row['execution_label'] = '委托状态待核实'
+ return rows
diff --git a/app/services/executor.py b/app/services/executor.py
index 6f5da5f..162419d 100644
--- a/app/services/executor.py
+++ b/app/services/executor.py
@@ -188,6 +188,29 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict:
is_last = tdays_left <= 1
try:
+ from app.services import execution_conflicts
+ if ins.get('action') == 'EXIT' and side == 'sell' and not dry_run:
+ cleanup = execution_conflicts.enforce_exit(ins)
+ out['errors'].extend(cleanup.get('errors') or [])
+ if side == 'buy':
+ # 上面的清仓可能已撤销本轮列表中的旧买入,不能拿旧列表继续发。
+ fresh = pms_repo.get_instruction(ins['instruction_id'])
+ if not fresh or fresh.get('status') not in LIVE:
+ continue
+ prog = dict(fresh.get('progress') or {})
+ if pms_repo.instruction_buy_stop(ins['instruction_id']) or prog.get('stopped_by_exit'):
+ if not dry_run:
+ cancelled = cancel_instruction(ins['instruction_id'], reason='清仓已终止原买入安排', arbiter='system')
+ if not cancelled.get('ok'):
+ out['errors'].append(cancelled.get('message') or '清仓停止买入尚未完成')
+ continue
+ blocked = execution_conflicts.buy_blocker(code)
+ if blocked:
+ prog['last_decision'] = {'at': now.strftime('%H:%M'), 'action': 'WAIT', 'reason': blocked}
+ if not dry_run:
+ pms_repo.update_instruction(ins['instruction_id'], progress=prog)
+ out['waited'].append({'instruction_id': ins['instruction_id'], 'code': code, 'reason': blocked})
+ continue
day = market.day_snapshot(code)
pos = _pos_of(view, code)
quota = et.daily_quota(remaining, tdays_left, lot=lot_of(code),
@@ -427,7 +450,7 @@ def sweep_windows(*, now=None) -> dict:
return out
-def cancel_instruction(instruction_id: str, reason: str = "页面人工撤销") -> dict:
+def cancel_instruction(instruction_id: str, reason: str = "页面人工撤销", *, arbiter="user") -> dict:
ins = pms_repo.get_instruction(instruction_id)
if not ins:
return {"ok": False, "error": "指令不存在"}
@@ -453,7 +476,7 @@ def cancel_instruction(instruction_id: str, reason: str = "页面人工撤销")
except Exception as e:
logger.warning("撤销指令进度回写失败 %s: %s", instruction_id, e)
pms_repo.update_instruction(instruction_id, status=ST_CANCELLED)
- pms_repo.insert_ledger(ts_code=ins["ts_code"], action=ins.get("action"), arbiter="user",
+ pms_repo.insert_ledger(ts_code=ins["ts_code"], action=ins.get("action"), arbiter=arbiter,
verdict="REJECT", price_at=0, ref_id=instruction_id, reason=reason,
hard_numbers=(ins.get('progress') or {}).get('plan') or {})
return {"ok": True, "downstream": r, "message": f"指令 {instruction_id} 已撤销"}
diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py
index 009c039..644bb17 100644
--- a/app/services/proposal_service.py
+++ b/app/services/proposal_service.py
@@ -1637,6 +1637,8 @@ def _inflight_keys() -> dict:
if action in ae.SELL_SIDE_ACTIONS:
for other in ae.SELL_SIDE_ACTIONS:
keys.setdefault((code, other), why + " (同票另一种减持一并让路)")
+ for buy in ('OPEN', 'FILL', 'ADD', 'DCA'):
+ keys.setdefault((code, buy), '该股有减持安排待处理,暂不新增买入提议')
try:
for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200):
_mark(p["ts_code"], p["action"], f"已有在途提议 {p.get('proposal_id')} 在等人确认")
diff --git a/app/services/strategy_service.py b/app/services/strategy_service.py
index e911f46..d1382fc 100644
--- a/app/services/strategy_service.py
+++ b/app/services/strategy_service.py
@@ -180,7 +180,8 @@ def cancel_by_code(ts_code: str, reason: str = "") -> dict:
from app.services import command_service, executor
try:
props = [p for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=500)
- if p.get("ts_code") == ts_code and p.get("action") in command_service.BUILD_ACTIONS]
+ if p.get("ts_code") == ts_code and (p.get("action") in command_service.BUILD_ACTIONS
+ or (p.get('hard_numbers') or {}).get('side') == 'buy')]
except Exception as e: # noqa: BLE001
props = []
out["ok"] = False
@@ -189,13 +190,15 @@ def cancel_by_code(ts_code: str, reason: str = "") -> dict:
try:
if pms_repo.decide_proposal(p["proposal_id"], "DECLINED"):
out["proposals"].append(p["proposal_id"])
+ pms_repo.insert_ledger(ts_code=ts_code, action=p.get('action'), arbiter='system',
+ verdict='REJECT', price_at=0, ref_id=p['proposal_id'], reason=why)
except Exception as e: # noqa: BLE001
out["ok"] = False
out["errors"].append(f"驳回买入提议 {p.get('proposal_id')} 失败: {type(e).__name__}: {e}")
# 三, 撤该票当前在途的买入指令 (已下发到券商的网格/补仓买单, 撤了方案它还挂着)
try:
live_buys = [i for i in pms_repo.list_instructions(
- statuses=list(command_service.LIVE_INSTR), side="buy", limit=500)
+ statuses=list(command_service.LIVE_INSTR), side="buy", ts_code=ts_code, limit=500)
if i.get("ts_code") == ts_code]
except Exception as e: # noqa: BLE001
live_buys = []
@@ -204,7 +207,7 @@ def cancel_by_code(ts_code: str, reason: str = "") -> dict:
for i in live_buys:
iid = i.get("instruction_id")
try:
- r = executor.cancel_instruction(iid, reason=why) or {}
+ r = executor.cancel_instruction(iid, reason=why, arbiter="system") or {}
if r.get("ok"):
out["instructions"].append(iid)
else:
diff --git a/app/web/main.py b/app/web/main.py
index 584fef4..50e5493 100644
--- a/app/web/main.py
+++ b/app/web/main.py
@@ -419,8 +419,11 @@ def api_lots(ts_code: str, status: str = Query("OPEN")):
def api_instructions(status: str = Query(None), limit: int = 200,
include_archived: bool = Query(False)):
statuses = [s for s in (status or "").split(",") if s] or None
- return ok(pms_repo.list_instructions, statuses=statuses, limit=limit,
- include_archived=include_archived)
+ def _list():
+ from app.services.execution_conflicts import instruction_progress
+ return instruction_progress(pms_repo.list_instructions(statuses=statuses, limit=limit,
+ include_archived=include_archived))
+ return ok(_list)
@app.get("/api/ledger")
@@ -523,6 +526,14 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default=
except Exception as e: # noqa: BLE001
logger.warning("[提议] 过期清扫失败: %s", e)
return {"ok": False, "error": "提议已过失效时刻, 不能再裁决; 该票由系统重新评估"}
+ if decision == 'ACCEPTED':
+ from app.services.execution_conflicts import buy_blocker
+ hn0 = p.get('hard_numbers') or {}
+ side0 = hn0.get('side') if hn0.get('kind') == 'strategy' else ('sell' if p['action'] in ('TRIM', 'EXIT') else 'buy')
+ if side0 == 'buy':
+ blocked = buy_blocker(p['ts_code'])
+ if blocked:
+ return {'ok': False, 'error': blocked, 'proposal_id': proposal_id}
expected_hard = None
if (decision == 'ACCEPTED' and p['action'] == ae.A_OPEN
and param_store.get_bool('PMS_PLAN_VERSION_RECHECK', False)):
diff --git a/app/web/static/index.html b/app/web/static/index.html
index 2c9a925..76e7f91 100644
--- a/app/web/static/index.html
+++ b/app/web/static/index.html
@@ -1144,7 +1144,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
{{ tx('action', s.row.action) }} · {{ tx('side', s.row.side) }}
{{ s.row.qty }} / {{ s.row.exec_qty||0 }}
{{ s.row.limit_price||'—' }}
- {{ tx('insStatus', s.row.status) }}
+ {{ s.row.execution_label || tx('insStatus', s.row.status) }}
@@ -1154,7 +1154,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
{{ nm(s.row.ts_code) }} {{ s.row.ts_code }}
{{ tx('action', s.row.action) }} · {{ tx('side', s.row.side) }}
{{ s.row.qty }} / {{ s.row.exec_qty||0 }}
- {{ tx('insStatus', s.row.status) }}
+ {{ s.row.execution_label || tx('insStatus', s.row.status) }}
@@ -1162,7 +1162,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
{{ nm(s.row.ts_code) }} {{ s.row.ts_code }}
{{ tx('action', s.row.action) }} · {{ tx('side', s.row.side) }}
- {{ tx('insStatus', s.row.status) }}
+ {{ s.row.execution_label || tx('insStatus', s.row.status) }}
@@ -2865,7 +2865,7 @@ createApp({
const T = {
// 账本的动作有十六个:八个真动作(planner.py:31 那批常量)加八个伪动作
// (账本里直写的字面量,例如对账修正、信号留痕)。少一个就会在页面上露原码。
- action: { OPEN:'建新仓', OPEN_REASK:'重问研判', FILL:'回落补足', ADD:'盈利加仓', DCA:'浮亏补仓', TRIM:'保盈减仓',
+ action: { BUY_STOP:'停止买入', OPEN:'建新仓', OPEN_REASK:'重问研判', FILL:'回落补足', ADD:'盈利加仓', DCA:'浮亏补仓', TRIM:'保盈减仓',
EXIT:'清仓', T0:'做T', T0_ROUND:'做T', SWITCH:'换仓', HALT:'停手',
RECON:'对账修正', CLEANUP:'收尾清理', MACRO:'大盘冷热',
SIGNAL:'信号留痕', SIGNAL_BUY:'买入信号',
@@ -2879,7 +2879,7 @@ createApp({
lot: { BASE:'首批', FILL:'回落补足', ADD:'盈利加仓', DCA:'浮亏补仓', T0:'做T仓', RECON:'对账修正' },
frozen: { NONE:'', COMMAND_HALT:'命令要求只卖不买', BRAKE:'组合刹车,只卖不买', MANUAL:'人工冻结,只卖不买' },
posStatus: { PLANNED:'计划建仓', OPENING:'建仓中', HOLDING:'持有中', EXITING:'清仓中', CLOSED:'已清仓' },
- insStatus: { PROPOSED:'待校验', RULE_PASSED:'合规检查已过', JUDGE_PASSED:'决策系统复核已过', DISPATCHED:'已挂单待成交',
+ insStatus: { PROPOSED:'待校验', RULE_PASSED:'合规检查已过', JUDGE_PASSED:'决策系统复核已过', DISPATCHED:'执行中',
CONFIRMED:'已成交', REJECTED:'已拦截', EXPIRED:'到期作废', CANCELLED:'已撤销' },
arbiter: { rule:'合规检查', judge:'决策系统', user:'人工', system:'系统自动' },
// 这一组是**评审账本**的结论,只有四个值。研判闸的结论是另一组 judgeVerdict,
diff --git a/app/ws/runner.py b/app/ws/runner.py
index cc61e32..206c95d 100644
--- a/app/ws/runner.py
+++ b/app/ws/runner.py
@@ -843,8 +843,8 @@ class WsRunner:
"""
while not self._stop.is_set():
try:
- await self._drain_outbox(seed)
await self._drain_cancels(seed)
+ await self._drain_outbox(seed)
except asyncio.CancelledError:
raise
except Exception as e:
@@ -871,6 +871,22 @@ class WsRunner:
if not await _db(qmt_repo.claim_order, iid):
continue # 被别的进程抢走了 (滚动重启的瞬间)
try:
+ # 排队后可能发生清仓或撤单。认领后再核对,未知状态保留重试。
+ fresh = await _db(qmt_repo.get_order, iid)
+ if not fresh or fresh.get('cancel_state') in ('REQUESTED', 'SENT'):
+ await _db(qmt_repo.abort_order, iid, '发出前撤销')
+ continue
+ from app.services.execution_conflicts import queued_buy_blocker
+ try:
+ blocked = await _db(queued_buy_blocker, fresh)
+ except Exception as exc:
+ await _db(qmt_repo.requeue_order, iid, f'买入依据暂不可核实: {exc}',
+ self._p('max_attempts', 3), False)
+ logger.warning('[outbox] %s 买入依据暂不可核实,保留队列', iid)
+ continue
+ if blocked:
+ await _db(qmt_repo.abort_order, iid, blocked)
+ continue
payload = wsc.place_order_payload(
instruction_id=iid, ts_code=r["ts_code"], side=r["side"],
qty=int(r["qty"]), limit_price=r["limit_price"],
diff --git a/docs/开发规格_买卖方向冲突修复_2026-10-19.md b/docs/开发规格_买卖方向冲突修复_2026-10-19.md
new file mode 100644
index 0000000..559893e
--- /dev/null
+++ b/docs/开发规格_买卖方向冲突修复_2026-10-19.md
@@ -0,0 +1,48 @@
+# 买卖方向冲突修复开发规格
+
+2026-10-19,北京时间。用户已授权修复并检查其他流程。
+
+## 目标与已核实反例
+
+科士达旧建仓目标600股,累计买入400股。今天09:51风控清仓400股,卖出200股。
+旧建仓仍活跃,剩余200股可能隔日再买。实际子委托均已成交,页面却写已挂单。
+原始卖出理由在audit_reason及component_scores.reason,PMS解析遗漏。
+证据保存在155 ~/pms_logs/kstar-conflict-20261019-1402.json及kstar-signal-20261019.json。
+
+## 实现与边界
+
+1. 停止依据追加到动作账本,不依赖可被并发覆盖的进度JSON。下发回写不得复活终态指令。
+ 全部完整清仓指令在执行前统一停买入侧。复用既有策略、提议、指令撤销路径。
+ 覆盖信号自动清仓、命令清仓、人工采纳、技术面离场及跟踪止盈。
+ 每跳重试未完成收尾,也覆盖上线前遗留的清仓指令。不修改已成交数量、成本或盈亏。
+2. 同票有活跃EXIT或TRIM时,买入发出前再检查。查询失败停止买入。
+ 普通减仓只暂缓买入,不永久撤销建仓或策略。做T自身T0_ROUND不当清仓。
+ 完整清仓期间,做T买回也不能抵消清仓;没有清仓时原有配对流程不变。
+3. 待确认卖出仅阻止新的自主增持提议,不自动撤销已有买单。
+ 用户采纳买入前核对已生效的卖出指令,冲突时保留提议和说明,不写已采纳。
+4. 出口队列先处理撤单,认领时排除待撤单。发送前重查买入冲突及父指令状态。
+ 尚未发出且已失效的买单本地作废,已经发出的买单走撤单回执。
+ 撤单受理不冒充成交撤回,迟到成交继续按已有逐笔路径记账。
+5. 页面读取实际子委托状态。没有活跃子单时显示等待下一批、等待可卖或执行等待。
+ 技术单号只在详情出现。卖出理由采用原文,不新调用模型。
+
+## 预判与验证
+
+先建立科士达跨日反例,再验证各入口共用的执行终检。
+覆盖部分成交、T+1、普通减仓、T0配对、撤单失败重试、排队后清仓、查询失败、重复执行。
+页面验证已发出且终态的子单不能显示为挂单,取数失败明确为状态待核实。
+全部测试使用配置隔离与禁网入口。运行三页面守卫。
+155部署前保存参数、账务、指令与镜像证据。只修模拟实例,188不动。
+上线后先查自然调度清理旧建仓,保留真实400股买入与200股卖出。
+不采纳提议、不制造成交,不改交易时段或业务阈值。
+
+## 回退
+
+保留成交与撤销审计。155挂载/app,须反向提交、git pull后重建四容器。
+回退不恢复已经撤销的买入指令。撤销前保存旧镜像与证据。
+
+## 本地验证记录
+
+22项专项回归通过,覆盖五类清仓入口共用执行器、撤单失败与旧进度覆盖、T0不误挡、人工确认被拦时保持待确认。
+45批配置隔离、禁止联网的回归通过,包含三页面守卫。生产状态另记部署判收。
+原临时虚拟环境被系统清理,本轮另建 /private/tmp/pms-conflict-20261019-venv。
diff --git a/scripts/run_tests.py b/scripts/run_tests.py
index 0647472..56b24d6 100644
--- a/scripts/run_tests.py
+++ b/scripts/run_tests.py
@@ -5,6 +5,7 @@
运行: docker compose run --rm pms-web python scripts/run_tests.py
包含 (例数按 2026-10-12 隔离回归实跑校正):
+ test_batch41_units.py 跨流程买卖互斥、清仓收尾、出口撤单与真实进展 (22 例)
test_batch40_units.py 逐笔历史修复、进度暂停、并发与事务回滚与均价失效 (22 例)
test_batch39_units.py 成交双来源归属、迟到、部分成交与失败守成 (12 例)
test_batch38_units.py 每日计划顺序、版本核验、重新确认、并发采纳、历史保留及采纳失败说明、明确否决撤下 (30 例)
@@ -191,7 +192,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch35_units.py",
"test_batch36_units.py",
"test_batch37_units.py",
- "test_batch38_units.py", "test_batch39_units.py", "test_batch40_units.py",
+ "test_batch38_units.py", "test_batch39_units.py", "test_batch40_units.py", "test_batch41_units.py",
"test_page_copy_guard.py",
"test_page_enum_guard.py",
"test_page_wiring_guard.py",
diff --git a/scripts/test_batch41_units.py b/scripts/test_batch41_units.py
new file mode 100644
index 0000000..cbc6d7c
--- /dev/null
+++ b/scripts/test_batch41_units.py
@@ -0,0 +1,256 @@
+"""本地:python scripts/unit_test_guard.py scripts/test_batch41_units.py。配置隔离、禁止联网。"""
+import unit_test_guard
+import os
+import sys
+import unittest
+from datetime import datetime, timedelta
+from unittest.mock import patch
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from test_wiring import install_fakes
+from app.repo import pms_repo, qmt_repo
+from app.services import executor, dispatcher, proposal_service, signal_service
+from app.core import signal_rules
+
+CODE = '002518.SZ'
+
+def ins(fake, iid, action, side, qty=600, done=0):
+ fake.insert_instruction(instruction_id=iid, origin_type='proposal', origin_id=None,
+ ts_code=CODE, action=action, side=side, qty=qty, status='DISPATCHED',
+ progress={'children': [], 'deadline': '2026-10-21', 'is_command': False})
+ fake.instructions[iid]['exec_qty'] = done
+ return fake.instructions[iid]
+
+class ConflictsTest(unittest.TestCase):
+ def setUp(self):
+ self.fake=install_fakes(params={'PMS_DISPATCH_MODE':'ws'})
+ self.fake.qmt.set_channel()
+
+ def test_old_partial_buy_stopped_before_exit(self):
+ from app.services import execution_conflicts as c
+ buy=ins(self.fake,'old','OPEN','buy',600,400)
+ sell=ins(self.fake,'exit','EXIT','sell',400,200)
+ result=c.enforce_exit(sell)
+ self.assertTrue(result['ok'])
+ self.assertEqual(buy['status'],'CANCELLED')
+ self.assertEqual((buy['qty'],buy['exec_qty']),(600,400))
+ self.assertEqual(sell['exec_qty'],200)
+ n=len(self.fake.ledger)
+ c.enforce_exit(sell)
+ self.assertEqual(len(self.fake.ledger),n)
+
+ def test_trim_pauses_without_cancelling_buy(self):
+ from app.services import execution_conflicts as c
+ buy=ins(self.fake,'old','OPEN','buy')
+ sell=ins(self.fake,'trim','TRIM','sell')
+ self.assertTrue(c.buy_blocker(CODE))
+ c.enforce_exit(sell)
+ self.assertEqual(buy['status'],'DISPATCHED')
+ sell['status']='CONFIRMED'
+ self.assertFalse(c.buy_blocker(CODE))
+
+ def test_t0_pair_is_not_exit(self):
+ from app.services import execution_conflicts as c
+ ins(self.fake,'t0','T0_ROUND','sell')
+ self.assertFalse(c.buy_blocker(CODE))
+
+ def test_dispatch_rejects_buy_with_active_sell(self):
+ ins(self.fake,'exit','EXIT','sell',400)
+ result=dispatcher.dispatch(instruction_id='buy_D01',parent_id='buy',ts_code=CODE,
+ side='buy',qty=200,limit_price=28,valid_until=datetime.now()+timedelta(minutes=10))
+ self.assertFalse(result['ok'])
+ self.assertEqual(self.fake.qmt.orders,{})
+
+ def test_lookup_failure_cannot_dispatch_buy(self):
+ with patch.object(pms_repo,'list_instructions',side_effect=RuntimeError('source down')):
+ r=dispatcher.dispatch(instruction_id='buy_D01',ts_code=CODE,side='buy',qty=200,limit_price=28)
+ self.assertFalse(r['ok'])
+ self.assertEqual(self.fake.qmt.orders,{})
+
+ def test_pending_sell_blocks_new_buy_proposals(self):
+ self.fake.insert_proposal(proposal_id='sell',ts_code=CODE,action='EXIT',qty=200,
+ hard_numbers={},expire_at=datetime.now()+timedelta(hours=1))
+ keys=proposal_service._inflight_keys()
+ for action in ('OPEN','ADD','FILL','DCA'):
+ self.assertIn((CODE,action),keys)
+
+ def test_signal_reason_preserved(self):
+ parsed=signal_rules.parse_intraday({'ts_code':CODE,'action':'SELL','confidence':'0.95',
+ 'audit_reason':'支撑失守,资金流出','component_scores':'{"reason":"另一份理由"}'})
+ self.assertEqual(parsed['reason'],'支撑失守,资金流出')
+ parsed=signal_rules.parse_intraday({'component_scores':'{"reason":"资金流出"}'})
+ self.assertEqual(parsed['reason'],'资金流出')
+
+ def test_cancel_failure_keeps_stop_after_exit_completes(self):
+ from app.services import execution_conflicts as c
+ buy=ins(self.fake,'old','OPEN','buy',600,400)
+ sell=ins(self.fake,'exit','EXIT','sell',400,200)
+ with patch.object(dispatcher,'cancel',return_value={'ok':False,'error':'offline'}):
+ result=c.enforce_exit(sell)
+ self.assertFalse(result['ok'])
+ self.assertEqual(buy['status'],'DISPATCHED')
+ self.assertEqual(buy['progress']['stopped_by_exit'],'exit')
+ sell['status']='CONFIRMED'
+ r=dispatcher.dispatch(instruction_id='old_D03',parent_id='old',ts_code=CODE,
+ side='buy',qty=200,limit_price=28)
+ self.assertFalse(r['ok'])
+ self.assertEqual(self.fake.qmt.orders,{})
+
+ def test_cleanup_cancels_gated_plans_and_strategy_buy_proposal(self):
+ from app.services import execution_conflicts as c
+ self.fake.plans=[{'plan_id':'p1','ts_code':CODE,'action':'ADD','status':'GATED','filled_qty':0}]
+ self.fake.insert_proposal(proposal_id='t0buy',ts_code=CODE,action='T0_ROUND',qty=200,
+ hard_numbers={'kind':'strategy','side':'buy'},expire_at=datetime.now()+timedelta(hours=1))
+ result=c.enforce_exit(ins(self.fake,'exit','EXIT','sell',400))
+ self.assertTrue(result['ok'],result)
+ self.assertEqual(self.fake.plans[0]['status'],'CANCELLED')
+ self.assertEqual(self.fake.proposals['t0buy']['status'],'DECLINED')
+ self.assertTrue(any(x['arbiter']=='system' and x['ref_id']=='t0buy' for x in self.fake.ledger))
+
+ def test_all_exit_origins_share_executor_cleanup(self):
+ from app.services import execution_conflicts as c, portfolio, exec_advisor
+ for origin in ('system','proposal','plan','strategy'):
+ with self.subTest(origin=origin):
+ self.fake.instructions.clear()
+ buy=ins(self.fake,'old','OPEN','buy',600,400)
+ sell=ins(self.fake,'exit','EXIT','sell',400,200)
+ sell['origin_type']=origin
+ with patch.object(portfolio,'positions_view',return_value={'positions':[],'params':{}}), \
+ patch.object(executor.td,'trade_days_left',return_value=2), \
+ patch.object(exec_advisor,'decide',return_value={'action':'WAIT','reason':'等待可卖'}):
+ result=executor.run_tick()
+ self.assertEqual(buy['status'],'CANCELLED',result)
+ self.assertFalse(result['fired'])
+ self.assertEqual(sell['exec_qty'],200)
+
+ def test_dry_run_does_not_cancel(self):
+ from app.services import portfolio, exec_advisor
+ buy=ins(self.fake,'old','OPEN','buy',600,400)
+ ins(self.fake,'exit','EXIT','sell',400,200)
+ with patch.object(portfolio,'positions_view',return_value={'positions':[],'params':{}}), \
+ patch.object(executor.td,'trade_days_left',return_value=2), \
+ patch.object(exec_advisor,'decide',return_value={'action':'WAIT','reason':'等待可卖'}):
+ executor.run_tick(dry_run=True)
+ self.assertEqual(buy['status'],'DISPATCHED')
+ self.assertFalse(buy['progress'].get('stopped_by_exit'))
+ self.assertFalse(self.fake.ledger)
+
+ def test_queued_buy_rechecks_parent_and_exit(self):
+ from app.services import execution_conflicts as c
+ buy=ins(self.fake,'old','OPEN','buy')
+ row={'parent_id':'old','ts_code':CODE,'side':'buy'}
+ self.assertIsNone(c.queued_buy_blocker(row))
+ ins(self.fake,'exit','EXIT','sell')
+ self.assertTrue(c.queued_buy_blocker(row))
+ self.fake.instructions['exit']['status']='CONFIRMED'
+ buy['status']='CANCELLED'
+ self.assertTrue(c.queued_buy_blocker(row))
+ self.assertIsNone(c.queued_buy_blocker(dict(row,side='sell')))
+
+ def test_missing_parent_keeps_queue_unverified(self):
+ from app.services import execution_conflicts as c
+ with self.assertRaises(RuntimeError):
+ c.queued_buy_blocker({'parent_id':'missing','ts_code':CODE,'side':'buy'})
+
+ def test_finished_children_show_wait_instead_of_pending_order(self):
+ from app.services import execution_conflicts as c
+ row=ins(self.fake,'old','OPEN','buy',600,400)
+ row['progress']['last_decision']={'reason':'当日配额已出完'}
+ with patch.object(qmt_repo,'orders_for_parent',return_value=[{'status':'FILLED'}]):
+ self.assertEqual(c.instruction_progress([row])[0]['execution_label'],'等待下一批')
+ row['progress']['last_decision']={'reason':'可卖量不足, 顺延'}
+ with patch.object(qmt_repo,'orders_for_parent',return_value=[]):
+ self.assertEqual(c.instruction_progress([row])[0]['execution_label'],'等待可卖')
+
+ def test_pending_cancel_never_shown_as_finished(self):
+ from app.services import execution_conflicts as c
+ row=ins(self.fake,'old','OPEN','buy');row['status']='CANCELLED'
+ with patch.object(qmt_repo,'orders_for_parent',return_value=[{'status':'PARTIAL','cancel_state':'REQUESTED'}]):
+ self.assertEqual(c.instruction_progress([row])[0]['execution_label'],'撤单处理中')
+ with patch.object(qmt_repo,'orders_for_parent',side_effect=RuntimeError('unavailable')):
+ self.assertEqual(c.instruction_progress([row])[0]['execution_label'],'委托状态待核实')
+
+ def test_other_stock_not_blocked(self):
+ from app.services import execution_conflicts as c
+ ins(self.fake,'exit','EXIT','sell')
+ self.assertIsNone(c.buy_blocker('600000.SH'))
+
+ def test_claim_rejects_cancel_requested_atomically(self):
+ with patch.object(qmt_repo,'execute',return_value=0) as write:
+ self.assertFalse(qmt_repo.claim_order('child'))
+ self.assertIn("cancel_state = 'NONE'",write.call_args.args[0])
+
+ def test_outbox_drops_new_conflict_without_sending(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+ from app.ws.runner import WsRunner
+ buy=ins(self.fake,'old','OPEN','buy')
+ ins(self.fake,'exit','EXIT','sell')
+ row={'instruction_id':'old_D03','parent_id':'old','ts_code':CODE,'side':'buy',
+ 'qty':200,'limit_price':28,'valid_until':int((datetime.now()+timedelta(minutes=10)).timestamp()*1000),
+ 'cancel_state':'NONE','status':'SENDING'}
+ loop=asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ self.addCleanup(loop.close)
+ runner=WsRunner()
+ with patch.object(qmt_repo,'next_queued',return_value=[row]), \
+ patch.object(qmt_repo,'claim_order',return_value=True), \
+ patch.object(qmt_repo,'get_order',return_value=row), \
+ patch.object(qmt_repo,'abort_order') as abort, \
+ patch.object(qmt_repo,'mark_sent'), \
+ patch.object(runner,'_send',new_callable=AsyncMock) as send:
+ asyncio.run(runner._drain_outbox('unused'))
+ abort.assert_called_once()
+ send.assert_not_called()
+
+ def test_outbox_source_failure_preserves_queue(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+ from app.ws.runner import WsRunner
+ row={'instruction_id':'old_D03','parent_id':'old','ts_code':CODE,'side':'buy',
+ 'valid_until':int((datetime.now()+timedelta(minutes=10)).timestamp()*1000), 'cancel_state':'NONE'}
+ loop=asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ self.addCleanup(loop.close)
+ runner=WsRunner()
+ with patch.object(qmt_repo,'next_queued',return_value=[row]), \
+ patch.object(qmt_repo,'claim_order',return_value=True), \
+ patch.object(qmt_repo,'get_order',return_value=row), \
+ patch.object(qmt_repo,'requeue_order') as retry, \
+ patch.object(qmt_repo,'mark_sent'), \
+ patch.object(runner,'_send',new_callable=AsyncMock) as send:
+ asyncio.run(runner._drain_outbox('unused'))
+ retry.assert_called_once()
+ self.assertIs(retry.call_args.args[-1],False)
+ send.assert_not_called()
+
+ def test_stale_progress_cannot_restore_stopped_buy(self):
+ from app.services import execution_conflicts as c
+ buy=ins(self.fake,'old','OPEN','buy',600,400)
+ sell=ins(self.fake,'exit','EXIT','sell',400,200)
+ with patch.object(dispatcher,'cancel',return_value={'ok':False,'error':'offline'}):
+ c.enforce_exit(sell)
+ buy['progress']={'children':[]} # 模拟并发旧进度覆盖,停止事实仍在追加账本中。
+ sell['status']='CONFIRMED'
+ result=dispatcher.dispatch(instruction_id='old_D03',parent_id='old',ts_code=CODE,side='buy',qty=200,limit_price=28)
+ self.assertFalse(result['ok'])
+
+ def test_terminal_parent_cannot_be_revived_by_old_dispatch_write(self):
+ buy=ins(self.fake,'old','OPEN','buy');buy['status']='CANCELLED'
+ self.assertEqual(pms_repo.update_instruction('old',status='DISPATCHED'),0)
+ self.assertEqual(buy['status'],'CANCELLED')
+
+ def test_human_accept_is_blocked_before_changing_proposal(self):
+ from app.web import main
+ ins(self.fake,'exit','EXIT','sell')
+ self.fake.insert_proposal(proposal_id='p',ts_code=CODE,action='OPEN',qty=200,
+ hard_numbers={},expire_at=datetime.now()+timedelta(hours=1))
+ with patch.object(main,'ok_logged',side_effect=lambda name,fn,**kw:fn()), \
+ patch.object(main,'_actor',return_value='test'):
+ result=main.api_decide(None,'p',{'decision':'ACCEPTED','reason':'用户确认'})
+ self.assertFalse(result['ok'])
+ self.assertEqual(self.fake.proposals['p']['status'],'WAIT_USER')
+ self.assertEqual(len(self.fake.instructions),1)
+
+if __name__=='__main__':unittest.main()
+
+
diff --git a/scripts/test_wiring.py b/scripts/test_wiring.py
index bd50e98..c5bbc88 100644
--- a/scripts/test_wiring.py
+++ b/scripts/test_wiring.py
@@ -257,6 +257,10 @@ class FakeRepo:
and (not ts_code or i["ts_code"] == ts_code)]
return out[:limit]
+ def instruction_buy_stop(self, instruction_id):
+ return next((r for r in reversed(self.ledger) if r.get('ref_id') == instruction_id
+ and r.get('action') == 'BUY_STOP' and r.get('verdict') == 'PASS'), None)
+
def get_instruction(self, iid):
return self.instructions.get(iid)
@@ -269,6 +273,8 @@ class FakeRepo:
i = self.instructions.get(iid)
if not i:
return 0
+ if status == 'DISPATCHED' and i.get('status') not in ('PROPOSED', 'RULE_PASSED', 'JUDGE_PASSED', 'DISPATCHED'):
+ return 0
for k, v in (("status", status), ("exec_qty", exec_qty),
("exec_avg_price", exec_avg_price), ("dispatch_ref", dispatch_ref),
("progress", progress)):