brighter-trading/src/static/charts.js

458 lines
16 KiB
JavaScript

class Charts {
constructor(idata) {
// Unpack the initialization data.
this.chart1_id = idata.chart1_id;
this.chart2_id = idata.chart2_id;
this.chart3_id = idata.chart3_id;
this.chart4_id = idata.chart4_id;
this.chart5_id = idata.chart5_id; // Patterns chart
this.trading_pair = idata.trading_pair;
this.price_history = idata.price_history;
/* A list of bound charts this is necessary for maintaining a dynamic
number of charts with their position and zoom factors bound.*/
this.bound_charts=[];
// Store unsubscribe functions for cleanup when rebinding
this._syncUnsubscribes = [];
// Debounce timestamp to prevent infinite loop in chart synchronization
this._lastSyncTime = 0;
// Flag to prevent recursive sync
this._isSyncing = false;
// Only the main chart is created by default.
this.create_main_chart();
}
create_main_chart() {
// Pass the id of the element to create the main chart in.
// This function returns the main chart object.
this.chart_1 = this.create_chart(this.chart1_id);
// Display the trading pair as a watermark overlaying the chart.
this.addWatermark(this.chart_1, this.trading_pair);
// - Create the candle stick series for our chart (v5 API)
this.candleSeries = this.chart_1.addSeries(LightweightCharts.CandlestickSeries);
// Initialize the candlestick series if price_history is available
this.price_history = this._normalizeCandles(this.price_history);
if (this.price_history && this.price_history.length > 0) {
this.candleSeries.setData(this.price_history);
console.log(`Candle series initialized with ${this.price_history.length} candles`);
console.log('First candle:', this.price_history[0]);
console.log('Last candle:', this.price_history[this.price_history.length - 1]);
} else {
console.error('Price history is not available or is empty. Received:', this.price_history);
}
this.bind_charts(this.chart_1);
}
update_main_chart(new_candle){
const normalizedCandle = this._normalizeCandle(new_candle);
if (!normalizedCandle) {
console.warn('Skipping invalid candle update:', new_candle);
return;
}
const lastCandle = Array.isArray(this.price_history) && this.price_history.length > 0
? this.price_history[this.price_history.length - 1]
: null;
if (lastCandle && normalizedCandle.time < lastCandle.time) {
console.warn('Skipping stale candle update:', normalizedCandle, 'last:', lastCandle);
return;
}
// Update candlestick series
this.candleSeries.update(normalizedCandle);
// Keep local price history aligned with the live chart series.
if (!Array.isArray(this.price_history)) {
this.price_history = [];
}
const lastIndex = this.price_history.length - 1;
if (lastIndex >= 0 && this.price_history[lastIndex].time === normalizedCandle.time) {
this.price_history[lastIndex] = normalizedCandle;
} else if (lastIndex < 0 || this.price_history[lastIndex].time < normalizedCandle.time) {
this.price_history.push(normalizedCandle);
}
}
_normalizeCandleTime(rawTime) {
if (rawTime === null || rawTime === undefined) {
return null;
}
if (typeof rawTime === 'number' && Number.isFinite(rawTime)) {
return rawTime > 1e12 ? Math.floor(rawTime / 1000) : Math.floor(rawTime);
}
if (typeof rawTime === 'string') {
const numericValue = Number(rawTime);
if (Number.isFinite(numericValue)) {
return this._normalizeCandleTime(numericValue);
}
const parsedTime = Date.parse(rawTime);
if (!Number.isNaN(parsedTime)) {
return Math.floor(parsedTime / 1000);
}
return null;
}
if (rawTime instanceof Date) {
return Math.floor(rawTime.getTime() / 1000);
}
if (typeof rawTime === 'object') {
if (
Object.prototype.hasOwnProperty.call(rawTime, 'year') &&
Object.prototype.hasOwnProperty.call(rawTime, 'month') &&
Object.prototype.hasOwnProperty.call(rawTime, 'day')
) {
return Math.floor(Date.UTC(rawTime.year, rawTime.month - 1, rawTime.day) / 1000);
}
for (const key of ['timestamp', 'time', 'value', '$date']) {
if (Object.prototype.hasOwnProperty.call(rawTime, key)) {
return this._normalizeCandleTime(rawTime[key]);
}
}
}
return null;
}
_normalizeCandle(candle) {
if (!candle) {
return null;
}
const time = this._normalizeCandleTime(candle.time);
if (time === null) {
return null;
}
return {
...candle,
time,
open: parseFloat(candle.open),
high: parseFloat(candle.high),
low: parseFloat(candle.low),
close: parseFloat(candle.close)
};
}
_normalizeCandles(candles) {
if (!Array.isArray(candles)) {
return [];
}
return candles
.map(candle => this._normalizeCandle(candle))
.filter(candle => candle !== null)
.sort((a, b) => a.time - b.time);
}
create_RSI_chart(){
this.chart2 = this.create_chart(this.chart2_id, 100);
this.set_priceScale(this.chart2, 0.3, 0.0);
// Put the name of the chart in a watermark in the chart.
this.addWatermark(this.chart2, 'RSI');
// Todo: Not sure how to set this
//this.chart2.applyOptions({ priceRange: {minValue:0,maxValue:100} });
this.bind_charts(this.chart2);
}
create_MACD_chart(){
this.chart3 = this.create_chart(this.chart3_id, 100);
this.addWatermark(this.chart3, 'MACD');
this.bind_charts(this.chart3);
}
create_PercentB_chart(){
this.chart4 = this.create_chart(this.chart4_id, 100);
this.set_priceScale(this.chart4, 0.3, 0.0);
// Put the name of the chart in a watermark
this.addWatermark(this.chart4, '%B');
this.bind_charts(this.chart4);
}
create_patterns_chart(){
// Create a chart for candlestick pattern indicators
// Scale is fixed -100 to +100 (bearish to bullish signals)
this.chart5 = this.create_chart(this.chart5_id, 100);
this.set_priceScale(this.chart5, 0.1, 0.1);
// Put the name of the chart in a watermark
this.addWatermark(this.chart5, 'Patterns');
this.bind_charts(this.chart5);
// Sync time scale with main chart so timestamps align (v5 API)
if (this.chart_1) {
let barSpacing = this.chart_1.timeScale().options().barSpacing;
let scrollPosition = this.chart_1.timeScale().scrollPosition();
this.chart5.timeScale().applyOptions({ rightOffset: scrollPosition, barSpacing: barSpacing });
}
}
create_chart(target_id, height=500){
// Accepts a target element to place the chart in.
// Returns the chart object.
let container = document.getElementById(target_id);
//Create a lightweight chart object.
let chart = LightweightCharts.createChart(container, {
width: 1000,
height: height,
crosshair: {
mode: LightweightCharts.CrosshairMode.Normal,
},
rightPriceScale: {
borderColor: 'rgba(197, 203, 206, 0.8)',
},
timeScale: {
borderColor: 'rgba(197, 203, 206, 0.8)',
timeVisible: true,
secondsVisible: false,
barSpacing: 6
},
handleScroll: true,
localization: {
// Display times in UTC to match server data
timeFormatter: (timestamp) => {
const date = new Date(timestamp * 1000);
const day = date.getUTCDate().toString().padStart(2, '0');
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const month = months[date.getUTCMonth()];
const year = date.getUTCFullYear().toString().slice(-2);
const hours = date.getUTCHours().toString().padStart(2, '0');
const minutes = date.getUTCMinutes().toString().padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}`;
}
}
});
return chart;
}
set_priceScale(chart, top, bottom){
chart.priceScale('right').applyOptions({
scaleMargins: {
top: top,
bottom: bottom,
},
});
}
addWatermark(chart, text){
// v5 API: watermarks are now created via createTextWatermark plugin
LightweightCharts.createTextWatermark(chart.panes()[0], {
horzAlign: 'center',
vertAlign: 'center',
lines: [
{
text: text,
color: '#DBC29E',
fontSize: 30,
fontFamily: 'Roboto',
fontStyle: 'bold',
}
]
});
}
bind_charts(chart){
// Add chart to list if not already present
if (!this.bound_charts.includes(chart)) {
this.bound_charts.push(chart);
}
// Only need to sync when we have 2+ charts
if (this.bound_charts.length >= 2) {
this._rebindAllCharts();
}
}
/**
* Unsubscribe all existing sync handlers and set up fresh ones.
* This prevents cumulative handlers from conflicting.
*/
_rebindAllCharts() {
// Unsubscribe all existing handlers using v5 API
for (const {chart, handler} of this._syncUnsubscribes) {
try {
chart.timeScale().unsubscribeVisibleTimeRangeChange(handler);
} catch (e) {
// Ignore errors during cleanup
}
}
this._syncUnsubscribes = [];
// Create a single sync handler that works with all current charts
const chartCount = this.bound_charts.length;
for (let sourceIndex = 0; sourceIndex < chartCount; sourceIndex++) {
const sourceChart = this.bound_charts[sourceIndex];
const syncHandler = () => {
// Prevent recursive sync (when we apply options, it triggers another event)
if (this._isSyncing) return;
const now = Date.now();
if (now - this._lastSyncTime < 50) return;
this._isSyncing = true;
this._lastSyncTime = now;
try {
const barSpacing = sourceChart.timeScale().options().barSpacing;
const scrollPosition = sourceChart.timeScale().scrollPosition();
// Apply to all other charts
for (let i = 0; i < this.bound_charts.length; i++) {
if (i !== sourceIndex) {
this.bound_charts[i].timeScale().applyOptions({
rightOffset: scrollPosition,
barSpacing: barSpacing
});
}
}
} finally {
// Use setTimeout to reset flag after current event loop
setTimeout(() => {
this._isSyncing = false;
}, 0);
}
};
// Subscribe and store chart+handler for later unsubscribe
sourceChart.timeScale().subscribeVisibleTimeRangeChange(syncHandler);
this._syncUnsubscribes.push({chart: sourceChart, handler: syncHandler});
}
}
// Set trade markers on chart for all trades in backtest results
setTradeMarkers(trades) {
if (!this.candleSeries) {
console.warn('Candlestick series not available');
return;
}
if (!trades || trades.length === 0) {
console.log('No trades to display as markers');
return;
}
const candleData = this.price_history || [];
if (candleData.length === 0) {
console.warn('No candle data available for markers');
return;
}
// Get the time range of loaded candle data
const minCandleTime = candleData[0].time;
const maxCandleTime = candleData[candleData.length - 1].time;
console.log(`Chart data range: ${minCandleTime} to ${maxCandleTime}`);
// Build markers array from all trades
const markers = [];
trades.forEach(trade => {
const openTime = this.dateStringToUnixTimestamp(trade.open_datetime);
const closeTime = trade.close_datetime ? this.dateStringToUnixTimestamp(trade.close_datetime) : null;
// Add entry marker if within chart data range
if (openTime && openTime >= minCandleTime && openTime <= maxCandleTime) {
const matchedOpenTime = this.findNearestCandleTime(openTime, candleData);
markers.push({
time: matchedOpenTime,
position: 'belowBar',
color: '#26a69a',
shape: 'arrowUp',
text: 'BUY @ ' + (trade.open_price ? trade.open_price.toFixed(2) : '')
});
}
// Add exit marker if within chart data range
if (closeTime && closeTime >= minCandleTime && closeTime <= maxCandleTime) {
const matchedCloseTime = this.findNearestCandleTime(closeTime, candleData);
markers.push({
time: matchedCloseTime,
position: 'aboveBar',
color: '#ef5350',
shape: 'arrowDown',
text: 'SELL @ ' + (trade.close_price ? trade.close_price.toFixed(2) : '')
});
}
});
if (markers.length === 0) {
console.log('No trades fall within the loaded chart data timespan');
return;
}
// Sort markers by time (required by lightweight-charts)
markers.sort((a, b) => a.time - b.time);
console.log(`Setting ${markers.length} trade markers on chart`);
// v5 API: use createSeriesMarkers instead of series.setMarkers
// Clear existing markers primitive if it exists
if (this.markersPrimitive) {
this.markersPrimitive.setMarkers([]);
}
// Create new markers primitive
this.markersPrimitive = LightweightCharts.createSeriesMarkers(this.candleSeries, markers);
}
// Clear all trade markers from chart
clearTradeMarkers() {
// v5 API: clear markers via the markers primitive
if (this.markersPrimitive) {
this.markersPrimitive.setMarkers([]);
console.log('Trade markers cleared');
}
}
// Find the nearest candle time in the data
findNearestCandleTime(targetTime, candleData) {
if (!candleData || candleData.length === 0) {
return targetTime;
}
let nearestTime = candleData[0].time;
let minDiff = Math.abs(targetTime - nearestTime);
for (const candle of candleData) {
const diff = Math.abs(targetTime - candle.time);
if (diff < minDiff) {
minDiff = diff;
nearestTime = candle.time;
}
// Early exit if exact match
if (diff === 0) break;
}
return nearestTime;
}
// Convert datetime string to Unix timestamp in seconds
dateStringToUnixTimestamp(dateStr) {
if (!dateStr) return null;
try {
const date = new Date(dateStr);
return Math.floor(date.getTime() / 1000);
} catch (e) {
console.warn('Failed to parse date:', dateStr, e);
return null;
}
}
}