A martingale-style grid trading agent on US options, built for the Alpaca AI Trading Agents Hackathon (lablab.ai, Aug 28 - Sep 4, 2026). It is a deliberately stripped-down port of the author's cTrader "Kangaroo" V2.2 Forex grid bot.
https://hanky27.github.io/KangarooOptions/
The competition account, republished every five minutes by a scheduled
task running deploy/publish_perf.ps1 while the agent trades. It is not
a dashboard the agent writes to: every figure on it is read back out of
the broker through the same Alpaca CLI the agent places orders with, and
the terms are measured independently of each other.
That independence is the point. Realized P&L is rebuilt by matching every
fill against the contract it closes at average cost, fees are summed from
the account's own FEE bookings, unrealized comes from the position
marks - and
equity - start = realized + unrealized - fees + transfers
has to hold to the cent or the page is not published at all. (Cash paid
in before the first fill is starting capital and sits inside start;
only a transfer AFTER trading began appears in that last term, and a
booking the report cannot classify as either stops it by name.) The equity
curve is checked the same way, against a second reading of the same
account, before it is drawn. Both checks exist because both terms were
caught being wrong: see 8717fff in DEVLOG.md.
Alpaca AI Trading Agents Hackathon (lablab.ai), 28 August - 4 September 2026.
| Competition paper account | PA3S85G7JUS0 |
| Account created | 2026-08-31, dedicated to this submission |
| Starting balance | 100,000 USD |
| Orders before the competition window | none - verified against the orders endpoint |
| Alpaca interface | the official CLI (alpaca), pinned to v0.0.13 |
| Options | credit spreads, multi-leg limit orders |
| Market data | Alpaca Basic (indicative options feed) |
The hackathon is judged on the robustness of the agent workflow as well as on P&L, so every change made to the agent WHILE it traded the competition account is recorded in DEVLOG.md - the symptom that was measured, the cause with its evidence, the fix, and the measurement that confirmed it.
Seventeen defects, nine of them on the last morning. Four were found on day one, all invisible to the backtest because they live in the execution path a simulator never runs: an order id that had to be unique, a wing the account already held, a strike a rebuy would have netted against, and a close the broker filled while the process was being stopped. Day two added three, one of which stopped the agent dead - the account crossed 100 open contracts, the batched quote request hit an endpoint limit of exactly 100, and 25 grids went unmanaged for 93 minutes while the watchdog restarted a process that died the same way every two minutes.
The last morning, after the account's first assignments, added nine more, and they share a shape worth naming: each was invisible in exactly the place someone would have looked. A fix of mine that deleted the method the whole report ran on, visible in its own diff only as 39 insertions against 54 deletions. A hardcoded workstation path inside the one module every test replaced. A deadlock that needed two individually correct behaviours to meet. A double-flatten that needed an assignment and an open market on the same day, which had never happened. Orders the BROKER placed on this account, indistinguishable from ours until you read the id. A premium counted on both sides of an identity that still balanced, because a bounded tolerance absorbed it. And a failure that existed only when the scheduled task ran, never when the same command was typed by hand.
Six of the seventeen were not in the agent at all - it traded correctly
through every one of them. They were in what this project SAYS about
itself, and one put a maximum drawdown of -102,137.75 on the public page
for eighteen hours with nothing in the output to say so. That is why the
report now refuses to draw a curve it has not checked against a second
reading, and why test_cli_contract.py reads the source rather than
calling it.
Deployment to the running agent goes through
deploy/update_and_restart.ps1, which waits for the observed change at
every step and refuses to report success unless the commit in the running
process's log banner is the one just shipped.
The event FAQ permits work done before kick-off and requires it to be disclosed. What predates the hackathon:
- The strategy is a port. Kangaroo is the author's own cTrader grid
bot ("Kangaroo V2.2", Forex). Its rules - rebuy on adverse moves with
1.1^ngrowth, cluster take-profit on aggregated P&L - were designed long before this event. What was built for the event is the options translation: credit spreads instead of spot, the expiry/settlement path, the assignment gate, the multi-instrument loader, and every measurement in this repository. - One commit predates the kick-off.
f4e8e3f, 2026-08-28 16:41 CEST - eighteen minutes before the 17:00 CEST start. It is the initial skeleton. Everything fromfb66ce7onwards was written during the event. - The research harness is shared with the author's other work. The
linearity metric used by
tools_sweep.pyis imported from a private QuantroTrader module (SignalEngine/optimizer/equity_shape.py); the GUI the backtests are shown in is that same private application. Neither is part of this submission, and nothing in this repository requires them -tools_week.py,tools_portfolio.pyandbacktest_options.pyrun standalone. - The LLM came later, and it can only subtract. This line used to read
"no LLM is in the trading loop"; that was true until
8d139b6, when the risk gate went live. What is in the loop is narrow and clamped in code: on a rebuy that matters,claude-sonnet-5sees the whole account and answers how much of the requested size survives, between zero and what was asked. It never opens a position, enlarges one, picks a strike or moves a stop, and it fails open. Everything else about the agent is deterministic - the grid needs no opinion about direction, and giving a model one would have been theatre.
Kangaroo is a pure grid - there is no entry signal. This port sells
credit spreads instead of trading the underlying. Each instrument runs
one direction, set by start_long:
start_long |
side | contract | profits while |
|---|---|---|---|
true |
long / bullish | put credit spread | the underlying holds or rises |
false |
short / bearish | call credit spread | the underlying holds or falls |
- Sell an initial spread immediately: SHORT the ATM option, LONG a protective wing ~5 $ further out of the money - below the short strike for puts, above it for calls (one multi-leg limit order, net credit).
- Whenever the underlying moves against the cluster by
rebuy_1st_pct(first rebuy) orrebuy_pct(all further rebuys) from the last leg's entry reference, sell another spread with a growing size (1.1^n). Against means falling for a put grid, rising for a call grid. - Stop rebuying - but close nothing - once the underlying has run
max_adverse_pctagainst the cluster's FIRST leg (0 disables it). The cluster keeps every position and still waits for its take profit; it just stops adding to a move that has already gone against it. - Close the whole cluster (buy back every spread) as soon as the WHOLE
cluster - open legs plus the realized pot of already-settled ones -
exceeds
invest_count * initial_qty * 100 * (tp_pct% of the underlying price). Because the threshold is positive, a cluster never takes profit while its total is negative. - Expiry = let legs expire: a leg reaching expiration is settled by the broker at the underlying's close of that day, and its realized USD joins the cluster's sunk pot. OTM expiry keeps the full credit. The cluster keeps living with its remaining legs.
- One direction per instrument: after a cluster ends the grid restarts on the same side - there is no Mode1 toggle. A symbol that should be traded both ways gets two instrument configs.
Trigger math always runs on the underlying quote, never on option premiums.
One process drives N instruments, in the loader shape the author's cTrader
bots use. config.yaml is the loader: it holds the process-wide
settings (CLI path, credentials file, sampling rates) and the grid defaults
every instrument inherits. config_path names a folder; every *.yaml in
it is one instrument that overrides what it names.
config.yaml # loader: paths, poll rates, grid defaults
configs/
spy_short.yaml # underlying + start_long + its own tuning
qqq_short.yaml
iwm_long.yaml
...
An instrument is identified by (underlying, direction), so one symbol
may carry a long and a short grid at once: they hold different contracts
and never touch the same position. Each gets its own state file
(state/kangaroo_<symbol>_<side>.json) and its own client_order_id
namespace.
Refused loud, because each would silently corrupt a live grid:
- an instrument config that sets a process-wide key (
cli_path,env_file,poll_seconds,poll_fill_seconds,fill_requote_samples,config_path) - those describe the process, not the grid; - two configs for the same underlying and direction - they would fight over the same positions;
- a
state_filein the loader whileconfig_pathnames a folder - that one file would be shared by every instrument, so each would load the previous one's cluster and overwrite it on the next save.
Set config_path: "_" to fall back to a single instrument built from the
loader itself.
The clock and the position list are read once per poll for all instruments, not once per instrument.
tools_week.py measures every candidate (DTE window x take-profit x
direction) per symbol over the last two trading weeks and reports wins
per week - the objective for a contest scored over a single week, where
a curve that needs months to straighten has no time to do so.
tools_make_configs.py turns those measurements into the files above,
admitting a side only if it is net positive over the window, and writes
the measurement into each config as a comment.
The configuration is the winner of an 11-run backtest sweep (SPY,
2024-02..2026-08, real Alpaca option prices, daily resolution): the
put-credit-spread grid was the only account-sized profitable variant, while
every long-option variant of the same grid was negative and the Mode1 short
side lost money in every measured style (see backtest_options.py; window
caveat: no extended bear market in the data, marks are trade closes without
spread costs).
Corrected 2026-08-29. The simulator refreshed the protective wing's mark only for CALL spreads, so every PUT spread was valued against a wing frozen at its entry price. Differential test (one line changed, same data): the call-only run is bit-identical, the put-only run drops from +34,484 USD to +2,688 USD (max drawdown -2,721 -> -2,954 USD, margin peak unchanged at 2,000 USD). All put-spread figures published before that date are inflated; the parameter set has not been re-tuned against the corrected numbers yet.
Deliberately not ported from the original: Mode1/Mode2/Mode3, Freeze/Unfreeze, hedging/netting order modes, grid close, PID factors, multi-symbol support, FX pip/spread simulation. This repo serves exactly one purpose.
| File | Role |
|---|---|
kangaroo_core.py |
Pure state machine + math. No I/O. |
alpaca_cli.py |
Thin fail-loud wrapper around the official Alpaca CLI. |
agent.py |
Poll loop: expiry/assignment gates -> clock -> underlying quote -> close check -> rebuy check. |
backtest_underlying.py |
Stage-1 edge check on the underlying itself (upper bound). |
backtest_options.py |
Stage-2 backtests with real Alpaca option prices (3 styles, 4 regimes). |
risk_gate.py |
The model's only entry point. Returns an integer between zero and what was asked; anything else is a broken contract. |
tools_perf_snapshot.py |
Measures the account through the same CLI and refuses to write a snapshot whose terms do not add up. |
perf_page.tpl.html |
Source of the live sheet. docs/index.html is its OUTPUT — edit the template. |
tools_deck.py, tools_cover.py |
Deck and cover image, every figure read from docs/snapshot.json. |
Tests — 107, python -m pytest
| File | What it holds against |
|---|---|
test_kangaroo_core.py |
The pure state machine (also runs standalone). |
test_agent_sunk_pot.py |
Expiry accounting, the loader's halt bookkeeping, quote batching, order paging. |
test_risk_gate.py |
22 tests, every one assuming the model misbehaves. |
test_perf_snapshot.py |
The reporting maths, fee timeline and curve checks. |
test_assignment_recovery.py |
The assignment deadlock, the shared-snapshot double-flatten, and legs the BROKER closed with its own order ids. |
test_cli_contract.py |
Reads the SOURCE, not the code: every cli.<name>( must exist on AlpacaCli, and nothing reachable from agent.py may hardcode an absolute path. Both defects it covers passed 93 tests, because every test replaced the boundary that was broken. |
All broker access goes through Alpaca's CLI (hackathon requirement:
MCP server or CLI - no raw API calls). Spreads are multi-leg LIMIT day
orders at marketable net-credit/net-debit limits with unique
client_order_ids; fills are awaited by polling the order status, and an
unfilled order is canceled by ID and re-quoted on the next loop. Any CLI
error, missing quote, or inconsistent state stops the agent immediately -
no retries, no fallbacks.
- Paper-only: the agent refuses to start when
ALPACA_LIVE_TRADEis set. With plain API keys the Alpaca CLI defaults to paper trading. - Defined risk per leg: the wing caps every spread's loss at
(width - credit) * 100per contract; margin per leg is the spread width, not the strike. - Assignment gate: an assigned stock position in the underlying is flattened immediately (no wheel), at startup as well as in the poll loop, and the shared position snapshot is updated the moment it fills - otherwise both directions of one underlying act on the same stale row and the account ends up long a stock nobody chose.
- No cancel-all: only ID-based order handling.
- Crash-safe state: cluster state is persisted atomically to
state_fileand reconciled against the account's real positions (short leg AND wing) at startup. A mismatch aborts that instrument - with exactly two evidenced exceptions, both taken from the BROKER's own records rather than from the state file: a leg it booked as expired, assigned or exercised while the resulting stock is still held, and a leg it closed itself with its own order id. Anything else still halts.
- Install the Alpaca CLI (Windows:
download the release zip; or
go install github.com/alpacahq/cli/cmd/alpaca@latest). pip install pyyamlcopy config.example.yaml config.yamland setcli_path(plusenv_file, or exportALPACA_API_KEY/ALPACA_SECRET_KEY).- Tests:
python -m pytest(107) - orpython test_kangaroo_core.pyfor the core alone, without pytest installed - Single decision pass without any order:
python agent.py --once --dry-run - Run:
python agent.py
-
Grid parameters are carried over from the FX original (AUDCAD, H1) and are not yet tuned for options or the one-week contest window.
-
The backtest window (Alpaca option data starts 2024-02) contains no extended bear market; a sustained downtrend makes the put grid lose the (capped) spread width repeatedly. This tail is bounded by construction but unmeasured.
-
Strike/DTE/width selection is static (ATM, nearest expiry in window, first available width). The AI layer covers sizing only: the risk gate may shrink or veto a rebuy the grid has already decided on (see
risk_gate.py). Underlying choice and DTE/strike policy are still rules, not judgements. -
🔴 The backtests behind those parameters never paid the bid/ask, and paying it flips the result.
backtest_options.pyprices every option at the bar close, a traded print; its--cost_usdparameter existed and every published run left it at 0. Measured on the live book: half the bid/ask is 34.95 USD per spread per crossing, 69.89 round trip, median quote width 4.9 % of mid. The same 2.5-year SPY run goescost 0 +2,850.00 realized drawdown -2,994 cost 17.00 -433.00 drawdown -3,716 cost 34.95 -6,172.65 drawdown -7,024The entire published profit was smaller than the cost that was never charged.
cost_usdnow has no default anywhere - every tool refuses to start without it - but the parameters inconfigs/were chosen before that, on a ten-day window whose own provenance comment readswin rate 1.0. Treat every backtest figure in this README as an upper bound. DEVLOG.md has the tables.