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,274 @@
1
+ """
2
+ SSM-step log capture + S3 ship-on-exit chokepoint.
3
+
4
+ Consolidation substrate for the trap-and-log-ship pattern that previously
5
+ appeared as an inline bash EXIT trap in every long Step Functions SSM
6
+ state across the alpha-engine fleet (MorningEnrich, DataPhase1,
7
+ RAGIngestion, DriftDetection in alpha-engine-data; PredictorTraining in
8
+ alpha-engine-predictor; Backtester, Parity, Evaluator in
9
+ alpha-engine-backtester). The pre-lift form looked like::
10
+
11
+ trap 'aws s3 cp /var/log/X.log "s3://alpha-engine-research/_ssm_logs/X/$(date -u +%Y-%m-%d)/$(hostname)-$(date -u +%H%M%SZ).log" --only-show-errors || true' EXIT
12
+ bash infrastructure/<launcher>.sh ... 2>&1 | tee /var/log/X.log
13
+
14
+ The pattern was originally added by alpha-engine-data PR #244
15
+ (2026-05-15) to close the diagnostic gap where SSM's 24KB
16
+ ``StandardOutputContent`` cap was hiding the root cause of long-step
17
+ failures: by the time SF Catch surfaced exit-1, the spot instance had
18
+ self-terminated and the full ``/var/log/X.log`` was gone with it. The
19
+ EXIT trap fires before the script's real exit propagates, ships the log
20
+ to S3, then yields back so the real exit code reaches the SF.
21
+
22
+ **Why the lift to lib (2026-05-22):** PR #253 in alpha-engine-data
23
+ (merged 2026-05-17) switched all 8 Saturday-SF spot states from plain
24
+ ``commands`` JSON arrays to ``commands.$ States.Array(...)`` so they
25
+ could splice ``$.run_date`` / ``$.preflight_args`` via ``States.Format``.
26
+ Inside ``States.Array`` arg strings, ASL's documented escape for an
27
+ inner single quote is ``\\'`` — but in practice the AWS ASL evaluator
28
+ does NOT unescape ``\\'`` to ``'``, it passes the backslash through
29
+ literally. The trap line ``'trap \\'cmd\\' EXIT'`` rendered into the
30
+ SSM ``_script.sh`` as ``trap \\'cmd\\' EXIT``; bash interpreted the
31
+ ``\\'`` outside quotes as a literal apostrophe stripped of its quoting
32
+ power, then word-split the line and passed every token after ``aws`` to
33
+ ``trap`` as a signal name. Symptom: ``trap: s3: invalid signal
34
+ specification``, exit 127 at line 7 of ``_script.sh``. The 2026-05-22
35
+ Friday-PM shell-run dry-pass of the Saturday SF caught this exactly as
36
+ designed (it was the first execution under the broken pattern; no
37
+ Saturday SF had run between #253 merge and the dry-pass).
38
+
39
+ Per the ``~/Development/CLAUDE.md`` SOTA / institutional-approach rule —
40
+ sub-sub-rule "when mirroring a pattern across repos, consider lifting
41
+ it into ``alpha-engine-lib``... Pure-Bash primitives can stay mirrored
42
+ unless re-expressible as a Python CLI entry callable from Bash, in
43
+ which case the CLI re-expression is the institutional path" — this
44
+ module is the canonical Python primitive. The SF JSON now spells a
45
+ single ``States.Format``-rendered string (no bash trap, no bash
46
+ quoting, no ASL escape surface) and the consumer behavior lives here
47
+ where it can be tested independently of every state's JSON shape.
48
+
49
+ **Public API:**
50
+
51
+ - :func:`run` — execute an inner command, tee its merged stdout+stderr
52
+ to a local log file AND to the parent process's stdout, on exit
53
+ (any code, including subprocess crash) ship the log to S3, return
54
+ the inner exit code verbatim.
55
+ - CLI: ``python -m alpha_engine_lib.ssm_log_capture run --slug <X>
56
+ --log /var/log/<X>.log -- <inner-cmd...>``. Designed for SF JSON;
57
+ a single ``States.Format`` template with ``$.preflight_args``
58
+ interpolated via ``{}`` produces the entire invocation as one
59
+ un-quoted token list — no bash trap, no inner single quotes.
60
+
61
+ **S3 layout:**
62
+
63
+ ``s3://{bucket}/_ssm_logs/{slug}/{YYYY-MM-DD}/{hostname}-{HHMMSSZ}.log``
64
+
65
+ Defaults: ``bucket=alpha-engine-research``, prefix ``_ssm_logs``. Date,
66
+ time, and hostname are computed at exit time (so a multi-hour run that
67
+ straddles UTC midnight gets the actual exit-side date in the key).
68
+
69
+ **Failure behavior — never raises:**
70
+
71
+ - Inner command's exit code is propagated verbatim. Subprocess setup
72
+ failure (e.g., ``FileNotFoundError`` on the binary) is logged to the
73
+ log file and stderr, returns 127 to match the bash convention.
74
+ - S3 upload failures (boto3 ``ClientError``, missing creds, missing
75
+ log file) are logged at WARNING and swallowed. The SF Catch must
76
+ see the true inner exit, not a secondary log-capture failure that
77
+ would mask it. Matches :mod:`alpha_engine_lib.alerts`' fail-safe
78
+ posture.
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import argparse
84
+ import logging
85
+ import os
86
+ import socket
87
+ import subprocess
88
+ import sys
89
+ from datetime import datetime, timezone
90
+ from pathlib import Path
91
+ from typing import Final
92
+
93
+ logger = logging.getLogger(__name__)
94
+
95
+ DEFAULT_BUCKET: Final[str] = "alpha-engine-research"
96
+ S3_PREFIX: Final[str] = "_ssm_logs"
97
+
98
+
99
+ def _exit_key(slug: str, *, now: datetime | None = None, host: str | None = None) -> str:
100
+ """Compute the S3 key for the log upload at exit time.
101
+
102
+ Public for tests; the canonical layout is
103
+ ``_ssm_logs/{slug}/{YYYY-MM-DD}/{hostname}-{HHMMSSZ}.log``.
104
+ """
105
+ now = now or datetime.now(timezone.utc)
106
+ host = host or socket.gethostname()
107
+ date_str = now.strftime("%Y-%m-%d")
108
+ time_str = now.strftime("%H%M%SZ")
109
+ return f"{S3_PREFIX}/{slug}/{date_str}/{host}-{time_str}.log"
110
+
111
+
112
+ def _ship_log_to_s3(slug: str, log_path: Path, bucket: str) -> tuple[bool, str]:
113
+ """Upload ``log_path`` to S3.
114
+
115
+ Returns ``(ok, detail)``. Never raises. Computes the key at call
116
+ time so the timestamp reflects when the trap fires, not when the
117
+ wrapper started.
118
+ """
119
+ key = _exit_key(slug)
120
+ if not log_path.exists():
121
+ return False, f"log file not found: {log_path}"
122
+ try:
123
+ import boto3
124
+
125
+ s3 = boto3.client("s3")
126
+ s3.upload_file(str(log_path), bucket, key)
127
+ return True, f"s3://{bucket}/{key}"
128
+ except Exception as exc:
129
+ return False, f"{type(exc).__name__}: {exc}"
130
+
131
+
132
+ def run(
133
+ slug: str,
134
+ log_path: Path | str,
135
+ cmd: list[str],
136
+ *,
137
+ bucket: str | None = None,
138
+ env: dict[str, str] | None = None,
139
+ ) -> int:
140
+ """Run ``cmd``, tee output to ``log_path`` and parent stdout, ship the log on exit.
141
+
142
+ Mirrors the pre-lift inline pattern::
143
+
144
+ bash <launcher> ... 2>&1 | tee /var/log/<slug>.log
145
+ # plus: trap 'aws s3 cp /var/log/<slug>.log "s3://..." || true' EXIT
146
+
147
+ Args:
148
+ slug: log slug used in the S3 key (e.g., ``"morning-enrich"``).
149
+ log_path: local log path to tee to (e.g., ``"/var/log/morning-enrich.log"``).
150
+ cmd: inner command as a list of argv (passed to subprocess
151
+ directly — no shell parsing, no quoting surface).
152
+ bucket: S3 bucket override (default: ``alpha-engine-research``).
153
+ env: environment override for the subprocess (default: inherit).
154
+
155
+ Returns:
156
+ Inner command's exit code. ``127`` if the subprocess could not
157
+ start (matches bash ``command not found`` convention).
158
+ """
159
+ bucket = bucket or DEFAULT_BUCKET
160
+ log_path = Path(log_path)
161
+ log_path.parent.mkdir(parents=True, exist_ok=True)
162
+
163
+ exit_code = 1
164
+ try:
165
+ with open(log_path, "wb") as logf:
166
+ proc = subprocess.Popen(
167
+ cmd,
168
+ stdout=subprocess.PIPE,
169
+ stderr=subprocess.STDOUT,
170
+ env=env if env is not None else os.environ.copy(),
171
+ )
172
+ assert proc.stdout is not None
173
+ fd = proc.stdout.fileno()
174
+ while True:
175
+ chunk = os.read(fd, 8192)
176
+ if not chunk:
177
+ break
178
+ sys.stdout.buffer.write(chunk)
179
+ sys.stdout.buffer.flush()
180
+ logf.write(chunk)
181
+ logf.flush()
182
+ proc.wait()
183
+ exit_code = proc.returncode
184
+ except FileNotFoundError as exc:
185
+ msg = f"alpha_engine_lib.ssm_log_capture: cannot exec {cmd!r}: {exc}\n"
186
+ _append_log(log_path, msg)
187
+ print(msg, file=sys.stderr)
188
+ exit_code = 127
189
+ except Exception as exc:
190
+ msg = f"alpha_engine_lib.ssm_log_capture: subprocess setup failed: {type(exc).__name__}: {exc}\n"
191
+ _append_log(log_path, msg)
192
+ print(msg, file=sys.stderr)
193
+ exit_code = 127
194
+ finally:
195
+ ok, detail = _ship_log_to_s3(slug, log_path, bucket)
196
+ if ok:
197
+ logger.info("ssm_log_capture: shipped %s", detail)
198
+ print(f"ssm_log_capture: shipped {detail}", file=sys.stderr)
199
+ else:
200
+ logger.warning("ssm_log_capture: ship failed (%s)", detail)
201
+ print(f"ssm_log_capture: log ship to S3 FAILED: {detail}", file=sys.stderr)
202
+
203
+ return exit_code
204
+
205
+
206
+ def _append_log(log_path: Path, msg: str) -> None:
207
+ """Best-effort append to the log file. Never raises."""
208
+ try:
209
+ with open(log_path, "ab") as logf:
210
+ logf.write(msg.encode("utf-8"))
211
+ except Exception:
212
+ pass
213
+
214
+
215
+ def main(argv: list[str] | None = None) -> int:
216
+ parser = argparse.ArgumentParser(
217
+ prog="python -m alpha_engine_lib.ssm_log_capture",
218
+ description=(
219
+ "Run an inner command with stdout/stderr tee'd to a local log "
220
+ "file + parent stdout, ship the log to S3 on exit, propagate "
221
+ "the inner exit code. The institutional replacement for the "
222
+ "inline `trap 'aws s3 cp ...' EXIT` pattern that broke under "
223
+ "ASL States.Array escape semantics (alpha-engine-data PR #244 "
224
+ "→ this lift)."
225
+ ),
226
+ )
227
+ subparsers = parser.add_subparsers(dest="cmd", required=True)
228
+
229
+ run_p = subparsers.add_parser(
230
+ "run",
231
+ help="Run a command with log capture + S3 ship-on-exit.",
232
+ )
233
+ run_p.add_argument(
234
+ "--slug",
235
+ required=True,
236
+ help=(
237
+ "Log slug for the S3 key (e.g., 'morning-enrich'). Identifies "
238
+ "the SSM step under the _ssm_logs/ tree."
239
+ ),
240
+ )
241
+ run_p.add_argument(
242
+ "--log",
243
+ required=True,
244
+ help="Local log file path (e.g., /var/log/morning-enrich.log).",
245
+ )
246
+ run_p.add_argument(
247
+ "--bucket",
248
+ default=None,
249
+ help=f"S3 bucket override (default: {DEFAULT_BUCKET}).",
250
+ )
251
+ run_p.add_argument(
252
+ "inner_cmd",
253
+ nargs=argparse.REMAINDER,
254
+ help=(
255
+ "Inner command after `--`, e.g., "
256
+ "`-- bash infrastructure/spot_data_weekly.sh --morning-enrich-only`."
257
+ ),
258
+ )
259
+
260
+ args = parser.parse_args(argv)
261
+
262
+ logging.basicConfig(level=logging.WARNING)
263
+
264
+ inner = args.inner_cmd or []
265
+ if inner and inner[0] == "--":
266
+ inner = inner[1:]
267
+ if not inner:
268
+ parser.error("inner command required after `--`")
269
+
270
+ return run(args.slug, args.log, list(inner), bucket=args.bucket)
271
+
272
+
273
+ if __name__ == "__main__":
274
+ sys.exit(main())
@@ -0,0 +1,165 @@
1
+ """
2
+ Telegram push-notification client for Alpha Engine modules.
3
+
4
+ Consolidation substrate for Telegram sends across consumer repos. Before this
5
+ module, ``alpha-engine/executor/notifier.py`` was the only Telegram producer
6
+ and duplicated token/chat_id resolution, markdown escaping, and the
7
+ fire-and-forget request shape inline. With the executor surveillance Lambda
8
+ arc (ROADMAP L1067, 2026-05-13), a second producer (``alpha-engine-research``)
9
+ needs the same send path — consolidating here prevents the
10
+ "two writers diverged silently" antipattern.
11
+
12
+ **Public API:**
13
+
14
+ - :func:`send_message` — primitive single-message send. Returns ``bool``,
15
+ never raises. Misconfigured secrets resolve to a logged warning + ``False``,
16
+ not an exception, so caller code can be fire-and-forget at every site.
17
+ - :func:`send_rollup` — convenience wrapper that joins a list of findings
18
+ into a single bulleted message, defaulting to ``disable_notification=True``
19
+ (in-channel surveillance digest without push buzz).
20
+
21
+ **Severity tiering via ``disable_notification``.** Telegram's
22
+ ``disable_notification`` flag delivers the message into the chat silently —
23
+ visible in-channel but no phone-buzz notification. Use this to send a single
24
+ channel both loud (critical alerts: daemon-down, position drawdown) and
25
+ silent (surveillance digests: untouched buy-candidates). Critical alerts:
26
+ ``send_message(text)`` (defaults to push). Informational digests:
27
+ ``send_rollup(findings)`` (defaults to silent).
28
+
29
+ **Secret resolution.** Both ``TELEGRAM_BOT_TOKEN`` and ``TELEGRAM_CHAT_ID``
30
+ are loaded via :func:`alpha_engine_lib.secrets.get_secret` with
31
+ ``required=False``. If either is absent, the call logs a warning and returns
32
+ ``False`` — matches the legacy ``notifier.py`` behavior so callers can be
33
+ configured-or-no-op without conditional branching.
34
+
35
+ **Failure behavior.** Network errors, HTTP non-200 responses, and timeouts
36
+ are logged at WARNING and returned as ``False``. No exceptions propagate.
37
+ This is by design — a failed Telegram notification must never block trade
38
+ execution or surveillance Lambda completion.
39
+
40
+ **Migration arc**: ``alpha-engine-config/private-docs/ROADMAP.md`` L1067
41
+ ("Intraday data store → executor surveillance Lambda"), PR 1 of the 3-PR
42
+ sequence.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import logging
48
+ from typing import Final
49
+
50
+ import requests
51
+
52
+ from alpha_engine_lib.secrets import get_secret
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+ TELEGRAM_API_URL: Final[str] = "https://api.telegram.org/bot{token}/sendMessage"
57
+ TELEGRAM_TIMEOUT_SEC: Final[int] = 5
58
+ PARSE_MODE: Final[str] = "Markdown"
59
+
60
+
61
+ def _escape_markdown(text: str) -> str:
62
+ """Escape Telegram Markdown v1 special characters.
63
+
64
+ Replaces characters that Telegram interprets as formatting markers
65
+ (``_``, `````, ``[``, ``]``) to prevent 400 Bad Request parse errors.
66
+ Preserves ``*`` for bold markers which callers control via message
67
+ templates.
68
+ """
69
+ return (
70
+ text
71
+ .replace("_", "-")
72
+ .replace("`", "'")
73
+ .replace("[", "(")
74
+ .replace("]", ")")
75
+ )
76
+
77
+
78
+ def send_message(text: str, *, disable_notification: bool = False) -> bool:
79
+ """Send a single Telegram message to the channel resolved from secrets.
80
+
81
+ Loads ``TELEGRAM_BOT_TOKEN`` + ``TELEGRAM_CHAT_ID`` via
82
+ :func:`alpha_engine_lib.secrets.get_secret` (required=False). Applies
83
+ Markdown v1 escaping, ``POST``s with a 5-second timeout. Returns ``True``
84
+ on HTTP 200, ``False`` on any other outcome (logged at WARNING). Never
85
+ raises.
86
+
87
+ :param text: The message body. Markdown v1 formatting (``*bold*``) is
88
+ respected; other special characters are escaped automatically.
89
+ :param disable_notification: If ``True``, the message is delivered into
90
+ the chat silently (no phone push). Use for informational/digest
91
+ traffic that should be visible but not buzz.
92
+ :returns: ``True`` if the Telegram API returned HTTP 200, ``False``
93
+ otherwise (missing secrets, network error, non-200 response).
94
+ """
95
+ token = get_secret("TELEGRAM_BOT_TOKEN", required=False)
96
+ chat_id = get_secret("TELEGRAM_CHAT_ID", required=False)
97
+ if not token or not chat_id:
98
+ logger.warning(
99
+ "Telegram not configured — TELEGRAM_BOT_TOKEN=%s TELEGRAM_CHAT_ID=%s",
100
+ "set" if token else "MISSING",
101
+ "set" if chat_id else "MISSING",
102
+ )
103
+ return False
104
+
105
+ payload = {
106
+ "chat_id": chat_id,
107
+ "text": _escape_markdown(text),
108
+ "parse_mode": PARSE_MODE,
109
+ "disable_notification": disable_notification,
110
+ }
111
+
112
+ try:
113
+ resp = requests.post(
114
+ TELEGRAM_API_URL.format(token=token),
115
+ json=payload,
116
+ timeout=TELEGRAM_TIMEOUT_SEC,
117
+ )
118
+ except requests.RequestException:
119
+ logger.warning("Telegram send failed (request exception)", exc_info=True)
120
+ return False
121
+
122
+ if resp.status_code == 200:
123
+ return True
124
+ logger.warning(
125
+ "Telegram API returned %d: %s",
126
+ resp.status_code,
127
+ resp.text[:200] if resp.text else "",
128
+ )
129
+ return False
130
+
131
+
132
+ def send_rollup(
133
+ findings: list[str],
134
+ *,
135
+ header: str | None = None,
136
+ disable_notification: bool = True,
137
+ ) -> bool:
138
+ """Send a bulleted rollup of N findings as a single message.
139
+
140
+ Convenience wrapper for surveillance digest traffic — a list of findings
141
+ becomes a single message with each finding rendered as a ``-``-prefixed
142
+ bullet. Defaults to ``disable_notification=True`` (silent in-channel) so
143
+ digests don't buzz the phone; pass ``False`` to override for high-severity
144
+ rollups.
145
+
146
+ Empty ``findings`` is a no-op that returns ``True`` without an API call —
147
+ callers can pass output of a filter directly without an emptiness check.
148
+
149
+ :param findings: List of finding strings (one per bullet).
150
+ :param header: Optional bold header rendered above the bullets.
151
+ :param disable_notification: Default ``True`` (silent). Pass ``False`` to
152
+ push.
153
+ :returns: ``True`` if no findings (no-op) or Telegram returned 200,
154
+ ``False`` on send failure.
155
+ """
156
+ if not findings:
157
+ return True
158
+
159
+ lines = []
160
+ if header:
161
+ lines.append(f"*{header}*")
162
+ lines.extend(f"- {item}" for item in findings)
163
+ text = "\n".join(lines)
164
+
165
+ return send_message(text, disable_notification=disable_notification)
@@ -0,0 +1,236 @@
1
+ """
2
+ trading_calendar.py — NYSE trading day check with holiday awareness.
3
+
4
+ Lightweight implementation that doesn't require exchange_calendars or
5
+ pandas_market_calendars. Maintains a static list of NYSE holidays through 2030.
6
+
7
+ Usage:
8
+ python trading_calendar.py # check today
9
+ python trading_calendar.py 2026-04-03 # check specific date
10
+
11
+ Exit codes:
12
+ Always exits 0 — Step Function checks stdout markers, not exit code.
13
+
14
+ Stdout markers:
15
+ "TRADING DAY" = NYSE is open (proceed with pipeline)
16
+ "MARKET_CLOSED" = weekend or holiday (skip pipeline)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from datetime import date, datetime, time, timedelta
23
+ from zoneinfo import ZoneInfo
24
+
25
+ # NYSE observed holidays through 2030.
26
+ # Source: https://www.nyse.com/markets/hours-calendars
27
+ # Updated annually — add new years as they're published.
28
+ NYSE_HOLIDAYS: set[date] = {
29
+ # 2025
30
+ date(2025, 1, 1), # New Year's Day
31
+ date(2025, 1, 20), # MLK Day
32
+ date(2025, 2, 17), # Presidents' Day
33
+ date(2025, 4, 18), # Good Friday
34
+ date(2025, 5, 26), # Memorial Day
35
+ date(2025, 6, 19), # Juneteenth
36
+ date(2025, 7, 4), # Independence Day
37
+ date(2025, 9, 1), # Labor Day
38
+ date(2025, 11, 27), # Thanksgiving
39
+ date(2025, 12, 25), # Christmas
40
+ # 2026
41
+ date(2026, 1, 1), # New Year's Day
42
+ date(2026, 1, 19), # MLK Day
43
+ date(2026, 2, 16), # Presidents' Day
44
+ date(2026, 4, 3), # Good Friday
45
+ date(2026, 5, 25), # Memorial Day
46
+ date(2026, 6, 19), # Juneteenth
47
+ date(2026, 7, 3), # Independence Day (observed, July 4 is Saturday)
48
+ date(2026, 9, 7), # Labor Day
49
+ date(2026, 11, 26), # Thanksgiving
50
+ date(2026, 12, 25), # Christmas
51
+ # 2027
52
+ date(2027, 1, 1), # New Year's Day
53
+ date(2027, 1, 18), # MLK Day
54
+ date(2027, 2, 15), # Presidents' Day
55
+ date(2027, 3, 26), # Good Friday
56
+ date(2027, 5, 31), # Memorial Day
57
+ date(2027, 6, 18), # Juneteenth (observed, June 19 is Saturday)
58
+ date(2027, 7, 5), # Independence Day (observed, July 4 is Sunday)
59
+ date(2027, 9, 6), # Labor Day
60
+ date(2027, 11, 25), # Thanksgiving
61
+ date(2027, 12, 24), # Christmas (observed, Dec 25 is Saturday)
62
+ # 2028
63
+ date(2028, 1, 17), # MLK Day
64
+ date(2028, 2, 21), # Presidents' Day
65
+ date(2028, 4, 14), # Good Friday
66
+ date(2028, 5, 29), # Memorial Day
67
+ date(2028, 6, 19), # Juneteenth
68
+ date(2028, 7, 4), # Independence Day
69
+ date(2028, 9, 4), # Labor Day
70
+ date(2028, 11, 23), # Thanksgiving
71
+ date(2028, 12, 25), # Christmas
72
+ # 2029
73
+ date(2029, 1, 1), # New Year's Day
74
+ date(2029, 1, 15), # MLK Day
75
+ date(2029, 2, 19), # Presidents' Day
76
+ date(2029, 3, 30), # Good Friday
77
+ date(2029, 5, 28), # Memorial Day
78
+ date(2029, 6, 19), # Juneteenth
79
+ date(2029, 7, 4), # Independence Day
80
+ date(2029, 9, 3), # Labor Day
81
+ date(2029, 11, 22), # Thanksgiving
82
+ date(2029, 12, 25), # Christmas
83
+ # 2030
84
+ date(2030, 1, 1), # New Year's Day
85
+ date(2030, 1, 21), # MLK Day
86
+ date(2030, 2, 18), # Presidents' Day
87
+ date(2030, 4, 19), # Good Friday
88
+ date(2030, 5, 27), # Memorial Day
89
+ date(2030, 6, 19), # Juneteenth
90
+ date(2030, 7, 4), # Independence Day
91
+ date(2030, 9, 2), # Labor Day
92
+ date(2030, 11, 28), # Thanksgiving
93
+ date(2030, 12, 25), # Christmas
94
+ }
95
+
96
+
97
+ def is_trading_day(d: date | None = None) -> bool:
98
+ """Return True if the given date is an NYSE trading day."""
99
+ if d is None:
100
+ d = date.today()
101
+ if d.weekday() > 4: # Saturday=5, Sunday=6
102
+ return False
103
+ if d in NYSE_HOLIDAYS:
104
+ return False
105
+ return True
106
+
107
+
108
+ def next_trading_day(d: date | None = None) -> date:
109
+ """Return the next NYSE trading day after the given date."""
110
+ if d is None:
111
+ d = date.today()
112
+ d = d + timedelta(days=1)
113
+ while not is_trading_day(d):
114
+ d = d + timedelta(days=1)
115
+ return d
116
+
117
+
118
+ def previous_trading_day(d: date | None = None) -> date:
119
+ """Return the most recent NYSE trading day strictly before the given date."""
120
+ if d is None:
121
+ d = date.today()
122
+ d = d - timedelta(days=1)
123
+ while not is_trading_day(d):
124
+ d = d - timedelta(days=1)
125
+ return d
126
+
127
+
128
+ def add_trading_days(start: date, n: int) -> date:
129
+ """Add ``n`` NYSE trading days to ``start`` (n >= 0).
130
+
131
+ Skips weekends + NYSE holidays. ``add_trading_days(d, 0) == d``
132
+ (no rounding to a trading day if ``d`` itself is not one — only
133
+ the forward steps land on trading days).
134
+
135
+ Use ``subtract_trading_days`` for negative offsets.
136
+ """
137
+ if n < 0:
138
+ raise ValueError(f"add_trading_days requires n >= 0, got {n}")
139
+ current = start
140
+ for _ in range(n):
141
+ current = next_trading_day(current)
142
+ return current
143
+
144
+
145
+ def subtract_trading_days(start: date, n: int) -> date:
146
+ """Subtract ``n`` NYSE trading days from ``start`` (n >= 0)."""
147
+ if n < 0:
148
+ raise ValueError(f"subtract_trading_days requires n >= 0, got {n}")
149
+ current = start
150
+ for _ in range(n):
151
+ current = previous_trading_day(current)
152
+ return current
153
+
154
+
155
+ def count_trading_days(start: date, end: date) -> int:
156
+ """Count NYSE trading days strictly between ``start`` and ``end``.
157
+
158
+ Half-open interval ``(start, end]`` — same convention as
159
+ ``add_trading_days``: ``count_trading_days(d, add_trading_days(d, n)) == n``
160
+ for any ``n >= 0`` and ``d`` (whether or not ``d`` is a trading day).
161
+
162
+ Returns 0 when ``end <= start``.
163
+ """
164
+ if end <= start:
165
+ return 0
166
+ total = 0
167
+ current = start
168
+ while current < end:
169
+ current = current + timedelta(days=1)
170
+ if is_trading_day(current):
171
+ total += 1
172
+ return total
173
+
174
+
175
+ # NYSE regular-session close (early-close holidays like the day after
176
+ # Thanksgiving close at 1 PM ET; we keep 4 PM as the conservative
177
+ # threshold — consumers waiting on post-close data should not assume
178
+ # anything before 4 PM ET).
179
+ _NYSE_CLOSE_ET = time(16, 0)
180
+ _NYSE_TZ = ZoneInfo("America/New_York")
181
+
182
+
183
+ def last_closed_trading_day(now: datetime | None = None) -> date:
184
+ """Return the most recent NYSE trading day whose session has actually closed.
185
+
186
+ Unified "last closed trading day" semantic for data consumers in
187
+ both pre-open and post-close contexts:
188
+
189
+ - Monday 9 AM ET → Fri (Monday's session has not closed yet)
190
+ - Monday 4:30 PM ET → Mon (Monday's session has closed)
191
+ - Sunday 10 AM ET → Fri (nothing has closed since Fri)
192
+ - Tue after MLK Day → Fri before MLK Day (MLK is not a trading day)
193
+
194
+ Morning consumers naturally land on the prior trading day (market
195
+ hasn't closed yet); EOD consumers naturally land on the same day
196
+ (market has closed). Both consumers ask the same question and get
197
+ the correct answer without knowing which context they're in.
198
+
199
+ Accepts either a naive datetime (assumed in NYSE local time) or a
200
+ timezone-aware datetime (converted to NYSE time for comparison).
201
+ Defaults to now in NYSE time.
202
+ """
203
+ if now is None:
204
+ now = datetime.now(_NYSE_TZ)
205
+ elif now.tzinfo is None:
206
+ now = now.replace(tzinfo=_NYSE_TZ)
207
+ else:
208
+ now = now.astimezone(_NYSE_TZ)
209
+
210
+ today = now.date()
211
+ if is_trading_day(today) and now.time() >= _NYSE_CLOSE_ET:
212
+ return today
213
+ d = today - timedelta(days=1)
214
+ while not is_trading_day(d):
215
+ d = d - timedelta(days=1)
216
+ return d
217
+
218
+
219
+ if __name__ == "__main__":
220
+ check_date = date.today()
221
+ if len(sys.argv) > 1:
222
+ check_date = date.fromisoformat(sys.argv[1])
223
+
224
+ trading = is_trading_day(check_date)
225
+ day_name = check_date.strftime("%A")
226
+
227
+ if trading:
228
+ print(f"{check_date} ({day_name}): TRADING DAY")
229
+ sys.exit(0)
230
+ else:
231
+ reason = "weekend" if check_date.weekday() > 4 else "NYSE holiday"
232
+ nxt = next_trading_day(check_date)
233
+ print(f"{check_date} ({day_name}): MARKET_CLOSED ({reason}) — next trading day: {nxt}")
234
+ # Exit 0 so SSM reports Success — Step Function checks stdout marker
235
+ # instead of exit code to distinguish holidays from script crashes.
236
+ sys.exit(0)