How Do You Turn an Indicator into a Testable Trading Rule? From Observation to Hypothesis with 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
- pandas DataFrame.shift documentation — useful for aligning prior states and future fields.
- scikit-learn TimeSeriesSplit documentation — reinforces the principle that time-ordered observations must preserve chronology during evaluation.