# SDK-010 — Trade Submission (SDK + CLI)

**Status:** Complete
**Date:** 2026-03-18
**Branch:** `sdk-010/trade-submission`
**Location:** `hyperscaled-sdk/`

---

## Summary

Implemented the trade submission path so SDK users and CLI operators can place market and limit orders on Hyperliquid, with automatic scaling-ratio translation to funded-account equivalents.

This work adds:

- a `TradingClient` with `submit()` / `submit_async()` that places orders via `hyperliquid-python-sdk`
- pair validation against a six-pair allowlist matching Vanta's `TradePair` config
- scaling math that translates between the user's HL account size and their funded account size
- a `_pre_validate()` seam for SDK-012 local rule enforcement (no-op for now)
- HL private key resolution from constructor param or `HYPERSCALED_HL_PRIVATE_KEY` env var (never persisted)
- `funded_account_size` persistence during registration
- a full CLI `hyperscaled trade submit` command with Rich output
- promotion of `hyperliquid-python-sdk` from optional to required dependency

Orders are submitted directly to Hyperliquid via the HL SDK — they are not routed through the Vanta miner REST server. This aligns with V2 design section 19 and keeps signing local to the SDK.

---

## What Was Built

### New Files

```text
hyperscaled-sdk/
├── hyperscaled/
│   └── sdk/
│       └── pairs.py              # Pair normalization and validation utilities
├── tests/
│   └── test_trading.py           # 19 mock-based tests for trade submission
└── planning/
    └── reports/
        └── SDK-010.md            # This report
```

### Modified Files

| File | Change |
|------|--------|
| `pyproject.toml` | Moved `hyperliquid-python-sdk` from optional `hl` group to required dependencies |
| `hyperscaled/sdk/config.py` | Added `funded_account_size: int = 0` to `AccountConfig` |
| `hyperscaled/sdk/client.py` | Added `hl_private_key` constructor param, `_resolve_hl_private_key()`, replaced `trade` descriptor stub with lazy-loaded property |
| `hyperscaled/sdk/trading.py` | Full `TradingClient` implementation (replaced one-line stub) |
| `hyperscaled/sdk/register.py` | Added `_persist_funded_account_size()`, calls it in `purchase_async()` and `check_status_async()` |
| `hyperscaled/cli/trade.py` | Replaced stub with full `trade submit` command, added `--price`, `--tp`, `--sl` options |
| `tests/test_client.py` | Moved `trade` from stub list to real lazy-loaded property tests, added settable/cached coverage |

---

## Details

### 1. `TradingClient` submission flow

`hyperscaled/sdk/trading.py` provides a `TradingClient` with the following submission path:

1. Validate inputs (pair allowlist, side enum, size > 0, price rules)
2. Call `_pre_validate()` seam (no-op until SDK-012)
3. Read `funded_account_size` from local config
4. Fetch live HL account balance via `AccountClient.check_balance_async()`
5. Compute `scaling_ratio = funded_account_size / hl_account_balance`
6. Normalize pair name (`BTC-USDC` → `BTC` for HL SDK)
7. Submit order via HL SDK `Exchange` (in `asyncio.to_thread` to avoid blocking)
8. Parse HL response → extract `oid`, status, fill price
9. Build and return `Order` model with funded translation fields

Market orders use `Exchange.market_open()`, limit orders use `Exchange.order()` with GTC time-in-force.

### 2. Pair normalization

`hyperscaled/sdk/pairs.py` centralizes the three naming conventions:

| Format | Example | Used by |
|--------|---------|---------|
| SDK public API | `BTC-USDC` | User-facing code |
| Hyperliquid SDK | `BTC` | `Exchange.market_open()` / `Exchange.order()` |
| Vanta internals | `BTCUSD` | `TradePair` config |

Supported pairs: BTC, ETH, SOL, XRP, DOGE, ADA (all `-USDC`). These match the six crypto pairs in Vanta's `TradePair` enum.

### 3. Scaling math

The scaling ratio translates between the user's actual HL account and their funded account:

```
scaling_ratio = funded_account_size / hl_account_balance
funded_equivalent_size = filled_size × scaling_ratio
```

For filled/partial orders, `filled_size` is the actual fill from Hyperliquid. For pending/resting orders, it is the requested size (nothing has filled yet).

Edge cases are guarded: zero or negative HL balance raises `HyperscaledError` before the division, and `funded_account_size = 0` (unregistered) raises with a message directing the user to complete registration.

### 4. HL private key handling

The HL signing key is resolved at runtime from:

1. `hl_private_key=` constructor parameter on `HyperscaledClient` (highest precedence)
2. `HYPERSCALED_HL_PRIVATE_KEY` environment variable (fallback)

The key is never persisted to `config.toml`, never logged, and never included in `model_dump()` output. Invalid keys are caught at `Exchange` initialization with a clean `HyperscaledError`.

### 5. Funded account size persistence

`RegisterClient` now persists `funded_account_size` to local config:

- In `purchase_async()`, immediately after a successful paid registration response
- In `check_status_async()`, when the backend provides `account_size` in the status payload (forward-compatible with richer responses)

This value is read by `TradingClient` at trade time to compute the scaling ratio without requiring a live validator lookup.

### 6. CLI `trade submit`

```bash
# Market order
hyperscaled trade submit --pair BTC-USDC --side long --size 0.01

# Limit order with TP/SL
hyperscaled trade submit --pair ETH-USDC --side short --size 0.5 --type limit --price 3500.00 --tp 3200 --sl 3700
```

The command renders a Rich panel with order ID, pair, side, size, type, status, fill price, scaling ratio, and funded equivalent size. Panel border is green for filled orders, yellow otherwise.

### 7. `_pre_validate()` seam

`TradingClient._pre_validate()` is a real async method called before any HL interaction. It is currently a no-op. SDK-012 will replace the body with local rule enforcement. The public API (`client.trade.submit()`) will not change when SDK-012 lands.

---

## Acceptance Criteria Status

| Criterion | Status |
|-----------|--------|
| `client.trade.submit(pair, side, size, order_type)` places an order on Hyperliquid | Pass |
| Market orders use `Exchange.market_open()`, limit orders use `Exchange.order()` with GTC | Pass |
| Pair validation enforces the six supported crypto pairs | Pass |
| Scaling ratio correctly translates between HL account and funded account | Pass |
| Filled, partial, pending, and rejected statuses are mapped to the `Order` model | Pass |
| HL private key resolved from constructor or env var, never persisted | Pass |
| `funded_account_size` persisted during registration and read at trade time | Pass |
| CLI `trade submit` renders human-readable order output | Pass |
| `_pre_validate()` seam exists for SDK-012 | Pass |
| Mock-based tests cover all happy and error paths without live HL API calls | Pass |

---

## Verification

```bash
python3 -m pytest tests/ -v  → 223 passed in 1.53s
```

All 223 tests pass — 19 new trading tests, updated client tests, and all pre-existing tests remain green.

---

## Test Coverage

| Test Area | Coverage |
|-----------|----------|
| Pair validation: valid pairs | Case-insensitive matching for all six supported pairs |
| Pair validation: unsupported pair | `LINK-USDC` rejected with `ValueError` |
| Pair validation: malformed pair | `BTCUSDC` (no hyphen) rejected |
| Pair normalization | `BTC-USDC` → `BTC` (HL), `BTC-USDC` → `BTCUSD` (Vanta) |
| Input: invalid side | Side not `long`/`short` raises `ValueError` |
| Input: zero size | Size ≤ 0 raises `ValueError` |
| Input: negative size | Negative size raises `ValueError` |
| Input: limit without price | Missing price on limit order raises `ValueError` |
| Input: market with price | Price on market order raises `ValueError` |
| Precondition: no funded account size | `funded_account_size = 0` raises `HyperscaledError` |
| Precondition: no private key | Missing key raises `HyperscaledError` |
| Submit: market filled | Full fill → `status="filled"`, correct `oid`, `fill_price`, `funded_equivalent_size` |
| Submit: market partial | Partial fill → `status="partial"`, `funded_equivalent_size` uses filled size |
| Submit: limit pending | Resting → `status="pending"`, no `fill_price`, `funded_equivalent_size` uses requested size |
| Submit: with TP/SL | Take profit and stop loss values pass through to the returned `Order` model |
| Scaling ratio: default | `100000 / 1000 = 100`, applied correctly to filled size |
| Scaling ratio: different balance | Different HL balance produces different ratio |
| Error: HL rejection | `"error"` in status → `HyperscaledError` with rejection message |
| Error: HL transport failure | Network exception → wrapped `HyperscaledError` |
| Error: HL top-level failure | `status != "ok"` → `HyperscaledError` |
| Error: unexpected response | Malformed HL response dict → `HyperscaledError` |
| Error: unexpected status entry | Unknown key in statuses → `HyperscaledError` |
| Error: zero balance | HL balance ≤ 0 → `HyperscaledError` before division |
| Seam: pre-validate called | `_pre_validate` invoked with correct args before HL submission |

---

## Design Decisions

### Why funded account size comes from config, not validator

The original plan considered fetching `funded_account_size` from the Vanta validator REST API at trade time. This was revised because:

1. **Immutable after registration.** The user selects it ($25K, $50K, $100K) and it does not change. A live fetch is unnecessary.
2. **Validator identity chain not ready.** The `/hl-traders/<hl_address>` endpoint that resolves `hl_address → synthetic_hotkey` is not yet implemented in vanta-network (per SDK-009 report).
3. **Validator auth is a barrier.** Subaccount endpoints require tier-200 API keys that SDK users may not have.
4. **V2 architecture alignment.** Direct SDK-to-validator calls should be introduced later behind a platform endpoint.

### Why orders go directly to Hyperliquid

Orders are placed via `hyperliquid-python-sdk`, not routed through the Vanta miner REST server's `/api/submit-order`. This keeps private key signing local to the SDK and avoids an extra network hop.

### Why the HL SDK calls are wrapped in `asyncio.to_thread`

The `hyperliquid-python-sdk` `Exchange` is synchronous (blocking HTTP calls). Since `TradingClient.submit_async()` is async, all HL SDK calls are wrapped in `asyncio.to_thread()` to avoid blocking the event loop.

---

## Open Follow-up Items

- **SDK-011:** Trade cancellation (`Exchange.cancel()`)
- **SDK-012:** Local rule enforcement — `_pre_validate()` body will enforce drawdown, exposure, leverage, and pair rules before submission
- **SDK-013:** Open positions / orders read path
- TP/SL are passed through on the `Order` model but not yet submitted as separate HL trigger orders
- `_sync_or_async` helper is duplicated across `account.py`, `register.py`, and `trading.py` — consider extracting to a shared utility
- When the Hyperscaled platform has a proper account-info endpoint, the SDK can switch to live-fetching `funded_account_size` instead of reading from config
