Metadata-Version: 2.4
Name: auspicium
Version: 0.12.2rc3
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.12.2rc3 | 2026-10-13 | **Pre-release — not a published `0.12.2`; supersedes `0.12.2rc2`.** `pip install auspicium` does not select it (`==0.12.2rc3` or `--pre`). **Multi-venue instrument descriptor:** every bot state-API row gains a nested `instrument` (venue, kind, full-precision `qty`, fee asset); Polymarket rows are otherwise unchanged. **Binance:** unsellable BUYs refused with the minimum viable stake (`UnsellableEntryError`, `Strategy.min_viable_stake`); half-even `quoteOrderQty` (`Order.spot_buy`); unsellable residuals held as dust (`portfolio.dust`, `on_dust`); exits below the venue minimum logged once (`exit_blocked`). Schema stays v7 |
| 0.12.2rc2 | 2026-10-11 | **Pre-release — not a published `0.12.2`; supersedes `0.12.2rc1`.** `pip install auspicium` does not select it (`==0.12.2rc2` or `--pre`). **Binance pairs quoted in anything but USDT/BTC/ETH/BNB were sent to Polymarket — EEA accounts could not trade Binance at all.** `is_binance_market()` knew four quote assets, so `BTC-USDC`, `ETH-EUR`, `BTC-FDUSD` routed to Polymarket in production, paper, fees, the backtest close type and resolution. EEA (EU) accounts cannot trade USDT pairs, so for them every Binance order went to the wrong venue. A dashed `BASE-QUOTE` is now Binance when QUOTE is any of the 50 quote assets in Binance's public `exchangeInfo` (recorded 2026-10-11). Undashed `BTCUSDC` / `ETHEUR` are refused like `BTCUSDT`. **`BinanceExchange.get_balance()` sums free USDT + USDC + FDUSD + USD1 (1:1)**; it read USDT only, so an EEA account reported 0. Polymarket ids route as before |
| 0.12.2rc1 | 2026-10-10 | **Pre-release.** **Series follows (AUS-505):** `Strategy.track_series(series_ref)` follows a recurring market family once and streams whichever occurrence is live through one `scope="series"` subscription (→ `SeriesBook`). `resolve_market()` gains `.series`; new `follow_series()`, `unfollow_series()`, `followed_series()`, `series_detail()`, `series_markets()`, `wait_until_series_ready()`; typed `SeriesNotFollowableError`. Additive only; existing frames byte-identical. **No `state.db` action** (schema stays v7). **Binance spot orders:** quantities and prices are checked against the symbol's `exchangeInfo` filters (new `auspicium.exchange.binance_filters`), floored, never rounded up, and sent as fixed-point strings; below-minimum orders fail before sending. `Strategy.cancel` now reaches Binance orders (plain, stop, OCO, trailing), and Binance fills carry `order_id=order.id` with the venue id in `exchange_order_id` (AUS-210 follow-up). Fees netted by `commissionAsset` (base-asset commission subtracted from `Fill.size`; BNB logged, not converted). Undashed `BTCUSDT` is refused in production and paper. OCO uses `orderList/oco`. Adapter carries `market_type` (spot only). |
| 0.12.1 | 2026-10-08 | **Stable release; `pip install auspicium` now resolves here.** Everything in `0.12.1rc1` and `0.12.1rc2`, plus text corrections only. **`unfollow_market()` no longer raises `JSONDecodeError` on DaaS's 204** (AUS-485). **Readiness waits default to 1800 s (was 600 s)** and the timeout error says what is still happening (AUS-486); pass `timeout=600` to keep the old behaviour. **Polymarket-only, copy-trading and tracked-market bots no longer call `/v1/ohlcv` on every chart poll (AUS-170)**; the chart gains an additive `primary_status` dict whose `state` is `built` / `empty` / `failed` / `not_applicable`. The readiness figures are corrected to observations from quality (median ~5 min; slowest seen 28 min), not a guarantee. No API removed or renamed. **No `state.db` action** (schema stays v7). See CHANGELOG § 0.12.1. |
| 0.12.1rc2 | 2026-10-07 | **Pre-release — not a published `0.12.1`; supersedes `0.12.1rc1`; the official `0.12.1` is cut from the last candidate.** `pip install auspicium` does not select it (`==0.12.1rc2` or `--pre`). Two fixes on top of `0.12.1rc1` (whose AUS-485 fix is included unchanged). **Readiness waits now default to 1800 s (was 600 s) (AUS-486)** — `wait_until_ready()`, `track_market()` / `track_event()` with `wait=True`, `load_market()` / `load_event()`: a call that used to fail at 10 min can now block up to 30 min; pass `timeout=` to keep the old behaviour. 1800 s is not a guarantee (the readiness figures this row first quoted are corrected under `0.12.1`), and an event shares one wait across all its markets. The timeout error now says what is still happening. **Polymarket-only, copy-trading and tracked-market bots no longer call `/v1/ohlcv` on every chart poll (AUS-170)**; the state API's chart gains an additive `primary_status` dict whose `state` is `built` / `empty` / `failed` / `not_applicable`; `chart.primary` unchanged. No API removed or renamed. **No `state.db` action** (schema stays v7). |
| 0.12.1rc1 | 2026-10-07 | **Pre-release — not a published `0.12.1`; the official `0.12.1` is cut from the last candidate.** `pip install auspicium` does not select it (`==0.12.1rc1` or `--pre`). **`unfollow_market()` raised `JSONDecodeError` on every call — after the unfollow had already succeeded (AUS-485).** DaaS answers `DELETE /v1/markets/follow/{venue}/{market_ref}` with `204 No Content`, and `RestConnector._request` parsed every 2xx as JSON; a 204 now returns `None`. Affects anyone calling `unfollow_market()` on `0.12.0`. The docstring is corrected too: the call is **idempotent** (DaaS answers 204 whether or not a follow existed) and never raised the `MarketNotTrackedError` it documented. No other behaviour change; no API removed or renamed. **No `state.db` action** (schema stays v7). |
| 0.12.0 | 2026-10-06 | **Stable release — the first since `0.11.2`.** Everything in `0.12.0rc1`–`rc6` plus **`max_daily_loss_usd` now counts fees (AUS-303)**. ⚠️ **Delete `state.db` coming from `0.11.x` or `0.12.0rc1`** (schema v6 → v7; no action from rc2–rc6). ⚠️ A live bot that has already paid more than its `max_daily_loss_usd` in fees today **auto-kills on its first tick after restarting onto 0.12.0** and stays halted until 00:00 UTC — upgrade just after 00:00 UTC. Backtests of fee-heavy strategies with a daily limit change. See CHANGELOG § 0.12.0. |
| 0.12.0rc6 | 2026-09-28 | **Pre-release — not a published `0.12.0`; supersedes `0.12.0rc5`; the official `0.12.0` is cut from the last candidate.** **`wait_until_ready(require_coverage_from=…)` no longer polls for ten minutes on a backfill that has already stopped (AUS-386).** It asked `coverage_complete is True`, which is not the question: DaaS derives that field FROM the terminal reason and is three-valued on purpose (`depth`/`exhausted` → `true`, `offset_capped` → `false`, `error` → `null`), so the two conditions meaning *stopped and we do not hold it* reported `false` and `null` — indistinguishable, on that field alone, from a backfill still in flight. Both fell through and polled to the 600 s default, then raised `MarketNotReadyError`: the wrong error, ten minutes late, on every `load_market()` / `track_market(wait=True)` of such a market. The predicate is now `backfill_terminal_reason is not None` (new `Coverage.is_terminal`), plus `ended` + `never_ticked` (new `TrackedMarket.coverage_is_terminal`) — no live tick can follow the end of a market. **A backfill with no terminal reason still waits**, unchanged. `CoverageError` now names the terminal reason, a plain-English gloss of it, and the platform's own `state_reason`, which on an `offset_capped` / `error` market is the only place the explanation exists. **D7 liveness fields parsed** (AUS-344): `TrackedMarket` / `OutcomeSpec` gain `never_ticked` and `last_row_at` (`extra="ignore"` was silently dropping both), with `has_never_ticked` so nothing infers "never" from a `last_tick_at` that is merely old; `flowing_threshold_seconds` is read from the payload (D7 raised it 120 → 180) and no liveness threshold is hard-coded anywhere — pinned by a test that greps the package; `MarketBook` carries all three through, answering `None` (not `False`) on a route that does not serve them. **`MarketSpec.short_title` / `.slug` and `MarketEvent.slug` parsed** (AUS-382) — carried by the server since D6b and dropped until now. **`MarketBook.label` resolves `short_title → follow_label → title → market_ref`**, and `EventBook[...]` matches all three names: `follow(all=true, label=…)` writes ONE label onto every sub-market, so preferring it collapsed an N-market event to a single repeated key and `probabilities()` lost sub-markets rather than merely mislabelling them. `EventBook` adds a uniqueness guard — if the preferred labels collide, every sub-market falls back to its title, all-or-nothing. **`market_prices(bucket="raw")` no longer raises on sub-second timestamps**: pandas infers a strptime format from the first row and applies it strictly, and one raw page genuinely mixes precisions because pydantic emits the fractional part only when microsecond is non-zero — so the failure depended on which row came first. Parsed precision-agnostically at all three sites. **`load_market()` column order is YES-first** (`ordinal` for a names-labelled market); `pivot_table` sorted alphabetically, so `df.iloc[:, 0]` was silently the NO series. **`/api/status` reports the real environment** from `get_config().env` — it emitted the literal `"production"`, so a quality paper bot announced itself as production. **The design-mode refusal names a path a workspace user can take** (deploy as a quality bot) instead of an environment variable they cannot set — and **`AUSPICIUM_ENV` is read NOWHERE in the SDK**: the variable is `AUSP_ENV`, and the wrong name shipped in that message, in six docstrings and in the run instructions of BOTH bundled examples, where `AUSPICIUM_ENV=quality python -m auspicium.strategy.runner` cannot work. All renamed. Seam fixtures re-copied byte-identically from post-D7 `quality`. **Tested locally only — nothing in this entry was executed against quality.** No `state.db` action. | Fix (additive; pre-release) — **no `state.db` action** |
| 0.12.0rc5 | 2026-09-20 | **Pre-release — not a published `0.12.0`; supersedes `0.12.0rc4`; the official `0.12.0` is cut from this candidate.** **Tracked markets (AUS-345): follow any Polymarket market or event on request and stream it.** Additive, every default unchanged. New `"market"` WebSocket channel (`subscribe("market", source=<venue>, symbol=<market_ref>)`, every outcome in one subscription) with typed `StreamMessage.venue` / `market_ref` / `outcome_ref` / `outcome`; `rest.resolve_market()` / `follow_market()` / `unfollow_market()` / `followed_markets()` / `market()` / `market_status()` / `market_prices()` (auto-paginated, loud ceiling) / `wait_until_ready()`; `Strategy.track_market(url) -> MarketBook` and `track_event(url) -> EventBook` (`.probabilities()`, `self.fed["<title>"]`, `.yes` / `.no` / `.outcome_ref(label)`); `notebook_helper.load_market()` / `load_event()`, blocking by default with a coverage guard that **refuses** a window the platform does not hold (`allow_partial_coverage=True` to warn instead). Trading stays binary through the AUS-185 poles; a names-labelled market (esports) is ordered by token id. Seam-tested against the DaaS D6 payload fixtures (daas `e2188a9`); not yet executed against quality. No `state.db` action. |
| 0.12.0rc4 | 2026-09-10 | **Pre-release — not a published `0.12.0`; supersedes `0.12.0rc3`; the official `0.12.0` is cut from this candidate. No `state.db` action from rc2/rc3 (schema stays v7).** **The SDK can now reach the 15-minute Polymarket markets (AUS-313).** DaaS serves `1H` and `15M` markets and defaults to `1H` wherever the SDK omitted it, so a 15-minute market could only be listed, loaded or streamed by bypassing the public API. Additive, every default unchanged: `rest.markets(*, interval=, limit=)`; new `rest.polymarket_ohlcv(base_asset, *, interval=, granularity=, hours=, from_=, to=, limit=, …)` (single request, clamped at 10,000 bars — no auto-pagination in rc4) plus `PolymarketBacktest.load_polymarket_ohlcv()`; `ws.subscribe(market_interval="15M")` is finally sent on the wire (rc3 accepted it and dropped it), `ws.unsubscribe(*, interval=, market_interval=)`, and the strategy runner forwards `market_interval` when declared; new `StreamMessage.market_interval` from the server's top-level echo; `Strategy.find_active_market(pattern, *, interval=)` now returns `candle_open` (was `None`) and matches `"BTC"` by `base_asset`; `prepare_price_matrix(labels=)` takes caller-supplied outcomes. Frames and requests for callers that pass nothing new are byte-identical to rc3. Verified live on quality through Kong. Planned, blocked on DaaS AUS-299: `cross_market(interval=)`, `resolution_rule`, `load_training_set`. |
| 0.12.0rc3 | 2026-09-08 | **Pre-release — not a published `0.12.0`; supersedes `0.12.0rc2`.** **No `state.db` action upgrading from rc2 — the schema stays v7**; from `0.12.0rc1`, `0.11.x` or earlier it is still v6 → v7, so rc2's mandatory delete applies. Four bugfixes, three of them in idioms the bundled operator guide itself taught. **(1) The guide's own book-preflight idiom submitted dust, and nothing logged the size it threw away (AUS-223).** §4 taught `order.size = min(order.size, depth)` followed by `if order.size > 0:`, and `> 0` is the wrong question — it is true of any surviving sliver, so a book that could fill 1.23 of 575.374 wanted shares produced an order rather than a skip. Measured on a live production bot on 2026-08-20, three orders in 3h36m *(bot container log, quality studio host)*: truncations of **467.8×**, 4.15× and **28.7×**, deploying **$138.42 of $1,500 intended** — a 9.2% capital deployment rate, with order 1 paying $0.0097 in fees to acquire **$1.07** of exposure. The operator's dashboard read `stake=$500.00` and was right: that line prints the *pre-truncation* figure, and nothing recorded the depth, the pre-truncation size, or the post. New **`Strategy.fit_to_depth(order, depth, *, min_usd=None) -> bool`** replaces the idiom — it truncates, then asks whether the remaining **notional** is worth trading, and logs wanted / available / submitted so the decision is legible afterwards. **It takes an `ExecutionQuote`, and that is the substance of the fix, not a convenience:** `book_depth_at_or_better` returns `0.0` both when the book is empty and when no adapter could be asked, two facts a float cannot separate — so `available is None` **does not truncate** and returns `True` (a FAK already fills only what exists), while `available == 0.0` skips with `no_book_depth`, kept deliberately distinct from `book_too_thin` because "nobody is offering" and "the offer is not worth the fee" have different fixes. The floor reads config, not a constant — `min_usd` defaults to `stake_min_usd` ($1 if unset) — and ⚠️ **that default would not have caught the $1.07 order**: the live bot never set `stake_min_usd`, so $1.07 cleared the $1.00 default by seven cents. The floor is only as good as the config, which is why it derives from config. New optional **`min_order_usd` risk limit, default `0` (off)**, and ⚠️ **it is reduction-aware, which is load-bearing**: a real `close_position()` residual leg is a **$0.90 SELL** (2.0 sh @ 0.45), so a naive floor would re-break AUS-131 and strand a position on a live bot — a pure reduction (`opening == 0`) is exempt, an order that reduces *and* opens is still charged. Because `0` means off, it is excluded from the AUS-204 "UNCONFIGURED" startup warning. The **`stake_max_usd` clamp is also no longer silent** — it halved $1,000 to $500 on the live bot and printed nothing while the adjacent `max_position_usd` clamp announced the identical action at WARNING. **(2) A backtest that produced nothing could not say why (AUS-304).** The §4 idiom, run verbatim through the real engine, produced **zero trades over 200/200 bars with flat equity** *(local, 200 bars, real engine + `BacktestPaperExchange`)*. A warning did fire — the issue's original "no warning" claim was false and was corrected before the fix — but it lands on **stderr, unprefixed**, through `logging.lastResort`, so anything keeping stdout only (`> out.txt`, the saved JSON, the Studio UI) saw a clean, silent zero. **The strategy had recorded the cause correctly the whole time and the backtest threw it away:** the idiom's own `else` branch calls `log_skip("no_book_depth")` on every bar and the live runner surfaces exactly that counter in its heartbeat, but `backtest/engine.py` contained **zero `_skip_counts` references** and there was no skip key in `stats()` (22 keys) or `to_dict()` (9 keys) — and `to_dict()` is what workspace-api returns to the frontend. So the operator read `No trades recorded.` / `0.00%` as *"no signal"*, the one conclusion the run does not support. `BacktestResult.skip_counts` now reads off the same attribute the live heartbeat reads, so a skip reason means the same thing in a backtest as in production; `stats()` gains **`total_skips`** and **`skip_reasons`** (22 → 24 keys) and `to_dict()` gains top-level **`skip_counts`** (9 → 10 keys), top level and not only inside `stats` because this is what the UI needs precisely when `stats` is all zeros. **Always present on every path including the empty-equity one** — a zero-trade run is the case these keys exist for, so they cannot be the ones that go missing exactly when there is nothing else to report. **§4 of the bundled guide is rewritten** to teach `fit_to_depth` + `execution_quote` and to name the old idiom as the defect it is; measured before and after on the same 200-bar series, the old float `min()` / `> 0` idiom gives **0** trades and `{"no_book_depth": 200}` while `fit_to_depth` + `execution_quote` gives **1** trade and `{"already_long": 199}` — the old zero is now *explained* rather than silent, and the new idiom trades. (`grep -ci execution_quote auspicium/CLAUDE.md` was **0** before this change, while the CHANGELOG and README documented it at length.) **Not fixed here, and worth more than either fix:** the bundled guide has no test that executes what it teaches — two of its shipped idioms were found non-functional on the same day. Tracked as AUS-274. **(3) The GTD minimum-lead floor was the venue's SHAVE, not its FLOOR: 60 → 180 (AUS-221, reopened).** `AUSP_GTD_MIN_LEAD_S` shipped with a default of 60 in 0.9.17 and 60 is wrong — off by 3×. Polymarket documents two separate rules and 0.9.17 read the second as the first: "the expiration must be at least **3 minutes** in the future — orders expiring sooner are rejected" is the **floor** (**180 s**, which is what this knob is), while "GTD orders expire **one minute before** their stated expiration as a security threshold" is the **shave** (60 s, a different quantity entirely); the two compose as `now + 60 + N`. So a guard whose entire purpose was to stop an order failing *only* in production accepted a 61-second lead the venue rejects, and the operator guide shipped alongside it taught `expires_at=max(close, self.now() + timedelta(seconds=90))` — **below the floor and therefore rejected unconditionally.** That idiom could not work and never could; anyone who copied it out of §3 has been sending orders the venue refuses. ⚠️ **The number is DOCUMENTATION-DERIVED, NOT MEASURED, and that label is part of the fix** — 180 is the same *class* of evidence that produced the wrong 60, and shipping it unlabelled would set up 0.13 to repeat 0.9.17's mistake exactly. Four things distinguish it from its predecessor, none of them a measurement: it is read from the venue's live documentation today rather than recalled; it is arithmetically self-consistent with the shave (60 shave + 120 effective is the 3 minutes, and the `now + 60 + N` formula only closes with a 180 floor); **NautilusTrader's Polymarket adapter implements the same 3-minute floor** independently; and it is consistent with the one live measurement we hold, which **definitively refutes 60** — the 2026-08-22 canary submitted rungs at 15/30/45/60/75/90/120 s with the guard disabled and got `venue_rejected = [15, 30, 45, 60, 75, 90, 120]`, `venue_accepted = []`, where a 60 s floor predicts 75/90/120 would be accepted. The old default was already contradicted by evidence the team had held for two weeks. **Be precise about what that canary does NOT show:** it rules **60 out**; it does **not** single **180 in**. The ladder accepted *nothing*, so it is unbracketed — the probe's own record says verbatim *"NOT BRACKETED — do not infer a number from this run"* — and every value ≥ 120 predicts the observation exactly as well as 180 does. 180's support is the first three points; the canary's contribution is purely **eliminative**. AUS-221 additionally holds two other explanations open, neither excluded: that the rejection **is not about lead time at all** and the venue's error message is misleading, or that it is specific to those probe orders (far-price, minimum size). The agreed next step is a wider ladder (5 min, 15 min, 1 h, 6 h, 24 h), which **has not been run**. **Treat 180 as the best-supported candidate, not as a measured threshold**, and anyone who executes a real probe should replace the constant and the test that pins it with the measurement — both say so in place. Also collapsed a latent drift: the module held the default *twice*, as the string `"60"` in the `os.environ.get` default and the float `60.0` in the `except` branch, two literals nothing forced to agree; they are now one `_GTD_MIN_LEAD_DEFAULT_S`. **NOT fixed here, and it is a real defect: because of the 60 s shave, `expires_at = close` dies roughly a minute BEFORE the close, not at it.** The correct shape is `close + 60` floored at 120, but that changes what lands at the venue and is gated on a live canary that has not been authorised; it is documented in `models.py`, in the operator guide and in the changelog, and deliberately not implemented. Do not "fix" it by lowering `AUSP_GTD_MIN_LEAD_S`, which controls a different thing. A strategy that already floors at ≥180 s is unaffected; one that floored at 60–179 s was having its orders rejected by the venue and will now be told so locally, in backtest, by a `ValueError` naming the number — which is the whole point of the guard. **(4) A retired order never gave back its `max_open_orders` slot (AUS-217).** An order that left the venue's books without filling freed the adapter's polling roster and kept its risk slot forever, so after `max_open_orders` (SDK default **20**) such orders every later `submit()` failed the risk check and the bot stopped trading — the pre-0.7.19 self-bricking shape, arriving through a fourth door after AUS-210 closed the third. **There are two dictionaries named `_open_orders`, and 0.9.16 fixed the other one:** the adapter's (`PolymarketExchange._open_orders`, keyed by the venue's exchange id) is the polling roster, while `Strategy._open_orders` (keyed by the strategy's `order.id`) is the one `RiskGuardian.check` counts against `max_open_orders` — and the only adapter → strategy bridge was `_notify_fill`, but a retirement emits no fill. Measured through the real runner on 0.12.0rc2, every non-fill retirement (`CANCELLED` / `CANCELED` / `EXPIRED` / `FAILED`, and an unresolved order aged out and cancelled) left the adapter dict at 0 and the strategy dict at **1 — leaked**; at `max_open_orders=3` with three orders the venue reported `LIVE` forever, the 4th submit raised `RiskLimitError` **whether `AUSP_STALE_ORDER_MAX_AGE_S` was unset or set to 60**. **The knob worked perfectly and the bot bricked identically** — which inverts the reopened issue, whose premise was that an operator who could set that knob could stop the leak. They could not: it retires the order from the polling roster, which is not the dictionary that bricks anything. **The fix is a second bridge.** `BaseExchange` gains `_notify_order_retired(order_id, reason)` alongside `_notify_fill`, registered by the runner exactly like the fill callback and propagated by `ProductionExchange` to both sub-adapters, including ones it builds lazily (which is every adapter a real bot uses). An adapter now owes the runner exactly one message per order that leaves its books — it EXECUTED, or it was RETIRED — and the runner frees the slot and logs at WARNING naming the order and the reason. `order_id` is the **strategy's** id, never the venue hash: passing the hash here is AUS-210 through a third door, and a mutation test pins it. **The venue-terminal half needs no configuration and is unconditional** — a `CANCELLED` or `EXPIRED` order is dead by the venue's own report, and that branch had been leaking on every terminal order since long before the stale-order work, so **no studio change is required for it**, which the reopened issue's comment argues the opposite of. **Retiring an order the venue never confirmed dead is still opt-in and still OFF by default**: a GTC `LIMIT` has no expiry, a `LIMIT_GTD` may legitimately rest for weeks, and forgetting an order that later fills hides a position the bot does not know it holds — the age-out still cancels first and keeps anything the venue refuses to cancel, now including its risk slot. **`max_open_orders` is unchanged, deliberately** — the cap's shape is right, the dictionary's membership was wrong, and repairing membership makes the existing cap mean what its docstring always said. **Both venues, one change:** `BinanceExchange` had the identical leak on its terminal-status branch and on its OCO registry, and both are fixed here, because leaving one adapter repaired while its twin keeps the defect is the AUS-281 shape. Still open and documented in place: `BinanceExchange._resp_to_fill` puts the venue's `orderId` in `Fill.order_id`, so a Binance **fill** leaks its slot for the AUS-210 reason rather than this one; it needs a venue to verify against and no Binance strategy runs in production today. **No `state.db` action and no API removed or renamed in any of the four** — `fit_to_depth`, `min_order_usd`, the new skip keys and `set_order_retired_callback` are all additive, and `set_order_retired_callback` defaults to `None`, so an adapter or embedder that never registers one behaves exactly as before. |
| 0.12.0rc2 | 2026-09-03 | **Pre-release — not a published `0.12.0`; supersedes `0.12.0rc1`.** ⚠️ **Delete `state.db` before running a bot on this version — schema v6 → v7.** **(1) Stopping a bot never sells, on either venue (AUS-233 + AUS-284).** `BinanceExchange.emergency_close_all` never consulted `_positions`: it called `get_account()` and market-sold **every** non-stablecoin balance with `free > 0`, so its blast radius was the scope of the **API key, not the bot** — a coin bought by hand, or a second bot sharing the key, was liquidated by an ordinary stop, and it fires on SIGTERM, on plain shutdown **and** on `RiskGuardian.should_auto_kill`. The sell is **deleted, not scoped**: scoping needs a position store the adapter does not have, and it would still sell filled positions, which is the part the operator ruled against. `PolymarketExchange.emergency_close_all` loses its FOK-at-0.01 sell loop under the same ruling, though it already sold nothing in practice — it iterated `self._positions`, which nothing in `exchange/` has ever assigned into, so the loop ran over an empty dict for six minor versions behind a test that hand-injected the entry. **The cost is real and does NOT mirror across venues:** Polymarket markets *resolve* and the venue's relayer redeems on-chain independently of the bot (`redeemPositions` is `nonpayable` with no expiry, so unclaimed value is locked, not lost), but a Binance spot balance never resolves and nothing redeems it — so **stopping a Binance bot now leaves live spot exposure the operator must flatten themselves**, including on the auto-kill path, which now stops the *bot*, not the *exposure*. Stopping also cancels OCO brackets and trailing stops, which were previously `.clear()`ed locally while left **live at the venue**; that is deliberate, because an abandoned stop resting against a held position is a stopped bot selling what it told you it was holding. **(2) `fills.id` silently discarded real fills (AUS-233).** `StateManager.record_fill` passed `fill.order_id` as BOTH the primary key and the `order_id` column under `INSERT OR IGNORE` and threw the cursor away, so two *distinct* fills sharing an order id collapsed into one row with no error, no log line and no counter — reachable through `PaperExchange.emergency_close_all`'s deterministic `emergency-<market>-<outcome>`, and paper is the quality environment. **Nothing looks wrong at the time**; the loss surfaces on the NEXT restart, when `_restore_state` replays a log with an exit missing and resurrects a phantom position that carries false exposure into every risk check. Reproduced through the real runner, real `PaperExchange` and a real on-disk `state.db`: two entry/exit cycles persisted **3 rows instead of 4**. A fill is now keyed by a **content hash** of every recorded field, `record_fill` returns whether the row was new, and the runner WARNs when it was not. `fills` also gains `price_source`, a field on `Fill` since 0.9.15 that was never persisted. **(3) `max_daily_loss_usd` was a LIFETIME limit in any process that ran past 00:00 UTC (AUS-233).** It **fired too early, not never** — a lifetime loss is more negative than today's, so `check` halted new entries and `should_auto_kill` killed the process outright for money lost on an earlier day. Measured: four fills across midnight left `daily_pnl` at **-11.00 instead of -1.00**, and auto-kill returned True against a $5.00 limit. `Portfolio` now carries `daily_pnl_date` and one shared rollover implementation used by both writers and the restore path, and the tick loop rolls an **idle** bot on the clock rather than judging it against yesterday until it happens to trade. **(4) Every `*_sync` wrapper raised in the notebook it was written for — all four (AUS-281).** They reached `asyncio.run()`, which refuses to start while a loop is already running on the thread, and a Jupyter kernel always has one, so the single documented notebook entry point for a backtest raised `RuntimeError` before doing any work — reproduced by an operator on `0.12.0rc1` in a real workspace, calling exactly what the docstring said to call. `run_copy_strategy_backtest_sync` *appeared* to have solved it with a `new_event_loop()` fallback that never worked, while advertising itself as "Notebook-friendly" and crediting the pattern to `backtest.run_sync`, which never had it. Fixed by **detect, then divert** in one shared private helper; **scripts are unaffected** and pinned by tests asserting the coroutine still runs on the caller's own thread. ⚠️ **Notebooks only: you cannot interrupt these calls** — the work runs on a worker thread, so "interrupt kernel" raises in the cell while the run continues. No API removed or renamed. |
| 0.12.0rc1 | 2026-09-02 | **Pre-release — not a published `0.12.0`.** Two changes, both from live incidents. **(1) A bot at its exposure cap could not exit (AUS-131).** `RiskGuardian.check()` took no side or intent parameter and scored every order as `current_exposure + order_value`, so a SELL that **reduced** exposure was charged as though it added it. Measured through the real `Strategy.close_position()` path (local): 20 LONG YES positions, exposure $200.00, cap $200.00 — **20 of 20 exits rejected, zero orders reached the exchange**, and partial exits were refused all the way down to **1% of the position**. Exposure only falls when something closes a position, so the condition is self-sustaining. All four limits could lock an exit independently, and 0.11.2's better `MARKET_FAK` exit was rejected before it ever reached the venue. Every order is now decomposed into `reducing` / `opening` shares against the position it acts on — the **same** rule `_position_math.apply_fill` uses when the fill lands, pinned by a new parity test — so `max_position_usd` is charged on the **opening** share only, `max_total_exposure_usd` compares **projected** post-order exposure, `max_daily_loss_usd` is **skipped** for a pure reduction, and `max_open_orders` is **still enforced**, deliberately. Direction is derived from the portfolio and is **never a parameter**, so it cannot be bypassed by a strategy claiming `intent="EXIT"`; `check()`'s signature is unchanged and entry protection collapses algebraically to the old arithmetic. `max_position_usd` widens in meaning from "value of a single order" to "value of **new risk** in a single order" — named, not slid in. **(2) The SDK can now be asked what a trade would actually cost (AUS-276).** New `Strategy.execution_quote(market, outcome, side=, size=, limit_price=)` → `auspicium.execution.ExecutionQuote` answers *"what price would I actually pay for N shares, right now?"* — venue-neutral and **size-aware**, so its `price` is the VWAP across the levels the order would consume rather than `asks[0][0]`. Motivated by two real fills on 2026-08-16 on a strategy gated at `>= 0.85` that executed at **0.6000** and **0.8100**: the bot re-priced but did not re-gate, and the market moved **0.29** in the 3–8 seconds before each order matched. `status` is `AVAILABLE`, `EMPTY` or `UNAVAILABLE`, so **a venue that could not be asked is distinguishable from one that answered "no depth"** — the distinction `book_depth_at_or_better`'s `0.0` cannot make — and `bool(quote)` **raises `TypeError`** rather than let `if quote:` silently read an unavailable answer as a value. Polymarket walks the real CLOB book; `ProductionExchange` delegates through the same `_route(market)` as `submit`; **backtest and paper return `UNAVAILABLE` rather than fabricate a price they will not honour**. `book_depth_at_or_better` is reimplemented on top of it and **no longer raises `AttributeError` on every backtest** — the `0.0` its own docstring always promised — with its float contract otherwise unchanged and a compatibility fallback for adapters that implement only `get_order_book`. No `state.db` action. No API removed or renamed. |
| 0.11.2  | 2026-08-31 | **Polymarket closes change semantics — `close_position()` no longer submits fill-or-kill (AUS-268). Behaviour change in a patch: read the entry.** Through 0.11.1 `Strategy.close_position()` built its closing order with `order_type="MARKET"` nailed shut and exposed no parameter to change it — and on the Polymarket CLOB a plain `MARKET` is **FOK**, so a book thinner than the position KILLED the close rather than filling the depth that existed. It now submits `MARKET_FAK` there: same full fill when depth allows, a **partial** instead of nothing when it does not. FAK strictly dominates FOK for an exit, so the direction is safe — but the behaviour is not identical, and **anyone on a range pin (`~=0.11.1`, `>=0.11,<0.12`) picks this up automatically**; pin exactly or pass `order_type="MARKET"` to keep the old behaviour. **Binance closes are unchanged.** A killed *entry* is a missed opportunity; a killed *close* left the strategy holding a position it explicitly asked to leave, with no retry and nothing in the log that reads as a problem, which then rode to resolution — and `close_position` is what an author reaches for exactly when a stop, a risk rule or a shutdown has decided they are out, the moments a prediction-market book is thinnest. AUS-266 was this defect in an example a caller could work around; this one was in the SDK's own public API, where they could not. The exposure is not the SDK call graph (nothing inside the SDK calls it) but `auspicium-studio`'s strategy-generation prompt, which advertises `close_position` / `close_all_positions` to the LLM as **the** recommended exit helpers — so every generated Polymarket strategy inherited FOK exits. That is AUS-194 one layer down. The order type is now chosen by the adapter that will receive the order, via a new optional `BaseExchange.preferred_close_order_type(market)` capability — the third hook of the same shape after `get_order_book` and `get_resolution` — defaulting to `"MARKET"` and overridden to `"MARKET_FAK"` on Polymarket, with `ProductionExchange` delegating through the same `_route(market)` it uses for `submit`. It has to be the adapter's call rather than the caller's: `submit` routes **per order at submit time**, so a strategy holding both a BTC-USDT and a Polymarket position cannot name one type correct for both. Both methods also gain a keyword-only `order_type=` override and now **return the closing orders submitted** — what was *sent*, not what *filled*, because `submit()` returns on venue acceptance and a result object claiming fill outcome would invent certainty on the async path. **Do not assume you are flat**: reconcile via the returned ids in `on_fill` (`fill.status` is `PARTIAL`, not `KILLED`). Adapters lacking the hook still get `"MARKET"`, nothing was removed or renamed, and there is **no `state.db` action**. |
| 0.11.1  | 2026-08-31 | **The copy-trader example submitted FOK orders on both sides; a killed exit strands the bot in the position (AUS-266).** `examples/copy_trader/strategy.py` omitted `order_type` on both `Order.binary_buy` (entry) and `Order.binary_sell` (exit), inheriting the factory default `"MARKET"` — which on Polymarket is **fill-or-kill**: an order larger than the resting book is KILLED outright rather than filling the depth that exists (the AUS-194 trap, bundled `CLAUDE.md` §4). Both call sites now pass `order_type="MARKET_FAK"` explicitly, matching `auspicium-studio`'s ported template, whose save-time linter has required it since AUS-194. The asymmetry is the point: a killed **entry** is a missed opportunity and the next signal comes along, but a killed **exit** leaves this bot holding a position it intended to leave — no retry, nothing in the log that reads as a problem, and the whale is already out — so it then rides to resolution, an outcome the user did not choose. Prediction-market books are thinnest exactly when a whale is taking profit, which is exactly when the exit fires. No book-depth preflight was added: `book_depth_at_or_better` returns `0.0` whenever the adapter cannot introspect the book (`BacktestPaperExchange`, Binance), so the §4 `min(size, depth)` recipe would silently zero every exit **in backtest** — and on an exit the honest fallback is a partial fill, not a skip. **Example-only change: no SDK API moved**, and no `state.db` action. `Strategy.close_position()` is untouched and still hardcodes `order_type="MARKET"` with no way to override it — tracked separately; the copy-trader deliberately does not route through it. | Bugfix (example strategy; live order behaviour) |
| 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.** ⚠️ **The 90 s in that idiom is itself BELOW the venue's floor, which is 180 s — see `0.12.0rc3` (AUS-221).** The clock-frame defect this row describes is real and is fixed; the number it uses to illustrate it is not a usable lead time and never was. | 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. ⚠️ **SUPERSEDED — 60 was the wrong number and this row records the mistake, not the current behaviour. The floor is 180 s as of `0.12.0rc3`:** 60 is Polymarket's *shave* ("GTD orders expire one minute before their stated expiration"), not its *floor* ("the expiration must be at least 3 minutes in the future"). Two rules, conflated. 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.
