| Overall Statistics |
|
Total Orders 573 Average Win 2.80% Average Loss -1.61% Compounding Annual Return 7.546% Drawdown 1.100% Expectancy -0.256 Start Equity 10000000 End Equity 12823914.03 Net Profit 28.239% Sharpe Ratio -0.079 Sortino Ratio -0.105 Probabilistic Sharpe Ratio 99.536% Loss Rate 73% Win Rate 27% Profit-Loss Ratio 1.74 Alpha -0.004 Beta 0.017 Annual Standard Deviation 0.027 Annual Variance 0.001 Information Ratio -0.915 Tracking Error 0.126 Treynor Ratio -0.124 Total Fees $219152.53 Estimated Strategy Capacity $590000.00 Lowest Capacity Asset SHV TP8J6Z7L419H Portfolio Turnover 24.57% Drawdown Recovery 66 |
'''
Dynamic Version of the Arbitrage stock model
Includes contract swapping when onhand AR + cost to exit + optimization < market AR
Strategy Design by Thomas Deng
Programmed by Thomas Deng & Eli Webster
'''
from AlgorithmImports import *
from collections import defaultdict
from typing import Dict, Any
class DynamicAR(QCAlgorithm):
def initialize(self) -> None:
self.set_start_date(2023, 1, 1)
self.set_end_date(2026, 6, 1)
self.set_cash(10_000_000)
self.settings.seed_initial_prices = True
# Add a "risk-free" asset.
self._shv = self.add_equity('SHV')
# Add the Equities and Options to trade.
tickers = ["AAPL", "MSFT", "NVDA", "AMZN", "META", "GOOGL", "TSLA"] # Mag 7 stocks only
self._equities = []
for ticker in tickers:
equity = self.add_equity(ticker)
option = self.add_option(ticker)
option.set_filter(lambda u: u.include_weeklys().expiration(7, 36).strikes(-30, 0))
equity.option_symbol = option.symbol
self._equities.append(equity)
# Add a member to track our current trade.
self._trade = None
# Define some parameters.
self._min_ar = self.get_parameter('min_ar', 5) # Minimum AR threshold to buy contract
self._swap_optimization = 3 # Minimum AR improvement required to justify a swap
# Add a Scheduled Event to scan for new trades.
# Scan every 5 minutes starting from market open, for 30 mins.
for i in range(self.get_parameter('entry_scans', 7)):
self.schedule.on(
self.date_rules.every_day('SPY'),
self.time_rules.after_market_open('SPY', max(5*i, 1)),
self._check_for_entry
)
# Add a Scheduled Event to scan for exits.
# Liquidate the portfolio on the day the contracts expiry.
self.schedule.on(
self.date_rules.every_day('SPY'),
self.time_rules.before_market_close('SPY', 2),
self._check_expiry
)
def _check_for_entry(self) -> None:
# Find the best available opportunity.
best_trade = self._find_best_trade()
# If holding a position, evaluate whether to swap or hold.
if self._trade:
# If expired, liquidate.
if (self.time.date() >= self._trade.expiry or
# Detect early assignment: stock position gone but expiry not reached
not self._trade.underlying.invested and self.time.date() < self._trade.expiry):
self.liquidate()
self._trade = None
# If there is a better opportunity and its worth swapping to it, go for it.
elif best_trade and best_trade["ar"] > self._get_hand_ar() + self._get_exit_cost() + self._swap_optimization:
self.liquidate()
self._trade = None
self._enter_position(best_trade)
# If there are no opportunities that passed our filters, do nothing.
elif not best_trade:
return
# If the best opportunity doesn't pass our threshold, hold the risk-free asset.
elif best_trade["ar"] < self._min_ar:
if not self._shv.invested:
self.set_holdings(self._shv, 1)
# Otherwise, sell the risk-free asset and buy the conversion.
elif self._shv.invested:
self.liquidate(self._shv)
self._enter_position(best_trade)
# If the contracts expire today, liquidate them.
def _check_expiry(self) -> None:
if self._trade and self.time.date() == self._trade.expiry:
self.liquidate()
self._trade = None
# Scan all tickers and return the best opportunity for annualized returns.
def _find_best_trade(self) -> Dict[str, Any]:
opportunities = []
# Scan all contract chains in selected tickers, finding best contract
for underlying in self._equities:
# Organize the current Option chain into a dictionary, grouping mirror contracts together.
chain = self.current_slice.option_chains.get(underlying.option_symbol)
if not chain:
continue
pairs = defaultdict(dict)
for contract in chain:
key = (contract.strike, contract.expiry.date())
pairs[key][contract.right] = contract
# Collect all unique strikes to calculate strike distance later.
all_strikes = sorted(set(contract.strike for contract in chain))
# A = Ask Price of Stock (use Price if Ask not available)
underlying_price = underlying.ask_price or underlying.price
# Loop through each pair on contracts.
today = self.time.date()
for (strike, expiry), legs in pairs.items():
# Skip contracts expiring today.
if expiry == today:
continue
# Get the call and put.
call = legs.get(OptionRight.CALL)
put = legs.get(OptionRight.PUT)
if not call or not put:
continue
# S = Strike Price
# C = Bid Price of Call
# P = Ask Price of Put
s = strike
c = call.bid_price
p = put.ask_price
net_premium = c - p
# Apply filters:
# 1) Net premium must be positive.
if (net_premium < 0 or
# 2) P > 0 and C > 0.
c <= 0 or p <= 0 or
# Strike must be within 10% of the underlying price.
s < 0.90 * underlying_price or s > 1.10 * underlying_price):
continue
# Save the arbitrage opportunity.
opportunities.append({
"underlying": underlying,
"call": call,
"put": put,
"ar": self._annualized_return(underlying, call, put)
})
# Select the arbitrage opportunity with the greatest annualized return.
return max(opportunities, key=lambda t: t['ar']) if opportunities else {}
def _annualized_return(self, underlying, call, put):
# C = Bid Price of Call (what we can sell our held call for)
c = self.securities[call.symbol].bid_price
# P = Ask Price of Put (what it costs to close our short put)
p = self.securities[put.symbol].ask_price
# A = Ask Price of Stock
a = underlying.ask_price
s = call.strike
t = self._years_to_expiry(call.expiry.date())
return ((c - p) + (s - a)) * 100 / (a * t)
def _years_to_expiry(self, expiry):
days_to_expiry = (expiry - self.time.date()).days
return max(days_to_expiry / 365.25, 1 / 365)
# Execute entry into a new position.
def _enter_position(self, trade: Dict[str, Any]) -> None:
underlying = trade["underlying"]
call = trade["call"]
put = trade["put"]
# Calculate the cost per combo:
# Buy stock at ask, buy put at ask, and sell call at bid
combo_price = 100 * (underlying.ask_price + put.ask_price - call.bid_price)
combos = int(self.portfolio.margin_remaining * .95 / combo_price)
if combos < 1:
return
# Build the trade legs.
legs = [
Leg.create(underlying, 100),
Leg.create(put.symbol, 1),
Leg.create(call.symbol, -1)
]
# Plat the combo order.
self.combo_market_order(legs, combos)
# Keep a reference to the trade.
self._trade = ComboTrade(underlying, call, put, call.expiry.date())
# AR of the position currently held (using current market prices)
def _get_hand_ar(self) -> float:
return self._annualized_return(self._trade.underlying, self._trade.call, self._trade.put)
# Annualized cost of exiting the current position (bid-ask spread)
# Formula: (put_spread + call_spread) * 100 / (T * A_bid)
def _get_exit_cost(self) -> float:
# Get the securities in the current trade.
underlying = self._trade.underlying
call = self.securities[self._trade.call.symbol]
put = self.securities[self._trade.put.symbol]
# Get the entry/exit prices.
call_ask = call.ask_price
call_bid = call.bid_price
put_ask = put.ask_price
put_bid = put.bid_price
stock_bid = underlying.bid_price
# Ensure the calculation will be valid.
if not all([call_ask, call_bid, put_ask, put_bid, stock_bid]):
return np.inf
# Calculate annualized return of exiting the position.
put_spread = put_ask - put_bid
call_spread = call_ask - call_bid
t = self._years_to_expiry(self._trade.expiry)
return (put_spread + call_spread) * 100 / (t * stock_bid)
class ComboTrade:
def __init__(self, underlying, call, put, expiry):
self.underlying = underlying
self.call = call
self.put = put
self.expiry = expiry