Skip to content

Commit c290d72

Browse files
authored
Add IBIT market signal runtime input support (#113)
1 parent e103470 commit c290d72

9 files changed

Lines changed: 258 additions & 6 deletions

.github/workflows/sync-cloud-run-env.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ jobs:
7373
FIRSTRADE_STATE_PREFIX: ${{ vars.FIRSTRADE_STATE_PREFIX }}
7474
FIRSTRADE_STRATEGY_CONFIG_PATH: ${{ vars.FIRSTRADE_STRATEGY_CONFIG_PATH }}
7575
FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON }}
76+
FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI: ${{ vars.FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI }}
77+
FIRSTRADE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI: ${{ vars.FIRSTRADE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI }}
78+
FIRSTRADE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI: ${{ vars.FIRSTRADE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI }}
79+
FIRSTRADE_MARKET_SIGNAL_CACHE_DIR: ${{ vars.FIRSTRADE_MARKET_SIGNAL_CACHE_DIR }}
80+
FIRSTRADE_MARKET_SIGNAL_REQUIRED: ${{ vars.FIRSTRADE_MARKET_SIGNAL_REQUIRED }}
7681
STRATEGY_PLUGIN_ALERT_CHANNELS: ${{ vars.STRATEGY_PLUGIN_ALERT_CHANNELS }}
7782
STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS }}
7883
STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL }}
@@ -537,6 +542,11 @@ jobs:
537542
add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH
538543
add_optional_env FIRSTRADE_STRATEGY_CONFIG_PATH
539544
add_optional_env FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON
545+
add_optional_env FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI
546+
add_optional_env FIRSTRADE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI
547+
add_optional_env FIRSTRADE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI
548+
add_optional_env FIRSTRADE_MARKET_SIGNAL_CACHE_DIR
549+
add_optional_env FIRSTRADE_MARKET_SIGNAL_REQUIRED
540550
add_optional_env STRATEGY_PLUGIN_ALERT_CHANNELS
541551
add_optional_env STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS
542552
add_optional_env STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL

application/rebalance_service.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
)
6262
from quant_platform_kit.strategy_contracts import build_strategy_evaluation_inputs
6363
from runtime_config_support import PlatformRuntimeSettings, load_platform_runtime_settings
64+
from market_signal_runtime import resolve_external_market_signal_inputs
6465
from strategy_runtime import load_strategy_runtime
6566

6667
LIMIT_SELL_DISCOUNT = 0.995
@@ -118,15 +119,30 @@ def build_market_inputs(
118119
market_data_port,
119120
benchmark_symbol: str,
120121
strategy_runtime_config: Mapping[str, Any],
122+
strategy_profile: str | None = None,
123+
runtime_settings: PlatformRuntimeSettings | None = None,
124+
log_message: Callable[[str], None] = print,
121125
) -> dict[str, Any]:
122126
inputs: dict[str, Any] = {}
127+
if runtime_settings is not None and strategy_profile is not None:
128+
inputs.update(
129+
resolve_external_market_signal_inputs(
130+
strategy_profile=strategy_profile,
131+
available_inputs=available_inputs,
132+
runtime_settings=runtime_settings,
133+
logger=log_message,
134+
)
135+
)
123136
if "market_history" in available_inputs:
124137
inputs["market_history"] = _build_market_history_loader(market_data_port)
125138
if "benchmark_history" in available_inputs:
126139
inputs["benchmark_history"] = _build_price_history(market_data_port, benchmark_symbol)
127140
if "qqq_history" in available_inputs:
128141
inputs["qqq_history"] = _build_price_history(market_data_port, benchmark_symbol)
129-
if "derived_indicators" in available_inputs or "indicators" in available_inputs:
142+
if (
143+
("derived_indicators" in available_inputs and "derived_indicators" not in inputs)
144+
or ("indicators" in available_inputs and "indicators" not in inputs)
145+
):
130146
indicators = _build_derived_indicators(
131147
market_data_port,
132148
trend_ma_window=int(strategy_runtime_config.get("trend_ma_window", 150)),
@@ -343,6 +359,9 @@ def run_strategy_cycle(
343359
market_data_port=market_data_port,
344360
benchmark_symbol=benchmark_symbol,
345361
strategy_runtime_config=strategy_runtime.merged_runtime_config,
362+
strategy_profile=settings.strategy_profile,
363+
runtime_settings=settings,
364+
log_message=log_message,
346365
)
347366
evaluation_inputs = build_strategy_evaluation_inputs(
348367
available_inputs=available_inputs,

market_signal_runtime.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from __future__ import annotations
2+
3+
from datetime import date, datetime
4+
from pathlib import Path
5+
from typing import Any, Callable, Iterable
6+
7+
from us_equity_strategies.signals import (
8+
IBIT_SMART_DCA_MARKET_SIGNAL_CONSUMER,
9+
MARKET_SIGNAL_REFERENCE_CONSUMPTION_AUDIT,
10+
MARKET_SIGNAL_REFERENCE_PLATFORM_HANDOFF,
11+
MARKET_SIGNAL_REFERENCE_PLATFORM_HANDOFF_INDEX,
12+
extract_consumer_market_signal_inputs_from_reference,
13+
)
14+
15+
16+
IBIT_SMART_DCA_PROFILE = "ibit_smart_dca"
17+
DEFAULT_MARKET_SIGNAL_CACHE_DIR = "/tmp/quant-platform-market-signals"
18+
19+
20+
def resolve_external_market_signal_inputs(
21+
*,
22+
strategy_profile: str,
23+
available_inputs: Iterable[str],
24+
runtime_settings: Any,
25+
as_of: Any = None,
26+
logger: Callable[[str], None] = print,
27+
client_factory: Any = None,
28+
) -> dict[str, Any]:
29+
if str(strategy_profile or "").strip().lower() != IBIT_SMART_DCA_PROFILE:
30+
return {}
31+
if "derived_indicators" not in {str(item) for item in available_inputs or ()}:
32+
return {}
33+
34+
reference_type, reference = _market_signal_reference(runtime_settings)
35+
if reference is None:
36+
if bool(getattr(runtime_settings, "market_signal_required", False)):
37+
raise RuntimeError("IBIT external market signal is required but no signal reference is configured")
38+
return {"derived_indicators": {}}
39+
40+
market_inputs, metadata = extract_consumer_market_signal_inputs_from_reference(
41+
reference,
42+
reference_type=reference_type,
43+
consumer=IBIT_SMART_DCA_MARKET_SIGNAL_CONSUMER,
44+
cache_dir=_market_signal_cache_dir(runtime_settings),
45+
as_of=_market_signal_as_of(as_of),
46+
client_factory=client_factory,
47+
)
48+
logger(
49+
"market_signal_inputs_loaded | "
50+
f"profile={strategy_profile} reference_type={metadata.get('reference_type')} "
51+
f"source_uri={metadata.get('source_uri') or reference} "
52+
f"materialized_count={metadata.get('materialized_count')}"
53+
)
54+
return dict(market_inputs)
55+
56+
57+
def _market_signal_reference(runtime_settings: Any) -> tuple[str, str | None]:
58+
consumption_audit_uri = _optional_string(
59+
getattr(runtime_settings, "market_signal_consumption_audit_uri", None)
60+
)
61+
if consumption_audit_uri:
62+
return MARKET_SIGNAL_REFERENCE_CONSUMPTION_AUDIT, consumption_audit_uri
63+
64+
handoff_manifest_uri = _optional_string(
65+
getattr(runtime_settings, "market_signal_handoff_manifest_uri", None)
66+
)
67+
if handoff_manifest_uri:
68+
return MARKET_SIGNAL_REFERENCE_PLATFORM_HANDOFF, handoff_manifest_uri
69+
70+
handoff_index_uri = _optional_string(
71+
getattr(runtime_settings, "market_signal_handoff_index_uri", None)
72+
)
73+
if handoff_index_uri:
74+
return MARKET_SIGNAL_REFERENCE_PLATFORM_HANDOFF_INDEX, handoff_index_uri
75+
76+
return MARKET_SIGNAL_REFERENCE_PLATFORM_HANDOFF_INDEX, None
77+
78+
79+
def _market_signal_cache_dir(runtime_settings: Any) -> Path:
80+
configured = _optional_string(getattr(runtime_settings, "market_signal_cache_dir", None))
81+
return Path(configured or DEFAULT_MARKET_SIGNAL_CACHE_DIR)
82+
83+
84+
def _market_signal_as_of(value: Any) -> str | None:
85+
if value is None:
86+
return None
87+
if isinstance(value, datetime):
88+
return value.date().isoformat()
89+
if isinstance(value, date):
90+
return value.isoformat()
91+
text = str(value).strip()
92+
return text[:10] if text else None
93+
94+
95+
def _optional_string(value: Any) -> str | None:
96+
text = str(value or "").strip()
97+
return text or None

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ authors = [
1414
]
1515
dependencies = [
1616
"firstrade==0.0.39",
17-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b846c9d777a450e95d23c264853997d671f47dd9",
18-
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@031a5bbd5a5ec64a57225a2b24f6569359dc9b11",
17+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@6a273601b5213844168d4642a7f3c9c4ed3ce162",
18+
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@3b557b349566caa14b5630aea614fd10a0310f1d",
1919
"google-cloud-storage",
2020
"requests",
2121
]

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
flask
22
gunicorn
33
firstrade==0.0.39
4-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b846c9d777a450e95d23c264853997d671f47dd9
5-
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@031a5bbd5a5ec64a57225a2b24f6569359dc9b11
4+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@6a273601b5213844168d4642a7f3c9c4ed3ce162
5+
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@3b557b349566caa14b5630aea614fd10a0310f1d
66
google-cloud-storage
77
google-auth
88
requests

runtime_config_support.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ class PlatformRuntimeSettings:
5959
dca_mode: str | None = None
6060
dca_base_investment_usd: float | None = None
6161
runtime_execution_window_trading_days: int | None = None
62+
market_signal_handoff_index_uri: str | None = None
63+
market_signal_handoff_manifest_uri: str | None = None
64+
market_signal_consumption_audit_uri: str | None = None
65+
market_signal_cache_dir: str | None = None
66+
market_signal_required: bool = False
6267
feature_snapshot_path: str | None = None
6368
feature_snapshot_manifest_path: str | None = None
6469
strategy_config_path: str | None = None
@@ -179,6 +184,29 @@ def load_platform_runtime_settings(
179184
runtime_execution_window_trading_days=_runtime_execution_window_trading_days_env(
180185
strategy_definition.profile
181186
),
187+
market_signal_handoff_index_uri=_first_non_empty(
188+
os.getenv("FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI"),
189+
os.getenv("MARKET_SIGNAL_HANDOFF_INDEX_URI"),
190+
),
191+
market_signal_handoff_manifest_uri=_first_non_empty(
192+
os.getenv("FIRSTRADE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI"),
193+
os.getenv("MARKET_SIGNAL_HANDOFF_MANIFEST_URI"),
194+
),
195+
market_signal_consumption_audit_uri=_first_non_empty(
196+
os.getenv("FIRSTRADE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI"),
197+
os.getenv("MARKET_SIGNAL_CONSUMPTION_AUDIT_URI"),
198+
),
199+
market_signal_cache_dir=_first_non_empty(
200+
os.getenv("FIRSTRADE_MARKET_SIGNAL_CACHE_DIR"),
201+
os.getenv("MARKET_SIGNAL_CACHE_DIR"),
202+
),
203+
market_signal_required=resolve_bool_value(
204+
_first_non_empty(
205+
os.getenv("FIRSTRADE_MARKET_SIGNAL_REQUIRED"),
206+
os.getenv("MARKET_SIGNAL_REQUIRED"),
207+
"false",
208+
)
209+
),
182210
feature_snapshot_path=runtime_paths.feature_snapshot_path,
183211
feature_snapshot_manifest_path=runtime_paths.feature_snapshot_manifest_path,
184212
strategy_config_path=runtime_paths.strategy_config_path,

strategy_runtime.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
build_strategy_context_from_available_inputs,
1919
)
2020
from runtime_config_support import PlatformRuntimeSettings
21+
from market_signal_runtime import resolve_external_market_signal_inputs
2122
from strategy_loader import (
2223
load_strategy_entrypoint_for_profile,
2324
load_strategy_runtime_adapter_for_profile,
@@ -76,11 +77,21 @@ def evaluate(
7677
)
7778

7879
as_of = datetime.now(timezone.utc)
80+
resolved_available_inputs = dict(available_inputs)
81+
resolved_available_inputs.update(
82+
resolve_external_market_signal_inputs(
83+
strategy_profile=self.profile,
84+
available_inputs=self.runtime_adapter.available_inputs or self.entrypoint.manifest.required_inputs,
85+
runtime_settings=self.runtime_settings,
86+
as_of=as_of,
87+
logger=self.logger,
88+
)
89+
)
7990
ctx = build_strategy_context_from_available_inputs(
8091
entrypoint=self.entrypoint,
8192
runtime_adapter=self.runtime_adapter,
8293
as_of=as_of,
83-
available_inputs=available_inputs,
94+
available_inputs=resolved_available_inputs,
8495
runtime_config=runtime_config,
8596
)
8697
decision = self.entrypoint.evaluate(ctx)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timezone
4+
from types import SimpleNamespace
5+
6+
import pytest
7+
8+
import market_signal_runtime
9+
10+
11+
def test_non_ibit_profile_does_not_load_market_signal():
12+
settings = SimpleNamespace(market_signal_required=True)
13+
14+
assert (
15+
market_signal_runtime.resolve_external_market_signal_inputs(
16+
strategy_profile="soxl_soxx_trend_income",
17+
available_inputs={"derived_indicators"},
18+
runtime_settings=settings,
19+
)
20+
== {}
21+
)
22+
23+
24+
def test_ibit_without_reference_provides_empty_indicator_input():
25+
settings = SimpleNamespace(market_signal_required=False)
26+
27+
assert market_signal_runtime.resolve_external_market_signal_inputs(
28+
strategy_profile="ibit_smart_dca",
29+
available_inputs={"derived_indicators"},
30+
runtime_settings=settings,
31+
) == {"derived_indicators": {}}
32+
33+
34+
def test_ibit_required_reference_missing_raises():
35+
settings = SimpleNamespace(market_signal_required=True)
36+
37+
with pytest.raises(RuntimeError, match="external market signal is required"):
38+
market_signal_runtime.resolve_external_market_signal_inputs(
39+
strategy_profile="ibit_smart_dca",
40+
available_inputs={"derived_indicators"},
41+
runtime_settings=settings,
42+
)
43+
44+
45+
def test_ibit_handoff_index_reference_is_extracted(monkeypatch, tmp_path):
46+
calls: dict[str, object] = {}
47+
48+
def fake_extract(reference, *, reference_type, consumer, cache_dir, as_of, client_factory=None):
49+
calls["extract"] = (reference, reference_type, consumer, cache_dir, as_of, client_factory)
50+
return {"derived_indicators": {"BTC": {"mvrv_z_score": 1.0}}}, {
51+
"reference_type": reference_type,
52+
"source_uri": reference,
53+
"materialized_count": 2,
54+
}
55+
56+
monkeypatch.setattr(
57+
market_signal_runtime,
58+
"extract_consumer_market_signal_inputs_from_reference",
59+
fake_extract,
60+
)
61+
settings = SimpleNamespace(
62+
market_signal_handoff_index_uri="gs://signals/platform_handoffs/index.json",
63+
market_signal_cache_dir=str(tmp_path),
64+
market_signal_required=False,
65+
)
66+
67+
assert market_signal_runtime.resolve_external_market_signal_inputs(
68+
strategy_profile="ibit_smart_dca",
69+
available_inputs={"derived_indicators"},
70+
runtime_settings=settings,
71+
as_of=datetime(2026, 6, 19, tzinfo=timezone.utc),
72+
logger=lambda _message: None,
73+
client_factory=object,
74+
) == {"derived_indicators": {"BTC": {"mvrv_z_score": 1.0}}}
75+
assert calls["extract"] == (
76+
"gs://signals/platform_handoffs/index.json",
77+
"platform_handoff_index",
78+
"us_equity:ibit_smart_dca",
79+
tmp_path,
80+
"2026-06-19",
81+
object,
82+
)

tests/test_sync_cloud_run_env_workflow.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ def test_sync_cloud_run_env_workflow_syncs_strategy_plugin_alert_settings():
4949
"INCOME_LAYER_ENABLED",
5050
"INCOME_LAYER_START_USD",
5151
"INCOME_LAYER_MAX_RATIO",
52+
"FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI",
53+
"FIRSTRADE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI",
54+
"FIRSTRADE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI",
55+
"FIRSTRADE_MARKET_SIGNAL_CACHE_DIR",
56+
"FIRSTRADE_MARKET_SIGNAL_REQUIRED",
5257
):
5358
assert f"{name}: ${{{{ vars.{name} }}}}" in workflow
5459
assert f"add_optional_env {name}" in workflow

0 commit comments

Comments
 (0)