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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 11 additions & 5 deletions src/signals/basket.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions test/basket-live-fallback.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading