Building a Halloween Strategy Indicator in Indie®

We use the Halloween Strategy as a hands-on example to explore Indie’s core tools — from plot backgrounds and Buy/Sell labels to custom parameters and performance stats.

Our Head of Design learned Indie in two days — with MCP, a few (okay, ten) quick syncs with the Indie team, and the docs. This guide is what came out of it: the Halloween Effect Indicator, and the first steps to building your own.

Step 1: Understanding the Halloween Strategy Idea

A Bit of History

The Halloween Strategy is a classic seasonality pattern built on one long-standing observation: stock markets tend to perform better from October 31 (Halloween) to May 1 than during the summer months.

It’s as simple as a pumpkin — buy on Halloween, sell on May 1, then spend the summer doing literally anything else. Despite its simplicity, the pattern still shows up across markets. Research finds the “Halloween Effect” in 36 out of 37 developed and emerging economies.

How Should Our Indicator Look?

Before writing a single line of code, let’s decide what we want to see on the chart:

  • Season Shading: instantly show when we’re in-market (winter) vs. off-market (summer). Think orange for the pumpkin season, navy for the beach break.
  • Entry and Exit Signals: clear entry on Oct 31 and exit on May 1.
  • Performance Statistics: key metrics — total P&L, win rate, max drawdown — to check if the pumpkin magic actually works.
  • Date Flexibility: want to test Nov 1 instead of Oct 31? You’ll need parameters to tweak and re-run fast.

Step 2: Creating the Foundation and Adding Season Shading

Let's start by creating the basic indicator structure and adding visual period separation. In Indie, any indicator begins with the @indicator decorator and a class inheriting from MainContext:

# indie:lang_version = 5from indie import indicator, color, plot, MainContext, param@indicator('🎃 Halloween Strategy', overlay_main_pane=True)# @plot.background decorators define background colors for different periods@plot.background(color=color.ORANGE(0.3), title='Winter Period Background')@plot.background(color=color.NAVY(0.3), title='Summer Period Background')class Main(MainContext):    def __init__(self):        ...    def calc(self):        ...

Key Indie Concepts:

  • @indicator — A decorator that turns a class into an indicator. The parameter overlay_main_pane=True means the indicator is drawn over the main price chart.
  • @plot.background — A decorator for defining visualization elements. We're creating two background layers: orange for winter (when holding position) and navy for summer (when resting).
  • MainContext — The base class for all indicators, providing access to market data (prices, time, volumes).
from datetime import datetimefrom indie import MutSeriesdef calc(self):    # The calc() method is called for each bar on the chart    timestamp = self.time[0]  # Get the current bar's timestamp    dt = datetime.utcfromtimestamp(timestamp)    month = dt.month    day = dt.day    # MutSeries - mutable data series, one of Indie's key structures    is_winter = MutSeries[bool].new(False)    # Logic for determining winter period    # Winter: from October 31 to May 1    if month < 5:  # January-April        is_winter[0] = True    elif month >= 5 and month < 10:  # May-September        is_winter[0] = False    elif month == 10:        if day < 31:  # October before the 31st            is_winter[0] = False        else:  # October the 31st            is_winter[0] = True    else:  # November-December        is_winter[0] = True    # Return the appropriate background color    if is_winter[0]:        return plot.Background(), plot.Background(color=color.TRANSPARENT)    return plot.Background(color=color.TRANSPARENT), plot.Background()

The MutSeries Idea

MutSeries is a mutable data series in Indie. Think of it as an array where index [0] is the current bar, [1] is the previous one, and so on. This makes it easy to work with historical data and create indicators that "remember" previous values.

Step 3: Adding Buy/Sell Labels

Now that we have beautiful colored zones, let's add buy and sell signals. For this, we need to detect period transitions:

from indie.drawings import LabelAbs, AbsolutePosition, callout_positiondef calc(self):    # ... (previous period determination code) ...        # Detect transitions between periods    is_winter_start = is_winter[0] and not is_winter[1]  # Winter start    is_winter_end = not is_winter[0] and is_winter[1]    # Winter end        if is_winter_start:        # Draw buy marker        self.chart.draw(            LabelAbs(                'Buy',                AbsolutePosition(self.time[0], self.low[0]),                bg_color=color.NAVY,                text_color=color.WHITE,                font_size=11,                callout_position=callout_position.BOTTOM_LEFT,            )        )        if is_winter_end:        # Draw sell marker        self.chart.draw(            LabelAbs(                'Sell',                AbsolutePosition(self.time[0], self.high[0]),                bg_color=color.ORANGE,                text_color=color.WHITE,                font_size=11,                callout_position=callout_position.TOP_RIGHT,            )        )

Key Non-Series Drawing Concepts in Indie:

  • LabelAbs — A label with absolute positioning on the chart. It's anchored to a specific point (time, price).
  • AbsolutePosition — Defines the exact position on the chart. We place "Buy" at the bar's low and "Sell" at the high for better visibility.
  • callout_position — The direction of the label's "tail." This small but important detail improves chart readability!

Step 4: Planning the Performance Stats

Before calculating statistics, let's think like experienced traders: which metrics are truly important for evaluating a strategy?

My Wishlist

  1. Total P&L (%) — Overall profit/loss. The main question: how much did we earn?
  2. Win Rate (%) — Percentage of profitable trades. How often does our pumpkin magic work?
  3. Number of Trades — For understanding statistical significance.
  4. Profit Factor — Ratio of total profit to total loss. Shows how many times profit exceeds losses.
  5. Sharpe Ratio — Risk-adjusted return ratio. Higher is better for risk/return balance.
  6. Maximum Drawdown — Maximum decline from peak. How painful can the worst moments be?
  7. Open P&L — Current open position. What's happening right now?

How Will We Calculate?

  • P&L: Track entry and exit prices, calculate percentage change
  • Win Rate: Keep counters for winning and total trades
  • Profit Factor: Separately sum profits and losses
  • Sharpe: Save all trade P&Ls, then calculate mean and standard deviation
  • Drawdown: Track peak equity value and current value

Step 5: Turning the Plan into Code

Now let's turn our plan into code. Add necessary variables to the constructor:

from indie import Optionaldef __init__(self):    # Trading variables    self._position = 0    self._open_price: Optional[float] = None    self._total_trades = 0    self._win_trades = 0        # For Profit Factor calculation    self._total_profit = 0.0    self._total_loss = 0.0        # For Sharpe Ratio calculation    self._trades_pnl_list: list[float] = []        # For Maximum Drawdown calculation    self._initial_equity = 100.0    self._current_equity = 100.0    self._peak_equity = 100.0    self._max_drawdown = 0.0

Now add calculation logic when opening and closing positions:

from indie.math import divide  # Safe division (protection from division by 0)def calc(self):    # ... (period determination code) ...        if is_winter_start:        if self._open_price is None:            self._open_price = self.close[0]  # Remember entry price            self._position += 1        if is_winter_end:        if self._open_price is not None:            # Calculate current trade P&L            trade_pnl_percent = (divide(self.close[0], self._open_price.value()) - 1.0) * 100.0                        # Save for Sharpe calculation            self._trades_pnl_list.append(trade_pnl_percent)                        # Update equity with compounding            self._current_equity = self._current_equity * (1.0 + trade_pnl_percent / 100.0)                        # Update Maximum Drawdown            if self._current_equity > self._peak_equity:                self._peak_equity = self._current_equity                        current_dd = divide(self._peak_equity - self._current_equity, self._peak_equity) * 100.0            if current_dd > self._max_drawdown:                self._max_drawdown = current_dd                        # Separate profitable and losing trades            if trade_pnl_percent > 0.0:                self._win_trades += 1                self._total_profit += trade_pnl_percent            elif trade_pnl_percent < 0.0:                self._total_loss += abs(trade_pnl_percent)                        self._total_trades += 1            self._open_price = None            self._position -= 1

Important Concept — Optional in Indie:

The Optional[float] type means the variable can contain either a float value or None. This helps track state — whether we have an open position or not. The .value() method extracts the value from Optional.

Step 6: Displaying Statistics on the Chart

The final touch — let's create an informative statistics panel. We'll use LabelRel for relative positioning:

from statistics import mean, stdevfrom indie.drawings import LabelRel, RelativePosition, vertical_anchor, horizontal_anchordef __init__(self):    # ... (previous code) ...        # Create label for statistics in the bottom-right corner    self._stats_label = LabelRel(        'Stats text',        position=RelativePosition(            vertical_anchor=vertical_anchor.BOTTOM,            horizontal_anchor=horizontal_anchor.RIGHT,            top_bottom_ratio=0.98,  # 98% from top (i.e., almost at the bottom)            left_right_ratio=0.98,   # 98% from left edge (i.e., almost at right)        ),        bg_color=color.BLACK,        text_color=color.WHITE,        font_size=10    )def calc(self):    # ... (all previous code) ...        # Calculate final metrics    open_pnl = 0.0    if self._position > 0 and self._open_price is not None:        open_pnl = (divide(self.close[0], self._open_price.value()) - 1.0) * 100.0        win_rate = divide(self._win_trades, self._total_trades, 0) * 100.0    total_pnl_real = self._current_equity - self._initial_equity    profit_factor = divide(self._total_profit, self._total_loss, 0)        # Sharpe Ratio    sharpe_ratio = 0.0    if len(self._trades_pnl_list) > 1:        avg_return = mean(self._trades_pnl_list)        std_return = stdev(self._trades_pnl_list)        sharpe_ratio = divide(avg_return, std_return, 0)        # Format statistics text    stats_text = (        'Total P&L: ' + str(round(total_pnl_real, 2)) + '%\n' +        'Win Rate:  ' + str(round(win_rate, 2)) + '%\n' +        'Trades:    ' + str(self._total_trades) + '\n' +        'Profit F:  ' + str(round(profit_factor, 2)) + '\n' +        'Sharpe:    ' + str(round(sharpe_ratio, 2)) + '\n' +        'Max DD:    ' + str(round(self._max_drawdown, 2)) + '%\n' +        '---\n' +        'CURRENT PERIOD\n' +        'Open P&L:  ' + str(round(open_pnl, 2)) + '%'    )        self._stats_label.text = stats_text    self.chart.draw(self._stats_label) 

Relative Positioning Concept:

LabelRel with RelativePosition allows placing elements relative to chart boundaries rather than anchoring to specific prices. This is perfect for information panels — they always stay in place regardless of chart zoom!

Bonus: Adding Flexibility Through Settings

What if we want to test the strategy with different dates? Or change font size? Indie provides an elegant solution — @param decorators:

@indicator('🎃 Halloween Strategy', overlay_main_pane=True)@param.int('winter_start_month', default=10, min=1, max=12, title='Winter Period - Start Month')@param.int('winter_start_day', default=31, min=1, max=31, title='Winter Period - Start Day')@param.int('summer_start_month', default=5, min=1, max=12, title='Summer Period - Start Month')@param.int('summer_start_day', default=1, min=1, max=31, title='Summer Period - Start Day')@param.float('initial_equity', default=100.0, min=1.0, max=1000000.0, title='Initial Equity (%)')...class Main(MainContext):    def __init__(self, initial_equity):        # Now all parameters are customizable!        ...        self._initial_equity = initial_equity        ...    def calc(self, winter_start_month, winter_start_day, summer_start_month, summer_start_day):        ...        if month < summer_start_month:        ...

@param decorators automatically create a user interface for configuring indicator parameters. Users can change dates, initial capital, and visual settings directly from the trading platform interface!

Conclusion: What We Built and What We Learned

Congrats — you’ve just gone from idea to a fully working Halloween Strategy indicator.
This one doesn’t just flash Buy/Sell signals — it measures, visualizes, and tells you if the story actually holds up.

Here’s what you picked up along the way:

  • Indicator Structure: @indicator, MainContext, and the calc() loop.
  • Data Handling: MutSeries for time series, Optional for flexible states.
  • Visualization: background zones with @plot.background, markers with LabelAbs and LabelRel.
  • Customization: @param decorators for user-editable settings.
  • Math Safety: clean calculations via indie.math.divide.
  • Positioning: when to go absolute, when to go relative.

The Halloween Strategy is a great reminder that even a simple seasonal rule can turn into a full analytical tool — when built with the right logic and structure. And Indie gives you exactly that: a way to turn trading ideas into something testable, visual, and real.

Just remember: even if it worked in the past, there are no guarantees for the future.
But now you’ve got the tools to test every myth the market ever told.

Need help? MCP, Discord, and the Indie dev team have your back:

Happy Halloween Trading 🎃

indie
halloween
strategy
how-to
indicator

Comments
Not authorized user image
No Comments yet image

Be the first to comment

Publish your first comment to unleash the wisdom of crowd.