Skip to content

Commit da4d5f2

Browse files
authored
Merge pull request #117 from TraderAlice/dev
feat: CCXT dynamic credentials + snapshot management + wizard refactor
2 parents f17659b + bfe2e89 commit da4d5f2

15 files changed

Lines changed: 571 additions & 128 deletions

File tree

CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ pnpm test # Vitest
1212
pnpm test:e2e # e2e test
1313
```
1414

15+
### Pre-commit Verification
16+
17+
Always run these checks before committing:
18+
19+
```bash
20+
npx tsc --noEmit # Type check (catches errors pnpm build misses)
21+
pnpm test # Unit tests
22+
```
23+
24+
`pnpm build` uses tsup which is lenient — `tsc --noEmit` catches strict type errors that tsup ignores.
25+
1526
## Project Structure
1627

1728
```
@@ -121,3 +132,4 @@ Centralized registry. `tool/` files register tools via `ToolCenter.register()`,
121132
- If squash is needed (messy history), do it — but never combine with `--delete-branch`
122133
- `archive/dev-pre-beta6` is a historical snapshot — do not modify or delete
123134
- **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.
135+
- **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.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "open-alice",
3-
"version": "0.9.0-beta.10",
3+
"version": "0.9.0-beta.11",
44
"description": "File-based trading agent engine",
55
"type": "module",
66
"scripts": {

src/connectors/web/routes/trading-config.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
11
import { Hono } from 'hono'
2+
import ccxt from 'ccxt'
23
import type { EngineContext } from '../../../core/types.js'
34
import {
45
readAccountsConfig, writeAccountsConfig,
56
accountConfigSchema,
67
} from '../../../core/config.js'
78
import { createBroker } from '../../../domain/trading/brokers/factory.js'
89
import { BROKER_REGISTRY } from '../../../domain/trading/brokers/registry.js'
10+
import type { BrokerConfigField } from '../../../domain/trading/brokers/types.js'
11+
12+
// ==================== CCXT credential field metadata ====================
13+
14+
/** Map of CCXT standard credential field name → UI display metadata. */
15+
const CCXT_CREDENTIAL_LABELS: Record<string, { label: string; type: BrokerConfigField['type']; sensitive: boolean; placeholder?: string }> = {
16+
apiKey: { label: 'API Key', type: 'password', sensitive: true },
17+
secret: { label: 'API Secret', type: 'password', sensitive: true },
18+
uid: { label: 'User ID', type: 'text', sensitive: false },
19+
accountId: { label: 'Account ID', type: 'text', sensitive: false },
20+
login: { label: 'Login', type: 'text', sensitive: false },
21+
password: { label: 'Passphrase', type: 'password', sensitive: true, placeholder: 'Required by some exchanges (e.g. OKX)' },
22+
twofa: { label: '2FA Secret', type: 'password', sensitive: true },
23+
privateKey: { label: 'Private Key', type: 'password', sensitive: true, placeholder: 'Wallet private key (for Hyperliquid, dYdX, etc.)' },
24+
walletAddress: { label: 'Wallet Address', type: 'text', sensitive: false, placeholder: '0x...' },
25+
token: { label: 'Token', type: 'password', sensitive: true },
26+
}
927

1028
// ==================== Credential helpers ====================
1129

@@ -58,6 +76,7 @@ export function createTradingConfigRoutes(ctx: EngineContext) {
5876
type,
5977
name: entry.name,
6078
description: entry.description,
79+
setupGuide: entry.setupGuide,
6180
badge: entry.badge,
6281
badgeColor: entry.badgeColor,
6382
fields: entry.configFields,
@@ -67,6 +86,44 @@ export function createTradingConfigRoutes(ctx: EngineContext) {
6786
return c.json({ brokerTypes })
6887
})
6988

89+
// ==================== CCXT dynamic exchange + credential metadata ====================
90+
91+
/** List all CCXT-supported exchanges (dynamically from the ccxt package). */
92+
app.get('/ccxt/exchanges', (c) => {
93+
const exchanges = (ccxt as unknown as { exchanges: string[] }).exchanges ?? []
94+
return c.json({ exchanges })
95+
})
96+
97+
/** Return the credential fields a given CCXT exchange requires (read from its requiredCredentials map). */
98+
app.get('/ccxt/exchanges/:name/credentials', (c) => {
99+
const name = c.req.param('name')
100+
const exchanges = ccxt as unknown as Record<string, new (opts?: Record<string, unknown>) => { requiredCredentials?: Record<string, boolean> }>
101+
const ExchangeClass = exchanges[name]
102+
if (!ExchangeClass) return c.json({ error: `Unknown exchange: ${name}` }, 404)
103+
104+
try {
105+
const inst = new ExchangeClass()
106+
const required = inst.requiredCredentials ?? {}
107+
const fields: BrokerConfigField[] = []
108+
for (const [key, needed] of Object.entries(required)) {
109+
if (!needed) continue
110+
const meta = CCXT_CREDENTIAL_LABELS[key]
111+
if (!meta) continue // skip unknown credential names (CCXT may add new ones)
112+
fields.push({
113+
name: key,
114+
type: meta.type,
115+
label: meta.label,
116+
required: true,
117+
sensitive: meta.sensitive,
118+
placeholder: meta.placeholder,
119+
})
120+
}
121+
return c.json({ fields })
122+
} catch (err) {
123+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500)
124+
}
125+
})
126+
70127
// ==================== Read all ====================
71128

72129
app.get('/', async (c) => {

src/connectors/web/routes/trading.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,15 @@ export function createTradingRoutes(ctx: EngineContext) {
204204
}
205205
})
206206

207+
app.delete('/accounts/:id/snapshots/:timestamp', async (c) => {
208+
if (!ctx.snapshotService) return c.json({ error: 'Snapshot service not available' }, 503)
209+
const id = c.req.param('id')
210+
const timestamp = decodeURIComponent(c.req.param('timestamp'))
211+
const deleted = await ctx.snapshotService.deleteSnapshot(id, timestamp)
212+
if (!deleted) return c.json({ error: 'Snapshot not found' }, 404)
213+
return c.json({ success: true })
214+
})
215+
207216
// Aggregated equity curve across all accounts
208217
app.get('/snapshots/equity-curve', async (c) => {
209218
if (!ctx.snapshotService) return c.json({ points: [] })

src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,11 @@ function makeSwapMarket(base: string, quote: string, symbol?: string): any {
7474
}
7575
}
7676

77-
function makeAccount(overrides?: Partial<{ exchange: string; apiKey: string; apiSecret: string }>) {
77+
function makeAccount(overrides?: Partial<{ exchange: string; apiKey: string; secret: string }>) {
7878
return new CcxtBroker({
7979
exchange: overrides?.exchange ?? 'bybit',
8080
apiKey: overrides?.apiKey ?? 'k',
81-
apiSecret: overrides?.apiSecret ?? 's',
81+
secret: overrides?.secret ?? 's',
8282
sandbox: false,
8383
})
8484
}
@@ -92,7 +92,7 @@ function setInitialized(acc: CcxtBroker, markets: Record<string, any>) {
9292

9393
describe('CcxtBroker — constructor', () => {
9494
it('throws for unknown exchange', () => {
95-
expect(() => new CcxtBroker({ exchange: 'unknownxyz', apiKey: 'k', apiSecret: 's', sandbox: false })).toThrow(
95+
expect(() => new CcxtBroker({ exchange: 'unknownxyz', apiKey: 'k', secret: 's', sandbox: false })).toThrow(
9696
'Unknown CCXT exchange',
9797
)
9898
})
@@ -853,9 +853,9 @@ describe('CcxtBroker — getAccount', () => {
853853
})
854854

855855
it('throws BrokerError when no API credentials', async () => {
856-
const acc = new CcxtBroker({ exchange: 'bybit', apiKey: '', apiSecret: '', sandbox: false })
856+
const acc = new CcxtBroker({ exchange: 'bybit', apiKey: '', secret: '', sandbox: false })
857857

858-
await expect(acc.init()).rejects.toThrow('No API credentials configured')
858+
await expect(acc.init()).rejects.toThrow(/requires credentials/)
859859
})
860860
})
861861

src/domain/trading/brokers/ccxt/CcxtBroker.ts

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
type TpSlParams,
2626
} from '../types.js'
2727
import '../../contract-ext.js'
28-
import type { CcxtBrokerConfig, CcxtMarket, FundingRate, OrderBook, OrderBookLevel } from './ccxt-types.js'
28+
import { CCXT_CREDENTIAL_FIELDS, type CcxtBrokerConfig, type CcxtMarket, type FundingRate, type OrderBook, type OrderBookLevel } from './ccxt-types.js'
2929
import { MAX_INIT_RETRIES, INIT_RETRY_BASE_MS } from './ccxt-types.js'
3030
import {
3131
ccxtTypeToSecType,
@@ -69,21 +69,27 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
6969
sandbox: z.boolean().default(false),
7070
demoTrading: z.boolean().default(false),
7171
options: z.record(z.string(), z.unknown()).optional(),
72+
// All 10 CCXT standard credential fields, all optional.
73+
// Each exchange requires its own subset (read via Exchange.requiredCredentials).
7274
apiKey: z.string().optional(),
73-
apiSecret: z.string().optional(),
75+
secret: z.string().optional(),
76+
apiSecret: z.string().optional(), // legacy alias for `secret`
77+
uid: z.string().optional(),
78+
accountId: z.string().optional(),
79+
login: z.string().optional(),
7480
password: z.string().optional(),
81+
twofa: z.string().optional(),
82+
privateKey: z.string().optional(),
83+
walletAddress: z.string().optional(),
84+
token: z.string().optional(),
7585
})
7686

87+
// Static base fields. Exchange dropdown options + per-exchange credential fields
88+
// are fetched dynamically by the frontend (see /api/trading/config/ccxt/* routes).
7789
static configFields: BrokerConfigField[] = [
78-
{ name: 'exchange', type: 'select', label: 'Exchange', required: true, options: [
79-
'binance', 'bybit', 'okx', 'bitget', 'gate', 'kucoin', 'coinbase',
80-
'kraken', 'htx', 'mexc', 'bingx', 'phemex', 'woo', 'hyperliquid',
81-
].map(e => ({ value: e, label: e.charAt(0).toUpperCase() + e.slice(1) })) },
90+
{ name: 'exchange', type: 'select', label: 'Exchange', required: true, options: [] },
8291
{ name: 'sandbox', type: 'boolean', label: 'Sandbox Mode', default: false },
8392
{ name: 'demoTrading', type: 'boolean', label: 'Demo Trading', default: false },
84-
{ name: 'apiKey', type: 'password', label: 'API Key', required: true, sensitive: true },
85-
{ name: 'apiSecret', type: 'password', label: 'API Secret', required: true, sensitive: true },
86-
{ name: 'password', type: 'password', label: 'Password', placeholder: 'Required by some exchanges (e.g. OKX)', sensitive: true },
8793
]
8894

8995
static fromConfig(config: { id: string; label?: string; brokerConfig: Record<string, unknown> }): CcxtBroker {
@@ -95,9 +101,17 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
95101
sandbox: bc.sandbox,
96102
demoTrading: bc.demoTrading,
97103
options: bc.options,
98-
apiKey: bc.apiKey ?? '',
99-
apiSecret: bc.apiSecret ?? '',
104+
apiKey: bc.apiKey,
105+
// Accept both `secret` (CCXT-native) and legacy `apiSecret`
106+
secret: bc.secret ?? bc.apiSecret,
107+
uid: bc.uid,
108+
accountId: bc.accountId,
109+
login: bc.login,
100110
password: bc.password,
111+
twofa: bc.twofa,
112+
privateKey: bc.privateKey,
113+
walletAddress: bc.walletAddress,
114+
token: bc.token,
101115
})
102116
}
103117

@@ -133,12 +147,14 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
133147
}
134148
const mergedOptions = { ...defaultOptions, ...config.options }
135149

136-
this.exchange = new ExchangeClass({
137-
apiKey: config.apiKey,
138-
secret: config.apiSecret,
139-
password: config.password,
140-
options: mergedOptions,
141-
})
150+
// Pass through all CCXT standard credential fields. CCXT ignores undefined.
151+
const cfgRecord = config as unknown as Record<string, unknown>
152+
const credentials: Record<string, unknown> = { options: mergedOptions }
153+
for (const field of CCXT_CREDENTIAL_FIELDS) {
154+
const v = cfgRecord[field]
155+
if (v !== undefined) credentials[field] = v
156+
}
157+
this.exchange = new ExchangeClass(credentials)
142158

143159
if (config.sandbox) {
144160
this.exchange.setSandboxMode(true)
@@ -164,10 +180,18 @@ export class CcxtBroker implements IBroker<CcxtBrokerMeta> {
164180
// ---- Lifecycle ----
165181

166182
async init(): Promise<void> {
167-
if (!this.exchange.apiKey || !this.exchange.secret) {
183+
// Validate credentials per the exchange's own requiredCredentials map.
184+
// Hyperliquid needs walletAddress + privateKey; OKX needs apiKey + secret + password; etc.
185+
try {
186+
this.exchange.checkRequiredCredentials()
187+
} catch (err) {
188+
const required = Object.entries(this.exchange.requiredCredentials ?? {})
189+
.filter(([, needed]) => needed)
190+
.map(([k]) => k)
191+
const missing = required.filter(k => !(this.exchange as unknown as Record<string, unknown>)[k])
168192
throw new BrokerError(
169193
'CONFIG',
170-
`No API credentials configured. Set apiKey and apiSecret in accounts.json to enable this account.`,
194+
`${this.exchangeName} requires credentials: ${required.join(', ')}. Missing: ${missing.join(', ') || 'unknown'}. (${err instanceof Error ? err.message : String(err)})`,
171195
)
172196
}
173197

src/domain/trading/brokers/ccxt/ccxt-types.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,30 @@ export interface CcxtBrokerConfig {
22
id?: string
33
label?: string
44
exchange: string
5-
apiKey: string
6-
apiSecret: string
7-
password?: string
85
sandbox: boolean
96
demoTrading?: boolean
107
options?: Record<string, unknown>
8+
// CCXT standard credential fields (all optional — each exchange requires a different subset)
9+
apiKey?: string
10+
secret?: string
11+
uid?: string
12+
accountId?: string
13+
login?: string
14+
password?: string
15+
twofa?: string
16+
privateKey?: string
17+
walletAddress?: string
18+
token?: string
1119
}
1220

21+
/** CCXT standard credential field names (matches base Exchange.requiredCredentials map). */
22+
export const CCXT_CREDENTIAL_FIELDS = [
23+
'apiKey', 'secret', 'uid', 'accountId', 'login',
24+
'password', 'twofa', 'privateKey', 'walletAddress', 'token',
25+
] as const
26+
27+
export type CcxtCredentialField = typeof CCXT_CREDENTIAL_FIELDS[number]
28+
1329
export interface CcxtMarket {
1430
id: string // exchange-native symbol, e.g. "BTCUSDT"
1531
symbol: string // CCXT unified format, e.g. "BTC/USDT:USDT"

src/domain/trading/brokers/registry.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface BrokerRegistryEntry {
4545
subtitleFields: SubtitleField[]
4646
/** Guard category — determines which guard types are available */
4747
guardCategory: 'crypto' | 'securities'
48+
/** Multi-line setup guide shown in the New Account wizard. Paragraphs separated by `\n\n`. */
49+
setupGuide?: string
4850
}
4951

5052
// ==================== Registry ====================
@@ -64,6 +66,13 @@ export const BROKER_REGISTRY: Record<string, BrokerRegistryEntry> = {
6466
{ field: 'sandbox', label: 'Sandbox' },
6567
],
6668
guardCategory: 'crypto',
69+
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.
70+
71+
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.
72+
73+
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.
74+
75+
Make sure to grant only the permissions you need (read + trade), and never enable withdrawal permissions on automated trading keys.`,
6776
},
6877
alpaca: {
6978
configSchema: AlpacaBroker.configSchema,
@@ -77,6 +86,9 @@ export const BROKER_REGISTRY: Record<string, BrokerRegistryEntry> = {
7786
{ field: 'paper', label: 'Paper Trading', falseLabel: 'Live Trading' },
7887
],
7988
guardCategory: 'securities',
89+
setupGuide: `Alpaca is a commission-free US equities broker with a clean REST API. It supports paper trading (free, simulated) and live trading.
90+
91+
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).`,
8092
},
8193
ibkr: {
8294
configSchema: IbkrBroker.configSchema,
@@ -91,5 +103,14 @@ export const BROKER_REGISTRY: Record<string, BrokerRegistryEntry> = {
91103
{ field: 'port' },
92104
],
93105
guardCategory: 'securities',
106+
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.
107+
108+
Before connecting:
109+
1. Open TWS / IB Gateway and log in to your paper or live account
110+
2. Enable API access: File → Global Configuration → API → Settings → "Enable ActiveX and Socket Clients"
111+
3. Note the socket port (paper: 7497, live: 7496)
112+
4. Add 127.0.0.1 to "Trusted IPs" if running locally
113+
114+
Paper trading requires a separate paper account login in TWS.`,
94115
},
95116
}

src/domain/trading/snapshot/service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface SnapshotService {
2121
takeSnapshot(accountId: string, trigger: SnapshotTrigger): Promise<UTASnapshot | null>
2222
takeAllSnapshots(trigger: SnapshotTrigger): Promise<void>
2323
getRecent(accountId: string, limit?: number): Promise<UTASnapshot[]>
24+
deleteSnapshot(accountId: string, timestamp: string): Promise<boolean>
2425
}
2526

2627
export function createSnapshotService(deps: {
@@ -103,5 +104,9 @@ export function createSnapshotService(deps: {
103104
async getRecent(accountId, limit = 10) {
104105
return getStore(accountId).readRange({ limit })
105106
},
107+
108+
async deleteSnapshot(accountId, timestamp) {
109+
return getStore(accountId).deleteByTimestamp(timestamp)
110+
},
106111
}
107112
}

src/domain/trading/snapshot/snapshot.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ describe('Snapshot Scheduler', () => {
463463
takeSnapshot: vi.fn(async () => null),
464464
takeAllSnapshots: vi.fn(async () => {}),
465465
getRecent: vi.fn(async () => []),
466+
deleteSnapshot: vi.fn(async () => false),
466467
}
467468

468469
scheduler = createSnapshotScheduler({

0 commit comments

Comments
 (0)