From 2802d6a36ec207b029fa7690c6017b07a05fd9d3 Mon Sep 17 00:00:00 2001 From: Oyebisi Ayobami Date: Tue, 29 Sep 2026 11:42:38 +0100 Subject: [PATCH] Bind basket correlation adjustment to selected market pools --- README.md | 2 ++ src/signals/basket.mjs | 16 ++++++++++++---- test/basket-live-fallback.test.mjs | 25 +++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b8d0946..25a616b 100644 --- a/README.md +++ b/README.md @@ -203,4 +203,6 @@ npm run smoke:production -- https://YOUR-API-HOST https://YOUR-SITE.vercel.app When basket legs request an `intervalMin`, both marginals must come from priced markets for that exact interval. Missing or unpriced requested windows return `insufficient_data`; aggregate sentiment and other intervals cannot substitute. If those marginals exist but correlation covers a different interval, the result is `independence_only` with a null adjusted probability. An independent estimate is not evidence that the two events are independent. +When several pools share that interval, the basket selects the correlation snapshot's pool when it is still priced. If a snapshot pool is no longer present among the priced markets, the basket reports the independent estimate and withholds the correlation adjustment. Matching interval alone does not establish matching event identity. + Run the deterministic engine regression suite with `npm ci && npm test`. Live API and production smoke scripts remain separate because they depend on external market data. diff --git a/src/signals/basket.mjs b/src/signals/basket.mjs index 9490b53..890e8ff 100644 --- a/src/signals/basket.mjs +++ b/src/signals/basket.mjs @@ -18,12 +18,14 @@ async function resolveDependencies(deps) { function selectWindow(sentiment, correlation, side, preferredWindowMin = null) { if (!Array.isArray(sentiment.windows) || !sentiment.windows.length) return null; + const pool = side === "A" ? correlation?.marketPoolA : correlation?.marketPoolB; + if (Number.isFinite(preferredWindowMin)) { - const requested = sentiment.windows.find((window) => window.intervalMin === preferredWindowMin && Number.isFinite(window.impliedProbabilityRaw)); + const matching = sentiment.windows.filter((window) => window.intervalMin === preferredWindowMin && Number.isFinite(window.impliedProbabilityRaw)); + const requested = matching.find((window) => pool && window.pool?.toLowerCase() === pool.toLowerCase()) || matching[0]; return requested || null; } - const pool = side === "A" ? correlation?.marketPoolA : correlation?.marketPoolB; if (pool) { const exactPool = sentiment.windows.find((window) => window.pool?.toLowerCase() === pool.toLowerCase()); if (exactPool) return exactPool; @@ -97,12 +99,18 @@ export async function priceBasket({ legs }, deps = {}) { const correlationWindowMismatch = preferredMarketWindowMin !== null && correlation.marketWindowMin !== preferredMarketWindowMin; - if (correlation.status !== "ok" || correlation.correlation === null || correlationWindowMismatch) { + const correlationPoolMismatch = [ + [correlation.marketPoolA, marginalA.window?.pool], + [correlation.marketPoolB, marginalB.window?.pool], + ].some(([correlationPool, pricedPool]) => correlationPool && pricedPool && correlationPool.toLowerCase() !== pricedPool.toLowerCase()); + if (correlation.status !== "ok" || correlation.correlation === null || correlationWindowMismatch || correlationPoolMismatch) { return { status: "independence_only", note: correlationWindowMismatch ? "Requested market prices are usable, but correlation does not cover the requested event window" - : "DreamDEX prices are usable, but current candle history is too asynchronous/sparse for a defensible correlation adjustment", + : correlationPoolMismatch + ? "Market prices are usable, but correlation was measured from a different market pool" + : "DreamDEX prices are usable, but current candle history is too asynchronous/sparse for a defensible correlation adjustment", ...base, adjustedProbability: null, pricingDifference: null, diff --git a/test/basket-live-fallback.test.mjs b/test/basket-live-fallback.test.mjs index ca1ce63..77b90a9 100644 --- a/test/basket-live-fallback.test.mjs +++ b/test/basket-live-fallback.test.mjs @@ -97,3 +97,28 @@ test('explicit window withholds adjustment from a different correlation window', assert.deepEqual(result.legs.map(leg => leg.intervalMin), [60, 60]); assert.ok(Math.abs(result.independentProbability - 0.0371) < 1e-12); }); + +test('explicit window never applies correlation from different pools at the same interval', async () => { + const result = await priceBasket({ legs: [ + { asset: 'BTC', intervalMin: 60 }, { asset: 'ETH', intervalMin: 60 }, + ] }, { + getAssetSentiment: async asset => asset === 'BTC' ? { + ...btc, windows: [{ ...btc.windows[2], pool: '0xbtc-current', impliedProbabilityRaw: 0.8 }, { ...btc.windows[2], pool: '0xbtc-old', impliedProbabilityRaw: 0.1 }], + } : eth, + calculateAssetCorrelation: async () => ({ status: 'ok', correlation: 0.8, qualityScore: 1, confidence: 'high', marketWindowMin: 60, marketPoolA: '0xbtc-old', marketPoolB: '0xeth60' }), + }); + assert.equal(result.legs[0].pool, '0xbtc-old'); + assert.equal(result.legs[0].probability, 0.1); + assert.equal(result.status, 'ok'); +}); + +test('correlation is withheld if its pool is absent from the priced sentiment snapshot', async () => { + const result = await priceBasket({ legs: [ + { asset: 'BTC', intervalMin: 60 }, { asset: 'ETH', intervalMin: 60 }, + ] }, { + getAssetSentiment: async asset => asset === 'BTC' ? btc : eth, + calculateAssetCorrelation: async () => ({ status: 'ok', correlation: 0.8, qualityScore: 1, confidence: 'high', marketWindowMin: 60, marketPoolA: '0xbtc-old', marketPoolB: '0xeth60' }), + }); + assert.equal(result.status, 'independence_only'); + assert.equal(result.adjustedProbability, null); +});