Skip to content

chore: roll up #95 #96 #100 #101 #102 stack into dev#103

Merged
fray-cloud merged 8 commits intodevfrom
feat/#98-position-reconciler
May 2, 2026
Merged

chore: roll up #95 #96 #100 #101 #102 stack into dev#103
fray-cloud merged 8 commits intodevfrom
feat/#98-position-reconciler

Conversation

@fray-cloud
Copy link
Copy Markdown
Owner

fray-cloud and others added 8 commits May 1, 2026 02:12
…, testnet/mainnet network

PR2 of 3 in the LLM-driven Binance Futures pivot. Lays the futures execution
foundation that PR3 will drive from a Claude-generated trade signal.

What's reshaped:
- OrderRequest/OrderResult/Order: side is now 'long'|'short' (the user-facing
  position direction; adapter translates internally to BUY/SELL). Add
  leverage, marginType, takeProfitPrice, stopLossPrice on request, plus
  entryPrice, liquidationPrice, tpOrderId, slOrderId on result. New
  OrderStatus, PositionSide, MarginType, Position, SymbolFilter exports.
- ExchangeCredentials gains optional `network: 'mainnet'|'testnet'` so the
  adapter can pick fapi.binance.com vs testnet.binancefuture.com per call.
- ExchangeKey schema: `network` column (default 'mainnet') and unique
  constraint widened to (userId, exchange, network) so a user can hold one
  key per network.
- Order schema: futures fields (leverage, marginType, positionSide,
  entryPrice, liquidationPrice, takeProfitPrice, stopLossPrice, tpOrderId,
  slOrderId, realizedPnl, closedAt). Init migration regenerated.

What's new:
- BinanceRest rewrite against fapi.binance.com (and testnet.binancefuture.com).
  Reuses the existing HMAC signing path. Implements setLeverage,
  setMarginType (-4046 idempotent), setPositionMode (-4059 idempotent),
  getPosition, closePosition (reduceOnly market), placeStopLoss/placeTakeProfit
  (STOP_MARKET / TAKE_PROFIT_MARKET with closePosition=true and
  workingType=MARK_PRICE), getSymbolFilter (LOT_SIZE / PRICE_FILTER /
  MIN_NOTIONAL via cached exchangeInfo).
- Worker saga: ConfigureFuturesAccountStep (one-way mode + margin type +
  leverage, all idempotent) runs before PlaceOrderStep; AttachTpSlStep runs
  after fill and inline-compensates by force-closing the position if either
  TP or SL placement fails — a naked position is the worst-case outcome.
  UpdateDbStep persists futures fields back to the Order row.
- Api-server CreateOrderDto picks up leverage (1-20 clamped), marginType,
  takeProfitPrice, stopLossPrice. mode is locked to 'real' (paper retired
  in PR1 — testnet replaces it via ExchangeKey.network).
- ClaudeToken and LlmDecisionLog Prisma models added now (unused until PR3)
  so the next migration is purely additive — keeps PR3 small.
- Dev-only POST /debug/futures-test endpoint: takes an exchangeKeyId +
  symbol/side/quantity/leverage/tp/sl, calls the adapter directly (skips
  Kafka and the full saga), returns entry result + tp/sl orderIds + the
  resulting Position. Gated by NODE_ENV !== 'production'.

Verified:
- pnpm build green across all 9 workspace packages
- Prisma migrate dev applied cleanly against fresh volume
- docker compose dev: postgres/redis/kafka healthy, api-server /api/health
  200, worker-service running, web /markets 200

Plan: PR3 will install Claude Code CLI in the worker image, add ClaudeToken
storage + /settings/claude UI, build the LLM trade form, and wire the
Claude-driven signal flow on top of this saga.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR3 of 3 in the LLM-driven Binance Futures pivot. Wires user input → Claude
signal → futures execution end-to-end.

Architecture decision: api-server hosts the Claude CLI subprocess for the
synchronous /signal call. The plan's worker-hosted variant would need Kafka
request-reply for sync HTTP, which is a heavier pattern than warranted for a
single-user app. Token decryption is contained to the LLM call site, then
released. Real-trade execution still goes through the existing worker saga.

What's added:

- Claude Code CLI installed in `coin-base` (used by api-server signal flow
  and by future worker tooling). `claude --version` runs at base build.
- ClaudeTokens module: AES-256-GCM encrypted storage of per-user OAuth
  tokens (`POST/GET/DELETE /claude-tokens`). Reuses existing encryption
  helpers and JWT auth.
- `/settings/claude` UI: paste token, status badge, replace, delete; links
  to `claude setup-token` instructions.
- LlmCliService (api-server): pure cli-runner spawn helper + queue=1
  service with 30s timeout, 1 retry, strict JSON parsing, and TP/SL
  geometry validation (LONG: sl<entry<tp; SHORT: tp<entry<sl). System
  prompt at `src/llm/prompts/trading-system.md` ships via Nest assets.
- LlmTrades module: two-step flow.
  - `POST /llm-trades/signal` — fetch candles via fapi public, call Claude,
    persist `LlmDecisionLog`, return signal+TP/SL+reasoning+entryPrice.
  - `POST /llm-trades/execute` — derive base-asset quantity from
    bet/leverage/entry, create Order row, publish OrderRequestedEvent on
    the existing topic. Picks testnet ExchangeKey by default if user has
    one.
- `/llm-trade` UI: 5 inputs (symbol top-10 / interval / candle count /
  leverage / bet USDT), Get Signal, response panel with TP/SL override,
  confirm dialog, execute. Real-time results piggyback on existing
  `order:updated` WS.
- Worker RiskGuardService: seven guards run before any real-mode order:
  KILL_SWITCH_REAL_TRADING, ENABLE_REAL_MAINNET, MAX_LEVERAGE (default 20),
  LLM_COOLDOWN_SECONDS per-user (Redis SETNX, default 30s),
  MAX_OPEN_POSITIONS_PER_USER (default 1), DAILY_LOSS_LIMIT_USDT
  (mainnet only, default 50), MAX_BET_PCT of available margin (mainnet
  only, default 10%). Mainnet-specific guards are no-ops on testnet.
  Hooked into `OrdersService.handleOrderRequested` so all real orders
  (LLM or otherwise) clear the gate.

Verified:

- pnpm build green across all 9 workspace packages
- coin-base rebuilt; `claude --version` succeeds inside both api-server and
  worker containers
- docker compose dev: postgres/redis/kafka healthy, api-server /api/health
  200, web /llm-trade and /settings/claude render

Manual end-to-end smoke (user does once a Claude token is registered):
1. /settings/claude → paste OAuth token from `claude setup-token`
2. /settings → register a Binance Futures **testnet** API key
3. /llm-trade → fill 5 inputs, Get Signal, confirm, Execute
4. Verify position appears on testnet.binancefuture.com with TP/SL attached

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stack of debugging fixes discovered while running PR3 against a real Binance
Futures account. Each one was a separate dead-end before the next was visible,
so they're bundled here as one commit that makes the feature work.

1. Encryption master key — `.env.dev` shipped with `ENCRYPTION_MASTER_KEY=`
   empty, so AES-256-GCM blew up with "Invalid key length" the moment the
   first ClaudeToken was saved. Confirmed unrelated to this PR but blocked
   smoke testing; the value is now copied from `.env`.

2. Network awareness on the read path — `GetBalancesHandler` and
   `GetOpenOrdersHandler` decrypted the ExchangeKey but never read
   `key.network`, so testnet credentials were always sent at the mainnet
   base URL and got -2015. Both handlers now thread `network` into
   `ExchangeCredentials`.

3. Settings UI for testnet vs mainnet — `/settings` (Accounts) had no way
   to choose network, so users could only register mainnet keys. Added a
   network toggle (defaulting to **testnet** for safety) and updated the
   `createExchangeKey` API client to accept it.

4. System prompt assets — `nest-cli.json` `assets` config didn't
   actually copy `trading-system.md` into `dist/llm/prompts/` under
   `nest start --watch`, so the CLI runner failed with "no system prompt
   file". Inlined the prompt into a TS module (`prompts/trading-system.ts`),
   reverted the assets config, and removed the .md file so there's a single
   source of truth.

5. Subprocess hygiene for `claude -p` — the spawned CLI inherited an open
   stdin pipe and stalled 3s waiting on it before exiting 1. Switched to
   `stdio: ['ignore', 'pipe', 'pipe']` so the CLI sees stdin closed
   immediately. Also explicitly delete `ANTHROPIC_API_KEY` /
   `ANTHROPIC_AUTH_TOKEN` from the spawn env so the user OAuth token wins.

6. `--bare` flag — `--bare` skips per-user config but, in this CLI version,
   it also disables `CLAUDE_CODE_OAUTH_TOKEN` env auth and the CLI returns
   "Not logged in." Dropped `--bare`; we still pass `--tools ""` to keep
   the run side-effect free.

7. Lot-size precision — quantity was computed as
   `(bet × leverage) / entry`.toFixed(6), which Binance rejected with -1111
   on BTCUSDT (stepSize 0.001). LlmTradesService now fetches
   `getSymbolFilter`, floors the raw quantity to the LOT_SIZE step, and
   returns a clear error if the snapped notional is below MIN_NOTIONAL.

8. Conditional orders moved to algoOrder — Binance migrated TP/SL types
   to a new endpoint on 2025-11-06; `/fapi/v1/order` now returns -4120
   "use Algo Order API endpoints instead" for STOP_MARKET /
   TAKE_PROFIT_MARKET regardless of params or account region (verified by
   freqtrade issue #12610 + the change-log entry). Switched
   `placeStopLoss`/`placeTakeProfit` to `POST /fapi/v1/algoOrder` with the
   new schema:
     - required `algoType: 'CONDITIONAL'`
     - `stopPrice` renamed to `triggerPrice`
     - response carries `algoId` instead of `orderId`
   The saga's `AttachTpSlStep` is back to placing real exchange-side TP/SL
   (no client-side watcher needed) and on attach failure compensates by
   force-closing the position.

End-to-end smoke test against Binance Futures testnet now succeeds:
entry MARKET fills, then both TP and SL are attached as conditional algo
orders with `algoStatus: NEW` and survive the saga to UpdateDb +
PublishResult.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/[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>
…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>
…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>
…-rate

fix(portfolio): include closed orders + render USD via base-currency hooks + LLM trade form polish
@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:12am

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.

Sorry @fray-cloud, your pull request is larger than the review limit of 150000 diff characters

@fray-cloud fray-cloud merged commit 267f67d into dev May 2, 2026
4 of 5 checks passed
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