BUILD NOTES · 賽馬場

How I built
AI Trading PK

Notes to my future self. A reference for talking about this project without saying "the AI built it." Read this once, skim the talking-points section before any conversation, and you'll be able to answer every question with confidence.

FastAPI · Python 3.11 Next.js 14 · TS strict SQLite · SQLAlchemy Yahoo Finance Chart API Claude (BYOK) · Gemini Tailwind · Framer Motion
CHANGELOG · LIVING DOC

Milestones, newest first

Every time we ship something meaningful, an entry lands here. Pre-existing sections below are updated in place when behaviour changes.

M8 2026-06-10

Production launch · live forward competition · the live-edge engine fix

  • Full stack in the cloud: backend on Cloud Run (SQLite on a GCS volume, pinned to a single instance = single writer), frontend on Cloudflare Workers via OpenNext (which forced a healthy Next 14 → 15 + React 19 upgrade), and Cloud Scheduler firing the idempotent tick at 14:15 TPE right after market close, with an 18:00 retry as insurance. Intraday the Stocks tab still streams near-real-time TWSE MIS quotes.
  • The critical engine fix: in a historical replay, "tomorrow's bar" is always cached; in live forward mode tomorrow hasn't happened yet. The tick used to skip decisions whenever the next trading day was missing — and since each day is idempotent and never re-decided, a live competition would mark-to-market forever and never place a single trade. Fix: queue orders with an estimated execute_date (Dk+1 calendar) and fill anything with execute_date ≤ today, re-stamping the actual fill day. A Friday order fills at Monday's open; no-look-ahead is untouched. Replays could never catch this bug — a live lesson in why backtests lie.
  • Live PK competition: four personas (momentum / chartist / YOLO / contrarian) make a real Gemini decision after every close and fill at the next open, trading forward on real data from today. All LLM reasoning and per-order rationales now come back in Traditional Chinese.
  • Four features in one day: a 0050 buy-and-hold ghost lane slotted into the race track at its true rank (did anyone actually beat the index?); a template-generated 3-sentence daily recap card (zero LLM cost); in-place strategy editing with per-field version diffs; and an ADMIN_SECRET guard on mutating admin ops so the URL is safely shareable.
  • Backend tests 99 → 110, all green. Live quotes stay display-only — the engine remains daily; feeding intraday prices into decisions would break no-look-ahead.
M7 2026-06-01

LIVE coverage fix · per-ticker news · Chinese build notes

  • LIVE column fortified: only ~30% of tickers were showing live data because TWSE MIS leaves the z field empty between matches. New fallback chain: z (match) → midpoint of best bid/ask → today's open. Near-100% coverage during market hours. UI tags the price source so users know how the number was derived.
  • News integration: per-ticker "新聞" button on the Stocks tab opens a modal with the 10 latest Chinese headlines via Google News RSS (zh-TW). Server-side 4-hour cache so 10 viewers = 1 upstream call.
  • Chinese build notes: docs/how-i-built-this.zh-TW.html — full Traditional Chinese mirror with native-feeling translation (not literal). Talking points re-written in Chinese for natural delivery. Cross-linked from this version.
  • 5 new tests (Google News RSS parser + cache + fallback). Total: 62 backend tests, all green.
M6 2026-06-01

三大法人 flows · risk-adjusted leaderboard · sharper LLM prompt

  • Added 三大法人 (institutional flows) — new InstitutionalFlow model + fetcher from TWSE T86 (TSE) and TPEx 3insti (OTC). Daily net buy/sell in 張 per ticker. Plugged into build_snapshot so the LLM sees foreign / trust / dealer flows on every decision. Visible as four new columns on the Stocks tab.
  • Verified live: 2454 聯發科 on 5/29 showed 外資 -2,325 張 / 投信 -1,021 張 / 自營 -130 張 = total -3,476 張, explaining the -2.27% price drop. The signal works.
  • Added Sharpe / volatility / max drawdown to every agent (computed from EquitySnapshot series). Leaderboard endpoint now accepts ?rank_by=return|sharpe|max_dd|equity — frontend has a toggle. Hero card shows the three metrics as pills below the headline return.
  • Verified by re-ranking the demo competition: by return, 航海王 (+8.75%) wins; by Sharpe, 價值老司機 (4.14) wins with a lower 4.58% return but only 2.25% max DD vs 航海王's 7.50%. Skill, not luck.
  • Sharper app/llm/prompt.py: explicit 5-step thinking framework (state → research → decide → size → emit), schema spelled out (with 三大法人 fields), reminder that orders fill at NEXT day's open, encouragement to return orders: [] when uncertain. Costs nothing, raises baseline LLM quality.
  • Refresh service now also pulls 三大法人 (date-range incremental, 2 HTTP calls per day total, regardless of ticker count).
  • 10 new tests (5 institutional parser, 5 metrics math). Total: 57 backend tests, all green.
M5 2026-05-29

Data freshness — auto-refresh after close + visible 資料截至

  • Caught by Jazz: 聯發科 showing 4410 (yesterday's close) instead of 4310 (today's close after market). The DB had only seeded data up to 5/28 — no auto-pull after the 13:30 TPE close.
  • Added app/services/refresh.py with expected_latest_finalized() (Mon–Fri after 14:00 TPE → today; otherwise last weekday) and refresh_universe() (pulls only the missing date range, no-op if up-to-date).
  • New endpoint POST /competitions/{id}/refresh-data. Stocks endpoint now returns {as_of, expected_latest, is_stale, rows} so the UI can decide when to refresh.
  • APScheduler job at 14:00 TPE Mon–Fri auto-refreshes every running competition's universe (de-duped across competitions). Engine decisions still run separately at 18:00.
  • Stocks page now shows "資料截至 YYYY-MM-DD" badge prominently. Auto-triggers background refresh on mount when stale. Manual "立即更新" / "重新拉取" button always available.
  • Verified: same DB that showed 4410 yesterday now returns close=4310, pct_1d=-2.27% via GET /competitions/1/stocks.
M4 2026-05-29

Near real-time TWSE prices — TWSE MIS · ~5s delay

  • Added TWSE MIS client (mis.twse.com.tw/stock/api/getStockInfo.jsp) — same source brokers use for "real-time" displays. ~5-second updates during 9:00–13:30 TPE Mon–Fri.
  • New endpoint GET /competitions/{id}/stocks/realtime. Server-side 5-second cache so 10 page-viewers = 1 batch call to TWSE.
  • Stocks page now has LIVE + 日內 % columns, auto-refresh every 30s, MARKET CLOSED state outside trading hours.
  • Boundary preserved: LLM decisions still use ONLY daily data. Intraday is a display-layer overlay only — the no-look-ahead invariant remains intact.
  • 9 new tests for the MIS client (market-hours gate, payload parse, cache reuse, network-failure graceful empty). Total tests: 47.
M3 2026-05-29

Stocks tab · responsive hero · agent-creation clarification

  • Added /stocks universe browser — sortable table over 194 TWSE names with the same snapshot the LLM sees. Click any row for a 90-day historical chart.
  • Fixed hero overlap on both LeaderHero and the agent profile. Title fonts dropped from clamp(2.5rem,5vw,5rem) to clamp(1.75rem,3.6vw,3.25rem); stats now use stack-on-narrow + whitespace-nowrap.
  • Added Section 8b clarifying that "agent" in this system is a DB row created via the UI, not a markdown config file.
M2 2026-05-29

賽馬場 redesign · BYOK Claude · 194-ticker universe

  • Removed shared ANTHROPIC_API_KEY entirely. BYOK Claude with Fernet-encrypted per-owner storage.
  • Full UI redesign: hero frontrunner card, horizontal race track, Framer Motion animated counters + layout transitions on rank changes.
  • Replaced broken yfinance lib with direct Yahoo Finance Chart API. Expanded seed universe from 137 to 194 unique TWSE names.
  • Engine bug fix: advance_one_trading_day now floors the first tick at start_date − 1 instead of the earliest cached price date.
M1 2026-05-29

Initial build · engine + LLM clients + first-pass UI

  • 11-table SQLite domain. Engine tick with idempotency + no-look-ahead invariant.
  • FastAPI routers, Pydantic v2 strict. Daily tick: APScheduler in local dev; in production Cloud Scheduler hits an idempotent cron endpoint (14:15 + 18:00 TPE).
  • 6 personas + 4-question quiz, strategy version-lock + weekly edit cap.
  • Next.js + Tailwind first-pass leaderboard, agent detail, new-agent wizard, admin.
  • 38 tests including the no-lookahead probe.
SECTION 1

The pitch in one paragraph

If someone gives you 30 seconds, this is what you say.

It's an internal toy I built — a paper-trading PK between AI agents on the Taiwan stock market. Each person describes a trading style in plain text, the system spawns a daily agent for them, and a race-track leaderboard ranks everyone by return %. The clever part is the engine: the LLM only proposes orders, the engine validates and accounts. So even if the model hallucinates, it can't break the books. — elevator-pitch v1
SECTION 2

Why it exists

The brief

Internal team tool. Phase 1: 10 users, designed-for 50. A meeting-room toy where non-engineers configure agents in plain language and we watch the race shift week over week.

What was off the table

Real money. Real-time data. Intraday streaming. Statistical-significance machinery. No SSO yet (owner_name is identity). Daily resolution is enough.

SECTION 3

Five concepts to know

If you understand these, you can answer almost any question someone throws at you.

Paper trading

Fake money on real prices. The system tracks what you would have made if you'd actually traded. No broker involved, no real orders sent.

Daily OHLCV

The four prices that summarize one trading day for a stock: Open, High, Low, Close, plus Volume. The unit of work for this whole system.

Look-ahead bias

Accidentally using future data to make a "past" decision. The cardinal sin of backtesting. It makes your strategy look great, then it tanks live. There's a test in this codebase that fails if you trip it.

Engine as source of truth

A pattern from real trading systems: the strategy / model proposes orders; the engine validates them, applies costs, mutates the portfolio. Two layers, two responsibilities. The model can't sneak past the rules even if it tries.

BYOK (Bring Your Own Key)

Instead of one shared LLM API key the server pays for, each user connects their own. Stored encrypted with Fernet (symmetric AES + HMAC). Their usage, their bill.

Adjusted vs unadjusted prices

"Adjusted close" silently bakes in past dividends and splits — useful for charting, poisonous for accounting. This system uses unadjusted prices and applies corporate actions explicitly (dividend → cash credit, split → shares × ratio, avg_cost ÷ ratio).

SECTION 4

The architecture in one diagram

Five layers, one direction of dependency. Nothing fancy.

Browser · Next.js 14 (App Router) race-track leaderboard · animated counters · framer-motion · port 3700 JSON / fetch / /api/* rewrite FastAPI · Python 3.11 · Pydantic v2 routers (competitions / agents / personas / admin / me_keys) · cron tick 14:15/18:00 Asia/Taipei · port 3701 Engine (source of truth) advance_one_trading_day execution · corp_actions · mtm · decisions no-look-ahead invariant enforced here LLM clients Claude · BYOK per owner_name Gemini · shared server key strict JSON · defensive parser · rate-limited SQLite · SQLAlchemy 11 tables · PriceBar cache · owner_keys (Fernet) phase 2: swap to Postgres via DATABASE_URL Yahoo Finance Chart API direct HTTP · OHLCV + events (div, split) TWSE OpenAPI as fallback · cached forever 194 TWSE tickers in seed universe · snapshotted per competition
SECTION 5

Stack, layer by layer

Why each piece is here, in one or two lines.

LayerChoiceWhy this, not something else
Backend framework FastAPI 0.115 Async, Pydantic v2 native, OpenAPI for free. Faster to ship than Django for an API-only service.
Schema validation Pydantic v2 strict Strict mode rejects type coercion ("1" → 1) so bugs surface at the boundary, not in the engine.
ORM SQLAlchemy 2.0 Typed Mapped[...] columns play with mypy/Pylance. ORM stays out of the way during queries.
Database SQLite (file) Zero ops, atomic file. ≤50 users will not hit any wall. Phase 2: change one env var to Postgres.
Scheduler APScheduler 3.10 In-process cron. No external Redis or Celery to babysit. Daily tick is the only job.
Market data Yahoo Chart API (HTTP) The yfinance Python lib silently returned empty for TWSE tickers. Direct HTTP is more reliable and ~50 lines.
LLM SDKs anthropic, google-genai Both have current Python SDKs. Wrapped behind a single DecisionProvider Protocol so the engine doesn't care which.
Encryption cryptography.Fernet Authenticated symmetric encryption. Standard library for "secret at rest". Key derived from SERVER_SECRET via SHA-256.
Frontend framework Next.js 14 App Router File-based routing, RSC for the static shell, client components only where needed. TS strict, no `any`.
Styling Tailwind 3 Design tokens in tailwind.config.ts, utility classes in components. Beats CSS-in-JS for fast iteration.
Animation Framer Motion 11 Layout animations on the race-track lanes when ranks shift. AnimatedNumber tweens for counters.
Tests pytest + TestClient 38 backend tests. The critical one is test_engine_no_lookahead — guards the central invariant.
SECTION 6 · THE HEART

The engine tick

One function — advance_one_trading_day in backend/app/engine/tick.py — runs the same six steps in the same order every day. Idempotent. No exceptions.

1 Resolve Dk next trading day in cache 2 Fill pending at Dk OPEN · re-validate 3 Corp actions dividend → cash split → shares 4 MTM at close write Equity Snapshot 5 Decide LLM call · queue orders for Dk+1 6 Advance current_trading _date = Dk all six steps committed in one DB transaction · re-running same Dk = no-op timeline Dk−1 close last tick Dk close decide here read ≤ Dk only Dk+1 open orders fill at this price

Why this shape

Each step has one responsibility. Fills happen before decisions because the new decision needs to see the post-fill portfolio. Corporate actions happen between fills and MTM because a split changes share counts before you mark to market. Decisions go LAST because they read everything else — and they queue orders for tomorrow, not today.

Idempotency, why

A daily job in production will hiccup. If the same Dk runs twice, you can't double-fill orders or double-pay dividends. The function checks "did I already write an EquitySnapshot for this Dk?" — if yes, return no-op. Safe to retry.

SECTION 7 · THE INVARIANT

No look-ahead, or it's all a lie

The single bug that ruins every amateur backtest: accidentally using future data in a "past" decision. This system has a test that fails loudly if you trip it.

The rule

A decision dated day Dk may only read prices and events with date ≤ Dk's close. Its resulting orders fill at Dk+1's OPEN — never at Dk's close.

Reason: in a real market you don't see Dk's close until the market is shut. You can't act on it until the next morning's open.

How it's enforced

Every query in build_decision_context filters date <= decision_date. The fill function explicitly looks up Dk's PriceBar.open, never .close.

And a probe provider in the test suite records every date it sees during a decision. Any date > Dk fails the test.

# backend/tests/test_engine_no_lookahead.py — the heart of the safety net

def test_decision_never_reads_future_data(db):
    # day 1..3 prices are 600..602; day 4 jumps to 999 (the "future")
    seed_path_bars(db, ticker="2330.TW", start=date(2026, 5, 4),
                   opens=[600, 601, 602, 999, 1000, 1001])

    snoop = SnoopProvider()
    advance_one_trading_day(db, comp, snoop, _snoop_snapshot)
    advance_one_trading_day(db, comp, snoop, _snoop_snapshot)
    advance_one_trading_day(db, comp, snoop, _snoop_snapshot)

    # for every decision, the dates it saw must be ≤ its own decision date
    for dk_observed, seen in snoop.observations:
        assert all(d <= dk_observed for d in seen), \
            f"Decision on {dk_observed} saw future dates {seen}"

Every refactor that touches the engine runs this test. If someone ever "optimizes" by reading tomorrow's open while making today's decision, the test breaks. The bug can't ship.

SECTION 8

Strategy schema: two halves

A single shape, half code-enforced, half plain language. Personas are just named presets of this shape.

structured (engine enforces)

{
  "max_position_pct": 0.30,
  "max_holdings": 4,
  "max_trades_per_decision": 3,
  "cash_floor_pct": 0.10,
  "stop_loss_pct": 0.08,
  "take_profit_pct": 0.20
}

These are hard caps. The engine validates every order against them after the LLM proposes. An order that would breach any cap is rejected with the reason recorded — visible in the agent's order history.

free_text (LLM reads)

"Lock in 5-day and 20-day momentum
leaders that are above MA20. Cut
positions down -8%. Take profit at
+20% in tranches. When momentum
fades, rotate immediately."

This is what the LLM reads each decision day. It's the personality. The engine does not parse it — it just bundles it with the market snapshot in the prompt.

Why both: structured alone is too rigid to feel like a personality. free_text alone lets the LLM over-trade and bleed out on fees. Splitting them gives you the upside of natural-language configuration with the safety of engine-enforced limits.

SECTION 8b · COMMON CONFUSION

"是怎麼建立 agent 的?" — clearing this up

Coworkers often ask if agents are markdown config files. They are not. Here is the precise mental model so you can answer with confidence.

What an agent IS in this system

A row in the agents table. Fields: id, competition_id, owner_name, name, model_provider, persona_id, decision_cadence_days + a linked StrategyVersion that holds the structured + free_text strategy.

Created via UI (/new-agent) or via API (POST /competitions/{id}/agents). No file editing involved.

What an agent is NOT

It's not a markdown file in a folder. It's not a YAML config someone has to commit. The confusion comes from Claude Code's subagent system — those ARE markdown files in .claude/agents/. Different concept entirely.

Different from LangChain or AutoGen agent definitions too. This system's "agent" is closer to a "trader profile" + LLM client wiring.

# The actual creation flow — UI under the hood

POST /competitions/1/agents
{
  "owner_name": "Jazz",
  "name": "技術線仙 v3",
  "model_provider": "claude",
  "persona_id": "ta_charter",
  "decision_cadence_days": 1,
  "strategy": {
    "structured": { ...numeric guardrails... },
    "free_text": "在 above_ma20 且 volume_ratio > 1.5 時進場..."
  }
}

Why the UI route exists: the brief was non-engineers configure agents. Forcing them to edit YAML files would defeat the entire point. The new-agent flow is: quiz → persona pick → tweak structured caps → write free_text → connect Claude → submit.

Coworker asks: "agent 是可以從平台生成嗎?"
Answer: "是的,全程在 UI — quiz、選 persona、調策略、連 Claude、推上場。不用碰任何檔案。" — ready-to-say
SECTION 8c · DECISION SUPPORT

The 個股 / Stocks tab

The leaderboard answers "who's winning?". The Stocks tab answers "what should I tell my agent to look at?". Added so users can browse the universe before tweaking their strategy.

What you see

A sortable table over the entire competition universe (~194 TWSE names). Columns: ticker, name, last close, 1-day / 5-day / 20-day return, MA20 cross marker, volume ratio, and a 30-day sparkline per row.

Sort by any column. Search by ticker or name. Click a row → modal with a 90-day historical line chart.

The key insight

This is the same snapshot the LLM sees during decisions. Same build_snapshot function, same numbers. So users browsing the page get the exact inputs an agent would see — which makes it a real decision-support tool, not a separate market viewer that could disagree with the agent's view.

# backend/app/api/stocks.py — the endpoint reuses the LLM's snapshot

@router.get("/competitions/{competition_id}/stocks")
def list_competition_stocks(competition_id, as_of, db):
    universe = universe_for_competition(db, competition_id)
    on = as_of or latest_finalized_date(db, universe)
    rows = build_snapshot(db, universe, on)   # <-- same fn the engine uses
    # attach 30-day sparkline so the table can render without N round-trips
    ...
    return rows

Why this matters for the PK: non-engineers writing free_text strategies tend to write "buy stocks that go up." Showing them the actual snapshot the LLM sees turns vague intuition into specific instructions — "buy stocks with pct_5d > 10% and volume_ratio > 1.5" — which the LLM can actually execute.

SECTION 8e · TAIWAN-SPECIFIC SIGNAL

三大法人 (institutional flows)

Every Taiwan trader checks 三大法人 numbers before they trade. If our agents don't see them, they're blind to the single biggest moving force in the market.

外資 (Foreign)

Foreign institutional investors — by far the largest mover on TSE. Includes 陸資 (Chinese capital) and 外資自營商 in our reporting. A 5,000-lot net buy on 台積電 moves price more than any retail flow.

投信 (Investment Trust)

Mutual funds and investment trust companies. Smaller flows but persistent — when 投信 starts buying a name, they often keep buying for multiple days. Watching for the start of a 投信 rally is a classic Taiwan signal.

自營商 (Dealer)

Proprietary trading desks at brokerages. Smaller flows again, split between 自行買賣 (directional) and 避險 (hedging against derivatives positions). Less directional signal, but useful to round out the picture.

How the LLM sees it. Each row in the snapshot now carries foreign_net_lots, trust_net_lots, dealer_net_lots, total_inst_net_lots alongside the price + volume fields. Units are 張 (1 張 = 1000 shares) for readability. Positive = net buy, negative = net sell. The system prompt explicitly tells the LLM what to look for — "三大法人 buying with rising price = strong signal; selling into rallies = caution; investment trust (投信) consistently buying = often a multi-day move".

# Verified: 2454 聯發科 on 2026-05-29

ticker: 2454.TW
last_close: 4310.0       # was 4410 day before
pct_1d: -2.27%           # the drop
foreign_net_lots: -2325   # 外資 dumped 2.3M shares
trust_net_lots: -1021     # 投信 sold 1M shares
dealer_net_lots: -130     # 自營 small sell
total_inst_net_lots: -3476  # 三大法人 sum: 3.5M sell

The data sources: https://www.twse.com.tw/rwd/zh/fund/T86 (TSE) and https://www.tpex.org.tw/web/stock/3insti/daily_trade/3itrade_hedge_result.php (OTC). Both free, both work, both updated within 30 min of market close. One HTTP call covers the entire market — so refreshing 三大法人 for our 194-ticker universe is just 2 requests per day.

SECTION 8f · SKILL OVER LUCK

Risk-adjusted leaderboard

Three metrics turn the leaderboard from "who got luckiest" into "who actually traded well". Toggle the ranking by Return / Sharpe / Max Drawdown.

Sharpe ratio

(mean daily return / stdev daily return) × √252. Higher = better return per unit of volatility. A Sharpe of 2 means you got 2× the volatility back as return. In real funds, >1 is good, >2 is great, >3 is suspicious.

No risk-free rate adjustment — this is a friendly PK, not a quant research platform.

Max drawdown

Largest peak-to-trough decline over the equity curve. Lower = better risk control. An agent that gained 20% then crashed to 5% had a max DD of ~12.5%.

Ranked by negated DD so "highest score wins" stays consistent across all sort modes.

Why this matters. Watch what happened in our own demo when we re-ranked:

AgentReturnMax DDSharpeWins by…
航海王 · Momentum +8.75% -7.50% 3.88 Return
價值老司機 · Contrarian +4.58% -2.25% 4.14 Sharpe Max DD

Momentum's raw return is nearly double, but Contrarian put up its smaller gain with one-third the drawdown — so on a risk-adjusted basis, Contrarian wins. This is the kind of comparison that turns the leaderboard from a slot machine into something you can actually defend in front of finance-literate coworkers.

Implementation in backend/app/services/metrics.py. Annualized using 252 trading days. Returns None on insufficient data (fewer than 2 snapshots, or zero volatility) — frontend renders that as "—" rather than fabricating a value.

SECTION 8d · DATA TIERS

How "real-time" actually works here

Two different data streams power the app. The boundary between them is the most important architectural decision after the engine-as-source-of-truth choice.

Daily layer (for decisions)

OHLCV from Yahoo Finance Chart API, cached forever once a bar finalizes. This is what the LLM sees. A decision dated Dk reads only data ≤ Dk's close. The no-look-ahead test guards this — intraday data cannot leak in.

File: backend/app/data/prices.py

Live layer (for browsing only)

TWSE MIS — the same endpoint Taiwan brokers wire into their "real-time" quote displays. ~5-second updates during 9:00–13:30 TPE Mon–Fri. Powers the LIVE column on the Stocks tab. The engine never reads this.

File: backend/app/data/realtime.py

SourceDelayFree?What we use it for
TWSE MIS (mis.twse.com.tw) ~5 sec during market hours Yes LIVE column on Stocks tab — for users browsing before tweaking their agent.
Yahoo Finance Chart API end-of-day daily Yes Daily OHLCV cache for the engine + decision snapshots.
Yahoo Finance quote endpoint ~15-20 min Yes Not used. TWSE MIS is faster + free.
GOOGLEFINANCE (Google Sheets) ~15-20 min Yes Comparable to Yahoo quote. Not used for the same reason.
Broker API (元大 / 富邦 / 富果) tick-by-tick No (account + sometimes paid) Overkill for an internal toy. Would be the path if we needed live fills.

The boundary in one sentence: live data is paint on top of the UI. Anything that mutates the portfolio (orders, fills, MTM snapshots) reads only daily data, so the engine stays deterministic and the no-look-ahead test still passes.

Why not push intraday into decisions? Two reasons. (1) A decision made at 11am using 11am prices can't fill at 11am — paper or real — so you'd need to invent a fill model that approximates VWAP or next-quote, and your backtest stops being trustworthy. (2) The no-look-ahead test is what makes me confident showing the dashboard to clients. Once you mix time scales, that confidence is gone and the bug surface grows.

SECTION 9

BYOK Claude: how user keys work

No shared Anthropic key. Each person connects their own.

Why BYOK

There is no public OAuth flow for arbitrary apps to call Claude using a Claude.ai subscription — that's a Claude Code special case. Realistic options were: one shared API key (one heavy user kills everyone's rate limit) or BYOK (each user pays for their own usage). BYOK won.

How it's stored

User pastes their key once in the "連結 Claude 帳號" panel. Server SHA-256-derives a Fernet key from SERVER_SECRET and encrypts the API key before insert. Stored in owner_keys.encrypted_key as binary. Only last4 + label ever come back out for display.

# backend/app/services/crypto.py — the encryption seam

def _derive_key(secret: str) -> bytes:
    digest = hashlib.sha256(secret.encode("utf-8")).digest()
    return base64.urlsafe_b64encode(digest)

def encrypt(plaintext: str) -> bytes:
    return _fernet().encrypt(plaintext.encode("utf-8"))

def decrypt(token: bytes) -> str:
    return _fernet().decrypt(token).decode("utf-8")

If the user disconnects, the row is deleted. If the user's key is stored but invalid, the LLM call fails gracefully — the decision logs "claude not connected" and returns zero orders. The engine never crashes mid-tick.

SECTION 10

The 賽馬場 UI redesign

The first version was a clean dark table — readable, dull. The redesign was a deliberate borrowing of horse-race energy. Six moves did most of the work.

1 · Hero frontrunner

The top 40% of the page is the leader. Crown SVG, gold halo with a pulse animation, giant Space Grotesk name (96px+), and a runner-up "chasing" sub-card. The leader gets the spotlight before you scroll.

2 · Horizontal race track

Every agent gets a lane. Their token's horizontal position is return_pct relative to the leader. Sparkline runs as lane background. Checkered finish-line stripe on the right.

3 · Animated number tweens

Framer Motion animate() on a useMotionValue. Equity and return % count up smoothly on every poll. No discontinuous jumps. Built once in AnimatedNumber.tsx, reused everywhere.

4 · Layout animation on rank changes

Each lane is wrapped in <motion.div layout>. When the leaderboard reorders, lanes smoothly swap instead of snapping. The race-feeling comes from this detail.

5 · Color hierarchy

Gold / silver / bronze for top three (palette + glow), neon green / red for up/down. Mute mid-pack so the eye locks onto the front. Tokens are picked from tailwind.config.ts, never inline.

6 · Typography

Display: Space Grotesk (bold, tight). Numbers: JetBrains Mono with tabular-nums so digits don't shuffle width when counting. Body: Inter. All loaded via Google Fonts in app/globals.css.

SECTION 11

Six decisions, and why I made them

If someone asks "why didn't you use X?", these are your answers.

Daily, not intraday

No websockets, no streaming feed, no minute-bar storage. One yfinance-grade data source, one tick per day. Cuts 80% of the build complexity. The "fun in meetings" goal doesn't need second-by-second.

Engine before LLM

Build the engine + tests first, then plug in the LLM as an interchangeable DecisionProvider. Step 3 of the build order was non-negotiable. The LLM only sees a clean snapshot, can only return JSON. No engine surface area touches it.

SQLite, defer Postgres

For 10–50 users, SQLite is faster than Postgres (no network), atomic on disk, zero ops. Phase 2 swap is one env var change because SQLAlchemy abstracts it.

Direct Yahoo HTTP, not yfinance

The yfinance Python library was silently returning zero bars for Taiwan tickers while their query1.finance.yahoo.com endpoint worked fine. Replacing yfinance with ~80 lines of httpx made the data layer simpler and faster.

BYOK Claude, shared Gemini

Anthropic only sells per-key API. BYOK is the only way to avoid one user eating everyone else's quota. Gemini Flash has a generous free tier — one shared key is fine, less friction for non-engineers.

Next.js App Router

Server component shell + client components only where motion lives. Tailwind utilities, no CSS-in-JS overhead. The 賽馬 redesign is ~2000 lines of TSX, all type-safe.

SECTION 12

What it costs to run

Be ready when someone asks about the bill.

ItemPer call / per monthNote
Anthropic Claude Sonnet 4.6 call ~$0.008 / call ~5–10K input tokens of snapshot + portfolio + strategy, <1K output JSON
Google Gemini 3.1 Flash Lite call ~$0.0005 / call Comparable I/O. Effectively zero at this scale.
10 users · 1 call each · daily · 30 days 300 calls / month Claude ≈ $2.40 · Gemini ≈ $0.15
Yahoo Finance Chart API $0 Public endpoint. Bars cached forever in SQLite once fetched.
SQLite storage $0 Local file. ~5 MB for a year of 200-stock OHLCV.
Server (Phase 1) local laptop or $5/mo VM One Python process + Next.js. Trivial.

Bottom line: even at "10 users daily for a year", total bill is a coffee per month. The BYOK split moves Claude costs to each user's own account — the system itself only pays for Gemini calls.

SECTION 13

What I'd build next

Real auth + SSO

Right now identity is the owner_name string. Phase 2: drop Google Workspace SSO in front of the API. The owner_keys table already keys on owner — swap to a real user ID with no schema change.

Benchmark vs 0050.TW

Every agent's return is shown alone. Better: show return vs holding 0050.TW (Taiwan 50 ETF) over the same window. Then the comparison is "did your AI beat the index?", which is the question finance people actually want to ask.

Replay scrubber

The equity curve already exists. Add a horizontal scrubber to play back any day — tooltip shows that day's decision reasoning, holdings, and rejected orders. Turns the dashboard into a film reel.

Postgres swap

One env var change for the URL, then run alembic upgrade head. The seam is already there — SQLAlchemy abstracts the dialect.

SECTION 14 · THE ONE YOU CAME FOR

Talking points

Punchy sentences I can actually say. Each one is under 30 words. Paraphrase, don't quote word-for-word. You built this — talk about it like you built it.

Elevator pitch
It's an internal paper-trading PK between AI agents on Taiwan stocks. Each person writes a trading style in plain text, the system spawns a daily agent, a race-track leaderboard ranks everyone.
The stack
FastAPI + SQLite on the backend, Next.js + Tailwind + Framer Motion on the frontend. Yahoo Finance for the data. Claude and Gemini for the decisions.
The clever bit
The engine is the source of truth. The LLM only proposes orders — the engine validates them against the strategy's caps before they fill. So even if the model hallucinates, it can't break the books.
No look-ahead
A decision dated day Dk only reads data up to Dk's close. Its orders fill at Dk+1's open. There's a test that fails if any code path peeks at future prices — guards against the cardinal backtest sin.
BYOK
Each person connects their own Anthropic key — no shared server key. The key is Fernet-encrypted with a server secret before it touches the database. Only the last four characters come back out.
The race-track UI
Built it for the meeting room. Big frontrunner card up top with the leading agent's crown. A horizontal race track below — each agent's lane position is their return relative to the leader. Sparklines, animated counters, layout transitions on rank changes.
What surprised me
The yfinance Python library was silently broken for Taiwan tickers. Switched to direct Yahoo Chart API HTTP calls — actually cleaner. Lesson: when a "wrapper" library has a bad day, the raw endpoint often saves you.
Cost to run
Maybe two or three dollars a month at this scale. BYOK Claude pushes the Anthropic bill to each user's own account, Gemini is effectively free, Yahoo data is free, SQLite is local.
If asked "how long?"
One focused build session for the engine, tests, API, and a first-pass UI. Then a second session for BYOK and the race-track redesign. The hard part isn't the code — it's deciding what NOT to build.
If asked "what's next?"
A benchmark line against 0050.TW so the question becomes "did your agent beat the index?". Then real SSO and a Postgres swap to scale beyond the team.
How to add an agent
You build it in the UI — quiz, pick a persona, tweak the strategy fields, connect Claude or pick Gemini, hit submit. No files, no YAML. Five minutes start to finish.
Stocks tab
The Stocks page shows the same market snapshot the LLM sees. Sort by 5-day move, check the MA20 cross, eyeball the sparkline — turns vague "buy what goes up" into a specific instruction you can hand the model.
"Is it real-time?"
Two streams. Decisions use daily data — that's deliberate, it keeps the no-look-ahead test passing. The Stocks tab has a LIVE column from TWSE MIS, the same source brokers use — about 5 seconds during market hours. Way faster than GOOGLEFINANCE.
三大法人
Every agent sees daily 外資 / 投信 / 自營 net buy-sell flows as part of its market snapshot — same data brokers feed their pro clients. When 投信 starts buying a name persistently, the LLM picks it up the next day.
Skill vs luck
Leaderboard ranks by Return, Sharpe, or Max Drawdown. Same data, three answers. The momentum agent wins on raw return; the contrarian wins on Sharpe — and that's the actual definition of "good trader" most pros would use.
"Is the data fresh?"
Every page shows "資料截至 YYYY-MM-DD" so you can see at a glance. Auto-refresh runs at 14:00 TPE every weekday — TWSE closes 13:30, then I wait 30 min for Yahoo to finalize. Page mount also triggers a refresh if it spots stale data.
SECTION 15

How to use this doc

Before any conversation

Skim Section 14 (talking points). Sixty seconds. Pick the two or three lines most relevant to your audience.

If they ask "how does it work?"

Open the architecture diagram (Section 4) and the engine tick (Section 6). Walk them left-to-right.

If they ask "is it accurate?"

Section 7 — the no-look-ahead invariant. The test exists. Show them the snippet.

If they ask "what would you change?"

Section 13 — what's next. Don't oversell, just name two real next moves.