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