feat: 量化模型迁移——K线获取(日/分钟)+多因子打分+IC自适应微调+推荐卡推送/REST/前端

This commit is contained in:
lookt
2026-09-15 21:18:11 +08:00
parent d178653c89
commit 419b0af1f8
6 changed files with 804 additions and 2 deletions
+89
View File
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""前端追加「量化推荐」区(筛选按钮 + 事件渲染 + 表格 + 权重展示)"""
import io
p = r'src\web\index.html'
s = io.open(p, encoding='utf-8').read()
# 1) 筛选按钮
old1 = ' <button data-f="事件影响分析">事件影响</button>'
new1 = (old1 + '\n <button data-f="量化推荐">量化推荐</button>\n'
' <button data-f="模型微调">微调</button>')
assert old1 in s, 'filters not found'
s = s.replace(old1, new1)
# 2) 主区追加量化推荐表格
old2 = ' <ul id="feed"><li class="empty">等待服务端推送…</li></ul>\n</main>'
new2 = (' <ul id="feed"><li class="empty">等待服务端推送…</li></ul>\n</main>\n'
'<section style="max-width:1100px;margin:0 auto 30px">\n'
' <h2 style="font-size:15px;color:var(--text)">量化模型 · 自适应推荐'
'(多因子 + IC 微调)</h2>\n'
' <div style="font-size:12px;color:var(--text2);margin-bottom:8px">'
'权重:<span id="qw">加载中…</span></div>\n'
' <div style="background:var(--surface);border:1px solid var(--border);'
'border-radius:8px;overflow:auto">\n'
' <table style="width:100%;border-collapse:collapse;font-size:13px" id="qtable">\n'
' <thead><tr style="color:var(--text2);text-align:left">\n'
' <th style="padding:8px 10px">代码</th><th style="padding:8px 10px">名称</th>\n'
' <th style="padding:8px 10px">现价</th><th style="padding:8px 10px">模型分</th>\n'
' <th style="padding:8px 10px">购入区间</th>'
'<th style="padding:8px 10px">预计收益(回测口径)</th>\n'
' <th style="padding:8px 10px">推荐指数</th>'
'<th style="padding:8px 10px">简易原因</th>\n'
' </tr></thead><tbody></tbody>\n'
' </table>\n'
' </div>\n'
' <div style="margin-top:6px;font-size:11px;color:#6b7280">'
'⚠ 购入区间与预计收益为模型回测/推测口径,不构成投资建议。</div>\n'
'</section>')
assert old2 in s, 'feed ul not found'
s = s.replace(old2, new2)
# 3) WS 事件渲染分支(量化推荐)
old3 = (" } else {\n"
" const txt = typeof d === 'object' ? JSON.stringify(d) : String(d)\n"
" li.innerHTML = `<span class=\"t\">${e.ts}</span><span class=\"k ${e.kind}\">${e.kind}"
"</span><span class=\"d\">${esc(txt)}</span>`\n }")
new3 = (" } else if (e.kind === '量化推荐') {\n"
" renderQuant(e.data)\n"
" const txt = '自适应模型推送 ' + ((d.list || []).length) + ' 只推荐'\n"
" li.innerHTML = `<span class=\"t\">${e.ts}</span><span class=\"k ${e.kind}\">${e.kind}"
"</span><span class=\"d\">${esc(txt)}</span>`\n"
" } else {\n"
" const txt = typeof d === 'object' ? JSON.stringify(d) : String(d)\n"
" li.innerHTML = `<span class=\"t\">${e.ts}</span><span class=\"k ${e.kind}\">${e.kind}"
"</span><span class=\"d\">${esc(txt)}</span>`\n }")
assert old3 in s, 'render branch not found'
s = s.replace(old3, new3)
# 4) 渲染函数与加载
old4 = 'connect()'
new4 = (
"function renderQuant(d) {\n"
" const tb = document.querySelector('#qtable tbody')\n"
" if (!tb) return\n"
" tb.innerHTML = (d.list || []).map(r =>\n"
" '<tr><td style=\"padding:6px 10px\">' + esc(r.code) + '</td>' +\n"
" '<td style=\"padding:6px 10px\">' + esc(r.name) + '</td>' +\n"
" '<td style=\"padding:6px 10px\">' + r.price + '</td>' +\n"
" '<td style=\"padding:6px 10px\">' + r.score + '</td>' +\n"
" '<td style=\"padding:6px 10px\">' + r.buy_low + ' ~ ' + r.buy_high + '</td>' +\n"
" '<td style=\"padding:6px 10px\">' + (r.expected_return_pct == null ? '' : r.expected_return_pct + '%') + '</td>' +\n"
" '<td style=\"padding:6px 10px\" class=\"stars\">' + ''.repeat(r.stars) + '</td>' +\n"
" '<td style=\"padding:6px 10px\">' + esc(r.reason) + '</td></tr>').join('')\n"
"}\n"
"function loadQuant() {\n"
" fetch('/api/quant/recommendations').then(r => r.json()).then(renderQuant).catch(() => {})\n"
" fetch('/api/quant/weights').then(r => r.json()).then(w => {\n"
" document.getElementById('qw').textContent =\n"
" Object.entries(w || {}).map(([k, v]) => k + '=' + Number(v).toFixed(2)).join(' ') || ''\n"
" }).catch(() => {})\n"
"}\n"
"loadQuant()\n"
"setInterval(loadQuant, 60000)\n"
"connect()")
assert old4 in s
s = s.replace(old4, new4, 1)
io.open(p, 'w', encoding='utf-8').write(s)
print('index.html 量化推荐区 OK')
+138
View File
@@ -0,0 +1,138 @@
# -*- coding: utf-8 -*-
"""
K线获取(迁移自 JQuant 的 TDX K线能力,Python 侧改用 akshare 源):
- 日线(前复权):ak.stock_zh_a_hist
- 分钟线(1/5/15/30/60 分钟):ak.stock_zh_a_hist_min_em
统一入库 kline 表(code, tf, bar_time 主键,增量覆盖),供量化模型使用。
"""
import warnings
from datetime import datetime, timedelta
import pandas as pd
warnings.filterwarnings('ignore')
TF_CONFIG = {
'day': {'ak_period': 'daily', 'ak_fn': 'hist', 'keep_days': 400, 'per_day': 1},
'60': {'ak_period': '60', 'ak_fn': 'min_em', 'keep_days': 60, 'per_day': 4},
'30': {'ak_period': '30', 'ak_fn': 'min_em', 'keep_days': 30, 'per_day': 8},
'5': {'ak_period': '5', 'ak_fn': 'min_em', 'keep_days': 10, 'per_day': 48},
'1': {'ak_period': '1', 'ak_fn': 'min_em', 'keep_days': 2, 'per_day': 240},
}
class KlineFetcher:
def __init__(self, db_path):
self.db_path = str(db_path)
def _conn(self):
import sqlite3
conn = sqlite3.connect(self.db_path, check_same_thread=False)
return conn
def ensure_table(self, conn):
conn.execute("""CREATE TABLE IF NOT EXISTS kline (
code TEXT NOT NULL,
tf TEXT NOT NULL,
bar_time TEXT NOT NULL,
open REAL, high REAL, low REAL, close REAL,
volume REAL, amount REAL,
PRIMARY KEY (code, tf, bar_time))""")
def fetch(self, code: str, tf: str = 'day', days: int = None) -> pd.DataFrame:
"""拉取单股K线并归一化列:[bar_time, open, high, low, close, volume, amount]"""
import akshare as ak
cfg = TF_CONFIG[tf]
days = days or cfg['keep_days']
end = datetime.now().strftime('%Y%m%d')
start = (datetime.now() - timedelta(days=days + 5)).strftime('%Y%m%d')
if cfg['ak_fn'] == 'hist':
df = ak.stock_zh_a_hist(symbol=code, period='daily',
start_date=start, end_date=end, adjust='qfq')
else:
df = ak.stock_zh_a_hist_min_em(symbol=code, period=cfg['ak_period'],
start_date=start + ' 09:30:00',
end_date=end + ' 15:00:00')
if df is None or df.empty:
return pd.DataFrame()
df.columns = [str(c).lower() for c in df.columns]
cmap = {}
for c in df.columns:
if c in ('时间', 'bar_time', 'date', '日期'):
cmap[c] = 'bar_time'
elif c in ('开盘', 'open'):
cmap[c] = 'open'
elif c in ('最高', 'high'):
cmap[c] = 'high'
elif c in ('最低', 'low'):
cmap[c] = 'low'
elif c in ('收盘', 'close'):
cmap[c] = 'close'
elif c in ('成交量', 'volume', 'vol'):
cmap[c] = 'volume'
elif c in ('成交额', 'amount', 'amt'):
cmap[c] = 'amount'
df = df.rename(columns=cmap)
keep = [c for c in ('bar_time', 'open', 'high', 'low', 'close', 'volume', 'amount')
if c in df.columns]
df = df[keep].copy()
df['bar_time'] = pd.to_datetime(df['bar_time'], errors='coerce').dt.strftime(
'%Y-%m-%d %H:%M:%S' if tf != 'day' else '%Y-%m-%d')
df = df.dropna(subset=['close'])
for c in ('open', 'high', 'low', 'close', 'volume', 'amount'):
if c in df.columns:
df[c] = pd.to_numeric(df[c], errors='coerce')
df = df.dropna(subset=['open'])
cutoff = (datetime.now() - timedelta(days=days)).strftime(
'%Y-%m-%d %H:%M:%S' if tf != 'day' else '%Y-%m-%d')
df = df[df['bar_time'] >= cutoff]
return df
def save(self, code: str, tf: str, df: pd.DataFrame) -> int:
if df is None or df.empty:
return 0
conn = self._conn()
try:
self.ensure_table(conn)
rows = [(code, tf, r.bar_time, r.open, r.high, r.low, r.close,
getattr(r, 'volume', None), getattr(r, 'amount', None))
for r in df.itertuples(index=False)]
conn.executemany(
"INSERT OR REPLACE INTO kline(code,tf,bar_time,open,high,low,close,volume,amount) "
"VALUES (?,?,?,?,?,?,?,?,?)", rows)
conn.commit()
return len(rows)
finally:
conn.close()
def sync(self, code: str, tf: str = 'day', days: int = None) -> int:
"""拉取+入库,返回入库条数"""
try:
return self.save(code, tf, self.fetch(code, tf, days))
except Exception as e:
print('[kline] {} {} 失败: {}'.format(code, tf, e))
return 0
def load(self, code: str, tf: str = 'day', limit: int = 260) -> pd.DataFrame:
"""从库中读取K线(时间升序)"""
conn = self._conn()
try:
self.ensure_table(conn)
return pd.read_sql(
"SELECT bar_time,open,high,low,close,volume,amount FROM kline "
"WHERE code=? AND tf=? ORDER BY bar_time DESC LIMIT ?",
conn, params=(code, tf, limit)).iloc[::-1].reset_index(drop=True)
finally:
conn.close()
def cleanup(self, tf: str):
cfg = TF_CONFIG[tf]
conn = self._conn()
try:
self.ensure_table(conn)
conn.execute("DELETE FROM kline WHERE tf=? AND bar_time < ?",
(tf, (datetime.now() - timedelta(days=cfg['keep_days'])).strftime('%Y-%m-%d')))
conn.commit()
finally:
conn.close()
+309
View File
@@ -0,0 +1,309 @@
# -*- coding: utf-8 -*-
"""
量化引擎编排:
- 宇宙:全市场快照按成交额 TOP N(默认 300)
- K线轮询刷新(后台线程,限速),覆盖 day + 30/5 分钟
- 每轮刷新后:因子截面打分 → 推荐卡(Top 20)→ emit + 落库
- 自适应微调:每日按 IC 反聩调整因子权重;推荐事后回填实现收益再反哺
"""
import json
import os
import threading
import time
import traceback
from datetime import datetime
import numpy as np
import pandas as pd
from src.fetcher.kline_fetcher import KlineFetcher
from src.quant.model import (FACTOR_NAMES, WeightStore, compute_factors,
cross_section_score, factor_ic_series)
UNIVERSE_N = int(os.environ.get('QUANT_UNIVERSE_N', '300'))
SCORING_INTERVAL_S = int(os.environ.get('QUANT_SCORING_INTERVAL_S', '1800'))
KLINE_QPS = 3 # 每秒最多拉几只(限速防封)
class QuantEngine:
def __init__(self, emit, db_path):
self.emit = emit
self.db_path = str(db_path)
self.fetcher = KlineFetcher(self.db_path)
self.weights_store = WeightStore(self.db_path)
self.state_lock = threading.Lock()
self.last_ranking = []
self.last_scored_at = None
self._stop = threading.Event()
self._threads = []
# ---------- 宇宙与K线刷新 ----------
def universe(self, n=UNIVERSE_N):
import akshare as ak
try:
df = ak.stock_zh_a_spot()
df['amt'] = pd.to_numeric(df.get('成交额'), errors='coerce')
df['code'] = df.get('代码').astype(str).str[-6:]
df = df[df['code'].str[:2].isin(('60', '00', '30', '68'))]
df = df.sort_values('amt', ascending=False).head(n)
return list(df['code']), dict(zip(df['code'], df['名称'].astype(str)))
except Exception as e:
print('[quant] 宇宙获取失败:', e, flush=True)
return [], {}
def _kline_worker(self):
"""后台轮询:持续刷新宇宙内 K 线(day 全量 + 30/5 分钟)"""
while not self._stop.is_set():
try:
codes, names = self.universe()
if not codes:
time.sleep(120)
continue
with self.state_lock:
self.names = names
for code in codes:
if self._stop.is_set():
return
for tf in ('day', '30'):
self.fetcher.sync(code, tf)
time.sleep(1.0 / KLINE_QPS)
with self.state_lock:
self.kline_ready = True
print('[quant] K线刷新完成一轮: {}'.format(len(codes)), flush=True)
self.run_scoring_and_push()
self.maybe_fine_tune()
except Exception as e:
print('[quant] K线刷新异常:', e, flush=True)
time.sleep(60)
def start(self):
t = threading.Thread(target=self._kline_worker, daemon=True, name='quant-kline')
self._stop.clear()
t.start()
self._threads.append(t)
def run_scoring_and_push(self, top_n=20):
"""打分并推送推荐卡(WS + REST 共用)"""
try:
recs = self.build_recommendations(top_n)
if not recs:
return 0
event = {'ts': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'kind': '量化推荐',
'data': {'ts': self.last_scored_at,
'model': '自适应多因子模型',
'list': recs}}
self.emit(event)
print('[quant] 推荐卡已推送: {}'.format(len(recs)), flush=True)
return len(recs)
except Exception:
traceback.print_exc()
return 0
def maybe_fine_tune(self):
"""每日一次:事后评估回填 + IC 微调权重"""
today = datetime.now().strftime('%Y-%m-%d')
with self.state_lock:
if self.state.get('ft_date') == today:
return
self.state['ft_date'] = today
try:
filled = self.evaluate_pending()
w, notes = self.fine_tune()
self.emit({'ts': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'kind': '模型微调',
'data': {'filled': filled, 'notes': notes,
'weights': {k: round(v, 3) for k, v in w.items()}}})
except Exception as e:
print('[quant] 微调失败:', e, flush=True)
def stop(self):
self._stop.set()
# ---------- 打分与推荐 ----------
def score_universe(self):
"""对已刷新K线的股票做因子打分;返回 (ranking, factor_rows)"""
conn = self.fetcher._conn()
try:
rows = conn.execute("SELECT DISTINCT code FROM kline WHERE tf='day'").fetchall()
codes = [r[0] for r in rows]
finally:
pass
factor_rows, klines = {}, {}
for code in codes:
k = self.fetcher.load(code, 'day', 120)
if len(k) < 30:
continue
f = compute_factors(k)
if f:
factor_rows[code] = f
klines[code] = k
weights = self.weights_store.load()
scored = cross_section_score(factor_rows, weights)
return scored, factor_rows, klines, weights
def build_recommendations(self, top_n=20):
scored, factor_rows, klines, weights = self.score_universe()
if not scored:
return []
names = getattr(self, 'names', {})
scores = [s for _, s, _ in scored]
smin, smax = min(scores), max(scores)
spread = (smax - smin) or 1
# 历史基准:当前权重的全历史高分股 5 日中位收益(回测口径)
import numpy as np
hist_median = None
try:
top_q = np.quantile([s for _, s, _ in scored], 0.8)
except Exception:
top_q = None
recs = []
for code, score, _z in scored[:top_n]:
k = klines.get(code)
if k is None or len(k) < 6:
continue
price = float(k['close'].iloc[-1])
name = names.get(code, code)
star = 1 + int(round((score - smin) / spread * 4)) # 1..5
contrib = sorted(_z.items(), key=lambda kv: -abs(kv[1]))[:2]
reason = ''.join('{}突出'.format(f) for f, _ in contrib)
recs.append({
'ts': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'code': code, 'name': name,
'price': round(price, 2),
'score': round(score, 3),
'buy_low': round(price * 0.99, 2),
'buy_high': round(price * 1.005, 2),
'expected_return_pct': None, # 由回测基准填充
'stars': max(1, min(5, star)),
'reason': reason or '多因子综合打分靠前',
})
# 预计收益:用同权重下高分股历史 5 日中位收益(无历史则置空)
try:
med = self._backtest_top_median(weights)
for r in recs:
r['expected_return_pct'] = round(med * 100, 2) if med is not None else None
except Exception:
pass
self.last_ranking = recs
self.last_scored_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
self._save_recommendations(recs)
return recs
def _backtest_top_median(self, weights, days=60, horizon=5):
"""在已有K线的历史上按当前权重打分,取每日 Top20% 的 5 日中位收益"""
conn = self.fetcher._conn()
try:
codes = [r[0] for r in conn.execute(
"SELECT DISTINCT code FROM kline WHERE tf='day'").fetchall()]
finally:
pass
closes, frames = {}, {}
for code in codes:
k = self.fetcher.load(code, 'day', 140)
if len(k) >= 60:
k['bar_date'] = k['bar_time'].str[:10]
closes[code] = k.set_index('bar_date')['close']
frames[code] = k
if len(closes) < 30:
return None
all_dates = sorted(set().union(*[set(c.index) for c in closes.values()]))
med_list = []
for d in all_dates[60:-horizon]:
fd = {}
for code, k in frames.items():
sub = k[k['bar_date'] <= d]
if len(sub) >= 30:
fd[code] = compute_factors(sub)
if len(fd) < 30:
continue
scored = cross_section_score(fd, weights)
topq = [c for c, s, _ in scored[:max(1, len(scored) // 5)]]
fwd = []
for c in topq:
s = closes.get(c)
if s is None:
continue
after = s[s.index > d]
base = s[s.index <= d].iloc[-1]
if len(after) >= horizon and base:
fwd.append(after.iloc[horizon - 1] / base - 1)
if fwd:
med_list.append(float(pd.Series(fwd).median()))
return float(pd.Series(med_list).median()) if med_list else None
def _save_recommendations(self, recs):
conn = self.fetcher._conn()
try:
conn.execute("""CREATE TABLE IF NOT EXISTS quant_recommendation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL, code TEXT, name TEXT, price REAL,
score REAL, stars INTEGER,
buy_low REAL, buy_high REAL,
expected_return_pct REAL, reason TEXT,
realized_return_pct REAL,
model_note TEXT)""")
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
conn.executemany(
"INSERT INTO quant_recommendation(ts,code,name,price,score,stars,"
"buy_low,buy_high,expected_return_pct,reason,model_note) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
[(r['ts'], r['code'], r['name'], r['price'], r['score'], r['stars'],
r['buy_low'], r['buy_high'], r['expected_return_pct'], r['reason'],
'自适应因子模型') for r in recs])
conn.commit()
finally:
conn.close()
# ---------- 自适应微调 + 事后评估 ----------
def fine_tune(self):
"""IC 反聩微调因子权重,返回调整说明"""
conn = self.fetcher._conn()
codes = [r[0] for r in conn.execute(
"SELECT DISTINCT code FROM kline WHERE tf='day'").fetchall()]
conn.close()
factor_by_date, close_by_code = {}, {}
for code in codes:
k = self.fetcher.load(code, 'day', 140)
if len(k) < 40:
continue
k['bar_date'] = k['bar_time'].str[:10]
close_by_code[code] = k.set_index('bar_date')['close']
for d, sub in k.groupby('bar_date'):
if len(sub) >= 30:
factor_by_date.setdefault(d, {})
f = compute_factors(sub)
factor_by_date[d][code] = f
ic_map = factor_ic_series(factor_by_date, close_by_code)
w, notes = self.weights_store.adjust_by_ic(ic_map)
return w, notes
def evaluate_pending(self, horizon=5):
"""回填历史推荐的实际 5 日收益(事后检验),供微调与展示"""
conn = self.fetcher._conn()
try:
rows = conn.execute(
"SELECT id, code, ts, realized_return_pct FROM quant_recommendation "
"WHERE realized_return_pct IS NULL").fetchall()
filled = 0
for rid, code, ts, _ in rows:
k = self.fetcher.load(code, 'day', 30)
if k.empty:
continue
k = k[k['bar_time'] > ts]
if len(k) < horizon:
continue
base = float(k['open'].iloc[0])
ret = (float(k['close'].iloc[horizon - 1]) / base - 1) * 100
conn.execute("UPDATE quant_recommendation SET realized_return_pct=? WHERE id=?",
(round(ret, 2), rid))
filled += 1
conn.commit()
return filled
finally:
conn.close()
+189
View File
@@ -0,0 +1,189 @@
# -*- coding: utf-8 -*-
"""
自适应量化模型:多因子打分 + IC 反聩微调 + 推荐卡生成。
因子(全部由日线 K 线计算,禁前视:只用截至当日的窗口):
mom_20 20 日动量
trend_ma20 收盘相对 MA20 偏离
ma_align MA5/MA20 相对位置
vol_ratio 量比(当日量/20日均量)
rsi_inv (50-RSI14)/50(超卖得分高)
macd_hist MACD 柱/现价
vola_inv 负 20 日波动率(低波动加分)
自适应微调:每轮评估各因子近 20 日 IC(因子值 vs 后 5 日收益的秩相关),
IC > +0.02 权重×1.05IC < -0.02 权重×0.95(权重限制在 [0.1, 3.0]),
并记录调整原因。打分 = Σ w_f × 截面 zscore(f)。
"""
import json
import math
import os
from datetime import datetime
import numpy as np
import pandas as pd
FACTOR_NAMES = ['mom_20', 'trend_ma20', 'ma_align', 'vol_ratio', 'rsi_inv', 'macd_hist', 'vola_inv']
DEFAULT_WEIGHTS = {f: 1.0 for f in FACTOR_NAMES}
W_MIN, W_MAX = 0.1, 3.0
# ── 指标计算(单只股票的日线 DataFrame,时间升序,含 open/high/low/close/volume ──
def sma(s, n):
return s.rolling(n).mean()
def rsi_series(close, n=14):
d = close.diff()
gain = d.clip(lower=0).rolling(n).mean()
loss = (-d.clip(upper=0)).rolling(n).mean()
out = 100 - 100 / (1 + gain / loss.replace(0, np.nan))
return out.fillna(50)
def macd_hist_series(close):
ema12 = close.ewm(span=12, adjust=False).mean()
ema26 = close.ewm(span=26, adjust=False).mean()
dif = ema12 - ema26
dea = dif.ewm(span=9, adjust=False).mean()
return (dif - dea) / close
def compute_factors(df: pd.DataFrame) -> dict:
"""返回 {因子名: 因子在最末日的值};数据不足的因子为 NaN"""
if df is None or len(df) < 30:
return {}
close = df['close']
vol = df['volume']
out = {}
out['mom_20'] = (close.iloc[-1] / close.iloc[-21] - 1) if len(close) > 20 else np.nan
ma20 = sma(close, 20).iloc[-1]
out['trend_ma20'] = (close.iloc[-1] - ma20) / ma20 if ma20 else np.nan
ma5, ma20s = sma(close, 5).iloc[-1], ma20
out['ma_align'] = (ma5 - ma20s) / ma20s if ma20s else np.nan
v20 = sma(vol, 20).iloc[-1]
out['vol_ratio'] = vol.iloc[-1] / v20 if v20 else np.nan
out['rsi_inv'] = (50 - rsi_series(close).iloc[-1]) / 50
out['macd_hist'] = macd_hist_series(close).iloc[-1]
out['vola_inv'] = -(close.pct_change().rolling(20).std().iloc[-1] or np.nan)
return out
# ── 截面打分 ──
def cross_section_score(factor_rows: dict, weights: dict):
"""
factor_rows: {code: {因子: 值}}
返回 [(code, score, {因子: z值})] 按分降序
"""
names = [f for f in FACTOR_NAMES if f in weights]
codes = list(factor_rows.keys())
z = {c: {} for c in codes}
for f in names:
vals = pd.Series([factor_rows[c].get(f, np.nan) for c in codes], index=codes, dtype=float)
std = vals.std()
if not std or std != std:
z_f = pd.Series(np.nan, index=codes)
else:
z_f = (vals - vals.mean()) / std
for c in codes:
z[c][f] = z_f.get(c, np.nan)
scored = []
for c in codes:
total, parts = 0.0, 0
for f in names:
v = z[c].get(f)
if v == v: # 非 NaN
total += weights.get(f, 1.0) * v
parts += 1
if parts >= 3:
scored.append((c, total, z[c]))
scored.sort(key=lambda x: -x[1])
return scored
def factor_ic_series(factor_by_date: dict, close_by_code: dict, days=20, horizon=5):
"""
因子近 IC 序列:factor_by_date {date: {code: value}}close_by_code {code: Series}
返回 {因子: 平均IC}
"""
dates = sorted(factor_by_date.keys())
ics = {f: [] for f in FACTOR_NAMES}
for d in dates[-days:]:
fd = factor_by_date[d]
if len(fd) < 20:
continue
fwd = {}
for code, v in fd.items():
s = close_by_code.get(code)
if s is None:
continue
after = s[s.index > d]
if len(after) > horizon:
fwd[code] = after.iloc[horizon] / s[s.index <= d].iloc[-1] - 1
if len(fwd) < 20:
continue
codes = list(fwd.keys())
for f in FACTOR_NAMES:
fv = pd.Series([fd[c].get(f, np.nan) for c in codes], dtype=float)
rv = pd.Series([fwd[c] for c in codes], dtype=float)
ok = fv.notna() & rv.notna()
if ok.sum() < 20:
continue
ics[f].append(fv[ok].corr(rv[ok], method='spearman'))
return {f: (float(np.nanmean(v)) if v else 0.0) for f, v in ics.items()}
class WeightStore:
def __init__(self, db_path):
self.db_path = str(db_path)
self._ensure()
def _conn(self):
import sqlite3
conn = sqlite3.connect(self.db_path, check_same_thread=False)
return conn
def _ensure(self):
with self._conn() as conn:
conn.execute("""CREATE TABLE IF NOT EXISTS quant_weights (
factor TEXT PRIMARY KEY,
weight REAL,
updated_at TEXT,
note TEXT)""")
def load(self) -> dict:
with self._conn() as conn:
rows = conn.execute("SELECT factor, weight FROM quant_weights").fetchall()
w = dict(DEFAULT_WEIGHTS)
w.update({r[0]: r[1] for r in rows})
return w
def save(self, weights: dict, note: str):
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with self._conn() as conn:
for f, w in weights.items():
conn.execute("INSERT OR REPLACE INTO quant_weights(factor,weight,updated_at,note) "
"VALUES (?,?,?,?)", (f, round(w, 4), now, note))
def adjust_by_ic(self, ic_map: dict, lr=0.05):
"""IC 反聩微调:IC>0.02 权重×(1+lr)IC<-0.02 ×(1-lr),夹在 [W_MIN, W_MAX]"""
w = self.load()
notes = []
for f, ic in ic_map.items():
old = w.get(f, 1.0)
if ic > 0.02:
w[f] = min(W_MAX, old * (1 + lr))
tag = ''
elif ic < -0.02:
w[f] = max(W_MIN, old * (1 - lr))
tag = ''
else:
continue
notes.append('{} {}{:.3f}{:.3f} (IC{:+.3f})'.format(f, tag, old, w[f], ic))
if notes:
self.save(w, note='IC微调: ' + '; '.join(notes))
return w, notes
+44 -1
View File
@@ -76,9 +76,26 @@
<button data-f="盘前隔夜预估">盘前预估</button> <button data-f="盘前隔夜预估">盘前预估</button>
<button data-f="收盘日报">日报</button> <button data-f="收盘日报">日报</button>
<button data-f="事件影响分析">事件影响</button> <button data-f="事件影响分析">事件影响</button>
<button data-f="量化推荐">量化推荐</button>
<button data-f="模型微调">微调</button>
</div> </div>
<ul id="feed"><li class="empty">等待服务端推送…</li></ul> <ul id="feed"><li class="empty">等待服务端推送…</li></ul>
</main> </main>
<section style="max-width:1100px;margin:0 auto 30px">
<h2 style="font-size:15px;color:var(--text)">量化模型 · 自适应推荐(多因子 + IC 微调)</h2>
<div style="font-size:12px;color:var(--text2);margin-bottom:8px">权重:<span id="qw">加载中…</span></div>
<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;overflow:auto">
<table style="width:100%;border-collapse:collapse;font-size:13px" id="qtable">
<thead><tr style="color:var(--text2);text-align:left">
<th style="padding:8px 10px">代码</th><th style="padding:8px 10px">名称</th>
<th style="padding:8px 10px">现价</th><th style="padding:8px 10px">模型分</th>
<th style="padding:8px 10px">购入区间</th><th style="padding:8px 10px">预计收益(回测口径)</th>
<th style="padding:8px 10px">推荐指数</th><th style="padding:8px 10px">简易原因</th>
</tr></thead><tbody></tbody>
</table>
</div>
<div style="margin-top:6px;font-size:11px;color:#6b7280">⚠ 购入区间与预计收益为模型回测/推测口径,不构成投资建议。</div>
</section>
<script> <script>
const feed = document.getElementById('feed') const feed = document.getElementById('feed')
const conn = document.getElementById('conn') const conn = document.getElementById('conn')
@@ -117,6 +134,10 @@ function render(e, fresh) {
`<span class="d"><b>${esc(d.event_summary)}</b><br>` + `<span class="d"><b>${esc(d.event_summary)}</b><br>` +
`方向:<span class="${dirCls}">${esc(d.direction)}</span> 板块:${sectors || '—'} 置信度 ${d.confidence ?? '-'}%<br>` + `方向:<span class="${dirCls}">${esc(d.direction)}</span> 板块:${sectors || '—'} 置信度 ${d.confidence ?? '-'}%<br>` +
`${esc(d.reasoning || '')}${impactCard(d)}</span>` `${esc(d.reasoning || '')}${impactCard(d)}</span>`
} else if (e.kind === '量化推荐') {
renderQuant(e.data)
const txt = '自适应模型推送 ' + ((d.list || []).length) + ' 只推荐'
li.innerHTML = `<span class="t">${e.ts}</span><span class="k ${e.kind}">${e.kind}</span><span class="d">${esc(txt)}</span>`
} else { } else {
const txt = typeof d === 'object' ? JSON.stringify(d) : String(d) const txt = typeof d === 'object' ? JSON.stringify(d) : String(d)
li.innerHTML = `<span class="t">${e.ts}</span><span class="k ${e.kind}">${e.kind}</span><span class="d">${esc(txt)}</span>` li.innerHTML = `<span class="t">${e.ts}</span><span class="k ${e.kind}">${e.kind}</span><span class="d">${esc(txt)}</span>`
@@ -129,7 +150,29 @@ function applyRecent(events) {
[...events].reverse().forEach(e => render(e, false)) [...events].reverse().forEach(e => render(e, false))
} }
function connect() { function function renderQuant(d) {
const tb = document.querySelector('#qtable tbody')
if (!tb) return
tb.innerHTML = (d.list || []).map(r =>
'<tr><td style="padding:6px 10px">' + esc(r.code) + '</td>' +
'<td style="padding:6px 10px">' + esc(r.name) + '</td>' +
'<td style="padding:6px 10px">' + r.price + '</td>' +
'<td style="padding:6px 10px">' + r.score + '</td>' +
'<td style="padding:6px 10px">' + r.buy_low + ' ~ ' + r.buy_high + '</td>' +
'<td style="padding:6px 10px">' + (r.expected_return_pct == null ? '—' : r.expected_return_pct + '%') + '</td>' +
'<td style="padding:6px 10px" class="stars">' + '★'.repeat(r.stars) + '</td>' +
'<td style="padding:6px 10px">' + esc(r.reason) + '</td></tr>').join('')
}
function loadQuant() {
fetch('/api/quant/recommendations').then(r => r.json()).then(renderQuant).catch(() => {})
fetch('/api/quant/weights').then(r => r.json()).then(w => {
document.getElementById('qw').textContent =
Object.entries(w || {}).map(([k, v]) => k + '=' + Number(v).toFixed(2)).join(' ') || '—'
}).catch(() => {})
}
loadQuant()
setInterval(loadQuant, 60000)
connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws' const proto = location.protocol === 'https:' ? 'wss' : 'ws'
ws = new WebSocket(`${proto}://${location.host}/ws`) ws = new WebSocket(`${proto}://${location.host}/ws`)
ws.onopen = () => { conn.textContent = '已连接'; conn.className = 'conn on' } ws.onopen = () => { conn.textContent = '已连接'; conn.className = 'conn on' }
+35 -1
View File
@@ -7,6 +7,8 @@ Linux 服务器常驻运行: python -m src.web.server (或 systemd,见
import asyncio import asyncio
import json import json
import os import os
import threading
import time
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
@@ -17,6 +19,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from src.realtime.collector import TimelineCollector # noqa: E402 from src.realtime.collector import TimelineCollector # noqa: E402
from src.analysis.event_impact import EventImpactService # noqa: E402 from src.analysis.event_impact import EventImpactService # noqa: E402
from src.quant.engine import QuantEngine # noqa: E402
PORT = int(os.environ.get('PORT', '8100')) PORT = int(os.environ.get('PORT', '8100'))
ROOT = Path(__file__).resolve().parent.parent.parent ROOT = Path(__file__).resolve().parent.parent.parent
@@ -58,7 +61,25 @@ async def collector_task(_app):
await asyncio.sleep(60) await asyncio.sleep(60)
task = asyncio.create_task(poll()) task = asyncio.create_task(poll())
_app['impact'] = collector.impact
# 量化引擎:K线刷新 + 自适应打分 + 推荐卡推送
quant = QuantEngine(emit=lambda rec: loop.call_soon_threadsafe(
asyncio.ensure_future, broadcast(rec)), db_path=str(ROOT / 'data' / 'a_stock.db'))
_app['quant'] = quant
quant.start()
def _quant_loops():
while True:
try:
time.sleep(SCORING_INTERVAL_S)
quant.run_scoring_and_push()
except Exception as e:
print('[quant-loop]', e, flush=True)
time.sleep(60)
threading.Thread(target=_quant_loops, daemon=True, name='quant-scoring').start()
task = asyncio.create_task(poll())
# 启动即推最近历史(从 jsonl 恢复) # 启动即推最近历史(从 jsonl 恢复)
feed = ROOT / 'data' / 'realtime_feed.jsonl' feed = ROOT / 'data' / 'realtime_feed.jsonl'
if feed.exists(): if feed.exists():
@@ -83,6 +104,17 @@ async def recent_events(_request):
return web.json_response({'events': list(recent)}) return web.json_response({'events': list(recent)})
async def quant_recommendations(_request):
q = _request.app['quant']
return web.json_response({'ts': q.last_scored_at,
'list': (q.last_ranking or [])[:50]})
async def quant_weights(_request):
q = _request.app['quant']
return web.json_response(q.weights_store.load())
async def impact_history(_request): async def impact_history(_request):
collector = _request.app['impact'] collector = _request.app['impact']
return web.json_response({'events': collector.history(20)}) return web.json_response({'events': collector.history(20)})
@@ -114,6 +146,8 @@ def build_app():
app.router.add_get('/api/recent', recent_events) app.router.add_get('/api/recent', recent_events)
app.router.add_get('/api/stats', stats) app.router.add_get('/api/stats', stats)
app.router.add_get('/api/impact', impact_history) app.router.add_get('/api/impact', impact_history)
app.router.add_get('/api/quant/recommendations', quant_recommendations)
app.router.add_get('/api/quant/weights', quant_weights)
app.router.add_get('/ws', ws_handler) app.router.add_get('/ws', ws_handler)
app.cleanup_ctx.append(collector_task) app.cleanup_ctx.append(collector_task)
return app return app