Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/uta-protocol/src/brokers/preset-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,52 @@ function defaultIsPaper(data: Record<string, unknown>): 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',
Expand Down Expand Up @@ -464,6 +510,7 @@ export const BROKER_PRESET_CATALOG: BrokerPresetDef[] = [
LONGBRIDGE_PRESET,
HYPERLIQUID_PRESET,
// ---- Crypto ----
BINANCE_PRESET,
OKX_PRESET,
BYBIT_PRESET,
BITGET_PRESET,
Expand Down
Original file line number Diff line number Diff line change
@@ -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/)
})
})
31 changes: 27 additions & 4 deletions services/uta/src/domain/trading/brokers/ccxt/CcxtBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
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 },
Expand Down Expand Up @@ -189,11 +191,32 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
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<string, unknown>; 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.`)
}
}
}

Expand Down
21 changes: 21 additions & 0 deletions services/uta/src/domain/trading/brokers/presets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getBrokerPreset,
isPaperPreset,
deriveUtaId,
BINANCE_PRESET,
OKX_PRESET,
BYBIT_PRESET,
HYPERLIQUID_PRESET,
Expand All @@ -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<string, Record<string, unknown>> = {
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' },
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions ui/src/components/uta/SchemaFormFields.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Field, inputClass } from '../form'
import { Toggle } from '../Toggle'
import type { SchemaField } from '../../hooks/useSchemaForm'

/**
Expand All @@ -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 (
<label key={f.key} className="flex items-start gap-2 cursor-pointer select-none">
<Toggle size="sm" checked={value === 'true'} onChange={(v) => setField(f.key, v ? 'true' : 'false')} />
<span>
<span className="text-[13px] text-text">{f.title}</span>
{f.description && <p className="text-[11px] text-text-muted/60 mt-0.5">{f.description}</p>}
</span>
</label>
)
case 'select':
return (
<Field key={f.key} label={f.title}>
Expand Down
74 changes: 74 additions & 0 deletions ui/src/hooks/useSchemaForm.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
26 changes: 20 additions & 6 deletions ui/src/hooks/useSchemaForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,7 +40,7 @@ export function useSchemaForm(
initialValues?: Record<string, string>,
): UseSchemaFormResult {
// Parse schema into const values and editable field descriptors
const { constValues, fieldDefs, defaults } = useMemo(() => {
const { constValues, fieldDefs, defaults, booleanKeys } = useMemo(() => {
const consts: Record<string, unknown> = {}
const fields: SchemaField[] = []
const defs: Record<string, string> = {}
Expand All @@ -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)
Expand All @@ -103,10 +112,15 @@ export function useSchemaForm(
const result: Record<string, unknown> = { ...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) {
Expand Down
Loading