动作引擎加新建仓 OPEN + 接上决策系统的买入信号: 未持仓票的转多信号补留痕, 有信号的候选排最前 (只改先后不改资格), 名额与金额滚动扣减, 研判只送定性材料, 参考位漂移首答锁定, 研判单轮时间预算

This commit is contained in:
zlt 2026-08-06 14:41:59 +08:00
parent b7abd8c3fe
commit 1f7554b973
13 changed files with 310 additions and 21 deletions

View File

@ -172,6 +172,70 @@ PMS_UNAVAILABLE 三个值,而 `pms_advisor._today_exit_verdict` 第 102 行明
---
## 2026-08-06 · 决策系统的买入信号一直被 PMS 丢在门口,已接上
**做了什么**
用户问「新建仓能不能并入决策系统原有的信号链路」。查下来先纠正了一个前提,又撞出一个洞。
**先纠正的前提**:决策系统的**建仓侧本来就是轮询**,不是订阅。
`workers/celery_app.py:94-97``entry-gate-poll``crontab(minute="*", hour="9-11,13-14")`
每分钟去 `trading_buy_plan``is_active=7`。真正事件驱动的是告警链
`scripts/intraday_watcher.py` 常驻进程 `xreadgroup` 读上游告警流)与卖出链。
所以「PMS 只能轮询不合理」对既有架构不成立——候选池是日频静态清单,没有事件可订阅,
轮询是它唯一的读法,而且与决策系统自己的建仓入口同构。
**撞出来的洞(与 08-05 那次同型)**:决策系统盘中判出 `REVERSAL_BUY` 会往 db2 的
`intraday_signals:{日期}` 广播(`workers/tasks_intraday.py:783-785`**PMS 一直订阅得到**
`signal_service.streams()` 第一条就是它,每分钟拉一批),但走到
`signal_rules.digest`(第 106 到 108 行)被归进 `ACT_RECORD`,而
`signal_service._handle` 的 RECORD 分支**只给持仓票写账本**。
于是「决策系统今天看多了某只没持仓的票」——**正是新建仓关心的那一批**——PMS 收到了、
计了个数,然后一个字都不留:账本查不到、页面看不见,事后复盘问「那天系统看见了吗」答不上来。
当年那句注释的理由(「买什么买多少由动作引擎决定」)在动作引擎没有新建仓动作时成立,
现在失效了。
**改法(口径:信号只做加速器,不改资格)**
- `signal_rules.py`:新增 `ACT_NOTE_BUY`。BUY 信号**仍然不产生任何买入动作**
但持没持仓都要留痕。HOLD 等其余类型口径一个字未动。
- `signal_service.py`:新增 `ACT_NOTE_BUY` 分支,落 `action=SIGNAL_BUY`、`verdict=NOTE`
的账本行,按(日期,来源,股票,动作)当日去重。用 `NOTE` 不用 `PASS`——
「记下来」和「放行」在账本里必须分得开。
- `pms_repo.buy_signals_today`:只读,今天有转多留痕的票。
- `proposal_service._scan_open``action_engine.scan_open`:有信号的候选**排最前**。
**只影响先后,不影响资格**——不在候选池里的票不会因为有信号就被建仓,
候选层那一整套过滤ST、黑名单、分数下限、主题限额、预期空间一道都不绕。
- `judge.OPEN_JUDGE_KEYS` 加两个键,把「你自己今天判过这只票转多」送回决策系统,
让它拿自己的结论对照一次。这是定性材料不是仓位数字,符合那条白名单的立意。
- 新参数 `PMS_OPEN_SIGNAL_PRIORITY`(默认开),关掉即退回纯分数排序。
**为什么走账本传递而不是让 signal_service 直接调建仓**
两边各管各的一件事:信号消化管「收到了、记下来」,动作引擎管「买不买、买多少」,
中间靠账本这个既有事实源接。不新增跨模块调用、不复制闸门逻辑。
代价是最多差一分钟(两个调度位各自每分钟一跳),而后面还要等择时区间,这点延迟无所谓。
**插队为什么是实打实的增量**:候选按分数降序取,名额只剩两个时第 25 名永远轮不上,
哪怕决策系统刚刚判它转多。分数是昨夜算的静态排名,「此刻转多」是盘中才有的新信息,
两者不同量纲,折算成分数得凭空定系数;插队直接表达了这件事。
**动了哪些文件**
`app/core/signal_rules.py`、`app/services/signal_service.py`、`app/repo/pms_repo.py`、
`app/services/proposal_service.py`、`app/core/action_engine.py`、`app/services/judge.py`、
`config/settings.py`、`app/services/param_store.py`;测试 `scripts/test_batch12_units.py`
加 7 例,`scripts/test_batch5_units.py` 与 `scripts/test_wiring.py` 各改一例
(那两例原本钉着 BUY→`ACT_RECORD` 的旧行为,「不买」这条口径没变,变的是留痕范围)。
全量单测 **ALL SUITES PASS458 例**
**还欠着什么**
1. `trading_buy_plan` 四个状态都有行,说明老的建仓链路还活着。**关掉 ENTRY_GATE 之前
必须先确认那些行是不是今天的**——若老链路今天仍在挂单,而 PMS 同时开始建仓,
就是两套系统都在买。查法见交接说明。
2. 关 ENTRY_GATE 是往决策系统 `.env``ENTRY_GATE_ENABLED=False`
**这是新增 env 值,必须 `docker compose up -d --force-recreate``restart` 不重读 env。**
3. 口径三(信号票在择时上放宽一档)本轮没做,要碰「不追高、接受买不上」那条纪律,
等积累一段实证再谈。
---
## 2026-08-06 · 新建仓动作:两侧代码写完,容器内全量单测通过,待实机部署
**做了什么**

View File

@ -305,6 +305,11 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float):
"tier": c.get("tier"), "upside": c.get("upside"), "heat": c.get("heat"),
"plan_rank": c.get("rank"), "plan_bucket": c.get("bucket"),
"plan_src": c.get("src"), "sector": c.get("sector"),
# 决策系统今天盘中判过这只票转多没有。这是**定性材料**不是仓位数字, 所以送研判时
# 也带过去 (见 judge.OPEN_JUDGE_KEYS) —— 等于当面告诉它「你自己今天判过这只票转多」,
# 它可以拿自己的结论去对照; 没有信号时这两项是 None, 不误导。
"intraday_buy_signal": bool(c.get("sig_buy")),
"intraday_buy_reason": (c.get("sig_buy") or {}).get("reason"),
# ---- 仓位口径: 只进评审账本做判分锚。judge.py 送研判时会把这几项过滤掉,
# 理由见那边的 OPEN_JUDGE_KEYS —— 决策系统本来就不管仓位, 别送过去带偏它。
"target_pct": target_pct, "target_amount": round(full_amt, 2),
@ -314,9 +319,10 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float):
"room_amt_before": round(_f(room_amt), 2),
}
reason = (f"新建仓: 候选池第 {c.get('rank') or ''} 名 (分数 {c.get('score') or ''}"
f"{', 主题 ' + str(c.get('theme')) if c.get('theme') else ''}), "
f"现价 {price}, 目标仓位 {target_pct:.0%}{full_amt:,.0f} 元, "
f"先建底仓 {qty} 股 (约 {qty * price:,.0f} 元)")
f"{', 主题 ' + str(c.get('theme')) if c.get('theme') else ''})"
+ ("; **决策系统今天盘中判过该股转多**" if c.get("sig_buy") else "")
+ f", 现价 {price}, 目标仓位 {target_pct:.0%}{full_amt:,.0f} 元, "
f"先建底仓 {qty} 股 (约 {qty * price:,.0f} 元)")
cand = _cand(c, A_OPEN, BUY, qty, reason, hard)
# 这两项是 OPEN 独有的, 供 proposal_service 用:
# price —— 新票在账本里没有行, _pos_of 拿不到现价, 取它会是 0 而被规则闸判 PRICE_MISSING
@ -359,10 +365,19 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
ctx = dict(caps)
sector_on = bool(ctx.get("sector_source_ready", True))
# 按分数降序、同分按榜内名次 —— 与 plan_feed.select_candidates 的次序一致。
# 上游给过来本来就是排好的, 这里再排一次只是防调用方乱序, 不改变正常路径的结果。
# 排序: 今天被决策系统判过「盘中转多」的票排最前, 其余按分数降序、同分按榜内名次
# (与 plan_feed.select_candidates 的次序一致)。
#
# 为什么让信号插队而不是加分: 分数是昨夜算的静态排名, 「此刻转多」是盘中才有的新信息,
# 两者不同量纲, 折算成分数就得凭空定一个系数。而名额是稀缺的 —— 只剩两个名额时,
# 候选池第 25 名按分数永远轮不上, 哪怕决策系统刚刚判它转多。插队直接表达了这件事。
# **只影响先后, 不影响资格**: 不在候选池里的票根本不会走到这里 (调用方传进来的就是
# 候选池的产物), 候选层那一整套过滤一道都不绕。
# PMS_OPEN_SIGNAL_PRIORITY 关掉即退回纯分数排序, 页面可改, 不用改码。
sig_first = bool(params.get("open_signal_priority", True))
for c in sorted(candidates or [],
key=lambda x: (-_f(x.get("score")), _f(x.get("rank"), 10 ** 9),
key=lambda x: (0 if (sig_first and x.get("sig_buy")) else 1,
-_f(x.get("score")), _f(x.get("rank"), 10 ** 9),
str(x.get("ts_code") or ""))):
code = c.get("ts_code")
if not code:

View File

@ -20,8 +20,22 @@
消化口径 (PMS ):
* SELL 信号 只对**持有的票**有意义置信度够高即转卖出动作 (减持方向不设确认门槛,
与保垫减仓同一口径); 置信度中等则落提议队列等用户裁决
* BUY / HOLD 信号 **不产生买入动作**买什么买多少是 PMS 自己的命令与动作引擎说了算
(设计: 持仓系统管做什么多少)这类信号只作为择时参考落痕, 不越权
* BUY 信号 **本模块仍然不产生买入动作**, 2026-08-06 **无论持没持仓都要留痕**
* HOLD 等其余 只给持仓票留痕 (口径未变)
2026-08-06 补的那条留痕, 由来值得写下来
------------------------------------------
决策系统盘中判出 REVERSAL_BUY 时会往 db2 这条流广播 (action=BUY / signal_type=ENTRY),
PMS 一直订阅得到, 但走到 digest 就被归进 ACT_RECORD, signal_service RECORD 分支
**只给持仓票写账本**于是决策系统今天看多了某只没持仓的票这件事, PMS 收到了计了个数,
然后一个字都不留 账本查不到页面看不见, 事后复盘问那天系统看见了吗答不上来
这与 2026-08-05 那次同一条结论一边当判决一边当摆设是同一个形状, 只是换了个入口
当时那句注释的理由 (买什么买多少由 PMS 的命令与动作引擎决定) 在动作引擎**没有新建仓
动作**的时候是成立的 PMS 确实没有能力消化一个买入信号动作引擎补上 OPEN 之后这条理由
就失效了, 所以先把留痕补上: 本模块只负责记下来, 要不要据此建仓由动作引擎那条路决定
(proposal_service 读账本里这些痕迹, 给候选排序时优先, 见那边的说明)
**分工没有变**: 信号消化不下买单, 买什么买多少仍然归动作引擎
"""
from __future__ import annotations
@ -29,6 +43,8 @@ import json
SRC_INTRADAY, SRC_RISK_SELL = "intraday", "risk_sell"
ACT_EXIT, ACT_PROPOSE, ACT_RECORD, ACT_IGNORE = "EXIT", "PROPOSE", "RECORD", "IGNORE"
# 买入信号留痕: 不产生任何买入动作, 但**持没持仓都要写账本** (与 ACT_RECORD 的差别就在这)
ACT_NOTE_BUY = "NOTE_BUY"
def _num(v, d=0.0):
@ -99,12 +115,26 @@ def digest(signal: dict, position: dict, params: dict) -> dict:
conf = _num((signal or {}).get("confidence"))
hard = {"source": signal.get("source"), "confidence": round(conf, 4),
"dominant_signal": signal.get("dominant_signal"),
"minute_qrs": signal.get("minute_qrs")}
"minute_qrs": signal.get("minute_qrs"),
"suggested_price": signal.get("suggested_price")}
if not code:
return _r(ACT_IGNORE, 0, "信号缺少股票代码", hard)
if act == "BUY":
# 买入信号**仍然不产生买入动作** —— 买什么买多少归动作引擎。
# 但无论持没持仓都要留痕: 未持仓的票正是新建仓关心的那一批, 从前它们连账本都没有。
held_now = int((position or {}).get("total_qty") or 0)
hard["held"] = held_now
return _r(ACT_NOTE_BUY, 0,
f"决策系统盘中判该股转多 (置信度 {conf:.0%}"
+ (f", 建议价 {signal.get('suggested_price')}" if signal.get("suggested_price")
else "")
+ f"){'; 该股当前有持仓' if held_now > 0 else '; 该股当前无持仓'} —— "
f"只留痕, 买不买由动作引擎按闸门决定: "
+ (signal.get("reason") or signal.get("dominant_signal") or "未给理由"),
hard)
if act != "SELL":
# 买入/持有类信号不产生动作 —— 买什么买多少由 PMS 的命令与动作引擎决定
# HOLD 等其余类型: 口径未变, 只给持仓票留痕
return _r(ACT_RECORD, 0, f"{act or '未知'} 信号仅作择时参考留痕, PMS 不据此买入", hard)
held = int((position or {}).get("total_qty") or 0)

View File

@ -541,6 +541,31 @@ def judge_rejected_today(since) -> set:
return {(r["ts_code"], r["action"]) for r in rows}
def buy_signals_today(since) -> dict:
"""今天决策系统判过「盘中转多」的票 → {代码: {reason, price, at}}。
读的是 signal_service 落下的那些留痕行 (action='SIGNAL_BUY', verdict='NOTE')
动作引擎那条路拿它给候选**排序**: 同样在候选池里的票, 今天被决策系统盘中判过转多的
排在前面**只影响先后, 不影响资格** 不在候选池里的票不会因为有信号就被建仓,
候选层那一整套过滤 (ST黑名单分数下限主题限额预期空间) 一道都不绕
为什么走账本而不是让 signal_service 直接调建仓: 两边各管各的一件事
信号消化管收到了记下来, 动作引擎管买不买买多少, 中间靠账本这个既有的事实源
传递, 不新增跨模块调用不复制闸门逻辑代价是最多差一分钟 (两个调度位各自每分钟一跳),
而后面还要等择时区间, 这点延迟无所谓
"""
rows = fetch_all(
"SELECT ts_code, MAX(decided_at) AS at, "
" SUBSTRING_INDEX(GROUP_CONCAT(reason ORDER BY decided_at DESC SEPARATOR '\\n'), "
" '\\n', 1) AS reason, "
" MAX(price_at) AS price "
"FROM pms_action_ledger "
"WHERE action = 'SIGNAL_BUY' AND decided_at >= :d GROUP BY ts_code",
{"d": since})
return {r["ts_code"]: {"reason": r.get("reason"), "at": str(r.get("at") or ""),
"price": float(r.get("price") or 0)} for r in rows}
def opened_names_today(since) -> list:
"""今天已经落了新建仓指令、或还在提议队列里等确认的票 (去重后的代码列表)。

View File

@ -36,7 +36,10 @@ PASS, REJECT, UNAVAILABLE = "PASS", "REJECT", "UNAVAILABLE"
# **只对 OPEN 生效。** FILL/ADD/DCA 的硬数字 (安全垫、评估档位、底仓量) 本身就是它们的判据,
# 一个都不能删: 那几类动作是在**已有持仓**上做加减, 谈的就是这个仓位, 与新建仓不是一回事。
OPEN_JUDGE_KEYS = ("price", "score", "theme", "tier", "upside", "heat",
"plan_rank", "plan_bucket", "plan_src", "sector")
"plan_rank", "plan_bucket", "plan_src", "sector",
# 决策系统今天盘中判没判过这只票转多。这是它自己的结论, 不是仓位数字,
# 送回去等于当面提醒它「你今天判过」, 让它拿自己的结论对照一次。
"intraday_buy_signal", "intraday_buy_reason")
def _judge_hard_numbers(action: str, hard: dict) -> dict:

View File

@ -125,6 +125,7 @@ DESC = {
"PMS_OPEN_AUTONOMY": "自主新建仓档位兼总开关 full / propose_only / off "
"(与 PMS_AUTONOMY 分开: 新建仓要不要人点头是另一个决定)",
"PMS_OPEN_REQUIRE_WS_CASH": "拿不到 ws 资金快照时不自动新建仓 (只影响新建仓, 其余动作照旧)",
"PMS_OPEN_SIGNAL_PRIORITY": "今天被决策系统判过盘中转多的候选票排最前 (只影响先后, 不影响资格)",
"PMS_OPEN_REF_DRIFT_MAX": "参考位盘中被改写超此幅度 → 该票当日暂停新建仓",
"PMS_JUDGE_TICK_BUDGET_SEC": "单轮提议扫描用于研判的时间预算 (秒), 用尽则剩下的候选留到下一跳",
"PMS_JUDGE_ENABLED": "研判闸开关", "PMS_JUDGE_ACTIONS": "需过研判闸的动作",

View File

@ -152,8 +152,16 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list:
# (industry.get_many 走 gp_stock_category 时是逐只查库、没有缓存; 给它加按日缓存能
# 省掉这几十次往返, 但那会改到既有函数的时序行为, 单独提、单独拍板, 这次不夹带。)
sectors = industry.get_many(codes) if codes else {}
cands = [{**x, "price": prices.get(x["ts_code"]), "sector": sectors.get(x["ts_code"])}
for x in items]
# 今天被决策系统盘中判过转多的票 (signal_service 落的留痕)。**只用来排序, 不改资格**:
# 不在候选池里的票不会因为有信号就被建仓, 候选层那一整套过滤一道都不绕。
# 它的增量是实打实的 —— 候选按分数降序取, 名额只剩两个时第 25 名永远轮不上;
# 而「此刻转多」是盘中才有的新信息, 昨夜算出来的分数与买入区间都表达不了它。
sig_buy = _buy_signals_today()
cands = [{**x, "price": prices.get(x["ts_code"]), "sector": sectors.get(x["ts_code"]),
"sig_buy": sig_buy.get(x["ts_code"])} for x in items]
hit = [c["ts_code"] for c in cands if c.get("sig_buy")]
if hit:
logger.info("[新建仓] 候选池里今天被决策系统判过转多的: %s", hit)
t = view["totals"]
p = view["params"]
@ -406,6 +414,7 @@ def _scan_params(view: dict) -> dict:
"buy_halt_dayup": param_store.get_float("PMS_BUY_HALT_DAYUP", 0.05),
"build_window_tdays": param_store.get_int("PMS_BUILD_WINDOW_TDAYS", 10),
"fill_max_loss": param_store.get_float("PMS_FILL_MAX_LOSS", -0.03),
"open_signal_priority": param_store.get_bool("PMS_OPEN_SIGNAL_PRIORITY", True),
})
return p
@ -455,6 +464,17 @@ def _rejected_today_keys() -> set:
return set()
def _buy_signals_today() -> dict:
"""今天的盘中转多留痕。读不到就返回空 —— 它只影响候选的先后, 不影响资格,
所以读失败时降级成按分数排就够了, 不该因此让整轮新建仓停摆"""
try:
return pms_repo.buy_signals_today(datetime.now().replace(
hour=0, minute=0, second=0, microsecond=0))
except Exception as e:
logger.warning("[新建仓] 读当日转多留痕失败 (本轮按纯分数排序): %s", e)
return {}
def _judge_rejected_open_keys() -> set:
"""今天已被研判闸驳回的**新建仓** (代码, 动作)。读不到就返回空集 (同上: 去重是降噪)。

View File

@ -30,6 +30,13 @@ from app.services import executor, param_store, portfolio
logger = logging.getLogger("pms.signal")
SEEN_KEY = "PMS_SIGNAL_SEEN" # 当日去重集合 (JSON), 日切自动作废
# 买入信号留痕在评审账本里用的动作名 (2026-08-06)。
# **刻意不叫 OPEN**: 账本里 action=OPEN 的行是真的建仓评审 (放行或拒绝), 两者混在一起,
# `make t-gate` 就分不清「决策系统说这只票转多了」和「PMS 决定建这只票」。
# 动作引擎那条路读的就是这个动作名 —— 见 pms_repo.buy_signals_today。
SIGNAL_BUY_ACTION = "SIGNAL_BUY"
_clients = {}
@ -145,6 +152,32 @@ def _handle(sig, view, prm, seen, ymd, dry_run, out):
out["recorded"] += 1
return
if act == sr.ACT_NOTE_BUY:
# 买入信号留痕: **持没持仓都写**。未持仓的票正是新建仓关心的那一批, 从前它们
# 连一行账本都没有 —— 「决策系统今天看多了哪几只」查不到, 事后没法复盘。
#
# 三条防冲垮的口径 (与上面 RECORD 那句「否则全市场广播会把评审账本冲垮」同一个顾虑):
# 1. 只认 BUY, HOLD 等仍走 RECORD 的老口径;
# 2. 按 (日期, 来源, 股票, 动作) 当日去重 —— 同一只票一天最多一行;
# 3. 上游那侧本来就有节流: watcher 对同股看多研判有两小时防抖锁, 昨夜已看多的票
# 直接免疫不派单, 所以 REVERSAL_BUY 是几十条的量级, 不是全市场广播。
# verdict 用 NOTE 不用 PASS —— 这是「记下来」不是「放行」, 账本里必须分得开
# (watch.py 对不认识的 verdict 有兜底符号, 不会显示异常)。
key = sr.dedup_key(sig, ymd)
if key in seen:
out["ignored"] += 1
return
out.setdefault("buy_notes", []).append({"ts_code": code, "reason": d["reason"]})
if dry_run:
return
px = float((pos or {}).get("price") or d["hard_numbers"].get("suggested_price") or 0)
pms_repo.insert_ledger(
ts_code=code, action=SIGNAL_BUY_ACTION, arbiter="rule", verdict="NOTE",
price_at=px, hard_numbers={**d["hard_numbers"], "msg_id": sig.get("msg_id")},
reason=d["reason"][:500])
seen.add(key)
return
key = sr.dedup_key(sig, ymd)
if key in seen:
out["ignored"] += 1

View File

@ -84,6 +84,12 @@ class Settings(BaseSettings):
# 规则闸那条「拿不到真实资金只告警不拦」是为**已经排好的命令**设计的 —— 通道故障
# 不该升级成业务停摆。但无人值守地从零建仓是可以等的, 所以这里比规则闸严一档:
# 07-30 教训 (scale 200 万 / 账户实际 98 万, 方案一路放行到下游才被拒) 不能重演。
PMS_OPEN_SIGNAL_PRIORITY: bool = True # 今天被决策系统判过盘中转多的候选票排最前
# 决策系统判出 REVERSAL_BUY 时会往 db2 的 intraday_signals 流广播, PMS 一直订阅得到,
# 但 2026-08-06 之前 signal_rules 把买入类信号一律归进"只留痕", 而留痕又只给持仓票写 ——
# 于是「决策系统今天看多了某只没持仓的票」这件事, PMS 收到了却一个字都不留。
# 现在补上了留痕 (action=SIGNAL_BUY, verdict=NOTE), 动作引擎读它给候选排序。
# **只影响先后, 不影响资格**: 不在候选池里的票不会因为有信号就被建仓。
PMS_OPEN_REF_DRIFT_MAX: float = 0.03 # 参考位盘中被改写超此幅度 → 该票当日暂停新建仓
# 择时读的 strategy_daily_results 会被盘中补扫就地改写 (实证: 002335 支撑 31.27→29.00,
# 差 7.3%)。已有持仓有摊薄成本与安全垫做锚, 漂一点不翻转判断; 而新建仓的买入区间

View File

@ -16,9 +16,10 @@
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 )
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (48 )
test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 )
test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算 (26 )
test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/
转多信号插队与留痕 (33 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
451
458
任一子集失败即整体失败 (退出码 1)
"""
import os

View File

@ -232,6 +232,89 @@ def _():
assert "评估异常" in whys and "RuntimeError" in whys, whys
@case("转多信号·插队排最前: 分数低但有信号的票抢在前面")
def _():
cs = [cand("600000.SH", score=300, rank=1),
cand("600001.SH", score=200, rank=2),
dict(cand("600002.SH", score=100, rank=3), sig_buy={"reason": "盘中判转多"})]
r = ae.scan_open(candidates=cs, params=params(), caps=caps(names_count=19),
room_amt=1_400_000, slots=1)
assert [c["ts_code"] for c in r["candidates"]] == ["600002.SH"], r["candidates"]
c = r["candidates"][0]
assert c["hard_numbers"]["intraday_buy_signal"] is True, c["hard_numbers"]
assert c["hard_numbers"]["intraday_buy_reason"] == "盘中判转多"
assert "转多" in c["reason"], c["reason"]
@case("转多信号·开关关掉就退回纯分数排序")
def _():
cs = [cand("600000.SH", score=300, rank=1),
dict(cand("600002.SH", score=100, rank=3), sig_buy={"reason": "盘中判转多"})]
r = ae.scan_open(candidates=cs, params=params(open_signal_priority=False),
caps=caps(names_count=19), room_amt=1_400_000, slots=1)
assert [c["ts_code"] for c in r["candidates"]] == ["600000.SH"], r["candidates"]
@case("转多信号·只改先后不改资格: 没信号的票照样能建, 有信号也过同样的闸")
def _():
# 有信号但组合没名额 —— 一样建不了, 信号不是免死金牌
r = ae.scan_open(candidates=[dict(cand("600002.SH"), sig_buy={"reason": "转多"})],
params=params(), caps=caps(names_count=20), room_amt=1_400_000, slots=0)
assert r["candidates"] == [], r
# 没信号的票照常建
r2 = ae.scan_open(candidates=[cand("600000.SH")], params=params(), caps=caps(),
room_amt=1_400_000, slots=20)
assert len(r2["candidates"]) == 1
assert r2["candidates"][0]["hard_numbers"]["intraday_buy_signal"] is False
@case("信号消化·未持仓票的买入信号也要留痕 (从前它连账本都没有)")
def _():
from app.core import signal_rules as sr
sig = {"source": "intraday", "ts_code": "600002.SH", "action": "BUY",
"confidence": 0.95, "suggested_price": 12.3, "reason": "放量突破"}
d = sr.digest(sig, None, {"sell_conf_min": 0.75, "auto_exit_conf": 0.85})
assert d["action"] == sr.ACT_NOTE_BUY, d
assert d["qty"] == 0, "留痕不产生任何数量"
assert "无持仓" in d["reason"] and "只留痕" in d["reason"], d["reason"]
assert d["hard_numbers"]["suggested_price"] == 12.3
@case("信号消化·持仓票的买入信号同样走留痕, 且标明有持仓")
def _():
from app.core import signal_rules as sr
sig = {"source": "intraday", "ts_code": "600000.SH", "action": "BUY", "confidence": 0.95}
d = sr.digest(sig, {"total_qty": 1000}, {})
assert d["action"] == sr.ACT_NOTE_BUY and d["hard_numbers"]["held"] == 1000, d
assert "有持仓" in d["reason"], d["reason"]
@case("信号消化·卖出与 HOLD 的口径一个字没变")
def _():
from app.core import signal_rules as sr
prm = {"sell_conf_min": 0.75, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3}
# HOLD 仍走老的 RECORD
d = sr.digest({"source": "intraday", "ts_code": "600000.SH", "action": "HOLD"}, None, prm)
assert d["action"] == sr.ACT_RECORD, d
# 高置信卖出仍是清仓
d = sr.digest({"source": "risk_sell", "ts_code": "600000.SH", "action": "SELL",
"confidence": 0.9}, {"total_qty": 1000, "avail_qty": 1000}, prm)
assert d["action"] == sr.ACT_EXIT and d["qty"] == 1000, d
# 未持有的卖出信号仍然忽略
d = sr.digest({"source": "risk_sell", "ts_code": "600000.SH", "action": "SELL",
"confidence": 0.9}, None, prm)
assert d["action"] == sr.ACT_IGNORE, d
@case("研判请求·转多信号是定性材料, 要送过去; 仓位数字照旧不送")
def _():
hard = {"price": 10.0, "score": 300.0, "intraday_buy_signal": True,
"intraday_buy_reason": "放量突破", "target_amount": 120000.0, "names_before": 3}
got = jd._judge_hard_numbers("OPEN", hard)
assert got["intraday_buy_signal"] is True and got["intraday_buy_reason"] == "放量突破"
assert "target_amount" not in got and "names_before" not in got, got
@case("回归·既有四类动作的判据与研判范围没被动过")
def _():
assert ae.JUDGE_ACTIONS == {"FILL", "ADD", "DCA", "OPEN"}, ae.JUDGE_ACTIONS
@ -253,7 +336,8 @@ def _():
"batch_scheme": "0.5,0.25,0.25", "names_before": 3, "max_names": 20,
"room_amt_before": 1_400_000.0}
got = jd._judge_hard_numbers("OPEN", hard)
assert set(got) == set(jd.OPEN_JUDGE_KEYS), got
assert set(got) <= set(jd.OPEN_JUDGE_KEYS), got
assert set(got) == {k for k in hard if k in jd.OPEN_JUDGE_KEYS}, got
for k in ("target_amount", "names_before", "max_names", "room_amt_before",
"batch_scheme", "base_amount", "target_pct"):
assert k not in got, f"{k} 不该送到决策系统"

View File

@ -94,12 +94,16 @@ def _():
@case("消化·BUY/HOLD 信号只留痕不买 (买什么买多少由 PMS 自己决定)")
def _():
# 2026-08-06: BUY 从 ACT_RECORD 改判 ACT_NOTE_BUY。**「不买」这条口径一个字没变** ——
# 变的只是留痕范围: RECORD 只给持仓票写账本, 而未持仓的票正是新建仓关心的那一批,
# 从前它们连一行痕迹都没有。NOTE_BUY 持没持仓都写。HOLD 等其余类型仍走 RECORD。
b = sr.parse_intraday({"ts_code": "600000.SH", "action": "BUY", "confidence": "0.95"})
d = sr.digest(b, POS, PRM)
assert d["action"] == sr.ACT_RECORD and d["qty"] == 0, d
assert "不据此买入" in d["reason"], d
assert d["action"] == sr.ACT_NOTE_BUY and d["qty"] == 0, d
assert "只留痕" in d["reason"] and "买不买由动作引擎" in d["reason"], d
h = sr.parse_intraday({"ts_code": "600000.SH", "action": "HOLD", "confidence": "0.99"})
assert sr.digest(h, POS, PRM)["action"] == sr.ACT_RECORD
assert "不据此买入" in sr.digest(h, POS, PRM)["reason"]
@case("消化·盘中流里的 SELL 也照样消化 (两条流同一套口径)")

View File

@ -1698,9 +1698,12 @@ def _():
_install_signal_fakes(fake, intraday_msgs=[
("1-1", {"ts_code": "600000.SH", "action": "BUY", "confidence": "0.95"})])
r = ss.consume()
assert r["recorded"] == 1 and not r["exits"], r
assert not any(i["side"] == "buy" for i in fake.instructions.values())
assert any(x["action"] == "SIGNAL" for x in fake.ledger), fake.ledger
# 2026-08-06: BUY 改走 NOTE_BUY —— 仍然一股都不买, 但账本必须留下一行, 而且
# **未持仓的票也要留** (从前 RECORD 只给持仓票写, 没持仓的票一个字都没有, 事后无从复盘)。
assert r.get("buy_notes") and not r["exits"], r
assert not any(i["side"] == "buy" for i in fake.instructions.values()), "买入信号绝不下买单"
notes = [x for x in fake.ledger if x["action"] == ss.SIGNAL_BUY_ACTION]
assert notes and notes[0]["verdict"] == "NOTE", fake.ledger
fake2 = install_fakes(prices={"600000.SH": 10.0},
params={"PMS_TOTAL_SCALE": "2000000", "PMS_SIGNAL_ENABLED": "false"},