diff --git a/src/connectors/web/routes/trading-config.ts b/src/connectors/web/routes/trading-config.ts index 450dcd27..485e39b3 100644 --- a/src/connectors/web/routes/trading-config.ts +++ b/src/connectors/web/routes/trading-config.ts @@ -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 ==================== @@ -17,18 +18,20 @@ 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>(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)[k] = mask(v) + } else if (v && typeof v === 'object' && !Array.isArray(v)) { + ;(result as Record)[k] = maskSecrets(v as Record) } } return result } -/** Restore masked values (****...) from existing config. */ +/** Restore masked values (****...) from existing config (recurses into nested objects). */ function unmaskSecrets( body: Record, existing: Record, @@ -36,6 +39,8 @@ function unmaskSecrets( 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, existing[k] as Record) } } } @@ -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) => { @@ -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') { @@ -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) diff --git a/src/connectors/web/routes/trading.ts b/src/connectors/web/routes/trading.ts index 8809cf5a..6d3bcc54 100644 --- a/src/connectors/web/routes/trading.ts +++ b/src/connectors/web/routes/trading.ts @@ -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) }) diff --git a/src/core/config.spec.ts b/src/core/config.spec.ts index 8ee41b16..2f31993c 100644 --- a/src/core/config.spec.ts +++ b/src/core/config.spec.ts @@ -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() }) diff --git a/src/core/config.ts b/src/core/config.ts index 86cd90d0..66d1650b 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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 @@ -374,6 +341,28 @@ export async function loadConfig(): Promise { // ==================== 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): Record { + if (raw.brokerConfig) return raw // already migrated + const migrated: Record = {} + const brokerConfig: Record = {} + 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 { const raw = await loadJsonFile('accounts.json') if (raw === undefined) { @@ -382,7 +371,9 @@ export async function readAccountsConfig(): Promise { 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)) + return accountsFileSchema.parse(migrated) } export async function writeAccountsConfig(accounts: AccountConfig[]): Promise { diff --git a/src/core/types.ts b/src/core/types.ts index 69c1262b..5b16d9dd 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -34,8 +34,6 @@ export interface EngineContext { // Trading (unified account model) accountManager: AccountManager - /** Reconnect a specific trading account by ID. */ - reconnectAccount: (accountId: string) => Promise /** Reconnect connector plugins (Telegram, MCP-Ask, etc.). */ reconnectConnectors: () => Promise } diff --git a/src/domain/trading/__test__/e2e/setup.ts b/src/domain/trading/__test__/e2e/setup.ts index 6d9a437c..dcc64c12 100644 --- a/src/domain/trading/__test__/e2e/setup.ts +++ b/src/domain/trading/__test__/e2e/setup.ts @@ -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 } } @@ -71,11 +75,15 @@ async function initAll(): Promise { 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 } } diff --git a/src/domain/trading/account-manager.ts b/src/domain/trading/account-manager.ts index 4c9cd529..d2cb87ce 100644 --- a/src/domain/trading/account-manager.ts +++ b/src/domain/trading/account-manager.ts @@ -1,13 +1,21 @@ /** - * AccountManager — multi-UTA registry and aggregation + * AccountManager — UTA lifecycle management, registry, and aggregation. * - * Holds all UnifiedTradingAccount instances, provides cross-account operations - * like aggregated equity, global contract search, and source routing. + * Owns the full account lifecycle: create → register → reconnect → remove → close. + * Also provides cross-account operations (aggregated equity, contract search). */ import type { Contract, ContractDescription, ContractDetails } from '@traderalice/ibkr' import type { AccountCapabilities, BrokerHealth, BrokerHealthInfo } from './brokers/types.js' -import type { UnifiedTradingAccount } from './UnifiedTradingAccount.js' +import { CcxtBroker } from './brokers/ccxt/CcxtBroker.js' +import { createCcxtProviderTools } from './brokers/ccxt/ccxt-tools.js' +import { createBroker } from './brokers/factory.js' +import { UnifiedTradingAccount } from './UnifiedTradingAccount.js' +import { loadGitState, createGitPersister } from './git-persistence.js' +import { readAccountsConfig, type AccountConfig } from '../../core/config.js' +import type { EventLog } from '../../core/event-log.js' +import type { ToolCenter } from '../../core/tool-center.js' +import type { ReconnectResult } from '../../core/types.js' import './contract-ext.js' // ==================== Account summary ==================== @@ -47,8 +55,95 @@ export interface ContractSearchResult { export class AccountManager { private entries = new Map() + private reconnecting = new Set() - // ---- Registration ---- + private eventLog?: EventLog + private toolCenter?: ToolCenter + + constructor(deps?: { eventLog: EventLog; toolCenter: ToolCenter }) { + this.eventLog = deps?.eventLog + this.toolCenter = deps?.toolCenter + } + + // ==================== Lifecycle ==================== + + /** Create a UTA from account config, register it, and start async broker connection. */ + async initAccount(accCfg: AccountConfig): Promise { + const broker = createBroker(accCfg) + const savedState = await loadGitState(accCfg.id) + const uta = new UnifiedTradingAccount(broker, { + guards: accCfg.guards, + savedState, + onCommit: createGitPersister(accCfg.id), + onHealthChange: (accountId, health) => { + this.eventLog?.append('account.health', { accountId, ...health }) + }, + }) + this.add(uta) + return uta + } + + /** Reconnect an account: close old → re-read config → create new → verify connection. */ + async reconnectAccount(accountId: string): Promise { + if (this.reconnecting.has(accountId)) { + return { success: false, error: 'Reconnect already in progress' } + } + this.reconnecting.add(accountId) + try { + // Re-read config to pick up credential/guard changes + const freshAccounts = await readAccountsConfig() + + // Close old account + await this.removeAccount(accountId) + + const accCfg = freshAccounts.find((a) => a.id === accountId) + if (!accCfg) { + return { success: true, message: `Account "${accountId}" not found in config (removed or disabled)` } + } + + const uta = await this.initAccount(accCfg) + + // Wait for broker.init() + broker.getAccount() to verify the connection + await uta.waitForConnect() + + // Re-register CCXT-specific tools if this is a CCXT account + if (accCfg.type === 'ccxt') { + this.toolCenter?.register( + createCcxtProviderTools(this), + 'trading-ccxt', + ) + } + + const label = uta.label ?? accountId + console.log(`reconnect: ${label} online`) + return { success: true, message: `${label} reconnected` } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + console.error(`reconnect: ${accountId} failed:`, msg) + return { success: false, error: msg } + } finally { + this.reconnecting.delete(accountId) + } + } + + /** Close and deregister an account. No-op if account doesn't exist. */ + async removeAccount(accountId: string): Promise { + const uta = this.entries.get(accountId) + if (!uta) return + this.entries.delete(accountId) + try { await uta.close() } catch { /* best effort */ } + } + + /** Register CCXT provider tools if any CCXT accounts are present. */ + registerCcxtToolsIfNeeded(): void { + const hasCcxt = this.resolve().some((uta) => uta.broker instanceof CcxtBroker) + if (hasCcxt) { + this.toolCenter?.register(createCcxtProviderTools(this), 'trading-ccxt') + console.log('ccxt: provider tools registered') + } + } + + // ==================== Registration ==================== add(uta: UnifiedTradingAccount): void { if (this.entries.has(uta.id)) { @@ -61,7 +156,7 @@ export class AccountManager { this.entries.delete(id) } - // ---- Lookups ---- + // ==================== Lookups ==================== get(id: string): UnifiedTradingAccount | undefined { return this.entries.get(id) @@ -84,13 +179,8 @@ export class AccountManager { return this.entries.size } - // ---- Source routing ---- + // ==================== Source routing ==================== - /** - * Resolve a source string to matching UTAs. - * - If omitted, returns all. - * - Matches by account id. - */ resolve(source?: string): UnifiedTradingAccount[] { if (!source) { return Array.from(this.entries.values()) @@ -100,9 +190,6 @@ export class AccountManager { return [] } - /** - * Resolve to exactly one UTA. Throws if zero or multiple matches. - */ resolveOne(source: string): UnifiedTradingAccount { const results = this.resolve(source) if (results.length === 0) { @@ -116,12 +203,11 @@ export class AccountManager { return results[0] } - // ---- Cross-account aggregation ---- + // ==================== Cross-account aggregation ==================== async getAggregatedEquity(): Promise { const results = await Promise.all( Array.from(this.entries.values()).map(async (uta) => { - // Unhealthy UTA → skip data query, nudge recovery to retry sooner if (uta.health !== 'healthy') { uta.nudgeRecovery() return { id: uta.id, label: uta.label, health: uta.health, info: null } @@ -130,7 +216,6 @@ export class AccountManager { const info = await uta.getAccount() return { id: uta.id, label: uta.label, health: uta.health, info } } catch { - // Healthy UTA failed this call — _callBroker already updated health + SSE return { id: uta.id, label: uta.label, health: uta.health, info: null } } }), @@ -162,7 +247,7 @@ export class AccountManager { return { totalEquity, totalCash, totalUnrealizedPnL, totalRealizedPnL, accounts } } - // ---- Cross-account contract search ---- + // ==================== Cross-account contract search ==================== async searchContracts( pattern: string, @@ -199,7 +284,7 @@ export class AccountManager { return uta.getContractDetails(query) } - // ---- Lifecycle ---- + // ==================== Cleanup ==================== async closeAll(): Promise { await Promise.allSettled( diff --git a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts index cfda3ba2..3f174a21 100644 --- a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts +++ b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts @@ -8,6 +8,7 @@ * Takes IBKR Order objects, reads relevant fields, ignores the rest. */ +import { z } from 'zod' import Alpaca from '@alpacahq/alpaca-trade-api' import Decimal from 'decimal.js' import { Contract, ContractDescription, ContractDetails, Order, OrderState, UNSET_DOUBLE, UNSET_DECIMAL } from '@traderalice/ibkr' @@ -21,6 +22,7 @@ import { type OpenOrder, type Quote, type MarketClock, + type BrokerConfigField, } from '../types.js' import '../../contract-ext.js' import type { @@ -58,6 +60,33 @@ function ibkrTifToAlpaca(tif: string): string { } export class AlpacaBroker implements IBroker { + // ---- Self-registration ---- + + static configSchema = z.object({ + paper: z.boolean().default(true), + apiKey: z.string().optional(), + apiSecret: z.string().optional(), + }) + + static configFields: BrokerConfigField[] = [ + { name: 'paper', type: 'boolean', label: 'Paper Trading', default: true, description: 'When enabled, orders are routed to Alpaca\'s paper trading environment.' }, + { name: 'apiKey', type: 'password', label: 'API Key', required: true, sensitive: true }, + { name: 'apiSecret', type: 'password', label: 'Secret Key', required: true, sensitive: true }, + ] + + static fromConfig(config: { id: string; label?: string; brokerConfig: Record }): AlpacaBroker { + const bc = AlpacaBroker.configSchema.parse(config.brokerConfig) + return new AlpacaBroker({ + id: config.id, + label: config.label, + apiKey: bc.apiKey ?? '', + secretKey: bc.apiSecret ?? '', + paper: bc.paper, + }) + } + + // ---- Instance ---- + readonly id: string readonly label: string diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index e1716cdc..febdf2ee 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -6,6 +6,7 @@ * aliceId format: "{exchange}-{encodedSymbol}" (e.g. "bybit-BTC_USDT.USDT"). */ +import { z } from 'zod' import ccxt from 'ccxt' import Decimal from 'decimal.js' import type { Exchange, Order as CcxtOrder } from 'ccxt' @@ -20,6 +21,7 @@ import { type OpenOrder, type Quote, type MarketClock, + type BrokerConfigField, } from '../types.js' import '../../contract-ext.js' import type { CcxtBrokerConfig, CcxtMarket, FundingRate, OrderBook, OrderBookLevel } from './ccxt-types.js' @@ -46,6 +48,47 @@ export interface CcxtBrokerMeta { } export class CcxtBroker implements IBroker { + // ---- Self-registration ---- + + static configSchema = z.object({ + 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(), + }) + + static configFields: BrokerConfigField[] = [ + { name: 'exchange', type: 'select', label: 'Exchange', required: true, options: [ + 'binance', 'bybit', 'okx', 'bitget', 'gate', 'kucoin', 'coinbase', + 'kraken', 'htx', 'mexc', 'bingx', 'phemex', 'woo', 'hyperliquid', + ].map(e => ({ value: e, label: e.charAt(0).toUpperCase() + e.slice(1) })) }, + { name: 'sandbox', type: 'boolean', label: 'Sandbox Mode', default: false }, + { name: 'demoTrading', type: 'boolean', label: 'Demo Trading', default: false }, + { name: 'apiKey', type: 'password', label: 'API Key', required: true, sensitive: true }, + { name: 'apiSecret', type: 'password', label: 'API Secret', required: true, sensitive: true }, + { name: 'password', type: 'password', label: 'Password', placeholder: 'Required by some exchanges (e.g. OKX)', sensitive: true }, + ] + + static fromConfig(config: { id: string; label?: string; brokerConfig: Record }): CcxtBroker { + const bc = CcxtBroker.configSchema.parse(config.brokerConfig) + return new CcxtBroker({ + id: config.id, + label: config.label, + exchange: bc.exchange, + sandbox: bc.sandbox, + demoTrading: bc.demoTrading, + options: bc.options, + apiKey: bc.apiKey ?? '', + apiSecret: bc.apiSecret ?? '', + password: bc.password, + }) + } + + // ---- Instance ---- + readonly id: string readonly label: string readonly meta: CcxtBrokerMeta diff --git a/src/domain/trading/brokers/factory.ts b/src/domain/trading/brokers/factory.ts index 0dad641d..30c6d580 100644 --- a/src/domain/trading/brokers/factory.ts +++ b/src/domain/trading/brokers/factory.ts @@ -1,46 +1,19 @@ /** * Broker Factory — creates broker instances from account config. * - * Single function: AccountConfig → IBroker. No intermediate platform layer. + * Delegates to the broker registry. Each broker class owns its own + * configSchema + fromConfig — no manual field mapping here. */ import type { IBroker } from './types.js' -import { CcxtBroker } from './ccxt/CcxtBroker.js' -import { AlpacaBroker } from './alpaca/AlpacaBroker.js' -import { IbkrBroker } from './ibkr/IbkrBroker.js' +import { BROKER_REGISTRY } from './registry.js' import type { AccountConfig } from '../../../core/config.js' -/** Create an IBroker from a merged account config. */ +/** Create an IBroker from account config. */ export function createBroker(config: AccountConfig): IBroker { - switch (config.type) { - case 'ccxt': - return new CcxtBroker({ - id: config.id, - label: config.label, - exchange: config.exchange, - sandbox: config.sandbox, - demoTrading: config.demoTrading, - options: config.options, - apiKey: config.apiKey ?? '', - apiSecret: config.apiSecret ?? '', - password: config.password, - }) - case 'alpaca': - return new AlpacaBroker({ - id: config.id, - label: config.label, - apiKey: config.apiKey ?? '', - secretKey: config.apiSecret ?? '', - paper: config.paper, - }) - case 'ibkr': - return new IbkrBroker({ - id: config.id, - label: config.label, - host: config.host, - port: config.port, - clientId: config.clientId, - accountId: config.accountId, - }) + const entry = BROKER_REGISTRY[config.type] + if (!entry) { + throw new Error(`Unknown broker type: "${config.type}". Registered types: ${Object.keys(BROKER_REGISTRY).join(', ')}`) } + return entry.fromConfig(config) } diff --git a/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/src/domain/trading/brokers/ibkr/IbkrBroker.ts index ad39239e..cdda2c9b 100644 --- a/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -11,6 +11,7 @@ * - Order IDs are numeric, assigned by TWS (nextValidId) */ +import { z } from 'zod' import Decimal from 'decimal.js' import { EClient, @@ -31,6 +32,7 @@ import { type OpenOrder, type Quote, type MarketClock, + type BrokerConfigField, } from '../types.js' import '../../contract-ext.js' import { RequestBridge } from './request-bridge.js' @@ -38,6 +40,38 @@ import { resolveSymbol } from './ibkr-contracts.js' import type { IbkrBrokerConfig, AccountDownloadResult } from './ibkr-types.js' export class IbkrBroker implements IBroker { + // ---- Self-registration ---- + + static configSchema = z.object({ + 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), + }) + + static configFields: BrokerConfigField[] = [ + { name: 'host', type: 'text', label: 'Host', default: '127.0.0.1', placeholder: '127.0.0.1' }, + { name: 'port', type: 'number', label: 'Port', default: 7497 }, + { name: 'clientId', type: 'number', label: 'Client ID', default: 0 }, + { name: 'accountId', type: 'text', label: 'Account ID', placeholder: 'Auto-detected from TWS' }, + { name: 'paper', type: 'boolean', label: 'Paper Trading', default: true, description: 'Authentication is handled by TWS/Gateway login — no API keys needed.' }, + ] + + static fromConfig(config: { id: string; label?: string; brokerConfig: Record }): IbkrBroker { + const bc = IbkrBroker.configSchema.parse(config.brokerConfig) + return new IbkrBroker({ + id: config.id, + label: config.label, + host: bc.host, + port: bc.port, + clientId: bc.clientId, + accountId: bc.accountId, + }) + } + + // ---- Instance ---- + readonly id: string readonly label: string diff --git a/src/domain/trading/brokers/index.ts b/src/domain/trading/brokers/index.ts index d0fbc9db..870e0978 100644 --- a/src/domain/trading/brokers/index.ts +++ b/src/domain/trading/brokers/index.ts @@ -8,10 +8,13 @@ export type { Quote, MarketClock, AccountCapabilities, + BrokerConfigField, } from './types.js' -// Factory +// Factory + Registry export { createBroker } from './factory.js' +export { BROKER_REGISTRY } from './registry.js' +export type { BrokerRegistryEntry } from './registry.js' // Alpaca export { AlpacaBroker } from './alpaca/index.js' diff --git a/src/domain/trading/brokers/mock/MockBroker.ts b/src/domain/trading/brokers/mock/MockBroker.ts index dcfb9701..a198df0a 100644 --- a/src/domain/trading/brokers/mock/MockBroker.ts +++ b/src/domain/trading/brokers/mock/MockBroker.ts @@ -8,6 +8,7 @@ * fillPendingOrder() to trigger fills in tests). */ +import { z } from 'zod' import Decimal from 'decimal.js' import { Contract, ContractDescription, ContractDetails, Order, OrderState, UNSET_DECIMAL, UNSET_DOUBLE } from '@traderalice/ibkr' import type { @@ -122,6 +123,17 @@ export function makePlaceOrderResult(overrides: Partial = {}): // ==================== MockBroker ==================== export class MockBroker implements IBroker { + // ---- Self-registration ---- + + static configSchema = z.object({}) + static configFields: import('../types.js').BrokerConfigField[] = [] + + static fromConfig(config: { id: string; label?: string; brokerConfig: Record }): MockBroker { + return new MockBroker({ id: config.id, label: config.label }) + } + + // ---- Instance ---- + readonly id: string readonly label: string diff --git a/src/domain/trading/brokers/registry.ts b/src/domain/trading/brokers/registry.ts new file mode 100644 index 00000000..178f03ab --- /dev/null +++ b/src/domain/trading/brokers/registry.ts @@ -0,0 +1,95 @@ +/** + * Broker Registry — maps type strings to broker classes. + * + * Each broker self-registers via static configSchema + configFields + fromConfig. + * Adding a new broker: import it here and add one entry to the registry. + */ + +import type { z } from 'zod' +import type { IBroker, BrokerConfigField } from './types.js' +import type { AccountConfig } from '../../../core/config.js' +import { CcxtBroker } from './ccxt/CcxtBroker.js' +import { AlpacaBroker } from './alpaca/AlpacaBroker.js' +import { IbkrBroker } from './ibkr/IbkrBroker.js' + +// ==================== Subtitle field descriptor ==================== + +export interface SubtitleField { + field: string + /** Text to show when boolean field is true */ + label?: string + /** Text to show when boolean field is false (omitted = don't show) */ + falseLabel?: string + /** Prefix before the value (e.g. "TWS ") */ + prefix?: string +} + +// ==================== Registry entry ==================== + +export interface BrokerRegistryEntry { + /** Zod schema for validating brokerConfig fields */ + configSchema: z.ZodType + /** UI field descriptors for dynamic form rendering */ + configFields: BrokerConfigField[] + /** Construct a broker instance from AccountConfig */ + fromConfig: (config: AccountConfig) => IBroker + /** Display name */ + name: string + /** Short description */ + description: string + /** Badge text (2-3 chars) */ + badge: string + /** Tailwind badge color class */ + badgeColor: string + /** Fields to show in account card subtitle */ + subtitleFields: SubtitleField[] + /** Guard category — determines which guard types are available */ + guardCategory: 'crypto' | 'securities' +} + +// ==================== Registry ==================== + +export const BROKER_REGISTRY: Record = { + ccxt: { + configSchema: CcxtBroker.configSchema, + configFields: CcxtBroker.configFields, + fromConfig: CcxtBroker.fromConfig, + name: 'CCXT (Crypto)', + description: 'Unified API for 100+ crypto exchanges. Supports Binance, Bybit, OKX, Coinbase, and more.', + badge: 'CC', + badgeColor: 'text-accent', + subtitleFields: [ + { field: 'exchange' }, + { field: 'demoTrading', label: 'Demo' }, + { field: 'sandbox', label: 'Sandbox' }, + ], + guardCategory: 'crypto', + }, + alpaca: { + configSchema: AlpacaBroker.configSchema, + configFields: AlpacaBroker.configFields, + fromConfig: AlpacaBroker.fromConfig, + name: 'Alpaca (Securities)', + description: 'Commission-free US equities and ETFs with fractional share support.', + badge: 'AL', + badgeColor: 'text-green', + subtitleFields: [ + { field: 'paper', label: 'Paper Trading', falseLabel: 'Live Trading' }, + ], + guardCategory: 'securities', + }, + ibkr: { + configSchema: IbkrBroker.configSchema, + configFields: IbkrBroker.configFields, + fromConfig: IbkrBroker.fromConfig, + name: 'IBKR (Interactive Brokers)', + description: 'Professional-grade trading via TWS or IB Gateway. Stocks, options, futures, bonds.', + badge: 'IB', + badgeColor: 'text-orange-400', + subtitleFields: [ + { field: 'host', prefix: 'TWS ' }, + { field: 'port' }, + ], + guardCategory: 'securities', + }, +} diff --git a/src/domain/trading/brokers/types.ts b/src/domain/trading/brokers/types.ts index 263a67c4..0fa44e9d 100644 --- a/src/domain/trading/brokers/types.ts +++ b/src/domain/trading/brokers/types.ts @@ -150,6 +150,22 @@ export interface AccountCapabilities { supportedOrderTypes: string[] } +// ==================== Broker config field descriptor ==================== + +/** Describes a single config field for a broker type — used by the frontend to dynamically render forms. */ +export interface BrokerConfigField { + name: string + type: 'text' | 'password' | 'number' | 'boolean' | 'select' + label: string + placeholder?: string + default?: unknown + required?: boolean + options?: Array<{ value: string; label: string }> + description?: string + /** True for secrets (apiKey, etc.) — backend masks these in API responses. */ + sensitive?: boolean +} + // ==================== IBroker ==================== export interface IBroker { diff --git a/src/domain/trading/git-persistence.ts b/src/domain/trading/git-persistence.ts new file mode 100644 index 00000000..ab475c9f --- /dev/null +++ b/src/domain/trading/git-persistence.ts @@ -0,0 +1,48 @@ +/** + * Git state persistence — load/save Trading-as-Git commit history. + * + * Extracted from main.ts. Pure functions + file IO, no instance dependencies. + */ + +import { readFile, writeFile, mkdir } from 'fs/promises' +import { resolve, dirname } from 'path' +import type { GitExportState } from './git/types.js' + +// ==================== Paths ==================== + +function gitFilePath(accountId: string): string { + return resolve(`data/trading/${accountId}/commit.json`) +} + +/** Legacy paths for backward compat. TODO: remove before v1.0 */ +const LEGACY_GIT_PATHS: Record = { + 'bybit-main': resolve('data/crypto-trading/commit.json'), + 'alpaca-paper': resolve('data/securities-trading/commit.json'), + 'alpaca-live': resolve('data/securities-trading/commit.json'), +} + +// ==================== Public API ==================== + +/** Read saved git state from disk, trying primary path then legacy fallback. */ +export async function loadGitState(accountId: string): Promise { + const primary = gitFilePath(accountId) + try { + return JSON.parse(await readFile(primary, 'utf-8')) as GitExportState + } catch { /* try legacy */ } + const legacy = LEGACY_GIT_PATHS[accountId] + if (legacy) { + try { + return JSON.parse(await readFile(legacy, 'utf-8')) as GitExportState + } catch { /* no saved state */ } + } + return undefined +} + +/** Create a callback that persists git state to disk on each commit. */ +export function createGitPersister(accountId: string): (state: GitExportState) => Promise { + const filePath = gitFilePath(accountId) + return async (state: GitExportState) => { + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, JSON.stringify(state, null, 2)) + } +} diff --git a/src/main.ts b/src/main.ts index 64379dd9..d7389228 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,16 +8,8 @@ import { TelegramPlugin } from './connectors/telegram/index.js' import { WebPlugin } from './connectors/web/index.js' import { McpAskPlugin } from './connectors/mcp-ask/index.js' import { createThinkingTools } from './tool/thinking.js' -import { - AccountManager, - UnifiedTradingAccount, - CcxtBroker, - createCcxtProviderTools, - createBroker, -} from './domain/trading/index.js' +import { AccountManager } from './domain/trading/index.js' import { createTradingTools } from './tool/trading.js' -import type { GitExportState } from './domain/trading/index.js' -import type { AccountConfig } from './core/config.js' import { Brain } from './domain/brain/index.js' import { createBrainTools } from './tool/brain.js' import type { BrainExportState } from './domain/brain/index.js' @@ -51,16 +43,6 @@ import { createNewsArchiveTools } from './tool/news.js' const BRAIN_FILE = resolve('data/brain/commit.json') -/** Per-account git state path. Falls back to legacy paths for backward compat. - * TODO: remove LEGACY_GIT_PATHS before v1.0 */ -function gitFilePath(accountId: string): string { - return resolve(`data/trading/${accountId}/commit.json`) -} -const LEGACY_GIT_PATHS: Record = { - 'bybit-main': resolve('data/crypto-trading/commit.json'), - 'alpaca-paper': resolve('data/securities-trading/commit.json'), - 'alpaca-live': resolve('data/securities-trading/commit.json'), -} const FRONTAL_LOBE_FILE = resolve('data/brain/frontal-lobe.md') const EMOTION_LOG_FILE = resolve('data/brain/emotion-log.md') const PERSONA_FILE = resolve('data/brain/persona.md') @@ -79,29 +61,6 @@ async function readWithDefault(target: string, defaultFile: string): Promise { - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, JSON.stringify(state, null, 2)) - } -} - -/** Read saved git state from disk, trying primary path then legacy fallback. */ -async function loadGitState(accountId: string): Promise { - const primary = gitFilePath(accountId) - try { - return JSON.parse(await readFile(primary, 'utf-8')) as GitExportState - } catch { /* try legacy */ } - const legacy = LEGACY_GIT_PATHS[accountId] - if (legacy) { - try { - return JSON.parse(await readFile(legacy, 'utf-8')) as GitExportState - } catch { /* no saved state */ } - } - return undefined -} - async function main() { const config = await loadConfig() @@ -110,33 +69,20 @@ async function main() { const eventLog = await createEventLog() const toolCallLog = await createToolCallLog() - // ==================== Trading Account Manager ==================== + // ==================== Tool Center (created early — AccountManager needs it) ==================== - const accountManager = new AccountManager() - - // ==================== Account Init ==================== + const toolCenter = new ToolCenter() - const accountConfigs = await readAccountsConfig() + // ==================== Trading Account Manager ==================== - /** Create and register a UTA from account config. */ - async function initAccount(accCfg: AccountConfig): Promise { - const broker = createBroker(accCfg) - const savedState = await loadGitState(accCfg.id) - const uta = new UnifiedTradingAccount(broker, { - guards: accCfg.guards, - savedState, - onCommit: createGitPersister(gitFilePath(accCfg.id)), - onHealthChange: (accountId, health) => { - eventLog.append('account.health', { accountId, ...health }) - }, - }) - accountManager.add(uta) - return uta - } + const accountManager = new AccountManager({ eventLog, toolCenter }) + const accountConfigs = await readAccountsConfig() for (const accCfg of accountConfigs) { - await initAccount(accCfg) + if (accCfg.enabled === false) continue + await accountManager.initAccount(accCfg) } + accountManager.registerCcxtToolsIfNeeded() // ==================== Brain ==================== @@ -218,9 +164,8 @@ async function main() { const symbolIndex = new SymbolIndex() await symbolIndex.load(equityClient) - // ==================== Tool Center ==================== + // ==================== Tool Registration ==================== - const toolCenter = new ToolCenter() toolCenter.register(createThinkingTools(), 'thinking') // One unified set of trading tools — routes via `source` parameter at runtime @@ -297,56 +242,6 @@ async function main() { console.log(`news-collector: started (${config.news.feeds.length} feeds, every ${config.news.intervalMinutes}m)`) } - // ==================== Account Reconnect ==================== - - const reconnectingAccounts = new Set() - - const reconnectAccount = async (accountId: string): Promise => { - if (reconnectingAccounts.has(accountId)) { - return { success: false, error: 'Reconnect already in progress' } - } - reconnectingAccounts.add(accountId) - try { - // Re-read config to pick up credential/guard changes - const freshAccounts = await readAccountsConfig() - - // Close old account - const currentUta = accountManager.get(accountId) - if (currentUta) { - await currentUta.close() - accountManager.remove(accountId) - } - - const accCfg = freshAccounts.find((a) => a.id === accountId) - if (!accCfg) { - return { success: true, message: `Account "${accountId}" not found in config (removed or disabled)` } - } - - const uta = await initAccount(accCfg) - - // Wait for broker.init() + broker.getAccount() to verify the connection - await uta.waitForConnect() - - // Re-register CCXT-specific tools if this is a CCXT account - if (accCfg.type === 'ccxt') { - toolCenter.register( - createCcxtProviderTools(accountManager), - 'trading-ccxt', - ) - } - - const label = uta.label ?? accountId - console.log(`reconnect: ${label} online`) - return { success: true, message: `${label} reconnected` } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - console.error(`reconnect: ${accountId} failed:`, msg) - return { success: false, error: msg } - } finally { - reconnectingAccounts.delete(accountId) - } - } - // ==================== Plugins ==================== // Core plugins — always-on, not toggleable at runtime @@ -463,7 +358,6 @@ async function main() { const ctx: EngineContext = { config, connectorCenter, agentCenter, eventLog, toolCallLog, heartbeat, cronEngine, toolCenter, accountManager, - reconnectAccount, reconnectConnectors, } @@ -474,19 +368,6 @@ async function main() { console.log('engine: started') - // ==================== CCXT Tools ==================== - // All UTAs are registered synchronously — check if any are CCXT and register provider tools. - { - const hasCcxt = accountManager.resolve().some((uta) => uta.broker instanceof CcxtBroker) - if (hasCcxt) { - toolCenter.register( - createCcxtProviderTools(accountManager), - 'trading-ccxt', - ) - console.log('ccxt: provider tools registered') - } - } - // ==================== Shutdown ==================== let stopped = false diff --git a/ui/src/api/trading.ts b/ui/src/api/trading.ts index 3b62c24c..a6e35e89 100644 --- a/ui/src/api/trading.ts +++ b/ui/src/api/trading.ts @@ -1,5 +1,5 @@ import { fetchJson } from './client' -import type { TradingAccount, AccountSummary, AccountInfo, Position, WalletCommitLog, ReconnectResult, AccountConfig, WalletStatus, WalletPushResult, WalletRejectResult, TestConnectionResult } from './types' +import type { TradingAccount, AccountSummary, AccountInfo, Position, WalletCommitLog, ReconnectResult, AccountConfig, WalletStatus, WalletPushResult, WalletRejectResult, TestConnectionResult, BrokerTypeInfo } from './types' // ==================== Unified Trading API ==================== @@ -79,6 +79,12 @@ export const tradingApi = { return res.json() }, + // ==================== Broker Types ==================== + + async getBrokerTypes(): Promise<{ brokerTypes: BrokerTypeInfo[] }> { + return fetchJson('/api/trading/config/broker-types') + }, + // ==================== Trading Config CRUD ==================== async loadTradingConfig(): Promise<{ accounts: AccountConfig[] }> { diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 14a8a817..2a7d5f49 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -266,43 +266,46 @@ export interface ToolCallRecord { // ==================== Trading Config ==================== -export interface CcxtAccountConfig { +export interface AccountConfig { id: string label?: string - type: 'ccxt' - exchange: string - sandbox: boolean - demoTrading: boolean - options?: Record - apiKey?: string - apiSecret?: string - password?: string + type: string + enabled: boolean guards: GuardEntry[] + brokerConfig: Record } -export interface AlpacaAccountConfig { - id: string - label?: string - type: 'alpaca' - paper: boolean - apiKey?: string - apiSecret?: string - guards: GuardEntry[] +// ==================== Broker Type Metadata (from /broker-types endpoint) ==================== + +export interface BrokerConfigField { + name: string + type: 'text' | 'password' | 'number' | 'boolean' | 'select' + label: string + placeholder?: string + default?: unknown + required?: boolean + options?: Array<{ value: string; label: string }> + description?: string + sensitive?: boolean } -export interface IbkrAccountConfig { - id: string +export interface SubtitleField { + field: string label?: string - type: 'ibkr' - host: string - port: number - clientId: number - accountId?: string - paper: boolean - guards: GuardEntry[] + falseLabel?: string + prefix?: string } -export type AccountConfig = CcxtAccountConfig | AlpacaAccountConfig | IbkrAccountConfig +export interface BrokerTypeInfo { + type: string + name: string + description: string + badge: string + badgeColor: string + fields: BrokerConfigField[] + subtitleFields: SubtitleField[] + guardCategory: 'crypto' | 'securities' +} export interface GuardEntry { type: string diff --git a/ui/src/pages/TradingPage.tsx b/ui/src/pages/TradingPage.tsx index 4ef9a7fb..99dc46d1 100644 --- a/ui/src/pages/TradingPage.tsx +++ b/ui/src/pages/TradingPage.tsx @@ -2,20 +2,14 @@ import { useState, useEffect, useCallback } from 'react' import { Section, Field, inputClass } from '../components/form' import { Toggle } from '../components/Toggle' import { GuardsSection, CRYPTO_GUARD_TYPES, SECURITIES_GUARD_TYPES } from '../components/guards' -import { SDKSelector, PLATFORM_TYPE_OPTIONS } from '../components/SDKSelector' +import { SDKSelector } from '../components/SDKSelector' +import type { SDKOption } from '../components/SDKSelector' import { ReconnectButton } from '../components/ReconnectButton' import { useTradingConfig } from '../hooks/useTradingConfig' import { useAccountHealth } from '../hooks/useAccountHealth' import { PageHeader } from '../components/PageHeader' import { api } from '../api' -import type { AccountConfig, CcxtAccountConfig, AlpacaAccountConfig, IbkrAccountConfig, BrokerHealthInfo } from '../api/types' - -// ==================== Constants ==================== - -const CCXT_EXCHANGES = [ - 'binance', 'bybit', 'okx', 'bitget', 'gate', 'kucoin', 'coinbase', - 'kraken', 'htx', 'mexc', 'bingx', 'phemex', 'woo', 'hyperliquid', -] as const +import type { AccountConfig, BrokerTypeInfo, BrokerConfigField, BrokerHealthInfo } from '../api/types' // ==================== Dialog state ==================== @@ -30,6 +24,12 @@ export function TradingPage() { const tc = useTradingConfig() const healthMap = useAccountHealth() const [dialog, setDialog] = useState(null) + const [brokerTypes, setBrokerTypes] = useState([]) + + // Fetch broker type metadata on mount + useEffect(() => { + api.trading.getBrokerTypes().then(r => setBrokerTypes(r.brokerTypes)).catch(() => {}) + }, []) useEffect(() => { if (dialog?.kind === 'edit') { @@ -66,6 +66,7 @@ export function TradingPage() { bt.type === account.type)} health={healthMap[account.id]} onClick={() => setDialog({ kind: 'edit', accountId: account.id })} /> @@ -84,6 +85,7 @@ export function TradingPage() { {/* Create Wizard */} {dialog?.kind === 'add' && ( a.id)} onSave={async (account) => { await tc.saveAccount(account) @@ -104,6 +106,7 @@ export function TradingPage() { return ( bt.type === account.type)} health={healthMap[account.id]} onSaveAccount={tc.saveAccount} onDelete={() => deleteAccount(account.id)} @@ -210,25 +213,36 @@ function HealthBadge({ health, size = 'sm' }: { health?: BrokerHealthInfo; size? } } +// ==================== Subtitle builder ==================== + +function buildSubtitle(account: AccountConfig, brokerType?: BrokerTypeInfo): string { + if (!brokerType) return account.type + const bc = account.brokerConfig + const parts: string[] = [] + for (const sf of brokerType.subtitleFields) { + const val = bc[sf.field] + if (typeof val === 'boolean') { + if (val && sf.label) parts.push(sf.label) + else if (!val && sf.falseLabel) parts.push(sf.falseLabel) + } else if (val != null && val !== '') { + parts.push(`${sf.prefix ?? ''}${val}`) + } + } + return parts.join(' · ') || brokerType.name +} + // ==================== Account Card ==================== -function AccountCard({ account, health, onClick }: { +function AccountCard({ account, brokerType, health, onClick }: { account: AccountConfig + brokerType?: BrokerTypeInfo health?: BrokerHealthInfo onClick: () => void }) { - const isDisabled = health?.disabled - const badge = account.type === 'ccxt' - ? { text: 'CC', color: 'text-accent bg-accent/10' } - : account.type === 'ibkr' - ? { text: 'IB', color: 'text-orange-400 bg-orange-400/10' } - : { text: 'AL', color: 'text-green bg-green/10' } - - const subtitle = account.type === 'ccxt' - ? [account.exchange, account.demoTrading && 'Demo', account.sandbox && 'Sandbox'].filter(Boolean).join(' · ') - : account.type === 'ibkr' - ? `TWS ${account.host ?? '127.0.0.1'}:${account.port ?? 7497}` - : account.paper ? 'Paper Trading' : 'Live Trading' + const isDisabled = health?.disabled || account.enabled === false + const badge = brokerType + ? { text: brokerType.badge, color: `${brokerType.badgeColor} ${brokerType.badgeColor.replace('text-', 'bg-')}/10` } + : { text: account.type.slice(0, 2).toUpperCase(), color: 'text-text-muted bg-text-muted/10' } return ( ) } -// ==================== Create Wizard (2-step) ==================== +// ==================== Dynamic Broker Fields ==================== + +function DynamicBrokerFields({ fields, values, showSecrets, onChange }: { + fields: BrokerConfigField[] + values: Record + showSecrets: boolean + onChange: (field: string, value: unknown) => void +}) { + return ( +
+ {fields.map((f) => { + switch (f.type) { + case 'boolean': + return ( +
+ + {f.description &&

{f.description}

} +
+ ) + case 'select': + return ( + + + + ) + case 'number': + return ( + + onChange(f.name, parseInt(e.target.value) || 0)} placeholder={f.placeholder} /> + + ) + case 'text': + case 'password': + default: + return ( + + onChange(f.name, e.target.value)} + placeholder={f.placeholder || (f.required ? 'Required' : '')} + /> + + ) + } + })} +
+ ) +} + +// ==================== Create Wizard ==================== function StepIndicator({ current, total }: { current: number; total: number }) { return ( @@ -276,41 +344,48 @@ function StepIndicator({ current, total }: { current: number; total: number }) { ) } -function CreateWizard({ existingAccountIds, onSave, onClose }: { +function CreateWizard({ brokerTypes, existingAccountIds, onSave, onClose }: { + brokerTypes: BrokerTypeInfo[] existingAccountIds: string[] onSave: (account: AccountConfig) => Promise onClose: () => void }) { const [step, setStep] = useState(1) - const [type, setType] = useState(null) - - // Connection fields + const [type, setType] = useState(null) const [id, setId] = useState('') - const [exchange, setExchange] = useState('binance') - const [sandbox, setSandbox] = useState(false) - const [demoTrading, setDemoTrading] = useState(false) - const [paper, setPaper] = useState(true) - - // IBKR connection fields - const [ibkrHost, setIbkrHost] = useState('127.0.0.1') - const [ibkrPort, setIbkrPort] = useState(7497) - const [ibkrClientId, setIbkrClientId] = useState(0) - const [ibkrAccountId, setIbkrAccountId] = useState('') - - // Credential fields (ccxt/alpaca only) - const [apiKey, setApiKey] = useState('') - const [apiSecret, setApiSecret] = useState('') - const [password, setPassword] = useState('') + const [brokerConfig, setBrokerConfig] = useState>({}) const [saving, setSaving] = useState(false) const [error, setError] = useState('') - // IBKR has no credentials step — only 1 step needed - const needsCredentials = type === 'ccxt' || type === 'alpaca' - const totalSteps = needsCredentials ? 2 : 1 + const bt = brokerTypes.find(b => b.type === type) + const hasSensitive = bt?.fields.some(f => f.sensitive) ?? false + const totalSteps = hasSensitive ? 2 : 1 + + // Split fields into connection (non-sensitive) and credential (sensitive) + const connectionFields = bt?.fields.filter(f => !f.sensitive) ?? [] + const credentialFields = bt?.fields.filter(f => f.sensitive) ?? [] - const defaultId = type === 'ccxt' ? `${exchange}-main` : type === 'ibkr' ? 'ibkr-paper' : 'alpaca-paper' + // Initialize defaults when type changes + useEffect(() => { + if (!bt) return + const defaults: Record = {} + for (const f of bt.fields) { + if (f.default !== undefined) defaults[f.name] = f.default + } + setBrokerConfig(defaults) + }, [type]) + + const defaultId = type ? `${type}-main` : '' const finalId = id.trim() || defaultId + const platformOptions: SDKOption[] = brokerTypes.map(b => ({ + id: b.type, + name: b.name, + description: b.description, + badge: b.badge, + badgeColor: b.badgeColor, + })) + const handleNext = () => { if (!type) return if (existingAccountIds.includes(finalId)) { @@ -318,46 +393,18 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { return } setError('') - if (needsCredentials) { + if (hasSensitive) { setStep(2) } else { handleCreate() } } - const buildConfig = (): AccountConfig => { - switch (type) { - case 'ccxt': - return { - id: finalId, type: 'ccxt', exchange, sandbox, demoTrading, - apiKey, apiSecret, - ...(password && { password }), - guards: [], - } - case 'alpaca': - return { - id: finalId, type: 'alpaca', paper, - apiKey, apiSecret, - guards: [], - } - case 'ibkr': - return { - id: finalId, type: 'ibkr', paper, - host: ibkrHost, port: ibkrPort, clientId: ibkrClientId, - ...(ibkrAccountId && { accountId: ibkrAccountId }), - guards: [], - } - default: - throw new Error(`Unknown account type: ${type}`) - } - } - const handleCreate = async () => { setSaving(true); setError('') try { - const account = buildConfig() + const account: AccountConfig = { id: finalId, type: type!, enabled: true, guards: [], brokerConfig } - // Test connection before saving const testResult = await api.trading.testConnection(account) if (!testResult.success) { setError(testResult.error || 'Connection failed') @@ -365,7 +412,6 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { return } - // Connection verified — persist config and create UTA await onSave(account) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create account') @@ -373,9 +419,9 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { } } - const typeBadge = type === 'ccxt' ? { text: 'CC', color: 'text-accent bg-accent/10' } - : type === 'ibkr' ? { text: 'IB', color: 'text-orange-400 bg-orange-400/10' } - : { text: 'AL', color: 'text-green bg-green/10' } + const canCreate = hasSensitive + ? credentialFields.filter(f => f.required).every(f => String(brokerConfig[f.name] ?? '').trim()) + : true return ( @@ -396,104 +442,45 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: {
{step === 1 && (
- {/* Platform selection */}

Platform

- setType(t as AccountConfig['type'])} /> + setType(t)} />
- {/* Connection config — expands after platform selection */} - {type && ( + {type && bt && (

Connection

- setId(e.target.value.trim())} placeholder={defaultId} /> - - {type === 'ccxt' && ( - <> - - - -
- - -
- - )} - - {type === 'alpaca' && ( - <> - -

When enabled, orders are routed to Alpaca's paper trading environment.

- - )} - - {type === 'ibkr' && ( - <> - - setIbkrHost(e.target.value)} placeholder="127.0.0.1" /> - - - setIbkrPort(parseInt(e.target.value) || 7497)} /> - - - setIbkrClientId(parseInt(e.target.value) || 0)} /> - - - setIbkrAccountId(e.target.value)} placeholder="Auto-detected from TWS" /> - - -

Authentication is handled by TWS/Gateway login — no API keys needed.

- - )} + setBrokerConfig(prev => ({ ...prev, [f]: v }))} + />
)} {error &&

{error}

}
)} - {step === 2 && ( + {step === 2 && bt && (
- - {typeBadge.text} - - - {type === 'ccxt' ? `${exchange} · CCXT` : `Alpaca · ${paper ? 'Paper' : 'Live'}`} + + {bt.badge} + {bt.name}
-

API Credentials

- - - setApiKey(e.target.value)} placeholder="Required" /> - - - setApiSecret(e.target.value)} placeholder="Required" /> - - {type === 'ccxt' && ( - - setPassword(e.target.value)} placeholder="Required by some exchanges (e.g. OKX)" /> - - )} +

Credentials

+ setBrokerConfig(prev => ({ ...prev, [f]: v }))} + /> {error &&

{error}

}
)} @@ -504,18 +491,18 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { - {step === 1 && !needsCredentials && type && ( + {step === 1 && !hasSensitive && type && ( )} - {step === 1 && (needsCredentials || !type) && ( + {step === 1 && (hasSensitive || !type) && ( )} {step === 2 && ( - )} @@ -526,8 +513,9 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { // ==================== Edit Dialog ==================== -function EditDialog({ account, health, onSaveAccount, onDelete, onClose }: { +function EditDialog({ account, brokerType, health, onSaveAccount, onDelete, onClose }: { account: AccountConfig + brokerType?: BrokerTypeInfo health?: BrokerHealthInfo onSaveAccount: (a: AccountConfig) => Promise onDelete: () => Promise @@ -543,8 +531,12 @@ function EditDialog({ account, health, onSaveAccount, onDelete, onClose }: { const dirty = JSON.stringify(draft) !== JSON.stringify(account) - const patch = (field: string, value: unknown) => { - setDraft((d) => ({ ...d, [field]: value }) as AccountConfig) + const patchBrokerConfig = (field: string, value: unknown) => { + setDraft(d => ({ ...d, brokerConfig: { ...d.brokerConfig, [field]: value } })) + } + + const patchGuards = (guards: AccountConfig['guards']) => { + setDraft(d => ({ ...d, guards })) } const handleSave = async () => { @@ -560,11 +552,13 @@ function EditDialog({ account, health, onSaveAccount, onDelete, onClose }: { } } - const guardTypes = account.type === 'ccxt' ? CRYPTO_GUARD_TYPES : SECURITIES_GUARD_TYPES + const fields = brokerType?.fields ?? [] + const hasSensitive = fields.some(f => f.sensitive) + const guardTypes = (brokerType?.guardCategory === 'crypto') ? CRYPTO_GUARD_TYPES : SECURITIES_GUARD_TYPES return ( - {/* Header — account id + health */} + {/* Header */}

{account.id}

@@ -579,47 +573,26 @@ function EditDialog({ account, health, onSaveAccount, onDelete, onClose }: { {/* Body */}
- {/* Connection */} -
+
Type - - {account.type === 'ccxt' ? 'CCXT' : account.type === 'ibkr' ? 'Interactive Brokers' : 'Alpaca'} - + {brokerType?.name ?? account.type}
- {draft.type === 'ccxt' ? ( - - ) : draft.type === 'alpaca' ? ( - - ) : ( - - )} -
- - {/* Credentials — per broker type. IBKR has no credentials (auth via TWS login). */} - {(account.type === 'ccxt' || account.type === 'alpaca') &&
- Credentials + + {hasSensitive && ( -
- }> - - patch('apiKey', e.target.value)} placeholder="Not configured" /> - - - patch('apiSecret', e.target.value)} placeholder="Not configured" /> - - {account.type === 'ccxt' && ( - - patch('password', e.target.value)} placeholder="Required by some exchanges (e.g. OKX)" /> - )} - } + {/* Guards */}
@@ -642,15 +615,15 @@ function EditDialog({ account, health, onSaveAccount, onDelete, onClose }: { guards={draft.guards} guardTypes={guardTypes} description="Guards validate operations before execution. Order matters." - onChange={(guards) => patch('guards', guards)} - onChangeImmediate={(guards) => patch('guards', guards)} + onChange={patchGuards} + onChangeImmediate={patchGuards} />
)}
- {/* Footer — Save/Reconnect left, Delete right */} + {/* Footer */}
{dirty && ( @@ -658,7 +631,15 @@ function EditDialog({ account, health, onSaveAccount, onDelete, onClose }: { {saving ? 'Saving...' : 'Save'} )} - + {draft.enabled !== false && } + {msg && {msg}}
@@ -668,70 +649,6 @@ function EditDialog({ account, health, onSaveAccount, onDelete, onClose }: { ) } -// ==================== Connection Fields ==================== - -function CcxtConnectionFields({ draft, onPatch }: { - draft: CcxtAccountConfig - onPatch: (field: string, value: unknown) => void -}) { - return ( - <> - - onPatch('exchange', e.target.value.trim())} placeholder="binance" /> - -
- - -
- - ) -} - -function AlpacaConnectionFields({ draft, onPatch }: { - draft: AlpacaAccountConfig - onPatch: (field: string, value: unknown) => void -}) { - return ( - <> - -

When enabled, orders are routed to Alpaca's paper trading environment.

- - ) -} - -function IbkrConnectionFields({ draft, onPatch }: { - draft: IbkrAccountConfig - onPatch: (field: string, value: unknown) => void -}) { - const inputClass = 'w-full bg-bg border border-border rounded-md px-3 py-1.5 text-[13px] text-text placeholder:text-text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent' - return ( - <> - - onPatch('host', e.target.value)} placeholder="127.0.0.1" /> - - - onPatch('port', parseInt(e.target.value) || 7497)} /> - - - onPatch('clientId', parseInt(e.target.value) || 0)} /> - - - onPatch('accountId', e.target.value || undefined)} placeholder="Auto-detected from TWS" /> - -

Authentication is handled by TWS/Gateway login — no API keys needed.

- - ) -} - // ==================== Delete Button ==================== function DeleteButton({ label, onConfirm }: { label: string; onConfirm: () => void }) {