akg-factor-bridge/sources.py

1245 lines
64 KiB
Python
Raw Permalink 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.

"""候选卡的取数层:把各条证据线从三处库读成"按前缀码索引的字典"(全部只读)。
## 为什么要有它
候选卡的规则在 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 re
import logging
import statistics
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",
"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 "
f"ORDER BY ts_code, disclosure_date DESC, claim_id",
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")),
"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)
# 质量指标必须在截断之前算:截断后只剩最近几条,"这票一共有几份研报来源"就看不出来了。
# 算完挂在每条论断上(每条都带同一份,卡上取第一条即可),不另开一个返回值,
# 免得上层要同时接两个字典、少接一个就静默丢掉质量提示。
quality = _logic_quality(items, ds)
kept = items[:n_per]
for c in kept:
c["quality"] = quality
out[k] = kept
return out
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
# 丙路(券商行动)两个窗口各自的长度,自然日。等长是硬要求:窗口不等长会让八成的票
# 假显示覆盖收缩——实测前 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
def broker_actions(codes, ds: str, *, window_days: int = BROKER_WINDOW_DAYS,
read_mysql=None, rows=None) -> dict:
"""券商用行动说话这一路:同一财年同一预测期的每股收益预测中位数与覆盖机构数,
比较最近两个等长窗口。返回前缀码到 logic_state.signal 的字典(算不出的票不进字典)。
三条口径必须照做,否则读数是错的:
一,两个窗口等长(见上面那条常量的说明)。
二,同一财年才可比,按预测期字段精确匹配,跨财年比较没有意义。
三,同一家机构在窗口里可能发多篇,先按机构取最近一篇再算中位数,
否则发得勤的机构会被重复计入。
看的是券商的行动不是言辞——券商极少明说不看好某个行业,所以等不到它开口,
只能看预测在不在下修、覆盖在不在收缩。
rows 可传 broker_reports 的返回(计划装配一次取数两路共用);不传就自己取。
"""
import logic_state as ls
if rows is None:
rows = broker_reports(codes, ds, days=int(window_days) * 2, read_mysql=read_mysql)
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)
for r in rows:
if start < r["date"] <= ds: # rows 可能来自更长的窗口,这里再裁一次
by_code[r["k"]].append(r)
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]
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)}
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
# ============================================================================
# 催化事件与事件日字段2026-09-08《量价研判链吸收方案》3.4):只展示,不进判决
# ============================================================================
#
# 研报的起点是四类券商正向事件:"间隔一年后深度覆盖推荐买入"、"主动上调盈利预测"、"研报标题含
# 业绩超预期"、"两者兼有"。这四类全部能从券商研报明细表 gp_report_rc 复现(报告类型、标题、评级、
# 每股收益历史都在),不用动数据基座。阈值一次定死(台账 045
# 深度覆盖 报告类型是"深度",评级是买入类,且该票在这篇之前 365 天内没有任何研报
# 上调预测 同一家机构对同一预测期180 天内上一篇每股收益为正且这一篇高出五成以上
# 超预期 标题含"超预期"
# 事件窗口 数据日往前 60 个自然日;同一天多篇合并成一个事件(研报的"同日复合"
# 它是论点卡"催化剂"一栏的第一个数据源(此前标无数据源),也是送研判的事件上下文。
EVENT_WINDOW_DAYS = 60
EVENT_COVER_GAP_DAYS = 365
EVENT_UPGRADE_LOOKBACK_DAYS = 180
EVENT_UPGRADE_RATIO = 1.5
EVENT_KEEP = 5 # 每票最多带几条事件到卡上
BUY_RATINGS = {"买入", "增持", "推荐", "强烈推荐", "强推", "跑赢行业", "优于大市", "买入-A", "买入-B",
"推荐-A", "增持-A", "审慎增持", "谨慎增持", "优于大市评级"}
EV_DEEP, EV_UPGRADE, EV_BEAT = "深度覆盖", "上调盈利预测", "业绩超预期"
def analyst_reports(codes, ds: str, *, days: int = EVENT_WINDOW_DAYS + EVENT_COVER_GAP_DAYS,
read_mysql=None) -> list:
"""券商研报明细表的原始行:报告日、类型、标题、评级、机构、预测期、每股收益。窗口要盖住
事件窗口加"前 365 天有没有覆盖"的回看,所以默认取 425 天。读失败返回空列表并打印原因。"""
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, report_type, report_title, rating, org_name, quarter, eps "
f"FROM gp_report_rc WHERE ts_code IN ({marks}) AND report_date > %s AND report_date <= %s",
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"))
if not d:
continue
out.append({"k": common.to_prefix(str(r.get("ts_code") or "").strip()), "date": d,
"type": str(r.get("report_type") or "").strip(),
"title": str(r.get("report_title") or "").strip(),
"rating": str(r.get("rating") or "").strip(),
"org": str(r.get("org_name") or "").strip() or "未署名",
"quarter": str(r.get("quarter") or "").strip(), "eps": _f(r.get("eps"))})
return out
def analyst_events(codes, ds: str, *, rows=None, window_days: int = EVENT_WINDOW_DAYS,
read_mysql=None) -> dict:
"""每只票近 window_days 天的券商正向事件,按前缀码索引;没有事件的票不在字典里。
返回 {k: {"latest": 最近事件日, "count": 事件天数, "events": [{date, types, orgs, title, n_reports,
compound}, ...] 最新在前}}。types 是这一天命中的事件类型列表compound 为真表示同一篇研报
同时命中上调预测与超预期(研报的第四类),或同一天多篇研报命中不同类型。"""
if rows is None:
rows = analyst_reports(codes, ds, read_mysql=read_mysql)
by_code: dict = defaultdict(list)
for r in rows:
by_code[r["k"]].append(r)
try:
cut = (dt.date.fromisoformat(ds) - dt.timedelta(days=int(window_days))).isoformat()
except ValueError:
return {}
out = {}
for k, rs in by_code.items():
rs = sorted(rs, key=lambda r: r["date"])
dates = [r["date"] for r in rs]
by_org_q: dict = defaultdict(list)
for r in rs:
if r["eps"] is not None:
by_org_q[(r["org"], r["quarter"])].append(r)
days_hit: dict = {}
for r in rs:
if r["date"] <= cut or r["date"] > ds:
continue
types = []
if "超预期" in r["title"]:
types.append(EV_BEAT)
if r["type"] == "深度" and r["rating"] in BUY_RATINGS:
gap_start = (dt.date.fromisoformat(r["date"])
- dt.timedelta(days=EVENT_COVER_GAP_DAYS)).isoformat()
# 这篇之前 365 天内有没有任何研报(不含同一天)
if not any(gap_start < d < r["date"] for d in dates):
types.append(EV_DEEP)
if r["eps"] is not None and r["eps"] > 0:
look = (dt.date.fromisoformat(r["date"])
- dt.timedelta(days=EVENT_UPGRADE_LOOKBACK_DAYS)).isoformat()
prev = [p for p in by_org_q[(r["org"], r["quarter"])]
if look < p["date"] < r["date"] and p["eps"] and p["eps"] > 0]
if prev and r["eps"] >= EVENT_UPGRADE_RATIO * prev[-1]["eps"]:
types.append(EV_UPGRADE)
if not types:
continue
ev = days_hit.setdefault(r["date"], {"date": r["date"], "types": [], "orgs": [],
"title": r["title"][:60], "n_reports": 0,
"compound": False})
for t in types:
if t not in ev["types"]:
ev["types"].append(t)
if r["org"] not in ev["orgs"]:
ev["orgs"].append(r["org"])
ev["n_reports"] += 1
if EV_UPGRADE in types and EV_BEAT in types:
ev["compound"] = True
if not days_hit:
continue
events = sorted(days_hit.values(), key=lambda e: e["date"], reverse=True)
for e in events:
if len(e["types"]) > 1:
e["compound"] = True
out[k] = {"latest": events[0]["date"], "count": len(events), "events": events[:EVENT_KEEP]}
return out
# 事件日字段:研报"输入事实"的第三块。事件前 5 日与 20 日涨幅判有没有抢跑,事件日跳空、日内收益、
# 收盘位置、量比、涨停判市场有没有确认。行情表 gp_day_data 是前复权(用户 09-08 确认),跳空与
# 涨幅直接算。没有事件的票按数据日算同一组数(那时它回答的是"今天这根 K 线的样子")。
PRICE_HISTORY_DAYS = 100 # 有事件的票取多少天行情:事件最远 60 天,前面还要 20 个交易日算量与涨幅
PRICE_HISTORY_DAYS_NO_EVENT = 35 # 没有事件的票按数据日算,只要 21 个交易日的历史
def _limit_up_threshold(k: str) -> float:
"""涨停线(百分数):创业板与科创板两成,北交所三成,其余一成。用 9.8 而不是 10 是给四舍五入留余地。"""
s = str(k or "")
num = s[2:] if len(s) == 8 else s
if num.startswith(("30", "68")):
return 19.8
if num.startswith(("4", "8")):
return 29.8
return 9.8
def price_history(codes, ds: str, *, days: int = PRICE_HISTORY_DAYS, read_mysql=None,
code_col: str | None = None) -> dict:
"""每只票近 days 个自然日的前复权日线,按前缀码索引、按日升序。行情表的代码列是前缀式。"""
reader = read_mysql or db.read_mysql
codes = sorted({_prefix_any(c) for c in codes if c})
if not codes:
return {}
try:
if code_col is None:
import factors
code_col = factors._price_code_col() # noqa: SLF001 —— 同仓自用
end = dt.date.fromisoformat(ds)
start = (end - dt.timedelta(days=int(days))).isoformat()
nxt = (end + dt.timedelta(days=1)).isoformat()
marks = ",".join(["%s"] * len(codes))
df = reader("price",
f"SELECT `{code_col}` AS ts_code, `timestamp` AS d, open, high, low, close, "
f"pre_close, percent, volume FROM gp_day_data "
f"WHERE `timestamp` >= %s AND `timestamp` < %s AND `{code_col}` IN ({marks})",
(start, nxt) + tuple(codes))
except Exception as e: # noqa: BLE001
print(f" (行情表读取失败,事件日字段与定价状态整体缺席: {e!r}")
return {}
out: dict = defaultdict(list)
for r in _records(df):
k = _prefix_any(r.get("ts_code"))
d = _ymd(r.get("d"))
if not k or not d:
continue
out[k].append({"date": d, "open": _f(r.get("open")), "high": _f(r.get("high")),
"low": _f(r.get("low")), "close": _f(r.get("close")),
"pre_close": _f(r.get("pre_close")), "pct": _f(r.get("percent")),
"volume": _f(r.get("volume"))})
return {k: sorted(v, key=lambda r: r["date"]) for k, v in out.items()}
def event_day_fields(codes, ds: str, events: dict, *, hist=None, read_mysql=None,
code_col: str | None = None) -> dict:
"""每只票的事件日字段按前缀码索引。events 是 analyst_events 的返回;没有事件的票按数据日算。
六个数:事件前 5 日与 20 日涨幅、事件日跳空幅度(开盘对前收)、日内收益(收盘对开盘)、
收盘位置收盘在当日高低区间里的位置0 到 1、事件日量比对此前 20 个交易日均量),
外加当日涨幅与是否涨停。行情不够时相应字段为 None不硬算。"""
if hist is None:
# 两段取:没有事件的票按数据日算,只要 21 个交易日的历史35 个自然日够);有事件的票
# 事件最远 60 天,前面再要 20 个交易日,取 100 天。一段取 100 天要拉十二万行、十秒多,
# 计划的实时重算撑不起PMS 拉计划的超时是 60 秒),分两段行数少六成。
hist = price_history(codes, ds, days=PRICE_HISTORY_DAYS_NO_EVENT, read_mysql=read_mysql,
code_col=code_col)
with_event = [k for k in (events or {}) if k in {str(c).strip() for c in (codes or []) if c}]
if with_event:
hist.update(price_history(with_event, ds, days=PRICE_HISTORY_DAYS, read_mysql=read_mysql,
code_col=code_col))
out = {}
for k in {str(c).strip() for c in (codes or []) if c}:
rows = hist.get(k) or []
if not rows:
continue
ev = (events or {}).get(k)
target = ev["latest"] if ev else ds
idx = None
for i, r in enumerate(rows):
if r["date"] <= target:
idx = i
if idx is None:
continue
cur = rows[idx]
closes = [r["close"] for r in rows]
def _ret(back: int):
if idx - back < 0 or not closes[idx - 1] or not closes[idx - back - 1]:
return None
return closes[idx - 1] / closes[idx - back - 1] - 1
prev_close = cur["pre_close"] or (closes[idx - 1] if idx >= 1 else None)
gap = (cur["open"] / prev_close - 1) if cur["open"] and prev_close else None
intraday = (cur["close"] / cur["open"] - 1) if cur["close"] and cur["open"] else None
rng = (cur["high"] - cur["low"]) if cur["high"] is not None and cur["low"] is not None else None
close_pos = ((cur["close"] - cur["low"]) / rng) if rng and cur["close"] is not None else None
vols = [r["volume"] for r in rows[max(0, idx - 20):idx] if r["volume"]]
vol_ratio = (cur["volume"] / (sum(vols) / len(vols))) if cur["volume"] and len(vols) >= 5 else None
pct = cur["pct"]
out[k] = {"event_date": cur["date"], "has_event": bool(ev),
"pre5": _r4(_ret(5)), "pre20": _r4(_ret(20)),
"gap": _r4(gap), "intraday": _r4(intraday), "close_pos": _r4(close_pos),
"vol_ratio": None if vol_ratio is None else round(vol_ratio, 2),
"day_pct": None if pct is None else round(pct / 100.0, 4),
"limit_up": None if pct is None else bool(pct >= _limit_up_threshold(k)),
"history_days": idx}
return out
def _r4(v):
return None if v is None else round(float(v), 4)
# ---------------------------------------------------------------------------
# 相关快讯2026-09-08台账 050财联社电报表 gp_cls_telegraph153 库,与行情表同一连接)里
# 用 stock_list_json 点名了这只票的条目。只当上下文,不当"正向事件"——电报说的是发生了什么,
# 不说利好利空,正负由看卡的人与择时决策系统的研判去判;不进判决、不进定价状态、不进复盘分组。
# 窗口按自然日往前数、不封顶到数据日07:10 出计划时,昨晚与今晨的快讯都要在。
# ---------------------------------------------------------------------------
NEWS_WINDOW_DAYS = 3
NEWS_MAX_PER_CODE = 5
NEWS_TITLE_LEN = 60
_news_log = logging.getLogger("bridge.news")
def _norm_stock_id(sid) -> str | None:
"""财联社的 StockIDsh688293 / sz000998→ 前缀码SH688293。认不出返回 None。"""
s = str(sid or "").strip().upper()
if len(s) == 8 and s[:2] in ("SH", "SZ", "BJ") and s[2:].isdigit():
return s
return None
def _news_title(title, content) -> str:
t = str(title or content or "").strip()
t = re.sub(r"^财联社\d{1,2}月\d{1,2}日电[,]?", "", t).strip()
return t[:NEWS_TITLE_LEN] + ("" if len(t) > NEWS_TITLE_LEN else "")
def news_flashes(codes, ds: str, *, days: int = NEWS_WINDOW_DAYS, read_mysql=None,
limit_per_code: int = NEWS_MAX_PER_CODE) -> dict:
"""每只票近 days 个自然日被财联社电报点名的快讯,按前缀码索引;没被点名的票不在字典里。
返回 {k: {"count": 条数, "latest": "MM-DD HH:MM", "items": [{time, level, title, url}, ...] 最新在前}}。
level 是财联社自己的重要度A 红色、B 加粗、C 普通),原样带出不解释。表读不到返回空字典,不断产。"""
rm = read_mysql or db.read_mysql
want = {c for c in (codes or []) if c}
if not want:
return {}
try:
since = (dt.date.fromisoformat(ds) - dt.timedelta(days=int(days))).isoformat()
except ValueError:
return {}
try:
df = rm("factor",
"SELECT cls_id, level, publish_time, title, content, stock_list_json, article_url "
"FROM gp_cls_telegraph WHERE publish_time >= %s AND JSON_LENGTH(stock_list_json) > 0 "
"ORDER BY publish_time DESC", (since,))
except Exception as e: # noqa: BLE001 —— 表没建、连接失败:整体缺席
_news_log.warning("news_flashes 读不到 gp_cls_telegraph: %r", e)
return {}
out: dict = {}
for r in df.to_dict("records"):
raw = r.get("stock_list_json")
try:
lst = json.loads(raw) if isinstance(raw, (str, bytes)) else (raw or [])
except (TypeError, ValueError):
continue
ks = {k for k in (_norm_stock_id((it or {}).get("StockID")) for it in lst) if k and k in want}
if not ks:
continue
ts = r.get("publish_time")
when = ts.strftime("%m-%d %H:%M") if hasattr(ts, "strftime") else str(ts or "")[5:16]
item = {"time": when, "level": str(r.get("level") or "C"), "cls_id": r.get("cls_id"),
"title": _news_title(r.get("title"), r.get("content")), "url": r.get("article_url")}
for k in ks:
slot = out.setdefault(k, {"count": 0, "latest": when, "items": []})
slot["count"] += 1
if len(slot["items"]) < limit_per_code:
slot["items"].append(item)
return out
# ---------------------------------------------------------------------------
# 行业催化2026-09-08台账 051数据基座按环节评析的材料另标出来的"行业级催化事件"
# 挂在环节上不挂个股(表 segment_catalysts基座 PG。候选卡按票所在的被指向环节取
# 只展示、只分组复盘,不进判决、不进逻辑状态。表没建或没材料就整体缺席,不断产。
# ---------------------------------------------------------------------------
CATALYST_WINDOW_DAYS = 180
CATALYST_MAX_PER_SEGMENT = 3
_MAG_ORDER = {"": 0, "": 1, "": 2}
def segment_catalysts(segments, ds: str, *, days: int = CATALYST_WINDOW_DAYS, read_pg=None,
limit_per_segment: int = CATALYST_MAX_PER_SEGMENT) -> dict:
"""{环节名: [{segment, event_date, title, direction, magnitude, horizon, confidence, mechanism}, ...]}
每个环节按量级大→小、事件日新→旧排,最多 limit_per_segment 条。"""
rp = read_pg or db.read_pg
want = sorted({str(x) for x in (segments or []) if x})
if not want:
return {}
try:
since = (dt.date.fromisoformat(ds) - dt.timedelta(days=int(days))).isoformat()
except ValueError:
return {}
try:
df = rp("SELECT segment_name, event_date, first_seen, title, direction, magnitude, horizon, "
"confidence, mechanism FROM segment_catalysts "
"WHERE segment_name = ANY(%s) AND coalesce(event_date, first_seen) >= %s",
(want, since))
except Exception as e: # noqa: BLE001 —— 表没建、连接失败:整体缺席
logging.getLogger("bridge.catalyst").warning("segment_catalysts 读不到: %r", e)
return {}
rows = []
for r in df.to_dict("records"):
ev = r.get("event_date") or r.get("first_seen")
rows.append({"segment": str(r.get("segment_name")), "event_date": str(ev)[:10] if ev else None,
"title": str(r.get("title") or "")[:80], "direction": r.get("direction") or "利好",
"magnitude": r.get("magnitude") or "", "horizon": r.get("horizon") or "短期",
"confidence": r.get("confidence") or "",
"mechanism": (str(r.get("mechanism") or "")[:120] or None)})
rows.sort(key=lambda x: (_MAG_ORDER.get(x["magnitude"], 9), x["event_date"] or ""), reverse=False)
rows.sort(key=lambda x: (_MAG_ORDER.get(x["magnitude"], 9), -(int((x["event_date"] or "0000-00-00").replace("-", "") or 0))))
out: dict = {}
for x in rows:
slot = out.setdefault(x["segment"], [])
if len(slot) < limit_per_segment:
slot.append(x)
return out
def catalysts_for_codes(seg_of: dict, cat_by_seg: dict, *, limit: int = 3) -> dict:
"""按票汇总所在环节的行业催化:同标题去重,量级大→小,最多 limit 条。没有的票不在字典里。"""
out: dict = {}
for k, segs in (seg_of or {}).items():
seen: set = set()
lst = []
for seg in segs or []:
for x in cat_by_seg.get(seg) or []:
if x["title"] in seen:
continue
seen.add(x["title"])
lst.append(x)
if lst:
lst.sort(key=lambda x: (_MAG_ORDER.get(x["magnitude"], 9), -(int((x["event_date"] or "0000-00-00").replace("-", "") or 0))))
out[k] = lst[:limit]
return out
# ---------------------------------------------------------------------------
# 公司深度评析2026-09-09 接入方案,台账 053数据基座 company_review_runs 索引表最新一行的摘要。
# 质地由基座代码合成,这里只读不算;报告正文在对象存储,链接给人。表读不到整体缺席不断产。
# ---------------------------------------------------------------------------
def company_reviews(codes, ds: str, *, read_pg=None) -> dict:
"""{前缀码: {"period", "review_version", "overall", "groups", "valuation", "doubt_hard", "quality_reasons",
"grade_counts", "grade_line", "thesis", "confidence", "invalidation", "antithesis", "catalyst", "brain_status",
"ran_at", "age_days", "report_url"}};没报告的票不在字典里。"""
rp = read_pg or db.read_pg
want = sorted({c for c in (codes or []) if c})
if not want:
return {}
dots = [_to_dot(c) for c in want]
try:
df = rp("SELECT DISTINCT ON (ts_code) ts_code, period, review_version, grades, summary, brain_used, brain_status, ran_at "
"FROM company_review_runs WHERE status = 'ok' AND ts_code = ANY(%s) ORDER BY ts_code, ran_at DESC", (dots,))
except Exception as e: # noqa: BLE001
logging.getLogger("bridge.company_review").warning("company_reviews 读不到: %r", e)
return {}
out: dict = {}
for r in df.to_dict("records"):
sm = r.get("summary")
if isinstance(sm, str):
try:
sm = json.loads(sm)
except ValueError:
sm = None
sm = sm or {}
grades = r.get("grades")
if isinstance(grades, str):
try:
grades = json.loads(grades)
except ValueError:
grades = {}
ran = r.get("ran_at")
try:
ran_day = ran.date() if hasattr(ran, "date") else dt.date.fromisoformat(str(ran)[:10])
age = (dt.date.fromisoformat(ds) - ran_day).days
except (TypeError, ValueError, AttributeError):
ran_day, age = None, None
k = _prefix_any(str(r.get("ts_code")))
brain = sm.get("brain") or {}
if age is not None and age < 0:
age = 0 # 报告运行日晚于数据日(今早刚跑):算零天,不算负数
out[k] = {"period": _clean_str(r.get("period")), "review_version": _clean_str(r.get("review_version")),
"overall": sm.get("overall"), "groups": sm.get("groups"), "valuation": sm.get("valuation"),
"doubt_hard": bool(sm.get("doubt_hard")), "quality_reasons": sm.get("quality_reasons") or [],
"grade_counts": sm.get("grade_counts") or _count_grades(grades), "grade_line": sm.get("grade_line"),
"thesis": sm.get("thesis"), "confidence": sm.get("confidence"), "invalidation": sm.get("invalidation"),
"antithesis": sm.get("antithesis"), "catalyst": sm.get("catalyst"),
"brain_status": _clean_str(r.get("brain_status")) or _clean_str(brain.get("status")),
"brain_used": bool(r.get("brain_used")) if r.get("brain_used") not in (None, "") and r.get("brain_used") == r.get("brain_used") else False,
"ran_at": ran_day.isoformat() if ran_day else None, "age_days": age,
"report_url": f"{config.AKG_API_BASE}{sm.get('report_path') or '/company/' + str(r.get('ts_code')) + '/review'}"}
return out
def _clean_str(v):
"""空列经 pandas 会变成 NaN浮点JSON 序列化直接失败;统一收成 None。"""
if v is None:
return None
if isinstance(v, float) and v != v:
return None
s = str(v).strip()
return s or None
def _count_grades(grades) -> dict:
out = {"": 0, "": 0, "": 0, "证据不足": 0}
for v in (grades or {}).values():
out[v] = out.get(v, 0) + 1
return out