Files
stock/backend/scripts/active_mv_calib.py
2026-09-16 09:09:10 +08:00

153 lines
6.6 KiB
Python
Raw 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.
"""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 = Σ 自由流通市值 × active20active20 = 1-Π(1-流通换手) 滚动20日。
自由流通市值 = volume/(turnover_rate_f%) × closedaily_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()