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
45 changes: 37 additions & 8 deletions src/connectors/web/routes/trading-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
accountConfigSchema,
} from '../../../core/config.js'
import { createBroker } from '../../../domain/trading/brokers/factory.js'
import { BROKER_REGISTRY } from '../../../domain/trading/brokers/registry.js'

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

Expand All @@ -17,25 +18,29 @@ function mask(value: string): string {
/** Field names that contain sensitive values. Convention-based, not hardcoded per broker. */
const SENSITIVE = /key|secret|password|token/i

/** Mask all sensitive string fields in a config object. */
/** Mask all sensitive string fields in a config object (recurses into nested objects). */
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)
} else if (v && typeof v === 'object' && !Array.isArray(v)) {
;(result as Record<string, unknown>)[k] = maskSecrets(v as Record<string, unknown>)
}
}
return result
}

/** Restore masked values (****...) from existing config. */
/** Restore masked values (****...) from existing config (recurses into nested objects). */
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]
} else if (v && typeof v === 'object' && !Array.isArray(v) && existing[k] && typeof existing[k] === 'object') {
unmaskSecrets(v as Record<string, unknown>, existing[k] as Record<string, unknown>)
}
}
}
Expand All @@ -46,6 +51,22 @@ function unmaskSecrets(
export function createTradingConfigRoutes(ctx: EngineContext) {
const app = new Hono()

// ==================== Broker types (for dynamic UI rendering) ====================

app.get('/broker-types', (c) => {
const brokerTypes = Object.entries(BROKER_REGISTRY).map(([type, entry]) => ({
type,
name: entry.name,
description: entry.description,
badge: entry.badge,
badgeColor: entry.badgeColor,
fields: entry.configFields,
subtitleFields: entry.subtitleFields,
guardCategory: entry.guardCategory,
}))
return c.json({ brokerTypes })
})

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

app.get('/', async (c) => {
Expand Down Expand Up @@ -84,6 +105,18 @@ export function createTradingConfigRoutes(ctx: EngineContext) {
accounts.push(validated)
}
await writeAccountsConfig(accounts)

// Handle enabled state changes at runtime
const wasEnabled = existing?.enabled !== false
const nowEnabled = validated.enabled !== false
if (wasEnabled && !nowEnabled) {
// Disabled — close running account
await ctx.accountManager.removeAccount(id)
} else if (!wasEnabled && nowEnabled) {
// Enabled — start account
ctx.accountManager.reconnectAccount(id).catch(() => {})
}

return c.json(validated)
} catch (err) {
if (err instanceof Error && err.name === 'ZodError') {
Expand All @@ -102,12 +135,8 @@ export function createTradingConfigRoutes(ctx: EngineContext) {
return c.json({ error: `Account "${id}" not found` }, 404)
}
await writeAccountsConfig(filtered)
// Close running account instance if any
if (ctx.accountManager.has(id)) {
const uta = ctx.accountManager.get(id)
ctx.accountManager.remove(id)
try { await uta?.close() } catch { /* best effort */ }
}
// Close and deregister running account instance if any
await ctx.accountManager.removeAccount(id)
return c.json({ success: true })
} catch (err) {
return c.json({ error: String(err) }, 500)
Expand Down
2 changes: 1 addition & 1 deletion src/connectors/web/routes/trading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function createTradingRoutes(ctx: EngineContext) {
// Reconnect
app.post('/accounts/:id/reconnect', async (c) => {
const id = c.req.param('id')
const result = await ctx.reconnectAccount(id)
const result = await ctx.accountManager.reconnectAccount(id)
return c.json(result, result.success ? 200 : 500)
})

Expand Down
6 changes: 3 additions & 3 deletions src/core/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,14 +255,14 @@ describe('readAccountsConfig', () => {

describe('writeAccountsConfig', () => {
it('writes validated accounts to accounts.json', async () => {
await writeAccountsConfig([{ id: 'acc-1', type: 'alpaca', paper: true, guards: [] }])
await writeAccountsConfig([{ id: 'acc-1', type: 'alpaca', guards: [], brokerConfig: { paper: true } }])
const filePath = mockWriteFile.mock.calls[0][0] as string
expect(filePath).toMatch(/accounts\.json$/)
})

it('throws ZodError for invalid account type', async () => {
it('throws ZodError for missing required fields', async () => {
await expect(
writeAccountsConfig([{ id: 'bad', type: 'unknown-type' } as any])
writeAccountsConfig([{ type: 'alpaca' } as any])
).rejects.toThrow()
expect(mockWriteFile).not.toHaveBeenCalled()
})
Expand Down
67 changes: 29 additions & 38 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,48 +212,15 @@ const guardConfigSchema = z.object({
options: z.record(z.string(), z.unknown()).default({}),
})

const ccxtAccountSchema = z.object({
export const accountConfigSchema = z.object({
id: z.string(),
label: z.string().optional(),
type: z.literal('ccxt'),
exchange: z.string(),
sandbox: z.boolean().default(false),
demoTrading: z.boolean().default(false),
options: z.record(z.string(), z.unknown()).optional(),
apiKey: z.string().optional(),
apiSecret: z.string().optional(),
password: z.string().optional(),
guards: z.array(guardConfigSchema).default([]),
}).passthrough()

const alpacaAccountSchema = z.object({
id: z.string(),
label: z.string().optional(),
type: z.literal('alpaca'),
paper: z.boolean().default(true),
apiKey: z.string().optional(),
apiSecret: z.string().optional(),
guards: z.array(guardConfigSchema).default([]),
})

const ibkrAccountSchema = z.object({
id: z.string(),
label: z.string().optional(),
type: z.literal('ibkr'),
host: z.string().default('127.0.0.1'),
port: z.number().int().default(7497),
clientId: z.number().int().default(0),
accountId: z.string().optional(),
paper: z.boolean().default(true),
type: z.string(),
enabled: z.boolean().default(true),
guards: z.array(guardConfigSchema).default([]),
brokerConfig: z.record(z.string(), z.unknown()).default({}),
})

export const accountConfigSchema = z.discriminatedUnion('type', [
ccxtAccountSchema,
alpacaAccountSchema,
ibkrAccountSchema,
])

export const accountsFileSchema = z.array(accountConfigSchema)

export type AccountConfig = z.infer<typeof accountConfigSchema>
Expand Down Expand Up @@ -374,6 +341,28 @@ export async function loadConfig(): Promise<Config> {

// ==================== Account Config Loader ====================

/** Common fields that live at the top level, not inside brokerConfig. */
const BASE_FIELDS = new Set(['id', 'label', 'type', 'guards', 'brokerConfig'])

/**
* Migrate flat account config (legacy) to nested brokerConfig format.
* Any field not in BASE_FIELDS gets moved into brokerConfig.
*/
function migrateAccountConfig(raw: Record<string, unknown>): Record<string, unknown> {
if (raw.brokerConfig) return raw // already migrated
const migrated: Record<string, unknown> = {}
const brokerConfig: Record<string, unknown> = {}
for (const [k, v] of Object.entries(raw)) {
if (BASE_FIELDS.has(k)) {
migrated[k] = v
} else {
brokerConfig[k] = v
}
}
migrated.brokerConfig = brokerConfig
return migrated
}

export async function readAccountsConfig(): Promise<AccountConfig[]> {
const raw = await loadJsonFile('accounts.json')
if (raw === undefined) {
Expand All @@ -382,7 +371,9 @@ export async function readAccountsConfig(): Promise<AccountConfig[]> {
await writeFile(resolve(CONFIG_DIR, 'accounts.json'), '[]\n')
return []
}
return accountsFileSchema.parse(raw)
// Migrate legacy flat format → nested brokerConfig
const migrated = (raw as unknown[]).map((item) => migrateAccountConfig(item as Record<string, unknown>))
return accountsFileSchema.parse(migrated)
}

export async function writeAccountsConfig(accounts: AccountConfig[]): Promise<void> {
Expand Down
2 changes: 0 additions & 2 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ export interface EngineContext {

// Trading (unified account model)
accountManager: AccountManager
/** Reconnect a specific trading account by ID. */
reconnectAccount: (accountId: string) => Promise<ReconnectResult>
/** Reconnect connector plugins (Telegram, MCP-Ask, etc.). */
reconnectConnectors: () => Promise<ReconnectResult>
}
Expand Down
20 changes: 14 additions & 6 deletions src/domain/trading/__test__/e2e/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,23 @@ export interface TestAccount {

/** Unified paper/sandbox check — E2E only runs non-live accounts. */
function isPaper(acct: AccountConfig): boolean {
const bc = acct.brokerConfig
switch (acct.type) {
case 'alpaca': return acct.paper
case 'ccxt': return acct.sandbox || acct.demoTrading
case 'ibkr': return acct.paper
case 'alpaca': return !!bc.paper
case 'ccxt': return !!(bc.sandbox || bc.demoTrading)
case 'ibkr': return !!bc.paper
default: return false
}
}

/** Check whether API credentials are configured (not applicable for all broker types). */
function hasCredentials(acct: AccountConfig): boolean {
const bc = acct.brokerConfig
switch (acct.type) {
case 'alpaca':
case 'ccxt': return !!acct.apiKey
case 'ccxt': return !!bc.apiKey
case 'ibkr': return true // no API key — auth via TWS/Gateway login
default: return true
}
}

Expand Down Expand Up @@ -71,11 +75,15 @@ async function initAll(): Promise<TestAccount[]> {
if (!isPaper(acct)) continue
if (!hasCredentials(acct)) continue

// Skip disabled accounts
if (acct.enabled === false) continue

// IBKR: check TWS/Gateway reachability before attempting connect
if (acct.type === 'ibkr') {
const reachable = await isTcpReachable(acct.host ?? '127.0.0.1', acct.port ?? 7497)
const bc = acct.brokerConfig
const reachable = await isTcpReachable(String(bc.host ?? '127.0.0.1'), Number(bc.port ?? 7497))
if (!reachable) {
console.warn(`e2e setup: ${acct.id} — TWS not reachable at ${acct.host ?? '127.0.0.1'}:${acct.port ?? 7497}, skipping`)
console.warn(`e2e setup: ${acct.id} — TWS not reachable at ${bc.host ?? '127.0.0.1'}:${bc.port ?? 7497}, skipping`)
continue
}
}
Expand Down
Loading
Loading