himmi

Chanakya: A 13-Agent Portfolio Brain, Engineered for Restraint

15 min read
engineeringdeep-divepythontypescriptllm-agentsfastapichanakya

Chanakya is a personal stock-portfolio intelligence platform: a system that watches a real, dual-market (US + India) portfolio, reasons about it through a pipeline of specialized agents, and proposes trades that a human approves before anything touches money. It is private, proprietary, and personal-use only, with no public repository — the actual design center of the system. Every architectural decision assumes a single operator who is also the only person accountable for a wrong call. That shows up in how aggressively cost is treated as a first-class engineering concern, how conservatively money-moving code is gated, and in a sprint-and-retro discipline that reads like an engineering journal.

503 commits, 15 shipped sprints (each closed with a written retro), 47 database migrations, roughly 66k lines of Python and 31k lines of TypeScript, built solo over about seven weeks. The system was formally declared "feature-complete" at sprint 1, then grew for 14 more sprints as daily use surfaced the requirements a personal tool only reveals once it's actually being used.

The problem

Retail portfolio tools are single-lens: a charting app for price action, a separate news reader, a spreadsheet for fundamentals, a mental model for macro regime. Forming a considered view means manually synthesizing all of that, every time, for every symbol — and there's no natural place to encode "I don't trust this signal enough to act on it alone" versus "three independent signals agree, this is worth a closer look." Chanakya's bet is that an event-driven multi-agent pipeline can do that synthesis continuously and cheaply enough to run daily, as long as two things are true: the system has to stay honest about its own accuracy (a forecast that's just confidently wrong is worse than no forecast), and nothing with real financial consequence executes without a human in the loop.

Architecture

LayerChoiceWhy
Data ingestion9 async collectors (SEC EDGAR, Alpaca, FRED, Finnhub, Yahoo/Google News RSS, Reddit/StockTwits)Each source used where it's actually strongest — EDGAR for fundamentals (official XBRL), Alpaca for OHLCV, FRED for macro — rather than one vendor's mediocre coverage of everything
Event busKafka (Redpanda)Every agent is an independent consumer group on the same topics — restartable and horizontally scalable piece by piece, no central orchestrator to keep alive
StorageTimescaleDB hypertables + pgvectorHypertables for the genuinely time-series tables (bars, indicators, macro_series); plain Postgres for everything sparse (corporate_actions, tickers); pgvector for news-dedup and agent-memory embeddings
Agent runtime13 agents, one AIOKafkaConsumer process, topic → handler mapDeliberately not arq yet — one process is sufficient at this scale, and the topic-per-agent structure is already shaped for that migration if it's ever needed
APIFastAPI + SQLAlchemy 2 (async)REST + Server-Sent Events for job progress; every state-mutating, safety-gated endpoint requires session auth and CSRF
FrontendNext.js 15, ECharts via a shared ChartContainerPure build*Option() functions per chart type, unit-tested without touching a canvas
LLMGoogle Gemini, 3-tier routing (lite / flash / pro)Each agent routes to the reasoning tier its task actually needs — a classification call and a judgment call are not the same job
Rendering diagram…

Design decisions and tradeoffs

Every agent is an independent Kafka consumer group

There is no orchestrator class calling agents in order. A single TOPIC_HANDLERS map (main.py) wires 11 Kafka topics to handler functions, and each agent is its own consumer group subscribed to whatever topic feeds it. indicators.updated triggers TechnicalAgent. theses.generated triggers DecisionAgent, which emits decisions.proposed, which triggers RiskAgent, which emits decisions.approved, which triggers ExecutionAgent. The pipeline is an emergent property of topic names rather than a control-flow object anywhere in the code, so any agent can be killed, redeployed, or scaled independently, and a crash in one doesn't cascade into the others' consumer offsets.

A Supervisor restarts crashed asyncio tasks with exponential backoff, and a per-topic CircuitBreaker trips open after a 50% failure rate over a 20-event sliding window, dropping events for a cooldown period before resuming.

Synthesis, Debate, Discovery, and the LLM forecast run on scheduled daily sweeps instead of firing per event, matching inference cadence to how markets actually move — most of what matters shows up once, in the pre-market window.

One agent, three roles: how the debate works

A DebateAgent makes three sequential Gemini calls inside a single tracked run, structured as a Bull/Bear/Judge debate:

Rendering diagram…

The Bull and Bear prompts each require an acknowledged weakness — the Judge is instructed to penalize blind optimism — so the debate can't degenerate into two agents independently agreeing with the price action. The Judge runs on the higher-reasoning pro tier, fed both stances plus the underlying data, and its judge_score becomes one of seven weighted signals — tied for the highest weight (0.20) alongside the technical signal — that a separate, purely deterministic DecisionAgent composes into a final conviction. If any of the three debate calls fails, no DebateRound is persisted, and DecisionAgent renormalizes its weights around the missing signal rather than defaulting it to a neutral 0.5.

The LLM proposes, deterministic code disposes

This is the split that actually matters for a system where a bad call costs real money: of the 13 agents, only 9 ever call an LLM at all. Sentiment, Decision, Risk, and Execution — the four agents closest to actually moving money — are pure deterministic Python. RiskAgent runs five hard checks against every proposed decision: single-name position weight cap (10%), gross exposure cap (100%), a correlation-penalty veto, sector/name concentration hard caps, and a drawdown gate against a Redis-persisted portfolio high-water-mark that force-halves position size at a 10% drawdown and fully blocks new risk at 15%. None of that is an LLM judgment call — it's arithmetic against hard thresholds, on purpose.

ExecutionAgent sits behind an even harder boundary — a triple gate that must unanimously agree before real money moves:

def evaluate_gates(decision: Decision | None) -> LiveTradingDecision:
    settings = get_settings()
    gate_env = not settings.alpaca_paper        # CK_ALPACA_PAPER=false
    gate_flag = settings.live_trading_enabled    # runtime kill-switch
    gate_hitl = decision is not None and decision.human_approved_at is not None
    if gate_env and gate_flag and gate_hitl:
        return LiveTradingDecision(mode="live", ...)
    # any single gate failing → silent fallback to paper, never a silent "live" attempt

Toggling live_trading_enabled is deliberately not exposed over the API — the docstring calls this out directly, to preserve the deliberate-action property of the gate. Every live-money attempt is written to an audit log table; paper attempts are not, by design.

The leakage guard and the neutral band

ForecastAgent predicts a 3-class direction (up / down / flat, internally nicknamed Bull/Bear/Horse) for both a 1-day and 5-day horizon in a single Gemini call per symbol, about 109 calls each morning. A leakage guard ensures the call only ever sees information that would have been available at decision time:

# LEAKAGE GUARD: never surface an article published after the call time.
news_rows = await session.execute(
    select(NewsArticle, SentimentScore)
    .where(
        NewsArticle.symbol == sym,
        NewsArticle.published_at <= now,   # LEAKAGE GUARD
        NewsArticle.published_at >= since,
    )
)

now is the caller-supplied decision timestamp rather than datetime.now(), so replaying a historical decision for backtesting or grading uses exactly the news that existed at that moment. A dedicated test (test_forecast_agent_leakage.py) plants future-dated articles and confirms they never enter the context.

The "flat" class is volatility-scaled per symbol and horizon, computed deterministically rather than by the LLM:

def neutral_band(atr: float | None, close: float, horizon: int) -> float:
    atr_pct = (atr / close) if (atr is not None and close > 0) else 0.015
    return _clamp(_BAND_K * atr_pct * math.sqrt(horizon), _BAND_FLOOR, _BAND_CEIL)

A volatile small-cap gets a wider "flat" band than a stable large-cap, and grading against the eventual close uses the same band the forecast was made against. The scorecard — a 3×3 confusion matrix, per-class precision/recall, and a Gorodkin multiclass MCC — measures correlation between predicted and actual class rather than raw accuracy: on a skewed base rate, accuracy flatters a constant predictor, while balanced accuracy and MCC both land at zero for a model that always guesses "flat." The test suite verifies this directly: an always-flat predictor scores balanced_accuracy ≈ 1/3, MCC = 0.

Feedback loops that close over time

Four systems keep the agents from running forever on frozen heuristics:

  1. Model promotion gate — a new prediction model trains every Sunday night, is scored on a holdout split, and only replaces the live champion model if it beats the incumbent on AUC × hit-rate × Brier score. A worse retrain stays a candidate; the live model is untouched.
  2. Calibration scorecard — walks every prediction past its target time, computes the realized return, and assigns an A–F grade per confidence quantile band, so the dashboard shows whether the model's stated confidence intervals are well calibrated.
  3. Regime-conditional weight matrix — 5 market regimes × 7 agent signals = 35 weight cells. DecisionAgent multiplies a regime prior against a Bayesian per-agent posterior before composing the final conviction, and every closed trade updates the cell for the regime it was opened in, so the matrix learns over time rather than being hand-tuned once.
  4. HITL meta-classifier — trains a LightGBM model on (decision features → was the human's override correct), using the same feature vector DecisionAgent itself used. Every decision response carries an override_confidence and a recommendation (trust_agent / consider_override / override_likely_correct) next to the approve/reject buttons, weighing when to trust the operator's judgment against the system's own signal.

Cost and inference discipline

Chanakya runs on a once-daily pre-market sweep per region, plus a startup-catchup pass, rather than continuous hourly polling — the signals that matter mostly settle once a day, and polling for them hourly just re-asks a question whose answer hasn't changed. Model calls route through three tiers: lightweight for classification-style calls, mid-tier for extraction, and the strongest tier reserved for judgment calls — the debate's Judge, Synthesis, Macro. Generation parameters are tuned per call type, so a structured classification call isn't paying for the same open-ended deliberation a judgment call needs. Embeddings run locally via sentence-transformers on-device (Apple Silicon MPS) at 384 dimensions, with no external dependency for a high-volume, low-value-per-call workload.

A SHA-256 input-hash dedup layer caps repeat work at one call per (agent, input) pair within a 24-hour window, verified by a test that asserts an LLM mock is called exactly once across two identical requests:

dedup_window_seconds: int = 86400   # class attribute on Agent

if not force and self.dedup_window_seconds > 0:
    cached = await self._find_cached_run(input_hash)
    if cached is not None:
        return await self._return_cached_result(cached, input_hash)

A cache hit writes a status='cached' marker row pointing back at the original run rather than making a fresh call, and downstream orchestration skips re-emitting Kafka events on a cache hit, so a restart doesn't cascade into re-triggering DecisionAgent for work that already happened — the system is safe to restart mid-day without duplicating effort or double-counting anything downstream.

A token-budget guard reserves tokens optimistically before a call and reconciles after: a failed call refunds its reservation rather than leaving it charged against the daily budget, so a burst of failures — a provider outage, a flaky network — can't drain the counter before a single successful call goes through. A 15-minute cooldown sits on top of that, tripping the moment any run fails with a budget-related error and checked before dispatch, so a high-volume per-article stream like NewsAgent backs off cleanly under sustained exhaustion instead of continuing to dispatch calls that would fail anyway.

Infrastructure footprint

TimescaleDB hypertables use 365-day chunks, sized for once-a-day OHLCV bars rather than high-frequency ticks — about 20 chunks per symbol across 20 years of history, keeping the per-query lock count low. set_chunk_time_interval governs only future chunks, so the migration sequencing runs ahead of the daily seed step, letting a fresh database start on the current chunking from row one.

The full 13-process fleet runs from a single shared base Docker image rather than one image per service — build time is about 140 seconds and image size about 2 GB. Each container runs under a per-container CPU ceiling, and native thread pools (OpenBLAS, OMP, numba) are each capped rather than left to claim every core by default. Lazy intraday seeding backfills only the interval actually requested rather than eagerly populating every interval at setup, bringing first-run setup to about 4.2 minutes.

Reasoning and access control

Every state-mutating, safety-gated endpoint requires session auth and CSRF double-submit validation. The CSRF check is Redis-backed and fails open if Redis becomes unreachable, the same infrastructure-availability policy applied to the drawdown risk gate: the system stays available under a dependency outage rather than blocking on it.

A local-only Astro documentation site — 33 pages, 12 real dashboard screenshots — covers the system's architecture and stays off the public internet by design, withholding the specific agent reasoning and sizing math that make up Chanakya's actual edge.

Sprint discipline

Every sprint close carries the same shape: a quality gate — ruff clean · pyright 0 errors · biome clean · tsc clean, plus current test counts — and a written retro. The suite stands at 681+ backend tests (pytest) and 376 frontend tests (vitest), with slower DB-integration tests scoped out of the fast local loop and run separately.

plans/ holds six efforts, each gated on an explicit external trigger rather than a backlog priority call: Interactive Brokers as a second execution venue, waiting on Alpaca live trading being proven first with real money; Twitter/X sentiment, gated on a cost-benefit ADR against a materially pricier API tier; a mobile-responsive overhaul; sandboxed user-defined strategy scripts; and full multi-user isolation (~28 router gates, 7 new foreign keys, CSRF/CORS hardening), waiting on a second user actually asking for access. The roadmap follows demand signals rather than a backlog-grooming exercise.

Stack at a glance

BackendPython 3.12, FastAPI, SQLAlchemy 2 (async), Alembic — 47 migrations
Data layerTimescaleDB (hypertables + compression), pgvector (384-dim, local embeddings)
Event busKafka via Redpanda — 13 agents, each an independent consumer group
Agents13 wired (+ 1 pure-function scorer), only 9 ever call an LLM
LLMGoogle Gemini, 3-tier routing (lite / flash / pro), per-call reasoning-depth tuning
FrontendNext.js 15, TypeScript, ECharts via pure build*Option() functions
ExecutionAlpaca (paper by default), triple-gated live trading
Tests681+ backend (pytest), 376 frontend (vitest), Playwright e2e
CIGitHub Actions — ruff, pyright, pytest, biome, tsc, vitest, gated e2e
ObservabilityOpenTelemetry (API), Prometheus + Grafana, Jaeger — opt-in stack
LicenseProprietary, personal-use only

Update: four months and 1,400+ commits later, Chanakya v2 covers where the platform stands now — 17 agents, a full quant-research module, and a complete personal-finance layer built around a database-enforced ledger.

More deep dives in this series: SuperZen, where a single 1Hz heartbeat replaces a pile of independent timers for the same reason Chanakya's DecisionAgent renormalizes around a missing signal instead of defaulting it — one disciplined mechanism doing the work of every special case; and BroSki, a Rust task runner built around the same instinct behind Chanakya's token-budget accounting: a system should be able to explain exactly why it did or didn't do something.