diff --git a/README.md b/README.md index 24d36b0..8c9fb0f 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ app/ command_spec.py 命令目录(A/B/C 全量 27 类) / 参数校验 / 双状态机 / 冲突识别 planner.py 方案生成器: 降仓凑额四档 / 升仓 / 建仓 / 清仓 / 行业 / 撤单 recon.py 成交认领与入账映射 / 对账差异与修正 / 除权检测 / T+1 可用量 + rule_gate.py 规则闸终检: 上限/一手/可卖/冻结/刹车/行业/不追高 (减持只放行不阻拦) + exec_timing.py 择时实现B: 分日配额 / 分笔 / 买卖出手判定 / 14:45 兜底 / 窗口收口 tradedays.py 交易日历: 调度守卫与执行窗口计算 db/session.py 三库连接 + **严格单表访问守卫** (JOIN/逗号连表/跨表子查询一律拒绝) repo/ 单表数据访问: pms_repo (自有 10 表) / downstream_repo (下游只读三表) @@ -32,6 +34,8 @@ app/ param_store.py 运行参数中心 (表值优先于 settings 初值, 页面调参即时生效) portfolio.py 组合快照 (账本+行情+行业 → 方案/规则闸/页面的统一输入) command_service.py 命令下达→校验→冲突→生效/规划→进度推进→撤销 + executor.py 方案→指令→分日出手→窗口收口 (规则闸与择时的编排落点) + dispatcher.py 下发通道三适配器: shadow(默认) / plan_x / channel_y ledger_service.py 成交回放 / 对账 / 除权 / 盘前 / 日终结算 / 运营日报 market.py 行情 (Redis db13) 与参考位 (决策系统主口径 + 兜底自算) industry.py 行业划分可插拔适配器 (custom_table / gp_stock_category / 停用) @@ -41,7 +45,8 @@ scripts/ run_tests.py 一次跑完全部单测 (见下方「Docker 部署」) test_core_units.py 仓位与安全垫核心逻辑 14 例 test_batch2_units.py 命令 / 方案 / 回放对账 纯逻辑 35 例 - test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 18 例 + test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 18 例 + test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 25 例 init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练) check_db.py 实机连通性与表结构自检 (需真实 .env) ``` @@ -91,7 +96,7 @@ git pull && docker compose build && docker compose up -d | 盘前准备 | 交易日 08:50 | T+1 可卖重置 / 参考位取数 / 刹车结算 | ✅ | | 命令轮询 | 每 1 分钟(全天) | 新命令解析 → 方案生成 → 状态机推进 | ✅ | | 成交回放 | 交易时段每 5 分钟 | `trading_order` 增量回放 + 盘中轻对账 | ✅ | -| 盘中执行 | 交易时段每 1 分钟 | 择时出手 + 自主提议扫描 | 🔜 下一批(当前只做在途指令超时收口) | +| 盘中执行 | 交易时段每 1 分钟 | 方案转指令 → 择时出手(规则闸终检 → 下发 → 记子单) | ✅(自主提议扫描待下一批) | | 信号消化 | 交易时段每 1 分钟 | 订阅决策系统盘中信号 | 🔜 下一批 | | T 仓平回 | 14:50 | 做T强制平回 | 🔜 二期(现只自证 T 仓为 0) | | 日终结算 | 15:10 | 除权检测 / 全量对账 / 安全垫 / 命令进度日结 | ✅ | @@ -99,11 +104,21 @@ git pull && docker compose build && docker compose up -d 调度器三条守卫:交易日守卫、故障即守成(任务内异常吞掉记 ERROR,绝不因调度异常产生新指令)、全局暂停执行(休假模式下除对账与日报外全部跳过)。 +## 指令下发通道(设计 §9,参数 `PMS_DISPATCH_MODE`,默认 `shadow`) + +| 模式 | 行为 | 什么时候用 | +|---|---|---| +| `shadow`(默认) | 指令照常过规则闸、照常置 DISPATCHED,但**不写下游**。你在 QMT 侧人工执行,成交由回放按 FIFO 认领回账本 | 通道协商完成前的一期口径(设计 §9:命令类降仓/清仓由用户人工执行、PMS 记账跟踪) | +| `plan_x` | 买入写 `trading_buy_plan`(`is_active=6` 待挂单、署名 `approved_by='pms'`);**卖出无对应通道,自动退回影子** | QMT 侧确认沿用旧通道过渡时 | +| `channel_y` | 写统一指令表 `pms_order_request`(DDL 见需求清单 B1) | B1 协商落地、表建好之后 | + +影子模式下的完整闭环:页面下命令 → 方案落表 → 方案转指令 → 择时按日配额给出「今天该出多少、什么价」→ 你照着在 QMT 下单 → 5 分钟一次的回放把成交认领回批次账本 → 命令进度自动推进。整条链路除了「人手下单」这一步,其余与实盘接管后完全一致。 + ## 已实现 / 待开发 -**已实现**:建表 DDL;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);管理页面四块 + 运维/日报抽屉;调度器骨架;单测 67 例。 +**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);**规则闸终检**;**择时执行器实现 B**(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口);**三模式下发通道**;管理页面四块 + 运维/日报抽屉;调度器八个调度位;单测 92 例。 -**待开发(下一批)**:择时执行器(实现 B 内置保守择时 + 分日配额 + 子指令)、规则闸终检与指令下发通道、动作引擎(FILL/ADD/DCA/TRIM 自主提议扫描)、决策系统信号订阅与研判闸对接、T0 做T(二期)。 +**待开发(下一批)**:动作引擎(FILL/ADD/DCA/TRIM 自主提议扫描 + 提议入队)、决策系统信号订阅(风控 SELL / 止盈 / 反转 → 卖出方案)、研判闸对接与择时实现 A、T0 做T(二期)。 **待外部协商**:`QMT_INTERFACE_REQUIREMENTS.md` 的 A/B/C/D 各项——尤其 A1(`trading_position` 完整 DDL 与可用数量列)、A2(`trading_order` 状态枚举与**来源标识**)、B1(统一指令通道)。在来源标识到位前,回放按「同股同向 + 下发早于成交 + FIFO」贪心认领,认领不上即判外部成交并告警;持仓数量列用候选名探测,探测结果可经页面「运维 → 导出下游表结构」查看,也是回填 D1 的现成材料。 diff --git a/app/core/exec_timing.py b/app/core/exec_timing.py new file mode 100644 index 0000000..721d275 --- /dev/null +++ b/app/core/exec_timing.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +""" +择时执行器 · 实现B「内置保守择时」(纯逻辑, 无外部依赖, 可单测) +================================================================ +设计 POSITION_MGMT_DESIGN.md §8。实现A (委托决策系统盘中择时研判) 走同一个 +`decide()` 接口, 研判接通后在 services 层换实现即可, 本模块是兜底也是一期主力。 + +规则原文与落点: + 每日配额 = 剩余量 ÷ 剩余窗口天数, 向上取整到一手 → daily_quota() + 卖出: 避开开盘 30 分钟; 现价 ≥ 当日均价(VWAP) 时分笔卖 → decide() side=sell + 14:45 未完成 → 现价 × 0.998 限价兜底 + 买入: 现价 ≤ 当日均价或进入回踩带时买; 当日涨幅 > 5% 停止买入 (不追高) + 窗口末日 14:45 强制限价完成或按命令属性作废 + 停牌/一字板当日跳过顺延; 窗口耗尽未完成 → 命令置「部分完成」并告警 + +时点一律用「分钟数」比较 (hm_to_min), 免去跨时区与字符串比较的坑。 +""" +from __future__ import annotations + +from app.core.sizer import LOT + +OPEN_MIN = 9 * 60 + 30 # 09:30 开盘 +CLOSE_MIN = 15 * 60 # 15:00 收盘 +LUNCH_START = 11 * 60 + 30 +LUNCH_END = 13 * 60 + +# decide() 的动作词 +ACT_FIRE = "FIRE" # 出手 +ACT_WAIT = "WAIT" # 条件未到, 本轮不动 +ACT_SKIP = "SKIP" # 当日跳过 (停牌/一字板), 顺延 +ACT_STOP = "STOP" # 本日不再出手 (如买入触发不追高) + + +def hm_to_min(hm) -> int: + """"14:45" / datetime / (h, m) → 当日分钟数。""" + if hm is None: + return 0 + if isinstance(hm, (tuple, list)): + return int(hm[0]) * 60 + int(hm[1]) + if hasattr(hm, "hour"): + return hm.hour * 60 + hm.minute + s = str(hm).strip() + if ":" in s: + h, m = s.split(":")[:2] + return int(h) * 60 + int(m) + return int(s) + + +def in_session(now_min: int) -> bool: + return (OPEN_MIN <= now_min <= LUNCH_START) or (LUNCH_END <= now_min <= CLOSE_MIN) + + +def daily_quota(remaining_qty: int, tdays_left: int, *, total_qty: int = None, + lot: int = LOT, allow_odd_tail: bool = False) -> int: + """当日配额 = 剩余量 ÷ 剩余交易日, 向上取整到一手。 + + * 最后一日 (tdays_left ≤ 1) 或剩余不足一手 → 全部剩余 (含零股尾巴)。 + * allow_odd_tail=True (整票清仓) 时不做整百取整, 零股一并出。 + """ + r = max(0, int(remaining_qty or 0)) + if r <= 0: + return 0 + d = max(1, int(tdays_left or 1)) + if d <= 1 or r <= lot: + return r + raw = r / d + q = int(-(-raw // lot)) * lot # 向上取整到一手 + q = min(q, r) + if not allow_odd_tail and q % lot and q != r: + q = (q // lot) * lot + # 若本次取整后剩下不足一手的尾巴, 并进本次一起出, 免得最后一天剩 30 股卡住 + if 0 < r - q < lot: + q = r + return max(q, 0) if q > 0 else r + + +def slice_qty(quota: int, slices: int = 1, lot: int = LOT) -> list: + """把当日配额切成 N 笔 (设计「分笔卖出配额」)。最后一笔兜底吃掉余数。""" + quota = max(0, int(quota or 0)) + n = max(1, int(slices or 1)) + if quota <= 0: + return [] + if n == 1 or quota <= lot: + return [quota] + per = int(quota / n / lot) * lot + if per <= 0: + return [quota] + out = [per] * (n - 1) + out.append(quota - per * (n - 1)) + return [q for q in out if q > 0] + + +def decide(*, side: str, now, day: dict, params: dict, is_last_day: bool, + fired_today: int = 0, quota: int = 0) -> dict: + """单条指令在「此刻」该不该出手。 + + day: {price, vwap, halted, limit_up, limit_down, day_chg_from_open, support} + params: {sell_avoid_open_min, buy_halt_dayup, eod_force_time, eod_force_discount} + 返回 {"action", "qty_hint", "limit_price", "reason", "forced"} + """ + now_min = hm_to_min(now) + price = float(day.get("price") or 0) + vwap = float(day.get("vwap") or 0) + eod_min = hm_to_min(params.get("eod_force_time") or "14:45") + disc = float(params.get("eod_force_discount") or 0.998) + left = max(0, int(quota) - int(fired_today)) + + def out(action, reason, limit=None, forced=False): + return {"action": action, "qty_hint": left, "limit_price": limit, + "reason": reason, "forced": forced} + + if price <= 0: + return out(ACT_SKIP, "取不到现价, 当日跳过") + if day.get("halted"): + return out(ACT_SKIP, "停牌, 当日跳过顺延") + if left <= 0: + return out(ACT_WAIT, "当日配额已出完") + if not in_session(now_min): + return out(ACT_WAIT, "非交易时段") + + if side == "sell": + if day.get("limit_down") and not is_last_day: + return out(ACT_SKIP, "跌停一字板, 当日跳过顺延") + avoid = int(params.get("sell_avoid_open_min") or 30) + if now_min < OPEN_MIN + avoid: + return out(ACT_WAIT, f"避开开盘 {avoid} 分钟 (至 {_fmt(OPEN_MIN + avoid)})") + if now_min >= eod_min: + return out(ACT_FIRE, f"{_fmt(eod_min)} 兜底: 限价 = 现价×{disc}", + limit=round(price * disc, 2), forced=True) + if vwap > 0 and price >= vwap: + return out(ACT_FIRE, f"现价 {price} ≥ 当日均价 {vwap}, 分笔卖出配额", + limit=round(price * disc, 2)) + return out(ACT_WAIT, f"现价 {price} < 当日均价 {vwap or '—'}, 等更好的价") + + if side == "buy": + if day.get("limit_up"): + return out(ACT_SKIP, "涨停一字板, 当日跳过顺延") + dayup = day.get("day_chg_from_open") + cap = float(params.get("buy_halt_dayup") or 0.05) + if dayup is not None and float(dayup) > cap: + return out(ACT_STOP, f"当日涨幅 {float(dayup):.2%} > {cap:.0%}, 停止买入 (不追高)") + premium = round(2 - disc, 4) # 买入兜底与卖出对称: 0.998 → 1.002 + if now_min >= eod_min: + if is_last_day: + return out(ACT_FIRE, f"窗口末日 {_fmt(eod_min)} 强制完成: 限价 = 现价×{premium}", + limit=round(price * premium, 2), forced=True) + return out(ACT_WAIT, f"{_fmt(eod_min)} 后不新开买单, 顺延次日") + support = float(day.get("support") or 0) + if vwap > 0 and price <= vwap: + return out(ACT_FIRE, f"现价 {price} ≤ 当日均价 {vwap}, 买入配额", + limit=round(price * premium, 2)) + if support > 0 and price <= support * 1.01: + return out(ACT_FIRE, f"现价 {price} 进入回踩带 (支撑 {support}), 买入配额", + limit=round(price * premium, 2)) + return out(ACT_WAIT, f"现价 {price} > 当日均价 {vwap or '—'} 且未回踩, 等回调") + + return out(ACT_SKIP, f"方向 {side!r} 非法") + + +def _fmt(m: int) -> str: + return f"{m // 60:02d}:{m % 60:02d}" + + +def window_verdict(*, remaining_qty: int, tdays_left: int, is_command: bool) -> dict: + """窗口耗尽时的收口 (设计 §8 末句)。 + + 还有剩余且窗口已尽 → 命令置「部分完成」并告警; 命令类保留人工兜底提示, + 自主类直接作废。 + """ + if int(remaining_qty or 0) <= 0: + return {"verdict": "DONE", "note": "已足额完成"} + if int(tdays_left or 0) > 0: + return {"verdict": "RUNNING", "note": f"窗口内剩余 {tdays_left} 交易日"} + return {"verdict": "PARTIAL" if is_command else "EXPIRED", + "note": f"窗口耗尽仍剩 {remaining_qty} 股 —— " + + ("命令置部分完成并告警, 请在页面决定顺延或人工完成" + if is_command else "自主指令作废")} diff --git a/app/core/rule_gate.py b/app/core/rule_gate.py new file mode 100644 index 0000000..07a92c5 --- /dev/null +++ b/app/core/rule_gate.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +""" +规则闸 · 一级关口 (纯逻辑, 无外部依赖, 可单测) +================================================ +设计 POSITION_MGMT_DESIGN.md §7: + 「全指令必过, PMS 纯代码。下发前用最新数据终检 —— 命令参数上限、一手检查、 + T+1 可卖、冻结状态、刹车状态、行业集中度、(买入)不追高。 + 任一不过或必要输入缺失 → 拒绝 (宁可不动), 未通过项落账本。」 + +两条容易搞反的口径, 在此钉死: + 1. **冻结与刹车只挡增持, 不挡减持** —— 卖出、止损任何时候都放行 (设计 §3.1/§5)。 + 2. **命令驱动不受刹车限制** —— 刹车是给自主提议踩的, 用户命令优先, 只提示不拦 + (设计 §5「命令类不受限, 执行前提示」)。上限/一手/可卖量这类硬约束则一视同仁。 + +输出结构固定为 {"passed", "failed", "warnings", "hard_numbers"}, 直接喂 pms_action_ledger +的 failed_checks_json / hard_numbers_json —— 「拒了的后来涨了多少」靠这份留痕做判分。 +""" +from __future__ import annotations + +from app.core.sizer import LOT +from app.core.planner import check_all_caps + +BUY, SELL = "buy", "sell" + + +def _num(v, default=0.0): + try: + return float(v) + except (TypeError, ValueError): + return default + + +def check(*, side: str, action: str, qty: int, price: float, ctx: dict) -> dict: + """指令下发前终检。 + + ctx 由调用方备齐 (services/executor.py): + position {total_qty, avail_qty, frozen_reason} 该股账本快照 + caps check_all_caps 所需组合上下文 (仅买入用) + day {price, vwap, ma5, day_chg_from_open, halted, limit_up, limit_down} + params {no_chase_ma5, buy_halt_dayup, sector_source_ready} + flags {buy_halt, exec_halt, brake_active, blacklisted, is_command} + """ + pos = ctx.get("position") or {} + day = ctx.get("day") or {} + prm = ctx.get("params") or {} + flg = ctx.get("flags") or {} + is_cmd = bool(flg.get("is_command")) + side = str(side or "").lower() + + failed, warns = [], [] + qty = int(qty or 0) + price = _num(price) + total_qty = int(pos.get("total_qty") or 0) + avail_qty = int(pos.get("avail_qty") or 0) + + hard = {"side": side, "action": action, "qty": qty, "price": price, + "total_qty": total_qty, "avail_qty": avail_qty, + "frozen_reason": pos.get("frozen_reason") or "NONE", + "day_chg_from_open": day.get("day_chg_from_open"), + "vwap": day.get("vwap"), "ma5": day.get("ma5"), "is_command": is_cmd} + + # ---- 通用: 必要输入缺失一律拒绝 (宁可不动) ---- + if qty <= 0: + failed.append(f"QTY_INVALID: 数量 {qty} 非法") + if price <= 0: + failed.append("PRICE_MISSING: 取不到现价, 无法定限价与校验 (宁可不动)") + if day.get("halted"): + failed.append("HALTED: 停牌, 当日跳过顺延") + if flg.get("exec_halt"): + failed.append("EXEC_HALT: 全局暂停执行 (休假模式) 生效中") + + if side == SELL: + _check_sell(failed, warns, qty, total_qty, avail_qty, day) + elif side == BUY: + _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard) + else: + failed.append(f"SIDE_INVALID: 方向 {side!r} 非法") + + return {"passed": not failed, "failed": failed, "warnings": warns, "hard_numbers": hard} + + +def _check_sell(failed, warns, qty, total_qty, avail_qty, day): + """减持方向: 冻结/刹车/上限一概不拦, 只看「卖得出去吗」。""" + if qty > total_qty: + failed.append(f"OVER_SELL: 卖出 {qty} > 持仓 {total_qty}") + elif qty > avail_qty: + failed.append(f"T1_UNAVAILABLE: 卖出 {qty} > T+1 可卖 {avail_qty} (当日买入次日才可卖)") + # 清仓允许卖零股 (A股规则); 部分减持必须整百 + if qty % LOT != 0 and qty != total_qty: + failed.append(f"LOT_INVALID: 部分减持 {qty} 股非整百 (零股只能在清仓时一次性卖出)") + if day.get("limit_down"): + warns.append("LIMIT_DOWN: 跌停封板, 大概率成交不了, 已按限价挂出") + + +def _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard): + """增持方向: 全部硬约束都要过。""" + if qty % LOT != 0: + failed.append(f"LOT_INVALID: 买入 {qty} 股非整百") + if (pos.get("frozen_reason") or "NONE") != "NONE": + failed.append(f"FROZEN: 该股处于 {pos['frozen_reason']}, 禁止增持") + if flg.get("buy_halt"): + failed.append("BUY_HALT: 全局暂停买入生效中") + if flg.get("blacklisted"): + failed.append("BLACKLIST: 该股在黑名单 (永不买入)") + if flg.get("brake_active"): + if is_cmd: + warns.append("BRAKE_ACTIVE: 组合刹车中 —— 命令驱动不受限, 仅提示") + else: + failed.append("BRAKE_ACTIVE: 组合刹车中, 自主增持暂停") + + if day.get("limit_up"): + failed.append("LIMIT_UP: 涨停封板, 不追买") + + # 不追高: 当日涨幅 与 距 MA5 两道 + dayup = day.get("day_chg_from_open") + cap_dayup = _num(prm.get("buy_halt_dayup"), 0.05) + if dayup is not None and _num(dayup) > cap_dayup: + failed.append(f"NO_CHASE_DAYUP: 当日涨幅 {_num(dayup):.2%} > 上限 {cap_dayup:.0%}") + ma5 = _num(day.get("ma5")) + cap_ma5 = _num(prm.get("no_chase_ma5"), 0.06) + if ma5 > 0: + gap = price / ma5 - 1 + hard["ma5_gap"] = round(gap, 4) + if gap > cap_ma5: + failed.append(f"NO_CHASE_MA5: 距 MA5 {gap:.2%} > 上限 {cap_ma5:.0%}") + else: + warns.append("MA5_MISSING: 取不到 MA5, 距均线一项未校验") + + caps = ctx.get("caps") + if caps: + bad = check_all_caps(ts_code=ctx.get("ts_code") or "-", add_amount=qty * price, ctx=caps) + failed.extend(bad) + if not caps.get("sector_source_ready", True): + warns.append("SECTOR_OFF: 行业数据源未配置, 行业集中度未校验 (页面已提示)") + else: + failed.append("CAPS_MISSING: 缺组合上下文, 无法校验上限 (宁可不动)") + + +def summarize(results: list) -> dict: + """一批指令的闸门统计 (日报关注区用)。""" + passed = [r for r in results if r.get("passed")] + reasons = {} + for r in results: + for f in r.get("failed") or []: + k = f.split(":")[0] + reasons[k] = reasons.get(k, 0) + 1 + return {"total": len(results), "passed": len(passed), + "rejected": len(results) - len(passed), "by_reason": reasons} diff --git a/app/scheduler.py b/app/scheduler.py index b0e9288..e5035c3 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -132,10 +132,14 @@ def replay_fills(): @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"} + """盘中执行: 方案转指令 → 择时出手 (规则闸终检 → 下发 → 记子单)。 + + 自主提议扫描 (FILL/ADD/DCA/TRIM 动作引擎) 仍为下一批交付。 + """ + from app.services import executor + r = {"materialized": executor.materialize_plans()} + r.update(executor.run_tick()) + return r @celery_app.task(name="pms.signal_digest") @@ -160,8 +164,18 @@ def t0_close(): @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() + from app.services import executor, ledger_service + out = ledger_service.daily_settle() + try: + out["steps"]["windows"] = executor.sweep_windows() # 窗口耗尽收口 + 方案成交回写 + except Exception as e: + out.setdefault("errors", []).append(f"窗口收口失败: {e}") + out["ok"] = False + try: + out["steps"]["expired_instructions"] = ledger_service.expire_stale_instructions() + except Exception as e: + out.setdefault("errors", []).append(f"指令过期处理失败: {e}") + return out @celery_app.task(name="pms.daily_report") diff --git a/app/services/command_service.py b/app/services/command_service.py index 0a96016..baf4e2d 100644 --- a/app/services/command_service.py +++ b/app/services/command_service.py @@ -218,7 +218,9 @@ def plan_command(cmd: dict) -> dict: "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", + # GATED = 建仓的回踩补足/盈利加仓批, 不随命令立即执行, + # 等动作引擎按条件解锁后才转 PENDING (设计 §6) + "status": "GATED" if it.get("gated") else "PENDING", "reason": it.get("reason")}) try: if rows: diff --git a/app/services/dispatcher.py b/app/services/dispatcher.py new file mode 100644 index 0000000..cdf26c1 --- /dev/null +++ b/app/services/dispatcher.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +""" +指令下发通道 (设计 §9 权限移交的落点) +====================================== +三个适配器, 由参数 `PMS_DISPATCH_MODE` 切换, **默认 shadow**: + + shadow 影子运行 —— 只记账不下发。指令照常过规则闸、照常置 DISPATCHED, + 等用户人工在 QMT 侧执行, 成交由回放按 FIFO 认领回来。 + 这是设计 §9 的一期口径:「通道未通前由用户人工执行、PMS 记账跟踪」。 + plan_x 过渡兼容 —— 买入沿用 `trading_buy_plan` (置 is_active=6 待挂单, + 署名 approved_by='pms')。**卖出无对应通道**, 自动退回 shadow。 + 列清单按下游现表推断, QMT 侧确认前请勿在实盘开启。 + channel_y 推荐方案 —— 写统一指令表 `pms_order_request` (DDL 见需求清单 B1)。 + 该表归属与形态仍在协商 (B1.1/B1.7), 表未建时会明确报错而非静默吞掉。 + +无论哪种模式, **指令先落 pms_instruction 再下发** (先记账后动作), 本模块只负责 +「往下游递一手」, 不改指令状态 —— 状态由 executor 统一推进。 +""" +from __future__ import annotations + +import logging +from datetime import datetime + +from app.db.session import execute +from app.services import param_store + +logger = logging.getLogger("pms.dispatch") + +MODE_SHADOW, MODE_PLAN_X, MODE_CHANNEL_Y = "shadow", "plan_x", "channel_y" +MODES = (MODE_SHADOW, MODE_PLAN_X, MODE_CHANNEL_Y) + + +def mode() -> str: + m = (param_store.get("PMS_DISPATCH_MODE", MODE_SHADOW) or MODE_SHADOW).strip() + return m if m in MODES else MODE_SHADOW + + +def describe() -> dict: + m = mode() + return {"mode": m, "shadow": m == MODE_SHADOW, "modes": list(MODES), + "hint": { + MODE_SHADOW: "影子运行: 指令只记账不下发, 请在 QMT 侧人工执行, " + "成交由回放自动认领回账本", + MODE_PLAN_X: "过渡通道: 买入写 trading_buy_plan(is_active=6), 卖出退回影子", + MODE_CHANNEL_Y: "统一通道: 写 pms_order_request, 由下游轮询执行", + }[m]} + + +def dispatch(*, instruction_id: str, ts_code: str, side: str, qty: int, limit_price=None, + valid_until=None, stock_name=None) -> dict: + """递一手给下游。返回 {ok, ref, mode, note, error}; ok=False 时 executor 不改指令状态。""" + m = mode() + try: + if m == MODE_CHANNEL_Y: + return _channel_y(instruction_id, ts_code, side, qty, limit_price, valid_until) + if m == MODE_PLAN_X and str(side).lower() == "buy": + return _plan_x(instruction_id, ts_code, qty, limit_price, stock_name) + if m == MODE_PLAN_X: + r = _shadow(instruction_id, side) + r["note"] = "plan_x 无卖出通道, 本单退回影子运行 (待 QMT B1 落地)" + return r + return _shadow(instruction_id, side) + except Exception as e: + logger.exception("下发失败 %s", instruction_id) + return {"ok": False, "ref": None, "mode": m, "note": "", + "error": f"{type(e).__name__}: {e}"} + + +def _shadow(instruction_id: str, side: str) -> dict: + return {"ok": True, "ref": f"manual:{instruction_id}", "mode": MODE_SHADOW, + "note": f"影子运行: 请在 QMT 侧人工{'买入' if side == 'buy' else '卖出'}, " + f"成交由回放认领", "error": None} + + +def _channel_y(instruction_id, ts_code, side, qty, limit_price, valid_until) -> dict: + now = datetime.now() + execute( + "INSERT INTO pms_order_request (instruction_id, ts_code, side, qty, limit_price, " + "valid_until, source, status, cancel_flag, create_time, update_time) VALUES " + "(:iid, :code, :side, :qty, :lp, :vu, 'pms', 'NEW', 0, :ts, :ts)", + {"iid": instruction_id, "code": ts_code, "side": str(side).lower(), "qty": int(qty), + "lp": limit_price, "vu": valid_until or now, "ts": now}) + return {"ok": True, "ref": instruction_id, "mode": MODE_CHANNEL_Y, + "note": "已写入 pms_order_request, 等下游轮询执行", "error": None} + + +def _plan_x(instruction_id, ts_code, qty, limit_price, stock_name) -> dict: + """买入走上游既有计划表。amount 由 数量×限价 反算 (下游按 buy_amount 挂单)。""" + px = float(limit_price or 0) + if px <= 0: + return {"ok": False, "ref": None, "mode": MODE_PLAN_X, "note": "", + "error": "plan_x 通道要求限价 (下游按 target_price 挂单)"} + now = datetime.now() + execute( + "INSERT INTO trading_buy_plan (stock_code, stock_name, target_price, buy_amount, " + "is_active, trading_time, create_time, update_time, approved_by, change_reason) " + "VALUES (:code, :name, :px, :amt, 6, :ts, :ts, :ts, 'pms', :rsn)", + {"code": ts_code, "name": stock_name or ts_code, "px": px, + "amt": round(qty * px, 2), "ts": now, + "rsn": f"PMS 指令 {instruction_id}"}) + return {"ok": True, "ref": f"buy_plan:{instruction_id}", "mode": MODE_PLAN_X, + "note": "已写 trading_buy_plan(is_active=6) 待下游挂单", "error": None} + + +def cancel(*, instruction_id: str, dispatch_ref=None) -> dict: + """请求撤单。shadow/plan_x 无撤单语义, 只回执由 executor 置本地状态。""" + m = mode() + if m != MODE_CHANNEL_Y: + return {"ok": True, "mode": m, "note": "本模式无下游撤单动作, 仅本地置撤销"} + try: + n = execute("UPDATE pms_order_request SET cancel_flag = 1, update_time = :ts " + "WHERE instruction_id = :iid AND status IN ('NEW', 'ACCEPTED', 'EXECUTING')", + {"iid": instruction_id, "ts": datetime.now()}) + return {"ok": True, "mode": m, "note": f"已置 cancel_flag, 影响 {n} 行"} + except Exception as e: + return {"ok": False, "mode": m, "error": f"{type(e).__name__}: {e}"} diff --git a/app/services/executor.py b/app/services/executor.py new file mode 100644 index 0000000..49a26c7 --- /dev/null +++ b/app/services/executor.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- +""" +执行编排: 方案 → 指令 → 分日出手 → 回执 (设计 §7 / §8 / §9 的落地) +===================================================================== +纯逻辑在 core/rule_gate.py (规则闸) 与 core/exec_timing.py (择时实现B), 本模块负责 +取数、落表与状态推进。三个入口: + + materialize_plans() 把命令方案里「可执行」的条目转成 pms_instruction (先记账后动作)。 + gated 方案 (建仓的 FILL/ADD 批) 状态为 GATED, 由动作引擎解锁, 此处不动。 + run_tick() 盘中每分钟一跳: 配额 → 择时判定 → 规则闸终检 → 下发 → 记子单。 + sweep_windows() 窗口耗尽收口: 命令类置部分完成并告警, 自主类作废。 + +指令与子单的关系: 一条 pms_instruction 承载一个方案条目的**总量**, 每日出手记一条子单到 +progress_json.children[]; exec_qty 由回放认领回填 (账本以下游成交为准, 不拿下发量当成交量)。 +""" +from __future__ import annotations + +import logging +from datetime import datetime + +from app.core import command_spec as cs +from app.core import exec_timing as et +from app.core import rule_gate +from app.core import tradedays as td +from app.repo import pms_repo +from app.services import (command_service, dispatcher, industry, market, param_store, + portfolio) + +logger = logging.getLogger("pms.exec") + +# 指令状态 +ST_PROPOSED, ST_RULE_PASSED, ST_DISPATCHED = "PROPOSED", "RULE_PASSED", "DISPATCHED" +ST_CONFIRMED, ST_REJECTED, ST_EXPIRED, ST_CANCELLED = ("CONFIRMED", "REJECTED", "EXPIRED", + "CANCELLED") +LIVE = (ST_PROPOSED, ST_RULE_PASSED, ST_DISPATCHED) + +# 方案状态: GATED = 建仓的补足/加仓批, 等动作引擎按条件解锁 (DDL 注释已同步) +PLAN_PENDING, PLAN_GATED, PLAN_EXEC, PLAN_DONE = "PENDING", "GATED", "EXECUTING", "DONE" + +SELL_ACTIONS = {"EXIT", "TRIM"} +BUY_ACTIONS = {"OPEN", "FILL", "ADD", "DCA"} + + +# ================================================================ 方案 → 指令 +def materialize_plans(limit: int = 100) -> dict: + """把在途命令的可执行方案条目转成指令。幂等: 已生成过指令的方案置 EXECUTING 不再重复。""" + out = {"created": [], "skipped": [], "errors": []} + plans = pms_repo.list_plans(statuses=[PLAN_PENDING], limit=limit) + if not plans: + return out + cmd_cache = {} + ymd = td.ymd() + seq = 0 + for p in plans: + act = p.get("action") + if act == "HALT": # 撤单类在规划期已执行完毕 + pms_repo.update_plan(p["plan_id"], status=PLAN_DONE) + out["skipped"].append({"plan_id": p["plan_id"], "why": "HALT 已在规划期执行"}) + continue + side = "sell" if act in SELL_ACTIONS else ("buy" if act in BUY_ACTIONS else None) + if not side: + out["skipped"].append({"plan_id": p["plan_id"], "why": f"动作 {act} 无对应指令"}) + continue + cid = p["command_id"] + if cid not in cmd_cache: + cmd_cache[cid] = pms_repo.get_command(cid) + cmd = cmd_cache[cid] + if not cmd or cmd["status"] not in (cs.ST_EXECUTING, cs.ST_PARTIAL): + out["skipped"].append({"plan_id": p["plan_id"], + "why": f"命令状态 {cmd['status'] if cmd else '缺失'}"}) + continue + qty = int(p.get("qty") or 0) + if qty <= 0: + out["skipped"].append({"plan_id": p["plan_id"], "why": "数量为 0"}) + continue + seq += 1 + iid = cs.make_instruction_id(ymd, p["ts_code"], act, seq) + window = int((cmd.get("progress") or {}).get("window_tdays") + or param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3)) + try: + pms_repo.insert_instruction( + instruction_id=iid, origin_type="plan", origin_id=p["plan_id"], + ts_code=p["ts_code"], action=act, side=side, qty=qty, + limit_price=None, window_tdays=window, status=ST_PROPOSED, + progress={"deadline": str(p.get("deadline") or ""), "command_id": cid, + "is_command": True, "children": []}) + pms_repo.update_plan(p["plan_id"], status=PLAN_EXEC) + out["created"].append(iid) + except Exception as e: + logger.exception("方案转指令失败 %s", p["plan_id"]) + out["errors"].append(f"{p['plan_id']}: {type(e).__name__}: {e}") + return out + + +# ================================================================ 出手 tick +def run_tick(*, now=None, dry_run: bool = False) -> dict: + """盘中每分钟一跳。dry_run=True 时只算不发不落库 (页面「试算」用)。""" + now = now or datetime.now() + out = {"ok": True, "checked": 0, "fired": [], "waited": [], "rejected": [], + "errors": [], "mode": dispatcher.mode(), "dry_run": dry_run} + + instrs = [i for i in pms_repo.list_instructions(statuses=list(LIVE), limit=200) + if int(i.get("qty") or 0) > int(i.get("exec_qty") or 0)] + if not instrs: + return out + + view = portfolio.positions_view() + stock_params = command_service.effective_stock_params() + prm = view["params"] + exec_prm = { + "sell_avoid_open_min": param_store.get_int("PMS_SELL_AVOID_OPEN_MIN", 30), + "buy_halt_dayup": param_store.get_float("PMS_BUY_HALT_DAYUP", 0.05), + "eod_force_time": param_store.get("PMS_EOD_FORCE_TIME", "14:45"), + "eod_force_discount": param_store.get_float("PMS_EOD_FORCE_DISCOUNT", 0.998), + "no_chase_ma5": param_store.get_float("PMS_NO_CHASE_MA5", 0.06), + } + slices = param_store.get_int("PMS_EXEC_SLICES", 1) + brake_active = td.ymd() < param_store.get_int("PMS_BRAKE_UNTIL", 0) + ymd_today = td.ymd() + + for ins in instrs: + out["checked"] += 1 + code, side = ins["ts_code"], str(ins.get("side") or "").lower() + prog = dict(ins.get("progress") or {}) + children = list(prog.get("children") or []) + remaining = int(ins["qty"]) - int(ins.get("exec_qty") or 0) + deadline = prog.get("deadline") or "" + tdays_left = td.trade_days_left(deadline, now) if deadline else 1 + is_last = tdays_left <= 1 + + try: + day = market.day_snapshot(code) + pos = _pos_of(view, code) + quota = et.daily_quota(remaining, tdays_left, + allow_odd_tail=(ins.get("action") == "EXIT")) + fired_today = sum(int(c.get("qty") or 0) for c in children + if int(c.get("ymd") or 0) == ymd_today) + day_ctx = {**day, "support": pos.get("support_ref"), + "limit_up": _limit_up(day), "limit_down": _limit_down(day), + "halted": not day or not day.get("price")} + d = et.decide(side=side, now=now, day=day_ctx, params=exec_prm, + is_last_day=is_last, fired_today=fired_today, quota=quota) + + if d["action"] != et.ACT_FIRE: + prog["last_decision"] = {"at": now.strftime("%H:%M"), **d} + if not dry_run: + pms_repo.update_instruction(ins["instruction_id"], progress=prog) + out["waited"].append({"instruction_id": ins["instruction_id"], "code": code, + "action": d["action"], "reason": d["reason"]}) + continue + + slice_list = et.slice_qty(min(d["qty_hint"], remaining), slices) + qty = slice_list[0] if slice_list else 0 + if side == "sell": + qty = min(qty, int(pos.get("avail_qty") or 0)) + if qty <= 0: + prog["last_decision"] = {"at": now.strftime("%H:%M"), "action": et.ACT_WAIT, + "reason": "可卖量不足, 顺延"} + if not dry_run: + pms_repo.update_instruction(ins["instruction_id"], progress=prog) + out["waited"].append({"instruction_id": ins["instruction_id"], "code": code, + "action": et.ACT_WAIT, "reason": "可卖量不足, 顺延"}) + continue + + ma5 = market.get_ma5(code) if side == "buy" else None + gate = rule_gate.check( + side=side, action=ins.get("action"), qty=qty, price=day_ctx.get("price"), + ctx={"ts_code": code, "position": pos, "day": {**day_ctx, "ma5": ma5}, + "params": {**exec_prm, "sector_source_ready": view["sector_ready"]}, + "caps": portfolio.caps_ctx(view, ts_code=code) if side == "buy" else None, + "flags": {"buy_halt": prm["buy_halt"], "exec_halt": prm["exec_halt"], + "brake_active": brake_active, + "blacklisted": bool(stock_params.get(code, {}).get("black")), + "is_command": bool(prog.get("is_command"))}}) + + if not gate["passed"]: + out["rejected"].append({"instruction_id": ins["instruction_id"], "code": code, + "failed": gate["failed"]}) + if not dry_run: + pms_repo.insert_ledger( + ts_code=code, action=ins.get("action"), arbiter="rule", verdict="REJECT", + price_at=day_ctx.get("price") or 0, hard_numbers=gate["hard_numbers"], + failed_checks=gate["failed"], ref_id=ins["instruction_id"], + reason="规则闸终检未通过 (宁可不动)") + prog["last_decision"] = {"at": now.strftime("%H:%M"), "action": "REJECT", + "reason": "; ".join(gate["failed"])} + pms_repo.update_instruction(ins["instruction_id"], progress=prog) + continue + + if dry_run: + out["fired"].append({"instruction_id": ins["instruction_id"], "code": code, + "qty": qty, "limit": d["limit_price"], + "reason": d["reason"], "dry_run": True}) + continue + + res = dispatcher.dispatch( + instruction_id=_child_id(ins["instruction_id"], len(children) + 1), + ts_code=code, side=side, qty=qty, limit_price=d["limit_price"], + valid_until=now) + if not res.get("ok"): + out["errors"].append(f"{ins['instruction_id']} 下发失败: {res.get('error')}") + continue + + children.append({"ymd": ymd_today, "at": now.strftime("%H:%M:%S"), "qty": qty, + "limit": d["limit_price"], "mode": res["mode"], "ref": res["ref"], + "forced": d.get("forced", False), "reason": d["reason"]}) + prog["children"] = children + prog.setdefault("dispatched_at", now.strftime("%Y-%m-%d %H:%M:%S")) + prog["last_decision"] = {"at": now.strftime("%H:%M"), "action": et.ACT_FIRE, + "reason": d["reason"]} + pms_repo.update_instruction(ins["instruction_id"], status=ST_DISPATCHED, + limit_price=None, dispatch_ref=res["ref"], + progress=prog) + pms_repo.insert_ledger(ts_code=code, action=ins.get("action"), arbiter="rule", + verdict="PASS", price_at=day_ctx.get("price") or 0, + hard_numbers={**gate["hard_numbers"], "limit": d["limit_price"], + "mode": res["mode"]}, + ref_id=ins["instruction_id"], reason=d["reason"]) + out["fired"].append({"instruction_id": ins["instruction_id"], "code": code, + "qty": qty, "limit": d["limit_price"], "mode": res["mode"], + "note": res.get("note"), "reason": d["reason"]}) + except Exception as e: + logger.exception("出手处理失败 %s", ins.get("instruction_id")) + out["errors"].append(f"{ins.get('instruction_id')}: {type(e).__name__}: {e}") + + out["ok"] = not out["errors"] + return out + + +# ================================================================ 窗口收口 +def sweep_windows(*, now=None) -> dict: + """窗口耗尽的指令收口, 并把成交量回写方案 (命令进度据此结算)。""" + now = now or datetime.now() + out = {"expired": [], "partial": [], "synced": 0, "errors": []} + for ins in pms_repo.list_instructions(statuses=list(LIVE), limit=300): + try: + prog = dict(ins.get("progress") or {}) + exec_qty = int(ins.get("exec_qty") or 0) + remaining = int(ins["qty"]) - exec_qty + if ins.get("origin_type") == "plan" and ins.get("origin_id"): + pms_repo.update_plan(ins["origin_id"], filled_qty=exec_qty, + status=PLAN_DONE if remaining <= 0 else None) + out["synced"] += 1 + if remaining <= 0: + pms_repo.update_instruction(ins["instruction_id"], status=ST_CONFIRMED) + continue + deadline = prog.get("deadline") or "" + if not deadline: + continue + left = td.trade_days_left(deadline, now) + v = et.window_verdict(remaining_qty=remaining, tdays_left=left, + is_command=bool(prog.get("is_command"))) + if v["verdict"] == "RUNNING": + continue + prog["window_verdict"] = v + if v["verdict"] == "EXPIRED": + pms_repo.update_instruction(ins["instruction_id"], status=ST_EXPIRED, + progress=prog) + out["expired"].append(ins["instruction_id"]) + else: + pms_repo.update_instruction(ins["instruction_id"], progress=prog) + out["partial"].append({"instruction_id": ins["instruction_id"], + "remaining": remaining, "note": v["note"]}) + logger.warning("[窗口耗尽] %s 仍剩 %s 股 —— %s", ins["instruction_id"], + remaining, v["note"]) + except Exception as e: + out["errors"].append(f"{ins.get('instruction_id')}: {type(e).__name__}: {e}") + try: + command_service.refresh_progress() + except Exception as e: + out["errors"].append(f"命令进度刷新失败: {e}") + return out + + +def cancel_instruction(instruction_id: str, reason: str = "页面人工撤销") -> dict: + ins = pms_repo.get_instruction(instruction_id) + if not ins: + return {"ok": False, "error": "指令不存在"} + if ins["status"] not in LIVE: + return {"ok": False, "error": f"指令处于 {ins['status']}, 不可撤销"} + r = dispatcher.cancel(instruction_id=instruction_id, dispatch_ref=ins.get("dispatch_ref")) + pms_repo.update_instruction(instruction_id, status=ST_CANCELLED) + pms_repo.insert_ledger(ts_code=ins["ts_code"], action=ins.get("action"), arbiter="user", + verdict="REJECT", price_at=0, ref_id=instruction_id, reason=reason) + return {"ok": True, "downstream": r, "message": f"指令 {instruction_id} 已撤销"} + + +# ================================================================ 内部 +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, "avail_qty": 0, "frozen_reason": "NONE"} + + +def _child_id(instruction_id: str, n: int) -> str: + return f"{instruction_id}_D{n:02d}" + + +def _limit_up(day: dict) -> bool: + """一字板粗判: 当日最高=最低=现价 且 涨幅为正 (无涨跌停价字段时的兜底口径)。""" + if not day: + return False + hi, lo, px = day.get("high"), day.get("low"), day.get("price") + chg = day.get("day_chg_from_open") + return bool(hi and lo and px and hi == lo == px and (chg or 0) >= 0 and day.get("bars", 0) > 3) + + +def _limit_down(day: dict) -> bool: + if not day: + return False + hi, lo, px = day.get("high"), day.get("low"), day.get("price") + chg = day.get("day_chg_from_open") + return bool(hi and lo and px and hi == lo == px and (chg or 0) <= 0 and day.get("bars", 0) > 3) diff --git a/app/services/market.py b/app/services/market.py index 2bf35dc..6c1f0ed 100644 --- a/app/services/market.py +++ b/app/services/market.py @@ -167,6 +167,28 @@ def self_calc_refs(ts_code: str, base_cost=None) -> dict: "source": "self_calc", "bars": len(rows)} +_ma_cache = {"day": None, "data": {}} + + +def get_ma5(ts_code: str): + """MA5 (规则闸「不追高」用)。按日缓存 —— 因子分表是日频数据, 盘中重复查没意义。""" + today = datetime.now().strftime("%Y%m%d") + if _ma_cache["day"] != today: + _ma_cache.update({"day": today, "data": {}}) + if ts_code in _ma_cache["data"]: + return _ma_cache["data"][ts_code] + val = None + try: + rows = _factor_rows(ts_code, days=10) + closes = [float(r["close_qfq"]) for r in rows if r.get("close_qfq")] + if len(closes) >= 5: + val = round(sum(closes[-5:]) / 5, 3) + except Exception as e: + logger.warning("MA5 取数失败 [%s]: %s", ts_code, e) + _ma_cache["data"][ts_code] = val + return val + + def get_refs(ts_code: str, *, base_cost=None) -> dict: """参考位: 决策系统主口径 → 日龄超期/缺失时兜底自算 → 都拿不到返回 source=none。""" stale_days = param_store.get_int("PMS_REF_STALE_TDAYS", 3) diff --git a/app/services/param_store.py b/app/services/param_store.py index df7531a..d3efbfb 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -64,6 +64,9 @@ DESC = { "PMS_EOD_FORCE_TIME": "当日配额兜底时点", "PMS_EOD_FORCE_DISCOUNT": "兜底限价系数 (卖出)", "PMS_MIN_LOT_MERGE": "一手检查: 批次自动合并", "PMS_DISPATCH_EXPIRE_MIN": "指令下发后未被接受的过期时间 (分钟)", + "PMS_DISPATCH_MODE": "下发通道: shadow=只记账待人工 / plan_x=买入走 trading_buy_plan / " + "channel_y=写 pms_order_request", + "PMS_EXEC_SLICES": "当日配额分几笔出手", "PMS_RISK_WARN_ENTRY": "单笔敞口告警线 (占规模)", "PMS_RISK_WARN_PORTFOLIO": "组合敞口告警线", "PMS_BRAKE_DRAWDOWN": "组合刹车: 自高水位回撤", "PMS_BRAKE_DAYS": "刹车持续交易日", "PMS_STOP_ATR_MULT": "自算止损参考: 成本 − N×ATR", @@ -223,6 +226,8 @@ def _range_check(key, v): 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" + if key == "PMS_DISPATCH_MODE" and v not in ("shadow", "plan_x", "channel_y"): + return "PMS_DISPATCH_MODE 只能是 shadow / plan_x / channel_y" lo_hi = _RANGES.get(key) if lo_hi and isinstance(v, (int, float)) and not isinstance(v, bool): lo, hi = lo_hi diff --git a/app/web/main.py b/app/web/main.py index 10d15b4..ed614a5 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -282,6 +282,39 @@ def api_plan_pending(): return ok(command_service.plan_pending) +@app.post("/api/ops/materialize") +def api_materialize(): + """方案 → 指令 (先记账后动作)。""" + from app.services import executor + return ok(executor.materialize_plans) + + +@app.post("/api/ops/exec-tick") +def api_exec_tick(dry_run: bool = Query(False)): + """择时出手一跳。dry_run=true 只试算不下发, 用来在盘中先看「现在会怎么动」。""" + from app.services import executor + return ok(executor.run_tick, dry_run=dry_run) + + +@app.post("/api/ops/sweep-windows") +def api_sweep_windows(): + from app.services import executor + return ok(executor.sweep_windows) + + +@app.post("/api/instructions/{instruction_id}/cancel") +def api_cancel_instruction(instruction_id: str, payload: dict = Body(default={})): + from app.services import executor + return ok(executor.cancel_instruction, instruction_id, + payload.get("reason") or "页面人工撤销") + + +@app.get("/api/dispatch-mode") +def api_dispatch_mode(): + from app.services import dispatcher + return ok(dispatcher.describe) + + @app.get("/api/ops/downstream-schema") def api_downstream_schema(): """导出下游三表的实际列定义 —— 用于回填 QMT_INTERFACE_REQUIREMENTS D1。""" diff --git a/app/web/static/index.html b/app/web/static/index.html index 5c60bfe..62c9740 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -50,6 +50,8 @@ 执行 {{ ov.exec_halt ? '已暂停':'正常' }} 自主档位 {{ ov.autonomy || '-' }} + + 下发 {{ dm.mode || '-' }} 日报 运维 刷新 @@ -65,6 +67,8 @@ :title="'以下持仓取不到实时价, 已用摊薄成本兜底: ' + ov.price_missing.join(', ')"/>