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:
commit
007cd95c79
133 files changed
+17321
No files matched your search
@@ -0,0 +1,5 @@
|
||||
"""Schwab broker integration (Milestone 3): auth, market data, account sync."""
|
||||
|
||||
from schwab.settings import SchwabSettings
|
||||
|
||||
__all__ = ["SchwabSettings"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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))
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Schwab orders (read-only)."""
|
||||
|
||||
from schwab.orders.reader import OrdersReader
|
||||
from schwab.orders.sync import OrderSync
|
||||
|
||||
__all__ = ["OrdersReader", "OrderSync"]
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Schwab streaming (scaffold)."""
|
||||
|
||||
from schwab.streaming.client import StreamingClient, StreamerInfo
|
||||
|
||||
__all__ = ["StreamingClient", "StreamerInfo"]
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user