feat: 策略研究Tab全流程回测框架 + API端点 + 前端渲染 + 盘中大跌分析日志
This commit is contained in:
@@ -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))
|
||||
Reference in New Issue
Block a user