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
5 changes: 5 additions & 0 deletions src/adapters/exchanges/blofinPriceFeedAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
5 changes: 5 additions & 0 deletions src/adapters/exchanges/coinbasePriceFeedAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
14 changes: 14 additions & 0 deletions src/adapters/exchanges/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 23 additions & 2 deletions src/providers/adapters/technicalLocalAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OHLCVCandle[] | undefined> =>
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<TechnicalLensV1["payload"]["htf"]> = {};
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
Expand Down
2 changes: 1 addition & 1 deletion test/oracle/INTENTIONAL_DIFFS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
66 changes: 66 additions & 0 deletions test/pipeline/htfProducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OHLCVCandle[]> {
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<OHLCVCandle[]> {
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<OHLCVCandle[]> {
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.
});
2 changes: 2 additions & 0 deletions test/support/deterministicPriceFeedAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading