916 lines
40 KiB
Python
916 lines
40 KiB
Python
import os
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
|
|
|
|
from src.services.execution import (
|
|
AccountRiskState,
|
|
RiskContext,
|
|
build_execution_observation_from_backtest_result,
|
|
build_execution_ticket_snapshot,
|
|
ExecutionService,
|
|
classify_execution_lifecycle,
|
|
compute_entry_price,
|
|
evaluate_account_risk_limits,
|
|
evaluate_execution_decision,
|
|
trade_execution_matches_decision,
|
|
)
|
|
|
|
|
|
class ExecutionDecisionTests(unittest.TestCase):
|
|
def test_allows_paper_execution_for_valid_signal(self) -> None:
|
|
decision = evaluate_execution_decision(
|
|
signal={
|
|
"side": "buy",
|
|
"status": "awaiting_confirmation",
|
|
"entry_type": "limit",
|
|
"entry_low": 100.0,
|
|
"entry_high": 101.0,
|
|
"stop_loss": 99.0,
|
|
"tp1": 102.0,
|
|
"tp2": 104.0,
|
|
"session_code": "LONDON",
|
|
"execution_intensity": "medium",
|
|
"meta": {"execution_permission": True},
|
|
},
|
|
execution_mode="paper",
|
|
account_equity=10000.0,
|
|
risk_pct=0.01,
|
|
)
|
|
|
|
self.assertTrue(decision.allowed)
|
|
self.assertEqual(decision.status, "open")
|
|
self.assertEqual(decision.execution_mode, "paper")
|
|
self.assertGreater(decision.size, 0.0)
|
|
self.assertEqual(decision.reasons, [])
|
|
|
|
def test_semi_auto_uses_pending_status_when_allowed(self) -> None:
|
|
decision = evaluate_execution_decision(
|
|
signal={
|
|
"side": "sell",
|
|
"status": "awaiting_confirmation",
|
|
"entry_type": "limit",
|
|
"entry_low": 100.0,
|
|
"entry_high": 101.0,
|
|
"stop_loss": 102.0,
|
|
"tp1": 99.0,
|
|
"tp2": 97.0,
|
|
"session_code": "NY_AM",
|
|
"execution_intensity": "high",
|
|
"meta": {"execution_permission": True},
|
|
},
|
|
execution_mode="semi_auto",
|
|
account_equity=12000.0,
|
|
risk_pct=0.01,
|
|
)
|
|
|
|
self.assertTrue(decision.allowed)
|
|
self.assertEqual(decision.status, "pending")
|
|
self.assertEqual(decision.execution_mode, "semi_auto")
|
|
self.assertTrue(decision.requires_confirmation)
|
|
|
|
def test_rejects_signal_without_stop_or_allowed_session(self) -> None:
|
|
decision = evaluate_execution_decision(
|
|
signal={
|
|
"side": "buy",
|
|
"status": "awaiting_context",
|
|
"entry_type": "limit",
|
|
"entry_low": 100.0,
|
|
"entry_high": 101.0,
|
|
"stop_loss": None,
|
|
"tp1": 102.0,
|
|
"tp2": 104.0,
|
|
"session_code": "ASIA",
|
|
"execution_intensity": "low",
|
|
"meta": {"execution_permission": False},
|
|
},
|
|
execution_mode="paper",
|
|
account_equity=10000.0,
|
|
risk_pct=0.03,
|
|
)
|
|
|
|
self.assertFalse(decision.allowed)
|
|
self.assertEqual(decision.status, "rejected")
|
|
self.assertIn("missing_stop_loss", decision.reasons)
|
|
self.assertIn("session_not_allowed", decision.reasons)
|
|
self.assertIn("signal_not_executable", decision.reasons)
|
|
self.assertIn("signal_execution_permission_denied", decision.reasons)
|
|
self.assertIn("bias_execution_intensity_low", decision.reasons)
|
|
self.assertIn("risk_pct_out_of_bounds", decision.reasons)
|
|
|
|
def test_account_risk_limits_reject_execution(self) -> None:
|
|
account_state = AccountRiskState(
|
|
daily_pnl_pct=-0.02,
|
|
consecutive_losses=3,
|
|
open_positions=1,
|
|
same_side_open_positions=1,
|
|
)
|
|
decision = evaluate_execution_decision(
|
|
signal={
|
|
"side": "buy",
|
|
"status": "awaiting_confirmation",
|
|
"entry_type": "limit",
|
|
"entry_low": 100.0,
|
|
"entry_high": 101.0,
|
|
"stop_loss": 99.0,
|
|
"tp1": 102.0,
|
|
"tp2": 104.0,
|
|
"session_code": "LONDON",
|
|
"execution_intensity": "medium",
|
|
"meta": {"execution_permission": True},
|
|
},
|
|
execution_mode="paper",
|
|
account_equity=10000.0,
|
|
risk_pct=0.01,
|
|
account_state=account_state,
|
|
)
|
|
|
|
self.assertFalse(decision.allowed)
|
|
self.assertEqual(decision.status, "rejected")
|
|
self.assertEqual(
|
|
decision.reasons,
|
|
[
|
|
"daily_loss_limit_reached",
|
|
"consecutive_loss_limit_reached",
|
|
"max_open_positions_reached",
|
|
"same_side_exposure_limit_reached",
|
|
],
|
|
)
|
|
self.assertEqual(decision.meta["account_risk"]["consecutive_losses"], 3)
|
|
|
|
def test_account_risk_limits_allow_clean_state(self) -> None:
|
|
self.assertEqual(
|
|
evaluate_account_risk_limits(
|
|
AccountRiskState(
|
|
daily_pnl_pct=-0.005,
|
|
consecutive_losses=1,
|
|
open_positions=0,
|
|
same_side_open_positions=0,
|
|
)
|
|
),
|
|
[],
|
|
)
|
|
|
|
def test_account_risk_kill_switch_rejects_execution(self) -> None:
|
|
decision = evaluate_execution_decision(
|
|
signal={
|
|
"side": "buy",
|
|
"status": "awaiting_confirmation",
|
|
"entry_type": "limit",
|
|
"entry_low": 100.0,
|
|
"entry_high": 101.0,
|
|
"stop_loss": 99.0,
|
|
"tp1": 102.0,
|
|
"tp2": 104.0,
|
|
"session_code": "LONDON",
|
|
"execution_intensity": "medium",
|
|
"meta": {"execution_permission": True},
|
|
},
|
|
execution_mode="paper",
|
|
account_equity=10000.0,
|
|
risk_pct=0.01,
|
|
account_state=AccountRiskState(kill_switch=True),
|
|
)
|
|
|
|
self.assertFalse(decision.allowed)
|
|
self.assertEqual(decision.status, "rejected")
|
|
self.assertIn("execution_kill_switch_enabled", decision.reasons)
|
|
self.assertTrue(decision.meta["account_risk"]["kill_switch"])
|
|
|
|
def test_rejects_modeled_signal_without_structural_targets(self) -> None:
|
|
decision = evaluate_execution_decision(
|
|
signal={
|
|
"model_code": "SWEEP_MSS_FVG",
|
|
"side": "buy",
|
|
"status": "awaiting_confirmation",
|
|
"entry_type": "limit",
|
|
"entry_low": 100.0,
|
|
"entry_high": 101.0,
|
|
"stop_loss": 99.0,
|
|
"tp1": 102.0,
|
|
"tp2": 104.0,
|
|
"session_code": "LONDON",
|
|
"execution_intensity": "medium",
|
|
"meta": {"execution_permission": True, "target_plan": {}, "management_rules": []},
|
|
},
|
|
execution_mode="paper",
|
|
account_equity=10000.0,
|
|
risk_pct=0.01,
|
|
)
|
|
|
|
self.assertFalse(decision.allowed)
|
|
self.assertIn("missing_structural_targets", decision.reasons)
|
|
self.assertIn("missing_management_rules", decision.reasons)
|
|
|
|
|
|
class ExecutionServiceTests(unittest.TestCase):
|
|
def test_semi_auto_creation_requires_explicit_confirmation(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_signal_for_execution.return_value = {
|
|
"id": 1,
|
|
"side": "buy",
|
|
"status": "awaiting_confirmation",
|
|
"entry_type": "limit",
|
|
"entry_low": 100.0,
|
|
"entry_high": 101.0,
|
|
"stop_loss": 99.0,
|
|
"tp1": 102.0,
|
|
"tp2": 104.0,
|
|
"session_code": "LONDON",
|
|
"execution_intensity": "high",
|
|
"meta": {"execution_permission": True},
|
|
}
|
|
repository.fetch_latest_execution.return_value = None
|
|
repository.insert_trade_execution.return_value = 88
|
|
service = ExecutionService(repository=repository)
|
|
|
|
execution_id = service.create_execution(signal_id=1, execution_mode="semi_auto")
|
|
|
|
self.assertIsNone(execution_id)
|
|
repository.insert_trade_execution.assert_not_called()
|
|
|
|
def test_semi_auto_creation_allows_confirmed_execution(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_signal_for_execution.return_value = {
|
|
"id": 1,
|
|
"side": "buy",
|
|
"status": "awaiting_confirmation",
|
|
"entry_type": "limit",
|
|
"entry_low": 100.0,
|
|
"entry_high": 101.0,
|
|
"stop_loss": 99.0,
|
|
"tp1": 102.0,
|
|
"tp2": 104.0,
|
|
"session_code": "LONDON",
|
|
"execution_intensity": "high",
|
|
"meta": {"execution_permission": True},
|
|
}
|
|
repository.fetch_latest_execution.return_value = None
|
|
repository.insert_trade_execution.return_value = 89
|
|
service = ExecutionService(repository=repository)
|
|
|
|
execution_id = service.create_execution(signal_id=1, execution_mode="semi_auto", confirmed=True)
|
|
|
|
self.assertIsNone(execution_id)
|
|
repository.insert_trade_execution.assert_not_called()
|
|
|
|
def test_semi_auto_ticket_must_be_confirmed_before_pending_execution(self) -> None:
|
|
repository = Mock()
|
|
ticket_row = {
|
|
"id": 51,
|
|
"signal_id": 1,
|
|
"execution_mode": "semi_auto",
|
|
"status": "awaiting_confirmation",
|
|
"created_ts": "created",
|
|
"expires_ts": "later",
|
|
"confirmed_by": None,
|
|
"confirmed_at": None,
|
|
"execution_id": None,
|
|
"ticket_snapshot": {},
|
|
}
|
|
repository.fetch_signal_for_execution.return_value = _valid_signal()
|
|
repository.fetch_latest_execution_ticket.return_value = None
|
|
repository.insert_execution_ticket.return_value = 51
|
|
repository.fetch_execution_ticket_by_id.side_effect = lambda ticket_id: dict(ticket_row)
|
|
repository.fetch_latest_execution.return_value = None
|
|
repository.insert_trade_execution.return_value = 89
|
|
service = ExecutionService(repository=repository)
|
|
service.build_risk_context_for_signal = Mock(return_value=RiskContext())
|
|
repository.update_execution_ticket.side_effect = lambda **kwargs: ticket_row.update(
|
|
{
|
|
"status": kwargs["status"],
|
|
"ticket_snapshot": kwargs["ticket_snapshot"],
|
|
"confirmed_by": kwargs.get("confirmed_by"),
|
|
"confirmed_at": kwargs.get("confirmed_at"),
|
|
"execution_id": kwargs.get("execution_id"),
|
|
}
|
|
)
|
|
|
|
ticket = service.create_execution_ticket(signal_id=1, execution_mode="semi_auto")
|
|
self.assertIsNotNone(ticket)
|
|
repository.insert_trade_execution.assert_not_called()
|
|
|
|
confirmed = service.confirm_execution_ticket(51, confirmed_by="alice")
|
|
|
|
self.assertIsNotNone(confirmed)
|
|
assert confirmed is not None
|
|
self.assertEqual(confirmed.status, "confirmed")
|
|
repository.insert_trade_execution.assert_called_once()
|
|
kwargs = repository.insert_trade_execution.call_args.kwargs
|
|
self.assertEqual(kwargs["status"], "pending")
|
|
self.assertEqual(kwargs["meta"]["execution_ticket_id"], 51)
|
|
self.assertEqual(kwargs["meta"]["confirmed_by"], "alice")
|
|
|
|
def test_ticket_rejects_when_risk_check_fails_before_creation(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_signal_for_execution.return_value = _valid_signal()
|
|
repository.fetch_latest_execution_ticket.return_value = None
|
|
repository.insert_execution_ticket.return_value = 61
|
|
service = ExecutionService(repository=repository)
|
|
service.build_risk_context_for_signal = Mock(
|
|
return_value=RiskContext.from_account_state(AccountRiskState(kill_switch=True))
|
|
)
|
|
|
|
ticket = service.create_execution_ticket(
|
|
signal_id=1,
|
|
execution_mode="semi_auto",
|
|
risk_context=None,
|
|
account_state=AccountRiskState(kill_switch=True),
|
|
)
|
|
|
|
self.assertIsNotNone(ticket)
|
|
assert ticket is not None
|
|
self.assertEqual(ticket.status, "rejected")
|
|
self.assertIn("execution_kill_switch_enabled", ticket.reasons)
|
|
repository.insert_trade_execution.assert_not_called()
|
|
|
|
def test_confirm_ticket_rejects_when_recheck_fails(self) -> None:
|
|
repository = Mock()
|
|
ticket_row = {
|
|
"id": 71,
|
|
"signal_id": 1,
|
|
"execution_mode": "semi_auto",
|
|
"status": "awaiting_confirmation",
|
|
"created_ts": "now",
|
|
"expires_ts": None,
|
|
"confirmed_by": None,
|
|
"confirmed_at": None,
|
|
"execution_id": None,
|
|
"ticket_snapshot": {},
|
|
}
|
|
repository.fetch_signal_for_execution.return_value = _valid_signal()
|
|
repository.fetch_execution_ticket_by_id.side_effect = lambda ticket_id: dict(ticket_row)
|
|
repository.fetch_latest_execution.return_value = None
|
|
service = ExecutionService(repository=repository)
|
|
service.build_risk_context_for_signal = Mock(
|
|
return_value=RiskContext.from_account_state(AccountRiskState(kill_switch=True))
|
|
)
|
|
repository.update_execution_ticket.side_effect = lambda **kwargs: ticket_row.update(
|
|
{
|
|
"status": kwargs["status"],
|
|
"ticket_snapshot": kwargs["ticket_snapshot"],
|
|
"confirmed_by": kwargs.get("confirmed_by"),
|
|
"confirmed_at": kwargs.get("confirmed_at"),
|
|
"execution_id": kwargs.get("execution_id"),
|
|
}
|
|
)
|
|
|
|
confirmed = service.confirm_execution_ticket(
|
|
71,
|
|
confirmed_by="bob",
|
|
account_state=AccountRiskState(kill_switch=True),
|
|
)
|
|
|
|
self.assertIsNotNone(confirmed)
|
|
assert confirmed is not None
|
|
self.assertEqual(confirmed.status, "rejected")
|
|
repository.insert_trade_execution.assert_not_called()
|
|
repository.update_execution_ticket.assert_called_once()
|
|
|
|
def test_ticket_snapshot_is_complete_for_replay(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_signal_for_execution.return_value = _valid_signal()
|
|
risk_context = RiskContext()
|
|
decision = evaluate_execution_decision(
|
|
signal=_valid_signal(),
|
|
execution_mode="paper",
|
|
account_equity=10000.0,
|
|
risk_pct=0.01,
|
|
)
|
|
snapshot = build_execution_ticket_snapshot(
|
|
signal=_valid_signal(),
|
|
decision=decision,
|
|
risk_context=risk_context,
|
|
execution_mode="semi_auto",
|
|
created_ts="created",
|
|
status="awaiting_confirmation",
|
|
account_equity=10000.0,
|
|
risk_pct=0.01,
|
|
)
|
|
|
|
self.assertEqual(snapshot["signal"]["signal_id"], 1)
|
|
self.assertEqual(snapshot["decision"]["status"], "open")
|
|
self.assertIn("risk_context", snapshot)
|
|
self.assertEqual(snapshot["execution_mode"], "semi_auto")
|
|
self.assertEqual(snapshot["signal"]["model_code"], "SWEEP_MSS_FVG")
|
|
|
|
def test_paper_ticket_confirmation_creates_open_execution(self) -> None:
|
|
repository = Mock()
|
|
ticket_row = {
|
|
"id": 81,
|
|
"signal_id": 1,
|
|
"execution_mode": "paper",
|
|
"status": "awaiting_confirmation",
|
|
"created_ts": "created",
|
|
"expires_ts": "later",
|
|
"confirmed_by": None,
|
|
"confirmed_at": None,
|
|
"execution_id": None,
|
|
"ticket_snapshot": {},
|
|
}
|
|
repository.fetch_signal_for_execution.return_value = _valid_signal()
|
|
repository.fetch_latest_execution_ticket.return_value = None
|
|
repository.insert_execution_ticket.return_value = 81
|
|
repository.fetch_execution_ticket_by_id.side_effect = lambda ticket_id: dict(ticket_row)
|
|
repository.fetch_latest_execution.return_value = None
|
|
repository.insert_trade_execution.return_value = 91
|
|
repository.update_execution_ticket.side_effect = lambda **kwargs: ticket_row.update(
|
|
{
|
|
"status": kwargs["status"],
|
|
"ticket_snapshot": kwargs["ticket_snapshot"],
|
|
"confirmed_by": kwargs.get("confirmed_by"),
|
|
"confirmed_at": kwargs.get("confirmed_at"),
|
|
"execution_id": kwargs.get("execution_id"),
|
|
}
|
|
)
|
|
service = ExecutionService(repository=repository)
|
|
service.build_risk_context_for_signal = Mock(return_value=RiskContext())
|
|
|
|
ticket = service.create_execution_ticket(signal_id=1, execution_mode="paper")
|
|
self.assertIsNotNone(ticket)
|
|
|
|
confirmed = service.confirm_execution_ticket(81, confirmed_by="paper_user")
|
|
|
|
self.assertIsNotNone(confirmed)
|
|
assert confirmed is not None
|
|
self.assertEqual(confirmed.status, "confirmed")
|
|
kwargs = repository.insert_trade_execution.call_args.kwargs
|
|
self.assertEqual(kwargs["status"], "open")
|
|
self.assertEqual(kwargs["meta"]["execution_ticket_id"], 81)
|
|
self.assertEqual(kwargs["meta"]["confirmed_by"], "paper_user")
|
|
|
|
def test_submit_confirmed_ticket_creates_execution_and_updates_ticket(self) -> None:
|
|
repository = Mock()
|
|
ticket_row = {
|
|
"id": 91,
|
|
"signal_id": 1,
|
|
"execution_mode": "semi_auto",
|
|
"status": "confirmed",
|
|
"created_ts": "created",
|
|
"expires_ts": "later",
|
|
"confirmed_by": "alice",
|
|
"confirmed_at": "2026-05-06T00:00:00+00:00",
|
|
"execution_id": None,
|
|
"ticket_snapshot": {},
|
|
}
|
|
repository.fetch_execution_ticket_by_id.side_effect = lambda ticket_id: dict(ticket_row)
|
|
repository.fetch_signal_for_execution.return_value = _valid_signal()
|
|
repository.fetch_latest_execution.return_value = None
|
|
repository.insert_trade_execution.return_value = 101
|
|
repository.update_execution_ticket.side_effect = lambda **kwargs: ticket_row.update(
|
|
{
|
|
"status": kwargs["status"],
|
|
"ticket_snapshot": kwargs["ticket_snapshot"],
|
|
"execution_id": kwargs.get("execution_id"),
|
|
}
|
|
)
|
|
service = ExecutionService(repository=repository)
|
|
service.build_risk_context_for_signal = Mock(return_value=RiskContext())
|
|
|
|
result = service.submit_execution_ticket(91)
|
|
|
|
self.assertIsNotNone(result)
|
|
assert result is not None
|
|
self.assertEqual(result.status, "submitted")
|
|
self.assertEqual(result.execution_id, 101)
|
|
repository.insert_trade_execution.assert_called_once()
|
|
repository.update_execution_ticket.assert_called_once()
|
|
|
|
def test_build_risk_context_for_signal_embeds_broker_state(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_latest_backtest_quality_gate.return_value = {"passed": True}
|
|
market_state_service = Mock()
|
|
market_state_service.build_market_state.return_value = {"freshness": {"code": "market_data_fresh"}}
|
|
market_state_service.repository = repository
|
|
service = ExecutionService(repository=Mock(), market_state_service=market_state_service)
|
|
|
|
from src.services.broker.broker_read_model_service import BrokerReadModelSnapshot
|
|
with patch("src.services.execution.execution_service.BrokerReadModelService.fetch_snapshot") as fetch_snapshot, patch(
|
|
"src.services.execution.execution_service.BrokerReadModelService.build_account_risk_state"
|
|
) as build_account_state:
|
|
fetch_snapshot.return_value = BrokerReadModelSnapshot(
|
|
status="broker_state_ok",
|
|
provider="okx_read_only",
|
|
fetched_ts=None,
|
|
account={"equity": 1000.0, "daily_pnl": -10.0},
|
|
positions=[{"instrument_id": "BTC-USDT-SWAP", "side": "buy"}],
|
|
orders=[],
|
|
fills=[],
|
|
alerts=[],
|
|
error=None,
|
|
)
|
|
build_account_state.return_value = AccountRiskState(open_positions=1, same_side_open_positions=1)
|
|
context = service.build_risk_context_for_signal(_valid_signal())
|
|
|
|
self.assertEqual(context.open_positions, 1)
|
|
self.assertEqual(context.same_side_open_positions, 1)
|
|
self.assertEqual(context.broker_state["status"], "broker_state_ok")
|
|
fetch_snapshot.assert_called_once()
|
|
build_account_state.assert_called_once()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
def test_close_execution_by_id_sets_closed_state(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_execution_by_id.return_value = {"id": 13, "status": "open", "meta": {}, "closed_ts": None}
|
|
service = ExecutionService(repository=repository)
|
|
|
|
settled = service.close_execution_by_id(13)
|
|
|
|
self.assertEqual(settled["status"], "closed")
|
|
self.assertEqual(settled["meta"]["close_reason"], "manual_close")
|
|
|
|
|
|
class ExecutionAuditTests(unittest.TestCase):
|
|
def test_classifies_lifecycle_states(self) -> None:
|
|
self.assertEqual(classify_execution_lifecycle("pending", None), "awaiting_confirmation")
|
|
self.assertEqual(classify_execution_lifecycle("open", {"tp1_hit": True, "tp2_hit": False, "outcome": "breakeven", "invalidated_before_entry": False}), "tp1_reached")
|
|
self.assertEqual(classify_execution_lifecycle("open", {"tp1_hit": True, "tp2_hit": True, "outcome": "win", "invalidated_before_entry": False}), "tp2_closed")
|
|
self.assertEqual(classify_execution_lifecycle("open", {"tp1_hit": False, "tp2_hit": False, "outcome": "loss", "invalidated_before_entry": False}), "stopped_out")
|
|
self.assertEqual(classify_execution_lifecycle("open", {"tp1_hit": False, "tp2_hit": False, "outcome": "missed", "invalidated_before_entry": False}), "missed")
|
|
self.assertEqual(classify_execution_lifecycle("open", {"tp1_hit": False, "tp2_hit": False, "outcome": "missed", "invalidated_before_entry": True}), "invalidated_before_entry")
|
|
self.assertEqual(classify_execution_lifecycle("cancelled", None), "cancelled")
|
|
self.assertEqual(classify_execution_lifecycle("partially_closed", None), "tp1_reached")
|
|
|
|
def test_audit_execution_reports_review_state(self) -> None:
|
|
audit = ExecutionService().audit_execution(
|
|
trade_execution={"status": "rejected", "meta": {"risk_checks": ["session_not_allowed"]}},
|
|
backtest_result={"outcome": "loss", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": False},
|
|
)
|
|
|
|
self.assertEqual(audit["audit_status"], "review")
|
|
self.assertFalse(audit["is_actionable"])
|
|
self.assertIn("execution_rejected", audit["error_tags"])
|
|
self.assertIn("session_not_allowed", audit["error_tags"])
|
|
self.assertEqual(audit["lifecycle_state"], "rejected")
|
|
self.assertEqual(audit["backtest_lifecycle_state"], "stopped_out")
|
|
self.assertTrue(audit["stop_loss_hit"])
|
|
|
|
def test_apply_execution_observation_supports_manual_cancellation(self) -> None:
|
|
settled = ExecutionService().apply_execution_observation(
|
|
trade_execution={"status": "pending", "meta": {}, "closed_ts": None},
|
|
observation={"source": "manual", "status": "cancelled", "close_reason": "cancelled"},
|
|
)
|
|
|
|
self.assertEqual(settled["status"], "cancelled")
|
|
self.assertEqual(settled["meta"]["lifecycle_state"], "cancelled")
|
|
self.assertEqual(settled["meta"]["settlement_source"], "manual")
|
|
self.assertEqual(settled["meta"]["position_state"], "cancelled")
|
|
self.assertIsNotNone(settled["closed_ts"])
|
|
|
|
def test_apply_execution_observation_marks_tp1_progress_as_partially_closed(self) -> None:
|
|
settled = ExecutionService().apply_execution_observation(
|
|
trade_execution={"status": "open", "meta": {}, "closed_ts": None},
|
|
observation={"source": "manual", "entry_triggered": True, "tp1_hit": True},
|
|
)
|
|
|
|
self.assertEqual(settled["status"], "partially_closed")
|
|
self.assertEqual(settled["meta"]["lifecycle_state"], "tp1_reached")
|
|
self.assertEqual(settled["meta"]["position_state"], "partially_closed")
|
|
self.assertEqual(settled["meta"]["fill_state"], "filled")
|
|
|
|
def test_settle_execution_from_backtest_closes_tp2_hit(self) -> None:
|
|
settled = ExecutionService().settle_execution_from_backtest(
|
|
trade_execution={"status": "open", "meta": {}, "closed_ts": None},
|
|
backtest_result={"outcome": "win", "tp1_hit": True, "tp2_hit": True, "invalidated_before_entry": False},
|
|
)
|
|
|
|
self.assertEqual(settled["status"], "closed")
|
|
self.assertEqual(settled["meta"]["lifecycle_state"], "tp2_closed")
|
|
self.assertTrue(settled["meta"]["tp1_hit"])
|
|
self.assertTrue(settled["meta"]["tp2_hit"])
|
|
self.assertEqual(settled["meta"]["settlement_source"], "backtest_projection")
|
|
self.assertIsNotNone(settled["closed_ts"])
|
|
|
|
def test_settle_execution_from_backtest_marks_stopped_out(self) -> None:
|
|
settled = ExecutionService().settle_execution_from_backtest(
|
|
trade_execution={"status": "open", "meta": {}, "closed_ts": None},
|
|
backtest_result={"outcome": "loss", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": False},
|
|
)
|
|
|
|
self.assertEqual(settled["status"], "closed")
|
|
self.assertEqual(settled["meta"]["lifecycle_state"], "stopped_out")
|
|
self.assertTrue(settled["meta"]["stop_loss_hit"])
|
|
|
|
def test_settle_execution_from_backtest_keeps_tp1_progress_open(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},
|
|
)
|
|
|
|
self.assertEqual(settled["status"], "partially_closed")
|
|
self.assertEqual(settled["meta"]["lifecycle_state"], "tp1_reached")
|
|
self.assertTrue(settled["meta"]["tp1_hit"])
|
|
self.assertFalse(settled["meta"]["tp2_hit"])
|
|
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},
|
|
backtest_result={"outcome": "missed", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": True},
|
|
)
|
|
|
|
self.assertEqual(settled["status"], "closed")
|
|
self.assertEqual(settled["meta"]["lifecycle_state"], "invalidated_before_entry")
|
|
self.assertEqual(settled["meta"]["backtest_lifecycle_state"], "invalidated_before_entry")
|
|
self.assertTrue(settled["meta"]["invalidated_before_entry"])
|
|
|
|
def test_settle_execution_from_backtest_closes_missed_without_entry_invalidation(self) -> None:
|
|
settled = ExecutionService().settle_execution_from_backtest(
|
|
trade_execution={"status": "open", "meta": {}, "closed_ts": None},
|
|
backtest_result={"outcome": "missed", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": False},
|
|
)
|
|
|
|
self.assertEqual(settled["status"], "closed")
|
|
self.assertEqual(settled["meta"]["lifecycle_state"], "missed")
|
|
self.assertEqual(settled["meta"]["backtest_lifecycle_state"], "missed")
|
|
self.assertFalse(settled["meta"]["invalidated_before_entry"])
|
|
|
|
def test_build_execution_observation_from_backtest_result_marks_projection_source(self) -> None:
|
|
observation = build_execution_observation_from_backtest_result(
|
|
{"outcome": "win", "tp1_hit": True, "tp2_hit": True, "invalidated_before_entry": False}
|
|
)
|
|
|
|
self.assertEqual(observation["source"], "backtest_projection")
|
|
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)
|
|
|
|
settled = service.persist_execution_settlement(
|
|
trade_execution={"id": 7, "status": "open", "meta": {}, "closed_ts": None},
|
|
backtest_result={"outcome": "win", "tp1_hit": True, "tp2_hit": True, "invalidated_before_entry": False},
|
|
)
|
|
|
|
self.assertEqual(settled["status"], "closed")
|
|
repository.update_trade_execution_lifecycle.assert_called_once()
|
|
kwargs = repository.update_trade_execution_lifecycle.call_args.kwargs
|
|
self.assertEqual(kwargs["execution_id"], 7)
|
|
self.assertEqual(kwargs["status"], "closed")
|
|
self.assertEqual(kwargs["meta"]["lifecycle_state"], "tp2_closed")
|
|
|
|
def test_settle_execution_by_id_fetches_and_persists(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_execution_by_id.return_value = {"id": 8, "status": "open", "meta": {}, "closed_ts": None}
|
|
service = ExecutionService(repository=repository)
|
|
|
|
settled = service.settle_execution_by_id(
|
|
execution_id=8,
|
|
backtest_result={"outcome": "loss", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": False},
|
|
)
|
|
|
|
self.assertEqual(settled["status"], "closed")
|
|
self.assertEqual(settled["meta"]["lifecycle_state"], "stopped_out")
|
|
repository.fetch_execution_by_id.assert_called_once_with(8)
|
|
repository.update_trade_execution_lifecycle.assert_called_once()
|
|
|
|
def test_settle_execution_by_id_returns_none_when_missing(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_execution_by_id.return_value = None
|
|
service = ExecutionService(repository=repository)
|
|
|
|
settled = service.settle_execution_by_id(execution_id=404, backtest_result={"outcome": "missed", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": True})
|
|
|
|
self.assertIsNone(settled)
|
|
repository.update_trade_execution_lifecycle.assert_not_called()
|
|
|
|
def test_settle_execution_by_id_returns_closed_tp2_sample(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_execution_by_id.return_value = {"id": 9, "status": "open", "meta": {}, "closed_ts": None}
|
|
service = ExecutionService(repository=repository)
|
|
|
|
settled = service.settle_execution_by_id(
|
|
execution_id=9,
|
|
backtest_result={"outcome": "win", "tp1_hit": True, "tp2_hit": True, "invalidated_before_entry": False},
|
|
)
|
|
|
|
self.assertEqual(settled["status"], "closed")
|
|
self.assertEqual(settled["meta"]["lifecycle_state"], "tp2_closed")
|
|
|
|
def test_settle_execution_by_id_from_latest_backtest_fetches_result(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_execution_by_id.return_value = {"id": 10, "signal_id": 3, "status": "open", "meta": {}, "closed_ts": None}
|
|
repository.fetch_latest_backtest_result_for_signal.return_value = {"outcome": "loss", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": False}
|
|
service = ExecutionService(repository=repository)
|
|
|
|
settled = service.settle_execution_by_id_from_latest_backtest(execution_id=10)
|
|
|
|
self.assertEqual(settled["status"], "closed")
|
|
self.assertEqual(settled["meta"]["lifecycle_state"], "stopped_out")
|
|
repository.fetch_execution_by_id.assert_called_once_with(10)
|
|
repository.fetch_latest_backtest_result_for_signal.assert_called_once_with(3)
|
|
repository.update_trade_execution_lifecycle.assert_called_once()
|
|
|
|
def test_settle_execution_by_id_from_latest_backtest_returns_none_without_result(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_execution_by_id.return_value = {"id": 11, "signal_id": 4, "status": "open", "meta": {}, "closed_ts": None}
|
|
repository.fetch_latest_backtest_result_for_signal.return_value = None
|
|
service = ExecutionService(repository=repository)
|
|
|
|
settled = service.settle_execution_by_id_from_latest_backtest(execution_id=11)
|
|
|
|
self.assertIsNone(settled)
|
|
repository.update_trade_execution_lifecycle.assert_not_called()
|
|
|
|
def test_settle_latest_execution_for_signal_uses_latest_execution(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_latest_execution.return_value = {"id": 12, "signal_id": 5, "status": "open", "meta": {}, "closed_ts": None}
|
|
repository.fetch_latest_backtest_result_for_signal.return_value = {"outcome": "win", "tp1_hit": True, "tp2_hit": True, "invalidated_before_entry": False}
|
|
service = ExecutionService(repository=repository)
|
|
|
|
settled = service.settle_latest_execution_for_signal(signal_id=5, execution_mode="paper")
|
|
|
|
self.assertEqual(settled["status"], "closed")
|
|
self.assertEqual(settled["meta"]["lifecycle_state"], "tp2_closed")
|
|
repository.fetch_latest_execution.assert_called_once_with(signal_id=5, execution_mode="paper")
|
|
repository.fetch_latest_backtest_result_for_signal.assert_called_once_with(5)
|
|
repository.update_trade_execution_lifecycle.assert_called_once()
|
|
|
|
def test_settle_latest_execution_for_signal_can_use_manual_observation(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_latest_execution.return_value = {"id": 12, "signal_id": 5, "status": "pending", "meta": {}, "closed_ts": None}
|
|
service = ExecutionService(repository=repository)
|
|
|
|
settled = service.settle_latest_execution_for_signal(
|
|
signal_id=5,
|
|
execution_mode="semi_auto",
|
|
observation={"source": "manual", "status": "cancelled", "close_reason": "cancelled"},
|
|
)
|
|
|
|
self.assertEqual(settled["status"], "cancelled")
|
|
self.assertEqual(settled["meta"]["settlement_source"], "manual")
|
|
repository.fetch_latest_backtest_result_for_signal.assert_not_called()
|
|
repository.update_trade_execution_lifecycle.assert_called_once()
|
|
|
|
def test_settle_latest_execution_for_signal_returns_none_without_execution(self) -> None:
|
|
repository = Mock()
|
|
repository.fetch_latest_execution.return_value = None
|
|
service = ExecutionService(repository=repository)
|
|
|
|
settled = service.settle_latest_execution_for_signal(signal_id=6)
|
|
|
|
self.assertIsNone(settled)
|
|
repository.fetch_latest_backtest_result_for_signal.assert_not_called()
|
|
repository.update_trade_execution_lifecycle.assert_not_called()
|
|
|
|
|
|
class ExecutionPricingTests(unittest.TestCase):
|
|
def test_entry_price_uses_midpoint_when_range_exists(self) -> None:
|
|
self.assertEqual(
|
|
compute_entry_price({"entry_low": 100.0, "entry_high": 102.0}),
|
|
101.0,
|
|
)
|
|
|
|
def test_entry_price_falls_back_to_single_bound(self) -> None:
|
|
self.assertEqual(compute_entry_price({"entry_low": 99.5, "entry_high": None}), 99.5)
|
|
|
|
|
|
class ExecutionIdempotencyTests(unittest.TestCase):
|
|
def test_detects_equivalent_open_execution(self) -> None:
|
|
decision = evaluate_execution_decision(
|
|
signal={
|
|
"side": "buy",
|
|
"status": "awaiting_confirmation",
|
|
"entry_type": "limit",
|
|
"entry_low": 100.0,
|
|
"entry_high": 101.0,
|
|
"stop_loss": 99.0,
|
|
"tp1": 102.0,
|
|
"tp2": 104.0,
|
|
"session_code": "LONDON",
|
|
"execution_intensity": "medium",
|
|
"daily_bias": "bullish",
|
|
"draw_side": "buy_side",
|
|
"meta": {"execution_permission": True},
|
|
},
|
|
execution_mode="paper",
|
|
account_equity=10000.0,
|
|
risk_pct=0.01,
|
|
)
|
|
existing = {
|
|
"execution_mode": "paper",
|
|
"status": "open",
|
|
"side": "buy",
|
|
"entry_price": decision.entry_price,
|
|
"stop_loss": decision.meta["stop_loss"],
|
|
"take_profit_1": decision.meta["tp1"],
|
|
"take_profit_2": decision.meta["tp2"],
|
|
"size": decision.size,
|
|
"risk_pct": decision.risk_pct,
|
|
"meta": decision.meta,
|
|
}
|
|
|
|
self.assertTrue(trade_execution_matches_decision(existing, decision))
|
|
|
|
def test_detects_equivalent_open_execution_with_rounding_differences(self) -> None:
|
|
decision = evaluate_execution_decision(
|
|
signal={
|
|
"side": "buy",
|
|
"status": "awaiting_confirmation",
|
|
"entry_type": "limit",
|
|
"entry_low": 77200.0,
|
|
"entry_high": 77223.3,
|
|
"stop_loss": 77122.8,
|
|
"tp1": 77300.5233,
|
|
"tp2": 77454.9699,
|
|
"session_code": "LONDON",
|
|
"execution_intensity": "medium",
|
|
"daily_bias": "bullish",
|
|
"draw_side": "buy_side",
|
|
"meta": {"execution_permission": True},
|
|
},
|
|
execution_mode="paper",
|
|
account_equity=10000.0,
|
|
risk_pct=0.01,
|
|
)
|
|
existing = {
|
|
"execution_mode": "paper",
|
|
"status": "open",
|
|
"side": "buy",
|
|
"entry_price": "77211.6500000000",
|
|
"stop_loss": "77122.8000000000",
|
|
"take_profit_1": "77300.5233000000",
|
|
"take_profit_2": "77454.9699000000",
|
|
"size": "1.1254924029",
|
|
"risk_pct": "0.0100",
|
|
"meta": decision.meta,
|
|
}
|
|
|
|
self.assertTrue(trade_execution_matches_decision(existing, decision))
|
|
|
|
|
|
def _valid_signal() -> dict:
|
|
target_plan = {
|
|
"status": "ready",
|
|
"tp1": {"price": 102.0, "objective_layer": "IRL", "source": {"type": "session_pivot", "table": "market_sessions", "field": "high"}},
|
|
"tp2": {"price": 104.0, "objective_layer": "ERL", "source": {"type": "dealing_range_boundary", "table": "dealing_ranges", "field": "high"}},
|
|
"management_rules": [{"code": "tp1_partial", "fraction": 0.5}],
|
|
}
|
|
return {
|
|
"id": 1,
|
|
"instrument_id": 1,
|
|
"model_code": "SWEEP_MSS_FVG",
|
|
"side": "buy",
|
|
"status": "awaiting_confirmation",
|
|
"entry_type": "limit",
|
|
"entry_low": 100.0,
|
|
"entry_high": 101.0,
|
|
"stop_loss": 99.0,
|
|
"tp1": 102.0,
|
|
"tp2": 104.0,
|
|
"session_code": "LONDON",
|
|
"execution_intensity": "high",
|
|
"daily_bias": "bullish",
|
|
"draw_side": "buy_side",
|
|
"created_ts": "2026-05-06T00:00:00+00:00",
|
|
"expires_ts": "2026-05-06T04:00:00+00:00",
|
|
"invalidations": [],
|
|
"evidence": {"mss_id": 1, "fvg_id": 2},
|
|
"target_plan": target_plan,
|
|
"meta": {
|
|
"execution_permission": True,
|
|
"management_rules": target_plan["management_rules"],
|
|
},
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|