An agent-driven equity trading system for Robinhood, built on the Robinhood MCP server. Claude orchestrates; a deterministic Python core makes every decision.
This places real orders with real money. It ships in shadow mode and should stay there until it has a track record. See Safety.
Most "AI trading bot" designs put the model in the decision path, where its output is unreproducible and its risk math is unverifiable. This one inverts that:
- Claude does what it is good at — fetching data through MCP tools, reading context an indicator cannot see, arguing adversarially against a proposed trade, and deciding when to ask a human.
- Python does what it is good at — the same arithmetic every time, gates that cannot be talked around, and a decision that can be replayed months later from its stored snapshot.
The boundary is JSON. Nothing in src/ can reach the broker, so no test,
import, or stray call can place an order.
Claude Python core
────── ───────────
MCP: quotes, bars, indicators,
fundamentals, earnings ──────► build_snapshot()
│
strategy.evaluate() → Signal
│
RiskEngine.evaluate() → RiskDecision
│
critique() → CriticReport
│
re-size (once, may
only shrink)
│
◄────── build_order_payload() → ExecutionPlan
review_equity_order
human confirmation
place_equity_order
Risk sizes before the critic runs, so the critic attacks a concrete order rather than an abstract signal. Its confidence adjustment then feeds back through a single bounded re-size pass.
Every cycle writes an audit entry — including the overwhelming majority that decide to do nothing. Those are the more valuable half of the record: a system that logs only its trades cannot tell you whether its filters work or whether it simply never sees a setup.
python -m venv .venv
.venv/Scripts/python.exe -m pip install -e ".[dev]"
# Optional: record which account to trade. Gitignored — account numbers are
# identifying information and permanent once committed. The agent resolves the
# account at runtime via get_accounts regardless, so this only saves a lookup.
cp config/account.example.yaml config/account.local.yaml
.venv/Scripts/python.exe -m agentic_trader.cli config-check
.venv/Scripts/python.exe -m pytest -qThen, in Claude Code:
/analyze-trade AAPL
Or drive the core directly with a bundle of raw MCP responses:
.venv/Scripts/python.exe -m agentic_trader.cli evaluate --input bundle.jsonAAPL on 2026-08-12, actual market data:
outcome: watch
reasons:
trend: price 302.25 vs SMA200 280.09 ✓
ma_stack: SMA50 309.48 vs SMA200 280.09 ✓
pullback: price 302.25 vs SMA20 321.22 ✓
rsi_band: RSI 40.21 vs [30, 45] ✓
pullback_depth: 5.91% below SMA20 (max 12%) ✓
failed_conditions:
momentum_stabilizing: MACD hist -3.34 vs prior -3.27 ✗
Five of six conditions pass. The sixth — a MACD histogram still deepening —
is the one that separates buying a pullback from catching a falling knife, so
the setup is watch, not enter. Remove that condition and the strategy will
happily buy the middle of a collapse.
When a setup does qualify, the intent carries its own reasoning:
outcome: shadow_filled
BUY AAPL $13.86 (~0.045856 sh @ ~302.25)
thesis:
AAPL remains in an uptrend — price 302.25 holds above its 200-day
(280.09) with the 50-day (309.48) above it — and has pulled back 5.91%
to the 20-day (321.22) while momentum stops deteriorating. Buying the
discount, not the breakdown.
invalidation_reason:
A close below the 50-day (309.48) breaks the uptrend premise this trade
rests on; the stop at 287.14 enforces that. RSI back below 30, or the
MACD histogram resuming its decline, means the pullback became a
breakdown and the setup was misread.
reward-to-risk 2.00 (minimum 2.00)
modeled loss 0.69 (not a floor — managed stop, market entry, gap risk)
sector Electronic Technology — 0.00 of 25.00 cap
The thesis and invalidation condition are written at entry, before the outcome is known. That is the difference between a post-mortem that reads what you believed and one that reconstructs what you wish you had believed.
trend_pullback buys an orderly retracement inside an intact uptrend, and only
once the selling has measurably stopped. Exits when the premise breaks (price
loses the 50-day) or momentum runs to an extreme.
momentum is a stub with a documented design sketch; it is disabled.
Sizing works backward from the loss you accept, not forward from the cash you hold:
stop distance = clamp(ATR(14) × multiple, floor, ceiling)
risk budget = account value × risk_per_trade_pct
notional = risk budget ÷ stop distance
A 1% budget with a 5% stop is a 20% position; the same budget with a 10% stop is 10%. The stop decides the size, not conviction — and the stock decides the stop. Reversing that order, picking a size and then finding a stop to fit it, is how a position ends up sized by preference rather than by risk.
The stop comes from measured volatility. ATR(14) scaled by a multiple, so a name that routinely moves 3% a day gets a wider stop and a proportionally smaller position than one that moves 1%. Three things bound it:
| Bound | Why |
|---|---|
Floor (min_stop_pct) |
Sizing divides by this. A 0.5% stop implies 200× the risk budget in notional. |
Ceiling (max_stop_pct) |
Volatility beyond it declines the setup rather than clamping — a stop at the ceiling would sit inside the stock's ordinary daily range, so the risk would only look bounded. |
| Structure | The 50-day may widen the stop, never tighten it. It may exceed the ceiling, and the risk engine refuses it there. |
When ATR is unavailable the stop falls back to a flat percentage and records
stop_basis: flat_pct. That still trades — refusing would mean no trades
whenever an indicator call fails — but the critic shrinks the position for it,
because a risk boundary asserted from a constant is a weaker claim than one
measured from the stock.
Sizes are in dollars, not shares — a $100 account cannot buy one share of a $300 stock, so a share-based sizer would simply never trade.
All configured in config/risk.yaml, all schema-validated at startup, each
with a test proving it blocks.
| Gate | Behaviour |
|---|---|
max_daily_loss_pct |
Blocks new entries. Resets tomorrow. |
kill_switch_daily_loss_pct |
Sticky. Writes HALT; a human must clear it. |
max_sector_exposure_pct |
Caps combined exposure to one sector, and caps sizing. |
min_risk_reward |
Rejects setups whose target does not justify the stop. |
max_position_pct, max_open_positions, max_portfolio_exposure_pct |
Concentration ceilings. |
max_stop_pct |
A stop this wide means the setup is too loose to size. |
earnings_blackout_days, symbol_cooldown_days |
Event and behavioural gates. |
min_avg_volume_30d |
Liquidity floor. |
max_spread_pct |
Real bid/ask spread at submission — paid in full on a market order. |
max_price_drift_pct |
How far price may move from the decision price before the setup is re-evaluated rather than chased. |
Three behaviours that are deliberate and will otherwise look like bugs:
- The kill switch does not block exits. It fires automatically, and an
automatic control that strands you in losing positions until you notice does
more damage than the loss that triggered it. It writes
HALT, so the next cycle stops entirely — by then a human is involved. - The sector cap binds immediately. The default universe is all one sector, so one position at the ceiling blocks the next. The fix is a more diversified universe, never a looser cap.
min_risk_rewardnever fires fortrend_pullback, which builds its target at exactly 2R. It guards future strategies whose targets come from structure. Raising it above 2.0 blocks every entry instead of improving selectivity.
Config is cross-validated, so contradictory setups fail at startup rather than behaving strangely later — a kill switch at or below the daily limit, or a position ceiling above the sector ceiling, are both rejected outright.
The last gate before an order exists is preflight, and all three of its checks
treat missing data as a failure rather than a pass:
| Check | Refuses when |
|---|---|
| Quote age | The quote's venue timestamp is older than 120s — or absent. |
| Spread | Bid/ask is missing, zero (the broker's no-book sentinel), or crossed. |
| Drift | Live price has run away from the price the decision was made at. |
This is worth stating because the earlier version of all three was unfalsifiable.
Staleness was measured against captured_at, which is stamped now() when the
snapshot is built — so every snapshot looked fresh, including one replayed from
a stored bundle months later. The spread check compared the live price to the
decision price, which is drift, not spread; bid and ask were never read at all.
Both passed every test they had.
A control that cannot fail is worse than a missing one, because it earns trust
it has not done anything to deserve. spread_pct returns None rather than
Decimal("0") for an unusable book for exactly this reason: a zero spread would
sail through the tightest possible threshold on the worst possible information.
market/market_regime.py classifies the backdrop from SPY, confirmed by QQQ:
BULL_TREND, BEAR_TREND, RANGE, HIGH_VOLATILITY, UNKNOWN. Volatility
outranks direction, and SPY/QQQ disagreeing downgrades a trend to RANGE — a
split market means the move is sectoral, not market-wide.
Nothing gates on it. No strategy consults it and no limit reads it. The useful version of this rule looks like
trend_pullback BULL_TREND expectancy +0.34R
RANGE expectancy -0.18R
HIGH_VOLATILITY expectancy -0.52R
and none of those numbers exist yet. Wiring a guess into a gate now would suppress exactly the trades needed to find out whether the guess was right. The regime and the inputs that produced it are journalled on every cycle; the rule follows the evidence, not the other way round.
agents/critic.py re-derives the trade mechanically and can return a
confidence_adjustment. It is clamped non-positive, and the orchestrator
re-sizes exactly once and asserts the notional did not increase.
This is not stylistic. Confidence multiplies notional in the sizer, so an adjustment that could raise it would let a language model enlarge a position — the one coupling this architecture exists to prevent. The model may veto or shrink. It may never amplify.
| Control | Mechanism |
|---|---|
| No accidental orders | Nothing in src/ can reach the broker |
| Manual kill switch | touch HALT at the project root |
| Automatic kill switch | Daily-loss breach writes HALT; sticky until cleared |
| Risk limits are immutable to the agent | Deny hook + integrity lock, below |
| Idempotency | Deterministic UUIDv5 ref_id + a unique DB constraint |
| Stale data | Preflight rejects old snapshots and excess price drift |
| Independent review | agents/critic.py re-derives the trade mechanically |
| LLM cannot enlarge a position | Critic confidence adjustment clamped non-positive |
| Wrong account | Only agentic_allowed: true accounts are permitted |
| Secrets | Account numbers live in gitignored local config, masked in output |
| Audit trail | Every cycle stored with its full snapshot, replayable |
The agent must not be able to widen a limit to fit a trade that was correctly blocked. Two independent layers, because they fail differently:
- A
PreToolUsedeny hook (.claude/hooks/guard_risk_config.py) refuses edits toconfig/risk.yamlandconfig/risk.lock, refuses thelock-riskcommand, and refuses shell commands that would write to those files while still allowing reads. It always exits 0 and returns its decision as JSON — a hook that could exit non-zero would block every tool call in the session. - An integrity lock (
config/risk.lock) stores a SHA-256 of the validated risk values. It is verified on every config load, so a change made by any route — including one the hook never sees — stops the system and names the key that moved.
Changing a limit is therefore a deliberate two-step human act, both steps visible in git history:
# 1. edit config/risk.yaml by hand, then:
python -m agentic_trader.cli lock-risk --confirmLayer 1 constrains this agent; layer 2 catches everything. Neither is meant to stop a determined human, and layer 1 is a speed bump rather than a boundary — the lock is the boundary.
Known gap: positions cannot be protected at this account size.
The entry order cannot carry a broker-native stop, so stops here are managed —
a separate stop_market order has to follow the fill. Investigating whether the
MCP could place one turned up a harder constraint than "not implemented yet":
Fractional quantities are accepted only on
type=market, and astop_marketorder is nottype=market. So a fractional position cannot carry a resting stop at all.
That binds because of how sizing works. notional = risk_budget / stop_distance
is about $20 today, so a position is whole shares only for a stock under roughly
$20 — and the universe trades at $250+. Every position this account can take
is unprotectable.
Note what that threshold depends on, though: stop_distance, which is now
derived from ATR and so varies per symbol rather than sitting at a fixed
percentage. A $0.50 stop on a $1 risk budget affords a $2 notional; a $2.00 stop
affords $0.50. There is no single price ceiling that separates protectable from
unprotectable — it moves with each stock's volatility. So "trade cheaper stocks"
is not established as the answer, and forcing a price ceiling into the scanner
to accommodate a $100 test account would distort which setups the strategy
sees. The honest sequencing is: find what the strategy
actually wants, then ask what capital that requires under whole-share
protection. A $100 account may simply be adequate for shadow validation and
inadequate for protected execution, which is a fine answer.
What exists instead:
- The strategy enforces the stop each cycle — exiting when the live price is at or below the level, and when a bar's low touched it even if price recovered, since a resting order would have filled there.
ProtectionStaterecords the truth per position, andUNAVAILABLEis distinct fromFAILED: one is a standing property of the account, the other an incident.- Shadow mode may carry an unprotectable position and journals why. Live and approval execution refuse it structurally — no configuration reaches that check.
This is why estimated_max_loss is a modeled loss and not a floor.
On confidence: the restriction is SCHEMA_DOCUMENTED, not
EMPIRICALLY_VERIFIED. review_equity_order previewed a fractional
stop_market sell without complaint — but it also accepted a short sale in an
account holding none of the symbol, so it appears not to validate order
parameters at all. Confirming the rule would mean placing a real order, which
this project will not do to settle a question. execution/capabilities.py tracks
that distinction per capability rather than burying it in a comment.
src/agentic_trader/
models/ domain types crossing every layer
market/ MCP payloads → snapshot; signals; regime
strategies/ opinions only — no account access, no sizing
risk/ limits, sizing, engine
agents/ orchestrator (one pure cycle), critic
execution/ payload construction, shadow fills
journal/ SQLite audit stream + trade records
config/ schema-validated loading + integrity lock
cli.py the JSON seam
config/
risk.yaml limits (agent cannot edit)
risk.lock SHA-256 baseline of the above
strategies.yaml per-strategy switches and params
account.example.yaml template; copy to account.local.yaml (gitignored)
.claude/
skills/ analyze-trade, critique-trade, review-performance
hooks/ guard_risk_config.py — the deny hook
settings.json wires the hook (tracked, so protection travels with the repo)
In rough priority order:
- A more diversified universe and a scanner, which the sector cap already
demands. Robinhood exposes
run_scanandget_scanner_filter_specsserver-side, which may replace much of a hand-built scanner. Deliberately not constrained by a price ceiling chosen to suit the current account. - Then, with ATR stops and a real universe known: how much capital this
strategy needs for whole-share broker protection. Answering it earlier would
be guessing — ATR makes the stop distance vary per symbol, so the price
ceiling implied by
risk_budget / stop_distanceis no longer one number. - The protective-stop lifecycle — submit, confirm acceptance, record the broker order id, monitor, reconcile on restart. Gated on (2) — until positions can be whole shares it could never leave its first state. Note there is no replace/modify tool, so moving a stop means cancel-then-place with an unprotected window in between.
- Normalized journal, a session-aware
ShadowExecutorwith realistic spread, slippage and stop-gap modelling, and a baseline-vs-critic A/B to establish whether the critic actually improves expectancy. ApprovalExecutor— last, and gated on evidence rather than on a green test suite (see safety rule 8).
Shadow mode. trend_pullback implemented and tested; momentum stubbed.
No live trades placed.
160 tests, ruff clean. Test coverage is weighted toward the negative cases — every risk gate has a test proving it blocks, because a limit that silently fails open is worse than no limit at all.
That number is not evidence of live readiness, and is deliberately not offered as any. A green suite shows the code does what it was written to do. It says nothing about whether the strategy has an edge, whether shadow fills resemble real ones, whether the system survives a restart mid-position, or whether its view of the account matches the broker's. None of those are established yet.