delete: schwab/ (23 files, dormant) + dead strategy modules

Finishes what d54a2a9's message claimed: schwab/ was never actually removed.
Also: comprehensive_backtest.py (crashed on import — research/connection.py
does not exist), strategy/universe.py, strategy/backtest/outcomes.py,
detect_momentum_ignition (kept _last_higher_low — imported by runner/
premarket_setup/liquid_breakout), milestone3_verify.py, tests/unit/schwab/,
SCHWAB_* keys from .env.example. All grep-gated: zero live references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-13 21:02:46 -04:00
1 parent be35385ffc
commit 99ff1dc51f
33 files changed
+11 -1979

No files matched your search

-12
View File
@@ -24,18 +24,6 @@ FINNHUB_EMAIL=fez5587@gmail.com
SEC_API_KEY=<REDACTED>
DATABENTO_API_KEY=<REDACTED>
# Schwab Developer API
SCHWAB_Market_APP_KEY=<REDACTED>
SCHWAB_Market_APP_SECRET=<REDACTED>
SCHWAB_Trading_APP_KEY=<REDACTED>
SCHWAB_Trading_APP_SECRET=<REDACTED>
# OAuth redirect URI must be YOUR registered app callback URI (not token endpoint)
# Routes: https://schwab.flcl.us/callback → nginx → localhost:8001/callback
SCHWAB_REDIRECT_URI=https://schwab.flcl.us/callback
# Authorization endpoint
SCHWAB_AUTHORIZATION_URI=https://api.schwabapi.com/v1/oauth/authorize
# Token endpoint
SCHWAB_TOKEN_URI=<REDACTED>
# Data directory for parquet files and DuckDB
DATA_DIR=./data
-326
View File
@@ -1,326 +0,0 @@
#!/usr/bin/env python3
"""Multi-week comprehensive backtest with detailed statistical analysis."""
from datetime import datetime, timedelta, date
import statistics
import pandas as pd
from rich.console import Console
from rich.table import Table
from config import Config
from research.connection import ResearchConnection
from research.query import query_minute_bars
from strategy.backtest.engine import BacktestEngine
console = Console()
def get_trading_days(start: date, end: date) -> list[date]:
"""Get all weekdays between start and end (trading days only)."""
days = []
current = start
while current <= end:
# Only include weekdays (0=Monday, 6=Sunday)
if current.weekday() < 5:
days.append(current)
current += timedelta(days=1)
return days
def run_backtest_multi_day(start_date: date, end_date: date, symbols: list[str] | None = None):
"""Run backtest across multiple days and aggregate results."""
config = Config()
con = ResearchConnection()
if symbols is None:
symbols = ["SNDL", "MARA", "RIOT", "BBAI", "SOUN", "PLUG"]
console.print(f"\n[bold blue]Comprehensive Backtest: {start_date} to {end_date}[/bold blue]")
console.print(f"[dim]Testing {len(symbols)} symbols across trading days[/dim]\n")
trading_days = get_trading_days(start_date, end_date)
console.print(f"[cyan]{len(trading_days)} trading days in range[/cyan]\n")
# Aggregate results
all_trades: list[dict] = []
daily_results: dict[date, dict] = {}
symbol_stats: dict[str, dict] = {}
engine = BacktestEngine(config)
# Progress bar
processed = 0
for trading_day in trading_days:
# Query minute bars for all symbols that day
day_symbols_with_data = []
day_bars = {}
for symbol in symbols:
try:
bars = query_minute_bars(con, symbol, trading_day)
if len(bars) > 0:
day_symbols_with_data.append(symbol)
day_bars[symbol] = bars
except Exception as e:
pass # No data for this day
if not day_symbols_with_data:
processed += 1
continue
# Initialize daily results if needed
if trading_day not in daily_results:
daily_results[trading_day] = {"trades": [], "pnl": 0.0}
# Run backtest for each symbol with data
for symbol in day_symbols_with_data:
bars = day_bars[symbol]
try:
result = engine.run(bars, symbol)
# Aggregate
daily_results[trading_day]["pnl"] += result.total_pnl
# Initialize symbol stats if needed
if symbol not in symbol_stats:
symbol_stats[symbol] = {"trades": [], "wins": 0, "losses": 0, "pnl": 0.0}
for trade in result.trades:
trade_data = {
"date": trading_day.isoformat(),
"symbol": symbol,
"entry_time": trade.entry_time,
"entry_price": trade.entry_price,
"stop_price": trade.stop_price,
"target_price": trade.target_price,
"quantity": trade.quantity,
"exit_price": trade.exit_price,
"exit_reason": trade.exit_reason,
"pnl": trade.realized_pnl or 0.0,
"r_multiple": trade.r_multiple or 0.0,
}
all_trades.append(trade_data)
daily_results[trading_day]["trades"].append(trade_data)
# Symbol stats
symbol_stats[symbol]["trades"].append(trade_data)
symbol_stats[symbol]["pnl"] += trade.realized_pnl or 0.0
if (trade.realized_pnl or 0) > 0:
symbol_stats[symbol]["wins"] += 1
else:
symbol_stats[symbol]["losses"] += 1
except Exception as e:
console.print(f"[red]Error backtesting {symbol} on {trading_day}: {e}[/red]")
processed += 1
console.print(f"[dim]Processed {processed}/{len(trading_days)} days[/dim]", end="\r")
console.print()
# === SUMMARY STATISTICS ===
if not all_trades:
console.print("[yellow]No trades found in backtest range[/yellow]")
return
# Daily summary
console.print("\n[bold cyan]=== DAILY SUMMARY ===[/bold cyan]")
daily_table = Table(title="P&L by Day")
daily_table.add_column("Date", style="cyan")
daily_table.add_column("Trades", justify="right")
daily_table.add_column("P&L", justify="right")
daily_pnls = []
for day in sorted(daily_results.keys()):
daily_pnl = daily_results[day]["pnl"]
daily_pnls.append(daily_pnl)
color = "green" if daily_pnl > 0 else "red"
daily_table.add_row(
str(day),
str(len(daily_results[day]["trades"])),
f"[{color}]${daily_pnl:,.2f}[/{color}]"
)
console.print(daily_table)
# Symbol summary
console.print("\n[bold cyan]=== SYMBOL SUMMARY ===[/bold cyan]")
symbol_table = Table(title="Results by Symbol")
symbol_table.add_column("Symbol", style="cyan")
symbol_table.add_column("Trades", justify="right")
symbol_table.add_column("Wins", justify="right")
symbol_table.add_column("Win %", justify="right")
symbol_table.add_column("P&L", justify="right")
symbol_table.add_column("Avg R", justify="right")
for symbol in sorted(symbol_stats.keys()):
stats = symbol_stats[symbol]
trades = stats["trades"]
if not trades:
continue
win_pct = (stats["wins"] / len(trades)) * 100 if trades else 0
avg_r = statistics.mean([t["r_multiple"] for t in trades]) if trades else 0
color = "green" if stats["pnl"] > 0 else "red"
symbol_table.add_row(
symbol,
str(len(trades)),
str(stats["wins"]),
f"{win_pct:.1f}%",
f"[{color}]${stats['pnl']:,.2f}[/{color}]",
f"{avg_r:.2f}R"
)
console.print(symbol_table)
# Overall stats
total_trades = len(all_trades)
total_pnl = sum(t["pnl"] for t in all_trades)
wins = len([t for t in all_trades if t["pnl"] > 0])
losses = len([t for t in all_trades if t["pnl"] < 0])
win_rate = (wins / total_trades) * 100 if total_trades else 0
r_multiples = [t["r_multiple"] for t in all_trades]
avg_r = statistics.mean(r_multiples) if r_multiples else 0
# Separate wins and losses for analysis
win_trades = [t for t in all_trades if t["pnl"] > 0]
loss_trades = [t for t in all_trades if t["pnl"] < 0]
avg_win = statistics.mean([t["pnl"] for t in win_trades]) if win_trades else 0
avg_loss = statistics.mean([t["pnl"] for t in loss_trades]) if loss_trades else 0
max_loss = min([t["pnl"] for t in loss_trades]) if loss_trades else 0
profit_factor = (sum([t["pnl"] for t in win_trades]) / abs(sum([t["pnl"] for t in loss_trades]))) if loss_trades and sum([t["pnl"] for t in loss_trades]) < 0 else 0
console.print("\n[bold cyan]=== OVERALL STATISTICS ===[/bold cyan]")
console.print(f"Total Trades: {total_trades}")
console.print(f"Wins: {wins} | Losses: {losses}")
console.print(f"Win Rate: {win_rate:.1f}%")
console.print(f"Total P&L: [{'green' if total_pnl > 0 else 'red'}]${total_pnl:,.2f}[/{'green' if total_pnl > 0 else 'red'}]")
console.print(f"Avg Win: ${avg_win:,.2f} | Avg Loss: ${avg_loss:,.2f}")
console.print(f"Max Loss: ${max_loss:,.2f}")
if profit_factor > 0:
console.print(f"Profit Factor: {profit_factor:.2f}x")
console.print(f"Avg R-Multiple: {avg_r:.2f}R")
# === LOSS ANALYSIS ===
if loss_trades:
console.print("\n[bold yellow]=== LOSS ANALYSIS ===[/bold yellow]")
console.print(f"Total Losing Trades: {len(loss_trades)}")
loss_table = Table(title="Top 10 Losses (for pattern analysis)")
loss_table.add_column("Date", style="cyan")
loss_table.add_column("Symbol", style="cyan")
loss_table.add_column("Entry", justify="right")
loss_table.add_column("Stop", justify="right")
loss_table.add_column("Exit", justify="right")
loss_table.add_column("P&L", justify="right")
loss_table.add_column("Reason")
sorted_losses = sorted(loss_trades, key=lambda t: t["pnl"])
for trade in sorted_losses[:10]:
loss_table.add_row(
trade["date"],
trade["symbol"],
f"${trade['entry_price']:.2f}",
f"${trade['stop_price']:.2f}",
f"${trade['exit_price']:.2f}" if trade["exit_price"] else "-",
f"[red]${trade['pnl']:,.2f}[/red]",
trade["exit_reason"] or "?"
)
console.print(loss_table)
# Loss metrics
stop_hits = len([t for t in loss_trades if "stop" in (t["exit_reason"] or "").lower()])
console.print(f"\n[dim]Losses by reason:[/dim]")
console.print(f" Hit stop: {stop_hits}/{len(loss_trades)}")
# Gap-through analysis
for trade in sorted_losses[:5]:
entry = trade["entry_price"]
stop = trade["stop_price"]
exit_price = trade["exit_price"]
if exit_price and exit_price < stop:
gap_pct = ((stop - exit_price) / stop) * 100
console.print(f" [yellow]{trade['symbol']} {trade['date']}: gap-through {gap_pct:.2f}% ({stop:.2f} → {exit_price:.2f})[/yellow]")
# === WIN ANALYSIS ===
if win_trades:
console.print("\n[bold green]=== WIN ANALYSIS ===[/bold green]")
console.print(f"Total Winning Trades: {len(win_trades)}")
win_table = Table(title="Recent Wins")
win_table.add_column("Date", style="cyan")
win_table.add_column("Symbol", style="cyan")
win_table.add_column("Entry", justify="right")
win_table.add_column("Target", justify="right")
win_table.add_column("Exit", justify="right")
win_table.add_column("P&L", justify="right")
win_table.add_column("R")
sorted_wins = sorted(win_trades, key=lambda t: t["pnl"], reverse=True)
for trade in sorted_wins[:5]:
win_table.add_row(
trade["date"],
trade["symbol"],
f"${trade['entry_price']:.2f}",
f"${trade['target_price']:.2f}",
f"${trade['exit_price']:.2f}" if trade["exit_price"] else "-",
f"[green]${trade['pnl']:,.2f}[/green]",
f"{trade['r_multiple']:.2f}R"
)
console.print(win_table)
# === RISK ASSESSMENT ===
console.print("\n[bold yellow]=== RISK ASSESSMENT ===[/bold yellow]")
if avg_loss != 0:
expectancy = (win_rate / 100 * avg_win) + ((1 - win_rate / 100) * avg_loss)
console.print(f"Expected value per trade: ${expectancy:,.2f}")
if profit_factor > 0:
if profit_factor < 1.5:
console.print("[yellow]⚠️ Profit factor < 1.5 indicates marginal edge or slippage issues[/yellow]")
else:
console.print("[green]✓ Profit factor suggests adequate win/loss ratio[/green]")
# Check for consistent losses
max_win = max([t["pnl"] for t in win_trades]) if win_trades else 0
if max_loss < 0 and abs(max_loss) > max_win * 1.5:
console.print(f"[red]⚠️ Max loss (${max_loss:,.2f}) >> max win (${max_win:,.2f}) — stop is too wide or slippage is severe[/red]")
# Sharpe-ish ratio (simplified)
if len(daily_pnls) > 1:
daily_std = statistics.stdev(daily_pnls) if len(daily_pnls) > 1 else 0
if daily_std > 0:
daily_mean = statistics.mean(daily_pnls)
sharpe_approx = daily_mean / daily_std if daily_std > 0 else 0
console.print(f"Daily Sharpe (approx): {sharpe_approx:.2f}")
console.print("\n[dim]Recommendations:[/dim]")
if win_rate < 40:
console.print("[red]• Win rate < 40% — strategy may lack edge even with profitable trades[/red]")
if profit_factor < 1.3 and profit_factor > 0:
console.print("[yellow]• Profit factor < 1.3 — consider tightening stops or improving entry[/yellow]")
if len(loss_trades) > 0 and max_loss < avg_loss / 2:
console.print("[yellow]• Losses are inconsistent — gap-throughs may be a factor[/yellow]")
console.print()
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Multi-week comprehensive backtest")
parser.add_argument("--start", type=str, default="2026-06-05", help="Start date (YYYY-MM-DD)")
parser.add_argument("--end", type=str, default="2026-06-12", help="End date (YYYY-MM-DD)")
parser.add_argument("--symbols", type=str, default="SNDL,MARA,RIOT,BBAI,SOUN,PLUG", help="Comma-separated symbols")
args = parser.parse_args()
start = datetime.strptime(args.start, "%Y-%m-%d").date()
end = datetime.strptime(args.end, "%Y-%m-%d").date()
symbols = args.symbols.split(",")
run_backtest_multi_day(start, end, symbols)
-100
View File
@@ -1,100 +0,0 @@
"""Milestone 3 verification: Schwab integration health check.
python milestone3_verify.py
Walks every layer of the Schwab integration and prints PASS/WARN/FAIL.
Designed to be useful both with and without credentials: without them it
verifies graceful degradation (fallback account, empty orders,
unauthenticated health) instead of crashing.
"""
from __future__ import annotations
import sys
from dotenv import load_dotenv
def check(label: str, fn):
try:
status, detail = fn()
except Exception as exc: # noqa: BLE001
status, detail = "FAIL", str(exc)
print(f"[{status:^4}] {label}: {detail}")
return status != "FAIL"
def main() -> int:
load_dotenv()
from schwab.auth.lifecycle import TokenLifecycle
from schwab.auth.token_store import TokenStore
from schwab.health.reporter import HealthReporter
from schwab.market.client import SchwabApiError, SchwabMarketClient
from schwab.orders.reader import OrdersReader
from schwab.positions.reader import PositionsReader
from schwab.settings import SchwabSettings
settings = SchwabSettings.from_env()
ok = True
def c_settings():
creds = settings.has_broker_credentials or settings.has_market_data_credentials
return ("PASS" if creds else "WARN",
f"app keys {'present' if creds else 'absent'}; token_path={settings.token_path}")
ok &= check("settings", c_settings)
def c_token():
store = TokenStore(settings.token_path)
bundle = store.load()
if bundle is None:
return "WARN", "no token file — run OAuth flow to authenticate"
return ("PASS" if not bundle.is_expired else "WARN",
f"token loaded, expired={bundle.is_expired}")
ok &= check("token store", c_token)
lifecycle = TokenLifecycle(settings)
def c_lifecycle():
status = lifecycle.status()
if status.get("authenticated"):
return "PASS", f"authenticated (expired={status.get('expired')})"
return "WARN", f"unauthenticated: {status.get('reason')}"
ok &= check("token lifecycle", c_lifecycle)
def c_health():
report = HealthReporter(settings=settings, lifecycle=lifecycle).check()
d = report.to_dict()
return "PASS", f"overall={d.get('status')} checks={len(d.get('checks') or [])}"
ok &= check("health reporter", c_health)
def c_positions():
reader = PositionsReader(settings=settings, lifecycle=lifecycle)
summary = reader.read_account_summary()
if getattr(summary, "is_fallback", False):
return "WARN", f"fallback summary ({summary.account_id}) — authenticate for live data"
return "PASS", f"account {summary.account_id}"
ok &= check("positions reader", c_positions)
def c_orders():
reader = OrdersReader(settings=settings, lifecycle=lifecycle)
orders = reader.read_orders()
return "PASS", f"{len(orders)} orders (empty is expected when unauthenticated)"
ok &= check("orders reader", c_orders)
def c_market():
client = SchwabMarketClient(settings=settings, lifecycle=lifecycle)
try:
quotes = client.get_quotes(["AAPL"])
return "PASS", f"quotes returned for {list(quotes)}"
except SchwabApiError as exc:
if exc.status == 401:
return "WARN", "market client correctly raises 401 when unauthenticated"
raise
ok &= check("market client", c_market)
print("\nmilestone 3:", "VERIFIED (warnings are expected without live Schwab auth)" if ok else "FAILED")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
-5
View File
@@ -1,5 +0,0 @@
"""Schwab broker integration (Milestone 3): auth, market data, account sync."""
from schwab.settings import SchwabSettings
__all__ = ["SchwabSettings"]
-7
View File
@@ -1,7 +0,0 @@
"""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
@@ -1,50 +0,0 @@
"""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
@@ -1,81 +0,0 @@
"""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
@@ -1,71 +0,0 @@
"""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
@@ -1,6 +0,0 @@
"""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
@@ -1,37 +0,0 @@
"""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
@@ -1,96 +0,0 @@
"""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
@@ -1,24 +0,0 @@
"""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
@@ -1,34 +0,0 @@
"""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
@@ -1,83 +0,0 @@
"""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
@@ -1,86 +0,0 @@
"""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
@@ -1,63 +0,0 @@
"""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
@@ -1,6 +0,0 @@
"""Schwab orders (read-only)."""
from schwab.orders.reader import OrdersReader
from schwab.orders.sync import OrderSync
__all__ = ["OrdersReader", "OrderSync"]
-67
View File
@@ -1,67 +0,0 @@
"""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
@@ -1,45 +0,0 @@
"""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
@@ -1,13 +0,0 @@
"""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
@@ -1,33 +0,0 @@
"""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
@@ -1,111 +0,0 @@
"""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
@@ -1,70 +0,0 @@
"""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
@@ -1,41 +0,0 @@
"""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
@@ -1,5 +0,0 @@
"""Schwab streaming (scaffold)."""
from schwab.streaming.client import StreamingClient, StreamerInfo
__all__ = ["StreamingClient", "StreamerInfo"]
-87
View File
@@ -1,87 +0,0 @@
"""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)
+2 -3
View File
@@ -1,6 +1,5 @@
"""Backtest package: deterministic engine and outcome labeling."""
"""Backtest package: deterministic engine."""
from strategy.backtest.engine import BacktestEngine, BacktestResult, Trade
from strategy.backtest.outcomes import label_outcome, OutcomeLabel
__all__ = ["BacktestEngine", "BacktestResult", "Trade", "label_outcome", "OutcomeLabel"]
__all__ = ["BacktestEngine", "BacktestResult", "Trade"]
-67
View File
@@ -1,67 +0,0 @@
"""Outcome labeling for setups (R-multiple based)."""
from __future__ import annotations
from dataclasses import dataclass
import pandas as pd
@dataclass
class OutcomeLabel:
"""Outcome of a setup given subsequent bars."""
setup_id: str
hit_target: bool
hit_stop: bool
r_multiple: float
max_favorable_r: float
max_adverse_r: float
bars_to_resolution: int
label: str # "win" | "loss" | "scratch" | "unresolved"
def label_outcome(
setup_id: str,
entry_price: float,
stop_price: float,
bars_after: pd.DataFrame,
target_r: float = 2.0,
) -> OutcomeLabel:
"""Walk forward through bars to see whether target or stop hit first."""
risk = entry_price - stop_price
if risk <= 0 or bars_after.empty:
return OutcomeLabel(setup_id, False, False, 0.0, 0.0, 0.0, 0, "unresolved")
target_price = entry_price + target_r * risk
max_fav = 0.0
max_adv = 0.0
for i, (_, bar) in enumerate(bars_after.iterrows(), start=1):
high = float(bar["high"])
low = float(bar["low"])
max_fav = max(max_fav, (high - entry_price) / risk)
max_adv = min(max_adv, (low - entry_price) / risk)
stop_hit = low <= stop_price
target_hit = high >= target_price
if stop_hit and target_hit:
# Conservative: assume stop hit first within the bar
return OutcomeLabel(
setup_id, False, True, -1.0, max_fav, max_adv, i, "loss"
)
if stop_hit:
return OutcomeLabel(
setup_id, False, True, -1.0, max_fav, max_adv, i, "loss"
)
if target_hit:
return OutcomeLabel(
setup_id, True, False, target_r, max_fav, max_adv, i, "win"
)
final_r = (float(bars_after["close"].iloc[-1]) - entry_price) / risk
label = "scratch" if abs(final_r) < 0.25 else ("win" if final_r > 0 else "loss")
return OutcomeLabel(
setup_id, False, False, round(final_r, 3), max_fav, max_adv,
len(bars_after), label,
)
+5 -110
View File
@@ -1,46 +1,13 @@
"""Momentum-ignition detector — a SECOND setup the pullback-ORB scorer is blind to.
"""Shared stop-placement helper for the shadow detectors.
A vertical squeeze (PLSM-class: blue-sky, volume explosion, NO pullback) makes
``strategy.evaluation.structure.classify_setup`` return NONE — it requires a
consolidation/pullback phase a true parabola never gives — so the bot blocks it
(PLSM 2026-06-24 was evaluated 1,895x and blocked ~30% every time). This is a
standalone, pure, binary-rule detector. It is deliberately NOT wired into
classify_setup / evaluate_setup (those pullback criteria would reject it, and
STRATEGY_SETUPS would filter it). SHADOW-ONLY: the caller logs the signal and
NEVER trades it until forward data proves runner-vs-trap separation.
Gates (ALL required; computable from intraday bars + a prior all-time high):
1. BLUE-SKY FRESH-HOD — the latest bar prints a NEW session high AND that
high is above the prior all-time high (no overhead resistance, room to run).
2. PRICE VELOCITY — +velocity_min over velocity_window bars, with
>= green_min of the last green_lookback bars green and rising highs.
3. ABSOLUTE VOLUME BURST — last-bar volume >= burst_mult x the prior K-bar
mean, AND cumulative session volume >= abs_vol_floor. ABSOLUTE floors, NOT
RVOL: scan_gappers RVOL zeros out on thin IPOs (avg-daily-vol = 0) and float
is NULL — that blind spot is exactly why PLSM read None mid-ignition.
stop = the last established HIGHER LOW beneath the ignition bar (ride the up-
structure). breakout_level = the fresh-HOD trigger. Catalyst / VWAP / float are
TAGS the caller attaches for grading — never gates here. Thresholds are params
(env knobs at the call site), seeded from one PLSM window; calibrate only after
shadow data accumulates.
Historical note: this module used to host ``detect_momentum_ignition`` (the
PLSM-class vertical-squeeze detector). It never gained a consumer and was
deleted in plan 002 (2026-07-13); only ``_last_higher_low`` survives because
runner.py, premarket_setup.py, and liquid_breakout.py import it.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import pandas as pd
@dataclass
class IgnitionSignal:
is_valid: bool
breakout_level: float | None = None # fresh-HOD trigger (entry reference)
stop_level: float | None = None # last established higher low
reason: str | None = None
signal_values: dict = field(default_factory=dict)
def _last_higher_low(lows: list[float]) -> float:
"""Ride the stop under the last established HIGHER LOW *beneath* the final
@@ -50,75 +17,3 @@ def _last_higher_low(lows: list[float]) -> float:
if lows[i] > lows[i - 1]:
return lows[i]
return min(lows) if lows else 0.0
def detect_momentum_ignition(
bars: pd.DataFrame,
*,
prior_ath: float | None = None,
velocity_min: float = 0.08,
velocity_window: int = 8,
green_min: int = 4,
green_lookback: int = 5,
burst_mult: float = 3.0,
burst_window: int = 5,
abs_vol_floor: float = 100_000.0,
) -> IgnitionSignal:
"""Detect a vertical momentum ignition. Pure; no pullback required."""
need = max(velocity_window + 1, green_lookback, burst_window + 1)
if bars is None or len(bars) < need:
return IgnitionSignal(False, reason=f"need >= {need} bars")
o = bars["open"].astype(float).to_numpy()
h = bars["high"].astype(float).to_numpy()
low_arr = bars["low"].astype(float).to_numpy()
c = bars["close"].astype(float).to_numpy()
v = bars["volume"].astype(float).to_numpy()
last = len(c) - 1
session_high = float(h.max())
cum_vol = float(v.sum())
# gate 1 — blue-sky fresh-HOD
new_hod = bool(h[last] >= session_high) # final bar made the high
blue_sky = prior_ath is not None and session_high > float(prior_ath)
# gate 2 — price velocity / verticality
base = c[last - velocity_window]
velocity_pct = (c[last] / base - 1.0) if base > 0 else 0.0
lookback = range(last - green_lookback + 1, last + 1)
green_n = sum(1 for i in lookback if c[i] > o[i])
hh_n = sum(1 for i in range(last - green_lookback + 1, last + 1) if h[i] > h[i - 1])
velocity_ok = (velocity_pct >= velocity_min and green_n >= green_min
and hh_n >= green_min)
# gate 3 — absolute volume burst (NOT rvol)
prior_mean = float(v[last - burst_window:last].mean()) if burst_window > 0 else 0.0
burst_ratio = (v[last] / prior_mean) if prior_mean > 0 else 0.0
volume_ok = burst_ratio >= burst_mult and cum_vol >= abs_vol_floor
vals = {
"velocity_pct": round(velocity_pct, 4),
"consecutive_green": green_n,
"higher_high_count": hh_n,
"volume_burst_ratio": round(burst_ratio, 2),
"cum_volume": cum_vol,
"session_high": round(session_high, 4),
"prior_ath": float(prior_ath) if prior_ath is not None else None,
"is_blue_sky": bool(blue_sky),
"new_hod": new_hod,
}
if not (new_hod and blue_sky):
return IgnitionSignal(False, reason="not blue-sky fresh-HOD", signal_values=vals)
if not velocity_ok:
return IgnitionSignal(False, reason="velocity gate failed", signal_values=vals)
if not volume_ok:
return IgnitionSignal(False, reason="volume-burst gate failed", signal_values=vals)
return IgnitionSignal(
True,
breakout_level=round(session_high, 4), # the fresh HOD = entry reference
stop_level=round(_last_higher_low(list(low_arr)), 4),
reason="momentum_ignition",
signal_values=vals,
)
-28
View File
@@ -1,28 +0,0 @@
"""Tradeable universe filtering (price band, volume, share class)."""
from __future__ import annotations
from config import UniverseConfig
def symbol_in_universe(
price: float,
avg_volume_20d: float | None = None,
is_etf: bool = False,
is_otc: bool = False,
config: UniverseConfig | None = None,
) -> tuple[bool, str | None]:
"""Check whether a symbol qualifies for the trading universe."""
config = config or UniverseConfig()
if price < config.price_min or price > config.price_max:
return False, f"price {price:.2f} outside [{config.price_min}, {config.price_max}]"
if (
avg_volume_20d is not None
and avg_volume_20d < config.min_avg_volume_20d
):
return False, f"avg volume {avg_volume_20d:,.0f} < {config.min_avg_volume_20d:,}"
if config.exclude_etf and is_etf:
return False, "ETF excluded"
if config.exclude_otc and is_otc:
return False, "OTC excluded"
return True, None
View File
Whitespace-only changes.
@@ -1,135 +0,0 @@
"""Schwab integration tests (Milestone 3): token store, lifecycle, fallbacks."""
import json
import time
import pytest
from schwab.auth.lifecycle import TokenLifecycle
from schwab.auth.token_store import TokenBundle, TokenStore
from schwab.health.models import HealthStatus
from schwab.health.reporter import HealthReporter
from schwab.market.client import SchwabApiError, SchwabMarketClient
from schwab.orders.reader import OrdersReader
from schwab.positions.reader import PositionsReader
from schwab.settings import SchwabSettings
from storage.event_store import EventStore
@pytest.fixture
def settings(tmp_path):
return SchwabSettings(token_path=str(tmp_path / "tokens.json"))
def make_bundle(expires_in=3600, refresh="refresh-tok"):
return TokenBundle(
access_token="access-tok",
refresh_token=refresh,
expires_at=time.time() + expires_in,
token_type="Bearer",
scope="api",
)
def test_token_store_round_trip_and_permissions(settings):
store = TokenStore(settings.token_path)
assert store.load() is None
store.save(make_bundle())
loaded = store.load()
assert loaded is not None
assert loaded.access_token == "access-tok"
assert not loaded.is_expired
import os
# The token file is locked to owner-only (0o600) on POSIX so OAuth tokens
# are never world-readable. Windows does not implement POSIX permission
# bits, so os.chmod can't produce 0o600 there — the round-trip above is the
# meaningful check on that platform.
if os.name != "nt":
mode = os.stat(settings.token_path).st_mode & 0o777
assert mode == 0o600 # tokens must never be world-readable
def test_token_bundle_expiry():
assert make_bundle(expires_in=-10).is_expired
assert not make_bundle(expires_in=600).is_expired
def test_lifecycle_status_unauthenticated(settings):
lifecycle = TokenLifecycle(settings)
status = lifecycle.status()
assert status["authenticated"] is False
assert lifecycle.get_access_token() is None
def test_lifecycle_status_authenticated(settings):
TokenStore(settings.token_path).save(make_bundle())
lifecycle = TokenLifecycle(settings)
status = lifecycle.status()
assert status["authenticated"] is True
assert status["expired"] is False
assert lifecycle.get_access_token() == "access-tok"
def test_positions_reader_falls_back_when_unauthenticated(settings):
reader = PositionsReader(settings=settings, lifecycle=TokenLifecycle(settings))
summary = reader.read_account_summary()
assert summary.is_fallback
assert summary.account_id == "SCHWAB-UNAUTH"
positions = reader.read_positions()
assert positions.is_fallback
assert positions.positions == []
def test_orders_reader_empty_when_unauthenticated(settings):
reader = OrdersReader(settings=settings, lifecycle=TokenLifecycle(settings))
assert reader.read_orders() == []
def test_market_client_raises_401_without_token(settings):
client = SchwabMarketClient(settings=settings, lifecycle=TokenLifecycle(settings))
with pytest.raises(SchwabApiError) as exc_info:
client.get_quotes(["AAPL"])
assert exc_info.value.status == 401
def test_health_reporter_unauthenticated_and_event_emission(settings):
store = EventStore(":memory:")
reporter = HealthReporter(
store=store, settings=settings,
lifecycle=TokenLifecycle(settings), session_id="t",
)
report = reporter.check()
d = report.to_dict()
assert d["status"] in {
HealthStatus.DOWN.value, HealthStatus.UNAUTHENTICATED.value, "down", "unauthenticated",
}
events = store.query_events(event_type="broker_health_changed")
assert events, "first check must emit a broker_health_changed event"
payload = json.loads(events[0]["payload_json"])
assert payload["broker_name"] == "schwab"
assert payload["new_health"]
# second check with no change should not re-emit
reporter.check()
assert len(store.query_events(event_type="broker_health_changed")) == 1
store.close()
def test_health_reporter_recovers_with_token(tmp_path):
settings = SchwabSettings(
token_path=str(tmp_path / "tokens.json"),
broker_app_key="key", broker_app_secret="secret",
)
store = EventStore(":memory:")
lifecycle = TokenLifecycle(settings)
reporter = HealthReporter(
store=store, settings=settings, lifecycle=lifecycle, session_id="t"
)
reporter.check()
TokenStore(settings.token_path).save(make_bundle())
second = reporter.check()
events = store.query_events(event_type="broker_health_changed")
assert len(events) == 2 # status changed -> new event
payload = json.loads(events[-1]["payload_json"])
assert payload["previous_health"] != payload["new_health"]
store.close()
+4 -77
View File
@@ -1,81 +1,8 @@
"""Momentum-ignition detector (P0): binary gates on synthetic vertical squeezes.
"""_last_higher_low: the shared stop-placement helper (live via runner /
premarket_setup / liquid_breakout). The detect_momentum_ignition tests died
with the detector in plan 002."""
Pure + deterministic. A real-PLSM regression fixture is deferred to P2 (when the
nightly scorer runs over real shadow signals)."""
import pandas as pd
from strategy.evaluation.ignition import _last_higher_low, detect_momentum_ignition
COLS = ["open", "high", "low", "close", "volume"]
# a clean vertical ignition: 10 bars, all green, rising, last bar a new HOD on a
# big volume burst. open, high, low, close, volume.
IGNITION = [
(4.00, 4.20, 3.90, 4.10, 5000),
(4.10, 4.40, 4.05, 4.35, 6000),
(4.35, 4.70, 4.30, 4.65, 7000),
(4.65, 5.00, 4.60, 4.95, 8000),
(4.95, 5.40, 4.90, 5.35, 9000),
(5.35, 5.90, 5.30, 5.85, 10000),
(5.85, 6.50, 5.80, 6.45, 12000),
(6.45, 7.20, 6.40, 7.10, 14000),
(7.10, 8.00, 7.05, 7.90, 16000),
(7.90, 9.20, 7.85, 9.10, 60000), # new HOD 9.20, volume burst 60k vs ~12k mean
]
def _df(rows):
return pd.DataFrame(rows, columns=COLS)
def test_ignition_fires_with_correct_trigger_and_stop():
r = detect_momentum_ignition(_df(IGNITION), prior_ath=5.0)
assert r.is_valid and r.reason == "momentum_ignition"
assert r.breakout_level == 9.20 # the fresh HOD = entry reference
assert r.stop_level == 7.05 # last higher low (bar 8 low > bar 7 low)
sv = r.signal_values
assert sv["is_blue_sky"] and sv["new_hod"]
assert sv["velocity_pct"] > 0.08
assert sv["volume_burst_ratio"] >= 3.0
assert sv["cum_volume"] >= 100_000
def test_not_blue_sky_blocks():
# prior ATH above the session high -> not blue sky, even though it's a new HOD
r = detect_momentum_ignition(_df(IGNITION), prior_ath=100.0)
assert not r.is_valid and r.reason == "not blue-sky fresh-HOD"
def test_not_new_hod_blocks():
# final bar fades below the session high (bar 8 = 8.00) -> no fresh HOD
fade = IGNITION[:-1] + [(7.90, 7.95, 7.40, 7.50, 60000)]
r = detect_momentum_ignition(_df(fade), prior_ath=5.0)
assert not r.is_valid and r.reason == "not blue-sky fresh-HOD"
assert r.signal_values["new_hod"] is False
def test_velocity_gate_blocks():
# blue-sky + volume pass, but require an impossibly steep move -> velocity fails
r = detect_momentum_ignition(_df(IGNITION), prior_ath=5.0, velocity_min=2.0)
assert not r.is_valid and r.reason == "velocity gate failed"
def test_volume_burst_gate_blocks():
# blue-sky + velocity pass, but require an impossible burst -> volume fails
r = detect_momentum_ignition(_df(IGNITION), prior_ath=5.0, burst_mult=100.0)
assert not r.is_valid and r.reason == "volume-burst gate failed"
def test_insufficient_bars():
r = detect_momentum_ignition(_df(IGNITION[:4]), prior_ath=5.0)
assert not r.is_valid and "need" in (r.reason or "")
def test_missing_prior_ath_is_not_blue_sky():
# no prior ATH -> can't confirm blue sky -> blocked (fail-safe, never fires blind)
r = detect_momentum_ignition(_df(IGNITION), prior_ath=None)
assert not r.is_valid and r.reason == "not blue-sky fresh-HOD"
from strategy.evaluation.ignition import _last_higher_low
def test_last_higher_low_helper():