Skip to content

Commit edffc77

Browse files
Hiksangclaude
andcommitted
feat(outcome): add outcome view — symmetric Yes/No book + underlying gap
Outcome markets have a complementary Yes/No structure where Yes BID + No ASK ≈ 1.0. Agents and humans need a single snapshot that exposes both books plus directional context (current underlying mark vs the binary's targetPrice) — not a multi-call combination of `book` + `market mid`. Adapter: - HyperliquidOutcomeAdapter.getView(outcome, depth) fetches both sides' books in parallel via Promise.all + a single allMids round-trip. From allMids it derives: - per-side mid + impliedProb - midSum (deviation from 1.0 = arbitrage hint) - underlying perp mid (parsed from description.underlying — currently only HL native perps; HIP-3 dex perps not referenced by HIP-4 yet) - gap = mark - target, gapPct, in-the-money side (priceBinary only) - New OutcomeView / OutcomeViewSide / OutcomeViewUnderlying types in outcome-interface.ts. CLI: - `perp outcome view <outcome> [--depth N=10]` (alias `status`) - TTY: header (target / current / gap / inTheMoney / expiry / implied probabilities) followed by stacked Yes/No book tables. - JSON: full OutcomeView struct. MCP: - new tool `get_outcome_view` (public read, no API key). - Total tools: 21 → 22. Skill bundle: - references/commands.md highlights `outcome view` as the preferred agent entry point and shortens the recommended workflow from 6 steps to 5 (drops the separate `market mid` lookup). - SKILL.md MCP tool count + tool name list updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6754be2 commit edffc77

6 files changed

Lines changed: 280 additions & 11 deletions

File tree

skills/perp-cli/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ When the user first sets up a wallet, ask:
320320
321321
## MCP Server
322322

323-
The package also ships a 21-tool MCP server (no API keys required for read-only market data — `get_markets`, `get_orderbook`, `get_funding_rates`, `get_prices`, `get_outcome_markets`, `get_outcome_book`, plus 15 account/advisor tools):
323+
The package also ships a 22-tool MCP server (no API keys required for read-only market data — `get_markets`, `get_orderbook`, `get_funding_rates`, `get_prices`, `get_outcome_markets`, `get_outcome_view`, `get_outcome_book`, plus 15 account/advisor tools):
324324

325325
```json
326326
{

skills/perp-cli/references/commands.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,16 @@ Fully-collateralized binary/range contracts on Hyperliquid. **Quote token: USDH
9999
# with mid + assetId.
100100
perp --json outcome list
101101

102-
# Orderbook for one side. <side> accepts: 0/1, Yes/No, or #<enc> / +<enc>
103-
# (the encoded form must match the outcome arg — mismatch throws
104-
# INVALID_PARAMS).
102+
# COMBINED VIEW — all sides' books in parallel + underlying mark price
103+
# (HL perp mid for `description.underlying`) + gap vs targetPrice + ms
104+
# until expiry + per-side implied probability. For a binary market the
105+
# symmetric structure means Yes BID + No ASK ≈ 1.0; this is the
106+
# preferred entry point for agents that need a single snapshot.
107+
perp --json outcome view <outcome> [--depth N=10]
108+
109+
# Orderbook for ONE side only (use `view` if you want both at once).
110+
# <side> accepts: 0/1, Yes/No, or #<enc> / +<enc> (the encoded form
111+
# must match the outcome arg — mismatch throws INVALID_PARAMS).
105112
perp --json outcome book <outcome> <side> [--depth N=10]
106113

107114
# Holdings + open orders.
@@ -145,17 +152,18 @@ Encoding formula: `enc = 10 * outcome + side`; asset id = `100,000,000 + enc`.
145152

146153
```
147154
1. perp --json outcome list # discover markets, parse description
148-
2. perp --json -e hyperliquid market mid <UNDERLYING>
149-
# if class:priceBinary, compare against targetPrice
150-
3. perp --json outcome book <outcome> <side> --depth 10
151-
# check liquidity (single-MM mirror book risk)
152-
4. perp --json outcome buy <outcome> <side> <usd> --dry-run
155+
2. perp --json outcome view <outcome> # full snapshot: both books +
156+
# underlying mark + gap vs target +
157+
# implied probabilities + time to expiry
158+
3. perp --json outcome buy <outcome> <side> <usd> --dry-run
153159
# validate notional, get user approval
154-
5. perp --json outcome buy <outcome> <side> <usd> [--limit <px>] [--tif ...]
160+
4. perp --json outcome buy <outcome> <side> <usd> [--limit <px>] [--tif ...]
155161
# execute (only after explicit user OK)
156-
6. perp --json outcome positions # confirm fill / monitor
162+
5. perp --json outcome positions # confirm fill / monitor
157163
```
158164

165+
`outcome view` replaces the old multi-call pattern (separate `book` + `market mid` lookups). One round-trip returns everything an agent needs to decide direction.
166+
159167
Settlement at `expiryMs` is venue-side: winning side → 1 USDH per share, losing → 0. Decide before expiry whether to self-close or let the venue settle.
160168

161169
## Funds (deposit, withdraw, transfer, bridge, rebalance)

src/commands/outcome.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
OutcomeMarketInfo,
1616
OutcomePosition,
1717
OutcomeOrderbook,
18+
OutcomeView,
1819
} from "../exchanges/outcome-interface.js";
1920
import { makeTable, printJson, jsonOk, jsonError, formatUsd } from "../utils.js";
2021
import { PerpError } from "../errors.js";
@@ -90,6 +91,26 @@ export function registerOutcomeCommands(
9091
printOutcomeBook(book, depth);
9192
});
9293

94+
// ── outcome view <outcome> ───────────────────────────────────────────────
95+
// Symmetric Yes/No book + underlying gap + time to expiry. Single
96+
// round-trip view for binary markets.
97+
outcome
98+
.command("view <outcome>")
99+
.alias("status")
100+
.description("Combined view: Yes/No books side-by-side + underlying mark gap + expiry")
101+
.option("--depth <n>", "Number of levels per side", "10")
102+
.action(async (outcomeArg: string, opts: { depth?: string }) => {
103+
const adapter = await getOutcomeAdapter();
104+
const outcomeId = Number(outcomeArg);
105+
if (!Number.isInteger(outcomeId) || outcomeId < 0) {
106+
throw new PerpError("INVALID_PARAMS", `Invalid outcome id: ${outcomeArg}`, {});
107+
}
108+
const depth = Math.max(1, Number(opts.depth ?? "10"));
109+
const view = await adapter.getView(outcomeId, depth);
110+
if (isJson()) return printJson(jsonOk(view));
111+
printOutcomeView(view);
112+
});
113+
93114
// ── outcome positions ────────────────────────────────────────────────────
94115
outcome
95116
.command("positions")
@@ -288,6 +309,72 @@ async function resolveOutcomeSide(
288309
return { outcome: outcomeId, side };
289310
}
290311

312+
function printOutcomeView(view: OutcomeView): void {
313+
console.log(chalk.white.bold(`\n Outcome #${view.outcome}${view.name}`));
314+
console.log(chalk.gray(` ${view.description}`));
315+
316+
// Header line: target / underlying mark / gap / expiry
317+
if (view.underlying) {
318+
const u = view.underlying;
319+
const target = u.targetPrice !== undefined ? `$${u.targetPrice.toLocaleString()}` : "—";
320+
const mark = u.markPrice !== undefined ? `$${Number(u.markPrice).toLocaleString()}` : chalk.gray("—");
321+
const gapStr = u.gap !== undefined && u.gapPct !== undefined
322+
? (u.gap >= 0
323+
? chalk.green(`+$${Math.abs(u.gap).toFixed(2)} (+${u.gapPct.toFixed(2)}%)`)
324+
: chalk.red(`-$${Math.abs(u.gap).toFixed(2)} (${u.gapPct.toFixed(2)}%)`))
325+
: chalk.gray("—");
326+
const itm = u.inTheMoney === "yes" ? chalk.green("Yes ITM")
327+
: u.inTheMoney === "no" ? chalk.red("No ITM")
328+
: chalk.gray("—");
329+
console.log(` ${u.symbol} target ${target} current ${mark} gap ${gapStr} ${itm}`);
330+
}
331+
if (view.expiryMs !== undefined) {
332+
const expiryStr = new Date(view.expiryMs).toISOString().replace("T", " ").slice(0, 16) + " UTC";
333+
const ttx = view.msToExpiry !== undefined ? formatDuration(view.msToExpiry) : "—";
334+
console.log(` Expires: ${expiryStr} (${ttx})`);
335+
}
336+
if (view.midSum !== undefined) {
337+
const sumColor = Math.abs(view.midSum - 1) < 0.01 ? chalk.gray : chalk.yellow;
338+
console.log(` Implied probabilities (sum ${sumColor(view.midSum.toFixed(4))}):`);
339+
for (const s of view.sides) {
340+
const p = s.impliedProb !== undefined ? `${(s.impliedProb * 100).toFixed(1)}%` : "—";
341+
console.log(` ${s.name.padEnd(6)} ${p}`);
342+
}
343+
}
344+
345+
// One table per side. Stacked layout — readable on any terminal width
346+
// and avoids the alignment quirks that come from padding ANSI strings.
347+
console.log("");
348+
for (const s of view.sides) {
349+
console.log(` ${chalk.white.bold(s.name)} book`);
350+
const maxLevels = Math.max(s.bids.length, s.asks.length);
351+
const rows: string[][] = [];
352+
for (let i = 0; i < maxLevels; i++) {
353+
const b = s.bids[i];
354+
const a = s.asks[i];
355+
rows.push([
356+
b ? chalk.green(b[0]) : "",
357+
b ? b[1] : "",
358+
a ? chalk.red(a[0]) : "",
359+
a ? a[1] : "",
360+
]);
361+
}
362+
console.log(makeTable(["bid", "size", "ask", "size"], rows));
363+
console.log("");
364+
}
365+
}
366+
367+
function formatDuration(ms: number): string {
368+
if (ms < 0) return "expired";
369+
const s = Math.floor(ms / 1000);
370+
const h = Math.floor(s / 3600);
371+
const m = Math.floor((s % 3600) / 60);
372+
if (h > 24) return `${Math.floor(h / 24)}d ${h % 24}h ${m}m left`;
373+
if (h > 0) return `${h}h ${m}m left`;
374+
if (m > 0) return `${m}m ${s % 60}s left`;
375+
return `${s}s left`;
376+
}
377+
291378
function printOutcomeBook(book: OutcomeOrderbook, depth: number): void {
292379
console.log(chalk.white.bold(`\n outcome=${book.outcome} side=${book.side}`));
293380
const bids = book.bids.slice(0, depth);

src/exchanges/hyperliquid-outcome.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import type {
1919
OutcomeOrderbook,
2020
OutcomePosition,
2121
OutcomeSideInfo,
22+
OutcomeView,
23+
OutcomeViewSide,
24+
OutcomeViewUnderlying,
2225
} from "./outcome-interface.js";
2326
import type { HyperliquidAdapter } from "./hyperliquid.js";
2427

@@ -194,6 +197,112 @@ export class HyperliquidOutcomeAdapter implements OutcomeAdapter {
194197
return positions;
195198
}
196199

200+
/**
201+
* Assemble a combined view of one outcome: all sides' books in parallel,
202+
* the underlying mark price (HL perp mid for `description.underlying`),
203+
* gap vs targetPrice, time-to-expiry, and per-side implied probability.
204+
*
205+
* Outcome markets have a symmetric (Yes / No) structure where the prices
206+
* sum to ~$1; this view exposes both sides in a single round-trip and
207+
* also surfaces the directional context for binary markets — what BTC
208+
* mark price would settle the contract right now.
209+
*/
210+
async getView(outcome: number, depth: number = 10): Promise<OutcomeView> {
211+
await this.init();
212+
const meta = this._outcomeMeta?.outcomes.find((o) => o.outcome === outcome);
213+
if (!meta) {
214+
throw new PerpError("SYMBOL_NOT_FOUND", `Unknown outcome id: ${outcome}`, {
215+
exchange: "hyperliquid",
216+
remediation: "Run: perp outcome list",
217+
});
218+
}
219+
const parsed = HyperliquidOutcomeAdapter.parseDescription(meta.description);
220+
221+
// Fetch books for every side in parallel + allMids once for mids and
222+
// the underlying symbol's mark price.
223+
const allMidsPromise = this._infoPost({ type: "allMids" }) as Promise<Record<string, string>>;
224+
const bookPromises = meta.sideSpecs.map((_, i) => this.getOrderbook(outcome, i));
225+
const [allMids, ...books] = await Promise.all([allMidsPromise, ...bookPromises]);
226+
227+
// Trim each book to `depth` levels and compute best bid/ask + implied prob.
228+
const sides: OutcomeViewSide[] = meta.sideSpecs.map((spec, i) => {
229+
const encoding = HyperliquidOutcomeAdapter.encoding(outcome, i);
230+
const book = books[i];
231+
const bids = book.bids.slice(0, depth);
232+
const asks = book.asks.slice(0, depth);
233+
const bestBid = bids[0]?.[0];
234+
const bestAsk = asks[0]?.[0];
235+
const mid = allMids[`#${encoding}`];
236+
return {
237+
side: i,
238+
name: spec.name,
239+
encoding,
240+
assetId: OUTCOME_ASSET_OFFSET + encoding,
241+
mid,
242+
bids,
243+
asks,
244+
bestBid,
245+
bestAsk,
246+
impliedProb: mid !== undefined ? Number(mid) : undefined,
247+
};
248+
});
249+
250+
const midSum = sides.every((s) => s.impliedProb !== undefined)
251+
? sides.reduce((acc, s) => acc + (s.impliedProb ?? 0), 0)
252+
: undefined;
253+
254+
// Underlying: HL perp mid for the parsed underlying symbol.
255+
let underlying: OutcomeViewUnderlying | null = null;
256+
if (parsed.underlying) {
257+
const sym = parsed.underlying.toUpperCase();
258+
// HL `allMids` keys perps by bare symbol (e.g. "BTC"). HIP-3 perps
259+
// use "@dexIdx:SYMBOL" but those won't be referenced in HIP-4
260+
// outcomes for now.
261+
const markPrice = allMids[sym];
262+
const target = parsed.targetPrice;
263+
let gap: number | undefined;
264+
let gapPct: number | undefined;
265+
let inTheMoney: "yes" | "no" | null = null;
266+
if (markPrice !== undefined && target !== undefined) {
267+
gap = Number(markPrice) - target;
268+
gapPct = (gap / target) * 100;
269+
// For class:priceBinary the convention is Yes = "underlying >=
270+
// target". When `class` is unknown or non-binary, leave inTheMoney
271+
// as null rather than guessing.
272+
if (parsed.class === "priceBinary" && Number.isFinite(gap)) {
273+
inTheMoney = gap >= 0 ? "yes" : "no";
274+
}
275+
}
276+
underlying = {
277+
symbol: sym,
278+
source: sym,
279+
markPrice,
280+
targetPrice: target,
281+
gap,
282+
gapPct,
283+
inTheMoney,
284+
};
285+
}
286+
287+
const expiryMs = parsed.expiryMs;
288+
const serverTime = Date.now();
289+
const msToExpiry = expiryMs !== undefined ? expiryMs - serverTime : undefined;
290+
291+
return {
292+
outcome,
293+
name: meta.name,
294+
description: meta.description,
295+
class: parsed.class,
296+
expiryMs,
297+
msToExpiry,
298+
period: parsed.period,
299+
underlying,
300+
sides,
301+
midSum,
302+
serverTime,
303+
};
304+
}
305+
197306
async getOrderbook(outcome: number, side: number): Promise<OutcomeOrderbook> {
198307
await this.init();
199308
this._validateOutcomeSide(outcome, side);

src/exchanges/outcome-interface.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,47 @@ export interface OutcomeOrderbook {
7474
time: number;
7575
}
7676

77+
export interface OutcomeViewSide extends OutcomeSideInfo {
78+
bids: [string, string][];
79+
asks: [string, string][];
80+
bestBid?: string;
81+
bestAsk?: string;
82+
/** Implied probability of THIS side winning, derived from mid */
83+
impliedProb?: number;
84+
}
85+
86+
export interface OutcomeViewUnderlying {
87+
/** Underlying symbol from description (e.g. "BTC") */
88+
symbol: string;
89+
/** Source perp symbol used to fetch mark (typically same as `symbol`, may include venue prefix) */
90+
source: string;
91+
markPrice?: string;
92+
targetPrice?: number;
93+
/** markPrice - targetPrice (USD) */
94+
gap?: number;
95+
/** (markPrice - targetPrice) / targetPrice * 100 */
96+
gapPct?: number;
97+
/** If markPrice were to settle now: which side is winning ("yes"|"no") or null when ambiguous */
98+
inTheMoney?: "yes" | "no" | null;
99+
}
100+
101+
export interface OutcomeView {
102+
outcome: number;
103+
name: string;
104+
description: string;
105+
class?: string;
106+
expiryMs?: number;
107+
/** ms until expiry; negative if already expired; undefined if expiry unknown */
108+
msToExpiry?: number;
109+
period?: string;
110+
underlying: OutcomeViewUnderlying | null;
111+
sides: OutcomeViewSide[];
112+
/** Sum of side mids; ~1.0 for fair binary, deviation hints at arbitrage. */
113+
midSum?: number;
114+
/** Server time-ms when the view was assembled */
115+
serverTime: number;
116+
}
117+
77118
export interface OutcomeAdapter {
78119
readonly name: string;
79120
init(): Promise<void>;
@@ -83,6 +124,8 @@ export interface OutcomeAdapter {
83124
getPositions(): Promise<OutcomePosition[]>;
84125
/** L2 orderbook for one (outcome, side). */
85126
getOrderbook(outcome: number, side: number): Promise<OutcomeOrderbook>;
127+
/** Combined view: all sides' books in parallel + underlying mark price gap + expiry. */
128+
getView(outcome: number, depth?: number): Promise<OutcomeView>;
86129
/** Place a limit order. Throws INVALID_PARAMS if `price * size < 10` USDH. */
87130
placeOrder(opts: {
88131
outcome: number;

src/mcp-server.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,28 @@ server.tool(
323323
},
324324
);
325325

326+
server.tool(
327+
"get_outcome_view",
328+
"Get a combined view of one outcome market: all sides' books in parallel + underlying mark price gap (vs targetPrice) + time to expiry + per-side implied probability (mid sum). For binary markets, the symmetric structure means Yes bid + No ask ≈ 1.0 — the view exposes both sides in a single round-trip and surfaces directional context (e.g., is BTC currently above the target). Public read.",
329+
{
330+
outcome: z.number().int().nonnegative().describe("Outcome id from outcomeMeta (e.g., 1 for the BTC binary daily)"),
331+
depth: z.number().int().min(1).max(50).optional().default(10).describe("Number of book levels per side"),
332+
},
333+
async ({ outcome, depth }) => {
334+
try {
335+
const { HyperliquidAdapter } = await import("./exchanges/hyperliquid.js");
336+
const { HyperliquidOutcomeAdapter } = await import("./exchanges/hyperliquid-outcome.js");
337+
const hl = new HyperliquidAdapter(undefined, false);
338+
await hl.init();
339+
const out = new HyperliquidOutcomeAdapter(hl);
340+
const view = await out.getView(outcome, depth);
341+
return { content: [{ type: "text", text: ok(view, { outcome, depth }) }] };
342+
} catch (e) {
343+
return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e), { outcome }) }], isError: true };
344+
}
345+
},
346+
);
347+
326348
server.tool(
327349
"get_outcome_book",
328350
"Get the orderbook for one outcome side. Public read — no API key needed.",

0 commit comments

Comments
 (0)