From 4c637ab07ee9cc2f6ceb2a53d95ea479f2f78776 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 12 Jun 2026 09:35:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(uta,tool):=20round-4=20dogfood=20=E2=80=94?= =?UTF-8?q?=20getOrders=20crash,=20orderId=20truncation,=20spot=20reduceOn?= =?UTF-8?q?ly,=20ambiguous=20portfolio=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walked the never-tested mutation paths (modify / partial close / notional cashQty / second-venue stops). Four catches, all live-verified on okx+bybit demo after fixing: 1. getOrders was COMPLETELY broken over the SDK: summarizeOrder called Decimal methods (.equals) on Order fields that arrive as plain JSON strings over HTTP — "totalQuantity.equals is not a function" on every listing. Rewritten on the value-tolerant compactors. (This also explains how the bug survived: the in-process path has real Decimals; only the split-process SDK path crashed.) 2. Order ids float-truncated in summaries: the inner IBKR-shaped order.orderId is a number — 19-digit CCXT ids lost their tail (…344 → …300), which would have made every downstream modify/cancel-by-id silently target a nonexistent order. Summaries now read the top-level string orderId. 3. closePosition sent reduceOnly on SPOT closes — a derivatives-only concept; okx rejects it outright (51205, observed on a partial spot close). reduceOnly now rides only on CRYPTO_PERP/FUT positions. Partial close live-verified: SELL 0.009 filled @1664.95. 4. getPortfolio rows carried symbol but neither secType nor aliceId — ETH spot and ETH perp rendered as two identical "ETH" rows, and the agent had no id to close either. Both fields added. Verified working this round with no fixes needed: modifyOrder end-to-end (price+qty amendment landed on okx, ids intact), cashQty notional market buy (ticker-converted size, filled + tracked), bybit STP via the new trigger mapping (accepted, tracked, cancelled). Co-Authored-By: Claude Fable 5 --- .../domain/trading/brokers/ccxt/CcxtBroker.ts | 7 +++- src/tool/trading.ts | 40 ++++++++++--------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts index 41ff6c2f..e04c52e2 100644 --- a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -620,7 +620,12 @@ export class CcxtBroker implements IBroker { order.orderType = 'MKT' order.totalQuantity = quantity ?? pos.quantity - return this.placeOrder(pos.contract, order, undefined, { reduceOnly: true }) + // reduceOnly is a DERIVATIVES concept (never open the opposite side by + // accident). Spot has no position to "reduce" — okx rejects the param + // outright (51205 "Reduce Only is not available", observed live on a + // partial spot close). Spot close = plain market sell. + const isDerivative = pos.contract.secType === 'CRYPTO_PERP' || pos.contract.secType === 'FUT' + return this.placeOrder(pos.contract, order, undefined, isDerivative ? { reduceOnly: true } : undefined) } // ---- Queries ---- diff --git a/src/tool/trading.ts b/src/tool/trading.ts index e669c817..a5a27d8a 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -9,13 +9,13 @@ import { tool, type Tool } from 'ai' import { z } from 'zod' import Decimal from 'decimal.js' -import { Contract, UNSET_DECIMAL, coerceSecType } from '@traderalice/ibkr' +import { Contract, coerceSecType } from '@traderalice/ibkr' import { BrokerError, type OpenOrder } from '@traderalice/uta-protocol' import type { UTAManagerSDK } from '@/services/uta-client/index.js' import { normalizeBrokerSearchPattern } from '@traderalice/uta-protocol' import { compactAccountInfo, compactCommit, compactContract, compactContractDetails, - compactOperation, compactPushResult, compactStageResult, compactStatus, + compactOperation, compactOrderFields, compactPushResult, compactStageResult, compactStatus, money, price, } from './trading-compact.js' // `Contract.aliceId` declaration merge is registered as a side-effect @@ -44,27 +44,25 @@ function handleBrokerError(err: unknown): { error: string; code: string; transie } } -/** Summarize an OpenOrder into a compact object for AI consumption. */ +/** + * Summarize an OpenOrder for AI consumption. Uses the value-tolerant + * compactors (NOT order.field.equals(...)) because over HTTP the Order's + * Decimal fields arrive as strings — calling Decimal methods on them threw + * "totalQuantity.equals is not a function" and broke getOrders entirely. + * Order id comes from the top-level string field: the inner `order.orderId` + * is the IBKR number form and float-truncates 19-digit CCXT ids (…344→…300). + */ function summarizeOrder(o: OpenOrder, source: string, stringOrderId?: string) { - const order = o.order + const order = o.order as unknown as Record + const innerId = order['orderId'] return { source, - orderId: stringOrderId ?? String(order.orderId), + orderId: stringOrderId ?? o.orderId ?? (innerId != null ? String(innerId) : ''), aliceId: o.contract.aliceId ?? '', symbol: o.contract.symbol || o.contract.localSymbol || '', - action: order.action, - orderType: order.orderType, - totalQuantity: order.totalQuantity.equals(UNSET_DECIMAL) ? '0' : order.totalQuantity.toFixed(), status: o.orderState.status, - ...(!order.lmtPrice.equals(UNSET_DECIMAL) && { lmtPrice: order.lmtPrice.toFixed() }), - ...(!order.auxPrice.equals(UNSET_DECIMAL) && { auxPrice: order.auxPrice.toFixed() }), - ...(!order.trailStopPrice.equals(UNSET_DECIMAL) && { trailStopPrice: order.trailStopPrice.toFixed() }), - ...(!order.trailingPercent.equals(UNSET_DECIMAL) && { trailingPercent: order.trailingPercent.toFixed() }), - ...(order.tif && { tif: order.tif }), - ...(!order.filledQuantity.equals(UNSET_DECIMAL) && { filledQuantity: order.filledQuantity.toString() }), - ...(o.avgFillPrice != null && { avgFillPrice: o.avgFillPrice }), - ...(order.parentId !== 0 && { parentId: order.parentId }), - ...(order.ocaGroup && { ocaGroup: order.ocaGroup }), + ...compactOrderFields(order), + ...(o.avgFillPrice != null && { avgFillPrice: price(o.avgFillPrice) }), ...(o.tpsl && { tpsl: o.tpsl }), } } @@ -296,7 +294,13 @@ If this tool returns an error with transient=true, wait a few seconds and retry const percentOfEquity = netLiqUsd.gt(0) ? mvUsd.div(netLiqUsd).mul(100) : new Decimal(0) const percentOfPortfolio = totalMarketValueUsd.gt(0) ? mvUsd.div(totalMarketValueUsd).mul(100) : new Decimal(0) allPositions.push({ - source: uta.id, symbol: pos.contract.symbol, currency: pos.currency, side: pos.side, + source: uta.id, symbol: pos.contract.symbol, + // secType + aliceId disambiguate same-symbol positions (ETH + // spot vs ETH perp render identically without them) and give + // the agent the exact id closePosition needs. + secType: pos.contract.secType, + aliceId: pos.contract.aliceId, + currency: pos.currency, side: pos.side, quantity: pos.quantity.toString(), avgCost: price(pos.avgCost), marketPrice: price(pos.marketPrice), marketValue: money(pos.marketValue), unrealizedPnL: money(pos.unrealizedPnL), realizedPnL: money(pos.realizedPnL), percentageOfEquity: `${percentOfEquity.toFixed(1)}%`,