Skip to content

Latest commit

 

History

33 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

agentic-trader

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.

The idea

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.

How a cycle works

Discovery and evaluation are separate commands because they cost different amounts. Discovery works on batched payloads — one get_scans, eight run_scan, and fundamentals at ten symbols per call — and its job is to decide which candidates justify the ~9 single-symbol calls that evaluation costs.

  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.

Quick start

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 -q

Then, 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.json

What a real result looks like

AAPL 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.

Discovery

The sector cap made a single-sector universe untenable, so candidate selection runs as its own phase against Robinhood's server-side scanner.

run_scan returns at most 200 rows, offers no pagination, and reports a total_items that does not survive scrutiny. One scan of the tradable universe hit that cap — meaning the result was the first 200 by market cap, not everything matching. A biased universe that looks complete.

The fix is structural rather than a re-sort. The universe is partitioned into eight market-cap bands, each a saved scan, sized so every one returns well clear of the cap:

Band Band
B1 $2B–$3B B5 $10B–$17.5B
B2 $3B–$4.5B B6 $17.5B–$35B
B3 $4.5B–$7B B7 $35B–$100B
B4 $7B–$10B B8 >$100B

The definition in force is agentic-discovery@v3-2026-08-20, fingerprinted fca219ad…. Verified live on 2026-08-20: 754 symbols, zero duplicates across bands, every shard complete with at least 87 rows of headroom. If a band ever reaches 200 the run aborts. The answer is to split that band again — never to accept a truncated universe, and never to relax the coverage requirement.

That union check proves the eight bands did not overlap for the observed universe. It does not prove two inclusive BETWEEN predicates can never collide on a shared boundary; the runtime duplicate detector remains the guard for that.

Coverage and drift answer different questions

CoverageStatus          did we see the declared universe?
DefinitionDriftStatus   does the declared universe still mean what we think?

Saved scans are editable in Legend. ScanDefinition pins what eight scan ids are believed to contain, fingerprinted with SHA-256, so a widened RSI band cannot leave runs labelled with a version that denoted a different universe. Severity is graded by whether a difference can change membership:

Drift Severity
Filter Blocking changes which symbols exist
Shard definition Blocking changes which scans define the set
Sort Conditional matters only when a shard is capped
Display / title Informational columns cannot reach a trade

Session semantics are part of this. The broker bakes session="all" into the filter expression rather than exposing it as a field, and all-session and regular-hours RSI are different numbers for the same symbol — so it is extracted from the expression and compared. Drift is never repaired automatically: whether Legend or the definition should change is a human decision.

Two budgets, and three different reasons to stop

Sector comes from authoritative fundamentals, never from a scanner column. That costs a call per ten symbols, so it gets a budget of its own, separate from the much more expensive per-symbol enrichment:

754 discovered
  ├── 714  fundamentals_budget    deliberately not requested
  └──  40  fundamentals_selected
        ├──  0  fundamentals_missing   requested, broker returned nothing
        └── 35  enrichment_budget      eligible, but not selected
              └── 5 selected for full enrichment

Those three exits are kept distinct on purpose. "We chose not to ask", "we asked and got nothing back", and "we asked, got an answer, and the answer was that the sector is unknown" are different facts, and collapsing them would let a failed batch look like ordinary budget rationing.

Selection is deterministic — seeded by sha256(date|symbol), not builtin hash(), so a re-run on the same trading date plans exactly the same symbols and the agent can fetch them in a second pass without the set shifting.

Scanner values never reach a decision. The RSI, market cap and volume in a scan row are discovery diagnostics only; the evaluator re-fetches everything authoritatively. A test feeds contradictory source_values for one symbol and asserts the resulting snapshots are identical.

The strategy

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.

Risk model

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.

Gates

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. Earnings fails closed on unknown — see below.
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 is now load-bearing rather than instantly binding. It was written when the universe was a handful of same-sector names, where one position at the ceiling blocked the next. Discovery now spans 754 symbols across every sector and selection spreads enrichment over them, so the cap does the job it was designed for instead of acting as a de-facto position limit.
  • min_risk_reward never fires for trend_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.

The earnings blackout fails closed, and knows whose earnings it is looking at. This gate was rebuilt after a live run showed it doing something worse than nothing. get_earnings_calendar takes no symbol argument — it is a market-wide window scan — and the parser reading it never checked the symbol field on a row. So a snapshot for NVO was assigned NVZMY's report date, from a payload NVO did not appear in, and all five candidates in that run were journalled with the same fabricated date.

Two rules now hold. Evidence must be provably about the symbol: rows are matched on symbol, the source is the per-symbol get_earnings_results, and the gate re-checks identity before reading a date. And not knowing blocks: a missing payload, a malformed one, an unresolved ticker, evidence from another trading date, or a symbol absent from the response all resolve to earnings_status_unknown and refuse the entry. Only one silence is authoritative — the source resolved the symbol and every report it holds is in the past.

Pendingness is decided by report.date, never by eps.actual. That field was observed unreliable in both directions: one symbol carried three past-dated reports whose actual was never filled in, and the calendar returned a future-dated row with actual already populated. Using the date can only over-block.

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.

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:

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 regime is recorded, not enforced

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.

The critic may shrink a trade, never grow it

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.

Safety

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

Why risk limits are enforced twice

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:

  1. A PreToolUse deny hook (.claude/hooks/guard_risk_config.py) refuses edits to config/risk.yaml and config/risk.lock, refuses the lock-risk command, 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.
  2. 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 --confirm

Layer 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 a stop_market order is not type=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.
  • 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 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.

Layout

src/agentic_trader/
  models/        domain types crossing every layer
  market/        MCP payloads → snapshot; signals; regime; acquisition contract;
                 local indicators (diagnostics only)
  universe/      scan definition, coverage, drift, candidate selection
  strategies/    opinions only — no account access, no sizing
  risk/          limits, sizing, engine
  agents/        orchestrator (one pure cycle), discovery, 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)
tests/
  fixtures/      live saved-scan configuration, for drift tests
.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)

Local indicators — now the decision inputs

The six broker indicator endpoints all derive from bars we already fetch, so computing them locally would cut roughly two-thirds of the per-symbol calls. The question is whether it changes what the strategy decides.

market/local_indicators.py writes RSI, MACD, SMA and ATR out by hand — no pandas-ta, no TA-Lib — because the point is to localize any disagreement to a formula, and an opaque dependency answers that with another opaque dependency. Every seed and recurrence is stated in the docstring and pinned by a hand-calculable test whose oracle is arithmetic, never the broker.

They are the decision inputs as of agentic-acquisition@v4. The six get_equity_technical_indicators calls are gone; indicator_derivation cuts each indicator from the bars the snapshot already carries, over the same windows those calls used to request. Per symbol that is four market-data calls instead of ten.

The cutover was deliberately semantic-preserving: it changed where a value comes from, not what the value is. Adopting the longer converged windows is a separate decision, and the legacy windows are shorter than the convergence requirement on purpose — today's values carry that seed dependence, and reproducing it is what made the change attributable.

How much history a recurrence needs

The binding constraint is MACD at 277 bars — not SMA200 at 200, which is the obvious guess and the wrong one. MACD's signal EMA smooths an already-smoothed line, so the two seeds compose. SMA is a finite window and needs no allowance at all; its minimum and its converged requirement are the same number.

minimum bars converged bars
sma_20 / 50 / 200 20 / 50 / 200 same
rsi_14 16 203
atr_14 15 202
macd 35 277

That set the historicals lookback to 420 calendar days (~290 bars). An earlier estimate of ~330 days would have yielded ~228 bars and been short; a test asserts so, to stop the number quietly regressing.

What the comparison found

12 symbols spanning ~$12 to ~$300 and deliberately including the volatile names, 30 sessions each, both sides computed over the identical 289-bar window.

Those requests were not the production shape, and the evidence says so. local-indicator-comparison@v1-2026-08-25 gives every call one shared start_time and a wider trim, because the question is whether two implementations agree on identical inputs — production gives each indicator its own lookback, which would have measured range and formula differences at once. The report records both identities separately: production_acquisition_* for the contract this validates for, validation_comparison_* for the requests that actually produced the numbers.

  • Zero decision disagreements in 348 comparisons, under both EMA seed conventions.
  • Numeric agreement at float64 round-trip: SMA to 4e-13, ATR to 1.8e-8, MACD to 6.8e-8, RSI to 1.2e-7. The residual is consistent with the provider computing in float64 while this implementation uses Decimal — not with any formula difference.
  • Zero ATR-driven disagreements on stop basis, tradability, or position size.

Full results in validation/local_indicator_equivalence_2026-08-25.json.

Numeric tolerance is the weaker half of that test and is recorded as such. macd_hist > macd_hist_prev is a direction comparison between two noisy numbers: it can flip while absolute error stays far inside any tolerance one would write down. So decision-flag agreement is the binding criterion and numeric tolerance merely necessary.

One thing worth knowing before cutover

The pinned per-indicator broker lookbacks are shorter than the convergence requirement — RSI and ATR request 180 calendar days (~124 bars) against ~203 needed. Truncating to that window moves RSI by up to 5.8e-3 points and the MACD histogram by up to 4.1e-4: four orders of magnitude more than the local-vs-broker disagreement. Lookback, not formula, is the dominant source of variation. Cutover would therefore shift values slightly, toward better convergence, and needs before/after decision parity tests rather than an assumption of no-op.

Reproducibility

A stored decision has to be readable without the session that made it. Three things are recorded because none of them can be inferred afterwards:

Recorded Why not inferred
scan_runs.trading_date Selection is seeded by trading date. started_at is a UTC instant, and a run starting 01:30 UTC seeds from the previous trading date — so deriving it back is wrong in exactly the case worth auditing.
audit.mode Five of seven outcomes produce neither a trade nor an execution plan in any mode. There is nothing for an inference to read.
audit.trading_date Every ranged request is trading_date - lookback, so this decided which bars the broker computed over. captured_at says when the snapshot was assembled - a different question that coincides only by habit.
audit.acquisition_profile_ref + fingerprint Says which pinned request contract produced the inputs. Without it a decision made on 30 bars of RSI warm-up is indistinguishable from the same decision made on 300.
audit.occurred_at The instant the decision was evaluated. now reaches quote age, price drift, and the risk gate's as_of, so a replay given any other clock re-decides rather than reproduces.

market/acquisition.py pins the request shape — interval, bounds, adjustment, output width, symbol-parameter shape, and a start_time derived from the trading date rather than the wall clock. It covers every market-data call an evaluation makes: quote, historicals, fundamentals, earnings, and the six indicators. It describes what is asked for; no returned value, timestamp, ticker, or count reaches its fingerprint, so the identity stays stable while the market does not.

Fundamentals is in the contract rather than in prose because it feeds two hard gates — average_volume_30d for liquidity, sector for the exposure cap. A decision input specified only in a skill file is a decision input the audit row cannot account for.

The lookbacks are derived rather than chosen. Each is the indicator's warm-up plus the trailing points the strategy reads, plus a convergence allowance for recursive smoothers, converted at ~252 trading days a year with a holiday buffer — and a test asserts each pinned value still covers its own derivation. RSI, MACD and ATR each depend on the previous value back to a seed at the start of the range, so a short range returns a genuinely different number; SMA is a finite window and is immune, which is why the problem stayed invisible until three workers fetched 30, 57, and 265 points for one indicator.

evaluate requires all three in the bundle and refuses one whose contract does not match the profile in force - missing, blank, stale, or wrong all fail closed, and nothing is journalled. The payloads are never re-stamped: a bundle fetched under different lookbacks genuinely is not what the current contract would have asked for, and recording it as such would be a false provenance claim, which reads back exactly like a true one. There is no override flag, because it would be reached for on precisely the day it should not be.

One thing this does not buy: omitting end_time makes the generated request byte-identical on every regeneration, but the broker's effective upper bound is request-time dependent, so the same request on two days can return different data.

Replay is a property of the journal, not of the bundle. Re-running a saved bundle later does not reproduce the original decision — build_snapshot stamps captured_at from the current clock, earnings normalization keys on that date, and quote age and drift measure against the instant given. Replay instead reads the row: rehydrate snapshot_json, pass the stored mode and trading_date, and set the clock to occurred_at. The freshness controls have no historical override on the normal path, and should not — they must keep measuring against the real decision time or they stop being freshness controls.

Scope of that claim. The journal preserves the market snapshot, trading date, acquisition identity, execution mode, and decision timestamp needed to replay the market-data side of a historical decision when supplied the same normalized account and configuration inputs. It is not a standalone event store: AccountState (buying power, open positions, open orders, realized daily P&L, sector exposure) and AppConfig identity are still supplied from outside the row, and both change outcomes. Capturing them is future reproducibility work, deliberately not attempted here.

Old journals migrate additively and keep an honest NULL in the new columns. Back-filling them would assert a mode, a date, and a contract nobody verified.

Next

In rough priority order:

  1. How much capital this strategy needs for whole-share broker protection. Answering it earlier would be guessing — ATR makes stop distance vary per symbol, so the price ceiling implied by risk_budget / stop_distance is not one number. Now that a real universe exists, the question is answerable.
  2. 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. There is no replace/modify tool, so moving a stop means cancel-then-place with an unprotected window in between.
  3. Normalized journal, a session-aware ShadowExecutor with realistic spread, slippage and stop-gap modelling, and a baseline-vs-critic A/B to establish whether the critic actually improves expectancy.
  4. Cut over to locally computed indicators. Equivalence is now measured and documented (see below); what remains is removing the six broker indicator calls, making bars authoritative, and proving decision parity before and after. Separate branch, separately reviewed.
  5. ApprovalExecutor — last, and gated on evidence rather than on a green test suite (see safety rule 8).

Status

Shadow mode. trend_pullback implemented and tested; momentum stubbed. Discovery runs live against an eight-shard universe. No live trades placed.

Last end-to-end shadow run, 2026-08-20: 754 symbols discovered, 40 fundamentals requested, 5 selected and fully enriched, 5 evaluated. Four watch, one no_signal, zero entries — so the sizing, critic and risk path were exercised by unit tests but not by that run. Zero order, cancel or replace calls have ever been made.

283 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.

About

An agent-driven equity trading system for Robinhood, built on the Robinhood MCP server. Claude orchestrates; a deterministic Python core makes every decision.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages