Compare commits
10
Commits
890ff36485
...
linux-web
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4288b18097 | ||
|
|
80d865de89 | ||
|
|
42e1e68e26 | ||
|
|
93ea5037ce | ||
|
|
b52f99351d | ||
|
|
24b9bd44ff | ||
|
|
db98f6f084 | ||
|
|
08efc52ff8 | ||
|
|
ab7f77163f | ||
|
|
a1329442bd |
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""服务器调试:两级状态桶在 000993 上的真实分布"""
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
sys.path.insert(0, '/opt/a_stock_timeline')
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
from src.fetcher.kline_fetcher import KlineFetcher
|
||||
from src.quant.model import per_stock_expected, _bucket, rsi_series
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
DB = '/opt/a_stock_timeline/data/a_stock.db'
|
||||
kf = KlineFetcher(DB)
|
||||
k = kf.load('000993', 'day', 150)
|
||||
print('bars:', len(k))
|
||||
|
||||
close = k['close'].reset_index(drop=True)
|
||||
ma20 = close.rolling(20).mean()
|
||||
rsi = rsi_series(close)
|
||||
|
||||
for fine in (True, False):
|
||||
cur = _bucket(close.iloc[-1], ma20.iloc[-1], rsi.iloc[-1], fine)
|
||||
rets = []
|
||||
horizon = 5
|
||||
for t in range(30, len(close) - horizon):
|
||||
b = _bucket(close.iloc[t], ma20.iloc[t], rsi.iloc[t], fine)
|
||||
if b == cur:
|
||||
rets.append(close.iloc[t + horizon] / close.iloc[t] - 1)
|
||||
med = float(np.median(rets)) if rets else None
|
||||
print('fine={} 桶={} 样本={} 中位={}'.format(fine, cur, len(rets), med))
|
||||
|
||||
med, n, bucket = per_stock_expected(k)
|
||||
print('per_stock_expected:', med, n, bucket)
|
||||
@@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""服务器端验证新推荐原因格式"""
|
||||
import sys, warnings
|
||||
sys.path.insert(0, '/opt/a_stock_timeline')
|
||||
warnings.filterwarnings('ignore')
|
||||
from src.fetcher.kline_fetcher import KlineFetcher
|
||||
from src.quant.engine import QuantEngine
|
||||
|
||||
DB = '/opt/a_stock_timeline/data/a_stock.db'
|
||||
q = QuantEngine(emit=lambda e: None, db_path=DB)
|
||||
recs = q.build_recommendations(3)
|
||||
print('=== 新格式推荐 ===')
|
||||
for r in recs:
|
||||
print('{}★ {} | {}'.format('★' * r['stars'], r['name'], r['reason'][:80]))
|
||||
print('=== 全部完成 ===')
|
||||
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
异常检测(a_stock_timeline 版):基于日K线的十条规则,
|
||||
每条输出带具体数字的中文描述,供 WS 推送与前端异常流展示。
|
||||
"""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _sma(s, n):
|
||||
return s.rolling(n).mean()
|
||||
|
||||
|
||||
def _chg(close):
|
||||
return close.pct_change() * 100
|
||||
|
||||
|
||||
def detect_anomalies(code: str, name: str, df: pd.DataFrame) -> list:
|
||||
"""
|
||||
df: 日K线(时间升序,含 open/high/low/close/volume),至少 30 行
|
||||
返回 [{code,name,type,desc,severity}],severity 1-5
|
||||
"""
|
||||
out = []
|
||||
if df is None or len(df) < 30:
|
||||
return out
|
||||
close = df['close'].reset_index(drop=True)
|
||||
op = df['open'].reset_index(drop=True)
|
||||
vol = df['volume'].reset_index(drop=True)
|
||||
high = df['high'].reset_index(drop=True)
|
||||
low = df['low'].reset_index(drop=True)
|
||||
n = len(close) - 1
|
||||
c = close.iloc[-1]
|
||||
v = vol.iloc[-1]
|
||||
v20 = vol.iloc[-20:-1].mean() # 前19日均量(不含当日,避免自稀释)
|
||||
chg = _chg(close).iloc[-1] if n > 0 else 0
|
||||
|
||||
def add(t, sev, desc):
|
||||
out.append({'code': code, 'name': name, 'type': t,
|
||||
'severity': max(1, min(5, sev)), 'desc': desc})
|
||||
|
||||
# 1. 天量(量比 ≥ 3 倍)
|
||||
if v20 and v20 > 0:
|
||||
ratio = v / v20
|
||||
if ratio >= 3:
|
||||
add('天量', min(5, int(ratio)), f'当日成交量是20日均量的{ratio:.1f}倍,换手剧烈')
|
||||
|
||||
# 2. 大幅波动(单日涨跌幅超 5%)
|
||||
if abs(chg) >= 5:
|
||||
direction = '上涨' if chg > 0 else '下跌'
|
||||
add('大幅波动', min(5, int(abs(chg) / 2)), f'单日{direction}{abs(chg):.1f}%,波动异常')
|
||||
|
||||
# 3. 连续上涨/下跌(近5日同方向且累计超5%)
|
||||
if n >= 5:
|
||||
last5 = close.iloc[-5:]
|
||||
rises = sum(last5.iloc[i+1] > last5.iloc[i] for i in range(4))
|
||||
cum5 = (close.iloc[-1] / close.iloc[-5] - 1) * 100 if close.iloc[-5] else 0
|
||||
if rises >= 4 and cum5 > 5:
|
||||
add('连续上涨', min(5, int(abs(cum5) / 3)), f'近5日涨{cum5:.1f}%({rises}天上涨),短期涨幅较大')
|
||||
elif rises <= 1 and cum5 < -5:
|
||||
add('连续下跌', min(5, int(abs(cum5) / 3)), f'近5日跌{cum5:.1f}%({4-rises}天下跌),注意风险')
|
||||
|
||||
# 4. 均线突破/破位(MA20)
|
||||
if n >= 20:
|
||||
ma20 = close.rolling(20).mean()
|
||||
prev_below = close.iloc[-2] < ma20.iloc[-2]
|
||||
now_above = close.iloc[-1] > ma20.iloc[-1]
|
||||
if prev_below and now_above:
|
||||
add('均线突破', 4, f'收盘{c:.2f}上穿MA20({ma20.iloc[-1]:.2f}),趋势可能转多')
|
||||
elif close.iloc[-2] > ma20.iloc[-2] and close.iloc[-1] < ma20.iloc[-1]:
|
||||
add('均线破位', 4, f'收盘{c:.2f}跌破MA20({ma20.iloc[-1]:.2f}),趋势可能转空')
|
||||
|
||||
# 5. 创20日新高/新低
|
||||
if n >= 20:
|
||||
hi20 = high.iloc[-20:].max()
|
||||
lo20 = low.iloc[-20:].min()
|
||||
if c >= hi20:
|
||||
add('创20日新高', 3, f'收盘{c:.2f}创近20日新高(前高{hi20:.2f}),关注量能配合')
|
||||
elif c <= lo20:
|
||||
add('创20日新低', 3, f'收盘{c:.2f}创近20日新低(前低{lo20:.2f}),注意下行风险')
|
||||
|
||||
# 6. 长上下影线(振幅超 8%)
|
||||
if c > 0:
|
||||
body = abs(c - op.iloc[-1])
|
||||
amp = (high.iloc[-1] - low.iloc[-1]) / c * 100 if c else 0
|
||||
if amp >= 8:
|
||||
add('长影线', min(5, int(amp / 3)), f'当日振幅{amp:.1f}%,多空分歧大')
|
||||
|
||||
# 7. 量价背离(放量滞涨 / 缩量上涨)
|
||||
if v20 and v20 > 0 and n >= 5:
|
||||
vol_r = v / v20
|
||||
chg5 = (close.iloc[-1] / close.iloc[-5] - 1) * 100 if close.iloc[-5] else 0
|
||||
if vol_r >= 2 and abs(chg) < 1:
|
||||
add('放量滞涨', 3, f'量比{vol_r:.1f}但涨跌幅仅{chg:.1f}%,警惕出货')
|
||||
elif vol_r <= 0.4 and abs(chg5) < 2:
|
||||
add('极度缩量', 2, f'量比{vol_r:.1f},市场关注度极低')
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""异常扫描:K线规则检测→入库→推送(事件流)"""
|
||||
from datetime import datetime
|
||||
|
||||
from src.analysis.anomaly_detect import detect_anomalies
|
||||
from src.fetcher.kline_fetcher import KlineFetcher
|
||||
|
||||
|
||||
class AnomalyScanner:
|
||||
"""扫描宇宙内 K 线异常并入库推送"""
|
||||
|
||||
def __init__(self, emit, db_path):
|
||||
self.push = emit
|
||||
self.db = str(db_path)
|
||||
c = __import__('sqlite3').connect(self.db)
|
||||
c.execute("CREATE TABLE IF NOT EXISTS anomaly_event"
|
||||
" (id INTEGER PRIMARY KEY AUTOINCREMENT"
|
||||
", ts TEXT, code TEXT, name TEXT"
|
||||
", type TEXT, severity INTEGER, detail TEXT)")
|
||||
c.commit()
|
||||
c.close()
|
||||
|
||||
def scan(self, codes, names=None):
|
||||
"""扫描并返回/入库异常列表"""
|
||||
kf = KlineFetcher(self.db)
|
||||
found = []
|
||||
for c in codes:
|
||||
k = kf.load(c, 'day', 40)
|
||||
if len(k) >= 30:
|
||||
found.extend(detect_anomalies(c, names.get(c, ''), k))
|
||||
if found:
|
||||
self._store(found)
|
||||
return found
|
||||
|
||||
def _store(self, items):
|
||||
c = __import__('sqlite3').connect(self.db)
|
||||
now = datetime.now().isoformat()
|
||||
c.executemany(
|
||||
"INSERT INTO anomaly_event"
|
||||
" (ts,code,name,type,severity,detail) VALUES (?,?,?,?,?,?)",
|
||||
[(now, x['code'], x.get('name', ''), x['type'],
|
||||
x.get('severity', 3), x.get('desc', '')) for x in items])
|
||||
c.commit()
|
||||
c.close()
|
||||
@@ -60,15 +60,23 @@ class KlineFetcher:
|
||||
except Exception as e2:
|
||||
raise e2 from e
|
||||
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')
|
||||
try:
|
||||
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')
|
||||
except Exception as em_err:
|
||||
# EM 分钟线被限速时回退新浪分钟线(仅 5/15/30/60 分钟)
|
||||
sym = ('sh' if code[:1] == '6' else 'sz') + code
|
||||
try:
|
||||
df = ak.stock_zh_a_minute(symbol=sym, period=cfg['ak_period'], adjust='')
|
||||
except Exception as e2:
|
||||
raise e2 from em_err
|
||||
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', '日期'):
|
||||
if c in ('时间', 'bar_time', 'date', '日期', 'day'):
|
||||
cmap[c] = 'bar_time'
|
||||
elif c in ('开盘', 'open'):
|
||||
cmap[c] = 'open'
|
||||
|
||||
+35
-15
@@ -18,7 +18,7 @@ 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)
|
||||
cross_section_score, factor_ic_series, per_stock_expected)
|
||||
|
||||
UNIVERSE_N = int(os.environ.get('QUANT_UNIVERSE_N', '300'))
|
||||
SCORING_INTERVAL_S = int(os.environ.get('QUANT_SCORING_INTERVAL_S', '1800'))
|
||||
@@ -66,14 +66,21 @@ class QuantEngine:
|
||||
return [c for c, _ in pairs], dict(pairs)
|
||||
except Exception as e:
|
||||
print('[quant] 东财宇宙失败:', e, flush=True)
|
||||
# 3) 兜底:资金流表中的活跃股
|
||||
conn = self.fetcher._conn()
|
||||
# 3) 兜底:资金流表中的活跃股(当日有资金流的股票即活跃宇宙)
|
||||
try:
|
||||
rows = conn.execute("SELECT DISTINCT ts_code FROM money_flow LIMIT ?",
|
||||
(n,)).fetchall()
|
||||
return [r[0] for r in rows], {}
|
||||
finally:
|
||||
pass
|
||||
conn = self.fetcher._conn()
|
||||
try:
|
||||
rows = conn.execute("SELECT DISTINCT ts_code FROM money_flow "
|
||||
"ORDER BY trade_date DESC LIMIT ?", (n,)).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
codes = [r[0] for r in rows]
|
||||
if codes:
|
||||
print('[quant] 宇宙兜底: 资金流活跃股 {} 只'.format(len(codes)), flush=True)
|
||||
return codes, {}
|
||||
except Exception as e:
|
||||
print('[quant] 资金流兜底失败:', e, flush=True)
|
||||
return [], {}
|
||||
|
||||
def _kline_count(self, tf):
|
||||
import sqlite3
|
||||
@@ -205,6 +212,7 @@ class QuantEngine:
|
||||
top_q = None
|
||||
|
||||
recs = []
|
||||
from src.quant.reason import build_reason
|
||||
for code, score, _z in scored[:top_n]:
|
||||
k = klines.get(code)
|
||||
if k is None or len(k) < 6:
|
||||
@@ -212,8 +220,8 @@ class QuantEngine:
|
||||
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)
|
||||
rich_reason, _detail = build_reason(code, k)
|
||||
reason = rich_reason or '多因子综合打分靠前'
|
||||
recs.append({
|
||||
'ts': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'code': code, 'name': name,
|
||||
@@ -223,15 +231,27 @@ class QuantEngine:
|
||||
'buy_high': round(price * 1.005, 2),
|
||||
'expected_return_pct': None, # 由回测基准填充
|
||||
'stars': max(1, min(5, star)),
|
||||
'reason': reason or '多因子综合打分靠前',
|
||||
'reason': reason,
|
||||
})
|
||||
# 预计收益:用同权重下高分股历史 5 日中位收益(无历史则置空)
|
||||
# 预计收益(逐股):个股同状态条件 5 日收益中位数(真实历史统计)
|
||||
# 组合层面中位数作为参考基准附在卡片级
|
||||
cohort = None
|
||||
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
|
||||
cohort = self._backtest_top_median(weights)
|
||||
except Exception:
|
||||
pass
|
||||
for r in recs:
|
||||
k = klines.get(r['code'])
|
||||
if k is None:
|
||||
continue
|
||||
est, samples, bucket = per_stock_expected(k)
|
||||
if est is not None:
|
||||
r['expected_return_pct'] = round(est * 100, 2)
|
||||
r['expected_samples'] = samples
|
||||
r['expected_basis'] = '同状态' + ('细' if '|' in bucket else '趋势')
|
||||
elif cohort is not None:
|
||||
r['expected_return_pct'] = round(cohort * 100, 2)
|
||||
r['expected_basis'] = '组合回测'
|
||||
self.last_ranking = recs
|
||||
self.last_scored_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
self._save_recommendations(recs)
|
||||
|
||||
+65
-1
@@ -71,6 +71,70 @@ def compute_factors(df: pd.DataFrame) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def state_bucket(close: float, ma20: float, rsi: float):
|
||||
"""技术状态桶:趋势方向 × RSI 区间(与 skill 实证口径一致)"""
|
||||
if ma20 != ma20 or rsi != rsi or close != close:
|
||||
return None
|
||||
trend = 'above' if close > ma20 else 'below'
|
||||
if rsi < 30:
|
||||
r = 'oversold'
|
||||
elif rsi > 70:
|
||||
r = 'overbought'
|
||||
else:
|
||||
r = 'mid'
|
||||
return trend + '|' + r
|
||||
|
||||
|
||||
def _bucket(close, ma20, rsi, fine):
|
||||
if ma20 != ma20 or rsi != rsi or close != close:
|
||||
return None
|
||||
trend = 'above' if close > ma20 else 'below'
|
||||
if not fine:
|
||||
return trend
|
||||
if rsi < 30:
|
||||
r = 'oversold'
|
||||
elif rsi > 70:
|
||||
r = 'overbought'
|
||||
else:
|
||||
r = 'mid'
|
||||
return trend + '|' + r
|
||||
|
||||
|
||||
def per_stock_expected(k: pd.DataFrame, horizon=5, min_samples=8):
|
||||
"""
|
||||
个股同状态条件收益(两级桶):
|
||||
先用 趋势xRSI 细桶,样本不足退到仅趋势方向粗桶。
|
||||
返回 (中位数, 样本数, 桶说明) 或 (None, 0, '')。
|
||||
"""
|
||||
close = k['close'].reset_index(drop=True)
|
||||
n = len(close)
|
||||
if n < 40:
|
||||
return None, 0, ''
|
||||
ma20 = close.rolling(20).mean()
|
||||
rsi = rsi_series(close)
|
||||
|
||||
def collect(fine):
|
||||
cur = _bucket(close.iloc[-1], ma20.iloc[-1], rsi.iloc[-1], fine)
|
||||
if cur is None:
|
||||
return None, []
|
||||
rets = []
|
||||
for t in range(30, n - horizon):
|
||||
b = _bucket(close.iloc[t], ma20.iloc[t], rsi.iloc[t], fine)
|
||||
if b == cur:
|
||||
fwd = close.iloc[t + horizon] / close.iloc[t] - 1
|
||||
if fwd == fwd:
|
||||
rets.append(fwd)
|
||||
return cur, rets
|
||||
|
||||
for fine in (True, False):
|
||||
cur, rets = collect(fine)
|
||||
if len(rets) >= min_samples:
|
||||
med = float(np.median(rets))
|
||||
return med, len(rets), cur
|
||||
return None, 0, ''
|
||||
|
||||
|
||||
|
||||
# ── 截面打分 ──
|
||||
|
||||
def cross_section_score(factor_rows: dict, weights: dict):
|
||||
@@ -98,7 +162,7 @@ def cross_section_score(factor_rows: dict, weights: dict):
|
||||
if v == v: # 非 NaN
|
||||
total += weights.get(f, 1.0) * v
|
||||
parts += 1
|
||||
if parts >= 3:
|
||||
if parts > 0:
|
||||
scored.append((c, total, z[c]))
|
||||
scored.sort(key=lambda x: -x[1])
|
||||
return scored
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
推荐原因生成器:因子截面值 → 带具体数字和方向的中文推荐理由。
|
||||
"""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def build_reason(code, kline_df, weights=None):
|
||||
"""
|
||||
从单股日K线(时间升序,≥30行)生成多维度推荐原因。
|
||||
返回 (reason_str, factor_detail_dict)
|
||||
"""
|
||||
if kline_df is None or len(kline_df) < 30:
|
||||
return '', {}
|
||||
close = kline_df['close'].reset_index(drop=True)
|
||||
vol = kline_df['volume'].reset_index(drop=True)
|
||||
high = kline_df['high'].reset_index(drop=True)
|
||||
low = kline_df['low'].reset_index(drop=True)
|
||||
n = len(close)
|
||||
c = close.iloc[-1]
|
||||
|
||||
parts = []
|
||||
|
||||
# 动量
|
||||
if n > 21:
|
||||
mom = (c / close.iloc[-21] - 1) * 100
|
||||
tag = '强' if mom > 5 else ('偏强' if mom > 0 else '偏弱' if mom > -5 else '弱')
|
||||
parts.append(f"20日动量{mom:+.1f}%({tag})")
|
||||
|
||||
# 趋势(MA20 偏离)
|
||||
ma20 = close.rolling(20).mean().iloc[-1]
|
||||
if ma20 and ma20 > 0:
|
||||
dev = (c - ma20) / ma20 * 100
|
||||
tag = '强势区' if dev > 3 else ('偏高水平' if dev > 0 else '偏低水平' if dev > -3 else '弱势区')
|
||||
parts.append(f"距MA20 {dev:+.1f}%({tag})")
|
||||
|
||||
# 量能
|
||||
v20 = vol.rolling(20).mean().iloc[-1]
|
||||
if v20 and v20 > 0:
|
||||
vr = vol.iloc[-1] / v20
|
||||
if vr > 1.5:
|
||||
parts.append(f"量比{vr:.1f}(放量)")
|
||||
elif vr < 0.5:
|
||||
parts.append(f"量比{vr:.1f}(极度缩量)")
|
||||
|
||||
# RSI
|
||||
close_s = close
|
||||
delta = close_s.diff()
|
||||
gain = delta.clip(lower=0).rolling(14).mean()
|
||||
loss = (-delta.clip(upper=0)).rolling(14).mean()
|
||||
rs = gain / loss.replace(0, np.nan)
|
||||
rsi = (100 - 100 / (1 + rs)).iloc[-1]
|
||||
if rsi == rsi:
|
||||
if rsi > 70:
|
||||
parts.append(f"RSI={rsi:.0f}(超买)")
|
||||
elif rsi < 30:
|
||||
parts.append(f"RSI={rsi:.0f}(超卖)")
|
||||
|
||||
# MACD
|
||||
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()
|
||||
hist = (dif - dea).iloc[-1]
|
||||
if hist > 0:
|
||||
parts.append("MACD多头")
|
||||
else:
|
||||
parts.append("MACD空头")
|
||||
|
||||
return ';'.join(parts), {}
|
||||
+119
-75
@@ -97,109 +97,153 @@
|
||||
<div style="margin-top:6px;font-size:11px;color:#6b7280">⚠ 购入区间与预计收益为模型回测/推测口径,不构成投资建议。</div>
|
||||
</section>
|
||||
<script>
|
||||
const feed = document.getElementById('feed')
|
||||
const conn = document.getElementById('conn')
|
||||
const buf = document.getElementById('buf')
|
||||
let filter = ''
|
||||
let ws, retryTimer
|
||||
'use strict';
|
||||
var feed = document.getElementById('feed')
|
||||
var connEl = document.getElementById('conn')
|
||||
var bufEl = document.getElementById('buf')
|
||||
var filter = ''
|
||||
var ws = null
|
||||
var retryTimer = null
|
||||
|
||||
function esc(s) { return String(s ?? '').replace(/[<>&]/g, '') }
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[<>&]/g, '') }
|
||||
|
||||
function impactCard(d) {
|
||||
if (!d || !d.stocks || !d.stocks.length) return ''
|
||||
const dirCls = { '利好': 'dir-bull', '利空': 'dir-bear' }[d.direction] || 'dir-neutral'
|
||||
const rows = d.stocks.map(s =>
|
||||
`<tr><td>${esc(s.name)}</td><td>${esc(s.code)}</td>` +
|
||||
`<td>${s.buy_zone[0]} ~ ${s.buy_zone[1]}</td>` +
|
||||
`<td class="${s.expected_return_pct > 0 ? 'up' : s.expected_return_pct < 0 ? 'down' : ''}">${s.expected_return_pct > 0 ? '+' : ''}${s.expected_return_pct}%</td>` +
|
||||
`<td class="stars">${'★'.repeat(s.stars)}${'☆'.repeat(5 - s.stars)}</td>` +
|
||||
`<td>${esc(s.reason)}</td></tr>`).join('')
|
||||
return `<div class="impact"><table>` +
|
||||
`<tr><th>标的</th><th>代码</th><th>建议购入区间</th><th>预计收益(5日,推测)</th><th>推荐指数</th><th>简易原因</th></tr>${rows}</table>` +
|
||||
`<div class="disclaimer">⚠ 模型推断仅供参考,预计收益为推测值,不构成投资建议;置信度 ${d.confidence ?? '-'}%</div></div>`
|
||||
var rows = d.stocks.map(function (s) {
|
||||
var er = Number(s.expected_return_pct) || 0
|
||||
var erCls = er > 0 ? 'up' : er < 0 ? 'down' : ''
|
||||
var erSign = er > 0 ? '+' : ''
|
||||
return '<tr><td>' + esc(s.name) + '</td><td>' + esc(s.code) + '</td>' +
|
||||
'<td>' + s.buy_zone[0] + ' ~ ' + s.buy_zone[1] + '</td>' +
|
||||
'<td class="' + erCls + '">' + erSign + er + '%</td>' +
|
||||
'<td class="stars">' + '★'.repeat(s.stars) + '☆'.repeat(5 - s.stars) + '</td>' +
|
||||
'<td>' + esc(s.reason) + '</td></tr>'
|
||||
}).join('')
|
||||
return '<div class="impact"><table>' +
|
||||
'<tr><th>标的</th><th>代码</th><th>建议购入区间</th><th>预计收益(5日,推测)</th><th>推荐指数</th><th>简易原因</th></tr>' +
|
||||
rows + '</table>' +
|
||||
'<div class="disclaimer">⚠ 模型推断仅供参考,预计收益为推测值,不构成投资建议;置信度 ' +
|
||||
(d.confidence == null ? '-' : d.confidence) + '%</div></div>'
|
||||
}
|
||||
|
||||
function render(e, fresh) {
|
||||
if (filter && e.kind !== filter) return
|
||||
const empty = feed.querySelector('.empty')
|
||||
if (empty) empty.remove()
|
||||
const li = document.createElement('li')
|
||||
var emptyLi = feed.querySelector('.empty')
|
||||
if (emptyLi) emptyLi.remove()
|
||||
var li = document.createElement('li')
|
||||
li.className = 'evt' + (fresh ? ' fresh' : '')
|
||||
const d = e.data || {}
|
||||
var d = e.data || {}
|
||||
if (e.kind === '事件影响分析') {
|
||||
const dirCls = { '利好': 'dir-bull', '利空': 'dir-bear', '中性': 'dir-neutral' }[d.direction] || ''
|
||||
const sectors = (d.sectors || []).map(s => `<span class="stars">${esc(s)}</span>`).join(' / ')
|
||||
li.innerHTML = `<span class="t">${e.ts}</span>` +
|
||||
`<span class="k ${e.kind}">事件影响</span>` +
|
||||
`<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>`
|
||||
var dirCls = d.direction === '利好' ? 'dir-bull' : d.direction === '利空' ? 'dir-bear' : 'dir-neutral'
|
||||
var sectors = (d.sectors || []).map(esc).join(' / ')
|
||||
li.innerHTML = '<span class="t">' + esc(e.ts) + '</span>' +
|
||||
'<span class="k">事件影响</span>' +
|
||||
'<span class="d"><b>' + esc(d.event_summary) + '</b><br>' +
|
||||
'方向:<span class="' + dirCls + '">' + esc(d.direction) + '</span> | 板块:' + sectors +
|
||||
' | 置信度 ' + (d.confidence == null ? '-' : 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>`
|
||||
var n = (d.list || []).length
|
||||
li.innerHTML = '<span class="t">' + esc(e.ts) + '</span><span class="k">量化推荐</span>' +
|
||||
'<span class="d">自适应模型推送 ' + n + ' 只推荐</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>`
|
||||
var txt = typeof d === 'object' ? JSON.stringify(d) : String(d)
|
||||
li.innerHTML = '<span class="t">' + esc(e.ts) + '</span><span class="k">' + esc(e.kind) +
|
||||
'</span><span class="d">' + esc(txt) + '</span>'
|
||||
}
|
||||
feed.prepend(li)
|
||||
while (feed.children.length > 300) feed.lastChild.remove()
|
||||
feed.insertBefore(li, feed.firstChild)
|
||||
while (feed.children.length > 300) feed.removeChild(feed.lastChild)
|
||||
}
|
||||
|
||||
function applyRecent(events) {
|
||||
[...events].reverse().forEach(e => render(e, false))
|
||||
function renderQuant(d) {
|
||||
var tb = document.querySelector('#qtable tbody')
|
||||
if (!tb || !d || !d.list) return
|
||||
tb.innerHTML = d.list.map(function (r) {
|
||||
return '<tr><td>' + esc(r.code) + '</td><td>' + esc(r.name) + '</td>' +
|
||||
'<td>' + r.price + '</td><td>' + r.score + '</td>' +
|
||||
'<td>' + r.buy_low + ' ~ ' + r.buy_high + '</td>' +
|
||||
'<td>' + (r.expected_return_pct == null ? '—' : r.expected_return_pct + '%') + '</td>' +
|
||||
'<td class="stars">' + '★'.repeat(r.stars) + '☆'.repeat(5 - r.stars) + '</td>' +
|
||||
'<td>' + esc(r.reason) + '</td></tr>'
|
||||
}).join('')
|
||||
}
|
||||
|
||||
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(() => {})
|
||||
fetch('/api/quant/recommendations').then(function (r) { return r.json() })
|
||||
.then(renderQuant).catch(function () {})
|
||||
fetch('/api/quant/weights').then(function (r) { return r.json() }).then(function (w) {
|
||||
var el = document.getElementById('qw')
|
||||
if (el) el.textContent = Object.keys(w || {}).map(function (k) {
|
||||
return k + '=' + Number(w[k]).toFixed(2)
|
||||
}).join(' ') || '—'
|
||||
}).catch(function () {})
|
||||
}
|
||||
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' }
|
||||
ws.onclose = () => {
|
||||
conn.textContent = '已断开,重连中…'; conn.className = 'conn off'
|
||||
|
||||
function setConn(connected) {
|
||||
connEl.textContent = connected ? '已连接' : '已断开,重连中…'
|
||||
connEl.className = 'conn ' + (connected ? 'on' : 'off')
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (ws && (ws.readyState === 0 || ws.readyState === 1)) return
|
||||
var proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
ws = new WebSocket(proto + '://' + location.host + '/ws')
|
||||
ws.onopen = function () {
|
||||
setConn(true)
|
||||
fetch('/api/recent').then(function (r) { return r.json() })
|
||||
.then(function (d) {
|
||||
(d.events || []).slice().reverse().forEach(function (e) { render(e, false) })
|
||||
}).catch(function () {})
|
||||
}
|
||||
ws.onclose = function () {
|
||||
setConn(false)
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
retryTimer = setTimeout(connect, 3000)
|
||||
}
|
||||
ws.onmessage = m => {
|
||||
try { render(JSON.parse(m.data), true) } catch (e) {}
|
||||
ws.onmessage = function (m) {
|
||||
try { render(JSON.parse(m.data), true) } catch (e) { /* 忽略单条解析失败 */ }
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('filters').addEventListener('click', ev => {
|
||||
if (ev.target.dataset.f === undefined) return
|
||||
filter = ev.target.dataset.f
|
||||
document.querySelectorAll('.filters button').forEach(b => b.classList.toggle('active', b === ev.target))
|
||||
fetch(`/api/recent`).then(r => r.json()).then(d => {
|
||||
feed.innerHTML = ''
|
||||
;[...(d.events || [])].reverse().forEach(e => render(e, false))
|
||||
})
|
||||
document.getElementById('filters').addEventListener('click', function (ev) {
|
||||
var f = ev.target.dataset ? ev.target.dataset.f : undefined
|
||||
if (f === undefined) return
|
||||
filter = f
|
||||
var btns = document.querySelectorAll('#filters button')
|
||||
for (var i = 0; i < btns.length; i++) {
|
||||
btns[i].className = btns[i].dataset.f === filter ? 'active' : ''
|
||||
}
|
||||
var rows = feed.children
|
||||
for (var j = rows.length - 1; j >= 0; j--) {
|
||||
var show = !filter || rows[j].dataset.kind === filter
|
||||
rows[j].style.display = show ? '' : 'none'
|
||||
}
|
||||
})
|
||||
|
||||
fetch('/api/stats').then(r => r.json()).then(s => buf.textContent = s.buffered)
|
||||
fetch('/api/recent').then(r => r.json()).then(d => {
|
||||
;[...(d.events || [])].reverse().forEach(e => render(e, false))
|
||||
function refreshData() {
|
||||
fetch('/api/recent').then(function (r) { return r.json() })
|
||||
.then(function (d) {
|
||||
var evs = d.events || []
|
||||
for (var i = evs.length - 1; i >= 0; i--) render(evs[i], false)
|
||||
}).catch(function () {})
|
||||
loadQuant()
|
||||
fetch('/api/stats').then(function (r) { return r.json() })
|
||||
.then(function (s) { bufEl.textContent = s.buffered }).catch(function () {})
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible') {
|
||||
connect()
|
||||
refreshData()
|
||||
}
|
||||
})
|
||||
setInterval(() => fetch('/api/stats').then(r => r.json()).then(s => buf.textContent = s.buffered), 10000)
|
||||
window.addEventListener('online', function () { location.reload() })
|
||||
|
||||
refreshData()
|
||||
setInterval(refreshData, 10000)
|
||||
loadQuant()
|
||||
setInterval(loadQuant, 60000)
|
||||
connect()
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+27
-4
@@ -22,6 +22,7 @@ from src.analysis.event_impact import EventImpactService # noqa: E402
|
||||
from src.quant.engine import QuantEngine # noqa: E402
|
||||
|
||||
PORT = int(os.environ.get('PORT', '8100'))
|
||||
SCORING_INTERVAL_S = int(os.environ.get('QUANT_SCORING_INTERVAL_S', '1800'))
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
WEB_DIR = Path(__file__).resolve().parent
|
||||
RECENT_MAX = 500
|
||||
@@ -69,13 +70,13 @@ async def collector_task(_app):
|
||||
quant.start()
|
||||
|
||||
def _quant_loops():
|
||||
time.sleep(5) # 启动即先跑一轮打分(K线可能不足,引擎会自行跳过)
|
||||
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)
|
||||
time.sleep(SCORING_INTERVAL_S) # 每轮之间强制间隔
|
||||
|
||||
threading.Thread(target=_quant_loops, daemon=True, name='quant-scoring').start()
|
||||
|
||||
@@ -106,8 +107,30 @@ async def recent_events(_request):
|
||||
|
||||
async def quant_recommendations(_request):
|
||||
q = _request.app['quant']
|
||||
return web.json_response({'ts': q.last_scored_at,
|
||||
'list': (q.last_ranking or [])[:50]})
|
||||
if q.last_ranking:
|
||||
return web.json_response({'ts': q.last_scored_at,
|
||||
'list': (q.last_ranking or [])[:50]})
|
||||
# 重启后内存为空:回退数据库最近一批推荐
|
||||
import sqlite3
|
||||
db = str(ROOT / 'data' / 'a_stock.db')
|
||||
try:
|
||||
conn = sqlite3.connect(db, check_same_thread=False)
|
||||
latest_ts = conn.execute(
|
||||
"SELECT ts FROM quant_recommendation ORDER BY id DESC LIMIT 1").fetchone()
|
||||
rows = []
|
||||
if latest_ts:
|
||||
rows = conn.execute(
|
||||
"SELECT code,name,price,score,stars,buy_low,buy_high,"
|
||||
"expected_return_pct,reason FROM quant_recommendation "
|
||||
"WHERE ts=? ORDER BY score DESC LIMIT 50", (latest_ts[0],)).fetchall()
|
||||
conn.close()
|
||||
except Exception:
|
||||
rows = []
|
||||
items = [{'code': r[0], 'name': r[1] or r[0], 'price': r[2], 'score': r[3],
|
||||
'stars': r[4], 'buy_low': r[5], 'buy_high': r[6],
|
||||
'expected_return_pct': r[7], 'reason': r[8]} for r in rows]
|
||||
return web.json_response({'ts': latest_ts[0] if rows else None, 'list': items,
|
||||
'source': 'db'})
|
||||
|
||||
|
||||
async def quant_weights(_request):
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""合成K线数据生成器:供测试使用(无网络依赖)"""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def gen_kline(n=120, base=10.0, trend=0.001, vol_pct=0.02, seed=42):
|
||||
"""
|
||||
生成合成日K线。
|
||||
trend: 每日平均漂移(如 0.001 = +0.1%/天)
|
||||
vol_pct: 每日随机波动幅度
|
||||
返回 DataFrame: [bar_time, open, high, low, close, volume, amount]
|
||||
"""
|
||||
rng = np.random.RandomState(seed)
|
||||
dates = pd.date_range('2025-01-01', periods=n, freq='B')
|
||||
close = np.zeros(n)
|
||||
close[0] = base
|
||||
for i in range(1, n):
|
||||
close[i] = close[i-1] * (1 + trend + rng.randn() * vol_pct)
|
||||
open_ = close * (1 + rng.randn(n) * vol_pct * 0.3)
|
||||
high = np.maximum(open_, close) * (1 + abs(rng.randn(n)) * vol_pct * 0.3)
|
||||
low = np.minimum(open_, close) * (1 - abs(rng.randn(n)) * vol_pct * 0.3)
|
||||
volume = np.abs(rng.randn(n)) * 1e6 + 5e5
|
||||
amount = volume * close
|
||||
return pd.DataFrame({
|
||||
'bar_time': dates.strftime('%Y-%m-%d'),
|
||||
'open': open_, 'high': high, 'low': low,
|
||||
'close': close, 'volume': volume,
|
||||
'amount': amount,
|
||||
})
|
||||
|
||||
|
||||
def gen_trending_up(n=80, base=10.0):
|
||||
"""持续上涨趋势K线"""
|
||||
return gen_kline(n=n, base=base, trend=0.008, vol_pct=0.01, seed=7)
|
||||
|
||||
|
||||
def gen_trending_down(n=80, base=50.0):
|
||||
"""持续下跌趋势K线"""
|
||||
return gen_kline(n=n, base=base, trend=-0.008, vol_pct=0.01, seed=7)
|
||||
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""异常检测规则单测"""
|
||||
import sys, os, warnings
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from tests.helpers import gen_kline
|
||||
|
||||
from src.analysis.anomaly_detect import detect_anomalies
|
||||
|
||||
|
||||
class TestAnomalyRules:
|
||||
|
||||
def test_volume_spike_detected(self):
|
||||
"""天量应被检出(放大到 15 倍均量确保触发)"""
|
||||
k = gen_kline(50, vol_pct=0.003, seed=1)
|
||||
base_vol = k['volume'].iloc[-20:-1].mean()
|
||||
k.loc[k.index[-1], 'volume'] = base_vol * 15 # 15 倍天量
|
||||
found = detect_anomalies('test', '测试股', k)
|
||||
types = [a['type'] for a in found]
|
||||
assert '天量' in types, f'天量未检出,检出: {types}'
|
||||
|
||||
def test_big_swing_detected(self):
|
||||
"""单日大涨 8% 应被检出"""
|
||||
k = gen_kline(40, vol_pct=0.005, seed=2)
|
||||
k.loc[k.index[-1], 'close'] = k['close'].iloc[-2] * 1.08
|
||||
found = detect_anomalies('test', '测试股', k)
|
||||
types = [a['type'] for a in found]
|
||||
assert '大幅波动' in types, f'大幅波动未检出,检出: {types}'
|
||||
|
||||
def test_no_anomaly_in_quiet_market(self):
|
||||
"""平静市场不应大量误报"""
|
||||
k = gen_kline(40, base=10, trend=0.0001, vol_pct=0.003, seed=3)
|
||||
found = detect_anomalies('test', '测试股', k)
|
||||
assert len(found) <= 1, f'平静市场不应大量报异常,实际 {len(found)} 条'
|
||||
|
||||
def test_output_format(self):
|
||||
"""输出包含必要字段"""
|
||||
k = gen_kline(40, vol_pct=0.02, seed=4)
|
||||
found = detect_anomalies('test', '测试股', k)
|
||||
for a in found:
|
||||
assert 'code' in a and 'type' in a and 'severity' in a and 'desc' in a
|
||||
|
||||
def test_severity_range(self):
|
||||
"""severity 在 1-5 范围内"""
|
||||
k = gen_kline(40, vol_pct=0.05, seed=5)
|
||||
found = detect_anomalies('test', '测试股', k)
|
||||
for a in found:
|
||||
assert 1 <= a['severity'] <= 5
|
||||
Binary file not shown.
@@ -0,0 +1,85 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""量化模型单测:因子计算 / 打分 / 权重微调 / 推荐原因"""
|
||||
import sys, os, warnings
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from tests.helpers import gen_kline, gen_trending_up, gen_trending_down
|
||||
from src.quant.model import (compute_factors, cross_section_score,
|
||||
factor_ic_series, WeightStore, state_bucket,
|
||||
FACTOR_NAMES)
|
||||
from src.quant.reason import build_reason
|
||||
|
||||
|
||||
class TestFactors:
|
||||
def test_momentum_positive_in_uptrend(self):
|
||||
k = gen_trending_up(60)
|
||||
f = compute_factors(k)
|
||||
assert f.get('mom_20', 0) > 0
|
||||
|
||||
def test_momentum_negative_in_downtrend(self):
|
||||
k = gen_trending_down(60)
|
||||
f = compute_factors(k)
|
||||
assert f.get('mom_20', 0) < 0
|
||||
|
||||
def test_all_factors_present(self):
|
||||
k = gen_kline(60)
|
||||
f = compute_factors(k)
|
||||
expected = {'mom_20', 'trend_ma20', 'ma_align', 'vol_ratio', 'rsi_inv', 'macd_hist', 'vola_inv'}
|
||||
assert expected.issubset(set(f.keys()))
|
||||
|
||||
def test_insufficient_data_returns_empty(self):
|
||||
k = gen_kline(10)
|
||||
assert compute_factors(k) == {}
|
||||
|
||||
|
||||
class TestCrossSectionScore:
|
||||
def test_ranking_order(self):
|
||||
rows = {
|
||||
'A': {'mom_20': 0.10, 'trend_ma20': 0.05},
|
||||
'B': {'mom_20': -0.05, 'trend_ma20': -0.02},
|
||||
'C': {'mom_20': 0.02, 'trend_ma20': 0.01},
|
||||
}
|
||||
weights = {'mom_20': 1.0, 'trend_ma20': 1.0}
|
||||
scored = cross_section_score(rows, weights)
|
||||
assert scored[0][0] == 'A'
|
||||
assert scored[-1][0] == 'B'
|
||||
|
||||
def test_single_factor_still_scored(self):
|
||||
"""优化后:只要有 ≥1 个有效因子即可参与打分"""
|
||||
rows = {
|
||||
'A': {'mom_20': 0.10},
|
||||
'B': {'mom_20': -0.05},
|
||||
}
|
||||
scored = cross_section_score(rows, {'mom_20': 1.0})
|
||||
assert len(scored) == 2
|
||||
assert scored[0][0] == 'A'
|
||||
|
||||
|
||||
class TestWeightStore:
|
||||
def test_load_defaults(self, tmp_path):
|
||||
ws = WeightStore(str(tmp_path / 'test.db'))
|
||||
w = ws.load()
|
||||
assert all(w.get(f, 0) > 0 for f in FACTOR_NAMES)
|
||||
|
||||
def test_save_and_reload(self, tmp_path):
|
||||
db = str(tmp_path / 'test.db')
|
||||
ws = WeightStore(db)
|
||||
ws.save({'mom_20': 1.5, 'trend_ma20': 0.5}, note='test')
|
||||
w = WeightStore(db).load()
|
||||
assert abs(w['mom_20'] - 1.5) < 0.01
|
||||
|
||||
|
||||
class TestReasonGeneration:
|
||||
def test_reason_contains_numbers(self):
|
||||
k = gen_kline(60)
|
||||
reason, _ = build_reason('test', k)
|
||||
assert any(c.isdigit() for c in reason)
|
||||
|
||||
def test_reason_not_empty(self):
|
||||
k = gen_kline(60)
|
||||
reason, _ = build_reason('test', k)
|
||||
assert len(reason) > 10
|
||||
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""自适应权重微调收敛性测试"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from src.quant.model import WeightStore, FACTOR_NAMES
|
||||
|
||||
|
||||
class TestWeightConvergence:
|
||||
def test_positive_ic_increases_weight(self, tmp_path):
|
||||
from src.quant.model import WeightStore
|
||||
ws = WeightStore(str(tmp_path / 't.db'))
|
||||
old = ws.load()['mom_20']
|
||||
ws.save({'mom_20': 1.5}, note='test')
|
||||
# IC 正 → 权重应偏向上调
|
||||
ic = 0.05 # 强正 IC
|
||||
w = ws.load()['mom_20']
|
||||
new_w = min(3.0, w * 1.05) # 模拟上调
|
||||
assert new_w > w, '正 IC 应推高权重'
|
||||
|
||||
def test_weight_bounds(self):
|
||||
"""权重不应突破 [0.1, 3.0]"""
|
||||
ws = WeightStore(str(__import__('pathlib').Path(__file__).parent / 'test_bounds.db'))
|
||||
ws.save({'mom_20': 5.0}, note='over')
|
||||
w = ws.load()['mom_20']
|
||||
# 保存后读取应正常(不做截断,截断在 adjust 时做)
|
||||
assert w > 0
|
||||
|
||||
def test_save_load_roundtrip(self, tmp_path):
|
||||
from src.quant.model import WeightStore
|
||||
db = str(tmp_path / 'rt.db')
|
||||
ws = WeightStore(db)
|
||||
ws.save({'mom_20': 1.23, 'trend_ma20': 0.56}, note='roundtrip')
|
||||
ws2 = WeightStore(db)
|
||||
w = ws2.load()
|
||||
assert abs(w['mom_20'] - 1.23) < 0.001
|
||||
assert abs(w['trend_ma20'] - 0.56) < 0.001
|
||||
Reference in New Issue
Block a user