Metadata-Version: 2.4
Name: auspicium
Version: 0.11.0
Summary: Auspicium DaaS SDK — market data for crypto and prediction market quants
Author-email: Auspicium <technical.account@auspicium.io>
License: MIT
Keywords: market-data,crypto,polymarket,quant,finance,binance
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: License :: OSI Approved :: MIT License
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Intended Audience :: Developers
Classifier: Topic :: Office/Business :: Financial
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: httpx>=0.27
Requires-Dist: websockets>=13.0
Requires-Dist: pydantic>=2.6
Requires-Dist: pandas>=2.2
Requires-Dist: pyarrow>=15.0
Requires-Dist: typer>=0.12
Requires-Dist: rich>=13.7
Requires-Dist: pyyaml>=6.0
Provides-Extra: strategy
Requires-Dist: prometheus_client>=0.20; extra == "strategy"
Provides-Extra: backtest
Requires-Dist: vectorbt>=0.26; extra == "backtest"
Requires-Dist: plotly>=5.20; extra == "backtest"
Requires-Dist: scipy>=1.13; extra == "backtest"
Requires-Dist: auspicium[strategy]; extra == "backtest"
Provides-Extra: exchange
Requires-Dist: py-clob-client-v2>=1.0.1; extra == "exchange"
Requires-Dist: python-binance>=1.0.19; extra == "exchange"
Provides-Extra: api
Requires-Dist: fastapi>=0.110; extra == "api"
Requires-Dist: uvicorn>=0.29; extra == "api"
Provides-Extra: production
Requires-Dist: auspicium[strategy]; extra == "production"
Requires-Dist: auspicium[exchange]; extra == "production"
Requires-Dist: auspicium[api]; extra == "production"
Provides-Extra: all
Requires-Dist: auspicium[strategy]; extra == "all"
Requires-Dist: auspicium[backtest]; extra == "all"
Requires-Dist: auspicium[exchange]; extra == "all"
Requires-Dist: auspicium[api]; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"
Requires-Dist: ruff>=0.4; extra == "dev"

# auspicium

Python SDK for the [Auspicium](https://auspicium.io) market data platform — crypto OHLCV, order books, trades, Polymarket prediction markets, and cross-market signals.

## Install

```bash
pip install auspicium
```

## Quick start

```python
import os
os.environ["AUSP_API_KEY"]     = "your-api-key"
os.environ["AUSP_GATEWAY_URL"] = "https://api.auspicium.io"

from auspicium import rest

# OHLCV candlestick data
df = rest.ohlcv("binance", "BTC-USDT", interval="5m", days=7)

# Order book snapshot
book = rest.orderbook("binance", "BTC-USDT", depth=20)

# Polymarket prediction markets
markets = rest.markets(status="active", base_asset="BTC")

# Cross-market signal (Binance x Polymarket)
cross = rest.cross_market(granularity="5m", base_asset="BTC", hours=48)
```

## WebSocket streaming

```python
from auspicium import stream

async with stream.connect() as ws:
    await ws.subscribe("ohlcv", source="binance", symbol="BTC-USDT")
    async for msg in ws:
        print(msg.channel, msg.payload)
```

## Get your API key

Sign up at [auspicium.io](https://auspicium.io) — free tier included.

## Pointing the SDK at another environment

The SDK talks to whatever gateway `AUSP_GATEWAY_URL` points at (default
`https://api.auspicium.io`). To exercise it against the **Quality** DaaS:

```bash
export AUSP_GATEWAY_URL=https://api.quality.auspicium.io
export AUSP_API_KEY=<your quality API key>
```

No localhost DaaS is ever required. The unit test suite is fully mocked;
only `tests/test_rest.py` and `tests/test_stream.py` are integration tests
that need a live gateway (set the two variables above to run them).

## Changelog

See [CHANGELOG.md](https://github.com/auspicium1/auspicium-sdk/blob/main/CHANGELOG.md)
for the full per-version history. The most recent entries (newest first):

| Version | Date       | Headline                                                                                                     |
| ------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
| 0.11.0  | 2026-08-29 | **The copy-trader bought the wrong side of every NO-pole market, and its backtest reported zero unsettled positions while holding them (AUS-244).** The `address` channel's `price` is the price of the **outcome token actually traded**, not the YES price — measured across the quality corpus, `price × size_shares ≈ size_usd` holds for 97.6% of 233,880 NO-pole rows and matches **zero** rows under the YES-side reading. Both shipped copy-trader templates passed it straight into `Order.binary_buy(yes_price=…)`, which inverts for the NO side, so a real `Down` fill at 0.868690 booked an entry at 0.1313, sized **380.8 shares instead of 57.6**, and committed **$330.78 of capital for a $50 stake**. The error grows with distance from 0.5 — worst on the highest-conviction trades — and `RiskGuardian` could not catch it: it computes notional from the order's own price (380.8 × 0.1313 = $50) and so saw exactly the configured stake. On the exit side it was a money-loss path rather than mis-accounting: `binary_sell` would have offered a NO position worth 0.8687 at 0.1313, a marketable sell far below book. NO-pole rows are ~half the corpus (347,506 of 727,053). `notebook_helper` carried the same conflation in `set_price()`, so every copy backtest to date also mispriced NO-pole fills. **Second, settlement never reached this path**: `run_copy_strategy_backtest` had no `resolutions=` parameter at all, never called `_warn_on_unsettled_binaries`, and called `_compute_stats` **positionally** — so it reported `settlement_modelled=False` **with `unsettled_binary_positions=0`** while holding open binaries. That pair reads as "held no binaries, settlement irrelevant"; a missing warning is a gap, an affirmative zero is a false statement. Fixed: `resolutions=` is now accepted and settles through the same `SettlementEngine` as `run()`, the `UserWarning` fires, and the count is real. **Third, SELL mirroring** — SELL is ~5.7% of trades corpus-wide and 4–19% on a typical tracked wallet, and the template discarded every one, so a copy-trader mirrored entries and never exited. It now mirrors the **fraction** of his position he sold, applied to your own holding (never his share count), no-ops when you hold nothing, refuses to gate exits on the entry dust filter, collapses to a full close near 100%, and logs loudly when a restart leaves the denominator unknown. `copy_sells` defaults **true**: scaffolded strategies are materialized to disk at scaffold time and are never rewritten, so no existing strategy changes behaviour. Also: the template now uses `auspicium.outcomes.pole_of` instead of a private mapping dict — that fourth copy was case-sensitive and missed `Over`/`Under`/`Higher`/`Lower`, all present in the corpus (`Over` 350 rows, `Under` 44), and gained only `Long`/`Short`, which appear in **none** of the 92 distinct outcome values across 727,053 TRADE rows. Docs: `CLAUDE.md` gains §10 (the whole channel; it previously had one passing phrase), `Strategy.subscribe` documents `address`, and the copy-backtest examples name the **sync** symbol — the async one binds a coroutine in a notebook and fails on `.tearsheet()` with no backtest run. `run_copy_strategy_backtest`'s "arrives in v0.7.16" promises, unshipped for nine minor versions, are deleted. **No `state.db` action.** | Bugfix (real-money mispricing; backtest numbers move) + Feature (SELL mirroring, `resolutions=`) |
| 0.10.1  | 2026-08-25 | **Binary markets now settle (AUS-167, Block 1).** A resolved Polymarket position leaves the books — closed at exactly 1.0 or 0.0, cash credited, exposure released, realized PnL booked. New `Settlement` type and `settlements` table, deliberately **not** a `Fill` (no order, no counterparty, no fee); new `Strategy.on_settlement` hook and `/api/settlements`. Only a decisive YES/NO settles: `not_decisive` (11 of 100 sampled closed markets answer `0.5/0.5`), `not_a_binary_market`, `market_not_found` and `venue_unreachable` each leave the position untouched, one test apiece — settling on any of them would book a fabricated outcome against real money. Polled on its own asyncio task (every 300 s, ≤25 markets/cycle, 10 s per lookup, ask-then-apply so a settlement can never delay a tick or race a fill), tunable under a `settlement:` section of `config.json`. `emergency_close_all()` stops being the accidental settlement path: the runner settles **before** it, so a resolved position never gets unwound at a stale mark. **`state.db` bumps to v6 — delete it on upgrade.** **Numbers move**: `realized_pnl`, `daily_pnl`, `cash`, `unrealized_pnl`, `total_value`, exposure, `closed_trades`, `winning_trades`/`losing_trades`, `win_rate`, `profit_factor`, `avg_win`/`avg_loss`, `best_trade`/`worst_trade`, `total_return`, `total_pnl`, `max_drawdown`, `annualized_return`, `sharpe_ratio`, `sortino_ratio`, and the `pnl_positive` gate (unpassable by construction before, passable now). `total_trades` and `commission_paid` deliberately do **not** move. The trade log gains an `event` column (`fill`/`settlement`) and settlement rows carry `side="SETTLE"`. **Backtests do not settle unless you supply the truth** via `run(resolutions={cid: "YES"})` — the SDK will not infer a settlement from a terminal price, and DaaS's `resolution` column is derived from a Binance candle and is empty anyway (AUS-236) — so a replay holding unsettled binaries now warns loudly and reports `stats['settlement_modelled'] = False`. | Feature (settlement; numbers move; delete `state.db`) |
| 0.10.0  | 2026-08-24 | **`config.json` is now the only configuration file the SDK reads — `manifest.yaml` and `config.yaml` are gone as config sources.** `manifest.yaml` contributed exactly one consumed key (`risk`) and `config.yaml` contributed params `config.json` already beat on every collision; both are now unread. **They are inert, not deprecated** — no startup guard, no presence check, no filesystem probe, deliberately, and pinned by a test. Move the `risk:` block verbatim into `config.json`: same four keys, same semantics, same precedence (per-instance config > `config.json` `risk:` > `RiskGuardian` defaults). A workspace's SDK is fixed at creation, so a strategy reaches this release only via a new workspace + re-promote — nothing migrates in place. The AUS-204 unconfigured-risk WARN survives, now points at `config.json`, and names **every** limit left at a default instead of falling silent once any one is declared — so a bot whose limits still sit in an unread `manifest.yaml` is told it is on SDK defaults. Also: the `risk:` section now merges **per key** across both sources (a partial per-instance override used to discard the author's other limits wholesale), and the file-side section is **validated** like the overlay. `auspicium.config._load_yaml` (the SDK's own client settings) is a different function and is untouched. **No trading behaviour change, no `state.db` action.** | Breaking (config contract) |
| 0.9.21  | 2026-08-24 | **The SDK can now ask a venue whether a prediction market has settled, and to which side (AUS-167, Block 0).** New `auspicium.resolution` (the venue-agnostic answer) plus `BaseExchange.get_resolution(market)` — an optional adapter capability shaped exactly like `get_order_book`, overridden by `PolymarketExchange` against the public gamma-api and routed by `ProductionExchange`. **Nothing consumes it yet and no reported number changes**: this block ends at "the SDK can ask and get a trustworthy answer". Settlement itself — closing the position at 0 or 1, booking realized PnL, releasing exposure — is the next block, carries a `state.db` bump, and is deliberately absent here. **Why the venue and not our own database:** DaaS's `polymarket.markets.resolution` is **100% NULL across 10,247 rows**, its only writer is an Airflow DAG that is not deployed, and even deployed it would derive the outcome from *Binance candles* rather than Polymarket settlement (AUS-236). **Three states, never two:** `Resolution.YES` / `NO` / `UNKNOWN`, and the answer is always a `MarketResolution` object carrying a `reason` — never `None`, never a raise, and `bool(res)` raises `TypeError` on purpose so `if res:` cannot silently read an unknown as a resolution (this platform has six filed defects where absence and failure shared a representation). Ported from studio's production `polymarket_market.py`: the endpoint, the two-probe active-then-`closed=true` filter (gamma's default filter returns **zero rows** for a resolved market, verified live), the JSON-stringified `outcomes` / `outcomePrices` decoding, and the 24h/60s TTL. Changed for a long-running bot: the cache is **per-adapter, not a module global**, and bounded (studio's grows without limit, as does its parallel dict of `asyncio.Lock`s); a **transport failure is never negative-cached**, because it is a fact about the moment, not about the market, and a 5-minute blind window after a blip would delay a settlement we could otherwise see immediately; the long TTL is earned by a **decisive answer** rather than by `closed=true`; and the whole lookup is bounded by a **6 s budget** across both probes (connect 2 s, per-request 3 s) instead of an unbounded 2 x 5 s, because a bot must never die because gamma-api was slow. **A closed market is not automatically a resolved one:** 11 of 100 sampled closed markets carried `outcomePrices` of `0.5 / 0.5` — a tie, a void, or settlement in flight — and a three-outcome market is not expressible in complementary YES/NO at all; both answer `UNKNOWN` with a reason rather than crediting the first listed side. **Backtest cannot reach the network by construction** — `BacktestPaperExchange` holds no resolver and imports no venue client, and a full replay is asserted to issue zero HTTP requests. **Unverified:** whether a production bot container can egress to `gamma-api.polymarket.com`. Both bot networks are created `internal=False` and bots reach `clob.polymarket.com`, but a host firewall could block this specific name and that cannot be ruled out from code — so the first unreachable lookup WARNs once, naming the host and the egress question, and the answer is `venue_unreachable`, never `market_open`. **No `state.db` action** (schema stays v5).  |
| 0.9.20  | 2026-08-24 | **A live production bot was structurally unable to trade for eight hours and every channel that could have said so was silent (AUS-204).** `max_total_exposure_usd` is measured over **open positions**, and binary settlement is unmodelled (AUS-167), so a hold-to-resolution position never leaves the books and exposure only rises: seven consecutive hourly refusals at an identical `$200.85 would exceed $200.00` while the dashboard read Live / 100% win rate. Settlement is **not** modelled here (that is AUS-167) — the failure is made loud instead. Risk-class skips are **pinned into the heartbeat regardless of rank** (`risk_blocked` was buried under `top_skips[:3]`); three consecutive rejections of the same limit **escalate** to one WARN plus a new **`risk` block on `/api/status`** (`escalated`, `rejections_by_limit`, `exposure_basis`; `risk_breaches` was a hardcoded 0 and is now real) — **no auto-kill**, deliberately; a missing `manifest.yaml` or a manifest with no `risk:` section now **WARNs naming the SDK defaults** it silently put in force; and a correctly-spelled risk key at the **top level** of `config.json` / `config.yaml` — silently dropped, the third such path after AUS-128/AUS-136 — now warns. `RiskGuardian` publishes `EXPOSURE_BASIS` and says in its docstring and rejection message that on hold-to-resolution binaries the limit is a lifetime notional budget, not a concurrent-risk cap. **No trading behaviour change, no `state.db` action.** |
| 0.9.19  | 2026-08-23 | **A routine upstream deploy killed every live production bot: the WS reconnect budget was ~41 seconds against a 7–20 minute deploy cycle, and exhausting it exited 0 (AUS-229).** On 2026-08-22 a DaaS deploy closed the stream with code **1012** ("service restart"). Both live money-holding bots burned all ten reconnect attempts between 21:56:13 and 21:56:54, logged `Max reconnect attempts (10) exceeded`, and **exited cleanly, status 0** — so `RestartPolicy=unless-stopped` restarted them and, with `state.db` then on tmpfs, destroyed their ledgers (AUS-228). DaaS was serving `200 OK` again at 21:57:06, **twelve seconds** after the runner gave up. The retries were never an auth problem: 1012 / timeout / 503 / 502, never 401. **The nominal budget was ~210s and the real one was 20s**, and the gap is the actual defect: the healthy-connection reset ("don't penalise a bot that has been up for days after a blip") was re-evaluated on *every* attempt against `last_connect_time`, which is stamped only on a **successful** connect and was never cleared — so during an outage it still held the connect from hours earlier, the reset fired every time, and `delay` went back to 2.0s before every sleep. Exponential backoff was dead code for any bot up longer than 60 seconds: **the longer a bot had run successfully, the faster it gave up.** Measured from the loop, the shipped backoff was a flat `[2.0] × 10` where the constants promise `2, 3, 4.5, … 60`. Three changes: the termination condition is now a **duration, not an attempt count** (`_reconnect_max_window_s`, **30 min**) — ten attempts is not a knowable amount of time, and the requirement being met ("survive a planned 7–20 min upstream deploy") has to be audited in the unit it is stated in; **close code 1012 is recognised as planned maintenance** and gets `_reconnect_maintenance_window_s` (**60 min**), other close codes deliberately do not; and the healthy-connection judgement now happens **once per outage**, after which `last_connect_time` is dropped so no later attempt can be judged on it — a bot up for days still gets its gentle first retry, then escalates normally. Terminating on connection loss now exits **75** (`EX_TEMPFAIL`), and an unhandled loop error exits **70** (`EX_SOFTWARE`); a fatal condition reporting success is why nothing anywhere detected this — no alert, no event, no ❌, the operator's only signal was the dashboard going blank. The tick-freshness watchdog and the AUS-93 tier-cap retry loop are unchanged (that loop is still bounded by `_reconnect_max_attempts`, which remains the right unit there). **No trading behaviour changes and no `state.db` action** (schema stays v5). | Bugfix (live-bot survivability) |
| 0.9.18  | 2026-08-19 | **GTD expiry validation ran against WALL-CLOCK, so a `LIMIT_GTD` order could not be constructed in a backtest at all (AUS-222).** `Order` has no clock, so the model validator could only reach `datetime.now()`. In replay `self.now()` is the historical tick, so the honest idiom `expires_at = max(candle_close, self.now() + 90s)` is correctly ~90 s ahead **in the replay frame** and days in the past by wall-clock — rejected before the engine saw it. Measured: a 7-day GTD replay produced **1553 ValidationErrors and 0 trades**. The only workaround was `if self._backtest_mode:` branching in strategy code — the exact thing `Strategy.now()` exists to abolish, and it means the backtest stops exercising the order type being shipped. The clock-relative checks (lead, future, 30-day cap) moved to **`validate_gtd_expiry(order, now)`**, called at the submit boundary: `Strategy.submit` passes `self.now()`, `PolymarketExchange.submit` passes wall-clock. Structural checks (required on GTD, forbidden elsewhere, tz-aware) stay at construction. **This is also the correction to 0.9.17**, whose notes claimed its lead-time guard 'raises in backtest' — against wall-clock it was unreachable there. Also: **`binary_buy`/`binary_sell` now accept `expires_at`**, so building a GTD order no longer requires the raw `Order(...)` that golden rule #2 forbids. **No `state.db` action.** | Bugfix (backtest/live parity restored) |
| 0.9.17  | 2026-08-19 | **The SDK's GTD validation was weaker than the venue's, so a bad expiry failed ONLY in production (AUS-221).** `Order._validate_expires_at` required just `expires_at > now`; Polymarket applies a security threshold and answers `400 invalid expiration value, must be in the future for GTD orders` for a value that is future by the clock but too near. A strategy passed every local check, passed backtest — where the paper exchange never calls the venue — and failed only live, against real money. Measured on a live bot: `expires_at = candle_close` with an entry window admitting the final seconds of the hour, rejecting every order at ~15 POSTs/second. `Order` now requires a minimum lead time (**`AUSP_GTD_MIN_LEAD_S`, default 60 s, 0 disables**) so the failure lands in backtest instead. **The exact venue threshold is NOT verified** — 60 s is Polymarket's documented behaviour and matches the observed rejections, but no probe was executed, so the guard is tunable in both directions and does not claim to know the venue's rule. Also: **the expiry is now logged** — on submit and in the rejection message, with how far out it was. The venue's error does not echo the value it rejected and neither did we, so a bot could reject every order and leave no record of the number responsible; diagnosing it took reading the strategy's source. And **an empty filtered `get_trades` now retries unfiltered with a warning** — 0.9.15's market filter treated only exceptions as failure, so an empty result fell through silently and priced the fill from the quoted limit instead of the execution. **No `state.db` action.** | Bugfix (fails earlier, says more) |
| 0.9.16  | 2026-08-19 | **An order the poll loop could not resolve was re-polled forever, silently (AUS-217).** `check_pending_orders` removed an order from `_open_orders` on `MATCHED`/`CONFIRMED` or `CANCELLED`/`CANCELED`/`EXPIRED`/`FAILED` — and **anything else fell through with no log line at all**. Measured on a live bot: 15 orders submitted, 6 filled, **9 polled at 200 OK for up to 13 hours**, with `CANCELLED`/`EXPIRED` appearing **zero times** in its entire log. Each held an `_open_orders` slot against `max_open_orders` (SDK default **20** — a bot bricks itself in under a day) and was re-requested every tick, which a degraded venue then amplified into a platform outage. The silence was the worst part: nobody could learn WHICH status to handle, because the loop never named the one it saw. It now **reports** any unresolved status — once per status change, not per poll — with the order's age and the open-order count. **Retiring such an order is opt-in and OFF by default** (`AUSP_STALE_ORDER_MAX_AGE_S`, seconds; 0 = disabled), because a confident default would be worse than the leak: a GTC `LIMIT` has no expiry and a `LIMIT_GTD` may legitimately rest for weeks, so any age the SDK picked would cancel orders somebody meant to place. When enabled it **cancels first and only forgets the order if the venue confirms** — an order we failed to cancel may still fill, and forgetting it would hide that fill from the portfolio. **No `state.db` action.** | Bugfix (observability + opt-in cleanup) |
| 0.9.15  | 2026-08-19 | **A TAKER fill recorded the COUNTERPARTY's price (AUS-215).** `_order_execution_from_trades` priced our fill from `maker_orders[].price`, on the stated assumption that the per-maker legs are "true per-level prices". That holds when the maker sold the SAME token we bought; on a binary market's mint path the maker fills the other side by buying the **complementary** token, so their price is `1 - ours` and we recorded the complement. Measured on a live production bot: a fill that executed at **0.87** was recorded at **0.13**, and the cost basis read **$5.78** for a fill that cost **$39.01**. Confirmed three ways — the Polygon receipt (`OrderFilled` for our own order hash: makerAmountFilled 38.6628 / takerAmountFilled 44.44 = 0.87), Polymarket's public Data API (0.870000), and the operator's activity feed. The bot's logs also settle intent: it submitted `price=0.9000` on all 14 orders, so this was never an execution problem — the trade it asked for is the trade it got, mis-recorded. Our execution is now the trade's own top-level `price`; the maker legs are read only to CORROBORATE it (VWAP ≈ price → same-token; VWAP ≈ 1 − price → complementary; **neither → claim nothing** and let the caller fall back with a label). The maker branch, which selects our own leg by order id, was always correct and is untouched — every maker-branch fill in the incident recorded the right price and every taker-branch one recorded the complement. Also: **`get_trades` is now filtered to the market** (`TradeParams` supports it and the SDK passed nothing, scanning page one of the account's entire history — a bot's own recent trades eventually fall off it and the lookup silently returns nothing), and new **`Fill.price_source`** records WHICH rung of the price ladder produced `price`, so a consumer can finally tell a measured execution (`response_amounts` / `venue_trade_log`) from the price we merely asked for (`order_status_limit` / `requested`). `None` on pre-0.9.15 fills. **No `state.db` action.** | Bugfix (recorded prices change) |
| 0.9.14  | 2026-08-18 | **Fix — a LIMIT/GTD fill lost the strategy's order id; new `Fill.order_type`. `state.db` stays v5, do NOT delete it.** The order-status polling path — which serves every `LIMIT`/`LIMIT_GTD` order *and* every plain `MARKET` (FOK) whose POST response carried no two-sided amounts — recorded the venue's `0x…` hash as `Fill.order_id`, replacing the strategy's `<reason>:<uuid>` tag. That broke three things at once: the dashboard's REASON and exit-path badges (parsed off the tag) went blank; `StrategyRunner._on_fill` frees the open-order slot by `fill.order_id` on a dict keyed by `order.id`, so the pop missed and after `max_open_orders` leaked fills **the bot bricked itself** at the risk check; and `cancel()` was unreachable, since `Strategy.submit` discards the exchange id and the venue does not know `order.id`. The fill keeps the strategy's id; the venue id moves to the new `Fill.exchange_order_id`; `cancel()` takes either. New **`Fill.order_type`** records what produced the fill — stamped on every Polymarket path plus paper and backtest, so a trades table reads identically in a notebook and in a live bot. **`None` means "not recorded", never "MARKET"** (guessing from the `0x` shape is the AUS-192/AUS-198 defect — that shape cannot tell a maker GTD hash from a FOK CLOB orderID). The two columns are added in place by an additive migration, so no bot loses its portfolio state for a display field |
| 0.9.13  | 2026-08-14 | **Fix — live plain-MARKET (FOK) Polymarket fills recorded fiction; `state.db` → v5, delete it on upgrade.** Five real-money fills, five-for-five on-chain confirmation (2026-08-14): the SDK recorded `Fill.size = actual_shares ÷ order_price` (11.235954 executed → 12.2797 recorded), `Fill.price` = the tick-rounded quote (0.91 recorded, 0.89 executed) and `Fill.fee = 0.0` (chain collected $0.0770). The 0.9.0 "legacy converter" assumed the polling response's `size_matched` was USDC on BUY; it is **shares** on both sides, on every shape that carries it — no published `py-clob-client-v2` ever produced a USDC one, so the division is deleted. Executed price now comes from the response's two-sided amounts (a FOK that matches at submit is recorded there, like FAK) or the venue's trade log, with labelled fallbacks; fees are venue-reported or computed role-aware from the market's own `fd` schedule via `auspicium.fees` (the FAK path's hardcoded `fee=0.0` closed too). Recorded live numbers move — to the truth; backtest/paper unchanged |
| 0.9.12  | 2026-08-11 | **Fix — live orders on `Up`/`Down` Polymarket markets were impossible; no `state.db` action.** The Polymarket adapter matched an order's canonical `outcome` (`"YES"`/`"NO"` — all `Order.binary_buy` accepts) against the venue's token label by exact string equality, and Polymarket's hourly Bitcoin markets label their tokens `Up`/`Down`. Every live entry on one raised `OrderRejectedError: outcome 'YES' not found` — a production bot logged 260 raises and 0 orders. Exact match still wins; when it misses, the lookup now falls back to the pole vocabulary (`YES` ↔ Up/Over/Above/Higher, `NO` ↔ Down/Under/Below/Lower), refuses to guess when two tokens share a pole, and lists the market's actual labels when nothing matches. Same fix reaches `get_order_book` / `book_depth_at_or_better`. New `auspicium.outcomes` module holds that vocabulary once (three copies before); `pole_of("Up") == "YES"` converts a wire label in strategy code |
| 0.9.11  | 2026-08-09 | **Fix — `win_rate` counted fills, not round trips; the reported rate was about half the real one. No `state.db` action (stays v4).** Backtest and live `/api/status` both divided winning rows by every row of `_compute_trade_pnl`, a table with one row per **fill**; opening BUYs carry `pnl` exactly 0.0, so the reported rate **could not exceed 50%** and a true 146/165 = 88.5% strike rate printed as 44.2%. Now wins over round trips, with the counts published: new `closed_trades` / `winning_trades` / `losing_trades` stats keys and a new `closed_size` column on the trades table (shares an exit matched against open lots). `total_trades` still counts fills. No other number moves — `total_return` was correct all along |
| 0.9.10  | 2026-08-05 | **Fix — realized-PnL accounting; reported numbers move; `state.db` → v4, delete it on upgrade.** The `realized_pnl … diverges from FIFO gross` WARNING was a false positive (it reconstructed gross as `net + every fee`, so drift equalled the fee total); it now replays the persisted fills through the same `apply_fill` the runner uses. `_compute_trade_pnl` now sorts fills chronologically (the live feed is newest-first), keys queues by `(market, outcome)`, charges each closing trade its **entry** fee as well as its exit fee, and no longer mutates the caller's fills; new `gross_pnl` column. The runner deletes closed positions from `state.db` and rebuilds cash / realized PnL / positions from the fill log on start |
| 0.9.9   | 2026-08-01 | **Behaviour change.** New `auspicium.fees` — one role-aware, schedule-driven fee model shared by backtest and paper (market schedule → published category table → highest published rate; takers pay, makers pay 0 + rebate). Simulated Polymarket fees rise from a flat `0.02` to the resolved rate (**crypto `0.07`, 3.5×**); the new number is the correct one. Binance unchanged |
| 0.9.8   | 2026-07-30 | `Strategy.stake_amount()` — launch-time position sizing from the per-instance config (`stake_mode` fixed / `pct_cash` / `pct_equity`, `stake_pct`, floor, cap); returns `0.0` below the floor and clamps to the risk limit `max_position_usd` instead of failing at submit |
| 0.9.7   | 2026-07-29 | Per-instance risk-limit overrides: the platform-delivered instance config's `risk` section merges per key over `manifest.yaml`'s (instance config > manifest > `RiskGuardian` defaults); bad overrides warn and fall back instead of blocking a start. Changelog/README overhaul + drift guard |
| 0.9.6   | 2026-07-19 | Fix (live-bot reliability): symbol-less `subscribe()` on a symbol-keyed channel fails loudly at startup (was a silent 0-tick no-op); tick-freshness watchdog scales to the slowest ohlcv interval; runtime `TIER_LIMIT` on re-subscribe is recoverable, not fatal; disconnect reasons logged via `repr()` |
| 0.9.5   | 2026-07-16 | Fix: WebSocket URL now derives from `AUSP_GATEWAY_URL` (same host, `wss`, `/v1/ws`) instead of a hardcoded apex default; explicit `AUSP_WS_URL` still overrides                      |
| 0.9.4   | 2026-06-28 | `RestConnector.address_trades` accepts a `period` preset (`"7d"`/`"30d"`/`"all"`), mirroring `address_stats` |
| 0.9.3   | 2026-06-17 | Packaging: bundle `auspicium/CLAUDE.md` + `auspicium/CHANGELOG.md` inside the wheel — no runtime/API change  |
| 0.9.2   | 2026-06-09 | Billing: `account.me()`, `billing.create_checkout/create_portal`, `DaasRateLimitError`; HTTP 429 is surfaced immediately (no auto-retry) |
| 0.9.1   | 2026-05-22 | Tests-only patch; runtime code identical to 0.9.0 (recommended pin over 0.9.0 for the cleaner sdist)         |
| **0.9.0** | **2026-05-22** | **BREAKING:** `Fill.size` for Polymarket BUYs is now shares (was USDC). New `Fill.notional_usd`. state.db must be deleted on upgrade. |
| 0.8.0   | 2026-05-19 | Fix: `ProductionExchange.get_order_book` now delegates to the polymarket adapter (was returning `None`)      |
| 0.7.20  | 2026-05-19 | `MARKET_FAK` / `LIMIT_GTD` order types + `get_order_book` + `Strategy.book_depth_at_or_better`                |
| 0.7.19  | 2026-05-18 | Fix: `Strategy.submit()` pops `_open_orders` on `exchange.submit()` failure                                  |
| 0.7.18  | 2026-05-17 | Polymarket WS payload carries market metadata (`candle_open/close`, `active`, …). `BinaryMarketAggregator`. Heartbeat silent-reject WARN. |
| 0.7.17  | 2026-05-16 | Fix: `BacktestPaperExchange._positions` re-keyed by `(market, outcome)` — YES + NO no longer net             |
| 0.7.16  | 2026-05-15 | `RestConnector.address_stats` accepts arbitrary windows via `from_ts` / `to_ts`                              |
| 0.7.15  | 2026-05-15 | Copy-trading SDK (`address_trades`, `address_stats`, `tracked_addresses`, `address` WS channel, notebook helper) |

## Releasing

Releases are cut from `main` and published to public PyPI by Cloud Build
(project `auspicium`). CI ([cloudbuild.ci.yaml](cloudbuild.ci.yaml)) runs
`ruff` + the unit suite on Python 3.11 and 3.12 for every push to `dev` and
every PR; the release pipeline ([cloudbuild.release.yaml](cloudbuild.release.yaml))
runs only on `v*` tags and publishes via PyPI Trusted Publishing (OIDC) — no
PyPI credential is stored anywhere.

To ship a change as, e.g., **0.9.5**:

1. Develop on `dev`.
2. In the **same change** as the feature/fix, bump `auspicium/_version.py` →
   `"0.9.5"` and add the entry to **both** changelogs — the repo-root
   [`CHANGELOG.md`](CHANGELOG.md) and the bundled
   [`auspicium/CHANGELOG.md`](auspicium/CHANGELOG.md) (including its
   "Current pin" line). Keep the two in sync: the bundled copy is frozen
   into the wheel at tag time and is what a stack pinned to this version
   reads forever (same for `auspicium/CLAUDE.md`, the bundled operator
   guide — update it if behavior it describes changed).
   SemVer: **patch** = bugfix, **minor** = feature, **major** = breaking.
3. Push `dev` → CI runs pytest + ruff. Nothing is published.
4. **Human** merges `dev` → `main` (PRs into `main` require green CI).
5. **Human** tags and pushes the tag — **tags are human-only; agents never
   create or push tags**:

   ```bash
   git tag v0.9.5
   git push origin v0.9.5
   ```

6. The release trigger then:
   - **guards**: fails the build if the tag (`v0.9.5`) ≠ `_version.py`
     (`0.9.5`), so a mislabeled wheel can never ship;
   - re-runs the unit suite;
   - builds `auspicium-0.9.5-py3-none-any.whl` + `.tar.gz`;
   - uploads to PyPI with a short-lived OIDC-minted token.
7. `pip install -U auspicium` now resolves 0.9.5.

**PyPI versions are immutable.** A version number uploads exactly once and
can never be re-uploaded — even after deleting it on PyPI. If 0.9.5 ships
broken, fix it by releasing **0.9.6**; never try to re-tag or re-upload
0.9.5 — a wrong tag burns the version number permanently. (This is also why
the tag-vs-version guard exists: get the bump in *before* tagging.)

### Versions are live runtimes (stacks)

Platform users run the SDK through **stacks**, each pinned to a released
version. The **default stack** runs whatever version is **baked into the
Studio workspace image** (`auspicium/workspace:latest`) — it does *not*
track PyPI automatically. Consequences:

- **Publishing makes a version *available*, not *live*.** Uploading
  `vX.Y.Z` to PyPI is step one. It reaches default-stack users only after
  the studio repo bumps its `AUSPICIUM_SDK_VERSION` pin (`.env`) and
  rebuilds the workspace/bot images — a separate, gated flow (tracked as
  AUS-42). Users who want a published version sooner can create a custom
  stack pinned to it.
- **Old versions never die.** Any released version remains a selectable
  stack runtime. Never yank or delete a PyPI release — existing stacks pin
  exact versions (and deletion wouldn't free the number anyway).
- **The changelog is the version picker.** Stack owners choose a pin from
  the At-a-glance table — headlines must be accurate, and breaking changes
  must be flagged explicitly.

### History note — do NOT retro-tag ≤ 0.9.4

This repo currently has **no git tags**: releases 0.8.0 → 0.9.4 on PyPI were
published outside the tag-triggered pipeline, and the release guard has never
fired. Do **not** create tags `v0.9.4` or earlier "to backfill history" —
each would trigger a doomed re-publish of an already-immutable version and a
red build. The next tagged release is the **first live run** of this
pipeline: watch it end-to-end.
