Mercurial
diff schwab_trader/schwab_cli.py @ 220:eb8b4230fdb9
[schwab-trader] Add guarded trading experiment
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 08:52:13 -0700 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schwab_trader/schwab_cli.py Sun Aug 02 08:52:13 2026 -0700 @@ -0,0 +1,171 @@ +from __future__ import annotations + +import argparse +import json +import sys + +from schwab_trader.schwab_client import ( + SchwabConfig, + SchwabError, + build_authorization_url, + build_equity_order, + exchange_code_for_tokens, + get_account, + get_account_numbers, + get_accounts, + load_tokens, + place_order, + refresh_tokens, + save_tokens, +) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Safe Schwab Trader API helper. This tool never chooses trades for you.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + auth_url = subparsers.add_parser("auth-url", help="Print the Schwab OAuth authorization URL") + auth_url.add_argument("--state", help="Optional OAuth state value") + + token = subparsers.add_parser("token", help="Exchange an authorization code/callback URL for tokens") + token.add_argument("--code", required=True, help="Authorization code or full callback URL") + + subparsers.add_parser("refresh", help="Refresh and save tokens") + + subparsers.add_parser("account-numbers", help="Print Schwab account-number/account-hash mapping") + + accounts = subparsers.add_parser("accounts", help="Print account data") + accounts.add_argument("--positions", action="store_true", help="Include positions if API permissions allow it") + + account = subparsers.add_parser("account", help="Print one account by account hash") + account.add_argument("--account-hash", required=True) + account.add_argument("--positions", action="store_true", help="Include positions if API permissions allow it") + + build_order = subparsers.add_parser("build-equity-order", help="Build and print a stock order JSON payload") + _add_order_args(build_order) + + place_equity = subparsers.add_parser( + "place-equity-order", + help="Place a user-specified stock order; defaults to dry-run output only", + ) + _add_order_args(place_equity) + place_equity.add_argument("--account-hash", required=True) + place_equity.add_argument("--live", action="store_true", help="Actually submit the order to Schwab") + place_equity.add_argument( + "--confirm-live-trade", + action="store_true", + help="Required with --live to reduce accidental orders", + ) + + args = parser.parse_args(argv) + + try: + if args.command == "auth-url": + config = SchwabConfig.from_env() + print(build_authorization_url(config.app_key, config.redirect_uri, args.state)) + return 0 + + if args.command == "token": + config = SchwabConfig.from_env() + tokens = exchange_code_for_tokens(config, args.code) + save_tokens(config.token_file, tokens) + print(f"Saved tokens to {config.token_file}") + return 0 + + if args.command == "refresh": + config = SchwabConfig.from_env() + tokens = refresh_tokens(config) + save_tokens(config.token_file, tokens) + print(f"Refreshed tokens in {config.token_file}") + return 0 + + if args.command == "account-numbers": + config = SchwabConfig.from_env() + access_token = _load_access_token(config) + _print_json(get_account_numbers(access_token).body) + return 0 + + if args.command == "accounts": + config = SchwabConfig.from_env() + access_token = _load_access_token(config) + fields = "positions" if args.positions else None + _print_json(get_accounts(access_token, fields).body) + return 0 + + if args.command == "account": + config = SchwabConfig.from_env() + access_token = _load_access_token(config) + fields = "positions" if args.positions else None + _print_json(get_account(access_token, args.account_hash, fields).body) + return 0 + + if args.command == "build-equity-order": + order = _build_order_from_args(args) + _print_json(order) + return 0 + + if args.command == "place-equity-order": + order = _build_order_from_args(args) + if not args.live: + print("DRY RUN: order was not sent. Add --live --confirm-live-trade to submit.") + _print_json(order) + return 0 + if not args.confirm_live_trade: + raise SchwabError("--live requires --confirm-live-trade") + + config = SchwabConfig.from_env() + access_token = _load_access_token(config) + response = place_order(access_token, args.account_hash, order) + print(f"Schwab order response status: {response.status}") + location = response.headers.get("Location") + if location: + print(f"Order location: {location}") + if response.body is not None: + _print_json(response.body) + return 0 + + raise SchwabError(f"Unknown command: {args.command}") + except SchwabError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + +def _add_order_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--action", required=True, choices=["BUY", "SELL", "buy", "sell"]) + parser.add_argument("--symbol", required=True) + parser.add_argument("--quantity", required=True, type=float) + parser.add_argument("--order-type", default="MARKET", choices=["MARKET", "LIMIT", "market", "limit"]) + parser.add_argument("--price", type=float, help="Required for LIMIT orders; invalid for MARKET orders") + parser.add_argument("--duration", default="DAY") + parser.add_argument("--session", default="NORMAL") + + +def _build_order_from_args(args: argparse.Namespace) -> dict: + return build_equity_order( + action=args.action, + symbol=args.symbol, + quantity=args.quantity, + order_type=args.order_type, + price=args.price, + duration=args.duration, + session=args.session, + ) + + +def _load_access_token(config: SchwabConfig) -> str: + tokens = load_tokens(config.token_file) + access_token = tokens.get("access_token") + if not access_token: + raise SchwabError(f"No access_token in {config.token_file}") + return access_token + + +def _print_json(value: object) -> None: + print(json.dumps(value, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + raise SystemExit(main()) +