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(() => { /> +
diff --git a/frontend/src/api.js b/frontend/src/api.js index 1fc8b7e..6930614 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -19,6 +19,9 @@ export const placeOrder = (payload) => http.post('/orders', payload).then((r) => export const runBacktest = (payload) => http.post('/backtest', payload, { timeout: 60000 }).then((r) => r.data) +export const runSweep = (payload) => + http.post('/sweep', payload, { timeout: 120000 }).then((r) => r.data) + export const getPresets = () => http.get('/presets').then((r) => r.data) export const createPreset = (payload) => http.post('/presets', payload).then((r) => r.data) export const deletePreset = (id) => http.delete(`/presets/${id}`).then((r) => r.data) diff --git a/frontend/src/components/SweepPanel.vue b/frontend/src/components/SweepPanel.vue new file mode 100644 index 0000000..bae0453 --- /dev/null +++ b/frontend/src/components/SweepPanel.vue @@ -0,0 +1,172 @@ + + +