-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
349 lines (296 loc) · 12.8 KB
/
Copy pathmain.py
File metadata and controls
349 lines (296 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
"""
CLI entry point that orchestrates the full backtesting pipeline:
1. Download/load historical data via yfinance
2. Generate SMA crossover trading signals
3. Execute virtual portfolio with commission tracking
4. Calculate performance metrics and benchmarks
5. Optionally optimize parameters via grid-search
Usage:
python main.py # Run single backtest
python main.py # Run grid-search optimization (see GRID_SEARCH_ENABLED)
Configuration:
Edit the constants below to customize backtest parameters.
"""
import logging
from typing import Optional
from src.data_loader import download_historical_data, load_csv_data
from src.metrics import calculate_max_drawdown, calculate_sharpe_ratio
from src.portfolio import Portfolio
from src.strategy import SMAStrategy
from src.optimizer import run_grid_search
from src.cpp_optimizer import is_cpp_available
from src.walk_forward import WalkForwardAnalyzer, print_wfa_summary
# ============================================================================
# CONFIGURATION: Modify these settings to customize the backtest
# ============================================================================
# Data & Backtest Parameters
TICKER = "DAX" # Stock ticker symbol (e.g., "AAPL", "^GSPC", "BAS.DE")
START_DATE = "2000-01-01" # Backtest start date (YYYY-MM-DD, inclusive)
END_DATE = "2027-01-01" # Backtest end date (YYYY-MM-DD, exclusive)
CSV_PATH = f"data/{TICKER}.csv" # Path to cache downloaded data
INITIAL_CASH = 10_000.0 # Starting capital (USD)
COMMISSION = 0.001 # Transaction fee as decimal (0.1% = 0.001)
SLIPPAGE = 0.002
SPREAD_MIN = 0.001
SPREAD_MAX = 0.002
SHORT_WINDOW = 20 # Fast SMA period (days)
LONG_WINDOW = 50 # Slow SMA period (days)
# Grid Search Optimization Parameters
# Set GRID_SEARCH_ENABLED = True to find optimal SMA parameters
# Otherwise, runs single backtest with SHORT_WINDOW & LONG_WINDOW above
GRID_SEARCH_ENABLED = True
# Parameter ranges for grid search (conservative range for better generalization)
GRID_SEARCH_FAST_MIN = 15 # Short SMA minimum
GRID_SEARCH_FAST_MAX = 35 # Short SMA maximum
GRID_SEARCH_SLOW_MIN = 40 # Long SMA minimum
GRID_SEARCH_SLOW_MAX = 100 # Long SMA maximum
# Total combinations: ~1200 (20 × 60)
# Walk-Forward Analysis Parameters
# Set WALK_FORWARD_ENABLED = True to use WFA for out-of-sample validation
# Otherwise, runs standard Grid Search on full historical data (in-sample only)
WALK_FORWARD_ENABLED = True
# Walk-Forward window configuration (trading days)
WFA_IS_WINDOW_DAYS = 504 # In-sample period (~2 trading years)
WFA_OOS_WINDOW_DAYS = 252 # Out-of-sample period (~1 trading year)
WFA_STEP_SIZE_DAYS = 252 # Roll forward by (~1 trading year)
WFA_WARMUP_DAYS = 406 # Indicator warmup period
# ============================================================================
# END CONFIGURATION
# ============================================================================
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
log = logging.getLogger(__name__)
def run_backtest(
data: Optional[list[dict]] = None,
ticker: str = TICKER,
start_date: str = START_DATE,
end_date: str = END_DATE,
csv_path: str = CSV_PATH,
initial_cash: float = INITIAL_CASH,
commission: float = COMMISSION,
slippage: float = SLIPPAGE,
spread_min=SPREAD_MIN,
spread_max=SPREAD_MAX,
short_window: int = SHORT_WINDOW,
long_window: int = LONG_WINDOW,
print_results: bool = True,
) -> list[float]:
"""
Executes a complete backtesting pipeline and returns portfolio value history.
This function:
1. Downloads/loads historical OHLCV data
2. Generates SMA crossover trading signals
3. Simulates portfolio execution with commission tracking
4. Calculates performance metrics (Sharpe, max drawdown, etc.)
5. Prints formatted results (optional)
Args:
data: Pre-loaded OHLCV records (if None, downloads from ticker/date range)
ticker: Stock ticker symbol (e.g., "AAPL", "^GSPC")
start_date: Backtest start date "YYYY-MM-DD"
end_date: Backtest end date "YYYY-MM-DD"
csv_path: Path to save/load CSV data
initial_cash: Starting capital in USD
commission: Transaction fee as decimal (0.001 = 0.1%)
short_window: Fast SMA period in days
long_window: Slow SMA period in days
print_results: If True, prints formatted performance summary
Returns:
list[float]: Portfolio values at each trading day (for metric calculations)
Raises:
ValueError: If data validation fails or parameters are invalid
FileNotFoundError: If CSV file cannot be loaded and ticker data unavailable
"""
# 1. Download & load data (only if not provided)
if data is None:
log.info("Downloading %s data (%s to %s)...", ticker, start_date, end_date)
download_historical_data(ticker, start_date, end_date, csv_path)
records = load_csv_data(csv_path)
log.info("%d records loaded.", len(records))
else:
records = data
# 2. Generate trading signals
strategy = SMAStrategy(short_window=short_window, long_window=long_window)
signals = strategy.generate_signals(records)
log.debug(
"%d signals generated (SMA %d/%d).",
len(signals),
short_window,
long_window,
)
# 3. Run backtest
portfolio = Portfolio(
initial_cash=initial_cash,
commission=commission,
slippage=slippage,
spread_min=spread_min,
spread_max=spread_max,
)
portfolio_values: list[float] = []
for signal in signals:
if signal["Signal"] == "BUY":
# Multiplikativ: (1 + spread) * (1 + slippage) * (1 + commission)
quantity = int(
portfolio.cash
// (
signal["Close"]
* (1 + spread_max)
* (1 + slippage)
* (1 + commission)
)
)
if quantity > 0:
portfolio.buy(signal["Date"], signal["Close"], quantity)
elif signal["Signal"] == "SELL" and portfolio.shares > 0:
log.debug(
"SELL %s | Price: %.2f | Shares: %d",
signal["Date"],
signal["Close"],
int(portfolio.shares),
)
portfolio.sell(signal["Date"], signal["Close"], portfolio.shares)
portfolio_values.append(portfolio.get_portfolio_value(signal["Close"]))
# 4. Performance metrics
if not portfolio_values:
log.warning("No portfolio values - nothing to report.")
return portfolio_values
mdd = calculate_max_drawdown(portfolio_values)
sharpe = calculate_sharpe_ratio(portfolio_values)
# Only print if requested (default True for CLI, False for optimization)
if print_results:
# Buy-and-Hold comparison
first_price = records[0]["Close"]
last_price = records[-1]["Close"]
buy_hold_end = (initial_cash / first_price) * last_price
strategy_return = (portfolio_values[-1] - initial_cash) / initial_cash
buyhold_return = (buy_hold_end - initial_cash) / initial_cash
# Calculate annualized return (assuming ~252 trading days per year, 5 years of data)
num_years = len(records) / 252
annualized_return = (
(portfolio_values[-1] / initial_cash) ** (1 / num_years)
) - 1
print("\nBacktest Results")
print("=" * 50)
print(f" SMA Parameters : ({short_window:>2d}, {long_window:>2d})")
print(f" Initial Capital : $ {initial_cash:>10,.2f}")
print(f" Final Value (SMA) : $ {portfolio_values[-1]:>10,.2f}")
print(f" Final Value (B&H) : $ {buy_hold_end:>10,.2f}")
print(f" Return (SMA) : {strategy_return:>10.2%}")
print(f" Return (B&H) : {buyhold_return:>10.2%}")
print(f" Return per Year : {annualized_return:>10.2%}")
print(f" Maximum Drawdown : {mdd:>10.2%}")
print(f" Sharpe Ratio : {sharpe:>10.4f}")
print("=" * 50)
return portfolio_values
def wfa_grid_search_wrapper(is_data: list[dict]) -> dict:
"""
Wrapper for grid search in Walk-Forward Analysis.
Uses global grid search parameters to find optimal SMA on IS window.
"""
results = run_grid_search(
data=is_data,
fast_range=range(GRID_SEARCH_FAST_MIN, GRID_SEARCH_FAST_MAX),
slow_range=range(GRID_SEARCH_SLOW_MIN, GRID_SEARCH_SLOW_MAX),
backtest_func=run_backtest,
)
# Extract best parameters (best Sharpe Ratio)
best_result = results["best_sharpe"]
return {
"best_short": best_result["short_window"],
"best_long": best_result["long_window"],
"best_sharpe": best_result["sharpe_ratio"],
}
def wfa_backtest_wrapper(
oos_data: list[dict], short_window: int, long_window: int
) -> list[float]:
"""
Wrapper for backtest in Walk-Forward Analysis.
Returns portfolio values for OOS validation.
"""
portfolio_values = run_backtest(
data=oos_data,
short_window=short_window,
long_window=long_window,
print_results=False,
)
return portfolio_values
if __name__ == "__main__":
# Single backtest with fixed parameters
if not GRID_SEARCH_ENABLED:
run_backtest()
# Grid-search optimization to find best SMA parameters
else:
# Suppress detailed logging during optimization
logging.getLogger().setLevel(logging.WARNING)
logging.getLogger("src.data_loader").setLevel(logging.WARNING)
logging.getLogger("src.strategy").setLevel(logging.WARNING)
logging.getLogger("src.portfolio").setLevel(logging.WARNING)
# Load data once
download_historical_data(TICKER, START_DATE, END_DATE, CSV_PATH)
data = load_csv_data(CSV_PATH)
# Calculate grid size
fast_count = GRID_SEARCH_FAST_MAX - GRID_SEARCH_FAST_MIN
slow_count = GRID_SEARCH_SLOW_MAX - GRID_SEARCH_SLOW_MIN
total_combinations = fast_count * slow_count
engine = "C++ Multithreaded" if is_cpp_available() else "Python"
print(
f"\n[Grid Search] {engine} | {len(data)} records | SMA {GRID_SEARCH_FAST_MIN}-{GRID_SEARCH_FAST_MAX-1} x {GRID_SEARCH_SLOW_MIN}-{GRID_SEARCH_SLOW_MAX-1} | {total_combinations} combos"
)
best_params = run_grid_search(
data=data,
fast_range=range(GRID_SEARCH_FAST_MIN, GRID_SEARCH_FAST_MAX),
slow_range=range(GRID_SEARCH_SLOW_MIN, GRID_SEARCH_SLOW_MAX),
backtest_func=run_backtest,
)
# Extract best parameters
best_sharpe = best_params["best_sharpe"]
best_returns = best_params["best_returns"]
# Run final backtest with best Sharpe parameters
print(f"\n{'='*70}")
print(
f"BEST SHARPE: SMA ({best_sharpe['short_window']}, {best_sharpe['long_window']})"
)
print(f"{'='*70}")
run_backtest(
data=data,
short_window=best_sharpe["short_window"],
long_window=best_sharpe["long_window"],
print_results=True,
)
# Run final backtest with best Returns parameters (if different)
if (
best_returns["short_window"] != best_sharpe["short_window"]
or best_returns["long_window"] != best_sharpe["long_window"]
):
print(f"\n{'='*70}")
print(
f"BEST RETURNS: SMA ({best_returns['short_window']}, {best_returns['long_window']})"
)
print(f"{'='*70}")
run_backtest(
data=data,
short_window=best_returns["short_window"],
long_window=best_returns["long_window"],
print_results=True,
)
# Walk-Forward Analysis
if WALK_FORWARD_ENABLED:
print("\n" + "=" * 100)
print("Starting Walk-Forward Analysis...")
print("=" * 100)
analyzer = WalkForwardAnalyzer(
data=data,
is_window_days=WFA_IS_WINDOW_DAYS,
oos_window_days=WFA_OOS_WINDOW_DAYS,
step_size_days=WFA_STEP_SIZE_DAYS,
warmup_days=WFA_WARMUP_DAYS,
)
wfa_results = analyzer.run(
grid_search_func=wfa_grid_search_wrapper, # Wrapper for standard grid search
backtest_func=wfa_backtest_wrapper, # Wrapper for backtest
initial_capital=INITIAL_CASH,
# Inner Cross-Validation (Layer 2) parameters
fast_range=range(GRID_SEARCH_FAST_MIN, GRID_SEARCH_FAST_MAX),
slow_range=range(GRID_SEARCH_SLOW_MIN, GRID_SEARCH_SLOW_MAX),
use_inner_cv=True, # Enable inner CV with robustness checks
)
print_wfa_summary(wfa_results, initial_capital=INITIAL_CASH)
# TODO: Document Walk-Forward Analysis in README