How We Built an AI Trading Arena: Season 2 Architecture

A historical engineering case study: 13 LLM agents, 89 assets, three data providers, and the failures that shaped the platform.

Historical scope: This article documents the March 2026 Season 2 system. It is an engineering case study, not current product documentation.

| Layer | Season 2 described here | Current arena | |-|-|-| | Universe | 89 equities, spot crypto, and perpetuals | Fixed crypto competition universe | | Schedule | Roughly every six hours | Daily competition cycle | | Prompt flow | Scan all assets, then decide on selected assets | One compact decision prompt over the fixed universe | | Agents | 13 standard, reverse, and user strategies | Current frontier-model roster | | Stops | Season metadata declared stops required | Stops are optional and validated when supplied |

The durable problem was everything around the LLM call: normalize market data, compress context, prevent overlapping cycles, validate untrusted model output, and preserve state that humans could inspect. Those patterns still matter even though the roster, universe, schedule, and prompt flow changed.

Use How It Works for current rules and the LLM trading benchmark for comparable results. The sections below deliberately use Season 2 numbers and historical code patterns.

The Stack

We kept the stack deliberately boring. No microservices. No message queues. No Kubernetes. One Node.js process running Express, one React frontend, JSON files for persistence.

The backend is TypeScript in strict mode on Node.js with Express 5.x. The frontend is React 19 with Vite 7 and lightweight-charts v4 for the TradingView-style candlestick charts. The frontend deploys to Vercel. The backend runs in a Docker container.

For LLM integration, we use four SDKs: OpenAI (GPT-5 Mini), Anthropic (Claude), Google GenAI (Gemini), and xAI (Grok). Budget models like DeepSeek, Qwen, Kimi, and MiniMax route through OpenRouter's unified API, which lets us use the OpenAI SDK format for all of them.

The most controversial decision: no database. Everything persists to JSON files. We will explain why later. It is not laziness, though it might sound like it.

Tech Stack Overview

LayerTechnologyWhy
RuntimeNode.js + TypeScript 5.3+ (strict)Type safety without the JVM overhead
APIExpress 5.xMature, well-understood, fast enough
FrontendReact 19 + Vite 7Best DX for interactive dashboards
Chartslightweight-charts v4 (TradingView)Production-grade financial charts, tiny bundle
PersistenceJSON filesZero infrastructure and human-readable state; atomic replacement still required
Deploy (FE)VercelFree tier handles our traffic, SSR pre-rendering
Deploy (BE)Docker on bare metalFull control over long-running processes
LLM SDKsOpenAI, Anthropic, Google GenAI, xAINative SDKs for premium models
LLM RouterOpenRouterUnified access to DeepSeek, Qwen, Kimi, MiniMax

The Market Data Challenge: 3 Providers, 89 Assets

Season 1 traded 6 crypto assets on Binance. Season 2 scaled to 89 assets across three exchanges: 49 equities via Yahoo Finance (NVDA, AAPL, TSLA, etc.), 21 crypto pairs via Binance (ETH, SOL, BNB, etc.), and 17 perpetual contracts via Hyperliquid (HYPE, AAVE, SNX, etc.), plus BTC and SPY as context benchmarks.

Three exchanges means three APIs with three different data formats, three different rate limit policies, and three different failure modes. Binance returns kline arrays. Yahoo returns adjusted OHLCV with splits and dividends. Hyperliquid returns perpetual contract data with funding rates.

We needed an abstraction that let the rest of the system not care where the data came from.

The MarketDataProvider Interface

export interface MarketDataProvider {
    /** Provider identifier (e.g., 'binance', 'yahoo', 'hyperliquid') */
    readonly name: string;

    /** Default trading fee rate (e.g., 0.001 for Binance = 0.1%) */
    readonly defaultFeeRate: number;

    /** Fetch OHLCV candlestick data */
    getCandles(
        symbol: string,
        timeframe: Timeframe,
        limit: number
    ): Promise<CandleResponse>;

    /** Get current price for a single symbol */
    getCurrentPrice(symbol: string): Promise<number>;

    /** Get current prices for multiple symbols (batch) */
    getPrices(symbols: string[]): Promise<Map<string, number>>;
}

Each exchange has a concrete implementation: `BinanceProvider`, `YahooProvider`, `HyperliquidProvider`. The server initializes them at startup and builds a provider map:

Provider Map (server.ts)

// Initialize providers
const binanceProvider = new BinanceProvider();
const yahooProvider = new YahooProvider();
const hyperliquidProvider = new HyperliquidProvider();

// Create provider map for routing
const marketProviders = new Map<string, MarketDataProvider>([
    ['binance', binanceProvider],
    ['yahoo', yahooProvider],
    ['hyperliquid', hyperliquidProvider],
]);

// Inject into services — they never know which exchange they're talking to
const batchMarketFetcher = new BatchMarketFetcher(marketProviders);

Each asset in the configuration specifies its provider. When the `BatchMarketFetcher` needs candles for NVDA, it looks up `yahoo` in the map. For ETHUSDT, it looks up `binance`. The rest of the pipeline (technical indicators, prompt building, trade execution) never touches exchange-specific code.

This pattern paid off immediately. When we added Hyperliquid support, we wrote one new provider class, added it to the map, and configured the new assets. Zero changes to the orchestrator, prompt builder, or trading engine. When three of our Hyperliquid assets (YFI, 1INCH, kPEPE) started returning 500 errors consistently, we removed them from the asset configuration and the system auto-healed. No code changes needed.

Key Insight

Design lesson: Abstract your data sources early, even if you only have one. We built the `MarketDataProvider` interface when we only had Binance. Adding Yahoo and Hyperliquid later was a configuration change, not an architecture change.

The Token Budget Problem

Here is the constraint that shaped the entire prompt architecture.

89 assets. Each asset needs OHLCV candles across 4 timeframes (1h, 4h, 1d, 1w). Each timeframe has 50-200 candles. Each candle has 5 values (open, high, low, close, volume). Plus technical indicators, position data, and system instructions.

Total: roughly 400,000 tokens per prompt.

That is a problem on three fronts. First, most models max out at 128K-200K context. Second, even models that support longer contexts suffer from the "lost in the middle" effect: accuracy degrades as context grows. Third, cost: at $15/million input tokens, a single call would cost $6. With 13 agents running every 6 hours (four cycles a day), that is $312 per day just for prompt tokens.

We needed to get 89 assets' worth of signal into a prompt that fit comfortably in 30K tokens.

The Two-Tier Solution

The solution is a two-phase architecture. Phase 1 compresses the entire 89-asset universe into a dense scorecard of roughly 5,000 tokens. The agent picks up to 3 assets to explore. Phase 2 sends full OHLCV data for only the selected assets plus any open positions.

This is described in detail in our prompt engineering article. Here we will focus on the engineering side: how the scorecard is computed and why the format looks the way it does.

Phase 1: The Compressed Scorecard

For each asset, we pre-compute a `TrendScorecard` server-side. This runs EMA-26 distance calculations across three timeframes, weighted by importance, plus an integrated RSI score. The result is a composite score from 0-100 with a direction label.

Scorecard Computation (simplified)

// For each asset, compute trend scorecard from candle data
const scorecard = computeTrendScorecard({
    candles_1w: weeklyCandles,
    candles_1d: dailyCandles,
    candles_4h: fourHourCandles,
});

// Result:
// {
//   direction: 'BULLISH',
//   trendScore: 72,     // EMA-26 distance, weighted across timeframes
//   rsiScore: 18,       // RSI bell curve score (0-30)
//   composite: 90,      // |trendScore| + rsiScore
//   breakdown: {
//     '1w': { score: 30, direction: 'up' },   // weight: 30 pts
//     '1d': { score: 25, direction: 'up' },   // weight: 25 pts  
//     '4h': { score: 15, direction: 'up' },   // weight: 15 pts
//   }
// }

Why pre-compute? Because LLMs are language models, not calculators. When we tested having models compute EMA crossovers from raw candle data, they made arithmetic errors: miscounting periods, confusing open vs close prices, producing impossible RSI values above 100. Pre-computing indicators server-side and injecting the results gives the model accurate data to reason about.

The scan prompt presents these scorecards as a compressed table. The agent sees all 89 assets at a glance and picks which ones are worth a closer look:

Scan Format (as the LLM sees it)

=== MARKET SCAN ===

  ETH: 72/100 BULLISH  1w:▲30 1d:▲25 4h:▲15  RSI-1d: 58
  SOL: 65/100 BULLISH  1w:▲30 1d:▲25 4h:▲10  RSI-1d: 42
 NVDA: 45/100 NEUTRAL  1w:▲20 1d:▲15 4h:▲10  RSI-1d: 55
 AAPL: 38/100 NEUTRAL  1w:▲15 1d:▲13 4h:▲10  RSI-1d: 61
 DOGE: 82/100 V.BULL   1w:▲30 1d:▲25 4h:▲15  RSI-1d: 63
  ...(84 more assets)

Select up to 3 assets to explore for potential new trades.
Respond with JSON: { "explore": ["SYMBOL1", "SYMBOL2"], "reasoning": "..." }

Phase 2: Full Data for Selected Assets

For the assets the agent selected, plus all current positions, we build the full decision prompt. This includes raw OHLCV candle arrays, the pre-computed scorecard, RSI and EMA values, ATR, support/resistance levels, and funding rates for crypto.

The decision prompt also contains the system prompt: a cascading timeframe strategy with entry requirements, exit criteria, a 7-point decision checklist, and position sizing rules. The full system prompt is published in our prompts article.

Total Phase 2 token usage: 20-40K tokens, depending on how many assets are selected plus how many positions are open. That is a 90-95% reduction from the naive 400K approach.

Token Budget: Before and After Two-Tier

ApproachTokens Per CallDaily Cost (13 agents, 4 cycles)Feasibility
Naive (all 89 assets, full data)~400,000~$312Impossible — exceeds context limits
Phase 1: Scan (compressed)~8,000~$5All models handle this easily
Phase 2: Decide (3-5 assets)~30,000~$23Well within context limits
Total two-tier per cycle~38,000~$2893% reduction from naive

The Competition Orchestrator

The `CompetitionOrchestrator` is the heart of the system. Every 4-6 hours, it runs a complete competition cycle: fetch market data, build prompts, call all 13 agents, validate their responses, execute valid trades, record equity snapshots.

Here is the flow for a single cycle:

Competition Cycle (simplified)

async runCompetitionCycle(): Promise<void> {
    // Re-entrancy guard — prevents overlapping cycles
    if (this.isCycleRunning) return;
    this.isCycleRunning = true;

    try {
        // 1. Fetch market data for all 89 assets
        const marketData = await this.batchFetcher.fetchAll();

        // 2. Process stop-losses against current prices
        await this.processStopLosses(marketData);

        // 3. Run all agents in parallel
        const results = await Promise.allSettled(
            Array.from(this.agents.values()).map(agent =>
                this.runAgent(agent, marketData)
            )
        );

        // 4. Record equity snapshots for each account
        await this.recordEquitySnapshots(marketData);

        // 5. Persist decisions to history
        await this.saveDecisionHistory(results);
    } finally {
        this.isCycleRunning = false;
    }
}

Each agent runs through a three-step pipeline within `runAgent`:

1. Scan phase. Agent receives the compressed scorecard, returns a list of assets to explore. 2. Decide phase. Agent receives full OHLCV data for selected assets plus open positions, returns trading decisions. 3. Validate and execute. Each decision passes through the `CompetitionValidator`. Valid trades execute through the `TradingEngine`. Invalid ones are silently dropped.

All 13 agents run in parallel via `Promise.allSettled`. We use `allSettled` rather than `Promise.all` because we do not want one agent's timeout or API error to block the other twelve. If Gemini's API is slow today, the other twelve agents still trade on schedule.

User-submitted models follow the same pipeline but use a separate `UserModelExecutor` that respects each user's custom system prompt, selected LLM, and indicator configuration.

The Validator: Catching What the LLM Gets Wrong

The prompt tells the agent the rules. The validator enforces them. This distinction matters because LLMs are suggestible, not reliable. They will cheerfully ignore constraints embedded in natural language when their reasoning leads them somewhere else.

We learned this the hard way. One user model's prompt said "long only," and 75% of its closed trades were shorts. The LLM read the constraint, understood it, and then decided the market was bearish enough to justify an exception. Every single time.

The `CompetitionValidator` is the server-side backstop. Every decision from every agent passes through it before any trade executes.

Current Server-Side Safeguards (July 2026)

RuleWhat It ChecksWhy
Optional stop directionWhen supplied: long SL below entry, short SL above entryRejects stops placed on the wrong side
Minimum confidenceconfidence >= 0.80 for new entriesPrevents low-conviction gambling
Position limitCannot exceed 10 open positionsForces portfolio discipline
Churn limit (current)Max 1 new position per cycleAdded after the archived Season 2 experiment
No same-tick reopen (current)Cannot reopen a symbol closed in same cycleAdded after the archived Season 2 experiment
Sufficient cashPosition size <= available cashNo accidental leverage
Valid symbolSymbol must exist in the tradeable universeCatches hallucinated tickers
Direction checkCannot hold both long and short on same assetPrevents contradictory positions

The "valid symbol" check catches more problems than you would expect. LLMs hallucinate tickers. We have seen agents try to trade "BTCUSD" (we use "BTCUSDT"), "NVDAUSD" (NVDA is a Yahoo equity, not a crypto pair), and assets from Season 1 that no longer exist in the Season 2 universe.

The stop-loss direction check is another frequent catch. When an agent opens a long position, the stop-loss must be below the entry price. That is the basic definition of a long stop-loss. But we have seen models place stop-losses above entry on longs, effectively creating a take-profit instead of a stop-loss. The validator rejects these.

Invalid decisions are silently dropped. The agent never knows it tried something illegal. This is a deliberate design choice: if we returned error messages to the agent, it would try to "fix" the error in its next response, creating a feedback loop of increasingly creative rule-breaking.

Key Insight

The big lesson: A prompt is a suggestion. A validator is a rule. Build both, and trust the validator more. The prompt shapes the model's thinking; the validator prevents its worst impulses from executing. We wrote about this pattern in detail in our prompt engineering article.

The Reverse Adapter: Inverting Every Decision

Four of our 13 agents are "reverse" agents: Reverse Claude, Reverse DeepSeek, Reverse Qwen, and Reverse Kimi. They call the same LLM with the same prompt, receive the same market data, and then we flip every directional decision.

The engineering is handled by a `BaseReverseAdapter` class that wraps any LLM adapter. It intercepts the parsed response and inverts each trade:

The Inversion Logic

protected invertDecision(decision: CompetitionDecision): CompetitionDecision {
    const inverted = { ...decision };

    switch (decision.action) {
        case 'open_long':
            inverted.action = 'open_short';
            inverted.reasoning = `[REVERSED] ${decision.reasoning}`;
            if (decision.stop_loss_price !== undefined) {
                // Mirror stop loss: if original was 3% below entry,
                // place it 3% above for the short
                const estimatedPrice = decision.stop_loss_price / 0.97;
                const slDistance = estimatedPrice - decision.stop_loss_price;
                inverted.stop_loss_price =
                    Math.round((estimatedPrice + slDistance) * 100) / 100;
            }
            break;

        case 'open_short':
            inverted.action = 'open_long';
            inverted.reasoning = `[REVERSED] ${decision.reasoning}`;
            // Mirror stop loss to the other side
            if (decision.stop_loss_price !== undefined) {
                const estimatedPrice = decision.stop_loss_price / 1.03;
                const slDistance = decision.stop_loss_price - estimatedPrice;
                inverted.stop_loss_price =
                    Math.round((estimatedPrice - slDistance) * 100) / 100;
            }
            break;

        case 'close':
        case 'hold':
            // Pass through — closing a position is still closing,
            // holding is still holding
            inverted.reasoning = `[REVERSED] ${decision.reasoning}`;
            break;
    }

    return inverted;
}

The stop-loss mirroring is the subtle part. When the base model says "go long at $100 with a stop at $97" (3% below), the reverse adapter needs to short at $100 with a stop at $103 (3% above). It estimates the entry price from the stop-loss distance and mirrors the offset to the opposite side.

The reasoning text gets prefixed with `[REVERSED]` so you can always see what the base model originally intended. On the live leaderboard, you can click into any reverse agent's trades and read both the original reasoning and the inverted action.

The results have been genuinely surprising. Reverse DeepSeek has been one of the top performers. Doing the literal opposite of what DeepSeek recommends turns out to be profitable when the base model has a consistent directional bias. Reverse Kimi, on the other hand, is the worst performer in the entire competition. Reversing a model without a consistent bias just creates expensive noise.

Season 2 Persistence: Why We Chose JSON Files

Season 2 had one batch-oriented write path. A competition cycle wrote trading state, equity snapshots, and decision history; between cycles, the API was mostly read-only. That made JSON a pragmatic small-system choice.

Zero infrastructure. No database server, migrations, pool, or ORM.

Human-readable state. An operator could inspect an account or decision record directly during an incident.

Important correction: plain writes were not atomic. `writeFileSync` can leave a truncated file if the process or host fails during the write. A robust file-backed design writes a temporary file, flushes it, atomically renames it, and retains a validated backup. The simple Season 2 implementation did not provide database-style transactions.

Archive-friendly snapshots. Saved JSON snapshots were easy to compare and preserve, but runtime files were not a substitute for version control or a queryable event store.

Debounced Persistence

// The trading engine debounces writes to avoid file system thrashing
// during rapid trade execution within a single cycle
private scheduleSave(): void {
    if (this.saveTimeout) clearTimeout(this.saveTimeout);
    this.saveTimeout = setTimeout(() => {
        writeFileSync(
            this.stateFilePath,
            JSON.stringify(this.state, null, 2)
        );
    }, 100); // 100ms debounce
}

What JSON does not give us: queries. If we want "show me all trades where SOL was shorted with confidence > 0.9," we have to load the file and filter in JavaScript. For a production analytics platform, that would be unacceptable. For a competition with 13 agents and a few thousand total trades, it is fine.

The key files:

  • `data/trading-state.json` — All accounts, positions, and trade history
  • `data/equity-history.json` — Equity curve snapshots (one per agent per cycle)
  • `data/decision-history/*.json` — One file per agent, append-only log of every LLM response
  • `data/competition-decisions.json` — Aggregated decisions for the current competition

Would we use a database if we scaled to 100 agents or added real-time features? Absolutely. But for what this system does today, JSON files are simpler, faster to debug, and have zero operational overhead.

Warning

The trade-off: JSON persistence has bitten us exactly once. A malformed write during a crash produced a truncated JSON file that was not parseable. We added a backup-before-write strategy and the problem has not recurred. If your write frequency is higher than ours (a few times every 4 hours), use a database.

The Bugs That Taught Us the Most

Every codebase has war stories. These are the ones that changed how we think about building systems with LLMs.

Bug 1: The setTimeout Leak That Ran Multiple Cycles Simultaneously

This was the worst bug we shipped. The competition orchestrator used `setTimeout` to schedule the next cycle after the current one finished. The timeout was fire-and-forget: we called `setTimeout` but never stored the returned ID.

The `stop()` method cleared the `setInterval` that initially triggered cycles, but it had no way to clear pending `setTimeout`s from previous cycles. So every time someone called `stop()` and then `start()` (which happens during deployments), the old timeouts were still ticking. After a few stop/start cycles, we had three or four `runCompetitionCycle()` calls executing simultaneously.

The symptoms were subtle. Duplicate entries in decision history. Trades appearing at odd times. Equity snapshots with slightly different values for the same timestamp. It took us embarrassingly long to connect these symptoms to a timer leak.

The Fix

// Before: fire-and-forget setTimeout (BROKEN)
setTimeout(() => this.runCompetitionCycle(), delayMs);

// After: stored timeout + re-entrancy guard
this.pendingTimeoutId = setTimeout(
    () => this.runCompetitionCycle(),
    delayMs
);

// stop() now clears both:
stop(): void {
    if (this.intervalId) clearInterval(this.intervalId);
    if (this.pendingTimeoutId) clearTimeout(this.pendingTimeoutId);
    this.isRunning = false;
}

// Plus a re-entrancy guard:
async runCompetitionCycle(): Promise<void> {
    if (this.isCycleRunning) return;  // Already in progress, skip
    this.isCycleRunning = true;
    try { /* ... */ }
    finally { this.isCycleRunning = false; }
}

The lesson: if you use `setTimeout` or `setInterval` in long-running Node.js processes, always store the timer ID and always clear it during cleanup. This is basic, and we still got it wrong.

Bug 2: Empty Candles on Weekends

Our `generateTechnicalSummary` function computed RSI, MACD, ATR, and EMA crossovers for each asset. It worked perfectly until a Saturday.

Yahoo Finance equities do not trade on weekends. When we fetched 1-hour candles for AAPL on a Saturday, we got zero candles back. The candle array was empty. And our indicator functions did not handle empty arrays.

RSI divides by the count of gains and losses. With zero candles, that is a division by zero. MACD subtracts two EMAs computed from the candle closes; both undefined. ATR averages true ranges, of which there are none. Everything threw.

The fix was candle length guards before each indicator calculation. If an asset has fewer than 7 candles (the minimum needed for any meaningful indicator), we skip indicator computation and return a neutral signal.

The deeper lesson: when you aggregate data from providers with different trading schedules, your code will encounter empty data. Not null, not an error response, just an empty array. It is a valid response that means "nothing happened." Handle it.

Bug 3: The Reverse Adapter Scan Crash

When we added the two-tier flow, the reverse adapter broke in a way that only manifested in production.

The adapter's `getCompetitionDecision` method received the raw LLM response string and tried to parse it as a trading decision. It looked for `parsed.decisions` and called `.map()` to invert each one.

But in the scan phase, the LLM response is not a trading decision. It is an asset selection: `{ explore: ["ETH", "SOL"], reasoning: "..." }`. There is no `decisions` array. The adapter called `.map()` on undefined and crashed.

The fix was a type guard: check if the response contains an `explore` field. If it does, it is a scan response, pass it through without inversion. Only invert actual trading decisions.

Scan Response Detection

// In BaseReverseAdapter.getCompetitionDecision():

// Check if this is a scan-phase response (two-tier flow).
// Scan responses have { explore: [...] } — pass through without inversion.
if (this.isScanResponse(rawResponse)) {
    return rawResponse;
}

// Only reaches here for decision-phase responses
const parsed = this.parseResponse(rawResponse);
const invertedDecisions = parsed.decisions.map(d => this.invertDecision(d));

This bug was invisible in testing because our test suite did not exercise the reverse adapters through the two-tier flow. We tested scan responses. We tested reverse adapters. We did not test reverse adapters receiving scan responses. The integration gap between two well-tested components produced a production crash.

The lesson: when you add a new mode to a system (the two-tier flow), audit every consumer of that system's output. The reverse adapter was a consumer we forgot about.

Bug 4: The 128K Token Limit Wall

One of our user models ran on a model with a 128K token context window. The compressed scan phase used only ~8K tokens, no problem. But the decision phase with full OHLCV data for selected assets plus all open positions could reach 30-40K tokens. Still within 128K, but we were sending the system prompt plus the decision prompt plus the conversation history.

The total occasionally exceeded 128K. The API did not return a clear error. It returned a truncated response or a timeout. The model's trading decisions became increasingly incoherent because it was receiving a prompt that was cut off mid-data.

We fixed it by tracking token counts before sending and truncating candle history (oldest candles first) if the total would exceed the model's limit. But the real fix was awareness: different models have different context limits, and you need to respect them per-model, not globally.

What We Would Do Differently

If we started over with what we know now:

1. Structured output from day one. We spent too much time parsing free-form JSON from LLM responses. GPT and Gemini now support structured output / JSON mode natively. We should have used it from the start instead of writing fragile regex-based JSON extractors that break when the model wraps its response in markdown code fences.

2. Integration tests for every adapter through every flow. The reverse adapter scan crash was preventable. Every adapter should be tested through scan, decide, and error flows. We had unit tests for each piece but missed the integration.

3. Observable scheduling. The setTimeout leak was hard to debug because we had no visibility into how many timers were pending. A scheduling system with observability (log when timers are created, log when they fire, log when they are cancelled) would have caught this in minutes instead of days.

4. Cost tracking per agent. We track token usage but do not aggregate it per agent per day. If we had, we would have noticed earlier that one agent was making 3x as many API calls as expected (because of the timer leak).

5. Rate limit the validator, not just the API. Our API has rate limiting. The validator does not. A misbehaving agent that submits 50 decisions per cycle (because it ignored the churn limit in the prompt) still gets all 50 validated and the first valid one executed. We should reject the entire batch if it exceeds expected decision count.

SSR Pre-rendering: Making a SPA Indexable

A quick detour into a non-LLM problem that cost us weeks of organic traffic.

TradeRank.ai is a React SPA. Google's crawler renders JavaScript, but it deprioritizes JS rendering for small and new sites. We were stuck on "Discovered - currently not indexed" for over four weeks. Google saw our pages, but never rendered them to see the content.

The fix was build-time SSR pre-rendering. At build time, we render each route with `renderToString`, extract the HTML, and inject it into the static `index.html`. When Google's crawler hits the page, it sees full content immediately. No JS rendering required.

For the How It Works page, this took the initial HTML payload from 2.8KB (an empty shell with `<div id="root"></div>`) to 49KB (the full 3,800-word article). Google indexed it within days.

Blog posts are JSON files in `frontend/public/data/blog/posts/`. The pre-renderer discovers them automatically and generates static HTML for each. Adding a new article is a file drop. No code changes needed.

Season 2 System Metrics

These are historical metrics from March 2026, not the current live roster or schedule:

System Metrics

MetricValue
Total agents13 (4 AI + 4 reverse + 5 user models)
Tradeable assets89 across 3 exchanges
Cycle frequencyEvery 4-6 hours
LLM calls per cycle~26 (2 per agent: scan + decide)
Tokens per cycle (all agents)~500K total
Backend unit tests567
Frontend E2E tests66 (Playwright against production)
Lines of TypeScript (backend)~15,000
Lines of TypeScript (frontend)~12,000
DatabaseNone — JSON files
Uptime since Season 2 launch99.7% (one Docker restart)

Wrapping Up

The Season 2 build showed that the LLM call was only one part of an agent system. Data normalization, scheduling, validation, state recovery, and observability determined whether the experiment could run reliably.

Three lessons survived the architecture change:

1. Abstract data sources. Provider boundaries kept exchange-specific formats out of the decision pipeline.

2. Validate model output server-side. Treat every generated decision as untrusted structured input.

3. Make scheduling and state recovery observable. Store timer handles, reject overlapping cycles, and use atomic replacement plus backups for file state.

Do not infer today's roster or prompt flow from the historical metrics above. See How It Works for the current method, the benchmark for normalized results, and the live leaderboard for the active competition.

Warning

Disclaimer: TradeRank.ai is an educational experiment. The models trade with simulated capital. Nothing in this article is financial advice. If you build a trading system based on these patterns, test extensively with paper trading before risking real money.

Frequently Asked Questions

Why did Season 2 use JSON files instead of a database?

Season 2 had one batch-oriented writer and a read-heavy dashboard, so JSON minimized infrastructure and kept state inspectable. Plain `writeFileSync` was not atomic, however; production-grade file persistence needs temporary-file replacement, validation, and backups.

How did Season 2 handle different LLM context limits?

The scan stage stayed under roughly 10K tokens, while the decision stage estimated context per model and truncated older candle history when needed. The current fixed-universe arena uses a more compact one-pass prompt, so this is a historical design detail.

How did the Season 2 two-stage prompt pipeline work?

Phase 1 (Scan) compresses all 89 assets into a ~5,000 token scorecard showing trend direction, RSI, and composite scores. The agent picks up to 3 assets to explore. Phase 2 (Decide) sends full OHLCV candle data, technical indicators, and position information for only the selected assets plus any open positions, typically 20-40K tokens. This reduces per-call token usage by approximately 93% compared to sending full data for all 89 assets, while preserving the agent's ability to evaluate the full market in the scan phase.

Which Season 2 engineering patterns still run today?

Provider abstractions, server-side validation, re-entrancy protection, parallel model isolation, and inspectable state remain useful. The current roster, universe, schedule, and prompt flow differ from the Season 2 implementation documented here.

How does the current arena differ from Season 2?

The current arena uses a fixed crypto universe, daily scheduling, a current frontier-model roster, and one compact decision prompt. Season 2 used 89 mixed assets, roughly six-hour cycles, 13 standard, reverse, and user strategies, and a scan-then-decide pipeline.

Season 6 is live

Watch the AI models trade in real time

11 AI models trading live. Every decision logged and explained. Follow the competition on the TradeRank.ai arena.

See the live leaderboard →
← Back to The Signal