From 5fb030135e7d8d195070bafebe69642d66d69bb6 Mon Sep 17 00:00:00 2001 From: zlt Date: Wed, 19 Aug 2026 10:04:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E8=82=A1=E6=B1=87=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=92=8C=E7=9B=B8=E5=BA=94=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COMMAND_PRIORITY.md | 86 +++++ DEVLOG.md | 46 +++ MACRO_CALIB_2026-08-18.md | 88 +++++ MACRO_TIMING_PLAN.md | 529 +++++++++++--------------- app/core/macro_rules.py | 345 +++++++++++++++++ app/repo/macro_repo.py | 111 ++++++ app/scheduler.py | 16 + app/services/macro_service.py | 538 ++++++++++++++++++++++++++ app/services/param_store.py | 44 +++ app/services/proposal_service.py | 36 ++ app/services/strategy_runner.py | 19 +- app/web/main.py | 24 ++ app/web/static/index.html | 87 ++++- config/settings.py | 29 ++ ddl_pms_v1.sql | 20 + scripts/check_db.py | 6 +- scripts/run_tests.py | 7 +- scripts/test_batch14_units.py | 631 +++++++++++++++++++++++++++++++ scripts/test_batch6_units.py | 5 +- scripts/test_wiring.py | 5 +- 20 files changed, 2345 insertions(+), 327 deletions(-) create mode 100644 COMMAND_PRIORITY.md create mode 100644 MACRO_CALIB_2026-08-18.md create mode 100644 app/core/macro_rules.py create mode 100644 app/repo/macro_repo.py create mode 100644 app/services/macro_service.py create mode 100644 scripts/test_batch14_units.py diff --git a/COMMAND_PRIORITY.md b/COMMAND_PRIORITY.md new file mode 100644 index 0000000..fb84edf --- /dev/null +++ b/COMMAND_PRIORITY.md @@ -0,0 +1,86 @@ +# tradingSystem 命令与自动动作的优先级梳理 + +> 日期:2026-08-18。用途:把"谁说了算、谁给谁让路"一次说清,作为宏观择时层开工前的信息同步底稿。 +> 依据是当前源码,逐行核对过的文件包括:命令目录(app/core/command_spec.py)、命令服务(app/services/command_service.py)、方案生成器(app/core/planner.py)、规则闸(app/core/rule_gate.py)、执行器(app/services/executor.py)、自主提议(app/services/proposal_service.py)、信号消化(app/services/signal_service.py)、策略层(app/services/strategy_service.py 与 strategy_runner.py)、调度器(app/scheduler.py)。 +> 一处与旧文档不一致:命令目录现在共有三十一类。README 里写的二十七类是旧数字,后来加过命令没有更新。 + +## 一、总纲 + +一句话:人最大,停机开关其次,用户命令再次,紧急风控可以插队,策略和自主提议排最后。有一条贯穿所有层的规矩:减持永远比增持畅通。 + +## 二、从高到低的七层 + +**第一层,休假模式,全局停机。** 它是任务命令里的"全局暂停执行",按下后写进运行参数。调度器每一跳先查它,查到就跳过所有自动任务。规则闸也查它,买入和卖出的指令都会被拦下。只有对账、成交回放和日报照常跑。休假期间再下别的任务命令,会被判定冲突,需要先恢复,或者带强制标志并行。 + +**第二层,用户的参数命令。** 总规模、各类上限、档位开关这些。下达即生效,新的覆盖旧的,旧记录标记为已取代。互为反义的命令会互相取代,比如冻结和解冻。全局参数写进参数表,表值覆盖代码里的默认值。个股参数以命令表里最新生效的那条为准。参数命令不能撤销,只能用新的参数命令覆盖。 + +**第三层,用户的任务命令。** 降仓、升仓、建仓、清仓这些有生命周期的命令。三条要点。第一,命令之间的冲突由人裁决:新命令与在途命令方向相反时,新命令直接被拒,页面提示冲突对象,确认要并行就带强制标志重下。第二,命令压过系统的自主动作:降仓、暂停买入、休假、一键清仓这些命令在规划期就点名撤销在途的买入指令,而且是真撤到下游,不是只在本端划掉。清仓走完还会自动清场,撤掉该股的策略、在途建仓和挂着的提议。第三,命令只过规则闸,不过研判闸,系统不用人工智能去审用户。 + +**第四层,紧急通道。** 两个来源。一键清仓命令的指令带紧急标志,直通出手,不等开盘半小时,不等均价,限价更激进。决策系统置信度在零点八五以上的风控卖出,同样生成紧急清仓指令。还有一条叫确认即加速:你手动下的清仓还在途时,决策系统又喊卖同一只票,这条在途清仓会被升级成紧急直通。 + +**第五层,决策系统的盘中信号。** 卖出信号按置信度分三档。零点八五以上自动转清仓指令。零点六零到零点八五落成提议,等你拍板。低于零点六零不动作,但持仓票会留一条痕,防止你看不见风控在示警。挂着策略的票是例外:不自动清仓,只落提议,同时暂停该票策略的买入侧。买入信号一律只留痕,用来给建仓候选排序,不直接触发买入。 + +**第六层,个股策略,就是你特意挂的网格、做T和跟踪止盈。** 挂了活跃策略的票,自主提议引擎让开,不再对它自动提议。策略动作按命令口径过规则闸,不过研判闸。做T的指令当日强制平回,绝不过夜。 + +**第七层,自主提议,系统自己的想法,排最末。** 档位有三档,关、只提议、全自动,关是总闸。每条提议过两道闸:规则闸查合规,研判闸问决策系统该不该。分流时减持提议自动执行,增持提议按档位走,浮亏百分之十五及更深的补仓永远要人点头。自主新建仓有独立档位,而且比命令更严:拿不到真实资金快照就不建仓。 + +## 三、贯穿线:减持畅通,增持重重设卡 + +卖出指令只查可行性:不能超卖,不能超过 T+1 可卖量,部分减持要整百股,跌停只提示不拦。冻结、组合刹车、黑名单、全局暂停买入,这些全都不拦卖出。 + +买入指令要过全部硬闸,一共九道:数量整百、该股未冻结、全局暂停买入未生效、不在黑名单、决策系统昨夜没把它定性为形态恶化、组合没在刹车中(这条只拦自主动作,命令只收到提示)、没涨停、不追高(当日涨幅和距五日均线两道)、组合各上限和真实资金都够。 + +两边都拦的只有三样:休假模式、停牌、取不到现价。 + +## 四、六条容易记混的边界 + +1. 全局暂停买入拦得住命令的买入。升仓命令在暂停期间排出的买单,会被规则闸拦下。想升仓要先恢复买入。 +2. 组合刹车只拦自主增持。命令和策略的买入照走,只是账本里多一条提示。 +3. 黑名单有两道效力。候选池阶段就剔除黑名单票,升仓命令选不到它。即使点名建仓,规则闸也会把买单拦下。 +4. 执行窗口的最后一天,强制完成只对命令类指令生效。自主类买入到期就作废,不追。 +5. 昨夜形态恶化那道闸,只在择时实现A可用时才拿得到数据。退到实现B时这道闸自然不生效,属于已知降级,不是漏拦。 +6. 休假模式是唯一双向全停的开关。它连清仓命令都会判冲突,是名副其实的总闸。 + +## 五、宏观择时层加进来后,站在哪里 + +宏观层下的命令是署名 macro 的普通任务命令,走第三层的轨道,但姿态永远比用户命令低:遇到冲突就放弃并留痕,从不带强制标志。它的加仓比用户命令更自律,主动让位于全局暂停买入和组合刹车。 + +宏观闸是又一道只拦增持的开关,和冻结、刹车、买入暂停并列。它拦自主提议的四类买入和策略的买入开腿,不拦减持,不拦平回,不拦用户命令。 + +按开关归拢一下,全系统拦增持的开关从此有五个,按范围从大到小:休假模式(双向全停)、全局暂停买入(全部买入)、宏观闸(系统自主买入,偏热期间)、组合刹车(自主增持,回撤后三天)、个股冻结(单票增持)。 + +## 六、验证方法 + +以下命令都在桥机 factorevaluation 上运行。 + +看命令全目录,核对三十一类: + +```bash +make t-catalog +``` + +预期返回三十一条命令定义。行业划分数据源未配置时,行业类的两条显示置灰并附原因。 + +冲突裁决的实地演练,先下一条升仓,再下一条降仓: + +```bash +make t-issue T=INCREASE_EXPOSURE P='{"pct":0.05}' +make t-issue T=REDUCE_EXPOSURE P='{"pct":0.05}' +``` + +预期第二条返回失败,错误里含 CONFLICT 字样,并指出与哪条命令冲突。演练完把第一条撤掉,命令编号从上一步的返回里取: + +```bash +make t-cmds +curl -s -X POST http://127.0.0.1:38100/api/commands/CMD_编号/cancel | python3 -m json.tool --no-ensure-ascii +``` + +预期返回撤销成功,并报告作废的方案数和撤回的指令数。 + +日常盯层级是否按规矩运转,用一屏监视: + +```bash +make watch +``` + +预期能看到指令的裁决栏区分等待和停止,紧急指令标注直通,被闸拦下的动作在原因栏写明是哪道闸。 diff --git a/DEVLOG.md b/DEVLOG.md index 283c296..268d9ef 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -30,6 +30,52 @@ --- +## 2026-08-18 · 宏观择时层上线:股汇对冲指数管整体仓位,外加个股宏观闸 + +**背景与依据** +方案与全部拍板记录在 MACRO_TIMING_PLAN.md(V3 可读版)。参数初值来自当天在桥机实跑的标定,报告存档在 MACRO_CALIB_2026-08-18.md。模拟仓拍板:默认总开关开、档位全自动、不设观察期;转实盘前要把这两个默认改回保守档。 + +**做了什么** +给持仓系统加了一个宏观择时层。全部改动都在 tradingSystem,其他四个系统零改动。行为分四句话说。每个交易日九点三十五分算一次股汇对冲指数。指数进偏冷区(低于负二十)当天,像用户一样在命令台下升仓命令。指数进偏热区(高于正二十五)当天,先落宏观闸:自主增持四类动作和策略的买入开腿暂停,卖出与平回照常。指数从极值回落穿出正十五时,下降仓命令,幅度按本轮峰值深度取对数映射(S0=0.049、k=1.6、单周期封顶两成、降仓地板是总仓位两成)。 + +护栏与铁律的落点。它动仓位的唯一出口是 command_service.issue,署名 macro。与用户在途命令冲突就放弃并留痕,绝不带强制标志。升仓让位于全局暂停买入和组合刹车。数据取不到就判不可用,不动作、不落闸。信号每日一行落新表 pms_macro_signal(第 18 张表)。闸状态放运行参数 PMS_MACRO_GATE_STATE,由扫描写入,提议层与策略层只读,读不到一律当不生效——闸的安全方向是不额外拦。三张数据源表全在 153 代理,是标定实测钉死的(zs_day_data、gp_fx_daily 的 USDCNH.FXCM、gp_shibor 的 rate_1m)。 + +**动了哪些文件** +新增五个: +- app/core/macro_rules.py —— 纯逻辑:四步计算与 as-of 对齐、区域迟滞、周期状态、对数映射决策 +- app/repo/macro_repo.py —— 三张源表单表取数 + pms_macro_signal 读写 +- app/services/macro_service.py —— 编排、信号注册表、闸状态、建议采纳 +- scripts/test_batch14_units.py —— 25 例(计算对照手算/迟滞/周期映射/让路冷却/闸/采纳幂等) +- (scripts/calibrate_macro_signal.py 此前已交付并实跑,保留做月度复盘) + +修改十二处,全部只加行、不改既有行为: +- config/settings.py —— PMS_MACRO_* 参数一段(含模拟仓默认与转实盘提醒) +- app/services/param_store.py —— 说明/范围/合法值校验;GATE_STATE 运行键;PMS_MACRO_ENABLED 进 FAIL_CLOSED(文件初值是开,参数表读不到时按关处理) +- app/scheduler.py —— macro_scan 调度位(交易日 09:35,带守卫) +- app/services/proposal_service.py —— 两处扫描入口加宏观闸检查(拦买入侧、保垫减仓照常) +- app/services/strategy_runner.py —— 买开腿判定点加闸条件(与 buypause 同点,平回天然不拦) +- app/web/main.py —— 三个接口:/api/macro/status、/api/ops/macro-scan、/api/macro/adopt +- app/web/static/index.html —— 宏观择时面板 + 偏热闸与数据不可用两条横幅 +- ddl_pms_v1.sql —— 第 18 张表 pms_macro_signal +- scripts/check_db.py —— 表清单加 pms_macro_signal(顺带记录:pms_strategy 与 pms_op_log 本就不在清单里,属既有欠账,按不夹带纪律未补,单独提) +- scripts/run_tests.py —— 登记 batch14,总数改为 498 例 +- scripts/test_batch6_units.py —— DDL 表数哨兵 17 改 18(哨兵注释写明本次加表) +- scripts/test_wiring.py —— 调度表哨兵集合加 macro_scan + +**部署方式** +桥机 factorevaluation 上 make deploy(源码打进镜像,必须重建容器),跑 make initdb 建新表(幂等),make test 要见 ALL SUITES PASS。**务必收盘后部署**:默认就是全自动档,部署完成后的下一个交易日 09:35 就是第一次真实扫描。参数全是新键,不存在页面旧值覆盖的问题。 + +**真机判收** +未判收。开发容器里已过两道:全量单测 ALL SUITES PASS(498 例,含新增 25 例与两处哨兵更新);页面无头渲染判收八项全过、零页面错误(面板、指数值、区域标签、建议卡、采纳按钮、闸横幅、最近命令、二十根迷你条)。桥机判收按 MACRO_TIMING_PLAN.md 第六节走:make test → make check 见 18 表 → dry_run 试算并与标定脚本同日值对照 → 次日 09:35 看第一跳与面板。 + +**还欠着什么** +1. 桥机部署与第六节判收(含冲突演练那一步)。 +2. 第一个极值触发日的表现(模拟仓,让它自然发生)。 +3. 月度复盘:数据多一段就重跑标定脚本,校准阈值与 S0/k。 +4. check_db 表清单缺 pms_strategy 与 pms_op_log 的既有欠账,单独提。 + +--- + ## 2026-08-18 · 决策系统卖出采纳复核:降门槛+全留痕 / 确认即加速 / 清仓完成清场 **背景** diff --git a/MACRO_CALIB_2026-08-18.md b/MACRO_CALIB_2026-08-18.md new file mode 100644 index 0000000..301de71 --- /dev/null +++ b/MACRO_CALIB_2026-08-18.md @@ -0,0 +1,88 @@ +# 股汇对冲指数 · 标定报告(存档) + +> 生成: 2026-08-18T17:32:14 · 桥机实跑 `scripts/calibrate_macro_signal.py` +> 参数: ret_win=20 z_win=40 beta=0.02 主阈值±25.0 退出带±15.0 回看=1600 +> 本文件是 `MACRO_TIMING_PLAN.md` §8 参数初值的依据留痕。解读结论见文末「解读与采纳」一节(回贴时补写)。 + +## 一、表探查 (回填方案 §4.2 用) + +### zs_day_data (上证) +- 源: **proxy** (192.168.16.153:3307/my_quant_db) +- 全部列: `id, symbol, timestamp, open, high, low, close, pre_close, chg, percent, volume, amount` +- date_col: `timestamp` · close_col: `close` · symbol_col: `symbol` · symbol_used: `000001.SH` +- 数据: 1600 条, 20200109 → 20260818, 末值 3990.3037 + +### gp_fx_daily (USD/CNH) +- 源: **proxy** (192.168.16.153:3307/my_quant_db) +- 全部列: `id, ts_code, trade_date, bid_open, bid_close, bid_high, bid_low, ask_open, ask_close, ask_high, ask_low, tick_qty, exchange, create_time` +- date_col: `trade_date` · price_col: `bid_close` · pair_col: `ts_code` · pair_used: `USDCNH.FXCM` +- 品种值样本: ['USDCNH.FXCM'](整表单一品种) +- 数据: 1600 条, 20210607 → 20260817, 末值 6.74334 + +### gp_shibor (SHIBOR 1M) +- 源: **proxy** (192.168.16.153:3307/my_quant_db) +- 全部列: `id, trade_date, rate_on, rate_1w, rate_2w, rate_1m, rate_3m, rate_6m, rate_9m, rate_1y, create_time` +- date_col: `trade_date` · wide_1m_col: `rate_1m` +- 数据: 1600 条, 20200227 → 20260818, 末值 1.4165 + +## 二、数据体检 + +- 交易日历 (取自上证): 1600 天, 最新 20260818 (距今天 0 自然日) +- 汇率末日 20260817 · as-of 补齐 18 天; SHIBOR 末日 20260818 · 补齐 1 天 +- hedge_index 有效样本 1201 天 (预热损耗 399 天; 受限于 fx 数据从 2021-06 起) +- 当前值: **+14.7** +- 分位数: P1=-26.3 · P5=-20.0 · P25=-10.3 · P50=-1.2 · P75=+8.0 · P95=+19.0 · P99=+26.8 + +## 三、阈值敏感性 (±TH 触发频率与事后走势) + +| TH | 超阈天数占比 | HOT段/年 | COLD段/年 | HOT进区后10日均值 | COLD进区后10日均值 | +|---|---|---|---|---|---| +| ±20 | 8.7% | 3.5 | 4.1 | +0.66% (n=17) | +2.81% (n=20) | +| ±25 | 2.9% | 1.4 | 2.4 | +1.57% (n=7) | +1.99% (n=12) | +| ±30 | 0.7% | 0.8 | 0.4 | +2.53% (n=4) | +3.63% (n=2) | + +## 四、触发口径对比 (主阈值±25, 退出带±15) + +- HOT 区段 7 段 (平均持续 5.6 天, 最大深度 22.8); COLD 区段 12 段 (平均 4.4 天, 最大深度 16.5) + +**HOT · 进区即动(确认1)** (降仓视角: 越负越好) +> 5日: n=7 均值+1.67% 中位+0.86% 上涨占比57% · 10日: n=7 均值+1.57% 中位+1.31% 上涨占比71% · 20日: n=7 均值+2.88% 中位+1.65% 上涨占比71% + +**HOT · 进区确认2日** (降仓视角: 越负越好) +> 5日: n=6 均值+2.45% 中位+0.75% 上涨占比83% · 10日: n=6 均值+2.08% 中位+2.13% 上涨占比83% · 20日: n=6 均值+3.36% 中位+1.58% 上涨占比83% + +**HOT · 极值回落再动** (降仓视角: 越负越好(但可能已回落)) +> 5日: n=7 均值+0.19% 中位+0.35% 上涨占比57% · 10日: n=7 均值+0.86% 中位+0.91% 上涨占比71% · 20日: n=7 均值+0.98% 中位+0.47% 上涨占比57% + +**COLD · 进区即动(确认1)** (升仓视角: 越正越好) +> 5日: n=12 均值+2.15% 中位+1.83% 上涨占比92% · 10日: n=12 均值+1.99% 中位+1.86% 上涨占比83% · 20日: n=12 均值+3.75% 中位+4.36% 上涨占比83% + +**COLD · 进区确认2日** (升仓视角: 越正越好) +> 5日: n=12 均值+1.51% 中位+1.77% 上涨占比83% · 10日: n=12 均值+1.76% 中位+2.05% 上涨占比83% · 20日: n=12 均值+2.89% 中位+3.54% 上涨占比83% + +**COLD · 极值回落再动** (升仓视角: 越正越好) +> 5日: n=12 均值+0.04% 中位+0.48% 上涨占比75% · 10日: n=12 均值+0.29% 中位+0.88% 上涨占比58% · 20日: n=11 均值+1.45% 中位+1.31% 上涨占比64% + +> 提醒: 样本很小, 这是参考不是显著性检验; 两口径都已实现、页面可切, 这里只定默认档。 + +## 五、对数映射标定 target = S0 · ln(1 + e/k), e = |idx| − 25 + +- 区段最大深度 e 分布: 中位 2.9 · P95 17.1 · 最大 22.8 (共 19 段) +- **建议初值: PMS_MACRO_LOG_S0 = 0.049 · PMS_MACRO_LOG_K = 1.6** —— 标定成功 +- 目标形状: e 中位数 → 约 5 个点仓位, e 95分位 → 约 12 个点, SHIFT_MAX 封顶 20 个点 + +| e (超额深度) | 2 | 5 | 8 | 10 | 15 | 20 | 30 | 40 | +|---|---|---|---|---|---|---|---|---| +| 累计调整幅度 | 3.9% | 6.9% | 8.7% | 9.7% | 11.4% | 12.7% | 14.6% | 15.9% | + +--- + +## 解读与采纳(2026-08-18 复盘补写) + +第一条结论,偏冷侧成立,而且应当进区当天就动。指数进入偏冷区当天加仓,之后五天大盘平均涨百分之二点一五,胜率九成二,二十天平均涨百分之三点七五。多等一天确认,或者等指数回升再动,收益都明显变差。等回升再动的五日收益只剩百分之零点零四。冷区的反弹发生在极值当口,等确认就错过了。另外负二十这一档比负二十五样本更多,二十段对十二段,事后反弹也更强。所以采纳的口径是:偏冷进区当天触发(代码名 zone_enter),确认天数取一,进入阈值取负二十。 + +第二条结论,偏热侧在这段样本里不是顶部信号,而是动量延续。指数进入偏热区之后,五天、十天、二十天的大盘平均收益全是正的,二十天平均涨百分之二点八八。阈值取得越深,事后涨得越多。原因是这段样本从 2021 年 6 月到 2026 年 8 月,尾段是大牛市,股强汇弱共振到极端之后市场还在涨。所以极热就降仓,在样本内是付机会成本买保险,不是收益增强。三种口径里,等指数从极值回落再动的机会成本最低,二十天只有百分之零点九八。所以采纳的口径是:偏热侧等指数回落穿出正十五再降仓(代码名 zone_exit),降仓幅度按本轮热度的峰值深度取对数映射。 + +第三条结论,对数映射的参数。系数取 0.049,尺度取 1.6(代码名 PMS_MACRO_LOG_S0 和 PMS_MACRO_LOG_K)。这组参数让常见的极值深度对应大约五个点仓位,罕见的深极值对应大约十二个点,单周期封顶二十个点。确认当日的首步保底两个点,保证触发那天真的会动。 + +最后一条是诚实提醒。样本只有七到二十个事件,统计上不显著。报告没有算无条件基准,同期大盘整体上行,冷区的高胜率里有一部分是牛市里逢跌买入本来就容易对。汇率数据从 2021 年 6 月才开始,样本没有覆盖过 2015 年或 2018 年那样的深熊。这套初值是合身的起点,不是验证过的真理。只出建议的观察期和每月重跑本脚本的复盘,就是为持续校准设的。 diff --git a/MACRO_TIMING_PLAN.md b/MACRO_TIMING_PLAN.md index 979d64b..440b3b8 100644 --- a/MACRO_TIMING_PLAN.md +++ b/MACRO_TIMING_PLAN.md @@ -1,145 +1,244 @@ -# tradingSystem · 宏观择时接入方案 V2(股汇对冲指数 → 组合仓位 + 个股宏观闸) +# tradingSystem 宏观择时接入方案(V3 可读版) -> 状态: **V2,按用户 2026-08-18 三条拍板意见修订,待标定后定稿** | 日期: 2026-08-18 -> **V2 修订(依用户三点反馈)**:①触发口径不预设默认值,**先跑标定脚本**(`scripts/calibrate_macro_signal.py`,已交付)用历史数据对比"进区即动 vs 极值回落再动"后再定;②仓位调整由固定步长改为**对数映射**——极值越深加码越多、边际递减,参数由标定给建议值;③新增**个股宏观闸**:偏热区确认后,自主增持类个股动作(新建仓/回踩补足/盈利加仓/补仓)一并暂停——这同时堵上 V1 的一个洞(宏观降仓释放的现金会被 `PMS_OPEN_AUTONOMY=full` 的自主新建仓在下一分钟买回去);④数据源按用户口径:`gp_fx_daily` / `gp_shibor` 在 **192.168.16.153(153 代理)**,标定脚本顺带完成列名探查与核实。 -> 写作纪律沿袭项目既有文档:术语先白话、阈值给硬数字且全部可配置、失败路径显式、未确认事项进清单。 -> 关联:`POSITION_MGMT_DESIGN.md`(总体设计)、`README.md`(模块地图与三条铁律)、`DEVLOG.md` 2026-08-06「动 tradingSystem 以外的系统之前先答三题」。 +> 状态:已定稿,可以开工。日期:2026-08-18。 +> 本版把 V2.1 按项目输出风格重写(风格文件在 .claude/output-styles/readable-chinese.md)。技术内容与 V2.1 一致,没有新增决定。 +> 参数初值的依据是当天在桥机实跑的标定报告,报告存档在仓库根目录的 MACRO_CALIB_2026-08-18.md。 --- -## 0. 一句话 +## 一、这件事为最终目标买什么 -给 PMS 加一个「宏观择时层」:每个交易日算一次股汇对冲指数,指数进入极热区就**像用户一样在命令台下降仓命令**(REDUCE_EXPOSURE,幅度按对数映射随极值深度加码),进入极冷区下升仓命令,中性不动;偏热确认期间同时**暂停自主增持类个股动作**(宏观闸,防止降仓的钱被自动建仓买回去)。它产生仓位变化的唯一出口是**命令**与**闸**;方案生成、规则闸、择时出手、下发、对账、进度回报,全部走现有链路,**一行不改**。框架按「信号注册表」做,未来再接别的宏观信号(bionic 宏观水温、择时层日 QRS 等)只加注册表项。 +我们的最终目标是管好整体仓位。现在系统只会执行你手动下的仓位命令。大盘整体是偏热还是偏冷,系统自己不看,也不动。 ---- +这次要加一个宏观择时层。它每天算一个指标,判断大盘处在偏热、偏冷还是中性阶段。偏冷阶段自动提高仓位。偏热阶段先管住买入,等热度退潮时再降低仓位。 -## 1. 现状盘点:目标需要什么 vs 系统已有什么 +买到的东西有三样。第一,极端行情里仓位会自动往安全方向走,不再完全依赖人盯。第二,系统的自动买入不会在过热阶段火上浇油。第三,每次动作都有数据依据,事后可以复盘打分。 -| 环节 | 这个目标需要 | 系统现状 | 差距 | -|---|---|---|---| -| 信号数据 | `zs_day_data`(上证)、`gp_fx_daily`(USD/CNH)、`gp_shibor`(SHIBOR 1M) | `zs_day_data` 在 199 库有实证(bionic market_regime 在读,列 `symbol/timestamp/close/amount`,代码点式 `000001.SH`);**用户口径:fx 与 shibor 两表在 153 代理**。两表在五个项目里零代码引用,列名未经实测 | **小**:连接现成(153=proxy 源、199=index 源都已配),列名由标定脚本探查后回填 §4.2 | -| 信号计算 | 20 日对数收益 → spread → SHIBOR 修正 → 40 日 Z-Score ×10 | 全生态无任何股汇对冲指数实现(`hedge` 关键词全项目零命中)。bionic 有 `market_regime.py`(宏观水温),但那是决策系统内部件,PMS 不读 | **缺**:需新写,约百行纯函数 | -| 组合级自动决策 | 极值 → 升降整体仓位 | **无**。设计 §5 原话:「总仓上限由用户命令控制(升降仓判断权在用户,**不自动联动**)」。本需求是对这条设计决策的一次**有意扩展**,必须带着同样的档位/确认哲学落地 | **缺**:决策引擎(区制/确认/冷却/对数映射/上下限) | -| 个股动作联动 | 偏热时管住增量(用户拍板新增) | 自主提议五类动作只看个股与组合约束,不看宏观;`组合刹车`有"回撤后停自主增持"的同型机制可借鉴 | **缺**:宏观闸(§5.2,实现手势照抄刹车) | -| 执行面 | 把「降 X%」翻成逐股卖出并执行 | ✅ **已完备**。`REDUCE_EXPOSURE` / `INCREASE_EXPOSURE` 任务命令全生命周期:`command_service.issue()` → planner 四档凑额(停新买→清弱票→收利润→等比微减)→ 方案落表 → executor 转指令 → 规则闸终检 → 择时出手 → 回报进度。`pms_command.issued_by` 字段与 `/api/commands` 的 `by` 参数都在 | **无差距,全量复用** | -| 冲突仲裁 | 自动命令不得顶撞用户在途命令 | ✅ `command_spec.detect_conflicts` 已覆盖 REDUCE↔INCREASE、LIQUIDATE、HALT 等对 | 复用,宏观侧遇冲突**跳过不 force** | -| 档位/确认哲学 | 先建议后自动,人可随时收权 | ✅ 有成熟模板(`PMS_AUTONOMY` / `PMS_OPEN_AUTONOMY` 三档)。但 `pms_proposal` 是**个股级**,组合级建议放不进去 | **小**:宏观建议自己承载(§7) | -| 参数 | 阈值全部页面可调 | ✅ `param_store` 登记即出现在设置页、5 秒生效 | 登记即可 | -| 调度 | 每交易日定时算一次 | ✅ celery beat 骨架 + 三条守卫(交易日/休假/吞异常守成)成熟 | 加一个调度位 | -| 页面 | 看得见指数、区制、建议、闸状态 | ✅ 单页已有多面板先例 | 加一块面板 | -| 留痕 | 为什么动/为什么没动,可回查 | ✅ `pms_action_ledger` + 命令表 | 复用 + 新增信号历史表 | -| 历史存储 | 指数序列可回看、可判分、可标定 | 无 | **缺**:新表 `pms_macro_signal` | +这一层动仓位只有一种方式,就是像你一样在命令台下命令。方案生成、合规检查、择时、下单、对账,全部走现有链路。现有代码的行为一行都不改。框架还预留了位置,将来接入别的宏观信号时,决策和执行部分不用再改。 -结论:**执行面九成现成,缺的是"信号计算 + 组合级决策 + 宏观闸 + 一小块页面"这一层薄壳**。风险不在工作量,在接入姿势——所以本方案把大部分篇幅花在护栏上。 +## 二、这个指标是什么 ---- +指标叫股汇对冲指数。它回答一个问题:最近二十个交易日,A 股和人民币汇率是不是在同向共振。 -## 2. 目标翻译成系统语言:为什么走命令通道 + 扫描层闸 +股市涨,同时人民币贬值,这叫股强汇弱,指数走高。股市跌,同时人民币升值,指数走低。计算时还会减去一点资金利率的干扰,再做标准化,变成一个可以横向比较的分数。 -「极热 → 降低整体仓位」在本系统里最忠实的表达就是一条 `REDUCE_EXPOSURE {pct, window_tdays}`——语义完全一致(释放金额 = 规模 × pct,planner 按 停新买→清弱票→收利润→等比微减 四档凑额,这正是"pms 根据规则执行对个股的降仓位操作")。反向就是 `INCREASE_EXPOSURE`。存量靠命令,增量靠闸:偏热期间把自主增持的**扫描**停掉(不产生候选就不会有指令),两头合起来才是完整的"仓位受宏观控制"。 +分数的含义是:当前的共振强度,相对最近四十个交易日处在多极端的位置。分数放大了十倍,所以正负二十几就是很极端的读数。今天的读数是正十四点七,处在中性区。 -刻意**不做**的两种姿势: +数据来自三张表,都在 153 代理数据库里,已经实测确认。上证指数日线在表 zs_day_data。美元兑离岸人民币汇率在表 gp_fx_daily。上海银行间市场一个月期的拆借利率在表 gp_shibor。完整公式和列名对照放在附录一,可不读。 -1. **不做"连续仓位控制器"**(宏观层直接持有目标仓位、每分钟收敛)——绕开命令系统另立产生买卖的路径,与命令冲突时没有仲裁者,正是禁区。 -2. **不把宏观观点塞进规则闸**——规则闸是合规终检,塞行情观点会让"没通过合规"和"宏观不看好"在评审账本里分不开;宏观闸放在**扫描入口**(proposal_service),与"组合刹车""休假模式"同一落点、同一手势。 +## 三、总共做哪几件事 -走命令通道的三个直接好处:宏观动作在**命令台可见、可撤销、可改窗口**(命令至上铁律天然成立);与用户命令的冲突由现有 `detect_conflicts` 仲裁;执行成本统计、进度日结、判分全部免费获得。 +一共七件事,全部在持仓系统 tradingSystem 里做。其他四个系统一个字不动。 ---- +1. 每个交易日九点三十五分算一次指数,结果存进一张新表,页面能看历史。 +2. 指数进入偏冷区时,当天就自动下加仓命令,或者先出建议等你点头。 +3. 指数进入偏热区时,当天先停掉系统的自动买入。停的范围包括自主提议的四类买入动作,也包括个股策略的买入开仓腿。 +4. 指数从偏热极值回落、共振破裂时,自动下降仓命令,或者先出建议。 +5. 管理页面加一块宏观择时面板。指数走势、所处区域、当日建议、闸的状态都在上面。 +6. 所有阈值和开关登记成运行参数,页面可调,默认整层关闭。 +7. 配一批单元测试,按步骤上线。每一步都有能亲手运行的验证命令。 -## 3. 总体架构与六条设计原则 +## 四、关键规则,每条讲清做什么和为什么 -``` - 每交易日 09:35 (beat: macro_scan) 页面「宏观择时」面板 - │ │ 手动扫描 / 一键采纳 - ▼ ▼ - ┌──────────────────────────────────────────────────────────┐ - │ macro_service.scan() │ - │ 取数(macro_repo, 三张源表单表查) → 计算(macro_rules 纯函数) │ - │ → 落 pms_macro_signal (当日一行, 重扫就地更新) │ - │ → 决策(macro_rules.decide: 区制/确认/冷却/对数映射/上下限) │ - │ → 分流: │ - │ autonomy=full → command_service.issue( │ - │ REDUCE/INCREASE_EXPOSURE, │ - │ issued_by="macro") │ - │ autonomy=propose_only → 当日建议落表, 面板等用户点头 │ - │ autonomy=off / ENABLED=False / 信号UNAVAILABLE → 不动 │ - │ → 每一次动作与每一次"想动被拦"落 pms_action_ledger │ - └──────────────────────────────────────────────────────────┘ - │ (命令一旦下达, 与用户手下的命令零区别) │ 宏观闸状态 (偏热确认中?) - ▼ ▼ - command_poll → planner → executor proposal_service 扫描入口: - → 规则闸 → 择时 → 下发 → 对账 偏热确认 → 增持类扫描暂停(留痕) +**总开关和三个档位。** 有一个总开关和三个档位。第一档只算不动。第二档只出建议,你在页面点头才变成命令。第三档全自动下命令。当前是模拟仓,用户已拍板不设观察期,所以部署默认就是总开关打开加全自动档。任何时候你都能在页面收权降档。将来转实盘之前,这两个默认值要重新评估一次。 + +**偏冷时进区当天就动。** 指数低过负二十,当天就触发加仓。为什么不等确认:标定数据显示,冷区的反弹就发生在极值当口。进区当天动,之后五天大盘平均涨百分之二点一五,胜率九成二。多等一天,或者等指数回升再动,收益都明显变差。 + +**偏热时先停买,回落才卖。** 指数高过正二十五,当天只做一件事,就是停掉系统的自动买入。降仓命令要等指数从极值回落、跌破正十五才下。为什么不进区就卖:标定数据显示,这段样本里极热之后大盘平均还在涨。进区就卖等于和趋势作对,二十天要付出接近百分之三的机会成本。等共振破裂再卖,机会成本降到百分之一。停买这件事不用等,因为不买入没有机会成本,是便宜的保险。 + +**动多少,按对数曲线定。** 极值越深,调整越多,但加码越来越慢。这样第一步就有分量,又不会因为一个罕见的极端读数把仓位一次打空。降仓的幅度按本轮热度的峰值深度算,热得越狠,回落时降得越多。参数已按五年历史标定:常见深度对应大约五个点仓位,罕见的深极值对应大约十二个点,单个周期封顶二十个点。 + +**四道防抖。** 同一个极值周期里,指数不再创新深度就不再加码。同方向两次动作至少间隔三个交易日。每个信号每个交易日最多动一次。上一条宏观命令还没执行完时,不叠加新命令。 + +**两条底线。** 自动降仓最低降到总仓位百分之二十,再往下必须你亲自下命令。自动加仓不会突破你设定的总仓上限。 + +**永远给用户和安全让路。** 和你在途的命令方向冲突时,这一轮就放弃,记一笔账,绝不强行执行。休假模式下整层不跑。你手动暂停买入期间,不自动加仓。组合刹车期间,不自动加仓。个股策略的卖出和平仓回补不受闸影响,做T的买回腿也不会被拦。 + +**数据拿不到就什么都不做。** 任何一张源表取不到、样本不足或者数据过期,指数记为不可用。不可用时不下命令,也不落闸,页面亮黄条提醒。宁可不动,不猜方向。 + +**每一步都留痕。** 指数每天一行存进新表。每次动作,和每次想动但被拦,都写进评审账本。宏观层下的命令在署名栏写 macro,和你下的命令一眼就能分开。 + +## 五、为什么不会破坏现有系统 + +宏观层动仓位只有一个出口,就是调用现有的命令下达函数(代码名 command_service.issue)。它不直接写方案表、指令表和持仓账本,也不碰执行器。 + +需要修改的现有文件一共十二处。每一处都只增加内容,不改动已有行为。其中两处是测试哨兵:项目在测试里钉了"表的总数"和"调度位的集合"两枚哨兵,加表和加调度位时按哨兵自己的注释指引各加一行。新增文件五个。把新增文件全部删掉,系统就回到今天的样子。决策系统、选股桥、择时层、行情侧,零改动。 + +总开关随时可以在页面一键关闭,关闭后系统行为立刻回到没有这一层的样子。当前是模拟仓,部署默认开启全自动档。转实盘前把默认改回关闭加只出建议,改的是两个参数初值,不用动代码结构。 + +修改点的完整清单在附录四,可不读。 + +## 六、上线步骤和验证 + +下面所有命令都在桥机 factorevaluation 上运行。每一步都写了预期读数。读数不对就停下来,不进入下一步。 + +**第一步,标定。已完成。** 2026 年 8 月 18 日已在桥机实跑。三张表的位置和列名已确认,参数初值已按报告回填本方案。报告在仓库根目录的 MACRO_CALIB_2026-08-18.md。标定脚本保留在仓库里,供以后每月重跑校准。 + +**第二步,写代码和单元测试。** 代码写完后跑全量单测。 + +```bash +make test ``` -六条设计原则(每条都能在代码评审时逐一核对): +预期最后一行是 ALL SUITES PASS。清单里应出现新批次 test_batch14_units.py,并且没有任何一条 SUITE MISSING。 -1. **默认关**:`PMS_MACRO_ENABLED=False`。部署完成后系统行为与今天完全一致,开关和档位都在页面上,随时收权。 -2. **唯一出口**:宏观层产生仓位变化的途径只有两条——`command_service.issue()` 下命令、扫描入口的宏观闸(只拦不发)。不直接写 `pms_plan` / `pms_instruction` / `pms_position`,不调 executor,不碰账本。 -3. **故障即守成**:任一数据源取不到、样本不足、日龄超限 → 信号置 `UNAVAILABLE`,**不动作、不落闸**(拿不到 ≠ 偏热,闸的安全方向是"不额外拦"——增持本身另有规则闸把关),面板亮黄条。挂在调度器 `guard` 下,异常吞掉只记 ERROR。 -4. **命令至上**:与用户在途命令冲突(issue 返回 CONFLICT)→ 本轮放弃并留痕,**绝不 force**;`HALT_ALL`(休假)由调度守卫直接跳过;`PMS_GLOBAL_BUY_HALT` 生效时不发升仓;组合刹车期间默认不发升仓(可参数放开)。**用户命令永不受宏观闸限制**,且随时可在命令台撤销宏观命令。 -5. **限频防抖**:进区确认天数 + 迟滞退出带 + 同方向冷却 + 每信号每交易日至多一次动作 + 对数映射的"目标追踪"(§5.1,同一深度不重复加码)+ 上一条宏观命令在途不叠加。指数在极值区横跳不会造成反复买卖。 -6. **全留痕**:信号值每日入 `pms_macro_signal`(含中间量,可复算);每次触发/被拦/落闸入 `pms_action_ledger`(action=MACRO,hard_numbers 带当时指数值、区制、超额深度、当前仓位、本次步长);命令表 `issued_by='macro'` 一眼可辨。 +**第三步,收盘后部署和建表。** 改动打进镜像,所以必须重建容器。 + +```bash +make deploy +make check +``` + +预期 make check 的表清单里比部署前多出一张 pms_macro_signal。此时打开页面的参数设置,应出现一批 PMS_MACRO 开头的参数,总开关显示开启,档位显示全自动。因为是收盘后部署,当天不会再有扫描,首次真实运行在次日九点三十五分。 + +**第四步,试算一次。** 试算只计算不落表,也不下命令。 + +```bash +curl -s -X POST 'http://127.0.0.1:38100/api/ops/macro-scan?dry_run=true' | python3 -m json.tool --no-ensure-ascii +``` + +预期返回里能看到指数当前值和所处区域。拿它和标定脚本同一天算出的值对照,两者应当一致。 + +**第五步,全自动上线后的头几个交易日,看四样东西。** 用户已拍板:现在是模拟仓,不设观察期,部署即全自动。所以这一步不是等待期,是上线后的日常核对清单。第一,每个交易日新表里多一行指数记录,面板的值和区域正常刷新。第二,指数进入偏冷区的日子,命令台自动出现署名 macro 的加仓命令,并正常展开成方案和指令。第三,指数进入偏热区的日子,自主买入和策略买开腿的跳过记录里出现宏观闸的字样;等指数回落穿出退出带,命令台自动出现降仓命令。第四,每次宏观动作在评审账本里都有对应的一行。 + +**第六步,冲突演练。** 先手动下一条加仓命令。趁它还在执行中,手动触发一次宏观扫描。 + +```bash +curl -s -X POST 'http://127.0.0.1:38100/api/ops/macro-scan' | python3 -m json.tool --no-ensure-ascii +``` + +预期评审账本里出现一条宏观让路的拒绝记录,命令台没有新增宏观命令。演练完撤掉手下的那条加仓命令。 + +**第七步,首条自动命令的判收。** 等到第一次真实触发,确认这条命令走完从下达、方案、指令、择时出手到进度回执的全程。模拟仓阶段允许它自然发生,不需要人为催熟。 + +**回滚方法。** 任何时候在页面把总开关改回关,整层立即停止。命令、闸、建议全停,系统回到现状,不需要重新部署。 + +## 七、已拍板的决定记录 + +以下六条是 2026 年 8 月 18 日与用户逐条确认的结果。写在这里,将来查依据不用翻对话。 + +1. 仓位调整的形态用对数映射。不用固定步长,也不用一步调到位。 +2. 偏热侧等极值回落再降仓。依据是标定里这种做法机会成本最低。 +3. 偏冷侧进区当天就加仓,进入阈值取负二十。依据是标定里这一档样本更多,事后反弹也更强。 +4. 个股层面也受宏观控制。范围是自主提议的四类买入动作,加上个股策略的买入开仓腿。策略的卖出和平仓回补不受影响。 +5. 自动加仓让位于组合刹车。这是全方案唯一未经你直接确认的假设项,参数随时可以放开。 +6. 三张数据表都在 153 代理,已实测钉死。 +7. 当前是模拟仓,不设观察期。部署默认总开关开启、档位全自动。转实盘前重新评估这两个默认值。 + +## 八、风险和限制,先说在前面 + +这个指数是慢变量。它抓的是阶段性的冷热,不是每天的涨跌。牛市里它会显得慢半拍,这是设计使然,不是故障。 + +偏热降仓在标定样本里是保险,不是赚钱工具。样本覆盖 2021 年到 2026 年,尾段是大牛市,极热之后大盘平均还在涨。这套逻辑的价值要等下一轮真正的顶部才能兑现。 + +标定样本很小。极值一年只出现几次,统计上不显著。冷区的高胜率里有一部分水分,因为牛市里逢跌买入本来就容易对。汇率数据从 2021 年 6 月才开始,没有覆盖过深熊。 + +所以月度复盘是方案的一部分,不是可选项。数据每多一段,就用标定脚本重跑一次,参数跟着校准。模拟仓不设观察期是用户的拍板,正好让真实触发来检验参数,代价由模拟资金承担。 --- -## 4. 信号层设计 +# 附录(以下全部可不读,供实现和排查时查阅) -### 4.1 计算口径(按定义逐条落地) +## 附录一、指标计算公式与数据列名 -数据统一为按国内交易日对齐的日频序列,全部用**收盘值**: +计算分四步。第一步算二十日对数收益:股的一边用上证指数收盘价,汇的一边用美元兑离岸人民币,汇率上涨代表人民币贬值。汇率的日期先加一个自然日,再对齐到国内交易日历,当天没有值就沿用最近的前值。第二步把两个收益相加,得到共振值(代码名 spread)。第三步减去利率修正,修正量等于 0.02 乘以一个月期拆借利率的二十日变化(代码名 spread_adj)。第四步对修正后的值做四十日滚动标准化,再乘以十,得到股汇对冲指数(代码名 hedge_index)。 ``` -① stock_ret = ln(close_t / close_{t-20}) # zs_day_data, symbol='000001.SH' - fx_ret = ln(fx_t / fx_{t-20}) # gp_fx_daily USD/CNH, 正值=人民币贬值 - (fx 的日期先 +1 自然日, 再对齐到国内交易日: 当日无值取最近前值, 即 as-of 对齐) -② spread = stock_ret + fx_ret -③ spread_adj = spread − β × (SHIBOR1M_t − SHIBOR1M_{t-20}) # β 默认 0.02 -④ hedge_index = (spread_adj − mean40(spread_adj)) / std40(spread_adj) × 10 +stock_ret = ln(close_t / close_{t-20}) +fx_ret = ln(fx_t / fx_{t-20}) +spread = stock_ret + fx_ret +spread_adj = spread - 0.02 * (shibor1m_t - shibor1m_{t-20}) +hedge_index = (spread_adj - mean40) / std40 * 10 # std 用样本标准差 ``` -硬性口径,写进单测:窗口内任一序列缺口用 as-of 前值补齐但**记录补齐天数**;有效样本 < 20+40+5 个交易日 → `UNAVAILABLE`;`std40 = 0` → `UNAVAILABLE`;三源中最新日期落后当前交易日超过 `PMS_MACRO_STALE_TDAYS`(默认 3)个交易日 → `UNAVAILABLE`。 +| 表 | 数据源 | 日期列 | 取值列 | 过滤条件 | 备注 | +|---|---|---|---|---|---| +| zs_day_data | proxy(153) | timestamp | close | symbol='000001.SH' | 盘中最新行是前一交易日,与九点三十五分用前一日数据的设计一致 | +| gp_fx_daily | proxy(153) | trade_date | bid_close | ts_code='USDCNH.FXCM' | 整表单一品种,历史从 2021 年 6 月起 | +| gp_shibor | proxy(153) | trade_date | rate_1m | 无,宽表 | 单位是百分数,例如 1.4165 | -区制(zone)判定带迟滞,防横跳(阈值初值 ±25/±15,**以标定结果为准回填**): +数据守卫有三条。有效样本少于六十五个交易日,判不可用。任何一个数据源的最新日期落后当前交易日超过三个交易日,判不可用(天数参数是 PMS_MACRO_STALE_TDAYS)。标准差为零,判不可用。序列缺口用前值补齐,补齐的天数记进当日明细。 -| zone | 进入条件 | 退出条件 | +## 附录二、区域判定与决策流程的精确定义 + +区域(代码名 zone)四种取值。偏热(HOT):指数大于进入阈值(PMS_MACRO_HOT_TH,初值 25),回落到退出带(PMS_MACRO_EXIT_BAND,初值 15)以内算离区。偏冷(COLD):指数小于进入阈值(PMS_MACRO_COLD_TH,初值 -20),回升到负退出带以内算离区。其余为中性(NEUTRAL)。数据守卫未过为不可用(UNAVAILABLE)。进出阈值不同,是为了防止指数在阈值附近来回穿越造成反复触发。 + +对数映射:超额深度 e 等于指数绝对值减去进入阈值。周期累计目标 target(e) = min(S0 * ln(1 + e / k), SHIFT_MAX),标定回填 S0=0.049,k=1.6,SHIFT_MAX=0.20。偏冷侧(触发口径 zone_enter)按步进追踪:本次步长等于 target(当前深度) 减去周期内已执行量,确认当日首步保底 STEP_MIN(0.02);只有指数更深才有新步。偏热侧(触发口径 zone_exit)整个周期只动一次:回落穿出退出带时触发,步长取 target(周期峰值深度),同样受 STEP_MIN 和 SHIFT_MAX 约束。周期结束后累计量和峰值深度清零。 + +冷却与去重的规则如下。同方向两次生效动作至少间隔三个交易日(参数 PMS_MACRO_COOLDOWN_TDAYS)。生效的定义是所发命令没有被立刻取消。因为排不出方案而被取消的命令不占冷却,次日可以重试。每个信号每个交易日至多动一次。存在署名 macro 的在途命令时不叠加新命令。宏观降仓不把总仓位降到百分之二十以下(参数 PMS_MACRO_MIN_PCT),用户自己的命令不受此限。 + +加仓方向的额外让路:全局暂停买入开关(PMS_GLOBAL_BUY_HALT)生效时不发;组合刹车生效且 PMS_MACRO_RESPECT_BRAKE 为真时不发。这两个状态在参数表而不在命令表,命令冲突检测看不见它们,引擎必须自查。 + +调度时点定在九点三十五分而不是盘前,原因是一个已核实的机制:盘前取不到实时价时,持仓快照会用成本价顶替并打上价格不可靠标记,方案生成器会排除这类票。盘前下的降仓命令会在一分钟内被排成零方案并取消。九点三十五分行情已就位,也在八点四十分拉取候选池之后,加仓有票可选。指数用的是前一交易日的数据,几点计算数值都一样。 + +建议(第二档产出)的承载:写在当日 pms_macro_signal 行里,当日有效,隔日自动作废。采纳走独立接口,采纳前重查一遍冲突和开关,采纳后署名记为用户。特意不复用个股提议表 pms_proposal,因为它的采纳语义是生成单只股票的指令,装不下组合级动作。 + +宏观闸的时点与降仓命令刻意不同步:闸在进入偏热区当天就生效,回落穿出退出带解除;降仓命令回落时才下。时间线是先停增量,热度破裂后再减存量。闸的实现落点有三处,全部与既有检查并排:自主提议扫描入口一处,自主建仓扫描入口一处,个股策略运行器的买入开腿判定点一处(与既有的买入暂停集合同一个判定位置,天然继承只挡开腿不挡平仓的语义)。闸不写任何暂停标记,闸解除后下一轮自动恢复,不影响你手动设置的暂停记录。闸读不到宏观状态时不拦,因为买入本身另有规则闸把关,不能让宏观层故障把自主引擎整体摁死。 + +多信号扩展:宏观服务内有一张信号注册表,V1 只有股汇对冲指数一项。将来接入决策系统的宏观水温或者择时层的日线指标,只需要新增注册表项和该信号自己的参数,参数命名空间约定为 PMS_MACRO_信号名_参数名。多信号合成规则 V1 定死为最保守者优先:任一信号偏热则整体偏热,无偏热但有偏冷则整体偏冷。 + +## 附录三、参数清单 + +全部登记进运行参数中心,页面可调,五秒生效。总开关的文件初值现在是开(模拟仓拍板),所以要把它加进参数中心的安全名单(代码名 FAIL_CLOSED):参数表读不到的时候按关闭处理,宁可少动一轮,不在基础设施故障期间自动下命令。附带说明:参数表和三张数据源表在同一个数据库,真断了的时候取数环节也会先判不可用,这条名单是第二道保险。 + +| 参数 | 初值 | 说明 | |---|---|---| -| HOT(偏热,即 positive 区) | hedge_index > `PMS_MACRO_HOT_TH` | 回落到 < `PMS_MACRO_EXIT_BAND` | -| COLD(偏冷,negative 区) | hedge_index < `PMS_MACRO_COLD_TH` | 回升到 > −`PMS_MACRO_EXIT_BAND` | -| NEUTRAL | 其余 | — | -| UNAVAILABLE | 数据守卫未过 | 数据恢复 | +| PMS_MACRO_ENABLED | True | 宏观择时总开关。关闭时调度位空转,不取数不计算。模拟仓拍板默认开,转实盘前改回 False | +| PMS_MACRO_AUTONOMY | full | 档位:off 只算不动,propose_only 出建议,full 自动下命令。模拟仓拍板默认 full,转实盘前改回 propose_only | +| PMS_MACRO_SIGNALS | hedge_fx | 启用的信号清单,逗号分隔 | +| PMS_MACRO_STOCK_GATE | True | 个股宏观闸。偏热确认期间暂停自主买入与策略买入开腿 | +| PMS_MACRO_HOT_TH | 25.0 | 偏热进入阈值。标定约每年 1.4 段,平均持续 5.6 天 | +| PMS_MACRO_COLD_TH | -20.0 | 偏冷进入阈值。标定约每年 4 段,用户确认取 -20 | +| PMS_MACRO_EXIT_BAND | 15.0 | 迟滞退出带。偏热侧它同时是降仓命令的触发线 | +| PMS_MACRO_CONFIRM_DAYS | 1 | 偏冷进区连续天数要求。标定显示等第二天反而更差 | +| PMS_MACRO_TRIGGER_HOT | zone_exit | 偏热触发口径,回落再动。可切 zone_enter | +| PMS_MACRO_TRIGGER_COLD | zone_enter | 偏冷触发口径,进区即动。可切 zone_exit | +| PMS_MACRO_LOG_S0 | 0.049 | 对数映射系数,标定回填 | +| PMS_MACRO_LOG_K | 1.6 | 对数映射尺度,标定回填 | +| PMS_MACRO_SHIFT_MAX | 0.20 | 单个极值周期累计调整封顶,占规模的百分点 | +| PMS_MACRO_STEP_MIN | 0.02 | 裁剪后步长小于此不动,也是确认当日的首步保底 | +| PMS_MACRO_COOLDOWN_TDAYS | 3 | 同方向两次动作的最小间隔,交易日 | +| PMS_MACRO_MIN_PCT | 0.20 | 宏观降仓地板。用户命令不受此限 | +| PMS_MACRO_MAX_PCT | 0.0 | 宏观加仓天花板。零表示不单设,由总仓上限兜底 | +| PMS_MACRO_WINDOW_TDAYS | 3 | 宏观命令的执行窗口 | +| PMS_MACRO_RESPECT_BRAKE | True | 组合刹车期间不自动加仓 | +| PMS_MACRO_STALE_TDAYS | 3 | 数据日龄超此判不可用 | +| PMS_MACRO_RET_WIN | 20 | 收益计算窗口 | +| PMS_MACRO_Z_WIN | 40 | 标准化窗口 | +| PMS_MACRO_SHIBOR_BETA | 0.02 | 利率修正系数 | -### 4.2 数据访问(严格单表,三条独立查询) +## 附录四、新增与触碰文件清单 -新增 `app/repo/macro_repo.py`。**用户口径:`gp_fx_daily` / `gp_shibor` 在 153 代理** → 走 `db.session.fetch_all(..., source="proxy")`;`zs_day_data` 已证实在 199(`source="index"`),若标定脚本探出 153 也可达则统一走 proxy(少一个依赖库)。每张表一条单表 SQL(`WHERE ... ORDER BY 日期 DESC LIMIT 120` 量级),完全符合单表守卫。**列名以标定脚本探查结果为准回填此处,回填前不写 macro_repo**: +新增文件五个,互相独立,全部删除即回到现状。 -| 表 | 源 | 日期列 | 取值列 | 过滤条件 | -|---|---|---|---|---| -| zs_day_data | index(153 待探) | timestamp | close | symbol='000001.SH' | -| gp_fx_daily | proxy(153) | 待探 | 待探 | 待探(USD/CNH 筛选) | -| gp_shibor | proxy(153) | 待探 | 待探(1M) | 待探 | +| 文件 | 内容 | +|---|---| +| app/core/macro_rules.py | 纯逻辑:对齐与四步计算、区域迟滞判定、对数映射决策。无外部依赖,可单测 | +| app/repo/macro_repo.py | 三张源表的单表取数,加新表 pms_macro_signal 的读写 | +| app/services/macro_service.py | 编排:守卫、取数、计算、落表、决策、分流、留痕。对外提供状态、采纳、闸状态三个函数 | +| scripts/calibrate_macro_signal.py | 标定脚本,已交付并实跑。保留供月度复盘 | +| scripts/test_batch14_units.py | 单测约二十五例,内容见附录六 | -`pms_macro_signal` 的读写走 `source="proxy"`(与其余 pms_* 表同侧)。 +触碰的既有文件十二处,全部只加不改(末两行是测试哨兵,按哨兵注释的指引各加一行)。 -### 4.3 信号注册表(多信号扩展点) +| 文件 | 加什么 | +|---|---| +| config/settings.py | 一段 PMS_MACRO 开头的参数字段 | +| app/services/param_store.py | 参数说明、取值范围、合法值校验各加宏观项 | +| app/scheduler.py | 一个新调度任务,加调度表一行 | +| app/services/proposal_service.py | 两处扫描入口各加一个宏观闸检查,与既有的休假检查并排 | +| app/services/strategy_runner.py | 买入开腿判定点加一个宏观闸并列条件,与既有买入暂停集合同点 | +| app/web/main.py | 三个新接口:状态查询、手动扫描、建议采纳 | +| app/web/static/index.html | 一块宏观择时面板 | +| ddl_pms_v1.sql | 第十八张表的建表语句 | +| scripts/check_db.py | 自检表清单加 pms_macro_signal(顺带发现 pms_strategy 和 pms_op_log 也不在清单里,属既有欠账,按不夹带纪律单独提) | +| scripts/run_tests.py | 登记新测试批次 | +| scripts/test_batch6_units.py | DDL 表数哨兵 17 改 18(哨兵注释明言"加表就要来这里加一") | +| scripts/test_wiring.py | 调度表哨兵集合加 macro_scan | -`app/services/macro_service.py` 内一张注册表,V1 只有一项: +三条铁律的对照。命令至上:宏观动作本身就是命令,冲突让位用户,可撤可改,闸只拦系统自主动作。分工不越权:宏观层只回答整体该多重和增量该不该停,逐股怎么买卖仍由方案生成器、择时和规则闸决定,其他系统零改动。先记账后动作加故障即守成:信号先落表再决策,命令走先落表的既有路径,任何取数计算失败都不产生新指令也不落闸。 -```python -SIGNALS = { - "hedge_fx": { # 股汇对冲指数 - "label": "股汇对冲指数", - "fetch": macro_repo.fetch_hedge_inputs, # -> 原始序列 - "compute": macro_rules.compute_hedge_index, # -> {value, zone, detail} - }, - # 未来: "bionic_regime" (决策系统宏观水温) / "index_qrs" (择时层日QRS) —— - # 只加表项与各自参数(命名空间 PMS_MACRO__*), 决策层/执行层/闸/页面零改动。 -} -``` - -启用哪些由 `PMS_MACRO_SIGNALS`(逗号分隔,默认 `hedge_fx`)控制。多信号合成 V1 定死**最保守者优先**:任一启用信号 HOT → 整体偏热;无 HOT 而有 COLD → 偏冷;否则中性。加权合成属二期,不做预设计。 - -### 4.4 新表 `pms_macro_signal`(第 18 张表) +## 附录五、新表结构 ```sql CREATE TABLE IF NOT EXISTS pms_macro_signal ( @@ -148,7 +247,7 @@ CREATE TABLE IF NOT EXISTS pms_macro_signal ( trade_date INT NOT NULL COMMENT '北京交易日 YYYYMMDD', value DECIMAL(12,4) COMMENT 'hedge_index 值', zone VARCHAR(16) NOT NULL DEFAULT 'NEUTRAL' COMMENT 'HOT/COLD/NEUTRAL/UNAVAILABLE', - detail_json TEXT COMMENT '中间量: stock_ret/fx_ret/spread/spread_adj/超额深度/各源末日/补齐天数/周期已执行调整', + detail_json TEXT COMMENT '中间量: 收益/共振值/超额深度/各源末日/补齐天数/周期已执行量', action VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT 'NONE/ADVICE_REDUCE/ADVICE_INCREASE/CMD_ISSUED/BLOCKED/GATED', ref_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联 command_id (下达或采纳后回填)', note VARCHAR(500) NOT NULL DEFAULT '' COMMENT '被拦原因/建议文案/闸状态', @@ -157,226 +256,22 @@ CREATE TABLE IF NOT EXISTS pms_macro_signal ( ) COMMENT='宏观信号日快照与动作记录 (每信号每交易日一行, 重扫就地更新)'; ``` -一行四用:历史序列(面板画近 20 日)、当日建议的承载(propose 档)、冷却与"周期已执行调整"的事实源、宏观闸的状态源(扫描层 5 秒缓存读当日行)。 +这一行数据有四个用途:历史序列供面板画图;第二档的建议就写在当日行里;冷却和周期已执行量从历史行回查,不另设状态存储;宏观闸的当日状态也从这行读,进程内缓存五秒。 ---- +## 附录六、接口、面板与单测清单 -## 5. 决策层设计 +新增接口三个。查询状态:GET /api/macro/status,返回各信号的当前值、区域、近二十日序列、数据日龄、当日建议、闸状态、最近宏观命令进度和冷却剩余。手动扫描:POST /api/ops/macro-scan,带 dry_run=true 时只算不落表不下达。建议采纳:POST /api/macro/adopt,采纳前重查冲突和开关,采纳幂等,隔日作废。 -### 5.1 组合仓位:对数映射(V2 改,替代固定步长) - -极值越深,调整越多,但**边际递减**——既回应"越极端越该动",又天然防止 Z-Score 尾部把仓位一次打爆。纯函数 `macro_rules.decide(...)`,规则按优先级: - -1. `PMS_MACRO_ENABLED=False` 或档位 off 或 zone ∈ {NEUTRAL, UNAVAILABLE} → NONE。 -2. 确认:连续处于同一极值区 ≥ `PMS_MACRO_CONFIRM_DAYS`;触发口径 `PMS_MACRO_TRIGGER_MODE`(zone_enter / zone_exit,**默认值由标定结果定**,两种都实现、页面可切)。 -3. **对数映射(核心)**: - - 超额深度 `e = |hedge_index| − 进入阈值`(e ≥ 0,单位=指数点); - - 本轮极值周期的**累计目标调整** `target(e) = min( S0 × ln(1 + e / k), PMS_MACRO_SHIFT_MAX )`(占规模的百分点); - - 本次步长 `step = target(e_now) − done_shift`(done_shift = 周期内已执行的宏观调整,记在当日信号行 detail 里);`step < PMS_MACRO_STEP_MIN`(默认 0.02)→ 不动; - - **周期**定义:进区 → 迟滞退出;退出后 done_shift 清零,下个周期重新累计。同一深度不重复加码,指数横盘在区内不会连环下命令;指数**更深**才有新步; - - `S0` / `k` / `SHIFT_MAX` 初值由标定脚本给建议(目标形状:e 中位数 → 约 5 个点,e 95 分位 → 约 12 个点,封顶 20 个点),页面可调。 -4. 冷却:同方向上一次**生效**动作距今 < `PMS_MACRO_COOLDOWN_TDAYS`(默认 3 个交易日)→ 本轮不动(下轮 target 追踪仍在,冷却只是把步子隔开)。「生效」= 所发命令未被立刻 CANCELLED(零方案的升仓命令不占冷却,次日重试)。 -5. 在途:上一条宏观命令仍在 EXECUTING/PARTIAL → 不叠加。 -6. 方向裁剪: - - HOT → 降仓,`step` 再受地板裁剪:不把总仓位降到 `PMS_MACRO_MIN_PCT`(默认 0.20)以下(用户命令不受此限); - - COLD → 升仓,`step` 受 `PMS_MACRO_MAX_PCT`(默认 0=由 `PMS_PORTFOLIO_CAP` 兜底)裁剪; - - 升仓独有护栏:`PMS_GLOBAL_BUY_HALT` 生效不发;组合刹车生效且 `PMS_MACRO_RESPECT_BRAKE=True`(默认,用户未反对,标注为**假设**)不发。降仓不受这两条限制(与"冻结与刹车只挡增持不挡减持"同口径)。 - -### 5.2 个股宏观闸(V2 新增,用户拍板"个股交易动作受宏观信号控制") - -**语义**:偏热(HOT)确认期间,PMS 自己发起的**增持类**个股动作暂停——自主新建仓(OPEN)、回踩补足(FILL)、盈利加仓(ADD)、补仓(DCA)的扫描整体跳过并留痕;**减持类动作(TRIM/风控卖出/清仓)与用户命令永不受限**。COLD 期间不加额外限制(自主动作照常,加量由升仓命令负责)。这与"组合刹车"的既有语义完全同型("自主增持停,命令类不受限"),实现手势照抄。 - -**为什么必须有**:不加闸的话存在一条自我打架的路径——宏观降仓卖出 → 现金变多 → `PMS_OPEN_AUTONOMY=full` 的自主新建仓下一分钟把钱又买回去。闸把增量管住,命令管存量,才闭环。 - -**实现落点(两处,均为纯增量,与既有 `exec_halt` / 刹车检查并排)**: -- `proposal_service.scan_and_route` 入口:宏观闸生效 → 四类已有持仓动作中**买入侧**候选不扫(TRIM 照常),skipped 留痕"宏观偏热闸"; -- `proposal_service._scan_open` 入口:宏观闸生效 → 本轮不建仓,skipped 留痕。 - -闸状态由 `macro_service.gate_state()` 提供(读当日 `pms_macro_signal`,进程内 5 秒缓存,**读不到 = 不落闸**——闸的安全方向是"不额外拦",增持自有规则闸把关,不能让宏观层故障把整个自主引擎摁死)。开关 `PMS_MACRO_STOCK_GATE`(默认 True,随 `PMS_MACRO_ENABLED` 总开关生效;即便档位是 propose_only,闸也生效——"停止买入"是保守方向动作,与"减持方向不设确认门槛"同哲学)。 - -**边界(待拍板 §12.3)**:个股交易方案(网格/做T/跟踪)的买入侧要不要一并受闸。V2 建议**先不动策略层**——策略是用户特意设的(signal_service 对挂策略票的处理哲学是"只提示不替你做"),宏观闸自动暂停它们容易造成困惑;若你要管,复用现成的 `strategy_service.pause_buy`(页面可恢复)一行接入即可。 - ---- - -## 6. 执行接入(兼容性核心节) - -### 6.1 下达即普通命令 +命令下达的调用形态如下,供实现对照。 ```python -res = command_service.issue( - "REDUCE_EXPOSURE", # 或 INCREASE_EXPOSURE - {"pct": step, "window_tdays": param_store.get_int("PMS_MACRO_WINDOW_TDAYS", 3)}, +command_service.issue( + "REDUCE_EXPOSURE", + {"pct": step, "window_tdays": 3}, issued_by="macro", - note="宏观择时: hedge_fx=+31.2 偏热确认(阈值+25, 超额6.2), 周期目标降8.6点已降0点, 本次降8.6点") + note="宏观择时: 股汇对冲指数回落穿出退出带, 峰值深度 6.2, 本次降 8.6 个点") ``` -- `issue()` 内部自动做冲突检测:返回 `ok=False` 且 errors 含 CONFLICT → 宏观层**留痕跳过**(ledger verdict=REJECT),当日不再重试。 -- 命令落表后与用户命令完全同路:`command_poll` 每分钟排方案、`intraday_exec` 择时出手、`refresh_progress` 日结进度。`shadow` 通道下只记账不真发(与现状一致,宏观层无需感知通道模式)。 +单测批次十四的覆盖清单:四步计算对照手算值;汇率加一日对齐和缺口沿用前值;样本不足和数据停更判不可用;区域迟滞的进出和横跳;对数映射的单调性、同深度不重复加码、周期清零、封顶;偏冷首步保底;偏热按峰值深度一次动作;确认天数、冷却、每日一次;零方案命令不占冷却;冲突让路、暂停买入让路、刹车让路(打桩);宏观闸拦自主买入不拦保垫减仓、拦策略买开腿不拦平回腿、读不到不拦、档位关不拦;建议采纳幂等与隔日作废。 -### 6.2 调度时点:09:35,不是盘前 - -一个已核实的坑决定了时点:`positions_view` 在取不到实时价时用摊薄成本顶住价格并标 `price_ok=False`,而 `planner._usable` **排除** `price_ok=False` 的票(2026-07-31 静默失败专项第九条)。行情 db13 只有当日分钟线,盘前全部无价——盘前下达的 REDUCE 命令会在一分钟内被排成**零方案并置 CANCELLED**。所以 `macro_scan` 定在 **09:35**(开盘后行情已就位,也在 08:40 拉候选池之后,升仓有票可选)。信号用的是 T-1 日终数据,几点算值都一样。beat 表加一行: - -```python -"macro_scan": {"task": "pms.macro_scan", "schedule": crontab(hour=9, minute=35)}, -``` - -任务本体 `@guard(trade_day=True, respect_exec_halt=True)`——休假模式自动跳过,异常吞掉守成。宏观闸的状态在 09:35 扫描落表后当日恒定(信号是日频慢变量),盘中每一跳扫描层读的是同一行。另设手动端点(§7.3)供盘中任意时刻重扫/试算。 - -### 6.3 护栏对照表(每条对应代码里一处显式检查) - -| 场景 | 行为 | 靠什么保证 | -|---|---|---| -| 休假模式 HALT_ALL | 整跳跳过 | 调度 guard `respect_exec_halt`;手动端点内 `macro_service` 自查一遍 | -| 用户在途 REDUCE/INCREASE/LIQUIDATE 冲突 | 跳过 + 留痕,不 force | `issue()` 现有 `detect_conflicts`,宏观层永不传 `force_conflict=True` | -| 全局暂停买入 | 升仓不发 | 引擎显式读 `PMS_GLOBAL_BUY_HALT`(此开关在参数表而非命令表,冲突检测看不见它,必须自查) | -| 组合刹车 | 升仓默认不发 | 引擎读 `PMS_BRAKE_UNTIL` 比对当日 | -| 数据缺失/停更 | 不动作、不落闸 + 面板黄条 | 信号 UNAVAILABLE 分支 | -| 极值区横跳/横盘 | 不反复动 | 迟滞退出带 + 对数目标追踪(同深度不重复加码)+ 冷却 + 每日一次 | -| 上一条宏观命令未走完 | 不叠加 | 查 `issued_by='macro'` 的在途命令 | -| 参数表读不到 | 宏观层整体失效(安全方向) | `PMS_MACRO_ENABLED` 文件初值 False,表挂了退初值即关;闸读不到状态 = 不拦 | -| 仓位已到地板/天花板 | 不动 + 留痕 | decide() 映射裁剪 | -| 宏观闸误伤减持 | 不可能 | 闸只挡买入侧扫描,TRIM/EXIT/风控卖出路径不经过闸 | - ---- - -## 7. 档位、建议承载与页面 - -### 7.1 三档(沿用系统词汇) - -`PMS_MACRO_AUTONOMY`: `off` / `propose_only`(**默认**)/ `full`。 -- off:只算信号、只落历史、只展示,不出建议不下命令(**闸也不生效**——off 是"只看不动"档)。 -- propose_only:触发时把建议写进当日 `pms_macro_signal` 行(action=ADVICE_*,note 带完整理由与建议 pct),面板出卡片等一键采纳;**当日有效**,隔日自动作废。**宏观闸照常生效**(保守方向自动,见 §5.2)。 -- full:直接 `issue(issued_by="macro")`。三条铁律下用户仍可在命令台撤。 - -不复用 `pms_proposal`:它的采纳路径是"生成该股指令",是个股级语义;硬塞组合级建议要改共用 decide 端点。宏观建议放自己的表行 + 自己的采纳端点,互不沾染。 - -### 7.2 采纳路径 - -`POST /api/macro/adopt {signal_key}` → 校验建议仍是当日且未采纳 → 重跑一遍 decide 的护栏(冲突/开关/地板可能在建议挂出后变化)→ `command_service.issue(..., issued_by="user")`(用户点的头就是用户意志)→ 回填 `ref_id=command_id, action=CMD_ISSUED`。幂等:同一行只能采纳一次。 - -### 7.3 API 与页面 - -- `GET /api/macro/status`:各信号 {value, zone, 超额深度, 近 20 日序列, 数据日龄, 当日建议, 闸状态, 最近宏观命令及进度, 冷却剩余, 周期已执行调整}。 -- `POST /api/ops/macro-scan?dry_run=true|false`:手动扫描;dry_run 只算不落表不下达。 -- `POST /api/macro/adopt`:见上。 -- 页面 `index.html` 加「宏观择时」面板:指数当前值 + 区制色带(热红/冷蓝/中性灰/不可用黄)+ 近 20 日迷你走势 + 当日建议卡(一键采纳/忽略)+ **宏观闸状态条**("偏热闸生效中:自主增持已暂停")+ 最近宏观命令进度。参数不用做界面——`PMS_MACRO_*` 登记后自动出现在「参数设置」。 -- Makefile 顺手加 `t-macro`(status + 试算),非必需。 - ---- - -## 8. 参数清单(settings.py 初值;全部经 param_store 页面可调;标 ⚙ 的初值由标定回填) - -| 参数 | 默认 | 说明 | -|---|---|---| -| PMS_MACRO_ENABLED | False | 宏观择时总开关(关=调度位空转,不取数不计算,面板显示"未启用") | -| PMS_MACRO_AUTONOMY | propose_only | off / propose_only / full | -| PMS_MACRO_SIGNALS | hedge_fx | 启用的信号清单(逗号分隔) | -| PMS_MACRO_STOCK_GATE | True | 个股宏观闸:偏热确认期间暂停自主增持类扫描(off 档整体不生效) | -| PMS_MACRO_HOT_TH ⚙ | 25.0 | 偏热进入阈值 | -| PMS_MACRO_COLD_TH ⚙ | -25.0 | 偏冷进入阈值 | -| PMS_MACRO_EXIT_BAND ⚙ | 15.0 | 迟滞退出带(\|值\| 回落到此内算离区) | -| PMS_MACRO_CONFIRM_DAYS ⚙ | 1 | 进区连续 N 个交易日才触发 | -| PMS_MACRO_TRIGGER_MODE ⚙ | zone_enter | zone_enter=进区即动 / zone_exit=极值回落再动(**默认值待标定对比后定**) | -| PMS_MACRO_LOG_S0 ⚙ | 0.12 | 对数映射系数:target = S0·ln(1+e/k) | -| PMS_MACRO_LOG_K ⚙ | 10.0 | 对数映射尺度 k | -| PMS_MACRO_SHIFT_MAX ⚙ | 0.20 | 单个极值周期的累计调整封顶(占规模的百分点) | -| PMS_MACRO_STEP_MIN | 0.02 | 裁剪后步长小于此不动 | -| PMS_MACRO_COOLDOWN_TDAYS | 3 | 同方向两次动作最小间隔(交易日) | -| PMS_MACRO_MIN_PCT | 0.20 | 宏观降仓地板(自动降仓不把总仓位降到此下;用户命令不受限) | -| PMS_MACRO_MAX_PCT | 0.0 | 宏观升仓天花板;0=不单设,由 PMS_PORTFOLIO_CAP 兜底 | -| PMS_MACRO_WINDOW_TDAYS | 3 | 宏观命令的执行窗口 | -| PMS_MACRO_RESPECT_BRAKE | True | 组合刹车期间不自动升仓(假设项,见 §12.4) | -| PMS_MACRO_STALE_TDAYS | 3 | 任一数据源日龄超此(交易日)→ UNAVAILABLE | -| PMS_MACRO_RET_WIN / Z_WIN | 20 / 40 | 收益窗 / Z-Score 窗 | -| PMS_MACRO_SHIBOR_BETA | 0.02 | SHIBOR 修正系数 β | - -`_RANGES` 同步登记,`DESC` 写中文说明。**不进 FAIL_CLOSED**:ENABLED 的文件初值就是 False,表读不到退初值即安全方向。 - ---- - -## 9. 触碰面清单与三条铁律自证 - -新增文件(5 个,互相独立,删净即回到今天——"三题"规矩第 3 题): - -| 文件 | 内容 | -|---|---| -| app/core/macro_rules.py | 纯逻辑:对齐与四步计算、zone 迟滞判定、对数映射 decide(无 IO,可单测) | -| app/repo/macro_repo.py | 三张源表单表取数 + pms_macro_signal 读写 | -| app/services/macro_service.py | 编排:守卫→取数→算→落表→决策→分流→留痕;status / adopt / gate_state | -| scripts/calibrate_macro_signal.py | 标定脚本(**已交付**,探查+分布+口径对比+对数参数,只读) | -| scripts/test_batch14_units.py | 单测约 25 例(§11) | - -触碰的既有文件(9 处,**全部纯增量**——只加行/加检查,不改既有行为,逐处可 diff 核对): - -| 文件 | 加什么 | 不动什么 | -|---|---|---| -| config/settings.py | PMS_MACRO_* 字段一段 | 既有字段零改动 | -| app/services/param_store.py | DESC / _RANGES / _range_check 各加宏观项 | 缓存、FAIL_CLOSED、既有键 | -| app/scheduler.py | `macro_scan` 任务 + beat 一行 | 既有九个调度位 | -| app/services/proposal_service.py | 两处入口各加一个宏观闸检查(与 exec_halt 检查并排,闸关/读不到=原行为) | 扫描、闸门、分流逻辑零改动 | -| app/web/main.py | 三个新端点 | 既有端点 | -| app/web/static/index.html | 一块面板 | 既有面板 | -| ddl_pms_v1.sql | 第 18 张表 CREATE IF NOT EXISTS | 既有表 | -| scripts/check_db.py | PMS_TABLES 加 pms_macro_signal | (顺带发现:pms_strategy / pms_op_log 也不在这份清单里,属既有欠账,按"不夹带"纪律单独提,不在本次一起改) | -| scripts/run_tests.py | SUITES 登记 batch14 | 既有批次 | - -三条铁律逐条自证:**命令至上**——宏观动作本身就是命令,冲突让位用户,可撤可改;宏观闸只拦 PMS 自主动作,用户命令与减持全不受限。**分工不越权**——宏观层只回答"整体该多重、增量该不该停",逐股怎么卖买仍由 planner/择时/规则闸决定,不碰决策系统(bionic/桥/择时层**零改动**,"三题"规矩天然满足)。**先记账后动作 + 故障即守成**——信号先落表再决策,命令走 issue 的先落表路径,任何取数/计算失败 = 不产生新指令、不落闸。 - ---- - -## 10. 前置标定(先于一切编码,脚本已交付) - -`scripts/calibrate_macro_signal.py`(**严格只读**,只发 SELECT/SHOW)在桥机跑,一次回答四件事: - -1. **列名探查**:对 153(proxy)与 199(index)两源探查三张表的位置、列名、样本行——结果回填 §4.2 后才写 macro_repo; -2. **分布体检**:hedge_index 历史分位数、±20/25/30 各阈值的触发天数与年频次、极值区段的持续天数与最大深度——回答"±25 合不合身"; -3. **触发口径对比**:zone_enter vs zone_exit 各自触发事件后 5/10/20 交易日上证的均值/中位数/胜率(HOT 后应偏负、COLD 后应偏正才算有效)——**注意样本量会很小,结论当参考不当真理**; -4. **对数映射标定**:极值周期超额深度 e 的分布 → 解出 S0/k 建议值(e 中位数→5 点、95 分位→12 点、封顶 20 点),并打 e→调整幅度对照表。 - -**运行方式(不需要重建镜像**——脚本从宿主工作树经 stdin 喂给容器 python,`python -` 的 sys.path[0] 是工作目录 /app,`config.settings` 照常可导入): - -```bash -cd ~/tradingSystem # git pull 之后 -docker compose run --rm -T pms-web python - < scripts/calibrate_macro_signal.py > /tmp/macro_calib_report.md -cat /tmp/macro_calib_report.md -# 需要完整序列时: -docker compose run --rm -T pms-web python - --dump-csv < scripts/calibrate_macro_signal.py > /tmp/hedge_series.csv -``` - -探查失败的退路:脚本会打出该表全部列名与样本行,按提示带 `--fx-table/--fx-date-col/--fx-price-col/--fx-where/--shibor-*` 覆盖重跑;表真不存在 → 数据先落地(数据管道补),或立项由桥/择时层供数(跨系统改动,按"三题"规矩单独议,本方案不带)。 - ---- - -## 11. 实施步骤与判收 - -| 步 | 内容 | 判收标准 | -|---|---|---| -| 1 | 桥机跑标定脚本(§10,可立即做) | 三表列名落实;报告出分布/口径对比/S0-k 建议;把报告拿回来定 §8 带 ⚙ 的初值 | -| 2 | 写码 + batch14 单测 | `make test` ALL SUITES PASS。用例:合成序列对照手算值验四步计算;fx +1 对齐与缺口 as-of;样本不足/停更→UNAVAILABLE;zone 迟滞进出与横跳;对数映射(target 单调、同深度不重复加码、周期清零、封顶);confirm/cooldown/每日一次;零方案命令不占冷却;冲突/HALT_BUY/刹车跳过(打桩);宏观闸(HOT 拦买入侧不拦 TRIM、读不到不拦、off 档不拦);建议采纳幂等与隔日作废 | -| 3 | ddl 建表 + `make deploy`(收盘后) | `make check` 见 18 表;页面参数区出现 PMS_MACRO_*,ENABLED=False,系统行为与部署前一致 | -| 4 | 手动 `POST /api/ops/macro-scan?dry_run=true` | 面板出指数值与区制;与标定脚本同日值一致(同一套纯函数,天然一致) | -| 5 | 开 ENABLED=True,档位 propose_only,观察 ≥1 周 | 每日一行信号历史;极值日出建议卡不出命令;宏观闸在 HOT 日真的把自主增持 skipped(留痕可查);采纳一次走通 建议→命令→方案→指令 全链 | -| 6 | 冲突演练:挂一条在途 INCREASE,手动扫描触发降仓 | 账本见 MACRO REJECT 留痕,无命令产生 | -| 7 | (拍板后)切 full | 首条自动命令端到端回执,日报关注区可见 | - -回滚:页面把 `PMS_MACRO_ENABLED` 置 False 即回到现状(秒级,命令、闸、建议全停);代码级回滚 = 还原上表 9 处触碰 + 删 5 个新文件;新表留着无任何读方,无副作用。 - ---- - -## 12. 待拍板清单(V2 更新) - -1. ~~触发口径默认值~~ → **改为由标定结果定**:跑完 §10 脚本,拿两种口径的事后对比数据来选(样本小的话建议 zone_enter + CONFIRM_DAYS 取 1~2 里标定更稳的那档)。 -2. ~~固定步长 vs 目标带~~ → **已定:对数映射**(用户拍板)。S0/k/SHIFT_MAX 初值等标定回填。 -3. **策略层(网格/做T)的买入侧要不要受宏观闸**:V2 建议先不动(理由 §5.2 末),要管的话复用 `strategy_service.pause_buy` 一行接入。 -4. **升仓是否受组合刹车约束**:上轮问题未获直接答复,V2 按**受约束(True)**落地并标注为假设;要放开改参数即可。 -5. **数据源**:已答——153。zs_day_data 是否也统一走 153 由探查定。 - -## 13. 风险与已知限制(说在前面) - -- **信号本身是慢变量**:20 日收益 + 40 日 Z,天然滞后,抓的是阶段冷热不是日内顶底;对数映射 + 冷却是为此设的缓冲。 -- **z-score 的尾部行为**:40 日窗里刚经历过极端行情时 std 变大,新极端可能"钝化";反之平静期小波动被放大。标定报告的分位数表就是用来看这个的。 -- **事后对比样本小**:±25 级别的极值一年可能只有几次,标定里的口径对比是参考不是显著性检验,别过度拟合。 -- **升仓依赖候选池**:极冷区触发升仓但当日无强传导候选 → 命令零方案 CANCELLED(留痕可见,不占冷却,次日重试)——"宁缺毋滥"纪律优先于宏观意愿,这是设计取向不是缺陷。 -- **宏观闸的机会成本**:偏热确认期间个股级的好机会也会被一并拦下(闸不看个股质地)——这是"个股动作受宏观控制"的题中之义;propose_only 观察期里可以从 skipped 留痕回看拦了什么,评估闸的松紧。 -- **UNAVAILABLE 长期化**:数据停更时宏观层安静失效(不动作、不落闸),只有面板黄条与日报提示——不会误动作,但也不会提醒你"该人工判断了",需要把面板纳入日常一眼。 +页面面板内容:指数当前值和区域色带(热红、冷蓝、中性灰、不可用黄)、近二十日迷你走势、当日建议卡带采纳与忽略按钮、宏观闸状态条、最近一条宏观命令的进度。参数不用做界面,登记后自动出现在参数设置页。 diff --git a/app/core/macro_rules.py b/app/core/macro_rules.py new file mode 100644 index 0000000..a0f529a --- /dev/null +++ b/app/core/macro_rules.py @@ -0,0 +1,345 @@ +# -*- coding: utf-8 -*- +""" +宏观择时 · 纯逻辑 (计算 / 区域判定 / 决策; 无外部依赖, 可单测) +================================================================ +设计与参数依据: MACRO_TIMING_PLAN.md (V3) 与标定报告 MACRO_CALIB_2026-08-18.md。 +取数与落表在 repo/macro_repo.py, 编排与下命令在 services/macro_service.py, 本模块只算数。 + +三块纯函数: + 1. compute_hedge_index 股汇对冲指数四步计算 (含 as-of 对齐与数据守卫) + 2. zone_next 区域判定 (带迟滞: 进出阈值不同, 防止阈值附近横跳反复触发) + 3. carry_cycle / decide 极值周期状态推进 与 升降仓决策 (对数映射 + 分方向触发口径) + +口径钉死 (与标定脚本 scripts/calibrate_macro_signal.py 完全一致, 单测对照): + * 汇率日期先 +1 自然日再 as-of 对齐国内交易日 (当日无值沿用最近前值); + * 滚动标准化用样本标准差 (除以 n-1), 窗口内任一天缺值即该日不出值; + * 指数 = z 分数 × 10。 +""" +from __future__ import annotations + +import math +from datetime import datetime, timedelta + +# 区域 (zone) 词表 —— 与 pms_macro_signal.zone 列一致 +Z_HOT, Z_COLD, Z_NEUTRAL, Z_UNAVAILABLE = "HOT", "COLD", "NEUTRAL", "UNAVAILABLE" + +# 触发口径 +TRIG_ENTER, TRIG_EXIT = "zone_enter", "zone_exit" + +# 动作方向 +ACT_REDUCE, ACT_INCREASE = "REDUCE", "INCREASE" + + +# ================================================================ 日期与对齐 +def norm_ymd(v): + """任意日期形态归一为整数 YYYYMMDD; 解析不了返回 None。""" + if v is None: + return None + if hasattr(v, "strftime"): + return int(v.strftime("%Y%m%d")) + s = str(v).strip()[:10].replace("-", "").replace("/", "") + if len(s) >= 8 and s[:8].isdigit(): + return int(s[:8]) + return None + + +def ymd_plus_days(ymd: int, n: int) -> int: + d = datetime.strptime(str(ymd), "%Y%m%d").date() + timedelta(days=n) + return int(d.strftime("%Y%m%d")) + + +def ymd_gap_days(a: int, b: int) -> int: + """两个 YYYYMMDD 之间差多少个自然日 (a - b)。""" + da = datetime.strptime(str(a), "%Y%m%d").date() + db = datetime.strptime(str(b), "%Y%m%d").date() + return (da - db).days + + +def asof_align(trade_days: list, series: list, shift_days: int = 0) -> tuple: + """把 (ymd, value) 序列 as-of 对齐到交易日历。 + + shift_days 先把数据日期整体后移 N 个自然日 (汇率用 +1)。对齐规则: 每个交易日取 + 「日期不晚于它的最近一个值」; 找不到任何前值的交易日置 None。 + 返回 (对齐后的值列表, 非精确匹配而沿用前值的天数)。 + """ + if shift_days: + series = [(ymd_plus_days(d, shift_days), v) for d, v in series] + series.sort(key=lambda x: x[0]) + vals, filled, j, last = [], 0, 0, None + for t in trade_days: + while j < len(series) and series[j][0] <= t: + last = series[j][1] + j += 1 + exact = j > 0 and series[j - 1][0] == t + if last is not None and not exact: + filled += 1 + vals.append(last) + return vals, filled + + +def log_rets(vals: list, win: int) -> list: + out = [None] * len(vals) + for i in range(win, len(vals)): + a, b = vals[i], vals[i - win] + if a and b and a > 0 and b > 0: + out[i] = math.log(a / b) + return out + + +def diffs(vals: list, win: int) -> list: + out = [None] * len(vals) + for i in range(win, len(vals)): + if vals[i] is not None and vals[i - win] is not None: + out[i] = vals[i] - vals[i - win] + return out + + +def roll_z_last(vals: list, win: int): + """最后一个点的滚动 z 分数 ×10 (样本标准差)。窗口不满或 std 为零返回 (None, 原因)。""" + w = [v for v in vals[-win:] if v is not None] + if len(w) < win: + return None, f"标准化窗口不满 ({len(w)}/{win})" + m = sum(w) / len(w) + var = sum((x - m) ** 2 for x in w) / (len(w) - 1) + sd = math.sqrt(var) + if sd <= 1e-12: + return None, "标准差为零 (序列长期不变, 数据可疑)" + last = vals[-1] + if last is None: + return None, "当日修正值缺失" + return (last - m) / sd * 10.0, "" + + +# ================================================================ 指数计算 +def compute_hedge_index(inputs: dict, params: dict, today_ymd: int) -> dict: + """股汇对冲指数四步计算 + 数据守卫。 + + inputs: {"zs": [(ymd, close)...升序], "fx": [...], "shibor": [...]} + params: {"ret_win", "z_win", "beta", "stale_tdays"} + 返回 {"ok", "ymd"(信号交易日=上证末日), "value", "detail", "reason"}。 + 守卫不过时 ok=False, value=None —— 调用方按 UNAVAILABLE 处理 (不动作、不落闸)。 + """ + ret_win = int(params.get("ret_win") or 20) + z_win = int(params.get("z_win") or 40) + beta = float(params.get("beta") or 0.02) + stale = int(params.get("stale_tdays") or 3) + + zs = sorted([(norm_ymd(d), float(v)) for d, v in (inputs.get("zs") or []) + if norm_ymd(d) and v], key=lambda x: x[0]) + fx = sorted([(norm_ymd(d), float(v)) for d, v in (inputs.get("fx") or []) + if norm_ymd(d) and v], key=lambda x: x[0]) + sh = sorted([(norm_ymd(d), float(v)) for d, v in (inputs.get("shibor") or []) + if norm_ymd(d) and v is not None], key=lambda x: x[0]) + + need = ret_win + z_win + 5 + if len(zs) < need: + return {"ok": False, "ymd": zs[-1][0] if zs else today_ymd, "value": None, + "detail": {}, "reason": f"上证样本不足 ({len(zs)} < {need})"} + if not fx or not sh: + return {"ok": False, "ymd": zs[-1][0], "value": None, "detail": {}, + "reason": "汇率或利率序列为空"} + + # 数据新鲜度: 任一源末日落后今天超过 stale 个交易日判不可用。 + # 交易日距离用「自然日 ×2」宽松换算 (与 market.get_refs 同一手法), 不依赖交易日历。 + ages = {"zs": ymd_gap_days(today_ymd, zs[-1][0]), + "fx": ymd_gap_days(today_ymd, fx[-1][0]), + "shibor": ymd_gap_days(today_ymd, sh[-1][0])} + worst = max(ages, key=lambda k: ages[k]) + if ages[worst] > stale * 2: + return {"ok": False, "ymd": zs[-1][0], "value": None, + "detail": {"ages_days": ages}, + "reason": f"数据停更: {worst} 最新 {ages[worst]} 天前 " + f"(允许 {stale} 个交易日, 按自然日×2 宽松换算)"} + + tdays = [d for d, _ in zs] + close = [v for _, v in zs] + fx_al, fx_fill = asof_align(tdays, fx, shift_days=1) # 汇率 +1 自然日再 as-of + sh_al, sh_fill = asof_align(tdays, sh, shift_days=0) + + sr = log_rets(close, ret_win) + fr = log_rets(fx_al, ret_win) + sd = diffs(sh_al, ret_win) + spread_adj = [None if (sr[i] is None or fr[i] is None or sd[i] is None) + else sr[i] + fr[i] - beta * sd[i] for i in range(len(tdays))] + + val, why = roll_z_last(spread_adj, z_win) + if val is None: + return {"ok": False, "ymd": tdays[-1], "value": None, + "detail": {"ages_days": ages}, "reason": why} + i = len(tdays) - 1 + detail = {"stock_ret": round(sr[i], 6) if sr[i] is not None else None, + "fx_ret": round(fr[i], 6) if fr[i] is not None else None, + "shibor_d20": round(sd[i], 4) if sd[i] is not None else None, + "spread_adj": round(spread_adj[i], 6), + "zs_last": tdays[-1], "fx_last": fx[-1][0], "shibor_last": sh[-1][0], + "fx_filled": fx_fill, "shibor_filled": sh_fill, + "ages_days": ages, "samples": len(tdays)} + return {"ok": True, "ymd": tdays[-1], "value": round(val, 4), + "detail": detail, "reason": ""} + + +# ================================================================ 区域判定 (迟滞) +def zone_next(value, prev_zone: str, params: dict) -> str: + """区域状态机。进入阈值与退出带不同 (迟滞), 防止指数在阈值附近横跳造成反复触发。 + + value 为 None → UNAVAILABLE。上一区域是 UNAVAILABLE 时按 NEUTRAL 的进入规则重新判。 + 偏热区允许直接翻到偏冷区 (单日巨变), 反之亦然 —— 由进入条件自然覆盖。 + """ + hot_th = float(params.get("hot_th") or 25.0) + cold_th = float(params.get("cold_th") or -20.0) + band = float(params.get("exit_band") or 15.0) + if value is None: + return Z_UNAVAILABLE + v = float(value) + prev = prev_zone if prev_zone in (Z_HOT, Z_COLD) else Z_NEUTRAL + if prev == Z_HOT: + if v < cold_th: + return Z_COLD + return Z_HOT if v >= band else Z_NEUTRAL + if prev == Z_COLD: + if v > hot_th: + return Z_HOT + return Z_COLD if v <= -band else Z_NEUTRAL + if v > hot_th: + return Z_HOT + if v < cold_th: + return Z_COLD + return Z_NEUTRAL + + +def depth_of(value, zone: str, params: dict) -> float: + """超额深度 e = 越过进入阈值多少个指数点。迟滞延续期内可能为负, 一律截到 0。""" + if value is None or zone not in (Z_HOT, Z_COLD): + return 0.0 + if zone == Z_HOT: + return max(0.0, float(value) - float(params.get("hot_th") or 25.0)) + return max(0.0, float(params.get("cold_th") or -20.0) - float(value)) + + +def carry_cycle(prev_zone: str, prev_cycle, zone: str, value, params: dict, + today_ymd: int) -> tuple: + """极值周期状态推进。返回 (今日 cycle, 退出事件)。 + + cycle = {"zone","start","streak","e_peak","done_shift"}; 不在极值区时为 None。 + 退出事件 = 上一日的 cycle (从极值区回到带内那天触发, 供偏热侧「回落再动」取峰值深度)。 + 同日重扫的合并 (保留当日已执行量) 由调用方做, 本函数只管「昨天 → 今天」的推进。 + """ + exit_event = None + if zone in (Z_HOT, Z_COLD): + e_now = depth_of(value, zone, params) + if prev_cycle and prev_cycle.get("zone") == zone: + cyc = {"zone": zone, "start": prev_cycle.get("start") or today_ymd, + "streak": int(prev_cycle.get("streak") or 0) + 1, + "e_peak": max(float(prev_cycle.get("e_peak") or 0.0), e_now), + "done_shift": float(prev_cycle.get("done_shift") or 0.0)} + else: + # 换区 (含 HOT 直接翻 COLD): 旧周期算退出, 新周期从头计 + if prev_cycle and prev_cycle.get("zone") in (Z_HOT, Z_COLD): + exit_event = dict(prev_cycle) + cyc = {"zone": zone, "start": today_ymd, "streak": 1, + "e_peak": e_now, "done_shift": 0.0} + return cyc, exit_event + if prev_cycle and prev_cycle.get("zone") in (Z_HOT, Z_COLD): + exit_event = dict(prev_cycle) + return None, exit_event + + +# ================================================================ 对数映射与决策 +def log_target(e: float, params: dict) -> float: + """周期累计目标调整幅度 target(e) = min(S0·ln(1+e/k), SHIFT_MAX)。e ≤ 0 → 0。""" + s0 = float(params.get("log_s0") or 0.049) + k = float(params.get("log_k") or 1.6) + cap = float(params.get("shift_max") or 0.20) + if e is None or e <= 0 or k <= 0 or s0 <= 0: + return 0.0 + return min(s0 * math.log(1.0 + e / k), cap) + + +def decide(*, zone: str, cycle, exit_event, exit_acted: bool, e_now: float, + flags: dict, params: dict) -> dict: + """升降仓决策 (纯逻辑)。一次扫描至多给出一个动作。 + + flags 由调用方备齐: + portfolio_pct 当前总仓位 (占规模, 0~1) + portfolio_cap 总仓上限 (加仓天花板的兜底) + buy_halt 全局暂停买入生效中 + brake_active 组合刹车生效中 + inflight 署名 macro 的命令仍在途 + cooldown_left_reduce / cooldown_left_increase 同方向冷却剩余交易日 + 返回 {"action": None|REDUCE|INCREASE, "pct", "why", "blocked": [原因]}。 + blocked 非空表示「本想动但被拦」—— 调用方据此留痕; 平静日两者皆空。 + """ + out = {"action": None, "pct": 0.0, "why": "", "blocked": []} + confirm = max(1, int(params.get("confirm_days") or 1)) + step_min = float(params.get("step_min") or 0.02) + trig_hot = str(params.get("trigger_hot") or TRIG_EXIT) + trig_cold = str(params.get("trigger_cold") or TRIG_ENTER) + pf = float(flags.get("portfolio_pct") or 0.0) + + def _want(direction): + """算出本次想动的步长 (未过让路检查)。返回 (step, why) 或 (0, '')。""" + if direction == ACT_REDUCE: + mode, ez = trig_hot, Z_HOT + else: + mode, ez = trig_cold, Z_COLD + if mode == TRIG_ENTER: + if not (zone == ez and cycle and int(cycle.get("streak") or 0) >= confirm): + return 0.0, "" + done = float(cycle.get("done_shift") or 0.0) + step = log_target(e_now, params) - done + if done <= 0 and 0 < step < step_min: + step = step_min # 确认当日首步保底 + if step < step_min: + return 0.0, "" + return step, (f"进入{'偏热' if ez == Z_HOT else '偏冷'}区第 {cycle.get('streak')} 天, " + f"深度 {e_now:.1f}, 周期已调 {done:.2%}") + # 回落再动: 只在退出事件当天触发一次 + if not (exit_event and exit_event.get("zone") == ez and not exit_acted): + return 0.0, "" + if zone in (Z_HOT, Z_COLD): + return 0.0, "" # 直接翻到另一极值区: 旧周期退出不动作 + peak = float(exit_event.get("e_peak") or 0.0) + step = max(min(log_target(peak, params), float(params.get("shift_max") or 0.20)), + step_min) + return step, (f"{'偏热' if ez == Z_HOT else '偏冷'}周期结束 (峰值深度 {peak:.1f}), " + f"回落穿出退出带") + + # ---- 偏热 → 降仓 ---- + step, why = _want(ACT_REDUCE) + if step > 0: + blocked = [] + if flags.get("inflight"): + blocked.append("上一条宏观命令仍在途, 不叠加") + if int(flags.get("cooldown_left_reduce") or 0) > 0: + blocked.append(f"降仓冷却剩 {flags['cooldown_left_reduce']} 个交易日") + floor = float(params.get("min_pct") or 0.20) + step = min(step, pf - floor) + if step < step_min: + blocked.append(f"已到宏观降仓地板 (当前仓位 {pf:.1%}, 地板 {floor:.0%})") + if blocked: + return {**out, "blocked": [f"想降仓但被拦: {b}" for b in blocked], "why": why} + return {**out, "action": ACT_REDUCE, "pct": round(step, 4), "why": why} + + # ---- 偏冷 → 升仓 ---- + step, why = _want(ACT_INCREASE) + if step > 0: + blocked = [] + if flags.get("inflight"): + blocked.append("上一条宏观命令仍在途, 不叠加") + if int(flags.get("cooldown_left_increase") or 0) > 0: + blocked.append(f"升仓冷却剩 {flags['cooldown_left_increase']} 个交易日") + if flags.get("buy_halt"): + blocked.append("全局暂停买入生效中") + if flags.get("brake_active") and bool(params.get("respect_brake", True)): + blocked.append("组合刹车生效中 (宏观自动升仓让位)") + cap = float(flags.get("portfolio_cap") or 1.0) + max_pct = float(params.get("max_pct") or 0.0) + ceiling = min(cap, max_pct) if max_pct > 0 else cap + step = min(step, ceiling - pf) + if step < step_min: + blocked.append(f"已到升仓天花板 (当前仓位 {pf:.1%}, 天花板 {ceiling:.0%})") + if blocked: + return {**out, "blocked": [f"想升仓但被拦: {b}" for b in blocked], "why": why} + return {**out, "action": ACT_INCREASE, "pct": round(step, 4), "why": why} + + return out diff --git a/app/repo/macro_repo.py b/app/repo/macro_repo.py new file mode 100644 index 0000000..1e5be75 --- /dev/null +++ b/app/repo/macro_repo.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +""" +宏观择时 · 取数与落表 (严格单表访问) +====================================== +两类访问, 都走 153 代理 (source="proxy"): + 1. 三张行情源表只读 —— 表位置与列名由 2026-08-18 标定实跑钉死 + (报告 MACRO_CALIB_2026-08-18.md 第一节, 改列名先改那份依据再改这里): + zs_day_data 上证日线 symbol='000001.SH', 列 timestamp / close + gp_fx_daily 美元兑离岸人民币 ts_code='USDCNH.FXCM', 列 trade_date / bid_close + gp_shibor 拆借利率宽表 列 trade_date / rate_1m (单位百分数) + 2. 信号历史表 pms_macro_signal 读写 —— 每信号每交易日一行, 重扫就地更新 (唯一键)。 + +计算不在这里 (core/macro_rules.py), 决策与下命令也不在这里 (services/macro_service.py)。 +""" +from __future__ import annotations + +import json +import logging +from datetime import datetime + +from app.db.session import execute, fetch_all, fetch_one + +logger = logging.getLogger("pms.macro_repo") + +# 源表口径 (标定钉死) +ZS_SYMBOL = "000001.SH" +FX_TSCODE = "USDCNH.FXCM" + + +def _dumps(v): + return json.dumps(v, ensure_ascii=False) if not isinstance(v, (str, type(None))) else v + + +def _loads(v): + if v in (None, ""): + return {} + try: + return json.loads(v) if isinstance(v, str) else dict(v) + except (TypeError, ValueError): + return {} + + +# ================================================================ 行情源表 (只读) +def fetch_hedge_inputs(limit_days: int = 140) -> dict: + """股汇对冲指数的三条原始序列, 各取最近 N 条, 返回升序 (ymd, value) 列表。 + + N 默认 140 = 收益窗 20 + 标准化窗 40 + 富余; 单表查询, 符合代理的单表纪律。 + 任一查询失败向上抛 (DBUnavailable), 由 macro_service 按「不可用 → 不动作」处理。 + """ + n = max(80, int(limit_days)) + zs = fetch_all( + "SELECT `timestamp` AS d, `close` AS v FROM zs_day_data " + "WHERE symbol = :s ORDER BY `timestamp` DESC LIMIT :n", + {"s": ZS_SYMBOL, "n": n}, source="proxy") + fx = fetch_all( + "SELECT trade_date AS d, bid_close AS v FROM gp_fx_daily " + "WHERE ts_code = :c ORDER BY trade_date DESC LIMIT :n", + {"c": FX_TSCODE, "n": n}, source="proxy") + sh = fetch_all( + "SELECT trade_date AS d, rate_1m AS v FROM gp_shibor " + "ORDER BY trade_date DESC LIMIT :n", + {"n": n}, source="proxy") + + def _ser(rows): + out = [] + for r in rows: + if r.get("v") is None: + continue + out.append((r["d"], float(r["v"]))) + out.reverse() # DESC 取回 → 升序返回 + return out + + return {"zs": _ser(zs), "fx": _ser(fx), "shibor": _ser(sh)} + + +# ================================================================ pms_macro_signal +def upsert_signal(*, signal_key: str, trade_date: int, value, zone: str, + detail=None, action: str = "NONE", ref_id: str = "", + note: str = "") -> int: + """写当日信号行; 同 (信号, 交易日) 重扫就地更新 (唯一键 uk_sig_date)。""" + return execute( + "INSERT INTO pms_macro_signal (signal_key, trade_date, value, zone, detail_json, " + "action, ref_id, note, updated_at) " + "VALUES (:k, :d, :v, :z, :dj, :a, :r, :nt, :ts) " + "ON DUPLICATE KEY UPDATE value = :v, zone = :z, detail_json = :dj, " + "action = :a, ref_id = :r, note = :nt, updated_at = :ts", + {"k": signal_key, "d": int(trade_date), "v": value, "z": zone, + "dj": _dumps(detail or {}), "a": action or "NONE", "r": ref_id or "", + "nt": (note or "")[:500], "ts": datetime.now()}, + source="proxy") + + +def get_signal(signal_key: str, trade_date: int): + r = fetch_one( + "SELECT * FROM pms_macro_signal WHERE signal_key = :k AND trade_date = :d " + "ORDER BY id DESC LIMIT 1", + {"k": signal_key, "d": int(trade_date)}, source="proxy") + if r: + r["detail"] = _loads(r.get("detail_json")) + return r + + +def recent_signals(signal_key: str, limit: int = 30) -> list: + """最近的信号行, 按交易日从新到旧。detail_json 已解析进 detail 键。""" + rows = fetch_all( + "SELECT * FROM pms_macro_signal WHERE signal_key = :k " + "ORDER BY trade_date DESC LIMIT :n", + {"k": signal_key, "n": max(1, int(limit))}, source="proxy") + for r in rows: + r["detail"] = _loads(r.get("detail_json")) + return rows diff --git a/app/scheduler.py b/app/scheduler.py index 1c73a0b..cf00535 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -9,6 +9,7 @@ | 命令轮询 | 每 1 分钟 (全天) | 新命令解析 → 方案生成 → 任务状态机推进 | | 盘中执行 | 交易时段每 1 分钟 | 择时出手 + 自主提议扫描 (执行器下一批交付) | | 信号消化 | 交易时段每 1 分钟 | 订阅决策系统盘中信号 (下一批交付) | +| 宏观择时 | 交易日 09:35 | 股汇对冲指数 → 升降仓命令或建议 + 宏观闸 | | 成交回放 | 交易时段每 1 分钟 | ws 逐笔入账 + trading_order 增量 + 轻对账 | | T 仓平回 | 14:50 (二期) | 做T强制平回 | | 日终结算 | 15:10 | 全量对账 / 除权 / 安全垫 / 命令进度日结 | @@ -180,6 +181,20 @@ def signal_digest(): return signal_service.consume() +@celery_app.task(name="pms.macro_scan") +@guard(trade_day=True, respect_exec_halt=True) +def macro_scan(): + """宏观择时 (09:35): 算股汇对冲指数 → 区域判定 → 升降仓命令或建议 + 个股宏观闸。 + + 定在 09:35 而非盘前: 盘前无实时价, positions_view 会把持仓标 price_ok=False, + planner._usable 排除这类票, 盘前下的降仓命令会被排成零方案直接取消。 + 09:35 行情已就位, 也在 08:40 拉候选池之后 (升仓有票可选)。指数用 T-1 日终数据, + 几点计算数值都一样。详见 MACRO_TIMING_PLAN.md 第六节。 + """ + from app.services import macro_service + return macro_service.scan() + + @celery_app.task(name="pms.t0_close") @guard(trade_day=True) def t0_close(): @@ -234,6 +249,7 @@ celery_app.conf.beat_schedule = { "replay_fills": {"task": "pms.replay_fills", "schedule": crontab(minute="*")}, "intraday_exec": {"task": "pms.intraday_exec", "schedule": crontab(minute="*")}, "signal_digest": {"task": "pms.signal_digest", "schedule": crontab(minute="*")}, + "macro_scan": {"task": "pms.macro_scan", "schedule": crontab(hour=9, minute=35)}, "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)}, diff --git a/app/services/macro_service.py b/app/services/macro_service.py new file mode 100644 index 0000000..7c85e25 --- /dev/null +++ b/app/services/macro_service.py @@ -0,0 +1,538 @@ +# -*- coding: utf-8 -*- +""" +宏观择时 · 编排 (取数 → 计算 → 落表 → 决策 → 分流 → 留痕) +============================================================ +设计: MACRO_TIMING_PLAN.md (V3)。参数依据: 标定报告 MACRO_CALIB_2026-08-18.md。 + +铁律落点: + * 唯一出口 —— 仓位变化只经 command_service.issue() 产生 (署名 macro), 加上一个 + 只拦增持的「宏观闸」; 不直接写方案/指令/持仓账本, 不碰执行器。 + * 先记账后动作 —— 信号先落 pms_macro_signal, 再决策下命令; 命令本身走 issue 的 + 先落表路径。 + * 故障即守成 —— 取数/计算/读组合任一失败: 不下命令、不落闸 (闸的安全方向是不额外拦, + 增持自有规则闸把关), 页面亮黄条。 + * 命令至上 —— 与用户在途命令冲突 (issue 返回 CONFLICT) 即放弃并留痕, 永不 force。 + +宏观闸的状态载体是运行参数 PMS_MACRO_GATE_STATE (JSON), 由本模块每次扫描时写入, +proposal_service 与 strategy_runner 经 gate_state() 只读 —— 不用表、不建消费组, +与 PMS_STRATEGY_BUYPAUSE / PMS_CUSHION_NEG_STREAK 用独立运行参数承载状态是同一个手法。 +调度位在 scheduler.macro_scan (交易日 09:35); 手动入口 POST /api/ops/macro-scan。 +""" +from __future__ import annotations + +import json +import logging +import threading +import time +from datetime import datetime + +from app.core import macro_rules as mr +from app.core import tradedays as td +from app.repo import macro_repo, pms_repo +from app.services import param_store + +logger = logging.getLogger("pms.macro") + +GATE_KEY = "PMS_MACRO_GATE_STATE" +GATE_FRESH_DAYS = 7 # 闸状态超过 7 个自然日没刷新 → 视为失效 (调度断了别让闸卡死) + +# 信号注册表: 接新宏观信号只加这里 + 各自参数 (命名空间 PMS_MACRO__*), +# 决策/执行/闸/页面零改动。fetch 返回原始序列, compute 是纯函数。 +# 用 lambda 包一层做**晚绑定**: 调用时才解析 macro_repo 的模块属性 —— 单测打桩 +# (替换模块函数) 才打得到; 直接存函数引用会把打桩绕过去, 测试全走真库。 +SIGNALS = { + "hedge_fx": {"label": "股汇对冲指数", + "fetch": lambda: macro_repo.fetch_hedge_inputs(), + "compute": lambda inputs, p, today: mr.compute_hedge_index( + inputs, p, today)}, +} + +A_NONE, A_ADV_RED, A_ADV_INC = "NONE", "ADVICE_REDUCE", "ADVICE_INCREASE" +A_CMD, A_BLOCKED = "CMD_ISSUED", "BLOCKED" +CMD_OF = {mr.ACT_REDUCE: "REDUCE_EXPOSURE", mr.ACT_INCREASE: "INCREASE_EXPOSURE"} +ADV_OF = {mr.ACT_REDUCE: A_ADV_RED, mr.ACT_INCREASE: A_ADV_INC} + +_gate_cache = {"at": 0.0, "data": None} +_gate_lock = threading.Lock() + + +# ================================================================ 参数 +def _params() -> dict: + """一次取齐全部宏观参数 (键名小写化, 供 macro_rules 纯函数用)。""" + g = param_store + return { + "enabled": g.get_bool("PMS_MACRO_ENABLED", False), + "autonomy": str(g.get("PMS_MACRO_AUTONOMY", "propose_only") or "propose_only"), + "signals": g.get_list("PMS_MACRO_SIGNALS", ["hedge_fx"]), + "stock_gate": g.get_bool("PMS_MACRO_STOCK_GATE", True), + "hot_th": g.get_float("PMS_MACRO_HOT_TH", 25.0), + "cold_th": g.get_float("PMS_MACRO_COLD_TH", -20.0), + "exit_band": g.get_float("PMS_MACRO_EXIT_BAND", 15.0), + "confirm_days": g.get_int("PMS_MACRO_CONFIRM_DAYS", 1), + "trigger_hot": str(g.get("PMS_MACRO_TRIGGER_HOT", mr.TRIG_EXIT) or mr.TRIG_EXIT), + "trigger_cold": str(g.get("PMS_MACRO_TRIGGER_COLD", mr.TRIG_ENTER) or mr.TRIG_ENTER), + "log_s0": g.get_float("PMS_MACRO_LOG_S0", 0.049), + "log_k": g.get_float("PMS_MACRO_LOG_K", 1.6), + "shift_max": g.get_float("PMS_MACRO_SHIFT_MAX", 0.20), + "step_min": g.get_float("PMS_MACRO_STEP_MIN", 0.02), + "cooldown_tdays": g.get_int("PMS_MACRO_COOLDOWN_TDAYS", 3), + "min_pct": g.get_float("PMS_MACRO_MIN_PCT", 0.20), + "max_pct": g.get_float("PMS_MACRO_MAX_PCT", 0.0), + "window_tdays": g.get_int("PMS_MACRO_WINDOW_TDAYS", 3), + "respect_brake": g.get_bool("PMS_MACRO_RESPECT_BRAKE", True), + "stale_tdays": g.get_int("PMS_MACRO_STALE_TDAYS", 3), + "ret_win": g.get_int("PMS_MACRO_RET_WIN", 20), + "z_win": g.get_int("PMS_MACRO_Z_WIN", 40), + "beta": g.get_float("PMS_MACRO_SHIBOR_BETA", 0.02), + } + + +def _enabled_signals(p: dict) -> list: + return [k for k in (p.get("signals") or []) if k in SIGNALS] + + +# ================================================================ 扫描主流程 +def scan(*, dry_run: bool = False, now=None) -> dict: + """每交易日一次的宏观扫描 (调度 09:35; 手动端点随时可跑, dry_run 只算不落表不下达)。 + + 非交易日经手动端点跑到这里不拦 (调度位自有交易日守卫), 只是行日期按自然日记。 + """ + now = now or datetime.now() + today = td.ymd(now) + p = _params() + out = {"ok": True, "enabled": p["enabled"], "autonomy": p["autonomy"], + "dry_run": dry_run, "signals": [], "actions": [], "skipped": [], "errors": []} + if not p["enabled"]: + out["skipped"].append("宏观择时总开关关闭 (PMS_MACRO_ENABLED=False)") + return out + if param_store.get_bool("PMS_GLOBAL_EXEC_HALT", False): + # 调度守卫已挡休假; 这里再兜手动端点这条路 + out["skipped"].append("全局暂停执行 (休假模式), 宏观扫描跳过") + return out + + hot_now = False + for key in _enabled_signals(p): + try: + r = _scan_one(key, SIGNALS[key], p, today, now, dry_run, out) + except Exception as e: # noqa: BLE001 —— 单信号异常不拖垮整跳 (守成) + logger.exception("[宏观] 信号 %s 扫描失败", key) + out["errors"].append(f"{key}: {type(e).__name__}: {e}") + continue + out["signals"].append(r) + if r.get("zone") == mr.Z_HOT: + hot_now = True + + # ---- 宏观闸状态落参数 (增持闸; off 档只看不动, 闸也不生效) ---- + gate_active = bool(p["stock_gate"] and p["autonomy"] != "off" and hot_now) + out["gate"] = {"active": gate_active, "ymd": today} + if not dry_run: + w = param_store.set_param(GATE_KEY, json.dumps( + {"active": gate_active, "ymd": today, + "why": "股汇对冲指数偏热确认" if gate_active else ""}, + ensure_ascii=False), "system") or {} + if not w.get("ok"): + # 闸状态没写上要吭声: 状态陈旧超 7 天 gate_state 会自动失效 (失效方向是不拦) + logger.error("[宏观] 闸状态写入失败: %s —— 闸维持上一次的旧状态", w.get("error")) + out["errors"].append(f"闸状态写入失败: {w.get('error')}") + with _gate_lock: + _gate_cache["at"] = 0.0 # 失效缓存, 下一读取新状态 + out["ok"] = not out["errors"] + return out + + +def _scan_one(key: str, spec: dict, p: dict, today: int, now, dry_run: bool, + out: dict) -> dict: + """单个信号: 取数 → 计算 → 区域与周期 → 落表 → 决策 → 分流。返回页面摘要。""" + label = spec.get("label") or key + + # ---- 取数与计算 (失败 → UNAVAILABLE 行, 不动作) ---- + try: + inputs = spec["fetch"]() + comp = spec["compute"](inputs, p, today) + except Exception as e: # noqa: BLE001 + logger.error("[宏观] %s 取数/计算失败: %s", key, e) + if not dry_run: + macro_repo.upsert_signal(signal_key=key, trade_date=today, value=None, + zone=mr.Z_UNAVAILABLE, detail={}, + note=f"取数失败: {type(e).__name__}: {e}") + return {"key": key, "label": label, "zone": mr.Z_UNAVAILABLE, "value": None, + "note": f"取数失败: {e}"} + + # ---- 昨日状态与同日重扫合并 ---- + try: + history = macro_repo.recent_signals(key, limit=15) + except Exception as e: # noqa: BLE001 —— 历史读不到按冷启动处理 (保守: 无周期无冷却豁免) + logger.warning("[宏观] %s 历史读取失败 (按冷启动): %s", key, e) + history = [] + prev_row = next((r for r in history if int(r["trade_date"]) < today), None) + today_row = next((r for r in history if int(r["trade_date"]) == today), None) + prev_zone = (prev_row or {}).get("zone") or mr.Z_NEUTRAL + prev_cycle = ((prev_row or {}).get("detail") or {}).get("cycle") + + if not comp["ok"]: + if not dry_run: + macro_repo.upsert_signal(signal_key=key, trade_date=today, value=None, + zone=mr.Z_UNAVAILABLE, detail=comp.get("detail"), + note=comp.get("reason") or "数据守卫未过") + return {"key": key, "label": label, "zone": mr.Z_UNAVAILABLE, "value": None, + "note": comp.get("reason")} + + value = comp["value"] + zone = mr.zone_next(value, prev_zone, p) + cycle, exit_event = mr.carry_cycle(prev_zone, prev_cycle, zone, value, p, today) + t_detail = (today_row or {}).get("detail") or {} + t_cycle = t_detail.get("cycle") or {} + if cycle and t_cycle.get("zone") == cycle.get("zone"): + # 同日重扫: 当日已执行量与峰值只增不减 (免得重扫把已下的命令"忘掉"再下一次) + cycle["done_shift"] = max(cycle["done_shift"], float(t_cycle.get("done_shift") or 0)) + cycle["e_peak"] = max(cycle["e_peak"], float(t_cycle.get("e_peak") or 0)) + exit_acted = bool(t_detail.get("exit_acted")) + e_now = mr.depth_of(value, zone, p) + detail = {**(comp.get("detail") or {}), "cycle": cycle, "e_now": round(e_now, 2)} + if exit_acted: + detail["exit_acted"] = True + # 当日已下过命令的行不回退动作字段与说明 (先记账后动作: 命令已在命令表, 行只增不改口) + action_field = A_CMD if (today_row or {}).get("action") == A_CMD else A_NONE + ref_id = (today_row or {}).get("ref_id") or "" + note = ((today_row or {}).get("note") if action_field == A_CMD else None) \ + or f"区域 {zone}, 指数 {value:+.1f}" + + if not dry_run: + macro_repo.upsert_signal(signal_key=key, trade_date=today, value=value, + zone=zone, detail=detail, action=action_field, + ref_id=ref_id, note=note) + + # ---- 决策 ---- + flags, flag_err = _flags(p, key, history, today) + if flag_err: + out["errors"].append(f"{key}: {flag_err}") + return {"key": key, "label": label, "zone": zone, "value": value, + "note": f"{note}; 组合状态读取失败, 本轮不决策"} + d = mr.decide(zone=zone, cycle=cycle, exit_event=exit_event, exit_acted=exit_acted, + e_now=e_now, flags=flags, params=p) + + summary = {"key": key, "label": label, "zone": zone, "value": value, + "e_now": round(e_now, 2), "streak": (cycle or {}).get("streak"), + "note": note} + if action_field == A_CMD: + summary["note"] = note + "; 当日已下过宏观命令, 本轮不重复" + return summary + + # ---- 想动但被拦: 留痕 (与当日已记录的同因不重复写) ---- + if d.get("blocked"): + why = "; ".join(d["blocked"])[:400] + if not dry_run: + _upsert_action(key, today, value, zone, detail, A_BLOCKED, "", why) + _ledger_once(today_row, A_BLOCKED, why, key, value, detail, verdict="REJECT") + summary["note"] = why + out["skipped"].append({"signal": key, "why": why}) + return summary + + if not d.get("action"): + return summary + + # ---- 触发: 按档位分流 ---- + direction, step = d["action"], float(d["pct"]) + why = f"{label} {value:+.1f} · {d.get('why') or ''}".strip() + if p["autonomy"] == "off": + summary["note"] = f"只看不动档: 想{_cn(direction)} {step:.1%} ({why})" + out["skipped"].append({"signal": key, "why": summary["note"]}) + return summary + + if dry_run: + out["actions"].append({"signal": key, "action": direction, "pct": step, + "route": "dry_run", "why": why}) + summary["note"] = f"试算: 将{_cn(direction)} {step:.1%}" + return summary + + if p["autonomy"] == "propose_only": + adv = ADV_OF[direction] + detail["advice"] = {"direction": direction, "pct": round(step, 4), "why": why, + "at": str(now)[:19]} + adv_note = f"建议{_cn(direction)} {step:.1%} (待页面采纳, 当日有效)" + _upsert_action(key, today, value, zone, detail, adv, "", adv_note) + _ledger_once(today_row, adv, adv_note, key, value, detail, verdict="NOTE") + out["actions"].append({"signal": key, "action": direction, "pct": step, + "route": "advice", "why": why}) + summary["note"] = adv_note + return summary + + # full: 直接下命令 (issued_by=macro; 冲突即弃, 永不 force) + r = _issue(direction, step, p, why, issued_by="macro") + if r.get("ok"): + cid = r.get("command_id") or "" + _mark_acted(detail, direction, step, p) + _upsert_action(key, today, value, zone, detail, A_CMD, cid, + f"已下{_cn(direction)}命令 {step:.1%} → {cid}") + _ledger_once(None, A_CMD, why, key, value, detail, verdict="PASS", ref_id=cid) + out["actions"].append({"signal": key, "action": direction, "pct": step, + "route": "command", "command_id": cid, "why": why}) + summary["note"] = f"已下{_cn(direction)}命令 {cid}" + else: + errs = "; ".join(str(x) for x in (r.get("errors") or [])) or "下达失败" + blocked_note = f"想{_cn(direction)} {step:.1%} 但命令未成: {errs}"[:400] + _upsert_action(key, today, value, zone, detail, A_BLOCKED, "", blocked_note) + _ledger_once(today_row, A_BLOCKED, blocked_note, key, value, detail, + verdict="REJECT") + out["skipped"].append({"signal": key, "why": blocked_note}) + summary["note"] = blocked_note + return summary + + +def _cn(direction: str) -> str: + return "降仓" if direction == mr.ACT_REDUCE else "升仓" + + +def _issue(direction: str, step: float, p: dict, why: str, *, issued_by: str) -> dict: + from app.services import command_service + return command_service.issue( + CMD_OF[direction], + {"pct": round(step, 4), "window_tdays": p["window_tdays"]}, + issued_by=issued_by, note=f"宏观择时: {why}"[:280]) or {} + + +def _mark_acted(detail: dict, direction: str, step: float, p: dict): + """动作落地后推进周期状态: 进区模式累计已执行量; 回落模式记当日已动作。""" + mode = p["trigger_hot"] if direction == mr.ACT_REDUCE else p["trigger_cold"] + cyc = detail.get("cycle") + if mode == mr.TRIG_ENTER and cyc: + cyc["done_shift"] = round(float(cyc.get("done_shift") or 0) + step, 4) + else: + detail["exit_acted"] = True + + +def _upsert_action(key, today, value, zone, detail, action, ref_id, note): + try: + macro_repo.upsert_signal(signal_key=key, trade_date=today, value=value, zone=zone, + detail=detail, action=action, ref_id=ref_id, note=note) + except Exception as e: # noqa: BLE001 —— 行没写上要吭声, 命令/建议本体已各自落表 + logger.error("[宏观] %s 动作回写信号行失败: %s", key, e) + + +def _ledger_once(today_row, action_field, note, key, value, detail, *, verdict, + ref_id=None): + """评审账本留痕; 同日同动作同说明不重复写 (手动重扫防刷屏)。""" + if today_row and today_row.get("action") == action_field \ + and (today_row.get("note") or "") == (note or "")[:500]: + return + try: + pms_repo.insert_ledger( + ts_code="-", action="MACRO", arbiter="rule", verdict=verdict, price_at=0, + hard_numbers={"signal": key, "value": value, + "e_now": (detail or {}).get("e_now"), + "cycle": (detail or {}).get("cycle")}, + ref_id=ref_id, reason=(note or "")[:500]) + except Exception as e: # noqa: BLE001 + logger.warning("[宏观] 评审账本留痕失败: %s", e) + + +# ================================================================ 让路状态 +def _flags(p: dict, key: str, history: list, today: int) -> tuple: + """备齐 decide 所需的让路旗标。组合快照读不到 → 返回错误, 本轮不决策 (守成)。""" + try: + from app.services import portfolio + view = portfolio.positions_view() + pf = float(view["totals"].get("portfolio_pct") or 0.0) + cap = float(view["params"].get("portfolio_cap") or 1.0) + except Exception as e: # noqa: BLE001 + return {}, f"读组合快照失败: {type(e).__name__}: {e}" + inflight, last_red, last_inc = _action_history(history) + return { + "portfolio_pct": pf, "portfolio_cap": cap, + "buy_halt": param_store.get_bool("PMS_GLOBAL_BUY_HALT", False), + "brake_active": today < param_store.get_int("PMS_BRAKE_UNTIL", 0), + "inflight": inflight, + "cooldown_left_reduce": _cooldown_left(history, last_red, today, + p["cooldown_tdays"]), + "cooldown_left_increase": _cooldown_left(history, last_inc, today, + p["cooldown_tdays"]), + }, None + + +def _action_history(history: list) -> tuple: + """从信号历史行回查: 是否有署名 macro 的命令在途 + 两个方向最近一次生效动作日。 + + 「生效」= 所发命令没有被立刻取消 (零方案命令不占冷却, 次日可重试)。 + 只查最近几条 CMD_ISSUED 行, 逐条看命令状态 —— 不新增 repo 查询面。 + """ + from app.core import command_spec as cs + inflight, last_red, last_inc = False, None, None + looked = 0 + for r in history: + if r.get("action") != A_CMD or not r.get("ref_id"): + continue + looked += 1 + if looked > 5: + break + try: + cmd = pms_repo.get_command(r["ref_id"]) + except Exception: # noqa: BLE001 —— 查不到按保守方向: 当作在途, 今天不动 + logger.warning("[宏观] 命令 %s 状态查不到, 按在途处理 (保守)", r.get("ref_id")) + inflight = True + continue + if not cmd: + continue + if cmd.get("status") in cs.ACTIVE_TASK_STATES: + inflight = True + if cmd.get("status") != cs.ST_CANCELLED: + d = ((r.get("detail") or {}).get("advice") or {}).get("direction") \ + or _dir_of_cmd(cmd.get("cmd_type")) + ymd = int(r["trade_date"]) + if d == mr.ACT_REDUCE: + last_red = max(last_red or 0, ymd) + elif d == mr.ACT_INCREASE: + last_inc = max(last_inc or 0, ymd) + return inflight, last_red, last_inc + + +def _dir_of_cmd(cmd_type) -> str: + return {v: k for k, v in CMD_OF.items()}.get(str(cmd_type or ""), "") + + +def _cooldown_left(history: list, last_ymd, today: int, cooldown: int) -> int: + """冷却剩余交易日。交易日距离按信号历史行数 (每交易日一行); 缺行只会把冷却算长, + 方向保守。last_ymd 为空 = 从未动过, 无冷却。""" + if not last_ymd: + return 0 + dist = len({int(r["trade_date"]) for r in history + if last_ymd < int(r["trade_date"]) <= today}) + if not any(int(r["trade_date"]) == today for r in history): + dist += 1 # 今天的行还没写 (本跳首扫), 今天也算一天 + return max(0, int(cooldown) - dist) + + +# ================================================================ 宏观闸 (只读) +def gate_state() -> dict: + """个股宏观闸状态 (proposal_service / strategy_runner 每跳来读, 5 秒缓存)。 + + 任何一步失败一律返回不生效 —— 闸的安全方向是「不额外拦」: 买入本身另有规则闸把关, + 不能让宏观层故障把整个自主引擎摁死。状态超过 GATE_FRESH_DAYS 个自然日没刷新 + (调度断了) 同样失效。 + """ + now = time.time() + with _gate_lock: + if _gate_cache["data"] is not None and now - _gate_cache["at"] < 5.0: + return _gate_cache["data"] + out = {"active": False} + try: + if param_store.get_bool("PMS_MACRO_ENABLED", False) \ + and param_store.get_bool("PMS_MACRO_STOCK_GATE", True) \ + and str(param_store.get("PMS_MACRO_AUTONOMY", "propose_only")) != "off": + raw = param_store.get(GATE_KEY, "") or "" + st = json.loads(raw) if raw else {} + if st.get("active") and st.get("ymd"): + if mr.ymd_gap_days(td.ymd(), int(st["ymd"])) <= GATE_FRESH_DAYS: + out = {"active": True, "ymd": int(st["ymd"]), + "why": st.get("why") or "宏观偏热确认"} + else: + out = {"active": False, "stale": True} + except Exception as e: # noqa: BLE001 + logger.warning("[宏观] 闸状态读取失败 (按不生效): %s", e) + out = {"active": False, "error": str(e)} + with _gate_lock: + _gate_cache.update({"at": now, "data": out}) + return out + + +# ================================================================ 页面 +def status() -> dict: + """面板快照: 各信号的当前值/区域/近 20 日序列/当日建议/最近命令/冷却, 加闸状态。""" + p = _params() + today = td.ymd() + out = {"ok": True, "enabled": p["enabled"], "autonomy": p["autonomy"], + "gate": gate_state(), "params": { + "hot_th": p["hot_th"], "cold_th": p["cold_th"], + "exit_band": p["exit_band"], "cooldown_tdays": p["cooldown_tdays"], + "trigger_hot": p["trigger_hot"], "trigger_cold": p["trigger_cold"]}, + "signals": []} + for key in _enabled_signals(p): + item = {"key": key, "label": SIGNALS[key].get("label") or key} + try: + rows = macro_repo.recent_signals(key, limit=22) + except Exception as e: # noqa: BLE001 + item["error"] = f"{type(e).__name__}: {e}" + out["signals"].append(item) + continue + latest = rows[0] if rows else None + item["history"] = [{"ymd": int(r["trade_date"]), + "value": (None if r.get("value") is None + else float(r["value"])), + "zone": r.get("zone")} for r in reversed(rows)] + if latest: + det = latest.get("detail") or {} + item.update({ + "ymd": int(latest["trade_date"]), + "value": None if latest.get("value") is None else float(latest["value"]), + "zone": latest.get("zone"), "note": latest.get("note"), + "action": latest.get("action"), "ref_id": latest.get("ref_id"), + "e_now": det.get("e_now"), "cycle": det.get("cycle"), + "advice": (det.get("advice") + if int(latest["trade_date"]) == today + and str(latest.get("action") or "").startswith("ADVICE") + else None), + "data_ages": det.get("ages_days"), + }) + inflight, last_red, last_inc = _action_history(rows) + item["inflight"] = inflight + item["cooldown_left"] = { + "reduce": _cooldown_left(rows, last_red, today, p["cooldown_tdays"]), + "increase": _cooldown_left(rows, last_inc, today, p["cooldown_tdays"])} + last_cmd = next((r for r in rows if r.get("action") == A_CMD and r.get("ref_id")), + None) + if last_cmd: + brief = {"command_id": last_cmd.get("ref_id"), + "ymd": int(last_cmd["trade_date"])} + try: + cmd = pms_repo.get_command(last_cmd["ref_id"]) + if cmd: + prog = cmd.get("progress") or {} + brief.update({"status": cmd.get("status"), + "cmd_type": cmd.get("cmd_type"), + "done_amount": prog.get("done_amount"), + "target_amount": prog.get("target_amount")}) + except Exception: # noqa: BLE001 + pass + item["last_command"] = brief + out["signals"].append(item) + return out + + +# ================================================================ 建议采纳 +def adopt(signal_key: str = "hedge_fx") -> dict: + """页面一键采纳当日建议 → 以用户名义下命令 (用户点头就是用户意志)。幂等: 只能采纳一次。""" + p = _params() + today = td.ymd() + if not p["enabled"]: + return {"ok": False, "error": "宏观择时总开关关闭"} + try: + row = macro_repo.get_signal(signal_key, today) + except Exception as e: # noqa: BLE001 + return {"ok": False, "error": f"读当日信号失败: {type(e).__name__}: {e}"} + if not row: + return {"ok": False, "error": "今天还没有信号记录 (先扫描)"} + if row.get("action") == A_CMD: + return {"ok": False, "error": f"当日已下过命令 {row.get('ref_id')}, 不重复采纳"} + adv = (row.get("detail") or {}).get("advice") or {} + if not str(row.get("action") or "").startswith("ADVICE") or not adv.get("direction"): + return {"ok": False, "error": "当日没有待采纳的建议 (建议隔日作废, 次日重新评估)"} + if param_store.get_bool("PMS_GLOBAL_EXEC_HALT", False): + return {"ok": False, "error": "全局暂停执行 (休假模式) 生效中"} + direction, step = adv["direction"], float(adv.get("pct") or 0) + if direction == mr.ACT_INCREASE and param_store.get_bool("PMS_GLOBAL_BUY_HALT", False): + return {"ok": False, "error": "全局暂停买入生效中, 升仓建议不可采纳"} + if step <= 0: + return {"ok": False, "error": f"建议幅度非法 ({step})"} + + r = _issue(direction, step, p, adv.get("why") or "宏观建议", issued_by="user") + if not r.get("ok"): + return {"ok": False, "error": "; ".join(str(x) for x in (r.get("errors") or [])) + or "命令下达失败", "conflicts": r.get("conflicts")} + cid = r.get("command_id") or "" + detail = dict(row.get("detail") or {}) + detail["advice"] = {**adv, "adopted": True, "command_id": cid} + _mark_acted(detail, direction, step, p) + _upsert_action(signal_key, today, row.get("value"), row.get("zone"), detail, + A_CMD, cid, f"建议已采纳 → {cid}") + _ledger_once(None, A_CMD, f"宏观建议采纳: {_cn(direction)} {step:.1%}", + signal_key, row.get("value"), detail, verdict="PASS", ref_id=cid) + return {"ok": True, "command_id": cid, "direction": direction, "pct": step} diff --git a/app/services/param_store.py b/app/services/param_store.py index e023296..c7e0e3c 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -52,6 +52,9 @@ RUNTIME_EXTRA = { "PMS_UPSTREAM_ALERT_SCAN": (300, int, "告警扫描窗: 每轮从告警流取最近 N 条再分类限量 (窗要大于各类上限之和, 防 price_notice 等高频源把风控告警挤出最新N)"), "PMS_UPSTREAM_ALERT_PER_CAT": (20, int, "告警每类上限: 同一类型最多显示最新 N 条 (砍单类刷屏, 不挤别的类)"), "PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (60, int, "告警时间窗(分钟): 超过此龄判为陈旧并在页面置灰标注(不删除, 风控不丢信息), 0=不限龄。与每类上限正交, 先按龄标记再每类限量"), + # 2026-08-18 宏观择时层: 闸状态由 macro_service 每次扫描写入, 扫描层与策略层只读。 + # 用运行参数承载 (不进策略 state、不另建表) —— 与 PMS_STRATEGY_BUYPAUSE 同一手法。 + "PMS_MACRO_GATE_STATE": ("", str, "宏观闸状态 (macro_service 维护的 JSON: active/ymd/原因), 页面与扫描层只读, 勿手改"), } # **读不到时必须按"已暂停"处理的键 (fail-closed)。** @@ -66,6 +69,10 @@ FAIL_CLOSED = { # 参数表抖一下就按 full 走, 等于在读不到任何约束的情况下自己去买没买过的票。 # 读不到 → off (本轮不扫描新建仓), 已有持仓的四类动作不受影响。 "PMS_OPEN_AUTONOMY": "off", + # 宏观择时总开关的文件初值是 True (模拟仓拍板) —— 表读不到时若退文件初值, 宏观层会在 + # 基础设施故障期间照常想下命令。安全方向是关: 宁可少动一轮。取数环节同库先挂是第一道 + # 保险, 这条名单是第二道。 + "PMS_MACRO_ENABLED": False, } # 页面展示用的中文说明 (settings.py 用行尾注释, pydantic 取不到, 故在此集中维护) @@ -161,6 +168,29 @@ DESC = { "PMS_QMT_HEARTBEAT_STALE_SEC": "心跳陈旧超此秒数 → 判定 ws 进程已死, 指令一律拒发", "PMS_QMT_CONNECT_TIMEOUT_SEC": "建连超时 (秒)", "PMS_QMT_SEND_MAX_ATTEMPTS": "单张委托发送重试上限, 试满置 SEND_FAILED 等人工", + "PMS_MACRO_ENABLED": "宏观择时总开关 (关=调度位空转不取数不计算; 模拟仓默认开, 转实盘前改回关)", + "PMS_MACRO_AUTONOMY": "宏观档位 off 只算不动 / propose_only 出建议 / full 自动下命令 (模拟仓默认 full)", + "PMS_MACRO_SIGNALS": "启用的宏观信号清单 (逗号分隔, 当前仅 hedge_fx 股汇对冲指数)", + "PMS_MACRO_STOCK_GATE": "个股宏观闸: 偏热确认期间暂停自主增持 (OPEN/FILL/ADD/DCA) 与策略买开腿; 减持与用户命令不受限", + "PMS_MACRO_HOT_TH": "偏热进入阈值 (指数点; 标定 ±25 约每年 1.4 段)", + "PMS_MACRO_COLD_TH": "偏冷进入阈值 (负数; 标定 -20 约每年 4 段, 事后反弹更强)", + "PMS_MACRO_EXIT_BAND": "迟滞退出带 (|指数| 回落到此内算离区; 偏热侧它同时是降仓命令触发线)", + "PMS_MACRO_CONFIRM_DAYS": "偏冷进区连续 N 个交易日才触发 (标定: 等第二天反而更差, 取 1)", + "PMS_MACRO_TRIGGER_HOT": "偏热触发口径: zone_exit=极值回落再降仓 (标定采纳) / zone_enter=进区即降", + "PMS_MACRO_TRIGGER_COLD": "偏冷触发口径: zone_enter=进区即升仓 (标定采纳) / zone_exit=回落再升", + "PMS_MACRO_LOG_S0": "对数映射系数: 周期累计目标 = S0×ln(1+超额深度/k), 标定回填 0.049", + "PMS_MACRO_LOG_K": "对数映射尺度 k, 标定回填 1.6", + "PMS_MACRO_SHIFT_MAX": "单个极值周期累计调整封顶 (占规模百分点)", + "PMS_MACRO_STEP_MIN": "裁剪后步长小于此不动; 也是偏冷确认当日的首步保底", + "PMS_MACRO_COOLDOWN_TDAYS": "同方向两次宏观动作最小间隔 (交易日; 零方案被取消的命令不占冷却)", + "PMS_MACRO_MIN_PCT": "宏观降仓地板: 自动降仓不把总仓位降到此线以下 (用户命令不受限)", + "PMS_MACRO_MAX_PCT": "宏观升仓天花板; 0=不单设, 由总仓上限 PMS_PORTFOLIO_CAP 兜底", + "PMS_MACRO_WINDOW_TDAYS": "宏观命令的执行窗口 (交易日)", + "PMS_MACRO_RESPECT_BRAKE": "组合刹车期间不自动升仓 (降仓不受限; 方案唯一假设项, 可放开)", + "PMS_MACRO_STALE_TDAYS": "任一数据源日龄超此 (交易日) → 信号不可用, 不动作不落闸", + "PMS_MACRO_RET_WIN": "股汇收益计算窗口 (交易日)", + "PMS_MACRO_Z_WIN": "滚动标准化窗口 (交易日)", + "PMS_MACRO_SHIBOR_BETA": "利率修正系数 β (spread − β×SHIBOR1M 二十日变化)", } # loaded 标记必不可少: 不能用「data 是否为空」判断缓存是否有效 —— @@ -325,12 +355,26 @@ _RANGES = { # 任务会先被 celery 打死 (而且是在已经落了一部分表之后)。 "PMS_UPSTREAM_ALERT_SCAN": (1, 20000), "PMS_UPSTREAM_ALERT_PER_CAT": (1, 1000), "PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (0, 1440), # 0=不限龄, 上限 24 小时 + # 宏观择时层 + "PMS_MACRO_HOT_TH": (0, 100), "PMS_MACRO_COLD_TH": (-100, 0), + "PMS_MACRO_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10), + "PMS_MACRO_LOG_S0": (0, 1), "PMS_MACRO_LOG_K": (0.01, 1000), + "PMS_MACRO_SHIFT_MAX": (0, 1), "PMS_MACRO_STEP_MIN": (0, 0.5), + "PMS_MACRO_COOLDOWN_TDAYS": (0, 20), "PMS_MACRO_MIN_PCT": (0, 1), + "PMS_MACRO_MAX_PCT": (0, 1), "PMS_MACRO_WINDOW_TDAYS": (1, 20), + "PMS_MACRO_STALE_TDAYS": (1, 20), "PMS_MACRO_RET_WIN": (5, 60), + "PMS_MACRO_Z_WIN": (10, 250), "PMS_MACRO_SHIBOR_BETA": (0, 1), } 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_MACRO_AUTONOMY" and v not in ("full", "propose_only", "off"): + return "PMS_MACRO_AUTONOMY 只能是 full / propose_only / off" + if key in ("PMS_MACRO_TRIGGER_HOT", "PMS_MACRO_TRIGGER_COLD") \ + and v not in ("zone_enter", "zone_exit"): + return f"{key} 只能是 zone_enter (进区即动) / zone_exit (极值回落再动)" if key == "PMS_OPEN_AUTONOMY" and v not in ("full", "propose_only", "off"): return "PMS_OPEN_AUTONOMY 只能是 full / propose_only / off" if key == "PMS_EXEC_IMPL" and str(v).strip().upper() not in ("A", "B"): diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index 0901849..52d1f98 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -42,6 +42,19 @@ logger = logging.getLogger("pms.proposal") AUTONOMY_FULL, AUTONOMY_PROPOSE, AUTONOMY_OFF = "full", "propose_only", "off" +def _macro_gate() -> dict: + """宏观偏热闸状态 (macro_service 维护, 这里只读; MACRO_TIMING_PLAN.md §5.2)。 + + 读不到一律按不生效 —— 闸的安全方向是「不额外拦」: 买入本身另有规则闸把关, + 不能让宏观层故障把整个自主引擎摁死。""" + try: + from app.services import macro_service + return macro_service.gate_state() or {} + except Exception as e: # noqa: BLE001 + logger.warning("[提议] 读宏观闸状态失败 (按不生效): %s", e) + return {} + + def scan_and_route(*, now=None, dry_run: bool = False) -> dict: """自主提议扫描一轮。dry_run=True 只出候选与判定, 不落任何表。""" now = now or datetime.now() @@ -88,6 +101,20 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: logger.exception("提议扫描失败") return {**out, "ok": False, "errors": [f"扫描失败: {type(e).__name__}: {e}"]} + # 宏观偏热闸: 只拦已有持仓四类动作里的**买入侧** (FILL/ADD/DCA), TRIM 保垫减仓照常。 + # 落点选在扫描产出之后、分流之前: 不碰 action_engine, 不产生指令自然过不了后面任何一层。 + mg = _macro_gate() + if mg.get("active"): + kept = [] + for c in scanned["candidates"]: + if c.get("side") == "buy": + out["skipped"].append({"ts_code": c.get("ts_code"), "action": c.get("action"), + "why": f"宏观偏热闸生效 ({mg.get('why') or '股汇对冲指数偏热'}), " + f"自主增持暂停"}) + else: + kept.append(c) + scanned["candidates"] = kept + out["candidates"] = len(scanned["candidates"]) out["skipped"].extend(scanned["skipped"]) stock_params = command_service.effective_stock_params() @@ -133,6 +160,15 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list: 注释就写着「拿昨收当现价去做不追高这类判断会出错, 只给规划期定量用」。 这条路是要真下单的, 所以走 market.get_prices, 取不到就整只跳过并留痕。 """ + # 宏观偏热闸: 新建仓全是买入, 整轮直接不扫 (MACRO_TIMING_PLAN.md §5.2)。 + # 放在函数入口: disposition_snapshot 复用本函数, 页面"系统在盯的候选"会如实显示闸生效。 + mg = _macro_gate() + if mg.get("active"): + out["skipped"].append({"action": ae.A_OPEN, + "why": f"宏观偏热闸生效 ({mg.get('why') or '股汇对冲指数偏热'}), " + f"本轮不自动新建仓"}) + return [] + src = (param_store.get("PMS_CANDIDATE_SOURCE", plan_feed.SRC_PLAN_API) or plan_feed.SRC_PLAN_API).strip() if src == plan_feed.SRC_BUY_PLAN: diff --git a/app/services/strategy_runner.py b/app/services/strategy_runner.py index 9e6c0c4..90208f5 100644 --- a/app/services/strategy_runner.py +++ b/app/services/strategy_runner.py @@ -103,6 +103,20 @@ def _buypause_codes() -> set: return set() +def _macro_gate_active() -> bool: + """宏观偏热闸 (MACRO_TIMING_PLAN.md §5.2): 生效时策略的买入开腿一并暂停。 + + 与 buypause 走同一个判定点 (ctx.buy_paused), 天然继承「只挡开新仓/加仓腿, + 卖出/平回/跟踪止盈照常」的既有语义 —— 反T 的买回是平回腿, 不会被拦。 + 读不到按不生效: 闸的安全方向是不额外拦。""" + try: + from app.services import macro_service + return bool((macro_service.gate_state() or {}).get("active")) + except Exception as e: # noqa: BLE001 + logger.warning("[strategy] 读宏观闸状态失败 (按不生效): %s", e) + return False + + # ================================================================ 指令 / 提议下发 def _emit_instruction(st: dict, dec: dict, *, forced: bool = False) -> str: """按决策发一张短窗口命令指令 (window_tdays=1, is_command=True), 交 run_tick 执行。 @@ -529,6 +543,9 @@ def tick(*, now=None, dry_run: bool = False) -> dict: scale = _f(view.get("totals", {}).get("scale")) today = _today() paused_codes = _buypause_codes() # 决策系统风控预警暂停买入的票 (本模块只读) + macro_gated = _macro_gate_active() # 宏观偏热闸: 全体策略买开腿暂停, 闸解除自动恢复 + if macro_gated: + out["notes"].append("宏观偏热闸生效: 策略买入开腿本轮暂停 (卖出与平回照常)") # 全局当日T亏熔断: 汇总所有做T策略的当日T盈亏 (realized_t_profit 相对日初基线的增量) global_t_pnl = 0.0 @@ -582,7 +599,7 @@ def tick(*, now=None, dry_run: bool = False) -> dict: out["notes"].append(f"{code} 当日T亏达单票熔断线 {stock_cap:.0f} 元, 今日不再开新T") ctx = {"state": state, "scale": scale, "halted": halted, "notes": out["notes"], - "buy_paused": code in paused_codes} + "buy_paused": (code in paused_codes) or macro_gated} dec = fn(st, pos, day, now, ctx) if dec: diff --git a/app/web/main.py b/app/web/main.py index 7bef926..7aa3093 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -506,6 +506,30 @@ def api_upstream_signals(): return ok(upstream_signals.snapshot) +# ================================================================ 宏观择时 (MACRO_TIMING_PLAN.md) +@app.get("/api/macro/status") +def api_macro_status(): + """宏观择时面板快照: 各信号当前值/区域/近 20 日序列/当日建议/最近命令/冷却 + 闸状态。""" + from app.services import macro_service + return ok(macro_service.status) + + +@app.post("/api/ops/macro-scan") +def api_macro_scan(dry_run: bool = Query(False)): + """手动宏观扫描 (= 09:35 调度位)。dry_run=true 只算不落表不下达, 供判收与盘中复核。""" + from app.services import macro_service + return ok_logged("macro_scan", macro_service.scan, dry_run=dry_run, + params={"dry_run": dry_run}) + + +@app.post("/api/macro/adopt") +def api_macro_adopt(payload: dict = Body(default={})): + """采纳当日宏观建议 → 以用户名义下命令 (幂等, 建议隔日作废)。""" + from app.services import macro_service + key = (payload or {}).get("signal_key") or "hedge_fx" + return ok_logged("macro_adopt", macro_service.adopt, key, params={"signal_key": key}) + + @app.get("/api/upstream/plan") def api_upstream_plan(limit: int = Query(30), date: str = Query(None), bucket: str = Query("main")): diff --git a/app/web/static/index.html b/app/web/static/index.html index 7ef789a..99b40ee 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -182,6 +182,11 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius: .lv-chip.on.lv-warn{color:var(--warn);background:var(--warn-weak);border-color:var(--warn-weak);} .lv-chip.on.lv-info{color:var(--ink-2);background:rgba(138,136,130,.14);} .sig-sep{font-size:12px;color:var(--muted);font-weight:600;margin:16px 0 8px;padding-top:9px;border-top:1px dashed var(--hair);} +/* 宏观择时面板 (MACRO_TIMING_PLAN.md) */ +.mz-bars{display:flex;align-items:flex-end;gap:2px;height:34px;margin:7px 0 3px;} +.mz-bar{width:9px;border-radius:2px;min-height:3px;opacity:.9;} +.mz-zone{padding:1px 9px;border-radius:9px;font-size:12px;font-weight:600;color:#fff;} +.mz-advice{margin:7px 0;padding:8px 11px;border:1px solid var(--warn);border-radius:9px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;} @@ -229,6 +234,11 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius: + + + @@ -252,6 +262,44 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius: 下方查看全部 ▾ + +
+

宏观择时 · 股汇对冲指数 → 整体仓位 + (总开关关闭, 只展示不动作) + (档位 {{ macro.autonomy==='full' ? '全自动' : (macro.autonomy==='propose_only' ? '只出建议' : '只算不动') }}) +

+
+
+ {{ s.label }} + {{ s.value==null ? '—' : (s.value>0?'+':'') + Number(s.value).toFixed(1) }} + {{ mzLabel(s.zone) }} + 超额深度 {{ s.e_now }} + 区内第 {{ s.streak }} 天 + 宏观命令在途 + {{ s.ymd }} +
+ + +
+
+ +
+
近 {{ s.history.length }} 个交易日 · 热区阈值 +{{ (macro.params||{}).hot_th }} / 冷区 {{ (macro.params||{}).cold_th }} / 退出带 ±{{ (macro.params||{}).exit_band }}
+
+ 今日建议:{{ s.advice.direction==='REDUCE' ? '降仓' : '升仓' }} {{ (s.advice.pct*100).toFixed(1) }}% + {{ s.advice.why }} + + 当日有效, 隔日自动作废 +
+
{{ s.note }}
+
最近宏观命令 {{ s.last_command.command_id }} · {{ s.last_command.cmd_type==='REDUCE_EXPOSURE' ? '降仓' : '升仓' }} · {{ s.last_command.status }}
+
读取失败: {{ s.error }}
+
+
暂无信号数据(还没扫描过, 或宏观层未启用)
+
+

上游信号 · 只读参考,不进下单逻辑 {{ showUpstream?'▲ 收起':'▼ 展开' }}

@@ -2075,6 +2123,38 @@ createApp({ return m ? (h + '小时' + m + '分前') : (h + '小时前'); } const alertRowClass = ({ row }) => (row && row.stale) ? 'alert-stale' : ''; + // —— 宏观择时 (MACRO_TIMING_PLAN.md): 面板取数 / 手动扫描 / 建议采纳 —— + const macro = ref({}); + const macroGateOn = computed(() => !!(((macro.value || {}).gate || {}).active)); + const macroUnavailable = computed(() => { + const m = macro.value || {}; + if (m.enabled === false) return ''; + const bad = (m.signals || []).filter(s => s.zone === 'UNAVAILABLE'); + if (!bad.length) return ''; + return bad.map(s => (s.label || s.key) + (s.note ? (':' + s.note) : '')).join(';'); + }); + function mzLabel(z) { return { HOT: '偏热', COLD: '偏冷', NEUTRAL: '中性', UNAVAILABLE: '数据不可用' }[z] || (z || '—'); } + function mzColor(z) { return { HOT: '#d8574a', COLD: '#3d7ff0', NEUTRAL: '#8a8f9c', UNAVAILABLE: '#d0a53c' }[z] || '#8a8f9c'; } + function mzBarStyle(h) { + const v = (h && h.value != null) ? Math.max(-40, Math.min(40, Number(h.value))) : 0; + return { height: (3 + Math.round(Math.abs(v) / 40 * 27)) + 'px', background: mzColor(h && h.zone) }; + } + async function loadMacro() { const d = await call('get', '/api/macro/status'); macro.value = d || {}; } + async function macroScan(dry) { + const d = await call('post', '/api/ops/macro-scan?dry_run=' + (dry ? 'true' : 'false'), {}); + ElementPlus.ElMessage[(d && d.ok !== false) ? 'success' : 'error']( + (dry ? '试算完成' : '扫描完成') + ((d && d.error) ? (':' + d.error) : '')); + await loadMacro(); + if (!dry) await Promise.all([loadCommands(), loadLedger()]); + } + async function macroAdopt(key) { + try { await ElementPlus.ElMessageBox.confirm('按今日宏观建议下达仓位命令?', '采纳建议', { type: 'warning' }); } + catch (e) { return; } + const d = await call('post', '/api/macro/adopt', { signal_key: key }); + ElementPlus.ElMessage[(d && d.ok) ? 'success' : 'error']((d && d.ok) ? ('已下达 ' + (d.command_id || '')) : ((d && d.error) || '采纳失败')); + await Promise.all([loadMacro(), loadCommands()]); + } + async function loadUpstream() { const d = await call('get', '/api/upstream-signals'); upstream.value = (d && d.sources) ? d : { sources: {} }; @@ -2113,7 +2193,8 @@ createApp({ loading.value = true; err.value = ''; await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(), loadPositions(), loadInstructions(), loadLedger(), loadProposals(), - loadDispatchMode(), loadWs(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream()]); + loadDispatchMode(), loadWs(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream(), + loadMacro()]); await loadNames(); lastRefresh.value = new Date().toTimeString().slice(0, 8); loading.value = false; @@ -2314,7 +2395,7 @@ createApp({ async function refreshLive() { // allSettled: 单个 loader 失败/超时(axios 60s 兜底)不拖累其余与时间戳更新。 await Promise.allSettled([loadOverview(), loadPositions(), loadInstructions(), loadLedger(), - loadProposals(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream()]); + loadProposals(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream(), loadMacro()]); try { await loadNames(); } catch (e) {} // 用客户端钟, 保证"自动·HH:MM:SS"稳定跳动, 不依赖 /health 的 now。 lastRefresh.value = new Date().toTimeString().slice(0, 8); @@ -2350,6 +2431,8 @@ createApp({ openScan, insTab, todayYmd, insLive, insDone, insEnd, dispOf, loadOpenScan, denyToday, showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh, upstream, showUpstream, usrc, loadUpstream, + macro, macroGateOn, macroUnavailable, mzLabel, mzColor, mzBarStyle, + loadMacro, macroScan, macroAdopt, showAlerts, alertItems, alertLatest, alertLevelClass, alertGroups, alertStripItems, alertStripCount, alertTotalCount, catShown, toggleCat, ageText, alertRowClass, alertItemsF, levelShown, toggleLevel, levelCount, ALERT_LEVELS, lvLabel, lvClass, alertGridFixed, passLevel, flowText, flowClass, diff --git a/config/settings.py b/config/settings.py index 42f6ace..c26b992 100644 --- a/config/settings.py +++ b/config/settings.py @@ -251,6 +251,35 @@ class Settings(BaseSettings): PMS_SIGNAL_AUTO_EXIT_CONF: float = 0.85 # 高于此置信度直接转清仓指令, 之间则落提议 PMS_SIGNAL_TRIM_RATIO: float = 0.3333 # 中等置信度时的减仓比例 + # --- 宏观择时层 (股汇对冲指数 → 组合仓位命令 + 个股宏观闸) --- + # 方案 MACRO_TIMING_PLAN.md; 阈值与对数映射参数依据标定报告 MACRO_CALIB_2026-08-18.md。 + # 模拟仓拍板 (2026-08-18): 默认总开关开 + 全自动档, 不设观察期; **转实盘前 + # 改回 False / propose_only**。ENABLED 已进 param_store.FAIL_CLOSED: 参数表 + # 读不到时按关闭处理, 基础设施故障期间宁可不动。 + PMS_MACRO_ENABLED: bool = True # 总开关; 关 = 调度位空转, 不取数不计算 + PMS_MACRO_AUTONOMY: str = "full" # off 只算不动 / propose_only 出建议 / full 自动下命令 + PMS_MACRO_SIGNALS: str = "hedge_fx" # 启用的信号清单 (逗号分隔; 注册表在 macro_service) + PMS_MACRO_STOCK_GATE: bool = True # 个股宏观闸: 偏热确认期间暂停自主增持与策略买开腿 + PMS_MACRO_HOT_TH: float = 25.0 # 偏热进入阈值 (标定: 约 1.4 段/年) + PMS_MACRO_COLD_TH: float = -20.0 # 偏冷进入阈值 (标定: 约 4 段/年, 用户确认取 -20) + PMS_MACRO_EXIT_BAND: float = 15.0 # 迟滞退出带; 偏热侧它同时是降仓命令的触发线 + PMS_MACRO_CONFIRM_DAYS: int = 1 # 偏冷进区连续 N 个交易日才触发 (标定: 等第二天更差) + PMS_MACRO_TRIGGER_HOT: str = "zone_exit" # 偏热口径: 回落再动 (标定: 机会成本最低) + PMS_MACRO_TRIGGER_COLD: str = "zone_enter" # 偏冷口径: 进区即动 (标定: 反弹在极值当口) + PMS_MACRO_LOG_S0: float = 0.049 # 对数映射 target = S0·ln(1+e/k), 标定回填 + PMS_MACRO_LOG_K: float = 1.6 # 对数映射尺度 k, 标定回填 + PMS_MACRO_SHIFT_MAX: float = 0.20 # 单个极值周期累计调整封顶 (占规模百分点) + PMS_MACRO_STEP_MIN: float = 0.02 # 裁剪后步长小于此不动; 也是确认当日首步保底 + PMS_MACRO_COOLDOWN_TDAYS: int = 3 # 同方向两次动作最小间隔 (交易日) + PMS_MACRO_MIN_PCT: float = 0.20 # 宏观降仓地板 (用户命令不受此限) + PMS_MACRO_MAX_PCT: float = 0.0 # 宏观升仓天花板; 0 = 不单设, 由总仓上限兜底 + PMS_MACRO_WINDOW_TDAYS: int = 3 # 宏观命令的执行窗口 + PMS_MACRO_RESPECT_BRAKE: bool = True # 组合刹车期间不自动升仓 (方案唯一假设项) + PMS_MACRO_STALE_TDAYS: int = 3 # 任一数据源日龄超此 (交易日) → 信号不可用 + PMS_MACRO_RET_WIN: int = 20 # 收益计算窗口 + PMS_MACRO_Z_WIN: int = 40 # 标准化窗口 + PMS_MACRO_SHIBOR_BETA: float = 0.02 # 利率修正系数 β + # --- 对账与回放 --- PMS_REPLAY_INTERVAL_MIN: int = 5 PMS_RECON_ALARM_DAYS: int = 3 # 连续不一致 N 日升级 ERROR diff --git a/ddl_pms_v1.sql b/ddl_pms_v1.sql index 52bfa85..88cd0de 100644 --- a/ddl_pms_v1.sql +++ b/ddl_pms_v1.sql @@ -373,3 +373,23 @@ CREATE TABLE IF NOT EXISTS pms_op_log ( KEY idx_op (op), KEY idx_code (ts_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易员操作日志'; + + +-- 18. 宏观信号日快照 —— 宏观择时层 (MACRO_TIMING_PLAN.md; 参数依据 MACRO_CALIB_2026-08-18.md)。 +-- 每信号每交易日一行, 重扫就地更新。一行四用: 面板历史序列 / 当日建议承载 / +-- 冷却与周期已执行量的事实源 / 评审留痕的锚。宏观闸的实时状态在运行参数 +-- PMS_MACRO_GATE_STATE (不在本表), 本表是历史与审计。 +CREATE TABLE IF NOT EXISTS pms_macro_signal ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + signal_key VARCHAR(32) NOT NULL COMMENT '信号名, 如 hedge_fx (股汇对冲指数)', + trade_date INT NOT NULL COMMENT '北京交易日 YYYYMMDD (评估日; 数据是 T-1 日终)', + value DECIMAL(12,4) NULL COMMENT 'hedge_index 值 (z 分数 ×10); NULL = 当日不可用', + zone VARCHAR(16) NOT NULL DEFAULT 'NEUTRAL' COMMENT 'HOT(偏热)/COLD(偏冷)/NEUTRAL/UNAVAILABLE', + detail_json TEXT NULL COMMENT '中间量: 股汇收益/修正值/超额深度/各源末日/补齐天数/周期状态/建议', + action VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT 'NONE/ADVICE_REDUCE/ADVICE_INCREASE/CMD_ISSUED/BLOCKED', + ref_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联 command_id (下达或采纳后回填)', + note VARCHAR(500) NOT NULL DEFAULT '' COMMENT '当日结论/被拦原因/建议文案', + updated_at DATETIME NULL COMMENT '写入或重扫更新时间', + UNIQUE KEY uk_sig_date (signal_key, trade_date), + KEY idx_key_id (signal_key, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宏观信号日快照与动作记录'; diff --git a/scripts/check_db.py b/scripts/check_db.py index 1ce7a5f..becdd80 100644 --- a/scripts/check_db.py +++ b/scripts/check_db.py @@ -26,7 +26,11 @@ PMS_TABLES = ["pms_command", "pms_plan", "pms_position", "pms_lot", "pms_instruc # 14/15 号表。**加表时记得往这里加一行** —— 漏了的话 init_db 建了表、 # check_db 却一声不吭, 表结构自检就有个静默的盲区。 # (pms_cash_flow 就漏过一轮, 2026-07-31 补上) - "pms_cash_flow", "pms_plan_snapshot"] + "pms_cash_flow", "pms_plan_snapshot", + # 18 号表: 宏观信号日快照 (2026-08-18 宏观择时层)。 + # 顺带记录既有欠账: 16/17 号表 pms_strategy / pms_op_log 不在本清单里, + # 属加表时漏登记 —— 按"不夹带"纪律这次不补, 单独提单独拍板。 + "pms_macro_signal"] DOWNSTREAM = ["trading_position", "trading_order", "trading_buy_plan"] FAILED, WARNED = [], [] diff --git a/scripts/run_tests.py b/scripts/run_tests.py index bfb86dc..bf24fb2 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -19,8 +19,11 @@ test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/ 转多信号插队与留痕/窗口末日只对命令/跳过原因分得开/ 研判请求体的数据库类型 (44 例) + test_batch13_units.py 决策系统卖出采纳: 门槛分档/清场标记/确认加速/闭仓清场 (4 例) + test_batch14_units.py 宏观择时: 指数计算与对齐/区域迟滞/周期与对数映射/ + 分方向触发/让路与冷却/宏观闸/建议采纳 (25 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 469 例 + 共 498 例 任一子集失败即整体失败 (退出码 1)。 """ import os @@ -33,7 +36,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py", "test_batch4_units.py", "test_batch5_units.py", "test_batch6_units.py", "test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py", "test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py", - "test_batch13_units.py", "test_wiring.py"] + "test_batch13_units.py", "test_batch14_units.py", "test_wiring.py"] def main(): diff --git a/scripts/test_batch14_units.py b/scripts/test_batch14_units.py new file mode 100644 index 0000000..274c5f8 --- /dev/null +++ b/scripts/test_batch14_units.py @@ -0,0 +1,631 @@ +# -*- coding: utf-8 -*- +""" +第十四批模块单测 (宏观择时层, 零外部依赖) +========================================== +运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch14_units.py +覆盖 (MACRO_TIMING_PLAN.md V3; 参数依据 MACRO_CALIB_2026-08-18.md): + * 指数计算: 四步公式对照手算值 / 汇率 +1 自然日 as-of 对齐 / 样本不足与停更守卫; + * 区域判定: 进出迟滞 / 带内横跳不切区 / 不可用与恢复; + * 周期与对数映射: streak 与峰值推进 / 同深度不重复加码 / 首步保底 / 封顶 / 周期清零; + * 分方向触发: 偏冷进区即动 / 偏热回落再动按峰值深度 / 直接翻区不动作; + * 让路: 在途 / 冷却 / 暂停买入 / 刹车 / 地板天花板; + * 服务层 (打桩): 全自动下命令与冲突让路 / 建议与采纳幂等 / 闸状态读写与陈旧失效 / + proposal_service 的买入侧过滤留 TRIM。 +""" +import json +import math +import os +import sys +import traceback + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.core import macro_rules as mr # noqa: E402 + +RESULTS = [] + + +def case(name): + def deco(fn): + RESULTS.append((name, fn)) + return fn + return deco + + +P = {"hot_th": 25.0, "cold_th": -20.0, "exit_band": 15.0, "confirm_days": 1, + "trigger_hot": "zone_exit", "trigger_cold": "zone_enter", + "log_s0": 0.049, "log_k": 1.6, "shift_max": 0.20, "step_min": 0.02, + "min_pct": 0.20, "max_pct": 0.0, "respect_brake": True, + "ret_win": 20, "z_win": 40, "beta": 0.02, "stale_tdays": 3} + +F0 = {"portfolio_pct": 0.50, "portfolio_cap": 0.60, "buy_halt": False, + "brake_active": False, "inflight": False, + "cooldown_left_reduce": 0, "cooldown_left_increase": 0} + + +# ================================================================ 计算 +def _mkdays(n, start=20240102): + """生成 n 个工作日 ymd (跳过周末, 够单测用)。""" + from datetime import date, timedelta + d = date(2024, 1, 2) + out = [] + while len(out) < n: + if d.weekday() < 5: + out.append(int(d.strftime("%Y%m%d"))) + d += timedelta(days=1) + return out + + +@case("计算·四步公式对照手算值 (常数汇率利率, 股指恒定日涨)") +def _(): + days = _mkdays(70) + zs = [(d, 3000.0 * math.exp(0.001 * i)) for i, d in enumerate(days)] + fx = [(d, 7.2) for d in days] + sh = [(d, 1.9) for d in days] + r = mr.compute_hedge_index({"zs": zs, "fx": fx, "shibor": sh}, P, days[-1]) + # 股 20 日收益恒 0.02, 汇 0, 利率变化 0 → spread_adj 恒 0.02 → 标准差为零 → 不可用 + assert not r["ok"] and "标准差" in r["reason"], r + + +@case("计算·有波动时值与手算 z 一致") +def _(): + days = _mkdays(75) + zs, px = [], 3000.0 + for i, d in enumerate(days): + px *= math.exp(0.002 if i % 2 == 0 else -0.001) + zs.append((d, px)) + fx = [(d, 7.2 + (0.01 if i % 3 == 0 else 0.0)) for i, d in enumerate(days)] + sh = [(d, 1.9) for d in days] + r = mr.compute_hedge_index({"zs": zs, "fx": fx, "shibor": sh}, P, days[-1]) + assert r["ok"], r + # 手算: 复算 spread_adj 序列与末点 z×10 + closes = [v for _, v in zs] + fx_al, _f = mr.asof_align(days, fx, shift_days=1) + sh_al, _s = mr.asof_align(days, sh, shift_days=0) + sr_ = mr.log_rets(closes, 20) + fr_ = mr.log_rets(fx_al, 20) + sd_ = mr.diffs(sh_al, 20) + adj = [None if (sr_[i] is None or fr_[i] is None or sd_[i] is None) + else sr_[i] + fr_[i] - 0.02 * sd_[i] for i in range(len(days))] + w = [v for v in adj[-40:] if v is not None] + m = sum(w) / len(w) + sd = math.sqrt(sum((x - m) ** 2 for x in w) / (len(w) - 1)) + want = (adj[-1] - m) / sd * 10 + assert abs(r["value"] - round(want, 4)) < 1e-6, (r["value"], want) + + +@case("计算·汇率 +1 自然日 as-of: 周一用上周五值") +def _(): + tdays = [20260803, 20260804, 20260805] # 周一到周三 + fx = [(20260731, 7.10), (20260803, 7.20)] # 周五 / 周一 + vals, filled = mr.asof_align(tdays, fx, shift_days=1) + assert vals == [7.10, 7.20, 7.20] and filled >= 2, (vals, filled) + + +@case("计算·样本不足与数据停更都判不可用") +def _(): + days = _mkdays(30) + zs = [(d, 3000.0 + i) for i, d in enumerate(days)] + r = mr.compute_hedge_index({"zs": zs, "fx": zs, "shibor": zs}, P, days[-1]) + assert not r["ok"] and "样本不足" in r["reason"], r + days = _mkdays(70) + zs = [(d, 3000.0 * math.exp((0.002 if i % 2 else -0.001))) + for i, d in enumerate(days)] + # 末日落后今天 30 个自然日 → 停更 + r = mr.compute_hedge_index({"zs": zs, "fx": zs, "shibor": zs}, P, + mr.ymd_plus_days(days[-1], 30)) + assert not r["ok"] and "停更" in r["reason"], r + + +# ================================================================ 区域迟滞 +@case("区域·进出迟滞: 26 进热, 20/16 仍在热, 14 才离区") +def _(): + assert mr.zone_next(26, "NEUTRAL", P) == mr.Z_HOT + assert mr.zone_next(20, mr.Z_HOT, P) == mr.Z_HOT # 带内延续 + assert mr.zone_next(16, mr.Z_HOT, P) == mr.Z_HOT + assert mr.zone_next(14, mr.Z_HOT, P) == mr.Z_NEUTRAL # 穿出退出带 + assert mr.zone_next(24, mr.Z_NEUTRAL, P) == mr.Z_NEUTRAL # 未过进入阈值不再进 + + +@case("区域·偏冷对称 + 直接翻区 + 不可用") +def _(): + assert mr.zone_next(-21, "NEUTRAL", P) == mr.Z_COLD + assert mr.zone_next(-16, mr.Z_COLD, P) == mr.Z_COLD + assert mr.zone_next(-14, mr.Z_COLD, P) == mr.Z_NEUTRAL + assert mr.zone_next(-21, mr.Z_HOT, P) == mr.Z_COLD # 单日巨变直接翻 + assert mr.zone_next(None, mr.Z_HOT, P) == mr.Z_UNAVAILABLE + assert mr.zone_next(26, mr.Z_UNAVAILABLE, P) == mr.Z_HOT # 恢复后按进入规则重判 + + +# ================================================================ 周期与映射 +@case("周期·streak 与峰值推进, 退区带出峰值, 换区清零") +def _(): + c1, ev = mr.carry_cycle("NEUTRAL", None, mr.Z_HOT, 27.0, P, 20260801) + assert c1["streak"] == 1 and abs(c1["e_peak"] - 2.0) < 1e-9 and ev is None + c2, ev = mr.carry_cycle(mr.Z_HOT, c1, mr.Z_HOT, 31.0, P, 20260802) + assert c2["streak"] == 2 and abs(c2["e_peak"] - 6.0) < 1e-9 and ev is None + c3, ev = mr.carry_cycle(mr.Z_HOT, c2, mr.Z_NEUTRAL, 12.0, P, 20260803) + assert c3 is None and ev and abs(ev["e_peak"] - 6.0) < 1e-9 + c4, ev = mr.carry_cycle(mr.Z_HOT, c2, mr.Z_COLD, -22.0, P, 20260803) + assert c4["zone"] == mr.Z_COLD and c4["streak"] == 1 and ev["zone"] == mr.Z_HOT + + +@case("映射·对数目标: 标定锚点与封顶") +def _(): + # S0=0.049, k=1.6: e=2.9 → ≈5.1%; e=17.1 → ≈12% + assert abs(mr.log_target(2.9, P) - 0.049 * math.log(1 + 2.9 / 1.6)) < 1e-9 + assert 0.049 < mr.log_target(2.9, P) < 0.053 + assert 0.115 < mr.log_target(17.1, P) < 0.125 + assert mr.log_target(1e9, P) == 0.20 # 封顶 + assert mr.log_target(0, P) == 0.0 and mr.log_target(-3, P) == 0.0 + + +@case("决策·偏冷进区即动: 浅进区首步保底 2%") +def _(): + cyc = {"zone": mr.Z_COLD, "start": 1, "streak": 1, "e_peak": 0.5, "done_shift": 0.0} + d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False, + e_now=0.5, flags=dict(F0), params=P) + assert d["action"] == mr.ACT_INCREASE and abs(d["pct"] - 0.02) < 1e-9, d + + +@case("决策·偏冷加深才有新步, 同深度不重复加码") +def _(): + # 已执行 5%, 深度对应目标 ≈5% → 无新步 + e = 2.9 + done = mr.log_target(e, P) + cyc = {"zone": mr.Z_COLD, "streak": 3, "e_peak": e, "done_shift": round(done, 4)} + d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False, + e_now=e, flags=dict(F0), params=P) + assert d["action"] is None and not d["blocked"], d + # 深度翻倍 → 目标增量超过 step_min → 出新步 + d2 = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False, + e_now=10.0, flags=dict(F0), params=P) + assert d2["action"] == mr.ACT_INCREASE \ + and abs(d2["pct"] - (mr.log_target(10.0, P) - done)) < 1e-4, d2 + + +@case("决策·确认天数: streak 不足不动") +def _(): + p2 = {**P, "confirm_days": 2} + cyc = {"zone": mr.Z_COLD, "streak": 1, "e_peak": 3.0, "done_shift": 0.0} + d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False, + e_now=3.0, flags=dict(F0), params=p2) + assert d["action"] is None and not d["blocked"], d + + +@case("决策·偏热回落再动: 退区日按峰值深度一次动作, 当日已动过不重复") +def _(): + ev = {"zone": mr.Z_HOT, "e_peak": 6.0, "done_shift": 0.0} + d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False, + e_now=0.0, flags=dict(F0), params=P) + assert d["action"] == mr.ACT_REDUCE \ + and abs(d["pct"] - mr.log_target(6.0, P)) < 1e-4, d + d2 = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=True, + e_now=0.0, flags=dict(F0), params=P) + assert d2["action"] is None, d2 + + +@case("决策·偏热在区内 (zone_exit 口径) 不动手; 直接翻冷区旧周期退出不降仓") +def _(): + cyc = {"zone": mr.Z_HOT, "streak": 3, "e_peak": 8.0, "done_shift": 0.0} + d = mr.decide(zone=mr.Z_HOT, cycle=cyc, exit_event=None, exit_acted=False, + e_now=8.0, flags=dict(F0), params=P) + assert d["action"] is None and not d["blocked"], d + # HOT 直接翻 COLD: 退出事件不触发降仓 (新区自己的逻辑接管) + ev = {"zone": mr.Z_HOT, "e_peak": 8.0} + ccold = {"zone": mr.Z_COLD, "streak": 1, "e_peak": 2.0, "done_shift": 0.0} + d2 = mr.decide(zone=mr.Z_COLD, cycle=ccold, exit_event=ev, exit_acted=False, + e_now=2.0, flags=dict(F0), params=P) + assert d2["action"] == mr.ACT_INCREASE, d2 + + +@case("决策·偏热 zone_enter 口径 (可切): 进区步进") +def _(): + p2 = {**P, "trigger_hot": "zone_enter"} + cyc = {"zone": mr.Z_HOT, "streak": 1, "e_peak": 3.0, "done_shift": 0.0} + d = mr.decide(zone=mr.Z_HOT, cycle=cyc, exit_event=None, exit_acted=False, + e_now=3.0, flags=dict(F0), params=p2) + assert d["action"] == mr.ACT_REDUCE and d["pct"] > 0, d + + +@case("决策·地板与天花板裁剪") +def _(): + ev = {"zone": mr.Z_HOT, "e_peak": 20.0} + d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False, + e_now=0.0, flags={**F0, "portfolio_pct": 0.21}, params=P) + assert d["action"] is None and any("地板" in b for b in d["blocked"]), d + cyc = {"zone": mr.Z_COLD, "streak": 1, "e_peak": 9.0, "done_shift": 0.0} + d2 = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False, + e_now=9.0, flags={**F0, "portfolio_pct": 0.595}, params=P) + assert d2["action"] is None and any("天花板" in b for b in d2["blocked"]), d2 + # 天花板裁剪但仍够一步 + d3 = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False, + e_now=9.0, flags={**F0, "portfolio_pct": 0.55}, params=P) + assert d3["action"] == mr.ACT_INCREASE and abs(d3["pct"] - 0.05) < 1e-9, d3 + + +@case("决策·让路: 暂停买入/刹车只拦升仓, 在途与冷却双向拦") +def _(): + cyc = {"zone": mr.Z_COLD, "streak": 1, "e_peak": 5.0, "done_shift": 0.0} + d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False, + e_now=5.0, flags={**F0, "buy_halt": True}, params=P) + assert d["action"] is None and any("暂停买入" in b for b in d["blocked"]), d + d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False, + e_now=5.0, flags={**F0, "brake_active": True}, params=P) + assert d["action"] is None and any("刹车" in b for b in d["blocked"]), d + d = mr.decide(zone=mr.Z_COLD, cycle=cyc, exit_event=None, exit_acted=False, + e_now=5.0, flags={**F0, "brake_active": True}, + params={**P, "respect_brake": False}) + assert d["action"] == mr.ACT_INCREASE, d # 参数放开则不让 + ev = {"zone": mr.Z_HOT, "e_peak": 6.0} + d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False, + e_now=0.0, flags={**F0, "buy_halt": True, "brake_active": True}, + params=P) + assert d["action"] == mr.ACT_REDUCE, d # 降仓不受这两条限制 + d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False, + e_now=0.0, flags={**F0, "inflight": True}, params=P) + assert d["action"] is None and any("在途" in b for b in d["blocked"]), d + d = mr.decide(zone=mr.Z_NEUTRAL, cycle=None, exit_event=ev, exit_acted=False, + e_now=0.0, flags={**F0, "cooldown_left_reduce": 2}, params=P) + assert d["action"] is None and any("冷却" in b for b in d["blocked"]), d + + +# ================================================================ 服务层 (打桩) +from app.services import macro_service as ms # noqa: E402 +from app.services import param_store, proposal_service # noqa: E402 +from app.repo import macro_repo, pms_repo # noqa: E402 + + +class _Env: + """打桩环境: 参数 / 信号表 / 命令服务 / 组合快照 全部进内存。""" + + def __init__(self, **params): + self.params = {"PMS_MACRO_ENABLED": True, "PMS_MACRO_AUTONOMY": "full", + "PMS_MACRO_STOCK_GATE": True, "PMS_GLOBAL_EXEC_HALT": False, + "PMS_GLOBAL_BUY_HALT": False, "PMS_BRAKE_UNTIL": 0, + "PMS_MACRO_COLD_TH": -20.0, **params} + self.rows = {} # (key, ymd) -> row + self.issued = [] + self.ledger = [] + self.commands = {} # command_id -> {status,...} + self.issue_result = {"ok": True, "command_id": "CMD_T_0001"} + self.saved = {} + + def install(self): + env = self + self._orig = (param_store.get, param_store.get_bool, param_store.get_float, + param_store.get_int, param_store.get_list, param_store.set_param, + macro_repo.fetch_hedge_inputs, macro_repo.upsert_signal, + macro_repo.get_signal, macro_repo.recent_signals, + pms_repo.insert_ledger, pms_repo.get_command) + + def _get(key, default=None): + return env.params.get(key, ms._params.__defaults__ or default) \ + if key in env.params else default + param_store.get = lambda k, d=None: env.params.get(k, d) + param_store.get_bool = lambda k, d=False: bool(env.params.get(k, d)) + param_store.get_float = lambda k, d=0.0: float(env.params.get(k, d)) + param_store.get_int = lambda k, d=0: int(env.params.get(k, d)) + param_store.get_list = lambda k, d=None, sep=",": \ + [x.strip() for x in str(env.params.get(k, "") or "").split(sep) if x.strip()] \ + or list(d or []) + def _setp(k, v, by="user"): + env.saved[k] = v + env.params[k] = v + return {"ok": True, "key": k, "value": v} + param_store.set_param = _setp + + macro_repo.fetch_hedge_inputs = lambda limit_days=140: env.inputs + def _ups(**kw): + env.rows[(kw["signal_key"], int(kw["trade_date"]))] = { + "signal_key": kw["signal_key"], "trade_date": int(kw["trade_date"]), + "value": kw.get("value"), "zone": kw.get("zone"), + "detail": kw.get("detail") or {}, "action": kw.get("action") or "NONE", + "ref_id": kw.get("ref_id") or "", "note": kw.get("note") or ""} + return 1 + macro_repo.upsert_signal = _ups + macro_repo.get_signal = lambda k, d: env.rows.get((k, int(d))) + macro_repo.recent_signals = lambda k, limit=30: sorted( + [r for (kk, _), r in env.rows.items() if kk == k], + key=lambda r: -r["trade_date"])[:limit] + pms_repo.insert_ledger = lambda **kw: env.ledger.append(kw) or 1 + pms_repo.get_command = lambda cid: env.commands.get(cid) + + import app.services.command_service as csvc + self._orig_issue = csvc.issue + def _issue(cmd_type, params, **kw): + env.issued.append({"cmd_type": cmd_type, "params": params, **kw}) + r = dict(env.issue_result) + if r.get("ok"): + env.commands[r["command_id"]] = {"command_id": r["command_id"], + "cmd_type": cmd_type, + "status": "EXECUTING", "progress": {}} + return r + csvc.issue = _issue + + import app.services.portfolio as pf + self._orig_view = pf.positions_view + pf.positions_view = lambda **kw: {"totals": {"portfolio_pct": 0.50}, + "params": {"portfolio_cap": 0.60}} + ms._gate_cache["at"] = 0.0 + ms._gate_cache["data"] = None + + def uninstall(self): + (param_store.get, param_store.get_bool, param_store.get_float, + param_store.get_int, param_store.get_list, param_store.set_param, + macro_repo.fetch_hedge_inputs, macro_repo.upsert_signal, + macro_repo.get_signal, macro_repo.recent_signals, + pms_repo.insert_ledger, pms_repo.get_command) = self._orig + import app.services.command_service as csvc + csvc.issue = self._orig_issue + import app.services.portfolio as pf + pf.positions_view = self._orig_view + ms._gate_cache["at"] = 0.0 + ms._gate_cache["data"] = None + + +def _cold_inputs(): + """构造一组会算出偏冷极值的序列: 末 20 日股指大跌 + 人民币升值。""" + days = _mkdays(80) + zs, px = [], 3300.0 + fxs, fxv = [], 7.30 + for i, d in enumerate(days): + drift = 0.001 if i % 2 == 0 else -0.0005 + if i >= len(days) - 15: + drift = -0.012 # 尾段连续大跌 + px *= math.exp(drift) + zs.append((d, px)) + fdrift = 0.0002 if i % 3 == 0 else 0.0 + if i >= len(days) - 15: + fdrift = -0.004 # 人民币升值 (USDCNH 下行) + fxv *= math.exp(fdrift) + fxs.append((d, fxv)) + sh = [(d, 1.9 + (0.01 if i % 5 == 0 else 0.0)) for i, d in enumerate(days)] + return {"zs": zs, "fx": fxs, "shibor": sh}, days[-1] + + +@case("服务·全自动: 偏冷极值 → 下升仓命令 (署名 macro), 信号行与闸状态落表") +def _(): + env = _Env(PMS_MACRO_SIGNALS="hedge_fx") + env.inputs, today = _cold_inputs() + env.install() + try: + r = ms.scan(now=__import__("datetime").datetime.strptime(str(today), "%Y%m%d")) + assert r["ok"], r + assert env.issued and env.issued[0]["cmd_type"] == "INCREASE_EXPOSURE", env.issued + assert env.issued[0]["issued_by"] == "macro" + assert 0.02 <= env.issued[0]["params"]["pct"] <= 0.20 + row = env.rows[("hedge_fx", today)] + assert row["action"] == "CMD_ISSUED" and row["ref_id"] == "CMD_T_0001", row + assert row["zone"] == mr.Z_COLD and float(row["value"]) < -20 + # 周期已执行量被推进 (进区口径) + assert row["detail"]["cycle"]["done_shift"] > 0, row["detail"] + # 偏冷不落闸 + st = json.loads(env.saved["PMS_MACRO_GATE_STATE"]) + assert st["active"] is False + # 评审账本有 PASS 留痕 + assert any(x.get("verdict") == "PASS" for x in env.ledger) + finally: + env.uninstall() + + +@case("服务·同日重扫不重复下命令; 冲突时让路留痕") +def _(): + env = _Env() + env.inputs, today = _cold_inputs() + env.install() + try: + now = __import__("datetime").datetime.strptime(str(today), "%Y%m%d") + ms.scan(now=now) + n1 = len(env.issued) + ms.scan(now=now) # 重扫: 当日已下过 → 不再下 + assert len(env.issued) == n1 == 1, env.issued + # 冲突场景: 清空当日行, 命令服务回 CONFLICT + env.rows.clear() + env.issued.clear() + env.ledger.clear() + env.issue_result = {"ok": False, "errors": ["CONFLICT: 与在途命令冲突"], + "conflicts": [{"with_cmd_type": "REDUCE_EXPOSURE"}]} + r = ms.scan(now=now) + row = env.rows[("hedge_fx", today)] + assert row["action"] == "BLOCKED" and "CONFLICT" in row["note"], row + assert any(x.get("verdict") == "REJECT" for x in env.ledger) + assert r["skipped"], r + finally: + env.uninstall() + + +@case("服务·只出建议档: 建议落行, 采纳幂等, 二次采纳被拒") +def _(): + env = _Env(PMS_MACRO_AUTONOMY="propose_only") + env.inputs, today = _cold_inputs() + env.install() + try: + now = __import__("datetime").datetime.strptime(str(today), "%Y%m%d") + ms.scan(now=now) + assert not env.issued + row = env.rows[("hedge_fx", today)] + assert row["action"] == "ADVICE_INCREASE" and row["detail"]["advice"]["pct"] > 0 + # 采纳 (td.ymd() 是真实今天, 与合成日期不同 → 先把行挪到真实今天再采纳) + from app.core import tradedays as td + real_today = td.ymd() + env.rows[("hedge_fx", real_today)] = {**row, "trade_date": real_today} + r = ms.adopt("hedge_fx") + assert r.get("ok") and env.issued and env.issued[0]["issued_by"] == "user", r + r2 = ms.adopt("hedge_fx") + assert not r2.get("ok") and "不重复" in r2.get("error", ""), r2 + finally: + env.uninstall() + + +@case("服务·偏热日落闸: 闸状态写入, gate_state 读回生效; 陈旧与关档失效") +def _(): + env = _Env() + days = _mkdays(80) + # 构造偏热: 尾段股指大涨 + 人民币贬值 + zs, px = [], 3000.0 + fxs, fxv = [], 6.90 + for i, d in enumerate(days): + drift = 0.0008 if i % 2 == 0 else -0.0006 + if i >= len(days) - 12: + drift = 0.012 + px *= math.exp(drift) + zs.append((d, px)) + fdrift = -0.0002 if i % 3 == 0 else 0.0 + if i >= len(days) - 12: + fdrift = 0.004 + fxv *= math.exp(fdrift) + fxs.append((d, fxv)) + env.inputs = {"zs": zs, "fx": fxs, + "shibor": [(d, 1.9 + (0.01 if i % 5 == 0 else 0.0)) + for i, d in enumerate(days)]} + env.install() + try: + now = __import__("datetime").datetime.strptime(str(days[-1]), "%Y%m%d") + r = ms.scan(now=now) + row = env.rows[("hedge_fx", days[-1])] + assert row["zone"] == mr.Z_HOT, row + # zone_exit 口径: 在区内不下命令 + assert not env.issued + st = json.loads(env.saved["PMS_MACRO_GATE_STATE"]) + assert st["active"] is True and r["gate"]["active"] is True + # gate_state 陈旧失效: 把 ymd 改到 30 天前 + env.params["PMS_MACRO_GATE_STATE"] = json.dumps( + {"active": True, "ymd": mr.ymd_plus_days(days[-1], -30)}) + ms._gate_cache["at"] = 0.0 + assert ms.gate_state()["active"] is False + # 关总开关立即失效 + env.params["PMS_MACRO_GATE_STATE"] = json.dumps( + {"active": True, "ymd": __import__("app.core.tradedays", + fromlist=["ymd"]).ymd()}) + env.params["PMS_MACRO_ENABLED"] = False + ms._gate_cache["at"] = 0.0 + assert ms.gate_state()["active"] is False + finally: + env.uninstall() + + +@case("服务·数据不可用: 不动作不落闸, 行记 UNAVAILABLE") +def _(): + env = _Env() + days = _mkdays(30) + env.inputs = {"zs": [(d, 3000.0 + i) for i, d in enumerate(days)], + "fx": [], "shibor": []} + env.install() + try: + now = __import__("datetime").datetime.strptime(str(days[-1]), "%Y%m%d") + r = ms.scan(now=now) + row = env.rows[("hedge_fx", days[-1])] + assert row["zone"] == mr.Z_UNAVAILABLE and not env.issued, row + st = json.loads(env.saved["PMS_MACRO_GATE_STATE"]) + assert st["active"] is False + assert r["ok"], r + finally: + env.uninstall() + + +@case("闸·proposal_service 过滤: 拦买入侧留 TRIM; 读闸炸了按不拦") +def _(): + # 直接验证 scan_and_route 里那段过滤逻辑等价的行为: 用 gate_state 打桩 + orig = ms.gate_state + ms.gate_state = lambda: {"active": True, "why": "单测"} + try: + g = proposal_service._macro_gate() + assert g.get("active") is True + cands = [{"ts_code": "A", "action": "ADD", "side": "buy"}, + {"ts_code": "B", "action": "TRIM", "side": "sell"}, + {"ts_code": "C", "action": "DCA", "side": "buy"}] + kept, skipped = [], [] + for c in cands: # 与 scan_and_route 中的过滤同构 + (skipped if c.get("side") == "buy" else kept).append(c) + assert [c["ts_code"] for c in kept] == ["B"] + assert len(skipped) == 2 + finally: + ms.gate_state = orig + # 读闸抛异常 → _macro_gate 返回空 dict (不拦) + ms.gate_state = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + try: + assert proposal_service._macro_gate() == {} + finally: + ms.gate_state = orig + + +@case("闸·strategy_runner 判定: 闸生效等价 buy_paused, 读不到按 False") +def _(): + from app.services import strategy_runner as srun + orig = ms.gate_state + ms.gate_state = lambda: {"active": True} + try: + assert srun._macro_gate_active() is True + finally: + ms.gate_state = orig + ms.gate_state = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + try: + assert srun._macro_gate_active() is False + finally: + ms.gate_state = orig + + +@case("冷却·按信号历史行数计交易日距离, 零方案命令不占冷却") +def _(): + hist = [{"trade_date": 20260805, "action": "NONE", "ref_id": "", "detail": {}}, + {"trade_date": 20260804, "action": "NONE", "ref_id": "", "detail": {}}, + {"trade_date": 20260803, "action": "CMD_ISSUED", "ref_id": "C1", + "detail": {"advice": {"direction": "REDUCE"}}}] + # C1 在途 EXECUTING + orig = pms_repo.get_command + pms_repo.get_command = lambda cid: {"command_id": cid, "status": "EXECUTING", + "cmd_type": "REDUCE_EXPOSURE"} + try: + inflight, last_red, last_inc = ms._action_history(hist) + assert inflight and last_red == 20260803 and last_inc is None + # 今天 0805 (行已在): 距 0803 过了 2 个交易日 → 冷却 3 剩 1 + assert ms._cooldown_left(hist, last_red, 20260805, 3) == 1 + # 今天 0806 首扫 (行未写): 距离 +1 → 剩 0 + assert ms._cooldown_left(hist, last_red, 20260806, 3) == 0 + # 零方案: 命令被立刻取消 → 不算生效动作 + pms_repo.get_command = lambda cid: {"command_id": cid, "status": "CANCELLED", + "cmd_type": "REDUCE_EXPOSURE"} + inflight, last_red, _li = ms._action_history(hist) + assert not inflight and last_red is None + finally: + pms_repo.get_command = orig + + +@case("服务·休假模式与总开关: 扫描整体跳过") +def _(): + env = _Env(PMS_GLOBAL_EXEC_HALT=True) + env.inputs, today = _cold_inputs() + env.install() + try: + r = ms.scan() + assert not env.issued and any("休假" in s for s in r["skipped"]), r + env.params["PMS_GLOBAL_EXEC_HALT"] = False + env.params["PMS_MACRO_ENABLED"] = False + r2 = ms.scan() + assert any("总开关" in s for s in r2["skipped"]), r2 + finally: + env.uninstall() + + +# ================================================================ runner +def main(): + passed = failed = 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() diff --git a/scripts/test_batch6_units.py b/scripts/test_batch6_units.py index f42af20..b09b124 100644 --- a/scripts/test_batch6_units.py +++ b/scripts/test_batch6_units.py @@ -557,7 +557,7 @@ def run(): "DEFAULT 'NONE' COMMENT 'NONE/REQUESTED/SENT'"): eq(find_adjacent_literals(good), [], f"误报: {good[:40]}") - @case("DDL 文件本身体检通过 (17 张表 + 1 条初始行)") + @case("DDL 文件本身体检通过 (18 张表 + 1 条初始行)") def _(): sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from init_db import DDL_FILE, find_adjacent_literals, parse_statements @@ -569,7 +569,8 @@ def run(): eq(find_adjacent_literals(s), [], f"{tbl} 有相邻字面量") # 加表就要来这里 +1 —— 这一行是 DDL 与代码之间唯一的哨兵, 它不动就说明新表没进 # ddl_pms_v1.sql (init_db 只认这个文件, 建不出来的表在实机上才会报"表不存在") - eq(len([1 for k, _, _ in stmts if k == "table"]), 17) + # (2026-08-18: +pms_macro_signal 宏观信号日快照 → 18) + eq(len([1 for k, _, _ in stmts if k == "table"]), 18) eq(len([1 for k, _, _ in stmts if k == "seed"]), 1) @case("通道三表的 SQL 全部单表合规") diff --git a/scripts/test_wiring.py b/scripts/test_wiring.py index 863e2f0..4e337e2 100644 --- a/scripts/test_wiring.py +++ b/scripts/test_wiring.py @@ -556,11 +556,12 @@ def _(): names = set(sch.celery_app.conf.beat_schedule) assert names == {"plan_pull", "premarket", "command_poll", "replay_fills", "intraday_exec", "signal_digest", "t0_close", "daily_settle", - "daily_report"}, names + "daily_report", + "macro_scan"}, names # 2026-08-18 +宏观择时 09:35 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", - "pms.plan_pull"): + "pms.plan_pull", "pms.macro_scan"): 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