Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ jobs:
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 }}
INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }}
INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }}
RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }}
EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }}
GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }}
NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}
Expand Down Expand Up @@ -557,6 +560,9 @@ jobs:
add_optional_env FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS
add_optional_env INCOME_THRESHOLD_USD
add_optional_env QQQI_INCOME_RATIO
add_optional_env INCOME_LAYER_ENABLED
add_optional_env INCOME_LAYER_MAX_RATIO
add_optional_env RUNTIME_TARGET_ENABLED
add_optional_env EXECUTION_REPORT_GCS_URI
add_optional_env GLOBAL_TELEGRAM_CHAT_ID
add_optional_env NOTIFY_LANG
Expand Down
8 changes: 7 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@
finalize_runtime_report,
persist_runtime_report,
)
from runtime_config_support import PlatformRuntimeSettings, load_platform_runtime_settings
from runtime_config_support import (
PlatformRuntimeSettings,
_runtime_target_enabled_env,
load_platform_runtime_settings,
)
from strategy_registry import get_platform_profile_status_matrix

app = Flask(__name__)
Expand Down Expand Up @@ -406,6 +410,8 @@ def run_strategy():
),
403,
)
if not _runtime_target_enabled_env():
return jsonify({"ok": True, "status": "skipped", "skip_reason": "runtime_target_disabled"}), 200
Comment on lines +413 to +414

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist a skipped report for disabled targets

When RUNTIME_TARGET_ENABLED=false is used on a monitored Cloud Run service, returning here skips _run_strategy_cycle_with_report, which is the path that builds and persists execution reports before returning. The heartbeat checker explicitly accepts skipped reports (scripts/execution_report_heartbeat.py:17), but if no report object is written it reports missing acceptable reports for required services (scripts/execution_report_heartbeat.py:371-393), so intentionally paused targets will still trigger heartbeat alerts instead of recording a clean skipped run.

Useful? React with 👍 / 👎.

try:
return jsonify(_run_strategy_cycle_with_report())
except (FirstradePlatformError, EnvironmentError, ValueError) as exc:
Expand Down
34 changes: 34 additions & 0 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,16 @@ class PlatformRuntimeSettings:
run_strategy_on_http: bool
live_order_ack: bool
max_order_notional_usd: float | None
runtime_target_enabled: bool = True
reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD
reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO
persist_strategy_runs: bool = False
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
debug_position_snapshot: bool = False
income_threshold_usd: float | None = None
qqqi_income_ratio: float | None = None
income_layer_enabled: bool | None = None
income_layer_max_ratio: float | None = None
runtime_execution_window_trading_days: int | None = None
feature_snapshot_path: str | None = None
feature_snapshot_manifest_path: str | None = None
Expand Down Expand Up @@ -140,6 +143,7 @@ def load_platform_runtime_settings(
tg_token=os.getenv("TELEGRAM_TOKEN"),
tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"),
dry_run_only=dry_run_only,
runtime_target_enabled=_runtime_target_enabled_env(),
live_trading_enabled=resolve_bool_value(os.getenv("FIRSTRADE_ENABLE_LIVE_TRADING")),
run_strategy_on_http=resolve_bool_value(os.getenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP")),
live_order_ack=resolve_bool_value(os.getenv("FIRSTRADE_LIVE_ORDER_ACK")),
Expand All @@ -164,6 +168,8 @@ def load_platform_runtime_settings(
debug_position_snapshot=resolve_bool_value(os.getenv("FIRSTRADE_DEBUG_POSITION_SNAPSHOT")),
income_threshold_usd=resolve_optional_float_env(os.environ, "INCOME_THRESHOLD_USD"),
qqqi_income_ratio=_qqqi_income_ratio_env(),
income_layer_enabled=_optional_bool_env("INCOME_LAYER_ENABLED"),
income_layer_max_ratio=_optional_ratio_env("INCOME_LAYER_MAX_RATIO"),
runtime_execution_window_trading_days=_runtime_execution_window_trading_days_env(
strategy_definition.profile
),
Expand Down Expand Up @@ -267,6 +273,34 @@ def _qqqi_income_ratio_env() -> float | None:
return value


def _optional_bool_env(name: str) -> bool | None:
raw_value = os.getenv(name)
if raw_value is None or str(raw_value).strip() == "":
return None
value = str(raw_value).strip().lower()
if value in {"1", "true", "yes", "y", "on"}:
return True
if value in {"0", "false", "no", "n", "off"}:
return False
raise ValueError(f"{name} must be boolean, got {raw_value!r}")


def _runtime_target_enabled_env() -> bool:
value = _optional_bool_env("RUNTIME_TARGET_ENABLED")
return True if value is None else value


def _optional_ratio_env(name: str) -> float | None:
value = resolve_optional_float_env(os.environ, name)
if value is None:
return None
if not math.isfinite(value):
raise ValueError(f"{name} must be finite, got {value}")
if not (0.0 <= value <= 1.0):
raise ValueError(f"{name} must be in [0,1], got {value}")
return value


def _resolve_non_negative_float_env(name: str, *, default: float) -> float:
value = resolve_optional_float_env(os.environ, name)
if value is None:
Expand Down
6 changes: 6 additions & 0 deletions strategy_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ def load_runtime_parameters(self) -> dict[str, Any]:

def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSettings) -> dict[str, Any]:
overrides: dict[str, Any] = {}
income_layer_enabled = getattr(runtime_settings, "income_layer_enabled", None)
income_layer_max_ratio = getattr(runtime_settings, "income_layer_max_ratio", None)
if income_layer_enabled is not None:
overrides["income_layer_enabled"] = income_layer_enabled
if income_layer_max_ratio is not None:
overrides["income_layer_max_ratio"] = income_layer_max_ratio
if profile == "tqqq_growth_income":
if runtime_settings.income_threshold_usd is not None:
overrides["income_threshold_usd"] = runtime_settings.income_threshold_usd
Expand Down
37 changes: 37 additions & 0 deletions tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,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.strategy_plugin_alert_channels == ()
assert settings.strategy_plugin_alert_email_recipients == ()
assert settings.strategy_plugin_alert_email_sender_email is None
Expand Down Expand Up @@ -96,6 +97,42 @@ def test_reserved_cash_policy_loads_from_env(monkeypatch):
assert settings.reserved_cash_ratio == 0.025


def test_income_layer_overrides_load_from_env(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("tqqq_growth_income"))
monkeypatch.setenv("INCOME_LAYER_ENABLED", "false")
monkeypatch.setenv("INCOME_LAYER_MAX_RATIO", "0.25")

settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")

assert settings.income_layer_enabled is False
assert settings.income_layer_max_ratio == 0.25


def test_invalid_income_layer_max_ratio_is_rejected(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("tqqq_growth_income"))
monkeypatch.setenv("INCOME_LAYER_MAX_RATIO", "1.5")

with pytest.raises(ValueError, match="INCOME_LAYER_MAX_RATIO"):
load_platform_runtime_settings(project_id_resolver=lambda: "project-1")


def test_runtime_target_enabled_loads_from_env(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json())
monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "false")

settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")

assert settings.runtime_target_enabled is False


def test_invalid_runtime_target_enabled_is_rejected(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json())
monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "maybe")

with pytest.raises(ValueError, match="RUNTIME_TARGET_ENABLED"):
load_platform_runtime_settings(project_id_resolver=lambda: "project-1")


def test_strategy_plugin_alert_email_settings_load_from_env(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json())
monkeypatch.setenv("STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS", "alerts@example.com; voice@example.com")
Expand Down
12 changes: 12 additions & 0 deletions tests/test_strategy_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,15 @@ 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) == {}


def test_income_layer_overrides_apply_to_runtime_config():
settings = _runtime_settings(
income_layer_enabled=False,
income_layer_max_ratio=0.25,
)

assert _build_runtime_overrides("global_etf_rotation", settings) == {
"income_layer_enabled": False,
"income_layer_max_ratio": 0.25,
}