diff --git a/packages/uta-protocol/src/types/history.ts b/packages/uta-protocol/src/types/history.ts new file mode 100644 index 00000000..8b13f2f5 --- /dev/null +++ b/packages/uta-protocol/src/types/history.ts @@ -0,0 +1,76 @@ +/** + * Trader-facing projections of the Trading-as-Git commit log. + * + * The git log is the faithful narrative (operations + results + sync + * updates); these are the exchange-frontend views of it: Order History + * (every order's lifecycle collapsed to one row) and Trade History (fills + * only). Projection lives in the UTA domain so every surface — UI, MCP + * tools, CLI — reads the same translation. + * + * Contract fields follow the IBKR superset deliberately: options/futures + * (strike / right / expiry / multiplier) must render correctly the day + * they arrive, not get retrofitted around a crypto-shaped subset. + */ + +/** Compact contract identity for history rows — IBKR-superset fields. */ +export interface HistoryContract { + aliceId?: string + symbol?: string + localSymbol?: string + secType?: string + currency?: string + exchange?: string + /** OPT/FOP/FUT: contract month or expiry (IBKR lastTradeDateOrContractMonth). */ + expiry?: string + /** OPT/FOP: strike price (string — Decimal-safe). */ + strike?: string + /** OPT/FOP: 'C' | 'P' (normalized). */ + right?: string + multiplier?: string +} + +export type OrderHistoryStatus = 'submitted' | 'filled' | 'cancelled' | 'rejected' | 'user-rejected' + +export type OrderHistorySource = 'alice' | 'external' + +export interface OrderHistoryEntry { + /** Broker order id (absent for rejected-before-submit). */ + orderId?: string + /** When the order entered the log (push/observe time, ISO). */ + timestamp: string + /** When the terminal transition was recorded, if any (sync/cancel time, ISO). */ + resolvedAt?: string + contract: HistoryContract + side: 'BUY' | 'SELL' + orderType?: string + quantity?: string + limitPrice?: string + stopPrice?: string + status: OrderHistoryStatus + filledQty?: string + avgFillPrice?: string + /** 'external' = observed on the broker, not placed through Alice. */ + source: OrderHistorySource + /** Commit that introduced the order — the audit pointer. */ + commitHash: string + /** Commit message (user intent for Alice orders; [observed] for external). */ + message: string + error?: string +} + +export type TradeHistorySource = 'order' | 'external' | 'reconcile' + +export interface TradeHistoryEntry { + /** Fill record time (ISO) — push time for immediate fills, sync time otherwise. */ + timestamp: string + orderId?: string + contract: HistoryContract + side: 'BUY' | 'SELL' + quantity: string + price: string + /** quantity × price × multiplier (string — Decimal-safe). */ + value: string + /** 'reconcile' = balance drift folded in at observed price, not a real fill record. */ + source: TradeHistorySource + commitHash: string +} diff --git a/packages/uta-protocol/src/types/index.ts b/packages/uta-protocol/src/types/index.ts index 4d5def22..f6385bac 100644 --- a/packages/uta-protocol/src/types/index.ts +++ b/packages/uta-protocol/src/types/index.ts @@ -19,3 +19,4 @@ export * from './manager.js' // `aliceId?` field. Re-export as side-effect so consumers don't need a // separate import for the augmentation. import './contract-ext.js' +export * from './history.js' diff --git a/services/uta/src/domain/trading/order-history.spec.ts b/services/uta/src/domain/trading/order-history.spec.ts new file mode 100644 index 00000000..58103373 --- /dev/null +++ b/services/uta/src/domain/trading/order-history.spec.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest' +import Decimal from 'decimal.js' +import { Contract, Order } from '@traderalice/ibkr' +import { projectOrderHistory, projectTradeHistory } from './order-history.js' +import type { GitCommit, Operation, OperationResult } from './git/types.js' +import './contract-ext.js' + +let n = 0 +function commit(ops: Operation[], results: OperationResult[], message = 'test'): GitCommit { + return { + hash: `c${++n}`, + parentHash: null, + message, + operations: ops, + results, + stateAfter: { + netLiquidation: '0', totalCashValue: '0', unrealizedPnL: '0', realizedPnL: '0', + positions: [], pendingOrders: [], + }, + timestamp: new Date(1_700_000_000_000 + n * 60_000).toISOString(), + } +} + +function contract(over: Partial = {}): Contract { + const c = new Contract() + c.aliceId = 'okx|ETH/USDT' + c.symbol = 'ETH' + c.localSymbol = 'ETH/USDT' + c.secType = 'CRYPTO' + Object.assign(c, over) + return c +} + +function limitBuy(qty: string, price: string): Order { + const o = new Order() + o.action = 'BUY' + o.orderType = 'LMT' + o.totalQuantity = new Decimal(qty) + o.lmtPrice = new Decimal(price) + return o +} + +describe('projectOrderHistory', () => { + it('collapses place→sync-fill into one resolved row', () => { + const commits = [ + commit( + [{ action: 'placeOrder', contract: contract(), order: limitBuy('0.01', '1650') }], + [{ action: 'placeOrder', success: true, orderId: 'o1', status: 'submitted' }], + 'buy the dip', + ), + commit( + [{ action: 'syncOrders' }], + [{ action: 'syncOrders', success: true, orderId: 'o1', status: 'filled', filledQty: '0.01', filledPrice: '1648.5' }], + ), + ] + const rows = projectOrderHistory(commits) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + orderId: 'o1', + side: 'BUY', + orderType: 'LMT', + quantity: '0.01', + limitPrice: '1650', + status: 'filled', + filledQty: '0.01', + avgFillPrice: '1648.5', + source: 'alice', + message: 'buy the dip', + }) + expect(rows[0].resolvedAt).toBeDefined() + }) + + it('external observed orders are flagged and resolvable by cancel', () => { + const commits = [ + commit( + [{ action: 'observeExternalOrder', contract: contract(), order: limitBuy('1', '1500') }], + [{ action: 'observeExternalOrder', success: true, orderId: 'ext1', status: 'submitted' }], + '[observed] 1 external order(s)', + ), + commit( + [{ action: 'cancelOrder', orderId: 'ext1' }], + [{ action: 'cancelOrder', success: true, orderId: 'ext1', status: 'cancelled' }], + ), + ] + const rows = projectOrderHistory(commits) + expect(rows).toHaveLength(1) + expect(rows[0].source).toBe('external') + expect(rows[0].status).toBe('cancelled') + }) + + it('preserves IBKR-superset option fields on the contract', () => { + const opt = contract({ + aliceId: 'ibkr|AAPL 260717C300', symbol: 'AAPL', secType: 'OPT', + lastTradeDateOrContractMonth: '20260717', strike: 300, right: 'C', multiplier: '100', + }) + const commits = [ + commit( + [{ action: 'placeOrder', contract: opt, order: limitBuy('1', '12.5') }], + [{ action: 'placeOrder', success: true, orderId: 'opt1', status: 'submitted' }], + ), + ] + const row = projectOrderHistory(commits)[0] + expect(row.contract).toMatchObject({ + secType: 'OPT', expiry: '20260717', strike: '300', right: 'C', multiplier: '100', + }) + }) + + it('rejected-before-submit rows survive without an orderId', () => { + const commits = [ + commit( + [{ action: 'placeOrder', contract: contract(), order: limitBuy('0.01', '99999') }], + [{ action: 'placeOrder', success: false, status: 'rejected', error: 'price band' }], + ), + ] + const rows = projectOrderHistory(commits) + expect(rows).toHaveLength(1) + expect(rows[0].status).toBe('rejected') + expect(rows[0].error).toBe('price band') + }) +}) + +describe('projectTradeHistory', () => { + it('records sync fills once with side/contract joined from the origin', () => { + const commits = [ + commit( + [{ action: 'placeOrder', contract: contract(), order: limitBuy('0.01', '1650') }], + [{ action: 'placeOrder', success: true, orderId: 'o1', status: 'submitted' }], + ), + commit( + [{ action: 'syncOrders' }], + [{ action: 'syncOrders', success: true, orderId: 'o1', status: 'filled', filledQty: '0.01', filledPrice: '1648.5' }], + ), + ] + const trades = projectTradeHistory(commits) + expect(trades).toHaveLength(1) + expect(trades[0]).toMatchObject({ + orderId: 'o1', side: 'BUY', quantity: '0.01', price: '1648.5', value: '16.485', source: 'order', + }) + }) + + it('reconcile foldings are labeled, not disguised as fills', () => { + const commits = [ + commit( + [{ action: 'reconcileBalance', aliceId: 'okx|ETH/USDT', quantityDelta: '0.5', markPrice: '1700' }], + [{ action: 'reconcileBalance', success: true, status: 'filled', filledQty: '0.5', filledPrice: '1700' }], + ), + ] + const trades = projectTradeHistory(commits) + expect(trades).toHaveLength(1) + expect(trades[0].source).toBe('reconcile') + expect(trades[0].side).toBe('BUY') + }) + + it('does not double-count an origin fill against a redundant sync', () => { + const order = limitBuy('1', '100') + const commits = [ + commit( + [{ action: 'placeOrder', contract: contract(), order }], + [{ action: 'placeOrder', success: true, orderId: 'o2', status: 'filled', filledQty: '1', filledPrice: '100' }], + ), + commit( + [{ action: 'syncOrders' }], + [{ action: 'syncOrders', success: true, orderId: 'o2', status: 'filled', filledQty: '1', filledPrice: '100' }], + ), + ] + expect(projectTradeHistory(commits)).toHaveLength(1) + }) +}) diff --git a/services/uta/src/domain/trading/order-history.ts b/services/uta/src/domain/trading/order-history.ts new file mode 100644 index 00000000..8f60b5b0 --- /dev/null +++ b/services/uta/src/domain/trading/order-history.ts @@ -0,0 +1,263 @@ +/** + * Order/Trade history — the exchange-frontend projection of the git log. + * + * Same join discipline as cost-basis.ts: orders are introduced by + * placeOrder / observeExternalOrder / closePosition operations and + * resolved by either their own push result (immediate fills/rejects), + * a cancelOrder result, or a later syncOrders result carrying the same + * orderId. One row per order, lifecycle collapsed. + * + * Domain-level on purpose (route-thinness rule): the UI route, MCP tools + * and the CLI all read the same translation. + */ + +import Decimal from 'decimal.js' +import { Contract, Order, UNSET_DECIMAL, UNSET_DOUBLE } from '@traderalice/ibkr' +import type { + GitCommit, + HistoryContract, + OrderHistoryEntry, + OrderHistoryStatus, + TradeHistoryEntry, +} from '@traderalice/uta-protocol' + +function toHistoryContract(contract: Contract | undefined): HistoryContract { + if (!contract) return {} + const strike = contract.strike != null && contract.strike !== UNSET_DOUBLE && contract.strike > 0 + ? String(contract.strike) + : undefined + const right = contract.right === 'C' || contract.right === 'CALL' ? 'C' + : contract.right === 'P' || contract.right === 'PUT' ? 'P' + : undefined + return { + ...(contract.aliceId && { aliceId: contract.aliceId }), + ...(contract.symbol && { symbol: contract.symbol }), + ...(contract.localSymbol && { localSymbol: contract.localSymbol }), + ...(contract.secType && { secType: contract.secType }), + ...(contract.currency && { currency: contract.currency }), + ...(contract.exchange && { exchange: contract.exchange }), + ...(contract.lastTradeDateOrContractMonth && { expiry: contract.lastTradeDateOrContractMonth }), + ...(strike && { strike }), + ...(right && { right }), + ...(contract.multiplier && { multiplier: String(contract.multiplier) }), + } +} + +function decStr(value: Decimal | undefined | null): string | undefined { + if (value == null) return undefined + const d = value instanceof Decimal ? value : new Decimal(String(value)) + if (d.equals(UNSET_DECIMAL)) return undefined + return d.toFixed() +} + +function orderFields(order: Order | undefined): { + side: 'BUY' | 'SELL' + orderType?: string + quantity?: string + limitPrice?: string + stopPrice?: string +} { + const side = (order?.action ?? 'BUY').toUpperCase() === 'SELL' ? 'SELL' : 'BUY' + return { + side, + ...(order?.orderType && { orderType: order.orderType }), + ...(decStr(order?.totalQuantity) && { quantity: decStr(order?.totalQuantity) }), + ...(decStr(order?.lmtPrice) && { limitPrice: decStr(order?.lmtPrice) }), + ...(decStr(order?.auxPrice) && { stopPrice: decStr(order?.auxPrice) }), + } +} + +/** Project the commit log into one-row-per-order history, newest first. */ +export function projectOrderHistory(commits: GitCommit[], opts: { limit?: number } = {}): OrderHistoryEntry[] { + const byOrderId = new Map() + const anonymous: OrderHistoryEntry[] = [] // rejected-before-submit rows have no orderId + + for (const commit of commits) { + for (let i = 0; i < commit.operations.length; i++) { + const op = commit.operations[i] + const result = commit.results[i] + + if (op.action === 'placeOrder' || op.action === 'observeExternalOrder' || op.action === 'closePosition') { + const entry: OrderHistoryEntry = { + ...(result?.orderId && { orderId: result.orderId }), + timestamp: commit.timestamp, + contract: toHistoryContract(op.contract), + ...(op.action === 'closePosition' + ? { side: 'SELL' as const, orderType: 'MKT', ...(op.quantity != null && { quantity: String(op.quantity) }) } + : orderFields(op.order)), + status: (result?.status ?? 'rejected') as OrderHistoryStatus, + ...(result?.filledQty && { filledQty: result.filledQty }), + ...(result?.filledPrice && { avgFillPrice: result.filledPrice }), + source: op.action === 'observeExternalOrder' ? 'external' : 'alice', + commitHash: commit.hash, + message: commit.message, + ...(result?.error && { error: result.error }), + } + if (result?.orderId) byOrderId.set(result.orderId, entry) + else anonymous.push(entry) + continue + } + + // cancelOrder: resolve the referenced order; a cancel of an unknown + // order still deserves a row? No — without the originating op there's + // no contract/side context; the commit log keeps the raw record. + if (op.action === 'cancelOrder' && result?.success) { + const target = byOrderId.get(op.orderId) + if (target) { + target.status = 'cancelled' + target.resolvedAt = commit.timestamp + } + continue + } + } + + // Sync commits: one op, one result PER ORDER — resolve terminal states. + if (commit.operations.some((op) => op.action === 'syncOrders')) { + for (const result of commit.results) { + if (!result.orderId || !result.success) continue + const target = byOrderId.get(result.orderId) + if (!target) continue + target.status = result.status as OrderHistoryStatus + target.resolvedAt = commit.timestamp + if (result.filledQty) target.filledQty = result.filledQty + if (result.filledPrice) target.avgFillPrice = result.filledPrice + } + } + } + + const all = [...byOrderId.values(), ...anonymous] + all.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1)) + return opts.limit ? all.slice(0, opts.limit) : all +} + +/** Project fills only (real executions + reconcile foldings), newest first. */ +export function projectTradeHistory(commits: GitCommit[], opts: { limit?: number } = {}): TradeHistoryEntry[] { + // First pass: orderId → originating op (contract + side), cost-basis style. + const orderMeta = new Map() + for (const commit of commits) { + for (let i = 0; i < commit.operations.length; i++) { + const op = commit.operations[i] + const result = commit.results[i] + if (!result?.orderId) continue + if (op.action === 'placeOrder' || op.action === 'observeExternalOrder') { + orderMeta.set(result.orderId, { + contract: toHistoryContract(op.contract), + side: orderFields(op.order).side, + multiplier: op.contract?.multiplier ? String(op.contract.multiplier) : '1', + }) + } else if (op.action === 'closePosition') { + orderMeta.set(result.orderId, { + contract: toHistoryContract(op.contract), + side: 'SELL', + multiplier: op.contract?.multiplier ? String(op.contract.multiplier) : '1', + }) + } + } + } + + const trades: TradeHistoryEntry[] = [] + const counted = new Set() // orderId fills already recorded (origin vs sync) + + const push = (params: { + timestamp: string + orderId?: string + contract: HistoryContract + side: 'BUY' | 'SELL' + qty: string + price: string + multiplier?: string + source: TradeHistoryEntry['source'] + commitHash: string + }): void => { + const value = new Decimal(params.qty).mul(params.price).mul(params.multiplier || '1') + trades.push({ + timestamp: params.timestamp, + ...(params.orderId && { orderId: params.orderId }), + contract: params.contract, + side: params.side, + quantity: params.qty, + price: params.price, + value: value.toFixed(), + source: params.source, + commitHash: params.commitHash, + }) + } + + for (const commit of commits) { + const isSync = commit.operations.some((op) => op.action === 'syncOrders') + if (isSync) { + for (const result of commit.results) { + if (!result.orderId || result.status !== 'filled') continue + if (!result.filledQty || !result.filledPrice || counted.has(result.orderId)) continue + const meta = orderMeta.get(result.orderId) + if (!meta) continue + push({ + timestamp: commit.timestamp, + orderId: result.orderId, + contract: meta.contract, + side: meta.side, + qty: result.filledQty, + price: result.filledPrice, + multiplier: meta.multiplier, + source: commitSourceIsExternal(commits, result.orderId) ? 'external' : 'order', + commitHash: commit.hash, + }) + counted.add(result.orderId) + } + continue + } + + for (let i = 0; i < commit.operations.length; i++) { + const op = commit.operations[i] + const result = commit.results[i] + if (!result?.success) continue + + if (op.action === 'reconcileBalance') { + if (!result.filledQty || !result.filledPrice) continue + const delta = new Decimal(op.quantityDelta) + push({ + timestamp: commit.timestamp, + contract: { aliceId: op.aliceId }, + side: delta.gte(0) ? 'BUY' : 'SELL', + qty: result.filledQty, + price: result.filledPrice, + source: 'reconcile', + commitHash: commit.hash, + }) + continue + } + + if (op.action === 'placeOrder' || op.action === 'closePosition' || op.action === 'observeExternalOrder') { + if (result.status !== 'filled' || !result.filledQty || !result.filledPrice) continue + if (result.orderId && counted.has(result.orderId)) continue + push({ + timestamp: commit.timestamp, + ...(result.orderId && { orderId: result.orderId }), + contract: toHistoryContract(op.contract), + side: op.action === 'closePosition' ? 'SELL' : orderFields(op.action === 'placeOrder' || op.action === 'observeExternalOrder' ? op.order : undefined).side, + qty: result.filledQty, + price: result.filledPrice, + multiplier: op.contract?.multiplier ? String(op.contract.multiplier) : '1', + source: op.action === 'observeExternalOrder' ? 'external' : 'order', + commitHash: commit.hash, + }) + if (result.orderId) counted.add(result.orderId) + } + } + } + + trades.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1)) + return opts.limit ? trades.slice(0, opts.limit) : trades +} + +/** Whether an orderId originated from an observeExternalOrder operation. */ +function commitSourceIsExternal(commits: GitCommit[], orderId: string): boolean { + for (const commit of commits) { + for (let i = 0; i < commit.operations.length; i++) { + if (commit.results[i]?.orderId === orderId) { + return commit.operations[i].action === 'observeExternalOrder' + } + } + } + return false +} diff --git a/services/uta/src/http/routes-trading.ts b/services/uta/src/http/routes-trading.ts index a2a2ef35..2c595758 100644 --- a/services/uta/src/http/routes-trading.ts +++ b/services/uta/src/http/routes-trading.ts @@ -7,6 +7,7 @@ import type { UnifiedTradingAccount } from '../domain/trading/UnifiedTradingAcco import { searchTradeableContracts } from '../domain/trading/contract-search.js' import type { AssetClassHint } from '@traderalice/uta-protocol' import { executeOneShotOrder, type OrderEntryPhase } from '../domain/trading/order-entry.js' +import { projectOrderHistory, projectTradeHistory } from '../domain/trading/order-history.js' // ==================== Order entry schemas ==================== // @@ -355,6 +356,24 @@ export function createTradingRoutes(ctx: UTAEngineContext) { return c.json({ commits: uta.log({ limit, symbol }) }) }) + // Exchange-frontend projections of the git log — Order History (one row + // per order, lifecycle collapsed) and Trade History (fills only). + // Projection logic lives in domain/trading/order-history.ts so MCP/CLI + // surfaces can reuse it. + app.get('/uta/:id/order-history', (c) => { + const uta = ctx.utaManager.get(c.req.param('id')) + if (!uta) return c.json({ error: 'Account not found' }, 404) + const limit = Number(c.req.query('limit')) || 50 + return c.json({ orders: projectOrderHistory(uta.exportGitState().commits, { limit }) }) + }) + + app.get('/uta/:id/trade-history', (c) => { + const uta = ctx.utaManager.get(c.req.param('id')) + if (!uta) return c.json({ error: 'Account not found' }, 404) + const limit = Number(c.req.query('limit')) || 50 + return c.json({ trades: projectTradeHistory(uta.exportGitState().commits, { limit }) }) + }) + app.get('/uta/:id/wallet/show/:hash', (c) => { const uta = ctx.utaManager.get(c.req.param('id')) if (!uta) return c.json({ error: 'Account not found' }, 404) diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index e5470ea6..78bcf72b 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -68,6 +68,12 @@ export type { ToolCallRecord, UTASnapshotSummary, EquityCurvePoint, + HistoryContract, + OrderHistoryEntry, + OrderHistoryStatus, + OrderHistorySource, + TradeHistoryEntry, + TradeHistorySource, NewsArticle, NewsListResponse, TopologyResponse, diff --git a/ui/src/api/trading.ts b/ui/src/api/trading.ts index a113d4a4..357bfda1 100644 --- a/ui/src/api/trading.ts +++ b/ui/src/api/trading.ts @@ -1,5 +1,5 @@ import { fetchJson } from './client' -import type { TradingAccount, UTASummary, AccountInfo, Position, WalletCommitLog, ReconnectResult, UTAConfig, WalletStatus, WalletPushResult, WalletRejectResult, TestConnectionResult, BrokerPreset, UTASnapshotSummary, EquityCurvePoint, PlaceOrderRequest, ClosePositionRequest, CancelOrderRequest, OrderErrorResponse } from './types' +import type { TradingAccount, UTASummary, AccountInfo, Position, WalletCommitLog, ReconnectResult, UTAConfig, WalletStatus, WalletPushResult, WalletRejectResult, TestConnectionResult, BrokerPreset, UTASnapshotSummary, EquityCurvePoint, PlaceOrderRequest, ClosePositionRequest, CancelOrderRequest, OrderErrorResponse, OrderHistoryEntry, TradeHistoryEntry } from './types' /** Thrown by the one-shot order endpoints when the server returns non-2xx. Carries the phase. */ export class OrderEntryError extends Error { @@ -77,10 +77,21 @@ export const tradingApi = { return fetchJson(`/api/trading/uta/${utaId}/orders`) }, - async marketClock(utaId: string): Promise<{ isOpen: boolean; nextOpen: string; nextClose: string }> { + /** nextOpen/nextClose may be absent for 24/7 venues (crypto). */ + async marketClock(utaId: string): Promise<{ isOpen: boolean; nextOpen?: string; nextClose?: string }> { return fetchJson(`/api/trading/uta/${utaId}/market-clock`) }, + /** Order History — every order's lifecycle collapsed to one row. */ + async orderHistory(utaId: string, limit = 50): Promise<{ orders: OrderHistoryEntry[] }> { + return fetchJson(`/api/trading/uta/${utaId}/order-history?limit=${limit}`) + }, + + /** Trade History — fills only. */ + async tradeHistory(utaId: string, limit = 50): Promise<{ trades: TradeHistoryEntry[] }> { + return fetchJson(`/api/trading/uta/${utaId}/trade-history?limit=${limit}`) + }, + async walletLog(utaId: string, limit = 20, symbol?: string): Promise<{ commits: WalletCommitLog[] }> { const params = new URLSearchParams({ limit: String(limit) }) if (symbol) params.set('symbol', symbol) diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index dec40369..39869ec1 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -333,15 +333,22 @@ export interface TradingAccount { label: string } +/** + * Mirrors `AccountInfo` in packages/uta-protocol/src/types/broker.ts — keep + * the two in lockstep. The contract is the IBKR superset: brokers that don't + * report a field omit it (e.g. Alpaca has no realizedPnL; CCXT venues often + * have no buyingPower). The UI must omit those rows, never fabricate zeros. + */ export interface AccountInfo { baseCurrency: string netLiquidation: string totalCashValue: string unrealizedPnL: string - realizedPnL: string + realizedPnL?: string buyingPower?: string initMarginReq?: string maintMarginReq?: string + dayTradesRemaining?: number } export interface Position { @@ -415,6 +422,74 @@ export interface WalletPushResult { rejected: Array<{ action: string; success: boolean; error?: string; status: string }> } +// ==================== Order / Trade History ==================== +// +// Hand-mirrors packages/uta-protocol/src/types/history.ts — the UI does not +// import uta-protocol, so keep these in lockstep with the wire types. + +/** Compact contract identity for history rows — IBKR-superset fields. */ +export interface HistoryContract { + aliceId?: string + symbol?: string + localSymbol?: string + secType?: string + currency?: string + exchange?: string + /** OPT/FOP/FUT: contract month or expiry (IBKR lastTradeDateOrContractMonth). */ + expiry?: string + /** OPT/FOP: strike price (string — Decimal-safe). */ + strike?: string + /** OPT/FOP: 'C' | 'P' (normalized). */ + right?: string + multiplier?: string +} + +export type OrderHistoryStatus = 'submitted' | 'filled' | 'cancelled' | 'rejected' | 'user-rejected' + +export type OrderHistorySource = 'alice' | 'external' + +export interface OrderHistoryEntry { + /** Broker order id (absent for rejected-before-submit). */ + orderId?: string + /** When the order entered the log (push/observe time, ISO). */ + timestamp: string + /** When the terminal transition was recorded, if any (sync/cancel time, ISO). */ + resolvedAt?: string + contract: HistoryContract + side: 'BUY' | 'SELL' + orderType?: string + quantity?: string + limitPrice?: string + stopPrice?: string + status: OrderHistoryStatus + filledQty?: string + avgFillPrice?: string + /** 'external' = observed on the broker, not placed through Alice. */ + source: OrderHistorySource + /** Commit that introduced the order — the audit pointer. */ + commitHash: string + /** Commit message (user intent for Alice orders; [observed] for external). */ + message: string + error?: string +} + +export type TradeHistorySource = 'order' | 'external' | 'reconcile' + +export interface TradeHistoryEntry { + /** Fill record time (ISO) — push time for immediate fills, sync time otherwise. */ + timestamp: string + orderId?: string + contract: HistoryContract + side: 'BUY' | 'SELL' + quantity: string + price: string + /** quantity × price × multiplier (string — Decimal-safe). */ + value: string + /** 'reconcile' = balance drift folded in at observed price, not a real fill record. */ + source: TradeHistorySource + commitHash: string +} + // ==================== Tool Call Log ==================== export interface ToolCallRecord { diff --git a/ui/src/components/EquityCurve.tsx b/ui/src/components/EquityCurve.tsx index c08b2a02..60c62697 100644 --- a/ui/src/components/EquityCurve.tsx +++ b/ui/src/components/EquityCurve.tsx @@ -51,6 +51,31 @@ export function EquityCurve({ })), [filtered]) + // Explicit Y domain + ticks. Recharts' default 'auto' domain rounds tick + // values so coarsely that tight ranges render duplicate labels + // ("$100.7K $100.7K $100.6K …") — compute our own 4 ticks with a + // formatter precise enough to keep them distinct. + const yAxis = useMemo(() => { + const vals = chartData.map(d => d.equityNum).filter(v => Number.isFinite(v)) + if (vals.length === 0) return null + let min = Math.min(...vals) + let max = Math.max(...vals) + if (min === max) { min -= 1; max += 1 } + const pad = (max - min) * 0.08 + const lo = min - pad + const hi = max + pad + const ticks = [0, 1, 2, 3].map(i => lo + ((hi - lo) * i) / 3) + return { + domain: [lo, hi] as [number, number], + ticks, + formatter: makeCurrencyTickFormatter(max - min, (hi - lo) / 3), + } + }, [chartData]) + + // Explicit X ticks aligned to round time boundaries (whole hours, local + // midnights) instead of recharts' arbitrary data-point positions. + const xTicks = useMemo(() => computeTimeTicks(chartData), [chartData]) + if (chartData.length === 0) return null const isAllView = selectedAccountId === 'all' @@ -128,6 +153,7 @@ export function EquityCurve({ dataKey="time" type="number" domain={['dataMin', 'dataMax']} + ticks={xTicks} tickFormatter={formatTime} tick={{ fontSize: 10, fill: 'var(--color-text-muted)' }} axisLine={{ stroke: 'var(--color-border)' }} @@ -135,12 +161,13 @@ export function EquityCurve({ minTickGap={40} /> } /> = 1_000) return `$${(val / 1_000).toFixed(1)}K` return `$${val.toFixed(0)}` } + +/** + * Tick formatter with range-aware precision: tight ranges (< $2,000 across + * the visible window) render full dollars with thousands separators + * ("$100,680"); wider ranges keep the compact K/M form but with enough + * decimals that adjacent ticks stay distinct ("$100.68K"). + */ +function makeCurrencyTickFormatter(range: number, tickSpacing: number): (val: number) => string { + return (val: number) => { + if (range < 2000) { + const decimals = range < 10 ? 2 : 0 + return `$${val.toLocaleString(getIntlLocale(), { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}` + } + if (Math.abs(val) < 1_000) return `$${val.toFixed(0)}` + const unit = Math.abs(val) >= 1_000_000 ? 1_000_000 : 1_000 + const suffix = unit === 1_000_000 ? 'M' : 'K' + // Enough fractional digits that one tick step is resolvable at this unit. + const decimals = Math.min(4, Math.max(1, Math.ceil(-Math.log10(tickSpacing / unit)))) + return `$${(val / unit).toFixed(decimals)}${suffix}` + } +} + +// ==================== X-axis round ticks ==================== + +const MINUTE = 60_000 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +/** Candidate tick steps, smallest first. */ +const TICK_STEPS = [5 * MINUTE, 15 * MINUTE, HOUR, 3 * HOUR, 6 * HOUR, DAY] as const + +/** + * Pick the smallest step that yields ≤ 6 ticks across the visible range and + * align tick values to round boundaries — epoch-aligned for sub-day steps + * (whole hours / 5-minute marks), local midnight for day-sized steps. For + * ranges beyond what 1-day steps can cover in 6 ticks, step by N days. + */ +function computeTimeTicks(data: Array<{ time: number }>): number[] | undefined { + if (data.length < 2) return undefined + const t0 = data[0].time + const t1 = data[data.length - 1].time + const span = t1 - t0 + if (span <= 0) return undefined + + const step = TICK_STEPS.find(s => span / s <= 6) ?? DAY + const ticks: number[] = [] + if (step >= DAY) { + const stride = Math.max(1, Math.ceil(span / (6 * DAY))) + const d = new Date(t0) + d.setHours(0, 0, 0, 0) + if (d.getTime() < t0) d.setDate(d.getDate() + 1) + for (; d.getTime() <= t1; d.setDate(d.getDate() + stride)) ticks.push(d.getTime()) + } else { + for (let t = Math.ceil(t0 / step) * step; t <= t1; t += step) ticks.push(t) + } + return ticks.length >= 2 ? ticks : undefined +} diff --git a/ui/src/components/ReconnectButton.tsx b/ui/src/components/ReconnectButton.tsx index 90598b21..6c545311 100644 --- a/ui/src/components/ReconnectButton.tsx +++ b/ui/src/components/ReconnectButton.tsx @@ -27,12 +27,15 @@ export function ReconnectButton({ accountId }: { accountId: string }) { } } + // No outer margin here — spacing belongs to the call site (this rides in + // the page-header action row AND in the edit dialog; a baked-in mt-3 was + // what knocked the header buttons out of alignment). return ( -
+
diff --git a/ui/src/components/Toggle.tsx b/ui/src/components/Toggle.tsx index cb1cc7db..2204e9b1 100644 --- a/ui/src/components/Toggle.tsx +++ b/ui/src/components/Toggle.tsx @@ -15,7 +15,7 @@ export function Toggle({ checked, onChange, size = 'md' }: ToggleProps) { aria-checked={checked} onClick={() => onChange(!checked)} className={`relative rounded-full cursor-pointer transition-colors ${track} ${ - checked ? 'bg-green' : 'bg-bg-tertiary' + checked ? 'bg-accent' : 'bg-bg-tertiary' }`} > = { [DEMO_UTA_PAPER]: { baseCurrency: 'USD', netLiquidation: '52840.13', totalCashValue: '8120.55', unrealizedPnL: '1924.58', - realizedPnL: '380.00', buyingPower: '16241.10', + dayTradesRemaining: 3, }, [DEMO_UTA_IBKR]: { baseCurrency: 'USD', @@ -215,7 +223,9 @@ export const demoSnapshotsByUTA: Record = Object.f netLiquidation: p.equity, totalCashValue: demoAccountByUTA[a.id]!.totalCashValue, unrealizedPnL: demoAccountByUTA[a.id]!.unrealizedPnL, - realizedPnL: demoAccountByUTA[a.id]!.realizedPnL, + // Snapshot schema requires the field; mirror the server-side + // builder's coalesce (services/uta .../snapshot/builder.ts). + realizedPnL: demoAccountByUTA[a.id]!.realizedPnL ?? '0', }, positions: (demoPositionsByUTA[a.id] ?? []).map((p) => ({ aliceId: p.contract.symbol ?? 'unknown', @@ -234,6 +244,140 @@ export const demoSnapshotsByUTA: Record = Object.f ]), ) +// ==================== Order / Trade history ==================== + +const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString() + +function stk(symbol: string, exchange = 'SMART'): HistoryContract { + return { symbol, secType: 'STK', currency: 'USD', exchange } +} + +function spot(localSymbol: string): HistoryContract { + return { symbol: localSymbol, localSymbol, secType: 'CRYPTO', currency: 'USDT', exchange: 'binance' } +} + +// IBKR-superset showcase: an AAPL July-2026 300 call. Exercises the full +// option field set (expiry / strike / right / multiplier) in the demo. +const AAPL_300C: HistoryContract = { + symbol: 'AAPL', + localSymbol: 'AAPL 260717C00300000', + secType: 'OPT', + currency: 'USD', + exchange: 'SMART', + expiry: '20260717', + strike: '300', + right: 'C', + multiplier: '100', +} + +export const demoOrderHistoryByUTA: Record = { + [DEMO_UTA_PAPER]: [ + { + orderId: '90412', timestamp: hoursAgo(1), contract: stk('AMD'), side: 'BUY', + orderType: 'LMT', quantity: '40', limitPrice: '140.00', status: 'submitted', + source: 'alice', commitHash: 'f31c9a2', message: 'Add AMD ahead of earnings', + }, + { + orderId: '90398', timestamp: hoursAgo(3), resolvedAt: hoursAgo(2.8), contract: stk('AAPL'), side: 'BUY', + orderType: 'LMT', quantity: '20', limitPrice: '188.00', status: 'filled', + filledQty: '20', avgFillPrice: '187.92', + source: 'alice', commitHash: 'c8d04e1', message: 'Add AAPL on pullback', + }, + { + orderId: '90371', timestamp: hoursAgo(7), resolvedAt: hoursAgo(7), contract: stk('NVDA'), side: 'SELL', + orderType: 'MKT', quantity: '10', status: 'filled', + filledQty: '10', avgFillPrice: '631.40', + source: 'alice', commitHash: 'a17b2f9', message: 'Trim NVDA into strength', + }, + { + orderId: '90244', timestamp: hoursAgo(28), resolvedAt: hoursAgo(25), contract: stk('GOOG'), side: 'BUY', + orderType: 'LMT', quantity: '15', limitPrice: '150.00', status: 'cancelled', + source: 'alice', commitHash: '4e9d70c', message: 'Bid GOOG at support', + }, + ], + [DEMO_UTA_IBKR]: [ + { + orderId: '7734', timestamp: hoursAgo(2), contract: stk('QQQ'), side: 'SELL', + orderType: 'LMT', quantity: '50', limitPrice: '445.00', status: 'submitted', + source: 'alice', commitHash: '2b8fe55', message: 'Take profit on half the QQQ position', + }, + { + orderId: '7729', timestamp: hoursAgo(4), resolvedAt: hoursAgo(3.9), contract: AAPL_300C, side: 'BUY', + orderType: 'LMT', quantity: '5', limitPrice: '8.20', status: 'filled', + filledQty: '5', avgFillPrice: '8.15', + source: 'alice', commitHash: '9d2a64b', message: 'Buy AAPL Jul26 300C — long-dated upside', + }, + { + timestamp: hoursAgo(6), resolvedAt: hoursAgo(6), contract: stk('TLT'), side: 'BUY', + orderType: 'MKT', quantity: '500', status: 'rejected', + source: 'alice', commitHash: 'e07c318', message: 'Add duration', + error: 'Insufficient buying power for order size', + }, + { + orderId: '7698', timestamp: hoursAgo(31), resolvedAt: hoursAgo(31), contract: stk('SPY'), side: 'BUY', + orderType: 'LMT', quantity: '100', limitPrice: '512.50', status: 'filled', + filledQty: '100', avgFillPrice: '512.48', + source: 'alice', commitHash: '6fa1d92', message: 'Scale into SPY core', + }, + ], + [DEMO_UTA_CRYPTO]: [ + { + orderId: 'ord-88121', timestamp: hoursAgo(5), resolvedAt: hoursAgo(4.9), contract: spot('ETH/USDT'), side: 'BUY', + orderType: 'LMT', quantity: '0.5', limitPrice: '3350', status: 'filled', + filledQty: '0.5', avgFillPrice: '3348.2', + source: 'alice', commitHash: 'b44c0d7', message: 'Add ETH on dip', + }, + { + orderId: 'ord-87903', timestamp: hoursAgo(12), resolvedAt: hoursAgo(12), contract: spot('BTC/USDT'), side: 'SELL', + orderType: 'MKT', quantity: '0.02', status: 'filled', + filledQty: '0.02', avgFillPrice: '66120', + source: 'external', commitHash: '0c5e8a1', message: '[observed] external order', + }, + { + orderId: 'ord-87410', timestamp: hoursAgo(40), resolvedAt: hoursAgo(36), contract: spot('ETH/USDT'), side: 'SELL', + orderType: 'LMT', quantity: '1.0', limitPrice: '3600', status: 'cancelled', + source: 'alice', commitHash: '5d91f3e', message: 'Offer ETH at resistance', + }, + ], +} + +export const demoTradeHistoryByUTA: Record = { + [DEMO_UTA_PAPER]: [ + { + timestamp: hoursAgo(2.8), orderId: '90398', contract: stk('AAPL'), side: 'BUY', + quantity: '20', price: '187.92', value: '3758.40', source: 'order', commitHash: 'c8d04e1', + }, + { + timestamp: hoursAgo(7), orderId: '90371', contract: stk('NVDA'), side: 'SELL', + quantity: '10', price: '631.40', value: '6314.00', source: 'order', commitHash: 'a17b2f9', + }, + ], + [DEMO_UTA_IBKR]: [ + { + timestamp: hoursAgo(3.9), orderId: '7729', contract: AAPL_300C, side: 'BUY', + quantity: '5', price: '8.15', value: '4075.00', source: 'order', commitHash: '9d2a64b', + }, + { + timestamp: hoursAgo(31), orderId: '7698', contract: stk('SPY'), side: 'BUY', + quantity: '100', price: '512.48', value: '51248.00', source: 'order', commitHash: '6fa1d92', + }, + ], + [DEMO_UTA_CRYPTO]: [ + { + timestamp: hoursAgo(4.9), orderId: 'ord-88121', contract: spot('ETH/USDT'), side: 'BUY', + quantity: '0.5', price: '3348.2', value: '1674.10', source: 'order', commitHash: 'b44c0d7', + }, + { + timestamp: hoursAgo(12), orderId: 'ord-87903', contract: spot('BTC/USDT'), side: 'SELL', + quantity: '0.02', price: '66120', value: '1322.40', source: 'external', commitHash: '0c5e8a1', + }, + { + timestamp: hoursAgo(18), contract: spot('BTC/USDT'), side: 'BUY', + quantity: '0.0005', price: '66480', value: '33.24', source: 'reconcile', commitHash: '7ae20c4', + }, + ], +} + // ==================== UTA configs ==================== export const demoUTAConfigs: UTAConfig[] = [ diff --git a/ui/src/demo/handlers/trading.ts b/ui/src/demo/handlers/trading.ts index 609c856a..f757827b 100644 --- a/ui/src/demo/handlers/trading.ts +++ b/ui/src/demo/handlers/trading.ts @@ -10,6 +10,8 @@ import { demoEquityCurve, demoEquityCurveByUTA, demoSnapshotsByUTA, + demoOrderHistoryByUTA, + demoTradeHistoryByUTA, } from '../fixtures/trading' function totals() { @@ -62,6 +64,12 @@ export const tradingHandlers = [ HttpResponse.json({ positions: demoPositionsByUTA[utaId(params)] ?? [] }), ), http.get('/api/trading/uta/:id/orders', () => HttpResponse.json({ orders: [] })), + http.get('/api/trading/uta/:id/order-history', ({ params }) => + HttpResponse.json({ orders: demoOrderHistoryByUTA[utaId(params)] ?? [] }), + ), + http.get('/api/trading/uta/:id/trade-history', ({ params }) => + HttpResponse.json({ trades: demoTradeHistoryByUTA[utaId(params)] ?? [] }), + ), http.get('/api/trading/uta/:id/market-clock', () => HttpResponse.json({ isOpen: false, diff --git a/ui/src/lib/contract-display.tsx b/ui/src/lib/contract-display.tsx new file mode 100644 index 00000000..187d2a7c --- /dev/null +++ b/ui/src/lib/contract-display.tsx @@ -0,0 +1,153 @@ +/** + * Shared contract rendering — the IBKR-superset display layer. + * + * Every surface that shows a contract (positions, open orders, order/trade + * history) renders through here so options/futures fields (secType, strike, + * right, expiry, multiplier) display correctly from day one, even while + * live accounts are stocks + crypto. + * + * The `secType` tag mirrors the canonical taxonomy string (STK / OPT / + * CRYPTO_PERP / ...) directly — no vernacular translation — so a label here + * is unambiguously the same thing as `contract.secType` everywhere else in + * the stack. + * + * Input shapes differ slightly across the wire: history rows carry `expiry`, + * Position rows carry `lastTradeDateOrContractMonth`, and strike/multiplier + * arrive as string (Decimal-safe) or number depending on the source. The + * normalize step absorbs all of that. + */ + +/** Loose input — accepts both HistoryContract and Position.contract shapes. */ +export interface ContractLike { + aliceId?: string + symbol?: string + localSymbol?: string + secType?: string + currency?: string + exchange?: string + /** History rows. */ + expiry?: string + /** Position rows (IBKR field name). */ + lastTradeDateOrContractMonth?: string + strike?: string | number + right?: string + multiplier?: string | number +} + +interface NormalizedContract { + aliceId?: string + symbol?: string + localSymbol?: string + secType?: string + currency?: string + exchange?: string + expiry?: string + strike?: string + right?: string + multiplier?: string +} + +function normalizeContract(c: ContractLike): NormalizedContract { + return { + aliceId: c.aliceId, + symbol: c.symbol, + localSymbol: c.localSymbol, + secType: c.secType, + currency: c.currency, + exchange: c.exchange, + expiry: c.expiry ?? c.lastTradeDateOrContractMonth, + strike: c.strike != null ? String(c.strike) : undefined, + right: c.right, + multiplier: c.multiplier != null ? String(c.multiplier) : undefined, + } +} + +/** Tail of an aliceId after the source prefix: `alpaca|AAPL` → `AAPL`. */ +function aliceIdTail(aliceId?: string): string | undefined { + if (!aliceId) return undefined + const idx = aliceId.lastIndexOf('|') + return idx >= 0 ? aliceId.slice(idx + 1) : aliceId +} + +/** Strip trailing zeros from a decimal string: "300.00" → "300", "7.50" → "7.5". */ +function trimDecimal(v: string): string { + if (!v.includes('.')) return v + return v.replace(/\.?0+$/, '') +} + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as const + +/** YYYYMMDD → MM/DD/YY; YYYYMM → MM/YY; anything else passes through. */ +function formatExpiryDate(expiry: string): string { + if (/^\d{8}$/.test(expiry)) { + return `${expiry.slice(4, 6)}/${expiry.slice(6, 8)}/${expiry.slice(2, 4)}` + } + if (/^\d{6}$/.test(expiry)) { + return `${expiry.slice(4, 6)}/${expiry.slice(2, 4)}` + } + return expiry +} + +/** YYYYMMDD / YYYYMM → "Sep 2026"; anything else passes through. */ +function formatExpiryMonthYear(expiry: string): string { + if (/^\d{6}(\d{2})?$/.test(expiry)) { + const month = Number(expiry.slice(4, 6)) + if (month >= 1 && month <= 12) return `${MONTHS[month - 1]} ${expiry.slice(0, 4)}` + } + return expiry +} + +/** + * Primary display line for a contract. + * + * - OPT/FOP: `AAPL 300C 07/17/26` + * - FUT: `ESU6 · Sep 2026` + * - CRYPTO: `ETH/USDT` (+ ` PERP` suffix for CRYPTO_PERP) + * - STK / default: symbol (fallback localSymbol, fallback aliceId tail) + */ +export function contractPrimary(input: ContractLike): string { + const c = normalizeContract(input) + const t = (c.secType ?? '').toUpperCase() + const baseSymbol = c.symbol ?? c.localSymbol ?? aliceIdTail(c.aliceId) ?? '?' + + if (t === 'OPT' || t === 'FOP') { + const strikeRight = [c.strike && trimDecimal(c.strike), c.right].filter(Boolean).join('') + const parts = [baseSymbol, strikeRight, c.expiry && formatExpiryDate(c.expiry)].filter(Boolean) + // No option fields at all — fall back to whatever identity we have. + return parts.length > 1 ? parts.join(' ') : (c.localSymbol ?? baseSymbol) + } + if (t === 'FUT') { + const name = c.localSymbol ?? baseSymbol + return c.expiry ? `${name} · ${formatExpiryMonthYear(c.expiry)}` : name + } + if (t === 'CRYPTO' || t === 'CRYPTO_PERP') { + const name = c.localSymbol ?? baseSymbol + return t === 'CRYPTO_PERP' ? `${name} PERP` : name + } + return baseSymbol +} + +/** + * Secondary muted line: `OPT · SMART · USD · ×100`. + * Multiplier is shown only when present and ≠ '1'. + */ +export function contractSecondary(input: ContractLike): string { + const c = normalizeContract(input) + const parts: string[] = [] + if (c.secType) parts.push(c.secType) + if (c.exchange) parts.push(c.exchange) + if (c.currency) parts.push(c.currency) + if (c.multiplier && trimDecimal(c.multiplier) !== '1') parts.push(`×${trimDecimal(c.multiplier)}`) + return parts.join(' · ') +} + +/** Two-line contract cell for tables: primary identity + muted detail line. */ +export function ContractCell({ contract }: { contract: ContractLike }) { + const secondary = contractSecondary(contract) + return ( +
+
{contractPrimary(contract)}
+ {secondary &&
{secondary}
} +
+ ) +} diff --git a/ui/src/pages/PortfolioPage.tsx b/ui/src/pages/PortfolioPage.tsx index 05a42910..f20e6615 100644 --- a/ui/src/pages/PortfolioPage.tsx +++ b/ui/src/pages/PortfolioPage.tsx @@ -11,6 +11,7 @@ import { Toggle } from '../components/Toggle' import { Metric, signFromDelta } from '../components/Metric' import { Sparkline } from '../components/Sparkline' import { fmt, fmtPnl, fmtNum, fmtPctSigned } from '../lib/format' +import { contractPrimary } from '../lib/contract-display' // ==================== Types ==================== @@ -507,25 +508,11 @@ interface PositionWithAccount extends Position { * `Position.contract.secType === 'CRYPTO_PERP'` everywhere else in the * stack. * - * `name` defaults to the symbol; for OPT/FOP we build a longer descriptor - * (expiry/right/strike) so two option positions on the same underlying - * are distinguishable in the table. + * `name` comes from the shared IBKR-superset formatter (lib/contract-display) + * so this table renders identically to the UTA detail page. */ function contractDisplay(p: Position): { name: string; tag: string } { - const c = p.contract - const sym = c.symbol ?? '???' - const t = c.secType || 'UNK' - - if (t === 'OPT' || t === 'FOP') { - const optDesc = c.localSymbol - ?? [sym, c.lastTradeDateOrContractMonth, c.right, c.strike && fmt(c.strike)].filter(Boolean).join(' ') - return { name: optDesc, tag: t } - } - if (t === 'FUT') { - const expiry = c.lastTradeDateOrContractMonth - return { name: expiry ? `${sym} ${expiry}` : sym, tag: t } - } - return { name: sym, tag: t } + return { name: contractPrimary(p.contract), tag: p.contract.secType || 'UNK' } } function PositionsTable({ positions, fxRates }: { positions: PositionWithAccount[]; fxRates: FxRateInfo[] }) { diff --git a/ui/src/pages/UTADetailPage.tsx b/ui/src/pages/UTADetailPage.tsx index 6f021900..4562d4da 100644 --- a/ui/src/pages/UTADetailPage.tsx +++ b/ui/src/pages/UTADetailPage.tsx @@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom' import type { ViewSpec } from '../tabs/types' import { api } from '../api' import { getIntlLocale } from '../lib/intl' -import type { UTAConfig, BrokerPreset, AccountInfo, Position, BrokerHealthInfo, UTASnapshotSummary, EquityCurvePoint } from '../api/types' +import type { UTAConfig, BrokerPreset, AccountInfo, Position, BrokerHealthInfo, UTASnapshotSummary, EquityCurvePoint, OrderHistoryEntry, OrderHistoryStatus, TradeHistoryEntry } from '../api/types' import { useTradingConfig } from '../hooks/useTradingConfig' import { useAccountHealth } from '../hooks/useAccountHealth' import { PageHeader } from '../components/PageHeader' @@ -13,11 +13,11 @@ import { Toggle } from '../components/Toggle' import { HealthBadge } from '../components/uta/HealthBadge' import { EditUTADialog } from '../components/uta/EditUTADialog' import { OrderEntryDialog, type OrderEntryMode } from '../components/uta/OrderEntryDialog' -import { SnapshotDetail } from '../components/SnapshotDetail' import { EquityCurve } from '../components/EquityCurve' import { Metric, signFromDelta } from '../components/Metric' import { fmt, fmtPnl, fmtNum, fmtPctSigned, isUnsetDecimal } from '../lib/format' import { secTypeToClass, assetClassLabel, ASSET_CLASS_ORDER, type AssetClass } from '../lib/asset-class' +import { ContractCell } from '../lib/contract-display' // ==================== Page ==================== @@ -39,8 +39,8 @@ export function UTADetailPage({ spec }: UTADetailPageProps) { const [editing, setEditing] = useState(false) const [orderMode, setOrderMode] = useState(null) const [dataError, setDataError] = useState(null) - const [expandedSnapshot, setExpandedSnapshot] = useState(null) const [lastUpdated, setLastUpdated] = useState(null) + const [clock, setClock] = useState(null) useEffect(() => { api.trading.getBrokerPresets().then(r => setPresets(r.presets)).catch(() => {}) @@ -89,6 +89,19 @@ export function UTADetailPage({ spec }: UTADetailPageProps) { return () => { clearInterval(liveInterval); clearInterval(snapshotInterval) } }, [refreshLive, refreshSnapshots]) + // Market clock — mount + every 60s. The poll itself re-renders the + // "opens in Xh Ym" countdown, so no separate ticker is needed. + useEffect(() => { + if (!id) return + let cancelled = false + const load = () => api.trading.marketClock(id) + .then(c => { if (!cancelled) setClock(c) }) + .catch(() => { if (!cancelled) setClock('error') }) + load() + const t = setInterval(load, 60_000) + return () => { cancelled = true; clearInterval(t) } + }, [id]) + // ?aliceId=... auto-opens the place-order form prefilled (e.g. clicked // from TradeableContractsPanel on the market workbench). useEffect(() => { @@ -102,10 +115,10 @@ export function UTADetailPage({ spec }: UTADetailPageProps) { }, [searchParams, setSearchParams, orderMode]) // 24h delta = current NLV − the oldest snapshot still within the trailing - // 24h window. We label this "today" in the UI even though it's strictly - // 24h-trailing — matches consumer-trading apps' "Day's Change" wording - // without entangling market-hours / timezone arithmetic. - const todayDelta = useMemo(() => { + // 24h window. Labeled "24h" in the UI — it IS a trailing-24h diff, not a + // market-session "today", and the honest label avoids market-hours / + // timezone arithmetic. + const delta24h = useMemo(() => { if (!account || snapshots.length === 0) return null const cutoff = Date.now() - 24 * 60 * 60 * 1000 let baseline: number | null = null @@ -170,62 +183,73 @@ export function UTADetailPage({ spec }: UTADetailPageProps) { } right={ + // One action row, one visual language: the enable toggle (state + // control) sits apart from the buttons behind a divider; the + // secondary actions share btn-secondary-sm; Place Order is the + // single filled-accent primary at the same size. No hand-rolled + // paddings — mixed sizes were what made this row look drunk.
{ await tc.saveUTA({ ...uta, enabled: v }) }} /> +
+ -
} />
-
+
{dataError && ( -
+
Failed to load live data: {dataError}
)} - - - {curvePoints.length >= 2 && ( - { /* single-account mode: switcher hidden */ }} - /> - )} - - setOrderMode({ - kind: 'close', - aliceId: p.contract.aliceId ?? p.contract.localSymbol ?? p.contract.symbol ?? '', - quantity: p.quantity, - symbol: p.contract.symbol, - })} - /> - - + {/* Exchange-style two-column layout: tables get the wide main + column, the Account panel rides a sticky sidebar. On narrow + screens it collapses to one column with the Account panel + first — it's the summary. */} +
+
+ +
- setExpandedSnapshot(prev => prev === ts ? null : ts)} - /> +
+ {curvePoints.length >= 2 && ( + { /* single-account mode: switcher hidden */ }} + /> + )} + + setOrderMode({ + kind: 'close', + aliceId: p.contract.aliceId ?? p.contract.localSymbol ?? p.contract.symbol ?? '', + quantity: p.quantity, + symbol: p.contract.symbol, + })} + /> + + +
+
@@ -269,57 +293,144 @@ function Shell({ title, children }: { title: string; children?: React.ReactNode ) } -// ==================== Hero ==================== +// ==================== Account panel (sidebar) ==================== -interface TodayDelta { delta: number; pct: number; currency: string } +interface Delta24h { delta: number; pct: number; currency: string } -function Hero({ account, todayDelta }: { account: AccountInfo | null; todayDelta: TodayDelta | null }) { +/** Sum a string-decimal field, ignoring non-finite entries. */ +function sumFinite(values: number[]): number { + return values.reduce((s, n) => s + (Number.isFinite(n) ? n : 0), 0) +} + +/** + * Sidebar account summary. The AccountInfo contract is the IBKR superset: + * a broker that doesn't report a field gets its row OMITTED — never a + * fabricated zero. (Live examples: Alpaca has no realizedPnL; CCXT/okx has + * realizedPnL but no buyingPower.) + */ +function AccountPanel({ account, positions, delta24h, clock }: { + account: AccountInfo | null + positions: Position[] + delta24h: Delta24h | null + clock: MarketClockState +}) { if (!account) { return ( -
+
+ {clock != null && ( +
+ )}

Loading account info…

) } + const ccy = account.baseCurrency || 'USD' + const netLiq = Number(account.netLiquidation) const unrealized = Number(account.unrealizedPnL) - const realized = Number(account.realizedPnL ?? '0') + + // Positions value = Σ marketValue of what the page already fetched — NOT + // netLiq − cash, which would bake in margin / quote-currency noise. + const positionsValue = sumFinite(positions.map(p => Number(p.marketValue))) + const utilizationPct = Number.isFinite(netLiq) && netLiq > 0 + ? (positionsValue / netLiq) * 100 + : null + + // Unrealized % vs cost basis, when a positive cost basis is computable. + const costBasis = sumFinite(positions.map(p => + Math.abs(Number(p.quantity)) * Number(p.avgCost) * (p.contract.multiplier ?? 1) + )) + const unrealizedPct = costBasis > 0 && Number.isFinite(unrealized) + ? (unrealized / costBasis) * 100 + : null + + const realized = account.realizedPnL != null ? Number(account.realizedPnL) : null + const marginUsed = account.initMarginReq != null && !isUnsetDecimal(account.initMarginReq) + ? Number(account.initMarginReq) + : null return ( -
+
+ {clock != null && ( +
+ )} + -
- - + + + + + {utilizationPct != null && ( +
+
+ Utilization + {utilizationPct.toFixed(1)}% +
+
+
+
+
+ )} + + - - + + {realized != null && ( + + )} + + {account.buyingPower != null && !isUnsetDecimal(account.buyingPower) && ( + + )} + + {marginUsed != null && marginUsed > 0 && ( + + )} + + {account.dayTradesRemaining != null && ( + + )}
) } +function AccountRow({ label, value, sign }: { + label: string + value: React.ReactNode + sign?: 'up' | 'down' | 'flat' +}) { + const valueColor = sign === 'up' ? 'text-green' : sign === 'down' ? 'text-red' : 'text-text' + return ( +
+ {label} + {value} +
+ ) +} + // ==================== Section helper ==================== function Section({ title, action, children }: { title: string; action?: React.ReactNode; children: React.ReactNode }) { @@ -428,12 +539,11 @@ function PositionRow({ position: p, onClose }: { position: Position; onClose: () const cost = Number(p.avgCost) * Number(p.quantity) const pnl = Number(p.unrealizedPnL) const pct = cost > 0 ? (pnl / cost) * 100 : 0 - const display = p.contract.aliceId ?? p.contract.localSymbol ?? p.contract.symbol ?? '?' return ( - {display} + @@ -461,7 +571,48 @@ function PositionRow({ position: p, onClose }: { position: Position; onClose: () ) } -// ==================== Open Orders ==================== +// ==================== Market clock chip ==================== + +type MarketClockState = { isOpen: boolean; nextOpen?: string; nextClose?: string } | 'error' | null + +function MarketClockChip({ clock }: { clock: NonNullable }) { + let dotClass = 'bg-green' + let label = '24/7' + + if (clock !== 'error') { + if (clock.isOpen) { + const closes = clock.nextClose ? new Date(clock.nextClose) : null + if (closes && !Number.isNaN(closes.getTime())) { + const at = closes.toLocaleTimeString(getIntlLocale(), { hour: '2-digit', minute: '2-digit', hour12: false }) + label = `Market Open · closes ${at}` + } else if (!clock.nextOpen && !clock.nextClose) { + label = '24/7' // crypto venues report open with no schedule + } else { + label = 'Market Open' + } + } else { + dotClass = 'bg-text-muted/50' + const opens = clock.nextOpen ? new Date(clock.nextOpen) : null + if (opens && !Number.isNaN(opens.getTime())) { + const mins = Math.max(0, Math.round((opens.getTime() - Date.now()) / 60_000)) + const h = Math.floor(mins / 60) + const m = mins % 60 + label = `Market Closed · opens in ${h > 0 ? `${h}h ` : ''}${m}m` + } else { + label = 'Market Closed' + } + } + } + + return ( + + + {label} + + ) +} + +// ==================== Orders — tabbed: Open / History / Trades ==================== interface OpenOrderRow { orderId?: number | string @@ -470,167 +621,285 @@ interface OpenOrderRow { orderState?: { status?: string } } -function OrdersSection({ orders }: { orders: unknown[] }) { +type OrdersTab = 'open' | 'history' | 'trades' + +function OrdersArea({ utaId, openOrders }: { utaId: string; openOrders: unknown[] }) { + const [tab, setTab] = useState('open') + const [history, setHistory] = useState(null) + const [trades, setTrades] = useState(null) + + // Lazy-fetch per tab on first open; refresh on the same 15s cadence as the + // live poll while the tab stays active. + useEffect(() => { + if (tab !== 'history') return + let cancelled = false + const load = () => api.trading.orderHistory(utaId, 50) + .then(r => { if (!cancelled) setHistory(r.orders) }) + .catch(() => {}) + load() + const t = setInterval(load, 15_000) + return () => { cancelled = true; clearInterval(t) } + }, [tab, utaId]) + + useEffect(() => { + if (tab !== 'trades') return + let cancelled = false + const load = () => api.trading.tradeHistory(utaId, 50) + .then(r => { if (!cancelled) setTrades(r.trades) }) + .catch(() => {}) + load() + const t = setInterval(load, 15_000) + return () => { cancelled = true; clearInterval(t) } + }, [tab, utaId]) + + const tabs: Array<{ id: OrdersTab; label: string }> = [ + { id: 'open', label: `Open (${openOrders.length})` }, + { id: 'history', label: 'History' }, + { id: 'trades', label: 'Trades' }, + ] + + return ( +
+ {tabs.map(t => ( + + ))} +
+ } + > + {tab === 'open' && } + {tab === 'history' && } + {tab === 'trades' && } + + ) +} + +function OpenOrdersTable({ orders }: { orders: unknown[] }) { const rows = orders as OpenOrderRow[] if (rows.length === 0) { return ( -
-
- No open orders. -
-
+
+ No open orders. +
) } return ( -
-
- - - - - - - - - - +
+
Order IDContractActionTypeQtyLimitStatus
+ + + + + + + + + + + + + {rows.map((o, i) => ( + + + + + + + + - - - {rows.map((o, i) => ( - - - - - - - - - - ))} - -
Order IDContractActionTypeQtyLimitStatus
{String(o.orderId ?? '—')} + {o.contract?.aliceId ?? o.contract?.localSymbol ?? o.contract?.symbol ?? '?'} + {o.order?.action ?? '—'}{o.order?.orderType ?? '—'}{String(o.order?.totalQuantity ?? '')}{o.order?.lmtPrice != null && !isUnsetDecimal(o.order.lmtPrice) ? String(o.order.lmtPrice) : '—'} + {o.orderState?.status ?? 'Unknown'} +
{String(o.orderId ?? '—')} - {o.contract?.aliceId ?? o.contract?.localSymbol ?? o.contract?.symbol ?? '?'} - {o.order?.action ?? '—'}{o.order?.orderType ?? '—'}{String(o.order?.totalQuantity ?? '')}{o.order?.lmtPrice != null && !isUnsetDecimal(o.order.lmtPrice) ? String(o.order.lmtPrice) : '—'} - {o.orderState?.status ?? 'Unknown'} -
-
-
+ ))} + + +
) } -// ==================== Snapshots — vertical timeline ==================== +// ==================== Order History tab ==================== -interface SnapshotsTimelineProps { - snapshots: UTASnapshotSummary[] - expandedTimestamp: string | null - onToggle: (ts: string) => void +const ORDER_STATUS_STYLES: Record = { + filled: 'bg-green/15 text-green', + cancelled: 'bg-bg-tertiary text-text-muted', + rejected: 'bg-red/15 text-red', + 'user-rejected': 'bg-red/15 text-red', + submitted: 'bg-accent/15 text-accent', } -function SnapshotsTimeline({ snapshots, expandedTimestamp, onToggle }: SnapshotsTimelineProps) { - // Group by calendar day. Snapshots are newest-first; preserve that order - // so the timeline reads top-down chronologically backwards (like git log). - const groups = useMemo(() => { - const map = new Map() - for (const s of snapshots) { - const day = new Date(s.timestamp).toDateString() - if (!map.has(day)) map.set(day, []) - map.get(day)!.push(s) - } - return Array.from(map.entries()) - }, [snapshots]) - - if (snapshots.length === 0) { - return ( -
-
- No snapshots yet. They are captured periodically (Portfolio → Snapshot Settings) or after each push. -
-
- ) - } +function OrderStatusBadge({ status }: { status: OrderHistoryStatus }) { + return ( + + {status} + + ) +} +function SideBadge({ side }: { side: 'BUY' | 'SELL' }) { return ( -
-
- {/* Vertical guide line tucked behind the dots */} -
- {groups.map(([day, items]) => ( -
-
- {formatDayLabel(day)} -
-
    - {items.map((s) => { - const idxAll = snapshots.indexOf(s) - const prev = snapshots[idxAll + 1] // older snapshot - const delta = prev ? Number(s.account.netLiquidation) - Number(prev.account.netLiquidation) : null - const isExpanded = expandedTimestamp === s.timestamp - return ( -
  • - - {isExpanded && ( -
    - onToggle(s.timestamp)} - /> -
    - )} -
  • - ) - })} -
-
- ))} -
-
+ + {side} + ) } -function TriggerBadge({ trigger }: { trigger: string }) { - const label = trigger === 'post-push' ? 'push' - : trigger === 'post-reject' ? 'reject' - : trigger +function SourceChip({ label }: { label: string }) { return ( - + {label} ) } -// ==================== Date helpers ==================== +function OrderHistoryTable({ orders }: { orders: OrderHistoryEntry[] | null }) { + const [expanded, setExpanded] = useState(null) -function formatDayLabel(dayString: string): string { - // dayString is the output of `Date.toDateString()` — locale-format it - // back into something more readable, with a "today" / "yesterday" hint. - const d = new Date(dayString) - const todayStr = new Date().toDateString() - const yesterdayStr = new Date(Date.now() - 24 * 60 * 60 * 1000).toDateString() - const formatted = d.toLocaleDateString(getIntlLocale(), { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) - if (dayString === todayStr) return `${formatted} · today` - if (dayString === yesterdayStr) return `${formatted} · yesterday` - return formatted + if (orders == null) { + return ( +
+ Loading order history… +
+ ) + } + if (orders.length === 0) { + return ( +
+ No order history yet. +
+ ) + } + return ( +
+ + + + + + + + + + + + + + + {orders.map((o, i) => ( + + setExpanded(prev => prev === i ? null : i)} + > + + + + + + + + + + {expanded === i && ( + + + + )} + + ))} + +
TimeContractSideTypeQtyLimitFillStatus
{formatHistoryTime(o.timestamp)}{o.orderType ?? '—'}{o.quantity != null ? fmtNum(o.quantity) : '—'}{o.limitPrice ?? '—'} + {o.avgFillPrice ? `${o.avgFillPrice}${o.filledQty ? ` × ${o.filledQty}` : ''}` : '—'} + + + + {o.source === 'external' && } + +
+
+ {o.commitHash} + {o.message} + {o.error && {o.error}} + {o.resolvedAt && resolved {formatHistoryTime(o.resolvedAt)}} +
+
+
+ ) +} + +// ==================== Trade History tab ==================== + +function TradeHistoryTable({ trades }: { trades: TradeHistoryEntry[] | null }) { + if (trades == null) { + return ( +
+ Loading trade history… +
+ ) + } + if (trades.length === 0) { + return ( +
+ No trades yet. +
+ ) + } + return ( +
+ + + + + + + + + + + + + {trades.map((t, i) => ( + + + + + + + + + + ))} + +
TimeContractSideQtyPriceValue +
{formatHistoryTime(t.timestamp)}{fmtNum(t.quantity)}{t.price}{fmt(t.value, t.contract.currency)} + {t.source !== 'order' && ( + + )} +
+
+ ) } -function formatTime(timestamp: string): string { +// ==================== Date helpers ==================== + +/** "14:32" for today; "Jun 11 14:32" otherwise. */ +function formatHistoryTime(timestamp: string): string { const d = new Date(timestamp) - return d.toLocaleTimeString(getIntlLocale(), { hour: '2-digit', minute: '2-digit', hour12: false }) + if (Number.isNaN(d.getTime())) return timestamp + const time = d.toLocaleTimeString(getIntlLocale(), { hour: '2-digit', minute: '2-digit', hour12: false }) + if (d.toDateString() === new Date().toDateString()) return time + return `${d.toLocaleDateString(getIntlLocale(), { month: 'short', day: 'numeric' })} ${time}` }