File size: 14,717 Bytes
725cb3b | 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 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 | import os
os.environ.setdefault("MPLBACKEND", "Agg")
import pandas as pd
import yfinance as yf
from IPython.display import Markdown, display
import backtrader as bt
import tempfile, os
import base64
from io import BytesIO
import numpy as np
from io import BytesIO
import matplotlib
matplotlib.use('Agg') # Set the backend to non-interactive
import matplotlib.pyplot as plt
from datetime import datetime, timedelta
# Cache directory for CSV files
CACHE_DIR = "data_cache"
os.makedirs(CACHE_DIR, exist_ok=True)
class TransactionRecorder(bt.Analyzer):
""" Records all buy/sell transactions with details."""
def __init__(self):
self.records = []
def notify_order(self, order):
if order.status != order.Completed:
return
self.records.append({
'datetime': self.strategy.data.datetime.datetime(),
'type': 'BUY' if order.isbuy() else 'SELL',
'price': order.executed.price,
'size': order.executed.size,
'value': order.executed.value,
'commission': order.executed.comm,
})
def get_analysis(self):
return pd.DataFrame(self.records)
class TradeRecorder(bt.Analyzer):
""" Records detailed trade information including entry/exit prices and PnL."""
def __init__(self):
self.records = []
self.trade_id = 0
def notify_trade(self, trade):
# RECORD ENTRY
if trade.isopen:
self.current_entry_price = trade.price
self.current_size = trade.size
return
# RECORD EXIT
if trade.isclosed:
self.trade_id += 1
# Backtrader clears trade.size to 0 on close, so restore saved size
size = self.current_size
# Compute exit price from PnL formula:
# pnl = (exit - entry) * size
if size is not None and size != 0:
exit_price = self.current_entry_price + (trade.pnl / size)
else:
exit_price = None
self.records.append({
'trade_id': self.trade_id,
'size': size,
'entry_price': self.current_entry_price,
'exit_price': exit_price,
'bruto_profitloss': trade.pnl,
'neto_profitloss': trade.pnlcomm,
'date_open': self._format_dt(trade.dtopen),
'date_close': self._format_dt(trade.dtclose),
})
# Reset after close
self.current_entry_price = None
self.current_size = None
def get_analysis(self):
return pd.DataFrame(self.records)
def _format_dt(self, val):
"""Convert a backtrader/matplotlib numeric datetime to a readable string."""
if val is None:
return None
try:
ord_day = int(val)
frac = val - ord_day
dt = datetime.fromordinal(ord_day) + timedelta(days=frac)
return dt.strftime('%Y-%m-%d %H:%M:%S')
except Exception as e:
return "Error formatting dt: " + str(e)
def _get_cache_filename(tckr_symbl, interval, start_date, end_date, adjust_prices):
"""Generate a cache filename based on ticker, interval, date range, and adjust_prices.
Format: ticker_interval_start_to_end_adjust.csv"""
start_str = start_date.replace('-', '_')
end_str = end_date.replace('-', '_')
adjust_str = "adj" if adjust_prices else "raw"
return os.path.join(CACHE_DIR, f"{tckr_symbl}_{interval}_{start_str}_to_{end_str}_{adjust_str}.csv")
def _is_valid_cache(df):
"""Simple check if cached dataframe is valid and usable."""
if df.empty or len(df) < 2:
return False
if not isinstance(df.index, pd.DatetimeIndex):
return False
# Check we have required columns
required_cols = ['Open', 'High', 'Low', 'Close', 'Volume']
if not all(col in df.columns for col in required_cols):
return False
return True
def _load_cached_data(cache_file):
"""Load cached data from CSV if it exists."""
if os.path.exists(cache_file):
try:
# Read CSV - Date column should be first and become index
df = pd.read_csv(cache_file, index_col=0, parse_dates=True, header=0)
# Handle multi-level columns if they exist
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
# Ensure index is DatetimeIndex (parse_dates might not always work)
if not isinstance(df.index, pd.DatetimeIndex):
try:
df.index = pd.to_datetime(df.index)
except Exception as e:
print(f"Could not parse dates in cache: {e}, will re-download")
return None
# Ensure we have the expected columns (case-insensitive check)
expected_cols = ['Open', 'High', 'Low', 'Close', 'Volume']
actual_cols = list(df.columns)
# Check if we have at least the main OHLCV columns
if not any(col in actual_cols for col in expected_cols):
print(f"Unexpected columns in cache: {actual_cols}, will re-download")
return None
print(f"Loaded cached data: {len(df)} rows, columns: {list(df.columns)} from {cache_file}")
return df
except Exception as e:
print(f"Error loading cache: {e}, will re-download")
import traceback
traceback.print_exc()
return None
return None
def _save_cached_data(df, cache_file):
"""Save dataframe to CSV cache."""
try:
df.to_csv(cache_file)
print(f"Cached data saved: {cache_file}")
except Exception as e:
print(f"Error saving cache: {e}")
def _fig_to_numpy(fig, dpi=150):
buf = BytesIO()
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
buf.seek(0)
img = plt.imread(buf, format="png")
buf.close()
return img
#TODO: Dates plotting not working
def plot_bt(figs, symbol, market_name, save_img=True):
"""
Plot backtrader results with market name in title
and readable date ticks on all x-axes.
"""
images = []
plt.ioff() # Turn off interactive mode
for i, fig_list in enumerate(figs):
for j, fig in enumerate(fig_list):
# Decorate titles
if fig.axes:
main_ax = fig.axes[0]
current_title = main_ax.get_title()
new_title = f"{market_name} ({symbol.upper()}) - {current_title or 'Price Chart'}"
main_ax.set_title(new_title, fontsize=8, fontweight="bold")
fig.suptitle(
f"Trading Strategy Analysis: {market_name}",
fontsize=10,
fontweight="bold",
y=0.98,
)
fig.set_size_inches(12, 6)
fig.autofmt_xdate()
fig.tight_layout(rect=[0, 0, 1, 0.95])
# Convert to numpy
img_data = _fig_to_numpy(fig)
images.append(img_data)
if save_img:
filename = f"plot_{symbol}_{i}_{j}.png"
fig.savefig(filename, dpi=300, bbox_inches="tight")
print(f"Chart saved as: {filename}")
plt.close(fig) # Close the figure to free memory
return images
def _download_data(tckr_symbl, interval, date, adjust_prices, auto_period=True, period='60d'):
# Download data using yfinance with simple exact-match caching
try:
print("Interval: ", interval)
start_dt = datetime.strptime(date["start"], "%Y-%m-%d")
end_dt = datetime.strptime(date["end"], "%Y-%m-%d")
# Get cache file with exact date range
cache_file = _get_cache_filename(tckr_symbl, interval, date["start"], date["end"], adjust_prices)
# Check if cache exists and is valid
cached_df = _load_cached_data(cache_file)
if cached_df is not None and _is_valid_cache(cached_df):
# Cache exists and is valid - use it directly (NO download)
df = cached_df
print(f"Using cached data: {len(df)} rows (no download needed)")
else:
# No valid cache - download fresh
print("No valid cache, downloading...")
if interval in ['1m', '2m', '5m', '15m', '30m', '60m', '1h'] and auto_period:
# Intraday data
if interval in ['1m']:
max_days = 7
elif interval in ['2m', '5m', '15m', '30m']:
max_days = 60
else:
max_days = 730
desired_days = max(1, (end_dt - start_dt).days or 1)
clamped_days = min(desired_days, max_days)
period = f"{clamped_days}d"
df = yf.download(tckr_symbl, period=period, interval=interval, auto_adjust=adjust_prices)
print(f"Downloaded {interval} data for {period}")
else:
# Daily or longer data
df = yf.download(tckr_symbl, start=date["start"], end=date["end"], interval=interval, auto_adjust=adjust_prices)
print(f"Downloaded data from {date['start']} to {date['end']} with {interval} interval")
# Handle multi-level columns
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
# Save to cache
_save_cached_data(df, cache_file)
# Handle multi-level columns
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
# Check if data is available
if df.empty:
raise ValueError("No data available for the specified parameters!")
if df.index.tz is not None:
df.index = df.index.tz_localize(None)
print(f"Data points: {len(df)}")
return df
except Exception as e:
raise ValueError(f"Error downloading data: {e}")
def run_bt(cerebro,
date={'start':'1990-01-01', 'end':'2024-12-31'},
tckr_symbl="SPY",
save_img=False,
interval='1d',
auto_period =True, # For the moment just working with auto thats why
period='60d',
market_name = "Complete Market Name here",
initial_capital=10000.0,
commission=0.001,
slippage_percent=0.01,
adjust_prices=True):
"""
Run backtrader strategy with enhanced plotting
Args:
strategy: Backtrader strategy class already init
date: Dictionary with start and end dates
tckr_symbl: Stock ticker symbol
save_img: Whether to save plot images
interval: Data interval ('1d', '1h', '30m', etc.)
period: Period for intraday data (e.g., '60d')
auto_period: Whether to auto adjust period based on interval
initial_capital: Starting capital for the broker
commission: Commission per share
slippage_percent: Percent (e.g., 0.01 for 0.01%) applied as slippage
adjust_prices: Whether to pull adjusted (dividend/split) prices
"""
print(f"Running strategy on: {market_name} ({tckr_symbl.upper()})")
print("-" * 50)
print(f"Initial capital: {initial_capital}, Commission: {commission}, Slippage%: {slippage_percent}, Adjusted prices: {adjust_prices}")
df = _download_data(tckr_symbl, interval, date, adjust_prices, auto_period, period)
# Add data feed
data = bt.feeds.PandasData(dataname=df)
cerebro.adddata(data)
# Set initial cash and commission
initial_cash = float(initial_capital)
cerebro.broker.setcash(initial_cash)
cerebro.broker.setcommission(commission=float(commission))
slippage_decimal = float(slippage_percent) / 100.0
cerebro.broker.set_slippage_perc(slippage_decimal)
# Add analyzers for better performance metrics
cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
cerebro.addanalyzer(TransactionRecorder, _name='transactions')
cerebro.addanalyzer(TradeRecorder, _name='trades')
# Print starting conditions
print(f'Starting Portfolio Value: ${cerebro.broker.getvalue():,.2f}')
# Run strategy
results = cerebro.run()
# Calculate and display results
final_value = cerebro.broker.getvalue()
total_return = (final_value - initial_cash) / initial_cash * 100
print(f'Final Portfolio Value: ${final_value:,.2f}')
print(f'Total Return: {total_return:.2f}%')
# Print analyzer results
strat = results[0]
try:
sharpe = strat.analyzers.sharpe.get_analysis().get('sharperatio', 'N/A')
if sharpe != 'N/A':
print(f'Sharpe Ratio: {sharpe:.3f}')
else:
print('Sharpe Ratio: N/A')
except:
print('Sharpe Ratio: N/A')
try:
max_dd = strat.analyzers.drawdown.get_analysis()['max']['drawdown']
print(f'Max Drawdown: {max_dd:.2f}%')
except:
print('Max Drawdown: N/A')
print("-" * 50)
# Trades and transaction tables
df_transactions = strat.analyzers.transactions.get_analysis()
df_trades = strat.analyzers.trades.get_analysis()
if not df_transactions.empty:
print("Transactions logs generated")
else:
print("No transactions recorded.")
if not df_trades.empty:
print("Trades logs generated")
else:
print("No trades recorded.")
# Generate plot with market name (disable interactive plotting to avoid GUI in threads)
figs = cerebro.plot(style='candlestick',
barstyle='candlestick', # Explicitly set bar style
rowsmajor=True, # Stack vertically
dpi=120,
# Add these parameters to show all data:
subplot=True, # Create subplot
plotabove=False, # Don't plot above other plots
# Most importantly:
downsample=False, # Disable downsampling
plotstyle='multiple',
iplot=False,
show=False
)
print ("How many charts where created: " , len(figs))
fig = plot_bt(figs, market_name, tckr_symbl, save_img)
return final_value, total_return, fig, df_trades, df_transactions |