Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ reports/
# Don't commit your secrets :)
secret*
!load_secrets.py
!src/robottraderslab/scaffold/templates/secrets.example.toml
!tests/**/secret*

# macOS
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
364 changes: 185 additions & 179 deletions src/robottraderslab/backtester/simulator/base_simulation_engine.py

Large diffs are not rendered by default.

43 changes: 23 additions & 20 deletions src/robottraderslab/backtester/simulator/order_book.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from collections.abc import Iterator
from itertools import islice

from robottraderslab._core import Symbol

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions src/robottraderslab/bootstrap/settings/load_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand All @@ -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] = {}
Expand Down
16 changes: 16 additions & 0 deletions src/robottraderslab/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/robottraderslab/scaffold/templates/registry.example.toml
Original file line number Diff line number Diff line change
@@ -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"]
3 changes: 3 additions & 0 deletions src/robottraderslab/scaffold/templates/secrets.example.toml
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions src/robottraderslab/scaffold/workspace.py
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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,
}
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
)
Expand All @@ -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
)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
)
Expand All @@ -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
)
Expand Down
Loading
Loading