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