2026-04-17 22:08:40 +00:00
|
|
|
"""Schwab workplace-RSU email parser.
|
|
|
|
|
|
2026-05-27 09:40:56 +00:00
|
|
|
Schwab Stock Plan Services sends a "Your trade was executed" email for
|
|
|
|
|
each sell-to-cover trade (and any user-initiated trade) on the workplace
|
|
|
|
|
account. The body has five `<td class="dark-background-body" align="right">`
|
|
|
|
|
cells holding date / direction / quantity / ticker / price.
|
|
|
|
|
|
|
|
|
|
It does NOT email vest-release / Release Confirmation messages to the
|
|
|
|
|
employee address for this account (verified against 4 years of inbox
|
2026-05-27 10:02:07 +00:00
|
|
|
history, 2022-2026). The vest itself is invisible to IMAP.
|
|
|
|
|
|
|
|
|
|
Same-day-sell synthesis: Meta RSUs vest and are sold the same day at
|
|
|
|
|
the same FMV (verified across 14 historical vests). When a SELL email
|
|
|
|
|
is parsed AND its trade date is on or after `VEST_INFER_FROM_DATE`,
|
|
|
|
|
we ALSO emit a paired BUY representing the underlying vest event —
|
|
|
|
|
same date, same quantity, same price. The date boundary stops this
|
|
|
|
|
back-filling historical vests that already have csv-sourced BUY rows
|
|
|
|
|
in Wealthfolio (which would duplicate at chart-level despite distinct
|
|
|
|
|
external_ids).
|
2026-05-27 09:40:56 +00:00
|
|
|
|
|
|
|
|
On any parse failure we return an empty list — an unparseable email
|
|
|
|
|
shouldn't crash the IMAP batch.
|
2026-04-17 22:08:40 +00:00
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
schwab: detect vest-confirmation emails + emit VestEvent
Extends parse_schwab_email to handle Schwab's RSU Release Confirmation
emails alongside the existing trade confirmations. Adds:
- `VestEvent` dataclass in models.py — carries vest_date, ticker,
shares_vested, shares_sold_to_cover, fmv_at_vest_usd, tax_withheld_usd.
Written to payslip_ingest.rsu_vest_events by a postgres sink (pending
a real email fixture + cross-service DB grant).
- `parse_schwab_email_full()` — new entry point returning both
`list[Activity]` and `VestEvent | None`. The legacy
`parse_schwab_email()` shape is preserved for existing callers.
- Vest-release dispatch heuristic: HTML body mentions "Release
Confirmation" / "Award Vesting" / "RSU Release". On match, extract
vest fields via label regexes; the full vest becomes a BUY Activity
and the sell-to-cover slice becomes a SELL Activity at the same FMV
(net zero cash on the day). Gross vest + sell-to-cover returned so
Wealthfolio gets the full portfolio picture.
- Tests: 3 new (vest roundtrip, unparseable-vest safety, legacy shape
preserved); existing 6 unchanged.
The regex heuristics will need tightening once a real email sample
exists — the HTML structure observed in public Schwab emails may
differ in material ways. For now, unmatched vest bodies return
empty-result (no Activity, no VestEvent) rather than crashing the
IMAP batch.
Part of: code-860
2026-04-19 18:27:58 +00:00
|
|
|
import logging
|
2026-05-27 10:02:07 +00:00
|
|
|
import os
|
|
|
|
|
from datetime import date, datetime
|
2026-04-17 22:08:40 +00:00
|
|
|
from decimal import Decimal, InvalidOperation
|
|
|
|
|
|
|
|
|
|
from bs4 import BeautifulSoup
|
|
|
|
|
from dateutil import parser as dateparser
|
|
|
|
|
|
2026-05-27 09:40:56 +00:00
|
|
|
from broker_sync.models import AccountType, Activity, ActivityType
|
schwab: detect vest-confirmation emails + emit VestEvent
Extends parse_schwab_email to handle Schwab's RSU Release Confirmation
emails alongside the existing trade confirmations. Adds:
- `VestEvent` dataclass in models.py — carries vest_date, ticker,
shares_vested, shares_sold_to_cover, fmv_at_vest_usd, tax_withheld_usd.
Written to payslip_ingest.rsu_vest_events by a postgres sink (pending
a real email fixture + cross-service DB grant).
- `parse_schwab_email_full()` — new entry point returning both
`list[Activity]` and `VestEvent | None`. The legacy
`parse_schwab_email()` shape is preserved for existing callers.
- Vest-release dispatch heuristic: HTML body mentions "Release
Confirmation" / "Award Vesting" / "RSU Release". On match, extract
vest fields via label regexes; the full vest becomes a BUY Activity
and the sell-to-cover slice becomes a SELL Activity at the same FMV
(net zero cash on the day). Gross vest + sell-to-cover returned so
Wealthfolio gets the full portfolio picture.
- Tests: 3 new (vest roundtrip, unparseable-vest safety, legacy shape
preserved); existing 6 unchanged.
The regex heuristics will need tightening once a real email sample
exists — the HTML structure observed in public Schwab emails may
differ in material ways. For now, unmatched vest bodies return
empty-result (no Activity, no VestEvent) rather than crashing the
IMAP batch.
Part of: code-860
2026-04-19 18:27:58 +00:00
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
2026-04-17 22:08:40 +00:00
|
|
|
|
|
|
|
|
_ACCOUNT_ID = "schwab-workplace"
|
|
|
|
|
_DEFAULT_CURRENCY = "USD"
|
|
|
|
|
|
2026-05-27 10:02:07 +00:00
|
|
|
# Inferred-BUY synthesis boundary. SELL emails on or after this date
|
|
|
|
|
# emit a paired BUY for the underlying vest; earlier ones do not (they
|
|
|
|
|
# already have csv-sourced BUYs in Wealthfolio from the one-shot
|
|
|
|
|
# historical backfill, last vest 2026-02-18). Override at runtime with
|
|
|
|
|
# the env var if a different cutover is needed. ISO-8601 yyyy-mm-dd.
|
|
|
|
|
_DEFAULT_VEST_INFER_FROM = "2026-04-01"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _vest_infer_from() -> date:
|
|
|
|
|
raw = os.environ.get("SCHWAB_VEST_INFER_FROM_DATE", _DEFAULT_VEST_INFER_FROM).strip()
|
|
|
|
|
try:
|
|
|
|
|
return datetime.strptime(raw, "%Y-%m-%d").date()
|
|
|
|
|
except ValueError:
|
|
|
|
|
log.warning(
|
|
|
|
|
"SCHWAB_VEST_INFER_FROM_DATE=%r is not yyyy-mm-dd; using default %s",
|
|
|
|
|
raw, _DEFAULT_VEST_INFER_FROM,
|
|
|
|
|
)
|
|
|
|
|
return datetime.strptime(_DEFAULT_VEST_INFER_FROM, "%Y-%m-%d").date()
|
|
|
|
|
|
2026-04-17 22:08:40 +00:00
|
|
|
|
|
|
|
|
def parse_schwab_email(raw_html: str) -> list[Activity]:
|
2026-05-27 10:02:07 +00:00
|
|
|
"""Return Activities for a Schwab trade-executed email.
|
|
|
|
|
|
|
|
|
|
Returns: empty list on parse failure; one Activity for a BUY-direction
|
|
|
|
|
email (rare — the workplace account is essentially sell-only); for a
|
|
|
|
|
SELL email, returns [SELL] plus an inferred paired BUY (=vest event)
|
|
|
|
|
when the trade date is on or after the synthesis-boundary date.
|
|
|
|
|
"""
|
2026-04-17 22:08:40 +00:00
|
|
|
try:
|
|
|
|
|
soup = BeautifulSoup(raw_html, "html.parser")
|
|
|
|
|
cells = [
|
|
|
|
|
td.get_text(strip=True) for td in soup.find_all("td", {
|
|
|
|
|
"class": "dark-background-body",
|
|
|
|
|
"align": "right"
|
|
|
|
|
})
|
|
|
|
|
]
|
|
|
|
|
if len(cells) < 5:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
date_txt, direction_txt, qty_txt, ticker, price_txt = cells[:5]
|
|
|
|
|
trade_date = dateparser.parse(date_txt)
|
|
|
|
|
direction = (ActivityType.SELL
|
|
|
|
|
if direction_txt.strip().lower() == "sold" else ActivityType.BUY)
|
|
|
|
|
quantity = Decimal(qty_txt.replace(",", "").strip())
|
|
|
|
|
price_clean = price_txt
|
|
|
|
|
for sign in ("$", "£", "€", "USD", "GBP", "EUR"):
|
|
|
|
|
price_clean = price_clean.replace(sign, "")
|
|
|
|
|
unit_price = Decimal(price_clean.replace(",", "").strip())
|
2026-05-27 10:02:07 +00:00
|
|
|
ticker_clean = ticker.strip()
|
2026-04-17 22:08:40 +00:00
|
|
|
|
2026-05-27 10:02:07 +00:00
|
|
|
external_id = (f"schwab:{trade_date.date().isoformat()}:{ticker_clean}:"
|
2026-04-17 22:08:40 +00:00
|
|
|
f"{direction.value}:{quantity}")
|
2026-05-27 10:02:07 +00:00
|
|
|
primary = Activity(
|
|
|
|
|
external_id=external_id,
|
|
|
|
|
account_id=_ACCOUNT_ID,
|
|
|
|
|
account_type=AccountType.GIA,
|
|
|
|
|
date=trade_date,
|
|
|
|
|
activity_type=direction,
|
|
|
|
|
symbol=ticker_clean,
|
|
|
|
|
quantity=quantity,
|
|
|
|
|
unit_price=unit_price,
|
|
|
|
|
currency=_DEFAULT_CURRENCY,
|
|
|
|
|
notes=f"schwab-email:{direction_txt}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if direction is not ActivityType.SELL or trade_date.date() < _vest_infer_from():
|
|
|
|
|
return [primary]
|
|
|
|
|
|
|
|
|
|
inferred_buy = Activity(
|
|
|
|
|
external_id=(f"schwab:vest:{trade_date.date().isoformat()}:"
|
|
|
|
|
f"{ticker_clean}:BUY:{quantity}"),
|
|
|
|
|
account_id=_ACCOUNT_ID,
|
|
|
|
|
account_type=AccountType.GIA,
|
|
|
|
|
date=trade_date,
|
|
|
|
|
activity_type=ActivityType.BUY,
|
|
|
|
|
symbol=ticker_clean,
|
|
|
|
|
quantity=quantity,
|
|
|
|
|
unit_price=unit_price,
|
|
|
|
|
currency=_DEFAULT_CURRENCY,
|
|
|
|
|
notes=(f"schwab-vest-inferred-from-same-day-sell | "
|
|
|
|
|
f"paired_sell_external_id={external_id}"),
|
|
|
|
|
)
|
|
|
|
|
return [inferred_buy, primary]
|
2026-04-17 22:08:40 +00:00
|
|
|
except (ValueError, InvalidOperation, IndexError, AttributeError):
|
|
|
|
|
return []
|