Refine strategy simulation with pyramiding, partial exits, and fixed marker logic

- Implement position pyramiding and 15% partial profit-taking
- Add mutual exclusion between entry and exit in the same candle
- Optimize simulation to use cached indicator results
- Revert Hurst Bands signal logic to cross-down (dip entry)
- Add safety filter for chart markers to prevent rendering errors
This commit is contained in:
DiTus
2026-03-03 15:39:59 +01:00
parent d92af6903d
commit 633a146e8e
3 changed files with 120 additions and 99 deletions

View File

@ -70,21 +70,21 @@ function calculateHurstSignal(indicator, lastCandle, prevCandle, values, prevVal
return null; return null;
} }
// BUY: Price crosses DOWN through lower Hurst Band // BUY: Price crosses DOWN through lower Hurst Band (dip entry)
if (prevClose > prevLower && close <= lower) { if (prevClose > prevLower && close <= lower) {
return { return {
type: 'buy', type: 'buy',
strength: 75, strength: 80,
value: close, value: close,
reasoning: `Price crossed DOWN through lower Hurst Band` reasoning: `Price crossed DOWN through lower Hurst Band`
}; };
} }
// SELL: Price crosses DOWN through upper Hurst Band (reversal from top) // SELL: Price crosses DOWN through upper Hurst Band (reversal entry)
if (prevClose > prevUpper && close <= upper) { if (prevClose > prevUpper && close <= upper) {
return { return {
type: 'sell', type: 'sell',
strength: 75, strength: 80,
value: close, value: close,
reasoning: `Price crossed DOWN through upper Hurst Band` reasoning: `Price crossed DOWN through upper Hurst Band`
}; };

View File

@ -604,10 +604,14 @@ async loadSignals() {
// Merge simulation markers if present // Merge simulation markers if present
if (this.simulationMarkers && this.simulationMarkers.length > 0) { if (this.simulationMarkers && this.simulationMarkers.length > 0) {
markers = [...markers, ...this.simulationMarkers]; markers = [...markers, ...this.simulationMarkers];
// Re-sort combined markers by time
markers.sort((a, b) => a.time - b.time);
} }
// CRITICAL: Filter out any markers with invalid timestamps before passing to chart
markers = markers.filter(m => m && m.time !== null && m.time !== undefined && !isNaN(m.time));
// Re-sort combined markers by time
markers.sort((a, b) => a.time - b.time);
// If we have a marker controller, update markers through it // If we have a marker controller, update markers through it
if (this.markerController) { if (this.markerController) {
try { try {

View File

@ -149,128 +149,141 @@ async function runSimulation() {
for (const indId of [...new Set([...config.openIndicators, ...config.closeIndicators])]) { for (const indId of [...new Set([...config.openIndicators, ...config.closeIndicators])]) {
const ind = activeIndicators.find(a => a.id === indId); const ind = activeIndicators.find(a => a.id === indId);
const IndicatorClass = IndicatorRegistry[ind.type];
const signalFunc = getSignalFunction(ind.type); const signalFunc = getSignalFunction(ind.type);
const results = ind.cachedResults; // Use already calculated results from chart
if (IndicatorClass && signalFunc) { if (results && signalFunc) {
const instance = new IndicatorClass(ind); // Map results to simCandles indices
const results = instance.calculate(candles); // Calculate on FULL history for correctness
// Map full history results to simCandles indices
const simSignals = simCandles.map(candle => { const simSignals = simCandles.map(candle => {
const idx = candles.findIndex(c => c.time === candle.time); const idx = candles.findIndex(c => c.time === candle.time);
if (idx < 1) return null; if (idx < 1) return null;
const res = results[idx]; const res = results[idx];
const prevRes = results[idx-1]; const prevRes = results[idx-1];
const values = typeof res === 'object' ? res : { ma: res };
const prevValues = typeof prevRes === 'object' ? prevRes : { ma: prevRes }; // Standardize result format (some indicators return objects, some return single values)
const values = typeof res === 'object' && res !== null ? res : { ma: res };
const prevValues = typeof prevRes === 'object' && prevRes !== null ? prevRes : { ma: prevRes };
return signalFunc(ind, candles[idx], candles[idx-1], values, prevValues); return signalFunc(ind, candles[idx], candles[idx-1], values, prevValues);
}); });
indicatorSignals[indId] = simSignals; indicatorSignals[indId] = simSignals;
} else {
console.warn(`[Simulation] Missing cached data or signal function for ${indId}`);
} }
} }
// Simulation loop // Simulation loop
let balance = config.capital; let balance = config.capital;
let equity = [{ time: simCandles[0].time, value: balance }]; let equity = [{ time: simCandles[0].time, value: balance }];
let positions = []; // { entryPrice, size, type, entryTime } let totalSize = 0; // Total position size in USD
let trades = []; // { type, entryTime, exitTime, entryPrice, exitPrice, pnl, result } let avgPrice = 0;
let trades = []; // { type, entryTime, exitTime, entryPrice, exitPrice, pnl, reason }
const PARTIAL_EXIT_PCT = 0.15; // 15% partial exit
for (let i = 0; i < simCandles.length; i++) { for (let i = 0; i < simCandles.length; i++) {
const candle = simCandles[i]; const candle = simCandles[i];
const price = candle.close; const price = candle.close;
let actionTakenInThisCandle = false;
// 1. Check TP for existing positions // 1. Check TP for total position
for (let j = positions.length - 1; j >= 0; j--) { if (totalSize > 0) {
const pos = positions[j]; let isTP = false;
let isClosed = false;
let exitPrice = price; let exitPrice = price;
let reason = '';
// TP Logic if (config.direction === 'long') {
if (pos.type === 'long') { if (candle.high >= avgPrice * (1 + config.tp)) {
if (candle.high >= pos.entryPrice * (1 + config.tp)) { isTP = true;
isClosed = true; exitPrice = avgPrice * (1 + config.tp);
exitPrice = pos.entryPrice * (1 + config.tp);
reason = 'TP';
} }
} else { } else {
if (candle.low <= pos.entryPrice * (1 - config.tp)) { if (candle.low <= avgPrice * (1 - config.tp)) {
isClosed = true; isTP = true;
exitPrice = pos.entryPrice * (1 - config.tp); exitPrice = avgPrice * (1 - config.tp);
reason = 'TP';
} }
} }
// Close Signal Logic if (isTP) {
if (!isClosed && config.closeIndicators.length > 0) { const amountToClose = totalSize * PARTIAL_EXIT_PCT;
const hasCloseSignal = config.closeIndicators.some(id => { const pnl = config.direction === 'long'
const sig = indicatorSignals[id][i]; ? (exitPrice - avgPrice) / avgPrice * amountToClose * config.leverage
if (!sig) return false; : (avgPrice - exitPrice) / avgPrice * amountToClose * config.leverage;
// Short: logic is inverted
if (config.direction === 'long') {
return sig.type === 'sell'; // Sell signal closes long
} else {
return sig.type === 'buy'; // Buy signal closes short
}
});
if (hasCloseSignal) {
isClosed = true;
reason = 'Signal';
}
}
if (isClosed) {
const pnl = pos.type === 'long'
? (exitPrice - pos.entryPrice) / pos.entryPrice * pos.size * config.leverage
: (pos.entryPrice - exitPrice) / pos.entryPrice * pos.size * config.leverage;
balance += pnl; balance += pnl;
trades.push({ trades.push({
type: pos.type, type: config.direction,
entryTime: pos.entryTime, recordType: 'exit',
exitTime: candle.time, time: candle.time,
entryPrice: pos.entryPrice, entryPrice: avgPrice,
exitPrice: exitPrice, exitPrice: exitPrice,
pnl: pnl, pnl: pnl,
reason: reason reason: 'TP (Partial)'
}); });
positions.splice(j, 1); totalSize -= amountToClose;
actionTakenInThisCandle = true;
} }
} }
// 2. Check Open Signals // 2. Check Close Signals (Partial Exit) - Only if TP didn't trigger
const hasOpenSignal = config.openIndicators.some(id => { if (!actionTakenInThisCandle && totalSize > 0 && config.closeIndicators.length > 0) {
const sig = indicatorSignals[id][i]; const hasCloseSignal = config.closeIndicators.some(id => {
if (!sig) return false; const sig = indicatorSignals[id][i];
if (!sig) return false;
if (config.direction === 'long') { return config.direction === 'long' ? sig.type === 'sell' : sig.type === 'buy';
return sig.type === 'buy';
} else {
return sig.type === 'sell';
}
});
// Ping-Pong Mode: Only 1 active position allowed
// Accumulation Mode (no close indicators): Multiple positions allowed
const isAccumulation = config.closeIndicators.length === 0;
const canOpen = isAccumulation || positions.length === 0;
if (hasOpenSignal && canOpen && balance >= config.posSize) {
positions.push({
type: config.direction,
entryPrice: price,
size: config.posSize,
entryTime: candle.time
}); });
if (hasCloseSignal) {
const amountToClose = totalSize * PARTIAL_EXIT_PCT;
const pnl = config.direction === 'long'
? (price - avgPrice) / avgPrice * amountToClose * config.leverage
: (avgPrice - price) / avgPrice * amountToClose * config.leverage;
balance += pnl;
trades.push({
type: config.direction,
recordType: 'exit',
time: candle.time,
entryPrice: avgPrice,
exitPrice: price,
pnl: pnl,
reason: 'Signal (Partial)'
});
totalSize -= amountToClose;
actionTakenInThisCandle = true;
}
} }
equity.push({ time: candle.time, value: balance }); // 3. Check Open Signals (Pyramiding) - Only if no exit happened in this candle
if (!actionTakenInThisCandle) {
const hasOpenSignal = config.openIndicators.some(id => {
const sig = indicatorSignals[id][i];
if (!sig) return false;
return config.direction === 'long' ? sig.type === 'buy' : sig.type === 'sell';
});
if (hasOpenSignal && balance >= config.posSize) {
const newSize = totalSize + config.posSize;
avgPrice = ((totalSize * avgPrice) + (config.posSize * price)) / newSize;
totalSize = newSize;
trades.push({
type: config.direction,
recordType: 'entry',
time: candle.time,
entryPrice: price,
reason: 'Entry'
});
}
}
// Calculate current equity (realized balance + unrealized PnL)
const unrealizedPnl = totalSize > 0
? (config.direction === 'long' ? (price - avgPrice) / avgPrice : (avgPrice - price) / avgPrice) * totalSize * config.leverage
: 0;
equity.push({ time: candle.time, value: balance + unrealizedPnl });
} }
displayResults(trades, equity, config); displayResults(trades, equity, config);
@ -380,22 +393,26 @@ function toggleSimulationMarkers(trades) {
const markers = []; const markers = [];
trades.forEach(t => { trades.forEach(t => {
// Entry marker // Entry marker
markers.push({ if (t.recordType === 'entry') {
time: t.entryTime, markers.push({
position: t.type === 'long' ? 'belowBar' : 'aboveBar', time: t.time,
color: t.type === 'long' ? '#2962ff' : '#9c27b0', position: t.type === 'long' ? 'belowBar' : 'aboveBar',
shape: t.type === 'long' ? 'arrowUp' : 'arrowDown', color: t.type === 'long' ? '#2962ff' : '#9c27b0',
text: `Entry ${t.type.toUpperCase()}` shape: t.type === 'long' ? 'arrowUp' : 'arrowDown',
}); text: `Entry ${t.type.toUpperCase()}`
});
}
// Exit marker // Exit marker
markers.push({ if (t.recordType === 'exit') {
time: t.exitTime, markers.push({
position: t.type === 'long' ? 'aboveBar' : 'belowBar', time: t.time,
color: t.pnl >= 0 ? '#26a69a' : '#ef5350', position: t.type === 'long' ? 'aboveBar' : 'belowBar',
shape: t.type === 'long' ? 'arrowDown' : 'arrowUp', color: t.pnl >= 0 ? '#26a69a' : '#ef5350',
text: `Exit ${t.reason} ($${t.pnl.toFixed(2)})` shape: t.type === 'long' ? 'arrowDown' : 'arrowUp',
}); text: `Exit ${t.reason} ($${t.pnl.toFixed(2)})`
});
}
}); });
// Sort markers by time // Sort markers by time