Spaces:
Running
Running
Commit Β·
a547315
1
Parent(s): bcf649d
feat: add feed health monitoring and structure-based TP2/trailing stop rules
Browse files- server.js +38 -0
- src/analysis/exitManager.js +25 -4
- src/analysis/signalGenerator.js +15 -0
- src/components/tradeManager.js +32 -12
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
|
| 109 |
} else {
|
| 110 |
-
suggestion = '
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
| 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 ? '
|
| 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 ? '
|
| 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 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
} else {
|
| 249 |
-
|
| 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
|
| 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) {
|