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.
Milestones, newest first
Every time we ship something meaningful, an entry lands here. Pre-existing sections below are updated in place when behaviour changes.
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 withexecute_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_SECRETguard 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.
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
zfield 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.
三大法人 flows · risk-adjusted leaderboard · sharper LLM prompt
- Added 三大法人 (institutional flows) — new
InstitutionalFlowmodel + fetcher from TWSE T86 (TSE) and TPEx 3insti (OTC). Daily net buy/sell in 張 per ticker. Plugged intobuild_snapshotso 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 returnorders: []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.
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.pywithexpected_latest_finalized()(Mon–Fri after 14:00 TPE → today; otherwise last weekday) andrefresh_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.
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.
Stocks tab · responsive hero · agent-creation clarification
- Added
/stocksuniverse 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
LeaderHeroand the agent profile. Title fonts dropped fromclamp(2.5rem,5vw,5rem)toclamp(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.
賽馬場 redesign · BYOK Claude · 194-ticker universe
- Removed shared
ANTHROPIC_API_KEYentirely. 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
yfinancelib with direct Yahoo Finance Chart API. Expanded seed universe from 137 to 194 unique TWSE names. - Engine bug fix:
advance_one_trading_daynow floors the first tick atstart_date − 1instead of the earliest cached price date.
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.
The pitch in one paragraph
If someone gives you 30 seconds, this is what you say.
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.
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).
The architecture in one diagram
Five layers, one direction of dependency. Nothing fancy.
Stack, layer by layer
Why each piece is here, in one or two lines.
| Layer | Choice | Why 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. |
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.
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.
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.
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.
"是怎麼建立 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.
Answer: "是的,全程在 UI — quiz、選 persona、調策略、連 Claude、推上場。不用碰任何檔案。" — ready-to-say
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.
三大法人 (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.
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:
| Agent | Return | Max DD | Sharpe | Wins 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.
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
| Source | Delay | Free? | 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.
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.
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.
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.
What it costs to run
Be ready when someone asks about the bill.
| Item | Per call / per month | Note |
|---|---|---|
| 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.
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.
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.
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.