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,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)
|