Add Ticker24h support: hourly market snapshots with USD-normalized volume filtering
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user