Skip to content
All work

agentic-trader

A language model in the loop, never in the decision.

Equity trading · AgenticShadow mode · no live tradesPublic repositoryUpdated 26 Aug 2026
View source

An agent-driven equity trading system for Robinhood, built on the Robinhood MCP server. Claude fetches the data and argues against the trade; a deterministic Python core makes every decision. The boundary between them is JSON, and nothing in the core can reach the broker.

Problem
Evaluate equity trades with agent-collected context without giving a model control of risk or broker access.
Constraint
Missing market data, model confidence, or a stray call could silently enlarge or place an order.
Decision
Put deterministic Python gates and sizing behind a JSON seam; the critic may veto or shrink, and broker tools remain outside the core.
Evidence
133 tests with a blocking test for every risk gate; shadow mode only, with no live trades placed.
PythonMCPSQLitepytestruff

What it is

Most agentic trading designs put the model in the decision path, where its output cannot be reproduced and its risk arithmetic cannot be checked. This one inverts that. Claude does what a language model is good at — pulling data through MCP tools, reading context an indicator cannot see, arguing adversarially against a proposed trade, and deciding when to involve a human. Python does the same arithmetic every time, behind gates that cannot be talked around.

The seam between the two is JSON. Nothing in src/ can reach the broker, so no test, import, or stray call can place an order. Every cycle is written to an audit stream — including the overwhelming majority that decide to do nothing, which 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 saw a setup.

The cycle

When a setup qualifies, the intent carries its own reasoning: a thesis and an invalidation condition, both written at entry, before the outcome is known. That is the difference between a post-mortem that reads back what you believed and one that reconstructs what you wish you had believed.

  1. Claude
    MCP tools — quotes, bars, indicators, fundamentals, earnings
    JSON
  2. build_snapshot()
    market/ — MCP payloads become one snapshot
  3. strategy.evaluate()
    opinions only — no account access, no sizing
    Signal
  4. RiskEngine.evaluate()
    gates, then sizing backward from the stop
    RiskDecision
  5. critique()
    re-derives the trade mechanically
    CriticReport, then one bounded re-size
  6. build_order_payload()
    ExecutionPlan — the JSON seam
    human confirmation
  7. place_equity_order
    back through Claude, never from the core
Risk sizes the position before the critic runs, so the critic attacks a concrete order rather than an abstract signal. Its confidence adjustment then feeds back through exactly one bounded re-size pass, which may only shrink the result.

Sizing and gates

Sizing works backward from the loss you accept, not forward from the cash you hold. A risk budget is a percentage of account value; the notional is that budget divided by the stop distance. A 1% budget with a 5% stop is a 20% position, and the same budget with a 10% stop is 10%. The stop decides the size, never conviction.

Sizes are in dollars rather than shares, because a $100 account cannot buy one share of a $300 stock and a share-based sizer would simply never trade. Every gate is configured in config/risk.yaml, schema-validated at startup, and carries a test proving it blocks.

Daily loss
max_daily_loss_pct blocks new entries and resets tomorrow. kill_switch_daily_loss_pct is sticky — it writes HALT, and a human must clear it.
Concentration
max_position_pct, max_open_positions, max_portfolio_exposure_pct, and max_sector_exposure_pct, which caps sizing as well as exposure.
Setup quality
min_risk_reward rejects setups whose target does not justify the stop; max_stop_pct rejects a stop so wide the setup is too loose to size.
Event and behaviour
earnings_blackout_days and symbol_cooldown_days.
Executability
min_avg_volume_30d, max_spread_pct measured on the real book at submission, and max_price_drift_pct — how far price may move from the decision price before the setup is re-evaluated rather than chased.

Three behaviours are deliberate and would otherwise look like bugs. The kill switch does not block exits, because an automatic control that strands you in losing positions until you notice does more damage than the loss that tripped it; it writes HALT so the next cycle stops entirely, by which point a human is involved. The sector cap binds immediately, since the default universe is all one sector — the fix is a more diversified universe, never a looser cap. And min_risk_reward never fires for the implemented strategy, which builds its target at exactly 2R; raising it above 2.0 blocks every entry rather than improving selectivity.

Unknown is refused, never assumed benign

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.

  • Quote age — refuses when the quote's venue timestamp is older than 120 seconds, or absent entirely.
  • Spread — refuses when bid/ask is missing, zero (the broker's no-book sentinel), or crossed.
  • Drift — refuses when the live price has run away from the price the decision was made at.

This is stated plainly because the earlier version of all three was unfalsifiable. Staleness was measured against a field stamped now() when the snapshot was built, so every snapshot looked fresh — including one replayed from storage 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 now returns None rather than a zero for an unusable book for exactly this reason: a zero spread would sail through the tightest possible threshold on the worst possible information.

The same principle governs the critic. It re-derives the trade mechanically and may return a confidence adjustment, which is clamped non-positive; 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.

Risk limits are enforced twice, because the two layers fail differently. A PreToolUse deny hook refuses edits to the risk config, refuses the lock command, and refuses shell commands that would write to those files, while still allowing reads. Independently, an integrity lock stores a SHA-256 of the validated risk values and verifies it 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 a deliberate two-step human act, both steps visible in git history.

The known gap

Positions cannot be protected at this account size, and the project says so rather than shipping around it. The entry order cannot carry a broker-native stop, so stops here are managed — a separate order has to follow the fill. Investigating whether one could be placed turned up a harder constraint than "not implemented yet": fractional quantities are accepted only on market orders, and a stop order is not a market order, so a fractional position cannot carry a resting stop at all.

That binds because of how sizing works. The notional is the risk budget divided by the stop distance — about $20 today, against a universe trading at $250 and up. Every position this account can take is unprotectable. But the threshold depends on stop distance, which is currently a flat percentage and will vary per symbol once stops are volatility-scaled. So "trade cheaper stocks" is not established as the answer, and forcing a price ceiling into the scanner to suit a $100 test account would distort which setups the strategy sees. The honest sequencing is to find what the strategy wants first, then ask what capital that requires.

  • 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.
  • ProtectionState records the truth per position, and UNAVAILABLE is distinct from FAILED: 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 the estimated maximum loss is a modeled figure and not a floor. The restriction itself is tracked as schema-documented rather than empirically verified, because confirming it would mean placing a real order — which this project will not do to settle a question.

Status

Shadow mode. The trend-pullback strategy is implemented and tested; a second strategy is stubbed with a documented design sketch and disabled. No live trades have been placed.

133 tests, ruff clean, with coverage 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 deliberately not offered as evidence of live readiness. 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.