Download src/ui/app.py from chen470/drl-trading-bot-dev2: direct link, hf CLI and curl.
- Browser
- Download file 150 kB
-
https://huggingface.co/spaces/chen470/drl-trading-bot-dev2/resolve/main/src/ui/app.py
- Command line
-
hf download hf://spaces/chen470/drl-trading-bot-dev2/src/ui/app.py
-
curl -L -o app.py https://huggingface.co/spaces/chen470/drl-trading-bot-dev2/resolve/main/src/ui/app.py
150 kB
| """ | |
| DRL Trading System - Streamlit Dashboard | |
| Real-time monitoring with TradingView charts, WebSocket live data, and timeframe switching. | |
| """ | |
| import streamlit as st | |
| import streamlit.components.v1 as components | |
| import pandas as pd | |
| import numpy as np | |
| import json | |
| import time | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| import sys | |
| import os | |
| # Add project root to path | |
| project_root = Path(__file__).parent.parent.parent | |
| sys.path.insert(0, str(project_root)) | |
| try: | |
| from src.backtest.data_loader import DataLoader, BinanceHistoricalDataFetcher | |
| _HAS_BACKTEST = True | |
| except ImportError: | |
| _HAS_BACKTEST = False | |
| from src.data.storage import get_storage, JsonFileStorage | |
| # True when running as a client-only HF Space (API_SERVER_URL points at remote server) | |
| IS_CLIENT_MODE = bool(os.environ.get('API_SERVER_URL')) | |
| # API server URL โ configurable for remote (local server) or local deployments | |
| def get_api_url() -> str: | |
| """Return the base URL of the Flask API server. | |
| Set API_SERVER_URL env var to point at a remote local server | |
| (e.g. https://abc123.ngrok.io). Defaults to localhost:5001. | |
| """ | |
| return os.environ.get('API_SERVER_URL', 'http://127.0.0.1:5001').rstrip('/') | |
| # Page configuration โ MUST be first Streamlit command | |
| st.set_page_config( | |
| page_title="DRL Trading System", | |
| page_icon="๐ค", | |
| layout="wide", | |
| initial_sidebar_state="expanded", | |
| ) | |
| # Initialize storage with caching (must be after set_page_config) | |
| def get_app_storage(): | |
| return get_storage() | |
| storage = get_app_storage() | |
| # Custom CSS โ Premium Dark Theme (matches Live Portfolio aesthetic) | |
| st.markdown(""" | |
| <style> | |
| /* โโโ Foundation โโโ */ | |
| .stApp { | |
| background-color: #0d1117; | |
| color: #e6edf3; | |
| } | |
| /* โโโ Sidebar โโโ */ | |
| div[data-testid="stSidebarContent"] { | |
| background-color: #0d1117; | |
| border-right: 1px solid #21262d; | |
| } | |
| div[data-testid="stSidebarContent"] .stMarkdown h3 { | |
| color: #8b949e; | |
| font-size: 14px; | |
| font-weight: 600; | |
| letter-spacing: 0.5px; | |
| } | |
| /* โโโ Metric Cards (native st.metric) โโโ */ | |
| div[data-testid="stMetric"] { | |
| background: #151b23; | |
| border: 1px solid #21262d; | |
| border-radius: 8px; | |
| padding: 16px 18px; | |
| } | |
| div[data-testid="stMetric"] label { | |
| color: #8b949e !important; | |
| font-size: 11px !important; | |
| text-transform: uppercase; | |
| letter-spacing: 0.8px; | |
| } | |
| div[data-testid="stMetric"] div[data-testid="stMetricValue"] { | |
| color: #fff !important; | |
| font-weight: 700; | |
| } | |
| div[data-testid="stMetricDelta"] svg { display: none; } | |
| /* โโโ Custom metric-card class (sidebar panels) โโโ */ | |
| .metric-card { | |
| background: #151b23; | |
| border: 1px solid #21262d; | |
| border-radius: 8px; | |
| padding: 16px 18px; | |
| margin-bottom: 12px; | |
| } | |
| .metric-label { | |
| color: #8b949e; | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.8px; | |
| margin-bottom: 6px; | |
| } | |
| .metric-value { | |
| font-size: 24px; | |
| font-weight: 700; | |
| color: #fff; | |
| } | |
| .metric-delta-positive { color: #00e676; } | |
| .metric-delta-negative { color: #ff5252; } | |
| /* โโโ Tabs โโโ */ | |
| .stTabs [data-baseweb="tab-list"] { | |
| gap: 8px; | |
| border-bottom: 1px solid #21262d; | |
| } | |
| .stTabs [data-baseweb="tab"] { | |
| background-color: transparent; | |
| color: #8b949e; | |
| border-radius: 6px 6px 0 0; | |
| padding: 8px 16px; | |
| font-size: 13px; | |
| } | |
| .stTabs [data-baseweb="tab"]:hover { | |
| color: #e6edf3; | |
| background-color: rgba(255,255,255,0.04); | |
| } | |
| .stTabs [aria-selected="true"] { | |
| color: #fff !important; | |
| font-weight: 600; | |
| border-bottom: 2px solid #00e676; | |
| } | |
| .stTabs [data-baseweb="tab-highlight"] { | |
| background-color: #00e676 !important; | |
| } | |
| .stTabs [data-baseweb="tab-border"] { | |
| display: none; | |
| } | |
| /* โโโ Buttons โโโ */ | |
| .stButton > button { | |
| background: #151b23; | |
| border: 1px solid #21262d; | |
| color: #e6edf3; | |
| border-radius: 6px; | |
| font-weight: 500; | |
| transition: all 0.15s ease; | |
| } | |
| .stButton > button:hover { | |
| background: #1c2333; | |
| border-color: #388bfd; | |
| color: #fff; | |
| } | |
| .stButton > button[kind="primary"], | |
| .stButton > button[data-testid="stBaseButton-primary"] { | |
| background: #1a6b3c; | |
| border-color: #1a6b3c; | |
| color: #00e676; | |
| } | |
| .stButton > button[kind="primary"]:hover, | |
| .stButton > button[data-testid="stBaseButton-primary"]:hover { | |
| background: #217a45; | |
| border-color: #00e676; | |
| } | |
| /* โโโ Inputs, Selects, Date Pickers โโโ */ | |
| div[data-baseweb="select"] > div, | |
| div[data-baseweb="input"] > div, | |
| .stDateInput > div > div > input, | |
| .stTextInput > div > div > input, | |
| .stSelectbox > div > div { | |
| background-color: #151b23 !important; | |
| border-color: #21262d !important; | |
| color: #e6edf3 !important; | |
| } | |
| /* โโโ Text Areas โโโ */ | |
| .stTextArea textarea { | |
| background-color: #151b23 !important; | |
| border-color: #21262d !important; | |
| color: #e6edf3 !important; | |
| border-radius: 6px; | |
| } | |
| /* โโโ Code Blocks โโโ */ | |
| .stCodeBlock, code, pre { | |
| background-color: #151b23 !important; | |
| border: 1px solid #21262d; | |
| border-radius: 6px; | |
| } | |
| /* โโโ Expanders โโโ */ | |
| .streamlit-expanderHeader { | |
| background: #151b23; | |
| border: 1px solid #21262d; | |
| border-radius: 6px; | |
| color: #e6edf3; | |
| } | |
| details { | |
| background: #151b23; | |
| border: 1px solid #21262d; | |
| border-radius: 8px; | |
| } | |
| /* โโโ Dividers โโโ */ | |
| hr { | |
| border-color: #21262d !important; | |
| } | |
| /* โโโ Checkboxes & Toggles โโโ */ | |
| .stCheckbox label span { | |
| color: #8b949e; | |
| } | |
| /* โโโ Dataframes โโโ */ | |
| .stDataFrame { | |
| border: 1px solid #21262d; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| } | |
| /* โโโ Alerts โโโ */ | |
| .stAlert { | |
| background: #151b23; | |
| border: 1px solid #21262d; | |
| border-radius: 8px; | |
| } | |
| /* โโโ Caption โโโ */ | |
| .stCaption { | |
| color: #8b949e !important; | |
| } | |
| /* โโโ Scrollbar โโโ */ | |
| ::-webkit-scrollbar { | |
| width: 6px; | |
| height: 6px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #0d1117; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #21262d; | |
| border-radius: 3px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: #30363d; | |
| } | |
| /* โโโ Hide defaults โโโ */ | |
| #MainMenu {visibility: hidden;} | |
| footer {visibility: hidden;} | |
| /* โโโ Timeframe buttons (custom) โโโ */ | |
| .timeframe-btn { | |
| background: #151b23; | |
| border: 1px solid #21262d; | |
| color: #8b949e; | |
| padding: 5px 12px; | |
| margin: 2px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 12px; | |
| } | |
| .timeframe-btn.active { | |
| background: #1a6b3c; | |
| color: #00e676; | |
| border-color: #1a6b3c; | |
| } | |
| .timeframe-btn:hover { | |
| background: #1c2333; | |
| border-color: #388bfd; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # Timeframe options | |
| TIMEFRAMES = { | |
| '1m': {'binance': '1m', 'label': '1m', 'days': 1}, | |
| '5m': {'binance': '5m', 'label': '5m', 'days': 2}, | |
| '15m': {'binance': '15m', 'label': '15m', 'days': 5}, | |
| '30m': {'binance': '30m', 'label': '30m', 'days': 7}, | |
| '1h': {'binance': '1h', 'label': '1H', 'days': 14}, | |
| '4h': {'binance': '4h', 'label': '4H', 'days': 30}, | |
| '1d': {'binance': '1d', 'label': '1D', 'days': 180}, | |
| } | |
| def load_trading_log(symbol: str = None) -> list: | |
| """Load real trading data โ via API in client mode, local storage otherwise.""" | |
| import requests as _r | |
| def _filter_by_symbol(trades, symbol): | |
| if not symbol: | |
| return trades | |
| s1 = symbol.replace('/', '').upper() | |
| return [t for t in trades if s1 in t.get('symbol', t.get('asset', '')).replace('/', '').upper() | |
| or t.get('symbol', t.get('asset', '')).replace('/', '').upper() in s1] | |
| if IS_CLIENT_MODE: | |
| try: | |
| resp = _r.get(f'{get_api_url()}/api/trades', timeout=10) | |
| if resp.ok: | |
| return _filter_by_symbol(resp.json(), symbol) | |
| except Exception: | |
| pass | |
| return [] | |
| # Local storage mode | |
| try: | |
| all_trades = storage.get_trades(limit=1000) | |
| # Filter by reset_timestamp if available (hide pre-reset trades) | |
| try: | |
| state = storage.load_state() | |
| reset_ts = state.get('reset_timestamp') | |
| if reset_ts: | |
| reset_dt = datetime.fromisoformat(reset_ts.replace('Z', '+00:00')) | |
| filtered_by_time = [] | |
| for trade in all_trades: | |
| try: | |
| trade_ts = trade.get('timestamp', '') | |
| trade_dt = datetime.fromisoformat(trade_ts.replace('Z', '+00:00')) | |
| if trade_dt >= reset_dt: | |
| filtered_by_time.append(trade) | |
| except: | |
| filtered_by_time.append(trade) | |
| all_trades = filtered_by_time | |
| except: | |
| pass | |
| return _filter_by_symbol(all_trades, symbol) | |
| except Exception as e: | |
| st.error(f"Failed to load trades: {e}") | |
| return [] | |
| def check_pid_running(pid: int) -> bool: | |
| """Check if a process with the given PID is running.""" | |
| if not pid: | |
| return False | |
| try: | |
| os.kill(int(pid), 0) | |
| return True | |
| except OSError: | |
| return False | |
| def check_process_running(process_name_substr: str) -> bool: | |
| """Check if a process is running by parsing ps aux output.""" | |
| try: | |
| import subprocess | |
| # Run ps aux | |
| res = subprocess.run(['ps', 'aux'], capture_output=True, text=True) | |
| if res.returncode != 0: | |
| return False | |
| # Check if process name is in output | |
| for line in res.stdout.splitlines(): | |
| if process_name_substr in line and "grep" not in line: | |
| return True | |
| return False | |
| except: | |
| return False | |
| def get_last_logs(log_path: Path, lines: int = 50) -> str: | |
| """Read last N lines of a log file.""" | |
| if not log_path.exists(): | |
| return f"Log file not found: {log_path}" | |
| try: | |
| # Use simple file reading for portability | |
| content = log_path.read_text().splitlines() | |
| return "\n".join(content[-lines:]) | |
| except Exception as e: | |
| return f"Error reading logs: {e}" | |
| def get_trading_state(selected_asset: str = None) -> dict: | |
| """Get current trading state โ via API in client mode, local storage otherwise.""" | |
| import requests as _r | |
| _empty = {'balance': 0, 'realized_pnl': 0, 'multi_asset': True, | |
| 'whale_alerts': [], 'assets': {}, 'available_assets': []} | |
| if IS_CLIENT_MODE: | |
| try: | |
| state_resp = _r.get(f'{get_api_url()}/api/state', timeout=10) | |
| state = state_resp.json() if state_resp.ok else {} | |
| trades_resp = _r.get(f'{get_api_url()}/api/trades', timeout=10) | |
| all_trades = trades_resp.json() if trades_resp.ok else [] | |
| raw_assets = state.get('assets', {}) | |
| if selected_asset: | |
| s1 = selected_asset.replace('/', '').upper() | |
| asset_trades = [t for t in all_trades | |
| if s1 in t.get('symbol', t.get('asset', '')).replace('/', '').upper()] | |
| asset_state = raw_assets.get(selected_asset, raw_assets.get(s1, {})) | |
| return { | |
| 'balance': state.get('balance', state.get('total_balance', 0)), | |
| 'total_balance': state.get('total_balance', state.get('balance', 0)), | |
| 'asset_balance': asset_state.get('balance', 0), | |
| 'position': asset_state.get('position', 0), | |
| 'realized_pnl': state.get('realized_pnl', state.get('total_pnl', 0)), | |
| 'total_pnl': state.get('total_pnl', state.get('realized_pnl', 0)), | |
| 'asset_pnl': asset_state.get('pnl', 0), | |
| 'trades': asset_trades, | |
| 'total_trades': len([t for t in asset_trades if 'OPEN' in t.get('action', '')]), | |
| 'position_price': asset_state.get('price', 0), | |
| 'position_size_units': asset_state.get('units', 0), | |
| 'price': asset_state.get('price', 0), | |
| 'timestamp': state.get('timestamp'), | |
| 'multi_asset': True, | |
| 'available_assets': state.get('available_assets') or list(raw_assets.keys()) or ['BTCUSDT'], | |
| 'whale_alerts': state.get('whale_alerts', []), | |
| 'raw_state': state, | |
| 'assets': raw_assets, | |
| 'sl': asset_state.get('sl', 0), | |
| 'tp': asset_state.get('tp', 0), | |
| } | |
| else: | |
| return { | |
| 'balance': state.get('balance', state.get('total_balance', 0)), | |
| 'total_balance': state.get('total_balance', state.get('balance', 0)), | |
| 'realized_pnl': state.get('realized_pnl', state.get('total_pnl', 0)), | |
| 'total_pnl': state.get('total_pnl', state.get('realized_pnl', 0)), | |
| 'multi_asset': True, | |
| 'available_assets': state.get('available_assets') or list(raw_assets.keys()) or ['BTCUSDT'], | |
| 'whale_alerts': state.get('whale_alerts', []), | |
| 'raw_state': state, | |
| 'assets': raw_assets, | |
| } | |
| except Exception: | |
| pass | |
| return _empty | |
| # โโ Local storage mode (server-side only) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| try: | |
| state = storage.load_state() | |
| if not state: | |
| return {**_empty} | |
| # If specific asset selected, return its details mixed with global | |
| if selected_asset and 'assets' in state and selected_asset in state['assets']: | |
| asset_state = state['assets'][selected_asset] | |
| asset_trades = load_trading_log(symbol=selected_asset) | |
| all_trades = load_trading_log() | |
| realized_pnl = sum(t.get('pnl', 0) for t in all_trades | |
| if 'CLOSE' in t.get('action', '').upper() or 'EXIT' in t.get('action', '').upper()) | |
| raw_assets = state.get('assets', {}) | |
| open_pnl = sum(a.get('pnl', 0) for a in raw_assets.values() if a.get('position', 0) != 0) | |
| total_pnl = realized_pnl + open_pnl | |
| total_balance = state.get('total_balance', state.get('balance')) | |
| whale_alerts = _load_whale_alerts_local() | |
| state['whale_alerts'] = whale_alerts | |
| return { | |
| 'balance': total_balance, | |
| 'total_balance': total_balance, | |
| 'asset_balance': asset_state.get('balance', 0), | |
| 'position': asset_state.get('position', 0), | |
| 'realized_pnl': total_pnl, | |
| 'total_pnl': total_pnl, | |
| 'asset_pnl': asset_state.get('pnl', 0), | |
| 'trades': asset_trades, | |
| 'total_trades': len([t for t in asset_trades if 'OPEN' in t.get('action', '')]), | |
| 'position_price': asset_state.get('price', 0), | |
| 'position_size_units': asset_state.get('units', 0), | |
| 'price': asset_state.get('price', 0), | |
| 'timestamp': state.get('timestamp'), | |
| 'multi_asset': True, | |
| 'available_assets': state.get('available_assets') or list(state.get('assets', {}).keys()) or ['BTCUSDT'], | |
| 'whale_alerts': whale_alerts, | |
| 'raw_state': state, | |
| 'assets': raw_assets, | |
| 'sl': asset_state.get('sl', 0), | |
| 'tp': asset_state.get('tp', 0), | |
| } | |
| # Global view | |
| all_trades = load_trading_log() | |
| realized_pnl = sum(t.get('pnl', 0) for t in all_trades | |
| if 'CLOSE' in t.get('action', '').upper() or 'EXIT' in t.get('action', '').upper()) | |
| raw_assets = state.get('assets', {}) | |
| open_pnl = sum(a.get('pnl', 0) for a in raw_assets.values() if a.get('position', 0) != 0) | |
| total_pnl = realized_pnl + open_pnl | |
| total_balance = state.get('total_balance', state.get('balance')) | |
| whale_alerts = _load_whale_alerts_local() | |
| state['whale_alerts'] = whale_alerts | |
| return { | |
| 'balance': total_balance, | |
| 'total_balance': total_balance, | |
| 'realized_pnl': total_pnl, | |
| 'total_pnl': total_pnl, | |
| 'multi_asset': True, | |
| 'available_assets': state.get('available_assets') or list(state.get('assets', {}).keys()) or ['BTCUSDT'], | |
| 'whale_alerts': whale_alerts, | |
| 'raw_state': state, | |
| 'assets': raw_assets, | |
| } | |
| except Exception: | |
| return {**_empty} | |
| def _load_whale_alerts_local() -> list: | |
| """Load whale alerts from local wallet files (server-side only). Returns [] on HF.""" | |
| import json as _json, time as _time | |
| whale_alerts = [] | |
| try: | |
| whale_dir = Path(__file__).parent.parent.parent / "data" / "whale_wallets" | |
| if not whale_dir.exists(): | |
| return [] | |
| try: | |
| from src.features.whale_wallet_registry import get_wallets_by_chain as _gwbc | |
| except ImportError: | |
| return [] | |
| for chain_dir in whale_dir.iterdir(): | |
| if not chain_dir.is_dir(): | |
| continue | |
| chain = chain_dir.name.upper() | |
| for wallet_file in chain_dir.glob("*.json"): | |
| try: | |
| with open(wallet_file, "r") as f: | |
| w_data = _json.load(f) | |
| addr = w_data.get("address", "") | |
| chain_wallets = _gwbc(chain) | |
| wallet = next((w for w in chain_wallets if w.address.lower() == addr.lower()), None) | |
| w_label = wallet.label if wallet else f"Unknown {chain} Whale" | |
| w_type = wallet.wallet_type if wallet else "unknown" | |
| price_map = {'BTC': 70000, 'ETH': 3500, 'SOL': 150, 'XRP': 0.6} | |
| for tx in w_data.get("transactions", [])[-10:]: | |
| val = float(tx.get('value', 0)) | |
| if val * price_map.get(chain, 1) > 50000: | |
| whale_alerts.append({ | |
| 'chain': chain, 'value': val, 'currency': tx.get('asset', chain), | |
| 'timestamp': tx.get('timestamp', int(_time.time())), | |
| 'link': tx.get('link', '#'), | |
| 'wallet_label': w_label, 'wallet_type': w_type, 'wallet_address': addr, | |
| }) | |
| except Exception: | |
| pass | |
| whale_alerts = sorted(whale_alerts, key=lambda x: x.get('timestamp', 0), reverse=True)[:50] | |
| except Exception: | |
| pass | |
| return whale_alerts | |
| def create_tradingview_chart_with_websocket(df: pd.DataFrame, trades: list, timeframe: str = '1h', symbol: str = 'BTC/USDT') -> str: | |
| """Create TradingView Lightweight Charts HTML with WebSocket live updates.""" | |
| if df.empty: | |
| return "<div style='color: #888; text-align: center; padding: 50px;'>No market data available</div>" | |
| # Convert data to the format expected by Lightweight Charts | |
| candlestick_data = [] | |
| for idx, row in df.iterrows(): | |
| candlestick_data.append({ | |
| 'time': int(idx.timestamp()), | |
| 'open': float(row['open']), | |
| 'high': float(row['high']), | |
| 'low': float(row['low']), | |
| 'close': float(row['close']), | |
| }) | |
| volume_data = [] | |
| for idx, row in df.iterrows(): | |
| color = '#26a69a80' if row['close'] >= row['open'] else '#ef535080' | |
| volume_data.append({ | |
| 'time': int(idx.timestamp()), | |
| 'value': float(row['volume']), | |
| 'color': color, | |
| }) | |
| # Create markers for trades | |
| markers = [] | |
| for trade in trades: | |
| if 'price' in trade and 'timestamp' in trade: | |
| try: | |
| ts = datetime.fromisoformat(trade['timestamp'].replace('Z', '+00:00')) | |
| action = trade.get('action', '') | |
| reason = trade.get('reason', 'model') | |
| if 'OPEN_LONG' in action: | |
| markers.append({ | |
| 'time': int(ts.timestamp()), | |
| 'position': 'belowBar', | |
| 'color': '#26a69a', | |
| 'shape': 'arrowUp', | |
| 'text': 'LONG', | |
| }) | |
| elif 'OPEN_SHORT' in action: | |
| markers.append({ | |
| 'time': int(ts.timestamp()), | |
| 'position': 'aboveBar', | |
| 'color': '#ef5350', | |
| 'shape': 'arrowDown', | |
| 'text': 'SHORT', | |
| }) | |
| elif 'CLOSE' in action: | |
| # Differentiate exit types | |
| if reason == 'stop_loss': | |
| markers.append({ | |
| 'time': int(ts.timestamp()), | |
| 'position': 'aboveBar', | |
| 'color': '#ff4444', | |
| 'shape': 'square', | |
| 'text': 'SL', | |
| }) | |
| elif reason == 'take_profit': | |
| markers.append({ | |
| 'time': int(ts.timestamp()), | |
| 'position': 'aboveBar', | |
| 'color': '#00ff88', | |
| 'shape': 'square', | |
| 'text': 'TP', | |
| }) | |
| else: | |
| markers.append({ | |
| 'time': int(ts.timestamp()), | |
| 'position': 'aboveBar', | |
| 'color': '#ffc107', | |
| 'shape': 'circle', | |
| 'text': 'EXIT', | |
| }) | |
| except: | |
| pass | |
| # Get OHLC for display | |
| last_candle = df.iloc[-1] | |
| tf_label = TIMEFRAMES.get(timeframe, {}).get('label', timeframe.upper()) | |
| # WebSocket stream name for Binance | |
| # Symbol needs to be lowercase and without / | |
| clean_symbol = symbol.replace('/', '').lower() | |
| ws_stream = f"{clean_symbol}@kline_{timeframe}" | |
| chart_id = f"chart_{clean_symbol}_{timeframe}" | |
| html = f""" | |
| <div id="tv-chart-container" style="width: 100%; height: 550px; position: relative; background: #131722;"> | |
| <!-- OHLC and Price Display --> | |
| <div id="chart-header" style=" | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| z-index: 100; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif; | |
| "> | |
| <div style="display: flex; align-items: center; gap: 15px;"> | |
| <span style="color: white; font-size: 16px; font-weight: bold;">{symbol}</span> | |
| <span style="color: #888; font-size: 13px;">{tf_label}</span> | |
| <span id="live-indicator" style=" | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| color: #26a69a; | |
| font-size: 11px; | |
| "> | |
| <span style=" | |
| width: 8px; | |
| height: 8px; | |
| background: #26a69a; | |
| border-radius: 50%; | |
| animation: pulse 2s infinite; | |
| "></span> | |
| LIVE | |
| </span> | |
| </div> | |
| <div id="ohlc-display" style=" | |
| margin-top: 5px; | |
| font-size: 12px; | |
| color: #d1d4dc; | |
| "> | |
| <span style="color: #888;">O</span> <span id="o-val">{last_candle['open']:.2f}</span> | |
| <span style="color: #888; margin-left: 10px;">H</span> <span id="h-val">{last_candle['high']:.2f}</span> | |
| <span style="color: #888; margin-left: 10px;">L</span> <span id="l-val">{last_candle['low']:.2f}</span> | |
| <span style="color: #888; margin-left: 10px;">C</span> <span id="c-val">{last_candle['close']:.2f}</span> | |
| <span id="change-val" style="margin-left: 15px;"></span> | |
| </div> | |
| </div> | |
| <!-- Current Price Label --> | |
| <div id="current-price" style=" | |
| position: absolute; | |
| top: 10px; | |
| right: 10px; | |
| z-index: 100; | |
| text-align: right; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif; | |
| "> | |
| <div id="price-value" style="font-size: 28px; font-weight: bold; color: white;"> | |
| ${last_candle['close']:,.2f} | |
| </div> | |
| <div id="price-change" style="font-size: 14px; color: #26a69a;"></div> | |
| </div> | |
| <div id="{chart_id}" style="width: 100%; height: 550px;"></div> | |
| </div> | |
| <style> | |
| @keyframes pulse {{ | |
| 0% {{ opacity: 1; }} | |
| 50% {{ opacity: 0.5; }} | |
| 100% {{ opacity: 1; }} | |
| }} | |
| </style> | |
| <script src="https://unpkg.com/lightweight-charts@4.1.0/dist/lightweight-charts.standalone.production.js"></script> | |
| <script> | |
| (function() {{ | |
| const container = document.getElementById('{chart_id}'); | |
| const chart = LightweightCharts.createChart(container, {{ | |
| width: container.clientWidth, | |
| height: 550, | |
| layout: {{ | |
| background: {{ type: 'solid', color: '#131722' }}, | |
| textColor: '#d1d4dc', | |
| }}, | |
| grid: {{ | |
| vertLines: {{ color: '#1e222d' }}, | |
| horzLines: {{ color: '#1e222d' }}, | |
| }}, | |
| crosshair: {{ | |
| mode: LightweightCharts.CrosshairMode.Normal, | |
| vertLine: {{ | |
| color: '#758696', | |
| width: 1, | |
| style: LightweightCharts.LineStyle.Dashed, | |
| labelBackgroundColor: '#2962FF', | |
| }}, | |
| horzLine: {{ | |
| color: '#758696', | |
| width: 1, | |
| style: LightweightCharts.LineStyle.Dashed, | |
| labelBackgroundColor: '#2962FF', | |
| }}, | |
| }}, | |
| rightPriceScale: {{ | |
| borderColor: '#2a2e39', | |
| scaleMargins: {{ | |
| top: 0.1, | |
| bottom: 0.2, | |
| }}, | |
| }}, | |
| timeScale: {{ | |
| borderColor: '#2a2e39', | |
| timeVisible: true, | |
| secondsVisible: false, | |
| }}, | |
| }}); | |
| // Candlestick series | |
| const candlestickSeries = chart.addCandlestickSeries({{ | |
| upColor: '#26a69a', | |
| downColor: '#ef5350', | |
| borderDownColor: '#ef5350', | |
| borderUpColor: '#26a69a', | |
| wickDownColor: '#ef5350', | |
| wickUpColor: '#26a69a', | |
| }}); | |
| let candleData = {json.dumps(candlestick_data)}; | |
| candlestickSeries.setData(candleData); | |
| // Add markers for trades | |
| const markers = {json.dumps(markers)}; | |
| if (markers.length > 0) {{ | |
| candlestickSeries.setMarkers(markers); | |
| }} | |
| // Volume series | |
| const volumeSeries = chart.addHistogramSeries({{ | |
| priceFormat: {{ | |
| type: 'volume', | |
| }}, | |
| priceScaleId: 'volume', | |
| }}); | |
| chart.priceScale('volume').applyOptions({{ | |
| scaleMargins: {{ | |
| top: 0.85, | |
| bottom: 0, | |
| }}, | |
| }}); | |
| let volumeData = {json.dumps(volume_data)}; | |
| volumeSeries.setData(volumeData); | |
| // Track whether user is hovering over a specific candle | |
| let isHoveringCandle = false; | |
| let hoverTimeout = null; | |
| // Update OHLC on crosshair move | |
| chart.subscribeCrosshairMove((param) => {{ | |
| if (param.time) {{ | |
| const data = param.seriesData.get(candlestickSeries); | |
| if (data) {{ | |
| // User is hovering over a candle | |
| isHoveringCandle = true; | |
| // Clear any existing timeout | |
| if (hoverTimeout) {{ | |
| clearTimeout(hoverTimeout); | |
| }} | |
| // Reset hover flag after 2 seconds of inactivity | |
| hoverTimeout = setTimeout(() => {{ | |
| isHoveringCandle = false; | |
| }}, 2000); | |
| document.getElementById('o-val').textContent = data.open.toFixed(2); | |
| document.getElementById('h-val').textContent = data.high.toFixed(2); | |
| document.getElementById('l-val').textContent = data.low.toFixed(2); | |
| document.getElementById('c-val').textContent = data.close.toFixed(2); | |
| const change = ((data.close - data.open) / data.open * 100).toFixed(2); | |
| const changeEl = document.getElementById('change-val'); | |
| changeEl.textContent = (change >= 0 ? '+' : '') + change + '%'; | |
| changeEl.style.color = change >= 0 ? '#26a69a' : '#ef5350'; | |
| }} | |
| }} else {{ | |
| // User moved cursor away from chart | |
| isHoveringCandle = false; | |
| if (hoverTimeout) {{ | |
| clearTimeout(hoverTimeout); | |
| hoverTimeout = null; | |
| }} | |
| }} | |
| }}); | |
| // Fit content | |
| chart.timeScale().fitContent(); | |
| // Resize handler | |
| new ResizeObserver(entries => {{ | |
| chart.applyOptions({{ width: entries[0].contentRect.width }}); | |
| }}).observe(container); | |
| // WebSocket for live updates | |
| let ws; | |
| let reconnectInterval = 5000; | |
| let lastCandle = candleData[candleData.length - 1]; | |
| function connectWebSocket() {{ | |
| ws = new WebSocket('wss://data-stream.binance.vision/ws/{ws_stream}'); | |
| ws.onopen = function() {{ | |
| console.log('WebSocket connected to Binance Vision cluster successfully'); | |
| document.getElementById('live-indicator').style.display = 'inline-flex'; | |
| }}; | |
| ws.onclose = function(event) {{ | |
| console.log('WebSocket closed: code=' + event.code + ', reason=' + event.reason); | |
| document.getElementById('live-indicator').style.display = 'none'; | |
| setTimeout(connectWebSocket, reconnectInterval); | |
| }}; | |
| ws.onerror = function(err) {{ | |
| console.error('WebSocket encountered an error:', err); | |
| ws.close(); | |
| }}; | |
| ws.onmessage = function(event) {{ | |
| const data = JSON.parse(event.data); | |
| const kline = data.k; | |
| const candle = {{ | |
| time: Math.floor(kline.t / 1000), | |
| open: parseFloat(kline.o), | |
| high: parseFloat(kline.h), | |
| low: parseFloat(kline.l), | |
| close: parseFloat(kline.c), | |
| }}; | |
| // Update or add candle | |
| candlestickSeries.update(candle); | |
| // Update volume | |
| const volColor = candle.close >= candle.open ? '#26a69a80' : '#ef535080'; | |
| volumeSeries.update({{ | |
| time: candle.time, | |
| value: parseFloat(kline.v), | |
| color: volColor, | |
| }}); | |
| // Update price display | |
| const priceEl = document.getElementById('price-value'); | |
| const changeEl = document.getElementById('price-change'); | |
| priceEl.textContent = '$' + candle.close.toLocaleString('en-US', {{ | |
| minimumFractionDigits: 2, | |
| maximumFractionDigits: 2 | |
| }}); | |
| // Calculate 24h change (approximation from last candle) | |
| if (lastCandle) {{ | |
| const change = ((candle.close - lastCandle.open) / lastCandle.open * 100); | |
| changeEl.textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%'; | |
| changeEl.style.color = change >= 0 ? '#26a69a' : '#ef5350'; | |
| priceEl.style.color = change >= 0 ? '#26a69a' : '#ef5350'; | |
| }} | |
| // Share price with sidebar via localStorage | |
| localStorage.setItem('{clean_symbol}_live_price', candle.close.toFixed(2)); | |
| // Update OHLC display for current candle (only if user is not hovering over a historical candle) | |
| if (!isHoveringCandle) {{ | |
| document.getElementById('o-val').textContent = candle.open.toFixed(2); | |
| document.getElementById('h-val').textContent = candle.high.toFixed(2); | |
| document.getElementById('l-val').textContent = candle.low.toFixed(2); | |
| document.getElementById('c-val').textContent = candle.close.toFixed(2); | |
| // Update change display as well | |
| const change = ((candle.close - candle.open) / candle.open * 100).toFixed(2); | |
| const changeEl = document.getElementById('change-val'); | |
| changeEl.textContent = (change >= 0 ? '+' : '') + change + '%'; | |
| changeEl.style.color = change >= 0 ? '#26a69a' : '#ef5350'; | |
| }} | |
| }}; | |
| }} | |
| connectWebSocket(); | |
| // Cleanup on page unload | |
| window.addEventListener('beforeunload', function() {{ | |
| if (ws) ws.close(); | |
| }}); | |
| }})(); | |
| </script> | |
| """ | |
| return html | |
| def render_position_card(state: dict, current_price: float, symbol: str = 'BTC/USDT'): | |
| """Render current position card.""" | |
| position = state.get('position', 0) | |
| clean_symbol = symbol.replace('/', '').lower() # For localStorage key | |
| # SL/TP percentages (match live trading config) | |
| SL_PCT = 0.015 # 1.5% (matches live_trading.py) | |
| TP_PCT = 0.025 # 2.5% (matches live_trading.py) | |
| if position == 0: | |
| st.markdown(f""" | |
| <div class="metric-card" style="text-align: center;"> | |
| <div class="metric-label">Current Position</div> | |
| <div style="font-size: 24px; color: #555; margin-top: 10px;">No Position (FLAT)</div> | |
| <div style="font-size: 12px; color: #888; margin-top: 5px;">Current Price: ${current_price:,.2f}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| # DEBUG: Inspect state to find units key | |
| # st.write(f"Debug State for P&L: {state}") | |
| # logger.info(f"Debug State for P&L: {state}") | |
| pass | |
| is_long = position == 1 | |
| color = "#26a69a" if is_long else "#ef5350" | |
| side = "LONG" if is_long else "SHORT" | |
| icon = "๐" if is_long else "๐" | |
| # Get entry price from state - check multiple field names for compatibility | |
| # Priority: position_price > entry_price > price (last trade price fallback) | |
| entry_price = state.get('position_price') or state.get('entry_price') or state.get('price', current_price) | |
| # Validation: Entry price must be reasonable (within 50% of current price) | |
| if entry_price > 0 and current_price > 0: | |
| price_diff_pct = abs(entry_price - current_price) / current_price | |
| if price_diff_pct > 0.5: # More than 50% difference is suspicious | |
| logger.warning(f"Entry price ${entry_price:,.2f} is {price_diff_pct*100:.1f}% different from current ${current_price:,.2f} - using current price") | |
| entry_price = current_price | |
| # Get SL/TP from state (preferred) or calculate | |
| sl_price = state.get('sl', 0) | |
| tp_price = state.get('tp', 0) | |
| if sl_price == 0 or tp_price == 0: | |
| # Fallback to estimation | |
| if is_long: | |
| sl_price = entry_price * (1 - SL_PCT) | |
| tp_price = entry_price * (1 + TP_PCT) | |
| else: | |
| sl_price = entry_price * (1 + SL_PCT) | |
| tp_price = entry_price * (1 - TP_PCT) | |
| # Calculate Unrealized PnL | |
| units = state.get('position_size_units', state.get('position_units', state.get('units', 0))) | |
| if is_long: | |
| unrealized_pnl = (current_price - entry_price) * units | |
| else: | |
| unrealized_pnl = (entry_price - current_price) * units | |
| pnl_color = "#26a69a" if unrealized_pnl >= 0 else "#ef5350" | |
| pnl_sign = "+" if unrealized_pnl >= 0 else "" | |
| st.markdown(f""" | |
| <div class="metric-card" style="border: 1px solid {color};"> | |
| <div style="display: flex; justify-content: space-between; align-items: center;"> | |
| <span class="metric-label">Current Position</span> | |
| <span style=" | |
| background: {color}; | |
| padding: 4px 12px; | |
| border-radius: 4px; | |
| color: white; | |
| font-weight: bold; | |
| font-size: 12px; | |
| ">{icon} {side}</span> | |
| </div> | |
| <div style="margin-top: 15px;"> | |
| <div style="display: flex; justify-content: space-between; margin-bottom: 5px;"> | |
| <span style="color: #888;">Entry Price:</span> | |
| <span style="color: white;">${entry_price:,.2f}</span> | |
| </div> | |
| <div style="display: flex; justify-content: space-between; margin-bottom: 5px;"> | |
| <span style="color: #888;">Current Price:</span> | |
| <span id="sidebar-current-price" style="color: white;">${current_price:,.2f}</span> | |
| </div> | |
| <div style="display: flex; justify-content: space-between; margin-bottom: 5px;"> | |
| <span style="color: #888;">Unrealized P&L:</span> | |
| <span id="sidebar-pnl" style="color: {pnl_color}; font-weight: bold;">{pnl_sign}${unrealized_pnl:,.2f}</span> | |
| </div> | |
| <div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #333;"> | |
| <div style="display: flex; justify-content: space-between; margin-bottom: 5px;"> | |
| <span style="color: #ef5350;">๐ Stop Loss:</span> | |
| <span style="color: #ef5350;">${sl_price:,.2f}</span> | |
| </div> | |
| <div style="display: flex; justify-content: space-between;"> | |
| <span style="color: #26a69a;">๐ฏ Take Profit:</span> | |
| <span style="color: #26a69a;">${tp_price:,.2f}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // Real-time price update from WebSocket via localStorage | |
| const entryPrice = {entry_price}; | |
| const positionUnits = {state.get('position_size_units', 0)}; | |
| const isLong = {'true' if is_long else 'false'}; | |
| function updateSidebarPrice() {{ | |
| const livePrice = parseFloat(localStorage.getItem('{clean_symbol}_live_price')); | |
| if (livePrice && livePrice > 0) {{ | |
| // Update current price | |
| const priceEl = document.getElementById('sidebar-current-price'); | |
| if (priceEl) priceEl.textContent = '$' + livePrice.toLocaleString('en-US', {{minimumFractionDigits: 2}}); | |
| // Update unrealized P&L | |
| let pnl = isLong ? (livePrice - entryPrice) * positionUnits : (entryPrice - livePrice) * positionUnits; | |
| const pnlEl = document.getElementById('sidebar-pnl'); | |
| if (pnlEl) {{ | |
| pnlEl.textContent = (pnl >= 0 ? '+' : '') + '$' + pnl.toFixed(2); | |
| pnlEl.style.color = pnl >= 0 ? '#26a69a' : '#ef5350'; | |
| }} | |
| }} | |
| }} | |
| // Update every 500ms | |
| setInterval(updateSidebarPrice, 500); | |
| updateSidebarPrice(); | |
| </script> | |
| """, unsafe_allow_html=True) | |
| def render_trade_history(trades: list): | |
| """Render real trade history.""" | |
| st.markdown('<div class="metric-label">Recent Trades</div>', unsafe_allow_html=True) | |
| action_trades = [t for t in trades if 'action' in t and t['action'] != 'HOLD'] | |
| if not action_trades: | |
| st.info("No trades yet") | |
| return | |
| for trade in reversed(action_trades[-10:]): | |
| action = trade.get('action', '') | |
| price = trade.get('price', 0) | |
| pnl = trade.get('pnl', 0) | |
| timestamp = trade.get('timestamp', '') | |
| reason = trade.get('reason', 'model') | |
| try: | |
| ts = datetime.fromisoformat(timestamp) | |
| time_str = ts.strftime('%m/%d %H:%M') | |
| except: | |
| time_str = '' | |
| # Determine display based on action and reason | |
| if 'OPEN_LONG' in action: | |
| color = "#26a69a" | |
| side = "LONG" | |
| elif 'OPEN_SHORT' in action: | |
| color = "#ef5350" | |
| side = "SHORT" | |
| elif 'CLOSE' in action: | |
| if reason == 'stop_loss': | |
| color = "#ff4444" | |
| side = "SL" | |
| elif reason == 'take_profit': | |
| color = "#00ff88" | |
| side = "TP" | |
| else: | |
| color = "#ffc107" | |
| side = "EXIT" | |
| else: | |
| color = "#888" | |
| side = action | |
| pnl_color = "#26a69a" if pnl >= 0 else "#ef5350" | |
| pnl_sign = "+" if pnl >= 0 else "" | |
| pnl_display = f"{pnl_sign}${pnl:,.2f}" if pnl != 0 else "" | |
| st.markdown(f""" | |
| <div style=" | |
| background: #1e222d; | |
| border-radius: 5px; | |
| padding: 10px; | |
| margin-bottom: 5px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| "> | |
| <div> | |
| <span style="color: {color}; font-weight: bold;">{side}</span> | |
| <span style="color: #888; font-size: 12px; margin-left: 10px;">${price:,.2f}</span> | |
| <span style="color: #555; font-size: 10px; margin-left: 10px;">{time_str}</span> | |
| </div> | |
| <span style="color: {pnl_color}; font-weight: bold;">{pnl_display}</span> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def load_real_market_data(symbol: str = 'BTC/USDT', timeframe: str = '1h') -> pd.DataFrame: | |
| """Load OHLCV candlestick data โ via /api/ohlcv or direct Binance public API.""" | |
| import requests as _mkt_requests | |
| import logging as _log | |
| _logger = _log.getLogger(__name__) | |
| clean_symbol = symbol.replace("/", "") | |
| def _parse_ohlcv_list(data: list) -> pd.DataFrame: | |
| """Parse list of {time,open,high,low,close,volume} dicts into DataFrame.""" | |
| df = pd.DataFrame(data) | |
| df.index = pd.to_datetime(df['time'], unit='s') | |
| df.index.name = None | |
| return df[['open', 'high', 'low', 'close', 'volume']] | |
| # Primary: /api/ohlcv via local Flask server | |
| api_url = get_api_url() | |
| try: | |
| resp = _mkt_requests.get( | |
| f'{api_url}/api/ohlcv', | |
| params={'symbol': clean_symbol, 'interval': timeframe, 'limit': 500}, | |
| timeout=10 | |
| ) | |
| if resp.ok: | |
| data = resp.json() | |
| if data and isinstance(data, list) and len(data) > 0: | |
| return _parse_ohlcv_list(data) | |
| else: | |
| _logger.warning(f"load_real_market_data: empty/invalid response from {api_url} for {clean_symbol} {timeframe}: {str(data)[:200]}") | |
| else: | |
| _logger.warning(f"load_real_market_data: HTTP {resp.status_code} from {api_url}/api/ohlcv for {clean_symbol} {timeframe}") | |
| except Exception as e: | |
| _logger.warning(f"load_real_market_data: Flask API unavailable ({api_url}): {e}") | |
| # Fallback: Direct Binance public API (no auth required, works on HF) | |
| try: | |
| _logger.info(f"load_real_market_data: trying direct Binance API for {clean_symbol} {timeframe}") | |
| binance_url = os.environ.get("BINANCE_FUTURES_URL", "https://data-api.binance.vision") | |
| resp = _mkt_requests.get( | |
| f"{binance_url}/api/v3/klines", | |
| params={'symbol': clean_symbol, 'interval': timeframe, 'limit': 500}, | |
| timeout=15 | |
| ) | |
| if resp.ok: | |
| raw = resp.json() | |
| if isinstance(raw, list) and len(raw) > 0 and not (isinstance(raw, dict) and raw.get('code')): | |
| candles = [ | |
| { | |
| 'time': int(row[0]) // 1000, | |
| 'open': float(row[1]), | |
| 'high': float(row[2]), | |
| 'low': float(row[3]), | |
| 'close': float(row[4]), | |
| 'volume': float(row[5]), | |
| } | |
| for row in raw | |
| ] | |
| _logger.info(f"load_real_market_data: direct Binance returned {len(candles)} candles for {clean_symbol} {timeframe}") | |
| return _parse_ohlcv_list(candles) | |
| else: | |
| _logger.warning(f"load_real_market_data: Binance direct API returned unexpected data: {str(raw)[:200]}") | |
| else: | |
| _logger.warning(f"load_real_market_data: Binance direct API HTTP {resp.status_code} for {clean_symbol} {timeframe}") | |
| except Exception as e: | |
| _logger.error(f"load_real_market_data: direct Binance fallback failed: {e}") | |
| # Final fallback: BinanceHistoricalDataFetcher if backtest module available (local server only) | |
| if _HAS_BACKTEST: | |
| try: | |
| fetcher = BinanceHistoricalDataFetcher() | |
| end_date = datetime.now() | |
| days = TIMEFRAMES.get(timeframe, {}).get('days', 7) | |
| start_date = end_date - timedelta(days=days) | |
| if "USDT" in symbol and "/" not in symbol: | |
| symbol = symbol.replace("USDT", "/USDT") | |
| df = fetcher.fetch_historical_data( | |
| symbol=symbol, timeframe=timeframe, | |
| start_date=start_date, end_date=end_date, | |
| ) | |
| return df | |
| except Exception as e: | |
| _logger.error(f"load_real_market_data: BinanceHistoricalDataFetcher failed: {e}") | |
| return pd.DataFrame() | |
| return pd.DataFrame() | |
| def render_sidebar_metrics_fragment(): | |
| """Render sidebar portfolio metrics with auto-refresh.""" | |
| import requests | |
| import logging | |
| logger = logging.getLogger(__name__) | |
| try: | |
| # Fetch State | |
| try: | |
| state_resp = requests.get(f'{get_api_url()}/api/state', timeout=5) | |
| if state_resp.status_code == 200: | |
| api_state = state_resp.json() | |
| # Update session state with API data (optional, but good for other parts) | |
| if 'balance' in api_state: | |
| st.session_state.portfolio_balance = api_state.get('balance', 0) | |
| st.session_state.total_pnl = api_state.get('total_pnl', 0) | |
| # Render | |
| st.markdown(f""" | |
| <div class="metric-card"> | |
| <div class="metric-label">Portfolio Value</div> | |
| <div class="metric-value">{f'${st.session_state["portfolio_balance"]:,.2f}' if st.session_state.get('portfolio_balance') is not None else 'โ'}</div> | |
| <div class="metric-delta" style="color: {'#26a69a' if float(st.session_state.get('total_pnl') or 0) >= 0 else '#ef5350'}"> | |
| P&L: {'+' if float(st.session_state.get('total_pnl') or 0) >= 0 else ''}${float(st.session_state.get('total_pnl') or 0):,.2f} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| except Exception as e: | |
| st.markdown(f"<div style='color: #ef5350'>Connection Error</div>", unsafe_allow_html=True) | |
| except Exception as e: | |
| logger.error(f"Sidebar data fetch error: {e}") | |
| def render_market_analysis_fragment(symbol: str): | |
| """Render market analysis panel with auto-refresh.""" | |
| import requests | |
| import logging | |
| logger = logging.getLogger(__name__) | |
| st.markdown("### ๐ Market Analysis") | |
| # Fetch Market Analysis for current asset | |
| market_data = {} | |
| try: | |
| api_symbol = symbol.replace('/', '').upper() | |
| market_resp = requests.get(f'{get_api_url()}/api/market?symbol={api_symbol}', timeout=15) | |
| if market_resp.status_code == 200: | |
| market_data = market_resp.json() | |
| else: | |
| st.markdown(f""" | |
| <div class="metric-card"> | |
| <div class="metric-label">๐ Market Analysis</div> | |
| <div style="color: #ef5350; font-size: 12px;">API error (HTTP {market_resp.status_code})</div> | |
| <div style="color: #888; font-size: 10px; margin-top:5px;">Server returned non-200 for /api/market</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| return | |
| except Exception as e: | |
| st.markdown(f""" | |
| <div class="metric-card"> | |
| <div class="metric-label">๐ Market Analysis</div> | |
| <div style="color: #ef5350; font-size: 12px;">Unable to load (API server offline?)</div> | |
| <div style="color: #888; font-size: 10px; margin-top:5px;">Error: {str(e)}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| return | |
| # Whale Tracker | |
| whale = market_data.get('whale', {}) | |
| if whale: | |
| if whale.get('error'): | |
| st.markdown(f""" | |
| <div class="metric-card"> | |
| <div class="metric-label">๐ Whale Signals</div> | |
| <div style="color: #ef5350; font-size: 12px;">Data Error</div> | |
| <div style="color: #888; font-size: 10px;">{whale.get('error')}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| whale_color = "#26a69a" if whale.get('score', 0) > 0 else "#ef5350" if whale.get('score', 0) < 0 else "#888" | |
| whale_emoji = "๐ข" if whale.get('score', 0) > 0.1 else "๐ด" if whale.get('score', 0) < -0.1 else "โช" | |
| # Format Flow Metrics | |
| flow_metrics = whale.get('flow_metrics', {}) | |
| net_flow = flow_metrics.get('net_flow', 0) | |
| flow_color = "#26a69a" if net_flow > 0 else "#ef5350" | |
| flow_sign = "+" if net_flow > 0 else "-" | |
| # Format to K or M | |
| if abs(net_flow) > 1000000: | |
| flow_str = f"{flow_sign}${abs(net_flow)/1000000:.1f}M" | |
| elif abs(net_flow) > 1000: | |
| flow_str = f"{flow_sign}${abs(net_flow)/1000:.0f}K" | |
| else: | |
| flow_str = "$0" | |
| st.markdown(f""" | |
| <div class="metric-card"> | |
| <div class="metric-label">๐ Whale Signals</div> | |
| <div style="color: {whale_color}; font-size: 14px;">{whale_emoji} {whale.get('direction', 'NEUTRAL')}</div> | |
| <div style="color: #888; font-size: 11px;"> | |
| Score: {whale.get('score', 0):.2f} | Conf: {whale.get('confidence', 0)}%<br> | |
| Flow (1m): <span style="color: {flow_color}; font-weight: bold;">{flow_str}</span><br> | |
| ๐ข{whale.get('bullish', 0)} ๐ด{whale.get('bearish', 0)} โช{whale.get('neutral', 0)} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Funding | |
| funding_data = market_data.get('funding', {}) | |
| funding = funding_data.get('data', {}) # structure varies, being safe | |
| if funding_data and not funding_data.get('error'): | |
| # Extract funding rate | |
| rate = funding_data.get('rate', 0) | |
| funding_color = "#26a69a" if rate > 0.0001 else "#ef5350" if rate < -0.0001 else "#888" | |
| st.markdown(f""" | |
| <div class="metric-card"> | |
| <div class="metric-label">๐ฐ Funding Rate</div> | |
| <div style="color: {funding_color}; font-size: 14px;">{rate:.4f}%</div> | |
| <div style="color: #888; font-size: 11px;"> | |
| Bias: {funding_data.get('bias', 'neutral')} | APR: {funding_data.get('annualized', 0):.1f}% | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Order Flow (enhanced 3-layer) | |
| order_flow = market_data.get('order_flow', {}) | |
| if order_flow and not order_flow.get('error'): | |
| of_bias = order_flow.get('bias', 'neutral') | |
| of_score = order_flow.get('score', 0) | |
| of_color = "#26a69a" if of_bias == 'bullish' else "#ef5350" if of_bias == 'bearish' else "#888" | |
| # Layer details | |
| cvd_data = order_flow.get('cvd', {}) | |
| taker_data = order_flow.get('taker', {}) | |
| notable_data = order_flow.get('notable', {}) | |
| cvd_trend = cvd_data.get('trend', 'n/a') | |
| taker_ratio = taker_data.get('ratio', 0.5) | |
| notable_buys = notable_data.get('large_buys', order_flow.get('large_buys', 0)) | |
| notable_sells = notable_data.get('large_sells', order_flow.get('large_sells', 0)) | |
| st.markdown(f""" | |
| <div class="metric-card"> | |
| <div class="metric-label">๐ Order Flow</div> | |
| <div style="color: {of_color}; font-size: 14px;">{of_bias.upper()} ({(of_score or 0):+.2f})</div> | |
| <div style="color: #888; font-size: 11px;"> | |
| CVD: {cvd_trend} | Taker Buy: {taker_ratio:.0%}<br/> | |
| Notable: B:{notable_buys} / S:{notable_sells} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # News Sentiment - DISABLED (not reliable, removed per user request) | |
| # Commented out - news sentiment disabled in trading logic | |
| # news_data = market_data.get('news') | |
| # if news_data is not None and isinstance(news_data, dict): | |
| # news_sentiment = news_data.get('sentiment', 0) | |
| # news_conf = news_data.get('confidence', 0) | |
| # news_trend = news_data.get('trend', 'unknown') | |
| # news_sources = news_data.get('sources', 0) | |
| # | |
| # # Sentiment color and emoji | |
| # news_color = "#26a69a" if news_sentiment > 0.1 else "#ef5350" if news_sentiment < -0.1 else "#888" | |
| # news_emoji = "๐ข" if news_sentiment > 0.1 else "๐ด" if news_sentiment < -0.1 else "โช" | |
| # sentiment_label = "Bullish" if news_sentiment > 0.1 else "Bearish" if news_sentiment < -0.1 else "Neutral" | |
| # | |
| # # Trend indicator | |
| # trend_emoji = "๐" if news_trend == "improving" else "๐" if news_trend == "deteriorating" else "โก๏ธ" | |
| # | |
| # st.markdown(f""" | |
| # <div class="metric-card"> | |
| # <div class="metric-label">๐ฐ News Sentiment</div> | |
| # <div style="color: {news_color}; font-size: 14px;">{news_emoji} {sentiment_label} ({news_sentiment:+.2f})</div> | |
| # <div style="color: #888; font-size: 11px;"> | |
| # Confidence: {news_conf:.0%} | Trend: {trend_emoji} {news_trend}<br/> | |
| # Sources: {news_sources}/3 (CryptoCompare) | |
| # </div> | |
| # </div> | |
| # """, unsafe_allow_html=True) | |
| # else: | |
| # # Show placeholder when news data is not available yet | |
| # st.markdown(f""" | |
| # <div class="metric-card"> | |
| # <div class="metric-label">๐ฐ News Sentiment</div> | |
| # <div style="color: #888; font-size: 12px;">Loading...</div> | |
| # <div style="color: #666; font-size: 10px;"> | |
| # Waiting for first news fetch (takes ~1-2 min) | |
| # </div> | |
| # </div> | |
| # """, unsafe_allow_html=True) | |
| # HMM Regime | |
| regime_data = market_data.get('regime', {}) | |
| if regime_data and not regime_data.get('error'): | |
| r_type = regime_data.get('type', 'UNKNOWN') | |
| # Colors: Green for Bull, Red for Bear, Orange for Breakout, Blue for Range | |
| r_color = "#26a69a" if "BULL" in r_type else "#ef5350" if "BEAR" in r_type else "#ffa726" if "BREAKOUT" in r_type else "#42a5f5" | |
| st.markdown(f""" | |
| <div class="metric-card"> | |
| <div class="metric-label">๐ Market Regime (HMM)</div> | |
| <div style="color: {r_color}; font-size: 14px; font-weight: bold;">{r_type.replace('_', ' ')}</div> | |
| <div style="color: #888; font-size: 11px;"> | |
| ADX: {regime_data.get('adx', 0)} | Volatility: {regime_data.get('volatility', 1.0)}x | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # TFT Forecast | |
| forecast = market_data.get('forecast') | |
| if forecast: | |
| ret_4h = forecast.get('return_4h', 0) | |
| fc_color = "#26a69a" if ret_4h > 0 else "#ef5350" if ret_4h < 0 else "#888" | |
| fc_sign = "+" if ret_4h > 0 else "" | |
| st.markdown(f""" | |
| <div class="metric-card"> | |
| <div class="metric-label">๐ AI Price Forecast (TFT)</div> | |
| <div style="color: {fc_color}; font-size: 14px;">4h: {fc_sign}{ret_4h}% | 12h: {forecast.get('return_12h', 0)}%</div> | |
| <div style="color: #888; font-size: 11px;"> | |
| Consensus: {forecast.get('consensus', 0):.2f} | Confidence: {forecast.get('confidence', 0):.2f} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Ensemble Confidence Engine | |
| confidence = market_data.get('ensemble_confidence') | |
| if confidence is not None: | |
| conf_pct = min(100, max(0, int(confidence * 100))) | |
| # Map 0-1.0 to 0.25x - 2.0x for UI display (matching the ConfidenceEngine logic roughly) | |
| mult = 0.25 + 1.75 * confidence if confidence < 0.5 else 1.0 + 1.0 * (confidence - 0.5) * 2 # Approximate for UI | |
| c_color = "#26a69a" if confidence > 0.6 else "#ffa726" if confidence > 0.35 else "#ef5350" | |
| st.markdown(f""" | |
| <div class="metric-card"> | |
| <div class="metric-label">๐ง Ensemble Agreement</div> | |
| <div style="color: {c_color}; font-size: 14px;">{conf_pct}% Alignment</div> | |
| <div style="color: #888; font-size: 11px;"> | |
| Position Size Multiplier: ~{mult:.1f}x | |
| </div> | |
| <!-- Progress Bar --> | |
| <div style="width: 100%; background-color: #333; height: 4px; border-radius: 2px; margin-top: 5px;"> | |
| <div style="width: {conf_pct}%; background-color: {c_color}; height: 100%; border-radius: 2px;"></div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def render_position_fragment(symbol: str): | |
| """Render current position and portfolio status with auto-refresh.""" | |
| import requests | |
| import os | |
| from datetime import datetime | |
| import logging | |
| logger = logging.getLogger(__name__) | |
| # 1. Fetch Trading State | |
| state = {} | |
| try: | |
| state_resp = requests.get(f'{get_api_url()}/api/state', timeout=5) | |
| if state_resp.status_code == 200: | |
| state = state_resp.json() | |
| except Exception as e: | |
| logger.error(f"State fetch error: {e}") | |
| # 2. Fetch Live Price (Fast, from API or Fallback) | |
| current_price = 0.0 | |
| try: | |
| # Try to get price from market API first (faster) | |
| clean_symbol = symbol.replace('/', '').upper() | |
| market_resp = requests.get(f'{get_api_url()}/api/market?symbol={clean_symbol}', timeout=5) | |
| if market_resp.status_code == 200: | |
| m_data = market_resp.json() | |
| if 'price' in m_data: | |
| current_price = float(m_data['price']) | |
| # Fallback if API didn't return price | |
| if current_price == 0: | |
| live_data = load_real_market_data(symbol, '1m') | |
| if not live_data.empty: | |
| current_price = float(live_data.iloc[-1]['close']) | |
| else: | |
| live_1h = load_real_market_data(symbol, '1h') | |
| if not live_1h.empty: | |
| current_price = float(live_1h.iloc[-1]['close']) | |
| except Exception as e: | |
| logger.error(f"Price fetch error: {e}") | |
| # 3. Fetch ALL Trades early to calculate perfectly mathematically synced global Portfolio Value | |
| all_trades = [] | |
| try: | |
| trades_resp = requests.get(f'{get_api_url()}/api/trades', timeout=5) | |
| if trades_resp.status_code == 200: | |
| all_trades = trades_resp.json() | |
| except Exception as e: | |
| logger.error(f"Trades fetch error: {e}") | |
| realized_pnl_total = sum(t.get('pnl', 0) for t in all_trades if 'CLOSE' in t.get('action', '').upper() or 'EXIT' in t.get('action', '').upper()) | |
| open_pnl_total = 0.0 | |
| raw_assets = state.get('raw_state', {}).get('assets', {}) | |
| for sym, asset_data in raw_assets.items(): | |
| if asset_data.get('position', 0) != 0: | |
| open_pnl_total += asset_data.get('pnl', 0) | |
| if all_trades or raw_assets: | |
| total_pnl = realized_pnl_total + open_pnl_total | |
| else: | |
| total_pnl = state.get('total_pnl', state.get('realized_pnl', 0)) | |
| balance = state.get('total_balance', state.get('balance')) | |
| pnl_class = "metric-delta-positive" if total_pnl >= 0 else "metric-delta-negative" | |
| pnl_sign = "+" if total_pnl >= 0 else "" | |
| st.markdown(f""" | |
| <div class="metric-card"> | |
| <div class="metric-label">Portfolio Value</div> | |
| <div class="metric-value">{f'${balance:,.2f}' if balance is not None else 'โ'}</div> | |
| <div class="{pnl_class}">P&L: {pnl_sign}${(total_pnl or 0):,.2f}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # 4. Render Position Card | |
| # Extract specific asset state from global state | |
| asset_state = {} | |
| if 'assets' in state: | |
| # Try exact match or cleaned match | |
| clean_symbol = symbol.replace('/', '').upper() | |
| if symbol in state['assets']: | |
| asset_state = state['assets'][symbol] | |
| elif clean_symbol in state['assets']: | |
| asset_state = state['assets'][clean_symbol] | |
| # If not found, fall back to global state (in case API returns single asset state) | |
| if not asset_state and 'position' in state: | |
| asset_state = state | |
| # NORMALIZE STATE: Ensure position_price is set for P&L calc | |
| # CRITICAL: entry_price is the actual entry price, price is the current price | |
| # Must prioritize entry_price over price to avoid showing current price as entry | |
| if asset_state: | |
| if 'position_price' not in asset_state and 'entry_price' in asset_state: | |
| asset_state['position_price'] = asset_state['entry_price'] | |
| elif 'position_price' not in asset_state and 'price' in asset_state: | |
| # Only use 'price' if entry_price is not available (legacy compatibility) | |
| asset_state['position_price'] = asset_state['price'] | |
| render_position_card(asset_state, current_price, symbol) | |
| # 5. Render Trade History | |
| # Filter trades for current symbol (already fetched above) | |
| clean_symbol = symbol.replace('/', '').upper() | |
| trades = [t for t in all_trades if t.get('symbol', '').replace('/', '').upper() == clean_symbol] | |
| render_trade_history(trades) | |
| def render_agent_status_fragment(): | |
| """Render active agent status and model info with auto-refresh.""" | |
| import requests | |
| import os | |
| from datetime import datetime | |
| import logging | |
| logger = logging.getLogger(__name__) | |
| # In client mode, use /api/model which returns pre-computed model stats | |
| if IS_CLIENT_MODE: | |
| try: | |
| model_resp = requests.get(f'{get_api_url()}/api/model', timeout=5) | |
| if model_resp.status_code == 200: | |
| model_info = model_resp.json() | |
| total_return = model_info.get('total_return', 0) | |
| win_rate = model_info.get('win_rate', 0) | |
| total_trades = model_info.get('total_trades', 0) | |
| model_date = model_info.get('model_date', 'Remote') | |
| model_exists = model_info.get('model_exists', True) | |
| else: | |
| total_return, win_rate, total_trades = 0, 0, 0 | |
| model_date, model_exists = 'API error', False | |
| except Exception: | |
| total_return, win_rate, total_trades = 0, 0, 0 | |
| model_date, model_exists = 'Connecting...', False | |
| else: | |
| # Local mode: check filesystem and compute from trades | |
| project_root = Path(__file__).parent.parent.parent | |
| model_path = project_root / 'data' / 'models' / 'ultimate_agent.zip' | |
| model_exists = model_path.exists() | |
| state = {} | |
| try: | |
| state_resp = requests.get(f'{get_api_url()}/api/state', timeout=5) | |
| if state_resp.status_code == 200: | |
| state = state_resp.json() | |
| except Exception: | |
| pass | |
| all_trades = state.get('trades', []) | |
| try: | |
| trades_resp = requests.get(f'{get_api_url()}/api/trades', timeout=5) | |
| if trades_resp.status_code == 200: | |
| all_trades = trades_resp.json() | |
| except Exception: | |
| pass | |
| realized_pnl = sum(t.get('pnl', 0) for t in all_trades if 'CLOSE' in t.get('action', '').upper() or 'EXIT' in t.get('action', '').upper()) | |
| raw_assets = state.get('raw_state', {}).get('assets', {}) | |
| open_pnl = 0.0 | |
| for sym, asset_data in raw_assets.items(): | |
| if asset_data.get('position', 0) != 0: | |
| current_price = asset_data.get('price', 0) | |
| units = asset_data.get('units', 0) | |
| position = asset_data.get('position', 0) | |
| entry_price = 0 | |
| sym_trades = [t for t in all_trades if t.get('symbol', '').upper() == sym.upper() or t.get('asset', '').upper() == sym.upper()] | |
| for t in reversed(sorted(sym_trades, key=lambda x: x.get('timestamp', ''))): | |
| if 'OPEN' in t.get('action', '').upper(): | |
| entry_price = t.get('price', 0) | |
| break | |
| if entry_price > 0 and units > 0 and current_price > 0: | |
| if position > 0: | |
| open_pnl += (current_price - entry_price) * units | |
| else: | |
| open_pnl += (entry_price - current_price) * units | |
| total_pnl = realized_pnl + open_pnl | |
| total_return = None # Cannot compute without knowing real initial capital | |
| closed_trades = [t for t in all_trades if 'CLOSE' in t.get('action', '').upper() or 'EXIT' in t.get('action', '').upper()] | |
| if closed_trades: | |
| winning = sum(1 for t in closed_trades if t.get('pnl', 0) > 0) | |
| win_rate = (winning / len(closed_trades) * 100) | |
| else: | |
| win_rate = 0 | |
| total_trades = len(all_trades) | |
| if model_exists: | |
| try: | |
| model_mtime = datetime.fromtimestamp(os.path.getmtime(model_path)) | |
| model_date = model_mtime.strftime("%Y-%m-%d") | |
| except Exception: | |
| model_date = "Unknown" | |
| else: | |
| model_date = "Not found" | |
| return_str = f"{'+' if total_return >= 0 else ''}{total_return:.2f}% Return" if total_return is not None else "N/A Return" | |
| return_color = "#26a69a" if (total_return or 0) >= 0 else "#ef5350" | |
| st.markdown(f""" | |
| <div class="metric-card"> | |
| <div class="metric-label">Active Model</div> | |
| <div style="color: white; font-size: 14px; margin-top: 5px;">Ultimate Agent (PPO)</div> | |
| <div style="color: {return_color}; font-size: 12px;">{return_str} | {win_rate:.1f}% Win Rate</div> | |
| <div style="color: #888; font-size: 11px;">Trades: {total_trades} | Model: {model_date}</div> | |
| <div style="color: {'#26a69a' if model_exists else '#ef5350'}; font-size: 11px;">{'โ Model loaded' if model_exists else 'โ Model not found'}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def on_asset_change(): | |
| """Callback for asset selection change.""" | |
| # Clear stale market analysis to trigger fresh fetch in fragments | |
| st.session_state.market_analysis = None | |
| # Optional: Reset other asset-specific state if needed | |
| def main(): | |
| """Main application entry point.""" | |
| # Initialize session state for timeframe | |
| if 'timeframe' not in st.session_state: | |
| st.session_state.timeframe = '1h' | |
| # Initialize session state for selected asset | |
| if 'selected_asset' not in st.session_state: | |
| st.session_state.selected_asset = 'BTCUSDT' | |
| # Check for multi-asset state to populate selector | |
| state_preview = get_trading_state() | |
| available_assets = state_preview.get('available_assets', ['BTCUSDT']) | |
| # Initialize session state for auto-refresh (kept for toggle state only) | |
| if 'auto_refresh' not in st.session_state: | |
| st.session_state.auto_refresh = True | |
| # Sidebar | |
| with st.sidebar: | |
| st.markdown("### โ๏ธ Settings") | |
| # Asset Selector | |
| if len(available_assets) > 1: | |
| st.session_state.selected_asset = st.selectbox( | |
| "Select Asset", | |
| available_assets, | |
| index=available_assets.index(st.session_state.selected_asset) if st.session_state.selected_asset in available_assets else 0, | |
| on_change=on_asset_change | |
| ) | |
| else: | |
| st.markdown(f"**Asset:** {st.session_state.selected_asset}") | |
| st.divider() | |
| st.markdown("### ๐ Debug") | |
| if st.checkbox("Show Crash Log"): | |
| log_path = project_root / "crash.log" | |
| if log_path.exists(): | |
| st.error("โ ๏ธ Crash Log Found") | |
| with open(log_path, "r") as f: | |
| st.text_area("Log Content", f.read(), height=300) | |
| else: | |
| st.success("โ No crash log found") | |
| if st.checkbox("Show Process Log (Stdout/Stderr)"): | |
| proc_log = project_root / "process.log" | |
| if proc_log.exists(): | |
| with open(proc_log, "r") as f: | |
| st.text_area("Process Output", f.read(), height=300) | |
| else: | |
| st.warning("โ ๏ธ process.log not found (yet)") | |
| if st.checkbox("Show API Server Log"): | |
| api_log = project_root / "api_server.log" | |
| if api_log.exists(): | |
| with open(api_log, "r") as f: | |
| st.text_area("API Server Output", f.read(), height=300) | |
| else: | |
| st.warning("โ ๏ธ api_server.log not found (yet)") | |
| # Storage path is server-side only; omitted from client UI | |
| # Database Reset (Dev Only) | |
| env = os.getenv("ENVIRONMENT", "production").lower() | |
| if env in ["dev", "development"]: | |
| st.divider() | |
| st.markdown("### ๐ Database Reset") | |
| st.warning("โ ๏ธ This will clear all trades and positions!") | |
| if st.button("๐๏ธ Reset All Trades", type="primary"): | |
| try: | |
| import subprocess | |
| reset_script = project_root / "reset_all_storage.py" | |
| if reset_script.exists(): | |
| result = subprocess.run( | |
| [sys.executable, str(reset_script)], | |
| capture_output=True, | |
| text=True, | |
| cwd=str(project_root) | |
| ) | |
| if result.returncode == 0: | |
| st.success("โ Database reset successful!") | |
| st.code(result.stdout) | |
| st.info("๐ Refresh the page to see changes") | |
| else: | |
| st.error(f"โ Reset failed: {result.stderr}") | |
| else: | |
| st.error(f"โ Reset script not found at {reset_script}") | |
| except Exception as e: | |
| st.error(f"โ Error running reset: {e}") | |
| if st.checkbox("Show System Inspector"): | |
| st.markdown("#### ๐ต๏ธ System Inspector") | |
| if st.button("List Processes (ps aux)"): | |
| try: | |
| import subprocess | |
| # Use 'ps aux' for more details, or 'ps -ef' | |
| res = subprocess.run(['ps', 'aux'], capture_output=True, text=True) | |
| st.code(res.stdout if res.returncode == 0 else res.stderr) | |
| except Exception as e: | |
| st.error(f"Failed to run ps: {e}") | |
| if st.button("List Files (ls -R)"): | |
| try: | |
| import subprocess | |
| res = subprocess.run(['ls', '-R'], capture_output=True, text=True) | |
| st.code(res.stdout if res.returncode == 0 else res.stderr) | |
| except Exception as e: | |
| st.error(f"Failed to run ls: {e}") | |
| if st.button("Check Connectivity (ping google.com)"): | |
| try: | |
| import subprocess | |
| res = subprocess.run(['ping', '-c', '3', 'google.com'], capture_output=True, text=True) | |
| st.code(res.stdout if res.returncode == 0 else res.stderr) | |
| except Exception as e: | |
| st.error(f"Ping failed: {e}") | |
| st.markdown("### ๐ API Status") | |
| eth_key = os.environ.get("ETHERSCAN_API_KEY") | |
| sol_key = os.environ.get("SOLSCAN_API_KEY") | |
| xrp_key = os.environ.get("XRPSCAN_API_KEY") | |
| st.caption(f"ETH: {'โ Set' if eth_key else 'โ Missing'}") | |
| st.caption(f"SOL: {'โ Set' if sol_key else 'โ Missing'}") | |
| st.caption(f"XRP: {'โ Set' if xrp_key else 'โช Optional (Public)'}") | |
| # Header | |
| col1, col2, col3 = st.columns([3, 1, 1]) | |
| with col1: | |
| st.markdown(f"# ๐ค DRL Trading System - {st.session_state.selected_asset}") | |
| with col2: | |
| refresh_status = "๐ Auto (10s)" if st.session_state.auto_refresh else "โธ๏ธ Paused" | |
| st.markdown(f""" | |
| <div style="text-align: right; padding-top: 10px;"> | |
| <span style="color: #00e676; font-size: 14px;">๐ข Connected</span><br> | |
| <span style="color: #8b949e; font-size: 12px;">{refresh_status}</span> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with col3: | |
| # Auto-refresh toggle | |
| st.session_state.auto_refresh = st.toggle("Auto Refresh", value=st.session_state.auto_refresh) | |
| # Data fetching is now handled inside fragments (render_sidebar_metrics_fragment, render_market_analysis_fragment) | |
| pass | |
| # Render Sidebar Metrics using Fragment | |
| with st.sidebar: | |
| render_sidebar_metrics_fragment() | |
| st.divider() | |
| # Main layout | |
| col_main, col_sidebar = st.columns([3, 1]) | |
| with col_main: | |
| # Timeframe selector | |
| st.markdown("#### Select Timeframe") | |
| tf_cols = st.columns(7) | |
| timeframes = ['1m', '5m', '15m', '30m', '1h', '4h', '1d'] | |
| for i, tf in enumerate(timeframes): | |
| with tf_cols[i]: | |
| label = TIMEFRAMES[tf]['label'] | |
| if st.button(label, key=f"tf_{tf}", use_container_width=True, | |
| type="primary" if st.session_state.timeframe == tf else "secondary"): | |
| st.session_state.timeframe = tf | |
| st.rerun() | |
| # Load data for selected timeframe and asset | |
| with st.spinner(f"Loading {st.session_state.selected_asset} {st.session_state.timeframe} data..."): | |
| df = load_real_market_data(st.session_state.selected_asset, st.session_state.timeframe) | |
| state = get_trading_state(st.session_state.selected_asset) | |
| current_price = float(df.iloc[-1]['close']) if not df.empty else 0 | |
| # Tabs | |
| tab_chart, tab_live_portfolio, tab_performance, tab_whales, tab_testnet, tab_htf, tab_backtest = st.tabs([ | |
| "๐ Live Chart", "๐ผ Live Portfolio", "๐ Performance", "๐ On-Chain Whales", "๐งช Testnet", "๐ฎ HTF Agent", "๐ฌ Backtest" | |
| ]) | |
| with tab_chart: | |
| # TradingView Chart with WebSocket | |
| trades = state.get('trades', []) | |
| chart_html = create_tradingview_chart_with_websocket(df, trades, st.session_state.timeframe, st.session_state.selected_asset) | |
| # Append timestamp comment to force re-render since components.html doesn't support key | |
| # Create a placeholder for the chart to force re-rendering | |
| chart_placeholder = st.empty() | |
| # Append timestamp comment to force re-render since components.html doesn't support key | |
| current_time = time.time() | |
| chart_html += f"<!-- {current_time} -->" | |
| with chart_placeholder: | |
| components.html(chart_html, height=600) | |
| # Info about trade markers | |
| num_trades = len([t for t in trades if 'OPEN' in t.get('action', '')]) | |
| st.caption(f"๐ {num_trades} trade signals on chart โข Switch timeframes to see trades at different intervals") | |
| # Trading Controls Section | |
| st.markdown("---") | |
| st.markdown("### ๐ฎ Trading Controls") | |
| if IS_CLIENT_MODE: | |
| st.info("๐ **Client Mode** โ Trading bot is managed on the remote server. Use the server dashboard to start/stop the bot or place manual trades.") | |
| if st.button("๐ Refresh Data", key="refresh_data", use_container_width=True): | |
| st.rerun() | |
| else: | |
| # Bot status check (server-side only) | |
| import subprocess | |
| bot_running = False | |
| try: | |
| result = subprocess.run(['pgrep', '-f', 'live_trading'], capture_output=True, text=True) | |
| bot_running = result.returncode == 0 | |
| except Exception: | |
| pass | |
| if bot_running: | |
| st.success("๐ข **Trading Bot is RUNNING** (Multi-Asset Mode)") | |
| else: | |
| st.warning("๐ **Trading Bot is STOPPED**") | |
| ctrl_col1, ctrl_col2, ctrl_col3, ctrl_col4 = st.columns(4) | |
| with ctrl_col1: | |
| if not bot_running: | |
| if st.button("โถ๏ธ Start Trading", key="start_trading", use_container_width=True, type="primary"): | |
| try: | |
| with open(project_root / "process.log", "a") as log_file: | |
| subprocess.Popen( | |
| ['./venv/bin/python', '-u', 'live_trading_multi.py', | |
| '--assets', 'BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'XRPUSDT', | |
| '--balance', '5000'], | |
| cwd=str(project_root), | |
| stdout=log_file, | |
| stderr=log_file, | |
| ) | |
| st.success("โ Multi-Asset Bot started!") | |
| time.sleep(2) | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Failed to start: {e}") | |
| else: | |
| if st.button("โน๏ธ Stop Trading", key="stop_trading", use_container_width=True, type="secondary"): | |
| try: | |
| subprocess.run(['pkill', '-f', 'live_trading'], check=False) | |
| st.info("โ Trading bot stopped") | |
| time.sleep(1) | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Failed to stop: {e}") | |
| with ctrl_col2: | |
| if st.button("๐ Open Long", key="open_long", use_container_width=True): | |
| trade = { | |
| 'timestamp': datetime.now().isoformat(), | |
| 'action': 'OPEN_LONG', | |
| 'price': current_price, | |
| 'pnl': 0, | |
| 'balance': state.get('balance'), | |
| 'position': 1, | |
| 'reason': 'manual', | |
| 'symbol': st.session_state.selected_asset, | |
| 'asset': st.session_state.selected_asset, | |
| } | |
| storage.log_trade(trade) | |
| st.success(f"โ Opened LONG @ ${current_price:,.2f}") | |
| time.sleep(0.5) | |
| st.rerun() | |
| with ctrl_col3: | |
| if st.button("๐ Open Short", key="open_short", use_container_width=True): | |
| trade = { | |
| 'timestamp': datetime.now().isoformat(), | |
| 'action': 'OPEN_SHORT', | |
| 'price': current_price, | |
| 'pnl': 0, | |
| 'balance': state.get('balance'), | |
| 'position': -1, | |
| 'reason': 'manual', | |
| 'symbol': st.session_state.selected_asset, | |
| 'asset': st.session_state.selected_asset, | |
| } | |
| storage.log_trade(trade) | |
| st.success(f"โ Opened SHORT @ ${current_price:,.2f}") | |
| time.sleep(0.5) | |
| st.rerun() | |
| with ctrl_col4: | |
| if st.button("๐ช Close Position", key="close_position", use_container_width=True): | |
| position = state.get('position', 0) | |
| if position != 0: | |
| action = 'CLOSE_LONG' if position == 1 else 'CLOSE_SHORT' | |
| trade = { | |
| 'timestamp': datetime.now().isoformat(), | |
| 'action': action, | |
| 'price': current_price, | |
| 'pnl': 0, | |
| 'balance': state.get('balance'), | |
| 'position': 0, | |
| 'reason': 'manual', | |
| 'symbol': st.session_state.selected_asset, | |
| 'asset': st.session_state.selected_asset, | |
| } | |
| storage.log_trade(trade) | |
| st.success(f"โ Closed position @ ${current_price:,.2f}") | |
| time.sleep(0.5) | |
| st.rerun() | |
| else: | |
| st.info("No position to close") | |
| st.markdown("") | |
| action_col1, action_col2 = st.columns(2) | |
| with action_col1: | |
| if st.button("๐ Refresh Data", key="refresh_data", use_container_width=True): | |
| st.rerun() | |
| with action_col2: | |
| if st.button("๐๏ธ Clear Trade Log", key="clear_log", use_container_width=True): | |
| try: | |
| log_file = project_root / 'logs' / 'trading_log.json' | |
| state_file = project_root / 'logs' / 'trading_state.json' | |
| log_file.write_text('') | |
| if state_file.exists(): | |
| state_file.unlink() | |
| st.success("โ Trade log cleared") | |
| time.sleep(0.5) | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Failed to clear log: {e}") | |
| with tab_live_portfolio: | |
| # โโโ Compute portfolio metrics from trade data โโโ | |
| all_trades_lp = [] | |
| try: | |
| # Use API in client mode, local storage otherwise | |
| all_trades_lp = load_trading_log() | |
| if not IS_CLIENT_MODE: | |
| # Apply reset filter (local mode only โ API already filters) | |
| try: | |
| lp_state = storage.load_state() | |
| reset_ts = lp_state.get('reset_timestamp') | |
| if reset_ts: | |
| reset_dt = datetime.fromisoformat(reset_ts.replace('Z', '+00:00')) | |
| all_trades_lp = [t for t in all_trades_lp if datetime.fromisoformat(t.get('timestamp', '2020-01-01').replace('Z', '+00:00')) >= reset_dt] | |
| except: | |
| pass | |
| except: | |
| pass | |
| # Separate by symbol and compute per-asset metrics | |
| assets_by_symbol = {} | |
| for t in all_trades_lp: | |
| sym = t.get('symbol', t.get('asset', 'UNKNOWN')) | |
| sym = sym.replace('/', '').upper() | |
| if sym not in assets_by_symbol: | |
| assets_by_symbol[sym] = [] | |
| assets_by_symbol[sym].append(t) | |
| # Compute closed P&L, open P&L, win rate | |
| realized_pnl_total = 0.0 | |
| open_pnl_total = 0.0 | |
| total_closed_trades = 0 | |
| total_winning_trades = 0 | |
| total_open_trades = 0 | |
| equity_points = [0.0] # Start at 0% | |
| asset_rows = [] | |
| # FIX: State structure is state['assets'], not state['raw_state']['assets'] | |
| raw_assets = state.get('assets', {}) | |
| for sym, trades_list in assets_by_symbol.items(): | |
| sorted_trades = sorted(trades_list, key=lambda x: x.get('timestamp', '')) | |
| sym_realized = 0.0 | |
| sym_open_pnl = 0.0 | |
| sym_wins = 0 | |
| sym_closed = 0 | |
| sym_open = 0 | |
| sym_best = None | |
| sym_worst = None | |
| sym_status = 'FLAT' | |
| for t in sorted_trades: | |
| action = t.get('action', '').upper() | |
| pnl = t.get('pnl', 0) or 0 | |
| if 'CLOSE' in action or 'EXIT' in action: | |
| sym_realized += pnl | |
| sym_closed += 1 | |
| if pnl > 0: | |
| sym_wins += 1 | |
| equity_points.append(equity_points[-1] + pnl) | |
| # Track best/worst | |
| if sym_best is None or pnl > sym_best: | |
| sym_best = pnl | |
| if sym_worst is None or pnl < sym_worst: | |
| sym_worst = pnl | |
| elif 'OPEN_LONG' in action: | |
| sym_status = 'LONG' | |
| sym_open += 1 | |
| elif 'OPEN_SHORT' in action: | |
| sym_status = 'SHORT' | |
| sym_open += 1 | |
| # FIX: Determine final status from last trade (if it was a CLOSE, position is FLAT) | |
| if sorted_trades: | |
| last_trade = sorted_trades[-1] | |
| last_action = last_trade.get('action', '').upper() | |
| if 'CLOSE' in last_action or 'EXIT' in last_action: | |
| sym_status = 'FLAT' | |
| # Check current state for position status (this overrides trade-based status) | |
| if sym in raw_assets: | |
| asset_data = raw_assets[sym] | |
| if asset_data.get('position', 0) != 0: | |
| # Calculate unrealized P&L from entry price vs current price | |
| current_price = asset_data.get('price', 0) | |
| units = asset_data.get('units', 0) | |
| position = asset_data.get('position', 0) | |
| # Find entry price from last OPEN trade | |
| entry_price = 0 | |
| for t in reversed(sorted_trades): | |
| if 'OPEN' in t.get('action', '').upper(): | |
| entry_price = t.get('price', 0) | |
| break | |
| # Calculate unrealized P&L | |
| if entry_price > 0 and units > 0 and current_price > 0: | |
| if position > 0: # LONG | |
| sym_open_pnl = (current_price - entry_price) * units | |
| else: # SHORT | |
| sym_open_pnl = (entry_price - current_price) * units | |
| sym_status = 'LONG' if position > 0 else 'SHORT' | |
| else: | |
| sym_status = 'FLAT' | |
| realized_pnl_total += sym_realized | |
| open_pnl_total += sym_open_pnl | |
| total_closed_trades += sym_closed | |
| total_winning_trades += sym_wins | |
| if sym_status != 'FLAT': | |
| total_open_trades += 1 | |
| # Format display symbol | |
| display_sym = sym | |
| if sym.endswith('USDT'): | |
| display_sym = sym[:-4] + ' /USDT' | |
| # Get price / equity from raw state | |
| sym_price = raw_assets.get(sym, {}).get('price', 0) | |
| # Calculate True Equity mathematically instead of relying on historically corrupted bot.balance | |
| sym_equity = 5000 + sym_realized + sym_open_pnl | |
| asset_rows.append({ | |
| 'symbol': display_sym, | |
| 'raw_symbol': sym, | |
| 'status': sym_status, | |
| 'price': sym_price, | |
| 'equity': sym_equity, | |
| 'pnl': sym_realized + sym_open_pnl, | |
| 'trades': sym_closed + (1 if sym_status != 'FLAT' else 0), | |
| 'open_trades': 1 if sym_status != 'FLAT' else 0, | |
| 'win_rate': (sym_wins / sym_closed * 100) if sym_closed > 0 else 0, | |
| 'wins': sym_wins, | |
| 'closed': sym_closed, | |
| 'best': sym_best, | |
| 'worst': sym_worst, | |
| }) | |
| # Overall metrics | |
| # Fixed: Always use 4 assets (BTCUSDT, ETHUSDT, SOLUSDT, XRPUSDT) | |
| # Previously used len(assets_by_symbol) which only counted assets with trades | |
| # Calculate from trades (single source of truth) - same logic as Agent Status sidebar | |
| lp_grand_total_pnl = realized_pnl_total + open_pnl_total | |
| lp_total_balance = state.get('total_balance', state.get('balance')) | |
| overall_win_rate = (total_winning_trades / total_closed_trades * 100) if total_closed_trades > 0 else 0 | |
| total_trades_count = total_closed_trades + total_open_trades | |
| lp_active_assets_count = len(asset_rows) if asset_rows else len(state.get('available_assets', [])) | |
| # System status (client mode: infer from recent trades; server mode: check process) | |
| if IS_CLIENT_MODE: | |
| is_online = bool(all_trades_lp and (datetime.now() - datetime.fromisoformat( | |
| all_trades_lp[-1].get('timestamp', '2000-01-01').replace('Z', '+00:00').split('+')[0] | |
| )).total_seconds() < 3600) | |
| else: | |
| is_online = check_process_running("live_trading_multi.py") | |
| status_dot = '๐ข' if is_online else '๐ด' | |
| status_text = 'Online' if is_online else 'Offline' | |
| status_color = '#00e676' if is_online else '#ff5252' | |
| # Color helpers | |
| def pnl_color(val): | |
| return '#00e676' if val >= 0 else '#ff5252' | |
| def pnl_sign(val): | |
| return '+' if val >= 0 else '-' | |
| # โโโ Build the Live Portfolio HTML โโโ | |
| # Equity curve data for SVG chart (pure inline, no CDN) | |
| eq_pct = list(equity_points) # absolute PnL values; SVG normalizes by range | |
| # Build SVG polyline points | |
| svg_w = 900 | |
| svg_h = 160 | |
| n_points = len(eq_pct) | |
| eq_min_val = min(eq_pct) if eq_pct else 0 | |
| eq_max_val = max(eq_pct) if eq_pct else 0 | |
| eq_range_val = max(abs(eq_min_val), abs(eq_max_val), 0.01) | |
| padding_y = 20 # vertical padding | |
| svg_points = [] | |
| svg_fill_points = [] | |
| for i, val in enumerate(eq_pct): | |
| x = (i / max(n_points - 1, 1)) * svg_w | |
| # Map value from [-range, +range] to [svg_h - padding, padding] | |
| y = svg_h - padding_y - ((val + eq_range_val) / (2 * eq_range_val)) * (svg_h - 2 * padding_y) | |
| svg_points.append(f"{x:.1f},{y:.1f}") | |
| svg_fill_points.append(f"{x:.1f},{y:.1f}") | |
| polyline_str = ' '.join(svg_points) | |
| # Close the fill polygon at bottom | |
| fill_points = svg_fill_points.copy() | |
| if fill_points: | |
| fill_points.append(f"{svg_w:.1f},{svg_h - padding_y:.1f}") | |
| fill_points.append(f"0,{svg_h - padding_y:.1f}") | |
| fill_str = ' '.join(fill_points) | |
| last_eq = eq_pct[-1] if eq_pct else 0 | |
| line_color = '#00e676' if last_eq >= 0 else '#ff5252' | |
| fill_color_start = 'rgba(0,230,118,0.3)' if last_eq >= 0 else 'rgba(255,82,82,0.3)' | |
| fill_color_end = 'rgba(0,230,118,0.0)' if last_eq >= 0 else 'rgba(255,82,82,0.0)' | |
| # Zero line Y position | |
| zero_y = svg_h - padding_y - ((0 + eq_range_val) / (2 * eq_range_val)) * (svg_h - 2 * padding_y) | |
| # Last point for dot | |
| last_x = svg_w if n_points <= 1 else ((n_points - 1) / max(n_points - 1, 1)) * svg_w | |
| last_y = svg_h - padding_y - ((last_eq + eq_range_val) / (2 * eq_range_val)) * (svg_h - 2 * padding_y) | |
| # Y-axis labels | |
| top_label = f"+{eq_range_val:.1f}%" | |
| bot_label = f"-{eq_range_val:.1f}%" | |
| svg_chart = f''' | |
| <svg width="100%" viewBox="0 0 {svg_w} {svg_h}" preserveAspectRatio="none" style="display:block;"> | |
| <defs> | |
| <linearGradient id="eqGrad" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stop-color="{fill_color_start}"/> | |
| <stop offset="100%" stop-color="{fill_color_end}"/> | |
| </linearGradient> | |
| </defs> | |
| <!-- Zero line --> | |
| <line x1="0" y1="{zero_y:.1f}" x2="{svg_w}" y2="{zero_y:.1f}" stroke="rgba(255,255,255,0.08)" stroke-width="1" stroke-dasharray="4,4"/> | |
| <!-- Fill area --> | |
| <polygon points="{fill_str}" fill="url(#eqGrad)"/> | |
| <!-- Line --> | |
| <polyline points="{polyline_str}" fill="none" stroke="{line_color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/> | |
| <!-- Last point dot --> | |
| <circle cx="{last_x:.1f}" cy="{last_y:.1f}" r="4" fill="{line_color}" stroke="#fff" stroke-width="1.5"/> | |
| <!-- Labels --> | |
| <text x="{svg_w - 5}" y="{padding_y + 4}" fill="#8b949e" font-size="10" text-anchor="end" font-family="monospace">{top_label}</text> | |
| <text x="{svg_w - 5}" y="{svg_h - padding_y + 12}" fill="#8b949e" font-size="10" text-anchor="end" font-family="monospace">{bot_label}</text> | |
| <text x="5" y="{zero_y - 4:.1f}" fill="#555" font-size="9" font-family="monospace">0%</text> | |
| </svg> | |
| ''' | |
| # Build asset rows HTML | |
| asset_rows_html = '' | |
| for row in asset_rows: | |
| # Status badge | |
| if row['status'] == 'LONG': | |
| status_html = '<span style="background:#1b3a26;color:#00e676;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:600;">โ LONG</span>' | |
| elif row['status'] == 'SHORT': | |
| status_html = '<span style="background:#3a1b1b;color:#ff5252;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:600;">โ SHORT</span>' | |
| else: | |
| status_html = '<span style="background:#2a2e39;color:#888;padding:3px 10px;border-radius:4px;font-size:11px;">โ โ</span>' | |
| # PNL | |
| pnl_val = row['pnl'] | |
| pnl_html = f'<span style="color:{pnl_color(pnl_val)};font-weight:600;">โ</span>' | |
| pnl_dollar_html = f'<span style="color:{pnl_color(pnl_val)};font-weight:600;font-family:monospace;">{pnl_sign(pnl_val)}${abs(pnl_val):,.2f}</span>' | |
| # Trades | |
| trades_str = str(row['trades']) | |
| if row['open_trades'] > 0: | |
| trades_str += f' <span style="color:#888;">(+{row["open_trades"]})</span>' | |
| # Win rate bar | |
| wr = row['win_rate'] | |
| bar_color = '#00e676' if wr >= 50 else '#ff9800' if wr > 0 else '#555' | |
| wr_html = f''' | |
| <div style="display:flex;align-items:center;gap:8px;"> | |
| <div style="flex:1;background:#1a1e2a;border-radius:4px;height:8px;overflow:hidden;min-width:60px;"> | |
| <div style="width:{wr}%;height:100%;background:{bar_color};border-radius:4px;"></div> | |
| </div> | |
| <span style="color:#ccc;font-size:12px;min-width:35px;">{wr:.0f}%</span> | |
| </div> | |
| ''' | |
| # Best | |
| if row['best'] is not None: | |
| best_html = f'<span style="color:#00e676;">{pnl_sign(row["best"])}${abs(row["best"]):,.2f}</span>' | |
| else: | |
| best_html = '<span style="color:#555;">โ</span>' | |
| # Worst | |
| if row['worst'] is not None: | |
| worst_html = f'<span style="color:#ff5252;">-${abs(row["worst"]):,.2f}</span>' | |
| else: | |
| worst_html = '<span style="color:#555;">โ</span>' | |
| asset_rows_html += f''' | |
| <tr style="border-bottom:1px solid #1a1e2a;"> | |
| <td style="padding:14px 16px;font-weight:600;color:#fff;font-size:13px;"> | |
| {row['symbol']} | |
| </td> | |
| <td style="padding:14px 16px;">{status_html}</td> | |
| <td style="padding:14px 16px;text-align:right;color:#ccc;font-size:13px;font-family:monospace;">${row['price']:,.2f}</td> | |
| <td style="padding:14px 16px;text-align:right;color:#ccc;font-size:13px;font-family:monospace;">${row['equity']:,.2f}</td> | |
| <td style="padding:14px 16px;">{pnl_html}</td> | |
| <td style="padding:14px 16px;">{pnl_dollar_html}</td> | |
| <td style="padding:14px 16px;color:#ccc;font-size:13px;">{trades_str}</td> | |
| <td style="padding:14px 16px;min-width:100px;">{wr_html}</td> | |
| <td style="padding:14px 16px;">{best_html}</td> | |
| <td style="padding:14px 16px;">{worst_html}</td> | |
| </tr> | |
| ''' | |
| if not asset_rows_html: | |
| asset_rows_html = ''' | |
| <tr> | |
| <td colspan="10" style="padding:30px;text-align:center;color:#555;font-size:14px;"> | |
| No trades recorded yet. Start the trading bot to see portfolio data. | |
| </td> | |
| </tr> | |
| ''' | |
| portfolio_html = f''' | |
| <div style=" | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: #0d1117; | |
| color: #fff; | |
| padding: 0; | |
| "> | |
| <!-- Header --> | |
| <div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;"> | |
| <span style="font-size:22px;font-weight:700;color:#fff;">Live Portfolio</span> | |
| <span style=" | |
| background: #1a6b3c; | |
| color: #00e676; | |
| padding: 3px 10px; | |
| border-radius: 4px; | |
| font-size: 10px; | |
| font-weight: 700; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| ">LIVE TRADING</span> | |
| <span style=" | |
| background: {'#1b3a26' if is_online else '#3a1b1b'}; | |
| color: {status_color}; | |
| padding: 3px 10px; | |
| border-radius: 4px; | |
| font-size: 10px; | |
| font-weight: 700; | |
| letter-spacing: 1px; | |
| margin-left: 4px; | |
| ">{status_dot} {status_text}</span> | |
| </div> | |
| <!-- Metric Cards Row 1 --> | |
| <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:24px;"> | |
| <div style="background:#151b23;border:1px solid #21262d;border-radius:8px;padding:18px 20px;"> | |
| <div style="color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">Realized PNL</div> | |
| <div style="font-size:26px;font-weight:700;color:{pnl_color(realized_pnl_total)};">{pnl_sign(realized_pnl_total)}${abs(realized_pnl_total):,.2f}</div> | |
| </div> | |
| <div style="background:#151b23;border:1px solid #21262d;border-radius:8px;padding:18px 20px;"> | |
| <div style="color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">Open PNL</div> | |
| <div style="font-size:26px;font-weight:700;color:{pnl_color(open_pnl_total)};">{pnl_sign(open_pnl_total)}${abs(open_pnl_total):,.2f}</div> | |
| </div> | |
| <div style="background:#151b23;border:1px solid #21262d;border-radius:8px;padding:18px 20px;"> | |
| <div style="color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">Win Rate</div> | |
| <div style="font-size:26px;font-weight:700;color:#fff;">{overall_win_rate:.0f}%</div> | |
| <div style="color:#8b949e;font-size:11px;">{total_winning_trades}W / {total_closed_trades - total_winning_trades}L</div> | |
| </div> | |
| <div style="background:#151b23;border:1px solid #21262d;border-radius:8px;padding:18px 20px;"> | |
| <div style="color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">Trades</div> | |
| <div style="font-size:26px;font-weight:700;color:#fff;">{total_trades_count}</div> | |
| <div style="color:#8b949e;font-size:11px;">{total_open_trades} open ยท {total_closed_trades} closed</div> | |
| </div> | |
| </div> | |
| <!-- Metric Cards Row 2 (Dollar Values) --> | |
| <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:24px;"> | |
| <div style="background:#151b23;border:1px solid #21262d;border-radius:8px;padding:14px 20px;"> | |
| <div style="color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;">Portfolio Value</div> | |
| <div style="font-size:22px;font-weight:700;color:#fff;">{f'${lp_total_balance:,.2f}' if lp_total_balance is not None else 'โ'}</div> | |
| </div> | |
| <div style="background:#151b23;border:1px solid #21262d;border-radius:8px;padding:14px 20px;"> | |
| <div style="color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;">Total P&L</div> | |
| <div style="font-size:22px;font-weight:700;color:{pnl_color(lp_grand_total_pnl)};">{pnl_sign(lp_grand_total_pnl)}${abs(lp_grand_total_pnl):,.2f}</div> | |
| </div> | |
| <div style="background:#151b23;border:1px solid #21262d;border-radius:8px;padding:14px 20px;"> | |
| <div style="color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;">Active Assets</div> | |
| <div style="font-size:22px;font-weight:700;color:#fff;">{lp_active_assets_count}</div> | |
| </div> | |
| </div> | |
| <!-- Equity Curve (Pure SVG โ no external deps) --> | |
| <div style="background:#151b23;border:1px solid #21262d;border-radius:8px;padding:20px;margin-bottom:24px;"> | |
| <div style="font-size:15px;font-weight:600;color:#fff;margin-bottom:2px;">Equity Curve</div> | |
| <div style="color:#8b949e;font-size:11px;margin-bottom:12px;">Cumulative P&L from closed trades</div> | |
| {svg_chart} | |
| </div> | |
| <!-- Asset Table --> | |
| <div style="background:#151b23;border:1px solid #21262d;border-radius:8px;overflow-x:auto;overflow-y:hidden;"> | |
| <table style="width:100%;min-width:1200px;border-collapse:collapse;"> | |
| <thead> | |
| <tr style="border-bottom:1px solid #21262d;"> | |
| <th style="padding:12px 16px;text-align:left;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">Asset</th> | |
| <th style="padding:12px 16px;text-align:left;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">Status</th> | |
| <th style="padding:12px 16px;text-align:right;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">Price</th> | |
| <th style="padding:12px 16px;text-align:right;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">Equity</th> | |
| <th style="padding:12px 16px;text-align:left;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">PNL (%)</th> | |
| <th style="padding:12px 16px;text-align:left;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">PNL ($)</th> | |
| <th style="padding:12px 16px;text-align:left;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">Trades</th> | |
| <th style="padding:12px 16px;text-align:left;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">Win Rate</th> | |
| <th style="padding:12px 16px;text-align:left;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">Best</th> | |
| <th style="padding:12px 16px;text-align:left;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">Worst</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {asset_rows_html} | |
| </tbody> | |
| </table> | |
| </div> | |
| <!-- Footer --> | |
| <div style="text-align:center;color:#555;font-size:11px;margin-top:16px;"> | |
| DRL Trading System ยท Signals from PPO + Composite Scoring ยท Connected to OKX | |
| </div> | |
| </div> | |
| ''' | |
| components.html(portfolio_html, height=950, scrolling=True) | |
| with tab_performance: | |
| total_pnl = state.get('realized_pnl', 0) | |
| total_trades = state.get('total_trades', 0) | |
| balance = state.get('balance', state.get('total_balance')) | |
| col1, col2, col3, col4 = st.columns(4) | |
| with col1: | |
| st.metric( | |
| label="Total Return", | |
| value="N/A", | |
| delta=f"${total_pnl:.2f}" | |
| ) | |
| with col2: | |
| st.metric( | |
| label="Portfolio Value", | |
| value=f"${balance:,.2f}" if balance is not None else "โ", | |
| ) | |
| with col3: | |
| st.metric( | |
| label="Total Trades", | |
| value=f"{total_trades}", | |
| ) | |
| with col4: | |
| st.metric( | |
| label="Realized P&L", | |
| value=f"${(total_pnl or 0):,.2f}", | |
| ) | |
| backtest_file = project_root / 'data' / 'backtest_report.txt' | |
| if backtest_file.exists(): | |
| st.markdown("### Backtest Results") | |
| with open(backtest_file, 'r') as f: | |
| st.code(f.read()) | |
| with tab_whales: | |
| st.markdown("### ๐ On-Chain Whale Analytics") | |
| whale_alerts = state.get('whale_alerts', []) | |
| if whale_alerts: | |
| import pandas as pd | |
| import plotly.express as px | |
| df = pd.DataFrame(whale_alerts) | |
| df['datetime'] = pd.to_datetime(df['timestamp'], unit='s') | |
| # Approximate USD prices for aggregation (since we only have raw crypto values) | |
| # This allows us to "tell the story" of total USD economic volume moved | |
| price_map = {'BTC': 70000, 'ETH': 3500, 'SOL': 150, 'XRP': 0.6} | |
| df['usd_value'] = df.apply(lambda row: row['value'] * price_map.get(row['chain'], 1), axis=1) | |
| total_usd = df['usd_value'].sum() | |
| top_chain = df.groupby('chain')['usd_value'].sum().idxmax() if not df.empty else "N/A" | |
| col1, col2, col3, col4 = st.columns(4) | |
| col1.metric("Recent Alerts", len(df)) | |
| col2.metric("Trailing Vol (USD)", f"${total_usd/1e6:.1f}M") | |
| col3.metric("Most Active Chain", top_chain) | |
| eth_whales = len(df[df['chain'] == 'ETH']) | |
| xrp_whales = len(df[df['chain'] == 'XRP']) | |
| sol_whales = len(df[df['chain'] == 'SOL']) | |
| btc_whales = len(df[df['chain'] == 'BTC']) | |
| col4.metric("Network Activity", f"BTC:{btc_whales} ETH:{eth_whales} SOL:{sol_whales} XRP:{xrp_whales}") | |
| st.divider() | |
| chart_col, table_col = st.columns([1.2, 1]) | |
| with chart_col: | |
| st.markdown("#### ๐ Whale Volume by Chain (USD)") | |
| # Group by chain and enforce order | |
| chain_vol = df.groupby('chain')['usd_value'].sum().reset_index() | |
| all_chains = pd.DataFrame({'chain': ['BTC', 'ETH', 'SOL', 'XRP']}) | |
| chain_vol = pd.merge(all_chains, chain_vol, on='chain', how='left').fillna(0) | |
| fig = px.bar( | |
| chain_vol, x='chain', y='usd_value', | |
| color='chain', text_auto='.2s', | |
| color_discrete_map={'BTC': '#F7931A', 'ETH': '#627EEA', 'SOL': '#14F195', 'XRP': '#00AAE4'}, | |
| labels={'usd_value': 'Estimated USD Volume', 'chain': 'Network'} | |
| ) | |
| fig.update_layout( | |
| plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)', | |
| font=dict(color='#8b949e'), height=250, margin=dict(l=0, r=0, t=30, b=0), | |
| showlegend=False, | |
| xaxis={'categoryorder':'array', 'categoryarray':['BTC','ETH','SOL','XRP']} | |
| ) | |
| st.plotly_chart(fig, use_container_width=True) | |
| st.markdown("#### ๐ Volume by Entity Type") | |
| if 'wallet_type' in df.columns: | |
| type_vol = df.groupby('wallet_type')['usd_value'].sum().reset_index() | |
| fig_type = px.pie( | |
| type_vol, values='usd_value', names='wallet_type', hole=0.4, | |
| color_discrete_sequence=['#F7931A', '#627EEA', '#14F195', '#00AAE4', '#888888'] | |
| ) | |
| fig_type.update_layout( | |
| plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)', | |
| font=dict(color='#8b949e'), height=250, margin=dict(l=0, r=0, t=30, b=0), | |
| showlegend=True | |
| ) | |
| st.plotly_chart(fig_type, use_container_width=True) | |
| with table_col: | |
| st.markdown("#### ๐ Latest Transaction Feed") | |
| # Format table | |
| cols_to_keep = ['datetime', 'chain', 'wallet_label', 'wallet_type', 'value', 'usd_value', 'link'] | |
| exist_cols = [c for c in cols_to_keep if c in df.columns] | |
| display_df = df[exist_cols].copy() | |
| display_df = display_df.sort_values('datetime', ascending=False) | |
| display_df['datetime'] = display_df['datetime'].dt.strftime('%H:%M:%S') | |
| display_df['value'] = display_df.apply(lambda r: f"{r['value']:,.0f} {r['chain']}" if r['value'] >= 1000 else (f"{r['value']:,.2f} {r['chain']}" if r['value'] >= 1 else f"{r['value']:,.4f} {r['chain']}"), axis=1) | |
| display_df['usd_value'] = display_df['usd_value'].apply(lambda x: f"${x/1e6:,.1f}M" if x >= 1e6 else f"${x/1000:,.0f}k") | |
| rename_map = { | |
| 'datetime': 'Time', | |
| 'chain': 'Net', | |
| 'wallet_label': 'Entity', | |
| 'wallet_type': 'Type', | |
| 'value': 'Amount', | |
| 'usd_value': 'Est. USD', | |
| 'link': 'Explorer' | |
| } | |
| display_df.rename(columns=rename_map, inplace=True) | |
| try: | |
| st.dataframe( | |
| display_df, | |
| column_config={ | |
| "Explorer": st.column_config.LinkColumn("Explorer", display_text="View TX โ") | |
| }, | |
| use_container_width=True, | |
| hide_index=True, | |
| height=400 | |
| ) | |
| except Exception: | |
| # Fallback for older Streamlit versions without column_config | |
| st.dataframe(display_df.drop(columns=['Explorer']), use_container_width=True, height=400) | |
| st.divider() | |
| st.markdown("#### ๐ค AI Momentum Predictions") | |
| st.caption("Real-time directional predictions based on institutional flow and wallet behavioral analysis.") | |
| pred_cols = st.columns(4) | |
| idx = 0 | |
| for chain in ['BTC', 'ETH', 'SOL', 'XRP']: | |
| chain_df = df[df['chain'] == chain] | |
| signal, reason, color = "๐ก STANDBY", f"Insufficient whale data for {chain}.", "#888888" | |
| if not chain_df.empty and 'wallet_type' in chain_df.columns: | |
| c_vol = chain_df['usd_value'].sum() | |
| if c_vol > 0: | |
| types_vol = chain_df.groupby('wallet_type')['usd_value'].sum() | |
| acc_vol = types_vol.get('accumulator', 0) | |
| exc_vol = types_vol.get('exchange', 0) | |
| if acc_vol / c_vol > 0.5: | |
| signal, color = "๐ข BULLISH", "#14F195" | |
| reason = f"Supply Shock: {acc_vol/c_vol*100:.0f}% of volume moving to Accumulators." | |
| elif exc_vol / c_vol > 0.6: | |
| signal, color = "๐ด BEARISH", "#FF4B4B" | |
| reason = f"Sell Wall: {exc_vol/c_vol*100:.0f}% of volume flowing into Exchanges." | |
| else: | |
| signal, color = "๐ก STANDBY", "#F7931A" | |
| reason = "Mixed flows. No clear imbalance." | |
| with pred_cols[idx]: | |
| st.markdown(f''' | |
| <div style="background-color: rgba(255,255,255,0.05); padding: 15px; border-radius: 8px; border-left: 4px solid {color}; height: 140px; overflow: hidden;"> | |
| <h4 style="margin: 0; padding: 0; color: #E2E8F0;">{chain}</h4> | |
| <h5 style="margin: 5px 0 10px 0; color: {color};">{signal}</h5> | |
| <p style="margin: 0; font-size: 0.85em; color: #94A3B8; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;">{reason}</p> | |
| </div> | |
| ''', unsafe_allow_html=True) | |
| idx += 1 | |
| else: | |
| st.info("๐ No whale alerts detected yet. Monitoring blockchain for large movements...") | |
| with tab_testnet: | |
| st.markdown("### ๐งช Binance Testnet Trading") | |
| st.markdown("Real orders on Binance Testnet โ bot decisions mirrored live.") | |
| # All testnet calls go through the API server (client-mode compatible) | |
| import requests as _tn_requests | |
| _api = get_api_url() | |
| # โโ Fetch all data in parallel โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| tn_data, tn_positions_data, tn_pnl_data, tn_trades_data = {}, {}, {}, {} | |
| try: | |
| tn_resp = _tn_requests.get(f'{_api}/api/testnet/status', timeout=15) | |
| tn_data = tn_resp.json() if tn_resp.status_code == 200 else {} | |
| except Exception as _e: | |
| st.error(f"โ Cannot reach API server: {_e}") | |
| try: | |
| _pos_resp = _tn_requests.get(f'{_api}/api/testnet/positions', timeout=20) | |
| tn_positions_data = _pos_resp.json() if _pos_resp.status_code == 200 else {} | |
| except Exception: | |
| tn_positions_data = {} | |
| try: | |
| _pnl_resp = _tn_requests.get(f'{_api}/api/testnet/pnl', timeout=20) | |
| tn_pnl_data = _pnl_resp.json() if _pnl_resp.status_code == 200 else {} | |
| except Exception: | |
| tn_pnl_data = {} | |
| try: | |
| _trades_resp = _tn_requests.get(f'{_api}/api/testnet/trades?limit=200', timeout=15) | |
| tn_trades_data = _trades_resp.json() if _trades_resp.status_code == 200 else {} | |
| except Exception: | |
| tn_trades_data = {} | |
| # โโ Connection status โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| if not tn_data.get('configured', True) or (tn_data.get('error') and not tn_data.get('connected')): | |
| st.error(f"โ ๏ธ {tn_data.get('error', 'Testnet not configured on server')}") | |
| st.info("Set `BINANCE_TESTNET_API_KEY` and `BINANCE_TESTNET_API_SECRET` in server environment.") | |
| else: | |
| _key_pfx = tn_data.get('api_key_prefix', '') | |
| _connected = tn_data.get('connected', False) | |
| status_cols = st.columns([2, 2, 2]) | |
| with status_cols[0]: | |
| if _connected: | |
| st.success(f"โ Connected to Binance Testnet") | |
| else: | |
| st.warning("โ ๏ธ Testnet connection failed") | |
| with status_cols[1]: | |
| if _key_pfx: | |
| st.info(f"๐ Key: `{_key_pfx}`") | |
| with status_cols[2]: | |
| mirror_active = bool(tn_data.get('connected')) | |
| st.info(f"๐ค Auto-Mirror: {'ON (set TESTNET_MIRROR=true)' if mirror_active else 'Enable via TESTNET_MIRROR=true'}") | |
| # โโ PNL Summary metrics โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| st.markdown("---") | |
| st.markdown("### ๐ฐ Portfolio & PNL Summary") | |
| portfolio_value = float(tn_data.get('portfolio_value', 0) or 0) | |
| usdt_balance = float(tn_data.get('usdt_balance', 0) or 0) | |
| realized_pnl = float(tn_pnl_data.get('realized_pnl', 0) or 0) | |
| unrealized_pnl = float(tn_pnl_data.get('unrealized_pnl', 0) or 0) | |
| total_pnl = float(tn_pnl_data.get('total_pnl', 0) or 0) | |
| total_trades = int(tn_pnl_data.get('total_trades', 0) or 0) | |
| closed_trades = int(tn_pnl_data.get('closed_trades', 0) or 0) | |
| win_rate = float(tn_pnl_data.get('win_rate', 0) or 0) | |
| winning_trades = int(tn_pnl_data.get('winning_trades', 0) or 0) | |
| m1, m2, m3, m4 = st.columns(4) | |
| with m1: | |
| st.metric( | |
| "๐ฐ Portfolio Value", | |
| f"${portfolio_value:,.2f}" if portfolio_value is not None else "โ", | |
| ) | |
| with m2: | |
| st.metric( | |
| "๐ต USDT Balance", | |
| f"${usdt_balance:,.2f}" if usdt_balance is not None else "โ", | |
| ) | |
| with m3: | |
| st.metric( | |
| "๐ Realized PNL", | |
| f"${realized_pnl:+,.2f}" if realized_pnl is not None else "โ", | |
| delta=f"${unrealized_pnl:+,.2f} unrealized" if unrealized_pnl else None, | |
| ) | |
| with m4: | |
| wr_str = f"{win_rate * 100:.1f}%" if win_rate is not None else "โ" | |
| st.metric( | |
| "๐ฏ Win Rate", | |
| wr_str, | |
| delta=f"{winning_trades}/{closed_trades} closed" if closed_trades > 0 else None, | |
| ) | |
| # โโ Bot-Mirrored Open Positions โโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| st.markdown("---") | |
| st.markdown("### ๐ Open Positions (Bot-Mirrored)") | |
| bot_positions = tn_positions_data.get('positions', []) | |
| if bot_positions: | |
| pos_rows = [] | |
| for p in bot_positions: | |
| sym = p.get('symbol', '') | |
| side = p.get('side', '') | |
| entry = float(p.get('entry_price', 0) or 0) | |
| curr = float(p.get('current_price', 0) or 0) | |
| amt = float(p.get('amount', 0) or 0) | |
| upnl = float(p.get('unrealized_pnl', 0) or 0) | |
| upnl_pct = float(p.get('unrealized_pnl_pct', 0) or 0) | |
| sl_p = float(p.get('sl', 0) or 0) | |
| tp_p = float(p.get('tp', 0) or 0) | |
| conf = float(p.get('confidence', 0) or 0) | |
| sim = bool(p.get('simulated', False)) | |
| side_display = f"{side} {'(sim)' if sim else ''}" | |
| pos_rows.append({ | |
| 'Symbol': sym, | |
| 'Side': side_display, | |
| 'Entry': f"${entry:,.4f}" if entry else "โ", | |
| 'Current': f"${curr:,.4f}" if curr else "โ", | |
| 'Amount': f"{amt:.6f}", | |
| 'Unreal. PNL': f"${upnl:+,.4f} ({upnl_pct:+.2f}%)" if curr else "โ", | |
| 'SL': f"${sl_p:,.4f}" if sl_p else "โ", | |
| 'TP': f"${tp_p:,.4f}" if tp_p else "โ", | |
| 'Confidence': f"{conf:.2f}" if conf else "โ", | |
| }) | |
| st.dataframe(pd.DataFrame(pos_rows), use_container_width=True, hide_index=True) | |
| else: | |
| st.info("No open bot-mirrored positions.") | |
| # Spot wallet positions from status endpoint | |
| spot_positions = tn_data.get('positions', []) | |
| if spot_positions: | |
| st.markdown("**Spot Wallet Holdings:**") | |
| spot_rows = [{ | |
| 'Asset': p.get('asset', ''), | |
| 'Amount': f"{float(p.get('amount', 0) or 0):.6f}", | |
| 'Price': f"${float(p.get('price', 0) or 0):,.2f}", | |
| 'Value (USDT)': f"${float(p.get('value_usdt', 0) or 0):,.2f}", | |
| } for p in spot_positions] | |
| st.dataframe(pd.DataFrame(spot_rows), use_container_width=True, hide_index=True) | |
| # โโ Equity Curve โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| equity_curve = tn_pnl_data.get('equity_curve', []) | |
| if equity_curve: | |
| st.markdown("---") | |
| st.markdown("### ๐ Equity Curve (Cumulative PNL)") | |
| try: | |
| eq_df = pd.DataFrame(equity_curve) | |
| eq_df['timestamp'] = pd.to_datetime(eq_df['timestamp'], errors='coerce') | |
| eq_df = eq_df.dropna(subset=['timestamp']) | |
| if not eq_df.empty: | |
| import plotly.graph_objects as go | |
| fig_eq = go.Figure() | |
| fig_eq.add_trace(go.Scatter( | |
| x=eq_df['timestamp'], | |
| y=eq_df['cumulative_pnl'], | |
| mode='lines+markers', | |
| name='Cumulative PNL', | |
| line=dict(color='#00e676', width=2), | |
| marker=dict(size=6), | |
| hovertemplate=( | |
| '<b>%{x}</b><br>' | |
| 'Cumulative PNL: $%{y:,.4f}<br>' | |
| '<extra></extra>' | |
| ), | |
| )) | |
| fig_eq.add_hline(y=0, line_dash='dash', line_color='#666') | |
| fig_eq.update_layout( | |
| height=280, | |
| margin=dict(l=0, r=0, t=20, b=0), | |
| paper_bgcolor='rgba(0,0,0,0)', | |
| plot_bgcolor='rgba(0,0,0,0)', | |
| font=dict(color='#E2E8F0'), | |
| xaxis=dict(gridcolor='rgba(255,255,255,0.1)'), | |
| yaxis=dict(gridcolor='rgba(255,255,255,0.1)', tickprefix='$'), | |
| ) | |
| st.plotly_chart(fig_eq, use_container_width=True) | |
| except Exception as _eq_e: | |
| st.warning(f"Equity curve render failed: {_eq_e}") | |
| # โโ Trade History โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| st.markdown("---") | |
| st.markdown("### ๐ Trade History (Testnet Executions)") | |
| all_trades = tn_trades_data.get('trades', []) | |
| if all_trades: | |
| trade_rows = [] | |
| for t in reversed(all_trades): # newest first | |
| ts = t.get('timestamp', '')[:19].replace('T', ' ') if t.get('timestamp') else 'โ' | |
| sym = t.get('symbol', 'โ') | |
| action = t.get('action', 'โ') | |
| price_v = float(t.get('filled_price') or t.get('price', 0) or 0) | |
| amt = float(t.get('amount', 0) or 0) | |
| pnl_v = t.get('pnl') | |
| pnl_str = f"${float(pnl_v):+,.4f}" if pnl_v is not None else "โ" | |
| oid = str(t.get('order_id', '') or 'โ')[:16] | |
| executed = 'โ ' if t.get('executed') else 'โ' | |
| err = t.get('error', '') | |
| trade_rows.append({ | |
| 'Time': ts, | |
| 'Symbol': sym, | |
| 'Action': action, | |
| 'Price': f"${price_v:,.4f}" if price_v else "โ", | |
| 'Amount': f"{amt:.6f}" if amt else "โ", | |
| 'PNL': pnl_str, | |
| 'Order ID': oid, | |
| 'OK': executed, | |
| 'Error': err if err else '', | |
| }) | |
| st.dataframe( | |
| pd.DataFrame(trade_rows), | |
| use_container_width=True, | |
| hide_index=True, | |
| height=320, | |
| ) | |
| else: | |
| st.info("No testnet trades recorded yet. Enable `TESTNET_MIRROR=true` to auto-mirror bot decisions.") | |
| # โโ Live Order Book (open orders) โโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| st.markdown("---") | |
| st.markdown("### ๐ Live Open Orders") | |
| ord_col1, ord_col2 = st.columns([3, 1]) | |
| with ord_col2: | |
| if st.button("๐ Refresh Orders", key="testnet_refresh_orders", use_container_width=True): | |
| st.rerun() | |
| try: | |
| orders_resp = _tn_requests.get(f'{_api}/api/testnet/orders', timeout=15) | |
| open_orders = orders_resp.json().get('orders', []) if orders_resp.status_code == 200 else [] | |
| if open_orders: | |
| ord_rows = [] | |
| for o in open_orders: | |
| ord_rows.append({ | |
| 'Order ID': str(o.get('orderId', o.get('id', 'โ')))[:16], | |
| 'Symbol': o.get('symbol', 'โ'), | |
| 'Side': o.get('side', 'โ'), | |
| 'Type': o.get('type', 'โ'), | |
| 'Price': f"${float(o.get('price', 0) or 0):,.4f}", | |
| 'Qty': f"{float(o.get('origQty', o.get('amount', 0)) or 0):.6f}", | |
| 'Status': o.get('status', 'โ'), | |
| }) | |
| st.dataframe(pd.DataFrame(ord_rows), use_container_width=True, hide_index=True) | |
| else: | |
| st.info("No open orders on testnet.") | |
| except Exception as _oe: | |
| st.warning(f"Could not fetch open orders: {_oe}") | |
| # โโ Manual Trading Controls โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| st.markdown("---") | |
| st.markdown("### ๐ฎ Manual Trading Controls") | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| trade_symbol = st.selectbox( | |
| "Select Pair", ['BTC/USDT', 'ETH/USDT', 'SOL/USDT', 'XRP/USDT'], | |
| key="testnet_symbol" | |
| ) | |
| with col2: | |
| trade_amount = st.number_input( | |
| "Amount (USDT)", min_value=10.0, | |
| max_value=float(usdt_balance) if usdt_balance > 10 else 10000.0, | |
| value=100.0, step=10.0, key="testnet_amount" | |
| ) | |
| btn_cols = st.columns(3) | |
| if btn_cols[0].button("๐ข BUY (Market)", key="testnet_buy", use_container_width=True): | |
| try: | |
| order_resp = _tn_requests.post( | |
| f'{_api}/api/testnet/order', | |
| json={'symbol': trade_symbol, 'side': 'buy', 'amount_usdt': trade_amount}, | |
| timeout=20 | |
| ) | |
| result = order_resp.json() | |
| if result.get('success'): | |
| _amt = float(result.get('amount', 0) or 0) | |
| _pr = float(result.get('price', 0) or 0) | |
| st.success(f"โ BUY: {_amt:.6f} {trade_symbol.split('/')[0]} @ ${_pr:,.2f}") | |
| st.rerun() | |
| else: | |
| st.error(f"โ Order failed: {result.get('error', 'Unknown error')}") | |
| except Exception as e: | |
| st.error(f"โ Order failed: {e}") | |
| if btn_cols[1].button("๐ด SELL (Market)", key="testnet_sell", use_container_width=True): | |
| try: | |
| order_resp = _tn_requests.post( | |
| f'{_api}/api/testnet/order', | |
| json={'symbol': trade_symbol, 'side': 'sell', 'amount_usdt': 0}, | |
| timeout=20 | |
| ) | |
| result = order_resp.json() | |
| if result.get('success'): | |
| _amt = float(result.get('amount', 0) or 0) | |
| _sym = trade_symbol.split('/')[0] | |
| st.success(f"โ SELL: {_amt:.6f} {_sym}") | |
| st.rerun() | |
| else: | |
| st.error(f"โ Order failed: {result.get('error', 'Unknown error')}") | |
| except Exception as e: | |
| st.error(f"โ Order failed: {e}") | |
| if btn_cols[2].button("๐งช Mirror Bot Trade", key="testnet_mirror_btn", use_container_width=True): | |
| try: | |
| _sym_raw = trade_symbol.replace('/', '') | |
| exec_resp = _tn_requests.post( | |
| f'{_api}/api/testnet/execute', | |
| json={'action': 'OPEN_LONG_SPLIT', 'symbol': f"{_sym_raw}USDT" if 'USDT' not in _sym_raw else _sym_raw, 'confidence': 0.65}, | |
| timeout=25 | |
| ) | |
| result = exec_resp.json() | |
| if result.get('success'): | |
| t = result.get('trade', {}) or {} | |
| _pr = float(t.get('price', 0) or 0) | |
| st.success(f"โ Testnet mirror executed: OPEN_LONG @ ${_pr:,.2f}") | |
| st.rerun() | |
| else: | |
| st.error(f"โ Mirror failed: {result.get('error', 'Unknown')}") | |
| except Exception as e: | |
| st.error(f"โ Mirror failed: {e}") | |
| st.markdown("---") | |
| _ic1, _ic2 = st.columns(2) | |
| with _ic1: | |
| st.info(""" | |
| **Bot Auto-Mirror (TESTNET_MIRROR=true):** | |
| - Set env var to enable real-time mirroring | |
| - Every bot decision โ real testnet order | |
| - LONG = real BUY order (50% market + 50% limit) | |
| - SHORT = conceptual (spot testnet only) | |
| - Trades logged to `logs/testnet_trades.json` | |
| """) | |
| with _ic2: | |
| st.warning(""" | |
| **Testnet Notes:** | |
| - Zero real money risk (testnet.binance.vision) | |
| - Testnet funds reset periodically | |
| - SHORT positions tracked conceptually (spot exchange) | |
| - SL/TP managed by bot logic (no exchange OCO orders) | |
| """) | |
| with tab_htf: | |
| st.markdown("### ๐ฎ HTF Agent โ Hierarchical Multi-Timeframe Trader") | |
| st.caption("4-timeframe cascade: 1D โ 4H โ 1H โ 15M | PPO | Walk-forward validated (Avg Sharpe 3.85, +14.8%/2mo)") | |
| api_base = get_api_url() | |
| # โโ Status โโ | |
| try: | |
| htf_status_resp = __import__('requests').get(f"{api_base}/api/htf/status", timeout=8) | |
| htf_status = htf_status_resp.json() if htf_status_resp.ok else {} | |
| except Exception: | |
| htf_status = {} | |
| if not htf_status.get('running'): | |
| st.warning( | |
| "**HTF bot is not running.** Start it with:\n" | |
| "```bash\npython live_trading_htf.py --interval 15\n```\n" | |
| "Add `--live` to enable real execution. Default is dry-run (paper trading)." | |
| ) | |
| else: | |
| dry_tag = " *(dry-run)*" if htf_status.get('dry_run') else " *(LIVE)*" | |
| col1, col2, col3, col4 = st.columns(4) | |
| pos_label = htf_status.get('position_label', 'FLAT') | |
| pos_color = {"LONG": "#00e676", "SHORT": "#ff5252", "FLAT": "#8b949e"}.get(pos_label, "#8b949e") | |
| col1.metric("Position", pos_label) | |
| col2.metric("Balance", f"${htf_status.get('balance', 0):,.2f}" if htf_status.get('balance') else "โ") | |
| col3.metric("Realized PnL", f"${htf_status.get('realized_pnl', 0):+,.2f}") | |
| col4.metric("Unrealized PnL", f"${htf_status.get('unrealized_pnl', 0):+,.2f}") | |
| # Position details | |
| if htf_status.get('position', 0) != 0: | |
| st.markdown(f""" | |
| <div style="background:#151b23;border:1px solid {pos_color};border-radius:8px;padding:14px 18px;margin:8px 0;"> | |
| <b style="color:{pos_color};">{pos_label}</b> | | |
| Entry: <b>${htf_status.get('position_price', 0):,.2f}</b> | | |
| SL: <b style="color:#ff5252;">${htf_status.get('sl_price', 0):,.2f}</b> | | |
| TP: <b style="color:#00e676;">${htf_status.get('tp_price', 0):,.2f}</b> | | |
| Units: <b>{htf_status.get('position_units', 0):.5f}</b> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| agent_cols = st.columns(3) | |
| agent_cols[0].info(f"**Win Rate:** {htf_status.get('win_rate', 0)*100:.1f}%") | |
| agent_cols[1].info(f"**Trades:** {htf_status.get('trade_count', 0)}") | |
| agent_cols[2].info(f"**Mode:** HTF PPO{dry_tag}") | |
| model_path = htf_status.get('model_path') or 'Not loaded' | |
| st.caption(f"Model: `{Path(model_path).name if model_path else 'โ'}` | " | |
| f"Started: {htf_status.get('start_time', 'โ')[:19] if htf_status.get('start_time') else 'โ'}") | |
| st.markdown("---") | |
| # โโ Performance Metrics โโ | |
| st.markdown("#### ๐ Performance Metrics") | |
| try: | |
| perf_resp = __import__('requests').get(f"{api_base}/api/htf/performance", timeout=8) | |
| perf = perf_resp.json() if perf_resp.ok else {} | |
| except Exception: | |
| perf = {} | |
| if perf and not perf.get('error') and perf.get('total_trades', 0) > 0: | |
| pm1, pm2, pm3, pm4, pm5 = st.columns(5) | |
| pm1.metric("Total Trades", perf.get('total_trades', 0)) | |
| pm2.metric("Win Rate", f"{perf.get('win_rate', 0)*100:.1f}%") | |
| pm3.metric("Total PnL", f"${perf.get('total_pnl', 0):+,.2f}") | |
| pm4.metric("Sharpe Ratio", f"{perf.get('sharpe', 0):.2f}") | |
| pm5.metric("Max Drawdown", f"{perf.get('max_drawdown', 0):.1f}%") | |
| pm6, pm7, pm8 = st.columns(3) | |
| pm6.metric("Return", f"{perf.get('return_pct', 0):+.1f}%") | |
| pm7.metric("Best Trade", f"${perf.get('best_trade', 0):+,.2f}") | |
| pm8.metric("Worst Trade", f"${perf.get('worst_trade', 0):+,.2f}") | |
| else: | |
| st.info(perf.get('message', 'No closed trades yet โ metrics will appear after first completed trade.')) | |
| st.markdown("---") | |
| # โโ Trade History โโ | |
| st.markdown("#### ๐ Trade History") | |
| try: | |
| trades_resp = __import__('requests').get(f"{api_base}/api/htf/trades?limit=100", timeout=8) | |
| htf_trades = trades_resp.json().get('trades', []) if trades_resp.ok else [] | |
| except Exception: | |
| htf_trades = [] | |
| if htf_trades: | |
| close_trades = [t for t in reversed(htf_trades) if 'CLOSE' in t.get('action', '').upper()] | |
| open_trades = [t for t in reversed(htf_trades) if 'OPEN' in t.get('action', '').upper()] | |
| if close_trades: | |
| rows = [] | |
| for t in close_trades[:50]: | |
| pnl = t.get('pnl', 0) | |
| rows.append({ | |
| 'Time': t.get('timestamp', '')[:19], | |
| 'Action': t.get('action', ''), | |
| 'Entry': f"${t.get('entry_price', 0):,.2f}", | |
| 'Exit': f"${t.get('exit_price', 0):,.2f}", | |
| 'PnL': f"${pnl:+,.2f}", | |
| 'Reason': t.get('reason', ''), | |
| }) | |
| df_trades = pd.DataFrame(rows) | |
| def _color_pnl(val): | |
| if isinstance(val, str) and val.startswith('$'): | |
| try: | |
| v = float(val.replace('$', '').replace(',', '').replace('+', '')) | |
| return 'color: #00e676' if v > 0 else 'color: #ff5252' | |
| except Exception: | |
| pass | |
| return '' | |
| st.dataframe( | |
| df_trades.style.applymap(_color_pnl, subset=['PnL']), | |
| use_container_width=True, | |
| hide_index=True, | |
| ) | |
| else: | |
| st.info("No closed trades yet.") | |
| if open_trades: | |
| st.markdown("**Open Trades**") | |
| for t in open_trades[:5]: | |
| st.markdown( | |
| f"- `{t.get('action','')}` @ **${t.get('price', 0):,.2f}** " | |
| f"| conf: {t.get('confidence', 0):.2f} " | |
| f"| {t.get('timestamp', '')[:19]}" | |
| ) | |
| else: | |
| st.info("No HTF trades recorded yet. Bot will begin trading on next cycle.") | |
| st.markdown("---") | |
| # โโ Architecture Info โโ | |
| with st.expander("๐ HTF Agent Architecture"): | |
| st.markdown(""" | |
| **Observation Space:** 117 dimensions across 4 timeframes | |
| | Block | Dims | Features | | |
| |-------|------|---------| | |
| | 1D | 20 | Macro trend, regime, HTF structure | | |
| | 4H | 25 | Swing structure, Smart Money Concepts (BOS, CHoCH, OB, FVG) | | |
| | 1H | 30 | Momentum, RSI divergence, MACD, Stochastic | | |
| | 15M | 35 | Micro entry triggers, candle patterns, Wyckoff phase | | |
| | Align | 4 | Cross-TF cascade hierarchy signals | | |
| | Pos | 3 | Position, unrealized PnL, balance ratio | | |
| **Agent:** PPO with `[512, 256, 128]` network, VecNormalize, curriculum training | |
| **Training:** Walk-forward validation (8 folds, 50% position size) | |
| **Validated:** Avg Sharpe 3.85 ยท +14.8% / 2 months ยท Max Drawdown 5.95% | |
| **Risk:** SL 1.5% ยท TP 3.0% ยท Fee 0.04% ยท Min hold 1h ยท Cooldown 30min after loss | |
| """) | |
| with tab_backtest: | |
| st.markdown("### ๐ฌ Backtest") | |
| if IS_CLIENT_MODE: | |
| st.info("๐ **Backtest is not available in client mode.** Run the trading server locally and access backtesting from the server dashboard.") | |
| else: | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| start_date = st.date_input( | |
| "Start Date", | |
| value=datetime.now() - timedelta(days=365) | |
| ) | |
| with col2: | |
| end_date = st.date_input( | |
| "End Date", | |
| value=datetime.now() | |
| ) | |
| if st.button("๐ Run Backtest", key="run_backtest"): | |
| st.info("To run backtest, execute in terminal:") | |
| st.code("python train_advanced.py --evaluate ./data/models/advanced_agent.zip") | |
| with col_sidebar: | |
| st.markdown("### ๐ฏ Agent Status") | |
| # Load state for sidebar | |
| state = get_trading_state(st.session_state.selected_asset) | |
| # Fetch real-time price using 1m data for accuracy | |
| # Position & Portfolio Fragment (Live 15s updates) | |
| render_position_fragment(st.session_state.selected_asset) | |
| # Market Analysis Fragment (Live 15s updates) | |
| render_market_analysis_fragment(st.session_state.selected_asset) | |
| # Agent Status Fragment (Live 15s updates) | |
| render_agent_status_fragment() | |
| # Footer | |
| st.markdown("---") | |
| st.markdown(f""" | |
| <div style="text-align: center; color: #888; font-size: 12px;"> | |
| DRL Trading System v2.1 | Advanced PPO Agent | | |
| <span style="color: #00e676;">โ</span> WebSocket Live Data | | |
| Deployed: {datetime.now().strftime('%Y-%m-%d %H:%M')} UTC | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if __name__ == "__main__": | |
| main() | |