第二批: 命令状态机/方案生成器/回放对账/管理页面/调度器骨架

This commit is contained in:
zlt 2026-07-27 17:12:09 +08:00
parent ec8fb48977
commit b3fdb4a4e6
29 changed files with 6611 additions and 29 deletions

3
.gitignore vendored
View File

@ -9,3 +9,6 @@ logs/
# PyCharm 工程文件目前已跟踪; 如决定不入库, 取消下行注释并执行:
# git rm -r --cached .idea && git commit -m "untrack .idea"
#.idea/
# 本地临时: Cowork 会话搬运产物, 可整目录删除
_to_delete/

View File

@ -14,8 +14,36 @@
| `QMT_INTERFACE_REQUIREMENTS.md` | 与 QMT 侧下游系统协商用的数据与接口需求清单(含资金快照、统一指令通道建议 DDL按编号答复回填 |
| `ddl_pms_v1.sql` | PMS 全部自有表建表语句153 代理侧10 张) |
| `config/settings.py` | 配置(基础设施键名对齐 bionic业务参数为初值页面调参持久化到 `pms_runtime_param` 后优先) |
| `app/core/sizer.py` / `app/core/cushion.py` | 首批核心纯逻辑:批次拆分与一手合并、组合约束、风险披露;摊薄成本、安全垫状态机、保垫触发、卖出核销次序 |
| `scripts/test_core_units.py` | 上述模块的实机单测(`python scripts/test_core_units.py`零外部依赖14 例) |
## 模块地图
```
app/
core/ 纯逻辑, 零外部依赖, 可单测 —— 系统的算数与纪律都在这里
sizer.py 批次拆分与一手合并 / 组合约束 / 风险敞口披露
cushion.py 摊薄成本 / 安全垫状态机 / 保垫触发 / 卖出核销次序
command_spec.py 命令目录(A/B/C 全量 27 类) / 参数校验 / 双状态机 / 冲突识别
planner.py 方案生成器: 降仓凑额四档 / 升仓 / 建仓 / 清仓 / 行业 / 撤单
recon.py 成交认领与入账映射 / 对账差异与修正 / 除权检测 / T+1 可用量
tradedays.py 交易日历: 调度守卫与执行窗口计算
db/session.py 三库连接 + **严格单表访问守卫** (JOIN/逗号连表/跨表子查询一律拒绝)
repo/ 单表数据访问: pms_repo (自有 10 表) / downstream_repo (下游只读三表)
services/ 编排层
param_store.py 运行参数中心 (表值优先于 settings 初值, 页面调参即时生效)
portfolio.py 组合快照 (账本+行情+行业 → 方案/规则闸/页面的统一输入)
command_service.py 命令下达→校验→冲突→生效/规划→进度推进→撤销
ledger_service.py 成交回放 / 对账 / 除权 / 盘前 / 日终结算 / 运营日报
market.py 行情 (Redis db13) 与参考位 (决策系统主口径 + 兜底自算)
industry.py 行业划分可插拔适配器 (custom_table / gp_stock_category / 停用)
web/ FastAPI + 单页 (Vue3 + ElementPlus),页面四块 + 运维/日报抽屉
scheduler.py Celery beat 调度总表 (设计 §10 八个调度位 + 三条守卫)
scripts/
run_tests.py 一次跑完全部单测 (见下方「Docker 部署」)
test_core_units.py 仓位与安全垫核心逻辑 14 例
test_batch2_units.py 命令 / 方案 / 回放对账 纯逻辑 35 例
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 18 例
check_db.py 实机连通性与表结构自检 (需真实 .env)
```
## 三条铁律
@ -25,29 +53,59 @@
## Docker 部署(项目统一以容器方式构建运行)
服务共用一个镜像:`pms-web`(管理页面,端口 38100+ `pms-beat` / `pms-worker`Celery 调度与执行,挂在 `sched` profile 下,调度器代码就绪后启用)。
服务共用一个镜像:`pms-web`(管理页面,端口 38100+ `pms-beat` / `pms-worker`Celery 调度与执行,挂在 `sched` profile 下)。
```bash
# 服务器首次部署
git clone <仓库地址> && cd tradingSystem
cp .env.example .env && vim .env # 填入真实连接串 (.env 不入库)
docker compose build # 默认走清华 PyPI 镜像; 可 --build-arg PIP_INDEX_URL=... 覆盖
docker compose run --rm pms-web python scripts/test_core_units.py # 构建验证: 应输出 ALL PASS (14 cases)
docker compose up -d # 当前仅启动 pms-web
curl http://127.0.0.1:38100/health # 健康检查 + 配置装载自证
# 调度器代码就绪后
docker compose --profile sched up -d
# 构建验证 (不连库, 秒级): 应输出 ALL SUITES PASS
docker compose run --rm pms-web python scripts/run_tests.py
# 实机自检 (连库, 需 .env): 三库连通 + pms_* 十表 + 下游表结构 + 行情 Redis
docker compose run --rm pms-web python scripts/check_db.py
docker compose up -d # 管理页面
curl http://127.0.0.1:38100/health # 健康检查 + 配置装载自证 + 库连通自证
# 浏览器打开 http://<服务器IP>:38100/ → 参数设置 / 命令台 / 持仓与账本 / 提议确认
docker compose --profile sched up -d # 启用调度器 (beat + worker)
docker compose logs -f pms-beat pms-worker
# 日常更新
git pull && docker compose build && docker compose up -d
```
基础镜像 `python:3.11-slim` 拉取慢时,先给服务器 Docker 配置 registry 镜像加速。日志落 `./logs`(已挂载卷);容器时区 Asia/Shanghai。
基础镜像 `python:3.11-slim` 拉取慢时,先给服务器 Docker 配置 registry 镜像加速。日志落 `./logs`(已挂载卷);容器时区 Asia/Shanghai。管理页面的前端资源Vue3 / ElementPlus / axios走 unpkg CDN浏览器需能访问外网若内网隔离把页面头部三行 `<script>/<link>` 换成本地文件即可(页面本身无构建步骤)。
## 调度总表(`--profile sched` 生效,设计 §10
| 调度 | 时间 | 任务 | 本批状态 |
|---|---|---|---|
| 盘前准备 | 交易日 08:50 | T+1 可卖重置 / 参考位取数 / 刹车结算 | ✅ |
| 命令轮询 | 每 1 分钟(全天) | 新命令解析 → 方案生成 → 状态机推进 | ✅ |
| 成交回放 | 交易时段每 5 分钟 | `trading_order` 增量回放 + 盘中轻对账 | ✅ |
| 盘中执行 | 交易时段每 1 分钟 | 择时出手 + 自主提议扫描 | 🔜 下一批(当前只做在途指令超时收口) |
| 信号消化 | 交易时段每 1 分钟 | 订阅决策系统盘中信号 | 🔜 下一批 |
| T 仓平回 | 14:50 | 做T强制平回 | 🔜 二期(现只自证 T 仓为 0 |
| 日终结算 | 15:10 | 除权检测 / 全量对账 / 安全垫 / 命令进度日结 | ✅ |
| 运营日报 | 15:30 | 关注区 + 全量统计(页面「日报」按钮可查) | ✅ |
调度器三条守卫:交易日守卫、故障即守成(任务内异常吞掉记 ERROR绝不因调度异常产生新指令、全局暂停执行休假模式下除对账与日报外全部跳过
## 已实现 / 待开发
**已实现**:建表 DDL配置与运行参数中心仓位规划器与安全垫账命令系统27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);管理页面四块 + 运维/日报抽屉;调度器骨架;单测 67 例。
**待开发(下一批)**:择时执行器(实现 B 内置保守择时 + 分日配额 + 子指令、规则闸终检与指令下发通道、动作引擎FILL/ADD/DCA/TRIM 自主提议扫描、决策系统信号订阅与研判闸对接、T0 做T二期
**待外部协商**`QMT_INTERFACE_REQUIREMENTS.md` 的 A/B/C/D 各项——尤其 A1`trading_position` 完整 DDL 与可用数量列、A2`trading_order` 状态枚举与**来源标识**、B1统一指令通道。在来源标识到位前回放按「同股同向 + 下发早于成交 + FIFO」贪心认领认领不上即判外部成交并告警持仓数量列用候选名探测探测结果可经页面「运维 → 导出下游表结构」查看,也是回填 D1 的现成材料。
## 开发约定
- 开发机与服务器经 git 同步代码;**构建与运行统一走 Docker**`docker compose build` → 容器内跑测试脚本 → `up -d`),测试结果回传后迭代。
- 153 代理侧数据库严格单表访问;持仓系统内部代码统一 Tushare 点式。
- 配置分两层:基础设施连接串在 `.env`(服务器手工维护,不入库,模板见 `.env.example`);业务参数在 `config/settings.py` 只是初值,上线后经管理页面修改并持久化到 `pms_runtime_param` 表。
- 开发机与服务器经 git 同步代码;**构建与运行统一走 Docker**`docker compose build` → 容器内 `python scripts/run_tests.py``up -d`),测试结果回传后迭代。
- 153 代理侧数据库严格单表访问;该纪律已落到 `app/db/session.py` 的静态守卫,违规 SQL 在执行前抛 `MultiTableSQL`。持仓系统内部代码统一 Tushare 点式(`600000.SH`),读决策系统结论表时转前缀式。
- 配置分两层:基础设施连接串在 `.env`(服务器手工维护,不入库,模板见 `.env.example`);业务参数在 `config/settings.py` 只是初值,上线后经管理页面修改并持久化到 `pms_runtime_param` 表。**业务代码禁止直接读 settings 取业务参数**,一律走 `services/param_store.py`
- 新增纯逻辑一律进 `app/core/`(零外部依赖 + 配套单测);需要连库的编排进 `app/services/`,并保证连库失败时降级而非崩页。
- 里程碑(设计定稿、建表、各期上线)及时 git 提交。

0
app/__init__.py Normal file
View File

0
app/core/__init__.py Normal file
View File

440
app/core/command_spec.py Normal file
View File

@ -0,0 +1,440 @@
# -*- coding: utf-8 -*-
"""
命令目录 · 参数校验 · 命令状态机 (纯逻辑, 无外部依赖, 可单测)
================================================================
对应设计 POSITION_MGMT_DESIGN.md §3.1 (命令场景目录, 已定不分期全部实现)
§11 (pms_command)§13 (命令冲突由用户裁决)
三类命令 (cls):
param 参数命令 改变长期约束, 立即生效并持久化, 状态 EFFECTIVE/SUPERSEDED
全局参数落 pms_runtime_param (param_key); 个股参数以 pms_command 最新
EFFECTIVE 记录为事实源 (设计 §11 原话), 同时投影到 pms_position 便于规则闸速读
task 任务命令 有生命周期的一次性使命, 状态 PENDINGPLANNINGEXECUTINGDONE/PARTIAL/CANCELLED
task(instant=True) 即时任务 有真实动作 (撤在途买入/改窗口/撤命令) 但无执行窗口,
规划完即 DONE设计把全局暂停买入等归在任务命令表下, 故保留 task 归类
本模块只做目录 + 校验 + 状态迁移 + 冲突识别, 不碰数据库不生成方案 (方案见 planner.py)
"""
from __future__ import annotations
CLS_PARAM = "param"
CLS_TASK = "task"
# ---- 字段类型 ----
F_MONEY = "money" # 金额 (元), > 0
F_PCT = "pct" # 比例, 0~1; 接受 "10%" 字符串或 0.1 浮点
F_INT = "int"
F_FLOAT = "float"
F_PRICE = "price" # 价格 (元), > 0
F_CODE = "code" # Tushare 点式 600000.SH
F_ENUM = "enum"
F_STR = "str"
# ---- 状态 ----
ST_PENDING = "PENDING"
ST_EFFECTIVE = "EFFECTIVE"
ST_SUPERSEDED = "SUPERSEDED"
ST_PLANNING = "PLANNING"
ST_EXECUTING = "EXECUTING"
ST_PARTIAL = "PARTIAL"
ST_DONE = "DONE"
ST_CANCELLED = "CANCELLED"
# 状态迁移表 (设计 §2: 待处理→规划中→执行中→完成/撤销)
TRANSITIONS = {
(CLS_PARAM, ST_PENDING): {ST_EFFECTIVE, ST_CANCELLED},
(CLS_PARAM, ST_EFFECTIVE): {ST_SUPERSEDED},
(CLS_PARAM, ST_SUPERSEDED): set(),
(CLS_PARAM, ST_CANCELLED): set(),
(CLS_TASK, ST_PENDING): {ST_PLANNING, ST_CANCELLED},
(CLS_TASK, ST_PLANNING): {ST_EXECUTING, ST_DONE, ST_CANCELLED},
(CLS_TASK, ST_EXECUTING): {ST_PARTIAL, ST_DONE, ST_CANCELLED},
(CLS_TASK, ST_PARTIAL): {ST_EXECUTING, ST_DONE, ST_CANCELLED}, # 顺延一日 → 回执行中
(CLS_TASK, ST_DONE): set(),
(CLS_TASK, ST_CANCELLED): set(),
}
TERMINAL = {ST_SUPERSEDED, ST_DONE, ST_CANCELLED}
ACTIVE_TASK_STATES = {ST_PENDING, ST_PLANNING, ST_EXECUTING, ST_PARTIAL}
def _f(ftype, required=True, **kw):
d = {"type": ftype, "required": required}
d.update(kw)
return d
# =====================================================================
# 命令目录 (设计 §3.1 A/B/C 三组全量)
# =====================================================================
SPECS = {
# ---------------- A. 资金与总体参数 (参数命令, 全局) ----------------
"SET_SCALE": {
"cls": CLS_PARAM, "label": "设定总规模", "group": "A", "scope": "global",
"fields": {"scale": _f(F_MONEY, min=0)},
"param_key": "PMS_TOTAL_SCALE", "value_field": "scale",
"note": "注资/抽资即改此值; 抽资导致超限由降仓提议消化, 不自动强平",
},
"SET_PORTFOLIO_CAP": {
"cls": CLS_PARAM, "label": "设定总仓上限", "group": "A", "scope": "global",
"fields": {"cap": _f(F_PCT, min=0, max=1)},
"param_key": "PMS_PORTFOLIO_CAP", "value_field": "cap",
"note": "调低后超限 → 生成降仓提议供确认",
},
"SET_STOCK_CAP": {
"cls": CLS_PARAM, "label": "设定单股上限", "group": "A", "scope": "global",
"fields": {"cap": _f(F_PCT, min=0, max=1)},
"param_key": "PMS_STOCK_CAP", "value_field": "cap",
},
"SET_STOCK_TARGET": {
"cls": CLS_PARAM, "label": "设定默认单股目标仓位", "group": "A", "scope": "global",
"fields": {"target": _f(F_PCT, min=0, max=1)},
"param_key": "PMS_STOCK_TARGET_DEFAULT", "value_field": "target",
},
"SET_MAX_NAMES": {
"cls": CLS_PARAM, "label": "设定最大持仓数", "group": "A", "scope": "global",
"fields": {"n": _f(F_INT, min=1, max=200)},
"param_key": "PMS_MAX_NAMES", "value_field": "n",
},
"SET_AUTONOMY": {
"cls": CLS_PARAM, "label": "设定自主档位", "group": "A", "scope": "global",
"fields": {"mode": _f(F_ENUM, choices=["full", "propose_only", "off"])},
"param_key": "PMS_AUTONOMY", "value_field": "mode",
},
"SET_CASH_RESERVE": {
"cls": CLS_PARAM, "label": "设定预留现金比例", "group": "A", "scope": "global",
"fields": {"ratio": _f(F_PCT, min=0, max=1)},
"param_key": "PMS_CASH_RESERVE", "value_field": "ratio",
"note": "与总仓上限双重约束",
},
# ---------------- B. 组合级动作 (任务命令) ----------------
"REDUCE_EXPOSURE": {
"cls": CLS_TASK, "label": "降仓", "group": "B", "scope": "global",
"fields": {"pct": _f(F_PCT, min=0, max=1),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "reduce_exposure",
"note": "释放金额 = 规模×pct; 优先级 停新买→清弱票→收利润→等比微减",
},
"INCREASE_EXPOSURE": {
"cls": CLS_TASK, "label": "升仓", "group": "B", "scope": "global",
"fields": {"pct": _f(F_PCT, min=0, max=1),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "increase_exposure",
"note": "来源: 既有持仓垫厚票补到目标 + 上游候选池新票建仓",
},
"HALT_BUY": {
"cls": CLS_TASK, "label": "全局暂停买入", "group": "B", "scope": "global",
"fields": {}, "planner": "halt_buy", "instant": True,
"switch_key": "PMS_GLOBAL_BUY_HALT", "switch_value": True,
"note": "停止一切新增买入并撤销在途买入指令; 卖出与止损不受影响",
},
"RESUME_BUY": {
"cls": CLS_TASK, "label": "恢复买入", "group": "B", "scope": "global",
"fields": {}, "planner": "noop", "instant": True,
"switch_key": "PMS_GLOBAL_BUY_HALT", "switch_value": False,
},
"HALT_ALL": {
"cls": CLS_TASK, "label": "全局暂停执行(休假模式)", "group": "B", "scope": "global",
"fields": {}, "planner": "halt_all", "instant": True,
"switch_key": "PMS_GLOBAL_EXEC_HALT", "switch_value": True,
"note": "所有自动动作暂停, 仅保留账本对账与日报",
},
"RESUME_ALL": {
"cls": CLS_TASK, "label": "恢复执行", "group": "B", "scope": "global",
"fields": {}, "planner": "noop", "instant": True,
"switch_key": "PMS_GLOBAL_EXEC_HALT", "switch_value": False,
},
"LIQUIDATE_ALL": {
"cls": CLS_TASK, "label": "一键清仓(紧急)", "group": "B", "scope": "global",
"fields": {"window_tdays": _f(F_INT, required=False, min=1, max=5, default=1),
"confirm": _f(F_ENUM, required=True, choices=["YES"])},
"planner": "liquidate_all", "danger": True,
"note": "全部持仓按最快节奏卖出, 不做择时优化; 页面二次确认 (confirm=YES)",
},
"SECTOR_EXIT": {
"cls": CLS_TASK, "label": "清仓某行业", "group": "B", "scope": "sector",
"fields": {"sector": _f(F_STR),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "sector_exit", "needs_sector_source": True,
},
"SECTOR_CAP": {
"cls": CLS_TASK, "label": "限制某行业上限", "group": "B", "scope": "sector",
"fields": {"sector": _f(F_STR), "cap": _f(F_PCT, min=0, max=1),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "sector_cap", "instant": False, "needs_sector_source": True,
"note": "写入行业上限参数; 当前已超限则同时生成减仓方案",
},
# ---------------- C. 个股级动作 ----------------
"OPEN_TARGET": {
"cls": CLS_TASK, "label": "建仓某股至目标%", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE), "target_pct": _f(F_PCT, min=0, max=1),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "open_target",
"note": "分批投放 50/25/25, 含一手合并; 上限与行业硬拦截在规划期即校验",
},
"EXIT_STOCK": {
"cls": CLS_TASK, "label": "清仓某股", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "exit_stock",
},
"REDUCE_STOCK": {
"cls": CLS_TASK, "label": "减至 X%", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE), "target_pct": _f(F_PCT, min=0, max=1),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "reduce_stock",
},
"FREEZE_STOCK": {
"cls": CLS_PARAM, "label": "冻结某股", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
"projection": {"frozen_reason": "COMMAND_HALT"},
"note": "停止该股一切增持(在途买入撤销), 卖出不受影响",
},
"UNFREEZE_STOCK": {
"cls": CLS_PARAM, "label": "解冻某股", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
"projection": {"frozen_reason": "NONE"},
},
"T0_ENABLE": {
"cls": CLS_PARAM, "label": "做T授权", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE), "t_ratio": _f(F_PCT, min=0, max=0.333)},
"projection": {"t0_enabled": 1},
"note": "T 仓 = 持仓 × t_ratio, 硬上限 1/3",
},
"T0_DISABLE": {
"cls": CLS_PARAM, "label": "取消做T授权", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
"projection": {"t0_enabled": 0},
},
"SET_STOP_PRICE": {
"cls": CLS_PARAM, "label": "设定某股止损价", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)},
"projection": {"stop_ref": "@price", "ref_source": "user"},
"note": "覆盖系统参考位; 触发即生成卖出方案",
},
"SET_TARGET_PRICE": {
"cls": CLS_PARAM, "label": "设定某股目标价", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)},
"note": "触发即生成止盈提议 (事实源为本命令最新 EFFECTIVE 记录)",
},
"BLACKLIST_ADD": {
"cls": CLS_PARAM, "label": "加入黑名单(永不买入)", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
},
"BLACKLIST_REMOVE": {
"cls": CLS_PARAM, "label": "移出黑名单", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
},
"WHITELIST_ADD": {
"cls": CLS_PARAM, "label": "加入白名单(优先规划)", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
},
"WHITELIST_REMOVE": {
"cls": CLS_PARAM, "label": "移出白名单", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
},
# ---------------- C'. 对在途命令的操作 ----------------
"ADJUST_WINDOW": {
"cls": CLS_TASK, "label": "调整命令窗口", "group": "C", "scope": "command",
"fields": {"target_command_id": _f(F_STR), "window_tdays": _f(F_INT, min=1, max=20)},
"planner": "adjust_window", "instant": True,
},
"CANCEL_COMMAND": {
"cls": CLS_TASK, "label": "撤销命令", "group": "C", "scope": "command",
"fields": {"target_command_id": _f(F_STR)},
"planner": "cancel_command", "instant": True,
},
}
# 互斥/冲突规则: (A 类型集合, B 类型集合, 作用域, 说明)
# 同一作用域内 A 与 B 同时在途 → 冲突, 由用户在页面裁决 (设计 §13, 系统不代替仲裁)
_CONFLICT_PAIRS = [
({"REDUCE_EXPOSURE"}, {"INCREASE_EXPOSURE"}, "global", "组合降仓与升仓方向相反"),
({"LIQUIDATE_ALL"}, {"INCREASE_EXPOSURE", "OPEN_TARGET"}, "global", "一键清仓在途, 不应再建仓"),
({"HALT_BUY"}, {"INCREASE_EXPOSURE", "OPEN_TARGET"}, "global", "全局暂停买入生效中"),
({"HALT_ALL"}, {"REDUCE_EXPOSURE", "INCREASE_EXPOSURE", "OPEN_TARGET",
"EXIT_STOCK", "REDUCE_STOCK", "LIQUIDATE_ALL"}, "global", "休假模式生效中"),
({"OPEN_TARGET"}, {"EXIT_STOCK", "REDUCE_STOCK"}, "stock", "同股建仓与减持方向相反"),
({"EXIT_STOCK"}, {"OPEN_TARGET", "REDUCE_STOCK"}, "stock", "同股清仓在途"),
({"FREEZE_STOCK"}, {"OPEN_TARGET"}, "stock", "该股已被命令冻结(禁增持)"),
]
def list_commands(group=None, sector_source_ready=True):
"""页面命令台目录。sector_source_ready=False 时行业类命令置灰 (设计 §5)。"""
out = []
for t, s in SPECS.items():
if group and s["group"] != group:
continue
item = {
"cmd_type": t, "cls": s["cls"], "label": s["label"], "group": s["group"],
"scope": s["scope"], "fields": s["fields"], "note": s.get("note", ""),
"danger": bool(s.get("danger")), "instant": bool(s.get("instant")),
"disabled": bool(s.get("needs_sector_source")) and not sector_source_ready,
}
if item["disabled"]:
item["disabled_reason"] = "行业划分数据源未配置 (PMS_SECTOR_SOURCE 为空)"
out.append(item)
return out
# =====================================================================
# 参数校验与归一
# =====================================================================
def _norm_pct(v):
if isinstance(v, str):
s = v.strip()
if s.endswith("%"):
return float(s[:-1]) / 100.0
return float(s)
return float(v)
def normalize_code(code: str) -> str:
"""归一为 Tushare 点式。接受 600000.SH / SH600000 / 600000 (按交易所规则补后缀)。"""
c = (code or "").strip().upper()
if not c:
return ""
if "." in c:
num, mkt = c.split(".", 1)
return f"{num}.{mkt}"
if c[:2] in ("SH", "SZ", "BJ") and c[2:].isdigit():
return f"{c[2:]}.{c[:2]}"
if c.isdigit() and len(c) == 6:
if c[0] == "6":
return f"{c}.SH"
if c[0] in "03":
return f"{c}.SZ"
if c[0] in "48":
return f"{c}.BJ"
return c
def validate(cmd_type: str, params: dict) -> tuple:
"""校验并归一命令参数。返回 (normalized_params, errors)。errors 非空即拒绝下达。"""
spec = SPECS.get(cmd_type)
if not spec:
return {}, [f"UNKNOWN_CMD: 未知命令类型 {cmd_type}"]
params = params or {}
out, errs = {}, []
for name, f in spec["fields"].items():
raw = params.get(name)
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
if f.get("required", True):
errs.append(f"MISSING: 缺少参数 {name}")
elif "default" in f:
out[name] = f["default"]
continue
t = f["type"]
try:
if t == F_PCT:
v = _norm_pct(raw)
elif t in (F_MONEY, F_PRICE, F_FLOAT):
v = float(raw)
elif t == F_INT:
v = int(float(raw))
elif t == F_CODE:
v = normalize_code(str(raw))
if not v or "." not in v:
errs.append(f"BAD_CODE: 股票代码无法归一为点式 ({raw})")
continue
elif t == F_ENUM:
v = str(raw).strip()
if v not in f["choices"]:
errs.append(f"BAD_ENUM: {name} 应为 {f['choices']} 之一, 收到 {raw}")
continue
else:
v = str(raw).strip()
except (TypeError, ValueError):
errs.append(f"BAD_TYPE: {name} 无法解析为 {t} ({raw})")
continue
if isinstance(v, (int, float)) and not isinstance(v, bool):
if "min" in f and v < f["min"]:
errs.append(f"OUT_OF_RANGE: {name}={v} 小于下限 {f['min']}")
if "max" in f and v > f["max"]:
errs.append(f"OUT_OF_RANGE: {name}={v} 大于上限 {f['max']}")
if t in (F_MONEY, F_PRICE) and v <= 0:
errs.append(f"OUT_OF_RANGE: {name} 必须为正数")
if t == F_PCT and cmd_type in ("REDUCE_EXPOSURE", "INCREASE_EXPOSURE") and v <= 0:
errs.append(f"OUT_OF_RANGE: {name} 必须大于 0")
out[name] = v
unknown = set(params) - set(spec["fields"])
if unknown:
errs.append(f"UNKNOWN_FIELD: 多余参数 {sorted(unknown)}")
return out, errs
# =====================================================================
# 状态机
# =====================================================================
def can_transition(cls: str, frm: str, to: str) -> bool:
return to in TRANSITIONS.get((cls, frm), set())
def transition(cls: str, frm: str, to: str) -> str:
"""迁移状态; 非法迁移抛 ValueError (调用方须捕获并落 ERROR 日志)。"""
if not can_transition(cls, frm, to):
raise ValueError(f"非法状态迁移: {cls} {frm}{to}")
return to
def initial_status(cls: str) -> str:
return ST_PENDING
def settle_task_status(target_amount: float, done_amount: float, window_over: bool,
eps: float = 1e-6) -> str:
"""任务命令进度结算 (设计 §3.2 第4步): 达标 DONE; 窗口末仍未完成 PARTIAL; 否则继续 EXECUTING。"""
if target_amount is None or target_amount <= 0:
return ST_DONE
if done_amount + eps >= target_amount:
return ST_DONE
return ST_PARTIAL if window_over else ST_EXECUTING
# =====================================================================
# 冲突检测 (系统只识别与提示, 裁决权在用户 —— 设计 §13)
# =====================================================================
def detect_conflicts(new_cmd: dict, active_cmds: list) -> list:
"""new_cmd: {cmd_type, ts_code?, params?}; active_cmds: 在途/生效中的命令列表
(task PENDING/PLANNING/EXECUTING/PARTIAL, param EFFECTIVE)
返回 [{"with_command_id","with_cmd_type","scope","reason"}...], = 无冲突"""
t = new_cmd.get("cmd_type")
code = normalize_code(new_cmd.get("ts_code") or "")
out = []
for a in active_cmds or []:
at = a.get("cmd_type")
acode = normalize_code(a.get("ts_code") or "")
for set_a, set_b, scope, why in _CONFLICT_PAIRS:
hit = (t in set_a and at in set_b) or (t in set_b and at in set_a)
if not hit:
continue
if scope == "stock" and (not code or code != acode):
continue
out.append({"with_command_id": a.get("command_id"), "with_cmd_type": at,
"scope": scope, "reason": why})
break
else:
# 同类型同标的重复下达 (参数命令不算冲突, 属于覆盖)
if t == at and SPECS.get(t, {}).get("cls") == CLS_TASK and code == acode:
out.append({"with_command_id": a.get("command_id"), "with_cmd_type": at,
"scope": SPECS[t]["scope"], "reason": "同类型命令已在途, 重复下达"})
return out
def make_command_id(ymd: int, seq: int) -> str:
return f"CMD_{ymd}_{seq:04d}"
def make_plan_id(command_id: str, seq: int) -> str:
return f"PLAN_{command_id}_{seq:03d}"
def make_instruction_id(ymd: int, ts_code: str, action: str, seq: int) -> str:
return f"INS_{ymd}_{ts_code.replace('.', '')}_{action}_{seq:03d}"

552
app/core/planner.py Normal file
View File

@ -0,0 +1,552 @@
# -*- coding: utf-8 -*-
"""
方案生成器 (纯逻辑, 无外部依赖, 可单测)
========================================
把一条任务命令展开成分股行动清单 (设计 POSITION_MGMT_DESIGN.md §3.2 / §5 / §6)
只算数, 不落库不下发 调用方 (services/command_service.py) 负责写 pms_plan
核心是降仓凑额四档优先级 (设计 §3.2):
撤销在途买入类指令 (先停止继续投入, 不计入释放额)
清弱票 浮亏且安全垫为负持续 N 整票清仓
收利润 浮盈票卖出加仓批/补足批 (保留底仓)
等比微减 仍不足则各票按市值等比例微减 (此时才动底仓)
约定与口径:
* 金额单位元, 数量单位股; A股一手 = 100
* 部分减持数量一律向下取整到一手; **整票清仓允许卖零股** (A股零股可一次性卖出),
所以 EXIT total_qty 原值, 不做取整
* 冻结 (frozen_reason != NONE) 只禁增持不禁减持 降仓类方案照常纳入该票
* 同一票在同一份方案中只出一条减持动作 (跨档累计用 _sold 台账防超卖)
* 卖出数量受 T+1 可卖量限制的部分不在此处扣减: 方案给的是**目标数量**,
分日出手由择时执行器按 avail_qty 处理 (设计 §8)方案层只保证不超过 total_qty
"""
from __future__ import annotations
from app.core.sizer import LOT, lot_qty, split_batches, check_caps
from app.core.cushion import sell_allocation # noqa: F401 (供调用方做核销预览)
# 方案动作词表 (与 ddl_pms_v1.sql pms_plan.action 注释一致)
A_OPEN, A_FILL, A_ADD, A_DCA = "OPEN", "FILL", "ADD", "DCA"
A_TRIM, A_EXIT, A_HALT, A_T0 = "TRIM", "EXIT", "HALT", "T0_ROUND"
SIDE_BUY, SIDE_SELL, SIDE_NONE = "buy", "sell", "none"
# 优先级 (越小越先执行; 设计 §3.2 的四档次序)
P_HALT, P_WEAK, P_HARVEST, P_PRORATA = 10, 20, 30, 40
P_OPEN_BASE, P_OPEN_FILL, P_OPEN_ADD = 10, 20, 30
# ---------------------------------------------------------------- 小工具
def floor_lot(qty) -> int:
return int(max(0, int(qty)) // LOT) * LOT
def ceil_lot(qty) -> int:
q = max(0, int(qty))
return ((q + LOT - 1) // LOT) * LOT
def mv_of(p: dict) -> float:
"""持仓市值 = 数量 × 现价 (缺价按 0, 由调用方剔除或告警)。"""
return float(p.get("total_qty") or 0) * float(p.get("price") or 0)
def _item(ts_code, action, side, qty, amount, priority, reason, tier="", **kw):
d = {"ts_code": ts_code, "action": action, "side": side, "qty": int(qty),
"amount": round(float(amount), 2), "priority": priority, "reason": reason,
"tier": tier}
d.update(kw)
return d
def _sortable(p, key, reverse=False):
"""稳定排序键: 主键 + ts_code (保证同值时结果确定, 便于单测与复现)。"""
v = p.get(key)
v = 0.0 if v is None else float(v)
return (-v if reverse else v, p.get("ts_code") or "")
def _usable(positions, exclude_codes):
"""可纳入减持方案的持仓: 有数量、有现价、不在排除名单 (已有在途方案的票)。"""
ex = set(exclude_codes or ())
out = []
for p in positions or []:
if p.get("ts_code") in ex:
continue
if int(p.get("total_qty") or 0) <= 0 or float(p.get("price") or 0) <= 0:
continue
out.append(p)
return out
# ================================================================
# 一、降仓凑额 (设计 §3.2 全流程示例的第 1 步)
# ================================================================
def plan_reduce_exposure(*, release_amount: float, positions: list, pending_buys=None,
params=None, exclude_codes=()) -> dict:
"""降仓 X% 的方案生成。
release_amount 需释放金额 () = 规模 × pct
positions 持仓快照列表, 每项至少含:
ts_code / price / total_qty / base_qty / cushion_pct /
neg_cushion_days (安全垫连续为负天数)
pending_buys 在途买入指令 [{instruction_id, ts_code, qty, amount}], 全部撤销
params {"weak_neg_days":5}
返回 {"ok","target_amount","planned_amount","gap","items","notes"}
"""
params = params or {}
weak_days = int(params.get("weak_neg_days", 5))
target = float(release_amount or 0)
items, notes = [], []
# ---- ① 撤销在途买入 (不计入释放额, 但必须先做: 停止继续投入) ----
for b in (pending_buys or []):
items.append(_item(b.get("ts_code"), A_HALT, SIDE_NONE, b.get("qty") or 0,
b.get("amount") or 0, P_HALT,
"降仓命令: 撤销在途买入指令", tier="1_停新买",
cancel_instruction_id=b.get("instruction_id")))
if pending_buys:
notes.append(f"撤销在途买入指令 {len(pending_buys)} 条 (不计入释放额)")
if target <= 0:
return {"ok": False, "target_amount": 0.0, "planned_amount": 0.0, "gap": 0.0,
"items": items, "notes": notes + ["释放金额为 0, 无减持动作"]}
pool = _usable(positions, exclude_codes)
if exclude_codes:
notes.append(f"跳过已有在途方案的票: {sorted(set(exclude_codes))}")
sold = {} # ts_code -> 已计划卖出股数 (跨档防超卖)
acc = 0.0
def remain_qty(p):
return int(p.get("total_qty") or 0) - sold.get(p["ts_code"], 0)
# ---- ② 清弱票: 浮亏且安全垫为负、持续 ≥N 日 → 整票清仓 (最差的先清) ----
weak = [p for p in pool
if p.get("cushion_pct") is not None and float(p["cushion_pct"]) < 0
and int(p.get("neg_cushion_days") or 0) >= weak_days]
for p in sorted(weak, key=lambda x: _sortable(x, "cushion_pct")):
if acc >= target:
break
qty = remain_qty(p) # 清仓卖全量, 零股一并卖出 (不取整)
if qty <= 0:
continue
amt = qty * float(p["price"])
sold[p["ts_code"]] = sold.get(p["ts_code"], 0) + qty
acc += amt
items.append(_item(p["ts_code"], A_EXIT, SIDE_SELL, qty, amt, P_WEAK,
f"清弱票: 安全垫 {float(p['cushion_pct']):.1%} 连续为负 "
f"{int(p.get('neg_cushion_days') or 0)}", tier="2_清弱票"))
if acc > target and weak:
notes.append(f"清弱票为整票清仓, 释放额 {acc:,.0f} 元已超目标 {target:,.0f}"
f"(超出 {acc - target:,.0f} 元, 弱票不做拆卖)")
# ---- ③ 收利润: 浮盈票卖加仓批+补足批, 保留底仓 (垫子厚的先收) ----
if acc < target:
profit = [p for p in pool
if p.get("cushion_pct") is not None and float(p["cushion_pct"]) > 0
and remain_qty(p) > 0]
for p in sorted(profit, key=lambda x: _sortable(x, "cushion_pct", reverse=True)):
if acc >= target:
break
keep_base = int(p.get("base_qty") or 0)
sellable = floor_lot(min(remain_qty(p), max(0, int(p.get("total_qty") or 0) - keep_base)))
if sellable <= 0:
continue
need = target - acc
want = min(sellable, ceil_lot(need / float(p["price"])))
if want <= 0:
continue
amt = want * float(p["price"])
sold[p["ts_code"]] = sold.get(p["ts_code"], 0) + want
acc += amt
items.append(_item(p["ts_code"], A_TRIM, SIDE_SELL, want, amt, P_HARVEST,
f"收利润: 安全垫 {float(p['cushion_pct']):.1%}, "
f"卖出加仓/补足批 {want} 股, 保留底仓 {keep_base}",
tier="3_收利润"))
# ---- ④ 等比微减: 仍不足 → 各票按市值等比例微减 (此时可动底仓) ----
if acc < target:
gap = target - acc
cand = [p for p in pool if remain_qty(p) >= LOT]
total_mv = sum(remain_qty(p) * float(p["price"]) for p in cand)
if total_mv <= 0:
notes.append("等比微减: 无可减持仓")
else:
plan_qty = {}
for p in cand:
w = remain_qty(p) * float(p["price"]) / total_mv
q = floor_lot(min(remain_qty(p), lot_qty(gap * w, float(p["price"]))))
if q > 0:
plan_qty[p["ts_code"]] = q
# 取整造成的缺口: 按剩余市值从大到小逐手补齐
planned = sum(q * float(next(x for x in cand if x["ts_code"] == c)["price"])
for c, q in plan_qty.items())
order = sorted(cand, key=lambda x: (-(remain_qty(x) * float(x["price"])),
x["ts_code"]))
guard = 0
while planned < gap - 1e-6 and guard < 10000:
guard += 1
progressed = False
for p in order:
cur = plan_qty.get(p["ts_code"], 0)
if cur + LOT <= remain_qty(p):
plan_qty[p["ts_code"]] = cur + LOT
planned += LOT * float(p["price"])
progressed = True
if planned >= gap - 1e-6:
break
if not progressed:
break
for p in order:
q = plan_qty.get(p["ts_code"], 0)
if q <= 0:
continue
amt = q * float(p["price"])
sold[p["ts_code"]] = sold.get(p["ts_code"], 0) + q
acc += amt
items.append(_item(p["ts_code"], A_TRIM, SIDE_SELL, q, amt, P_PRORATA,
f"等比微减: 按市值权重摊派 {q}", tier="4_等比微减"))
gap = max(0.0, target - acc)
if gap > 0:
notes.append(f"可减持仓不足, 缺口 {gap:,.0f} 元 —— 命令将置部分完成并告警")
return {"ok": gap <= 0, "target_amount": round(target, 2), "planned_amount": round(acc, 2),
"gap": round(gap, 2), "items": items, "notes": notes}
# ================================================================
# 二、升仓 (设计 §3.1 B: 既有持仓补到目标 + 候选池新票建仓)
# ================================================================
def plan_increase_exposure(*, add_amount: float, positions: list, candidates=None,
ctx: dict = None, params=None) -> dict:
"""升仓 X%。既有持仓中「垫厚(SOLID)且未达目标」的票先补足, 再从候选池建新仓。
candidates: [{ts_code, price, score, sector}] 上游计划池 白名单, 由调用方备好并
已剔除黑名单/已持有/冻结票每只都要过组合约束 (累计口径)
ctx: check_caps 所需上下文 ( sizer.check_caps), 规划过程中滚动更新
"""
params = params or {}
ctx = dict(ctx or {})
target = float(add_amount or 0)
items, notes, rejects = [], [], []
acc = 0.0
scale = float(ctx.get("scale") or 0)
default_target = float(params.get("stock_target_default", 0.06))
batch_split = params.get("batch_split") or (0.5, 0.25, 0.25)
merge = bool(params.get("min_lot_merge", True))
if target <= 0 or scale <= 0:
return {"ok": False, "target_amount": target, "planned_amount": 0.0,
"gap": target, "items": [], "notes": ["升仓金额或总规模为 0"], "rejects": []}
# ---- ① 既有持仓补到目标 (只补垫厚票, 呼应设计"垫厚且决策系统看多") ----
for p in sorted(positions or [], key=lambda x: _sortable(x, "cushion_pct", reverse=True)):
if acc >= target:
break
if float(p.get("price") or 0) <= 0:
continue
if (p.get("cushion_state") or "") != "SOLID":
continue
if (p.get("frozen_reason") or "NONE") != "NONE":
continue
tgt_pct = float(p.get("target_pct") or default_target)
room = tgt_pct * scale - mv_of(p)
if room <= 0:
continue
want_amt = min(room, target - acc)
q = lot_qty(want_amt, float(p["price"]))
if q < LOT:
continue
amt = q * float(p["price"])
bad = check_all_caps(ts_code=p["ts_code"], add_amount=amt, ctx=_stock_ctx(ctx, p, False))
if bad:
rejects.append({"ts_code": p["ts_code"], "reasons": bad})
continue
items.append(_item(p["ts_code"], A_ADD, SIDE_BUY, q, amt, P_OPEN_BASE,
f"升仓: 垫厚({float(p.get('cushion_pct') or 0):.1%})补至目标 "
f"{tgt_pct:.1%}", tier="1_补既有"))
acc += amt
ctx = _ctx_after(ctx, amt, is_new_name=False, sector=p.get("sector"))
# ---- ② 候选池新票建仓 ----
for c in sorted(candidates or [], key=lambda x: _sortable(x, "score", reverse=True)):
if acc >= target:
break
price = float(c.get("price") or 0)
if price <= 0:
continue
want_amt = min(default_target * scale, target - acc)
sp = split_batches(want_amt, price, splits=batch_split, merge=merge)
if not sp["ok"]:
rejects.append({"ts_code": c.get("ts_code"), "reasons": [sp["reason"]]})
continue
base = sp["batches"][0]
bad = check_all_caps(ts_code=c.get("ts_code"), add_amount=want_amt,
ctx=_new_name_ctx(ctx, c))
if bad:
rejects.append({"ts_code": c.get("ts_code"), "reasons": bad})
continue
items.extend(_open_batch_items(c.get("ts_code"), sp, price,
reason_prefix="升仓建新仓"))
acc += want_amt
ctx = _ctx_after(ctx, want_amt, is_new_name=True, sector=c.get("sector"))
notes.append(f"{c.get('ts_code')}: 目标 {want_amt:,.0f} 元, 首批 {base['qty']}")
gap = max(0.0, target - acc)
if gap > 0:
notes.append(f"候选与补仓空间不足, 缺口 {gap:,.0f}")
return {"ok": gap <= 0, "target_amount": round(target, 2), "planned_amount": round(acc, 2),
"gap": round(gap, 2), "items": items, "notes": notes, "rejects": rejects}
# ================================================================
# 三、个股级方案
# ================================================================
def plan_open_target(*, ts_code: str, target_pct: float, price: float, ctx: dict,
params=None) -> dict:
"""建仓某股至目标% —— 50/25/25 分批 (含一手合并), 组合约束硬校验。
BASE 批立即可执行; FILL/ADD 批以 gated=True 落方案, 由动作引擎按条件解锁 (设计 §6)
"""
params = params or {}
batch_split = params.get("batch_split") or (0.5, 0.25, 0.25)
merge = bool(params.get("min_lot_merge", True))
scale = float(ctx.get("scale") or 0)
stock_mv = float(ctx.get("stock_mv") or 0)
add_amount = max(0.0, float(target_pct) * scale - stock_mv)
if scale <= 0:
return {"ok": False, "items": [], "notes": [], "rejects": [
{"ts_code": ts_code, "reasons": ["SCALE_INVALID: 总规模未设置"]}]}
if add_amount <= 0:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"{ts_code} 现有仓位已达/超过目标 {target_pct:.1%}, 无需建仓"],
"rejects": []}
bad = check_all_caps(ts_code=ts_code, add_amount=add_amount, ctx=ctx)
if bad:
return {"ok": False, "items": [], "target_amount": round(add_amount, 2),
"planned_amount": 0.0, "gap": round(add_amount, 2), "notes": [],
"rejects": [{"ts_code": ts_code, "reasons": bad}]}
sp = split_batches(add_amount, float(price), splits=batch_split, merge=merge)
if not sp["ok"]:
return {"ok": False, "items": [], "target_amount": round(add_amount, 2),
"planned_amount": 0.0, "gap": round(add_amount, 2), "notes": [],
"rejects": [{"ts_code": ts_code, "reasons": [sp["reason"]]}]}
items = _open_batch_items(ts_code, sp, float(price), reason_prefix="建仓命令")
planned = sum(i["amount"] for i in items)
notes = []
if sp["scheme"] != tuple(batch_split):
notes.append(f"一手检查: 批次自动合并为 {sp['scheme']} (原 {tuple(batch_split)})")
return {"ok": True, "items": items, "target_amount": round(add_amount, 2),
"planned_amount": round(planned, 2), "gap": 0.0, "notes": notes, "rejects": []}
def plan_exit_stock(*, ts_code: str, position: dict, reason: str = "清仓命令") -> dict:
"""清仓某股 (整票, 零股一并卖出)。"""
qty = int((position or {}).get("total_qty") or 0)
price = float((position or {}).get("price") or 0)
if qty <= 0:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"{ts_code} 无持仓, 命令直接完成"], "rejects": []}
amt = qty * price
return {"ok": True, "target_amount": round(amt, 2), "planned_amount": round(amt, 2),
"gap": 0.0, "notes": [], "rejects": [],
"items": [_item(ts_code, A_EXIT, SIDE_SELL, qty, amt, P_WEAK, reason,
tier="个股清仓")]}
def plan_reduce_stock(*, ts_code: str, target_pct: float, position: dict, scale: float) -> dict:
"""减至 X%: 卖出超出目标仓位的部分 (向下取整到一手; 目标 0 等价清仓)。"""
qty_hold = int((position or {}).get("total_qty") or 0)
price = float((position or {}).get("price") or 0)
if qty_hold <= 0 or price <= 0:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"{ts_code} 无持仓或无现价"], "rejects": []}
if float(target_pct) <= 0:
return plan_exit_stock(ts_code=ts_code, position=position, reason="减至 0% (等价清仓)")
over = qty_hold * price - float(target_pct) * float(scale)
if over <= 0:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"{ts_code} 当前仓位未超过目标 {target_pct:.1%}"],
"rejects": []}
qty = min(qty_hold, floor_lot(lot_qty(over, price)))
if qty <= 0:
return {"ok": False, "items": [], "target_amount": round(over, 2), "planned_amount": 0.0,
"gap": round(over, 2), "rejects": [],
"notes": [f"{ts_code} 超出额 {over:,.0f} 元不足一手, 不减持"]}
amt = qty * price
return {"ok": True, "target_amount": round(over, 2), "planned_amount": round(amt, 2),
"gap": round(max(0.0, over - amt), 2), "notes": [], "rejects": [],
"items": [_item(ts_code, A_TRIM, SIDE_SELL, qty, amt, P_HARVEST,
f"减至 {target_pct:.1%}: 卖出超出部分 {qty}", tier="个股减仓")]}
def plan_liquidate_all(*, positions: list, pending_buys=None) -> dict:
"""一键清仓 (紧急): 撤在途买入 + 全部持仓清仓, 市值大的先卖。"""
items = []
for b in (pending_buys or []):
items.append(_item(b.get("ts_code"), A_HALT, SIDE_NONE, b.get("qty") or 0,
b.get("amount") or 0, P_HALT, "一键清仓: 撤销在途买入",
tier="1_停新买", cancel_instruction_id=b.get("instruction_id")))
acc = 0.0
for p in sorted(_usable(positions, ()), key=lambda x: (-mv_of(x), x["ts_code"])):
qty = int(p.get("total_qty") or 0)
amt = qty * float(p["price"])
acc += amt
items.append(_item(p["ts_code"], A_EXIT, SIDE_SELL, qty, amt, P_WEAK,
"一键清仓 (紧急, 不做择时优化)", tier="全部清仓"))
return {"ok": True, "target_amount": round(acc, 2), "planned_amount": round(acc, 2),
"gap": 0.0, "items": items, "rejects": [],
"notes": [f"全部 {len([i for i in items if i['action'] == A_EXIT])} 只持仓清仓"]}
def plan_sector_exit(*, sector: str, positions: list) -> dict:
"""清仓某行业 (行业名由 IndustryClassifier 提供, positions 需带 sector 字段)。"""
hit = [p for p in _usable(positions, ()) if (p.get("sector") or "") == sector]
if not hit:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"行业[{sector}]当前无持仓"], "rejects": []}
items, acc = [], 0.0
for p in sorted(hit, key=lambda x: (-mv_of(x), x["ts_code"])):
qty = int(p["total_qty"])
amt = qty * float(p["price"])
acc += amt
items.append(_item(p["ts_code"], A_EXIT, SIDE_SELL, qty, amt, P_WEAK,
f"清仓行业[{sector}]", tier="行业清仓"))
return {"ok": True, "target_amount": round(acc, 2), "planned_amount": round(acc, 2),
"gap": 0.0, "items": items, "notes": [], "rejects": []}
def plan_sector_cap(*, sector: str, cap: float, positions: list) -> dict:
"""限制某行业上限: 超出部分在该行业内按市值等比例减持 (不足一手的票跳过)。"""
pool = _usable(positions, ())
port_mv = sum(mv_of(p) for p in pool)
hit = [p for p in pool if (p.get("sector") or "") == sector]
sec_mv = sum(mv_of(p) for p in hit)
if port_mv <= 0 or sec_mv <= 0:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"行业[{sector}]无持仓, 仅写入上限参数"], "rejects": []}
over = sec_mv - float(cap) * port_mv
if over <= 0:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"行业[{sector}]占比 {sec_mv / port_mv:.1%} 未超上限 "
f"{float(cap):.0%}, 仅写入参数"], "rejects": []}
items, acc = [], 0.0
for p in sorted(hit, key=lambda x: (-mv_of(x), x["ts_code"])):
w = mv_of(p) / sec_mv
q = min(int(p["total_qty"]), floor_lot(lot_qty(over * w, float(p["price"]))))
if q <= 0:
continue
amt = q * float(p["price"])
acc += amt
items.append(_item(p["ts_code"], A_TRIM, SIDE_SELL, q, amt, P_PRORATA,
f"行业[{sector}]超限, 等比减持 {q}", tier="行业限额"))
return {"ok": acc > 0, "target_amount": round(over, 2), "planned_amount": round(acc, 2),
"gap": round(max(0.0, over - acc), 2), "items": items, "rejects": [],
"notes": [f"行业[{sector}]占比 {sec_mv / port_mv:.1%} → 需减 {over:,.0f}"]}
def plan_halt_buy(*, pending_buys: list) -> dict:
"""全局暂停买入: 撤销全部在途买入指令 (卖出与止损不受影响)。"""
items = [_item(b.get("ts_code"), A_HALT, SIDE_NONE, b.get("qty") or 0,
b.get("amount") or 0, P_HALT, "全局暂停买入: 撤销在途买入指令",
tier="停新买", cancel_instruction_id=b.get("instruction_id"))
for b in (pending_buys or [])]
return {"ok": True, "items": items, "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "rejects": [],
"notes": [f"撤销在途买入指令 {len(items)}"] if items else ["无在途买入指令"]}
def plan_halt_all(*, pending_instructions: list) -> dict:
"""全局暂停执行 (休假模式): 撤销全部在途指令, 仅保留对账与日报。"""
items = [_item(b.get("ts_code"), A_HALT, SIDE_NONE, b.get("qty") or 0,
b.get("amount") or 0, P_HALT,
f"休假模式: 撤销在途{'买入' if b.get('side') == SIDE_BUY else '卖出'}指令",
tier="停执行", cancel_instruction_id=b.get("instruction_id"))
for b in (pending_instructions or [])]
return {"ok": True, "items": items, "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "rejects": [],
"notes": [f"撤销在途指令 {len(items)}"] if items else ["无在途指令"]}
# ================================================================
# 四、约束校验 (规则闸与方案生成器共用同一份口径)
# ================================================================
def check_all_caps(*, ts_code: str, add_amount: float, ctx: dict) -> list:
"""组合约束 (sizer.check_caps) + 预留现金约束。返回未通过项列表, 空 = 全过。
预留现金 (设计 §3.1 A): 总规模中永不动用的部分, 与总仓上限双重约束
加仓后组合市值不得超过 规模 × (1 预留比例)
"""
v = list(check_caps(ts_code=ts_code, add_amount=add_amount, ctx=ctx))
reserve = float(ctx.get("cash_reserve") or 0)
scale = float(ctx.get("scale") or 0)
if reserve > 0 and scale > 0:
usable = scale * (1 - reserve)
after = float(ctx.get("portfolio_mv") or 0) + float(add_amount)
if after > usable + 1e-9:
v.append(f"CASH_RESERVE: 加后市值 {after:,.0f} > 可用额度 {usable:,.0f} "
f"(预留现金 {reserve:.0%})")
return v
def _stock_ctx(ctx: dict, p: dict, is_new: bool) -> dict:
d = dict(ctx)
sector = p.get("sector") if ctx.get("sector_source_ready", True) else None
d["stock_mv"] = mv_of(p)
d["is_new_name"] = is_new
d["sector"] = sector
d["sector_names"] = int((ctx.get("sector_names_map") or {}).get(sector, 0))
d["sector_mv"] = float((ctx.get("sector_mv_map") or {}).get(sector, 0.0))
return d
def _new_name_ctx(ctx: dict, c: dict) -> dict:
d = dict(ctx)
d["stock_mv"] = 0.0
d["is_new_name"] = True
d["sector"] = c.get("sector") if ctx.get("sector_source_ready", True) else None
d["sector_names"] = int((ctx.get("sector_names_map") or {}).get(c.get("sector"), 0))
d["sector_mv"] = float((ctx.get("sector_mv_map") or {}).get(c.get("sector"), 0.0))
return d
def _ctx_after(ctx: dict, add_amount: float, is_new_name: bool, sector=None) -> dict:
"""规划过程中滚动更新组合快照, 保证多笔累计口径下的上限校验正确。"""
d = dict(ctx)
d["portfolio_mv"] = float(ctx.get("portfolio_mv") or 0) + float(add_amount)
if is_new_name:
d["names_count"] = int(ctx.get("names_count") or 0) + 1
if sector:
nm = dict(ctx.get("sector_names_map") or {})
mm = dict(ctx.get("sector_mv_map") or {})
if is_new_name:
nm[sector] = int(nm.get(sector, 0)) + 1
mm[sector] = float(mm.get(sector, 0.0)) + float(add_amount)
d["sector_names_map"], d["sector_mv_map"] = nm, mm
return d
def _open_batch_items(ts_code, sp, price, reason_prefix="建仓"):
"""把 split_batches 的结果转成方案条目: BASE 立即执行, FILL/ADD 挂 gated 待引擎解锁。"""
names = [b["name"] for b in sp["batches"]]
prio = {"BASE": P_OPEN_BASE, "FILL": P_OPEN_FILL, "ADD": P_OPEN_ADD}
act = {"BASE": A_OPEN, "FILL": A_FILL, "ADD": A_ADD}
gate_reason = {
"BASE": "首批底仓, 规则闸通过即入择时队列",
"FILL": "回踩补足批: 建仓期内回踩支撑不破且浮亏 <3% 时解锁",
"ADD": "盈利加仓批: 安全垫 ≥3% 且创 5 日新高/站上压力位时解锁",
}
out = []
for i, b in enumerate(sp["batches"]):
nm = b["name"] if b["name"] in act else names[i]
out.append(_item(ts_code, act.get(nm, A_OPEN), SIDE_BUY, b["qty"], b["qty"] * price,
prio.get(nm, P_OPEN_BASE),
f"{reason_prefix}: {nm}{b['qty']} 股 —— {gate_reason.get(nm, '')}",
tier=f"批次_{nm}", gated=(nm != "BASE")))
return out

321
app/core/recon.py Normal file
View File

@ -0,0 +1,321 @@
# -*- coding: utf-8 -*-
"""
成交回放与对账 (纯逻辑, 无外部依赖, 可单测)
============================================
对应设计 POSITION_MGMT_DESIGN.md §4成交回放与对账生命线 §13 降级表
四件事:
1. 回放: 下游 trading_order 的已成交单 认领到 PMS 指令 转成批次入账动作;
认领不上的即外部成交, 买入并入 BASE 并告警留痕 (设计原话)
2. 对账: 账本 total_qty vs 下游 trading_position 数量, **以下游为准**修正,
修正走 RECON 批次留痕
3. 除权检测: 数量与价格反比突变 按比例调整批次; 比例不吻合 ERROR 待人工
4. T+1 可用量: 日初重置 = 全部持仓可卖; 当日买入不增可卖, 当日卖出扣减可卖
认领口径说明 (重要):
trading_order 目前**没有** PMS 指令来源列 该列已列入 QMT_INTERFACE_REQUIREMENTS A2
(«来源标识», 待下游答复)在拿到来源标识前, 本模块按 (代码, 方向, 时间, 数量)
FIFO 贪心认领: 同股同向下发时间早于成交时间的在途指令按下发顺序吃单
下游一旦提供来源标识, 只需给 fills 带上 instruction_id, 认领即退化为精确匹配 ( claim_fills)
"""
from __future__ import annotations
from app.core.cushion import sell_allocation
# 指令动作 → 批次类型 (设计 §4: 批次 BASE/FILL/ADD/DCA/T0, RECON 为对账调整专用)
ACTION_TO_LOT = {
"OPEN": "BASE", "FILL": "FILL", "ADD": "ADD", "DCA": "DCA", "T0_ROUND": "T0",
"BASE": "BASE", "T0": "T0",
}
ALERT_EXTERNAL = "EXTERNAL_FILL" # 对不上指令的外部成交
ALERT_OVERFILL = "OVER_FILL" # 成交量超过指令数量
ALERT_SELL_NO_LOT = "SELL_WITHOUT_LOT" # 卖出但账本无对应批次
SEV_OK, SEV_WARN, SEV_ERROR = "OK", "WARN", "ERROR"
def _side(v) -> str:
s = str(v or "").strip().lower()
if s in ("buy", "b", "1", "买入", ""):
return "buy"
if s in ("sell", "s", "2", "卖出", ""):
return "sell"
return s
def fill_qty_price(f: dict) -> tuple:
"""从一条 trading_order 记录取 (成交数量, 成交价)。
下游 filled_amount 不可靠 (bionic entry_gate 既有结论), 故价格口径:
filled_price/exec_avg_price > order_price; 数量口径: filled_qty > order_quantity
"""
qty = f.get("filled_qty")
if qty in (None, 0, "0"):
qty = f.get("order_quantity")
price = f.get("filled_price") or f.get("exec_avg_price") or f.get("order_price")
try:
qty = int(float(qty or 0))
except (TypeError, ValueError):
qty = 0
try:
price = float(price or 0)
except (TypeError, ValueError):
price = 0.0
return qty, price
# ================================================================
# 1. 回放: 认领 + 转账本动作
# ================================================================
def claim_fills(fills: list, open_instructions: list) -> list:
"""把成交单认领到 PMS 在途指令上 (FIFO 贪心)。
fills: [{order_id, ts_code, side, qty, price, done_time, instruction_id(可选)}] 时间升序
open_instructions: [{instruction_id, ts_code, side, qty, exec_qty, action, dispatched_at}]
返回 [{fill, claims:[{instruction_id, qty, action}], unclaimed_qty}]
"""
remain = {}
for i in open_instructions or []:
remain[i["instruction_id"]] = max(0, int(i.get("qty") or 0) - int(i.get("exec_qty") or 0))
idx = {i["instruction_id"]: i for i in (open_instructions or [])}
out = []
for f in fills or []:
qty = int(f.get("qty") or 0)
claims, left = [], qty
# (a) 下游已提供来源标识 → 精确认领
iid = f.get("instruction_id")
if iid and iid in remain:
take = min(left, remain[iid])
if take > 0:
claims.append({"instruction_id": iid, "qty": take,
"action": idx[iid].get("action")})
remain[iid] -= take
left -= take
# (b) 无来源标识 → 同股同向、下发早于成交、按下发时间 FIFO
if left > 0:
cands = [i for i in (open_instructions or [])
if i.get("ts_code") == f.get("ts_code")
and _side(i.get("side")) == _side(f.get("side"))
and remain.get(i["instruction_id"], 0) > 0
and _not_after(i.get("dispatched_at"), f.get("done_time"))]
cands.sort(key=lambda i: (str(i.get("dispatched_at") or ""), i["instruction_id"]))
for i in cands:
if left <= 0:
break
take = min(left, remain[i["instruction_id"]])
if take <= 0:
continue
claims.append({"instruction_id": i["instruction_id"], "qty": take,
"action": i.get("action")})
remain[i["instruction_id"]] -= take
left -= take
out.append({"fill": f, "claims": claims, "unclaimed_qty": left})
return out
def _not_after(dispatched_at, done_time) -> bool:
"""下发时间 ≤ 成交时间 (任一为空则不做时间约束, 交给数量口径兜底)。"""
if not dispatched_at or not done_time:
return True
try:
return str(dispatched_at) <= str(done_time)
except Exception:
return True
def map_fills_to_book(fills: list, open_instructions: list, known_order_ids=()) -> dict:
"""成交单 → 账本动作。幂等: known_order_ids 里的单直接跳过 (回放可重跑)。
返回 {"actions":[...], "alerts":[...], "skipped":int}
action = {kind: BUY|SELL, ts_code, qty, price, lot_type(买入), instruction_id, order_id, alerts}
"""
known = set(known_order_ids or ())
fresh = [f for f in (fills or []) if f.get("order_id") not in known]
claimed = claim_fills(fresh, open_instructions)
actions, alerts = [], []
for c in claimed:
f, left = c["fill"], c["unclaimed_qty"]
side = _side(f.get("side"))
price = float(f.get("price") or 0)
for cl in c["claims"]:
lot_type = ACTION_TO_LOT.get(str(cl.get("action") or "").upper(), "BASE")
actions.append({"kind": "BUY" if side == "buy" else "SELL",
"ts_code": f.get("ts_code"), "qty": cl["qty"], "price": price,
"lot_type": lot_type if side == "buy" else None,
"instruction_id": cl["instruction_id"],
"order_id": f.get("order_id"), "alerts": []})
if left > 0:
a = [ALERT_EXTERNAL]
msg = (f"外部成交: {f.get('ts_code')} {side} {left} 股 @ {price} "
f"(order_id={f.get('order_id')}) 认领不到 PMS 指令")
if c["claims"]:
a.append(ALERT_OVERFILL)
msg += " —— 且成交量超过在途指令数量"
alerts.append({"level": "WARN", "code": ALERT_EXTERNAL, "ts_code": f.get("ts_code"),
"message": msg, "order_id": f.get("order_id")})
actions.append({"kind": "BUY" if side == "buy" else "SELL",
"ts_code": f.get("ts_code"), "qty": left, "price": price,
"lot_type": "BASE" if side == "buy" else None, # 外部买入并入 BASE
"instruction_id": None, "order_id": f.get("order_id"), "alerts": a})
return {"actions": actions, "alerts": alerts,
"skipped": len(fills or []) - len(fresh)}
def apply_sell_to_lots(lots: list, qty: int) -> dict:
"""卖出核销预演: 按 T0→ADD(新→旧)→DCA→FILL→BASE 分配。
账本批次不足时按可核销量分配并告警 (以下游为准, 差额留给对账修正)"""
total = sum(int(l.get("qty") or 0) for l in (lots or []))
if qty > total:
alloc = sell_allocation(lots, total) if total > 0 else []
return {"alloc": alloc, "short": qty - total,
"alerts": [{"level": "WARN", "code": ALERT_SELL_NO_LOT,
"message": f"卖出 {qty} 股 > 账本批次 {total} 股, 差额 {qty - total} "
f"股待对账修正 (以下游为准)"}]}
return {"alloc": sell_allocation(lots, qty), "short": 0, "alerts": []}
def next_cursor(fills: list, cursor=None, key: str = "order_id"):
"""回放游标推进 (取本批最大 key, 空批保持原值)。"""
vals = [f.get(key) for f in (fills or []) if f.get(key) is not None]
if not vals:
return cursor
try:
mx = max(int(v) for v in vals)
return mx if cursor is None else max(int(cursor), mx)
except (TypeError, ValueError):
mx = max(str(v) for v in vals)
return mx if cursor is None else max(str(cursor), mx)
# ================================================================
# 2. 对账: 账本 vs 下游 (以下游为准)
# ================================================================
def diff_positions(book_rows: list, ds_rows: list) -> list:
"""账本与下游持仓比对。返回差异列表 (无差异 = 空表)。
book_rows: [{ts_code, total_qty}] ; ds_rows: [{ts_code, qty}] (下游 trading_position)
kind: MISSING_IN_BOOK(下游有账本无) / EXTRA_IN_BOOK(账本有下游无) / QTY_MISMATCH
delta = 下游 账本 (正数 = 账本要补, 负数 = 账本要减)
"""
book = {r["ts_code"]: int(r.get("total_qty") or 0) for r in (book_rows or [])}
ds = {r["ts_code"]: int(r.get("qty") or 0) for r in (ds_rows or [])}
out = []
for code in sorted(set(book) | set(ds)):
b, d = book.get(code, 0), ds.get(code, 0)
if b == d:
continue
if b == 0:
kind = "MISSING_IN_BOOK"
elif d == 0:
kind = "EXTRA_IN_BOOK"
else:
kind = "QTY_MISMATCH"
out.append({"ts_code": code, "book_qty": b, "ds_qty": d, "delta": d - b, "kind": kind})
return out
def build_recon_fixes(diffs: list, price_map=None, lots_map=None) -> list:
"""按差异生成修正动作 (以下游为准, 全部留痕)。
delta > 0 新增 RECON 批次补足 (价格取现价/成本代理, 无价则标 NEED_PRICE)
delta < 0 按核销次序冲销 |delta|
"""
price_map = price_map or {}
lots_map = lots_map or {}
fixes = []
for d in diffs or []:
code, delta = d["ts_code"], d["delta"]
px = float(price_map.get(code) or 0)
if delta > 0:
fixes.append({"ts_code": code, "op": "ADD_RECON_LOT", "qty": delta,
"price": px, "need_price": px <= 0,
"note": f"对账修正(+{delta}股): 以下游为准补入 RECON 批次"})
else:
lots = lots_map.get(code) or []
res = apply_sell_to_lots(lots, -delta)
fixes.append({"ts_code": code, "op": "REDUCE_LOTS", "qty": -delta,
"alloc": res["alloc"], "short": res["short"], "price": px,
"note": f"对账修正({delta}股): 以下游为准冲销批次"})
return fixes
def recon_severity(consecutive_days: int, alarm_days: int = 3) -> str:
"""连续不一致天数 → 告警级别 (设计 §13: 连续 3 日 → ERROR 待人工)。"""
n = int(consecutive_days or 0)
if n <= 0:
return SEV_OK
return SEV_ERROR if n >= int(alarm_days) else SEV_WARN
def summarize_recon(diffs: list, consecutive_days: int = 0, alarm_days: int = 3) -> dict:
kinds = {}
for d in diffs or []:
kinds[d["kind"]] = kinds.get(d["kind"], 0) + 1
return {"diff_count": len(diffs or []), "by_kind": kinds,
"consecutive_days": int(consecutive_days or 0),
"severity": recon_severity(consecutive_days if diffs else 0, alarm_days)}
# ================================================================
# 3. 除权检测与批次调整
# ================================================================
def detect_ex_right(prev_qty: int, now_qty: int, prev_price: float, now_price: float,
tol: float = 0.02) -> dict:
"""数量与价格反比突变 = 除权 (送转股)。
送股/转增: 数量 ×k, 价格 ÷k 数量比 价格反比
返回 None(无异动) / {"kind":"EX_RIGHT","ratio":k} / {"kind":"MISMATCH",...}(待人工)
"""
prev_qty, now_qty = int(prev_qty or 0), int(now_qty or 0)
prev_price, now_price = float(prev_price or 0), float(now_price or 0)
if prev_qty <= 0 or now_qty <= prev_qty:
return None
qr = now_qty / prev_qty
if prev_price <= 0 or now_price <= 0:
return {"kind": "MISMATCH", "qty_ratio": round(qr, 4), "price_ratio": None,
"reason": "缺少价格, 无法判定除权 (待人工)"}
pr = prev_price / now_price
rel = abs(qr - pr) / qr
if rel <= float(tol):
return {"kind": "EX_RIGHT", "ratio": round(qr, 6), "qty_ratio": round(qr, 4),
"price_ratio": round(pr, 4), "rel_err": round(rel, 4)}
return {"kind": "MISMATCH", "ratio": round(qr, 6), "qty_ratio": round(qr, 4),
"price_ratio": round(pr, 4), "rel_err": round(rel, 4),
"reason": f"数量比 {qr:.3f} 与价格反比 {pr:.3f} 不吻合 (偏差 {rel:.1%}), "
f"疑似非除权变动 → ERROR 待人工"}
def apply_ex_right(lots: list, ratio: float) -> list:
"""按除权比例调整批次: 数量 ×ratio, 成本价 ÷ratio (总成本不变)。"""
r = float(ratio)
out = []
for l in lots or []:
q = int(round(int(l.get("qty") or 0) * r))
px = float(l.get("open_price") or 0) / r if r else 0.0
n = dict(l)
n["qty"], n["open_price"] = q, round(px, 3)
n["note"] = f"除权调整 ×{r:g}"
out.append(n)
return out
# ================================================================
# 4. T+1 可用量
# ================================================================
def daily_avail_reset(total_qty: int) -> int:
"""日初重置: 昨日及以前的持仓全部可卖。"""
return max(0, int(total_qty or 0))
def avail_after_fill(avail_qty: int, side: str, qty: int) -> int:
"""当日成交对可卖量的影响: 买入不增可卖 (T+1), 卖出扣减可卖。"""
a, q = int(avail_qty or 0), int(qty or 0)
return max(0, a - q) if _side(side) == "sell" else a
def sellable_today(avail_qty: int, want_qty: int) -> int:
"""本次可卖数量 = min(想卖, 可卖); 部分不可卖时由执行器顺延次日。"""
return max(0, min(int(avail_qty or 0), int(want_qty or 0)))

104
app/core/tradedays.py Normal file
View File

@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
"""
交易日历 (A股) 调度守卫与执行窗口计算共用
==============================================
设计 §10 全部调度任务带交易日守卫; §3.1 任务命令的执行窗口以交易日计数
口径: 工作日且非法定节假日 = 交易日 (chinesecalendar 提供节假日表)
chinesecalendar 未安装或年份超出其数据范围时, 退化为周一至周五并置 degraded 标记
宁可多跑一次带守卫的任务, 也不静默跳过 (调度侧另有幂等)
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
try: # pragma: no cover - 依赖可用性分支
from chinese_calendar import is_workday as _is_workday
_HAS_CAL = True
except Exception: # pragma: no cover
_is_workday = None
_HAS_CAL = False
MAX_SCAN_DAYS = 400 # 防呆: 连续找不到交易日时的扫描上限
def _as_date(d) -> date:
if d is None:
return datetime.now().date()
if isinstance(d, datetime):
return d.date()
if isinstance(d, date):
return d
s = str(d).strip()
if len(s) == 8 and s.isdigit():
return date(int(s[:4]), int(s[4:6]), int(s[6:]))
return datetime.strptime(s[:10], "%Y-%m-%d").date()
def is_trade_day(d=None) -> bool:
dd = _as_date(d)
if dd.weekday() >= 5:
return False
if _HAS_CAL:
try:
return bool(_is_workday(dd))
except Exception:
return True # 年份超范围 → 按工作日处理 (degraded)
return True
def calendar_degraded() -> bool:
"""True = 未装 chinesecalendar, 节假日不可辨 (页面与日报应提示)。"""
return not _HAS_CAL
def next_trade_day(d=None, n: int = 1) -> date:
"""d 之后的第 n 个交易日 (n≥1); n=0 返回 d 当天 (不判是否交易日)。"""
dd = _as_date(d)
if n <= 0:
return dd
cnt, cur, guard = 0, dd, 0
while cnt < n and guard < MAX_SCAN_DAYS:
cur += timedelta(days=1)
guard += 1
if is_trade_day(cur):
cnt += 1
return cur
def window_deadline(start=None, window_tdays: int = 3) -> date:
"""执行窗口截止日 = 起始日(含, 若为交易日) 起的第 window_tdays 个交易日。
: 周一下达窗口 3 周三 (周一// 3 个交易日)
"""
n = max(1, int(window_tdays or 1))
cur = _as_date(start)
cnt = 1 if is_trade_day(cur) else 0
guard = 0
while cnt < n and guard < MAX_SCAN_DAYS:
cur += timedelta(days=1)
guard += 1
if is_trade_day(cur):
cnt += 1
if cnt == 0: # 起始日非交易日且窗口未推进 → 取下一个交易日
return next_trade_day(cur, 1)
return cur
def trade_days_left(deadline, today=None) -> int:
"""距截止日剩余交易日数 (含今日, 已过期返回 0) —— 择时执行器分配每日配额用。"""
dl, cur = _as_date(deadline), _as_date(today)
if cur > dl:
return 0
cnt, guard = 0, 0
while cur <= dl and guard < MAX_SCAN_DAYS:
if is_trade_day(cur):
cnt += 1
cur += timedelta(days=1)
guard += 1
return cnt
def ymd(d=None) -> int:
dd = _as_date(d)
return dd.year * 10000 + dd.month * 100 + dd.day

0
app/db/__init__.py Normal file
View File

191
app/db/session.py Normal file
View File

@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
"""
数据库连接与严格单表访问守卫
==================================
开发约定 (README): 153 代理侧数据库**严格单表访问** 代理不支持多表联查,
写错了要到线上才报错本模块在 SQL 执行入口做静态检查, 把这条纪律钉死在代码里
三个库 (键名对齐 bionic_trader, 同一 .env 可共用):
proxy PROXY_DB_URL 153 代理: pms_* 全部表 + trading_* + strategy_daily_results
factor SOURCE_DB_EXT_DSN 因子分表 (自算参考位/MA/ATR)
index DB_MYSQL_URL 大盘指数 zs_day_data (页面区制提示)
连接失败一律抛 DBUnavailable, 由上层 (API/任务) 捕获并降级 页面照常打开
调度任务照常守成不产生新指令 (设计 §13 三条铁律之故障即守成)
"""
from __future__ import annotations
import logging
import re
import time
import threading
from sqlalchemy import create_engine, text
from sqlalchemy.exc import SQLAlchemyError
from config.settings import settings
logger = logging.getLogger("pms.db")
_engines = {}
_lock = threading.Lock()
# 短路冷却: 库刚连挂过就直接快速失败, 不再逐次等 TCP 超时。
# 目的 —— 库不可达时页面仍能秒开 (每个接口只吃一次超时), 调度任务也不会被拖死。
FAIL_COOLDOWN_SEC = 10.0
_last_fail = {}
_DSN = {
"proxy": lambda: settings.PROXY_DB_URL,
"factor": lambda: settings.SOURCE_DB_EXT_DSN,
"index": lambda: settings.DB_MYSQL_URL,
}
class DBUnavailable(RuntimeError):
"""连库/执行失败。上层必须捕获并降级, 不得让异常冒泡成 500。"""
class MultiTableSQL(ValueError):
"""违反严格单表访问纪律。"""
# ---------------------------------------------------------------- 单表守卫
_TABLE_RE = re.compile(r"\b(?:from|join|into|update)\s+`?([a-zA-Z_][a-zA-Z0-9_]*)`?", re.I)
_JOIN_RE = re.compile(r"\bjoin\b", re.I)
# FROM 子句 (到下一个子句关键字为止) —— 用于识别 "FROM a, b" 这种逗号连表
_FROM_CLAUSE_RE = re.compile(
r"\bfrom\s+(.+?)(?=\bwhere\b|\bgroup\b|\border\b|\blimit\b|\bhaving\b|\bunion\b|\bon\b|\)|;|$)",
re.I | re.S)
def assert_single_table(sql: str) -> str:
"""静态检查: 一条 SQL 只能碰一张表, 且不得出现 JOIN / 逗号连表 / 跨表子查询。"""
s = re.sub(r"--[^\n]*", " ", str(sql))
s = re.sub(r"/\*.*?\*/", " ", s, flags=re.S)
if _JOIN_RE.search(s):
raise MultiTableSQL(f"严格单表访问: SQL 含 JOIN —— {s[:120]}")
for clause in _FROM_CLAUSE_RE.findall(s):
parts = [x.strip() for x in clause.split(",") if x.strip()]
if len(parts) > 1:
raise MultiTableSQL(f"严格单表访问: FROM 子句逗号连表 {parts} —— {s[:120]}")
tables = {m.lower() for m in _TABLE_RE.findall(s)}
if len(tables) > 1:
raise MultiTableSQL(f"严格单表访问: SQL 涉及多表 {sorted(tables)} —— {s[:120]}")
return sql
# ---------------------------------------------------------------- 引擎
def get_engine(name: str = "proxy"):
if name in _engines:
return _engines[name]
with _lock:
if name in _engines:
return _engines[name]
dsn_fn = _DSN.get(name)
if not dsn_fn:
raise DBUnavailable(f"未知数据源 {name}")
try:
eng = create_engine(dsn_fn(), pool_pre_ping=True, pool_size=5, max_overflow=5,
pool_recycle=3600, future=True,
# 连接超时兜底: 库不可达时快速失败, 不拖死页面与调度任务
connect_args={"connect_timeout": 3})
except Exception as e: # DSN 非法等
raise DBUnavailable(f"数据源 {name} 初始化失败: {e}") from e
_engines[name] = eng
return eng
def _check_cooldown(name: str):
ts = _last_fail.get(name)
if ts and (time.monotonic() - ts) < FAIL_COOLDOWN_SEC:
raise DBUnavailable(
f"数据源 {name} 刚刚连接失败, {FAIL_COOLDOWN_SEC:.0f} 秒内快速失败 "
f"(避免逐次等待超时); 恢复后自动重连")
def _mark(name: str, ok: bool):
if ok:
_last_fail.pop(name, None)
else:
_last_fail[name] = time.monotonic()
def ping(name: str = "proxy") -> dict:
try:
with get_engine(name).connect() as c:
c.execute(text("SELECT 1"))
_mark(name, True)
return {"ok": True, "source": name}
except Exception as e:
_mark(name, False)
return {"ok": False, "source": name, "error": f"{type(e).__name__}: {e}"}
# ---------------------------------------------------------------- 执行助手
def fetch_all(sql: str, params=None, source: str = "proxy") -> list:
assert_single_table(sql)
_check_cooldown(source)
try:
with get_engine(source).connect() as c:
rows = c.execute(text(sql), params or {}).mappings().fetchall()
_mark(source, True)
return [dict(r) for r in rows]
except MultiTableSQL:
raise
except SQLAlchemyError as e:
_mark(source, _is_sql_error(e))
raise DBUnavailable(f"查询失败({source}): {type(e).__name__}: {e}") from e
except Exception as e:
_mark(source, False)
raise DBUnavailable(f"查询异常({source}): {type(e).__name__}: {e}") from e
def _is_sql_error(e) -> bool:
"""区分「SQL 本身错」(库是通的, 不进冷却) 与「连不上库」(进冷却)。"""
msg = str(e).lower()
return not any(k in msg for k in ("can't connect", "connection refused", "timed out",
"lost connection", "gone away", "no route to host",
"name or service not known"))
def fetch_one(sql: str, params=None, source: str = "proxy"):
rows = fetch_all(sql, params, source)
return rows[0] if rows else None
def execute(sql: str, params=None, source: str = "proxy") -> int:
"""写操作 (INSERT/UPDATE/DELETE)。返回受影响行数。"""
assert_single_table(sql)
_check_cooldown(source)
try:
with get_engine(source).begin() as c:
r = c.execute(text(sql), params or {})
_mark(source, True)
return int(r.rowcount or 0)
except MultiTableSQL:
raise
except SQLAlchemyError as e:
_mark(source, _is_sql_error(e))
raise DBUnavailable(f"写入失败({source}): {type(e).__name__}: {e}") from e
except Exception as e:
_mark(source, False)
raise DBUnavailable(f"写入异常({source}): {type(e).__name__}: {e}") from e
def execute_many(sql: str, seq_params: list, source: str = "proxy") -> int:
assert_single_table(sql)
if not seq_params:
return 0
_check_cooldown(source)
try:
with get_engine(source).begin() as c:
r = c.execute(text(sql), seq_params)
_mark(source, True)
return int(r.rowcount or 0)
except SQLAlchemyError as e:
_mark(source, _is_sql_error(e))
raise DBUnavailable(f"批量写入失败({source}): {type(e).__name__}: {e}") from e
except Exception as e:
_mark(source, False)
raise DBUnavailable(f"批量写入异常({source}): {type(e).__name__}: {e}") from e

0
app/repo/__init__.py Normal file
View File

201
app/repo/downstream_repo.py Normal file
View File

@ -0,0 +1,201 @@
# -*- coding: utf-8 -*-
"""
下游/上游只读表访问 (trading_* strategy_daily_results, 153 代理, 严格单表)
==============================================================================
这三张表**归下游系统维护**, PMS 只读 (设计 §9)
列名不确定性 (重要):
QMT_INTERFACE_REQUIREMENTS A1/A2/D1 仍在协商 目前只确认 trading_position.stock_code
trading_order order_* 系列列因此本模块**不硬编码持仓数量列名**, 而是先取一行
探测列名 (候选列表按常见命名排优先级), 并把探测结果回传给上层, 在页面与日报显式展示
本次对账用的是哪一列协商答复到位后, PMS_DS_QTY_COL 参数写死即可停用探测
"""
from __future__ import annotations
from app.db.session import fetch_all, fetch_one
# 数量/可用量列名候选 (探测顺序即优先级)
QTY_CANDIDATES = ["current_qty", "total_qty", "position_qty", "hold_qty", "stock_qty",
"volume", "quantity", "qty", "position_volume", "hold_volume"]
AVAIL_CANDIDATES = ["available_qty", "avail_qty", "can_use_volume", "available_volume",
"sellable_qty", "enable_amount", "can_sell_qty"]
COST_CANDIDATES = ["cost_price", "avg_cost", "open_price", "position_cost", "cost"]
FROZEN_CANDIDATES = ["frozen_qty", "frozen_volume", "freeze_qty"]
FILLED_STATUSES = ("completed", "filled")
def to_dot(code: str) -> str:
"""SH600000 / 600000 → 600000.SH (PMS 内部统一点式)。"""
c = (code or "").strip().upper()
if not c:
return ""
if "." in c:
return c
if c[:2] in ("SH", "SZ", "BJ") and c[2:].isdigit():
return f"{c[2:]}.{c[:2]}"
if c.isdigit() and len(c) == 6:
return f"{c}.SH" if c[0] == "6" else (f"{c}.SZ" if c[0] in "03" else f"{c}.BJ")
return c
def to_prefix(code: str) -> str:
"""600000.SH → SH600000 (strategy_daily_results 口径)。"""
c = (code or "").strip().upper()
if "." in c:
num, mkt = c.split(".", 1)
return f"{mkt}{num}"
return c
def _pick(keys, candidates):
low = {str(k).lower(): k for k in keys}
for c in candidates:
if c in low:
return low[c]
return None
def describe(table: str) -> list:
"""SHOW COLUMNS —— 供页面导出、回填 QMT_INTERFACE_REQUIREMENTS D1。"""
if table not in ("trading_position", "trading_order", "trading_buy_plan",
"strategy_daily_results"):
raise ValueError(f"不允许探测的表: {table}")
return fetch_all(f"SHOW COLUMNS FROM {table}")
# ================================================================ trading_position
def fetch_positions() -> dict:
"""下游持仓快照。返回 {"rows":[{ts_code, qty, avail_qty, cost, frozen}], "columns":{...}}"""
rows = fetch_all("SELECT * FROM trading_position LIMIT 1000")
if not rows:
return {"rows": [], "columns": {"qty": None, "avail": None, "cost": None},
"raw_count": 0}
keys = rows[0].keys()
qty_col = _pick(keys, QTY_CANDIDATES)
avail_col = _pick(keys, AVAIL_CANDIDATES)
cost_col = _pick(keys, COST_CANDIDATES)
frozen_col = _pick(keys, FROZEN_CANDIDATES)
code_col = _pick(keys, ["stock_code", "ts_code", "code", "security_code"]) or "stock_code"
out = []
for r in rows:
code = to_dot(str(r.get(code_col) or ""))
if not code:
continue
out.append({
"ts_code": code,
"qty": _int(r.get(qty_col)) if qty_col else None,
"avail_qty": _int(r.get(avail_col)) if avail_col else None,
"cost": _float(r.get(cost_col)) if cost_col else None,
"frozen": _int(r.get(frozen_col)) if frozen_col else None,
})
return {"rows": out, "raw_count": len(rows),
"columns": {"code": code_col, "qty": qty_col, "avail": avail_col,
"cost": cost_col, "frozen": frozen_col}}
def _int(v):
try:
return int(float(v or 0))
except (TypeError, ValueError):
return 0
def _float(v):
try:
return float(v or 0)
except (TypeError, ValueError):
return 0.0
# ================================================================ trading_order
def fetch_filled_orders(*, since_id=None, since_time=None, limit: int = 500) -> list:
"""已成交单增量拉取 (回放用)。价格口径: filled_price > order_price (下游 filled_amount 不可靠)。
order_id 可能是字符串委托号 游标同时支持数值与字符串比较, 取不到则按时间兜底
"""
# 状态枚举为常量 (QMT A2 待正式确认), 直接内联避免 IN 绑定展开
where = ["order_status IN ('" + "', '".join(FILLED_STATUSES) + "')"]
p = {"n": int(limit)}
if since_id not in (None, "", 0):
where.append("order_id > :sid")
p["sid"] = since_id
elif since_time:
where.append("(filled_time >= :st OR order_time >= :st)")
p["st"] = since_time
sql = ("SELECT * FROM trading_order WHERE " + " AND ".join(where) +
" ORDER BY order_id ASC LIMIT :n")
rows = fetch_all(sql, p)
return [_norm_order(r) for r in rows]
def _norm_order(r: dict) -> dict:
side = str(r.get("order_side") or "").strip().lower()
if side in ("1", "b", "买入", ""):
side = "buy"
elif side in ("2", "s", "卖出", ""):
side = "sell"
qty = r.get("filled_quantity") or r.get("filled_qty") or r.get("order_quantity")
price = r.get("filled_price") or r.get("filled_avg_price") or r.get("order_price")
return {"order_id": r.get("order_id"), "ts_code": to_dot(str(r.get("stock_code") or "")),
"side": side, "qty": _int(qty), "price": _float(price),
"done_time": str(r.get("filled_time") or r.get("order_time") or ""),
"status": r.get("order_status"), "raw": r}
# ================================================================ trading_buy_plan
def fetch_buy_plans(*, is_active=None, limit: int = 200) -> list:
"""上游买入计划 (PMS 作为承接方, 替代原 ENTRY_GATE 角色 —— 设计 §6)。
is_active: 7=待仲裁 (上游产出待承接), 6=待挂单 (下游取走), 5=盘中拒
"""
sql = ("SELECT id, strategy_id, stock_code, stock_name, target_price, buy_amount, "
"factor_code, trading_time, create_time, update_time, tp_ratio, sl_ratio, "
"prob_thresh, hold_days, is_active, approved_by FROM trading_buy_plan")
p = {"n": int(limit)}
if is_active is not None:
sql += " WHERE is_active = :ia"
p["ia"] = int(is_active)
sql += " ORDER BY update_time DESC LIMIT :n"
rows = fetch_all(sql, p)
out = []
for r in rows:
px = _float(r.get("target_price"))
out.append({"plan_id": r.get("id"), "ts_code": to_dot(str(r.get("stock_code") or "")),
"name": r.get("stock_name"), "price": px,
"amount": _float(r.get("buy_amount")),
"score": _float(r.get("prob_thresh")), "factor": r.get("factor_code"),
"tp_ratio": _float(r.get("tp_ratio")), "sl_ratio": _float(r.get("sl_ratio")),
"is_active": r.get("is_active"), "update_time": r.get("update_time")})
return out
# ================================================================ strategy_daily_results
def fetch_refs(ts_code: str):
"""决策系统昨夜结论: 支撑/压力参考位 (主口径; 停更超期由 services 兜底自算)。"""
r = fetch_one(
"SELECT stock_code, signal_type, support_level, pressure_level, trade_date "
"FROM strategy_daily_results WHERE stock_code = :code "
"ORDER BY trade_date DESC LIMIT 1", {"code": to_prefix(ts_code)})
if not r:
return None
return {"ts_code": ts_code, "signal_type": r.get("signal_type"),
"support": _float(r.get("support_level")) or None,
"pressure": _float(r.get("pressure_level")) or None,
"trade_date": r.get("trade_date")}
def fetch_sector_from_category(ts_code: str):
"""行业适配器 gp_stock_category (映射表就绪前的临时数据源 —— 设计 §5)。"""
for col in ("stock_code", "ts_code"):
try:
r = fetch_one(f"SELECT * FROM gp_stock_category WHERE {col} = :code LIMIT 1",
{"code": to_prefix(ts_code) if col == "stock_code" else ts_code})
except Exception:
continue
if r:
for k in ("industry", "category", "sector", "industry_name", "sw_industry"):
if r.get(k):
return str(r[k])
return None

521
app/repo/pms_repo.py Normal file
View File

@ -0,0 +1,521 @@
# -*- coding: utf-8 -*-
"""
PMS 自有表数据访问 (pms_*, 153 代理侧, 严格单表)
=================================================
每个函数只碰一张表 跨表编排一律在 services 层做
所有 SQL db.session 的单表守卫; 列名白名单防注入
表结构见 ddl_pms_v1.sql
"""
from __future__ import annotations
import json
from datetime import datetime
from app.db.session import execute, execute_many, fetch_all, fetch_one
# 允许动态更新的列白名单 (防注入; 与 DDL 保持一致)
POSITION_COLS = {
"status", "frozen_reason", "total_qty", "avail_qty", "base_qty", "fill_qty", "add_qty",
"dca_qty", "t0_qty", "avg_cost", "realized_t_profit", "cushion_pct", "cushion_state",
"cushion_peak", "pct_of_scale", "target_pct", "stop_ref", "support_ref", "pressure_ref",
"ref_source", "t0_enabled", "t0_ratio", "opened_date", "fill_count", "last_add_date",
"dca_count", "t0_count_today",
}
LOT_COLS = {"qty", "closed_qty", "close_avg_price", "realized_pnl", "status", "open_price",
"note", "lot_type"}
_NOW = lambda: datetime.now() # noqa: E731 (容器时区 Asia/Shanghai)
def _dumps(v):
return json.dumps(v, ensure_ascii=False) if not isinstance(v, (str, type(None))) else v
def _loads(v, default=None):
if v in (None, ""):
return default
if isinstance(v, (dict, list)):
return v
try:
return json.loads(v)
except (ValueError, TypeError):
return default
def _set_clause(fields: dict, allow: set) -> tuple:
cols = [c for c in fields if c in allow]
if not cols:
return "", {}
return ", ".join(f"{c} = :{c}" for c in cols), {c: fields[c] for c in cols}
# ================================================================ pms_runtime_param
def all_params() -> dict:
rows = fetch_all("SELECT param_key, param_value, updated_by, updated_at FROM pms_runtime_param")
return {r["param_key"]: r for r in rows}
def set_param(key: str, value, updated_by: str = "user") -> int:
return execute(
"INSERT INTO pms_runtime_param (param_key, param_value, updated_by, updated_at) "
"VALUES (:k, :v, :by, :ts) "
"ON DUPLICATE KEY UPDATE param_value = :v, updated_by = :by, updated_at = :ts",
{"k": key, "v": str(value), "by": updated_by, "ts": _NOW()})
def get_param(key: str):
r = fetch_one("SELECT param_value FROM pms_runtime_param WHERE param_key = :k", {"k": key})
return r["param_value"] if r else None
# ================================================================ pms_command
def next_command_seq(ymd: int) -> int:
r = fetch_one("SELECT COUNT(*) AS n FROM pms_command WHERE command_id LIKE :p",
{"p": f"CMD_{ymd}_%"})
return int((r or {}).get("n") or 0) + 1
def insert_command(*, command_id, cmd_class, cmd_type, ts_code, params, status,
issued_by="user", note=None, progress=None) -> int:
return execute(
"INSERT INTO pms_command (command_id, cmd_class, cmd_type, ts_code, params_json, "
"status, progress_json, issued_by, issued_at, note) VALUES "
"(:cid, :cls, :ct, :code, :pj, :st, :pg, :by, :ts, :note)",
{"cid": command_id, "cls": cmd_class, "ct": cmd_type, "code": ts_code,
"pj": _dumps(params or {}), "st": status, "pg": _dumps(progress),
"by": issued_by, "ts": _NOW(), "note": note})
def get_command(command_id: str):
r = fetch_one("SELECT * FROM pms_command WHERE command_id = :cid", {"cid": command_id})
return _cmd_row(r) if r else None
def list_commands(*, statuses=None, cmd_class=None, limit: int = 200) -> list:
where, p = [], {"n": int(limit)}
if statuses:
keys = []
for i, s in enumerate(statuses):
keys.append(f":s{i}")
p[f"s{i}"] = s
where.append(f"status IN ({', '.join(keys)})")
if cmd_class:
where.append("cmd_class = :cls")
p["cls"] = cmd_class
sql = "SELECT * FROM pms_command"
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY id DESC LIMIT :n"
return [_cmd_row(r) for r in fetch_all(sql, p)]
def update_command(command_id: str, *, status=None, progress=None, done_at=None,
note=None) -> int:
sets, p = [], {"cid": command_id}
if status is not None:
sets.append("status = :st")
p["st"] = status
if progress is not None:
sets.append("progress_json = :pg")
p["pg"] = _dumps(progress)
if done_at is not None:
sets.append("done_at = :da")
p["da"] = done_at
if note is not None:
sets.append("note = :note")
p["note"] = note
if not sets:
return 0
return execute(f"UPDATE pms_command SET {', '.join(sets)} WHERE command_id = :cid", p)
def supersede_param_commands(cmd_type: str, ts_code=None, keep_command_id=None) -> int:
"""同类型 (同标的) 的旧生效参数命令置 SUPERSEDED —— 参数命令当前值取最新 EFFECTIVE。"""
sql = ("UPDATE pms_command SET status = 'SUPERSEDED' WHERE cmd_class = 'param' "
"AND status = 'EFFECTIVE' AND cmd_type = :ct")
p = {"ct": cmd_type}
if ts_code:
sql += " AND ts_code = :code"
p["code"] = ts_code
else:
sql += " AND ts_code IS NULL"
if keep_command_id:
sql += " AND command_id <> :keep"
p["keep"] = keep_command_id
return execute(sql, p)
def latest_effective(cmd_type: str, ts_code=None):
sql = ("SELECT * FROM pms_command WHERE cmd_class = 'param' AND status = 'EFFECTIVE' "
"AND cmd_type = :ct")
p = {"ct": cmd_type}
if ts_code:
sql += " AND ts_code = :code"
p["code"] = ts_code
sql += " ORDER BY id DESC LIMIT 1"
r = fetch_one(sql, p)
return _cmd_row(r) if r else None
def list_effective_stock_params(cmd_types=None) -> list:
"""个股参数命令的当前值 (黑白名单/止损价/目标价/做T授权/冻结 的事实源)。"""
sql = ("SELECT * FROM pms_command WHERE cmd_class = 'param' AND status = 'EFFECTIVE' "
"AND ts_code IS NOT NULL")
p = {}
if cmd_types:
keys = []
for i, t in enumerate(cmd_types):
keys.append(f":t{i}")
p[f"t{i}"] = t
sql += f" AND cmd_type IN ({', '.join(keys)})"
sql += " ORDER BY id DESC LIMIT 500"
return [_cmd_row(r) for r in fetch_all(sql, p)]
def _cmd_row(r: dict) -> dict:
d = dict(r)
d["params"] = _loads(d.pop("params_json", None), {})
d["progress"] = _loads(d.pop("progress_json", None), {})
return d
# ================================================================ pms_plan
def insert_plans(rows: list) -> int:
if not rows:
return 0
now = _NOW()
payload = [{"pid": r["plan_id"], "cid": r["command_id"], "code": r["ts_code"],
"act": r["action"], "qty": r.get("qty"), "amt": r.get("amount"),
"pri": r.get("priority", 100), "dl": r.get("deadline"),
"st": r.get("status", "PENDING"), "rsn": (r.get("reason") or "")[:300],
"ts": now}
for r in rows]
return execute_many(
"INSERT INTO pms_plan (plan_id, command_id, ts_code, action, qty, amount, priority, "
"deadline, status, filled_qty, reason, created_at, updated_at) VALUES "
"(:pid, :cid, :code, :act, :qty, :amt, :pri, :dl, :st, 0, :rsn, :ts, :ts)", payload)
def list_plans(*, command_id=None, statuses=None, ts_code=None, limit: int = 500) -> list:
where, p = [], {"n": int(limit)}
if command_id:
where.append("command_id = :cid")
p["cid"] = command_id
if ts_code:
where.append("ts_code = :code")
p["code"] = ts_code
if statuses:
keys = []
for i, s in enumerate(statuses):
keys.append(f":s{i}")
p[f"s{i}"] = s
where.append(f"status IN ({', '.join(keys)})")
sql = "SELECT * FROM pms_plan"
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY priority ASC, id ASC LIMIT :n"
return fetch_all(sql, p)
def update_plan(plan_id: str, *, status=None, filled_qty=None) -> int:
sets, p = ["updated_at = :ts"], {"pid": plan_id, "ts": _NOW()}
if status is not None:
sets.append("status = :st")
p["st"] = status
if filled_qty is not None:
sets.append("filled_qty = :fq")
p["fq"] = int(filled_qty)
return execute(f"UPDATE pms_plan SET {', '.join(sets)} WHERE plan_id = :pid", p)
def cancel_plans_of_command(command_id: str) -> int:
return execute("UPDATE pms_plan SET status = 'CANCELLED', updated_at = :ts "
"WHERE command_id = :cid AND status IN ('PENDING', 'EXECUTING')",
{"cid": command_id, "ts": _NOW()})
def set_plans_deadline(command_id: str, deadline) -> int:
return execute("UPDATE pms_plan SET deadline = :dl, updated_at = :ts "
"WHERE command_id = :cid AND status IN ('PENDING', 'EXECUTING')",
{"cid": command_id, "dl": deadline, "ts": _NOW()})
# ================================================================ pms_position
def list_positions(*, only_open: bool = False) -> list:
sql = "SELECT * FROM pms_position"
if only_open:
sql += " WHERE total_qty > 0"
sql += " ORDER BY ts_code"
return fetch_all(sql)
def get_position(ts_code: str):
return fetch_one("SELECT * FROM pms_position WHERE ts_code = :code", {"code": ts_code})
def ensure_position(ts_code: str) -> int:
return execute(
"INSERT INTO pms_position (ts_code, status, updated_at) VALUES (:code, 'PLANNED', :ts) "
"ON DUPLICATE KEY UPDATE updated_at = :ts", {"code": ts_code, "ts": _NOW()})
def update_position(ts_code: str, **fields) -> int:
clause, p = _set_clause(fields, POSITION_COLS)
if not clause:
return 0
p.update({"code": ts_code, "ts": _NOW()})
return execute(f"UPDATE pms_position SET {clause}, updated_at = :ts WHERE ts_code = :code", p)
def bump_position_qty(ts_code: str, *, total_delta: int = 0, avail_delta: int = 0) -> int:
return execute(
"UPDATE pms_position SET total_qty = GREATEST(0, total_qty + :td), "
"avail_qty = GREATEST(0, avail_qty + :ad), updated_at = :ts WHERE ts_code = :code",
{"td": int(total_delta), "ad": int(avail_delta), "code": ts_code, "ts": _NOW()})
def reset_avail_all() -> int:
"""日初 T+1 重置: 全部持仓可卖 (设计 §4)。"""
return execute("UPDATE pms_position SET avail_qty = total_qty, t0_count_today = 0, "
"updated_at = :ts WHERE total_qty >= 0", {"ts": _NOW()})
# ================================================================ pms_lot
def list_lots(ts_code=None, *, status: str = "OPEN", limit: int = 1000) -> list:
where, p = [], {"n": int(limit)}
if ts_code:
where.append("ts_code = :code")
p["code"] = ts_code
if status:
where.append("status = :st")
p["st"] = status
sql = "SELECT * FROM pms_lot"
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY open_date ASC, id ASC LIMIT :n"
return fetch_all(sql, p)
def insert_lot(*, ts_code, lot_type, qty, open_price, open_date, instruction_id=None,
note=None) -> int:
now = _NOW()
return execute(
"INSERT INTO pms_lot (ts_code, lot_type, qty, open_price, open_date, closed_qty, "
"realized_pnl, status, instruction_id, note, created_at, updated_at) VALUES "
"(:code, :lt, :qty, :px, :od, 0, 0, 'OPEN', :iid, :note, :ts, :ts)",
{"code": ts_code, "lt": lot_type, "qty": int(qty), "px": float(open_price),
"od": open_date, "iid": instruction_id, "note": note, "ts": now})
def close_lot_qty(lot_id: int, *, qty: int, close_price: float, realized_pnl: float) -> int:
"""核销批次数量。close_avg_price 按加权平均累计 (多次部分卖出不覆盖)。
: MySQL UPDATE ... SET 按书写顺序求值且后项可见前项新值
close_avg_price 必须写在 closed_qty 之前 (用旧 closed_qty 加权),
status 必须写在 qty 之后 (用新 qty 判断是否核销完)顺序不可随意调整
"""
return execute(
"UPDATE pms_lot SET "
"close_avg_price = CASE WHEN (closed_qty + :q) > 0 "
" THEN (COALESCE(close_avg_price, 0) * closed_qty + :px * :q) / (closed_qty + :q) "
" ELSE :px END, "
"qty = GREATEST(0, qty - :q), "
"closed_qty = closed_qty + :q, "
"realized_pnl = realized_pnl + :pnl, "
"status = CASE WHEN qty <= 0 THEN 'CLOSED' ELSE 'OPEN' END, "
"updated_at = :ts WHERE id = :id",
{"q": int(qty), "px": float(close_price), "pnl": float(realized_pnl),
"id": int(lot_id), "ts": _NOW()})
def update_lot(lot_id: int, **fields) -> int:
clause, p = _set_clause(fields, LOT_COLS)
if not clause:
return 0
p.update({"id": int(lot_id), "ts": _NOW()})
return execute(f"UPDATE pms_lot SET {clause}, updated_at = :ts WHERE id = :id", p)
# ================================================================ pms_instruction
def insert_instruction(*, instruction_id, origin_type, origin_id, ts_code, action, side, qty,
limit_price=None, window_tdays=3, status="PROPOSED",
progress=None) -> int:
now = _NOW()
return execute(
"INSERT INTO pms_instruction (instruction_id, origin_type, origin_id, ts_code, action, "
"side, qty, limit_price, window_tdays, status, exec_qty, progress_json, created_at, "
"updated_at) VALUES (:iid, :ot, :oid, :code, :act, :side, :qty, :lp, :w, :st, 0, :pg, "
":ts, :ts)",
{"iid": instruction_id, "ot": origin_type, "oid": origin_id, "code": ts_code,
"act": action, "side": side, "qty": int(qty), "lp": limit_price,
"w": int(window_tdays), "st": status, "pg": _dumps(progress), "ts": now})
def list_instructions(*, statuses=None, side=None, ts_code=None, limit: int = 300) -> list:
where, p = [], {"n": int(limit)}
if statuses:
keys = []
for i, s in enumerate(statuses):
keys.append(f":s{i}")
p[f"s{i}"] = s
where.append(f"status IN ({', '.join(keys)})")
if side:
where.append("side = :side")
p["side"] = side
if ts_code:
where.append("ts_code = :code")
p["code"] = ts_code
sql = "SELECT * FROM pms_instruction"
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY id DESC LIMIT :n"
rows = fetch_all(sql, p)
for r in rows:
r["progress"] = _loads(r.get("progress_json"), {})
return rows
def get_instruction(instruction_id: str):
r = fetch_one("SELECT * FROM pms_instruction WHERE instruction_id = :iid",
{"iid": instruction_id})
if r:
r["progress"] = _loads(r.get("progress_json"), {})
return r
def update_instruction(instruction_id: str, *, status=None, exec_qty=None, exec_avg_price=None,
dispatch_ref=None, progress=None) -> int:
sets, p = ["updated_at = :ts"], {"iid": instruction_id, "ts": _NOW()}
if status is not None:
sets.append("status = :st")
p["st"] = status
if exec_qty is not None:
sets.append("exec_qty = :eq")
p["eq"] = int(exec_qty)
if exec_avg_price is not None:
sets.append("exec_avg_price = :ep")
p["ep"] = float(exec_avg_price)
if dispatch_ref is not None:
sets.append("dispatch_ref = :dr")
p["dr"] = dispatch_ref
if progress is not None:
sets.append("progress_json = :pg")
p["pg"] = _dumps(progress)
return execute(f"UPDATE pms_instruction SET {', '.join(sets)} WHERE instruction_id = :iid", p)
def add_instruction_exec(instruction_id: str, qty: int) -> int:
return execute("UPDATE pms_instruction SET exec_qty = exec_qty + :q, updated_at = :ts "
"WHERE instruction_id = :iid",
{"q": int(qty), "iid": instruction_id, "ts": _NOW()})
# ================================================================ pms_proposal
def insert_proposal(*, proposal_id, ts_code, action, qty, hard_numbers, expire_at,
judge_verdict=None, judge_reason=None, status="WAIT_USER") -> int:
return execute(
"INSERT INTO pms_proposal (proposal_id, ts_code, action, qty, hard_numbers_json, "
"judge_verdict, judge_reason, status, expire_at, created_at) VALUES "
"(:pid, :code, :act, :qty, :hn, :jv, :jr, :st, :exp, :ts)",
{"pid": proposal_id, "code": ts_code, "act": action, "qty": int(qty),
"hn": _dumps(hard_numbers or {}), "jv": judge_verdict, "jr": judge_reason,
"st": status, "exp": expire_at, "ts": _NOW()})
def list_proposals(*, statuses=("WAIT_USER",), limit: int = 200) -> list:
p, keys = {"n": int(limit)}, []
for i, s in enumerate(statuses or ()):
keys.append(f":s{i}")
p[f"s{i}"] = s
sql = "SELECT * FROM pms_proposal"
if keys:
sql += f" WHERE status IN ({', '.join(keys)})"
sql += " ORDER BY id DESC LIMIT :n"
rows = fetch_all(sql, p)
for r in rows:
r["hard_numbers"] = _loads(r.get("hard_numbers_json"), {})
return rows
def get_proposal(proposal_id: str):
r = fetch_one("SELECT * FROM pms_proposal WHERE proposal_id = :pid", {"pid": proposal_id})
if r:
r["hard_numbers"] = _loads(r.get("hard_numbers_json"), {})
return r
def decide_proposal(proposal_id: str, status: str) -> int:
return execute("UPDATE pms_proposal SET status = :st, decided_at = :ts "
"WHERE proposal_id = :pid AND status = 'WAIT_USER'",
{"st": status, "pid": proposal_id, "ts": _NOW()})
def expire_proposals(now=None) -> int:
return execute("UPDATE pms_proposal SET status = 'EXPIRED' "
"WHERE status = 'WAIT_USER' AND expire_at < :ts", {"ts": now or _NOW()})
# ================================================================ pms_action_ledger
def insert_ledger(*, ts_code, action, arbiter, verdict, price_at, hard_numbers=None,
failed_checks=None, reason=None, ref_id=None) -> int:
return execute(
"INSERT INTO pms_action_ledger (ts_code, decided_at, action, arbiter, verdict, "
"price_at, hard_numbers_json, failed_checks_json, reason, ref_id, outcome_scored) "
"VALUES (:code, :ts, :act, :arb, :vd, :px, :hn, :fc, :rsn, :ref, 0)",
{"code": ts_code, "ts": _NOW(), "act": action, "arb": arbiter, "vd": verdict,
"px": float(price_at or 0), "hn": _dumps(hard_numbers), "fc": _dumps(failed_checks),
"rsn": (reason or "")[:500], "ref": ref_id})
def list_ledger(*, ts_code=None, limit: int = 200) -> list:
sql = "SELECT * FROM pms_action_ledger"
p = {"n": int(limit)}
if ts_code:
sql += " WHERE ts_code = :code"
p["code"] = ts_code
sql += " ORDER BY id DESC LIMIT :n"
rows = fetch_all(sql, p)
for r in rows:
r["hard_numbers"] = _loads(r.get("hard_numbers_json"), {})
r["failed_checks"] = _loads(r.get("failed_checks_json"), [])
return rows
# ================================================================ pms_daily_report
def upsert_report(ymd: int, report: dict) -> int:
return execute(
"INSERT INTO pms_daily_report (ymd, report_json, created_at) VALUES (:y, :r, :ts) "
"ON DUPLICATE KEY UPDATE report_json = :r, created_at = :ts",
{"y": int(ymd), "r": _dumps(report), "ts": _NOW()})
def get_report(ymd: int):
r = fetch_one("SELECT * FROM pms_daily_report WHERE ymd = :y", {"y": int(ymd)})
return {"ymd": r["ymd"], "report": _loads(r["report_json"], {})} if r else None
def latest_report():
r = fetch_one("SELECT * FROM pms_daily_report ORDER BY ymd DESC LIMIT 1")
return {"ymd": r["ymd"], "report": _loads(r["report_json"], {})} if r else None
# ================================================================ pms_industry_map
def get_industry(ts_code: str):
r = fetch_one("SELECT industry FROM pms_industry_map WHERE ts_code = :code",
{"code": ts_code})
return r["industry"] if r else None
def list_industry(limit: int = 5000) -> list:
return fetch_all("SELECT ts_code, industry, updated_at FROM pms_industry_map "
"ORDER BY ts_code LIMIT :n", {"n": int(limit)})
def upsert_industry(rows: list) -> int:
if not rows:
return 0
now = _NOW()
payload = [{"code": r["ts_code"], "ind": r["industry"], "ts": now} for r in rows]
return execute_many(
"INSERT INTO pms_industry_map (ts_code, industry, updated_at) VALUES (:code, :ind, :ts) "
"ON DUPLICATE KEY UPDATE industry = :ind, updated_at = :ts", payload)

185
app/scheduler.py Normal file
View File

@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""
调度器 (Celery beat + worker) 设计 §10 调度总表
====================================================
| 调度 | 时间 | 任务 |
|-----------|----------------------|---------------------------------------------|
| 盘前准备 | 交易日 08:50 | T+1 可卖重置 / 参考位取数 / 刹车结算 |
| 命令轮询 | 1 分钟 (全天) | 新命令解析 方案生成 任务状态机推进 |
| 盘中执行 | 交易时段每 1 分钟 | 择时出手 + 自主提议扫描 (执行器下一批交付) |
| 信号消化 | 交易时段每 1 分钟 | 订阅决策系统盘中信号 (下一批交付) |
| 成交回放 | 交易时段每 5 分钟 | trading_order 增量回放 + 轻对账 |
| T 仓平回 | 14:50 (二期) | 做T强制平回 |
| 日终结算 | 15:10 | 全量对账 / 除权 / 安全垫 / 命令进度日结 |
| 运营日报 | 15:30 | 关注区 + 全量统计, 页面可查 |
三条守卫:
1. 交易日守卫 非交易日任务直接返回 (chinesecalendar; 未装则退化为周一至周五)
2. 故障即守成 任务内异常一律吞掉并记 ERROR 日志, 绝不因调度异常产生新指令
3. 全局暂停执行 (休假模式) 除对账与日报外的任务全部跳过
启用: docker compose --profile sched up -d
"""
from __future__ import annotations
import functools
import logging
from datetime import datetime
from celery import Celery
from celery.schedules import crontab
from config.settings import settings
from app.core import tradedays as td
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("pms.sched")
celery_app = Celery("pms", broker=settings.PMS_REDIS_URL, backend=settings.PMS_REDIS_URL)
celery_app.conf.update(
timezone="Asia/Shanghai", enable_utc=False,
task_serializer="json", result_serializer="json", accept_content=["json"],
result_expires=3600, worker_max_tasks_per_child=200,
task_soft_time_limit=240, task_time_limit=300,
broker_connection_retry_on_startup=True,
)
# 交易时段 (含集合竞价前 5 分钟余量)
SESSIONS = ((9, 25, 11, 30), (13, 0, 15, 5))
def in_session(now=None) -> bool:
now = now or datetime.now()
hm = now.hour * 60 + now.minute
return any(h1 * 60 + m1 <= hm <= h2 * 60 + m2 for h1, m1, h2, m2 in SESSIONS)
def guard(*, trade_day=True, session=False, respect_exec_halt=True):
"""任务守卫: 交易日 / 交易时段 / 全局暂停执行; 并统一吞异常 (守成)。"""
def deco(fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
name = fn.__name__
if trade_day and not td.is_trade_day():
logger.info("[%s] 非交易日, 跳过", name)
return {"skipped": "not_trade_day"}
if session and not in_session():
return {"skipped": "not_in_session"}
if respect_exec_halt:
try:
from app.services import param_store
if param_store.get_bool("PMS_GLOBAL_EXEC_HALT", False):
logger.info("[%s] 全局暂停执行 (休假模式), 跳过", name)
return {"skipped": "exec_halt"}
except Exception as e:
logger.warning("[%s] 读取暂停开关失败 (按未暂停继续): %s", name, e)
t0 = datetime.now()
try:
r = fn(*a, **kw)
logger.info("[%s] 完成 %.1fs · %s", name, (datetime.now() - t0).total_seconds(),
_brief(r))
return r
except Exception as e:
logger.exception("[%s] 异常 (守成: 不产生新指令): %s", name, e)
return {"error": f"{type(e).__name__}: {e}"}
return wrapper
return deco
def _brief(r):
if not isinstance(r, dict):
return str(r)[:200]
keep = {k: v for k, v in r.items()
if k in ("ok", "fills", "actions", "planned", "failed", "diffs", "errors",
"skipped", "avail_reset", "refs", "cursor")}
if isinstance(keep.get("diffs"), list):
keep["diffs"] = len(keep["diffs"])
if isinstance(keep.get("errors"), list):
keep["errors"] = keep["errors"][:2]
return str(keep)[:300]
# ================================================================ 任务
@celery_app.task(name="pms.premarket")
@guard(trade_day=True)
def premarket():
from app.services import ledger_service
return ledger_service.premarket()
@celery_app.task(name="pms.command_poll")
@guard(trade_day=False, respect_exec_halt=True)
def command_poll():
"""命令轮询 (全天, 含非交易日 —— 用户随时可下命令, 方案先生成好待开盘执行)。"""
from app.services import command_service
r = command_service.plan_pending()
r.update(command_service.refresh_progress())
return r
@celery_app.task(name="pms.replay_fills")
@guard(trade_day=True, session=True)
def replay_fills():
from app.services import ledger_service
r = ledger_service.replay_fills()
light = ledger_service.reconcile(apply_fix=False) # 盘中轻对账: 只看差异不改账
r["light_recon"] = {"diffs": len(light.get("diffs") or []),
"severity": light.get("severity")}
return r
@celery_app.task(name="pms.intraday_exec")
@guard(trade_day=True, session=True)
def intraday_exec():
"""盘中执行: 择时出手 + 自主提议扫描。**执行器与动作引擎为下一批交付**,
本轮只做在途指令的超时收口 (设计 §13: 不自动重发)"""
from app.services import ledger_service
return {"expired": ledger_service.expire_stale_instructions(), "executor": "pending_batch3"}
@celery_app.task(name="pms.signal_digest")
@guard(trade_day=True, session=True)
def signal_digest():
"""信号消化: 订阅决策系统盘中信号 (风控 SELL/止盈/反转) → 卖出方案或提议。下一批交付。"""
return {"consumed": 0, "note": "决策系统信号订阅为下一批交付 (设计 §10 信号消化)"}
@celery_app.task(name="pms.t0_close")
@guard(trade_day=True)
def t0_close():
"""T 仓强制平回 (14:50)。做T为二期上线, 此处先留调度位并自证 T 仓应为 0。"""
from app.repo import pms_repo
left = [p["ts_code"] for p in pms_repo.list_positions(only_open=True)
if int(p.get("t0_qty") or 0) > 0]
if left:
logger.warning("[t0_close] 仍有 T 仓未平回: %s (做T为二期功能, 请人工核查)", left)
return {"t0_open": left, "phase": "二期"}
@celery_app.task(name="pms.daily_settle")
@guard(trade_day=True, respect_exec_halt=False) # 对账属守成动作, 休假模式下照跑
def daily_settle():
from app.services import ledger_service
return ledger_service.daily_settle()
@celery_app.task(name="pms.daily_report")
@guard(trade_day=True, respect_exec_halt=False)
def daily_report():
from app.services import ledger_service
r = ledger_service.build_daily_report()
return {"ymd": r.get("ymd"), "attention": len(r.get("attention") or [])}
# ================================================================ beat 调度表
celery_app.conf.beat_schedule = {
"premarket": {"task": "pms.premarket", "schedule": crontab(hour=8, minute=50)},
"command_poll": {"task": "pms.command_poll", "schedule": crontab(minute="*")},
"replay_fills": {"task": "pms.replay_fills", "schedule": crontab(minute="*/5")},
"intraday_exec": {"task": "pms.intraday_exec", "schedule": crontab(minute="*")},
"signal_digest": {"task": "pms.signal_digest", "schedule": crontab(minute="*")},
"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_report": {"task": "pms.daily_report", "schedule": crontab(hour=15, minute=30)},
}

0
app/services/__init__.py Normal file
View File

View File

@ -0,0 +1,494 @@
# -*- coding: utf-8 -*-
"""
命令服务: 下达 校验 冲突识别 生效/规划 进度推进
==========================================================
设计对应: §3 命令系统§3.2 降仓全流程§13 命令冲突由用户裁决
铁律落点:
* 命令至上 命令类动作只过规则闸 (上限/一手/行业), 不送研判闸
* 先记账后动作 命令与方案先落表, 指令下发由择时执行器另行负责 (下一批)
* 故障即守成 任何一步失败都返回 ok=False 并保留已落表内容, 不产生新指令
参数命令的事实源:
全局参数 pms_runtime_param (ParamStore 读取, 页面即时生效)
个股参数 pms_command 最新 EFFECTIVE 记录 (设计 §11 原话), 同时投影到 pms_position
"""
from __future__ import annotations
import logging
from datetime import datetime
from app.core import command_spec as cs
from app.core import planner as pl
from app.core import tradedays as td
from app.repo import pms_repo
from app.services import industry, param_store, portfolio
logger = logging.getLogger("pms.command")
# 在途 (可被撤销/冲突判定) 的指令状态
LIVE_INSTR = ("PROPOSED", "RULE_PASSED", "JUDGE_PASSED", "DISPATCHED")
STOCK_PARAM_TYPES = ("FREEZE_STOCK", "UNFREEZE_STOCK", "T0_ENABLE", "T0_DISABLE",
"SET_STOP_PRICE", "SET_TARGET_PRICE", "BLACKLIST_ADD", "BLACKLIST_REMOVE",
"WHITELIST_ADD", "WHITELIST_REMOVE")
# ================================================================ 查询
def active_commands() -> list:
"""在途任务命令 + 生效中参数命令 (冲突判定与页面「在途」列表共用)。"""
return pms_repo.list_commands(
statuses=[cs.ST_PENDING, cs.ST_PLANNING, cs.ST_EXECUTING, cs.ST_PARTIAL,
cs.ST_EFFECTIVE], limit=300)
def effective_stock_params() -> dict:
"""个股参数命令当前值: {ts_code: {frozen, t0, stop_price, target_price, black, white}}"""
rows = pms_repo.list_effective_stock_params(STOCK_PARAM_TYPES)
out = {}
for r in sorted(rows, key=lambda x: x["id"]): # 旧 → 新, 后者覆盖前者
code = r.get("ts_code")
if not code:
continue
d = out.setdefault(code, {})
t, p = r["cmd_type"], r.get("params") or {}
if t == "FREEZE_STOCK":
d["frozen"] = True
elif t == "UNFREEZE_STOCK":
d["frozen"] = False
elif t == "T0_ENABLE":
d["t0"] = True
d["t_ratio"] = p.get("t_ratio")
elif t == "T0_DISABLE":
d["t0"] = False
elif t == "SET_STOP_PRICE":
d["stop_price"] = p.get("price")
elif t == "SET_TARGET_PRICE":
d["target_price"] = p.get("price")
elif t == "BLACKLIST_ADD":
d["black"] = True
elif t == "BLACKLIST_REMOVE":
d["black"] = False
elif t == "WHITELIST_ADD":
d["white"] = True
elif t == "WHITELIST_REMOVE":
d["white"] = False
return out
def blacklist() -> set:
return {c for c, d in effective_stock_params().items() if d.get("black")}
def whitelist() -> set:
return {c for c, d in effective_stock_params().items() if d.get("white")}
# ================================================================ 下达
def issue(cmd_type: str, params: dict, *, note=None, issued_by="user",
force_conflict: bool = False) -> dict:
"""下达一条命令。返回 {ok, command_id, errors, conflicts, plan}。"""
spec = cs.SPECS.get(cmd_type)
if not spec:
return {"ok": False, "errors": [f"UNKNOWN_CMD: {cmd_type}"]}
norm, errors = cs.validate(cmd_type, params or {})
if errors:
return {"ok": False, "errors": errors}
if spec.get("needs_sector_source") and not industry.ready():
return {"ok": False, "errors": [
"SECTOR_SOURCE_OFF: 行业划分数据源未配置 (PMS_SECTOR_SOURCE 为空), 行业类命令不可用"]}
ts_code = norm.get("ts_code")
try:
conflicts = cs.detect_conflicts({"cmd_type": cmd_type, "ts_code": ts_code},
active_commands())
except Exception as e:
logger.warning("冲突检测失败(按无冲突继续): %s", e)
conflicts = []
if conflicts and not force_conflict:
return {"ok": False, "errors": ["CONFLICT: 与在途命令冲突, 请裁决后重试 "
"(确认要并行可带 force=true 重下)"],
"conflicts": conflicts}
ymd = td.ymd()
try:
seq = pms_repo.next_command_seq(ymd)
command_id = cs.make_command_id(ymd, seq)
pms_repo.insert_command(command_id=command_id, cmd_class=spec["cls"], cmd_type=cmd_type,
ts_code=ts_code, params=norm, status=cs.ST_PENDING,
issued_by=issued_by, note=note)
except Exception as e:
logger.exception("命令落表失败")
return {"ok": False, "errors": [f"DB_ERROR: 命令落表失败 {type(e).__name__}: {e}"]}
if spec["cls"] == cs.CLS_PARAM:
res = _apply_param(command_id, cmd_type, spec, norm)
else:
res = plan_command(pms_repo.get_command(command_id))
res.setdefault("command_id", command_id)
res["conflicts"] = conflicts
return res
def _apply_param(command_id: str, cmd_type: str, spec: dict, norm: dict) -> dict:
"""参数命令: 立即生效 + 旧记录置 SUPERSEDED + (全局)写运行参数 / (个股)投影到账本。"""
ts_code = norm.get("ts_code")
applied = {}
try:
if spec["scope"] == "global":
key, val = spec["param_key"], norm[spec["value_field"]]
r = param_store.set_param(key, val, updated_by="command")
if not r.get("ok"):
pms_repo.update_command(command_id, status=cs.ST_CANCELLED,
note=f"参数写入失败: {r.get('error')}")
return {"ok": False, "errors": [r.get("error")]}
applied[key] = val
else:
_project_stock_param(cmd_type, spec, norm, ts_code)
applied = dict(norm)
pms_repo.supersede_param_commands(cmd_type, ts_code, keep_command_id=command_id)
_supersede_opposites(cmd_type, ts_code)
pms_repo.update_command(command_id, status=cs.ST_EFFECTIVE, progress={"applied": applied},
done_at=datetime.now())
return {"ok": True, "status": cs.ST_EFFECTIVE, "applied": applied, "plan": None}
except Exception as e:
logger.exception("参数命令生效失败")
pms_repo.update_command(command_id, status=cs.ST_CANCELLED,
note=f"生效失败: {type(e).__name__}: {e}")
return {"ok": False, "errors": [f"APPLY_ERROR: {type(e).__name__}: {e}"]}
_OPPOSITE = {"FREEZE_STOCK": "UNFREEZE_STOCK", "UNFREEZE_STOCK": "FREEZE_STOCK",
"T0_ENABLE": "T0_DISABLE", "T0_DISABLE": "T0_ENABLE",
"BLACKLIST_ADD": "BLACKLIST_REMOVE", "BLACKLIST_REMOVE": "BLACKLIST_ADD",
"WHITELIST_ADD": "WHITELIST_REMOVE", "WHITELIST_REMOVE": "WHITELIST_ADD"}
def _supersede_opposites(cmd_type: str, ts_code):
opp = _OPPOSITE.get(cmd_type)
if opp and ts_code:
pms_repo.supersede_param_commands(opp, ts_code)
def _project_stock_param(cmd_type: str, spec: dict, norm: dict, ts_code: str):
"""把个股参数命令投影到 pms_position, 供规则闸速读 (事实源仍是命令表)。"""
proj = spec.get("projection")
if not proj or not ts_code:
return
pms_repo.ensure_position(ts_code)
fields = {}
for k, v in proj.items():
fields[k] = norm.get(str(v)[1:]) if isinstance(v, str) and v.startswith("@") else v
if cmd_type == "T0_ENABLE":
fields["t0_ratio"] = norm.get("t_ratio")
pms_repo.update_position(ts_code, **fields)
# ================================================================ 规划
def plan_command(cmd: dict) -> dict:
"""任务命令 → 方案落表。命令状态推进到 EXECUTING (即时任务直接 DONE)。"""
if not cmd:
return {"ok": False, "errors": ["命令不存在"]}
cmd_type, command_id = cmd["cmd_type"], cmd["command_id"]
spec = cs.SPECS.get(cmd_type, {})
p = cmd.get("params") or {}
try:
pms_repo.update_command(command_id, status=cs.ST_PLANNING)
except Exception as e:
return {"ok": False, "errors": [f"DB_ERROR: {e}"]}
try:
result = _dispatch_planner(cmd_type, p, cmd)
except Exception as e:
logger.exception("方案生成失败 %s", command_id)
pms_repo.update_command(command_id, status=cs.ST_CANCELLED,
note=f"方案生成失败: {type(e).__name__}: {e}")
return {"ok": False, "command_id": command_id,
"errors": [f"PLAN_ERROR: {type(e).__name__}: {e}"]}
items = result.get("items") or []
window = int(p.get("window_tdays") or param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3))
deadline = td.window_deadline(datetime.now().date(), window)
rows = []
for i, it in enumerate(items, start=1):
rows.append({"plan_id": cs.make_plan_id(command_id, i), "command_id": command_id,
"ts_code": it["ts_code"], "action": it["action"], "qty": it.get("qty"),
"amount": it.get("amount"), "priority": it.get("priority", 100),
"deadline": deadline,
"status": "PENDING" if not it.get("gated") else "PENDING",
"reason": it.get("reason")})
try:
if rows:
pms_repo.insert_plans(rows)
except Exception as e:
logger.exception("方案落表失败 %s", command_id)
pms_repo.update_command(command_id, status=cs.ST_CANCELLED,
note=f"方案落表失败: {type(e).__name__}: {e}")
return {"ok": False, "command_id": command_id, "errors": [f"DB_ERROR: {e}"]}
# 撤单类动作立刻执行 (撤销在途买入/全部在途指令)
cancelled = _cancel_marked_instructions(items)
# 开关类命令写运行参数
if spec.get("switch_key") is not None:
param_store.set_param(spec["switch_key"], spec.get("switch_value"), "command")
progress = {"target_amount": result.get("target_amount", 0.0),
"planned_amount": result.get("planned_amount", 0.0),
"done_amount": 0.0, "gap": result.get("gap", 0.0),
"plan_count": len(rows), "deadline": str(deadline),
"notes": result.get("notes", []), "rejects": result.get("rejects", []),
"cancelled_instructions": cancelled}
instant = bool(spec.get("instant")) or not rows
status = cs.ST_DONE if instant else cs.ST_EXECUTING
pms_repo.update_command(command_id, status=status, progress=progress,
done_at=datetime.now() if status == cs.ST_DONE else None)
_ledger_rejects(result.get("rejects") or [], command_id)
return {"ok": True, "command_id": command_id, "status": status, "plan": progress,
"items": items, "errors": []}
def _dispatch_planner(cmd_type: str, p: dict, cmd: dict) -> dict:
"""按命令类型调用对应的方案生成器 (纯逻辑在 core/planner.py)。"""
view = portfolio.positions_view()
sp = view["params"]
positions = view["held"]
scale = float(sp["scale"] or 0)
exclude = _codes_with_live_plans()
if cmd_type == "REDUCE_EXPOSURE":
return pl.plan_reduce_exposure(
release_amount=scale * float(p["pct"]), positions=positions,
pending_buys=_pending_buys(), params={"weak_neg_days": sp["weak_neg_days"]},
exclude_codes=exclude)
if cmd_type == "INCREASE_EXPOSURE":
return pl.plan_increase_exposure(
add_amount=scale * float(p["pct"]), positions=positions,
candidates=_candidates(view), ctx=portfolio.caps_ctx(view), params=sp)
if cmd_type in ("HALT_BUY",):
return pl.plan_halt_buy(pending_buys=_pending_buys())
if cmd_type in ("HALT_ALL",):
return pl.plan_halt_all(pending_instructions=_pending_instructions())
if cmd_type in ("RESUME_BUY", "RESUME_ALL"):
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": ["开关已恢复"], "rejects": []}
if cmd_type == "LIQUIDATE_ALL":
return pl.plan_liquidate_all(positions=positions, pending_buys=_pending_buys())
if cmd_type == "SECTOR_EXIT":
return pl.plan_sector_exit(sector=p["sector"], positions=positions)
if cmd_type == "SECTOR_CAP":
param_store.set_param(f"PMS_SECTOR_CAP_{p['sector']}", float(p["cap"]), "command")
return pl.plan_sector_cap(sector=p["sector"], cap=float(p["cap"]), positions=positions)
if cmd_type == "OPEN_TARGET":
code = p["ts_code"]
px = _price_of(view, code)
if not px:
return {"ok": False, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [], "rejects": [
{"ts_code": code, "reasons": ["PRICE_MISSING: 取不到现价, 本轮不建仓"]}]}
return pl.plan_open_target(ts_code=code, target_pct=float(p["target_pct"]), price=px,
ctx=portfolio.caps_ctx(view, ts_code=code), params=sp)
if cmd_type == "EXIT_STOCK":
return pl.plan_exit_stock(ts_code=p["ts_code"], position=_pos_of(view, p["ts_code"]))
if cmd_type == "REDUCE_STOCK":
return pl.plan_reduce_stock(ts_code=p["ts_code"], target_pct=float(p["target_pct"]),
position=_pos_of(view, p["ts_code"]), scale=scale)
if cmd_type == "ADJUST_WINDOW":
return _adjust_window(p)
if cmd_type == "CANCEL_COMMAND":
return _cancel_target(p)
return {"ok": False, "items": [], "target_amount": 0.0, "planned_amount": 0.0, "gap": 0.0,
"notes": [f"命令 {cmd_type} 暂无对应方案生成器"], "rejects": []}
def _adjust_window(p: dict) -> dict:
target = pms_repo.get_command(p["target_command_id"])
if not target:
return {"ok": False, "items": [], "notes": ["目标命令不存在"], "rejects": [],
"target_amount": 0.0, "planned_amount": 0.0, "gap": 0.0}
dl = td.window_deadline(datetime.now().date(), int(p["window_tdays"]))
pms_repo.set_plans_deadline(target["command_id"], dl)
prog = dict(target.get("progress") or {})
prog["deadline"] = str(dl)
pms_repo.update_command(target["command_id"], progress=prog)
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0, "gap": 0.0,
"notes": [f"命令 {target['command_id']} 窗口调整为 {p['window_tdays']} 交易日 "
f"(截止 {dl})"], "rejects": []}
def _cancel_target(p: dict) -> dict:
r = cancel(p["target_command_id"])
return {"ok": r.get("ok", False), "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [r.get("message", "")], "rejects": []}
# ================================================================ 撤销与进度
def cancel(command_id: str) -> dict:
"""撤销在途任务命令: 命令置 CANCELLED, 方案作废, 在途指令撤回 (设计: 在途自主指令自动撤销)。"""
cmd = pms_repo.get_command(command_id)
if not cmd:
return {"ok": False, "message": f"命令 {command_id} 不存在"}
if cmd["cmd_class"] != cs.CLS_TASK:
return {"ok": False, "message": "参数命令不可撤销, 请下达新的参数命令覆盖"}
if cmd["status"] not in cs.ACTIVE_TASK_STATES:
return {"ok": False, "message": f"命令处于 {cmd['status']}, 不可撤销"}
plans = pms_repo.list_plans(command_id=command_id, statuses=["PENDING", "EXECUTING"])
n_plan = pms_repo.cancel_plans_of_command(command_id)
n_ins = 0
plan_ids = {p["plan_id"] for p in plans}
for ins in pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=500):
if ins.get("origin_type") == "plan" and ins.get("origin_id") in plan_ids:
pms_repo.update_instruction(ins["instruction_id"], status="CANCELLED")
n_ins += 1
pms_repo.update_command(command_id, status=cs.ST_CANCELLED, done_at=datetime.now())
return {"ok": True, "message": f"命令 {command_id} 已撤销 (作废方案 {n_plan} 条, "
f"撤回指令 {n_ins} 条)"}
def plan_pending(limit: int = 20) -> dict:
"""调度器每分钟一跳: 把 PENDING 的任务命令推进到 EXECUTING (幂等)。"""
done, errs = [], []
for c in pms_repo.list_commands(statuses=[cs.ST_PENDING], cmd_class=cs.CLS_TASK,
limit=limit):
r = plan_command(c)
(done if r.get("ok") else errs).append(c["command_id"])
return {"planned": done, "failed": errs}
def refresh_progress(command_id=None) -> dict:
"""按方案成交进度刷新命令进度与状态 (窗口末未达标 → PARTIAL)。"""
cmds = ([pms_repo.get_command(command_id)] if command_id else
pms_repo.list_commands(statuses=[cs.ST_EXECUTING, cs.ST_PARTIAL],
cmd_class=cs.CLS_TASK, limit=100))
out = []
today = datetime.now().date()
for c in [x for x in cmds if x]:
plans = pms_repo.list_plans(command_id=c["command_id"])
done_amt = 0.0
for p in plans:
qty, amt, filled = int(p.get("qty") or 0), float(p.get("amount") or 0), \
int(p.get("filled_qty") or 0)
if qty > 0 and filled > 0:
done_amt += amt * min(1.0, filled / qty)
prog = dict(c.get("progress") or {})
prog["done_amount"] = round(done_amt, 2)
dl = prog.get("deadline")
over = bool(dl) and str(today) > str(dl)[:10]
st = cs.settle_task_status(float(prog.get("target_amount") or 0), done_amt, over)
if st != c["status"] and cs.can_transition(cs.CLS_TASK, c["status"], st):
pms_repo.update_command(c["command_id"], status=st, progress=prog,
done_at=datetime.now() if st == cs.ST_DONE else None)
else:
pms_repo.update_command(c["command_id"], progress=prog)
out.append({"command_id": c["command_id"], "status": st,
"done_amount": prog["done_amount"],
"target_amount": prog.get("target_amount")})
return {"commands": out}
# ================================================================ 内部助手
def _pending_buys() -> list:
rows = pms_repo.list_instructions(statuses=list(LIVE_INSTR), side="buy", limit=500)
return [{"instruction_id": r["instruction_id"], "ts_code": r["ts_code"],
"qty": int(r.get("qty") or 0),
"amount": float(r.get("qty") or 0) * float(r.get("limit_price") or 0),
"side": "buy"} for r in rows]
def _pending_instructions() -> list:
rows = pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=500)
return [{"instruction_id": r["instruction_id"], "ts_code": r["ts_code"],
"qty": int(r.get("qty") or 0),
"amount": float(r.get("qty") or 0) * float(r.get("limit_price") or 0),
"side": r.get("side")} for r in rows]
def _cancel_marked_instructions(items: list) -> list:
out = []
for it in items or []:
iid = it.get("cancel_instruction_id")
if not iid:
continue
try:
pms_repo.update_instruction(iid, status="CANCELLED")
out.append(iid)
except Exception as e:
logger.warning("撤销指令失败 %s: %s", iid, e)
return out
def _codes_with_live_plans() -> list:
rows = pms_repo.list_plans(statuses=["PENDING", "EXECUTING"], limit=500)
return sorted({r["ts_code"] for r in rows})
def _pos_of(view: dict, ts_code: str) -> dict:
for x in view["positions"]:
if x["ts_code"] == ts_code:
return x
return {"ts_code": ts_code, "total_qty": 0, "price": 0.0}
def _price_of(view: dict, ts_code: str):
p = _pos_of(view, ts_code)
if p.get("price"):
return float(p["price"])
from app.services import market
return market.get_price(ts_code)
def _candidates(view: dict) -> list:
"""升仓候选池 = 上游买入计划 白名单, 剔除黑名单/已持有/冻结。"""
from app.repo import downstream_repo
held = {x["ts_code"] for x in view["held"]}
sp = effective_stock_params()
black = {c for c, d in sp.items() if d.get("black")}
out, seen = [], set()
try:
plans = downstream_repo.fetch_buy_plans(is_active=7, limit=100)
except Exception as e:
logger.warning("读上游买入计划失败: %s", e)
plans = []
for p in plans:
c = p["ts_code"]
if not c or c in held or c in black or c in seen:
continue
seen.add(c)
out.append({"ts_code": c, "price": p.get("price") or 0, "score": p.get("score") or 0,
"sector": industry.get(c), "src": "upstream"})
from app.services import market
for c, d in sp.items():
if not d.get("white") or c in held or c in black or c in seen:
continue
px = market.get_price(c)
if not px:
continue
seen.add(c)
out.append({"ts_code": c, "price": px, "score": 1.0, "sector": industry.get(c),
"src": "whitelist"})
return out
def _ledger_rejects(rejects: list, command_id: str):
"""被上限/行业拦下的候选也要留痕 —— 「拒了的后来涨了多少」是调参核心数据 (设计 §7)。"""
for r in rejects or []:
try:
pms_repo.insert_ledger(ts_code=r.get("ts_code") or "-", action="OPEN", arbiter="rule",
verdict="REJECT", price_at=0,
failed_checks=r.get("reasons"), ref_id=command_id,
reason="命令规划期规则闸拦截")
except Exception as e:
logger.warning("拒绝留痕失败: %s", e)

88
app/services/industry.py Normal file
View File

@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
"""
行业划分接口 (设计 §5 的可插拔适配器)
======================================
IndustryClassifier.get(ts_code) -> 行业名 | None
数据源经参数 PMS_SECTOR_SOURCE 切换:
custom_table 自定义映射表 pms_industry_map (默认建议; 用户后续灌何种划分都只是灌数)
gp_stock_category 决策系统生态已有的行业表 (映射表就绪前的临时数据源)
""() 未配置 行业约束自动停用, 页面显著提示 (不静默放行也不误拦)
"""
from __future__ import annotations
import logging
import threading
import time
from app.repo import downstream_repo, pms_repo
from app.services import param_store
logger = logging.getLogger("pms.industry")
_cache = {"at": 0.0, "map": {}, "source": None}
_lock = threading.Lock()
CACHE_TTL = 300.0
def source() -> str:
return (param_store.get("PMS_SECTOR_SOURCE", "") or "").strip()
def ready() -> bool:
"""行业约束是否生效 (数据源已配置)。"""
return source() in ("custom_table", "gp_stock_category")
def status() -> dict:
src = source()
st = {"source": src, "ready": ready(), "count": 0,
"hint": "行业划分数据源未配置 —— 行业集中度硬拦截已自动停用, "
"行业类命令置灰 (设计 §5/§13)"}
if src == "custom_table":
try:
st["count"] = len(_load_custom())
st["hint"] = f"自定义映射表 pms_industry_map 已加载 {st['count']}"
except Exception as e:
st["ready"] = False
st["hint"] = f"映射表读取失败: {type(e).__name__}: {e}"
elif src == "gp_stock_category":
st["hint"] = "临时数据源 gp_stock_category (逐票查询, 建议尽快切 custom_table)"
return st
def _load_custom() -> dict:
now = time.time()
if _cache["source"] == "custom_table" and now - _cache["at"] < CACHE_TTL:
return _cache["map"]
with _lock:
rows = pms_repo.list_industry()
m = {r["ts_code"]: r["industry"] for r in rows}
_cache.update({"at": now, "map": m, "source": "custom_table"})
return m
def get(ts_code: str):
"""返回行业名; 未配置数据源或查不到 → None (调用方据此跳过行业约束)。"""
src = source()
if not ts_code or src not in ("custom_table", "gp_stock_category"):
return None
try:
if src == "custom_table":
return _load_custom().get(ts_code)
return downstream_repo.fetch_sector_from_category(ts_code)
except Exception as e:
logger.warning("行业查询失败 [%s]: %s", ts_code, e)
return None
def get_many(codes) -> dict:
src = source()
if src != "custom_table":
return {c: get(c) for c in (codes or [])}
m = _load_custom()
return {c: m.get(c) for c in (codes or [])}
def invalidate():
_cache["at"] = 0.0

View File

@ -0,0 +1,474 @@
# -*- coding: utf-8 -*-
"""
账本服务: 成交回放 · 对账 · 除权 · 日终结算 (设计 §4生命线)
================================================================
纯逻辑在 app/core/recon.py, 本模块只负责取数落库与状态推进
摊薄成本口径 ( core/cushion.PositionCost 等价, 但数据来自批次表):
cum_buy = Σ (剩余数量 + 已核销数量) × 开仓价
cum_sell = Σ 已核销数量 × 核销均价
avg_cost = max(0, (cum_buy cum_sell) / 当前持股数)
做T利润通过 T0 批次的买卖流水自然摊入上式, **不再另行扣减** realized_t_profit
(该列仅作展示统计, 重复扣减会把成本做低两次)
铁律: 对账以下游为准; 修正一律走 RECON 批次留痕; 连续 N 日不一致升级 ERROR
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from app.core import cushion as cu
from app.core import recon as rc
from app.core import tradedays as td
from app.repo import downstream_repo, pms_repo
from app.services import market, param_store, portfolio
logger = logging.getLogger("pms.ledger")
CURSOR_KEY = "PMS_REPLAY_CURSOR"
STREAK_KEY = "PMS_RECON_STREAK"
LIVE_INSTR = ("DISPATCHED", "JUDGE_PASSED", "RULE_PASSED")
# ================================================================ 回放
def replay_fills(*, limit: int = 500) -> dict:
"""增量回放 trading_order 已成交单 → 批次入账 (每 5 分钟一跳, 幂等)。"""
out = {"ok": True, "fills": 0, "actions": 0, "alerts": [], "errors": [], "cursor": None}
cursor = pms_repo.get_param(CURSOR_KEY) or None
try:
fills = downstream_repo.fetch_filled_orders(since_id=cursor, limit=limit)
except Exception as e:
out.update({"ok": False, "errors": [f"读 trading_order 失败: {type(e).__name__}: {e}"]})
return out
out["fills"] = len(fills)
if not fills:
out["cursor"] = cursor
return out
try:
instrs = _open_instructions()
except Exception as e:
instrs = []
out["errors"].append(f"读在途指令失败(按外部成交处理): {e}")
mapped = rc.map_fills_to_book(fills, instrs)
for act in mapped["actions"]:
try:
_apply_action(act)
out["actions"] += 1
except Exception as e:
logger.exception("入账失败 %s", act)
out["errors"].append(f"{act.get('ts_code')} 入账失败: {type(e).__name__}: {e}")
out["alerts"] = mapped["alerts"]
for code in {a["ts_code"] for a in mapped["actions"]}:
try:
recompute_position(code)
except Exception as e:
out["errors"].append(f"{code} 成本重算失败: {e}")
try:
nc = rc.next_cursor(fills, cursor)
pms_repo.set_param(CURSOR_KEY, nc, "system")
out["cursor"] = nc
except Exception as e:
out["errors"].append(f"游标推进失败: {e}")
out["ok"] = not out["errors"]
for a in out["alerts"]:
logger.warning("[回放告警] %s", a.get("message"))
return out
def _open_instructions() -> list:
rows = pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=500)
return [{"instruction_id": r["instruction_id"], "ts_code": r["ts_code"],
"side": r.get("side"), "qty": int(r.get("qty") or 0),
"exec_qty": int(r.get("exec_qty") or 0), "action": r.get("action"),
"dispatched_at": str(r.get("updated_at") or r.get("created_at") or "")}
for r in rows]
def _apply_action(act: dict):
code, qty, px = act["ts_code"], int(act["qty"]), float(act["price"] or 0)
if qty <= 0:
return
pms_repo.ensure_position(code)
if act["kind"] == "BUY":
pms_repo.insert_lot(ts_code=code, lot_type=act.get("lot_type") or "BASE", qty=qty,
open_price=px, open_date=datetime.now().date(),
instruction_id=act.get("instruction_id"),
note="外部成交并入 BASE" if not act.get("instruction_id") else None)
pms_repo.bump_position_qty(code, total_delta=qty, avail_delta=0) # T+1: 当日买入不可卖
else:
lots = pms_repo.list_lots(code, status="OPEN")
res = rc.apply_sell_to_lots(
[{"lot_id": l["id"], "lot_type": l["lot_type"], "qty": int(l["qty"]),
"open_date": _date_key(l["open_date"])} for l in lots], qty)
by_id = {l["id"]: l for l in lots}
t_profit = 0.0
for a in res["alloc"]:
lot = by_id[a["lot_id"]]
pnl = (px - float(lot["open_price"] or 0)) * a["qty"]
pms_repo.close_lot_qty(a["lot_id"], qty=a["qty"], close_price=px, realized_pnl=pnl)
if lot["lot_type"] == "T0":
t_profit += pnl
pms_repo.bump_position_qty(code, total_delta=-qty, avail_delta=-qty)
if t_profit:
pos = pms_repo.get_position(code) or {}
pms_repo.update_position(
code, realized_t_profit=float(pos.get("realized_t_profit") or 0) + t_profit)
for al in res["alerts"]:
logger.warning("[卖出核销] %s %s", code, al.get("message"))
if act.get("instruction_id"):
try:
pms_repo.add_instruction_exec(act["instruction_id"], qty)
_settle_instruction(act["instruction_id"])
except Exception as e:
logger.warning("指令进度更新失败 %s: %s", act["instruction_id"], e)
def _settle_instruction(instruction_id: str):
"""成交量达到指令数量 → 置 CONFIRMED (部分成交保持在途, 由窗口/过期规则收口)。"""
ins = pms_repo.get_instruction(instruction_id)
if ins and int(ins.get("exec_qty") or 0) >= int(ins.get("qty") or 0) > 0:
pms_repo.update_instruction(instruction_id, status="CONFIRMED")
def _date_key(d):
try:
return int(str(d).replace("-", "")[:8])
except (TypeError, ValueError):
return 0
def recompute_position(ts_code: str) -> dict:
"""由批次表重算持仓数量/摊薄成本/安全垫/垫子峰值。"""
lots = pms_repo.list_lots(ts_code, status=None, limit=2000)
qty = sum(int(l["qty"] or 0) for l in lots)
cum_buy = sum((int(l["qty"] or 0) + int(l["closed_qty"] or 0)) * float(l["open_price"] or 0)
for l in lots)
cum_sell = sum(int(l["closed_qty"] or 0) * float(l["close_avg_price"] or 0) for l in lots)
by_type = {}
for l in lots:
if int(l["qty"] or 0) > 0:
by_type[l["lot_type"]] = by_type.get(l["lot_type"], 0) + int(l["qty"])
avg_cost = max(0.0, (cum_buy - cum_sell) / qty) if qty > 0 else None
px = market.get_price(ts_code) or avg_cost or 0
cp = (px / avg_cost - 1.0) if (avg_cost and avg_cost > 0 and px) else None
solid = param_store.get_float("PMS_CUSHION_SOLID", 0.03)
pos = pms_repo.get_position(ts_code) or {}
peak = max(float(pos.get("cushion_peak") or 0), cp or 0)
scale = param_store.get_float("PMS_TOTAL_SCALE", 0)
fields = {
"total_qty": qty, "base_qty": by_type.get("BASE", 0) + by_type.get("RECON", 0),
"fill_qty": by_type.get("FILL", 0), "add_qty": by_type.get("ADD", 0),
"dca_qty": by_type.get("DCA", 0), "t0_qty": by_type.get("T0", 0),
"avg_cost": round(avg_cost, 3) if avg_cost else None,
"cushion_pct": round(cp, 4) if cp is not None else None,
"cushion_state": cu.cushion_state(cp, solid), "cushion_peak": round(peak, 4),
"pct_of_scale": round(qty * px / scale, 4) if scale > 0 else None,
"status": "CLOSED" if qty <= 0 else (pos.get("status") or "HOLDING"),
}
if qty > 0 and (pos.get("status") in (None, "PLANNED", "CLOSED")):
fields["status"] = "HOLDING"
if qty > 0 and not pos.get("opened_date"):
fields["opened_date"] = datetime.now().date()
pms_repo.update_position(ts_code, **fields)
return fields
# ================================================================ 对账
def reconcile(*, apply_fix: bool = True) -> dict:
"""账本 vs 下游持仓, 以下游为准修正并留痕。"""
out = {"ok": True, "diffs": [], "fixes": [], "errors": [], "columns": {}, "severity": rc.SEV_OK}
try:
ds = downstream_repo.fetch_positions()
except Exception as e:
out.update({"ok": False, "errors": [f"读 trading_position 失败: {type(e).__name__}: {e}"]})
return out
out["columns"] = ds["columns"]
if ds["rows"] and ds["columns"].get("qty") is None:
out.update({"ok": False, "errors": [
"下游持仓表未识别出数量列 —— 请按 QMT_INTERFACE_REQUIREMENTS A1/D1 取得 DDL 后, "
"把列名补进 downstream_repo.QTY_CANDIDATES"]})
return out
book = [{"ts_code": r["ts_code"], "total_qty": int(r.get("total_qty") or 0)}
for r in pms_repo.list_positions()]
diffs = rc.diff_positions(book, [{"ts_code": r["ts_code"], "qty": r["qty"]}
for r in ds["rows"]])
out["diffs"] = diffs
streak = param_store.get_int(STREAK_KEY, 0)
streak = streak + 1 if diffs else 0
# 必须走 ParamStore 写入: 直接写库不会失效缓存, 会导致连续天数一直读到旧值
param_store.set_param(STREAK_KEY, streak, "system")
out["severity"] = rc.recon_severity(streak if diffs else 0,
param_store.get_int("PMS_RECON_ALARM_DAYS", 3))
if not diffs or not apply_fix:
return out
codes = [d["ts_code"] for d in diffs]
prices = market.get_prices(codes)
lots_map = {c: [{"lot_id": l["id"], "lot_type": l["lot_type"], "qty": int(l["qty"]),
"open_date": _date_key(l["open_date"])}
for l in pms_repo.list_lots(c, status="OPEN")] for c in codes}
fixes = rc.build_recon_fixes(diffs, price_map=prices, lots_map=lots_map)
for f in fixes:
try:
_apply_fix(f)
recompute_position(f["ts_code"])
except Exception as e:
logger.exception("对账修正失败 %s", f)
out["errors"].append(f"{f['ts_code']} 修正失败: {type(e).__name__}: {e}")
out["fixes"] = fixes
out["ok"] = not out["errors"]
if out["severity"] == rc.SEV_ERROR:
logger.error("[对账] 连续 %s 日不一致, 升级 ERROR 待人工: %s 项差异", streak, len(diffs))
return out
def _apply_fix(f: dict):
code = f["ts_code"]
pms_repo.ensure_position(code)
if f["op"] == "ADD_RECON_LOT":
px = float(f.get("price") or 0)
pms_repo.insert_lot(ts_code=code, lot_type="RECON", qty=int(f["qty"]),
open_price=px, open_date=datetime.now().date(),
note=f["note"] + ("" if px > 0 else " [缺现价, 成本待人工核]"))
else:
lots = {l["id"]: l for l in pms_repo.list_lots(code, status="OPEN")}
px = float(f.get("price") or 0)
for a in f.get("alloc") or []:
lot = lots.get(a["lot_id"])
pnl = (px - float(lot["open_price"] or 0)) * a["qty"] if (lot and px) else 0.0
pms_repo.close_lot_qty(a["lot_id"], qty=a["qty"], close_price=px or
float(lot["open_price"] or 0), realized_pnl=pnl)
pms_repo.insert_ledger(ts_code=code, action="RECON", arbiter="rule", verdict="PASS",
price_at=float(f.get("price") or 0),
hard_numbers={"op": f["op"], "qty": f["qty"]},
reason=f["note"])
# ================================================================ 除权
def detect_and_apply_ex_right() -> dict:
"""用昨日结算快照与今日持仓/价格比对, 识别送转股并按比例调整批次。"""
out = {"checked": 0, "ex_rights": [], "mismatches": [], "errors": []}
prev = _prev_snapshot()
if not prev:
out["errors"].append("无昨日结算快照, 本次跳过除权检测 (次日起生效)")
return out
for pos in pms_repo.list_positions(only_open=True):
code = pos["ts_code"]
old = prev.get(code)
if not old:
continue
out["checked"] += 1
px = market.get_price(code)
r = rc.detect_ex_right(int(old.get("qty") or 0), int(pos.get("total_qty") or 0),
float(old.get("price") or 0), float(px or 0))
if not r:
continue
if r["kind"] == "EX_RIGHT":
try:
lots = pms_repo.list_lots(code, status="OPEN")
for l in rc.apply_ex_right(lots, r["ratio"]):
pms_repo.update_lot(l["id"], qty=l["qty"], open_price=l["open_price"],
note=l["note"])
recompute_position(code)
pms_repo.insert_ledger(ts_code=code, action="RECON", arbiter="rule",
verdict="PASS", price_at=px or 0, hard_numbers=r,
reason=f"除权调整 ×{r['ratio']}")
out["ex_rights"].append({"ts_code": code, **r})
except Exception as e:
out["errors"].append(f"{code} 除权调整失败: {e}")
else:
out["mismatches"].append({"ts_code": code, **r})
logger.error("[除权] %s 比例不吻合, 待人工: %s", code, r.get("reason"))
return out
def _prev_snapshot() -> dict:
"""取最近一份日终快照 {code: {qty, price}} (存在 pms_daily_report 里, 不新增表)。"""
r = pms_repo.latest_report()
if not r:
return {}
snap = (r.get("report") or {}).get("snapshot") or {}
return snap if isinstance(snap, dict) else {}
# ================================================================ 盘前 / 日终
def premarket() -> dict:
"""盘前准备 (08:50): T+1 可卖重置、参考位取数、刹车结算。"""
out = {"ok": True, "avail_reset": 0, "refs": 0, "brake": None, "errors": []}
try:
out["avail_reset"] = pms_repo.reset_avail_all()
except Exception as e:
out["errors"].append(f"可卖量重置失败: {e}")
for pos in pms_repo.list_positions(only_open=True):
code = pos["ts_code"]
try:
refs = market.get_refs(code, base_cost=pos.get("avg_cost"))
pms_repo.update_position(code, support_ref=refs.get("support"),
pressure_ref=refs.get("pressure"),
stop_ref=refs.get("stop"),
ref_source=refs.get("source"))
out["refs"] += 1
except Exception as e:
out["errors"].append(f"{code} 参考位取数失败: {e}")
try:
out["brake"] = _settle_brake()
except Exception as e:
out["errors"].append(f"刹车结算失败: {e}")
out["ok"] = not out["errors"]
return out
def _settle_brake() -> dict:
"""组合刹车: 自高水位回撤 ≥ 阈值 → 自主增持停 N 个交易日 (命令类不受限)。"""
v = portfolio.positions_view()
mv = v["totals"]["portfolio_mv"]
hw = param_store.get_float("PMS_HIGH_WATER", 0.0)
dd_limit = param_store.get_float("PMS_BRAKE_DRAWDOWN", 0.05)
days = param_store.get_int("PMS_BRAKE_DAYS", 3)
until = param_store.get_int("PMS_BRAKE_UNTIL", 0)
today = td.ymd()
if mv > hw:
param_store.set_param("PMS_HIGH_WATER", mv, "system")
hw = mv
drawdown = (1 - mv / hw) if hw > 0 else 0.0
if hw > 0 and drawdown >= dd_limit and today >= until:
until = td.ymd(td.next_trade_day(datetime.now().date(), days))
param_store.set_param("PMS_BRAKE_UNTIL", until, "system")
logger.warning("[刹车] 自高水位回撤 %.1f%%%.0f%%, 自主增持暂停至 %s",
drawdown * 100, dd_limit * 100, until)
return {"high_water": hw, "portfolio_mv": mv, "drawdown": round(drawdown, 4),
"brake_until": until, "active": today < until}
def daily_settle() -> dict:
"""日终结算 (15:10): 除权检测 → 全量对账 → 垫子峰值/连负天数 → 命令进度日结 → 快照留存。"""
from app.services import command_service
out = {"ok": True, "steps": {}, "errors": []}
try:
out["steps"]["ex_right"] = detect_and_apply_ex_right()
except Exception as e:
out["errors"].append(f"除权检测失败: {e}")
try:
out["steps"]["recon"] = reconcile()
except Exception as e:
out["errors"].append(f"对账失败: {e}")
try:
out["steps"]["cushion"] = _settle_cushion()
except Exception as e:
out["errors"].append(f"安全垫结算失败: {e}")
try:
out["steps"]["commands"] = command_service.refresh_progress()
except Exception as e:
out["errors"].append(f"命令进度结算失败: {e}")
try:
pms_repo.expire_proposals()
except Exception as e:
out["errors"].append(f"提议过期处理失败: {e}")
out["ok"] = not out["errors"]
return out
def _settle_cushion() -> dict:
"""更新垫子峰值与「安全垫连续为负天数」(清弱票判定所需)。"""
v = portfolio.positions_view()
streak = portfolio.neg_streak_map()
updated = 0
for x in v["held"]:
code, cp = x["ts_code"], x["cushion_pct"]
streak[code] = (int(streak.get(code, 0)) + 1) if (cp is not None and cp < 0) else 0
peak = max(float(x["cushion_peak"] or 0), cp or 0)
pms_repo.update_position(code, cushion_pct=cp, cushion_peak=round(peak, 4),
cushion_state=x["cushion_state"])
updated += 1
held = {x["ts_code"] for x in v["held"]}
portfolio.save_neg_streak({k: v2 for k, v2 in streak.items() if k in held})
return {"updated": updated,
"neg_streak": {k: v2 for k, v2 in streak.items() if v2 > 0 and k in held}}
# ================================================================ 日报
def build_daily_report(ymd: int = None) -> dict:
"""运营日报 (15:30): 关注区 + 全量统计 + 当日快照 (快照供次日除权检测)。"""
from app.services import command_service
ymd = int(ymd or td.ymd())
v = portfolio.positions_view()
t = v["totals"]
try:
recon_state = {"streak": param_store.get_int(STREAK_KEY, 0)}
except Exception:
recon_state = {}
cmds = pms_repo.list_commands(statuses=["EXECUTING", "PARTIAL", "PENDING", "PLANNING"],
limit=100)
proposals = pms_repo.list_proposals(statuses=("WAIT_USER",), limit=100)
live_ins = pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=200)
attention = []
for c in cmds:
prog = c.get("progress") or {}
attention.append({"type": "命令进度", "command_id": c["command_id"],
"cmd_type": c["cmd_type"], "status": c["status"],
"done": prog.get("done_amount"), "target": prog.get("target_amount"),
"deadline": prog.get("deadline")})
if proposals:
attention.append({"type": "待确认提议", "count": len(proposals)})
if recon_state.get("streak"):
attention.append({"type": "对账差异", "streak": recon_state["streak"],
"severity": rc.recon_severity(
recon_state["streak"],
param_store.get_int("PMS_RECON_ALARM_DAYS", 3))})
brake_until = param_store.get_int("PMS_BRAKE_UNTIL", 0)
if brake_until > ymd:
attention.append({"type": "组合刹车", "until": brake_until})
if v["price_missing"]:
attention.append({"type": "行情缺失", "codes": v["price_missing"]})
if not v["sector_ready"]:
attention.append({"type": "行业约束停用", "hint": "PMS_SECTOR_SOURCE 未配置"})
if td.calendar_degraded():
attention.append({"type": "交易日历降级", "hint": "未安装 chinesecalendar, 节假日不可辨"})
report = {
"ymd": ymd, "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"totals": t, "attention": attention,
"commands": [{"command_id": c["command_id"], "cmd_type": c["cmd_type"],
"status": c["status"], "progress": c.get("progress")} for c in cmds],
"proposals": len(proposals), "live_instructions": len(live_ins),
"positions": [{"ts_code": x["ts_code"], "qty": x["total_qty"], "price": x["price"],
"avg_cost": x["avg_cost"], "cushion_pct": x["cushion_pct"],
"cushion_state": x["cushion_state"], "mv": x["market_value"],
"pct_of_scale": x["pct_of_scale"]} for x in v["held"]],
# 次日除权检测用的快照 (数量 + 收盘价)
"snapshot": {x["ts_code"]: {"qty": x["total_qty"], "price": x["price"]}
for x in v["held"]},
"recon": recon_state,
}
try:
pms_repo.upsert_report(ymd, report)
except Exception as e:
logger.error("日报落表失败: %s", e)
report["save_error"] = str(e)
return report
def expire_stale_instructions() -> int:
"""下发后长时间未被接受的指令置过期 (不自动重发 —— 设计 §13)。"""
mins = param_store.get_int("PMS_DISPATCH_EXPIRE_MIN", 30)
cut = datetime.now() - timedelta(minutes=mins)
n = 0
for r in pms_repo.list_instructions(statuses=["DISPATCHED"], limit=500):
try:
if str(r.get("updated_at") or "") and str(r["updated_at"]) < str(cut):
pms_repo.update_instruction(r["instruction_id"], status="EXPIRED")
n += 1
except Exception:
continue
return n

194
app/services/market.py Normal file
View File

@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
"""
行情与参考位取数 (best-effort, 取不到一律返回 None 由上层降级)
==============================================================
现价: Redis db13 (SIGNAL_REDIS_DB_QUOTES), key `tushare:rt_min:1MIN:{600000.SH}`,
值为当日分钟 K 线数组 bionic_trader 既有读法完全一致, 不另立口径
参考位: 主口径取决策系统 strategy_daily_results 的支撑/压力; 日龄超 PMS_REF_STALE_TDAYS
转兜底自算 (设计 §13): 支撑 = max(MA20, 近20日低点×1.01), 压力 = 近60日高点,
止损 = 底仓成本 N×ATR 与支撑取高因子分表 gp_stock_factor_pro_YYYYMM 153 代理
逐表单查 (跨分表 UNION 会被代理拒绝, 沿用 bionic 的踩坑结论)
"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime, timedelta
from config.settings import settings
from app.db.session import fetch_all
from app.repo import downstream_repo
from app.services import param_store
logger = logging.getLogger("pms.market")
_redis = None
_lock = threading.Lock()
QUOTE_KEY = "tushare:rt_min:1MIN:{code}"
def _r():
global _redis
if _redis is not None:
return _redis
with _lock:
if _redis is None:
import redis
_redis = redis.Redis(
host=settings.SIGNAL_REDIS_HOST, port=settings.SIGNAL_REDIS_PORT,
password=settings.SIGNAL_REDIS_PASSWORD or None,
db=settings.SIGNAL_REDIS_DB_QUOTES, decode_responses=True,
socket_timeout=settings.SIGNAL_REDIS_SOCKET_TIMEOUT)
return _redis
def _bars(code: str):
try:
raw = _r().get(QUOTE_KEY.format(code=code))
if not raw:
return None
bars = json.loads(raw)
return bars if isinstance(bars, list) and bars else None
except Exception as e:
logger.warning("实时行情读取失败 [%s]: %s", code, e)
return None
def get_price(ts_code: str):
bars = _bars(ts_code)
if not bars:
return None
try:
px = float(bars[-1].get("close") or 0)
return px if px > 0 else None
except (TypeError, ValueError):
return None
def get_prices(codes) -> dict:
"""批量取现价 (逐 key GET; 数量级为持仓数, 无需 pipeline 复杂化)。"""
out = {}
for c in codes or []:
out[c] = get_price(c)
return out
def day_snapshot(ts_code: str) -> dict:
"""当日分钟线聚合: 现价/开盘/最高/最低/均价(VWAP近似)/涨幅 —— 择时与不追高检查用。"""
bars = _bars(ts_code)
if not bars:
return {}
try:
closes = [float(b.get("close") or 0) for b in bars if float(b.get("close") or 0) > 0]
if not closes:
return {}
highs = [float(b.get("high") or b.get("close") or 0) for b in bars]
lows = [float(b.get("low") or b.get("close") or 0) for b in bars if
float(b.get("low") or b.get("close") or 0) > 0]
opens = float(bars[0].get("open") or closes[0])
vols = [float(b.get("vol") or 0) for b in bars]
amt = sum(c * v for c, v in zip(closes, vols))
vwap = (amt / sum(vols)) if sum(vols) > 0 else sum(closes) / len(closes)
px = closes[-1]
return {"price": px, "open": opens, "high": max(highs) if highs else px,
"low": min(lows) if lows else px, "vwap": round(vwap, 3),
"day_chg_from_open": (px / opens - 1) if opens else None,
"bar_time": bars[-1].get("time"), "bars": len(bars)}
except (TypeError, ValueError) as e:
logger.warning("行情聚合失败 [%s]: %s", ts_code, e)
return {}
# ================================================================ 参考位
def _shard_tables(months: int = 4) -> list:
now = datetime.now()
out, d = [], now
for _ in range(max(1, months)):
out.append(f"gp_stock_factor_pro_{d.strftime('%Y%m')}")
d = (d.replace(day=1) - timedelta(days=1))
return out
def _factor_rows(ts_code: str, days: int = 70) -> list:
"""逐分表单查 (代理要求单表), 合并后取最近 days 条。symbol 先点式后前缀式试。"""
since = (datetime.now() - timedelta(days=int(days * 1.6))).strftime("%Y-%m-%d")
rows = []
for tbl in _shard_tables():
for sym in (ts_code, downstream_repo.to_prefix(ts_code)):
try:
part = fetch_all(
f"SELECT trade_date, close_qfq, high_qfq, low_qfq, atr_qfq FROM {tbl} "
f"WHERE symbol = :sym AND trade_date >= :since", {"sym": sym, "since": since})
except Exception:
part = []
if part:
rows.extend(part)
break
rows = [r for r in rows if r.get("close_qfq")]
rows.sort(key=lambda r: str(r.get("trade_date")))
return rows[-days:]
def self_calc_refs(ts_code: str, base_cost=None) -> dict:
"""兜底自算参考位 (设计 §13)。数据不足返回 {}"""
rows = _factor_rows(ts_code)
if len(rows) < 20:
return {}
close = [float(r["close_qfq"]) for r in rows]
low = [float(r.get("low_qfq") or r["close_qfq"]) for r in rows]
high = [float(r.get("high_qfq") or r["close_qfq"]) for r in rows]
atr = None
for r in reversed(rows):
if r.get("atr_qfq"):
atr = float(r["atr_qfq"])
break
ma20 = sum(close[-20:]) / 20
support = max(ma20, min(low[-20:]) * 1.01)
pressure = max(high[-60:]) if len(high) >= 60 else max(high)
stop = None
if base_cost and atr:
stop = max(float(base_cost) - param_store.get_float("PMS_STOP_ATR_MULT", 2.0) * atr,
support)
elif atr:
stop = max(close[-1] - param_store.get_float("PMS_STOP_ATR_MULT", 2.0) * atr, support)
return {"support": round(support, 3), "pressure": round(pressure, 3),
"stop": round(stop, 3) if stop else None, "atr": atr, "ma20": round(ma20, 3),
"source": "self_calc", "bars": len(rows)}
def get_refs(ts_code: str, *, base_cost=None) -> dict:
"""参考位: 决策系统主口径 → 日龄超期/缺失时兜底自算 → 都拿不到返回 source=none。"""
stale_days = param_store.get_int("PMS_REF_STALE_TDAYS", 3)
try:
r = downstream_repo.fetch_refs(ts_code)
except Exception as e:
logger.warning("读决策系统结论失败 [%s]: %s", ts_code, e)
r = None
if r and (r.get("support") or r.get("pressure")):
age = _age_days(r.get("trade_date"))
if age is not None and age <= stale_days * 2: # 自然日宽松换算交易日
return {"support": r.get("support"), "pressure": r.get("pressure"),
"stop": r.get("support"), "source": "bionic",
"trade_date": str(r.get("trade_date")), "age_days": age}
try:
s = self_calc_refs(ts_code, base_cost=base_cost)
except Exception as e:
logger.warning("自算参考位失败 [%s]: %s", ts_code, e)
s = {}
if s:
s["note"] = "兜底口径 (决策系统结论缺失或停更)"
return s
return {"support": None, "pressure": None, "stop": None, "source": "none",
"note": "参考位不可用 —— 敞口无法计算, 相关动作按保守处理"}
def _age_days(trade_date):
if not trade_date:
return None
try:
s = str(trade_date)[:10].replace("-", "")
d = datetime.strptime(s, "%Y%m%d").date()
return (datetime.now().date() - d).days
except (ValueError, TypeError):
return None

273
app/services/param_store.py Normal file
View File

@ -0,0 +1,273 @@
# -*- coding: utf-8 -*-
"""
运行参数中心 (页面调参即时生效的唯一入口)
==========================================
纪律 (config/settings.py 头部第 3 ): 业务代码**禁止**直接读 settings 取业务参数,
一律走 ParamStore 表值 (pms_runtime_param) 优先于文件初值, 页面改完立刻生效
settings 里声明的业务参数外, 另有一批运行态开关也落同一张表 (DDL 无需新增表):
PMS_GLOBAL_BUY_HALT / PMS_GLOBAL_EXEC_HALT 全局暂停买入 / 暂停执行
PMS_BRAKE_UNTIL 组合刹车解除日 (YYYYMMDD, 0=未刹车)
PMS_HIGH_WATER 组合高水位 (刹车判定基准)
PMS_REPLAY_CURSOR 成交回放游标
PMS_RECON_STREAK 连续对账不一致天数
PMS_SECTOR_CAP_* 单行业上限 (SECTOR_CAP 命令写入)
"""
from __future__ import annotations
import logging
import threading
import time
from config.settings import settings
from app.repo import pms_repo
logger = logging.getLogger("pms.params")
CACHE_TTL = 5.0 # 秒; 页面改参后最迟 5 秒全进程可见 (worker 多进程各持一份)
# 不允许页面修改的基础设施键 (连接串等只在 .env 维护)
INFRA_PREFIX = ("PROXY_DB", "SOURCE_DB", "DB_MYSQL", "SIGNAL_REDIS", "PMS_REDIS", "PMS_WEB")
# 运行态开关: key -> (默认值, 类型, 说明)
RUNTIME_EXTRA = {
"PMS_GLOBAL_BUY_HALT": (False, bool, "全局暂停买入 (HALT_BUY 命令置位)"),
"PMS_GLOBAL_EXEC_HALT": (False, bool, "全局暂停执行 / 休假模式"),
"PMS_BRAKE_UNTIL": (0, int, "组合刹车解除日 YYYYMMDD, 0=未刹车"),
"PMS_HIGH_WATER": (0.0, float, "组合市值高水位 (刹车判定基准)"),
"PMS_REPLAY_CURSOR": ("", str, "成交回放游标 (trading_order.order_id)"),
"PMS_RECON_STREAK": (0, int, "连续对账不一致天数"),
}
# 页面展示用的中文说明 (settings.py 用行尾注释, pydantic 取不到, 故在此集中维护)
DESC = {
"PMS_TOTAL_SCALE": "总操作规模 (元) —— 所有百分比约束的分母",
"PMS_PORTFOLIO_CAP": "总仓上限 (占规模)", "PMS_STOCK_CAP": "单股上限 (占规模)",
"PMS_STOCK_TARGET_DEFAULT": "默认单股目标仓位", "PMS_MAX_NAMES": "最大持仓只数",
"PMS_CASH_RESERVE": "预留现金比例 (永不动用, 与总仓上限双重约束)",
"PMS_AUTONOMY": "自主档位 full / propose_only / off",
"PMS_BATCH_SPLIT": "单股分批比例 (底仓/回踩补足/盈利加仓)",
"PMS_CUSHION_SOLID": "安全垫厚垫线 (解锁盈利加仓)",
"PMS_TRIM_PEAK": "保垫减仓: 垫子峰值门槛", "PMS_TRIM_GIVEBACK": "保垫减仓: 回吐比例门槛",
"PMS_DCA_TRIGGERS": "补仓评估档 (浮亏)", "PMS_DCA_DEEP_CONFIRM": "此档及更深永远需用户确认",
"PMS_DCA_MAX_RATIO": "补仓上限 (占底仓)", "PMS_NO_CHASE_MA5": "距 MA5 超此幅度不追买",
"PMS_BUILD_WINDOW_TDAYS": "建仓期窗口 (交易日)",
"PMS_FILL_MAX_LOSS": "浮亏深于此不走回踩补足",
"PMS_WEAK_NEG_DAYS": "降仓清弱票: 安全垫连续为负天数",
"PMS_PROPOSAL_TTL_HOURS": "自主提议待确认有效期 (小时)",
"PMS_SECTOR_SOURCE": "行业划分数据源: 空=约束停用 / custom_table / gp_stock_category",
"PMS_SECTOR_MAX_NAMES": "同行业最大持仓只数 (硬拦截)",
"PMS_SECTOR_MAX_RATIO": "同行业最大占总仓比例 (硬拦截)",
"PMS_EXEC_WINDOW_TDAYS": "任务命令默认执行窗口 (交易日)",
"PMS_SELL_AVOID_OPEN_MIN": "卖出避开开盘 N 分钟",
"PMS_BUY_HALT_DAYUP": "当日涨幅超此停止买入 (不追高)",
"PMS_EOD_FORCE_TIME": "当日配额兜底时点", "PMS_EOD_FORCE_DISCOUNT": "兜底限价系数 (卖出)",
"PMS_MIN_LOT_MERGE": "一手检查: 批次自动合并",
"PMS_DISPATCH_EXPIRE_MIN": "指令下发后未被接受的过期时间 (分钟)",
"PMS_RISK_WARN_ENTRY": "单笔敞口告警线 (占规模)", "PMS_RISK_WARN_PORTFOLIO": "组合敞口告警线",
"PMS_BRAKE_DRAWDOWN": "组合刹车: 自高水位回撤", "PMS_BRAKE_DAYS": "刹车持续交易日",
"PMS_STOP_ATR_MULT": "自算止损参考: 成本 N×ATR",
"PMS_REF_STALE_TDAYS": "决策系统结论日龄超此转自算兜底",
"PMS_JUDGE_ENABLED": "研判闸开关", "PMS_JUDGE_ACTIONS": "需过研判闸的动作",
"PMS_JUDGE_TIMEOUT": "研判超时 (秒) → 降级 propose_only",
"PMS_T0_RATIO_MAX": "T 仓硬上限 (占持仓)", "PMS_T0_PULLBACK_PCT": "正T: 距当日高点回落触发",
"PMS_T0_RALLY_PCT": "反T: 日内涨幅触发", "PMS_T0_ROUND_TARGET": "单次T目标价差",
"PMS_T0_CLOSE_TIME": "T仓强制平回时点", "PMS_T0_STOCK_DAY_LOSS": "单票当日T亏熔断",
"PMS_T0_GLOBAL_DAY_LOSS": "全局当日T亏熔断",
"PMS_REPLAY_INTERVAL_MIN": "成交回放间隔 (分钟)", "PMS_RECON_ALARM_DAYS": "连续不一致升级天数",
}
_cache = {"at": 0.0, "data": {}, "error": None}
_lock = threading.Lock()
def _editable_keys() -> dict:
"""可调业务参数 = settings 中 PMS_ 开头且非基础设施的字段。"""
out = {}
for name, field in type(settings).model_fields.items():
if not name.startswith("PMS_"):
continue
if any(name.startswith(p) for p in INFRA_PREFIX):
continue
out[name] = field
return out
def _coerce(value, target_type):
if target_type is bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("1", "true", "yes", "on", "y")
if target_type is int:
return int(float(value))
if target_type is float:
return float(value)
return str(value)
def _type_of(key: str):
f = _editable_keys().get(key)
if f is not None:
return f.annotation
if key in RUNTIME_EXTRA:
return RUNTIME_EXTRA[key][1]
return str
def refresh(force: bool = False) -> dict:
"""拉一次表值 (带 TTL 缓存)。连库失败不抛异常 —— 退回文件初值并记 error。"""
now = time.time()
if not force and now - _cache["at"] < CACHE_TTL and _cache["data"]:
return _cache["data"]
with _lock:
try:
rows = pms_repo.all_params()
_cache.update({"data": rows, "at": now, "error": None})
except Exception as e:
_cache.update({"at": now, "error": f"{type(e).__name__}: {e}"})
logger.warning("参数表读取失败, 退回 settings 初值: %s", e)
return _cache["data"]
def get(key: str, default=None):
"""取参数当前值: 表值优先 → settings 初值 → RUNTIME_EXTRA 默认 → default。"""
rows = refresh()
t = _type_of(key)
if key in rows:
try:
return _coerce(rows[key]["param_value"], t)
except (TypeError, ValueError):
logger.warning("参数 %s 表值非法 (%s), 退回初值", key, rows[key]["param_value"])
if hasattr(settings, key):
return getattr(settings, key)
if key in RUNTIME_EXTRA:
return RUNTIME_EXTRA[key][0]
return default
def get_float(key, default=0.0):
try:
return float(get(key, default))
except (TypeError, ValueError):
return default
def get_int(key, default=0):
try:
return int(float(get(key, default)))
except (TypeError, ValueError):
return default
def get_bool(key, default=False):
v = get(key, default)
return v if isinstance(v, bool) else str(v).strip().lower() in ("1", "true", "yes", "on")
def get_list(key, default=None, sep=","):
v = get(key, None)
if v in (None, ""):
return list(default or [])
return [x.strip() for x in str(v).split(sep) if x.strip()]
def get_tuple_floats(key, default=(0.5, 0.25, 0.25)):
try:
vals = tuple(float(x) for x in str(get(key, "")).split(",") if str(x).strip())
return vals or tuple(default)
except (TypeError, ValueError):
return tuple(default)
def set_param(key: str, value, updated_by: str = "user") -> dict:
"""页面改参入口。校验键名与类型后落表, 并立即失效缓存。"""
editable = _editable_keys()
if key not in editable and key not in RUNTIME_EXTRA and not key.startswith("PMS_SECTOR_CAP_"):
return {"ok": False, "error": f"参数 {key} 不可修改 (基础设施参数只在 .env 维护)"}
t = _type_of(key)
try:
v = _coerce(value, t)
except (TypeError, ValueError):
return {"ok": False, "error": f"参数 {key} 类型应为 {getattr(t, '__name__', t)}, "
f"收到 {value!r}"}
bad = _range_check(key, v)
if bad:
return {"ok": False, "error": bad}
try:
pms_repo.set_param(key, v, updated_by)
except Exception as e:
return {"ok": False, "error": f"写入失败: {type(e).__name__}: {e}"}
_cache["at"] = 0.0
refresh(force=True)
return {"ok": True, "key": key, "value": v}
_RANGES = {
"PMS_PORTFOLIO_CAP": (0, 1), "PMS_STOCK_CAP": (0, 1), "PMS_STOCK_TARGET_DEFAULT": (0, 1),
"PMS_CASH_RESERVE": (0, 1), "PMS_CUSHION_SOLID": (0, 1), "PMS_TRIM_GIVEBACK": (0, 1),
"PMS_DCA_MAX_RATIO": (0, 1), "PMS_SECTOR_MAX_RATIO": (0, 1), "PMS_T0_RATIO_MAX": (0, 0.3334),
"PMS_MAX_NAMES": (1, 200), "PMS_TOTAL_SCALE": (0, 10 ** 12),
"PMS_EXEC_WINDOW_TDAYS": (1, 20), "PMS_BRAKE_DAYS": (0, 30),
}
def _range_check(key, v):
if key == "PMS_AUTONOMY" and v not in ("full", "propose_only", "off"):
return "PMS_AUTONOMY 只能是 full / propose_only / off"
if key == "PMS_SECTOR_SOURCE" and v not in ("", "custom_table", "gp_stock_category"):
return "PMS_SECTOR_SOURCE 只能是 空 / custom_table / gp_stock_category"
lo_hi = _RANGES.get(key)
if lo_hi and isinstance(v, (int, float)) and not isinstance(v, bool):
lo, hi = lo_hi
if v < lo or v > hi:
return f"{key} 应在 [{lo}, {hi}] 区间, 收到 {v}"
return None
def snapshot() -> dict:
"""页面「参数设置」用: 每个可调参数的 当前值/来源/初值/说明。"""
rows = refresh()
out = {"params": [], "source_error": _cache.get("error")}
for key, field in sorted(_editable_keys().items()):
cur = get(key)
out["params"].append({
"key": key, "value": cur, "file_default": getattr(settings, key, None),
"source": "table" if key in rows else "file",
"type": getattr(field.annotation, "__name__", str(field.annotation)),
"desc": DESC.get(key, (field.description or "").strip()),
"updated_at": str(rows.get(key, {}).get("updated_at") or ""),
"updated_by": rows.get(key, {}).get("updated_by") or "",
})
for key, (dv, t, desc) in RUNTIME_EXTRA.items():
out["params"].append({
"key": key, "value": get(key), "file_default": dv,
"source": "table" if key in rows else "default",
"type": t.__name__, "desc": desc, "group": "runtime",
"updated_at": str(rows.get(key, {}).get("updated_at") or ""),
"updated_by": rows.get(key, {}).get("updated_by") or "",
})
return out
# ---------------------------------------------------------------- 常用组合读取
def sizing_params() -> dict:
"""方案生成器/规则闸共用的一组参数快照 (一次取齐, 避免逐项穿透缓存)。"""
return {
"scale": get_float("PMS_TOTAL_SCALE", 0),
"portfolio_cap": get_float("PMS_PORTFOLIO_CAP", 0.6),
"stock_cap": get_float("PMS_STOCK_CAP", 0.08),
"stock_target_default": get_float("PMS_STOCK_TARGET_DEFAULT", 0.06),
"max_names": get_int("PMS_MAX_NAMES", 15),
"cash_reserve": get_float("PMS_CASH_RESERVE", 0.0),
"autonomy": get("PMS_AUTONOMY", "propose_only"),
"batch_split": get_tuple_floats("PMS_BATCH_SPLIT"),
"min_lot_merge": get_bool("PMS_MIN_LOT_MERGE", True),
"sector_max_names": get_int("PMS_SECTOR_MAX_NAMES", 4),
"sector_max_ratio": get_float("PMS_SECTOR_MAX_RATIO", 0.40),
"sector_source": get("PMS_SECTOR_SOURCE", ""),
"exec_window_tdays": get_int("PMS_EXEC_WINDOW_TDAYS", 3),
"cushion_solid": get_float("PMS_CUSHION_SOLID", 0.03),
"weak_neg_days": get_int("PMS_WEAK_NEG_DAYS", 5),
"buy_halt": get_bool("PMS_GLOBAL_BUY_HALT", False),
"exec_halt": get_bool("PMS_GLOBAL_EXEC_HALT", False),
}

156
app/services/portfolio.py Normal file
View File

@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""
组合快照 (账本 + 行情 + 行业 方案生成器/规则闸/页面的统一输入)
================================================================
一处组装, 三处复用: 命令方案生成组合约束校验管理页面持仓与账本
安全垫连续为负天数 (清弱票判定所需) 的存放:
DDL 未设该列, 故以单行运行参数 PMS_CUSHION_NEG_STREAK (JSON 映射) 承载,
由日终结算 (ledger_service.daily_settle) 维护 不改表结构, 数据可查可重算
"""
from __future__ import annotations
import json
import logging
from app.core import cushion as cu
from app.repo import pms_repo
from app.services import industry, market, param_store
logger = logging.getLogger("pms.portfolio")
NEG_STREAK_KEY = "PMS_CUSHION_NEG_STREAK"
def neg_streak_map() -> dict:
try:
raw = pms_repo.get_param(NEG_STREAK_KEY)
return json.loads(raw) if raw else {}
except Exception:
return {}
def save_neg_streak(m: dict):
try:
pms_repo.set_param(NEG_STREAK_KEY, json.dumps(m, ensure_ascii=False), "system")
except Exception as e:
logger.warning("安全垫连负天数写入失败: %s", e)
def positions_view(*, with_price: bool = True) -> dict:
"""账本持仓 + 现价/安全垫/行业/占比。取不到现价的票用摊薄成本兜底并列入 price_missing。"""
p = param_store.sizing_params()
rows = pms_repo.list_positions()
codes = [r["ts_code"] for r in rows]
prices = market.get_prices(codes) if (with_price and codes) else {}
sectors = industry.get_many(codes) if codes else {}
streak = neg_streak_map()
scale = float(p["scale"] or 0)
out, missing = [], []
for r in rows:
code = r["ts_code"]
avg_cost = float(r.get("avg_cost") or 0)
px = prices.get(code)
if not px or px <= 0:
px = avg_cost or 0.0
if int(r.get("total_qty") or 0) > 0:
missing.append(code)
qty = int(r.get("total_qty") or 0)
mv = qty * px
cp = (px / avg_cost - 1.0) if avg_cost > 0 else None
out.append({
"ts_code": code, "status": r.get("status"), "frozen_reason": r.get("frozen_reason"),
"price": round(px, 3), "total_qty": qty, "avail_qty": int(r.get("avail_qty") or 0),
"base_qty": int(r.get("base_qty") or 0), "fill_qty": int(r.get("fill_qty") or 0),
"add_qty": int(r.get("add_qty") or 0), "dca_qty": int(r.get("dca_qty") or 0),
"t0_qty": int(r.get("t0_qty") or 0), "avg_cost": round(avg_cost, 3) or None,
"market_value": round(mv, 2),
"cushion_pct": round(cp, 4) if cp is not None else None,
"cushion_state": cu.cushion_state(cp, p["cushion_solid"]),
"cushion_peak": float(r.get("cushion_peak") or 0),
"neg_cushion_days": int(streak.get(code, 0)),
"pct_of_scale": round(mv / scale, 4) if scale > 0 else None,
"target_pct": float(r.get("target_pct") or 0) or None,
"stop_ref": float(r.get("stop_ref") or 0) or None,
"support_ref": float(r.get("support_ref") or 0) or None,
"pressure_ref": float(r.get("pressure_ref") or 0) or None,
"ref_source": r.get("ref_source"),
"t0_enabled": int(r.get("t0_enabled") or 0), "t0_ratio": r.get("t0_ratio"),
"realized_t_profit": float(r.get("realized_t_profit") or 0),
"sector": sectors.get(code),
})
held = [x for x in out if x["total_qty"] > 0]
port_mv = sum(x["market_value"] for x in held)
sector_names, sector_mv = {}, {}
for x in held:
s = x.get("sector")
if not s:
continue
sector_names[s] = sector_names.get(s, 0) + 1
sector_mv[s] = sector_mv.get(s, 0.0) + x["market_value"]
cost_sum = sum((x["avg_cost"] or 0) * x["total_qty"] for x in held)
totals = {
"scale": scale, "portfolio_mv": round(port_mv, 2),
"portfolio_pct": round(port_mv / scale, 4) if scale > 0 else None,
"names_count": len(held), "cash_est": round(scale - port_mv, 2) if scale > 0 else None,
"float_pnl": round(port_mv - cost_sum, 2) if cost_sum else 0.0,
"float_pnl_pct": round(port_mv / cost_sum - 1, 4) if cost_sum > 0 else None,
"sector_names": sector_names, "sector_mv": sector_mv,
"solid_names": len([x for x in held if x["cushion_state"] == "SOLID"]),
"neg_names": len([x for x in held if (x["cushion_pct"] or 0) < 0]),
}
return {"positions": out, "held": held, "totals": totals, "params": p,
"price_missing": missing, "sector_ready": industry.ready()}
def caps_ctx(view: dict, *, ts_code=None, is_new_name=False, sector=None) -> dict:
"""组装 check_all_caps / planner 所需的组合上下文 (加仓前快照)。"""
p, t = view["params"], view["totals"]
stock_mv = 0.0
if ts_code:
for x in view["positions"]:
if x["ts_code"] == ts_code:
stock_mv = x["market_value"]
sector = sector or x.get("sector")
is_new_name = x["total_qty"] <= 0
break
else:
is_new_name = True
ready = view.get("sector_ready", False)
return {
"scale": p["scale"], "portfolio_cap": p["portfolio_cap"], "stock_cap": p["stock_cap"],
"max_names": p["max_names"], "portfolio_mv": t["portfolio_mv"],
"names_count": t["names_count"], "stock_mv": stock_mv, "is_new_name": is_new_name,
"sector": sector if ready else None,
"sector_names": int(t["sector_names"].get(sector, 0)) if ready and sector else 0,
"sector_mv": float(t["sector_mv"].get(sector, 0.0)) if ready and sector else 0.0,
"sector_names_map": t["sector_names"] if ready else {},
"sector_mv_map": t["sector_mv"] if ready else {},
"sector_max_names": p["sector_max_names"], "sector_max_ratio": p["sector_max_ratio"],
"cash_reserve": p["cash_reserve"], "sector_source_ready": ready,
}
def overview() -> dict:
"""页面顶部仪表 (总览)。任何一环取不到都不该让页面开不了 —— 逐项 try。"""
try:
v = positions_view()
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
t, p = v["totals"], v["params"]
brake_until = param_store.get_int("PMS_BRAKE_UNTIL", 0)
return {
"ok": True,
"scale": t["scale"], "portfolio_mv": t["portfolio_mv"], "portfolio_pct": t["portfolio_pct"],
"portfolio_cap": p["portfolio_cap"], "names_count": t["names_count"],
"max_names": p["max_names"], "cash_est": t["cash_est"], "float_pnl": t["float_pnl"],
"float_pnl_pct": t["float_pnl_pct"], "solid_names": t["solid_names"],
"neg_names": t["neg_names"], "autonomy": p["autonomy"],
"buy_halt": p["buy_halt"], "exec_halt": p["exec_halt"],
"brake_until": brake_until, "sector_ready": v["sector_ready"],
"price_missing": v["price_missing"],
"cap_room": round(p["portfolio_cap"] * t["scale"] - t["portfolio_mv"], 2)
if t["scale"] else None,
}

0
app/web/__init__.py Normal file
View File

View File

@ -1,38 +1,325 @@
# -*- coding: utf-8 -*-
"""
PMS 管理页面 · Web 入口 (当前为最小骨架)
==========================================
现阶段职责: pms-web 容器可启动可健康检查, 验证 Docker 构建与配置装载链路
后续在此基础上扩展四块页面 (POSITION_MGMT_DESIGN.md §3.3):
参数设置 / 命令台 / 持仓与账本 / 提议确认
PMS 管理页面 · Web 入口 (FastAPI + 单页)
=========================================
设计 §3.3 四块: 参数设置 / 命令台 / 持仓与账本 / 提议确认
工程原则:
* 任何后端异常都不得让页面开不了 全部 API `ok(...)` 包装, 失败返回
{"ok": false, "error": "..."} HTTP 200, 由前端在顶部横幅提示
* 页面只读参数一律经 ParamStore (表值优先), 改参即持久化到 pms_runtime_param
* 手动运维按钮 (回放/对账/日终/日报) 与调度器调用同一份服务函数, 便于未接调度时先验证
"""
from fastapi import FastAPI
from __future__ import annotations
import logging
import os
from datetime import datetime, timedelta
from fastapi import Body, FastAPI, Query
from fastapi.responses import FileResponse, JSONResponse
from config.settings import settings
from app.core import command_spec as cs
from app.core import tradedays as td
from app.db import session as dbs
from app.repo import downstream_repo, pms_repo
from app.services import command_service, industry, ledger_service, param_store, portfolio
VERSION = "0.1.0-dev"
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("pms.web")
VERSION = "0.2.0-dev"
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
app = FastAPI(title="PMS 持仓管理系统", version=VERSION)
def ok(fn, *args, **kw):
"""统一出参包装: 成功 {"ok":true, ...}; 失败 {"ok":false,"error":...} 且 HTTP 200。"""
try:
data = fn(*args, **kw)
if isinstance(data, dict) and "ok" in data:
return data
return {"ok": True, "data": data}
except Exception as e:
logger.exception("API 失败: %s", getattr(fn, "__name__", fn))
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
# ================================================================ 基础
@app.get("/health")
def health():
"""容器健康检查 + 配置装载自证 (不连库, 只回显关键初值)。"""
"""容器健康检查 + 自证: 配置装载、库连通、参数来源、交易日历。"""
db = dbs.ping("proxy")
return {
"status": "ok",
"version": VERSION,
"service": "pms-web",
"status": "ok" if db["ok"] else "degraded",
"version": VERSION, "service": "pms-web",
"now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"trade_day": td.is_trade_day(), "calendar_degraded": td.calendar_degraded(),
"db_proxy": db,
"config_loaded": {
"total_scale": settings.PMS_TOTAL_SCALE,
"portfolio_cap": settings.PMS_PORTFOLIO_CAP,
"stock_cap": settings.PMS_STOCK_CAP,
"max_names": settings.PMS_MAX_NAMES,
"autonomy": settings.PMS_AUTONOMY,
"total_scale": param_store.get("PMS_TOTAL_SCALE"),
"portfolio_cap": param_store.get("PMS_PORTFOLIO_CAP"),
"stock_cap": param_store.get("PMS_STOCK_CAP"),
"max_names": param_store.get("PMS_MAX_NAMES"),
"autonomy": param_store.get("PMS_AUTONOMY"),
"web_port": settings.PMS_WEB_PORT,
},
"sector": industry.status(),
}
@app.get("/")
def index():
return {"hint": "PMS 管理页面骨架。健康检查: /health ; 页面四块随后续开发接入。"}
path = os.path.join(STATIC_DIR, "index.html")
if os.path.exists(path):
return FileResponse(path, media_type="text/html; charset=utf-8")
return JSONResponse({"hint": "页面文件缺失, 健康检查: /health"})
@app.get("/api/overview")
def api_overview():
return ok(portfolio.overview)
# ================================================================ ① 参数设置
@app.get("/api/params")
def api_params():
return ok(param_store.snapshot)
@app.post("/api/params")
def api_set_params(payload: dict = Body(...)):
"""单个 {key, value} 或批量 {items:[{key,value}...]}。逐项返回结果, 部分失败不整体回滚。"""
items = payload.get("items") or [{"key": payload.get("key"), "value": payload.get("value")}]
results = []
for it in items:
k = it.get("key")
if not k:
results.append({"ok": False, "error": "缺少 key"})
continue
results.append(param_store.set_param(k, it.get("value"),
updated_by=payload.get("by") or "user"))
return {"ok": all(r.get("ok") for r in results), "results": results}
# ================================================================ ② 命令台
@app.get("/api/commands/catalog")
def api_catalog():
return ok(lambda: {"commands": cs.list_commands(sector_source_ready=industry.ready()),
"sector": industry.status()})
@app.get("/api/commands/active")
def api_commands_active():
return ok(command_service.active_commands)
@app.get("/api/commands")
def api_commands(status: str = Query(None), limit: int = Query(100)):
statuses = [s for s in (status or "").split(",") if s] or None
return ok(pms_repo.list_commands, statuses=statuses, limit=limit)
@app.get("/api/commands/{command_id}")
def api_command_detail(command_id: str):
def _detail():
c = pms_repo.get_command(command_id)
if not c:
return {"ok": False, "error": f"命令 {command_id} 不存在"}
return {"ok": True, "command": c,
"plans": pms_repo.list_plans(command_id=command_id, limit=500)}
return ok(_detail)
@app.post("/api/commands")
def api_issue(payload: dict = Body(...)):
return ok(command_service.issue, payload.get("cmd_type"), payload.get("params") or {},
note=payload.get("note"), issued_by=payload.get("by") or "user",
force_conflict=bool(payload.get("force")))
@app.post("/api/commands/{command_id}/cancel")
def api_cancel(command_id: str):
return ok(command_service.cancel, command_id)
@app.post("/api/commands/{command_id}/replan")
def api_replan(command_id: str):
"""重新生成方案 (窗口内行情变化后可重算; 旧方案作废)。"""
def _replan():
c = pms_repo.get_command(command_id)
if not c:
return {"ok": False, "error": "命令不存在"}
if c["status"] not in cs.ACTIVE_TASK_STATES:
return {"ok": False, "error": f"命令处于 {c['status']}, 不可重规划"}
pms_repo.cancel_plans_of_command(command_id)
return command_service.plan_command(c)
return ok(_replan)
@app.get("/api/plans")
def api_plans(command_id: str = Query(None), status: str = Query(None), limit: int = 300):
statuses = [s for s in (status or "").split(",") if s] or None
return ok(pms_repo.list_plans, command_id=command_id, statuses=statuses, limit=limit)
# ================================================================ ③ 持仓与账本
@app.get("/api/positions")
def api_positions():
return ok(lambda: {"ok": True, **portfolio.positions_view(),
"stock_params": command_service.effective_stock_params()})
@app.get("/api/positions/{ts_code}/lots")
def api_lots(ts_code: str, status: str = Query("OPEN")):
return ok(pms_repo.list_lots, ts_code, status=(status or None))
@app.get("/api/instructions")
def api_instructions(status: str = Query(None), limit: int = 200):
statuses = [s for s in (status or "").split(",") if s] or None
return ok(pms_repo.list_instructions, statuses=statuses, limit=limit)
@app.get("/api/ledger")
def api_ledger(ts_code: str = Query(None), limit: int = 100):
return ok(pms_repo.list_ledger, ts_code=ts_code, limit=limit)
@app.get("/api/report")
def api_report(ymd: int = Query(None)):
return ok(lambda: (pms_repo.get_report(ymd) if ymd else pms_repo.latest_report())
or {"ymd": None, "report": {}})
# ================================================================ ④ 提议确认
@app.get("/api/proposals")
def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100):
statuses = tuple(s for s in (status or "").split(",") if s) or ("WAIT_USER",)
return ok(pms_repo.list_proposals, statuses=statuses, limit=limit)
@app.post("/api/proposals/{proposal_id}/decide")
def api_decide(proposal_id: str, payload: dict = Body(default={})):
"""采纳/驳回一条自主提议。采纳 = 先落指令表 (先记账后动作), 下发由择时执行器负责。"""
decision = str(payload.get("decision") or "").upper()
if decision not in ("ACCEPTED", "DECLINED"):
return {"ok": False, "error": "decision 必须是 ACCEPTED 或 DECLINED"}
def _decide():
p = pms_repo.get_proposal(proposal_id)
if not p:
return {"ok": False, "error": "提议不存在"}
if p["status"] != "WAIT_USER":
return {"ok": False, "error": f"提议已处于 {p['status']}"}
if not pms_repo.decide_proposal(proposal_id, decision):
return {"ok": False, "error": "提议状态已变更, 请刷新"}
hn = p.get("hard_numbers") or {}
pms_repo.insert_ledger(ts_code=p["ts_code"], action=p["action"], arbiter="user",
verdict="PASS" if decision == "ACCEPTED" else "REJECT",
price_at=float(hn.get("price") or 0), hard_numbers=hn,
ref_id=proposal_id, reason=payload.get("reason") or "页面人工裁决")
instruction_id = None
if decision == "ACCEPTED":
instruction_id = cs.make_instruction_id(td.ymd(), p["ts_code"], p["action"], 1)
side = "sell" if p["action"] in ("TRIM", "EXIT") else "buy"
pms_repo.insert_instruction(
instruction_id=instruction_id, origin_type="proposal", origin_id=proposal_id,
ts_code=p["ts_code"], action=p["action"], side=side, qty=int(p["qty"] or 0),
limit_price=hn.get("price"),
window_tdays=param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3),
status="PROPOSED", progress={"from_proposal": proposal_id})
return {"ok": True, "decision": decision, "instruction_id": instruction_id}
return ok(_decide)
@app.post("/api/proposals")
def api_create_proposal(payload: dict = Body(...)):
"""人工补录一条待确认提议 (影子运行期造数与联调用)。"""
def _create():
pid = payload.get("proposal_id") or f"PRP_{td.ymd()}_{int(datetime.now().timestamp())}"
ttl = param_store.get_int("PMS_PROPOSAL_TTL_HOURS", 24)
pms_repo.insert_proposal(
proposal_id=pid, ts_code=cs.normalize_code(payload.get("ts_code") or ""),
action=payload.get("action") or "ADD", qty=int(payload.get("qty") or 0),
hard_numbers=payload.get("hard_numbers") or {},
expire_at=datetime.now() + timedelta(hours=ttl))
return {"ok": True, "proposal_id": pid}
return ok(_create)
# ================================================================ 运维操作 (与调度器同一实现)
@app.post("/api/ops/replay")
def api_replay(limit: int = Query(500)):
return ok(ledger_service.replay_fills, limit=limit)
@app.post("/api/ops/reconcile")
def api_reconcile(apply_fix: bool = Query(True)):
return ok(ledger_service.reconcile, apply_fix=apply_fix)
@app.post("/api/ops/premarket")
def api_premarket():
return ok(ledger_service.premarket)
@app.post("/api/ops/daily-settle")
def api_daily_settle():
return ok(ledger_service.daily_settle)
@app.post("/api/ops/report")
def api_build_report():
return ok(ledger_service.build_daily_report)
@app.post("/api/ops/plan-pending")
def api_plan_pending():
return ok(command_service.plan_pending)
@app.get("/api/ops/downstream-schema")
def api_downstream_schema():
"""导出下游三表的实际列定义 —— 用于回填 QMT_INTERFACE_REQUIREMENTS D1。"""
def _schema():
out = {}
for t in ("trading_position", "trading_order", "trading_buy_plan"):
try:
out[t] = downstream_repo.describe(t)
except Exception as e:
out[t] = {"error": f"{type(e).__name__}: {e}"}
try:
out["_position_probe"] = downstream_repo.fetch_positions()["columns"]
except Exception as e:
out["_position_probe"] = {"error": str(e)}
return out
return ok(_schema)
# ================================================================ 行业映射
@app.get("/api/industry")
def api_industry(limit: int = 2000):
return ok(lambda: {"ok": True, "status": industry.status(),
"rows": pms_repo.list_industry(limit=limit)})
@app.post("/api/industry/import")
def api_industry_import(payload: dict = Body(...)):
"""导入行业映射。支持 {rows:[{ts_code,industry}]} 或 {text:"600000.SH,银行\\n..."}。"""
def _imp():
rows = payload.get("rows")
if not rows and payload.get("text"):
rows = []
for line in str(payload["text"]).splitlines():
parts = [x.strip() for x in line.replace("\t", ",").split(",") if x.strip()]
if len(parts) >= 2:
rows.append({"ts_code": cs.normalize_code(parts[0]), "industry": parts[1]})
rows = [r for r in (rows or []) if r.get("ts_code") and r.get("industry")]
n = pms_repo.upsert_industry(rows)
industry.invalidate()
return {"ok": True, "imported": len(rows), "affected": n, "status": industry.status()}
return ok(_imp)

596
app/web/static/index.html Normal file
View File

@ -0,0 +1,596 @@
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PMS 持仓管理系统</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css"/>
<link rel="stylesheet" href="https://unpkg.com/element-plus/theme-chalk/dark/css-vars.css"/>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<style>
body { margin:0; background:#0f0f0f; color:#ccc; font-family:'Inter',system-ui,sans-serif; }
.app { padding:12px 18px 32px; }
.hd { display:flex; align-items:center; gap:14px; flex-wrap:wrap;
padding:10px 14px; background:#1a1a1a; border:1px solid #333; border-radius:8px; }
.hd h1 { font-size:17px; margin:0; color:#eee; font-weight:600; letter-spacing:.5px; }
.hd .ver { color:#666; font-size:12px; }
.spacer { flex:1; }
.metrics { display:flex; gap:10px; flex-wrap:wrap; margin:12px 0; }
.m { background:#1a1a1a; border:1px solid #333; border-radius:8px; padding:10px 14px; min-width:118px; }
.m .k { color:#888; font-size:12px; }
.m .v { color:#eee; font-size:18px; font-weight:600; margin-top:3px; }
.m .v.up { color:#f56c6c; } .m .v.down { color:#67c23a; }
.panel { background:#1a1a1a; border:1px solid #333; border-radius:8px; padding:14px; margin-bottom:14px; }
.panel h3 { margin:0 0 10px; font-size:14px; color:#409EFF; font-weight:600; }
.muted { color:#777; font-size:12px; }
.row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.banner { margin:10px 0; }
.el-table { background:transparent !important; font-size:13px; }
.tier { font-size:12px; color:#909399; }
.mono { font-family:ui-monospace,Menlo,Consolas,monospace; }
.nowrap { white-space:nowrap; }
pre.json { background:#111; border:1px solid #333; border-radius:6px; padding:10px;
max-height:420px; overflow:auto; font-size:12px; color:#bbb; }
</style>
</head>
<body>
<div id="app" class="app" v-cloak>
<div class="hd">
<h1>PMS 持仓管理系统</h1>
<span class="ver">v{{ health.version || '-' }} · {{ health.now || '' }}
<el-tag v-if="health.trade_day" size="small" type="success" effect="dark">交易日</el-tag>
<el-tag v-else size="small" type="info" effect="dark">非交易日</el-tag>
</span>
<div class="spacer"></div>
<el-tag :type="ov.buy_halt ? 'danger':'info'" effect="dark" size="small">
买入 {{ ov.buy_halt ? '已暂停':'正常' }}</el-tag>
<el-tag :type="ov.exec_halt ? 'danger':'info'" effect="dark" size="small">
执行 {{ ov.exec_halt ? '已暂停':'正常' }}</el-tag>
<el-tag type="warning" effect="dark" size="small">自主档位 {{ ov.autonomy || '-' }}</el-tag>
<el-button size="small" @click="openReport">日报</el-button>
<el-button size="small" @click="opsDrawer=true">运维</el-button>
<el-button size="small" type="primary" :loading="loading" @click="loadAll">刷新</el-button>
</div>
<el-alert v-if="err" class="banner" :title="err" type="error" effect="dark" show-icon
@close="err=''"/>
<el-alert v-if="ov.sector_ready===false" class="banner" type="warning" effect="dark" show-icon
:closable="false"
title="行业划分数据源未配置 —— 行业集中度硬拦截已停用, 行业类命令置灰 (设计 §5)"/>
<el-alert v-if="ov.price_missing && ov.price_missing.length" class="banner" type="warning"
effect="dark" show-icon :closable="false"
:title="'以下持仓取不到实时价, 已用摊薄成本兜底: ' + ov.price_missing.join(', ')"/>
<el-alert v-if="health.calendar_degraded" class="banner" type="warning" effect="dark" show-icon
:closable="false" title="交易日历降级: 未安装 chinesecalendar, 节假日不可辨"/>
<div class="metrics">
<div class="m"><div class="k">总规模</div><div class="v">{{ money(ov.scale) }}</div></div>
<div class="m"><div class="k">组合市值</div><div class="v">{{ money(ov.portfolio_mv) }}</div></div>
<div class="m"><div class="k">总仓位</div>
<div class="v">{{ pct(ov.portfolio_pct) }}<span class="muted"> / {{ pct(ov.portfolio_cap) }}</span></div></div>
<div class="m"><div class="k">可用额度</div><div class="v">{{ money(ov.cap_room) }}</div></div>
<div class="m"><div class="k">持仓数</div>
<div class="v">{{ ov.names_count }}<span class="muted"> / {{ ov.max_names }}</span></div></div>
<div class="m"><div class="k">浮动盈亏</div>
<div class="v" :class="(ov.float_pnl||0)>=0?'up':'down'">{{ money(ov.float_pnl) }}</div></div>
<div class="m"><div class="k">厚垫 / 负垫</div>
<div class="v">{{ ov.solid_names }} / {{ ov.neg_names }}</div></div>
</div>
<el-tabs v-model="tab" type="border-card">
<!-- ==================== ① 参数设置 ==================== -->
<el-tab-pane label="参数设置" name="params">
<div class="row" style="margin-bottom:10px">
<span class="muted">页面改动持久化到 pms_runtime_param, 优先于 settings.py 初值; 基础设施连接串只在 .env 维护。</span>
<div class="spacer"></div>
<el-button size="small" type="primary" :disabled="!dirtyCount" @click="saveParams">
保存修改 ({{ dirtyCount }})</el-button>
<el-button size="small" @click="loadParams">放弃修改</el-button>
</div>
<el-table :data="params" size="small" height="560" border>
<el-table-column prop="key" label="参数" width="240" class-name="mono"/>
<el-table-column label="当前值" width="200">
<template #default="s">
<el-select v-if="s.row.key==='PMS_AUTONOMY'" v-model="s.row.value" size="small">
<el-option v-for="o in ['full','propose_only','off']" :key="o" :label="o" :value="o"/>
</el-select>
<el-select v-else-if="s.row.key==='PMS_SECTOR_SOURCE'" v-model="s.row.value" size="small">
<el-option label="(空) 停用行业约束" value=""/>
<el-option label="custom_table" value="custom_table"/>
<el-option label="gp_stock_category" value="gp_stock_category"/>
</el-select>
<el-switch v-else-if="s.row.type==='bool'" v-model="s.row.value"/>
<el-input v-else v-model="s.row.value" size="small"/>
</template>
</el-table-column>
<el-table-column prop="file_default" label="文件初值" width="130"/>
<el-table-column label="来源" width="80">
<template #default="s">
<el-tag size="small" :type="s.row.source==='table'?'success':'info'" effect="dark">
{{ s.row.source==='table' ? '页面' : '文件' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="desc" label="说明" min-width="260"/>
<el-table-column prop="updated_at" label="更新时间" width="160" class-name="muted"/>
</el-table>
</el-tab-pane>
<!-- ==================== ② 命令台 ==================== -->
<el-tab-pane label="命令台" name="cmd">
<div class="panel">
<h3>下达命令</h3>
<div class="row">
<el-select v-model="form.cmd_type" filterable placeholder="选择命令" style="width:300px"
@change="onCmdChange">
<el-option-group v-for="g in ['A','B','C']" :key="g" :label="groupLabel(g)">
<el-option v-for="c in catalog.filter(x=>x.group===g)" :key="c.cmd_type"
:label="c.label + ' · ' + c.cmd_type" :value="c.cmd_type"
:disabled="c.disabled">
<span>{{ c.label }}</span>
<span class="muted" style="float:right">{{ c.cls==='param'?'参数':'任务' }}</span>
</el-option>
</el-option-group>
</el-select>
<el-tag v-if="curSpec" size="small" effect="dark"
:type="curSpec.cls==='param'?'warning':'primary'">
{{ curSpec.cls==='param' ? '参数命令 (立即生效)' : '任务命令 (生成方案)' }}</el-tag>
<el-tag v-if="curSpec && curSpec.danger" size="small" type="danger" effect="dark">高危</el-tag>
</div>
<div class="muted" v-if="curSpec && curSpec.note" style="margin:8px 0">{{ curSpec.note }}</div>
<div class="row" style="margin-top:10px" v-if="curSpec">
<div v-for="(f,name) in curSpec.fields" :key="name" class="row">
<span class="muted">{{ fieldLabel(name) }}</span>
<el-select v-if="f.type==='enum'" v-model="form.params[name]" size="small" style="width:150px">
<el-option v-for="o in f.choices" :key="o" :label="o" :value="o"/>
</el-select>
<el-input v-else v-model="form.params[name]" size="small" style="width:150px"
:placeholder="f.required===false ? '可留空(默认)' : ''">
<template v-if="f.type==='pct'" #append>%</template>
<template v-else-if="f.type==='money'" #append></template>
<template v-else-if="f.type==='price'" #append></template>
</el-input>
</div>
<el-input v-model="form.note" size="small" style="width:200px" placeholder="备注(可选)"/>
<el-button type="primary" size="small" :loading="issuing" @click="issue(false)">下达</el-button>
</div>
<div class="muted" style="margin-top:8px" v-if="curSpec && curSpec.cls==='task'">
任务命令下达后立即生成方案并落 pms_plan; 指令下发由择时执行器负责 (下一批交付)。
</div>
</div>
<div class="panel">
<h3>在途命令与进度</h3>
<el-table :data="commands" size="small" border max-height="420"
:row-class-name="()=>''">
<el-table-column type="expand">
<template #default="s">
<div style="padding:8px 16px">
<div class="muted">参数: <span class="mono">{{ JSON.stringify(s.row.params) }}</span></div>
<div class="muted" v-if="s.row.progress && s.row.progress.notes">
说明: {{ (s.row.progress.notes||[]).join(' | ') }}</div>
<el-button size="small" style="margin:8px 0" @click="loadPlans(s.row.command_id)">
查看方案明细</el-button>
<el-table v-if="plansOf===s.row.command_id" :data="plans" size="small" border>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"/>
<el-table-column prop="action" label="动作" width="80"/>
<el-table-column prop="qty" label="数量" width="90"/>
<el-table-column label="金额" width="110">
<template #default="p">{{ money(p.row.amount) }}</template></el-table-column>
<el-table-column prop="priority" label="优先级" width="80"/>
<el-table-column prop="status" label="状态" width="90"/>
<el-table-column prop="deadline" label="截止" width="110"/>
<el-table-column prop="reason" label="理由" min-width="260"/>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="command_id" label="命令号" width="160" class-name="mono"/>
<el-table-column prop="cmd_type" label="类型" width="160"/>
<el-table-column prop="ts_code" label="标的" width="110" class-name="mono"/>
<el-table-column label="状态" width="100">
<template #default="s">
<el-tag size="small" effect="dark" :type="stTag(s.row.status)">{{ s.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" min-width="220">
<template #default="s">
<div v-if="s.row.progress && s.row.progress.target_amount">
<el-progress :percentage="progPct(s.row.progress)" :stroke-width="10"
:text-inside="true" status="success"/>
<span class="muted">{{ money(s.row.progress.done_amount) }} /
{{ money(s.row.progress.target_amount) }}
<template v-if="s.row.progress.gap"> · 缺口 {{ money(s.row.progress.gap) }}</template>
</span>
</div>
<span v-else class="muted">{{ (s.row.progress||{}).plan_count || 0 }} 条方案</span>
</template>
</el-table-column>
<el-table-column prop="issued_at" label="下达时间" width="160" class-name="muted"/>
<el-table-column label="操作" width="150">
<template #default="s">
<el-button size="small" :disabled="!canCancel(s.row)"
@click="cancelCmd(s.row.command_id)">撤销</el-button>
<el-button size="small" :disabled="!canCancel(s.row) || s.row.cmd_class!=='task'"
@click="replan(s.row.command_id)">重规划</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- ==================== ③ 持仓与账本 ==================== -->
<el-tab-pane label="持仓与账本" name="pos">
<div class="panel">
<h3>持仓总览 ({{ positions.length }} 只)</h3>
<el-table :data="positions" size="small" border max-height="460">
<el-table-column type="expand">
<template #default="s">
<div style="padding:8px 16px">
<el-button size="small" @click="loadLots(s.row.ts_code)">查看批次</el-button>
<el-table v-if="lotsOf===s.row.ts_code" :data="lots" size="small" border
style="margin-top:8px">
<el-table-column prop="lot_type" label="批次" width="90"/>
<el-table-column prop="qty" label="剩余" width="90"/>
<el-table-column prop="closed_qty" label="已核销" width="90"/>
<el-table-column prop="open_price" label="开仓价" width="100"/>
<el-table-column prop="close_avg_price" label="核销均价" width="100"/>
<el-table-column prop="realized_pnl" label="已实现" width="100"/>
<el-table-column prop="open_date" label="开仓日" width="110"/>
<el-table-column prop="instruction_id" label="来源指令" min-width="180"
class-name="mono"/>
<el-table-column prop="note" label="备注" min-width="160"/>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"/>
<el-table-column prop="sector" label="行业" width="100"/>
<el-table-column prop="status" label="状态" width="90"/>
<el-table-column label="冻结" width="110">
<template #default="s">
<el-tag v-if="s.row.frozen_reason && s.row.frozen_reason!=='NONE'" size="small"
type="danger" effect="dark">{{ s.row.frozen_reason }}</el-tag>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column prop="total_qty" label="持仓" width="90"/>
<el-table-column prop="avail_qty" label="可卖" width="90"/>
<el-table-column label="批次(底/补/加/补仓/T)" width="170">
<template #default="s"><span class="mono nowrap">{{ s.row.base_qty }}/{{ s.row.fill_qty }}/{{ s.row.add_qty }}/{{ s.row.dca_qty }}/{{ s.row.t0_qty }}</span></template>
</el-table-column>
<el-table-column prop="avg_cost" label="摊薄成本" width="100"/>
<el-table-column prop="price" label="现价" width="90"/>
<el-table-column label="安全垫" width="130">
<template #default="s">
<el-tag size="small" effect="dark" :type="cuTag(s.row.cushion_state)">
{{ pct(s.row.cushion_pct) }} {{ s.row.cushion_state }}</el-tag>
<span v-if="s.row.neg_cushion_days>0" class="muted"> 连负{{ s.row.neg_cushion_days }}日</span>
</template>
</el-table-column>
<el-table-column label="占规模" width="90">
<template #default="s">{{ pct(s.row.pct_of_scale) }}</template></el-table-column>
<el-table-column label="市值" width="110">
<template #default="s">{{ money(s.row.market_value) }}</template></el-table-column>
<el-table-column label="参考位(支/压/止损)" width="180">
<template #default="s"><span class="mono nowrap">{{ s.row.support_ref||'-' }}/{{ s.row.pressure_ref||'-' }}/{{ s.row.stop_ref||'-' }}</span>
<span class="muted"> {{ s.row.ref_source||'' }}</span></template>
</el-table-column>
<el-table-column label="做T" width="80">
<template #default="s">
<el-tag v-if="s.row.t0_enabled" size="small" type="warning" effect="dark">
{{ pct(s.row.t0_ratio) }}</el-tag><span v-else class="muted"></span>
</template>
</el-table-column>
</el-table>
</div>
<div class="panel">
<h3>在途指令</h3>
<el-table :data="instructions" size="small" border max-height="260">
<el-table-column prop="instruction_id" label="指令号" width="230" class-name="mono"/>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"/>
<el-table-column prop="action" label="动作" width="90"/>
<el-table-column prop="side" label="方向" width="70"/>
<el-table-column prop="qty" label="数量" width="90"/>
<el-table-column prop="exec_qty" label="已成交" width="90"/>
<el-table-column prop="limit_price" label="限价" width="90"/>
<el-table-column prop="status" label="状态" width="120"/>
<el-table-column prop="origin_type" label="来源" width="90"/>
<el-table-column prop="updated_at" label="更新" width="160" class-name="muted"/>
</el-table>
<div class="muted" style="margin-top:6px">
指令下发通道待 QMT 侧协商落地 (B1); 通道未通前减持类只记账不下发 (影子运行)。
</div>
</div>
<div class="panel">
<h3>评审账本 (规则闸 / 研判闸 / 人工裁决全量留痕)</h3>
<el-table :data="ledger" size="small" border max-height="260">
<el-table-column prop="decided_at" label="时间" width="160"/>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"/>
<el-table-column prop="action" label="动作" width="90"/>
<el-table-column prop="arbiter" label="评审方" width="90"/>
<el-table-column label="结论" width="90">
<template #default="s">
<el-tag size="small" effect="dark" :type="s.row.verdict==='PASS'?'success':'danger'">
{{ s.row.verdict }}</el-tag></template>
</el-table-column>
<el-table-column prop="price_at" label="评审价" width="90"/>
<el-table-column label="未通过项" min-width="240">
<template #default="s"><span class="muted">{{ (s.row.failed_checks||[]).join(' | ') }}</span></template>
</el-table-column>
<el-table-column prop="reason" label="理由" min-width="200"/>
</el-table>
</div>
</el-tab-pane>
<!-- ==================== ④ 提议确认 ==================== -->
<el-tab-pane name="prop">
<template #label>
提议确认
<el-badge v-if="proposals.length" :value="proposals.length" class="ml"/>
</template>
<div class="panel">
<h3>待确认队列 (自主档位 {{ ov.autonomy }})</h3>
<div class="muted" style="margin-bottom:8px">
propose_only 档位下, 增持类自主动作全部在此等待裁决; 减持方向不设确认门槛。
采纳后先落指令表 (先记账后动作), 由择时执行器分日出手。
</div>
<el-table :data="proposals" size="small" border max-height="420">
<el-table-column prop="proposal_id" label="提议号" width="230" class-name="mono"/>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"/>
<el-table-column prop="action" label="动作" width="90"/>
<el-table-column prop="qty" label="数量" width="90"/>
<el-table-column label="硬数字" min-width="320">
<template #default="s"><span class="mono muted">{{ JSON.stringify(s.row.hard_numbers) }}</span></template>
</el-table-column>
<el-table-column prop="judge_verdict" label="研判" width="100"/>
<el-table-column prop="expire_at" label="失效时间" width="160"/>
<el-table-column label="裁决" width="170">
<template #default="s">
<el-button size="small" type="success" @click="decide(s.row,'ACCEPTED')">采纳</el-button>
<el-button size="small" type="danger" @click="decide(s.row,'DECLINED')">驳回</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<!-- 运维抽屉 -->
<el-drawer v-model="opsDrawer" title="运维操作 (与调度器同一份实现)" size="46%">
<div class="muted" style="margin-bottom:10px">
调度器 (--profile sched) 会自动按调度总表执行; 此处按钮用于未接调度或临时验证时手动触发。
</div>
<div class="row">
<el-button @click="ops('replay')" :loading="opsLoading">成交回放</el-button>
<el-button @click="ops('reconcile')" :loading="opsLoading">账本对账</el-button>
<el-button @click="ops('premarket')" :loading="opsLoading">盘前准备</el-button>
<el-button @click="ops('daily-settle')" :loading="opsLoading">日终结算</el-button>
<el-button @click="ops('report')" :loading="opsLoading">生成日报</el-button>
<el-button @click="ops('plan-pending')" :loading="opsLoading">推进待规划命令</el-button>
<el-button @click="loadSchema" :loading="opsLoading">导出下游表结构</el-button>
</div>
<pre class="json" v-if="opsResult">{{ opsResult }}</pre>
</el-drawer>
<!-- 日报抽屉 -->
<el-drawer v-model="reportDrawer" :title="'运营日报 ' + (report.ymd||'')" size="52%">
<div v-if="(report.report||{}).attention">
<h3 style="color:#409EFF;font-size:14px">关注区</h3>
<el-table :data="report.report.attention" size="small" border>
<el-table-column prop="type" label="事项" width="130"/>
<el-table-column label="内容">
<template #default="s"><span class="mono muted">{{ JSON.stringify(s.row) }}</span></template>
</el-table-column>
</el-table>
</div>
<pre class="json">{{ JSON.stringify(report.report||{}, null, 2) }}</pre>
</el-drawer>
</div>
<script>
const { createApp, ref, reactive, computed, onMounted } = Vue;
const api = axios.create({ timeout: 60000 });
createApp({
setup() {
const tab = ref('cmd'), loading = ref(false), err = ref('');
const health = ref({}), ov = ref({}), params = ref([]), paramsRaw = ref({});
const catalog = ref([]), commands = ref([]), plans = ref([]), plansOf = ref('');
const positions = ref([]), lots = ref([]), lotsOf = ref('');
const instructions = ref([]), ledger = ref([]), proposals = ref([]);
const report = ref({}), reportDrawer = ref(false);
const opsDrawer = ref(false), opsResult = ref(''), opsLoading = ref(false);
const issuing = ref(false);
const form = reactive({ cmd_type: '', params: {}, note: '' });
const curSpec = computed(() => catalog.value.find(c => c.cmd_type === form.cmd_type));
const dirtyCount = computed(() =>
params.value.filter(p => String(p.value) !== String(paramsRaw.value[p.key])).length);
const money = v => (v === null || v === undefined || v === '') ? '—'
: Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
const pct = v => (v === null || v === undefined || v === '') ? '—'
: (Number(v) * 100).toFixed(2) + '%';
const groupLabel = g => ({ A: 'A 资金与总体参数', B: 'B 组合级动作', C: 'C 个股级动作' }[g]);
const fieldLabel = n => ({
pct: '比例', scale: '总规模', cap: '上限', target: '目标', n: '数量', mode: '档位',
ratio: '比例', window_tdays: '窗口(交易日)', ts_code: '股票代码', target_pct: '目标仓位',
sector: '行业', t_ratio: 'T仓比例', price: '价格', confirm: '二次确认',
target_command_id: '目标命令号'
}[n] || n);
const stTag = s => ({ EXECUTING: 'primary', DONE: 'success', PARTIAL: 'warning',
CANCELLED: 'info', EFFECTIVE: 'success', SUPERSEDED: 'info' }[s] || 'warning');
const cuTag = s => ({ SOLID: 'success', THIN: 'warning', NONE: 'danger' }[s] || 'info');
const canCancel = r => r.cmd_class === 'task' &&
['PENDING', 'PLANNING', 'EXECUTING', 'PARTIAL'].includes(r.status);
const progPct = p => {
const t = Number(p.target_amount || 0), d = Number(p.done_amount || 0);
return t > 0 ? Math.min(100, Math.round(d / t * 100)) : 0;
};
async function call(method, url, body) {
try {
const r = await api[method](url, body);
if (r.data && r.data.ok === false) { err.value = r.data.error || '请求失败'; }
return r.data;
} catch (e) { err.value = url + ' → ' + e.message; return { ok: false, error: e.message }; }
}
async function loadParams() {
const d = await call('get', '/api/params');
const list = ((d.data || d).params) || [];
params.value = list;
paramsRaw.value = Object.fromEntries(list.map(p => [p.key, p.value]));
}
async function saveParams() {
const items = params.value
.filter(p => String(p.value) !== String(paramsRaw.value[p.key]))
.map(p => ({ key: p.key, value: p.value }));
const d = await call('post', '/api/params', { items });
const bad = (d.results || []).filter(r => !r.ok);
if (bad.length) ElementPlus.ElMessage.error(bad.map(b => b.error).join('; '));
else ElementPlus.ElMessage.success('已保存 ' + items.length + ' 项');
await loadParams(); await loadOverview();
}
async function loadOverview() {
const d = await call('get', '/api/overview'); ov.value = d.data || d || {};
health.value = await call('get', '/health');
}
async function loadCatalog() {
const d = await call('get', '/api/commands/catalog');
catalog.value = ((d.data || d).commands) || [];
}
async function loadCommands() {
const d = await call('get', '/api/commands?limit=60'); commands.value = d.data || [];
}
async function loadPositions() {
const d = await call('get', '/api/positions'); positions.value = d.positions || [];
}
async function loadInstructions() {
const d = await call('get',
'/api/instructions?status=PROPOSED,RULE_PASSED,JUDGE_PASSED,DISPATCHED&limit=100');
instructions.value = d.data || [];
}
async function loadLedger() {
const d = await call('get', '/api/ledger?limit=60'); ledger.value = d.data || [];
}
async function loadProposals() {
const d = await call('get', '/api/proposals'); proposals.value = d.data || [];
}
async function loadPlans(cid) {
plansOf.value = cid;
const d = await call('get', '/api/plans?command_id=' + cid); plans.value = d.data || [];
}
async function loadLots(code) {
lotsOf.value = code;
const d = await call('get', '/api/positions/' + code + '/lots?status='); lots.value = d.data || [];
}
async function loadAll() {
loading.value = true; err.value = '';
await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(),
loadPositions(), loadInstructions(), loadLedger(), loadProposals()]);
loading.value = false;
}
function onCmdChange() {
form.params = {};
const s = curSpec.value; if (!s) return;
Object.entries(s.fields).forEach(([k, f]) => {
if (f.default !== undefined) form.params[k] = f.default;
if (f.type === 'enum' && f.choices && f.choices.length === 1) form.params[k] = f.choices[0];
});
}
function buildParams() {
const s = curSpec.value, out = {};
Object.entries(s.fields).forEach(([k, f]) => {
let v = form.params[k];
if (v === undefined || v === null || v === '') return;
out[k] = (f.type === 'pct') ? (String(v).trim() + '%') : v;
});
return out;
}
async function issue(force) {
const s = curSpec.value;
if (!s) { ElementPlus.ElMessage.warning('请先选择命令'); return; }
if (s.danger && !force) {
try {
await ElementPlus.ElMessageBox.confirm(
'【' + s.label + '】是高危命令, 将对全部持仓生效。确认执行?', '二次确认',
{ type: 'warning', confirmButtonText: '确认执行', cancelButtonText: '取消' });
} catch (e) { return; }
}
issuing.value = true;
const body = { cmd_type: s.cmd_type, params: buildParams(), note: form.note, force: !!force };
const d = await call('post', '/api/commands', body);
issuing.value = false;
if (d.ok) {
ElementPlus.ElMessage.success('命令 ' + d.command_id + ' 已下达 · ' + (d.status || ''));
const notes = ((d.plan || {}).notes || []); if (notes.length)
ElementPlus.ElMessage.info(notes.join(' | '));
err.value = '';
await Promise.all([loadCommands(), loadOverview(), loadPositions(), loadParams()]);
} else if ((d.conflicts || []).length) {
const txt = d.conflicts.map(c => c.with_cmd_type + '(' + c.with_command_id + '): ' + c.reason)
.join('\n');
try {
await ElementPlus.ElMessageBox.confirm(
'与在途命令冲突:\n' + txt + '\n\n命令冲突由用户裁决 —— 仍要并行下达?', '命令冲突',
{ type: 'warning', confirmButtonText: '仍然下达', cancelButtonText: '放弃' });
await issue(true);
} catch (e) { /* 用户放弃 */ }
} else {
ElementPlus.ElMessage.error((d.errors || [d.error]).join('; '));
}
}
async function cancelCmd(cid) {
const d = await call('post', '/api/commands/' + cid + '/cancel');
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.message || d.error);
await loadCommands();
}
async function replan(cid) {
const d = await call('post', '/api/commands/' + cid + '/replan');
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.ok ? '已重新生成方案' :
(d.errors || [d.error]).join('; '));
await loadCommands(); if (plansOf.value === cid) await loadPlans(cid);
}
async function decide(row, decision) {
const d = await call('post', '/api/proposals/' + row.proposal_id + '/decide', { decision });
if (d.ok) ElementPlus.ElMessage.success(decision === 'ACCEPTED'
? ('已采纳, 指令 ' + d.instruction_id) : '已驳回');
else ElementPlus.ElMessage.error(d.error);
await Promise.all([loadProposals(), loadInstructions(), loadLedger()]);
}
async function ops(name) {
opsLoading.value = true;
const d = await call('post', '/api/ops/' + name);
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
await loadAll();
}
async function loadSchema() {
opsLoading.value = true;
const d = await call('get', '/api/ops/downstream-schema');
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
}
async function openReport() {
const d = await call('get', '/api/report'); report.value = d.data || d || {};
reportDrawer.value = true;
}
onMounted(loadAll);
return { tab, loading, err, health, ov, params, catalog, commands, plans, plansOf,
positions, lots, lotsOf, instructions, ledger, proposals, report, reportDrawer,
opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount,
money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct,
loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue,
cancelCmd, replan, decide, ops, loadSchema, openReport };
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>

View File

@ -65,6 +65,8 @@ class Settings(BaseSettings):
PMS_NO_CHASE_MA5: float = 0.06 # 距 MA5 超此幅度不追买
PMS_BUILD_WINDOW_TDAYS: int = 10 # 建仓期窗口 (交易日)
PMS_FILL_MAX_LOSS: float = -0.03 # 浮亏深于此不走回踩补足 (转补仓域)
PMS_WEAK_NEG_DAYS: int = 5 # 降仓「清弱票」判定: 安全垫连续为负 N 日 (设计 §3.2)
PMS_PROPOSAL_TTL_HOURS: int = 24 # 自主提议待确认有效期 (超时置 EXPIRED)
# --- 行业约束 (硬拦截; 数据源接口化) ---
PMS_SECTOR_SOURCE: str = "" # "" = 停用并页面提示 / custom_table / gp_stock_category

125
scripts/check_db.py Normal file
View File

@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
"""
连通性与表结构自检 (实机, .env 真实连接串)
==============================================
运行: docker compose run --rm pms-web python scripts/check_db.py
检查项:
1. 三个库连通性 (153 代理 / 因子库 / 指数库)
2. pms_* 十张表是否存在与当前行数
3. 下游三表可读性 + 持仓数量列探测结果 (回填 QMT_INTERFACE_REQUIREMENTS A1/D1 )
4. 行情 Redis (db13) 连通性与样本键
5. 运行参数表当前生效值
全部通过退出码 0; 任一 FAIL 退出码 1 (WARN 不影响退出码)
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PMS_TABLES = ["pms_command", "pms_plan", "pms_position", "pms_lot", "pms_instruction",
"pms_proposal", "pms_action_ledger", "pms_daily_report", "pms_industry_map",
"pms_runtime_param"]
DOWNSTREAM = ["trading_position", "trading_order", "trading_buy_plan"]
FAILED, WARNED = [], []
def line(tag, msg):
print(f" {tag:<6}{msg}")
def fail(msg):
FAILED.append(msg)
line("FAIL", msg)
def warn(msg):
WARNED.append(msg)
line("WARN", msg)
def main():
from app.db import session as dbs
print("\n[1] 数据库连通性")
for name, label in (("proxy", "153 代理 (pms_* / trading_*)"),
("factor", "因子分表库"), ("index", "大盘指数库")):
r = dbs.ping(name)
if r["ok"]:
line("OK", f"{label}")
elif name == "proxy":
fail(f"{label} 连接失败: {r['error']}")
else:
warn(f"{label} 连接失败 (非致命): {r['error']}")
print("\n[2] PMS 自有表")
for t in PMS_TABLES:
try:
r = dbs.fetch_one(f"SELECT COUNT(*) AS n FROM {t}")
line("OK", f"{t:<20} {r['n']}")
except Exception as e:
fail(f"{t} 不可用: {type(e).__name__}: {e}")
print("\n[3] 下游只读表")
from app.repo import downstream_repo
for t in DOWNSTREAM:
try:
cols = downstream_repo.describe(t)
line("OK", f"{t:<20} {len(cols)} 列: "
f"{', '.join(str(c.get('Field')) for c in cols[:10])}"
f"{' ...' if len(cols) > 10 else ''}")
except Exception as e:
warn(f"{t} 读取失败: {type(e).__name__}: {e}")
try:
ds = downstream_repo.fetch_positions()
line("OK" if ds["columns"].get("qty") else "WARN",
f"持仓列探测: {ds['columns']} (共 {ds['raw_count']} 行)")
if ds["raw_count"] and not ds["columns"].get("qty"):
warn("未识别出持仓数量列 —— 请按 QMT 需求清单 A1/D1 取得 DDL 后补 QTY_CANDIDATES")
except Exception as e:
warn(f"持仓快照读取失败: {e}")
print("\n[4] 行情 Redis (db13)")
try:
from app.services import market
pos = []
try:
from app.repo import pms_repo
pos = [p["ts_code"] for p in pms_repo.list_positions(only_open=True)][:3]
except Exception:
pass
probe = pos or ["600000.SH"]
got = {c: market.get_price(c) for c in probe}
if any(v for v in got.values()):
line("OK", f"实时价样本: {got}")
else:
warn(f"取不到实时价样本 {got} (非交易时段属正常)")
except Exception as e:
warn(f"Redis 行情不可用: {type(e).__name__}: {e}")
print("\n[5] 运行参数 (表值优先于 settings 初值)")
try:
from app.services import param_store
snap = param_store.snapshot()
from_table = [p for p in snap["params"] if p["source"] == "table"]
line("OK", f"可调参数 {len(snap['params'])} 项, 其中页面已改写 {len(from_table)}")
for k in ("PMS_TOTAL_SCALE", "PMS_PORTFOLIO_CAP", "PMS_STOCK_CAP", "PMS_AUTONOMY",
"PMS_SECTOR_SOURCE"):
line("", f"{k:<26} = {param_store.get(k)}")
if snap.get("source_error"):
warn(f"参数表读取异常: {snap['source_error']}")
except Exception as e:
fail(f"参数中心不可用: {type(e).__name__}: {e}")
print("\n" + "-" * 62)
if FAILED:
print(f"FAILED: {len(FAILED)} 项致命问题, {len(WARNED)} 项告警")
for m in FAILED:
print(" - " + m)
sys.exit(1)
print(f"ALL OK ({len(WARNED)} 项告警)" if WARNED else "ALL OK")
if __name__ == "__main__":
main()

41
scripts/run_tests.py Normal file
View File

@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
"""
全部单测一次跑完 (零外部依赖, 不连库)
======================================
运行: docker compose run --rm pms-web python scripts/run_tests.py
包含:
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 )
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (18 )
任一子集失败即整体失败 (退出码 1)
"""
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SUITES = ["test_core_units.py", "test_batch2_units.py", "test_wiring.py"]
def main():
failed = []
for s in SUITES:
path = os.path.join(HERE, s)
if not os.path.exists(path):
print(f"== 跳过 {s} (文件不存在)")
continue
print(f"\n=== {s} " + "=" * (52 - len(s)))
r = subprocess.run([sys.executable, path], cwd=ROOT)
if r.returncode != 0:
failed.append(s)
print("\n" + "=" * 62)
if failed:
print(f"SUITE FAILED: {', '.join(failed)}")
sys.exit(1)
print("ALL SUITES PASS")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,551 @@
# -*- coding: utf-8 -*-
"""
第二批模块单测 (实机运行, 零外部依赖)
======================================
运行: tradingSystem 仓库根目录执行 python scripts/test_batch2_units.py
覆盖: command_spec 命令目录/校验/状态机/冲突; planner 降仓凑额四档+升仓+建仓+个股方案;
recon 回放认领/外部成交/对账差异/除权/T+1可用量
约定同 test_core_units.py: 全过输出 "ALL PASS (n cases)" 退出码 0, 任一失败退出码 1
"""
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import command_spec as cs # noqa: E402
from app.core import planner as pl # noqa: E402
from app.core import recon as rc # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
# ================================================================ command_spec
@case("命令校验·降仓参数归一 (10% 字符串 → 0.1, 窗口取默认值)")
def _():
p, e = cs.validate("REDUCE_EXPOSURE", {"pct": "10%"})
assert e == [], e
assert abs(p["pct"] - 0.10) < 1e-9 and p["window_tdays"] == 3, p
p2, e2 = cs.validate("REDUCE_EXPOSURE", {"pct": 0.1, "window_tdays": 5})
assert e2 == [] and p2["window_tdays"] == 5, (p2, e2)
@case("命令校验·缺参/越界/未知字段/未知命令全部拒绝")
def _():
_, e1 = cs.validate("REDUCE_EXPOSURE", {})
assert any(x.startswith("MISSING") for x in e1), e1
_, e2 = cs.validate("SET_PORTFOLIO_CAP", {"cap": 1.5})
assert any(x.startswith("OUT_OF_RANGE") for x in e2), e2
_, e3 = cs.validate("SET_MAX_NAMES", {"n": 15, "foo": 1})
assert any(x.startswith("UNKNOWN_FIELD") for x in e3), e3
_, e4 = cs.validate("NOT_A_CMD", {})
assert any(x.startswith("UNKNOWN_CMD") for x in e4), e4
_, e5 = cs.validate("SET_AUTONOMY", {"mode": "auto"})
assert any(x.startswith("BAD_ENUM") for x in e5), e5
_, e6 = cs.validate("LIQUIDATE_ALL", {}) # 紧急清仓必须二次确认
assert any(x.startswith("MISSING") for x in e6), e6
@case("命令校验·股票代码归一 (点式/前缀式/裸码)")
def _():
n = cs.normalize_code
assert n("600000.SH") == "600000.SH"
assert n("sh600000") == "600000.SH"
assert n("600000") == "600000.SH"
assert n("000001") == "000001.SZ"
assert n("300750") == "300750.SZ"
assert n("430047") == "430047.BJ"
p, e = cs.validate("EXIT_STOCK", {"ts_code": "sz000001"})
assert e == [] and p["ts_code"] == "000001.SZ", (p, e)
@case("状态机·任务命令合法链路与非法迁移")
def _():
st = cs.ST_PENDING
for nxt in (cs.ST_PLANNING, cs.ST_EXECUTING, cs.ST_DONE):
st = cs.transition(cs.CLS_TASK, st, nxt)
assert st == cs.ST_DONE
assert not cs.can_transition(cs.CLS_TASK, cs.ST_PENDING, cs.ST_DONE)
assert not cs.can_transition(cs.CLS_TASK, cs.ST_DONE, cs.ST_EXECUTING)
try:
cs.transition(cs.CLS_TASK, cs.ST_DONE, cs.ST_EXECUTING)
assert False, "非法迁移未拦截"
except ValueError:
pass
# 部分完成可顺延回执行中
assert cs.can_transition(cs.CLS_TASK, cs.ST_PARTIAL, cs.ST_EXECUTING)
@case("状态机·参数命令生效与被覆盖")
def _():
assert cs.can_transition(cs.CLS_PARAM, cs.ST_PENDING, cs.ST_EFFECTIVE)
assert cs.can_transition(cs.CLS_PARAM, cs.ST_EFFECTIVE, cs.ST_SUPERSEDED)
assert not cs.can_transition(cs.CLS_PARAM, cs.ST_SUPERSEDED, cs.ST_EFFECTIVE)
@case("状态机·任务进度结算 (达标DONE / 窗口末未达标PARTIAL / 途中EXECUTING)")
def _():
assert cs.settle_task_status(200_000, 200_000, False) == cs.ST_DONE
assert cs.settle_task_status(200_000, 120_000, True) == cs.ST_PARTIAL
assert cs.settle_task_status(200_000, 120_000, False) == cs.ST_EXECUTING
assert cs.settle_task_status(0, 0, False) == cs.ST_DONE
@case("命令冲突·组合反向/同股反向/异股不冲突/重复下达")
def _():
active = [{"command_id": "CMD_1", "cmd_type": "INCREASE_EXPOSURE"},
{"command_id": "CMD_2", "cmd_type": "OPEN_TARGET", "ts_code": "600000.SH"}]
c1 = cs.detect_conflicts({"cmd_type": "REDUCE_EXPOSURE"}, active)
assert len(c1) == 1 and c1[0]["with_command_id"] == "CMD_1", c1
c2 = cs.detect_conflicts({"cmd_type": "EXIT_STOCK", "ts_code": "600000.SH"}, active)
assert len(c2) == 1 and c2[0]["with_cmd_type"] == "OPEN_TARGET", c2
c3 = cs.detect_conflicts({"cmd_type": "EXIT_STOCK", "ts_code": "000001.SZ"}, active)
assert c3 == [], c3
c4 = cs.detect_conflicts({"cmd_type": "OPEN_TARGET", "ts_code": "600000.SH"}, active)
assert any("重复下达" in x["reason"] for x in c4), c4
# 冻结中的票不得建仓
c5 = cs.detect_conflicts({"cmd_type": "OPEN_TARGET", "ts_code": "600519.SH"},
[{"command_id": "CMD_9", "cmd_type": "FREEZE_STOCK",
"ts_code": "600519.SH"}])
assert len(c5) == 1 and "冻结" in c5[0]["reason"], c5
@case("命令目录·完整性与行业数据源未配置时置灰")
def _():
all_cmds = cs.list_commands()
assert len(all_cmds) >= 25, len(all_cmds)
for c in all_cmds:
assert c["label"] and c["cls"] in (cs.CLS_PARAM, cs.CLS_TASK), c
spec = cs.SPECS[c["cmd_type"]]
if spec["cls"] == cs.CLS_TASK:
assert "planner" in spec, c["cmd_type"]
if spec["cls"] == cs.CLS_PARAM and spec["scope"] == "global":
assert "param_key" in spec and "value_field" in spec, c["cmd_type"]
off = {c["cmd_type"]: c for c in cs.list_commands(sector_source_ready=False)}
assert off["SECTOR_EXIT"]["disabled"] and off["SECTOR_CAP"]["disabled"]
assert not off["REDUCE_EXPOSURE"]["disabled"]
on = {c["cmd_type"]: c for c in cs.list_commands(sector_source_ready=True)}
assert not on["SECTOR_EXIT"]["disabled"]
assert {c["group"] for c in all_cmds} == {"A", "B", "C"}
# ================================================================ planner · 降仓
def _positions_demo():
"""设计 §3.2 场景: 规模 200 万, 组合市值 42 万。"""
return [
# A: 浮盈 12%, 三批齐全 (底仓 7000, 补足 3500, 加仓 3500)
dict(ts_code="600000.SH", price=10.0, total_qty=14000, base_qty=7000,
fill_qty=3500, add_qty=3500, dca_qty=0, t0_qty=0,
cushion_pct=0.12, cushion_state="SOLID", neg_cushion_days=0,
status="HOLDING", frozen_reason="NONE"),
# B: 弱票 —— 安全垫 -9% 且连续 6 日为负
dict(ts_code="000001.SZ", price=8.0, total_qty=10000, base_qty=10000,
fill_qty=0, add_qty=0, dca_qty=0, t0_qty=0,
cushion_pct=-0.09, cushion_state="NONE", neg_cushion_days=6,
status="HOLDING", frozen_reason="NONE"),
# C: 微盈 2%, 只有底仓 (收利润档无可卖)
dict(ts_code="600519.SH", price=20.0, total_qty=5000, base_qty=5000,
fill_qty=0, add_qty=0, dca_qty=0, t0_qty=0,
cushion_pct=0.02, cushion_state="THIN", neg_cushion_days=0,
status="HOLDING", frozen_reason="NONE"),
# D: 浮盈 5%, 可卖 5000 股 (被命令冻结, 但冻结只禁增持不禁减持)
dict(ts_code="300750.SZ", price=5.0, total_qty=20000, base_qty=15000,
fill_qty=2500, add_qty=2500, dca_qty=0, t0_qty=0,
cushion_pct=0.05, cushion_state="SOLID", neg_cushion_days=0,
status="HOLDING", frozen_reason="COMMAND_HALT"),
]
@case("降仓凑额·四档全走通 (释放20万: 撤单→清弱票8万→收利润9.5万→等比微减凑齐)")
def _():
r = pl.plan_reduce_exposure(
release_amount=200_000, positions=_positions_demo(),
pending_buys=[{"instruction_id": "INS_X", "ts_code": "600036.SH",
"qty": 1000, "amount": 10_000}])
tiers = [i["tier"] for i in r["items"]]
assert tiers[0] == "1_停新买", tiers
assert r["items"][0]["action"] == pl.A_HALT
assert r["items"][0]["cancel_instruction_id"] == "INS_X"
weak = [i for i in r["items"] if i["tier"] == "2_清弱票"]
assert len(weak) == 1 and weak[0]["ts_code"] == "000001.SZ", weak
assert weak[0]["action"] == pl.A_EXIT and weak[0]["qty"] == 10000
assert abs(weak[0]["amount"] - 80_000) < 1e-6
harvest = [i for i in r["items"] if i["tier"] == "3_收利润"]
# 垫子厚的先收: A(12%) 卖 7000 股 = 7万, 再 D(5%) 卖 5000 股 = 2.5万
assert [i["ts_code"] for i in harvest] == ["600000.SH", "300750.SZ"], harvest
assert [i["qty"] for i in harvest] == [7000, 5000], harvest
assert all(i["action"] == pl.A_TRIM for i in harvest)
prorata = [i for i in r["items"] if i["tier"] == "4_等比微减"]
# 缺口 2.5 万, 按剩余市值 C(10万)/D(7.5万)/A(7万) 权重摊派 + 取整补齐
assert {i["ts_code"] for i in prorata} == {"600519.SH", "300750.SZ", "600000.SH"}, prorata
assert all(i["qty"] % 100 == 0 for i in prorata), prorata
assert r["ok"] and r["gap"] == 0.0, r
assert r["planned_amount"] >= 200_000, r
assert r["planned_amount"] == 201_500.0, r["planned_amount"] # 取整到手的必然轻微超额
@case("降仓凑额·不超卖 (每票累计计划量 ≤ 持仓量)")
def _():
r = pl.plan_reduce_exposure(release_amount=350_000, positions=_positions_demo())
hold = {p["ts_code"]: p["total_qty"] for p in _positions_demo()}
used = {}
for i in r["items"]:
if i["action"] in (pl.A_EXIT, pl.A_TRIM):
used[i["ts_code"]] = used.get(i["ts_code"], 0) + i["qty"]
for c, q in used.items():
assert q <= hold[c], (c, q, hold[c])
@case("降仓凑额·可减持仓不足时给缺口并标不达标")
def _():
r = pl.plan_reduce_exposure(release_amount=1_000_000, positions=_positions_demo())
assert not r["ok"] and r["gap"] > 0, r
assert any("缺口" in n for n in r["notes"]), r["notes"]
assert r["planned_amount"] <= 420_000 + 1e-6, r
@case("降仓凑额·清弱票为整票清仓, 超目标时留痕说明")
def _():
r = pl.plan_reduce_exposure(release_amount=30_000, positions=_positions_demo())
weak = [i for i in r["items"] if i["tier"] == "2_清弱票"]
assert len(weak) == 1 and weak[0]["qty"] == 10000, weak # 不为凑额拆卖弱票
assert any("超目标" in n for n in r["notes"]), r["notes"]
assert len([i for i in r["items"] if i["tier"] != "2_清弱票"]) == 0, r["items"]
@case("降仓凑额·跳过已有在途方案的票 + 零释放额只撤单")
def _():
r = pl.plan_reduce_exposure(release_amount=100_000, positions=_positions_demo(),
exclude_codes=["000001.SZ"])
assert all(i["ts_code"] != "000001.SZ" for i in r["items"]), r["items"]
r2 = pl.plan_reduce_exposure(release_amount=0, positions=_positions_demo(),
pending_buys=[{"instruction_id": "I1", "ts_code": "600000.SH",
"qty": 100, "amount": 1000}])
assert len(r2["items"]) == 1 and r2["items"][0]["action"] == pl.A_HALT, r2
assert not r2["ok"]
# ================================================================ planner · 建仓/升仓/个股
def _ctx(**kw):
d = dict(scale=2_000_000, portfolio_cap=0.60, stock_cap=0.08, max_names=15,
portfolio_mv=800_000, names_count=5, stock_mv=0.0, is_new_name=True,
sector=None, sector_names=0, sector_mv=0.0,
sector_max_names=4, sector_max_ratio=0.40, cash_reserve=0.0)
d.update(kw)
return d
@case("建仓命令·50/25/25 分批 (12万@10元 → 6000/3000/3000, 后两批 gated 待引擎解锁)")
def _():
r = pl.plan_open_target(ts_code="600000.SH", target_pct=0.06, price=10.0, ctx=_ctx())
assert r["ok"], r
assert [i["qty"] for i in r["items"]] == [6000, 3000, 3000], r["items"]
assert [i["action"] for i in r["items"]] == [pl.A_OPEN, pl.A_FILL, pl.A_ADD]
assert [i["gated"] for i in r["items"]] == [False, True, True]
assert all(i["side"] == pl.SIDE_BUY for i in r["items"])
assert abs(r["planned_amount"] - 120_000) < 1e-6
@case("建仓命令·一手合并 (12万@700元 → 合并为单批 100 股并留痕)")
def _():
r = pl.plan_open_target(ts_code="600519.SH", target_pct=0.06, price=700.0, ctx=_ctx())
assert r["ok"] and len(r["items"]) == 1 and r["items"][0]["qty"] == 100, r
assert any("批次自动合并" in n for n in r["notes"]), r["notes"]
@case("建仓命令·单股上限硬拦截 + 已达目标不重复建仓")
def _():
r = pl.plan_open_target(ts_code="600000.SH", target_pct=0.10, price=10.0, ctx=_ctx())
assert not r["ok"] and r["items"] == [], r
assert any(x.startswith("STOCK_CAP") for x in r["rejects"][0]["reasons"]), r["rejects"]
r2 = pl.plan_open_target(ts_code="600000.SH", target_pct=0.06, price=10.0,
ctx=_ctx(stock_mv=130_000))
assert r2["ok"] and r2["items"] == [], r2
@case("建仓命令·预留现金约束 (与总仓上限双重约束)")
def _():
ctx = _ctx(portfolio_mv=1_750_000, portfolio_cap=0.95, cash_reserve=0.10)
r = pl.plan_open_target(ts_code="600000.SH", target_pct=0.06, price=10.0, ctx=ctx)
assert not r["ok"], r
assert any(x.startswith("CASH_RESERVE") for x in r["rejects"][0]["reasons"]), r["rejects"]
@case("建仓命令·行业硬拦截 (同行业 4 只已满)")
def _():
ctx = _ctx(sector_names_map={"半导体": 4}, sector_mv_map={"半导体": 300_000})
r = pl.plan_increase_exposure(
add_amount=120_000, positions=[], ctx=ctx,
candidates=[{"ts_code": "688981.SH", "price": 20.0, "score": 0.9, "sector": "半导体"}],
params={"stock_target_default": 0.06})
assert r["items"] == [] and r["rejects"], r
assert any(x.startswith("SECTOR_NAMES") for x in r["rejects"][0]["reasons"]), r["rejects"]
@case("升仓命令·既有垫厚票补到目标 + 候选池新票建仓, 累计口径校验上限")
def _():
holdings = [dict(ts_code="600000.SH", price=10.0, total_qty=8000, base_qty=8000,
cushion_pct=0.08, cushion_state="SOLID", target_pct=0.06,
frozen_reason="NONE", sector=None)]
cands = [{"ts_code": "688981.SH", "price": 20.0, "score": 0.9, "sector": "半导体"},
{"ts_code": "000651.SZ", "price": 15.0, "score": 0.8, "sector": "家电"}]
r = pl.plan_increase_exposure(add_amount=200_000, positions=holdings, candidates=cands,
ctx=_ctx(), params={"stock_target_default": 0.06})
adds = [i for i in r["items"] if i["action"] == pl.A_ADD and i["tier"] == "1_补既有"]
assert len(adds) == 1 and adds[0]["ts_code"] == "600000.SH" and adds[0]["qty"] == 4000, adds
new_codes = {i["ts_code"] for i in r["items"] if i["tier"].startswith("批次_")}
assert new_codes == {"688981.SH", "000651.SZ"}, new_codes
assert r["ok"] and abs(r["planned_amount"] - 200_000) < 1e-6, r
@case("升仓命令·冻结票与非厚垫票不补仓")
def _():
holdings = [dict(ts_code="600000.SH", price=10.0, total_qty=8000, base_qty=8000,
cushion_pct=0.08, cushion_state="SOLID", target_pct=0.06,
frozen_reason="COMMAND_HALT", sector=None),
dict(ts_code="000001.SZ", price=8.0, total_qty=5000, base_qty=5000,
cushion_pct=0.01, cushion_state="THIN", target_pct=0.06,
frozen_reason="NONE", sector=None)]
r = pl.plan_increase_exposure(add_amount=100_000, positions=holdings, candidates=[],
ctx=_ctx(), params={})
assert r["items"] == [] and not r["ok"], r
@case("个股命令·清仓(含零股)/减至X%/减至0等价清仓")
def _():
pos = dict(ts_code="600000.SH", price=10.0, total_qty=14050, base_qty=14050)
r = pl.plan_exit_stock(ts_code="600000.SH", position=pos)
assert r["items"][0]["qty"] == 14050, r # 零股一并卖出, 不向下取整
assert r["items"][0]["action"] == pl.A_EXIT
pos2 = dict(ts_code="600000.SH", price=10.0, total_qty=20000, base_qty=10000)
r2 = pl.plan_reduce_stock(ts_code="600000.SH", target_pct=0.05, position=pos2,
scale=2_000_000)
assert r2["items"][0]["qty"] == 10000 and r2["items"][0]["action"] == pl.A_TRIM, r2
r3 = pl.plan_reduce_stock(ts_code="600000.SH", target_pct=0.0, position=pos2,
scale=2_000_000)
assert r3["items"][0]["action"] == pl.A_EXIT and r3["items"][0]["qty"] == 20000, r3
r4 = pl.plan_reduce_stock(ts_code="600000.SH", target_pct=0.20, position=pos2,
scale=2_000_000)
assert r4["items"] == [] and r4["ok"], r4 # 未超目标不减持
@case("组合命令·一键清仓(市值大的先卖)/行业清仓/行业限额/暂停买入撤单")
def _():
ps = _positions_demo()
for p, s in zip(ps, ["银行", "银行", "白酒", "新能源"]):
p["sector"] = s
r = pl.plan_liquidate_all(positions=ps, pending_buys=[
{"instruction_id": "I1", "ts_code": "600036.SH", "qty": 100, "amount": 1000}])
assert r["items"][0]["action"] == pl.A_HALT
exits = [i for i in r["items"] if i["action"] == pl.A_EXIT]
# 市值降序: A 14万 → D/C 各 10万 (同值按代码升序) → B 8万
assert [i["ts_code"] for i in exits] == ["600000.SH", "300750.SZ", "600519.SH", "000001.SZ"]
assert abs(r["planned_amount"] - 420_000) < 1e-6, r
r2 = pl.plan_sector_exit(sector="银行", positions=ps)
assert {i["ts_code"] for i in r2["items"]} == {"600000.SH", "000001.SZ"}, r2
assert abs(r2["planned_amount"] - 220_000) < 1e-6
# 银行占比 220/420 = 52.4% > 40% → 需减 52,000 元
r3 = pl.plan_sector_cap(sector="银行", cap=0.40, positions=ps)
assert r3["items"] and all(i["action"] == pl.A_TRIM for i in r3["items"]), r3
assert abs(r3["target_amount"] - 52_000) < 1e-6, r3
r4 = pl.plan_sector_cap(sector="白酒", cap=0.40, positions=ps)
assert r4["items"] == [] and "未超上限" in r4["notes"][0], r4
r5 = pl.plan_halt_buy(pending_buys=[{"instruction_id": "I1", "ts_code": "600000.SH",
"qty": 100, "amount": 1000}])
assert len(r5["items"]) == 1 and r5["items"][0]["action"] == pl.A_HALT
assert pl.plan_halt_buy(pending_buys=[])["items"] == []
# ================================================================ recon
@case("回放认领·FIFO 跨指令拆分 (一笔 4000 股成交吃两条在途指令)")
def _():
instrs = [{"instruction_id": "INS_A", "ts_code": "600000.SH", "side": "buy", "qty": 3000,
"exec_qty": 0, "action": "OPEN", "dispatched_at": "2026-07-27 09:35:00"},
{"instruction_id": "INS_B", "ts_code": "600000.SH", "side": "buy", "qty": 2000,
"exec_qty": 0, "action": "ADD", "dispatched_at": "2026-07-27 10:05:00"}]
fills = [{"order_id": 1, "ts_code": "600000.SH", "side": "buy", "qty": 4000,
"price": 10.0, "done_time": "2026-07-27 10:30:00"}]
r = rc.map_fills_to_book(fills, instrs)
assert [a["instruction_id"] for a in r["actions"]] == ["INS_A", "INS_B"], r["actions"]
assert [a["qty"] for a in r["actions"]] == [3000, 1000], r["actions"]
assert [a["lot_type"] for a in r["actions"]] == ["BASE", "ADD"], r["actions"]
assert r["alerts"] == []
@case("回放认领·下发晚于成交的指令不认领 (时间守卫)")
def _():
instrs = [{"instruction_id": "INS_LATE", "ts_code": "600000.SH", "side": "buy", "qty": 1000,
"exec_qty": 0, "action": "OPEN", "dispatched_at": "2026-07-27 14:00:00"}]
fills = [{"order_id": 7, "ts_code": "600000.SH", "side": "buy", "qty": 1000, "price": 10.0,
"done_time": "2026-07-27 09:40:00"}]
r = rc.map_fills_to_book(fills, instrs)
assert r["actions"][0]["instruction_id"] is None, r["actions"]
assert r["alerts"] and r["alerts"][0]["code"] == rc.ALERT_EXTERNAL
@case("回放·外部成交并入 BASE 并告警 (设计生命线条款)")
def _():
fills = [{"order_id": 2, "ts_code": "000001.SZ", "side": "buy", "qty": 500, "price": 8.0,
"done_time": "2026-07-27 09:40:00"}]
r = rc.map_fills_to_book(fills, [])
a = r["actions"][0]
assert a["instruction_id"] is None and a["lot_type"] == "BASE" and a["qty"] == 500
assert rc.ALERT_EXTERNAL in a["alerts"] and len(r["alerts"]) == 1
@case("回放·成交量超指令数量 → 超出部分标 OVER_FILL")
def _():
instrs = [{"instruction_id": "INS_A", "ts_code": "600000.SH", "side": "buy", "qty": 1000,
"exec_qty": 0, "action": "OPEN", "dispatched_at": "2026-07-27 09:30:00"}]
fills = [{"order_id": 3, "ts_code": "600000.SH", "side": "buy", "qty": 1500, "price": 10.0,
"done_time": "2026-07-27 09:50:00"}]
r = rc.map_fills_to_book(fills, instrs)
assert len(r["actions"]) == 2 and r["actions"][1]["qty"] == 500
assert rc.ALERT_OVERFILL in r["actions"][1]["alerts"], r["actions"]
@case("回放·幂等 (已入账 order_id 跳过) 与游标推进")
def _():
fills = [{"order_id": 10, "ts_code": "600000.SH", "side": "buy", "qty": 100, "price": 10.0},
{"order_id": 11, "ts_code": "600000.SH", "side": "buy", "qty": 100, "price": 10.0}]
r = rc.map_fills_to_book(fills, [], known_order_ids={10})
assert r["skipped"] == 1 and len(r["actions"]) == 1, r
assert rc.next_cursor(fills, cursor=5) == 11
assert rc.next_cursor([], cursor=5) == 5
assert rc.next_cursor(fills, cursor=99) == 99
@case("回放·卖出成交按核销次序分配, 批次不足时告警留差额")
def _():
lots = [{"lot_id": "L1", "lot_type": "BASE", "qty": 1000, "open_date": 20260701},
{"lot_id": "L2", "lot_type": "ADD", "qty": 500, "open_date": 20260710}]
ok = rc.apply_sell_to_lots(lots, 1200)
assert [x["lot_id"] for x in ok["alloc"]] == ["L2", "L1"], ok
assert [x["qty"] for x in ok["alloc"]] == [500, 700] and ok["short"] == 0
short = rc.apply_sell_to_lots(lots, 2000)
assert short["short"] == 500 and short["alerts"][0]["code"] == rc.ALERT_SELL_NO_LOT
@case("回放·成交数量与价格口径回退 (filled_* 缺失时用 order_*)")
def _():
assert rc.fill_qty_price({"order_quantity": 700, "order_price": 9.87}) == (700, 9.87)
assert rc.fill_qty_price({"filled_qty": 300, "order_quantity": 700,
"filled_price": 9.9, "order_price": 9.87}) == (300, 9.9)
assert rc.fill_qty_price({}) == (0, 0.0)
@case("对账·三类差异识别 (账本缺/账本多/数量不符)")
def _():
book = [{"ts_code": "600000.SH", "total_qty": 6000},
{"ts_code": "000001.SZ", "total_qty": 1000},
{"ts_code": "600519.SH", "total_qty": 300}]
ds = [{"ts_code": "600000.SH", "qty": 6000},
{"ts_code": "000001.SZ", "qty": 0},
{"ts_code": "300750.SZ", "qty": 800},
{"ts_code": "600519.SH", "qty": 500}]
d = {x["ts_code"]: x for x in rc.diff_positions(book, ds)}
assert "600000.SH" not in d
assert d["000001.SZ"]["kind"] == "EXTRA_IN_BOOK" and d["000001.SZ"]["delta"] == -1000
assert d["300750.SZ"]["kind"] == "MISSING_IN_BOOK" and d["300750.SZ"]["delta"] == 800
assert d["600519.SH"]["kind"] == "QTY_MISMATCH" and d["600519.SH"]["delta"] == 200
@case("对账·以下游为准生成修正动作 (补 RECON 批 / 冲销批次)")
def _():
diffs = rc.diff_positions([{"ts_code": "600000.SH", "total_qty": 1000}],
[{"ts_code": "600000.SH", "qty": 1500}])
fx = rc.build_recon_fixes(diffs, price_map={"600000.SH": 10.0})
assert fx[0]["op"] == "ADD_RECON_LOT" and fx[0]["qty"] == 500 and not fx[0]["need_price"]
diffs2 = rc.diff_positions([{"ts_code": "600000.SH", "total_qty": 1000}],
[{"ts_code": "600000.SH", "qty": 600}])
lots = {"600000.SH": [{"lot_id": "L1", "lot_type": "BASE", "qty": 1000,
"open_date": 20260701}]}
fx2 = rc.build_recon_fixes(diffs2, price_map={}, lots_map=lots)
assert fx2[0]["op"] == "REDUCE_LOTS" and fx2[0]["qty"] == 400
assert fx2[0]["alloc"] == [{"lot_id": "L1", "qty": 400}], fx2
@case("对账·连续不一致升级 (1-2日 WARN, 满 3 日 ERROR)")
def _():
assert rc.recon_severity(0) == rc.SEV_OK
assert rc.recon_severity(1) == rc.SEV_WARN
assert rc.recon_severity(3) == rc.SEV_ERROR
s = rc.summarize_recon([{"kind": "QTY_MISMATCH"}, {"kind": "QTY_MISMATCH"}], 3)
assert s["diff_count"] == 2 and s["by_kind"]["QTY_MISMATCH"] == 2
assert s["severity"] == rc.SEV_ERROR
assert rc.summarize_recon([], 3)["severity"] == rc.SEV_OK
@case("除权检测·10送10 判为除权; 比例不吻合判 MISMATCH 待人工")
def _():
r = rc.detect_ex_right(1000, 2000, 20.0, 10.0)
assert r["kind"] == "EX_RIGHT" and abs(r["ratio"] - 2.0) < 1e-6, r
assert rc.detect_ex_right(1000, 1000, 20.0, 20.0) is None
bad = rc.detect_ex_right(1000, 2000, 20.0, 18.0)
assert bad["kind"] == "MISMATCH" and "待人工" in bad["reason"], bad
nopx = rc.detect_ex_right(1000, 2000, 0, 10.0)
assert nopx["kind"] == "MISMATCH", nopx
# 10 送 3 (容差内)
r2 = rc.detect_ex_right(1000, 1300, 13.0, 10.0)
assert r2["kind"] == "EX_RIGHT" and abs(r2["ratio"] - 1.3) < 1e-6, r2
@case("除权调整·批次数量与成本按比例调整, 总成本不变")
def _():
lots = [{"lot_id": "L1", "lot_type": "BASE", "qty": 1000, "open_price": 20.0},
{"lot_id": "L2", "lot_type": "ADD", "qty": 500, "open_price": 22.0}]
out = rc.apply_ex_right(lots, 2.0)
assert [l["qty"] for l in out] == [2000, 1000]
assert [l["open_price"] for l in out] == [10.0, 11.0]
before = sum(l["qty"] * l["open_price"] for l in lots)
after = sum(l["qty"] * l["open_price"] for l in out)
assert abs(before - after) < 1e-6, (before, after)
@case("T+1 可用量·日初重置 / 当日买入不增可卖 / 卖出扣减")
def _():
assert rc.daily_avail_reset(6000) == 6000
assert rc.avail_after_fill(6000, "buy", 2000) == 6000 # 当日买入 T+1 才可卖
assert rc.avail_after_fill(6000, "sell", 2000) == 4000
assert rc.avail_after_fill(1000, "sell", 5000) == 0
assert rc.sellable_today(4000, 10000) == 4000
assert rc.sellable_today(0, 100) == 0
# ---------------------------------------------------------------- runner
def main():
passed, failed = 0, 0
for name, fn in RESULTS:
try:
fn()
print(f" PASS {name}")
passed += 1
except Exception:
print(f" FAIL {name}")
traceback.print_exc()
failed += 1
print("-" * 60)
if failed:
print(f"FAILED: {failed} / {passed + failed}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()

725
scripts/test_wiring.py Normal file
View File

@ -0,0 +1,725 @@
# -*- coding: utf-8 -*-
"""
装配自检 (零外部依赖, 不连库)
==============================
运行: python scripts/test_wiring.py
用内存桩替换 repo/行情, 验证服务层 核心逻辑 落表整条链路的接线是否正确:
导入链API 路由调度表与守卫单表访问守卫交易日历参数中心回退,
以及命令下达方案落表撤销成交回放批次入账成本重算 两条主干流程
"""
import os
import sys
import traceback
from datetime import date, datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
# ================================================================ 内存桩
class FakeRepo:
"""pms_repo 的内存替身 (只实现服务层用到的函数, 语义与 SQL 版一致)。"""
def __init__(self):
self.params, self.commands, self.plans = {}, {}, []
self.positions, self.lots, self.instructions = {}, [], {}
self.proposals, self.ledger, self.reports, self.industry = {}, [], {}, {}
self._lot_id = 0
# --- runtime param ---
def all_params(self):
return {k: {"param_value": v, "updated_by": "test", "updated_at": ""}
for k, v in self.params.items()}
def set_param(self, key, value, updated_by="user"):
self.params[key] = str(value)
return 1
def get_param(self, key):
return self.params.get(key)
# --- command ---
def next_command_seq(self, ymd):
return len(self.commands) + 1
def insert_command(self, **kw):
self.commands[kw["command_id"]] = {
"id": len(self.commands) + 1, "command_id": kw["command_id"],
"cmd_class": kw["cmd_class"], "cmd_type": kw["cmd_type"], "ts_code": kw["ts_code"],
"params": kw["params"], "status": kw["status"], "progress": kw.get("progress") or {},
"issued_by": kw.get("issued_by"), "issued_at": datetime.now(), "done_at": None,
"note": kw.get("note")}
return 1
def get_command(self, cid):
return self.commands.get(cid)
def list_commands(self, *, statuses=None, cmd_class=None, limit=200):
out = [c for c in self.commands.values()
if (not statuses or c["status"] in statuses)
and (not cmd_class or c["cmd_class"] == cmd_class)]
return sorted(out, key=lambda c: -c["id"])[:limit]
def update_command(self, cid, *, status=None, progress=None, done_at=None, note=None):
c = self.commands.get(cid)
if not c:
return 0
if status is not None:
c["status"] = status
if progress is not None:
c["progress"] = progress
if done_at is not None:
c["done_at"] = done_at
if note is not None:
c["note"] = note
return 1
def supersede_param_commands(self, cmd_type, ts_code=None, keep_command_id=None):
n = 0
for c in self.commands.values():
if (c["cmd_class"] == "param" and c["status"] == "EFFECTIVE"
and c["cmd_type"] == cmd_type and c["ts_code"] == ts_code
and c["command_id"] != keep_command_id):
c["status"] = "SUPERSEDED"
n += 1
return n
def latest_effective(self, cmd_type, ts_code=None):
hits = [c for c in self.commands.values()
if c["cmd_class"] == "param" and c["status"] == "EFFECTIVE"
and c["cmd_type"] == cmd_type and (not ts_code or c["ts_code"] == ts_code)]
return sorted(hits, key=lambda c: c["id"])[-1] if hits else None
def list_effective_stock_params(self, cmd_types=None):
return [c for c in self.commands.values()
if c["cmd_class"] == "param" and c["status"] == "EFFECTIVE" and c["ts_code"]
and (not cmd_types or c["cmd_type"] in cmd_types)]
# --- plan ---
def insert_plans(self, rows):
for r in rows:
d = dict(r)
d.setdefault("filled_qty", 0)
self.plans.append(d)
return len(rows)
def list_plans(self, *, command_id=None, statuses=None, ts_code=None, limit=500):
out = [p for p in self.plans
if (not command_id or p["command_id"] == command_id)
and (not statuses or p["status"] in statuses)
and (not ts_code or p["ts_code"] == ts_code)]
return sorted(out, key=lambda p: p.get("priority", 100))[:limit]
def update_plan(self, plan_id, *, status=None, filled_qty=None):
for p in self.plans:
if p["plan_id"] == plan_id:
if status:
p["status"] = status
if filled_qty is not None:
p["filled_qty"] = filled_qty
return 1
return 0
def cancel_plans_of_command(self, cid):
n = 0
for p in self.plans:
if p["command_id"] == cid and p["status"] in ("PENDING", "EXECUTING"):
p["status"] = "CANCELLED"
n += 1
return n
def set_plans_deadline(self, cid, dl):
for p in self.plans:
if p["command_id"] == cid:
p["deadline"] = dl
return 1
# --- position / lot ---
def list_positions(self, *, only_open=False):
out = list(self.positions.values())
return [p for p in out if int(p.get("total_qty") or 0) > 0] if only_open else out
def get_position(self, code):
return self.positions.get(code)
def ensure_position(self, code):
self.positions.setdefault(code, {"ts_code": code, "status": "PLANNED", "total_qty": 0,
"avail_qty": 0, "frozen_reason": "NONE"})
return 1
def update_position(self, code, **fields):
self.ensure_position(code)
self.positions[code].update(fields)
return 1
def bump_position_qty(self, code, *, total_delta=0, avail_delta=0):
self.ensure_position(code)
p = self.positions[code]
p["total_qty"] = max(0, int(p.get("total_qty") or 0) + total_delta)
p["avail_qty"] = max(0, int(p.get("avail_qty") or 0) + avail_delta)
return 1
def reset_avail_all(self):
for p in self.positions.values():
p["avail_qty"] = p.get("total_qty", 0)
p["t0_count_today"] = 0
return len(self.positions)
def list_lots(self, ts_code=None, *, status="OPEN", limit=1000):
out = [l for l in self.lots
if (not ts_code or l["ts_code"] == ts_code)
and (not status or l["status"] == status)]
return sorted(out, key=lambda l: (str(l["open_date"]), l["id"]))[:limit]
def insert_lot(self, *, ts_code, lot_type, qty, open_price, open_date, instruction_id=None,
note=None):
self._lot_id += 1
self.lots.append({"id": self._lot_id, "ts_code": ts_code, "lot_type": lot_type,
"qty": int(qty), "open_price": float(open_price),
"open_date": open_date, "closed_qty": 0, "close_avg_price": None,
"realized_pnl": 0.0, "status": "OPEN",
"instruction_id": instruction_id, "note": note})
return 1
def close_lot_qty(self, lot_id, *, qty, close_price, realized_pnl):
for l in self.lots:
if l["id"] == lot_id:
old_closed = int(l["closed_qty"])
l["close_avg_price"] = ((float(l["close_avg_price"] or 0) * old_closed
+ close_price * qty) / (old_closed + qty))
l["qty"] = max(0, int(l["qty"]) - qty)
l["closed_qty"] = old_closed + qty
l["realized_pnl"] += realized_pnl
l["status"] = "CLOSED" if l["qty"] <= 0 else "OPEN"
return 1
return 0
def update_lot(self, lot_id, **fields):
for l in self.lots:
if l["id"] == lot_id:
l.update(fields)
return 1
return 0
# --- instruction / proposal / ledger / report / industry ---
def insert_instruction(self, **kw):
kw.setdefault("exec_qty", 0)
kw["created_at"] = kw["updated_at"] = datetime.now()
self.instructions[kw["instruction_id"]] = kw
return 1
def list_instructions(self, *, statuses=None, side=None, ts_code=None, limit=300):
out = [i for i in self.instructions.values()
if (not statuses or i["status"] in statuses)
and (not side or i.get("side") == side)
and (not ts_code or i["ts_code"] == ts_code)]
return out[:limit]
def get_instruction(self, iid):
return self.instructions.get(iid)
def update_instruction(self, iid, **kw):
i = self.instructions.get(iid)
if not i:
return 0
for k, v in kw.items():
if v is not None:
i[k] = v
i["updated_at"] = datetime.now()
return 1
def add_instruction_exec(self, iid, qty):
i = self.instructions.get(iid)
if i:
i["exec_qty"] = int(i.get("exec_qty") or 0) + int(qty)
return 1
def insert_proposal(self, **kw):
kw["status"] = kw.get("status", "WAIT_USER")
kw["hard_numbers"] = kw.get("hard_numbers") or {}
self.proposals[kw["proposal_id"]] = kw
return 1
def list_proposals(self, *, statuses=("WAIT_USER",), limit=200):
return [p for p in self.proposals.values() if p["status"] in statuses][:limit]
def get_proposal(self, pid):
return self.proposals.get(pid)
def decide_proposal(self, pid, status):
p = self.proposals.get(pid)
if p and p["status"] == "WAIT_USER":
p["status"] = status
return 1
return 0
def expire_proposals(self, now=None):
return 0
def insert_ledger(self, **kw):
self.ledger.append(kw)
return 1
def list_ledger(self, *, ts_code=None, limit=200):
return self.ledger[-limit:]
def upsert_report(self, ymd, report):
self.reports[int(ymd)] = report
return 1
def get_report(self, ymd):
r = self.reports.get(int(ymd))
return {"ymd": ymd, "report": r} if r else None
def latest_report(self):
if not self.reports:
return None
y = max(self.reports)
return {"ymd": y, "report": self.reports[y]}
def get_industry(self, code):
return self.industry.get(code)
def list_industry(self, limit=5000):
return [{"ts_code": k, "industry": v} for k, v in self.industry.items()]
def upsert_industry(self, rows):
for r in rows:
self.industry[r["ts_code"]] = r["industry"]
return len(rows)
def install_fakes(prices=None, positions=None, params=None):
"""把内存桩装到各模块上, 返回 FakeRepo 实例。"""
from app.repo import downstream_repo, pms_repo
from app.services import industry, market, param_store, portfolio
fake = FakeRepo()
fake.params.update(params or {})
for p in (positions or []):
base = {"ts_code": p["ts_code"], "status": "HOLDING", "frozen_reason": "NONE",
"total_qty": 0, "avail_qty": 0, "base_qty": 0, "fill_qty": 0, "add_qty": 0,
"dca_qty": 0, "t0_qty": 0, "avg_cost": None, "cushion_peak": 0,
"realized_t_profit": 0, "t0_enabled": 0}
base.update(p)
fake.positions[p["ts_code"]] = base
for mod in (pms_repo,):
for name in dir(FakeRepo):
if name.startswith("_"):
continue
setattr(mod, name, getattr(fake, name))
market.get_price = lambda c: (prices or {}).get(c)
market.get_prices = lambda cs_: {c: (prices or {}).get(c) for c in (cs_ or [])}
market.get_refs = lambda c, **kw: {"support": None, "pressure": None, "stop": None,
"source": "none"}
industry.get_many = lambda cs_: {c: None for c in (cs_ or [])}
industry.get = lambda c: None
industry.ready = lambda: False
industry.status = lambda: {"source": "", "ready": False, "count": 0, "hint": "test"}
# 下游只读表默认桩 (单测不触网; 个别用例内再按需覆盖)
downstream_repo.fetch_buy_plans = lambda **kw: []
downstream_repo.fetch_filled_orders = lambda **kw: []
downstream_repo.fetch_positions = lambda: {"rows": [], "columns": {"qty": None},
"raw_count": 0}
downstream_repo.fetch_refs = lambda c: None
param_store._cache.update({"at": 0.0, "data": {}, "error": None})
portfolio.save_neg_streak({})
return fake
# ================================================================ 用例
@case("装配·全部模块可导入 (无循环依赖)")
def _():
import importlib
for m in ["app.core.sizer", "app.core.cushion", "app.core.command_spec", "app.core.planner",
"app.core.recon", "app.core.tradedays", "app.db.session", "app.repo.pms_repo",
"app.repo.downstream_repo", "app.services.param_store", "app.services.industry",
"app.services.market", "app.services.portfolio", "app.services.command_service",
"app.services.ledger_service", "app.web.main", "app.scheduler"]:
importlib.import_module(m)
@case("装配·API 路由齐全 (四块页面 + 运维)")
def _():
from app.web.main import app
paths = {r.path for r in app.routes}
need = ["/", "/health", "/api/overview", "/api/params", "/api/commands",
"/api/commands/catalog", "/api/commands/active", "/api/commands/{command_id}",
"/api/commands/{command_id}/cancel", "/api/plans", "/api/positions",
"/api/positions/{ts_code}/lots", "/api/instructions", "/api/ledger", "/api/report",
"/api/proposals", "/api/proposals/{proposal_id}/decide", "/api/ops/replay",
"/api/ops/reconcile", "/api/ops/daily-settle", "/api/ops/report",
"/api/ops/downstream-schema", "/api/industry"]
missing = [p for p in need if p not in paths]
assert not missing, missing
import os as _os
from app.web import main as web
assert _os.path.exists(_os.path.join(web.STATIC_DIR, "index.html")), "单页文件缺失"
@case("装配·调度表覆盖设计 §10 全部调度位")
def _():
from app import scheduler as sch
names = set(sch.celery_app.conf.beat_schedule)
assert names == {"premarket", "command_poll", "replay_fills", "intraday_exec",
"signal_digest", "t0_close", "daily_settle", "daily_report"}, names
tasks = set(sch.celery_app.tasks)
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"):
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, 12, 0)) is False
assert sch.in_session(datetime(2026, 7, 27, 14, 59)) is True
@case("装配·调度守卫: 非交易日跳过 / 休假模式跳过 / 异常不外抛")
def _():
from app import scheduler as sch
from app.core import tradedays as tdm
orig = tdm.is_trade_day
try:
tdm.is_trade_day = lambda d=None: False
@sch.guard(trade_day=True)
def t1():
raise AssertionError("非交易日不应执行")
assert t1() == {"skipped": "not_trade_day"}
tdm.is_trade_day = lambda d=None: True
install_fakes(params={"PMS_GLOBAL_EXEC_HALT": "true"})
@sch.guard(trade_day=True)
def t2():
raise AssertionError("休假模式不应执行")
assert t2() == {"skipped": "exec_halt"}
install_fakes(params={})
@sch.guard(trade_day=True)
def t3():
raise ValueError("boom")
r = t3()
assert "error" in r and "boom" in r["error"], r
finally:
tdm.is_trade_day = orig
@case("装配·严格单表访问守卫 (JOIN 与多表一律拒绝)")
def _():
from app.db.session import MultiTableSQL, assert_single_table
assert_single_table("SELECT * FROM pms_position WHERE ts_code = :c")
assert_single_table("UPDATE pms_lot SET qty = 0 WHERE id = :id")
for bad in ["SELECT a.* FROM pms_position a JOIN pms_lot b ON a.ts_code = b.ts_code",
"SELECT * FROM pms_position, pms_lot",
"SELECT * FROM pms_plan WHERE ts_code IN (SELECT ts_code FROM pms_position)"]:
try:
assert_single_table(bad)
assert False, f"未拦截: {bad}"
except MultiTableSQL:
pass
@case("交易日历·执行窗口与剩余交易日 (跨周末)")
def _():
from app.core import tradedays as tdm
fri, mon = date(2026, 7, 24), date(2026, 7, 27) # 2026-07-24 周五, 07-27 周一
assert tdm.is_trade_day(fri) and tdm.is_trade_day(mon)
assert tdm.is_trade_day(date(2026, 7, 25)) is False # 周六
assert tdm.next_trade_day(fri, 1) == mon
assert tdm.window_deadline(mon, 1) == mon
assert tdm.window_deadline(mon, 3) == date(2026, 7, 29)
assert tdm.window_deadline(fri, 2) == mon # 周五起 2 个交易日 = 周一
assert tdm.trade_days_left(date(2026, 7, 29), mon) == 3
assert tdm.trade_days_left(date(2026, 7, 20), mon) == 0
assert tdm.ymd(mon) == 20260727
@case("参数中心·表值优先/文件回退/非法值拒绝")
def _():
from app.services import param_store
from config.settings import settings
fake = install_fakes(params={"PMS_TOTAL_SCALE": "3000000", "PMS_AUTONOMY": "full"})
assert param_store.get("PMS_TOTAL_SCALE") == 3_000_000.0
assert param_store.get("PMS_AUTONOMY") == "full"
assert param_store.get("PMS_STOCK_CAP") == settings.PMS_STOCK_CAP # 未改写 → 文件初值
assert param_store.get_bool("PMS_GLOBAL_BUY_HALT") is False # 运行态默认
assert param_store.set_param("PROXY_DB_URL", "x")["ok"] is False # 基础设施不可改
assert param_store.set_param("PMS_PORTFOLIO_CAP", 1.5)["ok"] is False # 越界
assert param_store.set_param("PMS_AUTONOMY", "auto")["ok"] is False # 枚举非法
assert param_store.set_param("PMS_PORTFOLIO_CAP", 0.5)["ok"] is True
assert fake.params["PMS_PORTFOLIO_CAP"] == "0.5"
assert param_store.get("PMS_PORTFOLIO_CAP") == 0.5
sp = param_store.sizing_params()
assert sp["scale"] == 3_000_000.0 and sp["portfolio_cap"] == 0.5
@case("命令服务·参数命令立即生效并覆盖旧记录")
def _():
from app.services import command_service as csvc, param_store
fake = install_fakes()
r1 = csvc.issue("SET_SCALE", {"scale": 2_000_000})
assert r1["ok"] and r1["status"] == "EFFECTIVE", r1
assert param_store.get("PMS_TOTAL_SCALE") == 2_000_000.0
r2 = csvc.issue("SET_SCALE", {"scale": 2_500_000})
assert r2["ok"] and param_store.get("PMS_TOTAL_SCALE") == 2_500_000.0
assert fake.commands[r1["command_id"]]["status"] == "SUPERSEDED"
assert fake.commands[r2["command_id"]]["status"] == "EFFECTIVE"
bad = csvc.issue("SET_SCALE", {"scale": -1})
assert not bad["ok"] and any("OUT_OF_RANGE" in e for e in bad["errors"]), bad
@case("命令服务·个股参数命令投影到账本 (冻结/做T/止损价)")
def _():
from app.services import command_service as csvc
fake = install_fakes(positions=[{"ts_code": "600000.SH", "total_qty": 1000,
"avg_cost": 10.0}])
assert csvc.issue("FREEZE_STOCK", {"ts_code": "600000.SH"})["ok"]
assert fake.positions["600000.SH"]["frozen_reason"] == "COMMAND_HALT"
assert csvc.issue("T0_ENABLE", {"ts_code": "600000.SH", "t_ratio": "30%"})["ok"]
assert fake.positions["600000.SH"]["t0_enabled"] == 1
assert abs(float(fake.positions["600000.SH"]["t0_ratio"]) - 0.30) < 1e-9
assert csvc.issue("SET_STOP_PRICE", {"ts_code": "600000.SH", "price": 9.2})["ok"]
assert fake.positions["600000.SH"]["stop_ref"] == 9.2
assert fake.positions["600000.SH"]["ref_source"] == "user"
assert csvc.issue("UNFREEZE_STOCK", {"ts_code": "600000.SH"})["ok"]
assert fake.positions["600000.SH"]["frozen_reason"] == "NONE"
sp = csvc.effective_stock_params()["600000.SH"]
assert sp["frozen"] is False and sp["t0"] is True and sp["stop_price"] == 9.2, sp
# 做T比例超硬上限 1/3 被拒
assert not csvc.issue("T0_ENABLE", {"ts_code": "600000.SH", "t_ratio": "40%"})["ok"]
@case("命令服务·降仓命令端到端 (方案落表 + 进度 + 撤销)")
def _():
from app.services import command_service as csvc
fake = install_fakes(
prices={"600000.SH": 10.0, "000001.SZ": 8.0},
params={"PMS_TOTAL_SCALE": "2000000", "PMS_WEAK_NEG_DAYS": "5"},
positions=[{"ts_code": "600000.SH", "total_qty": 14000, "base_qty": 7000,
"fill_qty": 3500, "add_qty": 3500, "avg_cost": 8.93},
{"ts_code": "000001.SZ", "total_qty": 10000, "base_qty": 10000,
"avg_cost": 8.8}])
from app.services import portfolio
portfolio.save_neg_streak({"000001.SZ": 6})
r = csvc.issue("REDUCE_EXPOSURE", {"pct": "5%", "window_tdays": 3})
assert r["ok"] and r["status"] == "EXECUTING", r
cid = r["command_id"]
plans = fake.list_plans(command_id=cid)
assert plans, "方案未落表"
assert {p["action"] for p in plans} <= {"EXIT", "TRIM", "HALT"}, plans
exit_p = [p for p in plans if p["action"] == "EXIT"]
assert exit_p and exit_p[0]["ts_code"] == "000001.SZ", plans # 弱票优先清
assert all(p["deadline"] for p in plans)
prog = fake.commands[cid]["progress"]
assert prog["target_amount"] == 100_000.0 and prog["plan_count"] == len(plans), prog
# 进度结算: 方案未成交 → 保持 EXECUTING; 全部成交 → DONE
r2 = csvc.refresh_progress(cid)
assert r2["commands"][0]["status"] in ("EXECUTING", "PARTIAL"), r2
for p in plans:
fake.update_plan(p["plan_id"], status="DONE", filled_qty=p["qty"])
csvc.refresh_progress(cid)
assert fake.commands[cid]["status"] == "DONE", fake.commands[cid]
# 撤销: 已完成命令不可撤
assert csvc.cancel(cid)["ok"] is False
r3 = csvc.issue("REDUCE_EXPOSURE", {"pct": "3%"}, force_conflict=True)
assert csvc.cancel(r3["command_id"])["ok"] is True
assert fake.commands[r3["command_id"]]["status"] == "CANCELLED"
assert all(p["status"] == "CANCELLED"
for p in fake.list_plans(command_id=r3["command_id"]))
@case("命令服务·冲突拦截与强制下达")
def _():
from app.services import command_service as csvc
install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 14000, "base_qty": 7000,
"avg_cost": 9.0}])
a = csvc.issue("REDUCE_EXPOSURE", {"pct": "5%"})
assert a["ok"], a
b = csvc.issue("INCREASE_EXPOSURE", {"pct": "5%"})
assert not b["ok"] and b["conflicts"], b
assert any("方向相反" in c["reason"] for c in b["conflicts"])
c = csvc.issue("INCREASE_EXPOSURE", {"pct": "5%"}, force_conflict=True)
assert c["ok"] and c["conflicts"], c # 强制下达但冲突仍留痕
@case("命令服务·全局暂停买入撤在途买入指令并置开关")
def _():
from app.services import command_service as csvc, param_store
fake = install_fakes(prices={"600000.SH": 10.0})
fake.insert_instruction(instruction_id="INS_1", origin_type="plan", origin_id="P1",
ts_code="600000.SH", action="OPEN", side="buy", qty=1000,
limit_price=10.0, status="DISPATCHED")
fake.insert_instruction(instruction_id="INS_2", origin_type="plan", origin_id="P2",
ts_code="600000.SH", action="EXIT", side="sell", qty=500,
limit_price=10.0, status="DISPATCHED")
r = csvc.issue("HALT_BUY", {})
assert r["ok"] and r["status"] == "DONE", r
assert fake.instructions["INS_1"]["status"] == "CANCELLED"
assert fake.instructions["INS_2"]["status"] == "DISPATCHED" # 卖出不受影响
assert param_store.get_bool("PMS_GLOBAL_BUY_HALT") is True
assert csvc.issue("RESUME_BUY", {})["ok"]
assert param_store.get_bool("PMS_GLOBAL_BUY_HALT") is False
@case("账本服务·成交回放入账 → 批次/持仓/成本重算")
def _():
from app.services import ledger_service as ls
from app.repo import downstream_repo
fake = install_fakes(prices={"600000.SH": 11.0})
fake.insert_instruction(instruction_id="INS_A", origin_type="plan", origin_id="P1",
ts_code="600000.SH", action="OPEN", side="buy", qty=6000,
limit_price=10.0, status="DISPATCHED")
fills = [{"order_id": 101, "ts_code": "600000.SH", "side": "buy", "qty": 6000,
"price": 10.0, "done_time": "2026-07-27 09:40:00"}]
orig = downstream_repo.fetch_filled_orders
try:
downstream_repo.fetch_filled_orders = lambda **kw: fills
r = ls.replay_fills()
assert r["ok"] and r["actions"] == 1, r
assert fake.positions["600000.SH"]["total_qty"] == 6000
assert fake.positions["600000.SH"]["avail_qty"] == 0 # T+1: 当日买入不可卖
assert abs(float(fake.positions["600000.SH"]["avg_cost"]) - 10.0) < 1e-6
assert fake.positions["600000.SH"]["base_qty"] == 6000
assert abs(float(fake.positions["600000.SH"]["cushion_pct"]) - 0.10) < 1e-4
assert fake.instructions["INS_A"]["status"] == "CONFIRMED"
assert fake.params["PMS_REPLAY_CURSOR"] == "101"
# 幂等: 游标已推进, 同一批不再重复入账
downstream_repo.fetch_filled_orders = lambda **kw: []
r2 = ls.replay_fills()
assert r2["fills"] == 0 and fake.positions["600000.SH"]["total_qty"] == 6000
finally:
downstream_repo.fetch_filled_orders = orig
@case("账本服务·卖出回放按核销次序 + 摊薄成本下降")
def _():
from app.services import ledger_service as ls
from app.repo import downstream_repo
fake = install_fakes(prices={"600000.SH": 12.0})
fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=6000, open_price=10.0,
open_date="2026-07-01")
fake.insert_lot(ts_code="600000.SH", lot_type="ADD", qty=3000, open_price=11.0,
open_date="2026-07-20")
fake.update_position("600000.SH", total_qty=9000, avail_qty=9000)
fills = [{"order_id": 201, "ts_code": "600000.SH", "side": "sell", "qty": 3000,
"price": 12.0, "done_time": "2026-07-27 10:00:00"}]
orig = downstream_repo.fetch_filled_orders
try:
downstream_repo.fetch_filled_orders = lambda **kw: fills
r = ls.replay_fills()
assert r["ok"], r
add_lot = [l for l in fake.lots if l["lot_type"] == "ADD"][0]
assert add_lot["qty"] == 0 and add_lot["status"] == "CLOSED" # ADD 先核销
base_lot = [l for l in fake.lots if l["lot_type"] == "BASE"][0]
assert base_lot["qty"] == 6000 # 底仓保留
p = fake.positions["600000.SH"]
assert p["total_qty"] == 6000 and p["avail_qty"] == 6000
# 摊薄成本 = (60000+33000-36000)/6000 = 9.5
assert abs(float(p["avg_cost"]) - 9.5) < 1e-6, p["avg_cost"]
finally:
downstream_repo.fetch_filled_orders = orig
@case("账本服务·对账以下游为准 + 连续不一致升级")
def _():
from app.services import ledger_service as ls
from app.repo import downstream_repo
fake = install_fakes(prices={"600000.SH": 10.0})
fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=1000, open_price=10.0,
open_date="2026-07-01")
fake.update_position("600000.SH", total_qty=1000, avail_qty=1000)
orig = downstream_repo.fetch_positions
try:
downstream_repo.fetch_positions = lambda: {
"rows": [{"ts_code": "600000.SH", "qty": 1500, "avail_qty": 1500}],
"columns": {"code": "stock_code", "qty": "current_qty"}, "raw_count": 1}
r = ls.reconcile()
assert r["ok"] and len(r["diffs"]) == 1 and r["diffs"][0]["delta"] == 500, r
assert fake.positions["600000.SH"]["total_qty"] == 1500 # 以下游为准
assert any(l["lot_type"] == "RECON" for l in fake.lots) # 修正留痕
assert any(x.get("action") == "RECON" for x in fake.ledger)
assert r["severity"] == "WARN"
for _i in range(2): # 连续第 3 日 → ERROR
downstream_repo.fetch_positions = lambda: {
"rows": [{"ts_code": "600000.SH", "qty": 1500 + 100 * (_i + 1)}],
"columns": {"qty": "current_qty"}, "raw_count": 1}
r = ls.reconcile()
assert r["severity"] == "ERROR", r
finally:
downstream_repo.fetch_positions = orig
@case("账本服务·日报生成 (关注区 + 次日除权检测快照)")
def _():
from app.services import ledger_service as ls
fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
"avg_cost": 9.0}])
rep = ls.build_daily_report()
assert rep["totals"]["portfolio_mv"] == 60_000.0
assert rep["snapshot"]["600000.SH"] == {"qty": 6000, "price": 10.0}
assert any(a["type"] == "行业约束停用" for a in rep["attention"]), rep["attention"]
assert fake.reports[rep["ymd"]]["ymd"] == rep["ymd"]
@case("账本服务·除权检测走通 (10送10 → 批次按比例调整)")
def _():
from app.services import ledger_service as ls
fake = install_fakes(prices={"600000.SH": 5.0}, params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 2000,
"avg_cost": 10.0}])
fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=2000, open_price=10.0,
open_date="2026-07-01")
fake.upsert_report(20260726, {"snapshot": {"600000.SH": {"qty": 1000, "price": 10.0}}})
r = ls.detect_and_apply_ex_right()
assert r["ex_rights"] and abs(r["ex_rights"][0]["ratio"] - 2.0) < 1e-6, r
lot = fake.lots[0]
assert lot["qty"] == 4000 and abs(lot["open_price"] - 5.0) < 1e-6, lot
@case("盘前准备·T+1 可卖重置")
def _():
from app.services import ledger_service as ls
fake = install_fakes(prices={"600000.SH": 10.0},
positions=[{"ts_code": "600000.SH", "total_qty": 6000, "avail_qty": 0,
"avg_cost": 9.0}])
r = ls.premarket()
assert r["avail_reset"] >= 1, r
assert fake.positions["600000.SH"]["avail_qty"] == 6000
# ---------------------------------------------------------------- runner
def main():
passed, failed = 0, 0
for name, fn in RESULTS:
try:
fn()
print(f" PASS {name}")
passed += 1
except Exception:
print(f" FAIL {name}")
traceback.print_exc()
failed += 1
print("-" * 60)
if failed:
print(f"FAILED: {failed} / {passed + failed}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()