Skip to content

Commit 9a6d62c

Browse files
authored
Merge pull request #83 from TraderAlice/dev
refactor: broker self-registration + AccountManager lifecycle + account enable/disable
2 parents b9a2662 + 1dca81b commit 9a6d62c

File tree

20 files changed

+716
-545
lines changed

20 files changed

+716
-545
lines changed

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

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
accountConfigSchema,
66
} from '../../../core/config.js'
77
import { createBroker } from '../../../domain/trading/brokers/factory.js'
8+
import { BROKER_REGISTRY } from '../../../domain/trading/brokers/registry.js'
89

910
// ==================== Credential helpers ====================
1011

@@ -17,25 +18,29 @@ function mask(value: string): string {
1718
/** Field names that contain sensitive values. Convention-based, not hardcoded per broker. */
1819
const SENSITIVE = /key|secret|password|token/i
1920

20-
/** Mask all sensitive string fields in a config object. */
21+
/** Mask all sensitive string fields in a config object (recurses into nested objects). */
2122
function maskSecrets<T extends Record<string, unknown>>(obj: T): T {
2223
const result = { ...obj }
2324
for (const [k, v] of Object.entries(result)) {
2425
if (typeof v === 'string' && v.length > 0 && SENSITIVE.test(k)) {
2526
;(result as Record<string, unknown>)[k] = mask(v)
27+
} else if (v && typeof v === 'object' && !Array.isArray(v)) {
28+
;(result as Record<string, unknown>)[k] = maskSecrets(v as Record<string, unknown>)
2629
}
2730
}
2831
return result
2932
}
3033

31-
/** Restore masked values (****...) from existing config. */
34+
/** Restore masked values (****...) from existing config (recurses into nested objects). */
3235
function unmaskSecrets(
3336
body: Record<string, unknown>,
3437
existing: Record<string, unknown>,
3538
): void {
3639
for (const [k, v] of Object.entries(body)) {
3740
if (typeof v === 'string' && v.startsWith('****') && typeof existing[k] === 'string') {
3841
body[k] = existing[k]
42+
} else if (v && typeof v === 'object' && !Array.isArray(v) && existing[k] && typeof existing[k] === 'object') {
43+
unmaskSecrets(v as Record<string, unknown>, existing[k] as Record<string, unknown>)
3944
}
4045
}
4146
}
@@ -46,6 +51,22 @@ function unmaskSecrets(
4651
export function createTradingConfigRoutes(ctx: EngineContext) {
4752
const app = new Hono()
4853

54+
// ==================== Broker types (for dynamic UI rendering) ====================
55+
56+
app.get('/broker-types', (c) => {
57+
const brokerTypes = Object.entries(BROKER_REGISTRY).map(([type, entry]) => ({
58+
type,
59+
name: entry.name,
60+
description: entry.description,
61+
badge: entry.badge,
62+
badgeColor: entry.badgeColor,
63+
fields: entry.configFields,
64+
subtitleFields: entry.subtitleFields,
65+
guardCategory: entry.guardCategory,
66+
}))
67+
return c.json({ brokerTypes })
68+
})
69+
4970
// ==================== Read all ====================
5071

5172
app.get('/', async (c) => {
@@ -84,6 +105,18 @@ export function createTradingConfigRoutes(ctx: EngineContext) {
84105
accounts.push(validated)
85106
}
86107
await writeAccountsConfig(accounts)
108+
109+
// Handle enabled state changes at runtime
110+
const wasEnabled = existing?.enabled !== false
111+
const nowEnabled = validated.enabled !== false
112+
if (wasEnabled && !nowEnabled) {
113+
// Disabled — close running account
114+
await ctx.accountManager.removeAccount(id)
115+
} else if (!wasEnabled && nowEnabled) {
116+
// Enabled — start account
117+
ctx.accountManager.reconnectAccount(id).catch(() => {})
118+
}
119+
87120
return c.json(validated)
88121
} catch (err) {
89122
if (err instanceof Error && err.name === 'ZodError') {
@@ -102,12 +135,8 @@ export function createTradingConfigRoutes(ctx: EngineContext) {
102135
return c.json({ error: `Account "${id}" not found` }, 404)
103136
}
104137
await writeAccountsConfig(filtered)
105-
// Close running account instance if any
106-
if (ctx.accountManager.has(id)) {
107-
const uta = ctx.accountManager.get(id)
108-
ctx.accountManager.remove(id)
109-
try { await uta?.close() } catch { /* best effort */ }
110-
}
138+
// Close and deregister running account instance if any
139+
await ctx.accountManager.removeAccount(id)
111140
return c.json({ success: true })
112141
} catch (err) {
113142
return c.json({ error: String(err) }, 500)

src/connectors/web/routes/trading.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export function createTradingRoutes(ctx: EngineContext) {
6161
// Reconnect
6262
app.post('/accounts/:id/reconnect', async (c) => {
6363
const id = c.req.param('id')
64-
const result = await ctx.reconnectAccount(id)
64+
const result = await ctx.accountManager.reconnectAccount(id)
6565
return c.json(result, result.success ? 200 : 500)
6666
})
6767

src/core/config.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,14 +255,14 @@ describe('readAccountsConfig', () => {
255255

256256
describe('writeAccountsConfig', () => {
257257
it('writes validated accounts to accounts.json', async () => {
258-
await writeAccountsConfig([{ id: 'acc-1', type: 'alpaca', paper: true, guards: [] }])
258+
await writeAccountsConfig([{ id: 'acc-1', type: 'alpaca', guards: [], brokerConfig: { paper: true } }])
259259
const filePath = mockWriteFile.mock.calls[0][0] as string
260260
expect(filePath).toMatch(/accounts\.json$/)
261261
})
262262

263-
it('throws ZodError for invalid account type', async () => {
263+
it('throws ZodError for missing required fields', async () => {
264264
await expect(
265-
writeAccountsConfig([{ id: 'bad', type: 'unknown-type' } as any])
265+
writeAccountsConfig([{ type: 'alpaca' } as any])
266266
).rejects.toThrow()
267267
expect(mockWriteFile).not.toHaveBeenCalled()
268268
})

src/core/config.ts

Lines changed: 29 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -212,48 +212,15 @@ const guardConfigSchema = z.object({
212212
options: z.record(z.string(), z.unknown()).default({}),
213213
})
214214

215-
const ccxtAccountSchema = z.object({
215+
export const accountConfigSchema = z.object({
216216
id: z.string(),
217217
label: z.string().optional(),
218-
type: z.literal('ccxt'),
219-
exchange: z.string(),
220-
sandbox: z.boolean().default(false),
221-
demoTrading: z.boolean().default(false),
222-
options: z.record(z.string(), z.unknown()).optional(),
223-
apiKey: z.string().optional(),
224-
apiSecret: z.string().optional(),
225-
password: z.string().optional(),
226-
guards: z.array(guardConfigSchema).default([]),
227-
}).passthrough()
228-
229-
const alpacaAccountSchema = z.object({
230-
id: z.string(),
231-
label: z.string().optional(),
232-
type: z.literal('alpaca'),
233-
paper: z.boolean().default(true),
234-
apiKey: z.string().optional(),
235-
apiSecret: z.string().optional(),
236-
guards: z.array(guardConfigSchema).default([]),
237-
})
238-
239-
const ibkrAccountSchema = z.object({
240-
id: z.string(),
241-
label: z.string().optional(),
242-
type: z.literal('ibkr'),
243-
host: z.string().default('127.0.0.1'),
244-
port: z.number().int().default(7497),
245-
clientId: z.number().int().default(0),
246-
accountId: z.string().optional(),
247-
paper: z.boolean().default(true),
218+
type: z.string(),
219+
enabled: z.boolean().default(true),
248220
guards: z.array(guardConfigSchema).default([]),
221+
brokerConfig: z.record(z.string(), z.unknown()).default({}),
249222
})
250223

251-
export const accountConfigSchema = z.discriminatedUnion('type', [
252-
ccxtAccountSchema,
253-
alpacaAccountSchema,
254-
ibkrAccountSchema,
255-
])
256-
257224
export const accountsFileSchema = z.array(accountConfigSchema)
258225

259226
export type AccountConfig = z.infer<typeof accountConfigSchema>
@@ -374,6 +341,28 @@ export async function loadConfig(): Promise<Config> {
374341

375342
// ==================== Account Config Loader ====================
376343

344+
/** Common fields that live at the top level, not inside brokerConfig. */
345+
const BASE_FIELDS = new Set(['id', 'label', 'type', 'guards', 'brokerConfig'])
346+
347+
/**
348+
* Migrate flat account config (legacy) to nested brokerConfig format.
349+
* Any field not in BASE_FIELDS gets moved into brokerConfig.
350+
*/
351+
function migrateAccountConfig(raw: Record<string, unknown>): Record<string, unknown> {
352+
if (raw.brokerConfig) return raw // already migrated
353+
const migrated: Record<string, unknown> = {}
354+
const brokerConfig: Record<string, unknown> = {}
355+
for (const [k, v] of Object.entries(raw)) {
356+
if (BASE_FIELDS.has(k)) {
357+
migrated[k] = v
358+
} else {
359+
brokerConfig[k] = v
360+
}
361+
}
362+
migrated.brokerConfig = brokerConfig
363+
return migrated
364+
}
365+
377366
export async function readAccountsConfig(): Promise<AccountConfig[]> {
378367
const raw = await loadJsonFile('accounts.json')
379368
if (raw === undefined) {
@@ -382,7 +371,9 @@ export async function readAccountsConfig(): Promise<AccountConfig[]> {
382371
await writeFile(resolve(CONFIG_DIR, 'accounts.json'), '[]\n')
383372
return []
384373
}
385-
return accountsFileSchema.parse(raw)
374+
// Migrate legacy flat format → nested brokerConfig
375+
const migrated = (raw as unknown[]).map((item) => migrateAccountConfig(item as Record<string, unknown>))
376+
return accountsFileSchema.parse(migrated)
386377
}
387378

388379
export async function writeAccountsConfig(accounts: AccountConfig[]): Promise<void> {

src/core/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,6 @@ export interface EngineContext {
3434

3535
// Trading (unified account model)
3636
accountManager: AccountManager
37-
/** Reconnect a specific trading account by ID. */
38-
reconnectAccount: (accountId: string) => Promise<ReconnectResult>
3937
/** Reconnect connector plugins (Telegram, MCP-Ask, etc.). */
4038
reconnectConnectors: () => Promise<ReconnectResult>
4139
}

src/domain/trading/__test__/e2e/setup.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,23 @@ export interface TestAccount {
2424

2525
/** Unified paper/sandbox check — E2E only runs non-live accounts. */
2626
function isPaper(acct: AccountConfig): boolean {
27+
const bc = acct.brokerConfig
2728
switch (acct.type) {
28-
case 'alpaca': return acct.paper
29-
case 'ccxt': return acct.sandbox || acct.demoTrading
30-
case 'ibkr': return acct.paper
29+
case 'alpaca': return !!bc.paper
30+
case 'ccxt': return !!(bc.sandbox || bc.demoTrading)
31+
case 'ibkr': return !!bc.paper
32+
default: return false
3133
}
3234
}
3335

3436
/** Check whether API credentials are configured (not applicable for all broker types). */
3537
function hasCredentials(acct: AccountConfig): boolean {
38+
const bc = acct.brokerConfig
3639
switch (acct.type) {
3740
case 'alpaca':
38-
case 'ccxt': return !!acct.apiKey
41+
case 'ccxt': return !!bc.apiKey
3942
case 'ibkr': return true // no API key — auth via TWS/Gateway login
43+
default: return true
4044
}
4145
}
4246

@@ -71,11 +75,15 @@ async function initAll(): Promise<TestAccount[]> {
7175
if (!isPaper(acct)) continue
7276
if (!hasCredentials(acct)) continue
7377

78+
// Skip disabled accounts
79+
if (acct.enabled === false) continue
80+
7481
// IBKR: check TWS/Gateway reachability before attempting connect
7582
if (acct.type === 'ibkr') {
76-
const reachable = await isTcpReachable(acct.host ?? '127.0.0.1', acct.port ?? 7497)
83+
const bc = acct.brokerConfig
84+
const reachable = await isTcpReachable(String(bc.host ?? '127.0.0.1'), Number(bc.port ?? 7497))
7785
if (!reachable) {
78-
console.warn(`e2e setup: ${acct.id} — TWS not reachable at ${acct.host ?? '127.0.0.1'}:${acct.port ?? 7497}, skipping`)
86+
console.warn(`e2e setup: ${acct.id} — TWS not reachable at ${bc.host ?? '127.0.0.1'}:${bc.port ?? 7497}, skipping`)
7987
continue
8088
}
8189
}

0 commit comments

Comments
 (0)