From 6ed5203c2a1af09186ff77ad7fbb96a18a6483dc Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 18 Mar 2026 15:16:51 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20remove=20duplicate=20logToolCall=20f?= =?UTF-8?q?rom=20providers=20=E2=80=94=20AgentCenter=20pipeline=20is=20the?= =?UTF-8?q?=20single=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai-providers/agent-sdk/query.ts | 2 -- src/ai-providers/claude-code/provider.ts | 3 +-- src/ai-providers/vercel-ai-sdk/agent.ts | 6 ------ 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/ai-providers/agent-sdk/query.ts b/src/ai-providers/agent-sdk/query.ts index 1e919448..0d7b64e3 100644 --- a/src/ai-providers/agent-sdk/query.ts +++ b/src/ai-providers/agent-sdk/query.ts @@ -10,7 +10,6 @@ import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent- import { pino } from 'pino' import type { ContentBlock } from '../../core/session.js' import { readAIProviderConfig } from '../../core/config.js' -import { logToolCall } from '../utils.js' const logger = pino({ transport: { target: 'pino/file', options: { destination: 'logs/agent-sdk.log', mkdir: true } }, @@ -158,7 +157,6 @@ export async function askAgentSdk( const blocks: ContentBlock[] = [] for (const block of msg.content) { if (block.type === 'tool_use') { - logToolCall(block.name, block.input) logger.info({ tool: block.name, input: block.input }, 'tool_use') blocks.push({ type: 'tool_use', id: block.id, name: block.name, input: block.input }) onToolUse?.({ id: block.id, name: block.name, input: block.input }) diff --git a/src/ai-providers/claude-code/provider.ts b/src/ai-providers/claude-code/provider.ts index 184ab52b..5b5868c7 100644 --- a/src/ai-providers/claude-code/provider.ts +++ b/src/ai-providers/claude-code/provider.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process' import { pino } from 'pino' import type { ClaudeCodeConfig, ClaudeCodeResult, ClaudeCodeMessage } from './types.js' import type { ContentBlock } from '../../core/session.js' -import { logToolCall } from '../utils.js' + const logger = pino({ transport: { target: 'pino/file', options: { destination: 'logs/claude-code.log', mkdir: true } }, @@ -126,7 +126,6 @@ export async function askClaudeCode( const blocks: ContentBlock[] = [] for (const block of event.message.content) { if (block.type === 'tool_use') { - logToolCall(block.name, block.input) logger.info({ tool: block.name, input: block.input }, 'tool_use') blocks.push({ type: 'tool_use', id: block.id, name: block.name, input: block.input }) onToolUse?.({ id: block.id, name: block.name, input: block.input }) diff --git a/src/ai-providers/vercel-ai-sdk/agent.ts b/src/ai-providers/vercel-ai-sdk/agent.ts index 4db79ce9..5528477d 100644 --- a/src/ai-providers/vercel-ai-sdk/agent.ts +++ b/src/ai-providers/vercel-ai-sdk/agent.ts @@ -1,6 +1,5 @@ import { ToolLoopAgent, stepCountIs } from 'ai' import type { LanguageModel, Tool } from 'ai' -import { logToolCall } from '../utils.js' /** * Create a generic ToolLoopAgent with externally-provided tools. @@ -19,11 +18,6 @@ export function createAgent( tools, instructions, stopWhen: stepCountIs(maxSteps), - onStepFinish: (step) => { - for (const tc of step.toolCalls) { - logToolCall(tc.toolName, tc.input) - } - }, }) } From d75ee2ee452ef1b5fede19a8a3fa29626349385c Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 18 Mar 2026 18:15:50 +0800 Subject: [PATCH 2/4] feat: tradingPush requires manual approval via UI panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hollow out tradingPush tool so AI cannot execute trades directly — it now returns pending status and tells the user to approve in the UI. Add POST /accounts/:id/wallet/push REST endpoint for the frontend to call directly, and a PushApprovalPanel component on the chat page that polls for pending commits and provides an "Approve & Push" button. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/connectors/web/routes/trading.ts | 13 ++ src/tool/trading.ts | 18 +-- ui/src/api/trading.ts | 17 ++- ui/src/api/types.ts | 26 ++++ ui/src/components/PushApprovalPanel.tsx | 156 ++++++++++++++++++++++++ ui/src/pages/ChatPage.tsx | 4 + 6 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 ui/src/components/PushApprovalPanel.tsx diff --git a/src/connectors/web/routes/trading.ts b/src/connectors/web/routes/trading.ts index a4ff4285..46a013ce 100644 --- a/src/connectors/web/routes/trading.ts +++ b/src/connectors/web/routes/trading.ts @@ -120,5 +120,18 @@ export function createTradingRoutes(ctx: EngineContext) { return c.json(uta.status()) }) + // Push (manual approval — the AI tool is hollowed out, only humans can push) + app.post('/accounts/:id/wallet/push', async (c) => { + const uta = ctx.accountManager.get(c.req.param('id')) + if (!uta) return c.json({ error: 'Account not found' }, 404) + if (!uta.status().pendingMessage) return c.json({ error: 'Nothing to push' }, 400) + try { + const result = await uta.push() + return c.json(result) + } catch (err) { + return c.json({ error: String(err) }, 500) + } + }) + return app } diff --git a/src/tool/trading.ts b/src/tool/trading.ts index 4615fd9c..01dbb2e2 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -309,19 +309,21 @@ NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`, }), tradingPush: tool({ - description: 'Execute all committed trading operations (like "git push"). Must call tradingCommit first.', + description: 'Trading push requires manual approval — call tradingStatus to show the user what is pending, then tell them to approve in the UI.', inputSchema: z.object({ - source: z.string().optional().describe(sourceDesc(false, 'If omitted, pushes all committed accounts.')), + source: z.string().optional().describe(sourceDesc(false, 'If omitted, checks all accounts.')), }), execute: async ({ source }) => { const targets = manager.resolve(source) - const results: Array> = [] - for (const uta of targets) { - if (!uta.status().pendingMessage) continue - results.push({ source: uta.id, ...await uta.push() }) + const pending = targets.filter(uta => uta.status().pendingMessage) + if (pending.length === 0) return { message: 'No committed operations to push.' } + return { + message: 'Push requires manual approval. The user can approve pending operations in the UI.', + pending: pending.map(uta => ({ + source: uta.id, + ...uta.status(), + })), } - if (results.length === 0) return { message: 'No committed operations to push.' } - return results.length === 1 ? results[0] : results }, }), diff --git a/ui/src/api/trading.ts b/ui/src/api/trading.ts index 0e8fdf2f..1086adfd 100644 --- a/ui/src/api/trading.ts +++ b/ui/src/api/trading.ts @@ -1,5 +1,5 @@ import { fetchJson } from './client' -import type { TradingAccount, AccountInfo, Position, WalletCommitLog, ReconnectResult, PlatformConfig, AccountConfig } from './types' +import type { TradingAccount, AccountInfo, Position, WalletCommitLog, ReconnectResult, PlatformConfig, AccountConfig, WalletStatus, WalletPushResult } from './types' // ==================== Unified Trading API ==================== @@ -47,6 +47,21 @@ export const tradingApi = { return fetchJson(`/api/trading/accounts/${accountId}/wallet/show/${hash}`) }, + // ==================== Wallet operations ==================== + + async walletStatus(accountId: string): Promise { + return fetchJson(`/api/trading/accounts/${accountId}/wallet/status`) + }, + + async walletPush(accountId: string): Promise { + const res = await fetch(`/api/trading/accounts/${accountId}/wallet/push`, { method: 'POST' }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `Push failed (${res.status})`) + } + return res.json() + }, + // ==================== Trading Config CRUD ==================== async loadTradingConfig(): Promise<{ platforms: PlatformConfig[]; accounts: AccountConfig[] }> { diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 50a0fcd0..b8d20382 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -198,6 +198,32 @@ export interface ReconnectResult { message?: string } +// ==================== Wallet Status / Push ==================== + +export interface WalletOperation { + action: 'placeOrder' | 'modifyOrder' | 'closePosition' | 'cancelOrder' | 'syncOrders' + contract?: { aliceId?: string; symbol?: string; localSymbol?: string } + order?: { action?: string; orderType?: string; totalQuantity?: number | string; cashQty?: number | string; lmtPrice?: number | string; auxPrice?: number | string } + orderId?: string + quantity?: string + [key: string]: unknown +} + +export interface WalletStatus { + staged: WalletOperation[] + pendingMessage: string | null + head: string | null + commitCount: number +} + +export interface WalletPushResult { + hash: string + message: string + operationCount: number + submitted: Array<{ action: string; success: boolean; orderId?: string; status: string; error?: string }> + rejected: Array<{ action: string; success: boolean; error?: string; status: string }> +} + // ==================== Tool Call Log ==================== export interface ToolCallRecord { diff --git a/ui/src/components/PushApprovalPanel.tsx b/ui/src/components/PushApprovalPanel.tsx new file mode 100644 index 00000000..1b185b78 --- /dev/null +++ b/ui/src/components/PushApprovalPanel.tsx @@ -0,0 +1,156 @@ +import { useState, useEffect, useCallback } from 'react' +import { api } from '../api' +import type { TradingAccount, WalletStatus, WalletPushResult } from '../api/types' + +interface PendingAccount { + account: TradingAccount + status: WalletStatus +} + +/** Format an operation for display. */ +function formatOp(op: WalletStatus['staged'][number]): string { + const symbol = op.contract?.aliceId || op.contract?.symbol || op.contract?.localSymbol || '' + switch (op.action) { + case 'placeOrder': { + const side = op.order?.action || '?' + const type = op.order?.orderType || '' + const qty = op.order?.totalQuantity ?? op.order?.cashQty ?? '' + const price = op.order?.lmtPrice ? ` @ ${op.order.lmtPrice}` : '' + return `${side} ${qty} ${symbol} ${type}${price}`.trim() + } + case 'closePosition': + return `Close ${symbol}${op.quantity ? ` (${op.quantity})` : ''}` + case 'modifyOrder': + return `Modify order ${op.orderId || '?'}` + case 'cancelOrder': + return `Cancel order ${op.orderId || '?'}` + case 'syncOrders': + return 'Sync orders' + default: + return op.action + } +} + +export function PushApprovalPanel() { + const [pending, setPending] = useState([]) + const [pushing, setPushing] = useState(null) + const [result, setResult] = useState<{ accountId: string; data: WalletPushResult } | null>(null) + const [error, setError] = useState(null) + + const poll = useCallback(async () => { + try { + const { accounts } = await api.trading.listAccounts() + const results: PendingAccount[] = [] + for (const account of accounts) { + try { + const status = await api.trading.walletStatus(account.id) + if (status.pendingMessage) { + results.push({ account, status }) + } + } catch { /* skip unreachable accounts */ } + } + setPending(results) + } catch { /* ignore */ } + }, []) + + // Poll every 3 seconds + useEffect(() => { + poll() + const id = setInterval(poll, 3000) + return () => clearInterval(id) + }, [poll]) + + const handlePush = useCallback(async (accountId: string) => { + setPushing(accountId) + setError(null) + setResult(null) + try { + const data = await api.trading.walletPush(accountId) + setResult({ accountId, data }) + // Refresh pending list + await poll() + } catch (err) { + setError(err instanceof Error ? err.message : 'Push failed') + } finally { + setPushing(null) + } + }, [poll]) + + // Nothing pending and no recent result — hide panel + if (pending.length === 0 && !result) return null + + return ( +
+
+

Pending Push

+
+ +
+ {pending.map(({ account, status }) => ( +
+
{account.label || account.id}
+ + {/* Commit message */} +
+ {status.pendingMessage} +
+ + {/* Operations */} +
+ {status.staged.map((op, i) => ( +
+ {formatOp(op)} +
+ ))} +
+ + {/* Actions */} +
+ +
+
+ ))} + + {/* Result feedback */} + {result && ( +
+
Last push
+
+ {result.data.submitted.length > 0 && ( + {result.data.submitted.length} submitted + )} + {result.data.rejected.length > 0 && ( + <> + {result.data.submitted.length > 0 && ', '} + {result.data.rejected.length} rejected + + )} +
+ {result.data.rejected.map((r, i) => ( +
{r.error || 'Unknown error'}
+ ))} + +
+ )} + + {error && ( +
+ {error} + +
+ )} +
+
+ ) +} diff --git a/ui/src/pages/ChatPage.tsx b/ui/src/pages/ChatPage.tsx index faf6937d..d0430007 100644 --- a/ui/src/pages/ChatPage.tsx +++ b/ui/src/pages/ChatPage.tsx @@ -5,6 +5,7 @@ import { useChat } from '../hooks/useChat' import { ChatMessage, ToolCallGroup, ThinkingIndicator, StreamingToolGroup } from '../components/ChatMessage' import { ChatInput } from '../components/ChatInput' import { ChannelConfigModal } from '../components/ChannelConfigModal' +import { PushApprovalPanel } from '../components/PushApprovalPanel' interface ChatPageProps { onSSEStatus?: (connected: boolean) => void @@ -129,6 +130,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { }, [activeChannel, switchToChannel]) return ( +
{/* Sub-channel context bar */} {isOnSubChannel && ( @@ -410,5 +412,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { /> )}
+ +
) } From 0768df52a1496305db5c893323e1e30349a9f86b Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 18 Mar 2026 18:46:01 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20push=20approval=20panel=20=E2=80=94?= =?UTF-8?q?=20reject=20with=20history,=20visual=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TradingGit.reject(reason?) that records a user-rejected commit so the AI can see which operations were denied and why. New REST endpoint POST /accounts/:id/wallet/reject. Rewrite PushApprovalPanel: reject button, inline push confirmation, buy/sell color coding, commit history below pending section with status badges and relative timestamps. Panel stays visible when trading accounts exist (no layout jump). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/connectors/web/routes/trading.ts | 15 + src/domain/trading/UnifiedTradingAccount.ts | 5 + src/domain/trading/git/TradingGit.ts | 48 +++ src/domain/trading/git/index.ts | 1 + src/domain/trading/git/interfaces.ts | 2 + src/domain/trading/git/types.ts | 8 +- ui/src/api/trading.ts | 15 +- ui/src/api/types.ts | 6 + ui/src/components/PushApprovalPanel.tsx | 331 +++++++++++++++----- 9 files changed, 346 insertions(+), 85 deletions(-) diff --git a/src/connectors/web/routes/trading.ts b/src/connectors/web/routes/trading.ts index 46a013ce..5bdb529c 100644 --- a/src/connectors/web/routes/trading.ts +++ b/src/connectors/web/routes/trading.ts @@ -120,6 +120,21 @@ export function createTradingRoutes(ctx: EngineContext) { return c.json(uta.status()) }) + // Reject (records a user-rejected commit, clears staging) + app.post('/accounts/:id/wallet/reject', async (c) => { + const uta = ctx.accountManager.get(c.req.param('id')) + if (!uta) return c.json({ error: 'Account not found' }, 404) + if (!uta.status().pendingMessage) return c.json({ error: 'Nothing to reject' }, 400) + try { + const body = await c.req.json().catch(() => ({})) + const reason = typeof body.reason === 'string' ? body.reason : undefined + const result = await uta.reject(reason) + return c.json(result) + } catch (err) { + return c.json({ error: String(err) }, 500) + } + }) + // Push (manual approval — the AI tool is hollowed out, only humans can push) app.post('/accounts/:id/wallet/push', async (c) => { const uta = ctx.accountManager.get(c.req.param('id')) diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index d2621f92..315bc92b 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -16,6 +16,7 @@ import type { AddResult, CommitPrepareResult, PushResult, + RejectResult, GitStatus, GitCommit, GitState, @@ -255,6 +256,10 @@ export class UnifiedTradingAccount { return this.git.push() } + reject(reason?: string): Promise { + return this.git.reject(reason) + } + // ==================== Git queries ==================== log(options?: { limit?: number; symbol?: string }): CommitLogEntry[] { diff --git a/src/domain/trading/git/TradingGit.ts b/src/domain/trading/git/TradingGit.ts index f005142a..9fff6d0f 100644 --- a/src/domain/trading/git/TradingGit.ts +++ b/src/domain/trading/git/TradingGit.ts @@ -14,6 +14,7 @@ import type { AddResult, CommitPrepareResult, PushResult, + RejectResult, GitStatus, GitCommit, GitState, @@ -138,6 +139,50 @@ export class TradingGit implements ITradingGit { return { hash, message, operationCount: operations.length, submitted, rejected } } + async reject(reason?: string): Promise { + if (this.stagingArea.length === 0) { + throw new Error('Nothing to reject: staging area is empty') + } + if (this.pendingMessage === null || this.pendingHash === null) { + throw new Error('Nothing to reject: please commit first') + } + + const operations = [...this.stagingArea] + const message = `[rejected] ${this.pendingMessage}${reason ? ` — ${reason}` : ''}` + const hash = this.pendingHash + + const results: OperationResult[] = operations.map((op) => ({ + action: op.action, + success: false, + status: 'user-rejected' as const, + error: reason || 'Rejected by user', + })) + + const stateAfter = await this.config.getGitState() + + const commit: GitCommit = { + hash, + parentHash: this.head, + message, + operations, + results, + stateAfter, + timestamp: new Date().toISOString(), + round: this.currentRound, + } + + this.commits.push(commit) + this.head = hash + await this.config.onCommit?.(this.exportState()) + + // Clear staging + this.stagingArea = [] + this.pendingMessage = null + this.pendingHash = null + + return { hash, message, operationCount: operations.length } + } + // ==================== git log / show / status ==================== log(options: { limit?: number; symbol?: string } = {}): CommitLogEntry[] { @@ -197,6 +242,9 @@ export class TradingGit implements ITradingGit { const hasCash = cashQty !== UNSET_DOUBLE && cashQty > 0 const sizeStr = hasCash ? `$${cashQty}` : hasQty ? `${qty}` : '?' + if (result?.status === 'user-rejected') { + return `${side} ${sizeStr} (user-rejected)` + } if (result?.status === 'filled') { const price = result.execution?.price ? ` @${result.execution.price}` : '' return `${side} ${sizeStr}${price}` diff --git a/src/domain/trading/git/index.ts b/src/domain/trading/git/index.ts index f10ca889..094f725b 100644 --- a/src/domain/trading/git/index.ts +++ b/src/domain/trading/git/index.ts @@ -9,6 +9,7 @@ export type { AddResult, CommitPrepareResult, PushResult, + RejectResult, GitStatus, GitCommit, GitState, diff --git a/src/domain/trading/git/interfaces.ts b/src/domain/trading/git/interfaces.ts index 49615e30..8ffd1c62 100644 --- a/src/domain/trading/git/interfaces.ts +++ b/src/domain/trading/git/interfaces.ts @@ -11,6 +11,7 @@ import type { AddResult, CommitPrepareResult, PushResult, + RejectResult, GitStatus, GitCommit, CommitLogEntry, @@ -28,6 +29,7 @@ export interface ITradingGit { add(operation: Operation): AddResult commit(message: string): CommitPrepareResult push(): Promise + reject(reason?: string): Promise // ---- git log / show / status ---- diff --git a/src/domain/trading/git/types.ts b/src/domain/trading/git/types.ts index a1f94ca0..04366a2c 100644 --- a/src/domain/trading/git/types.ts +++ b/src/domain/trading/git/types.ts @@ -28,7 +28,7 @@ export type Operation = // ==================== Operation Result ==================== -export type OperationStatus = 'submitted' | 'filled' | 'rejected' | 'cancelled' +export type OperationStatus = 'submitted' | 'filled' | 'rejected' | 'cancelled' | 'user-rejected' export interface OperationResult { action: OperationAction @@ -89,6 +89,12 @@ export interface PushResult { rejected: OperationResult[] } +export interface RejectResult { + hash: CommitHash + message: string + operationCount: number +} + export interface GitStatus { staged: Operation[] pendingMessage: string | null diff --git a/ui/src/api/trading.ts b/ui/src/api/trading.ts index 1086adfd..2cb14a90 100644 --- a/ui/src/api/trading.ts +++ b/ui/src/api/trading.ts @@ -1,5 +1,5 @@ import { fetchJson } from './client' -import type { TradingAccount, AccountInfo, Position, WalletCommitLog, ReconnectResult, PlatformConfig, AccountConfig, WalletStatus, WalletPushResult } from './types' +import type { TradingAccount, AccountInfo, Position, WalletCommitLog, ReconnectResult, PlatformConfig, AccountConfig, WalletStatus, WalletPushResult, WalletRejectResult } from './types' // ==================== Unified Trading API ==================== @@ -53,6 +53,19 @@ export const tradingApi = { return fetchJson(`/api/trading/accounts/${accountId}/wallet/status`) }, + async walletReject(accountId: string, reason?: string): Promise { + const res = await fetch(`/api/trading/accounts/${accountId}/wallet/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(reason ? { reason } : {}), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `Reject failed (${res.status})`) + } + return res.json() + }, + async walletPush(accountId: string): Promise { const res = await fetch(`/api/trading/accounts/${accountId}/wallet/push`, { method: 'POST' }) if (!res.ok) { diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index b8d20382..c650a4c2 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -216,6 +216,12 @@ export interface WalletStatus { commitCount: number } +export interface WalletRejectResult { + hash: string + message: string + operationCount: number +} + export interface WalletPushResult { hash: string message: string diff --git a/ui/src/components/PushApprovalPanel.tsx b/ui/src/components/PushApprovalPanel.tsx index 1b185b78..85e6698a 100644 --- a/ui/src/components/PushApprovalPanel.tsx +++ b/ui/src/components/PushApprovalPanel.tsx @@ -1,59 +1,123 @@ import { useState, useEffect, useCallback } from 'react' import { api } from '../api' -import type { TradingAccount, WalletStatus, WalletPushResult } from '../api/types' +import type { TradingAccount, WalletStatus, WalletPushResult, WalletCommitLog } from '../api/types' + +// ==================== Types ==================== interface PendingAccount { account: TradingAccount status: WalletStatus } -/** Format an operation for display. */ -function formatOp(op: WalletStatus['staged'][number]): string { - const symbol = op.contract?.aliceId || op.contract?.symbol || op.contract?.localSymbol || '' +interface AccountHistory { + accountId: string + label: string + commits: WalletCommitLog[] +} + +// ==================== Helpers ==================== + +/** Extract symbol from operation. */ +function opSymbol(op: WalletStatus['staged'][number]): string { + const raw = op.contract?.aliceId || op.contract?.symbol || op.contract?.localSymbol || '' + // Strip "accountId|" prefix from aliceId + const sep = raw.indexOf('|') + return sep !== -1 ? raw.slice(sep + 1) : raw +} + +/** Format operation for display — returns { text, isBuy } */ +function formatOp(op: WalletStatus['staged'][number]): { text: string; side?: 'buy' | 'sell' } { + const symbol = opSymbol(op) switch (op.action) { case 'placeOrder': { - const side = op.order?.action || '?' - const type = op.order?.orderType || '' + const sideRaw = (op.order?.action || '').toUpperCase() + const isBuy = sideRaw === 'BUY' + const type = (op.order?.orderType || '').toUpperCase() + const typeBadge = type === 'MKT' || type === 'MARKET' ? 'MKT' : type === 'LMT' || type === 'LIMIT' ? 'LMT' : type const qty = op.order?.totalQuantity ?? op.order?.cashQty ?? '' + const qtyStr = typeof qty === 'number' ? qty.toLocaleString() : String(qty) const price = op.order?.lmtPrice ? ` @ ${op.order.lmtPrice}` : '' - return `${side} ${qty} ${symbol} ${type}${price}`.trim() + return { + text: `${sideRaw} ${qtyStr} ${symbol} ${typeBadge}${price}`.trim(), + side: isBuy ? 'buy' : 'sell', + } } case 'closePosition': - return `Close ${symbol}${op.quantity ? ` (${op.quantity})` : ''}` + return { text: `CLOSE ${symbol}${op.quantity ? ` (${op.quantity})` : ''}`, side: 'sell' } case 'modifyOrder': - return `Modify order ${op.orderId || '?'}` + return { text: `MODIFY ${op.orderId || '?'}` } case 'cancelOrder': - return `Cancel order ${op.orderId || '?'}` + return { text: `CANCEL ${op.orderId || '?'}` } case 'syncOrders': - return 'Sync orders' + return { text: 'SYNC' } default: - return op.action + return { text: op.action } + } +} + +/** Relative time string. */ +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + return `${Math.floor(hours / 24)}d ago` +} + +/** Status badge color. */ +function statusColor(status: string): string { + switch (status) { + case 'submitted': return 'text-blue-400' + case 'filled': return 'text-green-400' + case 'rejected': return 'text-red-400' + case 'user-rejected': return 'text-orange-400' + case 'cancelled': return 'text-text-muted' + default: return 'text-text-muted' } } +// ==================== Component ==================== + export function PushApprovalPanel() { + const [accounts, setAccounts] = useState([]) const [pending, setPending] = useState([]) + const [history, setHistory] = useState([]) const [pushing, setPushing] = useState(null) - const [result, setResult] = useState<{ accountId: string; data: WalletPushResult } | null>(null) + const [rejecting, setRejecting] = useState(null) + const [confirmingPush, setConfirmingPush] = useState(null) + const [lastResult, setLastResult] = useState<{ accountId: string; data: WalletPushResult } | null>(null) const [error, setError] = useState(null) const poll = useCallback(async () => { try { - const { accounts } = await api.trading.listAccounts() - const results: PendingAccount[] = [] - for (const account of accounts) { + const { accounts: accts } = await api.trading.listAccounts() + setAccounts(accts) + + const pendingResults: PendingAccount[] = [] + const historyResults: AccountHistory[] = [] + + for (const account of accts) { try { - const status = await api.trading.walletStatus(account.id) + const [status, { commits }] = await Promise.all([ + api.trading.walletStatus(account.id), + api.trading.walletLog(account.id, 10), + ]) if (status.pendingMessage) { - results.push({ account, status }) + pendingResults.push({ account, status }) + } + if (commits.length > 0) { + historyResults.push({ accountId: account.id, label: account.label || account.id, commits }) } - } catch { /* skip unreachable accounts */ } + } catch { /* skip unreachable */ } } - setPending(results) + + setPending(pendingResults) + setHistory(historyResults) } catch { /* ignore */ } }, []) - // Poll every 3 seconds useEffect(() => { poll() const id = setInterval(poll, 3000) @@ -62,12 +126,12 @@ export function PushApprovalPanel() { const handlePush = useCallback(async (accountId: string) => { setPushing(accountId) + setConfirmingPush(null) setError(null) - setResult(null) + setLastResult(null) try { const data = await api.trading.walletPush(accountId) - setResult({ accountId, data }) - // Refresh pending list + setLastResult({ accountId, data }) await poll() } catch (err) { setError(err instanceof Error ? err.message : 'Push failed') @@ -76,78 +140,179 @@ export function PushApprovalPanel() { } }, [poll]) - // Nothing pending and no recent result — hide panel - if (pending.length === 0 && !result) return null + const handleReject = useCallback(async (accountId: string) => { + setRejecting(accountId) + setError(null) + try { + await api.trading.walletReject(accountId) + await poll() + } catch (err) { + setError(err instanceof Error ? err.message : 'Reject failed') + } finally { + setRejecting(null) + } + }, [poll]) + + // No trading accounts configured — hide panel entirely + if (accounts.length === 0) return null + + const hasPending = pending.length > 0 + const hasHistory = history.length > 0 return (
-
-

Pending Push

+ {/* Header */} +
+ + + +

Trading

+ {hasPending && ( + + )}
-
- {pending.map(({ account, status }) => ( -
-
{account.label || account.id}
+
+ {/* ==================== Pending Section ==================== */} + {hasPending ? ( +
+ {pending.map(({ account, status }) => ( +
+
+ {account.label || account.id} +
- {/* Commit message */} -
- {status.pendingMessage} -
+ {/* Commit message */} +
+ {status.pendingMessage} +
- {/* Operations */} -
- {status.staged.map((op, i) => ( -
- {formatOp(op)} + {/* Staged operations */} +
+ {status.staged.map((op, i) => { + const { text, side } = formatOp(op) + return ( +
+ {text} +
+ ) + })}
- ))} -
- {/* Actions */} -
- -
-
- ))} - - {/* Result feedback */} - {result && ( -
-
Last push
-
- {result.data.submitted.length > 0 && ( - {result.data.submitted.length} submitted - )} - {result.data.rejected.length > 0 && ( - <> - {result.data.submitted.length > 0 && ', '} - {result.data.rejected.length} rejected - - )} -
- {result.data.rejected.map((r, i) => ( -
{r.error || 'Unknown error'}
+ {/* Inline confirm or action buttons */} + {confirmingPush === account.id ? ( +
+ Execute {status.staged.length} op{status.staged.length > 1 ? 's' : ''}? + + +
+ ) : ( +
+ + +
+ )} +
))} - + + {/* Last push result feedback */} + {lastResult && ( +
+
Last push
+
+ {lastResult.data.submitted.length > 0 && ( + {lastResult.data.submitted.length} submitted + )} + {lastResult.data.rejected.length > 0 && ( + <> + {lastResult.data.submitted.length > 0 && ', '} + {lastResult.data.rejected.length} rejected + + )} +
+ {lastResult.data.rejected.map((r, i) => ( +
{r.error || 'Unknown error'}
+ ))} + +
+ )} + + {error && ( +
+ {error} + +
+ )} +
+ ) : ( +
+ No pending operations
)} - {error && ( -
- {error} - + {/* ==================== History Section ==================== */} + {hasHistory && ( +
+
+
History
+
+
+ {history.map(({ accountId, label, commits }) => ( +
+ {history.length > 1 && ( +
{label}
+ )} + {commits.map((commit) => ( +
+
+ {commit.hash} + {timeAgo(commit.timestamp)} +
+
{commit.message}
+ {commit.operations.length > 0 && ( +
+ {commit.operations.map((op, i) => ( + + {op.symbol !== 'unknown' ? op.symbol : op.action} · {op.status} + + ))} +
+ )} +
+ ))} +
+ ))} +
)}
From d2df0729b6c0ad5f8c88454aa2ace40eba7279e0 Mon Sep 17 00:00:00 2001 From: Ame Date: Wed, 18 Mar 2026 19:10:59 +0800 Subject: [PATCH 4/4] fix: rehydrate Decimal fields in TradingGit.restore(), add UTA Bybit e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TradingGit.restore() now rebuilds Decimal instances (Order.totalQuantity, Position.quantity, closePosition.quantity) lost during JSON round-trip, fixing "qty.equals is not a function" crash on walletLog after restart. Add standalone UTA-level e2e test for Bybit (uta-ccxt-bybit.e2e.spec.ts) covering full lifecycle (stage→commit→push→sync→close) and reject flow. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__test__/e2e/uta-ccxt-bybit.e2e.spec.ts | 151 ++++++++++++++++++ src/domain/trading/git/TradingGit.ts | 50 +++++- 2 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts diff --git a/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts new file mode 100644 index 00000000..d6385a1e --- /dev/null +++ b/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts @@ -0,0 +1,151 @@ +/** + * UTA e2e — Trading-as-Git lifecycle against Bybit demo (crypto perps). + * + * Tests: stage → commit → push → sync → reject → log + * Crypto markets are 24/7, so this test is always runnable. + * + * Run: pnpm test:e2e + */ + +import { describe, it, expect, beforeAll } from 'vitest' +import { getTestAccounts, filterByProvider } from './setup.js' +import { UnifiedTradingAccount } from '../../UnifiedTradingAccount.js' +import type { IBroker } from '../../brokers/types.js' +import '../../contract-ext.js' + +describe('UTA — Bybit demo (ETH perp)', () => { + let broker: IBroker | null = null + let ethAliceId = '' + + beforeAll(async () => { + const all = await getTestAccounts() + const bybit = filterByProvider(all, 'ccxt').find(a => a.id.includes('bybit')) + if (!bybit) { + console.log('e2e: No Bybit demo account, skipping') + return + } + broker = bybit.broker + + const results = await broker.searchContracts('ETH') + const perp = results.find(r => r.contract.localSymbol?.includes('USDT:USDT')) + if (!perp) { + console.log('e2e: No ETH/USDT perp found, skipping') + broker = null + return + } + ethAliceId = `${bybit.id}|${perp.contract.localSymbol!}` + console.log(`UTA Bybit: aliceId=${ethAliceId}`) + }, 60_000) + + it('buy → sync → close → sync (full lifecycle)', async () => { + if (!broker) { console.log('e2e: skipped'); return } + + const uta = new UnifiedTradingAccount(broker) + const initialPositions = await broker.getPositions() + const initialQty = initialPositions.find(p => p.contract.localSymbol?.includes('USDT:USDT'))?.quantity.toNumber() ?? 0 + console.log(` initial ETH qty=${initialQty}`) + + // Stage + Commit + Push: buy 0.01 ETH + uta.stagePlaceOrder({ aliceId: ethAliceId, side: 'buy', type: 'market', qty: 0.01 }) + uta.commit('e2e: buy 0.01 ETH') + const pushResult = await uta.push() + expect(pushResult.submitted).toHaveLength(1) + expect(pushResult.rejected).toHaveLength(0) + console.log(` pushed: orderId=${pushResult.submitted[0].orderId}`) + + // Sync: confirm fill + const sync1 = await uta.sync({ delayMs: 3000 }) + expect(sync1.updatedCount).toBe(1) + expect(sync1.updates[0].currentStatus).toBe('filled') + console.log(` sync1: filled`) + + // Verify position + const state = await uta.getState() + const ethPos = state.positions.find(p => p.contract.aliceId === ethAliceId) + expect(ethPos).toBeDefined() + console.log(` position: qty=${ethPos!.quantity}`) + + // Close + uta.stageClosePosition({ aliceId: ethAliceId, qty: 0.01 }) + uta.commit('e2e: close 0.01 ETH') + const closePush = await uta.push() + expect(closePush.submitted).toHaveLength(1) + + const sync2 = await uta.sync({ delayMs: 3000 }) + expect(sync2.updatedCount).toBe(1) + expect(sync2.updates[0].currentStatus).toBe('filled') + console.log(` close: filled`) + + // Verify final qty + const finalPositions = await broker.getPositions() + const finalQty = finalPositions.find(p => p.contract.localSymbol?.includes('USDT:USDT'))?.quantity.toNumber() ?? 0 + expect(Math.abs(finalQty - initialQty)).toBeLessThan(0.02) + console.log(` final ETH qty=${finalQty} (initial=${initialQty})`) + + // Log: at least 4 commits (buy, sync, close, sync) + const log = uta.log({ limit: 10 }) + expect(log.length).toBeGreaterThanOrEqual(4) + console.log(` log: ${log.length} commits`) + }, 60_000) + + it('reject records user-rejected commit and clears staging', async () => { + if (!broker) { console.log('e2e: skipped'); return } + + const uta = new UnifiedTradingAccount(broker) + + // Stage + Commit (but don't push) + uta.stagePlaceOrder({ aliceId: ethAliceId, side: 'buy', type: 'market', qty: 0.01 }) + const commitResult = uta.commit('e2e: buy to be rejected') + expect(commitResult.prepared).toBe(true) + console.log(` committed: hash=${commitResult.hash}`) + + // Verify staging has content + const statusBefore = uta.status() + expect(statusBefore.staged).toHaveLength(1) + expect(statusBefore.pendingMessage).toBe('e2e: buy to be rejected') + + // Reject + const rejectResult = await uta.reject('user declined') + expect(rejectResult.operationCount).toBe(1) + expect(rejectResult.message).toContain('[rejected]') + expect(rejectResult.message).toContain('user declined') + console.log(` rejected: hash=${rejectResult.hash}, message="${rejectResult.message}"`) + + // Verify staging is cleared + const statusAfter = uta.status() + expect(statusAfter.staged).toHaveLength(0) + expect(statusAfter.pendingMessage).toBeNull() + + // Verify commit is in history with user-rejected status + const log = uta.log({ limit: 5 }) + const rejectedCommit = log.find(c => c.hash === rejectResult.hash) + expect(rejectedCommit).toBeDefined() + expect(rejectedCommit!.message).toContain('[rejected]') + expect(rejectedCommit!.operations[0].status).toBe('user-rejected') + console.log(` log entry: ${rejectedCommit!.operations[0].status}`) + + // Show the full commit + const fullCommit = uta.show(rejectResult.hash) + expect(fullCommit).not.toBeNull() + expect(fullCommit!.results[0].status).toBe('user-rejected') + expect(fullCommit!.results[0].error).toBe('user declined') + console.log(` show: results[0].error="${fullCommit!.results[0].error}"`) + }, 30_000) + + it('reject without reason still works', async () => { + if (!broker) { console.log('e2e: skipped'); return } + + const uta = new UnifiedTradingAccount(broker) + uta.stagePlaceOrder({ aliceId: ethAliceId, side: 'sell', type: 'limit', qty: 0.01, price: 99999 }) + uta.commit('e2e: sell to be rejected silently') + + const result = await uta.reject() + expect(result.operationCount).toBe(1) + expect(result.message).toContain('[rejected]') + expect(result.message).not.toContain('—') // no reason suffix + + const fullCommit = uta.show(result.hash) + expect(fullCommit!.results[0].error).toBe('Rejected by user') + console.log(` rejected without reason: ok`) + }, 15_000) +}) diff --git a/src/domain/trading/git/TradingGit.ts b/src/domain/trading/git/TradingGit.ts index 9fff6d0f..f0ead315 100644 --- a/src/domain/trading/git/TradingGit.ts +++ b/src/domain/trading/git/TradingGit.ts @@ -5,7 +5,8 @@ */ import { createHash } from 'crypto' -import { UNSET_DOUBLE, UNSET_DECIMAL } from '@traderalice/ibkr' +import Decimal from 'decimal.js' +import { Order, UNSET_DOUBLE, UNSET_DECIMAL } from '@traderalice/ibkr' import type { ITradingGit, TradingGitConfig } from './interfaces.js' import type { CommitHash, @@ -298,11 +299,56 @@ export class TradingGit implements ITradingGit { static restore(state: GitExportState, config: TradingGitConfig): TradingGit { const git = new TradingGit(config) - git.commits = [...state.commits] + git.commits = state.commits.map(TradingGit.rehydrateCommit) git.head = state.head return git } + /** Rehydrate Decimal fields lost during JSON round-trip. */ + private static rehydrateCommit(commit: GitCommit): GitCommit { + return { + ...commit, + operations: commit.operations.map(TradingGit.rehydrateOperation), + stateAfter: TradingGit.rehydrateGitState(commit.stateAfter), + } + } + + private static rehydrateOperation(op: Operation): Operation { + switch (op.action) { + case 'placeOrder': + return { + ...op, + order: op.order ? TradingGit.rehydrateOrder(op.order) : op.order, + } + case 'closePosition': + return { + ...op, + quantity: op.quantity != null ? new Decimal(String(op.quantity)) : op.quantity, + } + default: + return op + } + } + + private static rehydrateOrder(order: Order): Order { + const rehydrated = Object.assign(new Order(), order) + // totalQuantity is the critical Decimal field on Order + if (order.totalQuantity != null) { + rehydrated.totalQuantity = new Decimal(String(order.totalQuantity)) + } + return rehydrated + } + + private static rehydrateGitState(state: GitState): GitState { + return { + ...state, + positions: state.positions.map((pos) => ({ + ...pos, + quantity: new Decimal(String(pos.quantity)), + })), + } + } + setCurrentRound(round: number): void { this.currentRound = round }