How Do You Turn an Indicator into a Testable Trading Rule? From Observation to Hypothesis with Python

AAPL candlestick chart showing SMA 20 crossover events defined as testable trading rule conditions in Python

Phase 3 taught us how indicators are calculated.

SMA
EMA
RSI
MACD
ATR
ADX
Bollinger Bands
Stochastic

But understanding a calculation is not the same as testing a trading idea.

Phase 4 begins with the missing bridge:

indicator
    ↓
observation
    ↓
precise condition
    ↓
reproducible event
    ↓
testable hypothesis

Before we calculate a single strategy return, we need to make the rule precise enough that two programmers would implement the same experiment.

1. An Indicator Is Not a Trading Rule

Consider a 20-period simple moving average.

SMA 20

That is a calculation.

Now consider:

Price is above SMA 20.

That is an observation.

Neither one tells us exactly when to trade.

2. Observation, State, Event, and Rule Are Different

Indicator
→ SMA 20

State
→ Close > SMA 20

Event
→ Close moves
  from below SMA
  to above SMA

Trading Rule
→ specifies what to do
  when that event occurs

A state can remain true for many bars. An event describes a transition.

3. “Close Above SMA” Is a State

Monday
Close > SMA

Tuesday
Close > SMA

Wednesday
Close > SMA

The state is true on all three days. But there may have been only one transition into that state.

4. A Crossover Is an Event

Yesterday:
Close <= SMA

Today:
Close > SMA

therefore:

Cross Above Event = True

A downward crossover is the opposite:

Yesterday:
Close >= SMA

Today:
Close < SMA

5. Why Precision Matters

The sentence:

Buy when price crosses
the moving average.

still leaves too many choices.

Which price?
Open / High / Low / Close?

Which average?
SMA / EMA?

Which period?
20 / 50 / 200?

What counts as a cross?

When is the signal known?

When is the trade executed?

If these details change after we see results, we are no longer testing one fixed idea.

6. Freeze the First Rule Specification

Indicator:
SMA 20

State:
Close > SMA 20

Entry Event:
previous Close <= previous SMA 20
AND
current Close > current SMA 20

Exit Event:
previous Close >= previous SMA 20
AND
current Close < current SMA 20

Now the idea can be translated into code without interpretation.

7. Signal Time and Execution Time Must Be Separate

The crossover uses today's Close. Therefore the complete signal is known only after today's Close exists.

bar t closes
     ↓
Close(t) becomes known
     ↓
SMA(t) is finalized
     ↓
event can be evaluated

For this educational rule:

signal
→ after bar t Close

planned execution
→ bar t+1 Open

8. This Is Where Look-Ahead Bias Begins

A historical simulation becomes inconsistent if it lets a decision use information that was not yet available.

today's completed Close
used to form signal

+

same completed Close
assumed as execution price

→ timing problem

Phase 4 will repeatedly ask:

What did we know?

When did we know it?

When could we act?

9. Build the State in Python

def above_sma_state(
    close_values,
    sma_values,
):
    result = [None] * len(close_values)

    for i in range(len(close_values)):
        sma_value = sma_values[i]

        if sma_value is None:
            continue

        result[i] = (
            float(close_values[i])
            > float(sma_value)
        )

    return result
None
→ SMA does not exist yet

True
→ Close is above SMA

False
→ Close is not above SMA

10. Convert State Changes into Events

cross_above[i] = (
    current_state is True
    and previous_state is False
)

cross_below[i] = (
    current_state is False
    and previous_state is True
)
state
→ persists

event
→ transition

11. Test the Logic Before Using Market Data

The program first creates an artificial Close series. We already know where the crossings should occur.

expected Cross Above
→ indices 3 and 7

expected Cross Below
→ index 5

If Python cannot reproduce those events, the program stops before interpreting AAPL.

12. Implementation Test and Market Test Are Different

first question:

Does the code match
the written rule?

later question:

Does the written rule
have useful market behavior?

A profitable-looking result cannot rescue incorrect signal logic.

13. Create an Event Table

The program saves:

sma20_rule_events.csv

Each event row contains:

Signal Date
OHLC
SMA 20
Event
Next Date
Next Open

This lets us compare the code against the chart before calculating performance.

14. Why Store Next Open but Not Use It Yet?

Next Open is stored only as a future execution field. It does not participate in today's signal.

signal inputs
→ known by bar t Close

future execution field
→ bar t+1 Open

Keeping current information and future labels separate is a basic research habit.

15. A Rule Needs an Entry Condition

if

Close(t-1) <= SMA20(t-1)

and

Close(t) > SMA20(t)

then

Entry Event at t = True

16. A Rule Also Needs an Exit Condition

if

Close(t-1) >= SMA20(t-1)

and

Close(t) < SMA20(t)

then

Exit Event at t = True

This does not mean the rule is profitable. It means the position lifecycle can now be defined precisely.

17. We Still Do Not Have a Complete Backtest

still missing:

execution model
holding logic
transaction costs
slippage
position size
cash handling
baseline
performance metric

That is intentional. Phase 4 will add those layers one at a time.

18. Write the Hypothesis Before Looking at Results

When AAPL Close crosses
above SMA 20,

a position entered at
the next trading bar Open

and exited after
a later Cross Below event

may produce outcomes
different from
an appropriate baseline.

The key word is:

may

not

will

A hypothesis is a claim to test, not a conclusion we have already decided to believe.

19. Baseline Comes Before “Profit”

A rule can make money during a rising market and still add no useful information.

We need comparison.

possible baselines:

buy and hold

all bars

unconditional forward return

randomized events

another simple rule

Baseline design comes next.

20. Do Not Tune SMA 20 Yet

It is tempting to test many periods and keep whichever result looks best.

SMA 5
SMA 10
SMA 20
SMA 50
SMA 100
SMA 200

For now:

freeze SMA period = 20

test the research process first

21. The Chart Is for Verification, Not Proof

AAPL candlesticks

SMA 20

Cross Above
→ seagreen upward marker

Cross Below
→ firebrick downward marker

The chart helps us verify implementation. It does not prove predictive value.

22. The Complete Python Program

This lesson introduces no new external package. We reuse FinanceDataReader and matplotlib.

phase4_01_indicator_to_testable_rule.py
from pathlib import Path
from datetime import date, timedelta
import os

import FinanceDataReader as fdr
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle

symbol = "AAPL"
recent_trading_days = 220
sma_period = 20

bullish_color = "seagreen"
bearish_color = "firebrick"
sma_color = "dimgray"

SCRIPT_DIR = Path(__file__).resolve().parent
os.chdir(SCRIPT_DIR)


def simple_moving_average(values, period):
    result = [None] * len(values)

    if period <= 0:
        raise ValueError("period must be positive")

    for i in range(period - 1, len(values)):
        window = values[i - period + 1 : i + 1]
        result[i] = sum(float(v) for v in window) / period

    return result


def above_sma_state(close_values, sma_values):
    result = [None] * len(close_values)

    for i in range(len(close_values)):
        if sma_values[i] is None:
            continue

        result[i] = (
            float(close_values[i])
            > float(sma_values[i])
        )

    return result


def crossover_events(above_state):
    cross_above = [False] * len(above_state)
    cross_below = [False] * len(above_state)

    for i in range(1, len(above_state)):
        previous_state = above_state[i - 1]
        current_state = above_state[i]

        if previous_state is None or current_state is None:
            continue

        cross_above[i] = (
            current_state is True
            and previous_state is False
        )

        cross_below[i] = (
            current_state is False
            and previous_state is True
        )

    return cross_above, cross_below


def draw_candlesticks(ax, market_df, body_width=0.62):
    for x, (_, row) in enumerate(market_df.iterrows()):
        o = float(row["Open"])
        h = float(row["High"])
        l = float(row["Low"])
        c = float(row["Close"])

        color = bullish_color if c >= o else bearish_color

        ax.vlines(
            x, l, h,
            color=color,
            linewidth=1.0,
        )

        bottom = min(o, c)
        height = abs(c - o)

        if height == 0:
            height = max(h - l, 0.01) * 0.02

        ax.add_patch(
            Rectangle(
                (x - body_width / 2.0, bottom),
                body_width,
                height,
                facecolor=color,
                edgecolor=color,
                linewidth=0.8,
            )
        )


# ------------------------------------------------------------
# Self-test: verify rule logic before market data
# ------------------------------------------------------------

toy_close = [
    10.0,
    10.0,
    10.0,
    12.0,
    13.0,
    9.0,
    8.0,
    12.0,
]

toy_sma = simple_moving_average(
    toy_close,
    period=3,
)

toy_state = above_sma_state(
    toy_close,
    toy_sma,
)

toy_up, toy_down = crossover_events(
    toy_state
)

actual_up = [
    i
    for i, value in enumerate(toy_up)
    if value
]

actual_down = [
    i
    for i, value in enumerate(toy_down)
    if value
]

assert actual_up == [3, 7]
assert actual_down == [5]

print("Self-test")
print("=========")
print("Cross Above indices:", actual_up)
print("Cross Below indices:", actual_down)
print("Self-test: PASS")
print()


# ------------------------------------------------------------
# Download market data
# ------------------------------------------------------------

today = date.today()

start_date = (
    today
    - timedelta(days=520)
).strftime("%Y-%m-%d")

end_date = today.strftime("%Y-%m-%d")

df = fdr.DataReader(
    symbol,
    start_date,
    end_date,
)

df = df.tail(
    recent_trading_days
).copy()

close_values = [
    float(v)
    for v in df["Close"]
]

sma_values = simple_moving_average(
    close_values,
    period=sma_period,
)

df["SMA"] = sma_values

state = above_sma_state(
    close_values,
    sma_values,
)

cross_above, cross_below = crossover_events(
    state
)

df["Above SMA"] = state
df["Cross Above"] = cross_above
df["Cross Below"] = cross_below


# ------------------------------------------------------------
# Signal time vs execution time
# ------------------------------------------------------------

# Signal uses bar t Close, so it is known only after bar t closes.
# Educational execution convention:
#
# signal at bar t Close
# -> planned execution at bar t+1 Open
#
# Next Open is stored only as a future execution label.
# It is NOT used to create the signal.

df["Next Date"] = df.index.to_series().shift(-1)
df["Next Open"] = df["Open"].shift(-1)


# ------------------------------------------------------------
# Event table
# ------------------------------------------------------------

event_df = df[
    df["Cross Above"]
    | df["Cross Below"]
].copy()

event_df["Event"] = ""

event_df.loc[
    event_df["Cross Above"],
    "Event",
] = "Cross Above SMA"

event_df.loc[
    event_df["Cross Below"],
    "Event",
] = "Cross Below SMA"

event_output = event_df[
    [
        "Open",
        "High",
        "Low",
        "Close",
        "SMA",
        "Event",
        "Next Date",
        "Next Open",
    ]
].copy()

csv_file = SCRIPT_DIR / "sma20_rule_events.csv"

event_output.to_csv(
    csv_file,
    index_label="Signal Date",
)


# ------------------------------------------------------------
# Print frozen rule specification
# ------------------------------------------------------------

print("Rule specification")
print("==================")
print()
print(f"Indicator: SMA({sma_period})")
print("State: Close > SMA")
print(
    "Entry event: "
    "previous Close <= previous SMA "
    "and current Close > current SMA"
)
print(
    "Exit event: "
    "previous Close >= previous SMA "
    "and current Close < current SMA"
)
print(
    "Signal timestamp: "
    "after bar t Close is known"
)
print(
    "Planned execution: "
    "bar t+1 Open"
)
print()
print(
    "No profit calculation is performed "
    "in Phase 4-01."
)
print()
print("Detected events:", len(event_output))
print()
print(event_output.tail(10))


# ------------------------------------------------------------
# Verification chart
# ------------------------------------------------------------

plot_df = df.tail(120).copy()
x = list(range(len(plot_df)))

fig, ax = plt.subplots(figsize=(12, 8))

draw_candlesticks(
    ax,
    plot_df,
)

ax.plot(
    x,
    plot_df["SMA"],
    color=sma_color,
    linewidth=1.6,
    label=f"SMA {sma_period}",
)

entry_x = [
    i
    for i, value
    in enumerate(plot_df["Cross Above"])
    if bool(value)
]

entry_y = [
    float(plot_df.iloc[i]["Low"])
    for i in entry_x
]

exit_x = [
    i
    for i, value
    in enumerate(plot_df["Cross Below"])
    if bool(value)
]

exit_y = [
    float(plot_df.iloc[i]["High"])
    for i in exit_x
]

if entry_x:
    ax.scatter(
        entry_x,
        entry_y,
        marker="^",
        s=65,
        color=bullish_color,
        label="Cross Above event",
        zorder=4,
    )

if exit_x:
    ax.scatter(
        exit_x,
        exit_y,
        marker="v",
        s=65,
        color=bearish_color,
        label="Cross Below event",
        zorder=4,
    )

ax.set_title(
    f"{symbol} — From SMA Observation "
    "to Testable Events"
)

ax.set_ylabel("Price")
ax.grid(axis="y", alpha=0.20)
ax.legend()

step = max(1, len(plot_df) // 8)
positions = list(range(0, len(plot_df), step))

labels = [
    plot_df.index[i].strftime("%Y-%m-%d")
    for i in positions
]

ax.set_xticks(positions)
ax.set_xticklabels(
    labels,
    rotation=35,
    ha="right",
)

fig.subplots_adjust(
    left=0.09,
    right=0.98,
    top=0.93,
    bottom=0.15,
)

chart_file = (
    SCRIPT_DIR
    / "indicator_to_testable_rule_sma20.png"
)

fig.savefig(
    chart_file,
    dpi=140,
)

print()
print("Files saved:")
print(csv_file)
print(chart_file)

plt.show()
plt.close(fig)

23. Run the Program

python phase4_01_indicator_to_testable_rule.py

Confirm:

Self-test: PASS

The program saves:

sma20_rule_events.csv

indicator_to_testable_rule_sma20.png

24. A Research Rule Should Fit on One Specification Card

Indicator
SMA 20

State
Close > SMA 20

Entry Event
Cross Above

Exit Event
Cross Below

Signal Time
after bar t Close

Planned Execution
bar t+1 Open

Costs
not added yet

Baseline
not selected yet

Performance
not calculated yet

If the experiment cannot be written this clearly, it is not ready for a backtest.

Check Your Understanding

  • An indicator is a calculation, not a trading rule.
  • A state such as Close > SMA can persist for many bars.
  • A crossover is an event: a transition from one state to another.
  • A testable rule must specify exact inputs, parameters, and comparison operators.
  • Signal time and execution time must be distinguished.
  • The rule logic should be tested on artificial data before market performance is evaluated.
  • An entry rule without an exit rule does not define a complete position lifecycle.
  • A chart verifies implementation visually; it does not prove predictive value.
  • A hypothesis should be frozen before parameter tuning begins.

What You Just Learned

indicator
SMA 20
   ↓
observation
Close relative to SMA
   ↓
state
Close > SMA
   ↓
state transition
Cross Above / Cross Below
   ↓
event
   ↓
signal timestamp
   ↓
planned execution timestamp
   ↓
testable rule
   ↓
hypothesis

not yet:

profit claim

A trading idea becomes testable only after the observation, condition, timing, and action are written precisely enough that another researcher can reproduce the experiment.

Where Do We Go Next?

We now know how to create a clean event.

The next question is:

What happened after
that event?
event at t
   ↓
future horizon
   ↓
forward return
   ↓
compare with baseline

That will be the next Phase 4 Building Block.

Sources and Further Reading