feat: 量化模型迁移——K线获取(日/分钟)+多因子打分+IC自适应微调+推荐卡推送/REST/前端
This commit is contained in:
@@ -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')
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
+44
-1
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user