From acd6ffc64b66cf6d5c0d6d4bfe3f467a211fc8d1 Mon Sep 17 00:00:00 2001 From: Giovanni Giovanni Date: Tue, 25 Aug 2026 21:32:16 -0700 Subject: [PATCH 1/2] DEM-PRODUCER-HTF fix: a venue that cannot serve the window is an ABSENCE, not a failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the afi-gateway boundary proof (the one CI job that runs the reactor against the REAL coinbase feed): every submission 500'd after HTF merged. Cause, and why it is a real defect rather than a test-environment quirk: coinbase has no weekly bar (ccxt's own timeframes table), so the registered `1w` window's fetch threw and aborted the whole determination. Any coinbase-backed composition would have been permanently unscoreable in production too — which is not what the slot intended, and not what D-DEM-5(4)(b) means by a producer that "may legitimately not emit" a fact. The fix keeps the two cases strictly apart, because conflating them WOULD be the fall-back the owner's constraint forbids: - a timeframe the SELECTED VENUE does not offer is a CAPABILITY fact, known before any request is made: the window is not requested at all, no sub-block is emitted, and the registered mapping's declared `absent` member recodes it to neutral as a RECORDED default (D-DEM-5(4)(b)); - a fetch that FAILS for a timeframe the venue DOES offer is a real failure and still aborts the determination — fail closed, never fall back. - adapters/exchanges/types.ts: PriceFeedAdapter advertises `supportedTimeframes` (optional; absent = unknown, so every timeframe is attempted and nothing is silently skipped). The two ccxt adapters expose ccxt's own table; the deterministic test feed advertises the fixture timeframes. - technicalLocalAdapter: skips an unoffered window (logged), otherwise unchanged. - htfProducer.test.ts: three new cases — the unoffered window is never REQUESTED and lands as a declared absence; a fetch failure on an offered window still refuses; a feed with no capability table is treated as offering everything. - INTENTIONAL_DIFFS.md: recorded as residual (5) — a coinbase-backed composition scores weeklyBias=neutral permanently, capping its structure at 0.6. blofin (the production feed) offers both windows. Zero golden movement: goldens run on the deterministic feed, which offers both. 794/794 green; typecheck green. Co-Authored-By: Claude Fable 5 --- .../exchanges/blofinPriceFeedAdapter.ts | 5 ++ .../exchanges/coinbasePriceFeedAdapter.ts | 5 ++ src/adapters/exchanges/types.ts | 14 ++++ .../adapters/technicalLocalAdapter.ts | 25 ++++++- test/pipeline/htfProducer.test.ts | 66 +++++++++++++++++++ test/support/deterministicPriceFeedAdapter.ts | 2 + 6 files changed, 115 insertions(+), 2 deletions(-) diff --git a/src/adapters/exchanges/blofinPriceFeedAdapter.ts b/src/adapters/exchanges/blofinPriceFeedAdapter.ts index b45df04..333dd7f 100644 --- a/src/adapters/exchanges/blofinPriceFeedAdapter.ts +++ b/src/adapters/exchanges/blofinPriceFeedAdapter.ts @@ -28,6 +28,11 @@ class BloFinPriceFeedAdapter implements PriceFeedAdapter { public readonly supportsPerps = true; public readonly supportsSpot = true; + /** ccxt's own timeframe table for this venue (a capability fact). */ + public get supportedTimeframes(): readonly string[] { + return Object.keys(this.exchange.timeframes ?? {}); + } + // D8-R2: this `any` is why the OHLCV mapping below type-checks at all. // Typing it `ccxt.Exchange` surfaces that ccxt declares every OHLCV // element as `Num` (number | undefined) and `ticker.symbol` as `Str`, diff --git a/src/adapters/exchanges/coinbasePriceFeedAdapter.ts b/src/adapters/exchanges/coinbasePriceFeedAdapter.ts index 00dcc71..cd728dc 100644 --- a/src/adapters/exchanges/coinbasePriceFeedAdapter.ts +++ b/src/adapters/exchanges/coinbasePriceFeedAdapter.ts @@ -31,6 +31,11 @@ class CoinbasePriceFeedAdapter implements PriceFeedAdapter { public readonly supportsPerps = false; // Coinbase spot only public readonly supportsSpot = true; + /** ccxt's own timeframe table for this venue (a capability fact). */ + public get supportedTimeframes(): readonly string[] { + return Object.keys(this.exchange.timeframes ?? {}); + } + // D8-R2: this `any` is why the OHLCV mapping below type-checks at all. // Typing it `ccxt.Exchange` surfaces that ccxt declares every OHLCV // element as `Num` (number | undefined) and `ticker.symbol` as `Str`, diff --git a/src/adapters/exchanges/types.ts b/src/adapters/exchanges/types.ts index 775e7b6..2202541 100644 --- a/src/adapters/exchanges/types.ts +++ b/src/adapters/exchanges/types.ts @@ -79,6 +79,20 @@ export interface PriceFeedAdapter { /** Whether this adapter supports spot markets */ supportsSpot: boolean; + + /** + * The timeframe tokens this venue advertises (e.g. ["1m","5m","1h","1d"]). + * A CAPABILITY fact, known before any request is made — not an outcome. + * + * DEM-PRODUCER-HTF (DEM-GOV §9) reads it to distinguish two cases that must + * never be conflated: a timeframe this venue does not offer at all (the + * producer legitimately cannot emit that fact — a declared absence under + * D-DEM-5(4)(b)), and a fetch that FAILED for a timeframe the venue does + * offer (a real failure, which must refuse the determination — fail closed, + * never fall back). Absent means "unknown", and callers must then treat + * every timeframe as supported so nothing is silently skipped. + */ + supportedTimeframes?: readonly string[]; /** * Fetch OHLCV candles for a symbol diff --git a/src/providers/adapters/technicalLocalAdapter.ts b/src/providers/adapters/technicalLocalAdapter.ts index a33ff48..91271ce 100644 --- a/src/providers/adapters/technicalLocalAdapter.ts +++ b/src/providers/adapters/technicalLocalAdapter.ts @@ -139,16 +139,37 @@ export function createTechnicalLocalAdapter(deps?: TechnicalLocalAdapterDeps): P // The signal window and both higher-timeframe windows are fetched // CONCURRENTLY: this lane is the pipeline's entry node, so serial // round-trips here delay every downstream wave (platform-floor rule). + // A timeframe the SELECTED VENUE does not offer is a capability fact + // known before any request: the producer legitimately cannot emit that + // bias (a declared absence under D-DEM-5(4)(b)), so the window is not + // requested at all. A fetch that FAILS for a timeframe the venue does + // offer is a real failure and still aborts the determination — fail + // closed, never fall back. `supportedTimeframes` absent = unknown, so + // every timeframe is attempted and nothing is silently skipped. + const offers = (tf: string): boolean => + feed.supportedTimeframes === undefined || feed.supportedTimeframes.includes(tf); + const fetchHtf = (tf: string): Promise => + htfConfig && offers(tf) + ? feed.getOHLCV({ symbol, timeframe: tf, limit: htfConfig.htfCandleLimit }) + : Promise.resolve(undefined); const [rawCandles, rawDaily, rawWeekly] = await Promise.all([ feed.getOHLCV({ symbol, timeframe, limit }), - htfConfig ? feed.getOHLCV({ symbol, timeframe: htfConfig.dailyTimeframe, limit: htfConfig.htfCandleLimit }) : Promise.resolve(undefined), - htfConfig ? feed.getOHLCV({ symbol, timeframe: htfConfig.weeklyTimeframe, limit: htfConfig.htfCandleLimit }) : Promise.resolve(undefined), + htfConfig ? fetchHtf(htfConfig.dailyTimeframe) : Promise.resolve(undefined), + htfConfig ? fetchHtf(htfConfig.weeklyTimeframe) : Promise.resolve(undefined), ]); const candles = toAfiCandles(rawCandles); const technical = d.computeTechnical(candles); if (htfConfig && technical) { const htf: NonNullable = {}; + if (!offers(htfConfig.dailyTimeframe) || !offers(htfConfig.weeklyTimeframe)) { + ctx.logger.info("higher-timeframe window not offered by the selected venue (declared absence)", { + priceSource, + daily: htfConfig.dailyTimeframe, + weekly: htfConfig.weeklyTimeframe, + offered: feed.supportedTimeframes?.join(",") ?? "unknown", + }); + } const daily = htfBias(d.computeTechnical, rawDaily, htfConfig.dailyTimeframe); const weekly = htfBias(d.computeTechnical, rawWeekly, htfConfig.weeklyTimeframe); // A window below the kernel floor emits NO sub-block — a declared diff --git a/test/pipeline/htfProducer.test.ts b/test/pipeline/htfProducer.test.ts index 10cd9a3..9d47629 100644 --- a/test/pipeline/htfProducer.test.ts +++ b/test/pipeline/htfProducer.test.ts @@ -182,3 +182,69 @@ describe("DIR-GOV D-DIR-3 — no submitted or declared direction reaches any bia expect(READS_SUBMITTED_DIRECTION.test(stripComments(stamp))).toBe(true); }); }); + +describe("DEM-PRODUCER-HTF — a venue that does not offer the window (the capability case)", () => { + /** A feed that advertises no weekly bar — exactly ccxt's coinbase table. */ + function noWeeklyFeed(seen: string[]): PriceFeedAdapter { + return { + ...(demoPriceFeedAdapter as unknown as PriceFeedAdapter), + supportedTimeframes: ["1m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "1d"], + async getOHLCV(params: { symbol: string; timeframe: string; limit?: number }): Promise { + seen.push(params.timeframe); + return demoPriceFeedAdapter.getOHLCV(params); + }, + }; + } + + it("does not REQUEST a window the venue does not offer, and emits that sub-block as a DECLARED ABSENCE", async () => { + const seen: string[] = []; + const out = (await adapter(noWeeklyFeed(seen)).run( + ctx(signal("BTC/USDT", "long"), { candleLimit: 100, htf: HTF }) + )) as unknown as { technical: { htf?: Htf } }; + // The weekly window is never requested — a capability fact, known up front. + expect(seen.sort()).toEqual(["1d", "4h"]); + // The daily bias is still produced; the weekly one is absent, and the + // registered mapping's `absent` member recodes it to neutral (recorded). + expect(out.technical.htf!.daily).toMatchObject({ timeframe: "1d" }); + expect(out.technical.htf!.weekly).toBeUndefined(); + }); + + it("a FETCH FAILURE on a timeframe the venue DOES offer still refuses (fail closed, never fall back)", async () => { + const failing: PriceFeedAdapter = { + ...(demoPriceFeedAdapter as unknown as PriceFeedAdapter), + supportedTimeframes: ["4h", "1d", "1w"], + async getOHLCV(params: { symbol: string; timeframe: string; limit?: number }): Promise { + if (params.timeframe === "1w") throw new Error("upstream 500 from the venue"); + return demoPriceFeedAdapter.getOHLCV(params); + }, + }; + await expect( + adapter(failing).run(ctx(signal("BTC/USDT", "long"), { candleLimit: 100, htf: HTF })) + ).rejects.toThrow(/upstream 500/); + }); + + it("a feed that advertises NO capability table is treated as offering everything (nothing silently skipped)", async () => { + const seen: string[] = []; + const unknown: PriceFeedAdapter = { + ...(demoPriceFeedAdapter as unknown as PriceFeedAdapter), + supportedTimeframes: undefined, + async getOHLCV(params: { symbol: string; timeframe: string; limit?: number }): Promise { + seen.push(params.timeframe); + return demoPriceFeedAdapter.getOHLCV(params); + }, + }; + const out = (await adapter(unknown).run( + ctx(signal("BTC/USDT", "long"), { candleLimit: 100, htf: HTF }) + )) as unknown as { technical: { htf?: Htf } }; + expect(seen.sort()).toEqual(["1d", "1w", "4h"]); + expect(out.technical.htf!.weekly).toBeDefined(); + }); + + // NOTE: the real venues' capability tables are ccxt's own + // (`exchange.timeframes`), surfaced by each adapter's `supportedTimeframes` + // getter. They are not asserted here because importing the ccxt-backed + // adapters pulls ESM jest cannot parse (the repo idiom is to mock ccxt). + // Verified directly against ccxt at 2026-08-25: blofin offers 1d AND 1w; + // coinbase offers 1d and has NO weekly bar — which is exactly why this + // capability branch exists. +}); diff --git a/test/support/deterministicPriceFeedAdapter.ts b/test/support/deterministicPriceFeedAdapter.ts index 1f71a72..60e20ae 100644 --- a/test/support/deterministicPriceFeedAdapter.ts +++ b/test/support/deterministicPriceFeedAdapter.ts @@ -66,6 +66,8 @@ class DemoPriceFeedAdapter implements PriceFeedAdapter { public readonly name = "Deterministic Synthetic (non-production)"; public readonly supportsPerps = true; public readonly supportsSpot = true; + /** The synthetic feed offers every timeframe the fixtures use (capability fact). */ + public supportedTimeframes: readonly string[] = ["5m", "15m", "1h", "4h", "1d", "1w"]; /** * Generate deterministic synthetic OHLCV candles. From 511fb5b5f9b43fb88e0c299243afb73a398af168 Mon Sep 17 00:00:00 2001 From: Giovanni Giovanni Date: Tue, 25 Aug 2026 21:32:41 -0700 Subject: [PATCH 2/2] Record the venue-capability case in the D-DEM-5(5) residual list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Residual (5): a venue that does not offer a registered higher-timeframe window yields that bias as a declared absence for every signal it serves — coinbase has no weekly bar, so a coinbase-backed composition caps structure at 0.6; blofin (the production feed) offers both. The capability is checked before the request; a genuine fetch failure still refuses. Co-Authored-By: Claude Fable 5 --- test/oracle/INTENTIONAL_DIFFS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/oracle/INTENTIONAL_DIFFS.md b/test/oracle/INTENTIONAL_DIFFS.md index 8dc7402..2f3708e 100644 --- a/test/oracle/INTENTIONAL_DIFFS.md +++ b/test/oracle/INTENTIONAL_DIFFS.md @@ -676,4 +676,4 @@ The structure axis is no longer capped at `0.40`, but its two live terms are the ## D-DEM-5(5) residual after the four slots -Every scorer input is now a computed fact of a registered producer (D-DEM-5(1)); the residual is `liquiditySwept` alone, whose disposition D-DEM-3(5) expressly reserves. What remains, recorded honestly: (1) **risk on plan-less submissions** — the TradingView/MarkitTick routes carry no trade plan, so risk there is the declared floor moved only by `brokeEmaWithBody`; closing it needs a plan carrier on those routes (a separate authorization). (2) **The HA flat-back term** (+0.2) is unreachable in a `range` regime by construction of the rubric — a formula question for a modelling filing, not a placeholder. (3) **R:R is verified-plausible but submitter-chosen**: a stop-placement validity rule would be a new threshold (§8), so it belongs to a modelling filing. (4) **Symbols with fewer than 50 weekly bars** on the registered feed (listings under ~1 year) score `weeklyBias = neutral` permanently — a declared absence, not a defect, but it caps their structure at 0.6. +Every scorer input is now a computed fact of a registered producer (D-DEM-5(1)); the residual is `liquiditySwept` alone, whose disposition D-DEM-3(5) expressly reserves. What remains, recorded honestly: (1) **risk on plan-less submissions** — the TradingView/MarkitTick routes carry no trade plan, so risk there is the declared floor moved only by `brokeEmaWithBody`; closing it needs a plan carrier on those routes (a separate authorization). (2) **The HA flat-back term** (+0.2) is unreachable in a `range` regime by construction of the rubric — a formula question for a modelling filing, not a placeholder. (3) **R:R is verified-plausible but submitter-chosen**: a stop-placement validity rule would be a new threshold (§8), so it belongs to a modelling filing. (4) **Symbols with fewer than 50 weekly bars** on the registered feed (listings under ~1 year) score `weeklyBias = neutral` permanently — a declared absence, not a defect, but it caps their structure at 0.6. (5) **A venue that does not offer a registered higher-timeframe window** yields that bias as a declared absence for every signal it serves: `coinbase` has **no weekly bar** (ccxt's own timeframes table), so a coinbase-backed composition scores `weeklyBias = neutral` permanently and its structure is capped at 0.6; `blofin` — the production feed — offers both `1d` and `1w`. This is a capability fact checked BEFORE the request, so the window is never asked for; a fetch that FAILS for a timeframe the venue does offer still refuses the determination (fail closed, never fall back). Found by the afi-gateway boundary proof, which runs the reactor against the real coinbase feed.