diff --git a/src/connectors/telegram/telegram-plugin.ts b/src/connectors/telegram/telegram-plugin.ts index 2a6b1fcd7..1709da6b4 100644 --- a/src/connectors/telegram/telegram-plugin.ts +++ b/src/connectors/telegram/telegram-plugin.ts @@ -30,6 +30,7 @@ export class TelegramPlugin implements Plugin { private agentSdkConfig: AgentSdkConfig private bot: Bot | null = null private connectorCenter: ConnectorCenter | null = null + private ctx: EngineContext | null = null private merger: MediaGroupMerger | null = null private unregisterConnector?: () => void private unsubscribeNotifications?: () => void @@ -50,6 +51,7 @@ export class TelegramPlugin implements Plugin { } async start(engineCtx: EngineContext) { + this.ctx = engineCtx this.connectorCenter = engineCtx.connectorCenter this.webPort = engineCtx.config.connectors.web.port @@ -355,13 +357,7 @@ export class TelegramPlugin implements Plugin { const session = await this.getSession(userId) await this.sendReply(chatId, '> Compacting session...') - const result = await forceCompact( - session, - async (summarizePrompt) => { - const r = await askAgentSdk(summarizePrompt, { ...this.agentSdkConfig, maxTurns: 1 }) - return r.text - }, - ) + const result = await this.ctx!.agentCenter.forceCompact(session) if (!result) { await this.sendReply(chatId, 'Session is empty, nothing to compact.') diff --git a/src/core/agent-center.ts b/src/core/agent-center.ts index bb2a55980..6a5df9021 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -20,7 +20,7 @@ import { resolveProfile, resolveCredential } from './config.js' import { profileToCredential } from './credential-inference.js' import type { ISessionStore, ContentBlock } from './session.js' import type { CompactionConfig } from './compaction.js' -import { compactIfNeeded } from './compaction.js' +import { compactIfNeeded, forceCompact } from './compaction.js' import type { MediaAttachment } from './types.js' import { extractMediaFromToolResultContent } from './media.js' import { persistMedia } from './media-store.js' @@ -91,6 +91,15 @@ export class AgentCenter { return new StreamableResult(this._generate(prompt, session, opts)) } + /** Force a full compaction (summarization) of the session. */ + async forceCompact(session: ISessionStore, opts?: AskOptions): Promise<{ preTokens: number } | null> { + const { provider } = await this.router.resolve(opts?.profileSlug) + return forceCompact(session, async (summarizePrompt) => { + const result = await provider.ask(summarizePrompt) + return result.text + }) + } + // ==================== Pipeline ==================== private async *_generate( diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index 9043bbd51..6560dc3b2 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -331,19 +331,34 @@ export class UnifiedTradingAccount { // ==================== aliceId management ==================== + /** + * Resolve a Contract to its full broker-native form. + * If contract.aliceId is present but native fields (conId, localSymbol) are + * missing, it parses the nativeKey from aliceId and asks the broker to resolve it. + */ + resolveContract(contract: Contract): Contract { + if (contract.aliceId && !contract.conId && !contract.localSymbol) { + const parsed = UnifiedTradingAccount.parseAliceId(contract.aliceId) + if (parsed && parsed.utaId === this.id) { + const resolved = this.broker.resolveNativeKey(parsed.nativeKey) + // Copy resolved fields into original object to preserve reference + contract.conId = resolved.conId + contract.localSymbol = resolved.localSymbol + contract.symbol = contract.symbol || resolved.symbol + contract.secType = contract.secType || resolved.secType + contract.currency = contract.currency || resolved.currency + contract.exchange = contract.exchange || resolved.exchange + } + } + return contract + } + /** Construct aliceId: "{utaId}|{nativeKey}" using broker's native identity. */ private stampAliceId(contract: Contract): void { const nativeKey = this.broker.getNativeKey(contract) contract.aliceId = `${this.id}|${nativeKey}` } - /** Parse aliceId → { utaId, nativeKey }, or null if invalid. */ - static parseAliceId(aliceId: string): { utaId: string; nativeKey: string } | null { - const sep = aliceId.indexOf('|') - if (sep === -1) return null - return { utaId: aliceId.slice(0, sep), nativeKey: aliceId.slice(sep + 1) } - } - /** * Reverse of `stampAliceId`: parse an aliceId, verify it belongs to this * UTA, and rebuild the full Contract via the broker's native-key resolver. @@ -369,11 +384,23 @@ export class UnifiedTradingAccount { return contract } + /** Parse aliceId → { utaId, nativeKey }, or null if invalid. */ + static parseAliceId(aliceId: string): { utaId: string; nativeKey: string } | null { + const sep = aliceId.indexOf('|') + if (sep === -1) return null + return { utaId: aliceId.slice(0, sep), nativeKey: aliceId.slice(sep + 1) } + } + // ==================== Stage operations ==================== stagePlaceOrder(params: StagePlaceOrderParams): AddResult { // Resolve aliceId → full contract via broker (fills secType, exchange, currency, conId, etc.) - const contract = this.contractFromAliceId(params.aliceId) + const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId) + if (!parsed) { + throw new Error(`Invalid aliceId "${params.aliceId}". Use searchContracts to get a valid contract identifier (expected format: "accountId|nativeKey").`) + } + const contract = this.broker.resolveNativeKey(parsed.nativeKey) + contract.aliceId = params.aliceId if (params.symbol) contract.symbol = params.symbol const order = new Order() @@ -415,7 +442,12 @@ export class UnifiedTradingAccount { } stageClosePosition(params: StageClosePositionParams): AddResult { - const contract = this.contractFromAliceId(params.aliceId) + const parsed = UnifiedTradingAccount.parseAliceId(params.aliceId) + if (!parsed) { + throw new Error(`Invalid aliceId "${params.aliceId}". Use searchContracts to get a valid contract identifier (expected format: "accountId|nativeKey").`) + } + const contract = this.broker.resolveNativeKey(parsed.nativeKey) + contract.aliceId = params.aliceId if (params.symbol) contract.symbol = params.symbol return this.git.add({ @@ -620,6 +652,7 @@ export class UnifiedTradingAccount { } async getQuote(contract: Contract): Promise { + this.resolveContract(contract) const quote = await this._callBroker(() => this.broker.getQuote(contract)) this.stampAliceId(quote.contract) return quote @@ -648,6 +681,7 @@ export class UnifiedTradingAccount { } async getContractDetails(query: Contract): Promise { + this.resolveContract(query) const details = await this._callBroker(() => this.broker.getContractDetails(query)) if (details) this.stampAliceId(details.contract) return details diff --git a/src/main.ts b/src/main.ts index 23bce2fb8..fb9763acb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -133,6 +133,8 @@ async function main() { // ==================== Cron ==================== const cronEngine = createCronEngine({ registry: listenerRegistry }) + await cronEngine.start() + console.log('cron: engine started') // ==================== News Collector Store ==================== @@ -313,9 +315,7 @@ async function main() { // ==================== Activate Listeners + Start Cron Engine ==================== await listenerRegistry.start() - await cronEngine.start() console.log(`listener-registry: started (${listenerRegistry.list().length} listeners)`) - console.log('cron: engine started') // ==================== News Collector ==================== diff --git a/src/task/heartbeat/heartbeat.ts b/src/task/heartbeat/heartbeat.ts index ee4823470..fa22e511a 100644 --- a/src/task/heartbeat/heartbeat.ts +++ b/src/task/heartbeat/heartbeat.ts @@ -88,6 +88,8 @@ export interface HeartbeatOpts { export interface Heartbeat { start(): Promise stop(): void + /** Update heartbeat config (interval, prompt, etc.) and sync with pump. */ + updateConfig(newConfig: HeartbeatConfig): Promise /** Hot-toggle heartbeat on/off (persists to config + updates pump). */ setEnabled(enabled: boolean): Promise /** Current enabled state. */ @@ -99,7 +101,8 @@ export interface Heartbeat { // ==================== Factory ==================== export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { - const { config, agentWorkListener, registry } = opts + let { config } = opts + const { agentWorkListener, registry } = opts const session = opts.session ?? new SessionStore('heartbeat') const now = opts.now ?? Date.now @@ -191,6 +194,14 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { started = false }, + async updateConfig(newConfig: HeartbeatConfig) { + config = { ...newConfig } + enabled = config.enabled + if (pump) { + pump.setEnabled(enabled) + } + }, + async setEnabled(newEnabled: boolean) { enabled = newEnabled pump?.setEnabled(newEnabled) diff --git a/src/tool/trading.spec.ts b/src/tool/trading.spec.ts index c13ad8fdc..c3370012e 100644 --- a/src/tool/trading.spec.ts +++ b/src/tool/trading.spec.ts @@ -252,119 +252,53 @@ describe('createTradingTools — getOrders summarization', () => { }) }) -// ==================== getQuote (aliceId resolution) ==================== +// ==================== getPortfolio ==================== -describe('createTradingTools — getQuote', () => { - it('resolves aliceId via UTA so broker sees a contract with native fields', async () => { - const broker = new MockBroker({ id: 'mock-paper' }) - const spy = vi.spyOn(broker, 'getQuote') - const tools = createTradingTools(makeManager(broker)) - - const result = await (tools.getQuote.execute as Function)({ aliceId: 'mock-paper|AAPL' }) - - expect(spy).toHaveBeenCalledTimes(1) - const [passedContract] = spy.mock.calls[0] - // Without contractFromAliceId, this would be empty and broker resolution - // would fail. With the fix, MockBroker.resolveNativeKey populates symbol. - expect(passedContract.symbol || passedContract.localSymbol).toBeTruthy() - expect(passedContract.aliceId).toBe('mock-paper|AAPL') - expect(result.source).toBe('mock-paper') - }) +describe('createTradingTools — getPortfolio', () => { + it('returns positions with aliceId and calculated percentages', async () => { + const broker = new MockBroker({ id: 'mock-paper', cash: 100000 }) + broker.setMarkPrice('AAPL', 150) + broker.setMarkPrice('TSLA', 200) - it('returns error on malformed aliceId', async () => { - const broker = new MockBroker({ id: 'mock-paper' }) - const tools = createTradingTools(makeManager(broker)) - const result = await (tools.getQuote.execute as Function)({ aliceId: 'no-separator-here' }) - expect(result.error).toMatch(/Invalid aliceId/) - }) + // AAPL: 100 shares @ 150 = 15,000 + broker.externalDeposit({ nativeKey: 'AAPL', quantity: 100 }) + // TSLA: 50 shares @ 200 = 10,000 + broker.externalDeposit({ nativeKey: 'TSLA', quantity: 50 }) - it('routes to the UTA encoded in the aliceId without an explicit source', async () => { - const a1 = new MockBroker({ id: 'alpaca-paper' }) - const a2 = new MockBroker({ id: 'bybit-main' }) - const spy1 = vi.spyOn(a1, 'getQuote') - const spy2 = vi.spyOn(a2, 'getQuote') - const tools = createTradingTools(makeManager(a1, a2)) + const mgr = makeManager(broker) + const tools = createTradingTools(mgr) - await (tools.getQuote.execute as Function)({ aliceId: 'bybit-main|BTC' }) + const result = await (tools.getPortfolio.execute as Function)({ source: 'mock-paper' }) - expect(spy2).toHaveBeenCalledTimes(1) - expect(spy1).not.toHaveBeenCalled() - }) -}) + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(2) -// ==================== getContractDetails (aliceId resolution) ==================== + const aapl = result.find((p: any) => p.symbol === 'AAPL') + expect(aapl).toBeDefined() + expect(aapl.aliceId).toBe('mock-paper|AAPL') + expect(aapl.marketValue).toBe('15000') -describe('createTradingTools — getContractDetails', () => { - it('expands aliceId via UTA before calling broker.getContractDetails', async () => { - const broker = new MockBroker({ id: 'mock-paper' }) - const spy = vi.spyOn(broker, 'getContractDetails') - const tools = createTradingTools(makeManager(broker)) - - await (tools.getContractDetails.execute as Function)({ - source: 'mock-paper', - aliceId: 'mock-paper|AAPL', - }) - - expect(spy).toHaveBeenCalledTimes(1) - const [passedQuery] = spy.mock.calls[0] - expect(passedQuery.symbol || passedQuery.localSymbol).toBeTruthy() - expect(passedQuery.aliceId).toBe('mock-paper|AAPL') + const tsla = result.find((p: any) => p.symbol === 'TSLA') + expect(tsla).toBeDefined() + expect(tsla.aliceId).toBe('mock-paper|TSLA') + expect(tsla.marketValue).toBe('10000') }) - it('returns error on cross-UTA aliceId mismatch', async () => { + it('filters by symbol', async () => { const broker = new MockBroker({ id: 'mock-paper' }) - const tools = createTradingTools(makeManager(broker)) - const result = await (tools.getContractDetails.execute as Function)({ - source: 'mock-paper', - aliceId: 'other-account|AAPL', - }) - expect(result.error).toMatch(/belongs to UTA "other-account"/) - }) -}) - -// ==================== placeOrder schema (AI ergonomics) ==================== - -describe('placeOrder inputSchema', () => { - // LLMs frequently emit "" for fields they don't intend to set rather than - // omitting the key. Without empty-string tolerance, every optional numeric - // field rejects with "must be a positive numeric string" and the whole MKT - // call fails at the schema gate (the cashQty/lmtPrice/auxPrice cluster bug - // reported 2026-05-12). - it('treats empty-string optional numeric fields as omitted', () => { - const broker = new MockBroker({ id: 'mock-paper' }) - const tools = createTradingTools(makeManager(broker)) - - const result = (tools.placeOrder.inputSchema as any).safeParse({ - source: 'mock-paper', - aliceId: 'mock-paper|AAPL', - action: 'BUY', - orderType: 'MKT', - totalQuantity: '0.01', - cashQty: '', - lmtPrice: '', - auxPrice: '', - trailStopPrice: '', - trailingPercent: '', - }) - - expect(result.success).toBe(true) - expect(result.data.cashQty).toBeUndefined() - expect(result.data.lmtPrice).toBeUndefined() - expect(result.data.totalQuantity).toBe('0.01') - }) + broker.setMarkPrice('AAPL', 150) + broker.setMarkPrice('TSLA', 200) + broker.externalDeposit({ nativeKey: 'AAPL', quantity: 100 }) + broker.externalDeposit({ nativeKey: 'TSLA', quantity: 50 }) - it('still rejects non-empty invalid numerics', () => { - const broker = new MockBroker({ id: 'mock-paper' }) - const tools = createTradingTools(makeManager(broker)) + const mgr = makeManager(broker) + const tools = createTradingTools(mgr) - const result = (tools.placeOrder.inputSchema as any).safeParse({ - source: 'mock-paper', - aliceId: 'mock-paper|AAPL', - action: 'BUY', - orderType: 'MKT', - totalQuantity: '0', - }) + const result = await (tools.getPortfolio.execute as Function)({ source: 'mock-paper', symbol: 'AAPL' }) - expect(result.success).toBe(false) + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(1) + expect(result[0].symbol).toBe('AAPL') + expect(result[0].aliceId).toBe('mock-paper|AAPL') }) }) diff --git a/src/tool/trading.ts b/src/tool/trading.ts index 97e337e65..f232e95bb 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -11,7 +11,6 @@ import { z } from 'zod' import Decimal from 'decimal.js' import { Contract, UNSET_DECIMAL, coerceSecType } from '@traderalice/ibkr' import type { UTAManager } from '@/domain/trading/uta-manager.js' -import { UnifiedTradingAccount } from '@/domain/trading/UnifiedTradingAccount.js' import { BrokerError, type OpenOrder } from '@/domain/trading/brokers/types.js' import type { FxService } from '@/domain/trading/fx-service.js' import { normalizeBrokerSearchPattern } from '@/domain/trading/contract-search-rules.js' @@ -76,20 +75,11 @@ const sourceDesc = (required: boolean, extra?: string) => { * when the schema demands them; permissive `union([number, string])` * is unnecessary and re-opens the precision-loss path that this * whole sweep was meant to close. - * - * Empty string `""` is normalized to `undefined` before validation. - * Why: when this validator is used with `.optional()`, LLMs often - * emit `""` for fields they don't intend to set (instead of omitting - * the key), and a bare `z.string().refine(...).optional()` would - * then reject the empty string against the positive-number rule. - * Treating `""` as "not provided" matches the AI-ergonomics the - * `.optional()` site actually wants. */ const positiveNumeric = z .string() .refine( (v) => { - if (v === '') return true try { return new Decimal(v).gt(0) && new Decimal(v).isFinite() } catch { @@ -98,7 +88,6 @@ const positiveNumeric = z }, { message: 'must be a positive numeric string (e.g. "0.001", "150")' }, ) - .transform((v) => (v === '' ? undefined : v)) export function createTradingTools(manager: UTAManager, fxService?: FxService): Record { return { @@ -149,22 +138,15 @@ hitting the broker, which otherwise expects the bare base ticker.`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), symbol: z.string().optional().describe('Symbol to look up'), - aliceId: z.string().optional().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), + aliceId: z.string().optional().describe('Contract ID (format: accountId|nativeKey, from searchContracts or getPortfolio)'), secType: z.string().optional().describe('Security type filter'), currency: z.string().optional().describe('Currency filter'), }), execute: async ({ source, symbol, aliceId, secType, currency }) => { const uta = manager.resolveOne(source) - // When aliceId is provided, expand it via the broker's native-key - // resolver so the broker actually sees `localSymbol`/`symbol` — - // bare `query.aliceId = aliceId` is invisible to broker resolution. - let query: Contract - try { - query = aliceId ? uta.contractFromAliceId(aliceId) : new Contract() - } catch (err) { - return handleBrokerError(err) - } + const query = new Contract() if (symbol) query.symbol = symbol + if (aliceId) query.aliceId = aliceId if (secType) query.secType = coerceSecType(secType) if (currency) query.currency = currency const details = await uta.getContractDetails(query) @@ -236,9 +218,17 @@ If this tool returns an error with transient=true, wait a few seconds and retry const percentOfEquity = netLiqUsd.gt(0) ? mvUsd.div(netLiqUsd).mul(100) : new Decimal(0) const percentOfPortfolio = totalMarketValueUsd.gt(0) ? mvUsd.div(totalMarketValueUsd).mul(100) : new Decimal(0) allPositions.push({ - source: uta.id, symbol: pos.contract.symbol, currency: pos.currency, side: pos.side, - quantity: pos.quantity.toString(), avgCost: pos.avgCost, marketPrice: pos.marketPrice, - marketValue: pos.marketValue, unrealizedPnL: pos.unrealizedPnL, realizedPnL: pos.realizedPnL, + source: uta.id, + aliceId: pos.contract.aliceId, + symbol: pos.contract.symbol, + currency: pos.currency, + side: pos.side, + quantity: pos.quantity.toString(), + avgCost: pos.avgCost, + marketPrice: pos.marketPrice, + marketValue: pos.marketValue, + unrealizedPnL: pos.unrealizedPnL, + realizedPnL: pos.realizedPnL, percentageOfEquity: `${percentOfEquity.toFixed(1)}%`, percentageOfPortfolio: `${percentOfPortfolio.toFixed(1)}%`, }) @@ -293,24 +283,20 @@ If this tool returns an error with transient=true, wait a few seconds and retry description: `Query the latest quote/price for a contract. If this tool returns an error with transient=true, wait a few seconds and retry once before reporting to the user.`, inputSchema: z.object({ - aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), + aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts or getPortfolio)'), source: z.string().optional().describe(sourceDesc(false)), }), execute: async ({ aliceId, source }) => { - // aliceId is UTA-scoped (`{utaId}|{nativeKey}`); route directly to the - // owning UTA. Fall back to caller-supplied `source` if given (allows - // overrides / sanity-check). `contractFromAliceId` cross-validates. - const parsed = UnifiedTradingAccount.parseAliceId(aliceId) - if (!parsed) { - return { error: `Invalid aliceId "${aliceId}". Expected format: "accountId|nativeKey".` } - } - try { - const uta = manager.resolveOne(source ?? parsed.utaId) - const contract = uta.contractFromAliceId(aliceId) - return { source: uta.id, ...await uta.getQuote(contract) } - } catch (err) { - return handleBrokerError(err) + const targets = manager.resolve(source) + if (targets.length === 0) return { error: 'No accounts available.' } + const query = new Contract() + query.aliceId = aliceId + const results: Array> = [] + for (const uta of targets) { + try { results.push({ source: uta.id, ...await uta.getQuote(query) }) } catch { /* skip */ } } + if (results.length === 0) return { error: `No account could quote aliceId "${aliceId}".` } + return results.length === 1 ? results[0] : results }, }), @@ -406,7 +392,7 @@ Required params by orderType: Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), - aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), + aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts or getPortfolio)'), symbol: z.string().optional().describe('Human-readable symbol (optional, for display only)'), action: z.enum(['BUY', 'SELL']).describe('Order direction'), orderType: z.enum(['MKT', 'LMT', 'STP', 'STP LMT', 'TRAIL', 'TRAIL LIMIT', 'MOC']).describe('Order type'), @@ -453,7 +439,7 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, description: 'Stage a position close.\nNOTE: This stages the operation. Call tradingCommit + tradingPush to execute.', inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), - aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), + aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts or getPortfolio)'), symbol: z.string().optional().describe('Human-readable symbol. Optional.'), qty: positiveNumeric.optional().describe('Number of shares to sell. Decimal string. Default: sell all.'), }), diff --git a/src/webui/routes/config.ts b/src/webui/routes/config.ts index c3935a1af..d7b288e40 100644 --- a/src/webui/routes/config.ts +++ b/src/webui/routes/config.ts @@ -147,6 +147,9 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { if (section === 'connectors' || section === 'marketData') { await opts?.onConnectorsChange?.() } + if (section === 'heartbeat' && opts?.ctx) { + await opts.ctx.heartbeat.updateConfig(opts.ctx.config.heartbeat) + } return c.json(validated) } catch (err) { if (err instanceof Error && err.name === 'ZodError') {