Initial commit - BTC Trading Dashboard

- FastAPI backend with PostgreSQL database connection
- Frontend dashboard with lightweight-charts
- Technical indicators (SMA, EMA, RSI, MACD, Bollinger Bands, etc.)
- Trading strategy simulation and backtesting
- Database connection to NAS at 20.20.20.20:5433
- Development server setup and documentation
This commit is contained in:
DiTus
2026-02-25 22:10:30 +01:00
commit c7ee5135ae
55 changed files with 9172 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,94 @@
import { TradingDashboard, refreshTA, openAIAnalysis } from './ui/chart.js';
import { restoreSidebarState, toggleSidebar } from './ui/sidebar.js';
import { SimulationStorage } from './ui/storage.js';
import { showExportDialog, closeExportDialog, performExport, exportSavedSimulation } from './ui/export.js';
import {
runSimulation,
displayEnhancedResults,
showSimulationMarkers,
clearSimulationResults,
getLastResults,
setLastResults
} from './ui/simulation.js';
import {
renderStrategies,
selectStrategy,
loadStrategies,
saveSimulation,
renderSavedSimulations,
loadSavedSimulation,
deleteSavedSimulation,
setCurrentStrategy
} from './ui/strategies-panel.js';
import {
renderIndicatorList,
addIndicator,
toggleIndicator,
showIndicatorConfig,
applyIndicatorConfig,
removeIndicator,
removeIndicatorById,
removeIndicatorByIndex,
drawIndicatorsOnChart
} from './ui/indicators-panel.js';
import { StrategyParams } from './strategies/config.js';
import { IndicatorRegistry } from './indicators/index.js';
window.dashboard = null;
function setDefaultStartDate() {
const startDateInput = document.getElementById('simStartDate');
if (startDateInput) {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
startDateInput.value = sevenDaysAgo.toISOString().slice(0, 16);
}
}
function updateTimeframeDisplay() {
const display = document.getElementById('simTimeframeDisplay');
if (display && window.dashboard) {
display.value = window.dashboard.currentInterval.toUpperCase();
}
}
window.toggleSidebar = toggleSidebar;
window.refreshTA = refreshTA;
window.openAIAnalysis = openAIAnalysis;
window.showExportDialog = showExportDialog;
window.closeExportDialog = closeExportDialog;
window.performExport = performExport;
window.exportSavedSimulation = exportSavedSimulation;
window.runSimulation = runSimulation;
window.saveSimulation = saveSimulation;
window.renderSavedSimulations = renderSavedSimulations;
window.loadSavedSimulation = loadSavedSimulation;
window.deleteSavedSimulation = deleteSavedSimulation;
window.clearSimulationResults = clearSimulationResults;
window.updateTimeframeDisplay = updateTimeframeDisplay;
window.renderIndicatorList = renderIndicatorList;
window.addIndicator = addIndicator;
window.toggleIndicator = toggleIndicator;
window.showIndicatorConfig = showIndicatorConfig;
window.StrategyParams = StrategyParams;
window.SimulationStorage = SimulationStorage;
window.IndicatorRegistry = IndicatorRegistry;
document.addEventListener('DOMContentLoaded', async () => {
window.dashboard = new TradingDashboard();
restoreSidebarState();
setDefaultStartDate();
updateTimeframeDisplay();
renderSavedSimulations();
await loadStrategies();
renderIndicatorList();
const originalSwitchTimeframe = window.dashboard.switchTimeframe.bind(window.dashboard);
window.dashboard.switchTimeframe = function(interval) {
originalSwitchTimeframe(interval);
setTimeout(() => drawIndicatorsOnChart(), 500);
};
});

View File

@ -0,0 +1,15 @@
export const INTERVALS = ['1m', '3m', '5m', '15m', '30m', '37m', '1h', '2h', '4h', '8h', '12h', '1d', '3d', '1w', '1M'];
export const COLORS = {
tvBg: '#131722',
tvPanelBg: '#1e222d',
tvBorder: '#2a2e39',
tvText: '#d1d4dc',
tvTextSecondary: '#787b86',
tvGreen: '#26a69a',
tvRed: '#ef5350',
tvBlue: '#2962ff',
tvHover: '#2a2e39'
};
export const API_BASE = '/api/v1';

View File

@ -0,0 +1 @@
export { INTERVALS, COLORS, API_BASE } from './constants.js';

View File

@ -0,0 +1,38 @@
import { BaseIndicator } from './base.js';
export 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;
}
getMetadata() {
return {
name: 'ATR',
description: 'Average True Range - measures market volatility',
inputs: [{ name: 'period', label: 'Period', type: 'number', default: 14, min: 1, max: 100 }],
plots: [{ id: 'value', color: '#795548', title: 'ATR' }],
displayMode: 'pane'
};
}
}

View File

@ -0,0 +1,18 @@
export 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"); }
getMetadata() {
return {
name: this.name,
inputs: [],
plots: [],
displayMode: 'overlay'
};
}
}

View File

@ -0,0 +1,43 @@
import { BaseIndicator } from './base.js';
export 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;
}
getMetadata() {
return {
name: 'Bollinger Bands',
description: 'Volatility bands around a moving average',
inputs: [
{ name: 'period', label: 'Period', type: 'number', default: 20, min: 1, max: 100 },
{ name: 'stdDev', label: 'Std Dev', type: 'number', default: 2, min: 0.5, max: 5, step: 0.5 }
],
plots: [
{ id: 'upper', color: '#4caf50', title: 'Upper' },
{ id: 'middle', color: '#4caf50', title: 'Middle', lineStyle: 2 },
{ id: 'lower', color: '#4caf50', title: 'Lower' }
],
displayMode: 'overlay'
};
}
}

View File

@ -0,0 +1,18 @@
import { MA } from './ma.js';
import { BaseIndicator } from './base.js';
export class EMAIndicator extends BaseIndicator {
calculate(candles) {
const period = this.params.period || 44;
return MA.ema(candles, period, 'close');
}
getMetadata() {
return {
name: 'EMA',
inputs: [{ name: 'period', label: 'Period', type: 'number', default: 44, min: 1, max: 500 }],
plots: [{ id: 'value', color: '#ff9800', title: 'EMA' }],
displayMode: 'overlay'
};
}
}

View File

@ -0,0 +1,41 @@
import { MA } from './ma.js';
import { BaseIndicator } from './base.js';
export class HTSIndicator extends BaseIndicator {
calculate(candles) {
const shortPeriod = this.params.short || 33;
const longPeriod = this.params.long || 144;
const maType = this.params.maType || 'RMA';
const shortHigh = MA.get(maType, candles, shortPeriod, 'high');
const shortLow = MA.get(maType, candles, shortPeriod, 'low');
const longHigh = MA.get(maType, candles, longPeriod, 'high');
const longLow = MA.get(maType, candles, longPeriod, 'low');
return candles.map((_, i) => ({
fastHigh: shortHigh[i],
fastLow: shortLow[i],
slowHigh: longHigh[i],
slowLow: longLow[i]
}));
}
getMetadata() {
return {
name: 'HTS Trend System',
description: 'High/Low Trend System with Fast and Slow MAs',
inputs: [
{ name: 'short', label: 'Fast Period', type: 'number', default: 33, min: 1, max: 500 },
{ name: 'long', label: 'Slow Period', type: 'number', default: 144, min: 1, max: 500 },
{ name: 'maType', label: 'MA Type', type: 'select', options: ['SMA', 'EMA', 'RMA', 'WMA', 'VWMA'], default: 'RMA' }
],
plots: [
{ id: 'fastHigh', color: '#00bcd4', title: 'Fast High', width: 1 },
{ id: 'fastLow', color: '#00bcd4', title: 'Fast Low', width: 1 },
{ id: 'slowHigh', color: '#f44336', title: 'Slow High', width: 2 },
{ id: 'slowLow', color: '#f44336', title: 'Slow Low', width: 2 }
],
displayMode: 'overlay'
};
}
}

View File

@ -0,0 +1,43 @@
export { MA } from './ma.js';
export { BaseIndicator } from './base.js';
export { HTSIndicator } from './hts.js';
export { MAIndicator } from './ma_indicator.js';
export { RSIIndicator } from './rsi.js';
export { BollingerBandsIndicator } from './bb.js';
export { MACDIndicator } from './macd.js';
export { StochasticIndicator } from './stoch.js';
export { ATRIndicator } from './atr.js';
import { HTSIndicator } from './hts.js';
import { MAIndicator } from './ma_indicator.js';
import { RSIIndicator } from './rsi.js';
import { BollingerBandsIndicator } from './bb.js';
import { MACDIndicator } from './macd.js';
import { StochasticIndicator } from './stoch.js';
import { ATRIndicator } from './atr.js';
export const IndicatorRegistry = {
hts: HTSIndicator,
ma: MAIndicator,
rsi: RSIIndicator,
bb: BollingerBandsIndicator,
macd: MACDIndicator,
stoch: StochasticIndicator,
atr: ATRIndicator
};
/**
* Dynamically build the available indicators list from the registry.
* Each indicator class provides its own name and description via getMetadata().
*/
export function getAvailableIndicators() {
return Object.entries(IndicatorRegistry).map(([type, IndicatorClass]) => {
const instance = new IndicatorClass({ type, params: {}, name: '' });
const meta = instance.getMetadata();
return {
type,
name: meta.name || type.toUpperCase(),
description: meta.description || ''
};
});
}

View File

@ -0,0 +1,93 @@
export class MA {
static get(type, candles, period, source = 'close') {
switch (type.toUpperCase()) {
case 'SMA': return MA.sma(candles, period, source);
case 'EMA': return MA.ema(candles, period, source);
case 'RMA': return MA.rma(candles, period, source);
case 'WMA': return MA.wma(candles, period, source);
case 'VWMA': return MA.vwma(candles, period, source);
default: return MA.sma(candles, period, source);
}
}
static sma(candles, period, source = 'close') {
const results = new Array(candles.length).fill(null);
let sum = 0;
for (let i = 0; i < candles.length; i++) {
sum += candles[i][source];
if (i >= period) sum -= candles[i - period][source];
if (i >= period - 1) results[i] = sum / period;
}
return results;
}
static ema(candles, period, source = 'close') {
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][source];
if (i === period - 1) {
ema = sum / period;
results[i] = ema;
}
} else {
ema = (candles[i][source] - ema) * multiplier + ema;
results[i] = ema;
}
}
return results;
}
static rma(candles, period, source = 'close') {
const multiplier = 1 / period;
const results = new Array(candles.length).fill(null);
let rma = 0;
let sum = 0;
for (let i = 0; i < candles.length; i++) {
if (i < period) {
sum += candles[i][source];
if (i === period - 1) {
rma = sum / period;
results[i] = rma;
}
} else {
rma = (candles[i][source] - rma) * multiplier + rma;
results[i] = rma;
}
}
return results;
}
static wma(candles, period, source = 'close') {
const results = new Array(candles.length).fill(null);
const weightSum = (period * (period + 1)) / 2;
for (let i = period - 1; i < candles.length; i++) {
let sum = 0;
for (let j = 0; j < period; j++) {
sum += candles[i - j][source] * (period - j);
}
results[i] = sum / weightSum;
}
return results;
}
static vwma(candles, period, source = 'close') {
const results = new Array(candles.length).fill(null);
for (let i = period - 1; i < candles.length; i++) {
let sumPV = 0;
let sumV = 0;
for (let j = 0; j < period; j++) {
sumPV += candles[i - j][source] * candles[i - j].volume;
sumV += candles[i - j].volume;
}
results[i] = sumV !== 0 ? sumPV / sumV : null;
}
return results;
}
}

View File

@ -0,0 +1,23 @@
import { MA } from './ma.js';
import { BaseIndicator } from './base.js';
export class MAIndicator extends BaseIndicator {
calculate(candles) {
const period = this.params.period || 44;
const maType = this.params.maType || 'SMA';
return MA.get(maType, candles, period, 'close');
}
getMetadata() {
return {
name: 'MA',
description: 'Moving Average (SMA/EMA/RMA/WMA/VWMA)',
inputs: [
{ name: 'period', label: 'Period', type: 'number', default: 44, min: 1, max: 500 },
{ name: 'maType', label: 'MA Type', type: 'select', options: ['SMA', 'EMA', 'RMA', 'WMA', 'VWMA'], default: 'SMA' }
],
plots: [{ id: 'value', color: '#2962ff', title: 'MA' }],
displayMode: 'overlay'
};
}
}

View File

@ -0,0 +1,60 @@
import { MA } from './ma.js';
import { BaseIndicator } from './base.js';
export 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 fastEma = MA.ema(candles, fast, 'close');
const slowEma = MA.ema(candles, slow, 'close');
const macdLine = fastEma.map((f, i) => (f !== null && slowEma[i] !== null) ? f - slowEma[i] : null);
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
}));
}
getMetadata() {
return {
name: 'MACD',
description: 'Moving Average Convergence Divergence - trend & momentum',
inputs: [
{ name: 'fast', label: 'Fast Period', type: 'number', default: 12 },
{ name: 'slow', label: 'Slow Period', type: 'number', default: 26 },
{ name: 'signal', label: 'Signal Period', type: 'number', default: 9 }
],
plots: [
{ id: 'macd', color: '#2196f3', title: 'MACD' },
{ id: 'signal', color: '#ff5722', title: 'Signal' },
{ id: 'histogram', color: '#607d8b', title: 'Histogram', type: 'histogram' }
],
displayMode: 'pane'
};
}
}

View File

@ -0,0 +1,69 @@
import { BaseIndicator } from './base.js';
export class RSIIndicator extends BaseIndicator {
calculate(candles) {
const period = this.params.period || 14;
// 1. Calculate RSI using RMA (Wilder's Smoothing)
let rsiValues = new Array(candles.length).fill(null);
let upSum = 0;
let downSum = 0;
const rmaAlpha = 1 / period;
for (let i = 1; i < candles.length; i++) {
const diff = candles[i].close - candles[i-1].close;
const up = diff > 0 ? diff : 0;
const down = diff < 0 ? -diff : 0;
if (i < period) {
upSum += up;
downSum += down;
} else if (i === period) {
upSum += up;
downSum += down;
const avgUp = upSum / period;
const avgDown = downSum / period;
rsiValues[i] = avgDown === 0 ? 100 : (avgUp === 0 ? 0 : 100 - (100 / (1 + avgUp / avgDown)));
upSum = avgUp; // Store for next RMA step
downSum = avgDown;
} else {
upSum = (up - upSum) * rmaAlpha + upSum;
downSum = (down - downSum) * rmaAlpha + downSum;
rsiValues[i] = downSum === 0 ? 100 : (upSum === 0 ? 0 : 100 - (100 / (1 + upSum / downSum)));
}
}
// Combine results
return rsiValues.map((rsi, i) => {
return {
paneBg: 80, // Background lightening trick
rsi: rsi,
upperBand: 70,
lowerBand: 30
};
});
}
getMetadata() {
const plots = [
// RSI Line
{ id: 'rsi', color: '#7E57C2', title: '', width: 1, lastValueVisible: true },
// Bands
{ id: 'upperBand', color: '#787B86', title: '', style: 'dashed', width: 1, lastValueVisible: false },
{ id: 'lowerBand', color: '#787B86', title: '', style: 'dashed', width: 1, lastValueVisible: false }
];
return {
name: 'RSI',
description: 'Relative Strength Index',
inputs: [
{ name: 'period', label: 'RSI Length', type: 'number', default: 14, min: 1, max: 100 }
],
plots: plots,
displayMode: 'pane',
paneMin: 0,
paneMax: 100
};
}
}

View File

@ -0,0 +1,18 @@
import { MA } from './ma.js';
import { BaseIndicator } from './base.js';
export class SMAIndicator extends BaseIndicator {
calculate(candles) {
const period = this.params.period || 44;
return MA.sma(candles, period, 'close');
}
getMetadata() {
return {
name: 'SMA',
inputs: [{ name: 'period', label: 'Period', type: 'number', default: 44, min: 1, max: 500 }],
plots: [{ id: 'value', color: '#2962ff', title: 'SMA' }],
displayMode: 'overlay'
};
}
}

View File

@ -0,0 +1,48 @@
import { BaseIndicator } from './base.js';
export 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;
}
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;
}
getMetadata() {
return {
name: 'Stochastic',
description: 'Stochastic Oscillator - compares close to high-low range',
inputs: [
{ name: 'kPeriod', label: 'K Period', type: 'number', default: 14 },
{ name: 'dPeriod', label: 'D Period', type: 'number', default: 3 }
],
plots: [
{ id: 'k', color: '#3f51b5', title: '%K' },
{ id: 'd', color: '#ff9800', title: '%D' }
],
displayMode: 'pane',
paneMin: 0,
paneMax: 100
};
}
}

View File

@ -0,0 +1,5 @@
export const StrategyParams = {
ma_trend: [
{ name: 'period', label: 'MA Period', type: 'number', default: 44, min: 5, max: 500 }
]
};

View File

@ -0,0 +1,167 @@
import { IndicatorRegistry } from '../indicators/index.js';
import { RiskManager } from './risk-manager.js';
export class ClientStrategyEngine {
constructor() {
this.indicatorTypes = IndicatorRegistry;
}
run(candlesMap, strategyConfig, riskConfig, simulationStart) {
const primaryTF = strategyConfig.timeframes?.primary || '1d';
const candles = candlesMap[primaryTF];
if (!candles) return { error: `No candles for primary timeframe ${primaryTF}` };
const indicatorResults = {};
console.log('Calculating indicators for timeframes:', Object.keys(candlesMap));
for (const tf in candlesMap) {
indicatorResults[tf] = {};
const tfCandles = candlesMap[tf];
const tfIndicators = (strategyConfig.indicators || []).filter(ind => (ind.timeframe || primaryTF) === tf);
console.log(` TF ${tf}: ${tfIndicators.length} indicators to calculate`);
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);
const validValues = indicatorResults[tf][ind.name].filter(v => v !== null).length;
console.log(` Calculated ${ind.name} on ${tf}: ${validValues} valid values`);
}
}
}
const risk = new RiskManager(riskConfig);
const trades = [];
let position = null;
const startTimeSec = Math.floor(new Date(simulationStart).getTime() / 1000);
console.log('Simulation start (seconds):', startTimeSec, 'Date:', simulationStart);
console.log('Total candles available:', candles.length);
console.log('First candle time:', candles[0].time, 'Last candle time:', candles[candles.length - 1].time);
const pointers = {};
for (const tf in candlesMap) pointers[tf] = 0;
let processedCandles = 0;
for (let i = 1; i < candles.length; i++) {
const time = candles[i].time;
const price = candles[i].close;
if (time < startTimeSec) {
for (const tf in candlesMap) {
while (pointers[tf] < candlesMap[tf].length - 1 &&
candlesMap[tf][pointers[tf] + 1].time <= time) {
pointers[tf]++;
}
}
continue;
}
processedCandles++;
for (const tf in candlesMap) {
while (pointers[tf] < candlesMap[tf].length - 1 &&
candlesMap[tf][pointers[tf] + 1].time <= time) {
pointers[tf]++;
}
}
const signal = this.evaluate(i, pointers, candles, candlesMap, indicatorResults, strategyConfig, position);
if (signal === 'BUY' && !position) {
const size = risk.calculateSize(price);
position = { type: 'long', entryPrice: price, entryTime: candles[i].time, size };
} else if (signal === 'SELL' && position) {
const pnl = (price - position.entryPrice) * position.size;
trades.push({ ...position, exitPrice: price, exitTime: candles[i].time, pnl, pnlPct: (pnl / (position.entryPrice * position.size)) * 100 });
risk.balance += pnl;
position = null;
}
}
console.log(`Simulation complete: ${processedCandles} candles processed after start date, ${trades.length} trades`);
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, pointers, candles, candlesMap, indicatorResults, config, position) {
const primaryTF = config.timeframes?.primary || '1d';
const getVal = (indName, tf) => {
const tfValues = indicatorResults[tf]?.[indName];
if (!tfValues) return null;
return tfValues[pointers[tf]];
};
const getPrice = (tf) => {
const tfCandles = candlesMap[tf];
if (!tfCandles) return null;
return tfCandles[pointers[tf]].close;
};
if (config.id === 'ma_trend') {
const period = config.params?.period || 44;
if (index === 1) {
console.log('First candle time:', candles[index].time, 'Date:', new Date(candles[index].time * 1000));
console.log(`MA${period} value:`, getVal(`ma${period}`, primaryTF));
}
const maValue = getVal(`ma${period}`, primaryTF);
const price = candles[index].close;
const secondaryTF = config.timeframes?.secondary?.[0];
let secondaryBullish = true;
let secondaryBearish = true;
if (secondaryTF) {
const secondaryPrice = getPrice(secondaryTF);
const secondaryMA = getVal(`ma${period}_${secondaryTF}`, secondaryTF);
if (secondaryPrice !== null && secondaryMA !== null) {
secondaryBullish = secondaryPrice > secondaryMA;
secondaryBearish = secondaryPrice < secondaryMA;
}
if (index === 1) {
console.log(`Trend check: ${secondaryTF} price=${secondaryPrice}, MA=${secondaryMA}, bullish=${secondaryBullish}, bearish=${secondaryBearish}`);
}
}
if (maValue) {
if (price > maValue && secondaryBullish) return 'BUY';
if (price < maValue && secondaryBearish) return 'SELL';
}
}
const evaluateConditions = (conds) => {
if (!conds || !conds.conditions) return false;
const results = conds.conditions.map(c => {
const targetTF = c.timeframe || primaryTF;
const leftVal = c.indicator === 'price' ? getPrice(targetTF) : getVal(c.indicator, targetTF);
const rightVal = typeof c.value === 'number' ? c.value : (c.value === 'price' ? getPrice(targetTF) : getVal(c.value, targetTF));
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';
}
}

View File

@ -0,0 +1,3 @@
export { StrategyParams } from './config.js';
export { RiskManager } from './risk-manager.js';
export { ClientStrategyEngine } from './engine.js';

View File

@ -0,0 +1,17 @@
export class RiskManager {
constructor(config, initialBalance = 1000) {
this.config = config || {
positionSizing: { method: 'percent', value: 0.1 },
stopLoss: { enabled: true, method: 'percent', value: 0.02 },
takeProfit: { enabled: true, method: 'percent', value: 0.04 }
};
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;
}
}

View File

@ -0,0 +1,604 @@
import { INTERVALS, COLORS } from '../core/index.js';
export class TradingDashboard {
constructor() {
this.chart = null;
this.candleSeries = null;
this.currentInterval = '1d';
this.intervals = INTERVALS;
this.allData = new Map();
this.isLoading = false;
this.hasInitialLoad = false;
this.taData = null;
this.init();
}
init() {
this.createTimeframeButtons();
this.initChart();
this.initEventListeners();
this.loadInitialData();
setInterval(() => {
this.loadNewData();
this.loadStats();
if (new Date().getSeconds() < 15) this.loadTA();
}, 10000);
}
isAtRightEdge() {
const timeScale = this.chart.timeScale();
const visibleRange = timeScale.getVisibleLogicalRange();
if (!visibleRange) return true;
const data = this.candleSeries.data();
if (!data || data.length === 0) return true;
return visibleRange.to >= data.length - 5;
}
createTimeframeButtons() {
const container = document.getElementById('timeframeContainer');
container.innerHTML = '';
this.intervals.forEach(interval => {
const btn = document.createElement('button');
btn.className = 'timeframe-btn';
btn.dataset.interval = interval;
btn.textContent = interval;
if (interval === this.currentInterval) {
btn.classList.add('active');
}
btn.addEventListener('click', () => this.switchTimeframe(interval));
container.appendChild(btn);
});
}
initChart() {
const chartContainer = document.getElementById('chart');
this.chart = LightweightCharts.createChart(chartContainer, {
layout: {
background: { color: COLORS.tvBg },
textColor: COLORS.tvText,
panes: {
background: { color: '#1e222d' },
separatorColor: '#2a2e39',
separatorHoverColor: '#363c4e',
enableResize: true
}
},
grid: {
vertLines: { color: '#363d4e' },
horzLines: { color: '#363d4e' },
},
rightPriceScale: {
borderColor: '#363d4e',
autoScale: true,
},
timeScale: {
borderColor: '#363d4e',
timeVisible: true,
secondsVisible: false,
rightOffset: 12,
barSpacing: 10,
},
handleScroll: {
vertTouchDrag: false,
},
});
this.candleSeries = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
upColor: '#ff9800',
downColor: '#ff9800',
borderUpColor: '#ff9800',
borderDownColor: '#ff9800',
wickUpColor: '#ff9800',
wickDownColor: '#ff9800',
lastValueVisible: false,
priceLineVisible: false,
}, 0);
this.currentPriceLine = this.candleSeries.createPriceLine({
price: 0,
color: '#26a69a',
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dotted,
axisLabelVisible: true,
title: '',
});
this.initPriceScaleControls();
this.initNavigationControls();
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.onVisibleRangeChange.bind(this));
window.addEventListener('resize', () => {
this.chart.applyOptions({
width: chartContainer.clientWidth,
height: chartContainer.clientHeight,
});
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.loadNewData();
this.loadTA();
}
});
window.addEventListener('focus', () => {
this.loadNewData();
this.loadTA();
});
}
initPriceScaleControls() {
const btnAutoScale = document.getElementById('btnAutoScale');
const btnLogScale = document.getElementById('btnLogScale');
if (!btnAutoScale || !btnLogScale) return;
this.priceScaleState = {
autoScale: true,
logScale: false
};
btnAutoScale.addEventListener('click', () => {
this.priceScaleState.autoScale = !this.priceScaleState.autoScale;
btnAutoScale.classList.toggle('active', this.priceScaleState.autoScale);
this.candleSeries.priceScale().applyOptions({
autoScale: this.priceScaleState.autoScale
});
console.log('Auto Scale:', this.priceScaleState.autoScale ? 'ON' : 'OFF');
});
btnLogScale.addEventListener('click', () => {
this.priceScaleState.logScale = !this.priceScaleState.logScale;
btnLogScale.classList.toggle('active', this.priceScaleState.logScale);
let currentPriceRange = null;
let currentTimeRange = null;
if (!this.priceScaleState.autoScale) {
try {
currentPriceRange = this.candleSeries.priceScale().getVisiblePriceRange();
} catch (e) {
console.log('Could not get price range');
}
}
try {
currentTimeRange = this.chart.timeScale().getVisibleLogicalRange();
} catch (e) {
console.log('Could not get time range');
}
this.candleSeries.priceScale().applyOptions({
mode: this.priceScaleState.logScale ? LightweightCharts.PriceScaleMode.Logarithmic : LightweightCharts.PriceScaleMode.Normal
});
this.chart.applyOptions({});
setTimeout(() => {
if (currentTimeRange) {
try {
this.chart.timeScale().setVisibleLogicalRange(currentTimeRange);
} catch (e) {
console.log('Could not restore time range');
}
}
if (!this.priceScaleState.autoScale && currentPriceRange) {
try {
this.candleSeries.priceScale().setVisiblePriceRange(currentPriceRange);
} catch (e) {
console.log('Could not restore price range');
}
}
}, 100);
console.log('Log Scale:', this.priceScaleState.logScale ? 'ON' : 'OFF');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'a' || e.key === 'A') {
if (e.target.tagName !== 'INPUT') {
btnAutoScale.click();
}
}
});
}
initNavigationControls() {
const chartWrapper = document.getElementById('chartWrapper');
const navLeft = document.getElementById('navLeft');
const navRight = document.getElementById('navRight');
const navRecent = document.getElementById('navRecent');
if (!chartWrapper || !navLeft || !navRight || !navRecent) return;
chartWrapper.addEventListener('mousemove', (e) => {
const rect = chartWrapper.getBoundingClientRect();
const distanceFromBottom = rect.bottom - e.clientY;
chartWrapper.classList.toggle('show-nav', distanceFromBottom < 30);
});
chartWrapper.addEventListener('mouseleave', () => {
chartWrapper.classList.remove('show-nav');
});
navLeft.addEventListener('click', () => this.navigateLeft());
navRight.addEventListener('click', () => this.navigateRight());
navRecent.addEventListener('click', () => this.navigateToRecent());
}
navigateLeft() {
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
if (!visibleRange) return;
const visibleBars = visibleRange.to - visibleRange.from;
const shift = visibleBars * 0.8;
const newFrom = visibleRange.from - shift;
const newTo = visibleRange.to - shift;
this.chart.timeScale().setVisibleLogicalRange({ from: newFrom, to: newTo });
}
navigateRight() {
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
if (!visibleRange) return;
const visibleBars = visibleRange.to - visibleRange.from;
const shift = visibleBars * 0.8;
const newFrom = visibleRange.from + shift;
const newTo = visibleRange.to + shift;
this.chart.timeScale().setVisibleLogicalRange({ from: newFrom, to: newTo });
}
navigateToRecent() {
this.chart.timeScale().scrollToRealTime();
}
initEventListeners() {
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return;
const shortcuts = {
'1': '1m', '2': '3m', '3': '5m', '4': '15m', '5': '30m', '7': '37m',
'6': '1h', '8': '4h', '9': '8h', '0': '12h',
'd': '1d', 'D': '1d', 'w': '1w', 'W': '1w', 'm': '1M', 'M': '1M'
};
if (shortcuts[e.key]) {
this.switchTimeframe(shortcuts[e.key]);
}
if (e.key === 'ArrowLeft') {
this.navigateLeft();
} else if (e.key === 'ArrowRight') {
this.navigateRight();
} else if (e.key === 'ArrowUp') {
this.navigateToRecent();
}
});
}
async loadInitialData() {
await Promise.all([
this.loadData(1000, true),
this.loadStats()
]);
this.hasInitialLoad = true;
}
async loadData(limit = 1000, fitToContent = false) {
if (this.isLoading) return;
this.isLoading = true;
try {
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&limit=${limit}`);
const data = await response.json();
if (data.candles && data.candles.length > 0) {
const chartData = data.candles.reverse().map(c => ({
time: Math.floor(new Date(c.time).getTime() / 1000),
open: parseFloat(c.open),
high: parseFloat(c.high),
low: parseFloat(c.low),
close: parseFloat(c.close),
volume: parseFloat(c.volume || 0)
}));
const existingData = this.allData.get(this.currentInterval) || [];
const mergedData = this.mergeData(existingData, chartData);
this.allData.set(this.currentInterval, mergedData);
this.candleSeries.setData(mergedData);
if (fitToContent) {
this.chart.timeScale().scrollToRealTime();
} else if (visibleRange) {
this.chart.timeScale().setVisibleLogicalRange(visibleRange);
}
const latest = mergedData[mergedData.length - 1];
this.updateStats(latest);
}
} catch (error) {
console.error('Error loading data:', error);
} finally {
this.isLoading = false;
}
}
async loadNewData() {
if (!this.hasInitialLoad || this.isLoading) return;
try {
const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&limit=50`);
const data = await response.json();
if (data.candles && data.candles.length > 0) {
const atEdge = this.isAtRightEdge();
const currentSeriesData = this.candleSeries.data();
const lastTimestamp = currentSeriesData.length > 0
? currentSeriesData[currentSeriesData.length - 1].time
: 0;
const chartData = data.candles.reverse().map(c => ({
time: Math.floor(new Date(c.time).getTime() / 1000),
open: parseFloat(c.open),
high: parseFloat(c.high),
low: parseFloat(c.low),
close: parseFloat(c.close),
volume: parseFloat(c.volume || 0)
}));
chartData.forEach(candle => {
if (candle.time >= lastTimestamp) {
this.candleSeries.update(candle);
}
});
const existingData = this.allData.get(this.currentInterval) || [];
this.allData.set(this.currentInterval, this.mergeData(existingData, chartData));
if (atEdge) {
this.chart.timeScale().scrollToRealTime();
}
const latest = chartData[chartData.length - 1];
this.updateStats(latest);
}
} catch (error) {
console.error('Error loading new data:', error);
}
}
mergeData(existing, newData) {
const dataMap = new Map();
existing.forEach(c => dataMap.set(c.time, c));
newData.forEach(c => dataMap.set(c.time, c));
return Array.from(dataMap.values()).sort((a, b) => a.time - b.time);
}
onVisibleRangeChange() {
if (!this.hasInitialLoad || this.isLoading) {
return;
}
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
if (!visibleRange) {
return;
}
const data = this.candleSeries.data();
if (!data || data.length === 0) {
return;
}
const visibleBars = Math.ceil(visibleRange.to - visibleRange.from);
const bufferSize = visibleBars * 2;
const refillThreshold = bufferSize * 0.8;
const barsFromLeft = Math.floor(visibleRange.from);
if (barsFromLeft < refillThreshold) {
console.log(`Buffer low (${barsFromLeft} < ${refillThreshold.toFixed(0)}), silently prefetching ${bufferSize} candles...`);
const oldestCandle = data[0];
if (oldestCandle) {
this.loadHistoricalData(oldestCandle.time, bufferSize);
}
}
}
async loadHistoricalData(beforeTime, limit = 1000) {
if (this.isLoading) {
return;
}
this.isLoading = true;
try {
const endTime = new Date((beforeTime - 1) * 1000);
const response = await fetch(
`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&end=${endTime.toISOString()}&limit=${limit}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.candles && data.candles.length > 0) {
const chartData = data.candles.reverse().map(c => ({
time: Math.floor(new Date(c.time).getTime() / 1000),
open: parseFloat(c.open),
high: parseFloat(c.high),
low: parseFloat(c.low),
close: parseFloat(c.close),
volume: parseFloat(c.volume || 0)
}));
const existingData = this.allData.get(this.currentInterval) || [];
const mergedData = this.mergeData(existingData, chartData);
this.allData.set(this.currentInterval, mergedData);
this.candleSeries.setData(mergedData);
// Recalculate indicators with the expanded dataset
window.drawIndicatorsOnChart?.();
console.log(`Prefetched ${chartData.length} candles, total: ${mergedData.length}`);
} else {
console.log('No more historical data available');
}
} catch (error) {
console.error('Error loading historical data:', error);
} finally {
this.isLoading = false;
}
}
async loadTA() {
try {
const response = await fetch(`/api/v1/ta?symbol=BTC&interval=${this.currentInterval}`);
this.taData = await response.json();
this.renderTA();
} catch (error) {
console.error('Error loading TA:', error);
document.getElementById('taContent').innerHTML = '<div class="ta-error">Failed to load technical analysis</div>';
}
}
renderTA() {
if (!this.taData || this.taData.error) {
document.getElementById('taContent').innerHTML = `<div class="ta-error">${this.taData?.error || 'No data available'}</div>`;
return;
}
const data = this.taData;
const trendClass = data.trend.direction.toLowerCase();
const signalClass = data.trend.signal.toLowerCase();
const ma44Change = data.moving_averages.price_vs_ma44;
const ma125Change = data.moving_averages.price_vs_ma125;
document.getElementById('taInterval').textContent = this.currentInterval.toUpperCase();
document.getElementById('taLastUpdate').textContent = new Date().toLocaleTimeString();
document.getElementById('taContent').innerHTML = `
<div class="ta-section">
<div class="ta-section-title">Trend Analysis</div>
<div class="ta-trend ${trendClass}">
${data.trend.direction} ${trendClass === 'bullish' ? '↑' : trendClass === 'bearish' ? '↓' : '→'}
</div>
<div class="ta-strength">${data.trend.strength}</div>
<span class="ta-signal ${signalClass}">${data.trend.signal}</span>
</div>
<div class="ta-section">
<div class="ta-section-title">Moving Averages</div>
<div class="ta-ma-row">
<span class="ta-ma-label">MA 44</span>
<span class="ta-ma-value">
${data.moving_averages.ma_44 ? data.moving_averages.ma_44.toFixed(2) : 'N/A'}
${ma44Change !== null ? `<span class="ta-ma-change ${ma44Change >= 0 ? 'positive' : 'negative'}">${ma44Change >= 0 ? '+' : ''}${ma44Change.toFixed(1)}%</span>` : ''}
</span>
</div>
<div class="ta-ma-row">
<span class="ta-ma-label">MA 125</span>
<span class="ta-ma-value">
${data.moving_averages.ma_125 ? data.moving_averages.ma_125.toFixed(2) : 'N/A'}
${ma125Change !== null ? `<span class="ta-ma-change ${ma125Change >= 0 ? 'positive' : 'negative'}">${ma125Change >= 0 ? '+' : ''}${ma125Change.toFixed(1)}%</span>` : ''}
</span>
</div>
</div>
<div class="ta-section">
<div class="ta-section-title">Indicators</div>
<div id="indicatorList" class="indicator-list"></div>
</div>
<div class="ta-section" id="indicatorConfigPanel">
<div class="ta-section-title">Configuration</div>
<div id="configForm" style="margin-top: 8px;"></div>
<div style="display: flex; gap: 8px; margin-top: 12px;" id="configButtons">
<button class="ta-btn" onclick="applyIndicatorConfig()" style="flex: 1; font-size: 11px; background: var(--tv-blue); color: white; border: none;">Apply</button>
<button class="ta-btn" onclick="removeIndicator()" style="flex: 1; font-size: 11px; border-color: var(--tv-red); color: var(--tv-red);">Remove</button>
</div>
</div>
`;
window.renderIndicatorList?.();
}
async loadStats() {
try {
const response = await fetch('/api/v1/stats?symbol=BTC');
this.statsData = await response.json();
} catch (error) {
console.error('Error loading stats:', error);
}
}
updateStats(candle) {
const price = candle.close;
const isUp = candle.close >= candle.open;
if (this.currentPriceLine) {
this.currentPriceLine.applyOptions({
price: price,
color: isUp ? '#26a69a' : '#ef5350',
});
}
document.getElementById('currentPrice').textContent = price.toFixed(2);
if (this.statsData) {
const change = this.statsData.change_24h;
document.getElementById('currentPrice').className = 'stat-value ' + (change >= 0 ? 'positive' : 'negative');
document.getElementById('priceChange').textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%';
document.getElementById('priceChange').className = 'stat-value ' + (change >= 0 ? 'positive' : 'negative');
document.getElementById('dailyHigh').textContent = this.statsData.high_24h.toFixed(2);
document.getElementById('dailyLow').textContent = this.statsData.low_24h.toFixed(2);
}
}
switchTimeframe(interval) {
if (!this.intervals.includes(interval) || interval === this.currentInterval) return;
this.currentInterval = interval;
this.hasInitialLoad = false;
document.querySelectorAll('.timeframe-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.interval === interval);
});
this.allData.delete(interval);
this.loadInitialData();
this.loadTA();
window.clearSimulationResults?.();
window.updateTimeframeDisplay?.();
}
}
export function refreshTA() {
if (window.dashboard) {
window.dashboard.loadTA();
}
}
export function openAIAnalysis() {
const symbol = 'BTC';
const interval = window.dashboard?.currentInterval || '1d';
const prompt = `Analyze Bitcoin (${symbol}) ${interval} chart. Current trend, support/resistance levels, and trading recommendation. Technical indicators: MA44, MA125.`;
const geminiUrl = `https://gemini.google.com/app?prompt=${encodeURIComponent(prompt)}`;
window.open(geminiUrl, '_blank');
}

View File

@ -0,0 +1,140 @@
import { downloadFile } from '../utils/index.js';
export function showExportDialog() {
if (!window.lastSimulationResults) {
alert('Please run a simulation first');
return;
}
const overlay = document.createElement('div');
overlay.className = 'dialog-overlay';
overlay.onclick = () => closeExportDialog();
document.body.appendChild(overlay);
const dialog = document.createElement('div');
dialog.className = 'export-dialog';
dialog.id = 'exportDialog';
dialog.innerHTML = `
<div class="export-dialog-title">📥 Export Simulation Report</div>
<div class="export-options">
<label class="export-option">
<input type="radio" name="exportFormat" value="csv" checked>
<span>CSV (Trades list)</span>
</label>
<label class="export-option">
<input type="radio" name="exportFormat" value="json">
<span>JSON (Full data)</span>
</label>
<label class="export-option">
<input type="radio" name="exportFormat" value="both">
<span>Both CSV + JSON</span>
</label>
</div>
<div style="display: flex; gap: 8px;">
<button class="action-btn secondary" onclick="closeExportDialog()" style="flex: 1;">Cancel</button>
<button class="action-btn primary" onclick="performExport()" style="flex: 1;">Export</button>
</div>
`;
document.body.appendChild(dialog);
}
export function closeExportDialog() {
const overlay = document.querySelector('.dialog-overlay');
const dialog = document.getElementById('exportDialog');
if (overlay) overlay.remove();
if (dialog) dialog.remove();
}
export function performExport() {
const format = document.querySelector('input[name="exportFormat"]:checked').value;
const sim = window.lastSimulationResults;
const config = sim.config || {};
const dateStr = new Date().toISOString().slice(0, 10);
const baseFilename = generateSimulationName(config).replace(/[^a-zA-Z0-9_-]/g, '_');
if (format === 'csv' || format === 'both') {
exportToCSV(sim, `${baseFilename}.csv`);
}
if (format === 'json' || format === 'both') {
exportToJSON(sim, `${baseFilename}.json`);
}
closeExportDialog();
}
function generateSimulationName(config) {
if (!config) return 'Unnamed Simulation';
const start = new Date(config.startDate);
const now = new Date();
const duration = now - start;
const oneDay = 24 * 60 * 60 * 1000;
let dateStr;
if (duration < oneDay) {
dateStr = start.toISOString().slice(0, 16).replace('T', ' ');
} else {
dateStr = start.toISOString().slice(0, 10);
}
return `${config.strategyName}_${config.timeframe}_${dateStr}`;
}
function exportToCSV(simulation, filename) {
const results = simulation.results || simulation;
const config = simulation.config || {};
let csv = 'Trade #,Entry Time,Exit Time,Entry Price,Exit Price,Size,P&L ($),P&L (%),Type\n';
(results.trades || []).forEach((trade, i) => {
csv += `${i + 1},${trade.entryTime},${trade.exitTime},${trade.entryPrice},${trade.exitPrice},${trade.size},${trade.pnl},${trade.pnlPct},${trade.type}\n`;
});
csv += '\n';
csv += 'Summary\n';
csv += `Strategy,${config.strategyName || 'Unknown'}\n`;
csv += `Timeframe,${config.timeframe || 'Unknown'}\n`;
csv += `Start Date,${config.startDate || 'Unknown'}\n`;
csv += `Total Trades,${results.total_trades || 0}\n`;
csv += `Win Rate (%),${(results.win_rate || 0).toFixed(2)}\n`;
csv += `Total P&L ($),${(results.total_pnl || 0).toFixed(2)}\n`;
csv += `Risk % per Trade,${config.riskPercent || 2}\n`;
csv += `Stop Loss %,${config.stopLossPercent || 2}\n`;
downloadFile(csv, filename, 'text/csv');
}
function exportToJSON(simulation, filename) {
const exportData = {
metadata: {
exported_at: new Date().toISOString(),
version: '1.0'
},
configuration: simulation.config || {},
results: {
summary: {
total_trades: simulation.total_trades || simulation.results?.total_trades || 0,
win_rate: simulation.win_rate || simulation.results?.win_rate || 0,
total_pnl: simulation.total_pnl || simulation.results?.total_pnl || 0
},
trades: simulation.trades || simulation.results?.trades || [],
equity_curve: simulation.equity_curve || []
}
};
downloadFile(JSON.stringify(exportData, null, 2), filename, 'application/json');
}
export function exportSavedSimulation(id) {
const sim = window.SimulationStorage?.get(id);
if (!sim) {
alert('Simulation not found');
return;
}
window.lastSimulationResults = sim;
showExportDialog();
}
window.generateSimulationName = generateSimulationName;

View File

@ -0,0 +1,37 @@
export { TradingDashboard, refreshTA, openAIAnalysis } from './chart.js';
export { toggleSidebar, restoreSidebarState } from './sidebar.js';
export { SimulationStorage } from './storage.js';
export { showExportDialog, closeExportDialog, performExport, exportSavedSimulation } from './export.js';
export {
runSimulation,
displayEnhancedResults,
showSimulationMarkers,
clearSimulationMarkers,
clearSimulationResults,
getLastResults,
setLastResults
} from './simulation.js';
export {
renderStrategies,
selectStrategy,
renderStrategyParams,
loadStrategies,
saveSimulation,
renderSavedSimulations,
loadSavedSimulation,
deleteSavedSimulation,
getCurrentStrategy,
setCurrentStrategy
} from './strategies-panel.js';
export {
renderIndicatorList,
addNewIndicator,
selectIndicator,
renderIndicatorConfig,
applyIndicatorConfig,
removeIndicator,
removeIndicatorByIndex,
drawIndicatorsOnChart,
getActiveIndicators,
setActiveIndicators
} from './indicators-panel.js';

View File

@ -0,0 +1,677 @@
import { getAvailableIndicators, IndicatorRegistry as IR } from '../indicators/index.js';
let activeIndicators = [];
let configuringId = null;
let previewingType = null; // type being previewed (not yet added)
let nextInstanceId = 1;
const DEFAULT_COLORS = ['#2962ff', '#26a69a', '#ef5350', '#ff9800', '#9c27b0', '#00bcd4', '#ffeb3b', '#e91e63'];
const LINE_TYPES = ['solid', 'dotted', 'dashed'];
function getDefaultColor(index) {
return DEFAULT_COLORS[index % DEFAULT_COLORS.length];
}
function getPlotGroupName(plotId) {
if (plotId.toLowerCase().includes('fast')) return 'Fast';
if (plotId.toLowerCase().includes('slow')) return 'Slow';
if (plotId.toLowerCase().includes('upper')) return 'Upper';
if (plotId.toLowerCase().includes('lower')) return 'Lower';
if (plotId.toLowerCase().includes('middle') || plotId.toLowerCase().includes('basis')) return 'Middle';
if (plotId.toLowerCase().includes('signal')) return 'Signal';
if (plotId.toLowerCase().includes('histogram')) return 'Histogram';
if (plotId.toLowerCase().includes('k')) return '%K';
if (plotId.toLowerCase().includes('d')) return '%D';
return plotId;
}
function groupPlotsByColor(plots) {
const groups = {};
plots.forEach((plot, idx) => {
const groupName = getPlotGroupName(plot.id);
if (!groups[groupName]) {
groups[groupName] = { name: groupName, indices: [], plots: [] };
}
groups[groupName].indices.push(idx);
groups[groupName].plots.push(plot);
});
return Object.values(groups);
}
/** Generate a short label for an active indicator showing its key params */
function getIndicatorLabel(indicator) {
const meta = getIndicatorMeta(indicator);
if (!meta) return indicator.name;
const paramParts = meta.inputs.map(input => {
const val = indicator.params[input.name];
if (val !== undefined && val !== input.default) return val;
if (val !== undefined) return val;
return null;
}).filter(v => v !== null);
if (paramParts.length > 0) {
return `${indicator.name} (${paramParts.join(', ')})`;
}
return indicator.name;
}
function getIndicatorMeta(indicator) {
const IndicatorClass = IR?.[indicator.type];
if (!IndicatorClass) return null;
const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name });
return instance.getMetadata();
}
export function getActiveIndicators() {
return activeIndicators;
}
export function setActiveIndicators(indicators) {
activeIndicators = indicators;
}
/**
* Render the indicator catalog (available indicators) and active list.
* Catalog items are added via double-click (multiple instances allowed).
*/
export function renderIndicatorList() {
const container = document.getElementById('indicatorList');
if (!container) return;
const available = getAvailableIndicators();
container.innerHTML = `
<div class="indicator-catalog">
${available.map(ind => `
<div class="indicator-catalog-item ${previewingType === ind.type ? 'previewing' : ''}"
title="${ind.description || ''}"
data-type="${ind.type}">
<span class="indicator-catalog-name">${ind.name}</span>
<span class="indicator-catalog-add" data-type="${ind.type}">+</span>
</div>
`).join('')}
</div>
${activeIndicators.length > 0 ? `
<div class="indicator-active-divider">Active</div>
<div class="indicator-active-list">
${activeIndicators.map(ind => {
const isConfiguring = ind.id === configuringId;
const plotGroups = groupPlotsByColor(ind.plots || []);
const colorDots = plotGroups.map(group => {
const firstIdx = group.indices[0];
const color = ind.params[`_color_${firstIdx}`] || '#2962ff';
return `<span class="indicator-color-dot" style="background: ${color};"></span>`;
}).join('');
const label = getIndicatorLabel(ind);
return `
<div class="indicator-active-item ${isConfiguring ? 'configuring' : ''}"
data-id="${ind.id}">
<span class="indicator-active-eye" data-id="${ind.id}"
title="${ind.visible !== false ? 'Hide' : 'Show'}">
${ind.visible !== false ? '👁' : '👁‍🗨'}
</span>
<span class="indicator-active-name" data-id="${ind.id}">${label}</span>
${colorDots}
<button class="indicator-config-btn ${isConfiguring ? 'active' : ''}"
data-id="${ind.id}" title="Configure">⚙</button>
<button class="indicator-remove-btn"
data-id="${ind.id}" title="Remove">×</button>
</div>
`;
}).join('')}
</div>
` : ''}
`;
// Bind events via delegation
container.querySelectorAll('.indicator-catalog-item').forEach(el => {
el.addEventListener('click', () => previewIndicator(el.dataset.type));
el.addEventListener('dblclick', () => addIndicator(el.dataset.type));
});
container.querySelectorAll('.indicator-catalog-add').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
addIndicator(el.dataset.type);
});
});
container.querySelectorAll('.indicator-active-name').forEach(el => {
el.addEventListener('click', () => selectIndicatorConfig(el.dataset.id));
});
container.querySelectorAll('.indicator-config-btn').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
selectIndicatorConfig(el.dataset.id);
});
});
container.querySelectorAll('.indicator-remove-btn').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
removeIndicatorById(el.dataset.id);
});
});
container.querySelectorAll('.indicator-active-eye').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
toggleVisibility(el.dataset.id);
});
});
updateConfigPanel();
updateChartLegend();
}
function updateConfigPanel() {
const configPanel = document.getElementById('indicatorConfigPanel');
const configButtons = document.getElementById('configButtons');
if (!configPanel) return;
configPanel.style.display = 'block';
// Active indicator config takes priority over preview
const indicator = configuringId ? activeIndicators.find(a => a.id === configuringId) : null;
if (indicator) {
renderIndicatorConfig(indicator);
if (configButtons) configButtons.style.display = 'flex';
} else if (previewingType) {
renderPreviewConfig(previewingType);
if (configButtons) configButtons.style.display = 'none';
} else {
const container = document.getElementById('configForm');
if (container) {
container.innerHTML = '<div style="text-align: center; color: var(--tv-text-secondary); padding: 20px; font-size: 12px;">Click an indicator to preview its settings</div>';
}
if (configButtons) configButtons.style.display = 'none';
}
}
/** Single-click: preview config for a catalog indicator type (read-only) */
function previewIndicator(type) {
configuringId = null;
previewingType = previewingType === type ? null : type;
renderIndicatorList();
}
/** Render a read-only preview of an indicator's default config */
function renderPreviewConfig(type) {
const container = document.getElementById('configForm');
if (!container) return;
const IndicatorClass = IR?.[type];
if (!IndicatorClass) return;
const instance = new IndicatorClass({ type, params: {}, name: '' });
const meta = instance.getMetadata();
container.innerHTML = `
<div style="font-size: 11px; color: var(--tv-blue); margin-bottom: 4px; font-weight: 600;">${meta.name}</div>
<div style="font-size: 11px; color: var(--tv-text-secondary); margin-bottom: 10px;">${meta.description || ''}</div>
${meta.inputs.map(input => `
<div style="margin-bottom: 8px;">
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">${input.label}</label>
${input.type === 'select' ?
`<select class="sim-input" style="font-size: 12px; padding: 6px;" disabled>${input.options.map(o => `<option ${input.default === o ? 'selected' : ''}>${o}</option>`).join('')}</select>` :
`<input type="number" class="sim-input" value="${input.default}" ${input.step !== undefined ? `step="${input.step}"` : ''} style="font-size: 12px; padding: 6px;" disabled>`
}
</div>
`).join('')}
<div style="font-size: 10px; color: var(--tv-text-secondary); margin-top: 8px; text-align: center;">Double-click to add to chart</div>
`;
}
/** Add a new instance of an indicator type */
export function addIndicator(type) {
const IndicatorClass = IR?.[type];
if (!IndicatorClass) return;
previewingType = null;
const id = `${type}_${nextInstanceId++}`;
const instance = new IndicatorClass({ type, params: {}, name: '' });
const metadata = instance.getMetadata();
const params = {
_lineType: 'solid',
_lineWidth: 2
};
metadata.plots.forEach((plot, idx) => {
params[`_color_${idx}`] = plot.color || getDefaultColor(activeIndicators.length + idx);
});
metadata.inputs.forEach(input => {
params[input.name] = input.default;
});
activeIndicators.push({
id,
type,
name: metadata.name,
params,
plots: metadata.plots,
series: [],
visible: true
});
configuringId = id;
renderIndicatorList();
drawIndicatorsOnChart();
}
function selectIndicatorConfig(id) {
previewingType = null;
if (configuringId === id) {
configuringId = null;
} else {
configuringId = id;
}
renderIndicatorList();
}
function toggleVisibility(id) {
const indicator = activeIndicators.find(a => a.id === id);
if (!indicator) return;
indicator.visible = indicator.visible === false ? true : false;
// Show/hide all series for this indicator
indicator.series?.forEach(s => {
try {
s.applyOptions({ visible: indicator.visible });
} catch(e) {}
});
renderIndicatorList();
}
export function renderIndicatorConfig(indicator) {
const container = document.getElementById('configForm');
if (!container || !indicator) return;
const IndicatorClass = IR?.[indicator.type];
if (!IndicatorClass) {
container.innerHTML = '<div style="color: var(--tv-red);">Error loading indicator</div>';
return;
}
const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name });
const meta = instance.getMetadata();
const plotGroups = groupPlotsByColor(meta.plots);
const colorInputs = plotGroups.map(group => {
const firstIdx = group.indices[0];
const color = indicator.params[`_color_${firstIdx}`] || meta.plots[firstIdx].color || '#2962ff';
return `
<div style="margin-bottom: 8px;">
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">${group.name} Color</label>
<input type="color" id="config__color_${firstIdx}" value="${color}" style="width: 100%; height: 28px; border: 1px solid var(--tv-border); border-radius: 4px; cursor: pointer; background: var(--tv-bg);">
</div>
`;
}).join('');
container.innerHTML = `
<div style="font-size: 11px; color: var(--tv-blue); margin-bottom: 8px; font-weight: 600;">${getIndicatorLabel(indicator)}</div>
${colorInputs}
<div style="margin-bottom: 8px;">
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">Line Type</label>
<select id="config__lineType" class="sim-input" style="font-size: 12px; padding: 6px;">
${LINE_TYPES.map(lt => `<option value="${lt}" ${indicator.params._lineType === lt ? 'selected' : ''}>${lt.charAt(0).toUpperCase() + lt.slice(1)}</option>`).join('')}
</select>
</div>
<div style="margin-bottom: 8px;">
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">Line Width</label>
<input type="number" id="config__lineWidth" class="sim-input" value="${indicator.params._lineWidth || 2}" min="1" max="5" style="font-size: 12px; padding: 6px;">
</div>
${meta.inputs.map(input => `
<div style="margin-bottom: 8px;">
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">${input.label}</label>
${input.type === 'select' ?
`<select id="config_${input.name}" class="sim-input" style="font-size: 12px; padding: 6px;">${input.options.map(o => `<option value="${o}" ${indicator.params[input.name] === o ? 'selected' : ''}>${o}</option>`).join('')}</select>` :
`<input type="number" id="config_${input.name}" class="sim-input" value="${indicator.params[input.name]}" ${input.min !== undefined ? `min="${input.min}"` : ''} ${input.max !== undefined ? `max="${input.max}"` : ''} ${input.step !== undefined ? `step="${input.step}"` : ''} style="font-size: 12px; padding: 6px;">`
}
</div>
`).join('')}
`;
}
export function applyIndicatorConfig() {
const indicator = configuringId ? activeIndicators.find(a => a.id === configuringId) : null;
if (!indicator) return;
const IndicatorClass = IR?.[indicator.type];
if (!IndicatorClass) return;
const instance = new IndicatorClass({ type: indicator.type, params: {}, name: indicator.name });
const meta = instance.getMetadata();
const plotGroups = groupPlotsByColor(meta.plots);
plotGroups.forEach(group => {
const firstIdx = group.indices[0];
const colorEl = document.getElementById(`config__color_${firstIdx}`);
if (colorEl) {
const color = colorEl.value;
group.indices.forEach(idx => {
indicator.params[`_color_${idx}`] = color;
});
}
});
const lineTypeEl = document.getElementById('config__lineType');
const lineWidthEl = document.getElementById('config__lineWidth');
if (lineTypeEl) indicator.params._lineType = lineTypeEl.value;
if (lineWidthEl) indicator.params._lineWidth = parseInt(lineWidthEl.value);
meta.inputs.forEach(input => {
const el = document.getElementById(`config_${input.name}`);
if (el) {
indicator.params[input.name] = input.type === 'select' ? el.value : parseFloat(el.value);
}
});
renderIndicatorList();
drawIndicatorsOnChart();
}
export function removeIndicator() {
if (!configuringId) return;
removeIndicatorById(configuringId);
}
export function removeIndicatorById(id) {
const idx = activeIndicators.findIndex(a => a.id === id);
if (idx < 0) return;
activeIndicators[idx].series?.forEach(s => {
try { window.dashboard?.chart?.removeSeries(s); } catch(e) {}
});
activeIndicators.splice(idx, 1);
if (configuringId === id) {
configuringId = null;
}
renderIndicatorList();
drawIndicatorsOnChart();
}
export function removeIndicatorByIndex(index) {
if (index < 0 || index >= activeIndicators.length) return;
removeIndicatorById(activeIndicators[index].id);
}
let indicatorPanes = new Map();
let nextPaneIndex = 1;
export function drawIndicatorsOnChart() {
if (!window.dashboard || !window.dashboard.chart) return;
activeIndicators.forEach(ind => {
ind.series?.forEach(s => {
try { window.dashboard.chart.removeSeries(s); } catch(e) {}
});
});
const candles = window.dashboard.allData.get(window.dashboard.currentInterval);
if (!candles || candles.length === 0) return;
const lineStyleMap = { 'solid': LightweightCharts.LineStyle.Solid, 'dotted': LightweightCharts.LineStyle.Dotted, 'dashed': LightweightCharts.LineStyle.Dashed };
indicatorPanes.clear();
nextPaneIndex = 1;
const overlayIndicators = [];
const paneIndicators = [];
activeIndicators.forEach(ind => {
const IndicatorClass = IR?.[ind.type];
if (!IndicatorClass) return;
const instance = new IndicatorClass({ type: ind.type, params: ind.params, name: ind.name });
const meta = instance.getMetadata();
if (meta.displayMode === 'pane') {
paneIndicators.push({ indicator: ind, meta, instance });
} else {
overlayIndicators.push({ indicator: ind, meta, instance });
}
});
const totalPanes = 1 + paneIndicators.length;
const mainPaneHeight = paneIndicators.length > 0 ? 60 : 100;
const paneHeight = paneIndicators.length > 0 ? Math.floor(40 / paneIndicators.length) : 0;
window.dashboard.chart.panes()[0]?.setHeight(mainPaneHeight);
overlayIndicators.forEach(({ indicator, meta, instance }) => {
if (indicator.visible === false) {
indicator.series = [];
return;
}
renderIndicatorOnPane(indicator, meta, instance, candles, 0, lineStyleMap);
});
paneIndicators.forEach(({ indicator, meta, instance }, idx) => {
if (indicator.visible === false) {
indicator.series = [];
return;
}
const paneIndex = nextPaneIndex++;
indicatorPanes.set(indicator.id, paneIndex);
renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap);
const pane = window.dashboard.chart.panes()[paneIndex];
if (pane) {
pane.setHeight(paneHeight);
}
});
updateChartLegend();
}
function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) {
const results = instance.calculate(candles);
indicator.series = [];
const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid;
const lineWidth = indicator.params._lineWidth || 2;
const firstNonNull = results?.find(r => r !== null && r !== undefined);
const isObjectResult = firstNonNull && typeof firstNonNull === 'object';
meta.plots.forEach((plot, plotIdx) => {
if (isObjectResult) {
// Find if this specific plot has any non-null data across all results
const hasData = results.some(r => r && r[plot.id] !== undefined && r[plot.id] !== null);
if (!hasData) return;
}
const plotColor = indicator.params[`_color_${plotIdx}`] || plot.color || '#2962ff';
const data = [];
for (let i = 0; i < candles.length; i++) {
let value;
if (isObjectResult) {
value = results[i]?.[plot.id];
} else {
value = results[i];
}
if (value !== null && value !== undefined) {
data.push({
time: candles[i].time,
value: value
});
}
}
if (data.length === 0) return;
let series;
// Determine line style for this specific plot
let plotLineStyle = lineStyle;
if (plot.style === 'dashed') plotLineStyle = LightweightCharts.LineStyle.Dashed;
else if (plot.style === 'dotted') plotLineStyle = LightweightCharts.LineStyle.Dotted;
else if (plot.style === 'solid') plotLineStyle = LightweightCharts.LineStyle.Solid;
if (plot.type === 'histogram') {
series = window.dashboard.chart.addSeries(LightweightCharts.HistogramSeries, {
color: plotColor,
priceFormat: {
type: 'price',
precision: 4,
minMove: 0.0001
},
priceLineVisible: false,
lastValueVisible: false
}, paneIndex);
} else if (plot.type === 'baseline') {
series = window.dashboard.chart.addSeries(LightweightCharts.BaselineSeries, {
baseValue: { type: 'price', price: plot.baseValue || 0 },
topLineColor: plot.topLineColor || plotColor,
topFillColor1: plot.topFillColor1 || plotColor,
topFillColor2: plot.topFillColor2 || '#00000000',
bottomFillColor1: plot.bottomFillColor1 || '#00000000',
bottomColor: plot.bottomColor || '#00000000',
lineWidth: plot.width !== undefined ? plot.width : lineWidth,
lineStyle: plotLineStyle,
title: plot.title || '',
priceLineVisible: false,
lastValueVisible: plot.lastValueVisible !== false
}, paneIndex);
} else {
series = window.dashboard.chart.addSeries(LightweightCharts.LineSeries, {
color: plotColor,
lineWidth: plot.width !== undefined ? plot.width : lineWidth,
lineStyle: plotLineStyle,
title: plot.title || '',
priceLineVisible: false,
lastValueVisible: plot.lastValueVisible !== false
}, paneIndex);
}
series.setData(data);
indicator.series.push(series);
});
// Render gradient zones if available
if (meta.gradientZones && indicator.series.length > 0) {
// Find the main series to attach zones to
let baseSeries = indicator.series[0];
meta.gradientZones.forEach(zone => {
if (zone.from === undefined || zone.to === undefined) return;
// We use createPriceLine on the series for horizontal bands with custom colors
baseSeries.createPriceLine({
price: zone.from,
color: zone.color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[^)]+\)/, 'rgb($1, $2, $3)'),
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Solid,
axisLabelVisible: false,
title: zone.label || '',
});
if (zone.to !== zone.from) {
baseSeries.createPriceLine({
price: zone.to,
color: zone.color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[^)]+\)/, 'rgb($1, $2, $3)'),
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Solid,
axisLabelVisible: false,
title: '',
});
}
});
}
}
/** Update the TradingView-style legend overlay on the chart */
export function updateChartLegend() {
let legend = document.getElementById('chartIndicatorLegend');
if (!legend) {
const chartWrapper = document.getElementById('chartWrapper');
if (!chartWrapper) return;
legend = document.createElement('div');
legend.id = 'chartIndicatorLegend';
legend.className = 'chart-indicator-legend';
chartWrapper.appendChild(legend);
}
if (activeIndicators.length === 0) {
legend.innerHTML = '';
legend.style.display = 'none';
return;
}
legend.style.display = 'flex';
legend.innerHTML = activeIndicators.map(ind => {
const label = getIndicatorLabel(ind);
const plotGroups = groupPlotsByColor(ind.plots || []);
const firstColor = ind.params['_color_0'] || '#2962ff';
const dimmed = ind.visible === false;
return `
<div class="legend-item ${dimmed ? 'legend-dimmed' : ''} ${ind.id === configuringId ? 'legend-selected' : ''}"
data-id="${ind.id}">
<span class="legend-dot" style="background: ${firstColor};"></span>
<span class="legend-label">${label}</span>
<span class="legend-close" data-id="${ind.id}" title="Remove">&times;</span>
</div>
`;
}).join('');
// Bind legend events
legend.querySelectorAll('.legend-item').forEach(el => {
el.addEventListener('click', (e) => {
if (e.target.classList.contains('legend-close')) return;
selectIndicatorConfig(el.dataset.id);
renderIndicatorList();
});
});
legend.querySelectorAll('.legend-close').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
removeIndicatorById(el.dataset.id);
});
});
}
// Legacy compat: toggleIndicator still works for external callers
export function toggleIndicator(type) {
addIndicator(type);
}
export function showIndicatorConfig(index) {
if (index >= 0 && index < activeIndicators.length) {
selectIndicatorConfig(activeIndicators[index].id);
}
}
export function showIndicatorConfigByType(type) {
const ind = activeIndicators.find(a => a.type === type);
if (ind) {
selectIndicatorConfig(ind.id);
}
}
window.addIndicator = addIndicator;
window.toggleIndicator = toggleIndicator;
window.showIndicatorConfig = showIndicatorConfig;
window.applyIndicatorConfig = applyIndicatorConfig;
window.removeIndicator = removeIndicator;
window.removeIndicatorById = removeIndicatorById;
window.removeIndicatorByIndex = removeIndicatorByIndex;
window.drawIndicatorsOnChart = drawIndicatorsOnChart;

View File

@ -0,0 +1,23 @@
export function toggleSidebar() {
const sidebar = document.getElementById('rightSidebar');
sidebar.classList.toggle('collapsed');
localStorage.setItem('sidebar_collapsed', sidebar.classList.contains('collapsed'));
// Resize chart after sidebar toggle
setTimeout(() => {
if (window.dashboard && window.dashboard.chart) {
const container = document.getElementById('chart');
window.dashboard.chart.applyOptions({
width: container.clientWidth,
height: container.clientHeight
});
}
}, 350); // Wait for CSS transition
}
export function restoreSidebarState() {
const collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
if (collapsed) {
document.getElementById('rightSidebar').classList.add('collapsed');
}
}

View File

@ -0,0 +1,388 @@
import { ClientStrategyEngine } from '../strategies/index.js';
import { SimulationStorage } from './storage.js';
import { downloadFile } from '../utils/index.js';
import { showExportDialog, closeExportDialog, performExport } from './export.js';
let lastSimulationResults = null;
export function getLastResults() {
return lastSimulationResults;
}
export function setLastResults(results) {
lastSimulationResults = results;
window.lastSimulationResults = results;
}
export async function runSimulation() {
const strategyConfig = getStrategyConfig();
if (!strategyConfig) {
alert('Please select a strategy');
return;
}
const startDateInput = document.getElementById('simStartDate').value;
if (!startDateInput) {
alert('Please select a start date');
return;
}
const runBtn = document.getElementById('runSimBtn');
runBtn.disabled = true;
runBtn.textContent = '⏳ Running...';
try {
const start = new Date(startDateInput);
const fetchStart = new Date(start.getTime() - 200 * 24 * 60 * 60 * 1000);
if (!window.dashboard) {
throw new Error('Dashboard not initialized');
}
const interval = window.dashboard.currentInterval;
const secondaryTF = document.getElementById('simSecondaryTF').value;
const riskPercent = parseFloat(document.getElementById('simRiskPercent').value);
const stopLossPercent = parseFloat(document.getElementById('simStopLoss').value);
const timeframes = [interval];
if (secondaryTF && secondaryTF !== '') {
timeframes.push(secondaryTF);
}
const query = new URLSearchParams({ symbol: 'BTC', start: fetchStart.toISOString() });
timeframes.forEach(tf => query.append('timeframes', tf));
console.log('Fetching candles with query:', query.toString());
const response = await fetch(`/api/v1/candles/bulk?${query.toString()}`);
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log('Candle data received:', data);
console.log('Looking for interval:', interval);
console.log('Available timeframes:', Object.keys(data));
if (!data[interval] || data[interval].length === 0) {
throw new Error(`No candle data available for ${interval} timeframe. Check if data exists in database.`);
}
const candlesMap = {
[interval]: data[interval].map(c => ({
time: Math.floor(new Date(c.time).getTime() / 1000),
open: parseFloat(c.open),
high: parseFloat(c.high),
low: parseFloat(c.low),
close: parseFloat(c.close)
}))
};
if (secondaryTF && data[secondaryTF]) {
candlesMap[secondaryTF] = data[secondaryTF].map(c => ({
time: Math.floor(new Date(c.time).getTime() / 1000),
open: parseFloat(c.open),
high: parseFloat(c.high),
low: parseFloat(c.low),
close: parseFloat(c.close)
}));
}
const engineConfig = {
id: strategyConfig.id,
params: strategyConfig.params,
timeframes: { primary: interval, secondary: secondaryTF ? [secondaryTF] : [] },
indicators: []
};
console.log('Building strategy config:');
console.log(' Primary TF:', interval);
console.log(' Secondary TF:', secondaryTF);
console.log(' Available candles:', Object.keys(candlesMap));
if (strategyConfig.id === 'ma_trend') {
const period = strategyConfig.params?.period || 44;
engineConfig.indicators.push({
name: `ma${period}`,
type: 'sma',
params: { period: period },
timeframe: interval
});
if (secondaryTF) {
engineConfig.indicators.push({
name: `ma${period}_${secondaryTF}`,
type: 'sma',
params: { period: period },
timeframe: secondaryTF
});
}
}
console.log(' Indicators configured:', engineConfig.indicators.map(i => `${i.name} on ${i.timeframe}`));
const riskConfig = {
positionSizing: { method: 'percent', value: riskPercent },
stopLoss: { enabled: true, method: 'percent', value: stopLossPercent }
};
const engine = new ClientStrategyEngine();
const results = engine.run(candlesMap, engineConfig, riskConfig, start);
if (results.error) throw new Error(results.error);
setLastResults({
...results,
config: {
strategyId: strategyConfig.id,
strategyName: window.availableStrategies?.find(s => s.id === strategyConfig.id)?.name || strategyConfig.id,
timeframe: interval,
secondaryTimeframe: secondaryTF,
startDate: startDateInput,
riskPercent: riskPercent,
stopLossPercent: stopLossPercent,
params: strategyConfig.params
},
runAt: new Date().toISOString()
});
displayEnhancedResults(lastSimulationResults);
document.getElementById('resultsSection').style.display = 'block';
if (window.dashboard && candlesMap[interval]) {
const chartData = candlesMap[interval].map(c => ({
time: c.time,
open: c.open,
high: c.high,
low: c.low,
close: c.close
}));
window.dashboard.candleSeries.setData(chartData);
window.dashboard.allData.set(interval, chartData);
console.log(`Chart updated with ${chartData.length} candles from simulation range`);
}
showSimulationMarkers();
} catch (error) {
console.error('Simulation error:', error);
alert('Simulation error: ' + error.message);
} finally {
runBtn.disabled = false;
runBtn.textContent = '▶ Run Simulation';
}
}
export function displayEnhancedResults(simulation) {
const results = simulation.results || simulation;
document.getElementById('simTrades').textContent = results.total_trades || '0';
document.getElementById('simWinRate').textContent = (results.win_rate || 0).toFixed(1) + '%';
const pnl = results.total_pnl || 0;
const pnlElement = document.getElementById('simPnL');
pnlElement.textContent = (pnl >= 0 ? '+' : '') + '$' + pnl.toFixed(2);
pnlElement.style.color = pnl >= 0 ? '#4caf50' : '#f44336';
let grossProfit = 0;
let grossLoss = 0;
(results.trades || []).forEach(trade => {
if (trade.pnl > 0) grossProfit += trade.pnl;
else grossLoss += Math.abs(trade.pnl);
});
const profitFactor = grossLoss > 0 ? (grossProfit / grossLoss).toFixed(2) : grossProfit > 0 ? '∞' : '0';
document.getElementById('simProfitFactor').textContent = profitFactor;
drawEquitySparkline(results);
}
function drawEquitySparkline(results) {
const container = document.getElementById('equitySparkline');
if (!container || !results.trades || results.trades.length === 0) {
container.innerHTML = '<div style="text-align: center; color: var(--tv-text-secondary); padding: 20px; font-size: 11px;">No trades</div>';
return;
}
let equity = 1000;
const equityData = [{ time: results.trades[0].entryTime, equity: equity }];
results.trades.forEach(trade => {
equity += trade.pnl;
equityData.push({ time: trade.exitTime, equity: equity });
});
if (lastSimulationResults) {
lastSimulationResults.equity_curve = equityData;
}
container.innerHTML = '<canvas id="sparklineCanvas" width="300" height="60"></canvas>';
const canvas = document.getElementById('sparklineCanvas');
const ctx = canvas.getContext('2d');
const minEquity = Math.min(...equityData.map(d => d.equity));
const maxEquity = Math.max(...equityData.map(d => d.equity));
const range = maxEquity - minEquity || 1;
ctx.strokeStyle = equityData[equityData.length - 1].equity >= equityData[0].equity ? '#4caf50' : '#f44336';
ctx.lineWidth = 2;
ctx.beginPath();
equityData.forEach((point, i) => {
const x = (i / (equityData.length - 1)) * canvas.width;
const y = canvas.height - ((point.equity - minEquity) / range) * canvas.height;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.fillStyle = '#888';
ctx.font = '9px sans-serif';
ctx.fillText('$' + equityData[0].equity.toFixed(0), 2, canvas.height - 2);
ctx.fillText('$' + equityData[equityData.length - 1].equity.toFixed(0), canvas.width - 30, 10);
}
let tradeLineSeries = [];
export function showSimulationMarkers() {
const results = getLastResults();
if (!results || !window.dashboard) return;
const trades = results.trades || results.results?.trades || [];
const markers = [];
clearSimulationMarkers();
console.log('Plotting trades:', trades.length);
trades.forEach((trade, i) => {
let entryTime, exitTime;
if (typeof trade.entryTime === 'number') {
entryTime = trade.entryTime;
} else {
entryTime = Math.floor(new Date(trade.entryTime).getTime() / 1000);
}
if (typeof trade.exitTime === 'number') {
exitTime = trade.exitTime;
} else {
exitTime = Math.floor(new Date(trade.exitTime).getTime() / 1000);
}
const pnlSymbol = trade.pnl > 0 ? '+' : '';
markers.push({
time: entryTime,
position: 'belowBar',
color: '#2196f3',
shape: 'arrowUp',
text: 'BUY',
size: 1
});
markers.push({
time: exitTime,
position: 'aboveBar',
color: trade.pnl > 0 ? '#4caf50' : '#f44336',
shape: 'arrowDown',
text: `SELL ${pnlSymbol}${trade.pnlPct.toFixed(1)}%`,
size: 1
});
const lineSeries = window.dashboard.chart.addSeries(LightweightCharts.LineSeries, {
color: '#2196f3',
lineWidth: 1,
lastValueVisible: false,
title: '',
priceLineVisible: false,
crosshairMarkerVisible: false
}, 0);
lineSeries.setData([
{ time: entryTime, value: trade.entryPrice },
{ time: exitTime, value: trade.exitPrice }
]);
tradeLineSeries.push(lineSeries);
});
markers.sort((a, b) => a.time - b.time);
window.dashboard.candleSeries.setMarkers(markers);
console.log(`Plotted ${trades.length} trades with connection lines`);
}
export function clearSimulationMarkers() {
try {
if (window.dashboard && window.dashboard.candleSeries && typeof window.dashboard.candleSeries.setMarkers === 'function') {
window.dashboard.candleSeries.setMarkers([]);
}
} catch (e) {
// Ignore errors clearing markers
}
try {
tradeLineSeries.forEach(series => {
try {
if (window.dashboard && window.dashboard.chart) {
window.dashboard.chart.removeSeries(series);
}
} catch (e) {
// Series might already be removed
}
});
} catch (e) {
// Ignore errors removing series
}
tradeLineSeries = [];
}
export function clearSimulationResults() {
clearSimulationMarkers();
setLastResults(null);
const resultsSection = document.getElementById('resultsSection');
if (resultsSection) {
resultsSection.style.display = 'none';
}
const simTrades = document.getElementById('simTrades');
const simWinRate = document.getElementById('simWinRate');
const simPnL = document.getElementById('simPnL');
const simProfitFactor = document.getElementById('simProfitFactor');
const equitySparkline = document.getElementById('equitySparkline');
if (simTrades) simTrades.textContent = '0';
if (simWinRate) simWinRate.textContent = '0%';
if (simPnL) {
simPnL.textContent = '$0.00';
simPnL.style.color = '';
}
if (simProfitFactor) simProfitFactor.textContent = '0';
if (equitySparkline) equitySparkline.innerHTML = '';
}
function getStrategyConfig() {
const strategyId = window.currentStrategy;
if (!strategyId) return null;
const params = {};
const paramDefs = window.StrategyParams?.[strategyId] || [];
paramDefs.forEach(def => {
const input = document.getElementById(`param_${def.name}`);
if (input) {
params[def.name] = def.type === 'number' ? parseFloat(input.value) : input.value;
}
});
return {
id: strategyId,
params: params
};
}

View File

@ -0,0 +1,47 @@
export const SimulationStorage = {
STORAGE_KEY: 'btc_bot_simulations',
getAll() {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch (e) {
console.error('Error reading simulations:', e);
return [];
}
},
save(simulation) {
try {
const simulations = this.getAll();
simulation.id = simulation.id || 'sim_' + Date.now();
simulation.createdAt = new Date().toISOString();
simulations.push(simulation);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(simulations));
return simulation.id;
} catch (e) {
console.error('Error saving simulation:', e);
return null;
}
},
delete(id) {
try {
let simulations = this.getAll();
simulations = simulations.filter(s => s.id !== id);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(simulations));
return true;
} catch (e) {
console.error('Error deleting simulation:', e);
return false;
}
},
get(id) {
return this.getAll().find(s => s.id === id);
},
clear() {
localStorage.removeItem(this.STORAGE_KEY);
}
};

View File

@ -0,0 +1,309 @@
import { StrategyParams } from '../strategies/config.js';
let currentStrategy = null;
export function getCurrentStrategy() {
return currentStrategy;
}
export function setCurrentStrategy(strategyId) {
currentStrategy = strategyId;
window.currentStrategy = strategyId;
}
export function renderStrategies(strategies) {
const container = document.getElementById('strategyList');
if (!strategies || strategies.length === 0) {
container.innerHTML = '<div style="text-align: center; color: var(--tv-text-secondary); padding: 20px;">No strategies available</div>';
return;
}
container.innerHTML = strategies.map((s, index) => `
<div class="strategy-item ${index === 0 ? 'selected' : ''}" data-strategy-id="${s.id}" onclick="selectStrategy('${s.id}')">
<input type="radio" name="strategy" class="strategy-radio" ${index === 0 ? 'checked' : ''}>
<span class="strategy-name">${s.name}</span>
<span class="strategy-info" title="${s.description}">ⓘ</span>
</div>
`).join('');
if (strategies.length > 0) {
selectStrategy(strategies[0].id);
}
document.getElementById('runSimBtn').disabled = false;
}
export function selectStrategy(strategyId) {
document.querySelectorAll('.strategy-item').forEach(item => {
item.classList.toggle('selected', item.dataset.strategyId === strategyId);
const radio = item.querySelector('input[type="radio"]');
if (radio) radio.checked = item.dataset.strategyId === strategyId;
});
setCurrentStrategy(strategyId);
renderStrategyParams(strategyId);
}
export function renderStrategyParams(strategyId) {
const container = document.getElementById('strategyParams');
const params = StrategyParams[strategyId] || [];
if (params.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = params.map(param => `
<div class="config-group">
<label class="config-label">${param.label}</label>
<input type="${param.type}"
id="param_${param.name}"
class="config-input"
value="${param.default}"
${param.min !== undefined ? `min="${param.min}"` : ''}
${param.max !== undefined ? `max="${param.max}"` : ''}
${param.step !== undefined ? `step="${param.step}"` : ''}
>
</div>
`).join('');
}
export async function loadStrategies() {
try {
console.log('Fetching strategies from API...');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch('/api/v1/strategies?_=' + Date.now(), {
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('Strategies loaded:', data);
if (!data.strategies) {
throw new Error('Invalid response format: missing strategies array');
}
window.availableStrategies = data.strategies;
renderStrategies(data.strategies);
} catch (error) {
console.error('Error loading strategies:', error);
let errorMessage = error.message;
if (error.name === 'AbortError') {
errorMessage = 'Request timeout - API server not responding';
} else if (error.message.includes('Failed to fetch')) {
errorMessage = 'Cannot connect to API server - is it running?';
}
document.getElementById('strategyList').innerHTML =
`<div style="color: var(--tv-red); padding: 20px; text-align: center;">
${errorMessage}<br>
<small style="color: var(--tv-text-secondary);">Check console (F12) for details</small>
</div>`;
}
}
export function saveSimulation() {
const results = getLastResults();
if (!results) {
alert('Please run a simulation first');
return;
}
const defaultName = generateSimulationName(results.config);
const name = prompt('Save simulation as:', defaultName);
if (!name || name.trim() === '') return;
const simulation = {
name: name.trim(),
config: results.config,
results: {
total_trades: results.total_trades,
win_rate: results.win_rate,
total_pnl: results.total_pnl,
trades: results.trades,
equity_curve: results.equity_curve
}
};
const id = window.SimulationStorage?.save(simulation);
if (id) {
renderSavedSimulations();
alert('Simulation saved successfully!');
} else {
alert('Error saving simulation');
}
}
function generateSimulationName(config) {
if (!config) return 'Unnamed Simulation';
const start = new Date(config.startDate);
const now = new Date();
const duration = now - start;
const oneDay = 24 * 60 * 60 * 1000;
let dateStr;
if (duration < oneDay) {
dateStr = start.toISOString().slice(0, 16).replace('T', ' ');
} else {
dateStr = start.toISOString().slice(0, 10);
}
return `${config.strategyName}_${config.timeframe}_${dateStr}`;
}
export function renderSavedSimulations() {
const container = document.getElementById('savedSimulations');
const simulations = window.SimulationStorage?.getAll() || [];
if (simulations.length === 0) {
container.innerHTML = '<div style="text-align: center; color: var(--tv-text-secondary); padding: 10px; font-size: 12px;">No saved simulations</div>';
return;
}
container.innerHTML = simulations.map(sim => `
<div class="saved-sim-item">
<span class="saved-sim-name" onclick="loadSavedSimulation('${sim.id}')" title="${sim.name}">
${sim.name.length > 25 ? sim.name.slice(0, 25) + '...' : sim.name}
</span>
<div class="saved-sim-actions">
<button class="sim-action-btn" onclick="loadSavedSimulation('${sim.id}')" title="Load">📂</button>
<button class="sim-action-btn" onclick="exportSavedSimulation('${sim.id}')" title="Export">📥</button>
<button class="sim-action-btn" onclick="deleteSavedSimulation('${sim.id}')" title="Delete">🗑️</button>
</div>
</div>
`).join('');
}
export function loadSavedSimulation(id) {
const sim = window.SimulationStorage?.get(id);
if (!sim) {
alert('Simulation not found');
return;
}
if (sim.config) {
document.getElementById('simSecondaryTF').value = sim.config.secondaryTimeframe || '';
document.getElementById('simStartDate').value = sim.config.startDate || '';
document.getElementById('simRiskPercent').value = sim.config.riskPercent || 2;
document.getElementById('simStopLoss').value = sim.config.stopLossPercent || 2;
if (sim.config.strategyId) {
selectStrategy(sim.config.strategyId);
if (sim.config.params) {
Object.entries(sim.config.params).forEach(([key, value]) => {
const input = document.getElementById(`param_${key}`);
if (input) input.value = value;
});
}
}
}
setLastResults(sim);
displayEnhancedResults(sim.results);
document.getElementById('resultsSection').style.display = 'block';
}
export function deleteSavedSimulation(id) {
if (!confirm('Are you sure you want to delete this simulation?')) return;
if (window.SimulationStorage?.delete(id)) {
renderSavedSimulations();
}
}
function displayEnhancedResults(simulation) {
const results = simulation.results || simulation;
document.getElementById('simTrades').textContent = results.total_trades || '0';
document.getElementById('simWinRate').textContent = (results.win_rate || 0).toFixed(1) + '%';
const pnl = results.total_pnl || 0;
const pnlElement = document.getElementById('simPnL');
pnlElement.textContent = (pnl >= 0 ? '+' : '') + '$' + pnl.toFixed(2);
pnlElement.style.color = pnl >= 0 ? '#4caf50' : '#f44336';
let grossProfit = 0;
let grossLoss = 0;
(results.trades || []).forEach(trade => {
if (trade.pnl > 0) grossProfit += trade.pnl;
else grossLoss += Math.abs(trade.pnl);
});
const profitFactor = grossLoss > 0 ? (grossProfit / grossLoss).toFixed(2) : grossProfit > 0 ? '∞' : '0';
document.getElementById('simProfitFactor').textContent = profitFactor;
drawEquitySparkline(results);
}
function drawEquitySparkline(results) {
const container = document.getElementById('equitySparkline');
if (!container || !results.trades || results.trades.length === 0) {
container.innerHTML = '<div style="text-align: center; color: var(--tv-text-secondary); padding: 20px; font-size: 11px;">No trades</div>';
return;
}
let equity = 1000;
const equityData = [{ time: results.trades[0].entryTime, equity: equity }];
results.trades.forEach(trade => {
equity += trade.pnl;
equityData.push({ time: trade.exitTime, equity: equity });
});
const sim = getLastResults();
if (sim) {
sim.equity_curve = equityData;
}
container.innerHTML = '<canvas id="sparklineCanvas" width="300" height="60"></canvas>';
const canvas = document.getElementById('sparklineCanvas');
const ctx = canvas.getContext('2d');
const minEquity = Math.min(...equityData.map(d => d.equity));
const maxEquity = Math.max(...equityData.map(d => d.equity));
const range = maxEquity - minEquity || 1;
ctx.strokeStyle = equityData[equityData.length - 1].equity >= equityData[0].equity ? '#4caf50' : '#f44336';
ctx.lineWidth = 2;
ctx.beginPath();
equityData.forEach((point, i) => {
const x = (i / (equityData.length - 1)) * canvas.width;
const y = canvas.height - ((point.equity - minEquity) / range) * canvas.height;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.fillStyle = '#888';
ctx.font = '9px sans-serif';
ctx.fillText('$' + equityData[0].equity.toFixed(0), 2, canvas.height - 2);
ctx.fillText('$' + equityData[equityData.length - 1].equity.toFixed(0), canvas.width - 30, 10);
}
function getLastResults() {
return window.lastSimulationResults;
}
function setLastResults(results) {
window.lastSimulationResults = results;
}
window.selectStrategy = selectStrategy;
window.loadSavedSimulation = loadSavedSimulation;
window.deleteSavedSimulation = deleteSavedSimulation;
window.renderSavedSimulations = renderSavedSimulations;

View File

@ -0,0 +1,23 @@
export function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
export function formatDate(date) {
return new Date(date).toISOString().slice(0, 16);
}
export function formatPrice(price, decimals = 2) {
return price.toFixed(decimals);
}
export function formatPercent(value) {
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
}

View File

@ -0,0 +1 @@
export { downloadFile, formatDate, formatPrice, formatPercent } from './helpers.js';

636
src/api/server.py Normal file
View File

@ -0,0 +1,636 @@
"""
Simplified FastAPI server - working version
Removes the complex WebSocket manager that was causing issues
"""
import os
import asyncio
import logging
from dotenv import load_dotenv
load_dotenv()
from datetime import datetime, timedelta, timezone
from typing import Optional, List
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query, BackgroundTasks, Response
from fastapi.staticfiles import StaticFiles
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
import asyncpg
import csv
import io
from pydantic import BaseModel, Field
# Imports for backtest runner
from src.data_collector.database import DatabaseManager
from src.data_collector.indicator_engine import IndicatorEngine, IndicatorConfig
from src.data_collector.brain import Brain
from src.data_collector.backtester import Backtester
# Imports for strategy discovery
import importlib
from src.strategies.base import BaseStrategy
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Database connection settings
DB_HOST = os.getenv('DB_HOST', 'localhost')
DB_PORT = int(os.getenv('DB_PORT', 5432))
DB_NAME = os.getenv('DB_NAME', 'btc_data')
DB_USER = os.getenv('DB_USER', 'btc_bot')
DB_PASSWORD = os.getenv('DB_PASSWORD', '')
async def get_db_pool():
"""Create database connection pool"""
logger.info(f"Connecting to database: {DB_HOST}:{DB_PORT}/{DB_NAME} as {DB_USER}")
return await asyncpg.create_pool(
host=DB_HOST,
port=DB_PORT,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
min_size=2,
max_size=20,
max_inactive_connection_lifetime=300
)
pool = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifespan"""
global pool
pool = await get_db_pool()
logger.info("API Server started successfully")
yield
if pool:
await pool.close()
logger.info("API Server stopped")
app = FastAPI(
title="BTC Bot Data API",
description="REST API for accessing BTC candle data",
version="1.1.0",
lifespan=lifespan
)
# Enable CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def root():
"""Root endpoint"""
return {
"message": "BTC Bot Data API",
"docs": "/docs",
"dashboard": "/dashboard",
"status": "operational"
}
@app.get("/api/v1/strategies")
async def list_strategies(response: Response):
"""List all available trading strategies with metadata"""
# Prevent caching
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# Strategy registry from brain.py
strategy_registry = {
"ma_trend": "src.strategies.ma_strategy.MAStrategy",
}
strategies = []
for strategy_id, class_path in strategy_registry.items():
try:
module_path, class_name = class_path.rsplit('.', 1)
module = importlib.import_module(module_path)
strategy_class = getattr(module, class_name)
# Instantiate to get metadata
strategy_instance = strategy_class()
strategies.append({
"id": strategy_id,
"name": strategy_instance.display_name,
"description": strategy_instance.description,
"required_indicators": strategy_instance.required_indicators
})
except Exception as e:
logger.error(f"Failed to load strategy {strategy_id}: {e}")
return {
"strategies": strategies,
"count": len(strategies)
}
@app.get("/api/v1/candles")
async def get_candles(
symbol: str = Query("BTC", description="Trading pair symbol"),
interval: str = Query("1m", description="Candle interval"),
start: Optional[datetime] = Query(None, description="Start time (ISO format)"),
end: Optional[datetime] = Query(None, description="End time (ISO format)"),
limit: int = Query(1000, ge=1, le=10000, description="Maximum number of candles")
):
"""Get candle data for a symbol"""
async with pool.acquire() as conn:
query = """
SELECT time, symbol, interval, open, high, low, close, volume, validated
FROM candles
WHERE symbol = $1 AND interval = $2
"""
params = [symbol, interval]
if start:
query += f" AND time >= ${len(params) + 1}"
params.append(start)
if end:
query += f" AND time <= ${len(params) + 1}"
params.append(end)
query += f" ORDER BY time DESC LIMIT ${len(params) + 1}"
params.append(limit)
rows = await conn.fetch(query, *params)
return {
"symbol": symbol,
"interval": interval,
"count": len(rows),
"candles": [dict(row) for row in rows]
}
from typing import Optional, List
# ...
@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"""
logger.info(f"Bulk candle request: {symbol}, TFs: {timeframes}, Start: {start}, End: {end}")
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
]
logger.info(f"Returning {sum(len(v) for v in results.values())} candles total")
return results
@app.get("/api/v1/candles/latest")
async def get_latest_candle(symbol: str = "BTC", interval: str = "1m"):
"""Get the most recent candle"""
async with pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT time, symbol, interval, open, high, low, close, volume
FROM candles
WHERE symbol = $1 AND interval = $2
ORDER BY time DESC
LIMIT 1
""", symbol, interval)
if not row:
raise HTTPException(status_code=404, detail="No data found")
return dict(row)
@app.get("/api/v1/stats")
async def get_stats(symbol: str = "BTC"):
"""Get trading statistics"""
async with pool.acquire() as conn:
# Get latest price and 24h stats
latest = await conn.fetchrow("""
SELECT close, time
FROM candles
WHERE symbol = $1 AND interval = '1m'
ORDER BY time DESC
LIMIT 1
""", symbol)
day_ago = await conn.fetchrow("""
SELECT close
FROM candles
WHERE symbol = $1 AND interval = '1m' AND time <= NOW() - INTERVAL '24 hours'
ORDER BY time DESC
LIMIT 1
""", symbol)
stats_24h = await conn.fetchrow("""
SELECT
MAX(high) as high_24h,
MIN(low) as low_24h,
SUM(volume) as volume_24h
FROM candles
WHERE symbol = $1 AND interval = '1m' AND time > NOW() - INTERVAL '24 hours'
""", symbol)
if not latest:
raise HTTPException(status_code=404, detail="No data found")
current_price = float(latest['close'])
previous_price = float(day_ago['close']) if day_ago else current_price
change_24h = ((current_price - previous_price) / previous_price * 100) if previous_price else 0
return {
"symbol": symbol,
"current_price": current_price,
"change_24h": round(change_24h, 2),
"high_24h": float(stats_24h['high_24h']) if stats_24h['high_24h'] else current_price,
"low_24h": float(stats_24h['low_24h']) if stats_24h['low_24h'] else current_price,
"volume_24h": float(stats_24h['volume_24h']) if stats_24h['volume_24h'] else 0,
"last_update": latest['time'].isoformat()
}
@app.get("/api/v1/health")
async def health_check():
"""System health check"""
try:
async with pool.acquire() as conn:
latest = await conn.fetchrow("""
SELECT symbol, MAX(time) as last_time, COUNT(*) as count
FROM candles
WHERE time > NOW() - INTERVAL '24 hours'
GROUP BY symbol
""")
return {
"status": "healthy",
"database": "connected",
"latest_candles": dict(latest) if latest else None,
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error(f"Health check failed: {e}")
raise HTTPException(status_code=503, detail=f"Health check failed: {str(e)}")
@app.get("/api/v1/indicators")
async def get_indicators(
symbol: str = Query("BTC", description="Trading pair symbol"),
interval: str = Query("1d", description="Candle interval"),
name: str = Query(None, description="Filter by indicator name (e.g., ma44)"),
start: Optional[datetime] = Query(None, description="Start time"),
end: Optional[datetime] = Query(None, description="End time"),
limit: int = Query(1000, le=5000)
):
"""Get indicator values"""
async with pool.acquire() as conn:
query = """
SELECT time, indicator_name, value
FROM indicators
WHERE symbol = $1 AND interval = $2
"""
params = [symbol, interval]
if name:
query += f" AND indicator_name = ${len(params) + 1}"
params.append(name)
if start:
query += f" AND time >= ${len(params) + 1}"
params.append(start)
if end:
query += f" AND time <= ${len(params) + 1}"
params.append(end)
query += f" ORDER BY time DESC LIMIT ${len(params) + 1}"
params.append(limit)
rows = await conn.fetch(query, *params)
# Group by time for easier charting
grouped = {}
for row in rows:
ts = row['time'].isoformat()
if ts not in grouped:
grouped[ts] = {'time': ts}
grouped[ts][row['indicator_name']] = float(row['value'])
return {
"symbol": symbol,
"interval": interval,
"data": list(grouped.values())
}
@app.get("/api/v1/decisions")
async def get_decisions(
symbol: str = Query("BTC"),
interval: Optional[str] = Query(None),
backtest_id: Optional[str] = Query(None),
limit: int = Query(100, le=1000)
):
"""Get brain decisions"""
async with pool.acquire() as conn:
query = """
SELECT time, interval, decision_type, strategy, confidence,
price_at_decision, indicator_snapshot, reasoning, backtest_id
FROM decisions
WHERE symbol = $1
"""
params = [symbol]
if interval:
query += f" AND interval = ${len(params) + 1}"
params.append(interval)
if backtest_id:
query += f" AND backtest_id = ${len(params) + 1}"
params.append(backtest_id)
else:
query += " AND backtest_id IS NULL"
query += f" ORDER BY time DESC LIMIT ${len(params) + 1}"
params.append(limit)
rows = await conn.fetch(query, *params)
return [dict(row) for row in rows]
@app.get("/api/v1/backtests")
async def list_backtests(symbol: Optional[str] = None, limit: int = 20):
"""List historical backtests"""
async with pool.acquire() as conn:
query = """
SELECT id, strategy, symbol, start_time, end_time,
intervals, results, created_at
FROM backtest_runs
"""
params = []
if symbol:
query += " WHERE symbol = $1"
params.append(symbol)
query += f" ORDER BY created_at DESC LIMIT ${len(params) + 1}"
params.append(limit)
rows = await conn.fetch(query, *params)
return [dict(row) for row in rows]
class BacktestRequest(BaseModel):
symbol: str = "BTC"
intervals: list[str] = ["37m"]
start_date: str = "2025-01-01" # ISO date
end_date: Optional[str] = None
async def run_backtest_task(req: BacktestRequest):
"""Background task to run backtest"""
db = DatabaseManager(
host=DB_HOST, port=DB_PORT, database=DB_NAME,
user=DB_USER, password=DB_PASSWORD
)
await db.connect()
try:
# Load configs (hardcoded for now to match main.py)
configs = [
IndicatorConfig("ma44", "sma", 44, req.intervals),
IndicatorConfig("ma125", "sma", 125, req.intervals)
]
engine = IndicatorEngine(db, configs)
brain = Brain(db, engine)
backtester = Backtester(db, engine, brain)
start = datetime.fromisoformat(req.start_date).replace(tzinfo=timezone.utc)
end = datetime.fromisoformat(req.end_date).replace(tzinfo=timezone.utc) if req.end_date else datetime.now(timezone.utc)
await backtester.run(req.symbol, req.intervals, start, end)
except Exception as e:
logger.error(f"Backtest failed: {e}")
finally:
await db.disconnect()
@app.post("/api/v1/backtests")
async def trigger_backtest(req: BacktestRequest, background_tasks: BackgroundTasks):
"""Start a backtest in the background"""
background_tasks.add_task(run_backtest_task, req)
return {"message": "Backtest started", "params": req.dict()}
@app.get("/api/v1/ta")
async def get_technical_analysis(
symbol: str = Query("BTC", description="Trading pair symbol"),
interval: str = Query("1d", description="Candle interval")
):
"""
Get technical analysis for a symbol
Uses stored indicators from DB if available, falls back to on-the-fly calc
"""
try:
async with pool.acquire() as conn:
# 1. Get latest price
latest = await conn.fetchrow("""
SELECT close, time
FROM candles
WHERE symbol = $1 AND interval = $2
ORDER BY time DESC
LIMIT 1
""", symbol, interval)
if not latest:
return {"error": "No candle data found"}
current_price = float(latest['close'])
timestamp = latest['time']
# 2. Get latest indicators from DB
indicators = await conn.fetch("""
SELECT indicator_name, value
FROM indicators
WHERE symbol = $1 AND interval = $2
AND time <= $3
ORDER BY time DESC
""", symbol, interval, timestamp)
# Convert list to dict, e.g. {'ma44': 65000, 'ma125': 64000}
# We take the most recent value for each indicator
ind_map = {}
for row in indicators:
name = row['indicator_name']
if name not in ind_map:
ind_map[name] = float(row['value'])
ma_44 = ind_map.get('ma44')
ma_125 = ind_map.get('ma125')
# Determine trend
if ma_44 and ma_125:
if current_price > ma_44 > ma_125:
trend = "Bullish"
trend_strength = "Strong" if current_price > ma_44 * 1.05 else "Moderate"
elif current_price < ma_44 < ma_125:
trend = "Bearish"
trend_strength = "Strong" if current_price < ma_44 * 0.95 else "Moderate"
else:
trend = "Neutral"
trend_strength = "Consolidation"
else:
trend = "Unknown"
trend_strength = "Insufficient data"
# 3. Find support/resistance (simple recent high/low)
rows = await conn.fetch("""
SELECT high, low
FROM candles
WHERE symbol = $1 AND interval = $2
ORDER BY time DESC
LIMIT 20
""", symbol, interval)
if rows:
highs = [float(r['high']) for r in rows]
lows = [float(r['low']) for r in rows]
resistance = max(highs)
support = min(lows)
price_range = resistance - support
if price_range > 0:
position = (current_price - support) / price_range * 100
else:
position = 50
else:
resistance = current_price
support = current_price
position = 50
return {
"symbol": symbol,
"interval": interval,
"timestamp": timestamp.isoformat(),
"current_price": round(current_price, 2),
"moving_averages": {
"ma_44": round(ma_44, 2) if ma_44 else None,
"ma_125": round(ma_125, 2) if ma_125 else None,
"price_vs_ma44": round((current_price / ma_44 - 1) * 100, 2) if ma_44 else None,
"price_vs_ma125": round((current_price / ma_125 - 1) * 100, 2) if ma_125 else None
},
"trend": {
"direction": trend,
"strength": trend_strength,
"signal": "Buy" if trend == "Bullish" and trend_strength == "Strong" else
"Sell" if trend == "Bearish" and trend_strength == "Strong" else "Hold"
},
"levels": {
"resistance": round(resistance, 2),
"support": round(support, 2),
"position_in_range": round(position, 1)
},
"ai_placeholder": {
"available": False,
"message": "AI analysis available via Gemini or local LLM",
"action": "Click to analyze with AI"
}
}
except Exception as e:
logger.error(f"Technical analysis error: {e}")
raise HTTPException(status_code=500, detail=f"Technical analysis failed: {str(e)}")
@app.get("/api/v1/export/csv")
async def export_csv(
symbol: str = "BTC",
interval: str = "1m",
days: int = Query(7, ge=1, le=365, description="Number of days to export")
):
"""Export candle data to CSV"""
start_date = datetime.utcnow() - timedelta(days=days)
async with pool.acquire() as conn:
query = """
SELECT time, open, high, low, close, volume
FROM candles
WHERE symbol = $1 AND interval = $2 AND time >= $3
ORDER BY time
"""
rows = await conn.fetch(query, symbol, interval, start_date)
if not rows:
raise HTTPException(status_code=404, detail="No data found for export")
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['timestamp', 'open', 'high', 'low', 'close', 'volume'])
for row in rows:
writer.writerow([
row['time'].isoformat(),
row['open'],
row['high'],
row['low'],
row['close'],
row['volume']
])
output.seek(0)
return StreamingResponse(
io.BytesIO(output.getvalue().encode()),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename={symbol}_{interval}_{days}d.csv"
}
)
# Serve static files for dashboard
app.mount("/dashboard", StaticFiles(directory="src/api/dashboard/static", html=True), name="dashboard")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)