feat: 策略研究Tab全流程回测框架 + API端点 + 前端渲染 + 盘中大跌分析日志

This commit is contained in:
hmo
2026-07-28 22:53:29 +08:00
parent d3298dbf83
commit 63b53f5c56
8 changed files with 7669 additions and 1738 deletions
+488
View File
@@ -0,0 +1,488 @@
#!/usr/bin/env python3
"""MoFin 回测框架 v2 — 从 stock_daily 原始行情计算全部指标,模拟全流程"""
import sqlite3, json, math
from datetime import datetime, timedelta
from collections import defaultdict
DB_PATH = "/home/hmo/MoFin/data/mofin.db"
# ── 技术指标计算(纯 Python,不依赖第三方库)──
def calc_ma(series, n):
"""简单移动平均"""
result = []
for i in range(len(series)):
if i < n - 1:
result.append(None)
else:
result.append(sum(series[i-n+1:i+1]) / n)
return result
def calc_ema(series, n):
"""指数移动平均"""
result = []
k = 2 / (n + 1)
for i in range(len(series)):
if i == 0:
result.append(series[i])
else:
result.append(series[i] * k + result[-1] * (1 - k))
return result
def calc_rsi(series, n=14):
"""RSI"""
deltas = [series[i] - series[i-1] for i in range(1, len(series))]
gains = [d if d > 0 else 0 for d in deltas]
losses = [-d if d < 0 else 0 for d in deltas]
result = [None] * (n + 1)
avg_gain = sum(gains[:n]) / n
avg_loss = sum(losses[:n]) / n
if avg_loss == 0:
result.append(100)
else:
rs = avg_gain / avg_loss
result.append(100 - 100 / (1 + rs))
for i in range(n, len(gains)):
avg_gain = (avg_gain * (n-1) + gains[i]) / n
avg_loss = (avg_loss * (n-1) + losses[i]) / n
if avg_loss == 0:
result.append(100)
else:
rs = avg_gain / avg_loss
result.append(100 - 100 / (1 + rs))
# Pad front to match original length
while len(result) < len(series):
result.insert(0, None)
return result[:len(series)]
def calc_macd(series, fast=12, slow=26, signal=9):
"""MACD: 返回 macd_line, signal_line, histogram"""
ema_fast = calc_ema(series, fast)
ema_slow = calc_ema(series, slow)
macd_line = [ema_fast[i] - ema_slow[i] if ema_fast[i] is not None and ema_slow[i] is not None else None for i in range(len(series))]
# Recompute signal line from macd_line (only non-None values)
macd_clean = [v for v in macd_line if v is not None]
if not macd_clean:
return [None]*len(series), [None]*len(series), [None]*len(series)
signal_raw = calc_ema(macd_clean, signal)
signal_line = [None] * (len(series) - len(macd_clean)) + signal_raw
histogram = [macd_line[i] - signal_line[i] if macd_line[i] is not None and signal_line[i] is not None else None for i in range(len(series))]
return macd_line, signal_line, histogram
def calc_tr(highs, lows, closes):
"""True Range"""
tr = [highs[0] - lows[0]]
for i in range(1, len(highs)):
hl = highs[i] - lows[i]
hc = abs(highs[i] - closes[i-1])
lc = abs(lows[i] - closes[i-1])
tr.append(max(hl, hc, lc))
return tr
def calc_atr(highs, lows, closes, n=14):
"""ATR"""
tr = calc_tr(highs, lows, closes)
return calc_ema(tr, n)
def calc_trend_strength(highs, lows, closes, n=14):
"""简易趋势强度(替代 ADX: (DI+ - DI-) / (DI+ + DI-) 归一化"""
tr = calc_tr(highs, lows, closes)
up = [highs[i] - highs[i-1] for i in range(1, len(highs))]
down = [lows[i-1] - lows[i] for i in range(1, len(lows))]
# Directional indicators (raw)
di_plus_raw = [0.0] * len(up)
di_minus_raw = [0.0] * len(up)
for i in range(len(up)):
if up[i] > down[i] and up[i] > 0:
di_plus_raw[i] = up[i]
if down[i] > up[i] and down[i] > 0:
di_minus_raw[i] = down[i]
# Normalize by TR
tr_val = tr[i+1] if (i+1) < len(tr) else tr[-1]
if tr_val > 0:
di_plus_raw[i] = di_plus_raw[i] / tr_val * 100
di_minus_raw[i] = di_minus_raw[i] / tr_val * 100
# SMA of DMI
result = []
for i in range(len(di_plus_raw)):
if i < n - 1:
result.append(None)
else:
avg_plus = sum(di_plus_raw[i-n+1:i+1]) / n
avg_minus = sum(di_minus_raw[i-n+1:i+1]) / n
if avg_plus + avg_minus > 0:
dx = abs(avg_plus - avg_minus) / (avg_plus + avg_minus) * 100
else:
dx = 0
result.append(dx)
# Pad front to match original highs length
return [None] * (len(highs) - len(result)) + result
def calc_obv(closes, volumes):
"""OBV"""
obv = [volumes[0]]
for i in range(1, len(closes)):
if closes[i] > closes[i-1]:
obv.append(obv[-1] + volumes[i])
elif closes[i] < closes[i-1]:
obv.append(obv[-1] - volumes[i])
else:
obv.append(obv[-1])
return obv
def calc_roc(series, n=10):
"""Rate of Change"""
result = []
for i in range(len(series)):
if i < n:
result.append(None)
else:
result.append((series[i] - series[i-n]) / series[i-n] * 100 if series[i-n] != 0 else 0)
return result
def prepare_bars(code, start_date, end_date):
"""获取原始数据并计算全部指标"""
conn = sqlite3.connect(DB_PATH)
rows = conn.execute("""
SELECT date, open, close, high, low, volume, amount
FROM stock_daily WHERE code=? AND date>=? AND date<=?
ORDER BY date
""", (code, start_date, end_date)).fetchall()
conn.close()
if not rows or len(rows) < 30:
return None
dates = [r[0] for r in rows]
opens = [r[1] for r in rows]
closes = [r[2] for r in rows]
highs = [r[3] for r in rows]
lows = [r[4] for r in rows]
volumes = [r[5] for r in rows]
# 计算全部指标
ma5 = calc_ma(closes, 5)
ma10 = calc_ma(closes, 10)
ma20 = calc_ma(closes, 20)
ma60 = calc_ma(closes, 60)
rsi = calc_rsi(closes)
macd_line, signal_line, macd_hist = calc_macd(closes)
atr = calc_atr(highs, lows, closes)
trend_strength = calc_trend_strength(highs, lows, closes)
obv = calc_obv(closes, volumes)
roc = calc_roc(closes)
bars = []
for i in range(len(dates)):
bars.append({
'date': dates[i],
'open': opens[i],
'close': closes[i],
'high': highs[i],
'low': lows[i],
'volume': volumes[i],
'ma5': ma5[i] if i < len(ma5) else None,
'ma10': ma10[i] if i < len(ma10) else None,
'ma20': ma20[i] if i < len(ma20) else None,
'ma60': ma60[i] if i < len(ma60) else None,
'rsi': rsi[i] if i < len(rsi) else None,
'macd': macd_line[i] if i < len(macd_line) else None,
'macd_signal': signal_line[i] if i < len(signal_line) else None,
'macd_hist': macd_hist[i] if i < len(macd_hist) else None,
'atr': atr[i] if i < len(atr) else None,
'adx': trend_strength[i] if i < len(trend_strength) else None,
'obv': obv[i] if i < len(obv) else None,
'roc': roc[i] if i < len(roc) else None,
})
return bars
def compute_single_score(bars):
"""对单个股票计算多因子评分(与 MoFin 现有一致)"""
if not bars or len(bars) < 15:
return None
last = bars[-1]
prev5 = bars[-5] if len(bars) >= 5 else bars[-2] if len(bars) >= 2 else last
prev10 = bars[-10] if len(bars) >= 10 else bars[0]
def v(val): return val if val is not None else 0
score = {'trend': 0, 'momentum': 0, 'volume': 0, 'volatility': 0, 'risk': 0}
# 趋势分 (0-25)
ts = 0
adx = v(last.get('adx'))
if adx > 25: ts += 10
elif adx > 20: ts += 5
mh = v(last.get('macd_hist'))
pmh = v(prev5.get('macd_hist'))
if mh > 0 and mh > pmh: ts += 10
elif mh > 0: ts += 5
elif mh < 0 and mh < pmh: ts -= 5
ma5 = v(last.get('ma5'))
ma10 = v(last.get('ma10'))
ma20 = v(last.get('ma20'))
if ma5 > ma10 > ma20 and ma5 > 0 and ma10 > 0: ts += 5
elif ma5 < ma10 < ma20 and ma5 > 0 and ma10 > 0: ts -= 5
score['trend'] = max(0, min(25, ts))
# 动量分 (0-20)
ms = 0
roc = v(last.get('roc'))
if roc > 5: ms += 5
elif roc > 2: ms += 2
elif roc < -5: ms -= 5
elif roc < -2: ms -= 2
rsi = v(last.get('rsi'))
if 30 <= rsi <= 70: ms += 5
if 50 < rsi <= 65: ms += 3
elif rsi > 70: ms -= 3
elif rsi < 30: ms -= 3
close = v(last.get('close'))
if ma20 > 0:
pct = (close - ma20) / ma20 * 100
if -3 <= pct <= 5: ms += 7
elif pct < -10: ms -= 3
score['momentum'] = max(0, min(20, ms))
# 量能分 (0-20)
vs = 0
obv_cur = v(last.get('obv'))
obv_prev = v(prev5.get('obv'))
vol_cur = v(last.get('volume'))
vol_prev = v(prev5.get('volume'))
if obv_cur > obv_prev and vol_cur > 0 and vol_prev > 0:
vr = vol_cur / vol_prev
if vr > 1.5: vs += 10
elif vr > 1.2: vs += 5
if obv_cur > obv_prev: vs += 5
if vol_prev > 0 and vol_cur / vol_prev < 0.5: vs -= 5
score['volume'] = max(0, min(20, vs))
# 波动率分 (0-15)
vls = 5
atr = v(last.get('atr'))
if close > 0 and atr > 0:
atr_pct = atr / close * 100
if 1.5 <= atr_pct <= 3.5: vls += 5
elif atr_pct < 1: vls += 3
elif atr_pct > 5: vls -= 5
if len(bars) >= 20:
cls = [v(b.get('close')) for b in bars[-20:]]
if min(cls) > 0:
vol_range = (max(cls) - min(cls)) / min(cls) * 100
if vol_range < 10: vls += 5
elif vol_range > 30: vls -= 5
score['volatility'] = max(0, min(15, vls))
# 风险分 (0-20)
rs = 10
if len(bars) >= 10:
low5 = min(bars[-5:], key=lambda x: v(x.get('low')))
low10 = min(bars[-10:-5], key=lambda x: v(x.get('low'))) if len(bars) >= 10 else low5
if v(low5.get('close')) < v(low10.get('close')) and v(low5.get('macd_hist')) > v(low10.get('macd_hist')):
rs += 5
if v(low5.get('close')) > v(low10.get('close')) and v(low5.get('macd_hist')) < v(low10.get('macd_hist')):
rs -= 5
if ma20 > 0:
dist = (close - ma20) / ma20 * 100
if dist < 2: rs += 5
elif dist > 15: rs -= 5
score['risk'] = max(0, min(20, rs))
total = sum(score.values())
return total, score
def compute_kelly(total_score, target_pct, stop_pct):
"""半 Kelly 仓位"""
p = min(0.6, total_score / 100)
if stop_pct <= 0:
return 0
b = target_pct / stop_pct
if b <= 1:
return 0
kelly = (p * b - (1 - p)) / b
return max(0, min(0.5, kelly)) * 0.5 # 半 Kelly
def run_strategy_research(start_date, end_date, capital=1000000):
"""主回测入口"""
conn = sqlite3.connect(DB_PATH)
# 1. 获取所有有历史数据的股票
stocks = conn.execute("""
SELECT DISTINCT sd.code, COALESCE(s.name, sd.code) as name
FROM stock_daily sd
LEFT JOIN stocks s ON sd.code = s.code
WHERE sd.date>=? AND sd.date<=?
""", (start_date, end_date)).fetchall()
results = {
'period': f"{start_date} ~ {end_date}",
'capital': capital,
'total_stocks_screened': len(stocks),
'scored_stocks': 0,
'buy_signals': 0,
'trades': [],
'summary': {}
}
trades = []
code_names = {}
eval_dates = [] # collect evaluation dates
for code, name in stocks:
code_names[code] = name
bars = prepare_bars(code, start_date, end_date)
if not bars or len(bars) < 20:
continue
# 2. 滚动评估:每周评分一次
window_start = 20 # need at least 20 bars for indicators
while window_start < len(bars):
window = bars[:window_start]
score_result = compute_single_score(window)
if score_result is None:
window_start += 5
continue
total_score, score_components = score_result
results['scored_stocks'] += 1
last = window[-1]
close = v_close = last.get('close', 0) or 0
ma20 = last.get('ma20', 0) or 0
# 3. 买入条件:总评分 >= 45 且 momentum >= 8
buy_signal = total_score >= 45 and score_components['momentum'] >= 8
if buy_signal:
results['buy_signals'] += 1
entry_price = close
atr_val = last.get('atr', 0) or 0
if atr_val > 0:
target_price = entry_price * 1.10
stop_price = entry_price - atr_val * 2.0
else:
target_price = entry_price * 1.10
stop_price = entry_price * 0.93
kelly = compute_kelly(total_score, 0.10, (entry_price - stop_price) / entry_price)
# 4. 模拟未来走势
future = bars[window_start:min(window_start+20, len(bars))]
exit_price = None
exit_reason = None
max_high = entry_price
min_low = entry_price
for fb in future:
fb_high = fb.get('high', 0) or 0
fb_low = fb.get('low', 0) or 0
fb_close = fb.get('close', 0) or 0
max_high = max(max_high, fb_high)
min_low = min(min_low, fb_low)
if fb_high >= target_price:
exit_price = target_price
exit_reason = 'target'
break
elif fb_low <= stop_price:
exit_price = fb_close
exit_reason = 'stop'
break
if exit_price is None:
if future:
exit_price = future[-1].get('close', entry_price)
else:
exit_price = entry_price
exit_reason = 'keep'
hold_days = 0
if exit_reason and exit_reason != 'keep' and future:
for idx, fb in enumerate(future):
fb_high = fb.get('high', 0) or 0
fb_low = fb.get('low', 0) or 0
if (exit_reason == 'target' and fb_high >= target_price) or \
(exit_reason == 'stop' and fb_low <= stop_price):
hold_days = idx + 1
break
elif exit_reason == 'keep':
hold_days = len(future)
profit_pct = (exit_price - entry_price) / entry_price * 100 if entry_price > 0 else 0
trades.append({
'code': code,
'name': name,
'entry_date': last.get('date'),
'entry_price': round(entry_price, 2),
'exit_price': round(exit_price, 2),
'profit_pct': round(profit_pct, 2),
'exit_reason': exit_reason,
'hold_days': hold_days,
'score': total_score,
'kelly': round(kelly, 3),
'stop_loss': round(stop_price, 2),
'target': round(target_price, 2),
'max_high': round(max_high, 2),
'min_low': round(min_low, 2)
})
window_start += 5 # 每 5 个交易日评估一次
conn.close()
# 5. 汇总
if trades:
profits = [t['profit_pct'] for t in trades]
wins = [t for t in trades if t['profit_pct'] > 0]
losses = [t for t in trades if t['profit_pct'] <= 0]
win_rate = len(wins) / len(trades) * 100 if trades else 0
avg_profit = sum(profits) / len(profits) if profits else 0
avg_win = sum(t['profit_pct'] for t in wins) / len(wins) if wins else 0
avg_loss = sum(t['profit_pct'] for t in losses) / len(losses) if losses else 0
mean_ret = avg_profit / 100
if len(profits) > 1:
variance = sum((p/100 - mean_ret)**2 for p in profits) / (len(profits) - 1)
std_ret = math.sqrt(variance)
else:
std_ret = 0
sharpe = mean_ret / std_ret * math.sqrt(252) if std_ret > 0 else 0
# 最大回撤:用实际资本曲线计算
capital_curve = [capital]
for t in trades:
new_cap = capital_curve[-1] * (1 + t['profit_pct'] / 100)
capital_curve.append(new_cap)
peak_cap = capital
max_dd = 0
for c in capital_curve:
peak_cap = max(peak_cap, c)
dd = (peak_cap - c) / peak_cap * 100
max_dd = max(max_dd, dd)
results['trades'] = sorted(trades, key=lambda x: abs(x['profit_pct']), reverse=True)[:50]
results['summary'] = {
'total_trades': len(trades),
'win_rate': round(win_rate, 1),
'avg_profit_pct': round(avg_profit, 2),
'avg_win_pct': round(avg_win, 2),
'avg_loss_pct': round(avg_loss, 2),
'sharpe_ratio': round(sharpe, 2),
'max_drawdown_pct': round(max_dd, 2),
'profit_factor': round(abs(avg_win / avg_loss), 2) if avg_loss != 0 else float('inf'),
'wins': len(wins),
'losses': len(losses),
'capital_end': round(capital * (1 + avg_profit/100), 2)
}
return results
if __name__ == '__main__':
end = datetime.now().strftime('%Y-%m-%d')
start = (datetime.now() - timedelta(days=180)).strftime('%Y-%m-%d')
r = run_strategy_research(start, end)
print(json.dumps(r, indent=2, ensure_ascii=False))