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