Phase 4 management rule behavior
This commit is contained in:
@@ -385,15 +385,20 @@ def _evaluate_signal_outcome_steps(
|
||||
replay_config: Optional[BacktestReplayConfig],
|
||||
) -> BacktestOutcome:
|
||||
entry_mid = (signal.entry_low + signal.entry_high) / 2
|
||||
management_rules = list(signal.management_rules or [])
|
||||
tp1_partial_fraction = resolve_tp1_partial_fraction(management_rules)
|
||||
move_stop_enabled = has_management_rule(management_rules, "move_stop")
|
||||
entry_triggered = False
|
||||
tp1_hit = False
|
||||
tp2_hit = False
|
||||
stopped_out = False
|
||||
stopped_after_tp1 = False
|
||||
same_candle_ambiguous = False
|
||||
mfe = 0.0
|
||||
mae = 0.0
|
||||
entry_step: Optional[_BacktestEvaluationStep] = None
|
||||
exit_step: Optional[_BacktestEvaluationStep] = None
|
||||
active_stop_loss = signal.stop_loss
|
||||
|
||||
for step in steps:
|
||||
candle = step.candle
|
||||
@@ -413,13 +418,13 @@ def _evaluate_signal_outcome_steps(
|
||||
if signal.side == 'buy':
|
||||
mfe = max(mfe, high - entry_mid)
|
||||
mae = max(mae, entry_mid - low)
|
||||
stop_hit = bool(signal.stop_loss and low <= signal.stop_loss)
|
||||
stop_hit = bool(active_stop_loss and low <= active_stop_loss)
|
||||
tp1_now = bool(signal.tp1 and high >= signal.tp1)
|
||||
tp2_now = bool(signal.tp2 and high >= signal.tp2)
|
||||
else:
|
||||
mfe = max(mfe, entry_mid - low)
|
||||
mae = max(mae, high - entry_mid)
|
||||
stop_hit = bool(signal.stop_loss and high >= signal.stop_loss)
|
||||
stop_hit = bool(active_stop_loss and high >= active_stop_loss)
|
||||
tp1_now = bool(signal.tp1 and low <= signal.tp1)
|
||||
tp2_now = bool(signal.tp2 and low <= signal.tp2)
|
||||
|
||||
@@ -430,10 +435,13 @@ def _evaluate_signal_outcome_steps(
|
||||
|
||||
if stop_hit:
|
||||
stopped_out = True
|
||||
stopped_after_tp1 = tp1_hit
|
||||
exit_step = step
|
||||
break
|
||||
if tp1_now:
|
||||
tp1_hit = True
|
||||
if move_stop_enabled:
|
||||
active_stop_loss = entry_mid
|
||||
if tp2_now:
|
||||
tp2_hit = True
|
||||
exit_step = step
|
||||
@@ -448,6 +456,8 @@ def _evaluate_signal_outcome_steps(
|
||||
error_tags.append('entry_not_triggered')
|
||||
elif tp2_hit:
|
||||
outcome = 'win'
|
||||
elif stopped_out and tp1_hit and move_stop_enabled:
|
||||
outcome = 'breakeven'
|
||||
elif tp1_hit and not stopped_out:
|
||||
outcome = 'breakeven'
|
||||
elif stopped_out:
|
||||
@@ -471,7 +481,23 @@ def _evaluate_signal_outcome_steps(
|
||||
rr_tp1=signal.rr_tp1,
|
||||
rr_tp2=signal.rr_tp2,
|
||||
cost_r=cost_r,
|
||||
tp1_hit=tp1_hit,
|
||||
tp2_hit=tp2_hit,
|
||||
tp1_partial_fraction=tp1_partial_fraction,
|
||||
stopped_after_tp1=stopped_after_tp1,
|
||||
move_stop_applied=move_stop_enabled,
|
||||
)
|
||||
remaining_position_fraction = max(0.0, 1.0 - tp1_partial_fraction) if tp1_hit else 1.0
|
||||
management_applied = {
|
||||
"tp1_partial": tp1_partial_fraction > 0 and tp1_hit,
|
||||
"tp1_partial_fraction": tp1_partial_fraction if tp1_hit else 0.0,
|
||||
"move_stop": move_stop_enabled and tp1_hit,
|
||||
"move_stop_to": "breakeven" if move_stop_enabled and tp1_hit else None,
|
||||
"remaining_position_fraction": remaining_position_fraction,
|
||||
"remaining_position_closed": bool(tp2_hit or (stopped_after_tp1 and move_stop_enabled)),
|
||||
"tp1_realized_r": (tp1_partial_fraction * signal.rr_tp1) if tp1_hit else 0.0,
|
||||
"active_stop_loss_after_tp1": entry_mid if move_stop_enabled and tp1_hit else signal.stop_loss,
|
||||
}
|
||||
|
||||
meta = {
|
||||
'entry_triggered': entry_triggered,
|
||||
@@ -488,6 +514,7 @@ def _evaluate_signal_outcome_steps(
|
||||
'cost_model': cost_config.__dict__,
|
||||
'backtest_mode': mode,
|
||||
'r_multiple': r_multiple,
|
||||
'management_applied': management_applied,
|
||||
}
|
||||
if mode == BACKTEST_MODE_REPLAY_NO_LOOKAHEAD:
|
||||
meta.update(
|
||||
@@ -570,12 +597,34 @@ def estimate_exit_price(signal: BacktestSignal, outcome: str, entry_price: float
|
||||
return entry_price
|
||||
|
||||
|
||||
def compute_outcome_r_multiple(outcome: str, rr_tp1: float = 0.0, rr_tp2: float = 0.0, cost_r: float = 0.0) -> float:
|
||||
def compute_outcome_r_multiple(
|
||||
outcome: str,
|
||||
rr_tp1: float = 0.0,
|
||||
rr_tp2: float = 0.0,
|
||||
cost_r: float = 0.0,
|
||||
tp1_hit: bool = False,
|
||||
tp2_hit: bool = False,
|
||||
tp1_partial_fraction: float = 0.0,
|
||||
stopped_after_tp1: bool = False,
|
||||
move_stop_applied: bool = False,
|
||||
) -> float:
|
||||
"""Convert the current simplified outcome labels into conservative R multiples."""
|
||||
if outcome == 'win':
|
||||
gross_r = rr_tp2
|
||||
if tp1_hit and tp1_partial_fraction > 0:
|
||||
gross_r = (tp1_partial_fraction * rr_tp1) + ((1.0 - tp1_partial_fraction) * rr_tp2)
|
||||
else:
|
||||
gross_r = rr_tp2
|
||||
elif outcome == 'loss':
|
||||
gross_r = -1.0
|
||||
if tp1_hit and tp1_partial_fraction > 0:
|
||||
remaining_loss_r = 0.0 if move_stop_applied and stopped_after_tp1 else -(1.0 - tp1_partial_fraction)
|
||||
gross_r = (tp1_partial_fraction * rr_tp1) + remaining_loss_r
|
||||
else:
|
||||
gross_r = -1.0
|
||||
elif outcome == 'breakeven':
|
||||
if tp1_hit and tp1_partial_fraction > 0:
|
||||
gross_r = tp1_partial_fraction * rr_tp1
|
||||
else:
|
||||
gross_r = 0.0
|
||||
else:
|
||||
gross_r = 0.0
|
||||
|
||||
@@ -584,6 +633,22 @@ def compute_outcome_r_multiple(outcome: str, rr_tp1: float = 0.0, rr_tp2: float
|
||||
return gross_r
|
||||
|
||||
|
||||
def has_management_rule(management_rules: list[dict], code: str) -> bool:
|
||||
return any(str(item.get("code") or "") == code for item in management_rules)
|
||||
|
||||
|
||||
def resolve_tp1_partial_fraction(management_rules: list[dict]) -> float:
|
||||
for item in management_rules:
|
||||
if str(item.get("code") or "") != "tp1_partial":
|
||||
continue
|
||||
try:
|
||||
fraction = float(item.get("fraction") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return min(max(fraction, 0.0), 1.0)
|
||||
return 0.0
|
||||
|
||||
|
||||
def classify_backtest_lifecycle_state(
|
||||
*,
|
||||
outcome: Optional[str],
|
||||
|
||||
@@ -489,6 +489,18 @@ def evaluate_account_risk_limits(account_state: Optional[AccountRiskState]) -> l
|
||||
|
||||
def build_execution_observation_from_backtest_result(backtest_result: Optional[dict]) -> dict:
|
||||
backtest_lifecycle_state = classify_backtest_lifecycle_state_from_result(backtest_result)
|
||||
meta = dict((backtest_result or {}).get("meta") or {})
|
||||
management_applied = dict(meta.get("management_applied") or {})
|
||||
remaining_closed = bool(management_applied.get("remaining_position_closed"))
|
||||
close_reason = backtest_lifecycle_state
|
||||
status = None
|
||||
lifecycle_state = backtest_lifecycle_state
|
||||
position_state = None
|
||||
if management_applied.get("tp1_partial") and remaining_closed:
|
||||
status = "closed"
|
||||
lifecycle_state = "tp1_partial_managed_to_close"
|
||||
position_state = "closed"
|
||||
close_reason = "tp1_partial_then_management_close"
|
||||
return {
|
||||
"source": "backtest_projection",
|
||||
"entry_triggered": bool(backtest_result and (backtest_result.get("outcome") in {"win", "loss", "breakeven"} or backtest_result.get("tp1_hit") or backtest_result.get("tp2_hit"))),
|
||||
@@ -497,7 +509,10 @@ def build_execution_observation_from_backtest_result(backtest_result: Optional[d
|
||||
"stop_loss_hit": backtest_lifecycle_state == BACKTEST_LIFECYCLE_STOPPED_OUT,
|
||||
"invalidated_before_entry": bool(backtest_result and backtest_result.get("invalidated_before_entry")),
|
||||
"backtest_lifecycle_state": backtest_lifecycle_state,
|
||||
"close_reason": backtest_lifecycle_state,
|
||||
"close_reason": close_reason,
|
||||
"status": status,
|
||||
"lifecycle_state": lifecycle_state,
|
||||
"position_state": position_state,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -124,6 +124,18 @@ class BacktestOutcomeTests(unittest.TestCase):
|
||||
self.assertAlmostEqual(compute_outcome_r_multiple("win", rr_tp2=2.5, cost_r=0.1), 2.4)
|
||||
self.assertAlmostEqual(compute_outcome_r_multiple("loss", cost_r=0.1), -1.1)
|
||||
self.assertEqual(compute_outcome_r_multiple("missed", cost_r=0.1), 0.0)
|
||||
self.assertAlmostEqual(
|
||||
compute_outcome_r_multiple(
|
||||
"breakeven",
|
||||
rr_tp1=1.0,
|
||||
cost_r=0.1,
|
||||
tp1_hit=True,
|
||||
tp1_partial_fraction=0.5,
|
||||
stopped_after_tp1=True,
|
||||
move_stop_applied=True,
|
||||
),
|
||||
0.4,
|
||||
)
|
||||
|
||||
def test_cost_model_reduces_executed_r_multiple(self) -> None:
|
||||
signal = BacktestSignal(
|
||||
@@ -160,6 +172,39 @@ class BacktestOutcomeTests(unittest.TestCase):
|
||||
self.assertAlmostEqual(outcome.meta["r_multiple"], 2.5 - expected_cost_r)
|
||||
self.assertEqual(outcome.meta["cost_model"]["fee_rate"], 0.0005)
|
||||
|
||||
def test_tp1_partial_and_move_stop_affect_backtest_r_multiple(self) -> None:
|
||||
signal = BacktestSignal(
|
||||
signal_id=40,
|
||||
side="buy",
|
||||
entry_low=100.0,
|
||||
entry_high=101.0,
|
||||
stop_loss=99.0,
|
||||
tp1=102.0,
|
||||
tp2=104.0,
|
||||
rr_tp1=1.0,
|
||||
rr_tp2=2.3333333333,
|
||||
management_rules=[
|
||||
{"code": "tp1_partial", "fraction": 0.5},
|
||||
{"code": "move_stop", "to": "breakeven"},
|
||||
],
|
||||
)
|
||||
|
||||
outcome = evaluate_signal_outcome(
|
||||
signal,
|
||||
[
|
||||
{"high": 102.1, "low": 100.2},
|
||||
{"high": 101.0, "low": 100.4},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(outcome.outcome, "breakeven")
|
||||
self.assertTrue(outcome.tp1_hit)
|
||||
self.assertFalse(outcome.tp2_hit)
|
||||
self.assertAlmostEqual(outcome.meta["r_multiple"], 0.5)
|
||||
self.assertTrue(outcome.meta["management_applied"]["tp1_partial"])
|
||||
self.assertTrue(outcome.meta["management_applied"]["move_stop"])
|
||||
self.assertTrue(outcome.meta["management_applied"]["remaining_position_closed"])
|
||||
|
||||
def test_replay_no_lookahead_tracks_visible_candles_and_confirmed_swings_at_entry(self) -> None:
|
||||
signal = BacktestSignal(
|
||||
signal_id=5,
|
||||
|
||||
@@ -338,6 +338,22 @@ class ExecutionAuditTests(unittest.TestCase):
|
||||
self.assertFalse(settled["meta"]["stop_loss_hit"])
|
||||
self.assertEqual(settled["meta"]["position_state"], "partially_closed")
|
||||
|
||||
def test_settle_execution_from_backtest_closes_when_management_finishes_remaining_position(self) -> None:
|
||||
settled = ExecutionService().settle_execution_from_backtest(
|
||||
trade_execution={"status": "open", "meta": {}, "closed_ts": None},
|
||||
backtest_result={
|
||||
"outcome": "breakeven",
|
||||
"tp1_hit": True,
|
||||
"tp2_hit": False,
|
||||
"invalidated_before_entry": False,
|
||||
"meta": {"management_applied": {"tp1_partial": True, "remaining_position_closed": True}},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(settled["status"], "closed")
|
||||
self.assertEqual(settled["meta"]["lifecycle_state"], "tp1_partial_managed_to_close")
|
||||
self.assertEqual(settled["meta"]["position_state"], "closed")
|
||||
|
||||
def test_settle_execution_from_backtest_closes_invalidated_before_entry(self) -> None:
|
||||
settled = ExecutionService().settle_execution_from_backtest(
|
||||
trade_execution={"status": "open", "meta": {}, "closed_ts": None},
|
||||
@@ -369,6 +385,21 @@ class ExecutionAuditTests(unittest.TestCase):
|
||||
self.assertEqual(observation["close_reason"], "tp2_closed")
|
||||
self.assertTrue(observation["entry_triggered"])
|
||||
|
||||
def test_build_execution_observation_closes_partial_trade_when_management_finishes_remaining_position(self) -> None:
|
||||
observation = build_execution_observation_from_backtest_result(
|
||||
{
|
||||
"outcome": "breakeven",
|
||||
"tp1_hit": True,
|
||||
"tp2_hit": False,
|
||||
"invalidated_before_entry": False,
|
||||
"meta": {"management_applied": {"tp1_partial": True, "remaining_position_closed": True}},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(observation["status"], "closed")
|
||||
self.assertEqual(observation["lifecycle_state"], "tp1_partial_managed_to_close")
|
||||
self.assertEqual(observation["position_state"], "closed")
|
||||
|
||||
def test_persist_execution_settlement_updates_repository_when_execution_has_id(self) -> None:
|
||||
repository = Mock()
|
||||
service = ExecutionService(repository=repository)
|
||||
|
||||
Reference in New Issue
Block a user