feat: UI/UX overhaul — testnet/mainnet split, USD/KRW toggle, /orders/[id], dashboard rebuild#100
Conversation
…/[id], dashboard rebuild (#97) Resolves the four day-to-day operational gaps identified in #97 so that the post-PR3 LLM trading flow is actually usable. Backend - Portfolio: replace stale paper/real/all mode with testnet/mainnet/all driven by ExchangeKey.network; returns byNetwork breakdown so the UI can show split totals in one call. - Orders: GET /orders/:id now hydrates the row with the joined LLM decision, live mark price, and unrealized PnL; new POST /orders/:id/close publishes a Kafka close event consumed by a worker saga that calls reduceOnly MARKET closePosition. - Worker close saga also reconciles when the position is already gone on the exchange (TP/SL fired or liquidation): pre-checks getPosition and treats -2022/-4046/-2023/-4045 as "position gone" so the DB still flips to closed. - LLM trades: GET /llm-trades/decisions cursor-paginated history with order outcome joined. - Dashboard: new /dashboard/summary aggregate (today/week PnL split by network, open positions with live mark, last 5 LLM decisions) — single round-trip. - Activity: order item link now points to /orders/${id} (was the deleted /orders index route). Frontend - BaseCurrencyToggle pill in the global nav bar (KRW⇄USD, localStorage). - New formatCurrency helper returning {main, sub} so every price renderer can show primary + alt currency without bespoke math. - /portfolio: testnet/mainnet/all toggle, 모의/실거래 split totals, network badges on assets. - /orders/[id]: candle chart with entry/TP/SL price lines, PnL panel, manual Close Position button (gated to real-mode + open), LLM reasoning card. - /dashboard: PnL cards (today/week × testnet/mainnet), active positions table with one-click close, last 5 LLM decisions with outcome badges. Tests - 16 api-server + 1 worker + 7 web suites green. - New: portfolio network filter, close-order kafka emit, activity link, order detail payload, close-position-saga reconcile paths (TP/SL gone, -2022 rejection, idempotency). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Reviewer's GuideSplits portfolio and PnL by testnet/mainnet instead of paper/real, adds richer order/dashboards endpoints (including /orders/:id and /dashboard/summary), wires a Kafka-based manual close-position saga, and overhauls the web UI with network-aware portfolio views, an order detail page with chart/PnL, and a global KRW/USD toggle. Sequence diagram for manual close-position flow via Kafka sagasequenceDiagram
actor User
participant WebApp
participant ApiServer_OrdersController
participant CloseOrderHandler
participant Kafka
participant WorkerService_OrdersService
participant ClosePositionSaga
participant Exchange
participant PrismaDB
participant NotificationTopic
User->>WebApp: Click close position button
WebApp->>ApiServer_OrdersController: POST /orders/{id}/close
ApiServer_OrdersController->>CloseOrderHandler: execute(userId, orderId)
CloseOrderHandler->>PrismaDB: order.findFirst(userId, orderId)
PrismaDB-->>CloseOrderHandler: order
CloseOrderHandler->>Kafka: send(TRADING_ORDER_CLOSE_REQUESTED, OrderCloseRequestedEvent)
CloseOrderHandler-->>ApiServer_OrdersController: {id, status: pending}
ApiServer_OrdersController-->>WebApp: 202 Accepted
WebApp-->>User: Show close requested message
Kafka-->>WorkerService_OrdersService: TRADING_ORDER_CLOSE_REQUESTED event
WorkerService_OrdersService->>ClosePositionSaga: executeClosePositionSaga(event, prisma, producer, redis)
ClosePositionSaga->>PrismaDB: order.findFirst(userId, dbOrderId)
PrismaDB-->>ClosePositionSaga: order
ClosePositionSaga->>Exchange: getPosition(credentials, symbol)
Exchange-->>ClosePositionSaga: livePosition
alt position exists
ClosePositionSaga->>Exchange: closePosition(credentials, symbol, side, quantity)
Exchange-->>ClosePositionSaga: closeResult
else position already gone
ClosePositionSaga->>PrismaDB: order.update(status closed, realizedPnl null)
end
ClosePositionSaga->>PrismaDB: order.update(status closed, realizedPnl)
ClosePositionSaga->>Kafka: send(TRADING_ORDER_RESULT, OrderResultEvent)
ClosePositionSaga->>Kafka: send(NOTIFICATION_SEND, NotificationEvent)
Kafka-->>NotificationTopic: order_filled notification
WebApp->>ApiServer_OrdersController: GET /orders/{id}
ApiServer_OrdersController->>PrismaDB: order.findFirst with llmDecision, exchangeKey
PrismaDB-->>ApiServer_OrdersController: order with decision and network
ApiServer_OrdersController-->>WebApp: order detail with closedAt and realizedPnl
WebApp-->>User: Update order detail and PnL
Class diagram for updated portfolio and dashboard modelsclassDiagram
class PortfolioService {
+getSummary(userId string, network PortfolioNetwork) Promise~PortfolioSummaryModel~
-buildAvgCostMap(orders OrderArray) Map~string, number~
-calculateRealizedPnl(orders OrderArray, avgCostMap Map~string, number~) number
-dailyDeltaMap(orders OrderArray) Map~string, number~
-toCumulative(deltas Map~string, number~) DailyPnlArray
}
class PortfolioNetwork {
<<enumeration>>
testnet
mainnet
all
}
class PortfolioAssetModel {
+exchange string
+currency string
+network string
+quantity string
+avgCost number
+currentPrice number
+valueKrw number
+pnl number
}
class NetworkBreakdownModel {
+totalValueKrw number
+realizedPnl number
+unrealizedPnl number
+dailyPnl DailyPnlArray
}
class DailyPnlItemModel {
+date string
+pnl number
}
class PortfolioSummaryModel {
+network PortfolioNetwork
+totalValueKrw number
+realizedPnl number
+unrealizedPnl number
+assets PortfolioAssetModelArray
+dailyPnl DailyPnlArray
+byNetwork ByNetworkModel
}
class ByNetworkModel {
+testnet NetworkBreakdownModel
+mainnet NetworkBreakdownModel
}
class PortfolioAssetResponse {
+exchange string
+currency string
+network string
+quantity string
+avgCost number
+currentPrice number
+valueKrw number
+pnl number
}
class NetworkBreakdownResponse {
+totalValueKrw number
+realizedPnl number
+unrealizedPnl number
+dailyPnl DailyPnlItemResponseArray
}
class DailyPnlItemResponse {
+date string
+pnl number
}
class PortfolioByNetworkResponse {
+testnet NetworkBreakdownResponse
+mainnet NetworkBreakdownResponse
}
class PortfolioSummaryResponse {
+network string
+totalValueKrw number
+realizedPnl number
+unrealizedPnl number
+assets PortfolioAssetResponseArray
+dailyPnl DailyPnlItemResponseArray
+byNetwork PortfolioByNetworkResponse
}
class DashboardService {
+getSummary(userId string) Promise~DashboardSummaryModel~
-bucketByNetwork(rows OrderRowArray) NetworkPnlBucket
-fetchMarkPrice(exchange string, symbol string) Promise~number~
-computeUnrealizedPnl(order DashboardOrderModel, markPrice number) number
}
class DashboardSummaryModel {
+pnl DashboardPnlBuckets
+openPositions DashboardOpenPositionArray
+recentDecisions DashboardDecisionArray
}
class DashboardPnlBuckets {
+today NetworkPnlBucket
+week NetworkPnlBucket
}
class NetworkPnlBucket {
+testnet number
+mainnet number
}
PortfolioService --> PortfolioSummaryModel
PortfolioSummaryModel --> ByNetworkModel
ByNetworkModel --> NetworkBreakdownModel
PortfolioSummaryModel --> PortfolioAssetModel
NetworkBreakdownModel --> DailyPnlItemModel
PortfolioSummaryResponse --> PortfolioByNetworkResponse
PortfolioByNetworkResponse --> NetworkBreakdownResponse
NetworkBreakdownResponse --> DailyPnlItemResponse
PortfolioSummaryResponse --> PortfolioAssetResponse
DashboardService --> DashboardSummaryModel
DashboardSummaryModel --> DashboardPnlBuckets
DashboardPnlBuckets --> NetworkPnlBucket
Class diagram for order detail, getOrder handler, and close-position sagaclassDiagram
class OrderDetailModel {
+order OrderEntity
+decision LlmDecisionModel
+network string
+markPrice number
+unrealizedPnl number
}
class OrderEntity {
+id string
+userId string
+exchange string
+symbol string
+side string
+status string
+mode string
+entryPrice string
+takeProfitPrice string
+stopLossPrice string
+realizedPnl string
+closedAt Date
+leverage number
+positionSide string
+filledQuantity string
+quantity string
}
class LlmDecisionModel {
+id string
+parsedSignal ParsedSignalModel
+model string
+latencyMs number
+createdAt Date
}
class ParsedSignalModel {
+signal string
+takeProfitPrice string
+stopLossPrice string
+reasoning string
}
class GetOrderHandler {
+execute(query GetOrderQuery) Promise~OrderDetailModel~
-fetchMarkPrice(exchange string, symbol string) Promise~number~
-computeUnrealizedPnl(order GetOrderPnlOrder, markPrice number) number
}
class CloseOrderCommand {
+userId string
+orderId string
}
class CloseOrderHandler {
+execute(command CloseOrderCommand) Promise~CloseOrderResult~
+onModuleInit() void
+onModuleDestroy() void
-kafka Kafka
-producer Producer
}
class CloseOrderResult {
+id string
+status string
}
class OrderCloseRequestedEventModel {
+requestId string
+userId string
+dbOrderId string
}
class OrderResultEventModel {
+requestId string
+userId string
+dbOrderId string
+mode string
}
class NotificationEventModel {
+userId string
+type string
+title string
+message string
}
class ClosePositionSagaFunction {
+executeClosePositionSaga(event OrderCloseRequestedEventModel, prisma PrismaService, producer Producer, redis Redis) Promise~void~
-isPositionGoneError(err Error) boolean
-markOrderClosed(prisma PrismaService, orderId string, realizedPnl string) Promise~void~
-emitClosedEvents(producer Producer, event OrderCloseRequestedEventModel, order CloseOrderSagaOrder, side PositionSide, quantity string, closeResult CloseResultModel, message string) Promise~void~
}
class DashboardOpenPositionModel {
+entryPrice string
+takeProfitPrice string
+stopLossPrice string
+leverage number
+markPrice number
+unrealizedPnl number
+exchangeKey NetworkHolder
}
class NetworkHolder {
+network string
}
OrderDetailModel --> OrderEntity
OrderDetailModel --> LlmDecisionModel
LlmDecisionModel --> ParsedSignalModel
GetOrderHandler --> OrderDetailModel
GetOrderHandler --> OrderEntity
CloseOrderHandler --> CloseOrderCommand
CloseOrderHandler --> OrderCloseRequestedEventModel
ClosePositionSagaFunction --> OrderResultEventModel
ClosePositionSagaFunction --> NotificationEventModel
DashboardOpenPositionModel --> NetworkHolder
OrderEntity <|-- DashboardOpenPositionModel
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- In
PortfolioService.getSummary,buildAvgCostMapis called inside the loop overfilteredKeysfor each key; consider precomputing separate avg-cost maps for testnet/mainnet once (like you already do for PnL breakdown) and reusing them per key to avoid O(keys × orders) recomputation. GetOrderHandlerandDashboardServiceeach create their ownRedisinstances and duplicate mark-price/unrealized-PnL logic; it would be more efficient and maintainable to extract a shared, injectable Redis/ticker service that centralizes connection management and PnL computation.- In
close-position-saga.ts, the notification for a successful manual close usestype: 'order_filled'; if this is meant to be distinguished from initial fills, consider introducing a dedicated notification type (e.g.position_closed) to avoid overloading the existing semantics.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `PortfolioService.getSummary`, `buildAvgCostMap` is called inside the loop over `filteredKeys` for each key; consider precomputing separate avg-cost maps for testnet/mainnet once (like you already do for PnL breakdown) and reusing them per key to avoid O(keys × orders) recomputation.
- `GetOrderHandler` and `DashboardService` each create their own `Redis` instances and duplicate mark-price/unrealized-PnL logic; it would be more efficient and maintainable to extract a shared, injectable Redis/ticker service that centralizes connection management and PnL computation.
- In `close-position-saga.ts`, the notification for a successful manual close uses `type: 'order_filled'`; if this is meant to be distinguished from initial fills, consider introducing a dedicated notification type (e.g. `position_closed`) to avoid overloading the existing semantics.
## Individual Comments
### Comment 1
<location path="apps/worker-service/src/orders/sagas/close-position-saga.ts" line_range="82-86" />
<code_context>
+
+ // 1. If the position is already gone on Binance (TP/SL fired, liquidation,
+ // or a previous close request succeeded), just reconcile our DB and stop.
+ const livePosBefore = await adapter.getPosition(credentials, order.symbol).catch((e) => {
+ logger.warn(`getPosition pre-check failed: ${e}`);
+ return null;
+ });
+ if (!livePosBefore) {
+ logger.warn(`Position already gone on exchange — reconciling order ${order.id}`);
+ await markOrderClosed(prisma, order.id, null);
</code_context>
<issue_to_address>
**issue (bug_risk):** Treating `getPosition` failures as "position gone" can incorrectly close live positions on transient errors.
Because all `getPosition` errors are coerced to `null`, a transient network/API failure looks identical to “no position,” and the order is closed even though the position may still be open on the exchange. This can silently desync our state. Consider handling “position not found” explicitly (e.g., by checking specific error codes) and treating other errors as failures that should bubble up or be retried, rather than collapsing them into the “position gone” path.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| const livePosBefore = await adapter.getPosition(credentials, order.symbol).catch((e) => { | ||
| logger.warn(`getPosition pre-check failed: ${e}`); | ||
| return null; | ||
| }); | ||
| if (!livePosBefore) { |
There was a problem hiding this comment.
issue (bug_risk): Treating getPosition failures as "position gone" can incorrectly close live positions on transient errors.
Because all getPosition errors are coerced to null, a transient network/API failure looks identical to “no position,” and the order is closed even though the position may still be open on the exchange. This can silently desync our state. Consider handling “position not found” explicitly (e.g., by checking specific error codes) and treating other errors as failures that should bubble up or be retried, rather than collapsing them into the “position gone” path.
Closes #97.
PR3로 LLM 트레이드 흐름을 사용하기 시작했지만 주변 UI가 미완성이라 일상적인 운영이 어려운 4가지 문제를 한 번에 해결.
Backend
byNetwork분할 합계 포함GET /orders/:id가 결정/마크가/미실현 PnL까지 hydrate;POST /orders/:id/closereduceOnly MARKET 종료GET /llm-trades/decisions커서 페이지네이션 + 결과 outcome 조인/dashboard/summary단일 집계 엔드포인트/orders→/orders/:idFrontend
<BaseCurrencyToggle>(KRW⇄USD, localStorage)formatCurrencyhelper {main, sub} 반환/portfolio네트워크 토글, 모의/실거래 분할, 자산 네트워크 배지/orders/[id]진입/TP/SL 가격라인 캔들 차트, PnL 패널, 수동 종료, LLM 결정 카드/dashboard오늘/이번주 PnL × 네트워크, 활성 포지션 테이블 인라인 종료, 최근 5개 LLM 결정 outcome 배지Tests
🤖 Generated with Claude Code
Summary by Sourcery
Introduce network-aware portfolio, order close flow, and a rebuilt dashboard with richer LLM trading insights.
New Features:
Bug Fixes:
Enhancements:
Tests: