Built & tested · paper mode only · not trading real money

A trading bot where the interesting part isn't the strategy.

A broker-agnostic execution engine for a single daily-bar setup. The strategy is about forty lines. The other 95% of the codebase exists to answer one question: what happens when the process dies halfway through placing an order?

160 passing tests pytest
60 paper trades required before live 0 logged · gate shut
4 broker adapters, one interface paper · alpaca · rh_crypto · robinhood
$0 real capital deployed journal on disk: zero orders

The Standout Feature

It will not let me trade real money.

Most hobby bots are one environment variable away from live capital. This one is not. Before any real-money broker call, the engine reads its own journal and refuses to proceed unless a genuine paper track record already exists — at least 60 closed paper trades at a positive realized expectancy. The rule started life in a markdown design doc, which meant a single flag could quietly skip it, so it was moved into Engine._guard_promotion_gate where it runs on every single pass.

Current record: 0 closed paper trades. The gate is therefore shut.

That is not a placeholder — it is the actual state of the order journal on disk, and it is exactly why no real capital has ever moved through this engine. Overriding the gate is possible, but only by setting a deliberately unpleasant environment variable named OVERRIDE_PROMOTION_GATE=I_ACCEPT_UNVALIDATED_RISK, and doing so writes a warning into the log and the journal. You cannot trip it by accident.

Gate 1 — explicit live opt-in

DRY_RUN=true and ALLOW_LIVE=false are the shipped defaults. A live-capable adapter raises rather than starts unless you set ALLOW_LIVE=true yourself. The default broker is a local simulator with no network and no keys.

Gate 2 — earned track record

Passing gate 1 is not enough. The engine then asks the journal for (n, expectancy) and raises unless n >= 60 and expectancy is strictly positive. The error message reports the current count and expectancy so a blocked run explains itself.

Gate 3 — explainable zeros

A gate_detail() breakdown reports closed trades per broker, because "I have closed trades but the gate says zero" has two very different causes — simulator trades, which are never eligible, and trades whose exit price was never recovered, which are excluded rather than fabricated.

The Problem

Automated trading fails at the plumbing, not the signal.

Every hobby trading bot has the same failure modes, and none of them are about picking the wrong stock. It crashes between sending an order and recording it, restarts, and sends the order again. It fills an entry and never gets a stop in. It sells a position twice because two exit orders were live against one lot. It quietly manages a position you opened by hand. Each of those loses real money in a way a backtest will never show you.


What I Built

Six invariants, enforced structurally.

Duplicate orders are impossible

Every signal maps to a deterministic client_order_id, and a SQLite journal claims that id before the broker call. Crash and restart, the same id is re-derived and the order is skipped rather than re-sent. A test fires twelve concurrent writers at one journal from separate connections and asserts exactly one wins the claim and eleven lose — the real failure mode being a scheduled job firing twice, or a retry racing the original run.

It fails closed

DRY_RUN=true and ALLOW_LIVE=false are the defaults. A live-capable broker adapter refuses to start unless you explicitly opt in. There is also a halt kill switch that blocks all new entries.

Nothing sits unprotected

A stop goes in immediately after a fill. Failing to place one is logged as an error, never swallowed. The stop and the 2R target are linked, so one filling cancels the other — otherwise the stale leg eventually sells shares you no longer own.

It only touches what it opened

Bot-owned positions are recorded in a bot_positions table at fill time, keyed by strategy, symbol and entry bar. Any position without a journal row is assumed to be mine and is never managed or exited.

Strategy code never imports a broker

Everything goes through one Broker interface, so paper and live run identical code paths. You are never testing something different from what you run.

Repair is a first-class command

reconcile rebuilds the journal against the broker after a crash, and manage is fully idempotent — safe to run on a cron as often as you like, because it never places a second copy of an order.

How It Works

Scan, size, place, protect, repair.

cli.py
scan        # signals only — never places an order, no API keys needed
status      # account, guards, open positions
run         # scan + trade + manage (DRY_RUN=true just logs the intent)
manage      # maintain exits on bot-owned positions — idempotent
halt / resume  # kill switch
reconcile   # repair the journal against the broker after a crash
The OCO problem, handled per broker. On Alpaca the two exit legs are a single broker-native OCO order, so the losing leg is cancelled server-side even if my process is dead — there is no window where both are live. The other adapters can't express that, so they get a software OCO: two GTC orders linked in the journal, with the sibling cancelled on the next manage pass. That is strictly weaker, and the code and docs say so rather than pretending the two are equivalent. The cancel itself is crash-safe: the leg is marked cancel_pending in the journal before the broker call, so a crash in that gap gets repaired instead of leaving a live sell order against shares that are already gone.

Install & Quickstart

Starts on a simulator, with no keys.

The repository is private today, so there is no public clone URL — access is by request rather than a link. Once you have a copy, setup is a virtualenv, an editable install and a copy of the example environment file. The default broker is a local paper simulator: no network, no credentials, nothing that can reach a real account.

setup
uv venv && uv pip install -e .
cp .env.example .env      # .env is gitignored
cli.py
python cli.py scan       # signals only — never places an order, no keys needed
python cli.py status     # account, guards, positions
python cli.py run        # scan + trade + manage (DRY_RUN=true just logs intent)
python cli.py manage     # maintain exits on bot-owned positions — idempotent
python cli.py halt       # kill switch: blocks all new entries
python cli.py resume
python cli.py reconcile  # repair the journal against the broker after a crash

Watchlist is one ticker per line in watchlist.txt, or pass --symbols SPY,NVDA. Broker adapters are optional extras, so only the one you pick gets installed. Start with scan — it reaches no broker and needs no credentials at all.

BROKER=Official APIPaper modeNotes
papern/aYesLocal simulator. No network, no keys. Start here.
alpacaYesYesRecommended for equities. Only adapter with broker-native OCO exits.
rh_cryptoYesNoOfficial Robinhood Crypto API. Crypto only; the setup was never tested on crypto.
robinhoodNoNoUnofficial equities adapter. Not a recommended path — see below.

On the unofficial Robinhood equities adapter: it exists in the codebase and the README documents it as a bad idea. Robinhood publishes no public stocks API, so that adapter impersonates the mobile app — it breaks Robinhood's terms of service, accounts have been restricted for automated access, it can break without notice, it has no paper mode, and it requires storing a password and TOTP seed locally. It is disclosed here for completeness, not offered as an option. Use Alpaca.

The Stack

Small surface, deliberately.

Python uv SQLite journal Alpaca API Robinhood Crypto API Local paper simulator Deterministic client order IDs Idempotent reconciliation Promotion gate pytest · 160 tests

Four adapters implement one Broker interface, so paper and live run identical code paths and you are never testing something different from what you run. The default is the local simulator. Alpaca is the recommended real adapter, because it publishes an official API, offers a genuine paper mode, and is the only one that can express a broker-native OCO exit pair.


Honest Status

It is not making money, and I won't pretend otherwise.

Built and tested

160 tests cover the safety properties directly — duplicate suppression under concurrency, promotion-gate enforcement, unprotected-position detection, ownership boundaries, closed-bar-only signals. The engine runs against the paper simulator today.

No real capital, ever

It has never traded live money and the journal on disk holds zero orders. The promotion gate is shut and stays shut until a paper record exists. No sales, no users, no returns are being claimed here.

Not a signals service

It does not publish alerts, sell picks, or run as a service for anyone. It is a program you run on your own machine against your own broker credentials, and it manages only the positions it opened itself.

No return promises

The setup it executes — AVWAP-reclaim — did not survive my own adversarial re-test: the signal's own contribution measured +0.030R with a 95% CI of [−0.014, +0.077], crossing zero. That is not a validated edge. This is a well-engineered execution engine wrapped around a strategy I do not believe in enough to fund.

Known gaps, from the README: positions opened before the ownership table existed are unmanaged by design; the software-OCO brokers have a real cancel-latency window; partial fills are handled conservatively rather than precisely; and market-hours checks on the non-Alpaca adapters use a simple clock that doesn't know about holidays. Nothing here is financial advice, and none of it is an invitation to run this with your money.
🔒 Repo access: the repository is private. Happy to walk through the journal design or the reconciliation logic on a call — email me.

The reasoning behind the guardrails is written out on its own: what has to be true before paper becomes live, why the journal is the system of record, and which brokers publish an API worth building against.

FAQ

Common questions.

What does this trading bot actually do?

It executes one daily-bar setup end to end: it scans a watchlist after the close, sizes a position from a fixed percentage of equity risked against the entry-to-stop distance, places the entry, immediately attaches a stop and a 2R target, and then manages those exits until one fills or a 20-bar time stop fires. There is no intraday logic and none was validated.

Can it trade real money right now?

No, and it structurally refuses to. Two separate gates stand in the way: a live-capable broker adapter will not start unless ALLOW_LIVE is explicitly set to true, and beyond that the promotion gate blocks real funds until a genuine paper track record exists. The record today is zero closed paper trades, so the gate is shut.

What is the promotion gate?

It is a check inside the engine that refuses to trade real money until the journal shows at least 60 closed paper trades at a positive realized expectancy. The rule originally lived only in a markdown design doc, which meant a single environment variable could bypass it, so it was moved into code where it runs on every pass. Overriding it requires a deliberately named environment variable and it logs a warning when used.

What happens if the process crashes while placing an order?

Every signal maps to a deterministic client order id, and a SQLite journal claims that id before the broker call rather than after it. On restart the same id is re-derived and the order is skipped instead of being sent twice. A concurrency test runs twelve competing writers against one journal and asserts that exactly one of them wins the claim.

Which broker should I use?

Alpaca, for two reasons: it publishes an official API, and it offers a real paper-trading mode so the safety work can be exercised without money at stake. It is also the only adapter that can express a broker-native OCO exit pair, which means the losing leg is cancelled server-side even while this process is dead. Start on the local paper simulator, which needs no network and no keys.

Does it come with a profitable strategy?

No, and that is stated deliberately. The setup it executes did not survive an adversarial re-test: the signal's own incremental contribution measured +0.030R with a 95% confidence interval crossing zero, which is not a validated edge. This is an execution engine wrapped around a strategy that has not earned real capital.

Is it free and open source?

There is no paid tier, no account and no telemetry, and it runs entirely on your own machine. The repository is private at the moment rather than published, so access is by request instead of a public clone URL.


⚠ Risk disclosure.

This trading bot is an educational and research tool. It is not financial advice, not investment advice, and not a recommendation to buy or sell any security. Every result referenced on this page is backtested or simulated rather than live trading performance, and backtested and past results do not indicate future results. Automated trading carries substantial risk of loss, including the total loss of principal, and software defects, broker outages and network failures can all cause losses beyond what a backtest shows. You are solely responsible for your own trading decisions. See Terms, Privacy and Refunds.

Related: the screener and the audit that broke its result · position-sizing and risk calculators · TradeLog, the offline R-multiple journal · all apps and projects.