Skip to content

Commit 7b2db68

Browse files
Pigbibicodex
andauthored
fix: make rollback manager proposal-only (#378)
Co-authored-by: Codex <noreply@openai.com>
1 parent ec714c3 commit 7b2db68

5 files changed

Lines changed: 93 additions & 20 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,7 @@ Notifications are dispatched through a shared `NotificationPublisher` that wraps
488488
| `drift_detector` | Detect strategy performance drift vs. backtest expectations |
489489
| `performance_monitor` | Real-time performance tracking (CAGR, Sharpe, drawdown) |
490490
| `ai_reviewer` | LLM-based strategy code review and performance commentary |
491-
| `rollback_manager` | Manage strategy version rollbacks with audit trail |
491+
| `rollback_manager` | Detect degradation and record no-order rollback proposals with an audit trail; platform-specific, owner-authorized rollback remains separate |
492492
| `shadow_validator` | Validate shadow-mode execution outcomes against live results |
493493
| `health_dashboard` | Aggregate health metrics across platforms |
494494
| `audit_log` | Immutable audit log for strategy changes |

src/quant_platform_kit/strategy_lifecycle/contracts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ class UpdateStage(str, enum.Enum):
541541
DENIED = "denied"
542542
DEPLOYED = "deployed"
543543
RUNTIME_CONFIRMED = "runtime_confirmed"
544+
ROLLBACK_PROPOSED = "rollback_proposed"
544545
ROLLED_BACK = "rolled_back"
545546

546547

src/quant_platform_kit/strategy_lifecycle/rollback_manager.py

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
"""Rollback managermonitors post-deployment performance and auto-rolls back on degradation."""
1+
"""Rollback monitorrecords no-order rollback proposals on degradation."""
22

33
from __future__ import annotations
44

55
from collections.abc import Mapping
6-
from datetime import datetime, timezone
76
from typing import Any
87

98
import numpy as np
@@ -13,20 +12,20 @@
1312
from quant_platform_kit.strategy_lifecycle.performance_store import PerformanceStore
1413
from quant_platform_kit.strategy_lifecycle.update_policy import UpdatePolicy
1514

16-
17-
def _now_iso() -> str:
18-
return datetime.now(timezone.utc).isoformat()
19-
20-
2115
class RollbackManager:
22-
"""Monitors post-update performance and triggers rollback if needed.
16+
"""Monitors post-update performance and records rollback proposals.
17+
18+
This class has no deployment, runtime-target, broker, or order adapter.
19+
A performance breach is therefore an auditable proposal, not an executed
20+
rollback. A platform-specific, owner-authorized control path must provide
21+
any actual rollback separately.
2322
2423
Usage::
2524
2625
mgr = RollbackManager(store=store, policy=policy)
2726
decision = mgr.evaluate("global_etf_rotation", domain="us_equity")
2827
if decision["should_rollback"]:
29-
mgr.rollback(...)
28+
mgr.propose_rollback(...)
3029
"""
3130

3231
def __init__(
@@ -62,16 +61,25 @@ def evaluate(
6261
deployed_max_dd: Max drawdown at time of deployment.
6362
6463
Returns:
65-
Dict with "should_rollback", "reason", "live_sharpe", "live_max_dd".
64+
Dict with a rollback recommendation and an explicit
65+
``rollback_execution_authorized=False`` boundary.
6666
"""
6767
# Get latest live performance
6868
latest_snapshot = self._store.load_latest_snapshot(domain, strategy_profile)
6969
if latest_snapshot is None:
70-
return {"should_rollback": False, "reason": "No live performance data available"}
70+
return {
71+
"should_rollback": False,
72+
"reason": "No live performance data available",
73+
"rollback_execution_authorized": False,
74+
}
7175

7276
ref_window = latest_snapshot.windows.get(126) or latest_snapshot.windows.get(252)
7377
if ref_window is None:
74-
return {"should_rollback": False, "reason": "No window metrics available"}
78+
return {
79+
"should_rollback": False,
80+
"reason": "No window metrics available",
81+
"rollback_execution_authorized": False,
82+
}
7583

7684
live_sharpe = ref_window.sharpe_ratio
7785
live_max_dd = ref_window.max_drawdown
@@ -102,9 +110,10 @@ def evaluate(
102110
"reason": "; ".join(reasons) if reasons else "Performance within acceptable range",
103111
"live_sharpe": live_sharpe,
104112
"live_max_dd": live_max_dd,
113+
"rollback_execution_authorized": False,
105114
}
106115

107-
def rollback(
116+
def propose_rollback(
108117
self,
109118
strategy_profile: str,
110119
*,
@@ -113,27 +122,43 @@ def rollback(
113122
param_version_to: int,
114123
params_before: Mapping[str, Any],
115124
params_after: Mapping[str, Any],
116-
reason: str = "Auto-rollback due to post-deployment performance degradation",
125+
reason: str = "Rollback proposal due to post-deployment performance degradation",
117126
) -> dict[str, Any]:
118-
"""Execute a rollback and record it in the audit log."""
127+
"""Record a rollback proposal without changing any external state."""
119128
entry = record_audit_entry(
120129
strategy_profile=strategy_profile,
121130
domain=domain,
122-
stage=UpdateStage.ROLLED_BACK,
123-
operator="auto_optimizer",
131+
stage=UpdateStage.ROLLBACK_PROPOSED,
132+
operator="rollback_monitor",
124133
param_version_from=param_version_from,
125134
param_version_to=param_version_to,
126135
params_before=params_before,
127136
params_after=params_after,
128137
reason=reason,
129-
approval_source="auto",
138+
approval_source="not_authorized",
139+
store=self._store,
130140
)
131141

132142
return {
133-
"rolled_back": True,
143+
"proposal_recorded": True,
144+
"rolled_back": False,
145+
"rollback_executed": False,
146+
"execution_authorized": False,
147+
"requires_owner_approval": True,
148+
"stage": UpdateStage.ROLLBACK_PROPOSED.value,
134149
"strategy_profile": strategy_profile,
135150
"from_version": param_version_from,
136151
"to_version": param_version_to,
137152
"entry_id": entry.entry_id,
138153
"reason": reason,
139154
}
155+
156+
def rollback(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
157+
"""Compatibility alias for :meth:`propose_rollback`.
158+
159+
Kept so callers do not fail at import time, but it never claims or
160+
performs an external rollback. Consumers must check
161+
``rollback_executed`` rather than treating an audit record as runtime
162+
evidence.
163+
"""
164+
return self.propose_rollback(*args, **kwargs)

tests/test_lifecycle_contracts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ def test_update_stages(self) -> None:
254254
self.assertEqual(UpdateStage.PATCH_CREATED.value, "patch_created")
255255
self.assertEqual(UpdateStage.DEPLOYED.value, "deployed")
256256
self.assertEqual(UpdateStage.RUNTIME_CONFIRMED.value, "runtime_confirmed")
257+
self.assertEqual(UpdateStage.ROLLBACK_PROPOSED.value, "rollback_proposed")
257258
self.assertEqual(UpdateStage.ROLLED_BACK.value, "rolled_back")
258259

259260
def test_health_score(self) -> None:

tests/test_rollback_manager.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from __future__ import annotations
2+
3+
from types import SimpleNamespace
4+
from unittest.mock import patch
5+
6+
from quant_platform_kit.strategy_lifecycle.contracts import UpdateStage
7+
from quant_platform_kit.strategy_lifecycle.rollback_manager import RollbackManager
8+
9+
10+
def test_rollback_manager_records_only_an_owner_review_proposal() -> None:
11+
store = object()
12+
manager = RollbackManager(store=store, policy=object())
13+
14+
with patch(
15+
"quant_platform_kit.strategy_lifecycle.rollback_manager.record_audit_entry",
16+
return_value=SimpleNamespace(entry_id="proposal-123"),
17+
) as record_audit_entry:
18+
result = manager.propose_rollback(
19+
"soxl_soxx_trend_income",
20+
domain="us_equity",
21+
param_version_from=4,
22+
param_version_to=3,
23+
params_before={"risk_cap": 0.5},
24+
params_after={"risk_cap": 0.3},
25+
)
26+
27+
assert result["proposal_recorded"] is True
28+
assert result["rolled_back"] is False
29+
assert result["rollback_executed"] is False
30+
assert result["execution_authorized"] is False
31+
assert result["requires_owner_approval"] is True
32+
assert result["stage"] == "rollback_proposed"
33+
assert result["entry_id"] == "proposal-123"
34+
assert record_audit_entry.call_args.kwargs["stage"] is UpdateStage.ROLLBACK_PROPOSED
35+
assert record_audit_entry.call_args.kwargs["approval_source"] == "not_authorized"
36+
assert record_audit_entry.call_args.kwargs["store"] is store
37+
38+
39+
def test_legacy_rollback_alias_preserves_the_no_execution_boundary() -> None:
40+
manager = RollbackManager(store=object(), policy=object())
41+
42+
with patch.object(manager, "propose_rollback", return_value={"rollback_executed": False}) as propose:
43+
result = manager.rollback("tqqq_growth_income", domain="us_equity")
44+
45+
assert result == {"rollback_executed": False}
46+
propose.assert_called_once_with("tqqq_growth_income", domain="us_equity")

0 commit comments

Comments
 (0)