第二批: 命令状态机/方案生成器/回放对账/管理页面/调度器骨架
This commit is contained in:
parent
ec8fb48977
commit
b3fdb4a4e6
|
|
@ -9,3 +9,6 @@ logs/
|
|||
# PyCharm 工程文件目前已跟踪; 如决定不入库, 取消下行注释并执行:
|
||||
# git rm -r --cached .idea && git commit -m "untrack .idea"
|
||||
#.idea/
|
||||
|
||||
# 本地临时: Cowork 会话搬运产物, 可整目录删除
|
||||
_to_delete/
|
||||
|
|
|
|||
82
README.md
82
README.md
|
|
@ -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,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 任务命令 —— 有生命周期的一次性使命, 状态 PENDING→PLANNING→EXECUTING→DONE/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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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)))
|
||||
|
|
@ -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,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,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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
321
app/web/main.py
321
app/web/main.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue