diff --git a/AGENTS.md b/AGENTS.md
index 1a84116..7f1d9a0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,12 +93,16 @@ class MyStrategy(Strategy):
| 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` |
| Indicators | `ctx.sma(p)`, `ctx.ema(p)`, `ctx.rsi(p)`, `ctx.atr(p)`, `ctx.std(p)` |
-| Orders | `ctx.enter_long(size, stop_loss, take_profit, 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:
- `size` is a fraction of equity (1.0 = all-in). It is optional; defaults to 1.0.
- `stop_loss` / `take_profit` are absolute prices; the engine enforces them.
+- `trailing_stop_pct` (e.g. `0.02` for 2%) trails the stop with the price and
+ never loosens: for longs it ratchets up with the high, for shorts down with the
+ low. The engine and backtester both call `advance_trailing_stop`, which is the
+ only place that math lives.
- Only one position is open at a time. Intents that conflict are ignored (logged
via the engine status, not raised).
- Raise nothing for "not enough data" — just `return` early, as above.
@@ -184,6 +188,12 @@ Sharpe), the equity curve and the trade list. The dashboard's **Backtest** panel
renders all of it. Add a strategy-focused test in `tests/test_backtest.py` if the
behaviour is non-obvious.
+`POST /api/sweep` runs a cartesian grid of params through the same backtester
+(`app/backtest/sweep.py`) and returns the top N rows, ranked by
+`return − max_drawdown` (see `_score`). The dashboard's **Parameter sweep** panel
+drives it. Like backtests, sweeps share `app/engine/portfolio.py` with the live
+engine, so a winning parameter set behaves the same live.
+
## Commands
Backend (run from `backend/`):
diff --git a/README.md b/README.md
index 8cd6d76..6a3b082 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,9 @@ drop in one Python file, run it, and watch positions and P&L live.
- **Market data:** Binance public REST (no API key needed).
- **Backtesting:** replay historical candles through the same strategy interface,
with return/drawdown/win-rate/Sharpe metrics — so what you test is what runs.
+- **Parameter sweeps:** grid-search a strategy's params and rank the results.
+- **Trailing stops:** strategies can trail a stop with the price (never loosening),
+ enforced identically in backtest, paper and live.
> **Paper trading is the default.** Live mode places real orders and is opt-in.
> This is experimental software. Never trade more than you can afford to lose.
@@ -182,6 +185,7 @@ Bundled strategies: `sma_cross`, `ema_cross`, `macd_trend`,
| `POST` | `/api/bot/stop` | Stop the bot |
| `POST` | `/api/bot/kill` | Kill switch: flatten + halt |
| `POST` | `/api/backtest` | Backtest a strategy on historical candles |
+| `POST` | `/api/sweep` | Grid-sweep strategy params, ranked |
| `GET` | `/api/positions` | Positions (with unrealized P&L when open) |
| `GET` | `/api/orders` | Order history |
| `GET` | `/api/events` | Activity log |
diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py
index 21967b8..66dfec7 100644
--- a/backend/app/api/routes.py
+++ b/backend/app/api/routes.py
@@ -25,6 +25,8 @@ from app.schemas import (
StrategyConfigIn,
StrategyConfigOut,
StrategyOut,
+ SweepRequest,
+ SweepResult,
)
router = APIRouter(prefix="/api")
@@ -139,6 +141,18 @@ def delete_preset(config_id: int, runtime: GuardedRuntimeDep) -> dict[str, bool]
return {"deleted": True}
+@router.post("/sweep", response_model=SweepResult)
+async def run_sweep_endpoint(payload: SweepRequest, runtime: GuardedRuntimeDep) -> SweepResult:
+ try:
+ return await runtime.run_sweep(payload)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except httpx.HTTPError as exc:
+ raise HTTPException(status_code=502, detail=f"market data error: {exc}") from exc
+
+
def _position_out(position: Position, price: float | None) -> PositionOut:
out = PositionOut.model_validate(position)
if position.status == PositionStatus.OPEN and price is not None:
diff --git a/backend/app/backtest/__init__.py b/backend/app/backtest/__init__.py
index 61794e6..5cdc226 100644
--- a/backend/app/backtest/__init__.py
+++ b/backend/app/backtest/__init__.py
@@ -2,5 +2,14 @@ from __future__ import annotations
from app.backtest.backtester import Backtester
from app.backtest.metrics import compute_metrics, max_drawdown_pct, sharpe_ratio
+from app.backtest.sweep import SweepRow, expand_grid, run_sweep
-__all__ = ["Backtester", "compute_metrics", "max_drawdown_pct", "sharpe_ratio"]
+__all__ = [
+ "Backtester",
+ "SweepRow",
+ "compute_metrics",
+ "expand_grid",
+ "max_drawdown_pct",
+ "run_sweep",
+ "sharpe_ratio",
+]
diff --git a/backend/app/backtest/backtester.py b/backend/app/backtest/backtester.py
index 9b7b3ff..7b59b3a 100644
--- a/backend/app/backtest/backtester.py
+++ b/backend/app/backtest/backtester.py
@@ -7,6 +7,7 @@ from app.broker.paper import PaperBroker
from app.engine.portfolio import (
OpenPosition,
account_equity,
+ advance_trailing_stop,
apply_close,
apply_open,
order_qty,
@@ -91,6 +92,14 @@ class Backtester:
for i, candle in enumerate(candles):
mark = candle.close
if position is not None:
+ if position.trailing_stop_pct > 0:
+ position.stop_loss = advance_trailing_stop(
+ position.side,
+ position.stop_loss,
+ position.trailing_stop_pct,
+ candle.high,
+ candle.low,
+ )
hit = risk_exit(
position.side,
position.stop_loss,
@@ -130,6 +139,7 @@ class Backtester:
entry_fee=fill.fee,
stop_loss=intent.stop_loss,
take_profit=intent.take_profit,
+ trailing_stop_pct=intent.trailing_stop_pct,
entry_ts=candle.ts,
)
diff --git a/backend/app/backtest/sweep.py b/backend/app/backtest/sweep.py
new file mode 100644
index 0000000..2596add
--- /dev/null
+++ b/backend/app/backtest/sweep.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+import itertools
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import Any
+
+from app.backtest.backtester import Backtester
+from app.engine.strategies import get_strategy
+from app.engine.strategies.base import Strategy
+from app.schemas import Candle
+
+
+@dataclass(slots=True)
+class SweepRow:
+ params: dict[str, Any]
+ metrics: dict[str, Any]
+
+
+def expand_grid(grid: dict[str, Sequence[Any]]) -> list[dict[str, Any]]:
+ """Cartesian product of a param grid into a list of concrete param dicts."""
+ if not grid:
+ return [{}]
+ names = list(grid)
+ return [dict(zip(names, values, strict=True)) for values in itertools.product(*grid.values())]
+
+
+def _score(metrics: dict[str, Any]) -> float:
+ """Rank by return, penalising drawdown. Simple, transparent, sortable."""
+ return float(metrics.get("total_return_pct", 0.0)) - float(metrics.get("max_drawdown_pct", 0.0))
+
+
+def run_sweep(
+ strategy_key: str,
+ base_params: dict[str, Any],
+ grid: dict[str, Sequence[Any]],
+ candles: Sequence[Candle],
+ symbol: str,
+ timeframe: str,
+ *,
+ fee_bps: float = 10.0,
+ slippage_bps: float = 5.0,
+ starting_balance: float = 10_000.0,
+ top_n: int = 20,
+) -> list[SweepRow]:
+ """Backtest every combination in ``grid`` (merged over ``base_params``)."""
+ strategy_cls: type[Strategy] = get_strategy(strategy_key)
+ backtester = Backtester(
+ fee_bps=fee_bps, slippage_bps=slippage_bps, starting_balance=starting_balance
+ )
+
+ rows: list[SweepRow] = []
+ for params in expand_grid(grid):
+ merged = {**base_params, **params}
+ strategy = strategy_cls(merged)
+ result = backtester.run(candles, strategy, symbol, timeframe)
+ rows.append(SweepRow(params=merged, metrics=result.metrics.model_dump()))
+
+ rows.sort(key=lambda row: _score(row.metrics), reverse=True)
+ return rows[:top_n]
diff --git a/backend/app/db.py b/backend/app/db.py
index 16b51d5..87de536 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -18,6 +18,7 @@ _settings = get_settings()
# added here as (table, column, DDL type) tuples.
_ADDITIVE_COLUMNS: list[tuple[str, str, str]] = [
("strategy_configs", "name", "VARCHAR(80) DEFAULT ''"),
+ ("positions", "trailing_stop_pct", "FLOAT DEFAULT 0"),
]
diff --git a/backend/app/engine/engine.py b/backend/app/engine/engine.py
index 6341a4e..2fdd85d 100644
--- a/backend/app/engine/engine.py
+++ b/backend/app/engine/engine.py
@@ -10,6 +10,7 @@ from app.broker.base import Broker
from app.config import Settings
from app.engine.portfolio import (
account_equity,
+ advance_trailing_stop,
apply_close,
apply_open,
order_qty,
@@ -202,6 +203,7 @@ class TradingEngine:
size=intent.size if intent.size is not None else 1.0,
stop_loss=intent.stop_loss,
take_profit=intent.take_profit,
+ trailing_stop_pct=intent.trailing_stop_pct,
reason=intent.reason or intent.action,
)
@@ -213,6 +215,7 @@ class TradingEngine:
stop_loss: float | None,
take_profit: float | None,
reason: str,
+ trailing_stop_pct: float = 0.0,
qty_override: float | None = None,
) -> None:
price = self.last_price
@@ -250,6 +253,7 @@ class TradingEngine:
entry_fee=fill.fee,
stop_loss=stop_loss,
take_profit=take_profit,
+ trailing_stop_pct=trailing_stop_pct,
)
self.store.record_order(
position_id=self.position.id,
@@ -317,6 +321,21 @@ class TradingEngine:
position = self.position
if position is None:
return
+
+ # Ratchet a trailing stop before testing levels, so a bar that both
+ # advances the trail and reverses is handled consistently.
+ if position.trailing_stop_pct and position.trailing_stop_pct > 0:
+ advanced = advance_trailing_stop(
+ position.side,
+ position.stop_loss,
+ position.trailing_stop_pct,
+ candle.high,
+ candle.low,
+ )
+ if advanced is not None and advanced != position.stop_loss:
+ position.stop_loss = advanced
+ self.store.update_stop_loss(position.id, advanced)
+
hit = risk_exit(
position.side,
position.stop_loss,
diff --git a/backend/app/engine/portfolio.py b/backend/app/engine/portfolio.py
index b4b4b52..28ccb79 100644
--- a/backend/app/engine/portfolio.py
+++ b/backend/app/engine/portfolio.py
@@ -18,9 +18,33 @@ class OpenPosition:
entry_fee: float = 0.0
stop_loss: float | None = None
take_profit: float | None = None
+ # Distance to trail the stop below the high (long) / above the low (short),
+ # as a fraction of price (0.02 = 2%). 0 disables trailing.
+ trailing_stop_pct: float = 0.0
entry_ts: int = 0
+def advance_trailing_stop(
+ side: PositionSide,
+ stop_loss: float | None,
+ trailing_stop_pct: float,
+ high: float,
+ low: float,
+) -> float | None:
+ """Ratchet a trailing stop toward the price; never loosens.
+
+ For a long the stop trails ``pct`` below the running high; for a short it
+ trails ``pct`` above the running low. Returns the (possibly unchanged) stop.
+ """
+ if trailing_stop_pct <= 0:
+ return stop_loss
+ if side == PositionSide.LONG:
+ candidate = high * (1 - trailing_stop_pct)
+ return candidate if stop_loss is None else max(stop_loss, candidate)
+ candidate = low * (1 + trailing_stop_pct)
+ return candidate if stop_loss is None else min(stop_loss, candidate)
+
+
def apply_open(cash: float, side: PositionSide, qty: float, price: float, fee: float) -> float:
"""Cash after opening a position (buying long / selling short)."""
notional = qty * price
diff --git a/backend/app/engine/strategies/base.py b/backend/app/engine/strategies/base.py
index 5a1de13..3c69264 100644
--- a/backend/app/engine/strategies/base.py
+++ b/backend/app/engine/strategies/base.py
@@ -26,6 +26,7 @@ class Intent:
size: float | None = None
stop_loss: float | None = None
take_profit: float | None = None
+ trailing_stop_pct: float = 0.0
reason: str = ""
@@ -121,18 +122,24 @@ class StrategyContext:
size: float | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
+ trailing_stop_pct: float = 0.0,
reason: str = "",
) -> None:
- self._intents.append(Intent("enter_long", size, stop_loss, take_profit, reason))
+ self._intents.append(
+ Intent("enter_long", size, stop_loss, take_profit, trailing_stop_pct, reason)
+ )
def enter_short(
self,
size: float | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
+ trailing_stop_pct: float = 0.0,
reason: str = "",
) -> None:
- self._intents.append(Intent("enter_short", size, stop_loss, take_profit, reason))
+ self._intents.append(
+ Intent("enter_short", size, stop_loss, take_profit, trailing_stop_pct, reason)
+ )
def close(self, reason: str = "") -> None:
self._intents.append(Intent("close", reason=reason))
diff --git a/backend/app/engine/strategies/momentum.py b/backend/app/engine/strategies/momentum.py
index 7c3e7b2..2b08b51 100644
--- a/backend/app/engine/strategies/momentum.py
+++ b/backend/app/engine/strategies/momentum.py
@@ -21,19 +21,22 @@ class Momentum(Strategy):
"entry_threshold": 0.01,
"atr_period": 14,
"atr_stop_mult": 2.0,
+ "trailing_stop_pct": 0.0,
"size": 1.0,
}
param_help: ClassVar[dict] = {
"period": "ROC lookback in bars",
"entry_threshold": "Minimum ROC to enter (0.01 = 1%)",
- "atr_period": "ATR lookback for the stop",
+ "atr_period": "ATR lookback for the initial stop",
"atr_stop_mult": "Stop distance in ATRs (0 disables)",
+ "trailing_stop_pct": "Trailing stop distance as a fraction of price (0 disables)",
"size": "Fraction of equity per trade",
}
param_bounds: ClassVar[dict] = {
"period": (2, 200),
"entry_threshold": (0.0, 1.0),
"atr_stop_mult": (0.0, 10.0),
+ "trailing_stop_pct": (0.0, 0.5),
"size": (0.01, 1.0),
}
@@ -52,6 +55,7 @@ class Momentum(Strategy):
ctx.enter_long(
size=float(self.params["size"]),
stop_loss=stop,
+ trailing_stop_pct=float(self.params["trailing_stop_pct"]),
reason=f"ROC {value:.2%} above threshold",
)
elif ctx.side == PositionSide.LONG and value < 0:
diff --git a/backend/app/models.py b/backend/app/models.py
index 25da094..5eabb08 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -67,6 +67,7 @@ class Position(Base):
)
stop_loss: Mapped[float | None] = mapped_column(Float, nullable=True)
take_profit: Mapped[float | None] = mapped_column(Float, nullable=True)
+ trailing_stop_pct: Mapped[float] = mapped_column(Float, default=0.0)
entry_fee: Mapped[float] = mapped_column(Float, default=0.0)
exit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
exit_fee: Mapped[float] = mapped_column(Float, default=0.0)
diff --git a/backend/app/runtime.py b/backend/app/runtime.py
index 787b057..46c9215 100644
--- a/backend/app/runtime.py
+++ b/backend/app/runtime.py
@@ -6,7 +6,7 @@ from collections.abc import Callable
from contextlib import suppress
from typing import Any
-from app.backtest import Backtester
+from app.backtest import Backtester, run_sweep
from app.broker import build_broker
from app.broker.base import Broker
from app.config import Settings
@@ -15,7 +15,14 @@ from app.engine.strategies import get_strategy
from app.market import build_market_data
from app.market.base import MarketData
from app.notify import Notifier, build_notifier
-from app.schemas import BacktestRequest, BacktestResult, StatusOut
+from app.schemas import (
+ BacktestRequest,
+ BacktestResult,
+ StatusOut,
+ SweepRequest,
+ SweepResult,
+ SweepRowOut,
+)
from app.store import Store
logger = logging.getLogger(__name__)
@@ -139,6 +146,41 @@ class Runtime:
)
return backtester.run(candles, strategy, symbol, timeframe)
+ async def run_sweep(self, request: SweepRequest) -> SweepResult:
+ symbol = request.symbol or self.settings.symbol
+ timeframe = request.timeframe or self.settings.timeframe
+ strategy_cls = get_strategy(request.strategy_key)
+
+ feed = self._feed_factory(self.settings)
+ try:
+ candles = await feed.fetch_candles(symbol, timeframe, request.limit)
+ finally:
+ await feed.close()
+
+ rows = run_sweep(
+ request.strategy_key,
+ request.base_params,
+ request.grid,
+ candles,
+ symbol,
+ timeframe,
+ fee_bps=self.settings.fee_bps,
+ slippage_bps=self.settings.slippage_bps,
+ starting_balance=request.starting_balance or self.settings.starting_balance,
+ top_n=request.top_n,
+ )
+ combinations = 1
+ for values in request.grid.values():
+ combinations *= max(1, len(values))
+ return SweepResult(
+ strategy_key=request.strategy_key,
+ strategy_name=strategy_cls.name or request.strategy_key,
+ symbol=symbol,
+ timeframe=timeframe,
+ combinations=combinations,
+ rows=[SweepRowOut(params=row.params, metrics=row.metrics) for row in rows],
+ )
+
async def shutdown(self) -> None:
if self.engine is not None:
await self.engine.shutdown()
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index d697918..d0d5c89 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -206,3 +206,30 @@ class BacktestResult(BaseModel):
equity_curve: list[BacktestPointOut]
trades: list[BacktestTradeOut]
error: str | None = None
+
+
+class SweepRequest(BaseModel):
+ strategy_key: str
+ base_params: dict[str, Any] = Field(default_factory=dict)
+ # param name -> list of candidate values (cartesian product)
+ grid: dict[str, list[Any]] = Field(default_factory=dict)
+ symbol: str | None = None
+ timeframe: str | None = None
+ limit: int = Field(default=500, ge=20, le=1000)
+ starting_balance: float | None = None
+ top_n: int = Field(default=20, ge=1, le=100)
+
+
+class SweepRowOut(BaseModel):
+ params: dict[str, Any]
+ metrics: BacktestMetricsOut
+
+
+class SweepResult(BaseModel):
+ strategy_key: str
+ strategy_name: str
+ symbol: str
+ timeframe: str
+ combinations: int
+ rows: list[SweepRowOut]
+ error: str | None = None
diff --git a/backend/app/store.py b/backend/app/store.py
index 9141f14..f210627 100644
--- a/backend/app/store.py
+++ b/backend/app/store.py
@@ -33,6 +33,7 @@ class Store:
entry_fee: float,
stop_loss: float | None = None,
take_profit: float | None = None,
+ trailing_stop_pct: float = 0.0,
) -> Position:
with self._session_factory() as session:
position = Position(
@@ -44,6 +45,7 @@ class Store:
entry_fee=entry_fee,
stop_loss=stop_loss,
take_profit=take_profit,
+ trailing_stop_pct=trailing_stop_pct,
status=PositionStatus.OPEN,
)
session.add(position)
@@ -88,6 +90,14 @@ class Store:
session.commit()
return len(positions)
+ def update_stop_loss(self, position_id: int, stop_loss: float) -> None:
+ with self._session_factory() as session:
+ position = session.get(Position, position_id)
+ if position is None:
+ return
+ position.stop_loss = stop_loss
+ session.commit()
+
def open_position_for(self, strategy_key: str) -> Position | None:
with self._session_factory() as session:
return session.scalars(
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index db93f06..75ff94a 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -62,6 +62,32 @@ def test_backtest_endpoint(runtime: Runtime, market: FakeMarketData) -> None:
assert data["metrics"]["num_trades"] >= 1
+def test_sweep_endpoint(runtime: Runtime, market: FakeMarketData) -> None:
+ market.candles = FakeMarketData.sine_wave(200).candles
+ app = create_app(runtime.settings, runtime)
+ with TestClient(app) as client:
+ response = client.post(
+ "/api/sweep",
+ json={
+ "strategy_key": "sma_cross",
+ "grid": {"fast": [3, 5], "slow": [10, 20]},
+ "limit": 200,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["combinations"] == 4
+ assert 1 <= len(data["rows"]) <= 4
+ assert "total_return_pct" in data["rows"][0]["metrics"]
+
+
+def test_sweep_unknown_strategy_404(runtime: Runtime) -> None:
+ app = create_app(runtime.settings, runtime)
+ with TestClient(app) as client:
+ response = client.post("/api/sweep", json={"strategy_key": "nope"})
+ assert response.status_code == 404
+
+
def test_backtest_unknown_strategy_404(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
diff --git a/backend/tests/test_backtest.py b/backend/tests/test_backtest.py
index b1a069f..bd28a33 100644
--- a/backend/tests/test_backtest.py
+++ b/backend/tests/test_backtest.py
@@ -82,6 +82,36 @@ def test_stop_loss_is_honoured() -> None:
assert result.metrics.final_equity < 10_000
+class TrailStrategy(Strategy):
+ key: ClassVar[str] = "test_trail"
+ name: ClassVar[str] = "Test Trail"
+ default_params: ClassVar[dict] = {}
+
+ def on_candle(self, ctx: StrategyContext) -> None:
+ if ctx.is_flat and not getattr(self, "_entered", False):
+ self._entered = True
+ ctx.enter_long(size=1.0, trailing_stop_pct=0.10)
+
+
+def test_trailing_stop_locks_in_profit() -> None:
+ # Entry at 100, rise to 150, then a reversal. A 10% trail should exit near
+ # 135 (150 * 0.9) instead of round-tripping to the entry.
+ backtester = Backtester(fee_bps=0, slippage_bps=0, starting_balance=10_000)
+ candles = candles_from_closes([100, 120, 150, 130, 125, 120])
+ candles[2] = Candle(ts=2, open=120, high=150, low=120, close=150, volume=1.0)
+ candles[3] = Candle(ts=3, open=150, high=150, low=130, close=130, volume=1.0)
+ candles[4] = Candle(ts=4, open=130, high=132, low=125, close=125, volume=1.0)
+ candles[5] = Candle(ts=5, open=125, high=128, low=120, close=120, volume=1.0)
+ result = backtester.run(candles, TrailStrategy(), "BTC/USDT", "1h")
+
+ assert result.metrics.num_trades == 1
+ trade = result.trades[0]
+ assert "stop loss" in trade.reason
+ # 99 units, exit at the trailing stop (135), well above entry 100.
+ assert trade.exit_price == pytest.approx(135.0)
+ assert result.metrics.final_equity > 10_000
+
+
def test_strategy_error_is_surfaced() -> None:
class Boom(Strategy):
key = "test_boom"
diff --git a/backend/tests/test_portfolio.py b/backend/tests/test_portfolio.py
index ca4a05f..a4aeb98 100644
--- a/backend/tests/test_portfolio.py
+++ b/backend/tests/test_portfolio.py
@@ -4,6 +4,7 @@ import pytest
from app.engine.portfolio import (
account_equity,
+ advance_trailing_stop,
apply_close,
apply_open,
order_qty,
@@ -57,3 +58,30 @@ def test_risk_exit_short() -> None:
assert risk_exit(PositionSide.SHORT, 110, 90, high=111, low=100) == ("stop loss", 110)
assert risk_exit(PositionSide.SHORT, 110, 90, high=100, low=89) == ("take profit", 90)
assert risk_exit(PositionSide.SHORT, None, None, high=100, low=95) is None
+
+
+def test_trailing_stop_long_ratchets_up_only() -> None:
+ # First update seeds the stop 2% below the high.
+ stop = advance_trailing_stop(PositionSide.LONG, None, 0.02, high=100, low=95)
+ assert stop == pytest.approx(98.0)
+ # A higher high raises it.
+ stop = advance_trailing_stop(PositionSide.LONG, stop, 0.02, high=110, low=100)
+ assert stop == pytest.approx(107.8)
+ # A pullback never loosens it.
+ stop = advance_trailing_stop(PositionSide.LONG, stop, 0.02, high=105, low=100)
+ assert stop == pytest.approx(107.8)
+
+
+def test_trailing_stop_short_ratchets_down_only() -> None:
+ stop = advance_trailing_stop(PositionSide.SHORT, None, 0.02, high=100, low=95)
+ assert stop == pytest.approx(96.9)
+ stop = advance_trailing_stop(PositionSide.SHORT, stop, 0.02, high=95, low=90)
+ assert stop == pytest.approx(91.8)
+ # A bounce never loosens the short's stop.
+ stop = advance_trailing_stop(PositionSide.SHORT, stop, 0.02, high=95, low=92)
+ assert stop == pytest.approx(91.8)
+
+
+def test_trailing_stop_disabled_is_noop() -> None:
+ assert advance_trailing_stop(PositionSide.LONG, 90.0, 0.0, high=100, low=80) == 90.0
+ assert advance_trailing_stop(PositionSide.LONG, None, 0.0, high=100, low=80) is None
diff --git a/backend/tests/test_sweep.py b/backend/tests/test_sweep.py
new file mode 100644
index 0000000..d6d397b
--- /dev/null
+++ b/backend/tests/test_sweep.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+from app.backtest.sweep import expand_grid, run_sweep
+from app.schemas import Candle
+
+
+def candles_from_closes(closes: list[float]) -> list[Candle]:
+ return [Candle(ts=i, open=c, high=c, low=c, close=c, volume=1.0) for i, c in enumerate(closes)]
+
+
+def test_expand_grid() -> None:
+ grid = {"fast": [2, 3], "slow": [10, 20]}
+ combos = expand_grid(grid)
+ assert len(combos) == 4
+ assert {"fast": 2, "slow": 10} in combos
+ assert {"fast": 3, "slow": 20} in combos
+
+
+def test_expand_grid_empty() -> None:
+ assert expand_grid({}) == [{}]
+
+
+def test_expand_grid_single_axis() -> None:
+ assert expand_grid({"x": [1, 2, 3]}) == [{"x": 1}, {"x": 2}, {"x": 3}]
+
+
+def test_run_sweep_ranks_and_merges_base_params() -> None:
+ # A trending series so SMA crossover trades.
+ prices = [100 + i for i in range(60)]
+ rows = run_sweep(
+ "sma_cross",
+ base_params={"size": 1.0},
+ grid={"fast": [2, 5], "slow": [10, 20]},
+ candles=candles_from_closes(prices),
+ symbol="BTC/USDT",
+ timeframe="1h",
+ top_n=10,
+ )
+ assert len(rows) == 4
+ # Every row carries the merged base params.
+ assert all(row.params["size"] == 1.0 for row in rows)
+ # Sorted by composite score descending.
+ scores = [row.metrics["total_return_pct"] - row.metrics["max_drawdown_pct"] for row in rows]
+ assert scores == sorted(scores, reverse=True)
+
+
+def test_run_sweep_top_n_truncates() -> None:
+ prices = [100 + i for i in range(60)]
+ rows = run_sweep(
+ "sma_cross",
+ base_params={},
+ grid={"fast": [2, 3, 4], "slow": [10, 15, 20]},
+ candles=candles_from_closes(prices),
+ symbol="BTC/USDT",
+ timeframe="1h",
+ top_n=3,
+ )
+ assert len(rows) == 3
+
+
+def test_run_sweep_unknown_strategy() -> None:
+ import pytest
+
+ with pytest.raises(KeyError):
+ run_sweep("nope", {}, {}, candles_from_closes([1, 2, 3]), "BTC/USDT", "1h")
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 1e5d824..ea31347 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -8,6 +8,7 @@ import OrdersTable from './components/OrdersTable.vue'
import PositionsTable from './components/PositionsTable.vue'
import StatusCards from './components/StatusCards.vue'
import StrategyPanel from './components/StrategyPanel.vue'
+import SweepPanel from './components/SweepPanel.vue'
const status = ref({
running: false,
@@ -196,6 +197,7 @@ onUnmounted(() => {
/>
+ {{ error }} +
+ +| # | +Params | +Return | +Max DD | +Trades | +Win rate | +Sharpe | +
|---|---|---|---|---|---|---|
| {{ i + 1 }} | ++ {{ k }}={{ v }} + | ++ {{ signed(row.metrics.total_return_pct) }}% + | ++ -{{ fmtMoney(row.metrics.max_drawdown_pct) }}% + | +{{ row.metrics.num_trades }} | +{{ fmtMoney(row.metrics.win_rate_pct) }}% | ++ {{ row.metrics.sharpe === null ? '—' : fmtMoney(row.metrics.sharpe) }} + | +