Overall Statistics
Total Orders
189
Average Win
2.69%
Average Loss
-1.76%
Compounding Annual Return
253.783%
Drawdown
16.300%
Expectancy
0.263
Start Equity
100000
End Equity
146850.7
Net Profit
46.851%
Sharpe Ratio
3.609
Sortino Ratio
5.63
Probabilistic Sharpe Ratio
77.220%
Loss Rate
50%
Win Rate
50%
Profit-Loss Ratio
1.53
Alpha
1.324
Beta
0.807
Annual Standard Deviation
0.443
Annual Variance
0.196
Information Ratio
2.913
Tracking Error
0.432
Treynor Ratio
1.979
Total Fees
$466.83
Estimated Strategy Capacity
$360000000.00
Lowest Capacity Asset
CL Z4A01OQ8Q37L
Portfolio Turnover
120.53%
Drawdown Recovery
60
# region imports
from AlgorithmImports import *
# endregion


class VolumeSpikesInOilFuturesAlgorithm(QCAlgorithm):

    # Define some parameters
    SPIKE_MULTIPLE_ETH = 3  # x weekly-average minute volume, outside regular hours
    SPIKE_MULTIPLE_RTH = 6  # x weekly-average minute volume, during regular hours
    REFERENCE_MULTIPLE = 2  # x the same minute's volume on the busiest reference day
    TAKE_PROFIT = 0.1       # Close positions at +10% profit.
    SIGNAL_COOLDOWN = timedelta(hours=2)  # Don't enter new positions more frequently than this.

    def initialize(self):
        self.set_start_date(2026, 3, 20)
        self.set_end_date(2026, 7, 8)
        self.set_cash(100_000)
        # Add the universe of Oil Future contracts.
        self._contracts = []
        future = self.add_future(Futures.Energy.CRUDE_OIL_WTI, fill_forward=False, extended_market_hours=True)
        future.set_filter(0, 60)
        # Add the SPY ETF.
        self._spy = self.add_equity("SPY")
        # Add some members to assist with the trading logic.
        self._invested_contract = None
        self._trading_armed = False
        self._last_signal_time = datetime.min
        self._max_average_volume = None
        self._bars_of_most_liquid_contract = None
        # Add Scheduled Events to reset the daily volume tally, to refresh the weekly snapshot, and to arm trading.
        self.schedule.on(self.date_rules.every_day(future), self.time_rules.at(17, 0), self._reset_total_volume)
        self.schedule.on(self.date_rules.week_end(future), self.time_rules.at(17, 0), self._reset_weekly_snapshot)
        self.schedule.on(self.date_rules.week_start(future), self.time_rules.at(19, 0), self._arm_trading)
        # Add a warm-up period to prime the factors and historical bars.
        self.set_warm_up(timedelta(14))

    def _reset_total_volume(self):
        for contract in self._contracts:
            contract.total_volume_today = 0

    def _arm_trading(self):
        self._trading_armed = True

    def _reset_weekly_snapshot(self):
        # Get the max of each contract's average minute volume during RTH.
        self._max_average_volume = max(sum(c.volume_in_rth) / len(c.volume_in_rth) for c in self._contracts)
        # Get the minute bars of the contract that traded the most this week.
        self._bars_of_most_liquid_contract = max(self._contracts, key=lambda c: sum(bar.volume for bar in c.bars)).bars
        for contract in self._contracts:
            contract.volume_in_rth = []
            contract.bars = []
        self._trading_armed = False
            
    def on_securities_changed(self, changes):
        # As contracts enter the universe, use duck-typing to attach some custom members.
        for security in changes.added_securities:
            if security.type == SecurityType.EQUITY or security.symbol.is_canonical():
                continue
            security.total_volume_today = 0
            security.volume_in_rth = []
            security.bars = []
            self._contracts.append(security)
        for security in changes.removed_securities:
            self._contracts.remove(security)

    def on_data(self, data: Slice):
        # Apply the take-profit logic if we're holding a contract.
        if self._invested_contract:
            holding = self._invested_contract.holdings
            price = self._invested_contract.close
            if (holding.is_long and price > (1+self.TAKE_PROFIT) * holding.average_price or 
                holding.is_short and price < (1-self.TAKE_PROFIT) * holding.average_price):
                self.liquidate(tag="TAKE PROFIT")
                self._invested_contract = None
        # Record the volume and minute-bar of each contract.
        is_rth = self.is_market_open(self._spy)
        tradable_contracts = []
        for contract in self._contracts:
            bar = data.bars.get(contract)
            if not bar:
                continue
            contract.total_volume_today += bar.volume
            if is_rth:
                contract.volume_in_rth.append(bar.volume)
            contract.bars.append(bar)
            tradable_contracts.append(contract)
        # Check if we should scan for a new trade.
        if self.is_warming_up or not self._trading_armed or not tradable_contracts:
            return
        # Get the contract with the most volume today.
        contract = max(tradable_contracts, key=lambda contract: contract.total_volume_today)
        # Check if the current volume is abnormally high by comparing against last week's trading activity:
        #  - the max average minute volume in regular trading hours
        #  - the max volume of the most liquid contract during the same time (hour:minute combos)
        bar = data.bars.get(contract)
        if (bar.volume <= self._max_average_volume * (self.SPIKE_MULTIPLE_RTH if is_rth else self.SPIKE_MULTIPLE_ETH) or
            bar.volume <= self.REFERENCE_MULTIPLE * max([b.volume for b in self._bars_of_most_liquid_contract if b.end_time.time() == self.time.time()])):
            return
        # Check if the signal cool-down period has ended.
        if self.time <= self._last_signal_time + self.SIGNAL_COOLDOWN:
            return
        self._last_signal_time = self.time
        # Open a one-lot position (direction +1 long / -1 short), flipping any opposite one.
        # Green bar -> long, red bar -> short.
        direction = 1 if bar.close > bar.open else -1
        if not self._invested_contract:
            self.market_order(contract, direction)
            self._invested_contract = contract
            return
        holding = self._invested_contract.holdings
        if holding.is_short if direction == 1 else holding.is_long:
            self.liquidate()
            self.market_order(contract, direction)
            self._invested_contract = contract