Compare commits

...

2 Commits

Author SHA1 Message Date
zwitschi 0d1f6961d6 style: Clean up code formatting for improved readability
CI / lint-test-build (push) Failing after 14s
2026-06-01 15:18:01 +02:00
zwitschi d742577484 feat: Add dashboard controls for tradable pairs and strategy configuration 2026-06-01 15:13:05 +02:00
10 changed files with 146 additions and 34 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 -8
View File
@@ -30,8 +30,7 @@ def _parse_balances(raw: str) -> Mapping[str, float]:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Run a deterministic replay backtest.")
description="Run a deterministic replay backtest.")
parser.add_argument("--events", type=Path, required=True) parser.add_argument("--events", type=Path, required=True)
parser.add_argument("--starting-balances", type=str, default="USD=1000.0") parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
parser.add_argument("--trade-capital", type=float, default=100.0) parser.add_argument("--trade-capital", type=float, default=100.0)
@@ -56,18 +55,15 @@ def main() -> int:
started_at=events[0].occurred_at if events else datetime.now(UTC), started_at=events[0].occurred_at if events else datetime.now(UTC),
) )
report = asyncio.run( report = asyncio.run(
engine.run(events, starting_balances=_parse_balances( engine.run(events, starting_balances=_parse_balances(args.starting_balances))
args.starting_balances))
) )
print("Backtest report:") print("Backtest report:")
print(f"- processed_events: {report.processed_events}") print(f"- processed_events: {report.processed_events}")
print(f"- opportunities_seen: {report.opportunities_seen}") print(f"- opportunities_seen: {report.opportunities_seen}")
print(f"- trades_executed: {report.trades_executed}") print(f"- trades_executed: {report.trades_executed}")
print( print(f"- win_rate: {report.win_rate if report.win_rate is not None else 'n/a'}")
f"- win_rate: {report.win_rate if report.win_rate is not None else 'n/a'}") print(f"- fill_rate: {report.fill_rate if report.fill_rate is not None else 'n/a'}")
print(
f"- fill_rate: {report.fill_rate if report.fill_rate is not None else 'n/a'}")
print(f"- realized_pnl_usd: {report.realized_pnl_usd:.4f}") print(f"- realized_pnl_usd: {report.realized_pnl_usd:.4f}")
print(f"- max_drawdown_usd: {report.max_drawdown_usd:.4f}") print(f"- max_drawdown_usd: {report.max_drawdown_usd:.4f}")
print(f"- miss_reasons: {dict(report.miss_reasons)}") print(f"- miss_reasons: {dict(report.miss_reasons)}")
+3 -6
View File
@@ -63,16 +63,13 @@ def scan_worktree() -> list[str]:
for rule_name, pattern in PATTERNS: for rule_name, pattern in PATTERNS:
if pattern.search(content): if pattern.search(content):
findings.append( findings.append(f"worktree:{path.relative_to(WORKSPACE)}:{rule_name}")
f"worktree:{path.relative_to(WORKSPACE)}:{rule_name}")
return findings return findings
def scan_git_history() -> list[str]: def scan_git_history() -> list[str]:
cmd = ["git", "-C", str(WORKSPACE), "log", "--all", cmd = ["git", "-C", str(WORKSPACE), "log", "--all", "-p", "--pretty=format:%H"]
"-p", "--pretty=format:%H"] completed = subprocess.run(cmd, check=False, capture_output=True, text=True)
completed = subprocess.run(
cmd, check=False, capture_output=True, text=True)
if completed.returncode != 0: if completed.returncode != 0:
return ["history_scan_failed"] return ["history_scan_failed"]
+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,
}, },
) )
+6 -12
View File
@@ -153,8 +153,7 @@ def _parse_book_levels(raw_levels: Any) -> tuple[BookLevel, ...]:
or not isinstance(raw_level[1], int | float) or not isinstance(raw_level[1], int | float)
): ):
raise ValueError("Each level must be [price, volume]") raise ValueError("Each level must be [price, volume]")
levels.append(BookLevel(price=float( levels.append(BookLevel(price=float(raw_level[0]), volume=float(raw_level[1])))
raw_level[0]), volume=float(raw_level[1])))
return tuple(levels) return tuple(levels)
@@ -173,8 +172,7 @@ def load_replay_events(path: Path) -> list[ReplayBookEvent]:
if not isinstance(timestamp_raw, str) or not isinstance(symbol_raw, str): if not isinstance(timestamp_raw, str) or not isinstance(symbol_raw, str):
raise ValueError("Each event must include timestamp and symbol") raise ValueError("Each event must include timestamp and symbol")
occurred_at = datetime.fromisoformat( occurred_at = datetime.fromisoformat(timestamp_raw.replace("Z", "+00:00")).astimezone(UTC)
timestamp_raw.replace("Z", "+00:00")).astimezone(UTC)
events.append( events.append(
ReplayBookEvent( ReplayBookEvent(
occurred_at=occurred_at, occurred_at=occurred_at,
@@ -208,8 +206,7 @@ class BacktestReplayEngine:
min_order_size_by_pair=config.min_order_size_by_pair, min_order_size_by_pair=config.min_order_size_by_pair,
) )
self._pre_trade = PreTradeValidator() self._pre_trade = PreTradeValidator()
self._trade_limits = TradeLimitsGuard( self._trade_limits = TradeLimitsGuard(max_concurrent_trades=config.max_concurrent_trades)
max_concurrent_trades=config.max_concurrent_trades)
self._simulated_rest = _SimulatedRestClient( self._simulated_rest = _SimulatedRestClient(
self._clock, self._clock,
slippage_bps=config.slippage_bps, slippage_bps=config.slippage_bps,
@@ -244,8 +241,7 @@ class BacktestReplayEngine:
trades_executed = 0 trades_executed = 0
realized_pnl = 0.0 realized_pnl = 0.0
equity = float(starting_balances.get( equity = float(starting_balances.get(self._config.quote_asset.upper(), 0.0))
self._config.quote_asset.upper(), 0.0))
peak_equity = equity peak_equity = equity
max_drawdown = 0.0 max_drawdown = 0.0
@@ -288,8 +284,7 @@ class BacktestReplayEngine:
result = await self._sequencer.execute(opportunity) result = await self._sequencer.execute(opportunity)
self._trade_limits.close_trade(exposure) self._trade_limits.close_trade(exposure)
execution_latencies.append( execution_latencies.append(self._simulated_rest.last_trade_latency_ms)
self._simulated_rest.last_trade_latency_ms)
fill_samples.append(self._simulated_rest.last_fill_ratio) fill_samples.append(self._simulated_rest.last_fill_ratio)
if not result.success: if not result.success:
@@ -312,8 +307,7 @@ class BacktestReplayEngine:
wins = sum(1 for pnl in realized_samples if pnl > 0.0) wins = sum(1 for pnl in realized_samples if pnl > 0.0)
win_rate = (wins / len(realized_samples)) if realized_samples else None win_rate = (wins / len(realized_samples)) if realized_samples else None
fill_rate = (sum(fill_samples) / len(fill_samples) fill_rate = (sum(fill_samples) / len(fill_samples)) if fill_samples else None
) if fill_samples else None
return BacktestReport( return BacktestReport(
started_at=events[0].occurred_at if events else self._clock.now, started_at=events[0].occurred_at if events else self._clock.now,
+2 -4
View File
@@ -140,8 +140,7 @@ async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
snapshot_at = latest.snapshot_at.isoformat() snapshot_at = latest.snapshot_at.isoformat()
controls.is_running = latest.is_running controls.is_running = latest.is_running
if latest.kill_switch_active: if latest.kill_switch_active:
controls.kill_switch.activate( controls.kill_switch.activate(reason=latest.kill_switch_reason or "recovered")
reason=latest.kill_switch_reason or "recovered")
else: else:
controls.kill_switch.deactivate() controls.kill_switch.deactivate()
controls.mark_updated() controls.mark_updated()
@@ -151,8 +150,7 @@ async def restore_runtime_state(app: FastAPI) -> RuntimeRecoveryReport:
if open_trades > 0: if open_trades > 0:
controls.is_running = False controls.is_running = False
if not controls.kill_switch.is_active: if not controls.kill_switch.is_active:
controls.kill_switch.activate( controls.kill_switch.activate(reason="recovery_open_trades_detected")
reason="recovery_open_trades_detected")
controls.mark_updated() controls.mark_updated()
restart_guard_active = True restart_guard_active = True
+14
View File
@@ -202,6 +202,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 +231,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 +253,18 @@ 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:
+2 -4
View File
@@ -67,13 +67,11 @@ def test_backtest_replay_engine_runs_deterministically() -> None:
engine = BacktestReplayEngine( engine = BacktestReplayEngine(
cycles_by_pair=cycles_by_pair, cycles_by_pair=cycles_by_pair,
available_pairs=available_pairs, available_pairs=available_pairs,
config=BacktestConfig(trade_capital=100.0, config=BacktestConfig(trade_capital=100.0, slippage_bps=5.0, execution_latency_ms=10.0),
slippage_bps=5.0, execution_latency_ms=10.0),
started_at=started_at, started_at=started_at,
) )
report = asyncio.run(engine.run( report = asyncio.run(engine.run(replay_events, starting_balances={"USD": 1000.0}))
replay_events, starting_balances={"USD": 1000.0}))
assert report.processed_events == 3 assert report.processed_events == 3
assert report.opportunities_seen >= 0 assert report.opportunities_seen >= 0
+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 }} />