Compare commits

11 Commits

Author SHA1 Message Date
38cdffc2b9 hts with only two SMAs 2025-07-21 21:20:39 +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
10 changed files with 958 additions and 353 deletions

3
.gitignore vendored
View File

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

229
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. client = Client()
# 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.
if not os.path.exists(HISTORY_CSV_FILE):
logging.critical(f"CRITICAL: History file '{HISTORY_CSV_FILE}' not found. Please provide the CSV file. Halting data load.")
historical_data_cache = []
return
# 2. OPTIMIZED: Efficiently get the last timestamp to determine where to start fetching.
last_dt_in_csv = get_last_timestamp_from_csv(HISTORY_CSV_FILE)
start_fetch_date = None
if last_dt_in_csv:
start_fetch_date = last_dt_in_csv + timedelta(minutes=1)
logging.info(f"Last record in CSV is from {last_dt_in_csv}. Checking for new data since {start_fetch_date}.")
else:
logging.warning("Could not determine last timestamp from CSV. Assuming file is new or empty. No new data will be fetched.")
# 3. Fetch new data from Binance.
new_klines = []
if start_fetch_date and start_fetch_date < datetime.now(timezone.utc):
while True:
logging.info(f"Fetching new klines from {start_fetch_date}...")
fetched = client.get_historical_klines(SYMBOL, Client.KLINE_INTERVAL_1MINUTE, start_fetch_date.strftime("%Y-%m-%d %H:%M:%S"))
if not fetched:
logging.info("No new klines to fetch.")
break
new_klines.extend(fetched)
last_fetched_dt = datetime.fromtimestamp(fetched[-1][0] / 1000, tz=timezone.utc)
start_fetch_date = last_fetched_dt + timedelta(minutes=1)
logging.info(f"Fetched {len(fetched)} new klines, up to {last_fetched_dt}.")
if len(fetched) < 1000:
break
time.sleep(0.1)
# 4. If new data was found, append it to the CSV file.
if new_klines:
logging.info(f"Appending {len(new_klines)} new candles to {HISTORY_CSV_FILE}.")
try:
with open(HISTORY_CSV_FILE, 'a', newline='') as f:
writer = csv.writer(f)
for kline in new_klines:
open_time_dt = datetime.fromtimestamp(kline[0] / 1000, tz=timezone.utc)
open_time_str = open_time_dt.strftime('%Y-%m-%d %H:%M:%S')
close_time_dt = datetime.fromtimestamp(kline[6] / 1000, tz=timezone.utc)
close_time_str = close_time_dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
writer.writerow([open_time_str] + kline[1:6] + [close_time_str] + kline[7:])
except Exception as e:
logging.error(f"Failed to append new data to {HISTORY_CSV_FILE}: {e}")
# 5. OPTIMIZED: Read the CSV and load only the necessary data (2025 onwards) for the frontend.
logging.info("Reading CSV to populate cache with data from 01.01.2025 onwards...")
frontend_klines = []
frontend_start_dt = datetime(2025, 1, 1, tzinfo=timezone.utc)
try:
with open(HISTORY_CSV_FILE, 'r', newline='') as f:
reader = csv.reader(f)
next(reader) # Skip header
for row in reader:
try:
dt_obj = datetime.strptime(row[0], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
if dt_obj >= frontend_start_dt:
timestamp_ms = int(dt_obj.timestamp() * 1000)
frontend_klines.append([
timestamp_ms, row[1], row[2], row[3], row[4],
"0", "0", "0", "0", "0", "0"
])
except (ValueError, IndexError):
continue
historical_data_cache = frontend_klines # --- NEW SOLUTION: Load data for the last week ---
logging.info(f"--- Data initialization complete. {len(historical_data_cache)} candles cached for frontend. ---") logging.info(f"Fetching historical data for the last 7 days for SID={sid}")
# The `python-binance` library allows using relative date strings.
# This single call is more efficient for this use case.
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
)
# --- ORIGINAL SOLUTION COMMENTED OUT ---
# 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 ---
# The rest of the function processes the `all_klines` data as before
seen = set()
unique_klines = [kline for kline in sorted(all_klines, key=lambda x: x[0]) if tuple(kline) not in seen and not seen.add(tuple(kline))]
with open(HISTORY_FILE, 'w') as f:
json.dump(unique_klines, f)
logging.info(f"Finished data stream for SID={sid}. Sending final payload of {len(unique_klines)} klines.")
socketio.emit('history_finished', {'klines_1m': unique_klines}, to=sid)
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('/')
@ -218,4 +143,4 @@ def index():
# --- Main Application Execution --- # --- Main Application Execution ---
if __name__ == '__main__': if __name__ == '__main__':
logging.info("Starting Flask-SocketIO server...") logging.info("Starting Flask-SocketIO server...")
socketio.run(app, host='0.0.0.0', port=5000, allow_unsafe_werkzeug=True, debug=False) socketio.run(app, host='0.0.0.0', port=5000, allow_unsafe_werkzeug=True, debug=False)

1
historical_data_1m.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -14,7 +14,7 @@ const BB_INDICATOR = {
bb1_len_upper: 20, bb1_std_upper: 1.6, bb1_len_lower: 20, bb1_std_lower: 1.6, 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,
}; };

60
static/hts.js Normal file
View File

@ -0,0 +1,60 @@
/**
* HTS (High-Tech SMAs) - Combined Fast and Slow SMA Indicator
* This indicator displays both Fast SMA and Slow SMA on the same chart
*/
const HTS_INDICATOR = {
name: 'HTS',
label: 'HTS (Fast & Slow SMA)',
usesBaseData: false, // This indicator uses the chart's currently displayed data
params: [
{ name: 'fastPeriod', type: 'number', defaultValue: 33, min: 2, label: 'Fast SMA Period' },
{ name: 'slowPeriod', type: 'number', defaultValue: 133, min: 2, label: 'Slow SMA Period' },
],
calculateFull: calculateFullHTS,
};
function calculateFullHTS(data, params) {
const fastPeriod = params.fastPeriod;
const slowPeriod = params.slowPeriod;
if (!data || data.length < Math.max(fastPeriod, slowPeriod)) {
return {
fastSMA: [],
slowSMA: []
};
}
// Calculate Fast SMA
const fastSMA = calculateSMA(data, fastPeriod);
// Calculate Slow SMA
const slowSMA = calculateSMA(data, slowPeriod);
return {
fastSMA: fastSMA,
slowSMA: slowSMA
};
}
function calculateSMA(data, period) {
if (!data || data.length < period) return [];
let smaData = [];
let sum = 0;
// Calculate initial sum for the first period
for (let i = 0; i < period; i++) {
sum += data[i].close;
}
// Add the first SMA point
smaData.push({ time: data[period - 1].time, value: sum / period });
// Calculate remaining SMA points using sliding window
for (let i = period; i < data.length; i++) {
sum = sum - data[i - period].close + data[i].close;
smaData.push({ time: data[i].time, value: sum / period });
}
return smaData;
}

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,38 @@
/** /**
* 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' },
hts: {
fastSMA: '#00bcd4', // Cyan blue for Fast SMA
slowSMA: '#ff5252' // Red for Slow SMA
},
fast_sma: '#00bcd4',
slow_sma: '#ff5252',
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 +47,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 +81,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 +112,112 @@ 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 indicatorNameLower = slot.definition.name.toLowerCase();
const indicatorColor = colors[indicatorNameLower] || slot.definition.color || colors.default[slot.id - 1];
const series = chart.addLineSeries({ const series = chart.addLineSeries({
color: colors[bandName] ? colors[bandName][bandType] : colors.default[slot.id - 1], color: indicatorColor,
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,
}; };
} }

View File

@ -6,8 +6,9 @@
* 3. Add the indicator's definition object (e.g., RSI_INDICATOR) to this array. * 3. Add the indicator's definition object (e.g., RSI_INDICATOR) to this array.
*/ */
const AVAILABLE_INDICATORS = [ const AVAILABLE_INDICATORS = [
SMA_INDICATOR, HTS_INDICATOR,
EMA_INDICATOR, EMA_INDICATOR,
BB_INDICATOR, // Added the new Bollinger Bands indicator BB_INDICATOR, // Added the new Bollinger Bands indicator
// Add other indicators here, e.g., RSI_INDICATOR HURST_INDICATOR // Added the new Hurst Bands indicator
// Add other indicators here as needed
]; ];

View File

@ -1,14 +1,29 @@
/** /**
* Indicator Definition Object for SMA. * Indicator Definition Object for Fast SMA.
*/ */
const SMA_INDICATOR = { const FAST_SMA_INDICATOR = {
name: 'SMA', name: 'FAST_SMA',
label: 'Simple Moving Average', label: 'Fast SMA',
usesBaseData: false, // This simple indicator uses the chart's currently displayed data usesBaseData: false, // This simple indicator uses the chart's currently displayed data
params: [ params: [
{ name: 'period', type: 'number', defaultValue: 20, min: 2 }, { name: 'period', type: 'number', defaultValue: 33, min: 2 },
], ],
calculateFull: calculateFullSMA, calculateFull: calculateFullSMA,
color: '#00bcd4',
};
/**
* Indicator Definition Object for Slow SMA.
*/
const SLOW_SMA_INDICATOR = {
name: 'SLOW_SMA',
label: 'Slow SMA',
usesBaseData: false, // This simple indicator uses the chart's currently displayed data
params: [
{ name: 'period', type: 'number', defaultValue: 133, min: 2 },
],
calculateFull: calculateFullSMA,
color: '#ff5252',
}; };
function calculateFullSMA(data, params) { function calculateFullSMA(data, params) {

View File

@ -5,11 +5,13 @@
<script src="https://unpkg.com/lightweight-charts@4.1.3/dist/lightweight-charts.standalone.production.js"></script> <script src="https://unpkg.com/lightweight-charts@4.1.3/dist/lightweight-charts.standalone.production.js"></script>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script> <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<!-- Indicator & Aggregation Scripts --> <!-- NOTE: These 'url_for' will not work in a static HTML file. -->
<!-- They are placeholders for a Flask environment. For a standalone file, you would link directly to the JS files. -->
<script src="{{ url_for('static', filename='candle-aggregator.js') }}"></script> <script src="{{ url_for('static', filename='candle-aggregator.js') }}"></script>
<script src="{{ url_for('static', filename='sma.js') }}"></script> <script src="{{ url_for('static', filename='hts.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,23 @@
<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>
<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 +137,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 +154,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');
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;
manager = createIndicatorManager(chart, baseCandleData1m); 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) {
} else { baseCandleData1m.push(update);
currentCandle1m.high = Math.max(currentCandle1m.high, price); } else if (update.time === lastBaseCandle.time) {
currentCandle1m.low = Math.min(currentCandle1m.low, price); baseCandleData1m[baseCandleData1m.length - 1] = update;
currentCandle1m.close = price;
} }
const selectedInterval = parseInt(timeframeSelect.value, 10) * 60; const candleDurationSeconds = currentTimeframeMinutes * 60;
const displayedCandleTimestamp = tradeTime - (tradeTime % selectedInterval); let lastDisplayedCandle = displayedCandleData[displayedCandleData.length - 1];
const lastDisplayedCandle = displayedCandleData[displayedCandleData.length - 1];
let candleForUpdate; // Check if the update belongs to the currently forming displayed candle
if (lastDisplayedCandle && displayedCandleTimestamp === lastDisplayedCandle.time) { if (update.time >= lastDisplayedCandle.time && update.time < lastDisplayedCandle.time + candleDurationSeconds) {
candleForUpdate = { ...lastDisplayedCandle }; // It does, so just update the High, Low, and Close prices
candleForUpdate.high = Math.max(candleForUpdate.high, price); lastDisplayedCandle.high = Math.max(lastDisplayedCandle.high, update.high);
candleForUpdate.low = Math.min(candleForUpdate.low, price); lastDisplayedCandle.low = Math.min(lastDisplayedCandle.low, update.low);
candleForUpdate.close = price; lastDisplayedCandle.close = update.close;
displayedCandleData[displayedCandleData.length - 1] = candleForUpdate; candlestickSeries.update(lastDisplayedCandle);
} else if (!lastDisplayedCandle || displayedCandleTimestamp > lastDisplayedCandle.time) { } else if (update.time >= lastDisplayedCandle.time + candleDurationSeconds) {
candleForUpdate = { time: displayedCandleTimestamp, open: price, high: price, low: price, close: price }; // This update is for a NEW candle.
displayedCandleData.push(candleForUpdate); const newCandleTime = Math.floor(update.time / candleDurationSeconds) * candleDurationSeconds;
// A new candle has started, so update the indicators. // Create the new candle. Its O,H,L,C are all from this first tick.
manager.updateIndicatorsOnNewCandle(displayedCandleData); 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 {
// 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 (candleForUpdate) candlestickSeries.update(candleForUpdate); if (displayedCandleData.length === 0) return;
});
function updateChartForTimeframe() {
const selectedIntervalMinutes = parseInt(timeframeSelect.value, 10);
if (baseCandleData1m.length === 0) return;
const newCandleData = aggregateCandles(baseCandleData1m, selectedIntervalMinutes); // 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;
const visibleTimeRange = isFullReset ? null : chart.timeScale().getVisibleTimeRange();
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);
chart.timeScale().fitContent(); if (visibleTimeRange) {
chart.timeScale().setVisibleRange(visibleTimeRange);
} else {
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);