动作引擎加新建仓 OPEN + 接上决策系统的买入信号: 未持仓票的转多信号补留痕, 有信号的候选排最前 (只改先后不改资格), 名额与金额滚动扣减, 研判只送定性材料, 参考位漂移首答锁定, 研判单轮时间预算
This commit is contained in:
parent
b7abd8c3fe
commit
1f7554b973
64
DEVLOG.md
64
DEVLOG.md
|
|
@ -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 PASS,458 例**。
|
||||
|
||||
**还欠着什么**
|
||||
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 · 新建仓动作:两侧代码写完,容器内全量单测通过,待实机部署
|
||||
|
||||
**做了什么**
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""今天已经落了新建仓指令、或还在提议队列里等确认的票 (去重后的代码列表)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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": "需过研判闸的动作",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""今天已被研判闸驳回的**新建仓** (代码, 动作)。读不到就返回空集 (同上: 去重是降噪)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%)。已有持仓有摊薄成本与安全垫做锚, 漂一点不翻转判断; 而新建仓的买入区间
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} 不该送到决策系统"
|
||||
|
|
|
|||
|
|
@ -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 也照样消化 (两条流同一套口径)")
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
Loading…
Reference in New Issue