From 6d423aafc3cc86944f2258c1262b35a8e4dbe65d Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 21 Mar 2026 21:25:26 +0800 Subject: [PATCH 1/7] fix: stop UTA recovery loop for permanent broker errors (missing API keys) Introduce BrokerError with code/permanent fields. UTA now detects permanent errors (CONFIG, AUTH) and marks the account as disabled instead of retrying forever. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/domain/trading/UnifiedTradingAccount.ts | 27 +++++++++++++++++- .../trading/brokers/alpaca/AlpacaBroker.ts | 25 +++++++++-------- src/domain/trading/brokers/ccxt/CcxtBroker.ts | 28 ++++++++++--------- src/domain/trading/brokers/types.ts | 21 ++++++++++++++ 4 files changed, 76 insertions(+), 25 deletions(-) diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index 8dc07d69..c042ddff 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -9,7 +9,7 @@ import Decimal from 'decimal.js' import { Contract, Order, ContractDescription, ContractDetails } from '@traderalice/ibkr' -import type { IBroker, AccountInfo, Position, OpenOrder, PlaceOrderResult, Quote, MarketClock, AccountCapabilities, BrokerHealth, BrokerHealthInfo } from './brokers/types.js' +import { BrokerError, type IBroker, type AccountInfo, type Position, type OpenOrder, type PlaceOrderResult, type Quote, type MarketClock, type AccountCapabilities, type BrokerHealth, type BrokerHealthInfo } from './brokers/types.js' import { TradingGit } from './git/TradingGit.js' import type { Operation, @@ -121,6 +121,7 @@ export class UnifiedTradingAccount { private _lastFailureAt?: Date private _recoveryTimer?: ReturnType private _recovering = false + private _disabled = false constructor(broker: IBroker, options: UnifiedTradingAccountOptions = {}) { this.broker = broker @@ -186,11 +187,16 @@ export class UnifiedTradingAccount { // ==================== Health ==================== get health(): BrokerHealth { + if (this._disabled) return 'offline' if (this._consecutiveFailures >= UnifiedTradingAccount.OFFLINE_THRESHOLD) return 'offline' if (this._consecutiveFailures >= UnifiedTradingAccount.DEGRADED_THRESHOLD) return 'degraded' return 'healthy' } + get disabled(): boolean { + return this._disabled + } + getHealthInfo(): BrokerHealthInfo { return { status: this.health, @@ -199,6 +205,7 @@ export class UnifiedTradingAccount { lastSuccessAt: this._lastSuccessAt, lastFailureAt: this._lastFailureAt, recovering: this._recovering, + disabled: this._disabled, } } @@ -211,6 +218,12 @@ export class UnifiedTradingAccount { console.log(`UTA[${this.id}]: connected`) } catch (err) { const msg = err instanceof Error ? err.message : String(err) + if (err instanceof BrokerError && err.permanent) { + console.warn(`UTA[${this.id}]: disabled — ${msg}`) + this._disabled = true + this._lastError = msg + return + } console.warn(`UTA[${this.id}]: initial connect failed: ${msg}`) this._consecutiveFailures = UnifiedTradingAccount.OFFLINE_THRESHOLD this._lastError = msg @@ -220,6 +233,9 @@ export class UnifiedTradingAccount { } private async _callBroker(fn: () => Promise): Promise { + if (this._disabled) { + throw new BrokerError('CONFIG', `Account "${this.label}" is disabled due to configuration error: ${this._lastError}`) + } if (this.health === 'offline' && this._recovering) { throw new Error(`Account "${this.label}" is offline and reconnecting. Try again shortly.`) } @@ -272,6 +288,12 @@ export class UnifiedTradingAccount { console.log(`UTA[${this.id}]: auto-recovery succeeded`) } catch (err) { const msg = err instanceof Error ? err.message : String(err) + if (err instanceof BrokerError && err.permanent) { + console.warn(`UTA[${this.id}]: disabled — ${msg}`) + this._disabled = true + this._recovering = false + return + } console.warn(`UTA[${this.id}]: recovery attempt ${attempt + 1} failed: ${msg}`) this._scheduleRecoveryAttempt(attempt + 1) } @@ -367,6 +389,9 @@ export class UnifiedTradingAccount { } async push(): Promise { + if (this._disabled) { + throw new BrokerError('CONFIG', `Account "${this.label}" is disabled due to configuration error.`) + } if (this.health === 'offline') { throw new Error(`Account "${this.label}" is offline. Cannot execute trades.`) } diff --git a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts index 231585e3..9c1a6164 100644 --- a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts +++ b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts @@ -11,15 +11,16 @@ 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' -import type { - IBroker, - AccountCapabilities, - AccountInfo, - Position, - PlaceOrderResult, - OpenOrder, - Quote, - MarketClock, +import { + BrokerError, + type IBroker, + type AccountCapabilities, + type AccountInfo, + type Position, + type PlaceOrderResult, + type OpenOrder, + type Quote, + type MarketClock, } from '../types.js' import '../../contract-ext.js' import type { @@ -77,7 +78,8 @@ export class AlpacaBroker implements IBroker { async init(): Promise { if (!this.config.apiKey || !this.config.secretKey) { - throw new Error( + throw new BrokerError( + 'CONFIG', `No API credentials configured. Set apiKey and apiSecret in accounts.json to enable this account.`, ) } @@ -101,7 +103,8 @@ export class AlpacaBroker implements IBroker { const isAuthError = err instanceof Error && /40[13]|forbidden|unauthorized/i.test(err.message) if (isAuthError && attempt >= AlpacaBroker.MAX_AUTH_RETRIES) { - throw new Error( + throw new BrokerError( + 'AUTH', `Authentication failed — verify your Alpaca API key and secret are correct.`, ) } diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index 715835a1..d0b715cf 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -10,18 +10,19 @@ import ccxt from 'ccxt' import Decimal from 'decimal.js' import type { Exchange, Order as CcxtOrder } from 'ccxt' import { Contract, ContractDescription, ContractDetails, Order, OrderState, UNSET_DOUBLE, UNSET_DECIMAL } from '@traderalice/ibkr' -import type { - IBroker, - AccountCapabilities, - AccountInfo, - Position, - PlaceOrderResult, - OpenOrder, - Quote, - MarketClock, - FundingRate, - OrderBook, - OrderBookLevel, +import { + BrokerError, + type IBroker, + type AccountCapabilities, + type AccountInfo, + type Position, + type PlaceOrderResult, + type OpenOrder, + type Quote, + type MarketClock, + type FundingRate, + type OrderBook, + type OrderBookLevel, } from '../types.js' import '../../contract-ext.js' import type { CcxtBrokerConfig, CcxtMarket } from './ccxt-types.js' @@ -109,7 +110,8 @@ export class CcxtBroker implements IBroker { private ensureWritable(): void { if (this.readOnly) { - throw new Error( + throw new BrokerError( + 'CONFIG', `CcxtBroker[${this.id}] is in read-only mode (no API keys). This operation requires authentication.`, ) } diff --git a/src/domain/trading/brokers/types.ts b/src/domain/trading/brokers/types.ts index d7487340..cb1efa8c 100644 --- a/src/domain/trading/brokers/types.ts +++ b/src/domain/trading/brokers/types.ts @@ -11,6 +11,26 @@ import type { Contract, ContractDescription, ContractDetails, Order, OrderState, import type Decimal from 'decimal.js' import '../contract-ext.js' +// ==================== Errors ==================== + +export type BrokerErrorCode = 'CONFIG' | 'AUTH' | 'NETWORK' | 'EXCHANGE' | 'UNKNOWN' + +/** + * Structured broker error. `permanent` errors (CONFIG, AUTH) will not be retried + * by UTA's recovery loop — transient errors (NETWORK, EXCHANGE) will. + */ +export class BrokerError extends Error { + readonly code: BrokerErrorCode + readonly permanent: boolean + + constructor(code: BrokerErrorCode, message: string) { + super(message) + this.name = 'BrokerError' + this.code = code + this.permanent = code === 'CONFIG' || code === 'AUTH' + } +} + // ==================== Position ==================== /** @@ -113,6 +133,7 @@ export interface BrokerHealthInfo { lastSuccessAt?: Date lastFailureAt?: Date recovering: boolean + disabled: boolean } // ==================== Account capabilities ==================== From 5b1a8e9c7bc5b84508a9b0655b4a92136c537c91 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 21 Mar 2026 21:49:37 +0800 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20UTA=20management=20=E2=80=94=20?= =?UTF-8?q?remove=20legacy=20migration,=20add=20real-time=20health=20monit?= =?UTF-8?q?oring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Remove migrateLegacyTradingConfig() and crypto.json/securities.json fallback - Seed empty platforms.json/accounts.json on first run (accounts via UI wizard) - Skip account creation when apiKey is missing (safety net in main.ts) - Add onHealthChange callback to UTA, emit account.health events via eventLog - Move eventLog creation before trading init for proper ordering Frontend: - Add BrokerHealthInfo/AccountSummary types - New useAccountHealth hook (initial fetch + SSE subscription) - TradingPage: add Status column with HealthBadge (connected/unstable/reconnecting/disabled) - PortfolioPage: distinguish disabled (grey) from offline (red reconnecting) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/config.spec.ts | 55 ++----------- src/core/config.ts | 89 +++------------------ src/domain/trading/UnifiedTradingAccount.ts | 15 ++++ src/main.ts | 17 ++-- ui/src/api/trading.ts | 6 +- ui/src/api/types.ts | 20 +++++ ui/src/hooks/useAccountHealth.ts | 31 +++++++ ui/src/pages/PortfolioPage.tsx | 40 +++++---- ui/src/pages/TradingPage.tsx | 53 +++++++++++- 9 files changed, 175 insertions(+), 151 deletions(-) create mode 100644 ui/src/hooks/useAccountHealth.ts diff --git a/src/core/config.spec.ts b/src/core/config.spec.ts index 5b20936e..4f5f676a 100644 --- a/src/core/config.spec.ts +++ b/src/core/config.spec.ts @@ -308,63 +308,18 @@ describe('loadTradingConfig', () => { expect(mockWriteFile).not.toHaveBeenCalled() }) - it('migrates from crypto.json + securities.json when platforms.json is missing', async () => { - // platforms.json → ENOENT - fileNotFound() - // accounts.json → ENOENT - fileNotFound() - // crypto.json (loaded inside migrateLegacyTradingConfig) - fileReturns({ - provider: { - type: 'ccxt', - exchange: 'binance', - apiKey: 'k1', - apiSecret: 's1', - sandbox: false, - demoTrading: false, - }, - guards: [], - }) - // securities.json - fileReturns({ - provider: { type: 'alpaca', paper: true, apiKey: 'alpk', secretKey: 'alps' }, - guards: [], - }) - - const { platforms, accounts } = await loadTradingConfig() - - expect(platforms.find(p => p.type === 'ccxt')).toBeDefined() - expect(platforms.find(p => p.type === 'alpaca')).toBeDefined() - expect(accounts.find(a => a.id === 'binance-main')).toBeDefined() - expect(accounts.find(a => a.id === 'alpaca-paper')).toBeDefined() - - // Should have written platforms.json and accounts.json - const writtenPaths = mockWriteFile.mock.calls.map(c => c[0] as string) - expect(writtenPaths.some(p => p.endsWith('platforms.json'))).toBe(true) - expect(writtenPaths.some(p => p.endsWith('accounts.json'))).toBe(true) - }) - - it('migrates from legacy with none providers → empty arrays', async () => { + it('seeds empty arrays when config files are missing', async () => { fileNotFound() // platforms.json fileNotFound() // accounts.json - fileReturns({ provider: { type: 'none' }, guards: [] }) // crypto.json - fileReturns({ provider: { type: 'none' }, guards: [] }) // securities.json const { platforms, accounts } = await loadTradingConfig() expect(platforms).toHaveLength(0) expect(accounts).toHaveLength(0) - }) - - it('falls back to defaults when legacy files are also missing', async () => { - fileNotFound() // platforms.json - fileNotFound() // accounts.json - fileNotFound() // crypto.json - fileNotFound() // securities.json - const { platforms, accounts } = await loadTradingConfig() - // Default crypto is ccxt/binance, default securities is alpaca/paper - expect(platforms.find(p => p.type === 'ccxt')).toBeDefined() - expect(platforms.find(p => p.type === 'alpaca')).toBeDefined() + // Should have written empty platforms.json and accounts.json + const writtenPaths = mockWriteFile.mock.calls.map(c => c[0] as string) + expect(writtenPaths.some(p => p.endsWith('platforms.json'))).toBe(true) + expect(writtenPaths.some(p => p.endsWith('accounts.json'))).toBe(true) }) }) diff --git a/src/core/config.ts b/src/core/config.ts index 75901fb8..599c95fc 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -69,9 +69,7 @@ const cryptoSchema = z.object({ z.object({ type: z.literal('none'), }), - ]).default({ - type: 'ccxt', exchange: 'binance', sandbox: false, demoTrading: true, - }), + ]).default({ type: 'none' }), guards: z.array(z.object({ type: z.string(), options: z.record(z.string(), z.unknown()).default({}), @@ -89,7 +87,7 @@ const securitiesSchema = z.object({ z.object({ type: z.literal('none'), }), - ]).default({ type: 'alpaca', paper: true }), + ]).default({ type: 'none' }), guards: z.array(z.object({ type: z.string(), options: z.record(z.string(), z.unknown()).default({}), @@ -370,8 +368,8 @@ export async function loadConfig(): Promise { // ==================== Trading Config Loader ==================== /** - * Load platform + account config. - * Prefers platforms.json + accounts.json. Falls back to legacy crypto.json + securities.json. + * Load platform + account config from platforms.json + accounts.json. + * Seeds empty arrays on first run — accounts are created via UI wizard. */ export async function loadTradingConfig(): Promise<{ platforms: PlatformConfig[] @@ -382,79 +380,18 @@ export async function loadTradingConfig(): Promise<{ loadJsonFile('accounts.json'), ]) - if (rawPlatforms !== undefined && rawAccounts !== undefined) { - return { - platforms: platformsFileSchema.parse(rawPlatforms), - accounts: accountsFileSchema.parse(rawAccounts), - } - } - - // Migration: derive from legacy crypto.json + securities.json - return migrateLegacyTradingConfig() -} - -/** Derive platform+account config from old crypto.json + securities.json, then write to disk. - * TODO: remove before v1.0 — drop crypto.json/securities.json support entirely */ -async function migrateLegacyTradingConfig(): Promise<{ - platforms: PlatformConfig[] - accounts: AccountConfig[] -}> { - const [rawCrypto, rawSecurities] = await Promise.all([ - loadJsonFile('crypto.json'), - loadJsonFile('securities.json'), - ]) - - const crypto = cryptoSchema.parse(rawCrypto ?? {}) - const securities = securitiesSchema.parse(rawSecurities ?? {}) - - const platforms: PlatformConfig[] = [] - const accounts: AccountConfig[] = [] - - if (crypto.provider.type === 'ccxt') { - const p = crypto.provider - const platformId = `${p.exchange}-platform` - platforms.push({ - id: platformId, - type: 'ccxt', - exchange: p.exchange, - sandbox: p.sandbox, - demoTrading: p.demoTrading, - options: p.options, - }) - accounts.push({ - id: `${p.exchange}-main`, - platformId, - apiKey: p.apiKey, - apiSecret: p.apiSecret, - password: p.password, - guards: crypto.guards, - }) - } + const platforms = platformsFileSchema.parse(rawPlatforms ?? []) + const accounts = accountsFileSchema.parse(rawAccounts ?? []) - if (securities.provider.type === 'alpaca') { - const p = securities.provider - const platformId = 'alpaca-platform' - platforms.push({ - id: platformId, - type: 'alpaca', - paper: p.paper, - }) - accounts.push({ - id: p.paper ? 'alpaca-paper' : 'alpaca-live', - platformId, - apiKey: p.apiKey, - apiSecret: p.secretKey, - guards: securities.guards, - }) + // Seed empty files on first run so user has something to edit + if (rawPlatforms === undefined || rawAccounts === undefined) { + await mkdir(CONFIG_DIR, { recursive: true }) + const writes: Promise[] = [] + if (rawPlatforms === undefined) writes.push(writeFile(resolve(CONFIG_DIR, 'platforms.json'), JSON.stringify(platforms, null, 2) + '\n')) + if (rawAccounts === undefined) writes.push(writeFile(resolve(CONFIG_DIR, 'accounts.json'), JSON.stringify(accounts, null, 2) + '\n')) + await Promise.all(writes) } - // Seed to disk so the user can edit the new format directly - await mkdir(CONFIG_DIR, { recursive: true }) - await Promise.all([ - writeFile(resolve(CONFIG_DIR, 'platforms.json'), JSON.stringify(platforms, null, 2) + '\n'), - writeFile(resolve(CONFIG_DIR, 'accounts.json'), JSON.stringify(accounts, null, 2) + '\n'), - ]) - return { platforms, accounts } } diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index c042ddff..7a764c46 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -57,6 +57,7 @@ export interface UnifiedTradingAccountOptions { guards?: Array<{ type: string; options?: Record }> savedState?: GitExportState onCommit?: (state: GitExportState) => void | Promise + onHealthChange?: (accountId: string, health: BrokerHealthInfo) => void platformId?: string } @@ -108,6 +109,7 @@ export class UnifiedTradingAccount { readonly platformId?: string private readonly _getState: () => Promise + private readonly _onHealthChange?: (accountId: string, health: BrokerHealthInfo) => void // ---- Health tracking ---- private static readonly DEGRADED_THRESHOLD = 3 @@ -128,6 +130,7 @@ export class UnifiedTradingAccount { this.id = broker.id this.label = broker.label this.platformId = options.platformId + this._onHealthChange = options.onHealthChange // Wire internals this._getState = async (): Promise => { @@ -215,6 +218,7 @@ export class UnifiedTradingAccount { await this.broker.init() await this.broker.getAccount() this._onSuccess() + this._emitHealthChange() console.log(`UTA[${this.id}]: connected`) } catch (err) { const msg = err instanceof Error ? err.message : String(err) @@ -222,6 +226,7 @@ export class UnifiedTradingAccount { console.warn(`UTA[${this.id}]: disabled — ${msg}`) this._disabled = true this._lastError = msg + this._emitHealthChange() return } console.warn(`UTA[${this.id}]: initial connect failed: ${msg}`) @@ -249,7 +254,12 @@ export class UnifiedTradingAccount { } } + private _emitHealthChange(): void { + this._onHealthChange?.(this.id, this.getHealthInfo()) + } + private _onSuccess(): void { + const prev = this.health this._consecutiveFailures = 0 this._lastSuccessAt = new Date() if (this._recoveryTimer) { @@ -257,20 +267,24 @@ export class UnifiedTradingAccount { this._recoveryTimer = undefined this._recovering = false } + if (prev !== this.health) this._emitHealthChange() } private _onFailure(err: unknown): void { + const prev = this.health this._consecutiveFailures++ this._lastError = err instanceof Error ? err.message : String(err) this._lastFailureAt = new Date() if (this.health === 'offline' && !this._recovering) { this._startRecovery() } + if (prev !== this.health) this._emitHealthChange() } private _startRecovery(): void { if (this._recovering) return this._recovering = true + this._emitHealthChange() console.log(`UTA[${this.id}]: offline, starting auto-recovery...`) this._scheduleRecoveryAttempt(0) } @@ -292,6 +306,7 @@ export class UnifiedTradingAccount { console.warn(`UTA[${this.id}]: disabled — ${msg}`) this._disabled = true this._recovering = false + this._emitHealthChange() return } console.warn(`UTA[${this.id}]: recovery attempt ${attempt + 1} failed: ${msg}`) diff --git a/src/main.ts b/src/main.ts index bd632c6d..d2731ea2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -106,6 +106,11 @@ async function loadGitState(accountId: string): Promise { + eventLog.append('account.health', { accountId, ...health }) + }, platformId: accountCfg.platformId, }) accountManager.add(uta) @@ -138,6 +146,10 @@ async function main() { } for (const accCfg of tradingConfig.accounts) { + if (!accCfg.apiKey) { + console.warn(`Account "${accCfg.id}": no API key configured — skipping. Add credentials in the Trading page or accounts.json.`) + continue + } const platform = platformRegistry.get(accCfg.platformId)! await initAccount(accCfg, platform) } @@ -180,11 +192,6 @@ async function main() { `**Emotion:** ${emotion}`, ].join('\n') - // ==================== Event Log ==================== - - const eventLog = await createEventLog() - const toolCallLog = await createToolCallLog() - // ==================== Cron ==================== const cronEngine = createCronEngine({ eventLog }) diff --git a/ui/src/api/trading.ts b/ui/src/api/trading.ts index 2cb14a90..a64a637b 100644 --- a/ui/src/api/trading.ts +++ b/ui/src/api/trading.ts @@ -1,5 +1,5 @@ import { fetchJson } from './client' -import type { TradingAccount, AccountInfo, Position, WalletCommitLog, ReconnectResult, PlatformConfig, AccountConfig, WalletStatus, WalletPushResult, WalletRejectResult } from './types' +import type { TradingAccount, AccountSummary, AccountInfo, Position, WalletCommitLog, ReconnectResult, PlatformConfig, AccountConfig, WalletStatus, WalletPushResult, WalletRejectResult } from './types' // ==================== Unified Trading API ==================== @@ -10,6 +10,10 @@ export const tradingApi = { return fetchJson('/api/trading/accounts') }, + async listAccountSummaries(): Promise<{ accounts: AccountSummary[] }> { + return fetchJson('/api/trading/accounts') + }, + async equity(): Promise<{ totalEquity: number; totalCash: number; totalUnrealizedPnL: number; totalRealizedPnL: number; accounts: Array<{ id: string; label: string; equity: number; cash: number }> }> { return fetchJson('/api/trading/equity') }, diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index ba752935..71fd6eb8 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -147,6 +147,26 @@ export interface CronJob { // ==================== Trading ==================== +export type BrokerHealth = 'healthy' | 'degraded' | 'offline' + +export interface BrokerHealthInfo { + status: BrokerHealth + consecutiveFailures: number + lastError?: string + lastSuccessAt?: string + lastFailureAt?: string + recovering: boolean + disabled: boolean +} + +export interface AccountSummary { + id: string + label: string + platformId?: string + capabilities: { supportedSecTypes: string[]; supportedOrderTypes: string[] } + health: BrokerHealthInfo +} + export interface TradingAccount { id: string provider: string diff --git a/ui/src/hooks/useAccountHealth.ts b/ui/src/hooks/useAccountHealth.ts new file mode 100644 index 00000000..c76d4336 --- /dev/null +++ b/ui/src/hooks/useAccountHealth.ts @@ -0,0 +1,31 @@ +import { useState, useEffect, useCallback } from 'react' +import { api } from '../api' +import { useSSE } from './useSSE' +import type { BrokerHealthInfo } from '../api/types' + +/** + * Fetches account health on mount and subscribes to SSE for real-time updates. + * Returns a map of accountId → BrokerHealthInfo. + */ +export function useAccountHealth() { + const [healthMap, setHealthMap] = useState>({}) + + useEffect(() => { + api.trading.listAccountSummaries().then(({ accounts }) => { + const map: Record = {} + for (const a of accounts) map[a.id] = a.health + setHealthMap(map) + }).catch(() => {}) + }, []) + + const handleSSE = useCallback((entry: { type?: string; payload?: { accountId?: string } & BrokerHealthInfo }) => { + if (entry.type === 'account.health' && entry.payload?.accountId) { + const { accountId, ...health } = entry.payload + setHealthMap((prev) => ({ ...prev, [accountId]: health as BrokerHealthInfo })) + } + }, []) + + useSSE({ url: '/api/events/stream', onMessage: handleSSE }) + + return healthMap +} diff --git a/ui/src/pages/PortfolioPage.tsx b/ui/src/pages/PortfolioPage.tsx index 3fcf54d8..22efc98f 100644 --- a/ui/src/pages/PortfolioPage.tsx +++ b/ui/src/pages/PortfolioPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react' import { api, type Position, type WalletCommitLog } from '../api' +import { useAccountHealth } from '../hooks/useAccountHealth' import { PageHeader } from '../components/PageHeader' import { EmptyState } from '../components/StateViews' @@ -32,6 +33,7 @@ const EMPTY: PortfolioData = { equity: null, accounts: [] } // ==================== Page ==================== export function PortfolioPage() { + const healthMap = useAccountHealth() const [data, setData] = useState(EMPTY) const [loading, setLoading] = useState(true) const [lastRefresh, setLastRefresh] = useState(null) @@ -63,7 +65,8 @@ export function PortfolioPage() { const accountSources = (data.equity?.accounts ?? []).map(eq => { const acct = data.accounts.find(a => a.id === eq.id) const unrealizedPnL = acct?.positions.reduce((sum, p) => sum + p.unrealizedPnL, 0) ?? 0 - return { ...eq, provider: acct?.provider ?? '', unrealizedPnL, error: acct?.error, health: eq.health } + const hInfo = healthMap[eq.id] + return { ...eq, provider: acct?.provider ?? '', unrealizedPnL, error: acct?.error, health: eq.health, disabled: hInfo?.disabled ?? false } }) return ( @@ -185,28 +188,33 @@ const HEALTH_DOT: Record = { offline: 'bg-red', } -function AccountStrip({ sources }: { sources: Array<{ id: string; label: string; provider: string; equity: number; unrealizedPnL: number; error?: string; health?: string }> }) { +function AccountStrip({ sources }: { sources: Array<{ id: string; label: string; provider: string; equity: number; unrealizedPnL: number; error?: string; health?: string; disabled?: boolean }> }) { return (
{sources.map(s => { - const dotColor = HEALTH_DOT[s.health ?? 'healthy'] ?? 'bg-text-muted' - const isOffline = s.health === 'offline' + const isDisabled = s.disabled + const isOffline = s.health === 'offline' && !isDisabled + const dotColor = isDisabled + ? 'bg-text-muted/40' + : (HEALTH_DOT[s.health ?? 'healthy'] ?? 'bg-text-muted') return ( -
+
{s.label} - {isOffline - ? Reconnecting… - : <> - {fmt(s.equity)} - {s.unrealizedPnL !== 0 && ( - = 0 ? 'text-green' : 'text-red'}> - {fmtPnl(s.unrealizedPnL)} - - )} - + {isDisabled + ? Disabled + : isOffline + ? Reconnecting... + : <> + {fmt(s.equity)} + {s.unrealizedPnL !== 0 && ( + = 0 ? 'text-green' : 'text-red'}> + {fmtPnl(s.unrealizedPnL)} + + )} + } - {s.error && !isOffline && {s.error}} + {s.error && !isOffline && !isDisabled && {s.error}}
) })} diff --git a/ui/src/pages/TradingPage.tsx b/ui/src/pages/TradingPage.tsx index 39e71ee9..5e8965a7 100644 --- a/ui/src/pages/TradingPage.tsx +++ b/ui/src/pages/TradingPage.tsx @@ -5,8 +5,9 @@ import { GuardsSection, CRYPTO_GUARD_TYPES, SECURITIES_GUARD_TYPES } from '../co import { SDKSelector, PLATFORM_TYPE_OPTIONS } from '../components/SDKSelector' import { ReconnectButton } from '../components/ReconnectButton' import { useTradingConfig } from '../hooks/useTradingConfig' +import { useAccountHealth } from '../hooks/useAccountHealth' import { PageHeader } from '../components/PageHeader' -import type { PlatformConfig, CcxtPlatformConfig, AlpacaPlatformConfig, AccountConfig } from '../api/types' +import type { PlatformConfig, CcxtPlatformConfig, AlpacaPlatformConfig, AccountConfig, BrokerHealthInfo } from '../api/types' // ==================== Dialog state ==================== @@ -19,6 +20,7 @@ type DialogState = export function TradingPage() { const tc = useTradingConfig() + const healthMap = useAccountHealth() const [dialog, setDialog] = useState(null) // Close dialog if the selected account was deleted @@ -64,6 +66,7 @@ export function TradingPage() { setDialog({ kind: 'edit', accountId: id })} /> @@ -150,9 +153,47 @@ function Dialog({ onClose, width, children }: { // ==================== Accounts Table ==================== -function AccountsTable({ accounts, platforms, onSelect }: { +function HealthBadge({ health }: { health?: BrokerHealthInfo }) { + if (!health) return + + if (health.disabled) { + return ( + + + Disabled + + ) + } + + switch (health.status) { + case 'healthy': + return ( + + + Connected + + ) + case 'degraded': + return ( + + + Unstable + + ) + case 'offline': + return ( + + + {health.recovering ? 'Reconnecting...' : 'Offline'} + + ) + } +} + +function AccountsTable({ accounts, platforms, healthMap, onSelect }: { accounts: AccountConfig[] platforms: PlatformConfig[] + healthMap: Record onSelect: (id: string) => void }) { const getPlatform = (platformId: string) => platforms.find((p) => p.id === platformId) @@ -183,12 +224,15 @@ function AccountsTable({ accounts, platforms, onSelect }: { Account Connection + Status Guards {accounts.map((account) => { const p = getPlatform(account.platformId) + const health = healthMap[account.id] + const isDisabled = health?.disabled const badge = p?.type === 'ccxt' ? { text: 'CC', color: 'text-accent bg-accent/10' } : { text: 'AL', color: 'text-green bg-green/10' } @@ -197,7 +241,7 @@ function AccountsTable({ accounts, platforms, onSelect }: { onSelect(account.id)} - className="cursor-pointer transition-colors hover:bg-bg-tertiary/30" + className={`cursor-pointer transition-colors hover:bg-bg-tertiary/30 ${isDisabled ? 'opacity-50' : ''}`} > @@ -206,6 +250,9 @@ function AccountsTable({ accounts, platforms, onSelect }: { {account.id} {getConnectionLabel(account)} + + + {account.guards.length > 0 ? account.guards.length : '—'} From 34361f07727f739a6e68f82853c00996c0df6823 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 21 Mar 2026 21:55:02 +0800 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20move=20data/default/=20?= =?UTF-8?q?=E2=86=92=20default/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project resource templates shouldn't live inside the runtime data directory — makes it awkward to clear data/ without losing defaults. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 -- README.md | 4 ++-- {data/default => default}/heartbeat.default.md | 0 {data/default => default}/persona.default.md | 0 {data/default => default}/skills/createDocx.md | 0 src/core/config.ts | 2 +- src/main.ts | 2 +- 7 files changed, 4 insertions(+), 6 deletions(-) rename {data/default => default}/heartbeat.default.md (100%) rename {data/default => default}/persona.default.md (100%) rename {data/default => default}/skills/createDocx.md (100%) diff --git a/.gitignore b/.gitignore index 888c0be6..1680a852 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,6 @@ node_modules/ dist/ logs/ /data/* -!/data/default/ -!/data/default/** state.json *.log diff --git a/README.md b/README.md index 112e88d1..4006fad1 100644 --- a/README.md +++ b/README.md @@ -189,8 +189,8 @@ Persona and heartbeat prompts use a **default + user override** pattern: | Default (git-tracked) | User override (gitignored) | |------------------------|---------------------------| -| `data/default/persona.default.md` | `data/brain/persona.md` | -| `data/default/heartbeat.default.md` | `data/brain/heartbeat.md` | +| `default/persona.default.md` | `data/brain/persona.md` | +| `default/heartbeat.default.md` | `data/brain/heartbeat.md` | On first run, defaults are auto-copied to the user override path. Edit the user files to customize without touching version control. diff --git a/data/default/heartbeat.default.md b/default/heartbeat.default.md similarity index 100% rename from data/default/heartbeat.default.md rename to default/heartbeat.default.md diff --git a/data/default/persona.default.md b/default/persona.default.md similarity index 100% rename from data/default/persona.default.md rename to default/persona.default.md diff --git a/data/default/skills/createDocx.md b/default/skills/createDocx.md similarity index 100% rename from data/default/skills/createDocx.md rename to default/skills/createDocx.md diff --git a/src/core/config.ts b/src/core/config.ts index 599c95fc..4fb5f5dc 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -160,7 +160,7 @@ const connectorsSchema = z.object({ const heartbeatSchema = z.object({ enabled: z.boolean().default(false), every: z.string().default('30m'), - prompt: z.string().default('Read data/brain/heartbeat.md (or data/default/heartbeat.default.md if not found) and follow the instructions inside.'), + prompt: z.string().default('Read data/brain/heartbeat.md (or default/heartbeat.default.md if not found) and follow the instructions inside.'), activeHours: activeHoursSchema, }) diff --git a/src/main.ts b/src/main.ts index d2731ea2..3c1d89c6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -65,7 +65,7 @@ const LEGACY_GIT_PATHS: Record = { 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') -const PERSONA_DEFAULT = resolve('data/default/persona.default.md') +const PERSONA_DEFAULT = resolve('default/persona.default.md') const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) From e8b033cbc8a0322b803053a62c009566effa9b0e Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 21 Mar 2026 22:07:29 +0800 Subject: [PATCH 4/7] fix: remove CcxtBroker read-only mode, require API keys for all accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CcxtBroker now throws BrokerConfigError on init() without credentials (same as AlpacaBroker) instead of silently entering read-only mode - Remove readOnly field and ensureWritable() — UTA = trading account = must have keys - Wizard Step 3: API Key/Secret now required, Create button disabled when empty - Auto-reconnect after account creation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../trading/brokers/ccxt/CcxtBroker.spec.ts | 10 +---- src/domain/trading/brokers/ccxt/CcxtBroker.ts | 39 +++++++------------ ui/src/pages/TradingPage.tsx | 7 ++-- 3 files changed, 19 insertions(+), 37 deletions(-) diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts index 4dfaefc2..8b166ce4 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts @@ -95,11 +95,6 @@ describe('CcxtBroker — constructor', () => { ) }) - it('sets readOnly when no apiKey', () => { - const acc = new CcxtBroker({ exchange: 'bybit', apiKey: '', apiSecret: '', sandbox: false }) - expect((acc as any).readOnly).toBe(true) - }) - it('stores exchange name in meta', () => { const acc = makeAccount() expect(acc.meta).toEqual({ exchange: 'bybit' }) @@ -624,11 +619,10 @@ describe('CcxtBroker — getAccount', () => { expect(info.realizedPnL).toBe(150) }) - it('throws when read-only', async () => { + it('throws BrokerError when no API credentials', async () => { const acc = new CcxtBroker({ exchange: 'bybit', apiKey: '', apiSecret: '', sandbox: false }) - ;(acc as any).initialized = true - await expect(acc.getAccount()).rejects.toThrow('read-only') + await expect(acc.init()).rejects.toThrow('No API credentials configured') }) }) diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index d0b715cf..de7b865f 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -56,8 +56,6 @@ export class CcxtBroker implements IBroker { private exchange: Exchange private exchangeName: string private initialized = false - private readonly readOnly: boolean - // orderId → ccxtSymbol cache (CCXT needs symbol to cancel) private orderSymbolCache = new Map() @@ -66,7 +64,6 @@ export class CcxtBroker implements IBroker { this.meta = { exchange: config.exchange } this.id = config.id ?? `${config.exchange}-main` this.label = config.label ?? `${config.exchange.charAt(0).toUpperCase() + config.exchange.slice(1)} ${config.sandbox ? 'Testnet' : 'Live'}` - this.readOnly = !config.apiKey || !config.apiSecret const exchanges = ccxt as unknown as Record) => Exchange> const ExchangeClass = exchanges[config.exchange] @@ -108,22 +105,13 @@ export class CcxtBroker implements IBroker { } } - private ensureWritable(): void { - if (this.readOnly) { - throw new BrokerError( - 'CONFIG', - `CcxtBroker[${this.id}] is in read-only mode (no API keys). This operation requires authentication.`, - ) - } - } - // ---- Lifecycle ---- async init(): Promise { - if (this.readOnly) { - console.log( - `CcxtBroker[${this.id}]: no API credentials — running in market-data-only mode. ` + - `Set apiKey and apiSecret in accounts.json for trading.`, + if (!this.exchange.apiKey || !this.exchange.secret) { + throw new BrokerError( + 'CONFIG', + `No API credentials configured. Set apiKey and apiSecret in accounts.json to enable this account.`, ) } @@ -175,8 +163,7 @@ export class CcxtBroker implements IBroker { throw new Error(`CcxtBroker[${this.id}]: failed to load any markets`) } this.initialized = true - const mode = this.readOnly ? ', read-only (no API keys)' : '' - console.log(`CcxtBroker[${this.id}]: connected (${this.exchangeName}, ${marketCount} markets loaded${mode})`) + console.log(`CcxtBroker[${this.id}]: connected (${this.exchangeName}, ${marketCount} markets loaded)`) } async close(): Promise { @@ -253,7 +240,7 @@ export class CcxtBroker implements IBroker { async placeOrder(contract: Contract, order: Order, extraParams?: Record): Promise { this.ensureInit() - this.ensureWritable() + const ccxtSymbol = contractToCcxt(contract, this.markets, this.exchangeName) if (!ccxtSymbol) { @@ -311,7 +298,7 @@ export class CcxtBroker implements IBroker { async cancelOrder(orderId: string): Promise { this.ensureInit() - this.ensureWritable() + try { const ccxtSymbol = this.orderSymbolCache.get(orderId) @@ -324,7 +311,7 @@ export class CcxtBroker implements IBroker { async modifyOrder(orderId: string, changes: Order): Promise { this.ensureInit() - this.ensureWritable() + try { const ccxtSymbol = this.orderSymbolCache.get(orderId) @@ -358,7 +345,7 @@ export class CcxtBroker implements IBroker { async closePosition(contract: Contract, quantity?: Decimal): Promise { this.ensureInit() - this.ensureWritable() + const positions = await this.getPositions() const ccxtSymbol = contractToCcxt(contract, this.exchange.markets as Record, this.exchangeName) @@ -385,7 +372,7 @@ export class CcxtBroker implements IBroker { async getAccount(): Promise { this.ensureInit() - this.ensureWritable() + const [balance, rawPositions] = await Promise.all([ this.exchange.fetchBalance(), @@ -415,7 +402,7 @@ export class CcxtBroker implements IBroker { async getPositions(): Promise { this.ensureInit() - this.ensureWritable() + const raw = await this.exchange.fetchPositions() const result: Position[] = [] @@ -455,7 +442,7 @@ export class CcxtBroker implements IBroker { async getOrders(orderIds: string[]): Promise { this.ensureInit() - this.ensureWritable() + const results: OpenOrder[] = [] for (const id of orderIds) { @@ -467,7 +454,7 @@ export class CcxtBroker implements IBroker { async getOrder(orderId: string): Promise { this.ensureInit() - this.ensureWritable() + const ccxtSymbol = this.orderSymbolCache.get(orderId) if (!ccxtSymbol) return null diff --git a/ui/src/pages/TradingPage.tsx b/ui/src/pages/TradingPage.tsx index 5e8965a7..b5962176 100644 --- a/ui/src/pages/TradingPage.tsx +++ b/ui/src/pages/TradingPage.tsx @@ -86,6 +86,7 @@ export function TradingPage() { onSave={async (platform, account) => { await tc.savePlatform(platform) await tc.saveAccount(account) + if (account.apiKey) tc.reconnectAccount(account.id).catch(() => {}) setDialog(null) }} onClose={() => setDialog(null)} @@ -393,10 +394,10 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: {

API Credentials

- setApiKey(e.target.value)} placeholder="Optional — can be added later" /> + setApiKey(e.target.value)} placeholder="Required" /> - setApiSecret(e.target.value)} placeholder="Optional — can be added later" /> + setApiSecret(e.target.value)} placeholder="Required" /> {type === 'ccxt' && ( @@ -426,7 +427,7 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { )} {step === 3 && ( - )} From 844758968136bb12871d5a440aa10a715f635bc4 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 21 Mar 2026 22:28:10 +0800 Subject: [PATCH 5/7] =?UTF-8?q?ui:=20TradingPage=20full=20redesign=20?= =?UTF-8?q?=E2=80=94=20cards,=202-step=20wizard,=20visual=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace AccountsTable with AccountCard list (health badge, subtitle) - Merge wizard from 3 steps to 2 (platform+connection, then credentials) - Empty state with centered CTA card - EditDialog: health badge in header, show/hide credentials toggle - Upgrade all buttons to standard size, DeleteButton uses btn-danger - Widen dialogs to 560px, increase padding throughout - Section.title accepts ReactNode for inline controls Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/form.tsx | 2 +- ui/src/pages/TradingPage.tsx | 438 +++++++++++++++++++---------------- 2 files changed, 235 insertions(+), 205 deletions(-) diff --git a/ui/src/components/form.tsx b/ui/src/components/form.tsx index b1e5ff63..07d29ecc 100644 --- a/ui/src/components/form.tsx +++ b/ui/src/components/form.tsx @@ -24,7 +24,7 @@ export function Card({ children, className = '' }: CardProps) { interface SectionProps { id?: string - title: string + title: ReactNode description?: string children: ReactNode } diff --git a/ui/src/pages/TradingPage.tsx b/ui/src/pages/TradingPage.tsx index b5962176..bd9fbd65 100644 --- a/ui/src/pages/TradingPage.tsx +++ b/ui/src/pages/TradingPage.tsx @@ -23,7 +23,6 @@ export function TradingPage() { const healthMap = useAccountHealth() const [dialog, setDialog] = useState(null) - // Close dialog if the selected account was deleted useEffect(() => { if (dialog?.kind === 'edit') { if (!tc.accounts.some((a) => a.id === dialog.accountId)) setDialog(null) @@ -35,9 +34,7 @@ export function TradingPage() { return (

{tc.error}

- +
) } @@ -60,22 +57,29 @@ export function TradingPage() {
- {/* Content */}
-
- setDialog({ kind: 'edit', accountId: id })} - /> - - +
+ {tc.accounts.length === 0 ? ( + setDialog({ kind: 'add' })} /> + ) : ( + <> + {tc.accounts.map((account) => ( + setDialog({ kind: 'edit', accountId: account.id })} + /> + ))} + + + )}
@@ -102,6 +106,7 @@ export function TradingPage() { deleteAccountWithPlatform(account.id)} @@ -124,6 +129,22 @@ function PageShell({ subtitle, children }: { subtitle: string; children?: React. ) } +// ==================== Empty State ==================== + +function EmptyState({ onAdd }: { onAdd: () => void }) { + return ( +
+

No trading accounts

+

+ Connect a crypto exchange or brokerage account to start automated trading. +

+ +
+ ) +} + // ==================== Dialog ==================== function Dialog({ onClose, width, children }: { @@ -142,25 +163,26 @@ function Dialog({ onClose, width, children }: { return (
- {/* Backdrop */}
- {/* Content */} -
+
{children}
) } -// ==================== Accounts Table ==================== +// ==================== Health Badge ==================== + +function HealthBadge({ health, size = 'sm' }: { health?: BrokerHealthInfo; size?: 'sm' | 'md' }) { + const textSize = size === 'md' ? 'text-[12px]' : 'text-[11px]' + const dotSize = size === 'md' ? 'w-2 h-2' : 'w-1.5 h-1.5' -function HealthBadge({ health }: { health?: BrokerHealthInfo }) { if (!health) return if (health.disabled) { return ( - - + + Disabled ) @@ -169,105 +191,93 @@ function HealthBadge({ health }: { health?: BrokerHealthInfo }) { switch (health.status) { case 'healthy': return ( - - + + Connected ) case 'degraded': return ( - - + + Unstable ) case 'offline': return ( - - + + {health.recovering ? 'Reconnecting...' : 'Offline'} ) } } -function AccountsTable({ accounts, platforms, healthMap, onSelect }: { - accounts: AccountConfig[] - platforms: PlatformConfig[] - healthMap: Record - onSelect: (id: string) => void +// ==================== Account Card ==================== + +function AccountCard({ account, platform, health, onClick }: { + account: AccountConfig + platform?: PlatformConfig + health?: BrokerHealthInfo + onClick: () => void }) { - const getPlatform = (platformId: string) => platforms.find((p) => p.id === platformId) + const isDisabled = health?.disabled + const badge = platform?.type === 'ccxt' + ? { text: 'CC', color: 'text-accent bg-accent/10' } + : { text: 'AL', color: 'text-green bg-green/10' } - const getConnectionLabel = (account: AccountConfig) => { - const p = getPlatform(account.platformId) - if (!p) return '—' - if (p.type === 'ccxt') { - return p.exchange - } - return p.paper ? 'paper' : 'live' - } + const subtitle = platform?.type === 'ccxt' + ? [platform.exchange, platform.demoTrading && 'Demo', platform.sandbox && 'Sandbox'].filter(Boolean).join(' · ') + : platform?.type === 'alpaca' + ? platform.paper ? 'Paper Trading' : 'Live Trading' + : '—' - if (accounts.length === 0) { - return ( -
-

No accounts configured.

-

Click "+ Add Account" to connect your first trading account.

+ return ( + + ) +} + +// ==================== Create Wizard (2-step) ==================== +function StepIndicator({ current, total }: { current: number; total: number }) { return ( -
- - - - - - - - - - - - {accounts.map((account) => { - const p = getPlatform(account.platformId) - const health = healthMap[account.id] - const isDisabled = health?.disabled - const badge = p?.type === 'ccxt' - ? { text: 'CC', color: 'text-accent bg-accent/10' } - : { text: 'AL', color: 'text-green bg-green/10' } - - return ( - onSelect(account.id)} - className={`cursor-pointer transition-colors hover:bg-bg-tertiary/30 ${isDisabled ? 'opacity-50' : ''}`} - > - - - - - - - ) - })} - -
AccountConnectionStatusGuards
- - {badge.text} - - {account.id}{getConnectionLabel(account)} - - - {account.guards.length > 0 ? account.guards.length : '—'} -
+
+ {Array.from({ length: total }, (_, i) => ( +
+ ))}
) } -// ==================== Create Wizard ==================== - function CreateWizard({ existingAccountIds, onSave, onClose }: { existingAccountIds: string[] onSave: (platform: PlatformConfig, account: AccountConfig) => Promise @@ -276,7 +286,7 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { const [step, setStep] = useState(1) const [type, setType] = useState<'ccxt' | 'alpaca' | null>(null) - // Step 2 fields + // Connection fields const [id, setId] = useState('') const [exchange, setExchange] = useState('binance') const [marketType, setMarketType] = useState<'swap' | 'spot'>('swap') @@ -284,7 +294,7 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { const [demoTrading, setDemoTrading] = useState(false) const [paper, setPaper] = useState(true) - // Step 3 fields + // Credential fields const [apiKey, setApiKey] = useState('') const [apiSecret, setApiSecret] = useState('') const [password, setPassword] = useState('') @@ -294,18 +304,14 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { const defaultId = type === 'ccxt' ? `${exchange}-main` : 'alpaca-paper' const finalId = id.trim() || defaultId - const handleSelectType = (t: string) => { - setType(t as 'ccxt' | 'alpaca') - setStep(2) - } - const handleNext = () => { + if (!type) return if (existingAccountIds.includes(finalId)) { setError(`Account "${finalId}" already exists`) return } setError('') - setStep(3) + setStep(2) } const handleCreate = async () => { @@ -332,67 +338,89 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { return ( {/* Header */} -
-

New Account

- Step {step}/3 +
+
+

New Account

+ +
+
{/* Body */} -
+
{step === 1 && ( -
-

Choose your platform

- -
- )} - - {step === 2 && type === 'ccxt' && ( -
-

Configure your connection

- - setId(e.target.value.trim())} placeholder={defaultId} /> - - - setExchange(e.target.value.trim())} placeholder="binance" /> - - - - -
- - +
+ {/* Platform selection */} +
+

Platform

+ setType(t as 'ccxt' | 'alpaca')} />
- {error &&

{error}

} -
- )} - {step === 2 && type === 'alpaca' && ( -
-

Configure your connection

- - setId(e.target.value.trim())} placeholder={defaultId} /> - - -

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

+ {/* Connection config — expands after platform selection */} + {type && ( +
+

Connection

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

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

+ + )} +
+ )} {error &&

{error}

}
)} - {step === 3 && ( + {step === 2 && (
-

API Credentials

+
+ + {type === 'ccxt' ? 'CC' : 'AL'} + + + {type === 'ccxt' ? `${exchange} · CCXT` : `Alpaca · ${paper ? 'Paper' : 'Live'}`} + +
+ +

API Credentials

+ setApiKey(e.target.value)} placeholder="Required" /> @@ -410,25 +438,18 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: {
{/* Footer */} -
+
+ {step === 1 && ( - - )} - {step > 1 && ( - - )} - {step === 2 && ( - )} - {step === 3 && ( - )}
@@ -438,9 +459,10 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { // ==================== Edit Dialog ==================== -function EditDialog({ account, platform, onSaveAccount, onSavePlatform, onDelete, onClose }: { +function EditDialog({ account, platform, health, onSaveAccount, onSavePlatform, onDelete, onClose }: { account: AccountConfig platform: PlatformConfig + health?: BrokerHealthInfo onSaveAccount: (a: AccountConfig) => Promise onSavePlatform: (p: PlatformConfig) => Promise onDelete: () => Promise @@ -451,6 +473,7 @@ function EditDialog({ account, platform, onSaveAccount, onSavePlatform, onDelete const [saving, setSaving] = useState(false) const [msg, setMsg] = useState('') const [guardsOpen, setGuardsOpen] = useState(false) + const [showKeys, setShowKeys] = useState(false) useEffect(() => { setAccountDraft(account) }, [account]) useEffect(() => { setPlatformDraft(platform) }, [platform]) @@ -488,11 +511,14 @@ function EditDialog({ account, platform, onSaveAccount, onSavePlatform, onDelete const guardTypes = platform.type === 'ccxt' ? CRYPTO_GUARD_TYPES : SECURITIES_GUARD_TYPES return ( - - {/* Header */} -
-

{account.id}

-
{/* Body */} -
+
{/* Connection */}
@@ -517,16 +543,26 @@ function EditDialog({ account, platform, onSaveAccount, onSavePlatform, onDelete
{/* Credentials */} -
+
+ Credentials + +
+ }> - patchAccount('apiKey', e.target.value)} placeholder="Not configured" /> + patchAccount('apiKey', e.target.value)} placeholder="Not configured" /> - patchAccount('apiSecret', e.target.value)} placeholder="Not configured" /> + patchAccount('apiSecret', e.target.value)} placeholder="Not configured" /> {platform.type === 'ccxt' && ( - patchAccount('password', e.target.value)} placeholder="Required by some exchanges (e.g. OKX)" /> + patchAccount('password', e.target.value)} placeholder="Required by some exchanges (e.g. OKX)" /> )} @@ -558,27 +594,21 @@ function EditDialog({ account, platform, onSaveAccount, onSavePlatform, onDelete
)}
- - {/* Delete */} -
- -
- {/* Footer */} -
- {dirty && ( - - )} - {msg && {msg}} + {/* Footer — Save/Reconnect left, Delete right */} +
+
+ {dirty && ( + + )} + + {msg && {msg}} +
- +
) @@ -632,10 +662,10 @@ function DeleteButton({ label, onConfirm }: { label: string; onConfirm: () => vo if (confirming) { return (
- -
@@ -643,7 +673,7 @@ function DeleteButton({ label, onConfirm }: { label: string; onConfirm: () => vo } return ( - ) From 2d37d0b5fe9208e449ded646d8cf8e9c64a4846e Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 21 Mar 2026 22:39:41 +0800 Subject: [PATCH 6/7] fix: verify broker connection before confirming account creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UTA: expose waitForConnect() — resolves after broker.init() + getAccount() - reconnectAccount: await waitForConnect() before returning success - Frontend: await reconnect result in wizard, show error on failure Co-Authored-By: Claude Opus 4.6 (1M context) --- src/domain/trading/UnifiedTradingAccount.ts | 16 ++++++++++++++-- src/main.ts | 9 ++++++--- ui/src/pages/TradingPage.tsx | 7 ++++++- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index 7a764c46..e3d8d689 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -124,6 +124,7 @@ export class UnifiedTradingAccount { private _recoveryTimer?: ReturnType private _recovering = false private _disabled = false + private _connectPromise: Promise constructor(broker: IBroker, options: UnifiedTradingAccountOptions = {}) { this.broker = broker @@ -184,7 +185,17 @@ export class UnifiedTradingAccount { // Kick off broker connection asynchronously — UTA is usable immediately, // broker queries will fail (tracked by health) until init succeeds. - this._connect() + const p = this._connect() + // Silence unhandled rejection in fire-and-forget path. + // waitForConnect() returns the raw promise so callers can observe failures. + p.catch(() => {}) + this._connectPromise = p + + } + + /** Await initial broker connection. Resolves on success, rejects on failure. */ + waitForConnect(): Promise { + return this._connectPromise } // ==================== Health ==================== @@ -227,13 +238,14 @@ export class UnifiedTradingAccount { this._disabled = true this._lastError = msg this._emitHealthChange() - return + throw err } console.warn(`UTA[${this.id}]: initial connect failed: ${msg}`) this._consecutiveFailures = UnifiedTradingAccount.OFFLINE_THRESHOLD this._lastError = msg this._lastFailureAt = new Date() this._startRecovery() + throw err } } diff --git a/src/main.ts b/src/main.ts index 3c1d89c6..dec377a6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -350,11 +350,14 @@ async function main() { return { success: false, error: `Platform "${accCfg.platformId}" not found for account "${accountId}"` } } - const ok = await initAccount(accCfg, platform) - if (!ok) { + const uta = await initAccount(accCfg, platform) + if (!uta) { return { success: false, error: `Account "${accountId}" init failed` } } + // 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 (platform.providerType !== 'alpaca') { toolCenter.register( @@ -363,7 +366,7 @@ async function main() { ) } - const label = accountManager.get(accountId)?.label ?? accountId + const label = uta.label ?? accountId console.log(`reconnect: ${label} online`) return { success: true, message: `${label} reconnected` } } catch (err) { diff --git a/ui/src/pages/TradingPage.tsx b/ui/src/pages/TradingPage.tsx index bd9fbd65..b9b9183d 100644 --- a/ui/src/pages/TradingPage.tsx +++ b/ui/src/pages/TradingPage.tsx @@ -90,7 +90,12 @@ export function TradingPage() { onSave={async (platform, account) => { await tc.savePlatform(platform) await tc.saveAccount(account) - if (account.apiKey) tc.reconnectAccount(account.id).catch(() => {}) + if (account.apiKey) { + const result = await tc.reconnectAccount(account.id) + if (!result.success) { + throw new Error(result.error || 'Connection failed') + } + } setDialog(null) }} onClose={() => setDialog(null)} From 61610a1e6af24ec19c4a88e9897067937a5a1f2e Mon Sep 17 00:00:00 2001 From: Ame Date: Sun, 22 Mar 2026 09:08:24 +0800 Subject: [PATCH 7/7] feat: test connection before persisting account, exchange dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New POST /test-connection API: validates broker.init() + getAccount() with a temporary broker before writing anything to config - Wizard flow: test-connection → success → save config → create UTA (failure shows error in wizard, config untouched) - Exchange field changed from free text to dropdown (14 common exchanges) - Remove dead Market Type selector (not used by backend) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/connectors/web/routes/trading-config.ts | 35 +++++++++++++++++++ ui/src/api/trading.ts | 11 +++++- ui/src/api/types.ts | 6 ++++ ui/src/pages/TradingPage.tsx | 37 +++++++++++++++------ 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/connectors/web/routes/trading-config.ts b/src/connectors/web/routes/trading-config.ts index 195fc997..d50251b7 100644 --- a/src/connectors/web/routes/trading-config.ts +++ b/src/connectors/web/routes/trading-config.ts @@ -5,6 +5,7 @@ import { readAccountsConfig, writeAccountsConfig, platformConfigSchema, accountConfigSchema, } from '../../../core/config.js' +import { createPlatformFromConfig, createBrokerFromConfig } from '../../../domain/trading/brokers/factory.js' /** Mask a secret string: show last 4 chars, prefix with "****" */ function mask(value: string | undefined): string | undefined { @@ -153,5 +154,39 @@ export function createTradingConfigRoutes(ctx: EngineContext) { } }) + // ==================== Test Connection ==================== + + app.post('/test-connection', async (c) => { + let broker: { init: () => Promise; getAccount: () => Promise; close: () => Promise } | 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: [], + }) + + await broker.init() + const account = await broker.getAccount() + return c.json({ success: true, account }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return c.json({ success: false, error: msg }, 400) + } finally { + try { await broker?.close() } catch { /* best effort */ } + } + }) + return app } diff --git a/ui/src/api/trading.ts b/ui/src/api/trading.ts index a64a637b..eec3c8c6 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, PlatformConfig, AccountConfig, WalletStatus, WalletPushResult, WalletRejectResult } from './types' +import type { TradingAccount, AccountSummary, AccountInfo, Position, WalletCommitLog, ReconnectResult, PlatformConfig, AccountConfig, WalletStatus, WalletPushResult, WalletRejectResult, TestConnectionResult } from './types' // ==================== Unified Trading API ==================== @@ -126,4 +126,13 @@ export const tradingApi = { throw new Error(body.error || `Failed to delete account (${res.status})`) } }, + + async testConnection(platform: PlatformConfig, credentials: { apiKey: string; apiSecret: string; password?: string }): Promise { + const res = await fetch('/api/trading/config/test-connection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ platform, credentials }), + }) + return res.json() + }, } diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 71fd6eb8..e1c01e6d 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -302,3 +302,9 @@ export interface GuardEntry { type: string options: Record } + +export interface TestConnectionResult { + success: boolean + error?: string + account?: unknown +} diff --git a/ui/src/pages/TradingPage.tsx b/ui/src/pages/TradingPage.tsx index b9b9183d..c031c3f8 100644 --- a/ui/src/pages/TradingPage.tsx +++ b/ui/src/pages/TradingPage.tsx @@ -7,8 +7,16 @@ 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 { PlatformConfig, CcxtPlatformConfig, AlpacaPlatformConfig, AccountConfig, BrokerHealthInfo } from '../api/types' +// ==================== Constants ==================== + +const CCXT_EXCHANGES = [ + 'binance', 'bybit', 'okx', 'bitget', 'gate', 'kucoin', 'coinbase', + 'kraken', 'htx', 'mexc', 'bingx', 'phemex', 'woo', 'hyperliquid', +] as const + // ==================== Dialog state ==================== type DialogState = @@ -294,7 +302,6 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { // Connection fields const [id, setId] = useState('') const [exchange, setExchange] = useState('binance') - const [marketType, setMarketType] = useState<'swap' | 'spot'>('swap') const [sandbox, setSandbox] = useState(false) const [demoTrading, setDemoTrading] = useState(false) const [paper, setPaper] = useState(true) @@ -326,10 +333,22 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { const platform: PlatformConfig = type === 'ccxt' ? { id: platformId, type: 'ccxt', exchange, sandbox, demoTrading } : { id: platformId, type: 'alpaca', paper } + + // Step 1: Test connection before saving anything + const testResult = await api.trading.testConnection(platform, { + apiKey, apiSecret, + ...(password && type === 'ccxt' && { password }), + }) + if (!testResult.success) { + setError(testResult.error || 'Connection failed — check your credentials') + setSaving(false) + return + } + + // Step 2: Connection verified — now persist config and create UTA const account: AccountConfig = { id: finalId, platformId, - ...(apiKey && { apiKey }), - ...(apiSecret && { apiSecret }), + apiKey, apiSecret, ...(password && type === 'ccxt' && { password }), guards: [], } @@ -377,12 +396,10 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { {type === 'ccxt' && ( <> - setExchange(e.target.value.trim())} placeholder="binance" /> - - - setExchange(e.target.value)}> + {CCXT_EXCHANGES.map((ex) => ( + + ))}
@@ -454,7 +471,7 @@ function CreateWizard({ existingAccountIds, onSave, onClose }: { )} {step === 2 && ( )}