Build Your First Backtest from Scratch with Python

In Phase 4-06, we converted isolated trading events into a position lifecycle.

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

That solved an important timing problem. But it still did not answer a simple question:

If we start with a fixed amount of money,
how does the account value change through time?

Phase 4-07 adds the missing accounting layer. This is our first full backtest.

1. A Position Lifecycle Is Not Yet a Backtest

The Phase 4-06 lifecycle knew when a position entered and exited. It also calculated a gross return for each completed trade.

But it did not track:

  • starting capital,
  • cash,
  • number of shares,
  • position value,
  • total account equity,
  • or an equity curve.

A backtest needs those quantities because one trade changes the amount of money available for the next trade.

2. The New Layer Is Accounting

We keep the trading rule frozen. We are not searching for a better indicator or a better parameter.

same SMA20 rule
+
same signal timing
+
same next-Open execution
+
accounting
=
first backtest

This separation matters. If both the trading rule and the accounting model change at the same time, it becomes harder to understand what caused a result.

3. Freeze the Accounting Assumptions

The first account model is intentionally simple.

Starting Capital = $10,000

Position:
CASH or LONG

Position Size:
maximum whole shares
that available cash can afford

Leverage:
none

Short Selling:
none

Fees:
not yet

Slippage:
not yet

We use whole shares because they make the cash arithmetic visible. Fractional shares would also be possible, but they would hide the useful idea of leftover cash.

Flow from starting capital through cash, next-open buy, shares plus cash, equity at close, and next-open sell.
Figure 1. Backtest accounting flow. Starting capital becomes cash and shares after a BUY, equity is marked at each Close, and a SELL converts the position back to cash.

4. Start with Cash

Before the first trade, the account contains only cash.

Cash = 10,000
Shares = 0

Position Value = 0

Equity
= Cash + Position Value
= 10,000

This gives us the first distinction:

Cash
≠
Equity

They are equal while we hold no stock. They become different after a BUY.

5. BUY Still Happens at the Next Open

The entry event uses the completed Close of bar t. Therefore the event is not known until that bar has closed.

Close(t) becomes known
↓
SMA20(t) is final
↓
Cross Above can be evaluated
↓
BUY at Open(t+1)

The backtest does not move the trade backward in time.

Signal Time
≠
Execution Time

6. How Many Shares Do We Buy?

Suppose the next Open is $187.50.

available cash = 10,000

10,000 / 187.50
= 53.33...

Whole-share sizing means we buy 53 shares.

Shares
= floor(10,000 / 187.50)
= 53

The cost is:

53 × 187.50
= 9,937.50

So some cash remains:

Cash after BUY
= 10,000 - 9,937.50
= 62.50

7. Leftover Cash Stays in the Account

We do not throw away the remaining $62.50. It is still part of the account.

After the BUY:

Cash = 62.50
Shares = 53

The account now contains two components instead of one.

8. Position Value Changes with the Close

At the end of each bar, we mark the open position using that bar's Close.

Position Value
=
Shares × Close

If the Close becomes $190:

Position Value
=
53 × 190
=
10,070

We have not sold anything. The position is simply worth a different amount at this mark.

9. Equity Combines Cash and Position Value

The backtest account value is:

Equity
=
Cash
+
Position Value

Using the previous example:

Equity
=
62.50
+
10,070
=
10,132.50

This is why cash and equity are different while a position is open.

10. SELL Converts the Position Back to Cash

The exit event is also detected after a Close.

Cross Below after Close(t)
↓
SELL at Open(t+1)

If the next Open is $194:

Sale Proceeds
=
53 × 194
=
10,282

Add the leftover cash:

Ending Cash
=
62.50 + 10,282
=
10,344.50

After the SELL:

Cash = 10,344.50
Shares = 0
Position Value = 0
Equity = 10,344.50

11. Realized Cash and Open Equity Are Different

During a trade, part of the account is held as shares. A rising or falling Close changes equity even though cash does not change.

while LONG:

Cash
may stay constant

but

Equity
changes with Close

This gives us another important distinction:

Open Position
≠
Realized Cash

12. Walk Through One Tiny Example by Hand

Before using AAPL, the program tests the accounting engine with numbers whose answer we already know.

Starting Cash = 1,000

Day 2 Close:
Cross Above

Day 3 Open:
BUY at 100

Shares:
1,000 / 100 = 10

Cash after BUY:
0

Day 4 Close:
Cross Below

Day 5 Open:
SELL at 110

Sale Proceeds:
10 × 110 = 1,100

Ending Cash:
1,100

Gross PnL:
100

Total Return:
10%

If Python cannot reproduce those values exactly, the program stops before we interpret market results.

13. Implementation Test Still Comes First

The deterministic example asks an engineering question:

Does the accounting code
match the accounting rules?

It does not ask whether SMA20 is profitable.

Implementation Test
≠
Market Test

14. The Backtest Engine Walks One Bar at a Time

The core loop follows a strict order.

1. Today's Open arrives

2. Execute a pending BUY or SELL

3. Update Cash and Shares

4. Today's Close arrives

5. Mark Position Value

6. Calculate Equity

7. Evaluate Cross Above / Cross Below

8. Schedule any action for the next Open

The order is important because it prevents future information from leaking backward into an earlier action.

15. The Bar-by-Bar Table Becomes the Accounting Record

The program saves:

first_backtest_equity.csv

Each row stores:

  • OHLC,
  • SMA20,
  • Cross Above,
  • Cross Below,
  • Position State,
  • Shares,
  • Cash,
  • Position Value,
  • Equity,
  • Execution Action,
  • Execution Price,
  • Triggering Signal Date.

This table answers:

What did the account contain
on every market bar?

16. The Trade Ledger Still Has One Row per Completed Trade

The program also saves:

first_backtest_trade_ledger.csv

A completed trade includes:

  • Entry Signal Date,
  • Entry Date,
  • Entry Price,
  • Shares,
  • Exit Signal Date,
  • Exit Date,
  • Exit Price,
  • Bars Held,
  • Gross PnL,
  • Gross Return.

The two tables have different jobs.

Bar-by-Bar Table
→ account state through time

Trade Ledger
→ completed Entry-to-Exit episodes

17. See One Real Trade with Account Information

The program selects one completed AAPL trade and plots:

  • entry signal after Close(t),
  • BUY at the next Open,
  • the LONG holding window,
  • exit signal after Close(t),
  • SELL at the next Open.

The title also shows the number of shares and the gross PnL for that trade.

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 completed AAPL trade. Entry and exit signals are detected after the Close, while BUY and SELL executions occur at the next Open.

18. The Equity Curve Shows the Account Through Time

A trade ledger is a list of completed episodes. An equity curve is a continuous record of the account value through time.

Equity at bar 1
Equity at bar 2
Equity at bar 3
...
Equity at final bar

In the updated figure, the chart is split into two panels so the account is easier to read.

Top panel
→ Equity

Bottom panel
→ Cash

Right axis on bottom panel
→ Position State
   0 = CASH
   1 = LONG

The top panel shows the total account value. It also marks the BUY and SELL execution bars. This is the main backtest curve.

The bottom panel shows only the cash balance. The dashed step line on the right axis shows whether the strategy is currently in CASH or LONG.

This separation matters because low cash does not automatically mean the account lost money.

Equity
=
Cash
+
Position Value

During a LONG position, most capital may be invested in shares. In that case, cash can fall close to zero while equity remains much higher.

So if the lower panel drops near zero during LONG, read it as:

capital invested
≠
capital lost

When the strategy returns to CASH, the position is closed, the stock value disappears from the account, and cash becomes equal to equity again.

AAPL first gross backtest shown in two panels with equity on the top panel, cash on the bottom panel, buy and sell execution markers, and cash or long position state.
Figure 3. Two-panel account view. The top panel shows Equity with BUY/SELL executions. The bottom panel shows Cash and Position State. Near-zero cash during LONG means capital is invested, not lost.

19. What If the Final Position Is Still Open?

The dataset may end while the strategy is still LONG.

We do not invent a SELL that never occurred.

final bar arrives
+
position = LONG
+
no later Open exists

→ do not force-sell

Instead, the final position is marked using the final Close.

Ending Equity
=
Cash
+
Shares × Final Close

The summary reports the open position separately.

20. This Is Still a Gross Backtest

The account model now tracks capital correctly under our simplified rules. But trading is still frictionless in this lesson.

We have not subtracted:

  • commission,
  • spread,
  • slippage.

Therefore:

Gross Backtest
≠
Net Backtest

Costs are the next Building Block.

21. Total Return Is Not Enough to Judge a Strategy

The first summary includes:

  • Starting Capital,
  • Ending Cash,
  • Ending Equity,
  • Total Return,
  • Closed Trades,
  • Current Position,
  • Current Shares.

That is enough to verify the first accounting model. It is not enough to declare a strategy good.

Total Return
≠
Strategy Quality

Drawdown, volatility-adjusted metrics, benchmark comparison, sensitivity, and out-of-sample testing come later.

22. The Complete Python Program

Save the following as:

phase4_07_first_backtest.py
from pathlib import Path
from datetime import date, timedelta
import argparse
import os

import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
import pandas as pd


# ============================================================
# Phase 4-07 — First Backtest from Scratch
#
# Main idea:
#
# Position Lifecycle
#        ↓
# Starting Capital
#        ↓
# Cash + Shares
#        ↓
# Mark-to-Market Equity
#        ↓
# Equity Curve
#
# Frozen trading rule:
#
# Indicator:
#     SMA(20)
#
# Entry event:
#     previous Close <= previous SMA
#     AND
#     current Close > current SMA
#
# Exit event:
#     previous Close >= previous SMA
#     AND
#     current Close < current SMA
#
# Signal time:
#     after Close(t)
#
# Execution:
#     Open(t+1)
#
# Accounting assumptions:
#     starting capital = 10,000 USD
#     long only
#     one position at a time
#     whole shares only
#     use as much available cash as possible
#     leftover cash remains in the account
#     mark open positions at each bar Close
#
# Deliberately NOT included yet:
#     fees
#     slippage
#     spread
#     leverage
#     short selling
#     stop loss
#     take profit
#     benchmark
#     drawdown
#     Sharpe ratio
#
# Important:
#
# Signal Time ≠ Execution Time
# Cash ≠ Equity
# Open Position ≠ Realized Cash
# Gross Backtest ≠ Net Backtest
# Total Return ≠ Strategy Quality
# ============================================================


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

symbol = "AAPL"
recent_trading_days = 800
sma_period = 20
starting_capital = 10_000.0

bullish_color = "seagreen"
bearish_color = "firebrick"

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. Backtest accounting engine
# ------------------------------------------------------------

def run_backtest(
    market_df,
    starting_cash,
):
    """
    Run one long-only backtest.

    Signal timing:
        Cross Above / Cross Below is known
        only after Close(t).

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

    Position sizing:
        Buy the maximum number of whole shares
        that available cash can afford.

    Valuation:
        Position Value = Shares * Close
        Equity = Cash + Position Value

    Final open position:
        Do not force-sell it.
        Mark it at the final Close instead.
    """

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

    n = len(market_df)

    if n == 0:
        raise ValueError(
            "market_df is empty"
        )

    cash = float(
        starting_cash
    )

    shares = 0
    state = "CASH"

    pending_action = None
    pending_signal_index = None

    open_trade = None
    closed_trades = []

    position_state = [
        "CASH"
    ] * n

    shares_held = [
        0
    ] * n

    cash_series = [
        0.0
    ] * n

    position_value = [
        0.0
    ] * n

    equity = [
        0.0
    ] * n

    execution_action = [
        ""
    ] * n

    execution_price = [
        None
    ] * n

    triggering_signal_date = [
        None
    ] * n

    # ----------------------------------------
    # Walk through one market bar at a time.
    # ----------------------------------------

    for i in range(n):

        # ------------------------------------
        # A. Execute yesterday's pending order
        #    at today's Open.
        # ------------------------------------

        if pending_action is not None:

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

            signal_date = (
                market_df.index[
                    pending_signal_index
                ]
            )

            if pending_action == "BUY":

                quantity = int(
                    cash
                    // open_price
                )

                if quantity > 0:

                    entry_cost = (
                        quantity
                        * open_price
                    )

                    cash = (
                        cash
                        - entry_cost
                    )

                    shares = quantity
                    state = "LONG"

                    execution_action[i] = (
                        "BUY"
                    )

                    execution_price[i] = (
                        open_price
                    )

                    triggering_signal_date[i] = (
                        signal_date
                    )

                    open_trade = {
                        "Entry Signal Date":
                            signal_date,
                        "Entry Date":
                            market_df.index[i],
                        "Entry Price":
                            open_price,
                        "Shares":
                            quantity,
                        "Entry Cash After":
                            cash,
                        "Entry Index":
                            i,
                    }

                else:

                    execution_action[i] = (
                        "BUY SKIPPED"
                    )

                    execution_price[i] = (
                        open_price
                    )

                    triggering_signal_date[i] = (
                        signal_date
                    )

            elif pending_action == "SELL":

                if (
                    shares <= 0
                    or
                    open_trade is None
                ):
                    raise RuntimeError(
                        "SELL found without "
                        "an open position."
                    )

                quantity = shares

                proceeds = (
                    quantity
                    * open_price
                )

                cash = (
                    cash
                    + proceeds
                )

                gross_pnl = (
                    quantity
                    * (
                        open_price
                        -
                        open_trade[
                            "Entry Price"
                        ]
                    )
                )

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

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

                execution_action[i] = (
                    "SELL"
                )

                execution_price[i] = (
                    open_price
                )

                triggering_signal_date[i] = (
                    signal_date
                )

                closed_trades.append(
                    {
                        "Entry Signal Date":
                            open_trade[
                                "Entry Signal Date"
                            ],
                        "Entry Date":
                            open_trade[
                                "Entry Date"
                            ],
                        "Entry Price":
                            open_trade[
                                "Entry Price"
                            ],
                        "Shares":
                            quantity,
                        "Entry Cash After":
                            open_trade[
                                "Entry Cash After"
                            ],
                        "Exit Signal Date":
                            signal_date,
                        "Exit Date":
                            market_df.index[i],
                        "Exit Price":
                            open_price,
                        "Bars Held":
                            bars_held,
                        "Gross PnL":
                            gross_pnl,
                        "Gross Return":
                            gross_return,
                        "Exit Cash After":
                            cash,
                    }
                )

                shares = 0
                state = "CASH"
                open_trade = None

            else:
                raise ValueError(
                    "Unknown pending action."
                )

            pending_action = None
            pending_signal_index = None

        # ------------------------------------
        # B. Mark the account at today's Close.
        # ------------------------------------

        close_price = float(
            market_df.iloc[i][
                "Close"
            ]
        )

        current_position_value = (
            shares
            * close_price
        )

        current_equity = (
            cash
            +
            current_position_value
        )

        position_state[i] = state
        shares_held[i] = shares
        cash_series[i] = cash
        position_value[i] = (
            current_position_value
        )
        equity[i] = current_equity

        # ------------------------------------
        # C. After today's Close is known,
        #    inspect today's event.
        #
        #    Execution cannot occur until
        #    the next bar 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
            )

    # ----------------------------------------
    # Build bar-by-bar output.
    # ----------------------------------------

    backtest_df = (
        market_df.copy()
    )

    backtest_df[
        "Position State"
    ] = position_state

    backtest_df[
        "Shares"
    ] = shares_held

    backtest_df[
        "Cash"
    ] = cash_series

    backtest_df[
        "Position Value"
    ] = position_value

    backtest_df[
        "Equity"
    ] = equity

    backtest_df[
        "Execution Action"
    ] = execution_action

    backtest_df[
        "Execution Price"
    ] = execution_price

    backtest_df[
        "Triggering Signal Date"
    ] = triggering_signal_date

    trade_df = pd.DataFrame(
        closed_trades
    )

    open_trade_summary = None

    if open_trade is not None:

        last_close = float(
            market_df.iloc[-1][
                "Close"
            ]
        )

        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"
                ],
            "Shares":
                open_trade[
                    "Shares"
                ],
            "Cash":
                cash,
            "Last Close":
                last_close,
            "Marked Position Value":
                shares
                * last_close,
            "Ending Equity":
                equity[-1],
        }

    summary = {
        "Starting Capital":
            float(
                starting_cash
            ),
        "Ending Cash":
            float(
                cash_series[-1]
            ),
        "Ending Equity":
            float(
                equity[-1]
            ),
        "Total Return":
            float(
                equity[-1]
                /
                starting_cash
                - 1.0
            ),
        "Closed Trades":
            int(
                len(trade_df)
            ),
        "Current Position":
            str(
                position_state[-1]
            ),
        "Current Shares":
            int(
                shares_held[-1]
            ),
    }

    return (
        backtest_df,
        trade_df,
        summary,
        open_trade_summary,
    )


# ------------------------------------------------------------
# 6. Deterministic self-test
# ------------------------------------------------------------

def run_self_test():
    """
    Known toy example:

    Cross Above after row 1 Close
        → BUY row 2 Open at 100
        → 10 shares with 1,000 cash

    Cross Below after row 3 Close
        → SELL row 4 Open at 110

    Expected:
        ending cash = 1,100
        ending equity = 1,100
        gross PnL = 100
        total return = 10%
    """

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

    toy_df = pd.DataFrame(
        {
            "Open": [
                95,
                96,
                100,
                105,
                110,
                111,
            ],
            "High": [
                97,
                99,
                103,
                109,
                112,
                114,
            ],
            "Low": [
                94,
                95,
                99,
                104,
                108,
                110,
            ],
            "Close": [
                96,
                98,
                101,
                108,
                109,
                113,
            ],
            "SMA": [
                96,
                97,
                98,
                102,
                107,
                110,
            ],
            "Cross Above": [
                False,
                True,
                False,
                False,
                False,
                False,
            ],
            "Cross Below": [
                False,
                False,
                False,
                True,
                False,
                False,
            ],
        },
        index=toy_index,
    )

    (
        toy_backtest,
        toy_trades,
        toy_summary,
        toy_open_trade,
    ) = run_backtest(
        toy_df,
        starting_cash=1_000.0,
    )

    assert (
        toy_backtest.iloc[2][
            "Execution Action"
        ]
        ==
        "BUY"
    )

    assert (
        toy_backtest.iloc[2][
            "Execution Price"
        ]
        ==
        100.0
    )

    assert (
        toy_backtest.iloc[2][
            "Shares"
        ]
        ==
        10
    )

    assert (
        toy_backtest.iloc[2][
            "Cash"
        ]
        ==
        0.0
    )

    assert (
        toy_backtest.iloc[4][
            "Execution Action"
        ]
        ==
        "SELL"
    )

    assert (
        toy_backtest.iloc[4][
            "Execution Price"
        ]
        ==
        110.0
    )

    assert (
        toy_backtest.iloc[4][
            "Cash"
        ]
        ==
        1_100.0
    )

    assert (
        len(
            toy_trades
        )
        ==
        1
    )

    assert abs(
        toy_trades.iloc[0][
            "Gross PnL"
        ]
        -
        100.0
    ) < 1e-12

    assert abs(
        toy_summary[
            "Total Return"
        ]
        -
        0.10
    ) < 1e-12

    assert (
        toy_open_trade
        is None
    )

    print("Self-test")
    print("=========")
    print(
        "BUY:",
        toy_backtest.iloc[2][
            "Execution Price"
        ],
    )
    print(
        "Shares:",
        toy_backtest.iloc[2][
            "Shares"
        ],
    )
    print(
        "SELL:",
        toy_backtest.iloc[4][
            "Execution Price"
        ],
    )
    print(
        "Gross PnL:",
        toy_trades.iloc[0][
            "Gross PnL"
        ],
    )
    print(
        "Ending Equity:",
        toy_summary[
            "Ending Equity"
        ],
    )
    print(
        "Total Return:",
        toy_summary[
            "Total Return"
        ],
    )
    print(
        "Self-test: PASS"
    )
    print()

    return (
        toy_backtest,
        toy_trades,
        toy_summary,
    )


# ------------------------------------------------------------
# 7. Candlestick renderer
# ------------------------------------------------------------

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

    Bearish candle:
        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,
            )
        )


# ------------------------------------------------------------
# 8. Accounting flow figure
# ------------------------------------------------------------

def plot_accounting_flow(
    output_file,
):
    fig, ax = plt.subplots(
        figsize=(12, 4.8)
    )

    ax.axis(
        "off"
    )

    boxes = [
        (
            0.05,
            "Starting\nCapital"
        ),
        (
            0.23,
            "CASH"
        ),
        (
            0.41,
            "BUY at\nnext Open"
        ),
        (
            0.59,
            "Shares\n+ Cash"
        ),
        (
            0.77,
            "Equity\nat Close"
        ),
        (
            0.95,
            "SELL at\nnext Open"
        ),
    ]

    for x, label in boxes:
        ax.text(
            x,
            0.60,
            label,
            ha="center",
            va="center",
            transform=ax.transAxes,
            bbox={
                "boxstyle":
                    "round,pad=0.5",
                "facecolor":
                    "white",
                "edgecolor":
                    "black",
            },
        )

    for i in range(
        len(boxes) - 1
    ):
        ax.annotate(
            "",
            xy=(
                boxes[i + 1][0]
                - 0.055,
                0.60,
            ),
            xytext=(
                boxes[i][0]
                + 0.055,
                0.60,
            ),
            xycoords=ax.transAxes,
            textcoords=ax.transAxes,
            arrowprops={
                "arrowstyle":
                    "->",
            },
        )

    ax.text(
        0.59,
        0.25,
        (
            "Position Value "
            "= Shares × Close"
        ),
        ha="center",
        transform=ax.transAxes,
    )

    ax.text(
        0.77,
        0.25,
        (
            "Equity = Cash "
            "+ Position Value"
        ),
        ha="center",
        transform=ax.transAxes,
    )

    ax.set_title(
        "From Position Lifecycle to Backtest Accounting"
    )

    fig.tight_layout()
    fig.savefig(
        output_file,
        dpi=160,
        bbox_inches="tight",
    )
    plt.close(fig)


# ------------------------------------------------------------
# 9. One real trade figure
# ------------------------------------------------------------

def plot_one_trade(
    backtest_df,
    trades_df,
    output_file,
):
    if trades_df.empty:
        return False

    trade = (
        trades_df.iloc[-1]
    )

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

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

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

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

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

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

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

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

    plot_start = max(
        0,
        entry_signal_index - 10,
    )

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

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

    entry_signal_x = (
        entry_signal_index
        -
        plot_start
    )

    entry_x = (
        entry_index
        -
        plot_start
    )

    exit_signal_x = (
        exit_signal_index
        -
        plot_start
    )

    exit_x = (
        exit_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=bullish_color,
        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=bearish_color,
        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} — One Backtest Trade "
            f"({int(trade['Shares'])} shares, "
            f"Gross PnL {trade['Gross PnL']:.2f})"
        )
    )

    ax.set_ylabel(
        "Price"
    )

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

    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",
    )

    ax.legend()

    fig.tight_layout()

    fig.savefig(
        output_file,
        dpi=160,
        bbox_inches="tight",
    )

    plt.close(fig)

    return True


# ------------------------------------------------------------
# 10. Equity curve (2-panel)
# ------------------------------------------------------------

def plot_equity_curve(
    backtest_df,
    output_file,
):
    """
    More intuitive 2-panel view.

    Top panel:
        Equity only
        + BUY / SELL execution markers

    Bottom panel:
        Cash balance
        + Position State (CASH / LONG)

    Why this helps:
        In the original one-panel chart,
        low cash sometimes looked like
        a loss. In reality, low cash during
        LONG usually means that most capital
        is invested in shares.

    So the new chart makes the distinction:

        Equity
        ≠
        Cash
    """

    fig, (
        ax_equity,
        ax_cash,
    ) = plt.subplots(
        2,
        1,
        figsize=(12, 8.2),
        sharex=True,
        gridspec_kw={
            "height_ratios": [3.2, 1.6]
        },
    )

    # ----------------------------------------
    # Top panel: account equity
    # ----------------------------------------

    ax_equity.plot(
        backtest_df.index,
        backtest_df[
            "Equity"
        ],
        linewidth=1.8,
        label="Equity",
    )

    buy_rows = (
        backtest_df[
            "Execution Action"
        ]
        ==
        "BUY"
    )

    sell_rows = (
        backtest_df[
            "Execution Action"
        ]
        ==
        "SELL"
    )

    if buy_rows.any():
        ax_equity.scatter(
            backtest_df.index[
                buy_rows
            ],
            backtest_df.loc[
                buy_rows,
                "Equity",
            ],
            marker="^",
            s=48,
            label="BUY execution",
            zorder=4,
        )

    if sell_rows.any():
        ax_equity.scatter(
            backtest_df.index[
                sell_rows
            ],
            backtest_df.loc[
                sell_rows,
                "Equity",
            ],
            marker="v",
            s=48,
            label="SELL execution",
            zorder=4,
        )

    ax_equity.set_title(
        (
            f"{symbol} — First Gross Backtest "
            "(2-Panel View)"
        )
    )

    ax_equity.set_ylabel(
        "Equity"
    )

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

    ax_equity.legend(
        loc="upper left"
    )

    # ----------------------------------------
    # Bottom panel: cash balance
    # ----------------------------------------

    ax_cash.plot(
        backtest_df.index,
        backtest_df[
            "Cash"
        ],
        linewidth=1.4,
        label="Cash",
    )

    ax_cash.set_ylabel(
        "Cash"
    )

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

    ax_cash.legend(
        loc="upper left"
    )

    ax_cash.text(
        0.01,
        0.08,
        (
            "Near-zero cash during LONG means "
            "capital is invested, not lost."
        ),
        transform=ax_cash.transAxes,
        fontsize=9,
        bbox={
            "boxstyle": "round,pad=0.3",
            "facecolor": "white",
            "edgecolor": "black",
            "alpha": 0.85,
        },
    )

    # ----------------------------------------
    # Secondary axis: Position State
    # ----------------------------------------

    state_numeric = (
        backtest_df[
            "Position State"
        ]
        .eq("LONG")
        .astype(int)
    )

    ax_state = ax_cash.twinx()

    ax_state.step(
        backtest_df.index,
        state_numeric,
        where="post",
        linestyle="--",
        linewidth=1.2,
        alpha=0.80,
        label="Position State",
    )

    ax_state.set_ylim(
        -0.10,
        1.10,
    )

    ax_state.set_yticks(
        [0, 1]
    )

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

    ax_state.set_ylabel(
        "Position State"
    )

    fig.autofmt_xdate()
    fig.tight_layout()

    fig.savefig(
        output_file,
        dpi=160,
        bbox_inches="tight",
    )

    plt.close(fig)


# ------------------------------------------------------------
# 11. Download market data
# ------------------------------------------------------------

def download_market_data():
    try:
        import FinanceDataReader as fdr
    except ImportError as exc:
        raise RuntimeError(
            "FinanceDataReader is not installed. "
            "Run: pip install finance-datareader"
        ) from exc

    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."
        )

    return df


# ------------------------------------------------------------
# 12. Build the frozen SMA20 rule
# ------------------------------------------------------------

def add_rule_columns(
    df,
):
    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
    )

    out = df.copy()

    out[
        "SMA"
    ] = sma_values

    out[
        "Above SMA"
    ] = state

    out[
        "Cross Above"
    ] = cross_above

    out[
        "Cross Below"
    ] = cross_below

    return out


# ------------------------------------------------------------
# 13. Save outputs
# ------------------------------------------------------------

def save_outputs(
    backtest_df,
    trades_df,
    summary,
    open_trade_summary,
):
    backtest_file = (
        SCRIPT_DIR
        /
        "first_backtest_equity.csv"
    )

    trade_file = (
        SCRIPT_DIR
        /
        "first_backtest_trade_ledger.csv"
    )

    summary_file = (
        SCRIPT_DIR
        /
        "first_backtest_summary.csv"
    )

    flow_file = (
        SCRIPT_DIR
        /
        "first_backtest_accounting_flow.png"
    )

    trade_chart_file = (
        SCRIPT_DIR
        /
        "first_backtest_trade_accounting.png"
    )

    equity_chart_file = (
        SCRIPT_DIR
        /
        "first_backtest_equity_curve.png"
    )

    backtest_df.to_csv(
        backtest_file
    )

    trades_df.to_csv(
        trade_file,
        index=False,
    )

    summary_rows = [
        {
            "Metric":
                key,
            "Value":
                value,
        }
        for key, value
        in summary.items()
    ]

    if open_trade_summary is not None:
        for key, value in (
            open_trade_summary.items()
        ):
            summary_rows.append(
                {
                    "Metric":
                        f"Open Trade — {key}",
                    "Value":
                        value,
                }
            )

    pd.DataFrame(
        summary_rows
    ).to_csv(
        summary_file,
        index=False,
    )

    plot_accounting_flow(
        flow_file
    )

    plot_one_trade(
        backtest_df,
        trades_df,
        trade_chart_file,
    )

    plot_equity_curve(
        backtest_df,
        equity_chart_file,
    )

    return {
        "Backtest CSV":
            backtest_file,
        "Trade Ledger":
            trade_file,
        "Summary CSV":
            summary_file,
        "Accounting Flow":
            flow_file,
        "Trade Figure":
            trade_chart_file,
        "Equity Curve":
            equity_chart_file,
    }


# ------------------------------------------------------------
# 14. Main
# ------------------------------------------------------------

def parse_args():
    parser = argparse.ArgumentParser(
        description=(
            "Alphesta Phase 4-07 "
            "first backtest from scratch"
        )
    )

    parser.add_argument(
        "--self-test-only",
        action="store_true",
        help=(
            "Run only the deterministic "
            "accounting self-test."
        ),
    )

    return parser.parse_args()


def main():
    args = parse_args()

    run_self_test()

    if args.self_test_only:
        return 0

    print(
        "Downloading market data..."
    )

    df = download_market_data()

    df = add_rule_columns(
        df
    )

    (
        backtest_df,
        trades_df,
        summary,
        open_trade_summary,
    ) = run_backtest(
        df,
        starting_cash=starting_capital,
    )

    files = save_outputs(
        backtest_df,
        trades_df,
        summary,
        open_trade_summary,
    )

    print()
    print(
        "First backtest"
    )
    print(
        "=============="
    )
    print()

    for key, value in (
        summary.items()
    ):
        if key in {
            "Starting Capital",
            "Ending Cash",
            "Ending Equity",
        }:
            print(
                f"{key}: "
                f"{value:,.2f}"
            )

        elif key == "Total Return":
            print(
                f"{key}: "
                f"{100.0 * value:.2f}%"
            )

        else:
            print(
                f"{key}: "
                f"{value}"
            )

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

    print()
    print(
        "Important:"
    )
    print(
        "This is a gross backtest."
    )
    print(
        "Fees and slippage are not "
        "included yet."
    )
    print(
        "Total return alone is not "
        "enough to judge a strategy."
    )

    print()
    print(
        "Files saved:"
    )

    for name, path in (
        files.items()
    ):
        print(
            f"{name}: {path}"
        )

    return 0


if __name__ == "__main__":
    raise SystemExit(
        main()
    )

23. Run the Self-Test First

python phase4_07_first_backtest.py --self-test-only

Confirm:

Self-test: PASS

Only then run the market-data version.

24. Run the Full Program

python phase4_07_first_backtest.py

The program downloads recent AAPL data, rebuilds the frozen SMA20 events, runs the account model, and saves the output files.

25. Files Saved

The program creates:

first_backtest_equity.csv
first_backtest_trade_ledger.csv
first_backtest_summary.csv

first_backtest_accounting_flow.png
first_backtest_trade_accounting.png
first_backtest_equity_curve.png

Keep the CSV files. They are the audit trail behind the charts.

26. Check Your Understanding

  • A position lifecycle becomes a backtest only after account values are tracked through time.
  • Signal Time and Execution Time remain separate.
  • Whole-share sizing leaves unused cash in the account.
  • Position Value equals Shares multiplied by the current Close.
  • Equity equals Cash plus Position Value.
  • Cash and Equity are different while a position is open.
  • An open position should not be force-sold unless the research rule explicitly says so.
  • This lesson produces a gross backtest because costs are not included yet.
  • Total return alone is not enough to judge a trading strategy.

27. What You Just Learned

Trading Event
↓
Next-Open Execution
↓
Position State
↓
Shares
↓
Cash
↓
Position Value
↓
Equity
↓
Equity Curve

This is the first point in Phase 4 where one fixed trading rule becomes a complete bar-by-bar account simulation.

The key idea is not that the SMA20 rule makes money. The key idea is that the experiment is now explicit enough to calculate account value without hiding timing or accounting assumptions.

28. Where Do We Go Next?

The first backtest still assumes frictionless trading.

The next question is:

What changes
when every BUY and SELL
has a cost?

Phase 4-08 will add:

fees
+
slippage
↓
net trade PnL
↓
net equity curve

Sources and Further Reading