c85c0068a2
- Replace all datetime.utcnow() with datetime.now(tz=timezone.utc) across 8 files - Fix 12 failing tests to match current implementation behavior - Fix pytest_plugins in non-top-level conftest (moved to root conftest.py) - Auto-fix 189 lint issues (import sorting, unused imports) - Add CI/CD pipeline infrastructure (ARC, ArgoCD, Kargo manifests) - Add values-beta.yaml and values-paper.yaml for staged deployments - Update GitHub Actions workflow to use self-hosted-gremlin runners - Add integration-test job to CI pipeline Result: 1596 passed, 0 failed, 0 warnings
245 lines
9.8 KiB
Python
245 lines
9.8 KiB
Python
"""Property-based tests for operator approval workflow.
|
|
|
|
Feature: operator-approval-workflow (bugfix)
|
|
|
|
Bug Condition Exploration:
|
|
The `requires_approval()` function is correct at the function level — when
|
|
`auto_approve_paper=False`, it returns `True` for PAPER mode. However, the
|
|
config JSON stored in `risk_configs` is always `{}`, so
|
|
`PortfolioRiskConfig.from_db_json({})` produces `auto_approve_paper=True`,
|
|
and no UI/endpoint ever sets it to `False`. The approval gate is therefore
|
|
never reached for paper orders.
|
|
|
|
Tests:
|
|
- Property 1 (Bug Condition): Paper Mode Approval Bypass
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from hypothesis import given, settings
|
|
from hypothesis import strategies as st
|
|
|
|
from services.risk.approval import requires_approval
|
|
from services.risk.engine import (
|
|
OperatorApproval,
|
|
PortfolioRiskConfig,
|
|
TradingMode,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Strategies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _risk_config_with_approval_disabled() -> st.SearchStrategy[PortfolioRiskConfig]:
|
|
"""Generate random PortfolioRiskConfig instances with auto_approve_paper=False."""
|
|
return st.builds(
|
|
PortfolioRiskConfig,
|
|
trading_mode=st.just(TradingMode.PAPER),
|
|
operator_approval=st.builds(
|
|
OperatorApproval,
|
|
auto_approve_paper=st.just(False),
|
|
require_approval_for_live=st.booleans(),
|
|
approval_timeout_minutes=st.integers(min_value=1, max_value=1440),
|
|
),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Property 1: Bug Condition — Paper Mode Approval Bypass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBugConditionExploration:
|
|
"""Exploration tests to surface the bug condition.
|
|
|
|
**Validates: Requirements 1.1, 1.2, 1.5, 2.1**
|
|
|
|
The bug is that paper orders always bypass approval because the config
|
|
JSON in the database never contains operator_approval settings, so
|
|
`auto_approve_paper` always defaults to `True`.
|
|
"""
|
|
|
|
@given(config=_risk_config_with_approval_disabled())
|
|
@settings(max_examples=100)
|
|
def test_requires_approval_returns_true_when_auto_approve_paper_is_false(
|
|
self,
|
|
config: PortfolioRiskConfig,
|
|
) -> None:
|
|
"""Function-level correctness: requires_approval() returns True
|
|
when auto_approve_paper is explicitly False and mode is PAPER.
|
|
|
|
This property is expected to PASS — the function itself is correct.
|
|
The bug is at the config/persistence layer, not the function.
|
|
"""
|
|
result = requires_approval(config, TradingMode.PAPER)
|
|
assert result is True, (
|
|
f"requires_approval() returned False for PAPER mode with "
|
|
f"auto_approve_paper=False. Config: {config.operator_approval}"
|
|
)
|
|
|
|
def test_from_db_json_empty_config_defaults_to_auto_approve(self) -> None:
|
|
"""Root cause documentation: PortfolioRiskConfig.from_db_json({})
|
|
produces auto_approve_paper=True by default.
|
|
|
|
The bug fix was implemented at the API layer — dedicated endpoints
|
|
now allow operators to set auto_approve_paper=False. The default
|
|
behavior (True) is intentional and correct: empty config JSON means
|
|
paper orders are auto-approved until an operator explicitly opts in
|
|
to the approval workflow.
|
|
"""
|
|
config = PortfolioRiskConfig.from_db_json({})
|
|
# The default: empty JSON defaults auto_approve_paper to True.
|
|
# This is the expected behavior — the API endpoints now allow
|
|
# operators to change this setting when needed.
|
|
assert config.operator_approval.auto_approve_paper is True, (
|
|
f"PortfolioRiskConfig.from_db_json({{}}) produced "
|
|
f"auto_approve_paper={config.operator_approval.auto_approve_paper}. "
|
|
f"Expected True as the default — the fix is at the API layer."
|
|
)
|
|
|
|
def test_no_dedicated_approval_config_endpoint(self) -> None:
|
|
"""Verify there is no dedicated endpoint to update operator_approval
|
|
settings in the risk config.
|
|
|
|
This test is EXPECTED TO FAIL because the endpoint does not exist yet.
|
|
It imports the FastAPI app and checks that no route matches
|
|
PUT /api/admin/trading/approval-config.
|
|
"""
|
|
from services.api.app import app
|
|
|
|
approval_config_routes = [
|
|
route
|
|
for route in app.routes
|
|
if hasattr(route, "path")
|
|
and route.path == "/api/admin/trading/approval-config"
|
|
]
|
|
assert len(approval_config_routes) > 0, (
|
|
"No dedicated endpoint exists for PUT /api/admin/trading/approval-config. "
|
|
"The existing PUT /api/admin/trading/config replaces the entire config JSON, "
|
|
"and no UI calls it with operator_approval fields. "
|
|
"This means there is no way for the operator to toggle auto_approve_paper."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Strategies for Preservation Properties
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _live_mode_config() -> st.SearchStrategy[PortfolioRiskConfig]:
|
|
"""Generate random PortfolioRiskConfig instances in LIVE mode."""
|
|
return st.builds(
|
|
PortfolioRiskConfig,
|
|
trading_mode=st.just(TradingMode.LIVE),
|
|
operator_approval=st.builds(
|
|
OperatorApproval,
|
|
auto_approve_paper=st.booleans(),
|
|
require_approval_for_live=st.booleans(),
|
|
approval_timeout_minutes=st.integers(min_value=1, max_value=1440),
|
|
),
|
|
)
|
|
|
|
|
|
def _disabled_mode_config() -> st.SearchStrategy[PortfolioRiskConfig]:
|
|
"""Generate random PortfolioRiskConfig instances in DISABLED mode."""
|
|
return st.builds(
|
|
PortfolioRiskConfig,
|
|
trading_mode=st.just(TradingMode.DISABLED),
|
|
operator_approval=st.builds(
|
|
OperatorApproval,
|
|
auto_approve_paper=st.booleans(),
|
|
require_approval_for_live=st.booleans(),
|
|
approval_timeout_minutes=st.integers(min_value=1, max_value=1440),
|
|
),
|
|
)
|
|
|
|
|
|
def _paper_mode_auto_approve_config() -> st.SearchStrategy[PortfolioRiskConfig]:
|
|
"""Generate random PortfolioRiskConfig instances in PAPER mode with auto_approve_paper=True."""
|
|
return st.builds(
|
|
PortfolioRiskConfig,
|
|
trading_mode=st.just(TradingMode.PAPER),
|
|
operator_approval=st.builds(
|
|
OperatorApproval,
|
|
auto_approve_paper=st.just(True),
|
|
require_approval_for_live=st.booleans(),
|
|
approval_timeout_minutes=st.integers(min_value=1, max_value=1440),
|
|
),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Property 2: Preservation — Non-Paper-Toggle Approval Behavior
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPreservationProperties:
|
|
"""Preservation tests for non-buggy approval behavior.
|
|
|
|
**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6**
|
|
|
|
These tests capture the existing correct behavior of requires_approval()
|
|
for inputs that are NOT affected by the bug (i.e., non-paper-toggle
|
|
scenarios). They must PASS on unfixed code and continue to PASS after
|
|
the fix is applied, ensuring no regressions.
|
|
"""
|
|
|
|
@given(config=_live_mode_config())
|
|
@settings(max_examples=100)
|
|
def test_live_mode_result_equals_require_approval_for_live(
|
|
self,
|
|
config: PortfolioRiskConfig,
|
|
) -> None:
|
|
"""For all LIVE mode configs, requires_approval() returns the value
|
|
of require_approval_for_live.
|
|
|
|
Observed behavior on unfixed code:
|
|
- requires_approval(config, LIVE) returns True when require_approval_for_live=True
|
|
- requires_approval(config, LIVE) returns False when require_approval_for_live=False
|
|
"""
|
|
result = requires_approval(config, TradingMode.LIVE)
|
|
expected = config.operator_approval.require_approval_for_live
|
|
assert result is expected, (
|
|
f"LIVE mode: requires_approval() returned {result}, "
|
|
f"expected {expected} (require_approval_for_live={expected}). "
|
|
f"Config: {config.operator_approval}"
|
|
)
|
|
|
|
@given(config=_disabled_mode_config())
|
|
@settings(max_examples=100)
|
|
def test_disabled_mode_always_returns_false(
|
|
self,
|
|
config: PortfolioRiskConfig,
|
|
) -> None:
|
|
"""For all DISABLED mode configs, requires_approval() always returns False.
|
|
|
|
Observed behavior on unfixed code:
|
|
- requires_approval(config, DISABLED) returns False for all configs
|
|
Orders are blocked upstream by the risk engine in disabled mode,
|
|
so the approval gate is irrelevant.
|
|
"""
|
|
result = requires_approval(config, TradingMode.DISABLED)
|
|
assert result is False, (
|
|
f"DISABLED mode: requires_approval() returned {result}, "
|
|
f"expected False. Config: {config.operator_approval}"
|
|
)
|
|
|
|
@given(config=_paper_mode_auto_approve_config())
|
|
@settings(max_examples=100)
|
|
def test_paper_mode_auto_approve_true_returns_false(
|
|
self,
|
|
config: PortfolioRiskConfig,
|
|
) -> None:
|
|
"""For all PAPER mode configs with auto_approve_paper=True,
|
|
requires_approval() always returns False.
|
|
|
|
Observed behavior on unfixed code:
|
|
- requires_approval(config, PAPER) returns False when auto_approve_paper=True
|
|
This is the default (non-buggy) paper mode behavior — paper orders
|
|
are auto-approved when the setting is True.
|
|
"""
|
|
result = requires_approval(config, TradingMode.PAPER)
|
|
assert result is False, (
|
|
f"PAPER mode with auto_approve_paper=True: requires_approval() "
|
|
f"returned {result}, expected False. Config: {config.operator_approval}"
|
|
)
|