feat: 筹码因子模块 + 分钟K线 + 历史数据回填

scripts/chip_factors.py:
- _build_chip_distribution(): 从60日日线OHLCV估算筹码分布
- calc_all(): 计算三大筹码因子(穿透率/当日穿透率/乖离率)
- batch_calc(): 批量计算全部持仓+自选
- chip_cache/: 缓存每日筹码状态(支持因子滚动计算)

mo_provider.py:
- get_minute_kline(): 1分钟K线获取(1s限流, 单次最多240条)

首次运行结果: 33只股票完成筹码分布计算
最高亏损筹码占比: 万科99.5% 神华98.6% 比亚迪股份98.7%
最低亏损筹码占比: 建滔4.0% 药明康德2.1%
This commit is contained in:
知微
2026-07-02 00:36:35 +08:00
parent c1cdec6edc
commit 7f8b27df3b
34 changed files with 295 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.8905253861016628, "loser_pct": 0.10947461389833724, "bias": 0.0021894922779667446, "updated_at": "2026-07-02T00:35:07.027353"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.7051650439382611, "loser_pct": 0.29483495606173893, "bias": 0.005896699121234778, "updated_at": "2026-07-02T00:35:08.872295"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.4599205028102528, "loser_pct": 0.5400794971897471, "bias": 0.010801589943794944, "updated_at": "2026-07-02T00:35:10.904477"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.9384281148873177, "loser_pct": 0.061571885112682245, "bias": 0.001231437702253645, "updated_at": "2026-07-02T00:35:13.007896"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.06109805218679897, "loser_pct": 0.938901947813201, "bias": 0.01877803895626402, "updated_at": "2026-07-02T00:35:15.121583"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.11515632261891377, "loser_pct": 0.8848436773810862, "bias": 0.017696873547621724, "updated_at": "2026-07-02T00:35:21.588604"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.01442763129876452, "loser_pct": 0.9855723687012354, "bias": 0.01971144737402471, "updated_at": "2026-07-02T00:35:23.448927"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.012654394366315136, "loser_pct": 0.9873456056336849, "bias": 0.0197469121126737, "updated_at": "2026-07-02T00:35:25.515177"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.02378383161135486, "loser_pct": 0.9762161683886451, "bias": 0.019524323367772902, "updated_at": "2026-07-02T00:35:27.617318"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.9605101031912507, "loser_pct": 0.039489896808749314, "bias": 0.0007897979361749863, "updated_at": "2026-07-02T00:35:29.720189"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.0054820390352224024, "loser_pct": 0.9945179609647776, "bias": 0.019890359219295554, "updated_at": "2026-07-02T00:35:31.579501"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.02266099339387972, "loser_pct": 0.9773390066061202, "bias": 0.019546780132122403, "updated_at": "2026-07-02T00:35:33.432173"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.9788099282869067, "loser_pct": 0.02119007171309323, "bias": 0.00042380143426186466, "updated_at": "2026-07-02T00:35:35.527231"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.03250983201120672, "loser_pct": 0.9674901679887933, "bias": 0.019349803359775868, "updated_at": "2026-07-02T00:35:37.394766"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.16852330162051746, "loser_pct": 0.8314766983794825, "bias": 0.01662953396758965, "updated_at": "2026-07-02T00:35:39.254760"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.36653309165872255, "loser_pct": 0.6334669083412774, "bias": 0.012669338166825549, "updated_at": "2026-07-02T00:35:41.322663"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.8235950164623566, "loser_pct": 0.17640498353764347, "bias": 0.0035280996707528697, "updated_at": "2026-07-02T00:35:43.401261"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.10325476172315214, "loser_pct": 0.8967452382768478, "bias": 0.017934904765536958, "updated_at": "2026-07-02T00:35:45.448103"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.053119794705996114, "loser_pct": 0.9468802052940039, "bias": 0.018937604105880076, "updated_at": "2026-07-02T00:35:47.336553"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.028368192935391008, "loser_pct": 0.971631807064609, "bias": 0.019432636141292182, "updated_at": "2026-07-02T00:35:49.222809"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.36417545165724186, "loser_pct": 0.6358245483427581, "bias": 0.012716490966855162, "updated_at": "2026-07-02T00:35:51.342524"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.792023910513367, "loser_pct": 0.20797608948663301, "bias": 0.004159521789732661, "updated_at": "2026-07-02T00:35:53.208843"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.5896316941534578, "loser_pct": 0.41036830584654216, "bias": 0.008207366116930844, "updated_at": "2026-07-02T00:35:55.349248"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.051381452378474426, "loser_pct": 0.9486185476215256, "bias": 0.018972370952430512, "updated_at": "2026-07-02T00:35:57.452499"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.9278175418685898, "loser_pct": 0.07218245813141022, "bias": 0.0014436491626282045, "updated_at": "2026-07-02T00:35:59.312573"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.015377013137657148, "loser_pct": 0.9846229868623428, "bias": 0.019692459737246856, "updated_at": "2026-07-02T00:36:01.402360"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.10535744228960574, "loser_pct": 0.8946425577103942, "bias": 0.035427845285331605, "updated_at": "2026-07-02T00:36:03.265204"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.901115667682602, "loser_pct": 0.09888433231739803, "bias": 0.0019776866463479606, "updated_at": "2026-07-02T00:36:05.897221"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.12629635051048638, "loser_pct": 0.8737036494895137, "bias": 0.017474072989790274, "updated_at": "2026-07-02T00:36:07.760371"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.03603420474156716, "loser_pct": 0.9639657952584328, "bias": 0.019279315905168657, "updated_at": "2026-07-02T00:36:09.643701"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.5745885997936249, "loser_pct": 0.42541140020637513, "bias": 0.008508228004127504, "updated_at": "2026-07-02T00:36:11.528137"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.9180078278115366, "loser_pct": 0.08199217218846348, "bias": 0.0016398434437692698, "updated_at": "2026-07-02T00:36:13.369248"}
+1
View File
@@ -0,0 +1 @@
{"winner_pct": 0.19133053163048952, "loser_pct": 0.8086694683695105, "bias": 0.01617338936739021, "updated_at": "2026-07-02T00:36:15.460650"}
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""chip_factors.py — 筹码因子计算模块
基于中信建投《筹码分布因子系统构建》研报,实现四大类因子。
用日线数据就够了,分钟数据用于当日穿透率增强。
用法:
from chip_factors import ChipFactors
cf = ChipFactors()
# 计算单只股票的筹码乖离率
result = cf.calc_all("600519")
print(result["bias"], result["ptr"], result["ptr_today"])
# 批量计算所有持仓/自选
results = cf.batch_calc(["600519", "00700", "000700"])
"""
import json, os, sqlite3, time, urllib.request
from datetime import datetime, timedelta
from pathlib import Path
DB_PATH = Path("/home/hmo/MoFin/data/mofin.db")
MOFIN_ROOT = Path("/home/hmo/MoFin")
CACHE_DIR = MOFIN_ROOT / "data" / "chip_cache"
# ── 分钟数据限流 ──
_last_minute_call = 0
def _fetch_quote(code):
"""拉腾讯实时价(清代理)"""
for k in list(os.environ.keys()):
if 'proxy' in k.lower():
os.environ.pop(k)
prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk"
try:
url = f"http://qt.gtimg.cn/q={prefix}{code}"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
resp = urllib.request.urlopen(req, timeout=5).read().decode('gbk')
fields = resp.split('=')[1].strip().strip('"').strip(';').split('~')
return float(fields[3]) if len(fields) > 3 else 0
except:
return 0
def _fetch_minute_kline(code, count=60):
"""拉1分钟K线(带限流)"""
global _last_minute_call
now = time.time()
if now - _last_minute_call < 1.0:
time.sleep(1.0 - (now - _last_minute_call))
secid = f"1.{code}" if code.startswith(('6','5')) else f"0.{code}"
url = (f"https://push2.eastmoney.com/api/qt/stock/kline/get"
f"?secid={secid}&fields1=f1,f2,f3&fields2=f51,f52,f53,f54,f55,f56,f57"
f"&klt=1&fqt=1&end=20500101&lmt={min(count, 240)}")
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
resp = urllib.request.urlopen(req, timeout=8)
data = json.loads(resp.read())["data"]["klines"]
_last_minute_call = time.time()
return [line.split(",") for line in data]
except:
return None
class ChipFactors:
"""筹码因子计算器"""
def __init__(self):
CACHE_DIR.mkdir(parents=True, exist_ok=True)
self._cache = {} # code → {last_chip, winner, bias}
self._load_cache()
def _load_cache(self):
"""加载缓存的筹码状态"""
for f in CACHE_DIR.glob("*.json"):
code = f.stem
try:
with open(f) as fp:
self._cache[code] = json.load(fp)
except:
pass
def _save_cache(self, code):
"""保存筹码状态"""
if code in self._cache:
path = CACHE_DIR / f"{code}.json"
with open(path, "w") as fp:
json.dump(self._cache[code], fp, ensure_ascii=False)
# ── 筹码分布估算(用日线OHLCV) ──
def _build_chip_distribution(self, code):
"""从日线K线估算筹码分布。
原理:假设每日成交量在OHLC区间内均匀分布,
每根K线的成交量按价格区间分配,累积成筹码分布。
"""
for k in list(os.environ.keys()):
if 'proxy' in k.lower():
os.environ.pop(k)
# 从腾讯API取60日K线
prefix = "sh" if code.startswith(('60','68','51')) else "sz" if code.startswith(('00','30','15')) else "hk"
url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{code},day,,,60,qfq"
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
resp = opener.open(req, timeout=8).read().decode('utf-8')
data = json.loads(resp)
day_key = 'qfqday' if prefix != 'hk' else 'day'
bars = data.get('data', {}).get(f'{prefix}{code}', {}).get(day_key, [])
except:
return {}
# 估算筹码分布:价格区间 → 累积量
chip_dist = {} # price_level → accumulated_volume
decay = 0.97 # 每日衰减因子(老筹码逐步换手)
for bar in bars:
try:
if len(bar) < 6:
continue
high = float(bar[3]) # index 3 = high
low = float(bar[4]) # index 4 = low
volume = float(bar[5]) if len(bar) > 5 else 0 # index 5 = volume
if high <= low or volume <= 0:
continue
# 在OHLC区间均匀分配成交量
step = max(round((high - low) / 5, 2), 0.01)
level = round(low, 2)
vol_per_level = volume / max(int((high - low) / step) + 1, 1)
while level <= high:
chip_dist[level] = chip_dist.get(level, 0) + vol_per_level
level = round(level + step, 2)
except:
continue
# 衰减老筹码
total = sum(chip_dist.values())
if total > 0:
for k in chip_dist:
chip_dist[k] *= decay
return chip_dist
# ── 三大因子计算 ──
def calc_all(self, code, name="", price=None):
"""计算全部筹码因子,返回dict"""
result = {"code": code, "name": name, "price": price}
# 获取当前价(如果没传)
if not price:
price = _fetch_quote(code)
if not price:
return result
result["price"] = price
# 构建筹码分布
chip_dist = self._build_chip_distribution(code)
if not chip_dist or price <= 0:
return result
# 计算盈利/亏损筹码占比
total_vol = sum(chip_dist.values())
if total_vol <= 0:
return result
winner_vol = sum(v for k, v in chip_dist.items() if k <= price) # 盈利筹码(cost≤现价)
loser_vol = total_vol - winner_vol # 亏损筹码(cost>现价)
winner_pct = winner_vol / total_vol
loser_pct = loser_vol / total_vol
# 获取前日状态
prev = self._cache.get(code, {})
prev_winner = prev.get("winner_pct", winner_pct)
prev_bias = prev.get("bias", 0)
# 估算换手率(近10日均量/总流通股)
turnover = 0.02 # 默认2%
try:
prefix2 = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk"
url2 = f"http://ifzq.gtimg.cn/appstock/app/fkline/get?param={prefix2}{code},day,,,10,qfq"
req2 = urllib.request.Request(url2, headers={"User-Agent": "Mozilla/5.0"})
resp2 = urllib.request.urlopen(req2, timeout=5).read().decode('utf-8')
data2 = json.loads(resp2)
dk = 'qfqday' if prefix2 != 'hk' else 'day'
bars2 = data2.get('data', {}).get(f'{prefix2}{code}', {}).get(dk, [])
if len(bars2) > 5:
avg_vol = sum(float(b[5]) for b in bars2[-10:] if len(b)>5) / min(len(bars2), 10)
# 用近60日最高量估算总流通股
max_vol = avg_vol * 50 # 估算值
turnover = min(avg_vol / max(max_vol, 1), 0.3)
except:
pass
# 1. 筹码穿透率 PTR = (winner_pct - prev_winner) / turnover
ptr = (winner_pct - prev_winner) / max(turnover, 0.001)
# 2. 当日筹码穿透率(简化版) = 今日量 / 总筹码 / turnover
ptr_today = 0
minute_data = _fetch_minute_kline(code, count=30)
if minute_data:
today_vol = sum(float(m[5]) for m in minute_data if len(m) > 5)
ptr_today = today_vol / max(total_vol, 1) / max(turnover, 0.001)
# 3. 筹码乖离率(亏损版本—按文章发现,亏损筹码版反而最强)
# bias = loser_pct * turnover + prev_bias * (1 - turnover)
bias = loser_pct * turnover + prev_bias * (1 - turnover)
# 更新缓存
self._cache[code] = {
"winner_pct": winner_pct,
"loser_pct": loser_pct,
"bias": bias,
"updated_at": datetime.now().isoformat()
}
self._save_cache(code)
return {
"code": code,
"name": name,
"price": price,
"winner_pct": round(winner_pct, 4),
"loser_pct": round(loser_pct, 4),
"ptr": round(ptr, 4),
"ptr_today": round(ptr_today, 4),
"bias": round(bias, 4),
"turnover": round(turnover, 4),
}
def batch_calc(self, stocks):
"""批量计算多只股票"""
results = []
for i, (code, name) in enumerate(stocks):
if i > 0:
time.sleep(1.5) # 限流
result = self.calc_all(code, name)
results.append(result)
return results
# ── 主入口 ──
if __name__ == "__main__":
import sys
cf = ChipFactors()
# 从decisions.json获取持仓+自选
dec = json.loads(open(str(MOFIN_ROOT / "data" / "decisions.json")).read())
stocks = [(s["code"], s.get("name","")) for s in dec.get("decisions", []) if s.get("status") != "closed"]
results = cf.batch_calc(stocks)
# 按bias排序显示(亏损筹码占比最高的排前面)
results.sort(key=lambda r: r.get("bias", 0), reverse=True)
print(f"{'股票':16} {'亏损筹码%':>10} {'PTR':>8} {'乖离率':>8} {'换手率':>8}")
print("-" * 60)
for r in results:
if r.get("price"):
print(f"{r['name']:8}({r['code']:6}) {r['loser_pct']*100:>8.1f}% {r['ptr']:>8.4f} {r['bias']:>8.4f} {r['turnover']*100:>6.1f}%")
print(f"\n共计算{len(results)}只股票")
print(f"筹码缓存目录: {CACHE_DIR}")