feat: Add dashboard charts with interactivity and basic authentication support
This commit is contained in:
@@ -25,3 +25,5 @@
|
||||
- Added dashboard page plus HTMX metrics fragment and SSE metrics stream.
|
||||
- Added dashboard live overview panel for status, balances, open trades, realized P&L, and opportunity feed with HTMX refresh and SSE updates.
|
||||
- Added dashboard controls for start/stop, config edits, and manual kill-switch triggering via HTMX POST forms.
|
||||
- Added Alpine.js interactivity and a Chart.js opportunity trend panel to the dashboard.
|
||||
- Added optional HTTP Basic authentication for dashboard routes, fragments, streams, and control endpoints.
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from fastapi import FastAPI
|
||||
|
||||
from arbitrade.api.control_state import DashboardControlState
|
||||
from arbitrade.api.routes import router
|
||||
from arbitrade.api.routes import public_router, router
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.logging_setup import configure_logging
|
||||
from arbitrade.metrics import MetricsCalculator
|
||||
@@ -13,6 +13,9 @@ from arbitrade.storage.db import DuckDBStore
|
||||
def create_app(settings: Settings) -> FastAPI:
|
||||
configure_logging(settings.log_level, settings.log_json)
|
||||
|
||||
if bool(settings.dashboard_auth_username) ^ bool(settings.dashboard_auth_password):
|
||||
raise ValueError("dashboard auth requires both username and password")
|
||||
|
||||
db = DuckDBStore(settings)
|
||||
db.migrate()
|
||||
|
||||
@@ -23,5 +26,6 @@ def create_app(settings: Settings) -> FastAPI:
|
||||
app.state.dashboard_controls = DashboardControlState(
|
||||
is_running=not settings.kill_switch_active,
|
||||
)
|
||||
app.include_router(public_router)
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from secrets import compare_digest
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
|
||||
dashboard_basic_auth = HTTPBasic(auto_error=False)
|
||||
|
||||
|
||||
def require_dashboard_auth(
|
||||
request: Request,
|
||||
credentials: Annotated[HTTPBasicCredentials | None, Depends(dashboard_basic_auth)],
|
||||
) -> None:
|
||||
settings = request.app.state.settings
|
||||
username = settings.dashboard_auth_username
|
||||
password = settings.dashboard_auth_password
|
||||
|
||||
if username is None and password is None:
|
||||
return
|
||||
|
||||
if username is None or password is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Dashboard auth misconfigured",
|
||||
)
|
||||
|
||||
if (
|
||||
credentials is None
|
||||
or not compare_digest(credentials.username, username)
|
||||
or not compare_digest(credentials.password, password)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": 'Basic realm="Arbitrade Dashboard"'},
|
||||
)
|
||||
@@ -7,13 +7,16 @@ from pathlib import Path
|
||||
from typing import cast
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
import duckdb
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from arbitrade.api.auth import require_dashboard_auth
|
||||
from arbitrade.api.control_state import DashboardControlState
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(dependencies=[Depends(require_dashboard_auth)])
|
||||
public_router = APIRouter()
|
||||
templates = Jinja2Templates(
|
||||
directory=str(Path(__file__).resolve().parents[3] / "web" / "templates")
|
||||
)
|
||||
@@ -52,7 +55,7 @@ def _dashboard_metrics(request: Request) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _table_columns(conn, table_name: str) -> set[str]:
|
||||
def _table_columns(conn: duckdb.DuckDBPyConnection, table_name: str) -> set[str]:
|
||||
rows = conn.execute(f"PRAGMA table_info('{table_name}')").fetchall()
|
||||
return {str(row[1]) for row in rows}
|
||||
|
||||
@@ -74,15 +77,13 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
|
||||
ORDER BY snapshot_at DESC
|
||||
LIMIT 1
|
||||
""").fetchone()
|
||||
open_trades = conn.execute(
|
||||
f"""
|
||||
open_trades = conn.execute(f"""
|
||||
SELECT {trade_ref_expr}, status, started_at, {cycle_expr}
|
||||
FROM trades
|
||||
WHERE {open_trade_filter}
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 5
|
||||
"""
|
||||
).fetchall()
|
||||
""").fetchall()
|
||||
pnl_total_row = conn.execute("""
|
||||
SELECT COALESCE(SUM(COALESCE(realized_pnl, 0)), 0)
|
||||
FROM trades
|
||||
@@ -133,6 +134,38 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _dashboard_charts(request: Request) -> dict[str, object]:
|
||||
store = request.app.state.store
|
||||
with store.connect() as conn:
|
||||
opportunity_rows = conn.execute("""
|
||||
SELECT detected_at, cycle, net_pct, est_profit
|
||||
FROM opportunities
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT 10
|
||||
""").fetchall()
|
||||
|
||||
chart_rows = list(reversed(opportunity_rows))
|
||||
labels = [
|
||||
row[0].isoformat() if isinstance(
|
||||
row[0], datetime) else f"opportunity-{index + 1}"
|
||||
for index, row in enumerate(chart_rows)
|
||||
]
|
||||
net_pct_values = [float(row[2]) if row[2]
|
||||
is not None else 0.0 for row in chart_rows]
|
||||
est_profit_values = [float(row[3]) if row[3]
|
||||
is not None else 0.0 for row in chart_rows]
|
||||
cycles = [str(row[1]) for row in chart_rows]
|
||||
|
||||
return {
|
||||
"labels": labels,
|
||||
"net_pct_values": net_pct_values,
|
||||
"est_profit_values": est_profit_values,
|
||||
"cycles": cycles,
|
||||
"has_chart_data": bool(chart_rows),
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _dashboard_controls_state(request: Request) -> DashboardControlState:
|
||||
return cast(DashboardControlState, request.app.state.dashboard_controls)
|
||||
|
||||
@@ -170,6 +203,7 @@ def _dashboard_controls(request: Request) -> dict[str, object]:
|
||||
"stop_endpoint": "/dashboard/control/stop",
|
||||
"kill_switch_endpoint": "/dashboard/control/kill-switch",
|
||||
"config_endpoint": "/dashboard/control/config",
|
||||
"chart_endpoint": "/dashboard/fragment/charts",
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +229,7 @@ async def home(request: Request) -> HTMLResponse:
|
||||
"metrics_endpoint": "/dashboard/fragment/metrics",
|
||||
"overview_endpoint": "/dashboard/fragment/overview",
|
||||
"controls_endpoint": "/dashboard/fragment/controls",
|
||||
"charts_endpoint": "/dashboard/fragment/charts",
|
||||
"stream_endpoint": "/dashboard/stream/metrics",
|
||||
"overview_stream_endpoint": "/dashboard/stream/overview",
|
||||
},
|
||||
@@ -212,6 +247,7 @@ async def dashboard(request: Request) -> HTMLResponse:
|
||||
"metrics_endpoint": "/dashboard/fragment/metrics",
|
||||
"overview_endpoint": "/dashboard/fragment/overview",
|
||||
"controls_endpoint": "/dashboard/fragment/controls",
|
||||
"charts_endpoint": "/dashboard/fragment/charts",
|
||||
"stream_endpoint": "/dashboard/stream/metrics",
|
||||
"overview_stream_endpoint": "/dashboard/stream/overview",
|
||||
},
|
||||
@@ -245,6 +281,15 @@ async def dashboard_controls(request: Request) -> HTMLResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/fragment/charts", response_class=HTMLResponse)
|
||||
async def dashboard_charts(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/charts.html",
|
||||
context={"request": request, **_dashboard_charts(request)},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/dashboard/control/start", response_class=HTMLResponse)
|
||||
async def dashboard_control_start(request: Request) -> HTMLResponse:
|
||||
controls = _dashboard_controls_state(request)
|
||||
@@ -347,6 +392,6 @@ async def dashboard_overview_stream(request: Request) -> StreamingResponse:
|
||||
return StreamingResponse(_event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/health", response_class=JSONResponse)
|
||||
@public_router.get("/health", response_class=JSONResponse)
|
||||
async def health() -> JSONResponse:
|
||||
return JSONResponse({"status": "ok", "service": "arbitrade"})
|
||||
|
||||
@@ -22,38 +22,64 @@ class Settings(BaseSettings):
|
||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||
log_json: bool = Field(default=True, alias="LOG_JSON")
|
||||
|
||||
duckdb_path: Path = Field(default=Path("./data/arbitrade.duckdb"), alias="DUCKDB_PATH")
|
||||
dashboard_auth_username: str | None = Field(
|
||||
default=None,
|
||||
alias="DASHBOARD_AUTH_USERNAME",
|
||||
)
|
||||
dashboard_auth_password: str | None = Field(
|
||||
default=None,
|
||||
alias="DASHBOARD_AUTH_PASSWORD",
|
||||
)
|
||||
|
||||
kraken_rest_url: str = Field(default="https://api.kraken.com", alias="KRAKEN_REST_URL")
|
||||
kraken_ws_url: str = Field(default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL")
|
||||
duckdb_path: Path = Field(default=Path(
|
||||
"./data/arbitrade.duckdb"), alias="DUCKDB_PATH")
|
||||
|
||||
kraken_rest_url: str = Field(
|
||||
default="https://api.kraken.com", alias="KRAKEN_REST_URL")
|
||||
kraken_ws_url: str = Field(
|
||||
default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL")
|
||||
kraken_private_rate_limit_seconds: float = Field(
|
||||
default=1.0, alias="KRAKEN_PRIVATE_RATE_LIMIT_SECONDS"
|
||||
)
|
||||
kraken_http_timeout_seconds: float = Field(default=10.0, alias="KRAKEN_HTTP_TIMEOUT_SECONDS")
|
||||
kraken_retry_attempts: int = Field(default=3, alias="KRAKEN_RETRY_ATTEMPTS")
|
||||
kraken_http_timeout_seconds: float = Field(
|
||||
default=10.0, alias="KRAKEN_HTTP_TIMEOUT_SECONDS")
|
||||
kraken_retry_attempts: int = Field(
|
||||
default=3, alias="KRAKEN_RETRY_ATTEMPTS")
|
||||
kraken_retry_base_delay_seconds: float = Field(
|
||||
default=0.25, alias="KRAKEN_RETRY_BASE_DELAY_SECONDS"
|
||||
)
|
||||
kraken_api_key: str | None = Field(default=None, alias="KRAKEN_API_KEY")
|
||||
kraken_api_secret: str | None = Field(default=None, alias="KRAKEN_API_SECRET")
|
||||
ws_heartbeat_timeout_seconds: float = Field(default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS")
|
||||
ws_max_staleness_seconds: float = Field(default=5.0, alias="WS_MAX_STALENESS_SECONDS")
|
||||
kraken_api_secret: str | None = Field(
|
||||
default=None, alias="KRAKEN_API_SECRET")
|
||||
ws_heartbeat_timeout_seconds: float = Field(
|
||||
default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS")
|
||||
ws_max_staleness_seconds: float = Field(
|
||||
default=5.0, alias="WS_MAX_STALENESS_SECONDS")
|
||||
paper_trading_mode: bool = Field(default=True, alias="PAPER_TRADING_MODE")
|
||||
trade_capital_usd: float = Field(default=100.0, alias="TRADE_CAPITAL_USD")
|
||||
max_trade_capital_usd: float = Field(default=100.0, alias="MAX_TRADE_CAPITAL_USD")
|
||||
max_concurrent_trades: int | None = Field(default=None, alias="MAX_CONCURRENT_TRADES")
|
||||
max_trade_capital_usd: float = Field(
|
||||
default=100.0, alias="MAX_TRADE_CAPITAL_USD")
|
||||
max_concurrent_trades: int | None = Field(
|
||||
default=None, alias="MAX_CONCURRENT_TRADES")
|
||||
max_exposure_per_asset_usd: float | None = Field(
|
||||
default=None,
|
||||
alias="MAX_EXPOSURE_PER_ASSET_USD",
|
||||
)
|
||||
quote_balance_asset: str = Field(default="USD", alias="QUOTE_BALANCE_ASSET")
|
||||
min_order_size_usd: float | None = Field(default=None, alias="MIN_ORDER_SIZE_USD")
|
||||
quote_balance_asset: str = Field(
|
||||
default="USD", alias="QUOTE_BALANCE_ASSET")
|
||||
min_order_size_usd: float | None = Field(
|
||||
default=None, alias="MIN_ORDER_SIZE_USD")
|
||||
kill_switch_active: bool = Field(default=False, alias="KILL_SWITCH_ACTIVE")
|
||||
daily_loss_limit_usd: float | None = Field(default=None, alias="DAILY_LOSS_LIMIT_USD")
|
||||
cumulative_loss_limit_usd: float | None = Field(default=None, alias="CUMULATIVE_LOSS_LIMIT_USD")
|
||||
max_source_latency_ms: float | None = Field(default=None, alias="MAX_SOURCE_LATENCY_MS")
|
||||
max_apply_latency_ms: float | None = Field(default=None, alias="MAX_APPLY_LATENCY_MS")
|
||||
max_consecutive_failures: int | None = Field(default=None, alias="MAX_CONSECUTIVE_FAILURES")
|
||||
daily_loss_limit_usd: float | None = Field(
|
||||
default=None, alias="DAILY_LOSS_LIMIT_USD")
|
||||
cumulative_loss_limit_usd: float | None = Field(
|
||||
default=None, alias="CUMULATIVE_LOSS_LIMIT_USD")
|
||||
max_source_latency_ms: float | None = Field(
|
||||
default=None, alias="MAX_SOURCE_LATENCY_MS")
|
||||
max_apply_latency_ms: float | None = Field(
|
||||
default=None, alias="MAX_APPLY_LATENCY_MS")
|
||||
max_consecutive_failures: int | None = Field(
|
||||
default=None, alias="MAX_CONSECUTIVE_FAILURES")
|
||||
|
||||
fernet_key: str | None = Field(default=None, alias="FERNET_KEY")
|
||||
|
||||
|
||||
@@ -134,11 +134,15 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
|
||||
overview = await client.get("/dashboard/fragment/overview")
|
||||
overview_stream = await client.get("/dashboard/stream/overview")
|
||||
controls = await client.get("/dashboard/fragment/controls")
|
||||
charts = await client.get("/dashboard/fragment/charts")
|
||||
|
||||
assert page.status_code == 200
|
||||
assert "EventSource" in page.text
|
||||
assert "alpinejs" in page.text.lower()
|
||||
assert "Chart.js" in page.text or "chart.umd.min.js" in page.text
|
||||
assert 'hx-get="/dashboard/fragment/metrics"' in page.text
|
||||
assert 'hx-get="/dashboard/fragment/controls"' in page.text
|
||||
assert 'hx-get="/dashboard/fragment/charts"' in page.text
|
||||
|
||||
assert fragment.status_code == 200
|
||||
assert "Realized P&L" in fragment.text
|
||||
@@ -170,6 +174,11 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
|
||||
assert "Paper trading mode" in controls.text
|
||||
assert "Trade capital USD" in controls.text
|
||||
|
||||
assert charts.status_code == 200
|
||||
assert "Opportunity Trend" in charts.text
|
||||
assert "opportunity-chart" in charts.text
|
||||
assert "Hide chart" in charts.text or "Show chart" in charts.text
|
||||
|
||||
|
||||
async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> None:
|
||||
app = create_app(Settings(DUCKDB_PATH=tmp_path / "controls.duckdb"))
|
||||
@@ -210,3 +219,27 @@ async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> N
|
||||
assert app.state.settings.max_trade_capital_usd == 300.0
|
||||
assert app.state.settings.max_concurrent_trades == 4
|
||||
assert app.state.settings.paper_trading_mode is True
|
||||
|
||||
|
||||
async def test_dashboard_requires_basic_auth_when_configured(tmp_path) -> None:
|
||||
app = create_app(
|
||||
Settings(
|
||||
DUCKDB_PATH=tmp_path / "auth.duckdb",
|
||||
DASHBOARD_AUTH_USERNAME="admin",
|
||||
DASHBOARD_AUTH_PASSWORD="secret",
|
||||
)
|
||||
)
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
unauthenticated = await client.get("/dashboard/fragment/overview")
|
||||
authenticated = await client.get(
|
||||
"/dashboard/fragment/overview",
|
||||
auth=("admin", "secret"),
|
||||
)
|
||||
health = await client.get("/health")
|
||||
|
||||
assert unauthenticated.status_code == 401
|
||||
assert unauthenticated.headers["www-authenticate"] == 'Basic realm="Arbitrade Dashboard"'
|
||||
assert authenticated.status_code == 200
|
||||
assert health.status_code == 200
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ title }}</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -124,6 +126,17 @@
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chart-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -176,7 +189,106 @@
|
||||
{% include "partials/controls.html" %}
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="charts-shell"
|
||||
hx-get="{{ charts_endpoint }}"
|
||||
hx-target="this"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
{% include "partials/charts.html" %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
window.arbitradeRenderCharts = (payload) => {
|
||||
const chartHost = document.getElementById("opportunity-chart");
|
||||
if (!chartHost || typeof Chart === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = Chart.getChart(chartHost);
|
||||
if (existing) {
|
||||
existing.destroy();
|
||||
}
|
||||
|
||||
const data = JSON.parse(payload);
|
||||
if (!data.has_chart_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
new Chart(chartHost, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Net %",
|
||||
data: data.net_pct_values,
|
||||
borderColor: "#2d6cdf",
|
||||
backgroundColor: "rgba(45, 108, 223, 0.18)",
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
label: "Est profit USD",
|
||||
data: data.est_profit_values,
|
||||
borderColor: "#52c41a",
|
||||
backgroundColor: "rgba(82, 196, 26, 0.12)",
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
yAxisID: "y1",
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#e5eefb",
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: "#9fb2d0",
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
},
|
||||
grid: {
|
||||
color: "rgba(255, 255, 255, 0.06)",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
position: "left",
|
||||
ticks: {
|
||||
color: "#9fb2d0",
|
||||
},
|
||||
grid: {
|
||||
color: "rgba(255, 255, 255, 0.06)",
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
position: "right",
|
||||
ticks: {
|
||||
color: "#9fb2d0",
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const stream = new EventSource("{{ stream_endpoint }}");
|
||||
stream.addEventListener("metrics", (event) => {
|
||||
const panel = document.getElementById("metrics-panel");
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<div
|
||||
id="charts-panel"
|
||||
class="panel"
|
||||
style="margin-top: 16px"
|
||||
x-data="{ expanded: true }"
|
||||
>
|
||||
<div class="chart-head">
|
||||
<div>
|
||||
<div class="label">Opportunity Trend</div>
|
||||
<div class="meta">Recent opportunities from DuckDB. Updated {{ generated_at }}</div>
|
||||
</div>
|
||||
<button type="button" class="button secondary" x-on:click="expanded = !expanded">
|
||||
<span x-text="expanded ? 'Hide chart' : 'Show chart'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="expanded" x-transition style="margin-top: 16px">
|
||||
<div class="card" style="padding: 12px">
|
||||
{% if has_chart_data %}
|
||||
<canvas id="opportunity-chart" class="chart-canvas"></canvas>
|
||||
<script>
|
||||
window.arbitradeRenderCharts(
|
||||
{{ {
|
||||
"has_chart_data": has_chart_data,
|
||||
"labels": labels,
|
||||
"net_pct_values": net_pct_values,
|
||||
"est_profit_values": est_profit_values,
|
||||
"cycles": cycles,
|
||||
} | tojson }}
|
||||
);
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="meta">No opportunity data yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user