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
86 changes: 43 additions & 43 deletions core/api-doc-config.generated.json

Large diffs are not rendered by default.

29 changes: 23 additions & 6 deletions core/src/exchanges/hunch/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { hunchErrorMapper } from './errors';

const AGENT_PREFIX = '/api/agent/v1';
const DEFAULT_LIMIT = 200;
/** Safety backstop for cursor draining: 50 pages * 200 = 10k markets. */
const MAX_LIST_PAGES = 50;

// ---------------------------------------------------------------------------
// Raw venue-native shapes (what the Hunch agent API returns). Mirror the Zod
Expand Down Expand Up @@ -47,6 +49,10 @@ export interface HunchRawMarket {
/** Present in the schema; absent on the bare list endpoint — optional. */
volumeUsd?: number;
totalBets?: number;
/** Trailing-24h pool inflow (USD); absent on older API builds — optional. */
volume24hUsd?: number;
/** Live binary YES/NO odds on the list item; null/absent for N-way markets. */
odds?: HunchRawBinaryOdds | null;
targetMarketCapUsd: number | null;
outcomes: HunchRawOutcome[] | null;
headline?: string | null;
Expand Down Expand Up @@ -204,19 +210,30 @@ export class HunchFetcher
return one ? [one] : [];
}

const explicitLimit = typeof params?.limit === 'number';
const query: Record<string, unknown> = {
limit: params?.limit ?? DEFAULT_LIMIT,
};
const status = mapStatusToHunch(params?.status);
if (status) query.status = status;
if (params?.query) query.token = params.query;

const res = await this.ctx.http.get(`${this.baseUrl}${AGENT_PREFIX}/markets`, {
params: query,
headers: this.ctx.getHeaders(),
});
const markets: HunchRawMarket[] = res.data?.markets ?? [];
return markets;
// A catalog crawl (no explicit limit) follows `nextCursor` to drain
// the whole list; an explicit limit fetches just that one page.
// MAX_LIST_PAGES is a safety backstop against a runaway cursor loop.
const all: HunchRawMarket[] = [];
let cursor: string | undefined;
let pages = 0;
do {
const res = await this.ctx.http.get(`${this.baseUrl}${AGENT_PREFIX}/markets`, {
params: cursor ? { ...query, cursor } : query,
headers: this.ctx.getHeaders(),
});
all.push(...((res.data?.markets ?? []) as HunchRawMarket[]));
cursor = typeof res.data?.nextCursor === 'string' ? res.data.nextCursor : undefined;
pages += 1;
} while (!explicitLimit && cursor && pages < MAX_LIST_PAGES);
return all;
} catch (error: unknown) {
throw hunchErrorMapper.mapError(error);
}
Expand Down
73 changes: 66 additions & 7 deletions core/src/exchanges/hunch/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,62 @@ export function parseHunchSide(outcomeId: string): { marketId: string; side: str
};
}

// ---------------------------------------------------------------------------
// Category + tags — map Hunch's fine-grained native taxonomy onto pmxt's
// top-level categories (Crypto / Culture / …) + granular tags, so Hunch
// markets answer `?category=` filters and match with higher confidence.
// ---------------------------------------------------------------------------

/** Hunch native categories that are NOT crypto (manual-resolution markets). */
const HUNCH_TOP_CATEGORY: Record<string, string> = {
event: 'Culture',
};

/**
* Map a Hunch native category to a pmxt top-level category. Hunch is
* crypto-native, so every token/price/on-chain subtype rolls up to "Crypto";
* only the manual "event" markets (fights/debates) map elsewhere.
*/
export function mapHunchCategory(rawCategory: string | undefined): string {
return HUNCH_TOP_CATEGORY[rawCategory ?? ''] ?? 'Crypto';
}

/** Human-readable granular label per Hunch native category (for tags). */
const HUNCH_SUBTYPE_LABEL: Record<string, string> = {
market_cap: 'Market Cap',
token_mcap_range: 'Market Cap',
token_mcap_flip: 'Market Cap',
token_mcap_close: 'Market Cap',
token_basket_mcap: 'Market Cap',
price_direction: 'Price',
token_price_range: 'Price',
token_return: 'Returns',
token_rank_milestone: 'Ranking',
chain_volume: 'On-chain Volume',
chain_throughput: 'Throughput',
chain_stablecoins: 'Stablecoins',
launchpad_volume: 'Launchpad',
dune_metric: 'On-chain',
volume_eta: 'Volume',
event: 'Event',
};

function titleCaseSlug(s: string): string {
return s
.split(/[_\s-]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}

/** Tags = top category + a human subtype label + the token (deduped, non-empty). */
export function hunchMarketTags(raw: HunchRawMarket): string[] {
const top = mapHunchCategory(raw.category);
const label =
HUNCH_SUBTYPE_LABEL[raw.category] ?? (raw.category ? titleCaseSlug(raw.category) : '');
return [...new Set([top, label, raw.tokenSymbol].filter((t): t is string => Boolean(t)))];
}

/**
* Shared market normalizer used by both the live fetch path and the (rare)
* direct-mapping helper. Pulls a Hunch market ref into a {@link UnifiedMarket}.
Expand All @@ -125,6 +181,9 @@ export function mapHunchMarketToUnified(
if (!raw || !raw.id) return null;

const marketId = raw.id;
// Explicit odds (detail/quote read) win; else fall back to the live odds the
// list item now carries — so a bare list-crawl prices binary markets too.
const effectiveOdds = odds ?? raw.odds ?? null;
let outcomes: MarketOutcome[];

if (Array.isArray(raw.outcomes) && raw.outcomes.length > 0) {
Expand Down Expand Up @@ -154,10 +213,10 @@ export function mapHunchMarketToUnified(
});
} else {
// Binary YES/NO market.
const yesPrice = odds && typeof odds.yesPriceCents === 'number' ? odds.yesPriceCents / 100 : 0;
const yesPrice = effectiveOdds && typeof effectiveOdds.yesPriceCents === 'number' ? effectiveOdds.yesPriceCents / 100 : 0;
const noPrice =
odds && typeof odds.noPriceCents === 'number'
? odds.noPriceCents / 100
effectiveOdds && typeof effectiveOdds.noPriceCents === 'number'
? effectiveOdds.noPriceCents / 100
: yesPrice > 0
? 1 - yesPrice
: 0;
Expand All @@ -174,13 +233,13 @@ export function mapHunchMarketToUnified(
slug: raw.slug,
outcomes,
resolutionDate: raw.deadlineAt ? new Date(raw.deadlineAt) : undefined,
// Hunch is parimutuel and reports no 24h volume split — surface 0.
volume24h: 0,
// Trailing-24h pool inflow off the list item (0 when absent / no trades).
volume24h: typeof raw.volume24hUsd === 'number' ? raw.volume24hUsd : 0,
volume: typeof raw.volumeUsd === 'number' ? raw.volumeUsd : undefined,
liquidity: Number(raw.virtualLiquidityUsd || 0),
url: raw.links?.app || `${DEFAULT_BASE_URL}/markets/${raw.slug || marketId}`,
category: raw.category,
tags: raw.tokenSymbol ? [raw.tokenSymbol] : [],
category: mapHunchCategory(raw.category),
tags: hunchMarketTags(raw),
status: mapHunchStatus(raw.status),
sourceMetadata: buildSourceMetadata(
raw as unknown as Record<string, unknown>,
Expand Down
44 changes: 44 additions & 0 deletions core/test/exchanges/hunch-fetcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { HunchFetcher } from '../../src/exchanges/hunch/fetcher';
import { FetcherContext } from '../../src/exchanges/interfaces';

// Sprint 6 (pmxt surfacing): the Hunch list now paginates via nextCursor. A
// catalog crawl (no explicit limit) must follow the cursor to drain the WHOLE
// catalogue — else it silently truncates at the first page once Hunch grows.

function fetcherWithPages(pages: Array<{ markets: unknown[]; nextCursor: string | null }>) {
let calls = 0;
const ctx = {
http: {
get: async () => {
const page = pages[calls] ?? { markets: [], nextCursor: null };
calls += 1;
return { data: page };
},
} as unknown as FetcherContext['http'],
callApi: async () => ({}),
getHeaders: () => ({}),
} as FetcherContext;
return { fetcher: new HunchFetcher(ctx), getCalls: () => calls };
}

describe('HunchFetcher.fetchRawMarkets — cursor draining', () => {
it('drains every page via nextCursor when no explicit limit is set', async () => {
const { fetcher, getCalls } = fetcherWithPages([
{ markets: [{ id: 'a' }, { id: 'b' }], nextCursor: 'cur-1' },
{ markets: [{ id: 'c' }], nextCursor: null },
]);
const all = await fetcher.fetchRawMarkets();
expect(all.map((m) => m.id)).toEqual(['a', 'b', 'c']);
expect(getCalls()).toBe(2);
});

it('fetches only a single page when an explicit limit is set', async () => {
const { fetcher, getCalls } = fetcherWithPages([
{ markets: [{ id: 'a' }], nextCursor: 'cur-1' },
{ markets: [{ id: 'b' }], nextCursor: null },
]);
const all = await fetcher.fetchRawMarkets({ limit: 1 });
expect(all.map((m) => m.id)).toEqual(['a']);
expect(getCalls()).toBe(1);
});
});
47 changes: 46 additions & 1 deletion core/test/normalizers/hunch-normalizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ describe('HunchNormalizer', () => {
expect(market().volume).toBe(1_240);
});

it('volume24h is 0 (Hunch reports no 24h split)', () => {
it('volume24h is 0 when this fixture carries no volume24hUsd', () => {
expect(market().volume24h).toBe(0);
});

Expand Down Expand Up @@ -407,4 +407,49 @@ describe('HunchNormalizer', () => {
expect(normalizer.normalizeBalance(readiness)[0].total).toBe(0);
});
});

// -------------------------------------------------------------------------
// pmxt surfacing: live odds + 24h volume + category/tags off the LIST item
// -------------------------------------------------------------------------
describe('normalizeMarket — list odds + 24h volume (pmxt surfacing)', () => {
it('prices binary YES/NO from raw.odds on the bare list path (no explicit odds arg)', () => {
const raw = { ...binaryMarket, odds: { yesPriceCents: 64, noPriceCents: 36 } } as HunchRawMarket;
const m = normalizer.normalizeMarket(raw)!;
expect(m.outcomes[0].price).toBeCloseTo(0.64, 5);
expect(m.outcomes[1].price).toBeCloseTo(0.36, 5);
});

it('lets an explicit odds arg (detail/quote) win over raw.odds', () => {
const raw = { ...binaryMarket, odds: { yesPriceCents: 64, noPriceCents: 36 } } as HunchRawMarket;
const m = normalizer.normalizeMarket(raw, { yesPriceCents: 90, noPriceCents: 10 })!;
expect(m.outcomes[0].price).toBeCloseTo(0.9, 5);
});

it('passes through volume24h from raw.volume24hUsd', () => {
const raw = { ...binaryMarket, volume24hUsd: 320 } as HunchRawMarket;
expect(normalizer.normalizeMarket(raw)!.volume24h).toBe(320);
});

it('volume24h is 0 when the list item carries no 24h figure', () => {
expect(normalizer.normalizeMarket(binaryMarket)!.volume24h).toBe(0);
});
});

describe('category + tags alignment (pmxt taxonomy)', () => {
it('maps a Hunch crypto subtype to the top-level "Crypto" category', () => {
expect(normalizer.normalizeMarket(binaryMarket)!.category).toBe('Crypto');
});

it('maps an event market to "Culture"', () => {
const raw = { ...binaryMarket, category: 'event' } as HunchRawMarket;
expect(normalizer.normalizeMarket(raw)!.category).toBe('Culture');
});

it('tags carry the top category, a human subtype label, and the token', () => {
const tags = normalizer.normalizeMarket(binaryMarket)!.tags ?? [];
expect(tags).toContain('Crypto');
expect(tags).toContain('Market Cap');
expect(tags).toContain('HUNCH');
});
});
});
6 changes: 6 additions & 0 deletions sdks/python/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1632,6 +1632,8 @@ amount: float # Size of the trade in contracts/shares.
side: str # Trade side from the taker's perspective.
outcome_id: str # The outcome this trade is for (if known).
order_id: str # The order that produced this trade, if known.
market_id: str # The market this trade belongs to, when the venue exposes it (e.g. derivable from the fill's coin/asset).
fee: float # Trading fee paid by the user for this fill, when the venue exposes it.
tx_hash: str # Populated in hosted mode after on-chain settlement; null for local-mode and for non-on-chain venues.
chain: str # Populated in hosted mode after on-chain settlement; null for local-mode and for non-on-chain venues.
block_number: float # Populated in hosted mode after on-chain settlement; null for local-mode and for non-on-chain venues.
Expand Down Expand Up @@ -1998,6 +2000,8 @@ outcome_id: str # Reverse lookup -- find market containing this outcome
event_id: str # Find markets belonging to an event
page: float # For pagination (used by Limitless)
similarity_threshold: float # For semantic search (used by Limitless)
source_exchange: str # Filter by source venue (e.g. 'polymarket', 'kalshi', 'myriad'). `exchange` is an alias.
exchange: str # Alias for `sourceExchange`.
```

---
Expand All @@ -2021,6 +2025,8 @@ series: str # Filter events by their parent series. Accepts the venue-native ser
filter: Any # Optional client-side filter applied after fetching
category: str # Filter by category. Each event belongs to a venue-assigned category such as "Sports", "Politics", "Crypto", "Bitcoin", "Soccer", "Economic Policy" (Polymarket) or "Sports", "Mentions" (Kalshi).
tags: List[string] # Filter by tags. Returns events matching ANY of the provided tags. Tags are more specific than categories -- for example a "Politics" event might carry tags ["Politics", "Geopolitics", "Middle East", "Iran"]. Common tags include "Crypto", "Elections", "Fed Rates", "FIFA World Cup", "Trump".
source_exchange: str # Filter by source venue (e.g. 'polymarket', 'kalshi', 'myriad'). `exchange` is an alias.
exchange: str # Alias for `sourceExchange`.
```

---
Expand Down
3 changes: 2 additions & 1 deletion sdks/python/pmxt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from .client import Exchange
from .constants import ENV, ENV_BASE_URL, ENV_API_KEY
from ._exchanges import Polymarket, Limitless, Kalshi, KalshiDemo, Probable, Baozi, Myriad, Opinion, Metaculus, Smarkets, PolymarketUS, Polymarket_us, Hyperliquid, GeminiTitan, SuiBets, Suibets, Rain, Mock, Router
from ._exchanges import Polymarket, Limitless, Kalshi, KalshiDemo, Probable, Baozi, Myriad, Opinion, Metaculus, Smarkets, PolymarketUS, Polymarket_us, Hyperliquid, GeminiTitan, SuiBets, Suibets, Rain, Hunch, Mock, Router
from .router import Router
from .feed_client import FeedClient
from .server_manager import ServerManager
Expand Down Expand Up @@ -185,6 +185,7 @@ def restart_server() -> None:
"SuiBets",
"Suibets",
"Rain",
"Hunch",
"Mock",
"Router",
"Exchange",
Expand Down
28 changes: 28 additions & 0 deletions sdks/python/pmxt/_exchanges.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,34 @@ def __init__(
)


class Hunch(Exchange):
"""Hunch exchange client."""

def __init__(
self,
private_key: Optional[str] = None,
base_url: Optional[str] = None,
auto_start_server: Optional[bool] = None,
pmxt_api_key: Optional[str] = None,
) -> None:
"""
Initialize Hunch client.

Args:
private_key: Private key for authentication (optional)
base_url: Base URL of the PMXT sidecar server
auto_start_server: Automatically start server if not running (default: True)
pmxt_api_key: Hosted PMXT API key (optional; enables hosted mode)
"""
super().__init__(
exchange_name="hunch",
private_key=private_key,
base_url=base_url,
auto_start_server=auto_start_server,
pmxt_api_key=pmxt_api_key,
)


class Mock(Exchange):
"""Mock exchange client."""

Expand Down
Loading
Loading