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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ NOTIFY_LANG=en
TELEGRAM_TOKEN=
GLOBAL_TELEGRAM_CHAT_ID=
FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON=
DCA_MODE=
DCA_BASE_INVESTMENT_USD=
IBIT_ZSCORE_EXIT_ENABLED=
IBIT_ZSCORE_EXIT_MODE=
IBIT_ZSCORE_EXIT_PARKING_SYMBOL=BOXX
IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE=
IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE=
IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW=

# Optional email channel for escalated strategy plugin alerts.
STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS=
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ jobs:
INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }}
INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }}
INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }}
DCA_MODE: ${{ vars.DCA_MODE }}
DCA_BASE_INVESTMENT_USD: ${{ vars.DCA_BASE_INVESTMENT_USD }}
IBIT_ZSCORE_EXIT_ENABLED: ${{ vars.IBIT_ZSCORE_EXIT_ENABLED }}
IBIT_ZSCORE_EXIT_MODE: ${{ vars.IBIT_ZSCORE_EXIT_MODE }}
IBIT_ZSCORE_EXIT_PARKING_SYMBOL: ${{ vars.IBIT_ZSCORE_EXIT_PARKING_SYMBOL }}
IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE }}
IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE }}
IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW: ${{ vars.IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW }}
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 }}
Expand Down Expand Up @@ -589,6 +597,14 @@ jobs:
add_optional_env INCOME_LAYER_ENABLED
add_optional_env INCOME_LAYER_START_USD
add_optional_env INCOME_LAYER_MAX_RATIO
add_optional_env DCA_MODE
add_optional_env DCA_BASE_INVESTMENT_USD
add_optional_env IBIT_ZSCORE_EXIT_ENABLED
add_optional_env IBIT_ZSCORE_EXIT_MODE
add_optional_env IBIT_ZSCORE_EXIT_PARKING_SYMBOL
add_optional_env IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE
add_optional_env IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE
add_optional_env IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW
add_optional_env RUNTIME_TARGET_ENABLED
add_optional_env EXECUTION_REPORT_GCS_URI
add_optional_env GLOBAL_TELEGRAM_CHAT_ID
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ flask
gunicorn
firstrade==0.0.39
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@46ca4ea3de8f98a58e2dd86158e7f2070d085cd1
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@173b8abbabc6a281b4707e100d248eae99e1f9d5
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@ced1f78827e6112292af24d32dfe0e0f009e2833

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 Avoid pulling in the global ETF snapshot migration

This pin advances through the upstream commit that changes global_etf_rotation from direct market_history/portfolio_snapshot inputs to snapshot-backed feature_snapshot artifacts. In this repo, tests/test_strategy_registry.py still asserts the Firstrade adapter for global_etf_rotation uses broker runtime inputs, and the env-sync workflow will now require FIRSTRADE_FEATURE_SNAPSHOT_PATH for that profile; existing global ETF deployments without snapshot artifacts will start failing env sync instead of running from broker market data. Please either pin to a strategy commit that contains only the IBIT changes or update the platform/test/configuration for the new snapshot-backed contract.

Useful? React with 👍 / 👎.

google-cloud-storage
google-auth
requests
Expand Down
47 changes: 47 additions & 0 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ class PlatformRuntimeSettings:
income_layer_max_ratio: float | None = None
dca_mode: str | None = None
dca_base_investment_usd: float | None = None
ibit_zscore_exit_enabled: bool | None = None
ibit_zscore_exit_mode: str | None = None
ibit_zscore_exit_parking_symbol: str | None = None
ibit_zscore_exit_risk_reduced_exposure: float | None = None
ibit_zscore_exit_risk_off_exposure: float | None = None
ibit_zscore_exit_allow_outside_execution_window: bool | None = None
runtime_execution_window_trading_days: int | None = None
market_signal_handoff_index_uri: str | None = None
market_signal_handoff_manifest_uri: str | None = None
Expand Down Expand Up @@ -186,6 +192,16 @@ def load_platform_runtime_settings(
income_layer_max_ratio=_optional_ratio_env("INCOME_LAYER_MAX_RATIO"),
dca_mode=_optional_dca_mode_env("DCA_MODE"),
dca_base_investment_usd=_optional_positive_float_env("DCA_BASE_INVESTMENT_USD"),
ibit_zscore_exit_enabled=_optional_bool_env("IBIT_ZSCORE_EXIT_ENABLED"),
ibit_zscore_exit_mode=_optional_ibit_zscore_exit_mode_env("IBIT_ZSCORE_EXIT_MODE"),
ibit_zscore_exit_parking_symbol=_optional_symbol_env("IBIT_ZSCORE_EXIT_PARKING_SYMBOL"),
ibit_zscore_exit_risk_reduced_exposure=_optional_ratio_env(
"IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE"
),
ibit_zscore_exit_risk_off_exposure=_optional_ratio_env("IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE"),
ibit_zscore_exit_allow_outside_execution_window=_optional_bool_env(
"IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW"
),
runtime_execution_window_trading_days=_runtime_execution_window_trading_days_env(
strategy_definition.profile
),
Expand Down Expand Up @@ -407,6 +423,37 @@ def _optional_dca_mode_env(name: str) -> str | None:
return mode


def _optional_ibit_zscore_exit_mode_env(name: str) -> str | None:
raw_value = os.getenv(name)
if raw_value is None or str(raw_value).strip() == "":
return None
value = str(raw_value).strip().lower()
aliases = {
"off": "disabled",
"none": "disabled",
"false": "disabled",
"disable": "disabled",
"enabled": "live",
"shadow": "paper",
"dry_run": "paper",
"dry-run": "paper",
}
mode = aliases.get(value, value)
if mode not in {"disabled", "paper", "live"}:
raise ValueError(f"{name} must be disabled, paper, or live, got {raw_value!r}")
return mode


def _optional_symbol_env(name: str) -> str | None:
raw_value = os.getenv(name)
if raw_value is None or str(raw_value).strip() == "":
return None
value = str(raw_value).strip().upper()
if len(value) > 16 or not value.replace(".", "").replace("-", "").isalnum():
raise ValueError(f"{name} must be a symbol")
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
25 changes: 25 additions & 0 deletions strategy_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

_FEATURE_SNAPSHOT_INPUT = "feature_snapshot"
DCA_PROFILES = frozenset({"nasdaq_sp500_smart_dca", "ibit_smart_dca"})
IBIT_ZSCORE_EXIT_PROFILE = "ibit_smart_dca"


@dataclass(frozen=True)
Expand Down Expand Up @@ -183,6 +184,7 @@ def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSett
overrides["smart_multiplier_enabled"] = dca_mode == "smart"
if dca_base_investment_usd is not None:
overrides["base_investment_usd"] = dca_base_investment_usd
_apply_ibit_zscore_exit_runtime_overrides(profile, runtime_settings, overrides)
if profile == "tqqq_growth_income":
if runtime_settings.income_threshold_usd is not None:
overrides["income_threshold_usd"] = runtime_settings.income_threshold_usd
Expand All @@ -199,6 +201,29 @@ def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSett
return overrides


def _apply_ibit_zscore_exit_runtime_overrides(
profile: str,
runtime_settings: PlatformRuntimeSettings,
overrides: dict[str, Any],
) -> None:
if profile != IBIT_ZSCORE_EXIT_PROFILE:
return
for setting_name, override_name in (
("ibit_zscore_exit_enabled", "ibit_zscore_exit_enabled"),
("ibit_zscore_exit_mode", "ibit_zscore_exit_mode"),
("ibit_zscore_exit_parking_symbol", "ibit_zscore_exit_parking_symbol"),
("ibit_zscore_exit_risk_reduced_exposure", "ibit_zscore_exit_risk_reduced_exposure"),
("ibit_zscore_exit_risk_off_exposure", "ibit_zscore_exit_risk_off_exposure"),
(
"ibit_zscore_exit_allow_outside_execution_window",
"ibit_zscore_exit_allow_outside_execution_window",
),
):
value = getattr(runtime_settings, setting_name, None)
if value is not None:
overrides[override_name] = value


def load_strategy_runtime(
raw_profile: str | None,
*,
Expand Down
19 changes: 19 additions & 0 deletions tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,25 @@ def test_income_layer_overrides_load_from_env(monkeypatch):
assert settings.income_layer_max_ratio == 0.25


def test_ibit_zscore_exit_overrides_load_from_env(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("ibit_smart_dca"))
monkeypatch.setenv("IBIT_ZSCORE_EXIT_ENABLED", "true")
monkeypatch.setenv("IBIT_ZSCORE_EXIT_MODE", "enabled")
monkeypatch.setenv("IBIT_ZSCORE_EXIT_PARKING_SYMBOL", "boxx")
monkeypatch.setenv("IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE", "0.5")
monkeypatch.setenv("IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE", "0.25")
monkeypatch.setenv("IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW", "true")

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

assert settings.ibit_zscore_exit_enabled is True
assert settings.ibit_zscore_exit_mode == "live"
assert settings.ibit_zscore_exit_parking_symbol == "BOXX"
assert settings.ibit_zscore_exit_risk_reduced_exposure == 0.5
assert settings.ibit_zscore_exit_risk_off_exposure == 0.25
assert settings.ibit_zscore_exit_allow_outside_execution_window is True


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")
Expand Down
21 changes: 21 additions & 0 deletions tests/test_strategy_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,27 @@ def test_dca_overrides_apply_to_runtime_config():
}


def test_ibit_zscore_exit_overrides_apply_to_runtime_config():
settings = _runtime_settings(
strategy_profile="ibit_smart_dca",
ibit_zscore_exit_enabled=True,
ibit_zscore_exit_mode="live",
ibit_zscore_exit_parking_symbol="BOXX",
ibit_zscore_exit_risk_reduced_exposure=0.5,
ibit_zscore_exit_risk_off_exposure=0.25,
ibit_zscore_exit_allow_outside_execution_window=True,
)

assert _build_runtime_overrides("ibit_smart_dca", settings) == {
"ibit_zscore_exit_enabled": True,
"ibit_zscore_exit_mode": "live",
"ibit_zscore_exit_parking_symbol": "BOXX",
"ibit_zscore_exit_risk_reduced_exposure": 0.5,
"ibit_zscore_exit_risk_off_exposure": 0.25,
"ibit_zscore_exit_allow_outside_execution_window": True,
}


def test_reserved_cash_policy_overrides_apply_to_runtime_config():
settings = _runtime_settings(
strategy_profile="soxl_soxx_trend_income",
Expand Down
8 changes: 8 additions & 0 deletions tests/test_sync_cloud_run_env_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ def test_sync_cloud_run_env_workflow_syncs_strategy_plugin_alert_settings():
"INCOME_LAYER_ENABLED",
"INCOME_LAYER_START_USD",
"INCOME_LAYER_MAX_RATIO",
"DCA_MODE",
"DCA_BASE_INVESTMENT_USD",
"IBIT_ZSCORE_EXIT_ENABLED",
"IBIT_ZSCORE_EXIT_MODE",
"IBIT_ZSCORE_EXIT_PARKING_SYMBOL",
"IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE",
"IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE",
"IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW",
"FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI",
"FIRSTRADE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI",
"FIRSTRADE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI",
Expand Down