Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 30 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
27 changes: 18 additions & 9 deletions examples/11_mint_burn_demo.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
81 changes: 81 additions & 0 deletions examples/13_split_merge.py
Original file line number Diff line number Diff line change
@@ -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()
10 changes: 10 additions & 0 deletions hl4/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,4 +25,8 @@
"encode_coin",
"fetch_outcome_meta",
"parse_recurring_description",
"split_outcome",
"merge_outcome",
"negate_outcome",
"merge_question",
]
84 changes: 84 additions & 0 deletions hl4/outcome_actions.py
Original file line number Diff line number Diff line change
@@ -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}},
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading