Skip to content

feat: PositionReconciler — auto-detect TP/SL/liquidation fills (#98)#101

Merged
fray-cloud merged 1 commit intofeat/#97-ui-overhaulfrom
feat/#98-position-reconciler
May 2, 2026
Merged

feat: PositionReconciler — auto-detect TP/SL/liquidation fills (#98)#101
fray-cloud merged 1 commit intofeat/#97-ui-overhaulfrom
feat/#98-position-reconciler

Conversation

@fray-cloud
Copy link
Copy Markdown
Owner

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

Closes #98.

PR3 + #97에서 TP/SL을 algoOrder로 부착하지만 Binance가 fill을 푸시하지 않아 (User Data Stream 미사용) DB에 ghost open position이 남는 문제. 수동 종료(#97)는 사후 임시방편이었음.

Worker

  • PositionReconcilerService — 30초 주기 setInterval (RECONCILE_INTERVAL_MS 오버라이드)
  • 깊은 모듈 reconcileOrder() — deps 주입, isolated 테스트 가능
  • 알고리즘:
    1. getPosition 으로 포지션 사라짐 감지
    2. 사라졌으면 getIncome 으로 REALIZED_PNL + COMMISSION + FUNDING_FEE 합산해서 정확한 실현 손익
    3. INSURANCE_CLEAR 행 있으면 liquidation, TP/SL 등록 + 양수 PnL → take_profit 등 분류
  • Race-safe: updateMany WHERE closedAt IS NULL + Redis 락 (reconcile:order:<id>) — 수동 close와 중복 알림 방지
  • 인증 실패 쿨다운: 키당 3회 실패 → Redis 1h skip
  • close-position-saga도 closeReason 세팅 (manual / manual_on_exchange)

Adapter / types

  • BinanceRest.getIncome() 신규 (/fapi/v1/income)
  • IncomeRecord, IncomeType 타입 @coin/types 에 노출

Schema

  • Order.closeReason String? 컬럼 추가 + 마이그레이션
  • 값: take_profit | stop_loss | liquidation | manual | manual_on_exchange | reconciled_unknown

API / Frontend

  • OrderResponse DTO에 closeReason 추가
  • ActivityService description에 한국어 사유 라벨 + 종료 상태 표기
  • <CloseReasonBadge> 컴포넌트
  • 주문 상세 헤더 + 대시보드 outcome 매핑 closeReason 우선 사용

Tests

  • reconcileOrder 8 시나리오 (live/TP/SL/liquidation/empty income/lock/race/no-tpsl)
  • close-position-saga 5 시나리오 그대로 그린
  • api 57 + worker 13 + web 41 모두 그린

🤖 Generated with Claude Code

Summary by Sourcery

Add a background position reconciliation worker that closes ghost futures positions by querying Binance income data and recording structured close reasons, and surface those reasons across API and web UI.

New Features:

  • Introduce a PositionReconciler worker service that periodically inspects open real-mode orders, reconciles them against live positions and income records, and auto-closes orders when positions are gone.
  • Expose Binance futures income data via a new getIncome REST adapter method and shared IncomeRecord/IncomeType types.
  • Add structured order close reasons to the Order model and API response so TP/SL, liquidation, manual, and reconciled closures can be distinguished in clients.
  • Display close reasons and derived outcomes in the dashboard, order detail header, and activity feed using dedicated labels and badges.

Bug Fixes:

  • Ensure orders closed directly on the exchange or via TP/SL/liquidation are correctly reflected as closed in the database instead of remaining as ghost open positions.

Enhancements:

  • Make manual close-position saga set explicit close reasons for locally initiated closes.
  • Improve activity and dashboard status labels to reflect closed orders and their exit reasons more clearly.

Tests:

  • Add unit tests covering multiple position reconciliation scenarios including TP/SL, liquidation, empty income responses, locking behavior, and race conditions.

…inance (#98)

PR3 attaches TP/SL via algoOrder, but Binance never pushes fills back to us
(no User Data Stream subscription). When TP or SL fires, the position is
gone on the exchange while our DB stays at status='filled', closedAt=null
— effectively a permanent ghost open position. Manual close (#97) was the
only recovery path.

This adds a worker-side polling reconciler that closes the loop without
introducing a WebSocket dependency.

Worker
- New PositionReconcilerService runs every 30s (RECONCILE_INTERVAL_MS env
  override). Queries DB for status='filled' AND closedAt IS NULL AND
  mode='real' orders and reconciles each.
- Per-order reconcile is extracted into a deep, dep-injected reconcileOrder()
  module so the algorithm is testable in isolation from Kafka/Redis/Prisma
  wiring.
- Algorithm: getPosition() to detect vanished positions, then getIncome()
  windowed since order.createdAt to compute authoritative realizedPnl from
  REALIZED_PNL + COMMISSION + FUNDING_FEE rows. INSURANCE_CLEAR rows mean
  liquidation. With both TP and SL registered, sign of realizedPnl decides
  take_profit vs stop_loss; otherwise manual_on_exchange.
- Race-safe DB write: updateMany with closedAt=null guard so a concurrent
  manual close can't double-emit notifications.
- Auth-failure cooldown: 3 consecutive auth errors per exchange key →
  Redis 1h cooldown skip, so one bad key doesn't loop forever.
- close-position-saga now sets closeReason on its own writes ('manual' for
  the happy path, 'manual_on_exchange' for already-gone reconciliation).

Adapter / types
- New BinanceRest.getIncome() backed by /fapi/v1/income with full IncomeType
  union and IncomeRecord shape exported from @coin/types.
- IExchangeRest gains the matching method.

Schema
- Order.closeReason: String? — nullable, no default (historical rows untouched).
- Manual SQL migration only (DB not reachable from this env).

API
- OrderResponse DTO documents closeReason.
- ActivityService description suffixes Korean reason label and uses
  'closed' status when closedAt is set.

Frontend
- New CloseReasonBadge maps each reason to a colored badge.
- Order detail header shows it next to status. Dashboard recent decisions
  outcome map prefers closeReason over the old positive/negative-PnL guess.

Tests
- reconcileOrder: 8 scenarios — live position skip, TP, SL, liquidation,
  empty income, lock-held, race-lost (manual won the update), TP/SL
  unregistered → manual_on_exchange.
- close-position-saga and existing api/web suites still green.

Resolves #98.

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

Implements a background PositionReconciler worker that periodically detects positions closed on Binance (TP/SL/liquidation/manual on exchange) by querying the futures income endpoint, reconciles corresponding open orders in the DB with race-safe locking, and propagates a structured closeReason field end-to-end through API and web UI for better status display and notifications.

Sequence diagram for a single order reconciliation tick

sequenceDiagram
  participant T as ReconcileTimer
  participant PRS as PositionReconcilerService
  participant DB as PrismaOrder
  participant R as Redis
  participant A as BinanceRest
  participant RO as reconcileOrder
  participant K as KafkaProducer

  T->>PRS: runOnce()
  PRS->>DB: findMany(mode=real, status=filled, closedAt=null)
  DB-->>PRS: openOrders

  loop for each order
    PRS->>R: set(reconcile:order:orderId, 1, EX 60, NX)
    alt lock not acquired
      R-->>PRS: null
      PRS-->>PRS: skip lock_held
    else lock acquired
      R-->>PRS: OK
      PRS->>A: getPosition(credentials, symbol)
      A-->>PRS: position or null
      alt position still open
        PRS-->>R: del(lockKey)
        PRS-->>PRS: outcome skip live_position
      else position gone
        PRS->>A: getIncome(credentials, symbol, window)
        A-->>PRS: IncomeRecord[]
        PRS->>RO: reconcileOrder(order, deps)
        RO->>DB: updateMany(id, closedAt=null, set closed, realizedPnl, closeReason)
        DB-->>RO: {count}
        alt count == 0
          RO-->>PRS: outcome skip race_lost
        else count == 1
          RO->>K: emit NotificationEvent(order_filled, closeReason, realizedPnl)
          K-->>RO: ack
          RO-->>PRS: outcome closed
        end
        PRS->>R: del(lockKey)
      end
    end
  end

  PRS-->>T: outcomes
Loading

ER diagram for Order table with closeReason

erDiagram
  ORDER {
    string id PK
    string status
    datetime createdAt
    datetime closedAt
    string realizedPnl
    string closeReason
  }
Loading

Class diagram for reconciler, adapters, and closeReason propagation

classDiagram
  class PositionReconcilerService {
    -Logger logger
    -Redis redis
    -Kafka kafka
    -Producer producer
    -timer setInterval
    -boolean running
    +PositionReconcilerService(prisma: PrismaService)
    +onModuleInit()
    +onModuleDestroy()
    +runOnce() ReconcileOutcome[]
    -emit(events: EventList, userId: string)
    -isAuthError(err: Error) boolean
    -bumpAuthFailure(exchangeKeyId: string)
  }

  class ReconcileOrderInput {
    +string id
    +string userId
    +string exchange
    +string symbol
    +string side
    +string quantity
    +string filledQuantity
    +string entryPrice
    +string tpOrderId
    +string slOrderId
    +string exchangeKeyId
    +Date createdAt
  }

  class ReconcileOrderDelegate {
    +updateMany(where: ReconcileWhere, data: ReconcileData) UpdateCount
  }

  class ReconcileDeps {
    +prisma: ReconcilePrisma
    +redis: RedisLockClient
    +getPosition(symbol: string) Position
    +getIncome(opts: IncomeOpts) IncomeRecord[]
    +emit(events: EventList) void
  }

  class ReconcileOutcome {
    <<union>>
    +action: string
    +reason: CloseReason
    +realizedPnl: string
  }

  class CloseReason {
    <<enum>>
    take_profit
    stop_loss
    liquidation
    manual
    manual_on_exchange
    reconciled_unknown
  }

  class reconcileOrder {
    +reconcileOrder(order: ReconcileOrderInput, deps: ReconcileDeps) ReconcileOutcome
  }

  class IExchangeRest {
    <<interface>>
    +getPosition(credentials: ExchangeCredentials, symbol: string) Position
    +getIncome(credentials: ExchangeCredentials, opts: IncomeOpts) IncomeRecord[]
  }

  class BinanceRest {
    +getIncome(credentials: ExchangeCredentials, opts: IncomeOpts) IncomeRecord[]
  }

  class IncomeRecord {
    +string symbol
    +IncomeType incomeType
    +string income
    +string asset
    +number time
    +string tradeId
    +string tranId
    +string info
  }

  class IncomeType {
    <<enum>>
    REALIZED_PNL
    COMMISSION
    FUNDING_FEE
    INSURANCE_CLEAR
    TRANSFER
    WELCOME_BONUS
    REFERRAL_KICKBACK
    COMMISSION_REBATE
    API_REBATE
    CONTEST_REWARD
    CROSS_COLLATERAL_TRANSFER
    OPTIONS_PREMIUM_FEE
    OPTIONS_SETTLE_PROFIT
    INTERNAL_TRANSFER
    AUTO_EXCHANGE
    DELIVERED_SETTELMENT
    COIN_SWAP_DEPOSIT
    COIN_SWAP_WITHDRAW
    POSITION_LIMIT_INCREASE_FEE
  }

  class OrderEntity {
    +string id
    +string status
    +Date createdAt
    +Date closedAt
    +string realizedPnl
    +CloseReason closeReason
  }

  class ActivityService {
    +getRecentActivities() ActivityItem[]
    -formatCloseReason(reason: string) string
  }

  class CloseReasonBadge {
    +CloseReasonBadge(reason: CloseReason)
  }

  PositionReconcilerService --> ReconcileOrderInput
  PositionReconcilerService --> ReconcileDeps
  PositionReconcilerService --> IExchangeRest
  PositionReconcilerService --> reconcileOrder
  PositionReconcilerService --> OrderEntity

  reconcileOrder --> ReconcileOrderInput
  reconcileOrder --> ReconcileDeps
  reconcileOrder --> ReconcileOutcome
  reconcileOrder --> CloseReason
  reconcileOrder --> IncomeRecord

  BinanceRest ..|> IExchangeRest
  IExchangeRest --> IncomeRecord

  OrderEntity --> CloseReason
  ActivityService --> OrderEntity
  CloseReasonBadge --> CloseReason
Loading

File-Level Changes

Change Details Files
Add Binance income support and shared income types for position reconciliation.
  • Introduce IncomeType and IncomeRecord types in shared @coin/types exchange definitions.
  • Extend IExchangeRest interface with a typed getIncome method and implement it in BinanceRest using /fapi/v1/income.
  • Wire IncomeRecord usage into worker reconciler dependencies.
packages/types/src/exchange.ts
packages/exchange-adapters/src/interfaces/exchange-rest.ts
packages/exchange-adapters/src/binance/binance.rest.ts
Introduce a periodic PositionReconcilerService and reconcileOrder flow to auto-close ghost orders using live position and income data, with Redis locking and auth cooldowns.
  • Create reconcileOrder domain function that acquires a per-order Redis lock, inspects live position and Binance income records, classifies closure reason (TP/SL/liquidation/manual_on_exchange/reconciled_unknown), computes realized PnL and updates orders via Prisma updateMany.
  • Emit Kafka notification events when reconciliation closes an order and ensure idempotency via race-safe update and lock handling.
  • Add PositionReconcilerService Nest provider that runs on an interval, scans real-mode filled-but-open orders, decrypts exchange credentials, calls adapter getPosition/getIncome, and tracks API auth failures in Redis with a 1h cooldown per key.
  • Register PositionReconcilerService in OrdersModule and add unit tests for reconcileOrder covering TP/SL/liquidation/manual/no-income/lock/race cases.
apps/worker-service/src/orders/orders.module.ts
apps/worker-service/src/orders/reconciler/reconcile-order.ts
apps/worker-service/src/orders/reconciler/position-reconciler.service.ts
apps/worker-service/src/orders/reconciler/reconcile-order.test.ts
Track and expose closeReason for orders from DB through API and frontend, and adjust existing close flows to set the reason explicitly.
  • Add nullable closeReason column to Order table via Prisma migration and include it in order updates.
  • Extend OrderResponse DTO and web API client OrderDetail/LlmDecisionItem types with closeReason, plus a shared CloseReason type on the web side.
  • Update close-position-saga to pass explicit closeReason ('manual' or 'manual_on_exchange') into markOrderClosed, and persist closeReason along with realizedPnl and closedAt.
packages/database/prisma/migrations/20260501230000_add_close_reason/migration.sql
apps/api-server/src/orders/dto/order-response.dto.ts
apps/worker-service/src/orders/sagas/close-position-saga.ts
apps/web/src/lib/api-client.ts
Improve activity and dashboard display to reflect closeReason and normalized closed status.
  • Add formatCloseReason helper in ActivityService to localize close reasons and append them to order activity descriptions, while treating any order with closedAt as status 'closed'.
  • Update dashboard outcome computation to prioritize closeReason over PnL sign and add explicit labels/variants for each closeReason type.
  • Render order header status as 'closed' when closedAt is set, and show a dedicated CloseReasonBadge component based on closeReason.
apps/api-server/src/activity/activity.service.ts
apps/web/src/app/dashboard/page.tsx
apps/web/src/app/orders/[id]/page.tsx
apps/web/src/components/close-reason-badge.tsx

Possibly linked issues


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 3 issues, and left some high level feedback:

  • In classify within reconcile-order.ts, the entryPrice branch only computes and discards direction, which is effectively dead code—either implement the intended fallback PnL estimation or remove this block to avoid confusion.
  • The close-reason → label mapping is now duplicated in multiple places (formatCloseReason in the API, REASON_LABEL in the worker, and LABEL in the web component); consider centralizing this mapping (or at least the enum shape) to a shared module to avoid future drift between services.
  • PositionReconcilerService constructs its own Redis and Kafka clients instead of using Nest DI or existing shared clients; wiring these through injectable providers would make connection management, configuration, and testing more consistent with the rest of the app.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `classify` within `reconcile-order.ts`, the `entryPrice` branch only computes and discards `direction`, which is effectively dead code—either implement the intended fallback PnL estimation or remove this block to avoid confusion.
- The close-reason → label mapping is now duplicated in multiple places (`formatCloseReason` in the API, `REASON_LABEL` in the worker, and `LABEL` in the web component); consider centralizing this mapping (or at least the enum shape) to a shared module to avoid future drift between services.
- `PositionReconcilerService` constructs its own Redis and Kafka clients instead of using Nest DI or existing shared clients; wiring these through injectable providers would make connection management, configuration, and testing more consistent with the rest of the app.

## Individual Comments

### Comment 1
<location path="apps/worker-service/src/orders/reconciler/reconcile-order.ts" line_range="181-188" />
<code_context>
+  // Position closed but no income rows yet (Binance can lag a few seconds).
+  // Estimate from entry vs… we don't have a fill price here, so leave null
+  // and let the next tick refine when income lands.
+  if (order.entryPrice) {
+    const direction = (order.side as PositionSide) === 'long' ? 1 : -1;
+    void direction;
+  }
+  return { reason: 'reconciled_unknown', realizedPnl: null };
</code_context>
<issue_to_address>
**suggestion:** The `entryPrice` branch currently does nothing; either remove it or complete the estimation logic.

The `if (order.entryPrice)` block only computes `direction` and then discards it, so it’s effectively a no-op before returning `reconciled_unknown`. Either remove this block if you don’t intend to estimate PnL here, or implement the estimation logic so it doesn’t look partially implemented.

```suggestion
  // Position closed but no income rows yet (Binance can lag a few seconds).
  // We don't have a fill price here, so avoid guessing PnL and let the next
  // tick refine this once income rows land.
  return { reason: 'reconciled_unknown', realizedPnl: null };
```
</issue_to_address>

### Comment 2
<location path="apps/worker-service/src/orders/reconciler/position-reconciler.service.ts" line_range="90" />
<code_context>
+          network: (order.exchangeKey.network as 'mainnet' | 'testnet') ?? 'mainnet',
+        };
+
+        const adapter = REST_ADAPTERS[order.exchange as ExchangeId]();
+        const deps: ReconcileDeps = {
+          prisma: this.prisma,
</code_context>
<issue_to_address>
**suggestion (performance):** Avoid instantiating a new REST adapter per order in the loop; cache per-exchange instances instead.

This creates a new `BinanceRest` for every order on every tick, which adds avoidable connection and allocation overhead when many orders are open. Since these adapters are reusable, initialize them once per exchange (e.g., via a memoized map) and reuse the instances across orders to cut down churn and improve runtime performance.

Suggested implementation:

```typescript
        const exchangeId = order.exchange as ExchangeId;
        let adapter = restAdapterCache[exchangeId];

        if (!adapter) {
          adapter = REST_ADAPTERS[exchangeId]();
          restAdapterCache[exchangeId] = adapter;
        }

```

To make this work efficiently and only instantiate adapters once per exchange per reconciliation run, you should also:

1. Declare a cache object in the same method, **before** the loop that iterates over `orders` (or however `order` is obtained), for example:
   ```ts
   const restAdapterCache: Partial<Record<ExchangeId, ReturnType<(typeof REST_ADAPTERS)[ExchangeId]>>> = {};
   ```
   This ensures every `order` in that method run shares the same `restAdapterCache`.

2. Make sure this declaration is in the appropriate scope (i.e., inside the method but outside the loop) so that it is not recreated for every `order`, but also not shared across different concurrent method executions unless that is explicitly desired.
</issue_to_address>

### Comment 3
<location path="packages/exchange-adapters/src/interfaces/exchange-rest.ts" line_range="74" />
<code_context>
+    credentials: ExchangeCredentials,
+    opts: {
+      symbol?: string;
+      incomeType?: string;
+      startTime?: number;
+      endTime?: number;
</code_context>
<issue_to_address>
**suggestion:** Tighten the `incomeType` parameter type to align with the `IncomeType` union where possible.

Since `IncomeType` is now defined in `@coin/types`, consider updating this REST interface to use `IncomeType | string` (or just `IncomeType` if arbitrary values aren’t required). This will improve compile-time checking both for `getIncome` callers and for any pattern matching on `incomeType` in the reconciler.

Suggested implementation:

```typescript
  IncomeType,
} from '@coin/types';

```

```typescript
  getIncome(
    credentials: ExchangeCredentials,
    opts: {
      symbol?: string;
      incomeType?: IncomeType | string;
      startTime?: number;
      endTime?: number;
      limit?: number;
    },
  ): Promise<IncomeRecord[]>;
}

```
</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 +181 to +188
// Position closed but no income rows yet (Binance can lag a few seconds).
// Estimate from entry vs… we don't have a fill price here, so leave null
// and let the next tick refine when income lands.
if (order.entryPrice) {
const direction = (order.side as PositionSide) === 'long' ? 1 : -1;
void direction;
}
return { reason: 'reconciled_unknown', realizedPnl: null };
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: The entryPrice branch currently does nothing; either remove it or complete the estimation logic.

The if (order.entryPrice) block only computes direction and then discards it, so it’s effectively a no-op before returning reconciled_unknown. Either remove this block if you don’t intend to estimate PnL here, or implement the estimation logic so it doesn’t look partially implemented.

Suggested change
// Position closed but no income rows yet (Binance can lag a few seconds).
// Estimate from entry vs… we don't have a fill price here, so leave null
// and let the next tick refine when income lands.
if (order.entryPrice) {
const direction = (order.side as PositionSide) === 'long' ? 1 : -1;
void direction;
}
return { reason: 'reconciled_unknown', realizedPnl: null };
// Position closed but no income rows yet (Binance can lag a few seconds).
// We don't have a fill price here, so avoid guessing PnL and let the next
// tick refine this once income rows land.
return { reason: 'reconciled_unknown', realizedPnl: null };

network: (order.exchangeKey.network as 'mainnet' | 'testnet') ?? 'mainnet',
};

const adapter = REST_ADAPTERS[order.exchange as ExchangeId]();
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 (performance): Avoid instantiating a new REST adapter per order in the loop; cache per-exchange instances instead.

This creates a new BinanceRest for every order on every tick, which adds avoidable connection and allocation overhead when many orders are open. Since these adapters are reusable, initialize them once per exchange (e.g., via a memoized map) and reuse the instances across orders to cut down churn and improve runtime performance.

Suggested implementation:

        const exchangeId = order.exchange as ExchangeId;
        let adapter = restAdapterCache[exchangeId];

        if (!adapter) {
          adapter = REST_ADAPTERS[exchangeId]();
          restAdapterCache[exchangeId] = adapter;
        }

To make this work efficiently and only instantiate adapters once per exchange per reconciliation run, you should also:

  1. Declare a cache object in the same method, before the loop that iterates over orders (or however order is obtained), for example:

    const restAdapterCache: Partial<Record<ExchangeId, ReturnType<(typeof REST_ADAPTERS)[ExchangeId]>>> = {};

    This ensures every order in that method run shares the same restAdapterCache.

  2. Make sure this declaration is in the appropriate scope (i.e., inside the method but outside the loop) so that it is not recreated for every order, but also not shared across different concurrent method executions unless that is explicitly desired.

credentials: ExchangeCredentials,
opts: {
symbol?: string;
incomeType?: string;
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: Tighten the incomeType parameter type to align with the IncomeType union where possible.

Since IncomeType is now defined in @coin/types, consider updating this REST interface to use IncomeType | string (or just IncomeType if arbitrary values aren’t required). This will improve compile-time checking both for getIncome callers and for any pattern matching on incomeType in the reconciler.

Suggested implementation:

  IncomeType,
} from '@coin/types';
  getIncome(
    credentials: ExchangeCredentials,
    opts: {
      symbol?: string;
      incomeType?: IncomeType | string;
      startTime?: number;
      endTime?: number;
      limit?: number;
    },
  ): Promise<IncomeRecord[]>;
}

@fray-cloud fray-cloud merged commit 21de078 into feat/#97-ui-overhaul 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