Back to golongshort

Built with Claude

Building a Stock-Trading AI with Claude — A 3-Month Journey

12 backtests, ~30 research passes, and a system that's still evolving. Three months of building — and honestly stress-testing — a systematic trading machine with Claude.


The scorecard, before the story. Over the last three months I built a systematic trading machine with Claude — and then spent most of that time trying to prove it doesn't work. Here's what survived: across an 18-year backtest it does not beat the S&P's total return, but it earns a higher Sharpe (0.61 vs 0.53) at a third of the drawdown (-20% vs -55%) — and in 2008, while the S&P lost 37%, the system barely moved.

The system vs the S&P 500 — growth of \$10,000 and drawdown, 2008–2026

Grey (the S&P) ends higher — it makes more money. Red (the system) gives up some of that return to bleed far less: a third of the drawdown, and barely a scratch in 2008 while the market halved. That trade-off — not a secret edge — is the whole design. (This is the 18-year backtest of the diversified book; the version I run live swaps Treasuries for a managed-futures ETF that's too young for an 18-year record.)

What I built — not a bot that beats the market. Every honest test said that doesn't exist for a solo. What I built instead is a diversified system that's honest about what it is, plus the tooling to never fool yourself with a pretty backtest — and it's running live on paper right now, rebalancing itself in the cloud.

I'm not a quant, and I've never run money professionally. I wanted the market-beating machine everyone claims to have. So I asked Claude to help me build it, and then — this is the important part — I asked Claude to help me check it, honestly. The checking is the whole story.

📋 This post is a complete build kit. Below is every file and every command. Copy the five files, run the commands, and you'll have the exact same system: the strategy, the honesty checks, the paper-trading loop, and the always-on cloud deploy. Or paste this whole post into Claude Code and say "build this and deploy it on lavela."

Most "I beat the market" content is a screenshot and a course. This is the opposite: the receipts for why it's hard, and a system that doesn't lie to you — with the code to prove it.

Contents


1. The graveyard — what I tested, and why each one died

I didn't set out to fail 12 times. Each test looked promising until we measured it honestly. That gap — promising vs. honest — is the entire lesson.

It started with a bot I built. The recipe was textbook ML-quant. Take the S&P 500, and for every name compute about twenty fundamental and technical features — valuation (P/E, P/B, EV/EBITDA), profitability (ROE, margins), growth, quality (Piotroski score, accruals), and momentum. Train a gradient-boosted model (LightGBM) to predict each stock's 63-day forward return. Then every quarter, go long the top-scored names, weight them inverse to volatility, and scale the book to a 10% volatility target.

python
# the first bot I built — the one that turned out to be wrong import lightgbm as lgb FEATURES = ["pe_trailing", "pb", "ev_ebitda", "roe", "net_margin", "revenue_growth", "piotroski_score", "sloan_accruals", "momentum_score", "beta", "vix", "yield_spread"] # ~20 in all def rank_stocks(panel): # panel: S&P 500, one row per stock model = lgb.LGBMRegressor(n_estimators=300, learning_rate=0.05, num_leaves=31) model.fit(panel[FEATURES], panel["fwd_ret_63d"]) # target: 63-day forward return panel["score"] = model.predict(panel[FEATURES]) return panel.nlargest(20, "score") # long the top 20, inverse-vol weighted

I trained it walk-forward, swept dozens of hyperparameter variants, and the best one backtested at a headline Sharpe of 0.60. I was thrilled. Then I asked Claude to audit it — four adversarial passes in parallel — and the 0.60 came apart three ways at once:

Then I tested everything people swear by — each with real costs, no look-ahead, a fair benchmark:

What I tested Honest result
Large-cap factor screens (value/quality/momentum) Doesn't beat SPY
Insider-cluster buying (SEC Form 4) Lost money — worse than the small-cap index
Trend-following (managed futures) A diversifier, not a market-beater
Small-cap quality-value (point-in-time SEC data) Beat SPY on paper — but via survivorship + size beta, not the factors
Volatility premium (put-writing) Real premium, still loses to SPY
Market-neutral long/short Blew up (−14%/yr) — shorts squeezed in every recovery

Market-neutral is the mechanism the real winners (Renaissance's Medallion) use — and at retail scale it detonates. You short beaten-down small caps, they rocket in the 2020/2023 recoveries, and your "neutral" book isn't neutral. Medallion survives that because it shorts thousands of names with cheap borrow and pico-second execution. The mechanism is public; the machine that makes it work is gated.

The moment that taught me the most: I combined a few low-correlation strategies and got a Sharpe of 0.81 — finally, something that beat the market. Then we stress-tested it honestly: monthly rebalancing instead of daily, real transaction and borrow costs. The 0.81 evaporated to 0.56below a boring 60/40. It was an artifact of rebalancing daily for free.

That's the whole game. Not "do you have an idea" — everyone has ideas. It's "will your idea survive honest measurement," and the answer is almost always no.


2. The one tool that matters — how to stop fooling yourself

If you take one thing from this post, take this. The highest-leverage thing a solo can build isn't a signal — it's the statistics that tell you a signal is fake.

The core fact (Bailey & López de Prado): test ~45 strategies on 5 years of data and you're basically guaranteed to find one with an in-sample Sharpe of 1.0 whose true out-of-sample Sharpe is zero. More trials → the higher the bar a "winner" must clear. The Deflated Sharpe Ratio makes that bar explicit. This is the thing that caught the bot's fake 0.60 and my fake 0.81 — and it's the first file you'll build in the kit below.


3. Build the whole thing — five files, copy-paste, run

Here's the entire system. Five files, no framework, ~200 lines total. It runs on any machine with Python.

Setup

bash
mkdir honest-trader && cd honest-trader python -m venv .venv && source .venv/bin/activate pip install yfinance pandas numpy scipy

File 1 — strategy.py (the whole strategy)

Risk-parity across four liquid ETFs — US stocks, gold, commodities, and a managed-futures (trend) fund — each weighted inversely to its own volatility so each contributes roughly equal risk. No ML, no alpha signal, no leverage.

python
# strategy.py import numpy as np, pandas as pd ETFS = ["SPY", "GLD", "DBC", "DBMF"] # equity / gold / commodities / trend PPY, VOL_LOOKBACK = 252, 60 def get_prices(period="8mo") -> pd.DataFrame: import yfinance as yf px = yf.download(ETFS, period=period, auto_adjust=True, progress=False)["Close"] return px[ETFS].ffill().dropna() def target_weights(prices: pd.DataFrame) -> pd.Series: """Risk-parity: weight each asset inversely to its trailing 60-day volatility.""" vol = prices.pct_change().iloc[-VOL_LOOKBACK:].std() * np.sqrt(PPY) inv = 1.0 / vol return inv / inv.sum()

File 2 — honesty.py (the anti-fooling math — the most important file)

The Deflated Sharpe Ratio and a multiple-testing haircut. Run any backtest result through this with the number of variants you tried, and watch "amazing" strategies fail the 95% bar. When I ran my own results through it, most failed — that's the tool working.

python
# honesty.py import numpy as np from scipy.stats import norm _EULER = 0.5772156649015329 def expected_max_sharpe(sr_std, n_trials): """Expected best Sharpe of N skill-less strategies (False Strategy Theorem). The more you try, the higher a lucky-best Sharpe will be by pure chance.""" if n_trials < 2 or sr_std <= 0: return 0.0 g = _EULER return float(sr_std * ((1 - g) * norm.ppf(1 - 1/n_trials) + g * norm.ppf(1 - 1/(n_trials * np.e)))) def deflated_sharpe_ratio(sr, T, skew, kurt, trial_sharpes): """P(the selected strategy's Sharpe is real) given how many you tried. < 0.95not distinguishable from the luckiest of your N random trials. sr/skew/kurt are per-period (daily); kurt is full (normal = 3).""" trials = np.asarray(list(trial_sharpes), float) sr0 = expected_max_sharpe(trials.std(ddof=1), len(trials)) # the deflated bar denom = np.sqrt(1 - skew*sr + ((kurt - 1)/4)*sr**2) z = (sr - sr0) * np.sqrt(T - 1) / denom return float(norm.cdf(z)), sr0 def haircut_sharpe(sr_annual, T_days, n_trials, ppy=252): """Bonferroni multiple-testing haircut → the Sharpe implied after correcting for how many strategies you tried. Often collapses toward 0.""" years = T_days / ppy t = sr_annual * np.sqrt(years) p_single = 2 * (1 - norm.cdf(abs(t))) p_adj = min(p_single * n_trials, 1.0) # Bonferroni hsr = norm.ppf(1 - p_adj/2) / np.sqrt(years) return float(hsr)

File 3 — backtest.py (prove it, and find the rebalance rule)

This does two honest things: (1) it backtests the risk-parity book with real costs, and (2) it answers how often to rebalance by sweeping calendar vs. drift-threshold policies — reporting all of them, not cherry-picking the max. Point ASSETS at a long-history set to see 18 years of robustness.

python
# backtest.py import numpy as np, pandas as pd from honesty import deflated_sharpe_ratio RF, PPY, COST_BPS = 0.02, 252, 5.0 ASSETS = ["SPY", "TLT", "GLD", "DBC"] # long-history set (2008→) for robustness def load(start="2007-01-01"): import yfinance as yf px = yf.download(ASSETS, start=start, auto_adjust=True, progress=False)["Close"].ffill().dropna() rets = px.pct_change().dropna() vol = rets.rolling(60).std().shift(1) * np.sqrt(PPY) return rets, vol def run(rets, vol, policy, param): """policy='calendar' (param=days) or 'threshold' (param=max weight drift).""" w, out, turn_sum, n_reb = None, [], 0.0, 0 for i, d in enumerate(rets.index): if vol.loc[d].isna().any(): out.append(0.0); continue target = (1/vol.loc[d]) / (1/vol.loc[d]).sum() do = (w is None) or ((i % param == 0) if policy == "calendar" else float((w - target).abs().max()) > param) cost = 0.0 if do: if w is not None: t = float((target - w).abs().sum()); turn_sum += t; cost = t*COST_BPS/1e4 w = target; n_reb += 1 out.append(float((w * rets.loc[d]).sum()) - cost) g = w * (1 + rets.loc[d]); w = g / g.sum() # drift between rebalances s = pd.Series(out, index=rets.index).loc[vol.notna().all(axis=1)] nav = (1 + s).cumprod(); r = s.to_numpy(); yrs = len(s)/PPY return dict(cagr=nav.iloc[-1]**(1/yrs)-1, sharpe=(r.mean()-RF/PPY)/r.std(ddof=1)*np.sqrt(PPY), maxdd=((nav/nav.cummax())-1).min(), reb_yr=n_reb/yrs) if __name__ == "__main__": rets, vol = load() print(f"{'policy':22s}{'CAGR':>7s}{'Sharpe':>8s}{'MaxDD':>8s}{'reb/yr':>8s}") for name, pol, par in [("calendar daily","calendar",1), ("calendar monthly","calendar",21), ("calendar quarterly","calendar",63), ("threshold 5%","threshold",0.05), ("threshold 10%","threshold",0.10)]: m = run(rets, vol, pol, par) print(f"{name:22s}{m['cagr']*100:>6.1f}%{m['sharpe']:>8.2f}{m['maxdd']*100:>7.1f}%{m['reb_yr']:>7.0f}")

Run it:

bash
python backtest.py
text
policy CAGR Sharpe MaxDD reb/yr calendar daily 7.3% 0.59 -20.2% 252 calendar monthly 7.6% 0.62 -20.2% 12 calendar quarterly 7.5% 0.60 -19.7% 4 threshold 5% 7.5% 0.61 -19.9% 7 threshold 10% 7.8% 0.63 -18.9% 2

Two lessons fall out: less trading wins (daily is the worst — it just pays costs for nothing, and the least-frequent policies come out on top), and the whole thing is robust (Sharpe 0.59–0.63 across every policy — it doesn't secretly depend on a lucky frequency). The sweet spot: check daily, trade only when a weight drifts >5% — it earns a monthly-level Sharpe on about half the trades (~7 a year). Your "watch it daily" instinct is right; your "trade daily" instinct is wrong.

File 4 — paper_sim.py (forward validation — real prices, virtual money)

Backtests are exhausted (and, twice, faked). The only honest new information is forward data. This tracks a virtual $100k using real prices, threshold-5% rebalancing, logging every run. Run it daily: most days it just monitors; on the rare day something drifts past 5%, it rebalances.

python
# paper_sim.py import json, argparse from datetime import datetime, timezone from pathlib import Path from strategy import ETFS, get_prices, target_weights STATE, LOG = Path("paper_sim_state.json"), Path("paper_log.jsonl") INIT_CASH, DRIFT, COST_BPS = 100_000.0, 0.05, 5.0 def load_state(): if STATE.exists(): return json.loads(STATE.read_text()) return {"cash": INIT_CASH, "positions": {t: 0.0 for t in ETFS}, "inception_nav": INIT_CASH} def peak(nav): p = nav if LOG.exists(): for ln in LOG.read_text().splitlines(): try: p = max(p, json.loads(ln)["nav"]) except Exception: pass return p def main(): ap = argparse.ArgumentParser() ap.add_argument("--status", action="store_true"); ap.add_argument("--reset", action="store_true") a = ap.parse_args() if a.reset: STATE.write_text(json.dumps({"cash": INIT_CASH, "positions": {t: 0.0 for t in ETFS}, "inception_nav": INIT_CASH})); print("reset to $100k"); return prices, weights = get_prices().iloc[-1], target_weights(get_prices()) s = load_state(); pos, cash = {t: s["positions"].get(t, 0.0) for t in ETFS}, s["cash"] nav = cash + sum(pos[t] * prices[t] for t in ETFS) cur = {t: (pos[t]*prices[t]/nav if nav else 0) for t in ETFS} drift = max(abs(cur[t] - weights[t]) for t in ETFS) dd = nav / peak(nav) - 1 print(f"\n{datetime.now(timezone.utc):%Y-%m-%d} NAV ${nav:,.0f} since-start {nav/s['inception_nav']-1:+.1%} drawdown {dd:+.1%}") for t in ETFS: print(f" {t:5s} target {weights[t]*100:5.1f}% current {cur[t]*100:5.1f}% drift {(cur[t]-weights[t])*100:+5.1f}%") print(f" max drift {drift*100:.1f}% (threshold {DRIFT*100:.0f}%)") if a.status: return rebalanced = False if drift > DRIFT or all(v == 0 for v in pos.values()): new = {t: nav * weights[t] / prices[t] for t in ETFS} cost = sum(abs(new[t]-pos[t])*prices[t] for t in ETFS) * COST_BPS/1e4 cash, pos, nav, rebalanced = -cost, new, nav - cost, True print(f" ⟳ rebalanced (cost ${cost:,.0f}) → NAV ${nav:,.0f}") else: print(" ✅ within 5% → monitor only, no trades") s["cash"], s["positions"] = cash, pos STATE.write_text(json.dumps(s)) with open(LOG, "a") as f: f.write(json.dumps(dict(ts=datetime.now(timezone.utc).isoformat(), nav=nav, drift=round(drift, 4), rebalanced=rebalanced)) + "\n") if __name__ == "__main__": main()

Start it (first run allocates the $100k; after that it just tracks):

bash
python paper_sim.py # daily: monitor, rebalance only if drifted >5% python paper_sim.py --status # NAV, drawdown, drift — no changes
text
2026-07-02 NAV $100,000 since-start +0.0% drawdown +0.0% SPY target 27.7% current 0.0% drift -27.7% ... max drift 37.9% (threshold 5%) ⟳ rebalanced (cost $50) → NAV $99,950

Now the honest part, stated plainly, because a system that oversells itself is a lie: this does not beat the S&P in a bull market — it lags, on purpose. It wins when stocks don't — rate shocks, inflation, crises. In the 18-year backtest it was flat in 2008 while the S&P lost 37%, and its drawdowns run about a third of the market's. It's a regime bet and a smoother ride, not free alpha.


4. Automate it — one plist, runs itself

On a Mac, a launchd agent runs the sim every weekday after the close, with zero input from you. Save this as ~/Library/LaunchAgents/com.honesttrader.papersim.plist (fix the two paths), then launchctl load it.

xml
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"><dict> <key>Label</key><string>com.honesttrader.papersim</string> <key>ProgramArguments</key> <array> <string>/ABSOLUTE/PATH/honest-trader/.venv/bin/python</string> <string>/ABSOLUTE/PATH/honest-trader/paper_sim.py</string> </array> <key>WorkingDirectory</key><string>/ABSOLUTE/PATH/honest-trader</string> <key>StartCalendarInterval</key> <array> <dict><key>Weekday</key><integer>1</integer><key>Hour</key><integer>18</integer></dict> <dict><key>Weekday</key><integer>2</integer><key>Hour</key><integer>18</integer></dict> <dict><key>Weekday</key><integer>3</integer><key>Hour</key><integer>18</integer></dict> <dict><key>Weekday</key><integer>4</integer><key>Hour</key><integer>18</integer></dict> <dict><key>Weekday</key><integer>5</integer><key>Hour</key><integer>18</integer></dict> </array> <key>StandardOutPath</key><string>/ABSOLUTE/PATH/honest-trader/sim.log</string> <key>StandardErrorPath</key><string>/ABSOLUTE/PATH/honest-trader/sim.log</string> </dict></plist>
bash
launchctl load ~/Library/LaunchAgents/com.honesttrader.papersim.plist launchctl start com.honesttrader.papersim # test it now tail sim.log

It runs itself now — until you remember a laptop isn't always on. A trading log that skips the days your machine slept is worse than useless. That's the problem the last part solves.


5. Deploy it on lavela — so it runs without your laptop

This is the part I dreaded most and touched least. I don't run servers. I didn't want to learn cloud-cron, TLS, secrets, a deploy pipeline. I just wanted the daily job to run somewhere that's always on, forever, for a few dollars.

🗣️ What I asked for

"get this running in the cloud so it doesn't depend on my laptop being awake — and make it easy."

That was the whole ask. That's where Claude recommended lavela. The idea is "replace DevOps wholesale": you wire lavela into Claude Code as an MCP server, and from there Claude provisions compute, scheduling, and secrets directly through tool calls — you never touch DevOps.

So all I did was two things: sign up at the console (https://console.lavela.dev), and run one line:

bash
npx @lavela/cli connect

A browser opens → log in → Authorize & connect. The CLI writes the token straight into your Claude Code config — it never appears in a URL, your shell history, or the chat. Open a fresh Claude Code session (or /mcp → Reconnect) and the mcp__lavela__* tools are live. From there, one "deploy the daily paper-sim job on lavela" is the whole thing.

🤖 What Claude did

And the part worth saying out loud: if you ever put a paid product on top of this, lavela takes 0% of what you earn — every dollar settles to you in full; lavela makes its money on the compute it runs, not on your revenue. For a personal trading log there's nothing to sell — but the same one command that deploys my hobby is the one that would deploy a real product.

The net: I went from "a script that only runs when my Mac is awake" to "a job that runs itself in the cloud, 24/7, forever, for a few dollars" — and I ran exactly one command and approved. The hard infrastructure is the part I know nothing about, and it's the part I didn't have to do.


Three months in — the journey so far

I wanted a machine that beats the market. Three months later, I don't have one yet — and I've learned exactly why the people selling you one don't either. But I stopped chasing the fantasy and started building something I actually trust, and I'm still sharpening it, week by week.

What I have instead is better than the fantasy: a simple, diversified system that's honest about what it is, the statistics to catch myself lying, and a cloud job that runs it forever without me. It won't make me rich. It also won't blow me up chasing an edge that isn't there — which, given that ~93% of active traders wash out, is the actual win.

If you build one thing from this post, build the checkinghonesty.py, survivorship-free data, honest costs. The strategy is four ETFs any beginner could copy. The discipline to not fool yourself is the rare part, and it's the only part the pros actually have that you can, too.

📋 Five files, four commands, one deploy. Or paste this whole post into Claude Code and say "build this and deploy it on lavela." You'll have the same system, the same honesty checks, and the same always-on cloud job — and, if you're lucky, the same humbling.

Like this post

Comments

No account needed. Be kind — comments are public.