"""环境标签:只读决策系统的日频市场区制接口,写进当日计划快照;只展示与复盘分组,不作交易前置。 ## 为什么要有它 实证(docs/主观选股改进方案_2026-09-02.md 1.8、1.8e):市场环境是第一解释变量,但 "看过去五日或十日涨跌"没有预测力(相关 −0.28、−0.55)。所以桥不自算环境,只读 决策系统已有的、带迟滞的区制判断(主参考指数跌破二十日线且缩量或连续破位判弱势), 把它当标签挂在每张卡上,供复盘按"事前标签"分组。标签有没有预测力由复盘过线条件验证; 过线前不拦任何票,"弱环境不加新票"已退役(09-02 拍板)。 ## 时序 决策系统 08:40 预热当日快照;桥 07:10 出计划时拿不到,标 UNKNOWN。统一调度平台在 08:45 再触发一次追加步骤(xxl.py 的 regime-append),把当日快照写进当天 JSON 的 regime 段; /plan 应答的 regime 段只读那份落盘的快照,盘中不再向来源发请求。 ## 接口契约(桥按此消费;决策系统侧按此实现只读端点) GET {REGIME_API_URL}?date=YYYY-MM-DD -> {"status": "OK" | "UNKNOWN" | "DISABLED", "data_date": "YYYY-MM-DD", "indices": [{"code": "000300.SH", "name": "沪深300", "weak": true, "reason": "..."}], "weak_count": 3, "degraded": false, "computed_at": "..."} DISABLED 归 UNKNOWN 并在 source 里注明总开关关闭。任何失败 -> UNKNOWN,绝不抛错。 """ from __future__ import annotations import datetime as dt import json import os import urllib.parse import urllib.request import config UNKNOWN = "UNKNOWN" def fetch(day: str) -> dict: """读一次当日区制。返回归一后的 regime 段。""" base = {"status": UNKNOWN, "data_date": None, "weak_count": None, "weak_day": None, "indices": [], "degraded": None, "source": None, "fetched_at": dt.datetime.now().isoformat(timespec="seconds"), "weak_threshold": config.REGIME_WEAK_COUNT} if not config.REGIME_API_URL: base["source"] = "unconfigured(决策系统只读区制接口未接入)" return base url = f"{config.REGIME_API_URL}?{urllib.parse.urlencode({'date': day})}" base["source"] = url try: with urllib.request.urlopen(url, timeout=config.REGIME_API_TIMEOUT) as resp: payload = json.loads(resp.read().decode("utf-8", "replace")) except Exception as e: # noqa: BLE001 —— 来源不可达就是 UNKNOWN,不拦票 base["source"] = f"{url}(不可达: {type(e).__name__})" return base if not isinstance(payload, dict): return base status = str(payload.get("status") or UNKNOWN).upper() if status == "DISABLED": base["source"] += "(总开关关闭)" status = UNKNOWN # 两种形状都收:契约形状 indices 为列表;决策系统缓存快照的原始形状 indices 为 # {指数代码: {name, weak, weak_via, qrs_stale, ...}}、广度字段叫 breadth_weak。 raw_idx = payload.get("indices") if isinstance(raw_idx, dict): indices = [{"code": c, **(v if isinstance(v, dict) else {})} for c, v in raw_idx.items()] elif isinstance(raw_idx, list): indices = [x for x in raw_idx if isinstance(x, dict)] else: indices = [] weak_count = payload.get("weak_count") if weak_count is None: weak_count = payload.get("breadth_weak") if weak_count is None and indices: weak_count = sum(1 for x in indices if x.get("weak")) degraded = payload.get("degraded") if degraded is None and indices: degraded = any(x.get("qrs_stale") for x in indices) base.update({ "status": "OK" if status == "OK" else UNKNOWN, "data_date": payload.get("data_date"), "weak_count": weak_count, "weak_total": payload.get("breadth_total") or (len(indices) or None), "indices": [{"code": x.get("code"), "name": x.get("name"), "weak": bool(x.get("weak")), "weak_via": x.get("weak_via"), "qrs_stale": x.get("qrs_stale"), "dev_pct": x.get("dev_pct")} for x in indices], "degraded": bool(degraded) if degraded is not None else None, "computed_at": payload.get("computed_at"), }) if base["status"] == "OK" and isinstance(weak_count, int): base["weak_day"] = weak_count >= config.REGIME_WEAK_COUNT return base def snapshot_path(day: str) -> str: return os.path.join(config.PLAN_SNAPSHOT_DIR, f"plan_{day}.json") def append_to_snapshot(day: str) -> dict: """08:45 追加步骤:把当日区制写进当天 JSON 快照的 regime 段。 快照不存在(当日构建失败或 07:10 之前)就只打印,不创建空快照。""" reg = fetch(day) path = snapshot_path(day) if not os.path.exists(path): print(f"快照 {path} 不存在,环境段未写入(状态 {reg['status']})") return reg with open(path, "r", encoding="utf-8") as f: doc = json.load(f) doc["regime"] = reg tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(doc, f, ensure_ascii=False, indent=1) os.replace(tmp, path) print(f"环境段已写入 {path}:status={reg['status']} weak_count={reg['weak_count']} " f"weak_day={reg['weak_day']}") return reg def read_from_snapshot(day: str) -> dict | None: """/plan 用:只读当日快照里的 regime 段,没有就 None(应答里给 UNKNOWN)。""" path = snapshot_path(day) try: with open(path, "r", encoding="utf-8") as f: return json.load(f).get("regime") except (OSError, ValueError): return None