diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 7da1e409..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: hummingbot diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..852ccd4b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,24 @@ +name: Deploy to Server + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + port: ${{ secrets.SERVER_PORT }} + script: | + cd /home/deploy/dashboard + git pull origin main + cd /home/deploy/metal_deploy + docker compose up -d dashboard \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 0657e8ef..00000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Dashboard Docker Buildx Workflow - -on: - pull_request: - types: [closed] - branches: - - main - - development - release: - types: [published, edited] - -jobs: - build_pr: - if: github.event_name == 'pull_request' && github.event.pull_request.merged == true - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4.1.1 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.1.0 - - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push Development Image - if: github.base_ref == 'development' - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: hummingbot/dashboard:development - - - name: Build and push Latest Image - if: github.base_ref == 'main' - uses: docker/build-push-action@v5 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: hummingbot/dashboard:latest - - build_release: - if: github.event_name == 'release' - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4.1.1 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.1.0 - - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Extract tag name - id: get_tag - run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: hummingbot/dashboard:${{ steps.get_tag.outputs.VERSION }} diff --git a/credentials.yml b/credentials.yml index 53f15beb..5f7aa29b 100644 --- a/credentials.yml +++ b/credentials.yml @@ -1,14 +1,10 @@ credentials: usernames: - admin: - email: admin@gmail.com - name: John Doe - logged_in: False - password: abc + metal: + email: john@sonar-labs.com + name: Metal + password: SkA6z77oN7AABCoA19R cookie: expiry_days: 0 key: some_signature_key # Must be string - name: some_cookie_name -pre-authorized: - emails: - - admin@admin.com + name: some_cookie_name \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d35020f8..8e9fe554 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,10 +5,13 @@ services: ports: - "8501:8501" environment: - - AUTH_SYSTEM_ENABLED=False - - BACKEND_API_HOST=backend-api - - BACKEND_API_PORT=8000 - - BACKEND_API_USERNAME=admin - - BACKEND_API_PASSWORD=admin + - AUTH_SYSTEM_ENABLED=True + - BACKEND_API_HOST=https://api.metallorum.duckdns.org + # If you are running the API directly on the Docker host instead of via + # the public reverse proxy, swap the line above for the host alias below. + # - BACKEND_API_HOST=host.docker.internal + # - BACKEND_API_PORT=8000 + - BACKEND_API_USERNAME=metal + - BACKEND_API_PASSWORD=Sk:6z77oN7-/BCo}19R volumes: - .:/home/dashboard diff --git a/frontend/pages/landing.py b/frontend/pages/landing.py index e7987cde..44f67222 100644 --- a/frontend/pages/landing.py +++ b/frontend/pages/landing.py @@ -1,11 +1,27 @@ -import random -from datetime import datetime, timedelta - import pandas as pd import plotly.graph_objects as go import streamlit as st -from frontend.st_utils import initialize_st_page +from CONFIG import AUTH_SYSTEM_ENABLED +from frontend.st_utils import get_backend_api_client, initialize_st_page + + +def _portfolio_value(state: dict) -> float: + """Sum portfolio value, excluding paper-trading accounts, testnet exchanges, + and duplicate wallets (same exchange + identical token holdings).""" + seen = set() + total = 0.0 + for account, exchanges in state.items(): + for exchange, tokens_info in exchanges.items(): + if "testnet" in exchange: + continue + # Deduplicate: same exchange + same token set = same wallet under different credentials + fingerprint = (exchange, frozenset((t.get("token"), round(t.get("value", 0), 4)) for t in tokens_info)) + if fingerprint in seen: + continue + seen.add(fingerprint) + total += sum(t.get("value", 0) for t in tokens_info) + return total initialize_st_page( layout="wide", @@ -22,37 +38,18 @@ color: white; margin: 0.5rem 0; } - - .feature-card { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 15px; - padding: 1.5rem; - backdrop-filter: blur(10px); - margin: 1rem 0; - } - + .stat-number { font-size: 2rem; font-weight: bold; color: #4CAF50; } - - .pulse { - animation: pulse 2s infinite; - } - - @keyframes pulse { - 0% { opacity: 1; } - 50% { opacity: 0.7; } - 100% { opacity: 1; } - } - + .status-active { color: #4CAF50; font-weight: bold; } - + .status-inactive { color: #ff6b6b; font-weight: bold; @@ -70,259 +67,262 @@ """, unsafe_allow_html=True) -# Generate sample data for demonstration -def generate_sample_data(): - """Generate sample trading data for visualization""" - dates = pd.date_range(start=datetime.now() - timedelta(days=30), end=datetime.now(), freq='D') - - # Sample portfolio performance - portfolio_values = [] - base_value = 10000 - for i in range(len(dates)): - change = random.uniform(-0.02, 0.03) # -2% to +3% daily change - base_value *= (1 + change) - portfolio_values.append(base_value) - - return pd.DataFrame({ - 'date': dates, - 'portfolio_value': portfolio_values, - 'daily_return': [random.uniform(-0.05, 0.08) for _ in range(len(dates))] - }) - -# Quick Stats Dashboard +# Require authentication when auth system is enabled +if AUTH_SYSTEM_ENABLED and not st.session_state.get("authentication_status"): + st.info("Please log in to view the dashboard.") + st.stop() + +# Initialize backend client +backend_api_client = get_backend_api_client() + +# Fetch data from API +active_bots_count = 0 +total_portfolio = 0.0 +total_net_pnl = 0.0 +total_volume = 0.0 +total_tp = 0 +total_sl = 0 +total_ts = 0 +total_tl = 0 +total_es = 0 +controllers_data = [] +api_error = None + +try: + response = backend_api_client.bot_orchestration.get_active_bots_status() + if response.get("status") == "success": + active_bots = response.get("data", {}) + for bot_name in active_bots.keys(): + try: + bot_status = backend_api_client.bot_orchestration.get_bot_status(bot_name) + if bot_status.get("status") == "success": + bot_data = bot_status.get("data", {}) + if bot_data.get("status") == "running": + active_bots_count += 1 + performance = bot_data.get("performance", {}) + controller_configs = [] + try: + controller_configs = backend_api_client.controllers.get_bot_controller_configs(bot_name) or [] + except Exception: + pass + for controller_id, inner_dict in performance.items(): + if inner_dict.get("status") == "error": + continue + controller_config = next( + (c for c in controller_configs if c.get("id") == controller_id), {} + ) + if "testnet" in controller_config.get("connector_name", ""): + continue + cp = inner_dict.get("performance", {}) + total_net_pnl += cp.get("global_pnl_quote", 0) + total_volume += cp.get("volume_traded", 0) + close_types = cp.get("close_type_counts", {}) + total_tp += close_types.get("CloseType.TAKE_PROFIT", 0) + total_sl += close_types.get("CloseType.STOP_LOSS", 0) + total_ts += close_types.get("CloseType.TRAILING_STOP", 0) + total_tl += close_types.get("CloseType.TIME_LIMIT", 0) + total_es += close_types.get("CloseType.EARLY_STOP", 0) + controllers_data.append({ + "bot": bot_name, + "name": controller_config.get("controller_name", controller_id), + "connector": controller_config.get("connector_name", "N/A"), + "pair": controller_config.get("trading_pair", "N/A"), + "pnl": cp.get("global_pnl_quote", 0), + "active": not controller_config.get("manual_kill_switch", False), + }) + except Exception: + continue +except Exception as e: + api_error = str(e) + +try: + portfolio_state = backend_api_client.portfolio.get_state() + total_portfolio = _portfolio_value(portfolio_state) +except Exception: + pass + +total_closed = total_tp + total_sl + total_ts + total_tl + total_es +win_count = total_tp + total_ts +win_rate = win_count / total_closed if total_closed > 0 else None + +# Compute 7-day portfolio PNL% +# Per-(account, exchange): use value at 7-day cutoff if available, else first appearance. +# This avoids the distortion of accounts that joined the tracker mid-window. +seven_day_pnl_pct = None +try: + history = backend_api_client.portfolio.get_history() + history_records = history.get("data", []) if isinstance(history, dict) else history + if history_records: + _sorted = sorted(history_records, key=lambda r: pd.to_datetime(r.get("timestamp"))) + _cutoff = pd.to_datetime(_sorted[-1].get("timestamp")) - pd.Timedelta(days=7) + + # For each (account, exchange) key: track the most-recent value at/before cutoff, + # falling back to the first appearance after cutoff. + _baseline = {} # key -> value + for _rec in _sorted: + _ts = pd.to_datetime(_rec.get("timestamp")) + for _acct, _exs in _rec.get("state", {}).items(): + for _ex, _toks in _exs.items(): + if "testnet" in _ex: + continue + _key = (_acct, _ex) + _val = sum(t.get("value", 0) for t in _toks) + if _ts <= _cutoff: + _baseline[_key] = _val # keep updating โ†’ most-recent pre-cutoff + elif _key not in _baseline: + _baseline[_key] = _val # first appearance after cutoff + + # Current values from latest record (with dedup) + _current_total = 0.0 + _keys_seen = set() + for _acct, _exs in _sorted[-1].get("state", {}).items(): + for _ex, _toks in _exs.items(): + if "testnet" in _ex: + continue + _key = (_acct, _ex) + if _key in _keys_seen: + continue + _keys_seen.add(_key) + _current_total += sum(t.get("value", 0) for t in _toks) + + _baseline_total = sum(_baseline.get(k, 0) for k in _keys_seen) + if _baseline_total > 0: + seven_day_pnl_pct = (_current_total - _baseline_total) / _baseline_total +except Exception: + pass + +# Live Dashboard Overview st.markdown("## ๐Ÿ“Š Live Dashboard Overview") -# Mock data warning -st.warning(""" -โš ๏ธ **Demo Data Notice**: The metrics, charts, and statistics shown below are simulated/mocked data for demonstration purposes. -This showcases how real trading data would be presented in the dashboard once connected to live trading bots. -""") +if api_error: + st.error(f"Failed to connect to backend API: {api_error}") -col1, col2, col3, col4 = st.columns(4) +col1, col2, col3, col4, col5 = st.columns(5) with col1: - st.markdown(""" + st.markdown(f"""

๐Ÿ”„ Active Bots

-
3
-

Currently Trading

+
{active_bots_count}
+

Currently Running

""", unsafe_allow_html=True) with col2: - st.markdown(""" + portfolio_display = f"${total_portfolio:,.2f}" if total_portfolio > 0 else "N/A" + st.markdown(f"""

๐Ÿ’ฐ Total Portfolio

-
$12,847
-

+2.3% Today

+
{portfolio_display}
+

Across All Accounts

""", unsafe_allow_html=True) with col3: - st.markdown(""" + win_rate_display = f"{win_rate:.1%}" if win_rate is not None else "N/A" + win_rate_label = f"{total_closed} closed positions" if total_closed > 0 else "No closed positions" + st.markdown(f"""

๐Ÿ“ˆ Win Rate

-
74.2%
-

Last 30 Days

+
{win_rate_display}
+

{win_rate_label}

""", unsafe_allow_html=True) with col4: - st.markdown(""" + pnl_color = "#4CAF50" if total_net_pnl >= 0 else "#ff6b6b" + pnl_sign = "+" if total_net_pnl >= 0 else "" + st.markdown(f"""
-

โšก Total Trades

-
1,247
-

This Month

-
- """, unsafe_allow_html=True) - -st.divider() - -# Performance Chart -col1, col2 = st.columns([2, 1]) - -with col1: - st.markdown("### ๐Ÿ“ˆ Portfolio Performance (30 Days)") - - # Generate and display sample performance chart - df = generate_sample_data() - - fig = go.Figure() - fig.add_trace(go.Scatter( - x=df['date'], - y=df['portfolio_value'], - mode='lines+markers', - line=dict(color='#4CAF50', width=3), - fill='tonexty', - fillcolor='rgba(76, 175, 80, 0.1)', - name='Portfolio Value' - )) - - fig.update_layout( - template='plotly_dark', - height=400, - showlegend=False, - margin=dict(l=0, r=0, t=0, b=0), - xaxis=dict(showgrid=False), - yaxis=dict(showgrid=True, gridcolor='rgba(255,255,255,0.1)') - ) - - st.plotly_chart(fig, use_container_width=True) - -with col2: - st.markdown("### ๐ŸŽฏ Strategy Status") - - strategies = [ - {"name": "Market Making", "status": "active", "pnl": "+$342"}, - {"name": "Arbitrage", "status": "active", "pnl": "+$156"}, - {"name": "Grid Trading", "status": "active", "pnl": "+$89"}, - {"name": "DCA Bot", "status": "inactive", "pnl": "+$234"}, - ] - - for strategy in strategies: - status_class = "status-active" if strategy["status"] == "active" else "status-inactive" - status_icon = "๐ŸŸข" if strategy["status"] == "active" else "๐Ÿ”ด" - - st.markdown(f""" -
-
-
- {strategy['name']}
- {status_icon} {strategy['status'].title()} -
-
- {strategy['pnl']} -
-
-
- """, unsafe_allow_html=True) - -st.divider() - -# Feature Showcase -st.markdown("## ๐Ÿš€ Platform Features") - -col1, col2, col3 = st.columns(3) - -with col1: - st.markdown(""" -
-
-
๐ŸŽฏ
-

Strategy Development

-
- +

๐Ÿ’น NET PNL

+
{pnl_sign}${total_net_pnl:,.2f}
+

${total_volume:,.2f} volume traded

""", unsafe_allow_html=True) -with col2: - st.markdown(""" -
-
-
๐Ÿ“Š
-

Analytics & Insights

-
- -
- """, unsafe_allow_html=True) - -with col3: - st.markdown(""" -
-
-
โšก
-

Live Trading

-
- +with col5: + if seven_day_pnl_pct is not None: + _7d_color = "#4CAF50" if seven_day_pnl_pct >= 0 else "#ff6b6b" + _7d_sign = "+" if seven_day_pnl_pct >= 0 else "" + _7d_display = f"{_7d_sign}{seven_day_pnl_pct:.2%}" + else: + _7d_color = "#888" + _7d_display = "N/A" + st.markdown(f""" +
+

๐Ÿ“… 7D PNL (%)

+
{_7d_display}
+

Portfolio 7-day change

""", unsafe_allow_html=True) st.divider() -# Quick Actions -st.markdown("## โšก Quick Actions") - -# Alert for mocked navigation -st.info("โ„น๏ธ **Note**: This is a mocked landing page. The Quick Actions buttons below are for demonstration purposes and the page navigation is not functional.") - -col1, col2, col3, col4 = st.columns(4) - -with col1: - if st.button("๐Ÿš€ Deploy Strategy", use_container_width=True, type="primary"): - st.error("๐Ÿšซ Navigation unavailable - This is a mocked landing page for demonstration purposes.") - -with col2: - if st.button("๐Ÿ“Š View Performance", use_container_width=True): - st.error("๐Ÿšซ Navigation unavailable - This is a mocked landing page for demonstration purposes.") - -with col3: - if st.button("๐Ÿ” Backtesting", use_container_width=True): - st.error("๐Ÿšซ Navigation unavailable - This is a mocked landing page for demonstration purposes.") - -with col4: - if st.button("๐Ÿ—ƒ๏ธ Archived Bots", use_container_width=True): - st.error("๐Ÿšซ Navigation unavailable - This is a mocked landing page for demonstration purposes.") - -st.divider() - -# Community & Resources +# Portfolio performance chart col1, col2 = st.columns([2, 1]) with col1: - st.markdown("### ๐ŸŽฌ Learn & Explore") - - st.video("https://youtu.be/7eHiMPRBQLQ?si=PAvCq0D5QDZz1h1D") - -with col2: - st.markdown("### ๐Ÿ’ฌ Join Our Community") - - st.markdown(""" -
-

๐ŸŒŸ Connect with Traders

-

Join thousands of algorithmic traders sharing strategies and insights!

-
- - ๐Ÿ’ฌ Join Discord - -

- - ๐Ÿ› Report Issues - -
- """, unsafe_allow_html=True) - -# Footer stats -st.markdown("---") -col1, col2, col3, col4 = st.columns(4) - -with col1: - st.metric("๐ŸŒ Global Users", "10,000+") + st.markdown("### ๐Ÿ“ˆ Portfolio Value (History)") + try: + history = backend_api_client.portfolio.get_history() + history_records = history.get("data", []) if isinstance(history, dict) else history + if history_records: + data = [] + for record in history_records: + timestamp = record.get("timestamp") + state = record.get("state", {}) + data.append({"timestamp": timestamp, "value": _portfolio_value(state)}) + if data: + df = pd.DataFrame(data) + df["timestamp"] = pd.to_datetime(df["timestamp"]) + df = df.sort_values("timestamp") + fig = go.Figure() + fig.add_trace(go.Scatter( + x=df["timestamp"], + y=df["value"], + mode="lines", + line=dict(color="#4CAF50", width=2), + fill="tozeroy", + fillcolor="rgba(76, 175, 80, 0.1)", + name="Portfolio Value" + )) + fig.update_layout( + template="plotly_dark", + height=350, + showlegend=False, + margin=dict(l=0, r=0, t=0, b=0), + xaxis=dict(showgrid=False), + yaxis=dict(showgrid=True, gridcolor="rgba(255,255,255,0.1)") + ) + st.plotly_chart(fig, use_container_width=True) + else: + st.info("No portfolio history available.") + else: + st.info("No portfolio history available.") + except Exception as e: + st.info(f"Portfolio history unavailable: {e}") with col2: - st.metric("๐Ÿ’ฑ Exchanges", "20+") - -with col3: - st.metric("๐Ÿ”„ Daily Volume", "$2.5M+") - -with col4: - st.metric("โญ GitHub Stars", "7,800+") + st.markdown("### ๐ŸŽฏ Controller Status") + if controllers_data: + for ctrl in controllers_data: + status_icon = "๐ŸŸข" if ctrl["active"] else "๐Ÿ”ด" + status_label = "Active" if ctrl["active"] else "Stopped" + pnl_color = "#4CAF50" if ctrl["pnl"] >= 0 else "#ff6b6b" + pnl_sign = "+" if ctrl["pnl"] >= 0 else "" + st.markdown(f""" +
+
+
+ {ctrl['name']}
+ {ctrl['connector']} ยท {ctrl['pair']}
+ {status_icon} {status_label} +
+
+ {pnl_sign}${ctrl['pnl']:,.2f} +
+
+
+ """, unsafe_allow_html=True) + else: + st.info("No active bots found.") diff --git a/frontend/pages/orchestration/instances/app.py b/frontend/pages/orchestration/instances/app.py index d5d3eedf..1eb8ad0f 100644 --- a/frontend/pages/orchestration/instances/app.py +++ b/frontend/pages/orchestration/instances/app.py @@ -1,4 +1,9 @@ +import json +import re import time +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import quote import pandas as pd import streamlit as st @@ -83,6 +88,74 @@ def start_controllers(bot_name, controllers): return success_count > 0 +def parse_bot_launch_time(bot_name): + """Extract launch datetime from bot name pattern โ€ฆ-YYYYMMDD-HHMMSS.""" + match = re.search(r'(\d{8})-(\d{6})$', bot_name) + if match: + try: + return datetime.strptime( + f"{match.group(1)}-{match.group(2)}", "%Y%m%d-%H%M%S" + ).replace(tzinfo=timezone.utc) + except ValueError: + return None + return None + + +def is_simulated_connector(connector_name: str) -> bool: + return connector_name.endswith("_paper_trade") or "testnet" in connector_name + + +def get_price_connector(connector_name: str) -> str: + """Normalize simulated connectors to their live market-data equivalent.""" + normalized = connector_name.replace("_paper_trade", "") + normalized = re.sub(r"(^|[_-])testnet(?=$|[_-])", r"\1", normalized) + normalized = re.sub(r"[_-]{2,}", "_", normalized).strip("_-") + return normalized or connector_name + + +BNH_PRICES_FILE = Path(__file__).parents[4] / "data" / "bnh_entry_prices.json" + + +def _load_bnh_prices() -> dict: + if BNH_PRICES_FILE.exists(): + try: + return json.loads(BNH_PRICES_FILE.read_text()) + except Exception: + pass + return {} + + +def _save_bnh_prices(data: dict): + BNH_PRICES_FILE.parent.mkdir(parents=True, exist_ok=True) + BNH_PRICES_FILE.write_text(json.dumps(data, indent=2)) + + +def get_bnh_entry_price(bot_name: str, controller_id: str, current_price: float): + """Return the stored entry price, writing it the first time it is seen.""" + key = f"{bot_name}_{controller_id}" + prices = _load_bnh_prices() + if key not in prices and current_price is not None: + prices[key] = current_price + _save_bnh_prices(prices) + return prices.get(key) + + +def fetch_current_price(connector_name: str, trading_pair: str): + try: + response = backend_api_client.market_data.get_prices( + get_price_connector(connector_name), trading_pair + ) + if isinstance(response, dict): + if response.get("status") == "success": + return response.get("data", {}).get(trading_pair) + if "prices" in response: + return response.get("prices", {}).get(trading_pair) + return response.get(trading_pair) + except Exception: + pass + return None + + def render_bot_card(bot_name): """Render a bot performance card using native Streamlit components.""" try: @@ -115,14 +188,19 @@ def render_bot_card(bot_name): bot_data = bot_status.get("data", {}) is_running = bot_data.get("status") == "running" performance = bot_data.get("performance", {}) - error_logs = bot_data.get("error_logs", []) - general_logs = bot_data.get("general_logs", []) + simulated = bool(controller_configs) and all( + is_simulated_connector(c.get("connector_name", "")) + for c in controller_configs + ) # Bot header col1, col2, col3 = st.columns([2, 1, 1]) with col1: if is_running: - st.success(f"๐Ÿค– **{bot_name}** - Running") + if simulated: + st.info(f"๐Ÿ“„ **{bot_name}** - Running (Paper/Testnet)") + else: + st.success(f"๐Ÿค– **{bot_name}** - Running") else: st.warning(f"๐Ÿค– **{bot_name}** - Stopped") @@ -168,6 +246,16 @@ def render_bot_card(bot_name): global_pnl_quote = controller_performance.get("global_pnl_quote", 0) volume_traded = controller_performance.get("volume_traded", 0) + # Buy-and-hold benchmark + current_price = None + entry_price = None + bnh_return = None + if connector_name != "N/A" and trading_pair != "N/A": + current_price = fetch_current_price(connector_name, trading_pair) + entry_price = get_bnh_entry_price(bot_name, controller, current_price) + if entry_price and current_price and entry_price != 0: + bnh_return = (current_price - entry_price) / entry_price + close_types = controller_performance.get("close_type_counts", {}) tp = close_types.get("CloseType.TAKE_PROFIT", 0) sl = close_types.get("CloseType.STOP_LOSS", 0) @@ -188,6 +276,9 @@ def render_bot_card(bot_name): "NET PNL ($)": round(global_pnl_quote, 2), "Volume ($)": round(volume_traded, 2), "Close Types": close_types_str, + "B&H Entry ($)": round(entry_price, 4) if entry_price is not None else "โ€”", + "B&H Current ($)": round(current_price, 4) if current_price is not None else "โ€”", + "B&H Return (%)": f"{bnh_return:.2%}" if bnh_return is not None else "โ€”", "_controller_id": controller } @@ -202,8 +293,16 @@ def render_bot_card(bot_name): total_global_pnl_pct = total_global_pnl_quote / total_volume_traded if total_volume_traded > 0 else 0 + # Per-bot 7D PNL%: if bot launched within last 7 days, current session = 7D window + launch_time = parse_bot_launch_time(bot_name) + if launch_time is not None: + bot_age_days = (datetime.now(timezone.utc) - launch_time).total_seconds() / 86400 + seven_day_pnl_pct = total_global_pnl_pct if bot_age_days <= 7 else None + else: + seven_day_pnl_pct = None + # Display metrics - col1, col2, col3, col4 = st.columns(4) + col1, col2, col3, col4, col5 = st.columns(5) with col1: st.metric("๐Ÿฆ NET PNL", f"${total_global_pnl_quote:.2f}") @@ -213,6 +312,11 @@ def render_bot_card(bot_name): st.metric("๐Ÿ“Š NET PNL (%)", f"{total_global_pnl_pct:.2%}") with col4: st.metric("๐Ÿ’ธ Volume Traded", f"${total_volume_traded:.2f}") + with col5: + if seven_day_pnl_pct is not None: + st.metric("๐Ÿ“… 7D PNL (%)", f"{seven_day_pnl_pct:.2%}") + else: + st.metric("๐Ÿ“… 7D PNL (%)", "N/A") # Active Controllers if active_controllers: @@ -249,6 +353,13 @@ def render_bot_card(bot_name): stop_controllers(bot_name, selected_active) time.sleep(1) + with st.expander("๐Ÿ”ง Active Controller Parameters"): + for ctrl_info in active_controllers: + ctrl_id = ctrl_info["_controller_id"] + config = next((c for c in controller_configs if c.get("id") == ctrl_id), {}) + st.markdown(f"**{ctrl_info['Controller']}** โ€” `{ctrl_id}`") + st.json(config, expanded=False) + # Stopped Controllers if stopped_controllers: st.warning("๐Ÿ’ค **Stopped Controllers:** Controllers that are paused or stopped") @@ -284,32 +395,23 @@ def render_bot_card(bot_name): start_controllers(bot_name, selected_stopped) time.sleep(1) + with st.expander("๐Ÿ”ง Stopped Controller Parameters"): + for ctrl_info in stopped_controllers: + ctrl_id = ctrl_info["_controller_id"] + config = next((c for c in controller_configs if c.get("id") == ctrl_id), {}) + st.markdown(f"**{ctrl_info['Controller']}** โ€” `{ctrl_id}`") + st.json(config, expanded=False) + # Error Controllers if error_controllers: st.error("๐Ÿ’€ **Controllers with Errors:** Controllers that encountered errors") error_df = pd.DataFrame(error_controllers) st.dataframe(error_df, use_container_width=True, hide_index=True) - # Logs sections (available for both running and stopped bots) - with st.expander("๐Ÿ“‹ Error Logs"): - if error_logs: - for log in error_logs[:50]: - timestamp = log.get("timestamp", "") - message = log.get("msg", "") - logger_name = log.get("logger_name", "") - st.text(f"{timestamp} - {logger_name}: {message}") - else: - st.info("No error logs available.") - - with st.expander("๐Ÿ“ General Logs"): - if general_logs: - for log in general_logs[:50]: - timestamp = pd.to_datetime(int(log.get("timestamp", 0)), unit="s") - message = log.get("msg", "") - logger_name = log.get("logger_name", "") - st.text(f"{timestamp} - {logger_name}: {message}") - else: - st.info("No general logs available.") + # Datadog logs link + datadog_query = quote(f"@bot_name:{bot_name}") + datadog_url = f"https://app.us5.datadoghq.com/logs?query={datadog_query}" + st.markdown(f"๐Ÿ“‹ [View Logs in Datadog โ†—]({datadog_url})") except Exception as e: with st.container(border=True): diff --git a/frontend/st_utils.py b/frontend/st_utils.py index d6b2c7c2..fa21b02c 100644 --- a/frontend/st_utils.py +++ b/frontend/st_utils.py @@ -1,7 +1,9 @@ import inspect import os.path +import socket from pathlib import Path from typing import Optional, Union +from urllib.parse import urlparse import pandas as pd import streamlit as st @@ -73,16 +75,35 @@ def get_backend_api_client(): from CONFIG import BACKEND_API_HOST, BACKEND_API_PASSWORD, BACKEND_API_PORT, BACKEND_API_USERNAME + if not BACKEND_API_HOST.startswith(('http://', 'https://')): + base_url = f"http://{BACKEND_API_HOST}:{BACKEND_API_PORT}" + else: + base_url = BACKEND_API_HOST.rstrip('/') + + def get_resolution_hint() -> str: + parsed = urlparse(base_url) + hostname = parsed.hostname + if not hostname: + return "" + + try: + socket.getaddrinfo(hostname, parsed.port or (443 if parsed.scheme == "https" else 80)) + except socket.gaierror: + return ( + f" Host '{hostname}' is not resolvable from the dashboard runtime. " + "Set BACKEND_API_HOST to a reachable URL such as " + "'https://api.metallorum.duckdns.org', or run the dashboard and API " + "on the same Docker network." + ) + except OSError: + return "" + + return "" + # Use Streamlit session state to store singleton instance if 'backend_api_client' not in st.session_state or st.session_state.backend_api_client is None: try: # Create and enter the client context - # Ensure URL has proper protocol - if not BACKEND_API_HOST.startswith(('http://', 'https://')): - base_url = f"http://{BACKEND_API_HOST}:{BACKEND_API_PORT}" - else: - base_url = f"{BACKEND_API_HOST}:{BACKEND_API_PORT}" - client = SyncHummingbotAPIClient( base_url=base_url, username=BACKEND_API_USERNAME, @@ -115,7 +136,7 @@ def cleanup_client(): st.session_state.backend_api_client = client except Exception as e: - st.error(f"Failed to initialize API client: {str(e)}") + st.error(f"Failed to initialize API client at {base_url}: {e}.{get_resolution_hint()}") st.stop() return st.session_state.backend_api_client