539 lines
26 KiB
Python
539 lines
26 KiB
Python
# -*- 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}
|