Skill: brendanwood/snaptrade (version 1.0.0)

--- name: snaptrade-portfolio description: Connect to a user's investment accounts via SnapTrade SDK and generate portfolio reports (e.g., daily total value). Use when the user wants SnapTrade-based brokerage connectivity (Webull, E*TRADE, etc.), connection portal links, account registration, or automated portfolio summaries. --- # SnapTrade Portfolio ## Overview Connect brokerage accounts through SnapTrade and generate a daily total-value report. This skill uses the official SnapTrade Python SDK (`snaptrade_client`) with request signing handled automatically. ## Workflow ### 2d) Place buy/sell orders (stocks/ETFs) Run: ``` python3 scripts/snaptrade_order.py buy|sell TICKER UNITS --account-id <ACCOUNT_ID> [--order-type market|limit] [--limit-price <PRICE>] [--tif Day|GTC|IOC|FOK] ``` Defaults: - order type: **market** - time in force: **Day** - limit price: only required for limit orders Optional monitoring (poll recent orders): ``` python3 scripts/snaptrade_order.py buy|sell TICKER UNITS --account-id <ACCOUNT_ID> --watch ``` - polls every **10s** for up to **120s** by default - adjust with `--watch-interval` and `--watch-seconds` Watch an existing order by ID: ``` python3 scripts/snaptrade_watch_order.py --account-id <ACCOUNT_ID> --order-id <BROKERAGE_ORDER_ID> ``` - same 10s/120s defaults, adjustable with `--watch-interval` and `--watch-seconds` Use **account orders endpoint** to confirm fills and report open orders to the user when asked. ### 0) Create a SnapTrade account + API keys You need a SnapTrade account to connect brokerages. Create a free account at **https://snaptrade.com**, generate your **Client ID** and **Consumer Key**, then add them to the skill config. ### 1) Install dependencies ``` pip3 install -r requirements.txt ``` ### 2) Configure credentials (one-time) Store credentials in a local config file (not committed). `user_id` is generated automatically on first run: ``` /home/openclaw/.openclaw/workspace/secrets/snaptrade.json ``` Example: ``` { "client_id": "YOUR_CLIENT_ID", "consumer_key": "YOUR_CONSUMER_KEY", "user_id": "<auto-generated-uuid>" } ``` ### 2) Register user + generate connection portal link Run: ``` python3 scripts/snaptrade_portal.py ``` This will: - register the user if `user_secret` is missing - store `user_secret` back into the config file - print a **connection portal URL** the user must open to link accounts ### 2b) Reconnect a disabled connection Run: ``` python3 scripts/snaptrade_reconnect.py [brokerage-name] ``` This will: - list disabled connections - generate a reconnect link (optionally match by brokerage name) - print a **reconnect URL** for the user to re‑enable the connection ### 2c) List available brokerages (allowed connections) Run: ``` python3 scripts/snaptrade_brokers.py ``` This calls `/snaptrade/partners` and returns the `allowed_brokerages` list (display names). ### 3) Pull total portfolio value Run: ``` python3 scripts/snaptrade_total.py ``` Output is JSON like: ``` {"total_value": 123456.78, "currency": "CAD"} ``` **Implementation notes:** The script first lists brokerage authorizations to detect **disabled connections**. It skips refresh and sync-wait for disabled ones (but still reports their last-known balances), then waits for updated sync timestamps on enabled connections. It then enumerates all current **non-closed** accounts via `list_user_accounts` and uses `get_user_holdings` to read each account’s `account.balance.total`. Disabled connections are included in the output under `disabled_connections` for reporting. ### 4) Schedule daily report Use cron to call `snaptrade_total.py`, format a concise WhatsApp message, then send it to the user. Only the total value is required. ## Scripts - `scripts/snaptrade_common.py` — config load/save + client creation - `scripts/snaptrade_portal.py` — register user + generate connection portal link - `scripts/snaptrade_reconnect.py` — generate reconnect link for disabled connections - `scripts/snaptrade_brokers.py` — list allowed brokerages for this client - `scripts/snaptrade_order.py` — place buy/sell orders (market/limit) with optional monitoring - `scripts/snaptrade_watch_order.py` — watch an existing order by ID for fills - `scripts/snaptrade_total.py` — compute total value across all accounts ## Notes - Request signing is handled by the SDK via `request_after_hook` using `consumer_key`. - The total is computed by summing each account’s `account.balance.total` from holdings; this avoids errors when accounts are added or removed. - If multiple currencies are present, the script uses the first currency encountered. - Keep secrets in the local config file with `chmod 600` permissions.

path: requirements.txt
content: snaptrade-python-sdk==11.0.159


path: scripts/snaptrade_portal.py
content: #!/usr/bin/env python3
import json
import os
import sys
from typing import Any, Dict

from snaptrade_common import load_config, save_config, get_client


def pick_user_id(cfg: Dict[str, Any]) -> str:
    user_id = cfg.get("user_id")
    if user_id:
        return user_id
    # generate a random user_id on first run
    import uuid
    user_id = str(uuid.uuid4())
    cfg["user_id"] = user_id
    return user_id


def ensure_user(cfg: Dict[str, Any]) -> Dict[str, Any]:
    client = get_client(cfg)
    user_id = pick_user_id(cfg)
    if cfg.get("user_secret"):
        return cfg
    # register user
    resp = client.authentication.register_snap_trade_user(user_id=user_id)
    body = getattr(resp, "body", resp)
    # body may be dict-like
    user_secret = None
    if isinstance(body, dict):
        user_secret = body.get("userSecret") or body.get("user_secret")
    else:
        # try attribute access
        user_secret = getattr(body, "user_secret", None) or getattr(body, "userSecret", None)
    if not user_secret:
        raise RuntimeError("Failed to retrieve user_secret from SnapTrade response")
    cfg["user_secret"] = user_secret
    save_config(cfg)
    return cfg


def create_portal_link(cfg: Dict[str, Any]) -> str:
    client = get_client(cfg)
    user_id = cfg["user_id"]
    user_secret = cfg["user_secret"]
    resp = client.authentication.login_snap_trade_user(
        user_id=user_id,
        user_secret=user_secret,
        connection_type="read",
        connection_portal_version="v4",
        immediate_redirect=False,
        show_close_button=True,
    )
    body = getattr(resp, "body", resp)
    if isinstance(body, dict):
        url = body.get("redirectURI") or body.get("redirect_uri")
    else:
        url = getattr(body, "redirectURI", None) or getattr(body, "redirect_uri", None)
    if not url:
        raise RuntimeError("Failed to retrieve connection portal URL from response")
    return url


def main():
    cfg = load_config()
    cfg = ensure_user(cfg)
    url = create_portal_link(cfg)
    print(url)


if __name__ == "__main__":
    main()


path: scripts/snaptrade_brokers.py
content: #!/usr/bin/env python3
import json
from snaptrade_common import load_config
from snaptrade_client.api_client import ApiClient
from snaptrade_client.configuration import Configuration
from snaptrade_client.apis.paths.snap_trade_partners import SnapTradePartners


def main():
    cfg = load_config()
    config = Configuration(client_id=cfg.get("client_id"), consumer_key=cfg.get("consumer_key"))
    api_client = ApiClient(config)
    partners = SnapTradePartners(api_client)
    resp = partners.get()
    body = getattr(resp, "body", resp)
    allowed = body.get("allowed_brokerages") if isinstance(body, dict) else None
    if not allowed:
        print("[]")
        return
    # Return a simple list of broker display names
    names = []
    for b in allowed:
        name = b.get("display_name") or b.get("name")
        if name:
            names.append(name)
    print(json.dumps(sorted(names), indent=2))


if __name__ == "__main__":
    main()


path: scripts/snaptrade_total.py
content: #!/usr/bin/env python3
import math
from typing import Any, Dict, List, Tuple

from snaptrade_common import load_config, get_client


def extract_amount(balance_obj: Any) -> Tuple[float, str]:
    # balance_obj may be dict or model
    if balance_obj is None:
        return (0.0, "")
    if isinstance(balance_obj, dict):
        total = balance_obj.get("total") or {}
        amount = total.get("amount")
        currency = total.get("currency", "")
    else:
        total = getattr(balance_obj, "total", None)
        amount = None
        currency = ""
        if isinstance(total, dict):
            amount = total.get("amount")
            currency = total.get("currency", "")
        else:
            amount = getattr(total, "amount", None)
            currency = getattr(total, "currency", "")
    if amount is None:
        return (0.0, currency or "")
    return (float(amount), currency or "")


def main():
    cfg = load_config()
    client = get_client(cfg)
    user_id = cfg["user_id"]
    user_secret = cfg["user_secret"]

    # Trigger a manual refresh for all authorizations, then wait for all accounts to sync.
    # Skip refresh/wait on initial sync.
    import time
    from datetime import datetime, timezone

    def parse_ts(ts):
        if not ts:
            return None
        try:
            # handle Z suffix
            if isinstance(ts, str):
                if ts.endswith('Z'):
                    ts = ts.replace('Z', '+00:00')
                return datetime.fromisoformat(ts)
        except Exception:
            return None
        return None

    def is_closed(a):
        status = a.get("status") if isinstance(a, dict) else getattr(a, "status", None)
        if status and str(status).lower() == "closed":
            return True
        meta = a.get("meta") if isinstance(a, dict) else getattr(a, "meta", None)
        meta_status = None
        if isinstance(meta, dict):
            meta_status = meta.get("status")
        else:
            meta_status = getattr(meta, "status", None) if meta else None
        return str(meta_status).lower() == "closed"

    def list_accounts():
        resp = client.account_information.list_user_accounts(user_id=user_id, user_secret=user_secret)
        accounts = getattr(resp, "body", resp)
        if not isinstance(accounts, list):
            accounts = list(accounts) if accounts is not None else []
        # exclude closed accounts
        accounts = [a for a in accounts if not is_closed(a)]
        return accounts

    accounts = list_accounts()
    # Determine if this is initial sync for any account or newly linked accounts
    initial_sync = False
    newly_linked = False
    for a in accounts:
        sync = a.get("sync_status") if isinstance(a, dict) else getattr(a, "sync_status", None)
        holdings = (sync or {}).get("holdings") if isinstance(sync, dict) else getattr(sync, "holdings", None)
        initial_done = None
        if isinstance(holdings, dict):
            initial_done = holdings.get("initial_sync_completed")
        else:
            initial_done = getattr(holdings, "initial_sync_completed", None) if holdings else None
        if initial_done is False:
            initial_sync = True
            break
        # treat very recently created accounts as newly linked
        created = a.get("created_date") if isinstance(a, dict) else getattr(a, "created_date", None)
        created_ts = parse_ts(created)
        if created_ts and (datetime.now(timezone.utc) - created_ts).total_seconds() < 300:
            newly_linked = True

    disabled_auth_ids = set()
    disabled_connections = []
    if not initial_sync and not newly_linked:
        ref_ts = datetime.now(timezone.utc)
        try:
            auth_resp = client.connections.list_brokerage_authorizations(user_id=user_id, user_secret=user_secret)
            authorizations = getattr(auth_resp, "body", auth_resp)
            if not isinstance(authorizations, list):
                authorizations = list(authorizations) if authorizations is not None else []
            for auth in authorizations:
                auth_id = auth.get("id") if isinstance(auth, dict) else getattr(auth, "id", None)
                disabled = auth.get("disabled") if isinstance(auth, dict) else getattr(auth, "disabled", None)
                brokerage = auth.get("brokerage") if isinstance(auth, dict) else getattr(auth, "brokerage", None)
                bname = None
                if isinstance(brokerage, dict):
                    bname = brokerage.get("display_name") or brokerage.get("name")
                else:
                    bname = getattr(brokerage, "display_name", None) or getattr(brokerage, "name", None)
                if disabled:
                    if auth_id:
                        disabled_auth_ids.add(auth_id)
                    if bname:
                        disabled_connections.append(bname)
                    continue
                if not auth_id:
                    continue
                try:
                    client.connections.refresh_brokerage_authorization(
                        authorization_id=auth_id, user_id=user_id, user_secret=user_secret
                    )
                except Exception:
                    # best-effort refresh; continue
                    pass
        except Exception:
            pass

        # Wait for all accounts to show last_successful_sync > ref_ts (max 60s)
        deadline = time.time() + 60
        laggards = None
        while time.time() < deadline:
            laggards = []
            accounts = list_accounts()
            for a in accounts:
                name = a.get("name") if isinstance(a, dict) else getattr(a, "name", None)
                number = a.get("number") if isinstance(a, dict) else getattr(a, "number", None)
                auth_id = a.get("brokerage_authorization") if isinstance(a, dict) else getattr(a, "brokerage_authorization", None)
                if auth_id and auth_id in disabled_auth_ids:
                    continue
                sync = a.get("sync_status") if isinstance(a, dict) else getattr(a, "sync_status", None)
                holdings = (sync or {}).get("holdings") if isinstance(sync, dict) else getattr(sync, "holdings", None)
                last_sync = None
                if isinstance(holdings, dict):
                    last_sync = holdings.get("last_successful_sync")
                else:
                    last_sync = getattr(holdings, "last_successful_sync", None) if holdings else None
                ts = parse_ts(last_sync)
                if ts is None or ts <= ref_ts:
                    laggards.append({"name": name, "number": number, "last_sync": last_sync, "created_date": a.get("created_date") if isinstance(a, dict) else getattr(a, "created_date", None)})
            if not laggards:
                break
            time.sleep(1)

        if laggards:
            # If laggards are newly linked (<5 min old), proceed anyway
            still_wait = []
            for l in laggards:
                created_ts = parse_ts(l.get("created_date"))
                if not created_ts or (datetime.now(timezone.utc) - created_ts).total_seconds() >= 300:
                    still_wait.append(l)
            if still_wait:
                print({"error": "sync_timeout", "laggards": still_wait, "disabled_connections": sorted(set(disabled_connections))})
                return

    resp = client.account_information.list_user_accounts(user_id=user_id, user_secret=user_secret)
    accounts = getattr(resp, "body", resp)
    total_value = 0.0
    currency = None

    if not isinstance(accounts, list):
        # try to coerce
        accounts = list(accounts) if accounts is not None else []

    for acct in accounts:
        account_id = acct.get("id") if isinstance(acct, dict) else getattr(acct, "id", None)
        if not account_id:
            continue
        holdings_resp = client.account_information.get_user_holdings(
            user_id=user_id, user_secret=user_secret, account_id=account_id
        )
        holdings = getattr(holdings_resp, "body", holdings_resp)
        # holdings may be dict with account.balance.total
        bal = None
        if isinstance(holdings, dict):
            bal = (((holdings.get("account") or {}).get("balance") or {}).get("total"))
            if bal:
                amount = bal.get("amount")
                cur = bal.get("currency")
            else:
                amount = None
                cur = None
        else:
            acct_obj = getattr(holdings, "account", None)
            bal_obj = getattr(acct_obj, "balance", None) if acct_obj else None
            total_obj = getattr(bal_obj, "total", None) if bal_obj else None
            amount = getattr(total_obj, "amount", None) if total_obj else None
            cur = getattr(total_obj, "currency", None) if total_obj else None
        if amount is None:
            continue
        total_value += float(amount)
        if cur and not currency:
            currency = cur

    currency = currency or cfg.get("currency", "")
    # print as JSON for easy parsing
    result = {"total_value": round(total_value, 2), "currency": currency}
    if disabled_connections:
        result["disabled_connections"] = sorted(set(disabled_connections))
    print(result)


if __name__ == "__main__":
    main()


path: scripts/snaptrade_reconnect.py
content: #!/usr/bin/env python3
import sys
from typing import Any

from snaptrade_common import load_config, get_client


def main():
    cfg = load_config()
    client = get_client(cfg)

    # Find connections
    resp = client.connections.list_brokerage_authorizations(user_id=cfg["user_id"], user_secret=cfg["user_secret"])
    conns = getattr(resp, "body", resp)
    if not isinstance(conns, list):
        conns = list(conns) if conns is not None else []

    connections = []
    disabled = []
    for c in conns:
        disabled_flag = c.get("disabled") if isinstance(c, dict) else getattr(c, "disabled", None)
        brokerage = c.get("brokerage") if isinstance(c, dict) else getattr(c, "brokerage", None)
        name = None
        if isinstance(brokerage, dict):
            name = brokerage.get("display_name") or brokerage.get("name")
        else:
            name = getattr(brokerage, "display_name", None) or getattr(brokerage, "name", None)
        item = {"id": c.get("id") if isinstance(c, dict) else getattr(c, "id", None), "name": name}
        connections.append(item)
        if disabled_flag:
            disabled.append(item)

    if not connections:
        print("NO_CONNECTIONS")
        return

    # If a brokerage name was provided, try to match it from all connections
    target = None
    if len(sys.argv) > 1:
        wanted = sys.argv[1].lower()
        for d in connections:
            if d["name"] and wanted in d["name"].lower():
                target = d
                break
    if target is None:
        # fallback to first disabled, else first connection
        target = disabled[0] if disabled else connections[0]

    resp = client.authentication.login_snap_trade_user(
        user_id=cfg["user_id"],
        user_secret=cfg["user_secret"],
        reconnect=target["id"],
        connection_type="trade",
        connection_portal_version="v4",
        immediate_redirect=False,
        show_close_button=True,
    )
    body = getattr(resp, "body", resp)
    if isinstance(body, dict):
        url = body.get("redirectURI") or body.get("redirect_uri")
    else:
        url = getattr(body, "redirectURI", None) or getattr(body, "redirect_uri", None)
    print(url)


if __name__ == "__main__":
    main()


path: scripts/snaptrade_accounts.py
content: #!/usr/bin/env python3
import json
from snaptrade_common import load_config, get_client


def main():
    cfg = load_config()
    client = get_client(cfg)
    resp = client.account_information.list_user_accounts(user_id=cfg['user_id'], user_secret=cfg['user_secret'])
    accounts = getattr(resp, 'body', resp)

    out = []
    for a in accounts:
        if isinstance(a, dict):
            out.append({
                'id': a.get('id'),
                'name': a.get('name'),
                'number': a.get('number') or a.get('account_number'),
                'type': a.get('type'),
                'institution_name': a.get('institution_name') or a.get('brokerage_name')
            })
        else:
            out.append({
                'id': getattr(a, 'id', None),
                'name': getattr(a, 'name', None),
                'number': getattr(a, 'number', None),
                'type': getattr(a, 'type', None),
                'institution_name': getattr(a, 'institution_name', None)
            })
    print(json.dumps(out, indent=2))


if __name__ == '__main__':
    main()


path: scripts/snaptrade_common.py
content: #!/usr/bin/env python3
import json
import os
from pathlib import Path
from typing import Any, Dict

from snaptrade_client import SnapTrade
from snaptrade_client.configuration import Configuration

DEFAULT_CONFIG_PATH = os.environ.get(
    "SNAPTRADE_CONFIG",
    str(Path.home() / ".openclaw" / "workspace" / "secrets" / "snaptrade.json"),
)


def load_config(path: str = DEFAULT_CONFIG_PATH) -> Dict[str, Any]:
    p = Path(path)
    if not p.exists():
        raise FileNotFoundError(f"Config not found: {p}")
    data = json.loads(p.read_text())
    return data


def save_config(data: Dict[str, Any], path: str = DEFAULT_CONFIG_PATH) -> None:
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(json.dumps(data, indent=2))
    try:
        os.chmod(p, 0o600)
    except Exception:
        pass


def get_client(cfg: Dict[str, Any]) -> SnapTrade:
    client_id = cfg.get("client_id")
    consumer_key = cfg.get("consumer_key")
    if not client_id or not consumer_key:
        raise ValueError("Missing client_id or consumer_key in config")
    config = Configuration(client_id=client_id, consumer_key=consumer_key)
    return SnapTrade(config)


path: scripts/snaptrade_order.py
content: #!/usr/bin/env python3
import argparse
import time
from datetime import datetime, timezone

from snaptrade_common import load_config, get_client


def parse_args():
    p = argparse.ArgumentParser(description="Place a SnapTrade order and optionally monitor for fill.")
    p.add_argument("action", choices=["buy", "sell"], help="buy or sell")
    p.add_argument("symbol", help="Ticker symbol, e.g., AAPL or VXC.TO")
    p.add_argument("units", type=float, help="Number of units (shares). Use whole numbers when required by broker.")
    p.add_argument("--account-id", required=True, help="SnapTrade account id")
    p.add_argument("--order-type", default="market", choices=["market", "limit"], help="market (default) or limit")
    p.add_argument("--limit-price", type=float, help="Limit price (required if order-type=limit)")
    p.add_argument("--tif", default="Day", choices=["Day", "GTC", "IOC", "FOK"], help="Time in force")
    p.add_argument("--watch", action="store_true", help="Monitor for fill after placing")
    p.add_argument("--watch-interval", type=int, default=10, help="Seconds between checks (default 10)")
    p.add_argument("--watch-seconds", type=int, default=120, help="Max seconds to watch (default 120)")
    return p.parse_args()


def normalize_order_type(o):
    return "Market" if o.lower() == "market" else "Limit"


def main():
    args = parse_args()
    if args.order_type == "limit" and args.limit_price is None:
        raise SystemExit("--limit-price is required for limit orders")

    cfg = load_config()
    client = get_client(cfg)
    user_id = cfg["user_id"]
    user_secret = cfg["user_secret"]

    resp = client.trading.place_force_order(
        user_id=user_id,
        user_secret=user_secret,
        account_id=args.account_id,
        action=args.action.upper(),
        order_type=normalize_order_type(args.order_type),
        time_in_force=args.tif,
        symbol=args.symbol,
        units=args.units,
        price=args.limit_price if args.order_type == "limit" else None,
    )
    body = getattr(resp, "body", resp)
    order_id = body.get("brokerage_order_id") if isinstance(body, dict) else None
    print({"status": "placed", "order": body})

    if not args.watch or not order_id:
        return

    deadline = time.time() + args.watch_seconds
    last = None
    while time.time() < deadline:
        try:
            oresp = client.account_information.get_user_account_orders(
                user_id=user_id,
                user_secret=user_secret,
                account_id=args.account_id,
            )
            orders = getattr(oresp, "body", oresp) or []
            match = None
            for o in orders:
                if (o.get("brokerage_order_id") if isinstance(o, dict) else None) == order_id:
                    match = o
                    break
            if match:
                status = match.get("status") if isinstance(match, dict) else None
                if status != last:
                    last = status
                    print({"status": "update", "order_id": order_id, "order_status": status, "time": datetime.now(timezone.utc).isoformat()})
                if status in ("EXECUTED", "FILLED"):
                    return
        except Exception:
            pass
        time.sleep(args.watch_interval)

    print({"status": "timeout", "order_id": order_id})


if __name__ == "__main__":
    main()


path: scripts/snaptrade_watch_order.py
content: #!/usr/bin/env python3
import argparse
import time
from datetime import datetime, timezone

from snaptrade_common import load_config, get_client


def parse_args():
    p = argparse.ArgumentParser(description="Watch a SnapTrade order by ID for fill status.")
    p.add_argument("--account-id", required=True, help="SnapTrade account id")
    p.add_argument("--order-id", required=True, help="brokerage_order_id to watch")
    p.add_argument("--watch-interval", type=int, default=10, help="Seconds between checks (default 10)")
    p.add_argument("--watch-seconds", type=int, default=120, help="Max seconds to watch (default 120)")
    return p.parse_args()


def main():
    args = parse_args()
    cfg = load_config()
    client = get_client(cfg)
    user_id = cfg["user_id"]
    user_secret = cfg["user_secret"]

    deadline = time.time() + args.watch_seconds
    last = None
    while time.time() < deadline:
        try:
            oresp = client.account_information.get_user_account_orders(
                user_id=user_id,
                user_secret=user_secret,
                account_id=args.account_id,
            )
            orders = getattr(oresp, "body", oresp) or []
            match = None
            for o in orders:
                if (o.get("brokerage_order_id") if isinstance(o, dict) else None) == args.order_id:
                    match = o
                    break
            if match:
                status = match.get("status") if isinstance(match, dict) else None
                if status != last:
                    last = status
                    print({"status": "update", "order_id": args.order_id, "order_status": status, "time": datetime.now(timezone.utc).isoformat()})
                if status in ("EXECUTED", "FILLED"):
                    return
        except Exception:
            pass
        time.sleep(args.watch_interval)

    print({"status": "timeout", "order_id": args.order_id})


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)