加入股汇参数和相应逻辑
This commit is contained in:
parent
3eded57d7e
commit
5fb030135e
|
|
@ -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
|
||||
```
|
||||
|
||||
预期能看到指令的裁决栏区分等待和停止,紧急指令标注直通,被闸拦下的动作在原因栏写明是哪道闸。
|
||||
46
DEVLOG.md
46
DEVLOG.md
|
|
@ -30,6 +30,52 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-08-18 · 宏观择时层上线:股汇对冲指数管整体仓位,外加个股宏观闸
|
||||
|
||||
**背景与依据**
|
||||
方案与全部拍板记录在 MACRO_TIMING_PLAN.md(V3 可读版)。参数初值来自当天在桥机实跑的标定,报告存档在 MACRO_CALIB_2026-08-18.md。模拟仓拍板:默认总开关开、档位全自动、不设观察期;转实盘前要把这两个默认改回保守档。
|
||||
|
||||
**做了什么**
|
||||
给持仓系统加了一个宏观择时层。全部改动都在 tradingSystem,其他四个系统零改动。行为分四句话说。每个交易日九点三十五分算一次股汇对冲指数。指数进偏冷区(低于负二十)当天,像用户一样在命令台下升仓命令。指数进偏热区(高于正二十五)当天,先落宏观闸:自主增持四类动作和策略的买入开腿暂停,卖出与平回照常。指数从极值回落穿出正十五时,下降仓命令,幅度按本轮峰值深度取对数映射(S0=0.049、k=1.6、单周期封顶两成、降仓地板是总仓位两成)。
|
||||
|
||||
护栏与铁律的落点。它动仓位的唯一出口是 command_service.issue,署名 macro。与用户在途命令冲突就放弃并留痕,绝不带强制标志。升仓让位于全局暂停买入和组合刹车。数据取不到就判不可用,不动作、不落闸。信号每日一行落新表 pms_macro_signal(第 18 张表)。闸状态放运行参数 PMS_MACRO_GATE_STATE,由扫描写入,提议层与策略层只读,读不到一律当不生效——闸的安全方向是不额外拦。三张数据源表全在 153 代理,是标定实测钉死的(zs_day_data、gp_fx_daily 的 USDCNH.FXCM、gp_shibor 的 rate_1m)。
|
||||
|
||||
**动了哪些文件**
|
||||
新增五个:
|
||||
- app/core/macro_rules.py —— 纯逻辑:四步计算与 as-of 对齐、区域迟滞、周期状态、对数映射决策
|
||||
- app/repo/macro_repo.py —— 三张源表单表取数 + pms_macro_signal 读写
|
||||
- app/services/macro_service.py —— 编排、信号注册表、闸状态、建议采纳
|
||||
- scripts/test_batch14_units.py —— 25 例(计算对照手算/迟滞/周期映射/让路冷却/闸/采纳幂等)
|
||||
- (scripts/calibrate_macro_signal.py 此前已交付并实跑,保留做月度复盘)
|
||||
|
||||
修改十二处,全部只加行、不改既有行为:
|
||||
- config/settings.py —— PMS_MACRO_* 参数一段(含模拟仓默认与转实盘提醒)
|
||||
- app/services/param_store.py —— 说明/范围/合法值校验;GATE_STATE 运行键;PMS_MACRO_ENABLED 进 FAIL_CLOSED(文件初值是开,参数表读不到时按关处理)
|
||||
- app/scheduler.py —— macro_scan 调度位(交易日 09:35,带守卫)
|
||||
- app/services/proposal_service.py —— 两处扫描入口加宏观闸检查(拦买入侧、保垫减仓照常)
|
||||
- app/services/strategy_runner.py —— 买开腿判定点加闸条件(与 buypause 同点,平回天然不拦)
|
||||
- app/web/main.py —— 三个接口:/api/macro/status、/api/ops/macro-scan、/api/macro/adopt
|
||||
- app/web/static/index.html —— 宏观择时面板 + 偏热闸与数据不可用两条横幅
|
||||
- ddl_pms_v1.sql —— 第 18 张表 pms_macro_signal
|
||||
- scripts/check_db.py —— 表清单加 pms_macro_signal(顺带记录:pms_strategy 与 pms_op_log 本就不在清单里,属既有欠账,按不夹带纪律未补,单独提)
|
||||
- scripts/run_tests.py —— 登记 batch14,总数改为 498 例
|
||||
- scripts/test_batch6_units.py —— DDL 表数哨兵 17 改 18(哨兵注释写明本次加表)
|
||||
- scripts/test_wiring.py —— 调度表哨兵集合加 macro_scan
|
||||
|
||||
**部署方式**
|
||||
桥机 factorevaluation 上 make deploy(源码打进镜像,必须重建容器),跑 make initdb 建新表(幂等),make test 要见 ALL SUITES PASS。**务必收盘后部署**:默认就是全自动档,部署完成后的下一个交易日 09:35 就是第一次真实扫描。参数全是新键,不存在页面旧值覆盖的问题。
|
||||
|
||||
**真机判收**
|
||||
未判收。开发容器里已过两道:全量单测 ALL SUITES PASS(498 例,含新增 25 例与两处哨兵更新);页面无头渲染判收八项全过、零页面错误(面板、指数值、区域标签、建议卡、采纳按钮、闸横幅、最近命令、二十根迷你条)。桥机判收按 MACRO_TIMING_PLAN.md 第六节走:make test → make check 见 18 表 → dry_run 试算并与标定脚本同日值对照 → 次日 09:35 看第一跳与面板。
|
||||
|
||||
**还欠着什么**
|
||||
1. 桥机部署与第六节判收(含冲突演练那一步)。
|
||||
2. 第一个极值触发日的表现(模拟仓,让它自然发生)。
|
||||
3. 月度复盘:数据多一段就重跑标定脚本,校准阈值与 S0/k。
|
||||
4. check_db 表清单缺 pms_strategy 与 pms_op_log 的既有欠账,单独提。
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-18 · 决策系统卖出采纳复核:降门槛+全留痕 / 确认即加速 / 清仓完成清场
|
||||
|
||||
**背景**
|
||||
|
|
|
|||
|
|
@ -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 年那样的深熊。这套初值是合身的起点,不是验证过的真理。只出建议的观察期和每月重跑本脚本的复盘,就是为持续校准设的。
|
||||
|
|
@ -1,145 +1,244 @@
|
|||
# tradingSystem · 宏观择时接入方案 V2(股汇对冲指数 → 组合仓位 + 个股宏观闸)
|
||||
# tradingSystem 宏观择时接入方案(V3 可读版)
|
||||
|
||||
> 状态: **V2,按用户 2026-08-18 三条拍板意见修订,待标定后定稿** | 日期: 2026-08-18
|
||||
> **V2 修订(依用户三点反馈)**:①触发口径不预设默认值,**先跑标定脚本**(`scripts/calibrate_macro_signal.py`,已交付)用历史数据对比"进区即动 vs 极值回落再动"后再定;②仓位调整由固定步长改为**对数映射**——极值越深加码越多、边际递减,参数由标定给建议值;③新增**个股宏观闸**:偏热区确认后,自主增持类个股动作(新建仓/回踩补足/盈利加仓/补仓)一并暂停——这同时堵上 V1 的一个洞(宏观降仓释放的现金会被 `PMS_OPEN_AUTONOMY=full` 的自主新建仓在下一分钟买回去);④数据源按用户口径:`gp_fx_daily` / `gp_shibor` 在 **192.168.16.153(153 代理)**,标定脚本顺带完成列名探查与核实。
|
||||
> 写作纪律沿袭项目既有文档:术语先白话、阈值给硬数字且全部可配置、失败路径显式、未确认事项进清单。
|
||||
> 关联:`POSITION_MGMT_DESIGN.md`(总体设计)、`README.md`(模块地图与三条铁律)、`DEVLOG.md` 2026-08-06「动 tradingSystem 以外的系统之前先答三题」。
|
||||
> 状态:已定稿,可以开工。日期:2026-08-18。
|
||||
> 本版把 V2.1 按项目输出风格重写(风格文件在 .claude/output-styles/readable-chinese.md)。技术内容与 V2.1 一致,没有新增决定。
|
||||
> 参数初值的依据是当天在桥机实跑的标定报告,报告存档在仓库根目录的 MACRO_CALIB_2026-08-18.md。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话
|
||||
## 一、这件事为最终目标买什么
|
||||
|
||||
给 PMS 加一个「宏观择时层」:每个交易日算一次股汇对冲指数,指数进入极热区就**像用户一样在命令台下降仓命令**(REDUCE_EXPOSURE,幅度按对数映射随极值深度加码),进入极冷区下升仓命令,中性不动;偏热确认期间同时**暂停自主增持类个股动作**(宏观闸,防止降仓的钱被自动建仓买回去)。它产生仓位变化的唯一出口是**命令**与**闸**;方案生成、规则闸、择时出手、下发、对账、进度回报,全部走现有链路,**一行不改**。框架按「信号注册表」做,未来再接别的宏观信号(bionic 宏观水温、择时层日 QRS 等)只加注册表项。
|
||||
我们的最终目标是管好整体仓位。现在系统只会执行你手动下的仓位命令。大盘整体是偏热还是偏冷,系统自己不看,也不动。
|
||||
|
||||
---
|
||||
这次要加一个宏观择时层。它每天算一个指标,判断大盘处在偏热、偏冷还是中性阶段。偏冷阶段自动提高仓位。偏热阶段先管住买入,等热度退潮时再降低仓位。
|
||||
|
||||
## 1. 现状盘点:目标需要什么 vs 系统已有什么
|
||||
买到的东西有三样。第一,极端行情里仓位会自动往安全方向走,不再完全依赖人盯。第二,系统的自动买入不会在过热阶段火上浇油。第三,每次动作都有数据依据,事后可以复盘打分。
|
||||
|
||||
| 环节 | 这个目标需要 | 系统现状 | 差距 |
|
||||
|---|---|---|---|
|
||||
| 信号数据 | `zs_day_data`(上证)、`gp_fx_daily`(USD/CNH)、`gp_shibor`(SHIBOR 1M) | `zs_day_data` 在 199 库有实证(bionic market_regime 在读,列 `symbol/timestamp/close/amount`,代码点式 `000001.SH`);**用户口径:fx 与 shibor 两表在 153 代理**。两表在五个项目里零代码引用,列名未经实测 | **小**:连接现成(153=proxy 源、199=index 源都已配),列名由标定脚本探查后回填 §4.2 |
|
||||
| 信号计算 | 20 日对数收益 → spread → SHIBOR 修正 → 40 日 Z-Score ×10 | 全生态无任何股汇对冲指数实现(`hedge` 关键词全项目零命中)。bionic 有 `market_regime.py`(宏观水温),但那是决策系统内部件,PMS 不读 | **缺**:需新写,约百行纯函数 |
|
||||
| 组合级自动决策 | 极值 → 升降整体仓位 | **无**。设计 §5 原话:「总仓上限由用户命令控制(升降仓判断权在用户,**不自动联动**)」。本需求是对这条设计决策的一次**有意扩展**,必须带着同样的档位/确认哲学落地 | **缺**:决策引擎(区制/确认/冷却/对数映射/上下限) |
|
||||
| 个股动作联动 | 偏热时管住增量(用户拍板新增) | 自主提议五类动作只看个股与组合约束,不看宏观;`组合刹车`有"回撤后停自主增持"的同型机制可借鉴 | **缺**:宏观闸(§5.2,实现手势照抄刹车) |
|
||||
| 执行面 | 把「降 X%」翻成逐股卖出并执行 | ✅ **已完备**。`REDUCE_EXPOSURE` / `INCREASE_EXPOSURE` 任务命令全生命周期:`command_service.issue()` → planner 四档凑额(停新买→清弱票→收利润→等比微减)→ 方案落表 → executor 转指令 → 规则闸终检 → 择时出手 → 回报进度。`pms_command.issued_by` 字段与 `/api/commands` 的 `by` 参数都在 | **无差距,全量复用** |
|
||||
| 冲突仲裁 | 自动命令不得顶撞用户在途命令 | ✅ `command_spec.detect_conflicts` 已覆盖 REDUCE↔INCREASE、LIQUIDATE、HALT 等对 | 复用,宏观侧遇冲突**跳过不 force** |
|
||||
| 档位/确认哲学 | 先建议后自动,人可随时收权 | ✅ 有成熟模板(`PMS_AUTONOMY` / `PMS_OPEN_AUTONOMY` 三档)。但 `pms_proposal` 是**个股级**,组合级建议放不进去 | **小**:宏观建议自己承载(§7) |
|
||||
| 参数 | 阈值全部页面可调 | ✅ `param_store` 登记即出现在设置页、5 秒生效 | 登记即可 |
|
||||
| 调度 | 每交易日定时算一次 | ✅ celery beat 骨架 + 三条守卫(交易日/休假/吞异常守成)成熟 | 加一个调度位 |
|
||||
| 页面 | 看得见指数、区制、建议、闸状态 | ✅ 单页已有多面板先例 | 加一块面板 |
|
||||
| 留痕 | 为什么动/为什么没动,可回查 | ✅ `pms_action_ledger` + 命令表 | 复用 + 新增信号历史表 |
|
||||
| 历史存储 | 指数序列可回看、可判分、可标定 | 无 | **缺**:新表 `pms_macro_signal` |
|
||||
这一层动仓位只有一种方式,就是像你一样在命令台下命令。方案生成、合规检查、择时、下单、对账,全部走现有链路。现有代码的行为一行都不改。框架还预留了位置,将来接入别的宏观信号时,决策和执行部分不用再改。
|
||||
|
||||
结论:**执行面九成现成,缺的是"信号计算 + 组合级决策 + 宏观闸 + 一小块页面"这一层薄壳**。风险不在工作量,在接入姿势——所以本方案把大部分篇幅花在护栏上。
|
||||
## 二、这个指标是什么
|
||||
|
||||
---
|
||||
指标叫股汇对冲指数。它回答一个问题:最近二十个交易日,A 股和人民币汇率是不是在同向共振。
|
||||
|
||||
## 2. 目标翻译成系统语言:为什么走命令通道 + 扫描层闸
|
||||
股市涨,同时人民币贬值,这叫股强汇弱,指数走高。股市跌,同时人民币升值,指数走低。计算时还会减去一点资金利率的干扰,再做标准化,变成一个可以横向比较的分数。
|
||||
|
||||
「极热 → 降低整体仓位」在本系统里最忠实的表达就是一条 `REDUCE_EXPOSURE {pct, window_tdays}`——语义完全一致(释放金额 = 规模 × pct,planner 按 停新买→清弱票→收利润→等比微减 四档凑额,这正是"pms 根据规则执行对个股的降仓位操作")。反向就是 `INCREASE_EXPOSURE`。存量靠命令,增量靠闸:偏热期间把自主增持的**扫描**停掉(不产生候选就不会有指令),两头合起来才是完整的"仓位受宏观控制"。
|
||||
分数的含义是:当前的共振强度,相对最近四十个交易日处在多极端的位置。分数放大了十倍,所以正负二十几就是很极端的读数。今天的读数是正十四点七,处在中性区。
|
||||
|
||||
刻意**不做**的两种姿势:
|
||||
数据来自三张表,都在 153 代理数据库里,已经实测确认。上证指数日线在表 zs_day_data。美元兑离岸人民币汇率在表 gp_fx_daily。上海银行间市场一个月期的拆借利率在表 gp_shibor。完整公式和列名对照放在附录一,可不读。
|
||||
|
||||
1. **不做"连续仓位控制器"**(宏观层直接持有目标仓位、每分钟收敛)——绕开命令系统另立产生买卖的路径,与命令冲突时没有仲裁者,正是禁区。
|
||||
2. **不把宏观观点塞进规则闸**——规则闸是合规终检,塞行情观点会让"没通过合规"和"宏观不看好"在评审账本里分不开;宏观闸放在**扫描入口**(proposal_service),与"组合刹车""休假模式"同一落点、同一手势。
|
||||
## 三、总共做哪几件事
|
||||
|
||||
走命令通道的三个直接好处:宏观动作在**命令台可见、可撤销、可改窗口**(命令至上铁律天然成立);与用户命令的冲突由现有 `detect_conflicts` 仲裁;执行成本统计、进度日结、判分全部免费获得。
|
||||
一共七件事,全部在持仓系统 tradingSystem 里做。其他四个系统一个字不动。
|
||||
|
||||
---
|
||||
1. 每个交易日九点三十五分算一次指数,结果存进一张新表,页面能看历史。
|
||||
2. 指数进入偏冷区时,当天就自动下加仓命令,或者先出建议等你点头。
|
||||
3. 指数进入偏热区时,当天先停掉系统的自动买入。停的范围包括自主提议的四类买入动作,也包括个股策略的买入开仓腿。
|
||||
4. 指数从偏热极值回落、共振破裂时,自动下降仓命令,或者先出建议。
|
||||
5. 管理页面加一块宏观择时面板。指数走势、所处区域、当日建议、闸的状态都在上面。
|
||||
6. 所有阈值和开关登记成运行参数,页面可调,默认整层关闭。
|
||||
7. 配一批单元测试,按步骤上线。每一步都有能亲手运行的验证命令。
|
||||
|
||||
## 3. 总体架构与六条设计原则
|
||||
## 四、关键规则,每条讲清做什么和为什么
|
||||
|
||||
```
|
||||
每交易日 09:35 (beat: macro_scan) 页面「宏观择时」面板
|
||||
│ │ 手动扫描 / 一键采纳
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ macro_service.scan() │
|
||||
│ 取数(macro_repo, 三张源表单表查) → 计算(macro_rules 纯函数) │
|
||||
│ → 落 pms_macro_signal (当日一行, 重扫就地更新) │
|
||||
│ → 决策(macro_rules.decide: 区制/确认/冷却/对数映射/上下限) │
|
||||
│ → 分流: │
|
||||
│ autonomy=full → command_service.issue( │
|
||||
│ REDUCE/INCREASE_EXPOSURE, │
|
||||
│ issued_by="macro") │
|
||||
│ autonomy=propose_only → 当日建议落表, 面板等用户点头 │
|
||||
│ autonomy=off / ENABLED=False / 信号UNAVAILABLE → 不动 │
|
||||
│ → 每一次动作与每一次"想动被拦"落 pms_action_ledger │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
│ (命令一旦下达, 与用户手下的命令零区别) │ 宏观闸状态 (偏热确认中?)
|
||||
▼ ▼
|
||||
command_poll → planner → executor proposal_service 扫描入口:
|
||||
→ 规则闸 → 择时 → 下发 → 对账 偏热确认 → 增持类扫描暂停(留痕)
|
||||
**总开关和三个档位。** 有一个总开关和三个档位。第一档只算不动。第二档只出建议,你在页面点头才变成命令。第三档全自动下命令。当前是模拟仓,用户已拍板不设观察期,所以部署默认就是总开关打开加全自动档。任何时候你都能在页面收权降档。将来转实盘之前,这两个默认值要重新评估一次。
|
||||
|
||||
**偏冷时进区当天就动。** 指数低过负二十,当天就触发加仓。为什么不等确认:标定数据显示,冷区的反弹就发生在极值当口。进区当天动,之后五天大盘平均涨百分之二点一五,胜率九成二。多等一天,或者等指数回升再动,收益都明显变差。
|
||||
|
||||
**偏热时先停买,回落才卖。** 指数高过正二十五,当天只做一件事,就是停掉系统的自动买入。降仓命令要等指数从极值回落、跌破正十五才下。为什么不进区就卖:标定数据显示,这段样本里极热之后大盘平均还在涨。进区就卖等于和趋势作对,二十天要付出接近百分之三的机会成本。等共振破裂再卖,机会成本降到百分之一。停买这件事不用等,因为不买入没有机会成本,是便宜的保险。
|
||||
|
||||
**动多少,按对数曲线定。** 极值越深,调整越多,但加码越来越慢。这样第一步就有分量,又不会因为一个罕见的极端读数把仓位一次打空。降仓的幅度按本轮热度的峰值深度算,热得越狠,回落时降得越多。参数已按五年历史标定:常见深度对应大约五个点仓位,罕见的深极值对应大约十二个点,单个周期封顶二十个点。
|
||||
|
||||
**四道防抖。** 同一个极值周期里,指数不再创新深度就不再加码。同方向两次动作至少间隔三个交易日。每个信号每个交易日最多动一次。上一条宏观命令还没执行完时,不叠加新命令。
|
||||
|
||||
**两条底线。** 自动降仓最低降到总仓位百分之二十,再往下必须你亲自下命令。自动加仓不会突破你设定的总仓上限。
|
||||
|
||||
**永远给用户和安全让路。** 和你在途的命令方向冲突时,这一轮就放弃,记一笔账,绝不强行执行。休假模式下整层不跑。你手动暂停买入期间,不自动加仓。组合刹车期间,不自动加仓。个股策略的卖出和平仓回补不受闸影响,做T的买回腿也不会被拦。
|
||||
|
||||
**数据拿不到就什么都不做。** 任何一张源表取不到、样本不足或者数据过期,指数记为不可用。不可用时不下命令,也不落闸,页面亮黄条提醒。宁可不动,不猜方向。
|
||||
|
||||
**每一步都留痕。** 指数每天一行存进新表。每次动作,和每次想动但被拦,都写进评审账本。宏观层下的命令在署名栏写 macro,和你下的命令一眼就能分开。
|
||||
|
||||
## 五、为什么不会破坏现有系统
|
||||
|
||||
宏观层动仓位只有一个出口,就是调用现有的命令下达函数(代码名 command_service.issue)。它不直接写方案表、指令表和持仓账本,也不碰执行器。
|
||||
|
||||
需要修改的现有文件一共十二处。每一处都只增加内容,不改动已有行为。其中两处是测试哨兵:项目在测试里钉了"表的总数"和"调度位的集合"两枚哨兵,加表和加调度位时按哨兵自己的注释指引各加一行。新增文件五个。把新增文件全部删掉,系统就回到今天的样子。决策系统、选股桥、择时层、行情侧,零改动。
|
||||
|
||||
总开关随时可以在页面一键关闭,关闭后系统行为立刻回到没有这一层的样子。当前是模拟仓,部署默认开启全自动档。转实盘前把默认改回关闭加只出建议,改的是两个参数初值,不用动代码结构。
|
||||
|
||||
修改点的完整清单在附录四,可不读。
|
||||
|
||||
## 六、上线步骤和验证
|
||||
|
||||
下面所有命令都在桥机 factorevaluation 上运行。每一步都写了预期读数。读数不对就停下来,不进入下一步。
|
||||
|
||||
**第一步,标定。已完成。** 2026 年 8 月 18 日已在桥机实跑。三张表的位置和列名已确认,参数初值已按报告回填本方案。报告在仓库根目录的 MACRO_CALIB_2026-08-18.md。标定脚本保留在仓库里,供以后每月重跑校准。
|
||||
|
||||
**第二步,写代码和单元测试。** 代码写完后跑全量单测。
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
六条设计原则(每条都能在代码评审时逐一核对):
|
||||
预期最后一行是 ALL SUITES PASS。清单里应出现新批次 test_batch14_units.py,并且没有任何一条 SUITE MISSING。
|
||||
|
||||
1. **默认关**:`PMS_MACRO_ENABLED=False`。部署完成后系统行为与今天完全一致,开关和档位都在页面上,随时收权。
|
||||
2. **唯一出口**:宏观层产生仓位变化的途径只有两条——`command_service.issue()` 下命令、扫描入口的宏观闸(只拦不发)。不直接写 `pms_plan` / `pms_instruction` / `pms_position`,不调 executor,不碰账本。
|
||||
3. **故障即守成**:任一数据源取不到、样本不足、日龄超限 → 信号置 `UNAVAILABLE`,**不动作、不落闸**(拿不到 ≠ 偏热,闸的安全方向是"不额外拦"——增持本身另有规则闸把关),面板亮黄条。挂在调度器 `guard` 下,异常吞掉只记 ERROR。
|
||||
4. **命令至上**:与用户在途命令冲突(issue 返回 CONFLICT)→ 本轮放弃并留痕,**绝不 force**;`HALT_ALL`(休假)由调度守卫直接跳过;`PMS_GLOBAL_BUY_HALT` 生效时不发升仓;组合刹车期间默认不发升仓(可参数放开)。**用户命令永不受宏观闸限制**,且随时可在命令台撤销宏观命令。
|
||||
5. **限频防抖**:进区确认天数 + 迟滞退出带 + 同方向冷却 + 每信号每交易日至多一次动作 + 对数映射的"目标追踪"(§5.1,同一深度不重复加码)+ 上一条宏观命令在途不叠加。指数在极值区横跳不会造成反复买卖。
|
||||
6. **全留痕**:信号值每日入 `pms_macro_signal`(含中间量,可复算);每次触发/被拦/落闸入 `pms_action_ledger`(action=MACRO,hard_numbers 带当时指数值、区制、超额深度、当前仓位、本次步长);命令表 `issued_by='macro'` 一眼可辨。
|
||||
**第三步,收盘后部署和建表。** 改动打进镜像,所以必须重建容器。
|
||||
|
||||
```bash
|
||||
make deploy
|
||||
make check
|
||||
```
|
||||
|
||||
预期 make check 的表清单里比部署前多出一张 pms_macro_signal。此时打开页面的参数设置,应出现一批 PMS_MACRO 开头的参数,总开关显示开启,档位显示全自动。因为是收盘后部署,当天不会再有扫描,首次真实运行在次日九点三十五分。
|
||||
|
||||
**第四步,试算一次。** 试算只计算不落表,也不下命令。
|
||||
|
||||
```bash
|
||||
curl -s -X POST 'http://127.0.0.1:38100/api/ops/macro-scan?dry_run=true' | python3 -m json.tool --no-ensure-ascii
|
||||
```
|
||||
|
||||
预期返回里能看到指数当前值和所处区域。拿它和标定脚本同一天算出的值对照,两者应当一致。
|
||||
|
||||
**第五步,全自动上线后的头几个交易日,看四样东西。** 用户已拍板:现在是模拟仓,不设观察期,部署即全自动。所以这一步不是等待期,是上线后的日常核对清单。第一,每个交易日新表里多一行指数记录,面板的值和区域正常刷新。第二,指数进入偏冷区的日子,命令台自动出现署名 macro 的加仓命令,并正常展开成方案和指令。第三,指数进入偏热区的日子,自主买入和策略买开腿的跳过记录里出现宏观闸的字样;等指数回落穿出退出带,命令台自动出现降仓命令。第四,每次宏观动作在评审账本里都有对应的一行。
|
||||
|
||||
**第六步,冲突演练。** 先手动下一条加仓命令。趁它还在执行中,手动触发一次宏观扫描。
|
||||
|
||||
```bash
|
||||
curl -s -X POST 'http://127.0.0.1:38100/api/ops/macro-scan' | python3 -m json.tool --no-ensure-ascii
|
||||
```
|
||||
|
||||
预期评审账本里出现一条宏观让路的拒绝记录,命令台没有新增宏观命令。演练完撤掉手下的那条加仓命令。
|
||||
|
||||
**第七步,首条自动命令的判收。** 等到第一次真实触发,确认这条命令走完从下达、方案、指令、择时出手到进度回执的全程。模拟仓阶段允许它自然发生,不需要人为催熟。
|
||||
|
||||
**回滚方法。** 任何时候在页面把总开关改回关,整层立即停止。命令、闸、建议全停,系统回到现状,不需要重新部署。
|
||||
|
||||
## 七、已拍板的决定记录
|
||||
|
||||
以下六条是 2026 年 8 月 18 日与用户逐条确认的结果。写在这里,将来查依据不用翻对话。
|
||||
|
||||
1. 仓位调整的形态用对数映射。不用固定步长,也不用一步调到位。
|
||||
2. 偏热侧等极值回落再降仓。依据是标定里这种做法机会成本最低。
|
||||
3. 偏冷侧进区当天就加仓,进入阈值取负二十。依据是标定里这一档样本更多,事后反弹也更强。
|
||||
4. 个股层面也受宏观控制。范围是自主提议的四类买入动作,加上个股策略的买入开仓腿。策略的卖出和平仓回补不受影响。
|
||||
5. 自动加仓让位于组合刹车。这是全方案唯一未经你直接确认的假设项,参数随时可以放开。
|
||||
6. 三张数据表都在 153 代理,已实测钉死。
|
||||
7. 当前是模拟仓,不设观察期。部署默认总开关开启、档位全自动。转实盘前重新评估这两个默认值。
|
||||
|
||||
## 八、风险和限制,先说在前面
|
||||
|
||||
这个指数是慢变量。它抓的是阶段性的冷热,不是每天的涨跌。牛市里它会显得慢半拍,这是设计使然,不是故障。
|
||||
|
||||
偏热降仓在标定样本里是保险,不是赚钱工具。样本覆盖 2021 年到 2026 年,尾段是大牛市,极热之后大盘平均还在涨。这套逻辑的价值要等下一轮真正的顶部才能兑现。
|
||||
|
||||
标定样本很小。极值一年只出现几次,统计上不显著。冷区的高胜率里有一部分水分,因为牛市里逢跌买入本来就容易对。汇率数据从 2021 年 6 月才开始,没有覆盖过深熊。
|
||||
|
||||
所以月度复盘是方案的一部分,不是可选项。数据每多一段,就用标定脚本重跑一次,参数跟着校准。模拟仓不设观察期是用户的拍板,正好让真实触发来检验参数,代价由模拟资金承担。
|
||||
|
||||
---
|
||||
|
||||
## 4. 信号层设计
|
||||
# 附录(以下全部可不读,供实现和排查时查阅)
|
||||
|
||||
### 4.1 计算口径(按定义逐条落地)
|
||||
## 附录一、指标计算公式与数据列名
|
||||
|
||||
数据统一为按国内交易日对齐的日频序列,全部用**收盘值**:
|
||||
计算分四步。第一步算二十日对数收益:股的一边用上证指数收盘价,汇的一边用美元兑离岸人民币,汇率上涨代表人民币贬值。汇率的日期先加一个自然日,再对齐到国内交易日历,当天没有值就沿用最近的前值。第二步把两个收益相加,得到共振值(代码名 spread)。第三步减去利率修正,修正量等于 0.02 乘以一个月期拆借利率的二十日变化(代码名 spread_adj)。第四步对修正后的值做四十日滚动标准化,再乘以十,得到股汇对冲指数(代码名 hedge_index)。
|
||||
|
||||
```
|
||||
① stock_ret = ln(close_t / close_{t-20}) # zs_day_data, symbol='000001.SH'
|
||||
fx_ret = ln(fx_t / fx_{t-20}) # gp_fx_daily USD/CNH, 正值=人民币贬值
|
||||
(fx 的日期先 +1 自然日, 再对齐到国内交易日: 当日无值取最近前值, 即 as-of 对齐)
|
||||
② spread = stock_ret + fx_ret
|
||||
③ spread_adj = spread − β × (SHIBOR1M_t − SHIBOR1M_{t-20}) # β 默认 0.02
|
||||
④ hedge_index = (spread_adj − mean40(spread_adj)) / std40(spread_adj) × 10
|
||||
stock_ret = ln(close_t / close_{t-20})
|
||||
fx_ret = ln(fx_t / fx_{t-20})
|
||||
spread = stock_ret + fx_ret
|
||||
spread_adj = spread - 0.02 * (shibor1m_t - shibor1m_{t-20})
|
||||
hedge_index = (spread_adj - mean40) / std40 * 10 # std 用样本标准差
|
||||
```
|
||||
|
||||
硬性口径,写进单测:窗口内任一序列缺口用 as-of 前值补齐但**记录补齐天数**;有效样本 < 20+40+5 个交易日 → `UNAVAILABLE`;`std40 = 0` → `UNAVAILABLE`;三源中最新日期落后当前交易日超过 `PMS_MACRO_STALE_TDAYS`(默认 3)个交易日 → `UNAVAILABLE`。
|
||||
| 表 | 数据源 | 日期列 | 取值列 | 过滤条件 | 备注 |
|
||||
|---|---|---|---|---|---|
|
||||
| zs_day_data | proxy(153) | timestamp | close | symbol='000001.SH' | 盘中最新行是前一交易日,与九点三十五分用前一日数据的设计一致 |
|
||||
| gp_fx_daily | proxy(153) | trade_date | bid_close | ts_code='USDCNH.FXCM' | 整表单一品种,历史从 2021 年 6 月起 |
|
||||
| gp_shibor | proxy(153) | trade_date | rate_1m | 无,宽表 | 单位是百分数,例如 1.4165 |
|
||||
|
||||
区制(zone)判定带迟滞,防横跳(阈值初值 ±25/±15,**以标定结果为准回填**):
|
||||
数据守卫有三条。有效样本少于六十五个交易日,判不可用。任何一个数据源的最新日期落后当前交易日超过三个交易日,判不可用(天数参数是 PMS_MACRO_STALE_TDAYS)。标准差为零,判不可用。序列缺口用前值补齐,补齐的天数记进当日明细。
|
||||
|
||||
| zone | 进入条件 | 退出条件 |
|
||||
## 附录二、区域判定与决策流程的精确定义
|
||||
|
||||
区域(代码名 zone)四种取值。偏热(HOT):指数大于进入阈值(PMS_MACRO_HOT_TH,初值 25),回落到退出带(PMS_MACRO_EXIT_BAND,初值 15)以内算离区。偏冷(COLD):指数小于进入阈值(PMS_MACRO_COLD_TH,初值 -20),回升到负退出带以内算离区。其余为中性(NEUTRAL)。数据守卫未过为不可用(UNAVAILABLE)。进出阈值不同,是为了防止指数在阈值附近来回穿越造成反复触发。
|
||||
|
||||
对数映射:超额深度 e 等于指数绝对值减去进入阈值。周期累计目标 target(e) = min(S0 * ln(1 + e / k), SHIFT_MAX),标定回填 S0=0.049,k=1.6,SHIFT_MAX=0.20。偏冷侧(触发口径 zone_enter)按步进追踪:本次步长等于 target(当前深度) 减去周期内已执行量,确认当日首步保底 STEP_MIN(0.02);只有指数更深才有新步。偏热侧(触发口径 zone_exit)整个周期只动一次:回落穿出退出带时触发,步长取 target(周期峰值深度),同样受 STEP_MIN 和 SHIFT_MAX 约束。周期结束后累计量和峰值深度清零。
|
||||
|
||||
冷却与去重的规则如下。同方向两次生效动作至少间隔三个交易日(参数 PMS_MACRO_COOLDOWN_TDAYS)。生效的定义是所发命令没有被立刻取消。因为排不出方案而被取消的命令不占冷却,次日可以重试。每个信号每个交易日至多动一次。存在署名 macro 的在途命令时不叠加新命令。宏观降仓不把总仓位降到百分之二十以下(参数 PMS_MACRO_MIN_PCT),用户自己的命令不受此限。
|
||||
|
||||
加仓方向的额外让路:全局暂停买入开关(PMS_GLOBAL_BUY_HALT)生效时不发;组合刹车生效且 PMS_MACRO_RESPECT_BRAKE 为真时不发。这两个状态在参数表而不在命令表,命令冲突检测看不见它们,引擎必须自查。
|
||||
|
||||
调度时点定在九点三十五分而不是盘前,原因是一个已核实的机制:盘前取不到实时价时,持仓快照会用成本价顶替并打上价格不可靠标记,方案生成器会排除这类票。盘前下的降仓命令会在一分钟内被排成零方案并取消。九点三十五分行情已就位,也在八点四十分拉取候选池之后,加仓有票可选。指数用的是前一交易日的数据,几点计算数值都一样。
|
||||
|
||||
建议(第二档产出)的承载:写在当日 pms_macro_signal 行里,当日有效,隔日自动作废。采纳走独立接口,采纳前重查一遍冲突和开关,采纳后署名记为用户。特意不复用个股提议表 pms_proposal,因为它的采纳语义是生成单只股票的指令,装不下组合级动作。
|
||||
|
||||
宏观闸的时点与降仓命令刻意不同步:闸在进入偏热区当天就生效,回落穿出退出带解除;降仓命令回落时才下。时间线是先停增量,热度破裂后再减存量。闸的实现落点有三处,全部与既有检查并排:自主提议扫描入口一处,自主建仓扫描入口一处,个股策略运行器的买入开腿判定点一处(与既有的买入暂停集合同一个判定位置,天然继承只挡开腿不挡平仓的语义)。闸不写任何暂停标记,闸解除后下一轮自动恢复,不影响你手动设置的暂停记录。闸读不到宏观状态时不拦,因为买入本身另有规则闸把关,不能让宏观层故障把自主引擎整体摁死。
|
||||
|
||||
多信号扩展:宏观服务内有一张信号注册表,V1 只有股汇对冲指数一项。将来接入决策系统的宏观水温或者择时层的日线指标,只需要新增注册表项和该信号自己的参数,参数命名空间约定为 PMS_MACRO_信号名_参数名。多信号合成规则 V1 定死为最保守者优先:任一信号偏热则整体偏热,无偏热但有偏冷则整体偏冷。
|
||||
|
||||
## 附录三、参数清单
|
||||
|
||||
全部登记进运行参数中心,页面可调,五秒生效。总开关的文件初值现在是开(模拟仓拍板),所以要把它加进参数中心的安全名单(代码名 FAIL_CLOSED):参数表读不到的时候按关闭处理,宁可少动一轮,不在基础设施故障期间自动下命令。附带说明:参数表和三张数据源表在同一个数据库,真断了的时候取数环节也会先判不可用,这条名单是第二道保险。
|
||||
|
||||
| 参数 | 初值 | 说明 |
|
||||
|---|---|---|
|
||||
| HOT(偏热,即 positive 区) | hedge_index > `PMS_MACRO_HOT_TH` | 回落到 < `PMS_MACRO_EXIT_BAND` |
|
||||
| COLD(偏冷,negative 区) | hedge_index < `PMS_MACRO_COLD_TH` | 回升到 > −`PMS_MACRO_EXIT_BAND` |
|
||||
| NEUTRAL | 其余 | — |
|
||||
| UNAVAILABLE | 数据守卫未过 | 数据恢复 |
|
||||
| PMS_MACRO_ENABLED | True | 宏观择时总开关。关闭时调度位空转,不取数不计算。模拟仓拍板默认开,转实盘前改回 False |
|
||||
| PMS_MACRO_AUTONOMY | full | 档位:off 只算不动,propose_only 出建议,full 自动下命令。模拟仓拍板默认 full,转实盘前改回 propose_only |
|
||||
| PMS_MACRO_SIGNALS | hedge_fx | 启用的信号清单,逗号分隔 |
|
||||
| PMS_MACRO_STOCK_GATE | True | 个股宏观闸。偏热确认期间暂停自主买入与策略买入开腿 |
|
||||
| PMS_MACRO_HOT_TH | 25.0 | 偏热进入阈值。标定约每年 1.4 段,平均持续 5.6 天 |
|
||||
| PMS_MACRO_COLD_TH | -20.0 | 偏冷进入阈值。标定约每年 4 段,用户确认取 -20 |
|
||||
| PMS_MACRO_EXIT_BAND | 15.0 | 迟滞退出带。偏热侧它同时是降仓命令的触发线 |
|
||||
| PMS_MACRO_CONFIRM_DAYS | 1 | 偏冷进区连续天数要求。标定显示等第二天反而更差 |
|
||||
| PMS_MACRO_TRIGGER_HOT | zone_exit | 偏热触发口径,回落再动。可切 zone_enter |
|
||||
| PMS_MACRO_TRIGGER_COLD | zone_enter | 偏冷触发口径,进区即动。可切 zone_exit |
|
||||
| PMS_MACRO_LOG_S0 | 0.049 | 对数映射系数,标定回填 |
|
||||
| PMS_MACRO_LOG_K | 1.6 | 对数映射尺度,标定回填 |
|
||||
| PMS_MACRO_SHIFT_MAX | 0.20 | 单个极值周期累计调整封顶,占规模的百分点 |
|
||||
| PMS_MACRO_STEP_MIN | 0.02 | 裁剪后步长小于此不动,也是确认当日的首步保底 |
|
||||
| PMS_MACRO_COOLDOWN_TDAYS | 3 | 同方向两次动作的最小间隔,交易日 |
|
||||
| PMS_MACRO_MIN_PCT | 0.20 | 宏观降仓地板。用户命令不受此限 |
|
||||
| PMS_MACRO_MAX_PCT | 0.0 | 宏观加仓天花板。零表示不单设,由总仓上限兜底 |
|
||||
| PMS_MACRO_WINDOW_TDAYS | 3 | 宏观命令的执行窗口 |
|
||||
| PMS_MACRO_RESPECT_BRAKE | True | 组合刹车期间不自动加仓 |
|
||||
| PMS_MACRO_STALE_TDAYS | 3 | 数据日龄超此判不可用 |
|
||||
| PMS_MACRO_RET_WIN | 20 | 收益计算窗口 |
|
||||
| PMS_MACRO_Z_WIN | 40 | 标准化窗口 |
|
||||
| PMS_MACRO_SHIBOR_BETA | 0.02 | 利率修正系数 |
|
||||
|
||||
### 4.2 数据访问(严格单表,三条独立查询)
|
||||
## 附录四、新增与触碰文件清单
|
||||
|
||||
新增 `app/repo/macro_repo.py`。**用户口径:`gp_fx_daily` / `gp_shibor` 在 153 代理** → 走 `db.session.fetch_all(..., source="proxy")`;`zs_day_data` 已证实在 199(`source="index"`),若标定脚本探出 153 也可达则统一走 proxy(少一个依赖库)。每张表一条单表 SQL(`WHERE ... ORDER BY 日期 DESC LIMIT 120` 量级),完全符合单表守卫。**列名以标定脚本探查结果为准回填此处,回填前不写 macro_repo**:
|
||||
新增文件五个,互相独立,全部删除即回到现状。
|
||||
|
||||
| 表 | 源 | 日期列 | 取值列 | 过滤条件 |
|
||||
|---|---|---|---|---|
|
||||
| zs_day_data | index(153 待探) | timestamp | close | symbol='000001.SH' |
|
||||
| gp_fx_daily | proxy(153) | 待探 | 待探 | 待探(USD/CNH 筛选) |
|
||||
| gp_shibor | proxy(153) | 待探 | 待探(1M) | 待探 |
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| app/core/macro_rules.py | 纯逻辑:对齐与四步计算、区域迟滞判定、对数映射决策。无外部依赖,可单测 |
|
||||
| app/repo/macro_repo.py | 三张源表的单表取数,加新表 pms_macro_signal 的读写 |
|
||||
| app/services/macro_service.py | 编排:守卫、取数、计算、落表、决策、分流、留痕。对外提供状态、采纳、闸状态三个函数 |
|
||||
| scripts/calibrate_macro_signal.py | 标定脚本,已交付并实跑。保留供月度复盘 |
|
||||
| scripts/test_batch14_units.py | 单测约二十五例,内容见附录六 |
|
||||
|
||||
`pms_macro_signal` 的读写走 `source="proxy"`(与其余 pms_* 表同侧)。
|
||||
触碰的既有文件十二处,全部只加不改(末两行是测试哨兵,按哨兵注释的指引各加一行)。
|
||||
|
||||
### 4.3 信号注册表(多信号扩展点)
|
||||
| 文件 | 加什么 |
|
||||
|---|---|
|
||||
| config/settings.py | 一段 PMS_MACRO 开头的参数字段 |
|
||||
| app/services/param_store.py | 参数说明、取值范围、合法值校验各加宏观项 |
|
||||
| app/scheduler.py | 一个新调度任务,加调度表一行 |
|
||||
| app/services/proposal_service.py | 两处扫描入口各加一个宏观闸检查,与既有的休假检查并排 |
|
||||
| app/services/strategy_runner.py | 买入开腿判定点加一个宏观闸并列条件,与既有买入暂停集合同点 |
|
||||
| app/web/main.py | 三个新接口:状态查询、手动扫描、建议采纳 |
|
||||
| app/web/static/index.html | 一块宏观择时面板 |
|
||||
| ddl_pms_v1.sql | 第十八张表的建表语句 |
|
||||
| scripts/check_db.py | 自检表清单加 pms_macro_signal(顺带发现 pms_strategy 和 pms_op_log 也不在清单里,属既有欠账,按不夹带纪律单独提) |
|
||||
| scripts/run_tests.py | 登记新测试批次 |
|
||||
| scripts/test_batch6_units.py | DDL 表数哨兵 17 改 18(哨兵注释明言"加表就要来这里加一") |
|
||||
| scripts/test_wiring.py | 调度表哨兵集合加 macro_scan |
|
||||
|
||||
`app/services/macro_service.py` 内一张注册表,V1 只有一项:
|
||||
三条铁律的对照。命令至上:宏观动作本身就是命令,冲突让位用户,可撤可改,闸只拦系统自主动作。分工不越权:宏观层只回答整体该多重和增量该不该停,逐股怎么买卖仍由方案生成器、择时和规则闸决定,其他系统零改动。先记账后动作加故障即守成:信号先落表再决策,命令走先落表的既有路径,任何取数计算失败都不产生新指令也不落闸。
|
||||
|
||||
```python
|
||||
SIGNALS = {
|
||||
"hedge_fx": { # 股汇对冲指数
|
||||
"label": "股汇对冲指数",
|
||||
"fetch": macro_repo.fetch_hedge_inputs, # -> 原始序列
|
||||
"compute": macro_rules.compute_hedge_index, # -> {value, zone, detail}
|
||||
},
|
||||
# 未来: "bionic_regime" (决策系统宏观水温) / "index_qrs" (择时层日QRS) ——
|
||||
# 只加表项与各自参数(命名空间 PMS_MACRO_<KEY大写>_*), 决策层/执行层/闸/页面零改动。
|
||||
}
|
||||
```
|
||||
|
||||
启用哪些由 `PMS_MACRO_SIGNALS`(逗号分隔,默认 `hedge_fx`)控制。多信号合成 V1 定死**最保守者优先**:任一启用信号 HOT → 整体偏热;无 HOT 而有 COLD → 偏冷;否则中性。加权合成属二期,不做预设计。
|
||||
|
||||
### 4.4 新表 `pms_macro_signal`(第 18 张表)
|
||||
## 附录五、新表结构
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS pms_macro_signal (
|
||||
|
|
@ -148,7 +247,7 @@ CREATE TABLE IF NOT EXISTS pms_macro_signal (
|
|||
trade_date INT NOT NULL COMMENT '北京交易日 YYYYMMDD',
|
||||
value DECIMAL(12,4) COMMENT 'hedge_index 值',
|
||||
zone VARCHAR(16) NOT NULL DEFAULT 'NEUTRAL' COMMENT 'HOT/COLD/NEUTRAL/UNAVAILABLE',
|
||||
detail_json TEXT COMMENT '中间量: stock_ret/fx_ret/spread/spread_adj/超额深度/各源末日/补齐天数/周期已执行调整',
|
||||
detail_json TEXT COMMENT '中间量: 收益/共振值/超额深度/各源末日/补齐天数/周期已执行量',
|
||||
action VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT 'NONE/ADVICE_REDUCE/ADVICE_INCREASE/CMD_ISSUED/BLOCKED/GATED',
|
||||
ref_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联 command_id (下达或采纳后回填)',
|
||||
note VARCHAR(500) NOT NULL DEFAULT '' COMMENT '被拦原因/建议文案/闸状态',
|
||||
|
|
@ -157,226 +256,22 @@ CREATE TABLE IF NOT EXISTS pms_macro_signal (
|
|||
) COMMENT='宏观信号日快照与动作记录 (每信号每交易日一行, 重扫就地更新)';
|
||||
```
|
||||
|
||||
一行四用:历史序列(面板画近 20 日)、当日建议的承载(propose 档)、冷却与"周期已执行调整"的事实源、宏观闸的状态源(扫描层 5 秒缓存读当日行)。
|
||||
这一行数据有四个用途:历史序列供面板画图;第二档的建议就写在当日行里;冷却和周期已执行量从历史行回查,不另设状态存储;宏观闸的当日状态也从这行读,进程内缓存五秒。
|
||||
|
||||
---
|
||||
## 附录六、接口、面板与单测清单
|
||||
|
||||
## 5. 决策层设计
|
||||
新增接口三个。查询状态:GET /api/macro/status,返回各信号的当前值、区域、近二十日序列、数据日龄、当日建议、闸状态、最近宏观命令进度和冷却剩余。手动扫描:POST /api/ops/macro-scan,带 dry_run=true 时只算不落表不下达。建议采纳:POST /api/macro/adopt,采纳前重查冲突和开关,采纳幂等,隔日作废。
|
||||
|
||||
### 5.1 组合仓位:对数映射(V2 改,替代固定步长)
|
||||
|
||||
极值越深,调整越多,但**边际递减**——既回应"越极端越该动",又天然防止 Z-Score 尾部把仓位一次打爆。纯函数 `macro_rules.decide(...)`,规则按优先级:
|
||||
|
||||
1. `PMS_MACRO_ENABLED=False` 或档位 off 或 zone ∈ {NEUTRAL, UNAVAILABLE} → NONE。
|
||||
2. 确认:连续处于同一极值区 ≥ `PMS_MACRO_CONFIRM_DAYS`;触发口径 `PMS_MACRO_TRIGGER_MODE`(zone_enter / zone_exit,**默认值由标定结果定**,两种都实现、页面可切)。
|
||||
3. **对数映射(核心)**:
|
||||
- 超额深度 `e = |hedge_index| − 进入阈值`(e ≥ 0,单位=指数点);
|
||||
- 本轮极值周期的**累计目标调整** `target(e) = min( S0 × ln(1 + e / k), PMS_MACRO_SHIFT_MAX )`(占规模的百分点);
|
||||
- 本次步长 `step = target(e_now) − done_shift`(done_shift = 周期内已执行的宏观调整,记在当日信号行 detail 里);`step < PMS_MACRO_STEP_MIN`(默认 0.02)→ 不动;
|
||||
- **周期**定义:进区 → 迟滞退出;退出后 done_shift 清零,下个周期重新累计。同一深度不重复加码,指数横盘在区内不会连环下命令;指数**更深**才有新步;
|
||||
- `S0` / `k` / `SHIFT_MAX` 初值由标定脚本给建议(目标形状:e 中位数 → 约 5 个点,e 95 分位 → 约 12 个点,封顶 20 个点),页面可调。
|
||||
4. 冷却:同方向上一次**生效**动作距今 < `PMS_MACRO_COOLDOWN_TDAYS`(默认 3 个交易日)→ 本轮不动(下轮 target 追踪仍在,冷却只是把步子隔开)。「生效」= 所发命令未被立刻 CANCELLED(零方案的升仓命令不占冷却,次日重试)。
|
||||
5. 在途:上一条宏观命令仍在 EXECUTING/PARTIAL → 不叠加。
|
||||
6. 方向裁剪:
|
||||
- HOT → 降仓,`step` 再受地板裁剪:不把总仓位降到 `PMS_MACRO_MIN_PCT`(默认 0.20)以下(用户命令不受此限);
|
||||
- COLD → 升仓,`step` 受 `PMS_MACRO_MAX_PCT`(默认 0=由 `PMS_PORTFOLIO_CAP` 兜底)裁剪;
|
||||
- 升仓独有护栏:`PMS_GLOBAL_BUY_HALT` 生效不发;组合刹车生效且 `PMS_MACRO_RESPECT_BRAKE=True`(默认,用户未反对,标注为**假设**)不发。降仓不受这两条限制(与"冻结与刹车只挡增持不挡减持"同口径)。
|
||||
|
||||
### 5.2 个股宏观闸(V2 新增,用户拍板"个股交易动作受宏观信号控制")
|
||||
|
||||
**语义**:偏热(HOT)确认期间,PMS 自己发起的**增持类**个股动作暂停——自主新建仓(OPEN)、回踩补足(FILL)、盈利加仓(ADD)、补仓(DCA)的扫描整体跳过并留痕;**减持类动作(TRIM/风控卖出/清仓)与用户命令永不受限**。COLD 期间不加额外限制(自主动作照常,加量由升仓命令负责)。这与"组合刹车"的既有语义完全同型("自主增持停,命令类不受限"),实现手势照抄。
|
||||
|
||||
**为什么必须有**:不加闸的话存在一条自我打架的路径——宏观降仓卖出 → 现金变多 → `PMS_OPEN_AUTONOMY=full` 的自主新建仓下一分钟把钱又买回去。闸把增量管住,命令管存量,才闭环。
|
||||
|
||||
**实现落点(两处,均为纯增量,与既有 `exec_halt` / 刹车检查并排)**:
|
||||
- `proposal_service.scan_and_route` 入口:宏观闸生效 → 四类已有持仓动作中**买入侧**候选不扫(TRIM 照常),skipped 留痕"宏观偏热闸";
|
||||
- `proposal_service._scan_open` 入口:宏观闸生效 → 本轮不建仓,skipped 留痕。
|
||||
|
||||
闸状态由 `macro_service.gate_state()` 提供(读当日 `pms_macro_signal`,进程内 5 秒缓存,**读不到 = 不落闸**——闸的安全方向是"不额外拦",增持自有规则闸把关,不能让宏观层故障把整个自主引擎摁死)。开关 `PMS_MACRO_STOCK_GATE`(默认 True,随 `PMS_MACRO_ENABLED` 总开关生效;即便档位是 propose_only,闸也生效——"停止买入"是保守方向动作,与"减持方向不设确认门槛"同哲学)。
|
||||
|
||||
**边界(待拍板 §12.3)**:个股交易方案(网格/做T/跟踪)的买入侧要不要一并受闸。V2 建议**先不动策略层**——策略是用户特意设的(signal_service 对挂策略票的处理哲学是"只提示不替你做"),宏观闸自动暂停它们容易造成困惑;若你要管,复用现成的 `strategy_service.pause_buy`(页面可恢复)一行接入即可。
|
||||
|
||||
---
|
||||
|
||||
## 6. 执行接入(兼容性核心节)
|
||||
|
||||
### 6.1 下达即普通命令
|
||||
命令下达的调用形态如下,供实现对照。
|
||||
|
||||
```python
|
||||
res = command_service.issue(
|
||||
"REDUCE_EXPOSURE", # 或 INCREASE_EXPOSURE
|
||||
{"pct": step, "window_tdays": param_store.get_int("PMS_MACRO_WINDOW_TDAYS", 3)},
|
||||
command_service.issue(
|
||||
"REDUCE_EXPOSURE",
|
||||
{"pct": step, "window_tdays": 3},
|
||||
issued_by="macro",
|
||||
note="宏观择时: hedge_fx=+31.2 偏热确认(阈值+25, 超额6.2), 周期目标降8.6点已降0点, 本次降8.6点")
|
||||
note="宏观择时: 股汇对冲指数回落穿出退出带, 峰值深度 6.2, 本次降 8.6 个点")
|
||||
```
|
||||
|
||||
- `issue()` 内部自动做冲突检测:返回 `ok=False` 且 errors 含 CONFLICT → 宏观层**留痕跳过**(ledger verdict=REJECT),当日不再重试。
|
||||
- 命令落表后与用户命令完全同路:`command_poll` 每分钟排方案、`intraday_exec` 择时出手、`refresh_progress` 日结进度。`shadow` 通道下只记账不真发(与现状一致,宏观层无需感知通道模式)。
|
||||
单测批次十四的覆盖清单:四步计算对照手算值;汇率加一日对齐和缺口沿用前值;样本不足和数据停更判不可用;区域迟滞的进出和横跳;对数映射的单调性、同深度不重复加码、周期清零、封顶;偏冷首步保底;偏热按峰值深度一次动作;确认天数、冷却、每日一次;零方案命令不占冷却;冲突让路、暂停买入让路、刹车让路(打桩);宏观闸拦自主买入不拦保垫减仓、拦策略买开腿不拦平回腿、读不到不拦、档位关不拦;建议采纳幂等与隔日作废。
|
||||
|
||||
### 6.2 调度时点:09:35,不是盘前
|
||||
|
||||
一个已核实的坑决定了时点:`positions_view` 在取不到实时价时用摊薄成本顶住价格并标 `price_ok=False`,而 `planner._usable` **排除** `price_ok=False` 的票(2026-07-31 静默失败专项第九条)。行情 db13 只有当日分钟线,盘前全部无价——盘前下达的 REDUCE 命令会在一分钟内被排成**零方案并置 CANCELLED**。所以 `macro_scan` 定在 **09:35**(开盘后行情已就位,也在 08:40 拉候选池之后,升仓有票可选)。信号用的是 T-1 日终数据,几点算值都一样。beat 表加一行:
|
||||
|
||||
```python
|
||||
"macro_scan": {"task": "pms.macro_scan", "schedule": crontab(hour=9, minute=35)},
|
||||
```
|
||||
|
||||
任务本体 `@guard(trade_day=True, respect_exec_halt=True)`——休假模式自动跳过,异常吞掉守成。宏观闸的状态在 09:35 扫描落表后当日恒定(信号是日频慢变量),盘中每一跳扫描层读的是同一行。另设手动端点(§7.3)供盘中任意时刻重扫/试算。
|
||||
|
||||
### 6.3 护栏对照表(每条对应代码里一处显式检查)
|
||||
|
||||
| 场景 | 行为 | 靠什么保证 |
|
||||
|---|---|---|
|
||||
| 休假模式 HALT_ALL | 整跳跳过 | 调度 guard `respect_exec_halt`;手动端点内 `macro_service` 自查一遍 |
|
||||
| 用户在途 REDUCE/INCREASE/LIQUIDATE 冲突 | 跳过 + 留痕,不 force | `issue()` 现有 `detect_conflicts`,宏观层永不传 `force_conflict=True` |
|
||||
| 全局暂停买入 | 升仓不发 | 引擎显式读 `PMS_GLOBAL_BUY_HALT`(此开关在参数表而非命令表,冲突检测看不见它,必须自查) |
|
||||
| 组合刹车 | 升仓默认不发 | 引擎读 `PMS_BRAKE_UNTIL` 比对当日 |
|
||||
| 数据缺失/停更 | 不动作、不落闸 + 面板黄条 | 信号 UNAVAILABLE 分支 |
|
||||
| 极值区横跳/横盘 | 不反复动 | 迟滞退出带 + 对数目标追踪(同深度不重复加码)+ 冷却 + 每日一次 |
|
||||
| 上一条宏观命令未走完 | 不叠加 | 查 `issued_by='macro'` 的在途命令 |
|
||||
| 参数表读不到 | 宏观层整体失效(安全方向) | `PMS_MACRO_ENABLED` 文件初值 False,表挂了退初值即关;闸读不到状态 = 不拦 |
|
||||
| 仓位已到地板/天花板 | 不动 + 留痕 | decide() 映射裁剪 |
|
||||
| 宏观闸误伤减持 | 不可能 | 闸只挡买入侧扫描,TRIM/EXIT/风控卖出路径不经过闸 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 档位、建议承载与页面
|
||||
|
||||
### 7.1 三档(沿用系统词汇)
|
||||
|
||||
`PMS_MACRO_AUTONOMY`: `off` / `propose_only`(**默认**)/ `full`。
|
||||
- off:只算信号、只落历史、只展示,不出建议不下命令(**闸也不生效**——off 是"只看不动"档)。
|
||||
- propose_only:触发时把建议写进当日 `pms_macro_signal` 行(action=ADVICE_*,note 带完整理由与建议 pct),面板出卡片等一键采纳;**当日有效**,隔日自动作废。**宏观闸照常生效**(保守方向自动,见 §5.2)。
|
||||
- full:直接 `issue(issued_by="macro")`。三条铁律下用户仍可在命令台撤。
|
||||
|
||||
不复用 `pms_proposal`:它的采纳路径是"生成该股指令",是个股级语义;硬塞组合级建议要改共用 decide 端点。宏观建议放自己的表行 + 自己的采纳端点,互不沾染。
|
||||
|
||||
### 7.2 采纳路径
|
||||
|
||||
`POST /api/macro/adopt {signal_key}` → 校验建议仍是当日且未采纳 → 重跑一遍 decide 的护栏(冲突/开关/地板可能在建议挂出后变化)→ `command_service.issue(..., issued_by="user")`(用户点的头就是用户意志)→ 回填 `ref_id=command_id, action=CMD_ISSUED`。幂等:同一行只能采纳一次。
|
||||
|
||||
### 7.3 API 与页面
|
||||
|
||||
- `GET /api/macro/status`:各信号 {value, zone, 超额深度, 近 20 日序列, 数据日龄, 当日建议, 闸状态, 最近宏观命令及进度, 冷却剩余, 周期已执行调整}。
|
||||
- `POST /api/ops/macro-scan?dry_run=true|false`:手动扫描;dry_run 只算不落表不下达。
|
||||
- `POST /api/macro/adopt`:见上。
|
||||
- 页面 `index.html` 加「宏观择时」面板:指数当前值 + 区制色带(热红/冷蓝/中性灰/不可用黄)+ 近 20 日迷你走势 + 当日建议卡(一键采纳/忽略)+ **宏观闸状态条**("偏热闸生效中:自主增持已暂停")+ 最近宏观命令进度。参数不用做界面——`PMS_MACRO_*` 登记后自动出现在「参数设置」。
|
||||
- Makefile 顺手加 `t-macro`(status + 试算),非必需。
|
||||
|
||||
---
|
||||
|
||||
## 8. 参数清单(settings.py 初值;全部经 param_store 页面可调;标 ⚙ 的初值由标定回填)
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| PMS_MACRO_ENABLED | False | 宏观择时总开关(关=调度位空转,不取数不计算,面板显示"未启用") |
|
||||
| PMS_MACRO_AUTONOMY | propose_only | off / propose_only / full |
|
||||
| PMS_MACRO_SIGNALS | hedge_fx | 启用的信号清单(逗号分隔) |
|
||||
| PMS_MACRO_STOCK_GATE | True | 个股宏观闸:偏热确认期间暂停自主增持类扫描(off 档整体不生效) |
|
||||
| PMS_MACRO_HOT_TH ⚙ | 25.0 | 偏热进入阈值 |
|
||||
| PMS_MACRO_COLD_TH ⚙ | -25.0 | 偏冷进入阈值 |
|
||||
| PMS_MACRO_EXIT_BAND ⚙ | 15.0 | 迟滞退出带(\|值\| 回落到此内算离区) |
|
||||
| PMS_MACRO_CONFIRM_DAYS ⚙ | 1 | 进区连续 N 个交易日才触发 |
|
||||
| PMS_MACRO_TRIGGER_MODE ⚙ | zone_enter | zone_enter=进区即动 / zone_exit=极值回落再动(**默认值待标定对比后定**) |
|
||||
| PMS_MACRO_LOG_S0 ⚙ | 0.12 | 对数映射系数:target = S0·ln(1+e/k) |
|
||||
| PMS_MACRO_LOG_K ⚙ | 10.0 | 对数映射尺度 k |
|
||||
| PMS_MACRO_SHIFT_MAX ⚙ | 0.20 | 单个极值周期的累计调整封顶(占规模的百分点) |
|
||||
| PMS_MACRO_STEP_MIN | 0.02 | 裁剪后步长小于此不动 |
|
||||
| PMS_MACRO_COOLDOWN_TDAYS | 3 | 同方向两次动作最小间隔(交易日) |
|
||||
| PMS_MACRO_MIN_PCT | 0.20 | 宏观降仓地板(自动降仓不把总仓位降到此下;用户命令不受限) |
|
||||
| PMS_MACRO_MAX_PCT | 0.0 | 宏观升仓天花板;0=不单设,由 PMS_PORTFOLIO_CAP 兜底 |
|
||||
| PMS_MACRO_WINDOW_TDAYS | 3 | 宏观命令的执行窗口 |
|
||||
| PMS_MACRO_RESPECT_BRAKE | True | 组合刹车期间不自动升仓(假设项,见 §12.4) |
|
||||
| PMS_MACRO_STALE_TDAYS | 3 | 任一数据源日龄超此(交易日)→ UNAVAILABLE |
|
||||
| PMS_MACRO_RET_WIN / Z_WIN | 20 / 40 | 收益窗 / Z-Score 窗 |
|
||||
| PMS_MACRO_SHIBOR_BETA | 0.02 | SHIBOR 修正系数 β |
|
||||
|
||||
`_RANGES` 同步登记,`DESC` 写中文说明。**不进 FAIL_CLOSED**:ENABLED 的文件初值就是 False,表读不到退初值即安全方向。
|
||||
|
||||
---
|
||||
|
||||
## 9. 触碰面清单与三条铁律自证
|
||||
|
||||
新增文件(5 个,互相独立,删净即回到今天——"三题"规矩第 3 题):
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| app/core/macro_rules.py | 纯逻辑:对齐与四步计算、zone 迟滞判定、对数映射 decide(无 IO,可单测) |
|
||||
| app/repo/macro_repo.py | 三张源表单表取数 + pms_macro_signal 读写 |
|
||||
| app/services/macro_service.py | 编排:守卫→取数→算→落表→决策→分流→留痕;status / adopt / gate_state |
|
||||
| scripts/calibrate_macro_signal.py | 标定脚本(**已交付**,探查+分布+口径对比+对数参数,只读) |
|
||||
| scripts/test_batch14_units.py | 单测约 25 例(§11) |
|
||||
|
||||
触碰的既有文件(9 处,**全部纯增量**——只加行/加检查,不改既有行为,逐处可 diff 核对):
|
||||
|
||||
| 文件 | 加什么 | 不动什么 |
|
||||
|---|---|---|
|
||||
| config/settings.py | PMS_MACRO_* 字段一段 | 既有字段零改动 |
|
||||
| app/services/param_store.py | DESC / _RANGES / _range_check 各加宏观项 | 缓存、FAIL_CLOSED、既有键 |
|
||||
| app/scheduler.py | `macro_scan` 任务 + beat 一行 | 既有九个调度位 |
|
||||
| app/services/proposal_service.py | 两处入口各加一个宏观闸检查(与 exec_halt 检查并排,闸关/读不到=原行为) | 扫描、闸门、分流逻辑零改动 |
|
||||
| app/web/main.py | 三个新端点 | 既有端点 |
|
||||
| app/web/static/index.html | 一块面板 | 既有面板 |
|
||||
| ddl_pms_v1.sql | 第 18 张表 CREATE IF NOT EXISTS | 既有表 |
|
||||
| scripts/check_db.py | PMS_TABLES 加 pms_macro_signal | (顺带发现:pms_strategy / pms_op_log 也不在这份清单里,属既有欠账,按"不夹带"纪律单独提,不在本次一起改) |
|
||||
| scripts/run_tests.py | SUITES 登记 batch14 | 既有批次 |
|
||||
|
||||
三条铁律逐条自证:**命令至上**——宏观动作本身就是命令,冲突让位用户,可撤可改;宏观闸只拦 PMS 自主动作,用户命令与减持全不受限。**分工不越权**——宏观层只回答"整体该多重、增量该不该停",逐股怎么卖买仍由 planner/择时/规则闸决定,不碰决策系统(bionic/桥/择时层**零改动**,"三题"规矩天然满足)。**先记账后动作 + 故障即守成**——信号先落表再决策,命令走 issue 的先落表路径,任何取数/计算失败 = 不产生新指令、不落闸。
|
||||
|
||||
---
|
||||
|
||||
## 10. 前置标定(先于一切编码,脚本已交付)
|
||||
|
||||
`scripts/calibrate_macro_signal.py`(**严格只读**,只发 SELECT/SHOW)在桥机跑,一次回答四件事:
|
||||
|
||||
1. **列名探查**:对 153(proxy)与 199(index)两源探查三张表的位置、列名、样本行——结果回填 §4.2 后才写 macro_repo;
|
||||
2. **分布体检**:hedge_index 历史分位数、±20/25/30 各阈值的触发天数与年频次、极值区段的持续天数与最大深度——回答"±25 合不合身";
|
||||
3. **触发口径对比**:zone_enter vs zone_exit 各自触发事件后 5/10/20 交易日上证的均值/中位数/胜率(HOT 后应偏负、COLD 后应偏正才算有效)——**注意样本量会很小,结论当参考不当真理**;
|
||||
4. **对数映射标定**:极值周期超额深度 e 的分布 → 解出 S0/k 建议值(e 中位数→5 点、95 分位→12 点、封顶 20 点),并打 e→调整幅度对照表。
|
||||
|
||||
**运行方式(不需要重建镜像**——脚本从宿主工作树经 stdin 喂给容器 python,`python -` 的 sys.path[0] 是工作目录 /app,`config.settings` 照常可导入):
|
||||
|
||||
```bash
|
||||
cd ~/tradingSystem # git pull 之后
|
||||
docker compose run --rm -T pms-web python - < scripts/calibrate_macro_signal.py > /tmp/macro_calib_report.md
|
||||
cat /tmp/macro_calib_report.md
|
||||
# 需要完整序列时:
|
||||
docker compose run --rm -T pms-web python - --dump-csv < scripts/calibrate_macro_signal.py > /tmp/hedge_series.csv
|
||||
```
|
||||
|
||||
探查失败的退路:脚本会打出该表全部列名与样本行,按提示带 `--fx-table/--fx-date-col/--fx-price-col/--fx-where/--shibor-*` 覆盖重跑;表真不存在 → 数据先落地(数据管道补),或立项由桥/择时层供数(跨系统改动,按"三题"规矩单独议,本方案不带)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 实施步骤与判收
|
||||
|
||||
| 步 | 内容 | 判收标准 |
|
||||
|---|---|---|
|
||||
| 1 | 桥机跑标定脚本(§10,可立即做) | 三表列名落实;报告出分布/口径对比/S0-k 建议;把报告拿回来定 §8 带 ⚙ 的初值 |
|
||||
| 2 | 写码 + batch14 单测 | `make test` ALL SUITES PASS。用例:合成序列对照手算值验四步计算;fx +1 对齐与缺口 as-of;样本不足/停更→UNAVAILABLE;zone 迟滞进出与横跳;对数映射(target 单调、同深度不重复加码、周期清零、封顶);confirm/cooldown/每日一次;零方案命令不占冷却;冲突/HALT_BUY/刹车跳过(打桩);宏观闸(HOT 拦买入侧不拦 TRIM、读不到不拦、off 档不拦);建议采纳幂等与隔日作废 |
|
||||
| 3 | ddl 建表 + `make deploy`(收盘后) | `make check` 见 18 表;页面参数区出现 PMS_MACRO_*,ENABLED=False,系统行为与部署前一致 |
|
||||
| 4 | 手动 `POST /api/ops/macro-scan?dry_run=true` | 面板出指数值与区制;与标定脚本同日值一致(同一套纯函数,天然一致) |
|
||||
| 5 | 开 ENABLED=True,档位 propose_only,观察 ≥1 周 | 每日一行信号历史;极值日出建议卡不出命令;宏观闸在 HOT 日真的把自主增持 skipped(留痕可查);采纳一次走通 建议→命令→方案→指令 全链 |
|
||||
| 6 | 冲突演练:挂一条在途 INCREASE,手动扫描触发降仓 | 账本见 MACRO REJECT 留痕,无命令产生 |
|
||||
| 7 | (拍板后)切 full | 首条自动命令端到端回执,日报关注区可见 |
|
||||
|
||||
回滚:页面把 `PMS_MACRO_ENABLED` 置 False 即回到现状(秒级,命令、闸、建议全停);代码级回滚 = 还原上表 9 处触碰 + 删 5 个新文件;新表留着无任何读方,无副作用。
|
||||
|
||||
---
|
||||
|
||||
## 12. 待拍板清单(V2 更新)
|
||||
|
||||
1. ~~触发口径默认值~~ → **改为由标定结果定**:跑完 §10 脚本,拿两种口径的事后对比数据来选(样本小的话建议 zone_enter + CONFIRM_DAYS 取 1~2 里标定更稳的那档)。
|
||||
2. ~~固定步长 vs 目标带~~ → **已定:对数映射**(用户拍板)。S0/k/SHIFT_MAX 初值等标定回填。
|
||||
3. **策略层(网格/做T)的买入侧要不要受宏观闸**:V2 建议先不动(理由 §5.2 末),要管的话复用 `strategy_service.pause_buy` 一行接入。
|
||||
4. **升仓是否受组合刹车约束**:上轮问题未获直接答复,V2 按**受约束(True)**落地并标注为假设;要放开改参数即可。
|
||||
5. **数据源**:已答——153。zs_day_data 是否也统一走 153 由探查定。
|
||||
|
||||
## 13. 风险与已知限制(说在前面)
|
||||
|
||||
- **信号本身是慢变量**:20 日收益 + 40 日 Z,天然滞后,抓的是阶段冷热不是日内顶底;对数映射 + 冷却是为此设的缓冲。
|
||||
- **z-score 的尾部行为**:40 日窗里刚经历过极端行情时 std 变大,新极端可能"钝化";反之平静期小波动被放大。标定报告的分位数表就是用来看这个的。
|
||||
- **事后对比样本小**:±25 级别的极值一年可能只有几次,标定里的口径对比是参考不是显著性检验,别过度拟合。
|
||||
- **升仓依赖候选池**:极冷区触发升仓但当日无强传导候选 → 命令零方案 CANCELLED(留痕可见,不占冷却,次日重试)——"宁缺毋滥"纪律优先于宏观意愿,这是设计取向不是缺陷。
|
||||
- **宏观闸的机会成本**:偏热确认期间个股级的好机会也会被一并拦下(闸不看个股质地)——这是"个股动作受宏观控制"的题中之义;propose_only 观察期里可以从 skipped 留痕回看拦了什么,评估闸的松紧。
|
||||
- **UNAVAILABLE 长期化**:数据停更时宏观层安静失效(不动作、不落闸),只有面板黄条与日报提示——不会误动作,但也不会提醒你"该人工判断了",需要把面板纳入日常一眼。
|
||||
页面面板内容:指数当前值和区域色带(热红、冷蓝、中性灰、不可用黄)、近二十日迷你走势、当日建议卡带采纳与忽略按钮、宏观闸状态条、最近一条宏观命令的进度。参数不用做界面,登记后自动出现在参数设置页。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
| 命令轮询 | 每 1 分钟 (全天) | 新命令解析 → 方案生成 → 任务状态机推进 |
|
||||
| 盘中执行 | 交易时段每 1 分钟 | 择时出手 + 自主提议扫描 (执行器下一批交付) |
|
||||
| 信号消化 | 交易时段每 1 分钟 | 订阅决策系统盘中信号 (下一批交付) |
|
||||
| 宏观择时 | 交易日 09:35 | 股汇对冲指数 → 升降仓命令或建议 + 宏观闸 |
|
||||
| 成交回放 | 交易时段每 1 分钟 | ws 逐笔入账 + trading_order 增量 + 轻对账 |
|
||||
| T 仓平回 | 14:50 (二期) | 做T强制平回 |
|
||||
| 日终结算 | 15:10 | 全量对账 / 除权 / 安全垫 / 命令进度日结 |
|
||||
|
|
@ -180,6 +181,20 @@ def signal_digest():
|
|||
return signal_service.consume()
|
||||
|
||||
|
||||
@celery_app.task(name="pms.macro_scan")
|
||||
@guard(trade_day=True, respect_exec_halt=True)
|
||||
def macro_scan():
|
||||
"""宏观择时 (09:35): 算股汇对冲指数 → 区域判定 → 升降仓命令或建议 + 个股宏观闸。
|
||||
|
||||
定在 09:35 而非盘前: 盘前无实时价, positions_view 会把持仓标 price_ok=False,
|
||||
planner._usable 排除这类票, 盘前下的降仓命令会被排成零方案直接取消。
|
||||
09:35 行情已就位, 也在 08:40 拉候选池之后 (升仓有票可选)。指数用 T-1 日终数据,
|
||||
几点计算数值都一样。详见 MACRO_TIMING_PLAN.md 第六节。
|
||||
"""
|
||||
from app.services import macro_service
|
||||
return macro_service.scan()
|
||||
|
||||
|
||||
@celery_app.task(name="pms.t0_close")
|
||||
@guard(trade_day=True)
|
||||
def t0_close():
|
||||
|
|
@ -234,6 +249,7 @@ celery_app.conf.beat_schedule = {
|
|||
"replay_fills": {"task": "pms.replay_fills", "schedule": crontab(minute="*")},
|
||||
"intraday_exec": {"task": "pms.intraday_exec", "schedule": crontab(minute="*")},
|
||||
"signal_digest": {"task": "pms.signal_digest", "schedule": crontab(minute="*")},
|
||||
"macro_scan": {"task": "pms.macro_scan", "schedule": crontab(hour=9, minute=35)},
|
||||
"t0_close": {"task": "pms.t0_close", "schedule": crontab(hour=14, minute=50)},
|
||||
"daily_settle": {"task": "pms.daily_settle", "schedule": crontab(hour=15, minute=10)},
|
||||
"daily_report": {"task": "pms.daily_report", "schedule": crontab(hour=15, minute=30)},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -52,6 +52,9 @@ RUNTIME_EXTRA = {
|
|||
"PMS_UPSTREAM_ALERT_SCAN": (300, int, "告警扫描窗: 每轮从告警流取最近 N 条再分类限量 (窗要大于各类上限之和, 防 price_notice 等高频源把风控告警挤出最新N)"),
|
||||
"PMS_UPSTREAM_ALERT_PER_CAT": (20, int, "告警每类上限: 同一类型最多显示最新 N 条 (砍单类刷屏, 不挤别的类)"),
|
||||
"PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (60, int, "告警时间窗(分钟): 超过此龄判为陈旧并在页面置灰标注(不删除, 风控不丢信息), 0=不限龄。与每类上限正交, 先按龄标记再每类限量"),
|
||||
# 2026-08-18 宏观择时层: 闸状态由 macro_service 每次扫描写入, 扫描层与策略层只读。
|
||||
# 用运行参数承载 (不进策略 state、不另建表) —— 与 PMS_STRATEGY_BUYPAUSE 同一手法。
|
||||
"PMS_MACRO_GATE_STATE": ("", str, "宏观闸状态 (macro_service 维护的 JSON: active/ymd/原因), 页面与扫描层只读, 勿手改"),
|
||||
}
|
||||
|
||||
# **读不到时必须按"已暂停"处理的键 (fail-closed)。**
|
||||
|
|
@ -66,6 +69,10 @@ FAIL_CLOSED = {
|
|||
# 参数表抖一下就按 full 走, 等于在读不到任何约束的情况下自己去买没买过的票。
|
||||
# 读不到 → off (本轮不扫描新建仓), 已有持仓的四类动作不受影响。
|
||||
"PMS_OPEN_AUTONOMY": "off",
|
||||
# 宏观择时总开关的文件初值是 True (模拟仓拍板) —— 表读不到时若退文件初值, 宏观层会在
|
||||
# 基础设施故障期间照常想下命令。安全方向是关: 宁可少动一轮。取数环节同库先挂是第一道
|
||||
# 保险, 这条名单是第二道。
|
||||
"PMS_MACRO_ENABLED": False,
|
||||
}
|
||||
|
||||
# 页面展示用的中文说明 (settings.py 用行尾注释, pydantic 取不到, 故在此集中维护)
|
||||
|
|
@ -161,6 +168,29 @@ DESC = {
|
|||
"PMS_QMT_HEARTBEAT_STALE_SEC": "心跳陈旧超此秒数 → 判定 ws 进程已死, 指令一律拒发",
|
||||
"PMS_QMT_CONNECT_TIMEOUT_SEC": "建连超时 (秒)",
|
||||
"PMS_QMT_SEND_MAX_ATTEMPTS": "单张委托发送重试上限, 试满置 SEND_FAILED 等人工",
|
||||
"PMS_MACRO_ENABLED": "宏观择时总开关 (关=调度位空转不取数不计算; 模拟仓默认开, 转实盘前改回关)",
|
||||
"PMS_MACRO_AUTONOMY": "宏观档位 off 只算不动 / propose_only 出建议 / full 自动下命令 (模拟仓默认 full)",
|
||||
"PMS_MACRO_SIGNALS": "启用的宏观信号清单 (逗号分隔, 当前仅 hedge_fx 股汇对冲指数)",
|
||||
"PMS_MACRO_STOCK_GATE": "个股宏观闸: 偏热确认期间暂停自主增持 (OPEN/FILL/ADD/DCA) 与策略买开腿; 减持与用户命令不受限",
|
||||
"PMS_MACRO_HOT_TH": "偏热进入阈值 (指数点; 标定 ±25 约每年 1.4 段)",
|
||||
"PMS_MACRO_COLD_TH": "偏冷进入阈值 (负数; 标定 -20 约每年 4 段, 事后反弹更强)",
|
||||
"PMS_MACRO_EXIT_BAND": "迟滞退出带 (|指数| 回落到此内算离区; 偏热侧它同时是降仓命令触发线)",
|
||||
"PMS_MACRO_CONFIRM_DAYS": "偏冷进区连续 N 个交易日才触发 (标定: 等第二天反而更差, 取 1)",
|
||||
"PMS_MACRO_TRIGGER_HOT": "偏热触发口径: zone_exit=极值回落再降仓 (标定采纳) / zone_enter=进区即降",
|
||||
"PMS_MACRO_TRIGGER_COLD": "偏冷触发口径: zone_enter=进区即升仓 (标定采纳) / zone_exit=回落再升",
|
||||
"PMS_MACRO_LOG_S0": "对数映射系数: 周期累计目标 = S0×ln(1+超额深度/k), 标定回填 0.049",
|
||||
"PMS_MACRO_LOG_K": "对数映射尺度 k, 标定回填 1.6",
|
||||
"PMS_MACRO_SHIFT_MAX": "单个极值周期累计调整封顶 (占规模百分点)",
|
||||
"PMS_MACRO_STEP_MIN": "裁剪后步长小于此不动; 也是偏冷确认当日的首步保底",
|
||||
"PMS_MACRO_COOLDOWN_TDAYS": "同方向两次宏观动作最小间隔 (交易日; 零方案被取消的命令不占冷却)",
|
||||
"PMS_MACRO_MIN_PCT": "宏观降仓地板: 自动降仓不把总仓位降到此线以下 (用户命令不受限)",
|
||||
"PMS_MACRO_MAX_PCT": "宏观升仓天花板; 0=不单设, 由总仓上限 PMS_PORTFOLIO_CAP 兜底",
|
||||
"PMS_MACRO_WINDOW_TDAYS": "宏观命令的执行窗口 (交易日)",
|
||||
"PMS_MACRO_RESPECT_BRAKE": "组合刹车期间不自动升仓 (降仓不受限; 方案唯一假设项, 可放开)",
|
||||
"PMS_MACRO_STALE_TDAYS": "任一数据源日龄超此 (交易日) → 信号不可用, 不动作不落闸",
|
||||
"PMS_MACRO_RET_WIN": "股汇收益计算窗口 (交易日)",
|
||||
"PMS_MACRO_Z_WIN": "滚动标准化窗口 (交易日)",
|
||||
"PMS_MACRO_SHIBOR_BETA": "利率修正系数 β (spread − β×SHIBOR1M 二十日变化)",
|
||||
}
|
||||
|
||||
# loaded 标记必不可少: 不能用「data 是否为空」判断缓存是否有效 ——
|
||||
|
|
@ -325,12 +355,26 @@ _RANGES = {
|
|||
# 任务会先被 celery 打死 (而且是在已经落了一部分表之后)。
|
||||
"PMS_UPSTREAM_ALERT_SCAN": (1, 20000), "PMS_UPSTREAM_ALERT_PER_CAT": (1, 1000),
|
||||
"PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (0, 1440), # 0=不限龄, 上限 24 小时
|
||||
# 宏观择时层
|
||||
"PMS_MACRO_HOT_TH": (0, 100), "PMS_MACRO_COLD_TH": (-100, 0),
|
||||
"PMS_MACRO_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10),
|
||||
"PMS_MACRO_LOG_S0": (0, 1), "PMS_MACRO_LOG_K": (0.01, 1000),
|
||||
"PMS_MACRO_SHIFT_MAX": (0, 1), "PMS_MACRO_STEP_MIN": (0, 0.5),
|
||||
"PMS_MACRO_COOLDOWN_TDAYS": (0, 20), "PMS_MACRO_MIN_PCT": (0, 1),
|
||||
"PMS_MACRO_MAX_PCT": (0, 1), "PMS_MACRO_WINDOW_TDAYS": (1, 20),
|
||||
"PMS_MACRO_STALE_TDAYS": (1, 20), "PMS_MACRO_RET_WIN": (5, 60),
|
||||
"PMS_MACRO_Z_WIN": (10, 250), "PMS_MACRO_SHIBOR_BETA": (0, 1),
|
||||
}
|
||||
|
||||
|
||||
def _range_check(key, v):
|
||||
if key == "PMS_AUTONOMY" and v not in ("full", "propose_only", "off"):
|
||||
return "PMS_AUTONOMY 只能是 full / propose_only / off"
|
||||
if key == "PMS_MACRO_AUTONOMY" and v not in ("full", "propose_only", "off"):
|
||||
return "PMS_MACRO_AUTONOMY 只能是 full / propose_only / off"
|
||||
if key in ("PMS_MACRO_TRIGGER_HOT", "PMS_MACRO_TRIGGER_COLD") \
|
||||
and v not in ("zone_enter", "zone_exit"):
|
||||
return f"{key} 只能是 zone_enter (进区即动) / zone_exit (极值回落再动)"
|
||||
if key == "PMS_OPEN_AUTONOMY" and v not in ("full", "propose_only", "off"):
|
||||
return "PMS_OPEN_AUTONOMY 只能是 full / propose_only / off"
|
||||
if key == "PMS_EXEC_IMPL" and str(v).strip().upper() not in ("A", "B"):
|
||||
|
|
|
|||
|
|
@ -42,6 +42,19 @@ logger = logging.getLogger("pms.proposal")
|
|||
AUTONOMY_FULL, AUTONOMY_PROPOSE, AUTONOMY_OFF = "full", "propose_only", "off"
|
||||
|
||||
|
||||
def _macro_gate() -> dict:
|
||||
"""宏观偏热闸状态 (macro_service 维护, 这里只读; MACRO_TIMING_PLAN.md §5.2)。
|
||||
|
||||
读不到一律按不生效 —— 闸的安全方向是「不额外拦」: 买入本身另有规则闸把关,
|
||||
不能让宏观层故障把整个自主引擎摁死。"""
|
||||
try:
|
||||
from app.services import macro_service
|
||||
return macro_service.gate_state() or {}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[提议] 读宏观闸状态失败 (按不生效): %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
|
||||
"""自主提议扫描一轮。dry_run=True 只出候选与判定, 不落任何表。"""
|
||||
now = now or datetime.now()
|
||||
|
|
@ -88,6 +101,20 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
|
|||
logger.exception("提议扫描失败")
|
||||
return {**out, "ok": False, "errors": [f"扫描失败: {type(e).__name__}: {e}"]}
|
||||
|
||||
# 宏观偏热闸: 只拦已有持仓四类动作里的**买入侧** (FILL/ADD/DCA), TRIM 保垫减仓照常。
|
||||
# 落点选在扫描产出之后、分流之前: 不碰 action_engine, 不产生指令自然过不了后面任何一层。
|
||||
mg = _macro_gate()
|
||||
if mg.get("active"):
|
||||
kept = []
|
||||
for c in scanned["candidates"]:
|
||||
if c.get("side") == "buy":
|
||||
out["skipped"].append({"ts_code": c.get("ts_code"), "action": c.get("action"),
|
||||
"why": f"宏观偏热闸生效 ({mg.get('why') or '股汇对冲指数偏热'}), "
|
||||
f"自主增持暂停"})
|
||||
else:
|
||||
kept.append(c)
|
||||
scanned["candidates"] = kept
|
||||
|
||||
out["candidates"] = len(scanned["candidates"])
|
||||
out["skipped"].extend(scanned["skipped"])
|
||||
stock_params = command_service.effective_stock_params()
|
||||
|
|
@ -133,6 +160,15 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list:
|
|||
注释就写着「拿昨收当现价去做不追高这类判断会出错, 只给规划期定量用」。
|
||||
这条路是要真下单的, 所以走 market.get_prices, 取不到就整只跳过并留痕。
|
||||
"""
|
||||
# 宏观偏热闸: 新建仓全是买入, 整轮直接不扫 (MACRO_TIMING_PLAN.md §5.2)。
|
||||
# 放在函数入口: disposition_snapshot 复用本函数, 页面"系统在盯的候选"会如实显示闸生效。
|
||||
mg = _macro_gate()
|
||||
if mg.get("active"):
|
||||
out["skipped"].append({"action": ae.A_OPEN,
|
||||
"why": f"宏观偏热闸生效 ({mg.get('why') or '股汇对冲指数偏热'}), "
|
||||
f"本轮不自动新建仓"})
|
||||
return []
|
||||
|
||||
src = (param_store.get("PMS_CANDIDATE_SOURCE", plan_feed.SRC_PLAN_API)
|
||||
or plan_feed.SRC_PLAN_API).strip()
|
||||
if src == plan_feed.SRC_BUY_PLAN:
|
||||
|
|
|
|||
|
|
@ -103,6 +103,20 @@ def _buypause_codes() -> set:
|
|||
return set()
|
||||
|
||||
|
||||
def _macro_gate_active() -> bool:
|
||||
"""宏观偏热闸 (MACRO_TIMING_PLAN.md §5.2): 生效时策略的买入开腿一并暂停。
|
||||
|
||||
与 buypause 走同一个判定点 (ctx.buy_paused), 天然继承「只挡开新仓/加仓腿,
|
||||
卖出/平回/跟踪止盈照常」的既有语义 —— 反T 的买回是平回腿, 不会被拦。
|
||||
读不到按不生效: 闸的安全方向是不额外拦。"""
|
||||
try:
|
||||
from app.services import macro_service
|
||||
return bool((macro_service.gate_state() or {}).get("active"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[strategy] 读宏观闸状态失败 (按不生效): %s", e)
|
||||
return False
|
||||
|
||||
|
||||
# ================================================================ 指令 / 提议下发
|
||||
def _emit_instruction(st: dict, dec: dict, *, forced: bool = False) -> str:
|
||||
"""按决策发一张短窗口命令指令 (window_tdays=1, is_command=True), 交 run_tick 执行。
|
||||
|
|
@ -529,6 +543,9 @@ def tick(*, now=None, dry_run: bool = False) -> dict:
|
|||
scale = _f(view.get("totals", {}).get("scale"))
|
||||
today = _today()
|
||||
paused_codes = _buypause_codes() # 决策系统风控预警暂停买入的票 (本模块只读)
|
||||
macro_gated = _macro_gate_active() # 宏观偏热闸: 全体策略买开腿暂停, 闸解除自动恢复
|
||||
if macro_gated:
|
||||
out["notes"].append("宏观偏热闸生效: 策略买入开腿本轮暂停 (卖出与平回照常)")
|
||||
|
||||
# 全局当日T亏熔断: 汇总所有做T策略的当日T盈亏 (realized_t_profit 相对日初基线的增量)
|
||||
global_t_pnl = 0.0
|
||||
|
|
@ -582,7 +599,7 @@ def tick(*, now=None, dry_run: bool = False) -> dict:
|
|||
out["notes"].append(f"{code} 当日T亏达单票熔断线 {stock_cap:.0f} 元, 今日不再开新T")
|
||||
|
||||
ctx = {"state": state, "scale": scale, "halted": halted, "notes": out["notes"],
|
||||
"buy_paused": code in paused_codes}
|
||||
"buy_paused": (code in paused_codes) or macro_gated}
|
||||
dec = fn(st, pos, day, now, ctx)
|
||||
|
||||
if dec:
|
||||
|
|
|
|||
|
|
@ -506,6 +506,30 @@ def api_upstream_signals():
|
|||
return ok(upstream_signals.snapshot)
|
||||
|
||||
|
||||
# ================================================================ 宏观择时 (MACRO_TIMING_PLAN.md)
|
||||
@app.get("/api/macro/status")
|
||||
def api_macro_status():
|
||||
"""宏观择时面板快照: 各信号当前值/区域/近 20 日序列/当日建议/最近命令/冷却 + 闸状态。"""
|
||||
from app.services import macro_service
|
||||
return ok(macro_service.status)
|
||||
|
||||
|
||||
@app.post("/api/ops/macro-scan")
|
||||
def api_macro_scan(dry_run: bool = Query(False)):
|
||||
"""手动宏观扫描 (= 09:35 调度位)。dry_run=true 只算不落表不下达, 供判收与盘中复核。"""
|
||||
from app.services import macro_service
|
||||
return ok_logged("macro_scan", macro_service.scan, dry_run=dry_run,
|
||||
params={"dry_run": dry_run})
|
||||
|
||||
|
||||
@app.post("/api/macro/adopt")
|
||||
def api_macro_adopt(payload: dict = Body(default={})):
|
||||
"""采纳当日宏观建议 → 以用户名义下命令 (幂等, 建议隔日作废)。"""
|
||||
from app.services import macro_service
|
||||
key = (payload or {}).get("signal_key") or "hedge_fx"
|
||||
return ok_logged("macro_adopt", macro_service.adopt, key, params={"signal_key": key})
|
||||
|
||||
|
||||
@app.get("/api/upstream/plan")
|
||||
def api_upstream_plan(limit: int = Query(30), date: str = Query(None),
|
||||
bucket: str = Query("main")):
|
||||
|
|
|
|||
|
|
@ -182,6 +182,11 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
|
|||
.lv-chip.on.lv-warn{color:var(--warn);background:var(--warn-weak);border-color:var(--warn-weak);}
|
||||
.lv-chip.on.lv-info{color:var(--ink-2);background:rgba(138,136,130,.14);}
|
||||
.sig-sep{font-size:12px;color:var(--muted);font-weight:600;margin:16px 0 8px;padding-top:9px;border-top:1px dashed var(--hair);}
|
||||
/* 宏观择时面板 (MACRO_TIMING_PLAN.md) */
|
||||
.mz-bars{display:flex;align-items:flex-end;gap:2px;height:34px;margin:7px 0 3px;}
|
||||
.mz-bar{width:9px;border-radius:2px;min-height:3px;opacity:.9;}
|
||||
.mz-zone{padding:1px 9px;border-radius:9px;font-size:12px;font-weight:600;color:#fff;}
|
||||
.mz-advice{margin:7px 0;padding:8px 11px;border:1px solid var(--warn);border-radius:9px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
|
||||
</style>
|
||||
</head>
|
||||
<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"
|
||||
: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"
|
||||
: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>
|
||||
</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) 上游信号 (只读参考) -->
|
||||
<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>
|
||||
|
|
@ -2075,6 +2123,38 @@ createApp({
|
|||
return m ? (h + '小时' + m + '分前') : (h + '小时前');
|
||||
}
|
||||
const alertRowClass = ({ row }) => (row && row.stale) ? 'alert-stale' : '';
|
||||
// —— 宏观择时 (MACRO_TIMING_PLAN.md): 面板取数 / 手动扫描 / 建议采纳 ——
|
||||
const macro = ref({});
|
||||
const macroGateOn = computed(() => !!(((macro.value || {}).gate || {}).active));
|
||||
const macroUnavailable = computed(() => {
|
||||
const m = macro.value || {};
|
||||
if (m.enabled === false) return '';
|
||||
const bad = (m.signals || []).filter(s => s.zone === 'UNAVAILABLE');
|
||||
if (!bad.length) return '';
|
||||
return bad.map(s => (s.label || s.key) + (s.note ? (':' + s.note) : '')).join(';');
|
||||
});
|
||||
function mzLabel(z) { return { HOT: '偏热', COLD: '偏冷', NEUTRAL: '中性', UNAVAILABLE: '数据不可用' }[z] || (z || '—'); }
|
||||
function mzColor(z) { return { HOT: '#d8574a', COLD: '#3d7ff0', NEUTRAL: '#8a8f9c', UNAVAILABLE: '#d0a53c' }[z] || '#8a8f9c'; }
|
||||
function mzBarStyle(h) {
|
||||
const v = (h && h.value != null) ? Math.max(-40, Math.min(40, Number(h.value))) : 0;
|
||||
return { height: (3 + Math.round(Math.abs(v) / 40 * 27)) + 'px', background: mzColor(h && h.zone) };
|
||||
}
|
||||
async function loadMacro() { const d = await call('get', '/api/macro/status'); macro.value = d || {}; }
|
||||
async function macroScan(dry) {
|
||||
const d = await call('post', '/api/ops/macro-scan?dry_run=' + (dry ? 'true' : 'false'), {});
|
||||
ElementPlus.ElMessage[(d && d.ok !== false) ? 'success' : 'error'](
|
||||
(dry ? '试算完成' : '扫描完成') + ((d && d.error) ? (':' + d.error) : ''));
|
||||
await loadMacro();
|
||||
if (!dry) await Promise.all([loadCommands(), loadLedger()]);
|
||||
}
|
||||
async function macroAdopt(key) {
|
||||
try { await ElementPlus.ElMessageBox.confirm('按今日宏观建议下达仓位命令?', '采纳建议', { type: 'warning' }); }
|
||||
catch (e) { return; }
|
||||
const d = await call('post', '/api/macro/adopt', { signal_key: key });
|
||||
ElementPlus.ElMessage[(d && d.ok) ? 'success' : 'error']((d && d.ok) ? ('已下达 ' + (d.command_id || '')) : ((d && d.error) || '采纳失败'));
|
||||
await Promise.all([loadMacro(), loadCommands()]);
|
||||
}
|
||||
|
||||
async function loadUpstream() {
|
||||
const d = await call('get', '/api/upstream-signals');
|
||||
upstream.value = (d && d.sources) ? d : { sources: {} };
|
||||
|
|
@ -2113,7 +2193,8 @@ createApp({
|
|||
loading.value = true; err.value = '';
|
||||
await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(),
|
||||
loadPositions(), loadInstructions(), loadLedger(), loadProposals(),
|
||||
loadDispatchMode(), loadWs(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream()]);
|
||||
loadDispatchMode(), loadWs(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream(),
|
||||
loadMacro()]);
|
||||
await loadNames();
|
||||
lastRefresh.value = new Date().toTimeString().slice(0, 8);
|
||||
loading.value = false;
|
||||
|
|
@ -2314,7 +2395,7 @@ createApp({
|
|||
async function refreshLive() {
|
||||
// allSettled: 单个 loader 失败/超时(axios 60s 兜底)不拖累其余与时间戳更新。
|
||||
await Promise.allSettled([loadOverview(), loadPositions(), loadInstructions(), loadLedger(),
|
||||
loadProposals(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream()]);
|
||||
loadProposals(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream(), loadMacro()]);
|
||||
try { await loadNames(); } catch (e) {}
|
||||
// 用客户端钟, 保证"自动·HH:MM:SS"稳定跳动, 不依赖 /health 的 now。
|
||||
lastRefresh.value = new Date().toTimeString().slice(0, 8);
|
||||
|
|
@ -2350,6 +2431,8 @@ createApp({
|
|||
openScan, insTab, todayYmd, insLive, insDone, insEnd, dispOf, loadOpenScan, denyToday,
|
||||
showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh,
|
||||
upstream, showUpstream, usrc, loadUpstream,
|
||||
macro, macroGateOn, macroUnavailable, mzLabel, mzColor, mzBarStyle,
|
||||
loadMacro, macroScan, macroAdopt,
|
||||
showAlerts, alertItems, alertLatest, alertLevelClass, alertGroups, alertStripItems,
|
||||
alertStripCount, alertTotalCount, catShown, toggleCat, ageText, alertRowClass,
|
||||
alertItemsF, levelShown, toggleLevel, levelCount, ALERT_LEVELS, lvLabel, lvClass, alertGridFixed, passLevel, flowText, flowClass,
|
||||
|
|
|
|||
|
|
@ -251,6 +251,35 @@ class Settings(BaseSettings):
|
|||
PMS_SIGNAL_AUTO_EXIT_CONF: float = 0.85 # 高于此置信度直接转清仓指令, 之间则落提议
|
||||
PMS_SIGNAL_TRIM_RATIO: float = 0.3333 # 中等置信度时的减仓比例
|
||||
|
||||
# --- 宏观择时层 (股汇对冲指数 → 组合仓位命令 + 个股宏观闸) ---
|
||||
# 方案 MACRO_TIMING_PLAN.md; 阈值与对数映射参数依据标定报告 MACRO_CALIB_2026-08-18.md。
|
||||
# 模拟仓拍板 (2026-08-18): 默认总开关开 + 全自动档, 不设观察期; **转实盘前
|
||||
# 改回 False / propose_only**。ENABLED 已进 param_store.FAIL_CLOSED: 参数表
|
||||
# 读不到时按关闭处理, 基础设施故障期间宁可不动。
|
||||
PMS_MACRO_ENABLED: bool = True # 总开关; 关 = 调度位空转, 不取数不计算
|
||||
PMS_MACRO_AUTONOMY: str = "full" # off 只算不动 / propose_only 出建议 / full 自动下命令
|
||||
PMS_MACRO_SIGNALS: str = "hedge_fx" # 启用的信号清单 (逗号分隔; 注册表在 macro_service)
|
||||
PMS_MACRO_STOCK_GATE: bool = True # 个股宏观闸: 偏热确认期间暂停自主增持与策略买开腿
|
||||
PMS_MACRO_HOT_TH: float = 25.0 # 偏热进入阈值 (标定: 约 1.4 段/年)
|
||||
PMS_MACRO_COLD_TH: float = -20.0 # 偏冷进入阈值 (标定: 约 4 段/年, 用户确认取 -20)
|
||||
PMS_MACRO_EXIT_BAND: float = 15.0 # 迟滞退出带; 偏热侧它同时是降仓命令的触发线
|
||||
PMS_MACRO_CONFIRM_DAYS: int = 1 # 偏冷进区连续 N 个交易日才触发 (标定: 等第二天更差)
|
||||
PMS_MACRO_TRIGGER_HOT: str = "zone_exit" # 偏热口径: 回落再动 (标定: 机会成本最低)
|
||||
PMS_MACRO_TRIGGER_COLD: str = "zone_enter" # 偏冷口径: 进区即动 (标定: 反弹在极值当口)
|
||||
PMS_MACRO_LOG_S0: float = 0.049 # 对数映射 target = S0·ln(1+e/k), 标定回填
|
||||
PMS_MACRO_LOG_K: float = 1.6 # 对数映射尺度 k, 标定回填
|
||||
PMS_MACRO_SHIFT_MAX: float = 0.20 # 单个极值周期累计调整封顶 (占规模百分点)
|
||||
PMS_MACRO_STEP_MIN: float = 0.02 # 裁剪后步长小于此不动; 也是确认当日首步保底
|
||||
PMS_MACRO_COOLDOWN_TDAYS: int = 3 # 同方向两次动作最小间隔 (交易日)
|
||||
PMS_MACRO_MIN_PCT: float = 0.20 # 宏观降仓地板 (用户命令不受此限)
|
||||
PMS_MACRO_MAX_PCT: float = 0.0 # 宏观升仓天花板; 0 = 不单设, 由总仓上限兜底
|
||||
PMS_MACRO_WINDOW_TDAYS: int = 3 # 宏观命令的执行窗口
|
||||
PMS_MACRO_RESPECT_BRAKE: bool = True # 组合刹车期间不自动升仓 (方案唯一假设项)
|
||||
PMS_MACRO_STALE_TDAYS: int = 3 # 任一数据源日龄超此 (交易日) → 信号不可用
|
||||
PMS_MACRO_RET_WIN: int = 20 # 收益计算窗口
|
||||
PMS_MACRO_Z_WIN: int = 40 # 标准化窗口
|
||||
PMS_MACRO_SHIBOR_BETA: float = 0.02 # 利率修正系数 β
|
||||
|
||||
# --- 对账与回放 ---
|
||||
PMS_REPLAY_INTERVAL_MIN: int = 5
|
||||
PMS_RECON_ALARM_DAYS: int = 3 # 连续不一致 N 日升级 ERROR
|
||||
|
|
|
|||
|
|
@ -373,3 +373,23 @@ CREATE TABLE IF NOT EXISTS pms_op_log (
|
|||
KEY idx_op (op),
|
||||
KEY idx_code (ts_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易员操作日志';
|
||||
|
||||
|
||||
-- 18. 宏观信号日快照 —— 宏观择时层 (MACRO_TIMING_PLAN.md; 参数依据 MACRO_CALIB_2026-08-18.md)。
|
||||
-- 每信号每交易日一行, 重扫就地更新。一行四用: 面板历史序列 / 当日建议承载 /
|
||||
-- 冷却与周期已执行量的事实源 / 评审留痕的锚。宏观闸的实时状态在运行参数
|
||||
-- PMS_MACRO_GATE_STATE (不在本表), 本表是历史与审计。
|
||||
CREATE TABLE IF NOT EXISTS pms_macro_signal (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
signal_key VARCHAR(32) NOT NULL COMMENT '信号名, 如 hedge_fx (股汇对冲指数)',
|
||||
trade_date INT NOT NULL COMMENT '北京交易日 YYYYMMDD (评估日; 数据是 T-1 日终)',
|
||||
value DECIMAL(12,4) NULL COMMENT 'hedge_index 值 (z 分数 ×10); NULL = 当日不可用',
|
||||
zone VARCHAR(16) NOT NULL DEFAULT 'NEUTRAL' COMMENT 'HOT(偏热)/COLD(偏冷)/NEUTRAL/UNAVAILABLE',
|
||||
detail_json TEXT NULL COMMENT '中间量: 股汇收益/修正值/超额深度/各源末日/补齐天数/周期状态/建议',
|
||||
action VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT 'NONE/ADVICE_REDUCE/ADVICE_INCREASE/CMD_ISSUED/BLOCKED',
|
||||
ref_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联 command_id (下达或采纳后回填)',
|
||||
note VARCHAR(500) NOT NULL DEFAULT '' COMMENT '当日结论/被拦原因/建议文案',
|
||||
updated_at DATETIME NULL COMMENT '写入或重扫更新时间',
|
||||
UNIQUE KEY uk_sig_date (signal_key, trade_date),
|
||||
KEY idx_key_id (signal_key, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宏观信号日快照与动作记录';
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ PMS_TABLES = ["pms_command", "pms_plan", "pms_position", "pms_lot", "pms_instruc
|
|||
# 14/15 号表。**加表时记得往这里加一行** —— 漏了的话 init_db 建了表、
|
||||
# check_db 却一声不吭, 表结构自检就有个静默的盲区。
|
||||
# (pms_cash_flow 就漏过一轮, 2026-07-31 补上)
|
||||
"pms_cash_flow", "pms_plan_snapshot"]
|
||||
"pms_cash_flow", "pms_plan_snapshot",
|
||||
# 18 号表: 宏观信号日快照 (2026-08-18 宏观择时层)。
|
||||
# 顺带记录既有欠账: 16/17 号表 pms_strategy / pms_op_log 不在本清单里,
|
||||
# 属加表时漏登记 —— 按"不夹带"纪律这次不补, 单独提单独拍板。
|
||||
"pms_macro_signal"]
|
||||
DOWNSTREAM = ["trading_position", "trading_order", "trading_buy_plan"]
|
||||
|
||||
FAILED, WARNED = [], []
|
||||
|
|
|
|||
|
|
@ -19,8 +19,11 @@
|
|||
test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/
|
||||
转多信号插队与留痕/窗口末日只对命令/跳过原因分得开/
|
||||
研判请求体的数据库类型 (44 例)
|
||||
test_batch13_units.py 决策系统卖出采纳: 门槛分档/清场标记/确认加速/闭仓清场 (4 例)
|
||||
test_batch14_units.py 宏观择时: 指数计算与对齐/区域迟滞/周期与对数映射/
|
||||
分方向触发/让路与冷却/宏观闸/建议采纳 (25 例)
|
||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例)
|
||||
共 469 例
|
||||
共 498 例
|
||||
任一子集失败即整体失败 (退出码 1)。
|
||||
"""
|
||||
import os
|
||||
|
|
@ -33,7 +36,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
|
|||
"test_batch4_units.py", "test_batch5_units.py", "test_batch6_units.py",
|
||||
"test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py",
|
||||
"test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py",
|
||||
"test_batch13_units.py", "test_wiring.py"]
|
||||
"test_batch13_units.py", "test_batch14_units.py", "test_wiring.py"]
|
||||
|
||||
|
||||
def main():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -557,7 +557,7 @@ def run():
|
|||
"DEFAULT 'NONE' COMMENT 'NONE/REQUESTED/SENT'"):
|
||||
eq(find_adjacent_literals(good), [], f"误报: {good[:40]}")
|
||||
|
||||
@case("DDL 文件本身体检通过 (17 张表 + 1 条初始行)")
|
||||
@case("DDL 文件本身体检通过 (18 张表 + 1 条初始行)")
|
||||
def _():
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from init_db import DDL_FILE, find_adjacent_literals, parse_statements
|
||||
|
|
@ -569,7 +569,8 @@ def run():
|
|||
eq(find_adjacent_literals(s), [], f"{tbl} 有相邻字面量")
|
||||
# 加表就要来这里 +1 —— 这一行是 DDL 与代码之间唯一的哨兵, 它不动就说明新表没进
|
||||
# ddl_pms_v1.sql (init_db 只认这个文件, 建不出来的表在实机上才会报"表不存在")
|
||||
eq(len([1 for k, _, _ in stmts if k == "table"]), 17)
|
||||
# (2026-08-18: +pms_macro_signal 宏观信号日快照 → 18)
|
||||
eq(len([1 for k, _, _ in stmts if k == "table"]), 18)
|
||||
eq(len([1 for k, _, _ in stmts if k == "seed"]), 1)
|
||||
|
||||
@case("通道三表的 SQL 全部单表合规")
|
||||
|
|
|
|||
|
|
@ -556,11 +556,12 @@ def _():
|
|||
names = set(sch.celery_app.conf.beat_schedule)
|
||||
assert names == {"plan_pull", "premarket", "command_poll", "replay_fills",
|
||||
"intraday_exec", "signal_digest", "t0_close", "daily_settle",
|
||||
"daily_report"}, names
|
||||
"daily_report",
|
||||
"macro_scan"}, names # 2026-08-18 +宏观择时 09:35
|
||||
tasks = set(sch.celery_app.tasks)
|
||||
for t in ("pms.premarket", "pms.command_poll", "pms.replay_fills", "pms.daily_settle",
|
||||
"pms.daily_report", "pms.t0_close", "pms.intraday_exec", "pms.signal_digest",
|
||||
"pms.plan_pull"):
|
||||
"pms.plan_pull", "pms.macro_scan"):
|
||||
assert t in tasks, t
|
||||
assert sch.in_session(datetime(2026, 7, 27, 10, 0)) is True
|
||||
assert sch.in_session(datetime(2026, 7, 27, 12, 0)) is False
|
||||
|
|
|
|||
Loading…
Reference in New Issue