Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
5 changes: 3 additions & 2 deletions packages/ibkr/src/client/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ export class EClient {
buf = rest
const fields = readFields(msg)
if (fields.length >= 2) {
clearTimeout(timer)
this.conn!.removeListener('data', onData)
resolve({
serverVersion: parseInt(fields[0], 10),
Expand All @@ -293,8 +294,8 @@ export class EClient {
this.conn!.on('data', onData)

// Timeout after 10 seconds
setTimeout(() => {
this.conn!.removeListener('data', onData)
const timer = setTimeout(() => {
this.conn?.removeListener('data', onData)
reject(new Error('Handshake timeout'))
}, 10000)
})
Expand Down
25 changes: 23 additions & 2 deletions src/ai-providers/agent-sdk/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,14 +213,35 @@ export async function askAgentSdk(
} else {
ok = false
resultText = result.errors?.join('\n') ?? `Agent SDK error: ${result.subtype}`
// Log failed results with all available detail
const resultDetail = { subtype: result.subtype, errors: result.errors, result: result.result }
logger.error({ ...resultDetail, turns: result.num_turns, durationMs: result.duration_ms }, 'result_error')
console.error('[agent-sdk] Non-success result:', resultDetail)
}
logger.info({ subtype: result.subtype, turns: result.num_turns, durationMs: result.duration_ms }, 'result')
}
}
} catch (err) {
logger.error({ error: String(err) }, 'query_error')
// Extract as much detail as possible from the error
const errObj = err instanceof Error ? err : new Error(String(err))
const details: Record<string, unknown> = {
message: errObj.message,
stack: errObj.stack,
}
// SDK errors may carry stderr/stdout/cause as extra properties
for (const key of ['stderr', 'stdout', 'cause', 'code', 'signal'] as const) {
if ((errObj as any)[key] != null) details[key] = (errObj as any)[key]
}
// Enumerate any non-standard properties on the error object
const extraKeys = Object.keys(errObj).filter(k => !(k in details))
for (const k of extraKeys) details[k] = (errObj as any)[k]

logger.error(details, 'query_error')
// Also log to console so the developer can see it in the terminal
console.error('[agent-sdk] Claude Code process error:', details)
ok = false
resultText = `Agent SDK error: ${err}`
const stderrHint = details.stderr ? `\nstderr: ${details.stderr}` : ''
resultText = `Agent SDK error: ${errObj.message}${stderrHint}`
}

// Fallback: if result is empty, extract last assistant text
Expand Down
136 changes: 41 additions & 95 deletions src/connectors/web/routes/trading-config.ts
Original file line number Diff line number Diff line change
@@ -1,90 +1,58 @@
import { Hono } from 'hono'
import type { EngineContext } from '../../../core/types.js'
import {
readPlatformsConfig, writePlatformsConfig,
readAccountsConfig, writeAccountsConfig,
platformConfigSchema, accountConfigSchema,
accountConfigSchema,
} from '../../../core/config.js'
import { createPlatformFromConfig, createBrokerFromConfig } from '../../../domain/trading/brokers/factory.js'
import { createBroker } from '../../../domain/trading/brokers/factory.js'

// ==================== Credential helpers ====================

/** Mask a secret string: show last 4 chars, prefix with "****" */
function mask(value: string | undefined): string | undefined {
if (!value) return value
function mask(value: string): string {
if (value.length <= 4) return '****'
return '****' + value.slice(-4)
}

/** Trading config CRUD routes: platforms + accounts */
export function createTradingConfigRoutes(ctx: EngineContext) {
const app = new Hono()
/** Field names that contain sensitive values. Convention-based, not hardcoded per broker. */
const SENSITIVE = /key|secret|password|token/i

// ==================== Read all ====================
/** Mask all sensitive string fields in a config object. */
function maskSecrets<T extends Record<string, unknown>>(obj: T): T {
const result = { ...obj }
for (const [k, v] of Object.entries(result)) {
if (typeof v === 'string' && v.length > 0 && SENSITIVE.test(k)) {
;(result as Record<string, unknown>)[k] = mask(v)
}
}
return result
}

app.get('/', async (c) => {
try {
const [platforms, accounts] = await Promise.all([
readPlatformsConfig(),
readAccountsConfig(),
])
// Mask credentials in response
const maskedAccounts = accounts.map((a) => ({
...a,
apiKey: mask(a.apiKey),
apiSecret: mask(a.apiSecret),
password: mask(a.password),
}))
return c.json({ platforms, accounts: maskedAccounts })
} catch (err) {
return c.json({ error: String(err) }, 500)
/** Restore masked values (****...) from existing config. */
function unmaskSecrets(
body: Record<string, unknown>,
existing: Record<string, unknown>,
): void {
for (const [k, v] of Object.entries(body)) {
if (typeof v === 'string' && v.startsWith('****') && typeof existing[k] === 'string') {
body[k] = existing[k]
}
})
}
}

// ==================== Platforms CRUD ====================
// ==================== Routes ====================

app.put('/platforms/:id', async (c) => {
try {
const id = c.req.param('id')
const body = await c.req.json()
if (body.id !== id) {
return c.json({ error: 'Body id must match URL id' }, 400)
}
const validated = platformConfigSchema.parse(body)
const platforms = await readPlatformsConfig()
const idx = platforms.findIndex((p) => p.id === id)
if (idx >= 0) {
platforms[idx] = validated
} else {
platforms.push(validated)
}
await writePlatformsConfig(platforms)
return c.json(validated)
} catch (err) {
if (err instanceof Error && err.name === 'ZodError') {
return c.json({ error: 'Validation failed', details: JSON.parse(err.message) }, 400)
}
return c.json({ error: String(err) }, 500)
}
})
/** Trading config CRUD routes: accounts */
export function createTradingConfigRoutes(ctx: EngineContext) {
const app = new Hono()

// ==================== Read all ====================

app.delete('/platforms/:id', async (c) => {
app.get('/', async (c) => {
try {
const id = c.req.param('id')
const [platforms, accounts] = await Promise.all([
readPlatformsConfig(),
readAccountsConfig(),
])
const refs = accounts.filter((a) => a.platformId === id)
if (refs.length > 0) {
return c.json({
error: `Platform "${id}" is referenced by ${refs.length} account(s): ${refs.map((a) => a.id).join(', ')}. Remove them first.`,
}, 400)
}
const filtered = platforms.filter((p) => p.id !== id)
if (filtered.length === platforms.length) {
return c.json({ error: `Platform "${id}" not found` }, 404)
}
await writePlatformsConfig(filtered)
return c.json({ success: true })
const accounts = await readAccountsConfig()
const maskedAccounts = accounts.map((a) => maskSecrets({ ...a }))
return c.json({ accounts: maskedAccounts })
} catch (err) {
return c.json({ error: String(err) }, 500)
}
Expand All @@ -100,23 +68,15 @@ export function createTradingConfigRoutes(ctx: EngineContext) {
return c.json({ error: 'Body id must match URL id' }, 400)
}

// Resolve masked credentials: if value is masked, keep the existing value
// Restore masked credentials from existing config
const accounts = await readAccountsConfig()
const existing = accounts.find((a) => a.id === id)
if (existing) {
if (body.apiKey && body.apiKey.startsWith('****')) body.apiKey = existing.apiKey
if (body.apiSecret && body.apiSecret.startsWith('****')) body.apiSecret = existing.apiSecret
if (body.password && body.password.startsWith('****')) body.password = existing.password
unmaskSecrets(body, existing as unknown as Record<string, unknown>)
}

const validated = accountConfigSchema.parse(body)

// Validate platformId reference
const platforms = await readPlatformsConfig()
if (!platforms.some((p) => p.id === validated.platformId)) {
return c.json({ error: `Platform "${validated.platformId}" not found` }, 400)
}

const idx = accounts.findIndex((a) => a.id === id)
if (idx >= 0) {
accounts[idx] = validated
Expand Down Expand Up @@ -160,23 +120,9 @@ export function createTradingConfigRoutes(ctx: EngineContext) {
let broker: { init: () => Promise<void>; getAccount: () => Promise<unknown>; close: () => Promise<void> } | null = null
try {
const body = await c.req.json()
const platformConfig = platformConfigSchema.parse(body.platform)
const { apiKey, apiSecret, password } = body.credentials ?? {}

if (!apiKey || !apiSecret) {
return c.json({ success: false, error: 'API key and secret are required' }, 400)
}

const platform = createPlatformFromConfig(platformConfig)
broker = createBrokerFromConfig(platform, {
id: '__test__',
platformId: platformConfig.id,
apiKey,
apiSecret,
password,
guards: [],
})
const accountConfig = accountConfigSchema.parse({ ...body, id: body.id ?? '__test__' })

broker = createBroker(accountConfig)
await broker.init()
const account = await broker.getAccount()
return c.json({ success: true, account })
Expand Down
96 changes: 55 additions & 41 deletions src/connectors/web/routes/trading.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
import { Hono } from 'hono'
import type { Context } from 'hono'
import type { EngineContext } from '../../../core/types.js'
import { BrokerError } from '../../../domain/trading/brokers/types.js'
import type { UnifiedTradingAccount } from '../../../domain/trading/UnifiedTradingAccount.js'

/** Resolve account by :id param, return 404 if not found. */
function resolveAccount(ctx: EngineContext, c: Context): UnifiedTradingAccount | null {
return ctx.accountManager.get(c.req.param('id')) ?? null
}

/**
* Execute a data query against a UTA with health-aware error handling.
* - Offline → 503 + nudge recovery
* - Transient error → 503
* - Permanent error → 500
*/
async function queryAccount<T>(
c: Context,
account: UnifiedTradingAccount,
fn: () => Promise<T>,
): Promise<Response> {
if (account.health === 'offline') {
account.nudgeRecovery()
return c.json({
error: 'Account temporarily unavailable',
health: account.getHealthInfo(),
}, 503)
}
try {
return c.json(await fn())
} catch (err) {
const be = err instanceof BrokerError ? err : BrokerError.from(err)
return c.json({
error: be.message,
code: be.code,
transient: !be.permanent,
}, be.permanent ? 500 : 503)
}
}

/** Unified trading routes — works with all account types via AccountManager */
export function createTradingRoutes(ctx: EngineContext) {
Expand All @@ -14,12 +52,8 @@ export function createTradingRoutes(ctx: EngineContext) {
// ==================== Aggregated equity ====================

app.get('/equity', async (c) => {
try {
const equity = await ctx.accountManager.getAggregatedEquity()
return c.json(equity)
} catch (err) {
return c.json({ error: String(err) }, 500)
}
const equity = await ctx.accountManager.getAggregatedEquity()
return c.json(equity)
})

// ==================== Per-account routes ====================
Expand All @@ -33,67 +67,47 @@ export function createTradingRoutes(ctx: EngineContext) {

// Account info
app.get('/accounts/:id/account', async (c) => {
const account = ctx.accountManager.get(c.req.param('id'))
const account = resolveAccount(ctx, c)
if (!account) return c.json({ error: 'Account not found' }, 404)
try {
return c.json(await account.getAccount())
} catch (err) {
return c.json({ error: String(err) }, 500)
}
return queryAccount(c, account, () => account.getAccount())
})

// Positions
app.get('/accounts/:id/positions', async (c) => {
const account = ctx.accountManager.get(c.req.param('id'))
const account = resolveAccount(ctx, c)
if (!account) return c.json({ error: 'Account not found' }, 404)
try {
const positions = await account.getPositions()
return c.json({ positions })
} catch (err) {
return c.json({ error: String(err) }, 500)
}
return queryAccount(c, account, async () => ({ positions: await account.getPositions() }))
})

// Orders
app.get('/accounts/:id/orders', async (c) => {
const account = ctx.accountManager.get(c.req.param('id'))
const account = resolveAccount(ctx, c)
if (!account) return c.json({ error: 'Account not found' }, 404)
try {
// Default to pending orders if no ids specified
return queryAccount(c, account, async () => {
const idsParam = c.req.query('ids')
const orderIds = idsParam ? idsParam.split(',') : account.getPendingOrderIds().map(p => p.orderId)
const orders = await account.getOrders(orderIds)
return c.json({ orders })
} catch (err) {
return c.json({ error: String(err) }, 500)
}
return { orders }
})
})

// Market clock (optional capability)
// Market clock
app.get('/accounts/:id/market-clock', async (c) => {
const account = ctx.accountManager.get(c.req.param('id'))
const account = resolveAccount(ctx, c)
if (!account) return c.json({ error: 'Account not found' }, 404)
if (!account.getMarketClock) return c.json({ error: 'Market clock not supported' }, 501)
try {
return c.json(await account.getMarketClock())
} catch (err) {
return c.json({ error: String(err) }, 500)
}
return queryAccount(c, account, () => account.getMarketClock())
})

// Quote
app.get('/accounts/:id/quote/:symbol', async (c) => {
const account = ctx.accountManager.get(c.req.param('id'))
const account = resolveAccount(ctx, c)
if (!account) return c.json({ error: 'Account not found' }, 404)
try {
return queryAccount(c, account, async () => {
const { Contract } = await import('@traderalice/ibkr')
const contract = new Contract()
contract.symbol = c.req.param('symbol')
const quote = await account.getQuote(contract)
return c.json(quote)
} catch (err) {
return c.json({ error: String(err) }, 500)
}
return account.getQuote(contract)
})
})

// ==================== Per-account wallet/git routes ====================
Expand Down
Loading
Loading