From 915e55e2804cd047de464797e2bc4d062929142f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:33:39 +0800 Subject: [PATCH] Add fail-closed Firstrade reconciliation receipt Co-Authored-By: Codex --- application/broker_reconciliation.py | 341 ++++++++++++++++++++++++++- main.py | 72 ++++++ runtime_config_support.py | 4 +- tests/test_broker_reconciliation.py | 98 ++++++++ tests/test_request_handling.py | 77 ++++++ tests/test_runtime_config_support.py | 2 +- 6 files changed, 578 insertions(+), 16 deletions(-) diff --git a/application/broker_reconciliation.py b/application/broker_reconciliation.py index 1a0e067..7f2bbcb 100644 --- a/application/broker_reconciliation.py +++ b/application/broker_reconciliation.py @@ -1,31 +1,346 @@ -"""Default-off E3 reconciliation groundwork for Firstrade. +"""Default-off, read-only Firstrade reconciliation evidence. -This module owns no broker client and is intentionally not wired into any -runtime or order path. A future, explicitly authorized read-only collector -must be injected before reconciliation evidence can be created. +The collector never imports an order port or writes broker, session, baseline, +or execution state. Firstrade's available order reader does not establish a +bounded fill-history contract, so ``recent_executions`` is deliberately marked +unavailable and every candidate remains blocked until that provider boundary is +separately verified. """ from __future__ import annotations -from collections.abc import Callable +import json +import os +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any -from quant_platform_kit.common.broker_reconciliation import BrokerReconciliationEvidence +from quant_platform_kit.common.broker_reconciliation import ( + BrokerReconciliationEvidence, + BrokerReconciliationFinding, + build_broker_reconciliation_evidence, + calculate_broker_observation_sha256, + evaluate_broker_reconciliation_recovery, +) + +FIRSTRADE_RECONCILIATION_ENABLED_ENV = "FIRSTRADE_BROKER_RECONCILIATION_ENABLED" +FIRSTRADE_RECONCILIATION_EXPECTED_DIGESTS_ENV = "FIRSTRADE_RECONCILIATION_EXPECTED_DIGESTS_JSON" +_EXPECTED_DIGEST_KEYS = ( + "account_scope_sha256", + "positions_sha256", + "cash_sha256", + "open_orders_sha256", + "recent_executions_sha256", + "local_execution_ledger_sha256", +) +_SAFE_CANDIDATE_KEYS = frozenset( + { + "schema_version", + "permits_active_lkg", + "expected_digests_configured", + "execution_ledger_records_count", + "recent_executions_available", + "local_execution_ledger_available", + "recovery_blockers", + "evidence", + } +) +_TERMINAL_ORDER_STATUSES = frozenset({"CANCELED", "REJECTED", "EXPIRED", "FILLED", "REPLACED"}) class FirstradeReconciliationUnavailable(RuntimeError): - """Raised when read-only E3 evidence collection is unavailable or invalid.""" + """Raised when read-only reconciliation cannot safely produce evidence.""" + + +def reconciliation_enabled( + env_reader: Callable[[str, str | None], str | None] = os.getenv, +) -> bool: + """Require an explicit exact boolean; absent and malformed values stay off.""" + + return _text(env_reader(FIRSTRADE_RECONCILIATION_ENABLED_ENV, None)).lower() == "true" + + +def _text(value: object) -> str: + return str(value or "").strip() + + +def _json_value(value: object, *, surface: str) -> object: + """Reject non-canonical broker payloads before they can affect a digest.""" + + try: + return json.loads(json.dumps(value, ensure_ascii=True, sort_keys=True, allow_nan=False)) + except (TypeError, ValueError) as exc: + raise FirstradeReconciliationUnavailable( + f"Firstrade reconciliation received malformed {surface}." + ) from exc + + +def _canonical_records(records: list[Mapping[str, object]]) -> tuple[Mapping[str, object], ...]: + return tuple( + sorted( + (dict(_json_value(record, surface="orders")) for record in records), + key=lambda record: json.dumps(record, ensure_ascii=True, sort_keys=True), + ) + ) + + +@dataclass(frozen=True) +class FirstradeReconciliationObservations: + """Sensitive in-memory observations. Never serialize this object.""" + + account_scope: Mapping[str, object] + account_identity_match: bool + positions: object + cash: Mapping[str, object] + open_orders: tuple[Mapping[str, object], ...] + recent_executions: Mapping[str, object] + recent_executions_available: bool + + +@dataclass(frozen=True) +class FirstradeReconciliationCandidate: + """Public-safe candidate containing only hashes, booleans, and reason codes.""" + + evidence: BrokerReconciliationEvidence + recovery_blockers: tuple[BrokerReconciliationFinding, ...] + expected_digests_configured: bool + execution_ledger_records_count: int + recent_executions_available: bool + local_execution_ledger_available: bool + + @property + def permits_active_lkg(self) -> bool: + return not self.recovery_blockers + + def to_safe_dict(self) -> dict[str, object]: + return { + "schema_version": "firstrade_reconciliation_candidate.v1", + "permits_active_lkg": self.permits_active_lkg, + "expected_digests_configured": self.expected_digests_configured, + "execution_ledger_records_count": self.execution_ledger_records_count, + "recent_executions_available": self.recent_executions_available, + "local_execution_ledger_available": self.local_execution_ledger_available, + "recovery_blockers": [finding.value for finding in self.recovery_blockers], + "evidence": self.evidence.to_dict(), + } + + +def validate_reconciliation_preconditions( + *, + runtime_target: object, + client_builder: object, + env_reader: Callable[[str, str | None], str | None] = os.getenv, +) -> None: + """Reject before client or runtime context construction whenever unsafe.""" + + if not reconciliation_enabled(env_reader): + raise FirstradeReconciliationUnavailable("Firstrade broker reconciliation is disabled.") + if not callable(client_builder): + raise FirstradeReconciliationUnavailable("Firstrade broker reconciliation client is unavailable.") + if runtime_target is None: + raise FirstradeReconciliationUnavailable("Firstrade reconciliation requires an explicit runtime target.") + continuity = getattr(runtime_target, "live_continuity", None) + if _text(getattr(continuity, "state", "")).upper() != "RECONCILE_ONLY": + raise FirstradeReconciliationUnavailable( + "Firstrade reconciliation is only available for a frozen baseline." + ) + + +def collect_read_only_reconciliation_observations( + client: Any, + *, + requested_account: object, +) -> FirstradeReconciliationObservations: + """Call only verified account, balances, positions, and orders readers.""" + + account_numbers = getattr(client, "account_numbers", None) + select_account = getattr(client, "select_account", None) + get_balances = getattr(client, "get_balances", None) + get_positions = getattr(client, "get_positions", None) + get_orders = getattr(client, "get_orders", None) + if not all(callable(method) for method in (account_numbers, select_account, get_balances, get_positions, get_orders)): + raise FirstradeReconciliationUnavailable( + "Firstrade reconciliation requires read-only account, balance, position, and order APIs." + ) + account = _text(requested_account) + if not account: + raise FirstradeReconciliationUnavailable("Firstrade reconciliation requires an explicit account.") + known_accounts = {_text(value) for value in account_numbers()} - {""} + selected_account = _text(select_account(account)) + if account not in known_accounts or selected_account != account: + raise FirstradeReconciliationUnavailable("Firstrade reconciliation account identity is incomplete.") + balances = get_balances(account) + positions = get_positions(account) + try: + orders = get_orders(account, per_page=0) + except TypeError as exc: + raise FirstradeReconciliationUnavailable( + "Firstrade reconciliation requires the bounded read-only order API." + ) from exc + if not isinstance(balances, Mapping) or not balances: + raise FirstradeReconciliationUnavailable("Firstrade reconciliation received incomplete balances.") + if not isinstance(positions, Mapping): + raise FirstradeReconciliationUnavailable("Firstrade reconciliation received incomplete positions.") + if not isinstance(orders, list) or any(not isinstance(order, Mapping) for order in orders): + raise FirstradeReconciliationUnavailable("Firstrade reconciliation received incomplete orders.") + open_orders: list[Mapping[str, object]] = [] + for order in orders: + status = _text(order.get("status") or order.get("order_status") or order.get("state")).upper() + if not status: + raise FirstradeReconciliationUnavailable("Firstrade reconciliation received an order without status.") + if status not in _TERMINAL_ORDER_STATUSES: + open_orders.append(order) + # No verified bounded timestamps or fill semantics: never label order rows executions. + return FirstradeReconciliationObservations( + account_scope={"account_id": account}, + account_identity_match=True, + positions=_json_value(positions, surface="positions"), + cash=dict(_json_value(balances, surface="balances")), + open_orders=_canonical_records(open_orders), + recent_executions={"availability": "unavailable"}, + recent_executions_available=False, + ) + + +def _expected_digests( + *, env_reader: Callable[[str, str | None], str | None] = os.getenv +) -> Mapping[str, str] | None: + raw = _text(env_reader(FIRSTRADE_RECONCILIATION_EXPECTED_DIGESTS_ENV, None)) + if not raw: + return None + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise FirstradeReconciliationUnavailable("Firstrade reconciliation expected digests are invalid.") from exc + if not isinstance(value, Mapping) or set(value) != set(_EXPECTED_DIGEST_KEYS): + raise FirstradeReconciliationUnavailable("Firstrade reconciliation expected digests are incomplete.") + normalized = {key: _text(value[key]).lower().removeprefix("sha256:") for key in _EXPECTED_DIGEST_KEYS} + if any(len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest) for digest in normalized.values()): + raise FirstradeReconciliationUnavailable("Firstrade reconciliation expected digests are invalid.") + return normalized + + +def _continuity_fields(runtime_target: object) -> tuple[str, str, str]: + continuity = getattr(runtime_target, "live_continuity", None) + baseline_id = _text(getattr(continuity, "baseline_id", "")) + baseline_target_sha256 = _text(getattr(continuity, "baseline_target_sha256", "")).lower() + if not baseline_id or len(baseline_target_sha256) != 64: + raise FirstradeReconciliationUnavailable( + "Firstrade reconciliation requires a frozen live-continuity baseline." + ) + return baseline_id, baseline_target_sha256, baseline_target_sha256 + + +def build_reconciliation_candidate( + *, + observations: FirstradeReconciliationObservations, + runtime_target: object, + project_id: str | None, + ledger_digest_reader: Callable[[], tuple[str, int]], + env_reader: Callable[[str, str | None], str | None] = os.getenv, + observed_at: datetime | None = None, +) -> FirstradeReconciliationCandidate: + """Build redacted evidence; incomplete evidence can never permit recovery.""" + + del project_id # The injected reader owns its read-only storage configuration. + expected = _expected_digests(env_reader=env_reader) + platform_id = _text(getattr(runtime_target, "platform_id", "")) + strategy_profile = _text(getattr(runtime_target, "strategy_profile", "")) + account_scope = _text(getattr(runtime_target, "account_scope", "")) + if platform_id != "firstrade" or not strategy_profile or not account_scope: + raise FirstradeReconciliationUnavailable("Firstrade reconciliation runtime target is incomplete.") + baseline_id, baseline_target_sha256, runtime_target_sha256 = _continuity_fields(runtime_target) + digests = { + "positions_sha256": calculate_broker_observation_sha256(observations.positions), + "cash_sha256": calculate_broker_observation_sha256(observations.cash), + "open_orders_sha256": calculate_broker_observation_sha256(observations.open_orders), + "recent_executions_sha256": calculate_broker_observation_sha256(observations.recent_executions), + } + try: + ledger_digest, records_count = ledger_digest_reader() + local_execution_ledger_available = isinstance(records_count, int) and records_count >= 0 + digests["local_execution_ledger_sha256"] = str(ledger_digest).lower().removeprefix("sha256:") + if len(digests["local_execution_ledger_sha256"]) != 64: + local_execution_ledger_available = False + except Exception: + local_execution_ledger_available = False + records_count = 0 + digests["local_execution_ledger_sha256"] = calculate_broker_observation_sha256( + {"availability": "unavailable"} + ) + timestamp = observed_at or datetime.now(timezone.utc) + evidence = build_broker_reconciliation_evidence( + platform_id=platform_id, + strategy_profile=strategy_profile, + account_scope_sha256=calculate_broker_observation_sha256(observations.account_scope), + baseline_id=baseline_id, + baseline_target_sha256=baseline_target_sha256, + runtime_target_sha256=runtime_target_sha256, + observed_at=timestamp, + broker_connected=True, + account_identity_match=observations.account_identity_match, + positions_match=expected is not None and expected["positions_sha256"] == digests["positions_sha256"], + cash_match=expected is not None and expected["cash_sha256"] == digests["cash_sha256"], + open_orders_match=expected is not None and expected["open_orders_sha256"] == digests["open_orders_sha256"], + recent_executions_match=( + observations.recent_executions_available + and expected is not None + and expected["recent_executions_sha256"] == digests["recent_executions_sha256"] + ), + local_execution_ledger_match=( + local_execution_ledger_available + and expected is not None + and expected["local_execution_ledger_sha256"] == digests["local_execution_ledger_sha256"] + ), + **digests, + ) + blockers = evaluate_broker_reconciliation_recovery( + evidence, + now=timestamp, + expected_platform_id=platform_id, + expected_strategy_profile=strategy_profile, + expected_account_scope_sha256=(expected or {}).get("account_scope_sha256"), + expected_baseline_id=baseline_id, + expected_runtime_target_sha256=runtime_target_sha256, + **{ + f"expected_{key}": (expected or {}).get(key) + for key in _EXPECTED_DIGEST_KEYS + if key != "account_scope_sha256" + }, + ) + return FirstradeReconciliationCandidate( + evidence=evidence, + recovery_blockers=blockers, + expected_digests_configured=expected is not None, + execution_ledger_records_count=records_count if local_execution_ledger_available else 0, + recent_executions_available=observations.recent_executions_available, + local_execution_ledger_available=local_execution_ledger_available, + ) + + +def validate_reconciliation_candidate(candidate: object) -> dict[str, object]: + """Reject malformed receipts rather than returning sensitive observations.""" + + try: + payload = candidate.to_safe_dict() + evidence = BrokerReconciliationEvidence.from_dict(payload["evidence"]) + except Exception as exc: + raise FirstradeReconciliationUnavailable("Firstrade reconciliation receipt is invalid.") from exc + if set(payload) != _SAFE_CANDIDATE_KEYS: + raise FirstradeReconciliationUnavailable("Firstrade reconciliation receipt is invalid.") + if payload.get("schema_version") != "firstrade_reconciliation_candidate.v1" or evidence.platform_id != "firstrade": + raise FirstradeReconciliationUnavailable("Firstrade reconciliation receipt is invalid.") + normalized = dict(payload) + normalized["evidence"] = evidence.to_dict() + return normalized def collect_broker_reconciliation_evidence( *, collector: Callable[[], BrokerReconciliationEvidence] | None = None, ) -> BrokerReconciliationEvidence: - """Return injected QPK evidence without constructing broker or order context. - - The default keeps E3 disabled. This fail-closed boundary intentionally - precedes any future collector wiring, so ordinary runtime code cannot - silently create a broker session or execution port through this helper. - """ + """Compatibility injection boundary; never construct broker or order context.""" if not callable(collector): raise FirstradeReconciliationUnavailable("Firstrade reconciliation collector is not configured.") diff --git a/main.py b/main.py index ad600c2..1ca96a8 100644 --- a/main.py +++ b/main.py @@ -37,6 +37,14 @@ attach_unknown_failure_execution_receipt, ) from application.runtime_broker_adapters import build_runtime_broker_adapters +from application.broker_reconciliation import ( + FirstradeReconciliationUnavailable, + build_reconciliation_candidate, + collect_read_only_reconciliation_observations, + reconciliation_enabled, + validate_reconciliation_candidate, + validate_reconciliation_preconditions, +) from application.session_check_service import run_session_check from notifications.telegram import build_sender from quant_platform_kit.common.runtime_reports import ( @@ -60,6 +68,11 @@ app = Flask(__name__) register_health_endpoint(app) # GET /health /healthz +# There is intentionally no production builder here: the existing client login +# path can create session artifacts. An explicitly injected ephemeral client is +# required before this private read-only endpoint can contact the provider. +READ_ONLY_BROKER_RECONCILIATION_CLIENT_BUILDER = None + _REDACTED = "" _TELEGRAM_BOT_PATH_RE = re.compile(r"(?i)(/bot)([^/\s]+)") _SENSITIVE_QUERY_RE = re.compile( @@ -418,6 +431,60 @@ def _paper_command_consumer_session_date() -> str: return datetime.now(ZoneInfo(MARKET_TIMEZONE)).date().isoformat() +def _read_only_execution_ledger_digest(*, runtime_target: object, project_id: str | None) -> tuple[str, int]: + """Read the durable execution ledger without changing it.""" + + from quant_platform_kit.common.execution_state import build_execution_marker_store_from_env + + store = build_execution_marker_store_from_env( + platform_env_prefix="FIRSTRADE", + env_reader=os.getenv, + project_id=project_id, + ) + return store.calculate_recent_ledger_digest( + platform=str(getattr(runtime_target, "platform_id", "") or ""), + strategy_profile=str(getattr(runtime_target, "strategy_profile", "") or ""), + account_scope=str(getattr(runtime_target, "account_scope", "") or ""), + execution_mode="live", + ) + + +def _handle_reconciliation(): + """Return a private, redacted, no-order reconciliation candidate only.""" + + if not reconciliation_enabled(os.getenv): + return jsonify({"status": "blocked", "reason": "broker_reconciliation_disabled"}), 503 + try: + settings = _runtime_settings() + runtime_target = settings.runtime_target + validate_reconciliation_preconditions( + runtime_target=runtime_target, + client_builder=READ_ONLY_BROKER_RECONCILIATION_CLIENT_BUILDER, + env_reader=os.getenv, + ) + requested_account = str(os.getenv("FIRSTRADE_ACCOUNT") or "").strip() + client = READ_ONLY_BROKER_RECONCILIATION_CLIENT_BUILDER() + observations = collect_read_only_reconciliation_observations( + client, + requested_account=requested_account, + ) + candidate = build_reconciliation_candidate( + observations=observations, + runtime_target=runtime_target, + project_id=settings.project_id or get_project_id(), + ledger_digest_reader=lambda: _read_only_execution_ledger_digest( + runtime_target=runtime_target, + project_id=settings.project_id or get_project_id(), + ), + env_reader=os.getenv, + ) + return jsonify(validate_reconciliation_candidate(candidate)), 200 + except FirstradeReconciliationUnavailable: + return jsonify({"status": "blocked", "reason": "broker_reconciliation_unavailable"}), 503 + except Exception: + return jsonify({"status": "blocked", "reason": "broker_reconciliation_unavailable"}), 503 + + def _paper_command_consumer_runtime_is_isolated(settings: PlatformRuntimeSettings) -> bool: """Require an explicitly disabled, cash-only paper runtime.""" @@ -787,6 +854,11 @@ def paper_execution_command_consumer(): ) +@app.post("/reconcile") +def reconcile(): + return _handle_reconciliation() + + @app.post("/probe") def probe(): return session_check() diff --git a/runtime_config_support.py b/runtime_config_support.py index 0a94279..c32084b 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -71,7 +71,7 @@ class PlatformRuntimeSettings: live_order_ack: bool max_order_notional_usd: float | None strategy_metadata: Any = None - runtime_target_enabled: bool = True + runtime_target_enabled: bool = False cash_only_execution: bool = True reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO @@ -389,7 +389,7 @@ def _qqqi_income_ratio_env() -> float | None: def _runtime_target_enabled_env() -> bool: value = _optional_bool_env("RUNTIME_TARGET_ENABLED") - return True if value is None else value + return False if value is None else value def _optional_bool_env(name: str) -> bool | None: diff --git a/tests/test_broker_reconciliation.py b/tests/test_broker_reconciliation.py index 480ba91..e4ad758 100644 --- a/tests/test_broker_reconciliation.py +++ b/tests/test_broker_reconciliation.py @@ -1,11 +1,15 @@ from __future__ import annotations from datetime import datetime, timezone +from types import SimpleNamespace import pytest from application.broker_reconciliation import ( FirstradeReconciliationUnavailable, + FirstradeReconciliationObservations, + build_reconciliation_candidate, + collect_read_only_reconciliation_observations, collect_broker_reconciliation_evidence, ) from quant_platform_kit.common.broker_reconciliation import build_broker_reconciliation_evidence @@ -58,3 +62,97 @@ def test_reconciliation_entrypoint_returns_only_qpk_evidence(): def test_reconciliation_entrypoint_rejects_non_qpk_collector_result(): with pytest.raises(FirstradeReconciliationUnavailable, match="QPK evidence"): collect_broker_reconciliation_evidence(collector=lambda: object()) + + +def _runtime_target(): + return SimpleNamespace( + platform_id="firstrade", + strategy_profile="sample_profile", + account_scope="US", + live_continuity=SimpleNamespace( + state="RECONCILE_ONLY", + baseline_id="firstrade-baseline-001", + baseline_target_sha256="2" * 64, + ), + ) + + +class _ReadOnlyClient: + def account_numbers(self): + return ["account-sensitive-001"] + + def select_account(self, requested_account=None): + assert requested_account == "account-sensitive-001" + return requested_account + + def get_balances(self, account): + assert account == "account-sensitive-001" + return {"cash_balance": "100.25"} + + def get_positions(self, account): + assert account == "account-sensitive-001" + return {"items": [{"symbol": "SPY", "quantity": "1"}]} + + def get_orders(self, account, *, per_page=0): + assert account == "account-sensitive-001" + assert per_page == 0 + return [{"order_id": "order-sensitive-001", "status": "WORKING", "symbol": "SPY"}] + + +def test_read_only_observations_use_only_client_read_surfaces_and_mark_executions_unavailable(): + observations = collect_read_only_reconciliation_observations( + _ReadOnlyClient(), requested_account="account-sensitive-001" + ) + + assert observations.account_identity_match is True + assert observations.recent_executions_available is False + assert observations.open_orders + + +@pytest.mark.parametrize( + ("method_name", "value"), + [ + ("get_balances", {}), + ("get_positions", []), + ("get_orders", [{"order_id": "missing-status"}]), + ], +) +def test_read_only_observations_reject_incomplete_surfaces(method_name, value): + class IncompleteClient(_ReadOnlyClient): + pass + + setattr(IncompleteClient, method_name, lambda self, *_args, **_kwargs: value) + + with pytest.raises(FirstradeReconciliationUnavailable): + collect_read_only_reconciliation_observations( + IncompleteClient(), requested_account="account-sensitive-001" + ) + + +def test_candidate_with_no_immutable_baseline_is_redacted_and_remains_blocked(): + observations = FirstradeReconciliationObservations( + account_scope={"account_id": "account-sensitive-001"}, + account_identity_match=True, + positions=({"symbol": "SPY", "quantity": "1"},), + cash={"cash_balance": "100.25"}, + open_orders=({"order_id": "order-sensitive-001", "status": "WORKING"},), + recent_executions={"availability": "unavailable"}, + recent_executions_available=False, + ) + + candidate = build_reconciliation_candidate( + observations=observations, + runtime_target=_runtime_target(), + project_id=None, + ledger_digest_reader=lambda: ("7" * 64, 0), + observed_at=datetime(2026, 9, 4, tzinfo=timezone.utc), + env_reader=lambda _name, _default=None: None, + ) + payload = candidate.to_safe_dict() + + assert payload["permits_active_lkg"] is False + assert payload["expected_digests_configured"] is False + assert "broker_reconciliation_recent_executions_mismatch" in payload["recovery_blockers"] + serialized = str(payload) + for raw in ("account-sensitive-001", "order-sensitive-001", "100.25"): + assert raw not in serialized diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index 6154c38..14fc94b 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -31,6 +31,7 @@ def test_cloud_run_route_contracts_are_registered(): "/run": ["POST"], "/dry-run": ["GET", "POST"], "/paper-command-consumer": ["POST"], + "/reconcile": ["POST"], "/monitor-dispatch": ["GET", "POST"], "/probe": ["POST"], "/static/": ["GET"], @@ -109,8 +110,77 @@ def test_health_endpoint_remains_available_via_get(): assert response.status_code == 200 +def test_reconcile_disabled_before_runtime_or_client_context(monkeypatch): + monkeypatch.delenv("FIRSTRADE_BROKER_RECONCILIATION_ENABLED", raising=False) + + def fail(*_args, **_kwargs): + pytest.fail("disabled reconciliation must not build runtime or broker context") + + monkeypatch.setattr(main, "_runtime_settings", fail) + monkeypatch.setattr(main, "READ_ONLY_BROKER_RECONCILIATION_CLIENT_BUILDER", fail) + + response = main.app.test_client().post("/reconcile") + + assert response.status_code == 503 + assert response.get_json() == {"status": "blocked", "reason": "broker_reconciliation_disabled"} + + +def test_reconcile_enabled_returns_redacted_blocked_receipt(monkeypatch): + from types import SimpleNamespace + + monkeypatch.setenv("FIRSTRADE_BROKER_RECONCILIATION_ENABLED", "true") + monkeypatch.setattr( + main, + "_runtime_settings", + lambda: SimpleNamespace( + runtime_target=SimpleNamespace( + platform_id="firstrade", + strategy_profile="sample_profile", + account_scope="US", + live_continuity=SimpleNamespace( + state="RECONCILE_ONLY", + baseline_id="firstrade-baseline-001", + baseline_target_sha256="2" * 64, + ), + ), + project_id=None, + ), + ) + + class FakeClient: + def account_numbers(self): + return ["account-sensitive-001"] + + def select_account(self, requested_account=None): + return requested_account + + def get_balances(self, _account): + return {"cash_balance": "100.25"} + + def get_positions(self, _account): + return {"items": []} + + def get_orders(self, _account, *, per_page=0): + assert per_page == 0 + return [] + + monkeypatch.setenv("FIRSTRADE_ACCOUNT", "account-sensitive-001") + monkeypatch.setattr(main, "READ_ONLY_BROKER_RECONCILIATION_CLIENT_BUILDER", FakeClient) + monkeypatch.setattr(main, "_read_only_execution_ledger_digest", lambda **_kwargs: ("7" * 64, 0)) + + response = main.app.test_client().post("/reconcile") + + assert response.status_code == 200 + payload = response.get_json() + assert payload["permits_active_lkg"] is False + assert payload["expected_digests_configured"] is False + assert "account-sensitive-001" not in response.get_data(as_text=True) + assert "100.25" not in response.get_data(as_text=True) + + def test_run_endpoint_calls_strategy_cycle_when_gate_enabled(monkeypatch): monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true") + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "true") monkeypatch.setattr(main, "_should_skip_for_market_hours", lambda: (False, None)) monkeypatch.setattr(main, "_run_strategy_cycle_with_report", lambda **_kwargs: {"ok": True, "action_done": False}) client = main.app.test_client() @@ -151,6 +221,7 @@ def test_dry_run_admission_blocks_before_strategy_cycle(monkeypatch): def test_run_endpoint_skips_when_market_closed(monkeypatch): monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true") + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "true") monkeypatch.setattr( main, "_should_skip_for_market_hours", @@ -183,6 +254,7 @@ def test_run_endpoint_skips_when_market_closed(monkeypatch): def test_run_endpoint_returns_500_for_retryable_funding_block(monkeypatch): monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true") + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "true") monkeypatch.setattr(main, "_should_skip_for_market_hours", lambda: (False, None)) monkeypatch.setattr( main, @@ -207,6 +279,7 @@ def test_run_endpoint_returns_500_for_retryable_funding_block(monkeypatch): def test_run_endpoint_returns_500_for_retryable_execution_block(monkeypatch): monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true") + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "true") monkeypatch.setattr( main, "_run_strategy_cycle_with_report", @@ -293,6 +366,7 @@ def send(message): return send monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true") + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "true") monkeypatch.setenv("TELEGRAM_TOKEN", "token-1") monkeypatch.setenv("GLOBAL_TELEGRAM_CHAT_ID", "chat-1") monkeypatch.setenv("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN", "plugin-token") @@ -323,6 +397,7 @@ def send(message): def test_run_endpoint_error_notification_uses_chinese_copy(monkeypatch): monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true") + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "true") monkeypatch.setenv("TELEGRAM_TOKEN", "token-1") monkeypatch.setenv("GLOBAL_TELEGRAM_CHAT_ID", "chat-1") monkeypatch.setenv("NOTIFY_LANG", "zh") @@ -354,6 +429,7 @@ def send(message): def test_run_endpoint_redacts_sensitive_error_text(monkeypatch): monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true") + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "true") monkeypatch.setenv("TELEGRAM_TOKEN", "token-1") monkeypatch.setenv("GLOBAL_TELEGRAM_CHAT_ID", "chat-1") sent_messages = [] @@ -389,6 +465,7 @@ def send(message): def test_run_endpoint_error_does_not_require_telegram_config(monkeypatch): monkeypatch.setenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP", "true") + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "true") monkeypatch.delenv("TELEGRAM_TOKEN", raising=False) monkeypatch.delenv("GLOBAL_TELEGRAM_CHAT_ID", raising=False) monkeypatch.delenv("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN", raising=False) diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 49e477f..99b27d0 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -59,7 +59,7 @@ def test_reserved_cash_policy_defaults_to_zero(monkeypatch): assert settings.reserved_cash_floor_usd == 0.0 assert settings.reserved_cash_ratio == 0.0 - assert settings.runtime_target_enabled is True + assert settings.runtime_target_enabled is False assert settings.strategy_plugin_alert_channels == () assert settings.strategy_plugin_alert_email_recipients == () assert settings.strategy_plugin_alert_email_sender_email is None