tradingSystem/app/services/logic_state_service.py

271 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""逻辑状态四态在 PMS 的落点 (2026-09-07 下一阶段方案第三件 PMS 侧)。
选股系统每天早上给每只票算一个「支撑它的研究证据还在不在」的状态 —— 逻辑强化 / 逻辑成立 /
无法判断 / 逻辑存疑 (它那边的 logic_state.py), 随计划每行下发, 另有一个按代码查询的接口给持仓票用
(持仓票在候选筛选第一步就被整行剔掉, 计划里读不到它)。PMS 只读它, 不算它, 也不送研判 —— 产业逻辑
不归择时决策系统判, judge.OPEN_JUDGE_KEYS 不收它, 那是一致性检查表第九行要继续成立的东西。
四态里只有「逻辑存疑」会改分流通道, 其余三个值不产生任何动作 (收敛规则是单调的: 强化不升判决,
存疑不把仅展示变成可执行)。本模块做四件事:
一, 早上拉完计划后 (scheduler.plan_pull), 对在持的票查一次状态, 写进运行参数 PMS_LOGIC_STATE_MAP。
取不到就留空 (写一份空映射并记原因), **绝不折成存疑**。
二, 按状态暂停或恢复策略腿的买入: 存疑 → strategy_service.pause_buy(source="logic");
已明确不存疑 → 只清本来源的暂停, 不动风控 (signal) 与定性 (accum) 停的; 没查到的票不动。
三, 把当日态挂到持仓行上 (attach), 动作引擎据此停增持侧的自主动作、按开关出研究走弱的减持
(那条减持带 research_weak 来源, 分流那一步一票否决强制交人, 绝不自动卖)。
四, 持仓视图的两栏: 「当初为什么买」(账本里首批入场那条记录的判决依据与论断) 与
「现在证据还在不在」(今天的状态与出处)。
安全方向: 读不到状态 = 没有读数, 什么都不额外拦, 也不解除已有的暂停。总开关
PMS_LOGIC_STATE_ROUTE 关掉即回到只按判决分流; 减持提议另有 PMS_LOGIC_DOUBT_TRIM, 默认关。
"""
from __future__ import annotations
import json
import logging
from collections import Counter
from datetime import date, datetime
from app.core import action_engine as ae
from app.repo import pms_repo
from app.services import param_store, plan_feed, strategy_service
logger = logging.getLogger("pms.logic")
MAP_KEY = "PMS_LOGIC_STATE_MAP"
PAUSE_SOURCE = "logic" # 策略买入暂停表里本模块用的来源名, 与 signal / accum 并列
FRESH_DAYS = 3 # 映射超过这么多个自然日没刷新就当没有读数 (调度断了不能拿旧态拦人)
# 入场那条账本记录里, 只要带了这几个键之一就算「有研究理由」
_ENTRY_KEYS = ("basis", "logic", "verdict", "logic_state")
# ================================================================ 运行参数里的映射
def load_map() -> dict:
"""整份映射 (含时刻与错误), 页面与运维看。读不到或坏了返回空字典。"""
raw = param_store.get(MAP_KEY, "") or ""
if not raw:
return {}
try:
m = json.loads(raw)
except (TypeError, ValueError):
return {}
return m if isinstance(m, dict) else {}
def state_map(m: dict | None = None) -> dict:
"""{ts_code: 逻辑状态} —— 只在映射新鲜时给; 超过 FRESH_DAYS 个自然日没刷新一律按没有读数。"""
m = load_map() if m is None else (m or {})
at = str(m.get("at") or "")[:10]
try:
age = (date.today() - date.fromisoformat(at)).days
except ValueError:
return {}
if age > FRESH_DAYS:
return {}
states = m.get("states")
return dict(states) if isinstance(states, dict) else {}
def _save_map(m: dict) -> dict:
r = param_store.set_param(MAP_KEY, json.dumps(m, ensure_ascii=False), "system")
if not r.get("ok"):
# 写不进要吭声: 映射停在旧值, 明天早上以前扫描层看到的都是昨天的态
logger.error("[逻辑状态] 映射写入失败: %s —— 扫描层沿用上一份 (最多 %d 天后自动失效)",
r.get("error"), FRESH_DAYS)
return r
def route_enabled() -> bool:
return param_store.get_bool("PMS_LOGIC_STATE_ROUTE", True)
# ================================================================ 早上那一步
def pull_for_held(now=None, fetch=None) -> dict:
"""在持票的逻辑状态取回一次并落映射, 再按结果暂停或恢复策略买入腿。
fetch 可注入 (单测)。任何一步失败都不抛: 取数失败写空映射带原因 (扫描层按没有读数处理),
暂停那一步失败只记进 errors。返回一份读数给调度任务写日志。
"""
now = now or datetime.now()
stamp = now.strftime("%Y-%m-%d %H:%M:%S")
out = {"ok": True, "at": stamp, "held": 0, "got": 0, "by_state": {}, "paused": [],
"resumed": [], "errors": []}
try:
held = [r["ts_code"] for r in pms_repo.list_positions(only_open=True)]
except Exception as e: # noqa: BLE001
held = []
out["errors"].append(f"读持仓失败: {type(e).__name__}: {e}")
out["held"] = len(held)
if not held:
_save_map({"at": stamp, "date": None, "states": {}, "note": "没有在持的票"})
return out
try:
states = (fetch or plan_feed.fetch_logic_states)(held)
except Exception as e: # noqa: BLE001
# 取不到就留空, 绝不折成存疑; 也不动已有的暂停 (没有新证据不解除)
_save_map({"at": stamp, "date": None, "states": {}, "error": str(e)[:300]})
out.update(ok=False)
out["errors"].append(f"查逻辑状态失败: {e}")
return out
day = next((s.get("date") for s in states.values() if s.get("date")), None)
r = _save_map({"at": stamp, "date": day, "states": states})
if not r.get("ok"):
out["errors"].append(f"映射写入失败: {r.get('error')}")
out["got"] = len(states)
out["by_state"] = dict(Counter(s.get("state") or "" for s in states.values()))
missing = [c for c in held if c not in states]
if missing:
out["missing"] = missing
p = apply_pauses(states, held)
out["paused"], out["resumed"] = p["paused"], p["resumed"]
out["errors"].extend(p["errors"])
if p.get("skipped"):
out["pause_skipped"] = p["skipped"]
return out
def apply_pauses(states: dict, held) -> dict:
"""存疑 → 暂停策略买入腿 (来源 logic); 明确不存疑 → 只清本来源的暂停; 没查到的票不动。"""
out = {"paused": [], "resumed": [], "errors": []}
if not route_enabled():
out["skipped"] = "PMS_LOGIC_STATE_ROUTE 关着, 不动策略买入腿"
return out
for code in held or []:
st = (states or {}).get(code)
if not st:
continue # 没有读数: 不拦也不放
try:
if ae.is_logic_doubt(st):
strategy_service.pause_buy(
code, reason=ae.logic_doubt_text(st, ae.WHY_LOGIC_DOUBT_PAUSE),
source=PAUSE_SOURCE)
out["paused"].append(code)
else:
r = strategy_service.clear_buypause(code, only_source=PAUSE_SOURCE) or {}
if r.get("cleared"):
out["resumed"].append(code)
except Exception as e: # noqa: BLE001
out["errors"].append(f"{code} 策略买入腿处理失败: {type(e).__name__}: {e}")
return out
# ================================================================ 扫描层
def attach(rows: list, states: dict | None = None) -> list:
"""把当日逻辑状态挂到持仓行上 (原地改)。没有读数的票不挂键, 动作引擎按「没有读数」处理。"""
states = state_map() if states is None else (states or {})
for r in rows or []:
st = states.get(r.get("ts_code"))
if st:
r["logic_state"] = st
else:
r.pop("logic_state", None)
return rows
# ================================================================ 持仓视图两栏
def now_view(st) -> dict:
"""「现在证据还在不在」: 今天的状态、截止日、落定说明与第一条出处; 没有读数就说没有。"""
if not isinstance(st, dict) or not st.get("state"):
return {"state": None, "text": "今天没有读数(早上拉计划时没查到这只票)"}
reasons = [str(x) for x in (st.get("reasons") or []) if x]
return {"state": st.get("state"), "raw_state": st.get("raw_state"),
"as_of": st.get("as_of"), "settle_note": st.get("settle_note"),
"text": (reasons[0] if reasons else (st.get("why") or ""))[:160]}
def company_view(st):
"""持仓页「公司质地 · 失效条件」一行 (2026-09-09 接入方案): 质地档、三类、置信度、失效条件、报告链接。没读数返回 None。"""
cr = (st or {}).get("company_review") if isinstance(st, dict) else None
if not isinstance(cr, dict) or not cr.get("overall"):
return None
g = cr.get("groups") or {}
return {"overall": cr.get("overall"), "valuation": cr.get("valuation"), "period": cr.get("period"),
"line": f"质地{cr.get('overall')}(回报{g.get('回报与护城河') or ''}、盈余{g.get('盈余质量与财务安全') or ''}、成长{g.get('成长与含金量') or ''}"
f",估值{cr.get('valuation') or ''}" + (f",置信{cr['confidence']}" if cr.get("confidence") else ""),
"invalidation": cr.get("invalidation"), "doubt_hard": bool(cr.get("doubt_hard")),
"report_url": cr.get("report_url"), "text": (st or {}).get("company_review_text")}
def ref_target_view(st):
"""参考目标价: 第四件的中性情景估值 (中位每股收益乘中位市盈率), 2026-09-07 拍板在持仓页显示,
只显示不触发, 不替代用户手设的目标价 (到价提议只认命令表里那个)。算不出就不给。"""
v = st.get("valuation") if isinstance(st, dict) and isinstance(st.get("valuation"), dict) else None
if not v or v.get("na") or v.get("neut") is None:
return None
return {"price": v["neut"], "pess": v.get("pess"), "opt": v.get("opt"), "quarter": v.get("quarter"),
"firms": v.get("firms"), "as_of": v.get("as_of"), "text": st.get("valuation_text")}
def entry_view(ts_code: str) -> dict:
"""「当初为什么买」: 首批未平批次 → 它的指令 → 该指令或它来自的提议在账本上的放行记录 → 硬数字里的
判决依据、论断、判决与入场时的逻辑状态。三种情形都要说得出话: 有指令链、外部成交并入、账本无行。"""
try:
lots = pms_repo.list_lots(ts_code, status="OPEN", limit=50)
except Exception as e: # noqa: BLE001
return {"why": f"批次读取失败: {type(e).__name__}"}
if not lots:
return {"why": "没有未平的批次记录"}
first = lots[0]
open_date = str(first.get("open_date") or "")[:10]
iid = first.get("instruction_id")
if not iid:
return {"why": "外部成交并入的持仓,没有系统的入场记录", "open_date": open_date}
refs = [str(iid)]
try:
ins = pms_repo.get_instruction(str(iid)) or {}
except Exception: # noqa: BLE001
ins = {}
for k in (ins.get("origin_id"), (ins.get("progress") or {}).get("from_proposal")):
if k and str(k) not in refs:
refs.append(str(k))
try:
rows = pms_repo.ledger_by_ref(refs)
except Exception as e: # noqa: BLE001
return {"why": f"账本读取失败: {type(e).__name__}", "open_date": open_date}
hit = None
for r in rows:
hn = r.get("hard_numbers") or {}
if r.get("verdict") == "PASS" and any(hn.get(k) for k in _ENTRY_KEYS):
hit = r
break
if not hit:
return {"why": "账本里没有这次入场的研究理由(命令建仓或策略买入不带候选卡)",
"open_date": open_date, "instruction_id": str(iid)}
hn = hit.get("hard_numbers") or {}
st = hn.get("logic_state") if isinstance(hn.get("logic_state"), dict) else {}
return {"basis": hn.get("basis"), "logic": list(hn.get("logic") or [])[:3],
"verdict": hn.get("verdict"), "logic_state_at_entry": st.get("state"),
"decided_at": str(hit.get("decided_at") or "")[:16],
"arbiter": hit.get("arbiter"), "reason": str(hit.get("reason") or "")[:200],
"open_date": open_date, "instruction_id": str(iid)}
def decorate_positions(rows: list, states: dict | None = None) -> list:
"""给持仓接口的每一行加 entry 与 logic_now 两块 (原地改)。任何一行失败只写原因, 页面不能塌。"""
states = state_map() if states is None else (states or {})
for r in rows or []:
code = r.get("ts_code")
try:
r["logic_now"] = now_view(states.get(code))
except Exception as e: # noqa: BLE001
r["logic_now"] = {"state": None, "text": f"读数处理失败: {type(e).__name__}"}
try:
r["ref_target"] = ref_target_view(states.get(code))
except Exception: # noqa: BLE001
r["ref_target"] = None
try:
r["company"] = company_view(states.get(code))
except Exception: # noqa: BLE001
r["company"] = None
if int(r.get("total_qty") or 0) <= 0:
r["entry"] = None
continue
try:
r["entry"] = entry_view(code)
except Exception as e: # noqa: BLE001
r["entry"] = {"why": f"入场记录处理失败: {type(e).__name__}"}
return rows