Files
MoFin/backtest_framework.py
hmo b676d85c41 feat: v_mr 均值回复策略 v2——10y全市场验证通过(21690笔/53.6%WR/avg+3.34%)
- 独立均值回复引擎: MA60下>10%+RSI≤42+60日跌>15%+小盘≤1500万+RSI止跌≥2
- 出场: 止盈+18%/止损-8%/25天, 移除MA20反弹截断(v1毒药)
- backtest_framework: prepare_bars 补 amount 字段(千元)
- 大盘上下文: sh000001 2016-2020 数据从 sector_index_daily 回填 stock_daily
- 验证: sideways(ADX)过滤有害(206.6%→147.9%), mkt_mode=any 最优
- docs/v_mr_strategy.md 完整策略文档
2026-08-02 15:02:10 +08:00

515 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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]
amounts = [r[6] if len(r) > 6 else None 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)):
# ── 枢轴S/R(与实盘 technical_analysis.calc_support_resistance 同算法)──
piv = {}
if i >= 1:
h, l, c = highs[i], lows[i], closes[i]
yc = closes[i-1]
# 多日波幅(近20日)
win = bars[max(0, i-19):i] if i > 0 else []
multi_high = max([h] + [x.get('high', 0) or 0 for x in win])
multi_low = min([l] + [x.get('low', 1e9) or 1e9 for x in win])
eff_range = max(h - l, multi_high - multi_low, c * 0.05)
tp = (c - multi_low) / (multi_high - multi_low) if multi_high > multi_low else 0.5
if tp > 0.8 or tp < 0.2:
eff_range = max(eff_range, c * 0.08)
pp = (h + l + c) / 3
s1 = 2 * pp - h
s2 = pp - eff_range
r1 = 2 * pp - l
r2 = pp + eff_range
if yc < s1: s1 = yc
if yc > r1: r1 = yc
piv = {'pivot': pp, 'weak_support': s1, 'strong_support': s2,
'weak_resist': r1, 'strong_resist': r2}
bars.append({
'date': dates[i],
'open': opens[i],
'close': closes[i],
'high': highs[i],
'low': lows[i],
'volume': volumes[i],
'amount': amounts[i] if i < len(amounts) else None,
'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,
**piv,
})
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))