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
-
-
- - โจ Visual Strategy Builder
- - ๐ง Advanced Configuration
- - ๐ Custom Parameters
- - ๐งช Testing Environment
-
+
๐น NET PNL
+
{pnl_sign}${total_net_pnl:,.2f}
+
${total_volume:,.2f} volume traded
""", unsafe_allow_html=True)
-with col2:
- st.markdown("""
-
-
-
๐
-
Analytics & Insights
-
-
- - ๐ Real-time Performance
- - ๐ Advanced Backtesting
- - ๐ Detailed Reports
- - ๐จ Interactive Charts
-
-
- """, unsafe_allow_html=True)
-
-with col3:
- st.markdown("""
-
-
-
- - ๐ค Automated Execution
- - ๐ก Real-time Monitoring
- - ๐ก๏ธ Risk Management
- - ๐ Smart Alerts
-
+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("""
-
- """, 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