加入股汇参数和相应逻辑

This commit is contained in:
zlt 2026-08-19 10:04:20 +08:00
parent 3eded57d7e
commit 5fb030135e
20 changed files with 2345 additions and 327 deletions

86
COMMAND_PRIORITY.md Normal file
View File

@ -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
```
预期能看到指令的裁决栏区分等待和停止,紧急指令标注直通,被闸拦下的动作在原因栏写明是哪道闸。

View File

@ -30,6 +30,52 @@
--- ---
## 2026-08-18 · 宏观择时层上线:股汇对冲指数管整体仓位,外加个股宏观闸
**背景与依据**
方案与全部拍板记录在 MACRO_TIMING_PLAN.mdV3 可读版)。参数初值来自当天在桥机实跑的标定,报告存档在 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 PASS498 例,含新增 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 · 决策系统卖出采纳复核:降门槛+全留痕 / 确认即加速 / 清仓完成清场 ## 2026-08-18 · 决策系统卖出采纳复核:降门槛+全留痕 / 确认即加速 / 清仓完成清场
**背景** **背景**

88
MACRO_CALIB_2026-08-18.md Normal file
View File

@ -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 年那样的深熊。这套初值是合身的起点,不是验证过的真理。只出建议的观察期和每月重跑本脚本的复盘,就是为持续校准设的。

View File

@ -1,145 +1,244 @@
# tradingSystem · 宏观择时接入方案 V2股汇对冲指数 → 组合仓位 + 个股宏观闸 # tradingSystem 宏观择时接入方案V3 可读版
> 状态: **V2按用户 2026-08-18 三条拍板意见修订,待标定后定稿** | 日期: 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.153153 代理)**,标定脚本顺带完成列名探查与核实。 > 本版把 V2.1 按项目输出风格重写(风格文件在 .claude/output-styles/readable-chinese.md。技术内容与 V2.1 一致,没有新增决定。
> 写作纪律沿袭项目既有文档:术语先白话、阈值给硬数字且全部可配置、失败路径显式、未确认事项进清单。 > 参数初值的依据是当天在桥机实跑的标定报告,报告存档在仓库根目录的 MACRO_CALIB_2026-08-18.md。
> 关联:`POSITION_MGMT_DESIGN.md`(总体设计)、`README.md`(模块地图与三条铁律)、`DEVLOG.md` 2026-08-06「动 tradingSystem 以外的系统之前先答三题」。
--- ---
## 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}`——语义完全一致(释放金额 = 规模 × pctplanner 按 停新买→清弱票→收利润→等比微减 四档凑额,这正是"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") │ **永远给用户和安全让路。** 和你在途的命令方向冲突时这一轮就放弃记一笔账绝不强行执行。休假模式下整层不跑。你手动暂停买入期间不自动加仓。组合刹车期间不自动加仓。个股策略的卖出和平仓回补不受闸影响做T的买回腿也不会被拦。
│ autonomy=propose_only → 当日建议落表, 面板等用户点头 │
│ autonomy=off / ENABLED=False / 信号UNAVAILABLE → 不动 │ **数据拿不到就什么都不做。** 任何一张源表取不到、样本不足或者数据过期,指数记为不可用。不可用时不下命令,也不落闸,页面亮黄条提醒。宁可不动,不猜方向。
│ → 每一次动作与每一次"想动被拦"落 pms_action_ledger │
└──────────────────────────────────────────────────────────┘ **每一步都留痕。** 指数每天一行存进新表。每次动作,和每次想动但被拦,都写进评审账本。宏观层下的命令在署名栏写 macro和你下的命令一眼就能分开。
│ (命令一旦下达, 与用户手下的命令零区别) │ 宏观闸状态 (偏热确认中?)
▼ ▼ ## 五、为什么不会破坏现有系统
command_poll → planner → executor proposal_service 扫描入口:
→ 规则闸 → 择时 → 下发 → 对账 偏热确认 → 增持类扫描暂停(留痕) 宏观层动仓位只有一个出口,就是调用现有的命令下达函数(代码名 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。 ```bash
4. **命令至上**与用户在途命令冲突issue 返回 CONFLICT→ 本轮放弃并留痕,**绝不 force**`HALT_ALL`(休假)由调度守卫直接跳过;`PMS_GLOBAL_BUY_HALT` 生效时不发升仓;组合刹车期间默认不发升仓(可参数放开)。**用户命令永不受宏观闸限制**,且随时可在命令台撤销宏观命令。 make deploy
5. **限频防抖**:进区确认天数 + 迟滞退出带 + 同方向冷却 + 每信号每交易日至多一次动作 + 对数映射的"目标追踪"§5.1,同一深度不重复加码)+ 上一条宏观命令在途不叠加。指数在极值区横跳不会造成反复买卖。 make check
6. **全留痕**:信号值每日入 `pms_macro_signal`(含中间量,可复算);每次触发/被拦/落闸入 `pms_action_ledger`action=MACROhard_numbers 带当时指数值、区制、超额深度、当前仓位、本次步长);命令表 `issued_by='macro'` 一眼可辨。 ```
预期 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' stock_ret = ln(close_t / close_{t-20})
fx_ret = ln(fx_t / fx_{t-20}) # gp_fx_daily USD/CNH, 正值=人民币贬值 fx_ret = ln(fx_t / fx_{t-20})
fx 的日期先 +1 自然日, 再对齐到国内交易日: 当日无值取最近前值, 即 as-of 对齐) spread = stock_ret + fx_ret
② spread = stock_ret + fx_ret spread_adj = spread - 0.02 * (shibor1m_t - shibor1m_{t-20})
③ spread_adj = spread β × (SHIBOR1M_t SHIBOR1M_{t-20}) # β 默认 0.02 hedge_index = (spread_adj - mean40) / std40 * 10 # std 用样本标准差
④ hedge_index = (spread_adj mean40(spread_adj)) / std40(spread_adj) × 10
``` ```
硬性口径,写进单测:窗口内任一序列缺口用 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.049k=1.6SHIFT_MAX=0.20。偏冷侧(触发口径 zone_enter按步进追踪本次步长等于 target(当前深度) 减去周期内已执行量,确认当日首步保底 STEP_MIN0.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` | | PMS_MACRO_ENABLED | True | 宏观择时总开关。关闭时调度位空转,不取数不计算。模拟仓拍板默认开,转实盘前改回 False |
| COLD偏冷negative 区) | hedge_index < `PMS_MACRO_COLD_TH` | 回升到 > `PMS_MACRO_EXIT_BAND` | | PMS_MACRO_AUTONOMY | full | 档位off 只算不动propose_only 出建议full 自动下命令。模拟仓拍板默认 full转实盘前改回 propose_only |
| NEUTRAL | 其余 | — | | PMS_MACRO_SIGNALS | hedge_fx | 启用的信号清单,逗号分隔 |
| UNAVAILABLE | 数据守卫未过 | 数据恢复 | | 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 | index153 待探) | timestamp | close | symbol='000001.SH' | | app/core/macro_rules.py | 纯逻辑:对齐与四步计算、区域迟滞判定、对数映射决策。无外部依赖,可单测 |
| gp_fx_daily | proxy(153) | 待探 | 待探 | 待探USD/CNH 筛选) | | app/repo/macro_repo.py | 三张源表的单表取数,加新表 pms_macro_signal 的读写 |
| gp_shibor | proxy(153) | 待探 | 待探1M | 待探 | | 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_<KEY大写>_*), 决策层/执行层/闸/页面零改动。
}
```
启用哪些由 `PMS_MACRO_SIGNALS`(逗号分隔,默认 `hedge_fx`)控制。多信号合成 V1 定死**最保守者优先**:任一启用信号 HOT → 整体偏热;无 HOT 而有 COLD → 偏冷;否则中性。加权合成属二期,不做预设计。
### 4.4 新表 `pms_macro_signal`(第 18 张表)
```sql ```sql
CREATE TABLE IF NOT EXISTS pms_macro_signal ( 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', trade_date INT NOT NULL COMMENT '北京交易日 YYYYMMDD',
value DECIMAL(12,4) COMMENT 'hedge_index 值', value DECIMAL(12,4) COMMENT 'hedge_index 值',
zone VARCHAR(16) NOT NULL DEFAULT 'NEUTRAL' COMMENT 'HOT/COLD/NEUTRAL/UNAVAILABLE', 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', 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 (下达或采纳后回填)', ref_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联 command_id (下达或采纳后回填)',
note VARCHAR(500) NOT NULL DEFAULT '' COMMENT '被拦原因/建议文案/闸状态', note VARCHAR(500) NOT NULL DEFAULT '' COMMENT '被拦原因/建议文案/闸状态',
@ -157,226 +256,22 @@ CREATE TABLE IF NOT EXISTS pms_macro_signal (
) COMMENT='宏观信号日快照与动作记录 (每信号每交易日一行, 重扫就地更新)'; ) 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 ```python
res = command_service.issue( command_service.issue(
"REDUCE_EXPOSURE", # 或 INCREASE_EXPOSURE "REDUCE_EXPOSURE",
{"pct": step, "window_tdays": param_store.get_int("PMS_MACRO_WINDOW_TDAYS", 3)}, {"pct": step, "window_tdays": 3},
issued_by="macro", 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. **列名探查**:对 153proxy与 199index两源探查三张表的位置、列名、样本行——结果回填 §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样本不足/停更→UNAVAILABLEzone 迟滞进出与横跳对数映射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 长期化**:数据停更时宏观层安静失效(不动作、不落闸),只有面板黄条与日报提示——不会误动作,但也不会提醒你"该人工判断了",需要把面板纳入日常一眼。

345
app/core/macro_rules.py Normal file
View File

@ -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

111
app/repo/macro_repo.py Normal file
View File

@ -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

View File

@ -9,6 +9,7 @@
| 命令轮询 | 1 分钟 (全天) | 新命令解析 方案生成 任务状态机推进 | | 命令轮询 | 1 分钟 (全天) | 新命令解析 方案生成 任务状态机推进 |
| 盘中执行 | 交易时段每 1 分钟 | 择时出手 + 自主提议扫描 (执行器下一批交付) | | 盘中执行 | 交易时段每 1 分钟 | 择时出手 + 自主提议扫描 (执行器下一批交付) |
| 信号消化 | 交易时段每 1 分钟 | 订阅决策系统盘中信号 (下一批交付) | | 信号消化 | 交易时段每 1 分钟 | 订阅决策系统盘中信号 (下一批交付) |
| 宏观择时 | 交易日 09:35 | 股汇对冲指数 升降仓命令或建议 + 宏观闸 |
| 成交回放 | 交易时段每 1 分钟 | ws 逐笔入账 + trading_order 增量 + 轻对账 | | 成交回放 | 交易时段每 1 分钟 | ws 逐笔入账 + trading_order 增量 + 轻对账 |
| T 仓平回 | 14:50 (二期) | 做T强制平回 | | T 仓平回 | 14:50 (二期) | 做T强制平回 |
| 日终结算 | 15:10 | 全量对账 / 除权 / 安全垫 / 命令进度日结 | | 日终结算 | 15:10 | 全量对账 / 除权 / 安全垫 / 命令进度日结 |
@ -180,6 +181,20 @@ def signal_digest():
return signal_service.consume() 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") @celery_app.task(name="pms.t0_close")
@guard(trade_day=True) @guard(trade_day=True)
def t0_close(): def t0_close():
@ -234,6 +249,7 @@ celery_app.conf.beat_schedule = {
"replay_fills": {"task": "pms.replay_fills", "schedule": crontab(minute="*")}, "replay_fills": {"task": "pms.replay_fills", "schedule": crontab(minute="*")},
"intraday_exec": {"task": "pms.intraday_exec", "schedule": crontab(minute="*")}, "intraday_exec": {"task": "pms.intraday_exec", "schedule": crontab(minute="*")},
"signal_digest": {"task": "pms.signal_digest", "schedule": crontab(minute="*")}, "signal_digest": {"task": "pms.signal_digest", "schedule": crontab(minute="*")},
"macro_scan": {"task": "pms.macro_scan", "schedule": crontab(hour=9, minute=35)},
"t0_close": {"task": "pms.t0_close", "schedule": crontab(hour=14, minute=50)}, "t0_close": {"task": "pms.t0_close", "schedule": crontab(hour=14, minute=50)},
"daily_settle": {"task": "pms.daily_settle", "schedule": crontab(hour=15, minute=10)}, "daily_settle": {"task": "pms.daily_settle", "schedule": crontab(hour=15, minute=10)},
"daily_report": {"task": "pms.daily_report", "schedule": crontab(hour=15, minute=30)}, "daily_report": {"task": "pms.daily_report", "schedule": crontab(hour=15, minute=30)},

View File

@ -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_<KEY大写>_*),
# 决策/执行/闸/页面零改动。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}

View File

@ -52,6 +52,9 @@ RUNTIME_EXTRA = {
"PMS_UPSTREAM_ALERT_SCAN": (300, int, "告警扫描窗: 每轮从告警流取最近 N 条再分类限量 (窗要大于各类上限之和, 防 price_notice 等高频源把风控告警挤出最新N)"), "PMS_UPSTREAM_ALERT_SCAN": (300, int, "告警扫描窗: 每轮从告警流取最近 N 条再分类限量 (窗要大于各类上限之和, 防 price_notice 等高频源把风控告警挤出最新N)"),
"PMS_UPSTREAM_ALERT_PER_CAT": (20, int, "告警每类上限: 同一类型最多显示最新 N 条 (砍单类刷屏, 不挤别的类)"), "PMS_UPSTREAM_ALERT_PER_CAT": (20, int, "告警每类上限: 同一类型最多显示最新 N 条 (砍单类刷屏, 不挤别的类)"),
"PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (60, int, "告警时间窗(分钟): 超过此龄判为陈旧并在页面置灰标注(不删除, 风控不丢信息), 0=不限龄。与每类上限正交, 先按龄标记再每类限量"), "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)。** # **读不到时必须按"已暂停"处理的键 (fail-closed)。**
@ -66,6 +69,10 @@ FAIL_CLOSED = {
# 参数表抖一下就按 full 走, 等于在读不到任何约束的情况下自己去买没买过的票。 # 参数表抖一下就按 full 走, 等于在读不到任何约束的情况下自己去买没买过的票。
# 读不到 → off (本轮不扫描新建仓), 已有持仓的四类动作不受影响。 # 读不到 → off (本轮不扫描新建仓), 已有持仓的四类动作不受影响。
"PMS_OPEN_AUTONOMY": "off", "PMS_OPEN_AUTONOMY": "off",
# 宏观择时总开关的文件初值是 True (模拟仓拍板) —— 表读不到时若退文件初值, 宏观层会在
# 基础设施故障期间照常想下命令。安全方向是关: 宁可少动一轮。取数环节同库先挂是第一道
# 保险, 这条名单是第二道。
"PMS_MACRO_ENABLED": False,
} }
# 页面展示用的中文说明 (settings.py 用行尾注释, pydantic 取不到, 故在此集中维护) # 页面展示用的中文说明 (settings.py 用行尾注释, pydantic 取不到, 故在此集中维护)
@ -161,6 +168,29 @@ DESC = {
"PMS_QMT_HEARTBEAT_STALE_SEC": "心跳陈旧超此秒数 → 判定 ws 进程已死, 指令一律拒发", "PMS_QMT_HEARTBEAT_STALE_SEC": "心跳陈旧超此秒数 → 判定 ws 进程已死, 指令一律拒发",
"PMS_QMT_CONNECT_TIMEOUT_SEC": "建连超时 (秒)", "PMS_QMT_CONNECT_TIMEOUT_SEC": "建连超时 (秒)",
"PMS_QMT_SEND_MAX_ATTEMPTS": "单张委托发送重试上限, 试满置 SEND_FAILED 等人工", "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 是否为空」判断缓存是否有效 —— # loaded 标记必不可少: 不能用「data 是否为空」判断缓存是否有效 ——
@ -325,12 +355,26 @@ _RANGES = {
# 任务会先被 celery 打死 (而且是在已经落了一部分表之后)。 # 任务会先被 celery 打死 (而且是在已经落了一部分表之后)。
"PMS_UPSTREAM_ALERT_SCAN": (1, 20000), "PMS_UPSTREAM_ALERT_PER_CAT": (1, 1000), "PMS_UPSTREAM_ALERT_SCAN": (1, 20000), "PMS_UPSTREAM_ALERT_PER_CAT": (1, 1000),
"PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (0, 1440), # 0=不限龄, 上限 24 小时 "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): def _range_check(key, v):
if key == "PMS_AUTONOMY" and v not in ("full", "propose_only", "off"): if key == "PMS_AUTONOMY" and v not in ("full", "propose_only", "off"):
return "PMS_AUTONOMY 只能是 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"): if key == "PMS_OPEN_AUTONOMY" and v not in ("full", "propose_only", "off"):
return "PMS_OPEN_AUTONOMY 只能是 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"): if key == "PMS_EXEC_IMPL" and str(v).strip().upper() not in ("A", "B"):

View File

@ -42,6 +42,19 @@ logger = logging.getLogger("pms.proposal")
AUTONOMY_FULL, AUTONOMY_PROPOSE, AUTONOMY_OFF = "full", "propose_only", "off" 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: def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
"""自主提议扫描一轮。dry_run=True 只出候选与判定, 不落任何表。""" """自主提议扫描一轮。dry_run=True 只出候选与判定, 不落任何表。"""
now = now or datetime.now() now = now or datetime.now()
@ -88,6 +101,20 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
logger.exception("提议扫描失败") logger.exception("提议扫描失败")
return {**out, "ok": False, "errors": [f"扫描失败: {type(e).__name__}: {e}"]} 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["candidates"] = len(scanned["candidates"])
out["skipped"].extend(scanned["skipped"]) out["skipped"].extend(scanned["skipped"])
stock_params = command_service.effective_stock_params() 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, 取不到就整只跳过并留痕 这条路是要真下单的, 所以走 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) src = (param_store.get("PMS_CANDIDATE_SOURCE", plan_feed.SRC_PLAN_API)
or plan_feed.SRC_PLAN_API).strip() or plan_feed.SRC_PLAN_API).strip()
if src == plan_feed.SRC_BUY_PLAN: if src == plan_feed.SRC_BUY_PLAN:

View File

@ -103,6 +103,20 @@ def _buypause_codes() -> set:
return 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: def _emit_instruction(st: dict, dec: dict, *, forced: bool = False) -> str:
"""按决策发一张短窗口命令指令 (window_tdays=1, is_command=True), 交 run_tick 执行。 """按决策发一张短窗口命令指令 (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")) scale = _f(view.get("totals", {}).get("scale"))
today = _today() today = _today()
paused_codes = _buypause_codes() # 决策系统风控预警暂停买入的票 (本模块只读) paused_codes = _buypause_codes() # 决策系统风控预警暂停买入的票 (本模块只读)
macro_gated = _macro_gate_active() # 宏观偏热闸: 全体策略买开腿暂停, 闸解除自动恢复
if macro_gated:
out["notes"].append("宏观偏热闸生效: 策略买入开腿本轮暂停 (卖出与平回照常)")
# 全局当日T亏熔断: 汇总所有做T策略的当日T盈亏 (realized_t_profit 相对日初基线的增量) # 全局当日T亏熔断: 汇总所有做T策略的当日T盈亏 (realized_t_profit 相对日初基线的增量)
global_t_pnl = 0.0 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") out["notes"].append(f"{code} 当日T亏达单票熔断线 {stock_cap:.0f} 元, 今日不再开新T")
ctx = {"state": state, "scale": scale, "halted": halted, "notes": out["notes"], 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) dec = fn(st, pos, day, now, ctx)
if dec: if dec:

View File

@ -506,6 +506,30 @@ def api_upstream_signals():
return ok(upstream_signals.snapshot) 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") @app.get("/api/upstream/plan")
def api_upstream_plan(limit: int = Query(30), date: str = Query(None), def api_upstream_plan(limit: int = Query(30), date: str = Query(None),
bucket: str = Query("main")): bucket: str = Query("main")):

View File

@ -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-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);} .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);} .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;}
</style> </style>
</head> </head>
<body> <body>
@ -229,6 +234,11 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
<el-alert v-if="dm.shadow" class="banner" type="info" effect="dark" show-icon :closable="false" <el-alert v-if="dm.shadow" class="banner" type="info" effect="dark" show-icon :closable="false"
:title="'影子运行中 —— ' + (dm.hint || '')"></el-alert> :title="'影子运行中 —— ' + (dm.hint || '')"></el-alert>
<el-alert v-if="macroGateOn" class="banner" type="warning" effect="dark" show-icon :closable="false"
:title="'宏观偏热闸生效中 —— 系统自主买入与策略买开腿已暂停 (你的命令不受影响)。' + ((macro.gate||{}).why||'')"></el-alert>
<el-alert v-if="macroUnavailable" class="banner" type="warning" show-icon :closable="false"
:title="'宏观信号数据不可用 —— 宏观层今日不动作、不落闸: ' + macroUnavailable"></el-alert>
<el-alert v-if="scaleGap" class="banner" type="warning" effect="dark" show-icon :closable="false" <el-alert v-if="scaleGap" class="banner" type="warning" effect="dark" show-icon :closable="false"
:title="'总规模与账户不符 —— 参数 ' + money(ov.scale) + ' / 账户总资产 ' + money(ov.total_asset) + ' (' + pct(scaleGap) + ')。仓位上限按参数算、资金校验按账户算, 差得远时方案会过闸却下不出去'"></el-alert> :title="'总规模与账户不符 —— 参数 ' + money(ov.scale) + ' / 账户总资产 ' + money(ov.total_asset) + ' (' + pct(scaleGap) + ')。仓位上限按参数算、资金校验按账户算, 差得远时方案会过闸却下不出去'"></el-alert>
@ -252,6 +262,44 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
<span class="muted" style="font-size:11.5px;cursor:pointer" @click="showUpstream=true">下方查看全部 ▾</span> <span class="muted" style="font-size:11.5px;cursor:pointer" @click="showUpstream=true">下方查看全部 ▾</span>
</div> </div>
<!-- 宏观择时 (MACRO_TIMING_PLAN.md): 股汇对冲指数管整体仓位; 参数在「参数设置」PMS_MACRO_* -->
<div class="panel tblk">
<h3>宏观择时 <span class="muted" style="font-weight:400">· 股汇对冲指数 → 整体仓位</span>
<span v-if="macro.enabled===false" class="muted" style="font-size:12px;font-weight:normal">(总开关关闭, 只展示不动作)</span>
<span v-else-if="macro.autonomy" class="muted" style="font-size:12px;font-weight:normal">(档位 {{ macro.autonomy==='full' ? '全自动' : (macro.autonomy==='propose_only' ? '只出建议' : '只算不动') }}</span>
</h3>
<div v-for="s in (macro.signals||[])" :key="s.key" style="margin-bottom:6px">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<b>{{ s.label }}</b>
<span class="mono" style="font-size:21px;font-weight:700" :style="{color: mzColor(s.zone)}">{{ s.value==null ? '—' : (s.value>0?'+':'') + Number(s.value).toFixed(1) }}</span>
<span class="mz-zone" :style="{background: mzColor(s.zone)}">{{ mzLabel(s.zone) }}</span>
<span class="muted" v-if="s.e_now>0">超额深度 {{ s.e_now }}</span>
<span class="muted" v-if="s.streak">区内第 {{ s.streak }} 天</span>
<span class="muted" v-if="s.inflight">宏观命令在途</span>
<span class="muted" v-if="s.ymd" style="font-size:11.5px">{{ s.ymd }}</span>
<div class="spacer"></div>
<button class="tbtn" v-show="mode==='ops'" @click="macroScan(true)">试算</button>
<button class="tbtn" v-show="mode==='ops'" @click="macroScan(false)">立即扫描</button>
</div>
<div class="mz-bars" v-if="(s.history||[]).length">
<span v-for="(h,i) in s.history" :key="i" class="mz-bar"
:title="h.ymd + ' · ' + (h.value==null ? '不可用' : h.value) + ' · ' + mzLabel(h.zone)"
:style="mzBarStyle(h)"></span>
</div>
<div class="muted" style="font-size:11.5px" v-if="(s.history||[]).length">近 {{ s.history.length }} 个交易日 · 热区阈值 +{{ (macro.params||{}).hot_th }} / 冷区 {{ (macro.params||{}).cold_th }} / 退出带 ±{{ (macro.params||{}).exit_band }}</div>
<div v-if="s.advice && !s.advice.adopted" class="mz-advice">
<b>今日建议:{{ s.advice.direction==='REDUCE' ? '降仓' : '升仓' }} {{ (s.advice.pct*100).toFixed(1) }}%</b>
<span class="muted">{{ s.advice.why }}</span>
<button class="tbtn pri" @click="macroAdopt(s.key)">采纳并下命令</button>
<span class="muted" style="font-size:11.5px">当日有效, 隔日自动作废</span>
</div>
<div class="muted" v-if="s.note" style="font-size:12px">{{ s.note }}</div>
<div class="muted" v-if="s.last_command" style="font-size:12px">最近宏观命令 {{ s.last_command.command_id }} · {{ s.last_command.cmd_type==='REDUCE_EXPOSURE' ? '降仓' : '升仓' }} · {{ s.last_command.status }}<template v-if="s.last_command.target_amount"> · 进度 {{ money(s.last_command.done_amount||0) }} / {{ money(s.last_command.target_amount) }}</template></div>
<div style="color:var(--warn)" v-if="s.error">读取失败: {{ s.error }}</div>
</div>
<div class="muted" v-if="!(macro.signals||[]).length">暂无信号数据(还没扫描过, 或宏观层未启用)</div>
</div>
<!-- 7) 上游信号 (只读参考) --> <!-- 7) 上游信号 (只读参考) -->
<div class="panel tblk"> <div class="panel tblk">
<h3 style="cursor:pointer" @click="showUpstream=!showUpstream">上游信号 <span class="muted" style="font-weight:400">· 只读参考,不进下单逻辑</span> <span class="muted" style="font-size:12px;font-weight:normal">{{ showUpstream?'▲ 收起':'▼ 展开' }}</span></h3> <h3 style="cursor:pointer" @click="showUpstream=!showUpstream">上游信号 <span class="muted" style="font-weight:400">· 只读参考,不进下单逻辑</span> <span class="muted" style="font-size:12px;font-weight:normal">{{ showUpstream?'▲ 收起':'▼ 展开' }}</span></h3>
@ -2075,6 +2123,38 @@ createApp({
return m ? (h + '小时' + m + '分前') : (h + '小时前'); return m ? (h + '小时' + m + '分前') : (h + '小时前');
} }
const alertRowClass = ({ row }) => (row && row.stale) ? 'alert-stale' : ''; 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() { async function loadUpstream() {
const d = await call('get', '/api/upstream-signals'); const d = await call('get', '/api/upstream-signals');
upstream.value = (d && d.sources) ? d : { sources: {} }; upstream.value = (d && d.sources) ? d : { sources: {} };
@ -2113,7 +2193,8 @@ createApp({
loading.value = true; err.value = ''; loading.value = true; err.value = '';
await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(), await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(),
loadPositions(), loadInstructions(), loadLedger(), loadProposals(), loadPositions(), loadInstructions(), loadLedger(), loadProposals(),
loadDispatchMode(), loadWs(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream()]); loadDispatchMode(), loadWs(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream(),
loadMacro()]);
await loadNames(); await loadNames();
lastRefresh.value = new Date().toTimeString().slice(0, 8); lastRefresh.value = new Date().toTimeString().slice(0, 8);
loading.value = false; loading.value = false;
@ -2314,7 +2395,7 @@ createApp({
async function refreshLive() { async function refreshLive() {
// allSettled: 单个 loader 失败/超时(axios 60s 兜底)不拖累其余与时间戳更新。 // allSettled: 单个 loader 失败/超时(axios 60s 兜底)不拖累其余与时间戳更新。
await Promise.allSettled([loadOverview(), loadPositions(), loadInstructions(), loadLedger(), await Promise.allSettled([loadOverview(), loadPositions(), loadInstructions(), loadLedger(),
loadProposals(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream()]); loadProposals(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream(), loadMacro()]);
try { await loadNames(); } catch (e) {} try { await loadNames(); } catch (e) {}
// 用客户端钟, 保证"自动·HH:MM:SS"稳定跳动, 不依赖 /health 的 now。 // 用客户端钟, 保证"自动·HH:MM:SS"稳定跳动, 不依赖 /health 的 now。
lastRefresh.value = new Date().toTimeString().slice(0, 8); lastRefresh.value = new Date().toTimeString().slice(0, 8);
@ -2350,6 +2431,8 @@ createApp({
openScan, insTab, todayYmd, insLive, insDone, insEnd, dispOf, loadOpenScan, denyToday, openScan, insTab, todayYmd, insLive, insDone, insEnd, dispOf, loadOpenScan, denyToday,
showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh, showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh,
upstream, showUpstream, usrc, loadUpstream, upstream, showUpstream, usrc, loadUpstream,
macro, macroGateOn, macroUnavailable, mzLabel, mzColor, mzBarStyle,
loadMacro, macroScan, macroAdopt,
showAlerts, alertItems, alertLatest, alertLevelClass, alertGroups, alertStripItems, showAlerts, alertItems, alertLatest, alertLevelClass, alertGroups, alertStripItems,
alertStripCount, alertTotalCount, catShown, toggleCat, ageText, alertRowClass, alertStripCount, alertTotalCount, catShown, toggleCat, ageText, alertRowClass,
alertItemsF, levelShown, toggleLevel, levelCount, ALERT_LEVELS, lvLabel, lvClass, alertGridFixed, passLevel, flowText, flowClass, alertItemsF, levelShown, toggleLevel, levelCount, ALERT_LEVELS, lvLabel, lvClass, alertGridFixed, passLevel, flowText, flowClass,

View File

@ -251,6 +251,35 @@ class Settings(BaseSettings):
PMS_SIGNAL_AUTO_EXIT_CONF: float = 0.85 # 高于此置信度直接转清仓指令, 之间则落提议 PMS_SIGNAL_AUTO_EXIT_CONF: float = 0.85 # 高于此置信度直接转清仓指令, 之间则落提议
PMS_SIGNAL_TRIM_RATIO: float = 0.3333 # 中等置信度时的减仓比例 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_REPLAY_INTERVAL_MIN: int = 5
PMS_RECON_ALARM_DAYS: int = 3 # 连续不一致 N 日升级 ERROR PMS_RECON_ALARM_DAYS: int = 3 # 连续不一致 N 日升级 ERROR

View File

@ -373,3 +373,23 @@ CREATE TABLE IF NOT EXISTS pms_op_log (
KEY idx_op (op), KEY idx_op (op),
KEY idx_code (ts_code) KEY idx_code (ts_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易员操作日志'; ) 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='宏观信号日快照与动作记录';

View File

@ -26,7 +26,11 @@ PMS_TABLES = ["pms_command", "pms_plan", "pms_position", "pms_lot", "pms_instruc
# 14/15 号表。**加表时记得往这里加一行** —— 漏了的话 init_db 建了表、 # 14/15 号表。**加表时记得往这里加一行** —— 漏了的话 init_db 建了表、
# check_db 却一声不吭, 表结构自检就有个静默的盲区。 # check_db 却一声不吭, 表结构自检就有个静默的盲区。
# (pms_cash_flow 就漏过一轮, 2026-07-31 补上) # (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"] DOWNSTREAM = ["trading_position", "trading_order", "trading_buy_plan"]
FAILED, WARNED = [], [] FAILED, WARNED = [], []

View File

@ -19,8 +19,11 @@
test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/ test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/
转多信号插队与留痕/窗口末日只对命令/跳过原因分得开/ 转多信号插队与留痕/窗口末日只对命令/跳过原因分得开/
研判请求体的数据库类型 (44 ) 研判请求体的数据库类型 (44 )
test_batch13_units.py 决策系统卖出采纳: 门槛分档/清场标记/确认加速/闭仓清场 (4 )
test_batch14_units.py 宏观择时: 指数计算与对齐/区域迟滞/周期与对数映射/
分方向触发/让路与冷却/宏观闸/建议采纳 (25 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 ) test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
469 498
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
""" """
import os 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_batch4_units.py", "test_batch5_units.py", "test_batch6_units.py",
"test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py", "test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py",
"test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py", "test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py",
"test_batch13_units.py", "test_wiring.py"] "test_batch13_units.py", "test_batch14_units.py", "test_wiring.py"]
def main(): def main():

View File

@ -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()

View File

@ -557,7 +557,7 @@ def run():
"DEFAULT 'NONE' COMMENT 'NONE/REQUESTED/SENT'"): "DEFAULT 'NONE' COMMENT 'NONE/REQUESTED/SENT'"):
eq(find_adjacent_literals(good), [], f"误报: {good[:40]}") eq(find_adjacent_literals(good), [], f"误报: {good[:40]}")
@case("DDL 文件本身体检通过 (17 张表 + 1 条初始行)") @case("DDL 文件本身体检通过 (18 张表 + 1 条初始行)")
def _(): def _():
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from init_db import DDL_FILE, find_adjacent_literals, parse_statements from init_db import DDL_FILE, find_adjacent_literals, parse_statements
@ -569,7 +569,8 @@ def run():
eq(find_adjacent_literals(s), [], f"{tbl} 有相邻字面量") eq(find_adjacent_literals(s), [], f"{tbl} 有相邻字面量")
# 加表就要来这里 +1 —— 这一行是 DDL 与代码之间唯一的哨兵, 它不动就说明新表没进 # 加表就要来这里 +1 —— 这一行是 DDL 与代码之间唯一的哨兵, 它不动就说明新表没进
# ddl_pms_v1.sql (init_db 只认这个文件, 建不出来的表在实机上才会报"表不存在") # 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) eq(len([1 for k, _, _ in stmts if k == "seed"]), 1)
@case("通道三表的 SQL 全部单表合规") @case("通道三表的 SQL 全部单表合规")

View File

@ -556,11 +556,12 @@ def _():
names = set(sch.celery_app.conf.beat_schedule) names = set(sch.celery_app.conf.beat_schedule)
assert names == {"plan_pull", "premarket", "command_poll", "replay_fills", assert names == {"plan_pull", "premarket", "command_poll", "replay_fills",
"intraday_exec", "signal_digest", "t0_close", "daily_settle", "intraday_exec", "signal_digest", "t0_close", "daily_settle",
"daily_report"}, names "daily_report",
"macro_scan"}, names # 2026-08-18 +宏观择时 09:35
tasks = set(sch.celery_app.tasks) tasks = set(sch.celery_app.tasks)
for t in ("pms.premarket", "pms.command_poll", "pms.replay_fills", "pms.daily_settle", for t in ("pms.premarket", "pms.command_poll", "pms.replay_fills", "pms.daily_settle",
"pms.daily_report", "pms.t0_close", "pms.intraday_exec", "pms.signal_digest", "pms.daily_report", "pms.t0_close", "pms.intraday_exec", "pms.signal_digest",
"pms.plan_pull"): "pms.plan_pull", "pms.macro_scan"):
assert t in tasks, t assert t in tasks, t
assert sch.in_session(datetime(2026, 7, 27, 10, 0)) is True assert sch.in_session(datetime(2026, 7, 27, 10, 0)) is True
assert sch.in_session(datetime(2026, 7, 27, 12, 0)) is False assert sch.in_session(datetime(2026, 7, 27, 12, 0)) is False