diff --git a/README.md b/README.md index 56581f9..4e5771b 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ uv run python examples/04_orderbook_snapshot.py --outcome 5915 | 10 | `10_categorical_trade.py` | Trade a single leg of a question | | 11 | `11_mint_burn_demo.py` | Explainer of mint/burn/normal-trade fee classes | | 12 | `12_wait_for_settlement.py` | Hold to expiry, observe auto-settlement | +| 13 | `13_split_merge.py` | Explicit `userOutcome` split / merge / negate | ## Bot @@ -87,7 +88,7 @@ Mixing them up will silently fail to find your position. `hl4.outcomes` exposes BTC daily YES (outcome 5915) → trade `#59150`, balance `+59150`, asset_id `100_059_150`. ### SDK doesn't know about HIP-4 yet -`hyperliquid-python-sdk` v0.23.0 has no HIP-4 support. The shared module +`hyperliquid-python-sdk` (through v0.24.0) has no HIP-4 support. The shared module `hl4.client.make_clients()` patches the SDK's `Info` and `Exchange` instances by injecting `coin_to_asset` and `name_to_coin` entries for every live outcome fetched via `outcomeMeta`. Once injected, normal `Exchange.order(...)` calls @@ -111,20 +112,36 @@ Outcome prices are constrained to `0.001 .. 0.999` (probabilities, not raw prices). The price tick on the BTC binary is `0.0001` (e.g. `0.4235`); the bot defaults to `0.001` for safety. -### No split / merge primitive -Unlike Polymarket (Gnosis CTF), there is no `splitPosition` or -`mergePositions` API. To get YES exposure you place a buy on the YES book; to -get short-YES exposure you buy NO. Mint and burn happen *implicitly* inside -the matching engine as a side-effect of fills: +### Split / merge: explicit actions AND implicit engine classification +Two distinct mechanisms create/destroy shares: -| Engine classification | When it happens | Fee | -|---|---|---| -| MINT | both counterparties had no prior position | 0 | -| NORMAL | one side opens, the other closes | fee on the opener / taker | -| BURN | both counterparties hold the opposite side and unwind together | both sides (or taker only) | -| SETTLEMENT | at expiry, oracle credits 0 or 1 USDH | `settle_fraction × sz` | +**1. Explicit `userOutcome` actions** (like Polymarket's `splitPosition` / +`mergePositions`, which earlier HIP-4 builds lacked). Sent as raw L1 actions — +the SDK has no helper, so `hl4.outcome_actions` signs them by hand: -See `examples/11_mint_burn_demo.py` for the full explainer. +| Action | Shape | Effect | +|---|---|---| +| split | `{type: userOutcome, splitOutcome: {outcome, amount}}` | lock `amount` USDH → mint `amount` YES + `amount` NO | +| merge | `{type: userOutcome, mergeOutcome: {outcome, amount\|null}}` | burn YES+NO pair → release USDH (`null` = max) | +| negate | `{type: userOutcome, negateOutcome: {question, outcome, amount}}` | burn NO of one named leg → credit YES of every other leg (other named + fallback); unidirectional | +| merge-question | `{type: userOutcome, mergeQuestion: {question, amount\|null}}` | burn a complete YES set (1 of every leg) → USDH; the way to unwind a negate / exit a full set without waiting for settlement | + +Note: you cannot split a question's **fallback** outcome (`Cannot split +fallback outcome`). See `examples/13_split_merge.py`. + +**2. Implicit engine classification** of orderbook fills — to get YES exposure +you place a buy on the YES book; for short-YES you buy NO. The engine labels +each fill as a side-effect: + +| Engine classification | When it happens | +|---|---| +| MINT | both counterparties had no prior position | +| NORMAL | one side opens, the other closes | +| BURN | both counterparties hold the opposite side and unwind together | +| SETTLEMENT | at expiry, oracle credits 0 or 1 USDH (`settle_fraction × sz`) | + +**Fees are currently zero on outcome markets** (initial testing). See +`examples/11_mint_burn_demo.py` for the full explainer. ### No claim/redeem call at expiry Settlement is automatic — at expiry, USDH credits land in the account. No diff --git a/examples/11_mint_burn_demo.py b/examples/11_mint_burn_demo.py index f24e17f..5cca6ef 100644 --- a/examples/11_mint_burn_demo.py +++ b/examples/11_mint_burn_demo.py @@ -1,22 +1,31 @@ """Explainer: mint vs burn vs normal-trade on HIP-4. -HIP-4 has no separate `splitPosition` / `mergePositions` API like Polymarket -(Gnosis CTF). Instead, every fill in the orderbook is *classified* by the -matching engine into one of these cases (per the official fee docs): +There are TWO ways YES/NO shares get created or destroyed on HIP-4: + +A) Explicit `userOutcome` actions — split / merge / negate. You call them + directly, no counterparty needed (see 13_split_merge.py). split locks USDH + and mints a YES+NO pair; merge burns a pair back into USDH. + +B) Orderbook fills, which the matching engine *classifies* into one of the + cases below based on the inventory of the two matching counterparties. THIS + is what the explainer here is about — you don't pick the case, the engine + does: 1. MINT — both sides have no prior position. Locks USDH on each side - and creates a new YES holder + a new NO holder. FEE: 0. + and creates a new YES holder + a new NO holder. 2. NORMAL TRADE — one side closes an existing position, the other opens. - Fee paid by whichever side is opening / taking liquidity. 3. BURN — both sides hold opposite legs and trade against each other to flatten. The two positions cancel and USDH collateral - is released. Fees apply (both sides, or taker-only). + is released. 4. SETTLEMENT — at expiry, oracle posts result; YES credits 1 USDH, NO credits 0 (or vice versa). No claim call needed. -Implication for your bot: you don't choose mint/burn — the engine does, based -on the inventory of the matching counterparties. Two equivalent ways to get -short YES exposure are: +Fees: currently ZERO on outcome markets (initial testing). When fees turn on, +they follow the spot model (taker / builder-code), classified per case above. + +Implication for your bot: for orderbook fills you don't choose mint/burn — the +engine does, based on the inventory of the matching counterparties. Two +equivalent ways to get short YES exposure are: (a) place a sell on the YES side at price p (synthetic short) (b) place a buy on the NO side at price 1-p diff --git a/examples/13_split_merge.py b/examples/13_split_merge.py new file mode 100644 index 0000000..fd51171 --- /dev/null +++ b/examples/13_split_merge.py @@ -0,0 +1,81 @@ +"""Explicit split / merge / negate of HIP-4 outcome shares. + +Unlike normal trading (where the matching engine *classifies* fills as +mint/burn/normal — see 11_mint_burn_demo.py), these are user-initiated +`userOutcome` actions that convert shares directly, with no counterparty: + + split --outcome 7002 --amount 1 lock 1 USDH -> 1 YES + 1 NO + merge --outcome 7002 [--amount 1] burn 1 YES + 1 NO -> 1 USDH (omit = max) + negate --question 182 --outcome 7003 --amount 1 burn NO of 7003 -> + credit YES of every other leg (7004, 7005, fallback 7002) + merge-question --question 182 [--amount 1] burn 1 YES of EVERY leg -> + 1 USDH (a complete set). Omit --amount for max. This is how you exit + a full YES set (e.g. after a negate) without waiting for settlement. + +Fees on outcome markets are currently zero (initial testing). + +Usage: + uv run python examples/13_split_merge.py split --outcome 7002 --amount 1 + uv run python examples/13_split_merge.py merge --outcome 7002 --amount 1 + uv run python examples/13_split_merge.py merge --outcome 7002 + uv run python examples/13_split_merge.py negate --question 182 --outcome 7003 --amount 1 + uv run python examples/13_split_merge.py merge-question --question 182 +""" + +import argparse + +from rich.console import Console + +from hl4 import load_config +from hl4.client import make_clients +from hl4.outcome_actions import ( + merge_outcome, + merge_question, + negate_outcome, + split_outcome, +) + + +def main() -> None: + p = argparse.ArgumentParser() + sub = p.add_subparsers(dest="cmd", required=True) + + sp = sub.add_parser("split") + sp.add_argument("--outcome", type=int, required=True) + sp.add_argument("--amount", required=True) + + mp = sub.add_parser("merge") + mp.add_argument("--outcome", type=int, required=True) + mp.add_argument("--amount", default=None, help="omit to merge the max available") + + np = sub.add_parser("negate") + np.add_argument("--question", type=int, required=True) + np.add_argument("--outcome", type=int, required=True) + np.add_argument("--amount", required=True) + + qp = sub.add_parser("merge-question") + qp.add_argument("--question", type=int, required=True) + qp.add_argument("--amount", default=None, help="omit to merge the max complete set") + + args = p.parse_args() + cfg = load_config() + # No need to hydrate the coin map — userOutcome actions take raw ids, not coins. + _info, exchange = make_clients(cfg, hydrate_outcomes=False) + c = Console() + + if args.cmd == "split": + c.print(f"split outcome {args.outcome} amount {args.amount}") + c.print(split_outcome(exchange, args.outcome, args.amount)) + elif args.cmd == "merge": + c.print(f"merge outcome {args.outcome} amount {args.amount or 'MAX'}") + c.print(merge_outcome(exchange, args.outcome, args.amount)) + elif args.cmd == "negate": + c.print(f"negate Q{args.question} outcome {args.outcome} amount {args.amount}") + c.print(negate_outcome(exchange, args.question, args.outcome, args.amount)) + elif args.cmd == "merge-question": + c.print(f"merge-question Q{args.question} amount {args.amount or 'MAX'}") + c.print(merge_question(exchange, args.question, args.amount)) + + +if __name__ == "__main__": + main() diff --git a/hl4/__init__.py b/hl4/__init__.py index 5c6993b..8878320 100644 --- a/hl4/__init__.py +++ b/hl4/__init__.py @@ -1,4 +1,10 @@ from hl4.config import Config, load_config +from hl4.outcome_actions import ( + merge_outcome, + merge_question, + negate_outcome, + split_outcome, +) from hl4.outcomes import ( OutcomeSpec, QuestionSpec, @@ -19,4 +25,8 @@ "encode_coin", "fetch_outcome_meta", "parse_recurring_description", + "split_outcome", + "merge_outcome", + "negate_outcome", + "merge_question", ] diff --git a/hl4/outcome_actions.py b/hl4/outcome_actions.py new file mode 100644 index 0000000..d12be08 --- /dev/null +++ b/hl4/outcome_actions.py @@ -0,0 +1,84 @@ +"""Explicit HIP-4 `userOutcome` actions: split / merge / negate. + +These are manual conversions between outcome shares, sent as raw L1 actions to +the `/exchange` endpoint. The hyperliquid-python-sdk (<=0.24.0) has no helper +for them, so we sign and post by hand using the same `sign_l1_action` path the +SDK uses for `order`. + +Action shapes (from the HIP-4 docs): + + {"type": "userOutcome", "splitOutcome": {"outcome": int, "amount": str}} + {"type": "userOutcome", "mergeOutcome": {"outcome": int, "amount": str | None}} + {"type": "userOutcome", "negateOutcome": {"question": int, "outcome": int, "amount": str}} + {"type": "userOutcome", "mergeQuestion": {"question": int, "amount": str | None}} + +- split: lock `amount` USDH on `outcome`, mint `amount` YES + `amount` NO. +- merge: burn matching YES+NO pairs of one `outcome` back into USDH + (amount=None merges max). +- negate: burn `amount` NO of `outcome` (a named leg of `question`) and credit + `amount` YES of EVERY OTHER leg (the other named outcomes + the + fallback). i.e. NO-of-one-leg == YES-of-the-complementary-set. + Unidirectional — there is no reverse-negate. +- merge-question: burn `amount` YES of EVERY outcome of `question` (a complete + set) back into `amount` USDH. The way to exit a full YES set without + waiting for settlement (and the unwind for negate, which scatters + your position into YES legs across the question). + +`amount` is a string (same convention as order sizes), to avoid float drift. +""" + +from typing import Any, Optional + +from hyperliquid.exchange import Exchange +from hyperliquid.utils.constants import MAINNET_API_URL +from hyperliquid.utils.signing import get_timestamp_ms, sign_l1_action + + +def _post_user_outcome(exchange: Exchange, action: dict[str, Any]) -> Any: + timestamp = get_timestamp_ms() + signature = sign_l1_action( + exchange.wallet, + action, + exchange.vault_address, + timestamp, + exchange.expires_after, + exchange.base_url == MAINNET_API_URL, + ) + return exchange._post_action(action, signature, timestamp) + + +def split_outcome(exchange: Exchange, outcome: int, amount: str) -> Any: + """Mint `amount` YES + `amount` NO of `outcome`, locking `amount` USDH.""" + return _post_user_outcome( + exchange, + {"type": "userOutcome", "splitOutcome": {"outcome": outcome, "amount": amount}}, + ) + + +def merge_outcome(exchange: Exchange, outcome: int, amount: Optional[str] = None) -> Any: + """Burn matching YES+NO pairs back into USDH. `amount=None` merges the max.""" + return _post_user_outcome( + exchange, + {"type": "userOutcome", "mergeOutcome": {"outcome": outcome, "amount": amount}}, + ) + + +def negate_outcome(exchange: Exchange, question: int, outcome: int, amount: str) -> Any: + """Burn `amount` NO of `outcome` -> credit `amount` YES of every other leg + of `question` (other named outcomes + fallback). Needs the NO leg in hand.""" + return _post_user_outcome( + exchange, + { + "type": "userOutcome", + "negateOutcome": {"question": question, "outcome": outcome, "amount": amount}, + }, + ) + + +def merge_question(exchange: Exchange, question: int, amount: Optional[str] = None) -> Any: + """Burn `amount` YES of EVERY outcome of `question` (a complete set) back + into `amount` USDH. `amount=None` redeems the max complete set held.""" + return _post_user_outcome( + exchange, + {"type": "userOutcome", "mergeQuestion": {"question": question, "amount": amount}}, + ) diff --git a/pyproject.toml b/pyproject.toml index 092dece..522e50e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Learning examples and a passive market-maker bot for Hyperliquid readme = "README.md" requires-python = ">=3.11" dependencies = [ - "hyperliquid-python-sdk>=0.23.0", + "hyperliquid-python-sdk>=0.24.0", "eth-account>=0.13.0", "python-dotenv>=1.0.0", "httpx>=0.27.0", diff --git a/uv.lock b/uv.lock index 6277aa5..36aefc6 100644 --- a/uv.lock +++ b/uv.lock @@ -562,7 +562,7 @@ dependencies = [ requires-dist = [ { name = "eth-account", specifier = ">=0.13.0" }, { name = "httpx", specifier = ">=0.27.0" }, - { name = "hyperliquid-python-sdk", specifier = ">=0.23.0" }, + { name = "hyperliquid-python-sdk", specifier = ">=0.24.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "rich", specifier = ">=13.0" }, { name = "websockets", specifier = ">=13.0" }, @@ -570,7 +570,7 @@ requires-dist = [ [[package]] name = "hyperliquid-python-sdk" -version = "0.23.0" +version = "0.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "eth-account" }, @@ -579,9 +579,9 @@ dependencies = [ { name = "requests" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/ad/12b4559a953e26fc56677de5bf689023e11213196b802b991a6e6db94814/hyperliquid_python_sdk-0.23.0.tar.gz", hash = "sha256:14df0b62511a0cf08ca5a73f73f03656868ee67845ed3362539a79674511bb51", size = 25255, upload-time = "2026-04-14T21:51:24.646Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/b2/9cd579de770c5746c4b3855b2a77858c657eafd9f26494707aca3dde7fed/hyperliquid_python_sdk-0.24.0.tar.gz", hash = "sha256:b3fba7c53f4faee41578df4ee19471393866a4a188e38d1fd05a0217b7a74868", size = 25457, upload-time = "2026-06-04T19:47:26.76Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/e9/b7b23aefc319727f670992904b1defd7aee5fc3f59a51c141a87db05f7da/hyperliquid_python_sdk-0.23.0-py3-none-any.whl", hash = "sha256:5b4f9f7ab8c0b1ad9848f2222901dc047c8f97a6e6fe3fd7286b7b34337f80cb", size = 24638, upload-time = "2026-04-14T21:51:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/6e869c47b361b4992885991c0063ba92cb10747523ea0a4cca6f74240d1e/hyperliquid_python_sdk-0.24.0-py3-none-any.whl", hash = "sha256:f472dd4f6d8ef0e66182c7627276400462c86fbc0929eb5b63feda3ae45605f6", size = 24816, upload-time = "2026-06-04T19:47:25.42Z" }, ] [[package]]