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

117 lines
4.7 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活跃市值——可行性验证原型。
模型:
活跃筹码比例(t) = 1 - Π_{近N个交易日} (1 - 换手率) (换手衰减)
0AMV(t) = Σ_个股 流通市值(t) × 活跃筹码比例(t)
流通市值不额外拉接口:成交量(股) / (换手%/100) 反推流通股本,× close 即得,
且随解禁/增发每日自适应。N 为指南针未公开的窗口参数,输出多组候选供对照 app 校准。
用法backend 目录):
env -u SSLKEYLOGFILE uv run python scripts/active_mv_probe.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
WINDOWS = (20, 60, 120) # 活跃窗口候选(交易日)
LOOKBACK_DAYS = 500 # 日历日回看,足够 120 交易日窗口
SHOW_DAYS = 20 # 打印最近 N 个交易日
async def _load() -> tuple[pd.DataFrame, pd.DataFrame]:
async with async_session() as session:
rows = (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()
snap = (await session.execute(text("""
SELECT ts_code, circ_mv, trade_date
FROM daily_snapshot
WHERE trade_date >= (SELECT max(trade_date) - interval '10 days' FROM daily_snapshot)
AND circ_mv IS NOT NULL
"""))).fetchall()
df = pd.DataFrame(rows, columns=["ts", "symbol", "close", "volume", "amount", "turnover"])
snap = pd.DataFrame(snap, columns=["ts_code", "circ_mv_wan", "snap_date"])
return df, snap
def _validate(df: pd.DataFrame, snap: pd.DataFrame) -> None:
"""反推流通市值 vs daily_snapshot万元→元交叉验证。"""
if snap.empty:
print("校验daily_snapshot 为空")
return
snap["symbol"] = snap["ts_code"].str.split(".").str[0]
snap["d"] = snap["snap_date"].dt.date
common = set(snap["d"]) & set(df["date"])
if not common:
print("校验daily_snapshot 与 candles 无可对比日期")
return
snap_date = max(common)
day = df[df["date"] == snap_date]
m = day[["symbol", "circ_mv"]].merge(
snap.loc[snap["d"] == snap_date, ["symbol", "circ_mv_wan"]],
on="symbol", how="inner",
)
if m.empty:
print("校验:无可对比个股")
return
ratio = m["circ_mv"] / (m["circ_mv_wan"] * 1e4)
print(f"校验({snap_date}{len(m)} 只):反推/官方流通市值中位数 = {ratio.median():.4f}"
f"P10={ratio.quantile(0.1):.3f}P90={ratio.quantile(0.9):.3f}")
big = m.nlargest(5, "circ_mv")
for _, r in big.iterrows():
print(f" {r['symbol']}: 反推 {r['circ_mv']/1e8:,.0f}亿 vs 官方 {r['circ_mv_wan']/1e4:,.0f}亿"
f"(比值 {r['circ_mv']/(r['circ_mv_wan']*1e4):.3f}")
def main() -> None:
df, snap = asyncio.run(_load())
df["date"] = df["ts"].dt.date
# 关键:分组滚动前必须按 (symbol, date) 排序SQL 返回顺序不保证
df = df.sort_values(["symbol", "date"], kind="stable").reset_index(drop=True)
print(f"candles 载入 {len(df):,} 行,{df['symbol'].nunique():,} 只,"
f"{df['date'].min()} ~ {df['date'].max()}")
t = (df["turnover"] / 100.0).clip(upper=0.9999)
df["float_shares"] = df["volume"] / t # 流通股本volume 单位=股
df["circ_mv"] = df["float_shares"] * df["close"] # 流通市值(元)
_validate(df, snap)
df["log_inactive"] = np.log1p(-t)
agg_cols = {"circ_mv": "sum", "amount": "sum"}
for n in WINDOWS:
decay = df.groupby("symbol")["log_inactive"].transform(
lambda s: s.rolling(n, min_periods=1).sum()
)
df[f"amv_{n}"] = df["circ_mv"] * (1.0 - np.exp(decay.to_numpy()))
agg_cols[f"amv_{n}"] = "sum"
daily = df.groupby("date").agg(agg_cols).sort_index().tail(SHOW_DAYS)
print("\n日期 全市场流通市值(万亿) 日成交额(万亿) " +
" ".join(f"0AMV_{n}(万亿) 活跃占比_{n}" for n in WINDOWS))
for d, r in daily.iterrows():
print(f"{d} {r['circ_mv']/1e12:8.3f} {r['amount']/1e12:6.3f} " +
" ".join(
f"{r[f'amv_{n}']/1e12:7.3f} {r[f'amv_{n}']/r['circ_mv']*100:5.1f}%"
for n in WINDOWS
))
if __name__ == "__main__":
main()