4 Commits

Author SHA1 Message Date
f9d76c85bd dane z plików - nie chodzi 2025-07-17 00:12:33 +02:00
2a556781da switching TF, multi indicators on chart 2025-07-16 00:28:42 +02:00
4305e1cb02 changes styles 2025-07-15 22:05:29 +02:00
68d8bc9880 measurment tooltip + one week data from binance 2025-07-15 20:27:43 +02:00
8 changed files with 1086 additions and 188 deletions

3
.gitignore vendored
View File

@ -1 +1,2 @@
*.csv
*.csv
historical_data_1m.json

332
app.py
View File

@ -3,6 +3,8 @@ import logging
import asyncio
import os
import json
import csv
import re
from flask import Flask, render_template, request
from flask_socketio import SocketIO
from binance import Client
@ -13,6 +15,8 @@ from datetime import datetime, timedelta
# --- Configuration ---
SYMBOL = 'ETHUSDT'
HISTORY_FILE = 'historical_data_1m.json'
DATA_FOLDER = 'data'
USER_PREFERENCES_FILE = 'user_preferences.json'
RESTART_TIMEOUT_S = 15
BINANCE_WS_URL = f"wss://stream.binance.com:9443/ws/{SYMBOL.lower()}@trade"
@ -28,35 +32,277 @@ socketio = SocketIO(app, async_mode='threading')
app_initialized = False
app_init_lock = Lock()
current_bar = {} # To track the currently forming 1-minute candle
selected_csv_file = None # Currently selected CSV file
csv_file_lock = Lock() # Lock for CSV file operations
# --- Utility Functions ---
def get_available_csv_files():
"""Get list of available CSV files with their start dates."""
csv_files = []
if not os.path.exists(DATA_FOLDER):
os.makedirs(DATA_FOLDER)
return csv_files
for filename in os.listdir(DATA_FOLDER):
if filename.endswith('.csv') and SYMBOL in filename:
# Extract date from filename like ETHUSDT_20250101.csv
match = re.search(r'(\d{8})', filename)
if match:
date_str = match.group(1)
try:
start_date = datetime.strptime(date_str, '%Y%m%d')
file_path = os.path.join(DATA_FOLDER, filename)
file_size = os.path.getsize(file_path)
csv_files.append({
'filename': filename,
'start_date_str': start_date.strftime('%Y-%m-%d'),
'date_str': date_str,
'size': file_size,
'display_name': f"{start_date.strftime('%Y-%m-%d')} ({filename})"
})
logging.info(f"Found CSV file: {filename}, size: {file_size}, date: {date_str}")
except ValueError:
logging.warning(f"Could not parse date from filename: {filename}")
continue
# Sort by start date (newest first)
csv_files.sort(key=lambda x: x['date_str'], reverse=True)
logging.info(f"Available CSV files: {[f['filename'] for f in csv_files]}")
return csv_files
def get_default_csv_file():
"""Get the default CSV file (smallest one or last used)."""
# Try to load last used file
if os.path.exists(USER_PREFERENCES_FILE):
try:
with open(USER_PREFERENCES_FILE, 'r') as f:
prefs = json.load(f)
last_file = prefs.get('last_csv_file')
if last_file and os.path.exists(os.path.join(DATA_FOLDER, last_file)):
logging.info(f"Using last selected file: {last_file}")
return last_file
except:
pass
# Fall back to smallest file
csv_files = get_available_csv_files()
if csv_files:
# Filter to exclude the large Binance file for better performance
filtered_files = [f for f in csv_files if not f['filename'].endswith('_Binance.csv')]
if filtered_files:
smallest_file = min(filtered_files, key=lambda x: x['size'])
logging.info(f"Using smallest filtered file: {smallest_file['filename']} ({smallest_file['size']} bytes)")
else:
smallest_file = min(csv_files, key=lambda x: x['size'])
logging.info(f"Using smallest file: {smallest_file['filename']} ({smallest_file['size']} bytes)")
return smallest_file['filename']
logging.warning("No CSV files found")
return None
def save_user_preference(csv_filename):
"""Save the user's CSV file preference."""
prefs = {}
if os.path.exists(USER_PREFERENCES_FILE):
try:
with open(USER_PREFERENCES_FILE, 'r') as f:
prefs = json.load(f)
except:
pass
prefs['last_csv_file'] = csv_filename
with open(USER_PREFERENCES_FILE, 'w') as f:
json.dump(prefs, f)
def read_csv_data(csv_filename):
"""Read historical data from CSV file."""
csv_path = os.path.join(DATA_FOLDER, csv_filename)
if not os.path.exists(csv_path):
return []
klines = []
try:
with open(csv_path, 'r', newline='', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
# Convert CSV row to kline format
open_time = datetime.strptime(row['Open time'], '%Y-%m-%d %H:%M:%S')
close_time = datetime.strptime(row['Close time'].split('.')[0], '%Y-%m-%d %H:%M:%S')
# =================================================================
# --- FIX START: Convert string values to numeric types ---
# The original code passed the string values from the CSV directly.
# This caused the historical data to be misinterpreted by the chart.
# By converting to float/int here, we ensure data consistency.
# =================================================================
kline = [
int(open_time.timestamp() * 1000), # Open time (ms)
float(row['Open']), # Open
float(row['High']), # High
float(row['Low']), # Low
float(row['Close']), # Close
float(row['Volume']), # Volume
int(close_time.timestamp() * 1000), # Close time (ms)
float(row['Quote asset volume']), # Quote asset volume
int(row['Number of trades']), # Number of trades
float(row['Taker buy base asset volume']), # Taker buy base asset volume
float(row['Taker buy quote asset volume']), # Taker buy quote asset volume
float(row['Ignore']) # Ignore
]
# --- FIX END ---
# =================================================================
klines.append(kline)
except Exception as e:
logging.error(f"Error reading CSV file {csv_filename}: {e}")
return []
return klines
def append_to_csv(csv_filename, candle_data):
"""Append new candle data to CSV file."""
csv_path = os.path.join(DATA_FOLDER, csv_filename)
try:
with csv_file_lock:
# Convert candle data to CSV row
open_time = datetime.fromtimestamp(candle_data['time'])
close_time = open_time.replace(second=59, microsecond=999000)
row = [
open_time.strftime('%Y-%m-%d %H:%M:%S'),
candle_data['open'],
candle_data['high'],
candle_data['low'],
candle_data['close'],
0.0, # Volume (placeholder)
close_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3],
0.0, # Quote asset volume (placeholder)
1, # Number of trades (placeholder)
0.0, # Taker buy base asset volume (placeholder)
0.0, # Taker buy quote asset volume (placeholder)
0.0 # Ignore
]
# Check if file exists and has header
file_exists = os.path.exists(csv_path)
with open(csv_path, 'a', newline='', encoding='utf-8') as csvfile:
writer = csv.writer(csvfile)
# Write header if file is new
if not file_exists:
headers = [
'Open time', 'Open', 'High', 'Low', 'Close', 'Volume',
'Close time', 'Quote asset volume', 'Number of trades',
'Taker buy base asset volume', 'Taker buy quote asset volume', 'Ignore'
]
writer.writerow(headers)
writer.writerow(row)
except Exception as e:
logging.error(f"Error appending to CSV file {csv_filename}: {e}")
def fill_missing_data(csv_filename):
"""Fill missing data by downloading from Binance."""
global selected_csv_file
try:
logging.info(f"Checking for missing data in {csv_filename}")
# Get the start date from filename
match = re.search(r'(\d{8})', csv_filename)
if not match:
return
date_str = match.group(1)
start_date = datetime.strptime(date_str, '%Y%m%d')
# Read existing data
existing_data = read_csv_data(csv_filename)
# Determine what data we need to fetch
if existing_data:
# Get the last timestamp from existing data
last_timestamp = existing_data[-1][0] // 1000 # Convert to seconds
fetch_start = datetime.fromtimestamp(last_timestamp) + timedelta(minutes=1)
else:
fetch_start = start_date
# Fetch missing data up to current time
now = datetime.now()
if fetch_start >= now:
logging.info(f"No missing data for {csv_filename}")
return existing_data
logging.info(f"Fetching missing data from {fetch_start} to {now}")
client = Client()
missing_klines = client.get_historical_klines(
SYMBOL,
Client.KLINE_INTERVAL_1MINUTE,
start_str=fetch_start.strftime('%Y-%m-%d %H:%M:%S'),
end_str=now.strftime('%Y-%m-%d %H:%M:%S')
)
if missing_klines:
# Append missing data to CSV
csv_path = os.path.join(DATA_FOLDER, csv_filename)
with csv_file_lock:
with open(csv_path, 'a', newline='', encoding='utf-8') as csvfile:
writer = csv.writer(csvfile)
for kline in missing_klines:
open_time = datetime.fromtimestamp(kline[0] / 1000)
close_time = datetime.fromtimestamp(kline[6] / 1000)
row = [
open_time.strftime('%Y-%m-%d %H:%M:%S'),
kline[1], kline[2], kline[3], kline[4], kline[5],
close_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3],
kline[7], kline[8], kline[9], kline[10], kline[11]
]
writer.writerow(row)
logging.info(f"Added {len(missing_klines)} missing candles to {csv_filename}")
existing_data.extend(missing_klines)
return existing_data
except Exception as e:
logging.error(f"Error filling missing data for {csv_filename}: {e}")
return existing_data if 'existing_data' in locals() else []
# --- Historical Data Streaming ---
def stream_historical_data(sid):
"""
Loads historical data from the selected CSV file and sends it to the client.
"""
global selected_csv_file
try:
logging.info(f"Starting historical data stream for SID={sid}")
client = Client()
num_chunks = 6
chunk_size_days = 15
end_date = datetime.utcnow()
all_klines = []
for i in range(num_chunks):
start_date = end_date - timedelta(days=chunk_size_days)
logging.info(f"Fetching chunk {i + 1}/{num_chunks} for SID={sid}")
new_klines = client.get_historical_klines(SYMBOL, Client.KLINE_INTERVAL_1MINUTE, str(start_date), str(end_date))
if new_klines:
all_klines.extend(new_klines)
socketio.emit('history_progress', {'progress': ((i + 1) / num_chunks) * 100}, to=sid)
end_date = start_date
socketio.sleep(0.05)
seen = set()
unique_klines = [kline for kline in sorted(all_klines, key=lambda x: x[0]) if tuple(kline) not in seen and not seen.add(tuple(kline))]
with open(HISTORY_FILE, 'w') as f:
json.dump(unique_klines, f)
logging.info(f"Finished data stream for SID={sid}. Sending final payload of {len(unique_klines)} klines.")
socketio.emit('history_finished', {'klines_1m': unique_klines}, to=sid)
# Get selected CSV file or default
if not selected_csv_file:
selected_csv_file = get_default_csv_file()
if not selected_csv_file:
# No CSV files available, create a default one
logging.warning("No CSV files available, creating default file")
selected_csv_file = f"ETHUSDT_{datetime.now().strftime('%Y%m%d')}.csv"
logging.info(f"Using CSV file: {selected_csv_file}")
# Fill missing data and get all klines
all_klines = fill_missing_data(selected_csv_file)
# Send progress update
socketio.emit('history_progress', {'progress': 100}, to=sid)
logging.info(f"Finished data stream for SID={sid}. Sending final payload of {len(all_klines)} klines.")
socketio.emit('history_finished', {'klines_1m': all_klines}, to=sid)
except Exception as e:
logging.error(f"Error in stream_historical_data for SID={sid}: {e}", exc_info=True)
socketio.emit('history_error', {'message': str(e)}, to=sid)
@ -84,9 +330,13 @@ def binance_listener_thread():
if not current_bar or candle_timestamp > current_bar.get("time", 0):
if current_bar:
# The previous candle is now closed, emit it
# The previous candle is now closed, emit it and save to CSV
logging.info(f"Candle closed at {current_bar['close']}. Emitting 'candle_closed' event.")
socketio.emit('candle_closed', current_bar)
# Append to selected CSV file
if selected_csv_file:
append_to_csv(selected_csv_file, current_bar)
current_bar = {"time": candle_timestamp, "open": price, "high": price, "low": price, "close": price}
else:
@ -115,6 +365,40 @@ def handle_connect():
app_initialized = True
socketio.start_background_task(target=stream_historical_data, sid=request.sid)
@socketio.on('get_csv_files')
def handle_get_csv_files():
"""Send available CSV files to client."""
logging.info(f"Received get_csv_files request from SID={request.sid}")
csv_files = get_available_csv_files()
default_file = get_default_csv_file()
logging.info(f"Sending CSV files list: {len(csv_files)} files, default: {default_file}")
socketio.emit('csv_files_list', {
'files': csv_files,
'selected': default_file
})
@socketio.on('select_csv_file')
def handle_select_csv_file(data):
"""Handle CSV file selection by user."""
global selected_csv_file
logging.info(f"Received select_csv_file request from SID={request.sid} with data: {data}")
filename = data.get('filename')
if filename:
csv_files = get_available_csv_files()
valid_files = [f['filename'] for f in csv_files]
if filename in valid_files:
selected_csv_file = filename
save_user_preference(filename)
logging.info(f"User selected CSV file: {filename}")
# Stream new historical data
socketio.start_background_task(target=stream_historical_data, sid=request.sid)
else:
logging.error(f"Invalid CSV file selected: {filename}")
socketio.emit('error', {'message': f'Invalid CSV file: {filename}'})
# --- Flask Routes ---
@app.route('/')
def index():

102
data/data_miner.py Normal file
View File

@ -0,0 +1,102 @@
import csv
from datetime import datetime
def filter_csv_by_date(input_file, output_file, start_date_str):
"""
Reads a large CSV file line by line, filters by a start date,
and writes the results to a new file.
Args:
input_file (str): Path to the large input CSV.
output_file (str): Path to the output CSV file.
start_date_str (str): The start date in 'YYYY-MM-DD' format.
"""
try:
# Convert the start date string into a datetime object for comparison
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
print(f"Filtering for dates on or after {start_date_str}...")
print(f"Output will be saved to: {output_file}")
# Open the input and output files
with open(input_file, 'r', newline='') as infile, \
open(output_file, 'w', newline='') as outfile:
reader = csv.reader(infile)
writer = csv.writer(outfile)
# 1. Read and write the header
header = next(reader)
writer.writerow(header)
# Find the index of the 'Open time' column
try:
date_column_index = header.index('Open time')
except ValueError:
print("Error: 'Open time' column not found in the header.")
return
# 2. Process the rest of the file line by line
processed_lines = 0
written_lines = 0
for row in reader:
processed_lines += 1
# Avoid errors from empty or malformed rows
if not row:
continue
try:
# Get the date string from the correct column
row_date_str = row[date_column_index]
# Convert the row's date string to a datetime object
row_date = datetime.strptime(row_date_str, '%Y-%m-%d %H:%M:%S')
# 3. Compare dates and write to new file if it's a match
if row_date >= start_date:
writer.writerow(row)
written_lines += 1
except (ValueError, IndexError) as e:
# This will catch errors if a date is in the wrong format
# or if a row doesn't have enough columns.
print(f"Skipping malformed row {processed_lines + 1}: {row}. Error: {e}")
continue
# Optional: Print progress for very long operations
if processed_lines % 5000000 == 0:
print(f"Processed {processed_lines:,} lines...")
print("\n--- Processing Complete ---")
print(f"Total lines processed: {processed_lines:,}")
print(f"Total lines written: {written_lines:,}")
print(f"Filtered data saved to: {output_file}")
except FileNotFoundError:
print(f"Error: The file '{input_file}' was not found.")
except Exception as e:
print(f"An unexpected error occurred: {e}")
# --- Configuration ---
# 1. Replace with the name of your large input file
input_filename = 'ETHUSDT_1m_Binance.csv'
# 2. Provide the start date in YYYY-MM-DD format
start_date_filter = '2025-07-01' # <-- REPLACE THIS
# 3. The output filename is generated automatically in the requested format
if start_date_filter != 'YYYY-MM-DD':
# This line removes the hyphens for the filename
filename_date_part = start_date_filter.replace('-', '')
output_filename = f'ETHUSDT_{filename_date_part}.csv'
else:
output_filename = 'ETHUSDT_unfiltered.csv'
# --- Run the script ---
if start_date_filter == 'YYYY-MM-DD':
print("Please update the 'start_date_filter' variable in the script with a date like '2025-07-01'.")
else:
filter_csv_by_date(input_filename, output_filename, start_date_filter)

File diff suppressed because one or more lines are too long

View File

@ -14,7 +14,7 @@ const BB_INDICATOR = {
bb1_len_upper: 20, bb1_std_upper: 1.6, bb1_len_lower: 20, bb1_std_lower: 1.6,
bb2_len_upper: 20, bb2_std_upper: 2.4, bb2_len_lower: 20, bb2_std_lower: 2.4,
bb3_len_upper: 20, bb3_std_upper: 3.3, bb3_len_lower: 20, bb3_std_lower: 3.3,
},
},
calculateFull: calculateFullBollingerBands,
};

View File

@ -1,50 +1,62 @@
/**
* Aggregates fine-grained candle data into a larger timeframe.
* For example, it can convert 1-minute candles into 5-minute candles.
*
* @param {Array<Object>} data - An array of candle objects, sorted by time.
* Each object must have { time, open, high, low, close }.
* @param {number} intervalMinutes - The desired new candle interval in minutes (e.g., 5 for 5m).
* @returns {Array<Object>} A new array of aggregated candle objects.
*/
function aggregateCandles(data, intervalMinutes) {
if (!data || data.length === 0 || !intervalMinutes || intervalMinutes < 1) {
return [];
}
const intervalSeconds = intervalMinutes * 60;
const aggregated = [];
let currentAggCandle = null;
data.forEach(candle => {
// Calculate the timestamp for the start of the interval bucket
const bucketTimestamp = candle.time - (candle.time % intervalSeconds);
if (!currentAggCandle || bucketTimestamp !== currentAggCandle.time) {
// If a previous aggregated candle exists, push it to the results
if (currentAggCandle) {
aggregated.push(currentAggCandle);
}
// Start a new aggregated candle
currentAggCandle = {
time: bucketTimestamp,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
};
} else {
// This candle belongs to the current aggregated candle, so update it
currentAggCandle.high = Math.max(currentAggCandle.high, candle.high);
currentAggCandle.low = Math.min(currentAggCandle.low, candle.low);
currentAggCandle.close = candle.close; // The close is always the latest one
/**
* Aggregates fine-grained candle data into a larger timeframe.
* For example, it can convert 1-minute candles into 5-minute candles.
*
* @param {Array<Object>} data - An array of candle objects, sorted by time.
* Each object must have { time, open, high, low, close }.
* @param {number} intervalMinutes - The desired new candle interval in minutes (e.g., 5 for 5m).
* @returns {Array<Object>} A new array of aggregated candle objects.
*/
function aggregateCandles(data, intervalMinutes) {
if (!data || data.length === 0 || !intervalMinutes || intervalMinutes < 1) {
return [];
}
});
// Add the last aggregated candle if it exists
if (currentAggCandle) {
aggregated.push(currentAggCandle);
const intervalSeconds = intervalMinutes * 60;
const aggregated = [];
let currentAggCandle = null;
data.forEach(candle => {
// Validate candle data
if (!candle || !candle.time ||
isNaN(candle.open) || isNaN(candle.high) ||
isNaN(candle.low) || isNaN(candle.close) ||
candle.open <= 0 || candle.high <= 0 ||
candle.low <= 0 || candle.close <= 0) {
console.warn('Skipping invalid candle during aggregation:', candle);
return; // Skip this candle
}
// Calculate the timestamp for the start of the interval bucket
// Properly align to interval boundaries (e.g., 5-min intervals start at :00, :05, :10, etc.)
const bucketTimestamp = Math.floor(candle.time / intervalSeconds) * intervalSeconds;
if (!currentAggCandle || bucketTimestamp !== currentAggCandle.time) {
// If a previous aggregated candle exists, push it to the results
if (currentAggCandle) {
aggregated.push(currentAggCandle);
}
// Start a new aggregated candle
currentAggCandle = {
time: bucketTimestamp,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
};
} else {
// This candle belongs to the current aggregated candle, so update it
currentAggCandle.high = Math.max(currentAggCandle.high, candle.high);
currentAggCandle.low = Math.min(currentAggCandle.low, candle.low);
currentAggCandle.close = candle.close; // The close is always the latest one
}
});
// Add the last aggregated candle if it exists
if (currentAggCandle) {
aggregated.push(currentAggCandle);
}
return aggregated;
}
return aggregated;
}

View File

@ -6,18 +6,25 @@
* @returns {Object} A manager object with public methods to control indicators.
*/
function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef) {
// --- FIX: --- Added `debounceTimerId` to each slot object to track pending updates.
const indicatorSlots = [
{ id: 1, cellId: 'indicator-cell-1', series: [], definition: null, params: {}, calculator: null },
{ id: 2, cellId: 'indicator-cell-2', series: [], definition: null, params: {}, calculator: null },
{ id: 3, cellId: 'indicator-cell-3', series: [], definition: null, params: {}, calculator: null },
{ id: 4, cellId: 'indicator-cell-4', series: [], definition: null, params: {}, calculator: null },
{ id: 1, cellId: 'indicator-cell-1', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null },
{ id: 2, cellId: 'indicator-cell-2', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null },
{ id: 3, cellId: 'indicator-cell-3', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null },
{ id: 4, cellId: 'indicator-cell-4', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null },
];
// **FIX**: Updated colors object to match your styling request.
const colors = {
bb: { bb1_upper: '#FF9800', bb1_lower: '#FF9800', bb2_upper: '#2196F3', bb2_lower: '#2196F3', bb3_upper: '#9C27B0', bb3_lower: '#9C27B0' },
bb: {
bb1_upper: 'rgba(128, 25, 34, 0.5)',
bb2_upper: 'rgba(128, 25, 34, 0.75)',
bb3_upper: 'rgba(128, 25, 34, 1)',
bb1_lower: 'rgba(6, 95, 6, 0.5)',
bb2_lower: 'rgba(6, 95, 6, 0.75)',
bb3_lower: 'rgba(6, 95, 6, 1.0)',
},
hurst: { topBand: '#787b86', bottomBand: '#787b86', topBand_h: '#673ab7', bottomBand_h: '#673ab7' },
default: ['#00BCD4', '#FFEB3B', '#4CAF50', '#E91E63'] // Cyan, Yellow, Green, Pink
default: ['#00BCD4', '#FFEB3B', '#4CAF50', '#E91E63']
};
function populateDropdowns() {
@ -46,12 +53,19 @@ function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef
const slot = indicatorSlots.find(s => s.id === slotId);
if (!slot) return;
// --- FIX: --- Cancel any pending debounced update from the previous indicator's controls.
// This is the core of the fix, preventing the race condition.
if (slot.debounceTimerId) {
clearTimeout(slot.debounceTimerId);
slot.debounceTimerId = null;
}
slot.series.forEach(s => chart.removeSeries(s));
slot.series = [];
slot.definition = null;
slot.params = {};
slot.calculator = null;
const controlsContainer = document.querySelector(`#${slot.cellId} .indicator-controls`);
controlsContainer.innerHTML = '';
@ -61,12 +75,12 @@ function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef
if (!definition) return;
slot.definition = definition;
definition.params.forEach(param => {
const label = document.createElement('label');
label.textContent = param.label || param.name;
label.style.fontSize = '12px';
const input = document.createElement('input');
input.type = param.type;
input.value = param.defaultValue;
@ -75,13 +89,14 @@ function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef
input.className = 'input-field';
slot.params[param.name] = input.type === 'number' ? parseFloat(input.value) : input.value;
let debounceTimer;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
// --- FIX: --- Use the slot's `debounceTimerId` property to manage the timeout.
clearTimeout(slot.debounceTimerId);
slot.debounceTimerId = setTimeout(() => {
slot.params[param.name] = input.type === 'number' ? parseFloat(input.value) : input.value;
updateIndicator(slot.id, true);
}, 500);
slot.debounceTimerId = null; // Clear the ID after the function has run.
}, 500);
});
const controlGroup = document.createElement('div');
controlGroup.style.display = 'flex';
@ -96,26 +111,28 @@ function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef
function updateIndicator(slotId, isFullRecalculation = false) {
const slot = indicatorSlots.find(s => s.id === slotId);
const candleDataForCalc = (slot.definition.usesBaseData) ? baseCandleDataRef : displayedCandleDataRef;
if (!slot || !slot.definition) return;
const candleDataForCalc = (slot.definition.usesBaseData) ? baseCandleDataRef : displayedCandleDataRef;
if (candleDataForCalc.length === 0) return;
if (!slot || !slot.definition || candleDataForCalc.length === 0) return;
if (isFullRecalculation) {
slot.series.forEach(s => chart.removeSeries(s));
slot.series = [];
const indicatorResult = slot.definition.calculateFull(candleDataForCalc, slot.params);
if (typeof indicatorResult === 'object' && !Array.isArray(indicatorResult)) {
Object.keys(indicatorResult).forEach(key => {
const seriesData = indicatorResult[key];
const indicatorNameLower = slot.definition.name.toLowerCase();
const series = chart.addLineSeries({
color: (colors[indicatorNameLower] && colors[indicatorNameLower][key]) ? colors[indicatorNameLower][key] : colors.default[slot.id - 1],
lineWidth: 1, // **FIX**: Set line width to 1px
title: '', // **FIX**: Remove title label
lastValueVisible: false, // **FIX**: Remove price label on the right
priceLineVisible: false, // **FIX**: Remove dotted horizontal line
lineWidth: 1,
title: '',
lastValueVisible: false,
priceLineVisible: false,
});
series.setData(seriesData);
slot.series.push(series);
@ -123,28 +140,27 @@ function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef
} else {
const series = chart.addLineSeries({
color: colors.default[slot.id - 1],
lineWidth: 1, // **FIX**: Set line width to 1px
title: '', // **FIX**: Remove title label
lastValueVisible: false, // **FIX**: Remove price label on the right
priceLineVisible: false, // **FIX**: Remove dotted horizontal line
lineWidth: 1,
title: '',
lastValueVisible: false,
priceLineVisible: false,
});
series.setData(indicatorResult);
slot.series.push(series);
}
if (slot.definition.createRealtime) {
slot.calculator = slot.definition.createRealtime(slot.params);
slot.calculator.prime(candleDataForCalc);
}
} else if (slot.calculator) {
// **FIX**: This is the lightweight real-time update logic
const lastCandle = candleDataForCalc[candleDataForCalc.length - 1];
if (!lastCandle) return;
const newPoint = slot.calculator.update(lastCandle);
if (newPoint && typeof newPoint === 'object') {
if (slot.series.length > 1) { // Multi-line indicator
if (slot.series.length > 1) { // Multi-line indicator
Object.keys(newPoint).forEach((key, index) => {
if (slot.series[index] && newPoint[key]) {
slot.series[index].update(newPoint[key]);
@ -156,20 +172,37 @@ function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef
}
}
}
function recalculateAllAfterHistory(baseData, displayedData) {
baseCandleDataRef = baseData;
displayedCandleDataRef = displayedData;
indicatorSlots.forEach(slot => {
if (slot.definition) updateIndicator(slot.id, true);
baseCandleDataRef = baseData;
displayedCandleDataRef = displayedData;
// --- FIX: --- Clear any pending debounced updates from parameter changes.
// This prevents a stale update from a parameter input from running after
// the chart has already been reset for a new timeframe.
indicatorSlots.forEach(slot => {
if (slot.debounceTimerId) {
clearTimeout(slot.debounceTimerId);
slot.debounceTimerId = null;
}
});
// --- FIX: --- Defer the full recalculation to the next frame.
// This prevents a race condition where indicators are removed/added while the chart
// is still processing the main series' `setData` operation from a timeframe change.
setTimeout(() => {
indicatorSlots.forEach(slot => {
if (slot.definition) {
updateIndicator(slot.id, true);
}
});
}, 0);
}
// **FIX**: New lightweight function for real-time updates
function updateAllOnNewCandle() {
indicatorSlots.forEach(slot => {
if (slot.definition) {
updateIndicator(slot.id, false); // Perform a lightweight update
updateIndicator(slot.id, false);
}
});
}
@ -177,6 +210,6 @@ function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef
return {
populateDropdowns,
recalculateAllAfterHistory,
updateAllOnNewCandle, // Expose the new function
updateAllOnNewCandle,
};
}

View File

@ -5,11 +5,12 @@
<script src="https://unpkg.com/lightweight-charts@4.1.3/dist/lightweight-charts.standalone.production.js"></script>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<!-- Indicator & Aggregation Scripts -->
<!-- NOTE: These 'url_for' will not work in a static HTML file. -->
<!-- They are placeholders for a Flask environment. For a standalone file, you would link directly to the JS files. -->
<script src="{{ url_for('static', filename='candle-aggregator.js') }}"></script>
<script src="{{ url_for('static', filename='sma.js') }}"></script>
<script src="{{ url_for('static', filename='ema.js') }}"></script>
<script src="{{ url_for('static', filename='bb.js') }}"></script>
<script src="{{ url_for('static',filename='bb.js') }}"></script>
<script src="{{ url_for('static', filename='hurst.js') }}"></script>
<script src="{{ url_for('static', filename='indicators.js') }}"></script>
<script src="{{ url_for('static', filename='indicator-manager.js') }}"></script>
@ -19,6 +20,13 @@
--background-dark: #161A25; --container-dark: #1E222D; --border-color: #2A2E39;
--text-primary: #D1D4DC; --text-secondary: #8A91A0; --button-bg: #363A45;
--button-hover-bg: #434651; --accent-orange: #F0B90B; --green: #26a69a; --red: #ef5350;
/* Colors for measure tool */
--measure-tool-up-bg: rgba(41, 98, 255, 0.2);
--measure-tool-up-border: #2962FF;
--measure-tool-down-bg: rgba(239, 83, 80, 0.2);
--measure-tool-down-border: #ef5350;
--measure-tool-text: #FFFFFF;
}
body {
background-color: var(--background-dark); color: var(--text-primary);
@ -30,7 +38,8 @@
border-bottom: 1px solid var(--border-color); margin-bottom: 20px;
}
.header h1 { margin: 0; font-size: 24px; }
#chart { width: 90%; max-width: 1400px; height: 500px; }
#chart-wrapper { position: relative; width: 90%; max-width: 1400px; height: 500px; }
#chart { width: 100%; height: 100%; }
.control-panel {
display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px; width: 90%; max-width: 1400px; padding: 20px 0;
@ -48,20 +57,9 @@
border-radius: 5px; cursor: pointer; transition: background-color 0.3s, color 0.3s;
}
.action-button:hover, .control-cell select:hover { background-color: var(--button-hover-bg); }
#analyzeButton:hover { color: var(--accent-orange); }
.input-field { width: 60px; }
.analysis-section {
width: 90%; max-width: 1400px; background-color: var(--container-dark);
border: 1px solid var(--border-color); border-radius: 8px; padding: 20px;
margin-top: 10px; text-align: center;
}
#analysisResult {
margin-top: 15px; font-size: 0.95rem; line-height: 1.6; text-align: left;
white-space: pre-wrap; color: var(--text-primary); background-color: var(--background-dark);
padding: 15px; border-radius: 5px; min-height: 50px;
}
#candle-timer { font-size: 2rem; font-weight: 500; color: var(--accent-orange); }
#timeframe-select { margin-top: 10px; }
#timeframe-display { margin-top: 10px; min-width: 60px; }
.progress-bar-container {
width: 80%; height: 4px; background-color: var(--button-bg);
border-radius: 2px; margin-top: 10px; overflow: hidden;
@ -70,6 +68,44 @@
width: 0%; height: 100%; background-color: var(--green);
transition: width 0.4s ease-out;
}
/* --- Styles for Measure Tool --- */
#measure-tool {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; overflow: hidden; z-index: 10;
}
#measure-box { position: absolute; }
#measure-svg { position: absolute; width: 100%; height: 100%; top: 0; left: 0; }
#measure-tooltip {
position: absolute; color: var(--measure-tool-text); padding: 4px 8px;
border-radius: 4px; font-size: 11px; line-height: 1.2;
display: flex; flex-direction: column; align-items: center; justify-content: center;
}
/* --- Styles for Timeframe Modal --- */
.modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: none; /* Hidden by default */
justify-content: center; align-items: center; z-index: 1000;
}
.modal {
background-color: var(--container-dark); padding: 25px; border-radius: 8px;
border: 1px solid var(--border-color); text-align: center;
width: 300px; box-shadow: 0 5px 15px rgba(0,0,0,0.3);
}
.modal h2 {
margin-top: 0; margin-bottom: 20px; font-size: 18px; color: var(--text-primary);
}
.modal .input-field {
width: 100%; box-sizing: border-box; font-size: 24px;
padding: 10px; margin-bottom: 10px; text-align: center;
}
.modal #timeframe-preview-text {
color: var(--text-secondary); font-size: 14px; margin-top: 0;
margin-bottom: 20px; min-height: 20px;
}
.modal .action-button { width: 100%; }
</style>
</head>
<body>
@ -77,27 +113,32 @@
<h1 id="chart-title">{{ symbol }} Chart</h1>
</div>
<div id="chart"></div>
<div id="chart-wrapper">
<div id="chart"></div>
<div id="measure-tool" style="display: none;">
<div id="measure-box"></div>
<svg id="measure-svg"></svg>
<div id="measure-tooltip"></div>
</div>
</div>
<div class="control-panel">
<div class="control-cell">
<h3>Candle Closes In</h3>
<div id="candle-timer">--:--</div>
<select id="timeframe-select">
<option value="1">1m</option>
<option value="2">2m</option>
<option value="3">3m</option>
<option value="4">4m</option>
<option value="5">5m</option>
<option value="6">6m</option>
<option value="7">7m</option>
<option value="8">8m</option>
<option value="9">9m</option>
<option value="10">10m</option>
</select>
<div id="progress-container" class="progress-bar-container">
<div id="timeframe-display" class="action-button">1m</div>
<div id="progress-container" class="progress-bar-container">
<div class="progress-bar"></div>
</div>
<!-- CSV File Selection Dropdown -->
<div style="margin-top: 15px; width: 100%;">
<label for="csv-file-select" style="display: block; margin-bottom: 5px; font-size: 12px; color: var(--text-secondary);">Data Source:</label>
<select id="csv-file-select" style="width: 100%; background-color: var(--button-bg); border: 1px solid var(--border-color); color: var(--text-primary); padding: 6px; border-radius: 4px; font-size: 12px; cursor: pointer;">
<option value="">Loading...</option>
</select>
<div id="csv-info" style="font-size: 10px; color: var(--text-secondary); margin-top: 3px; text-align: center;"></div>
</div>
</div>
<div class="control-cell" id="indicator-cell-1"></div>
<div class="control-cell" id="indicator-cell-2"></div>
@ -105,10 +146,14 @@
<div class="control-cell" id="indicator-cell-4"></div>
</div>
<div class="analysis-section">
<h3>Analysis ✨</h3>
<button id="analyzeButton" class="action-button">Analyze Recent Price Action</button>
<div id="analysisResult">Click the button for AI analysis.</div>
<!-- Timeframe Modal -->
<div id="timeframe-modal-overlay" class="modal-overlay">
<div id="timeframe-modal" class="modal">
<h2>Change interval</h2>
<input type="number" id="timeframe-input" class="input-field" min="1" placeholder="Enter minutes"/>
<p id="timeframe-preview-text"></p>
<button id="timeframe-confirm-btn" class="action-button">OK</button>
</div>
</div>
<script>
@ -118,87 +163,508 @@
width: chartElement.clientWidth, height: 500,
layout: { background: { type: 'solid', color: '#1E222D' }, textColor: '#D1D4DC' },
grid: { vertLines: { color: '#2A2E39' }, horzLines: { color: '#2A2E39' } },
timeScale: { timeVisible: true, secondsVisible: true }
timeScale: { timeVisible: true, secondsVisible: true },
crosshair: {
mode: LightweightCharts.CrosshairMode.Normal,
},
});
const candlestickSeries = chart.addCandlestickSeries({
upColor: '#26a69a', downColor: '#ef5350', borderDownColor: '#ef5350',
borderUpColor: '#26a69a', wickDownColor: '#ef5350', wickUpColor: '#26a69a',
upColor: 'rgba(255, 152, 0, 1.0)', downColor: 'rgba(255, 152, 0, 0.66)', borderDownColor: 'rgba(255, 152, 0, 0.66)',
borderUpColor: 'rgba(255, 152, 0, 1.0)', wickDownColor: 'rgba(255, 152, 0, 0.66)', wickUpColor: 'rgba(255, 152, 0, 1.0)'
});
const originalAddLineSeries = chart.addLineSeries;
chart.addLineSeries = function(options) {
const newOptions = { ...options, crosshairMarkerVisible: false, };
return originalAddLineSeries.call(this, newOptions);
};
let baseCandleData1m = [];
let displayedCandleData = [];
let manager;
let currentTimeframeMinutes = 1;
const timeframeSelect = document.getElementById('timeframe-select');
const candleTimerDiv = document.getElementById('candle-timer');
const chartTitle = document.getElementById('chart-title');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.querySelector('.progress-bar');
const measureToolEl = document.getElementById('measure-tool');
const measureBoxEl = document.getElementById('measure-box');
const measureSvgEl = document.getElementById('measure-svg');
const measureTooltipEl = document.getElementById('measure-tooltip');
let measureState = { active: false, finished: false, startPoint: null, endPoint: null };
let isRedrawScheduled = false;
const timeframeDisplay = document.getElementById('timeframe-display');
const modalOverlay = document.getElementById('timeframe-modal-overlay');
const modalInput = document.getElementById('timeframe-input');
const modalPreviewText = document.getElementById('timeframe-preview-text');
const modalConfirmBtn = document.getElementById('timeframe-confirm-btn');
const csvFileSelect = document.getElementById('csv-file-select');
const csvInfoDiv = document.getElementById('csv-info');
function openModal(initialValue = '') {
modalOverlay.style.display = 'flex';
modalInput.value = initialValue;
updatePreviewText();
modalInput.focus();
modalInput.select();
}
function closeModal() {
modalOverlay.style.display = 'none';
}
function updatePreviewText() {
const value = modalInput.value;
if (value && parseInt(value) > 0) {
modalPreviewText.textContent = `${value} minute${parseInt(value) > 1 ? 's' : ''}`;
} else {
modalPreviewText.textContent = '';
}
}
function confirmTimeframe() {
const newTimeframe = parseInt(modalInput.value);
if (newTimeframe && newTimeframe > 0) {
currentTimeframeMinutes = newTimeframe;
timeframeDisplay.textContent = `${newTimeframe}m`;
updateChartForTimeframe(true);
closeModal();
}
}
timeframeDisplay.addEventListener('click', () => openModal(currentTimeframeMinutes));
modalConfirmBtn.addEventListener('click', confirmTimeframe);
modalInput.addEventListener('input', updatePreviewText);
modalInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') confirmTimeframe(); });
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); });
window.addEventListener('keydown', (e) => {
if (modalOverlay.style.display === 'flex' && e.key === 'Escape') {
closeModal();
}
else if (modalOverlay.style.display !== 'flex' && !isNaN(parseInt(e.key)) && !e.ctrlKey && !e.metaKey) {
const activeEl = document.activeElement;
if (activeEl.tagName !== 'INPUT' && activeEl.tagName !== 'TEXTAREA') {
e.preventDefault();
openModal(e.key);
}
}
});
manager = createIndicatorManager(chart, baseCandleData1m, displayedCandleData);
manager.populateDropdowns();
const socket = io();
socket.on('connect', () => console.log('Socket.IO connected.'));
socket.on('connect', () => {
console.log('Socket.IO connected.');
// Request available CSV files
socket.emit('get_csv_files');
});
socket.on('history_progress', (data) => {
if (data && data.progress) progressBar.style.width = `${data.progress}%`;
});
socket.on('csv_files_list', (data) => {
console.log('Received CSV files list:', data);
populateCsvDropdown(data.files, data.selected);
});
function populateCsvDropdown(files, selectedFile) {
csvFileSelect.innerHTML = '';
if (files.length === 0) {
const option = document.createElement('option');
option.value = '';
option.textContent = 'No CSV files available';
csvFileSelect.appendChild(option);
csvInfoDiv.textContent = '';
return;
}
files.forEach(file => {
const option = document.createElement('option');
option.value = file.filename;
option.textContent = file.display_name;
if (file.filename === selectedFile) {
option.selected = true;
// Show info about selected file
const sizeInMB = (file.size / (1024 * 1024)).toFixed(1);
csvInfoDiv.textContent = `${sizeInMB} MB - ${file.filename}`;
}
csvFileSelect.appendChild(option);
});
}
csvFileSelect.addEventListener('change', (e) => {
const selectedFile = e.target.value;
if (selectedFile) {
console.log('User selected CSV file:', selectedFile);
// Update info display
const selectedOption = e.target.selectedOptions[0];
const files = Array.from(e.target.options).map(option => ({
filename: option.value,
display_name: option.textContent,
size: 0 // Will be updated by server response
}));
socket.emit('select_csv_file', { filename: selectedFile });
// Show loading state
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
csvInfoDiv.textContent = 'Loading...';
}
});
socket.on('history_finished', (data) => {
if (!data || !data.klines_1m) return;
progressBar.style.width = '100%';
baseCandleData1m = data.klines_1m.map(k => ({
time: k[0] / 1000, open: parseFloat(k[1]), high: parseFloat(k[2]),
low: parseFloat(k[3]), close: parseFloat(k[4])
}));
updateChartForTimeframe(true); // Initial load, fit content
baseCandleData1m = data.klines_1m
.map(k => ({
time: k[0] / 1000,
open: parseFloat(k[1]),
high: parseFloat(k[2]),
low: parseFloat(k[3]),
close: parseFloat(k[4])
}))
.filter(candle => {
// Filter out invalid candles with null, undefined, or NaN values
return candle.time &&
!isNaN(candle.open) && !isNaN(candle.high) &&
!isNaN(candle.low) && !isNaN(candle.close) &&
candle.open > 0 && candle.high > 0 &&
candle.low > 0 && candle.close > 0;
});
updateChartForTimeframe(true);
setTimeout(() => { progressContainer.style.display = 'none'; }, 500);
});
socket.on('candle_update', (candle) => {
candlestickSeries.update(candle);
// --- MODIFICATION START: Rewritten candle update and creation logic ---
function handleLiveUpdate(update) {
if (baseCandleData1m.length === 0 || displayedCandleData.length === 0) return;
// Validate the update data
if (!update || !update.time ||
isNaN(update.open) || isNaN(update.high) ||
isNaN(update.low) || isNaN(update.close) ||
update.open <= 0 || update.high <= 0 ||
update.low <= 0 || update.close <= 0) {
console.warn('Invalid update data received:', update);
return;
}
// First, ensure the base 1m data is up-to-date.
const lastBaseCandle = baseCandleData1m[baseCandleData1m.length - 1];
if (update.time > lastBaseCandle.time) {
baseCandleData1m.push(update);
} else if (update.time === lastBaseCandle.time) {
baseCandleData1m[baseCandleData1m.length - 1] = update;
}
const candleDurationSeconds = currentTimeframeMinutes * 60;
let lastDisplayedCandle = displayedCandleData[displayedCandleData.length - 1];
// Calculate which bucket this update belongs to using simple division
const updateBucketTime = Math.floor(update.time / candleDurationSeconds) * candleDurationSeconds;
// Check if the update belongs to the currently forming displayed candle
if (updateBucketTime === lastDisplayedCandle.time) {
// It does, so just update the High, Low, and Close prices
lastDisplayedCandle.high = Math.max(lastDisplayedCandle.high, update.high);
lastDisplayedCandle.low = Math.min(lastDisplayedCandle.low, update.low);
lastDisplayedCandle.close = update.close;
candlestickSeries.update(lastDisplayedCandle);
} else if (updateBucketTime > lastDisplayedCandle.time) {
// This update is for a NEW candle.
// Create the new candle. Its O,H,L,C are all from this first tick.
const newCandle = {
time: updateBucketTime,
open: update.open,
high: update.high,
low: update.low,
close: update.close,
};
displayedCandleData.push(newCandle);
candlestickSeries.update(newCandle);
// Since a new candle has been formed, we should recalculate indicators
manager.recalculateAllAfterHistory(baseCandleData1m, displayedCandleData);
}
}
let latestCandleUpdate = null;
let isUpdateScheduled = false;
function processLatestUpdate() {
if (latestCandleUpdate) {
handleLiveUpdate(latestCandleUpdate);
latestCandleUpdate = null;
}
isUpdateScheduled = false;
}
socket.on('candle_update', (update) => {
latestCandleUpdate = update;
if (!isUpdateScheduled) {
isUpdateScheduled = true;
requestAnimationFrame(processLatestUpdate);
}
});
socket.on('candle_closed', (closedCandle) => {
const lastBaseCandle = baseCandleData1m.length > 0 ? baseCandleData1m[baseCandleData1m.length - 1] : null;
if (lastBaseCandle && lastBaseCandle.time === closedCandle.time) {
baseCandleData1m[baseCandleData1m.length - 1] = closedCandle;
} else {
baseCandleData1m.push(closedCandle);
}
updateChartForTimeframe(false); // Subsequent update, preserve zoom
});
function updateChartForTimeframe(isInitialLoad = false) {
const selectedIntervalMinutes = parseInt(timeframeSelect.value, 10);
if (baseCandleData1m.length === 0) return;
const visibleRange = isInitialLoad ? null : chart.timeScale().getVisibleLogicalRange();
const newCandleData = aggregateCandles(baseCandleData1m, selectedIntervalMinutes);
// This handler's primary job is to ensure data integrity by using the final, closed 1m candle.
if (newCandleData.length > 0) {
displayedCandleData = newCandleData;
candlestickSeries.setData(displayedCandleData);
chartTitle.textContent = `{{ symbol }} Chart (${selectedIntervalMinutes}m)`;
manager.recalculateAllAfterHistory(baseCandleData1m, displayedCandleData);
if (visibleRange) {
chart.timeScale().setVisibleLogicalRange(visibleRange);
} else {
chart.timeScale().fitContent();
// 1. Update the master 1-minute data array with the final version of the candle.
const candleIndex = baseCandleData1m.findIndex(c => c.time === closedCandle.time);
if (candleIndex !== -1) {
baseCandleData1m[candleIndex] = closedCandle;
} else {
// This case might happen if connection was lost and we missed updates for this candle
baseCandleData1m.push(closedCandle);
baseCandleData1m.sort((a, b) => a.time - b.time); // Keep it sorted just in case
}
if (displayedCandleData.length === 0) return;
// 2. Determine which displayed candle this closed 1m candle belongs to.
const candleDurationSeconds = currentTimeframeMinutes * 60;
const bucketTime = Math.floor(closedCandle.time / candleDurationSeconds) * candleDurationSeconds;
// 3. Find the displayed candle that needs to be corrected with final data.
const displayedCandleToUpdate = displayedCandleData.find(c => c.time === bucketTime);
if (!displayedCandleToUpdate) {
console.warn("Could not find a displayed candle to update for closed 1m candle at", new Date(closedCandle.time * 1000).toISOString());
// As a fallback, a full redraw can fix inconsistencies.
// updateChartForTimeframe(true);
return;
}
// 4. Find all 1m source candles for this bucket.
const sourceCandles = baseCandleData1m.filter(c =>
c.time >= bucketTime && c.time < bucketTime + candleDurationSeconds
);
// 5. If we have source candles, aggregate them to get the CORRECT final data.
if (sourceCandles.length > 0) {
const finalCandle = {
time: bucketTime,
open: sourceCandles[0].open,
high: Math.max(...sourceCandles.map(c => c.high)),
low: Math.min(...sourceCandles.map(c => c.low)),
close: sourceCandles[sourceCandles.length - 1].close
};
// 6. Update the specific candle in the displayed data array
const displayedIndex = displayedCandleData.findIndex(c => c.time === bucketTime);
if (displayedIndex !== -1) {
displayedCandleData[displayedIndex] = finalCandle;
}
// 7. Update the series on the chart and recalculate indicators for accuracy.
candlestickSeries.update(finalCandle);
manager.recalculateAllAfterHistory(baseCandleData1m, displayedCandleData);
}
});
// --- MODIFICATION END ---
function updateChartForTimeframe(isFullReset = false) {
if (baseCandleData1m.length === 0) return;
try {
const visibleTimeRange = isFullReset ? null : chart.timeScale().getVisibleTimeRange();
const newCandleData = aggregateCandles(baseCandleData1m, currentTimeframeMinutes);
// Validate the aggregated data
const validCandleData = newCandleData.filter(candle => {
return candle && candle.time &&
!isNaN(candle.open) && !isNaN(candle.high) &&
!isNaN(candle.low) && !isNaN(candle.close) &&
candle.open > 0 && candle.high > 0 &&
candle.low > 0 && candle.close > 0;
});
if (validCandleData.length > 0) {
displayedCandleData = validCandleData;
candlestickSeries.setData(displayedCandleData);
chartTitle.textContent = `{{ symbol }} Chart (${currentTimeframeMinutes}m)`;
manager.recalculateAllAfterHistory(baseCandleData1m, displayedCandleData);
if (visibleTimeRange) {
chart.timeScale().setVisibleRange(visibleTimeRange);
} else {
chart.timeScale().fitContent();
}
} else {
console.warn('No valid candle data available for timeframe:', currentTimeframeMinutes);
}
} catch (error) {
console.error('Error updating chart for timeframe:', error);
console.error('Current timeframe:', currentTimeframeMinutes);
console.error('Base data length:', baseCandleData1m.length);
}
}
timeframeSelect.addEventListener('change', () => updateChartForTimeframe(true));
function clearMeasureTool() {
measureState = { active: false, finished: false, startPoint: null, endPoint: null };
measureToolEl.style.display = 'none';
measureSvgEl.innerHTML = '';
}
function formatDuration(seconds) {
const d = Math.floor(seconds / (3600*24));
const h = Math.floor(seconds % (3600*24) / 3600);
const m = Math.floor(seconds % 3600 / 60);
let result = '';
if (d > 0) result += `${d}d `;
if (h > 0) result += `${h}h `;
if (m > 0) result += `${m}m`;
return result.trim() || '0m';
}
function drawMeasureTool() {
if (!measureState.startPoint || !measureState.endPoint) return;
const startCoord = {
x: chart.timeScale().timeToCoordinate(measureState.startPoint.time),
y: candlestickSeries.priceToCoordinate(measureState.startPoint.price)
};
const endCoord = {
x: chart.timeScale().timeToCoordinate(measureState.endPoint.time),
y: candlestickSeries.priceToCoordinate(measureState.endPoint.price)
};
if (startCoord.x === null || startCoord.y === null || endCoord.x === null || endCoord.y === null) {
measureToolEl.style.display = 'none';
return;
}
measureToolEl.style.display = 'block';
const isUp = measureState.endPoint.price >= measureState.startPoint.price;
const boxColor = isUp ? 'var(--measure-tool-up-bg)' : 'var(--measure-tool-down-bg)';
const borderColor = isUp ? 'var(--measure-tool-up-border)' : 'var(--measure-tool-down-border)';
measureBoxEl.style.backgroundColor = boxColor;
measureBoxEl.style.borderColor = borderColor;
measureTooltipEl.style.backgroundColor = borderColor;
const minX = Math.min(startCoord.x, endCoord.x);
const maxX = Math.max(startCoord.x, endCoord.x);
const minY = Math.min(startCoord.y, endCoord.y);
const maxY = Math.max(startCoord.y, endCoord.y);
measureBoxEl.style.left = `${minX}px`;
measureBoxEl.style.top = `${minY}px`;
measureBoxEl.style.width = `${maxX - minX}px`;
measureBoxEl.style.height = `${maxY - minY}px`;
const midX = minX + (maxX - minX) / 2;
const midY = minY + (maxY - minY) / 2;
const arrowSize = 5;
const isTimeGoingForward = measureState.endPoint.time >= measureState.startPoint.time;
let hArrowPoints = isTimeGoingForward
? `${maxX - arrowSize},${midY - arrowSize} ${maxX},${midY} ${maxX - arrowSize},${midY + arrowSize}`
: `${minX + arrowSize},${midY - arrowSize} ${minX},${midY} ${minX + arrowSize},${midY + arrowSize}`;
let vArrowPoints = isUp
? `${midX - arrowSize},${minY + arrowSize} ${midX},${minY} ${midX + arrowSize},${minY + arrowSize}`
: `${midX - arrowSize},${maxY - arrowSize} ${midX},${maxY} ${midX + arrowSize},${maxY - arrowSize}`;
measureSvgEl.innerHTML = `
<line x1="${minX}" y1="${midY}" x2="${maxX}" y2="${midY}" stroke="${borderColor}" stroke-width="1"/>
<polygon points="${hArrowPoints}" fill="${borderColor}" />
<line x1="${midX}" y1="${minY}" x2="${midX}" y2="${maxY}" stroke="${borderColor}" stroke-width="1"/>
<polygon points="${vArrowPoints}" fill="${borderColor}" />
`;
const priceChange = measureState.endPoint.price - measureState.startPoint.price;
const pctChange = (priceChange / measureState.startPoint.price) * 100;
const timeDifference = measureState.endPoint.time - measureState.startPoint.time;
const bars = Math.round(timeDifference / (currentTimeframeMinutes * 60));
const duration = formatDuration(Math.abs(timeDifference));
measureTooltipEl.innerHTML = `
<div>${priceChange.toFixed(2)} (${pctChange.toFixed(2)}%)</div>
<div>${bars} bars, ${duration}</div>
`;
measureTooltipEl.style.left = `${midX}px`;
const tooltipMargin = 8;
if (isUp) {
measureTooltipEl.style.top = `${minY}px`;
measureTooltipEl.style.transform = `translate(-50%, calc(-100% - ${tooltipMargin}px))`;
} else {
measureTooltipEl.style.top = `${maxY}px`;
measureTooltipEl.style.transform = `translate(-50%, ${tooltipMargin}px)`;
}
}
const onMeasureMove = (param) => {
if (!measureState.active || !param.point) return;
measureState.endPoint.time = chart.timeScale().coordinateToTime(param.point.x);
measureState.endPoint.price = candlestickSeries.coordinateToPrice(param.point.y);
if (!measureState.endPoint.time || !measureState.endPoint.price) return;
drawMeasureTool();
};
const onMeasureUp = () => {
measureState.active = false;
measureState.finished = true;
chart.unsubscribeCrosshairMove(onMeasureMove);
};
let priceScaleDragState = { isDragging: false };
function priceScaleRedrawLoop() {
if (!priceScaleDragState.isDragging) return;
drawMeasureTool();
requestAnimationFrame(priceScaleRedrawLoop);
}
chartElement.addEventListener('mousedown', (e) => {
if (!measureState.finished) return;
const rect = chartElement.getBoundingClientRect();
const priceScaleWidth = chart.priceScale('right').width();
if (e.clientX > rect.left + rect.width - priceScaleWidth) {
priceScaleDragState.isDragging = true;
requestAnimationFrame(priceScaleRedrawLoop);
}
});
window.addEventListener('mouseup', () => {
if (priceScaleDragState.isDragging) {
priceScaleDragState.isDragging = false;
}
});
chart.timeScale().subscribeVisibleTimeRangeChange(() => {
if (measureState.finished) {
drawMeasureTool();
}
});
chart.subscribeCrosshairMove(() => {
if (measureState.finished && !isRedrawScheduled) {
isRedrawScheduled = true;
requestAnimationFrame(() => {
drawMeasureTool();
isRedrawScheduled = false;
});
}
});
chart.subscribeClick((param) => {
if (measureState.finished) {
clearMeasureTool();
return;
}
if (!param.point || !param.sourceEvent.shiftKey) return;
if (measureState.active) {
onMeasureUp();
return;
}
clearMeasureTool();
const time = chart.timeScale().coordinateToTime(param.point.x);
const price = candlestickSeries.coordinateToPrice(param.point.y);
if (!time || !price) return;
measureState.startPoint = { time, price };
measureState.endPoint = { time, price };
measureState.active = true;
measureToolEl.style.display = 'block';
drawMeasureTool();
chart.subscribeCrosshairMove(onMeasureMove);
});
setInterval(() => {
const selectedIntervalSeconds = parseInt(timeframeSelect.value, 10) * 60;
const selectedIntervalSeconds = currentTimeframeMinutes * 60;
const now = new Date().getTime() / 1000;
const secondsRemaining = Math.floor(selectedIntervalSeconds - (now % selectedIntervalSeconds));
const minutes = Math.floor(secondsRemaining / 60);