Compare commits

...

6 Commits

Author SHA1 Message Date
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
6 changed files with 410 additions and 299 deletions

207
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,48 @@ 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.
This avoids reading the entire file into memory.
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. num_chunks = 6
f.seek(0, os.SEEK_END) chunk_size_days = 15
filesize = f.tell() end_date = datetime.utcnow()
if filesize == 0: all_klines = []
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 for i in range(num_chunks):
last_line_str = '' start_date = end_date - timedelta(days=chunk_size_days)
for line in reversed(lines): logging.info(f"Fetching chunk {i + 1}/{num_chunks} for SID={sid}")
decoded_line = line.decode('utf-8').strip() new_klines = client.get_historical_klines(SYMBOL, Client.KLINE_INTERVAL_1MINUTE, str(start_date), str(end_date))
if decoded_line: if new_klines:
last_line_str = decoded_line all_klines.extend(new_klines)
break socketio.emit('history_progress', {'progress': ((i + 1) / num_chunks) * 100}, to=sid)
end_date = start_date
if not last_line_str or 'Open time' in last_line_str: socketio.sleep(0.05)
return None
last_row = last_line_str.split(',') seen = set()
dt_obj = datetime.strptime(last_row[0], '%Y-%m-%d %H:%M:%S') 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))]
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 with open(HISTORY_FILE, 'w') as f:
logging.info(f"--- Data initialization complete. {len(historical_data_cache)} candles cached for frontend. ---") 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 +76,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 +108,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

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,25 @@
/** /**
* 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)
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 },
{ id: 2, cellId: 'indicator-cell-2', series: [], definition: null, params: {} }, { id: 2, cellId: 'indicator-cell-2', series: [], definition: null, params: {}, calculator: null },
{ id: 3, cellId: 'indicator-cell-3', series: [], definition: null, params: {} }, { id: 3, cellId: 'indicator-cell-3', series: [], definition: null, params: {}, calculator: null },
{ id: 4, cellId: 'indicator-cell-4', series: [], definition: null, params: {} }, { id: 4, cellId: 'indicator-cell-4', series: [], definition: null, params: {}, calculator: null },
]; ];
// Pre-defined colors for the indicator lines. // **FIX**: Updated colors object to match your styling request.
const colors = { const colors = {
bb1: { upper: '#FF9800', lower: '#FF9800' }, // Orange bb: { bb1_upper: '#FF9800', bb1_lower: '#FF9800', bb2_upper: '#2196F3', bb2_lower: '#2196F3', bb3_upper: '#9C27B0', bb3_lower: '#9C27B0' },
bb2: { upper: '#2196F3', lower: '#2196F3' }, // Blue hurst: { topBand: '#787b86', bottomBand: '#787b86', topBand_h: '#673ab7', bottomBand_h: '#673ab7' },
bb3: { upper: '#9C27B0', lower: '#9C27B0' }, // Purple default: ['#00BCD4', '#FFEB3B', '#4CAF50', '#E91E63'] // Cyan, Yellow, Green, Pink
default: ['#FF5722', '#03A9F4', '#8BC34A', '#F44336'] // Fallback colors for other indicators
}; };
/**
* 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 +34,23 @@ 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
slot.series.forEach(s => chart.removeSeries(s)); slot.series.forEach(s => chart.removeSeries(s));
slot.series = []; slot.series = [];
slot.definition = null; slot.definition = null;
slot.params = {}; slot.params = {};
slot.calculator = null;
const controlsContainer = document.querySelector(`#${slot.cellId} .indicator-controls`); const controlsContainer = document.querySelector(`#${slot.cellId} .indicator-controls`);
controlsContainer.innerHTML = ''; controlsContainer.innerHTML = '';
@ -78,7 +62,6 @@ function createIndicatorManager(chart, baseCandleData) {
slot.definition = definition; slot.definition = definition;
// Create UI controls for the indicator's parameters
definition.params.forEach(param => { definition.params.forEach(param => {
const label = document.createElement('label'); const label = document.createElement('label');
label.textContent = param.label || param.name; label.textContent = param.label || param.name;
@ -90,7 +73,6 @@ function createIndicatorManager(chart, baseCandleData) {
if (param.min !== undefined) input.min = param.min; if (param.min !== undefined) input.min = param.min;
if (param.step !== undefined) input.step = param.step; if (param.step !== undefined) input.step = param.step;
input.className = 'input-field'; input.className = 'input-field';
input.placeholder = param.name;
slot.params[param.name] = input.type === 'number' ? parseFloat(input.value) : input.value; slot.params[param.name] = input.type === 'number' ? parseFloat(input.value) : input.value;
let debounceTimer; let debounceTimer;
@ -98,7 +80,7 @@ function createIndicatorManager(chart, baseCandleData) {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = 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); }, 500);
}); });
const controlGroup = document.createElement('div'); const controlGroup = document.createElement('div');
@ -109,91 +91,92 @@ 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);
const candleDataForCalc = (slot.definition.usesBaseData) ? baseCandleDataRef : displayedCandleDataRef;
const candleDataForCalc = (slot.definition.usesBaseData) ? baseCandleData : currentAggregatedData;
if (!slot || !slot.definition || candleDataForCalc.length === 0) { if (!slot || !slot.definition || candleDataForCalc.length === 0) return;
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); if (typeof indicatorResult === 'object' && !Array.isArray(indicatorResult)) {
Object.keys(indicatorResult).forEach(key => {
// Handle multi-line indicators like Bollinger Bands const seriesData = indicatorResult[key];
if (typeof indicatorResult === 'object' && !Array.isArray(indicatorResult)) { const indicatorNameLower = slot.definition.name.toLowerCase();
Object.keys(indicatorResult).forEach(key => { const series = chart.addLineSeries({
const seriesData = indicatorResult[key]; color: (colors[indicatorNameLower] && colors[indicatorNameLower][key]) ? colors[indicatorNameLower][key] : colors.default[slot.id - 1],
const bandName = key.split('_')[0]; lineWidth: 1, // **FIX**: Set line width to 1px
const bandType = key.split('_')[1]; title: '', // **FIX**: Remove title label
lastValueVisible: false, // **FIX**: Remove price label on the right
const series = chart.addLineSeries({ priceLineVisible: false, // **FIX**: Remove dotted horizontal line
color: colors[bandName] ? colors[bandName][bandType] : colors.default[slot.id - 1], });
lineWidth: 2, series.setData(seriesData);
title: `${slot.definition.label} - ${key}`, slot.series.push(series);
lastValueVisible: false,
priceLineVisible: false,
}); });
series.setData(seriesData); } else {
const series = chart.addLineSeries({
color: colors.default[slot.id - 1],
lineWidth: 1, // **FIX**: Set line width to 1px
title: '', // **FIX**: Remove title label
lastValueVisible: false, // **FIX**: Remove price label on the right
priceLineVisible: false, // **FIX**: Remove dotted horizontal line
});
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); // **FIX**: This is the lightweight real-time update logic
slot.series.push(series); 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() {
indicatorSlots.forEach(slot => { indicatorSlots.forEach(slot => {
if (slot.definition) updateIndicator(slot.id, true);
});
}
// **FIX**: New lightweight function for real-time updates
function updateAllOnNewCandle() {
indicatorSlots.forEach(slot => {
if (slot.definition) { if (slot.definition) {
updateIndicator(slot.id); updateIndicator(slot.id, false); // Perform a lightweight update
} }
}); });
} }
/**
* 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, // Expose the new function
}; };
} }

View File

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

View File

@ -9,7 +9,8 @@
<script src="{{ url_for('static', filename='candle-aggregator.js') }}"></script> <script src="{{ url_for('static', filename='candle-aggregator.js') }}"></script>
<script src="{{ url_for('static', filename='sma.js') }}"></script> <script src="{{ url_for('static', filename='sma.js') }}"></script>
<script src="{{ url_for('static', filename='ema.js') }}"></script> <script src="{{ url_for('static', filename='ema.js') }}"></script>
<script src="{{ url_for('static', filename='bb.js') }}"></script> <script src="{{ url_for('static', filename='bb.js') }}"></script>
<script src="{{ url_for('static', filename='hurst.js') }}"></script>
<script src="{{ url_for('static', filename='indicators.js') }}"></script> <script src="{{ url_for('static', filename='indicators.js') }}"></script>
<script src="{{ url_for('static', filename='indicator-manager.js') }}"></script> <script src="{{ url_for('static', filename='indicator-manager.js') }}"></script>
@ -61,6 +62,14 @@
} }
#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-select { margin-top: 10px; }
.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;
}
</style> </style>
</head> </head>
<body> <body>
@ -86,6 +95,9 @@
<option value="9">9m</option> <option value="9">9m</option>
<option value="10">10m</option> <option value="10">10m</option>
</select> </select>
<div id="progress-container" class="progress-bar-container">
<div class="progress-bar"></div>
</div>
</div> </div>
<div class="control-cell" id="indicator-cell-1"></div> <div class="control-cell" id="indicator-cell-1"></div>
<div class="control-cell" id="indicator-cell-2"></div> <div class="control-cell" id="indicator-cell-2"></div>
@ -115,89 +127,75 @@
let baseCandleData1m = []; let baseCandleData1m = [];
let displayedCandleData = []; let displayedCandleData = [];
let currentCandle1m = null;
let manager; let manager;
const timeframeSelect = document.getElementById('timeframe-select'); const timeframeSelect = document.getElementById('timeframe-select');
const candleTimerDiv = document.getElementById('candle-timer'); const candleTimerDiv = document.getElementById('candle-timer');
const chartTitle = document.getElementById('chart-title'); const chartTitle = document.getElementById('chart-title');
const analyzeButton = document.getElementById('analyzeButton'); const progressContainer = document.getElementById('progress-container');
const analysisResultDiv = document.getElementById('analysisResult'); const progressBar = document.querySelector('.progress-bar');
manager = createIndicatorManager(chart, baseCandleData1m); manager = 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;
const mappedKlines = data.klines_1m.map(k => ({ progressBar.style.width = '100%';
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])
})); }));
baseCandleData1m.length = 0;
for (const kline of mappedKlines) {
baseCandleData1m.push(kline);
}
updateChartForTimeframe(); updateChartForTimeframe(true); // Initial load, fit content
setTimeout(() => { progressContainer.style.display = 'none'; }, 500);
}); });
socket.on('trade', (trade) => { socket.on('candle_update', (candle) => {
const price = parseFloat(trade.p); candlestickSeries.update(candle);
const tradeTime = Math.floor(trade.T / 1000); });
const candleTimestamp1m = tradeTime - (tradeTime % 60);
if (!currentCandle1m || candleTimestamp1m > currentCandle1m.time) { socket.on('candle_closed', (closedCandle) => {
if (currentCandle1m) baseCandleData1m.push(currentCandle1m); const lastBaseCandle = baseCandleData1m.length > 0 ? baseCandleData1m[baseCandleData1m.length - 1] : null;
currentCandle1m = { time: candleTimestamp1m, open: price, high: price, low: price, close: price }; if (lastBaseCandle && lastBaseCandle.time === closedCandle.time) {
baseCandleData1m[baseCandleData1m.length - 1] = closedCandle;
} else { } else {
currentCandle1m.high = Math.max(currentCandle1m.high, price); baseCandleData1m.push(closedCandle);
currentCandle1m.low = Math.min(currentCandle1m.low, price);
currentCandle1m.close = price;
} }
updateChartForTimeframe(false); // Subsequent update, preserve zoom
const selectedInterval = parseInt(timeframeSelect.value, 10) * 60;
const displayedCandleTimestamp = tradeTime - (tradeTime % selectedInterval);
const lastDisplayedCandle = displayedCandleData[displayedCandleData.length - 1];
let candleForUpdate;
if (lastDisplayedCandle && displayedCandleTimestamp === lastDisplayedCandle.time) {
candleForUpdate = { ...lastDisplayedCandle };
candleForUpdate.high = Math.max(candleForUpdate.high, price);
candleForUpdate.low = Math.min(candleForUpdate.low, price);
candleForUpdate.close = price;
displayedCandleData[displayedCandleData.length - 1] = candleForUpdate;
} else if (!lastDisplayedCandle || displayedCandleTimestamp > lastDisplayedCandle.time) {
candleForUpdate = { time: displayedCandleTimestamp, open: price, high: price, low: price, close: price };
displayedCandleData.push(candleForUpdate);
// A new candle has started, so update the indicators.
manager.updateIndicatorsOnNewCandle(displayedCandleData);
}
if (candleForUpdate) candlestickSeries.update(candleForUpdate);
}); });
function updateChartForTimeframe() { function updateChartForTimeframe(isInitialLoad = false) {
const selectedIntervalMinutes = parseInt(timeframeSelect.value, 10); const selectedIntervalMinutes = parseInt(timeframeSelect.value, 10);
if (baseCandleData1m.length === 0) return; if (baseCandleData1m.length === 0) return;
const visibleRange = isInitialLoad ? null : chart.timeScale().getVisibleLogicalRange();
const newCandleData = aggregateCandles(baseCandleData1m, selectedIntervalMinutes); const newCandleData = aggregateCandles(baseCandleData1m, selectedIntervalMinutes);
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 (${selectedIntervalMinutes}m)`;
manager.recalculateAllAfterHistory(displayedCandleData); manager.recalculateAllAfterHistory(baseCandleData1m, displayedCandleData);
chart.timeScale().fitContent();
if (visibleRange) {
chart.timeScale().setVisibleLogicalRange(visibleRange);
} else {
chart.timeScale().fitContent();
}
} }
} }
timeframeSelect.addEventListener('change', updateChartForTimeframe); timeframeSelect.addEventListener('change', () => updateChartForTimeframe(true));
setInterval(() => { setInterval(() => {
const selectedIntervalSeconds = parseInt(timeframeSelect.value, 10) * 60; const selectedIntervalSeconds = parseInt(timeframeSelect.value, 10) * 60;