137 lines
6.8 KiB
Python
137 lines
6.8 KiB
Python
# -*- 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
|