Compare commits

10 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
3843bfdff8 Merge branch 'main' into hurst_bands 2025-07-14 23:51:08 +02:00
e88f836f9d dual hurst + removed labels 2025-07-14 23:47:00 +02:00
80e3875abe single hurst band works OK 2025-07-14 22:34:49 +02:00
0c3c9ecd81 first hurst working, only for current TF 2025-07-14 21:45:48 +02:00
f8064f2f44 added hurst to indicators 2025-07-14 21:09:50 +02:00
666d5fb007 first hurst 2025-07-14 20:49:19 +02:00
10 changed files with 1423 additions and 414 deletions

1
.gitignore vendored
View File

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

479
app.py
View File

@ -4,17 +4,19 @@ import asyncio
import os import os
import json import json
import csv import csv
import re
from flask import Flask, render_template, request from flask import Flask, render_template, request
from flask_socketio import SocketIO from flask_socketio import SocketIO
from binance import Client from binance import Client
import websockets import websockets
from threading import Lock from threading import Lock
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
# --- Configuration --- # --- Configuration ---
SYMBOL = 'ETHUSDT' SYMBOL = 'ETHUSDT'
# The CSV file is now the primary source of historical data. HISTORY_FILE = 'historical_data_1m.json'
HISTORY_CSV_FILE = 'ETHUSDT_1m_Binance.csv' DATA_FOLDER = 'data'
USER_PREFERENCES_FILE = 'user_preferences.json'
RESTART_TIMEOUT_S = 15 RESTART_TIMEOUT_S = 15
BINANCE_WS_URL = f"wss://stream.binance.com:9443/ws/{SYMBOL.lower()}@trade" BINANCE_WS_URL = f"wss://stream.binance.com:9443/ws/{SYMBOL.lower()}@trade"
@ -29,141 +31,290 @@ socketio = SocketIO(app, async_mode='threading')
# --- Global State --- # --- Global State ---
app_initialized = False app_initialized = False
app_init_lock = Lock() app_init_lock = Lock()
# This cache will hold the filtered historical data to be sent to the frontend. current_bar = {} # To track the currently forming 1-minute candle
historical_data_cache = [] selected_csv_file = None # Currently selected CSV file
csv_file_lock = Lock() # Lock for CSV file operations
# --- Helper Function for Optimized Reading --- # --- Utility Functions ---
def get_last_timestamp_from_csv(filepath): def get_available_csv_files():
""" """Get list of available CSV files with their start dates."""
Efficiently reads the end of a CSV to get the timestamp from the last valid row. csv_files = []
This avoids reading the entire file into memory. if not os.path.exists(DATA_FOLDER):
Returns a datetime object or None. 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: try:
with open(filepath, 'rb') as f: start_date = datetime.strptime(date_str, '%Y%m%d')
# Seek to a position near the end of the file to read a chunk. file_path = os.path.join(DATA_FOLDER, filename)
# 4096 bytes should be enough to contain several lines. file_size = os.path.getsize(file_path)
f.seek(0, os.SEEK_END) csv_files.append({
filesize = f.tell() 'filename': filename,
if filesize == 0: 'start_date_str': start_date.strftime('%Y-%m-%d'),
return None 'date_str': date_str,
'size': file_size,
f.seek(max(0, filesize - 4096), os.SEEK_SET) 'display_name': f"{start_date.strftime('%Y-%m-%d')} ({filename})"
})
# Read the last part of the file logging.info(f"Found CSV file: {filename}, size: {file_size}, date: {date_str}")
lines = f.readlines() except ValueError:
if not lines: logging.warning(f"Could not parse date from filename: {filename}")
return None
# Get the last non-empty line
last_line_str = ''
for line in reversed(lines):
decoded_line = line.decode('utf-8').strip()
if decoded_line:
last_line_str = decoded_line
break
if not last_line_str or 'Open time' in last_line_str:
return None
last_row = last_line_str.split(',')
dt_obj = datetime.strptime(last_row[0], '%Y-%m-%d %H:%M:%S')
return dt_obj.replace(tzinfo=timezone.utc)
except (IOError, IndexError, ValueError) as e:
logging.error(f"Could not get last timestamp from CSV: {e}")
return None
# --- Data Management ---
def load_and_update_data():
"""
Loads historical data from the CSV, updates it with the latest data from Binance,
and then filters it for the frontend.
"""
global historical_data_cache
client = Client()
# 1. Check if the primary CSV data source exists.
if not os.path.exists(HISTORY_CSV_FILE):
logging.critical(f"CRITICAL: History file '{HISTORY_CSV_FILE}' not found. Please provide the CSV file. Halting data load.")
historical_data_cache = []
return
# 2. OPTIMIZED: Efficiently get the last timestamp to determine where to start fetching.
last_dt_in_csv = get_last_timestamp_from_csv(HISTORY_CSV_FILE)
start_fetch_date = None
if last_dt_in_csv:
start_fetch_date = last_dt_in_csv + timedelta(minutes=1)
logging.info(f"Last record in CSV is from {last_dt_in_csv}. Checking for new data since {start_fetch_date}.")
else:
logging.warning("Could not determine last timestamp from CSV. Assuming file is new or empty. No new data will be fetched.")
# 3. Fetch new data from Binance.
new_klines = []
if start_fetch_date and start_fetch_date < datetime.now(timezone.utc):
while True:
logging.info(f"Fetching new klines from {start_fetch_date}...")
fetched = client.get_historical_klines(SYMBOL, Client.KLINE_INTERVAL_1MINUTE, start_fetch_date.strftime("%Y-%m-%d %H:%M:%S"))
if not fetched:
logging.info("No new klines to fetch.")
break
new_klines.extend(fetched)
last_fetched_dt = datetime.fromtimestamp(fetched[-1][0] / 1000, tz=timezone.utc)
start_fetch_date = last_fetched_dt + timedelta(minutes=1)
logging.info(f"Fetched {len(fetched)} new klines, up to {last_fetched_dt}.")
if len(fetched) < 1000:
break
time.sleep(0.1)
# 4. If new data was found, append it to the CSV file.
if new_klines:
logging.info(f"Appending {len(new_klines)} new candles to {HISTORY_CSV_FILE}.")
try:
with open(HISTORY_CSV_FILE, 'a', newline='') as f:
writer = csv.writer(f)
for kline in new_klines:
open_time_dt = datetime.fromtimestamp(kline[0] / 1000, tz=timezone.utc)
open_time_str = open_time_dt.strftime('%Y-%m-%d %H:%M:%S')
close_time_dt = datetime.fromtimestamp(kline[6] / 1000, tz=timezone.utc)
close_time_str = close_time_dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
writer.writerow([open_time_str] + kline[1:6] + [close_time_str] + kline[7:])
except Exception as e:
logging.error(f"Failed to append new data to {HISTORY_CSV_FILE}: {e}")
# 5. OPTIMIZED: Read the CSV and load only the necessary data (2025 onwards) for the frontend.
logging.info("Reading CSV to populate cache with data from 01.01.2025 onwards...")
frontend_klines = []
frontend_start_dt = datetime(2025, 1, 1, tzinfo=timezone.utc)
try:
with open(HISTORY_CSV_FILE, 'r', newline='') as f:
reader = csv.reader(f)
next(reader) # Skip header
for row in reader:
try:
dt_obj = datetime.strptime(row[0], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
if dt_obj >= frontend_start_dt:
timestamp_ms = int(dt_obj.timestamp() * 1000)
frontend_klines.append([
timestamp_ms, row[1], row[2], row[3], row[4],
"0", "0", "0", "0", "0", "0"
])
except (ValueError, IndexError):
continue continue
historical_data_cache = frontend_klines # Sort by start date (newest first)
logging.info(f"--- Data initialization complete. {len(historical_data_cache)} candles cached for frontend. ---") 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: except Exception as e:
logging.error(f"Failed to read CSV for frontend cache: {e}") logging.error(f"Error appending to CSV file {csv_filename}: {e}")
historical_data_cache = []
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}")
# 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)
# --- Real-time Data Listener --- # --- Real-time Data Listener ---
def binance_listener_thread(): def binance_listener_thread():
"""
Connects to Binance, manages the 1-minute candle, and emits updates.
"""
global current_bar
async def listener(): async def listener():
global current_bar
while True: while True:
try: try:
logging.info(f"Connecting to Binance WebSocket at {BINANCE_WS_URL}...") logging.info(f"Connecting to Binance WebSocket at {BINANCE_WS_URL}...")
@ -171,7 +322,31 @@ def binance_listener_thread():
logging.info("Binance WebSocket connected successfully.") logging.info("Binance WebSocket connected successfully.")
while True: while True:
message = await websocket.recv() message = await websocket.recv()
socketio.emit('trade', json.loads(message)) trade = json.loads(message)
price = float(trade['p'])
trade_time_s = trade['T'] // 1000
candle_timestamp = trade_time_s - (trade_time_s % 60)
if not current_bar or candle_timestamp > current_bar.get("time", 0):
if current_bar:
# 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:
current_bar['high'] = max(current_bar.get('high', price), price)
current_bar['low'] = min(current_bar.get('low', price), price)
current_bar['close'] = price
# Emit the live, updating candle for visual feedback
socketio.emit('candle_update', current_bar)
except Exception as e: except Exception as e:
logging.error(f"Binance listener error: {e}. Reconnecting...") logging.error(f"Binance listener error: {e}. Reconnecting...")
await asyncio.sleep(RESTART_TIMEOUT_S) await asyncio.sleep(RESTART_TIMEOUT_S)
@ -183,32 +358,46 @@ def binance_listener_thread():
def handle_connect(): def handle_connect():
global app_initialized global app_initialized
logging.info(f"Client connected: IP={request.remote_addr}, SID={request.sid}") logging.info(f"Client connected: IP={request.remote_addr}, SID={request.sid}")
with app_init_lock: with app_init_lock:
if not app_initialized: if not app_initialized:
logging.info("--- First client connected, initializing application data ---") logging.info("--- Initializing Application ---")
socketio.start_background_task(load_and_update_data)
socketio.start_background_task(binance_listener_thread) socketio.start_background_task(binance_listener_thread)
app_initialized = True app_initialized = True
socketio.start_background_task(target=stream_historical_data, sid=request.sid)
# Wait until the cache is populated. @socketio.on('get_csv_files')
while not historical_data_cache: def handle_get_csv_files():
logging.info(f"SID={request.sid} is waiting for historical data cache...") """Send available CSV files to client."""
socketio.sleep(1) 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
})
logging.info(f"Sending {len(historical_data_cache)} cached klines to SID={request.sid}") @socketio.on('select_csv_file')
socketio.emit('history_finished', {'klines_1m': historical_data_cache}, to=request.sid) 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]
@socketio.on('analyze_chart') if filename in valid_files:
def handle_analyze_chart(data): selected_csv_file = filename
sid = request.sid save_user_preference(filename)
logging.info(f"Received 'analyze_chart' request from frontend (SID={sid})") logging.info(f"User selected CSV file: {filename}")
recent_data = data[-100:]
prompt_data = "\n".join([f"Time: {c['time']}, Open: {c['open']}, High: {c['high']}, Low: {c['low']}, Close: {c['close']}" for c in recent_data])
prompt = (f"You are a financial analyst. Based on the following recent candlestick data for {SYMBOL}, provide a brief technical analysis (3-4 sentences). Mention the current trend and any potential short-term support or resistance levels.\n\nData:\n{prompt_data}")
socketio.emit('analysis_result', {'analysis': "AI analysis is currently unavailable."}, to=sid)
# 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 --- # --- Flask Routes ---
@app.route('/') @app.route('/')

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)

1
historical_data_1m.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -17,8 +17,19 @@ function aggregateCandles(data, intervalMinutes) {
let currentAggCandle = null; let currentAggCandle = null;
data.forEach(candle => { 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 // Calculate the timestamp for the start of the interval bucket
const bucketTimestamp = candle.time - (candle.time % intervalSeconds); // 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 (!currentAggCandle || bucketTimestamp !== currentAggCandle.time) {
// If a previous aggregated candle exists, push it to the results // If a previous aggregated candle exists, push it to the results
@ -48,3 +59,4 @@ function aggregateCandles(data, intervalMinutes) {
return aggregated; return aggregated;
} }

223
static/hurst.js Normal file
View File

@ -0,0 +1,223 @@
/**
* Indicator Definition Object for Hurst Bands (Multi-Timeframe).
* This object is used by the indicator manager to create and control the indicator.
* It defines the parameters and the calculation functions.
*/
const HURST_INDICATOR = {
name: 'Hurst',
label: 'Hurst Bands (Multi-TF)',
params: [
{ name: 'cycle', type: 'number', defaultValue: 30, min: 2 },
{ name: 'timeframe_mult', type: 'number', defaultValue: 5, min: 2, step: 1 },
],
// The output is { topBand, bottomBand, topBand_h, bottomBand_h }
calculateFull: calculateFullHurst,
createRealtime: createRealtimeHurstCalculator,
};
// --- Helper Functions (private to this file) ---
/**
* Aggregates candle data into a higher timeframe.
* @param {Array<Object>} data - The original candle data.
* @param {number} multiplier - The timeframe multiplier (e.g., 5 for 5-minute candles from 1-minute data).
* @returns {Array<Object>} A new array of aggregated candle objects.
*/
function _aggregateCandles(data, multiplier) {
if (multiplier <= 1) return data;
const aggregatedData = [];
for (let i = 0; i < data.length; i += multiplier) {
const chunk = data.slice(i, i + multiplier);
if (chunk.length > 0) {
const newCandle = {
open: chunk[0].open,
high: Math.max(...chunk.map(c => c.high)),
low: Math.min(...chunk.map(c => c.low)),
close: chunk[chunk.length - 1].close,
// The timestamp of the new candle corresponds to the end of the period.
time: chunk[chunk.length - 1].time,
};
aggregatedData.push(newCandle);
}
}
return aggregatedData;
}
/**
* Calculates RMA (Relative Moving Average), a type of EMA.
* @param {number[]} series - An array of numbers.
* @param {number} period - The smoothing period.
* @returns {number[]} The calculated RMA series.
*/
function _calculateRMA(series, period) {
if (series.length < period) return [];
const alpha = 1 / period;
let rma = [];
let sum = 0;
for (let i = 0; i < period; i++) {
sum += series[i];
}
rma.push(sum / period);
for (let i = period; i < series.length; i++) {
const val = alpha * series[i] + (1 - alpha) * rma[rma.length - 1];
rma.push(val);
}
return rma;
}
/**
* Calculates ATR (Average True Range).
* @param {Array<Object>} data - The full candle data.
* @param {number} period - The ATR period.
* @returns {number[]} The calculated ATR series.
*/
function _calculateATR(data, period) {
if (data.length < period) return [];
let tr_series = [data[0].high - data[0].low];
for (let i = 1; i < data.length; i++) {
const h = data[i].high;
const l = data[i].low;
const prev_c = data[i - 1].close;
const tr = Math.max(h - l, Math.abs(h - prev_c), Math.abs(l - prev_c));
tr_series.push(tr);
}
return _calculateRMA(tr_series, period);
}
/**
* A generic function to calculate a single set of Hurst Bands.
* This is the core calculation logic.
* @param {Array<Object>} data - An array of candle objects for a specific timeframe.
* @param {number} cycle - The cycle length for this calculation.
* @param {number} atr_mult - The ATR multiplier for this calculation.
* @returns {Object} An object containing two arrays: { topBand: [...], bottomBand: [...] }.
*/
function _calculateSingleBandSet(data, cycle, atr_mult) {
const mcl = Math.floor(cycle / 2);
const mcl_2 = Math.floor(mcl / 2);
if (data.length < cycle + mcl_2) {
return { topBand: [], bottomBand: [] };
}
const closePrices = data.map(d => d.close);
const ma_mcl_full = _calculateRMA(closePrices, mcl);
const atr_full = _calculateATR(data, mcl);
const topBand = [];
const bottomBand = [];
const startIndex = mcl - 1 + mcl_2;
for (let i = startIndex; i < data.length; i++) {
const rma_atr_base_index = i - (mcl - 1);
const center_ma_index = rma_atr_base_index - mcl_2;
if (center_ma_index >= 0 && rma_atr_base_index >= 0) {
const center = ma_mcl_full[center_ma_index];
const offset = atr_full[rma_atr_base_index] * atr_mult;
if (center !== undefined && offset !== undefined) {
topBand.push({ time: data[i].time, value: center + offset });
bottomBand.push({ time: data[i].time, value: center - offset });
}
}
}
return { topBand, bottomBand };
}
// --- Main Calculation Functions ---
/**
* Calculates both primary and higher-timeframe Hurst Bands for an entire dataset.
* @param {Array<Object>} data - An array of candle objects.
* @param {Object} params - An object with { cycle, timeframe_mult }.
* @returns {Object} An object containing four arrays: { topBand, bottomBand, topBand_h, bottomBand_h }.
*/
function calculateFullHurst(data, params) {
const { cycle, timeframe_mult } = params;
// 1. Calculate Primary Bands (e.g., 1-minute)
const primaryBands = _calculateSingleBandSet(data, cycle, 1.8);
// 2. Aggregate candles to higher timeframe (e.g., 5-minute)
const higherTfData = _aggregateCandles(data, timeframe_mult);
// 3. Calculate Higher Timeframe Bands
const higherTFBandsRaw = _calculateSingleBandSet(higherTfData, cycle, 1.9);
// 4. Align higher timeframe results back to the primary timeframe for plotting
const higherTfResults = new Map(higherTFBandsRaw.topBand.map((p, i) => [
p.time,
{ top: p.value, bottom: higherTFBandsRaw.bottomBand[i].value }
]));
const topBand_h = [];
const bottomBand_h = [];
let lastKnownTop = null;
let lastKnownBottom = null;
for (const candle of data) {
if (higherTfResults.has(candle.time)) {
const bands = higherTfResults.get(candle.time);
lastKnownTop = bands.top;
lastKnownBottom = bands.bottom;
}
// Carry forward the last known value until a new one is calculated
if (lastKnownTop !== null) {
topBand_h.push({ time: candle.time, value: lastKnownTop });
bottomBand_h.push({ time: candle.time, value: lastKnownBottom });
}
}
return {
topBand: primaryBands.topBand,
bottomBand: primaryBands.bottomBand,
topBand_h,
bottomBand_h,
};
}
/**
* Creates a stateful Hurst calculator for real-time updates.
* @param {Object} params - An object with { cycle, timeframe_mult }.
* @returns {Object} A calculator object with `update` and `prime` methods.
*/
function createRealtimeHurstCalculator(params) {
const { cycle, timeframe_mult } = params;
// Buffer needs to be large enough to contain enough aggregated candles for a valid calculation.
const minHigherTfCandles = cycle + Math.floor(Math.floor(cycle / 2) / 2);
const bufferSize = minHigherTfCandles * timeframe_mult * 2; // Use a safe buffer size
let buffer = [];
return {
update: function(candle) {
buffer.push(candle);
if (buffer.length > bufferSize) {
buffer.shift();
}
// Check if there's enough data for at least one calculation on the higher timeframe.
const requiredLength = minHigherTfCandles * timeframe_mult;
if (buffer.length < requiredLength) {
return null;
}
const result = calculateFullHurst(buffer, params);
if (result.topBand.length > 0 && result.topBand_h.length > 0) {
return {
topBand: result.topBand[result.topBand.length - 1],
bottomBand: result.bottomBand[result.bottomBand.length - 1],
topBand_h: result.topBand_h[result.topBand_h.length - 1],
bottomBand_h: result.bottomBand_h[result.bottomBand_h.length - 1],
};
}
return null;
},
prime: function(historicalCandles) {
buffer = historicalCandles.slice(-bufferSize);
}
};
}

View File

@ -1,32 +1,32 @@
/** /**
* Creates and manages all indicator-related logic for the chart. * Creates and manages all indicator-related logic for the chart.
* @param {Object} chart - The Lightweight Charts instance. * @param {Object} chart - The Lightweight Charts instance.
* @param {Array<Object>} baseCandleData - A reference to the array holding the chart's BASE 1m candle data. * @param {Array<Object>} baseCandleDataRef - A reference to the array holding the chart's BASE 1m candle data.
* @param {Array<Object>} displayedCandleDataRef - A reference to the array with currently visible candles.
* @returns {Object} A manager object with public methods to control indicators. * @returns {Object} A manager object with public methods to control indicators.
*/ */
function createIndicatorManager(chart, baseCandleData) { function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef) {
// This holds the candle data currently displayed on the chart (e.g., 5m, 10m) // --- FIX: --- Added `debounceTimerId` to each slot object to track pending updates.
let currentAggregatedData = [];
// Defines the 4 slots available in the UI for indicators.
const indicatorSlots = [ const indicatorSlots = [
{ id: 1, cellId: 'indicator-cell-1', series: [], definition: null, params: {} }, { id: 1, cellId: 'indicator-cell-1', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null },
{ id: 2, cellId: 'indicator-cell-2', series: [], definition: null, params: {} }, { id: 2, cellId: 'indicator-cell-2', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null },
{ id: 3, cellId: 'indicator-cell-3', series: [], definition: null, params: {} }, { id: 3, cellId: 'indicator-cell-3', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null },
{ id: 4, cellId: 'indicator-cell-4', series: [], definition: null, params: {} }, { id: 4, cellId: 'indicator-cell-4', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null },
]; ];
// Pre-defined colors for the indicator lines.
const colors = { const colors = {
bb1: { upper: '#FF9800', lower: '#FF9800' }, // Orange bb: {
bb2: { upper: '#2196F3', lower: '#2196F3' }, // Blue bb1_upper: 'rgba(128, 25, 34, 0.5)',
bb3: { upper: '#9C27B0', lower: '#9C27B0' }, // Purple bb2_upper: 'rgba(128, 25, 34, 0.75)',
default: ['#FF5722', '#03A9F4', '#8BC34A', '#F44336'] // Fallback colors for other indicators 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']
}; };
/**
* Populates the dropdown menus in each indicator cell.
*/
function populateDropdowns() { function populateDropdowns() {
indicatorSlots.forEach(slot => { indicatorSlots.forEach(slot => {
const cell = document.getElementById(slot.cellId); const cell = document.getElementById(slot.cellId);
@ -41,32 +41,30 @@ function createIndicatorManager(chart, baseCandleData) {
const controlsContainer = document.createElement('div'); const controlsContainer = document.createElement('div');
controlsContainer.className = 'indicator-controls'; controlsContainer.className = 'indicator-controls';
cell.innerHTML = ''; // Clear previous content cell.innerHTML = '';
cell.appendChild(select); cell.appendChild(select);
cell.appendChild(controlsContainer); cell.appendChild(controlsContainer);
select.addEventListener('change', (e) => { select.addEventListener('change', (e) => loadIndicator(slot.id, e.target.value));
const indicatorName = e.target.value;
loadIndicator(slot.id, indicatorName);
});
}); });
} }
/**
* Loads a new indicator into a specified slot.
* @param {number} slotId - The ID of the slot (1-4).
* @param {string} indicatorName - The name of the indicator to load (e.g., 'SMA').
*/
function loadIndicator(slotId, indicatorName) { function loadIndicator(slotId, indicatorName) {
const slot = indicatorSlots.find(s => s.id === slotId); const slot = indicatorSlots.find(s => s.id === slotId);
if (!slot) return; if (!slot) return;
// Clean up any previous indicator series in this slot // --- 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.forEach(s => chart.removeSeries(s));
slot.series = []; slot.series = [];
slot.definition = null; slot.definition = null;
slot.params = {}; slot.params = {};
slot.calculator = null;
const controlsContainer = document.querySelector(`#${slot.cellId} .indicator-controls`); const controlsContainer = document.querySelector(`#${slot.cellId} .indicator-controls`);
controlsContainer.innerHTML = ''; controlsContainer.innerHTML = '';
@ -78,7 +76,6 @@ function createIndicatorManager(chart, baseCandleData) {
slot.definition = definition; slot.definition = definition;
// Create UI controls for the indicator's parameters
definition.params.forEach(param => { definition.params.forEach(param => {
const label = document.createElement('label'); const label = document.createElement('label');
label.textContent = param.label || param.name; label.textContent = param.label || param.name;
@ -90,15 +87,15 @@ function createIndicatorManager(chart, baseCandleData) {
if (param.min !== undefined) input.min = param.min; if (param.min !== undefined) input.min = param.min;
if (param.step !== undefined) input.step = param.step; if (param.step !== undefined) input.step = param.step;
input.className = 'input-field'; input.className = 'input-field';
input.placeholder = param.name;
slot.params[param.name] = input.type === 'number' ? parseFloat(input.value) : input.value; slot.params[param.name] = input.type === 'number' ? parseFloat(input.value) : input.value;
let debounceTimer;
input.addEventListener('input', () => { input.addEventListener('input', () => {
clearTimeout(debounceTimer); // --- FIX: --- Use the slot's `debounceTimerId` property to manage the timeout.
debounceTimer = setTimeout(() => { clearTimeout(slot.debounceTimerId);
slot.debounceTimerId = setTimeout(() => {
slot.params[param.name] = input.type === 'number' ? parseFloat(input.value) : input.value; slot.params[param.name] = input.type === 'number' ? parseFloat(input.value) : input.value;
updateIndicator(slot.id); updateIndicator(slot.id, true);
slot.debounceTimerId = null; // Clear the ID after the function has run.
}, 500); }, 500);
}); });
const controlGroup = document.createElement('div'); const controlGroup = document.createElement('div');
@ -109,91 +106,110 @@ function createIndicatorManager(chart, baseCandleData) {
controlsContainer.appendChild(controlGroup); controlsContainer.appendChild(controlGroup);
}); });
updateIndicator(slot.id); updateIndicator(slot.id, true);
} }
/** function updateIndicator(slotId, isFullRecalculation = false) {
* Recalculates and redraws the lines for a specific indicator.
* @param {number} slotId - The ID of the slot to update.
*/
function updateIndicator(slotId) {
const slot = indicatorSlots.find(s => s.id === slotId); const slot = indicatorSlots.find(s => s.id === slotId);
if (!slot || !slot.definition) return;
const candleDataForCalc = (slot.definition.usesBaseData) ? baseCandleData : currentAggregatedData; const candleDataForCalc = (slot.definition.usesBaseData) ? baseCandleDataRef : displayedCandleDataRef;
if (candleDataForCalc.length === 0) return;
if (!slot || !slot.definition || candleDataForCalc.length === 0) {
return;
}
// Clean up previous series before creating new ones if (isFullRecalculation) {
slot.series.forEach(s => chart.removeSeries(s)); slot.series.forEach(s => chart.removeSeries(s));
slot.series = []; slot.series = [];
console.log(`Recalculating ${slot.definition.name} for slot ${slot.id} on ${candleDataForCalc.length} candles.`);
const indicatorResult = slot.definition.calculateFull(candleDataForCalc, slot.params); const indicatorResult = slot.definition.calculateFull(candleDataForCalc, slot.params);
// Handle multi-line indicators like Bollinger Bands
if (typeof indicatorResult === 'object' && !Array.isArray(indicatorResult)) { if (typeof indicatorResult === 'object' && !Array.isArray(indicatorResult)) {
Object.keys(indicatorResult).forEach(key => { Object.keys(indicatorResult).forEach(key => {
const seriesData = indicatorResult[key]; const seriesData = indicatorResult[key];
const bandName = key.split('_')[0]; const indicatorNameLower = slot.definition.name.toLowerCase();
const bandType = key.split('_')[1];
const series = chart.addLineSeries({ const series = chart.addLineSeries({
color: colors[bandName] ? colors[bandName][bandType] : colors.default[slot.id - 1], color: (colors[indicatorNameLower] && colors[indicatorNameLower][key]) ? colors[indicatorNameLower][key] : colors.default[slot.id - 1],
lineWidth: 2, lineWidth: 1,
title: `${slot.definition.label} - ${key}`, title: '',
lastValueVisible: false, lastValueVisible: false,
priceLineVisible: false, priceLineVisible: false,
}); });
series.setData(seriesData); series.setData(seriesData);
slot.series.push(series); slot.series.push(series);
}); });
} else { // Handle single-line indicators like SMA/EMA } else {
const series = chart.addLineSeries({ const series = chart.addLineSeries({
color: colors.default[slot.id - 1], color: colors.default[slot.id - 1],
lineWidth: 2, lineWidth: 1,
title: slot.definition.label, title: '',
lastValueVisible: false,
priceLineVisible: false,
}); });
series.setData(indicatorResult); series.setData(indicatorResult);
slot.series.push(series); slot.series.push(series);
} }
if (slot.definition.createRealtime) {
slot.calculator = slot.definition.createRealtime(slot.params);
slot.calculator.prime(candleDataForCalc);
}
} else if (slot.calculator) {
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
Object.keys(newPoint).forEach((key, index) => {
if (slot.series[index] && newPoint[key]) {
slot.series[index].update(newPoint[key]);
}
});
} else if (slot.series.length === 1) { // Single-line indicator
slot.series[0].update(newPoint);
}
}
}
} }
/** function recalculateAllAfterHistory(baseData, displayedData) {
* Internal function to recalculate all active indicators. baseCandleDataRef = baseData;
*/ displayedCandleDataRef = displayedData;
function recalculateAllIndicators() {
// --- 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 => { indicatorSlots.forEach(slot => {
if (slot.definition) { if (slot.definition) {
updateIndicator(slot.id); updateIndicator(slot.id, true);
}
});
}, 0);
}
function updateAllOnNewCandle() {
indicatorSlots.forEach(slot => {
if (slot.definition) {
updateIndicator(slot.id, false);
} }
}); });
} }
/**
* Sets the candle data for indicators and triggers a full recalculation.
* @param {Array<Object>} aggregatedCandleData - The candle data for the currently selected timeframe.
*/
function recalculateAllAfterHistory(aggregatedCandleData) {
currentAggregatedData = aggregatedCandleData;
recalculateAllIndicators();
}
/**
* Updates all indicators in response to a new candle closing.
* @param {Array<Object>} aggregatedCandleData - The latest candle data for the currently selected timeframe.
*/
function updateIndicatorsOnNewCandle(aggregatedCandleData) {
currentAggregatedData = aggregatedCandleData;
recalculateAllIndicators();
}
// Public API for the manager
return { return {
populateDropdowns, populateDropdowns,
recalculateAllAfterHistory, recalculateAllAfterHistory,
updateIndicatorsOnNewCandle updateAllOnNewCandle,
}; };
} }

View File

@ -9,5 +9,6 @@ const AVAILABLE_INDICATORS = [
SMA_INDICATOR, SMA_INDICATOR,
EMA_INDICATOR, EMA_INDICATOR,
BB_INDICATOR, // Added the new Bollinger Bands indicator BB_INDICATOR, // Added the new Bollinger Bands indicator
// Add other indicators here, e.g., RSI_INDICATOR HURST_INDICATOR // Added the new Hurst Bands indicator
// Add other indicators here as needed
]; ];

View File

@ -5,11 +5,13 @@
<script src="https://unpkg.com/lightweight-charts@4.1.3/dist/lightweight-charts.standalone.production.js"></script> <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> <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='candle-aggregator.js') }}"></script>
<script src="{{ url_for('static', filename='sma.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='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='indicators.js') }}"></script>
<script src="{{ url_for('static', filename='indicator-manager.js') }}"></script> <script src="{{ url_for('static', filename='indicator-manager.js') }}"></script>
@ -18,6 +20,13 @@
--background-dark: #161A25; --container-dark: #1E222D; --border-color: #2A2E39; --background-dark: #161A25; --container-dark: #1E222D; --border-color: #2A2E39;
--text-primary: #D1D4DC; --text-secondary: #8A91A0; --button-bg: #363A45; --text-primary: #D1D4DC; --text-secondary: #8A91A0; --button-bg: #363A45;
--button-hover-bg: #434651; --accent-orange: #F0B90B; --green: #26a69a; --red: #ef5350; --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 { body {
background-color: var(--background-dark); color: var(--text-primary); background-color: var(--background-dark); color: var(--text-primary);
@ -29,7 +38,8 @@
border-bottom: 1px solid var(--border-color); margin-bottom: 20px; border-bottom: 1px solid var(--border-color); margin-bottom: 20px;
} }
.header h1 { margin: 0; font-size: 24px; } .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 { .control-panel {
display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px; width: 90%; max-width: 1400px; padding: 20px 0; gap: 15px; width: 90%; max-width: 1400px; padding: 20px 0;
@ -47,20 +57,55 @@
border-radius: 5px; cursor: pointer; transition: background-color 0.3s, color 0.3s; 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); } .action-button:hover, .control-cell select:hover { background-color: var(--button-hover-bg); }
#analyzeButton:hover { color: var(--accent-orange); }
.input-field { width: 60px; } .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); } #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;
}
.progress-bar {
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> </style>
</head> </head>
<body> <body>
@ -68,24 +113,32 @@
<h1 id="chart-title">{{ symbol }} Chart</h1> <h1 id="chart-title">{{ symbol }} Chart</h1>
</div> </div>
<div id="chart-wrapper">
<div id="chart"></div> <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-panel">
<div class="control-cell"> <div class="control-cell">
<h3>Candle Closes In</h3> <h3>Candle Closes In</h3>
<div id="candle-timer">--:--</div> <div id="candle-timer">--:--</div>
<select id="timeframe-select"> <div id="timeframe-display" class="action-button">1m</div>
<option value="1">1m</option> <div id="progress-container" class="progress-bar-container">
<option value="2">2m</option> <div class="progress-bar"></div>
<option value="3">3m</option> </div>
<option value="4">4m</option>
<option value="5">5m</option> <!-- CSV File Selection Dropdown -->
<option value="6">6m</option> <div style="margin-top: 15px; width: 100%;">
<option value="7">7m</option> <label for="csv-file-select" style="display: block; margin-bottom: 5px; font-size: 12px; color: var(--text-secondary);">Data Source:</label>
<option value="8">8m</option> <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="9">9m</option> <option value="">Loading...</option>
<option value="10">10m</option>
</select> </select>
<div id="csv-info" style="font-size: 10px; color: var(--text-secondary); margin-top: 3px; text-align: center;"></div>
</div>
</div> </div>
<div class="control-cell" id="indicator-cell-1"></div> <div class="control-cell" id="indicator-cell-1"></div>
<div class="control-cell" id="indicator-cell-2"></div> <div class="control-cell" id="indicator-cell-2"></div>
@ -93,10 +146,14 @@
<div class="control-cell" id="indicator-cell-4"></div> <div class="control-cell" id="indicator-cell-4"></div>
</div> </div>
<div class="analysis-section"> <!-- Timeframe Modal -->
<h3>Analysis ✨</h3> <div id="timeframe-modal-overlay" class="modal-overlay">
<button id="analyzeButton" class="action-button">Analyze Recent Price Action</button> <div id="timeframe-modal" class="modal">
<div id="analysisResult">Click the button for AI analysis.</div> <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> </div>
<script> <script>
@ -106,101 +163,508 @@
width: chartElement.clientWidth, height: 500, width: chartElement.clientWidth, height: 500,
layout: { background: { type: 'solid', color: '#1E222D' }, textColor: '#D1D4DC' }, layout: { background: { type: 'solid', color: '#1E222D' }, textColor: '#D1D4DC' },
grid: { vertLines: { color: '#2A2E39' }, horzLines: { color: '#2A2E39' } }, 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({ const candlestickSeries = chart.addCandlestickSeries({
upColor: '#26a69a', downColor: '#ef5350', borderDownColor: '#ef5350', upColor: 'rgba(255, 152, 0, 1.0)', downColor: 'rgba(255, 152, 0, 0.66)', borderDownColor: 'rgba(255, 152, 0, 0.66)',
borderUpColor: '#26a69a', wickDownColor: '#ef5350', wickUpColor: '#26a69a', 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 baseCandleData1m = [];
let displayedCandleData = []; let displayedCandleData = [];
let currentCandle1m = null;
let manager; let manager;
let currentTimeframeMinutes = 1;
const timeframeSelect = document.getElementById('timeframe-select');
const candleTimerDiv = document.getElementById('candle-timer'); const candleTimerDiv = document.getElementById('candle-timer');
const chartTitle = document.getElementById('chart-title'); const chartTitle = document.getElementById('chart-title');
const analyzeButton = document.getElementById('analyzeButton'); const progressContainer = document.getElementById('progress-container');
const analysisResultDiv = document.getElementById('analysisResult'); const progressBar = document.querySelector('.progress-bar');
manager = createIndicatorManager(chart, baseCandleData1m); 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(); manager.populateDropdowns();
const socket = io(); 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) => { socket.on('history_finished', (data) => {
if (!data || !data.klines_1m) return; if (!data || !data.klines_1m) return;
progressBar.style.width = '100%';
const mappedKlines = data.klines_1m.map(k => ({ baseCandleData1m = data.klines_1m
time: k[0] / 1000, open: parseFloat(k[1]), high: parseFloat(k[2]), .map(k => ({
low: parseFloat(k[3]), close: parseFloat(k[4]) time: k[0] / 1000,
})); open: parseFloat(k[1]),
high: parseFloat(k[2]),
baseCandleData1m.length = 0; low: parseFloat(k[3]),
for (const kline of mappedKlines) { close: parseFloat(k[4])
baseCandleData1m.push(kline); }))
} .filter(candle => {
// Filter out invalid candles with null, undefined, or NaN values
updateChartForTimeframe(); 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('trade', (trade) => { // --- MODIFICATION START: Rewritten candle update and creation logic ---
const price = parseFloat(trade.p); function handleLiveUpdate(update) {
const tradeTime = Math.floor(trade.T / 1000); if (baseCandleData1m.length === 0 || displayedCandleData.length === 0) return;
const candleTimestamp1m = tradeTime - (tradeTime % 60);
if (!currentCandle1m || candleTimestamp1m > currentCandle1m.time) { // Validate the update data
if (currentCandle1m) baseCandleData1m.push(currentCandle1m); if (!update || !update.time ||
currentCandle1m = { time: candleTimestamp1m, open: price, high: price, low: price, close: price }; 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) => {
// This handler's primary job is to ensure data integrity by using the final, closed 1m candle.
// 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 { } else {
currentCandle1m.high = Math.max(currentCandle1m.high, price); // This case might happen if connection was lost and we missed updates for this candle
currentCandle1m.low = Math.min(currentCandle1m.low, price); baseCandleData1m.push(closedCandle);
currentCandle1m.close = price; baseCandleData1m.sort((a, b) => a.time - b.time); // Keep it sorted just in case
} }
const selectedInterval = parseInt(timeframeSelect.value, 10) * 60; if (displayedCandleData.length === 0) return;
const displayedCandleTimestamp = tradeTime - (tradeTime % selectedInterval);
const lastDisplayedCandle = displayedCandleData[displayedCandleData.length - 1];
let candleForUpdate; // 2. Determine which displayed candle this closed 1m candle belongs to.
if (lastDisplayedCandle && displayedCandleTimestamp === lastDisplayedCandle.time) { const candleDurationSeconds = currentTimeframeMinutes * 60;
candleForUpdate = { ...lastDisplayedCandle }; const bucketTime = Math.floor(closedCandle.time / candleDurationSeconds) * candleDurationSeconds;
candleForUpdate.high = Math.max(candleForUpdate.high, price);
candleForUpdate.low = Math.min(candleForUpdate.low, price);
candleForUpdate.close = price;
displayedCandleData[displayedCandleData.length - 1] = candleForUpdate;
} else if (!lastDisplayedCandle || displayedCandleTimestamp > lastDisplayedCandle.time) {
candleForUpdate = { time: displayedCandleTimestamp, open: price, high: price, low: price, close: price };
displayedCandleData.push(candleForUpdate);
// A new candle has started, so update the indicators. // 3. Find the displayed candle that needs to be corrected with final data.
manager.updateIndicatorsOnNewCandle(displayedCandleData); 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;
} }
if (candleForUpdate) candlestickSeries.update(candleForUpdate); // 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() { function updateChartForTimeframe(isFullReset = false) {
const selectedIntervalMinutes = parseInt(timeframeSelect.value, 10);
if (baseCandleData1m.length === 0) return; if (baseCandleData1m.length === 0) return;
const newCandleData = aggregateCandles(baseCandleData1m, selectedIntervalMinutes); try {
const visibleTimeRange = isFullReset ? null : chart.timeScale().getVisibleTimeRange();
const newCandleData = aggregateCandles(baseCandleData1m, currentTimeframeMinutes);
if (newCandleData.length > 0) { // Validate the aggregated data
displayedCandleData = newCandleData; 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); candlestickSeries.setData(displayedCandleData);
chartTitle.textContent = `{{ symbol }} Chart (${selectedIntervalMinutes}m)`; chartTitle.textContent = `{{ symbol }} Chart (${currentTimeframeMinutes}m)`;
manager.recalculateAllAfterHistory(displayedCandleData); manager.recalculateAllAfterHistory(baseCandleData1m, displayedCandleData);
if (visibleTimeRange) {
chart.timeScale().setVisibleRange(visibleTimeRange);
} else {
chart.timeScale().fitContent(); 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); 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(() => { setInterval(() => {
const selectedIntervalSeconds = parseInt(timeframeSelect.value, 10) * 60; const selectedIntervalSeconds = currentTimeframeMinutes * 60;
const now = new Date().getTime() / 1000; const now = new Date().getTime() / 1000;
const secondsRemaining = Math.floor(selectedIntervalSeconds - (now % selectedIntervalSeconds)); const secondsRemaining = Math.floor(selectedIntervalSeconds - (now % selectedIntervalSeconds));
const minutes = Math.floor(secondsRemaining / 60); const minutes = Math.floor(secondsRemaining / 60);