Skip to main content
This article is for educational purposes only and does not constitute financial advice. Trading involves risk of loss. Past performance does not guarantee future results. Consult a licensed financial advisor before making investment decisions.
AI & Automation18 min readUpdated October 3, 2026
TW

Build an AI Trading Agent with Alpaca Python: Paper Only

Build an Alpaca Python paper agent with completed IEX bars, validated model proposals, bounded bracket requests, dry run, and order reconciliation.

Put this into practice with a watchlist

Build a watchlist, then review each signal’s entry, stop, target, and reasoning. Broker access is optional.

Build a Watchlist

Build an AI Trading Agent with Alpaca Python: Start with a Paper Boundary

To build an AI trading agent with Alpaca, separate historical data, a model's proposal, validation, and broker order state. This tutorial uses the official alpaca-py SDK for a single pass in a paper account. It reads completed daily IEX bars, checks a JSON proposal, and prints a bounded bracket limit request by default. Paper submission requires a separate command-line flag. There is no live trading path.

The model adapter is a manual step: you give a data snapshot to your chosen research model, save its response, and review it before running the validator. The Python program does not call an LLM or run an autonomous loop. Model output never authorizes an order. This is educational software, not financial advice, a profitable strategy, or a production risk system.

1. Install the SDK and Use Separate Paper Credentials

Create an isolated Python 3.11+ environment and install the SDK. The published snippets were checked offline with alpaca-py 0.43.2; check the current official SDK when upgrading.

python3 -m venv .venv
source .venv/bin/activate
python -m pip install alpaca-py==0.43.2

Create a paper account and its own keys through Alpaca's paper setup. Supply them through your local secret manager as ALPACA_PAPER_API_KEY and ALPACA_PAPER_SECRET_KEY. Do not put credentials in the script, a model prompt, a repository, or a screenshot. The trading client below fixes paper=True and does not expose an endpoint override.

Save the three Python blocks in order in one file named paper_agent.py. Keep this demonstration separate from accounts used by other scripts. Its checks require an otherwise empty paper account.

2. StockBarsRequest and get_stock_bars: Completed Daily IEX Data

StockHistoricalDataClient.get_stock_bars(StockBarsRequest(...)) returns a BarSet. Its .data mapping groups bars by symbol; its .df property offers a pandas view indexed by symbol and timestamp. See the historical client reference, bar models, and stock request reference.

Use timezone-aware UTC boundaries. The example ends before the current UTC date and rejects any returned bar at or beyond that boundary, so a partially formed current-day bar is not used. It requires at least 20 ordered bars with finite, consistent OHLC values and rejects a snapshot more than seven calendar days old. These are illustrative input rules; they are not a complete exchange-calendar or corporate-action validator. Daily bars are historical observations, not a real-time signal.

DataFeed.IEX is explicit. Alpaca's paper-only account documentation identifies IEX as the entitled feed. IEX covers one exchange; it is not a consolidated view of the whole market. Raw prices are chosen explicitly, so a split or other corporate action needs a separate review before using the data. The fixed SPY symbol demonstrates request handling and is not an investment recommendation.

import argparse
import json
import os
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from pathlib import Path

from alpaca.common.exceptions import APIError
from alpaca.data.enums import Adjustment, DataFeed
from alpaca.data.historical import StockHistoricalDataClient
from alpaca.data.requests import StockBarsRequest, StockLatestQuoteRequest
from alpaca.data.timeframe import TimeFrame
from alpaca.trading.client import TradingClient
from alpaca.trading.enums import (
    AccountStatus,
    AssetClass,
    AssetStatus,
    OrderClass,
    OrderSide,
    QueryOrderStatus,
    TimeInForce,
)
from alpaca.trading.requests import (
    GetOrdersRequest,
    LimitOrderRequest,
    StopLossRequest,
    TakeProfitRequest,
)

SYMBOL = "SPY"  # Demonstration symbol, not a recommendation.
CENT = Decimal("0.01")


def number(value):
    if isinstance(value, bool) or not isinstance(value, (str, int, float, Decimal)):
        raise ValueError("Not a number")
    value = Decimal(str(value))
    if not value.is_finite() or value < 0 or value > Decimal("1e9"):
        raise ValueError("Number outside this example's bounds")
    return value


def utc(value):
    if value.tzinfo is None or value.utcoffset() is None:
        raise ValueError("Timezone required")
    return value.astimezone(UTC)


def completed_bars(data, now):
    end = utc(now).replace(hour=0, minute=0, second=0, microsecond=0)
    start = end - timedelta(days=45)
    response = data.get_stock_bars(
        StockBarsRequest(
            symbol_or_symbols=SYMBOL,
            start=start,
            end=end,
            timeframe=TimeFrame.Day,
            feed=DataFeed.IEX,
            adjustment=Adjustment.RAW,
        )
    )
    bars = list(response.data.get(SYMBOL, []))
    if len(bars) < 20:
        raise ValueError("Need at least 20 completed daily bars")
    previous = start - timedelta(seconds=1)
    rows = []
    for bar in bars:
        stamp = utc(bar.timestamp)
        prices = [number(getattr(bar, field)) for field in ("open", "high", "low", "close")]
        opening, high, low, close = prices
        if not start <= stamp < end or stamp <= previous:
            raise ValueError("Incomplete, duplicate, or unordered bar")
        if low <= 0 or not low <= min(opening, close) <= max(opening, close) <= high:
            raise ValueError("Invalid OHLC")
        number(bar.volume)
        rows.append({"timestamp": stamp.isoformat(), "close": float(close)})
        previous = stamp
    if end - previous > timedelta(days=7):
        raise ValueError("Daily data too old for this example")
    return rows[-20:]

Run python paper_agent.py after copying all three blocks. With no proposal file, the script makes a read-only historical-data request and prints a JSON snapshot. Check the symbol, feed, dates, and observations before sharing it with a model. It never sends account balances or broker keys to the model.

3. Ask for a Proposal, Then Validate It Independently

Give the model the exported snapshot and this instruction:

This is historical daily IEX research data, not a current market quote.
Return one JSON object with exactly: symbol, bar_time, direction,
confidence, entry, stop, target. Copy symbol and the last bar timestamp
exactly. direction must be "long" or "none". Use JSON numbers for all
four numeric fields. Choose "none" if the observations are insufficient.
For a long proposal, use prices >= 1 at whole cents and stop < entry < target.
Do not claim a confidence score proves a probability or profitable outcome.

Save the response as proposal.json and review it. Do not copy a hypothetical price example into an order. The validator rejects missing or extra fields, duplicate keys, strings and booleans in numeric fields, non-finite values, unsupported directions, and a mismatched data timestamp. A none proposal skips the order path. Its score cutoff of 70 is an arbitrary teaching filter, not a calibrated success probability. A valid schema says nothing about whether the idea is good.

def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError("Duplicate JSON key")
        result[key] = value
    return result


def reject_constant(value):
    raise ValueError(f"Invalid JSON number: {value}")


def proposal(text, bar_time):
    if len(text) > 4096:
        raise ValueError("Proposal too large")
    value = json.loads(
        text, parse_float=Decimal, parse_int=Decimal, parse_constant=reject_constant, object_pairs_hook=unique_object
    )
    fields = {"symbol", "bar_time", "direction", "confidence", "entry", "stop", "target"}
    if not isinstance(value, dict) or set(value) != fields:
        raise ValueError("Unexpected proposal schema")
    if value["symbol"] != SYMBOL or value["bar_time"] != bar_time:
        raise ValueError("Proposal does not match this data snapshot")
    if value["direction"] not in ("long", "none"):
        raise ValueError("Only long or none is supported")
    for field in ("confidence", "entry", "stop", "target"):
        if not isinstance(value[field], Decimal):  # Reject strings and booleans.
            raise ValueError("Proposal numbers must be JSON numbers")
        number(value[field])
    if not 0 <= value["confidence"] <= 100:
        raise ValueError("Invalid confidence range")
    if value["direction"] == "none":
        return None
    for field in ("entry", "stop", "target"):
        price = value[field]
        if price < 1 or price != price.quantize(CENT):
            raise ValueError("This example requires prices >= $1 at whole cents")
    if not value["stop"] < value["entry"] < value["target"]:
        raise ValueError("Require stop < entry < target")
    if value["confidence"] < 70:  # Arbitrary demonstration threshold.
        return None
    return value

If you automate the model adapter later, verify your provider's current authentication, model ID, structured-response support, timeouts, and costs separately. Keep the validator and broker controls outside the model. Do not grant it a tool that bypasses those controls.

4. Refresh Paper State and Construct a Bounded Bracket Limit Request

The next block checks Alpaca's clock, equity eligibility, positions, pending orders, a fresh IEX quote, and refreshed account balances. It rejects a closed regular session or an account that is not empty. It checks quote age against a fresh UTC clock after the quote request, rather than the earlier time used to request daily bars.

The sample's order-value cap is the smallest of $1,000, 2% of paper equity, 25% of paper cash, and 25% of reported buying power. A second sizing bound uses the smaller of $10 or 0.1% of equity divided by the proposed entry-to-stop distance. These arbitrary numbers illustrate taking the most restrictive limit; they are not recommended allocations or maximum-loss guarantees. Gaps and stop execution can produce a larger loss. A budget below one whole share causes a skip through an error, not a fractional order.

The SDK order request reference documents LimitOrderRequest, TakeProfitRequest, and StopLossRequest. This example uses a whole-share buy, a DAY limit entry, both bracket exits, and extended_hours=False. Prices must already be whole cents. It checks the stop at least $0.01 below the entry and observed IEX bid. Alpaca also checks the current market base when accepting advanced orders; the broker can still reject the request as prices move. An IEX quote is not the NBBO used by the paper fill simulator.

def existing_order(trade, client_id):
    try:
        return trade.get_order_by_client_id(client_id)
    except APIError as error:
        if error.status_code == 404:
            return None
        raise  # Authentication, throttling, and other failures are not absence.


def paper_request(trade, data, signal, client_id, now_fn):
    if not trade.get_clock().is_open:
        raise ValueError("Regular equity session is closed")
    asset = trade.get_asset(SYMBOL)
    if not asset.tradable or asset.status != AssetStatus.ACTIVE or asset.asset_class != AssetClass.US_EQUITY:
        raise ValueError("Not an active tradable equity")
    if trade.get_all_positions() or trade.get_orders(GetOrdersRequest(status=QueryOrderStatus.OPEN)):
        raise ValueError("Use an otherwise empty paper account; positions or orders exist")
    quote = data.get_stock_latest_quote(
        StockLatestQuoteRequest(
            symbol_or_symbols=SYMBOL,
            feed=DataFeed.IEX,
        )
    )[SYMBOL]
    age = (utc(now_fn()) - utc(quote.timestamp)).total_seconds()
    bid, ask = number(quote.bid_price), number(quote.ask_price)
    if not 0 <= age <= 60 or not 0 < bid <= ask:
        raise ValueError("Missing, crossed, or stale IEX quote")
    entry, stop, target = (signal[field] for field in ("entry", "stop", "target"))
    if not bid * Decimal("0.98") <= entry <= ask * Decimal("1.02"):
        raise ValueError("Entry is too far from this quote")
    if stop > min(entry, bid) - CENT:
        raise ValueError("Stop is too close to entry or quoted market")
    account = trade.get_account()  # Refresh balances just before sizing.
    if account.status != AccountStatus.ACTIVE or account.trading_blocked or account.account_blocked:
        raise ValueError("Paper account is blocked or inactive")
    equity, cash, buying_power = (number(getattr(account, field)) for field in ("equity", "cash", "buying_power"))
    # Arbitrary teaching limits, not a risk prescription or a loss guarantee.
    budget = min(Decimal("1000"), equity * Decimal("0.02"), cash * Decimal("0.25"), buying_power * Decimal("0.25"))
    stop_distance_budget = min(Decimal("10"), equity * Decimal("0.001"))
    qty = int(min(budget // entry, stop_distance_budget // (entry - stop)))
    if qty < 1:
        raise ValueError("Budget cannot support one whole share")
    return LimitOrderRequest(
        symbol=SYMBOL,
        qty=qty,
        side=OrderSide.BUY,
        limit_price=float(entry),
        time_in_force=TimeInForce.DAY,
        order_class=OrderClass.BRACKET,
        extended_hours=False,
        take_profit=TakeProfitRequest(limit_price=float(target)),
        stop_loss=StopLossRequest(stop_price=float(stop)),
        client_order_id=client_id,
    )


def run_once(
    trade, data, text, *, now, dry_run=True, attempt_dir=Path(".paper-guide-attempts"), now_fn=lambda: datetime.now(UTC)
):
    rows = completed_bars(data, now)
    signal = proposal(text, rows[-1]["timestamp"])
    if signal is None:
        return {"result": "skip"}
    # One decision per symbol/completed bar, even if model prices change later.
    client_id = f"paper-guide-v1-{SYMBOL}-{rows[-1]['timestamp'][:10]}"
    existing = existing_order(trade, client_id)
    if existing is not None:
        return {"result": "existing", "id": str(existing.id), "status": str(existing.status)}
    request = paper_request(trade, data, signal, client_id, now_fn)
    if dry_run:
        return {"result": "dry_run", "request": request.model_dump(mode="json", exclude_none=True)}
    attempt_dir.mkdir(parents=True, exist_ok=True)
    # Keep this marker after every result, including a timeout or rejection.
    with (attempt_dir / f"{client_id}.json").open("x") as marker:
        json.dump({"client_order_id": client_id}, marker)
        marker.flush()
        os.fsync(marker.fileno())
    try:
        order = trade.submit_order(order_data=request)
    except Exception:
        recovered = existing_order(trade, client_id)
        if recovered is None:
            raise RuntimeError("Submission unresolved. Inspect the paper broker and marker; do not retry.") from None
        order = recovered
    return {"result": "inspect_order", "id": str(order.id), "status": str(order.status)}


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--proposal", type=Path)
    parser.add_argument("--submit-paper", action="store_true")
    args = parser.parse_args()
    if args.submit_paper and args.proposal is None:
        parser.error("--submit-paper requires a reviewed --proposal file")
    key, secret = os.environ["ALPACA_PAPER_API_KEY"], os.environ["ALPACA_PAPER_SECRET_KEY"]
    data = StockHistoricalDataClient(key, secret)
    now = datetime.now(UTC)
    if args.proposal is None:
        print(json.dumps({"symbol": SYMBOL, "feed": "iex", "bars": completed_bars(data, now)}))
        return
    trade = TradingClient(key, secret, paper=True)  # No live endpoint option.
    result = run_once(trade, data, args.proposal.read_text(), now=now, dry_run=not args.submit_paper)
    print(json.dumps(result))


if __name__ == "__main__":
    main()

Put the setup on a watchlist first

Use the rules in this guide to evaluate a signal’s entry, stop, target, and reasoning before deciding what, if anything, to do.

Build a Watchlist

5. Dry Run First; Inspect Order State After Paper Submission

python paper_agent.py --proposal proposal.json

This prints the proposed SDK request without submitting it. Dry run still makes read-only data and paper-account API calls. The offline verification of this article used fake clients and real SDK request models; it did not connect to a broker or submit an order.

Only after inspecting the proposed symbol, mode, quantity, prices, and account state, you can explicitly request the paper submission path:

python paper_agent.py --proposal proposal.json --submit-paper

The result prints the broker's returned order ID and status. new, accepted, or an HTTP success is not proof of a fill. Inspect filled quantity, average fill price, remaining quantity, and each exit leg in the paper dashboard or order API. The code does not poll, cancel, replace, or flatten a position for you.

The client order ID is stable for the symbol and last completed daily bar. A rerun checks that ID first, including rejected or canceled orders. Only a confirmed HTTP 404 counts as absence; authentication failures and timeouts abort. Before submission, an exclusive local attempt file is written and retained after every result. If submission raises an error, the program queries the same ID once and does not automatically make another application-level submission. The SDK can have its own transport retry behavior; this is not a claim of exactly one HTTP request.

An unresolved response requires inspecting both the paper broker and the retained attempt file. Do not delete the marker or change the order ID to bypass that inspection. This persistent local marker protects a shared local path from a repeated application attempt. It is not a distributed lock, a crash-proof transaction, or a general recovery system. Separate API reads are also not an atomic snapshot: another process or manual action can change the account between checks. Use one operator and one script for this exercise.

6. What Brackets and Paper Fills Do Not Guarantee

Alpaca's order documentation explains that bracket exit legs activate only after the entry is completely filled. A partial entry can therefore exist before either exit is active. In a fast market, both exit orders can fill before cancellation reaches the other leg. A sell stop becomes a market order when triggered and does not promise its trigger price as the fill price.

DAY orders can expire at the close while a position remains open. This example has no monitoring or end-of-day flattening loop. Inspect the position and the actual status of both remaining exit legs; do not assume a bracket provides continuing protection. Extended-hours bracket execution is not supported here. Stopping this script prevents its next application call; revoking keys does not itself confirm cancellation of already accepted orders or close a position.

Alpaca's paper specification identifies simulation omissions such as market impact, latency-related slippage, order queue position, fees, and dividends. A paper record cannot establish live profitability. This tutorial does not cover short selling, fractional brackets, options, crypto, live-account eligibility, legal requirements, or broker-specific account restrictions. Consult the current broker documents for those separate topics.

Next Steps for Research

Review invalid inputs, empty data, stale quotes, denied access, insufficient cash, rejected requests, partial fills, session close, and ambiguous responses. Keep original proposals and skipped decisions alongside order-state observations. There is no fixed number of paper trades or days that proves a system is ready for live trading.

Use the AI bot research prototype guide for per-symbol data work, the broker API testing checklist for permission and order-state questions, and the paper trading guide for simulation limits. Tradewink's public workflow supports research, signal review, and paper practice. Public subscriptions are paper-only; separately approved private beta accounts may submit live broker orders. This educational script is not Tradewink's production trading engine.

Frequently Asked Questions

Why does get_stock_bars return missing or empty data?

Check symbol, UTC start and end, feed entitlement, and whether the period contains completed sessions. BarSet.data groups bars by symbol; BarSet.df provides a pandas view with symbol and timestamp indexes. This example stops on missing or insufficient bars instead of inventing values.

Are daily StockBarsRequest bars real-time signals?

No. This tutorial uses completed historical daily IEX bars for research. It separately reads a fresh IEX quote before preparing a paper request. IEX covers one exchange and is not a consolidated market feed.

Does dry run call Alpaca or place an order?

Dry run reads historical data and, with a proposal file, paper-account and quote state. It prints a request without submitting it. Offline tests use fake clients and SDK models. Only the explicit --submit-paper flag enters the paper submission path; the client is fixed to paper=True.

What should I do after a paper submission timeout?

Inspect the stable client order ID in the paper broker and retain the local attempt marker. The example reconciles once and makes no automatic application-level resubmission. Only HTTP 404 proves absence for the initial lookup; other lookup errors abort. A local marker is not distributed or crash-proof recovery.

Does a bracket guarantee a stop or protect a partial fill?

No. Alpaca activates the exits only after the parent fills completely. Both exits can fill before cancellation in a fast market, and stop fill prices are not guaranteed. DAY orders can expire while a position remains. Inspect parent, filled quantity, positions, and remaining exit legs.

Can an LLM confidence score or paper result prove profitability?

No. A model score is not a calibrated probability. Schema validation and sample budget caps do not validate a strategy. Paper fills omit real-market effects, so no fixed trade count or review duration establishes live readiness.

Keep learning with a related guide before putting an idea on your watchlist.

Ready to evaluate a signal?

Start free with a watchlist and inspect the context before you consider a broker connection.

Try AI signals on your watchlist

Send yourself a signal preview, then add tickers to see ranked entries, exits, and risk notes in Tradewink.

Enter the email address where you want to receive a Tradewink AI signal preview.

TW

Tradewink builds explainable market research for self-directed traders. Build a watchlist, inspect signal reasoning and risk context, and paper-track ideas before you decide. Public subscriptions are paper-only; separately approved private beta accounts may submit live broker orders.

How this guide is reviewed

Tradewink reviews educational content against its documented market-data sources, risk controls, and product methodology. See our data sources and evaluation methodology for the evidence and limitations behind the platform.

Important disclosures

Informational purposes only

Tradewink is published by Tradewink LLC, which is not a registered investment adviser, broker-dealer, commodity trading advisor, or financial planner. All data, signals, and analytics on this page are general, impersonal, and for informational purposes only. They do not constitute investment advice, financial advice, or a recommendation to buy or sell any security or other instrument.

Trading risk

Past performance does not guarantee future results. Trading involves substantial risk of loss, including the possibility of losing more than your initial investment. You are solely responsible for your own trading decisions.