Compare commits

12 Commits

Author SHA1 Message Date
a9aafbc07c double short SMAs auto TF 2025-07-21 23:02:08 +02:00
af4c81b95f one week data 2025-07-21 21:29:21 +02:00
d2c45cac12 wyswietlanie wykresu chodzi jak powinno 2025-07-17 00:21:14 +02:00
2a556781da switching TF, multi indicators on chart 2025-07-16 00:28:42 +02:00
4305e1cb02 changes styles 2025-07-15 22:05:29 +02:00
68d8bc9880 measurment tooltip + one week data from binance 2025-07-15 20:27:43 +02:00
3843bfdff8 Merge branch 'main' into hurst_bands 2025-07-14 23:51:08 +02:00
e88f836f9d dual hurst + removed labels 2025-07-14 23:47:00 +02:00
80e3875abe single hurst band works OK 2025-07-14 22:34:49 +02:00
0c3c9ecd81 first hurst working, only for current TF 2025-07-14 21:45:48 +02:00
f8064f2f44 added hurst to indicators 2025-07-14 21:09:50 +02:00
666d5fb007 first hurst 2025-07-14 20:49:19 +02:00
9 changed files with 1027 additions and 353 deletions

1
.gitignore vendored
View File

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

217
app.py
View File

@ -3,18 +3,16 @@ import logging
import asyncio import asyncio
import os import os
import json import json
import csv
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'
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 +27,68 @@ 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 = []
# --- Helper Function for Optimized Reading --- # --- Historical Data Streaming ---
def get_last_timestamp_from_csv(filepath): def stream_historical_data(sid):
""" """
Efficiently reads the end of a CSV to get the timestamp from the last valid row. Fetches the last week of historical 1-minute kline data from Binance,
This avoids reading the entire file into memory. saves it to a file, and sends it to the connected client.
Returns a datetime object or None.
""" """
try: try:
with open(filepath, 'rb') as f: logging.info(f"Starting historical data stream for SID={sid}")
# 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() client = Client()
# 1. Check if the primary CSV data source exists. # --- NEW SOLUTION: Load data for the last week ---
if not os.path.exists(HISTORY_CSV_FILE): logging.info(f"Fetching historical data for the last 7 days for SID={sid}")
logging.critical(f"CRITICAL: History file '{HISTORY_CSV_FILE}' not found. Please provide the CSV file. Halting data load.") # The `python-binance` library allows using relative date strings.
historical_data_cache = [] # This single call is more efficient for this use case.
return all_klines = client.get_historical_klines(
SYMBOL,
Client.KLINE_INTERVAL_1MINUTE,
start_str="1 week ago UTC" # Fetches data starting from 8 weeks ago until now
)
# 2. OPTIMIZED: Efficiently get the last timestamp to determine where to start fetching. # --- ORIGINAL SOLUTION COMMENTED OUT ---
last_dt_in_csv = get_last_timestamp_from_csv(HISTORY_CSV_FILE) # num_chunks = 6
# chunk_size_days = 15
# end_date = datetime.utcnow()
# all_klines = []
#
# for i in range(num_chunks):
# start_date = end_date - timedelta(days=chunk_size_days)
# logging.info(f"Fetching chunk {i + 1}/{num_chunks} for SID={sid}")
# new_klines = client.get_historical_klines(SYMBOL, Client.KLINE_INTERVAL_1MINUTE, str(start_date), str(end_date))
# if new_klines:
# all_klines.extend(new_klines)
# # The progress emission is no longer needed for a single API call
# # socketio.emit('history_progress', {'progress': ((i + 1) / num_chunks) * 100}, to=sid)
# end_date = start_date
# socketio.sleep(0.05)
# --- END OF ORIGINAL SOLUTION ---
start_fetch_date = None # The rest of the function processes the `all_klines` data as before
if last_dt_in_csv: seen = set()
start_fetch_date = last_dt_in_csv + timedelta(minutes=1) unique_klines = [kline for kline in sorted(all_klines, key=lambda x: x[0]) if tuple(kline) not in seen and not seen.add(tuple(kline))]
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. with open(HISTORY_FILE, 'w') as f:
new_klines = [] json.dump(unique_klines, f)
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) logging.info(f"Finished data stream for SID={sid}. Sending final payload of {len(unique_klines)} klines.")
last_fetched_dt = datetime.fromtimestamp(fetched[-1][0] / 1000, tz=timezone.utc) socketio.emit('history_finished', {'klines_1m': unique_klines}, to=sid)
start_fetch_date = last_fetched_dt + timedelta(minutes=1)
logging.info(f"Fetched {len(fetched)} new klines, up to {last_fetched_dt}.")
if len(fetched) < 1000:
break
time.sleep(0.1)
# 4. If new data was found, append it to the CSV file.
if new_klines:
logging.info(f"Appending {len(new_klines)} new candles to {HISTORY_CSV_FILE}.")
try:
with open(HISTORY_CSV_FILE, 'a', newline='') as f:
writer = csv.writer(f)
for kline in new_klines:
open_time_dt = datetime.fromtimestamp(kline[0] / 1000, tz=timezone.utc)
open_time_str = open_time_dt.strftime('%Y-%m-%d %H:%M:%S')
close_time_dt = datetime.fromtimestamp(kline[6] / 1000, tz=timezone.utc)
close_time_str = close_time_dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
writer.writerow([open_time_str] + kline[1:6] + [close_time_str] + kline[7:])
except Exception as e:
logging.error(f"Failed to append new data to {HISTORY_CSV_FILE}: {e}")
# 5. OPTIMIZED: Read the CSV and load only the necessary data (2025 onwards) for the frontend.
logging.info("Reading CSV to populate cache with data from 01.01.2025 onwards...")
frontend_klines = []
frontend_start_dt = datetime(2025, 1, 1, tzinfo=timezone.utc)
try:
with open(HISTORY_CSV_FILE, 'r', newline='') as f:
reader = csv.reader(f)
next(reader) # Skip header
for row in reader:
try:
dt_obj = datetime.strptime(row[0], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
if dt_obj >= frontend_start_dt:
timestamp_ms = int(dt_obj.timestamp() * 1000)
frontend_klines.append([
timestamp_ms, row[1], row[2], row[3], row[4],
"0", "0", "0", "0", "0", "0"
])
except (ValueError, IndexError):
continue
historical_data_cache = frontend_klines
logging.info(f"--- Data initialization complete. {len(historical_data_cache)} candles cached for frontend. ---")
except Exception as e: except Exception as e:
logging.error(f"Failed to read CSV for frontend cache: {e}") logging.error(f"Error in stream_historical_data for SID={sid}: {e}", exc_info=True)
historical_data_cache = [] 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 +96,27 @@ 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
logging.info(f"Candle closed at {current_bar['close']}. Emitting 'candle_closed' event.")
socketio.emit('candle_closed', 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 +128,12 @@ def binance_listener_thread():
def handle_connect(): def handle_connect():
global app_initialized global app_initialized
logging.info(f"Client connected: IP={request.remote_addr}, SID={request.sid}") logging.info(f"Client connected: IP={request.remote_addr}, SID={request.sid}")
with app_init_lock: with app_init_lock:
if not app_initialized: if not app_initialized:
logging.info("--- First client connected, initializing application data ---") logging.info("--- Initializing Application ---")
socketio.start_background_task(load_and_update_data)
socketio.start_background_task(binance_listener_thread) socketio.start_background_task(binance_listener_thread)
app_initialized = True app_initialized = True
socketio.start_background_task(target=stream_historical_data, sid=request.sid)
# Wait until the cache is populated.
while not historical_data_cache:
logging.info(f"SID={request.sid} is waiting for historical data cache...")
socketio.sleep(1)
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)
@socketio.on('analyze_chart')
def handle_analyze_chart(data):
sid = request.sid
logging.info(f"Received 'analyze_chart' request from frontend (SID={sid})")
recent_data = data[-100:]
prompt_data = "\n".join([f"Time: {c['time']}, Open: {c['open']}, High: {c['high']}, Low: {c['low']}, Close: {c['close']}" for c in recent_data])
prompt = (f"You are a financial analyst. Based on the following recent candlestick data for {SYMBOL}, provide a brief technical analysis (3-4 sentences). Mention the current trend and any potential short-term support or resistance levels.\n\nData:\n{prompt_data}")
socketio.emit('analysis_result', {'analysis': "AI analysis is currently unavailable."}, to=sid)
# --- Flask Routes --- # --- Flask Routes ---
@app.route('/') @app.route('/')

1
historical_data_1m.json Normal file

File diff suppressed because one or more lines are too long

98
static/hts.js Normal file
View File

@ -0,0 +1,98 @@
/**
* HTS Indicator Definition
* Allows selecting average type and fast interval.
*/
const HTS_INDICATOR = {
name: 'HTS',
label: 'HTS',
usesBaseData: false,
params: [
{ name: 'avgType', type: 'select', defaultValue: 'VWMA', options: ['RMA', 'EMA', 'SMA', 'WMA', 'VWMA'], label: 'Average Type' },
{ name: 'fast', type: 'number', defaultValue: 33, min: 1, label: 'Fast Interval' }
],
calculateFull: function(data, params) {
// determine data and period based on autoTF
let source = data;
const period = params.fast;
if (params.autoTF) {
// current timeframe in seconds between candles
const tfSec = (data.length > 1) ? (data[1].time - data[0].time) : 60;
const tfMin = tfSec / 60;
const autoInterval = Math.max(1, Math.floor(tfMin / 4));
// aggregate displayed data at autoInterval
source = aggregateCandles(data, autoInterval);
} else {
source = data;
}
const type = params.avgType;
if (!source || source.length < period) return { max: [], min: [] };
const maxSeries = [];
const minSeries = [];
let sumH, sumL, prevH, prevL, mult;
switch (type) {
case 'SMA':
sumH = 0; sumL = 0;
for (let i = 0; i < period; i++) { sumH += source[i].high; sumL += source[i].low; }
maxSeries.push({ time: source[period - 1].time, value: sumH / period });
minSeries.push({ time: source[period - 1].time, value: sumL / period });
for (let i = period; i < source.length; i++) {
sumH += source[i].high - source[i - period].high;
sumL += source[i].low - source[i - period].low;
maxSeries.push({ time: source[i].time, value: sumH / period });
minSeries.push({ time: source[i].time, value: sumL / period });
}
break;
case 'EMA':
case 'VWMA':
mult = 2 / (period + 1);
// initialize
sumH = 0; sumL = 0;
for (let i = 0; i < period; i++) { sumH += source[i].high; sumL += source[i].low; }
prevH = sumH / period; prevL = sumL / period;
maxSeries.push({ time: source[period - 1].time, value: prevH });
minSeries.push({ time: source[period - 1].time, value: prevL });
for (let i = period; i < source.length; i++) {
const h = source[i].high, l = source[i].low;
prevH = (h - prevH) * mult + prevH;
prevL = (l - prevL) * mult + prevL;
maxSeries.push({ time: source[i].time, value: prevH });
minSeries.push({ time: source[i].time, value: prevL });
}
break;
case 'RMA':
// Wilder's smoothing
sumH = 0; sumL = 0;
for (let i = 0; i < period; i++) { sumH += source[i].high; sumL += source[i].low; }
prevH = sumH / period; prevL = sumL / period;
maxSeries.push({ time: source[period - 1].time, value: prevH });
minSeries.push({ time: source[period - 1].time, value: prevL });
for (let i = period; i < source.length; i++) {
const h = source[i].high, l = source[i].low;
prevH = prevH + (h - prevH) / period;
prevL = prevL + (l - prevL) / period;
maxSeries.push({ time: source[i].time, value: prevH });
minSeries.push({ time: source[i].time, value: prevL });
}
break;
case 'WMA':
const denom = period * (period + 1) / 2;
for (let i = period - 1; i < source.length; i++) {
let wSumH = 0, wSumL = 0;
for (let j = 0; j < period; j++) {
const w = period - j;
wSumH += source[i - j].high * w;
wSumL += source[i - j].low * w;
}
maxSeries.push({ time: source[i].time, value: wSumH / denom });
minSeries.push({ time: source[i].time, value: wSumL / denom });
}
break;
default:
source.forEach(d => {
maxSeries.push({ time: d.time, value: d.high });
minSeries.push({ time: d.time, value: d.low });
});
}
return { max: maxSeries, min: minSeries };
}
};

223
static/hurst.js Normal file
View File

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

View File

@ -1,32 +1,32 @@
/** /**
* Creates and manages all indicator-related logic for the chart. * Creates and manages all indicator-related logic for the chart.
* @param {Object} chart - The Lightweight Charts instance. * @param {Object} chart - The Lightweight Charts instance.
* @param {Array<Object>} baseCandleData - A reference to the array holding the chart's BASE 1m candle data. * @param {Array<Object>} baseCandleDataRef - A reference to the array holding the chart's BASE 1m candle data.
* @param {Array<Object>} displayedCandleDataRef - A reference to the array with currently visible candles.
* @returns {Object} A manager object with public methods to control indicators. * @returns {Object} A manager object with public methods to control indicators.
*/ */
function createIndicatorManager(chart, baseCandleData) { function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef) {
// This holds the candle data currently displayed on the chart (e.g., 5m, 10m) // --- FIX: --- Added `debounceTimerId` to each slot object to track pending updates.
let currentAggregatedData = [];
// Defines the 4 slots available in the UI for indicators.
const indicatorSlots = [ const indicatorSlots = [
{ id: 1, cellId: 'indicator-cell-1', series: [], definition: null, params: {} }, { id: 1, cellId: 'indicator-cell-1', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null },
{ id: 2, cellId: 'indicator-cell-2', series: [], definition: null, params: {} }, { id: 2, cellId: 'indicator-cell-2', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null },
{ id: 3, cellId: 'indicator-cell-3', series: [], definition: null, params: {} }, { id: 3, cellId: 'indicator-cell-3', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null },
{ id: 4, cellId: 'indicator-cell-4', series: [], definition: null, params: {} }, { id: 4, cellId: 'indicator-cell-4', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null },
]; ];
// Pre-defined colors for the indicator lines.
const colors = { const colors = {
bb1: { upper: '#FF9800', lower: '#FF9800' }, // Orange bb: {
bb2: { upper: '#2196F3', lower: '#2196F3' }, // Blue bb1_upper: 'rgba(128, 25, 34, 0.5)',
bb3: { upper: '#9C27B0', lower: '#9C27B0' }, // Purple bb2_upper: 'rgba(128, 25, 34, 0.75)',
default: ['#FF5722', '#03A9F4', '#8BC34A', '#F44336'] // Fallback colors for other indicators bb3_upper: 'rgba(128, 25, 34, 1)',
bb1_lower: 'rgba(6, 95, 6, 0.5)',
bb2_lower: 'rgba(6, 95, 6, 0.75)',
bb3_lower: 'rgba(6, 95, 6, 1.0)',
},
hurst: { topBand: '#787b86', bottomBand: '#787b86', topBand_h: '#673ab7', bottomBand_h: '#673ab7' },
default: ['#00BCD4', '#FFEB3B', '#4CAF50', '#E91E63']
}; };
/**
* Populates the dropdown menus in each indicator cell.
*/
function populateDropdowns() { function populateDropdowns() {
indicatorSlots.forEach(slot => { indicatorSlots.forEach(slot => {
const cell = document.getElementById(slot.cellId); const cell = document.getElementById(slot.cellId);
@ -41,32 +41,30 @@ function createIndicatorManager(chart, baseCandleData) {
const controlsContainer = document.createElement('div'); const controlsContainer = document.createElement('div');
controlsContainer.className = 'indicator-controls'; controlsContainer.className = 'indicator-controls';
cell.innerHTML = ''; // Clear previous content cell.innerHTML = '';
cell.appendChild(select); cell.appendChild(select);
cell.appendChild(controlsContainer); cell.appendChild(controlsContainer);
select.addEventListener('change', (e) => { select.addEventListener('change', (e) => loadIndicator(slot.id, e.target.value));
const indicatorName = e.target.value;
loadIndicator(slot.id, indicatorName);
});
}); });
} }
/**
* Loads a new indicator into a specified slot.
* @param {number} slotId - The ID of the slot (1-4).
* @param {string} indicatorName - The name of the indicator to load (e.g., 'SMA').
*/
function loadIndicator(slotId, indicatorName) { function loadIndicator(slotId, indicatorName) {
const slot = indicatorSlots.find(s => s.id === slotId); const slot = indicatorSlots.find(s => s.id === slotId);
if (!slot) return; if (!slot) return;
// Clean up any previous indicator series in this slot // --- FIX: --- Cancel any pending debounced update from the previous indicator's controls.
// This is the core of the fix, preventing the race condition.
if (slot.debounceTimerId) {
clearTimeout(slot.debounceTimerId);
slot.debounceTimerId = null;
}
slot.series.forEach(s => chart.removeSeries(s)); slot.series.forEach(s => chart.removeSeries(s));
slot.series = []; slot.series = [];
slot.definition = null; slot.definition = null;
slot.params = {}; slot.params = {};
slot.calculator = null;
const controlsContainer = document.querySelector(`#${slot.cellId} .indicator-controls`); const controlsContainer = document.querySelector(`#${slot.cellId} .indicator-controls`);
controlsContainer.innerHTML = ''; controlsContainer.innerHTML = '';
@ -77,28 +75,72 @@ function createIndicatorManager(chart, baseCandleData) {
if (!definition) return; if (!definition) return;
slot.definition = definition; slot.definition = definition;
// Special case for HTS: hide avgType/fast controls, show only Auto TF checkbox
// Create UI controls for the indicator's parameters if (indicatorName === 'HTS') {
const label = document.createElement('label');
label.textContent = 'Auto TF';
label.style.fontSize = '12px';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'input-field';
// initialize params
// default HTS params
slot.params.autoTF = false;
slot.params.avgType = definition.params.find(p => p.name === 'avgType').defaultValue;
slot.params.fast = definition.params.find(p => p.name === 'fast').defaultValue;
checkbox.addEventListener('change', () => {
slot.params.autoTF = checkbox.checked;
updateIndicator(slot.id, true);
});
const controlGroup = document.createElement('div');
controlGroup.style.display = 'flex';
controlGroup.style.flexDirection = 'column';
controlGroup.appendChild(label);
controlGroup.appendChild(checkbox);
controlsContainer.appendChild(controlGroup);
// initial draw
updateIndicator(slot.id, true);
return;
}
// Default controls for other indicators
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'); // Create select for dropdowns, input for numbers
let input;
if (param.type === 'select') {
input = document.createElement('select');
input.className = 'input-field';
// populate options
param.options.forEach(opt => {
const option = document.createElement('option');
option.value = opt;
option.textContent = opt;
if (opt === param.defaultValue) option.selected = true;
input.appendChild(option);
});
slot.params[param.name] = input.value;
} else {
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] = parseFloat(input.value);
slot.params[param.name] = input.type === 'number' ? parseFloat(input.value) : input.value; }
let debounceTimer;
input.addEventListener('input', () => { input.addEventListener('input', () => {
clearTimeout(debounceTimer); // debounce param changes
debounceTimer = setTimeout(() => { clearTimeout(slot.debounceTimerId);
slot.params[param.name] = input.type === 'number' ? parseFloat(input.value) : input.value; slot.debounceTimerId = setTimeout(() => {
updateIndicator(slot.id); // update param value
const val = input.value;
slot.params[param.name] = (param.type === 'number') ? parseFloat(val) : val;
updateIndicator(slot.id, true);
slot.debounceTimerId = null;
}, 500); }, 500);
}); });
const controlGroup = document.createElement('div'); const controlGroup = document.createElement('div');
@ -109,91 +151,116 @@ 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; // for HTS autoTF, always use base data for aggregation; else follow definition flag
let candleDataForCalc;
if (!slot || !slot.definition || candleDataForCalc.length === 0) { if (slot.definition.name === 'HTS' && slot.params.autoTF) {
return; candleDataForCalc = baseCandleDataRef;
} else {
candleDataForCalc = (slot.definition.usesBaseData) ? baseCandleDataRef : displayedCandleDataRef;
} }
if (candleDataForCalc.length === 0) return;
// Clean up previous series before creating new ones
if (isFullRecalculation) {
slot.series.forEach(s => chart.removeSeries(s)); slot.series.forEach(s => chart.removeSeries(s));
slot.series = []; slot.series = [];
console.log(`Recalculating ${slot.definition.name} for slot ${slot.id} on ${candleDataForCalc.length} candles.`);
const indicatorResult = slot.definition.calculateFull(candleDataForCalc, slot.params); const indicatorResult = slot.definition.calculateFull(candleDataForCalc, slot.params);
// Handle multi-line indicators like Bollinger Bands
if (typeof indicatorResult === 'object' && !Array.isArray(indicatorResult)) { if (typeof indicatorResult === 'object' && !Array.isArray(indicatorResult)) {
Object.keys(indicatorResult).forEach(key => { Object.keys(indicatorResult).forEach(key => {
const seriesData = indicatorResult[key]; const seriesData = indicatorResult[key];
const bandName = key.split('_')[0]; const indicatorNameLower = slot.definition.name.toLowerCase();
const bandType = key.split('_')[1];
const series = chart.addLineSeries({ const series = chart.addLineSeries({
color: colors[bandName] ? colors[bandName][bandType] : colors.default[slot.id - 1], color: (colors[indicatorNameLower] && colors[indicatorNameLower][key]) ? colors[indicatorNameLower][key] : colors.default[slot.id - 1],
lineWidth: 2, lineWidth: 1,
title: `${slot.definition.label} - ${key}`, title: '',
lastValueVisible: false, lastValueVisible: false,
priceLineVisible: false, priceLineVisible: false,
}); });
series.setData(seriesData); series.setData(seriesData);
slot.series.push(series); slot.series.push(series);
}); });
} else { // Handle single-line indicators like SMA/EMA } else {
const series = chart.addLineSeries({ const series = chart.addLineSeries({
color: colors.default[slot.id - 1], color: colors.default[slot.id - 1],
lineWidth: 2, lineWidth: 1,
title: slot.definition.label, title: '',
lastValueVisible: false,
priceLineVisible: false,
}); });
series.setData(indicatorResult); series.setData(indicatorResult);
slot.series.push(series); slot.series.push(series);
} }
if (slot.definition.createRealtime) {
slot.calculator = slot.definition.createRealtime(slot.params);
slot.calculator.prime(candleDataForCalc);
}
} else if (slot.calculator) {
const lastCandle = candleDataForCalc[candleDataForCalc.length - 1];
if (!lastCandle) return;
const newPoint = slot.calculator.update(lastCandle);
if (newPoint && typeof newPoint === 'object') {
if (slot.series.length > 1) { // Multi-line indicator
Object.keys(newPoint).forEach((key, index) => {
if (slot.series[index] && newPoint[key]) {
slot.series[index].update(newPoint[key]);
}
});
} else if (slot.series.length === 1) { // Single-line indicator
slot.series[0].update(newPoint);
}
}
}
} }
/** function recalculateAllAfterHistory(baseData, displayedData) {
* Internal function to recalculate all active indicators. baseCandleDataRef = baseData;
*/ displayedCandleDataRef = displayedData;
function recalculateAllIndicators() {
// --- FIX: --- Clear any pending debounced updates from parameter changes.
// This prevents a stale update from a parameter input from running after
// the chart has already been reset for a new timeframe.
indicatorSlots.forEach(slot => {
if (slot.debounceTimerId) {
clearTimeout(slot.debounceTimerId);
slot.debounceTimerId = null;
}
});
// --- FIX: --- Defer the full recalculation to the next frame.
// This prevents a race condition where indicators are removed/added while the chart
// is still processing the main series' `setData` operation from a timeframe change.
setTimeout(() => {
indicatorSlots.forEach(slot => { indicatorSlots.forEach(slot => {
if (slot.definition) { if (slot.definition) {
updateIndicator(slot.id); updateIndicator(slot.id, true);
}
});
}, 0);
}
function updateAllOnNewCandle() {
indicatorSlots.forEach(slot => {
if (slot.definition) {
updateIndicator(slot.id, false);
} }
}); });
} }
/**
* Sets the candle data for indicators and triggers a full recalculation.
* @param {Array<Object>} aggregatedCandleData - The candle data for the currently selected timeframe.
*/
function recalculateAllAfterHistory(aggregatedCandleData) {
currentAggregatedData = aggregatedCandleData;
recalculateAllIndicators();
}
/**
* Updates all indicators in response to a new candle closing.
* @param {Array<Object>} aggregatedCandleData - The latest candle data for the currently selected timeframe.
*/
function updateIndicatorsOnNewCandle(aggregatedCandleData) {
currentAggregatedData = aggregatedCandleData;
recalculateAllIndicators();
}
// Public API for the manager
return { return {
populateDropdowns, populateDropdowns,
recalculateAllAfterHistory, recalculateAllAfterHistory,
updateIndicatorsOnNewCandle updateAllOnNewCandle,
}; };
} }

View File

@ -9,5 +9,7 @@ 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
HTS_INDICATOR // Added the new HTS indicator
// Add other indicators here as needed
]; ];

View File

@ -5,11 +5,14 @@
<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='hts.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 +21,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 +39,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 +58,56 @@
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 { select.input-field { width: 100px; }
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 +115,23 @@
<h1 id="chart-title">{{ symbol }} Chart</h1> <h1 id="chart-title">{{ symbol }} Chart</h1>
</div> </div>
<div id="chart-wrapper">
<div id="chart"></div> <div id="chart"></div>
<div id="measure-tool" style="display: none;">
<div id="measure-box"></div>
<svg id="measure-svg"></svg>
<div id="measure-tooltip"></div>
</div>
</div>
<div class="control-panel"> <div class="control-panel">
<div class="control-cell"> <div class="control-cell">
<h3>Candle Closes In</h3> <h3>Candle Closes In</h3>
<div id="candle-timer">--:--</div> <div id="candle-timer">--:--</div>
<select id="timeframe-select"> <div id="timeframe-display" class="action-button">1m</div>
<option value="1">1m</option> <div id="progress-container" class="progress-bar-container">
<option value="2">2m</option> <div class="progress-bar"></div>
<option value="3">3m</option> </div>
<option value="4">4m</option>
<option value="5">5m</option>
<option value="6">6m</option>
<option value="7">7m</option>
<option value="8">8m</option>
<option value="9">9m</option>
<option value="10">10m</option>
</select>
</div> </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 +139,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 +156,408 @@
width: chartElement.clientWidth, height: 500, width: chartElement.clientWidth, height: 500,
layout: { background: { type: 'solid', color: '#1E222D' }, textColor: '#D1D4DC' }, layout: { background: { type: 'solid', color: '#1E222D' }, textColor: '#D1D4DC' },
grid: { vertLines: { color: '#2A2E39' }, horzLines: { color: '#2A2E39' } }, grid: { vertLines: { color: '#2A2E39' }, horzLines: { color: '#2A2E39' } },
timeScale: { timeVisible: true, secondsVisible: true } timeScale: { timeVisible: true, secondsVisible: true },
crosshair: {
mode: LightweightCharts.CrosshairMode.Normal,
},
}); });
const candlestickSeries = chart.addCandlestickSeries({ const candlestickSeries = chart.addCandlestickSeries({
upColor: '#26a69a', downColor: '#ef5350', borderDownColor: '#ef5350', upColor: 'rgba(255, 152, 0, 1.0)', downColor: 'rgba(255, 152, 0, 0.66)', borderDownColor: 'rgba(255, 152, 0, 0.66)',
borderUpColor: '#26a69a', wickDownColor: '#ef5350', wickUpColor: '#26a69a', borderUpColor: 'rgba(255, 152, 0, 1.0)', wickDownColor: 'rgba(255, 152, 0, 0.66)', wickUpColor: 'rgba(255, 152, 0, 1.0)'
}); });
const originalAddLineSeries = chart.addLineSeries;
chart.addLineSeries = function(options) {
const newOptions = { ...options, crosshairMarkerVisible: false, };
return originalAddLineSeries.call(this, newOptions);
};
let baseCandleData1m = []; let baseCandleData1m = [];
let displayedCandleData = []; let displayedCandleData = [];
let currentCandle1m = null;
let manager; let manager;
let currentTimeframeMinutes = 1;
const timeframeSelect = document.getElementById('timeframe-select');
const candleTimerDiv = document.getElementById('candle-timer'); const candleTimerDiv = document.getElementById('candle-timer');
const chartTitle = document.getElementById('chart-title'); const chartTitle = document.getElementById('chart-title');
const analyzeButton = document.getElementById('analyzeButton'); const progressContainer = document.getElementById('progress-container');
const analysisResultDiv = document.getElementById('analysisResult'); const progressBar = document.querySelector('.progress-bar');
manager = createIndicatorManager(chart, baseCandleData1m); const measureToolEl = document.getElementById('measure-tool');
const measureBoxEl = document.getElementById('measure-box');
const measureSvgEl = document.getElementById('measure-svg');
const measureTooltipEl = document.getElementById('measure-tooltip');
let measureState = { active: false, finished: false, startPoint: null, endPoint: null };
let isRedrawScheduled = false;
const timeframeDisplay = document.getElementById('timeframe-display');
const modalOverlay = document.getElementById('timeframe-modal-overlay');
const modalInput = document.getElementById('timeframe-input');
const modalPreviewText = document.getElementById('timeframe-preview-text');
const modalConfirmBtn = document.getElementById('timeframe-confirm-btn');
function openModal(initialValue = '') {
modalOverlay.style.display = 'flex';
modalInput.value = initialValue;
updatePreviewText();
modalInput.focus();
modalInput.select();
}
function closeModal() {
modalOverlay.style.display = 'none';
}
function updatePreviewText() {
const value = modalInput.value;
if (value && parseInt(value) > 0) {
modalPreviewText.textContent = `${value} minute${parseInt(value) > 1 ? 's' : ''}`;
} else {
modalPreviewText.textContent = '';
}
}
function confirmTimeframe() {
const newTimeframe = parseInt(modalInput.value);
if (newTimeframe && newTimeframe > 0) {
currentTimeframeMinutes = newTimeframe;
timeframeDisplay.textContent = `${newTimeframe}m`;
updateChartForTimeframe(true);
closeModal();
}
}
timeframeDisplay.addEventListener('click', () => openModal(currentTimeframeMinutes));
modalConfirmBtn.addEventListener('click', confirmTimeframe);
modalInput.addEventListener('input', updatePreviewText);
modalInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') confirmTimeframe(); });
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); });
window.addEventListener('keydown', (e) => {
if (modalOverlay.style.display === 'flex' && e.key === 'Escape') {
closeModal();
}
else if (modalOverlay.style.display !== 'flex' && !isNaN(parseInt(e.key)) && !e.ctrlKey && !e.metaKey) {
const activeEl = document.activeElement;
if (activeEl.tagName !== 'INPUT' && activeEl.tagName !== 'TEXTAREA') {
e.preventDefault();
openModal(e.key);
}
}
});
manager = createIndicatorManager(chart, baseCandleData1m, displayedCandleData);
manager.populateDropdowns(); manager.populateDropdowns();
const socket = io(); const socket = io();
socket.on('connect', () => console.log('Socket.IO connected.')); socket.on('connect', () => console.log('Socket.IO connected.'));
socket.on('history_progress', (data) => {
if (data && data.progress) progressBar.style.width = `${data.progress}%`;
});
socket.on('history_finished', (data) => { socket.on('history_finished', (data) => {
if (!data || !data.klines_1m) return; if (!data || !data.klines_1m) return;
progressBar.style.width = '100%';
const mappedKlines = data.klines_1m.map(k => ({ baseCandleData1m = data.klines_1m.map(k => ({
time: k[0] / 1000, open: parseFloat(k[1]), high: parseFloat(k[2]), time: k[0] / 1000, open: parseFloat(k[1]), high: parseFloat(k[2]),
low: parseFloat(k[3]), close: parseFloat(k[4]) low: parseFloat(k[3]), close: parseFloat(k[4])
})); }));
updateChartForTimeframe(true);
baseCandleData1m.length = 0; setTimeout(() => { progressContainer.style.display = 'none'; }, 500);
for (const kline of mappedKlines) {
baseCandleData1m.push(kline);
}
updateChartForTimeframe();
}); });
socket.on('trade', (trade) => { // --- MODIFICATION START: Rewritten candle update and creation logic ---
const price = parseFloat(trade.p); function handleLiveUpdate(update) {
const tradeTime = Math.floor(trade.T / 1000); if (baseCandleData1m.length === 0 || displayedCandleData.length === 0) return;
const candleTimestamp1m = tradeTime - (tradeTime % 60);
if (!currentCandle1m || candleTimestamp1m > currentCandle1m.time) { // First, ensure the base 1m data is up-to-date.
if (currentCandle1m) baseCandleData1m.push(currentCandle1m); const lastBaseCandle = baseCandleData1m[baseCandleData1m.length - 1];
currentCandle1m = { time: candleTimestamp1m, open: price, high: price, low: price, close: price }; 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];
// Check if the update belongs to the currently forming displayed candle
if (update.time >= lastDisplayedCandle.time && update.time < lastDisplayedCandle.time + candleDurationSeconds) {
// 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 (update.time >= lastDisplayedCandle.time + candleDurationSeconds) {
// This update is for a NEW candle.
const newCandleTime = Math.floor(update.time / candleDurationSeconds) * candleDurationSeconds;
// Create the new candle. Its O,H,L,C are all from this first tick.
const newCandle = {
time: newCandleTime,
open: update.open,
high: update.high,
low: update.low,
close: update.close,
};
displayedCandleData.push(newCandle);
candlestickSeries.update(newCandle);
// Since a new candle has been formed, we should recalculate indicators
manager.recalculateAllAfterHistory(baseCandleData1m, displayedCandleData);
}
}
let latestCandleUpdate = null;
let isUpdateScheduled = false;
function processLatestUpdate() {
if (latestCandleUpdate) {
handleLiveUpdate(latestCandleUpdate);
latestCandleUpdate = null;
}
isUpdateScheduled = false;
}
socket.on('candle_update', (update) => {
latestCandleUpdate = update;
if (!isUpdateScheduled) {
isUpdateScheduled = true;
requestAnimationFrame(processLatestUpdate);
}
});
socket.on('candle_closed', (closedCandle) => {
// This handler's primary job is to ensure data integrity by using the final, closed 1m candle.
// 1. Update the master 1-minute data array with the final version of the candle.
const candleIndex = baseCandleData1m.findIndex(c => c.time === closedCandle.time);
if (candleIndex !== -1) {
baseCandleData1m[candleIndex] = closedCandle;
} else { } else {
currentCandle1m.high = Math.max(currentCandle1m.high, price); // This case might happen if connection was lost and we missed updates for this candle
currentCandle1m.low = Math.min(currentCandle1m.low, price); baseCandleData1m.push(closedCandle);
currentCandle1m.close = price; baseCandleData1m.sort((a, b) => a.time - b.time); // Keep it sorted just in case
} }
const selectedInterval = parseInt(timeframeSelect.value, 10) * 60; if (displayedCandleData.length === 0) return;
const displayedCandleTimestamp = tradeTime - (tradeTime % selectedInterval);
const lastDisplayedCandle = displayedCandleData[displayedCandleData.length - 1];
let candleForUpdate; // 2. Determine which displayed candle this closed 1m candle belongs to.
if (lastDisplayedCandle && displayedCandleTimestamp === lastDisplayedCandle.time) { const candleDurationSeconds = currentTimeframeMinutes * 60;
candleForUpdate = { ...lastDisplayedCandle }; const bucketTime = Math.floor(closedCandle.time / candleDurationSeconds) * candleDurationSeconds;
candleForUpdate.high = Math.max(candleForUpdate.high, price);
candleForUpdate.low = Math.min(candleForUpdate.low, price);
candleForUpdate.close = price;
displayedCandleData[displayedCandleData.length - 1] = candleForUpdate;
} else if (!lastDisplayedCandle || displayedCandleTimestamp > lastDisplayedCandle.time) {
candleForUpdate = { time: displayedCandleTimestamp, open: price, high: price, low: price, close: price };
displayedCandleData.push(candleForUpdate);
// A new candle has started, so update the indicators. // 3. Find the displayed candle that needs to be corrected with final data.
manager.updateIndicatorsOnNewCandle(displayedCandleData); const displayedCandleToUpdate = displayedCandleData.find(c => c.time === bucketTime);
if (!displayedCandleToUpdate) {
console.warn("Could not find a displayed candle to update for closed 1m candle at", new Date(closedCandle.time * 1000).toISOString());
// As a fallback, a full redraw can fix inconsistencies.
// updateChartForTimeframe(true);
return;
} }
if (candleForUpdate) candlestickSeries.update(candleForUpdate); // 4. Find all 1m source candles for this bucket.
const sourceCandles = baseCandleData1m.filter(c =>
c.time >= bucketTime && c.time < bucketTime + candleDurationSeconds
);
// 5. If we have source candles, aggregate them to get the CORRECT final data.
if (sourceCandles.length > 0) {
const finalCandle = {
time: bucketTime,
open: sourceCandles[0].open,
high: Math.max(...sourceCandles.map(c => c.high)),
low: Math.min(...sourceCandles.map(c => c.low)),
close: sourceCandles[sourceCandles.length - 1].close
};
// 6. Update the specific candle in the displayed data array
const displayedIndex = displayedCandleData.findIndex(c => c.time === bucketTime);
if (displayedIndex !== -1) {
displayedCandleData[displayedIndex] = finalCandle;
}
// 7. Update the series on the chart and recalculate indicators for accuracy.
candlestickSeries.update(finalCandle);
manager.recalculateAllAfterHistory(baseCandleData1m, displayedCandleData);
}
}); });
// --- MODIFICATION END ---
function updateChartForTimeframe() { function updateChartForTimeframe(isFullReset = false) {
const selectedIntervalMinutes = parseInt(timeframeSelect.value, 10);
if (baseCandleData1m.length === 0) return; if (baseCandleData1m.length === 0) return;
const visibleTimeRange = isFullReset ? null : chart.timeScale().getVisibleTimeRange();
const newCandleData = aggregateCandles(baseCandleData1m, selectedIntervalMinutes); const newCandleData = aggregateCandles(baseCandleData1m, currentTimeframeMinutes);
if (newCandleData.length > 0) { if (newCandleData.length > 0) {
displayedCandleData = newCandleData; displayedCandleData = newCandleData;
candlestickSeries.setData(displayedCandleData); candlestickSeries.setData(displayedCandleData);
chartTitle.textContent = `{{ symbol }} Chart (${selectedIntervalMinutes}m)`; chartTitle.textContent = `{{ symbol }} Chart (${currentTimeframeMinutes}m)`;
manager.recalculateAllAfterHistory(displayedCandleData); manager.recalculateAllAfterHistory(baseCandleData1m, displayedCandleData);
if (visibleTimeRange) {
chart.timeScale().setVisibleRange(visibleTimeRange);
} else {
chart.timeScale().fitContent(); chart.timeScale().fitContent();
} }
} }
}
timeframeSelect.addEventListener('change', updateChartForTimeframe); function clearMeasureTool() {
measureState = { active: false, finished: false, startPoint: null, endPoint: null };
measureToolEl.style.display = 'none';
measureSvgEl.innerHTML = '';
}
function formatDuration(seconds) {
const d = Math.floor(seconds / (3600*24));
const h = Math.floor(seconds % (3600*24) / 3600);
const m = Math.floor(seconds % 3600 / 60);
let result = '';
if (d > 0) result += `${d}d `;
if (h > 0) result += `${h}h `;
if (m > 0) result += `${m}m`;
return result.trim() || '0m';
}
function drawMeasureTool() {
if (!measureState.startPoint || !measureState.endPoint) return;
const startCoord = {
x: chart.timeScale().timeToCoordinate(measureState.startPoint.time),
y: candlestickSeries.priceToCoordinate(measureState.startPoint.price)
};
const endCoord = {
x: chart.timeScale().timeToCoordinate(measureState.endPoint.time),
y: candlestickSeries.priceToCoordinate(measureState.endPoint.price)
};
if (startCoord.x === null || startCoord.y === null || endCoord.x === null || endCoord.y === null) {
measureToolEl.style.display = 'none';
return;
}
measureToolEl.style.display = 'block';
const isUp = measureState.endPoint.price >= measureState.startPoint.price;
const boxColor = isUp ? 'var(--measure-tool-up-bg)' : 'var(--measure-tool-down-bg)';
const borderColor = isUp ? 'var(--measure-tool-up-border)' : 'var(--measure-tool-down-border)';
measureBoxEl.style.backgroundColor = boxColor;
measureBoxEl.style.borderColor = borderColor;
measureTooltipEl.style.backgroundColor = borderColor;
const minX = Math.min(startCoord.x, endCoord.x);
const maxX = Math.max(startCoord.x, endCoord.x);
const minY = Math.min(startCoord.y, endCoord.y);
const maxY = Math.max(startCoord.y, endCoord.y);
measureBoxEl.style.left = `${minX}px`;
measureBoxEl.style.top = `${minY}px`;
measureBoxEl.style.width = `${maxX - minX}px`;
measureBoxEl.style.height = `${maxY - minY}px`;
const midX = minX + (maxX - minX) / 2;
const midY = minY + (maxY - minY) / 2;
const arrowSize = 5;
const isTimeGoingForward = measureState.endPoint.time >= measureState.startPoint.time;
let hArrowPoints = isTimeGoingForward
? `${maxX - arrowSize},${midY - arrowSize} ${maxX},${midY} ${maxX - arrowSize},${midY + arrowSize}`
: `${minX + arrowSize},${midY - arrowSize} ${minX},${midY} ${minX + arrowSize},${midY + arrowSize}`;
let vArrowPoints = isUp
? `${midX - arrowSize},${minY + arrowSize} ${midX},${minY} ${midX + arrowSize},${minY + arrowSize}`
: `${midX - arrowSize},${maxY - arrowSize} ${midX},${maxY} ${midX + arrowSize},${maxY - arrowSize}`;
measureSvgEl.innerHTML = `
<line x1="${minX}" y1="${midY}" x2="${maxX}" y2="${midY}" stroke="${borderColor}" stroke-width="1"/>
<polygon points="${hArrowPoints}" fill="${borderColor}" />
<line x1="${midX}" y1="${minY}" x2="${midX}" y2="${maxY}" stroke="${borderColor}" stroke-width="1"/>
<polygon points="${vArrowPoints}" fill="${borderColor}" />
`;
const priceChange = measureState.endPoint.price - measureState.startPoint.price;
const pctChange = (priceChange / measureState.startPoint.price) * 100;
const timeDifference = measureState.endPoint.time - measureState.startPoint.time;
const bars = Math.round(timeDifference / (currentTimeframeMinutes * 60));
const duration = formatDuration(Math.abs(timeDifference));
measureTooltipEl.innerHTML = `
<div>${priceChange.toFixed(2)} (${pctChange.toFixed(2)}%)</div>
<div>${bars} bars, ${duration}</div>
`;
measureTooltipEl.style.left = `${midX}px`;
const tooltipMargin = 8;
if (isUp) {
measureTooltipEl.style.top = `${minY}px`;
measureTooltipEl.style.transform = `translate(-50%, calc(-100% - ${tooltipMargin}px))`;
} else {
measureTooltipEl.style.top = `${maxY}px`;
measureTooltipEl.style.transform = `translate(-50%, ${tooltipMargin}px)`;
}
}
const onMeasureMove = (param) => {
if (!measureState.active || !param.point) return;
measureState.endPoint.time = chart.timeScale().coordinateToTime(param.point.x);
measureState.endPoint.price = candlestickSeries.coordinateToPrice(param.point.y);
if (!measureState.endPoint.time || !measureState.endPoint.price) return;
drawMeasureTool();
};
const onMeasureUp = () => {
measureState.active = false;
measureState.finished = true;
chart.unsubscribeCrosshairMove(onMeasureMove);
};
let priceScaleDragState = { isDragging: false };
function priceScaleRedrawLoop() {
if (!priceScaleDragState.isDragging) return;
drawMeasureTool();
requestAnimationFrame(priceScaleRedrawLoop);
}
chartElement.addEventListener('mousedown', (e) => {
if (!measureState.finished) return;
const rect = chartElement.getBoundingClientRect();
const priceScaleWidth = chart.priceScale('right').width();
if (e.clientX > rect.left + rect.width - priceScaleWidth) {
priceScaleDragState.isDragging = true;
requestAnimationFrame(priceScaleRedrawLoop);
}
});
window.addEventListener('mouseup', () => {
if (priceScaleDragState.isDragging) {
priceScaleDragState.isDragging = false;
}
});
chart.timeScale().subscribeVisibleTimeRangeChange(() => {
if (measureState.finished) {
drawMeasureTool();
}
});
chart.subscribeCrosshairMove(() => {
if (measureState.finished && !isRedrawScheduled) {
isRedrawScheduled = true;
requestAnimationFrame(() => {
drawMeasureTool();
isRedrawScheduled = false;
});
}
});
chart.subscribeClick((param) => {
if (measureState.finished) {
clearMeasureTool();
return;
}
if (!param.point || !param.sourceEvent.shiftKey) return;
if (measureState.active) {
onMeasureUp();
return;
}
clearMeasureTool();
const time = chart.timeScale().coordinateToTime(param.point.x);
const price = candlestickSeries.coordinateToPrice(param.point.y);
if (!time || !price) return;
measureState.startPoint = { time, price };
measureState.endPoint = { time, price };
measureState.active = true;
measureToolEl.style.display = 'block';
drawMeasureTool();
chart.subscribeCrosshairMove(onMeasureMove);
});
setInterval(() => { setInterval(() => {
const selectedIntervalSeconds = parseInt(timeframeSelect.value, 10) * 60; const selectedIntervalSeconds = currentTimeframeMinutes * 60;
const now = new Date().getTime() / 1000; const now = new Date().getTime() / 1000;
const secondsRemaining = Math.floor(selectedIntervalSeconds - (now % selectedIntervalSeconds)); const secondsRemaining = Math.floor(selectedIntervalSeconds - (now % selectedIntervalSeconds));
const minutes = Math.floor(secondsRemaining / 60); const minutes = Math.floor(secondsRemaining / 60);