feat: implement MVP of client-side strategy architecture
- Added /api/v1/candles/bulk endpoint for high-performance data download - Implemented ClientStrategyEngine in JS with support for 7 indicators - Added Multi-Timeframe support for strategy confirmation - Added Risk Management module (Risk % per trade, Stop Loss %) - Overhauled dashboard simulation UI with TF selector and risk inputs - Removed server-side backtest dependency for simulations
This commit is contained in:
@ -632,6 +632,382 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// --- Indicator Library ---
|
||||||
|
class BaseIndicator {
|
||||||
|
constructor(config) {
|
||||||
|
this.name = config.name;
|
||||||
|
this.type = config.type;
|
||||||
|
this.params = config.params || {};
|
||||||
|
this.timeframe = config.timeframe || '1m';
|
||||||
|
}
|
||||||
|
calculate(candles) { throw new Error("Not implemented"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class SMAIndicator extends BaseIndicator {
|
||||||
|
calculate(candles) {
|
||||||
|
const period = this.params.period || 44;
|
||||||
|
const results = new Array(candles.length).fill(null);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < candles.length; i++) {
|
||||||
|
sum += candles[i].close;
|
||||||
|
if (i >= period) sum -= candles[i - period].close;
|
||||||
|
if (i >= period - 1) results[i] = sum / period;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EMAIndicator extends BaseIndicator {
|
||||||
|
calculate(candles) {
|
||||||
|
const period = this.params.period || 44;
|
||||||
|
const multiplier = 2 / (period + 1);
|
||||||
|
const results = new Array(candles.length).fill(null);
|
||||||
|
let ema = 0;
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < candles.length; i++) {
|
||||||
|
if (i < period) {
|
||||||
|
sum += candles[i].close;
|
||||||
|
if (i === period - 1) {
|
||||||
|
ema = sum / period;
|
||||||
|
results[i] = ema;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ema = (candles[i].close - ema) * multiplier + ema;
|
||||||
|
results[i] = ema;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RSIIndicator extends BaseIndicator {
|
||||||
|
calculate(candles) {
|
||||||
|
const period = this.params.period || 14;
|
||||||
|
const results = new Array(candles.length).fill(null);
|
||||||
|
let gains = 0, losses = 0;
|
||||||
|
for (let i = 1; i < candles.length; i++) {
|
||||||
|
const diff = candles[i].close - candles[i-1].close;
|
||||||
|
const gain = diff > 0 ? diff : 0;
|
||||||
|
const loss = diff < 0 ? -diff : 0;
|
||||||
|
if (i <= period) {
|
||||||
|
gains += gain;
|
||||||
|
losses += loss;
|
||||||
|
if (i === period) {
|
||||||
|
let avgGain = gains / period;
|
||||||
|
let avgLoss = losses / period;
|
||||||
|
results[i] = 100 - (100 / (1 + (avgGain / (avgLoss || 0.00001))));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const lastAvgGain = (results[i-1] ? (results[i-1] > 0 ? (period-1) * (results[i-1] * (gains+losses)/100) : 0) : 0); // Simplified
|
||||||
|
// Wilder's smoothing
|
||||||
|
// Note: This is a simplified RSI for MVP
|
||||||
|
gains = (gains * (period - 1) + gain) / period;
|
||||||
|
losses = (losses * (period - 1) + loss) / period;
|
||||||
|
results[i] = 100 - (100 / (1 + (gains / (losses || 0.00001))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BollingerBandsIndicator extends BaseIndicator {
|
||||||
|
calculate(candles) {
|
||||||
|
const period = this.params.period || 20;
|
||||||
|
const stdDevMult = this.params.stdDev || 2;
|
||||||
|
const results = new Array(candles.length).fill(null);
|
||||||
|
|
||||||
|
for (let i = period - 1; i < candles.length; i++) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let j = 0; j < period; j++) sum += candles[i-j].close;
|
||||||
|
const sma = sum / period;
|
||||||
|
|
||||||
|
let diffSum = 0;
|
||||||
|
for (let j = 0; j < period; j++) diffSum += Math.pow(candles[i-j].close - sma, 2);
|
||||||
|
const stdDev = Math.sqrt(diffSum / period);
|
||||||
|
|
||||||
|
results[i] = {
|
||||||
|
middle: sma,
|
||||||
|
upper: sma + (stdDevMult * stdDev),
|
||||||
|
lower: sma - (stdDevMult * stdDev)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MACDIndicator extends BaseIndicator {
|
||||||
|
calculate(candles) {
|
||||||
|
const fast = this.params.fast || 12;
|
||||||
|
const slow = this.params.slow || 26;
|
||||||
|
const signal = this.params.signal || 9;
|
||||||
|
|
||||||
|
const fastEmaInd = new EMAIndicator({ params: { period: fast } });
|
||||||
|
const slowEmaInd = new EMAIndicator({ params: { period: slow } });
|
||||||
|
|
||||||
|
const fastEma = fastEmaInd.calculate(candles);
|
||||||
|
const slowEma = slowEmaInd.calculate(candles);
|
||||||
|
|
||||||
|
const macdLine = fastEma.map((f, i) => (f !== null && slowEma[i] !== null) ? f - slowEma[i] : null);
|
||||||
|
|
||||||
|
// Signal line is EMA of MACD line
|
||||||
|
const signalLine = new Array(candles.length).fill(null);
|
||||||
|
const multiplier = 2 / (signal + 1);
|
||||||
|
let ema = 0;
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < macdLine.length; i++) {
|
||||||
|
if (macdLine[i] === null) continue;
|
||||||
|
count++;
|
||||||
|
if (count < signal) {
|
||||||
|
sum += macdLine[i];
|
||||||
|
} else if (count === signal) {
|
||||||
|
sum += macdLine[i];
|
||||||
|
ema = sum / signal;
|
||||||
|
signalLine[i] = ema;
|
||||||
|
} else {
|
||||||
|
ema = (macdLine[i] - ema) * multiplier + ema;
|
||||||
|
signalLine[i] = ema;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return macdLine.map((m, i) => ({
|
||||||
|
macd: m,
|
||||||
|
signal: signalLine[i],
|
||||||
|
histogram: (m !== null && signalLine[i] !== null) ? m - signalLine[i] : null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StochasticIndicator extends BaseIndicator {
|
||||||
|
calculate(candles) {
|
||||||
|
const kPeriod = this.params.kPeriod || 14;
|
||||||
|
const dPeriod = this.params.dPeriod || 3;
|
||||||
|
const results = new Array(candles.length).fill(null);
|
||||||
|
|
||||||
|
const kValues = new Array(candles.length).fill(null);
|
||||||
|
|
||||||
|
for (let i = kPeriod - 1; i < candles.length; i++) {
|
||||||
|
let lowest = Infinity;
|
||||||
|
let highest = -Infinity;
|
||||||
|
for (let j = 0; j < kPeriod; j++) {
|
||||||
|
lowest = Math.min(lowest, candles[i-j].low);
|
||||||
|
highest = Math.max(highest, candles[i-j].high);
|
||||||
|
}
|
||||||
|
const diff = highest - lowest;
|
||||||
|
kValues[i] = diff === 0 ? 50 : ((candles[i].close - lowest) / diff) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// D is SMA of K
|
||||||
|
for (let i = kPeriod + dPeriod - 2; i < candles.length; i++) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let j = 0; j < dPeriod; j++) sum += kValues[i-j];
|
||||||
|
results[i] = { k: kValues[i], d: sum / dPeriod };
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ATRIndicator extends BaseIndicator {
|
||||||
|
calculate(candles) {
|
||||||
|
const period = this.params.period || 14;
|
||||||
|
const results = new Array(candles.length).fill(null);
|
||||||
|
const tr = new Array(candles.length).fill(0);
|
||||||
|
|
||||||
|
for (let i = 1; i < candles.length; i++) {
|
||||||
|
const h_l = candles[i].high - candles[i].low;
|
||||||
|
const h_pc = Math.abs(candles[i].high - candles[i-1].close);
|
||||||
|
const l_pc = Math.abs(candles[i].low - candles[i-1].close);
|
||||||
|
tr[i] = Math.max(h_l, h_pc, l_pc);
|
||||||
|
}
|
||||||
|
|
||||||
|
let atr = 0;
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 1; i <= period; i++) sum += tr[i];
|
||||||
|
atr = sum / period;
|
||||||
|
results[period] = atr;
|
||||||
|
|
||||||
|
for (let i = period + 1; i < candles.length; i++) {
|
||||||
|
atr = (atr * (period - 1) + tr[i]) / period;
|
||||||
|
results[i] = atr;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Risk Management ---
|
||||||
|
class RiskManager {
|
||||||
|
constructor(config, initialBalance = 1000) {
|
||||||
|
this.config = config || {
|
||||||
|
positionSizing: { method: 'percent', value: 0.1 }, // 10%
|
||||||
|
stopLoss: { enabled: true, method: 'percent', value: 0.02 }, // 2%
|
||||||
|
takeProfit: { enabled: true, method: 'percent', value: 0.04 } // 4%
|
||||||
|
};
|
||||||
|
this.balance = initialBalance;
|
||||||
|
this.equity = initialBalance;
|
||||||
|
}
|
||||||
|
calculateSize(price) {
|
||||||
|
if (this.config.positionSizing.method === 'percent') {
|
||||||
|
return (this.balance * this.config.positionSizing.value) / price;
|
||||||
|
}
|
||||||
|
return this.config.positionSizing.value / price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Strategy Engine ---
|
||||||
|
class ClientStrategyEngine {
|
||||||
|
constructor() {
|
||||||
|
this.indicatorTypes = {
|
||||||
|
'sma': SMAIndicator,
|
||||||
|
'ema': EMAIndicator,
|
||||||
|
'rsi': RSIIndicator,
|
||||||
|
'bb': BollingerBandsIndicator,
|
||||||
|
'macd': MACDIndicator,
|
||||||
|
'stoch': StochasticIndicator,
|
||||||
|
'atr': ATRIndicator
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
run(candlesMap, strategyConfig, riskConfig) {
|
||||||
|
const primaryTF = strategyConfig.timeframes?.primary || '1d';
|
||||||
|
const candles = candlesMap[primaryTF];
|
||||||
|
if (!candles) return { error: `No candles for primary timeframe ${primaryTF}` };
|
||||||
|
|
||||||
|
// 1. Calculate Indicators for all TFs
|
||||||
|
const indicatorResults = {};
|
||||||
|
for (const tf in candlesMap) {
|
||||||
|
indicatorResults[tf] = {};
|
||||||
|
const tfCandles = candlesMap[tf];
|
||||||
|
const tfIndicators = (strategyConfig.indicators || []).filter(ind => (ind.timeframe || primaryTF) === tf);
|
||||||
|
|
||||||
|
for (const ind of tfIndicators) {
|
||||||
|
const IndicatorClass = this.indicatorTypes[ind.type];
|
||||||
|
if (IndicatorClass) {
|
||||||
|
const instance = new IndicatorClass(ind);
|
||||||
|
indicatorResults[tf][ind.name] = instance.calculate(tfCandles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Run Simulation
|
||||||
|
const risk = new RiskManager(riskConfig);
|
||||||
|
const trades = [];
|
||||||
|
let position = null;
|
||||||
|
|
||||||
|
for (let i = 1; i < candles.length; i++) {
|
||||||
|
const price = candles[i].close;
|
||||||
|
const time = candles[i].time;
|
||||||
|
|
||||||
|
const signal = this.evaluate(i, candles, candlesMap, indicatorResults, strategyConfig, position);
|
||||||
|
|
||||||
|
if (signal === 'BUY' && !position) {
|
||||||
|
const size = risk.calculateSize(price);
|
||||||
|
position = { type: 'long', entryPrice: price, entryTime: time, size };
|
||||||
|
} else if (signal === 'SELL' && position) {
|
||||||
|
const pnl = (price - position.entryPrice) * position.size;
|
||||||
|
trades.push({ ...position, exitPrice: price, exitTime: time, pnl, pnlPct: (pnl / (position.entryPrice * position.size)) * 100 });
|
||||||
|
risk.balance += pnl;
|
||||||
|
position = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_trades: trades.length,
|
||||||
|
win_rate: (trades.filter(t => t.pnl > 0).length / (trades.length || 1)) * 100,
|
||||||
|
total_pnl: risk.balance - 1000,
|
||||||
|
trades
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluate(index, candles, candlesMap, indicatorResults, config, position) {
|
||||||
|
const primaryTF = config.timeframes?.primary || '1d';
|
||||||
|
const currentTime = new Date(candles[index].time).getTime();
|
||||||
|
|
||||||
|
// Helper to get indicator value at specific time for any TF
|
||||||
|
const getVal = (indName, tf) => {
|
||||||
|
const tfCandles = candlesMap[tf];
|
||||||
|
const tfValues = indicatorResults[tf][indName];
|
||||||
|
if (!tfCandles || !tfValues) return null;
|
||||||
|
|
||||||
|
// Find latest candle in TF that is <= currentTime
|
||||||
|
// Simple linear search for MVP, can be binary search
|
||||||
|
let tfIdx = -1;
|
||||||
|
for (let j = 0; j < tfCandles.length; j++) {
|
||||||
|
if (new Date(tfCandles[j].time).getTime() <= currentTime) tfIdx = j;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return tfIdx !== -1 ? tfValues[tfIdx] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get price at specific time for any TF
|
||||||
|
const getPrice = (tf) => {
|
||||||
|
const tfCandles = candlesMap[tf];
|
||||||
|
if (!tfCandles) return null;
|
||||||
|
let tfIdx = -1;
|
||||||
|
for (let j = 0; j < tfCandles.length; j++) {
|
||||||
|
if (new Date(tfCandles[j].time).getTime() <= currentTime) tfIdx = j;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return tfIdx !== -1 ? tfCandles[tfIdx].close : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple logic for MVP strategies
|
||||||
|
if (config.id === 'ma44_strategy') {
|
||||||
|
const ma44 = getVal('ma44', primaryTF);
|
||||||
|
const price = candles[index].close;
|
||||||
|
|
||||||
|
// Optional: Multi-TF trend filter
|
||||||
|
const secondaryTF = config.timeframes?.secondary?.[0];
|
||||||
|
const secondaryMA = secondaryTF ? getVal(`ma44_${secondaryTF}`, secondaryTF) : null;
|
||||||
|
const secondaryPrice = secondaryTF ? getPrice(secondaryTF) : null;
|
||||||
|
const trendOk = !secondaryTF || (secondaryPrice > secondaryMA);
|
||||||
|
|
||||||
|
if (ma44) {
|
||||||
|
if (price > ma44 && trendOk) return 'BUY';
|
||||||
|
if (price < ma44) return 'SELL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.id === 'ma125_strategy') {
|
||||||
|
const ma125 = getVal('ma125', primaryTF);
|
||||||
|
const price = candles[index].close;
|
||||||
|
if (ma125) {
|
||||||
|
if (price > ma125) return 'BUY';
|
||||||
|
if (price < ma125) return 'SELL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic Logic for custom strategies
|
||||||
|
const evaluateConditions = (conds) => {
|
||||||
|
if (!conds || !conds.conditions) return false;
|
||||||
|
const results = conds.conditions.map(c => {
|
||||||
|
const leftVal = c.indicator === 'price' ? getPrice(c.timeframe || primaryTF) : getVal(c.indicator, c.timeframe || primaryTF);
|
||||||
|
const rightVal = typeof c.value === 'number' ? c.value : (c.value === 'price' ? getPrice(c.timeframe || primaryTF) : getVal(c.value, c.timeframe || primaryTF));
|
||||||
|
|
||||||
|
if (leftVal === null || rightVal === null) return false;
|
||||||
|
|
||||||
|
switch(c.operator) {
|
||||||
|
case '>': return leftVal > rightVal;
|
||||||
|
case '<': return leftVal < rightVal;
|
||||||
|
case '>=': return leftVal >= rightVal;
|
||||||
|
case '<=': return leftVal <= rightVal;
|
||||||
|
case '==': return leftVal == rightVal;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conds.logic === 'OR') return results.some(r => r);
|
||||||
|
return results.every(r => r);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (evaluateConditions(config.entryLong)) return 'BUY';
|
||||||
|
if (evaluateConditions(config.exitLong)) return 'SELL';
|
||||||
|
|
||||||
|
return 'HOLD';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class TradingDashboard {
|
class TradingDashboard {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.chart = null;
|
this.chart = null;
|
||||||
@ -997,14 +1373,40 @@
|
|||||||
<div class="ta-section" id="simulationPanel">
|
<div class="ta-section" id="simulationPanel">
|
||||||
<div class="ta-section-title">Strategy Simulation</div>
|
<div class="ta-section-title">Strategy Simulation</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">
|
||||||
<!-- Date picker -->
|
<!-- Date picker -->
|
||||||
<div class="sim-input-group" style="margin: 0 0 8px 0;">
|
<div class="sim-input-group" style="margin: 0;">
|
||||||
<label style="font-size: 10px; text-transform: uppercase; color: var(--tv-text-secondary);">Start Date:</label>
|
<label style="font-size: 9px; text-transform: uppercase; color: var(--tv-text-secondary);">Start Date:</label>
|
||||||
<input type="datetime-local" id="simStartDate" class="sim-input" style="margin-top: 2px;">
|
<input type="datetime-local" id="simStartDate" class="sim-input" style="padding: 4px; font-size: 11px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Secondary TF -->
|
||||||
|
<div class="sim-input-group" style="margin: 0;">
|
||||||
|
<label style="font-size: 9px; text-transform: uppercase; color: var(--tv-text-secondary);">Confirmation TF:</label>
|
||||||
|
<select id="simSecondaryTF" class="sim-input" style="padding: 4px; font-size: 11px;">
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="1h">1h</option>
|
||||||
|
<option value="4h">4h</option>
|
||||||
|
<option value="1d" selected>1d</option>
|
||||||
|
<option value="1w">1w</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Risk Settings -->
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">
|
||||||
|
<div class="sim-input-group" style="margin: 0;">
|
||||||
|
<label style="font-size: 9px; text-transform: uppercase; color: var(--tv-text-secondary);">Risk % per Trade:</label>
|
||||||
|
<input type="number" id="simRiskPercent" class="sim-input" value="2" min="0.1" max="100" step="0.1" style="padding: 4px; font-size: 11px;">
|
||||||
|
</div>
|
||||||
|
<div class="sim-input-group" style="margin: 0;">
|
||||||
|
<label style="font-size: 9px; text-transform: uppercase; color: var(--tv-text-secondary);">Stop Loss %:</label>
|
||||||
|
<input type="number" id="simStopLoss" class="sim-input" value="2" min="0.1" max="20" step="0.1" style="padding: 4px; font-size: 11px;">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Strategies loaded dynamically here -->
|
<!-- Strategies loaded dynamically here -->
|
||||||
<div id="strategyList" class="sim-strategies" style="max-height: 100px; overflow-y: auto;">
|
<div id="strategyList" class="sim-strategies" style="max-height: 80px; overflow-y: auto; border: 1px solid var(--tv-border); border-radius: 4px; padding: 2px;">
|
||||||
<div class="loading-strategies">Loading strategies...</div>
|
<div class="loading-strategies">Loading strategies...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1105,6 +1507,7 @@
|
|||||||
throw new Error('Invalid response format: missing strategies array');
|
throw new Error('Invalid response format: missing strategies array');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.availableStrategies = data.strategies;
|
||||||
renderStrategies(data.strategies);
|
renderStrategies(data.strategies);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading strategies:', error);
|
console.error('Error loading strategies:', error);
|
||||||
@ -1146,147 +1549,114 @@
|
|||||||
document.getElementById('runSimBtn').disabled = false;
|
document.getElementById('runSimBtn').disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run simulation
|
// Run simulation (Client-Side)
|
||||||
async function runSimulation() {
|
async function runSimulation() {
|
||||||
const selectedStrategy = document.querySelector('input[name="strategy"]:checked');
|
const selectedStrategy = document.querySelector('input[name="strategy"]:checked');
|
||||||
|
|
||||||
if (!selectedStrategy) {
|
if (!selectedStrategy) {
|
||||||
alert('Please select a strategy');
|
alert('Please select a strategy');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const strategyId = selectedStrategy.value;
|
const strategyId = selectedStrategy.value;
|
||||||
|
const strategy = (window.availableStrategies || []).find(s => s.id === strategyId);
|
||||||
const startDateInput = document.getElementById('simStartDate').value;
|
const startDateInput = document.getElementById('simStartDate').value;
|
||||||
|
|
||||||
if (!startDateInput) {
|
if (!startDateInput) {
|
||||||
alert('Please select a start date');
|
alert('Please select a start date');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format date for API
|
|
||||||
const startDate = new Date(startDateInput).toISOString().split('T')[0];
|
|
||||||
|
|
||||||
// Disable button during simulation
|
|
||||||
const runBtn = document.getElementById('runSimBtn');
|
const runBtn = document.getElementById('runSimBtn');
|
||||||
runBtn.disabled = true;
|
|
||||||
runBtn.textContent = 'Running...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Trigger backtest via API
|
|
||||||
const response = await fetch('/api/v1/backtests', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
symbol: 'BTC',
|
|
||||||
intervals: [window.dashboard?.currentInterval || '1d'],
|
|
||||||
start_date: startDate
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.message) {
|
|
||||||
// Show that simulation is running
|
|
||||||
runBtn.textContent = 'Running...';
|
|
||||||
|
|
||||||
// Poll for results
|
|
||||||
setTimeout(() => {
|
|
||||||
pollForBacktestResults(strategyId, startDate);
|
|
||||||
}, 2000); // Wait 2 seconds then poll
|
|
||||||
} else {
|
|
||||||
alert('Failed to start simulation');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error running simulation:', error);
|
|
||||||
alert('Error running simulation: ' + error.message);
|
|
||||||
// Reset button only on error
|
|
||||||
runBtn.disabled = false;
|
|
||||||
runBtn.textContent = 'Run Simulation';
|
|
||||||
}
|
|
||||||
// Button stays as "Running..." until polling completes or times out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll for backtest results
|
|
||||||
async function pollForBacktestResults(strategyId, startDate, attempts = 0) {
|
|
||||||
const runBtn = document.getElementById('runSimBtn');
|
|
||||||
|
|
||||||
if (attempts > 30) { // Stop after 30 attempts (60 seconds)
|
|
||||||
console.log('Backtest polling timeout');
|
|
||||||
runBtn.textContent = 'Run Simulation';
|
|
||||||
runBtn.disabled = false;
|
|
||||||
|
|
||||||
// Show timeout message in results area
|
|
||||||
const simResults = document.getElementById('simResults');
|
const simResults = document.getElementById('simResults');
|
||||||
if (simResults) {
|
runBtn.disabled = true;
|
||||||
simResults.innerHTML = `
|
runBtn.textContent = 'Downloading...';
|
||||||
<div class="sim-stat-row" style="color: var(--tv-text-secondary); font-size: 11px; text-align: center;">
|
simResults.style.display = 'none';
|
||||||
<span>Simulation timeout - no results found after 60s.<br>Check server logs or try again.</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
simResults.style.display = 'block';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/backtests?limit=5');
|
const interval = window.dashboard?.currentInterval || '1d';
|
||||||
const backtests = await response.json();
|
const secondaryTF = document.getElementById('simSecondaryTF').value;
|
||||||
|
const riskPercent = parseFloat(document.getElementById('simRiskPercent').value) / 100;
|
||||||
|
const stopLossPercent = parseFloat(document.getElementById('simStopLoss').value) / 100;
|
||||||
|
|
||||||
// Find the most recent backtest that matches our criteria
|
const start = new Date(startDateInput).toISOString();
|
||||||
const recentBacktest = backtests.find(bt =>
|
|
||||||
bt.strategy && bt.strategy.includes(strategyId) ||
|
|
||||||
bt.created_at > new Date(Date.now() - 60000).toISOString() // Created in last minute
|
|
||||||
);
|
|
||||||
|
|
||||||
if (recentBacktest && recentBacktest.results) {
|
// 1. Fetch Bulk Candles
|
||||||
// Parse JSON string if needed (database stores results as text)
|
const timeframes = [interval];
|
||||||
const parsedBacktest = {
|
if (secondaryTF && !timeframes.includes(secondaryTF)) timeframes.push(secondaryTF);
|
||||||
...recentBacktest,
|
|
||||||
results: typeof recentBacktest.results === 'string'
|
// For MA strategies, we might need more data before start date for calculation
|
||||||
? JSON.parse(recentBacktest.results)
|
const fetchStart = new Date(new Date(start).getTime() - (200 * 24 * 60 * 60 * 1000)).toISOString();
|
||||||
: recentBacktest.results
|
|
||||||
};
|
const query = new URLSearchParams({ symbol: 'BTC', start: fetchStart });
|
||||||
// Results found! Display them
|
timeframes.forEach(tf => query.append('timeframes', tf));
|
||||||
displayBacktestResults(parsedBacktest);
|
const response = await fetch(`/api/v1/candles/bulk?${query.toString()}`);
|
||||||
runBtn.textContent = 'Run Simulation';
|
const candlesMap = await response.json();
|
||||||
runBtn.disabled = false;
|
|
||||||
return;
|
if (!candlesMap[interval] || candlesMap[interval].length === 0) {
|
||||||
|
throw new Error('No data found for the selected period');
|
||||||
}
|
}
|
||||||
|
|
||||||
// No results yet, poll again in 2 seconds
|
runBtn.textContent = 'Calculating...';
|
||||||
setTimeout(() => {
|
|
||||||
pollForBacktestResults(strategyId, startDate, attempts + 1);
|
// 2. Setup Strategy Config
|
||||||
}, 2000);
|
const strategyConfig = {
|
||||||
|
id: strategyId,
|
||||||
|
timeframes: { primary: interval, secondary: secondaryTF ? [secondaryTF] : [] },
|
||||||
|
indicators: (strategy?.required_indicators || []).map(name => ({
|
||||||
|
name: name,
|
||||||
|
type: name.startsWith('ma') ? 'sma' : name,
|
||||||
|
params: { period: parseInt(name.replace(/\D/g, '')) || 44 },
|
||||||
|
timeframe: interval // primary by default
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add secondary TF indicators if needed (example: MA44 on secondary TF)
|
||||||
|
if (secondaryTF) {
|
||||||
|
strategyConfig.indicators.push({
|
||||||
|
name: `ma44_${secondaryTF}`,
|
||||||
|
type: 'sma',
|
||||||
|
params: { period: 44 },
|
||||||
|
timeframe: secondaryTF
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Run Engine
|
||||||
|
const riskConfig = {
|
||||||
|
positionSizing: { method: 'percent', value: riskPercent },
|
||||||
|
stopLoss: { enabled: true, method: 'percent', value: stopLossPercent }
|
||||||
|
};
|
||||||
|
|
||||||
|
const engine = new ClientStrategyEngine();
|
||||||
|
const results = engine.run(candlesMap, strategyConfig, riskConfig);
|
||||||
|
|
||||||
|
if (results.error) throw new Error(results.error);
|
||||||
|
|
||||||
|
// 4. Display Results
|
||||||
|
displayBacktestResults({ results });
|
||||||
|
|
||||||
|
console.log(`Simulation complete: ${results.total_trades} trades found.`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error polling for backtest results:', error);
|
console.error('Simulation error:', error);
|
||||||
runBtn.textContent = 'Run Simulation';
|
alert('Simulation error: ' + error.message);
|
||||||
|
} finally {
|
||||||
runBtn.disabled = false;
|
runBtn.disabled = false;
|
||||||
|
runBtn.textContent = 'Run Simulation';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display backtest results in the UI
|
// Display backtest results in the UI
|
||||||
function displayBacktestResults(backtest) {
|
function displayBacktestResults(backtest) {
|
||||||
// Parse JSON string if needed (database stores results as text)
|
const results = backtest.results;
|
||||||
const results = typeof backtest.results === 'string'
|
|
||||||
? JSON.parse(backtest.results)
|
|
||||||
: backtest.results;
|
|
||||||
|
|
||||||
// Update the results display
|
document.getElementById('simTrades').textContent = results.total_trades || '0';
|
||||||
document.getElementById('simTrades').textContent = results.total_trades || '--';
|
document.getElementById('simWinRate').textContent = (results.win_rate || 0).toFixed(1) + '%';
|
||||||
document.getElementById('simWinRate').textContent = results.win_rate ? results.win_rate.toFixed(1) + '%' : '--';
|
|
||||||
|
|
||||||
const pnlElement = document.getElementById('simPnL');
|
const pnlElement = document.getElementById('simPnL');
|
||||||
const pnl = results.total_pnl || 0;
|
const pnl = results.total_pnl || 0;
|
||||||
pnlElement.textContent = (pnl >= 0 ? '+' : '') + '$' + pnl.toFixed(2);
|
pnlElement.textContent = (pnl >= 0 ? '+' : '') + '$' + pnl.toFixed(2);
|
||||||
pnlElement.className = 'sim-value ' + (pnl >= 0 ? 'positive' : 'negative');
|
pnlElement.className = 'sim-value ' + (pnl >= 0 ? 'positive' : 'negative');
|
||||||
|
|
||||||
// Show results section
|
|
||||||
document.getElementById('simResults').style.display = 'block';
|
document.getElementById('simResults').style.display = 'block';
|
||||||
|
|
||||||
console.log('Backtest results:', backtest);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default start date (7 days ago)
|
// Set default start date (7 days ago)
|
||||||
|
|||||||
@ -172,6 +172,43 @@ async def get_candles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/candles/bulk")
|
||||||
|
async def get_candles_bulk(
|
||||||
|
symbol: str = Query("BTC"),
|
||||||
|
timeframes: list[str] = Query(["1h"]),
|
||||||
|
start: datetime = Query(...),
|
||||||
|
end: Optional[datetime] = Query(None),
|
||||||
|
):
|
||||||
|
"""Get multiple timeframes of candles in a single request for client-side processing"""
|
||||||
|
if not end:
|
||||||
|
end = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
for tf in timeframes:
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT time, open, high, low, close, volume
|
||||||
|
FROM candles
|
||||||
|
WHERE symbol = $1 AND interval = $2
|
||||||
|
AND time >= $3 AND time <= $4
|
||||||
|
ORDER BY time ASC
|
||||||
|
""", symbol, tf, start, end)
|
||||||
|
|
||||||
|
results[tf] = [
|
||||||
|
{
|
||||||
|
"time": r['time'].isoformat(),
|
||||||
|
"open": float(r['open']),
|
||||||
|
"high": float(r['high']),
|
||||||
|
"low": float(r['low']),
|
||||||
|
"close": float(r['close']),
|
||||||
|
"volume": float(r['volume'])
|
||||||
|
} for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/candles/latest")
|
@app.get("/api/v1/candles/latest")
|
||||||
async def get_latest_candle(symbol: str = "BTC", interval: str = "1m"):
|
async def get_latest_candle(symbol: str = "BTC", interval: str = "1m"):
|
||||||
"""Get the most recent candle"""
|
"""Get the most recent candle"""
|
||||||
|
|||||||
Reference in New Issue
Block a user