Aditya4573 commited on
Commit
ebfcd8b
·
1 Parent(s): 01369a3

perf: implement centralized server market-data cache, staggered polling, and spot drift TTL

Browse files
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
- if (xauusdOffset !== null) return xauusdOffset;
 
 
 
 
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
- return 0; // Fallback to 0
 
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 = xauusdOffset !== null ? xauusdOffset : await getXauusdOffset(rawClose);
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._isPolling = false;
46
 
47
- // Run first poll immediately, then poll every 6 seconds
48
- this._poll();
49
- this._intervalTimer = setInterval(() => {
50
- this._poll();
51
- }, 6000);
 
 
52
  }
53
 
54
  /** Disconnect the stream. */
55
  unsubscribe() {
56
- if (this._intervalTimer) {
57
- clearInterval(this._intervalTimer);
58
- this._intervalTimer = null;
 
59
  }
60
  this._symbol = null;
61
  this._timeframe = null;
62
  this._callbacks = null;
63
- this._isPolling = false;
64
  }
65
 
66
  /** @private */
67
- async _poll() {
68
- // Prevent overlapping poll cycles
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
- } finally {
105
- this._isPolling = false;
 
 
 
 
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
  }