From 607fa4f28107ae46067767ed325da4588999efc0 Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 9 Apr 2026 18:23:32 +0800 Subject: [PATCH 1/3] fix: initialize select fields with first option when no default is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Controlled setSelectedAccount(e.target.value)} + className="text-[13px] px-2 py-1.5 rounded-md border border-border bg-bg text-text" + > + {accounts.map(a => ( + + ))} + + + {snapshots.length} snapshots + + + {/* Snapshots table */} + {loading ? ( +
+ ) : snapshots.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {snapshots.map((s, i) => ( + setExpandedIdx(expandedIdx === i ? null : i)} + onDelete={() => handleDelete(s.timestamp)} + /> + ))} + +
TimestampTriggerHealthPositionsEquity
+
+ )} + + + ) +} + +function SnapshotRow({ snapshot: s, expanded, onToggle, onDelete }: { + snapshot: UTASnapshotSummary + expanded: boolean + onToggle: () => void + onDelete: () => void +}) { + const [confirming, setConfirming] = useState(false) + const healthColor = s.health === 'healthy' ? 'bg-green' : s.health === 'degraded' ? 'bg-yellow-400' : 'bg-red' + + return ( + <> + + + {new Date(s.timestamp).toLocaleString()} + + + {s.trigger} + + +
+ + {s.positions.length} + + ${Number(s.account.netLiquidation).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + e.stopPropagation()}> + {confirming ? ( +
+ + +
+ ) : ( + + )} + + + {expanded && ( + + +
+ {/* Account metrics */} +
+ Cash: ${Number(s.account.totalCashValue).toLocaleString('en-US', { minimumFractionDigits: 2 })} + Unrealized PnL: = 0 ? 'text-green' : 'text-red'}>{Number(s.account.unrealizedPnL) >= 0 ? '+' : ''}${Number(s.account.unrealizedPnL).toLocaleString('en-US', { minimumFractionDigits: 2 })} + {s.account.baseCurrency && Base: {s.account.baseCurrency}} +
+ {/* Positions detail */} + {s.positions.length > 0 && ( + + + + + + + + + + + + + + {s.positions.map((p, j) => { + const sym = p.aliceId.split('|').pop() ?? p.aliceId + const pnl = Number(p.unrealizedPnL) + return ( + + + + + + + + + + ) + })} + +
SymbolCcyQtyAvg CostMkt PriceMkt ValuePnL
{sym}{p.currency}{p.quantity}{Number(p.avgCost).toFixed(2)}{Number(p.marketPrice).toFixed(2)}{Number(p.marketValue).toFixed(2)}= 0 ? 'text-green' : 'text-red'}`}> + {pnl >= 0 ? '+' : ''}{pnl.toFixed(2)} +
+ )} + {s.positions.length === 0 && ( +

No positions in this snapshot.

+ )} +
+ + + )} + + ) +} + // ==================== Tools Tab ==================== function ToolsTab() { From bfe2e89ad6a48b6b304837daec341451b79fce6b Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 10 Apr 2026 17:22:28 +0800 Subject: [PATCH 3/3] feat: dynamic CCXT credentials + single-page New Account wizard Fixes inability to configure wallet-based exchanges like Hyperliquid: CcxtBroker previously hardcoded apiKey + secret + password fields, but each CCXT exchange declares its own requiredCredentials map (10 standard fields including walletAddress, privateKey, uid, twofa, etc.). Backend - CcxtBroker.configSchema accepts all 10 CCXT credential fields, with legacy `apiSecret` aliased to `secret` for back-compat - Constructor passes all set fields through to ccxt[exchange]() generically - init() validates via the exchange's own checkRequiredCredentials() and reports which fields are missing - New endpoints: GET /ccxt/exchanges (list all CCXT-supported exchanges) and GET /ccxt/exchanges/:name/credentials (read requiredCredentials) - BrokerRegistryEntry gets a setupGuide field; ccxt/alpaca/ibkr each have a multi-paragraph guide explaining what they are, how to get credentials, and prerequisites Frontend - TradingPage CreateWizard refactored from two-step to single-page form (consistent with EditDialog and other config dialogs); deleted StepIndicator and the sensitive/non-sensitive field split that put Hyperliquid's walletAddress and privateKey on different pages - Selecting a platform shows the broker's setupGuide as a description block before the configuration fields - For CCXT: exchange dropdown is populated dynamically from /ccxt/exchanges, and credential fields are loaded based on the selected exchange via /ccxt/exchanges/:name/credentials - EditDialog also wired with dynamic CCXT credential fetching - Bump version to 0.9.0-beta.11 Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/connectors/web/routes/trading-config.ts | 57 +++++ .../trading/brokers/ccxt/CcxtBroker.spec.ts | 10 +- src/domain/trading/brokers/ccxt/CcxtBroker.ts | 62 +++-- src/domain/trading/brokers/ccxt/ccxt-types.ts | 22 +- src/domain/trading/brokers/registry.ts | 21 ++ ui/src/api/trading.ts | 10 +- ui/src/api/types.ts | 2 + ui/src/pages/TradingPage.tsx | 216 ++++++++++-------- 9 files changed, 276 insertions(+), 126 deletions(-) diff --git a/package.json b/package.json index 6dd4d62d..16fcc674 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-alice", - "version": "0.9.0-beta.10", + "version": "0.9.0-beta.11", "description": "File-based trading agent engine", "type": "module", "scripts": { diff --git a/src/connectors/web/routes/trading-config.ts b/src/connectors/web/routes/trading-config.ts index 485e39b3..70923450 100644 --- a/src/connectors/web/routes/trading-config.ts +++ b/src/connectors/web/routes/trading-config.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono' +import ccxt from 'ccxt' import type { EngineContext } from '../../../core/types.js' import { readAccountsConfig, writeAccountsConfig, @@ -6,6 +7,23 @@ import { } from '../../../core/config.js' import { createBroker } from '../../../domain/trading/brokers/factory.js' import { BROKER_REGISTRY } from '../../../domain/trading/brokers/registry.js' +import type { BrokerConfigField } from '../../../domain/trading/brokers/types.js' + +// ==================== CCXT credential field metadata ==================== + +/** Map of CCXT standard credential field name → UI display metadata. */ +const CCXT_CREDENTIAL_LABELS: Record = { + apiKey: { label: 'API Key', type: 'password', sensitive: true }, + secret: { label: 'API Secret', type: 'password', sensitive: true }, + uid: { label: 'User ID', type: 'text', sensitive: false }, + accountId: { label: 'Account ID', type: 'text', sensitive: false }, + login: { label: 'Login', type: 'text', sensitive: false }, + password: { label: 'Passphrase', type: 'password', sensitive: true, placeholder: 'Required by some exchanges (e.g. OKX)' }, + twofa: { label: '2FA Secret', type: 'password', sensitive: true }, + privateKey: { label: 'Private Key', type: 'password', sensitive: true, placeholder: 'Wallet private key (for Hyperliquid, dYdX, etc.)' }, + walletAddress: { label: 'Wallet Address', type: 'text', sensitive: false, placeholder: '0x...' }, + token: { label: 'Token', type: 'password', sensitive: true }, +} // ==================== Credential helpers ==================== @@ -58,6 +76,7 @@ export function createTradingConfigRoutes(ctx: EngineContext) { type, name: entry.name, description: entry.description, + setupGuide: entry.setupGuide, badge: entry.badge, badgeColor: entry.badgeColor, fields: entry.configFields, @@ -67,6 +86,44 @@ export function createTradingConfigRoutes(ctx: EngineContext) { return c.json({ brokerTypes }) }) + // ==================== CCXT dynamic exchange + credential metadata ==================== + + /** List all CCXT-supported exchanges (dynamically from the ccxt package). */ + app.get('/ccxt/exchanges', (c) => { + const exchanges = (ccxt as unknown as { exchanges: string[] }).exchanges ?? [] + return c.json({ exchanges }) + }) + + /** Return the credential fields a given CCXT exchange requires (read from its requiredCredentials map). */ + app.get('/ccxt/exchanges/:name/credentials', (c) => { + const name = c.req.param('name') + const exchanges = ccxt as unknown as Record) => { requiredCredentials?: Record }> + const ExchangeClass = exchanges[name] + if (!ExchangeClass) return c.json({ error: `Unknown exchange: ${name}` }, 404) + + try { + const inst = new ExchangeClass() + const required = inst.requiredCredentials ?? {} + const fields: BrokerConfigField[] = [] + for (const [key, needed] of Object.entries(required)) { + if (!needed) continue + const meta = CCXT_CREDENTIAL_LABELS[key] + if (!meta) continue // skip unknown credential names (CCXT may add new ones) + fields.push({ + name: key, + type: meta.type, + label: meta.label, + required: true, + sensitive: meta.sensitive, + placeholder: meta.placeholder, + }) + } + return c.json({ fields }) + } catch (err) { + return c.json({ error: err instanceof Error ? err.message : String(err) }, 500) + } + }) + // ==================== Read all ==================== app.get('/', async (c) => { diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts index 81e325ac..a73895c3 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts @@ -74,11 +74,11 @@ function makeSwapMarket(base: string, quote: string, symbol?: string): any { } } -function makeAccount(overrides?: Partial<{ exchange: string; apiKey: string; apiSecret: string }>) { +function makeAccount(overrides?: Partial<{ exchange: string; apiKey: string; secret: string }>) { return new CcxtBroker({ exchange: overrides?.exchange ?? 'bybit', apiKey: overrides?.apiKey ?? 'k', - apiSecret: overrides?.apiSecret ?? 's', + secret: overrides?.secret ?? 's', sandbox: false, }) } @@ -92,7 +92,7 @@ function setInitialized(acc: CcxtBroker, markets: Record) { describe('CcxtBroker — constructor', () => { it('throws for unknown exchange', () => { - expect(() => new CcxtBroker({ exchange: 'unknownxyz', apiKey: 'k', apiSecret: 's', sandbox: false })).toThrow( + expect(() => new CcxtBroker({ exchange: 'unknownxyz', apiKey: 'k', secret: 's', sandbox: false })).toThrow( 'Unknown CCXT exchange', ) }) @@ -853,9 +853,9 @@ describe('CcxtBroker — getAccount', () => { }) it('throws BrokerError when no API credentials', async () => { - const acc = new CcxtBroker({ exchange: 'bybit', apiKey: '', apiSecret: '', sandbox: false }) + const acc = new CcxtBroker({ exchange: 'bybit', apiKey: '', secret: '', sandbox: false }) - await expect(acc.init()).rejects.toThrow('No API credentials configured') + await expect(acc.init()).rejects.toThrow(/requires credentials/) }) }) diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index c5395b04..946c62e9 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -25,7 +25,7 @@ import { type TpSlParams, } from '../types.js' import '../../contract-ext.js' -import type { CcxtBrokerConfig, CcxtMarket, FundingRate, OrderBook, OrderBookLevel } from './ccxt-types.js' +import { CCXT_CREDENTIAL_FIELDS, type CcxtBrokerConfig, type CcxtMarket, type FundingRate, type OrderBook, type OrderBookLevel } from './ccxt-types.js' import { MAX_INIT_RETRIES, INIT_RETRY_BASE_MS } from './ccxt-types.js' import { ccxtTypeToSecType, @@ -69,21 +69,27 @@ export class CcxtBroker implements IBroker { sandbox: z.boolean().default(false), demoTrading: z.boolean().default(false), options: z.record(z.string(), z.unknown()).optional(), + // All 10 CCXT standard credential fields, all optional. + // Each exchange requires its own subset (read via Exchange.requiredCredentials). apiKey: z.string().optional(), - apiSecret: z.string().optional(), + secret: z.string().optional(), + apiSecret: z.string().optional(), // legacy alias for `secret` + uid: z.string().optional(), + accountId: z.string().optional(), + login: z.string().optional(), password: z.string().optional(), + twofa: z.string().optional(), + privateKey: z.string().optional(), + walletAddress: z.string().optional(), + token: z.string().optional(), }) + // Static base fields. Exchange dropdown options + per-exchange credential fields + // are fetched dynamically by the frontend (see /api/trading/config/ccxt/* routes). 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: 'exchange', type: 'select', label: 'Exchange', required: true, options: [] }, { 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 { @@ -95,9 +101,17 @@ export class CcxtBroker implements IBroker { sandbox: bc.sandbox, demoTrading: bc.demoTrading, options: bc.options, - apiKey: bc.apiKey ?? '', - apiSecret: bc.apiSecret ?? '', + apiKey: bc.apiKey, + // Accept both `secret` (CCXT-native) and legacy `apiSecret` + secret: bc.secret ?? bc.apiSecret, + uid: bc.uid, + accountId: bc.accountId, + login: bc.login, password: bc.password, + twofa: bc.twofa, + privateKey: bc.privateKey, + walletAddress: bc.walletAddress, + token: bc.token, }) } @@ -133,12 +147,14 @@ export class CcxtBroker implements IBroker { } const mergedOptions = { ...defaultOptions, ...config.options } - this.exchange = new ExchangeClass({ - apiKey: config.apiKey, - secret: config.apiSecret, - password: config.password, - options: mergedOptions, - }) + // Pass through all CCXT standard credential fields. CCXT ignores undefined. + const cfgRecord = config as unknown as Record + const credentials: Record = { options: mergedOptions } + for (const field of CCXT_CREDENTIAL_FIELDS) { + const v = cfgRecord[field] + if (v !== undefined) credentials[field] = v + } + this.exchange = new ExchangeClass(credentials) if (config.sandbox) { this.exchange.setSandboxMode(true) @@ -164,10 +180,18 @@ export class CcxtBroker implements IBroker { // ---- Lifecycle ---- async init(): Promise { - if (!this.exchange.apiKey || !this.exchange.secret) { + // Validate credentials per the exchange's own requiredCredentials map. + // Hyperliquid needs walletAddress + privateKey; OKX needs apiKey + secret + password; etc. + try { + this.exchange.checkRequiredCredentials() + } catch (err) { + const required = Object.entries(this.exchange.requiredCredentials ?? {}) + .filter(([, needed]) => needed) + .map(([k]) => k) + const missing = required.filter(k => !(this.exchange as unknown as Record)[k]) throw new BrokerError( 'CONFIG', - `No API credentials configured. Set apiKey and apiSecret in accounts.json to enable this account.`, + `${this.exchangeName} requires credentials: ${required.join(', ')}. Missing: ${missing.join(', ') || 'unknown'}. (${err instanceof Error ? err.message : String(err)})`, ) } diff --git a/src/domain/trading/brokers/ccxt/ccxt-types.ts b/src/domain/trading/brokers/ccxt/ccxt-types.ts index d5f4719d..07f900b6 100644 --- a/src/domain/trading/brokers/ccxt/ccxt-types.ts +++ b/src/domain/trading/brokers/ccxt/ccxt-types.ts @@ -2,14 +2,30 @@ export interface CcxtBrokerConfig { id?: string label?: string exchange: string - apiKey: string - apiSecret: string - password?: string sandbox: boolean demoTrading?: boolean options?: Record + // CCXT standard credential fields (all optional — each exchange requires a different subset) + apiKey?: string + secret?: string + uid?: string + accountId?: string + login?: string + password?: string + twofa?: string + privateKey?: string + walletAddress?: string + token?: string } +/** CCXT standard credential field names (matches base Exchange.requiredCredentials map). */ +export const CCXT_CREDENTIAL_FIELDS = [ + 'apiKey', 'secret', 'uid', 'accountId', 'login', + 'password', 'twofa', 'privateKey', 'walletAddress', 'token', +] as const + +export type CcxtCredentialField = typeof CCXT_CREDENTIAL_FIELDS[number] + export interface CcxtMarket { id: string // exchange-native symbol, e.g. "BTCUSDT" symbol: string // CCXT unified format, e.g. "BTC/USDT:USDT" diff --git a/src/domain/trading/brokers/registry.ts b/src/domain/trading/brokers/registry.ts index 178f03ab..b640ea2d 100644 --- a/src/domain/trading/brokers/registry.ts +++ b/src/domain/trading/brokers/registry.ts @@ -45,6 +45,8 @@ export interface BrokerRegistryEntry { subtitleFields: SubtitleField[] /** Guard category — determines which guard types are available */ guardCategory: 'crypto' | 'securities' + /** Multi-line setup guide shown in the New Account wizard. Paragraphs separated by `\n\n`. */ + setupGuide?: string } // ==================== Registry ==================== @@ -64,6 +66,13 @@ export const BROKER_REGISTRY: Record = { { field: 'sandbox', label: 'Sandbox' }, ], guardCategory: 'crypto', + setupGuide: `CCXT is a unified library that connects to 100+ cryptocurrency exchanges through a single API. After picking a specific exchange below, the form will auto-load the credential fields that exchange requires. + +Most exchanges (Binance, Bybit, OKX, etc.) use API key + secret — you can create them in your exchange account's API settings. OKX additionally requires a passphrase you set when creating the key. + +Wallet-based exchanges like Hyperliquid use a wallet address + private key instead. For Hyperliquid, you can generate a dedicated API wallet at app.hyperliquid.xyz/API to avoid exposing your main wallet's private key. + +Make sure to grant only the permissions you need (read + trade), and never enable withdrawal permissions on automated trading keys.`, }, alpaca: { configSchema: AlpacaBroker.configSchema, @@ -77,6 +86,9 @@ export const BROKER_REGISTRY: Record = { { field: 'paper', label: 'Paper Trading', falseLabel: 'Live Trading' }, ], guardCategory: 'securities', + setupGuide: `Alpaca is a commission-free US equities broker with a clean REST API. It supports paper trading (free, simulated) and live trading. + +Sign up at alpaca.markets, then create API keys from the dashboard. Toggle "Paper" on this form to use the paper trading endpoint with your paper keys, or off for live trading with your live keys (different key sets).`, }, ibkr: { configSchema: IbkrBroker.configSchema, @@ -91,5 +103,14 @@ export const BROKER_REGISTRY: Record = { { field: 'port' }, ], guardCategory: 'securities', + setupGuide: `Interactive Brokers requires a local TWS (Trader Workstation) or IB Gateway process running on your machine. OpenAlice connects to it over a TCP socket — no API key needed, authentication happens via TWS login. + +Before connecting: +1. Open TWS / IB Gateway and log in to your paper or live account +2. Enable API access: File → Global Configuration → API → Settings → "Enable ActiveX and Socket Clients" +3. Note the socket port (paper: 7497, live: 7496) +4. Add 127.0.0.1 to "Trusted IPs" if running locally + +Paper trading requires a separate paper account login in TWS.`, }, } diff --git a/ui/src/api/trading.ts b/ui/src/api/trading.ts index b2a30877..35076738 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, BrokerTypeInfo, UTASnapshotSummary, EquityCurvePoint } from './types' +import type { TradingAccount, AccountSummary, AccountInfo, Position, WalletCommitLog, ReconnectResult, AccountConfig, WalletStatus, WalletPushResult, WalletRejectResult, TestConnectionResult, BrokerTypeInfo, BrokerConfigField, UTASnapshotSummary, EquityCurvePoint } from './types' // ==================== Unified Trading API ==================== @@ -91,6 +91,14 @@ export const tradingApi = { return fetchJson('/api/trading/config/broker-types') }, + async getCcxtExchanges(): Promise<{ exchanges: string[] }> { + return fetchJson('/api/trading/config/ccxt/exchanges') + }, + + async getCcxtCredentialFields(exchange: string): Promise<{ fields: BrokerConfigField[] }> { + return fetchJson(`/api/trading/config/ccxt/exchanges/${encodeURIComponent(exchange)}/credentials`) + }, + // ==================== Trading Config CRUD ==================== async loadTradingConfig(): Promise<{ accounts: AccountConfig[] }> { diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index a61df9da..14b9ac08 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -332,6 +332,8 @@ export interface BrokerTypeInfo { type: string name: string description: string + /** Multi-line setup guide shown in the New Account wizard. Paragraphs separated by `\n\n`. */ + setupGuide?: string badge: string badgeColor: string fields: BrokerConfigField[] diff --git a/ui/src/pages/TradingPage.tsx b/ui/src/pages/TradingPage.tsx index 09150769..d324929b 100644 --- a/ui/src/pages/TradingPage.tsx +++ b/ui/src/pages/TradingPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } 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' @@ -329,41 +329,53 @@ function DynamicBrokerFields({ fields, values, showSecrets, onChange }: { // ==================== Create Wizard ==================== -function StepIndicator({ current, total }: { current: number; total: number }) { - return ( -
- {Array.from({ length: total }, (_, i) => ( -
- ))} -
- ) -} - 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) const [id, setId] = useState('') const [brokerConfig, setBrokerConfig] = useState>({}) const [saving, setSaving] = useState(false) const [error, setError] = useState('') + const [showSecrets, setShowSecrets] = useState(false) + // CCXT-only: dynamic exchange list and credential fields + const [ccxtExchanges, setCcxtExchanges] = useState([]) + const [ccxtCredFields, setCcxtCredFields] = useState([]) 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) ?? [] + // Merge dynamic CCXT data into broker fields + const mergedFields: BrokerConfigField[] = useMemo(() => { + if (!bt) return [] + if (type !== 'ccxt') return bt.fields + return bt.fields.map(f => { + if (f.name === 'exchange') { + return { ...f, options: ccxtExchanges.map(e => ({ value: e, label: e.charAt(0).toUpperCase() + e.slice(1) })) } + } + return f + }).concat(ccxtCredFields) + }, [bt, type, ccxtExchanges, ccxtCredFields]) + + const hasSensitive = mergedFields.some(f => f.sensitive) + + // Fetch CCXT exchange list when CCXT is selected + useEffect(() => { + if (type !== 'ccxt') return + api.trading.getCcxtExchanges().then(r => setCcxtExchanges(r.exchanges)).catch(() => setCcxtExchanges([])) + }, [type]) + + // Fetch CCXT credential fields when exchange changes + useEffect(() => { + if (type !== 'ccxt') { setCcxtCredFields([]); return } + const exchange = brokerConfig.exchange as string | undefined + if (!exchange) { setCcxtCredFields([]); return } + api.trading.getCcxtCredentialFields(exchange) + .then(r => setCcxtCredFields(r.fields)) + .catch(() => setCcxtCredFields([])) + }, [type, brokerConfig.exchange]) // Initialize defaults when type changes useEffect(() => { @@ -376,6 +388,14 @@ function CreateWizard({ brokerTypes, existingAccountIds, onSave, onClose }: { setBrokerConfig(defaults) }, [type]) + // For CCXT: pre-select first exchange once the list arrives + useEffect(() => { + if (type !== 'ccxt' || ccxtExchanges.length === 0) return + if (!brokerConfig.exchange) { + setBrokerConfig(prev => ({ ...prev, exchange: ccxtExchanges[0] })) + } + }, [type, ccxtExchanges]) + const defaultId = type ? `${type}-main` : '' const finalId = id.trim() || defaultId @@ -387,24 +407,15 @@ function CreateWizard({ brokerTypes, existingAccountIds, onSave, onClose }: { badgeColor: b.badgeColor, })) - const handleNext = () => { + const handleCreate = async () => { if (!type) return if (existingAccountIds.includes(finalId)) { setError(`Account "${finalId}" already exists`) return } - setError('') - if (hasSensitive) { - setStep(2) - } else { - handleCreate() - } - } - - const handleCreate = async () => { setSaving(true); setError('') try { - const account: AccountConfig = { id: finalId, type: type!, enabled: true, guards: [], brokerConfig } + const account: AccountConfig = { id: finalId, type, enabled: true, guards: [], brokerConfig } const testResult = await api.trading.testConnection(account) if (!testResult.success) { @@ -420,93 +431,74 @@ function CreateWizard({ brokerTypes, existingAccountIds, onSave, onClose }: { } } - const canCreate = hasSensitive - ? credentialFields.filter(f => f.required).every(f => String(brokerConfig[f.name] ?? '').trim()) - : true + const canCreate = !!type + && mergedFields.filter(f => f.required).every(f => String(brokerConfig[f.name] ?? '').trim()) return ( {/* Header */} -
-
-

New Account

- -
- +
+

New Account

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

Platform

- setType(t)} /> -
+
+
+

Platform

+ setType(t)} /> +
+ + {type && bt && ( + <> + {bt.setupGuide && ( +
+ {bt.setupGuide.trim().split('\n\n').map((para, i) => ( +

+ {para} +

+ ))} +
+ )} - {type && bt && (
-

Connection

+

Configuration

setId(e.target.value.trim())} placeholder={defaultId} /> setBrokerConfig(prev => ({ ...prev, [f]: v }))} /> + {hasSensitive && ( + + )}
- )} - {error &&

{error}

} -
- )} - - {step === 2 && bt && ( -
-
- - {bt.badge} - - {bt.name} -
+ + )} -

Credentials

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

{error}

} -
- )} + {error &&

{error}

} +
{/* Footer */}
- + - {step === 1 && !hasSensitive && type && ( - - )} - {step === 1 && (hasSensitive || !type) && ( - - )} - {step === 2 && ( - - )}
) @@ -527,9 +519,28 @@ function EditDialog({ account, brokerType, health, onSaveAccount, onDelete, onCl const [msg, setMsg] = useState('') const [guardsOpen, setGuardsOpen] = useState(false) const [showKeys, setShowKeys] = useState(false) + // CCXT-only: dynamic exchange list and credential fields + const [ccxtExchanges, setCcxtExchanges] = useState([]) + const [ccxtCredFields, setCcxtCredFields] = useState([]) useEffect(() => { setDraft(account) }, [account]) + // Fetch CCXT exchange list when editing a CCXT account + useEffect(() => { + if (account.type !== 'ccxt') return + api.trading.getCcxtExchanges().then(r => setCcxtExchanges(r.exchanges)).catch(() => setCcxtExchanges([])) + }, [account.type]) + + // Fetch CCXT credential fields whenever exchange changes + useEffect(() => { + if (account.type !== 'ccxt') { setCcxtCredFields([]); return } + const exchange = draft.brokerConfig.exchange as string | undefined + if (!exchange) { setCcxtCredFields([]); return } + api.trading.getCcxtCredentialFields(exchange) + .then(r => setCcxtCredFields(r.fields)) + .catch(() => setCcxtCredFields([])) + }, [account.type, draft.brokerConfig.exchange]) + const dirty = JSON.stringify(draft) !== JSON.stringify(account) const patchBrokerConfig = (field: string, value: unknown) => { @@ -553,7 +564,18 @@ function EditDialog({ account, brokerType, health, onSaveAccount, onDelete, onCl } } - const fields = brokerType?.fields ?? [] + // Merge dynamic CCXT data into broker fields + const fields: BrokerConfigField[] = useMemo(() => { + const base = brokerType?.fields ?? [] + if (account.type !== 'ccxt') return base + return base.map(f => { + if (f.name === 'exchange') { + return { ...f, options: ccxtExchanges.map(e => ({ value: e, label: e.charAt(0).toUpperCase() + e.slice(1) })) } + } + return f + }).concat(ccxtCredFields) + }, [brokerType, account.type, ccxtExchanges, ccxtCredFields]) + const hasSensitive = fields.some(f => f.sensitive) const guardTypes = (brokerType?.guardCategory === 'crypto') ? CRYPTO_GUARD_TYPES : SECURITIES_GUARD_TYPES