Learning Hub Strategy Development Lesson 18
Phase 3 — Lesson 1 of 8
Hub
Phase 3 · Strategy Development · Lesson 18

Strategy Architecture
Rules Engine & State Machine

Learn how to structure a trading strategy as clean, testable Python — signal types, state machine design, and the StrategyBase class every serious system needs.

~45 min
~120 lines Python
Phase 3 · Lesson 1 of 8
Phase 3 Progress13%

🧠 Welcome to Phase 3 — Strategy Development

You have live data and indicators. Now we turn them into complete, backtestable, deployable strategies. Phase 3 covers architecture, backtesting, sizing, metrics, and portfolio construction.

Three Types of Signals

Every strategy produces three distinct signal types. Mixing them up is the most common architecture mistake beginners make.

TypePurposeExample
Entry SignalWhen to open a new positionMACD crossover + RSI > 55
Exit SignalWhen to close an existing positionOpposite crossover OR stop hit
Filter SignalBlocks trades in unfavourable conditionsPrice below SMA200, VIX > 25, news window
Design rule

A strategy should only produce an entry signal when all filters pass. Filters reject noise before the entry logic even runs — this alone cuts false signals by 40–60% in most strategies.

State Machine Design

A strategy is always in one of three states. Transitions happen when signals fire. This mental model prevents the classic bug of entering a new trade while already in one.

FLAT
No position
entry LONG
LONG
Holding long
exit signal / SL
FLAT
FLAT
entry SHORT
SHORT
Holding short
exit signal / SL
FLAT
PYTHONstrategy_base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Literal
import pandas as pd

State = Literal["FLAT", "LONG", "SHORT"]

@dataclass
class Signal:
    direction : Literal["LONG", "SHORT", "EXIT", "NONE"]
    reason    : str = ""
    strength  : float = 1.0   # 0.0–1.0 confidence

SIGNAL_NONE  = Signal("NONE")
SIGNAL_EXIT  = Signal("EXIT")

class StrategyBase(ABC):
    """
    Base class for all strategies.
    Subclasses implement generate_signal() only.
    State tracking, trade logging handled here.
    """
    def __init__(self, name: str):
        self.name   = name
        self.state  : State = "FLAT"
        self.trades : list[dict] = []
        self._entry_price : float | None = None
        self._entry_time  = None

    @abstractmethod
    def generate_signal(self, df: pd.DataFrame) -> Signal:
        """Given enriched OHLCV df, return the current signal."""
        ...

    def on_candle(self, df: pd.DataFrame) -> Signal:
        """Call this each time a new candle closes."""
        sig = self.generate_signal(df)
        row = df.iloc[-1]

        if self.state == "FLAT":
            if sig.direction in ("LONG", "SHORT"):
                self.state        = sig.direction
                self._entry_price = row["close"]
                self._entry_time  = row.name

        elif self.state in ("LONG", "SHORT"):
            if sig.direction == "EXIT" or (
                sig.direction != "NONE" and
                sig.direction != self.state
            ):
                self.trades.append({
                    "entry_time"  : self._entry_time,
                    "exit_time"   : row.name,
                    "direction"   : self.state,
                    "entry_price" : self._entry_price,
                    "exit_price"  : row["close"],
                    "pnl_pts"     : (
                        row["close"] - self._entry_price
                        if self.state == "LONG"
                        else self._entry_price - row["close"]
                    ),
                    "reason": sig.reason,
                })
                self.state = "FLAT"
                self._entry_price = None

        return sig

    def trade_log(self) -> pd.DataFrame:
        return pd.DataFrame(self.trades)

EMA Crossover Strategy — Concrete Example

The cleanest way to learn the pattern is to implement a simple strategy. Here's a complete EMA-crossover strategy that inherits StrategyBase.

PYTHONstrategy_ema_cross.py
from strategy_base import StrategyBase, Signal, SIGNAL_NONE, SIGNAL_EXIT
from indicators   import ema, sma, rsi

class EMACrossStrategy(StrategyBase):
    def __init__(self, fast=9, slow=21, rsi_period=14):
        super().__init__(name=f"EMA{fast}x{slow}")
        self.fast       = fast
        self.slow       = slow
        self.rsi_period = rsi_period

    def generate_signal(self, df):
        if len(df) < self.slow + 5:
            return SIGNAL_NONE

        c     = df["close"]
        fast  = ema(c, self.fast)
        slow  = ema(c, self.slow)
        r     = rsi(c, self.rsi_period)
        trend = sma(c, 200)

        # ── Filters ──────────────────────────────────────────────
        above_trend = c.iloc[-1] > trend.iloc[-1]
        below_trend = c.iloc[-1] < trend.iloc[-1]

        # ── Entry signals ────────────────────────────────────────
        bull_cross = (fast.iloc[-2] < slow.iloc[-2]) and \
                     (fast.iloc[-1] > slow.iloc[-1])
        bear_cross = (fast.iloc[-2] > slow.iloc[-2]) and \
                     (fast.iloc[-1] < slow.iloc[-1])

        # ── Exit signals ─────────────────────────────────────────
        if self.state == "LONG"  and bear_cross:
            return Signal("EXIT", "bear_cross")
        if self.state == "SHORT" and bull_cross:
            return Signal("EXIT", "bull_cross")

        # ── Entry (filter first) ──────────────────────────────────
        if bull_cross and above_trend and r.iloc[-1] > 50:
            return Signal("LONG",  "bull_cross+trend", strength=r.iloc[-1]/100)
        if bear_cross and below_trend and r.iloc[-1] < 50:
            return Signal("SHORT", "bear_cross+trend", strength=(100-r.iloc[-1])/100)

        return SIGNAL_NONE
Key architectural benefits

Because StrategyBase handles all state transitions and trade logging, EMACrossStrategy contains zero boilerplate. Writing a new strategy only means implementing generate_signal() — typically 10–20 lines.

Vectorised vs Event-Driven

There are two modes to run a strategy. You'll use both — vectorised for backtesting (fast), event-driven for live trading (real-time).

ModeHow it worksWhen to useSpeed
VectorisedApply signals across entire DataFrame at onceBacktesting over years of dataMilliseconds
Event-drivenFeed candles one-by-one via on_candle()Live trading / paper tradingReal-time
PYTHONrun modes
# ── Event-driven (live) ───────────────────────────────────
strategy = EMACrossStrategy(fast=9, slow=21)

def on_candle_close(candle):
    df = get_buffer(candle["token"])   # enriched DataFrame
    sig = strategy.on_candle(df)
    if sig.direction in ("LONG", "SHORT"):
        place_order(sig)

# ── Vectorised (backtest) ─────────────────────────────────
from backtest_engine import BacktestEngine   # ← built in L19

engine = BacktestEngine(strategy=EMACrossStrategy())
result = engine.run(df_historical)
print(result.summary())

Build Your Own Strategy

Using StrategyBase, implement a SupertrendStrategy that:

  • Enters LONG when Supertrend flips from -1 to +1 AND price > VWAP
  • Enters SHORT when Supertrend flips from +1 to -1 AND price < VWAP
  • Exits when Supertrend flips back to the opposite direction

You only need to implement one method: generate_signal(self, df). State management is handled by the base class.