diff --git a/.env.example b/.env.example index d43ef9c..e100f33 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ FIRSTRADE_ACCOUNT= # Shared US equity strategy runtime. STRATEGY_PROFILE= FIRSTRADE_DRY_RUN_ONLY=true +FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS= ACCOUNT_PREFIX=FIRSTRADE ACCOUNT_REGION=US NOTIFY_LANG=en diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 0626935..1636c92 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -59,6 +59,7 @@ jobs: FIRSTRADE_STATE_PREFIX: ${{ vars.FIRSTRADE_STATE_PREFIX }} FIRSTRADE_STRATEGY_CONFIG_PATH: ${{ vars.FIRSTRADE_STRATEGY_CONFIG_PATH }} FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON }} + FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }} FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }} INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }} QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }} @@ -419,6 +420,7 @@ jobs: add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH add_optional_env FIRSTRADE_STRATEGY_CONFIG_PATH add_optional_env FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON + add_optional_env FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS add_optional_env FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS add_optional_env INCOME_THRESHOLD_USD add_optional_env QQQI_INCOME_RATIO diff --git a/README.md b/README.md index 8426019..516f499 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ commit credentials. | `FIRSTRADE_ACCOUNT` | Optional | Required when multiple accounts are returned | | `STRATEGY_PROFILE` | Yes for runtime | Shared US equity strategy profile | | `FIRSTRADE_DRY_RUN_ONLY` | Optional | Defaults to `true` for platform runtime | +| `FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS` | Optional | Override the supported strategy runtime execution window in trading days. Unset uses the strategy default | | `FIRSTRADE_REUSE_SESSION` | Optional | Try cached Firstrade session headers before logging in again. Defaults to `false` | | `FIRSTRADE_SESSION_CACHE_TTL_SECONDS` | Optional | Max age for local session header reuse when `FIRSTRADE_REUSE_SESSION=true`. Defaults to `21600` | | `FIRSTRADE_PERSIST_SESSION_CACHE` | Optional | Persist Firstrade session headers to the configured GCS state bucket when `FIRSTRADE_REUSE_SESSION=true`. Defaults to `false` | diff --git a/application/rebalance_service.py b/application/rebalance_service.py index f853c87..42519a0 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -53,6 +53,7 @@ "sell_quantity_zero", } ) +TERMINAL_FUNDING_BLOCK_SKIP_REASONS = frozenset({"insufficient_cash_for_whole_share"}) def _utcnow() -> datetime: @@ -71,6 +72,15 @@ def _execution_blocking_skips(skipped_orders: list[dict[str, Any]]) -> list[dict ] +def _is_terminal_funding_block(blocking_skips: list[dict[str, Any]]) -> bool: + if not blocking_skips: + return False + return all( + str(item.get("reason") or "") in TERMINAL_FUNDING_BLOCK_SKIP_REASONS + for item in blocking_skips + ) + + def _series_from_price_history(market_data_port, symbol: str) -> pd.Series: series = market_data_port.get_price_series(symbol) index = pd.DatetimeIndex([pd.Timestamp(point.as_of) for point in series.points]) @@ -304,6 +314,8 @@ def run_strategy_cycle( skipped_orders = list(execution_result.skipped_orders) blocking_skips = _execution_blocking_skips(skipped_orders) execution_blocked = bool(blocking_skips) + funding_blocked = _is_terminal_funding_block(blocking_skips) + terminal_funding_block = funding_blocked and not execution_result.action_done result = { "ok": not execution_blocked, "api_kind": "unofficial-reverse-engineered", @@ -324,14 +336,19 @@ def run_strategy_cycle( } if execution_blocked: result["execution_blocked"] = True + result["execution_block_retryable"] = not terminal_funding_block result["execution_blocking_skips"] = blocking_skips result["error"] = "Strategy execution blocked; see execution_blocking_skips." + if funding_blocked: + result["funding_blocked"] = True if strategy_run_persistence_error: result["strategy_run_persistence_error"] = strategy_run_persistence_error if persist_strategy_runs: stage = "DRY_RUN_COMPLETED" if not settings.dry_run_only: - if execution_blocked and execution_result.action_done: + if terminal_funding_block and not execution_result.action_done: + stage = "FUNDING_BLOCKED" + elif execution_blocked and execution_result.action_done: stage = "PARTIAL_SUBMITTED" elif execution_blocked: stage = "EXECUTION_BLOCKED" diff --git a/application/strategy_run_persistence.py b/application/strategy_run_persistence.py index cc2b98c..abce945 100644 --- a/application/strategy_run_persistence.py +++ b/application/strategy_run_persistence.py @@ -10,7 +10,7 @@ from application.state_persistence import GcsStateStore -LIVE_TERMINAL_STAGES = frozenset({"SUBMITTED", "RECONCILED", "COMPLETED"}) +LIVE_TERMINAL_STAGES = frozenset({"SUBMITTED", "FUNDING_BLOCKED", "RECONCILED", "COMPLETED"}) def utcnow() -> datetime: diff --git a/runtime_config_support.py b/runtime_config_support.py index ea96294..d0f03d6 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -173,19 +173,17 @@ def _qqqi_income_ratio_env() -> float | None: def _runtime_execution_window_trading_days_env(strategy_profile: str) -> int | None: - if strategy_profile != "tech_communication_pullback_enhancement": - return None - raw_value = os.getenv("FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS") + raw_value = os.getenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS") + env_name = "FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS" + if raw_value is None and strategy_profile == "tech_communication_pullback_enhancement": + raw_value = os.getenv("FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS") + env_name = "FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS" if raw_value is None or not str(raw_value).strip(): return None try: value = int(str(raw_value).strip()) except ValueError as exc: - raise ValueError( - "FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS must be a positive integer" - ) from exc + raise ValueError(f"{env_name} must be a positive integer") from exc if value <= 0: - raise ValueError( - "FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS must be a positive integer" - ) + raise ValueError(f"{env_name} must be a positive integer") return value diff --git a/strategy_runtime.py b/strategy_runtime.py index 0d379fd..ddad952 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -148,7 +148,10 @@ def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSett overrides["income_threshold_usd"] = runtime_settings.income_threshold_usd if runtime_settings.qqqi_income_ratio is not None: overrides["qqqi_income_ratio"] = runtime_settings.qqqi_income_ratio - if profile == "tech_communication_pullback_enhancement": + if profile in { + "mega_cap_leader_rotation_top50_balanced", + "tech_communication_pullback_enhancement", + }: if runtime_settings.runtime_execution_window_trading_days is not None: overrides["runtime_execution_window_trading_days"] = ( runtime_settings.runtime_execution_window_trading_days @@ -187,4 +190,3 @@ def load_strategy_runtime( merged_runtime_config=merged_runtime_config, logger=logger, ) - diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 57c1f00..e9a6415 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -262,9 +262,76 @@ def test_run_strategy_cycle_persists_live_execution_blocked_without_terminal_sta assert result["action_done"] is False assert result["ok"] is False assert result["execution_blocked"] is True + assert result["execution_block_retryable"] is True assert latest_payload["stage"] == "EXECUTION_BLOCKED" +def test_run_strategy_cycle_persists_live_funding_block_as_terminal(monkeypatch): + store = FakeStateStore() + settings = _runtime_settings_with_persistence( + dry_run_only=False, + live_trading_enabled=True, + live_order_ack=True, + persist_strategy_runs=True, + max_order_notional_usd=None, + ) + + class FundingBlockedClient(FakeFirstradeClient): + def get_balances(self, _account): + return {"total_value": "150.00", "cash": "50.00", "buying_power": "50.00"} + + def get_quote(self, _account, symbol): + return {"symbol": symbol, "last": "100.00", "bid": "99.90", "ask": "100.10"} + + class FundingBlockedRuntime(FakeStrategyRuntime): + def evaluate(self, **inputs): + assert "portfolio_snapshot" in inputs + return SimpleNamespace( + decision=StrategyDecision( + positions=( + PositionTarget(symbol="AAA", target_value=150.0, role="risk"), + ), + diagnostics={"execution_annotations": {"trade_threshold_value": 1.0}}, + ), + metadata={"strategy_profile": self.profile}, + ) + + monkeypatch.setattr( + "application.rebalance_service.load_strategy_runtime", + lambda *_args, **_kwargs: FundingBlockedRuntime(), + ) + + result = run_strategy_cycle( + runtime_settings=settings, + credentials=FirstradeCredentials(username="user", password="pass"), + client_factory=FundingBlockedClient, + state_store=store, + env_reader=lambda _name, default=None: default, + ) + + latest_payload = store.writes[-2][1] + assert result["action_done"] is False + assert result["ok"] is False + assert result["execution_blocked"] is True + assert result["execution_block_retryable"] is False + assert result["funding_blocked"] is True + assert result["skipped_orders"][0]["reason"] == "insufficient_cash_for_whole_share" + assert latest_payload["stage"] == "FUNDING_BLOCKED" + + write_count = len(store.writes) + second_result = run_strategy_cycle( + runtime_settings=settings, + credentials=FirstradeCredentials(username="user", password="pass"), + client_factory=FundingBlockedClient, + state_store=store, + env_reader=lambda _name, default=None: default, + ) + + assert second_result["idempotency_skipped"] is True + assert second_result["existing_strategy_run_stage"] == "FUNDING_BLOCKED" + assert len(store.writes) == write_count + + def test_run_strategy_cycle_persists_live_partial_submission_as_non_terminal(monkeypatch): store = FakeStateStore() settings = _runtime_settings_with_persistence( diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py new file mode 100644 index 0000000..afc657a --- /dev/null +++ b/tests/test_runtime_config_support.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import pytest + +from runtime_config_support import _runtime_execution_window_trading_days_env + + +def test_runtime_execution_window_uses_generic_env(monkeypatch): + monkeypatch.setenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", "7") + monkeypatch.setenv("FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", "3") + + assert ( + _runtime_execution_window_trading_days_env("mega_cap_leader_rotation_top50_balanced") + == 7 + ) + assert ( + _runtime_execution_window_trading_days_env("tech_communication_pullback_enhancement") + == 7 + ) + + +def test_runtime_execution_window_keeps_legacy_tech_env(monkeypatch): + monkeypatch.delenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", raising=False) + monkeypatch.setenv("FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", "5") + + assert ( + _runtime_execution_window_trading_days_env("tech_communication_pullback_enhancement") + == 5 + ) + assert ( + _runtime_execution_window_trading_days_env("mega_cap_leader_rotation_top50_balanced") + is None + ) + + +@pytest.mark.parametrize("raw_value", ["0", "-1", "abc"]) +def test_runtime_execution_window_rejects_invalid_generic_env(monkeypatch, raw_value): + monkeypatch.setenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", raw_value) + + with pytest.raises( + ValueError, + match="FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", + ): + _runtime_execution_window_trading_days_env("mega_cap_leader_rotation_top50_balanced") diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py new file mode 100644 index 0000000..ad8d946 --- /dev/null +++ b/tests/test_strategy_runtime.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from runtime_config_support import PlatformRuntimeSettings +from strategy_runtime import _build_runtime_overrides + + +def _runtime_settings(**overrides) -> PlatformRuntimeSettings: + values = { + "project_id": None, + "account_prefix": "FIRSTRADE", + "account_region": "US", + "strategy_profile": "mega_cap_leader_rotation_top50_balanced", + "strategy_display_name": "Mega Cap Leader Rotation Top 50 Balanced", + "strategy_domain": "us_equity", + "notify_lang": "en", + "tg_token": None, + "tg_chat_id": None, + "dry_run_only": True, + "live_trading_enabled": False, + "run_strategy_on_http": False, + "live_order_ack": False, + "max_order_notional_usd": None, + } + values.update(overrides) + return PlatformRuntimeSettings(**values) + + +def test_runtime_execution_window_override_applies_to_mega_strategy(): + settings = _runtime_settings(runtime_execution_window_trading_days=7) + + assert _build_runtime_overrides( + "mega_cap_leader_rotation_top50_balanced", + settings, + ) == {"runtime_execution_window_trading_days": 7} + + +def test_runtime_execution_window_override_applies_to_tech_strategy(): + settings = _runtime_settings(runtime_execution_window_trading_days=7) + + assert _build_runtime_overrides( + "tech_communication_pullback_enhancement", + settings, + ) == {"runtime_execution_window_trading_days": 7} + + +def test_runtime_execution_window_override_ignores_other_profiles(): + settings = _runtime_settings(runtime_execution_window_trading_days=7) + + assert _build_runtime_overrides("global_etf_rotation", settings) == {}