Problem Statement
사용자가 PR3로 LLM 트레이드 흐름을 사용하기 시작했지만 주변 UI가 미완성이라 일상적인 운영이 어려움:
- 포트폴리오가
paper/real/all 모드 토글을 보여주지만 PR1에서 paper 모드를 폐기했고 실제로는 ExchangeKey의 network(testnet/mainnet)로 갈라야 함. 현재는 testnet/mainnet 손익이 한 화면에 섞여 보임.
- 모든 가격이 USD 단독 표시라 한국 사용자가 즉각적인 가치 판단이 어려움.
useBaseCurrency / useExchangeRate 훅과 formatKrw 유틸은 이미 있는데 일부 페이지에서만 활용.
- 활동 피드에서 주문 항목 클릭하면 404 — Order activity item에
link: '/orders'가 박혀 있는데 PR1에서 /orders 라우트 자체를 삭제함.
- 대시보드는 "Check back soon" placeholder — 무엇을 보여줘야 하는지 미정의.
Solution
네 가지 개선:
- 포트폴리오 네트워크 분리 — testnet / mainnet / all 토글 (
mode 파라미터 제거하고 network로 대체). 백엔드는 Order 행을 exchangeKey.network로 필터.
- USD/KRW 통화 토글 글로벌 노출 — nav-bar에
useBaseCurrency 토글 추가. 모든 가격 표시 컴포넌트가 토글에 반응. localStorage 영속.
/orders/[id] 주문 상세 페이지 신설 — 캔들 차트 + entry/TP/SL 마커, 현재 mark price 기준 미실현 PnL, 수동 "Close Position" 버튼(실거래 모드 한정), 연결된 LlmDecisionLog의 reasoning.
- 대시보드 재구축 — 세 섹션:
- PnL 카드 (오늘 / 이번주, testnet vs mainnet 분리)
- 활성 포지션 테이블 + 인라인 "수동 닫기" 버튼
- 최근 5개 LLM 결정 카드 (signal, TP/SL, reasoning, 결과)
User Stories
- As a 한국 트레이더, I want to see USD prices alongside KRW equivalents so that I can evaluate values in my local currency at a glance.
- As a 트레이더, I want a single toggle in the nav-bar to switch between USD-primary and KRW-primary so that my preference applies everywhere.
- As a 트레이더, I want my currency toggle preference to persist across reloads so that I don't have to set it every session.
- As a 트레이더, I want to filter portfolio by testnet only / mainnet only / both so that I see real-money status separately from sandbox practice.
- As a 트레이더, I want testnet PnL labelled clearly as "모의" so that I never confuse it with real gains/losses.
- As a 트레이더, I want to click an order in the activity feed and land on its detail page (not a 404) so that navigation is reliable.
- As a 트레이더 on the order detail page, I want to see the entry price, TP and SL marked on the candle chart so that I understand the trade visually.
- As a 트레이더 on the order detail page, I want to see current unrealized PnL refreshed every few seconds so that I can monitor live status.
- As a 트레이더 with an open mainnet position, I want a "Close Position" button on the order detail page so that I can react to news mid-trade without going to Binance.
- As a 트레이더 on the order detail page, I want to see the LLM reasoning that opened this trade so that I can evaluate model quality post-hoc.
- As a 트레이더, I want the dashboard to show today's realized PnL split by testnet vs mainnet so that I know my position at a glance.
- As a 트레이더, I want the dashboard to show this week's cumulative PnL so that I have weekly context.
- As a 트레이더, I want the dashboard to list my open positions with one-click close access so that emergency exits are fast.
- As a 트레이더, I want the dashboard to show my last 5 LLM decisions with their reasoning so that I see the model's recent thinking.
- As a 트레이더, I want each LLM decision card to show the trade outcome (filled / stopped out / take-profit hit) so that I can correlate signal quality with results.
- As a 사용자 in
/settings, I want to register both a testnet and mainnet API key for the same exchange (binance) so that I can practice and trade live with one account.
- As a 트레이더, I want USD/KRW conversions to use a recently-cached exchange rate so that values aren't stale.
Implementation Decisions
Backend
PortfolioService.getSummary(userId, network?: 'testnet' | 'mainnet' | 'all') — replace mode: paper/real/all. Filter Order rows by joined exchangeKey.network. "all" returns both networks, plus a byNetwork breakdown so the UI can show split totals from one call.
ActivityService — order activity item links from /orders → /orders/${order.id}.
- New
GET /orders/:id — returns: Order row, related LlmDecisionLog (via FK), live mark price (from exchange), computed unrealized PnL, exchangeKey network.
- New
POST /orders/:id/close — manual close. Validates order is real-mode and still open. Publishes a Kafka close event consumed by the worker saga, which calls BinanceRest.closePosition() (MARKET reduceOnly — already known to work post-PR3 fix).
- New
GET /llm-trades/decisions?limit=N — paginated user-scoped list, newest first, joined with Order for outcome status.
- New
GET /dashboard/summary — single aggregate endpoint: { pnl: { today: {testnet, mainnet}, week: {testnet, mainnet} }, openPositions: Order[], recentDecisions: LlmDecisionLog[] }. Avoids three round-trips on dashboard load.
Frontend
/orders/[id]/page.tsx — new page. Sections: header (symbol + side + status badge), OrderChart component (CandleChart wrapper with TP/SL overlay markers), PnL panel, Close Position button (gated: real mode + open status), reasoning card.
OrderChart component — extends existing CandleChart with horizontal lines at entry/TP/SL and small text labels.
/dashboard/page.tsx — full rewrite. Calls GET /dashboard/summary once. Three sections rendered in CSS grid (responsive).
/portfolio/page.tsx — replace mode: paper/real/all toggle with network: testnet/mainnet/all toggle. Show split totals when "all" selected.
<BaseCurrencyToggle> in nav-bar.tsx — small KRW ⇄ USD pill button. Uses existing useBaseCurrency hook (already localStorage-backed). Visible on every page.
- Currency display utility — generalise so any price-rendering component takes
(price, baseCurrency, krwPerUsd) and emits { main, sub } strings consistently. Apply to: ticker-table, ticker-card-list, candle-chart label, /orders/[id] PnL, /portfolio/page, dashboard PnL cards.
- Activity page — already cleaned in PR1; just need link to point to new /orders/[id].
Data
- No schema changes required.
ExchangeKey.network (PR2), Order.realizedPnl / closedAt / entryPrice / takeProfitPrice / stopLossPrice / tpOrderId / slOrderId (PR2), LlmDecisionLog.orderId (PR2) — all in place.
API Contracts
GET /portfolio/summary?network=testnet|mainnet|all
GET /orders/:id → { order, decision?, markPrice, unrealizedPnl }
POST /orders/:id/close → { status: 'pending' } then WS order:updated
GET /llm-trades/decisions?limit=20&cursor=...
GET /dashboard/summary → aggregate
Testing Decisions
External behavior only — never assert on internal call ordering or private fields.
PortfolioService unit (Vitest, mocked Prisma): given a mix of testnet and mainnet filled Orders, summary with network=testnet returns only testnet aggregates and zero mainnet totals; network=all returns both plus split.
ActivityService unit: order activity items have link: '/orders/${id}'.
GET /orders/:id controller test: 200 with hydrated payload for own order, 404 for missing, 403 for other user's order.
POST /orders/:id/close controller: rejects on already-closed orders, rejects on testnet-mode-only env if mainnet config disabled, publishes Kafka event with correct shape.
- Worker close saga unit: calls
BinanceRest.closePosition with opposite side + filled quantity + reduceOnly.
useBaseCurrency integration: toggle persists via localStorage; rendering helper outputs { main: '$X', sub: '₩Y' } correctly for both modes.
/orders/[id] page: mocks API response, asserts entry/TP/SL markers render on chart, Close button hidden when order closed.
Prior art: existing Vitest patterns under apps/api-server/src/**/*.test.ts (mocked Prisma + class-based service tests), apps/web/src/**/*.test.tsx (component tests with mocked queries).
Out of Scope
- Position watcher (no longer needed — algoOrder migration in PR3 fix made conditional orders work directly on Binance).
- Manual non-LLM trading (no "place a limit order yourself" form).
- Multi-symbol watchlist or alerts on dashboard.
- Performance/quality analytics of LLM signals over time (separate PRD).
- Mobile-specific layouts (responsive CSS only, no native).
- Mainnet enablement gate (already env-gated since PR3).
- Internationalisation beyond ko/en (no JP, CN, etc.).
Further Notes
- The 404 on activity feed is purely the stale
/orders link from PR0 — not a routing bug. The fix is one line in ActivityService plus the new detail page.
- Currency toggle hook + helpers already exist; this PRD is mostly applying them everywhere rather than building fresh infrastructure.
- Dashboard is a single aggregate endpoint to avoid waterfall loads on a screen most users will visit first thing every session.
- Order detail "Close Position" should reuse PR3's
BinanceRest.closePosition() MARKET reduceOnly path (verified working post -4120 fix).
- All new pages must use
useBaseCurrency from day one — no hardcoded "$" prefixes — to avoid having to retrofit later.
Problem Statement
사용자가 PR3로 LLM 트레이드 흐름을 사용하기 시작했지만 주변 UI가 미완성이라 일상적인 운영이 어려움:
paper/real/all모드 토글을 보여주지만 PR1에서 paper 모드를 폐기했고 실제로는 ExchangeKey의network(testnet/mainnet)로 갈라야 함. 현재는 testnet/mainnet 손익이 한 화면에 섞여 보임.useBaseCurrency/useExchangeRate훅과formatKrw유틸은 이미 있는데 일부 페이지에서만 활용.link: '/orders'가 박혀 있는데 PR1에서/orders라우트 자체를 삭제함.Solution
네 가지 개선:
mode파라미터 제거하고network로 대체). 백엔드는Order행을exchangeKey.network로 필터.useBaseCurrency토글 추가. 모든 가격 표시 컴포넌트가 토글에 반응. localStorage 영속./orders/[id]주문 상세 페이지 신설 — 캔들 차트 + entry/TP/SL 마커, 현재 mark price 기준 미실현 PnL, 수동 "Close Position" 버튼(실거래 모드 한정), 연결된 LlmDecisionLog의 reasoning.User Stories
/settings, I want to register both a testnet and mainnet API key for the same exchange (binance) so that I can practice and trade live with one account.Implementation Decisions
Backend
PortfolioService.getSummary(userId, network?: 'testnet' | 'mainnet' | 'all')— replacemode: paper/real/all. FilterOrderrows by joinedexchangeKey.network. "all" returns both networks, plus abyNetworkbreakdown so the UI can show split totals from one call.ActivityService— order activity item links from/orders→/orders/${order.id}.GET /orders/:id— returns: Order row, related LlmDecisionLog (via FK), live mark price (from exchange), computed unrealized PnL, exchangeKey network.POST /orders/:id/close— manual close. Validates order is real-mode and still open. Publishes a Kafka close event consumed by the worker saga, which callsBinanceRest.closePosition()(MARKET reduceOnly — already known to work post-PR3 fix).GET /llm-trades/decisions?limit=N— paginated user-scoped list, newest first, joined with Order for outcome status.GET /dashboard/summary— single aggregate endpoint:{ pnl: { today: {testnet, mainnet}, week: {testnet, mainnet} }, openPositions: Order[], recentDecisions: LlmDecisionLog[] }. Avoids three round-trips on dashboard load.Frontend
/orders/[id]/page.tsx— new page. Sections: header (symbol + side + status badge),OrderChartcomponent (CandleChart wrapper with TP/SL overlay markers), PnL panel, Close Position button (gated: real mode + open status), reasoning card.OrderChartcomponent — extends existingCandleChartwith horizontal lines at entry/TP/SL and small text labels./dashboard/page.tsx— full rewrite. CallsGET /dashboard/summaryonce. Three sections rendered in CSS grid (responsive)./portfolio/page.tsx— replacemode: paper/real/alltoggle withnetwork: testnet/mainnet/alltoggle. Show split totals when "all" selected.<BaseCurrencyToggle>innav-bar.tsx— small KRW ⇄ USD pill button. Uses existinguseBaseCurrencyhook (already localStorage-backed). Visible on every page.(price, baseCurrency, krwPerUsd)and emits{ main, sub }strings consistently. Apply to: ticker-table, ticker-card-list, candle-chart label, /orders/[id] PnL, /portfolio/page, dashboard PnL cards.Data
ExchangeKey.network(PR2),Order.realizedPnl/closedAt/entryPrice/takeProfitPrice/stopLossPrice/tpOrderId/slOrderId(PR2),LlmDecisionLog.orderId(PR2) — all in place.API Contracts
GET /portfolio/summary?network=testnet|mainnet|allGET /orders/:id→{ order, decision?, markPrice, unrealizedPnl }POST /orders/:id/close→{ status: 'pending' }then WSorder:updatedGET /llm-trades/decisions?limit=20&cursor=...GET /dashboard/summary→ aggregateTesting Decisions
External behavior only — never assert on internal call ordering or private fields.
PortfolioServiceunit (Vitest, mocked Prisma): given a mix of testnet and mainnet filled Orders, summary withnetwork=testnetreturns only testnet aggregates and zero mainnet totals;network=allreturns both plus split.ActivityServiceunit: order activity items havelink: '/orders/${id}'.GET /orders/:idcontroller test: 200 with hydrated payload for own order, 404 for missing, 403 for other user's order.POST /orders/:id/closecontroller: rejects on already-closed orders, rejects on testnet-mode-only env if mainnet config disabled, publishes Kafka event with correct shape.BinanceRest.closePositionwith opposite side + filled quantity + reduceOnly.useBaseCurrencyintegration: toggle persists via localStorage; rendering helper outputs{ main: '$X', sub: '₩Y' }correctly for both modes./orders/[id]page: mocks API response, asserts entry/TP/SL markers render on chart, Close button hidden when order closed.Prior art: existing Vitest patterns under
apps/api-server/src/**/*.test.ts(mocked Prisma + class-based service tests),apps/web/src/**/*.test.tsx(component tests with mocked queries).Out of Scope
Further Notes
/orderslink from PR0 — not a routing bug. The fix is one line inActivityServiceplus the new detail page.BinanceRest.closePosition()MARKET reduceOnly path (verified working post -4120 fix).useBaseCurrencyfrom day one — no hardcoded "$" prefixes — to avoid having to retrofit later.