添加交易逻辑

This commit is contained in:
zlt 2026-08-25 13:45:51 +08:00
parent c5adcde53f
commit f4123d36d3
11 changed files with 1507 additions and 17 deletions

View File

@ -30,6 +30,47 @@
--- ---
## 2026-08-25· 策略自动挂载:个股打法状态机(吸筹挂网格 / 高热挂止盈 / 派发停买腿 / 接力切换)
**背景**
用户要把个股策略从「手工挂」推进到「默认自动用」:有吸筹标志就开始网格建仓,热度很高就挂跟踪止盈。方案文件 STRATEGY_AUTO_ATTACH_PLAN.mdV2 定稿)已入库,五处拍板均已当面定下:只认「明确吸筹」档触发;建仓资格不放宽(吸筹只影响候选排序,不给入场资格);不设观察期直接全自动;实现骨架取「状态机含接力切换」(网格票站上区间上界且热度达标时自动换挂止盈);判分闭环进 V1。上线前在桥机跑过一轮只读探测scripts/probe_strategy_signals.py最新结论日吸筹定性 420 只(明确 164热度全市场 5195 只、不低于 0.80 的共 194 只(约前 4%,阈值 0.80 因此保留),候选池 31 只中 17 只带明确吸筹(拍板②成立),两信号源日龄 1 天。探测同时暴露一个问题62 只「词表外」定性,根因是首版归类用前缀匹配,而 state 常带前后缀修饰——数据底座 feed.py 用的是子串包含,本次全部改为与它同法(子串 + 派发优先的保守次序)。
**做了什么**
一,新模块 app/services/strategy_advisor.py每交易日 09:40 对每只持仓票判阶段、走四条边——明确吸筹且新鲜挂网格(区间锚支撑压力、锚不住退百分比带,锚不出合法区间就放弃不硬凑);热度超阈值且安全垫为正挂跟踪止盈(双命中也取止盈,负垫永不挂止盈,把位置留给补仓评估);定性转派发或标志失效只暂停网格买入腿(来源标 accum回到明确吸筹自动解除不动卖出侧自动网格站上区间上界且热度达标撤网格换挂止盈接力高水位从当日现价起算。挂载与切换只做配置动作真正买卖仍由 strategy_runner 逐笔过规则闸与全部熔断。自动策略的身份全靠 note 约定承载(「自动挂载: 」「自动挂载(接力): 」「[接力撤下]」),每日新挂上限、人工撤下冷却(同票同规则 10 个交易日)、接力冷却(同票网格边 10 个交易日)全部从策略表推导,不加新表不加状态参数。排除项:人工策略不碰、冻结与黑名单不挂网格、有在途不挂、热度表停更时热度边整轮不动、词表外定性按无标志处理并整轮报样本。
strategy_service 补 clear_buypause按来源解除买入暂停advisor 只清自己 accum 停的不放开风控停的scheduler 新任务 pms.strategy_attach09:40在宏观扫描 09:35 之后宏观要降仓时在途票自动让路web 补试算端点 POST /api/ops/strategy-attach-scan?dry_run=true只判不写供判收与日常复核
13 个 PMS_AUTO_* 参数进 settings 与参数页(总开关、边清单、日上限 2、吸筹日龄 3、网格带宽/档距/投入比例、热度阈值 0.80、止盈回撤/卖出比例、接力开关与两种冷却),全带中文说明与范围校验,边清单在写入口拦未知边名;总开关进 param_store.FAIL_CLOSED——参数表读不到时按关处理故障期间宁可不挂。
四,探测脚本升 v1.1(归类改子串包含并打印词表外原文样本;对读注释改口径说明:探测是单日分布、前端清单是 30 天窗口,比结构不比绝对数)。
**动了哪些文件**
app/services/strategy_advisor.py新增app/services/strategy_service.pyclear_buypauseapp/services/param_store.pyDESC 13 条 / _RANGES 10 条 / FAIL_CLOSED 加总开关 / 边清单校验config/settings.pyPMS_AUTO_* 参数块app/scheduler.pystrategy_attach 任务 + beat 09:40 + 调度总表注释app/web/main.pystrategy-attach-scan 端点scripts/probe_strategy_signals.pyv1.1scripts/test_batch17_units.py新增 34 例定性归类子串与保守优先级、网格参数生成与三条放弃路径、连边矩阵含双命中与负垫、note 字面量钉死、日上限接力不占、两种冷却推导、接力判定、clear_buypause 来源匹配、编排冒烟 dry_run 滴水不写/名额/边三/边四全链/三道总闸scripts/test_wiring.pybeat 哨兵集合加 strategy_attach路由清单加试算端点scripts/run_tests.py注册 batch17总数 519 → 553
**部署方式**
桥机 factorevaluation 上收盘后 make deploy源码打进镜像make test 见 ALL SUITES PASS。不动 .env不需要迁移。上线即全自动拍板③随时可在参数页把 PMS_AUTO_STRATEGY_ENABLED 或单独把接力 PMS_AUTO_HANDOFF_ENABLED 关掉,即时生效。
**真机判收**
未判收。开发容器全量单测 ALL SUITES PASS553 例)。判收建议:部署当天先 curl -X POST 'http://127.0.0.1:38100/api/ops/strategy-attach-scan?dry_run=true' 看它想挂什么、排除原因对不对(尤其 unknown_states 是否为空);次日 09:40 后看 beat 日志 strategy_attach 一跳的 attached/handoffs/paused页面策略列表出现「自动挂载: 」开头的条目、台账出现 ATTACH 留痕;挂出的网格由 strategy_runner 正常逐档发单、每笔过闸。
**还欠着什么**
判分闭环的读数脚本report_strategy_score.py自动挂载 vs 人工挂载 vs 不挂的对照统计本次未写——留痕字段ATTACH/HANDOFF/NOTE 台账 + note 约定)已齐,脚本只读统计,等跑出两周数据再交,先欠着。二,探测顺带发现候选池计划日停在 2026-08-21探测日 08-25日龄已 2 个交易日以上),疑似上游桥的日更链又停了,且 PMS_PLAN_STALE_TDAYS 可能被调宽过——与本特性无关,单独排查。三,热度阈值 0.80 与日上限 2 都是首版拍的,判分数据出来后用读数回调。
---
## 2026-08-25 · 软归档:四类记录加 archived_at已完成默认从在办视图移除补记
**做了什么**
命令 / 策略 / 指令 / 提议四张表加 archived_at 列做软归档:只归**终态**记录(各表终态集在 pms_repo._ARCHIVE_TERMINAL 一处钉死,前端「可移除」判据用同一份),归档只写时间戳不删行,列表默认带 archived_at IS NULL、页面勾「显示已完成」传 include_archived=true 拉回来。repo 层 archive_*/unarchive_* 均为单表 UPDATE、过单表守卫web 层四类各一对归档/取消归档端点。第十六批单测 5 例钉死:只归终态、默认排除、单表守卫、终态集与设计一致。
**动了哪些文件**
app/repo/pms_repo.pyarchive_*/unarchive_*/list_* 的 include_archivedapp/web/main.py归档端点与列表参数app/web/static/index.html显示已完成开关与可移除判据scripts/ddl_pms_v1.sql建表带列scripts/migrate_archived_at.py新增给**已有库**补列,幂等,先演练后 --yesscripts/test_batch16_units.py5 例scripts/run_tests.pybatch16 注册,总数 519
**部署方式**
桥机 make deploy 之外**必须先跑一次迁移**`docker compose run --rm pms-web python scripts/migrate_archived_at.py`(演练)确认四条 ALTER再加 `--yes` 执行。init_db 只认 CREATE TABLE IF NOT EXISTS不会给旧表加列——不跑迁移新代码的列表 SQL 一执行就报未知列。新库不需要(建表语句已带列)。
**真机判收**
未判收。判收建议:迁移脚本两跑(演练/实做)各表 added 或 skip 清楚;页面把一条 DONE 命令归档后从在办列表消失勾「显示已完成」回来且标已归档ALL SUITES PASS 含 batch16。
---
## 2026-08-20· 清仓与减仓对策略的优先级理顺:减仓不掐网格、清仓补撤在途买单、在途文案分开 ## 2026-08-20· 清仓与减仓对策略的优先级理顺:减仓不掐网格、清仓补撤在途买单、在途文案分开
**背景** **背景**

View File

@ -10,6 +10,7 @@
| 盘中执行 | 交易时段每 1 分钟 | 择时出手 + 自主提议扫描 (执行器下一批交付) | | 盘中执行 | 交易时段每 1 分钟 | 择时出手 + 自主提议扫描 (执行器下一批交付) |
| 信号消化 | 交易时段每 1 分钟 | 订阅决策系统盘中信号 (下一批交付) | | 信号消化 | 交易时段每 1 分钟 | 订阅决策系统盘中信号 (下一批交付) |
| 宏观择时 | 交易日 09:35 | 股汇对冲指数 升降仓命令或建议 + 宏观闸 | | 宏观择时 | 交易日 09:35 | 股汇对冲指数 升降仓命令或建议 + 宏观闸 |
| 策略挂载 | 交易日 09:40 | 个股打法状态机: 吸筹挂网格/高热挂止盈/接力 |
| 成交回放 | 交易时段每 1 分钟 | ws 逐笔入账 + trading_order 增量 + 轻对账 | | 成交回放 | 交易时段每 1 分钟 | ws 逐笔入账 + trading_order 增量 + 轻对账 |
| T 仓平回 | 14:50 (二期) | 做T强制平回 | | T 仓平回 | 14:50 (二期) | 做T强制平回 |
| 日终结算 | 15:10 | 全量对账 / 除权 / 安全垫 / 命令进度日结 | | 日终结算 | 15:10 | 全量对账 / 除权 / 安全垫 / 命令进度日结 |
@ -195,6 +196,20 @@ def macro_scan():
return macro_service.scan() return macro_service.scan()
@celery_app.task(name="pms.strategy_attach")
@guard(trade_day=True, respect_exec_halt=True)
def strategy_attach():
"""策略自动挂载 (09:40): 个股打法状态机 —— 吸筹挂网格 / 高热挂止盈 / 派发停买腿 / 接力。
定在 09:40: 在宏观扫描 (09:35) 之后 若宏观当天要降仓, 先让降仓命令占住在途,
有在途的票本轮自动挂载会主动让路 (缓到下一个扫描日); 也避开开盘前 15 分钟的
竞价噪声, 此刻 positions_view 已有实时价, 网格区间锚得住只做配置动作不下单,
真正买卖由 strategy_runner 逐笔过闸详见 STRATEGY_AUTO_ATTACH_PLAN.md
"""
from app.services import strategy_advisor
return strategy_advisor.scan()
@celery_app.task(name="pms.t0_close") @celery_app.task(name="pms.t0_close")
@guard(trade_day=True) @guard(trade_day=True)
def t0_close(): def t0_close():
@ -250,6 +265,7 @@ celery_app.conf.beat_schedule = {
"intraday_exec": {"task": "pms.intraday_exec", "schedule": crontab(minute="*")}, "intraday_exec": {"task": "pms.intraday_exec", "schedule": crontab(minute="*")},
"signal_digest": {"task": "pms.signal_digest", "schedule": crontab(minute="*")}, "signal_digest": {"task": "pms.signal_digest", "schedule": crontab(minute="*")},
"macro_scan": {"task": "pms.macro_scan", "schedule": crontab(hour=9, minute=35)}, "macro_scan": {"task": "pms.macro_scan", "schedule": crontab(hour=9, minute=35)},
"strategy_attach": {"task": "pms.strategy_attach", "schedule": crontab(hour=9, minute=40)},
"t0_close": {"task": "pms.t0_close", "schedule": crontab(hour=14, minute=50)}, "t0_close": {"task": "pms.t0_close", "schedule": crontab(hour=14, minute=50)},
"daily_settle": {"task": "pms.daily_settle", "schedule": crontab(hour=15, minute=10)}, "daily_settle": {"task": "pms.daily_settle", "schedule": crontab(hour=15, minute=10)},
"daily_report": {"task": "pms.daily_report", "schedule": crontab(hour=15, minute=30)}, "daily_report": {"task": "pms.daily_report", "schedule": crontab(hour=15, minute=30)},

View File

@ -73,6 +73,9 @@ FAIL_CLOSED = {
# 基础设施故障期间照常想下命令。安全方向是关: 宁可少动一轮。取数环节同库先挂是第一道 # 基础设施故障期间照常想下命令。安全方向是关: 宁可少动一轮。取数环节同库先挂是第一道
# 保险, 这条名单是第二道。 # 保险, 这条名单是第二道。
"PMS_MACRO_ENABLED": False, "PMS_MACRO_ENABLED": False,
# 2026-08-25 策略自动挂载: 同宏观层的理由 —— 文件初值 True (模拟仓拍板全自动),
# 表读不到时退文件初值等于故障期间照常自动挂策略。安全方向是关, 本轮不挂不撤。
"PMS_AUTO_STRATEGY_ENABLED": False,
} }
# 页面展示用的中文说明 (settings.py 用行尾注释, pydantic 取不到, 故在此集中维护) # 页面展示用的中文说明 (settings.py 用行尾注释, pydantic 取不到, 故在此集中维护)
@ -151,6 +154,19 @@ DESC = {
"PMS_T0_CLOSE_TIME": "T仓强制平回时点", "PMS_T0_STOCK_DAY_LOSS": "单票当日T亏熔断", "PMS_T0_CLOSE_TIME": "T仓强制平回时点", "PMS_T0_STOCK_DAY_LOSS": "单票当日T亏熔断",
"PMS_T0_GLOBAL_DAY_LOSS": "全局当日T亏熔断", "PMS_T0_GLOBAL_DAY_LOSS": "全局当日T亏熔断",
"PMS_STRATEGY_ENABLED": "个股交易方案(策略)层总开关 (关=strategy_runner 空转)", "PMS_STRATEGY_ENABLED": "个股交易方案(策略)层总开关 (关=strategy_runner 空转)",
"PMS_AUTO_STRATEGY_ENABLED": "策略自动挂载总开关 (个股打法状态机; 关=09:40 调度位空转不挂不撤)",
"PMS_AUTO_STRATEGY_RULES": "启用的边 (逗号分隔): accum_grid 吸筹挂网格 / heat_trail 热度挂止盈 / accum_exit 派发暂停买腿 / handoff 网格换止盈接力",
"PMS_AUTO_STRATEGY_DAILY_MAX": "每日自动新挂上限 (只数; 接力换挂是换不是增, 不计入)",
"PMS_AUTO_ACCUM_STALE_TDAYS": "吸筹结论日龄超此 (交易日) 视为过期不触发",
"PMS_AUTO_GRID_BAND": "网格区间退化带宽: 支撑/压力锚不住时用 现价×(1±带宽)",
"PMS_AUTO_GRID_STEP_PCT": "自动网格步长 (占中枢价)",
"PMS_AUTO_GRID_CAP_RATIO": "自动网格最大投入占单股上限余量的比例",
"PMS_AUTO_HEAT_TH": "热度阈值 0~1 (2026-08-25 实测: ≥0.80 约取全市场前 4%, 194 只)",
"PMS_AUTO_TRAIL_GIVEBACK": "自动跟踪止盈: 距高水位回撤触发比例",
"PMS_AUTO_TRAIL_SELL_RATIO": "自动跟踪止盈: 触发时卖出比例",
"PMS_AUTO_HANDOFF_ENABLED": "边四接力独立开关 (自动网格站上区间上界且热度达标→撤网格换挂止盈)",
"PMS_AUTO_HANDOFF_COOLDOWN_TDAYS": "接力后同票网格边冷却 (交易日; 防区间上沿震荡把两种策略翻来覆去换)",
"PMS_AUTO_OPTOUT_COOLDOWN_TDAYS": "人工撤下自动策略后同票同规则冷却 (交易日; 尊重人的否决, 防人机拉锯)",
"PMS_REPLAY_INTERVAL_MIN": "成交回放间隔 (分钟)", "PMS_RECON_ALARM_DAYS": "连续不一致升级天数", "PMS_REPLAY_INTERVAL_MIN": "成交回放间隔 (分钟)", "PMS_RECON_ALARM_DAYS": "连续不一致升级天数",
"PMS_SIGNAL_ENABLED": "是否消化决策系统盘中信号", "PMS_SIGNAL_ENABLED": "是否消化决策系统盘中信号",
"PMS_SIGNAL_GROUP": "信号消费组名 (独立于 trading_service, 互不抢消息)", "PMS_SIGNAL_GROUP": "信号消费组名 (独立于 trading_service, 互不抢消息)",
@ -364,6 +380,12 @@ _RANGES = {
"PMS_MACRO_MAX_PCT": (0, 1), "PMS_MACRO_WINDOW_TDAYS": (1, 20), "PMS_MACRO_MAX_PCT": (0, 1), "PMS_MACRO_WINDOW_TDAYS": (1, 20),
"PMS_MACRO_STALE_TDAYS": (1, 20), "PMS_MACRO_RET_WIN": (5, 60), "PMS_MACRO_STALE_TDAYS": (1, 20), "PMS_MACRO_RET_WIN": (5, 60),
"PMS_MACRO_Z_WIN": (10, 250), "PMS_MACRO_SHIBOR_BETA": (0, 1), "PMS_MACRO_Z_WIN": (10, 250), "PMS_MACRO_SHIBOR_BETA": (0, 1),
# 策略自动挂载
"PMS_AUTO_STRATEGY_DAILY_MAX": (0, 20), "PMS_AUTO_ACCUM_STALE_TDAYS": (1, 20),
"PMS_AUTO_GRID_BAND": (0.01, 0.5), "PMS_AUTO_GRID_STEP_PCT": (0.005, 0.2),
"PMS_AUTO_GRID_CAP_RATIO": (0, 1), "PMS_AUTO_HEAT_TH": (0, 1),
"PMS_AUTO_TRAIL_GIVEBACK": (0.01, 0.5), "PMS_AUTO_TRAIL_SELL_RATIO": (0.1, 1),
"PMS_AUTO_HANDOFF_COOLDOWN_TDAYS": (0, 60), "PMS_AUTO_OPTOUT_COOLDOWN_TDAYS": (0, 60),
} }
@ -388,6 +410,14 @@ def _range_check(key, v):
return "PMS_DISPATCH_MODE 只能是 shadow / ws" return "PMS_DISPATCH_MODE 只能是 shadow / ws"
if key == "PMS_CANDIDATE_SOURCE" and v not in ("plan_api", "buy_plan", "both"): if key == "PMS_CANDIDATE_SOURCE" and v not in ("plan_api", "buy_plan", "both"):
return "PMS_CANDIDATE_SOURCE 只能是 plan_api / buy_plan / both" return "PMS_CANDIDATE_SOURCE 只能是 plan_api / buy_plan / both"
if key == "PMS_AUTO_STRATEGY_RULES":
# 页面上打错一个边名不会报错、只会静默少一条边 —— 在写入口就拦下来
bad = [t.strip() for t in str(v).split(",")
if t.strip() and t.strip() not in ("accum_grid", "heat_trail",
"accum_exit", "handoff")]
if bad:
return (f"PMS_AUTO_STRATEGY_RULES 含未知边 {bad}; "
f"可选 accum_grid / heat_trail / accum_exit / handoff")
lo_hi = _RANGES.get(key) lo_hi = _RANGES.get(key)
if lo_hi and isinstance(v, (int, float)) and not isinstance(v, bool): if lo_hi and isinstance(v, (int, float)) and not isinstance(v, bool):
lo, hi = lo_hi lo, hi = lo_hi

View File

@ -0,0 +1,649 @@
# -*- coding: utf-8 -*-
"""
策略自动挂载 · 个股打法状态机 (STRATEGY_AUTO_ATTACH_PLAN.md V2)
================================================================
每交易日 09:40 跑一次 (scheduler.strategy_attach)对每只持仓票判阶段:
阶段: 吸筹震荡 / 启动拉升 / 高位派发 / 深亏修复 / 中性
边一 中性吸筹震荡 明确吸筹且结论新鲜 挂网格
边二 中性启动拉升 热度超阈值且安全垫为正 挂跟踪止盈 (双命中也取止盈)
边三 吸筹震荡高位派发 定性转派发或标志失效 暂停网格买入腿 (来源 accum, 回明确自动解除)
边四 吸筹震荡启动拉升 自动网格票站上区间上界且热度超阈值 撤网格换挂止盈 (接力)
**挂载和切换本身不下单** 真正的买卖仍由 strategy_runner 每分钟评估每一笔过规则闸与
全部熔断本模块只做配置动作: 读信号判阶段 strategy_service.attach / set_status,
全程留痕 (pms_action_ledger: ATTACH / HANDOFF / NOTE)
两个信号源 (153 代理, 严格单表; 口径同源声明见方案第二节):
吸筹定性 strategy_daily_results.raw_logic_json fund_flow.state 决策系统每晚产出
判定用**子串包含**而不是前缀 (与数据底座 feed.py _ACCUM_KEEP 同手法):
实测 state 会带前后缀修饰, 前缀匹配漏了 2026-08-25 探测里 62 词表外
派发优先于含明确吸筹 两个词同现时按保守方向算派发
词表外的 state 一律当无标志 (宁可不挂), 并在返回里报出来供核对契约
热度分 stock_fund_heat_scores 最新交易日最大批次, 0~1全市场约 5200 ,
阈值 0.80 约取前 4% (2026-08-25 探测: 0.8 194 )
自动策略的身份全靠 note 约定承载, **不加任何新表新参数状态** (可从策略表完整推导,
审计与测试都只看得见的东西):
"自动挂载: ..." 本模块常规挂出的 (计入每日新挂上限)
"自动挂载(接力): ..." 边四换挂出来的止盈 (不占每日上限 它是换不是增)
note 里含 "[接力撤下]" 被边四撤掉的网格 (据此算接力冷却, 不算人工撤下)
由此派生的两种冷却 (方案第七节小口径):
人工撤下: 自动策略被撤票还持有note 无接力标记 同票同规则 N 个交易日不再自动挂
接力之后: 同票网格边 N 个交易日不再挂 (防区间上沿来回震把两种策略翻来覆去换)
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta
from app.core import tradedays as td
from app.db.session import fetch_all, fetch_one
from app.repo import pms_repo
from app.repo.downstream_repo import to_dot, to_prefix
from app.services import param_store
logger = logging.getLogger("pms.strategy_advisor")
# 决策系统定性词表 (契约)。判定看子串, 次序即优先级 —— 派发最先 (保守方向)。
CLS_DISTRIB = "高位派发"
CLS_CLEAR, CLS_MAYBE = "明确吸筹", "潜在吸筹"
CLS_NONE_SIGN, CLS_UNCLEAR = "无吸筹迹象", "信号不明"
CLS_UNKNOWN, CLS_NOFIELD = "词表外", "无字段"
_CLS_ORDER = (("派发", CLS_DISTRIB), ("明确吸筹", CLS_CLEAR), ("潜在吸筹", CLS_MAYBE),
("无吸筹迹象", CLS_NONE_SIGN), ("不明", CLS_UNCLEAR))
# 边 (规则注册表键名, PMS_AUTO_STRATEGY_RULES 里逗号列出即启用)
R_GRID, R_TRAIL, R_EXIT, R_HANDOFF = "accum_grid", "heat_trail", "accum_exit", "handoff"
# note 约定 (见模块头; 改这里必须同步改 test_batch17 钉住的字面量)
NOTE_AUTO = "自动挂载: "
NOTE_HANDOFF = "自动挂载(接力): "
MARK_HANDOFF_OUT = "[接力撤下]"
ACCUM_WINDOW_DAYS = 45 # 每票取近 45 自然日内最新一条结论
HEAT_MAX_AGE_DAYS = 4 # 热度表末日落后超此自然日 → 热度信号本轮不可用
_RULE_OF_TYPE = {"GRID": R_GRID, "TRAIL": R_TRAIL}
def _f(v, d=None):
try:
return float(v)
except (TypeError, ValueError):
return d
# ================================================================ 纯逻辑
def classify_accum(state) -> str:
"""定性字符串 → 档位。子串包含 + 固定优先级; 判不出的当词表外 (调用方按无标志处理)。"""
s = str(state or "").strip()
if not s:
return CLS_NOFIELD
for key, cls in _CLS_ORDER:
if key in s:
return cls
return CLS_UNKNOWN
def grid_params(*, price, support, pressure, band, step_pct, cap_room, cap_ratio):
"""网格参数自动生成 (方案附录二)。返回 (params, why); params=None 时 why 说明放弃原因。
区间优先锚支撑压力, 锚不住退百分比带; 任何一步不满足 0<下界<中枢<上界 就放弃不硬凑
"""
p = _f(price, 0.0)
if not p or p <= 0:
return None, "取不到实时价, 网格区间无从定"
cap = _f(cap_room, 0.0) or 0.0
max_capital = round(cap * _f(cap_ratio, 0.5), 2)
if max_capital < p * 100:
return None, (f"单股上限余量 {cap:,.0f} 元按投入比例折出 {max_capital:,.0f} 元, "
f"买不起一手, 不挂")
r, s = _f(pressure, 0.0) or 0.0, _f(support, 0.0) or 0.0
upper = round(r * 1.01, 3) if r > p else round(p * (1 + band), 3)
lower = round(s * 0.99, 3) if 0 < s < p else round(p * (1 - band), 3)
if not (0 < lower < p < upper):
return None, (f"区间不成立 (下界 {lower} / 现价 {p} / 上界 {upper}), "
f"支撑压力形态不适合网格, 本轮放弃")
step = max(0.005, _f(step_pct, 0.02))
n_below = max(1, int((p - lower) / (p * step)))
per_lot = int(max_capital / n_below / p / 100) * 100
if per_lot < 100:
per_lot = 100
return ({"center": p, "lower": lower, "upper": upper, "step_pct": step,
"per_lot": per_lot, "max_capital": max_capital}, "")
def plan_edge(*, cls, fresh, heat, cushion, prm):
"""无策略持仓票该走哪条边。返回 (边|None, 原因)。
双命中取止盈 (保住利润优先于做波段); 负垫不挂止盈 (挂了永远不武装, 还把该票从
动作引擎排除, 白挡掉深亏补仓的评估) 方案第七节小口径
"""
rules = prm["rules"]
hot = heat is not None and heat >= prm["heat_th"]
pos = cushion is not None and _f(cushion, 0.0) > 0
clear = (cls == CLS_CLEAR and fresh)
if R_TRAIL in rules and hot and pos:
return R_TRAIL, ("明确吸筹与高热度双命中, 按口径取跟踪止盈" if clear
else f"热度 {heat:.3f} 超阈值 {prm['heat_th']:.2f} 且有浮盈")
if R_GRID in rules and clear:
return R_GRID, "明确吸筹且结论新鲜, 适合网格吃震荡"
if hot and not pos:
return None, "热度够但安全垫不正, 不挂止盈 (把位置留给补仓评估)"
if cls == CLS_CLEAR and not fresh:
return None, "吸筹结论超日龄, 视为无标志"
return None, ""
def count_auto_today(rows, today_ymd: int) -> int:
"""今天已常规自动挂载几条 (接力挂出的不算 —— 它是换不是增)。rows=策略行 (含已归档)。"""
n = 0
for r in rows or []:
note = str(r.get("note") or "")
if not note.startswith(NOTE_AUTO):
continue
if _ymd_of(r.get("created_at")) == today_ymd:
n += 1
return n
def cooldowns_from_cancelled(rows, held_codes, today_ymd: int, *,
optout_tdays: int, handoff_tdays: int):
"""从已撤销的自动策略行推导两种冷却 (无状态, 全部可从表推导)。
返回 (optout: {(code, rule)}, handoff_cool: {code})
人工撤下 = 自动策略被撤票还持有note 无接力标记 撤它的可能是你也可能是
清仓命令或清场; 后两种情形持仓多半已归零, 票还持有这一条把它们天然排除,
剩下的按"有人特意撤过"处理, 冷却期内不再自动挂, 杜绝人机拉锯
交易日换算按自然日乘二宽松 (与全库惯例一致), 冷却只会偏长不会偏短
"""
optout, hand = set(), set()
held = set(held_codes or ())
for r in rows or []:
note = str(r.get("note") or "")
if not note.startswith("自动挂载"):
continue
code = r.get("ts_code")
age = _age_days_of(r.get("updated_at"), today_ymd)
if age is None:
continue
if MARK_HANDOFF_OUT in note:
if age <= handoff_tdays * 2:
hand.add(code)
continue
rule = _RULE_OF_TYPE.get(str(r.get("type") or "").upper())
if rule and code in held and age <= optout_tdays * 2:
optout.add((code, rule))
return optout, hand
def handoff_ready(*, price, price_ok, upper, heat, cushion, prm):
"""边四触发判定 (不含「无在途委托」那条 —— 那要查库, 由编排层补)。返回 (bool, why)。"""
if not prm.get("handoff_enabled") or R_HANDOFF not in prm["rules"]:
return False, ""
if not price_ok or _f(price, 0.0) <= 0:
return False, ""
if _f(upper, 0.0) <= 0 or _f(price) < _f(upper):
return False, ""
if heat is None or heat < prm["heat_th"]:
return False, f"已站上网格上界但热度 {heat if heat is not None else '缺失'} 未达阈值, 继续网格"
if cushion is None or _f(cushion, 0.0) <= 0:
return False, "已站上网格上界但安全垫不正, 不接力"
return True, (f"现价 {price} 站上网格上界 {upper} 且热度 {heat:.3f} 超阈值 —— "
f"吸筹震荡期转启动拉升期, 网格换跟踪止盈")
def _ymd_of(ts) -> int:
try:
if hasattr(ts, "strftime"):
return int(ts.strftime("%Y%m%d"))
s = str(ts or "").strip().replace("-", "")[:8]
return int(s) if s.isdigit() and len(s) == 8 else 0
except (TypeError, ValueError):
return 0
def _age_days_of(ts, today_ymd: int):
y = _ymd_of(ts)
if not y:
return None
try:
a = datetime.strptime(str(y), "%Y%m%d").date()
b = datetime.strptime(str(today_ymd), "%Y%m%d").date()
return (b - a).days
except ValueError:
return None
# ================================================================ 取数 (153 代理, 单表)
def _variants(dot_codes):
rev = {}
for c in dot_codes or []:
d = to_dot(c)
if not d:
continue
rev[d] = d
rev[to_prefix(d)] = d
rev[d.split(".")[0]] = d
return rev
def _in(values, prefix, params):
keys = []
for i, v in enumerate(values):
keys.append(f":{prefix}{i}")
params[f"{prefix}{i}"] = v
return ", ".join(keys)
def accum_of(dot_codes) -> dict:
"""{点式: {cls, state, score, ymd, age_days}} —— 窗口内每票最新一条定性。"""
if not dot_codes:
return {}
rev = _variants(dot_codes)
since = int((datetime.now().date() - timedelta(days=ACCUM_WINDOW_DAYS)).strftime("%Y%m%d"))
p = {"since": since}
rows = fetch_all(
"SELECT stock_code, trade_date, raw_logic_json FROM strategy_daily_results "
f"WHERE trade_date >= :since AND stock_code IN ({_in(list(rev), 'c', p)})", p)
best = {}
for r in rows:
dot = rev.get(str(r.get("stock_code") or "").strip())
ymd = _ymd_of(r.get("trade_date"))
if not dot or not ymd or (dot in best and best[dot]["ymd"] >= ymd):
continue
try:
raw = r.get("raw_logic_json")
d = raw if isinstance(raw, dict) else json.loads(raw or "{}")
ff = d.get("fund_flow") or {}
except (ValueError, TypeError):
ff = {}
state = ff.get("state") if isinstance(ff, dict) else None
best[dot] = {"ymd": ymd, "age_days": _age_days_of(ymd, td.ymd()),
"cls": classify_accum(state), "state": state,
"score": _f((ff or {}).get("score"))}
return best
def heat_of(dot_codes):
"""({点式: score}, meta)。meta.stale=True 表示热度表停更, 调用方按热度不可用处理。"""
meta = {"td": None, "batch": None, "stale": True}
if not dot_codes:
return {}, meta
r = fetch_one("SELECT MAX(trade_date) AS td FROM stock_fund_heat_scores")
tdd = (r or {}).get("td")
if tdd is None:
return {}, meta
r2 = fetch_one("SELECT MAX(batch_no) AS b FROM stock_fund_heat_scores "
"WHERE trade_date = :td", {"td": tdd})
rev = _variants(dot_codes)
p = {"td": tdd, "b": (r2 or {}).get("b")}
rows = fetch_all(
"SELECT stock_code, score FROM stock_fund_heat_scores "
f"WHERE trade_date = :td AND batch_no = :b AND stock_code IN ({_in(list(rev), 'c', p)})",
p)
out = {}
for x in rows:
dot = rev.get(str(x.get("stock_code") or "").strip())
v = _f(x.get("score"))
if dot and v is not None:
out[dot] = v
age = _age_days_of(_ymd_of(tdd), td.ymd())
meta.update({"td": str(tdd), "batch": (r2 or {}).get("b"),
"stale": age is None or age > HEAT_MAX_AGE_DAYS, "age_days": age})
return out, meta
# ================================================================ 参数
def _params() -> dict:
g = param_store
return {
"enabled": g.get_bool("PMS_AUTO_STRATEGY_ENABLED", False),
"rules": set(g.get_list("PMS_AUTO_STRATEGY_RULES",
[R_GRID, R_TRAIL, R_EXIT, R_HANDOFF])),
"daily_max": g.get_int("PMS_AUTO_STRATEGY_DAILY_MAX", 2),
"accum_stale_tdays": g.get_int("PMS_AUTO_ACCUM_STALE_TDAYS", 3),
"grid_band": g.get_float("PMS_AUTO_GRID_BAND", 0.08),
"grid_step_pct": g.get_float("PMS_AUTO_GRID_STEP_PCT", 0.02),
"grid_cap_ratio": g.get_float("PMS_AUTO_GRID_CAP_RATIO", 0.5),
"heat_th": g.get_float("PMS_AUTO_HEAT_TH", 0.80),
"trail_giveback": g.get_float("PMS_AUTO_TRAIL_GIVEBACK", 0.05),
"trail_sell_ratio": g.get_float("PMS_AUTO_TRAIL_SELL_RATIO", 0.5),
"handoff_enabled": g.get_bool("PMS_AUTO_HANDOFF_ENABLED", True),
"handoff_cooldown_tdays": g.get_int("PMS_AUTO_HANDOFF_COOLDOWN_TDAYS", 10),
"optout_cooldown_tdays": g.get_int("PMS_AUTO_OPTOUT_COOLDOWN_TDAYS", 10),
}
# ================================================================ 编排
def scan(*, dry_run: bool = False, now=None) -> dict:
"""每交易日一跳 (09:40)。dry_run=True 只判不写 —— 不挂、不留痕、不动暂停表。"""
now = now or datetime.now()
today = td.ymd(now)
prm = _params()
out = {"ok": True, "enabled": prm["enabled"], "dry_run": dry_run, "checked": 0,
"attached": [], "handoffs": [], "paused": [], "resumed": [],
"blocked": [], "skipped": [], "errors": [],
"unknown_states": []}
if not prm["enabled"]:
out["skipped"].append({"why": "策略自动挂载总开关关闭 (PMS_AUTO_STRATEGY_ENABLED)"})
return out
if not param_store.get_bool("PMS_STRATEGY_ENABLED", False):
out["skipped"].append({"why": "策略层总开关关闭 (PMS_STRATEGY_ENABLED) —— "
"挂了也不会跑, 本轮不挂"})
return out
if param_store.get_bool("PMS_GLOBAL_EXEC_HALT", False):
out["skipped"].append({"why": "全局暂停执行 (休假模式)"})
return out
from app.services import command_service, portfolio, strategy_service
try:
view = portfolio.positions_view()
except Exception as e: # noqa: BLE001 —— 守成: 读不到持仓不动任何东西
return {**out, "ok": False, "errors": [f"读持仓失败: {type(e).__name__}: {e}"]}
held = [p for p in view["held"] if int(p.get("total_qty") or 0) > 0]
if not held:
out["skipped"].append({"why": "当前无持仓"})
return out
codes = [p["ts_code"] for p in held]
# ---- 信号与既有状态一次取齐 (任一失败只废对应的边, 不废整轮) ----
try:
accum = accum_of(codes)
except Exception as e: # noqa: BLE001
accum = {}
out["errors"].append(f"吸筹取数失败 (边一/三/四本轮不动): {type(e).__name__}: {e}")
try:
heat, heat_meta = heat_of(codes)
if heat_meta.get("stale"):
heat = {}
out["skipped"].append({"why": f"热度表停更 (最新 {heat_meta.get('td')}), "
f"热度相关的边本轮不动"})
except Exception as e: # noqa: BLE001
heat = {}
out["errors"].append(f"热度取数失败 (边二/四本轮不动): {type(e).__name__}: {e}")
for c in codes:
a = accum.get(c)
if a and a["cls"] == CLS_UNKNOWN:
out["unknown_states"].append({"ts_code": c, "state": a.get("state")})
if out["unknown_states"]:
logger.warning("[自动挂载] %s 只票的吸筹定性在词表外 (按无标志处理), 样本: %s —— "
"五档契约可能变了, 与决策系统核对", len(out["unknown_states"]),
out["unknown_states"][:3])
try:
strat_all = pms_repo.list_strategies(statuses=["ACTIVE", "PAUSED"], limit=500,
include_archived=True)
except Exception as e: # noqa: BLE001
return {**out, "ok": False, "errors": out["errors"] +
[f"读策略表失败, 整轮守成不动: {type(e).__name__}: {e}"]}
strat_by_code = {}
for s in strat_all:
strat_by_code.setdefault(s.get("ts_code"), s)
try:
cancelled = pms_repo.list_strategies(statuses=["CANCELLED"], limit=300,
include_archived=True)
except Exception as e: # noqa: BLE001
cancelled = []
out["errors"].append(f"读已撤策略失败 (冷却按无算): {e}")
optout, handoff_cool = cooldowns_from_cancelled(
cancelled, codes, today, optout_tdays=prm["optout_cooldown_tdays"],
handoff_tdays=prm["handoff_cooldown_tdays"])
attached_today = count_auto_today(
(list(strat_all) + list(cancelled)), today)
try:
black = command_service.blacklist()
except Exception: # noqa: BLE001
black = set()
try:
from app.services import executor
live_codes = {i.get("ts_code") for i in
pms_repo.list_instructions(statuses=list(executor.LIVE), limit=300)}
live_codes |= {pl.get("ts_code") for pl in
pms_repo.list_plans(statuses=["PENDING", "GATED", "EXECUTING"],
limit=300)}
except Exception as e: # noqa: BLE001
live_codes = set()
out["errors"].append(f"读在途失败 (在途排除按无算): {e}")
buypause = {}
try:
buypause = strategy_service.buypause_map()
except Exception: # noqa: BLE001
pass
# ---- 逐票走状态机 ----
for p in held:
out["checked"] += 1
code = p["ts_code"]
try:
a = accum.get(code) or {}
fresh = (a.get("age_days") is not None
and a["age_days"] <= prm["accum_stale_tdays"] * 2)
hv = heat.get(code)
st = strat_by_code.get(code)
if st:
_tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool,
today, dry_run, out, strategy_service)
continue
edge, why = plan_edge(cls=a.get("cls"), fresh=fresh, heat=hv,
cushion=p.get("cushion_pct"), prm=prm)
if not edge:
if why:
out["skipped"].append({"ts_code": code, "why": why})
continue
_attach_one(edge, why, p, a, hv, prm, view, black, live_codes, optout,
attached_today, today, dry_run, out, strategy_service)
attached_today = out["_attached_today"]
except Exception as e: # noqa: BLE001 —— 单票异常不拖垮整轮
logger.exception("[自动挂载] %s 处理失败", code)
out["errors"].append(f"{code}: {type(e).__name__}: {e}")
out.pop("_attached_today", None)
out["ok"] = not out["errors"]
return out
def _tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool, today,
dry_run, out, strategy_service):
"""已挂策略的票: 边三 (派发停买/回明确恢复) 与 边四 (接力切换)。"""
code = p["ts_code"]
note = str(st.get("note") or "")
is_auto = note.startswith("自动挂载")
stype = str(st.get("type") or "").upper()
if not is_auto:
out["skipped"].append({"ts_code": code, "why": f"挂着人工策略 ({stype}), 自动挂载不碰它"})
return
if stype == "GRID":
# ---- 边四: 接力切换 (先于边三判 —— 都成立时说明已在拉升, 换止盈比停买更对) ----
ok_h, why_h = handoff_ready(price=p.get("price"), price_ok=p.get("price_ok"),
upper=(st.get("params") or {}).get("upper"),
heat=hv, cushion=p.get("cushion_pct"), prm=prm)
if ok_h and code in handoff_cool:
ok_h, why_h = False, "接力条件到了但在接力冷却期内, 不动"
if ok_h and _has_pending(st):
ok_h, why_h = False, "接力条件到了但策略有在途委托, 等它走完 (下一跳再看)"
if ok_h:
if dry_run:
out["handoffs"].append({"ts_code": code, "from": st["strategy_id"],
"dry_run": True, "why": why_h})
return
_do_handoff(st, p, hv, prm, why_h, out, strategy_service)
return
if why_h:
out["skipped"].append({"ts_code": code, "why": why_h})
# ---- 边三: 派发/失效 → 停买腿; 回明确 → 解除 accum 来源的暂停 ----
if R_EXIT not in prm["rules"]:
return
cls = a.get("cls")
pause_ent = (buypause or {}).get(code) or {}
if cls == CLS_DISTRIB or (cls == CLS_CLEAR and not fresh) or cls in (
CLS_NONE_SIGN, CLS_UNKNOWN, CLS_NOFIELD):
why = ("吸筹定性转高位派发" if cls == CLS_DISTRIB
else "吸筹标志消失或超日龄")
if pause_ent:
return # 已经停着 (accum 或风控来源), 不重复
if dry_run:
out["paused"].append({"ts_code": code, "dry_run": True, "why": why})
return
ids = strategy_service.pause_buy(code, reason=f"{why}, 网格买入腿暂停 (卖出照常)",
source="accum")
out["paused"].append({"ts_code": code, "strategies": ids, "why": why})
_ledger(code, "NOTE", f"边三: {why} —— 网格买入腿已暂停, 卖出与已买档位照常",
{"accum_state": a.get("state"), "accum_ymd": a.get("ymd")},
st["strategy_id"], out, action="NOTE")
elif cls == CLS_CLEAR and fresh and pause_ent.get("source") == "accum":
if dry_run:
out["resumed"].append({"ts_code": code, "dry_run": True})
return
r = strategy_service.clear_buypause(code, only_source="accum") or {}
if r.get("cleared"):
out["resumed"].append({"ts_code": code})
_ledger(code, "NOTE", "边三解除: 定性回到明确吸筹, 网格买入腿恢复",
{"accum_state": a.get("state")}, st["strategy_id"], out,
action="NOTE")
elif r.get("error"):
out["errors"].append(f"{code} 解除买入暂停失败: {r['error']}")
else:
out["skipped"].append({"ts_code": code, "why": f"已挂自动 {stype}, 本轮无事"})
def _attach_one(edge, why, p, a, hv, prm, view, black, live_codes, optout,
attached_today, today, dry_run, out, strategy_service):
"""无策略票走边一/边二: 排除项 → 参数生成 → 校验挂载 → 留痕。"""
code = p["ts_code"]
out["_attached_today"] = attached_today
if (code, edge) in optout:
out["skipped"].append({"ts_code": code, "why":
f"人工撤下过同类自动策略, 冷却期内不再自动挂 "
f"({prm['optout_cooldown_tdays']} 个交易日)"})
return
if code in live_codes:
out["skipped"].append({"ts_code": code, "why": "有在途指令或未完成方案, 缓到下一个扫描日"})
return
if edge == R_GRID:
if (p.get("frozen_reason") or "NONE") != "NONE":
out["skipped"].append({"ts_code": code, "why": "该股被冻结 (禁增持), 网格只剩半条腿, 不挂"})
return
if code in black:
out["skipped"].append({"ts_code": code, "why": "该股在黑名单, 网格买腿必被闸拦, 不挂"})
return
if attached_today >= prm["daily_max"]:
out["blocked"].append({"ts_code": code, "edge": edge,
"why": f"今日新挂名额已满 ({prm['daily_max']} 条), 留到明天"})
if not dry_run:
_ledger(code, "NOTE", f"想挂 {edge} 但今日新挂名额已满, 留到明天",
_snap(a, hv, p), None, out)
return
prm_view = view["params"]
if edge == R_GRID:
cap_room = max(0.0, _f(prm_view.get("stock_cap"), 0.08)
* _f(prm_view.get("scale"), 0.0)
- _f(p.get("market_value"), 0.0))
gp, gwhy = grid_params(price=(p.get("price") if p.get("price_ok") else None),
support=p.get("support_ref"), pressure=p.get("pressure_ref"),
band=prm["grid_band"], step_pct=prm["grid_step_pct"],
cap_room=cap_room, cap_ratio=prm["grid_cap_ratio"])
if not gp:
out["blocked"].append({"ts_code": code, "edge": edge, "why": gwhy})
if not dry_run:
_ledger(code, "NOTE", f"想挂网格但放弃: {gwhy}", _snap(a, hv, p), None, out)
return
stype, params = "GRID", gp
note = (f"{NOTE_AUTO}明确吸筹(评分 {a.get('score')}, 结论日 {a.get('ymd')}) → 网格 "
f"[{gp['lower']}~{gp['upper']}] 档距 {gp['step_pct']:.1%} "
f"上限 {gp['max_capital']:,.0f}")
else:
stype = "TRAIL"
params = {"giveback": prm["trail_giveback"], "sell_ratio": prm["trail_sell_ratio"]}
note = (f"{NOTE_AUTO}热度 {hv:.3f} 超阈值 {prm['heat_th']:.2f}"
+ (", 与明确吸筹双命中取止盈" if a.get("cls") == CLS_CLEAR else "")
+ f" → 跟踪止盈 回撤 {prm['trail_giveback']:.1%}{prm['trail_sell_ratio']:.0%}")
if dry_run:
out["attached"].append({"ts_code": code, "type": stype, "dry_run": True,
"why": why, "params": params})
out["_attached_today"] = attached_today + 1
return
r = strategy_service.attach({"ts_code": code, "type": stype, "autonomy": "auto",
"params": params, "note": note[:280]}, by="auto") or {}
if not r.get("ok"):
errs = "; ".join(str(x) for x in (r.get("errors") or ["挂载校验未过"]))
out["blocked"].append({"ts_code": code, "edge": edge, "why": errs})
_ledger(code, "NOTE", f"想挂 {stype} 被挂载校验挡下: {errs}", _snap(a, hv, p), None, out)
return
out["attached"].append({"ts_code": code, "type": stype,
"strategy_id": r.get("strategy_id"), "why": why})
out["_attached_today"] = attached_today + 1
_ledger(code, "PASS", f"{note[:200]} —— {why}",
{**_snap(a, hv, p), "params": params}, r.get("strategy_id"), out,
action="ATTACH")
logger.warning("[自动挂载] %s%s: %s", code, stype, why)
def _do_handoff(st, p, hv, prm, why, out, strategy_service):
"""边四落地: 撤网格 (打接力标记) → 挂止盈 → 高水位从当日高点起算 → 留痕。"""
code = p["ts_code"]
old_note = str(st.get("note") or "")
r1 = strategy_service.set_status(st["strategy_id"], "CANCELLED", by="auto") or {}
if not r1.get("ok"):
out["errors"].append(f"{code} 接力第一步撤网格失败: {r1.get('error')}")
return
n = pms_repo.update_strategy(st["strategy_id"],
note=(old_note + " " + MARK_HANDOFF_OUT)[:280])
if not n:
# 标记没打上: 冷却推导会把这次接力误判成人工撤下 —— 只是偏保守 (多冷却), 但要留痕
out["errors"].append(f"{code} 接力标记没写上 (影响 0 行), 冷却推导会偏保守")
params = {"giveback": prm["trail_giveback"], "sell_ratio": prm["trail_sell_ratio"]}
note = (f"{NOTE_HANDOFF}{why}"[:200] + f" 回撤 {prm['trail_giveback']:.1%}")
r2 = strategy_service.attach({"ts_code": code, "type": "TRAIL", "autonomy": "auto",
"params": params, "note": note[:280]}, by="auto") or {}
if not r2.get("ok"):
errs = "; ".join(str(x) for x in (r2.get("errors") or ["挂载校验未过"]))
out["errors"].append(f"{code} 接力第二步挂止盈失败 (网格已撤!): {errs} —— "
f"该票此刻无策略保护, 请人工处理")
_ledger(code, "REJECT", f"接力半途而废: 网格已撤但止盈没挂上 ({errs})",
{"heat": hv}, st["strategy_id"], out, action="HANDOFF")
return
hw0 = _f((p.get("price") if p.get("price_ok") else None), 0.0)
if hw0:
nn = pms_repo.update_strategy(r2["strategy_id"], state={"high_water": round(hw0, 3)})
if not nn:
out["errors"].append(f"{code} 止盈高水位初始化没写上 (影响 0 行), "
f"将从下一跳现价起算, 只偏保守")
out["handoffs"].append({"ts_code": code, "from": st["strategy_id"],
"to": r2.get("strategy_id"), "why": why})
_ledger(code, "PASS", f"接力切换: {why}",
{"heat": hv, "from": st["strategy_id"], "to": r2.get("strategy_id")},
r2.get("strategy_id"), out, action="HANDOFF")
logger.warning("[自动挂载] %s 接力: 网格 %s → 止盈 %s", code, st["strategy_id"],
r2.get("strategy_id"))
def _has_pending(st) -> bool:
"""策略上一笔委托还在途? 查不到按无在途 (接力多等一天没有代价, 误停一天有)。"""
pend = (st.get("state") or {}).get("pending") or {}
iid = pend.get("iid")
if not iid:
return False
try:
ins = pms_repo.get_instruction(iid)
except Exception: # noqa: BLE001
return False
return bool(ins and ins.get("status") in ("PROPOSED", "RULE_PASSED", "DISPATCHED"))
def _snap(a, hv, p) -> dict:
return {"accum_state": (a or {}).get("state"), "accum_score": (a or {}).get("score"),
"accum_ymd": (a or {}).get("ymd"), "heat": hv,
"price": (p or {}).get("price"), "cushion_pct": (p or {}).get("cushion_pct")}
def _ledger(code, verdict, reason, hard, ref_id, out, *, action="ATTACH"):
try:
pms_repo.insert_ledger(ts_code=code, action=action, arbiter="rule", verdict=verdict,
price_at=_f((hard or {}).get("price"), 0.0) or 0,
hard_numbers=hard, ref_id=ref_id, reason=str(reason)[:500])
except Exception as e: # noqa: BLE001 —— 留痕失败不拦动作本体, 但要说
out["errors"].append(f"{code} 留痕失败: {type(e).__name__}: {e}")

View File

@ -194,3 +194,25 @@ def resume_buy(strategy_id: str, by: str = "user") -> dict:
if not r.get("ok"): if not r.get("ok"):
return {"ok": False, "error": r.get("error")} return {"ok": False, "error": r.get("error")}
return {"ok": True, "strategy_id": strategy_id, "ts_code": code} return {"ok": True, "strategy_id": strategy_id, "ts_code": code}
def clear_buypause(ts_code: str, only_source: str = None) -> dict:
"""按票解除买入暂停 (strategy_advisor 边三恢复用, 2026-08-25)。
only_source 给了就只清对应来源的暂停项 advisor "accum", 这样它只解除
自己停的, 不会把决策系统风控 (source="signal") 停的顺手放开; 页面上人停的同理
cleared=False ok=True 表示没有可清的项 (没停过, 或来源不匹配), 不算错"""
if not ts_code:
return {"ok": False, "cleared": False, "error": "ts_code 为空"}
m = buypause_map()
ent = m.get(ts_code)
if not ent:
return {"ok": True, "cleared": False}
if only_source and ent.get("source") != only_source:
return {"ok": True, "cleared": False,
"why": f"暂停来源是 {ent.get('source')}, 不动 (只清 {only_source})"}
m.pop(ts_code, None)
r = _save_buypause(m)
if not r.get("ok"):
return {"ok": False, "cleared": False, "error": r.get("error")}
return {"ok": True, "cleared": True, "ts_code": ts_code}

View File

@ -536,6 +536,15 @@ def api_macro_adopt(payload: dict = Body(default={})):
return ok_logged("macro_adopt", macro_service.adopt, key, params={"signal_key": key}) return ok_logged("macro_adopt", macro_service.adopt, key, params={"signal_key": key})
@app.post("/api/ops/strategy-attach-scan")
def api_strategy_attach_scan(dry_run: bool = Query(False)):
"""手动策略自动挂载 (= 09:40 调度位)。dry_run=true 只判不写 —— 不挂、不留痕、
不动买入暂停表, 返回本轮会做什么, 供上线判收与日常复核"""
from app.services import strategy_advisor
return ok_logged("strategy_attach_scan", strategy_advisor.scan, dry_run=dry_run,
params={"dry_run": dry_run})
@app.get("/api/upstream/plan") @app.get("/api/upstream/plan")
def api_upstream_plan(limit: int = Query(30), date: str = Query(None), def api_upstream_plan(limit: int = Query(30), date: str = Query(None),
bucket: str = Query("main")): bucket: str = Query("main")):

View File

@ -238,6 +238,25 @@ class Settings(BaseSettings):
PMS_T0_GLOBAL_DAY_LOSS: float = 0.01 # 全局当日T亏熔断 PMS_T0_GLOBAL_DAY_LOSS: float = 0.01 # 全局当日T亏熔断
PMS_STRATEGY_ENABLED: bool = False # 个股交易方案(策略)层总开关; False=strategy_runner 空转不评估 PMS_STRATEGY_ENABLED: bool = False # 个股交易方案(策略)层总开关; False=strategy_runner 空转不评估
# --- 策略自动挂载 (个股打法状态机, STRATEGY_AUTO_ATTACH_PLAN.md) ---
# 每交易日 09:40 由 scheduler.strategy_attach 跑一次, 只做「配置动作」(挂/换/暂停买腿),
# 真正的买卖仍由 strategy_runner 逐分钟评估、逐笔过规则闸与全部熔断。
# 模拟仓拍板 (2026-08-25): 全自动上线不设观察期; ENABLED 已进 param_store.FAIL_CLOSED
# (参数表读不到时按关处理, 基础设施故障期间宁可不挂)。
PMS_AUTO_STRATEGY_ENABLED: bool = True # 总开关; 关 = 09:40 调度位空转不挂不撤
PMS_AUTO_STRATEGY_RULES: str = "accum_grid,heat_trail,accum_exit,handoff" # 启用的边
PMS_AUTO_STRATEGY_DAILY_MAX: int = 2 # 每日自动新挂上限 (接力换挂不计入)
PMS_AUTO_ACCUM_STALE_TDAYS: int = 3 # 吸筹结论日龄超此 (交易日) 视为过期不触发
PMS_AUTO_GRID_BAND: float = 0.08 # 支撑压力锚不住时网格区间退化为 现价×(1±带宽)
PMS_AUTO_GRID_STEP_PCT: float = 0.02 # 自动网格步长 (占中枢价)
PMS_AUTO_GRID_CAP_RATIO: float = 0.5 # 网格最大投入 = 单股上限余量 × 此比例
PMS_AUTO_HEAT_TH: float = 0.80 # 热度阈值 0~1 (2026-08-25 实测 ≥0.8 约前 4%)
PMS_AUTO_TRAIL_GIVEBACK: float = 0.05 # 自动跟踪止盈: 距高水位回撤触发
PMS_AUTO_TRAIL_SELL_RATIO: float = 0.5 # 自动跟踪止盈: 触发时卖出比例
PMS_AUTO_HANDOFF_ENABLED: bool = True # 边四接力 (网格→止盈) 独立开关, 页面可单独关
PMS_AUTO_HANDOFF_COOLDOWN_TDAYS: int = 10 # 接力后同票网格边冷却 (交易日)
PMS_AUTO_OPTOUT_COOLDOWN_TDAYS: int = 10 # 人工撤下后同票同规则冷却 (交易日)
# --- 决策系统信号消化 (设计 §10) --- # --- 决策系统信号消化 (设计 §10) ---
PMS_SIGNAL_ENABLED: bool = True PMS_SIGNAL_ENABLED: bool = True
PMS_SIGNAL_GROUP: str = "pms_signal_consumer" # 独立消费组, 不与 trading_service 抢消息 PMS_SIGNAL_GROUP: str = "pms_signal_consumer" # 独立消费组, 不与 trading_service 抢消息

View File

@ -30,7 +30,13 @@ from app.repo.downstream_repo import to_dot, to_prefix # noqa: E402
from app.services import param_store # noqa: E402 from app.services import param_store # noqa: E402
# 决策系统定性词表 (契约; 见方案「口径同源声明」)。不在表里的词一律当无标志。 # 决策系统定性词表 (契约; 见方案「口径同源声明」)。不在表里的词一律当无标志。
# v1.1 (2026-08-25): 归类从前缀匹配改成**子串包含** —— 首跑 62 只「词表外」的根因是
# state 带前后缀修饰 (如 "→ 高位派发风险"), 数据底座 feed.py 的 _ACCUM_KEEP 用的
# 正是 any(k in state)。次序即优先级, 派发最先 (两词同现按保守方向), 与 strategy_advisor
# 的 classify_accum 完全同法 —— 探测读数和线上判定必须是同一双眼睛。
FIVE_STATES = ("明确吸筹", "潜在吸筹", "无吸筹迹象", "高位派发", "信号不明") FIVE_STATES = ("明确吸筹", "潜在吸筹", "无吸筹迹象", "高位派发", "信号不明")
_CLS_ORDER = (("派发", "高位派发"), ("明确吸筹", "明确吸筹"), ("潜在吸筹", "潜在吸筹"),
("无吸筹迹象", "无吸筹迹象"), ("不明", "信号不明"))
ACCUM_WINDOW_DAYS = 45 # 每票取近 45 自然日内最新一条 (方案口径是 30 天, 取宽探测) ACCUM_WINDOW_DAYS = 45 # 每票取近 45 自然日内最新一条 (方案口径是 30 天, 取宽探测)
@ -66,10 +72,12 @@ def _fund_flow(raw):
def _classify(state): def _classify(state):
s = str(state or "").strip() s = str(state or "").strip()
for k in FIVE_STATES: if not s:
if s.startswith(k): return "无字段"
return k for key, cls in _CLS_ORDER:
return "词表外" if s else "无字段" if key in s:
return cls
return "词表外"
def _variants(dot_codes): def _variants(dot_codes):
@ -100,15 +108,20 @@ def latest_accum_day():
def accum_distribution(td): def accum_distribution(td):
"""最新一日的定性分布 (全池)。""" """最新一日的定性分布 (全池)。返回 (总数, 分布, 词表外原文样本)。"""
rows = fetch_all("SELECT stock_code, raw_logic_json FROM strategy_daily_results " rows = fetch_all("SELECT stock_code, raw_logic_json FROM strategy_daily_results "
"WHERE trade_date = :td", {"td": td}) "WHERE trade_date = :td", {"td": td})
dist, total = {}, 0 dist, total, oov = {}, 0, {}
for r in rows: for r in rows:
total += 1 total += 1
ff = _fund_flow(r.get("raw_logic_json")) ff = _fund_flow(r.get("raw_logic_json"))
dist[_classify((ff or {}).get("state"))] = dist.get(_classify((ff or {}).get("state")), 0) + 1 state = (ff or {}).get("state")
return total, dist cls = _classify(state)
dist[cls] = dist.get(cls, 0) + 1
if cls == "词表外":
oov[str(state)] = oov.get(str(state), 0) + 1
samples = sorted(oov.items(), key=lambda kv: -kv[1])[:10]
return total, dist, samples
def accum_of(dot_codes): def accum_of(dot_codes):
@ -181,16 +194,18 @@ def main():
if accum_td is None: if accum_td is None:
print(" 结论表里一行都没有 —— 先确认决策系统夜间链在跑") print(" 结论表里一行都没有 —— 先确认决策系统夜间链在跑")
else: else:
total, dist = accum_distribution(accum_td) total, dist, oov_samples = accum_distribution(accum_td)
age = _age_days(_ymd_int(accum_td)) age = _age_days(_ymd_int(accum_td))
print(f" 最新结论日 {accum_td} (距今 {age} 个自然日), 当日共 {total}") print(f" 最新结论日 {accum_td} (距今 {age} 个自然日), 当日共 {total}")
for k in list(FIVE_STATES) + ["词表外", "无字段"]: for k in list(FIVE_STATES) + ["词表外", "无字段"]:
if dist.get(k): if dist.get(k):
print(f" {k:<6} {dist[k]}") print(f" {k:<6} {dist[k]}")
if dist.get("词表外"): if dist.get("词表外"):
print(" ⚠ 出现词表外定性 —— 五档契约可能漂了, 需与决策系统核对") print(" ⚠ 仍有子串也认不出的定性 —— 五档契约可能真漂了, 拿下面原文与决策系统核对:")
print(" 对读: 8 月 24 日前端清单读数为 明确192 / 潜在278 / 不明82 / 派发8, " for s, n in oov_samples:
"同量级即两个读者一致") print(f"{s}× {n}")
print(" 对读口径: 本段是**单日**分布; 数据底座前端清单是 30 天窗口每票最新一条,"
" 总数天然更大, 两边比的是档位结构而不是绝对数")
except Exception as e: except Exception as e:
print(f" ✗ 本段失败: {type(e).__name__}: {e}") print(f" ✗ 本段失败: {type(e).__name__}: {e}")

View File

@ -26,8 +26,11 @@
误判配额已出完/清仓命令即时撤策略/买入提议/在途买单; 误判配额已出完/清仓命令即时撤策略/买入提议/在途买单;
在途与配额文案拆分; 减仓不掐策略腿 (12 ) 在途与配额文案拆分; 减仓不掐策略腿 (12 )
test_batch16_units.py 软归档 archived_at: 单表守卫/只归终态/列表默认排除 (5 ) test_batch16_units.py 软归档 archived_at: 单表守卫/只归终态/列表默认排除 (5 )
test_batch17_units.py 策略自动挂载: 定性归类(子串+保守优先)/网格参数生成/
连边矩阵/note 约定与冷却推导/接力判定/clear_buypause/
编排冒烟(dry_run 滴水不写/名额/边三/边四全链) (34 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 ) test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
519 553
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
""" """
import os import os
@ -41,7 +44,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py", "test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py",
"test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py", "test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py",
"test_batch13_units.py", "test_batch14_units.py", "test_batch15_units.py", "test_batch13_units.py", "test_batch14_units.py", "test_batch15_units.py",
"test_batch16_units.py", "test_wiring.py"] "test_batch16_units.py", "test_batch17_units.py", "test_wiring.py"]
def main(): def main():

View File

@ -0,0 +1,684 @@
# -*- coding: utf-8 -*-
"""
第十七批: 策略自动挂载 (个股打法状态机) STRATEGY_AUTO_ATTACH_PLAN.md 步骤二
==============================================================================
零外部依赖, 不连库四块内容:
1. 纯逻辑: 定性归类 (子串包含+保守优先级) / 网格参数生成与放弃路径 /
连边矩阵 (双命中取止盈负垫不挂) / 日上限计数 (接力不占) / 冷却推导 / 接力判定;
2. note 约定字面量钉死 冷却与计数全靠它, 改一个字就把无状态推导改坏;
3. strategy_service.clear_buypause 的来源匹配 (只清自己停的, 不放开风控停的);
4. 编排冒烟 (全打桩): dry_run 滴水不写 / 真挂走 attach+留痕 / 名额满挡下 /
边三停买腿与恢复 / 边四接力全链 (撤网格打标记挂止盈初始化高水位)
运行: python scripts/test_batch17_units.py
"""
import os
import sys
import traceback
from types import SimpleNamespace
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import param_store # noqa: E402
from app.services import strategy_advisor as adv # noqa: E402
from app.services import strategy_service as svc # noqa: E402
from app.repo import pms_repo # noqa: E402
RESULTS = []
TODAY = 20260825
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
def _prm(**kw):
d = {"enabled": True,
"rules": {adv.R_GRID, adv.R_TRAIL, adv.R_EXIT, adv.R_HANDOFF},
"daily_max": 2, "accum_stale_tdays": 3, "grid_band": 0.08,
"grid_step_pct": 0.02, "grid_cap_ratio": 0.5, "heat_th": 0.80,
"trail_giveback": 0.05, "trail_sell_ratio": 0.5, "handoff_enabled": True,
"handoff_cooldown_tdays": 10, "optout_cooldown_tdays": 10}
d.update(kw)
return d
def _out():
return {"ok": True, "checked": 0, "attached": [], "handoffs": [], "paused": [],
"resumed": [], "blocked": [], "skipped": [], "errors": [],
"unknown_states": []}
def _pos(**kw):
d = {"ts_code": "600000.SH", "total_qty": 1000, "avail_qty": 1000,
"price": 10.0, "price_ok": True, "market_value": 10000.0,
"cushion_pct": 0.05, "support_ref": 9.0, "pressure_ref": 11.0,
"frozen_reason": "NONE"}
d.update(kw)
return d
# ================================================================
# [一] 定性归类: 子串包含 + 保守优先级
# ================================================================
@case("[归类] 五档标准词各归各位 (契约钉死)")
def _():
assert adv.classify_accum("明确吸筹") == adv.CLS_CLEAR
assert adv.classify_accum("潜在吸筹") == adv.CLS_MAYBE
assert adv.classify_accum("无吸筹迹象") == adv.CLS_NONE_SIGN
assert adv.classify_accum("高位派发") == adv.CLS_DISTRIB
assert adv.classify_accum("信号不明") == adv.CLS_UNCLEAR
@case("[归类] 子串包含: 带前后缀修饰照样认 (探测实测 62 只词表外的根因)")
def _():
# 数据底座 feed.py 的 _ACCUM_KEEP 用 any(k in state), 前缀匹配会把这些全漏掉
assert adv.classify_accum("明确吸筹(强)") == adv.CLS_CLEAR
assert adv.classify_accum("近期呈潜在吸筹迹象") == adv.CLS_MAYBE
assert adv.classify_accum("→ 高位派发风险") == adv.CLS_DISTRIB
assert adv.classify_accum("走势信号不明朗") == adv.CLS_UNCLEAR
@case("[归类] 两词同现按保守方向: 派发优先于明确吸筹")
def _():
assert adv.classify_accum("前期明确吸筹, 现转高位派发") == adv.CLS_DISTRIB
assert adv.classify_accum("高位派发后再现潜在吸筹") == adv.CLS_DISTRIB
@case("[归类] 空值→无字段, 认不出→词表外 (调用方一律按无标志处理)")
def _():
assert adv.classify_accum(None) == adv.CLS_NOFIELD
assert adv.classify_accum("") == adv.CLS_NOFIELD
assert adv.classify_accum(" ") == adv.CLS_NOFIELD
assert adv.classify_accum("横盘整理") == adv.CLS_UNKNOWN
assert adv.classify_accum("chip_concentration_up") == adv.CLS_UNKNOWN
# ================================================================
# [二] 网格参数生成 (方案附录二)
# ================================================================
@case("[网格] 支撑压力锚得住: 上界=压力×1.01 下界=支撑×0.99, 手数为整百")
def _():
gp, why = adv.grid_params(price=10.0, support=9.0, pressure=11.0, band=0.08,
step_pct=0.02, cap_room=70000.0, cap_ratio=0.5)
assert gp, why
assert gp["lower"] == 8.91 and gp["upper"] == 11.11 and gp["center"] == 10.0, gp
assert gp["max_capital"] == 35000.0, gp
assert gp["per_lot"] % 100 == 0 and gp["per_lot"] >= 100, gp
# 下方档数 5 档 (1.09 / 0.2), 每档预算 7000 → 700 股
assert gp["per_lot"] == 700, gp
@case("[网格] 锚不住 (支撑压力缺失/不合形) 退百分比带")
def _():
gp, _ = adv.grid_params(price=10.0, support=0, pressure=0, band=0.08,
step_pct=0.02, cap_room=70000.0, cap_ratio=0.5)
assert gp and gp["lower"] == 9.2 and gp["upper"] == 10.8, gp
# 支撑在现价上方 (不合形) 同样退带宽, 不硬凑
gp2, _ = adv.grid_params(price=10.0, support=12.0, pressure=11.0, band=0.08,
step_pct=0.02, cap_room=70000.0, cap_ratio=0.5)
assert gp2 and gp2["lower"] == 9.2 and gp2["upper"] == 11.11, gp2
@case("[网格] 三条放弃路径: 无实时价 / 买不起一手 / 区间不成立")
def _():
gp, why = adv.grid_params(price=None, support=9, pressure=11, band=0.08,
step_pct=0.02, cap_room=70000, cap_ratio=0.5)
assert gp is None and "实时价" in why, why
gp, why = adv.grid_params(price=10.0, support=9, pressure=11, band=0.08,
step_pct=0.02, cap_room=1500.0, cap_ratio=0.5)
assert gp is None and "买不起一手" in why, why # 750 元 < 一手 1000 元
gp, why = adv.grid_params(price=10.0, support=0, pressure=0, band=1.5,
step_pct=0.02, cap_room=70000, cap_ratio=0.5)
assert gp is None and "区间不成立" in why, why # 带宽>1 → 下界为负 (防御路径)
@case("[网格] 档距下限 0.5%: 传入更小的步长被抬起来")
def _():
gp, _ = adv.grid_params(price=10.0, support=9.0, pressure=11.0, band=0.08,
step_pct=0.001, cap_room=70000.0, cap_ratio=0.5)
assert gp and gp["step_pct"] == 0.005, gp
# ================================================================
# [三] 连边矩阵 (plan_edge)
# ================================================================
@case("[连边] 双命中 (明确吸筹+高热+正垫) 取止盈 —— 保利润优先于做波段")
def _():
e, why = adv.plan_edge(cls=adv.CLS_CLEAR, fresh=True, heat=0.9, cushion=0.05,
prm=_prm())
assert e == adv.R_TRAIL and "双命中" in why, (e, why)
@case("[连边] 单命中各走各边: 高热正垫→止盈; 明确吸筹新鲜→网格")
def _():
e, why = adv.plan_edge(cls=adv.CLS_NOFIELD, fresh=False, heat=0.85,
cushion=0.02, prm=_prm())
assert e == adv.R_TRAIL and "超阈值" in why, (e, why)
e, why = adv.plan_edge(cls=adv.CLS_CLEAR, fresh=True, heat=0.3, cushion=-0.02,
prm=_prm())
assert e == adv.R_GRID, (e, why)
@case("[连边] 负垫永不挂止盈 (把位置留给深亏补仓评估) —— 小口径钉死")
def _():
e, why = adv.plan_edge(cls=adv.CLS_NOFIELD, fresh=False, heat=0.95,
cushion=-0.08, prm=_prm())
assert e is None and "补仓评估" in why, (e, why)
# 垫子缺失 (盘前无价) 同样不挂
e, _ = adv.plan_edge(cls=adv.CLS_NOFIELD, fresh=False, heat=0.95,
cushion=None, prm=_prm())
assert e is None
@case("[连边] 吸筹结论超日龄视为无标志; 潜在吸筹不触发 (拍板①只认明确)")
def _():
e, why = adv.plan_edge(cls=adv.CLS_CLEAR, fresh=False, heat=None, cushion=0.05,
prm=_prm())
assert e is None and "超日龄" in why, (e, why)
e, _ = adv.plan_edge(cls=adv.CLS_MAYBE, fresh=True, heat=None, cushion=0.05,
prm=_prm())
assert e is None
@case("[连边] 边清单裁剪生效: 去掉 heat_trail 后双命中落回网格")
def _():
p = _prm(rules={adv.R_GRID, adv.R_EXIT})
e, _ = adv.plan_edge(cls=adv.CLS_CLEAR, fresh=True, heat=0.9, cushion=0.05, prm=p)
assert e == adv.R_GRID, e
p = _prm(rules={adv.R_TRAIL})
e, _ = adv.plan_edge(cls=adv.CLS_CLEAR, fresh=True, heat=0.3, cushion=0.05, prm=p)
assert e is None, e
# ================================================================
# [四] note 约定与无状态推导 (日上限 / 两种冷却)
# ================================================================
@case("[约定] note 字面量钉死 —— 冷却与计数全靠它, 改一个字就坏")
def _():
assert adv.NOTE_AUTO == "自动挂载: "
assert adv.NOTE_HANDOFF == "自动挂载(接力): "
assert adv.MARK_HANDOFF_OUT == "[接力撤下]"
assert adv.NOTE_HANDOFF.startswith("自动挂载") # 冷却推导认「自动挂载」开头
@case("[计数] 今日新挂只数常规挂计入、接力与人工与昨日不计入")
def _():
rows = [
{"note": "自动挂载: 明确吸筹→网格", "created_at": "2026-08-25 09:40:01"},
{"note": "自动挂载: 高热→止盈", "created_at": "2026-08-25 09:40:02"},
{"note": "自动挂载(接力): 站上上界", "created_at": "2026-08-25 09:41:00"}, # 接力不算
{"note": "手工挂的网格", "created_at": "2026-08-25 10:00:00"}, # 人工不算
{"note": "自动挂载: 昨天挂的", "created_at": "2026-08-24 09:40:00"}, # 昨日不算
{"note": None, "created_at": "2026-08-25 09:40:00"},
]
assert adv.count_auto_today(rows, TODAY) == 2
@case("[冷却] 人工撤下→同票同规则进冷却; 接力标记→只进接力冷却不算人工")
def _():
rows = [
# 人工撤下的自动网格, 票还持有, 5 天前 → optout
{"ts_code": "600000.SH", "type": "GRID", "note": "自动挂载: 网格",
"updated_at": "2026-08-20 10:00:00"},
# 接力撤下的网格 → 只进 handoff_cool
{"ts_code": "600519.SH", "type": "GRID", "note": "自动挂载: 网格 [接力撤下]",
"updated_at": "2026-08-20 11:00:00"},
]
opt, hand = adv.cooldowns_from_cancelled(rows, ["600000.SH", "600519.SH"], TODAY,
optout_tdays=10, handoff_tdays=10)
assert ("600000.SH", adv.R_GRID) in opt, opt
assert "600519.SH" not in {c for c, _ in opt}, opt
assert hand == {"600519.SH"}, hand
@case("[冷却] 票已不持有不算人工撤下 (清仓清场撤的不该罚) / 超窗口出冷却 / 人工策略不掺和")
def _():
rows = [
{"ts_code": "600000.SH", "type": "GRID", "note": "自动挂载: 网格",
"updated_at": "2026-08-20 10:00:00"}, # 不在持仓 → 不算
{"ts_code": "600519.SH", "type": "TRAIL", "note": "自动挂载: 止盈",
"updated_at": "2026-07-10 10:00:00"}, # 46 天 > 10td×2 → 出冷却
{"ts_code": "000001.SZ", "type": "GRID", "note": "手工网格",
"updated_at": "2026-08-24 10:00:00"}, # 人工挂的, 撤了也不进冷却
]
opt, hand = adv.cooldowns_from_cancelled(rows, ["600519.SH", "000001.SZ"], TODAY,
optout_tdays=10, handoff_tdays=10)
assert opt == set() and hand == set(), (opt, hand)
# ================================================================
# [五] 接力判定 (handoff_ready)
# ================================================================
@case("[接力] 齐活才走: 站上上界 + 热度达标 + 正垫 → True")
def _():
ok, why = adv.handoff_ready(price=10.6, price_ok=True, upper=10.5, heat=0.85,
cushion=0.06, prm=_prm())
assert ok and "站上网格上界" in why, (ok, why)
@case("[接力] 没站上上界静默不动; 站上了但热度不够/垫不正要说清")
def _():
ok, why = adv.handoff_ready(price=10.2, price_ok=True, upper=10.5, heat=0.9,
cushion=0.05, prm=_prm())
assert not ok and why == "", (ok, why)
ok, why = adv.handoff_ready(price=10.6, price_ok=True, upper=10.5, heat=0.5,
cushion=0.05, prm=_prm())
assert not ok and "未达阈值" in why, why
ok, why = adv.handoff_ready(price=10.6, price_ok=True, upper=10.5, heat=0.9,
cushion=-0.01, prm=_prm())
assert not ok and "不接力" in why, why
@case("[接力] 独立开关与边清单双闸; 价格拿成本顶的 (price_ok=False) 不判")
def _():
ok, _ = adv.handoff_ready(price=10.6, price_ok=True, upper=10.5, heat=0.9,
cushion=0.05, prm=_prm(handoff_enabled=False))
assert not ok
ok, _ = adv.handoff_ready(price=10.6, price_ok=True, upper=10.5, heat=0.9,
cushion=0.05,
prm=_prm(rules={adv.R_GRID, adv.R_TRAIL, adv.R_EXIT}))
assert not ok
ok, _ = adv.handoff_ready(price=10.6, price_ok=False, upper=10.5, heat=0.9,
cushion=0.05, prm=_prm())
assert not ok
# ================================================================
# [六] clear_buypause: 只清自己停的
# ================================================================
def _with_buypause(entry, fn):
import json as _json
saved = {}
orig_get, orig_set = pms_repo.get_param, pms_repo.set_param
pms_repo.get_param = lambda k: _json.dumps(entry) if entry is not None else None
pms_repo.set_param = lambda k, v, by="system": saved.update({"key": k, "val": v}) or {"ok": True}
try:
r = fn()
finally:
pms_repo.get_param, pms_repo.set_param = orig_get, orig_set
return r, saved
@case("[停买] clear_buypause 来源匹配才清: accum 停的清得掉, 风控停的不动")
def _():
r, saved = _with_buypause({"600000.SH": {"source": "accum", "reason": "x"}},
lambda: svc.clear_buypause("600000.SH", only_source="accum"))
assert r == {"ok": True, "cleared": True, "ts_code": "600000.SH"}, r
assert saved and "600000.SH" not in saved["val"], saved
r, saved = _with_buypause({"600000.SH": {"source": "signal", "reason": "风控预警"}},
lambda: svc.clear_buypause("600000.SH", only_source="accum"))
assert r["ok"] and not r["cleared"] and "signal" in r.get("why", ""), r
assert not saved, saved # 没清 → 一个字都没写
@case("[停买] 没停过→ok 且 cleared=False; 空码→ok=False")
def _():
r, saved = _with_buypause({}, lambda: svc.clear_buypause("600000.SH",
only_source="accum"))
assert r["ok"] and not r["cleared"] and not saved, (r, saved)
r, _ = _with_buypause({}, lambda: svc.clear_buypause(""))
assert not r["ok"], r
# ================================================================
# [七] 参数登记 (param_store)
# ================================================================
@case("[参数] 总开关进 FAIL_CLOSED=False; 13 个键全有中文说明")
def _():
assert param_store.FAIL_CLOSED.get("PMS_AUTO_STRATEGY_ENABLED") is False
for k in ("PMS_AUTO_STRATEGY_ENABLED", "PMS_AUTO_STRATEGY_RULES",
"PMS_AUTO_STRATEGY_DAILY_MAX", "PMS_AUTO_ACCUM_STALE_TDAYS",
"PMS_AUTO_GRID_BAND", "PMS_AUTO_GRID_STEP_PCT", "PMS_AUTO_GRID_CAP_RATIO",
"PMS_AUTO_HEAT_TH", "PMS_AUTO_TRAIL_GIVEBACK", "PMS_AUTO_TRAIL_SELL_RATIO",
"PMS_AUTO_HANDOFF_ENABLED", "PMS_AUTO_HANDOFF_COOLDOWN_TDAYS",
"PMS_AUTO_OPTOUT_COOLDOWN_TDAYS"):
assert k in param_store.DESC, k
from config.settings import settings as _s
assert hasattr(_s, k), k
@case("[参数] 边清单写入口校验: 打错边名被拦, 合法值与越界数值各归各")
def _():
err = param_store._range_check("PMS_AUTO_STRATEGY_RULES", "accum_grid,typo_edge")
assert err and "typo_edge" in err, err
assert param_store._range_check("PMS_AUTO_STRATEGY_RULES",
"accum_grid,heat_trail,accum_exit,handoff") is None
assert param_store._range_check("PMS_AUTO_HEAT_TH", 1.5), "热度阈值 1.5 应越界"
assert param_store._range_check("PMS_AUTO_HEAT_TH", 0.8) is None
# ================================================================
# [八] 编排冒烟 (全打桩, 不连库)
# ================================================================
class _Rec:
"""记录调用的假 strategy_service / pms_repo 面板。"""
def __init__(self):
self.attach_calls, self.status_calls, self.pause_calls = [], [], []
self.clear_calls, self.ledger, self.upd_calls = [], [], []
def _scan_stubbed(rec, *, held, accum, heat, strategies=None, cancelled=None,
dry_run, prm=None, attach_ret=None):
"""把 scan() 的所有外部依赖打桩后跑一轮, 返回 out。"""
from app.services import command_service, portfolio, strategy_service
orig = {}
def keep(mod, name, fake):
orig[(mod, name)] = getattr(mod, name)
setattr(mod, name, fake)
view = {"held": held, "positions": held,
"params": {"scale": 1000000.0, "stock_cap": 0.08}}
n_attach = {"n": 0}
def fake_attach(cfg, by="user"):
rec.attach_calls.append((cfg, by))
n_attach["n"] += 1
return dict(attach_ret or {"ok": True, "strategy_id": f"S_NEW{n_attach['n']}"})
def fake_list_strategies(**kw):
sts = kw.get("statuses") or []
if "CANCELLED" in sts:
return list(cancelled or [])
return list(strategies or [])
keep(adv, "_params", lambda: prm or _prm())
keep(param_store, "get_bool",
lambda k, d=False: {"PMS_STRATEGY_ENABLED": True,
"PMS_GLOBAL_EXEC_HALT": False}.get(k, d))
keep(portfolio, "positions_view", lambda: view)
keep(adv, "accum_of", lambda codes: dict(accum))
keep(adv, "heat_of", lambda codes: (dict(heat), {"stale": False, "td": TODAY}))
keep(command_service, "blacklist", lambda: set())
keep(pms_repo, "list_strategies", fake_list_strategies)
keep(pms_repo, "list_instructions", lambda **kw: [])
keep(pms_repo, "list_plans", lambda **kw: [])
keep(pms_repo, "insert_ledger",
lambda **kw: rec.ledger.append(kw) or 1)
keep(pms_repo, "update_strategy",
lambda sid, **kw: rec.upd_calls.append((sid, kw)) or 1)
keep(strategy_service, "buypause_map", lambda: {})
keep(strategy_service, "attach", fake_attach)
keep(strategy_service, "set_status",
lambda sid, st, by="user": rec.status_calls.append((sid, st, by)) or
{"ok": True, "strategy_id": sid})
keep(strategy_service, "pause_buy",
lambda code, reason="", source="signal":
rec.pause_calls.append((code, source)) or ["SID"])
keep(strategy_service, "clear_buypause",
lambda code, only_source=None:
rec.clear_calls.append((code, only_source)) or {"ok": True, "cleared": True})
try:
return adv.scan(dry_run=dry_run)
finally:
for (mod, name), fn in orig.items():
setattr(mod, name, fn)
@case("[冒烟] dry_run 滴水不写: 报出会挂网格, 但 attach/留痕/停买全没动")
def _():
rec = _Rec()
out = _scan_stubbed(rec, held=[_pos()], heat={},
accum={"600000.SH": {"cls": adv.CLS_CLEAR, "state": "明确吸筹",
"score": 80, "ymd": TODAY, "age_days": 1}},
dry_run=True)
assert out["ok"], out["errors"]
assert out["attached"] and out["attached"][0]["dry_run"], out["attached"]
assert out["attached"][0]["ts_code"] == "600000.SH"
assert not rec.attach_calls and not rec.ledger and not rec.pause_calls, "dry_run 写了东西"
@case("[冒烟] 真挂网格: attach 收到区间与上限, note 以「自动挂载: 」开头, 留痕 ATTACH/PASS")
def _():
rec = _Rec()
out = _scan_stubbed(rec, held=[_pos()], heat={},
accum={"600000.SH": {"cls": adv.CLS_CLEAR, "state": "明确吸筹",
"score": 80, "ymd": TODAY, "age_days": 1}},
dry_run=False)
assert out["ok"] and out["attached"], (out["errors"], out["blocked"], out["skipped"])
cfg, by = rec.attach_calls[0]
assert by == "auto" and cfg["type"] == "GRID" and cfg["autonomy"] == "auto"
assert cfg["note"].startswith(adv.NOTE_AUTO), cfg["note"]
assert 0 < cfg["params"]["lower"] < 10.0 < cfg["params"]["upper"], cfg["params"]
assert cfg["params"]["max_capital"] == 35000.0, cfg["params"]
led = [x for x in rec.ledger if x.get("action") == "ATTACH"]
assert led and led[0]["verdict"] == "PASS" and led[0]["ref_id"] == "S_NEW1", led
@case("[冒烟] 每日名额: 第三只被挡并留痕说明, dry_run 同样受限")
def _():
held = [_pos(ts_code=c) for c in ("600000.SH", "600519.SH", "000001.SZ")]
accum = {c: {"cls": adv.CLS_CLEAR, "state": "明确吸筹", "score": 80,
"ymd": TODAY, "age_days": 1} for c in ("600000.SH", "600519.SH",
"000001.SZ")}
rec = _Rec()
out = _scan_stubbed(rec, held=held, accum=accum, heat={}, dry_run=True)
assert len(out["attached"]) == 2, out
assert len(out["blocked"]) == 1 and "名额已满" in out["blocked"][0]["why"], out["blocked"]
rec2 = _Rec()
out2 = _scan_stubbed(rec2, held=held, accum=accum, heat={}, dry_run=False)
assert len(rec2.attach_calls) == 2, rec2.attach_calls
assert any("名额已满" in x.get("reason", "") for x in rec2.ledger), rec2.ledger
@case("[冒烟] 排除项: 冻结票与热度停更都不挂; 词表外定性浮到 unknown_states")
def _():
held = [_pos(frozen_reason="RISK_FREEZE")]
accum = {"600000.SH": {"cls": adv.CLS_CLEAR, "state": "明确吸筹", "score": 80,
"ymd": TODAY, "age_days": 1}}
rec = _Rec()
out = _scan_stubbed(rec, held=held, accum=accum, heat={}, dry_run=False)
assert not out["attached"] and not rec.attach_calls
assert any("冻结" in s.get("why", "") for s in out["skipped"]), out["skipped"]
# 词表外
rec2 = _Rec()
out2 = _scan_stubbed(rec2, held=[_pos()], heat={},
accum={"600000.SH": {"cls": adv.CLS_UNKNOWN, "state": "横盘整理",
"score": None, "ymd": TODAY, "age_days": 1}},
dry_run=False)
assert out2["unknown_states"] and out2["unknown_states"][0]["state"] == "横盘整理"
assert not rec2.attach_calls
@case("[冒烟] 边三: 已挂自动网格遇派发 → pause_buy(source=accum) 一次, 已停不重复")
def _():
st = {"strategy_id": "S_G", "ts_code": "600000.SH", "type": "GRID",
"note": "自动挂载: 明确吸筹→网格", "params": {"upper": 10.5}, "state": {}}
rec = _Rec()
fake_svc = SimpleNamespace(
pause_buy=lambda code, reason="", source="signal":
rec.pause_calls.append((code, source)) or ["S_G"],
clear_buypause=lambda code, only_source=None:
rec.clear_calls.append((code, only_source)) or {"ok": True, "cleared": True})
orig_led = pms_repo.insert_ledger
pms_repo.insert_ledger = lambda **kw: rec.ledger.append(kw) or 1
try:
out = _out()
adv._tend_existing(st, _pos(price=10.0), {"cls": adv.CLS_DISTRIB, "state": "高位派发"},
True, 0.3, _prm(), {}, set(), TODAY, False, out, fake_svc)
assert rec.pause_calls == [("600000.SH", "accum")], rec.pause_calls
assert out["paused"] and any(x.get("action") == "NOTE" for x in rec.ledger)
# 已停着 (无论谁停的) 不重复停
out2 = _out()
adv._tend_existing(st, _pos(price=10.0), {"cls": adv.CLS_DISTRIB}, True, 0.3,
_prm(), {"600000.SH": {"source": "signal"}}, set(), TODAY,
False, out2, fake_svc)
assert len(rec.pause_calls) == 1 and not out2["paused"]
finally:
pms_repo.insert_ledger = orig_led
@case("[冒烟] 边三解除: 定性回明确且新鲜, 只解除 accum 来源的停买")
def _():
st = {"strategy_id": "S_G", "ts_code": "600000.SH", "type": "GRID",
"note": "自动挂载: 网格", "params": {"upper": 10.5}, "state": {}}
rec = _Rec()
fake_svc = SimpleNamespace(
pause_buy=lambda *a, **k: [],
clear_buypause=lambda code, only_source=None:
rec.clear_calls.append((code, only_source)) or {"ok": True, "cleared": True})
orig_led = pms_repo.insert_ledger
pms_repo.insert_ledger = lambda **kw: rec.ledger.append(kw) or 1
try:
out = _out()
adv._tend_existing(st, _pos(price=10.0),
{"cls": adv.CLS_CLEAR, "state": "明确吸筹"}, True, 0.3,
_prm(), {"600000.SH": {"source": "accum"}}, set(), TODAY,
False, out, fake_svc)
assert rec.clear_calls == [("600000.SH", "accum")], rec.clear_calls
assert out["resumed"] == [{"ts_code": "600000.SH"}], out["resumed"]
# 风控停的不归边三管
out2 = _out()
adv._tend_existing(st, _pos(price=10.0), {"cls": adv.CLS_CLEAR}, True, 0.3,
_prm(), {"600000.SH": {"source": "signal"}}, set(), TODAY,
False, out2, fake_svc)
assert len(rec.clear_calls) == 1 and not out2["resumed"]
finally:
pms_repo.insert_ledger = orig_led
@case("[冒烟] 边四全链: 撤网格→老 note 打[接力撤下]→挂止盈(接力 note)→高水位从现价起算")
def _():
st = {"strategy_id": "S_G", "ts_code": "600000.SH", "type": "GRID",
"note": "自动挂载: 网格", "params": {"upper": 10.5}, "state": {}}
rec = _Rec()
fake_svc = SimpleNamespace(
set_status=lambda sid, s, by="user": rec.status_calls.append((sid, s, by)) or
{"ok": True},
attach=lambda cfg, by="user": rec.attach_calls.append((cfg, by)) or
{"ok": True, "strategy_id": "S_T"},
pause_buy=lambda *a, **k: [], clear_buypause=lambda *a, **k: {"ok": True})
orig_led, orig_upd = pms_repo.insert_ledger, pms_repo.update_strategy
pms_repo.insert_ledger = lambda **kw: rec.ledger.append(kw) or 1
pms_repo.update_strategy = lambda sid, **kw: rec.upd_calls.append((sid, kw)) or 1
try:
out = _out()
adv._tend_existing(st, _pos(price=10.6), {"cls": adv.CLS_CLEAR}, True, 0.9,
_prm(), {}, set(), TODAY, False, out, fake_svc)
assert rec.status_calls == [("S_G", "CANCELLED", "auto")], rec.status_calls
marks = [kw for sid, kw in rec.upd_calls if sid == "S_G"]
assert marks and adv.MARK_HANDOFF_OUT in marks[0]["note"], rec.upd_calls
cfg, by = rec.attach_calls[0]
assert cfg["type"] == "TRAIL" and cfg["note"].startswith(adv.NOTE_HANDOFF)
hw = [kw for sid, kw in rec.upd_calls if sid == "S_T"]
assert hw and hw[0]["state"] == {"high_water": 10.6}, rec.upd_calls
assert out["handoffs"] and out["handoffs"][0]["to"] == "S_T"
assert any(x.get("action") == "HANDOFF" and x.get("verdict") == "PASS"
for x in rec.ledger)
finally:
pms_repo.insert_ledger, pms_repo.update_strategy = orig_led, orig_upd
@case("[冒烟] 边四让路: 接力冷却期 / 有在途委托 / dry_run 都不真动")
def _():
st = {"strategy_id": "S_G", "ts_code": "600000.SH", "type": "GRID",
"note": "自动挂载: 网格", "params": {"upper": 10.5}, "state": {}}
rec = _Rec()
fake_svc = SimpleNamespace(
set_status=lambda *a, **k: rec.status_calls.append(a) or {"ok": True},
attach=lambda *a, **k: rec.attach_calls.append(a) or {"ok": True,
"strategy_id": "X"},
pause_buy=lambda *a, **k: [], clear_buypause=lambda *a, **k: {"ok": True})
# 冷却期
out = _out()
adv._tend_existing(st, _pos(price=10.6), {"cls": adv.CLS_CLEAR}, True, 0.9,
_prm(), {}, {"600000.SH"}, TODAY, False, out, fake_svc)
assert not rec.status_calls and any("冷却" in s["why"] for s in out["skipped"])
# 在途委托
st2 = dict(st, state={"pending": {"iid": "INS_1"}})
orig_get = pms_repo.get_instruction
pms_repo.get_instruction = lambda iid: {"instruction_id": iid, "status": "DISPATCHED"}
try:
out2 = _out()
adv._tend_existing(st2, _pos(price=10.6), {"cls": adv.CLS_CLEAR}, True, 0.9,
_prm(), {}, set(), TODAY, False, out2, fake_svc)
assert not rec.status_calls and any("在途" in s["why"] for s in out2["skipped"])
finally:
pms_repo.get_instruction = orig_get
# dry_run 只报不动
out3 = _out()
adv._tend_existing(st, _pos(price=10.6), {"cls": adv.CLS_CLEAR}, True, 0.9,
_prm(), {}, set(), TODAY, True, out3, fake_svc)
assert out3["handoffs"] and out3["handoffs"][0]["dry_run"] and not rec.status_calls
@case("[冒烟] 人工策略不碰; 自动止盈无事; 接力半途而废要报错并留痕 REJECT")
def _():
rec = _Rec()
fake_svc = SimpleNamespace(
set_status=lambda sid, s, by="user": {"ok": True},
attach=lambda cfg, by="user": {"ok": False, "errors": ["跟踪止盈触发时要卖出, 但当前 T+1 可卖为 0"]},
pause_buy=lambda *a, **k: [], clear_buypause=lambda *a, **k: {"ok": True})
manual = {"strategy_id": "S_M", "ts_code": "600000.SH", "type": "GRID",
"note": "手工网格", "params": {"upper": 10.5}, "state": {}}
out = _out()
adv._tend_existing(manual, _pos(price=10.6), {"cls": adv.CLS_CLEAR}, True, 0.9,
_prm(), {}, set(), TODAY, False, out, fake_svc)
assert any("人工策略" in s["why"] for s in out["skipped"]), out["skipped"]
auto_trail = dict(manual, note="自动挂载: 止盈", type="TRAIL")
out2 = _out()
adv._tend_existing(auto_trail, _pos(price=10.6), {"cls": adv.CLS_CLEAR}, True, 0.9,
_prm(), {}, set(), TODAY, False, out2, fake_svc)
assert any("本轮无事" in s["why"] for s in out2["skipped"]), out2["skipped"]
# 半途而废: 撤成挂败 → errors 里有「人工处理」, 留痕 HANDOFF/REJECT
orig_led, orig_upd = pms_repo.insert_ledger, pms_repo.update_strategy
pms_repo.insert_ledger = lambda **kw: rec.ledger.append(kw) or 1
pms_repo.update_strategy = lambda sid, **kw: 1
try:
grid = dict(manual, note="自动挂载: 网格")
out3 = _out()
adv._tend_existing(grid, _pos(price=10.6), {"cls": adv.CLS_CLEAR}, True, 0.9,
_prm(), {}, set(), TODAY, False, out3, fake_svc)
assert any("人工处理" in e for e in out3["errors"]), out3["errors"]
assert any(x.get("action") == "HANDOFF" and x.get("verdict") == "REJECT"
for x in rec.ledger), rec.ledger
finally:
pms_repo.insert_ledger, pms_repo.update_strategy = orig_led, orig_upd
@case("[冒烟] 三道总闸: 自动开关关 / 策略层关 / 休假模式, 各自明说并整轮不动")
def _():
rec = _Rec()
out = _scan_stubbed(rec, held=[_pos()], accum={}, heat={}, dry_run=False,
prm=_prm(enabled=False))
assert not rec.attach_calls and any("总开关关闭" in s["why"] for s in out["skipped"])
orig = param_store.get_bool
param_store.get_bool = lambda k, d=False: {"PMS_STRATEGY_ENABLED": False,
"PMS_GLOBAL_EXEC_HALT": False}.get(k, d)
orig_p = adv._params
adv._params = lambda: _prm()
try:
out2 = adv.scan(dry_run=False)
assert any("策略层总开关" in s["why"] for s in out2["skipped"]), out2
param_store.get_bool = lambda k, d=False: {"PMS_STRATEGY_ENABLED": True,
"PMS_GLOBAL_EXEC_HALT": True}.get(k, d)
out3 = adv.scan(dry_run=False)
assert any("休假" in s["why"] for s in out3["skipped"]), out3
finally:
param_store.get_bool, adv._params = orig, orig_p
def main():
passed, failed = 0, []
for name, fn in RESULTS:
try:
fn()
passed += 1
print(f"{name}")
except Exception as e:
failed.append((name, e))
print(f"{name}: {type(e).__name__}: {e}")
traceback.print_exc()
print()
if failed:
print(f"FAILED {len(failed)}/{len(RESULTS)}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()

View File

@ -542,7 +542,8 @@ def _():
"/api/proposals", "/api/proposals/{proposal_id}/decide", "/api/ops/replay", "/api/proposals", "/api/proposals/{proposal_id}/decide", "/api/ops/replay",
"/api/ops/reconcile", "/api/ops/daily-settle", "/api/ops/report", "/api/ops/reconcile", "/api/ops/daily-settle", "/api/ops/report",
"/api/ops/downstream-schema", "/api/industry", "/api/ops/downstream-schema", "/api/industry",
"/api/upstream/plan", "/api/ops/plan-refresh"] "/api/upstream/plan", "/api/ops/plan-refresh",
"/api/ops/strategy-attach-scan"] # 2026-08-25 策略自动挂载试算位
missing = [p for p in need if p not in paths] missing = [p for p in need if p not in paths]
assert not missing, missing assert not missing, missing
import os as _os import os as _os
@ -557,11 +558,12 @@ def _():
assert names == {"plan_pull", "premarket", "command_poll", "replay_fills", assert names == {"plan_pull", "premarket", "command_poll", "replay_fills",
"intraday_exec", "signal_digest", "t0_close", "daily_settle", "intraday_exec", "signal_digest", "t0_close", "daily_settle",
"daily_report", "daily_report",
"macro_scan"}, names # 2026-08-18 +宏观择时 09:35 "macro_scan", # 2026-08-18 +宏观择时 09:35
"strategy_attach"}, names # 2026-08-25 +策略自动挂载 09:40
tasks = set(sch.celery_app.tasks) tasks = set(sch.celery_app.tasks)
for t in ("pms.premarket", "pms.command_poll", "pms.replay_fills", "pms.daily_settle", for t in ("pms.premarket", "pms.command_poll", "pms.replay_fills", "pms.daily_settle",
"pms.daily_report", "pms.t0_close", "pms.intraday_exec", "pms.signal_digest", "pms.daily_report", "pms.t0_close", "pms.intraday_exec", "pms.signal_digest",
"pms.plan_pull", "pms.macro_scan"): "pms.plan_pull", "pms.macro_scan", "pms.strategy_attach"):
assert t in tasks, t assert t in tasks, t
assert sch.in_session(datetime(2026, 7, 27, 10, 0)) is True assert sch.in_session(datetime(2026, 7, 27, 10, 0)) is True
assert sch.in_session(datetime(2026, 7, 27, 12, 0)) is False assert sch.in_session(datetime(2026, 7, 27, 12, 0)) is False