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
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
57 changes: 57 additions & 0 deletions src/connectors/web/routes/trading-config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { Hono } from 'hono'
import ccxt from 'ccxt'
import type { EngineContext } from '../../../core/types.js'
import {
readAccountsConfig, writeAccountsConfig,
accountConfigSchema,
} 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<string, { label: string; type: BrokerConfigField['type']; sensitive: boolean; placeholder?: string }> = {
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 ====================

Expand Down Expand Up @@ -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,
Expand All @@ -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<string, new (opts?: Record<string, unknown>) => { requiredCredentials?: Record<string, boolean> }>
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) => {
Expand Down
9 changes: 9 additions & 0 deletions src/connectors/web/routes/trading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] })
Expand Down
10 changes: 5 additions & 5 deletions src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
Expand All @@ -92,7 +92,7 @@ function setInitialized(acc: CcxtBroker, markets: Record<string, any>) {

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',
)
})
Expand Down Expand Up @@ -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/)
})
})

Expand Down
62 changes: 43 additions & 19 deletions src/domain/trading/brokers/ccxt/CcxtBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -69,21 +69,27 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
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<string, unknown> }): CcxtBroker {
Expand All @@ -95,9 +101,17 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
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,
})
}

Expand Down Expand Up @@ -133,12 +147,14 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
}
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<string, unknown>
const credentials: Record<string, unknown> = { 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)
Expand All @@ -164,10 +180,18 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
// ---- Lifecycle ----

async init(): Promise<void> {
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<string, unknown>)[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)})`,
)
}

Expand Down
22 changes: 19 additions & 3 deletions src/domain/trading/brokers/ccxt/ccxt-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
// 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"
Expand Down
21 changes: 21 additions & 0 deletions src/domain/trading/brokers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ====================
Expand All @@ -64,6 +66,13 @@ export const BROKER_REGISTRY: Record<string, BrokerRegistryEntry> = {
{ 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,
Expand All @@ -77,6 +86,9 @@ export const BROKER_REGISTRY: Record<string, BrokerRegistryEntry> = {
{ 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,
Expand All @@ -91,5 +103,14 @@ export const BROKER_REGISTRY: Record<string, BrokerRegistryEntry> = {
{ 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.`,
},
}
5 changes: 5 additions & 0 deletions src/domain/trading/snapshot/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface SnapshotService {
takeSnapshot(accountId: string, trigger: SnapshotTrigger): Promise<UTASnapshot | null>
takeAllSnapshots(trigger: SnapshotTrigger): Promise<void>
getRecent(accountId: string, limit?: number): Promise<UTASnapshot[]>
deleteSnapshot(accountId: string, timestamp: string): Promise<boolean>
}

export function createSnapshotService(deps: {
Expand Down Expand Up @@ -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)
},
}
}
1 change: 1 addition & 0 deletions src/domain/trading/snapshot/snapshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading