Overall Statistics
Total Orders
4497
Average Win
0.22%
Average Loss
-0.27%
Compounding Annual Return
-7.710%
Drawdown
82.300%
Expectancy
-0.144
Start Equity
1000000
End Equity
301661.38
Net Profit
-69.834%
Sharpe Ratio
-0.14
Sortino Ratio
-0.097
Probabilistic Sharpe Ratio
0.000%
Loss Rate
53%
Win Rate
47%
Profit-Loss Ratio
0.82
Alpha
-0.038
Beta
0.01
Annual Standard Deviation
0.268
Annual Variance
0.072
Information Ratio
-0.419
Tracking Error
0.303
Treynor Ratio
-3.688
Total Fees
$60822.50
Estimated Strategy Capacity
$29000000.00
Lowest Capacity Asset
USO THORT68ZZSYT
Portfolio Turnover
3.16%
# region imports
from AlgorithmImports import *
from delta_hedge_module import DeltaHedgeModule
# endregion

class ETFOptionsDeltaHedgeModule(DeltaHedgeModule):

    def __init__(
        self, 
        algo: QCAlgorithm
    ) -> None:

        super(ETFOptionsDeltaHedgeModule, self).__init__(algo)

    def delta_hedge(self, symbol: Symbol) -> None:
        delta_hedge_quantity: float = self._get_delta_hedge_quantity(symbol)
        if delta_hedge_quantity != 0:
            self._algo.market_order(symbol, delta_hedge_quantity)

    def _get_delta_hedge_quantity(self, symbol: Symbol) -> float:
        chains: List[OptionChain] = [
            opt_chain for opt_chain in self._algo.current_slice.option_chains.values()
            if opt_chain.underlying.symbol == symbol
        ]
        if not chains:
            self._algo.log(f'DeltaHedgeModule._get_delta_hedge_quantity: No option chain found for {symbol}. Number of option chains present in the algorithm {self._algo.current_slice.option_chains.count}')
            return 0.
        
        chain: OptionChain = chains[0]

        contracts_in_portfolio: List[OptionContract] = [
            contract.value for contract in chain.contracts
            if self._algo.securities[contract.value.symbol].type == SecurityType.OPTION
            and self._algo.portfolio[contract.value.symbol].invested
        ]

        delta_hedge_quantity: float = -1 * np.floor(sum([
            c.greeks.delta * self._algo.portfolio[c.symbol].quantity * self._algo.securities[c].contract_multiplier
            for c in contracts_in_portfolio
        ]))

        additional_quantity: float = delta_hedge_quantity - self._algo.portfolio[symbol].quantity
        
        return additional_quantity
#region imports
from AlgorithmImports import *
#endregion

class SymbolData():
    def __init__(
        self, 
        cot_f_data_symbol: Symbol, 
        cot_c_data_symbol: Symbol,
    ) -> None:
        
        self.cot_f_data_symbol: Symbol = cot_f_data_symbol
        self.cot_c_data_symbol: Symbol = cot_c_data_symbol

        self._call: Option = None
        self._put: Option = None
        self._call_delta: Union[None, float] = None
        self._put_delta: Union[None, float] = None

    def update_options(self, call: OptionContract, put: OptionContract) -> None:
        self._call = call
        self._put = put

    def reset_options(self) -> None:
        self._call = None
        self._put = None

    @property
    def is_ready(self) -> bool:
        return self._call or self._put

class LastDateHandler():
    _last_update_date:Dict[Symbol, datetime.date] = {}
    
    @staticmethod
    def get_last_update_date() -> Dict[Symbol, datetime.date]:
       return LastDateHandler._last_update_date

# Commitments of Traders data.
# NOTE: IMPORTANT: Data order must be ascending (datewise).
# Data source: https://commitmentsoftraders.org/cot-data/
# Data description: https://commitmentsoftraders.org/wp-content/uploads/Static/CoTData/file_key.html
class CommitmentsOfTradersFutures(PythonData):
    def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
        return SubscriptionDataSource("data.quantpedia.com/backtesting_data/futures/cot/{0}.PRN".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)

    # File example.
    # DATE   OPEN     HIGH        LOW       CLOSE     VOLUME   OI
    # ----   ----     ----        ---       -----     ------   --
    # DATE   LARGE    SPECULATOR  COMMERCIAL HEDGER   SMALL TRADER
    #        LONG     SHORT       LONG      SHORT     LONG     SHORT
    def Reader(self, config: SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseData:
        data = CommitmentsOfTradersFutures()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit(): return None
        split = line.split(',')
        
        # Prevent lookahead bias.
        data.Time = datetime.strptime(split[0], "%Y%m%d") + timedelta(days=1)
        
        data['LARGE_SPECULATOR_LONG'] = int(split[1])
        data['LARGE_SPECULATOR_SHORT'] = int(split[2])
        data['COMMERCIAL_HEDGER_LONG'] = int(split[3])
        data['COMMERCIAL_HEDGER_SHORT'] = int(split[4])
        data['SMALL_TRADER_LONG'] = int(split[5])
        data['SMALL_TRADER_SHORT'] = int(split[6])
        data['OPEN_INTEREST'] = int(split[1]) + int(split[2]) + int(split[3]) + int(split[4]) + int(split[5]) + int(split[6])
        data.Value = int(split[1])

        if config.Symbol.Value not in LastDateHandler._last_update_date:
            LastDateHandler._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()
        if data.Time.date() > LastDateHandler._last_update_date[config.Symbol.Value]:
            LastDateHandler._last_update_date[config.Symbol.Value] = data.Time.date()

        return data

# Commitments of Traders data.
# NOTE: IMPORTANT: Data order must be ascending (datewise).
# Data source: https://commitmentsoftraders.org/cot-data/
# Data description: https://commitmentsoftraders.org/wp-content/uploads/Static/CoTData/file_key.html
class CommitmentsOfTradersCombined(PythonData):
    def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
        return SubscriptionDataSource("data.quantpedia.com/backtesting_data/futures/cot/options_futures_combined/{0}.PRN".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)

    # File example.
    # DATE   OPEN     HIGH        LOW       CLOSE     VOLUME   OI
    # ----   ----     ----        ---       -----     ------   --
    # DATE   LARGE    SPECULATOR  COMMERCIAL HEDGER   SMALL TRADER
    #        LONG     SHORT       LONG      SHORT     LONG     SHORT
    def Reader(self, config: SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseData:
        data = CommitmentsOfTradersCombined()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit(): return None
        split = line.split(',')
        
        # Prevent lookahead bias.
        data.Time = datetime.strptime(split[0], "%Y%m%d") + timedelta(days=1)
        
        data['LARGE_SPECULATOR_LONG'] = int(split[1])
        data['LARGE_SPECULATOR_SHORT'] = int(split[2])
        data['COMMERCIAL_HEDGER_LONG'] = int(split[3])
        data['COMMERCIAL_HEDGER_SHORT'] = int(split[4])
        data['SMALL_TRADER_LONG'] = int(split[5])
        data['SMALL_TRADER_SHORT'] = int(split[6])
        data['open_interest'] = int(split[1]) + int(split[2]) + int(split[3]) + int(split[4]) + int(split[5]) + int(split[6])
        data.Value = int(split[1])

        if config.Symbol.Value not in LastDateHandler._last_update_date:
            LastDateHandler._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()
        if data.Time.date() > LastDateHandler._last_update_date[config.Symbol.Value]:
            LastDateHandler._last_update_date[config.Symbol.Value] = data.Time.date()

        return data
# region imports
from AlgorithmImports import *
from abc import abstractmethod, ABC
# endregion

class DeltaHedgeModule(ABC):
    def __init__(
        self, 
        algo: QCAlgorithm, 
    ) -> None:

        self._algo: QCAlgorithm = algo

    @abstractmethod
    def delta_hedge(self, symbol: Symbol) -> None:
        ...

    @abstractmethod
    def _get_delta_hedge_quantity(self, symbol: Symbol) -> float:
        ...
# https://quantpedia.com/strategies/hedging-pressure-predicts-commodity-option-returns/
# 
# The investment universe consists of 24 commodity options and their underlying futures contracts. A complete list of traded commodity options is described in 
# Appendix A. Apply the following filters to the options universe. First, include only the standard option contracts with the same maturity months as the 
# underlying futures contracts. Second, eliminate option prices that violate no-arbitrage boundary conditions. Third, discard options that have Black's (1976) 
# implied volatilities less than 1%. Last, remove options in the last week before expiration. Using the data from the Commitments of Traders (COT) reports, 
# infer hedgers' positions in options in week t that are long the underlying commodity i by taking the futures-and-option-combined long positions of commercial 
# traders and subtracting their futures-only long position. Analogously, infer hedgers' positions in options that are short the underlying commodity. Finally, 
# compute the option interest as the futures-and-option-combined open interest minus futures-only open interest. For each commodity i in week t, calculate the 
# hedging pressure in options (HPO) as the net short option position of the hedgers divided by the option open interest (see equation 1). At the end of each 
# Tuesday, rank the 24 commodities in ascending order based on their HPO and form five equal-weighted quantile portfolios consisting of 5, 5, 4, 5, 5 commodities, 
# respectively. The trading rule for the strategy is as follows: at the end of Tuesday of week t, buy (sell) the delta-hedged OTM calls and sell (buy) the 
# delta-hedged OTM puts for commodities in the highest (lowest) quintile (see equation 5). Categorize an option as out-of-the-money (OTM) if its absolute delta 
# is between 0.125 and 0.375 and recompute the option's delta weekly, every Tuesday (see equations 2,3,4). Hold the position for four weeks and then rebalance.
#
# Implementation changes:
#   - The investment universe consists of 1 commodity options and their underlying commodity ETFs.
#   - Rebalance is done on monthly basis with one month holding period.
#   - Only 50% of portfolio value is traded.

# region imports
from AlgorithmImports import *
import data_tools
from delta_hedge.etf_options_delta_hedge_module import ETFOptionsDeltaHedgeModule
# endregion

class HedgingPressurePredictsCommodityOptionReturns(QCAlgorithm):

    def initialize(self) -> None:
        self.set_start_date(2010, 1, 1)
        self.set_cash(1_000_000)

        symbols: List[str] = [
            ('WEAT', 'QW', 'UW'),	    # Teucrium Wheat Fund
            ('CORN', 'QC', 'UC'),	    # Teucrium Corn Fund
            ('SOYB', 'QS', 'US'),	    # Teucrium Soybean Fund
            ('CANE', 'QSB', 'USB'),	    # Teucrium Sugar Fund
            ('CPER', 'QHG', 'UHG'),	    # United States Copper Index Fund
            ('USO', 'QCL', 'UCL'),	    # United States Oil Fund LP
            ('UNG', 'QNG', 'UNG'),	    # United States Natural Gas Fund LP
            ('UGA', 'QRB', 'URB'),	    # United States Gasoline Fund LP
            ('GLD', 'QGC', 'UGC'),	    # SPDR Gold Shares
            ('PALL', 'QPA', 'UPA'),	    # abrdn Physical Palladium Shares ETF
            ('PPLT', 'QPL', 'UPL'),	    # abrdn Physical Platinum Shares ETF
            # ('IAU',),	                # iShares Gold Trust
            # ('BNO',),	                # United States Brent Oil Fund LP
        ]

        seeder: FuncSecuritySeeder = FuncSecuritySeeder(self.get_last_known_prices)
        self.set_security_initializer(lambda security: seeder.seed_security(security))
        self.universe_settings.data_normalization_mode = DataNormalizationMode.RAW
        self.settings.minimum_order_margin_portfolio_percentage = 0
        self.settings.daily_precise_end_time = False

        # delta hedge module
        self._delta_hedge_module: ETFOptionsDeltaHedgeModule = ETFOptionsDeltaHedgeModule(self)

        leverage: int = 5
        self._traded_count: int = 2
        self._traded_percentage: float = 0.5

        self._min_expiry: int = 30
        self._max_expiry: int = 3 * 30

        self._min_delta: float = 0.125
        self._max_delta: float = 0.375

        self._symbol_data: Dict[Symbol, data_tools.SymbolData] = {}
        
        for ticker, cot_f_symbol, cot_c_symbol in symbols:
            # COT futures data
            cot_f_symbol: Symbol = self.add_data(data_tools.CommitmentsOfTradersFutures, cot_f_symbol, Resolution.DAILY).symbol

            # COT option and futures combined data
            cot_c_symbol: Symbol = self.add_data(data_tools.CommitmentsOfTradersCombined, cot_c_symbol, Resolution.DAILY).symbol

            symbol: Security = self.add_equity(ticker, Resolution.DAILY, leverage=leverage).symbol
            
            # QC futures
            self._symbol_data[symbol] = data_tools.SymbolData(cot_f_symbol, cot_c_symbol)

        self._selection_flag: bool = False
        self._rebalance_flag: bool = False

        self.schedule.on(
            self.date_rules.every(DayOfWeek.WEDNESDAY),
            self.time_rules.at(0, 0), self._selection
        )
        self._recent_month: int = -1
        
        self._HPO: Dict[Symbol, float] = {}
        market: Symbol = self.add_equity('SPY', Resolution.DAILY).symbol
        self.schedule.on(
            self.date_rules.every_day(market),
            self.time_rules.at(10, 0),
            self._delta_hedge
        )

    def _delta_hedge(self) -> None:
        for etf_symbol in self._symbol_data:
            self._delta_hedge_module.delta_hedge(etf_symbol)

    def on_data(self, slice: Slice) -> None:
        if self._rebalance_flag:
            self._rebalance_flag = False

            if len(self._HPO) < self._traded_count:
                self._HPO.clear()
                return

            sorted_by_HPO: List[Symbol] = sorted(self._HPO, key=self._HPO.get, reverse=True)
            long: List[Symbol] = sorted_by_HPO[:self._traded_count]
            short: List[Symbol] = sorted_by_HPO[-self._traded_count:]
            
            self._HPO.clear()

            if self.portfolio.invested:
                return

            for symbol in long + short:
                underlying_price: float = self.securities[symbol].ask_price

                if self._symbol_data[symbol]._call:
                    options_quantity: int = self.portfolio.total_portfolio_value * self._traded_percentage // self._traded_count // (self.securities[self._symbol_data[symbol]._call.symbol].symbol_properties.contract_multiplier * underlying_price)
                    if symbol in long:
                        self.buy(self._symbol_data[symbol]._call.symbol, options_quantity)
                    else:
                        self.sell(self._symbol_data[symbol]._call.symbol, options_quantity)

                if self._symbol_data[symbol]._put:
                    options_quantity: int = self.portfolio.total_portfolio_value * self._traded_percentage // self._traded_count // (self.securities[self._symbol_data[symbol]._call.symbol].symbol_properties.contract_multiplier * underlying_price)
                    if symbol in long:
                        self.sell(self._symbol_data[symbol]._put.symbol, options_quantity)
                    else:
                        self.buy(self._symbol_data[symbol]._put.symbol, options_quantity)

            self._symbol_data[symbol].reset_options()

        if not self._selection_flag:
            return
        self._selection_flag = False
        
        self.liquidate()
        for symbol, symbol_data in self._symbol_data.items():
            if symbol_data.is_ready:
                self.remove_option_contract(symbol_data._call.symbol)
                self.remove_option_contract(symbol_data._put.symbol)
        
        custom_data_last_update_date: Dict[str, datetime.date] = data_tools.LastDateHandler.get_last_update_date()

        for symbol, symbol_data in self._symbol_data.items():
            if any([self.securities[cot_symbol].get_last_data() and self.time.date() > custom_data_last_update_date[cot_symbol] for cot_symbol in [symbol_data.cot_c_data_symbol.value, symbol_data.cot_f_data_symbol.value]]):
                self.liquidate()
                self.log(f'There is no more COT data for {symbol}')
                return

            cot_c_data = self.securities[symbol_data.cot_c_data_symbol].get_last_data()
            cot_f_data = self.securities[symbol_data.cot_f_data_symbol].get_last_data()

            if cot_c_data and cot_f_data:
                # calculate hedging pressure
                long_options_position: float = cot_c_data.Commercial_Hedger_Long - cot_f_data.Commercial_Hedger_Long
                short_options_position: float = cot_c_data.Commercial_Hedger_Short - cot_f_data.Commercial_Hedger_Short
                open_interest: float =  cot_c_data.Open_Interest - cot_f_data.Open_Interest

                if long_options_position != 0 and short_options_position != 0:
                    hpo: float = (short_options_position - long_options_position) / open_interest

                    chain: OptionChain = self.option_chain(symbol)
                    # chain: OptionChain = slice.option_chains.get(symbol_data.option_symbol)
                    if not chain:
                        self.log(f'No option chain found for {symbol_data.option_symbol}')
                    else:
                        contracts: List[OptionContract] = list(map(lambda kvp: kvp.value, chain.contracts))
                        
                        # get contracts within delta range
                        otm_call_contracts: List[OptionContract] = [
                            c for c in contracts
                            if c.right == OptionRight.CALL 
                            and c.underlying_last_price <= c.strike
                            and self._min_delta <= abs(c.greeks.delta) <= self._max_delta
                        ]

                        otm_put_contracts: List[OptionContract] = [
                            c for c in contracts
                            if c.right == OptionRight.PUT 
                            and c.underlying_last_price >= c.strike
                            and self._min_delta <= abs(c.greeks.delta) <= self._max_delta
                        ]

                        if len(otm_call_contracts) != 0 and len(otm_put_contracts) != 0:
                            otm_call: OptionContract = sorted(otm_call_contracts, \
                                key = lambda x: x.expiry)[0]

                            otm_put: OptionContract = sorted(otm_put_contracts, \
                                key = lambda x: x.expiry)[0]
                            
                            symbol_data.update_options(
                                self.add_option_contract(otm_call.symbol), 
                                self.add_option_contract(otm_put.symbol)
                            )

                        if symbol_data.is_ready:
                            self._HPO[symbol] = hpo
                        else:
                            self.log(f'Not enought calls (len: {len(otm_call_contracts)}) or puts (len: {len(otm_put_contracts)}) found for {symbol}')

                self._rebalance_flag = True

    def _selection(self) -> None:
        # self._selection_flag = True
        if self._recent_month != self.time.month:
            self._selection_flag = True
            self._recent_month = self.time.month

# Custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))