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
24 changes: 20 additions & 4 deletions src/ml4t/backtest/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def __init__(
stop_fill_mode: StopFillMode = StopFillMode.STOP_PRICE,
stop_level_basis: StopLevelBasis = StopLevelBasis.FILL_PRICE,
trail_hwm_source: WaterMarkSource = WaterMarkSource.CLOSE,
trail_include_entry_bar_extremes: bool = False,
initial_hwm_source: InitialHwmSource = InitialHwmSource.FILL_PRICE,
trail_stop_timing: TrailStopTiming = TrailStopTiming.LAGGED,
allow_short_selling: bool = False,
Expand Down Expand Up @@ -120,6 +121,7 @@ def __init__(
self.stop_fill_mode = stop_fill_mode
self.stop_level_basis = stop_level_basis
self.trail_hwm_source = trail_hwm_source
self.trail_include_entry_bar_extremes = trail_include_entry_bar_extremes
self.initial_hwm_source = initial_hwm_source
self.trail_stop_timing = trail_stop_timing
self.share_type = share_type
Expand Down Expand Up @@ -360,6 +362,7 @@ def from_config(
stop_fill_mode=config.stop_fill_mode,
stop_level_basis=config.stop_level_basis,
trail_hwm_source=config.trail_hwm_source,
trail_include_entry_bar_extremes=config.trail_include_entry_bar_extremes,
initial_hwm_source=config.initial_hwm_source,
trail_stop_timing=config.trail_stop_timing,
allow_short_selling=config.allow_short_selling,
Expand Down Expand Up @@ -767,18 +770,27 @@ def last_rejection_reason(self) -> str | None:

# === Risk Management ===

def set_position_rules(self, rules: PositionRule, asset: str | None = None) -> None:
def set_position_rules(
self,
rules: PositionRule | None,
asset: str | None = None,
) -> None:
"""Set position rules globally or per-asset.

Args:
rules: PositionRule or RuleChain to apply
rules: PositionRule or RuleChain to apply. ``None`` explicitly
disables rules for the selected scope.
asset: If provided, apply only to this asset; otherwise global
"""
if asset:
if asset is not None:
self._position_rules_by_asset[asset] = rules
else:
self._position_rules = rules

def clear_position_rules(self, asset: str | None = None) -> None:
"""Disable position rules globally or for one asset."""
self.set_position_rules(None, asset=asset)

def update_position_context(self, asset: str, context: dict) -> None:
"""Update context data for a position (used by signal-based rules).

Expand Down Expand Up @@ -1725,14 +1737,18 @@ def _update_water_marks(self):
Water mark source configuration:
- trail_hwm_source == BAR_EXTREME: Update HWM from high, LWM from low (VBT Pro OHLC mode)
- trail_hwm_source == CLOSE: Update HWM/LWM from close only (default)
- trail_include_entry_bar_extremes: Include a completed entry bar's
extreme in the watermark used from the next bar onward
"""
for asset, pos in self.positions.items():
if asset in self._current_prices:
# For new positions (created this bar), skip updating from entry bar's HIGH/LOW
# VBT Pro only updates water marks from bar extremes on the bar AFTER entry
is_new_position = asset in self._positions_created_this_bar
# BAR_EXTREME: use HIGH for HWM (longs), LOW for LWM (shorts)
use_extremes = self.trail_hwm_source.value == "bar_extreme" and not is_new_position
use_extremes = self.trail_hwm_source.value == "bar_extreme" and (
not is_new_position or self.trail_include_entry_bar_extremes
)
pos.update_water_marks(
current_price=self._current_prices[asset],
bar_high=self._current_highs.get(asset),
Expand Down
7 changes: 7 additions & 0 deletions src/ml4t/backtest/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ class BacktestConfig:
stop_fill_mode: StopFillMode = StopFillMode.STOP_PRICE
stop_level_basis: StopLevelBasis = StopLevelBasis.FILL_PRICE
trail_hwm_source: WaterMarkSource = WaterMarkSource.CLOSE
trail_include_entry_bar_extremes: bool = False
initial_hwm_source: InitialHwmSource = InitialHwmSource.FILL_PRICE
trail_stop_timing: TrailStopTiming = TrailStopTiming.LAGGED

Expand Down Expand Up @@ -840,6 +841,7 @@ def to_dict(self) -> dict:
"stop_fill_mode": self.stop_fill_mode.value,
"stop_level_basis": self.stop_level_basis.value,
"trail_hwm_source": self.trail_hwm_source.value,
"trail_include_entry_bar_extremes": self.trail_include_entry_bar_extremes,
"initial_hwm_source": self.initial_hwm_source.value,
"trail_stop_timing": self.trail_stop_timing.value,
},
Expand Down Expand Up @@ -946,6 +948,7 @@ def from_dict(
"stop_fill_mode",
"stop_level_basis",
"trail_hwm_source",
"trail_include_entry_bar_extremes",
"initial_hwm_source",
"trail_stop_timing",
},
Expand Down Expand Up @@ -1073,6 +1076,9 @@ def from_dict(
stop_fill_mode=StopFillMode(stops_cfg.get("stop_fill_mode", "stop_price")),
stop_level_basis=StopLevelBasis(stops_cfg.get("stop_level_basis", "fill_price")),
trail_hwm_source=WaterMarkSource(stops_cfg.get("trail_hwm_source", "close")),
trail_include_entry_bar_extremes=stops_cfg.get(
"trail_include_entry_bar_extremes", False
),
initial_hwm_source=InitialHwmSource(stops_cfg.get("initial_hwm_source", "fill_price")),
trail_stop_timing=TrailStopTiming(stops_cfg.get("trail_stop_timing", "lagged")),
# Sizing
Expand Down Expand Up @@ -1337,6 +1343,7 @@ def describe(self) -> str:
f" Fill mode: {self.stop_fill_mode.value}",
f" Level basis: {self.stop_level_basis.value}",
f" Trail HWM source: {self.trail_hwm_source.value}",
f" Include entry-bar extremes: {self.trail_include_entry_bar_extremes}",
f" Trail timing: {self.trail_stop_timing.value}",
"",
"Position Sizing:",
Expand Down
3 changes: 3 additions & 0 deletions src/ml4t/backtest/core/order_book.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ def submit_order(
order_id=f"ORD-{broker._order_counter}",
created_at=broker._current_time,
_created_bar_index=broker._bar_index,
_risk_exit_reason=options.risk_exit_reason if options is not None else None,
_exit_reason=options.exit_reason if options is not None else None,
_risk_fill_price=options.risk_fill_price if options is not None else None,
)

order._signal_price = broker._current_prices.get(asset)
Expand Down
34 changes: 21 additions & 13 deletions src/ml4t/backtest/core/risk_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ def evaluate_position_rules(self):
asset,
-pos.quantity,
order_type=OrderType.MARKET,
_options=SubmitOrderOptions(eligible_in_next_bar_mode=True),
_options=SubmitOrderOptions(
eligible_in_next_bar_mode=True,
risk_exit_reason=action.reason,
exit_reason=reason_to_exit_reason(action.reason),
risk_fill_price=action.fill_price,
),
)
if order:
order._risk_exit_reason = action.reason
order._exit_reason = reason_to_exit_reason(action.reason)
order._risk_fill_price = action.fill_price
exit_orders.append(order)
broker._stop_exits_this_bar.add(asset)

Expand All @@ -69,19 +71,23 @@ def evaluate_position_rules(self):
asset,
actual_qty,
order_type=OrderType.MARKET,
_options=SubmitOrderOptions(eligible_in_next_bar_mode=True),
_options=SubmitOrderOptions(
eligible_in_next_bar_mode=True,
risk_exit_reason=action.reason,
exit_reason=reason_to_exit_reason(action.reason),
risk_fill_price=action.fill_price,
),
)
if order:
order._risk_exit_reason = action.reason
order._exit_reason = reason_to_exit_reason(action.reason)
order._risk_fill_price = action.fill_price
exit_orders.append(order)

return exit_orders

def _get_position_rules(self, asset: str):
broker = self.broker
return broker._position_rules_by_asset.get(asset) or broker._position_rules
if asset in broker._position_rules_by_asset:
return broker._position_rules_by_asset[asset]
return broker._position_rules

def _build_position_state(self, pos, current_price: float):
broker = self.broker
Expand Down Expand Up @@ -144,12 +150,14 @@ def process_pending_exits(self):
asset,
-exit_qty,
order_type=OrderType.MARKET,
_options=SubmitOrderOptions(eligible_in_next_bar_mode=True),
_options=SubmitOrderOptions(
eligible_in_next_bar_mode=True,
risk_exit_reason=pending["reason"],
exit_reason=reason_to_exit_reason(pending["reason"]),
risk_fill_price=fill_price,
),
)
if order:
order._risk_exit_reason = pending["reason"]
order._exit_reason = reason_to_exit_reason(pending["reason"])
order._risk_fill_price = fill_price
exit_orders.append(order)

del broker._pending_exits[asset]
Expand Down
3 changes: 3 additions & 0 deletions src/ml4t/backtest/core/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ class SubmitOrderOptions:

eligible_in_next_bar_mode: bool = False
rebalance_id: str | None = None
risk_exit_reason: str | None = None
exit_reason: ExitReason | None = None
risk_fill_price: float | None = None


def is_exit_order(order: Order, positions: dict[str, Position]) -> bool:
Expand Down
4 changes: 4 additions & 0 deletions src/ml4t/backtest/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ def run(self) -> BacktestResult:
# This must happen BEFORE evaluate_position_rules() to clear deferred exits
self.broker._process_pending_exits()

# Optional strategy phase for opening orders that must receive risk
# protection during the current bar. Existing strategies inherit a no-op.
self.strategy.on_before_risk(timestamp, assets_data, context, self.broker)

# Evaluate position rules (stops, trails, etc.) - generates exit orders
self.broker.evaluate_position_rules()

Expand Down
10 changes: 10 additions & 0 deletions src/ml4t/backtest/strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
class Strategy(ABC):
"""Base strategy class."""

def on_before_risk(
self,
timestamp: datetime,
data: dict[str, dict],
context: dict[str, Any],
broker: Any,
) -> None:
"""Called before position rules are evaluated for the current bar."""
return None

@abstractmethod
def on_data(
self,
Expand Down
27 changes: 27 additions & 0 deletions tests/test_broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,33 @@ def test_set_position_rules_global(self):
# Should apply to assets without explicit overrides
assert broker._position_rules_by_asset.get("AAPL") is None

def test_clear_position_rules_for_one_asset(self):
"""Clearing one asset disables its rule without changing other assets."""
from ml4t.backtest.risk.position.static import StopLoss

broker = Broker(100000.0, NoCommission(), NoSlippage())
stop_rule = StopLoss(pct=0.05)
broker.set_position_rules(stop_rule, asset="AAPL")
broker.set_position_rules(stop_rule, asset="MSFT")

broker.clear_position_rules(asset="AAPL")

assert broker._risk_engine._get_position_rules("AAPL") is None
assert broker._risk_engine._get_position_rules("MSFT") == stop_rule

def test_asset_rule_can_explicitly_disable_global_rule(self):
"""An explicit per-asset None overrides, rather than falls back to, a global rule."""
from ml4t.backtest.risk.position.static import StopLoss

broker = Broker(100000.0, NoCommission(), NoSlippage())
stop_rule = StopLoss(pct=0.05)
broker.set_position_rules(stop_rule)

broker.set_position_rules(None, asset="AAPL")

assert broker._risk_engine._get_position_rules("AAPL") is None
assert broker._risk_engine._get_position_rules("MSFT") == stop_rule

def test_update_position_context(self):
"""Test updating position context."""
broker = Broker(100000.0, NoCommission(), NoSlippage())
Expand Down
Loading