I Built an AI Trading Bot That Is Not Allowed to Trust the AI
How InvestmentThing works: a Claude strategist that proposes, a deterministic risk engine that overrules it, two brokers pretending to be one account, a second model that tunes the dials, and every expensive mistake I made getting there.

InvestmentThing is a self hosted trading console that runs a real, small money portfolio across stocks and crypto. An AI model proposes what the portfolio should look like. A few thousand lines of deliberately boring TypeScript then decide how much of that proposal is allowed to happen.
The design principle is four words: Claude proposes, code disposes.
Everything below is an elaboration of that sentence, plus the bruises I collected learning why it matters. I built it over about three months of evenings, and the interesting part of the project turned out not to be the model at all. It was the machinery that constrains the model.

A note on the screenshots before we start. The interface, the layout and the decision text in every image below are exactly what the app renders. The numbers are not. Holdings appear as placeholder tickers, and every balance, position value, profit and loss figure and return you see in a screenshot is representative data chosen to illustrate the feature, not this account's real numbers. Where I quote actual performance, in the section on how it is doing further down, those percentages are real and I say so there.
Why build it this way
The obvious way to build an AI trading bot is to ask a model what to buy and then buy it. That works right up until it does not, and the failure modes are unpleasant. The model hallucinates a ticker. It proposes a ninety percent position in one name. It decides to sell everything on a bad headline. Or it simply changes its mind every hour and churns the account to death in fees.
A large language model is genuinely good at reading a messy pile of context, prices, momentum, news, your current positions, what worked before, and forming an opinion about it. It is genuinely bad at being a system of record for risk. It has no memory of what it did yesterday unless you give it one, no guarantee of internal consistency, and no obligation to respect a limit just because you wrote that limit into the prompt.
So the model never touches the broker. It returns an opinion in a fixed schema, and code decides what happens next.
The model's entire output is this:
{
marketSummary: string, // what it thinks is going on
strategy: string, // what it wants to do about it
riskAssessment: string, // what it is worried about
cashTargetWeight: number, // how much to hold in cash
targetAllocations: [ // the actual proposal
{ symbol, assetClass, targetWeight, conviction, rationale, setup }
],
actionPlan?: string,
universeSuggestions?: string[]
}
Target weights, not orders. No quantities, no prices, no "sell forty shares of this." The model says it would like a name to be fifteen percent of the book. The risk engine decides whether that is legal, what it implies given what you already hold, whether there is cash at the right custodian to do it, whether it would breach a cap, and how much of the move fits inside today's turnover budget. Then it emits orders.
If the model returns garbage, the worst case is a proposal that gets clamped, trimmed, or rejected outright, and a run that places nothing. That is a boring failure, which is exactly what you want from a system wired to your money. This is the same separation I argued for in The AI Agent Governance Gap, except here I had to actually live with the consequences of getting it wrong.
The stack
- Next.js 15 on the App Router, deployed on Vercel, which supplies serverless functions and a cron scheduler without extra infrastructure.
- Supabase for Postgres, auth, and row level security. Every table is owner scoped.
- Alpaca for US equities and options.
- Kraken for crypto.
- Claude as the strategist, and separately as a settings tuner, which I will come back to.
- TypeScript everywhere, roughly 59,000 lines across 255 files, with 755 unit tests.
Twenty four SQL migrations and about 500 commits.
Anatomy of a single run
A run is one complete decision cycle. Here is what happens, in order.
Acquire the lock. One run at a time per account. A manual "Run now" and the cron heartbeat must never trade against each other. The lock is a single conditional UPDATE on a timestamp column, which makes it atomic. Locks go stale after six minutes so a crashed run cannot freeze the account forever.
Write a provisional run row. Before any order is placed, a row goes into bot_runs with status: 'running'. This exists purely so that a run which dies mid execution leaves a trace. I learned that one the hard way.
Gather state. Account snapshot, current positions, quotes for the whole universe, historical bars, benchmark bars, recent news headlines, and the account's own trade history.
Check whether anything can even trade. If every asset class scheduled for this run has a closed market, the run stops there and records itself as market-closed, without calling the AI. A holiday costs nothing.
Build the context. This is the largest single piece of engineering in the app, and the part I would call the actual craft. The strategist gets a compact structured brief containing account equity, cash, and per venue spendable budgets; every current position with entry price, unrealized profit and loss, and relative strength against its sleeve benchmark; technical indicators for the whole universe; a market pulse covering what the benchmark did and whether the portfolio is beating it; separate regime reads for stocks and crypto so a crypto panic does not shrink equity sizing; recent headlines and an optional macro brief; the realized track record per symbol, meaning names the account has actually made or lost money in; the previous run's target weights as an anti churn anchor; and every guardrail currently active, with exactly how much cash is spendable at each custodian.
That last item matters more than it sounds. Telling the model what it is not allowed to do, in the same breath as what it is looking at, cuts the number of proposals that get rejected downstream. It does not eliminate them, which is the entire reason the risk engine exists, but a strategist that knows the constraints wastes fewer of its own suggestions. I wrote about the general version of this in Give Your AI Agent a Brain, and this project is the most demanding version of it I have built.
Ask the model. One call, JSON schema constrained output. If it fails, the run continues with a hold everything strategy and records the failure on the run row.
Run the risk engine. Described below.
Execute sells first. Then re-fetch actual buying power from the broker, and only then place buys. This ordering matters enormously. You cannot spend the proceeds of a sale you have not made yet, and brokers reject orders that assume otherwise.
Reconcile and persist. Placed orders get their real fill quantities and prices read back, everything is written to Postgres, and the provisional run row is updated in place with the outcome.
The risk engine is where the product actually lives
If someone asked me what this app is, I would point at one pure function. Current state plus a proposal goes in, a bounded plan comes out. It is pure so that it is testable, and most of those 755 tests point at it.
It applies the following in order, and each stage can override the one before it.
Global circuit breakers. Kill switch. Bot disabled. Broker reports trading blocked. Daily drawdown halt. Maximum drawdown from peak. Weekly loss limit. Any of these can stop the run dead. Most of them block buys while leaving protective sells running, because a circuit breaker that traps you inside a falling position is worse than no circuit breaker at all.
Whole portfolio stop loss, per venue aware, so a crypto collapse can liquidate the crypto sleeve without touching the equities.
Market guard. A broad selloff tripwire watching the benchmark's distance from its twenty day high, single day crash moves, and market breadth. Under stress it blocks new buys, and there is an opt in mode that also de-risks down to a defensive cap. Crypto gets its own thresholds, because crypto's normal is equities' panic.
Normalization and anti fragmentation. Model weights get clamped to caps and normalized. Positions too small to matter get merged away. A tiny stake in eleven names is a worse portfolio than a real stake in one, and it costs eleven times the fees.
Core participation floor. A configurable minimum in a broad market ETF. This exists because an AI hunting for alpha will happily leave you with zero market exposure, and most of the time the market itself is the trade.
Per position guardrails, in priority order. Stop loss cuts a loser past its threshold, in full, in one run. The trailing stop is a profit ratchet: once a position is up past an arming threshold, if it gives back a set fraction from its peak since you entered, it is sold. That one is profit only, it never fires on an underwater position, and the peak is scoped to the current holding period rather than a rolling window. Take profit trims half at a target gain and lets the rest run, with a re-arm gate so it does not serially liquidate the winner over successive runs.
Anti whipsaw controls. A re-entry cooldown after selling a name. A minimum hold time before reversing a trade. A deployment slew limiter so the book cannot go from forty percent to ninety seven percent invested in a single run. A rebalance dead band so a one percent drift does not trigger a trade.
Loss realization discipline. By default the engine will not sell at a loss to fund a rotation. Stop losses and market guard exits override this. Ordinary "I like that one better" rotations do not.
Fee aware floor for crypto. Crypto round trips cost roughly six times what equities do. If the expected gain does not clear the fee, the trade does not happen.
Hard caps as the last word. Position cap, sector cap, crypto sleeve cap, total deployment cap. These apply after everything else, so nothing upstream can talk its way past them.
Per run budgets. Maximum orders, maximum notional per order, turnover per run, turnover per day, minimum order size. Protective exits are explicitly exempt, because a stop loss sliced into three daily chunks by a turnover cap is worse than useless. I found that out by watching it happen.
Every decision the engine makes carries its reasons as strings, so the interface can show exactly why a proposal was rejected.

That screenshot is the whole thesis in one card. The strategist asked for five things. Two were blocked because the cash they needed was sitting at the wrong custodian, one was trimmed to fit the turnover budget and then trimmed again to available cash, and four existing positions were held with a named reason each. "Insufficient cash at this venue to fund this buy" is infinitely more useful than a silent no-op, and being able to read that months later is what makes the system debuggable at all.
Above all of it sits the switch that overrides everything else.

Two AI engines, not one
The strategist picks positions. That is the one described above.
The settings tuner is the stranger idea. It is a second model that never picks investments. It adjusts the bot's own settings based on measured results. Each cycle it looks at the equity curve against benchmarks, the realized trade ledger, win rate, hold times, fee load, order rejections grouped by cause, and running AI costs, then proposes bounded changes. Tighten turnover. Widen the rebalance band. Move a stop.
It is governed by rails, which are a whitelist of tunable keys, each with a range and a maximum step per evaluation. It can move a stop loss one notch. It cannot touch the kill switch, the trading cadence, the protected reserve, or any capital allocation choice. Ordered settings like risk tolerance move one level at a time. A twenty four hour per key cooldown stops it oscillating, and that cooldown also respects manual changes, so it will not quietly undo something I just set.

The rules it operates under are explicit in its prompt. Never loosen risk controls to win back losses. Prefer tightening after a losing stretch. Distinguish "orders were impossible" from "we are over trading", because those two look identical if you only count rejections. And no change is a good outcome.
It is useful and unnerving in about equal measure, for reasons I will get to.
Two brokers pretending to be one account
Alpaca does stocks. Kraken does crypto. I wanted one blended portfolio, not two.
A CompositeBroker presents them as a single account and routes by asset class. The hard part is not the routing. It is that cash cannot cross custodians mid run. Dollars sitting at Kraken cannot buy a stock. So the account snapshot carries a per venue cash breakdown, and the risk engine budgets each sleeve's buys against its own custodian's cash. Get that wrong and you produce orders that are arithmetically valid and physically impossible, which is precisely what those two BLOCKED lines in the screenshot above are.
There is also the degraded path. When Kraken is unreachable, blended equity has to stay continuous, otherwise a transient API failure reads as a sudden loss, trips the drawdown guard, and halts trading. So the composite carries the last known crypto sleeve value, marks the venue unavailable so no crypto orders are attempted, and reconstructs which coins are held from the most recent snapshot. If it cannot reconstruct the composition, it carries the value as equity but never as cash, because presenting an invested sleeve as spendable cash makes the strategist propose spending money that does not exist.
Security and the boring parts that matter
Every table is row level security scoped to its owner. Broker API secrets are encrypted at rest with AES-256-GCM under a dedicated key and are never returned to the browser. Rotating a key requires entering both halves fresh.
Auth is Supabase with optional TOTP, and the middleware enforces step up. If you have enrolled a second factor but are only at password level, you get bounced to the MFA screen. The one route that is public, so the cron scheduler can reach it without cookies, still enforces step up for signed in POSTs, because triggering a real trading run is not something a stolen password should be able to do. The cron secret is compared with a timing safe digest comparison.
Money moving actions require explicit confirmation on a live account and are one click on paper. That covers the obvious ones, placing an order and selling everything, and the less obvious ones I added later: enabling automation, releasing the kill switch, turning on margin, releasing banked profit.
A strict Content Security Policy allows exactly the app's own origin plus its database. Every broker call and every AI call happens server side, so the browser never needs to talk to anyone else.
Scheduling: one heartbeat, many jobs
A single cron hits one endpoint every five minutes. That endpoint runs the trading scheduler and then six sweeps: alerts, the settings tuner, price triggers, opportunistic triggers, quick win capture, and weekly digests.
Each account has its own cadence, plus optional pegs at the market open and close, optional trading windows and weekday restrictions, and independent settings for the stock and crypto sleeves, since crypto trades around the clock and does not care about market hours.
On top of the schedule there is an opportunistic trigger. Between scheduled runs it watches for standout setups, sharp moves in holdings, big benchmark swings, breaking news, and structural laggards. When one fires it can run an off cadence cycle. It is fingerprinted so the same signal cannot re-fire all day, capped at four fires per day, and, after a lesson described below, it now checks whether the signalled venue can actually fund a trade before spending an AI call on it.
The whole tick runs under a 270 second deadline. Every sweep checks that deadline and records an audit row if it stops early, because a sweep that silently never runs is the worst kind of bug.
The thing nobody talks about: what the AI costs
On a large account, inference cost is a rounding error. On a small one it is a headline expense.
The strategist runs about three calls per weekday. On an account of a few thousand dollars, that lands somewhere between three and a half and five percent of equity per year, deducted before you make a single dollar. It is the single largest fixable drag on the portfolio, and it shrinks toward nothing as the account grows. At ten times the size, the same bill is under half a percent.
So I built cost accounting in as a first class feature rather than a footnote. Every Claude call outside the strategist, meaning the tuner, the world brief, chat, research, and the settings wizard, is booked to a usage ledger with prompt cache tokens accounted separately. A dashboard card shows the combined AI bill plus estimated venue fees as an annualized percentage of equity, broken down by which part of the app spent it.

Expressing it as a percentage of equity rather than a dollar total is the part that changed my behaviour. A monthly bill in dollars feels small and stays invisible. The same number as an annualized drag on the thing you are trying to grow is impossible to ignore, and it is the framing I argued for more generally in FinOps for AI.
Then I fed that number to the settings tuner, with an explicit rule. Weigh cost, but never trade results for it. Only consider a cheaper model when the annualized cost is above roughly three percent and the account has lagged its benchmark with no realized edge to protect.
It promptly used that rule to downgrade my model. Which brings us to the useful section.
What went wrong
Every one of these is a real incident with a real fix. If you build something like this, you will meet most of them.
The auto tuner was silently dead for six weeks
The tuner claims each evaluation cycle by writing a marker row, then confirms it owns the cycle by checking that it wrote the earliest marker since the last one. One evaluation died mid call and left its claim row behind as "evaluating".
The ownership check computed its cutoff from that row by round tripping the timestamp through a JavaScript Date, which truncates Postgres microseconds to milliseconds. The stale row's own timestamp ended 24.94473, the cutoff became 24.944, and the row matched its own cutoff. It won the earliest marker check forever. Every subsequent cycle lost, deleted its claim, and bailed.
No error. No alert. The setting still said "auto". It just quietly stopped tuning for six weeks.
The fixes: compare timestamps at full precision, treat an "evaluating" claim older than fifteen minutes as abandoned and retire it with a visible error outcome, and never let the previous row be its own successor.
The lesson is one I keep relearning. A job that fails loudly is a bug. A job that stops existing is a much worse bug, and you will not notice it. Every recurring job now records why it did nothing, which is why the history below reads as a list of deliberate holds rather than a suspicious silence.

Quick Bank would sell a whole position instead of one percent
I built a Quick Bank button. Pick holdings, pick percentages, sell, and pause reinvestment for a chosen period. The API normalized the percentage with a magnitude heuristic: if the number is greater than one it is a percent, otherwise it is already a fraction.
The slider's minimum was 1, meaning one percent. The API read 1 as the fraction 1.0 and sold the entire position by quantity.
A subagent reviewing the diff caught it before anyone clicked it.
The lesson: never infer units from magnitude. One unit, declared, at the boundary. The fix was a four line pure function and eight tests.
The protected reserve was held back twice
Banked profit is held out of trading. It was implemented twice, once by raising the effective minimum cash floor and once by subtracting the reserve dollars from the spendable budget.
On an all cash account with a reserve set to half of it and a five percent cash floor, the bot should have had most of the remainder to deploy. It had nothing. Every buy came back "insufficient cash", and the account simply stopped investing.
The lesson: a constraint expressed in two units, a weight and a dollar amount, will eventually be applied in both.
A page that never resolved, and an app that looked dead
This one had three causes stacked, and it is my favourite because each is individually reasonable.
Every link in my header pointed at a server rendered page. Next.js prefetches links as they scroll into view. So one page render fanned out about a dozen server renders at once, and several of those walk Kraken's private trade and ledger history.
Kraken's rate limiter counts expensive calls with a slow decay. A dozen concurrent history walks trip it instantly. Each rate limited call then retried with backoff, so every page sat there for seconds.
The cache that was supposed to prevent this was process local. Serverless instances start cold and do not share memory, so each of those dozen renders re-walked the history from page one.
And the walk had no time limit, so on a long history it could outlive the page's own function timeout. The page never resolved at all.
The fixes: navigation links no longer prefetch, the history cache moved to a shared table so a cold instance reads the copy another already paid for, and a walk abandons itself after eight seconds rather than hanging.
Then production told me the real problem, which is that the walk could not finish. So it got rebuilt properly. Ask the API for only the deposit and withdrawal rows instead of paging through a ledger that is overwhelmingly trades, sync the full history once, and thereafter fetch only what is new. A walk that runs out of time saves its position and resumes rather than restarting, and a partial history is never served to a caller, because a missed deposit would quietly read as profit.
The lesson: "the app is broken" was actually "one upstream API is rate limiting a call I make on twelve pages at once." Check what your framework does for free before blaming your own code.
Same day deposits vanished
Funding events were filtered with a string comparison, event.t > sinceISO. Alpaca's settled cash activities often carry a date only, like "2026-09-08", and a bare date string sorts before any timestamp on the same day. So a deposit made today was invisible to every "since the last run" calculation.
The consequences were subtle and bad. A deposit could be counted as profit and skimmed into the reserve, and a withdrawal could manufacture a permanent phantom drawdown that locked the bot out of trading.
The lesson: compare instants, never strings, and normalize at the adapter boundary rather than at every call site.
The tuner downgraded my model overnight
Not a bug. The rule I wrote, working exactly as specified. AI cost was above my three percent threshold, the account had lagged its benchmark over thirty days, and there was zero realized profit to protect. So it switched the strategist to a cheaper, weaker model and wrote a clear justification into the audit log. You can see it as the top line of the tuner history above.
Every condition was met. It was also the one change I had explicitly decided against, because the cheaper model is materially worse at the equity sleeve.
The lesson: when you give a system authority over its own configuration, it will use that authority, precisely, at four in the morning, in a case you did not picture. The fix was not to remove the authority. It was to add a lock for the specific setting I wanted to own, which is the "Never change the AI model" toggle two screenshots up, and leave it free everywhere else.
And the arithmetic error I made yesterday
The account had stopped placing orders entirely. Fully invested, cash floor eating everything left, every proposed buy rejected as unfundable.
I diagnosed it as the minimum order size and said that lowering the cash floor could not fix it. Then I read the function that actually enforces the minimum. It already caps any configured minimum at one percent of equity, so the real floor was a quarter of what the setting said. The cash floor was the binding constraint, and lowering it had already fixed the stock sleeve before I claimed it could not.
The lesson: read the enforcement code, not the configuration value. The number in the settings table is what you asked for, not necessarily what runs.
Testing
755 tests across 69 files, almost all against pure functions. The architecture is shaped to make that possible. The risk engine is a pure function. So is the schedule calculation, cost pricing, tooltip placement, benchmark alignment, holding period detection, and percentage parsing. All pure, all tested without mocking a broker or a database.
The pattern I found most valuable is this: after fixing a bug, neuter the fix temporarily and confirm the new test fails. A test that passes against the broken code is worse than no test, because now you believe something false. That is the same instinct behind treating evals as acceptance criteria, applied to plain deterministic code.
There is also an offline smoke test that runs the entire pipeline, strategy through risk engine through order placement, against a simulated broker with no network access. It catches integration breakage that unit tests miss, and it runs in under a second.
How it is actually doing
Mixed, and the honest version is more interesting than a curated one. Unlike the screenshots above, the percentages in this section are the real ones.
The account was funded in June. Against the S&P 500 over the same windows:
| Window | Account | S&P 500 |
|---|---|---|
| Since fully funded | -0.6% | +4.9% |
| July | -7.9% | -0.8% |
| August | +7.9% | ~+2% |
| Last 30 days | +6.5% | ~-0.4% |
The entire lifetime gap is June and July. Over 900 runs, more than a thousand orders, leveraged ETFs round tripped repeatedly, and a tech drawdown while the book was concentrated in semiconductors. That period is what the anti churn machinery, the minimum hold times, re-entry cooldowns, turnover caps, and the trailing stop, was built in response to.
Since those changes went in: seventeen orders in three weeks, and a month that beat the index. Roughly half of that outperformance was one good week in crypto. Five weeks is not a track record. I would want a year before I would claim the system beats anything.
The unglamorous truth about a small account is that alpha barely matters. Beating the index by five points a year on a balance this size is worth less than a single volatile day moves it. The dominant term is contributions, and the second largest is the AI bill.
What I would tell someone building this
Let the model propose, never dispose. Constrain its output to a schema. Treat it as untrusted advice. Put every hard limit in code you can unit test.
Make "did nothing" loud. My worst bugs were not crashes. They were silence. A job that stops running, a sweep that gets starved, a history walk that returns empty. Every recurring job should record why it did nothing.
Never serve partial data as if it were complete. A truncated deposit history does not look broken. It looks like profit. Prefer stale and complete over fresh and partial, every time.
Instrument cost from day one, and not just in tokens. Cost as a percentage of the thing you are trying to grow is the number that changes behaviour.
Confirm real money transitions in the server, not the interface. Client side confirmation dialogs protect against mis-clicks. Server side checks protect against everything else.
Give the automation authority, then decide what it may not touch. The tuner is a good idea. The rails around it are what make it safe to leave on. That distinction, between granting autonomy and bounding it, is the part most agentic projects skip, and it is the part that decides whether you can sleep.
What it does not do
No shorting. No leverage by default. No futures or forex. No high frequency anything. The fastest cadence is minutes and mine is daily. It will not make anyone rich, and on a small balance it spends a noticeable fraction of its own returns on inference.
What it does do is take a set of rules I actually believe in, apply them without flinching at four in the morning, and keep an auditable record of every decision, including the ones it refused to make. For a project whose entire premise is that I do not trust the model, that record is the product.
This is a personal project and a description of how it is built. It is not financial advice, it is not a product, and nothing here is a suggestion that you should trade with it. Automated trading can lose money faster than manual trading, which is precisely why most of this post is about the brakes rather than the engine.
