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