Skip to content

Commit fc66f54

Browse files
authored
Merge pull request #386 from QuantStrategyLab/codex/lifecycle-stream-isolation
feat: isolate lifecycle telemetry account streams
2 parents a34ed1e + ff76ce0 commit fc66f54

7 files changed

Lines changed: 244 additions & 30 deletions

File tree

docs/strategy-lifecycle-benchmark-catalog.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,23 @@ Use a JSON file with this shape:
2424
Run strict monitoring with:
2525

2626
```text
27-
quant-lifecycle monitor --domain us_equity --benchmark-catalog catalog.json --require-explicit-benchmark
27+
quant-lifecycle monitor --domain us_equity --strategy soxl_soxx_trend_income --live-stream-id longbridge-quant-sg-service --benchmark-catalog catalog.json --require-explicit-benchmark
2828
```
2929

3030
Strict mode refuses to publish a snapshot when either the strategy binding or
3131
its benchmark return series is absent. The catalog is monitoring-only and
3232
never grants strategy, broker, or promotion authority.
33+
34+
## Account-safe live telemetry
35+
36+
Live account equity must be evaluated one account/runtime stream at a time.
37+
When it runs in Cloud Run, QPK records the built-in `K_SERVICE` identity as
38+
`lifecycle_stream_id`; other runtimes can set `LIFECYCLE_STREAM_ID`. The
39+
stream becomes part of the storage path and the live-record deduplication key.
40+
41+
If the same strategy profile has records from more than one stream and no
42+
stream is selected, the return collector omits that profile rather than
43+
combining separate broker accounts into a false equity curve. `--live-stream-id`
44+
is therefore required for promotion-grade monitoring of persisted live data.
45+
This is still read-only monitoring: it cannot place orders, alter a strategy,
46+
or promote a candidate.

src/quant_platform_kit/strategy_lifecycle/cli.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ def _run_monitor(args: argparse.Namespace) -> int:
3939
kwargs["strategy_benchmarks"] = load_strategy_benchmark_catalog(benchmark_catalog)
4040
if getattr(args, "require_explicit_benchmark", False):
4141
kwargs["require_explicit_benchmark"] = True
42+
live_stream_id = getattr(args, "live_stream_id", None)
43+
if live_stream_id:
44+
kwargs["live_stream_id"] = live_stream_id
4245
snapshots = run_monitor(**kwargs)
4346
_print(f"[monitor] Generated {len(snapshots)} performance snapshots")
4447
return 0
@@ -206,6 +209,7 @@ def _run_lifecycle(args: argparse.Namespace) -> int:
206209
output_dir=None,
207210
benchmark_catalog=getattr(args, "benchmark_catalog", None),
208211
require_explicit_benchmark=getattr(args, "require_explicit_benchmark", False),
212+
live_stream_id=getattr(args, "live_stream_id", None),
209213
)
210214
)
211215
if monitor_status != 0:
@@ -313,6 +317,11 @@ def build_parser() -> argparse.ArgumentParser:
313317
monitor.add_argument("--domain", default="us_equity")
314318
monitor.add_argument("--strategy", default=None)
315319
monitor.add_argument("--output-dir", default=None)
320+
monitor.add_argument(
321+
"--live-stream-id",
322+
default=None,
323+
help="Monitor one stable account/runtime telemetry stream; never mix streams.",
324+
)
316325
monitor.add_argument(
317326
"--benchmark-catalog",
318327
default=None,
@@ -384,6 +393,7 @@ def build_parser() -> argparse.ArgumentParser:
384393
lifecycle.add_argument("--dry-run-alerts", action="store_true")
385394
lifecycle.add_argument("--benchmark-catalog", default=None)
386395
lifecycle.add_argument("--require-explicit-benchmark", action="store_true")
396+
lifecycle.add_argument("--live-stream-id", default=None)
387397
_add_baseline_options(lifecycle)
388398
lifecycle.set_defaults(func=_run_lifecycle)
389399

src/quant_platform_kit/strategy_lifecycle/performance_monitor.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,37 @@ def _now_iso() -> str:
3333
return datetime.now(timezone.utc).isoformat()
3434

3535

36+
def resolve_lifecycle_stream_id(
37+
explicit_stream_id: str = "",
38+
*,
39+
execution_result: Mapping[str, Any] | None = None,
40+
) -> str:
41+
"""Return a stable, account-safe telemetry stream identity.
42+
43+
Cloud Run supplies ``K_SERVICE`` per deployed service, which keeps the
44+
same strategy on different broker accounts from being combined into a
45+
synthetic equity curve. ``LIFECYCLE_STREAM_ID`` is available for
46+
non-Cloud-Run runtimes that need a stable explicit identity.
47+
"""
48+
import os
49+
50+
for candidate in (
51+
explicit_stream_id,
52+
os.environ.get("LIFECYCLE_STREAM_ID"),
53+
os.environ.get("K_SERVICE"),
54+
):
55+
value = str(candidate or "").strip()
56+
if value:
57+
return value
58+
59+
payload = execution_result if isinstance(execution_result, Mapping) else {}
60+
platform = str(payload.get("platform") or "").strip()
61+
account_scope = str(payload.get("account_scope") or payload.get("service_name") or "").strip()
62+
if platform and account_scope:
63+
return f"{platform}:{account_scope}"
64+
return platform
65+
66+
3667
def _is_valid_series(series: object, *, min_observations: int = 10) -> bool:
3768
if not isinstance(series, pd.Series):
3869
return False
@@ -51,6 +82,7 @@ def run_monitor(
5182
collector: ReturnCollector | None = None,
5283
strategy_benchmarks: Mapping[str, str] | None = None,
5384
require_explicit_benchmark: bool = False,
85+
live_stream_id: str | None = None,
5486
) -> list[StrategyPerformanceSnapshot]:
5587
"""Run the performance monitor for the given domain.
5688
@@ -67,6 +99,9 @@ def run_monitor(
6799
require_explicit_benchmark: Refuse to monitor profiles without a binding
68100
or without the declared benchmark return series. This is the
69101
promotion-grade setting for leveraged strategies.
102+
live_stream_id: Optional stable telemetry stream identity. When live
103+
account data is used, this prevents independent broker accounts
104+
from being merged into one return series.
70105
71106
Returns:
72107
List of StrategyPerformanceSnapshot objects generated.
@@ -75,7 +110,10 @@ def run_monitor(
75110
collector = collector or ReturnCollector()
76111

77112
# 1. Collect returns
78-
all_returns = collector.collect(domain)
113+
if isinstance(collector, ReturnCollector):
114+
all_returns = collector.collect(domain, live_stream_id=live_stream_id)
115+
else:
116+
all_returns = collector.collect(domain)
79117
if not all_returns:
80118
if fail_on_empty:
81119
raise RuntimeError(
@@ -118,7 +156,7 @@ def run_monitor(
118156
snapshot = StrategyPerformanceSnapshot(
119157
strategy_profile=profile,
120158
domain=domain,
121-
platform="",
159+
platform=str(live_stream_id or "").strip(),
122160
as_of=date.today(),
123161
benchmark_symbol=benchmark_symbol,
124162
computed_at=_now_iso(),
@@ -234,6 +272,7 @@ def record(
234272
execution_result: Mapping[str, Any] | None = None,
235273
*,
236274
domain: str = "",
275+
stream_id: str = "",
237276
) -> dict[str, Any]:
238277
profile = str(profile_id or "").strip()
239278
if not profile:
@@ -246,8 +285,21 @@ def record(
246285
"decision": _serialize_decision(decision),
247286
"execution_result": dict(execution_result or {}),
248287
}
249-
self._store.save_live_run_record(profile, str(domain or "").strip(), payload)
250-
return {"ok": True, "profile": profile, "domain": str(domain or "").strip()}
288+
resolved_stream_id = resolve_lifecycle_stream_id(stream_id, execution_result=execution_result)
289+
if resolved_stream_id:
290+
payload["lifecycle_stream_id"] = resolved_stream_id
291+
self._store.save_live_run_record(
292+
profile,
293+
str(domain or "").strip(),
294+
payload,
295+
stream_id=resolved_stream_id,
296+
)
297+
return {
298+
"ok": True,
299+
"profile": profile,
300+
"domain": str(domain or "").strip(),
301+
"stream_id": resolved_stream_id,
302+
}
251303

252304
def record_execution(
253305
self,
@@ -256,6 +308,7 @@ def record_execution(
256308
*,
257309
domain: str = "",
258310
decision: Any | None = None,
311+
stream_id: str = "",
259312
) -> dict[str, Any]:
260313
"""Persist platform-layer execution telemetry after order routing."""
261314
profile = str(profile_id or "").strip()
@@ -273,8 +326,21 @@ def record_execution(
273326
}
274327
if decision is not None:
275328
payload["decision"] = _serialize_decision(decision)
276-
self._store.save_live_run_record(profile, str(domain or "").strip(), payload)
277-
return {"ok": True, "profile": profile, "domain": str(domain or "").strip()}
329+
resolved_stream_id = resolve_lifecycle_stream_id(stream_id, execution_result=execution_result)
330+
if resolved_stream_id:
331+
payload["lifecycle_stream_id"] = resolved_stream_id
332+
self._store.save_live_run_record(
333+
profile,
334+
str(domain or "").strip(),
335+
payload,
336+
stream_id=resolved_stream_id,
337+
)
338+
return {
339+
"ok": True,
340+
"profile": profile,
341+
"domain": str(domain or "").strip(),
342+
"stream_id": resolved_stream_id,
343+
}
278344

279345

280346
def infer_strategy_domain(profile_id: str, *, explicit_domain: str = "") -> str:
@@ -297,6 +363,7 @@ def try_record_platform_execution(
297363
*,
298364
domain: str = "",
299365
decision: Any | None = None,
366+
stream_id: str = "",
300367
) -> None:
301368
"""Best-effort execution recorder for platform runtimes; never raises."""
302369
try:
@@ -308,6 +375,7 @@ def try_record_platform_execution(
308375
execution_result,
309376
domain=infer_strategy_domain(profile_id, explicit_domain=domain),
310377
decision=decision,
378+
stream_id=stream_id,
311379
)
312380
except Exception as exc: # pragma: no cover
313381
logger.warning("PerformanceMonitor.record_execution failed: %s", exc)

src/quant_platform_kit/strategy_lifecycle/performance_store.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -379,32 +379,55 @@ def list_snapshot_profiles(self, domain: str) -> tuple[str, ...]:
379379

380380
# ── live runs (per-evaluate / per-execution records) ─────────
381381

382-
def _live_run_key(self, domain: str, strategy_profile: str, recorded_at: str) -> str:
382+
def _live_run_key(
383+
self,
384+
domain: str,
385+
strategy_profile: str,
386+
recorded_at: str,
387+
*,
388+
stream_id: str = "",
389+
) -> str:
383390
safe_time = recorded_at.replace(":", "-")
384-
return f"live_runs/{_clean_key(domain)}/{_clean_key(strategy_profile)}/{safe_time}.json"
391+
root = f"live_runs/{_clean_key(domain)}/{_clean_key(strategy_profile)}"
392+
stream = str(stream_id or "").strip()
393+
if stream:
394+
return f"{root}/streams/{_clean_key(stream)}/{safe_time}.json"
395+
return f"{root}/{safe_time}.json"
385396

386397
def save_live_run_record(
387398
self,
388399
strategy_profile: str,
389400
domain: str,
390401
payload: Mapping[str, Any],
402+
*,
403+
stream_id: str = "",
391404
) -> None:
392405
recorded_at = str(payload.get("recorded_at") or _now_iso())
406+
stream = str(stream_id or payload.get("lifecycle_stream_id") or "").strip()
407+
stored_payload = dict(payload)
408+
if stream:
409+
stored_payload["lifecycle_stream_id"] = stream
393410
self._write(
394-
self._live_run_key(domain, strategy_profile, recorded_at),
395-
{**dict(payload), "schema_version": SCHEMA_VERSION},
411+
self._live_run_key(domain, strategy_profile, recorded_at, stream_id=stream),
412+
{**stored_payload, "schema_version": SCHEMA_VERSION},
396413
)
397414

398415
def list_live_run_records(
399416
self,
400417
domain: str,
401418
*,
402419
strategy_profile: str | None = None,
420+
stream_id: str | None = None,
403421
) -> list[dict[str, Any]]:
404422
"""Load persisted live evaluation/execution records for a domain."""
405423
prefix = f"live_runs/{_clean_key(domain)}/"
406424
if strategy_profile:
407425
prefix = f"{prefix}{_clean_key(strategy_profile)}/"
426+
stream = str(stream_id or "").strip()
427+
if stream:
428+
if not strategy_profile:
429+
raise ValueError("strategy_profile is required when filtering live records by stream_id")
430+
prefix = f"{prefix}streams/{_clean_key(stream)}/"
408431

409432
records: list[dict[str, Any]] = []
410433

@@ -426,9 +449,12 @@ def list_live_run_records(
426449

427450
deduped: dict[str, dict[str, Any]] = {}
428451
for record in records:
452+
if stream and str(record.get("lifecycle_stream_id") or "").strip() != stream:
453+
continue
429454
dedupe_key = "|".join(
430455
[
431456
str(record.get("strategy_profile") or ""),
457+
str(record.get("lifecycle_stream_id") or ""),
432458
str(record.get("recorded_at") or ""),
433459
str(record.get("record_kind") or ""),
434460
]

src/quant_platform_kit/strategy_lifecycle/return_collector.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,42 @@ def _store_instance(self) -> PerformanceStore:
108108
return self._store
109109
return PerformanceStore.from_env()
110110

111-
def collect_from_live_runs(self, domain: str) -> Mapping[str, pd.Series]:
112-
"""Build per-strategy return series from persisted live run equity snapshots."""
111+
def collect_from_live_runs(
112+
self,
113+
domain: str,
114+
*,
115+
stream_id: str | None = None,
116+
) -> Mapping[str, pd.Series]:
117+
"""Build per-strategy returns without merging independent account streams.
118+
119+
A caller that needs live monitoring must supply a specific stream when
120+
more than one stream has reported the same strategy profile. Skipping
121+
that ambiguous profile is safer than deriving a false equity curve
122+
from separate broker accounts.
123+
"""
113124
records = self._store_instance().list_live_run_records(domain)
114125
grouped = group_live_run_records_by_profile(records)
115-
return {
116-
profile: live_run_records_to_return_series(profile_records)
117-
for profile, profile_records in grouped.items()
118-
if live_run_records_to_return_series(profile_records).size > 0
119-
}
126+
result: dict[str, pd.Series] = {}
127+
requested_stream = str(stream_id or "").strip()
128+
for profile, profile_records in grouped.items():
129+
streams = {
130+
str(record.get("lifecycle_stream_id") or "").strip()
131+
for record in profile_records
132+
}
133+
if requested_stream:
134+
if requested_stream not in streams:
135+
continue
136+
profile_records = [
137+
record
138+
for record in profile_records
139+
if str(record.get("lifecycle_stream_id") or "").strip() == requested_stream
140+
]
141+
elif len(streams) > 1:
142+
continue
143+
series = live_run_records_to_return_series(profile_records)
144+
if not series.empty:
145+
result[profile] = series
146+
return result
120147

121148
def _merge_return_series(
122149
self,
@@ -140,6 +167,7 @@ def collect(
140167
*,
141168
date_column: str = "as_of",
142169
benchmark_columns: Sequence[str] | None = None,
170+
live_stream_id: str | None = None,
143171
) -> Mapping[str, pd.Series]:
144172
"""Collect all strategy return series for a domain.
145173
@@ -164,7 +192,12 @@ def collect(
164192
else:
165193
all_strategies[name] = series
166194

167-
live_series = self.collect_from_live_runs(domain)
195+
if live_stream_id:
196+
live_series = self.collect_from_live_runs(domain, stream_id=live_stream_id)
197+
else:
198+
# Keep the original call shape for integrations that replace this
199+
# best-effort collector with a compatible one-argument adapter.
200+
live_series = self.collect_from_live_runs(domain)
168201
return self._merge_return_series(all_strategies, live_series)
169202

170203
def collect_benchmark(

tests/test_lifecycle_cli.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,30 @@ def fake_run_monitor(**kwargs):
7777
},
7878
)
7979

80+
def test_monitor_command_passes_live_stream_filter_only_when_requested(self) -> None:
81+
observed = {}
82+
83+
def fake_load_callable(_module_name: str, _function_name: str):
84+
def fake_run_monitor(**kwargs):
85+
observed.update(kwargs)
86+
return [object()]
87+
88+
return fake_run_monitor
89+
90+
with patch.object(cli, "_load_callable", fake_load_callable):
91+
result = cli.main([
92+
"monitor",
93+
"--domain",
94+
"us_equity",
95+
"--strategy",
96+
"soxl_soxx_trend_income",
97+
"--live-stream-id",
98+
"longbridge-quant-sg-service",
99+
])
100+
101+
self.assertEqual(result, 0)
102+
self.assertEqual(observed["live_stream_id"], "longbridge-quant-sg-service")
103+
80104
def test_drift_command_counts_status_values(self) -> None:
81105
statuses = [
82106
SimpleNamespace(status=SimpleNamespace(value="critical")),

0 commit comments

Comments
 (0)