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.
Files changed (40) hide show
  1. alpha_engine_lib/__init__.py +3 -0
  2. alpha_engine_lib/agent_schemas.py +663 -0
  3. alpha_engine_lib/alerts.py +576 -0
  4. alpha_engine_lib/arcticdb.py +340 -0
  5. alpha_engine_lib/collector_results.py +69 -0
  6. alpha_engine_lib/cost.py +665 -0
  7. alpha_engine_lib/dates.py +273 -0
  8. alpha_engine_lib/decision_capture.py +462 -0
  9. alpha_engine_lib/ec2_spot.py +363 -0
  10. alpha_engine_lib/email_sender.py +206 -0
  11. alpha_engine_lib/eval_artifacts.py +361 -0
  12. alpha_engine_lib/logging.py +303 -0
  13. alpha_engine_lib/model_pricing.yaml +73 -0
  14. alpha_engine_lib/pillars.py +756 -0
  15. alpha_engine_lib/pipeline_status/__init__.py +70 -0
  16. alpha_engine_lib/pipeline_status/read.py +541 -0
  17. alpha_engine_lib/pipeline_status/registry.py +368 -0
  18. alpha_engine_lib/pipeline_status/templates.py +120 -0
  19. alpha_engine_lib/preflight.py +444 -0
  20. alpha_engine_lib/rag/__init__.py +39 -0
  21. alpha_engine_lib/rag/db.py +96 -0
  22. alpha_engine_lib/rag/embeddings.py +63 -0
  23. alpha_engine_lib/rag/migrations/0001_content_tsv.sql +39 -0
  24. alpha_engine_lib/rag/rerank.py +377 -0
  25. alpha_engine_lib/rag/retrieval.py +465 -0
  26. alpha_engine_lib/rag/schema.sql +65 -0
  27. alpha_engine_lib/reconcile.py +203 -0
  28. alpha_engine_lib/secrets.py +186 -0
  29. alpha_engine_lib/sources/__init__.py +35 -0
  30. alpha_engine_lib/sources/protocols.py +227 -0
  31. alpha_engine_lib/ssm_log_capture.py +274 -0
  32. alpha_engine_lib/telegram.py +165 -0
  33. alpha_engine_lib/trading_calendar.py +236 -0
  34. alpha_engine_lib/transparency.py +746 -0
  35. alpha_engine_lib/transparency_inventory.yaml +260 -0
  36. alpha_engine_lib/universe.py +83 -0
  37. alpha_engine_lib-0.32.0.dist-info/METADATA +217 -0
  38. alpha_engine_lib-0.32.0.dist-info/RECORD +40 -0
  39. alpha_engine_lib-0.32.0.dist-info/WHEEL +5 -0
  40. 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)