tradingSystem/app/services/consensus_service.py

137 lines
6.8 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""三源合议装配 (2026-09-11 工作包二)。把基本面、技术面、择时三票凑齐, 合成方向与路由。
三个纯逻辑判定在 core (fund_rules / tech_rules / timing_rules / consensus); 本模块负责取数与装配:
基本面 候选行 / 持仓行里带的公司深度评析 (company_review), fund_rules
技术面 tech_service 早上写的映射 (PMS_TECH_STATE_MAP) 里该票的紧凑立场
择时 决策系统昨夜定性 (downstream.fetch_nightly_verdicts strategy_daily_results.signal_type),
加当天盘中转多留痕 (flip_at, 由调用方传入, 暂缺就只按昨夜定性)
装配后给每行凑出四块意见, 再由 hard_keys 折成提议的六个硬数字键 (方案附录丁)
立场词一律中文 (看多/看空/中性/无读数); 任何一路无读数都弃权, 绝不折成看空 (设计原则二)
"""
from __future__ import annotations
import logging
from app.core import consensus, fund_rules, timing_rules, tradedays
from app.core.command_spec import normalize_code
from app.services import param_store, tech_service
logger = logging.getLogger("pms.consensus")
def _params() -> dict:
gi, gb = param_store.get_int, param_store.get_bool
return {
"fund_required": gb("PMS_FUND_REQUIRED", True),
"fund_stale_days": gi("PMS_FUND_STALE_DAYS", 120),
"fund_consensus_good_min": gi("PMS_FUND_CONSENSUS_GOOD_MIN", 8),
"consensus_route": gb("PMS_CONSENSUS_ROUTE", True),
"timing_stale_tdays": gi("PMS_REF_STALE_TDAYS", 3),
}
def _timing_stance(entry, *, today_ymd, stale_tdays, flip_at=None):
"""昨夜定性 entry {verdict, trade_date} → timing_rules 立场; trade_date 超期传 fresh=False。"""
if not entry:
return timing_rules.synthesize(None, intraday_flip_at=flip_at)
fresh = True
td_ = entry.get("trade_date")
if td_ is not None:
try:
ymd = int(str(td_).replace("-", "")[:8])
elapsed = max(0, tradedays.trade_days_left(today_ymd, ymd) - 1)
fresh = elapsed <= int(stale_tdays)
except (ValueError, TypeError):
fresh = True
return timing_rules.synthesize(entry.get("verdict"), intraday_flip_at=flip_at, fresh=fresh)
def nightly_map(codes) -> dict:
"""批量读昨夜定性 {ts_code: {verdict, trade_date}}。取不到返回空, 不抛 (无读数弃权)。"""
try:
from app.repo import downstream_repo
return downstream_repo.fetch_nightly_verdicts(codes)
except Exception as e: # noqa: BLE001
logger.warning("[合议] 昨夜定性取不到: %s", e)
return {}
def assemble(row, *, nightly=None, tech_states=None, flip_at=None, params=None) -> dict:
"""给一行 (候选 / 持仓) 装配四块意见。row 需带 ts_code 与 company_review。不改 row。
nightly: 该票昨夜定性 entry; tech_states: 技术面映射 (state_map); flip_at: 盘中转多时刻"""
p = params or _params()
code = normalize_code(row.get("ts_code") or "")
cr = row.get("company_review") if isinstance(row.get("company_review"), dict) else None
fund = fund_rules.synthesize(cr, params={"stale_days": p["fund_stale_days"],
"consensus_good_min": p["fund_consensus_good_min"]})
tech = (tech_states or {}).get(code) or {"stance": "无读数", "no_read_why": "没有技术面读数"}
tm = _timing_stance(nightly, today_ymd=tradedays.ymd(),
stale_tdays=p["timing_stale_tdays"], flip_at=flip_at)
# 合议参与分流的开关关掉时, 基本面无读数不由合议这层拦 (回到只按判决分流)
con = consensus.decide(fund["stance"], tech.get("stance") or "无读数", tm["stance"],
tech_phase=tech.get("phase"),
fund_required=(p["fund_required"] and p["consensus_route"]))
return {"fund": fund, "tech": tech, "timing": tm, "consensus": con}
def hard_keys(blocks) -> dict:
"""四块意见 → 提议硬数字六键 (方案附录丁)。这六键不进 judge.OPEN_JUDGE_KEYS。"""
fund, tech, tm, con = blocks["fund"], blocks["tech"], blocks["timing"], blocks["consensus"]
return {
"fund_stance": {"stance": fund["stance"], "mark": fund.get("mark"), "fact": fund.get("fact")},
"tech": tech,
"tech_text": tech.get("reason") or tech.get("no_read_why"),
"timing": {"stance": tm["stance"], "nightly": tm.get("nightly"),
"flip_at": tm.get("intraday_flip_at")},
"consensus": {"direction": con["direction"], "votes": con["votes"],
"strength": con.get("strength"), "reason": con.get("reason"),
"route": con.get("route"), "route_reason": con.get("route_reason")},
"consensus_text": con.get("reason"),
}
def state_map():
"""技术面映射, 透传 tech_service.state_map (装配时批量取一次给所有行用)。"""
try:
return tech_service.state_map()
except Exception as e: # noqa: BLE001
logger.warning("[合议] 技术面映射取不到: %s", e)
return {}
def decorate_positions(rows, *, nightly=None, tech_states=None, company_reviews=None,
flip_map=None, params=None):
"""给持仓行装配三源合议四块 (原地改), 挂 consensus_blocks 一键 —— 动作引擎的增持门读它。
持仓票在候选筛选第一步就被整行剔掉, 计划里读不到它, 所以基本面那一票的买方评析要由调用方
从逻辑状态映射里带出 (company_reviews: {code: company_review})取不到就当无读数弃权, 绝不
折成看空任何一行失败只记日志把这一行的 consensus_blocks 抹掉 (按无合议), 页面与扫描不塌
开关的判断由调用方负责 (关掉时根本不调本函数); 这里只管装配"""
rows = rows or []
if not rows:
return rows
p = params or _params()
codes = [normalize_code(r.get("ts_code") or "") for r in rows if r.get("ts_code")]
if nightly is None:
nightly = nightly_map(codes)
if tech_states is None:
tech_states = state_map()
company_reviews = company_reviews or {}
flip_map = flip_map or {}
for r in rows:
code = normalize_code(r.get("ts_code") or "")
if not code:
r.pop("consensus_blocks", None)
continue
try:
row = {"ts_code": code, "company_review": company_reviews.get(code)}
r["consensus_blocks"] = assemble(row, nightly=nightly.get(code),
tech_states=tech_states,
flip_at=flip_map.get(code), params=p)
except Exception as e: # noqa: BLE001
logger.warning("[合议] 持仓装配失败 %s (本行按无合议): %s", code, e)
r.pop("consensus_blocks", None)
return rows