From b395f215c4151cd4197174f5e8bb5b76819dd5d2 Mon Sep 17 00:00:00 2001 From: Oyebisi Ayobami Date: Sun, 27 Sep 2026 22:13:05 +0100 Subject: [PATCH] fix: honor explicit basket event windows and add engine CI --- .github/workflows/ci.yml | 18 ++++++++++++++++ README.md | 6 ++++++ src/signals/basket.mjs | 16 +++++++++----- test/basket-live-fallback.test.mjs | 34 ++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6c4f86d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: Engine tests + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + engine: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test diff --git a/README.md b/README.md index e4bdd2c..b8d0946 100644 --- a/README.md +++ b/README.md @@ -198,3 +198,9 @@ After deployment, validate the public URLs with: ```bash npm run smoke:production -- https://YOUR-API-HOST https://YOUR-SITE.vercel.app ``` + +## Explicit basket windows + +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. + +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 0afa384..9490b53 100644 --- a/src/signals/basket.mjs +++ b/src/signals/basket.mjs @@ -20,7 +20,7 @@ function selectWindow(sentiment, correlation, side, preferredWindowMin = null) { if (Number.isFinite(preferredWindowMin)) { const requested = sentiment.windows.find((window) => window.intervalMin === preferredWindowMin && Number.isFinite(window.impliedProbabilityRaw)); - if (requested) return requested; + return requested || null; } const pool = side === "A" ? correlation?.marketPoolA : correlation?.marketPoolB; @@ -67,8 +67,10 @@ export async function priceBasket({ legs }, deps = {}) { const windowA = selectWindow(sentimentA, correlation, "A", preferredMarketWindowMin); const windowB = selectWindow(sentimentB, correlation, "B", preferredMarketWindowMin); - const marginalA = marginalProbability(sentimentA, windowA); - const marginalB = marginalProbability(sentimentB, windowB); + // An explicit event window is a contract with the caller, not a preference. + // Never fall back to unrelated windows or aggregate asset sentiment. + const marginalA = preferredMarketWindowMin && !windowA ? null : marginalProbability(sentimentA, windowA); + const marginalB = preferredMarketWindowMin && !windowB ? null : marginalProbability(sentimentB, windowB); if (!marginalA || !marginalB) return { status: "insufficient_data", note: preferredMarketWindowMin ? `both basket legs need a priced ${preferredMarketWindowMin}m DreamDEX market` : "both basket legs need at least one priced DreamDEX market", legs, sentimentA, sentimentB, correlation }; const directionA = (legA.direction || "UP").toUpperCase(); @@ -93,10 +95,14 @@ export async function priceBasket({ legs }, deps = {}) { correlation, }; - if (correlation.status !== "ok" || correlation.correlation === null) { + const correlationWindowMismatch = preferredMarketWindowMin !== null + && correlation.marketWindowMin !== preferredMarketWindowMin; + if (correlation.status !== "ok" || correlation.correlation === null || correlationWindowMismatch) { return { status: "independence_only", - note: "DreamDEX prices are usable, but current candle history is too asynchronous/sparse for a defensible correlation adjustment", + 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", ...base, adjustedProbability: null, pricingDifference: null, diff --git a/test/basket-live-fallback.test.mjs b/test/basket-live-fallback.test.mjs index 335581a..ca1ce63 100644 --- a/test/basket-live-fallback.test.mjs +++ b/test/basket-live-fallback.test.mjs @@ -63,3 +63,37 @@ test("basket returns independent price when correlation remains insufficient", a assert.ok(Math.abs(result.independentProbability - 0.72 * 0.64) < 1e-12); assert.equal(result.adjustedProbability, null); }); + +for (const missing of ['BTC', 'ETH', 'both', 'aggregate-only', 'unpriced']) { + test(`explicit window refuses unavailable marginals (${missing})`, async () => { + const result = await priceBasket({ legs: [ + { asset: 'BTC', intervalMin: 60 }, { asset: 'ETH', intervalMin: 60 }, + ] }, { + getAssetSentiment: async (asset) => { + const source = asset === 'BTC' ? btc : eth; + if (missing === 'aggregate-only') return { asset, probability: 0.5 }; + if (missing === 'unpriced') return { ...source, windows: source.windows.map(w => w.intervalMin === 60 ? { ...w, impliedProbabilityRaw: null } : w) }; + return missing === 'both' || missing === asset + ? { ...source, windows: source.windows.filter(w => w.intervalMin !== 60) } + : source; + }, + calculateAssetCorrelation: async () => ({ status: 'ok', correlation: 0.4, marketWindowMin: 5, marketPoolA: '0xbtc5', marketPoolB: '0xeth5' }), + }); + assert.equal(result.status, 'insufficient_data'); + assert.equal(result.adjustedProbability, undefined); + assert.equal(result.independentProbability, undefined); + }); +} + +test('explicit window withholds adjustment from a different correlation window', 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: 5, marketPoolA: '0xbtc5', marketPoolB: '0xeth5' }), + }); + assert.equal(result.status, 'independence_only'); + assert.equal(result.adjustedProbability, null); + assert.deepEqual(result.legs.map(leg => leg.intervalMin), [60, 60]); + assert.ok(Math.abs(result.independentProbability - 0.0371) < 1e-12); +});