Baseline before Postgres migration (WS0)

Snapshot before the DuckDB->PostgreSQL migration. data/, archive/, the 232MB
rebuild zip, the Windows .venv, and .env are excluded via .gitignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-15 23:39:29 -04:00
commit 007cd95c79
133 files changed
+17321

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
"""Schwab broker integration (Milestone 3): auth, market data, account sync."""
from schwab.settings import SchwabSettings
__all__ = ["SchwabSettings"]
+7
View File
@@ -0,0 +1,7 @@
"""Schwab OAuth2 authentication."""
from schwab.auth.token_store import TokenBundle, TokenStore
from schwab.auth.oauth2_flow_manager import OAuth2FlowManager
from schwab.auth.lifecycle import TokenLifecycle
__all__ = ["TokenBundle", "TokenStore", "OAuth2FlowManager", "TokenLifecycle"]
+50
View File
@@ -0,0 +1,50 @@
"""Token lifecycle: load, refresh when stale, surface auth status."""
from __future__ import annotations
import logging
from schwab.auth.oauth2_flow_manager import OAuth2FlowManager
from schwab.auth.token_store import TokenBundle, TokenStore
from schwab.settings import SchwabSettings
logger = logging.getLogger(__name__)
class TokenLifecycle:
def __init__(
self,
settings: SchwabSettings | None = None,
token_store: TokenStore | None = None,
):
self.settings = settings or SchwabSettings.from_env()
self.token_store = token_store or TokenStore(self.settings.token_path)
self.flow = OAuth2FlowManager(self.settings, self.token_store)
def status(self) -> dict:
bundle = self.token_store.load()
if bundle is None:
return {"authenticated": False, "reason": "no token file"}
if bundle.is_expired and not bundle.refresh_token:
return {"authenticated": False, "reason": "token expired, no refresh token"}
return {
"authenticated": True,
"expired": bundle.is_expired,
"expires_at": bundle.expires_at,
}
def get_access_token(self) -> str | None:
"""Return a valid access token, refreshing if needed. None if unauth."""
bundle = self.token_store.load()
if bundle is None:
return None
if not bundle.is_expired:
return bundle.access_token
if not bundle.refresh_token:
return None
try:
refreshed: TokenBundle = self.flow.refresh(bundle.refresh_token)
return refreshed.access_token
except Exception:
logger.exception("schwab token refresh failed")
return None
+81
View File
@@ -0,0 +1,81 @@
"""Schwab OAuth2 authorization-code flow."""
from __future__ import annotations
import base64
import json
import logging
import urllib.parse
import urllib.request
from schwab.auth.token_store import TokenBundle, TokenStore
from schwab.settings import SchwabSettings
logger = logging.getLogger(__name__)
AUTH_URL = "https://api.schwabapi.com/v1/oauth/authorize"
TOKEN_URL = "https://api.schwabapi.com/v1/oauth/token"
class OAuth2FlowManager:
def __init__(
self,
settings: SchwabSettings | None = None,
token_store: TokenStore | None = None,
use_broker_app: bool = True,
):
self.settings = settings or SchwabSettings.from_env()
self.token_store = token_store or TokenStore(self.settings.token_path)
if use_broker_app and self.settings.has_broker_credentials:
self.app_key = self.settings.broker_app_key
self.app_secret = self.settings.broker_app_secret
else:
self.app_key = self.settings.market_data_app_key
self.app_secret = self.settings.market_data_app_secret
def build_authorization_url(self, state: str = "momentum") -> str:
params = urllib.parse.urlencode(
{
"client_id": self.app_key,
"redirect_uri": self.settings.redirect_uri,
"response_type": "code",
"state": state,
}
)
return f"{AUTH_URL}?{params}"
def _basic_auth_header(self) -> str:
raw = f"{self.app_key}:{self.app_secret}".encode()
return "Basic " + base64.b64encode(raw).decode()
def _token_request(self, form: dict) -> TokenBundle:
data = urllib.parse.urlencode(form).encode()
req = urllib.request.Request(
TOKEN_URL,
data=data,
headers={
"Authorization": self._basic_auth_header(),
"Content-Type": "application/x-www-form-urlencoded",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=20) as resp:
payload = json.loads(resp.read().decode())
bundle = TokenBundle.from_oauth_response(payload)
self.token_store.save(bundle)
return bundle
def exchange_code(self, authorization_code: str) -> TokenBundle:
"""Exchange the redirect ?code= for access + refresh tokens."""
return self._token_request(
{
"grant_type": "authorization_code",
"code": urllib.parse.unquote(authorization_code),
"redirect_uri": self.settings.redirect_uri,
}
)
def refresh(self, refresh_token: str) -> TokenBundle:
return self._token_request(
{"grant_type": "refresh_token", "refresh_token": refresh_token}
)
+71
View File
@@ -0,0 +1,71 @@
"""Token persistence for Schwab OAuth2 tokens.
SECURITY: token files contain live credentials. They are written 0600 and
data/schwab_tokens.json is git-ignored. Never commit token files.
"""
from __future__ import annotations
import json
import os
import time
from dataclasses import dataclass, asdict
from pathlib import Path
@dataclass
class TokenBundle:
access_token: str = ""
refresh_token: str = ""
expires_at: float = 0.0 # unix epoch seconds
token_type: str = "Bearer"
scope: str = ""
@property
def is_expired(self) -> bool:
return not self.access_token or time.time() >= self.expires_at - 60
@classmethod
def from_oauth_response(cls, payload: dict) -> "TokenBundle":
return cls(
access_token=payload.get("access_token", ""),
refresh_token=payload.get("refresh_token", ""),
expires_at=time.time() + float(payload.get("expires_in", 1800)),
token_type=payload.get("token_type", "Bearer"),
scope=payload.get("scope", ""),
)
class TokenStore:
def __init__(self, path: str | Path = "data/schwab_tokens.json"):
self.path = Path(path)
def exists(self) -> bool:
return self.path.exists()
def load(self) -> TokenBundle | None:
if not self.path.exists():
return None
try:
data = json.loads(self.path.read_text())
except (json.JSONDecodeError, OSError):
return None
return TokenBundle(
access_token=data.get("access_token", ""),
refresh_token=data.get("refresh_token", ""),
expires_at=float(data.get("expires_at", 0)),
token_type=data.get("token_type", "Bearer"),
scope=data.get("scope", ""),
)
def save(self, bundle: TokenBundle) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(asdict(bundle), indent=2))
try:
os.chmod(self.path, 0o600)
except OSError:
pass
def clear(self) -> None:
if self.path.exists():
self.path.unlink()
+6
View File
@@ -0,0 +1,6 @@
"""Schwab broker health."""
from schwab.health.models import HealthStatus, HealthCheck, HealthReport
from schwab.health.reporter import HealthReporter
__all__ = ["HealthStatus", "HealthCheck", "HealthReport", "HealthReporter"]
+37
View File
@@ -0,0 +1,37 @@
"""Broker health models."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class HealthStatus(str, Enum):
HEALTHY = "healthy"
DEGRADED = "degraded"
DOWN = "down"
UNAUTHENTICATED = "unauthenticated"
@dataclass
class HealthCheck:
name: str
status: HealthStatus
detail: str | None = None
@dataclass
class HealthReport:
broker_name: str
status: HealthStatus
checks: list[HealthCheck] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"broker_name": self.broker_name,
"status": self.status.value,
"checks": [
{"name": c.name, "status": c.status.value, "detail": c.detail}
for c in self.checks
],
}
+96
View File
@@ -0,0 +1,96 @@
"""Schwab broker health reporter — emits broker_health_changed events."""
from __future__ import annotations
import logging
from datetime import datetime
from schwab.auth.lifecycle import TokenLifecycle
from schwab.health.models import HealthCheck, HealthReport, HealthStatus
from schwab.settings import SchwabSettings
from storage.event_schema import BrokerHealthChangedEvent, EventMode
from storage.event_store import EventStore
logger = logging.getLogger(__name__)
class HealthReporter:
def __init__(
self,
store: EventStore | None = None,
settings: SchwabSettings | None = None,
lifecycle: TokenLifecycle | None = None,
session_id: str | None = None,
):
self.store = store
self.settings = settings or SchwabSettings.from_env()
self.lifecycle = lifecycle or TokenLifecycle(self.settings)
self.session_id = session_id
self._last_status: HealthStatus | None = None
def check(self) -> HealthReport:
checks: list[HealthCheck] = []
if self.settings.has_broker_credentials or self.settings.has_market_data_credentials:
checks.append(HealthCheck("credentials", HealthStatus.HEALTHY))
else:
checks.append(
HealthCheck(
"credentials",
HealthStatus.DOWN,
"no SCHWAB_* app keys configured",
)
)
auth = self.lifecycle.status()
if auth.get("authenticated") and not auth.get("expired"):
checks.append(HealthCheck("token", HealthStatus.HEALTHY))
elif auth.get("authenticated"):
checks.append(
HealthCheck("token", HealthStatus.DEGRADED, "token expired; refresh pending")
)
else:
checks.append(
HealthCheck(
"token", HealthStatus.UNAUTHENTICATED,
str(auth.get("reason") or "not authenticated"),
)
)
statuses = {c.status for c in checks}
if statuses == {HealthStatus.HEALTHY}:
overall = HealthStatus.HEALTHY
elif HealthStatus.DOWN in statuses:
overall = HealthStatus.DOWN
elif HealthStatus.UNAUTHENTICATED in statuses:
overall = HealthStatus.UNAUTHENTICATED
else:
overall = HealthStatus.DEGRADED
report = HealthReport(broker_name="schwab", status=overall, checks=checks)
self._maybe_emit(report)
return report
def _maybe_emit(self, report: HealthReport) -> None:
if self.store is None or report.status == self._last_status:
self._last_status = report.status
return
try:
self.store.emit(
BrokerHealthChangedEvent(
timestamp=datetime.now(),
mode=EventMode.LIVE,
correlation_id=self.session_id,
message=f"Schwab health: {report.status.value}",
broker_name="schwab",
previous_health=self._last_status.value
if self._last_status
else "unknown",
new_health=report.status.value,
health_reason="; ".join(
f"{c.name}={c.status.value}" for c in report.checks
),
payload=report.to_dict(),
)
)
except Exception:
logger.exception("failed to emit broker health event")
self._last_status = report.status
+24
View File
@@ -0,0 +1,24 @@
"""Schwab market data."""
from schwab.market.models import Quote, Candle, PriceHistory, OptionQuote
from schwab.market.client import SchwabMarketClient, SchwabApiError
from schwab.market.adapter import (
quotes_from_payload,
price_history_to_dataframe,
payload_to_dataframe,
)
from schwab.market.option_chain_service import OptionChainService, OptionChainResult
__all__ = [
"Quote",
"Candle",
"PriceHistory",
"OptionQuote",
"SchwabMarketClient",
"SchwabApiError",
"quotes_from_payload",
"price_history_to_dataframe",
"payload_to_dataframe",
"OptionChainService",
"OptionChainResult",
]
+34
View File
@@ -0,0 +1,34 @@
"""Adapter: Schwab market payloads -> typed models / DataFrames."""
from __future__ import annotations
from datetime import datetime, timezone
import pandas as pd
from schwab.market.models import PriceHistory, Quote
def quotes_from_payload(payload: dict) -> list[Quote]:
return [Quote.from_api(symbol, body) for symbol, body in payload.items()]
def price_history_to_dataframe(history: PriceHistory) -> pd.DataFrame:
rows = [
{
"timestamp": datetime.fromtimestamp(
c.datetime_ms / 1000, tz=timezone.utc
).replace(tzinfo=None),
"open": c.open,
"high": c.high,
"low": c.low,
"close": c.close,
"volume": c.volume,
}
for c in history.candles
]
return pd.DataFrame(rows)
def payload_to_dataframe(payload: dict) -> pd.DataFrame:
return price_history_to_dataframe(PriceHistory.from_api(payload))
+83
View File
@@ -0,0 +1,83 @@
"""Schwab market-data HTTP client (quotes + price history)."""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
from schwab.auth.lifecycle import TokenLifecycle
from schwab.settings import SchwabSettings
logger = logging.getLogger(__name__)
class SchwabApiError(RuntimeError):
def __init__(self, status: int, body: str):
super().__init__(f"Schwab API error {status}: {body[:300]}")
self.status = status
self.body = body
class SchwabMarketClient:
def __init__(
self,
settings: SchwabSettings | None = None,
lifecycle: TokenLifecycle | None = None,
timeout: int = 15,
):
self.settings = settings or SchwabSettings.from_env()
self.lifecycle = lifecycle or TokenLifecycle(self.settings)
self.timeout = timeout
def _get(self, path: str, params: dict | None = None) -> dict:
token = self.lifecycle.get_access_token()
if not token:
raise SchwabApiError(401, "no valid Schwab access token")
url = f"{self.settings.api_base_url}{path}"
if params:
url += "?" + urllib.parse.urlencode(
{k: v for k, v in params.items() if v is not None}
)
req = urllib.request.Request(
url, headers={"Authorization": f"Bearer {token}"}
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read().decode() or "{}")
except urllib.error.HTTPError as exc:
raise SchwabApiError(exc.code, exc.read().decode(errors="replace")) from exc
def get_quotes(self, symbols: list[str]) -> dict:
return self._get(
"/marketdata/v1/quotes", params={"symbols": ",".join(symbols)}
)
def get_price_history(
self,
symbol: str,
period_type: str = "day",
period: int = 1,
frequency_type: str = "minute",
frequency: int = 1,
need_extended_hours: bool = True,
) -> dict:
return self._get(
"/marketdata/v1/pricehistory",
params={
"symbol": symbol,
"periodType": period_type,
"period": period,
"frequencyType": frequency_type,
"frequency": frequency,
"needExtendedHoursData": str(need_extended_hours).lower(),
},
)
def get_option_chain(self, symbol: str, contract_type: str = "ALL") -> dict:
return self._get(
"/marketdata/v1/chains",
params={"symbol": symbol, "contractType": contract_type},
)
+86
View File
@@ -0,0 +1,86 @@
"""Schwab market-data models.
(Repaired: the original had broken dataclass indentation, a `string` type
annotation, and an invalid `int = None = None` default chain.)
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class Quote:
symbol: str
bid_price: float | None = None
ask_price: float | None = None
last_price: float | None = None
bid_size: int | None = None
ask_size: int | None = None
total_volume: int | None = None
quote_time: str | None = None
@classmethod
def from_api(cls, symbol: str, payload: dict) -> "Quote":
quote = payload.get("quote") or payload
return cls(
symbol=symbol,
bid_price=quote.get("bidPrice"),
ask_price=quote.get("askPrice"),
last_price=quote.get("lastPrice"),
bid_size=quote.get("bidSize"),
ask_size=quote.get("askSize"),
total_volume=quote.get("totalVolume"),
quote_time=str(quote.get("quoteTime")) if quote.get("quoteTime") else None,
)
@dataclass
class Candle:
open: float
high: float
low: float
close: float
volume: int
datetime_ms: int
@classmethod
def from_api(cls, payload: dict) -> "Candle":
return cls(
open=float(payload.get("open") or 0),
high=float(payload.get("high") or 0),
low=float(payload.get("low") or 0),
close=float(payload.get("close") or 0),
volume=int(payload.get("volume") or 0),
datetime_ms=int(payload.get("datetime") or 0),
)
@dataclass
class PriceHistory:
symbol: str
candles: list[Candle] = field(default_factory=list)
empty: bool = True
@classmethod
def from_api(cls, payload: dict) -> "PriceHistory":
candles = [Candle.from_api(c) for c in payload.get("candles") or []]
return cls(
symbol=payload.get("symbol", ""),
candles=candles,
empty=payload.get("empty", not candles),
)
@dataclass
class OptionQuote:
symbol: str
strike: float | None = None
expiration: str | None = None
put_call: str | None = None
bid: float | None = None
ask: float | None = None
last: float | None = None
delta: float | None = None
open_interest: int | None = None
volume: int | None = None
+63
View File
@@ -0,0 +1,63 @@
"""Option chain retrieval service.
(Repaired: original had dataclass default-ordering errors and unbalanced
parentheses.)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from schwab.market.client import SchwabApiError, SchwabMarketClient
from schwab.market.models import OptionQuote
logger = logging.getLogger(__name__)
@dataclass
class OptionChainResult:
symbol: str
underlying_price: float | None = None
calls: list[OptionQuote] = field(default_factory=list)
puts: list[OptionQuote] = field(default_factory=list)
error: str | None = None
class OptionChainService:
def __init__(self, client: SchwabMarketClient | None = None):
self.client = client or SchwabMarketClient()
def get_chain(self, symbol: str) -> OptionChainResult:
try:
payload = self.client.get_option_chain(symbol)
except SchwabApiError as exc:
return OptionChainResult(symbol=symbol, error=str(exc))
result = OptionChainResult(
symbol=symbol, underlying_price=payload.get("underlyingPrice")
)
result.calls = self._parse_side(payload.get("callExpDateMap") or {}, "CALL")
result.puts = self._parse_side(payload.get("putExpDateMap") or {}, "PUT")
return result
@staticmethod
def _parse_side(exp_map: dict, put_call: str) -> list[OptionQuote]:
quotes: list[OptionQuote] = []
for expiration, strikes in exp_map.items():
for strike, contracts in (strikes or {}).items():
for contract in contracts or []:
quotes.append(
OptionQuote(
symbol=contract.get("symbol", ""),
strike=float(strike),
expiration=expiration.split(":")[0],
put_call=put_call,
bid=contract.get("bid"),
ask=contract.get("ask"),
last=contract.get("last"),
delta=contract.get("delta"),
open_interest=contract.get("openInterest"),
volume=contract.get("totalVolume"),
)
)
return quotes
+6
View File
@@ -0,0 +1,6 @@
"""Schwab orders (read-only)."""
from schwab.orders.reader import OrdersReader
from schwab.orders.sync import OrderSync
__all__ = ["OrdersReader", "OrderSync"]
+67
View File
@@ -0,0 +1,67 @@
"""Read Schwab orders (read-only, with unauthenticated fallback)."""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from schwab.auth.lifecycle import TokenLifecycle
from schwab.settings import SchwabSettings
logger = logging.getLogger(__name__)
class OrdersReader:
def __init__(
self,
settings: SchwabSettings | None = None,
lifecycle: TokenLifecycle | None = None,
timeout: int = 15,
):
self.settings = settings or SchwabSettings.from_env()
self.lifecycle = lifecycle or TokenLifecycle(self.settings)
self.timeout = timeout
def read_orders(self, days_back: int = 2) -> list[dict]:
token = self.lifecycle.get_access_token()
if not token:
return []
now = datetime.now(timezone.utc)
params = urllib.parse.urlencode(
{
"fromEnteredTime": (now - timedelta(days=days_back)).strftime(
"%Y-%m-%dT%H:%M:%S.000Z"
),
"toEnteredTime": now.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
}
)
url = f"{self.settings.api_base_url}/trader/v1/orders?{params}"
req = urllib.request.Request(
url, headers={"Authorization": f"Bearer {token}"}
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = json.loads(resp.read().decode() or "[]")
except (urllib.error.URLError, OSError, json.JSONDecodeError):
logger.exception("schwab orders read failed")
return []
return [
{
"broker_order_id": str(o.get("orderId") or ""),
"symbol": (o.get("orderLegCollection") or [{}])[0]
.get("instrument", {})
.get("symbol"),
"side": (o.get("orderLegCollection") or [{}])[0].get("instruction"),
"quantity": o.get("quantity"),
"filled_quantity": o.get("filledQuantity"),
"status": o.get("status"),
"type": o.get("orderType"),
"price": o.get("price"),
"submitted_at": o.get("enteredTime"),
}
for o in raw
]
+45
View File
@@ -0,0 +1,45 @@
"""Sync Schwab orders into the event store."""
from __future__ import annotations
import logging
from datetime import datetime
from schwab.orders.reader import OrdersReader
from storage.event_schema import AccountOrdersUpdatedEvent, EventMode
from storage.event_store import EventStore
logger = logging.getLogger(__name__)
BROKER_NAME = "schwab"
class OrderSync:
def __init__(
self,
store: EventStore,
reader: OrdersReader | None = None,
session_id: str | None = None,
mode: EventMode = EventMode.LIVE,
account_id: str = "schwab",
):
self.store = store
self.reader = reader or OrdersReader()
self.session_id = session_id
self.mode = mode
self.account_id = account_id
def sync_orders(self) -> list[dict]:
orders = self.reader.read_orders()
self.store.emit(
AccountOrdersUpdatedEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Schwab orders: {len(orders)}",
broker_name=BROKER_NAME,
account_id=self.account_id,
orders=orders,
)
)
return orders
+13
View File
@@ -0,0 +1,13 @@
"""Schwab account & positions (read-only)."""
from schwab.positions.models import AccountSummary, AccountPositions, Position
from schwab.positions.reader import PositionsReader
from schwab.positions.sync import AccountSync
__all__ = [
"AccountSummary",
"AccountPositions",
"Position",
"PositionsReader",
"AccountSync",
]
+33
View File
@@ -0,0 +1,33 @@
"""Schwab account/position models."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class AccountSummary:
account_id: str
account_desc: str = "Schwab"
total_equity: float = 0.0
cash_balance: float = 0.0
buying_power: float = 0.0
net_liquidating_value: float = 0.0
is_fallback: bool = False
@dataclass
class Position:
symbol: str
quantity: float = 0.0
avg_entry_price: float = 0.0
market_value: float = 0.0
unrealized_pnl: float = 0.0
asset_type: str = "EQUITY"
@dataclass
class AccountPositions:
account_id: str
positions: list[Position] = field(default_factory=list)
is_fallback: bool = False
+111
View File
@@ -0,0 +1,111 @@
"""Read Schwab account summary and positions.
When no valid token exists (the common case before OAuth setup), the reader
returns clearly-labelled fallback data rather than crashing, so the rest of
the system — and its tests — keep working.
"""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
from schwab.auth.lifecycle import TokenLifecycle
from schwab.positions.models import AccountPositions, AccountSummary, Position
from schwab.settings import SchwabSettings
logger = logging.getLogger(__name__)
class PositionsReader:
def __init__(
self,
settings: SchwabSettings | None = None,
lifecycle: TokenLifecycle | None = None,
timeout: int = 15,
):
self.settings = settings or SchwabSettings.from_env()
self.lifecycle = lifecycle or TokenLifecycle(self.settings)
self.timeout = timeout
def _get(self, path: str, token: str, params: dict | None = None):
url = f"{self.settings.api_base_url}{path}"
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(
url, headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read().decode() or "{}")
# -- fallbacks -------------------------------------------------------
@staticmethod
def _fallback_account_summary() -> AccountSummary:
return AccountSummary(
account_id="SCHWAB-UNAUTH",
account_desc="Schwab (not authenticated — fallback data)",
is_fallback=True,
)
@staticmethod
def _fallback_positions() -> AccountPositions:
return AccountPositions(account_id="SCHWAB-UNAUTH", is_fallback=True)
# -- public ----------------------------------------------------------
def read_account_summary(self) -> AccountSummary:
token = self.lifecycle.get_access_token()
if not token:
return self._fallback_account_summary()
try:
accounts = self._get("/trader/v1/accounts", token)
except (urllib.error.URLError, OSError, json.JSONDecodeError):
logger.exception("schwab account read failed")
return self._fallback_account_summary()
if not accounts:
return self._fallback_account_summary()
account = accounts[0].get("securitiesAccount", accounts[0])
balances = account.get("currentBalances", {})
return AccountSummary(
account_id=str(account.get("accountNumber") or "schwab"),
account_desc=account.get("type", "Schwab"),
total_equity=float(balances.get("equity") or 0),
cash_balance=float(balances.get("cashBalance") or 0),
buying_power=float(balances.get("buyingPower") or 0),
net_liquidating_value=float(balances.get("liquidationValue") or 0),
)
def read_positions(self) -> AccountPositions:
token = self.lifecycle.get_access_token()
if not token:
return self._fallback_positions()
try:
accounts = self._get(
"/trader/v1/accounts", token, params={"fields": "positions"}
)
except (urllib.error.URLError, OSError, json.JSONDecodeError):
logger.exception("schwab positions read failed")
return self._fallback_positions()
if not accounts:
return self._fallback_positions()
account = accounts[0].get("securitiesAccount", accounts[0])
positions = [
Position(
symbol=p.get("instrument", {}).get("symbol", ""),
quantity=float(p.get("longQuantity") or 0)
- float(p.get("shortQuantity") or 0),
avg_entry_price=float(p.get("averagePrice") or 0),
market_value=float(p.get("marketValue") or 0),
unrealized_pnl=float(p.get("longOpenProfitLoss") or 0),
asset_type=p.get("instrument", {}).get("assetType", "EQUITY"),
)
for p in account.get("positions") or []
]
return AccountPositions(
account_id=str(account.get("accountNumber") or "schwab"),
positions=positions,
)
+70
View File
@@ -0,0 +1,70 @@
"""Sync Schwab account state into the event store."""
from __future__ import annotations
import logging
from dataclasses import asdict
from datetime import datetime
from schwab.positions.reader import PositionsReader
from storage.event_schema import (
AccountPositionsUpdatedEvent,
AccountSummaryUpdatedEvent,
EventMode,
)
from storage.event_store import EventStore
logger = logging.getLogger(__name__)
BROKER_NAME = "schwab"
class AccountSync:
def __init__(
self,
store: EventStore,
reader: PositionsReader | None = None,
session_id: str | None = None,
mode: EventMode = EventMode.LIVE,
):
self.store = store
self.reader = reader or PositionsReader()
self.session_id = session_id
self.mode = mode
def sync_summary(self) -> dict:
summary = self.reader.read_account_summary()
self.store.emit(
AccountSummaryUpdatedEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Schwab account {summary.account_id} equity {summary.total_equity}",
broker_name=BROKER_NAME,
account_id=summary.account_id,
account_desc=summary.account_desc,
total_equity=summary.total_equity,
cash_balance=summary.cash_balance,
buying_power=summary.buying_power,
net_liquidating_value=summary.net_liquidating_value,
)
)
return asdict(summary)
def sync_positions(self) -> dict:
snapshot = self.reader.read_positions()
self.store.emit(
AccountPositionsUpdatedEvent(
timestamp=datetime.now(),
mode=self.mode,
correlation_id=self.session_id,
message=f"Schwab positions: {len(snapshot.positions)}",
broker_name=BROKER_NAME,
account_id=snapshot.account_id,
positions=[asdict(p) for p in snapshot.positions],
)
)
return asdict(snapshot)
def sync_all(self) -> dict:
return {"summary": self.sync_summary(), "positions": self.sync_positions()}
+41
View File
@@ -0,0 +1,41 @@
"""Schwab API settings (Milestone 3)."""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class SchwabSettings:
market_data_app_key: str = ""
market_data_app_secret: str = ""
broker_app_key: str = ""
broker_app_secret: str = ""
redirect_uri: str = "https://127.0.0.1:8182/callback"
token_path: str = "data/schwab_tokens.json"
api_base_url: str = "https://api.schwabapi.com"
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "SchwabSettings":
values = dict(os.environ)
if env is not None:
values.update(env)
return cls(
market_data_app_key=values.get("SCHWAB_MARKET_DATA_APP_KEY", ""),
market_data_app_secret=values.get("SCHWAB_MARKET_DATA_APP_SECRET", ""),
broker_app_key=values.get("SCHWAB_BROKER_APP_KEY", ""),
broker_app_secret=values.get("SCHWAB_BROKER_APP_SECRET", ""),
redirect_uri=values.get(
"SCHWAB_REDIRECT_URI", "https://127.0.0.1:8182/callback"
),
token_path=values.get("SCHWAB_TOKEN_PATH", "data/schwab_tokens.json"),
)
@property
def has_market_data_credentials(self) -> bool:
return bool(self.market_data_app_key and self.market_data_app_secret)
@property
def has_broker_credentials(self) -> bool:
return bool(self.broker_app_key and self.broker_app_secret)
+5
View File
@@ -0,0 +1,5 @@
"""Schwab streaming (scaffold)."""
from schwab.streaming.client import StreamingClient, StreamerInfo
__all__ = ["StreamingClient", "StreamerInfo"]
+87
View File
@@ -0,0 +1,87 @@
"""Schwab streaming client scaffold.
(Repaired: original had dataclass default-ordering errors and unbalanced
parentheses.) Real-time streaming requires the streamer-info handshake from
user preferences; this scaffold builds the login payload and exposes a
callback interface, while the live system polls REST minute bars instead.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from typing import Callable
logger = logging.getLogger(__name__)
@dataclass
class StreamerInfo:
streamer_socket_url: str = ""
schwab_client_customer_id: str = ""
schwab_client_correl_id: str = ""
schwab_client_channel: str = "N9"
schwab_client_function_id: str = "APIAPP"
@classmethod
def from_user_preferences(cls, payload: dict) -> "StreamerInfo":
info = (payload.get("streamerInfo") or [{}])[0]
return cls(
streamer_socket_url=info.get("streamerSocketUrl", ""),
schwab_client_customer_id=info.get("schwabClientCustomerId", ""),
schwab_client_correl_id=info.get("schwabClientCorrelId", ""),
schwab_client_channel=info.get("schwabClientChannel", "N9"),
schwab_client_function_id=info.get("schwabClientFunctionId", "APIAPP"),
)
@dataclass
class StreamingClient:
streamer_info: StreamerInfo
access_token: str
request_id: int = 0
on_message: Callable[[dict], None] | None = None
subscriptions: list[dict] = field(default_factory=list)
def _next_request_id(self) -> int:
self.request_id += 1
return self.request_id
def build_login_request(self) -> dict:
return {
"service": "ADMIN",
"command": "LOGIN",
"requestid": self._next_request_id(),
"SchwabClientCustomerId": self.streamer_info.schwab_client_customer_id,
"SchwabClientCorrelId": self.streamer_info.schwab_client_correl_id,
"parameters": {
"Authorization": self.access_token,
"SchwabClientChannel": self.streamer_info.schwab_client_channel,
"SchwabClientFunctionId": self.streamer_info.schwab_client_function_id,
},
}
def build_quote_subscription(self, symbols: list[str]) -> dict:
request = {
"service": "LEVELONE_EQUITIES",
"command": "SUBS",
"requestid": self._next_request_id(),
"SchwabClientCustomerId": self.streamer_info.schwab_client_customer_id,
"SchwabClientCorrelId": self.streamer_info.schwab_client_correl_id,
"parameters": {
"keys": ",".join(symbols),
"fields": "0,1,2,3,4,5,8",
},
}
self.subscriptions.append(request)
return request
def handle_raw_message(self, raw: str) -> None:
try:
payload = json.loads(raw)
except json.JSONDecodeError:
logger.warning("unparseable streaming message: %.120s", raw)
return
if self.on_message:
self.on_message(payload)