From 99ff1dc51f09f4825cc6e1ca92c95978089b559c Mon Sep 17 00:00:00 2001 From: momentum dev Date: Mon, 13 Jul 2026 21:02:46 -0400 Subject: [PATCH] delete: schwab/ (23 files, dormant) + dead strategy modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 12 - comprehensive_backtest.py | 326 ------------------- milestone3_verify.py | 100 ------ schwab/__init__.py | 5 - schwab/auth/__init__.py | 7 - schwab/auth/lifecycle.py | 50 --- schwab/auth/oauth2_flow_manager.py | 81 ----- schwab/auth/token_store.py | 71 ---- schwab/health/__init__.py | 6 - schwab/health/models.py | 37 --- schwab/health/reporter.py | 96 ------ schwab/market/__init__.py | 24 -- schwab/market/adapter.py | 34 -- schwab/market/client.py | 83 ----- schwab/market/models.py | 86 ----- schwab/market/option_chain_service.py | 63 ---- schwab/orders/__init__.py | 6 - schwab/orders/reader.py | 67 ---- schwab/orders/sync.py | 45 --- schwab/positions/__init__.py | 13 - schwab/positions/models.py | 33 -- schwab/positions/reader.py | 111 ------- schwab/positions/sync.py | 70 ---- schwab/settings.py | 41 --- schwab/streaming/__init__.py | 5 - schwab/streaming/client.py | 87 ----- strategy/backtest/__init__.py | 5 +- strategy/backtest/outcomes.py | 67 ---- strategy/evaluation/ignition.py | 115 +------ strategy/universe.py | 28 -- tests/unit/schwab/__init__.py | 0 tests/unit/schwab/test_schwab_integration.py | 135 -------- tests/unit/test_ignition.py | 81 +---- 33 files changed, 11 insertions(+), 1979 deletions(-) delete mode 100644 comprehensive_backtest.py delete mode 100644 milestone3_verify.py delete mode 100644 schwab/__init__.py delete mode 100644 schwab/auth/__init__.py delete mode 100644 schwab/auth/lifecycle.py delete mode 100644 schwab/auth/oauth2_flow_manager.py delete mode 100644 schwab/auth/token_store.py delete mode 100644 schwab/health/__init__.py delete mode 100644 schwab/health/models.py delete mode 100644 schwab/health/reporter.py delete mode 100644 schwab/market/__init__.py delete mode 100644 schwab/market/adapter.py delete mode 100644 schwab/market/client.py delete mode 100644 schwab/market/models.py delete mode 100644 schwab/market/option_chain_service.py delete mode 100644 schwab/orders/__init__.py delete mode 100644 schwab/orders/reader.py delete mode 100644 schwab/orders/sync.py delete mode 100644 schwab/positions/__init__.py delete mode 100644 schwab/positions/models.py delete mode 100644 schwab/positions/reader.py delete mode 100644 schwab/positions/sync.py delete mode 100644 schwab/settings.py delete mode 100644 schwab/streaming/__init__.py delete mode 100644 schwab/streaming/client.py delete mode 100644 strategy/backtest/outcomes.py delete mode 100644 strategy/universe.py delete mode 100644 tests/unit/schwab/__init__.py delete mode 100644 tests/unit/schwab/test_schwab_integration.py diff --git a/.env.example b/.env.example index e944d12..077dbf8 100644 --- a/.env.example +++ b/.env.example @@ -24,18 +24,6 @@ FINNHUB_EMAIL=fez5587@gmail.com SEC_API_KEY= DATABENTO_API_KEY= -# Schwab Developer API -SCHWAB_Market_APP_KEY= -SCHWAB_Market_APP_SECRET= -SCHWAB_Trading_APP_KEY= -SCHWAB_Trading_APP_SECRET= -# 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= # Data directory for parquet files and DuckDB DATA_DIR=./data diff --git a/comprehensive_backtest.py b/comprehensive_backtest.py deleted file mode 100644 index be109b0..0000000 --- a/comprehensive_backtest.py +++ /dev/null @@ -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) diff --git a/milestone3_verify.py b/milestone3_verify.py deleted file mode 100644 index 6c19a28..0000000 --- a/milestone3_verify.py +++ /dev/null @@ -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()) diff --git a/schwab/__init__.py b/schwab/__init__.py deleted file mode 100644 index 8186d05..0000000 --- a/schwab/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Schwab broker integration (Milestone 3): auth, market data, account sync.""" - -from schwab.settings import SchwabSettings - -__all__ = ["SchwabSettings"] diff --git a/schwab/auth/__init__.py b/schwab/auth/__init__.py deleted file mode 100644 index c6ebf6c..0000000 --- a/schwab/auth/__init__.py +++ /dev/null @@ -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"] diff --git a/schwab/auth/lifecycle.py b/schwab/auth/lifecycle.py deleted file mode 100644 index ff6f71d..0000000 --- a/schwab/auth/lifecycle.py +++ /dev/null @@ -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 diff --git a/schwab/auth/oauth2_flow_manager.py b/schwab/auth/oauth2_flow_manager.py deleted file mode 100644 index 456a7dc..0000000 --- a/schwab/auth/oauth2_flow_manager.py +++ /dev/null @@ -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} - ) diff --git a/schwab/auth/token_store.py b/schwab/auth/token_store.py deleted file mode 100644 index 28c40de..0000000 --- a/schwab/auth/token_store.py +++ /dev/null @@ -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() diff --git a/schwab/health/__init__.py b/schwab/health/__init__.py deleted file mode 100644 index 9aeb0a7..0000000 --- a/schwab/health/__init__.py +++ /dev/null @@ -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"] diff --git a/schwab/health/models.py b/schwab/health/models.py deleted file mode 100644 index 9607fe4..0000000 --- a/schwab/health/models.py +++ /dev/null @@ -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 - ], - } diff --git a/schwab/health/reporter.py b/schwab/health/reporter.py deleted file mode 100644 index c4c1e68..0000000 --- a/schwab/health/reporter.py +++ /dev/null @@ -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 diff --git a/schwab/market/__init__.py b/schwab/market/__init__.py deleted file mode 100644 index ce33f84..0000000 --- a/schwab/market/__init__.py +++ /dev/null @@ -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", -] diff --git a/schwab/market/adapter.py b/schwab/market/adapter.py deleted file mode 100644 index f4b2f43..0000000 --- a/schwab/market/adapter.py +++ /dev/null @@ -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)) diff --git a/schwab/market/client.py b/schwab/market/client.py deleted file mode 100644 index 1767c1b..0000000 --- a/schwab/market/client.py +++ /dev/null @@ -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}, - ) diff --git a/schwab/market/models.py b/schwab/market/models.py deleted file mode 100644 index 4631e22..0000000 --- a/schwab/market/models.py +++ /dev/null @@ -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 diff --git a/schwab/market/option_chain_service.py b/schwab/market/option_chain_service.py deleted file mode 100644 index 2253bc0..0000000 --- a/schwab/market/option_chain_service.py +++ /dev/null @@ -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 diff --git a/schwab/orders/__init__.py b/schwab/orders/__init__.py deleted file mode 100644 index 1b0149d..0000000 --- a/schwab/orders/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Schwab orders (read-only).""" - -from schwab.orders.reader import OrdersReader -from schwab.orders.sync import OrderSync - -__all__ = ["OrdersReader", "OrderSync"] diff --git a/schwab/orders/reader.py b/schwab/orders/reader.py deleted file mode 100644 index a233217..0000000 --- a/schwab/orders/reader.py +++ /dev/null @@ -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 - ] diff --git a/schwab/orders/sync.py b/schwab/orders/sync.py deleted file mode 100644 index c7ce496..0000000 --- a/schwab/orders/sync.py +++ /dev/null @@ -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 diff --git a/schwab/positions/__init__.py b/schwab/positions/__init__.py deleted file mode 100644 index 8a22a04..0000000 --- a/schwab/positions/__init__.py +++ /dev/null @@ -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", -] diff --git a/schwab/positions/models.py b/schwab/positions/models.py deleted file mode 100644 index 26d6570..0000000 --- a/schwab/positions/models.py +++ /dev/null @@ -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 diff --git a/schwab/positions/reader.py b/schwab/positions/reader.py deleted file mode 100644 index acd93b7..0000000 --- a/schwab/positions/reader.py +++ /dev/null @@ -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, - ) diff --git a/schwab/positions/sync.py b/schwab/positions/sync.py deleted file mode 100644 index 75fa6e9..0000000 --- a/schwab/positions/sync.py +++ /dev/null @@ -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()} diff --git a/schwab/settings.py b/schwab/settings.py deleted file mode 100644 index ac2e250..0000000 --- a/schwab/settings.py +++ /dev/null @@ -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) diff --git a/schwab/streaming/__init__.py b/schwab/streaming/__init__.py deleted file mode 100644 index 815ccd7..0000000 --- a/schwab/streaming/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Schwab streaming (scaffold).""" - -from schwab.streaming.client import StreamingClient, StreamerInfo - -__all__ = ["StreamingClient", "StreamerInfo"] diff --git a/schwab/streaming/client.py b/schwab/streaming/client.py deleted file mode 100644 index f6e2cd1..0000000 --- a/schwab/streaming/client.py +++ /dev/null @@ -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) diff --git a/strategy/backtest/__init__.py b/strategy/backtest/__init__.py index bfb019c..88069a4 100644 --- a/strategy/backtest/__init__.py +++ b/strategy/backtest/__init__.py @@ -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"] diff --git a/strategy/backtest/outcomes.py b/strategy/backtest/outcomes.py deleted file mode 100644 index 39ed814..0000000 --- a/strategy/backtest/outcomes.py +++ /dev/null @@ -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, - ) diff --git a/strategy/evaluation/ignition.py b/strategy/evaluation/ignition.py index a04a0bf..657e3c1 100644 --- a/strategy/evaluation/ignition.py +++ b/strategy/evaluation/ignition.py @@ -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, - ) diff --git a/strategy/universe.py b/strategy/universe.py deleted file mode 100644 index 7466e6d..0000000 --- a/strategy/universe.py +++ /dev/null @@ -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 diff --git a/tests/unit/schwab/__init__.py b/tests/unit/schwab/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/unit/schwab/test_schwab_integration.py b/tests/unit/schwab/test_schwab_integration.py deleted file mode 100644 index 08a3f46..0000000 --- a/tests/unit/schwab/test_schwab_integration.py +++ /dev/null @@ -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() diff --git a/tests/unit/test_ignition.py b/tests/unit/test_ignition.py index 6a7f0d5..0d149c4 100644 --- a/tests/unit/test_ignition.py +++ b/tests/unit/test_ignition.py @@ -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():