akg-factor-bridge/sources.py

797 lines
40 KiB
Python
Raw Normal View History

"""候选卡的取数层:把各条证据线从三处库读成"按前缀码索引的字典"(全部只读)。
## 为什么要有它
候选卡的规则在 card.py是纯函数证据从哪来怎么对齐代码格式缺了怎么办
全部收在这里plan.py 只做装配三处来源
基座 PG v_factor_transmission_moved 已启动成员及其所在环节的传导证据只认 Segment 目标
v_factor_stock_daily 数据日涨幅主力净额异常值热度变化
153 代理 strategy_daily_results 决策系统昨夜结论信号支撑压力位吸筹块
吸筹的评分状态评分日三项一次读齐基座落库的吸筹版本没有评分日所以不从基座取
平台 MySQL gp_day_data 交易日历算评分日龄用取一只长期存在的票的日期序列
基座 PG v_factor_logic 数据基座抽取的因果论断方向机制时效出处每票最多几条
只展示不作门槛2026-09-03 方案第 3.3
平台 MySQL zs_day_data / eastmoney_rzrq_data / fear_greed_index
计划环境段的市场四项两市成交额融资余额恐贪指数
市场广度从基座 v_factor_stock_daily 当日行自算同一节
基座 PG v_factor_judgement 产业研判与环节评析的最新一版结论采信倾向多空条数
自我校验材料指纹每个计划日抄一份存版本史judgement.py
代码格式基座是点后缀式 600000.SH决策系统与桥是前缀式 SH600000进出都过 common.to_prefix
读失败的语义每一路读不到都返回空字典并打印一行原因候选卡按"缺失"处理进关注或仅展示
不让计划断产 pool.py 的安全边界一致
离线单测logic_claims market_context 都接受注入的读函数read_pg / read_mysql
test_market_context.py 用假数据函数替换真实连接不连库两者内部只对"记录列表"做计算
读函数返回 DataFrame 或普通的字典列表都可以 _records
"""
from __future__ import annotations
import datetime as dt
import json
import statistics
逻辑状态四态接进计划装配,结论随每张卡发给下游 此前四态只是一个纯函数模块,结论躺在库里没人读。这次接上: 计划装配时每只票算一次四态,三路输入分别是研报论断(已有)、产业研判(读行业观点 快照,按环节名对上今日被指向的环节)、券商行动(同财年同预测期的每股收益预测中位数 与覆盖机构数,两个等长窗口比较)。公司事件那一路无数据源,恒出缺失,但照样记名—— 缺失要让人看得见系统缺的是什么,不能让人以为系统判过了。 两处取数搬进公用的地方,免得读数脚本和计划各写一套:行业观点快照的当日读取进 judgement.py,券商行动进 sources.py。读数脚本改调它们,自己那两份删掉。 修了一处日期口径错误:行业观点快照按计划日落库,行情与论断按数据日取,两者在生产里 差一天。原先用同一个日期取三样东西,结果是快照首日读到空。 另修一处:读数脚本原先只读含已启动成员的窄传导视图,而候选卡认的是完整那张, 算出来的产业研判覆盖比卡上真实看到的低。两边现在同一口径。 接口每行新增逻辑状态:状态、子因、每路的来龙去脉与截止日。不发权重、不发判决改动—— 四态怎么作用于建仓通道是 PMS 那边的事,这里只提供状态与出处。内部中间量不外泄。 测试十七例,重点钉住四态不改判决:四个状态乘三个判决十二种组合逐个扫过,判决一次 都没被动过。这是收敛规则单调性的守卫。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:46:01 +08:00
from collections import defaultdict
import pandas as pd
import common
import config
import db
# 上证指数与深证成指在指数日线表 zs_day_data 里的代码,两市成交额取二者当日 amount 之和。
MARKET_INDEX_CODES = ("000001.SH", "399001.SZ")
# 市场广度里"涨停家数"的近似口径:涨幅达到 9.8%(不读涨跌停价表,与环节日行情视图注释一致)。
LIMIT_UP_PCT = 9.8
# 融资表与恐贪指数表的日期列名不在本仓库内核实过,按候选名逐个匹配(第一个命中的用)。
_DATE_COL_CANDIDATES = ("trade_date", "date", "stat_date", "data_date", "report_date",
"dt", "timestamp", "update_date", "record_date", "created_at")
def moved_members(ds: str) -> dict[str, dict]:
"""数据日 ds 被传导指向的环节里,已启动(不在未动名单)的成员。
同一票挂在多个被指向环节上时取源数最多其次链符最高的那条作卡上的证据"""
try:
df = db.read_pg(
"SELECT ts_code, target, n_sources, chain_fit, members_total, moved, "
"moved_ratio, mkt_trade_date FROM v_factor_transmission_moved "
"WHERE scan_date = %s", (ds,))
except Exception as e: # noqa: BLE001
print(f" (已动成员视图读取失败,候选卡的传导门槛整体缺席: {e!r}")
return {}
if df.empty:
return {}
df["k"] = df["ts_code"].map(lambda s: common.to_prefix(str(s).strip()))
df["n_sources"] = pd.to_numeric(df["n_sources"], errors="coerce").fillna(0)
df["chain_fit"] = pd.to_numeric(df["chain_fit"], errors="coerce").fillna(0)
df = df.sort_values(["n_sources", "chain_fit"], ascending=False).drop_duplicates("k")
out = {}
for r in df.itertuples():
out[r.k] = {"theme": str(r.target), "n_sources": int(r.n_sources),
"chain_fit": float(r.chain_fit),
"members_total": None if pd.isna(r.members_total) else int(r.members_total),
"moved": None if pd.isna(r.moved) else int(r.moved),
"mkt_trade_date": None if pd.isna(r.mkt_trade_date) else str(r.mkt_trade_date)}
return out
def stock_daily(ds: str) -> dict[str, dict]:
"""数据日 ds 的个股行情三列:涨幅(百分数)、主力净额异常值、热度五日变化。"""
try:
df = db.read_pg(
"SELECT ts_code, pct_change, net_z, heat_chg FROM v_factor_stock_daily "
"WHERE trade_date = %s", (ds,))
except Exception as e: # noqa: BLE001
print(f" (个股日行情视图读取失败,候选卡的已启动门槛整体缺席: {e!r}")
return {}
out = {}
for r in df.itertuples():
k = common.to_prefix(str(r.ts_code).strip())
out[k] = {"pct0": _f(r.pct_change), "net_z": _f(r.net_z), "heat_chg": _f(r.heat_chg)}
return out
def trading_days(upto: str, back_days: int = 90) -> list[str]:
"""交易日历:取一只长期存在的票在 gp_day_data 里的日期序列(表有一千四百万行,
全表 DISTINCT 太慢 symbol 走索引返回升序 ISO 日期串"""
start = (dt.date.fromisoformat(upto) - dt.timedelta(days=back_days)).isoformat()
try:
df = db.read_mysql(
"factor", "SELECT DISTINCT DATE(`timestamp`) d FROM gp_day_data "
"WHERE symbol = %s AND `timestamp` >= %s AND `timestamp` <= %s "
"ORDER BY d", ("SH600519", start, upto))
return [pd.Timestamp(x).date().isoformat() for x in df["d"].tolist()]
except Exception as e: # noqa: BLE001
print(f" 交易日历读取失败评分日龄按自然日×5/7 近似: {e!r}")
return []
def night_conclusions(codes, ds: str) -> dict[str, dict]:
"""决策系统昨夜结论(每票最新一行):信号、支撑压力位、吸筹块与评分日龄。
评分日龄 = 结论行的 trade_date 到数据日 ds 之间的交易日数含头不含尾
该表会被盘中补扫就地改写没有落库时刻列日龄以行的 trade_date 为准
这是已知局限方案 2.2 第三项"""
codes = sorted({common.to_prefix(str(c).strip()) for c in codes if c})
if not codes:
return {}
# 只取不晚于数据日 ds 的结论行:生产上 ds 就是数据日,与"取最新一行"等价;
# 复盘重建历史日时这一条防前视(否则历史日会读到今天的吸筹状态)。
# trade_date 是整数 YYYYMMDD数据源盘点 §1a直接比大小。
ds_int = int(ds.replace("-", "")) if ds else 99999999
try:
marks = ",".join(["%s"] * len(codes))
df = db.read_mysql(
"pms",
f"SELECT stock_code, trade_date, signal_type, support_level, pressure_level, "
f"raw_logic_json FROM strategy_daily_results "
f"WHERE stock_code IN ({marks}) AND trade_date <= %s",
tuple(codes) + (ds_int,))
except Exception as e: # noqa: BLE001
print(f" (决策系统结论表读取失败,吸筹确认线与坏信号风险整体缺席: {e!r}")
return {}
if df.empty:
return {}
df = df.sort_values("trade_date").drop_duplicates("stock_code", keep="last")
cal = trading_days(ds)
cal_index = {d: i for i, d in enumerate(cal)}
out = {}
for r in df.itertuples():
k = common.to_prefix(str(r.stock_code).strip())
tdate = _ymd(r.trade_date)
age = _age(tdate, ds, cal_index)
ff = {}
try:
raw = json.loads(r.raw_logic_json) if isinstance(r.raw_logic_json, str) else (r.raw_logic_json or {})
ff = (raw or {}).get("fund_flow") or {}
except Exception: # noqa: BLE001 —— 坏 JSON 当无吸筹块
ff = {}
out[k] = {
"signal": str(r.signal_type or "").strip().upper() or None,
"support": _f(r.support_level), "pressure": _f(r.pressure_level),
"conclusion_date": tdate,
"accum_state": str(ff.get("state") or "") or None,
"accum_score": _f(ff.get("score")),
"accum_structure": ff.get("structure"), "accum_pos_tag": ff.get("pos_tag"),
"accum_age": age,
}
return out
def _ymd(v) -> str | None:
"""各表的日期列形态不一(整数 YYYYMMDD、date、datetime、ISO 串),统一成 ISO 日期串。
前几种形态不经 pandas 就能认出来这样离线单测里的假数据不依赖 pandas认不出的最后才交给
pandas 解析仍失败返回 None"""
if v is None or (isinstance(v, float) and v != v):
return None
if isinstance(v, dt.datetime):
return v.date().isoformat()
if isinstance(v, dt.date):
return v.isoformat()
s = str(v).strip()
if len(s) == 8 and s.isdigit():
return f"{s[:4]}-{s[4:6]}-{s[6:]}"
if len(s) >= 10 and s[4] == "-" and s[7] == "-" and s[:4].isdigit():
return s[:10]
try:
return pd.Timestamp(s).date().isoformat()
except Exception: # noqa: BLE001
return None
def _age(tdate: str | None, ds: str, cal_index: dict) -> int | None:
if not tdate:
return None
if tdate in cal_index and ds in cal_index:
return cal_index[ds] - cal_index[tdate]
try: # 日历缺失或日期在日历之外:自然日 × 5/7 近似
nat = (dt.date.fromisoformat(ds) - dt.date.fromisoformat(tdate)).days
return max(0, round(nat * 5 / 7))
except ValueError:
return None
def _f(v):
try:
x = float(v)
except (TypeError, ValueError):
return None
return None if x != x else x
def _records(df) -> list[dict]:
"""把读函数的返回统一成字典列表DataFrame 走 to_dict普通列表原样返回None 与空表返回空列表。
这样取数函数的计算部分只面对普通 Python 对象离线单测的假读函数直接返回字典列表即可"""
if df is None:
return []
if isinstance(df, list):
return [dict(r) for r in df]
if hasattr(df, "to_dict"):
if getattr(df, "empty", False):
return []
return list(df.to_dict("records"))
return list(df)
def _to_dot(code: str) -> str:
"""前缀式 SH600000 转成数据基座的点后缀式 600000.SH已是点后缀式或纯数字则原样返回。"""
s = str(code or "").strip().upper()
if "." in s or len(s) < 3:
return s
if s[:2] in ("SH", "SZ", "BJ") and s[2:].isdigit():
return f"{s[2:]}.{s[:2]}"
return s
# ============================================================================
# 因果论断(数据基座 v_factor_logic2026-09-03 方案第 3.3 节"候选卡读因果论断"
# ============================================================================
_LOGIC_COLS = ("ts_code", "subject_name", "object_name", "direction", "mechanism", "condition",
"horizon", "strength", "tier", "confidence", "disclosure_date", "doc_id",
2026-09-04 08:59:11 +08:00
"doc_title", "source_span", "claim_id", "via_segment",
# 质量两列2026-09-03 起取disputed 是这条论断还在多空分歧里没结论,
# review_flag 是逻辑评析读过之后标的"疑似误抽"。两者都只作提示,不过滤——
# 那是模型的判断不是确定性事实,过滤会让人看不到系统曾经抽到过什么。
"disputed", "review_flag")
def logic_claims(codes, ds: str, per_stock: int | None = None, read_pg=None) -> dict[str, list[dict]]:
"""这批票在数据基座因果论断视图里、披露日不晚于数据日 ds 的论断,每票取最近披露日的最多
per_stock 默认 config.LOGIC_CLAIMS_PER_STOCK按前缀码索引
每条论断带方向机制条件时效强度层级置信度披露日出处文档标题与编号
论断编号经由环节主体与客体名只作展示与出处不进判决读失败返回空字典并打印一行原因
read_pg 可注入离线单测默认走 db.read_pg"""
n_per = config.LOGIC_CLAIMS_PER_STOCK if per_stock is None else int(per_stock)
if n_per <= 0:
return {}
wanted = sorted({common.to_prefix(str(c).strip()) for c in codes if c})
if not wanted:
return {}
dots = [_to_dot(c) for c in wanted]
reader = read_pg or db.read_pg
try:
marks = ",".join(["%s"] * len(dots))
rows = _records(reader(
f"SELECT {', '.join(_LOGIC_COLS)} FROM v_factor_logic "
f"WHERE ts_code IN ({marks}) AND disclosure_date <= %s",
tuple(dots) + (ds,)))
except Exception as e: # noqa: BLE001
print(f" (因果论断视图 v_factor_logic 读取失败,候选卡的论断证据线整体缺席: {e!r}")
return {}
if not rows:
return {}
want = set(wanted)
by_code: dict[str, list[dict]] = {}
seen: set[tuple[str, str]] = set()
for r in rows:
k = common.to_prefix(str(r.get("ts_code") or "").strip())
if k not in want: # 只留请求的票,视图返回的多余行不带进卡
continue
cid = _s(r.get("claim_id"))
if cid and (k, cid) in seen: # 视图里客体为环节的论断按成员展开,同票同论断只留一条
continue
if cid:
seen.add((k, cid))
by_code.setdefault(k, []).append({
"direction": _s(r.get("direction")), "mechanism": _s(r.get("mechanism")),
"condition": _s(r.get("condition")), "horizon": _s(r.get("horizon")),
"strength": _s(r.get("strength")), "tier": _s(r.get("tier")),
"confidence": _f(r.get("confidence")),
"disclosure_date": _ymd(r.get("disclosure_date")),
"doc_id": _s(r.get("doc_id")), "doc_title": _s(r.get("doc_title")),
"source_span": (_s(r.get("source_span")) or "")[:200] or None,
"claim_id": _s(r.get("claim_id")), "via_segment": _s(r.get("via_segment")),
"subject": _s(r.get("subject_name")), "object": _s(r.get("object_name")),
2026-09-04 08:59:11 +08:00
"disputed": bool(r.get("disputed")), "review_flag": _s(r.get("review_flag")),
})
out = {}
for k, items in by_code.items():
items.sort(key=lambda c: (c["disclosure_date"] or "", c["confidence"] or -1.0), reverse=True)
2026-09-04 08:59:11 +08:00
# 质量指标必须在截断之前算:截断后只剩最近几条,"这票一共有几份研报来源"就看不出来了。
# 算完挂在每条论断上(每条都带同一份,卡上取第一条即可),不另开一个返回值,
# 免得上层要同时接两个字典、少接一个就静默丢掉质量提示。
quality = _logic_quality(items, ds)
kept = items[:n_per]
for c in kept:
c["quality"] = quality
out[k] = kept
return out
2026-09-04 08:59:11 +08:00
def _logic_quality(items: list, ds: str) -> dict:
"""一票全部论断的质量画像,在截断之前算。四项:
age_days 最新一条论断距数据日多少天同一张卡上吸筹评分超过三十个交易日就判陈旧
而论断此前永不过期这是双标这里给出天数阈值判定在 card.py
n_docs 论断来自几份不同研报实测近三成带多条论断的票全部论断出自同一份研报
那份研报一旦过时或本身有偏这票的整条研究证据一起失效而卡上看不出来
n_flagged 被逻辑评析标为疑似误抽的条数n_disputed 还在多空分歧里没结论的条数
n_via_segment 客体是环节按成员展开挂上来的条数那类不是直接讲这家公司
与直接讲这家公司的论断分量不同卡上要分得开
"""
days = None
dates = [c["disclosure_date"] for c in items if c.get("disclosure_date")]
if dates and ds:
try:
days = (dt.date.fromisoformat(ds) - dt.date.fromisoformat(max(dates))).days
except ValueError:
days = None
return {
"n": len(items),
"age_days": days,
"latest_date": max(dates) if dates else None,
"n_docs": len({c["doc_id"] for c in items if c.get("doc_id")}),
"n_flagged": sum(1 for c in items if c.get("review_flag")),
"n_disputed": sum(1 for c in items if c.get("disputed")),
"n_via_segment": sum(1 for c in items if c.get("via_segment")),
}
def _s(v) -> str | None:
if v is None or (isinstance(v, float) and v != v):
return None
s = str(v).strip()
return s or None
def _i(v) -> int | None:
x = _f(v)
return None if x is None else int(x)
def _b(v) -> bool | None:
"""自我校验结果这类三值列:真、假、还没有结果。空值保持为空,不当成假。"""
if v is None or (isinstance(v, float) and v != v):
return None
if isinstance(v, bool):
return v
s = str(v).strip().lower()
if s in ("true", "t", "1", "yes", "y"):
return True
if s in ("false", "f", "0", "no", "n"):
return False
return None
def _n_items(v) -> int | None:
"""JSON 数组列只留条数:列表直接数,字符串先按 JSON 解析,认不出返回空。"""
if v is None or (isinstance(v, float) and v != v):
return None
if isinstance(v, (list, tuple)):
return len(v)
if isinstance(v, str):
try:
parsed = json.loads(v)
except ValueError:
return None
return len(parsed) if isinstance(parsed, list) else None
return None
# ============================================================================
# 产业研判与环节评析(数据基座 v_factor_judgement
# 用处见 judgement.py选股系统每个计划日自留一份快照攒版本史。
# ============================================================================
_JUDGEMENT_COLS = ("scope", "subject_name", "segment_name", "cluster_key", "leaning",
"n_bull", "n_bear", "n_flags", "verified", "verify_problems",
"n_materials", "model", "rounds", "review_date", "reviewed_at",
"input_version", "review_id")
def judgement_rows(scopes=None, read_pg=None) -> list[dict]:
"""数据基座研判结论视图里 scope 落在指定几类的全部行,一行一个评析簇。
scopes 不传时取 config.JUDGEMENT_SCOPES默认产业研判 industry 与环节评析 segment
每行带主题名或环节名采信倾向多头论点条数空头论点条数旗标条数自我校验结果与
校验问题条数材料条数模型与轮次生成日期与生成时刻材料指纹评析编号簇键
这张视图按簇键唯一重评时整行覆盖视图注释里的已知局限 a所以它永远只有最新一版
版本史由读的一方自己攒 judgement.py读失败返回空列表并打印一行原因不阻断上层
read_pg 可注入离线单测默认走 db.read_pg"""
want = tuple(scopes) if scopes else tuple(sorted(config.JUDGEMENT_SCOPES))
if not want:
return []
reader = read_pg or db.read_pg
try:
marks = ",".join(["%s"] * len(want))
rows = _records(reader(
f"SELECT {', '.join(_JUDGEMENT_COLS)} FROM v_factor_judgement "
f"WHERE scope IN ({marks})", want))
except Exception as e: # noqa: BLE001
print(f" (研判结论视图 v_factor_judgement 读取失败,本计划日没有行业观点行可写: {e!r}")
return []
out = []
for r in rows:
key = _s(r.get("cluster_key"))
subject = _s(r.get("subject_name")) or _s(r.get("segment_name"))
if not key or not subject:
continue # 簇键或主题名缺一样,这一行没法参与逐版本比对,跳过
out.append({
"scope": _s(r.get("scope")), "subject_name": subject,
"segment_name": _s(r.get("segment_name")), "cluster_key": key,
"leaning": _s(r.get("leaning")),
"n_bull": _i(r.get("n_bull")), "n_bear": _i(r.get("n_bear")),
"n_flags": _i(r.get("n_flags")),
"verified": _b(r.get("verified")),
"n_verify_problems": _n_items(r.get("verify_problems")),
"n_materials": _i(r.get("n_materials")),
"model": _s(r.get("model")), "rounds": _i(r.get("rounds")),
"review_date": _ymd(r.get("review_date")),
"reviewed_at": _dt_str(r.get("reviewed_at")),
"input_version": _s(r.get("input_version")),
"review_id": _s(r.get("review_id")),
})
return out
def _dt_str(v) -> str | None:
"""时刻列统一成 MySQL 认的 'YYYY-MM-DD HH:MM:SS';只有日期的补零点;认不出返回空。"""
if v is None or (isinstance(v, float) and v != v):
return None
if isinstance(v, dt.datetime):
return v.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(v, dt.date):
return f"{v.isoformat()} 00:00:00"
s = str(v).strip().replace("T", " ")
if len(s) >= 19 and s[4] == "-" and s[7] == "-":
return s[:19]
day = _ymd(s)
return f"{day} 00:00:00" if day else None
# ============================================================================
# 计划环境段的市场四项2026-09-03 方案第 1.4 节清单里"有"与"可自算"的项)
# ============================================================================
def market_context(ds: str, read_pg=None, read_mysql=None) -> dict:
"""数据日 ds 的市场环境四项,全部只展示与复盘分组,不拦任何票。
turnover 两市成交额指数日线表 zs_day_data 里上证与深成当日 amount 之和以及相对前五个
交易日均值的比值原表单位未换算
breadth 市场广度基座个股日行情视图当日行自算上涨下跌平盘家数涨幅达 9.8% 的家数
涨停近似涨幅中位数
margin 融资eastmoney_rzrq_data 最新一日的 financing_balance change_percent_5d
fear_greed 恐贪指数fear_greed_index 最新一日的 index_value 与日期
每一项读失败为 None 并把原因记进 errors不阻断出计划read_pg / read_mysql 可注入离线单测
融资与恐贪两张表按"最新一日"不按 ds 过滤它们是 T+1 更新的情绪读数计划日早晨看到的
就是最新一行行里带日期读者自己判断新鲜度"""
rpg = read_pg or db.read_pg
rmy = read_mysql or db.read_mysql
src = config.MARKET_MYSQL_SOURCE
out = {"date": ds, "turnover": None, "breadth": None, "margin": None, "fear_greed": None,
"errors": {}, "fetched_at": dt.datetime.now().isoformat(timespec="seconds")}
# 一、两市成交额。不按日期列过滤列的类型未在本仓库内核实DATE 与整数 YYYYMMDD 的比较
# 语义不同),改为取每个指数最近的几十行,在 Python 里按归一化日期筛不晚于 ds 的行。
try:
marks = ",".join(["%s"] * len(MARKET_INDEX_CODES))
rows = _records(rmy(
src, f"SELECT symbol, `timestamp` AS d, amount FROM zs_day_data "
f"WHERE symbol IN ({marks}) ORDER BY `timestamp` DESC LIMIT 80",
tuple(MARKET_INDEX_CODES)))
out["turnover"] = _turnover(rows, ds)
if out["turnover"] is None:
out["errors"]["turnover"] = "zs_day_data 最近 40 个交易日内没有不晚于计划日、且两市齐全的行"
except Exception as e: # noqa: BLE001
out["errors"]["turnover"] = repr(e)
print(f" (两市成交额读取失败,环境段该项为空: {e!r}")
# 二、市场广度:基座个股日行情视图当日全部行自算。
try:
rows = _records(rpg(
"SELECT pct_change FROM v_factor_stock_daily WHERE trade_date = %s", (ds,)))
out["breadth"] = _breadth(rows)
if out["breadth"] is None:
out["errors"]["breadth"] = "v_factor_stock_daily 当日无行"
except Exception as e: # noqa: BLE001
out["errors"]["breadth"] = repr(e)
print(f" (市场广度自算失败,环境段该项为空: {e!r}")
# 三、融资余额与五日变化;四、恐贪指数。两张表都取最新一日一行。
for key, table, cols, label in (
("margin", "eastmoney_rzrq_data", ("financing_balance", "change_percent_5d"), "融资余额"),
("fear_greed", "fear_greed_index", ("index_value",), "恐贪指数")):
try:
row, date_col = _latest_row(rmy, src, table)
if row is None:
out["errors"][key] = f"{table} 为空表"
continue
item = {"date": _ymd(row.get(date_col)) if date_col else None,
"date_col": date_col, "source": table}
for c in cols:
item[c] = _f(row.get(c))
if all(item[c] is None for c in cols):
out["errors"][key] = f"{table} 最新行缺列 {cols}(实际列: {sorted(row)[:12]}"
continue
out[key] = item
except Exception as e: # noqa: BLE001
out["errors"][key] = repr(e)
print(f" {label}读取失败,环境段该项为空: {e!r}")
return out
def _turnover(rows: list[dict], ds: str) -> dict | None:
"""两市成交额:按日期把两个指数的 amount 相加,只认两市齐全的日子;当日取不晚于 ds 的最近一日,
前五日均值取它之前的五个交易日不足五个按实际个数"""
by_day: dict[str, dict] = {}
for r in rows:
d = _ymd(r.get("d"))
a = _f(r.get("amount"))
if not d or a is None or d > ds:
continue
by_day.setdefault(d, {})[str(r.get("symbol") or "").strip()] = a
full = sorted((d for d, m in by_day.items() if all(c in m for c in MARKET_INDEX_CODES)),
reverse=True)
if not full:
return None
day0 = full[0]
amt0 = sum(by_day[day0].values())
prev = [sum(by_day[d].values()) for d in full[1:6]]
avg5 = (sum(prev) / len(prev)) if prev else None
return {"data_date": day0, "amount": amt0, "prev5_avg": avg5,
"ratio_vs_prev5": (amt0 / avg5) if avg5 else None, "prev5_days": len(prev),
# 2026-09-03 实测核对09-02 两市 amount 合计 1,321,831,143而当日真实成交额约
# 1.32 万亿元,所以这一列的单位是千元,不是元。换算成亿元要除以十万(乘一千再除一亿)。
# 渲染与下游一律用 amount_yi原始值保留在 amount 里备查。
"unit": "千元",
"amount_yi": amt0 / 1e5,
"prev5_avg_yi": (avg5 / 1e5) if avg5 else None,
"source": "zs_day_data 上证 000001.SH 与深成 399001.SZ 当日 amount 之和"}
def _breadth(rows: list[dict]) -> dict | None:
pcts = [p for p in (_f(r.get("pct_change")) for r in rows) if p is not None]
if not pcts:
return None
return {"n": len(pcts),
"up": sum(1 for p in pcts if p > 0), "down": sum(1 for p in pcts if p < 0),
"flat": sum(1 for p in pcts if p == 0),
"limit_up_approx": sum(1 for p in pcts if p >= LIMIT_UP_PCT),
"pct_median": round(statistics.median(pcts), 3),
"limit_up_rule": f"涨幅达 {LIMIT_UP_PCT}% 记为涨停近似",
"source": "v_factor_stock_daily 当日行自算"}
def _latest_row(rmy, src: str, table: str) -> tuple[dict | None, str | None]:
"""取一张表按日期列排序的最新一行。日期列名先用一行样本探出(候选名见 _DATE_COL_CANDIDATES
探不到就按第一列倒序通常是自增主键并把 date_col 记为 None"""
sample = _records(rmy(src, f"SELECT * FROM {table} LIMIT 1"))
if not sample:
return None, None
cols = list(sample[0].keys())
date_col = next((c for c in _DATE_COL_CANDIDATES if c in cols), None)
order = f"`{date_col}`" if date_col else "1"
rows = _records(rmy(src, f"SELECT * FROM {table} ORDER BY {order} DESC LIMIT 1"))
return (rows[0] if rows else None), date_col
逻辑状态四态接进计划装配,结论随每张卡发给下游 此前四态只是一个纯函数模块,结论躺在库里没人读。这次接上: 计划装配时每只票算一次四态,三路输入分别是研报论断(已有)、产业研判(读行业观点 快照,按环节名对上今日被指向的环节)、券商行动(同财年同预测期的每股收益预测中位数 与覆盖机构数,两个等长窗口比较)。公司事件那一路无数据源,恒出缺失,但照样记名—— 缺失要让人看得见系统缺的是什么,不能让人以为系统判过了。 两处取数搬进公用的地方,免得读数脚本和计划各写一套:行业观点快照的当日读取进 judgement.py,券商行动进 sources.py。读数脚本改调它们,自己那两份删掉。 修了一处日期口径错误:行业观点快照按计划日落库,行情与论断按数据日取,两者在生产里 差一天。原先用同一个日期取三样东西,结果是快照首日读到空。 另修一处:读数脚本原先只读含已启动成员的窄传导视图,而候选卡认的是完整那张, 算出来的产业研判覆盖比卡上真实看到的低。两边现在同一口径。 接口每行新增逻辑状态:状态、子因、每路的来龙去脉与截止日。不发权重、不发判决改动—— 四态怎么作用于建仓通道是 PMS 那边的事,这里只提供状态与出处。内部中间量不外泄。 测试十七例,重点钉住四态不改判决:四个状态乘三个判决十二种组合逐个扫过,判决一次 都没被动过。这是收敛规则单调性的守卫。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:46:01 +08:00
# 丙路(券商行动)两个窗口各自的长度,自然日。等长是硬要求:窗口不等长会让八成的票
# 假显示覆盖收缩——实测前 135 天对近 45 天时有 907 只票误报。等长本身也是抗抖动的低通。
BROKER_WINDOW_DAYS = 45
def broker_reports(codes, ds: str, *, days: int = BROKER_WINDOW_DAYS * 2, read_mysql=None) -> list:
"""券商研报明细表里这些票近 days 个自然日的每股收益与市盈率预测——原始行,按机构分箱之前。
券商行动两个等长窗口与安全边际三情景整段窗口共用这一次取数两路看的是同一批研报
每行归一成 {k 前缀码, date 报告日, quarter 预测期, org 机构, eps, pe}没有每股收益或
预测期的行不要读失败返回空列表并打印原因两路都按缺席处理计划不断产
"""
reader = read_mysql or db.read_mysql
end = dt.date.fromisoformat(ds)
start = end - dt.timedelta(days=int(days))
dotted = sorted({_to_dot(c) for c in codes if c})
if not dotted:
return []
marks = ",".join(["%s"] * len(dotted))
try:
df = reader(
"factor",
f"SELECT ts_code, report_date, quarter, org_name, eps, pe FROM gp_report_rc "
f"WHERE ts_code IN ({marks}) AND report_date > %s AND report_date <= %s "
f"AND eps IS NOT NULL AND quarter IS NOT NULL",
tuple(dotted) + (start.isoformat(), end.isoformat()))
except Exception as e: # noqa: BLE001
print(f" (券商研报明细表读取失败,券商行动与安全边际两路整体缺席: {e!r}")
return []
out = []
for r in _records(df):
d = _ymd(r.get("report_date"))
q = str(r.get("quarter") or "").strip()
eps = _f(r.get("eps"))
if not d or not q or eps is None:
continue
out.append({"k": common.to_prefix(str(r.get("ts_code") or "").strip()), "date": d,
"quarter": q, "org": str(r.get("org_name") or "").strip() or "未署名",
"eps": eps, "pe": _f(r.get("pe"))})
return out
def _latest_by_org(rows) -> dict:
"""同一家机构在窗口里可能发多篇,只留最近一篇:{预测期: {机构: 行}}。"""
box: dict = defaultdict(dict)
for r in rows:
slot = box[r["quarter"]]
if r["org"] not in slot or r["date"] > slot[r["org"]]["date"]:
slot[r["org"]] = r
return box
逻辑状态四态接进计划装配,结论随每张卡发给下游 此前四态只是一个纯函数模块,结论躺在库里没人读。这次接上: 计划装配时每只票算一次四态,三路输入分别是研报论断(已有)、产业研判(读行业观点 快照,按环节名对上今日被指向的环节)、券商行动(同财年同预测期的每股收益预测中位数 与覆盖机构数,两个等长窗口比较)。公司事件那一路无数据源,恒出缺失,但照样记名—— 缺失要让人看得见系统缺的是什么,不能让人以为系统判过了。 两处取数搬进公用的地方,免得读数脚本和计划各写一套:行业观点快照的当日读取进 judgement.py,券商行动进 sources.py。读数脚本改调它们,自己那两份删掉。 修了一处日期口径错误:行业观点快照按计划日落库,行情与论断按数据日取,两者在生产里 差一天。原先用同一个日期取三样东西,结果是快照首日读到空。 另修一处:读数脚本原先只读含已启动成员的窄传导视图,而候选卡认的是完整那张, 算出来的产业研判覆盖比卡上真实看到的低。两边现在同一口径。 接口每行新增逻辑状态:状态、子因、每路的来龙去脉与截止日。不发权重、不发判决改动—— 四态怎么作用于建仓通道是 PMS 那边的事,这里只提供状态与出处。内部中间量不外泄。 测试十七例,重点钉住四态不改判决:四个状态乘三个判决十二种组合逐个扫过,判决一次 都没被动过。这是收敛规则单调性的守卫。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:46:01 +08:00
def broker_actions(codes, ds: str, *, window_days: int = BROKER_WINDOW_DAYS,
read_mysql=None, rows=None) -> dict:
逻辑状态四态接进计划装配,结论随每张卡发给下游 此前四态只是一个纯函数模块,结论躺在库里没人读。这次接上: 计划装配时每只票算一次四态,三路输入分别是研报论断(已有)、产业研判(读行业观点 快照,按环节名对上今日被指向的环节)、券商行动(同财年同预测期的每股收益预测中位数 与覆盖机构数,两个等长窗口比较)。公司事件那一路无数据源,恒出缺失,但照样记名—— 缺失要让人看得见系统缺的是什么,不能让人以为系统判过了。 两处取数搬进公用的地方,免得读数脚本和计划各写一套:行业观点快照的当日读取进 judgement.py,券商行动进 sources.py。读数脚本改调它们,自己那两份删掉。 修了一处日期口径错误:行业观点快照按计划日落库,行情与论断按数据日取,两者在生产里 差一天。原先用同一个日期取三样东西,结果是快照首日读到空。 另修一处:读数脚本原先只读含已启动成员的窄传导视图,而候选卡认的是完整那张, 算出来的产业研判覆盖比卡上真实看到的低。两边现在同一口径。 接口每行新增逻辑状态:状态、子因、每路的来龙去脉与截止日。不发权重、不发判决改动—— 四态怎么作用于建仓通道是 PMS 那边的事,这里只提供状态与出处。内部中间量不外泄。 测试十七例,重点钉住四态不改判决:四个状态乘三个判决十二种组合逐个扫过,判决一次 都没被动过。这是收敛规则单调性的守卫。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:46:01 +08:00
"""券商用行动说话这一路:同一财年同一预测期的每股收益预测中位数与覆盖机构数,
比较最近两个等长窗口返回前缀码到 logic_state.signal 的字典算不出的票不进字典
三条口径必须照做否则读数是错的
两个窗口等长见上面那条常量的说明
同一财年才可比按预测期字段精确匹配跨财年比较没有意义
同一家机构在窗口里可能发多篇先按机构取最近一篇再算中位数
否则发得勤的机构会被重复计入
看的是券商的行动不是言辞券商极少明说不看好某个行业所以等不到它开口
只能看预测在不在下修覆盖在不在收缩
rows 可传 broker_reports 的返回计划装配一次取数两路共用不传就自己取
逻辑状态四态接进计划装配,结论随每张卡发给下游 此前四态只是一个纯函数模块,结论躺在库里没人读。这次接上: 计划装配时每只票算一次四态,三路输入分别是研报论断(已有)、产业研判(读行业观点 快照,按环节名对上今日被指向的环节)、券商行动(同财年同预测期的每股收益预测中位数 与覆盖机构数,两个等长窗口比较)。公司事件那一路无数据源,恒出缺失,但照样记名—— 缺失要让人看得见系统缺的是什么,不能让人以为系统判过了。 两处取数搬进公用的地方,免得读数脚本和计划各写一套:行业观点快照的当日读取进 judgement.py,券商行动进 sources.py。读数脚本改调它们,自己那两份删掉。 修了一处日期口径错误:行业观点快照按计划日落库,行情与论断按数据日取,两者在生产里 差一天。原先用同一个日期取三样东西,结果是快照首日读到空。 另修一处:读数脚本原先只读含已启动成员的窄传导视图,而候选卡认的是完整那张, 算出来的产业研判覆盖比卡上真实看到的低。两边现在同一口径。 接口每行新增逻辑状态:状态、子因、每路的来龙去脉与截止日。不发权重、不发判决改动—— 四态怎么作用于建仓通道是 PMS 那边的事,这里只提供状态与出处。内部中间量不外泄。 测试十七例,重点钉住四态不改判决:四个状态乘三个判决十二种组合逐个扫过,判决一次 都没被动过。这是收敛规则单调性的守卫。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:46:01 +08:00
"""
import logic_state as ls
if rows is None:
rows = broker_reports(codes, ds, days=int(window_days) * 2, read_mysql=read_mysql)
逻辑状态四态接进计划装配,结论随每张卡发给下游 此前四态只是一个纯函数模块,结论躺在库里没人读。这次接上: 计划装配时每只票算一次四态,三路输入分别是研报论断(已有)、产业研判(读行业观点 快照,按环节名对上今日被指向的环节)、券商行动(同财年同预测期的每股收益预测中位数 与覆盖机构数,两个等长窗口比较)。公司事件那一路无数据源,恒出缺失,但照样记名—— 缺失要让人看得见系统缺的是什么,不能让人以为系统判过了。 两处取数搬进公用的地方,免得读数脚本和计划各写一套:行业观点快照的当日读取进 judgement.py,券商行动进 sources.py。读数脚本改调它们,自己那两份删掉。 修了一处日期口径错误:行业观点快照按计划日落库,行情与论断按数据日取,两者在生产里 差一天。原先用同一个日期取三样东西,结果是快照首日读到空。 另修一处:读数脚本原先只读含已启动成员的窄传导视图,而候选卡认的是完整那张, 算出来的产业研判覆盖比卡上真实看到的低。两边现在同一口径。 接口每行新增逻辑状态:状态、子因、每路的来龙去脉与截止日。不发权重、不发判决改动—— 四态怎么作用于建仓通道是 PMS 那边的事,这里只提供状态与出处。内部中间量不外泄。 测试十七例,重点钉住四态不改判决:四个状态乘三个判决十二种组合逐个扫过,判决一次 都没被动过。这是收敛规则单调性的守卫。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:46:01 +08:00
end = dt.date.fromisoformat(ds)
mid = (end - dt.timedelta(days=int(window_days))).isoformat()
start = (end - dt.timedelta(days=int(window_days) * 2)).isoformat()
by_code: dict = defaultdict(list)
逻辑状态四态接进计划装配,结论随每张卡发给下游 此前四态只是一个纯函数模块,结论躺在库里没人读。这次接上: 计划装配时每只票算一次四态,三路输入分别是研报论断(已有)、产业研判(读行业观点 快照,按环节名对上今日被指向的环节)、券商行动(同财年同预测期的每股收益预测中位数 与覆盖机构数,两个等长窗口比较)。公司事件那一路无数据源,恒出缺失,但照样记名—— 缺失要让人看得见系统缺的是什么,不能让人以为系统判过了。 两处取数搬进公用的地方,免得读数脚本和计划各写一套:行业观点快照的当日读取进 judgement.py,券商行动进 sources.py。读数脚本改调它们,自己那两份删掉。 修了一处日期口径错误:行业观点快照按计划日落库,行情与论断按数据日取,两者在生产里 差一天。原先用同一个日期取三样东西,结果是快照首日读到空。 另修一处:读数脚本原先只读含已启动成员的窄传导视图,而候选卡认的是完整那张, 算出来的产业研判覆盖比卡上真实看到的低。两边现在同一口径。 接口每行新增逻辑状态:状态、子因、每路的来龙去脉与截止日。不发权重、不发判决改动—— 四态怎么作用于建仓通道是 PMS 那边的事,这里只提供状态与出处。内部中间量不外泄。 测试十七例,重点钉住四态不改判决:四个状态乘三个判决十二种组合逐个扫过,判决一次 都没被动过。这是收敛规则单调性的守卫。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:46:01 +08:00
for r in rows:
if start < r["date"] <= ds: # rows 可能来自更长的窗口,这里再裁一次
by_code[r["k"]].append(r)
逻辑状态四态接进计划装配,结论随每张卡发给下游 此前四态只是一个纯函数模块,结论躺在库里没人读。这次接上: 计划装配时每只票算一次四态,三路输入分别是研报论断(已有)、产业研判(读行业观点 快照,按环节名对上今日被指向的环节)、券商行动(同财年同预测期的每股收益预测中位数 与覆盖机构数,两个等长窗口比较)。公司事件那一路无数据源,恒出缺失,但照样记名—— 缺失要让人看得见系统缺的是什么,不能让人以为系统判过了。 两处取数搬进公用的地方,免得读数脚本和计划各写一套:行业观点快照的当日读取进 judgement.py,券商行动进 sources.py。读数脚本改调它们,自己那两份删掉。 修了一处日期口径错误:行业观点快照按计划日落库,行情与论断按数据日取,两者在生产里 差一天。原先用同一个日期取三样东西,结果是快照首日读到空。 另修一处:读数脚本原先只读含已启动成员的窄传导视图,而候选卡认的是完整那张, 算出来的产业研判覆盖比卡上真实看到的低。两边现在同一口径。 接口每行新增逻辑状态:状态、子因、每路的来龙去脉与截止日。不发权重、不发判决改动—— 四态怎么作用于建仓通道是 PMS 那边的事,这里只提供状态与出处。内部中间量不外泄。 测试十七例,重点钉住四态不改判决:四个状态乘三个判决十二种组合逐个扫过,判决一次 都没被动过。这是收敛规则单调性的守卫。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:46:01 +08:00
out = {}
for k, rs in by_code.items():
now_box = _latest_by_org([r for r in rs if r["date"] > mid])
prev_box = _latest_by_org([r for r in rs if r["date"] <= mid])
usable = [(q, now_box[q], prev_box[q]) for q in now_box if q in prev_box]
逻辑状态四态接进计划装配,结论随每张卡发给下游 此前四态只是一个纯函数模块,结论躺在库里没人读。这次接上: 计划装配时每只票算一次四态,三路输入分别是研报论断(已有)、产业研判(读行业观点 快照,按环节名对上今日被指向的环节)、券商行动(同财年同预测期的每股收益预测中位数 与覆盖机构数,两个等长窗口比较)。公司事件那一路无数据源,恒出缺失,但照样记名—— 缺失要让人看得见系统缺的是什么,不能让人以为系统判过了。 两处取数搬进公用的地方,免得读数脚本和计划各写一套:行业观点快照的当日读取进 judgement.py,券商行动进 sources.py。读数脚本改调它们,自己那两份删掉。 修了一处日期口径错误:行业观点快照按计划日落库,行情与论断按数据日取,两者在生产里 差一天。原先用同一个日期取三样东西,结果是快照首日读到空。 另修一处:读数脚本原先只读含已启动成员的窄传导视图,而候选卡认的是完整那张, 算出来的产业研判覆盖比卡上真实看到的低。两边现在同一口径。 接口每行新增逻辑状态:状态、子因、每路的来龙去脉与截止日。不发权重、不发判决改动—— 四态怎么作用于建仓通道是 PMS 那边的事,这里只提供状态与出处。内部中间量不外泄。 测试十七例,重点钉住四态不改判决:四个状态乘三个判决十二种组合逐个扫过,判决一次 都没被动过。这是收敛规则单调性的守卫。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:46:01 +08:00
if not usable:
continue
q, n, p = max(usable, key=lambda t: len(t[1]) + len(t[2]))
now = {"eps": statistics.median([r["eps"] for r in n.values()]), "firms": len(n)}
prev = {"eps": statistics.median([r["eps"] for r in p.values()]), "firms": len(p)}
逻辑状态四态接进计划装配,结论随每张卡发给下游 此前四态只是一个纯函数模块,结论躺在库里没人读。这次接上: 计划装配时每只票算一次四态,三路输入分别是研报论断(已有)、产业研判(读行业观点 快照,按环节名对上今日被指向的环节)、券商行动(同财年同预测期的每股收益预测中位数 与覆盖机构数,两个等长窗口比较)。公司事件那一路无数据源,恒出缺失,但照样记名—— 缺失要让人看得见系统缺的是什么,不能让人以为系统判过了。 两处取数搬进公用的地方,免得读数脚本和计划各写一套:行业观点快照的当日读取进 judgement.py,券商行动进 sources.py。读数脚本改调它们,自己那两份删掉。 修了一处日期口径错误:行业观点快照按计划日落库,行情与论断按数据日取,两者在生产里 差一天。原先用同一个日期取三样东西,结果是快照首日读到空。 另修一处:读数脚本原先只读含已启动成员的窄传导视图,而候选卡认的是完整那张, 算出来的产业研判覆盖比卡上真实看到的低。两边现在同一口径。 接口每行新增逻辑状态:状态、子因、每路的来龙去脉与截止日。不发权重、不发判决改动—— 四态怎么作用于建仓通道是 PMS 那边的事,这里只提供状态与出处。内部中间量不外泄。 测试十七例,重点钉住四态不改判决:四个状态乘三个判决十二种组合逐个扫过,判决一次 都没被动过。这是收敛规则单调性的守卫。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:46:01 +08:00
sig = ls.from_broker(now, prev, as_of=ds)
if sig["refs"]:
sig["refs"][0]["quarter"] = q
out[k] = sig
return out
# ============================================================================
# 安全边际三情景2026-09-07 下一阶段方案第四件):只展示,不进判决
# ============================================================================
#
# 回答的是价值投资的经典问题:买入价相对内在价值的折扣有多少,最坏情况下现价还有多少
# 下跌空间。这是事前概念,与 PMS 里那个也叫"垫"的字段(建仓后的浮盈)不是一回事。
#
# 口径:同一预测期的每股收益预测与市盈率预测,按机构去重后各取最小、中位、最大——
# 悲观 = 最低每股收益 × 最低市盈率,中性 = 两项中位数,乐观 = 两项最高。
# 隐含市盈率 = 现价 ÷ 中位每股收益;赔率 = 中性上行 ÷ 悲观下行。
# 用盈利预测不用目标价:目标价字段覆盖不到三成且不去重不加权,出现过 +181% 的读数;
# 每股收益预测覆盖 98%、市盈率预测 91%。恩捷股份 09-02 手算(方案四之三):悲观 42.64、
# 中性 56.75、乐观 84.96,赔率 0.87 比 1——单测用它对表。
# 机构分歧"极大"的线:每股收益或市盈率预测的最高对最低超过这个倍数就标注。取 3 倍——正常的
# 分歧在一倍多到两倍之间(恩捷 1.3 与 1.5 倍),三倍以上多半是有机构的口径不同或数据有误。
# 一次定死,只影响标注,不影响算法。
WIDE_SPREAD = 3.0
def _pick_period(box: dict):
"""挑哪个预测期:优先年度(预测期以 Q4 结尾),其中覆盖机构最多的;同数取更近的年份。"""
cands = [(q, len(orgs)) for q, orgs in box.items() if orgs]
if not cands:
return None
annual = [c for c in cands if c[0].upper().endswith("Q4")]
pool = annual or cands
return sorted(pool, key=lambda c: (-c[1], c[0]))[0][0]
def scenarios(eps_vals, pe_vals, price, *, quarter=None, as_of=None, min_firms: int = 2) -> dict:
"""纯函数:三情景估值。算不出时 na 写明原因(四种不适用各一句人话),算得出时 na 为 None。"""
eps_vals = [float(x) for x in (eps_vals or []) if x is not None]
pe_vals = [float(x) for x in (pe_vals or []) if x is not None and float(x) > 0]
base = {"quarter": quarter, "firms": len(eps_vals), "as_of": as_of,
"price": None if price is None else float(price)}
if len(eps_vals) < int(min_firms):
return {**base, "na": f"覆盖机构只有 {len(eps_vals)} 家,不足 {min_firms}"}
if price is None or float(price) <= 0:
return {**base, "na": "现价取不到"}
if min(eps_vals) <= 0:
return {**base, "na": "每股收益预测有负值或零,市盈率口径不适用"}
if len(pe_vals) < int(min_firms):
return {**base, "na": f"市盈率预测只有 {len(pe_vals)} 家给了,不足 {min_firms}"}
px = float(price)
e = {"min": min(eps_vals), "med": statistics.median(eps_vals), "max": max(eps_vals)}
p = {"min": min(pe_vals), "med": statistics.median(pe_vals), "max": max(pe_vals)}
pess, neut, opt = e["min"] * p["min"], e["med"] * p["med"], e["max"] * p["max"]
down, up_n, up_o = pess / px - 1, neut / px - 1, opt / px - 1
odds = None
note = None
if down >= 0:
note = "悲观情景仍高于现价,没有下行空间可比"
elif up_n <= 0:
note = "中性情景低于现价,赔率不成立"
else:
odds = up_n / (-down)
# 机构分歧的量:最高对最低的倍数。悲观取最低乘最低、乐观取最高乘最高,分歧一大两头就会被
# 放大到离谱(实测有票悲观 -89%、乐观 +1054%)。超过阈值只标注"分歧极大",不改算法——
# 这本身就是一条信息:券商对这家公司的盈利路径没有共识。
spread = {"eps": round(e["max"] / e["min"], 2), "pe": round(p["max"] / p["min"], 2)}
wide = spread["eps"] > WIDE_SPREAD or spread["pe"] > WIDE_SPREAD
return {**base, "na": None,
"eps": {k: round(v, 4) for k, v in e.items()},
"pe": {k: round(v, 2) for k, v in p.items()},
"pess": round(pess, 2), "neut": round(neut, 2), "opt": round(opt, 2),
"down": round(down, 4), "up_neut": round(up_n, 4), "up_opt": round(up_o, 4),
"implied_pe": round(px / e["med"], 1),
"odds": None if odds is None else round(odds, 2), "note": note,
"spread": spread, "wide": wide}
def valuation_scenarios(codes, ds: str, prices: dict, *, rows=None,
window_days: int = BROKER_WINDOW_DAYS * 2, min_firms: int = 2,
read_mysql=None) -> dict:
"""每只票的三情景估值,按前缀码索引;没有任何研报行的票给 None卡上写"没有券商预测")。"""
if rows is None:
rows = broker_reports(codes, ds, days=int(window_days), read_mysql=read_mysql)
by_code: dict = defaultdict(list)
for r in rows:
by_code[r["k"]].append(r)
out = {}
for k in {str(c) for c in (codes or []) if c}:
rs = by_code.get(k) or []
if not rs:
out[k] = None
continue
box = _latest_by_org(rs)
q = _pick_period(box)
firms = box.get(q, {}) if q else {}
out[k] = scenarios([r["eps"] for r in firms.values()],
[r["pe"] for r in firms.values()],
(prices or {}).get(k), quarter=q,
as_of=max((r["date"] for r in firms.values()), default=None),
min_firms=min_firms)
return out
def _prefix_any(s: str) -> str:
"""行情表的代码列可能是 600000.SH也可能是裸 6 位码;统一成前缀式。"""
s = str(s or "").strip().upper()
if "." in s:
return common.to_prefix(s)
if len(s) == 6 and s.isdigit():
return ("SH" if s[0] == "6" else ("BJ" if s[0] in "48" else "SZ")) + s
return s
def close_prices(ds: str, read_mysql=None, code_col: str | None = None) -> dict:
"""数据日的收盘价(前复权行情表,与预期空间同一来源),按前缀码索引;读不到返回空字典。
区间写成 [ds, ds+1) 而不是 = ds时间列若带时分秒等号会一行都对不上而这样写两种
形态都对也走得了索引code_col 可注入离线单测不传就沿用预期空间那一路的探列
"""
reader = read_mysql or db.read_mysql
try:
if code_col is None:
import factors
code_col = factors._price_code_col() # noqa: SLF001 —— 同仓自用
nxt = (dt.date.fromisoformat(ds) + dt.timedelta(days=1)).isoformat()
df = reader("price", f"SELECT `{code_col}` AS ts_code, close FROM gp_day_data "
f"WHERE `timestamp` >= %s AND `timestamp` < %s", (ds, nxt))
except Exception as e: # noqa: BLE001
print(f" (收盘价读取失败,安全边际这一行整体缺席: {e!r}")
return {}
out = {}
for r in _records(df):
k = _prefix_any(r.get("ts_code"))
px = _f(r.get("close"))
if k and px and px > 0:
out[k] = px
return out