From Events to Positions: Build a Trading Position Lifecycle with Python

In the previous Phase 4 lessons, we studied a signal as a research event.

Signal
↓
Forward Return
↓
Baseline
↓
Distribution
↓
Dependence
↓
Uncertainty

But a real trading position behaves differently.

A signal may happen at one moment, while a position can remain open for many bars.

Phase 4-06 introduces that missing idea: the position lifecycle.

1. Event Study and Trading Position Are Not the Same Thing

Until now, an SMA20 Cross Above event was followed by a fixed future window:

Signal
after Close(t)

Measure
Open(t+1)
→
Open(t+6)

That is useful for asking:

What usually happened
after the signal?

But a trading strategy asks a different question:

When do we enter?

While we are already in,
what do we do?

When do we exit?

That requires a position state.

2. Start with Only Two States

We deliberately keep the first lifecycle simple.

CASH

or

LONG

No short selling. No leverage. No partial positions. No multiple entries.

One position at a time.

3. See the Lifecycle Before Writing Code

The whole model can be drawn as:

CASH
  ↓
Cross Above
after Close(t)
  ↓
BUY at Open(t+1)
  ↓
LONG
  ↓
Cross Below
after Close(t)
  ↓
SELL at Open(t+1)
  ↓
CASH

The Python program creates the same idea as a state-machine diagram.

Position lifecycle state machine showing CASH, entry signal, BUY at next Open, LONG, exit signal, SELL at next Open, and return to CASH
Figure 1. The first position lifecycle has only two states: CASH and LONG. A Cross Above event schedules a BUY for the next Open, while a Cross Below event schedules a SELL for the next Open.

4. Why Do We Need a State?

Suppose a Cross Above signal appears.

We buy and become LONG.

Two days later another bullish-looking event appears. Should we buy again?

In this first model:

No.

We are already LONG.

The correct action depends not only on the new event, but also on the position we already hold.

same event

+
different current state

=

different action

5. State and Event Have Different Meanings

A state can last for many bars.

LONG
LONG
LONG
LONG
LONG

An event happens at a transition.

Cross Above
happens once

then the position
may remain LONG
for many bars

Therefore:

State ≠ Event

6. Keep Signal Time and Execution Time Separate

Our SMA20 Cross Above uses the current Close.

That means the event is known only after the current bar closes.

Close(t)
↓
now we know
Cross Above happened

We cannot pretend that we already traded at that same Close.

The simple execution rule remains:

Signal
after Close(t)

Execution
Open(t+1)

7. Entry Rule

Entry requires both:

Current Position
=
CASH

AND

Cross Above
=
True

Then:

schedule BUY

execute at
next bar Open

8. Exit Rule

Exit also depends on the current position.

Current Position
=
LONG

AND

Cross Below
=
True

Then:

schedule SELL

execute at
next bar Open

9. Ignore Events That Do Not Match the Current State

Suppose we are already LONG and another Cross Above appears.

LONG
+
Cross Above

→
do nothing

Likewise:

CASH
+
Cross Below

→
do nothing

This is the first place where a state machine prevents contradictory trading actions.

10. See One Real Trade on Candlesticks

The program finds a real closed AAPL trade generated by the SMA20 lifecycle.

The chart shows:

Entry signal
after Close(t)

BUY
at next Open

LONG holding period

Exit signal
after Close(t)

SELL
at next Open

Green candles are bullish. Red candles are bearish.

The important idea is that the signal line and the execution line are not the same line.

AAPL candlestick chart showing entry signal, BUY at the next Open, LONG holding period, exit signal, and SELL at the next Open
Figure 2. One real AAPL position lifecycle. The signal is detected after the Close, execution happens at the next Open, and the shaded region shows the period while the position remains LONG. Green candles are bullish and red candles are bearish.

11. Follow One Tiny Example by Hand

Imagine this sequence:

Day 1
CASH

Day 2 Close
Cross Above occurs

Day 3 Open
BUY at 102

Day 3
LONG

Day 4
LONG

Day 4 Close
Cross Below occurs

Day 5 Open
SELL at 104

Day 5
CASH

The trade return is:

104
---
102
- 1

=
+1.96%

Notice that the entry and exit signals happen one bar before the actual executions.

12. A Position Is a Value That Persists Through Time

We can encode the state numerically:

CASH
=
0

LONG
=
1

Then a sequence may look like:

Date       Position

Day 1         0
Day 2         0
Day 3         1
Day 4         1
Day 5         0
Day 6         0

13. See the Position State as a Timeline

The program creates:

position_state_timeline.png

The line can only be at:

0
CASH

or

1
LONG

A jump from 0 to 1 means a BUY was executed. A drop from 1 to 0 means a SELL was executed.

Timeline of AAPL position state switching between CASH and LONG
Figure 3. Position is a state that persists through time. The line stays at CASH until a BUY is executed, remains at LONG while the trade is open, and returns to CASH after a SELL execution.

14. Event, Execution, Position, and Trade Are Four Different Things

These terms are easy to mix together.

Event
Cross Above or Cross Below
detected after Close(t)


Execution
BUY or SELL
at Open(t+1)


Position
CASH or LONG
held through time


Trade
one completed
Entry → Exit pair

This distinction is fundamental for every backtest we build later.

15. Why Use a Pending Action?

When a signal appears after Close(t), the execution belongs to the next bar.

The code therefore temporarily stores:

pending_action

pending_signal_index

Example:

today's Close

Cross Above
↓
pending_action = BUY


next day's Open

execute BUY
↓
pending_action = None

This makes the timing explicit instead of hiding it inside a return formula.

16. What Does the Lifecycle Table Store?

The program saves:

position_lifecycle_table.csv

Each market row includes:

Open
High
Low
Close

SMA

Cross Above
Cross Below

Position State
Position

Execution Action
Execution Price

Triggering Signal Date

This table answers:

What happened
on every bar?

17. What Is a Trade Ledger?

The second output is:

position_trade_ledger.csv

One row represents one completed trade.

Entry Signal Date
Entry Date
Entry Price

Exit Signal Date
Exit Date
Exit Price

Bars Held
Gross Return

This table answers:

What happened
for every completed trade?

18. Lifecycle Table and Trade Ledger Are Different

Lifecycle Table

one row
per market bar


Trade Ledger

one row
per completed trade

We need both.

The lifecycle table explains position state through time. The trade ledger summarizes complete Entry → Exit episodes.

19. What Happens If the Last Trade Is Still Open?

The dataset may end while the position is still LONG.

last bar arrives

position
=
LONG

but no future
Cross Below yet

Phase 4-06 does not invent an artificial exit.

It reports:

OPEN AT END OF DATA

Later backtest rules can decide whether the final position should be force-closed for reporting.

20. Why Do We Call the Return “Gross Return”?

The trade ledger currently calculates:

Exit Price
----------
Entry Price
- 1

But it does not subtract:

commission

spread

slippage

Therefore the correct name is:

Gross Return

Costs will be added later in Phase 4-08.

21. Why Is This Still Not a Full Backtest?

We now know:

when a trade enters

when it exits

how long it stays open

its gross return

But we have not yet modeled:

starting capital

number of shares

portfolio value

cash balance

equity curve

drawdown

Those belong to Phase 4-07.

Therefore:

Position Lifecycle
≠
Full Backtest

22. The Complete Python Program

This lesson reuses:

FinanceDataReader
pandas
matplotlib

Save as:

phase4_06_position_lifecycle.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
import pandas as pd


# ============================================================
# Phase 4-06 — Position Lifecycle
#
# Main idea:
#
# Event Study
#     ↓
# Entry / Exit Events
#     ↓
# Execution at Next Open
#     ↓
# Position State
#     ↓
# Trade Ledger
#
# State machine:
#
# CASH
#   ↓ Cross Above signal after Close(t)
# BUY at Open(t+1)
#   ↓
# LONG
#   ↓ Cross Below signal after Close(t)
# SELL at Open(t+1)
#   ↓
# CASH
#
# Important:
#
# Signal Time ≠ Execution Time
# Event ≠ Position
# Position ≠ Trade
# Event Study ≠ Backtest
#
# This lesson does NOT add:
# - fees
# - slippage
# - leverage
# - stop loss
# - position sizing
# - portfolio logic
#
# Change one thing at a time.
# ============================================================


# ------------------------------------------------------------
# 1. Settings
# ------------------------------------------------------------

symbol = "AAPL"

recent_trading_days = 800

sma_period = 20

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

os.chdir(SCRIPT_DIR)


# ------------------------------------------------------------
# 2. SMA
# ------------------------------------------------------------

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(value)
                for value
                in window
            )
            / period
        )

    return result


# ------------------------------------------------------------
# 3. State: Close above SMA?
# ------------------------------------------------------------

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


# ------------------------------------------------------------
# 4. Events: Cross Above / Cross Below
# ------------------------------------------------------------

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] = (
            previous_state is False
            and current_state is True
        )

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

    return (
        cross_above,
        cross_below,
    )


# ------------------------------------------------------------
# 5. Position lifecycle engine
# ------------------------------------------------------------

def build_position_lifecycle(
    market_df,
):
    """
    Signal timing:
        Cross Above / Cross Below is known
        only after Close(t).

    Execution:
        BUY or SELL happens at Open(t+1).

    Position states:
        CASH
        LONG

    We use one position at a time.

    If already LONG:
        another Cross Above does nothing.

    If already CASH:
        a Cross Below does nothing.
    """

    n = len(market_df)

    position_state = [
        "CASH"
    ] * n

    execution_action = [
        ""
    ] * n

    execution_price = [
        None
    ] * n

    triggering_signal_date = [
        None
    ] * n

    state = "CASH"

    pending_action = None

    pending_signal_index = None

    open_trade = None

    closed_trades = []

    for i in range(n):

        # ----------------------------------------
        # A. Execute a previously scheduled order
        #    at today's Open.
        # ----------------------------------------

        if pending_action is not None:

            price = float(
                market_df.iloc[i]["Open"]
            )

            signal_date = (
                market_df.index[
                    pending_signal_index
                ]
            )

            if pending_action == "BUY":

                state = "LONG"

                execution_action[i] = "BUY"

                execution_price[i] = price

                triggering_signal_date[i] = (
                    signal_date
                )

                open_trade = {
                    "Entry Signal Date":
                        signal_date,

                    "Entry Date":
                        market_df.index[i],

                    "Entry Price":
                        price,

                    "Entry Index":
                        i,
                }

            elif pending_action == "SELL":

                execution_action[i] = "SELL"

                execution_price[i] = price

                triggering_signal_date[i] = (
                    signal_date
                )

                if open_trade is None:
                    raise RuntimeError(
                        "SELL execution found "
                        "without an open trade."
                    )

                gross_return = (
                    price
                    / open_trade[
                        "Entry Price"
                    ]
                    - 1.0
                )

                bars_held = (
                    i
                    - open_trade[
                        "Entry Index"
                    ]
                )

                closed_trades.append(
                    {
                        "Entry Signal Date":
                            open_trade[
                                "Entry Signal Date"
                            ],

                        "Entry Date":
                            open_trade[
                                "Entry Date"
                            ],

                        "Entry Price":
                            open_trade[
                                "Entry Price"
                            ],

                        "Exit Signal Date":
                            signal_date,

                        "Exit Date":
                            market_df.index[i],

                        "Exit Price":
                            price,

                        "Bars Held":
                            bars_held,

                        "Gross Return":
                            gross_return,
                    }
                )

                open_trade = None

                state = "CASH"

            pending_action = None

            pending_signal_index = None

        # ----------------------------------------
        # B. Record the position held during
        #    today's bar after the Open execution.
        # ----------------------------------------

        position_state[i] = state

        # ----------------------------------------
        # C. At today's Close, inspect the event.
        #
        #    We cannot execute until next Open.
        # ----------------------------------------

        if i >= n - 1:
            continue

        cross_above = bool(
            market_df.iloc[i][
                "Cross Above"
            ]
        )

        cross_below = bool(
            market_df.iloc[i][
                "Cross Below"
            ]
        )

        if (
            state == "CASH"
            and cross_above
        ):
            pending_action = "BUY"

            pending_signal_index = i

        elif (
            state == "LONG"
            and cross_below
        ):
            pending_action = "SELL"

            pending_signal_index = i

    lifecycle_df = (
        market_df.copy()
    )

    lifecycle_df[
        "Position State"
    ] = position_state

    lifecycle_df[
        "Position"
    ] = [
        1
        if value == "LONG"
        else 0
        for value
        in position_state
    ]

    lifecycle_df[
        "Execution Action"
    ] = execution_action

    lifecycle_df[
        "Execution Price"
    ] = execution_price

    lifecycle_df[
        "Triggering Signal Date"
    ] = triggering_signal_date

    trade_df = pd.DataFrame(
        closed_trades
    )

    open_trade_summary = None

    if open_trade is not None:
        open_trade_summary = {
            "Status":
                "OPEN AT END OF DATA",

            "Entry Signal Date":
                open_trade[
                    "Entry Signal Date"
                ],

            "Entry Date":
                open_trade[
                    "Entry Date"
                ],

            "Entry Price":
                open_trade[
                    "Entry Price"
                ],
        }

    return (
        lifecycle_df,
        trade_df,
        open_trade_summary,
    )


# ------------------------------------------------------------
# 6. Candlestick renderer
# ------------------------------------------------------------

def draw_candlesticks(
    ax,
    market_df,
    body_width=0.62,
):
    """
    Bullish candle:
        seagreen

    Bearish candle:
        firebrick
    """

    bullish_color = "seagreen"

    bearish_color = "firebrick"

    for x, (_, row) in enumerate(
        market_df.iterrows()
    ):
        open_price = float(
            row["Open"]
        )

        high_price = float(
            row["High"]
        )

        low_price = float(
            row["Low"]
        )

        close_price = float(
            row["Close"]
        )

        bullish = (
            close_price
            >= open_price
        )

        candle_color = (
            bullish_color
            if bullish
            else bearish_color
        )

        ax.vlines(
            x,
            low_price,
            high_price,
            color=candle_color,
            linewidth=1.0,
        )

        body_bottom = min(
            open_price,
            close_price,
        )

        body_height = abs(
            close_price
            - open_price
        )

        if body_height == 0:
            body_height = max(
                high_price
                - low_price,
                0.01,
            ) * 0.02

        ax.add_patch(
            Rectangle(
                (
                    x
                    - body_width / 2.0,
                    body_bottom,
                ),
                body_width,
                body_height,
                facecolor=candle_color,
                edgecolor=candle_color,
                linewidth=1.0,
                alpha=0.90,
            )
        )


# ------------------------------------------------------------
# 7. Deterministic self-test
# ------------------------------------------------------------

toy_df = pd.DataFrame(
    {
        "Open": [
            100,
            101,
            102,
            103,
            104,
            105,
        ],

        "High": [
            101,
            102,
            103,
            104,
            105,
            106,
        ],

        "Low": [
            99,
            100,
            101,
            102,
            103,
            104,
        ],

        "Close": [
            100,
            101,
            102,
            103,
            104,
            105,
        ],

        "Cross Above": [
            False,
            True,
            False,
            False,
            False,
            False,
        ],

        "Cross Below": [
            False,
            False,
            False,
            True,
            False,
            False,
        ],
    },

    index=pd.date_range(
        "2026-01-01",
        periods=6,
        freq="D",
    ),
)

(
    toy_lifecycle,
    toy_trades,
    toy_open_trade,
) = build_position_lifecycle(
    toy_df
)

# Cross Above on row 1:
# BUY should execute at Open(row 2) = 102
assert (
    toy_lifecycle.iloc[2][
        "Execution Action"
    ]
    == "BUY"
)

assert abs(
    float(
        toy_lifecycle.iloc[2][
            "Execution Price"
        ]
    )
    - 102.0
) < 1e-12

# Cross Below on row 3:
# SELL should execute at Open(row 4) = 104
assert (
    toy_lifecycle.iloc[4][
        "Execution Action"
    ]
    == "SELL"
)

assert abs(
    float(
        toy_lifecycle.iloc[4][
            "Execution Price"
        ]
    )
    - 104.0
) < 1e-12

# Position should be LONG on rows 2 and 3.
assert (
    toy_lifecycle.iloc[2][
        "Position State"
    ]
    == "LONG"
)

assert (
    toy_lifecycle.iloc[3][
        "Position State"
    ]
    == "LONG"
)

# After SELL at row 4:
# position should be CASH.
assert (
    toy_lifecycle.iloc[4][
        "Position State"
    ]
    == "CASH"
)

assert len(
    toy_trades
) == 1

expected_return = (
    104.0
    / 102.0
    - 1.0
)

assert abs(
    float(
        toy_trades.iloc[0][
            "Gross Return"
        ]
    )
    - expected_return
) < 1e-12

assert (
    toy_open_trade
    is None
)

print("Self-test")
print("=========")
print(
    "BUY execution:",
    toy_lifecycle.iloc[2][
        "Execution Price"
    ],
)
print(
    "SELL execution:",
    toy_lifecycle.iloc[4][
        "Execution Price"
    ],
)
print(
    "Gross return:",
    toy_trades.iloc[0][
        "Gross Return"
    ],
)
print("Self-test: PASS")
print()


# ------------------------------------------------------------
# 8. Download market data
# ------------------------------------------------------------

today = date.today()

start_date = (
    today
    - timedelta(
        days=1800
    )
).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()

if len(df) < (
    sma_period + 30
):
    raise ValueError(
        "Not enough market data."
    )


# ------------------------------------------------------------
# 9. Build the frozen SMA20 events
# ------------------------------------------------------------

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

sma_values = (
    simple_moving_average(
        close_values,
        sma_period,
    )
)

state = above_sma_state(
    close_values,
    sma_values,
)

cross_above, cross_below = (
    crossover_events(
        state
    )
)

df["SMA"] = sma_values

df["Above SMA"] = state

df["Cross Above"] = (
    cross_above
)

df["Cross Below"] = (
    cross_below
)


# ------------------------------------------------------------
# 10. Build the lifecycle
# ------------------------------------------------------------

(
    lifecycle_df,
    trades_df,
    open_trade_summary,
) = build_position_lifecycle(
    df
)

lifecycle_file = (
    SCRIPT_DIR
    / "position_lifecycle_table.csv"
)

trade_file = (
    SCRIPT_DIR
    / "position_trade_ledger.csv"
)

lifecycle_df.to_csv(
    lifecycle_file
)

trades_df.to_csv(
    trade_file,
    index=False,
)


# ------------------------------------------------------------
# 11. Print lifecycle summary
# ------------------------------------------------------------

print("Position lifecycle")
print("==================")
print()

print(
    f"Symbol: {symbol}"
)

print(
    f"Closed trades: "
    f"{len(trades_df)}"
)

print(
    "Current position:",
    lifecycle_df.iloc[-1][
        "Position State"
    ],
)

print()

if not trades_df.empty:

    display_trades = (
        trades_df.copy()
    )

    display_trades[
        "Gross Return"
    ] = (
        100.0
        * display_trades[
            "Gross Return"
        ]
    )

    print(
        "First closed trades"
    )

    print(
        "-------------------"
    )

    print(
        display_trades.head(
            8
        ).to_string(
            index=False
        )
    )

if (
    open_trade_summary
    is not None
):
    print()
    print(
        "Open trade at end:"
    )
    print(
        open_trade_summary
    )

print()

print(
    "Important:"
)

print(
    "These are gross returns."
)

print(
    "No fees or slippage "
    "have been applied yet."
)

print(
    "This lesson builds "
    "position accounting, "
    "not a full backtest."
)


# ------------------------------------------------------------
# 12. Visual 1 — state machine diagram
# ------------------------------------------------------------

state_machine_file = (
    SCRIPT_DIR
    / "position_state_machine.png"
)

fig, ax = plt.subplots(
    figsize=(10, 4.8)
)

ax.axis(
    "off"
)

ax.text(
    0.18,
    0.55,
    "CASH",
    ha="center",
    va="center",
    fontsize=18,
    bbox=dict(
        boxstyle="round,pad=0.6",
        fill=False,
    ),
)

ax.text(
    0.50,
    0.55,
    "BUY\nOpen(t+1)",
    ha="center",
    va="center",
    fontsize=14,
)

ax.text(
    0.82,
    0.55,
    "LONG",
    ha="center",
    va="center",
    fontsize=18,
    bbox=dict(
        boxstyle="round,pad=0.6",
        fill=False,
    ),
)

ax.annotate(
    "",
    xy=(
        0.42,
        0.55,
    ),
    xytext=(
        0.28,
        0.55,
    ),
    arrowprops=dict(
        arrowstyle="->",
        linewidth=1.5,
    ),
)

ax.text(
    0.35,
    0.67,
    "Cross Above\nknown after Close(t)",
    ha="center",
    va="center",
    fontsize=11,
)

ax.annotate(
    "",
    xy=(
        0.72,
        0.55,
    ),
    xytext=(
        0.58,
        0.55,
    ),
    arrowprops=dict(
        arrowstyle="->",
        linewidth=1.5,
    ),
)

ax.annotate(
    "",
    xy=(
        0.28,
        0.30,
    ),
    xytext=(
        0.72,
        0.30,
    ),
    arrowprops=dict(
        arrowstyle="->",
        linewidth=1.5,
    ),
)

ax.text(
    0.50,
    0.19,
    "Cross Below after Close(t)\n"
    "→ SELL at Open(t+1)",
    ha="center",
    va="center",
    fontsize=11,
)

ax.set_title(
    "Position Lifecycle: CASH → LONG → CASH",
    fontsize=16,
)

fig.subplots_adjust(
    left=0.03,
    right=0.97,
    top=0.88,
    bottom=0.05,
)

fig.savefig(
    state_machine_file,
    dpi=140,
)

plt.close(fig)


# ------------------------------------------------------------
# 13. Visual 2 — one real closed trade on candles
# ------------------------------------------------------------

trade_candlestick_file = (
    SCRIPT_DIR
    / "position_lifecycle_candlestick.png"
)

trade_example_file = (
    SCRIPT_DIR
    / "position_lifecycle_example.csv"
)

if not trades_df.empty:

    example_trade = (
        trades_df.iloc[-1]
    )

    entry_date = (
        pd.Timestamp(
            example_trade[
                "Entry Date"
            ]
        )
    )

    exit_date = (
        pd.Timestamp(
            example_trade[
                "Exit Date"
            ]
        )
    )

    entry_index = (
        lifecycle_df.index.get_loc(
            entry_date
        )
    )

    exit_index = (
        lifecycle_df.index.get_loc(
            exit_date
        )
    )

    entry_signal_date = (
        pd.Timestamp(
            example_trade[
                "Entry Signal Date"
            ]
        )
    )

    exit_signal_date = (
        pd.Timestamp(
            example_trade[
                "Exit Signal Date"
            ]
        )
    )

    entry_signal_index = (
        lifecycle_df.index.get_loc(
            entry_signal_date
        )
    )

    exit_signal_index = (
        lifecycle_df.index.get_loc(
            exit_signal_date
        )
    )

    plot_start = max(
        0,
        entry_signal_index - 10,
    )

    plot_end = min(
        len(lifecycle_df),
        exit_index + 10,
    )

    plot_df = (
        lifecycle_df.iloc[
            plot_start:plot_end
        ].copy()
    )

    entry_x = (
        entry_index
        - plot_start
    )

    exit_x = (
        exit_index
        - plot_start
    )

    entry_signal_x = (
        entry_signal_index
        - plot_start
    )

    exit_signal_x = (
        exit_signal_index
        - plot_start
    )

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

    draw_candlesticks(
        ax,
        plot_df,
    )

    ax.plot(
        list(
            range(
                len(plot_df)
            )
        ),
        plot_df[
            "SMA"
        ],
        linewidth=1.4,
        label=f"SMA {sma_period}",
    )

    ax.axvline(
        entry_signal_x,
        linestyle=":",
        linewidth=1.2,
        label=(
            "Entry signal "
            "after Close(t)"
        ),
    )

    ax.axvline(
        entry_x,
        color="seagreen",
        linewidth=1.6,
        label="BUY at next Open",
    )

    ax.axvline(
        exit_signal_x,
        linestyle=":",
        linewidth=1.2,
        label=(
            "Exit signal "
            "after Close(t)"
        ),
    )

    ax.axvline(
        exit_x,
        color="firebrick",
        linewidth=1.6,
        label="SELL at next Open",
    )

    ax.axvspan(
        entry_x,
        exit_x,
        alpha=0.08,
        label="LONG position",
    )

    ax.set_title(
        f"{symbol} — From Entry Event "
        "to Position Lifecycle"
    )

    ax.set_ylabel(
        "Price"
    )

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

    step = max(
        1,
        len(plot_df) // 8,
    )

    tick_positions = list(
        range(
            0,
            len(plot_df),
            step,
        )
    )

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

    ax.set_xticks(
        tick_positions
    )

    ax.set_xticklabels(
        tick_labels,
        rotation=35,
        ha="right",
    )

    ax.legend()

    fig.subplots_adjust(
        left=0.09,
        right=0.98,
        top=0.90,
        bottom=0.18,
    )

    fig.savefig(
        trade_candlestick_file,
        dpi=140,
    )

    plt.close(fig)

    pd.DataFrame(
        [
            example_trade
        ]
    ).to_csv(
        trade_example_file,
        index=False,
    )


# ------------------------------------------------------------
# 14. Visual 3 — position state through time
# ------------------------------------------------------------

position_timeline_file = (
    SCRIPT_DIR
    / "position_state_timeline.png"
)

timeline_df = (
    lifecycle_df.tail(
        160
    ).copy()
)

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

x_values = list(
    range(
        len(timeline_df)
    )
)

ax.step(
    x_values,
    timeline_df[
        "Position"
    ],
    where="post",
    linewidth=1.8,
)

ax.set_yticks(
    [0, 1]
)

ax.set_yticklabels(
    [
        "CASH",
        "LONG",
    ]
)

ax.set_ylim(
    -0.15,
    1.15,
)

ax.set_title(
    f"{symbol} — Position State Through Time"
)

ax.set_xlabel(
    "Trading Date"
)

ax.grid(
    axis="x",
    alpha=0.15,
)

step = max(
    1,
    len(
        timeline_df
    ) // 8,
)

tick_positions = list(
    range(
        0,
        len(timeline_df),
        step,
    )
)

tick_labels = [
    timeline_df.index[i].strftime(
        "%Y-%m-%d"
    )
    for i
    in tick_positions
]

ax.set_xticks(
    tick_positions
)

ax.set_xticklabels(
    tick_labels,
    rotation=35,
    ha="right",
)

fig.subplots_adjust(
    left=0.12,
    right=0.98,
    top=0.88,
    bottom=0.20,
)

fig.savefig(
    position_timeline_file,
    dpi=140,
)

plt.close(fig)


# ------------------------------------------------------------
# 15. Finish
# ------------------------------------------------------------

print()
print("Files saved:")
print(lifecycle_file)
print(trade_file)
print(state_machine_file)

if not trades_df.empty:
    print(trade_example_file)
    print(trade_candlestick_file)

print(position_timeline_file)

23. Run the Program

python phase4_06_position_lifecycle.py

First confirm:

Self-test: PASS

Then inspect:

position_lifecycle_table.csv

position_trade_ledger.csv

position_state_machine.png

position_lifecycle_example.csv

position_lifecycle_candlestick.png

position_state_timeline.png

24. Research Checkpoint

Indicator
SMA20

Entry Event
Cross Above

Entry Signal Time
after Close(t)

Entry Execution
Open(t+1)

Exit Event
Cross Below

Exit Signal Time
after Close(t)

Exit Execution
Open(t+1)

Position States
CASH
LONG

Trade Model
one position at a time

Current Output
Lifecycle Table
Trade Ledger

Return
Gross Return

Not Yet
capital
position size
fees
slippage
equity curve
drawdown

25. Check Your Understanding

  • An event and a position are not the same thing.
  • A position state can persist for many bars.
  • A Cross Above event matters only when the current state is CASH.
  • A Cross Below event matters only when the current state is LONG.
  • The signal is known after Close(t), but the execution happens at Open(t+1).
  • A pending action keeps signal time and execution time separate.
  • The lifecycle table stores one row per market bar.
  • The trade ledger stores one row per completed Entry → Exit trade.
  • An open trade at the end of the dataset should be reported explicitly.
  • Gross Return does not include trading costs.
  • A position lifecycle is necessary for a backtest, but it is not yet a full backtest.

26. Change One Thing Yourself

Keep the SMA20 signal and next-open execution frozen.

Change only the exit event.

Instead of:

Exit
Cross Below SMA20

try a simple fixed holding rule:

Exit
5 bars after entry

Then ask:

How does the
Position timeline change?

How does
Bars Held change?

How many trades
are completed?

Do entry events
occur while already LONG?

Do not compare profitability yet.

The goal is to understand how an exit rule changes the lifecycle.

27. What You Just Learned

Event
↓
schedule action
↓
next Open execution
↓
Position State
↓
hold through time
↓
Exit Event
↓
next Open execution
↓
Trade completed
↓
Trade Ledger

The key idea is:

Trading is not
a collection of isolated signals.

It is a sequence of
state transitions through time.

28. Where Do We Go Next?

We now have:

CASH
↓
BUY
↓
LONG
↓
SELL
↓
CASH

We also have completed trades.

The next question is:

If we start with
a fixed amount of money,

how does the account value
change through time?

Phase 4-07 will build the first full backtest:

Starting Capital
↓
Position
↓
Trade PnL
↓
Cash
↓
Equity
↓
Equity Curve

Sources and Further Reading