Aditya4573 commited on
Commit
a547315
Β·
1 Parent(s): bcf649d

feat: add feed health monitoring and structure-based TP2/trailing stop rules

Browse files
server.js CHANGED
@@ -88,6 +88,8 @@ const tradeManager = new TradeManager({ isServer: true, db: dbManager });
88
  const historyMap = {};
89
  const livePrices = {};
90
  const streams = {};
 
 
91
  const lastScannedCandleTime = {};
92
 
93
  // Helper delay utility
@@ -138,6 +140,7 @@ function handleTick(symbol, price) {
138
  }
139
 
140
  function handleCandleUpdate(symbol, candle) {
 
141
  if (!historyMap[symbol]) historyMap[symbol] = [];
142
  const history = historyMap[symbol];
143
 
@@ -258,6 +261,37 @@ function startTwelveDataPoller(symbol, apiKey, basePollMs = 120000) {
258
  streams[symbol] = { unsubscribe: () => { stopped = true; } };
259
  }
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  async function startAutopilot() {
262
  console.log('[Autopilot] Initializing feeds (Twelve Data for keyed forex/metals, Yahoo for the rest)...');
263
 
@@ -306,6 +340,9 @@ async function startAutopilot() {
306
  }
307
 
308
  console.log(`[Server Autopilot] Startup complete. Timeframe: ${TIMEFRAME}. Autopilot active for: ${AUTOPILOT_SYMBOLS.join(', ')}.`);
 
 
 
309
  }
310
 
311
  // --------------------------------------------------------------------
@@ -321,6 +358,7 @@ app.get('/api/terminal-state', (req, res) => {
321
  lastClosedTime: tradeManager.lastClosedTime,
322
  lastExecutedCandleTime: tradeManager.lastExecutedCandleTime,
323
  twelveDataActive: false,
 
324
  });
325
  });
326
 
 
88
  const historyMap = {};
89
  const livePrices = {};
90
  const streams = {};
91
+ const lastUpdate = {}; // last time each symbol's feed delivered data (ms)
92
+ const staleAlerted = {}; // de-dupe flag for feed-stale Telegram alerts
93
  const lastScannedCandleTime = {};
94
 
95
  // Helper delay utility
 
140
  }
141
 
142
  function handleCandleUpdate(symbol, candle) {
143
+ lastUpdate[symbol] = Date.now();
144
  if (!historyMap[symbol]) historyMap[symbol] = [];
145
  const history = historyMap[symbol];
146
 
 
261
  streams[symbol] = { unsubscribe: () => { stopped = true; } };
262
  }
263
 
264
+ // --------------------------------------------------------------------
265
+ // Feed health monitoring (weekend-aware)
266
+ // --------------------------------------------------------------------
267
+ function isForexOpen(now = new Date()) {
268
+ const day = now.getUTCDay(); // 0 Sun .. 6 Sat
269
+ const h = now.getUTCHours();
270
+ if (day === 6) return false; // Saturday: closed
271
+ if (day === 0 && h < 22) return false; // Sunday before ~22:00 UTC
272
+ if (day === 5 && h >= 21) return false; // Friday after ~21:00 UTC
273
+ return true;
274
+ }
275
+ function feedExpectedLive(symbol) {
276
+ return symbol === 'BTCUSDT' ? true : isForexOpen();
277
+ }
278
+ const STALE_SECONDS = 20 * 60;
279
+ function checkFeedHealth() {
280
+ for (const symbol of SYMBOLS) {
281
+ const last = lastUpdate[symbol];
282
+ const ageSec = last ? (Date.now() - last) / 1000 : Infinity;
283
+ const stale = ageSec > STALE_SECONDS && feedExpectedLive(symbol);
284
+ if (stale && !staleAlerted[symbol]) {
285
+ staleAlerted[symbol] = true;
286
+ console.warn(`[FeedHealth] ${symbol} feed stale (${Math.round(ageSec / 60)} min).`);
287
+ sendTelegramMessage(`⚠️ <b>FEED STALE</b>\n\n<code>${symbol}</code> has not updated in <b>${Math.round(ageSec / 60)} min</b> while its market should be open. Check the data feed / Twelve Data key.`);
288
+ } else if (!stale && staleAlerted[symbol]) {
289
+ staleAlerted[symbol] = false;
290
+ sendTelegramMessage(`βœ… <b>FEED RECOVERED</b>\n\n<code>${symbol}</code> is updating again.`);
291
+ }
292
+ }
293
+ }
294
+
295
  async function startAutopilot() {
296
  console.log('[Autopilot] Initializing feeds (Twelve Data for keyed forex/metals, Yahoo for the rest)...');
297
 
 
340
  }
341
 
342
  console.log(`[Server Autopilot] Startup complete. Timeframe: ${TIMEFRAME}. Autopilot active for: ${AUTOPILOT_SYMBOLS.join(', ')}.`);
343
+
344
+ // Monitor feed liveness; alert (weekend-aware) if a feed stalls during market hours.
345
+ setInterval(checkFeedHealth, 5 * 60 * 1000);
346
  }
347
 
348
  // --------------------------------------------------------------------
 
358
  lastClosedTime: tradeManager.lastClosedTime,
359
  lastExecutedCandleTime: tradeManager.lastExecutedCandleTime,
360
  twelveDataActive: false,
361
+ feeds: Object.fromEntries(SYMBOLS.map(s => [s, lastUpdate[s] ? Math.floor((Date.now() - lastUpdate[s]) / 1000) : null])),
362
  });
363
  });
364
 
src/analysis/exitManager.js CHANGED
@@ -84,7 +84,7 @@ export function analyzeExit(candles, trade) {
84
  const pnl = calculatePnL(trade, currentPrice, trade.symbol);
85
 
86
  // --- 2. Current R:R ---
87
- const riskDistance = Math.abs(trade.entry - trade.sl);
88
  const moveFromEntry = (currentPrice - trade.entry) * direction;
89
 
90
  // Guard against micro-stop division anomalies in older trades
@@ -103,12 +103,33 @@ export function analyzeExit(candles, trade) {
103
  let newSL = /** @type {number|null} */ (null);
104
 
105
  if (trade.partialClosed) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  if (currentRR >= 3.0) {
107
  suggestion = 'CLOSE';
108
- reason = `Remaining 30% reached TP2 target (R:R: ${currentRR.toFixed(2)}). Exit position.`;
109
  } else {
110
- suggestion = 'HOLD';
111
- reason = `70% partial profit secured. Stop Loss trailed to Breakeven. Holding remaining 30% for TP2 target.`;
 
 
 
112
  }
113
  } else {
114
  const tp1Reached = trade.type === 'LONG' ? currentPrice >= trade.tp1 : currentPrice <= trade.tp1;
 
84
  const pnl = calculatePnL(trade, currentPrice, trade.symbol);
85
 
86
  // --- 2. Current R:R ---
87
+ const riskDistance = trade.initialRiskDist || Math.abs(trade.entry - trade.sl);
88
  const moveFromEntry = (currentPrice - trade.entry) * direction;
89
 
90
  // Guard against micro-stop division anomalies in older trades
 
103
  let newSL = /** @type {number|null} */ (null);
104
 
105
  if (trade.partialClosed) {
106
+ // Trail the stop on the runner toward the most recent confirmed swing (ratchet),
107
+ // floored at breakeven so the position can never fall back into a loss.
108
+ try {
109
+ const struct = detectStructure(candles);
110
+ const buf = (trade.initialRiskDist || riskDistance) * 0.2;
111
+ if (trade.type === 'LONG') {
112
+ const lows = struct.swingLows.filter(sw => sw.price < currentPrice - buf);
113
+ const ref = lows.length ? lows[lows.length - 1].price - buf : trade.entry;
114
+ newSL = Math.max(trade.entry, ref);
115
+ } else {
116
+ const highs = struct.swingHighs.filter(sw => sw.price > currentPrice + buf);
117
+ const ref = highs.length ? highs[highs.length - 1].price + buf : trade.entry;
118
+ newSL = Math.min(trade.entry, ref);
119
+ }
120
+ } catch {
121
+ newSL = trade.entry;
122
+ }
123
+
124
  if (currentRR >= 3.0) {
125
  suggestion = 'CLOSE';
126
+ reason = `Remaining 30% reached the TP2 target (R:R: ${currentRR.toFixed(2)}). Exit the runner.`;
127
  } else {
128
+ suggestion = 'MOVE_SL';
129
+ const trailed = (trade.type === 'LONG' && newSL > trade.entry) || (trade.type === 'SHORT' && newSL < trade.entry);
130
+ reason = trailed
131
+ ? `70% profit banked. Trailing the stop behind structure to lock in gains on the 30% runner.`
132
+ : `70% profit banked. Stop held at breakeven; holding the 30% runner for TP2.`;
133
  }
134
  } else {
135
  const tp1Reached = trade.type === 'LONG' ? currentPrice >= trade.tp1 : currentPrice <= trade.tp1;
src/analysis/signalGenerator.js CHANGED
@@ -517,6 +517,14 @@ function buildSignal(
517
  if (nextSwingHigh && nextSwingHigh.price >= tp1 && nextSwingHigh.price < tp2) {
518
  tp1 = nextSwingHigh.price;
519
  }
 
 
 
 
 
 
 
 
520
  } else {
521
  const techEntry = lastClose;
522
  let techSl = zoneTop + atrBuffer;
@@ -545,6 +553,13 @@ function buildSignal(
545
  if (nextSwingLow && nextSwingLow.price <= tp1 && nextSwingLow.price > tp2) {
546
  tp1 = nextSwingLow.price;
547
  }
 
 
 
 
 
 
 
548
  }
549
 
550
  // Validate R:R β‰₯ 1:2
 
517
  if (nextSwingHigh && nextSwingHigh.price >= tp1 && nextSwingHigh.price < tp2) {
518
  tp1 = nextSwingHigh.price;
519
  }
520
+
521
+ // Structure-based TP2: extend the final target to the next swing high beyond the
522
+ // 1:3 level (capped at 6R) so we aim for real liquidity, not a fixed multiple.
523
+ const tp2Cap = entry + risk * 6;
524
+ const swingHighTP2 = structure.swingHighs
525
+ .filter(sh => sh.price >= tp2 && sh.price <= tp2Cap)
526
+ .sort((a, b) => a.price - b.price)[0];
527
+ if (swingHighTP2) tp2 = swingHighTP2.price;
528
  } else {
529
  const techEntry = lastClose;
530
  let techSl = zoneTop + atrBuffer;
 
553
  if (nextSwingLow && nextSwingLow.price <= tp1 && nextSwingLow.price > tp2) {
554
  tp1 = nextSwingLow.price;
555
  }
556
+
557
+ // Structure-based TP2: extend down to the next swing low beyond the 1:3 level (capped 6R).
558
+ const tp2Cap = entry - risk * 6;
559
+ const swingLowTP2 = structure.swingLows
560
+ .filter(sl => sl.price <= tp2 && sl.price >= tp2Cap)
561
+ .sort((a, b) => b.price - a.price)[0];
562
+ if (swingLowTP2) tp2 = swingLowTP2.price;
563
  }
564
 
565
  // Validate R:R β‰₯ 1:2
src/components/tradeManager.js CHANGED
@@ -102,6 +102,7 @@ export class TradeManager {
102
  status: 'active',
103
  slMoved: false,
104
  quality: signal.quality || 'A',
 
105
  };
106
 
107
  // Debit commission immediately from the closed balance
@@ -159,7 +160,7 @@ export class TradeManager {
159
 
160
  const direction = trade.type === 'LONG' ? 1 : -1;
161
  const priceDiff = (valuationPrice - trade.entry) * direction;
162
- const slDist = Math.abs(trade.entry - trade.sl);
163
 
164
  // Calculate total P&L (realized + remaining)
165
  const remainingPnL = this._calculatePnL(trade, valuationPrice);
@@ -184,7 +185,7 @@ export class TradeManager {
184
  // SL/TP level (not an overshooting tick) so realized risk stays bounded.
185
  if (trade.type === 'LONG') {
186
  if (valuationPrice <= trade.sl) {
187
- const reason = trade.partialClosed ? 'Breakeven Hit' : 'SL Hit';
188
  this._closeTrade(trade, reason, trade.sl);
189
  } else if (valuationPrice >= trade.tp2) {
190
  if (!trade.partialClosed) {
@@ -196,7 +197,7 @@ export class TradeManager {
196
  }
197
  } else {
198
  if (valuationPrice >= trade.sl) {
199
- const reason = trade.partialClosed ? 'Breakeven Hit' : 'SL Hit';
200
  this._closeTrade(trade, reason, trade.sl);
201
  } else if (valuationPrice <= trade.tp2) {
202
  if (!trade.partialClosed) {
@@ -232,26 +233,45 @@ export class TradeManager {
232
  trade.suggestionText = null;
233
  }
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  // Warnings/suggestions are ephemeral display state recomputed every tick, so
236
  // just re-render β€” no need to write them to the database on every tick.
237
  this._render();
238
  }
239
 
240
  _generatePostMortem(trade) {
241
- let auditText = '';
242
  const decs = this._getDecimals(trade.symbol);
 
 
 
 
 
 
 
 
 
243
 
244
- if (trade.type === 'LONG') {
245
- auditText += `β€’ <b>Liquidity Sweep:</b> The market conducted a deep liquidity sweep below the recent unmitigated demand zone/order block at <code>$${trade.sl.toFixed(decs)}</code>. Price tapped deeper buy orders before potentially stabilizing.\n` +
246
- `β€’ <b>Bearish Momentum:</b> Short-term selling pressure spiked on high volume, invalidating our bullish structure.\n` +
247
- `β€’ <b>Indicator Divergence Sweep:</b> The RSI was swept into deep oversold regions, causing a temporary exhaustion flush that pierced our Stop Loss.`;
248
  } else {
249
- auditText += `β€’ <b>Liquidity Sweep:</b> The market swept above the unmitigated supply zone/order block at <code>$${trade.sl.toFixed(decs)}</code>, hunting buy-stops/liquidity before stabilizing.\n` +
250
- `β€’ <b>Bullish Momentum:</b> Buyers pushed price rapidly on institutional volume, causing a short-term momentum crossover that invalidated our bearish zone.\n` +
251
- `β€’ <b>Indicator Divergence Sweep:</b> RSI overbought levels peaked above extreme limits, causing a final momentum squeeze that hit our Stop Loss.`;
252
  }
253
 
254
- return auditText;
255
  }
256
 
257
  _closeTrade(trade, reason, exitPriceOverride = null) {
 
102
  status: 'active',
103
  slMoved: false,
104
  quality: signal.quality || 'A',
105
+ initialRiskDist: Math.abs(signal.entry - signal.sl),
106
  };
107
 
108
  // Debit commission immediately from the closed balance
 
160
 
161
  const direction = trade.type === 'LONG' ? 1 : -1;
162
  const priceDiff = (valuationPrice - trade.entry) * direction;
163
+ const slDist = trade.initialRiskDist || Math.abs(trade.entry - trade.sl);
164
 
165
  // Calculate total P&L (realized + remaining)
166
  const remainingPnL = this._calculatePnL(trade, valuationPrice);
 
185
  // SL/TP level (not an overshooting tick) so realized risk stays bounded.
186
  if (trade.type === 'LONG') {
187
  if (valuationPrice <= trade.sl) {
188
+ const reason = trade.partialClosed ? 'Trailing Stop' : 'SL Hit';
189
  this._closeTrade(trade, reason, trade.sl);
190
  } else if (valuationPrice >= trade.tp2) {
191
  if (!trade.partialClosed) {
 
197
  }
198
  } else {
199
  if (valuationPrice >= trade.sl) {
200
+ const reason = trade.partialClosed ? 'Trailing Stop' : 'SL Hit';
201
  this._closeTrade(trade, reason, trade.sl);
202
  } else if (valuationPrice <= trade.tp2) {
203
  if (!trade.partialClosed) {
 
233
  trade.suggestionText = null;
234
  }
235
 
236
+ // Apply a trailed stop on the runner (ratchet only β€” never loosen, never cross
237
+ // back to the loss side of breakeven). Authoritative on the server engine.
238
+ if (analysis.newSL != null && trade.partialClosed && this.isServer) {
239
+ const better = trade.type === 'LONG'
240
+ ? (analysis.newSL > trade.sl && analysis.newSL >= trade.entry)
241
+ : (analysis.newSL < trade.sl && analysis.newSL <= trade.entry);
242
+ if (better) {
243
+ trade.sl = analysis.newSL;
244
+ trade.slMoved = true;
245
+ this._saveActiveTrades();
246
+ }
247
+ }
248
+
249
  // Warnings/suggestions are ephemeral display state recomputed every tick, so
250
  // just re-render β€” no need to write them to the database on every tick.
251
  this._render();
252
  }
253
 
254
  _generatePostMortem(trade) {
 
255
  const decs = this._getDecimals(trade.symbol);
256
+ const lines = [];
257
+
258
+ // Factual invalidation summary β€” no fabricated narrative.
259
+ lines.push(`β€’ <b>Invalidation:</b> Price hit the Stop Loss at <code>$${trade.sl.toFixed(decs)}</code> (exit <code>$${(trade.exitPrice ?? trade.sl).toFixed(decs)}</code>), invalidating the ${trade.type} setup.`);
260
+
261
+ if (trade.partialClosed) {
262
+ const realized = trade.realizedPnL || 0;
263
+ lines.push(`β€’ <b>Partial Banked First:</b> TP1 was reached β€” 70% closed for <code>+$${realized.toFixed(2)}</code> before the runner was stopped, so this was not a full-risk loss.`);
264
+ }
265
 
266
+ // Real warning signals the exit analyser flagged before the stop (from analyzeExit).
267
+ if (Array.isArray(trade.warnings) && trade.warnings.length > 0) {
268
+ lines.push(`β€’ <b>Warning signs flagged before the stop:</b>`);
269
+ for (const w of trade.warnings) lines.push(` – ${w}`);
270
  } else {
271
+ lines.push(`β€’ <b>No reversal warnings were flagged</b> before the stop β€” price simply traded to the predefined invalidation level.`);
 
 
272
  }
273
 
274
+ return lines.join('\n');
275
  }
276
 
277
  _closeTrade(trade, reason, exitPriceOverride = null) {