Add Schwab settings, credential placeholders and an entitlement probe

config.py had no Schwab fields at all, so the keys listed in .env.example were
being silently dropped by extra="ignore". They exist now, blank, and nothing
reads them while live_source is yahoo.

The token path moves under data/, which is the Coolify persistent volume. Left
at the repository root it would vanish on every rebuild, and re-authenticating
is an interactive browser flow, not something a deploy can do for itself.

scripts/check_schwab.py answers empirically what the app is entitled to rather
than inferring it from documentation: whether the credentials authenticate,
whether /ES quotes return (futures market data is a separate entitlement from
equities), and whether the streamer bootstrap responds.

That last one is the decision. StreamClient.login() reads
/trader/v1/userPreference for its socket URL and credentials, and that path
belongs to the Accounts and Trading product — so an app registered for Market
Data Production alone cannot stream, and CHART_FUTURES is unreachable until the
app adds it. The script reports which of the two paths is open instead of
leaving it to be discovered halfway through an implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 04:48:29 -05:00
parent 0bafe9de01
commit 5ccb2bfb52
4 changed files with 121 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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

96
scripts/check_schwab.py Normal file
View file

@ -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())