Compare commits
10 Commits
96a96e2166
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9d76c85bd | |||
| 2a556781da | |||
| 4305e1cb02 | |||
| 68d8bc9880 | |||
| 3843bfdff8 | |||
| e88f836f9d | |||
| 80e3875abe | |||
| 0c3c9ecd81 | |||
| f8064f2f44 | |||
| 666d5fb007 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -1 +1,2 @@
|
|||||||
*.csv
|
*.csv
|
||||||
|
historical_data_1m.json
|
||||||
|
|||||||
487
app.py
487
app.py
@ -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
|
||||||
try:
|
|
||||||
with open(filepath, 'rb') as f:
|
|
||||||
# Seek to a position near the end of the file to read a chunk.
|
|
||||||
# 4096 bytes should be enough to contain several lines.
|
|
||||||
f.seek(0, os.SEEK_END)
|
|
||||||
filesize = f.tell()
|
|
||||||
if filesize == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
f.seek(max(0, filesize - 4096), os.SEEK_SET)
|
|
||||||
|
|
||||||
# Read the last part of the file
|
|
||||||
lines = f.readlines()
|
|
||||||
if not lines:
|
|
||||||
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.
|
for filename in os.listdir(DATA_FOLDER):
|
||||||
if not os.path.exists(HISTORY_CSV_FILE):
|
if filename.endswith('.csv') and SYMBOL in filename:
|
||||||
logging.critical(f"CRITICAL: History file '{HISTORY_CSV_FILE}' not found. Please provide the CSV file. Halting data load.")
|
# Extract date from filename like ETHUSDT_20250101.csv
|
||||||
historical_data_cache = []
|
match = re.search(r'(\d{8})', filename)
|
||||||
return
|
if match:
|
||||||
|
date_str = match.group(1)
|
||||||
# 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:
|
try:
|
||||||
dt_obj = datetime.strptime(row[0], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
|
start_date = datetime.strptime(date_str, '%Y%m%d')
|
||||||
if dt_obj >= frontend_start_dt:
|
file_path = os.path.join(DATA_FOLDER, filename)
|
||||||
timestamp_ms = int(dt_obj.timestamp() * 1000)
|
file_size = os.path.getsize(file_path)
|
||||||
frontend_klines.append([
|
csv_files.append({
|
||||||
timestamp_ms, row[1], row[2], row[3], row[4],
|
'filename': filename,
|
||||||
"0", "0", "0", "0", "0", "0"
|
'start_date_str': start_date.strftime('%Y-%m-%d'),
|
||||||
])
|
'date_str': date_str,
|
||||||
except (ValueError, IndexError):
|
'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
|
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:
|
except Exception as e:
|
||||||
logging.error(f"Failed to read CSV for frontend cache: {e}")
|
logging.error(f"Error reading CSV file {csv_filename}: {e}")
|
||||||
historical_data_cache = []
|
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}")
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
# Wait until the cache is populated.
|
logging.info(f"Received select_csv_file request from SID={request.sid} with data: {data}")
|
||||||
while not historical_data_cache:
|
filename = data.get('filename')
|
||||||
logging.info(f"SID={request.sid} is waiting for historical data cache...")
|
if filename:
|
||||||
socketio.sleep(1)
|
csv_files = get_available_csv_files()
|
||||||
|
valid_files = [f['filename'] for f in csv_files]
|
||||||
logging.info(f"Sending {len(historical_data_cache)} cached klines to SID={request.sid}")
|
|
||||||
socketio.emit('history_finished', {'klines_1m': historical_data_cache}, to=request.sid)
|
if filename in valid_files:
|
||||||
|
selected_csv_file = filename
|
||||||
|
save_user_preference(filename)
|
||||||
@socketio.on('analyze_chart')
|
logging.info(f"User selected CSV file: {filename}")
|
||||||
def handle_analyze_chart(data):
|
|
||||||
sid = request.sid
|
# Stream new historical data
|
||||||
logging.info(f"Received 'analyze_chart' request from frontend (SID={sid})")
|
socketio.start_background_task(target=stream_historical_data, sid=request.sid)
|
||||||
recent_data = data[-100:]
|
else:
|
||||||
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])
|
logging.error(f"Invalid CSV file selected: {filename}")
|
||||||
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('error', {'message': f'Invalid CSV file: {filename}'})
|
||||||
socketio.emit('analysis_result', {'analysis': "AI analysis is currently unavailable."}, to=sid)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Flask Routes ---
|
# --- Flask Routes ---
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
|
|||||||
102
data/data_miner.py
Normal file
102
data/data_miner.py
Normal 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
1
historical_data_1m.json
Normal file
File diff suppressed because one or more lines are too long
@ -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,
|
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,
|
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,
|
bb3_len_upper: 20, bb3_std_upper: 3.3, bb3_len_lower: 20, bb3_std_lower: 3.3,
|
||||||
},
|
},
|
||||||
calculateFull: calculateFullBollingerBands,
|
calculateFull: calculateFullBollingerBands,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,50 +1,62 @@
|
|||||||
/**
|
/**
|
||||||
* Aggregates fine-grained candle data into a larger timeframe.
|
* Aggregates fine-grained candle data into a larger timeframe.
|
||||||
* For example, it can convert 1-minute candles into 5-minute candles.
|
* For example, it can convert 1-minute candles into 5-minute candles.
|
||||||
*
|
*
|
||||||
* @param {Array<Object>} data - An array of candle objects, sorted by time.
|
* @param {Array<Object>} data - An array of candle objects, sorted by time.
|
||||||
* Each object must have { time, open, high, low, close }.
|
* Each object must have { time, open, high, low, close }.
|
||||||
* @param {number} intervalMinutes - The desired new candle interval in minutes (e.g., 5 for 5m).
|
* @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.
|
* @returns {Array<Object>} A new array of aggregated candle objects.
|
||||||
*/
|
*/
|
||||||
function aggregateCandles(data, intervalMinutes) {
|
function aggregateCandles(data, intervalMinutes) {
|
||||||
if (!data || data.length === 0 || !intervalMinutes || intervalMinutes < 1) {
|
if (!data || data.length === 0 || !intervalMinutes || intervalMinutes < 1) {
|
||||||
return [];
|
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
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
const intervalSeconds = intervalMinutes * 60;
|
||||||
// Add the last aggregated candle if it exists
|
const aggregated = [];
|
||||||
if (currentAggCandle) {
|
let currentAggCandle = null;
|
||||||
aggregated.push(currentAggCandle);
|
|
||||||
|
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;
|
|
||||||
}
|
|
||||||
223
static/hurst.js
Normal file
223
static/hurst.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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,33 +41,31 @@ 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 = '';
|
||||||
|
|
||||||
@ -77,29 +75,28 @@ function createIndicatorManager(chart, baseCandleData) {
|
|||||||
if (!definition) return;
|
if (!definition) return;
|
||||||
|
|
||||||
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;
|
||||||
label.style.fontSize = '12px';
|
label.style.fontSize = '12px';
|
||||||
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = param.type;
|
input.type = param.type;
|
||||||
input.value = param.defaultValue;
|
input.value = param.defaultValue;
|
||||||
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);
|
||||||
}, 500);
|
slot.debounceTimerId = null; // Clear the ID after the function has run.
|
||||||
|
}, 500);
|
||||||
});
|
});
|
||||||
const controlGroup = document.createElement('div');
|
const controlGroup = document.createElement('div');
|
||||||
controlGroup.style.display = 'flex';
|
controlGroup.style.display = 'flex';
|
||||||
@ -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;
|
|
||||||
|
|
||||||
if (!slot || !slot.definition || candleDataForCalc.length === 0) {
|
const candleDataForCalc = (slot.definition.usesBaseData) ? baseCandleDataRef : displayedCandleDataRef;
|
||||||
return;
|
if (candleDataForCalc.length === 0) return;
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up previous series before creating new ones
|
|
||||||
slot.series.forEach(s => chart.removeSeries(s));
|
|
||||||
slot.series = [];
|
|
||||||
|
|
||||||
console.log(`Recalculating ${slot.definition.name} for slot ${slot.id} on ${candleDataForCalc.length} candles.`);
|
if (isFullRecalculation) {
|
||||||
|
slot.series.forEach(s => chart.removeSeries(s));
|
||||||
const indicatorResult = slot.definition.calculateFull(candleDataForCalc, slot.params);
|
slot.series = [];
|
||||||
|
|
||||||
// Handle multi-line indicators like Bollinger Bands
|
const indicatorResult = slot.definition.calculateFull(candleDataForCalc, slot.params);
|
||||||
if (typeof indicatorResult === 'object' && !Array.isArray(indicatorResult)) {
|
|
||||||
Object.keys(indicatorResult).forEach(key => {
|
if (typeof indicatorResult === 'object' && !Array.isArray(indicatorResult)) {
|
||||||
const seriesData = indicatorResult[key];
|
Object.keys(indicatorResult).forEach(key => {
|
||||||
const bandName = key.split('_')[0];
|
const seriesData = indicatorResult[key];
|
||||||
const bandType = key.split('_')[1];
|
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,
|
||||||
|
title: '',
|
||||||
|
lastValueVisible: false,
|
||||||
|
priceLineVisible: false,
|
||||||
|
});
|
||||||
|
series.setData(seriesData);
|
||||||
|
slot.series.push(series);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
const series = chart.addLineSeries({
|
const series = chart.addLineSeries({
|
||||||
color: colors[bandName] ? colors[bandName][bandType] : colors.default[slot.id - 1],
|
color: 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(indicatorResult);
|
||||||
slot.series.push(series);
|
slot.series.push(series);
|
||||||
});
|
}
|
||||||
} else { // Handle single-line indicators like SMA/EMA
|
|
||||||
const series = chart.addLineSeries({
|
if (slot.definition.createRealtime) {
|
||||||
color: colors.default[slot.id - 1],
|
slot.calculator = slot.definition.createRealtime(slot.params);
|
||||||
lineWidth: 2,
|
slot.calculator.prime(candleDataForCalc);
|
||||||
title: slot.definition.label,
|
}
|
||||||
});
|
} else if (slot.calculator) {
|
||||||
series.setData(indicatorResult);
|
const lastCandle = candleDataForCalc[candleDataForCalc.length - 1];
|
||||||
slot.series.push(series);
|
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() {
|
|
||||||
indicatorSlots.forEach(slot => {
|
// --- 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAllOnNewCandle() {
|
||||||
|
indicatorSlots.forEach(slot => {
|
||||||
if (slot.definition) {
|
if (slot.definition) {
|
||||||
updateIndicator(slot.id);
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
];
|
];
|
||||||
@ -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"></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-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);
|
|
||||||
manager.populateDropdowns();
|
|
||||||
|
|
||||||
const socket = io();
|
|
||||||
socket.on('connect', () => console.log('Socket.IO connected.'));
|
|
||||||
|
|
||||||
socket.on('history_finished', (data) => {
|
|
||||||
if (!data || !data.klines_1m) return;
|
|
||||||
|
|
||||||
const mappedKlines = 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])
|
|
||||||
}));
|
|
||||||
|
|
||||||
baseCandleData1m.length = 0;
|
|
||||||
for (const kline of mappedKlines) {
|
|
||||||
baseCandleData1m.push(kline);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateChartForTimeframe();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('trade', (trade) => {
|
|
||||||
const price = parseFloat(trade.p);
|
|
||||||
const tradeTime = Math.floor(trade.T / 1000);
|
|
||||||
const candleTimestamp1m = tradeTime - (tradeTime % 60);
|
|
||||||
|
|
||||||
if (!currentCandle1m || candleTimestamp1m > currentCandle1m.time) {
|
|
||||||
if (currentCandle1m) baseCandleData1m.push(currentCandle1m);
|
|
||||||
currentCandle1m = { time: candleTimestamp1m, open: price, high: price, low: price, close: price };
|
|
||||||
} else {
|
|
||||||
currentCandle1m.high = Math.max(currentCandle1m.high, price);
|
|
||||||
currentCandle1m.low = Math.min(currentCandle1m.low, price);
|
|
||||||
currentCandle1m.close = price;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedInterval = parseInt(timeframeSelect.value, 10) * 60;
|
|
||||||
const displayedCandleTimestamp = tradeTime - (tradeTime % selectedInterval);
|
|
||||||
const lastDisplayedCandle = displayedCandleData[displayedCandleData.length - 1];
|
|
||||||
|
|
||||||
let candleForUpdate;
|
|
||||||
if (lastDisplayedCandle && displayedCandleTimestamp === lastDisplayedCandle.time) {
|
|
||||||
candleForUpdate = { ...lastDisplayedCandle };
|
|
||||||
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.
|
|
||||||
manager.updateIndicatorsOnNewCandle(displayedCandleData);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candleForUpdate) candlestickSeries.update(candleForUpdate);
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateChartForTimeframe() {
|
const measureToolEl = document.getElementById('measure-tool');
|
||||||
const selectedIntervalMinutes = parseInt(timeframeSelect.value, 10);
|
const measureBoxEl = document.getElementById('measure-box');
|
||||||
if (baseCandleData1m.length === 0) return;
|
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 newCandleData = aggregateCandles(baseCandleData1m, selectedIntervalMinutes);
|
const timeframeDisplay = document.getElementById('timeframe-display');
|
||||||
|
const modalOverlay = document.getElementById('timeframe-modal-overlay');
|
||||||
if (newCandleData.length > 0) {
|
const modalInput = document.getElementById('timeframe-input');
|
||||||
displayedCandleData = newCandleData;
|
const modalPreviewText = document.getElementById('timeframe-preview-text');
|
||||||
candlestickSeries.setData(displayedCandleData);
|
const modalConfirmBtn = document.getElementById('timeframe-confirm-btn');
|
||||||
chartTitle.textContent = `{{ symbol }} Chart (${selectedIntervalMinutes}m)`;
|
const csvFileSelect = document.getElementById('csv-file-select');
|
||||||
manager.recalculateAllAfterHistory(displayedCandleData);
|
const csvInfoDiv = document.getElementById('csv-info');
|
||||||
chart.timeScale().fitContent();
|
|
||||||
|
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 = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
timeframeSelect.addEventListener('change', updateChartForTimeframe);
|
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.');
|
||||||
|
// 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])
|
||||||
|
}))
|
||||||
|
.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 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) => {
|
||||||
|
// 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 {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user