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