<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="../assets/xml/rss.xsl" media="all"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>TinyComputers.io (Posts about finance)</title><link>https://tinycomputers.io/</link><description></description><atom:link href="https://tinycomputers.io/categories/finance.xml" rel="self" type="application/rss+xml"></atom:link><language>en</language><copyright>Contents © 2026 A.C. Jokela 
&lt;!-- div style="width: 100%" --&gt;
&lt;a rel="license" href="http://creativecommons.org/licenses/by-sa/4.0/"&gt;&lt;img alt="" style="border-width:0" src="https://i.creativecommons.org/l/by-sa/4.0/80x15.png" /&gt; Creative Commons Attribution-ShareAlike&lt;/a&gt;&amp;nbsp;|&amp;nbsp;
&lt;!-- /div --&gt;
</copyright><lastBuildDate>Fri, 08 May 2026 19:39:30 GMT</lastBuildDate><generator>Nikola (getnikola.com)</generator><docs>http://blogs.law.harvard.edu/tech/rss</docs><item><title>Building Stalker: A Mid-Cap Trading Bot and the Data Network That Feeds It</title><link>https://tinycomputers.io/posts/building-stalker-a-mid-cap-trading-bot-and-the-data-network-that-feeds-it.html?utm_source=feed&amp;utm_medium=rss&amp;utm_campaign=rss</link><dc:creator>A.C. Jokela</dc:creator><description>&lt;div class="audio-widget"&gt;
&lt;div class="audio-widget-header"&gt;
&lt;span class="audio-widget-icon"&gt;🎧&lt;/span&gt;
&lt;span class="audio-widget-label"&gt;Listen to this article&lt;/span&gt;
&lt;/div&gt;
&lt;audio controls preload="metadata"&gt;
&lt;source src="https://tinycomputers.io/building-stalker-a-mid-cap-trading-bot-and-the-data-network-that-feeds-it_tts.mp3" type="audio/mpeg"&gt;
&lt;/source&gt;&lt;/audio&gt;
&lt;div class="audio-widget-footer"&gt;60 min · AI-generated narration&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Three years ago I &lt;a href="https://tinycomputers.io/posts/a-little-rust-a-little-python-and-some-openai-custom-company-stock-reports.html"&gt;built a Slack bot that generated company stock reports&lt;/a&gt;. You'd type &lt;code&gt;/report TSLA&lt;/code&gt; and a Lambda fan-out would pull yfinance data, scrape recent news through BeautifulSoup, run technical indicators with the &lt;code&gt;ta&lt;/code&gt; library, and ask GPT-4 to write three paragraphs about what was happening with the stock. The output landed back in Slack and on a static S3 site. It was fun. It was a toy.&lt;/p&gt;
&lt;p&gt;It told you what one stock looked like. It didn't tell you what to do about it.&lt;/p&gt;
&lt;p&gt;Stalker is what I built after I stopped wanting toys.&lt;/p&gt;
&lt;p&gt;It's an autonomous mid-cap equity trading bot. It runs on AWS Lambda. It reads a daily macro brief, ranks a 300-name universe by factor scores, asks Claude Sonnet 4.6 to propose orders against the current portfolio, runs the proposed plan through a deterministic risk gate, and submits the survivors to Alpaca's paper trading API with deterministic client-order IDs so re-fires are idempotent. It logs every decision, every fill, every rejection. It emails me a daily report. It runs without me touching it.&lt;/p&gt;
&lt;p&gt;Right now it has 12 positions in a paper account modeled as a synthetic \$1,000 seeded deposit. Inception-to-date return on that \$1,000 baseline is +3.76% against SPY's +2.12% — so +1.65pp of alpha over four weeks of live operation. (To head off a misreading of the next number: the position book currently shows \$1,576 of market value, which is &lt;strong&gt;not&lt;/strong&gt; \$576 of gains on the seed. It's over-deployment from a bug I'll cover later — the bot was reading its cash limit as auto-replenishing every brief day instead of flowing from the order ledger, so cumulative buys ran past what a real \$1,000 account could have funded. The fix is in; the bot is currently trimming positions back to within the seed. The +3.76% number is the honest baseline-relative return.) The numbers don't matter at four weeks anyway — that's noise. What matters is that the system is running, the architecture is settled, and the methodology for measuring whether any of it actually works is pre-registered.&lt;/p&gt;
&lt;p&gt;Stalker is one of six related projects. The other five are data sources that feed it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Headwater&lt;/strong&gt; is a daily financial-newsletter aggregator that emits a structured macro brief twice a weekday morning and afternoon. It reads the writers I follow, classifies what they're saying, and ships a JSON document with a regime call, sector tilts, themes, and a watchlist.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Estuary&lt;/strong&gt; does cross-source consensus detection. When five different writers all flag the same ticker on the same week, that's a cluster, and clusters end up in a daily brief at 23:30 UTC.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PrivateEye&lt;/strong&gt; decodes paywalled-newsletter teasers into ticker picks. The actual decoded stock recommendations land in a daily digest.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tributary&lt;/strong&gt; ingests SEC EDGAR 8-K filings, extracts each item-level event, and classifies materiality. NT-10K late-filings, 4.02 restatements, 1.03 bankruptcies, 5.02 executive departures — Stalker reads these as risk-and-opportunity overlays.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Goldfinch&lt;/strong&gt; pulls federal prime contract awards from USAspending.gov, maps recipient legal entities to public tickers, and emits the matches as confirmatory revenue-disclosure signals.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each one is its own AWS account-scope project — its own Lambdas, its own DynamoDB tables, its own SES inbound endpoint. They're loosely coupled: each emits a JSON document on a known schema; Stalker reads from each one through a producer-specific loader at analyze time. Adding a new feeder is a Pydantic model plus a load function plus an SES rule. The architecture is "more sources are better, but no source is required."&lt;/p&gt;
&lt;p&gt;This is the post that explains how all of it hangs together.&lt;/p&gt;
&lt;h3&gt;The Data Network&lt;/h3&gt;
&lt;p&gt;The shape of the network matters. Each feeder is a separate project because each one solves a different problem. Trying to put all the signal extraction inside Stalker would have produced a monolith that does five things badly. The split lets each project focus on one thing — newsletter aggregation, consensus detection, teaser decoding, EDGAR ingestion, contract scraping — and lets Stalker focus on the trading layer alone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Headwater&lt;/strong&gt; generates the macro overlay. The structured brief carries a &lt;code&gt;regime&lt;/code&gt; field (one of &lt;code&gt;risk_off&lt;/code&gt;, &lt;code&gt;transitional&lt;/code&gt;, &lt;code&gt;neutral&lt;/code&gt;, &lt;code&gt;transitional_risk_on&lt;/code&gt;, &lt;code&gt;risk_on&lt;/code&gt;), a list of &lt;code&gt;sector_tilts&lt;/code&gt; with &lt;code&gt;view&lt;/code&gt; and &lt;code&gt;strength&lt;/code&gt;, a list of &lt;code&gt;thematic_views&lt;/code&gt; with &lt;code&gt;affected_sectors&lt;/code&gt;, a &lt;code&gt;key_risks&lt;/code&gt; list with horizons, and a &lt;code&gt;watchlist&lt;/code&gt; of tickers under active discussion. Stalker doesn't trade the watchlist names directly — those are mostly megacaps that fall outside the mid-cap filter — but the macro block converts directly into multipliers on the combined factor score. A bullish-Energy tilt at high strength multiplies every Energy mid-cap's &lt;code&gt;combined_z&lt;/code&gt; by 1.20 before ranking. A bearish-Tech tilt at medium strength multiplies it by 0.90. The factor selection is sector-tilted by the macro view at the rank-construction stage, not by Claude's interpretation at the prompt stage. The macro pipe into selection is deterministic and traceable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Estuary&lt;/strong&gt; is a different role. It tracks what individual newsletter writers are publishing across a window, computes consensus when multiple writers converge on the same ticker within a few days, and emits the clusters along with the per-writer high-conviction picks. A cluster of five sources flagging a single name in the same week is a stronger signal than five separate calls scattered across the year. Stalker reads Estuary's output as a confirmatory soft signal — when a name in the candidate universe also has Estuary cluster support, Claude can use that as a tie-breaker between similarly-ranked candidates.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PrivateEye&lt;/strong&gt; is the cheapest unit-economics piece in the stack. Financial-newsletter teasers are designed to make you subscribe — they're written to gesture at a recommendation without giving it away. PrivateEye reads the teasers and extracts the underlying ticker pick. The decoded picks ship in a daily digest. Most of them are megacaps that don't apply to Stalker, but the percentage that fall in the \$2B–\$10B band become another confirmatory soft signal alongside the Estuary clusters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tributary&lt;/strong&gt; is the structural-events feed. The SEC publishes 8-K filings continuously, and many of them are noise — boilerplate amendments, routine disclosures, exhibit lists. The interesting ones are the 5.02 executive departures, the 1.03 bankruptcy filings, the 4.02 audit-restatements, the 5.07 favorable shareholder votes, and the 8.01 material acquisitions. Tributary classifies each item-level event by materiality and salience, summarizes the substance, and emits one record per filing. Stalker uses high-materiality Tributary events as risk overlays: a recent NT-10K on a holding is a reason to consider trimming. The architecture treats negative-materiality items as exit triggers and positive items as additional context.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Goldfinch&lt;/strong&gt; is the newest feeder. Federal contract awards land in USAspending.gov within a few days of action; they're a real fundamental disclosure that often precedes earnings discussion of the same revenue. The challenge is the recipient-to-ticker mapping — federal awards are made to legal entities, not to listed companies, and many awards go to subsidiaries or government-services divisions whose parent ticker isn't obvious. Goldfinch handles the mapping (with a confidence rating per match), filters to material awards, and emits the matched records. A \$300M Department of Defense award to a \$5B mid-cap defense contractor is a real near-term revenue event; the same award to a \$200B megacap is rounding error. Stalker weighs the signal accordingly.&lt;/p&gt;
&lt;p&gt;Each feeder ships through SES inbound mail to its own recipient at &lt;code&gt;in.stalker.bot&lt;/code&gt;. &lt;code&gt;briefs@&lt;/code&gt; is Headwater. &lt;code&gt;estuary@&lt;/code&gt; is Estuary. &lt;code&gt;events@&lt;/code&gt; is Tributary. &lt;code&gt;picks@&lt;/code&gt; is PrivateEye. Goldfinch is a special case — it writes directly to a shared DynamoDB table because it runs in the same AWS account, but conceptually it's the same pattern: structured JSON, schema-versioned, lenient on unknown fields. SES drops each inbound message into S3, an EventBridge notification fires the Stalker ingest Lambda, the Lambda dispatches to the producer-specific parser, validates against a Pydantic model, archives the parsed payload, indexes a row in DynamoDB, and (for Headwater only) emits a &lt;code&gt;BriefIngested&lt;/code&gt; event that triggers the analysis layer.&lt;/p&gt;
&lt;p&gt;That's the inbound side. The outbound side is one Lambda — the analyzer.&lt;/p&gt;
&lt;h3&gt;The Trading Core&lt;/h3&gt;
&lt;p&gt;Stalker's actual decision logic is layered. There's a deterministic factor stack at the bottom, an LLM call in the middle, and a deterministic risk gate at the top. The LLM has freedom in the middle layer; everything below and above is mechanical.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The universe.&lt;/strong&gt; Every morning at 04:00 UTC, a Lambda hits FMP's screener API for US-listed stocks with market cap between \$2 billion and \$10 billion that are tradable on Alpaca. The result is a partition in the &lt;code&gt;stalker-universe&lt;/code&gt; DynamoDB table keyed on &lt;code&gt;refresh_date&lt;/code&gt;. The mid-cap band is the strategy's first commitment: Stalker doesn't trade megacaps (they're efficient, no edge available) and doesn't trade microcaps (liquidity, regulatory, and size-premium hazards). Mid-cap is the band where factor strategies historically have shown the most edge over passive indexing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Factor scoring.&lt;/strong&gt; Fifteen minutes after the universe refresh, a second Lambda computes per-name factor scores. Three factors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Momentum&lt;/strong&gt;: 12-month return minus the most recent month — the classical Jegadeesh-Titman 12-1 factor. The skip-month removes short-term reversal noise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Quality&lt;/strong&gt;: trailing-twelve-month ROIC plus operating margin, equally weighted. This is the post-ADR-011 production definition. (Stalker tracks load-bearing strategy decisions in numbered Architecture Decision Records — short Markdown documents in &lt;code&gt;docs/adr/&lt;/code&gt; that record the context, the evidence, the chosen option, and the reasoning. ADR-011 was the one that switched the quality factor.) The original ROE-plus-gross-margin definition turned out to be silently destructive in backtest, dragging alpha by 31 percentage points over the 3-year survivorship-bias-free window. The ADR walks through the seven candidate definitions, the alpha and Sharpe under each, and the reasoning for picking ROIC-plus-operating-margin over single-metric ROIC even though the latter showed a slightly higher headline alpha. Subsequent ADRs in this post (009, 010, 013, 014) follow the same pattern.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Low-vol&lt;/strong&gt;: the negative of 60-day realized volatility. Lower-vol names get higher z-scores.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each factor is z-scored within sector — a Financials name's quality is graded against other Financials, not against Tech. The sector-neutral approach prevents structural concentration: REITs and banks naturally have high ROE and low vol, so cross-sectional z-scoring would otherwise dump the whole portfolio into one or two sectors regardless of the macro view. Sectors with fewer than five names skip scoring; their z-scores would be noise.&lt;/p&gt;
&lt;p&gt;The three factor z-scores combine into a &lt;code&gt;combined_z&lt;/code&gt; via configurable weights. The current production weights are 0.55 momentum, 0.225 quality, 0.225 low-vol — momentum-tilted because the bias-corrected backtest sweep showed monotonic alpha improvement up through 0.55 with diminishing return beyond that. The factor stack writes top-300 by &lt;code&gt;combined_z&lt;/code&gt; back to DynamoDB with &lt;code&gt;in_universe=true&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The macro overlay applies here.&lt;/strong&gt; Before the factor scores are written, Headwater's sector tilts are pulled in and applied as multiplicative adjustments to &lt;code&gt;combined_z&lt;/code&gt; per sector. This is the "macro pipe" mentioned earlier: the sector view from the structured brief becomes a factor in the rank itself. A &lt;code&gt;bullish/high&lt;/code&gt; Energy tilt floats Energy names up; a &lt;code&gt;bearish/medium&lt;/code&gt; Tech tilt sinks Tech names. The multipliers are deterministic — defined in &lt;code&gt;macro_sizing.py&lt;/code&gt; and unit-tested — so a given brief plus a given factor snapshot produces a reproducible rank. No LLM in this layer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The analyze layer.&lt;/strong&gt; When a Headwater brief lands in S3 and the ingest Lambda emits &lt;code&gt;BriefIngested&lt;/code&gt;, the analyze Lambda fires. It loads the brief, fetches the current Alpaca account state, fetches today's universe partition with factor scores, queries the four producer feeder loaders for recent confirmatory signals, builds a structured user message, and calls Claude Sonnet 4.6 with a tool-use schema that constrains the output to a &lt;code&gt;propose_trade_plan&lt;/code&gt; JSON object. The system prompt explains the strategy posture, the hard rules the risk gate enforces, and the role of each feeder. The user message contains the macro block, the top-50 candidates by &lt;code&gt;combined_z&lt;/code&gt; with their factor scores and Kelly-derived suggested allocations, the current positions, the universe whitelist, and the soft-signal sections from each feeder.&lt;/p&gt;
&lt;p&gt;Claude's job is constrained selection. It picks 6–10 names from the candidate list (or the existing positions) and assigns target dollar values. It cannot trade outside the universe whitelist. It cannot propose a single trade larger than 15% of NAV. It cannot exceed 25% of NAV in any single position post-trade. It cannot buy a name with earnings inside seven days. The system prompt makes these constraints explicit, but the risk gate enforces them mechanically — the LLM is one defense layer, not the only one.&lt;/p&gt;
&lt;p&gt;The proposed plan flows into &lt;code&gt;risk.evaluate()&lt;/code&gt;, a pure-Python function that takes the state and the proposed orders and returns one of four decisions: &lt;code&gt;approved&lt;/code&gt;, &lt;code&gt;needs_human_approval&lt;/code&gt;, &lt;code&gt;rejected&lt;/code&gt;, or — under specific edge cases — a noise band that triggers a re-prompt. The risk gate enforces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;25% per-position cap (post-trade)&lt;/li&gt;
&lt;li&gt;15% per-trade cap on buys&lt;/li&gt;
&lt;li&gt;2% minimum cash buffer&lt;/li&gt;
&lt;li&gt;31-day IRS wash-sale block on buys of recently sold-at-loss names&lt;/li&gt;
&lt;li&gt;Earnings veto (no buys within 7 days of next earnings)&lt;/li&gt;
&lt;li&gt;Daily drawdown halt (-5% intraday → no new buys)&lt;/li&gt;
&lt;li&gt;Total drawdown halt (-15% from inception → no new buys)&lt;/li&gt;
&lt;li&gt;Losing-streak halt (3 consecutive losing sells → no new buys)&lt;/li&gt;
&lt;li&gt;Universe whitelist (any non-whitelisted ticker is rejected)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The risk gate's outputs are logged in DynamoDB regardless of whether execution proceeds. If the plan is &lt;code&gt;approved&lt;/code&gt;, the executor Lambda picks it up via EventBridge and submits each order to Alpaca with a deterministic &lt;code&gt;client_order_id&lt;/code&gt; formed from the SHA-1 of &lt;code&gt;(plan_id, ticker, action)&lt;/code&gt;. The deterministic ID makes re-fires idempotent: if the executor crashes after submitting three of five orders, the next invocation tries to submit the same orders, hits a 422 collision on the three already-submitted, fetches their existing state, and proceeds with the remaining two.&lt;/p&gt;
&lt;p&gt;There's one nuance in the executor that took an incident to find. Alpaca's day-trade detection rejects new orders on a symbol when an opposite-side order is already open — labeled "potential wash trade" in the rejection message. We discovered this when a sell got blocked because a stale stop-loss was still on the book. The fix is a preflight: before each submission, the executor lists open Alpaca orders for the symbol, cancels them, syncs the matching &lt;code&gt;stalker-orders&lt;/code&gt; rows to &lt;code&gt;cancelled&lt;/code&gt;, and then submits the new order. Belt and suspenders — Alpaca's enforcement still works, but we don't rely on it.&lt;/p&gt;
&lt;h3&gt;The Seeded Account&lt;/h3&gt;
&lt;p&gt;There's a subtle constraint baked into the architecture that took two iterations to get right: the bot is supposed to behave as if I'd actually deposited \$1,000 of real money, not as if it had access to Alpaca's \$100,000 paper-account default. Live trading caps the position size to what a \$1,000 retail account would actually do; paper testing should exercise the same sizing logic.&lt;/p&gt;
&lt;p&gt;The first cut of this used a simple &lt;code&gt;min(real_cash, NAV_CAP)&lt;/code&gt; cap on the cash field reported to the LLM and risk gate. That worked when the bot had no positions. Once positions accumulated, the cap silently broke the percentage math: a position with \$230 of market value plus a proposed \$40 buy is \$270 against the real \$2,500 NAV (a benign 10.8% of portfolio), but the cap reported the NAV as \$1,000, making the same position read as 27% — over the hard 25% cap. Three plans got rejected over a 36-hour window before the bug surfaced.&lt;/p&gt;
&lt;p&gt;The first fix tightened that: cap the cash, not the NAV. NAV becomes &lt;code&gt;capped_cash + sum(position market values)&lt;/code&gt;, which is a coherent number that reflects real portfolio percentages while preserving the small-account sizing exercise. That fixed the rejection bug.&lt;/p&gt;
&lt;p&gt;But it introduced a deeper problem: the cash kept reading \$1,000 every brief, replenished from Alpaca's bottomless paper-account seed. A real \$1,000 account doesn't work that way. A real account spends \$200 to buy a position; cash goes to \$800. The bot was effectively redeploying \$1,000 of fresh capital every brief day. Over four weeks the cumulative buys totaled \$2,123 against \$557 of sells — 2.1× the intended seed. A real account would have hit a cash wall after about ten buys.&lt;/p&gt;
&lt;p&gt;The honest fix is the second iteration: cash flows from the order ledger. The seeded cash at any moment is &lt;code&gt;$1,000 + sum(filled_sells) − sum(filled_buys)&lt;/code&gt;. The function scans &lt;code&gt;stalker-orders&lt;/code&gt; for filled and partially-filled rows, sums the &lt;code&gt;filled_qty * filled_avg_price&lt;/code&gt; per side, and returns the seeded cash. NAV is &lt;code&gt;seeded_cash + positions_mv&lt;/code&gt;. Buying power is &lt;code&gt;max(0, seeded_cash)&lt;/code&gt; — no margin in the seeded model. When seeded cash goes negative (the bot is over-deployed relative to the seed), the LLM sees the negative number with an inline note instructing trim-first behavior, and the risk gate's existing 2% cash-buffer check naturally enforces rebalance-only mode until sells refill the seed.&lt;/p&gt;
&lt;p&gt;After the second fix, the bot's reported state is &lt;code&gt;cash = -$566, nav = $993, buying_power = $0&lt;/code&gt;. It will spend the next several brief cycles trimming positions back into the seed before it can buy again. That's exactly how a real \$1,000 account would behave coming off a 4-week over-deployment streak. Self-healing — no manual reset, no position rebalancing required.&lt;/p&gt;
&lt;p&gt;The lesson here generalizes beyond Stalker: when a paper-test wrapper diverges from the live behavior it's supposed to mirror, the divergence accumulates silently. The only protection is to define the wrapper's semantics carefully and test the boundary conditions. "What happens when positions exceed the cap" was the question I should have asked at design time.&lt;/p&gt;
&lt;h3&gt;Backtesting Without Lying To Yourself&lt;/h3&gt;
&lt;p&gt;Backtesting a strategy against historical data is the easiest way to fool yourself in finance. The classic failure mode is &lt;strong&gt;survivorship bias&lt;/strong&gt;: you backtest against today's universe of public companies, applied retroactively. The names that delisted, got acquired, or went to zero aren't in your test set, because they're not in today's universe. Your universe is by construction a sample of survivors. You're testing how well the strategy would have done on the names that turned out to be successful — which is not the same as testing how well it would have done in real time.&lt;/p&gt;
&lt;p&gt;Stalker's backtest engine handles this through a point-in-time (PIT) universe archive. The bootstrap process queries FMP's &lt;code&gt;/delisted-companies&lt;/code&gt; endpoint to recover the names that left the public market, then queries &lt;code&gt;/historical-market-cap&lt;/code&gt; per ticker to determine the cap band membership at each historical date. The result is a JSON archive at &lt;code&gt;~/.cache/stalker-backtest/pit_universe/&amp;lt;window&amp;gt;.json&lt;/code&gt; that answers the question "which tickers were in the \$2B–\$10B mid-cap band on date X?" for any X in the bootstrap window. The current archive covers 2023-01-01 → 2026-04-30 and contains roughly 2,000 tickers ever in band, of which several hundred have a &lt;code&gt;delisted_date&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Every backtest run can take a &lt;code&gt;--pit&lt;/code&gt; flag. With it on, the rebalance candidate set at each weekly rebalance date is filtered to symbols whose market cap was actually in the \$2B–\$10B band on that date. With it off, the rebalance uses today's universe applied retroactively — the survivorship-biased path, kept around for legacy comparability.&lt;/p&gt;
&lt;p&gt;The first thing the PIT archive did was discredit a previous result. ADR-009 had bumped the production momentum weight from a balanced 0.40 / 0.30 / 0.30 to 0.55 / 0.225 / 0.225 based on a non-PIT backtest showing +24pp alpha over a 1-year window. When I re-ran the same configuration with PIT, alpha collapsed to -12pp. The original number was a survivorship-bias artifact. The momentum tilt looked dominant because the survivors were the names with strongest momentum — by definition.&lt;/p&gt;
&lt;p&gt;ADR-010 superseded ADR-009 with the bias-corrected result: at 0.55 momentum on PIT, the strategy beats SPY by +21.6% over the 3-year window. Better than equal-weight but a fraction of what the survivorship-biased number suggested. Honest accounting hurts.&lt;/p&gt;
&lt;p&gt;The PIT archive also enabled ADR-011's quality-factor switch. The original quality definition (ROE plus gross margin) was producing essentially zero contribution in the bias-corrected backtest. The natural diagnostic question was whether quality is a noisy factor at our universe size, or whether ROE-plus-GM is the wrong measurement of quality. Adding a &lt;code&gt;quality_definition&lt;/code&gt; knob to the engine and sweeping seven definitions showed the second answer: switching to ROIC-plus-operating-margin lifted alpha by +31 percentage points at production weights and improved Sharpe from 1.23 to 1.44. ROE rewards leverage (which varies enormously across mid-cap capital structures), and gross margin is industry-structural (SaaS at 80%, distribution at 8%) which sector-neutral z-scoring only partially undoes. ROIC is leverage-neutral and operating margin captures pricing power within sector — both tighter signals at our universe size.&lt;/p&gt;
&lt;p&gt;The PIT archive turns "backtest" from a marketing exercise into a real measurement. It's not perfect — historical FMP fundamental data has its own gaps and revisions — but it removes the dominant bias.&lt;/p&gt;
&lt;h3&gt;The Meta-Experiment&lt;/h3&gt;
&lt;p&gt;The factor stack has been validated end-to-end on bias-corrected backtests. The risk gate is a pure-Python module with full test coverage. The executor is mechanical. What hasn't been validated, in any rigorous way, is the LLM layer in the middle. The brief-driven analyze step might be adding alpha by combining macro context, position-aware reasoning, and human-style synthesis the factors can't see. Or it might be a wash. Or it might be subtracting alpha by overriding good factor picks with brief-narrative picks that don't survive in the data.&lt;/p&gt;
&lt;p&gt;The cost of getting this wrong in either direction is asymmetric. A non-additive LLM layer costs roughly \$200–700 a year in API plus an ongoing complexity tax — debugging an LLM-mediated trade path is harder than debugging a deterministic one. An additive LLM layer foregone is real alpha left on the table. Either way, the answer should come from data, not intuition.&lt;/p&gt;
&lt;p&gt;ADR-013 is the pre-registered A/B test that mechanizes the question. Two arms, identical except for the selection signal: the brief arm (current Stalker, with Claude reading the brief and picking 6–10 names) versus a factors-only arm (top-N by &lt;code&gt;combined_z&lt;/code&gt;, equal-weighted, same risk gates, same Kelly sizing). The factors-only arm runs as a daily shadow book — a separate DynamoDB table tracking what the deterministic strategy would have done each weekday close. The pairwise comparison is a paired daily-return diff t-test pooled over the elapsed window.&lt;/p&gt;
&lt;p&gt;The pre-registration document locks the methodology before any data is examined. Hypotheses are pinned. The test statistic is &lt;code&gt;mean(d) / (sd(d) / sqrt(N))&lt;/code&gt; where &lt;code&gt;d&lt;/code&gt; is the daily return difference. The threshold is 30 basis points per month of alpha at p &amp;lt; 0.05. The decision rule is mechanical:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Brief arm wins by &amp;gt;30bp/mo at p&amp;lt;0.05 → keep&lt;/li&gt;
&lt;li&gt;Brief arm loses by &amp;gt;30bp/mo at p&amp;lt;0.05 → simplify (retire the LLM)&lt;/li&gt;
&lt;li&gt;Inconclusive → simplify (default; the burden of evidence is on the LLM layer to justify itself)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The horizon is 12 months from inception (2026-05-04 to 2027-05-04). At horizon end, the test runs once on the pooled paired returns, the decision rule is applied, and the ADR's status updates from &lt;code&gt;proposed&lt;/code&gt; to one of &lt;code&gt;accepted (kept)&lt;/code&gt;, &lt;code&gt;accepted (simplified)&lt;/code&gt;, or — if the data supports a conditional-fire hybrid — &lt;code&gt;accepted (hybrid)&lt;/code&gt; with a follow-up ADR scoping the hybrid design.&lt;/p&gt;
&lt;p&gt;Mid-flight changes invalidate the pre-registration. If the factor weights change, the test restarts. If the prompt changes, the test restarts. The whole point of pre-registration is that it converts a tempting post-hoc optimization into a principled experiment, with a documented decision rule that doesn't move once data starts coming in.&lt;/p&gt;
&lt;p&gt;The shadow book runs on a daily 16:35 CT cron that ticks the factors-only portfolio through one rebalance, marks positions to today's close, and writes a row to &lt;code&gt;stalker-shadow-performance&lt;/code&gt;. A weekly Monday-morning cron joins the live and shadow performance series, computes the running paired-diff statistic, and posts a status comment to the project's tracking ticket. The cron is dormant infrastructure for the first 12 months — the running stats are informational only; the keep-versus-simplify decision fires once at horizon close, not every week.&lt;/p&gt;
&lt;p&gt;There's a sub-experiment I ran during the pre-registration drafting that's worth noting because it killed a tempting alternative. ADR-013's locked baseline is equal-weight factors-only, but the live system uses Kelly-derived suggestions to bias Claude's sizing. A reasonable concern was: if Kelly-as-binding-sizer (using the &lt;code&gt;combined_z&lt;/code&gt; to set position weights directly, not just suggest them to the LLM) beats equal-weight by a lot in backtest, then the equal-weight baseline is a weak counterfactual and the brief arm is being given an unfair advantage. ADR-014 ran the offline comparison: at top_n=30, Kelly-as-binding-sizer appeared to beat equal-weight by +47.75pp alpha. Striking number. I almost wrote it up as a win.&lt;/p&gt;
&lt;p&gt;The result didn't survive sensitivity checking. At top_n=10 (where Kelly's per-name weights actually fit within the cash budget), Kelly underperformed equal-weight by -43pp. The +47.75pp at top_n=30 was an implementation artifact: with the unnormalized Kelly weights summing to over 100% of NAV, the engine's per-buy &lt;code&gt;cost = min(delta, cash)&lt;/code&gt; clamp was eating the lower-ranked names and concentrating capital in the highest-z names. Kelly was acting as both selector and sizer at top_n=30 by exhausting cash on the top names. The apparent edge was concentration, not better relative weighting. ADR-014 was rejected — Kelly stays as advisory input to Claude rather than a binding sizer, and the equal-weight baseline for ADR-013 is defensible.&lt;/p&gt;
&lt;p&gt;This kind of pre-flight check is the discipline pre-registration enables. The temptation to ship a +47pp number is real. The discipline of asking "but does it survive the obvious sensitivity check?" is what separates a research finding from a marketing claim.&lt;/p&gt;
&lt;h3&gt;The Data Extraction Layer&lt;/h3&gt;
&lt;p&gt;Most of what makes the data network valuable is the upstream work — the work of getting the data into a normalized, schema-versioned form that Stalker can read. Each feeder has its own extraction story. Headwater reads HTML email digests and parses them into structured records. Estuary tracks individual writer feeds and computes consensus. PrivateEye decodes paywalled teasers into ticker picks, which is its own can of worms. Tributary subscribes to SEC EDGAR's filing stream and runs a structured extraction over the 8-K text. Goldfinch hits USAspending.gov's API and runs the recipient-to-ticker mapping.&lt;/p&gt;
&lt;p&gt;The orchestration for the heavier extraction work runs on a Bosgame M5 mini PC in my basement — the same machine that handles DirtScout's tax-list PDF extraction. It's a Ryzen-class system with 128GB of RAM and decent local inference horsepower for the structured-extraction passes that don't need frontier-model quality. The split between cloud and on-prem is roughly: cloud handles the trade-path quality-sensitive work (Claude Sonnet 4.6 for analysis), and on-prem handles the batch extraction work (PDF parsing, structured field extraction, classification at volume). Same pattern I described in &lt;a href="https://tinycomputers.io/posts/the-economics-of-owning-your-own-inference.html"&gt;the economics of owning your own inference&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Cron jobs on the Bosgame run the weekly gradient sweep that probes the factor weight space, the daily shadow-book tick for the ADR-013 A/B, and the periodic ADR validation runs that check whether shipped strategy changes are tracking their predicted alpha. Each script is wrapped in a &lt;code&gt;cron_wrapper.sh&lt;/code&gt; that does a &lt;code&gt;git pull --ff-only&lt;/code&gt; before exec, so changes pushed from my laptop propagate to the on-prem cron without manual SSH. The bash wrapper is forty lines long and has saved me hours.&lt;/p&gt;
&lt;p&gt;The point of the on-prem layer is operational independence. The cloud Lambdas are for the trading path — they need to be reliable, fast, and well-observable. The on-prem cron is for the background research path — it can take twenty minutes to run a backtest sweep, and that's fine. Putting the long-running work on Lambda would burn timeout budget and money for no benefit. Putting the trading work on the on-prem machine would bind the strategy's uptime to my home internet. The split is operationally cleanest.&lt;/p&gt;
&lt;h3&gt;What This Is Actually For&lt;/h3&gt;
&lt;p&gt;Stalker is paper-trading. It hasn't moved a dollar of real money. The Alpaca account is paper, the brokerage credentials are paper-mode, and the seeded \$1,000 is synthetic. The point isn't to make money in the next four weeks. The point is to validate the architecture under realistic conditions before any real money is at stake.&lt;/p&gt;
&lt;p&gt;The criteria I want to satisfy before live cutover are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The strategy beats SPY meaningfully on PIT-corrected backtest (currently +21.6% over 3 years at production weights — done).&lt;/li&gt;
&lt;li&gt;The factor definitions are documented in ADRs and the rationale is reproducible (done).&lt;/li&gt;
&lt;li&gt;The risk gate is unit-tested with full coverage of every guardrail (done).&lt;/li&gt;
&lt;li&gt;The executor handles real-world edge cases like opposite-side wash-trade rejection and partial fills (done).&lt;/li&gt;
&lt;li&gt;The seeded-account model is validated end-to-end including the over-deployment failure mode (done).&lt;/li&gt;
&lt;li&gt;Six months of paper-trading without operational incidents — no missed briefs, no failed analyses, no risk-gate false positives, no executor failures that get past idempotency.&lt;/li&gt;
&lt;li&gt;The brief-versus-factors-only A/B has run to horizon and the LLM layer is justified (12 months — in progress).&lt;/li&gt;
&lt;li&gt;The bot's behavior under drawdown halts has been observed in practice (waiting for a real correction).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That's a long list. The 6-month operational checkpoint and the 12-month ADR-013 close-out are the slow parts. The other items are mostly done.&lt;/p&gt;
&lt;p&gt;What this isn't: a money-printing scheme. The factor stack has documented edge in academic literature and bias-corrected backtest, but mid-cap factor strategies are well-known and don't have huge mispricing margins. The expected outcome at the upper end is something like SPY +5–15pp annualized at modestly higher volatility. That's a decent risk-adjusted return, not a free lunch. If the strategy underperforms in real time, the answer is to deconstruct what changed — regime shift, factor decay, prompt drift, brief-stream change — not to tweak parameters until the curve looks right.&lt;/p&gt;
&lt;p&gt;What this is: an exercise in building the smallest deployable instance of a real-money trading system, then validating its components individually before scaling. The \$1,000 seed is the smallest amount that exercises live-account semantics (rounding, fractional shares, cash management) without trivial scaling. The mid-cap focus is the band where factor edges historically existed. The pre-registered A/B is the methodology that converts "I think the LLM is doing useful work" into "the data either supports keeping the LLM or it doesn't."&lt;/p&gt;
&lt;p&gt;The other five projects in the data network exist for the same reason. Each one is its own small thing that does one job well, with a published schema, with structured output, with a clean handoff to whoever's downstream. Headwater could feed any number of consumers; Stalker is just one. Tributary's 8-K events could drive any number of overlays; Stalker happens to read them as risk filters. The architecture is a sequence of clean producer-consumer interfaces with no shared state, no implicit dependencies, no monolith. Adding a seventh project tomorrow would be — schema, ingest path, loader, prompt mention. Same pattern, different signal.&lt;/p&gt;
&lt;h3&gt;What I'd Do Differently&lt;/h3&gt;
&lt;p&gt;A few things, in retrospect.&lt;/p&gt;
&lt;p&gt;I'd start with the PIT archive sooner. ADR-009 shipped a non-PIT backtest result that turned out to be largely a survivorship-bias artifact. The corrected number from ADR-010 is still a real edge, but it's a fraction of what the original number suggested. If I'd built the PIT bootstrap as part of the initial backtest infrastructure rather than retrofitting it, I would have shipped fewer ADRs that needed superseding. The cost of the PIT bootstrap is real — the FMP &lt;code&gt;/delisted-companies&lt;/code&gt; endpoint takes a few minutes to walk and the per-ticker historical-cap queries take an hour the first time — but the cost is one-time, and the cost of being wrong about a strategy parameter is worse.&lt;/p&gt;
&lt;p&gt;I'd pre-register the LLM-versus-factors test earlier. ADR-013's pre-registration locks methodology before data examination. I drafted it after the live system had been running for three weeks, which means the locked-baseline decision was already informed by the running paper performance. That's not strictly invalidating — the locked threshold (30bp/mo at p&amp;lt;0.05) doesn't move with informed data — but a properly clean pre-registration runs before any live data is observed. The lesson is to instrument the meta-experiment before instrumenting the experiment.&lt;/p&gt;
&lt;p&gt;I'd separate the risk constants from the strategy constants more cleanly. Things like &lt;code&gt;MAX_POSITION_PCT&lt;/code&gt; and &lt;code&gt;WASH_SALE_DAYS&lt;/code&gt; live in the same module as factor weights, which conflates policy-decision parameters (where changes are sensitive and should be ADR-driven) with implementation parameters (where changes are routine). The current structure works, but a clean separation would make the policy boundary more obvious.&lt;/p&gt;
&lt;p&gt;I wouldn't change the producer-consumer pattern. That's the most reusable architectural decision in the stack. Each feeder being its own project with its own schema and its own SES inbound endpoint means I can add or remove sources without touching the consumer logic. Stalker reads from each loader independently and degrades gracefully if any loader returns empty — DDB throttle, S3 hiccup, brief stream paused, whatever. The system stays operational on partial signal. That property has been worth every minute of the architecture-discipline cost.&lt;/p&gt;
&lt;h3&gt;Cross-Project Notes&lt;/h3&gt;
&lt;p&gt;If you've read &lt;a href="https://tinycomputers.io/posts/building-dirtscout-a-land-acquisition-platform-with-claude-code.html"&gt;the DirtScout post&lt;/a&gt;, some of the patterns here will look familiar. CDK in Python for infrastructure-as-code. Python Lambdas with deterministic IDs for idempotency. DynamoDB instead of a relational database. SES inbound for the producer-mail pattern. Static Next.js export on CloudFront for the human-facing dashboard. The same architectural style that worked for a land-acquisition platform works for a trading system, because both are read-heavy event-driven workloads with bursty inbound and structured persistence.&lt;/p&gt;
&lt;p&gt;The differences are at the edges. DirtScout deals with parcels — slow-moving, geographically-bound, low-cardinality. Stalker deals with tickers — fast-moving, market-state-dependent, high-correlation. DirtScout's risk model is "did we accidentally surface a parcel that's not for sale." Stalker's risk model is "did we put 30% of NAV into one ticker right before its earnings miss." The shape of the failure modes determines the shape of the safeguards.&lt;/p&gt;
&lt;p&gt;The other shared piece is the &lt;a href="https://tinycomputers.io/posts/vibecoding-the-controversial-art-of-letting-ai-write-your-code-friend-or-foe.html"&gt;vibecoding&lt;/a&gt; approach to the codebase itself. I direct the architecture, make the load-bearing decisions, and review the diffs. The actual lines of code mostly come from conversations. Stalker is around 8,500 lines of Python plus 800 lines of CDK plus 3,400 lines of TypeScript (the dashboard). I wrote almost none of that by hand. I directed all of it.&lt;/p&gt;
&lt;p&gt;That's a real distinction. "Directing" means owning the architecture, the policy decisions, the risk constants, the methodology for evaluation, the criteria for live cutover. It means saying "no, this isn't how we should structure that" or "actually let's pull this up to its own ADR before we ship it." It's design and review, not typing. The typing is a commodity. The design isn't.&lt;/p&gt;
&lt;h3&gt;The Forward Path&lt;/h3&gt;
&lt;p&gt;The 12-month ADR-013 horizon ends 2027-05-04. Between now and then, the system runs on its own. The shadow book accumulates daily. The weekly stats post lands in the project tracker every Monday. If the strategy hits a real drawdown, the halt logic engages and I find out whether the halt criteria are calibrated correctly. If a brief stream goes down for a day, the bot still has factor signal to fall back on. If a feeder schema changes, the lenient Pydantic models tolerate the addition until I update the consumer.&lt;/p&gt;
&lt;p&gt;At horizon, the close-out runs the t-test, applies the decision rule, and updates ADR-013's status. If the brief arm wins by margin, the LLM layer keeps its place in the architecture. If it loses or is inconclusive, I retire the LLM analysis path — &lt;code&gt;analyze_handler.py&lt;/code&gt; becomes a thin "select top-N by combined_z" function and the brief stream becomes pure observability rather than the trading driver. Both outcomes are structurally fine; the point is the choice is made by data.&lt;/p&gt;
&lt;p&gt;The five upstream projects keep running regardless. Headwater publishes its briefs. Estuary computes its consensus clusters. PrivateEye decodes its teasers. Tributary classifies its 8-Ks. Goldfinch matches its contracts to tickers. Each project is independently valuable; each one feeds Stalker; none depends on Stalker for its purpose.&lt;/p&gt;
&lt;p&gt;The five-feeders-and-a-trader architecture is the part I'm most certain about. The factor stack might need to evolve. The LLM layer might get retired at horizon. The risk constants might need adjustment under live conditions. But the producer-consumer pattern, the per-project SES inbound endpoint, the lenient Pydantic schemas, the deterministic IDs, the bias-corrected backtest discipline, the pre-registered A/B for the load-bearing architectural choice — those are durable. They're the parts I'd rebuild the same way if I started over.&lt;/p&gt;
&lt;p&gt;Three years ago I built a Slack bot that generated stock reports. It told you what one stock looked like.&lt;/p&gt;
&lt;p&gt;Stalker tells you what to do about it. It runs on its own. It's written down what it expects to see and how it'll know whether it was right. And it's surrounded by five other projects that do the work of making structured data available in the first place — because the trading layer is the smallest part of the system, and the data layer is where most of the leverage lives.&lt;/p&gt;
&lt;p&gt;The work continues.&lt;/p&gt;</description><category>ai</category><category>alpaca</category><category>aws</category><category>backtest</category><category>claude code</category><category>dynamodb</category><category>estuary</category><category>factor investing</category><category>finance</category><category>goldfinch</category><category>headwater</category><category>lambda</category><category>mid-cap</category><category>paper trading</category><category>privateeye</category><category>python</category><category>stalker</category><category>trading</category><category>tributary</category><guid>https://tinycomputers.io/posts/building-stalker-a-mid-cap-trading-bot-and-the-data-network-that-feeds-it.html</guid><pubDate>Fri, 08 May 2026 18:00:00 GMT</pubDate></item></channel></rss>