458 lines
16 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
|
|
|
|
}
|