153 lines
6.6 KiB
Python
153 lines
6.6 KiB
Python
"""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()
|