diff --git a/.env.example b/.env.example index fe44a05..e4d2232 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,17 @@ YAHOO_POLL_SECONDS=20 SEED_1H_RANGE=730d SEED_1M_RANGE=8d +# Schwab. Only read once LIVE_SOURCE=schwab; blank is fine until then. +# The callback must match the app registration exactly — changing it there can +# re-trigger approval. The same URL works from local and production: the +# redirect lands in your browser, not on the machine running the code. +SCHWAB_API_KEY= +SCHWAB_APP_SECRET= +SCHWAB_CALLBACK_URL=https://chart.amow.com/api/qt +# Under data/ so it survives a deploy — that path is the Coolify volume. +SCHWAB_TOKEN_PATH=./data/.schwab_token.json +SCHWAB_SYMBOL=/ES + # Chart and analysis TIMEFRAMES=1m,5m,15m,30m,1h,1d BASE_TIMEFRAMES=1m,30m,1d diff --git a/app/config.py b/app/config.py index d4b94bb..5fb248a 100644 --- a/app/config.py +++ b/app/config.py @@ -32,6 +32,16 @@ class Settings(BaseSettings): ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200" daily_anchor_et: str = "18:00" manual_lines_path: Path = Path("./data/manual_lines.json") + + # Schwab. Empty until the app's keys are issued; nothing reads them while + # live_source is yahoo. The token lives under data/ so it lands on the + # Coolify persistent volume — a rebuild would otherwise log you out, and + # re-authenticating is an interactive browser flow. + schwab_api_key: str = "" + schwab_app_secret: str = "" + schwab_callback_url: str = "https://chart.amow.com/api/qt" + schwab_token_path: Path = Path("./data/.schwab_token.json") + schwab_symbol: str = "/ES" confluence_min_score: float = 28 # Four hours, chosen from the sweep in scripts/calibrate_alerts.py. Suppression is # per price zone, so an unrelated zone still alerts immediately; this only diff --git a/requirements-dev.txt b/requirements-dev.txt index ee4ba01..df026fe 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,6 @@ pytest pytest-asyncio + +# Only used by scripts/check_schwab.py until the Schwab source lands; +# the app itself never imports it while LIVE_SOURCE=yahoo. +schwab-py diff --git a/scripts/check_schwab.py b/scripts/check_schwab.py new file mode 100644 index 0000000..eef71d9 --- /dev/null +++ b/scripts/check_schwab.py @@ -0,0 +1,96 @@ +"""Find out what a Schwab app is actually entitled to, before building on it. + +Run once after putting SCHWAB_API_KEY and SCHWAB_APP_SECRET in .env. It answers +the three questions that decide the design, and it answers them empirically +rather than from documentation: + + 1. Do the credentials authenticate at all? + 2. Do REST quotes work for /ES — futures market data is a separate + entitlement from equities and may not be granted. + 3. Does the streamer connect? Its bootstrap reads /trader/v1/userPreference, + which belongs to the Accounts and Trading product, so an app registered + for Market Data Production alone is expected to fail here. + +Uses the manual OAuth flow because the callback is hosted rather than local: +visit the printed URL, complete the login, then paste the URL you land on. The +callback page shows it ready to copy. + + python3 -m scripts.check_schwab +""" +import asyncio + +from app.config import Settings + + +def heading(text: str) -> None: + print(f"\n{text}\n{'-' * len(text)}") + + +async def main() -> None: + settings = Settings() + if not settings.schwab_api_key or not settings.schwab_app_secret: + raise SystemExit("Set SCHWAB_API_KEY and SCHWAB_APP_SECRET in .env first") + + try: + from schwab.auth import client_from_manual_flow, client_from_token_file + except ImportError: + raise SystemExit("pip install -r requirements-dev.txt (schwab-py is not installed)") + + heading("1. Authentication") + token_path = str(settings.schwab_token_path) + try: + client = client_from_token_file( + token_path, settings.schwab_api_key, settings.schwab_app_secret + ) + print(f" reused the token at {token_path}") + except Exception: + print(" no usable token — starting the manual flow") + client = client_from_manual_flow( + settings.schwab_api_key, + settings.schwab_app_secret, + settings.schwab_callback_url, + token_path, + ) + print(" authenticated") + + heading(f"2. REST quote for {settings.schwab_symbol}") + response = client.get_quote(settings.schwab_symbol) + print(f" HTTP {response.status_code}") + if response.status_code == 200: + payload = response.json() + print(f" keys: {list(payload)[:4]}") + for symbol, data in list(payload.items())[:1]: + quote = data.get("quote", {}) + print(f" {symbol}: last={quote.get('lastPrice')} " + f"bid={quote.get('bidPrice')} ask={quote.get('askPrice')}") + print(" -> futures market data IS available") + else: + print(f" body: {response.text[:200]}") + print(" -> futures market data is NOT available on this app") + + heading("3. Streamer bootstrap (/trader/v1/userPreference)") + prefs = client.get_user_preferences() + print(f" HTTP {prefs.status_code}") + if prefs.status_code == 200: + info = prefs.json().get("streamerInfo") or [] + print(f" streamerInfo entries: {len(info)}") + print(" -> streaming is available; CHART_FUTURES should work") + else: + print(f" body: {prefs.text[:200]}") + print(" -> streaming is NOT available. This endpoint belongs to the") + print(" Accounts and Trading product; a Market Data Production app") + print(" cannot reach it, so CHART_FUTURES is out of reach until the") + print(" app adds that product.") + + heading("Summary") + print(" Quotes available :", response.status_code == 200) + print(" Streaming available:", prefs.status_code == 200) + if response.status_code == 200 and prefs.status_code != 200: + print("\n Polling REST quotes is then the real-time path: it removes") + print(" Yahoo's ten-minute delay without needing trading scope, at the") + print(" cost of building bars from snapshots rather than receiving") + print(" true exchange OHLCV.") + + +if __name__ == "__main__": + asyncio.run(main())