From 2f0aa68dc08325b914c588d813e93be76e243f81 Mon Sep 17 00:00:00 2001 From: Couras Date: Fri, 29 May 2026 15:49:46 +0100 Subject: [PATCH] feat(webui): trading signal panel, predict-future mode, dark theme & bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Major improvements to the Kronos Web UI focusing on usability, visual clarity, and a new real-time trading signal analysis panel. ## New Features ### Predict Future Now - Added a dedicated 'Predict Future Now' button that uses the most recent 400 candles (lookback) to forecast the next 120 periods beyond the last data point — i.e., a true future forecast, not a backtest. - Future timestamps are generated programmatically via pd.date_range so the model always forecasts into the uncharted future. - No 'actual data' orange trace is shown in this mode (the future hasn't happened yet). ### Trading Signal Analysis Panel - After every prediction a full trading signal panel is displayed below the chart, automatically computed from the prediction output. - Signal categories: STRONG BUY / BUY / NEUTRAL / CAUTION / DO NOT BUY, each with a distinct color scheme. - Panel includes six metric cards: Current Price, Predicted Final Price (%), Predicted High, Predicted Low, Predicted Mean, Support / Resistance. - Trajectory timeline: Now → [Nd: +X%] → [Nd: +X%] → [End: +X%]. - Operations panel: Entry, Stop-Loss (-3%), Target 1, Target 2, Timeframe. ### Dark Theme & Chart Color Overhaul - Chart background switched to plotly_dark with dark paper/plot bg. - Historical candles: gray (#78909C / #546E7A) — neutral context. - Prediction candles: blue (#1E88E5 / #1565C0) — clearly distinguishable. - Actual/backtest candles: orange (#F57C00 / #BF360C) — easy comparison. ### Windows Launcher (start.ps1) - One-command launcher: auto-detects the Python installation with CUDA support by iterating all python.exe in PATH and testing torch.cuda.is_available(). - Falls back to CPU if no CUDA Python is found. - Deactivates any active venv to prevent using the wrong Python. - Generates a random port (10000-60000), checks availability via Get-NetTCPConnection, then auto-opens the browser after 3 s. - Supports -SkipInstall flag to skip pip install on repeat launches. ### Data Download Utility (download_data.py) - CLI utility to download market data (crypto, stocks, forex) via yfinance and save to the Kronos data/ directory in the expected CSV format. - Supports all yfinance intervals (1m, 5m, 15m, 1h, 4h, 1d, 1wk, 1mo). - Usage: python download_data.py BTC-USD --interval 4h --period 2y ## Bug Fixes ### actual_data showing wrong price range in predict-future mode (critical) - Root cause: the else branch in the actual_data section always fetched df.iloc[0:lookback+pred_len] regardless of mode, so in predict-future mode (no start_date) it served the very first candles in the file (e.g. 2024 XRP at ~.50 instead of current ~.30), rendering an orange trace in a completely wrong price band. - Fix: actual_data is now mode-aware: - predict_future=True → actual_df = None (no trace) - start_date provided → actual_df from time_range_df[lookback:lookback+pred_len] - neither → no actual data ### historical_start_idx wrong in predict-future mode - Root cause: historical_start_idx defaulted to 0, so the chart showed candles from the very beginning of the file (years-old data) as the 'historical' context window instead of the last 400 candles. - Fix: when predict_future=True, historical_start_idx = len(df) - lookback. ## Localization - All user-visible text translated from Chinese/Portuguese to English. - html lang attribute updated to 'en'. - Date formatting locale updated to en-US. - .gitignore updated to exclude data/ and webui/prediction_results/ (runtime artifacts). --- .gitignore | 6 + download_data.py | 102 ++++++++++++ start.ps1 | 104 ++++++++++++ webui/app.py | 151 +++++++++++------- webui/templates/index.html | 319 ++++++++++++++++++++++++++++++++++++- 5 files changed, 620 insertions(+), 62 deletions(-) create mode 100644 download_data.py create mode 100644 start.ps1 diff --git a/.gitignore b/.gitignore index 68871c97..b1c1e8b0 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,9 @@ venv.bak/ temp/ tmp/ .python-version + +# Market data files (downloaded at runtime) +data/ + +# WebUI runtime output +webui/prediction_results/ diff --git a/download_data.py b/download_data.py new file mode 100644 index 00000000..93a609ad --- /dev/null +++ b/download_data.py @@ -0,0 +1,102 @@ +""" +Kronos - Downloader de dados de mercado +Baixa dados de qualquer ativo (cripto, acoes, forex) e salva em data/ +para uso no Web UI. + +Uso: + python download_data.py XRP-USD # XRP/USD - diario + python download_data.py BTC-USD --interval 1h + python download_data.py AAPL --interval 1d --period 2y + python download_data.py ETH-USD --interval 5m --period 60d + python download_data.py BTC-USD ETH-USD XRP-USD # multiplos de uma vez + +Intervalos suportados: 1m 2m 5m 15m 30m 60m 90m 1h 1d 5d 1wk 1mo +Periodos suportados : 1d 5d 1mo 3mo 6mo 1y 2y 5y 10y ytd max + (para intervalos < 1h, o maximo e 60 dias) +""" + +import argparse +import os +import sys +import pandas as pd +import yfinance as yf + +DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") + + +def download(ticker: str, interval: str = "1d", period: str = "2y") -> None: + print(f" Baixando {ticker} intervalo={interval} periodo={period} ...") + + df = yf.download(ticker, interval=interval, period=period, progress=False, auto_adjust=True) + + if df.empty: + print(f" [ERRO] Nenhum dado retornado para '{ticker}'. Verifique o simbolo.") + return + + # Flatten MultiIndex columns (yfinance retorna assim para tickers unicos as vezes) + if isinstance(df.columns, pd.MultiIndex): + df.columns = [col[0].lower() for col in df.columns] + else: + df.columns = [c.lower() for c in df.columns] + + # Renomear colunas para o padrao Kronos + rename = {"adj close": "close"} + df = df.rename(columns=rename) + + # Garantir colunas obrigatorias + required = ["open", "high", "low", "close"] + missing = [c for c in required if c not in df.columns] + if missing: + print(f" [ERRO] Colunas faltando apos download: {missing}. Colunas presentes: {list(df.columns)}") + return + + # Resetar index e renomear coluna de data/hora + df = df.reset_index() + time_col = df.columns[0] # 'Datetime' ou 'Date' + df = df.rename(columns={time_col: "timestamps"}) + df["timestamps"] = pd.to_datetime(df["timestamps"]) + + # Remover timezone para evitar problemas de serializacao + if df["timestamps"].dt.tz is not None: + df["timestamps"] = df["timestamps"].dt.tz_convert("UTC").dt.tz_localize(None) + + # Manter apenas colunas uteis + keep = ["timestamps", "open", "high", "low", "close"] + if "volume" in df.columns: + keep.append("volume") + df = df[keep].dropna() + + # Nome do arquivo: TICKER_interval.csv + safe_ticker = ticker.replace("/", "-").replace("=", "") + filename = f"{safe_ticker}_{interval}.csv" + out_path = os.path.join(DATA_DIR, filename) + + os.makedirs(DATA_DIR, exist_ok=True) + df.to_csv(out_path, index=False) + + size_kb = os.path.getsize(out_path) / 1024 + print(f" [OK] {filename} ({len(df)} candles, {size_kb:.1f} KB) -> data/{filename}") + + +def main(): + parser = argparse.ArgumentParser( + description="Baixa dados de mercado e salva em data/ para uso no Kronos Web UI." + ) + parser.add_argument("tickers", nargs="+", help="Simbolos yfinance (ex: XRP-USD BTC-USD AAPL)") + parser.add_argument("--interval", default="1d", + help="Intervalo das velas (default: 1d). Ex: 5m 1h 1d") + parser.add_argument("--period", default="2y", + help="Periodo historico (default: 2y). Ex: 60d 1y 5y max") + args = parser.parse_args() + + print(f"\nKronos Data Downloader") + print(f"Destino: {DATA_DIR}\n") + + for ticker in args.tickers: + download(ticker.upper(), interval=args.interval, period=args.period) + + print(f"\nConcluido! Reinicie o servidor ou recarregue a pagina para ver os novos arquivos.") + + +if __name__ == "__main__": + main() diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 00000000..7a932651 --- /dev/null +++ b/start.ps1 @@ -0,0 +1,104 @@ +# Kronos - Startup Script +# Starts the Web UI on a random port, using Python with CUDA support + +param( + [switch]$SkipInstall +) + +$ErrorActionPreference = "Stop" + +Write-Host "======================================" -ForegroundColor Cyan +Write-Host " Kronos - Startup" -ForegroundColor Cyan +Write-Host "======================================" -ForegroundColor Cyan +Write-Host "" + +# If a venv is active, deactivate it to avoid using the wrong Python +if ($env:VIRTUAL_ENV) { + Write-Host "[INFO] Deactivating venv '$env:VIRTUAL_ENV' ..." -ForegroundColor Yellow + & deactivate 2>$null + # Manually clear venv variables from this session's PATH + $env:PATH = ($env:PATH -split ';' | Where-Object { $_ -notlike "$env:VIRTUAL_ENV*" }) -join ';' + Remove-Item Env:\VIRTUAL_ENV -ErrorAction SilentlyContinue + Remove-Item Env:\VIRTUAL_ENV_PROMPT -ErrorAction SilentlyContinue +} + +# Find Python with CUDA support (search all instances in PATH) +$pythonExe = $null +$candidatos = (where.exe python 2>$null) -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" -and (Test-Path $_) } + +foreach ($cand in $candidatos) { + $result = & $cand -c "import torch; print(torch.cuda.is_available())" 2>$null + if ($result -eq "True") { + $pythonExe = $cand + break + } +} + +if (-not $pythonExe) { + # Fallback: accept CPU if no CUDA found + $pythonExe = (Get-Command python -ErrorAction SilentlyContinue)?.Source + if (-not $pythonExe) { + Write-Host "[ERROR] Python not found in PATH. Install Python 3.10+ and try again." -ForegroundColor Red + exit 1 + } + Write-Host "[WARNING] Python with CUDA not found. Using CPU: $pythonExe" -ForegroundColor Yellow + Write-Host " Para CUDA, instale: pip install torch --index-url https://download.pytorch.org/whl/cu128" -ForegroundColor Yellow +} else { + $pyVersion = & $pythonExe --version 2>&1 + $gpuName = & $pythonExe -c "import torch; print(torch.cuda.get_device_name(0))" 2>&1 + Write-Host "[OK] $pyVersion | CUDA - $gpuName" -ForegroundColor Green + Write-Host "[OK] Python: $pythonExe" -ForegroundColor Green +} + +# Project root directory +$projectRoot = $PSScriptRoot +Set-Location $projectRoot + +# Install dependencies +if (-not $SkipInstall) { + Write-Host "[...] Installing project dependencies ..." -ForegroundColor Yellow + & $pythonExe -m pip install -r (Join-Path $projectRoot "requirements.txt") --quiet + + $webuiReqs = Join-Path $projectRoot "webui\requirements.txt" + if (Test-Path $webuiReqs) { + Write-Host "[...] Installing Web UI dependencies ..." -ForegroundColor Yellow + & $pythonExe -m pip install -r $webuiReqs --quiet + } + Write-Host "[OK] Dependencies installed." -ForegroundColor Green +} else { + Write-Host "[SKIP] Dependency installation skipped (-SkipInstall)." -ForegroundColor Yellow +} + +# Generate random port between 10000 and 60000 (avoids well-known ports) +$port = Get-Random -Minimum 10000 -Maximum 60000 +for ($i = 0; $i -lt 10; $i++) { + if (-not (Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue)) { break } + $port = Get-Random -Minimum 10000 -Maximum 60000 +} + +Write-Host "" +Write-Host "======================================" -ForegroundColor Cyan +Write-Host " Kronos Web UI" -ForegroundColor Cyan +Write-Host " Port : $port" -ForegroundColor Cyan +Write-Host " URL : http://localhost:$port" -ForegroundColor Cyan +Write-Host "======================================" -ForegroundColor Cyan +Write-Host "Press Ctrl+C to stop the server." -ForegroundColor DarkGray +Write-Host "" + +# Open browser automatically after a few seconds +Start-Job -ScriptBlock { + param($url) + Start-Sleep -Seconds 3 + Start-Process $url +} -ArgumentList "http://localhost:$port" | Out-Null + +# Start Flask on the random port +Set-Location (Join-Path $projectRoot "webui") +$env:FLASK_APP = "app.py" + +& $pythonExe -c @" +import sys, os +sys.path.insert(0, '..') +from webui.app import app +app.run(debug=False, host='127.0.0.1', port=$port) +"@ diff --git a/webui/app.py b/webui/app.py index d240a372..ed64ea93 100644 --- a/webui/app.py +++ b/webui/app.py @@ -223,7 +223,7 @@ def create_prediction_chart(df, pred_df, lookback, pred_len, actual_df=None, his # Create chart fig = go.Figure() - # Add historical data (candlestick chart) + # Add historical data (candlestick chart) — GRAY (neutral background) fig.add_trace(go.Candlestick( x=historical_df['timestamps'] if 'timestamps' in historical_df.columns else historical_df.index, open=historical_df['open'], @@ -231,8 +231,11 @@ def create_prediction_chart(df, pred_df, lookback, pred_len, actual_df=None, his low=historical_df['low'], close=historical_df['close'], name='Historical Data (400 data points)', - increasing_line_color='#26A69A', - decreasing_line_color='#EF5350' + increasing_line_color='#78909C', + decreasing_line_color='#546E7A', + increasing_fillcolor='#B0BEC5', + decreasing_fillcolor='#78909C', + opacity=0.7 )) # Add prediction data (candlestick chart) @@ -259,8 +262,11 @@ def create_prediction_chart(df, pred_df, lookback, pred_len, actual_df=None, his low=pred_df['low'], close=pred_df['close'], name='Prediction Data (120 data points)', - increasing_line_color='#66BB6A', - decreasing_line_color='#FF7043' + increasing_line_color='#1E88E5', + decreasing_line_color='#1565C0', + increasing_fillcolor='#64B5F6', + decreasing_fillcolor='#1E88E5', + opacity=0.9 )) # Add actual data for comparison (if exists) @@ -292,18 +298,25 @@ def create_prediction_chart(df, pred_df, lookback, pred_len, actual_df=None, his low=actual_df['low'], close=actual_df['close'], name='Actual Data (120 data points)', - increasing_line_color='#FF9800', - decreasing_line_color='#F44336' + increasing_line_color='#F57C00', + decreasing_line_color='#BF360C', + increasing_fillcolor='#FFB74D', + decreasing_fillcolor='#FF7043', + opacity=0.9 )) # Update layout fig.update_layout( - title='Kronos Financial Prediction Results - 400 Historical Points + 120 Prediction Points vs 120 Actual Points', + title='Kronos — 400 Historical (gray) | 120 Prediction (blue) | 120 Actual (orange)', xaxis_title='Time', yaxis_title='Price', - template='plotly_white', - height=600, - showlegend=True + template='plotly_dark', + height=620, + showlegend=True, + legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1), + paper_bgcolor='#1a1a2e', + plot_bgcolor='#16213e', + font=dict(color='#e0e0e0') ) # Ensure x-axis time continuity @@ -437,31 +450,41 @@ def predict(): # Process time period selection start_date = data.get('start_date') - - if start_date: - # Custom time period - fix logic: use data within selected window + predict_future = data.get('predict_future', False) + + if predict_future: + # TRUE FUTURE MODE: use the most recent 'lookback' candles, generate timestamps beyond last data point + x_df = df.tail(lookback)[required_cols].reset_index(drop=True) + x_timestamp = df.tail(lookback)['timestamps'].reset_index(drop=True) + + # Detect candle interval from data + time_diff = df['timestamps'].iloc[-1] - df['timestamps'].iloc[-2] + + # Generate future timestamps that don't exist yet + last_ts = df['timestamps'].iloc[-1] + y_timestamp = pd.Series( + pd.date_range(start=last_ts + time_diff, periods=pred_len, freq=time_diff), + name='timestamps' + ) + prediction_type = f"FUTURE PREDICTION — using last {lookback} candles up to {last_ts.strftime('%Y-%m-%d %H:%M')}, forecasting next {pred_len} candles ({(time_diff * pred_len)})" + + elif start_date: + # BACKTEST MODE: use data within selected window start_dt = pd.to_datetime(start_date) - - # Find data after start time + mask = df['timestamps'] >= start_dt time_range_df = df[mask] - - # Ensure sufficient data: lookback + pred_len + if len(time_range_df) < lookback + pred_len: return jsonify({'error': f'Insufficient data from start time {start_dt.strftime("%Y-%m-%d %H:%M")}, need at least {lookback + pred_len} data points, currently only {len(time_range_df)} available'}), 400 - - # Use first lookback data points within selected window for prediction + x_df = time_range_df.iloc[:lookback][required_cols] x_timestamp = time_range_df.iloc[:lookback]['timestamps'] - - # Use last pred_len data points within selected window as actual values y_timestamp = time_range_df.iloc[lookback:lookback+pred_len]['timestamps'] - - # Calculate actual time period length + start_timestamp = time_range_df['timestamps'].iloc[0] end_timestamp = time_range_df['timestamps'].iloc[lookback+pred_len-1] time_span = end_timestamp - start_timestamp - prediction_type = f"Kronos model prediction (within selected window: first {lookback} data points for prediction, last {pred_len} data points for comparison, time span: {time_span})" else: # Use latest data @@ -494,49 +517,36 @@ def predict(): # Prepare actual data for comparison (if exists) actual_data = [] actual_df = None - - if start_date: # Custom time period - # Fix logic: use data within selected window - # Prediction uses first 400 data points within selected window - # Actual data should be last 120 data points within selected window + + if predict_future: + # TRUE FUTURE MODE: no actual data exists — the future hasn't happened yet + actual_df = None + + elif start_date: + # BACKTEST MODE: actual data = the pred_len candles right after the 400 lookback candles start_dt = pd.to_datetime(start_date) - - # Find data starting from start_date mask = df['timestamps'] >= start_dt time_range_df = df[mask] - + if len(time_range_df) >= lookback + pred_len: - # Get last 120 data points within selected window as actual values actual_df = time_range_df.iloc[lookback:lookback+pred_len] - - for i, (_, row) in enumerate(actual_df.iterrows()): - actual_data.append({ - 'timestamp': row['timestamps'].isoformat(), - 'open': float(row['open']), - 'high': float(row['high']), - 'low': float(row['low']), - 'close': float(row['close']), - 'volume': float(row['volume']) if 'volume' in row else 0, - 'amount': float(row['amount']) if 'amount' in row else 0 - }) - else: # Latest data - # Prediction uses first 400 data points - # Actual data should be 120 data points after first 400 data points - if len(df) >= lookback + pred_len: - actual_df = df.iloc[lookback:lookback+pred_len] - for i, (_, row) in enumerate(actual_df.iterrows()): + for _, row in actual_df.iterrows(): actual_data.append({ 'timestamp': row['timestamps'].isoformat(), - 'open': float(row['open']), - 'high': float(row['high']), - 'low': float(row['low']), - 'close': float(row['close']), + 'open': float(row['open']), + 'high': float(row['high']), + 'low': float(row['low']), + 'close': float(row['close']), 'volume': float(row['volume']) if 'volume' in row else 0, 'amount': float(row['amount']) if 'amount' in row else 0 }) + # else: no start_date and not future → no actual data # Create chart - pass historical data start position - if start_date: + if predict_future: + # Future mode: show the last 400 candles as historical + historical_start_idx = len(df) - lookback + elif start_date: # Custom time period: find starting position of historical data in original df start_dt = pd.to_datetime(start_date) mask = df['timestamps'] >= start_dt @@ -610,11 +620,40 @@ def predict(): except Exception as e: print(f"Failed to save prediction results: {e}") + # Compute interval in hours for the frontend signal panel + time_diff_h = (df['timestamps'].iloc[-1] - df['timestamps'].iloc[-2]).total_seconds() / 3600 + + # Build prediction_data list with timestamps for the frontend + prediction_data = [] + for i, (_, row) in enumerate(pred_df.iterrows()): + if i < len(y_timestamp): + ts_val = y_timestamp.iloc[i] + # Always produce a proper ISO-8601 string so JS new Date() never gets Invalid Date + if hasattr(ts_val, 'isoformat'): + ts_str = ts_val.isoformat() + else: + ts_str = str(ts_val).replace(' ', 'T') + else: + ts_str = None + prediction_data.append({ + 'timestamp': ts_str, + 'open': float(row['open']), + 'high': float(row['high']), + 'low': float(row['low']), + 'close': float(row['close']), + }) + + # Current price = last close in the input window + current_price = float(x_df['close'].iloc[-1]) + return jsonify({ 'success': True, 'prediction_type': prediction_type, 'chart': chart_json, 'prediction_results': prediction_results, + 'prediction_data': prediction_data, + 'current_price': current_price, + 'interval_hours': time_diff_h, 'actual_data': actual_data, 'has_comparison': len(actual_data) > 0, 'message': f'Prediction completed, generated {pred_len} prediction points' + (f', including {len(actual_data)} actual data points for comparison' if len(actual_data) > 0 else '') diff --git a/webui/templates/index.html b/webui/templates/index.html index dd24a49e..14005568 100644 --- a/webui/templates/index.html +++ b/webui/templates/index.html @@ -1,5 +1,5 @@ - + @@ -302,6 +302,88 @@ } /* Comparison analysis styles */ + /* Trading Signal Panel */ + #signal-section { + margin-top: 20px; + border-radius: 10px; + overflow: hidden; + border: 1px solid #2a2a4a; + } + .signal-header { + padding: 14px 20px; + font-size: 1.1em; + font-weight: 700; + letter-spacing: 0.5px; + } + .signal-STRONG-BUY { background: #0d3b1e; color: #4ade80; } + .signal-BUY { background: #0d2b1e; color: #86efac; } + .signal-NEUTRAL { background: #1e1e2e; color: #94a3b8; } + .signal-CAUTION { background: #2b1e0d; color: #fbbf24; } + .signal-NO-BUY { background: #2b0d0d; color: #f87171; } + .signal-body { + background: #0f1117; + padding: 16px 20px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; + } + .signal-card { + background: #1a1a2e; + border-radius: 8px; + padding: 12px 16px; + border-left: 3px solid #1E88E5; + } + .signal-card.danger { border-left-color: #f87171; } + .signal-card.success { border-left-color: #4ade80; } + .signal-card.warn { border-left-color: #fbbf24; } + .signal-card .sc-label { font-size: 0.72em; color: #64748b; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px; } + .signal-card .sc-value { font-size: 1.25em; font-weight: 700; color: #e2e8f0; } + .signal-card .sc-sub { font-size: 0.8em; color: #94a3b8; margin-top: 2px; } + .signal-traj { + background: #0f1117; + padding: 0 20px 16px; + display: flex; + gap: 0; + align-items: center; + } + .traj-step { + flex: 1; + text-align: center; + padding: 10px 4px; + background: #1a1a2e; + font-size: 0.82em; + color: #94a3b8; + } + .traj-step .ts-val { font-size: 1.1em; font-weight: 700; margin-top: 2px; } + .traj-step.up .ts-val { color: #4ade80; } + .traj-step.down .ts-val { color: #f87171; } + .traj-step.flat .ts-val { color: #94a3b8; } + .traj-arrow { color: #334155; font-size: 1.2em; padding: 0 2px; } + .signal-ops { + background: #0f1117; + padding: 0 20px 20px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; + } + .op-box { + border-radius: 6px; + padding: 10px 14px; + font-size: 0.85em; + } + .op-box .ob-label { color: #64748b; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.6px; } + .op-box .ob-val { font-size: 1.1em; font-weight: 700; margin-top: 3px; } + .op-entry { background: #0d1f3c; border: 1px solid #1E88E5; } + .op-entry .ob-val { color: #64B5F6; } + .op-stop { background: #2b0d0d; border: 1px solid #f87171; } + .op-stop .ob-val { color: #f87171; } + .op-target1 { background: #0d2b1e; border: 1px solid #4ade80; } + .op-target1 .ob-val { color: #4ade80; } + .op-target2 { background: #0d3b1e; border: 1px solid #86efac; } + .op-target2 .ob-val { color: #86efac; } + .op-period { background: #1e1e2e; border: 1px solid #334155; } + .op-period .ob-val { color: #94a3b8; } + .comparison-section { background: #f7fafc; border: 1px solid #e2e8f0; @@ -566,8 +648,15 @@

⏰ Time Window Selection

+ + + + Uses the last 400 candles → predicts the next 120 that haven't happened yet +
@@ -578,8 +667,13 @@

⏰ Time Window Selection

📈 Prediction Results Chart

-
+
+
+
+ + +