625 lines
37 KiB
Python
625 lines
37 KiB
Python
"""候选单复盘(只读):这个系统唯一的评价方式——名单级观察收益,不是回测。
|
||
|
||
## 为什么要有它
|
||
|
||
方案(docs/主观选股改进方案_2026-09-02.md 第 2.4 节)把评价单位定为"候选单"而不是因子:
|
||
不建仓、不计成本、不定仓位、不算净值,只看几份名单在后五、十、二十个交易日相对
|
||
全池与主榜的超额和命中率,按环境与证据线分组,每周五出报告。它取代了原对比器
|
||
score_lab(两套读数工具两种口径的坑),也承接了 09-02 手工归因的三组读数:
|
||
第一步先"对账"——用 signal_close 口径复现方案 1.8b 的四档读数(强传导 −2.04、
|
||
观察档 +0.52),证明脚本口径与手工一致;第二步再切常规口径 next_close 出周报。
|
||
|
||
本脚本不提供权重参数,只提供分组与期限——防止复盘变成调参或模拟交易。
|
||
|
||
## 六份名单
|
||
|
||
生产名单 PMS 实际拿到的候选:PMS 计划快照名册(pms_plan_snapshot.roster_json)里档位为
|
||
强传导、按分数降序前 N(N 取当时 PMS_PLAN_TOP_N,历史值不可还原,用现值并注明);
|
||
快照缺失的日子退为桥按同规则重算并标注
|
||
候选单 候选卡判决为"候选"(候选卡上线日之前的历史日,按当日快照或重算得出,
|
||
"已启动"依赖已动成员视图,视图未建的日子候选为空并标注)
|
||
关注单 判决为"关注"
|
||
环节名单 当日被传导指向的环节的全体成员等权(把"定位对"与"选票对"分开)
|
||
主榜等权 当日全部主榜
|
||
全池等权 基座行情快照当日全部个股(基准)
|
||
|
||
## 另四份名单(2026-09-03 方案第 3.3 节"复盘四份名单与对照节")
|
||
|
||
机器通过名单 PMS 动作账本 pms_action_ledger 当日 action='OPEN'、arbiter='judge'、verdict='PASS' 的票
|
||
人批名单 同表当日 arbiter='user'、verdict='PASS'
|
||
人拒名单 同表当日 arbiter='user'、verdict='REJECT'
|
||
择时看多名单 择时决策系统结论表 strategy_daily_results 当日 signal_type='BUY' 的票
|
||
账本按 decided_at 的日历日筛,代码统一转前缀式;任一路读失败该名单为空并在逐日注记里说明。
|
||
周报第七节"三套对照"把选股系统候选单、择时决策系统自评、PMS 账本四份名单摆在一张表里;
|
||
第八节"台账对表"列出 docs/复盘决定台账.md 的条目,留"一致 / 不一致"两列给人填。
|
||
|
||
## 口径
|
||
|
||
起点价 next_close(默认,实盘买得到):T 日出计划,T+1 收盘买,收益 = Σ pct[T+2 .. T+1+h]
|
||
signal_close(对账用):收益 = Σ pct[T+1 .. T+h],与方案 1.8 系列的手工口径一致
|
||
期限 5 / 10 / 20 个交易日
|
||
指标 均收益、相对全池超额、相对主榜超额、跑赢全池比例(命中率)
|
||
分组 档位、判决、吸筹三态(基座行情快照当日的 accum 状态)、事后环境(全池后 h 日涨跌,
|
||
只作解释)、事前标签(当日快照 regime 段,上线后才有)、市值三分位(成交额除以换手率)
|
||
|
||
## 跑法【桥机 155 · ~/project/akg-factor-bridge】
|
||
|
||
docker compose exec -T akg-factor-bridge python plan_review.py --since 2026-07-29 --horizons 5,10,20
|
||
docker compose exec -T akg-factor-bridge python plan_review.py --since 2026-07-29 --start-price signal_close --horizons 5 # 对账 1.8b
|
||
|
||
只读:因子表、基座视图与行情快照、PMS 计划快照全部 SELECT;只写 data/review/ 下的报告与明细。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import datetime as dt
|
||
import json
|
||
import os
|
||
import re
|
||
|
||
import pandas as pd
|
||
|
||
import common
|
||
import config
|
||
import db
|
||
import plan
|
||
|
||
HORIZONS_DEFAULT = (5, 10, 20)
|
||
MAIN_MIN = 150.0
|
||
LEDGER_LISTS = ("机器通过名单", "人批名单", "人拒名单")
|
||
TIMING_LIST = "择时看多名单"
|
||
DECISION_LEDGER_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "docs", "复盘决定台账.md")
|
||
|
||
|
||
# ============================================================================
|
||
# 数据
|
||
# ============================================================================
|
||
def plan_dates(since: str, until: str | None) -> list[str]:
|
||
q = "SELECT DISTINCT trade_date FROM t_factor_akg_gate WHERE trade_date >= %s"
|
||
args = [since]
|
||
if until:
|
||
q += " AND trade_date <= %s"
|
||
args.append(until)
|
||
df = db.read_mysql("factor", q + " ORDER BY trade_date", tuple(args))
|
||
return [pd.Timestamp(x).date().isoformat() for x in df["trade_date"]]
|
||
|
||
|
||
def price_panel(since: str, days_after: int = 30) -> pd.DataFrame:
|
||
"""基座行情快照:trade_date × 前缀码 -> 日涨幅(百分数)。"""
|
||
end = (dt.date.fromisoformat(since) + dt.timedelta(days=200)).isoformat()
|
||
df = db.read_pg(
|
||
"SELECT trade_date, code, (metrics->>'pct_change')::float AS pct, "
|
||
"metrics->'accum'->>'state' AS accum FROM mkt_daily "
|
||
"WHERE kind='stock' AND trade_date >= %s AND trade_date <= %s", (since, end))
|
||
df["k"] = df["code"].map(common.to_prefix)
|
||
df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.date.astype(str)
|
||
return df
|
||
|
||
|
||
def cap_bucket(day: str) -> dict[str, str]:
|
||
"""市值三分位(成交额除以换手率的近似流通市值),同日分桶。读不到返回空。"""
|
||
try:
|
||
df = db.read_mysql(
|
||
"factor", "SELECT symbol, amount, turnoverrate FROM gp_day_data "
|
||
"WHERE DATE(`timestamp`) = %s AND turnoverrate > 0 AND amount > 0", (day,))
|
||
except Exception as e: # noqa: BLE001
|
||
print(f" (市值分桶读取失败 {day}: {e!r})")
|
||
return {}
|
||
if df.empty:
|
||
return {}
|
||
df["k"] = df["symbol"].astype(str).str.strip().map(common.to_prefix)
|
||
df["mv"] = df["amount"] / df["turnoverrate"]
|
||
try:
|
||
df["cap"] = pd.qcut(df["mv"], 3, labels=["小盘", "中盘", "大盘"])
|
||
except ValueError:
|
||
return {}
|
||
return dict(zip(df["k"], df["cap"].astype(str)))
|
||
|
||
|
||
def pms_roster(day: str) -> tuple[list[str], str]:
|
||
"""PMS 当日拿到的生产名单:计划快照名册里档位强传导、按分数序前 N。返回 (代码, 注记)。"""
|
||
try:
|
||
df = db.read_mysql(
|
||
"pms", "SELECT roster_json, fetched_at FROM pms_plan_snapshot "
|
||
"WHERE plan_date = %s ORDER BY id DESC LIMIT 1", (day,))
|
||
n_df = db.read_mysql(
|
||
"pms", "SELECT param_value FROM pms_runtime_param WHERE param_key='PMS_PLAN_TOP_N'")
|
||
except Exception as e: # noqa: BLE001
|
||
# 异常原文会被印进周报的逐日注记。只进日志,不进报告。
|
||
print(f" (PMS 计划快照读取失败: {e!r})")
|
||
return [], "当天没能连上 PMS 数据库,取不到计划快照"
|
||
if df.empty:
|
||
return [], "PMS 无当日快照,生产名单退为桥重算"
|
||
n = int(n_df.iloc[0, 0]) if not n_df.empty else 30
|
||
roster = json.loads(df.iloc[0]["roster_json"] or "[]")
|
||
rows = [r for r in roster if str(r.get("t") or "") == "强传导" and str(r.get("b") or "main") == "main"]
|
||
rows.sort(key=lambda r: -(r.get("s") or 0))
|
||
return [common.to_prefix(str(r["c"]).strip()) for r in rows[:n] if r.get("c")], \
|
||
f"PMS 快照名册(N={n} 为现值,历史 N 不可还原)"
|
||
|
||
|
||
def _uniq_codes(values) -> list[str]:
|
||
"""代码列 -> 去重、去占位符(账本里宏观闸等行的 ts_code 是 "-")、转前缀式,保持出现顺序。"""
|
||
out, seen = [], set()
|
||
for v in values:
|
||
s = str(v or "").strip()
|
||
if not s or s == "-" or s.lower() == "nan":
|
||
continue
|
||
k = common.to_prefix(s.upper())
|
||
if k not in seen:
|
||
seen.add(k)
|
||
out.append(k)
|
||
return out
|
||
|
||
|
||
def ledger_lists(day: str) -> tuple[dict[str, list[str]], str]:
|
||
"""PMS 动作账本当日新建仓评审的三份名单:机器通过(研判闸 judge 放行)、人批、人拒。
|
||
decided_at 按日历日筛(day 零点到次日零点);读失败三份都为空并返回原因。"""
|
||
empty = {name: [] for name in LEDGER_LISTS}
|
||
nxt = (dt.date.fromisoformat(day) + dt.timedelta(days=1)).isoformat()
|
||
try:
|
||
df = db.read_mysql(
|
||
"pms", "SELECT ts_code, arbiter, verdict FROM pms_action_ledger "
|
||
"WHERE action = 'OPEN' AND decided_at >= %s AND decided_at < %s", (day, nxt))
|
||
except Exception as e: # noqa: BLE001
|
||
return empty, f"PMS 账本读取失败({e!r}),机器通过、人批、人拒三份名单为空"
|
||
if df.empty:
|
||
return empty, "PMS 账本当日无新建仓评审行"
|
||
arb = df["arbiter"].astype(str).str.strip().str.lower()
|
||
vd = df["verdict"].astype(str).str.strip().str.upper()
|
||
return {
|
||
"机器通过名单": _uniq_codes(df.loc[(arb == "judge") & (vd == "PASS"), "ts_code"]),
|
||
"人批名单": _uniq_codes(df.loc[(arb == "user") & (vd == "PASS"), "ts_code"]),
|
||
"人拒名单": _uniq_codes(df.loc[(arb == "user") & (vd == "REJECT"), "ts_code"]),
|
||
}, f"PMS 账本当日评审行 {len(df)} 条"
|
||
|
||
|
||
def timing_bullish(day: str) -> tuple[list[str], str]:
|
||
"""择时决策系统结论表当日 signal_type='BUY' 的票(trade_date 是整数 YYYYMMDD)。读失败为空。"""
|
||
try:
|
||
df = db.read_mysql(
|
||
"pms", "SELECT stock_code FROM strategy_daily_results "
|
||
"WHERE trade_date = %s AND signal_type = 'BUY'", (int(day.replace("-", "")),))
|
||
except Exception as e: # noqa: BLE001
|
||
return [], f"择时决策系统结论表读取失败({e!r}),择时看多名单为空"
|
||
return _uniq_codes(df["stock_code"]) if not df.empty else [], ""
|
||
|
||
|
||
def timing_self_eval(since: str, until: str | None) -> str:
|
||
"""择时决策系统自评口径与本期读数:判分表 decision_outcome 里日终策略(ref_type='strategy')
|
||
五日方向命中率——命中按原始收益方向判,不是超额(方案第 1.3 节列为已知缺陷)。读不到写"未接入"。"""
|
||
lo = int(since.replace("-", ""))
|
||
hi = int((until or dt.date.today().isoformat()).replace("-", ""))
|
||
try:
|
||
df = db.read_mysql(
|
||
"pms", "SELECT COUNT(*) AS n, SUM(dir_hit) AS hits FROM decision_outcome "
|
||
"WHERE ref_type = 'strategy' AND horizon = 5 AND dir_hit IS NOT NULL "
|
||
"AND base_date >= %s AND base_date <= %s", (lo, hi))
|
||
n = int(df.iloc[0]["n"] or 0) if not df.empty else 0
|
||
hits = int(df.iloc[0]["hits"] or 0) if not df.empty else 0
|
||
except Exception as e: # noqa: BLE001
|
||
# 表名是 PMS 的库表名,不该出现在周报的读数列里。原因写进运行日志。
|
||
print(f" (择时决策系统的打分表读取失败: {e!r})")
|
||
return (f"读不到择时决策系统的打分数据,"
|
||
f"{since} 至 {until or '今日'} 这段的五日方向命中率这期算不出来")
|
||
if n == 0:
|
||
# 与"读不到"分开写:这两种情况在表里要一眼看得出差别。
|
||
return "择时决策系统这段时间没有打过分的日终策略记录,这期没有命中率"
|
||
return (f"看 5 个交易日后涨跌方向判断对不对,{n} 条里对了 {hits} 条,"
|
||
f"命中率 {hits / n * 100:.0f}%({since} 至 {until or '今日'})")
|
||
|
||
|
||
def decision_ledger_entries(path: str = DECISION_LEDGER_PATH) -> list[dict]:
|
||
"""解析 docs/复盘决定台账.md 的条目标题行 "## 0NN · 日期 · 标题" -> [{no, date, title}]。
|
||
文件不存在或没有条目返回空列表,周报对表节据此写"台账文件缺失"。"""
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
text = f.read()
|
||
except OSError:
|
||
return []
|
||
pat = re.compile(r"^##\s+(\d{3})\s*·\s*(\d{4}-\d{2}-\d{2})\s*·\s*(.+?)\s*$", re.M)
|
||
return [{"no": m.group(1), "date": m.group(2), "title": m.group(3)} for m in pat.finditer(text)]
|
||
|
||
|
||
# ============================================================================
|
||
# 收益
|
||
# ============================================================================
|
||
def forward(pivot: pd.DataFrame, days: list[str], day: str, h: int, start: str) -> pd.Series | None:
|
||
"""从 day 起按口径取后 h 日累计涨幅(每票)。数据不够返回 None。"""
|
||
if day not in days:
|
||
return None
|
||
i = days.index(day)
|
||
lo = i + (2 if start == "next_close" else 1)
|
||
hi = lo + h # 切片 [lo, hi)
|
||
if hi > len(days):
|
||
return None
|
||
block = pivot.loc[days[lo:hi]]
|
||
return block.sum(min_count=h)
|
||
|
||
|
||
# 表头的中文说法。周报是给人读的,列名不该是代码里的字段名。
|
||
# 有几处必须区分开,不能想当然地译:
|
||
# excess_all 与 beat 都跟"全池"比,但一个是收益差、一个是只数比例,
|
||
# 译成同一个说法会在同一张表里紧挨着撞车;
|
||
# h 不能译成"持有天数"——这个脚本开头反复写明不建仓、不计成本、不是回测,
|
||
# 它只是"往后看几个交易日";
|
||
# grade 只有两档(可读、方向),代码在 :387,导读段落此前写着"三档"是错的。
|
||
_COL_CN = {
|
||
"days": "一共几个计划日", "n": "一共几只股票", "ret": "名单平均涨跌%",
|
||
"excess_all": "比全池多涨几个点", "excess_main": "比主榜多涨几个点",
|
||
"excess_cap": "比同市值股票多涨几个点",
|
||
"beat": "跑赢全池的股票占比%", "share": "最多的一天占了多少%",
|
||
"grade": "样本够不够看", "h": "往后看几个交易日",
|
||
"list": "名单", "group": "分组", "date": "计划日",
|
||
"regime_post": "这几天全池是涨是跌", "regime_pre": "下单当天是不是弱势日",
|
||
}
|
||
|
||
|
||
# 加权版列名到本体列名的对应。多数是直接去掉 _w 后缀就能对上,只有 excess_w 例外——
|
||
# 它算的是相对全池的超额(:433),本体列叫 excess_all,去掉后缀查不到。
|
||
_W_BASE = {"excess_w": "excess_all"}
|
||
|
||
|
||
def _col_cn(c) -> str:
|
||
"""列名翻成中文。带 _w 后缀的是同一个读数的样本加权版本,只用于对账。
|
||
|
||
翻不出来的原样返回而不是硬编一个中文——列名对不上时宁可露出原名让人发现,
|
||
也好过给它安一个错的说法。
|
||
"""
|
||
name = str(c)
|
||
if name.endswith("_w"):
|
||
base = _COL_CN.get(_W_BASE.get(name) or name[:-2])
|
||
return f"股票多的日子算得重·{base}" if base else name
|
||
return _COL_CN.get(name, name)
|
||
|
||
|
||
def _md(df: pd.DataFrame) -> str:
|
||
"""自己拼 Markdown 表:桥镜像没装 tabulate,pandas.to_markdown 用不了。
|
||
|
||
表头统一在这里翻成中文,每张表都受益,不用逐处改。"""
|
||
if df is None or df.empty:
|
||
return "(无数据)"
|
||
cols = [_col_cn(c) for c in df.columns]
|
||
lines = ["| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)]
|
||
for _, r in df.iterrows():
|
||
lines.append("| " + " | ".join("" if (isinstance(v, float) and pd.isna(v)) else str(v)
|
||
for v in r.tolist()) + " |")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def summarize(ret: pd.Series, codes: list[str], base_all: float, base_main: float,
|
||
caps: dict | None = None, cap_base: dict | None = None) -> dict | None:
|
||
"""一份名单在一个计划日、一个期限上的读数。
|
||
excess_all 相对全池等权;excess_main 相对主榜等权(同为有券商覆盖的篮子,剥掉大票对小票的贝塔);
|
||
excess_cap 相对同市值桶均值(每票减当日同桶全池均值再平均,方案 1.8c 的市值中性口径)。"""
|
||
r = ret.reindex([c for c in codes if c in ret.index]).dropna()
|
||
if r.empty:
|
||
return None
|
||
out = {"n": int(len(r)), "ret": round(float(r.mean()), 2),
|
||
"excess_all": round(float(r.mean() - base_all), 2),
|
||
"excess_main": round(float(r.mean() - base_main), 2) if base_main is not None else None,
|
||
"excess_cap": None,
|
||
"beat": round(float((r > base_all).mean() * 100), 1)}
|
||
if caps and cap_base:
|
||
adj = [float(v) - cap_base[caps[c]] for c, v in r.items() if caps.get(c) in cap_base]
|
||
if adj:
|
||
out["excess_cap"] = round(sum(adj) / len(adj), 2)
|
||
return out
|
||
|
||
|
||
# ============================================================================
|
||
# 主流程
|
||
# ============================================================================
|
||
def _pricing_spread_md(groups_tbl: pd.DataFrame) -> str:
|
||
"""定价状态的"多空差"式对照(2026-09-08):候选单里价格发现与趋势延续两组的超额均值,减去高位兑现组,
|
||
按期限各一行。研报用它检验方向区分能力;我们只作名单级观察,样本不够只看方向。"""
|
||
if groups_tbl is None or groups_tbl.empty or "group" not in groups_tbl.columns:
|
||
return ""
|
||
g = groups_tbl[(groups_tbl["list"] == "候选单") & groups_tbl["group"].astype(str).str.startswith("定价状态=")]
|
||
if g.empty:
|
||
return "(定价状态分组本期无样本,多空差不算。)"
|
||
lines = ["定价状态的多空差(候选单,价格发现与趋势延续两组的「比全池多涨几个点」均值,减去高位兑现组):", ""]
|
||
for h, gh in g.groupby("h"):
|
||
val = {str(r["group"]).replace("定价状态=", ""): r for _, r in gh.iterrows()}
|
||
bull = [val[k] for k in ("价格发现", "趋势延续") if k in val]
|
||
bear = val.get("高位兑现")
|
||
if not bull or bear is None:
|
||
lines.append(f"- {h} 日:两端不齐(多头端 {len(bull)} 组,高位兑现组{'有' if bear is not None else '无'}),不算。")
|
||
continue
|
||
bull_ex = sum(float(r["excess_all"]) for r in bull) / len(bull)
|
||
n_bull = sum(int(r["n"]) for r in bull)
|
||
diff = bull_ex - float(bear["excess_all"])
|
||
lines.append(f"- {h} 日:多头端 {n_bull} 只样本、比全池多涨 {bull_ex:+.2f} 个点;高位兑现 {int(bear['n'])} 只、"
|
||
f"{float(bear['excess_all']):+.2f} 个点;差 {diff:+.2f} 个点,样本"
|
||
f"{'够' if n_bull >= 100 and int(bear['n']) >= 30 else '不够,只看方向'}。")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def run(since: str, until: str | None, horizons: tuple, start: str, out_dir: str,
|
||
with_cards: bool = True) -> dict:
|
||
days_plan = plan_dates(since, until)
|
||
if not days_plan:
|
||
raise SystemExit("区间内没有档位日。")
|
||
px = price_panel(since)
|
||
pivot = px.pivot_table(index="trade_date", columns="k", values="pct")
|
||
days = sorted(pivot.index.tolist())
|
||
accum_by_day = {d: dict(zip(g["k"], g["accum"])) for d, g in px.groupby("trade_date")}
|
||
|
||
rows, notes = [], []
|
||
for day in days_plan:
|
||
try:
|
||
data = plan.collect(day, top=5000, obs_top=5000, theme_cap=0)
|
||
except Exception as e: # noqa: BLE001
|
||
notes.append(f"{day}: 计划重算失败 {e!r}")
|
||
continue
|
||
full = data.get("_full") or {}
|
||
main_rows, obs_rows = full.get("main", []), full.get("observe", [])
|
||
main_codes = [r["code"] for r in main_rows]
|
||
obs_codes = [r["code"] for r in obs_rows]
|
||
tier_of = {r["code"]: r.get("tier") for r in main_rows}
|
||
verdict_of = {r["code"]: r.get("verdict") for r in main_rows + obs_rows}
|
||
seg_codes = sorted({r["code"] for r in main_rows + obs_rows if r.get("evidence")})
|
||
prod, prod_note = pms_roster(day)
|
||
if not prod:
|
||
# 无 PMS 快照的日子不再退化为桥重算(那不是 PMS 拿到的名单):交付名单当日剔除并注记(台账 010)
|
||
prod_note = f"{prod_note};无快照,交付名单当日剔除"
|
||
cands = [r["code"] for r in data.get("candidates") or []]
|
||
watch = [r["code"] for r in data.get("watch") or []]
|
||
ledger, ledger_note = ledger_lists(day)
|
||
bullish, bullish_note = timing_bullish(day)
|
||
caps = cap_bucket(day)
|
||
acc = accum_by_day.get(day, {})
|
||
reg = None
|
||
try:
|
||
import regime
|
||
reg = regime.read_from_snapshot(day)
|
||
except Exception: # noqa: BLE001
|
||
reg = None
|
||
|
||
for h in horizons:
|
||
ret = forward(pivot, days, day, h, start)
|
||
if ret is None:
|
||
continue
|
||
base_all = float(ret.dropna().mean())
|
||
main_ret = ret.reindex([c for c in main_codes if c in ret.index]).dropna()
|
||
base_main = float(main_ret.mean()) if not main_ret.empty else None
|
||
cap_base: dict = {} # 当日三个市值桶各自的全池均值,供 excess_cap 用
|
||
if caps:
|
||
for cap in ("小盘", "中盘", "大盘"):
|
||
v = ret.reindex([c for c, b in caps.items() if b == cap and c in ret.index]).dropna()
|
||
if not v.empty:
|
||
cap_base[cap] = float(v.mean())
|
||
regime_post = "涨周" if base_all > 0 else "跌周"
|
||
regime_pre = (("弱势日" if reg.get("weak_day") else "非弱势日")
|
||
if reg and reg.get("weak_day") is not None else "无标签")
|
||
lists = {
|
||
"强传导交付名单": prod, "候选单": cands, "关注单": watch, "环节名单": seg_codes,
|
||
"主榜等权": main_codes, "观察档等权": obs_codes,
|
||
"全池等权": list(ret.dropna().index),
|
||
# 2026-09-03:PMS 账本三份与择时看多一份,与其余名单同口径算收益
|
||
**ledger, TIMING_LIST: bullish,
|
||
}
|
||
for name, codes in lists.items():
|
||
s = summarize(ret, codes, base_all, base_main, caps, cap_base)
|
||
if s:
|
||
rows.append({"date": day, "h": h, "list": name, "group": "全部",
|
||
"regime_post": regime_post, "regime_pre": regime_pre, **s})
|
||
# 分组:档位、判决、吸筹三态、市值
|
||
for tier in ("强传导", "弱传导", "无传导"):
|
||
codes = [c for c, t in tier_of.items() if t == tier]
|
||
s = summarize(ret, codes, base_all, base_main, caps, cap_base)
|
||
if s:
|
||
rows.append({"date": day, "h": h, "list": "主榜", "group": f"档位={tier}",
|
||
"regime_post": regime_post, "regime_pre": regime_pre, **s})
|
||
for v in ("候选", "关注", "仅展示"):
|
||
codes = [c for c, vv in verdict_of.items() if vv == v]
|
||
s = summarize(ret, codes, base_all, base_main, caps, cap_base)
|
||
if s:
|
||
rows.append({"date": day, "h": h, "list": "档位表", "group": f"判决={v}",
|
||
"regime_post": regime_post, "regime_pre": regime_pre, **s})
|
||
for st_label, pred in (("明确吸筹", lambda s: str(s).startswith("明确")),
|
||
("潜在吸筹", lambda s: str(s).startswith("潜在")),
|
||
("其他", lambda s: not (str(s).startswith("明确") or str(s).startswith("潜在")))):
|
||
codes = [c for c, s in acc.items() if s and pred(s)]
|
||
s = summarize(ret, codes, base_all, base_main, caps, cap_base)
|
||
if s:
|
||
rows.append({"date": day, "h": h, "list": "全池", "group": f"吸筹={st_label}",
|
||
"regime_post": regime_post, "regime_pre": regime_pre, **s})
|
||
if caps:
|
||
for cap in ("小盘", "中盘", "大盘"):
|
||
codes = [c for c in cands if caps.get(c) == cap]
|
||
s = summarize(ret, codes, base_all, base_main, caps, cap_base)
|
||
if s:
|
||
rows.append({"date": day, "h": h, "list": "候选单", "group": f"市值={cap}",
|
||
"regime_post": regime_post, "regime_pre": regime_pre, **s})
|
||
# 定价状态四情形与有无催化事件(2026-09-08《量价研判链吸收方案》3.4 第三件):
|
||
# 候选单与档位表各分一次,只作观察,决定"要不要当门槛"的依据在这里攒。
|
||
pricing_of = {r["code"]: (r.get("pricing_state") or {}).get("state") for r in main_rows + obs_rows}
|
||
event_of = {r["code"]: bool((r.get("events") or {}).get("events")) for r in main_rows + obs_rows}
|
||
for lst_name, lst_codes in (("候选单", cands), ("档位表", list(pricing_of))):
|
||
for st_name in ("价格发现", "趋势延续", "高位兑现", "震荡消化", "算不出"):
|
||
codes = [c for c in lst_codes if (pricing_of.get(c) or "算不出") == st_name]
|
||
s = summarize(ret, codes, base_all, base_main, caps, cap_base)
|
||
if s:
|
||
rows.append({"date": day, "h": h, "list": lst_name, "group": f"定价状态={st_name}",
|
||
"regime_post": regime_post, "regime_pre": regime_pre, **s})
|
||
for flag, label in ((True, "有"), (False, "无")):
|
||
codes = [c for c in lst_codes if event_of.get(c, False) is flag]
|
||
s = summarize(ret, codes, base_all, base_main, caps, cap_base)
|
||
if s:
|
||
rows.append({"date": day, "h": h, "list": lst_name, "group": f"催化事件={label}",
|
||
"regime_post": regime_post, "regime_pre": regime_pre, **s})
|
||
# 行业催化有无(2026-09-08 台账 051):环节级催化,只作观察分组;材料未到时全部落"无"。
|
||
ind_of = {r["code"]: bool(r.get("industry_catalyst")) for r in main_rows + obs_rows}
|
||
for lst_name, lst_codes in (("候选单", cands), ("档位表", list(ind_of))):
|
||
for flag, label in ((True, "有"), (False, "无")):
|
||
codes = [c for c in lst_codes if ind_of.get(c, False) is flag]
|
||
s = summarize(ret, codes, base_all, base_main, caps, cap_base)
|
||
if s:
|
||
rows.append({"date": day, "h": h, "list": lst_name, "group": f"行业催化={label}",
|
||
"regime_post": regime_post, "regime_pre": regime_pre, **s})
|
||
notes.append(f"{day}: 主榜 {len(main_codes)} 观察 {len(obs_rows)} 候选 {len(cands)} "
|
||
f"关注 {len(watch)} 生产 {len(prod)}({prod_note});"
|
||
f"账本 机器通过 {len(ledger['机器通过名单'])} 人批 {len(ledger['人批名单'])} "
|
||
f"人拒 {len(ledger['人拒名单'])}({ledger_note});择时看多 {len(bullish)}"
|
||
+ (f"({bullish_note})" if bullish_note else ""))
|
||
|
||
df = pd.DataFrame(rows)
|
||
if df.empty:
|
||
raise SystemExit("没有任何可算的期限(数据尾部不足)。")
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
stamp = days_plan[-1]
|
||
csv_path = os.path.join(out_dir, f"复盘明细_{stamp}_{start}.csv")
|
||
df.to_csv(csv_path, index=False, encoding="utf-8-sig")
|
||
|
||
# 汇总:按名单 × 期限(全部);按分组 × 期限;按事后环境 × 名单(只作解释)
|
||
def agg(g: pd.DataFrame) -> pd.Series:
|
||
# 主口径按日等权:每个计划日先算名单均值,再跨日平均——一份名单一天一票;
|
||
# 带 _w 的三列按样本加权(大名单的日子权重大),与方案 1.8b/1.8c 手工读数同口径,只作对账。
|
||
n = g["n"].astype(float)
|
||
w = n / n.sum() if n.sum() else n
|
||
days, total = g["date"].nunique(), int(n.sum())
|
||
# 样本纪律(方案 2.4)机器标注,只有两档:可读=计划日≥20 且样本≥100;其余只看方向
|
||
grade = "够" if (days >= 20 and total >= 100) else "不够,只看方向"
|
||
return pd.Series({"days": days, "n": total,
|
||
"ret": round(g["ret"].mean(), 2),
|
||
"excess_all": round(g["excess_all"].mean(), 2),
|
||
"excess_main": round(g["excess_main"].mean(), 2) if g["excess_main"].notna().any() else None,
|
||
"excess_cap": round(g["excess_cap"].mean(), 2) if g["excess_cap"].notna().any() else None,
|
||
"beat": round(g["beat"].mean(), 1),
|
||
"share": round(float(n.max() / n.sum() * 100), 0) if n.sum() else None,
|
||
"grade": grade,
|
||
"ret_w": round(float((g["ret"] * w).sum()), 2),
|
||
"excess_w": round(float((g["excess_all"] * w).sum()), 2),
|
||
"beat_w": round(float((g["beat"] * w).sum()), 1)})
|
||
lists_tbl = df[df["group"] == "全部"].groupby(["list", "h"]).apply(agg).reset_index()
|
||
groups_tbl = df[df["group"] != "全部"].groupby(["list", "group", "h"]).apply(agg).reset_index()
|
||
regime_tbl = df[df["group"] == "全部"].groupby(["regime_post", "list", "h"]).apply(agg).reset_index()
|
||
pre_tbl = df[(df["group"] == "全部") & (df["regime_pre"] != "无标签")] \
|
||
.groupby(["regime_pre", "list", "h"]).apply(agg).reset_index()
|
||
h0 = int(df["h"].min())
|
||
daily_c = df[(df["list"] == "候选单") & (df["group"] == "全部") & (df["h"] == h0)] \
|
||
[["date", "n", "ret", "excess_all", "excess_cap", "beat", "regime_post"]].sort_values("date")
|
||
|
||
md = [f"# 候选单复盘 · {days_plan[0]} 至 {stamp}(起点价 {start})", "",
|
||
f"这份周报覆盖 {len(days_plan)} 个计划日。"
|
||
f"一份名单要够二十个计划日、且总共够一百只股票,读数才算够看;"
|
||
f"不够的只看方向,不看具体数值。", "",
|
||
"## 一、六份名单 × 期限", "",
|
||
"怎么读这张表。"
|
||
"「比全池多涨几个点」是拿这份名单和当天全部股票的平均涨跌相比;"
|
||
"「比主榜多涨几个点」是和主榜相比,这样能去掉大盘股和小盘股整体差异的影响;"
|
||
"「比同市值股票多涨几个点」是只和市值相近的股票比。"
|
||
"「跑赢全池的股票占比」和上面几列不是一回事:上面是收益差,这一列是只数比例。"
|
||
"全池自己这一行的占比只有四成多,因为少数大涨的股票把平均值拉高了;"
|
||
"所以读别的名单时要拿它和全池那一行相减,不能直接看绝对值。"
|
||
"「最多的一天占了多少」超过三成,说明整个读数被一天主导,那就只看逐日那张表。"
|
||
"「往后看几个交易日」不是持有天数——这个脚本不建仓、不计成本,不是回测。"
|
||
"不带前缀的列按计划日等权,一天算一票,这是主口径;"
|
||
"带「股票多的日子算得重」前缀的列是另一种算法,只用来和手工读数对账。", "",
|
||
_md(lists_tbl), "",
|
||
"## 二、分组读数", "", _md(groups_tbl), "",
|
||
"分组里的「定价状态」与「催化事件」两类是 2026-09-08 起加的:定价状态按最近一次券商正向事件日的"
|
||
"事件前涨幅、事件日跳空、收盘位置与量比归成四情形,催化事件是近 60 天有没有深度覆盖、上调盈利预测、"
|
||
"业绩超预期。两者都只展示不进判决,这两组读数是将来决定要不要当门槛的依据。", "",
|
||
_pricing_spread_md(groups_tbl), "",
|
||
f"## 二之二、候选单逐日(期限 {h0} 日;第一节按日等权的读数就是这张表的平均,看集中度)", "",
|
||
_md(daily_c), "",
|
||
"## 三、按事后环境分组(未来 h 日全池涨跌,只作解释,不作交易前置)", "",
|
||
_md(regime_tbl), ""]
|
||
if not pre_tbl.empty:
|
||
md += ["## 四、按事前线上标签分组(快照 regime 段,上线后才有)", "",
|
||
_md(pre_tbl), ""]
|
||
else:
|
||
md += ["## 四、按事前线上标签分组", "", "(区间内没有带环境标签的快照,本节待环境标签上线后出现。)", ""]
|
||
md += ["## 五、逐日注记", ""] + [f"- {n}" for n in notes] + ["",
|
||
"## 六、待决定事项", "", "(只列读数与选项,不改任何东西——由每周五人工填写。)", ""]
|
||
md += ["## 七、三套对照(选股系统、择时决策系统、PMS 账本各自的读数摆在一张表里,只对照不合并)", "",
|
||
_md(compare_table(lists_tbl, since, until)), ""]
|
||
md += ["## 八、台账对表(一致 / 不一致两列由人填;对表依据是方案第 4.1 节一致性检查表)", "",
|
||
_md(ledger_table()), ""]
|
||
md_path = os.path.join(out_dir, f"复盘_{stamp}_{start}.md")
|
||
with open(md_path, "w", encoding="utf-8") as f:
|
||
f.write("\n".join(md))
|
||
print("\n".join(md[:8]))
|
||
print(f"\n已写入 {md_path} 与 {csv_path}")
|
||
return {"md": md_path, "csv": csv_path, "days": len(days_plan)}
|
||
|
||
|
||
def _list_reading(lists_tbl: pd.DataFrame, name: str) -> str:
|
||
"""第一节汇总表里一份名单各期限的按日等权读数,拼成一句;没有样本写明。"""
|
||
if lists_tbl is None or lists_tbl.empty or "list" not in lists_tbl.columns:
|
||
return "无样本(区间内该名单没有可算的期限:名单为空、读失败或数据尾部不足)"
|
||
sub = lists_tbl[lists_tbl["list"] == name].sort_values("h")
|
||
if sub.empty:
|
||
return "无样本(区间内该名单没有可算的期限:名单为空、读失败或数据尾部不足)"
|
||
parts = []
|
||
for _, r in sub.iterrows():
|
||
parts.append(f"{int(r['h'])} 日:计划日 {int(r['days'])},样本 {int(r['n'])},"
|
||
f"比全池多涨 {r['excess_all']:+.2f} 个点,跑赢全池的占 {r['beat']:.1f}%,样本{r['grade']}")
|
||
return ";".join(parts)
|
||
|
||
|
||
def compare_table(lists_tbl: pd.DataFrame, since: str, until: str | None) -> pd.DataFrame:
|
||
"""第七节"三套对照":三行——选股系统候选单按日等权读数;择时决策系统自评口径与本期方向命中率;
|
||
PMS 账本四份名单读数。三套口径不同,只并列不合并。"""
|
||
rows = [
|
||
{"系统": "选股系统", "口径": "候选单按计划日等权,相对全池等权超额与跑赢比例(第一节主口径)",
|
||
"读数": _list_reading(lists_tbl, "候选单")},
|
||
{"系统": "择时决策系统",
|
||
"口径": "择时决策系统自己打的分。每个交易日收盘后给出的策略,看 5 个交易日后涨跌"
|
||
"方向判断对不对,统计判对的比例。只看股票自己的涨跌,没有减去大盘涨跌,"
|
||
"与上一行候选单的超额口径不同,两个数字不能直接比大小。",
|
||
"读数": timing_self_eval(since, until)},
|
||
{"系统": "PMS 账本",
|
||
"口径": "这一行是 PMS 当天的新开仓审批记录,拆成三份名单:系统自动放行的、"
|
||
"人工同意的、人工否决的。否决的也照样算收益,用来看当初拒得对不对。"
|
||
"另外单列一份择时决策系统当天看多的股票。这四份名单的买入时点、"
|
||
"往后看几天、跟谁比,都和候选单完全一样,可以直接跟候选单那一行对着看。",
|
||
"读数": ";".join(f"{name}—{_list_reading(lists_tbl, name)}"
|
||
for name in (*LEDGER_LISTS, TIMING_LIST))},
|
||
]
|
||
return pd.DataFrame(rows)
|
||
|
||
|
||
def ledger_table() -> pd.DataFrame:
|
||
"""第八节"台账对表":台账条目编号、日期、标题,加"一致""不一致"两列空着给人填。"""
|
||
entries = decision_ledger_entries()
|
||
if not entries:
|
||
return pd.DataFrame([{"编号": "—", "日期": "—", "标题": f"台账文件缺失或无条目({DECISION_LEDGER_PATH})",
|
||
"一致": "", "不一致": ""}])
|
||
return pd.DataFrame([{"编号": e["no"], "日期": e["date"], "标题": e["title"], "一致": "", "不一致": ""}
|
||
for e in entries])
|
||
|
||
|
||
def weekly_window(today=None, *, tail_days: int = 7, span_days: int = 21) -> tuple[str, str]:
|
||
"""每周五固定动作的复盘窗口(2026-09-07 方案第五件之三)。
|
||
截止日取 7 个自然日前,让最短的 5 日期限对窗口尾部的计划日也算得出收益;
|
||
起点再往前 21 个自然日,约十五个计划日。返回 ISO 日期字符串 (since, until)。"""
|
||
import datetime as dt
|
||
today = today or dt.date.today()
|
||
until = today - dt.timedelta(days=tail_days)
|
||
since = until - dt.timedelta(days=span_days)
|
||
return since.isoformat(), until.isoformat()
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="候选单复盘(只读,名单级观察收益,不是回测)")
|
||
ap.add_argument("--since", default="2026-07-29")
|
||
ap.add_argument("--until")
|
||
ap.add_argument("--horizons", default="5,10,20")
|
||
ap.add_argument("--start-price", choices=["next_close", "signal_close"], default="next_close",
|
||
help="next_close=次日收盘起算(默认,实盘口径);signal_close=信号日收盘起算(对账方案 1.8 系列)")
|
||
ap.add_argument("--out", default="data/review")
|
||
ap.add_argument("--weekly", action="store_true",
|
||
help="按每周五固定动作的滚动窗口跑(截止 7 天前、往前 21 天),忽略 --since/--until")
|
||
a = ap.parse_args()
|
||
hs = tuple(int(x) for x in a.horizons.split(",") if x.strip())
|
||
since, until = (weekly_window() if a.weekly else (a.since, a.until))
|
||
run(since, until, hs, a.start_price, a.out)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|