Skip to content

Commit

Permalink
added basic support for margin trading
Browse files Browse the repository at this point in the history
  • Loading branch information
arman-bd committed Dec 9, 2024
1 parent 0340501 commit 0a99774
Show file tree
Hide file tree
Showing 7 changed files with 425 additions and 66 deletions.
27 changes: 27 additions & 0 deletions portfolios/margin-trading.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
188 changes: 132 additions & 56 deletions src/backtest/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,28 @@ 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."""
self.initial_capital = Decimal(str(initial_capital))
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 = []
Expand All @@ -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:
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -296,108 +310,155 @@ 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

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)

self.journal.write(f"Capital After: ${float(self.current_capital):,.2f}")

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."""
Expand All @@ -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]
Expand Down
Loading

0 comments on commit 0a99774

Please sign in to comment.