Skip to content

Commit ebf7f7f

Browse files
authored
Merge pull request #128 from QuantStrategyLab/fix/ibit-smart-dca-market-signal-config
Fix IBIT smart DCA market signal wiring for Firstrade
2 parents b1da79d + fd11671 commit ebf7f7f

6 files changed

Lines changed: 192 additions & 16 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ GLOBAL_TELEGRAM_CHAT_ID=
2525
FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON=
2626
DCA_MODE=
2727
DCA_BASE_INVESTMENT_USD=
28+
FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI=
29+
FIRSTRADE_MARKET_SIGNAL_REQUIRED=
30+
FIRSTRADE_MARKET_SIGNAL_FALLBACK_MODE=last_valid
31+
FIRSTRADE_MARKET_SIGNAL_MAX_STALE_DAYS=3
32+
EXECUTION_REPORT_GCS_URI=
2833
IBIT_ZSCORE_EXIT_ENABLED=
2934
IBIT_ZSCORE_EXIT_MODE=
3035
IBIT_ZSCORE_EXIT_PARKING_SYMBOL=BOXX

application/rebalance_service.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
publish_strategy_plugin_alerts as dispatch_strategy_plugin_alerts,
6161
)
6262
from quant_platform_kit.strategy_contracts import build_strategy_evaluation_inputs
63-
from runtime_config_support import PlatformRuntimeSettings, load_platform_runtime_settings
63+
from runtime_config_support import IBIT_SMART_DCA_PROFILE, PlatformRuntimeSettings, load_platform_runtime_settings
6464
from market_signal_runtime import resolve_external_market_signal_inputs
6565
from strategy_runtime import load_strategy_runtime
6666

@@ -147,6 +147,15 @@ def _build_derived_indicators(market_data_port, *, trend_ma_window: int):
147147
)
148148

149149

150+
def _ibit_smart_multiplier_enabled(
151+
strategy_profile: str | None,
152+
strategy_runtime_config: Mapping[str, Any],
153+
) -> bool:
154+
if str(strategy_profile or "").strip().lower() != IBIT_SMART_DCA_PROFILE:
155+
return False
156+
return bool(strategy_runtime_config.get("smart_multiplier_enabled"))
157+
158+
150159
def build_market_inputs(
151160
*,
152161
available_inputs: set[str],
@@ -167,7 +176,11 @@ def build_market_inputs(
167176
logger=log_message,
168177
)
169178
)
170-
if "market_history" in available_inputs:
179+
skip_broker_market_history = _ibit_smart_multiplier_enabled(
180+
strategy_profile,
181+
strategy_runtime_config,
182+
)
183+
if "market_history" in available_inputs and not skip_broker_market_history:
171184
inputs["market_history"] = _build_market_history_loader(market_data_port)
172185
if "benchmark_history" in available_inputs:
173186
inputs["benchmark_history"] = _build_price_history(market_data_port, benchmark_symbol)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ authors = [
1515
dependencies = [
1616
"firstrade==0.0.39",
1717
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5",
18-
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@0cbdacc95fb9041f590472254aef8f1cea35adf8",
18+
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@a7306afc943cad8ddb7e368efd24640e84fd6e3d",
1919
"google-cloud-storage",
2020
"requests",
2121
]

runtime_config_support.py

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
DEFAULT_RESERVED_CASH_FLOOR_USD = 0.0
2828
DEFAULT_RESERVED_CASH_RATIO = 0.0
2929
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
30+
IBIT_SMART_DCA_PROFILE = "ibit_smart_dca"
3031

3132

3233
@dataclass(frozen=True)
@@ -205,10 +206,7 @@ def load_platform_runtime_settings(
205206
runtime_execution_window_trading_days=_runtime_execution_window_trading_days_env(
206207
strategy_definition.profile
207208
),
208-
market_signal_handoff_index_uri=_first_non_empty(
209-
os.getenv("FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI"),
210-
os.getenv("MARKET_SIGNAL_HANDOFF_INDEX_URI"),
211-
),
209+
market_signal_handoff_index_uri=_resolve_market_signal_handoff_index_uri(),
212210
market_signal_handoff_manifest_uri=_first_non_empty(
213211
os.getenv("FIRSTRADE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI"),
214212
os.getenv("MARKET_SIGNAL_HANDOFF_MANIFEST_URI"),
@@ -221,16 +219,13 @@ def load_platform_runtime_settings(
221219
os.getenv("FIRSTRADE_MARKET_SIGNAL_CACHE_DIR"),
222220
os.getenv("MARKET_SIGNAL_CACHE_DIR"),
223221
),
224-
market_signal_required=resolve_bool_value(
225-
_first_non_empty(
226-
os.getenv("FIRSTRADE_MARKET_SIGNAL_REQUIRED"),
227-
os.getenv("MARKET_SIGNAL_REQUIRED"),
228-
"false",
229-
)
222+
market_signal_required=_resolve_market_signal_required(
223+
strategy_profile=strategy_definition.profile,
224+
dca_mode=_optional_dca_mode_env("DCA_MODE"),
230225
),
231-
market_signal_fallback_mode=_first_non_empty(
232-
os.getenv("FIRSTRADE_MARKET_SIGNAL_FALLBACK_MODE"),
233-
os.getenv("MARKET_SIGNAL_FALLBACK_MODE"),
226+
market_signal_fallback_mode=_resolve_market_signal_fallback_mode(
227+
strategy_profile=strategy_definition.profile,
228+
dca_mode=_optional_dca_mode_env("DCA_MODE"),
234229
),
235230
market_signal_max_stale_days=_optional_int(
236231
_first_non_empty(
@@ -472,6 +467,58 @@ def _resolve_ratio_env(name: str, *, default: float) -> float:
472467
return value
473468

474469

470+
def _default_market_signal_handoff_index_uri_from_report_bucket(
471+
execution_report_gcs_uri: str | None,
472+
) -> str | None:
473+
text = str(execution_report_gcs_uri or "").strip()
474+
if not text.startswith("gs://"):
475+
return None
476+
bucket = text[len("gs://") :].split("/", 1)[0].strip()
477+
if not bucket:
478+
return None
479+
return f"gs://{bucket}/platform_handoffs/index.json"
480+
481+
482+
def _resolve_market_signal_handoff_index_uri() -> str | None:
483+
explicit = _first_non_empty(
484+
os.getenv("FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI"),
485+
os.getenv("MARKET_SIGNAL_HANDOFF_INDEX_URI"),
486+
)
487+
if explicit:
488+
return explicit
489+
return _default_market_signal_handoff_index_uri_from_report_bucket(
490+
os.getenv("EXECUTION_REPORT_GCS_URI"),
491+
)
492+
493+
494+
def _resolve_market_signal_required(*, strategy_profile: str, dca_mode: str | None) -> bool:
495+
explicit = _first_non_empty(
496+
os.getenv("FIRSTRADE_MARKET_SIGNAL_REQUIRED"),
497+
os.getenv("MARKET_SIGNAL_REQUIRED"),
498+
)
499+
if explicit is not None:
500+
return resolve_bool_value(explicit)
501+
if strategy_profile == IBIT_SMART_DCA_PROFILE and str(dca_mode or "").strip().lower() == "smart":
502+
return True
503+
return False
504+
505+
506+
def _resolve_market_signal_fallback_mode(
507+
*,
508+
strategy_profile: str,
509+
dca_mode: str | None,
510+
) -> str | None:
511+
explicit = _first_non_empty(
512+
os.getenv("FIRSTRADE_MARKET_SIGNAL_FALLBACK_MODE"),
513+
os.getenv("MARKET_SIGNAL_FALLBACK_MODE"),
514+
)
515+
if explicit:
516+
return explicit
517+
if strategy_profile == IBIT_SMART_DCA_PROFILE and str(dca_mode or "").strip().lower() == "smart":
518+
return "last_valid"
519+
return None
520+
521+
475522
def _first_non_empty(*raw_values: str | None) -> str | None:
476523
for raw_value in raw_values:
477524
value = str(raw_value or "").strip()

tests/test_build_market_inputs.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from __future__ import annotations
2+
3+
from types import SimpleNamespace
4+
5+
from application.rebalance_service import build_market_inputs
6+
7+
8+
class _FakeMarketDataPort:
9+
def get_price_series(self, _symbol):
10+
raise AssertionError("broker market history should not be queried")
11+
12+
13+
def test_ibit_smart_mode_skips_broker_market_history_loader():
14+
inputs = build_market_inputs(
15+
available_inputs={"derived_indicators", "market_history", "portfolio_snapshot"},
16+
market_data_port=_FakeMarketDataPort(),
17+
benchmark_symbol="QQQ",
18+
strategy_runtime_config={"smart_multiplier_enabled": True},
19+
strategy_profile="ibit_smart_dca",
20+
runtime_settings=SimpleNamespace(market_signal_required=False),
21+
)
22+
23+
assert "derived_indicators" in inputs
24+
assert "market_history" not in inputs
25+
26+
27+
def test_ibit_fixed_mode_keeps_broker_market_history_loader():
28+
inputs = build_market_inputs(
29+
available_inputs={"derived_indicators", "market_history", "portfolio_snapshot"},
30+
market_data_port=_FakeMarketDataPort(),
31+
benchmark_symbol="QQQ",
32+
strategy_runtime_config={"smart_multiplier_enabled": False},
33+
strategy_profile="ibit_smart_dca",
34+
runtime_settings=SimpleNamespace(market_signal_required=False),
35+
)
36+
37+
assert callable(inputs["market_history"])

tests/test_runtime_config_support.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
import pytest
44

55
from runtime_config_support import (
6+
_default_market_signal_handoff_index_uri_from_report_bucket,
7+
_resolve_market_signal_fallback_mode,
8+
_resolve_market_signal_handoff_index_uri,
9+
_resolve_market_signal_required,
610
_resolve_non_negative_float_env,
711
_resolve_ratio_env,
812
_runtime_execution_window_trading_days_env,
@@ -275,3 +279,73 @@ def test_runtime_execution_window_rejects_invalid_generic_env(monkeypatch, raw_v
275279
match="FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS",
276280
):
277281
_runtime_execution_window_trading_days_env("russell_top50_leader_rotation")
282+
283+
284+
def test_market_signal_handoff_index_infers_from_execution_report_bucket():
285+
assert (
286+
_default_market_signal_handoff_index_uri_from_report_bucket(
287+
"gs://qsl-runtime-logs-shared/execution-reports"
288+
)
289+
== "gs://qsl-runtime-logs-shared/platform_handoffs/index.json"
290+
)
291+
292+
293+
def test_ibit_smart_mode_requires_market_signal_by_default():
294+
assert _resolve_market_signal_required(
295+
strategy_profile="ibit_smart_dca",
296+
dca_mode="smart",
297+
) is True
298+
assert _resolve_market_signal_required(
299+
strategy_profile="ibit_smart_dca",
300+
dca_mode="fixed",
301+
) is False
302+
303+
304+
def test_ibit_smart_mode_defaults_market_signal_fallback_to_last_valid():
305+
assert _resolve_market_signal_fallback_mode(
306+
strategy_profile="ibit_smart_dca",
307+
dca_mode="smart",
308+
) == "last_valid"
309+
310+
311+
def test_market_signal_handoff_index_prefers_explicit_env(monkeypatch):
312+
monkeypatch.setenv(
313+
"FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI",
314+
"gs://signals/custom/platform_handoffs/index.json",
315+
)
316+
monkeypatch.setenv(
317+
"EXECUTION_REPORT_GCS_URI",
318+
"gs://qsl-runtime-logs-shared/execution-reports",
319+
)
320+
321+
assert (
322+
_resolve_market_signal_handoff_index_uri()
323+
== "gs://signals/custom/platform_handoffs/index.json"
324+
)
325+
326+
327+
def test_load_platform_runtime_settings_for_ibit_smart_wires_market_signal_defaults(monkeypatch):
328+
monkeypatch.setenv(
329+
"RUNTIME_TARGET_JSON",
330+
_target_json(profile="ibit_smart_dca"),
331+
)
332+
monkeypatch.setenv("DCA_MODE", "smart")
333+
monkeypatch.setenv(
334+
"EXECUTION_REPORT_GCS_URI",
335+
"gs://qsl-runtime-logs-shared/execution-reports",
336+
)
337+
monkeypatch.delenv("FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI", raising=False)
338+
monkeypatch.delenv("MARKET_SIGNAL_HANDOFF_INDEX_URI", raising=False)
339+
monkeypatch.delenv("FIRSTRADE_MARKET_SIGNAL_REQUIRED", raising=False)
340+
monkeypatch.delenv("MARKET_SIGNAL_REQUIRED", raising=False)
341+
monkeypatch.delenv("FIRSTRADE_MARKET_SIGNAL_FALLBACK_MODE", raising=False)
342+
monkeypatch.delenv("MARKET_SIGNAL_FALLBACK_MODE", raising=False)
343+
344+
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
345+
346+
assert settings.market_signal_required is True
347+
assert settings.market_signal_fallback_mode == "last_valid"
348+
assert (
349+
settings.market_signal_handoff_index_uri
350+
== "gs://qsl-runtime-logs-shared/platform_handoffs/index.json"
351+
)

0 commit comments

Comments
 (0)