Add Ticker24h support: hourly market snapshots with USD-normalized volume filtering

This commit is contained in:
2026-04-26 18:39:52 -04:00
parent 85fcbe1330
commit 0178b5d29d
45 changed files with 1995 additions and 170 deletions

View File

@@ -57,6 +57,7 @@ interface BarSubscription {
ticker: string;
periodSeconds: number;
callback: BarUpdateCallback;
openBars: boolean;
}
export class WebSocketHandler {
@@ -65,6 +66,8 @@ export class WebSocketHandler {
private workspaces = new Map<string, WorkspaceManager>();
/** Per-session realtime bar subscriptions for cleanup on disconnect */
private barSubscriptions = new Map<string, BarSubscription[]>();
/** "sessionId:pandas_ta_name" → active request_id; supersedes stale requests on scroll */
private activeEvaluations = new Map<string, string>();
constructor(config: WebSocketHandlerConfig) {
this.config = config;
@@ -501,13 +504,19 @@ export class WebSocketHandler {
const sessionId = authContext.sessionId;
const subs = this.barSubscriptions.get(sessionId);
if (subs && this.config.ohlcService) {
for (const { ticker, periodSeconds, callback } of subs) {
this.config.ohlcService.unsubscribeFromTicker(ticker, periodSeconds, callback);
for (const { ticker, periodSeconds, callback, openBars } of subs) {
this.config.ohlcService.unsubscribeFromTicker(ticker, periodSeconds, callback, openBars);
}
this.barSubscriptions.delete(sessionId);
logger.info({ sessionId, count: subs.length }, 'Cleaned up realtime bar subscriptions');
}
// Cleanup active indicator evaluations for this session
const evalPrefix = `${sessionId}:`;
for (const key of this.activeEvaluations.keys()) {
if (key.startsWith(evalPrefix)) this.activeEvaluations.delete(key);
}
// Cleanup workspace
await workspace!.shutdown();
this.workspaces.delete(authContext.sessionId);
@@ -623,18 +632,11 @@ export class WebSocketHandler {
case 'search_symbols': {
logger.info({ query: payload.query, limit: payload.limit }, 'Handling search_symbols');
// Use SymbolIndexService if available, otherwise fallback to OHLCService stub
const symbolIndexService = this.config.symbolIndexService;
logger.info({ hasSymbolIndexService: !!symbolIndexService }, 'Service check for search');
const results = symbolIndexService
? await symbolIndexService.search(payload.query, payload.limit || 30)
: (ohlcService ? await ohlcService.searchSymbols(
payload.query,
payload.symbol_type,
payload.exchange,
payload.limit || 30
) : []);
: [];
logger.info({ resultsCount: results.length }, 'Search complete');
socket.send(
@@ -649,13 +651,11 @@ export class WebSocketHandler {
case 'resolve_symbol': {
logger.info({ symbol: payload.symbol }, 'Handling resolve_symbol');
// Use SymbolIndexService if available, otherwise fallback to OHLCService stub
const symbolIndexService = this.config.symbolIndexService;
logger.info({ hasSymbolIndexService: !!symbolIndexService }, 'Service check for resolve');
const symbolInfo = symbolIndexService
? await symbolIndexService.resolveSymbol(payload.symbol)
: (ohlcService ? await ohlcService.resolveSymbol(payload.symbol) : null);
: null;
logger.info({ found: !!symbolInfo }, 'Symbol resolution complete');
@@ -723,6 +723,8 @@ export class WebSocketHandler {
const subTicker: string = payload.symbol;
const subPeriod: number = payload.period_seconds ?? payload.resolution ?? 60;
// 'open' = in-progress bar snapshots every tick (chart); 'closed' = completed bars only (strategies)
const openBars: boolean = (payload.bar_type ?? 'open') === 'open';
const sessionId = authContext.sessionId;
// Create a per-subscription callback that forwards bars to this socket
@@ -733,6 +735,7 @@ export class WebSocketHandler {
subscription_id: payload.subscription_id,
ticker: bar.ticker,
period_seconds: bar.periodSeconds,
is_closed: bar.isClosed,
bar: {
// Convert nanoseconds → seconds for client compatibility
time: Number(bar.timestamp / 1_000_000_000n),
@@ -745,7 +748,7 @@ export class WebSocketHandler {
}));
};
ohlcService.subscribeToTicker(subTicker, subPeriod, barCallback);
ohlcService.subscribeToTicker(subTicker, subPeriod, barCallback, openBars);
// Track for cleanup on disconnect
if (!this.barSubscriptions.has(sessionId)) {
@@ -755,6 +758,7 @@ export class WebSocketHandler {
ticker: subTicker,
periodSeconds: subPeriod,
callback: barCallback,
openBars,
});
logger.info({ sessionId, ticker: subTicker, period: subPeriod }, 'Subscribed to realtime bars');
@@ -782,7 +786,7 @@ export class WebSocketHandler {
);
if (idx >= 0) {
const [removed] = subs.splice(idx, 1);
ohlcService.unsubscribeFromTicker(unsubTicker, unsubPeriod, removed.callback);
ohlcService.unsubscribeFromTicker(unsubTicker, unsubPeriod, removed.callback, removed.openBars);
logger.info({ sessionId, ticker: unsubTicker, period: unsubPeriod }, 'Unsubscribed from realtime bars');
}
}
@@ -807,6 +811,19 @@ export class WebSocketHandler {
}));
break;
}
// Supersede any in-flight request for the same indicator (e.g. rapid scrolling)
const evalKey = `${authContext.sessionId}:${payload.pandas_ta_name}`;
const prevRequestId = this.activeEvaluations.get(evalKey);
if (prevRequestId) {
socket.send(JSON.stringify({
type: 'evaluate_indicator_result',
request_id: prevRequestId,
error: 'superseded',
}));
}
this.activeEvaluations.set(evalKey, requestId);
try {
const mcpResult = await harness.callMcpTool('EvaluateIndicator', {
symbol: payload.symbol,
@@ -816,6 +833,11 @@ export class WebSocketHandler {
pandas_ta_name: payload.pandas_ta_name,
parameters: payload.parameters ?? {},
}) as any;
// Discard result if a newer request arrived while we were awaiting
if (this.activeEvaluations.get(evalKey) !== requestId) break;
this.activeEvaluations.delete(evalKey);
// MCP returns { content: [{type: 'text', text: '...json...'}] }
// When the tool raises an exception, the MCP framework sets isError: true
// and puts the raw exception text in content[0].text (not JSON-wrapped).
@@ -849,6 +871,9 @@ export class WebSocketHandler {
...data,
}));
} catch (err: any) {
if (this.activeEvaluations.get(evalKey) === requestId) {
this.activeEvaluations.delete(evalKey);
}
logger.error({ err: err?.message, pandas_ta_name: payload.pandas_ta_name }, 'evaluate_indicator handler error');
socket.send(JSON.stringify({
type: 'evaluate_indicator_result',