Finance Agent

5 LLM personas argue every trade idea. A deterministic judge decides what's worth telling me. The agent never places an order.

Why this stack

The whole system runs on a single Intel NUC at home, so infrastructure choices favored local-first over hosted. SQLite in WAL mode skips the cloud database bill and the network dependency; a single-writer queue is simpler to reason about than a connection pool. systemd runs the daemon (Type=notify, a companion timer, LoadCredential for every API key) because the NUC already has it, and the process needs a real supervisor: watchdog pings, clean SIGTERM draining, and an automatic restart after a budget-exhaustion exit. Pydantic AI replaced a hand-rolled agent loop so every persona's output is a typed schema the synthesizer and the judge can reason over directly, not prose to re-parse. And since a research agent whose only visible output is a phone notification can't go dark every time 1 vendor has an outage, every persona and the judge route through a provider string instead of a hardcoded Anthropic call.

AI-assist note

It's spec-driven, built with Claude Code across 17 specs (spec, plan, tasks, and a peer-review-remediation pass on several sprints). I wrote every spec and reviewed every diff and every peer-review finding; Claude Code wrote most of the implementation and tests. The multi-provider diagnosis, the trade-safety guardrails, and the evidence-pipeline fixes below all came out of that same spec-plan-implement-review loop, not from hand-typing every line.

Stack

  • Python
  • Pydantic AI
  • SQLite (WAL)
  • systemd
  • Anthropic Claude (+ OpenAI, DeepSeek, Gemini, Groq via routing)
  • Alpaca Markets (paper trading)
  • Tradier (options data)
  • Langfuse (observability)

Domains

  • Agentic AI
  • Finance & Trading Research
  • AI Safety & Guardrails
  • Systems Reliability
Live3 mo
InfraPython 3.12, SQLite (WAL mode), systemd on Intel NUC, Pydantic AI, Langfuse + ntfy.sh
Authsystemd LoadCredential + per-provider API keys (Anthropic, OpenAI, DeepSeek, Gemini, Groq, Alpaca, Tradier)
Specs17

Why this exists

Every morning I want 1 honest answer for a stock I hold: is anything worth paying attention to today, and if so, what’s the actual evidence behind it. Reading every 10-K, earnings call, options chain, and price move myself doesn’t scale, and a single-model summary tends to agree with whatever it’s shown. Finance Agent is a long-running agent that reads the real data, argues both sides of the trade with 5 separate personas, gates the result through a deterministic rulebook and an LLM judge, and pushes a grounded briefing to my phone. It never places a trade. I do that by hand, at my actual brokerage, after reading the briefing.

Architecture

Four views: the always-on loop that decides when to think, the five-persona debate that does the thinking, the research pipeline that feeds it, and the safety-and-ops layer that makes an autonomous agent on a home server trustworthy.

The always-on loop: deciding when to act

Always-on agent loopA systemd-supervised daemon ticks every 15 minutes. Each tick passes a daily budget guard, a hybrid trigger of research windows and signal-boost detection, and a watchdog task, before opening a research session.systemd · Type=notify60s watchdog · LoadCredential secrets ·memory caps · restart on failure15-minute tickevery tick answers: is now worth spending money on?Budget guarddaily $2 cap; exhausted → clean exit,a 00:05 timer resumes next dayHybrid triggerresearch windows (ET, weekdays, cooldowns)+ signal-boost pass on fresh signalsWatchdog taskpings systemd every 20s,never awaits LLM worktrigger firesResearch sessionthe five-persona debate (next diagram)

The daemon’s core skill is restraint. systemd supervises it like a real service: watchdog pings from a task that never awaits LLM work, so a hung model call can’t fake liveness, plus API keys via LoadCredential, memory caps, and automatic restart. Every 15-minute tick decides whether now is worth spending money on. Sessions only open inside weekday research windows (with cooldowns) or when a fresh research signal lands outside them, and a daily $2 budget cap ends the day with a clean exit that a morning timer undoes. This loop is also where the “looks alive, does nothing” bug lived: the agent ran healthily for days while a stub trigger never fired, which is why the trigger now has deterministic tests for every window, weekday, cooldown, and daylight-saving edge.

The debate: five personas, two gates

Research session flowFour data sources feed a typed evidence pool. Five personas (bull, bear, risk, reward, speculative) reason over it in parallel, each routable to a different LLM provider. A deterministic synthesizer applies a quorum vote and gates, an LLM judge scores the result, and only a passing briefing reaches the phone. A human places any trade manually, outside the system.Alpacamarket data + positionsSEC EDGAR10-K / 10-Q / 8-K filingsTradieroptions chain + IV rankResearch DBsignals + safety stateEvidence Poolfetched once in parallel, typed + frozen, byte-stable for prompt cachingBullClaude SonnetBearClaude SonnetRiskClaude HaikuRewardClaude SonnetSpeculativeClaude Haikuparallel under a 120s timeout · every slot env-routable across 5 providers · drill-in tool: pool first, capped live fallbackDeterministic Synthesizerquorum vote (3+ strong → strong; risk veto → contested) + policy gatesLLM Judgetemp 0 · hard gate · borderline reruns 3×passes gatentfy.shpush to phone · per-ticker/day deduphuman-only stepI read it, then place any trade myselfat my brokerage, outside the write boundary

Evidence flows one direction. The pool is fetched once per session (six parallel fetches, cache-first, with the safety-state fetch failing closed) and frozen into a typed structure whose serialization is byte-identical across personas, because timestamp drift between five prompts was quietly collapsing the prompt-cache hit rate. The five personas argue in parallel under a 120-second timeout, each slot independently routable to a different provider (that routing is how an Anthropic-specific outage got diagnosed in one run), with a drill-in tool that checks the pool first and only makes a capped live call for claims the pool can’t answer. Then two gates that don’t share a failure mode: a deterministic synthesizer that applies a quorum vote (three-plus strong opinions make a strong call, and the risk persona holds a veto that marks the result contested), and a temperature-zero LLM judge whose borderline scores get rerun three times and majority-voted. Only a briefing that clears both reaches the phone.

The research pipeline: RAG without a vector store

Research ingest pipelineSEC filings, earnings calls and podcasts, and market signal sources are analyzed document by document by Claude into typed research signals stored in SQLite: structured facts, inferences, and sentiment with confidence scores, not embeddings.SEC EDGAR10-K / 10-Q / 8-K,per watchlist companyEarnings calls + podcaststranscripts, with speech-to-textfor audio-only showsMarket + investor signalsFinnhub · analyst newsletters ·13F filings of notable investorsClaude document analyzerevery document → typed analysis, tracked per runresearch_signal (SQLite)fact / inference / sentiment + confidence · no embeddings

The retrieval layer is deliberately not a vector database. Every ingested document (filings, earnings-call transcripts, podcast episodes, analyst newsletters, 13F filings) gets read once by Claude and reduced to typed research signals: a fact, an inference, or a sentiment, with a confidence score, linked to the company and the source document. Personas then query structured signals instead of embedding-similarity chunks, which keeps retrieval explainable (every citation traces to a document) and keeps the whole thing runnable on a single SQLite file on a home server. Fresh signals landing outside research windows are also what the trigger’s signal-boost pass reacts to.

Safety and operations on a $2/day budget

Safety boundary and operationsThe never-trades boundary is enforced in three independent layers: a static analysis test, a runtime read-only tool allowlist, and sandboxed document reads. Operations run on Langfuse traces as the spend source of truth, a daily budget with a per-provider price table, and an audit log with a health CLI.THE NEVER-TRADES BOUNDARYOPS ON A HOME SERVERStatic analysis (build time)AST walk fails the build if any module imports orcalls an order-placing SDK methodLangfuse tracessource of truth for spend; adopted after a localtoken tracker under-counted a runaway dayRuntime tool allowlistonly read-only tool names register with the agent;anything else rejected at decorate + dispatch timeDaily budget$2/day cap against a per-provider price table;exhaustion is a clean exit, not a crashSandboxed document readssymlink rejection · path-traversal check ·file-size cap · never silently emptyAudit log + health CLIevery fetch and decision audited in SQLite;one command reports config, DB, pipeline healththree layers, no shared failure mode:import drift, injected tool names, and path escapes each die separatelydeploys via a script to the NUC, schema-versionedmigrations with a rollback path

The agent’s entire job is producing research briefings, never placing trades, and that boundary is enforced three ways that don’t share a failure mode: a static AST test that fails the build if order-placing SDK methods are even imported, a runtime allowlist that rejects any non-read-only tool name before dispatch, and a sandboxed document-read path hardened against symlinks, traversal, and oversized files. The ops side is sized to the machine it runs on: Langfuse is the source of truth for spend (adopted after a local token tracker under-counted a runaway day), the $2 daily budget prices every call against a per-provider table and exits cleanly at the cap, and everything the agent fetches and decides lands in an append-only audit log a single health command can summarize.

What shipped

By the final spec the agent had closed out the original “make it useful” backlog. Multi-provider routing means an Anthropic outage can’t take the whole system dark. The evidence pipeline replaced an earlier sprint’s stub data with real filings, prices, and options. The idle-poll trigger fixed an agent that ran forever without ever deciding to act. A daily portfolio snapshot rounds out the picture for trend review. 17 specs, each with its own spec, plan, tasks, and research trail, drove the build end to end with Claude Code.

The system has only ever run on paper-trading credentials. That’s a deliberate constraint, not a placeholder: the whole point was to prove the reasoning pipeline before ever touching a real order.

Skill stories

Each card below opens the engineering story behind one skill: the decision I made, what broke, how I measured it, and how I fixed it. Click any card to read it.

  1. Agentic AIMulti-Provider LLM Routing
  2. RAG & Prompt EngineeringGrounded Evidence Pipeline
  3. Systems ReliabilityIdle-Poll Trigger Detection
  4. AI SafetyDefense-in-Depth Trade Safety

Agentic AI

Multi-Provider LLM Routing

Decision
Every persona (bull, bear, risk, reward, speculative) plus the judge step called Anthropic directly, with the model ID hardcoded per module. That's a single-vendor dependency: an Anthropic outage would silently kill the only output the operator sees on their phone. I added a provider:model_id string per persona slot, read from an env var, covering Anthropic, OpenAI, DeepSeek, Gemini, and Groq, with every default preserving the existing Anthropic-only behavior.
What broke
Right before this sprint, every persona call had started returning HTTP 400 from Anthropic. No signal existed for whether the bug was Anthropic-specific or a problem in the shared prompt construction that any provider would reject.
How I measured it
Routed just the bull persona to openai:gpt-5.4 and left the other 4 personas on their still-failing Anthropic defaults, then ran a session against the same evidence pool and compared results side by side in the journal output.
How I fixed it
The OpenAI persona came back with a valid structured result while the other 4 still 400'd, so the bug was Anthropic-specific, not universal. That closed a real ambiguity in 1 run instead of a guess. The same sprint added fail-fast startup validation (naming every missing provider key at once) and a per-provider price table so the daily budget cap couldn't silently under-bill a misrouted call.

RAG & Prompt Engineering

Grounded Evidence Pipeline

Decision
The 5-persona agent loop shipped end to end in an earlier sprint, but every persona reasoned over a stub evidence pool instead of real data. I replaced it with a typed EvidencePool (price and bars, broker positions, SEC filings, research signals, options data) fetched once per session and shared across all 5 personas, plus a narrow drill-in tool that checks the pool first and only falls back to a live source for the claims the prefix doesn't cover.
What broke
The stub-only sessions before this sprint hit 0% on the citation-coverage target. Personas had nothing real to point to, so grounded output was structurally impossible no matter how the prompts were worded.
How I measured it
Tracked citation coverage per session (at least 1 real citation per persona output, targeting 80% of sessions) and prompt-cache hit ratio per model cohort, since Anthropic's Sonnet and Haiku personas don't share a cache and had to be measured separately.
How I fixed it
Made the evidence-pool serialization deterministic (sorted keys, second-resolution ISO timestamps) and moved the cache-point marker after every variant token, because timestamp drift between personas was quietly collapsing the cache hit ratio below target. Also wrapped the 5-way persona dispatch in a 120-second timeout so 1 hung persona couldn't stall the whole session.

Systems Reliability

Idle-Poll Trigger Detection

Decision
The always-on agent loop (systemd, 15-minute tick) shipped with a stub trigger function that always returned nothing. I replaced it with a hybrid detector: a wall-clock research-window pass (morning and afternoon windows, weekday-gated, cooldown-gated) plus a signal-boost pass that opens an extra session when a fresh research signal lands outside those windows.
What broke
The agent had been running on the NUC for days, looking completely healthy in the logs, and had pushed 0 briefings to my phone the entire time. The session table was empty. A loop that looks alive but never decides to act is functionally dead, and nothing in the systemd status surfaced that.
How I measured it
Forced the clock to a weekday morning against an empty session table and confirmed the trigger returned the watchlist ticker, then confirmed it returned nothing 5 minutes later with the cooldown active. Every window, weekday, and cooldown edge case, including the March daylight-saving jump, got a deterministic unit test against a fake clock instead of the real one.
How I fixed it
The 24-hour deploy bar became concrete: at least 1 real session row and 1 message-history row from the autonomous loop, not from a manual CLI run. A new startup log line reports the resolved window and cooldown config, so a misconfigured env var shows up in the logs instead of failing silently again.

AI Safety

Defense-in-Depth Trade Safety

Decision
The agent's entire job is producing research briefings, never placing trades, but 1 bad import or a well-crafted prompt injection is all it takes for an autonomous loop to call a live order-placement endpoint. I built 2 independent layers instead of 1: a static-analysis test that fails the build if any module imports a trading-write method from the Alpaca SDK, plus a runtime allowlist that only ever registers read-only tool names with the agent and rejects anything else before the call reaches the wrapped function.
What broke
A static import check alone only catches source drift at test time. It does nothing about a prompt-injection attempt to fabricate a disallowed tool name at runtime, since that name never shows up as a Python import.
How I measured it
3 separate checks, each aimed at a different failure mode: static analysis for import drift, an integration test with a fake Alpaca client asserting no trading-write method is ever called during a full session, and an allowlist-rejection test that dispatches a forbidden tool name directly and asserts it gets rejected before the wrapped function runs.
How I fixed it
Every trading-write attempt gets blocked from 2 directions that don't share a failure mode. The same discipline carried into later evidence work: a document-read path that resolves user-influenced file paths, rejects symlinks, and caps file size, because the agent-only-reads guarantee needed the same treatment as the agent-never-trades one.