From 3c634c39d3707a5d7d039d03e933cee73156b95e Mon Sep 17 00:00:00 2001 From: RobotTraders Date: Sun, 27 Sep 2026 21:17:38 +0000 Subject: [PATCH] Release v0.2.0 --- .gitignore | 1 + pyproject.toml | 2 +- .../simulator/base_simulation_engine.py | 364 +++++++++--------- .../backtester/simulator/order_book.py | 43 ++- .../bootstrap/settings/load_secrets.py | 6 +- src/robottraderslab/cli/app.py | 16 + .../scaffold/templates/registry.example.toml | 6 + .../scaffold/templates/secrets.example.toml | 3 + src/robottraderslab/scaffold/workspace.py | 36 ++ .../test_execution_stop_loss_order.py | 32 +- .../test_execution_take_profit_order.py | 14 +- .../test_protection_after_entry_fill.py | 62 +++ tests/bootstrap/settings/test_load_secrets.py | 13 + tests/cli/test_app.py | 27 ++ tests/scaffold/test_workspace.py | 72 ++++ uv.lock | 38 +- 16 files changed, 491 insertions(+), 244 deletions(-) create mode 100644 src/robottraderslab/scaffold/templates/registry.example.toml create mode 100644 src/robottraderslab/scaffold/templates/secrets.example.toml create mode 100644 src/robottraderslab/scaffold/workspace.py create mode 100644 tests/backtester/simulator/simulation_engine/test_protection_after_entry_fill.py create mode 100644 tests/scaffold/test_workspace.py diff --git a/.gitignore b/.gitignore index 1eba7e8..906ca25 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,7 @@ reports/ # Don't commit your secrets :) secret* !load_secrets.py +!src/robottraderslab/scaffold/templates/secrets.example.toml !tests/**/secret* # macOS diff --git a/pyproject.toml b/pyproject.toml index 9c491f1..d566220 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "robottraderslab" -version = "0.1.0" +version = "0.2.0" description = "Backtesting and live trading engine for algorithmic trading strategies" requires-python = ">=3.13,<3.14" license = "Apache-2.0" diff --git a/src/robottraderslab/backtester/simulator/base_simulation_engine.py b/src/robottraderslab/backtester/simulator/base_simulation_engine.py index 57c22b9..7e033a5 100644 --- a/src/robottraderslab/backtester/simulator/base_simulation_engine.py +++ b/src/robottraderslab/backtester/simulator/base_simulation_engine.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import cast -from robottraderslab._core import FillDescriber, OHLCVsBySymbol, Symbol +from robottraderslab._core import FillDescriber, OHLCVRow, OHLCVsBySymbol, Symbol from robottraderslab.exceptions import ExchangeRecoverableError, StrategyCriticalError from robottraderslab.exchanges import ( Balance, @@ -87,62 +87,13 @@ def converter(self) -> CurrencyConverter: @abstractmethod def equity_currency(self) -> Currency: ... - @property - @abstractmethod - def open_positions(self) -> dict[Symbol, PositionSnapshot]: ... - @property def open_orders(self) -> list[Order]: return list(self._order_book) - def get_balances(self) -> dict[Currency, Balance]: - return self._balances.copy() - - def get_equity(self, currency: Currency) -> float: - if self._last_tick_snapshot is not None: - return self._last_tick_snapshot.get_equity(currency) - return self._initial_equity(currency) - - def get_daily_equity_snapshots(self) -> list[EquitySnapshot]: - return self._daily_equity_recorder.snapshots - - def get_trade_equity_snapshots(self) -> list[EquitySnapshot]: - return self._trade_equity_recorder.snapshots - - def get_last_fills(self) -> dict[str, VenueFill]: - return self._last_fills - - def record_prices(self, prices: Prices) -> None: - """Seed the last-seen prices used for valuation and currency conversion.""" - self._last_seen_prices.update(prices) - - def record_opening_equity(self, moment: datetime) -> None: - """Record the equity the run starts with at its start, a moment no - candle of the run closes on. - """ - self._update_equity_snapshot(moment) - self._daily_equity_recorder.record_bound(self._last_tick_snapshot) # type: ignore[arg-type] - - def record_closing_equity(self) -> None: - """Keep the last candle's equity in the daily curve, whatever moment - that candle closed on, once the orders placed at its close have filled. - """ - self._record_trade_equity() - self._daily_equity_recorder.record_bound(self._last_tick_snapshot) # type: ignore[arg-type] - - def enable_execution_recording(self) -> None: - """Hook called once, before the first candle, for a run that needs - executions read back. Override to act on it; building an Execution - on every fill is wasted work for a run that never asks. - """ - pass - - def describe_fills_with(self, describer: FillDescriber) -> None: - """Hook called once, before the first candle, with the strategy's - describer. Override to consult it for every fill whose order states no - reason. - """ - pass + @property + @abstractmethod + def open_positions(self) -> dict[Symbol, PositionSnapshot]: ... def add_order(self, order: LimitOrder | MarketOrder | TriggerOrder) -> None: """A resting order meets only the candles that follow the one it was @@ -192,6 +143,55 @@ def cancel_orders_for_symbol(self, symbol: Symbol) -> None: if _is_limit_entry(cancelled): self._on_release_limit_entry(cast(LimitOrder, cancelled)) + def describe_fills_with(self, describer: FillDescriber) -> None: + """Hook called once, before the first candle, with the strategy's + describer. Override to consult it for every fill whose order states no + reason. + """ + pass + + def enable_execution_recording(self) -> None: + """Hook called once, before the first candle, for a run that needs + executions read back. Override to act on it; building an Execution + on every fill is wasted work for a run that never asks. + """ + pass + + def get_balances(self) -> dict[Currency, Balance]: + return self._balances.copy() + + def get_daily_equity_snapshots(self) -> list[EquitySnapshot]: + return self._daily_equity_recorder.snapshots + + def get_equity(self, currency: Currency) -> float: + if self._last_tick_snapshot is not None: + return self._last_tick_snapshot.get_equity(currency) + return self._initial_equity(currency) + + def get_last_fills(self) -> dict[str, VenueFill]: + return self._last_fills + + def get_trade_equity_snapshots(self) -> list[EquitySnapshot]: + return self._trade_equity_recorder.snapshots + + def record_closing_equity(self) -> None: + """Keep the last candle's equity in the daily curve, whatever moment + that candle closed on, once the orders placed at its close have filled. + """ + self._record_trade_equity() + self._daily_equity_recorder.record_bound(self._last_tick_snapshot) # type: ignore[arg-type] + + def record_opening_equity(self, moment: datetime) -> None: + """Record the equity the run starts with at its start, a moment no + candle of the run closes on. + """ + self._update_equity_snapshot(moment) + self._daily_equity_recorder.record_bound(self._last_tick_snapshot) # type: ignore[arg-type] + + def record_prices(self, prices: Prices) -> None: + """Seed the last-seen prices used for valuation and currency conversion.""" + self._last_seen_prices.update(prices) + def simulate_on_current_ohlcvs( self, timestamp_arg: datetime, ohlcvs: OHLCVsBySymbol ) -> None: @@ -207,7 +207,6 @@ def simulate_on_current_ohlcvs( self._last_fills = {} self._record_current_prices(ohlcvs) - taker_fee_rate = self._fee_rates.taker logger.debug("Pending orders:") for symbol in ohlcvs: ohlcv = ohlcvs[symbol] @@ -227,25 +226,7 @@ def simulate_on_current_ohlcvs( ) ): continue - low, high = ohlcv.low, ohlcv.high - fee_rate = self._fee_rates.maker - if low <= limit_order.limit_price <= high: - if limit_order.reduce_only: - self._exit_position( - timestamp, - limit_order, - limit_order.limit_price, - fee_rate, - ) - else: - self._on_release_limit_entry(limit_order) - self._enter_position( - timestamp, - limit_order, - limit_order.limit_price, - fee_rate, - taker_fee_rate, - ) + self._match_limit_order(timestamp, limit_order, ohlcv) elif order.kind == _MARKET and not math.isnan(ohlcv.close): price = ohlcv.close @@ -310,6 +291,12 @@ def simulate_on_current_ohlcvs( trigger_order.trigger_price, ) self._order_book.remove_order(trigger_order) + if trigger_order.order.kind == _LIMIT: + self._match_limit_order( + timestamp, + cast(LimitOrder, trigger_order.order), + ohlcv, + ) except ExchangeRecoverableError as e: logger.warning(e) @@ -318,56 +305,26 @@ def simulate_on_current_ohlcvs( self._update_equity_snapshot(timestamp_arg) self._daily_equity_recorder.record_snapshot(self._last_tick_snapshot) # type: ignore[arg-type] - def _settled_at_placement( - self, timestamp: datetime, limit_order: LimitOrder, close: float - ) -> bool: - """An `IOC` order fills or is cancelled at the close it is settled - against, and never rests. A `POST_ONLY` order rests like a GTC order, - except one placed with no close to judge it by, which is cancelled at - WARNING when the first close it meets shows it would have taken - liquidity. - - Returns: - Whether the order is done with, filled or cancelled. + def _after_tick(self, timestamp: datetime, ohlcvs: OHLCVsBySymbol) -> None: + """Hook called after the order matching loop. Override for post-tick + logic (e.g. liquidations). """ - limit_order.settled = True - if limit_order.time_in_force is TimeInForce.POST_ONLY: - if _takes_liquidity(limit_order, close): - logger.warning( - "%s would take liquidity at %s and is cancelled", limit_order, close - ) - self._cancel_at_placement(limit_order) - return True - return False - if _takes_liquidity(limit_order, close): - self._order_book.remove_order(limit_order) - self._fill_taking_liquidity(timestamp, limit_order, close) - return True - logger.info("%s is not filled at %s and is cancelled", limit_order, close) - self._cancel_at_placement(limit_order) - return True - - def _rest_order(self, order: LimitOrder | MarketOrder | TriggerOrder) -> None: - if _is_limit_entry(order): - self._on_add_limit_entry(cast(LimitOrder, order)) - self._order_book.add_order(order) + pass def _cancel_at_placement(self, limit_order: LimitOrder) -> None: self._order_book.remove_order(limit_order) if _is_limit_entry(limit_order): self._on_release_limit_entry(limit_order) - def _fill_taking_liquidity( - self, timestamp: datetime, limit_order: LimitOrder, price: float - ) -> None: - taker_fee_rate = self._fee_rates.taker - if limit_order.reduce_only: - self._exit_position(timestamp, limit_order, price, taker_fee_rate) - return - self._on_release_limit_entry(limit_order) - self._enter_position( - timestamp, limit_order, price, taker_fee_rate, taker_fee_rate - ) + @abstractmethod + def _enter_position( + self, + timestamp: datetime, + order: LimitOrder | MarketOrder, + price: float, + fee_rate: float, + taker_fee_rate: float, + ) -> None: ... def _execute_market_order( self, @@ -383,10 +340,35 @@ def _execute_market_order( else: self._exit_position(timestamp, market_order, price, taker_fee_rate) + @abstractmethod + def _exit_position( + self, + timestamp: datetime, + order: LimitOrder | MarketOrder | StopLossOrder | TakeProfitOrder, + price: float, + fee_rate: float, + ) -> None: ... + + def _fill_taking_liquidity( + self, timestamp: datetime, limit_order: LimitOrder, price: float + ) -> None: + taker_fee_rate = self._fee_rates.taker + if limit_order.reduce_only: + self._exit_position(timestamp, limit_order, price, taker_fee_rate) + return + self._on_release_limit_entry(limit_order) + self._enter_position( + timestamp, limit_order, price, taker_fee_rate, taker_fee_rate + ) + def _get_position_side(self, _symbol: Symbol) -> PositionSide: """Defaults to LONG here, since spot trading has no short side.""" return PositionSide.LONG + def _get_positions_for_snapshot(self) -> dict[Symbol, SimulatedPosition]: + """Return positions dict for equity snapshot. Override in subclasses.""" + return {} + def _initial_equity(self, currency: Currency) -> float: """Assumes a single initial balance per currency, since equity has no snapshot yet to read a truer figure from. @@ -396,35 +378,36 @@ def _initial_equity(self, currency: Currency) -> float: return 0.0 return balance.total - def _record_current_prices(self, ohlcvs_by_symbol: OHLCVsBySymbol) -> None: - for symbol, ohlcv in ohlcvs_by_symbol.items(): - if not math.isnan(ohlcv.close): - self._last_seen_prices[symbol] = ohlcv.close - - def _record_trade_equity(self) -> None: - """Value the account at the last simulated moment once every trade of - that moment has settled, the candle's own fills and the orders placed - at its close alike, so the moment holds one trade snapshot. - """ - if not self._trades_occurred_this_tick: + def _match_limit_order( + self, timestamp: datetime, limit_order: LimitOrder, ohlcv: OHLCVRow + ) -> None: + limit_price = limit_order.limit_price + if not ohlcv.low <= limit_price <= ohlcv.high: return - self._update_equity_snapshot(self._last_tick_snapshot.timestamp) # type: ignore[union-attr] - self._trade_equity_recorder.record_snapshot(self._last_tick_snapshot) # type: ignore[arg-type] - self._trades_occurred_this_tick = False - - def _update_equity_snapshot(self, timestamp: datetime) -> None: - self._last_tick_snapshot = EquitySnapshot( - timestamp=timestamp, - market_type=self._market_type, - balances=self.get_balances(), - positions=self._get_positions_for_snapshot(), - prices=self._last_seen_prices, - converter=self.converter, + maker_fee_rate = self._fee_rates.maker + if limit_order.reduce_only: + self._exit_position(timestamp, limit_order, limit_price, maker_fee_rate) + return + self._on_release_limit_entry(limit_order) + self._enter_position( + timestamp, + limit_order, + limit_price, + maker_fee_rate, + self._fee_rates.taker, ) - def _get_positions_for_snapshot(self) -> dict[Symbol, SimulatedPosition]: - """Return positions dict for equity snapshot. Override in subclasses.""" - return {} + def _on_add_limit_entry(self, limit_order: LimitOrder) -> None: + """Hook called when a limit entry is added, whatever its side. Override + to lock the funds it would fill against. + """ + pass + + def _on_release_limit_entry(self, limit_order: LimitOrder) -> None: + """Hook called when a limit entry leaves the book, matched or cancelled. + Override to release what `_on_add_limit_entry` locked. + """ + pass def _open_tp_sl(self, order: LimitOrder | MarketOrder) -> str | None: """Open the resting guards `order` requests, if any. @@ -475,6 +458,66 @@ def _open_tp_sl(self, order: LimitOrder | MarketOrder) -> str | None: return group_id + def _record_current_prices(self, ohlcvs_by_symbol: OHLCVsBySymbol) -> None: + for symbol, ohlcv in ohlcvs_by_symbol.items(): + if not math.isnan(ohlcv.close): + self._last_seen_prices[symbol] = ohlcv.close + + def _record_trade_equity(self) -> None: + """Value the account at the last simulated moment once every trade of + that moment has settled, the candle's own fills and the orders placed + at its close alike, so the moment holds one trade snapshot. + """ + if not self._trades_occurred_this_tick: + return + self._update_equity_snapshot(self._last_tick_snapshot.timestamp) # type: ignore[union-attr] + self._trade_equity_recorder.record_snapshot(self._last_tick_snapshot) # type: ignore[arg-type] + self._trades_occurred_this_tick = False + + def _rest_order(self, order: LimitOrder | MarketOrder | TriggerOrder) -> None: + if _is_limit_entry(order): + self._on_add_limit_entry(cast(LimitOrder, order)) + self._order_book.add_order(order) + + def _settled_at_placement( + self, timestamp: datetime, limit_order: LimitOrder, close: float + ) -> bool: + """An `IOC` order fills or is cancelled at the close it is settled + against, and never rests. A `POST_ONLY` order rests like a GTC order, + except one placed with no close to judge it by, which is cancelled at + WARNING when the first close it meets shows it would have taken + liquidity. + + Returns: + Whether the order is done with, filled or cancelled. + """ + limit_order.settled = True + if limit_order.time_in_force is TimeInForce.POST_ONLY: + if _takes_liquidity(limit_order, close): + logger.warning( + "%s would take liquidity at %s and is cancelled", limit_order, close + ) + self._cancel_at_placement(limit_order) + return True + return False + if _takes_liquidity(limit_order, close): + self._order_book.remove_order(limit_order) + self._fill_taking_liquidity(timestamp, limit_order, close) + return True + logger.info("%s is not filled at %s and is cancelled", limit_order, close) + self._cancel_at_placement(limit_order) + return True + + def _update_equity_snapshot(self, timestamp: datetime) -> None: + self._last_tick_snapshot = EquitySnapshot( + timestamp=timestamp, + market_type=self._market_type, + balances=self.get_balances(), + positions=self._get_positions_for_snapshot(), + prices=self._last_seen_prices, + converter=self.converter, + ) + def _validate_tp_sl_prices( self, order: LimitOrder | MarketOrder, entry_price: float ) -> None: @@ -512,43 +555,6 @@ def _validate_tp_sl_prices( f"Invalid TP {tp.trigger_price} for {entry}: TP must be below entry price" ) - def _on_add_limit_entry(self, limit_order: LimitOrder) -> None: - """Hook called when a limit entry is added, whatever its side. Override - to lock the funds it would fill against. - """ - pass - - def _on_release_limit_entry(self, limit_order: LimitOrder) -> None: - """Hook called when a limit entry leaves the book, matched or cancelled. - Override to release what `_on_add_limit_entry` locked. - """ - pass - - def _after_tick(self, timestamp: datetime, ohlcvs: OHLCVsBySymbol) -> None: - """Hook called after the order matching loop. Override for post-tick - logic (e.g. liquidations). - """ - pass - - @abstractmethod - def _enter_position( - self, - timestamp: datetime, - order: LimitOrder | MarketOrder, - price: float, - fee_rate: float, - taker_fee_rate: float, - ) -> None: ... - - @abstractmethod - def _exit_position( - self, - timestamp: datetime, - order: LimitOrder | MarketOrder | StopLossOrder | TakeProfitOrder, - price: float, - fee_rate: float, - ) -> None: ... - def _is_limit_entry(order: Order) -> bool: return order.kind == _LIMIT and not cast(LimitOrder, order).reduce_only diff --git a/src/robottraderslab/backtester/simulator/order_book.py b/src/robottraderslab/backtester/simulator/order_book.py index 1128ee8..6e648f8 100644 --- a/src/robottraderslab/backtester/simulator/order_book.py +++ b/src/robottraderslab/backtester/simulator/order_book.py @@ -1,5 +1,6 @@ import logging from collections.abc import Iterator +from itertools import islice from robottraderslab._core import Symbol @@ -29,31 +30,11 @@ def __iter__(self) -> Iterator[Order]: if id(order) not in self._dead_order_ids: yield order - def orders_for(self, symbol: Symbol) -> Iterator[Order]: - """Iterate over pending orders for one symbol, skipping any removed - during iteration. - """ - try: - for order in self._orders_by_symbol.get(symbol, ()): - if id(order) not in self._dead_order_ids: - yield order - finally: - self._compact_symbol(symbol) - def add_order(self, order: Order) -> None: """Add an order to the book.""" self._orders_by_symbol.setdefault(order.symbol, []).append(order) logger.debug("Order added: %s", order) - def remove_order(self, order: Order) -> None: - """Remove an order from the book and resolve the rest of its - contingency group. - """ - logger.debug("Order removed: %s", order) - self._dead_order_ids.add(id(order)) - group_id: str | None = getattr(order, "group_id", None) - self.resolve_group(order.symbol, group_id) - def cancel_order_by_id(self, symbol: Symbol, order_id: str) -> Order | None: """Remove the order with this id from the symbol's bucket. @@ -94,6 +75,28 @@ def find_guard( return order return None + def orders_for(self, symbol: Symbol) -> Iterator[Order]: + """An order added during iteration, such as a guard a fill opens, is left + to the next iteration, so a guard is first matched against prices after + its entry fills. + """ + bucket = self._orders_by_symbol.get(symbol, []) + try: + for order in islice(bucket, len(bucket)): + if id(order) not in self._dead_order_ids: + yield order + finally: + self._compact_symbol(symbol) + + def remove_order(self, order: Order) -> None: + """Remove an order from the book and resolve the rest of its + contingency group. + """ + logger.debug("Order removed: %s", order) + self._dead_order_ids.add(id(order)) + group_id: str | None = getattr(order, "group_id", None) + self.resolve_group(order.symbol, group_id) + def resolve_group(self, symbol: Symbol, group_id: str | None) -> None: """Cancel every order on the symbol that belongs to the given group. diff --git a/src/robottraderslab/bootstrap/settings/load_secrets.py b/src/robottraderslab/bootstrap/settings/load_secrets.py index dc719e1..63e1709 100644 --- a/src/robottraderslab/bootstrap/settings/load_secrets.py +++ b/src/robottraderslab/bootstrap/settings/load_secrets.py @@ -18,8 +18,8 @@ def load_secrets(secrets_file: str | Path | None) -> SecretsByName: """Read the credentials a bot keeps out of its config file. - A bot may name no secrets file at all, or name one it has not written yet, - and in both cases it starts with no credentials. + A copy of the example `rtlab init live` laid out, with no entry in it yet, + starts a bot with no credentials, the same as a file never written. """ if secrets_file is None: return {} @@ -35,7 +35,7 @@ def load_secrets(secrets_file: str | Path | None) -> SecretsByName: raw_secrets = tomllib.load(f) if not raw_secrets.get("secrets"): - logger.warning("No secrets found in the file.") + logger.debug("No secrets declared in `%s`", secrets_file) return {} secrets_by_name: dict[str, dict] = {} diff --git a/src/robottraderslab/cli/app.py b/src/robottraderslab/cli/app.py index f861124..3caf92b 100644 --- a/src/robottraderslab/cli/app.py +++ b/src/robottraderslab/cli/app.py @@ -29,6 +29,7 @@ "backtest": "robottraderslab.backtester", "flatten": "robottraderslab.live.flatten", "grid-search": "robottraderslab.grid_search", + "init": "robottraderslab.scaffold.workspace", "live": "robottraderslab.live", "report": "robottraderslab.live.report", "scaffold": "robottraderslab.scaffold", @@ -89,6 +90,11 @@ bool, typer.Option("--list", help="List the examples the installed plugins ship"), ] +_RunKind = Annotated[ + str, + typer.Argument(metavar="KIND", help="`live`, the one kind of run laid out"), +] +_LIVE_RUN = "live" app = typer.Typer( rich_markup_mode=None, @@ -202,6 +208,16 @@ def grid_search( _run("grid-search", config=config, output_csv=output_csv) +@app.command() +def init(ctx: typer.Context, kind: _RunKind) -> None: + """Lay out what a live bot needs in the workspace: the example secrets file + and the example registry of bots, to copy and fill. + """ + if kind != _LIVE_RUN: + ctx.fail(f"The one kind of run laid out is `{_LIVE_RUN}`") + _run("init") + + @app.command() def live( config: _ConfigFile, diff --git a/src/robottraderslab/scaffold/templates/registry.example.toml b/src/robottraderslab/scaffold/templates/registry.example.toml new file mode 100644 index 0000000..b578695 --- /dev/null +++ b/src/robottraderslab/scaffold/templates/registry.example.toml @@ -0,0 +1,6 @@ +# The bots the scheduler launches, one [[bot]] entry each: the bot's +# configuration file, relative to this file, and the timeframes it trades on. +# +# [[bot]] +# config = "first-bot/impulse-bot-example.toml" +# timeframes = ["1h", "2h", "4h"] diff --git a/src/robottraderslab/scaffold/templates/secrets.example.toml b/src/robottraderslab/scaffold/templates/secrets.example.toml new file mode 100644 index 0000000..4928292 --- /dev/null +++ b/src/robottraderslab/scaffold/templates/secrets.example.toml @@ -0,0 +1,3 @@ +# The credentials the bots of this workspace use, one [[secrets]] entry each, +# under the name a bot's configuration file gives as `secret_name`. The fields +# an entry takes are on the page of the connector or plugin that reads it. diff --git a/src/robottraderslab/scaffold/workspace.py b/src/robottraderslab/scaffold/workspace.py new file mode 100644 index 0000000..6f4e585 --- /dev/null +++ b/src/robottraderslab/scaffold/workspace.py @@ -0,0 +1,36 @@ +from importlib.resources import files +from pathlib import Path + +from robottraderslab._core import mark_root + +_WORKSPACE = Path("workspace") +_TEMPLATES_DIR_NAME = "templates" +_EXAMPLE_FILES = ("secrets.example.toml", "registry.example.toml") + + +def lay_out_workspace(root: Path) -> list[Path]: + """The examples are the engine's, so a run over a workspace that already + holds them writes the current ones. + + Args: + root: The workspace directory. + """ + root.mkdir(parents=True, exist_ok=True) + written: list[Path] = [] + for name in _EXAMPLE_FILES: + target = root / name + target.write_text(_template(name), encoding="utf-8") + written.append(target) + mark_root(root) + return written + + +def main() -> None: + """Lay out the workspace and print one line per example written.""" + for path in lay_out_workspace(_WORKSPACE): + print(f"Wrote {path}") + + +def _template(name: str) -> str: + template = files(__package__) / _TEMPLATES_DIR_NAME / name + return template.read_text(encoding="utf-8") diff --git a/tests/backtester/simulator/simulation_engine/test_execution_stop_loss_order.py b/tests/backtester/simulator/simulation_engine/test_execution_stop_loss_order.py index 6f265a4..aaaa42a 100644 --- a/tests/backtester/simulator/simulation_engine/test_execution_stop_loss_order.py +++ b/tests/backtester/simulator/simulation_engine/test_execution_stop_loss_order.py @@ -51,12 +51,12 @@ def simulate_filled_stop_loss_order( ohlcv = ( { "low": self.stop_loss - 20, - "high": self.take_profit_long + 20, + "high": self.stop_loss + 20, "close": self.stop_loss + 10, } if side == PositionSide.LONG else { - "low": self.take_profit_short - 20, + "low": self.stop_loss - 20, "high": self.stop_loss + 20, "close": self.stop_loss - 10, } @@ -70,12 +70,12 @@ def simulate_filled_tpsl_orders( ohlcv = ( { "low": self.stop_loss - 20, - "high": self.stop_loss + 20, + "high": self.take_profit_long + 20, "close": self.stop_loss + 10, } if side == PositionSide.LONG else { - "low": self.stop_loss - 20, + "low": self.take_profit_short - 20, "high": self.stop_loss + 20, "close": self.stop_loss - 10, } @@ -102,7 +102,7 @@ def simulate_unfilled_tpsl_orders( sim.simulate_on_current_ohlcvs(btc_usdt_perp, **ohlcv) - async def test_long_stop_loss_not_yet_executed(self, sim, btc_usdt_perp: Symbol): + async def test_long_stop_loss_not_yet_executed(self, sim, btc_usdt_perp): await self.place_enter_long_with_stop_loss(sim, btc_usdt_perp) self.simulate_unfilled_tpsl_orders(sim, btc_usdt_perp, PositionSide.LONG) @@ -112,7 +112,7 @@ async def test_long_stop_loss_not_yet_executed(self, sim, btc_usdt_perp: Symbol) assert sim.open_orders[0].kind == "stop-loss" assert sim.simulation_engine.get_equity("USDT") == sim.initial_usdt - async def test_short_stop_loss_not_yet_executed(self, sim, btc_usdt_perp: Symbol): + async def test_short_stop_loss_not_yet_executed(self, sim, btc_usdt_perp): await self.place_enter_short_with_stop_loss(sim, btc_usdt_perp) self.simulate_unfilled_tpsl_orders(sim, btc_usdt_perp, PositionSide.SHORT) @@ -122,25 +122,27 @@ async def test_short_stop_loss_not_yet_executed(self, sim, btc_usdt_perp: Symbol assert sim.open_orders[0].kind == "stop-loss" assert sim.simulation_engine.get_equity("USDT") == sim.initial_usdt - async def test_long_stop_loss_executed(self, sim, btc_usdt_perp: Symbol): + async def test_long_stop_loss_executed(self, sim, btc_usdt_perp): await self.place_enter_long_with_stop_loss(sim, btc_usdt_perp) + self.simulate_unfilled_tpsl_orders(sim, btc_usdt_perp, PositionSide.LONG) self.simulate_filled_stop_loss_order(sim, btc_usdt_perp, PositionSide.LONG) assert len(sim.open_orders) == 0 assert len(sim.open_positions) == 0 assert sim.simulation_engine.get_equity("USDT") < sim.initial_usdt - async def test_short_stop_loss_executed(self, sim, btc_usdt_perp: Symbol): + async def test_short_stop_loss_executed(self, sim, btc_usdt_perp): await self.place_enter_short_with_stop_loss(sim, btc_usdt_perp) + self.simulate_unfilled_tpsl_orders(sim, btc_usdt_perp, PositionSide.SHORT) self.simulate_filled_stop_loss_order(sim, btc_usdt_perp, PositionSide.SHORT) assert len(sim.open_orders) == 0 assert len(sim.open_positions) == 0 assert sim.simulation_engine.get_equity("USDT") < sim.initial_usdt - async def test_long_tpsl_stop_loss_executed(self, sim, btc_usdt_perp: Symbol): + async def test_long_tpsl_stop_loss_executed(self, sim, btc_usdt_perp): await self.place_enter_long_with_stop_loss( sim, btc_usdt_perp, with_take_profit=True ) @@ -156,7 +158,7 @@ async def test_long_tpsl_stop_loss_executed(self, sim, btc_usdt_perp: Symbol): assert len(sim.open_positions) == 0 assert sim.simulation_engine.get_equity("USDT") < sim.initial_usdt - async def test_short_tpsl_stop_loss_executed(self, sim, btc_usdt_perp: Symbol): + async def test_short_tpsl_stop_loss_executed(self, sim, btc_usdt_perp): await self.place_enter_short_with_stop_loss( sim, btc_usdt_perp, with_take_profit=True ) @@ -172,26 +174,24 @@ async def test_short_tpsl_stop_loss_executed(self, sim, btc_usdt_perp: Symbol): assert len(sim.open_positions) == 0 assert sim.simulation_engine.get_equity("USDT") < sim.initial_usdt - async def test_long_stop_loss_wins_over_take_profit( - self, sim, btc_usdt_perp: Symbol - ): + async def test_long_stop_loss_wins_over_take_profit(self, sim, btc_usdt_perp): await self.place_enter_long_with_stop_loss( sim, btc_usdt_perp, with_take_profit=True ) + self.simulate_unfilled_tpsl_orders(sim, btc_usdt_perp, PositionSide.LONG) self.simulate_filled_tpsl_orders(sim, btc_usdt_perp, PositionSide.LONG) assert len(sim.open_orders) == 0 assert len(sim.open_positions) == 0 assert sim.simulation_engine.get_equity("USDT") < sim.initial_usdt - async def test_short_stop_loss_wins_over_take_profit( - self, sim, btc_usdt_perp: Symbol - ): + async def test_short_stop_loss_wins_over_take_profit(self, sim, btc_usdt_perp): await self.place_enter_short_with_stop_loss( sim, btc_usdt_perp, with_take_profit=True ) + self.simulate_unfilled_tpsl_orders(sim, btc_usdt_perp, PositionSide.SHORT) self.simulate_filled_tpsl_orders(sim, btc_usdt_perp, PositionSide.SHORT) assert len(sim.open_orders) == 0 diff --git a/tests/backtester/simulator/simulation_engine/test_execution_take_profit_order.py b/tests/backtester/simulator/simulation_engine/test_execution_take_profit_order.py index 5d234fd..5f65f89 100644 --- a/tests/backtester/simulator/simulation_engine/test_execution_take_profit_order.py +++ b/tests/backtester/simulator/simulation_engine/test_execution_take_profit_order.py @@ -79,7 +79,7 @@ def simulate_unfilled_tpsl_orders( sim.simulate_on_current_ohlcvs(btc_usdt_perp, **ohlcv) - async def test_long_take_profit_not_yet_executed(self, sim, btc_usdt_perp: Symbol): + async def test_long_take_profit_not_yet_executed(self, sim, btc_usdt_perp): await self.place_enter_long_with_take_profit(sim, btc_usdt_perp) self.simulate_unfilled_tpsl_orders(sim, btc_usdt_perp, PositionSide.LONG) @@ -89,7 +89,7 @@ async def test_long_take_profit_not_yet_executed(self, sim, btc_usdt_perp: Symbo assert sim.open_orders[0].kind == "take-profit" assert sim.simulation_engine.get_equity("USDT") == sim.initial_usdt - async def test_short_take_profit_not_yet_executed(self, sim, btc_usdt_perp: Symbol): + async def test_short_take_profit_not_yet_executed(self, sim, btc_usdt_perp): await self.place_enter_short_with_take_profit(sim, btc_usdt_perp) self.simulate_unfilled_tpsl_orders(sim, btc_usdt_perp, PositionSide.SHORT) @@ -99,25 +99,27 @@ async def test_short_take_profit_not_yet_executed(self, sim, btc_usdt_perp: Symb assert sim.open_orders[0].kind == "take-profit" assert sim.simulation_engine.get_equity("USDT") == sim.initial_usdt - async def test_long_take_profit_executed(self, sim, btc_usdt_perp: Symbol): + async def test_long_take_profit_executed(self, sim, btc_usdt_perp): await self.place_enter_long_with_take_profit(sim, btc_usdt_perp) + self.simulate_unfilled_tpsl_orders(sim, btc_usdt_perp, PositionSide.LONG) self.simulate_filled_take_profit_order(sim, btc_usdt_perp, PositionSide.LONG) assert len(sim.open_orders) == 0 assert len(sim.open_positions) == 0 assert sim.simulation_engine.get_equity("USDT") > sim.initial_usdt - async def test_short_take_profit_executed(self, sim, btc_usdt_perp: Symbol): + async def test_short_take_profit_executed(self, sim, btc_usdt_perp): await self.place_enter_short_with_take_profit(sim, btc_usdt_perp) + self.simulate_unfilled_tpsl_orders(sim, btc_usdt_perp, PositionSide.SHORT) self.simulate_filled_take_profit_order(sim, btc_usdt_perp, PositionSide.SHORT) assert len(sim.open_orders) == 0 assert len(sim.open_positions) == 0 assert sim.simulation_engine.get_equity("USDT") > sim.initial_usdt - async def test_long_tpsl_take_profit_executed(self, sim, btc_usdt_perp: Symbol): + async def test_long_tpsl_take_profit_executed(self, sim, btc_usdt_perp): await self.place_enter_long_with_take_profit( sim, btc_usdt_perp, with_stop_loss=True ) @@ -133,7 +135,7 @@ async def test_long_tpsl_take_profit_executed(self, sim, btc_usdt_perp: Symbol): assert len(sim.open_positions) == 0 assert sim.simulation_engine.get_equity("USDT") > sim.initial_usdt - async def test_short_tpsl_take_profit_executed(self, sim, btc_usdt_perp: Symbol): + async def test_short_tpsl_take_profit_executed(self, sim, btc_usdt_perp): await self.place_enter_short_with_take_profit( sim, btc_usdt_perp, with_stop_loss=True ) diff --git a/tests/backtester/simulator/simulation_engine/test_protection_after_entry_fill.py b/tests/backtester/simulator/simulation_engine/test_protection_after_entry_fill.py new file mode 100644 index 0000000..90e47a0 --- /dev/null +++ b/tests/backtester/simulator/simulation_engine/test_protection_after_entry_fill.py @@ -0,0 +1,62 @@ +from datetime import UTC, datetime + +from robottraderslab import Symbol +from robottraderslab._core import OHLCVRow +from robottraderslab.exchanges import OrderSide, StopLoss, TakeProfit + +ENTRY_PRICE = 100.0 +FIRST_CLOSE = datetime(2026, 3, 1, 15, 15, tzinfo=UTC) +SECOND_CLOSE = datetime(2026, 3, 1, 15, 30, tzinfo=UTC) +THIRD_CLOSE = datetime(2026, 3, 1, 15, 45, tzinfo=UTC) + + +def _play_candle( + sim, symbol: Symbol, close_moment: datetime, low: float, high: float +) -> None: + candle = OHLCVRow( + timestamp=close_moment, + open=ENTRY_PRICE, + high=high, + low=low, + close=ENTRY_PRICE, + volume=1.0, + ) + sim.simulation_engine.simulate_on_current_ohlcvs(close_moment, {symbol: candle}) + + +async def test_take_profit_fills_on_the_first_later_candle_reaching_it( + sim, btc_usdt_perp +): + await sim.exchange.place_limit_order( + symbol=btc_usdt_perp, + side=OrderSide.SELL, + quantity=1.0, + price=ENTRY_PRICE, + take_profit=TakeProfit(trigger_price=99.0), + ) + + _play_candle(sim, btc_usdt_perp, FIRST_CLOSE, low=98.5, high=100.5) + _play_candle(sim, btc_usdt_perp, SECOND_CLOSE, low=100.0, high=101.0) + _play_candle(sim, btc_usdt_perp, THIRD_CLOSE, low=98.0, high=100.0) + + fills = sim.fill_recorder.get_fills() + assert list(fills.index) == [FIRST_CLOSE, THIRD_CLOSE] + assert list(fills["price"]) == [ENTRY_PRICE, 99.0] + + +async def test_stop_loss_rests_through_the_candle_its_entry_fills_on( + sim, btc_usdt_perp +): + await sim.exchange.place_limit_order( + symbol=btc_usdt_perp, + side=OrderSide.SELL, + quantity=1.0, + price=ENTRY_PRICE, + stop_loss=StopLoss(trigger_price=101.0), + ) + + _play_candle(sim, btc_usdt_perp, FIRST_CLOSE, low=99.0, high=101.5) + _play_candle(sim, btc_usdt_perp, SECOND_CLOSE, low=99.0, high=100.0) + + assert sim.open_positions[btc_usdt_perp].quantity == 1.0 + assert [order.kind for order in sim.open_orders] == ["stop-loss"] diff --git a/tests/bootstrap/settings/test_load_secrets.py b/tests/bootstrap/settings/test_load_secrets.py index 2ef142c..6785687 100644 --- a/tests/bootstrap/settings/test_load_secrets.py +++ b/tests/bootstrap/settings/test_load_secrets.py @@ -6,6 +6,7 @@ from robottraderslab.bootstrap import load_secrets from robottraderslab.exceptions import StrategyCriticalError +from robottraderslab.scaffold.workspace import lay_out_workspace _TEST_DIR = Path(__file__).parent @@ -63,3 +64,15 @@ def test_a_named_but_missing_secrets_file_warns_and_yields_nothing(tmp_path, cap assert secrets == {} assert "not found" in caplog.text + + +def test_a_laid_out_secrets_example_yields_nothing_without_a_warning(tmp_path, caplog): + laid_out = lay_out_workspace(tmp_path) + + with caplog.at_level(logging.DEBUG): + secrets = load_secrets(laid_out[0]) + + assert secrets == {} + assert [ + record.levelno for record in caplog.records if "declared" in record.message + ] == [logging.DEBUG] diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py index 0db11fa..1fb6866 100644 --- a/tests/cli/test_app.py +++ b/tests/cli/test_app.py @@ -28,6 +28,7 @@ "backtest": "robottraderslab.backtester", "flatten": "robottraderslab.live.flatten", "grid-search": "robottraderslab.grid_search", + "init": "robottraderslab.scaffold.workspace", "live": "robottraderslab.live", "report": "robottraderslab.live.report", "scaffold": "robottraderslab.scaffold", @@ -172,6 +173,32 @@ def test_command_requires_a_config(name, cli, replace_command): assert received == [] +def test_init_live_takes_no_argument(cli, replace_command): + received: list[bool] = [] + replace_command("init", lambda: received.append(True)) + + invoked = cli.invoke(app, ["init", "live"]) + + assert invoked.exit_code == 0 + assert received == [True] + + +def test_init_requires_a_kind_of_run(cli): + invoked = cli.invoke(app, ["init"]) + + assert invoked.exit_code == 2 + + +def test_init_refuses_a_kind_of_run_it_does_not_lay_out(cli, replace_command): + received: list[bool] = [] + replace_command("init", lambda: received.append(True)) + + invoked = cli.invoke(app, ["init", "backtest"]) + + assert invoked.exit_code == 2 + assert received == [] + + def test_scaffold_receives_the_example_name(cli, replace_command): received: list[str | None] = [] replace_command("scaffold", lambda name: received.append(name)) diff --git a/tests/scaffold/test_workspace.py b/tests/scaffold/test_workspace.py new file mode 100644 index 0000000..552e3a0 --- /dev/null +++ b/tests/scaffold/test_workspace.py @@ -0,0 +1,72 @@ +import tomllib +from pathlib import Path + +import pytest + +from robottraderslab.scaffold.workspace import lay_out_workspace, main + +_EXAMPLES = ("secrets.example.toml", "registry.example.toml") + + +@pytest.fixture +def workspace(tmp_path: Path) -> Path: + return tmp_path / "workspace" + + +def test_writes_both_examples_into_a_workspace_it_creates(workspace): + written = lay_out_workspace(workspace) + + assert written == [workspace / name for name in _EXAMPLES] + + +def test_marks_the_workspace_as_the_root(workspace): + lay_out_workspace(workspace) + + assert (workspace / ".rtlab").is_dir() + + +def test_a_second_run_keeps_what_the_root_already_holds(workspace): + lay_out_workspace(workspace) + lock = workspace / ".rtlab" / "bot.lock" + lock.write_text("", encoding="utf-8") + + lay_out_workspace(workspace) + + assert lock.is_file() + + +def test_each_example_is_toml_declaring_nothing_yet(workspace): + lay_out_workspace(workspace) + + for name in _EXAMPLES: + assert tomllib.loads((workspace / name).read_text(encoding="utf-8")) == {} + + +def test_an_example_already_there_is_written_afresh(workspace): + workspace.mkdir() + stale = workspace / "secrets.example.toml" + stale.write_text("stale", encoding="utf-8") + + lay_out_workspace(workspace) + + assert stale.read_text(encoding="utf-8") != "stale" + + +def test_a_filled_file_beside_the_examples_is_untouched(workspace): + workspace.mkdir() + filled = workspace / "secrets.toml" + filled.write_text('[[secrets]]\nname = "mine"\n', encoding="utf-8") + + lay_out_workspace(workspace) + + assert filled.read_text(encoding="utf-8") == '[[secrets]]\nname = "mine"\n' + + +def test_main_names_each_example_it_wrote(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + + main() + + assert capsys.readouterr().out == "".join( + f"Wrote {Path('workspace') / name}\n" for name in _EXAMPLES + ) diff --git a/uv.lock b/uv.lock index 6cd7bba..56e827e 100644 --- a/uv.lock +++ b/uv.lock @@ -305,26 +305,26 @@ wheels = [ [[package]] name = "coverage" -version = "7.16.1" +version = "7.16.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/74/c08c0c4dc9fa6bcd1d90728a63660aa1b17b488a806948598456c48f75d1/coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9", size = 223501, upload-time = "2026-09-13T19:09:37.276Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c8/784986d326663285258a8e39835c46fb730cc85284f0dbcd82078586dd22/coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245", size = 223876, upload-time = "2026-09-13T19:09:39.385Z" }, - { url = "https://files.pythonhosted.org/packages/15/76/73fb792928872bbb07e553f920ff55c65ee962c469265feb5d1d4ae5f97a/coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5", size = 254863, upload-time = "2026-09-13T19:09:41.326Z" }, - { url = "https://files.pythonhosted.org/packages/ca/8d/1fd78899513244b065ccecdd1cfc6aa8b8f1bdee796d8c4f2aa8e8e5397d/coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5", size = 257460, upload-time = "2026-09-13T19:09:43.086Z" }, - { url = "https://files.pythonhosted.org/packages/47/1c/b23ddfcb7ef9bd7b3fdb7be9a5dccf9925ae88e556df7aa5b655283c78f2/coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f", size = 258697, upload-time = "2026-09-13T19:09:45.161Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c9/19d0c6ca35778a7e9415c30c46a0c64c9d4374219d004a35563132296896/coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41", size = 260827, upload-time = "2026-09-13T19:09:47.061Z" }, - { url = "https://files.pythonhosted.org/packages/97/fc/5862f7344382c62b85df43d483e579168cc062e007f54d895dfa51fe3e7d/coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878", size = 255038, upload-time = "2026-09-13T19:09:48.945Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9f/5c26583199a68df8d15124b4590520903f8eb7cef17db293fea712bb0783/coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2", size = 256827, upload-time = "2026-09-13T19:09:50.738Z" }, - { url = "https://files.pythonhosted.org/packages/95/13/605198bff079b107336710f28a797312ab132587168600d70a290e7ecd2e/coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080", size = 254794, upload-time = "2026-09-13T19:09:52.577Z" }, - { url = "https://files.pythonhosted.org/packages/43/b7/0bbb32dc5ccdac766a13763fdfbffd7d73fc80349a69230c46c6b71e7508/coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037", size = 258947, upload-time = "2026-09-13T19:09:54.372Z" }, - { url = "https://files.pythonhosted.org/packages/13/bd/65c31ddd43ff4e61b63721b3dad17cca412dba6e2ce89f92abdf75734339/coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b", size = 254613, upload-time = "2026-09-13T19:09:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/25/20/d278115f2ba522b3e3128c6852be265f4e0df7202b2effca4db96cf0217d/coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0", size = 256388, upload-time = "2026-09-13T19:09:58.095Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ca/d43aa396fb3a2f71c9411b99d926475f5acaf5c124f605c592865c80c8d5/coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a", size = 225550, upload-time = "2026-09-13T19:09:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/0e/30/df2f6114f6a17cffe94721bd1d298f77f86aad493717fa5bc03cb291a1a6/coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa", size = 226091, upload-time = "2026-09-13T19:10:02.331Z" }, - { url = "https://files.pythonhosted.org/packages/ee/31/6f90d8aab72a112492bd50e3b5485b53b772b1f5fa70da0a620e723caae6/coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d", size = 225481, upload-time = "2026-09-13T19:10:04.151Z" }, - { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, ] [[package]] @@ -1313,7 +1313,7 @@ wheels = [ [[package]] name = "robottraderslab" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },