建構筆記 · 中文版 · 賽馬場

AI Trading PK
我是怎麼蓋起來的

這份文件是寫給未來的自己。哪天有同事問「這個怎麼做的?技術棧長怎樣?」 時,可以打開最後一節「講解話術」掃 60 秒,每句話都是現成可以講的。 全文都是我(用 Claude Code)真的蓋出來的東西,不是 AI 隨便講講。

FastAPI · Python 3.11 Next.js 14 · TS strict SQLite · SQLAlchemy Yahoo Finance Chart API · TWSE MIS Claude (BYOK) · Gemini Tailwind · Framer Motion
CHANGELOG · 滾動更新中

里程碑(最新在上)

每出一個有意義的版本就在這留一筆。下面各章節若有變更,會就地更新。

M8 2026-06-10

正式上線 · 真實盤比賽 · live-edge 引擎修復

  • 全套上雲:後端 Cloud Run(SQLite 放 GCS volume、鎖單一 instance = 單一寫入者)、 前端 Cloudflare Workers(OpenNext,順手把 Next 14 升到 15 + React 19)、 Cloud Scheduler 每個交易日 14:15 收盤後自動 tick(18:00 第二發當保險 — endpoint 冪等,重打無害)。 盤中個股頁照舊走 TWSE MIS 近即時報價。
  • 最關鍵的引擎修復:歷史回放時「明天的 K 棒」永遠在快取裡,但真實前進時明天根本還沒發生 — 原本引擎遇到「下一個交易日沒資料」就跳過決策,而每一天又是冪等、不會重決 → 真實盤比賽會永遠 MTM、永遠不下單。 改成:用估計的 execute_date(Dk+1 日曆日)掛單,成交端改用 execute_date <= 當天 撈單並回寫實際成交日 — 週五掛的單自然在週一開盤成交,no-look-ahead 不變。回放模式測不到這隻 bug:「回放會騙人」的活教材。
  • Live PK 真實盤:四個 persona(動能 / 技術線仙 / 少年股神 / 逆勢抄底)每個交易日收盤後 真的呼叫 Gemini 做決策、隔日開盤成交,在真實資料上往前跑。LLM 的 reasoning 與每筆單的 rationale 全面改為繁體中文輸出。
  • 新功能四連發:0050 買進持有「幽靈跑道」直接插進賽道排名(一眼看出誰真的贏過大盤); 每日戰報卡(成交 / 領先 / 最大波動三句話,模板生成、零 LLM 成本); 策略線上編輯 + 版本歷史逐欄 diff; 管理操作加上 ADMIN_SECRET 防護(網址可以公開分享了)。
  • 後端測試 99 → 110 全綠。即時報價維持 display-only — 引擎仍然每日結算,餵盤中價給決策會破壞 no-look-ahead。
M7 2026-06-01

LIVE 強化 · 新聞整合 · 中文版本文件

  • LIVE 欄位補強:原本只有 ~30% 的股票顯示即時價(因為 TWSE MIS 的 z 最新成交價在沒新撮合時是空的)。改成優先用 z,沒有時退回最佳買賣中價 (mid), 最後 fallback 到今日開盤價 (open)。現在交易時段幾乎 100% 覆蓋。畫面會用顏色 + 小標籤標示資料來源。
  • 新聞整合:每檔個股新增「新聞」欄位,點開跳出 modal 顯示最近 10 則 Google News 中文新聞, 附發布時間 + 媒體名稱 + 原文連結。Server side 快取 4 小時,10 個人同時看 = 4 小時只打 1 次 RSS。
  • 中文版建構筆記:現在你正在看的這份。和英文版同步維護。
M6 2026-06-01

三大法人 · 風險調整排名 · 強化 LLM prompt

  • 引入 三大法人:TWSE T86 + TPEx 3insti 抓取每日 外資 / 投信 / 自營 淨買賣, 存進 InstitutionalFlow 表,並寫進 LLM 的 snapshot 裡。 個股頁加上四個欄位。
  • 實證有效:2454 聯發科 5/29 顯示 外資 -2,325 張 / 投信 -1,021 張 / 自營 -130 張 = 三大法人 -3,476 張, 完美對應到當天 -2.27% 的跌幅。訊號是真的有用。
  • 新增 Sharpe / 年化波動 / 最大回撤:每個 agent 用 EquitySnapshot 算指標。 排行榜可切換排序:報酬率 / Sharpe / 最大回撤。Hero 加上三個 metric pills。
  • 實證:航海王 報酬率 +8.75% 但最大回撤 7.5%;價值老司機 報酬率 +4.58% 但最大回撤只有 2.25%。 按 Sharpe 排,價值老司機贏。這才是真正定義「會交易」的方式。
  • 強化 app/llm/prompt.py:明確的 5 步思考流程(state → research → decide → size → emit), 欄位 schema 寫清楚(包含 三大法人),提醒 LLM 訂單會在下一個交易日 open 成交。
  • 10 個新測試(5 個 institutional parser、5 個 metrics math)。總計 57 個後端測試全綠。
M5 2026-05-29

資料新鮮度 — 收盤後自動更新 + 顯眼的「資料截至」

  • Jazz 抓包:聯發科顯示 4410(昨日收盤)而不是 4310(今日收盤)。 因為 seed 只到 5/28,13:30 收盤後沒人主動 pull。
  • 新增 app/services/refresh.pyexpected_latest_finalized() 知道 「週一到週五 14:00 TPE 之後 → 今日;其他時候 → 上個交易日」。 refresh_universe() 只 pull 缺的日期區間。
  • 新 endpoint POST /competitions/{id}/refresh-data。Stocks endpoint 改回傳 {as_of, expected_latest, is_stale, rows} 讓前端判斷要不要更新。
  • APScheduler 加排程:週一到週五 14:00 TPE 自動更新所有比賽的 universe。引擎決策還是維持 18:00。
  • 個股頁顯眼的「資料截至 YYYY-MM-DD」badge。資料過舊時自動背景更新。手動「立即更新」按鈕隨時可用。
M4 2026-05-29

近即時 TWSE 報價 — TWSE MIS · 約 5 秒延遲

  • 接入 TWSE MIS(mis.twse.com.tw),這是台灣券商「即時報價」用的同一個 source。 交易時段(週一到週五 9:00-13:30 TPE)約 5 秒一次更新。
  • 新 endpoint GET /competitions/{id}/stocks/realtime。Server 端 5 秒快取, 10 個人開頁面也只打一次 TWSE。
  • 個股頁加上 LIVE 欄位 + 日內 %,每 30 秒自動更新,非交易時段顯示「MARKET CLOSED」。
  • 架構紅線:LLM 決策永遠只用日線資料。即時資料是 display-layer overlay 而已 — no-look-ahead invariant 不會破。
M3 2026-05-29

個股看盤頁 · Hero 字級修復 · Agent 建立流程說明

  • 新增 /stocks 個股 universe 瀏覽頁 — 可排序的 194 檔 TWSE 一覽表,欄位和 LLM 看到的 snapshot 一致。點任一檔看 90 日歷史走勢。
  • 修掉 Hero 文字重疊:標題字級 clamp(2.5rem,5vw,5rem) 改成 clamp(1.75rem,3.6vw,3.25rem), stats 在窄螢幕改成垂直堆疊。
  • 新增「agent 不是 .md 檔,是從 UI 建立的 DB row」說明(第 8b 節)。
M2 2026-05-29

賽馬場視覺改版 · BYOK Claude · 194 檔 universe

  • 拿掉 server 端共用的 ANTHROPIC_API_KEY。改成 BYOK Claude,每位 owner 自己連結,金鑰 Fernet 加密儲存。
  • UI 全面改版:Hero frontrunner card、橫向 race track、Framer Motion 動畫數字、排名變動有過渡。
  • 把壞掉的 yfinance Python lib 換成直接打 Yahoo Finance Chart API。Seed universe 從 137 檔擴充到 194 檔。
  • 引擎 bug fix:advance_one_trading_day 第一天起算改成 start_date − 1,不會再從歷史回溯資料的最早日子開始 tick。
M1 2026-05-29

初版 · 引擎 + LLM clients + 第一版 UI

  • SQLite 11 張表。引擎 tick 有 idempotency + no-look-ahead invariant。
  • FastAPI routers + Pydantic v2 strict。每日 tick:本機開發用 APScheduler;正式環境改由 Cloud Scheduler 打冪等的 cron endpoint(14:15 + 18:00 雙保險)。
  • 6 個 personas + 4 題小測驗,策略 version-lock + 每週修改上限。
  • Next.js + Tailwind 第一版排行榜、agent detail、新增 agent wizard、admin 後台。
  • 38 個測試,包含 no-lookahead probe。
第 1 節

30 秒講完版本

有人只給你 30 秒,你就講這段。

這是我用 Claude Code 蓋的內部小工具 — 一個 AI agent 之間用台股做模擬交易 PK 的平台。 每個人用一段自然語言描述自己的操作風格,系統就生出一個每日跑的 agent, 最後一個「賽馬場」式的排行榜按報酬率排名。 最關鍵的設計是:LLM 只「提議」訂單,引擎才驗證和入帳 — 所以即使模型亂講,也不可能搞壞帳本。 — 一句話版本 v1
第 2 節

為什麼有這個東西

需求

內部團隊小工具,第一階段 10 人、設計上撐到 50 人。 是會議室裡放著看的小玩具,讓沒寫過程式的同事用自然語言設定 agent, 然後每週看排名變化。

明確不做的

真錢、即時資料、盤中串流、統計顯著性那種研究級工具。 也沒有真的 SSO(用 owner_name 當身分就夠了)。 日線解析度已足夠。

第 3 節

五個你該知道的觀念

把這五個搞懂,幾乎所有同事問的問題你都答得出來。

Paper trading(紙上模擬交易)

用真實價格、虛擬資金。系統紀錄你「如果真的這樣交易,會賺多少」。 不涉及券商,不下真的單。

日線 OHLCV

四個價格摘要一檔股票的一個交易日:Open(開)、 High(高)、Low(低)、 Close(收)加上 Volume(量)。 這整個系統的最小單位。

Look-ahead bias(前視偏誤)

不小心用了「未來的資料」做「過去的決策」。 回測世界最大的原罪:策略看起來超強,一上線就崩。 我這個 codebase 有個測試專門守這條 invariant, 只要程式碼偷看未來,測試就會掛。

Engine as source of truth

來自真實交易系統的 pattern:策略/模型「提議」訂單; 引擎驗證、套入成本、修改投組。兩層架構各司其職, 模型怎麼亂講都動不了規則底線。

BYOK(Bring Your Own Key)

不用 server 統一付一把 LLM API key,每個人連結自己的。 用 Fernet(對稱式 AES + HMAC)加密儲存。 用多少自己付,不用爭 quota。

未調整 vs 調整後價格

「調整後收盤」會把過去的股利和分割隱性地融入價格 — 適合畫線圖、但用來算 P&L 會錯。 這個系統用未調整價格,公司行動(除權息、分割)會明確套用: 除息 → 加現金;分割 → 股數 × 比例、均價 ÷ 比例。

第 4 節

一張圖看完架構

五層,依賴方向永遠由上往下。沒什麼花俏的。

瀏覽器 · Next.js 14(App Router) 賽馬場排行榜 · 動畫計數 · framer-motion · port 3700 JSON / fetch / /api/* rewrite FastAPI · Python 3.11 · Pydantic v2 routers · cron tick 14:15/18:00 Asia/Taipei · port 3701 引擎(唯一帳本權威) advance_one_trading_day execution · corp_actions · mtm · decisions no-look-ahead invariant 在這把守 LLM clients Claude · BYOK per owner_name Gemini · 共用 server key strict JSON · 防禦式 parser · rate-limit SQLite · SQLAlchemy 12 張表 · PriceBar 快取 · owner_keys (Fernet) 第二階段:改 DATABASE_URL 就升 Postgres 外部資料來源 Yahoo Chart · TWSE MIS · TWSE T86 · 投信 Google News RSS(中文)· 通通免費 194 檔 TWSE tickers · 每場比賽建立時 snapshot 凍結
第 5 節

技術棧逐層說明

每樣東西為什麼選它,一兩句講完。

選擇為什麼是它,不是別的
後端框架FastAPI 0.115 原生 async、Pydantic v2、自動 OpenAPI。比 Django 蓋純 API 更快。
Schema 驗證Pydantic v2 strict Strict mode 不會自動把 "1" 轉成 1。bugs 在邊界就被擋住,不會跑到引擎裡才出包。
ORMSQLAlchemy 2.0 Typed Mapped[...] 跟 mypy / Pylance 合得來。查詢時 ORM 不會擋路。
資料庫SQLite(檔案) 零維運、原子寫入。≤50 人完全不會撞牆。第二階段換 Postgres 只是改一個 env var。
排程APScheduler 3.10 Process 內 cron。不需要 Redis 或 Celery 那一套。目前就一個每日 tick + 一個午後 refresh。
市場資料Yahoo Chart API(直接 HTTP) yfinance Python lib 對 TWSE tickers 莫名其妙回傳空的。直接打 endpoint 反而穩。
即時報價TWSE MIS 台灣券商「即時報價」用的同一個。約 5 秒延遲、免費、不用註冊。
三大法人TWSE T86 + TPEx 3insti 官方每日法人買賣超公開資料。免費、每天收盤後 30 分內更新。
新聞Google News RSS(zh-TW) 免費、不用 API key、可以針對 ticker + 名稱查詢、中文支援好。
LLM SDKsanthropic, google-genai 兩家都有現役 Python SDK。包成一個 DecisionProvider Protocol,引擎不在乎是哪家。
加密cryptography.Fernet 認證式對稱加密,「at-rest secret」的標準解。Master key 從 SERVER_SECRET 經 SHA-256 衍生。
前端框架Next.js 14 App Router File-based routing、RSC 處理靜態殼、client components 只用在需要的地方。TS strict、不用 any
樣式Tailwind 3 Design tokens 寫在 tailwind.config.ts,utility classes 寫在組件裡。比 CSS-in-JS 快上鏡。
動畫Framer Motion 11 賽馬道排名變動的 layout animation、AnimatedNumber 平滑計數。
測試pytest + TestClient 62 個後端測試。最重要的是 test_engine_no_lookahead — 守住核心 invariant。
第 6 節 · 心臟

引擎 tick

backend/app/engine/tick.py 裡面只有一個函數 advance_one_trading_day, 每天跑同樣的 6 個步驟,順序不變。Idempotent。沒有特例。

六步驟在做什麼

  1. 定 Dk — universe 裡下一個有資料的交易日
  2. 填昨日掛單 — 在 Dk 的 OPEN 成交,重新驗證每個 guardrail
  3. 套用公司行動 — 除息 → 加現金;分割 → 股數 × 因子
  4. 收盤 MTM — Dk 收盤價 × 持股 = 市值;寫 EquitySnapshot
  5. 跑決策 — 該決策的 agent 呼叫 LLM,產生明日掛單
  6. 更新指標current_trading_date = Dk

為什麼是這個順序

每一步只負責一件事。「填單」要在「決策」之前,因為新決策需要看到填完之後的投組狀態。 公司行動排在填單跟 MTM 之間,因為分割會改變股數,要先處理。 決策放最後,它讀所有其他東西的結果 — 然後產生的單是給「明天」用,不是今天。

第 7 節 · 核心 INVARIANT

不能偷看未來,否則一切都是假的

業餘回測最常見的單一錯誤:不小心讓「過去的決策」用到了「未來的資料」。 這個系統有個測試,只要踩到這條線就會炸給你看。

規則

Dk 那天做的決策,只能讀 ≤ Dk 收盤的資料。 它產生的訂單會在 Dk+1 的 OPEN 成交 — 絕對不會用 Dk 的收盤填單。

理由:真實市場裡,你在 13:30 收盤前不會知道收盤價。 你只能在明天早上開盤時動手。

怎麼守

build_decision_context 裡每個查詢都加 date <= decision_date。 填單函數明確讀 Dk 的 PriceBar.open,不是 .close

測試套件裡有一個 probe provider,記錄它「看到」的每個日期。 只要有任何日期 > Dk,測試就 fail。

# backend/tests/test_engine_no_lookahead.py — 守在這裡

def test_decision_never_reads_future_data(db):
    # 第 1-3 天股價 600..602;第 4 天跳到 999(這是「未來」)
    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)

    # 每次決策看到的日期,都不能 > 自己的 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}"

每次重構引擎都會跑這個測試。哪天有人「優化」程式碼結果偷讀了明天的 open, 測試就斷給你看。bug 進不了生產環境。

第 8 節

策略 schema:兩半

同一個 shape,一半是程式硬性守的、一半是自然語言。Personas 就是這個 shape 的預設值。

structured(引擎強制執行)

{
  "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
}

這些是硬上限。LLM 提議的每個訂單,引擎都會驗證一次。 超過任何一條都會被 rejected 並記錄理由 — 看 agent 的訂單紀錄就看得到。

free_text(LLM 讀)

"鎖定 5 日與 20 日動能最強、且
above_ma20 的股票。持倉跌破 -8%
出場,獲利 +20% 起逐步停利。
動能消退立刻換股。"

這段是 LLM 每次決策時讀的,是 agent 的「個性」。 引擎不解析這段,只把它和市場 snapshot 包進 prompt 裡。

為什麼要兩半:純 structured 太死板,沒個性;純 free_text 容易亂下單浪費手續費。 切兩半同時拿到「自然語言設定」的好處跟「程式守底線」的安全。

第 8b 節 · 常見誤會

「agent 怎麼建立?」

同事常常以為 agent 是 markdown 設定檔。不是。 這節把心理模型講清楚,下次有人問你就能秒答。

這個系統的 agent 是什麼

agents 表裡的一筆 row。欄位: id, competition_id, owner_name, name, model_provider, persona_id, decision_cadence_days + 連結的 StrategyVersion (存 structured + free_text 策略)。

從 UI(/new-agent)或 API(POST /competitions/{id}/agents)建立。 全程不用碰任何檔案。

它「不」是什麼

不是某個資料夾裡的 markdown 檔,也不是要 commit 的 YAML 設定。 混淆來自 Claude Code 的 subagent — 那個確實是 .claude/agents/ 底下的 md 檔。 完全不一樣的東西。

也跟 LangChain 或 AutoGen 的 agent 定義不一樣。 這個系統的 agent 比較像「交易者 profile」+「LLM client 連結」。

同事問:「agent 是可以從平台生成嗎?」
回答:「是的,全程在 UI — quiz、選 persona、調策略、連 Claude、推上場。不用碰任何檔案。」 — 現成可講
第 8c 節 · 決策輔助

個股 / Stocks tab

排行榜告訴你「誰在贏」,個股頁告訴你「該叫你的 agent 看哪些股票」。 讓使用者在調策略之前可以先逛 universe。

看得到什麼

整個比賽 universe(~194 檔 TWSE)的可排序表格。 欄位:代號、名稱、收盤、LIVE、日內 %、1d/5d/20d 報酬、MA20 cross、量比、 外資/投信/自營/三大法人 淨買賣(張)、新聞、30 日 sparkline。

任何欄位可排序、可搜尋(ticker 或公司名)。 點 row → 90 日歷史 K 線 modal。點「新聞」按鈕 → 最新中文新聞 modal。

關鍵設計

這個頁面顯示的就是 LLM 決策時看到的同一份 snapshot。 用同一個 build_snapshot 函數、同一份數字。 使用者瀏覽看到的就是 agent 會看到的, 不會出現「看的人跟做的人各說各話」這種事。

對 PK 來說為什麼重要:沒寫過程式的同事寫 free_text 策略時, 很容易寫「買漲的股票」這種模糊指令。給他看到 LLM 看到的實際 snapshot, 他就會學會寫「pct_5d > 10%volume_ratio > 1.5 才買」這種具體可執行的指令。

第 8e 節 · 台股特化訊號

三大法人

每個台股 trader 都會先看三大法人,才下單。如果我們的 agent 看不到,等於對台股市場最大的推力視而不見。

外資

外資及陸資 — 台股最大的單一推力。 含外資自營商。 外資對 2330 一張買 5000 張的衝擊力,比任何散戶都大。

投信

共同基金與投信公司。流量比較小,但持續 — 投信一旦開始買某檔,常常一連買好幾天。 抓「投信開始進場的第一天」是經典台股訊號。

自營商

券商自營部。流量更小,分自行買賣(directional)跟避險(hedge)。 方向性訊號比較弱,但能補齊整體面貌。

LLM 怎麼看到:snapshot 的每個 row 現在都帶著 foreign_net_lotstrust_net_lotsdealer_net_lotstotal_inst_net_lots,跟價量欄位並排。單位是「張」(1 張 = 1000 股)。 正數 = 淨買、負數 = 淨賣。系統 prompt 也明確跟 LLM 講該怎麼看 — 「三大法人買超 + 股價上漲 = 強訊號;賣超 + 股價反彈 = 警訊; 投信持續買進 = 多日行情的開始」

# 實證:2454 聯發科 2026-05-29

ticker: 2454.TW
last_close: 4310.0          # 前一天 4410
pct_1d: -2.27%              # 跌
foreign_net_lots: -2325     # 外資倒貨 2,325 張
trust_net_lots: -1021       # 投信賣 1,021 張
dealer_net_lots: -130       # 自營小賣
total_inst_net_lots: -3476  # 三大法人合計賣 3,476 張

資料來源:https://www.twse.com.tw/rwd/zh/fund/T86(上市)跟 https://www.tpex.org.tw/web/stock/3insti/daily_trade/3itrade_hedge_result.php(上櫃)。 兩個都免費、都能用,每天收盤後 30 分內更新。一次 HTTP 拿回整個市場 — 所以更新 194 檔 universe 的三大法人,一天只要 2 個 request。

第 8f 節 · 用實力定勝負

風險調整後的排行榜

三個指標把排行榜從「誰運氣好」變成「誰真的會做交易」。可以切換排序:報酬率 / Sharpe / 最大回撤。

Sharpe Ratio

(每日平均報酬 / 每日報酬標準差) × √252。 越高 = 用更少的波動賺到同樣多的錢。 Sharpe 2 意思是「我承擔的波動,有 2 倍報酬回來」。 真實基金界 >1 算好、>2 算很好、>3 可能是運氣或樣本太短。

沒做無風險利率調整 — 這只是內部 PK,不是研究級工具。

最大回撤 Max Drawdown

淨值曲線上「最高點到之後最低點」的最大跌幅。 越小 = 風險控制越好。 賺 20% 又跌到 5%,最大回撤約 12.5%。

排序時取負值,這樣所有排序模式都是「分數越高排越前面」。

為什麼重要:看看我們自己 demo 切換排序時發生什麼事:

Agent報酬率最大回撤Sharpe用什麼贏
航海王 · Momentum +8.75% -7.50% 3.88 報酬率
價值老司機 · Contrarian +4.58% -2.25% 4.14 Sharpe 最大回撤

Momentum 的報酬將近兩倍,但 Contrarian 用三分之一的回撤拿到一半的報酬 — 所以以風險調整後的角度,Contrarian 才是贏家。 這種比較讓排行榜從「吃角子老虎機」變成「面對懂金融的同事也站得住腳」的工具。

實作在 backend/app/services/metrics.py。用 252 個交易日年化。 資料不足(< 2 個 snapshot 或波動為零)時回傳 None,前端顯示「—」而不是亂湊。

第 8d 節 · 資料分層

「即時」到底有多即時

兩條資料流,分得清清楚楚。這個邊界僅次於 engine-as-source-of-truth,是整個架構的第二根脊椎。

日線(決策用)

從 Yahoo Finance Chart API 拉的 OHLCV,bar 確定之後永久快取。 這是 LLM 看到的。 Dk 那天的決策只能讀 ≤ Dk 收盤的資料 — no-look-ahead 測試守住,盤中資料無法滲入。

檔案:backend/app/data/prices.py

即時(只給人看)

TWSE MIS — 台灣券商「即時報價」用的同一個 source。 週一到週五 9:00-13:30 TPE 約 5 秒一次更新。 餵給個股頁的 LIVE 欄位。引擎絕對不會讀這個

檔案:backend/app/data/realtime.py

來源延遲免費?用在哪
TWSE MIS交易時段約 5 秒 個股頁 LIVE 欄位。優先 z (match) → midpoint (b/a 中價) → o (今日開盤)。
Yahoo Finance Chart API收盤後日線 引擎跟決策 snapshot 用的日線快取。
TWSE T86 + TPEx 3insti收盤後 30 分 三大法人每日淨買賣。
Google News RSS幾分鐘到幾小時 每檔個股最新中文新聞。Server 快取 4 小時。
GOOGLEFINANCE15-20 分 沒用 — TWSE MIS 更快。
券商 API(元大 / 富邦)tick-by-tick否(要開戶) 小工具不用。要做真實 fill 才需要。

一句話講邊界:即時資料只是 UI 上的塗料。 任何會改投組的東西(訂單、填單、MTM snapshot)都只讀日線資料 — 引擎保持決定性、no-look-ahead 測試依然能跑。

第 9 節

BYOK Claude:使用者金鑰怎麼運作

沒有共用的 Anthropic key。每個人連自己的。

為什麼 BYOK

市面上沒有第三方 app 能透過 Claude.ai 訂閱呼叫 Claude API 的公開 OAuth — Claude Code 是特例。 現實選項只有兩個:共用 API key(一個用量大戶吃光大家的 rate limit) 或 BYOK(每個人自己付)。BYOK 贏。

怎麼儲存

使用者在「連結 Claude 帳號」面板貼一次金鑰。 Server 用 SERVER_SECRET SHA-256 衍生出 Fernet key, 加密之後才寫進 owner_keys.encrypted_key(二進位)。 只有 last4label 會回到前端顯示。

# backend/app/services/crypto.py — 加密邊界

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")

使用者解除連結時 row 就刪。如果存的金鑰失效,LLM 呼叫會優雅失敗 — 決策記錄為「claude not connected」並回傳 0 個訂單。引擎絕不會在 tick 中崩潰。

第 10 節

賽馬場視覺改版的六個動作

第一版乾淨的 dark theme 表格 — 可讀但無聊。改版有意識地借用了「賽馬」的能量,靠這六個動作就完成大半。

1 · Hero frontrunner

頁面上 40% 完全給冠軍。皇冠 SVG、金色光暈帶 pulse 動畫、 巨大的 Space Grotesk 名字(96px+)、亞軍 "chasing" 副卡。 還沒往下滑就看見焦點。

2 · 橫向賽馬道

每個 agent 一條 lane。token 的橫向位置 = 該 agent 報酬率 vs 領先者。 lane 背景是 sparkline。最右邊是格仔旗終點線。

3 · 動畫計數

Framer Motion 的 animate() 套在 useMotionValue 上。 每次 poll 數字會平滑跳到新值,不會 jump cut。 在 AnimatedNumber.tsx 寫一次到處用。

4 · 排名變動的 layout 動畫

每個 lane 包在 <motion.div layout>。 排行榜重新排序時 lane 會平滑換位,不會啪一下跳。 整個「賽馬感」靠的就是這個細節。

5 · 顏色階層

前三名金 / 銀 / 銅(配色 + glow),漲跌綠 / 紅,中間排淡化。 眼睛自動鎖定到前段。色碼從 tailwind.config.ts 拿,不直接寫 inline。

6 · 字型

Display:Space Grotesk(粗、緊)。 數字:JetBrains Mono + tabular-nums — 計數時數字寬度不會跳動。 內文:Inter。中文用 Noto Sans TC。 都從 Google Fonts 在 app/globals.css 載。

第 14 節 · 你來看這份文件就是為了這個

講解話術

可以直接講出來的句子。每句都在 30 個字內。要 paraphrase,不要逐字念。 這是你蓋的東西 — 講起來要像你蓋的。

電梯簡介
內部小工具,AI agent 之間用台股做模擬交易 PK。 每個人寫一段自然語言設定操作風格,系統生出每日跑的 agent, 賽馬場風格的排行榜按報酬率排名。
技術棧
後端 FastAPI + SQLite,前端 Next.js + Tailwind + Framer Motion。 Yahoo Finance 拉日線、TWSE MIS 拉即時。決策接 Claude 跟 Gemini。
最關鍵的設計
引擎才是帳本權威。LLM 只能「提議」訂單,引擎才驗證跟入帳。 模型亂講都動不了規則底線。
不能偷看未來
Dk 那天的決策只能讀 ≤ Dk 收盤的資料。訂單會在 Dk+1 的 open 才成交。 有測試守在那邊,碰到未來資料就斷給你看 — 守的就是回測的原罪。
BYOK
每個人連自己的 Anthropic 金鑰,沒有 server 共用 key。 用 Fernet + server secret 加密,回傳只看得到後四碼。
賽馬場 UI
為會議室設計的。最上面 hero 給冠軍配皇冠跟金色光暈。 下面橫向跑道,每個 agent 的位置 = 報酬率 vs 領先者。 sparkline、動畫計數、排名變動的 layout 動畫。
三大法人
每個 agent 每天 snapshot 都看得到 外資 / 投信 / 自營 淨買賣 — 跟券商給 pro 客戶的同一份資料。投信開始持續買某檔,LLM 隔天就會注意。
實力 vs 運氣
排行榜可以按報酬率、Sharpe、最大回撤排。 同樣資料、三個答案。動能 agent 報酬率冠軍;保守 agent Sharpe 冠軍 — 後者才是金融界真正定義的「會做交易」。
即時嗎?
兩條流。決策用日線資料是刻意的,要守 no-look-ahead 測試。 個股頁有 LIVE 欄位用 TWSE MIS — 跟券商 app 同一個 source、約 5 秒延遲。 比 GOOGLEFINANCE 快很多。
資料新鮮度
每個頁面都顯示「資料截至 YYYY-MM-DD」。 週一到週五 14:00 TPE 自動 refresh — TWSE 13:30 收盤之後, 等 30 分鐘讓 Yahoo 結算。個股頁讀到舊資料時也會自動背景更新。
花了多久
一次專注 session 蓋出引擎、測試、API 跟第一版 UI。 然後另一次蓋 BYOK + 賽馬場改版。難的不是程式碼, 難的是判斷「哪些東西不要做」。
下一步
加 0050.TW 當基準線,這樣比賽問題會變成「你的 agent 有沒有打贏大盤?」。 然後接真實 SSO 跟 Postgres,讓規模能撐到團隊以外。
agent 怎麼建立?
全程在 UI — quiz、選 persona、調策略、連 Claude(或 Gemini),按推上場。 不用碰任何檔案、不用寫 YAML、五分鐘搞定。
新聞
個股頁每檔都有「新聞」按鈕,點開看 Google News 中文最新 10 則。 Server 端快取 4 小時,所以同事一起看也不會被擋。 下一版會把新聞餵進 LLM 的 snapshot 裡。
第 15 節

怎麼用這份文件

每次對話之前

掃一下第 14 節「講解話術」。60 秒。挑兩三句最切題的。

有人問「這怎麼運作?」

打開第 4 節(架構圖)+ 第 6 節(引擎 tick)。從左到右講過去。

有人問「準不準?」

第 7 節 — no-look-ahead 不變式。測試在那裡,把那段程式碼秀給他看。

有人問「下一步呢?」

第 13 節 — 下一步要做什麼。別吹太大,就講兩個真的可以做的方向。