diff --git a/packages/uta-protocol/src/brokers/preset-catalog.ts b/packages/uta-protocol/src/brokers/preset-catalog.ts index 56a997bc9..77a9b0896 100644 --- a/packages/uta-protocol/src/brokers/preset-catalog.ts +++ b/packages/uta-protocol/src/brokers/preset-catalog.ts @@ -107,6 +107,52 @@ function defaultIsPaper(data: Record): boolean { // ==================== CCXT-engine presets ==================== +export const BINANCE_PRESET: BrokerPresetDef = { + id: 'binance', + label: 'Binance', + description: 'Binance — spot, USDⓈ-M & COIN-M futures. The world\'s largest crypto exchange.', + category: 'crypto', + // Paper trading uses Binance Demo Trading (mode 'demo' → demoTrading → + // enableDemoTrading): the unified simulator at demo-*.binance.com covering + // spot + futures with virtual funds. We deliberately do NOT expose Binance's + // legacy Testnet (setSandboxMode): + // - Futures testnet (testnet.binancefuture.com) is DEAD — CCXT 4.5.38 hard + // -throws NotSupported on any private futures call there ("not supported + // for futures anymore … use demo trading instead"). + // - Spot testnet (testnet.binance.vision) is alive but spot-only, and the + // engine's getAccount/getPositions unconditionally call fetchPositions() + // (futures) — which would hit the dead futures testnet. Supporting a + // spot-only account means breaking the full-spectrum-load invariant; + // tracked as a separate engine change (Linear), not worth it for a + // dev-oriented sandbox inside a trading product. Demo covers the need. + hint: '**Demo Trading** is Binance\'s risk-free simulator — virtual funds, spot + futures in one place. It runs on isolated demo servers, so prices and fills are simulated and can differ from live. Generate a dedicated Demo API key at demo.binance.com → API Management; your live keys are rejected here (and demo keys are rejected on live).\n\n**Live** places real orders on real money — grant trade-only permissions and never enable withdrawals.', + defaultName: 'binance-main', + badge: 'BN', + badgeColor: 'text-yellow-400', + engine: 'ccxt', + guardCategory: 'crypto', + modes: [ + { id: 'live', label: 'Live' }, + { id: 'demo', label: 'Demo Trading' }, + ], + zodSchema: z.object({ + mode: z.enum(['live', 'demo']).default('live').describe('Mode'), + apiKey: z.string().min(1).describe('API Key'), + secret: z.string().min(1).describe('API Secret'), + }), + subtitleFields: [ + { field: 'mode', prefix: 'Binance · ' }, + ], + writeOnlyFields: ['apiKey', 'secret'], + fingerprintFields: ['mode', 'apiKey'], + toEngineConfig: (d) => ({ + exchange: 'binance', + demoTrading: d.mode === 'demo', + apiKey: d.apiKey, + secret: d.secret, + }), +} + export const OKX_PRESET: BrokerPresetDef = { id: 'okx', label: 'OKX', @@ -464,6 +510,7 @@ export const BROKER_PRESET_CATALOG: BrokerPresetDef[] = [ LONGBRIDGE_PRESET, HYPERLIQUID_PRESET, // ---- Crypto ---- + BINANCE_PRESET, OKX_PRESET, BYBIT_PRESET, BITGET_PRESET, diff --git a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.guard.spec.ts b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.guard.spec.ts new file mode 100644 index 000000000..36b838f45 --- /dev/null +++ b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.guard.spec.ts @@ -0,0 +1,46 @@ +/** + * Construct-time demo/sandbox guard tests for CcxtBroker. + * + * Unlike CcxtBroker.spec.ts (which mocks ccxt), this spec uses the REAL ccxt + * module — the guards turn on actual per-exchange CCXT behavior (whether an + * exchange defines urls.demo / urls.test and how it routes enableDemoTrading / + * setSandboxMode), so a mock would just test the mock. Every case is + * construct-time only: `new CcxtBroker(...)` runs the guards synchronously with + * no init() / network call, so these are offline and deterministic. They also + * lock in the regression a ccxt version bump could reintroduce (e.g. an + * exchange dropping urls.demo). + */ +import { describe, it, expect } from 'vitest' +import { CcxtBroker } from './CcxtBroker.js' + +describe('CcxtBroker construct-time demo/sandbox guards', () => { + it('throws a clear CONFIG error for an exchange whose demo has no endpoint (okx)', () => { + // okx demo == the sandbox x-simulated-trading header, NOT a demo domain. + // CCXT base enableDemoTrading sets urls.api = urls.demo (undefined for okx); + // without the guard this only crashes later with "reading 'rest'". + expect( + () => new CcxtBroker({ id: 't', exchange: 'okx', sandbox: false, demoTrading: true, apiKey: 'k', secret: 's', password: 'p' }), + ).toThrow(/no CCXT demo-trading endpoint/) + }) + + it('constructs cleanly for an exchange that supports demo trading (binance)', () => { + // binance overrides enableDemoTrading and has urls.demo → urls.api stays valid. + expect( + () => new CcxtBroker({ id: 't', exchange: 'binance', sandbox: false, demoTrading: true, apiKey: 'k', secret: 's' }), + ).not.toThrow() + }) + + it('throws CONFIG when demo + sandbox are combined (CCXT NotSupported)', () => { + // setSandboxMode first → isSandboxModeEnabled; enableDemoTrading then throws. + expect( + () => new CcxtBroker({ id: 't', exchange: 'binance', sandbox: true, demoTrading: true, apiKey: 'k', secret: 's' }), + ).toThrow(/cannot enable Demo Trading/) + }) + + it('throws a clear CONFIG error for sandbox on an exchange with no testnet URL', () => { + // kucoin has no urls.test → setSandboxMode throws NotSupported → wrapped CONFIG. + expect( + () => new CcxtBroker({ id: 't', exchange: 'kucoin', sandbox: true, demoTrading: false, apiKey: 'k', secret: 's', password: 'p' }), + ).toThrow(/cannot enable Sandbox/) + }) +}) diff --git a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts index e04c52e28..514c1ae89 100644 --- a/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -112,8 +112,10 @@ export class CcxtBroker implements IBroker { 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 base fields for the legacy dynamic-config form. There is NO dynamic + // exchange-enumeration route today — `options: []` ships empty and the wizard + // drives CCXT accounts through BROKER_PRESET_CATALOG (the CCXT Custom preset), + // not this field list. A populated exchange dropdown would be net-new work. static configFields: BrokerConfigField[] = [ { name: 'exchange', type: 'select', label: 'Exchange', required: true, options: [] }, { name: 'sandbox', type: 'boolean', label: 'Sandbox Mode', default: false }, @@ -189,11 +191,32 @@ export class CcxtBroker implements IBroker { this.exchange = new ExchangeClass(credentials) if (config.sandbox) { - this.exchange.setSandboxMode(true) + try { + this.exchange.setSandboxMode(true) + } catch (err) { + // CCXT throws NotSupported for exchanges with no testnet/sandbox URL. + // Make it a permanent, actionable CONFIG error — symmetric with the + // demoTrading branch below — instead of an unclassified UNKNOWN. + throw new BrokerError('CONFIG', `${this.exchangeName}: cannot enable Sandbox — ${err instanceof Error ? err.message : String(err)}`) + } } if (config.demoTrading) { - (this.exchange as unknown as { enableDemoTrading: (enable: boolean) => void }).enableDemoTrading(true) + const ex = this.exchange as unknown as { urls?: Record; enableDemoTrading: (enable: boolean) => void } + try { + ex.enableDemoTrading(true) + } catch (err) { + // CCXT throws e.g. NotSupported when demo + sandbox are combined. + throw new BrokerError('CONFIG', `${this.exchangeName}: cannot enable Demo Trading — ${err instanceof Error ? err.message : String(err)}`) + } + // CCXT's base enableDemoTrading swaps urls.api ← urls.demo. Exchanges with + // no demo endpoint (e.g. okx, whose demo IS sandbox's x-simulated-trading + // header — not a separate domain) end up with urls.api === undefined and + // only fail much later with a cryptic "Cannot read properties of undefined + // (reading 'rest')" when the first request builds its URL. Fail loudly now. + if (ex.urls?.['api'] === undefined) { + throw new BrokerError('CONFIG', `${this.exchangeName} has no CCXT demo-trading endpoint. Turn off Demo Trading; if this exchange has a testnet, enable Sandbox instead, or use the dedicated ${this.exchangeName} preset.`) + } } } diff --git a/services/uta/src/domain/trading/brokers/presets.spec.ts b/services/uta/src/domain/trading/brokers/presets.spec.ts index 774b6862b..864f6db53 100644 --- a/services/uta/src/domain/trading/brokers/presets.spec.ts +++ b/services/uta/src/domain/trading/brokers/presets.spec.ts @@ -17,6 +17,7 @@ import { getBrokerPreset, isPaperPreset, deriveUtaId, + BINANCE_PRESET, OKX_PRESET, BYBIT_PRESET, HYPERLIQUID_PRESET, @@ -34,6 +35,7 @@ import { BROKER_ENGINE_REGISTRY } from './registry.js' /** Minimal valid presetConfig for each preset id. Use to round-trip through schema + engine. */ const SAMPLE_CONFIGS: Record> = { + binance: { mode: 'live', apiKey: 'k', secret: 's' }, okx: { mode: 'live', apiKey: 'k', secret: 's', password: 'p' }, bybit: { mode: 'live', apiKey: 'k', secret: 's' }, hyperliquid: { mode: 'live', walletAddress: '0xabc', privateKey: 'pk' }, @@ -88,6 +90,19 @@ describe.each(BROKER_PRESET_CATALOG)('preset $id', (preset) => { // ==================== Mode → engine flag translation (the OKX-bug guard) ==================== describe('preset → engine config translation', () => { + it('Binance mode=demo sets demoTrading=true (unified demo-*.binance.com)', () => { + const cfg = BINANCE_PRESET.toEngineConfig({ mode: 'demo', apiKey: 'k', secret: 's' }) + expect(cfg.demoTrading).toBe(true) + }) + + it('Binance mode=live sets demoTrading=false, no sandbox (testnet not offered)', () => { + const cfg = BINANCE_PRESET.toEngineConfig({ mode: 'live', apiKey: 'k', secret: 's' }) + expect(cfg.demoTrading).toBe(false) + // Binance futures testnet is deprecated and spot-only testnet needs an + // engine change; the preset exposes only live + demo, never sandbox. + expect(cfg.sandbox).toBeUndefined() + }) + it('OKX mode=demo sets sandbox=true (avoids the demoTrading footgun)', () => { const cfg = OKX_PRESET.toEngineConfig({ mode: 'demo', apiKey: 'k', secret: 's', password: 'p' }) expect(cfg.sandbox).toBe(true) @@ -166,6 +181,12 @@ describe('isPaperPreset', () => { expect(isPaperPreset('bybit', { mode: 'live' })).toBe(false) }) + it('true for Binance testnet AND demo, false for live', () => { + expect(isPaperPreset('binance', { mode: 'testnet' })).toBe(true) + expect(isPaperPreset('binance', { mode: 'demo' })).toBe(true) + expect(isPaperPreset('binance', { mode: 'live' })).toBe(false) + }) + it('true for Alpaca paper, false for Alpaca live', () => { expect(isPaperPreset('alpaca', { mode: 'paper' })).toBe(true) expect(isPaperPreset('alpaca', { mode: 'live' })).toBe(false) diff --git a/ui/src/components/uta/SchemaFormFields.tsx b/ui/src/components/uta/SchemaFormFields.tsx index a662478bf..eb11f90b4 100644 --- a/ui/src/components/uta/SchemaFormFields.tsx +++ b/ui/src/components/uta/SchemaFormFields.tsx @@ -1,4 +1,5 @@ import { Field, inputClass } from '../form' +import { Toggle } from '../Toggle' import type { SchemaField } from '../../hooks/useSchemaForm' /** @@ -16,6 +17,16 @@ export function SchemaFormFields({ fields, formData, setField, showSecrets }: { {fields.map(f => { const value = formData[f.key] ?? f.defaultValue ?? '' switch (f.type) { + case 'boolean': + return ( + + ) case 'select': return ( diff --git a/ui/src/hooks/useSchemaForm.spec.ts b/ui/src/hooks/useSchemaForm.spec.ts new file mode 100644 index 000000000..b6ab56cd0 --- /dev/null +++ b/ui/src/hooks/useSchemaForm.spec.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useSchemaForm } from './useSchemaForm' +import type { JsonSchema } from '../api/types' + +// Mirrors the JSON Schema the backend emits for the CCXT Custom preset +// (z.boolean().default(false) → { type: 'boolean', default: false }, and +// the defaulted key lands in `required`). Regression guard for the bug +// where boolean fields were rendered as text inputs and submitted as the +// strings 'true'/'false', which the backend z.boolean() schema rejected +// with "expected boolean, received string". +const ccxtCustomSchema: JsonSchema = { + type: 'object', + properties: { + exchange: { type: 'string', title: 'Exchange' }, + sandbox: { type: 'boolean', default: false, title: 'Sandbox / Testnet' }, + demoTrading: { type: 'boolean', default: false, title: 'Demo Trading' }, + apiKey: { type: 'string', writeOnly: true, title: 'API Key' }, + }, + required: ['exchange', 'sandbox', 'demoTrading'], +} + +describe('useSchemaForm — boolean fields', () => { + it('parses boolean JSON-schema props as boolean fields', () => { + const { result } = renderHook(() => useSchemaForm(ccxtCustomSchema)) + const byKey = Object.fromEntries(result.current.fields.map(f => [f.key, f])) + expect(byKey['sandbox'].type).toBe('boolean') + expect(byKey['demoTrading'].type).toBe('boolean') + // sibling fields keep their own types + expect(byKey['exchange'].type).toBe('text') + expect(byKey['apiKey'].type).toBe('password') + }) + + it('submits real booleans, not the strings "true"/"false"', () => { + const { result } = renderHook(() => useSchemaForm(ccxtCustomSchema)) + + // Default state: unchecked → real `false`, not "false" + let data = result.current.getSubmitData() + expect(data.sandbox).toBe(false) + expect(data.demoTrading).toBe(false) + expect(typeof data.sandbox).toBe('boolean') + + // Check the box → real `true` + act(() => result.current.setField('sandbox', 'true')) + data = result.current.getSubmitData() + expect(data.sandbox).toBe(true) + expect(data.demoTrading).toBe(false) + }) + + it('seeds + submits a required boolean that has no .default() (defaults to false)', () => { + // z.boolean() with no .default() lands in required[] but emits no `default`. + // It must still seed form state and submit a real `false`, not go missing. + const schema: JsonSchema = { + type: 'object', + properties: { flag: { type: 'boolean', title: 'Flag' } }, + required: ['flag'], + } + const { result } = renderHook(() => useSchemaForm(schema)) + expect(result.current.validate()).toBeNull() // not falsely "Flag is required" + expect(result.current.getSubmitData().flag).toBe(false) + }) + + it('round-trips a stored boolean via String()-ified initialValues (edit dialog)', () => { + // EditUTADialog passes presetConfig through String(v); a stored `true` + // boolean arrives here as the string 'true'. + const { result } = renderHook(() => + useSchemaForm(ccxtCustomSchema, { exchange: 'binance', sandbox: 'true', demoTrading: 'false' }), + ) + const data = result.current.getSubmitData() + expect(data.sandbox).toBe(true) + expect(data.demoTrading).toBe(false) + expect(data.exchange).toBe('binance') + }) +}) diff --git a/ui/src/hooks/useSchemaForm.ts b/ui/src/hooks/useSchemaForm.ts index 2a8056ca9..85cee8113 100644 --- a/ui/src/hooks/useSchemaForm.ts +++ b/ui/src/hooks/useSchemaForm.ts @@ -12,7 +12,7 @@ import type { JsonSchema, JsonSchemaProperty } from '../api/types' export interface SchemaField { key: string - type: 'text' | 'password' | 'select' + type: 'text' | 'password' | 'select' | 'boolean' title: string description?: string required: boolean @@ -40,7 +40,7 @@ export function useSchemaForm( initialValues?: Record, ): UseSchemaFormResult { // Parse schema into const values and editable field descriptors - const { constValues, fieldDefs, defaults } = useMemo(() => { + const { constValues, fieldDefs, defaults, booleanKeys } = useMemo(() => { const consts: Record = {} const fields: SchemaField[] = [] const defs: Record = {} @@ -67,17 +67,26 @@ export function useSchemaForm( } else if (prop.enum) { const options = prop.enum.map(v => ({ value: v, label: v })) fields.push({ key, type: 'select', title, description: prop.description, required: isRequired, options }) + } else if (prop.type === 'boolean') { + // Stored in string form-state as 'true'/'false'; getSubmitData + // converts back to a real boolean. Rendered as a checkbox. + fields.push({ key, type: 'boolean', title, description: prop.description, required: isRequired, defaultValue: String(prop.default ?? false) }) } else { fields.push({ key, type: 'text', title, description: prop.description, required: isRequired, defaultValue: prop.default !== undefined ? String(prop.default) : undefined }) } - // Collect defaults - if (prop.default !== undefined) { + // Collect defaults. Booleans always seed (a checkbox/toggle is never + // "unset"), so even a required boolean with no .default() lands in form + // state and submits a real `false` rather than going missing. + if (prop.type === 'boolean') { + defs[key] = String(prop.default ?? false) + } else if (prop.default !== undefined) { defs[key] = String(prop.default) } } - return { constValues: consts, fieldDefs: fields, defaults: defs } + const booleanKeys = new Set(fields.filter(f => f.type === 'boolean').map(f => f.key)) + return { constValues: consts, fieldDefs: fields, defaults: defs, booleanKeys } }, [schema]) // Form state — reset when schema changes (e.g. user picks a different preset) @@ -103,10 +112,15 @@ export function useSchemaForm( const result: Record = { ...constValues } for (const [key, value] of Object.entries(formData)) { if (key.endsWith('__custom')) continue + // Boolean fields carry 'true'/'false' strings in form state — emit a + // real boolean (always, incl. false) so the backend z.boolean() schema + // accepts it. NB: z.coerce.boolean() can't be used backend-side because + // Boolean('false') === true. + if (booleanKeys.has(key)) { result[key] = value === 'true'; continue } if (value !== '' && value !== undefined) result[key] = value } return result - }, [constValues, formData]) + }, [constValues, formData, booleanKeys]) const validate = useCallback((): string | null => { for (const field of fieldDefs) {