Compare commits

...
10 Commits
Author SHA1 Message Date
matitos 9d6028fbbf Add the Williams %R strategy
CI/CD / backend (push) Successful in 9m58s
CI/CD / frontend (push) Successful in 9m32s
CI/CD / build (push) Successful in 30s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 34s
2026-09-26 18:44:00 +00:00
matitos d92f152f9b Add the Williams %R strategy
New williams_r indicator (in [-100, 0]) exposed as ctx.williams_r(period), and a
long-only mean-reversion strategy: enter when %R is at/below the oversold level,
exit when it reaches the overbought level, with an optional ATR stop. 11
strategies total. Adds indicator and strategy tests; smoke-tested on live
BTC/USDT 5m (backtest + parameter sweep).
2026-09-26 18:44:00 +00:00
matitos ef895b6b9c Aggregate the top status cards across strategies
CI/CD / backend (push) Successful in 9m59s
CI/CD / frontend (push) Successful in 9m32s
CI/CD / build (push) Successful in 20s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 1m8s
2026-09-22 23:11:19 +00:00
matitos 57dcfe7adb Aggregate the top status cards across strategies
With several strategies the header cards showed only the first one. Expose
day_pnl and drawdown_pct per strategy and add aggregateStatus() (frontend) so the
cards show the whole account: summed cash/equity/P&L/trades/positions, worst
drawdown, and any halt. Single-strategy runs are unchanged. Adds tests.
2026-09-22 23:11:18 +00:00
matitos 657c473e82 Merge the single and multi strategy panels into one
CI/CD / backend (push) Successful in 10m2s
CI/CD / frontend (push) Successful in 9m31s
CI/CD / build (push) Successful in 15s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 33s
2026-09-22 17:57:10 +00:00
matitos 50b22df948 Merge the single and multi strategy panels into one
The single "Strategy" panel and "Strategies (multi)" were redundant: the multi
panel with one row is exactly the single-strategy behaviour. Remove StrategyPanel
and make "Strategies" the one trading panel. Presets move into it (load adds a
row, per-row Save), and the header now reports "N strategies" when several run
instead of only the first. Deletes the obsolete component and its test; adds
preset tests to the multi-strategy suite.
2026-09-22 17:57:10 +00:00
matitos d4d864dc5c Observability: Prometheus metrics, log panel, runbook
CI/CD / backend (push) Successful in 9m56s
CI/CD / frontend (push) Successful in 9m31s
CI/CD / build (push) Successful in 19s
CI/CD / cleanup (push) Successful in 6s
CI/CD / deploy (push) Successful in 42s
2026-09-22 17:10:39 +00:00
matitos ac92dd5ac4 Observability: Prometheus metrics, log panel, runbook
- GET /api/metrics renders Prometheus text (equity, P&L, per-strategy
  equity/pnl/allocation/halted, info) with no client library.
- In-memory ring log handler (app/logs.py) exposed at GET /api/logs and a
  dashboard "Server logs" panel; Runtime mirrors bot events into the logger.
- docs/runbook.md covers deploys, the kill switch, resetting paper state and
  going live. Adds tests for metrics rendering and the log ring.
2026-09-22 17:10:38 +00:00
matitos c605a0f316 Add Supertrend and VWAP strategies plus a short-side example
CI/CD / backend (push) Successful in 5m49s
CI/CD / frontend (push) Successful in 5m19s
CI/CD / build (push) Successful in 14s
CI/CD / cleanup (push) Successful in 7s
CI/CD / deploy (push) Successful in 1m1s
2026-09-22 14:14:13 +00:00
matitos d1f66c95c4 Add Supertrend and VWAP strategies plus a short-side example
- New indicators: vwap (rolling/cumulative) and supertrend (line + direction),
  exposed on StrategyContext.
- New strategies: supertrend (flip-based, optional shorts), vwap_reversion
  (mean reversion below a rolling VWAP) and ema_cross_short (short-side
  example, mirroring the long EMA cross). 10 strategies total.
- Adds indicator and strategy tests; smoke-tested all three on live BTC/USDT
  5m data.
2026-09-22 14:14:12 +00:00
30 changed files with 1303 additions and 183 deletions

No files matched your search

+12 -1
View File
@@ -92,7 +92,7 @@ class MyStrategy(Strategy):
| --- | --- | | --- | --- |
| Price / candles | `ctx.price`, `ctx.candles`, `ctx.closes`, `ctx.high(n)`, `ctx.low(n)` | | Price / candles | `ctx.price`, `ctx.candles`, `ctx.closes`, `ctx.high(n)`, `ctx.low(n)` |
| Portfolio | `ctx.is_flat`, `ctx.side`, `ctx.position`, `ctx.equity`, `ctx.cash` | | Portfolio | `ctx.is_flat`, `ctx.side`, `ctx.position`, `ctx.equity`, `ctx.cash` |
| Indicators | `ctx.sma(p)`, `ctx.ema(p)`, `ctx.rsi(p)`, `ctx.atr(p)`, `ctx.std(p)` | | Indicators | `ctx.sma(p)`, `ctx.ema(p)`, `ctx.rsi(p)`, `ctx.atr(p)`, `ctx.std(p)`, `ctx.macd()`, `ctx.roc(p)`, `ctx.vwap(p)`, `ctx.supertrend(p, mult)`, `ctx.williams_r(p)` |
| Orders | `ctx.enter_long(size, stop_loss, take_profit, trailing_stop_pct, reason)`, `ctx.enter_short(...)`, `ctx.close(reason)` | | Orders | `ctx.enter_long(size, stop_loss, take_profit, trailing_stop_pct, reason)`, `ctx.enter_short(...)`, `ctx.close(reason)` |
Notes: Notes:
@@ -136,6 +136,17 @@ the single and multi start paths are covered) and surfaced via
call `check_live_ready` first. `LIVE_CONFIRMATION` must equal call `check_live_ready` first. `LIVE_CONFIRMATION` must equal
`"I UNDERSTAND THE RISK"` — keep it a phrase, not a boolean. `"I UNDERSTAND THE RISK"` — keep it a phrase, not a boolean.
## Observability
- `app/metrics.py` renders Prometheus text at `GET /api/metrics` straight from
the manager status — no client library. Keep new series out of hot loops.
- `app/logs.py` keeps a small in-memory ring of log records, exposed at
`GET /api/logs` and shown in the dashboard. `Runtime._log_event` mirrors bot
events into the logger so the panel is useful (uvicorn's own loggers do not
propagate to the root handler).
- `docs/runbook.md` is the operations guide; update it when deploy or
recovery steps change.
## Risk controls & notifications ## Risk controls & notifications
- `app/engine/risk.py` (`RiskManager`) tracks daily loss, drawdown, position cap - `app/engine/risk.py` (`RiskManager`) tracks daily loss, drawdown, position cap
+15 -1
View File
@@ -207,7 +207,9 @@ appears in the UI immediately. See **[AGENTS.md](AGENTS.md)** for the full
template and the `StrategyContext` API. template and the `StrategyContext` API.
Bundled strategies: `sma_cross`, `ema_cross`, `macd_trend`, Bundled strategies: `sma_cross`, `ema_cross`, `macd_trend`,
`bollinger_reversion`, `donchian_breakout`, `momentum`, `rsi_reversion`. `bollinger_reversion`, `donchian_breakout`, `momentum`, `rsi_reversion`,
`supertrend`, `vwap_reversion`, `ema_cross_short` (short-side example),
`williams_r`.
## API ## API
@@ -219,6 +221,8 @@ Bundled strategies: `sma_cross`, `ema_cross`, `macd_trend`,
| `POST` | `/api/bot/start-many` | Start several strategies with per-strategy allocations | | `POST` | `/api/bot/start-many` | Start several strategies with per-strategy allocations |
| `GET` | `/api/bot/status` | Per-strategy status for a multi-strategy run | | `GET` | `/api/bot/status` | Per-strategy status for a multi-strategy run |
| `GET` | `/api/live-check` | Go-live preflight (blockers for live trading) | | `GET` | `/api/live-check` | Go-live preflight (blockers for live trading) |
| `GET` | `/api/metrics` | Prometheus text exposition |
| `GET` | `/api/logs` | Recent backend log lines (in-memory ring) |
| `POST` | `/api/bot/stop` | Stop the bot | | `POST` | `/api/bot/stop` | Stop the bot |
| `POST` | `/api/bot/kill` | Kill switch: flatten + halt | | `POST` | `/api/bot/kill` | Kill switch: flatten + halt |
| `POST` | `/api/backtest` | Backtest a strategy on historical candles | | `POST` | `/api/backtest` | Backtest a strategy on historical candles |
@@ -233,6 +237,16 @@ Bundled strategies: `sma_cross`, `ema_cross`, `macd_trend`,
| `DELETE` | `/api/presets/{id}` | Delete a preset | | `DELETE` | `/api/presets/{id}` | Delete a preset |
| `WS` | `/api/ws` | Live status + event stream | | `WS` | `/api/ws` | Live status + event stream |
## Observability
- `GET /api/metrics` exposes Prometheus metrics (`crypto_equity`,
`crypto_total_pnl`, per-strategy `crypto_strategy_equity`/`_pnl`/`_allocation`/
`_halted`, `crypto_info`). Point the existing Prometheus scrape at it.
- The dashboard's **Server logs** panel shows recent backend log lines
(in-memory ring buffer), useful alongside **Activity**.
- See [docs/runbook.md](docs/runbook.md) for operations (deploys, kill switch,
resetting paper state, going live).
## Tests & lint ## Tests & lint
```bash ```bash
+26 -1
View File
@@ -4,10 +4,20 @@ import asyncio
from typing import Annotated from typing import Annotated
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
Response,
WebSocket,
WebSocketDisconnect,
)
from app.config import Settings from app.config import Settings
from app.engine.strategies import get_strategy, list_strategies, strategy_spec from app.engine.strategies import get_strategy, list_strategies, strategy_spec
from app.logs import get_log_handler
from app.metrics import render_metrics
from app.models import OrderSide, Position, PositionStatus from app.models import OrderSide, Position, PositionStatus
from app.runtime import Runtime from app.runtime import Runtime
from app.schemas import ( from app.schemas import (
@@ -65,6 +75,21 @@ def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@router.get("/metrics")
def metrics(runtime: GuardedRuntimeDep) -> Response:
"""Prometheus text exposition, unauthenticated so a scraper can reach it."""
status = runtime.multi_status()
body = render_metrics(status, runtime.settings.mode, runtime.settings.exchange)
return Response(content=body, media_type="text/plain; version=0.0.4")
@router.get("/logs")
def logs(runtime: GuardedRuntimeDep, limit: int = 100) -> dict:
handler = get_log_handler()
entries = handler.snapshot(limit) if handler is not None else []
return {"entries": entries}
@router.get("/version") @router.get("/version")
def version(request: Request) -> dict[str, str]: def version(request: Request) -> dict[str, str]:
"""Build version of the running backend, used by the client to self-heal """Build version of the running backend, used by the client to self-heal
+97
View File
@@ -151,6 +151,103 @@ def roc(values: Sequence[float], period: int = 10) -> list[Number]:
return out return out
def williams_r(candles: Sequence[Candle], period: int = 14) -> list[Number]:
"""Williams %R, in [-100, 0]. Below -80 is oversold, above -20 overbought."""
if period <= 0:
raise ValueError("period must be positive")
out: list[Number] = [None] * len(candles)
for i in range(period - 1, len(candles)):
window = candles[i - period + 1 : i + 1]
highest = max(candle.high for candle in window)
lowest = min(candle.low for candle in window)
if highest == lowest:
out[i] = -50.0
else:
out[i] = (highest - candles[i].close) / (highest - lowest) * -100
return out
def typical_price(candle: Candle) -> float:
return (candle.high + candle.low + candle.close) / 3
def vwap(candles: Sequence[Candle], period: int | None = None) -> list[Number]:
"""Volume-weighted average price, rolling over ``period`` bars.
With ``period=None`` it is cumulative from the first candle.
"""
out: list[Number] = [None] * len(candles)
pv_sum = 0.0
volume_sum = 0.0
window: list[tuple[float, float]] = []
for i, candle in enumerate(candles):
price = typical_price(candle)
volume = candle.volume
pv_sum += price * volume
volume_sum += volume
if period is not None:
window.append((price, volume))
if len(window) > period:
old_price, old_volume = window.pop(0)
pv_sum -= old_price * old_volume
volume_sum -= old_volume
if len(window) < period:
continue
if volume_sum:
out[i] = pv_sum / volume_sum
return out
def supertrend(
candles: Sequence[Candle],
period: int = 10,
multiplier: float = 3.0,
) -> tuple[list[float | None], list[bool | None]]:
"""Supertrend line and trend direction (True = uptrend) per candle."""
atr_series = atr(candles, period)
line: list[float | None] = [None] * len(candles)
up: list[bool | None] = [None] * len(candles)
final_upper: float | None = None
final_lower: float | None = None
prev_close: float | None = None
direction_up = True
for i, candle in enumerate(candles):
atr_value = atr_series[i]
if atr_value is None:
continue
midpoint = (candle.high + candle.low) / 2
basic_upper = midpoint + multiplier * atr_value
basic_lower = midpoint - multiplier * atr_value
if final_upper is None or final_lower is None:
final_upper, final_lower = basic_upper, basic_lower
direction_up = candle.close >= midpoint
else:
assert prev_close is not None
final_upper = (
basic_upper
if basic_upper < final_upper or prev_close > final_upper
else final_upper
)
final_lower = (
basic_lower
if basic_lower > final_lower or prev_close < final_lower
else final_lower
)
if direction_up:
direction_up = candle.close >= final_lower
else:
direction_up = candle.close > final_upper
prev_close = candle.close
line[i] = final_lower if direction_up else final_upper
up[i] = direction_up
return line, up
def last(series: Sequence[Number]) -> Number: def last(series: Sequence[Number]) -> Number:
for value in reversed(series): for value in reversed(series):
if value is not None: if value is not None:
+2
View File
@@ -158,6 +158,8 @@ class MultiStrategyManager:
halted=engine_status.halted, halted=engine_status.halted,
halt_reason=engine_status.halt_reason, halt_reason=engine_status.halt_reason,
trades_today=engine_status.trades_today, trades_today=engine_status.trades_today,
day_pnl=engine_status.day_pnl,
drawdown_pct=engine_status.drawdown_pct,
error=engine_status.error, error=engine_status.error,
) )
) )
+24 -1
View File
@@ -5,7 +5,18 @@ from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, ClassVar, Literal, Protocol from typing import Any, ClassVar, Literal, Protocol
from app.engine.indicators import atr, ema, macd, roc, rolling_std, rsi, sma from app.engine.indicators import (
atr,
ema,
macd,
roc,
rolling_std,
rsi,
sma,
supertrend,
vwap,
williams_r,
)
from app.models import PositionSide from app.models import PositionSide
from app.schemas import Candle, ParamSpec, StrategyOut from app.schemas import Candle, ParamSpec, StrategyOut
@@ -116,6 +127,18 @@ class StrategyContext:
def roc(self, period: int = 10) -> float | None: def roc(self, period: int = 10) -> float | None:
return roc(self.closes, period)[-1] return roc(self.closes, period)[-1]
def vwap(self, period: int | None = None) -> float | None:
return vwap(self._candles, period)[-1]
def williams_r(self, period: int = 14) -> float | None:
return williams_r(self._candles, period)[-1]
def supertrend(
self, period: int = 10, multiplier: float = 3.0
) -> tuple[float | None, bool | None]:
line, up = supertrend(self._candles, period, multiplier)
return line[-1], up[-1]
# --- order intents ------------------------------------------------ # --- order intents ------------------------------------------------
def enter_long( def enter_long(
self, self,
@@ -0,0 +1,70 @@
from __future__ import annotations
from typing import ClassVar
from app.engine.indicators import atr, ema
from app.engine.strategies.base import Strategy, StrategyContext, register
from app.models import PositionSide
@register
class EmaCrossShort(Strategy):
"""Short-side example: sell EMA cross-downs, cover on cross-up."""
key: ClassVar[str] = "ema_cross_short"
name: ClassVar[str] = "EMA Crossover (short)"
description: ClassVar[str] = (
"Open a short when the fast EMA crosses below the slow EMA and cover when "
"it crosses back above. Demonstrates short trades; optional ATR stop."
)
default_params: ClassVar[dict] = {
"fast": 9,
"slow": 21,
"atr_period": 14,
"atr_stop_mult": 2.0,
"size": 1.0,
}
param_help: ClassVar[dict] = {
"fast": "Fast EMA period",
"slow": "Slow EMA period",
"atr_period": "ATR lookback for the stop",
"atr_stop_mult": "Stop distance in ATRs (0 disables)",
"size": "Fraction of equity per short",
}
param_bounds: ClassVar[dict] = {
"fast": (2, 100),
"slow": (3, 300),
"atr_stop_mult": (0.0, 10.0),
"size": (0.01, 1.0),
}
def on_candle(self, ctx: StrategyContext) -> None:
fast_period = int(self.params["fast"])
slow_period = int(self.params["slow"])
if len(ctx.closes) < slow_period + 2:
return
fast = ema(ctx.closes, fast_period)
slow = ema(ctx.closes, slow_period)
f_prev, f_now = fast[-2], fast[-1]
s_prev, s_now = slow[-2], slow[-1]
if None in (f_prev, f_now, s_prev, s_now):
return
crossed_down = f_prev >= s_prev and f_now < s_now
crossed_up = f_prev <= s_prev and f_now > s_now
if ctx.is_flat and crossed_down:
stop = None
mult = float(self.params["atr_stop_mult"])
if mult > 0:
atr_value = atr(ctx.candles, int(self.params["atr_period"]))[-1]
if atr_value:
stop = ctx.price + mult * atr_value
ctx.enter_short(
size=float(self.params["size"]),
stop_loss=stop,
reason="EMA cross down",
)
elif ctx.side == PositionSide.SHORT and crossed_up:
ctx.close(reason="EMA cross up")
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import ClassVar
from app.engine.indicators import supertrend as supertrend_series
from app.engine.strategies.base import Strategy, StrategyContext, register
from app.models import PositionSide
@register
class Supertrend(Strategy):
"""Trend following on the Supertrend indicator, long or short."""
key: ClassVar[str] = "supertrend"
name: ClassVar[str] = "Supertrend"
description: ClassVar[str] = (
"Go long when Supertrend flips to an uptrend and short when it flips to "
"a downtrend. Uses the Supertrend line as a stop."
)
default_params: ClassVar[dict] = {
"period": 10,
"multiplier": 3.0,
"allow_shorts": False,
"size": 1.0,
}
param_help: ClassVar[dict] = {
"period": "ATR period for the Supertrend bands",
"multiplier": "ATR multiplier (band width)",
"allow_shorts": "Also take short trades in downtrends",
"size": "Fraction of equity per trade",
}
param_bounds: ClassVar[dict] = {
"period": (2, 100),
"multiplier": (0.5, 10.0),
"size": (0.01, 1.0),
}
def on_candle(self, ctx: StrategyContext) -> None:
period = int(self.params["period"])
multiplier = float(self.params["multiplier"])
if len(ctx.candles) < period + 2:
return
line_series, up_series = supertrend_series(ctx.candles, period, multiplier)
line, up = line_series[-1], up_series[-1]
prev_up = up_series[-2]
if line is None or up is None or prev_up is None:
return
allow_shorts = bool(self.params.get("allow_shorts", False))
size = float(self.params["size"])
if ctx.side == PositionSide.LONG and not up:
ctx.close(reason="Supertrend turned down")
return
if ctx.side == PositionSide.SHORT and up:
ctx.close(reason="Supertrend turned up")
return
if ctx.is_flat and up and not prev_up:
ctx.enter_long(size=size, stop_loss=line, reason="Supertrend flipped up")
elif ctx.is_flat and allow_shorts and not up and prev_up:
ctx.enter_short(size=size, stop_loss=line, reason="Supertrend flipped down")
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import ClassVar
from app.engine.strategies.base import Strategy, StrategyContext, register
from app.models import PositionSide
@register
class VwapReversion(Strategy):
"""Mean reversion around VWAP, long-only."""
key: ClassVar[str] = "vwap_reversion"
name: ClassVar[str] = "VWAP Reversion"
description: ClassVar[str] = (
"Open long when price falls this far below a rolling VWAP and exit when "
"it returns to VWAP. Optional ATR stop."
)
default_params: ClassVar[dict] = {
"period": 20,
"deviation_pct": 0.005,
"atr_period": 14,
"atr_stop_mult": 2.0,
"size": 1.0,
}
param_help: ClassVar[dict] = {
"period": "Rolling VWAP window in bars",
"deviation_pct": "Entry distance below VWAP (0.005 = 0.5%)",
"atr_period": "ATR lookback for the stop",
"atr_stop_mult": "Stop distance in ATRs (0 disables)",
"size": "Fraction of equity per trade",
}
param_bounds: ClassVar[dict] = {
"period": (2, 200),
"deviation_pct": (0.0, 0.2),
"atr_stop_mult": (0.0, 10.0),
"size": (0.01, 1.0),
}
def on_candle(self, ctx: StrategyContext) -> None:
period = int(self.params["period"])
if len(ctx.candles) < period + 1:
return
reference = ctx.vwap(period)
if reference is None or reference <= 0:
return
threshold = reference * (1 - float(self.params["deviation_pct"]))
if ctx.is_flat and ctx.price < threshold:
stop = None
mult = float(self.params["atr_stop_mult"])
if mult > 0:
atr_value = ctx.atr(int(self.params["atr_period"]))
if atr_value:
stop = ctx.price - mult * atr_value
ctx.enter_long(
size=float(self.params["size"]),
stop_loss=stop,
reason="price below VWAP band",
)
elif ctx.side == PositionSide.LONG and ctx.price >= reference:
ctx.close(reason="price returned to VWAP")
@@ -0,0 +1,61 @@
from __future__ import annotations
from typing import ClassVar
from app.engine.strategies.base import Strategy, StrategyContext, register
from app.models import PositionSide
@register
class WilliamsR(Strategy):
"""Williams %R mean reversion, long-only."""
key: ClassVar[str] = "williams_r"
name: ClassVar[str] = "Williams %R"
description: ClassVar[str] = (
"Enter long when Williams %R is oversold (at or below the oversold "
"level) and exit when it becomes overbought. Optional ATR stop."
)
default_params: ClassVar[dict] = {
"period": 14,
"oversold": -80.0,
"overbought": -20.0,
"atr_period": 14,
"atr_stop_mult": 2.0,
"size": 1.0,
}
param_help: ClassVar[dict] = {
"period": "Williams %R lookback",
"oversold": "Oversold level (e.g. -80)",
"overbought": "Overbought level (e.g. -20)",
"atr_period": "ATR lookback for the stop",
"atr_stop_mult": "Stop distance in ATRs (0 disables)",
"size": "Fraction of equity per trade",
}
param_bounds: ClassVar[dict] = {
"period": (2, 100),
"oversold": (-100.0, -50.0),
"overbought": (-50.0, 0.0),
"atr_stop_mult": (0.0, 10.0),
"size": (0.01, 1.0),
}
def on_candle(self, ctx: StrategyContext) -> None:
value = ctx.williams_r(int(self.params["period"]))
if value is None:
return
if ctx.is_flat and value <= float(self.params["oversold"]):
stop = None
mult = float(self.params["atr_stop_mult"])
if mult > 0:
atr_value = ctx.atr(int(self.params["atr_period"]))
if atr_value:
stop = ctx.price - mult * atr_value
ctx.enter_long(
size=float(self.params["size"]),
stop_loss=stop,
reason=f"Williams %R {value:.1f} oversold",
)
elif ctx.side == PositionSide.LONG and value >= float(self.params["overbought"]):
ctx.close(reason=f"Williams %R {value:.1f} overbought")
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import logging
from collections import deque
from datetime import UTC, datetime
class RingLogHandler(logging.Handler):
"""Keeps the most recent log records in memory for the dashboard.
Deliberately small and dependency-free: the goal is a debugging aid, not a
log store. Attach once at startup.
"""
def __init__(self, capacity: int = 200) -> None:
super().__init__()
self.records: deque[dict] = deque(maxlen=capacity)
def emit(self, record: logging.LogRecord) -> None:
try:
self.records.append(
{
"time": datetime.fromtimestamp(record.created, UTC).isoformat(),
"level": record.levelname.lower(),
"logger": record.name,
"message": record.getMessage(),
}
)
except Exception: # never let logging break the app
self.handleError(record)
def snapshot(self, limit: int = 100) -> list[dict]:
items = list(self.records)
return items[-limit:]
_handler: RingLogHandler | None = None
def install_log_capture(capacity: int = 200) -> RingLogHandler:
"""Attach the ring handler to the root logger (idempotent)."""
global _handler
if _handler is not None:
return _handler
_handler = RingLogHandler(capacity)
_handler.setLevel(logging.INFO)
root = logging.getLogger()
root.addHandler(_handler)
if root.level > logging.INFO or root.level == logging.NOTSET:
root.setLevel(logging.INFO)
return _handler
def get_log_handler() -> RingLogHandler | None:
return _handler
+2
View File
@@ -10,6 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import router from app.api.routes import router
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.db import init_db from app.db import init_db
from app.logs import install_log_capture
from app.runtime import Runtime from app.runtime import Runtime
@@ -22,6 +23,7 @@ def create_app(
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
install_log_capture()
init_db() init_db()
app.state.runtime = runtime app.state.runtime = runtime
yield yield
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
from app.schemas import MultiStatusOut
# Rendered as Prometheus text exposition format (no client library needed).
# The route serves this at GET /api/metrics for the existing Prometheus scrape.
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def _metric(
name: str, help_text: str, value: float | int, labels: dict[str, str] | None = None
) -> str:
lines = [f"# HELP {name} {help_text}", f"# TYPE {name} gauge"]
if labels:
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"{name}{{{rendered}}} {value}")
else:
lines.append(f"{name} {value}")
return "\n".join(lines)
def render_metrics(status: MultiStatusOut, mode: str, exchange: str) -> str:
"""Render the current portfolio state as Prometheus metrics."""
parts = [
_metric("crypto_running", "1 when the bot is running", int(status.running)),
_metric("crypto_equity", "Total account equity", status.equity),
_metric("crypto_starting_balance", "Starting balance", status.starting_balance),
_metric("crypto_total_pnl", "Total profit and loss", status.total_pnl),
_metric(
"crypto_open_positions",
"Number of open positions across strategies",
sum(s.open_positions for s in status.strategies),
),
_metric(
"crypto_strategies",
"Number of configured strategies",
len(status.strategies),
),
]
if status.price is not None:
parts.append(_metric("crypto_price", "Latest mark price", status.price))
# Common labels shared by the per-strategy families.
lines = [
"# HELP crypto_strategy_equity Equity per strategy",
"# TYPE crypto_strategy_equity gauge",
]
for s in status.strategies:
labels = {"strategy": s.strategy_key, "symbol": s.symbol}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"crypto_strategy_equity{{{rendered}}} {s.equity}")
parts.append("\n".join(lines))
lines = [
"# HELP crypto_strategy_pnl Total P&L per strategy",
"# TYPE crypto_strategy_pnl gauge",
]
for s in status.strategies:
labels = {"strategy": s.strategy_key, "symbol": s.symbol}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"crypto_strategy_pnl{{{rendered}}} {s.total_pnl}")
parts.append("\n".join(lines))
lines = [
"# HELP crypto_strategy_allocation Fraction of the balance per strategy",
"# TYPE crypto_strategy_allocation gauge",
]
for s in status.strategies:
labels = {"strategy": s.strategy_key}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"crypto_strategy_allocation{{{rendered}}} {s.allocation}")
parts.append("\n".join(lines))
lines = [
"# HELP crypto_strategy_halted 1 when a strategy is halted",
"# TYPE crypto_strategy_halted gauge",
]
for s in status.strategies:
labels = {"strategy": s.strategy_key}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"crypto_strategy_halted{{{rendered}}} {int(s.halted)}")
parts.append("\n".join(lines))
info_labels = {"mode": mode, "exchange": exchange}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in info_labels.items())
parts.append(
"# HELP crypto_info Build information\n"
"# TYPE crypto_info gauge\n"
f"crypto_info{{{rendered}}} 1"
)
return "\n".join(parts) + "\n"
+16
View File
@@ -32,6 +32,15 @@ from app.store import Store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_LOG_LEVELS = {
"debug": logging.DEBUG,
"info": logging.INFO,
"trade": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
}
FeedFactory = Callable[[Settings], MarketData] FeedFactory = Callable[[Settings], MarketData]
BrokerFactory = Callable[[Settings], Broker] BrokerFactory = Callable[[Settings], Broker]
NotifierFactory = Callable[[Settings], Notifier | None] NotifierFactory = Callable[[Settings], Notifier | None]
@@ -91,8 +100,15 @@ class Runtime:
with suppress(asyncio.QueueFull): with suppress(asyncio.QueueFull):
queue.put_nowait(payload) queue.put_nowait(payload)
if payload.get("type") == "event": if payload.get("type") == "event":
self._log_event(payload["data"])
self._maybe_notify(payload["data"]) self._maybe_notify(payload["data"])
def _log_event(self, event: dict[str, Any]) -> None:
"""Mirror bot events into the logger so the dashboard log view is useful."""
level = str(event.get("level", "info")).lower()
message = event.get("message", "")
logger.log(_LOG_LEVELS.get(level, logging.INFO), "%s", message)
def _maybe_notify(self, event: dict[str, Any]) -> None: def _maybe_notify(self, event: dict[str, Any]) -> None:
if event.get("level") not in self.notify_levels: if event.get("level") not in self.notify_levels:
return return
+2
View File
@@ -177,6 +177,8 @@ class StrategyStatusOut(BaseModel):
halted: bool = False halted: bool = False
halt_reason: str | None = None halt_reason: str | None = None
trades_today: int = 0 trades_today: int = 0
day_pnl: float = 0.0
drawdown_pct: float = 0.0
error: str | None = None error: str | None = None
+2
View File
@@ -134,6 +134,8 @@ def test_start_many_and_multi_status(runtime: Runtime, market: FakeMarketData) -
assert status["running"] is True assert status["running"] is True
assert len(status["strategies"]) == 2 assert len(status["strategies"]) == 2
assert status["equity"] == 10000 assert status["equity"] == 10000
# Per-strategy fields the dashboard aggregates must be present.
assert {"day_pnl", "drawdown_pct"} <= set(status["strategies"][0])
client.post("/api/bot/stop") client.post("/api/bot/stop")
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import pytest
from app.engine.indicators import supertrend, vwap
from app.engine.strategies import get_strategy
from app.engine.strategies.base import StrategyContext
from app.models import PositionSide
from app.schemas import Candle
def candles(closes: list[float], volume: float = 1.0) -> list[Candle]:
return [
Candle(ts=i, open=c, high=c * 1.001, low=c * 0.999, close=c, volume=volume)
for i, c in enumerate(closes)
]
def ctx(closes: list[float], volume: float = 1.0, position=None) -> StrategyContext:
cs = candles(closes, volume)
return StrategyContext(candles=cs, position=position, equity=1000.0, cash=1000.0)
# --- indicators -----------------------------------------------------------
def test_vwap_equals_typical_price_when_flat() -> None:
cs = candles([100.0, 100.0, 100.0])
series = vwap(cs)
# Typical price equals the close for symmetric high/low here only if equal;
# with high/low spread the VWAP is the volume-weighted typical price.
assert series[-1] is not None
assert series[-1] == pytest.approx(100.0, abs=0.5)
def test_vwap_weights_by_volume() -> None:
cs = [
Candle(ts=0, open=100, high=100, low=100, close=100, volume=1),
Candle(ts=1, open=200, high=200, low=200, close=200, volume=3),
]
series = vwap(cs)
assert series[-1] == pytest.approx((100 * 1 + 200 * 3) / 4)
def test_vwap_rolling_window() -> None:
cs = [
Candle(ts=0, open=100, high=100, low=100, close=100, volume=1),
Candle(ts=1, open=100, high=100, low=100, close=100, volume=1),
Candle(ts=2, open=200, high=200, low=200, close=200, volume=1),
]
series = vwap(cs, period=2)
assert series[0] is None
assert series[1] == pytest.approx(100.0)
assert series[2] == pytest.approx(150.0)
def test_supertrend_direction_flips() -> None:
rising = [float(i) for i in range(1, 60)]
_, up_rising = supertrend(candles(rising), 10, 3.0)
assert up_rising[-1] is True
falling = [float(i) for i in range(60, 1, -1)]
_, up_falling = supertrend(candles(falling), 10, 3.0)
assert up_falling[-1] is False
def test_supertrend_line_trails_price() -> None:
rising = [float(i) for i in range(1, 60)]
line, up = supertrend(candles(rising), 10, 3.0)
assert line[-1] is not None and up[-1] is True
assert line[-1] < rising[-1] # the stop sits below price in an uptrend
# --- strategies -----------------------------------------------------------
def test_new_strategies_registered() -> None:
from app.engine.strategies import list_strategies
keys = {c.key for c in list_strategies()}
assert {"supertrend", "vwap_reversion", "ema_cross_short"} <= keys
def test_ema_cross_short_opens_a_short() -> None:
strategy = get_strategy("ema_cross_short")({"fast": 2, "slow": 4, "atr_stop_mult": 0})
# Fast EMA above the slow then crossing below: mirror of the long case.
c = ctx([10, 10, 10, 10, 10, 10, 10, 10, 13, 11, 8])
strategy.on_candle(c)
intents = c.take_intents()
assert intents and intents[0].action == "enter_short"
assert intents[0].stop_loss is None
def test_ema_cross_short_covers_on_cross_up() -> None:
from app.models import Position
strategy = get_strategy("ema_cross_short")({"fast": 2, "slow": 4, "atr_stop_mult": 0})
position = Position(
strategy_key="ema_cross_short",
symbol="BTC/USDT",
side=PositionSide.SHORT,
qty=1.0,
entry_price=100.0,
)
c = ctx([10, 10, 10, 10, 10, 10, 10, 10, 7, 9, 12], position=position)
strategy.on_candle(c)
intents = c.take_intents()
assert intents and intents[0].action == "close"
def test_vwap_reversion_runs_without_error() -> None:
strategy = get_strategy("vwap_reversion")({"period": 5, "deviation_pct": 0.01})
c = ctx([100, 100, 100, 100, 100, 90])
strategy.on_candle(c)
intents = c.take_intents()
assert intents and intents[0].action == "enter_long"
def test_supertrend_runs_without_error() -> None:
strategy = get_strategy("supertrend")({})
c = ctx([100 + i for i in range(40)])
strategy.on_candle(c)
# No assertion on intents (depends on the flip); just must not raise.
assert isinstance(c.take_intents(), list)
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
import logging
from app.logs import RingLogHandler
from app.metrics import render_metrics
from app.schemas import MultiStatusOut, StrategyStatusOut
def make_status(**overrides) -> MultiStatusOut:
strategy = StrategyStatusOut(
strategy_key="sma_cross",
strategy_name="SMA Crossover",
params={},
symbol="BTC/USDT",
timeframe="5m",
allocation=0.5,
cash=5000,
equity=5100,
realized_pnl=100,
unrealized_pnl=0,
total_pnl=100,
open_positions=1,
halted=False,
trades_today=2,
)
base = {
"running": True,
"mode": "paper",
"exchange": "binance",
"price": 100.0,
"starting_balance": 10000,
"equity": 10100,
"total_pnl": 100,
"strategies": [strategy],
}
base.update(overrides)
return MultiStatusOut(**base)
def test_render_metrics_contains_core_series() -> None:
body = render_metrics(make_status(), "paper", "binance")
assert "crypto_running 1" in body
assert "crypto_equity 10100" in body
assert "crypto_total_pnl 100" in body
assert "crypto_open_positions 1" in body
assert 'crypto_strategy_equity{strategy="sma_cross",symbol="BTC/USDT"} 5100' in body
assert 'crypto_strategy_allocation{strategy="sma_cross"} 0.5' in body
assert 'crypto_info{mode="paper",exchange="binance"} 1' in body
def test_render_metrics_omits_price_when_none() -> None:
body = render_metrics(make_status(price=None), "paper", "binance")
assert "crypto_price" not in body
def test_ring_log_handler_captures() -> None:
handler = RingLogHandler(capacity=5)
logger = logging.getLogger("test.ring")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
try:
logger.info("hello %s", "world")
entries = handler.snapshot()
assert entries[-1]["message"] == "hello world"
assert entries[-1]["level"] == "info"
finally:
logger.removeHandler(handler)
def test_ring_log_handler_respects_capacity() -> None:
handler = RingLogHandler(capacity=3)
logger = logging.getLogger("test.ring.cap")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
try:
for i in range(10):
logger.info("m%d", i)
entries = handler.snapshot()
assert len(entries) == 3
assert entries[-1]["message"] == "m9"
finally:
logger.removeHandler(handler)
def test_metrics_endpoint(runtime) -> None:
from fastapi.testclient import TestClient
from app.main import create_app
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.get("/api/metrics")
assert response.status_code == 200
assert "crypto_running" in response.text
def test_logs_endpoint(runtime) -> None:
from fastapi.testclient import TestClient
from app.main import create_app
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.get("/api/logs")
assert response.status_code == 200
assert "entries" in response.json()
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import pytest
from app.engine.indicators import williams_r
from app.engine.strategies import get_strategy
from app.engine.strategies.base import StrategyContext
from app.models import Position, PositionSide
from app.schemas import Candle
def candles(closes: list[float]) -> list[Candle]:
return [
Candle(ts=i, open=c, high=c * 1.001, low=c * 0.999, close=c, volume=1.0)
for i, c in enumerate(closes)
]
def ctx(closes: list[float], position=None) -> StrategyContext:
return StrategyContext(candles=candles(closes), position=position, equity=1000.0, cash=1000.0)
# --- indicator ------------------------------------------------------------
def test_williams_r_bounds() -> None:
series = williams_r(candles([100, 102, 98, 101, 99, 103, 97, 100]), 5)
for value in series:
if value is not None:
assert -100.0 <= value <= 0.0
def test_williams_r_rising_is_overbought() -> None:
rising = [float(i) for i in range(1, 30)]
value = williams_r(candles(rising), 14)[-1]
assert value is not None and value > -50 # near the top of the range
def test_williams_r_falling_is_oversold() -> None:
falling = [float(i) for i in range(30, 1, -1)]
value = williams_r(candles(falling), 14)[-1]
assert value is not None and value < -50 # near the bottom of the range
def test_williams_r_flat_series_is_neutral() -> None:
flat = [100.0] * 20
assert williams_r(candles(flat), 14)[-1] == pytest.approx(-50.0)
# --- strategy -------------------------------------------------------------
def test_williams_r_registered() -> None:
from app.engine.strategies import list_strategies
assert "williams_r" in {c.key for c in list_strategies()}
def test_enters_long_when_oversold() -> None:
strategy = get_strategy("williams_r")({"period": 5, "oversold": -80, "atr_stop_mult": 0})
falling = [100 - i for i in range(15)]
c = ctx([float(x) for x in falling])
strategy.on_candle(c)
intents = c.take_intents()
assert intents and intents[0].action == "enter_long"
def test_exits_when_overbought() -> None:
strategy = get_strategy("williams_r")({"period": 5, "overbought": -20, "atr_stop_mult": 0})
position = Position(
strategy_key="williams_r",
symbol="BTC/USDT",
side=PositionSide.LONG,
qty=1.0,
entry_price=100.0,
)
rising = [100 + i for i in range(15)]
c = ctx([float(x) for x in rising], position=position)
strategy.on_candle(c)
intents = c.take_intents()
assert intents and intents[0].action == "close"
def test_atr_stop_is_set_when_enabled() -> None:
strategy = get_strategy("williams_r")({"period": 5, "oversold": -80, "atr_stop_mult": 2.0})
falling = [float(100 - i) for i in range(15)]
c = ctx(falling)
strategy.on_candle(c)
intents = c.take_intents()
assert intents and intents[0].action == "enter_long"
assert intents[0].stop_loss is not None
+59
View File
@@ -0,0 +1,59 @@
# Operations runbook
Practical steps for running the deployed bot. Host paths are examples — adjust
to the deploy host.
## Find the stack
```bash
docker compose ps
docker compose logs --tail=200 backend
docker compose logs -f frontend
```
The backend exposes `GET /api/metrics` (Prometheus text format) for the existing
Prometheus/Grafana stack; scrape it alongside the other services. Useful series:
`crypto_running`, `crypto_equity`, `crypto_total_pnl`, `crypto_open_positions`,
`crypto_strategy_equity`, `crypto_strategy_pnl`, `crypto_strategy_halted`,
`crypto_info`.
## Stop / kill trading
- **Dashboard:** the **Kill all** button (or Stop all) flattens and halts.
- **API:** `curl -X POST -H "X-API-Token: $TOKEN" https://<host>/api/bot/kill`
- **Hard stop:** `docker compose stop backend` stops the process entirely. It
does **not** close open positions — use the API kill first if you care.
## Something is wrong
1. `docker compose ps` — is the service `healthy`?
2. `docker compose logs --tail=200 backend` — look for `error`/`Traceback`.
3. In the dashboard, open **Server logs** (shows the in-memory ring of recent
log lines) and **Activity** (trades, halts, errors).
4. `curl https://<host>/api/live-check` — if live is blocked, the reasons are
listed.
## A deploy went out but the UI looks stale
The deploy job fails if the frontend container's build stamp does not match the
commit; if you see stale UI and the job passed, hard-refresh or open
incognito. The footer shows `client` vs `server` versions — they must match.
## Reset paper state
```bash
docker compose stop backend
# remove the SQLite file on the host mount
rm -f <CRYPTO_DATA_DIR>/trader.db
docker compose start backend
```
## Going live (do this deliberately)
1. Rehearse the kill switch in paper mode.
2. Set `MAX_DAILY_LOSS`, `MAX_DRAWDOWN_PCT` and/or `MAX_TRADES_PER_DAY`, and
`MAX_POSITION_PCT < 1.0`.
3. Set `MIN_LIVE_BALANCE` and add exchange credentials.
4. Set `LIVE_CONFIRMATION="I UNDERSTAND THE RISK"`.
5. Deploy, confirm `/api/live-check` returns `ok: true`, then start with a
small balance and watch it.
+35 -62
View File
@@ -1,14 +1,15 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { aggregateStatus } from './aggregate'
import * as api from './api' import * as api from './api'
import BacktestPanel from './components/BacktestPanel.vue' import BacktestPanel from './components/BacktestPanel.vue'
import EquityChart from './components/EquityChart.vue' import EquityChart from './components/EquityChart.vue'
import EventLog from './components/EventLog.vue' import EventLog from './components/EventLog.vue'
import LogsPanel from './components/LogsPanel.vue'
import MultiStrategyPanel from './components/MultiStrategyPanel.vue' import MultiStrategyPanel from './components/MultiStrategyPanel.vue'
import OrdersTable from './components/OrdersTable.vue' import OrdersTable from './components/OrdersTable.vue'
import PositionsTable from './components/PositionsTable.vue' import PositionsTable from './components/PositionsTable.vue'
import StatusCards from './components/StatusCards.vue' import StatusCards from './components/StatusCards.vue'
import StrategyPanel from './components/StrategyPanel.vue'
import SweepPanel from './components/SweepPanel.vue' import SweepPanel from './components/SweepPanel.vue'
const status = ref({ const status = ref({
@@ -34,6 +35,29 @@ const strategiesLoading = ref(true)
const presets = ref([]) const presets = ref([])
const multiStatus = ref({ running: false, strategies: [], total_pnl: 0, equity: 0 }) const multiStatus = ref({ running: false, strategies: [], total_pnl: 0, equity: 0 })
const liveCheck = ref({ ok: true, mode: 'paper', blockers: [] }) const liveCheck = ref({ ok: true, mode: 'paper', blockers: [] })
const runningLabel = computed(() => {
const list = multiStatus.value.strategies || []
if (!list.length) return 'Idle'
if (list.length === 1) {
return `Running: ${list[0].strategy_name || list[0].strategy_key}`
}
return `Running: ${list.length} strategies`
})
const displayStatus = computed(() => aggregateStatus(status.value, multiStatus.value))
const scopeLabel = computed(() => {
const list = multiStatus.value.strategies || []
if (!list.length) {
return status.value.symbol ? `${status.value.symbol} ${status.value.timeframe}` : ''
}
const first = list[0]
const sameScope = list.every(
(s) => s.symbol === first.symbol && s.timeframe === first.timeframe,
)
return sameScope ? `${first.symbol} ${first.timeframe}` : `${list.length} pairs`
})
const positions = ref([]) const positions = ref([])
const orders = ref([]) const orders = ref([])
const events = ref([]) const events = ref([])
@@ -97,25 +121,6 @@ async function handleDeletePreset(id) {
await loadPresets() await loadPresets()
} }
async function handleStart(payload) {
if (liveCheck.value.mode === 'live' && !liveCheck.value.ok) {
status.value = {
...status.value,
error: 'Live trading blocked: ' + liveCheck.value.blockers.join('; '),
}
return
}
busy.value = true
try {
status.value = await api.startBot(payload)
await refreshAll()
} catch (error) {
status.value = { ...status.value, error: error.response?.data?.detail || error.message }
} finally {
busy.value = false
}
}
async function refreshMulti() { async function refreshMulti() {
multiStatus.value = await api.getMultiStatus() multiStatus.value = await api.getMultiStatus()
} }
@@ -167,29 +172,6 @@ async function handleKillMany() {
} }
} }
async function handleStop() {
busy.value = true
try {
status.value = await api.stopBot()
await refreshMulti()
await refreshAll()
} finally {
busy.value = false
}
}
async function handleKill() {
busy.value = true
try {
status.value = await api.killBot('manual kill switch')
await refreshAll()
} catch (error) {
status.value = { ...status.value, error: error.response?.data?.detail || error.message }
} finally {
busy.value = false
}
}
async function handleClose(id) { async function handleClose(id) {
try { try {
status.value = await api.closePosition(id) status.value = await api.closePosition(id)
@@ -313,17 +295,16 @@ onUnmounted(() => {
</span> </span>
</div> </div>
<div class="text-xs text-slate-400"> <div class="text-xs text-slate-400">
<span v-if="status.strategy_name">Running: {{ status.strategy_name }}</span> <span>{{ runningLabel }}</span>
<span v-else>Idle</span> <span v-if="scopeLabel"> · {{ scopeLabel }}</span>
<span v-if="status.symbol"> · {{ status.symbol }} {{ status.timeframe }}</span>
</div> </div>
</header> </header>
<div <div
v-if="status.halted" v-if="displayStatus.halted"
class="rounded-lg border border-rose-500 bg-rose-500/20 p-3 text-sm font-semibold text-rose-100" class="rounded-lg border border-rose-500 bg-rose-500/20 p-3 text-sm font-semibold text-rose-100"
> >
HALTED — {{ status.halt_reason }}. Trading is stopped and any open position was HALTED — {{ displayStatus.halt_reason }}. Trading is stopped and any open position was
flattened. Restart the bot to resume (limits reset). flattened. Restart the bot to resume (limits reset).
</div> </div>
@@ -341,29 +322,19 @@ onUnmounted(() => {
{{ status.error }} {{ status.error }}
</div> </div>
<StatusCards :status="status" /> <StatusCards :status="displayStatus" />
<StrategyPanel
:strategies="strategies"
:status="status"
:busy="busy"
:presets="presets"
:loading="strategiesLoading"
@start="handleStart"
@stop="handleStop"
@kill="handleKill"
@save-preset="handleSavePreset"
@delete-preset="handleDeletePreset"
/>
<MultiStrategyPanel <MultiStrategyPanel
:strategies="strategies" :strategies="strategies"
:multi-status="multiStatus" :multi-status="multiStatus"
:presets="presets"
:busy="busy" :busy="busy"
:loading="strategiesLoading" :loading="strategiesLoading"
@start-many="handleStartMany" @start-many="handleStartMany"
@stop="handleStopMany" @stop="handleStopMany"
@kill="handleKillMany" @kill="handleKillMany"
@save-preset="handleSavePreset"
@delete-preset="handleDeletePreset"
/> />
<BacktestPanel :strategies="strategies" :status="status" :loading="strategiesLoading" /> <BacktestPanel :strategies="strategies" :status="status" :loading="strategiesLoading" />
@@ -374,6 +345,8 @@ onUnmounted(() => {
<EventLog :events="events" /> <EventLog :events="events" />
</div> </div>
<LogsPanel />
<PositionsTable :positions="positions" @close="handleClose" /> <PositionsTable :positions="positions" @close="handleClose" />
<OrdersTable :orders="orders" /> <OrdersTable :orders="orders" />
+34
View File
@@ -0,0 +1,34 @@
/**
* Top-of-dashboard status. With one strategy it is that strategy's status;
* with several, the per-strategy numbers are aggregated for the whole account.
*/
export function aggregateStatus(status, multiStatus) {
const list = multiStatus?.strategies || []
if (list.length <= 1) return status
const sum = (field) => list.reduce((acc, s) => acc + (s[field] || 0), 0)
const halted = list.filter((s) => s.halted)
return {
...status,
running: multiStatus.running,
price: multiStatus.price ?? status.price,
cash: sum('cash'),
equity: multiStatus.equity,
starting_balance: multiStatus.starting_balance,
realized_pnl: sum('realized_pnl'),
unrealized_pnl: sum('unrealized_pnl'),
total_pnl: multiStatus.total_pnl,
day_pnl: sum('day_pnl'),
// Worst individual strategy drawdown; the portfolio peak is not tracked.
drawdown_pct: Math.max(0, ...list.map((s) => s.drawdown_pct || 0)),
trades_today: sum('trades_today'),
open_positions: sum('open_positions'),
halted: halted.length > 0,
halt_reason:
halted
.map((s) => s.halt_reason)
.filter(Boolean)
.join('; ') || null,
}
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { aggregateStatus } from './aggregate'
const SINGLE = { equity: 100, total_pnl: 5, strategies: [] }
function strategy(overrides = {}) {
return {
strategy_key: 'sma_cross',
symbol: 'BTC/USDT',
timeframe: '5m',
allocation: 0.5,
cash: 1000,
equity: 1000,
realized_pnl: 0,
unrealized_pnl: 0,
total_pnl: 0,
open_positions: 0,
halted: false,
halt_reason: null,
trades_today: 0,
day_pnl: 0,
drawdown_pct: 0,
...overrides,
}
}
describe('aggregateStatus', () => {
it('returns the plain status when there is at most one strategy', () => {
expect(aggregateStatus(SINGLE, { strategies: [] })).toBe(SINGLE)
expect(aggregateStatus(SINGLE, { strategies: [strategy()] })).toBe(SINGLE)
})
it('sums the per-strategy numbers', () => {
const multi = {
running: true,
equity: 2100,
starting_balance: 2000,
total_pnl: 100,
price: 123,
strategies: [
strategy({ cash: 1000, equity: 1100, realized_pnl: 100, unrealized_pnl: 0, total_pnl: 100, trades_today: 2, open_positions: 1, day_pnl: 40 }),
strategy({ cash: 1000, equity: 1000, realized_pnl: 0, unrealized_pnl: 0, total_pnl: 0, trades_today: 0, open_positions: 0, day_pnl: -10 }),
],
}
const result = aggregateStatus(SINGLE, multi)
expect(result.cash).toBe(2000)
expect(result.equity).toBe(2100)
expect(result.realized_pnl).toBe(100)
expect(result.total_pnl).toBe(100)
expect(result.day_pnl).toBe(30)
expect(result.trades_today).toBe(2)
expect(result.open_positions).toBe(1)
expect(result.price).toBe(123)
expect(result.running).toBe(true)
})
it('reports the worst drawdown and any halt', () => {
const multi = {
running: true,
equity: 2000,
starting_balance: 2000,
total_pnl: 0,
price: null,
strategies: [
strategy({ drawdown_pct: 3, halted: false }),
strategy({ drawdown_pct: 7.5, halted: true, halt_reason: 'daily loss limit hit' }),
],
}
const result = aggregateStatus(SINGLE, multi)
expect(result.drawdown_pct).toBe(7.5)
expect(result.halted).toBe(true)
expect(result.halt_reason).toBe('daily loss limit hit')
})
it('falls back to the plain price when multi has none', () => {
const multi = {
running: false,
equity: 2000,
starting_balance: 2000,
total_pnl: 0,
price: null,
strategies: [strategy(), strategy()],
}
const result = aggregateStatus({ ...SINGLE, price: 99 }, multi)
expect(result.price).toBe(99)
})
})
+2
View File
@@ -12,6 +12,8 @@ export const getPositions = () => http.get('/positions').then((r) => r.data)
export const getOrders = () => http.get('/orders').then((r) => r.data) export const getOrders = () => http.get('/orders').then((r) => r.data)
export const getEvents = () => http.get('/events').then((r) => r.data) export const getEvents = () => http.get('/events').then((r) => r.data)
export const getEquity = () => http.get('/equity').then((r) => r.data) export const getEquity = () => http.get('/equity').then((r) => r.data)
export const getLogs = (limit = 100) =>
http.get('/logs', { params: { limit } }).then((r) => r.data)
export const startBot = (payload) => http.post('/bot/start', payload).then((r) => r.data) export const startBot = (payload) => http.post('/bot/start', payload).then((r) => r.data)
export const stopBot = () => http.post('/bot/stop').then((r) => r.data) export const stopBot = () => http.post('/bot/stop').then((r) => r.data)
+73
View File
@@ -0,0 +1,73 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import * as api from '../api'
import { fmtTime } from '../format'
import CollapsiblePanel from './CollapsiblePanel.vue'
const entries = ref([])
const levelFilter = ref('all')
let timer
const levelClass = {
info: 'text-slate-300',
warning: 'text-amber-300',
error: 'text-rose-300',
critical: 'text-rose-400',
debug: 'text-slate-500',
}
const filtered = computed(() => {
if (levelFilter.value === 'all') return entries.value
return entries.value.filter((e) => e.level === levelFilter.value)
})
async function load() {
try {
const data = await api.getLogs(200)
entries.value = data.entries || []
} catch {
// ignore transient errors
}
}
onMounted(() => {
load()
timer = setInterval(load, 5000)
})
onUnmounted(() => clearInterval(timer))
</script>
<template>
<CollapsiblePanel
title="Server logs"
storage-key="logs"
:default-open="false"
:summary="`${entries.length} lines`"
>
<div class="space-y-2">
<div class="flex items-center gap-2">
<label class="space-y-1">
<span class="label">Level</span>
<select v-model="levelFilter" class="input">
<option value="all">all</option>
<option value="info">info</option>
<option value="warning">warning</option>
<option value="error">error</option>
</select>
</label>
</div>
<div class="max-h-72 space-y-0.5 overflow-y-auto font-mono text-xs">
<div v-for="(e, i) in filtered" :key="i" class="flex gap-2 border-b border-edge/40 py-0.5">
<span class="shrink-0 text-slate-500">{{ fmtTime(e.time) }}</span>
<span class="shrink-0" :class="levelClass[e.level] || 'text-slate-300'">
[{{ e.level }}]
</span>
<span class="shrink-0 text-slate-500">{{ e.logger }}</span>
<span :class="levelClass[e.level] || 'text-slate-300'">{{ e.message }}</span>
</div>
<div v-if="!filtered.length" class="py-6 text-center text-slate-500">No log lines</div>
</div>
</div>
</CollapsiblePanel>
</template>
+39 -4
View File
@@ -7,10 +7,11 @@ import StrategyRowEditor from './StrategyRowEditor.vue'
const props = defineProps({ const props = defineProps({
strategies: { type: Array, required: true }, strategies: { type: Array, required: true },
multiStatus: { type: Object, required: true }, multiStatus: { type: Object, required: true },
presets: { type: Array, default: () => [] },
busy: { type: Boolean, default: false }, busy: { type: Boolean, default: false },
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
}) })
const emit = defineEmits(['start-many', 'stop', 'kill']) const emit = defineEmits(['start-many', 'stop', 'kill', 'save-preset', 'delete-preset'])
const startingBalance = ref(10000) const startingBalance = ref(10000)
// Each row: { strategy_key, symbol, timeframe, weight, params } // Each row: { strategy_key, symbol, timeframe, weight, params }
@@ -64,6 +65,32 @@ function removeRow(index) {
rows.value.splice(index, 1) rows.value.splice(index, 1)
} }
// Load a saved preset as an additional row.
function loadPreset(event) {
const preset = props.presets.find((p) => p.id === Number(event.target.value))
event.target.value = ''
if (!preset) return
rows.value.push({
strategy_key: preset.strategy_key,
symbol: preset.symbol,
timeframe: preset.timeframe,
weight: 1,
params: { ...preset.params },
})
}
function saveRow(row) {
const name = window.prompt('Preset name', row.strategy_key)
if (name === null) return
emit('save-preset', {
name: name || row.strategy_key,
strategy_key: row.strategy_key,
symbol: row.symbol,
timeframe: row.timeframe,
params: row.params,
})
}
function start() { function start() {
emit('start-many', { emit('start-many', {
strategies: rows.value.map((r) => ({ strategies: rows.value.map((r) => ({
@@ -85,7 +112,7 @@ function confirmKill() {
</script> </script>
<template> <template>
<CollapsiblePanel title="Strategies (multi)" storage-key="multi" :summary="summary"> <CollapsiblePanel title="Strategies" storage-key="strategies" :summary="summary">
<div class="space-y-3"> <div class="space-y-3">
<div class="flex flex-wrap items-end gap-3"> <div class="flex flex-wrap items-end gap-3">
<label class="space-y-1"> <label class="space-y-1">
@@ -102,6 +129,15 @@ function confirmKill() {
<button class="btn-muted" :disabled="running || loading" @click="addRow"> <button class="btn-muted" :disabled="running || loading" @click="addRow">
Add strategy Add strategy
</button> </button>
<label v-if="presets.length" class="space-y-1">
<span class="label">Load preset</span>
<select class="input" :disabled="running || loading" @change="loadPreset">
<option value="">—</option>
<option v-for="p in presets" :key="p.id" :value="p.id">
{{ p.name || `${p.strategy_key} #${p.id}` }}
</option>
</select>
</label>
<button <button
class="btn-primary" class="btn-primary"
:disabled="busy || running || loading || !rows.length" :disabled="busy || running || loading || !rows.length"
@@ -127,11 +163,10 @@ function confirmKill() {
:key="i" :key="i"
v-model="rows[i]" v-model="rows[i]"
:strategies="strategies" :strategies="strategies"
:default-symbol="multiStatus.symbol"
:default-timeframe="multiStatus.timeframe"
:disabled="running" :disabled="running"
:allocation="allocations[i]" :allocation="allocations[i]"
@remove="removeRow(i)" @remove="removeRow(i)"
@save-preset="saveRow"
/> />
</div> </div>
<p v-else class="text-sm text-slate-400"> <p v-else class="text-sm text-slate-400">
-58
View File
@@ -1,58 +0,0 @@
<script setup>
import { computed } from 'vue'
import CollapsiblePanel from './CollapsiblePanel.vue'
import StrategyForm from './StrategyForm.vue'
const props = defineProps({
strategies: { type: Array, required: true },
status: { type: Object, required: true },
busy: { type: Boolean, default: false },
presets: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['start', 'stop', 'kill', 'save-preset', 'delete-preset'])
const summary = computed(() => {
const name = props.status.strategy_name || 'none'
return `${name} · ${props.status.running ? 'running' : 'stopped'}`
})
function confirmKill() {
if (window.confirm('Kill switch: flatten the position and halt trading?')) {
emit('kill')
}
}
</script>
<template>
<CollapsiblePanel title="Strategy" storage-key="strategy" :summary="summary">
<div class="space-y-3">
<StrategyForm
:strategies="strategies"
:disabled="status.running"
:busy="busy"
submit-label="Start bot"
:symbol="status.symbol"
:timeframe="status.timeframe"
:presets="presets"
:loading="loading"
@submit="emit('start', $event)"
@save-preset="emit('save-preset', $event)"
@delete-preset="emit('delete-preset', $event)"
/>
<div class="flex flex-wrap gap-2">
<button class="btn-danger" :disabled="busy || !status.running" @click="emit('stop')">
Stop bot
</button>
<button
class="btn bg-rose-700 font-bold uppercase text-white hover:bg-rose-600"
:disabled="busy || (!status.running && status.open_positions === 0)"
@click="confirmKill"
>
Kill switch
</button>
</div>
</div>
</CollapsiblePanel>
</template>
@@ -4,13 +4,11 @@ import { computed, ref, watch } from 'vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: Object, required: true }, modelValue: { type: Object, required: true },
strategies: { type: Array, required: true }, strategies: { type: Array, required: true },
defaultSymbol: { type: String, default: 'BTC/USDT' },
defaultTimeframe: { type: String, default: '5m' },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
// Share of the total balance this row will receive (0..1), for display. // Share of the total balance this row will receive (0..1), for display.
allocation: { type: Number, default: 0 }, allocation: { type: Number, default: 0 },
}) })
const emit = defineEmits(['update:modelValue', 'remove']) const emit = defineEmits(['update:modelValue', 'remove', 'save-preset'])
const row = computed(() => props.modelValue) const row = computed(() => props.modelValue)
const selected = computed(() => props.strategies.find((s) => s.key === row.value.strategy_key)) const selected = computed(() => props.strategies.find((s) => s.key === row.value.strategy_key))
@@ -115,7 +113,8 @@ watch(
</label> </label>
</div> </div>
<div class="mt-2 flex justify-end"> <div class="mt-2 flex justify-end gap-2">
<button class="btn-muted" :disabled="disabled" @click="emit('save-preset', row)">Save</button>
<button class="btn-muted" :disabled="disabled" @click="emit('remove')">Remove</button> <button class="btn-muted" :disabled="disabled" @click="emit('remove')">Remove</button>
</div> </div>
</div> </div>
+51 -3
View File
@@ -1,7 +1,11 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import MultiStrategyPanel from './components/MultiStrategyPanel.vue' import MultiStrategyPanel from './components/MultiStrategyPanel.vue'
afterEach(() => {
vi.restoreAllMocks()
})
const STRATEGIES = [ const STRATEGIES = [
{ {
key: 'sma_cross', key: 'sma_cross',
@@ -19,9 +23,20 @@ const STRATEGIES = [
const EMPTY_STATUS = { running: false, strategies: [], total_pnl: 0, equity: 0 } const EMPTY_STATUS = { running: false, strategies: [], total_pnl: 0, equity: 0 }
function mountPanel(multiStatus = EMPTY_STATUS) { const PRESETS = [
{
id: 1,
name: 'BTC fast',
strategy_key: 'ema_cross',
symbol: 'ETH/USDT',
timeframe: '15m',
params: { fast: 3 },
},
]
function mountPanel(multiStatus = EMPTY_STATUS, presets = []) {
return mount(MultiStrategyPanel, { return mount(MultiStrategyPanel, {
props: { strategies: STRATEGIES, multiStatus, busy: false, loading: false }, props: { strategies: STRATEGIES, multiStatus, presets, busy: false, loading: false },
}) })
} }
@@ -89,6 +104,39 @@ describe('MultiStrategyPanel', () => {
expect(text).toContain('running') expect(text).toContain('running')
}) })
it('loads a preset as an additional row', async () => {
const wrapper = mountPanel(EMPTY_STATUS, PRESETS)
const presetSelect = wrapper.findAll('select').find((s) => s.text().includes('BTC fast'))
expect(presetSelect).toBeTruthy()
await presetSelect.setValue('1')
// A second row now exists, seeded from the preset.
expect(wrapper.findAll('select').length).toBeGreaterThan(1)
const inputs = wrapper.findAll('input')
expect(inputs.some((i) => i.element.value === 'ETH/USDT')).toBe(true)
expect(inputs.some((i) => i.element.value === '15m')).toBe(true)
})
it('emits save-preset with the row config', async () => {
vi.spyOn(window, 'prompt').mockReturnValue('my preset')
const wrapper = mountPanel()
const save = wrapper.findAll('button').find((b) => b.text() === 'Save')
await save.trigger('click')
const payload = wrapper.emitted('save-preset')[0][0]
expect(payload.name).toBe('my preset')
expect(payload.strategy_key).toBe('sma_cross')
expect(payload.params).toEqual({ fast: 10 })
})
it('does not save when the prompt is cancelled', async () => {
vi.spyOn(window, 'prompt').mockReturnValue(null)
const wrapper = mountPanel()
const save = wrapper.findAll('button').find((b) => b.text() === 'Save')
await save.trigger('click')
expect(wrapper.emitted('save-preset')).toBeUndefined()
})
it('marks a halted strategy', () => { it('marks a halted strategy', () => {
const wrapper = mountPanel({ const wrapper = mountPanel({
running: true, running: true,
-48
View File
@@ -1,48 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import StrategyPanel from './components/StrategyPanel.vue'
function mountPanel() {
return mount(StrategyPanel, {
props: {
strategies: [
{
key: 'sma_cross',
name: 'SMA Crossover',
description: 'test',
params: [{ name: 'fast', type: 'int', default: 10, description: '' }],
},
],
status: { running: false, symbol: 'BTC/USDT', timeframe: '5m', open_positions: 0 },
busy: false,
presets: [],
},
})
}
describe('StrategyPanel collapse', () => {
it('shows the strategy form by default', () => {
const wrapper = mountPanel()
expect(wrapper.find('select').exists()).toBe(true)
})
it('hides the form when the header is clicked', async () => {
const wrapper = mountPanel()
const header = wrapper.find('button')
expect(header.attributes('aria-expanded')).toBe('true')
await header.trigger('click')
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false')
// The form container is v-show-hidden, so the select is no longer visible.
const hidden = wrapper.findAll('div').some((d) => d.element.style.display === 'none')
expect(hidden).toBe(true)
})
it('picks the first strategy so the dropdown is populated', () => {
const wrapper = mountPanel()
const select = wrapper.find('select')
expect(select.element.value).toBe('sma_cross')
expect(select.findAll('option')).toHaveLength(1)
})
})