"""0AMV 校验 v2:对照指南针 app EOD 收盘读数,寻找能否精确一致。 指南针目标(亿元,app 收盘读数): 2026-09-11: 172,364.3 2026-09-14: 169,908.5(开 171,979.5 高 175,413.6 低 169,908.5) 2026-09-15: 166,791.5(开 169,782.7 高 173,060.4 低 166,658.0) 模型:0AMV = Σ 自由流通市值 × active20,active20 = 1-Π(1-流通换手) 滚动20日。 自由流通市值 = volume/(turnover_rate_f%) × close(daily_snapshot 近期才有 tff)。 检验变体:全市场 / 剔北交所 / 再剔科创板 / 剔次新(上市<90自然日), 若均差一个常数因子 → 指南针自由流通股本口径私有,无法精确复刻。 用法(backend 目录): env -u SSLKEYLOGFILE PYTHONIOENCODING=utf-8 uv run python scripts/active_mv_calib.py """ from __future__ import annotations import asyncio import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import numpy as np import pandas as pd from sqlalchemy import text from app.db import async_session N_WIN = 20 TARGETS = {"2026-09-11": 172364.3e8, "2026-09-14": 169908.5e8, "2026-09-15": 166791.5e8} async def _load() -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]: async with async_session() as session: hist = (await session.execute(text(""" SELECT ts, symbol, close, volume, amount, turnover FROM candles WHERE timeframe = '1d' AND turnover IS NOT NULL AND turnover > 0 AND volume > 0 AND ts >= now() - interval '500 days' """))).fetchall() pend = (await session.execute(text(""" SELECT ts, symbol, close, volume, amount FROM candles WHERE timeframe = '1d' AND volume > 0 AND ts::date = (SELECT max(ts)::date FROM candles WHERE timeframe='1d' AND volume > 0) """))).fetchall() snap = (await session.execute(text(""" SELECT ts_code, trade_date::date AS d, turnover_rate, turnover_rate_f FROM daily_snapshot WHERE trade_date >= now() - interval '20 days' AND turnover_rate IS NOT NULL AND turnover_rate_f > 0 """))).fetchall() basic = (await session.execute(text(""" SELECT symbol, market, exchange, list_date FROM stock_basic """))).fetchall() return ( pd.DataFrame(hist, columns=["ts", "symbol", "close", "volume", "amount", "turnover"]), pd.DataFrame(pend, columns=["ts", "symbol", "close", "volume", "amount"]), pd.DataFrame(snap, columns=["ts_code", "d", "turnover", "turnover_rate_f"]), pd.DataFrame(basic, columns=["symbol", "market", "exchange", "list_date"]), ) def main() -> None: hist, pend, snap, basic = asyncio.run(_load()) snap["symbol"] = snap["ts_code"].str.split(".").str[0] # 最新 candles 交易日若缺换手率(夜间 3.5 步未跑),用 snapshot 补 if not pend.empty and not snap.empty: d_pend = pend["ts"].dt.date.max() sp = snap[snap["d"] == d_pend][["symbol", "turnover"]] pend = pend[pend["ts"].dt.date == d_pend].merge(sp, on="symbol", how="inner") pend = pend[pend["turnover"] > 0] print(f"最新交易日 {d_pend}:{len(pend):,} 行用 snapshot 换手率补齐") df = pd.concat([hist, pend[hist.columns]], ignore_index=True) df["date"] = df["ts"].dt.date df = df.sort_values(["symbol", "date"], kind="stable").reset_index(drop=True) df = df.merge(basic, on="symbol", how="left") print(f"合计 {len(df):,} 行,{df['symbol'].nunique():,} 只,{df['date'].min()} ~ {df['date'].max()}") print("板块分布:", df.drop_duplicates("symbol")["market"].value_counts().to_dict()) t = (df["turnover"] / 100.0).clip(upper=0.9999) df["log_inactive"] = np.log1p(-t) decay = df.groupby("symbol")["log_inactive"].transform( lambda s: s.rolling(N_WIN, min_periods=1).sum() ) df["active20"] = 1.0 - np.exp(decay.to_numpy()) # ---- 每个有 tff 的日期:个股级 FF20 明细 ---- ms: dict = {} for d in sorted(snap["d"].unique())[-12:]: day = df[df["date"] == d] if day.empty: continue m = day.merge(snap.loc[snap["d"] == d, ["symbol", "turnover_rate_f"]], on="symbol", how="inner") m = m[m["turnover_rate_f"] > 0] tff = (m["turnover_rate_f"] / 100.0).clip(upper=0.9999) m["free_mv"] = m["volume"] / tff * m["close"] m["ff"] = m["free_mv"] * m["active20"] listed = pd.to_datetime(m["list_date"], format="%Y%m%d", errors="coerce") m["age_days"] = (pd.Timestamp(d) - listed).dt.days.fillna(10**6) ms[d] = m # ---- 参数扫描:剔除次新窗口 × 是否剔北交所 ---- print("\n== 参数扫描(对指南针三日的比值;理想=1.00000 稳定)==") print(f"{'剔北交所':<6}{'剔次新(自然日)':>12}{'09-11':>10}{'09-14':>10}{'09-15':>10}{'均值':>10}{'极差':>9}") results = [] for bse_ex in (False, True): for w in (0, 60, 90, 120, 150, 180, 270, 365, 550): ratios = [] for tgt_d, tgt in TARGETS.items(): d = pd.Timestamp(tgt_d).date() if d not in ms: break m = ms[d] mask = m["age_days"] >= w if bse_ex: mask &= m["market"] != "北交所" ratios.append(m.loc[mask, "ff"].sum() / tgt) if len(ratios) < 3: continue mean_r = float(np.mean(ratios)) spread = max(ratios) - min(ratios) results.append((abs(mean_r - 1) + spread, bse_ex, w, ratios, mean_r, spread)) print(f"{'是' if bse_ex else '否':<6}{w:>12}{ratios[0]:>10.5f}{ratios[1]:>10.5f}" f"{ratios[2]:>10.5f}{mean_r:>10.5f}{spread:>9.5f}") results.sort() _, best_bse, best_w, best_ratios, best_mean, best_spread = results[0] print(f"\n最优配置:剔北交所={'是' if best_bse else '否'},剔上市<{best_w}自然日") print(f" 三日比值 {['%.5f' % r for r in best_ratios]},极差 {best_spread:.5f}") # 最优配置下的每日序列 print("\n== 最优配置近 12 日 FF20(亿)==") for d in sorted(ms): m = ms[d] mask = m["age_days"] >= best_w if best_bse: mask &= m["market"] != "北交所" v = m.loc[mask, "ff"].sum() / 1e8 mark = f" ←指南针 {TARGETS[str(d)]/1e8:,.1f}" if str(d) in TARGETS else "" print(f"{d} {v:>10,.0f} n={int(mask.sum())}{mark}") if __name__ == "__main__": main()