diff --git a/CLAUDE.md b/CLAUDE.md index 5b0adcb0..f2b7f289 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,17 @@ pnpm test # Vitest pnpm test:e2e # e2e test ``` +### Pre-commit Verification + +Always run these checks before committing: + +```bash +npx tsc --noEmit # Type check (catches errors pnpm build misses) +pnpm test # Unit tests +``` + +`pnpm build` uses tsup which is lenient — `tsc --noEmit` catches strict type errors that tsup ignores. + ## Project Structure ``` @@ -121,3 +132,4 @@ Centralized registry. `tool/` files register tools via `ToolCenter.register()`, - If squash is needed (messy history), do it — but never combine with `--delete-branch` - `archive/dev-pre-beta6` is a historical snapshot — do not modify or delete - **After merging a PR**, always `git pull origin master` to sync local master. Stale local master causes confusion about what's merged and what's not. +- **Before creating a PR**, always `git fetch origin master` to check what's already merged. Use `git log --oneline origin/master..HEAD` to verify only the intended commits are ahead. Stale local refs cause PRs with wrong diff. 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/connectors/web/routes/trading.ts b/src/connectors/web/routes/trading.ts index 9bb5ab66..dc92272b 100644 --- a/src/connectors/web/routes/trading.ts +++ b/src/connectors/web/routes/trading.ts @@ -204,6 +204,15 @@ export function createTradingRoutes(ctx: EngineContext) { } }) + app.delete('/accounts/:id/snapshots/:timestamp', async (c) => { + if (!ctx.snapshotService) return c.json({ error: 'Snapshot service not available' }, 503) + const id = c.req.param('id') + const timestamp = decodeURIComponent(c.req.param('timestamp')) + const deleted = await ctx.snapshotService.deleteSnapshot(id, timestamp) + if (!deleted) return c.json({ error: 'Snapshot not found' }, 404) + return c.json({ success: true }) + }) + // Aggregated equity curve across all accounts app.get('/snapshots/equity-curve', async (c) => { if (!ctx.snapshotService) return c.json({ points: [] }) 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/src/domain/trading/snapshot/service.ts b/src/domain/trading/snapshot/service.ts index 0510ecbf..de75ef4a 100644 --- a/src/domain/trading/snapshot/service.ts +++ b/src/domain/trading/snapshot/service.ts @@ -21,6 +21,7 @@ export interface SnapshotService { takeSnapshot(accountId: string, trigger: SnapshotTrigger): Promise takeAllSnapshots(trigger: SnapshotTrigger): Promise getRecent(accountId: string, limit?: number): Promise + deleteSnapshot(accountId: string, timestamp: string): Promise } export function createSnapshotService(deps: { @@ -103,5 +104,9 @@ export function createSnapshotService(deps: { async getRecent(accountId, limit = 10) { return getStore(accountId).readRange({ limit }) }, + + async deleteSnapshot(accountId, timestamp) { + return getStore(accountId).deleteByTimestamp(timestamp) + }, } } diff --git a/src/domain/trading/snapshot/snapshot.spec.ts b/src/domain/trading/snapshot/snapshot.spec.ts index e0f37964..7de22427 100644 --- a/src/domain/trading/snapshot/snapshot.spec.ts +++ b/src/domain/trading/snapshot/snapshot.spec.ts @@ -463,6 +463,7 @@ describe('Snapshot Scheduler', () => { takeSnapshot: vi.fn(async () => null), takeAllSnapshots: vi.fn(async () => {}), getRecent: vi.fn(async () => []), + deleteSnapshot: vi.fn(async () => false), } scheduler = createSnapshotScheduler({ diff --git a/src/domain/trading/snapshot/store.ts b/src/domain/trading/snapshot/store.ts index 10927908..fcda404c 100644 --- a/src/domain/trading/snapshot/store.ts +++ b/src/domain/trading/snapshot/store.ts @@ -15,7 +15,7 @@ * appends from corrupting the index. */ -import { readFile, writeFile, appendFile, rename, mkdir } from 'node:fs/promises' +import { readFile, writeFile, appendFile, rename, mkdir, unlink } from 'node:fs/promises' import { resolve } from 'node:path' import type { UTASnapshot, SnapshotIndex } from './types.js' @@ -29,6 +29,7 @@ export interface SnapshotStoreOptions { export interface SnapshotStore { append(snapshot: UTASnapshot): Promise readRange(opts?: { startTime?: string; endTime?: string; limit?: number }): Promise + deleteByTimestamp(timestamp: string): Promise } export function createSnapshotStore(accountId: string, options?: SnapshotStoreOptions): SnapshotStore { @@ -83,6 +84,45 @@ export function createSnapshotStore(accountId: string, options?: SnapshotStoreOp await saveIndex(index) } + async function doDelete(timestamp: string): Promise { + const index = await readIndex() + for (let i = 0; i < index.chunks.length; i++) { + const chunk = index.chunks[i] + if (timestamp < chunk.startTime || timestamp > chunk.endTime) continue + + const filePath = resolve(dir, chunk.file) + const raw = await readFile(filePath, 'utf-8') + const lines = raw.trim().split('\n').filter(Boolean) + const kept = lines.filter(line => { + const snap = JSON.parse(line) as UTASnapshot + return snap.timestamp !== timestamp + }) + + if (kept.length === lines.length) continue // not found in this chunk + + if (kept.length === 0) { + // Chunk is empty — remove file and index entry + await unlink(filePath).catch(() => {}) + index.chunks.splice(i, 1) + } else { + // Rewrite chunk with remaining lines + const tmp = `${filePath}.${process.pid}.tmp` + await writeFile(tmp, kept.join('\n') + '\n', 'utf-8') + await rename(tmp, filePath) + // Update index metadata + const first = JSON.parse(kept[0]) as UTASnapshot + const last = JSON.parse(kept[kept.length - 1]) as UTASnapshot + chunk.count = kept.length + chunk.startTime = first.timestamp + chunk.endTime = last.timestamp + } + + await saveIndex(index) + return true + } + return false + } + return { append(snapshot) { const p = writeChain.then(() => doAppend(snapshot)) @@ -91,6 +131,12 @@ export function createSnapshotStore(accountId: string, options?: SnapshotStoreOp return p }, + deleteByTimestamp(timestamp) { + const p = writeChain.then(() => doDelete(timestamp)) + writeChain = p.then(() => {}).catch(() => {}) + return p + }, + async readRange(opts) { const index = await readIndex() const { startTime, endTime, limit } = opts ?? {} diff --git a/ui/src/api/trading.ts b/ui/src/api/trading.ts index 6fc14905..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[] }> { @@ -128,6 +136,11 @@ export const tradingApi = { return fetchJson(`/api/trading/accounts/${accountId}/snapshots?${params}`) }, + async deleteSnapshot(accountId: string, timestamp: string): Promise<{ success: boolean }> { + const res = await fetch(`/api/trading/accounts/${accountId}/snapshots/${encodeURIComponent(timestamp)}`, { method: 'DELETE' }) + return res.json() + }, + async equityCurve(opts?: { startTime?: string; endTime?: string; limit?: number }): Promise<{ points: EquityCurvePoint[] }> { const params = new URLSearchParams() if (opts?.limit) params.set('limit', String(opts.limit)) 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/DevPage.tsx b/ui/src/pages/DevPage.tsx index 45ef2c74..55a265f0 100644 --- a/ui/src/pages/DevPage.tsx +++ b/ui/src/pages/DevPage.tsx @@ -14,15 +14,17 @@ import { type ToolDetail, type ExecuteResult, } from '../api/tools' +import { api, type UTASnapshotSummary } from '../api' // ==================== Tab Types ==================== -type Tab = 'connectors' | 'tools' | 'sessions' +type Tab = 'connectors' | 'tools' | 'sessions' | 'snapshots' const TABS: { key: Tab; label: string }[] = [ { key: 'connectors', label: 'Connectors' }, { key: 'tools', label: 'Tools' }, { key: 'sessions', label: 'Sessions' }, + { key: 'snapshots', label: 'Snapshots' }, ] // ==================== DevPage ==================== @@ -61,6 +63,7 @@ export function DevPage() { {tab === 'connectors' && } {tab === 'tools' && } {tab === 'sessions' && } + {tab === 'snapshots' && } ) @@ -297,6 +300,217 @@ function SessionsSection() { ) } +// ==================== Snapshots Tab ==================== + +function SnapshotsTab() { + const toast = useToast() + const [accounts, setAccounts] = useState>([]) + const [selectedAccount, setSelectedAccount] = useState('') + const [snapshots, setSnapshots] = useState([]) + const [loading, setLoading] = useState(false) + const [expandedIdx, setExpandedIdx] = useState(null) + + // Load accounts list + useEffect(() => { + api.trading.listAccounts().then(r => { + const list = r.accounts.map(a => ({ id: a.id, label: a.label })) + setAccounts(list) + if (list.length > 0 && !selectedAccount) setSelectedAccount(list[0].id) + }).catch(() => {}) + }, []) + + // Load snapshots when account changes + const loadSnapshots = useCallback(async () => { + if (!selectedAccount) return + setLoading(true) + setExpandedIdx(null) + try { + const r = await api.trading.snapshots(selectedAccount, { limit: 200 }) + setSnapshots(r.snapshots) + } catch { + setSnapshots([]) + } + setLoading(false) + }, [selectedAccount]) + + useEffect(() => { loadSnapshots() }, [loadSnapshots]) + + const handleDelete = async (timestamp: string) => { + try { + await api.trading.deleteSnapshot(selectedAccount, timestamp) + toast.success('Snapshot deleted') + setExpandedIdx(null) + await loadSnapshots() + } catch { + toast.error('Failed to delete snapshot') + } + } + + return ( +
+
+ {/* Account selector */} +
+ + + + {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() { diff --git a/ui/src/pages/TradingPage.tsx b/ui/src/pages/TradingPage.tsx index 99dc46d1..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(() => { @@ -371,10 +383,19 @@ function CreateWizard({ brokerTypes, existingAccountIds, onSave, onClose }: { const defaults: Record = {} for (const f of bt.fields) { if (f.default !== undefined) defaults[f.name] = f.default + else if (f.type === 'select' && f.options?.length) defaults[f.name] = f.options[0].value } 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 @@ -386,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) { @@ -419,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 && ( - - )}
) @@ -526,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) => { @@ -552,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