Skip to content

fix(portfolio): include closed orders + render USD via base-currency hooks + LLM trade form polish#102

Merged
fray-cloud merged 2 commits intofeat/#98-position-reconcilerfrom
fix/realized-pnl-and-exchange-rate
May 2, 2026
Merged

fix(portfolio): include closed orders + render USD via base-currency hooks + LLM trade form polish#102
fray-cloud merged 2 commits intofeat/#98-position-reconcilerfrom
fix/realized-pnl-and-exchange-rate

Conversation

@fray-cloud
Copy link
Copy Markdown
Owner

@fray-cloud fray-cloud commented May 2, 2026

실거래 운용 중 발견한 두 버그 + LLM trade 폼 UX 3가지 개선.

Bug 1 — 모의 실현 손익 항상 0

PortfolioServicestatus='filled' 만 조회 → close-saga/reconciler가 'closed' 로 바꾼 행이 통계에서 빠짐.

  • 수정: status IN ('filled','closed') 로 변경

Bug 2 — KRW 전반적으로 깨짐

백엔드 valueKrw 는 사실 USDT × qty = USD. 프론트가 formatKrw(usd값) 으로 콤마만 찍어서 USD를 KRW처럼 출력.

  • 백엔드: valueKrw → valueUsd, totalValueKrw → totalValueUsd 정직화
  • 프론트: 새 <MoneyValue usd={n}/> + <PnlMoney usd={n}/> — 내부에서 useBaseCurrency + useExchangeRate 통해 KRW/USD 토글에 반응. 환율 미로딩 시 USD 단독 fallback.
  • 적용: /portfolio, /dashboard, /orders/[id] 의 모든 USD 값 (총가치/실현/미실현/현재가/평균가/마크/PnL)

LLM trade form 개선

  1. TP/SL %: 진입가 대비 가격 차이 + ROE(레버리지 곱) 표시
  2. 레버리지 슬라이더: 1~125 range + 1/25/50/75/100/125 마크 버튼
  3. 잔고 인지 베팅: 네트워크 토글 + 선택 키의 USDT free/total 잔고 표시 + 10/25/50/100% 빠른 입력 + 잔고 초과 시 실행 차단

Backend

  • GetExchangeKeysHandlernetwork 까지 select (프론트가 키 선택)

Tests

  • 신규: <MoneyValue> 4개 + <PnlMoney> 3개
  • 기존 api 57 + worker 13 + web 43 모두 그린

🤖 Generated with Claude Code

Summary by Sourcery

Fix portfolio PnL calculation to include closed orders and standardize portfolio values as USD/USDT while introducing base-currency-aware money components and polishing the LLM trade form UX.

Bug Fixes:

  • Include both filled and closed orders in portfolio calculations so realized PnL is correctly reflected.
  • Correct portfolio value fields to represent USD/USDT amounts instead of mislabeled KRW values across backend and frontend.

Enhancements:

  • Add base-currency-aware MoneyValue and PnlMoney components and wire them into portfolio, dashboard, and order detail views for consistent currency display and KRW/USD toggling.
  • Enhance the LLM trade form with a leverage slider and presets, network-aware exchange key selection and balance display, quick bet-size percentage buttons, and TP/SL percentage and ROE indicators.

Tests:

  • Add unit tests for the new MoneyValue and PnlMoney components.

fray-cloud and others added 2 commits May 2, 2026 01:22
…a base-currency hooks

Two bugs the user hit when checking testnet portfolio after #98 landed.

1. Realized PnL on the testnet card was always 0 because PortfolioService
   only queried status='filled'. The close-saga and reconciler both flip
   status to 'closed' on settlement, so every TP/SL/manual close fell out
   of the rollup. Fix: WHERE status IN ('filled','closed') so realizedPnl
   is summed from settled rows.

2. Backend `valueKrw` was misnamed — for futures it's USDT × quantity,
   i.e. USD. The frontend then ran formatKrw(usdAmount) and printed those
   USD figures with comma-separators as if they were KRW, so KRW display
   was effectively broken everywhere (and broke harder when the
   exchange-rate cache was empty).

Backend
- Order query in PortfolioService.getSummary now includes 'closed'.
- Field rename valueKrw→valueUsd (also totalValueKrw→totalValueUsd) so the
  contract is honest about the unit. Same in DTOs and the frontend types.

Frontend
- New <MoneyValue usd={n}/> and <PnlMoney usd={n}/> shared components.
  They read useBaseCurrency + useExchangeRate themselves and route through
  formatCurrency, so a USD number displays correctly in KRW or USD with
  a sub-label of the alternate currency. When krwPerUsd=0 (rate not yet
  loaded) they fall back to USD-only — no more misnamed KRW.
- Replaced every formatKrw(usd) / <PnlValue usd> usage:
  - /portfolio: total / realized / unrealized cards, byNetwork breakdown,
    asset-table, asset-card-list
  - /dashboard: today/week PnL cards, open positions table (entry, mark,
    unrealizedPnl)
  - /orders/[id]: entry / mark / TP / SL / realized / unrealized rows
- Deleted the orphaned <PnlValue> + its test/stories.
- demoPortfolio mock updated to USD scale.

Tests
- New: <MoneyValue> (KRW main + USD sub, USD main + KRW sub, no rate, null)
  and <PnlMoney> (sign, color, null) — 7 tests.
- All existing api/worker/web suites still green (57 + 13 + 43).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three usability gaps reported on the LLM trade form:

1. TP/SL prices were just numbers — user couldn't tell at a glance how
   far they were from entry. Now each input shows raw price-distance %
   plus profit/ROE % (multiplied by leverage), with sign-corrected for
   short positions so a TP below entry on a short reads as +profit.

2. Leverage was a 1-20 number input that didn't match Binance's actual
   1x-125x range or its preferred steps. Replaced with a 1-125 slider
   plus tick-buttons at 1/25/50/75/100/125 for one-click jumps.

3. Bet input was unbounded — user had no idea what their actual USDT
   balance was. Added a network toggle (testnet/mainnet), wires up the
   matching exchange key, fetches its balances via the existing
   /exchange-keys/:id/balances endpoint, displays free/total USDT, and
   adds 10/25/50/100% quick-fill buttons. Bet is clamped to free
   balance and the execute button is disabled when over.

Backend: GetExchangeKeysHandler now selects `network` so the frontend
can pick the right key per network without an extra round-trip.

Tests still green (api 57, worker 13, web 43).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
coin-web Ready Ready Preview, Comment May 2, 2026 6:07am

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented May 2, 2026

Reviewer's Guide

Backend now computes and returns portfolio values strictly in USD (including closed orders for realized PnL), and the frontend renders all monetary values via new base-currency-aware MoneyValue/PnlMoney components while also upgrading the LLM trade form with network-aware key selection, balance-aware betting, and TP/SL leverage-aware ROE hints.

Sequence diagram for LLM trade form with network-aware keys and balance-aware betting

sequenceDiagram
  actor Trader
  participant LlmTradeForm
  participant UseExchangeKeys
  participant ApiServer
  participant GetExchangeKeysHandler
  participant UseBalances
  participant BalanceApi
  participant ExecuteTradeApi

  Trader->>LlmTradeForm: Open LlmTradeForm
  activate LlmTradeForm

  LlmTradeForm->>UseExchangeKeys: useExchangeKeys()
  UseExchangeKeys->>ApiServer: GET /exchange-keys
  ApiServer->>GetExchangeKeysHandler: GetExchangeKeysQuery
  GetExchangeKeysHandler-->>ApiServer: ExchangeKeyItem[] (id, exchange, network,...)
  ApiServer-->>UseExchangeKeys: ExchangeKeyItem[]
  UseExchangeKeys-->>LlmTradeForm: keys

  LlmTradeForm->>LlmTradeForm: selectKey = keys.find(exchange=binance, network=testnet)

  LlmTradeForm->>UseBalances: useBalances(selectKey.id)
  UseBalances->>BalanceApi: GET /balances?exchangeKeyId
  BalanceApi-->>UseBalances: balances[USDT]
  UseBalances-->>LlmTradeForm: balances

  LlmTradeForm->>LlmTradeForm: usdtFree, usdtBalance via useMemo
  LlmTradeForm->>LlmTradeForm: useEffect clamp betUsdt to usdtFree

  Trader->>LlmTradeForm: Toggle network mainnet
  LlmTradeForm->>LlmTradeForm: network = mainnet
  LlmTradeForm->>LlmTradeForm: selectedKey = keys.find(network=mainnet)
  LlmTradeForm->>UseBalances: useBalances(selectedKey.id)
  UseBalances->>BalanceApi: GET /balances?exchangeKeyId
  BalanceApi-->>UseBalances: balances[USDT]
  UseBalances-->>LlmTradeForm: balances
  LlmTradeForm->>LlmTradeForm: update usdtFree, usdtBalance

  Trader->>LlmTradeForm: Adjust leverage slider, TP, SL, betUsdt
  LlmTradeForm->>LlmTradeForm: tpPct, slPct, tpProfitPct, slLossPct, ROE

  Trader->>LlmTradeForm: Click Execute
  LlmTradeForm->>LlmTradeForm: Check selectedKey && betUsdt <= usdtFree
  LlmTradeForm-->>Trader: Disable if invalid
  LlmTradeForm->>ExecuteTradeApi: POST /execute-trade (exchangeKeyId, network, betUsdt, leverage, TP, SL)
  ExecuteTradeApi-->>LlmTradeForm: Execution result
  LlmTradeForm-->>Trader: Show success / error message
  deactivate LlmTradeForm
Loading

Sequence diagram for portfolio summary including closed orders and USD values

sequenceDiagram
  actor User
  participant WebApp
  participant ApiClient
  participant ApiServer
  participant PortfolioService
  participant Prisma

  User->>WebApp: Open portfolio / dashboard
  WebApp->>ApiClient: getPortfolioSummary(network)
  ApiClient->>ApiServer: GET /portfolio?network
  ApiServer->>PortfolioService: getSummary(userId, network)

  PortfolioService->>Prisma: order.findMany(where userId, status in [filled, closed])
  Prisma-->>PortfolioService: orders with realizedPnl and network

  PortfolioService->>PortfolioService: buildAvgCostMap(rows)
  PortfolioService->>PortfolioService: dailyDeltaMap(rows)
  PortfolioService->>Prisma: balance and price queries
  Prisma-->>PortfolioService: balances, prices

  PortfolioService->>PortfolioService: Compute valueUsd = currentPrice * quantity
  PortfolioService->>PortfolioService: Compute pnl per asset
  PortfolioService->>PortfolioService: Aggregate totalValueUsd, realizedPnl, unrealizedPnl by network
  PortfolioService-->>ApiServer: PortfolioSummaryResponse (totalValueUsd, byNetwork.totalValueUsd,...)

  ApiServer-->>ApiClient: PortfolioSummary
  ApiClient-->>WebApp: data

  WebApp->>WebApp: Render MoneyValue and PnlMoney components
  WebApp-->>User: Portfolio and dashboard with base-currency-aware values
Loading

Updated class diagram for portfolio types and money display components

classDiagram
  class PortfolioService {
    +getSummary(userId: string, network: string) Promise~PortfolioSummaryResponse~
    -buildAvgCostMap(rows: any[]) Map~string, number~
    -dailyDeltaMap(rows: any[]) Map~string, number~
    -calculateRealizedPnl(rows: any[], avgCostMap: Map~string, number~) number
    -toCumulative(deltas: Map~string, number~) DailyPnlItem[]
  }

  class PortfolioAsset {
    +exchange: string
    +currency: string
    +network: string
    +quantity: string
    +avgCost: number
    +currentPrice: number
    +valueUsd: number
    +pnl: number
  }

  class NetworkBreakdownResponse {
    +totalValueUsd: number
    +realizedPnl: number
    +unrealizedPnl: number
    +dailyPnl: DailyPnlItem[]
  }

  class DailyPnlItem {
    +date: string
    +pnl: number
  }

  class PortfolioSummaryResponse {
    +network: string
    +totalValueUsd: number
    +realizedPnl: number
    +unrealizedPnl: number
    +assets: PortfolioAssetResponse[]
    +byNetwork: ByNetworkResponse
  }

  class PortfolioAssetResponse {
    +exchange: string
    +currency: string
    +network: string
    +quantity: string
    +avgCost: number
    +currentPrice: number
    +valueUsd: number
    +pnl: number
  }

  class ByNetworkResponse {
    +testnet: NetworkBreakdownResponse
    +mainnet: NetworkBreakdownResponse
  }

  class ExchangeKeyItem {
    +id: string
    +exchange: string
    +network: string
    +createdAt: string
    +updatedAt: string
  }

  class PortfolioSummary {
    +network: string
    +totalValueUsd: number
    +realizedPnl: number
    +unrealizedPnl: number
    +assets: PortfolioAsset[]
    +byNetwork: FrontendByNetwork
  }

  class FrontendByNetwork {
    +testnet: NetworkBreakdown
    +mainnet: NetworkBreakdown
  }

  class NetworkBreakdown {
    +totalValueUsd: number
    +realizedPnl: number
    +unrealizedPnl: number
    +dailyPnl: DailyPnlItem[]
  }

  class MoneyValue {
    +usd: number
    +className: string
    +showSub: boolean
    +render(): JSX.Element
  }

  class PnlMoney {
    +usd: number
    +className: string
    +showSub: boolean
    +render(): JSX.Element
  }

  class UseBaseCurrencyHook {
    +currency: string
  }

  class UseExchangeRateHook {
    +krwPerUsd: number
  }

  PortfolioService --> PortfolioSummaryResponse
  PortfolioSummaryResponse *-- PortfolioAssetResponse
  PortfolioSummaryResponse *-- ByNetworkResponse
  ByNetworkResponse *-- NetworkBreakdownResponse
  NetworkBreakdownResponse *-- DailyPnlItem
  PortfolioAssetResponse o-- PortfolioAsset
  PortfolioSummary o-- PortfolioSummaryResponse
  PortfolioSummary *-- FrontendByNetwork
  FrontendByNetwork *-- NetworkBreakdown
  NetworkBreakdown *-- DailyPnlItem
  MoneyValue ..> UseBaseCurrencyHook
  MoneyValue ..> UseExchangeRateHook
  PnlMoney ..> UseBaseCurrencyHook
  PnlMoney ..> UseExchangeRateHook
  LlmTradeForm ..> ExchangeKeyItem
  PortfolioPage ..> MoneyValue
  PortfolioPage ..> PnlMoney
  AssetTable ..> MoneyValue
  AssetTable ..> PnlMoney
  AssetCardList ..> MoneyValue
  AssetCardList ..> PnlMoney

  class LlmTradeForm
  class PortfolioPage
  class AssetTable
  class AssetCardList
Loading

File-Level Changes

Change Details Files
Include closed orders in portfolio calculations and normalize portfolio value fields from KRW to USD across API, types, and mock data.
  • Update PortfolioService to fetch orders with status in ['filled','closed'] so closed orders contribute realized PnL.
  • Rename and reinterpret portfolio asset value field from valueKrw to valueUsd and totalValueKrw to totalValueUsd in PortfolioService, accumulating per-network and overall values in USD.
  • Adjust NetworkBreakdown and PortfolioSummary response DTOs to expose valueUsd/totalValueUsd, and update mock portfolio data to use USD-denominated values.
apps/api-server/src/portfolio/portfolio.service.ts
apps/api-server/src/portfolio/dto/portfolio-response.dto.ts
apps/web/src/lib/api-client.ts
apps/web/src/mocks/data/portfolio.ts
Introduce reusable base-currency-aware MoneyValue and PnlMoney components and replace legacy KRW/formatCurrency usages throughout portfolio, dashboard, and order views.
  • Add MoneyValue and PnlMoney components that take USD amounts and internally use useBaseCurrency/useExchangeRate + formatCurrency to render main/sub values with PnL coloring and optional secondary unit display.
  • Replace direct formatKrw/formatCurrency and PnlValue usages in portfolio summary cards, by-network breakdown, asset tables/cards, dashboard PnL and open positions, and order detail pricing/PnL with MoneyValue/PnlMoney.
  • Update portfolio asset types and UI to use valueUsd instead of valueKrw for sorting and display, including asset table sort keys and labels.
apps/web/src/components/shared/money-value.tsx
apps/web/src/components/shared/pnl-money.tsx
apps/web/src/app/portfolio/page.tsx
apps/web/src/components/portfolio/asset-table.tsx
apps/web/src/components/portfolio/asset-card-list.tsx
apps/web/src/app/dashboard/page.tsx
apps/web/src/app/orders/[id]/page.tsx
apps/web/src/lib/api-client.ts
Enhance the LLM trade form with network selection, balance-aware bet sizing, extended leverage controls, and TP/SL percentage + ROE display, wired to exchange keys and balances.
  • Add Network type and network toggle (testnet/mainnet) in LlmTradeForm, using useExchangeKeys to select the appropriate Binance key (now including network from backend) and pass exchangeKeyId into executeTrade.
  • Use useBalances on the selected key to compute USDT free/total, clamp betUsdt to free balance, add quick-fill percentage buttons, display current balances via Badge, and block execution when bet size exceeds free balance or when no key is selected.
  • Replace numeric leverage input with a 1–125 range slider plus discrete leverage mark buttons, and compute TP/SL percentage difference and position-side-aware profit/loss + leverage-multiplied ROE, showing them inline next to TP/SL inputs with color-coded hints.
  • Disable execute button accordingly and extend its label to show signal direction, leverage, and network (mock vs real).
apps/web/src/components/llm-trade/llm-trade-form.tsx
apps/web/src/lib/api-client.ts
apps/api-server/src/exchange-keys/queries/get-exchange-keys.handler.ts
apps/web/src/hooks/use-exchange-keys.ts
apps/web/src/hooks/use-balances.ts
Expose exchange key network information to the frontend so keys can be filtered by testnet/mainnet.
  • Extend ExchangeKeyItem in the web api-client to include a network field with 'mainnet'
'testnet'.
  • Update GetExchangeKeysHandler Prisma select to include network, enabling the frontend hooks to distinguish testnet vs mainnet keys.

  • Tips and commands

    Interacting with Sourcery

    • Trigger a new review: Comment @sourcery-ai review on the pull request.
    • Continue discussions: Reply directly to Sourcery's review comments.
    • Generate a GitHub issue from a review comment: Ask Sourcery to create an
      issue from a review comment by replying to it. You can also reply to a
      review comment with @sourcery-ai issue to create an issue from it.
    • Generate a pull request title: Write @sourcery-ai anywhere in the pull
      request title to generate a title at any time. You can also comment
      @sourcery-ai title on the pull request to (re-)generate the title at any time.
    • Generate a pull request summary: Write @sourcery-ai summary anywhere in
      the pull request body to generate a PR summary at any time exactly where you
      want it. You can also comment @sourcery-ai summary on the pull request to
      (re-)generate the summary at any time.
    • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
      request to (re-)generate the reviewer's guide at any time.
    • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
      pull request to resolve all Sourcery comments. Useful if you've already
      addressed all the comments and don't want to see them anymore.
    • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
      request to dismiss all existing Sourcery reviews. Especially useful if you
      want to start fresh with a new review - don't forget to comment
      @sourcery-ai review to trigger a new review!

    Customizing Your Experience

    Access your dashboard to:

    • Enable or disable review features such as the Sourcery-generated pull request
      summary, the reviewer's guide, and others.
    • Change the review language.
    • Add, remove or edit custom review instructions.
    • Adjust other review settings.

    Getting Help

    Copy link
    Copy Markdown
    Contributor

    @sourcery-ai sourcery-ai Bot left a comment

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Hey - I've found 1 issue

    Prompt for AI Agents
    Please address the comments from this code review:
    
    ## Individual Comments
    
    ### Comment 1
    <location path="apps/web/src/components/llm-trade/llm-trade-form.tsx" line_range="63-66" />
    <code_context>
    +  }, [balances]);
    +
    +  // Clamp bet to free balance whenever balance changes (and bet was over).
    +  useEffect(() => {
    +    if (usdtFree > 0 && betUsdt > usdtFree) setBetUsdt(Math.floor(usdtFree));
    +  }, [usdtFree, betUsdt]);
    +
       const signalMutation = useMutation({
    </code_context>
    <issue_to_address>
    **suggestion (bug_risk):** The bet size clamping effect can set `betUsdt` below the input `min` and does more work than necessary.
    
    Right now the effect can set `betUsdt` to `0` when `0 < usdtFree < 1`, because it uses `Math.floor(usdtFree)` without respecting the `<input min={1}>`. That makes the React state and the HTML constraint disagree. Including `betUsdt` in the dependency array also causes unnecessary re-renders, since the clamp only needs to react to balance changes.
    
    You can align the state with the declared `min` and simplify the effect to only depend on `usdtFree`:
    
    ```ts
    useEffect(() => {
      const maxBet = Math.max(1, Math.floor(usdtFree));
      setBetUsdt((prev) => (prev > maxBet ? maxBet : prev));
    }, [usdtFree]);
    ```
    
    ```suggestion
      // Clamp bet to free balance whenever balance changes (and bet was over).
      useEffect(() => {
        const maxBet = Math.max(1, Math.floor(usdtFree));
        setBetUsdt((prev) => (prev > maxBet ? maxBet : prev));
      }, [usdtFree]);
    ```
    </issue_to_address>

    Sourcery is free for open source - if you like our reviews please consider sharing them ✨
    Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

    Comment on lines +63 to +66
    // Clamp bet to free balance whenever balance changes (and bet was over).
    useEffect(() => {
    if (usdtFree > 0 && betUsdt > usdtFree) setBetUsdt(Math.floor(usdtFree));
    }, [usdtFree, betUsdt]);
    Copy link
    Copy Markdown
    Contributor

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    suggestion (bug_risk): The bet size clamping effect can set betUsdt below the input min and does more work than necessary.

    Right now the effect can set betUsdt to 0 when 0 < usdtFree < 1, because it uses Math.floor(usdtFree) without respecting the <input min={1}>. That makes the React state and the HTML constraint disagree. Including betUsdt in the dependency array also causes unnecessary re-renders, since the clamp only needs to react to balance changes.

    You can align the state with the declared min and simplify the effect to only depend on usdtFree:

    useEffect(() => {
      const maxBet = Math.max(1, Math.floor(usdtFree));
      setBetUsdt((prev) => (prev > maxBet ? maxBet : prev));
    }, [usdtFree]);
    Suggested change
    // Clamp bet to free balance whenever balance changes (and bet was over).
    useEffect(() => {
    if (usdtFree > 0 && betUsdt > usdtFree) setBetUsdt(Math.floor(usdtFree));
    }, [usdtFree, betUsdt]);
    // Clamp bet to free balance whenever balance changes (and bet was over).
    useEffect(() => {
    const maxBet = Math.max(1, Math.floor(usdtFree));
    setBetUsdt((prev) => (prev > maxBet ? maxBet : prev));
    }, [usdtFree]);

    @fray-cloud fray-cloud merged commit 8759dd7 into feat/#98-position-reconciler May 2, 2026
    3 checks passed
    fray-cloud added a commit that referenced this pull request May 2, 2026
    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Labels

    None yet

    Projects

    None yet

    Development

    Successfully merging this pull request may close these issues.

    1 participant