← Back to Projects

Bot Bought – Automated Paper-Trading System (Python, SQL, FinBERT)

An automated, rules-based stock trading system I designed and deployed on a self-administered Linux server – paper trading only – built as a statistics-first experiment: every decision logged to SQL, every strategy claim tested against a null baseline and a buy-and-hold benchmark before being believed.

  • Python
  • pandas
  • SQL (SQLite)
  • FinBERT
  • Alpaca API
  • yfinance
  • FRED
  • Ubuntu Server
  • systemd
  • Git
  • Claude Code
The Question

Can a simple dip-buying rule beat the market – and how would I know?

Rather than hunting for a profitable backtest, I used this project to practice rigorous analysis. Every strategy claim had to survive four tests before being believed:

  • Monte Carlo null baseline: is the result distinguishable from random entry timing?
  • Risk-adjusted benchmark: how does it compare to SPY buy-and-hold on Sharpe ratio?
  • A/B test: does a news-sentiment filter improve results?
  • Power analysis: how much data would a real verdict need?

The Answer

The strategy does not beat SPY, and the sentiment filter hurt returns. That negative result is the point of the project: I let the data say no.

System Diagram

One engine, config-driven. Runs daily at 3:45 pm ET on weekdays on a headless Ubuntu server, scheduled with systemd timers.

  1. 1 Data fetch Pulls daily bars for 16 stocks + SPY benchmark
  2. 2 Signal Flags a dip: drop ≥ 1.5× the stock's 20-day ATR AND below its 20-day moving average; skips stocks within 3 days of earnings
  3. 3 Sentiment FinBERT scores recent headlines; negative-news dips are vetoed (shadow mode: scored and logged, not enforced)
  4. 4 Risk $500 fixed position, max 6 open positions, $400 max daily loss, kill switch, cash only
  5. 5 Execute Paper orders via Alpaca; exits at +6% target, −6% stop, or 10 trading days
  6. 6 Log Every signal, gate decision, veto (with the headlines behind it), order, fill, and reconciliation stored in SQLite
  7. 7 Report Nightly text digest, weekly review package, automated backups
Review loop: a weekly human-in-the-loop review, where analysis proposes changes, I approve/reject, and approved changes must prove themselves on a separate paper account before promotion. Autonomous self-modification was deliberately rejected (it overfits to noise).
Strategy Design & Key Decisions

Trading Rules

  • Entry: drop ≥ 1.5× the stock's 20-day ATR (volatility-normalized) AND below its 20-day moving average; skips stocks within 3 days of earnings
  • Sentiment filter: FinBERT scores recent headlines; negative-news dips are vetoed. Currently in "shadow mode": scored and logged, not enforced (see Results for why)
  • Risk guardrails: hard checks before any order: $500 fixed position, max 6 open positions, $400 max daily loss, kill switch, cash only. No automated process can change these.
  • Exits: +6% target, −6% stop, or 10 trading days

Design Choices

  • ATR-normalized entry instead of a flat % drop: "a 3% drop in KO is an event; in TSLA it's Tuesday"
  • Avoided RSI<30 on purpose: the most-taught retail signal is the most crowded
  • Watchlist chosen for sector spread (max 3/sector), varied volatility, and varied news coverage, so the sentiment filter could be tested where news is thick vs. thin
  • $10k paper balance (not the $100k default) so results transfer to realistic account sizes

Project Facts

My roleDesigned and directed, built with Claude Code. Sole owner: designed the strategy, set constraints, directed the build, and verified every result.
TimelineStarted Aug 11, 2026; live paper trading since mid-Aug 2026; ongoing
StatusBuild complete (one optional test step left); in live forward-testing / review phase
Budget$0 – free data, free APIs, open-source model, reused hardware
Money at riskNone – Alpaca paper account ($10,000 simulated)
Scale~13,000 lines of Python source, 1,504 automated tests, 99% line coverage on the risk module
Backtest10.6 years (2016–2026), 16 tickers, 45,000+ daily bars, 487 simulated round-trip trades

Tech Stack

  • Languages/data: Python (pandas), SQL (SQLite – trade log, schema migrations, append-only audit tables)
  • Data sources: Alpaca API (prices, news, order execution), yfinance (historical bars), FRED (macro)
  • ML/NLP: FinBERT (HuggingFace transformers) – financial-news sentiment scoring, run locally on CPU
  • Infrastructure: Ubuntu Server (headless), systemd timers for scheduling, SSH, Git/GitHub, nightly automated backups
  • Testing: pytest-style unit tests, mutation testing (deliberately breaking code to confirm tests catch it)
  • AI tooling: Claude Code (implementation), Claude (planning, review, analysis)
Results

All results below are from the backtest (2016–2026, live parameters, nothing tuned). They are not live trading results.

Backtest vs. SPY Buy-and-Hold

MetricStrategySPY buy-and-hold
Total return+21.4%+305.8%
Sharpe ratio0.644 (best parameter cell)0.839
Round-trip trades487–

The raw return comparison is unfair (the strategy averaged only 7.2% of capital deployed), so I used Sharpe ratio for the fair comparison. Buy-and-hold still wins on a risk-adjusted basis.

Null Baseline (Monte Carlo Permutation Test)

MeasureValue
Test design100 seeds × 2 generators × 2 fill-cost models
Real strategy's percentile among random entry schedules94th–97th
Pre-registered threshold for a 9-cell parameter grid (family-wise correction)99.43rd
How often a cell hits the 97th percentile by chance24% of the time
VerdictNOT statistically distinguishable from chance

Sharper finding: when dates were kept but tickers shuffled, the edge nearly vanished. Timing carries what edge exists; stock selection doesn't.

Sentiment Filter A/B Test (2020–2026)

MetricFilter ONFilter OFF
EV per trade+$2.42+$3.68
Sharpe ratio0.3130.497
Total, ON vs. OFF−$545
By yearON worse in every year except 2023
TradesWin rate
Blocked by the filter58.5%
Kept by the filter55.9%
  • Mechanism: a dip big enough to trigger usually HAS negative news – that's what moved the price – so a negative-news filter removes the best rebounds.
  • Checked whether this was just COVID: it wasn't. Blocked trades spread across every year.
  • The filter is structurally a mega-cap filter: low-coverage stocks (KO, CAT) often have no news to read.

Market-Regime Analysis

MeasureValue
Share of trading days carrying 32% of all signals6.8%
Signals on those days where the 6-position cap binds64%

Signals cluster in market-wide shocks (Brexit, Feb 2018, COVID, April 2025 tariffs).

What I Learned

Exits: Retracting My Own Recommendation

56% of trades exit on the 10-day time limit. That initially looked like "wasted" trades, but timeouts were the LARGEST profit contributor:

Exit typeProfit contribution
10-day time limit+$1,311
Target / stop+$795

I retracted my own earlier recommendation to tighten exits. A 5-day max hold was clearly worse; 10 vs. 20 days was a plateau.

Win Rate vs. EV

The strategy with the best win rate (58.3%) earned less per trade than one winning 54.4%. Ranking by win rate inverted the right answer, so I chose EV per trade and Sharpe ratio as the metrics that matter.

Timing vs. Selection

Shuffling tickers while keeping dates nearly erased the edge: what edge exists comes from when the strategy buys, not which stock it picks.

Data Quality Catches

  • Caught a stock-split bug producing false signals at 20–30× ATR; fixed via split-adjusted data
  • Caught the backtester computing ATR differently from the live bot (87 of 136 rows disagreed) and made them identical
  • Found the vendor revised "final" volume data after the fact; narrowed a validation rule that had been over-trusted and pre-registered a test of when bars become final
  • Found a verification script reporting 163 "blocking" disagreements, 158 of which were a stale data snapshot

Engineering Rigor

  • Pre-registration: predictions and decision thresholds written down before running tests, to avoid fitting a story after the fact
  • Verify the verifier: planted known defects in comparison tools to confirm they detect them before trusting a "0 differences" result
  • Mutation testing on the money path (order submission/recovery)
  • Append-only audit trail: trade log and decision register can't be edited, only annotated
Limitations & Honest Caveats
  • Paper trading only. No real money is at risk, and no real profits or losses exist.
  • Backtest results, not live results. The live forward record is small (1 trade open, 0 closed as of 9/17/26). The backtest numbers above are the substantive results; there is no live performance data yet.
  • No out-of-sample split, by reasoned choice. 2020 held most stress events, so any chronological holdout would be either crisis-free or crisis-only – a holdout that can't fail returns "validated," which is worse than none. I used the null baseline instead.
  • The sentiment filter can't be tested evenly. Low-coverage stocks (KO, CAT) often have no news to read, so the filter is structurally a mega-cap filter.

Power Analysis: How Much Data a Verdict Needs

MeasureValue
Per-trade standard deviation$29.16
Confidence interval at the v1 target of 30 trades±$10.44/trade
Backtest edge to detect+$4.33/trade
Trades needed to detect it~175 (~3.8 years at the measured rate)

So the review process was designed NOT to wait on a verdict that 30 trades can't deliver.

Current Status & What's Next

As of 2026-09-17.

  • Live paper trading since mid-Aug 2026; 31 daily runs, 476 signals evaluated
  • First strategy-chosen trade: BAC, Sep 16, 2026 (1.60× ATR dip, sentiment scored neutral → passed)
  • First sentiment filter decision logged the same day
  • v1 goal: 30+ closed trades, projected ~April 2027 at the measured signal rate
  • Next milestone: final v1 report comparing live results to SPY (report script already built, waits on data)

Source code is currently private.