189 lines
9.0 KiB
Python
189 lines
9.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
择时实现A客户端 · 委托决策系统 (设计 §8, 待办 #9)
|
|
==================================================
|
|
接口契约见 BIONIC_PMS_INTERFACE.md。决策系统按它凌晨算好的支撑/压力推出买入区间与
|
|
卖出区间, 盘中只回答「现价在不在区间内 + 限价挂哪里」, 不做任何新的盘中判断
|
|
(2026-08-03 用户定的原则: 提前计算为主、盘中监控为辅, 可以接受买不上)。
|
|
|
|
分工与降级 (三条, 都是纪律不是实现细节):
|
|
1. **本地检查先行**: 配额/兜底/停牌/一字板/不追高(当日涨幅) 由 core.exec_timing 的
|
|
hard_gate 先判, 命中就不咨询 —— 这些检查始终留在 PMS 本地。尤其 14:45 兜底:
|
|
到点必须完成, 决策系统说什么都不算。
|
|
2. **拿不到不等于有答案**: 咨询失败/超时/对端回 UNAVAILABLE/答复无法识别 → 本轮
|
|
整体退实现B (设计 §13「决策系统择时不可用 → 择时退实现B」), 并进入冷却期
|
|
(冷却内不再咨询, 免得每分钟 tick 都白等一次超时)。绝不把「拿不到」当成 FIRE 或 WAIT。
|
|
3. **应答带有效期**: 结果缓存在指令 progress.exec_advice 里 (随既有落表持久化,
|
|
页面/t-ins 可见), 有效期内不重复咨询。对端可用 valid_min 缩短有效期, 只缩不放。
|
|
|
|
默认 PMS_EXEC_IMPL=B —— 本模块整个短路, run_tick 行为与接通前一字不差。
|
|
页面把 PMS_EXEC_IMPL 改成 A (并保证 PMS_EXEC_API_BASE 或 PMS_JUDGE_API_BASE 已填)
|
|
即切实现A, 随时可改回 B, 不需要重启。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from app.core import exec_timing as et
|
|
from app.core import tradedays as td
|
|
from app.services import judge, param_store
|
|
|
|
logger = logging.getLogger("pms.exec_advisor")
|
|
|
|
IMPL_A, IMPL_B = "A", "B"
|
|
FIRE, WAIT = "FIRE", "WAIT"
|
|
|
|
|
|
def impl() -> str:
|
|
v = str(param_store.get("PMS_EXEC_IMPL", IMPL_B) or IMPL_B).strip().upper()
|
|
return v if v in (IMPL_A, IMPL_B) else IMPL_B
|
|
|
|
|
|
def base_url() -> str:
|
|
"""实现A的接口根地址; PMS_EXEC_API_BASE 留空时沿用研判闸的 PMS_JUDGE_API_BASE
|
|
(两者是同一个 bionic 服务, 不逼着用户填两遍)。"""
|
|
b = (param_store.get("PMS_EXEC_API_BASE", "") or "").strip().rstrip("/")
|
|
return b or judge.base_url()
|
|
|
|
|
|
def available() -> bool:
|
|
return impl() == IMPL_A and bool(base_url())
|
|
|
|
|
|
def status() -> dict:
|
|
"""页面/排查用的一句话状态。"""
|
|
if impl() != IMPL_A:
|
|
return {"impl": IMPL_B, "available": False,
|
|
"note": "内置保守择时 (PMS_EXEC_IMPL=B)。切实现A: 页面改 PMS_EXEC_IMPL=A"}
|
|
if not base_url():
|
|
return {"impl": IMPL_A, "available": False,
|
|
"note": "PMS_EXEC_IMPL=A 但接口地址为空 (PMS_EXEC_API_BASE 与 "
|
|
"PMS_JUDGE_API_BASE 都没填) —— 实际全程退实现B"}
|
|
return {"impl": IMPL_A, "available": True, "base": base_url(),
|
|
"path": param_store.get("PMS_EXEC_PATH", "/api/intraday/pms_exec"),
|
|
"ttl_min": param_store.get_int("PMS_EXEC_ADVICE_TTL_MIN", 10)}
|
|
|
|
|
|
def _post(url: str, payload: dict, timeout: int) -> dict:
|
|
"""HTTP 一跳, 单测在这里打桩。"""
|
|
import requests
|
|
r = requests.post(url, json=payload, timeout=timeout)
|
|
r.raise_for_status()
|
|
return r.json() or {}
|
|
|
|
|
|
def decide(*, side: str, action: str, ts_code: str, now, day: dict, params: dict,
|
|
is_last_day: bool, fired_today: int = 0, quota: int = 0,
|
|
pos: dict = None, tdays_left=None, prog: dict = None) -> dict:
|
|
"""择时判定统一入口 (executor.run_tick 的唯一调用点)。
|
|
|
|
返回结构与 exec_timing.decide 相同, 另带 source 字段标明这条决定是谁做的:
|
|
B 实现B (默认档位, 或实现A未配置)
|
|
guard 本地事实性检查 (配额/兜底/停牌/一字板/不追高) —— 与实现无关
|
|
A / A缓存 决策系统应答 (新咨询 / 有效期内复用)
|
|
B(实现A不可用: ...) 咨询失败退实现B, 括号里是原因
|
|
prog 由调用方传入指令的 progress dict, 咨询结果/失败冷却会写进 prog["exec_advice"],
|
|
随调用方既有的落表动作持久化; 传 None 则本轮结论不缓存 (dry_run 语义)。
|
|
"""
|
|
if not available():
|
|
d = et.decide(side=side, now=now, day=day, params=params, is_last_day=is_last_day,
|
|
fired_today=fired_today, quota=quota)
|
|
d["source"] = IMPL_B
|
|
return d
|
|
|
|
left = max(0, int(quota) - int(fired_today))
|
|
h = et.hard_gate(side=side, now=now, day=day, params=params, is_last_day=is_last_day,
|
|
fired_today=fired_today, quota=quota)
|
|
if h is not None:
|
|
h["source"] = "guard" # 本地事实性检查 (配额/兜底等), 与实现无关
|
|
return h
|
|
|
|
advice, note = _advice(ts_code=ts_code, side=side, action=action, now=now, day=day,
|
|
pos=pos or {}, left=left, is_last_day=is_last_day,
|
|
tdays_left=tdays_left, prog=prog)
|
|
if advice is not None:
|
|
d = et.apply_advice(side=side, day=day, params=params, advice=advice, left=left)
|
|
if d is not None:
|
|
d["source"] = advice.get("_source") or "A"
|
|
return d
|
|
note = f"研判动作无法识别: {advice.get('verdict')!r}"
|
|
|
|
d = et.decide(side=side, now=now, day=day, params=params, is_last_day=is_last_day,
|
|
fired_today=fired_today, quota=quota)
|
|
d["source"] = f"B(实现A不可用: {note})"
|
|
return d
|
|
|
|
|
|
def _advice(*, ts_code, side, action, now, day, pos, left, is_last_day, tdays_left, prog):
|
|
"""取一份有效研判: 缓存命中 → 直接用; 冷却中 → (None, 原因); 否则咨询一次。
|
|
返回 (advice|None, 不可用原因)。advice 带 _source 标明 A / A缓存。"""
|
|
now_min = et.hm_to_min(now)
|
|
today = td.ymd()
|
|
ttl = max(1, param_store.get_int("PMS_EXEC_ADVICE_TTL_MIN", 10))
|
|
cached = dict((prog or {}).get("exec_advice") or {})
|
|
|
|
if int(cached.get("ymd") or 0) == today:
|
|
if (cached.get("verdict") in (FIRE, WAIT)
|
|
and now_min <= int(cached.get("valid_until_min") or -1)):
|
|
c = dict(cached)
|
|
c["_source"] = "A缓存"
|
|
return c, ""
|
|
if cached.get("fail_until_min") and now_min <= int(cached["fail_until_min"]):
|
|
return None, (f"冷却至 {et._fmt(int(cached['fail_until_min']))}: "
|
|
f"{cached.get('error') or '上次咨询失败'}")
|
|
|
|
payload = {
|
|
"direction": "PMS_EXEC", "ts_code": ts_code, "side": side, "action": action,
|
|
"qty_left": left, "is_last_day": bool(is_last_day), "tdays_left": tdays_left,
|
|
"now": et._fmt(now_min),
|
|
"day": {k: day.get(k) for k in ("price", "vwap", "open", "high", "low",
|
|
"day_chg_from_open", "bars")},
|
|
"refs": {"support": pos.get("support_ref"), "pressure": pos.get("pressure_ref"),
|
|
"stop": pos.get("stop_ref"), "source": pos.get("ref_source")},
|
|
"position": {"total_qty": pos.get("total_qty"), "avail_qty": pos.get("avail_qty"),
|
|
"avg_cost": pos.get("avg_cost"), "cushion_pct": pos.get("cushion_pct")},
|
|
}
|
|
to = max(1, param_store.get_int("PMS_EXEC_TIMEOUT_SEC", 8))
|
|
url = base_url() + (param_store.get("PMS_EXEC_PATH", "/api/intraday/pms_exec") or "")
|
|
|
|
def _cool(err: str):
|
|
cool = max(1, param_store.get_int("PMS_EXEC_FAIL_COOLDOWN_MIN", 5))
|
|
if prog is not None:
|
|
prog["exec_advice"] = {"ymd": today, "error": err[:200],
|
|
"fail_until_min": et.add_trade_minutes(now_min, cool),
|
|
"consulted_at": et._fmt(now_min)}
|
|
|
|
try:
|
|
data = _post(url, payload, to)
|
|
except Exception as e:
|
|
err = f"{type(e).__name__}: {e}"
|
|
logger.error("[择时A] 咨询失败, 本轮退实现B (%s %s): %s", ts_code, side, err)
|
|
_cool(err)
|
|
return None, err
|
|
|
|
verdict = str(data.get("verdict") or "").strip().upper()
|
|
if verdict not in (FIRE, WAIT):
|
|
# 对端明说给不出结论 (UNAVAILABLE), 或答复不认识 —— 都按拿不到处理, 退实现B
|
|
reason = str(data.get("reason") or f"verdict={verdict or '空'}")[:200]
|
|
logger.warning("[择时A] 决策系统给不出结论 (%s %s): %s —— 本轮退实现B",
|
|
ts_code, side, reason)
|
|
_cool(f"UNAVAILABLE: {reason}")
|
|
return None, f"UNAVAILABLE: {reason}"
|
|
|
|
try:
|
|
valid_min = int(data.get("valid_min") or ttl)
|
|
except (TypeError, ValueError):
|
|
valid_min = ttl
|
|
valid_min = max(1, min(valid_min, ttl)) # 对端只能缩短有效期, 不能放长
|
|
adv = {"ymd": today, "verdict": verdict,
|
|
"limit_price": data.get("limit_price"),
|
|
"reason": str(data.get("reason") or "")[:200],
|
|
"confidence": data.get("confidence"),
|
|
"valid_until_min": et.add_trade_minutes(now_min, valid_min),
|
|
"consulted_at": et._fmt(now_min)}
|
|
if prog is not None:
|
|
prog["exec_advice"] = adv
|
|
a = dict(adv)
|
|
a["_source"] = "A"
|
|
return a, ""
|