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,363 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EC2 spot-launch capacity-resilience chokepoint.
|
|
3
|
+
|
|
4
|
+
Consolidation substrate for the spot-launch pattern that previously
|
|
5
|
+
appeared as three mirrored copies of the same fragility across the
|
|
6
|
+
alpha-engine fleet — each repo's launcher script (``spot_data_weekly.sh``
|
|
7
|
+
in alpha-engine-data, ``spot_train.sh`` in alpha-engine-predictor,
|
|
8
|
+
``spot_backtest.sh`` in alpha-engine-backtester) independently encoded
|
|
9
|
+
the same hardcoded ``--subnet-id`` (single AZ, us-east-1f) +
|
|
10
|
+
``--instance-type c5.large`` (single SKU) + N retries-with-backoff. When
|
|
11
|
+
AWS ran out of c5.large capacity in us-east-1f, every spot-launching
|
|
12
|
+
state failed simultaneously with no resilience.
|
|
13
|
+
|
|
14
|
+
**Why now (2026-05-22 evening):** The post-trap-fix dry-pass of the
|
|
15
|
+
Saturday SF (``postfix-keystone-20260522T232655Z``) hit
|
|
16
|
+
``InsufficientInstanceCapacity`` on the Evaluator's spot launch in
|
|
17
|
+
us-east-1f. The 2 earlier spots (Backtester + Parity) happened to clear
|
|
18
|
+
because AWS capacity rolled between the launches; Evaluator drew the
|
|
19
|
+
short straw. The defect class is "any single Saturday SF run has a
|
|
20
|
+
non-trivial chance of hitting capacity in at least one of the 3+ spot
|
|
21
|
+
states." The Friday-PM dry-pass exposed it (third in a row caught break
|
|
22
|
+
of the dry-pass safety net — first was the trap escape, second was the
|
|
23
|
+
keystone merge order, third is this).
|
|
24
|
+
|
|
25
|
+
**Why a CLI, not a bash function:**
|
|
26
|
+
|
|
27
|
+
Per ``~/Development/CLAUDE.md`` SOTA sub-sub-rule — "when mirroring a
|
|
28
|
+
pattern across repos, consider lifting it into ``alpha-engine-lib``...
|
|
29
|
+
Pure-Bash primitives can stay mirrored unless re-expressible as a Python
|
|
30
|
+
CLI entry callable from Bash, in which case the CLI re-expression is
|
|
31
|
+
the institutional path." Third repo with the same fragility is well
|
|
32
|
+
past the second-recurrence trigger. The CLI shape mirrors
|
|
33
|
+
:mod:`alpha_engine_lib.alerts` + :mod:`alpha_engine_lib.ssm_log_capture`
|
|
34
|
+
precedent.
|
|
35
|
+
|
|
36
|
+
**Strategy:**
|
|
37
|
+
|
|
38
|
+
The function iterates ``(instance_type, subnet)`` combinations in the
|
|
39
|
+
order given, attempting :func:`RunInstances` against each. On
|
|
40
|
+
``InsufficientInstanceCapacity`` / ``InsufficientHostCapacity`` /
|
|
41
|
+
``Unsupported`` (instance type not in AZ) → rotate to the next
|
|
42
|
+
combination. On any other error (auth, quota, AMI not found) → raise.
|
|
43
|
+
|
|
44
|
+
Caller controls the rotation order by listing types/subnets. Default
|
|
45
|
+
shape we use in the fleet:
|
|
46
|
+
|
|
47
|
+
- types: ``[c5.large, m5.large, c6i.large, c5a.large]`` (all 2 vCPU /
|
|
48
|
+
~4-8 GB RAM; capacity-resilient set chosen 2026-05-22)
|
|
49
|
+
- subnets: all default-VPC subnets across us-east-1{a,b,c,d,e,f}
|
|
50
|
+
|
|
51
|
+
**Public API:**
|
|
52
|
+
|
|
53
|
+
- :func:`launch` — Python API returning ``InstanceId`` on success,
|
|
54
|
+
raising :class:`SpotCapacityExhausted` if every combination hit a
|
|
55
|
+
capacity error, or :class:`SpotLaunchError` on any other error.
|
|
56
|
+
- CLI: ``python -m alpha_engine_lib.ec2_spot launch --types ... --subnets ...``.
|
|
57
|
+
Returns ``InstanceId`` on stdout. Exits non-zero on failure;
|
|
58
|
+
capacity-exhaustion exits 64 (distinguishable from generic failure).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
import argparse
|
|
64
|
+
import json
|
|
65
|
+
import logging
|
|
66
|
+
import os
|
|
67
|
+
import sys
|
|
68
|
+
from typing import Final, Sequence
|
|
69
|
+
|
|
70
|
+
logger = logging.getLogger(__name__)
|
|
71
|
+
|
|
72
|
+
# Error codes (RunInstances) that mean "this combination is out of
|
|
73
|
+
# capacity, try another." Anything else is a hard error.
|
|
74
|
+
CAPACITY_ERROR_CODES: Final[frozenset[str]] = frozenset(
|
|
75
|
+
{
|
|
76
|
+
"InsufficientInstanceCapacity",
|
|
77
|
+
"InsufficientHostCapacity",
|
|
78
|
+
"SpotMaxPriceTooLow", # spot-specific; AWS returns this when
|
|
79
|
+
# the AZ's spot price exceeds bid
|
|
80
|
+
"Unsupported", # instance type not offered in AZ
|
|
81
|
+
"InvalidAvailabilityZone",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
CAPACITY_EXIT_CODE: Final[int] = 64
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SpotLaunchError(Exception):
|
|
89
|
+
"""Non-capacity RunInstances failure (auth, quota, AMI not found, …)."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SpotCapacityExhausted(SpotLaunchError):
|
|
93
|
+
"""Every (instance_type, subnet) combination returned a capacity error."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _build_run_instances_kwargs(
|
|
97
|
+
*,
|
|
98
|
+
image_id: str,
|
|
99
|
+
instance_type: str,
|
|
100
|
+
key_name: str,
|
|
101
|
+
security_group_ids: list[str],
|
|
102
|
+
subnet_id: str,
|
|
103
|
+
iam_instance_profile: str,
|
|
104
|
+
spot: bool,
|
|
105
|
+
volume_size_gb: int,
|
|
106
|
+
volume_type: str,
|
|
107
|
+
shutdown_behavior: str,
|
|
108
|
+
tag_name: str | None,
|
|
109
|
+
) -> dict:
|
|
110
|
+
kwargs: dict = {
|
|
111
|
+
"ImageId": image_id,
|
|
112
|
+
"InstanceType": instance_type,
|
|
113
|
+
"KeyName": key_name,
|
|
114
|
+
"SecurityGroupIds": security_group_ids,
|
|
115
|
+
"SubnetId": subnet_id,
|
|
116
|
+
"IamInstanceProfile": {"Name": iam_instance_profile},
|
|
117
|
+
"MinCount": 1,
|
|
118
|
+
"MaxCount": 1,
|
|
119
|
+
"InstanceInitiatedShutdownBehavior": shutdown_behavior,
|
|
120
|
+
"BlockDeviceMappings": [
|
|
121
|
+
{
|
|
122
|
+
"DeviceName": "/dev/xvda",
|
|
123
|
+
"Ebs": {"VolumeSize": volume_size_gb, "VolumeType": volume_type},
|
|
124
|
+
}
|
|
125
|
+
],
|
|
126
|
+
}
|
|
127
|
+
if spot:
|
|
128
|
+
kwargs["InstanceMarketOptions"] = {
|
|
129
|
+
"MarketType": "spot",
|
|
130
|
+
"SpotOptions": {
|
|
131
|
+
"SpotInstanceType": "one-time",
|
|
132
|
+
"InstanceInterruptionBehavior": "terminate",
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
if tag_name:
|
|
136
|
+
kwargs["TagSpecifications"] = [
|
|
137
|
+
{
|
|
138
|
+
"ResourceType": "instance",
|
|
139
|
+
"Tags": [{"Key": "Name", "Value": tag_name}],
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
return kwargs
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def launch(
|
|
146
|
+
instance_types: Sequence[str],
|
|
147
|
+
subnets: Sequence[str],
|
|
148
|
+
*,
|
|
149
|
+
image_id: str,
|
|
150
|
+
key_name: str,
|
|
151
|
+
security_group_ids: Sequence[str],
|
|
152
|
+
iam_instance_profile: str,
|
|
153
|
+
spot: bool = True,
|
|
154
|
+
volume_size_gb: int = 30,
|
|
155
|
+
volume_type: str = "gp3",
|
|
156
|
+
shutdown_behavior: str = "terminate",
|
|
157
|
+
tag_name: str | None = None,
|
|
158
|
+
region: str = "us-east-1",
|
|
159
|
+
) -> str:
|
|
160
|
+
"""Launch a spot, rotating across instance_types × subnets on capacity error.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Instance ID of the first successful launch.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
SpotCapacityExhausted: every (type, subnet) combination returned
|
|
167
|
+
a capacity error. Caller can wait + retry, or escalate.
|
|
168
|
+
SpotLaunchError: any other RunInstances error (auth, quota,
|
|
169
|
+
AMI not found, …) — these don't retry, they raise loud.
|
|
170
|
+
ValueError: empty instance_types or subnets list.
|
|
171
|
+
"""
|
|
172
|
+
if not instance_types:
|
|
173
|
+
raise ValueError("instance_types must be non-empty")
|
|
174
|
+
if not subnets:
|
|
175
|
+
raise ValueError("subnets must be non-empty")
|
|
176
|
+
|
|
177
|
+
import boto3
|
|
178
|
+
from botocore.exceptions import ClientError
|
|
179
|
+
|
|
180
|
+
ec2 = boto3.client("ec2", region_name=region)
|
|
181
|
+
sg_ids = list(security_group_ids)
|
|
182
|
+
|
|
183
|
+
capacity_attempts: list[str] = []
|
|
184
|
+
for instance_type in instance_types:
|
|
185
|
+
for subnet_id in subnets:
|
|
186
|
+
kwargs = _build_run_instances_kwargs(
|
|
187
|
+
image_id=image_id,
|
|
188
|
+
instance_type=instance_type,
|
|
189
|
+
key_name=key_name,
|
|
190
|
+
security_group_ids=sg_ids,
|
|
191
|
+
subnet_id=subnet_id,
|
|
192
|
+
iam_instance_profile=iam_instance_profile,
|
|
193
|
+
spot=spot,
|
|
194
|
+
volume_size_gb=volume_size_gb,
|
|
195
|
+
volume_type=volume_type,
|
|
196
|
+
shutdown_behavior=shutdown_behavior,
|
|
197
|
+
tag_name=tag_name,
|
|
198
|
+
)
|
|
199
|
+
try:
|
|
200
|
+
resp = ec2.run_instances(**kwargs)
|
|
201
|
+
except ClientError as exc:
|
|
202
|
+
err = exc.response.get("Error", {})
|
|
203
|
+
code = err.get("Code", "UnknownError")
|
|
204
|
+
msg = err.get("Message", str(exc))
|
|
205
|
+
if code in CAPACITY_ERROR_CODES:
|
|
206
|
+
capacity_attempts.append(f"{instance_type}@{subnet_id}: {code}")
|
|
207
|
+
logger.warning(
|
|
208
|
+
"ec2_spot: %s in %s for %s — rotating",
|
|
209
|
+
code,
|
|
210
|
+
subnet_id,
|
|
211
|
+
instance_type,
|
|
212
|
+
)
|
|
213
|
+
print(
|
|
214
|
+
f"ec2_spot: {code} for {instance_type}@{subnet_id} — rotating",
|
|
215
|
+
file=sys.stderr,
|
|
216
|
+
)
|
|
217
|
+
continue
|
|
218
|
+
raise SpotLaunchError(
|
|
219
|
+
f"RunInstances failed with non-capacity error "
|
|
220
|
+
f"{code} ({instance_type}@{subnet_id}): {msg}"
|
|
221
|
+
) from exc
|
|
222
|
+
|
|
223
|
+
instance_id = resp["Instances"][0]["InstanceId"]
|
|
224
|
+
logger.info(
|
|
225
|
+
"ec2_spot: launched %s as %s in %s",
|
|
226
|
+
instance_type,
|
|
227
|
+
instance_id,
|
|
228
|
+
subnet_id,
|
|
229
|
+
)
|
|
230
|
+
print(
|
|
231
|
+
f"ec2_spot: launched {instance_type} as {instance_id} in {subnet_id}",
|
|
232
|
+
file=sys.stderr,
|
|
233
|
+
)
|
|
234
|
+
return instance_id
|
|
235
|
+
|
|
236
|
+
raise SpotCapacityExhausted(
|
|
237
|
+
f"every (instance_type, subnet) combination returned a capacity error "
|
|
238
|
+
f"({len(capacity_attempts)} attempts): "
|
|
239
|
+
+ "; ".join(capacity_attempts)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _split_csv(s: str) -> list[str]:
|
|
244
|
+
return [x.strip() for x in s.split(",") if x.strip()]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main(argv: list[str] | None = None) -> int:
|
|
248
|
+
parser = argparse.ArgumentParser(
|
|
249
|
+
prog="python -m alpha_engine_lib.ec2_spot",
|
|
250
|
+
description=(
|
|
251
|
+
"Launch an EC2 spot with capacity-resilient rotation across "
|
|
252
|
+
"instance types and subnets. The institutional replacement for "
|
|
253
|
+
"the hardcoded single-subnet + single-instance-type pattern "
|
|
254
|
+
"mirrored across the alpha-engine fleet's spot launchers."
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
subparsers = parser.add_subparsers(dest="cmd", required=True)
|
|
258
|
+
|
|
259
|
+
launch_p = subparsers.add_parser(
|
|
260
|
+
"launch",
|
|
261
|
+
help="Launch a spot with rotating (type, subnet) combinations.",
|
|
262
|
+
)
|
|
263
|
+
launch_p.add_argument(
|
|
264
|
+
"--types",
|
|
265
|
+
required=True,
|
|
266
|
+
help=(
|
|
267
|
+
"Comma-separated instance types to try in order "
|
|
268
|
+
"(e.g., 'c5.large,m5.large,c6i.large'). First success wins."
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
launch_p.add_argument(
|
|
272
|
+
"--subnets",
|
|
273
|
+
required=True,
|
|
274
|
+
help=(
|
|
275
|
+
"Comma-separated subnet IDs to try in order. Each is an AZ "
|
|
276
|
+
"(default-VPC subnets in us-east-1 span 1a-1f)."
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
launch_p.add_argument("--image-id", required=True, help="AMI ID.")
|
|
280
|
+
launch_p.add_argument("--key-name", required=True, help="EC2 key pair name.")
|
|
281
|
+
launch_p.add_argument(
|
|
282
|
+
"--security-group",
|
|
283
|
+
required=True,
|
|
284
|
+
action="append",
|
|
285
|
+
help=(
|
|
286
|
+
"Security group ID. Pass multiple times for >1 SG: "
|
|
287
|
+
"--security-group sg-A --security-group sg-B"
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
launch_p.add_argument(
|
|
291
|
+
"--iam-profile",
|
|
292
|
+
required=True,
|
|
293
|
+
help="IAM instance profile NAME (not ARN).",
|
|
294
|
+
)
|
|
295
|
+
launch_p.add_argument(
|
|
296
|
+
"--no-spot",
|
|
297
|
+
action="store_true",
|
|
298
|
+
help="Launch on-demand instead of spot.",
|
|
299
|
+
)
|
|
300
|
+
launch_p.add_argument(
|
|
301
|
+
"--volume-size",
|
|
302
|
+
type=int,
|
|
303
|
+
default=30,
|
|
304
|
+
help="Root EBS volume size in GB (default: 30).",
|
|
305
|
+
)
|
|
306
|
+
launch_p.add_argument(
|
|
307
|
+
"--volume-type",
|
|
308
|
+
default="gp3",
|
|
309
|
+
help="Root EBS volume type (default: gp3).",
|
|
310
|
+
)
|
|
311
|
+
launch_p.add_argument(
|
|
312
|
+
"--shutdown-behavior",
|
|
313
|
+
default="terminate",
|
|
314
|
+
choices=("terminate", "stop"),
|
|
315
|
+
help="Instance-initiated shutdown behavior (default: terminate).",
|
|
316
|
+
)
|
|
317
|
+
launch_p.add_argument(
|
|
318
|
+
"--name",
|
|
319
|
+
default=None,
|
|
320
|
+
help="Name tag applied to the launched instance.",
|
|
321
|
+
)
|
|
322
|
+
launch_p.add_argument(
|
|
323
|
+
"--region",
|
|
324
|
+
default=os.environ.get("AWS_REGION", "us-east-1"),
|
|
325
|
+
help="AWS region (default: $AWS_REGION or us-east-1).",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
args = parser.parse_args(argv)
|
|
329
|
+
|
|
330
|
+
logging.basicConfig(level=logging.WARNING)
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
instance_id = launch(
|
|
334
|
+
instance_types=_split_csv(args.types),
|
|
335
|
+
subnets=_split_csv(args.subnets),
|
|
336
|
+
image_id=args.image_id,
|
|
337
|
+
key_name=args.key_name,
|
|
338
|
+
security_group_ids=args.security_group,
|
|
339
|
+
iam_instance_profile=args.iam_profile,
|
|
340
|
+
spot=not args.no_spot,
|
|
341
|
+
volume_size_gb=args.volume_size,
|
|
342
|
+
volume_type=args.volume_type,
|
|
343
|
+
shutdown_behavior=args.shutdown_behavior,
|
|
344
|
+
tag_name=args.name,
|
|
345
|
+
region=args.region,
|
|
346
|
+
)
|
|
347
|
+
except SpotCapacityExhausted as exc:
|
|
348
|
+
print(f"ec2_spot: capacity exhausted: {exc}", file=sys.stderr)
|
|
349
|
+
return CAPACITY_EXIT_CODE
|
|
350
|
+
except SpotLaunchError as exc:
|
|
351
|
+
print(f"ec2_spot: launch failed: {exc}", file=sys.stderr)
|
|
352
|
+
return 1
|
|
353
|
+
except ValueError as exc:
|
|
354
|
+
print(f"ec2_spot: bad input: {exc}", file=sys.stderr)
|
|
355
|
+
return 2
|
|
356
|
+
|
|
357
|
+
# InstanceId on stdout — bash callers capture this via $(...)
|
|
358
|
+
print(instance_id)
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
if __name__ == "__main__":
|
|
363
|
+
sys.exit(main())
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email-send client for Alpha Engine modules.
|
|
3
|
+
|
|
4
|
+
Consolidation substrate for transactional email across consumer repos.
|
|
5
|
+
Before this module, every repo independently implemented the
|
|
6
|
+
"Gmail SMTP primary + AWS SES fallback" send path against the same
|
|
7
|
+
``EMAIL_SENDER`` / ``EMAIL_RECIPIENTS`` / ``GMAIL_APP_PASSWORD`` /
|
|
8
|
+
``AWS_REGION`` env-var convention (``~/Development/CLAUDE.md`` "Email"):
|
|
9
|
+
``alpha-engine/executor/eod_emailer.py``, ``alpha-engine-research``'s
|
|
10
|
+
sender, plus the predictor morning briefing, backtester evaluator email,
|
|
11
|
+
and data-collector failure alerts. Email is the higher-cardinality
|
|
12
|
+
producer (>=4 modules), so the "two writers diverged silently"
|
|
13
|
+
antipattern — drift on retry semantics, MIME shape, fallback ordering —
|
|
14
|
+
carries more risk here than it did for the Telegram consolidation
|
|
15
|
+
(``alpha_engine_lib.telegram``, lib v0.14.0) that surfaced this gap.
|
|
16
|
+
|
|
17
|
+
**Public API:**
|
|
18
|
+
|
|
19
|
+
- :func:`send_email` — primitive single-message send. Returns ``bool``,
|
|
20
|
+
never raises. Missing/misconfigured secrets resolve to a logged warning
|
|
21
|
+
+ ``False``, not an exception, so every caller can be fire-and-forget.
|
|
22
|
+
|
|
23
|
+
**Transport.** Gmail SMTP (``smtp.gmail.com:587`` STARTTLS) is the primary
|
|
24
|
+
path when ``GMAIL_APP_PASSWORD`` is set — mail originates from Gmail's
|
|
25
|
+
servers and passes SPF/DKIM. When the app password is absent the send
|
|
26
|
+
falls back to AWS SES in ``AWS_REGION``. (SES delivers reliably only with
|
|
27
|
+
a verified custom-domain sender; an ``@gmail.com`` SES sender may be
|
|
28
|
+
silently dropped — the legacy per-repo behavior, preserved here.)
|
|
29
|
+
|
|
30
|
+
**Secret resolution.** ``EMAIL_SENDER``, ``EMAIL_RECIPIENTS``,
|
|
31
|
+
``GMAIL_APP_PASSWORD`` and ``AWS_REGION`` are loaded via
|
|
32
|
+
:func:`alpha_engine_lib.secrets.get_secret` with ``required=False``.
|
|
33
|
+
Explicit ``sender`` / ``recipients`` / ``region`` arguments override the
|
|
34
|
+
resolved secrets. If no sender or no recipients can be determined the call
|
|
35
|
+
logs a warning and returns ``False`` — configured-or-no-op without
|
|
36
|
+
conditional branching at the call site, matching
|
|
37
|
+
:func:`alpha_engine_lib.telegram.send_message`.
|
|
38
|
+
|
|
39
|
+
**Failure behavior.** SMTP auth failures, network errors, SES
|
|
40
|
+
``ClientError``, and any other exception are logged at ERROR and returned
|
|
41
|
+
as ``False``. No exceptions propagate. By design — a failed notification
|
|
42
|
+
must never block trade execution, EOD reconcile, or a Saturday-SF stage.
|
|
43
|
+
|
|
44
|
+
**Migration arc**: ``alpha-engine-config/private-docs/ROADMAP.md`` L3204
|
|
45
|
+
("Consolidate ``email_sender`` into ``alpha_engine_lib``"), PR 1 of the
|
|
46
|
+
~5-PR sequence (this PR = lib substrate + tests + version bump; PRs 2-N
|
|
47
|
+
migrate each consumer with a lockstep requirements pin bump).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import logging
|
|
53
|
+
import smtplib
|
|
54
|
+
from email.mime.multipart import MIMEMultipart
|
|
55
|
+
from email.mime.text import MIMEText
|
|
56
|
+
from typing import Final
|
|
57
|
+
|
|
58
|
+
from alpha_engine_lib.secrets import get_secret
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
GMAIL_SMTP_HOST: Final[str] = "smtp.gmail.com"
|
|
63
|
+
GMAIL_SMTP_PORT: Final[int] = 587
|
|
64
|
+
SMTP_TIMEOUT_SEC: Final[int] = 30
|
|
65
|
+
DEFAULT_AWS_REGION: Final[str] = "us-east-1"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve_recipients(recipients: list[str] | None) -> list[str]:
|
|
69
|
+
"""Return the recipient list, preferring the explicit argument.
|
|
70
|
+
|
|
71
|
+
Falls back to the comma-separated ``EMAIL_RECIPIENTS`` secret. Blank
|
|
72
|
+
entries are stripped so a trailing comma in the env value doesn't
|
|
73
|
+
produce an empty-string recipient.
|
|
74
|
+
"""
|
|
75
|
+
if recipients:
|
|
76
|
+
return [r.strip() for r in recipients if r and r.strip()]
|
|
77
|
+
raw = get_secret("EMAIL_RECIPIENTS", required=False, default="") or ""
|
|
78
|
+
return [r.strip() for r in raw.split(",") if r.strip()]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _send_via_gmail(
|
|
82
|
+
*, sender: str, recipients: list[str], subject: str,
|
|
83
|
+
plain_body: str, html: str | None, app_password: str,
|
|
84
|
+
) -> bool:
|
|
85
|
+
"""Send through Gmail SMTP with STARTTLS. Returns success bool."""
|
|
86
|
+
msg = MIMEMultipart("alternative")
|
|
87
|
+
msg["Subject"] = subject
|
|
88
|
+
msg["From"] = sender
|
|
89
|
+
msg["To"] = ", ".join(recipients)
|
|
90
|
+
msg.attach(MIMEText(plain_body, "plain", "utf-8"))
|
|
91
|
+
if html:
|
|
92
|
+
msg.attach(MIMEText(html, "html", "utf-8"))
|
|
93
|
+
try:
|
|
94
|
+
with smtplib.SMTP(
|
|
95
|
+
GMAIL_SMTP_HOST, GMAIL_SMTP_PORT, timeout=SMTP_TIMEOUT_SEC
|
|
96
|
+
) as server:
|
|
97
|
+
server.ehlo()
|
|
98
|
+
server.starttls()
|
|
99
|
+
server.ehlo()
|
|
100
|
+
server.login(sender, app_password)
|
|
101
|
+
server.sendmail(sender, recipients, msg.as_string())
|
|
102
|
+
logger.info("Email sent via Gmail SMTP: %r -> %s", subject, recipients)
|
|
103
|
+
return True
|
|
104
|
+
except smtplib.SMTPAuthenticationError as e:
|
|
105
|
+
logger.error(
|
|
106
|
+
"Gmail SMTP auth failed: %s. Check GMAIL_APP_PASSWORD and 2FA.", e
|
|
107
|
+
)
|
|
108
|
+
return False
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.error("Gmail SMTP send error: %s", e)
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _send_via_ses(
|
|
115
|
+
*, sender: str, recipients: list[str], subject: str,
|
|
116
|
+
plain_body: str, html: str | None, region: str,
|
|
117
|
+
) -> bool:
|
|
118
|
+
"""Send through AWS SES. Returns success bool."""
|
|
119
|
+
logger.warning(
|
|
120
|
+
"GMAIL_APP_PASSWORD not set — falling back to SES. "
|
|
121
|
+
"If sender is @gmail.com, email may be silently dropped."
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
import boto3
|
|
125
|
+
from botocore.exceptions import ClientError
|
|
126
|
+
|
|
127
|
+
ses = boto3.client("ses", region_name=region)
|
|
128
|
+
message: dict = {
|
|
129
|
+
"Subject": {"Data": subject, "Charset": "UTF-8"},
|
|
130
|
+
"Body": {"Text": {"Data": plain_body, "Charset": "UTF-8"}},
|
|
131
|
+
}
|
|
132
|
+
if html:
|
|
133
|
+
message["Body"]["Html"] = {"Data": html, "Charset": "UTF-8"}
|
|
134
|
+
ses.send_email(
|
|
135
|
+
Source=sender,
|
|
136
|
+
Destination={"ToAddresses": recipients},
|
|
137
|
+
Message=message,
|
|
138
|
+
)
|
|
139
|
+
logger.info("Email sent via SES: %r -> %s", subject, recipients)
|
|
140
|
+
return True
|
|
141
|
+
except ClientError as e:
|
|
142
|
+
logger.error("SES send failed: %s", e.response["Error"]["Message"])
|
|
143
|
+
return False
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.error("SES email error: %s", e)
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def send_email(
|
|
150
|
+
subject: str,
|
|
151
|
+
body: str,
|
|
152
|
+
*,
|
|
153
|
+
recipients: list[str] | None = None,
|
|
154
|
+
html: str | None = None,
|
|
155
|
+
sender: str | None = None,
|
|
156
|
+
region: str | None = None,
|
|
157
|
+
) -> bool:
|
|
158
|
+
"""Send a single email via Gmail SMTP (primary) or AWS SES (fallback).
|
|
159
|
+
|
|
160
|
+
Resolves ``EMAIL_SENDER`` / ``EMAIL_RECIPIENTS`` / ``GMAIL_APP_PASSWORD``
|
|
161
|
+
/ ``AWS_REGION`` via :func:`alpha_engine_lib.secrets.get_secret`
|
|
162
|
+
(``required=False``); explicit arguments override the resolved secrets.
|
|
163
|
+
Gmail SMTP is used when an app password is available, otherwise SES.
|
|
164
|
+
Returns ``True`` only on a confirmed successful send; ``False`` on
|
|
165
|
+
missing config, auth failure, network error, or any other outcome
|
|
166
|
+
(logged). **Never raises** — callers are fire-and-forget.
|
|
167
|
+
|
|
168
|
+
:param subject: Email subject line.
|
|
169
|
+
:param body: Plain-text body (always sent as the ``text/plain`` part).
|
|
170
|
+
:param recipients: Explicit recipient list. Overrides
|
|
171
|
+
``EMAIL_RECIPIENTS`` when truthy.
|
|
172
|
+
:param html: Optional HTML body. When provided the message is
|
|
173
|
+
``multipart/alternative`` (plain + html); otherwise plain only.
|
|
174
|
+
:param sender: Explicit From address. Overrides ``EMAIL_SENDER``.
|
|
175
|
+
:param region: Explicit AWS region for the SES fallback. Overrides
|
|
176
|
+
``AWS_REGION`` (default ``us-east-1``).
|
|
177
|
+
:returns: ``True`` if the email was sent, ``False`` otherwise.
|
|
178
|
+
"""
|
|
179
|
+
sender = sender or get_secret("EMAIL_SENDER", required=False, default="") or ""
|
|
180
|
+
to = _resolve_recipients(recipients)
|
|
181
|
+
if not sender or not to:
|
|
182
|
+
logger.warning(
|
|
183
|
+
"Email not configured — EMAIL_SENDER=%s recipients=%s",
|
|
184
|
+
"set" if sender else "MISSING",
|
|
185
|
+
"set" if to else "MISSING",
|
|
186
|
+
)
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
app_password = (
|
|
190
|
+
get_secret("GMAIL_APP_PASSWORD", required=False, default="") or ""
|
|
191
|
+
).replace(" ", "")
|
|
192
|
+
|
|
193
|
+
if app_password:
|
|
194
|
+
return _send_via_gmail(
|
|
195
|
+
sender=sender, recipients=to, subject=subject,
|
|
196
|
+
plain_body=body, html=html, app_password=app_password,
|
|
197
|
+
)
|
|
198
|
+
region = (
|
|
199
|
+
region
|
|
200
|
+
or get_secret("AWS_REGION", required=False, default="")
|
|
201
|
+
or DEFAULT_AWS_REGION
|
|
202
|
+
)
|
|
203
|
+
return _send_via_ses(
|
|
204
|
+
sender=sender, recipients=to, subject=subject,
|
|
205
|
+
plain_body=body, html=html, region=region,
|
|
206
|
+
)
|