From 8c1f70d7c1cd32270eb83ecfbd3301548c91f573 Mon Sep 17 00:00:00 2001 From: Marco Lavagnino Date: Mon, 16 Mar 2026 11:58:57 -0300 Subject: [PATCH 01/11] implement hardcoded stats --- frontend/pages/landing.py | 417 ++++++++++++++++---------------------- frontend/st_utils.py | 2 +- 2 files changed, 173 insertions(+), 246 deletions(-) diff --git a/frontend/pages/landing.py b/frontend/pages/landing.py index e7987cde..4896e730 100644 --- a/frontend/pages/landing.py +++ b/frontend/pages/landing.py @@ -1,11 +1,9 @@ -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 initialize_st_page( layout="wide", @@ -22,37 +20,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 +49,207 @@ """, 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 + 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) + controller_config = next( + (c for c in controller_configs if c.get("id") == controller_id), {} + ) + 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() + for account, exchanges in portfolio_state.items(): + for exchange, tokens_info in exchanges.items(): + for info in tokens_info: + total_portfolio += info.get("value", 0) +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 + +# 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) 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

+

๐Ÿ’น NET PNL

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

${total_volume:,.2f} volume traded

""", unsafe_allow_html=True) st.divider() -# Performance Chart +# Portfolio 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) + 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", {}) + total_value = sum( + info.get("value", 0) + for exchanges in state.values() + for tokens_info in exchanges.values() + for info in tokens_info + ) + data.append({"timestamp": timestamp, "value": total_value}) + 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.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']} + 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) - -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
  • -
-
- """, 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(""" -
-
-
โšก
-

Live Trading

-
-
    -
  • ๐Ÿค– Automated Execution
  • -
  • ๐Ÿ“ก Real-time Monitoring
  • -
  • ๐Ÿ›ก๏ธ Risk Management
  • -
  • ๐Ÿ”” Smart Alerts
  • -
-
- """, 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 -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+") - -with col2: - st.metric("๐Ÿ’ฑ Exchanges", "20+") - -with col3: - st.metric("๐Ÿ”„ Daily Volume", "$2.5M+") - -with col4: - st.metric("โญ GitHub Stars", "7,800+") + """, unsafe_allow_html=True) + else: + st.info("No active bots found.") diff --git a/frontend/st_utils.py b/frontend/st_utils.py index d6b2c7c2..289b77d8 100644 --- a/frontend/st_utils.py +++ b/frontend/st_utils.py @@ -81,7 +81,7 @@ def get_backend_api_client(): 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}" + base_url = BACKEND_API_HOST.rstrip('/') client = SyncHummingbotAPIClient( base_url=base_url, From cf29eed040a0939a41d379a45a28a6abdcf5bd01 Mon Sep 17 00:00:00 2001 From: Marco Lavagnino Date: Mon, 16 Mar 2026 12:10:47 -0300 Subject: [PATCH 02/11] credentials --- credentials.yml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) 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 From 2bd5519220d676f41d761b91a154e114c678b1f9 Mon Sep 17 00:00:00 2001 From: Marco Lavagnino Date: Fri, 20 Mar 2026 11:37:42 -0300 Subject: [PATCH 03/11] add CI --- .github/FUNDING.yml | 1 - .github/workflows/main.yml | 80 -------------------------------------- docker-compose.yml | 10 ++--- 3 files changed, 5 insertions(+), 86 deletions(-) delete mode 100644 .github/FUNDING.yml delete mode 100644 .github/workflows/main.yml 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/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/docker-compose.yml b/docker-compose.yml index d35020f8..b7932f09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,10 +5,10 @@ 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 + # - BACKEND_API_PORT=80 + - BACKEND_API_USERNAME=metal + - BACKEND_API_PASSWORD=Sk:6z77oN7-/BCo}19R volumes: - .:/home/dashboard From c68c72b7593f398f38952ae298e20343e0fe9ae4 Mon Sep 17 00:00:00 2001 From: Marco Lavagnino Date: Fri, 20 Mar 2026 11:46:35 -0300 Subject: [PATCH 04/11] add workflow --- .github/workflows/deploy.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..dfbe825a --- /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/hummingbot-api + docker compose up -d dashboard \ No newline at end of file From 15cf9495181bfcb7868419cc61c4d394237f47d5 Mon Sep 17 00:00:00 2001 From: Marco Lavagnino Date: Fri, 20 Mar 2026 11:55:59 -0300 Subject: [PATCH 05/11] show controller parameters --- frontend/pages/orchestration/instances/app.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/pages/orchestration/instances/app.py b/frontend/pages/orchestration/instances/app.py index d5d3eedf..c9039143 100644 --- a/frontend/pages/orchestration/instances/app.py +++ b/frontend/pages/orchestration/instances/app.py @@ -249,6 +249,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,6 +291,13 @@ 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") From bfff1657a4997db68c74eb14949810d967f06077 Mon Sep 17 00:00:00 2001 From: Marco Lavagnino Date: Fri, 27 Mar 2026 12:37:44 -0300 Subject: [PATCH 06/11] add 7 day PNL --- .github/workflows/deploy.yml | 2 +- frontend/pages/landing.py | 46 +++++++++++++++- frontend/pages/orchestration/instances/app.py | 55 ++++++++++++------- 3 files changed, 80 insertions(+), 23 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dfbe825a..7fd8b2ca 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: key: ${{ secrets.SERVER_SSH_KEY }} port: ${{ secrets.SERVER_PORT }} script: | - cd /home/deploy/dashboard + cd /home/deploy/metal_deploy git pull origin main cd /home/deploy/hummingbot-api docker compose up -d dashboard \ No newline at end of file diff --git a/frontend/pages/landing.py b/frontend/pages/landing.py index 4896e730..8e4d6941 100644 --- a/frontend/pages/landing.py +++ b/frontend/pages/landing.py @@ -128,13 +128,41 @@ win_count = total_tp + total_ts win_rate = win_count / total_closed if total_closed > 0 else None +# Compute 7-day portfolio PNL% +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: + _hist_data = [] + for record in history_records: + _ts = record.get("timestamp") + _state = record.get("state", {}) + _val = sum( + info.get("value", 0) + for exchanges in _state.values() + for tokens_info in exchanges.values() + for info in tokens_info + ) + _hist_data.append({"timestamp": pd.to_datetime(_ts), "value": _val}) + _df = pd.DataFrame(_hist_data).sort_values("timestamp") + if not _df.empty: + _current = _df.iloc[-1]["value"] + _cutoff = _df.iloc[-1]["timestamp"] - pd.Timedelta(days=7) + _older = _df[_df["timestamp"] <= _cutoff] + _base = _older.iloc[-1]["value"] if not _older.empty else _df.iloc[0]["value"] + if _base != 0: + seven_day_pnl_pct = (_current - _base) / _base +except Exception: + pass + # Live Dashboard Overview st.markdown("## ๐Ÿ“Š Live Dashboard Overview") 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(f""" @@ -177,6 +205,22 @@
""", unsafe_allow_html=True) +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() # Portfolio performance chart diff --git a/frontend/pages/orchestration/instances/app.py b/frontend/pages/orchestration/instances/app.py index c9039143..2a7db15d 100644 --- a/frontend/pages/orchestration/instances/app.py +++ b/frontend/pages/orchestration/instances/app.py @@ -1,7 +1,10 @@ +import re import time +from datetime import datetime, timezone import pandas as pd import streamlit as st +from urllib.parse import quote from frontend.st_utils import get_backend_api_client, initialize_st_page @@ -83,6 +86,19 @@ 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 render_bot_card(bot_name): """Render a bot performance card using native Streamlit components.""" try: @@ -202,8 +218,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 +237,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: @@ -304,26 +333,10 @@ def render_bot_card(bot_name): 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): From 20d2ee4e4602faa56254ea2e63f8ecadb40ce9b2 Mon Sep 17 00:00:00 2001 From: Marco Lavagnino Date: Fri, 27 Mar 2026 12:58:49 -0300 Subject: [PATCH 07/11] fix repo name --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7fd8b2ca..852ccd4b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: key: ${{ secrets.SERVER_SSH_KEY }} port: ${{ secrets.SERVER_PORT }} script: | - cd /home/deploy/metal_deploy + cd /home/deploy/dashboard git pull origin main - cd /home/deploy/hummingbot-api + cd /home/deploy/metal_deploy docker compose up -d dashboard \ No newline at end of file From 20bb6fc18f3c0090d5106cd2a7620b72d0c2b529 Mon Sep 17 00:00:00 2001 From: Marco Lavagnino Date: Fri, 27 Mar 2026 16:52:53 -0300 Subject: [PATCH 08/11] stop counting paper trade towards the 7 day pnl % --- frontend/pages/landing.py | 95 +++++++++++++++++++++++++-------------- 1 file changed, 62 insertions(+), 33 deletions(-) diff --git a/frontend/pages/landing.py b/frontend/pages/landing.py index 8e4d6941..44f67222 100644 --- a/frontend/pages/landing.py +++ b/frontend/pages/landing.py @@ -5,6 +5,24 @@ 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", show_readme=False @@ -90,6 +108,11 @@ 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) @@ -99,9 +122,6 @@ 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) - controller_config = next( - (c for c in controller_configs if c.get("id") == controller_id), {} - ) controllers_data.append({ "bot": bot_name, "name": controller_config.get("controller_name", controller_id), @@ -117,10 +137,7 @@ try: portfolio_state = backend_api_client.portfolio.get_state() - for account, exchanges in portfolio_state.items(): - for exchange, tokens_info in exchanges.items(): - for info in tokens_info: - total_portfolio += info.get("value", 0) + total_portfolio = _portfolio_value(portfolio_state) except Exception: pass @@ -129,30 +146,48 @@ 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: - _hist_data = [] - for record in history_records: - _ts = record.get("timestamp") - _state = record.get("state", {}) - _val = sum( - info.get("value", 0) - for exchanges in _state.values() - for tokens_info in exchanges.values() - for info in tokens_info - ) - _hist_data.append({"timestamp": pd.to_datetime(_ts), "value": _val}) - _df = pd.DataFrame(_hist_data).sort_values("timestamp") - if not _df.empty: - _current = _df.iloc[-1]["value"] - _cutoff = _df.iloc[-1]["timestamp"] - pd.Timedelta(days=7) - _older = _df[_df["timestamp"] <= _cutoff] - _base = _older.iloc[-1]["value"] if not _older.empty else _df.iloc[0]["value"] - if _base != 0: - seven_day_pnl_pct = (_current - _base) / _base + _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 @@ -236,13 +271,7 @@ for record in history_records: timestamp = record.get("timestamp") state = record.get("state", {}) - total_value = sum( - info.get("value", 0) - for exchanges in state.values() - for tokens_info in exchanges.values() - for info in tokens_info - ) - data.append({"timestamp": timestamp, "value": total_value}) + data.append({"timestamp": timestamp, "value": _portfolio_value(state)}) if data: df = pd.DataFrame(data) df["timestamp"] = pd.to_datetime(df["timestamp"]) From 540436dd23c2d3644120f8581f48717d546be29c Mon Sep 17 00:00:00 2001 From: Jefferson Pita Date: Mon, 30 Mar 2026 14:53:25 -0300 Subject: [PATCH 09/11] paper trade testnet --- docker-compose.yml | 5 +- frontend/pages/orchestration/instances/app.py | 76 ++++++++++++++++++- frontend/st_utils.py | 35 +++++++-- 3 files changed, 104 insertions(+), 12 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b7932f09..8e9fe554 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,10 @@ services: environment: - AUTH_SYSTEM_ENABLED=True - BACKEND_API_HOST=https://api.metallorum.duckdns.org - # - BACKEND_API_PORT=80 + # 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: diff --git a/frontend/pages/orchestration/instances/app.py b/frontend/pages/orchestration/instances/app.py index 2a7db15d..240cffdc 100644 --- a/frontend/pages/orchestration/instances/app.py +++ b/frontend/pages/orchestration/instances/app.py @@ -1,10 +1,12 @@ +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 -from urllib.parse import quote from frontend.st_utils import get_backend_api_client, initialize_st_page @@ -99,6 +101,54 @@ def parse_bot_launch_time(bot_name): 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: + """Strip paper_trade suffix so we can look up real market prices.""" + return connector_name.replace("_paper_trade", "") + + +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) and response.get("status") == "success": + return response.get("data", {}).get(trading_pair) + except Exception: + pass + return None + + def render_bot_card(bot_name): """Render a bot performance card using native Streamlit components.""" try: @@ -131,14 +181,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") @@ -184,6 +239,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) @@ -204,6 +269,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 } diff --git a/frontend/st_utils.py b/frontend/st_utils.py index 289b77d8..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 = BACKEND_API_HOST.rstrip('/') - 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 From f500994d9842e7cdfb25f5a846c560bb2514783b Mon Sep 17 00:00:00 2001 From: Jefferson Pita Date: Mon, 30 Mar 2026 15:14:22 -0300 Subject: [PATCH 10/11] BandH --- frontend/pages/orchestration/instances/app.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/pages/orchestration/instances/app.py b/frontend/pages/orchestration/instances/app.py index 240cffdc..ecb8de58 100644 --- a/frontend/pages/orchestration/instances/app.py +++ b/frontend/pages/orchestration/instances/app.py @@ -106,8 +106,11 @@ def is_simulated_connector(connector_name: str) -> bool: def get_price_connector(connector_name: str) -> str: - """Strip paper_trade suffix so we can look up real market prices.""" - return connector_name.replace("_paper_trade", "") + """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" From eba9a2b7b9c1bb3c63165f4f165e9090cf3a1c8c Mon Sep 17 00:00:00 2001 From: Jefferson Pita Date: Mon, 30 Mar 2026 15:23:10 -0300 Subject: [PATCH 11/11] BandH --- frontend/pages/orchestration/instances/app.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/pages/orchestration/instances/app.py b/frontend/pages/orchestration/instances/app.py index ecb8de58..1eb8ad0f 100644 --- a/frontend/pages/orchestration/instances/app.py +++ b/frontend/pages/orchestration/instances/app.py @@ -145,8 +145,12 @@ def fetch_current_price(connector_name: str, trading_pair: str): response = backend_api_client.market_data.get_prices( get_price_connector(connector_name), trading_pair ) - if isinstance(response, dict) and response.get("status") == "success": - return response.get("data", {}).get(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