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 Data fetch Pulls daily bars for 16 stocks + SPY benchmark
- 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 Sentiment FinBERT scores recent headlines; negative-news dips are vetoed (shadow mode: scored and logged, not enforced)
- 4 Risk $500 fixed position, max 6 open positions, $400 max daily loss, kill switch, cash only
- 5 Execute Paper orders via Alpaca; exits at +6% target, −6% stop, or 10 trading days
- 6 Log Every signal, gate decision, veto (with the headlines behind it), order, fill, and reconciliation stored in SQLite
- 7 Report Nightly text digest, weekly review package, automated backups
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 role | Designed and directed, built with Claude Code. Sole owner: designed the strategy, set constraints, directed the build, and verified every result. |
|---|---|
| Timeline | Started Aug 11, 2026; live paper trading since mid-Aug 2026; ongoing |
| Status | Build 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 risk | None – Alpaca paper account ($10,000 simulated) |
| Scale | ~13,000 lines of Python source, 1,504 automated tests, 99% line coverage on the risk module |
| Backtest | 10.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
| Metric | Strategy | SPY buy-and-hold |
|---|---|---|
| Total return | +21.4% | +305.8% |
| Sharpe ratio | 0.644 (best parameter cell) | 0.839 |
| Round-trip trades | 487 | – |
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)
| Measure | Value |
|---|---|
| Test design | 100 seeds × 2 generators × 2 fill-cost models |
| Real strategy's percentile among random entry schedules | 94th–97th |
| Pre-registered threshold for a 9-cell parameter grid (family-wise correction) | 99.43rd |
| How often a cell hits the 97th percentile by chance | 24% of the time |
| Verdict | NOT 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)
| Metric | Filter ON | Filter OFF |
|---|---|---|
| EV per trade | +$2.42 | +$3.68 |
| Sharpe ratio | 0.313 | 0.497 |
| Total, ON vs. OFF | −$545 | |
| By year | ON worse in every year except 2023 | |
| Trades | Win rate |
|---|---|
| Blocked by the filter | 58.5% |
| Kept by the filter | 55.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
| Measure | Value |
|---|---|
| Share of trading days carrying 32% of all signals | 6.8% |
| Signals on those days where the 6-position cap binds | 64% |
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 type | Profit 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
| Measure | Value |
|---|---|
| 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.