feat: Add dashboard controls for tradable pairs and strategy configuration

This commit is contained in:
2026-06-01 15:13:05 +02:00
parent 7c86e838fa
commit d742577484
5 changed files with 140 additions and 5 deletions
+2
View File
@@ -18,6 +18,7 @@
- Added synthetic latency profiler scenarios and CLI scripts for baseline generation and regression checks. - Added synthetic latency profiler scenarios and CLI scripts for baseline generation and regression checks.
- Added latency baseline/threshold artifacts and CI latency guardrail enforcement. - Added latency baseline/threshold artifacts and CI latency guardrail enforcement.
- Added deterministic replay backtesting engine, CLI script, and unit coverage for JSONL event replay. - Added deterministic replay backtesting engine, CLI script, and unit coverage for JSONL event replay.
- Added dashboard controls for tradable pair universe selection and strategy mode/parameter configuration.
### Changed ### Changed
@@ -27,6 +28,7 @@
- Added explicit Kraken API key permission configuration (`KRAKEN_API_KEY_PERMISSIONS`) and docs for least-privilege key usage. - Added explicit Kraken API key permission configuration (`KRAKEN_API_KEY_PERMISSIONS`) and docs for least-privilege key usage.
- Optimized dashboard metrics aggregation to use DuckDB SQL aggregates/quantiles instead of Python row scans. - Optimized dashboard metrics aggregation to use DuckDB SQL aggregates/quantiles instead of Python row scans.
- Added backtesting usage and replay format documentation to README. - Added backtesting usage and replay format documentation to README.
- Dashboard controls now surface tradable pairs and strategy config snapshot values.
### Removed ### Removed
+4
View File
@@ -10,6 +10,10 @@ from arbitrade.risk.kill_switch import KillSwitch
class DashboardControlState: class DashboardControlState:
is_running: bool = True is_running: bool = True
kill_switch: KillSwitch = field(default_factory=KillSwitch) kill_switch: KillSwitch = field(default_factory=KillSwitch)
tradable_pairs: list[str] = field(default_factory=list)
strategy_mode: str = "incremental"
strategy_profit_threshold: float = 0.0005
strategy_max_depth_levels: int = 10
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
def mark_updated(self) -> None: def mark_updated(self) -> None:
+35
View File
@@ -271,6 +271,9 @@ def _dashboard_controls(request: Request) -> dict[str, object]:
dedup_seconds_raw = alert_status.get("dedup_seconds", 0.0) dedup_seconds_raw = alert_status.get("dedup_seconds", 0.0)
dedup_seconds = float(dedup_seconds_raw) if isinstance(dedup_seconds_raw, int | float) else 0.0 dedup_seconds = float(dedup_seconds_raw) if isinstance(dedup_seconds_raw, int | float) else 0.0
tradable_pairs_display = (
", ".join(controls.tradable_pairs) if controls.tradable_pairs else "All"
)
return { return {
"execution_status": "running" if controls.is_running else "stopped", "execution_status": "running" if controls.is_running else "stopped",
@@ -307,6 +310,11 @@ def _dashboard_controls(request: Request) -> dict[str, object]:
"alerts_last_channel_results": [ "alerts_last_channel_results": [
str(item) for item in cast(list[object], alert_status.get("last_channel_results", [])) str(item) for item in cast(list[object], alert_status.get("last_channel_results", []))
], ],
"tradable_pairs_display": tradable_pairs_display,
"tradable_pairs_value": ", ".join(controls.tradable_pairs),
"strategy_mode": controls.strategy_mode,
"strategy_profit_threshold": f"{controls.strategy_profit_threshold:.6f}",
"strategy_max_depth_levels": str(controls.strategy_max_depth_levels),
"updated_at": controls.updated_at.isoformat(), "updated_at": controls.updated_at.isoformat(),
"start_endpoint": "/dashboard/control/start", "start_endpoint": "/dashboard/control/start",
"stop_endpoint": "/dashboard/control/stop", "stop_endpoint": "/dashboard/control/stop",
@@ -327,6 +335,18 @@ def _form_bool(value: str | None) -> bool:
return value.lower() in {"1", "true", "yes", "on"} return value.lower() in {"1", "true", "yes", "on"}
def _parse_comma_separated_list(value: str | None) -> list[str]:
if value is None:
return []
items: list[str] = []
for raw_item in value.split(","):
item = raw_item.strip().upper()
if item and item not in items:
items.append(item)
return items
async def _dashboard_response( async def _dashboard_response(
request: Request, template_name: str = "dashboard.html" request: Request, template_name: str = "dashboard.html"
) -> HTMLResponse: ) -> HTMLResponse:
@@ -514,6 +534,17 @@ async def dashboard_control_config(request: Request) -> HTMLResponse:
max_concurrent_value = form["max_concurrent_trades"].strip() max_concurrent_value = form["max_concurrent_trades"].strip()
settings.max_concurrent_trades = int(max_concurrent_value) if max_concurrent_value else None settings.max_concurrent_trades = int(max_concurrent_value) if max_concurrent_value else None
controls.tradable_pairs = _parse_comma_separated_list(form.get("tradable_pairs"))
if "strategy_mode" in form and form["strategy_mode"].strip():
strategy_mode = form["strategy_mode"].strip().lower()
if strategy_mode not in {"incremental", "paper", "live"}:
raise ValueError("strategy_mode must be one of: incremental, paper, live")
controls.strategy_mode = strategy_mode
if "strategy_profit_threshold" in form and form["strategy_profit_threshold"].strip():
controls.strategy_profit_threshold = float(form["strategy_profit_threshold"])
if "strategy_max_depth_levels" in form and form["strategy_max_depth_levels"].strip():
controls.strategy_max_depth_levels = int(form["strategy_max_depth_levels"])
settings.paper_trading_mode = _form_bool(form.get("paper_trading_mode")) settings.paper_trading_mode = _form_bool(form.get("paper_trading_mode"))
controls.mark_updated() controls.mark_updated()
@@ -549,6 +580,10 @@ async def dashboard_control_config(request: Request) -> HTMLResponse:
"max_trade_capital_usd": settings.max_trade_capital_usd, "max_trade_capital_usd": settings.max_trade_capital_usd,
"max_concurrent_trades": settings.max_concurrent_trades, "max_concurrent_trades": settings.max_concurrent_trades,
"paper_trading_mode": settings.paper_trading_mode, "paper_trading_mode": settings.paper_trading_mode,
"tradable_pairs": controls.tradable_pairs,
"strategy_mode": controls.strategy_mode,
"strategy_profit_threshold": controls.strategy_profit_threshold,
"strategy_max_depth_levels": controls.strategy_max_depth_levels,
}, },
) )
+25 -5
View File
@@ -191,7 +191,8 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
assert "trade-open" in overview.text assert "trade-open" in overview.text
assert overview_stream.status_code == 200 assert overview_stream.status_code == 200
assert overview_stream.headers["content-type"].startswith("text/event-stream") assert overview_stream.headers["content-type"].startswith(
"text/event-stream")
assert "event: overview" in overview_stream.text assert "event: overview" in overview_stream.text
assert "trade-open" in overview_stream.text assert "trade-open" in overview_stream.text
@@ -202,6 +203,8 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
assert "Last result" in controls.text assert "Last result" in controls.text
assert "Paper trading mode" in controls.text assert "Paper trading mode" in controls.text
assert "Trade capital USD" in controls.text assert "Trade capital USD" in controls.text
assert "Tradable pairs" in controls.text
assert "Strategy mode" in controls.text
assert charts.status_code == 200 assert charts.status_code == 200
assert "Opportunity Trend" in charts.text assert "Opportunity Trend" in charts.text
@@ -229,6 +232,10 @@ async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> N
"trade_capital_usd": "250.50", "trade_capital_usd": "250.50",
"max_trade_capital_usd": "300.00", "max_trade_capital_usd": "300.00",
"max_concurrent_trades": "4", "max_concurrent_trades": "4",
"tradable_pairs": "BTC/USD, ETH/BTC, BTC/USD",
"strategy_mode": "paper",
"strategy_profit_threshold": "0.0025",
"strategy_max_depth_levels": "7",
"paper_trading_mode": "on", "paper_trading_mode": "on",
}, },
) )
@@ -247,10 +254,19 @@ async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> N
assert "250.50 USD" in config_response.text assert "250.50 USD" in config_response.text
assert "300.00 USD" in config_response.text assert "300.00 USD" in config_response.text
assert "4" in config_response.text assert "4" in config_response.text
assert "BTC/USD, ETH/BTC" in config_response.text
assert "paper" in config_response.text
assert "0.002500" in config_response.text
assert "7" in config_response.text
assert app.state.settings.trade_capital_usd == 250.5 assert app.state.settings.trade_capital_usd == 250.5
assert app.state.settings.max_trade_capital_usd == 300.0 assert app.state.settings.max_trade_capital_usd == 300.0
assert app.state.settings.max_concurrent_trades == 4 assert app.state.settings.max_concurrent_trades == 4
assert app.state.settings.paper_trading_mode is True assert app.state.settings.paper_trading_mode is True
assert app.state.dashboard_controls.tradable_pairs == [
"BTC/USD", "ETH/BTC"]
assert app.state.dashboard_controls.strategy_mode == "paper"
assert app.state.dashboard_controls.strategy_profit_threshold == 0.0025
assert app.state.dashboard_controls.strategy_max_depth_levels == 7
transport = httpx.ASGITransport(app=app) transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
@@ -259,10 +275,14 @@ async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> N
assert audit_recent.status_code == 200 assert audit_recent.status_code == 200
entries = audit_recent.json()["entries"] entries = audit_recent.json()["entries"]
assert len(entries) >= 4 assert len(entries) >= 4
assert any(entry["event_type"] == "dashboard.control.stop" for entry in entries) assert any(entry["event_type"] ==
assert any(entry["event_type"] == "dashboard.control.start" for entry in entries) "dashboard.control.stop" for entry in entries)
assert any(entry["event_type"] == "dashboard.control.kill_switch" for entry in entries) assert any(entry["event_type"] ==
assert any(entry["event_type"] == "dashboard.control.config" for entry in entries) "dashboard.control.start" for entry in entries)
assert any(entry["event_type"] ==
"dashboard.control.kill_switch" for entry in entries)
assert any(entry["event_type"] ==
"dashboard.control.config" for entry in entries)
async def test_dashboard_controls_emit_alerts(tmp_path) -> None: async def test_dashboard_controls_emit_alerts(tmp_path) -> None:
+74
View File
@@ -16,6 +16,10 @@
<div class="meta">Trade capital: {{ trade_capital_usd }}</div> <div class="meta">Trade capital: {{ trade_capital_usd }}</div>
<div class="meta">Max trade capital: {{ max_trade_capital_usd }}</div> <div class="meta">Max trade capital: {{ max_trade_capital_usd }}</div>
<div class="meta">Max concurrent trades: {{ max_concurrent_trades }}</div> <div class="meta">Max concurrent trades: {{ max_concurrent_trades }}</div>
<div class="meta">Tradable pairs: {{ tradable_pairs_display }}</div>
<div class="meta">Strategy mode: {{ strategy_mode }}</div>
<div class="meta">Profit threshold: {{ strategy_profit_threshold }}</div>
<div class="meta">Max depth levels: {{ strategy_max_depth_levels }}</div>
</article> </article>
<article class="card"> <article class="card">
<div class="label">Alerting</div> <div class="label">Alerting</div>
@@ -109,6 +113,76 @@
value="{{ max_concurrent_trades_value }}" value="{{ max_concurrent_trades_value }}"
/> />
</label> </label>
<label class="field">
<span>Tradable pairs</span>
<input
name="tradable_pairs"
type="text"
placeholder="BTC/USD, ETH/BTC"
value="{{ tradable_pairs_value }}"
/>
</label>
<label class="field">
<span>Strategy mode</span>
<select name="strategy_mode">
<option
value="incremental"
{%
if
strategy_mode=""
="incremental"
%}selected{%
endif
%}
>
incremental
</option>
<option
value="paper"
{%
if
strategy_mode=""
="paper"
%}selected{%
endif
%}
>
paper
</option>
<option
value="live"
{%
if
strategy_mode=""
="live"
%}selected{%
endif
%}
>
live
</option>
</select>
</label>
<label class="field">
<span>Strategy profit threshold</span>
<input
name="strategy_profit_threshold"
type="number"
min="0"
step="0.0001"
value="{{ strategy_profit_threshold }}"
/>
</label>
<label class="field">
<span>Max depth levels</span>
<input
name="strategy_max_depth_levels"
type="number"
min="1"
step="1"
value="{{ strategy_max_depth_levels }}"
/>
</label>
<label class="field checkbox"> <label class="field checkbox">
{% set check = "checked" if paper_trading_mode == "enabled" else "" %} {% set check = "checked" if paper_trading_mode == "enabled" else "" %}
<input name="paper_trading_mode" type="checkbox" {{ check }} /> <input name="paper_trading_mode" type="checkbox" {{ check }} />