Stock Trading Bot Architecture: Signals, Risk Gates, and Order State

한국어로 읽기

ENGINEERING NOTE / 01

A stock trading bot has two separate jobs: deciding what it wants to do and establishing what actually happened at the broker. Treating those jobs as one function is how a small connection problem turns into a duplicate order.

This walkthrough builds the boundary between the two. It uses invented closing prices and a local Python example; it does not connect to a broker or demonstrate a profitable trading strategy.

A signal is only a proposalObserve: Receive a closed bar; record its source and timestamp. → Decide: Compare the previous and current moving averages. → Check: Reject stale data, duplicate events and excess exposure. → Submit: Persist an intent, then send an order through the adapter. → Reconcile: Track acceptance, partial fills, fills and cancellation.TECH INSIGHT / FIELD NOTESA signal is only a proposal01ObserveReceive a closed bar; record its source and timestamp.02DecideCompare the previous and current moving averages.03CheckReject stale data, duplicate events and excess exposure.04SubmitPersist an intent, then send an order through the adapter.05ReconcileTrack acceptance, partial fills, fills and cancellation.Architecture sketch by Tech Insight. Risk checks happen before order submission.
Architecture sketch by Tech Insight. Risk checks happen before order submission.

1. Start with a data contract

A price without a timestamp is not enough input for a trading decision. A useful bar record identifies the instrument, interval, event time, source and whether the bar has closed. The signal function below expects positive, finite closing prices, ordered from oldest to newest, for the same instrument and interval.

Keep the broker response in an adapter and pass a small, explicit record to the strategy. That lets a saved data file and a live feed drive the same decision code. Decide what a stale or missing bar means before connecting an order endpoint: for a first version, rejecting a decision and recording the reason is easier to inspect than silently filling a gap.

2. A moving-average relationship is not a crossover

If the short average is already above the long average, testing only short_ma > long_ma produces BUY on every call. A crossover needs a previous relationship and a current relationship. This version emits BUY when the gap changes from zero or negative to positive, and SELL for the opposite transition.

The warm-up requirement is long + 1 bars. The extra bar is needed to calculate the previous long average. Equality alone produces HOLD. Python's statistics.fmean supplies the arithmetic mean; the event rules are defined here.

from math import isfinite
from statistics import fmean

def ma_cross_signal(closes: list[float], short: int, long: int) -> str:
    if type(short) is not int or type(long) is not int or not 1 <= short < long:
        raise ValueError("require integer windows: 1 <= short < long")
    if len(closes) < long + 1:
        return "HOLD"  # need both the previous and current windows
    window = closes[-(long + 1):]
    if any(not isfinite(p) or p <= 0 for p in window):
        raise ValueError("closes must be finite and positive")
    previous, current = window[:-1], window[1:]
    prev_gap = fmean(previous[-short:]) - fmean(previous)
    curr_gap = fmean(current[-short:]) - fmean(current)
    if prev_gap <= 0 < curr_gap:
        return "BUY"
    if prev_gap >= 0 > curr_gap:
        return "SELL"
    return "HOLD"

if __name__ == "__main__":
    print(ma_cross_signal([5, 4, 3, 6], 2, 3))  # BUY: upward crossing
    print(ma_cross_signal([1, 2, 3, 4], 2, 3))  # HOLD: already above
    print(ma_cross_signal([2, 4, 6, 1], 2, 3))  # SELL: downward crossing
    print(ma_cross_signal([5, 4, 3], 2, 3))     # HOLD: not enough data

Running this file with Python 3.12 produces:

BUY
HOLD
SELL
HOLD

The second line is the important counterexample: prices keep rising, but there is no new crossover. Five local test methods also cover flat prices, insufficient history, invalid window sizes and non-finite or non-positive input.

This is still a stateless function. Calling it twice with the same closed bar returns the same event twice. The caller must persist the last processed bar or an event key, such as strategy version + instrument + interval + bar close time. A SELL signal also needs a position rule; it is not permission to open a short position.

3. Put a risk gate before submission

Pass the signal to a separate gate that knows the account state. The strategy should not have to guess how much is already committed by an unfilled order. For a basic long-only simulation, the gate can check data freshness, existing holdings, outstanding orders and a configured exposure limit before creating an order intent.

These are design checks, not a promise that a stop order or exposure limit can prevent every loss. In production, price gaps, execution costs and the broker's order rules still matter. Keep the initial implementation in a local simulation or the broker's paper environment.

4. Distinguish a request from a fill

Consider a deliberately constructed failure: the broker accepts an order, but the connection closes before the client reads the response. A timeout tells the client that the result is unknown. It does not establish that the order was rejected.

ObservationBot stateNext action
Intent saved, request not sentPREPAREDRecord the request identity before submission.
Connection fails after submissionUNKNOWNCheck order and account state; avoid a blind second order.
Broker reports partial executionPARTIALLY_FILLEDAccount for both filled quantity and remaining quantity.
Broker reports cancellation completeCANCELEDPreserve any fills that occurred before cancellation.

Use a broker-supported request identity where available. For example, Alpaca documents submitting and retrieving orders with a client order ID. That is an example of a specific broker contract, not evidence that every broker deduplicates retries in the same way. Check uniqueness, lookup and retry semantics for the actual adapter.

5. A useful first milestone

A first bot is inspectable when one saved input can reproduce its signal, a repeated input cannot cause a second order intent, and a restart can reconcile its local state with outstanding orders. Add a new strategy only after you can explain those three paths.

Continue with the M1 → M2 → M3 development stages, or use the implementation review checklist to decide what to test next.

Content and examples reviewed: 2026-09-29

댓글