Overall Statistics
Total Trades
0
Average Win
0%
Average Loss
0%
Compounding Annual Return
0%
Drawdown
0%
Expectancy
0
Net Profit
0%
Sharpe Ratio
0
Probabilistic Sharpe Ratio
0%
Loss Rate
0%
Win Rate
0%
Profit-Loss Ratio
0
Alpha
0
Beta
0
Annual Standard Deviation
0
Annual Variance
0
Information Ratio
-2.815
Tracking Error
0.12
Treynor Ratio
0
Total Fees
$0.00
Estimated Strategy Capacity
$0
Lowest Capacity Asset
Portfolio Turnover
0%
# region imports
from AlgorithmImports import *
from collections import deque
# endregion

class VolumeProfileComparisonAlgorithm(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2023, 10, 1)
        self.SetEndDate(2023, 12, 31)
        self.SetCash(100_000)

        res = Resolution.Minute
        self.symbol = self.AddEquity("AAPL", res).Symbol
        self.previous_day = None
        self.bar_count = 0

        if False:
            # Create an Market Profile indicator for the symbol with Volume Profile (VOL) mode
            # https://github.com/QuantConnect/Lean/blob/master/Indicators/MarketProfile.cs#L39
            # VolumeProfile(string name, int period, decimal valueAreaVolumePercentage = 0.70m, decimal priceRangeRoundOff = 0.05m)
            # period: The period of the VP
            # valueAreaVolumePercentage: The percentage of volume contained in the value area
            # priceRangeRoundOff: How many digits you want to round and the precision. i.e 0.01 round to two digits exactly.
            # resolution: The resolution
            # selector: Selects a value from the BaseData to send into the indicator, if null defaults to casting the input value to a TradeBar
            self.vp = self.VP(
                symbol=self.symbol,
                period=380, # for plotting the full day of open market hours at 15:50
                valueAreaVolumePercentage=0.85,
                priceRangeRoundOff=0.01,
                resolution=res)

        else:
            # Initialize MyVolumeProfile indicator
            self.vp = MyVolumeProfile (
                name="MP",
                period=380, # for plotting the full day of open market hours at 15:50
                valueAreaVolumePercentage=0.85,
                priceRangeRoundOff=0.01)
            # Register the indicator for automatic updates
            self.RegisterIndicator(self.symbol, self.vp, res)

    def OnData(self, slice):
        '''
        Profile High: The highest price level within the volume profile.
        Profile Low: The lowest price level within the volume profile.
        Point of Control Price (POCPrice): The price level with the highest trading volume.
        Value Area High: The upper price level of the value area.
        Value Area Low: The lower price level of the value area.
        Point of Control Volume (POCVolume): The volume at the Point of Control price level.
        Value Area Volume: The total volume within the value area, which is the range where a specified percentage (e.g., 70%) of the total volume is traded.
        Num Buckets: The number of buckets resulting from priceRangeRoundOff.
        '''
        if self.Time.day != self.previous_day:
            self.previous_day = self.Time.day
            self.daily_bar_count = 0

        self.bar_count += 1

        if not slice.ContainsKey(self.symbol) or (self.Time.hour == 0 and self.Time.minute == 0):
            return

        close = slice[self.symbol].Close

        # Check for plotting time
        if (self.Time.hour < 16 and self.Time.minute % 5 == 0) or (self.Time.hour == 15 and self.Time.minute == 59):
            if self.vp.IsReady:
                #self.Debug(f'{self.Time} {self.bar_count}')
                self.Plot("VP1", "close", close)
                self.Plot("VP1", "vp", self.vp.Current.Value)
                self.Plot("VP1", "profilehigh", self.vp.ProfileHigh)
                self.Plot("VP1", "profilelow", self.vp.ProfileLow)
                #self.Plot("VP1", "pocprice", self.vp.POCPrice)
                self.Plot("VP1", "valueareahigh", self.vp.ValueAreaHigh)
                self.Plot("VP1", "valuearealow", self.vp.ValueAreaLow)
                self.Plot("VP2", "pocvolume", self.vp.POCVolume)
                self.Plot("VP2", "valueareavolume", self.vp.ValueAreaVolume)
                if hasattr(self.vp, 'Time'):
                    self.Plot("VP0", "hour", self.vp.Time.hour)
                if hasattr(self.vp, 'NumBuckets'):
                    self.Plot("VP3", "number of buckets", self.vp.NumBuckets)

class MyVolumeProfile(PythonIndicator):
    """
    Represents a Volume Profile Indicator.

    The Volume Profile indicator displays trading activity over a specified period, showing the volume at different price levels. 
    This indicator is useful for identifying support and resistance levels and understanding where significant trading activity has occurred.

    Args:
        name (str): The name of the indicator.
        period (int): The lookback period for calculating the volume profile.
        valueAreaVolumePercentage (float): Percentage of total volume contained in the Value Area.
        priceRangeRoundOff (float): Precision for rounding off price levels.
    """
    def __init__(self, name, period, valueAreaVolumePercentage=0.70, priceRangeRoundOff=0.05):
        super().__init__(name)
        self.period = period
        self.valueAreaVolumePercentage = valueAreaVolumePercentage # Percentage of total volume contained in the ValueArea
        self.priceRangeRoundOff = 1 / priceRangeRoundOff # The range of roundoff to the prices. i.e two decimal places, three decimal places
        self.is_ready = False
        self.Value = 0
        self.Time = datetime.min
        self.volumePerPrice = {} # Buckets with Close values and Volume values in the given period of time
        self.oldDataPoints = deque(maxlen=period) # Rolling window filled with tuple of (close, volume)
        self.ProfileHigh = 0  # Highest price level in the volume profile
        self.ProfileLow = 0   # Lowest price level in the volume profile
        self.ValueAreaHigh = 0  # Upper boundary of the value area
        self.ValueAreaLow = 0   # Lower boundary of the value area
        self.ValueAreaVolume = 0  # Total volume within the value area
        self.POCPrice = 0  # Price level with the highest volume (Point of Control)
        self.POCVolume = 0  # Volume at the Point of Control
        self.NumBuckets = 0  # Number of distinct price levels considered

    def Update(self, input: TradeBar) -> bool:
        if input is None:
            return False

        # Get time, close, volume
        self.Time = input.Time
        rounded_close = self._round(input.Close)

        # Update rolling window
        self.oldDataPoints.append((rounded_close, input.Volume))

        # Update buckets with key=Close and value=Volume
        if rounded_close not in self.volumePerPrice:
            self.volumePerPrice[rounded_close] = input.Volume
        else:
            self.volumePerPrice[rounded_close] += input.Volume

        # Remove old data from bucket and rolling window
        if len(self.oldDataPoints) == self.period:
            self.is_ready = True
            # Remove and return the leftmost (or first) item from rolling window
            removed_close, removed_volume = self.oldDataPoints.popleft()
            # Remove old data point's volume
            if removed_close in self.volumePerPrice:
                self.volumePerPrice[removed_close] -= removed_volume
                # Remove old data point, if no volume left
                if self.volumePerPrice[removed_close] <= 0:
                    del self.volumePerPrice[removed_close]

        # Update derived values
        if self.is_ready and len(self.volumePerPrice) > 0:
            self.UpdatePOC()
            self.UpdateValueArea()

        return self.is_ready

    def _round(self, value: float) -> float:
        # Round the value to the specified precision
        return round(value * self.priceRangeRoundOff) / self.priceRangeRoundOff

    def UpdatePOC(self):
        # Determine the Point of Control (POC)
        poc_price, poc_volume = max(self.volumePerPrice.items(), key=lambda x: x[1])
        self.Value = poc_price
        self.POCPrice = poc_price
        self.POCVolume = poc_volume

        # Determine Profile High and Low
        self.ProfileHigh = max(self.volumePerPrice.keys())
        self.ProfileLow = min(self.volumePerPrice.keys())

        # Determine the number of price buckets for control purposes
        self.NumBuckets = len(self.volumePerPrice)

    def UpdateValueArea(self):
        # Calculate the value area and its volume
        total_volume = sum(volume for _, volume in self.oldDataPoints)
        border_area_volume_target = total_volume * (1 - self.valueAreaVolumePercentage) / 2

        # Create list of (Close, Volume) sorted by Volume
        sorted_volume_data = sorted(self.volumePerPrice.items(), key=lambda x: x[0], reverse=False)

        # Determine ValueAreaLow price
        current_volume = 0
        value_area_prices = []
        for price, volume in sorted_volume_data:
            if current_volume + volume > border_area_volume_target:
                break
            value_area_prices.append(price)
            current_volume += volume
        self.ValueAreaLow = max(value_area_prices) if len(value_area_prices) > 0 else self.ProfileLow
        border_area_volume_low = current_volume

        # Determine ValueAreaHigh price
        current_volume = 0
        value_area_prices = []
        for price, volume in reversed(sorted_volume_data):
            if current_volume + volume > border_area_volume_target:
                break
            value_area_prices.append(price)
            current_volume += volume
        self.ValueAreaHigh = min(value_area_prices) if len(value_area_prices) > 0 else self.ProfileHigh
        border_area_volume_high = current_volume

        # Calculate Value Area Volume
        self.ValueAreaVolume = total_volume - border_area_volume_low - border_area_volume_high

    @property
    def IsReady(self) -> bool:
        return self.is_ready