Skip to content

Commit 6a27360

Browse files
authored
Add market signal artifact tree materializer (#104)
1 parent b846c9d commit 6a27360

3 files changed

Lines changed: 356 additions & 0 deletions

File tree

‎src/quant_platform_kit/common/__init__.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@
4040
build_runtime_target,
4141
resolve_runtime_target_from_env,
4242
)
43+
from .market_signal_artifacts import (
44+
MARKET_SIGNAL_ARTIFACT_LINK_FIELDS,
45+
cache_root_for_market_signal_artifact_tree,
46+
local_path_for_gcs_object,
47+
materialize_market_signal_artifact_tree,
48+
resolve_gcs_artifact_reference,
49+
)
4350
from .strategy_plugins import (
4451
CRISIS_RESPONSE_SHADOW_SUPPORTED_STRATEGIES,
4552
DEFAULT_STRATEGY_PLUGIN_DEFINITIONS,
@@ -123,11 +130,15 @@
123130
"SUPPORTED_STRATEGY_PLUGIN_MODES",
124131
"MACRO_RISK_GOVERNOR_SUPPORTED_STRATEGIES",
125132
"MARKET_REGIME_CONTROL_SUPPORTED_STRATEGIES",
133+
"MARKET_SIGNAL_ARTIFACT_LINK_FIELDS",
126134
"TACO_REBOUND_SHADOW_SUPPORTED_STRATEGIES",
127135
"filter_execution_blocking_skips",
128136
"is_terminal_funding_block",
129137
"is_terminal_strategy_run_stage",
130138
"localize_notification_text",
139+
"cache_root_for_market_signal_artifact_tree",
140+
"local_path_for_gcs_object",
141+
"materialize_market_signal_artifact_tree",
131142
"merge_strategy_plugin_i18n",
132143
"normalize_skip_reason",
133144
"normalize_stage",
@@ -162,6 +173,7 @@
162173
"parse_strategy_plugin_notification_targets",
163174
"parse_strategy_plugin_mounts",
164175
"resolve_runtime_target_from_env",
176+
"resolve_gcs_artifact_reference",
165177
"should_alert_strategy_plugin_signal",
166178
"translate_strategy_plugin_value",
167179
"translator_uses_zh",
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Materialization helpers for external market signal artifact trees."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import json
7+
import posixpath
8+
from pathlib import Path
9+
from typing import Any, Iterable, Mapping
10+
11+
from .strategy_plugin_artifacts import download_gcs_object, parse_gcs_uri
12+
13+
14+
MARKET_SIGNAL_ARTIFACT_LINK_FIELDS = frozenset(
15+
{
16+
"bundle_path",
17+
"catalog_path",
18+
"consumer_contract_registry_manifest_path",
19+
"handoff_manifest_path",
20+
"quality_report_path",
21+
"registry_path",
22+
"signal_bundle_manifest_path",
23+
"source_family_catalog_manifest_path",
24+
}
25+
)
26+
27+
28+
def materialize_market_signal_artifact_tree(
29+
reference: str,
30+
*,
31+
cache_dir: Path,
32+
client_factory: Any = None,
33+
link_fields: Iterable[str] | None = None,
34+
) -> tuple[Path, dict[str, Any]]:
35+
"""Return a local path for a market signal artifact and its linked JSON tree."""
36+
37+
raw_reference = _required_string(reference, field_name="reference")
38+
if not raw_reference.startswith("gs://"):
39+
local_path = Path(raw_reference).expanduser()
40+
return local_path, {
41+
"source_uri": None,
42+
"local_path": raw_reference,
43+
"cache_dir": None,
44+
"materialized_count": 0,
45+
"materialized_paths": (),
46+
}
47+
48+
fields = frozenset(link_fields or MARKET_SIGNAL_ARTIFACT_LINK_FIELDS)
49+
cache_root = cache_root_for_market_signal_artifact_tree(
50+
raw_reference,
51+
cache_dir=cache_dir,
52+
)
53+
visited: dict[str, Path] = {}
54+
_materialize_gcs_json_tree(
55+
raw_reference,
56+
cache_root=cache_root,
57+
client_factory=client_factory,
58+
link_fields=fields,
59+
visited=visited,
60+
)
61+
_, object_name = parse_gcs_uri(raw_reference)
62+
local_path = local_path_for_gcs_object(cache_root, object_name)
63+
return local_path, {
64+
"source_uri": raw_reference,
65+
"local_path": str(local_path),
66+
"cache_dir": str(cache_root),
67+
"materialized_count": len(visited),
68+
"materialized_paths": tuple(str(path) for path in visited.values()),
69+
}
70+
71+
72+
def cache_root_for_market_signal_artifact_tree(reference: str, *, cache_dir: Path) -> Path:
73+
raw_reference = _required_string(reference, field_name="reference")
74+
digest = hashlib.sha256(raw_reference.encode("utf-8")).hexdigest()[:16]
75+
return cache_dir / digest
76+
77+
78+
def local_path_for_gcs_object(cache_root: Path, object_name: str) -> Path:
79+
raw_object_name = _required_string(object_name, field_name="object_name")
80+
if raw_object_name.startswith("/"):
81+
raise ValueError(f"GCS object name must be relative: {raw_object_name}")
82+
normalized = posixpath.normpath(raw_object_name)
83+
if normalized in {"", ".", ".."} or normalized.startswith("../"):
84+
raise ValueError(f"GCS object name escapes the cache root: {raw_object_name}")
85+
return cache_root.joinpath(*normalized.split("/"))
86+
87+
88+
def resolve_gcs_artifact_reference(base_uri: str, reference: str) -> str:
89+
raw_reference = _required_string(reference, field_name="reference")
90+
if raw_reference.startswith("gs://"):
91+
parse_gcs_uri(raw_reference)
92+
return raw_reference
93+
if "://" in raw_reference:
94+
raise ValueError(f"Unsupported market signal artifact reference: {raw_reference}")
95+
if raw_reference.startswith("/"):
96+
raise ValueError(
97+
"GCS market signal artifacts must use relative linked paths or gs:// URIs: "
98+
f"{raw_reference}"
99+
)
100+
101+
bucket_name, object_name = parse_gcs_uri(base_uri)
102+
base_dir = posixpath.dirname(object_name)
103+
resolved = posixpath.normpath(posixpath.join(base_dir, raw_reference))
104+
if resolved in {"", ".", ".."} or resolved.startswith("../"):
105+
raise ValueError(
106+
"GCS market signal artifact reference escapes the bucket root: "
107+
f"{raw_reference}"
108+
)
109+
return f"gs://{bucket_name}/{resolved}"
110+
111+
112+
def _materialize_gcs_json_tree(
113+
uri: str,
114+
*,
115+
cache_root: Path,
116+
client_factory: Any,
117+
link_fields: frozenset[str],
118+
visited: dict[str, Path],
119+
) -> None:
120+
if uri in visited:
121+
return
122+
123+
_, object_name = parse_gcs_uri(uri)
124+
local_path = local_path_for_gcs_object(cache_root, object_name)
125+
download_gcs_object(uri, local_path, client_factory=client_factory)
126+
visited[uri] = local_path
127+
128+
payload = _read_json_object(local_path)
129+
if payload is None:
130+
return
131+
for linked_uri in _iter_linked_gcs_artifact_uris(
132+
payload,
133+
base_uri=uri,
134+
link_fields=link_fields,
135+
):
136+
_materialize_gcs_json_tree(
137+
linked_uri,
138+
cache_root=cache_root,
139+
client_factory=client_factory,
140+
link_fields=link_fields,
141+
visited=visited,
142+
)
143+
144+
145+
def _read_json_object(path: Path) -> Mapping[str, Any] | list[Any] | None:
146+
if path.suffix.lower() != ".json":
147+
return None
148+
try:
149+
payload = json.loads(path.read_text(encoding="utf-8"))
150+
except json.JSONDecodeError as exc:
151+
raise ValueError(f"Invalid JSON market signal artifact: {path}") from exc
152+
if not isinstance(payload, (dict, list)):
153+
return None
154+
return payload
155+
156+
157+
def _iter_linked_gcs_artifact_uris(
158+
payload: Any,
159+
*,
160+
base_uri: str,
161+
link_fields: frozenset[str],
162+
) -> Iterable[str]:
163+
if isinstance(payload, Mapping):
164+
for key, value in payload.items():
165+
if key in link_fields and isinstance(value, str) and value.strip():
166+
yield resolve_gcs_artifact_reference(base_uri, value.strip())
167+
yield from _iter_linked_gcs_artifact_uris(
168+
value,
169+
base_uri=base_uri,
170+
link_fields=link_fields,
171+
)
172+
elif isinstance(payload, list):
173+
for item in payload:
174+
yield from _iter_linked_gcs_artifact_uris(
175+
item,
176+
base_uri=base_uri,
177+
link_fields=link_fields,
178+
)
179+
180+
181+
def _required_string(value: Any, *, field_name: str) -> str:
182+
text = str(value or "").strip()
183+
if not text:
184+
raise ValueError(f"{field_name} must be a non-empty string")
185+
return text
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from quant_platform_kit.common.market_signal_artifacts import (
9+
cache_root_for_market_signal_artifact_tree,
10+
local_path_for_gcs_object,
11+
materialize_market_signal_artifact_tree,
12+
resolve_gcs_artifact_reference,
13+
)
14+
15+
16+
class _FakeBlob:
17+
def __init__(self, payloads: dict[str, str], key: str) -> None:
18+
self._payloads = payloads
19+
self._key = key
20+
21+
def download_to_filename(self, destination: str) -> None:
22+
Path(destination).parent.mkdir(parents=True, exist_ok=True)
23+
Path(destination).write_text(self._payloads[self._key], encoding="utf-8")
24+
25+
26+
class _FakeBucket:
27+
def __init__(self, payloads: dict[str, str], bucket_name: str) -> None:
28+
self._payloads = payloads
29+
self._bucket_name = bucket_name
30+
31+
def blob(self, object_name: str) -> _FakeBlob:
32+
return _FakeBlob(self._payloads, f"gs://{self._bucket_name}/{object_name}")
33+
34+
35+
class _FakeClient:
36+
def __init__(self, payloads: dict[str, str]) -> None:
37+
self._payloads = payloads
38+
39+
def bucket(self, bucket_name: str) -> _FakeBucket:
40+
return _FakeBucket(self._payloads, bucket_name)
41+
42+
43+
def _json(payload: object) -> str:
44+
return json.dumps(payload)
45+
46+
47+
def test_materialize_local_market_signal_artifact_tree_does_not_download():
48+
local_path, metadata = materialize_market_signal_artifact_tree(
49+
"~/signals/platform_handoff_index.json",
50+
cache_dir=Path("/tmp/cache"),
51+
client_factory=lambda: (_ for _ in ()).throw(AssertionError("should not download")),
52+
)
53+
54+
assert local_path == Path("~/signals/platform_handoff_index.json").expanduser()
55+
assert metadata == {
56+
"source_uri": None,
57+
"local_path": "~/signals/platform_handoff_index.json",
58+
"cache_dir": None,
59+
"materialized_count": 0,
60+
"materialized_paths": (),
61+
}
62+
63+
64+
def test_materialize_gcs_market_signal_artifact_tree_downloads_linked_artifacts(tmp_path):
65+
payloads = {
66+
"gs://signals/live/platform_handoffs/index.json": _json(
67+
{
68+
"handoffs": [
69+
{
70+
"handoff_manifest_path": "2026-06-19/platform_handoff.json",
71+
}
72+
],
73+
}
74+
),
75+
"gs://signals/live/platform_handoffs/2026-06-19/platform_handoff.json": _json(
76+
{
77+
"signal_bundle_manifest_path": "../bundles/manifest.json",
78+
"source_family_catalog_manifest_path": "../catalog/source.manifest.json",
79+
"consumer_contract_registry_manifest_path": "../contracts/registry.manifest.json",
80+
}
81+
),
82+
"gs://signals/live/platform_handoffs/bundles/manifest.json": _json(
83+
{
84+
"bundle_path": "signal_bundle.json",
85+
"quality_report_path": "quality_report.json",
86+
}
87+
),
88+
"gs://signals/live/platform_handoffs/bundles/signal_bundle.json": _json(
89+
{"schema_version": "test.signal_bundle.v1"}
90+
),
91+
"gs://signals/live/platform_handoffs/bundles/quality_report.json": _json(
92+
{"schema_version": "test.quality_report.v1"}
93+
),
94+
"gs://signals/live/platform_handoffs/catalog/source.manifest.json": _json(
95+
{"catalog_path": "signal_source_families.json"}
96+
),
97+
"gs://signals/live/platform_handoffs/catalog/signal_source_families.json": _json(
98+
{"schema_version": "test.source_catalog.v1"}
99+
),
100+
"gs://signals/live/platform_handoffs/contracts/registry.manifest.json": _json(
101+
{"registry_path": "market_signal_consumers.json"}
102+
),
103+
"gs://signals/live/platform_handoffs/contracts/market_signal_consumers.json": _json(
104+
{"schema_version": "test.consumer_registry.v1"}
105+
),
106+
}
107+
108+
local_path, metadata = materialize_market_signal_artifact_tree(
109+
"gs://signals/live/platform_handoffs/index.json",
110+
cache_dir=tmp_path,
111+
client_factory=lambda: _FakeClient(payloads),
112+
)
113+
114+
cache_root = cache_root_for_market_signal_artifact_tree(
115+
"gs://signals/live/platform_handoffs/index.json",
116+
cache_dir=tmp_path,
117+
)
118+
assert local_path == cache_root / "live" / "platform_handoffs" / "index.json"
119+
assert metadata["source_uri"] == "gs://signals/live/platform_handoffs/index.json"
120+
assert metadata["local_path"] == str(local_path)
121+
assert metadata["cache_dir"] == str(cache_root)
122+
assert metadata["materialized_count"] == len(payloads)
123+
assert set(metadata["materialized_paths"]) == {
124+
str(local_path_for_gcs_object(cache_root, uri.removeprefix("gs://signals/")))
125+
for uri in payloads
126+
}
127+
128+
for uri in payloads:
129+
object_name = uri.removeprefix("gs://signals/")
130+
assert local_path_for_gcs_object(cache_root, object_name).exists()
131+
132+
133+
def test_resolve_gcs_artifact_reference_rejects_non_portable_paths():
134+
assert (
135+
resolve_gcs_artifact_reference(
136+
"gs://bucket/root/platform_handoffs/index.json",
137+
"2026-06-19/platform_handoff.json",
138+
)
139+
== "gs://bucket/root/platform_handoffs/2026-06-19/platform_handoff.json"
140+
)
141+
assert (
142+
resolve_gcs_artifact_reference(
143+
"gs://bucket/root/platform_handoffs/2026-06-19/platform_handoff.json",
144+
"../bundles/manifest.json",
145+
)
146+
== "gs://bucket/root/platform_handoffs/bundles/manifest.json"
147+
)
148+
149+
with pytest.raises(ValueError, match="relative linked paths"):
150+
resolve_gcs_artifact_reference(
151+
"gs://bucket/root/platform_handoffs/index.json",
152+
"/tmp/signal_bundle_manifest.json",
153+
)
154+
155+
with pytest.raises(ValueError, match="escapes the bucket root"):
156+
resolve_gcs_artifact_reference(
157+
"gs://bucket/root/platform_handoffs/index.json",
158+
"../../../signal_bundle_manifest.json",
159+
)

0 commit comments

Comments
 (0)