|
|
@@ -82,7 +82,7 @@ function emptyResult(signalId: string, error?: string): PriceResult {
|
|
|
|
|
|
/**
|
|
|
* Fetch latest CLOB prices for a set of signals.
|
|
|
- * Queries BOTH YES and NO orderbooks directly — no synthetic conversion.
|
|
|
+ * Merges YES + NO orderbooks (neg-risk) so NO prices match Polymarket website.
|
|
|
*/
|
|
|
export async function POST(request: Request) {
|
|
|
try {
|
|
|
@@ -93,16 +93,25 @@ export async function POST(request: Request) {
|
|
|
}
|
|
|
|
|
|
// Build token map: keyed by city|date|bucket to avoid cross-date collisions
|
|
|
+ // Prefer Gamma API (always has current token IDs) over DB snapshots (may be stale)
|
|
|
const tokenMap = new Map<string, TokenPair>();
|
|
|
|
|
|
- // 1. Try DB (with date filter)
|
|
|
+ // 1. Try Gamma first (authoritative source for current token IDs)
|
|
|
+ const gammaTokens = await fetchAllGammaTokens();
|
|
|
+ for (const signal of signals) {
|
|
|
+ const dateStr = signal.targetDate?.slice(0, 10) || '';
|
|
|
+ const key = `${signal.city}|${dateStr}|${signal.bucket}`;
|
|
|
+ if (gammaTokens.has(key)) {
|
|
|
+ tokenMap.set(key, gammaTokens.get(key)!);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. DB fallback for any missing (e.g. if Gamma API is down)
|
|
|
for (const signal of signals) {
|
|
|
- // All signal types use per-bucket pricing
|
|
|
const dateStr = signal.targetDate?.slice(0, 10) || '';
|
|
|
const key = `${signal.city}|${dateStr}|${signal.bucket}`;
|
|
|
if (tokenMap.has(key)) continue;
|
|
|
|
|
|
- // Build date range filter
|
|
|
const whereClause: Record<string, unknown> = {
|
|
|
city: signal.city,
|
|
|
bucketLabel: signal.bucket,
|
|
|
@@ -125,24 +134,6 @@ export async function POST(request: Request) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 2. Gamma fallback for missing (now keyed by city|date|bucket)
|
|
|
- const hasMissing = signals.some((s) => {
|
|
|
- // All signal types need token lookup
|
|
|
- const dateStr = s.targetDate?.slice(0, 10) || '';
|
|
|
- return !tokenMap.has(`${s.city}|${dateStr}|${s.bucket}`);
|
|
|
- });
|
|
|
-
|
|
|
- if (hasMissing) {
|
|
|
- const gammaTokens = await fetchAllGammaTokens();
|
|
|
- for (const signal of signals) {
|
|
|
- const dateStr = signal.targetDate?.slice(0, 10) || '';
|
|
|
- const key = `${signal.city}|${dateStr}|${signal.bucket}`;
|
|
|
- if (!tokenMap.has(key) && gammaTokens.has(key)) {
|
|
|
- tokenMap.set(key, gammaTokens.get(key)!);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
// Fetch prices for each signal
|
|
|
const results: PriceResult[] = await Promise.all(
|
|
|
signals.map(async (signal): Promise<PriceResult> => {
|
|
|
@@ -168,33 +159,50 @@ export async function POST(request: Request) {
|
|
|
return emptyResult(signal.signalId, 'Orderbook unavailable');
|
|
|
}
|
|
|
|
|
|
- // Parse raw YES orderbook
|
|
|
- const rawYesBids: BookLevel[] = (yesBook?.bids || [])
|
|
|
+ // Merge YES + NO books (neg-risk: NO prices derived from YES complement)
|
|
|
+ const mergedYesBids: BookLevel[] = (yesBook?.bids || [])
|
|
|
.map((b) => ({ price: parseFloat(b.price), size: parseFloat(b.size) }));
|
|
|
- const rawYesAsks: BookLevel[] = (yesBook?.asks || [])
|
|
|
+ const mergedYesAsks: BookLevel[] = (yesBook?.asks || [])
|
|
|
.map((a) => ({ price: parseFloat(a.price), size: parseFloat(a.size) }));
|
|
|
|
|
|
- // Parse raw NO orderbook
|
|
|
- const rawNoBids: BookLevel[] = (noBook?.bids || [])
|
|
|
- .map((b) => ({ price: parseFloat(b.price), size: parseFloat(b.size) }));
|
|
|
- const rawNoAsks: BookLevel[] = (noBook?.asks || [])
|
|
|
- .map((a) => ({ price: parseFloat(a.price), size: parseFloat(a.size) }));
|
|
|
+ if (noBook) {
|
|
|
+ // Synthetic YES bids from NO asks: NO ask @ P → YES bid @ (1-P)
|
|
|
+ for (const a of noBook.asks || []) {
|
|
|
+ const syn = 1 - parseFloat(a.price);
|
|
|
+ if (syn > 0 && syn < 1) mergedYesBids.push({ price: syn, size: parseFloat(a.size) });
|
|
|
+ }
|
|
|
+ // Synthetic YES asks from NO bids: NO bid @ P → YES ask @ (1-P)
|
|
|
+ for (const b of noBook.bids || []) {
|
|
|
+ const syn = 1 - parseFloat(b.price);
|
|
|
+ if (syn > 0 && syn < 1) mergedYesAsks.push({ price: syn, size: parseFloat(b.size) });
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- // Use DIRECT orderbook prices (matches Polymarket website)
|
|
|
- rawYesBids.sort((a, b) => b.price - a.price);
|
|
|
- rawYesAsks.sort((a, b) => a.price - b.price);
|
|
|
- rawNoBids.sort((a, b) => b.price - a.price);
|
|
|
- rawNoAsks.sort((a, b) => a.price - b.price);
|
|
|
+ // Deduplicate by aggregating sizes at same price level
|
|
|
+ const dedup = (levels: BookLevel[]) => {
|
|
|
+ const map = new Map<number, number>();
|
|
|
+ for (const l of levels) {
|
|
|
+ const rounded = Math.round(l.price * 10000) / 10000;
|
|
|
+ map.set(rounded, (map.get(rounded) ?? 0) + l.size);
|
|
|
+ }
|
|
|
+ return Array.from(map, ([price, size]) => ({ price, size }));
|
|
|
+ };
|
|
|
+ const dedupBids = dedup(mergedYesBids);
|
|
|
+ const dedupAsks = dedup(mergedYesAsks);
|
|
|
+ dedupBids.sort((a, b) => b.price - a.price);
|
|
|
+ dedupAsks.sort((a, b) => a.price - b.price);
|
|
|
|
|
|
- const yesBestBid = rawYesBids[0]?.price ?? 0;
|
|
|
- const yesBestAsk = rawYesAsks[0]?.price ?? 1;
|
|
|
+ const yesBestBid = dedupBids[0]?.price ?? 0;
|
|
|
+ const yesBestAsk = dedupAsks[0]?.price ?? 1;
|
|
|
const yesSpread = yesBestAsk - yesBestBid;
|
|
|
+ const yesMidpoint = (yesBestBid + yesBestAsk) / 2;
|
|
|
|
|
|
- const noBestBid = rawNoBids[0]?.price ?? 0;
|
|
|
- const noBestAsk = rawNoAsks[0]?.price ?? 1;
|
|
|
+ // Derive NO prices from merged YES book (matches Polymarket website)
|
|
|
+ // NO best ask = 1 - YES best bid (to buy NO, someone sells YES)
|
|
|
+ // NO best bid = 1 - YES best ask (to sell NO, someone buys YES)
|
|
|
+ const noBestAsk = yesBestBid > 0 ? 1 - yesBestBid : 1;
|
|
|
+ const noBestBid = yesBestAsk < 1 ? 1 - yesBestAsk : 0;
|
|
|
const noSpread = noBestAsk - noBestBid;
|
|
|
-
|
|
|
- const yesMidpoint = (yesBestBid + yesBestAsk) / 2;
|
|
|
const noMidpoint = (noBestBid + noBestAsk) / 2;
|
|
|
|
|
|
return {
|
|
|
@@ -209,8 +217,9 @@ export async function POST(request: Request) {
|
|
|
noBestAsk,
|
|
|
noSpread,
|
|
|
noMidpoint,
|
|
|
- noAsks: rawNoAsks.slice(0, 5),
|
|
|
- yesAsks: rawYesAsks.slice(0, 5),
|
|
|
+ // Derive NO asks from YES bids (NO ask @ (1-P) for each YES bid @ P)
|
|
|
+ noAsks: dedupBids.slice(0, 5).map((b) => ({ price: 1 - b.price, size: b.size })),
|
|
|
+ yesAsks: dedupAsks.slice(0, 5),
|
|
|
// Legacy
|
|
|
bestBid: yesBestBid,
|
|
|
bestAsk: yesBestAsk,
|