# SDK-013 — Open Positions & Orders (SDK + CLI)

**Status:** Complete
**Date:** 2026-03-18
**Branch:** `sdk-013/open-positions-orders`
**Location:** `hyperscaled-sdk/`

---

## Summary

Implemented read-only portfolio visibility so SDK users and CLI operators can inspect live open positions and open orders from the Vanta validator without using the browser extension or making direct validator calls.

This work adds:

- a `PortfolioClient` with `open_positions()` / `open_orders()` backed by the validator's permissionless `GET /hl/<hl_address>/dashboard` endpoint
- normalization from validator-native payloads into the existing SDK `Position` and `Order` models
- model broadening so `Order` and `Position` support both the trade-submission write path and the portfolio read path
- CLI commands `hyperscaled positions open` and `hyperscaled orders open` with table and `--json` output
- a new `orders` CLI group wired into the main CLI entry point

Both reads come from a single validator dashboard endpoint. The SDK maps the relevant sections (`dashboard.positions.positions` for open positions, `dashboard.limit_orders` for open orders) into clean SDK models.

---

## What Was Built

### New Files

```text
hyperscaled-sdk/
├── hyperscaled/
│   └── cli/
│       └── orders.py             # CLI commands for open orders
├── tests/
│   └── test_portfolio.py         # 27 tests for portfolio SDK + CLI
└── planning/
    └── reports/
        └── SDK-013.md            # This report
```

### Modified Files

| File | Change |
|------|--------|
| `hyperscaled/models/trading.py` | Made `Order.hl_order_id`, `Order.size`, `Order.funded_equivalent_size`, `Order.scaling_ratio` optional; added `Order.order_id` and `Order.limit_price`; added `"open"` to status literal; made `Position.mark_price` optional |
| `hyperscaled/sdk/portfolio.py` | Replaced one-line stub with full `PortfolioClient` implementation |
| `hyperscaled/sdk/client.py` | Removed `portfolio` from stub descriptors, added lazy-loaded `portfolio` property |
| `hyperscaled/cli/positions.py` | Replaced stub with real `positions open` and `positions open --json` commands |
| `hyperscaled/cli/main.py` | Wired in `orders` CLI group |
| `tests/test_client.py` | Removed `portfolio` from STUBS list, added `test_portfolio_lazy_loaded`, added `portfolio` to settable/cached tests |
| `tests/test_imports.py` | Added `hyperscaled.cli.orders` to import smoke test |
| `tests/test_models.py` | Added `test_valid_read_path_order`, `test_json_roundtrip_read_path`, `test_valid_without_mark_price` |

---

## Details

### 1. Validator endpoint

`PortfolioClient` reads from the validator's permissionless `GET /hl/<hl_address>/dashboard` endpoint on the `feat/hyperscaled_unified_entity_miner` branch (ENG-B-008/009). The endpoint resolves `hl_address` → `synthetic_hotkey` internally, then aggregates position, order, and account data from multiple validator subsystems.

This is the same endpoint already used by `RulesClient._fetch_dashboard()`. For SDK-013, the duplication is intentional to keep sub-clients self-contained.

### 2. Position mapping

Each entry in `dashboard.positions.positions` is mapped to an SDK `Position`:

| Validator field | SDK field | Notes |
|---|---|---|
| `trade_pair` | `symbol` | `["BTCUSD", "BTC/USD", ...]` → `BTC-USDC` |
| `position_type` | `side` | `LONG` → `"long"`, `SHORT` → `"short"` |
| `net_quantity` | `size` | Decimal conversion |
| `net_value` | `position_value` | Funded-account notional |
| `average_entry_price` | `entry_price` | Decimal conversion |
| `unrealized_pnl` | `unrealized_pnl` | Decimal conversion |
| embedded order TP/SL | `take_profit`, `stop_loss` | Best-effort extraction from orders and unfilled_orders |
| `open_ms` | `open_time` | Epoch ms → timezone-aware UTC datetime |

`mark_price` and `liquidation_price` are set to `None` — the validator does not provide these fields on this endpoint.

Filtering: only entries with `is_closed_position == false` and `position_type != "FLAT"` are returned.

### 3. Order mapping

Each entry in `dashboard.limit_orders` is mapped to an SDK `Order`:

| Validator field | SDK field | Notes |
|---|---|---|
| `order_uuid` | `order_id` | Validator signal identifier |
| `trade_pair` | `pair` | Normalized to SDK format |
| `order_type` | `side` | `LONG` → `"long"`, `SHORT` → `"short"` |
| `execution_type` | `order_type` | Normalized to lowercase |
| `limit_price` | `limit_price` | The price the limit order is set at |
| `quantity` | `size` | May be `None` if the order was sized by `leverage` or `value` |
| `value` | `funded_equivalent_size` | May be `None` |
| `take_profit` | `take_profit` | Optional |
| `stop_loss` | `stop_loss` | Optional |
| `processed_ms` | `created_at` | Epoch ms → timezone-aware UTC datetime |

`hl_order_id`, `scaling_ratio`, and `fill_price` are set to `None` — these are write-path fields from trade submission. `status` is always `"open"` since the endpoint only returns unfilled orders.

### 4. Model changes

The `Order` and `Position` models were broadened to support both the trade-submission write path (SDK-010) and the portfolio read path (SDK-013):

**Order changes:**
- `hl_order_id`: `str` → `str | None = None`
- `size`: `Decimal` → `Decimal | None = None`
- `funded_equivalent_size`: `Decimal` → `Decimal | None = None`
- `scaling_ratio`: `Decimal` → `Decimal | None = None`
- Added `order_id: str | None = None` for validator signal identifiers
- Added `limit_price: Decimal | None = None` for open limit orders
- Added `"open"` to the `status` literal

**Position changes:**
- `mark_price`: `Decimal` → `Decimal | None = None`

All existing SDK-010/011 tests continue to pass with these changes — existing write-path code is unaffected.

### 5. CLI commands

**`hyperscaled positions open`** renders a table with columns: Pair, Side, Size, Value, Entry, Unrealized PnL, Opened. `--json` outputs a JSON array of `Position.model_dump(mode="json")`.

**`hyperscaled orders open`** renders a table with columns: Pair, Side, Type, Limit Price, Size, Funded Size, Created. `--json` outputs a JSON array of `Order.model_dump(mode="json")`.

Both commands print clear empty-state messages when there are no positions/orders. Optional numeric fields render as `--` instead of crashing on `None` formatting.

### 6. Pair normalization

A `_normalize_trade_pair()` helper converts the validator's 5-element trade-pair tuple into the SDK display format:

- `["BTCUSD", "BTC/USD", 0.003, 0.001, 0.5]` → `"BTC-USDC"`
- `["ETHUSD", "ETH/USD", ...]` → `"ETH-USDC"`

The helper indexes safely into the tuple and falls back to string coercion for unexpected formats.

---

## Acceptance Criteria Status

| Criterion | Status |
|-----------|--------|
| `client.portfolio.open_positions()` returns `list[Position]` from validator data | Pass |
| `client.portfolio.open_orders()` returns `list[Order]` from validator data | Pass |
| `hyperscaled positions open` shows a readable table of live positions | Pass |
| `hyperscaled positions open --json` produces reliable structured output | Pass |
| `hyperscaled orders open` shows a readable table of open orders | Pass |
| `hyperscaled orders open --json` produces reliable structured output | Pass |
| Missing or unknown funded account identifiers are handled cleanly | Pass |
| Mock-based tests cover empty positions, empty orders, populated responses, and validator API failure handling | Pass |

---

## Verification

```bash
python3 -m pytest tests/ -q  →  266 passed in 1.33s
```

All 266 tests pass — 27 new portfolio tests, updated client/model/import tests, and all pre-existing tests remain green.

---

## Test Coverage

| Test | Coverage |
|------|----------|
| `test_open_positions_populated` | Maps open positions correctly from validator payload |
| `test_open_positions_filters_closed` | Closed positions are excluded from results |
| `test_open_positions_filters_flat` | FLAT positions are defensively skipped |
| `test_open_positions_empty` | Valid dashboard with no open positions returns `[]` |
| `test_open_positions_extracts_tp_sl` | TP/SL extracted from embedded orders |
| `test_open_positions_short_side` | SHORT positions mapped to `side="short"` |
| `test_open_positions_pair_normalization` | ETH/USD tuple normalized to `ETH-USDC` |
| `test_open_positions_sync` | Sync wrapper works outside event loop |
| `test_open_orders_populated` | Maps limit orders into SDK Order objects |
| `test_open_orders_empty` | Valid dashboard with no orders returns `[]` |
| `test_open_orders_none_limit_orders` | `None` limit_orders key returns `[]` |
| `test_open_orders_with_tp_sl` | TP/SL values mapped correctly |
| `test_open_orders_optional_size_fields` | Both `size` and `funded_equivalent_size` can be `None` |
| `test_open_orders_value_as_funded_size` | `value` maps to `funded_equivalent_size` |
| `test_open_orders_sync` | Sync wrapper works outside event loop |
| `test_fetch_dashboard_404` | Unknown account raises `HyperscaledError` |
| `test_fetch_dashboard_http_failure` | HTTP 500 raises `HyperscaledError` |
| `test_fetch_dashboard_missing_dashboard_key` | Malformed response raises `HyperscaledError` |
| `test_no_wallet_configured` | No `hl_address` raises `HyperscaledError` |
| `test_positions_open_table` | CLI renders readable table with position data |
| `test_positions_open_json` | CLI `--json` emits valid JSON array |
| `test_positions_open_empty` | CLI prints "No open positions" for empty results |
| `test_orders_open_table` | CLI renders readable table with order data |
| `test_orders_open_json` | CLI `--json` emits valid JSON array with correct fields |
| `test_orders_open_empty` | CLI prints "No open orders" for empty results |
| `test_orders_open_none_size_renders_dashes` | CLI renders `--` for `None` numeric fields |
| Model tests | `test_valid_read_path_order`, `test_json_roundtrip_read_path`, `test_valid_without_mark_price` |

---

## Design Decisions

### Why the validator endpoint, not the entity miner API

The sprint spec says "Reads are permissionless and should use the validator endpoints from ENG-B-008/009." The entity miner API (`/api/hl/<hl_address>/dashboard` on port 8089) serves the same data as a caching layer, and the Hyperscaled app frontend uses it. However, the validator is the source of truth and the endpoint is already permissionless on the confirmed feature branch.

Product discussion confirmed the validator direct path as the correct backend for SDK-013.

### Why `hl_address` instead of `funded_account_id`

The sprint spec originally said the default lookup should use `funded_account_id`, but every layer of the stack — the app frontend, the entity miner API, the validator public endpoints, and the SDK's existing `RulesClient` — uses `hl_address` as the primary key. The validator resolves `hl_address → synthetic_hotkey` internally. There is no permissionless endpoint that accepts `funded_account_id`.

### Why model fields were made optional instead of creating separate read/write models

The SDK has one `Order` and one `Position` model used across both the trade submission path and the portfolio read path. Making write-path-only fields optional (rather than splitting into `SubmittedOrder` vs `PortfolioOrder`) keeps the public API surface small and avoids model proliferation at this stage. If the divergence grows, a split can be introduced later without changing method signatures.

### Why `_fetch_dashboard` is duplicated between PortfolioClient and RulesClient

Both sub-clients hit the same `GET /hl/<hl_address>/dashboard` endpoint with nearly identical error handling. The duplication is intentional for SDK-013 to keep sub-clients self-contained. If a third consumer appears, the fetch logic should be extracted into a shared helper on `HyperscaledClient` or a common module.

---

## Open Follow-Up Items

- Historical positions / orders remain future work
- If the validator contract later exposes mark price or liquidation price, `Position` mapping can be enriched without changing public method signatures
- If the SDK later standardizes separate read models vs write models, `Order` may eventually split into submission-result and portfolio-order variants
- `PortfolioClient._fetch_dashboard()` and `RulesClient._fetch_dashboard()` are near-duplicates — extract a shared helper if a third consumer appears
- Payout info and rule-violation history are not yet available from the validator (noted as TODO on ENG-B-009) and are out of scope for this ticket
- `_sync_or_async` helper is duplicated across multiple sub-clients — consider extracting to a shared utility
