From 04f4bbff4ff3cff121aff2aee567921c963d55df Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 12 Jun 2026 08:32:09 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(tool,cli):=20agent-boundary=20compacti?= =?UTF-8?q?on=20=E2=80=94=20trading=20outputs=20stop=20lying=20to=20the=20?= =?UTF-8?q?model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live audit of alice-uta found the agent surface poisoning analysis: a staged order echoed the full IBKR Order serialization — 4.3KB, 38 UNSET sentinels per order. Sentinels READ AS DATA to an LLM ("minQty: 2147483647" looks like a real constraint), and a single stage→status→push round burned thousands of tokens of noise. trading-compact.ts: at the tool boundary, unset = absent. Contracts keep instrument identity (IBKR-superset derivative fields ride along exactly when set), Orders keep only set fields, results drop the raw echo and the 120-field orderState while keeping the one signal field (rejectReason). Money displays at 2dp, prices at 8dp; full precision stays in the ledger. Measured live: git status 4278B/38 sentinels → 404B/0. Discoverability fix: every order verb's --help showed "(no flags)" — positiveNumeric's .transform() made z.toJSONSchema throw and the catch silently emptied the manifest schema. io:'input' + unrepresentable: 'any' renders the input side, which is what a CLI manifest wants; flags are now fully listed. Surface completion: tradingReject (the undo for a wrong stage — auto-prepares the commit so the mental model stays stage→reject), orderHistory + tradeHistory tools (the same exchange-frontend projections the UI reads), wired as git reject / order history / order trades on alice-uta. Co-Authored-By: Claude Fable 5 --- .../domain/trading/UnifiedTradingAccount.ts | 12 + src/server/cli-commands.ts | 4 + src/server/cli.ts | 8 +- src/services/uta-client/UTAAccountSDK.ts | 18 ++ src/tool/trading-compact.spec.ts | 116 ++++++++ src/tool/trading-compact.ts | 257 ++++++++++++++++++ src/tool/trading.ts | 93 ++++++- 7 files changed, 494 insertions(+), 14 deletions(-) create mode 100644 src/tool/trading-compact.spec.ts create mode 100644 src/tool/trading-compact.ts diff --git a/services/uta/src/domain/trading/UnifiedTradingAccount.ts b/services/uta/src/domain/trading/UnifiedTradingAccount.ts index cc127835..ca7cbb41 100644 --- a/services/uta/src/domain/trading/UnifiedTradingAccount.ts +++ b/services/uta/src/domain/trading/UnifiedTradingAccount.ts @@ -14,6 +14,8 @@ import { BrokerError, type IBroker, type AccountInfo, type Position, type OpenOr const REACH_RANK: Record = { down: 0, connected: 1, readable: 2 } import { TradingGit } from './git/TradingGit.js' import { recomputeCostBasisFromCommits } from './cost-basis.js' +import { projectOrderHistory, projectTradeHistory } from './order-history.js' +import type { OrderHistoryEntry, TradeHistoryEntry } from '@traderalice/uta-protocol' import { pnlOf } from './position-math.js' import type { Operation, @@ -628,6 +630,16 @@ export class UnifiedTradingAccount { return this.git.getPendingOrderIds() } + /** Exchange-frontend projection — same translation the UI and routes use. */ + async orderHistory(limit = 50): Promise { + return projectOrderHistory(this.git.exportState().commits, { limit }) + } + + /** Exchange-frontend projection — fills only. */ + async tradeHistory(limit = 50): Promise { + return projectTradeHistory(this.git.exportState().commits, { limit }) + } + /** firstSeen/lastPolled per pending order — drives the per-order polling * backoff for brokers without a listing API. In-memory only: a restart * resets every order to "fresh", which just means one eager poll. */ diff --git a/src/server/cli-commands.ts b/src/server/cli-commands.ts index c354b72b..be3e8c92 100644 --- a/src/server/cli-commands.ts +++ b/src/server/cli-commands.ts @@ -166,11 +166,14 @@ export const CLI_EXPORTS: Record = { }, order: { list: 'getOrders', + history: 'orderHistory', + trades: 'tradeHistory', place: 'placeOrder', modify: 'modifyOrder', cancel: 'cancelOrder', }, position: { + // listing positions = `account portfolio` (one tool, one verb). close: 'closePosition', }, // trading-as-git: the approval/state flow mirrors git verbs on purpose. @@ -180,6 +183,7 @@ export const CLI_EXPORTS: Record = { show: 'tradingShow', commit: 'tradingCommit', push: 'tradingPush', + reject: 'tradingReject', sync: 'tradingSync', }, market: { diff --git a/src/server/cli.ts b/src/server/cli.ts index 7820ba49..4e2bff8d 100644 --- a/src/server/cli.ts +++ b/src/server/cli.ts @@ -119,7 +119,13 @@ export function registerCliRoutes(app: Hono, deps: CliGatewayDeps): void { if (!tool) continue let schema: unknown = {} try { - schema = z.toJSONSchema(tool.inputSchema as z.ZodType) + // io:'input' + unrepresentable:'any' — schemas with .transform() + // (e.g. trading's positiveNumeric) have no output-side JSON-schema + // representation; the default call threw and the catch silently + // rendered "(no flags)" for every order verb, leaving agents to + // guess flag names from prose. Input-side conversion is exactly + // what a CLI manifest wants anyway. + schema = z.toJSONSchema(tool.inputSchema as z.ZodType, { io: 'input', unrepresentable: 'any' }) } catch { /* leave {} */ } diff --git a/src/services/uta-client/UTAAccountSDK.ts b/src/services/uta-client/UTAAccountSDK.ts index 78745a27..2da0d0f8 100644 --- a/src/services/uta-client/UTAAccountSDK.ts +++ b/src/services/uta-client/UTAAccountSDK.ts @@ -13,6 +13,8 @@ import type { UTAClient, AccountInfo, + OrderHistoryEntry, + TradeHistoryEntry, Position, OpenOrder, Quote, @@ -209,6 +211,22 @@ export class UTAAccountSDK { return this.client.get(`/api/trading/uta/${encodeURIComponent(this.id)}/wallet/status`) } + /** Exchange-frontend projection: one row per order, lifecycle collapsed. */ + async orderHistory(limit = 50): Promise { + const r = await this.client.get<{ orders: OrderHistoryEntry[] }>( + `/api/trading/uta/${encodeURIComponent(this.id)}/order-history?limit=${limit}`, + ) + return r.orders + } + + /** Exchange-frontend projection: fills only (reconcile foldings labeled). */ + async tradeHistory(limit = 50): Promise { + const r = await this.client.get<{ trades: TradeHistoryEntry[] }>( + `/api/trading/uta/${encodeURIComponent(this.id)}/trade-history?limit=${limit}`, + ) + return r.trades + } + getState(): Promise { // Wallet status returns GitStatus (a projection of GitState); for now // synthesize a minimal GitState shape from status. Route gap tracked. diff --git a/src/tool/trading-compact.spec.ts b/src/tool/trading-compact.spec.ts new file mode 100644 index 00000000..71dec79d --- /dev/null +++ b/src/tool/trading-compact.spec.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest' +import Decimal from 'decimal.js' +import { + val, money, price, + compactContract, compactOrderFields, compactOperation, + compactResult, compactStatus, compactAccountInfo, +} from './trading-compact.js' + +describe('val — sentinel normalization', () => { + it('drops every IBKR unset sentinel in every value form', () => { + expect(val(1.7976931348623157e+308)).toBeUndefined() // UNSET_DOUBLE number + expect(val('1.7976931348623157e+308')).toBeUndefined() // …as string + expect(val(2147483647)).toBeUndefined() // UNSET_INTEGER + expect(val('1.70141183460469231731687303715884105727e+38')).toBeUndefined() // UNSET_DECIMAL string + expect(val(new Decimal('1.70141183460469231731687303715884105727e+38'))).toBeUndefined() + expect(val('')).toBeUndefined() + expect(val(null)).toBeUndefined() + expect(val(undefined)).toBeUndefined() + }) + + it('keeps real values, including awkward ones', () => { + expect(val('0.01')).toBe('0.01') + expect(val(0)).toBe('0') + expect(val(new Decimal('0.00000001'))).toBe('0.00000001') + expect(val('DAY')).toBe('DAY') + }) +}) + +describe('money / price — display precision', () => { + it('caps money at 2dp and price at 8dp without mangling', () => { + expect(money('90273.826752780986')).toBe('90273.83') + expect(money('150.48841053856901168')).toBe('150.49') + expect(price('2165.8896354239932')).toBe('2165.88963542') + expect(price('0.00000001')).toBe('0.00000001') + }) +}) + +describe('compactContract', () => { + it('keeps instrument identity, drops sentinels/empties, normalizes right', () => { + const c = compactContract({ + conId: 0, symbol: 'AAPL', secType: 'OPT', lastTradeDateOrContractMonth: '20260717', + lastTradeDate: '', strike: 300, right: 'CALL', multiplier: '100', exchange: 'SMART', + primaryExchange: '', currency: 'USD', localSymbol: 'AAPL 260717C00300000', + tradingClass: '', includeExpired: false, secIdType: '', comboLegs: [], + deltaNeutralContract: null, aliceId: 'ibkr|x', + }) + expect(c).toEqual({ + aliceId: 'ibkr|x', symbol: 'AAPL', localSymbol: 'AAPL 260717C00300000', + secType: 'OPT', currency: 'USD', exchange: 'SMART', + expiry: '20260717', strike: '300', right: 'C', multiplier: '100', + }) + }) + + it('omits multiplier when it is 1 (canonical, carries no signal)', () => { + expect(compactContract({ symbol: 'ETH', multiplier: '1' })).toEqual({ symbol: 'ETH' }) + }) +}) + +describe('compactOrderFields', () => { + it('reduces the ~120-field Order to its set fields', () => { + const o = compactOrderFields({ + action: 'BUY', orderType: 'LMT', totalQuantity: '0.01', lmtPrice: '1200', + auxPrice: '1.70141183460469231731687303715884105727e+38', + trailingPercent: '1.70141183460469231731687303715884105727e+38', + minQty: 2147483647, percentOffset: 1.7976931348623157e+308, + tif: 'DAY', outsideRth: false, softDollarTier: { name: '' }, + filledQuantity: '1.70141183460469231731687303715884105727e+38', + }) + expect(o).toEqual({ action: 'BUY', orderType: 'LMT', totalQuantity: '0.01', lmtPrice: '1200', tif: 'DAY' }) + }) +}) + +describe('compactOperation / compactStatus / compactResult', () => { + it('placeOrder operation has no sentinel anywhere in its JSON', () => { + const op = compactOperation({ + action: 'placeOrder', + contract: { symbol: 'ETH', strike: 1.7976931348623157e+308, conId: 0 }, + order: { action: 'BUY', totalQuantity: '0.01', minQty: 2147483647 }, + }) + const json = JSON.stringify(op) + expect(json).not.toMatch(/1\.797693|1\.7014118|2147483647/) + expect(op).toEqual({ action: 'placeOrder', contract: { symbol: 'ETH' }, order: { action: 'BUY', totalQuantity: '0.01' } }) + }) + + it('compactResult drops raw + orderState but keeps the reject reason', () => { + const r = compactResult({ + action: 'placeOrder', success: false, status: 'rejected', error: 'price band', + raw: { huge: 'payload' }, + orderState: { status: 'Inactive', rejectReason: 'okx 51138', commissionAndFees: 1.7976931348623157e+308 }, + }) + expect(r).toEqual({ action: 'placeOrder', success: false, status: 'rejected', error: 'price band', rejectReason: 'okx 51138' }) + }) + + it('compactStatus compacts staged ops and passes scalars through', () => { + const s = compactStatus({ + staged: [{ action: 'cancelOrder', orderId: 'o1' }], + pendingMessage: null, pendingHash: null, head: 'abc', commitCount: 5, + }) + expect(s).toEqual({ staged: [{ action: 'cancelOrder', orderId: 'o1' }], pendingMessage: null, pendingHash: null, head: 'abc', commitCount: 5 }) + }) +}) + +describe('compactAccountInfo', () => { + it('rounds money to 2dp and omits unreported fields (never fabricates zeros)', () => { + const a = compactAccountInfo({ + baseCurrency: 'USD', netLiquidation: '90273.826752780986', + totalCashValue: '81351.50743564543', unrealizedPnL: '150.48841053856901168', + realizedPnL: '-0.3654613868044494', initMarginReq: '1.11583333333', + }) + expect(a).toEqual({ + baseCurrency: 'USD', netLiquidation: '90273.83', totalCashValue: '81351.51', + unrealizedPnL: '150.49', realizedPnL: '-0.37', initMarginReq: '1.12', + }) + expect('buyingPower' in a).toBe(false) + }) +}) diff --git a/src/tool/trading-compact.ts b/src/tool/trading-compact.ts new file mode 100644 index 00000000..1f8b4ce1 --- /dev/null +++ b/src/tool/trading-compact.ts @@ -0,0 +1,257 @@ +/** + * Agent-boundary compaction for trading tool outputs. + * + * The wire shapes are IBKR-superset objects: an Order serializes to ~120 + * fields, most carrying UNSET sentinels (1.7976931348623157e+308, + * 2147483647, 1.70141…e+38 Decimal max). For an LLM that's not just token + * waste — sentinels READ AS DATA ("minQty: 2147483647" looks like a real + * constraint) and actively mislead analysis. Principle at this boundary: + * **unset = absent**. Only fields that carry information leave the tool. + * + * Tolerant of all three value forms the SDK can hand us (Decimal instance, + * Decimal-as-string, plain number) because rehydration depth varies by + * call path. + */ + +import Decimal from 'decimal.js' + +// ==================== value normalization ==================== + +const UNSET_DOUBLE_STR = '1.7976931348623157e+308' +const UNSET_I32_STR = '2147483647' +// Decimal UNSET sentinel = 2^127-ish; match the canonical prefix in both +// scientific ("1.70141…e+38") and toFixed plain-integer ("170141…", 39 +// digits) renderings. +const UNSET_DECIMAL_RE = /^1\.?70141183460469/ + +/** Normalize a maybe-Decimal/string/number to a string, or undefined when + * it's an UNSET sentinel / empty. */ +export function val(v: unknown): string | undefined { + if (v == null) return undefined + const s = v instanceof Decimal ? v.toFixed() + : typeof v === 'object' && 'toFixed' in (v as object) ? (v as Decimal).toFixed() + : String(v) + if (s === '' || s === UNSET_DOUBLE_STR || s === UNSET_I32_STR) return undefined + if (UNSET_DECIMAL_RE.test(s)) return undefined + return s +} + +/** val() + decimal-place cap (display precision for money fields the AI + * reads but never feeds back into order entry). */ +export function money(v: unknown, dp = 2): string | undefined { + const s = val(v) + if (s === undefined) return undefined + try { + return new Decimal(s).toDecimalPlaces(dp).toFixed() + } catch { + return s + } +} + +/** val() with a looser cap for prices/costs (crypto needs sub-cent). */ +export function price(v: unknown): string | undefined { + const s = val(v) + if (s === undefined) return undefined + try { + return new Decimal(s).toDecimalPlaces(8).toFixed() + } catch { + return s + } +} + +// ==================== shape compactors ==================== + +type AnyRec = Record + +function pick(out: AnyRec, key: string, value: unknown): void { + if (value !== undefined) out[key] = value +} + +/** Contract → only the fields that identify the instrument (IBKR superset: + * derivative fields ride along exactly when set). */ +export function compactContract(c: unknown): AnyRec { + if (!c || typeof c !== 'object') return {} + const k = c as AnyRec + const out: AnyRec = {} + pick(out, 'aliceId', val(k['aliceId'])) + pick(out, 'symbol', val(k['symbol'])) + pick(out, 'localSymbol', val(k['localSymbol'])) + pick(out, 'secType', val(k['secType'])) + pick(out, 'currency', val(k['currency'])) + pick(out, 'exchange', val(k['exchange'])) + pick(out, 'description', val(k['description'])) + pick(out, 'expiry', val(k['lastTradeDateOrContractMonth'])) + pick(out, 'strike', val(k['strike'])) + const right = val(k['right']) + if (right === 'C' || right === 'CALL') out['right'] = 'C' + else if (right === 'P' || right === 'PUT') out['right'] = 'P' + const mult = val(k['multiplier']) + if (mult && mult !== '1') out['multiplier'] = mult + return out +} + +/** Order → the set fields only (the ~115 others are IBKR defaults). */ +export function compactOrderFields(o: unknown): AnyRec { + if (!o || typeof o !== 'object') return {} + const k = o as AnyRec + const out: AnyRec = {} + pick(out, 'action', val(k['action'])) + pick(out, 'orderType', val(k['orderType'])) + pick(out, 'totalQuantity', val(k['totalQuantity'])) + pick(out, 'cashQty', val(k['cashQty'])) + pick(out, 'lmtPrice', val(k['lmtPrice'])) + pick(out, 'auxPrice', val(k['auxPrice'])) + pick(out, 'trailStopPrice', val(k['trailStopPrice'])) + pick(out, 'trailingPercent', val(k['trailingPercent'])) + pick(out, 'tif', val(k['tif'])) + pick(out, 'goodTillDate', val(k['goodTillDate'])) + if (k['outsideRth'] === true) out['outsideRth'] = true + const filled = val(k['filledQuantity']) + if (filled) out['filledQuantity'] = filled + return out +} + +/** Operation (staged / committed) → human-scale summary. */ +export function compactOperation(op: unknown): AnyRec { + if (!op || typeof op !== 'object') return {} + const k = op as AnyRec + const action = k['action'] as string + switch (action) { + case 'placeOrder': + case 'observeExternalOrder': + return { + action, + contract: compactContract(k['contract']), + order: compactOrderFields(k['order']), + ...(k['tpsl'] ? { tpsl: k['tpsl'] } : {}), + } + case 'closePosition': + return { + action, + contract: compactContract(k['contract']), + ...(val(k['quantity']) ? { quantity: val(k['quantity']) } : {}), + } + case 'modifyOrder': + return { action, orderId: k['orderId'], changes: compactOrderFields(k['changes']) } + case 'cancelOrder': + return { action, orderId: k['orderId'] } + default: + return { action } + } +} + +/** OperationResult → status + execution data; never the raw echo or the + * 120-field orderState. The reject reason is the one orderState field + * that carries signal. */ +export function compactResult(r: unknown): AnyRec { + if (!r || typeof r !== 'object') return {} + const k = r as AnyRec + const out: AnyRec = { + action: k['action'], + success: k['success'], + status: k['status'], + } + pick(out, 'orderId', val(k['orderId'])) + pick(out, 'filledQty', val(k['filledQty'])) + pick(out, 'filledPrice', price(k['filledPrice'])) + pick(out, 'error', val(k['error'])) + const orderState = k['orderState'] as AnyRec | undefined + const rejectReason = orderState ? val(orderState['rejectReason']) : undefined + if (rejectReason) out['rejectReason'] = rejectReason + const warning = orderState ? val(orderState['warningText']) : undefined + if (warning) out['warning'] = warning + return out +} + +/** GitStatus → staged ops compacted; scalars pass through. */ +export function compactStatus(status: unknown): AnyRec { + if (!status || typeof status !== 'object') return {} + const k = status as AnyRec + return { + staged: Array.isArray(k['staged']) ? k['staged'].map(compactOperation) : [], + pendingMessage: k['pendingMessage'] ?? null, + pendingHash: k['pendingHash'] ?? null, + head: k['head'] ?? null, + commitCount: k['commitCount'], + } +} + +/** AddResult (stage echo) → confirmation, not a serialization dump. */ +export function compactStageResult(r: unknown): AnyRec { + if (!r || typeof r !== 'object') return {} + const k = r as AnyRec + return { + staged: k['staged'], + index: k['index'], + operation: compactOperation(k['operation']), + } +} + +/** PushResult → per-op outcomes without raw/orderState noise. */ +export function compactPushResult(r: unknown): AnyRec { + if (!r || typeof r !== 'object') return {} + const k = r as AnyRec + return { + hash: k['hash'], + message: k['message'], + operationCount: k['operationCount'], + submitted: Array.isArray(k['submitted']) ? k['submitted'].map(compactResult) : [], + rejected: Array.isArray(k['rejected']) ? k['rejected'].map(compactResult) : [], + } +} + +/** GitCommit (tradingShow) → ops + results compacted; stateAfter collapsed + * to the account-level numbers + counts (the full position/order arrays + * are reachable via getPortfolio/getOrders when actually needed). */ +export function compactCommit(commit: unknown): AnyRec { + if (!commit || typeof commit !== 'object') return {} + const k = commit as AnyRec + const state = (k['stateAfter'] ?? {}) as AnyRec + return { + hash: k['hash'], + parentHash: k['parentHash'], + message: k['message'], + timestamp: k['timestamp'], + operations: Array.isArray(k['operations']) ? k['operations'].map(compactOperation) : [], + results: Array.isArray(k['results']) ? k['results'].map(compactResult) : [], + stateAfter: { + netLiquidation: money(state['netLiquidation']), + totalCashValue: money(state['totalCashValue']), + unrealizedPnL: money(state['unrealizedPnL']), + realizedPnL: money(state['realizedPnL']), + positionCount: Array.isArray(state['positions']) ? state['positions'].length : 0, + pendingOrderCount: Array.isArray(state['pendingOrders']) ? state['pendingOrders'].length : 0, + }, + } +} + +/** ContractDetails → contract compacted + primitive fields that carry + * signal (generic sentinel sweep over scalars; nested IBKR noise dropped). */ +export function compactContractDetails(details: unknown): AnyRec { + if (!details || typeof details !== 'object') return {} + const k = details as AnyRec + const out: AnyRec = {} + for (const [key, v] of Object.entries(k)) { + if (key === 'contract') { out['contract'] = compactContract(v); continue } + if (v == null || typeof v === 'object') continue + const s = val(v) + if (s !== undefined && s !== '0' && s !== 'false') out[key] = v + } + return out +} + +/** AccountInfo → 2dp money (display precision; the ledger keeps full). */ +export function compactAccountInfo(info: unknown): AnyRec { + if (!info || typeof info !== 'object') return {} + const k = info as AnyRec + const out: AnyRec = { baseCurrency: k['baseCurrency'] } + pick(out, 'netLiquidation', money(k['netLiquidation'])) + pick(out, 'totalCashValue', money(k['totalCashValue'])) + pick(out, 'unrealizedPnL', money(k['unrealizedPnL'])) + pick(out, 'realizedPnL', money(k['realizedPnL'])) + pick(out, 'buyingPower', money(k['buyingPower'])) + pick(out, 'initMarginReq', money(k['initMarginReq'])) + pick(out, 'maintMarginReq', money(k['maintMarginReq'])) + if (k['dayTradesRemaining'] != null) out['dayTradesRemaining'] = k['dayTradesRemaining'] + return out +} diff --git a/src/tool/trading.ts b/src/tool/trading.ts index d7e2a9a5..b06856bd 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -13,6 +13,11 @@ import { Contract, UNSET_DECIMAL, 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, + money, price, +} from './trading-compact.js' // `Contract.aliceId` declaration merge is registered as a side-effect // of `@traderalice/uta-protocol`'s barrel — already pulled in above. @@ -177,7 +182,7 @@ hitting the broker, which otherwise expects the bare base ticker.`, try { const details = await uta.getContractDetails(query) if (!details) return { error: 'No contract details found.' } - return { source: uta.id, ...details } + return { source: uta.id, ...compactContractDetails(details) } } catch (err) { return handleBrokerError(err) } @@ -194,7 +199,7 @@ If this tool returns an error with transient=true, wait a few seconds and retry const targets = await manager.resolve(source) if (targets.length === 0) return { error: 'No accounts available.' } try { - const results = await Promise.all(targets.map(async (uta) => ({ source: uta.id, ...await uta.getAccount() }))) + const results = await Promise.all(targets.map(async (uta) => ({ source: uta.id, ...compactAccountInfo(await uta.getAccount()) }))) return results.length === 1 ? results[0] : results } catch (err) { return handleBrokerError(err) @@ -258,8 +263,8 @@ If this tool returns an error with transient=true, wait a few seconds and retry 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, - quantity: pos.quantity.toString(), avgCost: pos.avgCost, marketPrice: pos.marketPrice, - marketValue: pos.marketValue, unrealizedPnL: pos.unrealizedPnL, realizedPnL: pos.realizedPnL, + 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)}%`, percentageOfPortfolio: `${percentOfPortfolio.toFixed(1)}%`, }) @@ -337,7 +342,8 @@ If this tool returns an error with transient=true, wait a few seconds and retry // UnifiedTradingAccount.getQuote (and the route), so the tool // just hands over the aliceId stub. const contract = Object.assign(new Contract(), { aliceId }) - return { source: uta.id, ...await uta.getQuote(contract) } + const quote = await uta.getQuote(contract) + return { source: uta.id, ...quote, contract: compactContract(quote.contract) } } catch (err) { return handleBrokerError(err) } @@ -385,7 +391,7 @@ IMPORTANT: Check this BEFORE making new trading decisions.`, execute: async ({ hash }) => { for (const uta of await manager.resolve()) { const commit = await uta.show(hash) - if (commit) return { source: uta.id, ...commit } + if (commit) return { source: uta.id, ...compactCommit(commit) } } return { error: `Commit ${hash} not found in any account` } }, @@ -396,7 +402,7 @@ IMPORTANT: Check this BEFORE making new trading decisions.`, inputSchema: z.object({ source: z.string().optional().describe(sourceDesc(false)) }).meta({ examples: [{ source: 'alpaca-paper' }] }), execute: async ({ source }) => { const targets = await manager.resolve(source) - const results = await Promise.all(targets.map(async (uta) => ({ source: uta.id, ...await uta.status() }))) + const results = await Promise.all(targets.map(async (uta) => ({ source: uta.id, ...compactStatus(await uta.status()) }))) return results.length === 1 ? results[0] : results }, }), @@ -459,7 +465,7 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, limitPrice: z.string().optional().describe('Limit price for stop-limit SL (omit for stop-market)'), }).optional().describe('Stop loss order (single-level, full quantity)'), }).meta({ examples: [{ source: 'alpaca-paper', aliceId: 'alpaca-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: '1' }] }), - execute: async ({ source, ...params }) => (await manager.resolveOne(source)).stagePlaceOrder(params), + execute: async ({ source, ...params }) => compactStageResult(await (await manager.resolveOne(source)).stagePlaceOrder(params)), }), modifyOrder: tool({ @@ -476,7 +482,7 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, tif: z.enum(['DAY', 'GTC', 'IOC', 'FOK', 'OPG', 'GTD']).optional().describe('New time in force'), goodTillDate: z.string().optional().describe('New expiration date'), }).meta({ examples: [{ source: 'alpaca-paper', orderId: '1', lmtPrice: '150' }] }), - execute: async ({ source, ...params }) => (await manager.resolveOne(source)).stageModifyOrder(params), + execute: async ({ source, ...params }) => compactStageResult(await (await manager.resolveOne(source)).stageModifyOrder(params)), }), closePosition: tool({ @@ -487,7 +493,7 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, symbol: z.string().optional().describe('Human-readable symbol. Optional.'), qty: positiveNumeric.optional().describe('Number of shares to sell. Decimal string. Default: sell all.'), }).meta({ examples: [{ source: 'alpaca-paper', aliceId: 'alpaca-paper|AAPL' }] }), - execute: async ({ source, ...params }) => (await manager.resolveOne(source)).stageClosePosition(params), + execute: async ({ source, ...params }) => compactStageResult(await (await manager.resolveOne(source)).stageClosePosition(params)), }), cancelOrder: tool({ @@ -496,7 +502,7 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, source: z.string().describe(sourceDesc(true)), orderId: z.string().describe('Order ID to cancel'), }).meta({ examples: [{ source: 'alpaca-paper', orderId: '1' }] }), - execute: async ({ source, orderId }) => (await manager.resolveOne(source)).stageCancelOrder({ orderId }), + execute: async ({ source, orderId }) => compactStageResult(await (await manager.resolveOne(source)).stageCancelOrder({ orderId })), }), tradingCommit: tool({ @@ -532,7 +538,7 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, if (uncommitted.length > 0) { return { error: 'You have staged operations that are NOT committed yet. Call tradingCommit first, then tradingPush.', - uncommitted: uncommitted.map(({ uta, status }) => ({ source: uta.id, staged: status.staged })), + uncommitted: uncommitted.map(({ uta, status }) => ({ source: uta.id, staged: status.staged.map(compactOperation) })), } } return { message: 'No committed operations to push.' } @@ -541,12 +547,73 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, message: 'Push requires manual approval. The user can approve pending operations from any connected channel (Web UI, Telegram /trading, etc).', pending: pending.map(({ uta, status }) => ({ source: uta.id, - ...status, + ...compactStatus(status), })), } }, }), + tradingReject: tool({ + description: 'Discard staged (and committed-but-unpushed) operations — the undo for a wrong stage (like "git reset"). Nothing is sent to the broker; the rejection is recorded in the trading log.', + inputSchema: z.object({ + source: z.string().describe(sourceDesc(true)), + reason: z.string().optional().describe('Why the staged operations are being discarded'), + }).meta({ examples: [{ source: 'alpaca-paper', reason: 'wrong limit price' }] }), + execute: async ({ source, reason }) => { + try { + const uta = await manager.resolveOne(source) + const status = await uta.status() + if (status.staged.length === 0) return { message: 'Nothing staged to reject.' } + // reject() requires a prepared commit — prepare one transparently + // so the AI's mental model stays "stage → reject = undo". + if (!status.pendingHash) await uta.commit(reason ?? 'discarding staged operations') + return { source: uta.id, ...await uta.reject(reason) } + } catch (err) { + return handleBrokerError(err) + } + }, + }), + + orderHistory: tool({ + description: 'Order history — one row per order with its lifecycle collapsed (submitted → filled/cancelled/rejected, fill price+qty, source "external" for orders placed outside Alice). Prefer this over tradingLog when analyzing what happened to orders.', + inputSchema: z.object({ + source: z.string().optional().describe(sourceDesc(false)), + limit: z.number().int().min(1).max(200).optional().describe('Max rows per account (default 50)'), + }).meta({ examples: [{ source: 'alpaca-paper', limit: 20 }] }), + execute: async ({ source, limit }) => { + const targets = await manager.resolve(source) + if (targets.length === 0) return { error: 'No accounts available.' } + try { + const all = (await Promise.all(targets.map(async (uta) => + (await uta.orderHistory(limit ?? 50)).map((o) => ({ account: uta.id, ...o })), + ))).flat() + return all.length === 0 ? { orders: [], message: 'No order history yet.' } : all + } catch (err) { + return handleBrokerError(err) + } + }, + }), + + tradeHistory: tool({ + description: 'Trade history — fills only, with execution price/qty/value. Entries with source "reconcile" are balance drift folded in at observed price (external transfers, fees), not real fills.', + inputSchema: z.object({ + source: z.string().optional().describe(sourceDesc(false)), + limit: z.number().int().min(1).max(200).optional().describe('Max rows per account (default 50)'), + }).meta({ examples: [{ source: 'alpaca-paper', limit: 20 }] }), + execute: async ({ source, limit }) => { + const targets = await manager.resolve(source) + if (targets.length === 0) return { error: 'No accounts available.' } + try { + const all = (await Promise.all(targets.map(async (uta) => + (await uta.tradeHistory(limit ?? 50)).map((t) => ({ account: uta.id, ...t })), + ))).flat() + return all.length === 0 ? { trades: [], message: 'No trades yet.' } : all + } catch (err) { + return handleBrokerError(err) + } + }, + }), + tradingSync: tool({ description: 'Sync pending order statuses from broker (like "git pull"). Use delayMs to wait before querying — exchanges may need a few seconds to settle after order placement.', inputSchema: z.object({ From fe8a3c62c5474b5fd1e9ffbe2ef78764b262c0d8 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 12 Jun 2026 08:44:23 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(tool):=20dogfood=20findings=20=E2=80=94?= =?UTF-8?q?=20search=20false-negative,=20stale=20approval=20channel,=20sel?= =?UTF-8?q?f-correcting=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Used alice-uta end-to-end as the trading agent it serves. Three more catches: 1. Per-account contract search ALWAYS returned [] — the SDK assumed the aggregated /contracts/search returns grouped {id, results[]} rows, but the route returns flat {source, contract} rows; the find() never matched. Worst possible failure for an agent: "SOL isn't tradeable" (it was). Shape aligned + search results now compact their contracts like every other output. 2. The tradingPush approval-wall message told agents users could approve via "Telegram /trading" — the Telegram connector was deleted in 0.30.0. Agents were relaying dead instructions. Now points at the Web UI surfaces that exist. 3. resolve(source) misses said "No accounts available." — false when accounts exist and the SOURCE didn't match. Now: 'Unknown source "okx-banana". Available accounts: …' — the agent self-corrects in one step instead of concluding the broker layer is down. Co-Authored-By: Claude Fable 5 --- src/services/uta-client/UTAAccountSDK.ts | 15 ++++++---- src/tool/trading.ts | 36 +++++++++++++++++------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/services/uta-client/UTAAccountSDK.ts b/src/services/uta-client/UTAAccountSDK.ts index 2da0d0f8..1d9dde0c 100644 --- a/src/services/uta-client/UTAAccountSDK.ts +++ b/src/services/uta-client/UTAAccountSDK.ts @@ -168,13 +168,18 @@ export class UTAAccountSDK { } searchContracts(pattern: string): Promise { - // The existing `/api/trading/contracts/search` is aggregated across - // accounts; per-account search isn't a route yet. Fall back to the - // aggregated endpoint and filter by id. Route added in Step 6 follow-up. + // The `/api/trading/contracts/search` route is aggregated across + // accounts and returns FLAT rows `{ source, contract, ... }` — one per + // hit, tagged with the owning account. (An earlier SDK version assumed + // a grouped `{ id, results[] }` shape; the find() never matched and + // every per-account search silently returned [] — an analysis-killing + // false negative: "SOL isn't tradeable" when it plainly was.) return this.client - .get<{ results: Array<{ id: string; results: ContractDescription[] }> }>( + .get<{ results: Array<{ source: string } & ContractDescription> }>( `/api/trading/contracts/search`, { pattern }) - .then((r) => r.results.find((b) => b.id === this.id)?.results ?? []) + .then((r) => r.results + .filter((row) => row.source === this.id) + .map(({ source: _source, ...desc }) => desc as ContractDescription)) } // ==================== Contract details ==================== diff --git a/src/tool/trading.ts b/src/tool/trading.ts index b06856bd..4f4eb433 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -114,6 +114,22 @@ const positiveNumeric = z ) .transform((v) => (v === '' ? undefined : v)) + +/** Distinguish "no accounts configured" from "your source matched nothing" + * — and list what WOULD match, so the agent self-corrects in one step + * instead of concluding no accounts exist. */ +async function noAccountsError(manager: UTAManagerSDK, source?: string): Promise<{ error: string }> { + try { + const ids = (await manager.listUTAs()).map((u: { id: string }) => u.id) + if (source && ids.length > 0) { + return { error: `Unknown source "${source}". Available accounts: ${ids.join(', ')}.` } + } + return { error: ids.length === 0 ? 'No trading accounts configured.' : 'No accounts available.' } + } catch { + return { error: 'No accounts available.' } + } +} + export function createTradingTools(manager: UTAManagerSDK): Record { return { listUTAs: tool({ @@ -144,14 +160,14 @@ hitting the broker, which otherwise expects the bare base ticker.`, // Source-scoped: when the caller pinned an account, only that one is // hit; otherwise fan out to all configured accounts. const targets = await manager.resolve(source) - if (targets.length === 0) return { error: 'No accounts available.' } + if (targets.length === 0) return await noAccountsError(manager, source) const all: Array> = [] const settled = await Promise.allSettled( targets.map(async (uta) => ({ id: uta.id, results: await uta.searchContracts(brokerPattern) })), ) for (const r of settled) { if (r.status !== 'fulfilled') continue - for (const desc of r.value.results) all.push({ source: r.value.id, ...desc }) + for (const desc of r.value.results) all.push({ source: r.value.id, ...desc, contract: compactContract((desc as { contract?: unknown }).contract) }) } if (all.length === 0) return { results: [], message: `No contracts found matching "${brokerPattern}" (input: "${pattern}").` } return all @@ -197,7 +213,7 @@ If this tool returns an error with transient=true, wait a few seconds and retry }).meta({ examples: [{ source: 'alpaca-paper' }] }), execute: async ({ source }) => { const targets = await manager.resolve(source) - if (targets.length === 0) return { error: 'No accounts available.' } + if (targets.length === 0) return await noAccountsError(manager, source) try { const results = await Promise.all(targets.map(async (uta) => ({ source: uta.id, ...compactAccountInfo(await uta.getAccount()) }))) return results.length === 1 ? results[0] : results @@ -216,7 +232,7 @@ If this tool returns an error with transient=true, wait a few seconds and retry }).meta({ examples: [{ source: 'alpaca-paper' }] }), execute: async ({ source, symbol }) => { const targets = await manager.resolve(source) - if (targets.length === 0) return { positions: [], message: 'No accounts available.' } + if (targets.length === 0) return { positions: [], ...(await noAccountsError(manager, source)) } // FX rates table — UTA's /fx-rates collects every currency in // use server-side and returns a flat lookup. Locally we treat // missing rates as 1.0 (the broker probably reported a USD-side @@ -356,7 +372,7 @@ If this tool returns an error with transient=true, wait a few seconds and retry inputSchema: z.object({ source: z.string().optional().describe(sourceDesc(false)) }).meta({ examples: [{ source: 'alpaca-paper' }] }), execute: async ({ source }) => { const targets = await manager.resolve(source) - if (targets.length === 0) return { error: 'No accounts available.' } + if (targets.length === 0) return await noAccountsError(manager, source) try { const results = await Promise.all(targets.map(async (uta) => ({ source: uta.id, ...await uta.getMarketClock() }))) return results.length === 1 ? results[0] : results @@ -418,7 +434,7 @@ IMPORTANT: Check this BEFORE making new trading decisions.`, }).meta({ examples: [{ source: 'alpaca-paper', priceChanges: [{ symbol: 'AAPL', change: '+10%' }] }] }), execute: async ({ source, priceChanges }) => { const targets = await manager.resolve(source) - if (targets.length === 0) return { error: 'No accounts available.' } + if (targets.length === 0) return await noAccountsError(manager, source) const results: Array> = [] for (const uta of targets) results.push({ source: uta.id, ...await uta.simulatePriceChange(priceChanges) }) return results.length === 1 ? results[0] : results @@ -525,7 +541,7 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, }), tradingPush: tool({ - description: 'Trading push requires manual approval — call tradingStatus to show the user what is pending, then tell them to approve (via Web UI, Telegram /trading, or other connected channels).', + description: 'Trading push requires manual approval — call tradingStatus to show the user what is pending, then ask them to approve it on the Web UI (Trading as Git page, or the account detail page).', inputSchema: z.object({ source: z.string().optional().describe(sourceDesc(false, 'If omitted, checks all accounts.')), }).meta({ examples: [{ source: 'alpaca-paper' }] }), @@ -544,7 +560,7 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, return { message: 'No committed operations to push.' } } return { - message: 'Push requires manual approval. The user can approve pending operations from any connected channel (Web UI, Telegram /trading, etc).', + message: 'Push requires manual approval. Tell the user to review and approve the pending operations in the Web UI (Trading as Git page, or the account detail page).', pending: pending.map(({ uta, status }) => ({ source: uta.id, ...compactStatus(status), @@ -582,7 +598,7 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, }).meta({ examples: [{ source: 'alpaca-paper', limit: 20 }] }), execute: async ({ source, limit }) => { const targets = await manager.resolve(source) - if (targets.length === 0) return { error: 'No accounts available.' } + if (targets.length === 0) return await noAccountsError(manager, source) try { const all = (await Promise.all(targets.map(async (uta) => (await uta.orderHistory(limit ?? 50)).map((o) => ({ account: uta.id, ...o })), @@ -602,7 +618,7 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, }).meta({ examples: [{ source: 'alpaca-paper', limit: 20 }] }), execute: async ({ source, limit }) => { const targets = await manager.resolve(source) - if (targets.length === 0) return { error: 'No accounts available.' } + if (targets.length === 0) return await noAccountsError(manager, source) try { const all = (await Promise.all(targets.map(async (uta) => (await uta.tradeHistory(limit ?? 50)).map((t) => ({ account: uta.id, ...t })), From ec7e7add1b4d99241328e46173e0660b6607a674 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 12 Jun 2026 09:01:21 +0800 Subject: [PATCH 3/3] fix(uta,tool,cli): TPSL refusal gate + the trading-as-git ergonomics sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline catch from a full pre-trade→TPSL dogfood run: an attached TP/SL order on okx spot STAGED fine, PUSHED fine, FILLED fine — and the stop never existed. ccxt accepted the unified takeProfit/stopLoss params and the venue mapping silently dropped them: the ledger said "long with a stop", the exchange said "naked long". CcxtBroker now REFUSES tpsl placement unless the exchange has a venue-verified placeOrderWithTpSl override (none yet — okx needs attachAlgoOrds, bybit its native v5 fields), with an actionable message. A missing stop that looks attached is the worst failure mode a trading system has; loud refusal beats silent downgrade. CLI shim (all four byte-identical copies): - JSON-looking flag values parse into objects — `--takeProfit '{"price":"1725"}'` previously shipped as a raw string and failed validation opaquely. - Gateway validation errors now surface field-level zod issues, and the shim prints them — "Validation failed" alone stranded the agent guessing flag names. Trading-as-git tool ergonomics (from the same run): - placeOrder/closePosition/modifyOrder/cancelOrder accept commitMessage — stage AND commit in one call (one decision = one operation is the dominant agent flow; the push approval wall is untouched). - placeOrder/closePosition derive source from aliceId when omitted — the account id was always inside the aliceId. - compactStatus renames the overloaded "pending*" fields to awaitingApproval (pending ORDERS are a different thing). - Sync commit messages carry symbols: "[sync] ETH filled" instead of "[sync] 1 order(s) updated". Co-Authored-By: Claude Fable 5 --- .../domain/trading/brokers/ccxt/CcxtBroker.ts | 34 ++++++++++-- .../domain/trading/brokers/ccxt/overrides.ts | 18 +++++++ .../uta/src/domain/trading/git/TradingGit.ts | 2 +- src/server/cli.ts | 7 ++- src/tool/trading-compact.spec.ts | 11 ++-- src/tool/trading-compact.ts | 7 ++- src/tool/trading.ts | 52 +++++++++++++++---- src/workspaces/cli/bin/alice | 13 ++++- src/workspaces/cli/bin/alice-uta | 13 ++++- src/workspaces/cli/bin/alice-workspace | 13 ++++- src/workspaces/cli/bin/traderhub | 13 ++++- 11 files changed, 156 insertions(+), 27 deletions(-) diff --git a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts index 0b42ea78..4184f367 100644 --- a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -440,6 +440,29 @@ export class CcxtBroker implements IBroker { return { success: false, error: 'Either totalQuantity or cashQty must be provided' } } + // Attached TP/SL on CCXT venues: REFUSE until a per-exchange override + // has verified the attach actually reaches the venue. Observed live on + // okx spot: the unified takeProfit/stopLoss params were accepted by + // ccxt, silently dropped at the venue mapping, and the entry filled + // UNPROTECTED — the ledger said "long with a stop", the exchange said + // "naked long". A missing stop that looks attached is the worst failure + // mode a trading system has; loud refusal beats silent downgrade + // (same rule as order-type support). Venue-verified attach + // implementations land via the overrides registry (fetchAllOpenOrders + // pattern) — okx needs attachAlgoOrds, bybit its native v5 fields. + if (tpsl?.takeProfit || tpsl?.stopLoss) { + const attachOverride = this.overrides.placeOrderWithTpSl + if (!attachOverride) { + return { + success: false, + error: + `Attached TP/SL is not verified to reach ${this.exchangeName} through ccxt — refusing rather than ` + + `risking a silently unprotected position. Place the entry first, then a separate stop/take-profit ` + + `order, or use a venue with verified attach support.`, + } + } + } + try { const params: Record = { ...extraParams } @@ -460,10 +483,15 @@ export class CcxtBroker implements IBroker { ? order.lmtPrice.toNumber() : undefined + const attachOverride = this.overrides.placeOrderWithTpSl const placeOverride = this.overrides.placeOrder - const ccxtOrder = placeOverride - ? await placeOverride(this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), refPrice, params, defaultPlaceOrder) - : await defaultPlaceOrder(this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), refPrice, params) + const ccxtOrder = (tpsl?.takeProfit || tpsl?.stopLoss) && attachOverride + // Venue-verified attach path — the gate above guarantees tpsl only + // gets this far when the exchange has an override for it. + ? await attachOverride(this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), refPrice, tpsl, params) + : placeOverride + ? await placeOverride(this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), refPrice, params, defaultPlaceOrder) + : await defaultPlaceOrder(this.exchange, ccxtSymbol, ccxtOrderType, side, parseFloat(size), refPrice, params) // Cache orderId → symbol if (ccxtOrder.id) { diff --git a/services/uta/src/domain/trading/brokers/ccxt/overrides.ts b/services/uta/src/domain/trading/brokers/ccxt/overrides.ts index 407cecf5..c5e6ce72 100644 --- a/services/uta/src/domain/trading/brokers/ccxt/overrides.ts +++ b/services/uta/src/domain/trading/brokers/ccxt/overrides.ts @@ -72,6 +72,24 @@ export interface CcxtExchangeOverrides { defaultImpl: DefaultImpl<[Exchange], CcxtPosition[]>, ): Promise + /** Place an order WITH attached TP/SL, venue-verified. CcxtBroker + * refuses tpsl placement entirely when an exchange has no such + * override — observed live: ccxt's unified takeProfit/stopLoss params + * were silently dropped on okx spot and the entry filled unprotected. + * Implementations must map to the venue's real attach mechanism (okx: + * attachAlgoOrds; bybit: v5 takeProfit/stopLoss fields) and be verified + * live before registering. */ + placeOrderWithTpSl?( + exchange: Exchange, + symbol: string, + type: string, + side: 'buy' | 'sell', + amount: number, + price: number | undefined, + tpsl: { takeProfit?: { price: string }; stopLoss?: { price: string; limitPrice?: string } }, + params: Record, + ): Promise + /** List ALL open orders across every market type the account trades. * Override when the venue's listing endpoint is category-scoped and the * unscoped call silently returns a subset (bybit: defaultType 'swap' diff --git a/services/uta/src/domain/trading/git/TradingGit.ts b/services/uta/src/domain/trading/git/TradingGit.ts index 4137f202..983c5976 100644 --- a/services/uta/src/domain/trading/git/TradingGit.ts +++ b/services/uta/src/domain/trading/git/TradingGit.ts @@ -566,7 +566,7 @@ export class TradingGit implements ITradingGit { const commit: GitCommit = { hash, parentHash: this.head, - message: `[sync] ${updates.length} order(s) updated`, + message: `[sync] ${updates.slice(0, 3).map((u) => `${u.symbol} ${u.currentStatus}`).join(', ')}${updates.length > 3 ? ` +${updates.length - 3} more` : ''}`, operations: [{ action: 'syncOrders' as const }], results: updates.map((u) => ({ action: 'syncOrders' as const, diff --git a/src/server/cli.ts b/src/server/cli.ts index 4e2bff8d..39e138c8 100644 --- a/src/server/cli.ts +++ b/src/server/cli.ts @@ -167,7 +167,12 @@ export function registerCliRoutes(app: Hono, deps: CliGatewayDeps): void { try { validated = await schema.parseAsync(rawArgs) } catch (err) { - return c.json({ error: 'Validation failed', details: String(err) }, 400) + // Field-level issues, not String(ZodError) — an agent reading + // "Validation failed" alone is stranded guessing flag names/shapes. + const details = err instanceof z.ZodError + ? err.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('\n') + : String(err) + return c.json({ error: 'Validation failed', details }, 400) } const result = await wrapToolExecute(tool)(validated) diff --git a/src/tool/trading-compact.spec.ts b/src/tool/trading-compact.spec.ts index 71dec79d..ecb23c2f 100644 --- a/src/tool/trading-compact.spec.ts +++ b/src/tool/trading-compact.spec.ts @@ -91,12 +91,17 @@ describe('compactOperation / compactStatus / compactResult', () => { expect(r).toEqual({ action: 'placeOrder', success: false, status: 'rejected', error: 'price band', rejectReason: 'okx 51138' }) }) - it('compactStatus compacts staged ops and passes scalars through', () => { - const s = compactStatus({ + it('compactStatus compacts staged ops and renames pending→awaitingApproval', () => { + const idle = compactStatus({ staged: [{ action: 'cancelOrder', orderId: 'o1' }], pendingMessage: null, pendingHash: null, head: 'abc', commitCount: 5, }) - expect(s).toEqual({ staged: [{ action: 'cancelOrder', orderId: 'o1' }], pendingMessage: null, pendingHash: null, head: 'abc', commitCount: 5 }) + expect(idle).toEqual({ staged: [{ action: 'cancelOrder', orderId: 'o1' }], awaitingApproval: null, head: 'abc', commitCount: 5 }) + + const committed = compactStatus({ + staged: [], pendingMessage: 'long ETH', pendingHash: 'h1', head: 'abc', commitCount: 5, + }) + expect(committed.awaitingApproval).toEqual({ message: 'long ETH', hash: 'h1' }) }) }) diff --git a/src/tool/trading-compact.ts b/src/tool/trading-compact.ts index 1f8b4ce1..6c498a8f 100644 --- a/src/tool/trading-compact.ts +++ b/src/tool/trading-compact.ts @@ -167,10 +167,13 @@ export function compactResult(r: unknown): AnyRec { export function compactStatus(status: unknown): AnyRec { if (!status || typeof status !== 'object') return {} const k = status as AnyRec + // "pending" is overloaded in trading (pending ORDERS = working on the + // exchange) — at the agent boundary the committed-not-pushed state is + // named what it is: awaiting approval. + const msg = k['pendingMessage'] return { staged: Array.isArray(k['staged']) ? k['staged'].map(compactOperation) : [], - pendingMessage: k['pendingMessage'] ?? null, - pendingHash: k['pendingHash'] ?? null, + awaitingApproval: msg ? { message: msg, hash: k['pendingHash'] ?? null } : null, head: k['head'] ?? null, commitCount: k['commitCount'], } diff --git a/src/tool/trading.ts b/src/tool/trading.ts index 4f4eb433..e669c817 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -130,6 +130,24 @@ async function noAccountsError(manager: UTAManagerSDK, source?: string): Promise } } + +/** Stage + (optionally) commit in one call. The stage→commit split is pure + * ceremony when one decision = one operation — which is the dominant agent + * flow. The approval wall (push) is untouched. */ +async function stageAndMaybeCommit( + uta: { stage: () => Promise | unknown; commit: (msg: string) => Promise | unknown }, + commitMessage?: string, +): Promise> { + const staged = compactStageResult(await uta.stage()) + if (!commitMessage) return staged + const committed = await uta.commit(commitMessage) as Record + return { + ...staged, + committed: { hash: committed['hash'], message: committed['message'] }, + nextStep: 'Awaiting user approval — they approve in the Web UI (push executes there).', + } +} + export function createTradingTools(manager: UTAManagerSDK): Record { return { listUTAs: tool({ @@ -457,7 +475,7 @@ Required params by orderType: MOC: totalQuantity Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, inputSchema: z.object({ - source: z.string().describe(sourceDesc(true)), + source: z.string().optional().describe(sourceDesc(false, 'Defaults to the account inside aliceId.')), aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), symbol: z.string().optional().describe('Human-readable symbol (optional, for display only)'), action: z.enum(['BUY', 'SELL']).describe('Order direction'), @@ -480,8 +498,12 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, price: z.string().describe('Stop loss trigger price'), limitPrice: z.string().optional().describe('Limit price for stop-limit SL (omit for stop-market)'), }).optional().describe('Stop loss order (single-level, full quantity)'), - }).meta({ examples: [{ source: 'alpaca-paper', aliceId: 'alpaca-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: '1' }] }), - execute: async ({ source, ...params }) => compactStageResult(await (await manager.resolveOne(source)).stagePlaceOrder(params)), + commitMessage: z.string().optional().describe('Stage AND commit in one step with this message (your trading thesis). Push/approval still required.'), + }).meta({ examples: [{ aliceId: 'alpaca-paper|AAPL', action: 'BUY', orderType: 'MKT', totalQuantity: '1', commitMessage: 'Entry: momentum breakout' }] }), + execute: async ({ source, commitMessage, ...params }) => { + const uta = await manager.resolveOne(source ?? parseAliceId(params.aliceId)?.utaId ?? '') + return stageAndMaybeCommit({ stage: () => uta.stagePlaceOrder(params), commit: (m) => uta.commit(m) }, commitMessage) + }, }), modifyOrder: tool({ @@ -497,19 +519,27 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, orderType: z.enum(['MKT', 'LMT', 'STP', 'STP LMT', 'TRAIL', 'TRAIL LIMIT', 'MOC']).optional().describe('New order type'), tif: z.enum(['DAY', 'GTC', 'IOC', 'FOK', 'OPG', 'GTD']).optional().describe('New time in force'), goodTillDate: z.string().optional().describe('New expiration date'), + commitMessage: z.string().optional().describe('Stage AND commit in one step with this message. Push/approval still required.'), }).meta({ examples: [{ source: 'alpaca-paper', orderId: '1', lmtPrice: '150' }] }), - execute: async ({ source, ...params }) => compactStageResult(await (await manager.resolveOne(source)).stageModifyOrder(params)), + execute: async ({ source, commitMessage, ...params }) => { + const uta = await manager.resolveOne(source) + return stageAndMaybeCommit({ stage: () => uta.stageModifyOrder(params), commit: (m) => uta.commit(m) }, commitMessage) + }, }), closePosition: tool({ description: 'Stage a position close.\nNOTE: This stages the operation. Call tradingCommit + tradingPush to execute.', inputSchema: z.object({ - source: z.string().describe(sourceDesc(true)), + source: z.string().optional().describe(sourceDesc(false, 'Defaults to the account inside aliceId.')), aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), symbol: z.string().optional().describe('Human-readable symbol. Optional.'), qty: positiveNumeric.optional().describe('Number of shares to sell. Decimal string. Default: sell all.'), - }).meta({ examples: [{ source: 'alpaca-paper', aliceId: 'alpaca-paper|AAPL' }] }), - execute: async ({ source, ...params }) => compactStageResult(await (await manager.resolveOne(source)).stageClosePosition(params)), + commitMessage: z.string().optional().describe('Stage AND commit in one step with this message. Push/approval still required.'), + }).meta({ examples: [{ aliceId: 'alpaca-paper|AAPL', commitMessage: 'Exit: thesis invalidated' }] }), + execute: async ({ source, commitMessage, ...params }) => { + const uta = await manager.resolveOne(source ?? parseAliceId(params.aliceId)?.utaId ?? '') + return stageAndMaybeCommit({ stage: () => uta.stageClosePosition(params), commit: (m) => uta.commit(m) }, commitMessage) + }, }), cancelOrder: tool({ @@ -517,8 +547,12 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), orderId: z.string().describe('Order ID to cancel'), - }).meta({ examples: [{ source: 'alpaca-paper', orderId: '1' }] }), - execute: async ({ source, orderId }) => compactStageResult(await (await manager.resolveOne(source)).stageCancelOrder({ orderId })), + commitMessage: z.string().optional().describe('Stage AND commit in one step with this message. Push/approval still required.'), + }).meta({ examples: [{ source: 'alpaca-paper', orderId: '1', commitMessage: 'Cancel: stale level' }] }), + execute: async ({ source, orderId, commitMessage }) => { + const uta = await manager.resolveOne(source) + return stageAndMaybeCommit({ stage: () => uta.stageCancelOrder({ orderId }), commit: (m) => uta.commit(m) }, commitMessage) + }, }), tradingCommit: tool({ diff --git a/src/workspaces/cli/bin/alice b/src/workspaces/cli/bin/alice index 14507af7..465d2419 100755 --- a/src/workspaces/cli/bin/alice +++ b/src/workspaces/cli/bin/alice @@ -87,8 +87,11 @@ async function invoke(base, tool, args) { body: JSON.stringify({ tool, args }), }) if (!r.ok) { - const msg = r.body && r.body.error ? r.body.error : `HTTP ${r.status}` - fail(typeof msg === 'string' ? msg : JSON.stringify(msg)) + let msg = r.body && r.body.error ? r.body.error : `HTTP ${r.status}` + if (typeof msg !== 'string') msg = JSON.stringify(msg) + const details = r.body && r.body.details + if (details) msg += '\n' + (typeof details === 'string' ? details : JSON.stringify(details, null, 2)) + fail(msg) } const blocks = (r.body && r.body.content) || [] return blocks @@ -137,6 +140,12 @@ function parseFlags(tokens) { i++ } } + // JSON-looking values parse into objects/arrays so object flags work: + // --takeProfit '{"price":"1725"}' + // Parse failures fall through as plain strings (gateway validates). + if (typeof val === 'string' && (val.startsWith('{') || val.startsWith('['))) { + try { val = JSON.parse(val) } catch { /* keep the raw string */ } + } if (key === 'meta') { // repeatable: --meta key=value -> metadataFilter const e = val.indexOf('=') diff --git a/src/workspaces/cli/bin/alice-uta b/src/workspaces/cli/bin/alice-uta index 14507af7..465d2419 100755 --- a/src/workspaces/cli/bin/alice-uta +++ b/src/workspaces/cli/bin/alice-uta @@ -87,8 +87,11 @@ async function invoke(base, tool, args) { body: JSON.stringify({ tool, args }), }) if (!r.ok) { - const msg = r.body && r.body.error ? r.body.error : `HTTP ${r.status}` - fail(typeof msg === 'string' ? msg : JSON.stringify(msg)) + let msg = r.body && r.body.error ? r.body.error : `HTTP ${r.status}` + if (typeof msg !== 'string') msg = JSON.stringify(msg) + const details = r.body && r.body.details + if (details) msg += '\n' + (typeof details === 'string' ? details : JSON.stringify(details, null, 2)) + fail(msg) } const blocks = (r.body && r.body.content) || [] return blocks @@ -137,6 +140,12 @@ function parseFlags(tokens) { i++ } } + // JSON-looking values parse into objects/arrays so object flags work: + // --takeProfit '{"price":"1725"}' + // Parse failures fall through as plain strings (gateway validates). + if (typeof val === 'string' && (val.startsWith('{') || val.startsWith('['))) { + try { val = JSON.parse(val) } catch { /* keep the raw string */ } + } if (key === 'meta') { // repeatable: --meta key=value -> metadataFilter const e = val.indexOf('=') diff --git a/src/workspaces/cli/bin/alice-workspace b/src/workspaces/cli/bin/alice-workspace index 14507af7..465d2419 100755 --- a/src/workspaces/cli/bin/alice-workspace +++ b/src/workspaces/cli/bin/alice-workspace @@ -87,8 +87,11 @@ async function invoke(base, tool, args) { body: JSON.stringify({ tool, args }), }) if (!r.ok) { - const msg = r.body && r.body.error ? r.body.error : `HTTP ${r.status}` - fail(typeof msg === 'string' ? msg : JSON.stringify(msg)) + let msg = r.body && r.body.error ? r.body.error : `HTTP ${r.status}` + if (typeof msg !== 'string') msg = JSON.stringify(msg) + const details = r.body && r.body.details + if (details) msg += '\n' + (typeof details === 'string' ? details : JSON.stringify(details, null, 2)) + fail(msg) } const blocks = (r.body && r.body.content) || [] return blocks @@ -137,6 +140,12 @@ function parseFlags(tokens) { i++ } } + // JSON-looking values parse into objects/arrays so object flags work: + // --takeProfit '{"price":"1725"}' + // Parse failures fall through as plain strings (gateway validates). + if (typeof val === 'string' && (val.startsWith('{') || val.startsWith('['))) { + try { val = JSON.parse(val) } catch { /* keep the raw string */ } + } if (key === 'meta') { // repeatable: --meta key=value -> metadataFilter const e = val.indexOf('=') diff --git a/src/workspaces/cli/bin/traderhub b/src/workspaces/cli/bin/traderhub index 14507af7..465d2419 100755 --- a/src/workspaces/cli/bin/traderhub +++ b/src/workspaces/cli/bin/traderhub @@ -87,8 +87,11 @@ async function invoke(base, tool, args) { body: JSON.stringify({ tool, args }), }) if (!r.ok) { - const msg = r.body && r.body.error ? r.body.error : `HTTP ${r.status}` - fail(typeof msg === 'string' ? msg : JSON.stringify(msg)) + let msg = r.body && r.body.error ? r.body.error : `HTTP ${r.status}` + if (typeof msg !== 'string') msg = JSON.stringify(msg) + const details = r.body && r.body.details + if (details) msg += '\n' + (typeof details === 'string' ? details : JSON.stringify(details, null, 2)) + fail(msg) } const blocks = (r.body && r.body.content) || [] return blocks @@ -137,6 +140,12 @@ function parseFlags(tokens) { i++ } } + // JSON-looking values parse into objects/arrays so object flags work: + // --takeProfit '{"price":"1725"}' + // Parse failures fall through as plain strings (gateway validates). + if (typeof val === 'string' && (val.startsWith('{') || val.startsWith('['))) { + try { val = JSON.parse(val) } catch { /* keep the raw string */ } + } if (key === 'meta') { // repeatable: --meta key=value -> metadataFilter const e = val.indexOf('=')