alpha-engine-lib 0.32.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alpha_engine_lib/__init__.py +3 -0
- alpha_engine_lib/agent_schemas.py +663 -0
- alpha_engine_lib/alerts.py +576 -0
- alpha_engine_lib/arcticdb.py +340 -0
- alpha_engine_lib/collector_results.py +69 -0
- alpha_engine_lib/cost.py +665 -0
- alpha_engine_lib/dates.py +273 -0
- alpha_engine_lib/decision_capture.py +462 -0
- alpha_engine_lib/ec2_spot.py +363 -0
- alpha_engine_lib/email_sender.py +206 -0
- alpha_engine_lib/eval_artifacts.py +361 -0
- alpha_engine_lib/logging.py +303 -0
- alpha_engine_lib/model_pricing.yaml +73 -0
- alpha_engine_lib/pillars.py +756 -0
- alpha_engine_lib/pipeline_status/__init__.py +70 -0
- alpha_engine_lib/pipeline_status/read.py +541 -0
- alpha_engine_lib/pipeline_status/registry.py +368 -0
- alpha_engine_lib/pipeline_status/templates.py +120 -0
- alpha_engine_lib/preflight.py +444 -0
- alpha_engine_lib/rag/__init__.py +39 -0
- alpha_engine_lib/rag/db.py +96 -0
- alpha_engine_lib/rag/embeddings.py +63 -0
- alpha_engine_lib/rag/migrations/0001_content_tsv.sql +39 -0
- alpha_engine_lib/rag/rerank.py +377 -0
- alpha_engine_lib/rag/retrieval.py +465 -0
- alpha_engine_lib/rag/schema.sql +65 -0
- alpha_engine_lib/reconcile.py +203 -0
- alpha_engine_lib/secrets.py +186 -0
- alpha_engine_lib/sources/__init__.py +35 -0
- alpha_engine_lib/sources/protocols.py +227 -0
- alpha_engine_lib/ssm_log_capture.py +274 -0
- alpha_engine_lib/telegram.py +165 -0
- alpha_engine_lib/trading_calendar.py +236 -0
- alpha_engine_lib/transparency.py +746 -0
- alpha_engine_lib/transparency_inventory.yaml +260 -0
- alpha_engine_lib/universe.py +83 -0
- alpha_engine_lib-0.32.0.dist-info/METADATA +217 -0
- alpha_engine_lib-0.32.0.dist-info/RECORD +40 -0
- alpha_engine_lib-0.32.0.dist-info/WHEEL +5 -0
- alpha_engine_lib-0.32.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""
|
|
2
|
+
eval_artifacts.py — Canonical S3 layout for eval-style judgment artifacts.
|
|
3
|
+
|
|
4
|
+
Codifies the institutional partition template that emerged from two
|
|
5
|
+
parallel implementations:
|
|
6
|
+
|
|
7
|
+
- alpha-engine-research ``evals/orchestrator.py`` — LLM-as-judge rubric
|
|
8
|
+
evaluations of decision-capture artifacts (shipped 2026-05-08;
|
|
9
|
+
alpha-engine-research #143/#144/#145).
|
|
10
|
+
- alpha-engine-predictor ``analysis/triple_barrier_cutover_runner.py`` —
|
|
11
|
+
Stage 3 cutover-gate evaluations of parallel triple-barrier predictions
|
|
12
|
+
(shipped 2026-05-10; alpha-engine-predictor #129+).
|
|
13
|
+
|
|
14
|
+
Both pipelines share the same shape: a JUDGMENT artifact produced by a
|
|
15
|
+
batch invocation against captured source data, with a need to preserve
|
|
16
|
+
forensic capture across same-day re-runs. This module is the single
|
|
17
|
+
source of truth for the partition / run-identifier / sidecar conventions
|
|
18
|
+
those pipelines share, so future eval-style pipelines (Stage 4
|
|
19
|
+
continuous regime feature gate, Stage 5 meta-labeling, etc.) ship
|
|
20
|
+
LdP-correct dating + run-identifier discipline by default.
|
|
21
|
+
|
|
22
|
+
Canonical S3 layout::
|
|
23
|
+
|
|
24
|
+
{prefix}/
|
|
25
|
+
{run_id}.json ← per-invocation artifact (YYMMDDHHMM encodes date)
|
|
26
|
+
latest.json ← single-fetch operator UX (mirror)
|
|
27
|
+
|
|
28
|
+
Where:
|
|
29
|
+
|
|
30
|
+
- ``prefix`` is the eval pipeline's S3 prefix (e.g.,
|
|
31
|
+
``predictor/variant_gates/triple_barrier``).
|
|
32
|
+
- ``run_id`` is a structured timestamp produced by
|
|
33
|
+
:func:`new_eval_run_id` — ``YYMMDDHHMM`` (year, month, day, hour,
|
|
34
|
+
minute, UTC). Sortable lexicographically across the entire prefix —
|
|
35
|
+
no date partition needed because the timestamp itself encodes the
|
|
36
|
+
date. Listings yield chronological order automatically.
|
|
37
|
+
|
|
38
|
+
The flat layout (no ``{calendar_date}/`` sub-partition) is deliberate:
|
|
39
|
+
once the run_id is timestamp-encoded, a date prefix is pure redundancy.
|
|
40
|
+
A weekly-cadence eval pipeline accumulates ~52 entries/year — trivial
|
|
41
|
+
for S3 list operations even over multi-year history. Date scoping is
|
|
42
|
+
still possible by listing with ``StartAfter="{prefix}/2605"`` (everything
|
|
43
|
+
in May 2026) etc.
|
|
44
|
+
|
|
45
|
+
The artifact payload still carries both ``calendar_date`` (UTC wall-clock)
|
|
46
|
+
and ``trading_day`` (last closed NYSE session) per
|
|
47
|
+
``DATE_CONVENTIONS.md`` dual-tracking — those are FACTS about the
|
|
48
|
+
artifact for downstream join queries, distinct from the path's role as
|
|
49
|
+
addressing.
|
|
50
|
+
|
|
51
|
+
Same-minute collisions are by design (see ``new_eval_run_id`` docstring):
|
|
52
|
+
production cron cadence makes them effectively impossible. Sub-minute
|
|
53
|
+
re-runs would overwrite — for tests and deterministic-id needs, callers
|
|
54
|
+
inject explicit ``run_id`` strings to whatever helper consumes them.
|
|
55
|
+
|
|
56
|
+
The ``latest.json`` sidecar provides a stable single-fetch endpoint for
|
|
57
|
+
dashboards / evaluator email rendering / operator scripts.
|
|
58
|
+
|
|
59
|
+
Use this only for **eval-style judgment artifacts** — pipelines that
|
|
60
|
+
produce a verdict against captured source data and may run multiple
|
|
61
|
+
times per day. Config-recommendation artifacts (assembler, regression
|
|
62
|
+
monitor, cost-anomaly) use simple ``{date}.json`` partitioning since
|
|
63
|
+
they're single-writer-per-day and overwrite-on-rerun is the desired
|
|
64
|
+
semantic (the latest verdict IS the canonical config).
|
|
65
|
+
|
|
66
|
+
Example::
|
|
67
|
+
|
|
68
|
+
from alpha_engine_lib.dates import now_dual
|
|
69
|
+
from alpha_engine_lib.eval_artifacts import (
|
|
70
|
+
new_eval_run_id, eval_artifact_key, eval_latest_key,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
dual = now_dual()
|
|
74
|
+
run_id = new_eval_run_id()
|
|
75
|
+
payload = {
|
|
76
|
+
"calendar_date": dual.calendar_date,
|
|
77
|
+
"trading_day": dual.trading_day,
|
|
78
|
+
"run_id": run_id,
|
|
79
|
+
"verdict": ...,
|
|
80
|
+
}
|
|
81
|
+
dated_key = eval_artifact_key(
|
|
82
|
+
prefix="predictor/variant_gates/triple_barrier",
|
|
83
|
+
run_id=run_id,
|
|
84
|
+
)
|
|
85
|
+
latest_key = eval_latest_key(
|
|
86
|
+
prefix="predictor/variant_gates/triple_barrier",
|
|
87
|
+
)
|
|
88
|
+
s3.put_object(Bucket=bucket, Key=dated_key, Body=...)
|
|
89
|
+
s3.put_object(Bucket=bucket, Key=latest_key, Body=...) # mirror
|
|
90
|
+
"""
|
|
91
|
+
from __future__ import annotations
|
|
92
|
+
|
|
93
|
+
import json
|
|
94
|
+
import logging
|
|
95
|
+
from datetime import datetime, timezone
|
|
96
|
+
from typing import Any
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
logger = logging.getLogger(__name__)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Stable filename for the operator-UX single-fetch sidecar. Constant
|
|
103
|
+
# rather than configurable so dashboards / scripts can hard-code it.
|
|
104
|
+
EVAL_LATEST_FILENAME: str = "latest.json"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def new_eval_run_id(*, now: datetime | None = None) -> str:
|
|
108
|
+
"""Mint a structured-timestamp run identifier in ``YYMMDDHHMM`` form.
|
|
109
|
+
|
|
110
|
+
Returns the UTC wall-clock moment formatted as a 10-character
|
|
111
|
+
``YYMMDDHHMM`` string. Sortable lexicographically — the partition
|
|
112
|
+
listing automatically yields chronological order. Human-readable
|
|
113
|
+
in path listings, S3 console UI, and operator dashboards.
|
|
114
|
+
|
|
115
|
+
Replaces the prior UUIDv4 convention (used in the early eval-judge
|
|
116
|
+
+ triple-barrier-gate implementations) — UUIDs are globally unique
|
|
117
|
+
but provide no temporal information at the path level. Operators
|
|
118
|
+
routinely needed to open each JSON to see when it ran; the
|
|
119
|
+
structured timestamp encodes that in the filename itself.
|
|
120
|
+
|
|
121
|
+
Collision profile: minute granularity. Two runs firing in the same
|
|
122
|
+
UTC minute would collide and overwrite. In production this is
|
|
123
|
+
essentially impossible (Sat SF cron fires once weekly; ad-hoc
|
|
124
|
+
operator runs are sparse). For tests and deterministic-id needs,
|
|
125
|
+
callers can construct any ``YYMMDDHHMM``-shaped string and pass it
|
|
126
|
+
explicitly to whatever helper consumes a ``run_id``.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
now: optional UTC datetime override (testing / deterministic
|
|
130
|
+
replay). When None, uses ``datetime.now(timezone.utc)``.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
10-character string ``YYMMDDHHMM``. Example: a run at 2026-05-10
|
|
134
|
+
14:37 UTC returns ``"2605101437"``.
|
|
135
|
+
"""
|
|
136
|
+
moment = now if now is not None else datetime.now(timezone.utc)
|
|
137
|
+
if moment.tzinfo is None:
|
|
138
|
+
# Treat naive datetimes as UTC (consistent with dates.now_dual).
|
|
139
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
140
|
+
return moment.strftime("%y%m%d%H%M")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def eval_artifact_key(
|
|
144
|
+
prefix: str,
|
|
145
|
+
run_id: str,
|
|
146
|
+
*,
|
|
147
|
+
basename: str = "result.json",
|
|
148
|
+
) -> str:
|
|
149
|
+
"""Format the canonical S3 key for an eval-style artifact.
|
|
150
|
+
|
|
151
|
+
Returns ``{prefix}/{run_id}.json`` when ``basename`` is the default;
|
|
152
|
+
otherwise ``{prefix}/{run_id}_{basename}`` (multi-file-per-run
|
|
153
|
+
pipelines). No date sub-partition — the YYMMDDHHMM run_id encodes
|
|
154
|
+
the date itself, so listings yield chronological order across the
|
|
155
|
+
full prefix without a ``{calendar_date}/`` partition.
|
|
156
|
+
|
|
157
|
+
Two forms supported:
|
|
158
|
+
|
|
159
|
+
- **Single-file-per-run pipelines** (e.g., cutover gate): one JSON
|
|
160
|
+
per invocation, default basename.
|
|
161
|
+
- **Multi-file-per-run pipelines** (e.g., eval-judge with per-stage
|
|
162
|
+
outputs): caller supplies per-file basename like
|
|
163
|
+
``"haiku_eval.json"``, ``"sonnet_escalation.json"``, etc. The
|
|
164
|
+
run_id prefix keeps files for the same run grouped in path
|
|
165
|
+
listings.
|
|
166
|
+
|
|
167
|
+
Trailing/leading slashes on ``prefix`` are normalized away. ``run_id``
|
|
168
|
+
is NOT validated here — callers should derive it via
|
|
169
|
+
:func:`new_eval_run_id`.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
prefix: S3 prefix root for the eval pipeline. Example:
|
|
173
|
+
``"predictor/variant_gates/triple_barrier"``.
|
|
174
|
+
run_id: ``YYMMDDHHMM`` string from :func:`new_eval_run_id` (or
|
|
175
|
+
any caller-supplied identifier — the function does not
|
|
176
|
+
constrain shape, only formats the key).
|
|
177
|
+
basename: per-file name. Defaults to ``"result.json"`` →
|
|
178
|
+
simplified to ``{run_id}.json``. Any other basename →
|
|
179
|
+
``{run_id}_{basename}`` to preserve run-id grouping in
|
|
180
|
+
sub-file listings.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Fully-formatted S3 key string.
|
|
184
|
+
"""
|
|
185
|
+
prefix_clean = prefix.strip("/")
|
|
186
|
+
if basename == "result.json":
|
|
187
|
+
return f"{prefix_clean}/{run_id}.json"
|
|
188
|
+
return f"{prefix_clean}/{run_id}_{basename}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def eval_latest_key(prefix: str) -> str:
|
|
192
|
+
"""Format the canonical S3 key for the operator-UX latest sidecar.
|
|
193
|
+
|
|
194
|
+
Returns ``{prefix}/latest.json``. Pure mirror of the most-recently-
|
|
195
|
+
written dated artifact for the pipeline; the dated key remains the
|
|
196
|
+
forensic source of truth so re-runs are preserved.
|
|
197
|
+
|
|
198
|
+
Trailing/leading slashes on ``prefix`` are normalized away.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
prefix: S3 prefix root for the eval pipeline.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
S3 key string for the latest sidecar.
|
|
205
|
+
"""
|
|
206
|
+
return f"{prefix.strip('/')}/{EVAL_LATEST_FILENAME}"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ─ Canonical readers ──────────────────────────────────────────────────
|
|
210
|
+
# Symmetric counterpart to the write helpers above. Multiple repos
|
|
211
|
+
# need to read eval-artifact-shaped artifacts (regime substrate from
|
|
212
|
+
# research + predictor + executor + dashboard; future eval pipelines
|
|
213
|
+
# similarly) — these helpers are the single source of truth for the
|
|
214
|
+
# sidecar-resolution + history-listing patterns so consumers don't
|
|
215
|
+
# each reimplement them with slightly different fail-graceful semantics.
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def load_latest_eval_artifact(
|
|
219
|
+
s3_client: Any,
|
|
220
|
+
*,
|
|
221
|
+
bucket: str,
|
|
222
|
+
prefix: str,
|
|
223
|
+
) -> dict | None:
|
|
224
|
+
"""Load the most recent eval-style artifact via canonical sidecar pointer.
|
|
225
|
+
|
|
226
|
+
Resolution sequence (all-or-nothing, returns None on any failure):
|
|
227
|
+
|
|
228
|
+
1. ``s3_client.get_object`` on ``{prefix}/latest.json``
|
|
229
|
+
2. Parse sidecar JSON, extract ``artifact_key``
|
|
230
|
+
3. ``s3_client.get_object`` on the artifact key
|
|
231
|
+
4. Parse + return the artifact payload
|
|
232
|
+
|
|
233
|
+
Returns ``None`` and logs at INFO level on any failure mode:
|
|
234
|
+
missing sidecar, malformed sidecar, missing artifact_key in
|
|
235
|
+
sidecar, missing artifact body, parse errors, transient S3 hiccups.
|
|
236
|
+
Callers handle the None case to fall back to whatever default
|
|
237
|
+
behavior makes sense for their domain (regime substrate: macro
|
|
238
|
+
agent falls back to LLM + post-LLM-guardrail; etc.).
|
|
239
|
+
|
|
240
|
+
The ``s3_client`` parameter is a boto3-like S3 client — any object
|
|
241
|
+
that responds to ``get_object(Bucket=, Key=)`` with a dict whose
|
|
242
|
+
``Body`` field is readable. Pass a real ``boto3.client("s3")`` in
|
|
243
|
+
production, or an in-memory stub in tests.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
s3_client: boto3-like S3 client.
|
|
247
|
+
bucket: S3 bucket name (e.g. ``"alpha-engine-research"``).
|
|
248
|
+
prefix: S3 prefix root for the pipeline (e.g. ``"regime"``,
|
|
249
|
+
``"predictor/variant_gates/triple_barrier"``).
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Parsed artifact payload dict, or ``None`` if unavailable.
|
|
253
|
+
"""
|
|
254
|
+
sidecar_key = eval_latest_key(prefix)
|
|
255
|
+
try:
|
|
256
|
+
sidecar_obj = s3_client.get_object(Bucket=bucket, Key=sidecar_key)
|
|
257
|
+
sidecar = json.loads(sidecar_obj["Body"].read())
|
|
258
|
+
except Exception as e:
|
|
259
|
+
logger.info(
|
|
260
|
+
"[load_latest_eval_artifact] sidecar read failed at s3://%s/%s (%s) — "
|
|
261
|
+
"no artifact available yet",
|
|
262
|
+
bucket, sidecar_key, type(e).__name__,
|
|
263
|
+
)
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
artifact_key = sidecar.get("artifact_key") if isinstance(sidecar, dict) else None
|
|
267
|
+
if not artifact_key:
|
|
268
|
+
logger.warning(
|
|
269
|
+
"[load_latest_eval_artifact] sidecar at s3://%s/%s lacks artifact_key",
|
|
270
|
+
bucket, sidecar_key,
|
|
271
|
+
)
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
body_obj = s3_client.get_object(Bucket=bucket, Key=artifact_key)
|
|
276
|
+
return json.loads(body_obj["Body"].read())
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.warning(
|
|
279
|
+
"[load_latest_eval_artifact] artifact body read failed at s3://%s/%s (%s)",
|
|
280
|
+
bucket, artifact_key, type(e).__name__,
|
|
281
|
+
)
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def list_eval_artifacts(
|
|
286
|
+
s3_client: Any,
|
|
287
|
+
*,
|
|
288
|
+
bucket: str,
|
|
289
|
+
prefix: str,
|
|
290
|
+
n_recent: int | None = None,
|
|
291
|
+
) -> list[dict]:
|
|
292
|
+
"""List eval-style artifacts under ``prefix``, oldest → newest by run_id.
|
|
293
|
+
|
|
294
|
+
Lists ``{prefix}/{YYMMDDHHMM}.json`` keys (canonical eval_artifacts
|
|
295
|
+
shape — flat layout, no calendar_date sub-partition), filters out:
|
|
296
|
+
|
|
297
|
+
- the ``latest.json`` sidecar (pure pointer, not an artifact)
|
|
298
|
+
- any nested keys (none expected under the canonical shape)
|
|
299
|
+
- filenames that aren't 10-digit run_ids (defensive — catches
|
|
300
|
+
accidentally-written files)
|
|
301
|
+
- non-``.json`` files
|
|
302
|
+
|
|
303
|
+
Sorts lexicographically by run_id (= chronological since the
|
|
304
|
+
timestamp encoding is left-padded), then loads up to ``n_recent``
|
|
305
|
+
most-recent payloads. ``n_recent=None`` returns all.
|
|
306
|
+
|
|
307
|
+
Partial progress on fetch failures — one S3 hiccup on a single
|
|
308
|
+
artifact doesn't drop the rest of the window. Failed reads are
|
|
309
|
+
logged at WARNING and silently skipped.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
s3_client: boto3-like S3 client.
|
|
313
|
+
bucket: S3 bucket name.
|
|
314
|
+
prefix: S3 prefix root for the pipeline.
|
|
315
|
+
n_recent: Cap on number of most-recent artifacts to load.
|
|
316
|
+
``None`` = all. Default ``None``.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
List of parsed artifact payload dicts, oldest → newest.
|
|
320
|
+
Empty list when no artifacts exist (pre-deploy state).
|
|
321
|
+
"""
|
|
322
|
+
prefix_clean = prefix.strip("/")
|
|
323
|
+
list_prefix = f"{prefix_clean}/"
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
paginator = s3_client.get_paginator("list_objects_v2")
|
|
327
|
+
run_ids: list[tuple[str, str]] = [] # (run_id, artifact_key)
|
|
328
|
+
for page in paginator.paginate(Bucket=bucket, Prefix=list_prefix):
|
|
329
|
+
for obj in page.get("Contents", []):
|
|
330
|
+
key = obj.get("Key", "")
|
|
331
|
+
rel = key[len(list_prefix):]
|
|
332
|
+
if "/" in rel or not rel.endswith(".json"):
|
|
333
|
+
continue
|
|
334
|
+
run_id = rel[:-len(".json")]
|
|
335
|
+
if run_id == "latest" or not run_id.isdigit() or len(run_id) != 10:
|
|
336
|
+
continue
|
|
337
|
+
run_ids.append((run_id, key))
|
|
338
|
+
except Exception as e:
|
|
339
|
+
logger.warning(
|
|
340
|
+
"[list_eval_artifacts] listing failed at s3://%s/%s (%s)",
|
|
341
|
+
bucket, list_prefix, type(e).__name__,
|
|
342
|
+
)
|
|
343
|
+
return []
|
|
344
|
+
|
|
345
|
+
run_ids.sort() # lexicographic = chronological with YYMMDDHHMM
|
|
346
|
+
if n_recent is not None and len(run_ids) > n_recent:
|
|
347
|
+
run_ids = run_ids[-n_recent:]
|
|
348
|
+
|
|
349
|
+
out: list[dict] = []
|
|
350
|
+
for _run_id, key in run_ids:
|
|
351
|
+
try:
|
|
352
|
+
body_obj = s3_client.get_object(Bucket=bucket, Key=key)
|
|
353
|
+
out.append(json.loads(body_obj["Body"].read()))
|
|
354
|
+
except Exception as e:
|
|
355
|
+
logger.warning(
|
|
356
|
+
"[list_eval_artifacts] body read failed at s3://%s/%s (%s) — "
|
|
357
|
+
"skipping this artifact",
|
|
358
|
+
bucket, key, type(e).__name__,
|
|
359
|
+
)
|
|
360
|
+
continue
|
|
361
|
+
return out
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared structured logging + Flow Doctor integration.
|
|
3
|
+
|
|
4
|
+
Replaces near-identical copies of ``log_config.py`` in alpha-engine-data
|
|
5
|
+
and alpha-engine/executor. Consumers call :func:`setup_logging` once at
|
|
6
|
+
process startup; subsequent call sites retrieve the Flow Doctor instance
|
|
7
|
+
via :func:`get_flow_doctor`.
|
|
8
|
+
|
|
9
|
+
Modes:
|
|
10
|
+
|
|
11
|
+
- Text (default): human-readable single-line log format.
|
|
12
|
+
- JSON: activated by ``ALPHA_ENGINE_JSON_LOGS=1``. Emits one JSON object
|
|
13
|
+
per log record, including tracebacks for errors.
|
|
14
|
+
|
|
15
|
+
Flow Doctor activates only when ``FLOW_DOCTOR_ENABLED=1`` and a
|
|
16
|
+
``flow_doctor_yaml`` path is provided. ERROR-level records (including
|
|
17
|
+
``logger.exception``) fire the FlowDoctorHandler, which dispatches per
|
|
18
|
+
the yaml config (email + GitHub issue with dedup + rate limits).
|
|
19
|
+
|
|
20
|
+
Requires the ``flow_doctor`` optional extra when FLOW_DOCTOR_ENABLED=1
|
|
21
|
+
(``alpha-engine-lib[flow_doctor]``).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
# ``${VAR}`` interpolation tokens in a flow-doctor.yaml. flow-doctor
|
|
34
|
+
# resolves these from ``os.environ`` eagerly at ``flow_doctor.init()``
|
|
35
|
+
# time — before any lazy ``get_secret()`` consumer-site call runs — so
|
|
36
|
+
# the seed below must populate them first.
|
|
37
|
+
_FD_VAR_RE = re.compile(r"\$\{([A-Z][A-Z0-9_]*)\}")
|
|
38
|
+
|
|
39
|
+
# Singleton populated by setup_logging() when FLOW_DOCTOR_ENABLED=1.
|
|
40
|
+
# ``Optional[object]`` typing avoids forcing a flow_doctor import here.
|
|
41
|
+
_fd_instance: Optional[object] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class JSONFormatter(logging.Formatter):
|
|
45
|
+
"""Emit log records as single-line JSON objects."""
|
|
46
|
+
|
|
47
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
48
|
+
entry = {
|
|
49
|
+
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
50
|
+
"level": record.levelname,
|
|
51
|
+
"module": record.module,
|
|
52
|
+
"func": record.funcName,
|
|
53
|
+
"msg": record.getMessage(),
|
|
54
|
+
}
|
|
55
|
+
if record.exc_info and record.exc_info[0] is not None:
|
|
56
|
+
entry["exc"] = self.formatException(record.exc_info)
|
|
57
|
+
if hasattr(record, "ctx"):
|
|
58
|
+
entry["ctx"] = record.ctx
|
|
59
|
+
return json.dumps(entry, default=str)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Default-active secrets redaction patterns. Applied at the logging-handler
|
|
63
|
+
# layer by SecretsRedactingFilter so every log record reaching stdout / CW
|
|
64
|
+
# Logs / flow-doctor has the matching substrings replaced. Conservative
|
|
65
|
+
# by design — false positives (over-redaction) are visible; false negatives
|
|
66
|
+
# (leaked keys) reach public CW Logs.
|
|
67
|
+
#
|
|
68
|
+
# Pattern list is closed-form: every alpha-engine secret class known to leak
|
|
69
|
+
# at the data-collector / research / predictor / backtester log sites. Adding
|
|
70
|
+
# a new pattern is a one-line addition; opt-out per-record/per-attach is
|
|
71
|
+
# possible via ``record.no_redact = True`` (rare — see ``SecretsRedactingFilter``).
|
|
72
|
+
#
|
|
73
|
+
# Origin: 2026-05-24 audit on alpha-engine-research-runner CW Logs surfaced
|
|
74
|
+
# the FMP API key in plaintext inside HTTP-error WARNING lines (the FMP
|
|
75
|
+
# /stable 402 paid-tier errors mid-Research). alpha-engine-data #255 had
|
|
76
|
+
# shipped a repo-local scrubber for the same defect class earlier; lifting
|
|
77
|
+
# to the lib chokepoint per [[feedback_lift_invariants_to_chokepoint_after_second_recurrence]]
|
|
78
|
+
# closes the recurrence permanently across every repo that consumes
|
|
79
|
+
# ``alpha_engine_lib.logging.setup_logging``.
|
|
80
|
+
_SECRET_REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
81
|
+
# URL-query-string credentials: `?apikey=...&symbol=X` /
|
|
82
|
+
# `?api_key=...` / `?key=...` / `?token=...`. Conservative length
|
|
83
|
+
# floor (16 chars) avoids redacting short ID-like tokens that look
|
|
84
|
+
# alphanumeric but aren't secrets.
|
|
85
|
+
(
|
|
86
|
+
re.compile(
|
|
87
|
+
r"([?&](?:apikey|api_key|key|token|access_token|auth_token)=)"
|
|
88
|
+
r"([A-Za-z0-9_\-\.]{16,})"
|
|
89
|
+
),
|
|
90
|
+
r"\1<REDACTED>",
|
|
91
|
+
),
|
|
92
|
+
# AWS Access Key ID — exactly 16 alphanumerics after AKIA prefix.
|
|
93
|
+
(re.compile(r"\bAKIA[A-Z0-9]{16}\b"), "<AWS_ACCESS_KEY_REDACTED>"),
|
|
94
|
+
# Anthropic API keys — sk-ant-{api,sid,oat}-...
|
|
95
|
+
(re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{16,}"), "<ANTHROPIC_KEY_REDACTED>"),
|
|
96
|
+
# OpenAI-style secret keys — sk-... 32+ chars (covers project keys).
|
|
97
|
+
(re.compile(r"\bsk-[A-Za-z0-9_\-]{32,}"), "<OPENAI_KEY_REDACTED>"),
|
|
98
|
+
# Authorization: Bearer <token> headers, case-insensitive.
|
|
99
|
+
(
|
|
100
|
+
re.compile(r"(authorization:\s*bearer\s+)([A-Za-z0-9_\-\.]+)", re.IGNORECASE),
|
|
101
|
+
r"\1<REDACTED>",
|
|
102
|
+
),
|
|
103
|
+
# GitHub personal access tokens — classic (ghp_*) and fine-grained
|
|
104
|
+
# (github_pat_*). Both have well-known prefixes; min-length 30
|
|
105
|
+
# guards against the prefix-alone false-positive.
|
|
106
|
+
(re.compile(r"\b(?:ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9_]{30,}"), "<GITHUB_TOKEN_REDACTED>"),
|
|
107
|
+
(re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}"), "<GITHUB_TOKEN_REDACTED>"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SecretsRedactingFilter(logging.Filter):
|
|
112
|
+
"""logging.Filter that redacts known secret-shaped substrings from
|
|
113
|
+
every log record's formatted message.
|
|
114
|
+
|
|
115
|
+
Attached by default to the handler created by :func:`setup_logging`.
|
|
116
|
+
Opt-out via the ``ALPHA_ENGINE_DISABLE_LOG_REDACTION=1`` env var (for
|
|
117
|
+
cases where redaction obscures debugging of a non-secret pattern that
|
|
118
|
+
matches a redaction regex by coincidence).
|
|
119
|
+
|
|
120
|
+
Per-record opt-out is also possible via ``record.no_redact = True``
|
|
121
|
+
on a record where the redaction is known-safe to skip. Use rarely —
|
|
122
|
+
the default-active posture is a security property.
|
|
123
|
+
|
|
124
|
+
Never raises. A regex compilation or replacement error is caught and
|
|
125
|
+
the original record passes through unmodified (better to log
|
|
126
|
+
something than nothing). The catch is intentionally broad: a logging
|
|
127
|
+
filter that crashes is worse than one that silently no-ops.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
131
|
+
if getattr(record, "no_redact", False):
|
|
132
|
+
return True
|
|
133
|
+
try:
|
|
134
|
+
# Render the message with its args so substitution sees the
|
|
135
|
+
# actual emitted text (otherwise a `logger.warning("...%s...", key)`
|
|
136
|
+
# would pass `record.msg` containing only the format string).
|
|
137
|
+
rendered = record.getMessage()
|
|
138
|
+
redacted = rendered
|
|
139
|
+
for pattern, replacement in _SECRET_REDACTION_PATTERNS:
|
|
140
|
+
redacted = pattern.sub(replacement, redacted)
|
|
141
|
+
if redacted != rendered:
|
|
142
|
+
# Replace the format string + clear args so downstream
|
|
143
|
+
# formatters re-emit the redacted text rather than
|
|
144
|
+
# re-interpolating.
|
|
145
|
+
record.msg = redacted
|
|
146
|
+
record.args = ()
|
|
147
|
+
except Exception: # noqa: BLE001 - filter MUST NOT crash logging
|
|
148
|
+
pass
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_flow_doctor():
|
|
153
|
+
"""Return the shared flow-doctor instance, or None if not initialized."""
|
|
154
|
+
return _fd_instance
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _seed_flow_doctor_secrets(yaml_path: str) -> None:
|
|
158
|
+
"""Populate the flow-doctor ``${VAR}`` secrets into ``os.environ``.
|
|
159
|
+
|
|
160
|
+
flow-doctor resolves every ``${VAR}`` in its yaml from ``os.environ``
|
|
161
|
+
eagerly inside ``flow_doctor.init()``, before any consumer-site
|
|
162
|
+
:func:`alpha_engine_lib.secrets.get_secret` call has had a chance to
|
|
163
|
+
run. With the legacy ``ssm_secrets.load_secrets()`` bulk-load shim
|
|
164
|
+
retired (PR 9g), systemd/Step-Functions-launched entrypoints have no
|
|
165
|
+
``.env`` source, so those ``${VAR}`` refs would resolve to nothing
|
|
166
|
+
and flow-doctor's email + GitHub dispatch would silently misfire.
|
|
167
|
+
|
|
168
|
+
This is the single chokepoint every repo reaches flow-doctor
|
|
169
|
+
through, so seeding here closes the gap system-wide with no
|
|
170
|
+
per-repo code. The var set is derived from the yaml itself rather
|
|
171
|
+
than hardcoded — each repo's flow-doctor.yaml carries a different
|
|
172
|
+
``${VAR}`` set, and a yaml-added secret must not silently re-open
|
|
173
|
+
the gap.
|
|
174
|
+
|
|
175
|
+
Invariants (mirroring the retired shim):
|
|
176
|
+
|
|
177
|
+
- A var already present in ``os.environ`` wins — never overwritten.
|
|
178
|
+
- A genuinely unresolvable secret is left **unset**, so
|
|
179
|
+
flow-doctor's own ``ConfigError`` fires loudly rather than being
|
|
180
|
+
masked with ``""`` (see ``feedback_no_silent_fails``).
|
|
181
|
+
- A secrets-backend hiccup never blocks logging setup; it is logged
|
|
182
|
+
at WARNING and the var is left unset (same loud-failure path).
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
with open(yaml_path, "r", encoding="utf-8") as fh:
|
|
186
|
+
yaml_text = fh.read()
|
|
187
|
+
except OSError:
|
|
188
|
+
# Missing/unreadable yaml is reported by _attach_flow_doctor's
|
|
189
|
+
# own os.path.exists guard with a clearer message.
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
from alpha_engine_lib.secrets import get_secret
|
|
193
|
+
|
|
194
|
+
for var in sorted(set(_FD_VAR_RE.findall(yaml_text))):
|
|
195
|
+
if os.environ.get(var):
|
|
196
|
+
continue
|
|
197
|
+
try:
|
|
198
|
+
value = get_secret(var, required=False)
|
|
199
|
+
except Exception as exc: # noqa: BLE001 - backend hiccup is non-fatal
|
|
200
|
+
logging.getLogger(__name__).warning(
|
|
201
|
+
"flow-doctor secret seed: get_secret(%s) raised %r; "
|
|
202
|
+
"leaving unset so flow-doctor fails loudly", var, exc,
|
|
203
|
+
)
|
|
204
|
+
continue
|
|
205
|
+
if value:
|
|
206
|
+
os.environ[var] = value
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _attach_flow_doctor(
|
|
210
|
+
yaml_path: str,
|
|
211
|
+
exclude_patterns: list[str] | None = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Initialize the shared flow-doctor instance and attach a log handler.
|
|
214
|
+
|
|
215
|
+
``exclude_patterns`` is a list of regex strings forwarded to
|
|
216
|
+
``FlowDoctorHandler(exclude_patterns=...)``. Log records whose
|
|
217
|
+
rendered message matches any pattern are dropped before entering
|
|
218
|
+
the flow-doctor dispatch pipeline (email / GitHub issue). Use for
|
|
219
|
+
benign ERROR-level noise that would otherwise dedup-spam on-call.
|
|
220
|
+
"""
|
|
221
|
+
global _fd_instance
|
|
222
|
+
try:
|
|
223
|
+
import flow_doctor
|
|
224
|
+
except ImportError as exc:
|
|
225
|
+
raise RuntimeError(
|
|
226
|
+
"FLOW_DOCTOR_ENABLED=1 but flow-doctor is not installed. Install "
|
|
227
|
+
"via alpha-engine-lib[flow_doctor] or add flow-doctor[diagnosis] "
|
|
228
|
+
f"to requirements: {exc}"
|
|
229
|
+
) from exc
|
|
230
|
+
|
|
231
|
+
if not os.path.exists(yaml_path):
|
|
232
|
+
raise RuntimeError(
|
|
233
|
+
f"FLOW_DOCTOR_ENABLED=1 but flow-doctor config not found at {yaml_path}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
_seed_flow_doctor_secrets(yaml_path)
|
|
237
|
+
_fd_instance = flow_doctor.init(config_path=yaml_path)
|
|
238
|
+
handler_kwargs: dict = {"level": logging.ERROR}
|
|
239
|
+
if exclude_patterns:
|
|
240
|
+
handler_kwargs["exclude_patterns"] = exclude_patterns
|
|
241
|
+
handler = flow_doctor.FlowDoctorHandler(_fd_instance, **handler_kwargs)
|
|
242
|
+
logging.getLogger().addHandler(handler)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def setup_logging(
|
|
246
|
+
name: str,
|
|
247
|
+
flow_doctor_yaml: str | None = None,
|
|
248
|
+
exclude_patterns: list[str] | None = None,
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Configure the root logger for an Alpha Engine entrypoint.
|
|
251
|
+
|
|
252
|
+
:param name: Logger name shown in the text-mode prefix
|
|
253
|
+
(``"%(asctime)s %(levelname)s [{name}] %(message)s"``). Typically
|
|
254
|
+
the module name (``"data-collector"``, ``"executor"``, etc.).
|
|
255
|
+
:param flow_doctor_yaml: Absolute or CWD-relative path to the
|
|
256
|
+
flow-doctor yaml config. Required if ``FLOW_DOCTOR_ENABLED=1``;
|
|
257
|
+
ignored otherwise.
|
|
258
|
+
:param exclude_patterns: Optional list of regex strings. When
|
|
259
|
+
``FLOW_DOCTOR_ENABLED=1``, these are forwarded to
|
|
260
|
+
``FlowDoctorHandler`` so matching ERROR-level records are
|
|
261
|
+
dropped before the flow-doctor dispatch pipeline. Use sparingly
|
|
262
|
+
— this silences *alerts*, not logs. The records still appear in
|
|
263
|
+
stdout / JSON logs; only flow-doctor's email + GitHub issue
|
|
264
|
+
routing is suppressed. Example: the executor passes
|
|
265
|
+
``[r"Error 10197"]`` to suppress benign IB Gateway noise when
|
|
266
|
+
the iOS app steals the live-data session.
|
|
267
|
+
|
|
268
|
+
Env vars consulted:
|
|
269
|
+
|
|
270
|
+
- ``ALPHA_ENGINE_JSON_LOGS`` — ``"1"`` enables JSON formatter.
|
|
271
|
+
- ``FLOW_DOCTOR_ENABLED`` — ``"1"`` attaches FlowDoctorHandler.
|
|
272
|
+
"""
|
|
273
|
+
json_mode = os.environ.get("ALPHA_ENGINE_JSON_LOGS", "0") == "1"
|
|
274
|
+
|
|
275
|
+
handler = logging.StreamHandler()
|
|
276
|
+
if json_mode:
|
|
277
|
+
handler.setFormatter(JSONFormatter())
|
|
278
|
+
else:
|
|
279
|
+
handler.setFormatter(logging.Formatter(
|
|
280
|
+
f"%(asctime)s %(levelname)s [{name}] %(message)s"
|
|
281
|
+
))
|
|
282
|
+
|
|
283
|
+
# Attach secrets-redacting filter by default. Closes the FMP-API-key /
|
|
284
|
+
# AWS-key / Anthropic-key / GitHub-PAT plaintext-log class system-wide
|
|
285
|
+
# (every consumer of alpha_engine_lib.logging.setup_logging inherits
|
|
286
|
+
# the filter on its next lib pin bump). Opt-out via
|
|
287
|
+
# ALPHA_ENGINE_DISABLE_LOG_REDACTION=1 for the rare debugging scenario
|
|
288
|
+
# where a redaction regex matches a non-secret pattern.
|
|
289
|
+
if os.environ.get("ALPHA_ENGINE_DISABLE_LOG_REDACTION", "0") != "1":
|
|
290
|
+
handler.addFilter(SecretsRedactingFilter())
|
|
291
|
+
|
|
292
|
+
root = logging.getLogger()
|
|
293
|
+
root.handlers.clear()
|
|
294
|
+
root.addHandler(handler)
|
|
295
|
+
root.setLevel(logging.INFO)
|
|
296
|
+
|
|
297
|
+
if os.environ.get("FLOW_DOCTOR_ENABLED", "0") == "1":
|
|
298
|
+
if not flow_doctor_yaml:
|
|
299
|
+
raise RuntimeError(
|
|
300
|
+
"FLOW_DOCTOR_ENABLED=1 but setup_logging() was not given a "
|
|
301
|
+
"flow_doctor_yaml path"
|
|
302
|
+
)
|
|
303
|
+
_attach_flow_doctor(flow_doctor_yaml, exclude_patterns=exclude_patterns)
|