You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PR3 + #97에서 사용자는 LLM 시그널로 Binance Futures 포지션을 열고 TP/SL을 algoOrder로 부착할 수 있게 됐고, 수동 종료 버튼도 추가됐다. 그런데 TP 또는 SL이 거래소에서 발동해 포지션이 자동 종료된 경우, 우리 시스템은 이 종료 이벤트를 전혀 인지하지 못한다:
Binance 측에서는 algoOrder 트리거 → reduceOnly market 주문 fill → 포지션 0 → 정상 종료
우리 워커는 Kafka trading.order.requested 만 처리하고 있어서, Binance가 우리에게 능동적으로 알려주는 채널이 없음 (User Data Stream 미사용)
그 결과 DB는 status='filled', closedAt=null 상태로 영구히 "열려있는 포지션"처럼 남음
사용자가 대시보드/포트폴리오/주문 상세를 보면 이미 끝난 포지션이 활성으로 보이고, 미실현 PnL이 계속 갱신되며, 실현 손익(realizedPnl) 도 0인 채 유지됨
워커 서비스에 Position Reconciler 백그라운드 컴포넌트를 추가해 30초 주기로 미종료 실거래 주문을 polling하면서 Binance 실제 상태와 동기화한다. 단계별:
DB에서 status='filled' AND closedAt IS NULL AND mode='real' 인 모든 주문 조회 (보통 사용자당 0~5건이라 가벼움)
각 주문에 대해 거래소 API로 현재 포지션 상태 조회 (positionRisk)
포지션이 사라졌으면 → 해당 시점 이후의 income 기록(REALIZED_PNL 트랜잭션)을 1회 조회해서 정확한 실현 손익 합산 → DB를 status='closed', closedAt, realizedPnl 로 업데이트
닫힌 사유(TP fill / SL fill / 수동 / 청산)를 가능한 한 추론해서 알림 메시지에 포함
알림 발송 (Telegram + Web Push) + WebSocket 브로드캐스트로 UI 즉시 갱신
이로써 사용자가 별도 액션을 하지 않아도 자동으로 포지션 상태가 정리되고, 실현 손익이 정확히 채워지며, 대시보드/포트폴리오의 카운트가 즉시 업데이트된다.
User Stories
As a 트레이더, I want my position to be marked closed in the local app within ~30 seconds of Binance's TP firing, so that the dashboard's "active positions" count is accurate without me having to act.
As a 트레이더, I want my position to be marked closed within ~30 seconds of Binance's SL firing, so that I'm not misled into thinking I still have exposure.
As a 트레이더 whose position got liquidated, I want the order to be marked closed with the liquidation outcome flagged, so that I can review the loss without confusion.
As a 트레이더 who closed a position directly on Binance's app, I want the local app to detect that and reconcile, so that there's a single source of truth.
As a 트레이더, I want the realizedPnl on a TP-closed order to reflect Binance's actual settled PnL (including funding fees and trading fees), so that my realized total is accurate to the cent.
As a 트레이더, I want to receive a Telegram (and Web Push) notification when TP fires automatically, so that I know my profit was taken.
As a 트레이더, I want to receive a Telegram (and Web Push) notification when SL fires automatically, so that I'm alerted to the loss.
As a 트레이더, I want the dashboard's "오늘 실현 손익" card to refresh automatically after a TP/SL fires, so that I see the up-to-date PnL.
As a 트레이더, I want the order detail page (/orders/[id]) to update from "open" to "closed" automatically when reconciliation completes, so that the Close button disappears and a closed badge appears.
As a 트레이더, I want the activity feed to show the auto-close event (with reason: TP / SL / Liquidation / Manual on exchange), so that I have a complete history.
As a 트레이더, I want the reconciler to never double-process the same fill, so that my realized PnL doesn't get doubled.
As a 트레이더, I want the reconciler to keep working even if Binance API is temporarily down, so that orders eventually settle when API recovers.
As an 운영자, I want the reconciler to log every reconcile attempt with order id and outcome, so that I can debug "why is this position still open" issues from logs.
As an 운영자, I want the reconciler to respect Binance's rate limits, so that polling doesn't get our IP/keys throttled or banned.
As a 트레이더 with both testnet and mainnet keys, I want the reconciler to work for both networks independently, so that practice positions also reconcile correctly.
As a 트레이더, I want the reconciler not to interfere with my manual close action, so that pressing 종료 still works without race conditions.
As an 운영자, I want a reconcile attempt failure (e.g., expired API key) not to block reconciliation of unrelated orders, so that one bad key doesn't stall everyone.
As a 트레이더, I want orders that have been open for an unreasonably long time (e.g., >7 days) without any movement to still be considered for reconcile, so that abandoned orders don't accumulate.
As an 운영자, I want the reconciler interval to be configurable via env var, so that I can tune cadence in staging vs production without redeploy.
As a 트레이더, I don't want the reconciler to incorrectly mark an order closed when the position is actually still open and just had its TP/SL price re-attached, so that my open trades stay open.
Implementation Decisions
Architecture
New service: PositionReconcilerService in worker-service. Runs as a singleton with a setInterval-driven loop (matching ExchangesService exchange-rate polling pattern). No @nestjs/schedule dependency added.
Default interval: 30 seconds, overridable via RECONCILE_INTERVAL_MS env var.
Reconciler is order-driven, not user-driven — it queries the DB for all status='filled' AND closedAt IS NULL AND mode='real' orders and processes them. Multi-tenancy comes for free.
Per-iteration concurrency: process orders in a loop with bounded parallelism (e.g., Promise.all over chunks of ≤5). Avoids hammering Binance with hundreds of parallel signed calls if many orders are open.
Binance signed-request weight: each iteration is ≤ 2 × N_open_orders weight. With 50 open orders this is 100 weight per 30s — well under the 2400/min futures limit. We log a warning at 1000 weight/min.
Reconcile algorithm (per order)
Decrypt user's exchange key once per user (cache for the iteration).
Call getPosition(symbol). If position exists with non-zero quantity, the order is still open → skip.
If position is gone (or quantity === 0):
a. Call getIncome(symbol, startTime=order.createdAt, endTime=now) to fetch all realized-pnl/funding-fee/commission records for that symbol since the order opened. Filter to those whose tradeId ties back to our entry's exit (best-effort) or summed within the window.
b. Compute realizedPnl = Σ income.income over relevant rows. If income endpoint returns nothing (rare, can lag), fall back to estimate: (markPrice - entryPrice) × quantity × (long?+1:-1), then re-poll on next cycle to refine.
c. Determine close reason heuristic:
If a REALIZED_PNL row exists with order id = TP algo order id → reason: take_profit
Else if = SL algo order id → reason: stop_loss
Else if Binance returns LIQUIDATION income type → reason: liquidation
Else → reason: manual_or_unknown
d. Atomically update DB: status='closed', closedAt, realizedPnl, closeReason.
e. Emit Kafka events: trading.order.result (for WebSocket fan-out / activity feed) and notification.send (Telegram + Web Push, body includes reason).
Backed by Binance Futures /fapi/v1/income. Returns up to 1000 records per call; reconciler pagination not needed within typical 30s window.
API contract changes
GET /orders/:id response gains optional closeReason field on the embedded order object.
GET /dashboard/summary and GET /portfolio/summary already aggregate realizedPnl, no shape change.
WebSocket order:updated payload gains closeReason so UI can show "TP 익절" / "SL 손절" / "청산" badges.
Frontend changes
/orders/[id] and /dashboard rely on existing 5-10s refetchInterval, so reconciler-driven changes appear within one refresh cycle — no architecture change needed.
New badge variant on order detail page header: maps closeReason → user-facing label (익절 / 손절 / 청산 / 수동 / 정리됨).
Activity feed: enrich order item description with reason when present.
Failure of one order's reconcile must NOT abort the iteration; wrap each order in its own try/catch.
A user with an expired/revoked API key produces repeating errors — after 3 consecutive auth failures we emit a notification.send event suggesting key rotation, and skip that user's orders for 1 hour (Redis cooldown key).
Testing Decisions
External-behavior testing only; no asserting on internal call sequences.
PositionReconcilerService.runOnce() unit (Vitest, mocked Prisma + mocked adapter):
Open orders with live position → no DB write, no Kafka emit
Open orders with vanished position + matching REALIZED_PNL income from TP order id → DB updated to closed, closeReason='take_profit', realizedPnl summed correctly
Same scenario but matching SL order id → closeReason='stop_loss'
Income includes LIQUIDATION row → closeReason='liquidation'
Position gone but income endpoint returns empty → DB updated with estimated realizedPnl, closeReason='reconciled_unknown'
Adapter throws auth error → that order is skipped, others still processed; auth-failure counter increments
getIncome adapter unit: signed-request URL contains correct query params; response array is mapped to IncomeRecord[] shape.
Worker integration smoke (existing test pattern): mock Binance HTTP; start reconciler with 100ms interval; insert a fixture order with no live position; assert DB row transitions to closed within 1s.
Idempotency end-to-end: simulate two reconciler ticks back-to-back on the same closed order — second tick is a no-op (verified by Kafka producer mock not being called the second time).
Prior art:
apps/worker-service/src/exchanges/exchanges.service.ts for setInterval lifecycle
apps/api-server/src/orders/sagas/saga-timeout.watchdog.ts for periodic worker pattern
apps/api-server/src/portfolio/portfolio.service.test.ts for mocked-adapter tests with the FakeBinanceRest pattern
apps/worker-service/src/orders/sagas/close-position-saga.test.ts for hoisted-mock idiom
Out of Scope
Binance User Data Stream (WebSocket push for ORDER_TRADE_UPDATE). This would deliver fills in <1s instead of ≤30s, but adds listenKey lifecycle, reconnect-on-disconnect, key-rotation logic, and a parallel Kafka producer pipeline. Defer to a follow-up PRD; the polling reconciler is the reliable safety net regardless of whether WS is added later.
Reconciliation for spot orders — out of scope because PR1 dropped spot trading; only futures positions exist now.
Reconciliation for paper orders — paper mode was retired; not applicable.
Backfilling historical orders that are stuck in the DB — covered indirectly because the reconciler will pick them up on next tick. No special migration.
Automatic re-attaching TP/SL to a position that lost them somehow — out of scope; reconciler only observes, never re-arms.
Exchange-side audit (matching every Binance trade to a local order) — that's a deeper finance/accounting feature; reconciler only handles open→closed transitions.
Multi-exchange reconciler — Binance only for now (matches the rest of the platform).
Configurable per-user polling cadence — single global interval is enough.
Further Notes
The 30-second cadence + Binance income endpoint gives us authoritative realized PnL including the funding fee and commission components that the simple (mark - entry) × qty math misses. This is the right tradeoff for accuracy.
The new closeReason column is the smallest schema delta needed and is forward-compatible with adding a User Data Stream consumer later (the consumer would just write the same column).
The reconciler must not race with UI/UX overhaul: testnet/mainnet split, USD/KRW toggle, /orders/[id], dashboard rebuild #97's manual-close path. The shared Redis lock key (reconcile:order:<id>) is the synchronization primitive; both writers acquire it before updating DB. Manual close already acquires saga:close-lock:<requestId> — this PRD adds an order-scoped lock as well.
The income endpoint can lag by a few seconds after a fill. The reconciler tolerates this by running again 30s later and overwriting an estimated realizedPnl with the authoritative value if the previous tick wrote an estimate. To make this safe we require the second update to ONLY happen when realizedPnl was an estimate (a small realizedPnlSource field, or just "if income now returns data and our row was set without a tradeId reference, refine it once"). Keep the implementation simple: only refine within a 5-minute window after the close.
Problem Statement
PR3 + #97에서 사용자는 LLM 시그널로 Binance Futures 포지션을 열고 TP/SL을 algoOrder로 부착할 수 있게 됐고, 수동 종료 버튼도 추가됐다. 그런데 TP 또는 SL이 거래소에서 발동해 포지션이 자동 종료된 경우, 우리 시스템은 이 종료 이벤트를 전혀 인지하지 못한다:
trading.order.requested만 처리하고 있어서, Binance가 우리에게 능동적으로 알려주는 채널이 없음 (User Data Stream 미사용)status='filled',closedAt=null상태로 영구히 "열려있는 포지션"처럼 남음다음 시나리오 모두에서 같은 문제가 발생한다:
Solution
워커 서비스에 Position Reconciler 백그라운드 컴포넌트를 추가해 30초 주기로 미종료 실거래 주문을 polling하면서 Binance 실제 상태와 동기화한다. 단계별:
status='filled' AND closedAt IS NULL AND mode='real'인 모든 주문 조회 (보통 사용자당 0~5건이라 가벼움)positionRisk)REALIZED_PNL트랜잭션)을 1회 조회해서 정확한 실현 손익 합산 → DB를status='closed',closedAt,realizedPnl로 업데이트이로써 사용자가 별도 액션을 하지 않아도 자동으로 포지션 상태가 정리되고, 실현 손익이 정확히 채워지며, 대시보드/포트폴리오의 카운트가 즉시 업데이트된다.
User Stories
/orders/[id]) to update from "open" to "closed" automatically when reconciliation completes, so that the Close button disappears and a closed badge appears.Implementation Decisions
Architecture
PositionReconcilerServiceinworker-service. Runs as a singleton with asetInterval-driven loop (matchingExchangesServiceexchange-rate polling pattern). No@nestjs/scheduledependency added.RECONCILE_INTERVAL_MSenv var.status='filled' AND closedAt IS NULL AND mode='real'orders and processes them. Multi-tenancy comes for free.Promise.allover chunks of ≤5). Avoids hammering Binance with hundreds of parallel signed calls if many orders are open.≤ 2 × N_open_ordersweight. With 50 open orders this is 100 weight per 30s — well under the 2400/min futures limit. We log a warning at 1000 weight/min.Reconcile algorithm (per order)
getPosition(symbol). If position exists with non-zero quantity, the order is still open → skip.quantity === 0):a. Call
getIncome(symbol, startTime=order.createdAt, endTime=now)to fetch all realized-pnl/funding-fee/commission records for that symbol since the order opened. Filter to those whosetradeIdties back to our entry's exit (best-effort) or summed within the window.b. Compute
realizedPnl = Σ income.incomeover relevant rows. If income endpoint returns nothing (rare, can lag), fall back to estimate:(markPrice - entryPrice) × quantity × (long?+1:-1), then re-poll on next cycle to refine.c. Determine close reason heuristic:
REALIZED_PNLrow exists with order id = TP algo order id → reason:take_profitstop_lossLIQUIDATIONincome type → reason:liquidationmanual_or_unknownd. Atomically update DB:
status='closed', closedAt, realizedPnl, closeReason.e. Emit Kafka events:
trading.order.result(for WebSocket fan-out / activity feed) andnotification.send(Telegram + Web Push, body includes reason).closedAtagain right before update inside a transaction; if already set, skip emit. Use a Redis lock keyed byreconcile:order:<id>with short TTL (e.g., 60s) so manual close (UI/UX overhaul: testnet/mainnet split, USD/KRW toggle, /orders/[id], dashboard rebuild #97) and reconciler can't both process the same order.Schema
Order:closeReason: String?with values'take_profit' | 'stop_loss' | 'liquidation' | 'manual' | 'manual_on_exchange' | 'reconciled_unknown'. Backfill = null for historical rows.closedAt,realizedPnl,tpOrderId,slOrderId,entryPrice,filledQuantity.Exchange adapter additions
IExchangeRest:getIncome(credentials, opts: { symbol?, startTime, endTime, incomeType? }): Promise<IncomeRecord[]>IncomeRecord = { tradeId?, orderId?, symbol, incomeType, income, asset, time }/fapi/v1/income. Returns up to 1000 records per call; reconciler pagination not needed within typical 30s window.API contract changes
GET /orders/:idresponse gains optionalcloseReasonfield on the embeddedorderobject.GET /dashboard/summaryandGET /portfolio/summaryalready aggregaterealizedPnl, no shape change.order:updatedpayload gainscloseReasonso UI can show "TP 익절" / "SL 손절" / "청산" badges.Frontend changes
/orders/[id]and/dashboardrely on existing 5-10srefetchInterval, so reconciler-driven changes appear within one refresh cycle — no architecture change needed.closeReason→ user-facing label (익절/손절/청산/수동/정리됨).Operational
{ orderId, userId, symbol, action: 'skip'|'close'|'error', reason?, latencyMs }. Pino JSON output is ingested by existing log pipeline.notification.sendevent suggesting key rotation, and skip that user's orders for 1 hour (Redis cooldown key).Testing Decisions
External-behavior testing only; no asserting on internal call sequences.
PositionReconcilerService.runOnce()unit (Vitest, mocked Prisma + mocked adapter):REALIZED_PNLincome from TP order id → DB updated toclosed,closeReason='take_profit',realizedPnlsummed correctlycloseReason='stop_loss'LIQUIDATIONrow →closeReason='liquidation'realizedPnl,closeReason='reconciled_unknown'getIncomeadapter unit: signed-request URL contains correct query params; response array is mapped toIncomeRecord[]shape.closedwithin 1s.Prior art:
apps/worker-service/src/exchanges/exchanges.service.tsfor setInterval lifecycleapps/api-server/src/orders/sagas/saga-timeout.watchdog.tsfor periodic worker patternapps/api-server/src/portfolio/portfolio.service.test.tsfor mocked-adapter tests with the FakeBinanceRest patternapps/worker-service/src/orders/sagas/close-position-saga.test.tsfor hoisted-mock idiomOut of Scope
Further Notes
(mark - entry) × qtymath misses. This is the right tradeoff for accuracy.closeReasoncolumn is the smallest schema delta needed and is forward-compatible with adding a User Data Stream consumer later (the consumer would just write the same column).reconcile:order:<id>) is the synchronization primitive; both writers acquire it before updating DB. Manual close already acquiressaga:close-lock:<requestId>— this PRD adds an order-scoped lock as well.realizedPnlwith the authoritative value if the previous tick wrote an estimate. To make this safe we require the second update to ONLY happen whenrealizedPnlwas an estimate (a smallrealizedPnlSourcefield, or just "if income now returns data and our row was set without a tradeId reference, refine it once"). Keep the implementation simple: only refine within a 5-minute window after the close.