Spaces:
Running
Running
Commit ·
ebfcd8b
1
Parent(s): 01369a3
perf: implement centralized server market-data cache, staggered polling, and spot drift TTL
Browse files- server.js +15 -2
- src/data/dataManager.js +25 -0
- src/data/serverStream.js +103 -0
- src/data/yahooFinanceAPI.js +11 -3
- src/data/yahooWebSocket.js +25 -31
server.js
CHANGED
|
@@ -241,7 +241,7 @@ async function startAutopilot() {
|
|
| 241 |
// 2. Subscribe to real-time Yahoo streams for all 6 symbols in the background
|
| 242 |
// This keeps the server autopilot 100% active and rate-limit free!
|
| 243 |
console.log('[Autopilot] Establishing background Yahoo monitoring feeds...');
|
| 244 |
-
for (const symbol of SYMBOLS) {
|
| 245 |
const stream = new YahooStream();
|
| 246 |
stream.subscribe(symbol, TIMEFRAME, {
|
| 247 |
onTick: (price) => {
|
|
@@ -256,7 +256,7 @@ async function startAutopilot() {
|
|
| 256 |
handleCandleUpdate(symbol, candle);
|
| 257 |
}
|
| 258 |
},
|
| 259 |
-
});
|
| 260 |
streams[symbol] = stream;
|
| 261 |
}
|
| 262 |
|
|
@@ -295,6 +295,19 @@ app.get('/api/terminal-state', (req, res) => {
|
|
| 295 |
});
|
| 296 |
});
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
app.post('/api/take-trade', async (req, res) => {
|
| 299 |
const { signal } = req.body;
|
| 300 |
if (!signal) {
|
|
|
|
| 241 |
// 2. Subscribe to real-time Yahoo streams for all 6 symbols in the background
|
| 242 |
// This keeps the server autopilot 100% active and rate-limit free!
|
| 243 |
console.log('[Autopilot] Establishing background Yahoo monitoring feeds...');
|
| 244 |
+
for (const [idx, symbol] of SYMBOLS.entries()) {
|
| 245 |
const stream = new YahooStream();
|
| 246 |
stream.subscribe(symbol, TIMEFRAME, {
|
| 247 |
onTick: (price) => {
|
|
|
|
| 256 |
handleCandleUpdate(symbol, candle);
|
| 257 |
}
|
| 258 |
},
|
| 259 |
+
}, { pollMs: 10000, staggerMs: idx * 1500 });
|
| 260 |
streams[symbol] = stream;
|
| 261 |
}
|
| 262 |
|
|
|
|
| 295 |
});
|
| 296 |
});
|
| 297 |
|
| 298 |
+
// Cached market data: browsers read candles + live price from the server's own
|
| 299 |
+
// poller cache instead of each hitting Yahoo. Reads in-memory state, no upstream call.
|
| 300 |
+
app.get('/api/market-data', (req, res) => {
|
| 301 |
+
const symbol = req.query.symbol;
|
| 302 |
+
const candles = (symbol && historyMap[symbol]) ? historyMap[symbol].slice(-500) : [];
|
| 303 |
+
res.json({
|
| 304 |
+
symbol: symbol || null,
|
| 305 |
+
candles,
|
| 306 |
+
price: (symbol && livePrices[symbol] != null) ? livePrices[symbol] : null,
|
| 307 |
+
serverTime: Math.floor(Date.now() / 1000),
|
| 308 |
+
});
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
app.post('/api/take-trade', async (req, res) => {
|
| 312 |
const { signal } = req.body;
|
| 313 |
if (!signal) {
|
src/data/dataManager.js
CHANGED
|
@@ -8,6 +8,7 @@ import { fetchKlines, fetchTickerPrice } from './binanceAPI.js';
|
|
| 8 |
import { BinanceStream } from './binanceWebSocket.js';
|
| 9 |
import { fetchCandles as fetchYahooCandles, fetchQuote as fetchYahooQuote } from './yahooFinanceAPI.js';
|
| 10 |
import { YahooStream } from './yahooWebSocket.js';
|
|
|
|
| 11 |
import { fetchCandles as fetchTwelveCandles, fetchQuote as fetchTwelveQuote } from './twelveDataAPI.js';
|
| 12 |
import { TwelveDataStream } from './twelveDataWebSocket.js';
|
| 13 |
|
|
@@ -57,6 +58,8 @@ export default class DataManager {
|
|
| 57 |
this._binanceStream = new BinanceStream();
|
| 58 |
/** @type {YahooStream} */
|
| 59 |
this._yahooStream = new YahooStream();
|
|
|
|
|
|
|
| 60 |
/** @type {TwelveDataStream} */
|
| 61 |
this._twelveStream = new TwelveDataStream();
|
| 62 |
}
|
|
@@ -96,6 +99,22 @@ export default class DataManager {
|
|
| 96 |
|
| 97 |
/** @private */
|
| 98 |
async _loadYahooHistory(symbol, timeframe) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
console.log(`[DataManager] Loading Yahoo history: ${symbol} ${timeframe}`);
|
| 100 |
return fetchYahooCandles(symbol, timeframe);
|
| 101 |
}
|
|
@@ -146,6 +165,11 @@ export default class DataManager {
|
|
| 146 |
if (config.provider === 'binance') {
|
| 147 |
console.log(`[DataManager] Subscribing Binance stream: ${config.wsSymbol} ${tf.binance}`);
|
| 148 |
this._binanceStream.subscribe(config.wsSymbol, tf.binance, callbacks);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
} else {
|
| 150 |
console.log(`[DataManager] Subscribing Yahoo stream: ${symbol} ${timeframe}`);
|
| 151 |
this._yahooStream.subscribe(symbol, timeframe, callbacks);
|
|
@@ -157,6 +181,7 @@ export default class DataManager {
|
|
| 157 |
console.log('[DataManager] Unsubscribing all streams');
|
| 158 |
this._binanceStream.unsubscribe();
|
| 159 |
this._yahooStream.unsubscribe();
|
|
|
|
| 160 |
this._twelveStream.unsubscribe();
|
| 161 |
}
|
| 162 |
|
|
|
|
| 8 |
import { BinanceStream } from './binanceWebSocket.js';
|
| 9 |
import { fetchCandles as fetchYahooCandles, fetchQuote as fetchYahooQuote } from './yahooFinanceAPI.js';
|
| 10 |
import { YahooStream } from './yahooWebSocket.js';
|
| 11 |
+
import { ServerStream } from './serverStream.js';
|
| 12 |
import { fetchCandles as fetchTwelveCandles, fetchQuote as fetchTwelveQuote } from './twelveDataAPI.js';
|
| 13 |
import { TwelveDataStream } from './twelveDataWebSocket.js';
|
| 14 |
|
|
|
|
| 58 |
this._binanceStream = new BinanceStream();
|
| 59 |
/** @type {YahooStream} */
|
| 60 |
this._yahooStream = new YahooStream();
|
| 61 |
+
/** @type {ServerStream} */
|
| 62 |
+
this._serverStream = new ServerStream();
|
| 63 |
/** @type {TwelveDataStream} */
|
| 64 |
this._twelveStream = new TwelveDataStream();
|
| 65 |
}
|
|
|
|
| 99 |
|
| 100 |
/** @private */
|
| 101 |
async _loadYahooHistory(symbol, timeframe) {
|
| 102 |
+
// For the server's native 15m timeframe, prefer the server's cached candles so
|
| 103 |
+
// the browser doesn't hit Yahoo directly. Fall back to Yahoo if the cache is cold.
|
| 104 |
+
if (timeframe === '15m') {
|
| 105 |
+
try {
|
| 106 |
+
const res = await fetch(`/api/market-data?symbol=${encodeURIComponent(symbol)}`);
|
| 107 |
+
if (res.ok) {
|
| 108 |
+
const data = await res.json();
|
| 109 |
+
if (data.candles && data.candles.length > 50) {
|
| 110 |
+
console.log(`[DataManager] Loaded ${data.candles.length} candles from server cache: ${symbol}`);
|
| 111 |
+
return data.candles;
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
} catch (err) {
|
| 115 |
+
console.warn(`[DataManager] Server cache unavailable for ${symbol}, falling back to Yahoo:`, err.message);
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
console.log(`[DataManager] Loading Yahoo history: ${symbol} ${timeframe}`);
|
| 119 |
return fetchYahooCandles(symbol, timeframe);
|
| 120 |
}
|
|
|
|
| 165 |
if (config.provider === 'binance') {
|
| 166 |
console.log(`[DataManager] Subscribing Binance stream: ${config.wsSymbol} ${tf.binance}`);
|
| 167 |
this._binanceStream.subscribe(config.wsSymbol, tf.binance, callbacks);
|
| 168 |
+
} else if (timeframe === '15m') {
|
| 169 |
+
// Default timeframe served from the server's shared cache so each browser
|
| 170 |
+
// doesn't poll Yahoo directly (server is the single upstream poller).
|
| 171 |
+
console.log(`[DataManager] Subscribing server-cache stream: ${symbol} ${timeframe}`);
|
| 172 |
+
this._serverStream.subscribe(symbol, timeframe, callbacks);
|
| 173 |
} else {
|
| 174 |
console.log(`[DataManager] Subscribing Yahoo stream: ${symbol} ${timeframe}`);
|
| 175 |
this._yahooStream.subscribe(symbol, timeframe, callbacks);
|
|
|
|
| 181 |
console.log('[DataManager] Unsubscribing all streams');
|
| 182 |
this._binanceStream.unsubscribe();
|
| 183 |
this._yahooStream.unsubscribe();
|
| 184 |
+
this._serverStream.unsubscribe();
|
| 185 |
this._twelveStream.unsubscribe();
|
| 186 |
}
|
| 187 |
|
src/data/serverStream.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* serverStream.js
|
| 3 |
+
* Drop-in replacement for YahooStream that reads candles + live price from the
|
| 4 |
+
* backend's own cached market data (/api/market-data) instead of polling Yahoo
|
| 5 |
+
* directly. This makes the server the single upstream data client, so additional
|
| 6 |
+
* browser viewers cost only a cheap cache read rather than another Yahoo request.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
const TIMEFRAME_SECONDS = {
|
| 10 |
+
'1m': 60,
|
| 11 |
+
'5m': 300,
|
| 12 |
+
'15m': 900,
|
| 13 |
+
'1H': 3600,
|
| 14 |
+
'4H': 14400,
|
| 15 |
+
'1D': 86400,
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export class ServerStream {
|
| 19 |
+
constructor() {
|
| 20 |
+
this._timer = null;
|
| 21 |
+
this._stopped = false;
|
| 22 |
+
this._symbol = null;
|
| 23 |
+
this._timeframe = null;
|
| 24 |
+
this._callbacks = null;
|
| 25 |
+
this._lastClosedTime = null;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* @param {string} symbol
|
| 30 |
+
* @param {string} timeframe
|
| 31 |
+
* @param {{ onCandleUpdate?: (candle: object) => void, onTick?: (price: number) => void }} callbacks
|
| 32 |
+
* @param {{ pollMs?: number, maxPollMs?: number }} [options]
|
| 33 |
+
*/
|
| 34 |
+
subscribe(symbol, timeframe, callbacks, options = {}) {
|
| 35 |
+
this.unsubscribe();
|
| 36 |
+
this._symbol = symbol;
|
| 37 |
+
this._timeframe = timeframe;
|
| 38 |
+
this._callbacks = callbacks;
|
| 39 |
+
this._stopped = false;
|
| 40 |
+
this._lastClosedTime = null;
|
| 41 |
+
this._baseMs = options.pollMs || 5000;
|
| 42 |
+
this._maxMs = options.maxPollMs || 30000;
|
| 43 |
+
this._currentMs = this._baseMs;
|
| 44 |
+
this._timer = setTimeout(() => this._loop(), 0);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
unsubscribe() {
|
| 48 |
+
this._stopped = true;
|
| 49 |
+
if (this._timer) {
|
| 50 |
+
clearTimeout(this._timer);
|
| 51 |
+
this._timer = null;
|
| 52 |
+
}
|
| 53 |
+
this._symbol = null;
|
| 54 |
+
this._timeframe = null;
|
| 55 |
+
this._callbacks = null;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/** @private */
|
| 59 |
+
async _loop() {
|
| 60 |
+
if (this._stopped || !this._symbol) return;
|
| 61 |
+
|
| 62 |
+
let ok = false;
|
| 63 |
+
try {
|
| 64 |
+
const res = await fetch(`/api/market-data?symbol=${encodeURIComponent(this._symbol)}`);
|
| 65 |
+
if (res.ok) {
|
| 66 |
+
const data = await res.json();
|
| 67 |
+
const candles = data.candles || [];
|
| 68 |
+
if (candles.length > 0) {
|
| 69 |
+
ok = true;
|
| 70 |
+
const latest = candles[candles.length - 1];
|
| 71 |
+
const price = (data.price != null) ? data.price : latest.close;
|
| 72 |
+
|
| 73 |
+
if (this._callbacks?.onTick) {
|
| 74 |
+
this._callbacks.onTick(price);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
if (this._callbacks?.onCandleUpdate) {
|
| 78 |
+
const duration = TIMEFRAME_SECONDS[this._timeframe] || 900;
|
| 79 |
+
const nowUnix = Math.floor(Date.now() / 1000);
|
| 80 |
+
// Emit closed=true only once per candle, not on every poll while the
|
| 81 |
+
// server is still serving the same bar as latest.
|
| 82 |
+
let isClosed = false;
|
| 83 |
+
if ((nowUnix - latest.time) >= duration && this._lastClosedTime !== latest.time) {
|
| 84 |
+
isClosed = true;
|
| 85 |
+
this._lastClosedTime = latest.time;
|
| 86 |
+
}
|
| 87 |
+
this._callbacks.onCandleUpdate({ ...latest, isClosed });
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
} catch (err) {
|
| 92 |
+
console.error(`[ServerStream] poll error for ${this._symbol}:`, err.message);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// Reset cadence on success, back off on failure up to the cap.
|
| 96 |
+
this._currentMs = ok ? this._baseMs : Math.min(this._maxMs, Math.round(this._currentMs * 1.8));
|
| 97 |
+
if (!this._stopped) {
|
| 98 |
+
this._timer = setTimeout(() => this._loop(), this._currentMs);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
export default ServerStream;
|
src/data/yahooFinanceAPI.js
CHANGED
|
@@ -16,6 +16,8 @@ export const YAHOO_SYMBOL_MAP = {
|
|
| 16 |
};
|
| 17 |
|
| 18 |
let xauusdOffset = null;
|
|
|
|
|
|
|
| 19 |
|
| 20 |
/**
|
| 21 |
* Calculates or returns the cached offset between Swissquote Spot Gold and Yahoo Finance Futures (GC=F).
|
|
@@ -24,7 +26,11 @@ let xauusdOffset = null;
|
|
| 24 |
* @returns {Promise<number>}
|
| 25 |
*/
|
| 26 |
async function getXauusdOffset(latestYahooClose) {
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
try {
|
| 29 |
const res = await fetch('/api/swissquote');
|
| 30 |
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
@@ -38,13 +44,15 @@ async function getXauusdOffset(latestYahooClose) {
|
|
| 38 |
const spotPrice = (bid + ask) / 2;
|
| 39 |
|
| 40 |
xauusdOffset = spotPrice - latestYahooClose;
|
|
|
|
| 41 |
console.log(`[yahooFinanceAPI] Calculated XAUUSD spot offset: ${xauusdOffset.toFixed(4)} (Spot: ${spotPrice.toFixed(2)}, Futures: ${latestYahooClose.toFixed(2)})`);
|
| 42 |
return xauusdOffset;
|
| 43 |
}
|
| 44 |
} catch (err) {
|
| 45 |
console.error('[yahooFinanceAPI] Failed to fetch Swissquote spot price for offset:', err);
|
| 46 |
}
|
| 47 |
-
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
const TIMEFRAME_CONFIG = {
|
|
@@ -243,7 +251,7 @@ export async function fetchQuote(symbol) {
|
|
| 243 |
let offset = 0;
|
| 244 |
if (symbol === 'XAUUSD') {
|
| 245 |
const rawClose = parseFloat(current);
|
| 246 |
-
offset =
|
| 247 |
}
|
| 248 |
|
| 249 |
return {
|
|
|
|
| 16 |
};
|
| 17 |
|
| 18 |
let xauusdOffset = null;
|
| 19 |
+
let xauusdOffsetTime = 0;
|
| 20 |
+
const XAU_OFFSET_TTL_MS = 5 * 60 * 1000; // refresh the futures->spot offset every 5 min
|
| 21 |
|
| 22 |
/**
|
| 23 |
* Calculates or returns the cached offset between Swissquote Spot Gold and Yahoo Finance Futures (GC=F).
|
|
|
|
| 26 |
* @returns {Promise<number>}
|
| 27 |
*/
|
| 28 |
async function getXauusdOffset(latestYahooClose) {
|
| 29 |
+
// Cache the offset but refresh periodically — futures vs spot basis drifts
|
| 30 |
+
// intraday, so a once-only offset goes stale over a session.
|
| 31 |
+
if (xauusdOffset !== null && (Date.now() - xauusdOffsetTime) < XAU_OFFSET_TTL_MS) {
|
| 32 |
+
return xauusdOffset;
|
| 33 |
+
}
|
| 34 |
try {
|
| 35 |
const res = await fetch('/api/swissquote');
|
| 36 |
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
|
|
| 44 |
const spotPrice = (bid + ask) / 2;
|
| 45 |
|
| 46 |
xauusdOffset = spotPrice - latestYahooClose;
|
| 47 |
+
xauusdOffsetTime = Date.now();
|
| 48 |
console.log(`[yahooFinanceAPI] Calculated XAUUSD spot offset: ${xauusdOffset.toFixed(4)} (Spot: ${spotPrice.toFixed(2)}, Futures: ${latestYahooClose.toFixed(2)})`);
|
| 49 |
return xauusdOffset;
|
| 50 |
}
|
| 51 |
} catch (err) {
|
| 52 |
console.error('[yahooFinanceAPI] Failed to fetch Swissquote spot price for offset:', err);
|
| 53 |
}
|
| 54 |
+
// Keep the last known offset on failure rather than snapping back to 0.
|
| 55 |
+
return xauusdOffset ?? 0;
|
| 56 |
}
|
| 57 |
|
| 58 |
const TIMEFRAME_CONFIG = {
|
|
|
|
| 251 |
let offset = 0;
|
| 252 |
if (symbol === 'XAUUSD') {
|
| 253 |
const rawClose = parseFloat(current);
|
| 254 |
+
offset = await getXauusdOffset(rawClose);
|
| 255 |
}
|
| 256 |
|
| 257 |
return {
|
src/data/yahooWebSocket.js
CHANGED
|
@@ -36,73 +36,67 @@ export class YahooStream {
|
|
| 36 |
* @param {string} timeframe - Local timeframe (1m, 5m, 15m, 1H, 4H, 1D)
|
| 37 |
* @param {{ onCandleUpdate?: (candle: object) => void, onTick?: (price: number) => void }} callbacks
|
| 38 |
*/
|
| 39 |
-
subscribe(symbol, timeframe, callbacks) {
|
| 40 |
this.unsubscribe();
|
| 41 |
|
| 42 |
this._symbol = symbol;
|
| 43 |
this._timeframe = timeframe;
|
| 44 |
this._callbacks = callbacks;
|
| 45 |
-
this.
|
| 46 |
|
| 47 |
-
//
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
/** Disconnect the stream. */
|
| 55 |
unsubscribe() {
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
this.
|
|
|
|
| 59 |
}
|
| 60 |
this._symbol = null;
|
| 61 |
this._timeframe = null;
|
| 62 |
this._callbacks = null;
|
| 63 |
-
this._isPolling = false;
|
| 64 |
}
|
| 65 |
|
| 66 |
/** @private */
|
| 67 |
-
async
|
| 68 |
-
|
| 69 |
-
if (this._isPolling) return;
|
| 70 |
-
this._isPolling = true;
|
| 71 |
|
|
|
|
| 72 |
try {
|
| 73 |
-
if (!this._symbol || !this._timeframe) {
|
| 74 |
-
this._isPolling = false;
|
| 75 |
-
return;
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
const candles = await fetchCandles(this._symbol, this._timeframe);
|
| 79 |
|
| 80 |
if (candles && candles.length > 0) {
|
|
|
|
| 81 |
const latestCandle = candles[candles.length - 1];
|
| 82 |
|
| 83 |
-
// Emit tick for latest close price
|
| 84 |
if (this._callbacks?.onTick) {
|
| 85 |
this._callbacks.onTick(latestCandle.close);
|
| 86 |
}
|
| 87 |
|
| 88 |
-
// Determine if this candle has expired/closed
|
| 89 |
const duration = TIMEFRAME_SECONDS[this._timeframe] || 900;
|
| 90 |
const nowUnix = Math.floor(Date.now() / 1000);
|
| 91 |
-
|
| 92 |
-
// A candle is considered closed if the current time has moved past its period boundary
|
| 93 |
const isClosed = (nowUnix - latestCandle.time) >= duration;
|
| 94 |
|
| 95 |
if (this._callbacks?.onCandleUpdate) {
|
| 96 |
-
this._callbacks.onCandleUpdate({
|
| 97 |
-
...latestCandle,
|
| 98 |
-
isClosed: isClosed,
|
| 99 |
-
});
|
| 100 |
}
|
| 101 |
}
|
| 102 |
} catch (err) {
|
| 103 |
-
console.error(`[YahooStream] Polling error for ${this._symbol}:`, err);
|
| 104 |
-
}
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
}
|
| 107 |
}
|
| 108 |
}
|
|
|
|
| 36 |
* @param {string} timeframe - Local timeframe (1m, 5m, 15m, 1H, 4H, 1D)
|
| 37 |
* @param {{ onCandleUpdate?: (candle: object) => void, onTick?: (price: number) => void }} callbacks
|
| 38 |
*/
|
| 39 |
+
subscribe(symbol, timeframe, callbacks, options = {}) {
|
| 40 |
this.unsubscribe();
|
| 41 |
|
| 42 |
this._symbol = symbol;
|
| 43 |
this._timeframe = timeframe;
|
| 44 |
this._callbacks = callbacks;
|
| 45 |
+
this._stopped = false;
|
| 46 |
|
| 47 |
+
// Self-scheduling poll with exponential backoff: transient Yahoo errors / 429s
|
| 48 |
+
// slow the loop down instead of hammering. Optional stagger spreads multiple
|
| 49 |
+
// symbols so they don't all hit Yahoo on the same tick.
|
| 50 |
+
this._baseMs = options.pollMs || 6000;
|
| 51 |
+
this._maxMs = options.maxPollMs || 60000;
|
| 52 |
+
this._currentMs = this._baseMs;
|
| 53 |
+
this._timer = setTimeout(() => this._loop(), options.staggerMs || 0);
|
| 54 |
}
|
| 55 |
|
| 56 |
/** Disconnect the stream. */
|
| 57 |
unsubscribe() {
|
| 58 |
+
this._stopped = true;
|
| 59 |
+
if (this._timer) {
|
| 60 |
+
clearTimeout(this._timer);
|
| 61 |
+
this._timer = null;
|
| 62 |
}
|
| 63 |
this._symbol = null;
|
| 64 |
this._timeframe = null;
|
| 65 |
this._callbacks = null;
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
/** @private */
|
| 69 |
+
async _loop() {
|
| 70 |
+
if (this._stopped || !this._symbol || !this._timeframe) return;
|
|
|
|
|
|
|
| 71 |
|
| 72 |
+
let ok = false;
|
| 73 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
const candles = await fetchCandles(this._symbol, this._timeframe);
|
| 75 |
|
| 76 |
if (candles && candles.length > 0) {
|
| 77 |
+
ok = true;
|
| 78 |
const latestCandle = candles[candles.length - 1];
|
| 79 |
|
|
|
|
| 80 |
if (this._callbacks?.onTick) {
|
| 81 |
this._callbacks.onTick(latestCandle.close);
|
| 82 |
}
|
| 83 |
|
|
|
|
| 84 |
const duration = TIMEFRAME_SECONDS[this._timeframe] || 900;
|
| 85 |
const nowUnix = Math.floor(Date.now() / 1000);
|
|
|
|
|
|
|
| 86 |
const isClosed = (nowUnix - latestCandle.time) >= duration;
|
| 87 |
|
| 88 |
if (this._callbacks?.onCandleUpdate) {
|
| 89 |
+
this._callbacks.onCandleUpdate({ ...latestCandle, isClosed });
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
}
|
| 92 |
} catch (err) {
|
| 93 |
+
console.error(`[YahooStream] Polling error for ${this._symbol}:`, err.message);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Reset cadence on success, back off on failure up to the cap.
|
| 97 |
+
this._currentMs = ok ? this._baseMs : Math.min(this._maxMs, Math.round(this._currentMs * 1.8));
|
| 98 |
+
if (!this._stopped) {
|
| 99 |
+
this._timer = setTimeout(() => this._loop(), this._currentMs);
|
| 100 |
}
|
| 101 |
}
|
| 102 |
}
|