diff --git a/patch_frontend_quant.py b/patch_frontend_quant.py new file mode 100644 index 0000000..1f840b4 --- /dev/null +++ b/patch_frontend_quant.py @@ -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 = ' ' +new1 = (old1 + '\n \n' + ' ') +assert old1 in s, 'filters not found' +s = s.replace(old1, new1) + +# 2) 主区追加量化推荐表格 +old2 = ' \n' +new2 = (' \n\n' + '
\n' + '

量化模型 · 自适应推荐' + '(多因子 + IC 微调)

\n' + '
' + '权重:加载中…
\n' + '
\n' + ' \n' + ' \n' + ' \n' + ' \n' + ' ' + '\n' + ' ' + '\n' + ' \n' + '
代码名称现价模型分购入区间预计收益(回测口径)推荐指数简易原因
\n' + '
\n' + '
' + '⚠ 购入区间与预计收益为模型回测/推测口径,不构成投资建议。
\n' + '
') +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 = `${e.ts}${e.kind}" + "${esc(txt)}`\n }") +new3 = (" } else if (e.kind === '量化推荐') {\n" + " renderQuant(e.data)\n" + " const txt = '自适应模型推送 ' + ((d.list || []).length) + ' 只推荐'\n" + " li.innerHTML = `${e.ts}${e.kind}" + "${esc(txt)}`\n" + " } else {\n" + " const txt = typeof d === 'object' ? JSON.stringify(d) : String(d)\n" + " li.innerHTML = `${e.ts}${e.kind}" + "${esc(txt)}`\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" + " '' + esc(r.code) + '' +\n" + " '' + esc(r.name) + '' +\n" + " '' + r.price + '' +\n" + " '' + r.score + '' +\n" + " '' + r.buy_low + ' ~ ' + r.buy_high + '' +\n" + " '' + (r.expected_return_pct == null ? '—' : r.expected_return_pct + '%') + '' +\n" + " '' + '★'.repeat(r.stars) + '' +\n" + " '' + esc(r.reason) + '').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') diff --git a/src/fetcher/kline_fetcher.py b/src/fetcher/kline_fetcher.py new file mode 100644 index 0000000..eb3ceb2 --- /dev/null +++ b/src/fetcher/kline_fetcher.py @@ -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() diff --git a/src/quant/engine.py b/src/quant/engine.py new file mode 100644 index 0000000..44655a5 --- /dev/null +++ b/src/quant/engine.py @@ -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() diff --git a/src/quant/model.py b/src/quant/model.py new file mode 100644 index 0000000..0013ef9 --- /dev/null +++ b/src/quant/model.py @@ -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.05,IC < -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 diff --git a/src/web/index.html b/src/web/index.html index 8a57bb8..4f02697 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -76,9 +76,26 @@ + + +
+

量化模型 · 自适应推荐(多因子 + IC 微调)

+
权重:加载中…
+
+ + + + + + + +
代码名称现价模型分购入区间预计收益(回测口径)推荐指数简易原因
+
+
⚠ 购入区间与预计收益为模型回测/推测口径,不构成投资建议。
+