Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/ai-providers/agent-sdk/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down Expand Up @@ -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 })
Expand Down
3 changes: 1 addition & 2 deletions src/ai-providers/claude-code/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down Expand Up @@ -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 })
Expand Down
6 changes: 0 additions & 6 deletions src/ai-providers/vercel-ai-sdk/agent.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
}
},
})
}

Expand Down
28 changes: 28 additions & 0 deletions src/connectors/web/routes/trading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,5 +120,33 @@ 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'))
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
}
5 changes: 5 additions & 0 deletions src/domain/trading/UnifiedTradingAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
AddResult,
CommitPrepareResult,
PushResult,
RejectResult,
GitStatus,
GitCommit,
GitState,
Expand Down Expand Up @@ -255,6 +256,10 @@ export class UnifiedTradingAccount {
return this.git.push()
}

reject(reason?: string): Promise<RejectResult> {
return this.git.reject(reason)
}

// ==================== Git queries ====================

log(options?: { limit?: number; symbol?: string }): CommitLogEntry[] {
Expand Down
151 changes: 151 additions & 0 deletions src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
98 changes: 96 additions & 2 deletions src/domain/trading/git/TradingGit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,6 +15,7 @@ import type {
AddResult,
CommitPrepareResult,
PushResult,
RejectResult,
GitStatus,
GitCommit,
GitState,
Expand Down Expand Up @@ -138,6 +140,50 @@ export class TradingGit implements ITradingGit {
return { hash, message, operationCount: operations.length, submitted, rejected }
}

async reject(reason?: string): Promise<RejectResult> {
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[] {
Expand Down Expand Up @@ -197,6 +243,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}`
Expand Down Expand Up @@ -250,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
}
Expand Down
Loading
Loading