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>
</div>
<ul id="feed"><li class="empty">等待服务端推送…</li></ul>
</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>
const feed = document.getElementById('feed')
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="${dirCls}">${esc(d.direction)}</span> 板块:${sectors || '—'} 置信度 ${d.confidence ?? '-'}%<br>` +
`${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 {
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>`
@@ -129,7 +150,29 @@ function applyRecent(events) {
[...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'
ws = new WebSocket(`${proto}://${location.host}/ws`)
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 json
import os
import threading
import time
from collections import deque
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.analysis.event_impact import EventImpactService # noqa: E402
from src.quant.engine import QuantEngine # noqa: E402
PORT = int(os.environ.get('PORT', '8100'))
ROOT = Path(__file__).resolve().parent.parent.parent
@@ -58,7 +61,25 @@ async def collector_task(_app):
await asyncio.sleep(60)
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 恢复)
feed = ROOT / 'data' / 'realtime_feed.jsonl'
if feed.exists():
@@ -83,6 +104,17 @@ async def recent_events(_request):
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):
collector = _request.app['impact']
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/stats', stats)
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.cleanup_ctx.append(collector_task)
return app