diff --git a/portfolios/margin-trading.json b/portfolios/margin-trading.json new file mode 100644 index 0000000..f32a26b --- /dev/null +++ b/portfolios/margin-trading.json @@ -0,0 +1,27 @@ +{ + "initial_capital": 10000, + "position_size": 0.1, + "max_positions": 10, + "start_date": "2024-10-01", + "end_date": "2024-10-15", + "interval": "1h", + "margin_call": 0.2, + "assets": [ + { + "symbol": "CL=F", + "type": "future", + "strategy": "ftr", + "min_leverage": 1, + "max_leverage": 20, + "spread_fee": 0.001 + }, + { + "symbol": "GC=F", + "type": "future", + "strategy": "ftr", + "min_leverage": 1, + "max_leverage": 20, + "spread_fee": 0.001 + } + ] +} \ No newline at end of file diff --git a/src/backtest/engine.py b/src/backtest/engine.py index 2781104..a82bf60 100644 --- a/src/backtest/engine.py +++ b/src/backtest/engine.py @@ -23,15 +23,17 @@ class BacktestResult: class BacktestEngine: - """Main backtesting engine.""" - - # Return type for backtest results + """Engine to run backtests on trading strategies.""" def __init__( self, initial_capital: float = 100000, - position_size: float = 0.1, # Changed to 10% of capital + position_size: float = 0.1, max_positions: int = 5, + min_leverage: float = 1.0, # Minimum allowed leverage + max_leverage: float = 1.0, # Maximum allowed leverage + spread_fee: float = 0.0, # Spread fee as percentage + margin_call: float = 0.2, # Margin call level as percentage journal: JournalWriter = None, ): """Initialize the backtest engine.""" @@ -39,6 +41,10 @@ def __init__( self.current_capital = self.initial_capital self.position_size = Decimal(str(position_size)) self.max_positions = max_positions + self.min_leverage = Decimal(str(min_leverage)) + self.max_leverage = Decimal(str(max_leverage)) + self.spread_fee = Decimal(str(spread_fee)) + self.margin_call = Decimal(str(margin_call)) self.positions: list[Position] = [] self.closed_positions: list[Position] = [] self.equity_curve = [] @@ -51,6 +57,8 @@ def run( start_date: pd.Timestamp, end_date: pd.Timestamp, interval: str = "1d", + leverage: float = 1.0, + spread_fee: float = 0.0, ) -> BacktestResult: """Run backtest for given strategy and asset.""" with self.journal: @@ -94,7 +102,9 @@ def run( self.journal.write(f"\nProcessing BUY signal at {current_time}") self.journal.write(f"Current price: {current_bar['Close']}") self.journal.write(f"Current capital: {self.current_capital}") - self._open_position(asset, current_bar, current_time) + self._open_position( + asset, current_bar, current_time, leverage, spread_fee + ) elif current_signal == SignalType.SELL and len(self.positions) > 0: self.journal.write( f"\nProcessing SELL signal at {current_time}" @@ -143,6 +153,8 @@ def run_portfolio( start_date: pd.Timestamp, end_date: pd.Timestamp, interval: str = "1d", + leverage: float = 1.0, + spread_fee: float = 0.0, ) -> PerformanceMetrics: """Run backtest for a portfolio of assets.""" try: @@ -217,7 +229,9 @@ def run_portfolio( self.journal.write( f"\nProcessing BUY signal for {asset.symbol} at {current_time}" ) - self._open_position(asset, current_bar, current_time) + self._open_position( + asset, current_bar, current_time, leverage, spread_fee + ) elif current_signal == SignalType.SELL: self.journal.write( f"\nProcessing SELL signal for {asset.symbol} at {current_time}" @@ -296,79 +310,113 @@ def run_portfolio( raise def _update_positions(self, bar: pd.Series, timestamp: pd.Timestamp): - """Update open positions with current prices.""" - for position in self.positions: + """Update open positions and check for liquidation.""" + current_price = Decimal(str(bar["Close"])) + + for position in self.positions[:]: # Use copy to avoid modification during iteration + # Check liquidation first + if position.check_liquidation(current_price): + self._close_position(position, current_price, timestamp, liquidation=True) + continue + # Check stop loss - if position.stop_loss and bar["Low"] <= position.stop_loss: - self._close_position(position, position.stop_loss, timestamp) + if position.stop_loss is not None and current_price <= position.stop_loss: + self._close_position(position, current_price, timestamp) continue # Check take profit - if position.take_profit and bar["High"] >= position.take_profit: - self._close_position(position, position.take_profit, timestamp) + if position.take_profit is not None and current_price >= position.take_profit: + self._close_position(position, current_price, timestamp) continue - def _open_position(self, asset: Asset, bar: pd.Series, timestamp: pd.Timestamp): - """Open new position if conditions are met.""" + def _open_position( + self, + asset: Asset, + bar: pd.Series, + timestamp: pd.Timestamp, + leverage: float = 1.0, + _spread_fee: float = None, + ): + """Open a new leveraged position with proper position sizing.""" try: - # Only check max positions limit if len(self.positions) >= self.max_positions: - self.journal.write( - f"Skip opening position: Max positions ({self.max_positions}) reached" - ) + self.journal.write(f"Skip opening position: Max positions ({self.max_positions}) reached") return - # Calculate position size - position_capital = self.current_capital * self.position_size + # Use maximum configured leverage + leverage = Decimal(str(self.max_leverage)) current_price = Decimal(str(bar["Close"])) if current_price == 0: - self.journal.write( - f"Skip opening position: Invalid price {current_price}" - ) + self.journal.write(f"Skip opening position: Invalid price {current_price}") return - shares = float(position_capital / current_price) + # Calculate position size based on current capital + position_size = self.position_size # 10% of current capital + available_capital = self.current_capital * position_size + + # Calculate shares with leverage + shares = float(available_capital * leverage / current_price) shares = round(shares, 3) shares_decimal = Decimal(str(shares)) - if shares < 0.001: - self.journal.write( - "Skip opening position: Position size too small", printable=True - ) + # Calculate spread fee + spread_fee = current_price * (Decimal(str(_spread_fee)) if _spread_fee is not None else self.spread_fee) + + # Calculate required margin + required_margin = (current_price * shares_decimal) / leverage + + # Check if we have enough margin + if required_margin > self.current_capital: + self.journal.write("Skip opening position: Insufficient margin") return + # Calculate liquidation price + margin_ratio = self.margin_call # 20% margin call level + price_drop_to_liquidate = (required_margin * margin_ratio) / shares_decimal + liquidation_price = current_price - price_drop_to_liquidate + position = Position( symbol=asset.symbol, entry_price=current_price, entry_date=timestamp, shares=shares_decimal, + leverage=leverage, + spread_fee=spread_fee, + liquidation_price=liquidation_price ) - cost = current_price * shares_decimal - if cost > self.current_capital: - self.journal.write("Skip opening position: Insufficient capital") - return - + # Log position details self.journal.write(f"\nOpening position #{len(self.positions) + 1}:") self.journal.write(f"Symbol: {position.symbol}") self.journal.write(f"Shares: {float(shares_decimal):,.3f}") self.journal.write(f"Price: ${float(current_price):,.2f}") - self.journal.write(f"Total Cost: ${float(cost):,.2f}") - self.journal.write(f"Capital Before: ${float(self.current_capital):,.2f}") - + + if leverage != 1.0: + self.journal.write(f"Leverage: {float(leverage)}x") + self.journal.write(f"Position Value: ${float(current_price * shares_decimal):,.2f}") + self.journal.write(f"Required Margin: ${float(required_margin):,.2f}") + self.journal.write(f"Spread Fee: ${float(spread_fee):,.2f}") + self.journal.write(f"Liquidation Price: ${float(liquidation_price):,.2f}") + + # Deduct margin from available capital + self.current_capital -= required_margin self.positions.append(position) - self.current_capital -= cost + self.journal.write(f"Capital After: ${float(self.current_capital):,.2f}") self.journal.write(f"Total Open Positions: {len(self.positions)}") except Exception as e: self.journal.write(f"Error opening position: {str(e)}", printable=True) def _close_position( - self, position: Position, price: Decimal, timestamp: pd.Timestamp + self, + position: Position, + price: Decimal, + timestamp: pd.Timestamp, + liquidation: bool = False, ): - """Close specific position.""" + """Close a leveraged position and update capital.""" try: if not position.is_open: return @@ -376,18 +424,34 @@ def _close_position( position.exit_price = price position.exit_date = timestamp - value = price * Decimal(str(position.shares)) - pnl = position.profit_loss + # Calculate P&L including leverage and fees + price_diff = position.exit_price - position.entry_price + pnl = price_diff * position.shares * position.leverage + + # Subtract spread fees + total_spread_cost = position.spread_fee * position.shares * Decimal('2') + final_pnl = pnl - total_spread_cost + + # Return margin to available capital + margin_returned = (position.entry_price * position.shares) / position.leverage self.journal.write("\nClosing position:") self.journal.write(f"Symbol: {position.symbol}") - self.journal.write(f"Shares: {position.shares:,}") + self.journal.write(f"Shares: {float(position.shares):,.3f}") self.journal.write(f"Entry: ${float(position.entry_price):,.2f}") self.journal.write(f"Exit: ${float(price):,.2f}") - self.journal.write(f"P&L: ${float(pnl):,.2f}") - self.journal.write(f"Capital Before: ${float(self.current_capital):,.2f}") - - self.current_capital += value + self.journal.write(f"Raw P&L: ${float(pnl):,.2f}") + self.journal.write(f"Net P&L: ${float(final_pnl):,.2f}") + if position.leverage != 1.0: + self.journal.write(f"Leverage: {float(position.leverage)}x") + self.journal.write(f"Spread Fees: ${float(total_spread_cost):,.2f}") + if liquidation: + self.journal.write("*** Position Liquidated ***") + + # Update capital with margin and P&L + self.current_capital += margin_returned + final_pnl + + # Record position self.closed_positions.append(position) self.positions.remove(position) @@ -395,9 +459,6 @@ def _close_position( except Exception as e: self.journal.write(f"Error closing position: {str(e)}", printable=True) - import traceback - - traceback.print_exc() def _close_positions(self, asset: Asset, bar: pd.Series, timestamp: pd.Timestamp): """Close all positions for given asset.""" @@ -412,13 +473,28 @@ def _close_all_positions(self, bar: pd.Series, timestamp: pd.Timestamp): self._close_position(position, Decimal(str(bar["Close"])), timestamp) def _calculate_equity(self, bar: pd.Series) -> Decimal: - """Calculate current equity including open positions.""" - open_positions_value = sum( - Decimal(str(position.shares)) - * Decimal(str(bar["Close"])) # Convert both to Decimal - for position in self.positions - ) - return self.current_capital + open_positions_value + """Calculate current equity including leveraged positions.""" + try: + # Start with current cash + equity = self.current_capital + + # Add unrealized P&L from open positions + for position in self.positions: + current_price = Decimal(str(bar["Close"])) + + # Calculate position P&L including leverage + price_diff = current_price - position.entry_price + leveraged_pnl = price_diff * position.shares * position.leverage + + # Subtract spread fees if position was just opened + total_spread_cost = position.spread_fee * position.shares * Decimal('2') + + equity += leveraged_pnl - total_spread_cost + + return equity + except Exception as e: + self.journal.write(f"Error calculating equity: {str(e)}", printable=True) + return self.current_capital def _calculate_portfolio_equity( self, current_bars: dict[str, pd.Series] diff --git a/src/cli/commands.py b/src/cli/commands.py index 428f261..afc4ab5 100644 --- a/src/cli/commands.py +++ b/src/cli/commands.py @@ -8,6 +8,7 @@ from rich.table import Table from src.data.fetcher import DataFetcher +from src.strategies.futures import FuturesStrategy from ..backtest.engine import BacktestEngine from ..core.asset import Asset, AssetType @@ -57,6 +58,21 @@ atr_multiplier=float(params.get("atr_multiplier", 2.0)), journal=journal, ), + "ftr": lambda params: FuturesStrategy( + volatility_window=int(params.get("volatility_window", 20)), + atr_periods=int(params.get("atr_periods", 14)), + atr_multiplier=float(params.get("atr_multiplier", 2.0)), + rsi_period=int(params.get("rsi_period", 14)), + rsi_oversold=int(params.get("rsi_oversold", 30)), + rsi_overbought=int(params.get("rsi_overbought", 70)), + trend_short_window=int(params.get("trend_short_window", 10)), + trend_long_window=int(params.get("trend_long_window", 50)), + max_leverage=float(params.get("max_leverage", 5.0)), + min_leverage=float(params.get("min_leverage", 1.0)), + risk_per_trade=float(params.get("risk_per_trade", 0.02)), + profit_ratio=float(params.get("profit_ratio", 2.0)), + journal=journal, + ), } @@ -285,12 +301,22 @@ def backtest_portfolio( asset_type = asset_config["type"] strategy_type = asset_config["strategy"] + # Extract leverage and spread parameters + min_leverage = asset_config.get("min_leverage", 1.0) + max_leverage = asset_config.get("max_leverage", 1.0) + spread_fee = asset_config.get("spread_fee", 0.0) + + # Create strategy with leverage parameters + strategy_params = { + "min_leverage": min_leverage, + "max_leverage": max_leverage, + **asset_config.get("params", {}) + } + asset = Asset(symbol, AssetType(asset_type)) assets.append(asset) - strategy = STRATEGIES[strategy_type]( - params=asset_config.get("params", {}) - ) + strategy = STRATEGIES[strategy_type](params=strategy_params) strategies[symbol] = strategy progress.stop_task(prg1) @@ -301,6 +327,10 @@ def backtest_portfolio( initial_capital=config.get("initial_capital", 100000), position_size=config.get("position_size", 0.1), max_positions=config.get("max_positions", 5), + min_leverage=min_leverage, + max_leverage=max_leverage, + spread_fee=spread_fee, + margin_call=config.get("margin_call", 0.0), journal=journal, ) @@ -330,6 +360,8 @@ def backtest_portfolio( start_date=pd.Timestamp(config["start_date"]), end_date=pd.Timestamp(config["end_date"]), interval=config.get("interval", "1d"), + leverage=max_leverage, # Pass leverage to run_portfolio + spread_fee=spread_fee # Pass spread fee to run_portfolio ) progress.stop_task(prg3) diff --git a/src/core/asset.py b/src/core/asset.py index f85f2d5..83e5eed 100644 --- a/src/core/asset.py +++ b/src/core/asset.py @@ -2,12 +2,15 @@ class AssetType(Enum): + """Enumeration of different asset types.""" + STOCK = "stock" ETF = "etf" FOREX = "forex" CRYPTO = "crypto" COMMODITY = "commodity" INDEX = "index" + FUTURE = "future" class Asset: @@ -28,6 +31,11 @@ class Asset: } def __init__(self, symbol: str, asset_type: AssetType): + """Initialize an Asset instance. + + :param symbol: The symbol of the asset. + :param asset_type: The type of the asset, as an AssetType enum. + """ self.symbol = symbol self.asset_type = asset_type self.yahoo_symbol = self._get_yahoo_symbol() @@ -35,6 +43,6 @@ def __init__(self, symbol: str, asset_type: AssetType): def _get_yahoo_symbol(self) -> str: if self.asset_type == AssetType.COMMODITY: return self.COMMODITY_MAPPINGS.get(self.symbol, self.symbol) - elif self.asset_type == AssetType.INDEX: + if self.asset_type == AssetType.INDEX: return self.INDEX_MAPPINGS.get(self.symbol, self.symbol) return self.symbol diff --git a/src/core/position.py b/src/core/position.py index c5fd279..ba56c30 100644 --- a/src/core/position.py +++ b/src/core/position.py @@ -2,15 +2,17 @@ from datetime import datetime from decimal import Decimal - @dataclass class Position: - """Represents a trading position.""" + """Represents a trading position with leverage support.""" symbol: str entry_price: Decimal entry_date: datetime - shares: int + shares: Decimal + leverage: Decimal = Decimal('1') # Default leverage is 1x + spread_fee: Decimal = Decimal('0') # Spread fee per share + liquidation_price: Decimal | None = None stop_loss: Decimal | None = None take_profit: Decimal | None = None exit_price: Decimal | None = None @@ -26,14 +28,42 @@ def duration(self) -> int | None: return None return (self.exit_date - self.entry_date).days + @property + def position_value(self) -> Decimal: + """Calculate the actual position value including leverage.""" + return self.shares * self.entry_price * self.leverage + + @property + def margin_required(self) -> Decimal: + """Calculate required margin for the position.""" + return self.position_value / self.leverage + @property def profit_loss(self) -> Decimal | None: + """Calculate P&L including leverage and spread fees.""" if not self.exit_price: return None - return (self.exit_price - self.entry_price) * self.shares + + # Calculate raw P&L with leverage + raw_pl = (self.exit_price - self.entry_price) * self.shares * self.leverage + + # Subtract spread fees (applied to both entry and exit) + total_spread_cost = self.spread_fee * self.shares * Decimal('2') + + return raw_pl - total_spread_cost @property def profit_loss_pct(self) -> Decimal | None: if not self.profit_loss: return None - return (self.profit_loss / (self.entry_price * self.shares)) * 100 + return (self.profit_loss / self.margin_required) * 100 + + def check_liquidation(self, current_price: Decimal) -> bool: + """Check if position should be liquidated based on current price.""" + if self.liquidation_price is None: + return False + + if self.leverage > Decimal('1'): + return current_price <= self.liquidation_price + + return False \ No newline at end of file diff --git a/src/strategies/futures.py b/src/strategies/futures.py new file mode 100644 index 0000000..8420366 --- /dev/null +++ b/src/strategies/futures.py @@ -0,0 +1,187 @@ +import pandas as pd + +from ..utils.journal import JournalWriter +from .base import SignalType, Strategy + + +class FuturesStrategy(Strategy): + """Futures trading strategy with dynamic leverage management.""" + + def __init__( + self, + volatility_window: int = 20, + atr_periods: int = 14, + atr_multiplier: float = 2.0, + rsi_period: int = 14, + rsi_oversold: int = 30, + rsi_overbought: int = 70, + trend_short_window: int = 10, + trend_long_window: int = 50, + max_leverage: float = 5.0, + min_leverage: float = 1.0, + risk_per_trade: float = 0.02, # 2% risk per trade + profit_ratio: float = 2.0, # Risk:Reward ratio + journal: JournalWriter = None, + ): + """Initialize strategy parameters.""" + super().__init__("Futures Strategy", journal) + self.volatility_window = volatility_window + self.atr_periods = atr_periods + self.atr_multiplier = atr_multiplier + self.rsi_period = rsi_period + self.rsi_oversold = rsi_oversold + self.rsi_overbought = rsi_overbought + self.trend_short_window = trend_short_window + self.trend_long_window = trend_long_window + self.max_leverage = float(max_leverage) # Ensure float conversion + self.min_leverage = float(min_leverage) + self.risk_per_trade = risk_per_trade + self.profit_ratio = profit_ratio + + # Store calculated values for position management + self.current_leverage = float(max_leverage) # Start with max leverage + self.current_stop_loss = None + self.current_take_profit = None + + def generate_signals(self, data: pd.DataFrame) -> pd.Series: + """Generate trading signals with dynamic leverage and risk management.""" + if len(data) < max(self.volatility_window, self.atr_periods, self.rsi_period): + return pd.Series(SignalType.HOLD, index=data.index) + + # Calculate technical indicators + atr = self._calculate_atr(data) + volatility = self._calculate_volatility(data) + rsi = self._calculate_rsi(data) + trend = self._calculate_trend(data) + + # Initialize signals and tracking variables + signals = pd.Series(SignalType.HOLD, index=data.index) + position_open = False + + for i in range(max(self.volatility_window, self.atr_periods, self.rsi_period), len(data)): + current_price = data['Close'].iloc[i] + current_atr = atr.iloc[i] + current_vol = volatility.iloc[i] + + if not position_open: + # Entry conditions + long_signal = ( + rsi.iloc[i] < self.rsi_oversold and + trend.iloc[i] > 0 and + current_vol < volatility.rolling(window=20).mean().iloc[i] + ) + + short_signal = ( + rsi.iloc[i] > self.rsi_overbought and + trend.iloc[i] < 0 and + current_vol < volatility.rolling(window=20).mean().iloc[i] + ) + + if long_signal or short_signal: + # Always use maximum leverage for futures + self.current_leverage = self.max_leverage + + # Calculate stop loss and take profit levels + stop_distance = current_atr * self.atr_multiplier + if long_signal: + self.current_stop_loss = current_price - stop_distance + self.current_take_profit = current_price + (stop_distance * self.profit_ratio) + else: # short signal + self.current_stop_loss = current_price + stop_distance + self.current_take_profit = current_price - (stop_distance * self.profit_ratio) + + signals.iloc[i] = SignalType.BUY + position_open = True + + elif position_open: + # Exit conditions + exit_signal = ( + (current_price <= self.current_stop_loss) or + (current_price >= self.current_take_profit) or + (rsi.iloc[i] > self.rsi_overbought and trend.iloc[i] < 0) or + (rsi.iloc[i] < self.rsi_oversold and trend.iloc[i] > 0) + ) + + if exit_signal: + signals.iloc[i] = SignalType.SELL + position_open = False + + self.journal.write( + f"Generated Futures signals: Buy={sum(signals == SignalType.BUY)}, Sell={sum(signals == SignalType.SELL)}", + printable=True, + ) + + return signals + + def _calculate_atr(self, data: pd.DataFrame) -> pd.Series: + """Calculate Average True Range.""" + high = data["High"] + low = data["Low"] + close = data["Close"].shift(1) + + tr1 = high - low + tr2 = abs(high - close) + tr3 = abs(low - close) + + tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + return tr.rolling(window=self.atr_periods).mean() + + def _calculate_volatility(self, data: pd.DataFrame) -> pd.Series: + """Calculate rolling volatility.""" + returns = data["Close"].pct_change() + return returns.rolling(window=self.volatility_window).std() + + def _calculate_rsi(self, data: pd.DataFrame) -> pd.Series: + """Calculate Relative Strength Index.""" + delta = data["Close"].diff() + gain = (delta.where(delta > 0, 0)).rolling(window=self.rsi_period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=self.rsi_period).mean() + + rs = gain / loss + return 100 - (100 / (1 + rs)) + + def _calculate_trend(self, data: pd.DataFrame) -> pd.Series: + """Calculate trend strength using EMA difference.""" + short_ema = data["Close"].ewm(span=self.trend_short_window, adjust=False).mean() + long_ema = data["Close"].ewm(span=self.trend_long_window, adjust=False).mean() + + # Normalize trend strength between -1 and 1 + trend = (short_ema - long_ema) / long_ema + return trend + + def _calculate_dynamic_leverage( + self, current_volatility: float, trend_strength: float, avg_volatility: float + ) -> float: + """Calculate appropriate leverage based on market conditions.""" + # Reduce leverage when volatility is high + vol_ratio = min( + avg_volatility / current_volatility if current_volatility > 0 else 1, 2 + ) + base_leverage = vol_ratio * self.max_leverage + + # Adjust leverage based on trend strength + trend_factor = min(abs(trend_strength) * 2, 1) # Scale trend strength + leverage = base_leverage * trend_factor + + # Ensure leverage stays within bounds + leverage = max(min(leverage, self.max_leverage), self.min_leverage) + + return round(leverage, 2) + + def get_suggested_position_size(self, capital: float, price: float) -> float: + """Calculate suggested position size based on risk parameters.""" + risk_amount = capital * self.risk_per_trade + position_value = risk_amount * self.current_leverage + return position_value / price + + def get_current_leverage(self) -> float: + """Get the currently calculated leverage for the next trade.""" + return self.current_leverage + + def get_stop_loss(self) -> float | None: + """Get the calculated stop loss for the current/next trade.""" + return self.current_stop_loss + + def get_take_profit(self) -> float | None: + """Get the calculated take profit for the current/next trade.""" + return self.current_take_profit diff --git a/src/visualization/enhanced_charts.py b/src/visualization/enhanced_charts.py index 7d0a58c..4233374 100644 --- a/src/visualization/enhanced_charts.py +++ b/src/visualization/enhanced_charts.py @@ -528,7 +528,6 @@ def create_equity_curve( fig.show() return fig - def create_drawdown_chart( self, equity_series: pd.Series, format: str = "html"