auto-sync: 2026-05-11 08:34:38
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Dual Moving Average (双均线) Strategy
|
||||
|
||||
Classic trend-following strategy:
|
||||
- Golden Cross: Short MA crosses above Long MA → BUY
|
||||
- Death Cross: Short MA crosses below Long MA → SELL
|
||||
|
||||
Author: Zhang Fei
|
||||
Date: 2026-05-11
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trade:
|
||||
code: str
|
||||
entry_date: datetime
|
||||
exit_date: Optional[datetime]
|
||||
entry_price: float
|
||||
exit_price: Optional[float]
|
||||
direction: int
|
||||
shares: int
|
||||
entry_value: float
|
||||
exit_value: Optional[float]
|
||||
profit: Optional[float]
|
||||
profit_pct: Optional[float]
|
||||
hold_days: Optional[int]
|
||||
strategy: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestResult:
|
||||
strategy: str
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
initial_capital: float
|
||||
final_capital: float
|
||||
total_return: float
|
||||
annual_return: float
|
||||
max_drawdown: float
|
||||
sharpe_ratio: float
|
||||
win_rate: float
|
||||
total_trades: int
|
||||
win_trades: int
|
||||
loss_trades: int
|
||||
avg_profit_pct: float
|
||||
avg_win_pct: float
|
||||
avg_loss_pct: float
|
||||
trades: List[Trade]
|
||||
|
||||
|
||||
class DualMAStrategy:
|
||||
def __init__(self, short_period: int = 5, long_period: int = 20):
|
||||
self.short_period = short_period
|
||||
self.long_period = long_period
|
||||
self.name = f"Dual_MA_{short_period}_{long_period}"
|
||||
|
||||
def calculate_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
df = df.copy()
|
||||
df['ma_short'] = df['close'].rolling(window=self.short_period, min_periods=1).mean()
|
||||
df['ma_long'] = df['close'].rolling(window=self.long_period, min_periods=1).mean()
|
||||
return df
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
df = self.calculate_indicators(df)
|
||||
|
||||
# Generate cross signals
|
||||
df['ma_prev_short'] = df['ma_short'].shift(1)
|
||||
df['ma_prev_long'] = df['ma_long'].shift(1)
|
||||
|
||||
# Golden Cross: Short MA crosses above Long MA
|
||||
df['golden_cross'] = (
|
||||
(df['ma_prev_short'] <= df['ma_prev_long']) &
|
||||
(df['ma_short'] > df['ma_long'])
|
||||
).astype(int)
|
||||
|
||||
# Death Cross: Short MA crosses below Long MA
|
||||
df['death_cross'] = (
|
||||
(df['ma_prev_short'] >= df['ma_prev_long']) &
|
||||
(df['ma_short'] < df['ma_long'])
|
||||
).astype(int)
|
||||
|
||||
df['signal'] = 0
|
||||
df.loc[df['golden_cross'] == 1, 'signal'] = 1
|
||||
df.loc[df['death_cross'] == 1, 'signal'] = -1
|
||||
|
||||
return df
|
||||
|
||||
|
||||
class DualMABacktester:
|
||||
def __init__(self, initial_capital: float = 100000.0,
|
||||
short_period: int = 5,
|
||||
long_period: int = 20,
|
||||
commission: float = 0.0003,
|
||||
slipage: float = 0.001):
|
||||
self.initial_capital = initial_capital
|
||||
self.commission = commission
|
||||
self.slipage = slipage
|
||||
self.strategy = DualMAStrategy(short_period, long_period)
|
||||
|
||||
def run_single_stock(self, df: pd.DataFrame, code: str) -> BacktestResult:
|
||||
df = df.sort_values('date').reset_index(drop=True)
|
||||
df = self.strategy.generate_signals(df)
|
||||
|
||||
capital = self.initial_capital
|
||||
position = 0
|
||||
trades = []
|
||||
current_trade = None
|
||||
|
||||
for i, row in df.iterrows():
|
||||
# Entry
|
||||
if row['signal'] == 1 and position == 0:
|
||||
entry_price = row['close'] * (1 + self.slipage)
|
||||
shares = int(capital / entry_price)
|
||||
if shares > 0:
|
||||
entry_value = shares * entry_price
|
||||
commission_fee = entry_value * self.commission
|
||||
capital -= (entry_value + commission_fee)
|
||||
position = shares
|
||||
current_trade = Trade(
|
||||
code=code,
|
||||
entry_date=row['date'],
|
||||
exit_date=None,
|
||||
entry_price=entry_price,
|
||||
exit_price=None,
|
||||
direction=1,
|
||||
shares=shares,
|
||||
entry_value=entry_value,
|
||||
exit_value=None,
|
||||
profit=None,
|
||||
profit_pct=None,
|
||||
hold_days=None,
|
||||
strategy=self.strategy.name
|
||||
)
|
||||
|
||||
# Exit
|
||||
elif row['signal'] == -1 and position > 0 and current_trade is not None:
|
||||
exit_price = row['close'] * (1 - self.slipage)
|
||||
exit_value = position * exit_price
|
||||
commission_fee = exit_value * self.commission
|
||||
capital += (exit_value - commission_fee)
|
||||
|
||||
current_trade.exit_date = row['date']
|
||||
current_trade.exit_price = exit_price
|
||||
current_trade.exit_value = exit_value
|
||||
current_trade.profit = exit_value - current_trade.entry_value - commission_fee - (current_trade.entry_value * self.commission)
|
||||
current_trade.profit_pct = (exit_price - current_trade.entry_price) / current_trade.entry_price
|
||||
current_trade.hold_days = (row['date'] - current_trade.entry_date).days
|
||||
|
||||
trades.append(current_trade)
|
||||
position = 0
|
||||
current_trade = None
|
||||
|
||||
# Close remaining position at end
|
||||
if position > 0 and current_trade is not None:
|
||||
last_row = df.iloc[-1]
|
||||
exit_price = last_row['close']
|
||||
exit_value = position * exit_price
|
||||
commission_fee = exit_value * self.commission
|
||||
capital += (exit_value - commission_fee)
|
||||
|
||||
current_trade.exit_date = last_row['date']
|
||||
current_trade.exit_price = exit_price
|
||||
current_trade.exit_value = exit_value
|
||||
current_trade.profit = exit_value - current_trade.entry_value - commission_fee - (current_trade.entry_value * self.commission)
|
||||
current_trade.profit_pct = (exit_price - current_trade.entry_price) / current_trade.entry_price
|
||||
current_trade.hold_days = (last_row['date'] - current_trade.entry_date).days
|
||||
|
||||
trades.append(current_trade)
|
||||
|
||||
return self._calculate_metrics(trades, df)
|
||||
|
||||
def _calculate_metrics(self, trades: List[Trade], df: pd.DataFrame) -> BacktestResult:
|
||||
if len(trades) == 0:
|
||||
return BacktestResult(
|
||||
strategy=self.strategy.name,
|
||||
start_date=df['date'].min(),
|
||||
end_date=df['date'].max(),
|
||||
initial_capital=self.initial_capital,
|
||||
final_capital=self.initial_capital,
|
||||
total_return=0.0,
|
||||
annual_return=0.0,
|
||||
max_drawdown=0.0,
|
||||
sharpe_ratio=0.0,
|
||||
win_rate=0.0,
|
||||
total_trades=0,
|
||||
win_trades=0,
|
||||
loss_trades=0,
|
||||
avg_profit_pct=0.0,
|
||||
avg_win_pct=0.0,
|
||||
avg_loss_pct=0.0,
|
||||
trades=[]
|
||||
)
|
||||
|
||||
final_capital = self.initial_capital + sum(t.profit or 0 for t in trades)
|
||||
total_return = (final_capital - self.initial_capital) / self.initial_capital
|
||||
|
||||
days = (df['date'].max() - df['date'].min()).days
|
||||
annual_return = (1 + total_return) ** (365 / days) - 1 if days > 0 else 0
|
||||
|
||||
win_trades = [t for t in trades if (t.profit or 0) > 0]
|
||||
loss_trades = [t for t in trades if (t.profit or 0) <= 0]
|
||||
|
||||
win_rate = len(win_trades) / len(trades) if trades else 0
|
||||
avg_profit_pct = np.mean([t.profit_pct or 0 for t in trades])
|
||||
avg_win_pct = np.mean([t.profit_pct or 0 for t in win_trades]) if win_trades else 0
|
||||
avg_loss_pct = np.mean([t.profit_pct or 0 for t in loss_trades]) if loss_trades else 0
|
||||
|
||||
# Simple max drawdown calculation
|
||||
cumulative = self.initial_capital
|
||||
max_cumulative = cumulative
|
||||
max_drawdown = 0.0
|
||||
|
||||
for trade in trades:
|
||||
cumulative += trade.profit or 0
|
||||
if cumulative > max_cumulative:
|
||||
max_cumulative = cumulative
|
||||
drawdown = (max_cumulative - cumulative) / max_cumulative
|
||||
if drawdown > max_drawdown:
|
||||
max_drawdown = drawdown
|
||||
|
||||
# Sharpe ratio (simplified)
|
||||
daily_returns = [t.profit_pct / t.hold_days for t in trades if t.hold_days and t.hold_days > 0]
|
||||
sharpe_ratio = np.sqrt(252) * np.mean(daily_returns) / np.std(daily_returns) if len(daily_returns) > 1 and np.std(daily_returns) > 0 else 0
|
||||
|
||||
return BacktestResult(
|
||||
strategy=self.strategy.name,
|
||||
start_date=df['date'].min(),
|
||||
end_date=df['date'].max(),
|
||||
initial_capital=self.initial_capital,
|
||||
final_capital=final_capital,
|
||||
total_return=total_return,
|
||||
annual_return=annual_return,
|
||||
max_drawdown=max_drawdown,
|
||||
sharpe_ratio=sharpe_ratio,
|
||||
win_rate=win_rate,
|
||||
total_trades=len(trades),
|
||||
win_trades=len(win_trades),
|
||||
loss_trades=len(loss_trades),
|
||||
avg_profit_pct=avg_profit_pct,
|
||||
avg_win_pct=avg_win_pct,
|
||||
avg_loss_pct=avg_loss_pct,
|
||||
trades=trades
|
||||
)
|
||||
|
||||
|
||||
def load_sample_data(code: str = '510300.SH',
|
||||
start_date: str = '2023-01-01',
|
||||
end_date: str = '2024-12-31') -> pd.DataFrame:
|
||||
"""Load sample data for testing"""
|
||||
np.random.seed(42)
|
||||
dates = pd.date_range(start=start_date, end=end_date, freq='B')
|
||||
n = len(dates)
|
||||
|
||||
# Generate synthetic price data with trend
|
||||
base_price = 4.0
|
||||
returns = np.random.normal(0.0005, 0.015, n)
|
||||
prices = base_price * np.cumprod(1 + returns)
|
||||
|
||||
df = pd.DataFrame({
|
||||
'date': dates,
|
||||
'open': prices * (1 + np.random.normal(0, 0.002, n)),
|
||||
'high': prices * (1 + np.random.normal(0.005, 0.003, n)),
|
||||
'low': prices * (1 - np.random.normal(0.005, 0.003, n)),
|
||||
'close': prices,
|
||||
'volume': np.random.randint(1000000, 10000000, n)
|
||||
})
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def main():
|
||||
# Sample backtest
|
||||
logger.info("Starting Dual MA Strategy Backtest...")
|
||||
|
||||
df = load_sample_data()
|
||||
logger.info(f"Loaded {len(df)} days of data")
|
||||
|
||||
backtester = DualMABacktester(
|
||||
initial_capital=100000.0,
|
||||
short_period=5,
|
||||
long_period=20
|
||||
)
|
||||
|
||||
result = backtester.run_single_stock(df, '510300.SH')
|
||||
|
||||
logger.info(f"Strategy: {result.strategy}")
|
||||
logger.info(f"Period: {result.start_date.date()} to {result.end_date.date()}")
|
||||
logger.info(f"Initial Capital: {result.initial_capital:.2f}")
|
||||
logger.info(f"Final Capital: {result.final_capital:.2f}")
|
||||
logger.info(f"Total Return: {result.total_return*100:.2f}%")
|
||||
logger.info(f"Annual Return: {result.annual_return*100:.2f}%")
|
||||
logger.info(f"Max Drawdown: {result.max_drawdown*100:.2f}%")
|
||||
logger.info(f"Sharpe Ratio: {result.sharpe_ratio:.2f}")
|
||||
logger.info(f"Win Rate: {result.win_rate*100:.2f}%")
|
||||
logger.info(f"Total Trades: {result.total_trades}")
|
||||
logger.info(f"Win/Loss: {result.win_trades}/{result.loss_trades}")
|
||||
logger.info(f"Avg Profit %: {result.avg_profit_pct*100:.2f}%")
|
||||
|
||||
# Print first 5 trades
|
||||
logger.info("\nFirst 5 Trades:")
|
||||
for i, trade in enumerate(result.trades[:5]):
|
||||
logger.info(f" {i+1}. {trade.entry_date.date()} -> {trade.exit_date.date()}: "
|
||||
f"{trade.profit_pct*100:+.2f}% (hold {trade.hold_days} days)")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user