Files
manifoldbt/examples/20_entry_orders.py
T

99 lines
3.5 KiB
Python
Raw Normal View History

2026-08-16 12:02:58 +00:00
"""Entry orders — resting an entry at a price instead of taking the close.
By default an entry takes a market fill on the execution bar. This example runs
the same signal four ways so the difference is visible in one place:
market fill at the execution bar's close
limit wait for a pullback, fill passively (maker, no slippage)
stop wait for a breakout, fill through the level (taker + gap)
limit on a signal rest on a level the DSL computes (here: 1 ATR below close)
Usage:
python examples/20_entry_orders.py
"""
import os
from time import perf_counter
import manifoldbt as mbt
from manifoldbt.indicators import atr, close, ema
from manifoldbt.helpers import Interval, Slippage, time_range
# -- Signal -------------------------------------------------------------------
fast = ema(close, 12)
slow = ema(close, 50)
trend = mbt.when(fast > slow, 1.0, 0.0)
# The level a signal-priced entry rests on: one ATR below the close.
pullback = close - atr(14)
def build(name: str, entry) -> "mbt.Strategy":
"""The same strategy every time; only the entry order changes."""
s = (
mbt.Strategy.create(name)
.signal("fast", fast)
.signal("slow", slow)
.signal("pullback", pullback)
.size(trend)
.stop_loss(pct=3.0)
)
return entry(s) if entry else s
VARIANTS = {
# Market: no entry order at all. The fast kernel stays available.
"market": None,
# Passive: 25 bps below the signal close, cancelled if unfilled after 5 bars.
"limit -25bps": lambda s: s.limit_entry(offset_bps=25, time_in_force={"GTB": 5}),
# Breakout: 25 bps above. Crosses the book, and a gap through it fills at the open.
"stop +25bps": lambda s: s.stop_entry(offset_bps=-25, time_in_force={"GTB": 5}),
# Signal-priced: rest on whatever the DSL computed, here close - atr(14).
"limit @ close-ATR": lambda s: s.limit_entry(signal="pullback", time_in_force={"GTB": 5}),
}
# -- Config -------------------------------------------------------------------
start, end = time_range("2022-01-01", "2025-01-01")
config = mbt.BacktestConfig(
universe={"binance": ["BTC-USDT:perp"]},
time_range_start=start,
time_range_end=end,
bar_interval=Interval.hours(4),
initial_capital=10_000,
fees=mbt.FeeConfig.binance_perps(),
slippage=Slippage.fixed_bps(2),
warmup_bars=60,
)
# -- Run ----------------------------------------------------------------------
if __name__ == "__main__":
root = os.path.join(os.path.dirname(__file__), "..")
data_root = os.path.abspath(os.path.join(root, "data"))
store = mbt.DataStore(
data_root=data_root,
metadata_db=os.path.abspath(os.path.join(root, "metadata", "metadata.sqlite")),
arrow_dir=os.path.join(data_root, "mega"),
)
print(f"{'entry':<20} {'trades':>7} {'return':>9} {'sharpe':>8} {'elapsed':>9}")
print("-" * 56)
for label, entry in VARIANTS.items():
strategy = build(label.replace(" ", "_"), entry)
t0 = perf_counter()
result = mbt.run(strategy, config, store)
elapsed = perf_counter() - t0
m = result.metrics
print(
f"{label:<20} {result.trades.num_rows:>7} "
f"{m['total_return']:>8.1%} {m['sharpe']:>8.2f} {elapsed:>8.2f}s"
)
# A resting entry can simply never fill. That failure mode looks like a
# clean backtest, so the engine reports it rather than staying silent.
for w in result.warnings:
if "unfilled" in w:
print(f"{'':<20} ! {w}")