From 27d1c8debe4ec63f72f1b6602e399a38adfd82db Mon Sep 17 00:00:00 2001 From: Giovanni Giovanni Date: Tue, 25 Aug 2026 17:48:09 -0700 Subject: [PATCH 1/2] DEM-PRODUCER-PLAN (reactor): the planned R:R is a verified provider fact; goldens regenerated once, itemized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEM-GOV §9 DEM-PRODUCER-PLAN (owner-authorized 2026-08-25; afi-governance #55; §9 determinations D-2/D-4/D-5 in #56). Merge AFTER afi-core dem/plan-core and afi-config dem/plan-config. Source: - uss/cpjMapper.ts: the submitted plan survives as uss.plan (afi.trade-plan.v1, decimal strings, validated against the governed contract) or is refused at ingest (typed error → 422 trade_plan_invalid). TV/MarkitTick untouched. - enrichment/tradePlanVerification.ts (NEW): the D-DEM-5(6) law — every submitted level within [L − W, H + W] of the fetched window; plan geometry from prices only (D-DIR-3 by construction); R:R to the nearest target, 4 dp; refusal = TradePlanVerificationError (NodeConfigurationError). - providers/adapters/technicalLocalAdapter.ts: the producer — verifies the plan against ITS fetched candles and emits technical.plan (declared absences: no plan → no block; no stop/target → no R:R). - pipeline/nodes/laneView.ts: viewTechnical projects plan verbatim. - server.ts: 422 trade_plan_unverifiable (unwrapping NodeExecutionError.cause), 422 trade_plan_invalid; both persisted:false. - types/TradePlan.ts (NEW), types/UssLenses.ts, uss/ussValidator.ts: types. Registries/pins: the three fixture trees mirror afi-config (mapping 1.1.0, config, three implementationVersion moves, registration hash 5cb9b7a4…); registryLoader/officialArtifactPins pins rotated (pluginSetHash 36f911f4…). Goldens: the two plan-bearing CPJ fixtures re-authored inside the demo envelope (old levels were unverifiable by the law this slot lands); 12 goldens regenerated ONCE; every moved path itemized in INTENTIONAL_DIFFS.md (risk 0.9 → {0.5 via the provider's rr 1.4286 | 0.2 via the declared floor}; executionSummaryHash flips executed → degraded on the two goldens where the floor is the first fired default; identity-hash class on all 12). test/oracle/support/goldenDiff.mjs (NEW): the committed per-path differ. Tests: verification KATs (band, geometry, absences, error class), carrier (decimal strings, refusals), producer (role split, D-DIR-3 negative test, refusal, kernel floor, envelope-covers-fixtures), error-table rows for both 422s (no record), seam suites re-oracled to the composition reference with the mapping resolved by mappingRef from the fixture registry, golden scorerInput captured at the rubric call. 756/756; typecheck; compiled-build proofs (unavailable 503; Mongo persistence 10/10; oracle equivalence 7/7; SIGTERM) green locally. No scoring-law value moves. Sealed records untouched. Co-Authored-By: Claude Fable 5 --- src/enrichment/tradePlanVerification.ts | 160 +++++++++++++++ src/pipeline/nodes/laneView.ts | 4 + .../adapters/technicalLocalAdapter.ts | 17 ++ src/server.ts | 45 +++- src/types/TradePlan.ts | 74 +++++++ src/types/UssLenses.ts | 8 + src/uss/cpjMapper.ts | 119 ++++++++++- src/uss/ussValidator.ts | 8 + .../mappingPerStrategyResolution.test.ts | 86 +++++--- test/guardrails/uwrRuntimeProfile.test.ts | 32 ++- test/guardrails/uwrStampSemantics.test.ts | 19 +- .../reactorEvidencePersistence.mjs | 7 +- .../reactorEvidenceUnavailable.mjs | 3 +- test/oracle/INTENTIONAL_DIFFS.md | 48 +++++ .../afi-analysis-technical--2.0.0.json | 13 +- .../afi-merge-enriched-view--1.1.0.json | 6 +- ...i-scorer-froggy-trend-pullback--1.0.0.json | 2 +- ...oggy--trend_pullback_v1--1.0.0.config.json | 11 +- .../froggy--trend_pullback_v1--1.0.0.json | 2 +- .../froggy-trend-pullback--1.1.0.json | 99 +++++++++ .../fixtures/cpj/cpj-blofin-perp-long.json | 12 +- .../fixtures/cpj/cpj-coinbase-spot-sell.json | 4 +- .../cpj-blofin-perp-long.builtin.json | 129 +++++++++--- .../cpj-blofin-perp-long.registry.json | 129 +++++++++--- .../cpj-blofin-perp-neutral.builtin.json | 32 +-- .../cpj-blofin-perp-neutral.registry.json | 32 +-- .../cpj-coinbase-spot-sell.builtin.json | 107 +++++++--- .../cpj-coinbase-spot-sell.registry.json | 107 +++++++--- .../goldens/enriched/tv-long.builtin.json | 34 +-- .../goldens/enriched/tv-long.registry.json | 34 +-- .../goldens/enriched/tv-neutral.builtin.json | 32 +-- .../goldens/enriched/tv-neutral.registry.json | 32 +-- .../goldens/enriched/tv-short.builtin.json | 32 +-- .../goldens/enriched/tv-short.registry.json | 32 +-- test/oracle/oracleErrorTable.test.ts | 29 +++ test/oracle/oracleGoldensEnriched.test.ts | 11 +- test/oracle/oracleInvariance.test.ts | 14 +- test/oracle/support/goldenDiff.mjs | 145 +++++++++++++ .../afi-analysis-technical--2.0.0.json | 13 +- .../afi-merge-enriched-view--1.1.0.json | 6 +- ...i-scorer-froggy-trend-pullback--1.0.0.json | 2 +- ...oggy--trend_pullback_v1--1.0.0.config.json | 11 +- .../froggy--trend_pullback_v1--1.0.0.json | 2 +- .../froggy-trend-pullback--1.1.0.json | 99 +++++++++ .../afi-analysis-technical--2.0.0.json | 13 +- .../afi-merge-enriched-view--1.1.0.json | 6 +- ...i-scorer-froggy-trend-pullback--1.0.0.json | 2 +- ...oggy--trend_pullback_v1--1.0.0.config.json | 11 +- .../froggy--trend_pullback_v1--1.0.0.json | 2 +- .../froggy-trend-pullback--1.1.0.json | 99 +++++++++ test/pipeline/officialArtifactPins.test.ts | 4 +- test/pipeline/registryLoader.test.ts | 4 +- test/pipeline/support/froggyMapping.ts | 54 +++++ test/pipeline/tradePlanCarrier.test.ts | 85 ++++++++ test/pipeline/tradePlanProducer.test.ts | 127 ++++++++++++ test/pipeline/tradePlanVerification.test.ts | 194 ++++++++++++++++++ 56 files changed, 2064 insertions(+), 380 deletions(-) create mode 100644 src/enrichment/tradePlanVerification.ts create mode 100644 src/types/TradePlan.ts create mode 100644 test/oracle/fixtures/afi-config/registries/enrichment-mappings/froggy-trend-pullback--1.1.0.json create mode 100644 test/oracle/support/goldenDiff.mjs create mode 100644 test/pipeline/fixtures/afi-config-atlas/registries/enrichment-mappings/froggy-trend-pullback--1.1.0.json create mode 100644 test/pipeline/fixtures/afi-config/registries/enrichment-mappings/froggy-trend-pullback--1.1.0.json create mode 100644 test/pipeline/support/froggyMapping.ts create mode 100644 test/pipeline/tradePlanCarrier.test.ts create mode 100644 test/pipeline/tradePlanProducer.test.ts create mode 100644 test/pipeline/tradePlanVerification.test.ts diff --git a/src/enrichment/tradePlanVerification.ts b/src/enrichment/tradePlanVerification.ts new file mode 100644 index 00000000..dfe4b029 --- /dev/null +++ b/src/enrichment/tradePlanVerification.ts @@ -0,0 +1,160 @@ +/** + * DEM-PRODUCER-PLAN — the technical lane's trade-plan verification law + * (DEM-GOV D-DEM-5(6); §9 DEM-PRODUCER-PLAN, owner-authorized 2026-08-25). + * + * "No producer may emit as its own output a value whose sole origin is an + * assertion by the party who submitted the signal, unless that value is + * verifiable against observed market data and is validated as such by the + * producer that emits it … and refused when it cannot be checked." + * + * THE LAW (pure; no I/O; reads NO submitted side — plan geometry is derived + * from the submitted PRICES only, so DIR-GOV D-DIR-3 is honored by + * construction): + * envelope L = min(low), H = max(high) over the fetched window; W = H − L + * (W > 0 required — a flat window cannot verify anything); + * band every submitted level (entry bound(s), stop, each target) must + * lie within [L − W, H + W]: one observed-range width around the + * observed window. Breakout entries above the range and limit + * entries below it are lawful; a level a full range-width away + * from anything the market printed is not verifiable; + * geometry (only when a stop AND at least one target are submitted) every + * target lies on the same side of the stop; the entry lies + * strictly between the stop and every target. For an entry + * RANGE the conservative bound is used (nearest the targets — + * smallest reward, largest risk); + * R:R |firstTarget − entry| / |entry − stop| where firstTarget is the + * target nearest the entry; rounded half-up to 4 decimals. + * Any violation REFUSES the determination (TradePlanVerificationError — a + * NodeConfigurationError: no retry, pipeline abort, no score, no record). + * + * Declared producer absences (D-DEM-5(4)(b)): no stop or no target → no + * `rrToFirstTarget` (nothing to verify: no R:R was claimed); an entry range + * with no stop/target geometry → no `entryPrice`. + * + * This is profile math of the reference technical lane (MATH-GOV): it + * promotes nothing into afi-math and sets no scoring-law value. + */ +import { NodeConfigurationError } from "../pipeline/nodeSdk.js"; +import type { AfiCandle } from "../types/AfiCandle.js"; +import type { TechnicalPlanFacts, TradePlanV1 } from "../types/TradePlan.js"; + +export class TradePlanVerificationError extends NodeConfigurationError { + readonly code = "trade_plan_unverifiable" as const; + readonly reason: string; + constructor(reason: string) { + super(`trade plan unverifiable against the fetched candles: ${reason}`); + this.name = "TradePlanVerificationError"; + this.reason = reason; + } +} + +const DECIMAL_STRING = /^[0-9]+(\.[0-9]+)?$/; + +function refuse(reason: string): never { + throw new TradePlanVerificationError(reason); +} + +function parsePrice(label: string, raw: unknown): number { + if (typeof raw !== "string" || !DECIMAL_STRING.test(raw)) { + refuse(`${label} is not a decimal-string price (${JSON.stringify(raw)})`); + } + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) refuse(`${label} is not a positive finite price (${raw})`); + return value; +} + +/** Round half-up to 4 decimals (positive inputs only). */ +export function roundRr(value: number): number { + return Math.round(value * 1e4) / 1e4; +} + +export function verifyTradePlan(plan: TradePlanV1, candles: readonly AfiCandle[]): TechnicalPlanFacts { + if (!plan || typeof plan !== "object" || plan.schema !== "afi.trade-plan.v1" || !plan.levels) { + refuse("plan is not an afi.trade-plan.v1 object"); + } + if (candles.length === 0) refuse("no fetched candles to verify against"); + + let low = Number.POSITIVE_INFINITY; + let high = Number.NEGATIVE_INFINITY; + for (const c of candles) { + if (!Number.isFinite(c.low) || !Number.isFinite(c.high) || c.low <= 0 || c.high < c.low) { + refuse("fetched window carries a malformed candle"); + } + if (c.low < low) low = c.low; + if (c.high > high) high = c.high; + } + const width = high - low; + if (!(width > 0)) refuse("fetched window is flat (zero observed range)"); + const bandLow = low - width; + const bandHigh = high + width; + const inBand = (label: string, price: number): number => { + if (price < bandLow || price > bandHigh) { + refuse( + `${label} ${price} lies outside the observed band [${bandLow}, ${bandHigh}] ` + + `(window low ${low}, high ${high}, ${candles.length} candles)` + ); + } + return price; + }; + + // --- entry (single price or range) --- + const entryRaw = plan.levels.entry; + let entryLow: number; + let entryHigh: number; + if (typeof entryRaw === "string") { + entryLow = entryHigh = inBand("entry", parsePrice("entry", entryRaw)); + } else if (entryRaw && typeof entryRaw === "object") { + entryLow = inBand("entry.min", parsePrice("entry.min", (entryRaw as { min?: unknown }).min)); + entryHigh = inBand("entry.max", parsePrice("entry.max", (entryRaw as { max?: unknown }).max)); + if (entryLow > entryHigh) refuse(`entry range is inverted (min ${entryLow} > max ${entryHigh})`); + } else { + refuse("entry is neither a price nor a range"); + } + + // --- stop / targets (each verified against the band whenever present) --- + const stopPrice = + plan.levels.stopLoss !== undefined + ? inBand("stopLoss", parsePrice("stopLoss", plan.levels.stopLoss)) + : undefined; + const targets: number[] = []; + for (const [i, tp] of (plan.levels.takeProfits ?? []).entries()) { + targets.push(inBand(`takeProfits[${i}].price`, parsePrice(`takeProfits[${i}].price`, tp?.price))); + } + + const facts: TechnicalPlanFacts = { + entryLow, + entryHigh, + targetCount: targets.length, + envelopeLow: low, + envelopeHigh: high, + barCount: candles.length, + }; + if (entryLow === entryHigh) facts.entryPrice = entryLow; + if (stopPrice !== undefined) facts.stopPrice = stopPrice; + + // --- geometry + R:R only when a stop AND a target were submitted --- + if (stopPrice === undefined || targets.length === 0) return facts; + + const above = targets.filter((t) => t > stopPrice).length; + const below = targets.filter((t) => t < stopPrice).length; + if (above > 0 && below > 0) refuse("targets lie on both sides of the stop"); + if (above === 0 && below === 0) refuse("a target equals the stop"); + const longGeometry = above > 0; + // Conservative entry bound: nearest the targets. + const entryPrice = longGeometry ? entryHigh : entryLow; + if (longGeometry) { + if (!(stopPrice < entryPrice)) refuse(`stop ${stopPrice} is not below the entry ${entryPrice}`); + for (const t of targets) if (!(t > entryPrice)) refuse(`target ${t} is not above the entry ${entryPrice}`); + } else { + if (!(stopPrice > entryPrice)) refuse(`stop ${stopPrice} is not above the entry ${entryPrice}`); + for (const t of targets) if (!(t < entryPrice)) refuse(`target ${t} is not below the entry ${entryPrice}`); + } + let firstTarget = targets[0]; + for (const t of targets) if (Math.abs(t - entryPrice) < Math.abs(firstTarget - entryPrice)) firstTarget = t; + const risk = Math.abs(entryPrice - stopPrice); + const reward = Math.abs(firstTarget - entryPrice); + facts.entryPrice = entryPrice; + facts.firstTargetPrice = firstTarget; + facts.rrToFirstTarget = roundRr(reward / risk); + return facts; +} diff --git a/src/pipeline/nodes/laneView.ts b/src/pipeline/nodes/laneView.ts index 5abf022f..78ddd47b 100644 --- a/src/pipeline/nodes/laneView.ts +++ b/src/pipeline/nodes/laneView.ts @@ -88,6 +88,10 @@ export function viewTechnical( // AR-GOV D-AR-3: the regime is projected AND read by the adapter (unlike // the four context-only fields above/below it). atrRegime: payload.atrRegime, + // DEM-PRODUCER-PLAN: the lane's verified trade-plan facts ride the view + // verbatim so the registered mapping can bind them (technical.plan.*); + // omitted (not null) when the lane emitted none. + ...(payload.plan !== undefined ? { plan: payload.plan } : {}), }; } diff --git a/src/providers/adapters/technicalLocalAdapter.ts b/src/providers/adapters/technicalLocalAdapter.ts index a2906607..d2941749 100644 --- a/src/providers/adapters/technicalLocalAdapter.ts +++ b/src/providers/adapters/technicalLocalAdapter.ts @@ -18,6 +18,8 @@ import type { computeTechnicalEnrichment } from "../../enrichment/technicalIndic import type { AfiCandle } from "../../types/AfiCandle.js"; import { NodeConfigurationError } from "../../pipeline/nodeSdk.js"; import type { CategoryResult, ProviderAdapter, ProviderAdapterContext } from "../types.js"; +import { TradePlanVerificationError, verifyTradePlan } from "../../enrichment/tradePlanVerification.js"; +import type { TradePlanV1 } from "../../types/TradePlan.js"; export interface TechnicalLocalAdapterDeps { resolvePriceSource: typeof getDefaultPriceSource; @@ -80,6 +82,21 @@ export function createTechnicalLocalAdapter(deps?: TechnicalLocalAdapterDeps): P const candles = toAfiCandles(rawCandles); const technical = d.computeTechnical(candles); + // DEM-PRODUCER-PLAN (D-DEM-5(6)): a submitted trade plan is verified + // against THIS fetched window by THIS producer before any of its + // levels becomes a fact; an unverifiable plan refuses the determination + // (no retry, no score, no record). Reads only uss.plan and the candles — + // never facts.direction (DIR-GOV D-DIR-3) and never analyst config. + const plan = (ctx.signal as { plan?: unknown }).plan; + if (plan !== undefined && plan !== null) { + if (!technical) { + throw new TradePlanVerificationError( + `no computable indicator window (${candles.length} candles fetched, kernel floor not met)` + ); + } + technical.plan = verifyTradePlan(plan as TradePlanV1, candles); + } + ctx.logger.info("technical enrichment computed (provider adapter)", { priceSource, symbol, diff --git a/src/server.ts b/src/server.ts index dd12d22a..0985ff36 100644 --- a/src/server.ts +++ b/src/server.ts @@ -32,6 +32,7 @@ dotenv.config(); import express, { Request, Response } from "express"; import { validateUsignalV11 } from "./uss/ussValidator.js"; +import { TradePlanVerificationError } from "./enrichment/tradePlanVerification.js"; import { mapTradingViewToUssV11, type TradingViewAlertPayload, @@ -112,6 +113,26 @@ function respondWithFailure(res: Response, err: unknown, context: string): Respo persisted: false, }); } + // DEM-PRODUCER-PLAN (D-DEM-5(6)): a submitted plan the technical lane could + // not verify against its fetched candles is an honest 422 — no score, no + // record. The executor wraps the fatal node error (NodeExecutionError) with + // the original as `cause`; unwrap one level. + const cause = (err as { cause?: unknown } | null)?.cause; + const unverifiable = + err instanceof TradePlanVerificationError + ? err + : cause instanceof TradePlanVerificationError + ? cause + : undefined; + if (unverifiable) { + console.warn(`⚠️ ${context}: trade plan refused`, { reason: unverifiable.reason }); + return res.status(422).json({ + error: unverifiable.code, + message: unverifiable.message, + reason: unverifiable.reason, + persisted: false, + }); + } console.error(`❌ ${context}:`, (err as Error)?.message ?? String(err)); return res.status(500).json({ error: "internal_error", @@ -662,16 +683,28 @@ app.post("/api/ingest/cpj", async (req: Request, res: Response) => { // ✅ STEP 2: Map CPJ → USS v1.1 with strict symbol validation const mappingResult = mapCpjToUssV11(rawPayload, resolution.triple); - // Check for symbol normalization failures + // Typed mapping refusals: symbol normalization, or (DEM-PRODUCER-PLAN) a + // submitted trade plan that does not conform to afi.trade-plan.v1. if (!mappingResult.success) { - console.error(`❌ Symbol normalization failed:`, mappingResult.error); + const mappingError = mappingResult.error!; + if (mappingError.type === "trade_plan_invalid") { + console.warn(`⚠️ Trade plan refused at ingest:`, mappingError); + return res.status(422).json({ + error: "trade_plan_invalid", + message: "Submitted trade levels do not form a valid afi.trade-plan.v1", + reason: mappingError.reason, + details: mappingError.details, + persisted: false, + }); + } + console.error(`❌ Symbol normalization failed:`, mappingError); return res.status(422).json({ error: "symbol_normalization_failed", message: "Could not normalize symbol to canonical BASE/QUOTE format", - symbolRaw: mappingResult.error!.symbolRaw, - symbolNormalizedAttempt: mappingResult.error!.symbolNormalizedAttempt, - reason: mappingResult.error!.reason, - details: mappingResult.error!.details, + symbolRaw: mappingError.symbolRaw, + symbolNormalizedAttempt: mappingError.symbolNormalizedAttempt, + reason: mappingError.reason, + details: mappingError.details, }); } diff --git a/src/types/TradePlan.ts b/src/types/TradePlan.ts new file mode 100644 index 00000000..524dea14 --- /dev/null +++ b/src/types/TradePlan.ts @@ -0,0 +1,74 @@ +/** + * afi.trade-plan.v1 — the governed trade-plan contract + * (afi-config/schemas/provenance/v1/trade-plan.schema.json), as carried on the + * canonical USS signal at `uss.plan` by the CPJ→USS mapper (DEM-GOV §9 + * DEM-PRODUCER-PLAN, owner-authorized 2026-08-25; determination D-4). + * + * NUMBER POLICY: every price/quantity is a DECIMAL STRING (`^[0-9]+(\.[0-9]+)?$`), + * never a float — the afi.hash.v1 input-hash law rejects raw non-integers, and + * the contract fixes strings. The runtime validates the block against the + * governed schema (validateTradePlanV1) at ingest; the technical lane verifies + * the LEVELS against the candles it fetched before any of them becomes a fact + * (D-DEM-5(6)). + */ +export interface TradePlanTakeProfitV1 { + price: string; + sizePct?: string; +} + +export interface TradePlanPriceRangeV1 { + min: string; + max: string; +} + +export interface TradePlanLevelsV1 { + entry: string | TradePlanPriceRangeV1; + stopLoss?: string; + takeProfits?: TradePlanTakeProfitV1[]; +} + +export interface TradePlanV1 { + schema: "afi.trade-plan.v1"; + signalId?: string; + levels: TradePlanLevelsV1; + leverageHint?: string; + venueHint?: string; + marketTypeHint?: "spot" | "perp" | "futures"; + notes?: string; +} + +/** + * The technical lane's VERIFIED plan facts (`technical.plan` on the lens + * payload; projected verbatim into the enriched view). Every price here was + * checked against the fetched candle window (tradePlanVerification.ts); a + * plan that cannot be checked never produces this block — the determination + * refuses instead (D-DEM-5(6)). + * + * Declared producer absences (D-DEM-5(4)(b), recorded in the technical + * plugin manifest's description): the whole block is absent when the signal + * carries no plan; `stopPrice` / `firstTargetPrice` / `rrToFirstTarget` are + * absent when the plan carries no stop or no target (no R:R claim to + * verify); `entryPrice` is absent for an entry RANGE whose conservative bound + * cannot be chosen (no stop/target geometry). + */ +export interface TechnicalPlanFacts { + /** Lower / upper submitted entry bound (equal for a single entry price). */ + entryLow: number; + entryHigh: number; + /** The entry price the R:R is computed at: the submitted price, or for a + * range the CONSERVATIVE bound (nearest the targets — smallest reward, + * largest risk). */ + entryPrice?: number; + stopPrice?: number; + /** The submitted target nearest the entry. */ + firstTargetPrice?: number; + /** Number of submitted take-profit targets. */ + targetCount: number; + /** |firstTarget − entry| / |entry − stop|, rounded half-up to 4 decimals. */ + rrToFirstTarget?: number; + /** The verification band's source: the fetched window's extreme low/high. */ + envelopeLow: number; + envelopeHigh: number; + /** Fetched candles the envelope was computed over. */ + barCount: number; +} diff --git a/src/types/UssLenses.ts b/src/types/UssLenses.ts index 4013029b..bcc8105b 100644 --- a/src/types/UssLenses.ts +++ b/src/types/UssLenses.ts @@ -9,6 +9,7 @@ * * @module UssLenses */ +import type { TechnicalPlanFacts } from "./TradePlan.js"; /** * Base lens interface. @@ -60,6 +61,13 @@ export interface TechnicalLensV1 extends UssLens { * every atrRegime classification auditable against these bytes. */ atrPercentile?: number; + /** + * DEM-PRODUCER-PLAN: the lane's VERIFIED trade-plan facts (the submitted + * afi.trade-plan.v1 levels checked against this window, D-DEM-5(6)). + * Absent when the signal carried no plan; an unverifiable plan refuses + * the determination instead of producing this block. + */ + plan?: TechnicalPlanFacts; }; } diff --git a/src/uss/cpjMapper.ts b/src/uss/cpjMapper.ts index ea0772ad..54163b67 100644 --- a/src/uss/cpjMapper.ts +++ b/src/uss/cpjMapper.ts @@ -20,6 +20,9 @@ import { type SymbolNormalizationResult, SymbolNormalizationError, } from "./symbolNormalizer.js"; +import { validateTradePlanV1 } from "../evidence/provenance/schemaValidation.js"; +import { toCanonicalDecimalString } from "../evidence/provenance/hashProjection.js"; +import type { TradePlanV1 } from "../types/TradePlan.js"; /** * CPJ to USS mapping result @@ -27,13 +30,96 @@ import { export interface CpjMappingResult { success: boolean; uss?: UssV11Payload; - error?: { - type: "symbol_normalization_failed"; - symbolRaw: string; - symbolNormalizedAttempt?: string; - reason: SymbolNormalizationError; - details?: string; - }; + error?: CpjMappingError; +} + +export type CpjMappingError = + | { + type: "symbol_normalization_failed"; + symbolRaw: string; + symbolNormalizedAttempt?: string; + reason: SymbolNormalizationError; + details?: string; + } + | { + /** DEM-PRODUCER-PLAN: the submitted levels do not form a valid + * afi.trade-plan.v1 — refused at ingest (422), never dropped. */ + type: "trade_plan_invalid"; + reason: string; + details?: string[]; + }; + +/** Thrown inside buildTradePlan; converted to the typed error union by the mapper. */ +class TradePlanInvalid extends Error { + readonly details?: string[]; + constructor(reason: string, details?: string[]) { + super(reason); + this.details = details; + } +} + +/** + * A CPJ number → the contract's decimal string (plain notation, no exponent). + * Negative, NaN, or non-finite numbers cannot be expressed by the contract's + * `^[0-9]+(\.[0-9]+)?$` pattern and are refused by the schema validation below. + */ +function toPlanDecimal(label: string, value: unknown): string { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new TradePlanInvalid(`${label} is not a finite number`); + } + if (value < 0) throw new TradePlanInvalid(`${label} is negative`); + return toCanonicalDecimalString(value); +} + +/** + * DEM-PRODUCER-PLAN (DEM-GOV §9; determination D-4): carry the submitted trade + * plan through to the canonical signal as `uss.plan`, an afi.trade-plan.v1 + * object validated against the governed contract. Returns undefined when the + * submission carries NO plan field at all (entry / stopLoss / takeProfits) — + * the technical lane then emits no plan facts (declared absence). A partial + * or malformed plan (stop or targets without an entry, a negative price, …) + * is REFUSED here: the levels are never silently dropped again. + */ +export function buildTradePlanFromCpj(cpj: CpjV01Payload, signalId: string): TradePlanV1 | undefined { + const x = cpj.extracted; + const hasEntry = x.entry !== undefined && x.entry !== null; + const hasStop = x.stopLoss !== undefined && x.stopLoss !== null; + const hasTargets = Array.isArray(x.takeProfits) && x.takeProfits.length > 0; + if (!hasEntry && !hasStop && !hasTargets) return undefined; + if (!hasEntry) { + throw new TradePlanInvalid("a stop or target was submitted without an entry (afi.trade-plan.v1 requires levels.entry)"); + } + + const entry = + typeof x.entry === "number" + ? toPlanDecimal("entry", x.entry) + : { min: toPlanDecimal("entry.min", (x.entry as { min: number }).min), max: toPlanDecimal("entry.max", (x.entry as { max: number }).max) }; + const levels: TradePlanV1["levels"] = { entry }; + if (hasStop) levels.stopLoss = toPlanDecimal("stopLoss", x.stopLoss); + if (hasTargets) { + levels.takeProfits = (x.takeProfits as Array<{ price: number; percentage?: number }>).map((tp, i) => { + const out: { price: string; sizePct?: string } = { price: toPlanDecimal(`takeProfits[${i}].price`, tp.price) }; + if (tp.percentage !== undefined && tp.percentage !== null) { + out.sizePct = toPlanDecimal(`takeProfits[${i}].percentage`, tp.percentage); + } + return out; + }); + } + const plan: TradePlanV1 = { schema: "afi.trade-plan.v1", signalId, levels }; + if (x.leverageHint !== undefined && x.leverageHint !== null) plan.leverageHint = toPlanDecimal("leverageHint", x.leverageHint); + if (typeof x.venueHint === "string" && x.venueHint.length > 0) plan.venueHint = x.venueHint; + if (x.marketTypeHint === "spot" || x.marketTypeHint === "perp" || x.marketTypeHint === "futures") { + plan.marketTypeHint = x.marketTypeHint; + } + + const validation = validateTradePlanV1(plan); + if (!validation.ok) { + throw new TradePlanInvalid( + "submitted levels do not conform to afi.trade-plan.v1", + validation.errors.map((e) => `${e.field || "/"} ${e.message}`) + ); + } + return plan; } /** @@ -305,6 +391,21 @@ export function mapCpjToUssV11( // Map CPJ providerType to USS-compatible providerType const ussProviderType = mapProviderType(cpj.provenance.providerType); + // DEM-PRODUCER-PLAN: the submitted trade plan survives the mapping as + // uss.plan (afi.trade-plan.v1) or is refused — never dropped. + let plan: TradePlanV1 | undefined; + try { + plan = buildTradePlanFromCpj(cpj, signalId); + } catch (error) { + if (error instanceof TradePlanInvalid) { + return { + success: false, + error: { type: "trade_plan_invalid", reason: error.message, details: error.details }, + }; + } + throw error; + } + // Construct canonical USS v1.1 const uss: UssV11Payload = { schema: "afi.usignal.v1.1", @@ -333,6 +434,10 @@ export function mapCpjToUssV11( strategy: resolvedStrategy.strategyId, direction, }, + // DEM-PRODUCER-PLAN: root-level governed block (USS v1.1's root is open; + // `facts` stays closed and untouched). Present only when a plan was + // submitted. Enters inputHash as decimal strings (afi.hash.v1). + ...(plan !== undefined ? { plan } : {}), }; return { success: true, uss }; diff --git a/src/uss/ussValidator.ts b/src/uss/ussValidator.ts index 40718e46..3e146615 100644 --- a/src/uss/ussValidator.ts +++ b/src/uss/ussValidator.ts @@ -11,6 +11,7 @@ import { Ajv } from "ajv"; import * as ajvFormatsModule from "ajv-formats"; import { readFileSync } from "fs"; +import type { TradePlanV1 } from "../types/TradePlan.js"; import { join } from "path"; import type { ValidateFunction } from "ajv"; @@ -57,6 +58,13 @@ export interface UssV11Payload { }; core?: any; lens?: string; + /** + * DEM-PRODUCER-PLAN: the submitted trade plan (afi.trade-plan.v1, decimal + * strings), carried by the CPJ→USS mapper when a plan was submitted and + * verified by the technical lane against its fetched candles (D-DEM-5(6)). + * The TradingView/MarkitTick routes carry no plan today. + */ + plan?: TradePlanV1; [key: string]: any; } diff --git a/test/guardrails/mappingPerStrategyResolution.test.ts b/test/guardrails/mappingPerStrategyResolution.test.ts index 437b3275..17578564 100644 --- a/test/guardrails/mappingPerStrategyResolution.test.ts +++ b/test/guardrails/mappingPerStrategyResolution.test.ts @@ -39,23 +39,25 @@ import { type ResolvedMappingCarrier, } from "../../src/pipeline/nodeSdk.js"; import type { FroggyEnrichedView } from "../../node_modules/afi-core/analysts/froggy.enrichment_adapter.js"; -// DEM-BIND (e2) seam-test rework (ruling R1): the adapter's expressible half -// survives as the TEST-SIDE byte-equivalence oracle — the export the FLPR -// guards and the golden harness already sanction. The live path composes -// fragment+residual only; equivalence is proven against the adapter export -// imported directly here, never via a removed legacy branch. -import { buildFroggyTrendPullbackInputFromEnriched } from "../../node_modules/afi-core/analysts/froggy.enrichment_adapter.js"; -import { scoreFroggyTrendPullback } from "../../node_modules/afi-core/analysts/froggy.trend_pullback_v1.js"; +// DEM-PRODUCER-PLAN: the retired adapter no longer yields a full scorer +// input (its rrMultiplePlanned synthesis is deleted), so the seam is proven +// against the COMPOSITION REFERENCE — the production assembly (registered +// mapping fragment + residual → composer → rubric) run out-of-band — with the +// mapping resolved by the fixture registration's mappingRef, exactly as boot +// resolves it (never the afi-config example). +import { compositionReference, froggyMappingCarrier } from "../pipeline/support/froggyMapping.js"; const REPO_ROOT = process.cwd(); const FIXTURE_CONFIG_ROOT = path.resolve(REPO_ROOT, "test/pipeline/fixtures/afi-config"); const CONFIG_REL = "registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json"; const REGISTRATION_REL = "registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json"; -const CANONICAL_MAPPING = path.resolve( - REPO_ROOT, - "node_modules/afi-config/examples/enrichment-mapping/v1/enrichment-mapping.example.json" -); const FROGGY_KEY = "froggy/trend_pullback_v1@1.0.0"; +// The registered 1.0.0 mapping, from the fixture registry (byte-identical to +// the afi-config canonical example; DEM-PRODUCER-PLAN registered 1.1.0 beside it). +const REGISTERED_MAPPING_100 = path.resolve( + FIXTURE_CONFIG_ROOT, + "registries/enrichment-mappings/froggy-trend-pullback--1.0.0.json" +); /** The production plugin registry over an empty provider runtime (binding only). */ function testBuiltinRegistry() { @@ -102,7 +104,7 @@ function removeMappingRef(root: string): void { function registerFroggyMapping(root: string, mutateDoc?: (doc: any) => void): void { const dir = join(root, "registries/enrichment-mappings"); mkdirSync(dir, { recursive: true }); - const doc = JSON.parse(readFileSync(CANONICAL_MAPPING, "utf-8")); + const doc = JSON.parse(readFileSync(REGISTERED_MAPPING_100, "utf-8")); mutateDoc?.(doc); writeFileSync(join(dir, "froggy-trend-pullback--1.0.0.json"), JSON.stringify(doc, null, 2)); } @@ -140,7 +142,9 @@ describe("D-DEM-2(6): fail-closed mapping resolution at boot", () => { it("mappingRef with NO registered document refuses boot — never a silent skip", () => { bootRefusal( scratchRoot((r) => { - rmSync(join(r, "registries/enrichment-mappings/froggy-trend-pullback--1.0.0.json")); + // Version-agnostic: remove the whole registry family, whatever version + // the fixture config names (DEM-PRODUCER-PLAN registered 1.1.0). + rmSync(join(r, "registries/enrichment-mappings"), { recursive: true, force: true }); }), /does not resolve.*fail-closed, D-DEM-2\(6\)/ ); @@ -221,15 +225,11 @@ describe("the ctx.mapping carrier is bounded: the scorer node is its sole reader }); describe("the seam: fragment+residual equals the legacy path; fired defaults are recorded", () => { - const skipIfNoMapping = existsSync(CANONICAL_MAPPING) ? describe : describe.skip; + const skipIfNoMapping = existsSync(REGISTERED_MAPPING_100) ? describe : describe.skip; skipIfNoMapping("with the canonical froggy mapping", () => { function carrier(): ResolvedMappingCarrier { - return { - mappingId: "froggy-trend-pullback", - version: "1.0.0", - doc: JSON.parse(readFileSync(CANONICAL_MAPPING, "utf-8")), - }; + return froggyMappingCarrier(FIXTURE_CONFIG_ROOT); } function ctx(withMapping: boolean): NodeRunContext { @@ -271,23 +271,40 @@ describe("the seam: fragment+residual equals the legacy path; fired defaults are return clone; } - /** The adapter export, applied as the sanctioned test-side oracle (R1). */ - function adapterOracleAnalysis(enriched: FroggyEnrichedView) { + /** The production assembly, run out-of-band (the composition reference). */ + function referenceAnalysis(enriched: FroggyEnrichedView) { const uwr = getUwrRuntimeConfigForProfile("uwr-weighted-lifts-v0.1"); - return scoreFroggyTrendPullback( - buildFroggyTrendPullbackInputFromEnriched(enriched), - uwr.config, - enriched - ); + return compositionReference(enriched, uwr.config, carrier()).analysis; } - it("mapping path output is byte-identical to the adapter oracle (no fired defaults)", async () => { - const withMapping = await scorerFroggyTrendPullbackNode.run(view(), ctx(true)); + /** A view carrying the technical lane's verified plan facts (DEM-PRODUCER-PLAN). */ + function viewWithPlan(): FroggyEnrichedView { + const v = view(); + return { + ...v, + technical: { + ...v.technical, + plan: { entryPrice: 100, stopPrice: 98, firstTargetPrice: 104, rrToFirstTarget: 2, targetCount: 1 }, + }, + }; + } + + it("mapping path output equals the composition reference (no fired defaults when the plan fact is present)", async () => { + const withMapping = await scorerFroggyTrendPullbackNode.run(viewWithPlan(), ctx(true)); const a = withMapping.output as { analysis: unknown }; - expect(normalized(a.analysis)).toEqual(normalized(adapterOracleAnalysis(view()))); + expect(normalized(a.analysis)).toEqual(normalized(referenceAnalysis(viewWithPlan()))); expect(withMapping.degradations).toEqual([]); }); + it("a plan-less view fires ONLY the rrMultiplePlanned floor default — recorded, never silent (D-DEM-5(3); §9 determination D-5)", async () => { + const result = await scorerFroggyTrendPullbackNode.run(view(), ctx(true)); + expect(result.degradations).toEqual([{ class: "declared-default-fired", detail: "rrMultiplePlanned" }]); + const a = result.output as { analysis: { analystScore: { uwrAxes: { risk: number } } } }; + // The rubric floor (rr 1 → 0.2) — a hash-committed, recorded degradation. + expect(a.analysis.analystScore.uwrAxes.risk).toBe(0.2); + expect(normalized(a.analysis)).toEqual(normalized(referenceAnalysis(view()))); + }); + it("a fired grandfathered default is a RECORDED degradation (D-DEM-5(3)), never silent", async () => { const bare = { ...view(), @@ -301,19 +318,22 @@ describe("the seam: fragment+residual equals the legacy path; fired defaults are "declared-default-fired", "declared-default-fired", "declared-default-fired", + "declared-default-fired", ]); const targets = (result.degradations ?? []).map((d) => d.detail).sort(); expect(targets).toEqual([ "atrRegime", "distanceFromDailyEmaPct", "pulledBackIntoSweetSpot", + "rrMultiplePlanned", "triggerPatternQuality", ]); - // The adapter oracle scores the same INPUT VALUES (the grandfather - // exists so DEM-BIND reproduces today's scored values exactly, - // D-DEM-5(4)). + // The composition reference scores the same INPUT VALUES (the + // grandfather exists so DEM-BIND reproduces today's scored values + // exactly, D-DEM-5(4); the plan floor is the PLAN slot's declared + // default). const a = result.output as { analysis: unknown }; - expect(normalized(a.analysis)).toEqual(normalized(adapterOracleAnalysis(bare))); + expect(normalized(a.analysis)).toEqual(normalized(referenceAnalysis(bare))); }); it("an absent resolved mapping yields NO determination (D-DEM-2(6), D-DEM-5(7))", async () => { diff --git a/test/guardrails/uwrRuntimeProfile.test.ts b/test/guardrails/uwrRuntimeProfile.test.ts index fbaa93ef..194b308b 100644 --- a/test/guardrails/uwrRuntimeProfile.test.ts +++ b/test/guardrails/uwrRuntimeProfile.test.ts @@ -46,7 +46,7 @@ import { // reason the analysts moduleNameMapper exists for runtime); the relative // path lets TypeScript check the file:-linked source directly while jest's // relative mapper resolves the identical module at runtime. -import { scoreFroggyTrendPullbackFromEnriched } from "../../node_modules/afi-core/analysts/froggy.trend_pullback_v1.js"; +import { compositionReference, froggyMappingCarrier } from "../pipeline/support/froggyMapping.js"; import type { FroggyEnrichedView } from "../../node_modules/afi-core/analysts/froggy.enrichment_adapter.js"; import { scorerFroggyTrendPullbackNode } from "../../src/pipeline/nodes/scorerFroggyTrendPullback.js"; import { @@ -59,21 +59,9 @@ import { // equivalence probes hand down the canonical registered froggy mapping // (D-DEM-2(6); its interpreter fragment is byte-equivalent to the adapter // over the full non-refusal domain, so plugin-vs-kernel equality is unmoved). -function froggyMappingCarrier(): ResolvedMappingCarrier { - return { - mappingId: "froggy-trend-pullback", - version: "1.0.0", - doc: JSON.parse( - readFileSync( - path.resolve( - process.cwd(), - "node_modules/afi-config/examples/enrichment-mapping/v1/enrichment-mapping.example.json" - ), - "utf-8" - ) - ) as Record, - }; -} +// DEM-PRODUCER-PLAN: the registered mapping is resolved from the fixture +// registry by the fixture registration's mappingRef (test/pipeline/support/ +// froggyMapping.ts) — never the afi-config example (the immutable 1.0.0 vector). /** The LIVE scoring seam (D-FCP-9: the old froggy analyst plugin is deleted; * the scorer node composes the identical afi-core kernels and emits the @@ -530,12 +518,15 @@ describe("PR-UWR-RUNTIME-READ: plugin call-site equivalence (the changed consume return clone; } - it("builtin (explicit): plugin run() output is identical to scoreFroggyTrendPullbackFromEnriched", async () => { + it("builtin (explicit): plugin run() output is identical to the composition reference", async () => { process.env[UWR_PROFILE_SOURCE_ENV] = "builtin"; __resetUwrRuntimeConfigForTests(); const enriched = enrichedFixture(); const viaPlugin = await runScorer(enriched); - const reference = scoreFroggyTrendPullbackFromEnriched(enrichedFixture()); + const reference = compositionReference( + enrichedFixture(), + getUwrRuntimeConfigForProfile("uwr-weighted-lifts-v0.1").config + ).analysis; expect(normalized(viaPlugin.analysis)).toEqual(normalized(reference)); // No response-contract leakage: output shape = enriched + analysis + // uwrResolvedSource only. (uwrResolvedSource was added by @@ -561,7 +552,10 @@ describe("PR-UWR-RUNTIME-READ: plugin call-site equivalence (the changed consume process.env[UWR_PROFILE_SOURCE_ENV] = "registry"; __resetUwrRuntimeConfigForTests(); const viaPlugin = await runScorer(enrichedFixture()); - const reference = scoreFroggyTrendPullbackFromEnriched(enrichedFixture()); + const reference = compositionReference( + enrichedFixture(), + getUwrRuntimeConfigForProfile("uwr-weighted-lifts-v0.1").config + ).analysis; expect(normalized(viaPlugin.analysis)).toEqual(normalized(reference)); // RC-6 propagation: a successful registry resolution — and only a // successful one; failure throws before scoring — flows to the stamp. diff --git a/test/guardrails/uwrStampSemantics.test.ts b/test/guardrails/uwrStampSemantics.test.ts index 37f6585b..5bb1fd92 100644 --- a/test/guardrails/uwrStampSemantics.test.ts +++ b/test/guardrails/uwrStampSemantics.test.ts @@ -53,26 +53,15 @@ import { type NodeRunContext, type ResolvedMappingCarrier, } from "../../src/pipeline/nodeSdk.js"; +import { froggyMappingCarrier } from "../pipeline/support/froggyMapping.js"; // DEM-BIND (e2): the scorer node refuses without a resolved mapping — these // semantics probes hand down the canonical registered froggy mapping // (D-DEM-2(6); its interpreter fragment is byte-equivalent to the adapter // over the full non-refusal domain, so every equivalence below is unmoved). -function froggyMappingCarrier(): ResolvedMappingCarrier { - return { - mappingId: "froggy-trend-pullback", - version: "1.0.0", - doc: JSON.parse( - readFileSync( - path.resolve( - process.cwd(), - "node_modules/afi-config/examples/enrichment-mapping/v1/enrichment-mapping.example.json" - ), - "utf-8" - ) - ) as Record, - }; -} +// DEM-PRODUCER-PLAN: the registered mapping is resolved from the fixture +// registry by the fixture registration's mappingRef (test/pipeline/support/ +// froggyMapping.ts) — never the afi-config example (the immutable 1.0.0 vector). // Repo idiom (see test/evidence/provenance/*.test.ts): jest runs from the repo root. const REPO_ROOT = process.cwd(); diff --git a/test/integration-mongo/reactorEvidencePersistence.mjs b/test/integration-mongo/reactorEvidencePersistence.mjs index b19476d1..de60bbfe 100644 --- a/test/integration-mongo/reactorEvidencePersistence.mjs +++ b/test/integration-mongo/reactorEvidencePersistence.mjs @@ -156,9 +156,10 @@ async function main() { extracted: { symbolRaw: "BTCUSDT", side: "long", - entry: 42500, - stopLoss: 41800, - takeProfits: [{ price: 43500 }], + // DEM-PRODUCER-PLAN: the plan must verify against the demo window (BTC ≈ 49k–51k). + entry: 50000, + stopLoss: 49300, + takeProfits: [{ price: 51000 }], timeframeHint: "4h", venueHint: "blofin", marketTypeHint: "perp", diff --git a/test/integration-mongo/reactorEvidenceUnavailable.mjs b/test/integration-mongo/reactorEvidenceUnavailable.mjs index f4d64254..eb8c28d8 100644 --- a/test/integration-mongo/reactorEvidenceUnavailable.mjs +++ b/test/integration-mongo/reactorEvidenceUnavailable.mjs @@ -72,7 +72,8 @@ async function main() { extracted: { symbolRaw: "BTCUSDT", side: "long", - entry: 42500, + // DEM-PRODUCER-PLAN: a plan must verify against the demo window (BTC ≈ 49k–51k). + entry: 50000, timeframeHint: "4h", venueHint: "blofin", marketTypeHint: "perp", diff --git a/test/oracle/INTENTIONAL_DIFFS.md b/test/oracle/INTENTIONAL_DIFFS.md index a85437f4..c4903ea0 100644 --- a/test/oracle/INTENTIONAL_DIFFS.md +++ b/test/oracle/INTENTIONAL_DIFFS.md @@ -545,3 +545,51 @@ retired seam byte-for-byte, incl. the grandfathered defaults — D-DEM-5(4)), both direction surfaces, and the full `httpResponse` family. No scored-value field moved. Anything else diffing would have been a defect to fix in code, never absorbed into a golden. + +--- + +# DEM-PRODUCER-PLAN reconciliation — the planned R:R becomes a provider fact (DEM-GOV §9, owner-authorized 2026-08-25) + +**Authority.** DEM-GOV §9 `DEM-PRODUCER-PLAN` (founder instruction of 2026-08-25 recorded verbatim in the DEM-GOV Status line + §9; afi-governance #55) with §9 determinations D-2/D-4/D-5 (afi-governance #56). D-DEM-7(2) authorizes the movement in class (the risk axis fed by `rrMultiplePlanned`, hence `uwrScore`/`conviction`); this section is the D-DEM-7(3) itemization. **A slot whose diff exceeds its own itemization must not merge.** + +**What changed on the path.** (1) `cpjMapper` carries the submitted trade plan as `uss.plan` (`afi.trade-plan.v1`, decimal strings) or refuses at ingest (422 `trade_plan_invalid`); TV/MarkitTick untouched. (2) The technical lane verifies every submitted level against the candles it fetched (band `[L − W, H + W]`, plan geometry from prices only) and emits `technical.plan` (`entryLow/entryHigh/entryPrice/stopPrice/firstTargetPrice/targetCount/rrToFirstTarget/envelopeLow/envelopeHigh/barCount`) or refuses the determination (422 `trade_plan_unverifiable`, no record). (3) `viewTechnical` projects `plan` verbatim. (4) Mapping **1.1.0** binds `rrMultiplePlanned ← technical.plan.rrToFirstTarget` (producer-declared optional, default **1** = the rubric floor; the firing is a recorded degradation). (5) afi-core deletes the `pulledBackIntoSweetSpot && !brokeEmaWithBody ? 2 : 1` synthesis; the composer partitions the ten fields exactly. (6) `implementationVersion`: technical `2.0.0 → 2.1.0`, merge `1.1.0 → 1.2.0`, scorer `1.3.0 → 1.4.0`; `pluginSetHash` `220c004e… → 36f911f4…`; `analystConfigHash` `aa8cf5cf… → 5cb9b7a4…` (mappingRef 1.0.0 → 1.1.0). (7) The two plan-bearing CPJ **fixtures were re-authored inside the demo feed's price envelope** (BTC: entry 42500/SL 41800/TP 43500 → 50000/49300/51000; ETH: entry 2280.5 → 3001.5) — the old levels lay thousands of dollars outside every window the demo feed can print and would have been refused by the law this slot lands; a test now proves the envelope contains every committed fixture plan. + +Goldens regenerated ONCE via `npm run oracle:regen`; audited with the committed differ `node test/oracle/support/goldenDiff.mjs` (all 12 changed; 0 byte-equal). + +## Per-golden scored-value movement (the class D-DEM-7(2) authorizes: the risk axis and its derivatives) + +| golden | `scorerInput.rrMultiplePlanned` | `uwrAxes.risk` | `uwrScore` = `conviction` | `structure` | plan on USS | `executionSummaryHash` | +|---|---|---|---|---|---|---| +| `cpj-blofin-perp-long.{builtin,registry}` | 2 → 1.4286 | 0.9 → 0.5 | 0.59167 → 0.49167 | 0.4 (unmoved) | yes (verified; rr 1000/700) | byte-equal (already degraded by a grandfather firing) | +| `cpj-blofin-perp-neutral.{builtin,registry}` | 2 → 1 | 0.9 → 0.2 | 0.4 → 0.225 | 0.4 | no → floor default fired | byte-equal (already degraded) | +| `cpj-coinbase-spot-sell.{builtin,registry}` | 2 → 1 | 0.9 → 0.2 | 0.59167 → 0.41667 | 0.4 | yes, entry-only → no R:R claim → floor default fired | **moved** (`executed` → `degraded`) | +| `tv-long.{builtin,registry}` | 2 → 1 | 0.9 → 0.2 | 0.59167 → 0.41667 | 0.4 | no → floor default fired | **moved** (`executed` → `degraded`) | +| `tv-neutral.{builtin,registry}` | 2 → 1 | 0.9 → 0.2 | 0.4 → 0.225 | 0.4 | no → floor default fired | byte-equal (already degraded) | +| `tv-short.{builtin,registry}` | 2 → 1 | 0.9 → 0.2 | 0.35 → 0.175 | 0.4 | no → floor default fired | byte-equal (already degraded) | + +`riskBucket` is unmoved on all 12 (it reads `atrRegime`, not the risk axis). `rationale`/`axisNotes` unmoved (the rubric emits no risk note). The risk axis is now **non-constant over the corpus** ({0.2, 0.5}) — via the provider's plan on the plan-bearing CPJ fixture and the declared floor elsewhere. + +## Per-golden identity-hash / input-surface movement + +| JSON path class | goldens | why | +|---|---|---| +| `/canonicalUss/plan`, `/httpResponse/uss/plan`, `/httpResponse/pipelineResult/rawUss/plan` (absent → object) | 4: the two plan-bearing CPJ fixtures × 2 modes | the carried `afi.trade-plan.v1` block | +| `/canonicalUss/provenance/ingestHash` (+ the two response echoes, `/httpResponse/ingestHash`) | same 4 | the CPJ fixture bytes changed (levels re-authored) — ingestHash hashes the CPJ payload | +| `/inputHash/value`, `/evidenceRecord/provenanceRecord/inputHash/value` | same 4 | the canonical USS gained `plan` (decimal strings; afi.hash.v1 admits them verbatim) | +| `/httpResponse/pipelineResult/lenses/0/payload/plan`, `…/_priceFeedMetadata/technicalIndicators/plan` | same 4 | the lane's verified plan facts ride the technical payload | +| `/evidenceRecord/composition/enrichmentHash/value` | same 4 | the technical lens payload gained `plan` | +| `/evidenceRecord/providerInvocations/4/{providerResultHash,categoryResultHash}/value` (technical) | same 4 | the validated technical CategoryResult gained `technical.plan` | +| `/evidenceRecord/providerInvocations/0..4/invocationInputHash/value` (all five lanes) | same 4 | every lane's proof commits to the canonical signal, which gained `plan` | +| `/evidenceRecord/composition/executionSummaryHash/value` | 4: `cpj-coinbase-spot-sell.*`, `tv-long.*` | the scorer node's status flips `executed → degraded` where the floor default is the FIRST fired default (D-DEM-5(3)); the other 8 were already `degraded` by a grandfather firing | +| `/evidenceRecord/composition/analystConfigHash/value` | all 12 | `aa8cf5cf… → 5cb9b7a4…` (mappingRef 1.1.0) | +| `/evidenceRecord/composition/pluginSetHash/value` | all 12 | `220c004e… → 36f911f4…` (three implementationVersion moves) | +| `/outputHash/value`, `/evidenceRecord/provenanceRecord/outputHash/value` | all 12 | the scored output moved (risk axis) | +| `/evidenceRecord/recordHash/value`, `/evidenceRecord/replayHash/value` | all 12 | consequences of the above | + +## Explicitly byte-EQUAL (asserted by the differ across all 12) + +`manifestHash` (the pipeline manifest is untouched), `decayParams`, `uwrProfile`, `scoredSignal.direction`/`meta.direction` (DIR-GOV D-DIR-3: the submitted side is untouched and unread by the producer), every axis other than `risk` (`structure` 0.4, `execution`, `insight` — byte-equal), `riskBucket`, `rationale`/`axisNotes`, the pattern/sentiment/news/aiMl lens payloads, the pattern/sentiment/news/aiMl `providerResultHash`/`categoryResultHash`, every `scorerInput` field other than `rrMultiplePlanned`, `canonicalUss.facts` (still exactly five keys), all six TV goldens' `canonicalUss`/`inputHash`/`ingestHash` (the TV route carries no plan), the `312da118…126e06` provenance golden and `ATLAS_MANIFEST_HASH`. No scoring-law value moved (weights, clamps, rr bands, riskBucket map untouched). + +## D-DEM-5(5) residual, recorded honestly + +On the CPJ route with a complete plan the risk axis is now a function of the provider's verified R:R and `brokeEmaWithBody` — the Evidence-§3 collinearity is **resolved** there. On every plan-less submission (all TradingView/MarkitTick traffic today, and CPJ submissions without a stop or target) the risk axis reads the declared floor and varies only through `brokeEmaWithBody`: **residual** until a plan carrier exists on that route (§9 determination D-5). The R:R is verified-plausible but submitter-chosen; a stop-placement validity rule would be a new threshold (§8) — a modelling filing. diff --git a/test/oracle/fixtures/afi-config/registries/analysis-plugins/afi-analysis-technical--2.0.0.json b/test/oracle/fixtures/afi-config/registries/analysis-plugins/afi-analysis-technical--2.0.0.json index 625bbcb1..dfdb6dda 100644 --- a/test/oracle/fixtures/afi-config/registries/analysis-plugins/afi-analysis-technical--2.0.0.json +++ b/test/oracle/fixtures/afi-config/registries/analysis-plugins/afi-analysis-technical--2.0.0.json @@ -2,20 +2,25 @@ "schema": "afi.analysis-plugin.v1", "pluginId": "afi-analysis-technical", "pluginVersion": "2.0.0", - "implementationVersion": "2.0.0", + "implementationVersion": "2.1.0", "category": "technical", - "description": "Official technical lane plugin manifest (vendor-neutral, provider-instance-backed): the node reads its explicit providerInstanceRef, and the runtime provider layer resolves the instance and invokes the trusted registered technical adapter, which computes indicator features (EMA/RSI/ATR-style) over the market candles for the validated signal. The node contains no vendor logic; deterministic given the selected instance's inputs.", + "description": "Official technical lane plugin manifest (vendor-neutral, provider-instance-backed): the node reads its explicit providerInstanceRef, and the runtime provider layer resolves the instance and invokes the trusted registered technical adapter, which computes indicator features (EMA/RSI/ATR-style) over the market candles for the validated signal. The node contains no vendor logic; deterministic given the selected instance's inputs. DEM-PRODUCER-PLAN producer contract: when the canonical signal carries an afi.trade-plan.v1 plan, the lane verifies every submitted level against the fetched candle window (all levels within one observed-range width of the window; stop and targets on opposite sides of entry) and emits technical.plan; an unverifiable plan REFUSES the determination (D-DEM-5(6)). Declared absences (D-DEM-5(4)(b)): technical.plan is absent when the signal carries no plan; technical.plan.rrToFirstTarget is absent when the plan carries no stop or no target.", "inputSchemaRef": "afi.usignal.v1.1", "outputSchemaRef": "afi.enrichment.technical.v1", "deterministic": true, - "capabilities": ["provider:instance-backed"], + "capabilities": [ + "provider:instance-backed" + ], "defaultTimeoutMs": 10000, "defaultRetryPolicy": { "maxRetries": 1, "retryDelayMs": 500, "backoff": "fixed" }, - "permittedFailurePolicies": ["abort", "degrade"], + "permittedFailurePolicies": [ + "abort", + "degrade" + ], "paramsSchema": { "type": "object", "additionalProperties": false, diff --git a/test/oracle/fixtures/afi-config/registries/analysis-plugins/afi-merge-enriched-view--1.1.0.json b/test/oracle/fixtures/afi-config/registries/analysis-plugins/afi-merge-enriched-view--1.1.0.json index a33a534b..529dd8dd 100644 --- a/test/oracle/fixtures/afi-config/registries/analysis-plugins/afi-merge-enriched-view--1.1.0.json +++ b/test/oracle/fixtures/afi-config/registries/analysis-plugins/afi-merge-enriched-view--1.1.0.json @@ -2,14 +2,16 @@ "schema": "afi.analysis-plugin.v1", "pluginId": "afi-merge-enriched-view", "pluginVersion": "1.1.0", - "implementationVersion": "1.1.0", + "implementationVersion": "1.2.0", "category": "merge", "description": "Official deterministic merge plugin manifest: joins the five analysis category results (technical, pattern, sentiment, news, aiMl) into one enriched view under the namespace-by-node strategy — exactly one result per category, duplicate categories rejected, degraded/skipped optional parents contribute empty namespaces. Pure structural merge — deterministic, no external I/O, no provider invocation.", "inputSchemaRef": "afi.enrichment.bundle.v1", "outputSchemaRef": "afi.enrichment.enriched-view.v1", "deterministic": true, "defaultTimeoutMs": 2000, - "permittedFailurePolicies": ["abort"], + "permittedFailurePolicies": [ + "abort" + ], "paramsSchema": { "type": "object", "additionalProperties": false, diff --git a/test/oracle/fixtures/afi-config/registries/analysis-plugins/afi-scorer-froggy-trend-pullback--1.0.0.json b/test/oracle/fixtures/afi-config/registries/analysis-plugins/afi-scorer-froggy-trend-pullback--1.0.0.json index 3d324a1c..45cf960d 100644 --- a/test/oracle/fixtures/afi-config/registries/analysis-plugins/afi-scorer-froggy-trend-pullback--1.0.0.json +++ b/test/oracle/fixtures/afi-config/registries/analysis-plugins/afi-scorer-froggy-trend-pullback--1.0.0.json @@ -2,7 +2,7 @@ "schema": "afi.analysis-plugin.v1", "pluginId": "afi-scorer-froggy-trend-pullback", "pluginVersion": "1.0.0", - "implementationVersion": "1.3.0", + "implementationVersion": "1.4.0", "category": "scorer", "description": "Official froggy trend-pullback scorer plugin manifest: the single scoring seam of the froggy-trend-pullback pipeline. Consumes the AI/ML-augmented enriched view and produces the scored output on deterministic kernels; performs the sole VALIDATED->SCORED transition in the consuming runtime (LIFE-GOV transition monopoly).", "inputSchemaRef": "afi.enrichment.aiml.v1", diff --git a/test/oracle/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json b/test/oracle/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json index 06b5733d..da1a4ddc 100644 --- a/test/oracle/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json +++ b/test/oracle/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json @@ -22,12 +22,21 @@ }, "mappingRef": { "mappingId": "froggy-trend-pullback", - "version": "1.0.0" + "version": "1.1.0" }, "decayConfig": { "ratio": { "barsPerHalfLife": 12, "unknownTimeframeMinutes": 5 } + }, + "metadata": { + "note": "Non-authoritative, hash-excluded lineage of this configuration version (D-CFG-5(4): the version identity IS analystConfigHash; a superseded version survives on every record sealed under it and in git history).", + "supersedes": [ + { + "analystConfigHash": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479", + "act": "DEM-GOV DEM-BIND step (d) — mappingRef froggy-trend-pullback 1.0.0 (2026-08-23)" + } + ] } } diff --git a/test/oracle/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json b/test/oracle/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json index 0a82d4b6..b0fabc7c 100644 --- a/test/oracle/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json +++ b/test/oracle/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json @@ -7,7 +7,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "configRef": "afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json", "providerBindingPolicy": { diff --git a/test/oracle/fixtures/afi-config/registries/enrichment-mappings/froggy-trend-pullback--1.1.0.json b/test/oracle/fixtures/afi-config/registries/enrichment-mappings/froggy-trend-pullback--1.1.0.json new file mode 100644 index 00000000..f1e9bca6 --- /dev/null +++ b/test/oracle/fixtures/afi-config/registries/enrichment-mappings/froggy-trend-pullback--1.1.0.json @@ -0,0 +1,99 @@ +{ + "schema": "afi.enrichment-mapping.v1", + "mappingId": "froggy-trend-pullback", + "version": "1.1.0", + "description": "DEM-PRODUCER-PLAN: the 1.0.0 bindings plus rrMultiplePlanned bound to technical.plan.rrToFirstTarget, the technical lane's VERIFIED trade-plan fact (the submitted afi.trade-plan.v1 levels checked against the fetched candles, D-DEM-5(6)). Optional under D-DEM-5(4)(b): the producer emits no R:R for a submission carrying no complete plan; the declared default is the rubric floor (1), hash-committed when fired (DEM-GOV §9 D-5).", + "namespaceDefaults": [ + "technical", + "pattern" + ], + "bindings": { + "distanceFromDailyEmaPct": { + "operator": "bind", + "source": { + "lane": "technical", + "path": "emaDistancePct" + }, + "type": "number", + "optionality": { + "ground": "grandfather", + "default": 0 + } + }, + "pulledBackIntoSweetSpot": { + "operator": "bind", + "source": { + "lane": "technical", + "path": "isInValueSweetSpot" + }, + "type": "boolean", + "optionality": { + "ground": "grandfather", + "default": false + } + }, + "triggerPatternQuality": { + "operator": "band", + "source": { + "lane": "pattern", + "path": "patternConfidence" + }, + "rows": [ + { + "when": { + "gte": 75 + }, + "value": 3 + }, + { + "when": { + "gte": 65 + }, + "value": 2 + }, + { + "when": { + "gt": 0 + }, + "value": 1 + } + ], + "otherwise": 0, + "absent": 0 + }, + "atrRegime": { + "operator": "recode", + "source": { + "lane": "technical", + "path": "atrRegime" + }, + "table": { + "low": "low", + "high": "high", + "extreme": "extreme" + }, + "fallback": "normal", + "absent": "normal" + }, + "rrMultiplePlanned": { + "operator": "bind", + "source": { + "lane": "technical", + "path": "plan.rrToFirstTarget", + "producedBy": { + "pluginId": "afi-analysis-technical", + "pluginVersion": "2.0.0" + } + }, + "type": "number", + "optionality": { + "ground": "producer-declared", + "default": 1, + "producerRef": { + "pluginId": "afi-analysis-technical", + "pluginVersion": "2.0.0" + } + } + } + } +} diff --git a/test/oracle/fixtures/cpj/cpj-blofin-perp-long.json b/test/oracle/fixtures/cpj/cpj-blofin-perp-long.json index 443e431f..41ea5d1f 100644 --- a/test/oracle/fixtures/cpj/cpj-blofin-perp-long.json +++ b/test/oracle/fixtures/cpj/cpj-blofin-perp-long.json @@ -5,15 +5,19 @@ "providerId": "oracle-telegram-channel-1", "messageId": "oracle-cpj-msg-0001", "postedAt": "2026-01-15T10:00:00Z", - "rawText": "BTC LONG entry 42500 SL 41800 TP 43500 (oracle fixture)", + "rawText": "BTC LONG entry 50000 SL 49300 TP 51000 (oracle fixture)", "channelName": "Oracle Fixture Channel" }, "extracted": { "symbolRaw": "BTCUSDT", "side": "long", - "entry": 42500, - "stopLoss": 41800, - "takeProfits": [{ "price": 43500 }], + "entry": 50000, + "stopLoss": 49300, + "takeProfits": [ + { + "price": 51000 + } + ], "timeframeHint": "4h", "venueHint": "blofin", "marketTypeHint": "perp" diff --git a/test/oracle/fixtures/cpj/cpj-coinbase-spot-sell.json b/test/oracle/fixtures/cpj/cpj-coinbase-spot-sell.json index cc7f47e8..44d447c2 100644 --- a/test/oracle/fixtures/cpj/cpj-coinbase-spot-sell.json +++ b/test/oracle/fixtures/cpj/cpj-coinbase-spot-sell.json @@ -5,12 +5,12 @@ "providerId": "oracle-telegram-channel-2", "messageId": "oracle-cpj-msg-0002", "postedAt": "2026-01-15T11:00:00Z", - "rawText": "ETH sell 2280 (oracle fixture)" + "rawText": "ETH sell 3001.5 (oracle fixture)" }, "extracted": { "symbolRaw": "ETH-USD", "side": "sell", - "entry": 2280.5, + "entry": 3001.5, "timeframeHint": "1h", "venueHint": "coinbase", "marketTypeHint": "spot" diff --git a/test/oracle/goldens/enriched/cpj-blofin-perp-long.builtin.json b/test/oracle/goldens/enriched/cpj-blofin-perp-long.builtin.json index a25bedc1..20ad47fa 100644 --- a/test/oracle/goldens/enriched/cpj-blofin-perp-long.builtin.json +++ b/test/oracle/goldens/enriched/cpj-blofin-perp-long.builtin.json @@ -3,7 +3,7 @@ "analystId": "froggy", "assetClass": "crypto", "baseAsset": "BTC", - "conviction": 0.5916666666666667, + "conviction": 0.4916666666666667, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -22,10 +22,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.5, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.4916666666666667 }, "canonicalUss": { "facts": { @@ -35,11 +35,26 @@ "symbol": "BTC/USDT", "timeframe": "4h" }, + "plan": { + "levels": { + "entry": "50000", + "stopLoss": "49300", + "takeProfits": [ + { + "price": "51000" + } + ] + }, + "marketTypeHint": "perp", + "schema": "afi.trade-plan.v1", + "signalId": "cpj-telegram-oracle-telegram-channel-1-oracle-cpj-msg-0001", + "venueHint": "blofin" + }, "provenance": { "cpjMessageId": "oracle-cpj-msg-0001", "cpjParseConfidence": 0.87, "cpjPostedAt": "2026-01-15T10:00:00Z", - "ingestHash": "30e3b29854aca4c1e2d7883ab07ccec5ac9f914f390d18f4a4633f7e0e8cc979", + "ingestHash": "d5f4b7bb3d26facb9797a2ba2bd45b16446af4be0b29731b33aa334d3a7294c4", "ingestedAt": "", "providerId": "oracle-telegram-channel-1", "providerRef": "Oracle Fixture Channel", @@ -65,13 +80,13 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "enrichmentHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.enrichment-bundle", - "value": "76c5d51aaf4fd3e88cc60cd11af4c8adc92d8180b41e8fa9ad0b09221c32fbdf" + "value": "18bb43eb7798b6bb32f1494e4e6e5aefe6b8c1a66db06f019469dbccc6e51efd" }, "executionSummaryHash": { "algorithm": "sha256", @@ -91,7 +106,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.plugin-set", - "value": "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "value": "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" }, "schema": "afi.composition-ref.v1", "scorerPluginId": "afi-scorer-froggy-trend-pullback", @@ -109,13 +124,13 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.signal-input", - "value": "28c6017db962f2fc5c0b2bc60aeae40cbbda930766383be79bdc3cdfcd38dae7" + "value": "a80523bb6aac8e5073ce0247e87b2bfca55f4567fca4d42e0c15eeacc0321665" }, "outputHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "d53f0adf4a948a2e7a30033c4c8d3d4e8e31a31b9032c51ec6d397049958019a" + "value": "7d962cc8629632253a8e3f8f236ea1983a5c87d37cf10d26bcd12717ddd627da" }, "schema": "afi.provenance-record.v1", "schemaVersions": { @@ -173,7 +188,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "8702b8a1556f2dd0159dbee32706858c8f316b8e277d9ba6180f2cbb7d831bda" + "value": "f0adfe203a96427dc6df4fdeba3a2581b9b87a494ad05ef88adf19919e9729d9" }, "provider": { "deterministic": false, @@ -228,7 +243,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "9d1d5fd4c9d8a4ec2737391a557f35ad90ec9e55a9665db929d9b31c3e8ebacc" + "value": "2514cfe04fce4ed88f32a88ed3c1390154d0cceee919475c522739a257db2bd0" }, "provider": { "deterministic": false, @@ -282,7 +297,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "c8eb5ca2494e8cab6cb6ea3d854112bec5361b1f450193298c64770de41fbe99" + "value": "326e37a3560b0990a7c41317e884054cb80a6ea4d2a6193ef8f9b2ac7fd80b31" }, "provider": { "deterministic": true, @@ -336,7 +351,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "e863ed9d44ce95ad0ade8846209e39e2d412ba2985195d672626f9e98494f32d" + "value": "302c40bf06714992fdfe3b90567d9d8fc5113e8da49300e0dba187176be60325" }, "provider": { "deterministic": false, @@ -381,7 +396,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.lane-output", - "value": "d700b9e2221bfc867533d62aefe1657405022153852e0662fe0ed9089ae24b10" + "value": "8e1b7f10070a10e5e594877f88251155c6d894c820008415b75767ba0db1aed6" }, "credential": { "mode": "keyless" @@ -390,7 +405,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "d89aedc4c0c2d1b09900b195690be54707af0acd6bff8dd669a86ce05e15b6ee" + "value": "d26929d750140bfee0eca63136a490c4c3be3a93fcbb7bec8d1182be0b0eed7c" }, "priceSource": "demo", "provider": { @@ -419,7 +434,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-result", - "value": "92f5e94a04420fec5d91efd38fe07273edaa5870a37a4cde58747f5369094f8c" + "value": "c69857869e58c01bac4bc993681d1f4449de421d7c9ed8ddb029646f578f096c" }, "resultSchema": "afi.enrichment.technical.v1", "schema": "afi.provider-invocation-proof.v1", @@ -430,19 +445,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-record", - "value": "a8f27363728fdcc5d2079f6eccf4494279fdfeb3984159340cf41a548c3e6d52" + "value": "872c97d513bc807761512571204cd8840265d9b9d8217208735fc0c345605a75" }, "recordVersion": 1, "replayHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-replay", - "value": "5867dc5ab1bb27eb4517e73ffd0f54cb25833bcfccfb54c74310ecf7bc547311" + "value": "ec9f4c5b3b41835ffff942e10611b33640fe8a483342a8461d9a28c0005e5d05" }, "schema": "afi.scored-signal-evidence.v3", "scoredSignal": { "analystId": "froggy", - "conviction": 0.5916666666666667, + "conviction": 0.4916666666666667, "direction": "long", "provenanceRecordRef": "provenance-record:cpj-telegram-oracle-telegram-channel-1-oracle-cpj-msg-0001", "providerId": "oracle-telegram-channel-1", @@ -454,10 +469,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.5, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.4916666666666667 }, "signalId": "cpj-telegram-oracle-telegram-channel-1-oracle-cpj-msg-0001", "strategyId": "trend_pullback_v1", @@ -470,7 +485,7 @@ } }, "httpResponse": { - "ingestHash": "30e3b29854aca4c1e2d7883ab07ccec5ac9f914f390d18f4a4633f7e0e8cc979", + "ingestHash": "d5f4b7bb3d26facb9797a2ba2bd45b16446af4be0b29731b33aa334d3a7294c4", "ok": true, "persistence": { "lifecycleState": "SCORED", @@ -513,6 +528,18 @@ "ema50": 50000.250065789325, "emaDistancePct": 0.6212694575958345, "isInValueSweetSpot": true, + "plan": { + "barCount": 100, + "entryHigh": 50000, + "entryLow": 50000, + "entryPrice": 50000, + "envelopeHigh": 50821.013653806585, + "envelopeLow": 49146.90317099503, + "firstTargetPrice": 51000, + "rrToFirstTarget": 1.4286, + "stopPrice": 49300, + "targetCount": 1 + }, "rsi14": 53.96469980585331, "trendBias": "range", "volumeRatio": 1.49681802597411 @@ -523,7 +550,7 @@ "analystId": "froggy", "assetClass": "crypto", "baseAsset": "BTC", - "conviction": 0.5916666666666667, + "conviction": 0.4916666666666667, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -542,10 +569,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.5, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.4916666666666667 }, "decayParams": { "barsPerHalfLife": 12, @@ -564,6 +591,18 @@ "ema50": 50000.250065789325, "emaDistancePct": 0.6212694575958345, "isInValueSweetSpot": true, + "plan": { + "barCount": 100, + "entryHigh": 50000, + "entryLow": 50000, + "entryPrice": 50000, + "envelopeHigh": 50821.013653806585, + "envelopeLow": 49146.90317099503, + "firstTargetPrice": 51000, + "rrToFirstTarget": 1.4286, + "stopPrice": 49300, + "targetCount": 1 + }, "rsi14": 53.96469980585331, "trendBias": "range", "volumeRatio": 1.49681802597411 @@ -667,11 +706,26 @@ "symbol": "BTC/USDT", "timeframe": "4h" }, + "plan": { + "levels": { + "entry": "50000", + "stopLoss": "49300", + "takeProfits": [ + { + "price": "51000" + } + ] + }, + "marketTypeHint": "perp", + "schema": "afi.trade-plan.v1", + "signalId": "cpj-telegram-oracle-telegram-channel-1-oracle-cpj-msg-0001", + "venueHint": "blofin" + }, "provenance": { "cpjMessageId": "oracle-cpj-msg-0001", "cpjParseConfidence": 0.87, "cpjPostedAt": "2026-01-15T10:00:00Z", - "ingestHash": "30e3b29854aca4c1e2d7883ab07ccec5ac9f914f390d18f4a4633f7e0e8cc979", + "ingestHash": "d5f4b7bb3d26facb9797a2ba2bd45b16446af4be0b29731b33aa334d3a7294c4", "ingestedAt": "", "providerId": "oracle-telegram-channel-1", "providerRef": "Oracle Fixture Channel", @@ -695,11 +749,26 @@ "symbol": "BTC/USDT", "timeframe": "4h" }, + "plan": { + "levels": { + "entry": "50000", + "stopLoss": "49300", + "takeProfits": [ + { + "price": "51000" + } + ] + }, + "marketTypeHint": "perp", + "schema": "afi.trade-plan.v1", + "signalId": "cpj-telegram-oracle-telegram-channel-1-oracle-cpj-msg-0001", + "venueHint": "blofin" + }, "provenance": { "cpjMessageId": "oracle-cpj-msg-0001", "cpjParseConfidence": 0.87, "cpjPostedAt": "2026-01-15T10:00:00Z", - "ingestHash": "30e3b29854aca4c1e2d7883ab07ccec5ac9f914f390d18f4a4633f7e0e8cc979", + "ingestHash": "d5f4b7bb3d26facb9797a2ba2bd45b16446af4be0b29731b33aa334d3a7294c4", "ingestedAt": "", "providerId": "oracle-telegram-channel-1", "providerRef": "Oracle Fixture Channel", @@ -714,13 +783,13 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.signal-input", - "value": "28c6017db962f2fc5c0b2bc60aeae40cbbda930766383be79bdc3cdfcd38dae7" + "value": "a80523bb6aac8e5073ce0247e87b2bfca55f4567fca4d42e0c15eeacc0321665" }, "outputHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "d53f0adf4a948a2e7a30033c4c8d3d4e8e31a31b9032c51ec6d397049958019a" + "value": "7d962cc8629632253a8e3f8f236ea1983a5c87d37cf10d26bcd12717ddd627da" }, "scorerInput": { "atrRegime": "normal", @@ -730,7 +799,7 @@ "haFlatBackConfirmed": false, "liquiditySwept": false, "pulledBackIntoSweetSpot": true, - "rrMultiplePlanned": 2, + "rrMultiplePlanned": 1.4286, "triggerPatternQuality": 2, "weeklyBias": "neutral" }, diff --git a/test/oracle/goldens/enriched/cpj-blofin-perp-long.registry.json b/test/oracle/goldens/enriched/cpj-blofin-perp-long.registry.json index fefa610e..c9579269 100644 --- a/test/oracle/goldens/enriched/cpj-blofin-perp-long.registry.json +++ b/test/oracle/goldens/enriched/cpj-blofin-perp-long.registry.json @@ -3,7 +3,7 @@ "analystId": "froggy", "assetClass": "crypto", "baseAsset": "BTC", - "conviction": 0.5916666666666667, + "conviction": 0.4916666666666667, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -22,10 +22,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.5, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.4916666666666667 }, "canonicalUss": { "facts": { @@ -35,11 +35,26 @@ "symbol": "BTC/USDT", "timeframe": "4h" }, + "plan": { + "levels": { + "entry": "50000", + "stopLoss": "49300", + "takeProfits": [ + { + "price": "51000" + } + ] + }, + "marketTypeHint": "perp", + "schema": "afi.trade-plan.v1", + "signalId": "cpj-telegram-oracle-telegram-channel-1-oracle-cpj-msg-0001", + "venueHint": "blofin" + }, "provenance": { "cpjMessageId": "oracle-cpj-msg-0001", "cpjParseConfidence": 0.87, "cpjPostedAt": "2026-01-15T10:00:00Z", - "ingestHash": "30e3b29854aca4c1e2d7883ab07ccec5ac9f914f390d18f4a4633f7e0e8cc979", + "ingestHash": "d5f4b7bb3d26facb9797a2ba2bd45b16446af4be0b29731b33aa334d3a7294c4", "ingestedAt": "", "providerId": "oracle-telegram-channel-1", "providerRef": "Oracle Fixture Channel", @@ -65,13 +80,13 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "enrichmentHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.enrichment-bundle", - "value": "76c5d51aaf4fd3e88cc60cd11af4c8adc92d8180b41e8fa9ad0b09221c32fbdf" + "value": "18bb43eb7798b6bb32f1494e4e6e5aefe6b8c1a66db06f019469dbccc6e51efd" }, "executionSummaryHash": { "algorithm": "sha256", @@ -91,7 +106,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.plugin-set", - "value": "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "value": "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" }, "schema": "afi.composition-ref.v1", "scorerPluginId": "afi-scorer-froggy-trend-pullback", @@ -109,13 +124,13 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.signal-input", - "value": "28c6017db962f2fc5c0b2bc60aeae40cbbda930766383be79bdc3cdfcd38dae7" + "value": "a80523bb6aac8e5073ce0247e87b2bfca55f4567fca4d42e0c15eeacc0321665" }, "outputHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "d53f0adf4a948a2e7a30033c4c8d3d4e8e31a31b9032c51ec6d397049958019a" + "value": "7d962cc8629632253a8e3f8f236ea1983a5c87d37cf10d26bcd12717ddd627da" }, "schema": "afi.provenance-record.v1", "schemaVersions": { @@ -173,7 +188,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "8702b8a1556f2dd0159dbee32706858c8f316b8e277d9ba6180f2cbb7d831bda" + "value": "f0adfe203a96427dc6df4fdeba3a2581b9b87a494ad05ef88adf19919e9729d9" }, "provider": { "deterministic": false, @@ -228,7 +243,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "9d1d5fd4c9d8a4ec2737391a557f35ad90ec9e55a9665db929d9b31c3e8ebacc" + "value": "2514cfe04fce4ed88f32a88ed3c1390154d0cceee919475c522739a257db2bd0" }, "provider": { "deterministic": false, @@ -282,7 +297,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "c8eb5ca2494e8cab6cb6ea3d854112bec5361b1f450193298c64770de41fbe99" + "value": "326e37a3560b0990a7c41317e884054cb80a6ea4d2a6193ef8f9b2ac7fd80b31" }, "provider": { "deterministic": true, @@ -336,7 +351,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "e863ed9d44ce95ad0ade8846209e39e2d412ba2985195d672626f9e98494f32d" + "value": "302c40bf06714992fdfe3b90567d9d8fc5113e8da49300e0dba187176be60325" }, "provider": { "deterministic": false, @@ -381,7 +396,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.lane-output", - "value": "d700b9e2221bfc867533d62aefe1657405022153852e0662fe0ed9089ae24b10" + "value": "8e1b7f10070a10e5e594877f88251155c6d894c820008415b75767ba0db1aed6" }, "credential": { "mode": "keyless" @@ -390,7 +405,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "d89aedc4c0c2d1b09900b195690be54707af0acd6bff8dd669a86ce05e15b6ee" + "value": "d26929d750140bfee0eca63136a490c4c3be3a93fcbb7bec8d1182be0b0eed7c" }, "priceSource": "demo", "provider": { @@ -419,7 +434,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-result", - "value": "92f5e94a04420fec5d91efd38fe07273edaa5870a37a4cde58747f5369094f8c" + "value": "c69857869e58c01bac4bc993681d1f4449de421d7c9ed8ddb029646f578f096c" }, "resultSchema": "afi.enrichment.technical.v1", "schema": "afi.provider-invocation-proof.v1", @@ -430,19 +445,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-record", - "value": "d38c486d600d0688a89065b9ab776892591cf77914ccc3bf64a171bbf0a35700" + "value": "ccb66974486ea9a30de99b307037aabf849983451cb7bd25f0f3611ad77f824e" }, "recordVersion": 1, "replayHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-replay", - "value": "77fc54638146048bce83ab20de51e25fdc280702baf927c19444ee95f609b56d" + "value": "bb0c4e6e9554207cb6d9b36190992e9ad43cfd0f74db78f0cd1f0409f6aaa0c6" }, "schema": "afi.scored-signal-evidence.v3", "scoredSignal": { "analystId": "froggy", - "conviction": 0.5916666666666667, + "conviction": 0.4916666666666667, "direction": "long", "provenanceRecordRef": "provenance-record:cpj-telegram-oracle-telegram-channel-1-oracle-cpj-msg-0001", "providerId": "oracle-telegram-channel-1", @@ -454,10 +469,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.5, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.4916666666666667 }, "signalId": "cpj-telegram-oracle-telegram-channel-1-oracle-cpj-msg-0001", "strategyId": "trend_pullback_v1", @@ -470,7 +485,7 @@ } }, "httpResponse": { - "ingestHash": "30e3b29854aca4c1e2d7883ab07ccec5ac9f914f390d18f4a4633f7e0e8cc979", + "ingestHash": "d5f4b7bb3d26facb9797a2ba2bd45b16446af4be0b29731b33aa334d3a7294c4", "ok": true, "persistence": { "lifecycleState": "SCORED", @@ -513,6 +528,18 @@ "ema50": 50000.250065789325, "emaDistancePct": 0.6212694575958345, "isInValueSweetSpot": true, + "plan": { + "barCount": 100, + "entryHigh": 50000, + "entryLow": 50000, + "entryPrice": 50000, + "envelopeHigh": 50821.013653806585, + "envelopeLow": 49146.90317099503, + "firstTargetPrice": 51000, + "rrToFirstTarget": 1.4286, + "stopPrice": 49300, + "targetCount": 1 + }, "rsi14": 53.96469980585331, "trendBias": "range", "volumeRatio": 1.49681802597411 @@ -523,7 +550,7 @@ "analystId": "froggy", "assetClass": "crypto", "baseAsset": "BTC", - "conviction": 0.5916666666666667, + "conviction": 0.4916666666666667, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -542,10 +569,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.5, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.4916666666666667 }, "decayParams": { "barsPerHalfLife": 12, @@ -564,6 +591,18 @@ "ema50": 50000.250065789325, "emaDistancePct": 0.6212694575958345, "isInValueSweetSpot": true, + "plan": { + "barCount": 100, + "entryHigh": 50000, + "entryLow": 50000, + "entryPrice": 50000, + "envelopeHigh": 50821.013653806585, + "envelopeLow": 49146.90317099503, + "firstTargetPrice": 51000, + "rrToFirstTarget": 1.4286, + "stopPrice": 49300, + "targetCount": 1 + }, "rsi14": 53.96469980585331, "trendBias": "range", "volumeRatio": 1.49681802597411 @@ -667,11 +706,26 @@ "symbol": "BTC/USDT", "timeframe": "4h" }, + "plan": { + "levels": { + "entry": "50000", + "stopLoss": "49300", + "takeProfits": [ + { + "price": "51000" + } + ] + }, + "marketTypeHint": "perp", + "schema": "afi.trade-plan.v1", + "signalId": "cpj-telegram-oracle-telegram-channel-1-oracle-cpj-msg-0001", + "venueHint": "blofin" + }, "provenance": { "cpjMessageId": "oracle-cpj-msg-0001", "cpjParseConfidence": 0.87, "cpjPostedAt": "2026-01-15T10:00:00Z", - "ingestHash": "30e3b29854aca4c1e2d7883ab07ccec5ac9f914f390d18f4a4633f7e0e8cc979", + "ingestHash": "d5f4b7bb3d26facb9797a2ba2bd45b16446af4be0b29731b33aa334d3a7294c4", "ingestedAt": "", "providerId": "oracle-telegram-channel-1", "providerRef": "Oracle Fixture Channel", @@ -695,11 +749,26 @@ "symbol": "BTC/USDT", "timeframe": "4h" }, + "plan": { + "levels": { + "entry": "50000", + "stopLoss": "49300", + "takeProfits": [ + { + "price": "51000" + } + ] + }, + "marketTypeHint": "perp", + "schema": "afi.trade-plan.v1", + "signalId": "cpj-telegram-oracle-telegram-channel-1-oracle-cpj-msg-0001", + "venueHint": "blofin" + }, "provenance": { "cpjMessageId": "oracle-cpj-msg-0001", "cpjParseConfidence": 0.87, "cpjPostedAt": "2026-01-15T10:00:00Z", - "ingestHash": "30e3b29854aca4c1e2d7883ab07ccec5ac9f914f390d18f4a4633f7e0e8cc979", + "ingestHash": "d5f4b7bb3d26facb9797a2ba2bd45b16446af4be0b29731b33aa334d3a7294c4", "ingestedAt": "", "providerId": "oracle-telegram-channel-1", "providerRef": "Oracle Fixture Channel", @@ -714,13 +783,13 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.signal-input", - "value": "28c6017db962f2fc5c0b2bc60aeae40cbbda930766383be79bdc3cdfcd38dae7" + "value": "a80523bb6aac8e5073ce0247e87b2bfca55f4567fca4d42e0c15eeacc0321665" }, "outputHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "d53f0adf4a948a2e7a30033c4c8d3d4e8e31a31b9032c51ec6d397049958019a" + "value": "7d962cc8629632253a8e3f8f236ea1983a5c87d37cf10d26bcd12717ddd627da" }, "scorerInput": { "atrRegime": "normal", @@ -730,7 +799,7 @@ "haFlatBackConfirmed": false, "liquiditySwept": false, "pulledBackIntoSweetSpot": true, - "rrMultiplePlanned": 2, + "rrMultiplePlanned": 1.4286, "triggerPatternQuality": 2, "weeklyBias": "neutral" }, diff --git a/test/oracle/goldens/enriched/cpj-blofin-perp-neutral.builtin.json b/test/oracle/goldens/enriched/cpj-blofin-perp-neutral.builtin.json index feaabb34..88a30bdb 100644 --- a/test/oracle/goldens/enriched/cpj-blofin-perp-neutral.builtin.json +++ b/test/oracle/goldens/enriched/cpj-blofin-perp-neutral.builtin.json @@ -7,7 +7,7 @@ "insight": "Liquidity or volatility context is weak." }, "baseAsset": "SOL", - "conviction": 0.4, + "conviction": 0.22500000000000003, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -27,10 +27,10 @@ "uwrAxes": { "execution": 0, "insight": 0.3, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.4 + "uwrScore": 0.22500000000000003 }, "canonicalUss": { "facts": { @@ -70,7 +70,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "enrichmentHash": { "algorithm": "sha256", @@ -96,7 +96,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.plugin-set", - "value": "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "value": "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" }, "schema": "afi.composition-ref.v1", "scorerPluginId": "afi-scorer-froggy-trend-pullback", @@ -120,7 +120,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "796fa7e279b2433cf35f7f5c85dc6e20e689cbab8463e70940330ecaac26a98c" + "value": "dfe337e65e6568af9bf10c01523b8eccf5342fb6f9f3bf27b89d423f5b92c4b4" }, "schema": "afi.provenance-record.v1", "schemaVersions": { @@ -435,19 +435,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-record", - "value": "bfa23ce42be174a80ced20342b0784d15139560487634dc6a08834e6f033f06c" + "value": "2d3c61dc5d15cce19420deb2174801228d38291a94cd437578c3c09c0e65fcff" }, "recordVersion": 1, "replayHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-replay", - "value": "717635a7456d22bca03fa37f1c62a7b8eab2e5dc0beaa83acc769835c7277469" + "value": "562068cd1d9b3015249bc7982c80863e05297591994d5c959ce98e924a650cdc" }, "schema": "afi.scored-signal-evidence.v3", "scoredSignal": { "analystId": "froggy", - "conviction": 0.4, + "conviction": 0.22500000000000003, "direction": "neutral", "provenanceRecordRef": "provenance-record:cpj-discord-oracle-discord-guild-3-oracle-cpj-msg-0003", "providerId": "oracle-discord-guild-3", @@ -459,10 +459,10 @@ "uwrAxes": { "execution": 0, "insight": 0.3, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.4 + "uwrScore": 0.22500000000000003 }, "signalId": "cpj-discord-oracle-discord-guild-3-oracle-cpj-msg-0003", "strategyId": "trend_pullback_v1", @@ -520,7 +520,7 @@ "insight": "Liquidity or volatility context is weak." }, "baseAsset": "SOL", - "conviction": 0.4, + "conviction": 0.22500000000000003, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -540,10 +540,10 @@ "uwrAxes": { "execution": 0, "insight": 0.3, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.4 + "uwrScore": 0.22500000000000003 }, "decayParams": { "barsPerHalfLife": 12, @@ -682,7 +682,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "796fa7e279b2433cf35f7f5c85dc6e20e689cbab8463e70940330ecaac26a98c" + "value": "dfe337e65e6568af9bf10c01523b8eccf5342fb6f9f3bf27b89d423f5b92c4b4" }, "scorerInput": { "atrRegime": "high", @@ -692,7 +692,7 @@ "haFlatBackConfirmed": false, "liquiditySwept": false, "pulledBackIntoSweetSpot": true, - "rrMultiplePlanned": 2, + "rrMultiplePlanned": 1, "triggerPatternQuality": 0, "weeklyBias": "neutral" }, diff --git a/test/oracle/goldens/enriched/cpj-blofin-perp-neutral.registry.json b/test/oracle/goldens/enriched/cpj-blofin-perp-neutral.registry.json index fb4cf944..6a27f570 100644 --- a/test/oracle/goldens/enriched/cpj-blofin-perp-neutral.registry.json +++ b/test/oracle/goldens/enriched/cpj-blofin-perp-neutral.registry.json @@ -7,7 +7,7 @@ "insight": "Liquidity or volatility context is weak." }, "baseAsset": "SOL", - "conviction": 0.4, + "conviction": 0.22500000000000003, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -27,10 +27,10 @@ "uwrAxes": { "execution": 0, "insight": 0.3, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.4 + "uwrScore": 0.22500000000000003 }, "canonicalUss": { "facts": { @@ -70,7 +70,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "enrichmentHash": { "algorithm": "sha256", @@ -96,7 +96,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.plugin-set", - "value": "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "value": "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" }, "schema": "afi.composition-ref.v1", "scorerPluginId": "afi-scorer-froggy-trend-pullback", @@ -120,7 +120,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "796fa7e279b2433cf35f7f5c85dc6e20e689cbab8463e70940330ecaac26a98c" + "value": "dfe337e65e6568af9bf10c01523b8eccf5342fb6f9f3bf27b89d423f5b92c4b4" }, "schema": "afi.provenance-record.v1", "schemaVersions": { @@ -435,19 +435,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-record", - "value": "841339e347fffafac98cd92185492ec493de9cd7a444449430ac1940f0714a81" + "value": "746c77f73e55fb392f42f20cacf4a31073d40fa4e18eaf410ab9b531510a4787" }, "recordVersion": 1, "replayHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-replay", - "value": "bbbbe59595894c6ff3fd3f4d51ab046079531c1dad91ac6454a462fb2c29cf53" + "value": "8ee23c624347df6f11ab3c3a56231f0579091f91edfd7e6d86688e2281a717d1" }, "schema": "afi.scored-signal-evidence.v3", "scoredSignal": { "analystId": "froggy", - "conviction": 0.4, + "conviction": 0.22500000000000003, "direction": "neutral", "provenanceRecordRef": "provenance-record:cpj-discord-oracle-discord-guild-3-oracle-cpj-msg-0003", "providerId": "oracle-discord-guild-3", @@ -459,10 +459,10 @@ "uwrAxes": { "execution": 0, "insight": 0.3, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.4 + "uwrScore": 0.22500000000000003 }, "signalId": "cpj-discord-oracle-discord-guild-3-oracle-cpj-msg-0003", "strategyId": "trend_pullback_v1", @@ -520,7 +520,7 @@ "insight": "Liquidity or volatility context is weak." }, "baseAsset": "SOL", - "conviction": 0.4, + "conviction": 0.22500000000000003, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -540,10 +540,10 @@ "uwrAxes": { "execution": 0, "insight": 0.3, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.4 + "uwrScore": 0.22500000000000003 }, "decayParams": { "barsPerHalfLife": 12, @@ -682,7 +682,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "796fa7e279b2433cf35f7f5c85dc6e20e689cbab8463e70940330ecaac26a98c" + "value": "dfe337e65e6568af9bf10c01523b8eccf5342fb6f9f3bf27b89d423f5b92c4b4" }, "scorerInput": { "atrRegime": "high", @@ -692,7 +692,7 @@ "haFlatBackConfirmed": false, "liquiditySwept": false, "pulledBackIntoSweetSpot": true, - "rrMultiplePlanned": 2, + "rrMultiplePlanned": 1, "triggerPatternQuality": 0, "weeklyBias": "neutral" }, diff --git a/test/oracle/goldens/enriched/cpj-coinbase-spot-sell.builtin.json b/test/oracle/goldens/enriched/cpj-coinbase-spot-sell.builtin.json index 098b942e..cd351d9d 100644 --- a/test/oracle/goldens/enriched/cpj-coinbase-spot-sell.builtin.json +++ b/test/oracle/goldens/enriched/cpj-coinbase-spot-sell.builtin.json @@ -3,7 +3,7 @@ "analystId": "froggy", "assetClass": "crypto", "baseAsset": "ETH", - "conviction": 0.5916666666666667, + "conviction": 0.41666666666666663, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -22,10 +22,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.41666666666666663 }, "canonicalUss": { "facts": { @@ -35,11 +35,20 @@ "symbol": "ETH/USD", "timeframe": "1h" }, + "plan": { + "levels": { + "entry": "3001.5" + }, + "marketTypeHint": "spot", + "schema": "afi.trade-plan.v1", + "signalId": "cpj-telegram-oracle-telegram-channel-2-oracle-cpj-msg-0002", + "venueHint": "coinbase" + }, "provenance": { "cpjMessageId": "oracle-cpj-msg-0002", "cpjParseConfidence": 0.6180339887, "cpjPostedAt": "2026-01-15T11:00:00Z", - "ingestHash": "5c89a9e9c8ef090a4da758910ac33fed69bc887de2d7a8c4a6224473e5ca978c", + "ingestHash": "b7bfe2d26f182396659e2548f6e7021a64a02f71e7e349a147c2c6f9716614cf", "ingestedAt": "", "providerId": "oracle-telegram-channel-2", "providerRef": "oracle-telegram-channel-2", @@ -65,19 +74,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "enrichmentHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.enrichment-bundle", - "value": "4eddd28c441aaf46637adfb3f018f4d7074931aeef2ebaaddea61831335e7dad" + "value": "0f706295d4b2180475d044b2931bf6fd51c89789a59da082619adfb1380ff4a2" }, "executionSummaryHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.execution-summary", - "value": "9b23d299c792c6f97f50afaef26d931f8feefe783d9bd375200e9d09978630b4" + "value": "1178e36dcbad0110b42c69bfdff3917592c5d82fef49401d815b09e7a20b5013" }, "manifestHash": { "algorithm": "sha256", @@ -91,7 +100,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.plugin-set", - "value": "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "value": "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" }, "schema": "afi.composition-ref.v1", "scorerPluginId": "afi-scorer-froggy-trend-pullback", @@ -109,13 +118,13 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.signal-input", - "value": "fdf5fe2e03ebb5a62cf8cf71e2416e07d0208cff527826ad27b534c873f1a7d8" + "value": "7f1c18840dd4a715f256e4b91cd4e5f3adf6ad2b8436ab32f10461d2938b16d9" }, "outputHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "03e31a8e4374c7fc4e4a51c2a8f418902041e2ad64ed1fca62576e7373e36e9d" + "value": "0d6692715c09c0c89531d8f1e359761e7e2c8ca9610196992395bbf9501de876" }, "schema": "afi.provenance-record.v1", "schemaVersions": { @@ -173,7 +182,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "39c93e84deb72187cdd7cba99eed77fc03668bacf0568553ab4bddc3373c0295" + "value": "b55250716818e6f3faaaf74ccf4e0569f17268469606969afdcd0b1267eb505e" }, "provider": { "deterministic": false, @@ -228,7 +237,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "6b93561dc4c33405af16bd592f9666b4c8f9be53fc46d566e99cd69c1bb5852f" + "value": "8974684c2c52e6912f38159e3ee0e44cefa936139303320d0ca6a21792658b45" }, "provider": { "deterministic": false, @@ -282,7 +291,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "7feef5dbd1b0613a67b6751f1dfd6e3a4bae49f729f4625ff32934116a2d6a02" + "value": "dc53fecbb804800cbf19399949d5144bdfb583d9dfd3cb68a2574081ec4b4fe3" }, "provider": { "deterministic": true, @@ -336,7 +345,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "e913e6836cd39ceee8f4d54844d688c1ca3e22a1c575a90a0ebbcb5c55d438cd" + "value": "1586265552293589ecc4838cdb0bf78667cf441478c0d69eefff05bd8ff56566" }, "provider": { "deterministic": false, @@ -381,7 +390,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.lane-output", - "value": "3d06a2f88967ba527788f8895838f3965ebf35a0904bd8c16b0129649c9094ef" + "value": "435bb57dc7dc5f3ada1691e1e4a4f7fb2b35ea6c16e9bc9435bca67cb905c552" }, "credential": { "mode": "keyless" @@ -390,7 +399,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "f12cc96839ed2de196a14f799e346f9ad8e2f09c0ac11dacb3aabaf48941ec73" + "value": "9677821cd94f391031f2dfca16b78467912918d8da2308f90c84f0db2566a8fb" }, "priceSource": "demo", "provider": { @@ -419,7 +428,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-result", - "value": "a5fbb1c2bb13428bd3513edede61517fbacaa57024c49917cc48beb1bd4db1ba" + "value": "1323407907c4a9eefc8eaa4650b386e49a8f582c21299017d17d43d09ea9d403" }, "resultSchema": "afi.enrichment.technical.v1", "schema": "afi.provider-invocation-proof.v1", @@ -430,19 +439,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-record", - "value": "59eb2c9faebcf82c4abaf9d663f123c9619fb48df464d1839068c3bfa1908e14" + "value": "33a4e9286ed946bc360d2efb59dbd40e3a26a548f5a111e279f3a13eec4f179c" }, "recordVersion": 1, "replayHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-replay", - "value": "1f2e31f69f481d8cb97ff82739733f46f46181aba9b6540844fad86bf079aca4" + "value": "ca32f15d3377c4c685da0f7727521366bf6bd07188386a92347a06220f78b430" }, "schema": "afi.scored-signal-evidence.v3", "scoredSignal": { "analystId": "froggy", - "conviction": 0.5916666666666667, + "conviction": 0.41666666666666663, "direction": "short", "provenanceRecordRef": "provenance-record:cpj-telegram-oracle-telegram-channel-2-oracle-cpj-msg-0002", "providerId": "oracle-telegram-channel-2", @@ -454,10 +463,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.41666666666666663 }, "signalId": "cpj-telegram-oracle-telegram-channel-2-oracle-cpj-msg-0002", "strategyId": "trend_pullback_v1", @@ -470,7 +479,7 @@ } }, "httpResponse": { - "ingestHash": "5c89a9e9c8ef090a4da758910ac33fed69bc887de2d7a8c4a6224473e5ca978c", + "ingestHash": "b7bfe2d26f182396659e2548f6e7021a64a02f71e7e349a147c2c6f9716614cf", "ok": true, "persistence": { "lifecycleState": "SCORED", @@ -513,6 +522,15 @@ "ema50": 2999.0429709032783, "emaDistancePct": -0.6511453704633388, "isInValueSweetSpot": true, + "plan": { + "barCount": 100, + "entryHigh": 3001.5, + "entryLow": 3001.5, + "entryPrice": 3001.5, + "envelopeHigh": 3045.258508126984, + "envelopeLow": 2950.71729627225, + "targetCount": 0 + }, "rsi14": 45.747266481159215, "trendBias": "range", "volumeRatio": 0.7842600319383496 @@ -523,7 +541,7 @@ "analystId": "froggy", "assetClass": "crypto", "baseAsset": "ETH", - "conviction": 0.5916666666666667, + "conviction": 0.41666666666666663, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -542,10 +560,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.41666666666666663 }, "decayParams": { "barsPerHalfLife": 12, @@ -564,6 +582,15 @@ "ema50": 2999.0429709032783, "emaDistancePct": -0.6511453704633388, "isInValueSweetSpot": true, + "plan": { + "barCount": 100, + "entryHigh": 3001.5, + "entryLow": 3001.5, + "entryPrice": 3001.5, + "envelopeHigh": 3045.258508126984, + "envelopeLow": 2950.71729627225, + "targetCount": 0 + }, "rsi14": 45.747266481159215, "trendBias": "range", "volumeRatio": 0.7842600319383496 @@ -667,11 +694,20 @@ "symbol": "ETH/USD", "timeframe": "1h" }, + "plan": { + "levels": { + "entry": "3001.5" + }, + "marketTypeHint": "spot", + "schema": "afi.trade-plan.v1", + "signalId": "cpj-telegram-oracle-telegram-channel-2-oracle-cpj-msg-0002", + "venueHint": "coinbase" + }, "provenance": { "cpjMessageId": "oracle-cpj-msg-0002", "cpjParseConfidence": 0.6180339887, "cpjPostedAt": "2026-01-15T11:00:00Z", - "ingestHash": "5c89a9e9c8ef090a4da758910ac33fed69bc887de2d7a8c4a6224473e5ca978c", + "ingestHash": "b7bfe2d26f182396659e2548f6e7021a64a02f71e7e349a147c2c6f9716614cf", "ingestedAt": "", "providerId": "oracle-telegram-channel-2", "providerRef": "oracle-telegram-channel-2", @@ -695,11 +731,20 @@ "symbol": "ETH/USD", "timeframe": "1h" }, + "plan": { + "levels": { + "entry": "3001.5" + }, + "marketTypeHint": "spot", + "schema": "afi.trade-plan.v1", + "signalId": "cpj-telegram-oracle-telegram-channel-2-oracle-cpj-msg-0002", + "venueHint": "coinbase" + }, "provenance": { "cpjMessageId": "oracle-cpj-msg-0002", "cpjParseConfidence": 0.6180339887, "cpjPostedAt": "2026-01-15T11:00:00Z", - "ingestHash": "5c89a9e9c8ef090a4da758910ac33fed69bc887de2d7a8c4a6224473e5ca978c", + "ingestHash": "b7bfe2d26f182396659e2548f6e7021a64a02f71e7e349a147c2c6f9716614cf", "ingestedAt": "", "providerId": "oracle-telegram-channel-2", "providerRef": "oracle-telegram-channel-2", @@ -714,13 +759,13 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.signal-input", - "value": "fdf5fe2e03ebb5a62cf8cf71e2416e07d0208cff527826ad27b534c873f1a7d8" + "value": "7f1c18840dd4a715f256e4b91cd4e5f3adf6ad2b8436ab32f10461d2938b16d9" }, "outputHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "03e31a8e4374c7fc4e4a51c2a8f418902041e2ad64ed1fca62576e7373e36e9d" + "value": "0d6692715c09c0c89531d8f1e359761e7e2c8ca9610196992395bbf9501de876" }, "scorerInput": { "atrRegime": "normal", @@ -730,7 +775,7 @@ "haFlatBackConfirmed": false, "liquiditySwept": false, "pulledBackIntoSweetSpot": true, - "rrMultiplePlanned": 2, + "rrMultiplePlanned": 1, "triggerPatternQuality": 2, "weeklyBias": "neutral" }, diff --git a/test/oracle/goldens/enriched/cpj-coinbase-spot-sell.registry.json b/test/oracle/goldens/enriched/cpj-coinbase-spot-sell.registry.json index 83bdf4ac..f023b492 100644 --- a/test/oracle/goldens/enriched/cpj-coinbase-spot-sell.registry.json +++ b/test/oracle/goldens/enriched/cpj-coinbase-spot-sell.registry.json @@ -3,7 +3,7 @@ "analystId": "froggy", "assetClass": "crypto", "baseAsset": "ETH", - "conviction": 0.5916666666666667, + "conviction": 0.41666666666666663, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -22,10 +22,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.41666666666666663 }, "canonicalUss": { "facts": { @@ -35,11 +35,20 @@ "symbol": "ETH/USD", "timeframe": "1h" }, + "plan": { + "levels": { + "entry": "3001.5" + }, + "marketTypeHint": "spot", + "schema": "afi.trade-plan.v1", + "signalId": "cpj-telegram-oracle-telegram-channel-2-oracle-cpj-msg-0002", + "venueHint": "coinbase" + }, "provenance": { "cpjMessageId": "oracle-cpj-msg-0002", "cpjParseConfidence": 0.6180339887, "cpjPostedAt": "2026-01-15T11:00:00Z", - "ingestHash": "5c89a9e9c8ef090a4da758910ac33fed69bc887de2d7a8c4a6224473e5ca978c", + "ingestHash": "b7bfe2d26f182396659e2548f6e7021a64a02f71e7e349a147c2c6f9716614cf", "ingestedAt": "", "providerId": "oracle-telegram-channel-2", "providerRef": "oracle-telegram-channel-2", @@ -65,19 +74,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "enrichmentHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.enrichment-bundle", - "value": "4eddd28c441aaf46637adfb3f018f4d7074931aeef2ebaaddea61831335e7dad" + "value": "0f706295d4b2180475d044b2931bf6fd51c89789a59da082619adfb1380ff4a2" }, "executionSummaryHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.execution-summary", - "value": "9b23d299c792c6f97f50afaef26d931f8feefe783d9bd375200e9d09978630b4" + "value": "1178e36dcbad0110b42c69bfdff3917592c5d82fef49401d815b09e7a20b5013" }, "manifestHash": { "algorithm": "sha256", @@ -91,7 +100,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.plugin-set", - "value": "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "value": "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" }, "schema": "afi.composition-ref.v1", "scorerPluginId": "afi-scorer-froggy-trend-pullback", @@ -109,13 +118,13 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.signal-input", - "value": "fdf5fe2e03ebb5a62cf8cf71e2416e07d0208cff527826ad27b534c873f1a7d8" + "value": "7f1c18840dd4a715f256e4b91cd4e5f3adf6ad2b8436ab32f10461d2938b16d9" }, "outputHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "03e31a8e4374c7fc4e4a51c2a8f418902041e2ad64ed1fca62576e7373e36e9d" + "value": "0d6692715c09c0c89531d8f1e359761e7e2c8ca9610196992395bbf9501de876" }, "schema": "afi.provenance-record.v1", "schemaVersions": { @@ -173,7 +182,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "39c93e84deb72187cdd7cba99eed77fc03668bacf0568553ab4bddc3373c0295" + "value": "b55250716818e6f3faaaf74ccf4e0569f17268469606969afdcd0b1267eb505e" }, "provider": { "deterministic": false, @@ -228,7 +237,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "6b93561dc4c33405af16bd592f9666b4c8f9be53fc46d566e99cd69c1bb5852f" + "value": "8974684c2c52e6912f38159e3ee0e44cefa936139303320d0ca6a21792658b45" }, "provider": { "deterministic": false, @@ -282,7 +291,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "7feef5dbd1b0613a67b6751f1dfd6e3a4bae49f729f4625ff32934116a2d6a02" + "value": "dc53fecbb804800cbf19399949d5144bdfb583d9dfd3cb68a2574081ec4b4fe3" }, "provider": { "deterministic": true, @@ -336,7 +345,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "e913e6836cd39ceee8f4d54844d688c1ca3e22a1c575a90a0ebbcb5c55d438cd" + "value": "1586265552293589ecc4838cdb0bf78667cf441478c0d69eefff05bd8ff56566" }, "provider": { "deterministic": false, @@ -381,7 +390,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.lane-output", - "value": "3d06a2f88967ba527788f8895838f3965ebf35a0904bd8c16b0129649c9094ef" + "value": "435bb57dc7dc5f3ada1691e1e4a4f7fb2b35ea6c16e9bc9435bca67cb905c552" }, "credential": { "mode": "keyless" @@ -390,7 +399,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-invocation-input", - "value": "f12cc96839ed2de196a14f799e346f9ad8e2f09c0ac11dacb3aabaf48941ec73" + "value": "9677821cd94f391031f2dfca16b78467912918d8da2308f90c84f0db2566a8fb" }, "priceSource": "demo", "provider": { @@ -419,7 +428,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.provider-result", - "value": "a5fbb1c2bb13428bd3513edede61517fbacaa57024c49917cc48beb1bd4db1ba" + "value": "1323407907c4a9eefc8eaa4650b386e49a8f582c21299017d17d43d09ea9d403" }, "resultSchema": "afi.enrichment.technical.v1", "schema": "afi.provider-invocation-proof.v1", @@ -430,19 +439,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-record", - "value": "8499d1f7a7b6b7b91ed0bcf8eb889f3cae3312b2bfef7dfb821337c4e73b44d0" + "value": "6b9c06c256913209f0338fda4562d9730ce01062755ddf0cdba8f5c8abb11cf7" }, "recordVersion": 1, "replayHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-replay", - "value": "9f4bebc69e8f2685b80b14a7ee5b45756b20e8601a1741617d7b9a49440eeed2" + "value": "a883346b80e6f93a802f567dabc6432c4928029fd0629350d3ad2a8f71bcfd95" }, "schema": "afi.scored-signal-evidence.v3", "scoredSignal": { "analystId": "froggy", - "conviction": 0.5916666666666667, + "conviction": 0.41666666666666663, "direction": "short", "provenanceRecordRef": "provenance-record:cpj-telegram-oracle-telegram-channel-2-oracle-cpj-msg-0002", "providerId": "oracle-telegram-channel-2", @@ -454,10 +463,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.41666666666666663 }, "signalId": "cpj-telegram-oracle-telegram-channel-2-oracle-cpj-msg-0002", "strategyId": "trend_pullback_v1", @@ -470,7 +479,7 @@ } }, "httpResponse": { - "ingestHash": "5c89a9e9c8ef090a4da758910ac33fed69bc887de2d7a8c4a6224473e5ca978c", + "ingestHash": "b7bfe2d26f182396659e2548f6e7021a64a02f71e7e349a147c2c6f9716614cf", "ok": true, "persistence": { "lifecycleState": "SCORED", @@ -513,6 +522,15 @@ "ema50": 2999.0429709032783, "emaDistancePct": -0.6511453704633388, "isInValueSweetSpot": true, + "plan": { + "barCount": 100, + "entryHigh": 3001.5, + "entryLow": 3001.5, + "entryPrice": 3001.5, + "envelopeHigh": 3045.258508126984, + "envelopeLow": 2950.71729627225, + "targetCount": 0 + }, "rsi14": 45.747266481159215, "trendBias": "range", "volumeRatio": 0.7842600319383496 @@ -523,7 +541,7 @@ "analystId": "froggy", "assetClass": "crypto", "baseAsset": "ETH", - "conviction": 0.5916666666666667, + "conviction": 0.41666666666666663, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -542,10 +560,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.41666666666666663 }, "decayParams": { "barsPerHalfLife": 12, @@ -564,6 +582,15 @@ "ema50": 2999.0429709032783, "emaDistancePct": -0.6511453704633388, "isInValueSweetSpot": true, + "plan": { + "barCount": 100, + "entryHigh": 3001.5, + "entryLow": 3001.5, + "entryPrice": 3001.5, + "envelopeHigh": 3045.258508126984, + "envelopeLow": 2950.71729627225, + "targetCount": 0 + }, "rsi14": 45.747266481159215, "trendBias": "range", "volumeRatio": 0.7842600319383496 @@ -667,11 +694,20 @@ "symbol": "ETH/USD", "timeframe": "1h" }, + "plan": { + "levels": { + "entry": "3001.5" + }, + "marketTypeHint": "spot", + "schema": "afi.trade-plan.v1", + "signalId": "cpj-telegram-oracle-telegram-channel-2-oracle-cpj-msg-0002", + "venueHint": "coinbase" + }, "provenance": { "cpjMessageId": "oracle-cpj-msg-0002", "cpjParseConfidence": 0.6180339887, "cpjPostedAt": "2026-01-15T11:00:00Z", - "ingestHash": "5c89a9e9c8ef090a4da758910ac33fed69bc887de2d7a8c4a6224473e5ca978c", + "ingestHash": "b7bfe2d26f182396659e2548f6e7021a64a02f71e7e349a147c2c6f9716614cf", "ingestedAt": "", "providerId": "oracle-telegram-channel-2", "providerRef": "oracle-telegram-channel-2", @@ -695,11 +731,20 @@ "symbol": "ETH/USD", "timeframe": "1h" }, + "plan": { + "levels": { + "entry": "3001.5" + }, + "marketTypeHint": "spot", + "schema": "afi.trade-plan.v1", + "signalId": "cpj-telegram-oracle-telegram-channel-2-oracle-cpj-msg-0002", + "venueHint": "coinbase" + }, "provenance": { "cpjMessageId": "oracle-cpj-msg-0002", "cpjParseConfidence": 0.6180339887, "cpjPostedAt": "2026-01-15T11:00:00Z", - "ingestHash": "5c89a9e9c8ef090a4da758910ac33fed69bc887de2d7a8c4a6224473e5ca978c", + "ingestHash": "b7bfe2d26f182396659e2548f6e7021a64a02f71e7e349a147c2c6f9716614cf", "ingestedAt": "", "providerId": "oracle-telegram-channel-2", "providerRef": "oracle-telegram-channel-2", @@ -714,13 +759,13 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.signal-input", - "value": "fdf5fe2e03ebb5a62cf8cf71e2416e07d0208cff527826ad27b534c873f1a7d8" + "value": "7f1c18840dd4a715f256e4b91cd4e5f3adf6ad2b8436ab32f10461d2938b16d9" }, "outputHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "03e31a8e4374c7fc4e4a51c2a8f418902041e2ad64ed1fca62576e7373e36e9d" + "value": "0d6692715c09c0c89531d8f1e359761e7e2c8ca9610196992395bbf9501de876" }, "scorerInput": { "atrRegime": "normal", @@ -730,7 +775,7 @@ "haFlatBackConfirmed": false, "liquiditySwept": false, "pulledBackIntoSweetSpot": true, - "rrMultiplePlanned": 2, + "rrMultiplePlanned": 1, "triggerPatternQuality": 2, "weeklyBias": "neutral" }, diff --git a/test/oracle/goldens/enriched/tv-long.builtin.json b/test/oracle/goldens/enriched/tv-long.builtin.json index c7e76d22..49189cec 100644 --- a/test/oracle/goldens/enriched/tv-long.builtin.json +++ b/test/oracle/goldens/enriched/tv-long.builtin.json @@ -3,7 +3,7 @@ "analystId": "froggy", "assetClass": "crypto", "baseAsset": "BTC", - "conviction": 0.5916666666666667, + "conviction": 0.41666666666666663, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -22,10 +22,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.41666666666666663 }, "canonicalUss": { "facts": { @@ -62,7 +62,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "enrichmentHash": { "algorithm": "sha256", @@ -74,7 +74,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.execution-summary", - "value": "9b23d299c792c6f97f50afaef26d931f8feefe783d9bd375200e9d09978630b4" + "value": "1178e36dcbad0110b42c69bfdff3917592c5d82fef49401d815b09e7a20b5013" }, "manifestHash": { "algorithm": "sha256", @@ -88,7 +88,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.plugin-set", - "value": "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "value": "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" }, "schema": "afi.composition-ref.v1", "scorerPluginId": "afi-scorer-froggy-trend-pullback", @@ -112,7 +112,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "5af3fcb6ba64bc5ec380676653de57607607d779d9906125b681f8feb0a2502c" + "value": "7c92f64874a59b4b0d5d6111a6841388f734e0f95e766663590246306665288f" }, "schema": "afi.provenance-record.v1", "schemaVersions": { @@ -427,19 +427,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-record", - "value": "19178400ced77a5fe6457f22fa491ead6b8a78e53742680cdbec80a384078d8f" + "value": "099bf59336c702007135dcd802f7eb16d67b1bd884159d096cc2adad7ec1ad60" }, "recordVersion": 1, "replayHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-replay", - "value": "465fd154fcfbc04f53f6be76c682b1d465bed24cfa5c4ecdfe991f9e8e8b9bab" + "value": "98d1dfaefe53f2964648b1a5dfd41c799c26581d887b2eb93e8baa01fef2fe74" }, "schema": "afi.scored-signal-evidence.v3", "scoredSignal": { "analystId": "froggy", - "conviction": 0.5916666666666667, + "conviction": 0.41666666666666663, "direction": "long", "provenanceRecordRef": "provenance-record:oracle-tv-long-0001", "providerId": "oracle-provider-tv", @@ -451,10 +451,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.41666666666666663 }, "signalId": "oracle-tv-long-0001", "strategyId": "trend_pullback_v1", @@ -511,7 +511,7 @@ "analystId": "froggy", "assetClass": "crypto", "baseAsset": "BTC", - "conviction": 0.5916666666666667, + "conviction": 0.41666666666666663, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -530,10 +530,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.41666666666666663 }, "decayParams": { "barsPerHalfLife": 12, @@ -686,7 +686,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "5af3fcb6ba64bc5ec380676653de57607607d779d9906125b681f8feb0a2502c" + "value": "7c92f64874a59b4b0d5d6111a6841388f734e0f95e766663590246306665288f" }, "scorerInput": { "atrRegime": "normal", @@ -696,7 +696,7 @@ "haFlatBackConfirmed": false, "liquiditySwept": false, "pulledBackIntoSweetSpot": true, - "rrMultiplePlanned": 2, + "rrMultiplePlanned": 1, "triggerPatternQuality": 2, "weeklyBias": "neutral" }, diff --git a/test/oracle/goldens/enriched/tv-long.registry.json b/test/oracle/goldens/enriched/tv-long.registry.json index e3334832..9e7f66e3 100644 --- a/test/oracle/goldens/enriched/tv-long.registry.json +++ b/test/oracle/goldens/enriched/tv-long.registry.json @@ -3,7 +3,7 @@ "analystId": "froggy", "assetClass": "crypto", "baseAsset": "BTC", - "conviction": 0.5916666666666667, + "conviction": 0.41666666666666663, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -22,10 +22,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.41666666666666663 }, "canonicalUss": { "facts": { @@ -62,7 +62,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "enrichmentHash": { "algorithm": "sha256", @@ -74,7 +74,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.execution-summary", - "value": "9b23d299c792c6f97f50afaef26d931f8feefe783d9bd375200e9d09978630b4" + "value": "1178e36dcbad0110b42c69bfdff3917592c5d82fef49401d815b09e7a20b5013" }, "manifestHash": { "algorithm": "sha256", @@ -88,7 +88,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.plugin-set", - "value": "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "value": "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" }, "schema": "afi.composition-ref.v1", "scorerPluginId": "afi-scorer-froggy-trend-pullback", @@ -112,7 +112,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "5af3fcb6ba64bc5ec380676653de57607607d779d9906125b681f8feb0a2502c" + "value": "7c92f64874a59b4b0d5d6111a6841388f734e0f95e766663590246306665288f" }, "schema": "afi.provenance-record.v1", "schemaVersions": { @@ -427,19 +427,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-record", - "value": "09cc8b17bbeadbd6e5051ead3c398c6dafcb2a387c9c728d3e508d80afd47898" + "value": "4515e1afc437909c07930adbb85c25cb4cf2de16054025e5c2b6cb15400dab3c" }, "recordVersion": 1, "replayHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-replay", - "value": "3d544926cf935c9110b09f0ba848e60fd9c102c6aa8c62c6a69dc44ffb02464c" + "value": "1d1619ea81e682bcdecf5c492c2ba1bc296feafd6af8fec279738d50860b678b" }, "schema": "afi.scored-signal-evidence.v3", "scoredSignal": { "analystId": "froggy", - "conviction": 0.5916666666666667, + "conviction": 0.41666666666666663, "direction": "long", "provenanceRecordRef": "provenance-record:oracle-tv-long-0001", "providerId": "oracle-provider-tv", @@ -451,10 +451,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.41666666666666663 }, "signalId": "oracle-tv-long-0001", "strategyId": "trend_pullback_v1", @@ -511,7 +511,7 @@ "analystId": "froggy", "assetClass": "crypto", "baseAsset": "BTC", - "conviction": 0.5916666666666667, + "conviction": 0.41666666666666663, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -530,10 +530,10 @@ "uwrAxes": { "execution": 0.6666666666666666, "insight": 0.4, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.5916666666666667 + "uwrScore": 0.41666666666666663 }, "decayParams": { "barsPerHalfLife": 12, @@ -686,7 +686,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "5af3fcb6ba64bc5ec380676653de57607607d779d9906125b681f8feb0a2502c" + "value": "7c92f64874a59b4b0d5d6111a6841388f734e0f95e766663590246306665288f" }, "scorerInput": { "atrRegime": "normal", @@ -696,7 +696,7 @@ "haFlatBackConfirmed": false, "liquiditySwept": false, "pulledBackIntoSweetSpot": true, - "rrMultiplePlanned": 2, + "rrMultiplePlanned": 1, "triggerPatternQuality": 2, "weeklyBias": "neutral" }, diff --git a/test/oracle/goldens/enriched/tv-neutral.builtin.json b/test/oracle/goldens/enriched/tv-neutral.builtin.json index daa8126f..b10e754c 100644 --- a/test/oracle/goldens/enriched/tv-neutral.builtin.json +++ b/test/oracle/goldens/enriched/tv-neutral.builtin.json @@ -7,7 +7,7 @@ "insight": "Liquidity or volatility context is weak." }, "baseAsset": "BTC", - "conviction": 0.4, + "conviction": 0.22500000000000003, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -27,10 +27,10 @@ "uwrAxes": { "execution": 0, "insight": 0.3, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.4 + "uwrScore": 0.22500000000000003 }, "canonicalUss": { "facts": { @@ -67,7 +67,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "enrichmentHash": { "algorithm": "sha256", @@ -93,7 +93,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.plugin-set", - "value": "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "value": "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" }, "schema": "afi.composition-ref.v1", "scorerPluginId": "afi-scorer-froggy-trend-pullback", @@ -117,7 +117,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "1cd9b6f4c34c4ae5a7ce4053515542d8cd1a8712fa9a179fbe278b9779bb0924" + "value": "d042a5fa7ba5ccd0a58bfc5ac267f8c5e0cc3c33fc4770ce75e979513c18097b" }, "schema": "afi.provenance-record.v1", "schemaVersions": { @@ -432,19 +432,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-record", - "value": "52c64a7cb8071a6495e4fc2d984ec831a84f2212f5a4b85d13e69c2160899b3a" + "value": "62d321e11ad3dd92e2d125976f1c6971207089ba25d49fdb65fd3f4d24763448" }, "recordVersion": 1, "replayHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-replay", - "value": "bacec88b67e3b97de52da812f039bef79ef2f3670013502112f17d7d2424dfdf" + "value": "74884fb9b27731275bd99e8707e57493ab24049e85c48bd81911fc79a8378aa1" }, "schema": "afi.scored-signal-evidence.v3", "scoredSignal": { "analystId": "froggy", - "conviction": 0.4, + "conviction": 0.22500000000000003, "direction": "neutral", "provenanceRecordRef": "provenance-record:oracle-tv-neutral-0001", "providerId": "oracle-provider-tv", @@ -456,10 +456,10 @@ "uwrAxes": { "execution": 0, "insight": 0.3, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.4 + "uwrScore": 0.22500000000000003 }, "signalId": "oracle-tv-neutral-0001", "strategyId": "trend_pullback_v1", @@ -508,7 +508,7 @@ "insight": "Liquidity or volatility context is weak." }, "baseAsset": "BTC", - "conviction": 0.4, + "conviction": 0.22500000000000003, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -528,10 +528,10 @@ "uwrAxes": { "execution": 0, "insight": 0.3, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.4 + "uwrScore": 0.22500000000000003 }, "decayParams": { "barsPerHalfLife": 12, @@ -648,7 +648,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "1cd9b6f4c34c4ae5a7ce4053515542d8cd1a8712fa9a179fbe278b9779bb0924" + "value": "d042a5fa7ba5ccd0a58bfc5ac267f8c5e0cc3c33fc4770ce75e979513c18097b" }, "scorerInput": { "atrRegime": "high", @@ -658,7 +658,7 @@ "haFlatBackConfirmed": false, "liquiditySwept": false, "pulledBackIntoSweetSpot": true, - "rrMultiplePlanned": 2, + "rrMultiplePlanned": 1, "triggerPatternQuality": 0, "weeklyBias": "neutral" }, diff --git a/test/oracle/goldens/enriched/tv-neutral.registry.json b/test/oracle/goldens/enriched/tv-neutral.registry.json index 54c74a91..8882e4f9 100644 --- a/test/oracle/goldens/enriched/tv-neutral.registry.json +++ b/test/oracle/goldens/enriched/tv-neutral.registry.json @@ -7,7 +7,7 @@ "insight": "Liquidity or volatility context is weak." }, "baseAsset": "BTC", - "conviction": 0.4, + "conviction": 0.22500000000000003, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -27,10 +27,10 @@ "uwrAxes": { "execution": 0, "insight": 0.3, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.4 + "uwrScore": 0.22500000000000003 }, "canonicalUss": { "facts": { @@ -67,7 +67,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "enrichmentHash": { "algorithm": "sha256", @@ -93,7 +93,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.plugin-set", - "value": "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "value": "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" }, "schema": "afi.composition-ref.v1", "scorerPluginId": "afi-scorer-froggy-trend-pullback", @@ -117,7 +117,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "1cd9b6f4c34c4ae5a7ce4053515542d8cd1a8712fa9a179fbe278b9779bb0924" + "value": "d042a5fa7ba5ccd0a58bfc5ac267f8c5e0cc3c33fc4770ce75e979513c18097b" }, "schema": "afi.provenance-record.v1", "schemaVersions": { @@ -432,19 +432,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-record", - "value": "82f9c2b5d3eeb89591e70e254fa2da4af2568b64344af0e398f00a94b616d516" + "value": "eda306ba90a6044983f85e587e50483315d9369b1d5510c80a77960f9de00c84" }, "recordVersion": 1, "replayHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-replay", - "value": "27947833f82966bfb1610b8cf5b4cbcf89121a48be638e7be757b5bafe336c41" + "value": "3c41aae7db36f10475805e59fd171cd7ad19769dd60b7fa8170c4b3c4c731c60" }, "schema": "afi.scored-signal-evidence.v3", "scoredSignal": { "analystId": "froggy", - "conviction": 0.4, + "conviction": 0.22500000000000003, "direction": "neutral", "provenanceRecordRef": "provenance-record:oracle-tv-neutral-0001", "providerId": "oracle-provider-tv", @@ -456,10 +456,10 @@ "uwrAxes": { "execution": 0, "insight": 0.3, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.4 + "uwrScore": 0.22500000000000003 }, "signalId": "oracle-tv-neutral-0001", "strategyId": "trend_pullback_v1", @@ -508,7 +508,7 @@ "insight": "Liquidity or volatility context is weak." }, "baseAsset": "BTC", - "conviction": 0.4, + "conviction": 0.22500000000000003, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -528,10 +528,10 @@ "uwrAxes": { "execution": 0, "insight": 0.3, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.4 + "uwrScore": 0.22500000000000003 }, "decayParams": { "barsPerHalfLife": 12, @@ -648,7 +648,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "1cd9b6f4c34c4ae5a7ce4053515542d8cd1a8712fa9a179fbe278b9779bb0924" + "value": "d042a5fa7ba5ccd0a58bfc5ac267f8c5e0cc3c33fc4770ce75e979513c18097b" }, "scorerInput": { "atrRegime": "high", @@ -658,7 +658,7 @@ "haFlatBackConfirmed": false, "liquiditySwept": false, "pulledBackIntoSweetSpot": true, - "rrMultiplePlanned": 2, + "rrMultiplePlanned": 1, "triggerPatternQuality": 0, "weeklyBias": "neutral" }, diff --git a/test/oracle/goldens/enriched/tv-short.builtin.json b/test/oracle/goldens/enriched/tv-short.builtin.json index 185e3afb..c41e08d9 100644 --- a/test/oracle/goldens/enriched/tv-short.builtin.json +++ b/test/oracle/goldens/enriched/tv-short.builtin.json @@ -7,7 +7,7 @@ "insight": "Liquidity or volatility context is weak." }, "baseAsset": "BTC", - "conviction": 0.35000000000000003, + "conviction": 0.17500000000000002, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -27,10 +27,10 @@ "uwrAxes": { "execution": 0, "insight": 0.1, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.35000000000000003 + "uwrScore": 0.17500000000000002 }, "canonicalUss": { "facts": { @@ -67,7 +67,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "enrichmentHash": { "algorithm": "sha256", @@ -93,7 +93,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.plugin-set", - "value": "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "value": "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" }, "schema": "afi.composition-ref.v1", "scorerPluginId": "afi-scorer-froggy-trend-pullback", @@ -117,7 +117,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "81a258ce32bd25660b808d6404254a154d8208726851ed08809464a523282e53" + "value": "53510590e062dcbc12eaefbd5f14bdf2e3aad952ca6eb08a4a335a1e2ac7e7c7" }, "schema": "afi.provenance-record.v1", "schemaVersions": { @@ -432,19 +432,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-record", - "value": "e53092704c001078b95a1459991e3382ee47e5b71bfd1813b42ba751daf59e75" + "value": "9e81a4e83e1d40d065e5ab846530a4a819ce658672ea810e479b9075a576a24c" }, "recordVersion": 1, "replayHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-replay", - "value": "7f6c0c5f7ebc1861d4060a5c913f6c961f6427b70afa541589927e9207e681ad" + "value": "e3e0ce534f54d4332c0abea71beaadc0c2b3c40b185b94a63943f2d50f5110a1" }, "schema": "afi.scored-signal-evidence.v3", "scoredSignal": { "analystId": "froggy", - "conviction": 0.35000000000000003, + "conviction": 0.17500000000000002, "direction": "short", "provenanceRecordRef": "provenance-record:oracle-tv-short-0001", "providerId": "oracle-provider-tv", @@ -456,10 +456,10 @@ "uwrAxes": { "execution": 0, "insight": 0.1, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.35000000000000003 + "uwrScore": 0.17500000000000002 }, "signalId": "oracle-tv-short-0001", "strategyId": "trend_pullback_v1", @@ -508,7 +508,7 @@ "insight": "Liquidity or volatility context is weak." }, "baseAsset": "BTC", - "conviction": 0.35000000000000003, + "conviction": 0.17500000000000002, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -528,10 +528,10 @@ "uwrAxes": { "execution": 0, "insight": 0.1, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.35000000000000003 + "uwrScore": 0.17500000000000002 }, "decayParams": { "barsPerHalfLife": 12, @@ -672,7 +672,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "81a258ce32bd25660b808d6404254a154d8208726851ed08809464a523282e53" + "value": "53510590e062dcbc12eaefbd5f14bdf2e3aad952ca6eb08a4a335a1e2ac7e7c7" }, "scorerInput": { "atrRegime": "low", @@ -682,7 +682,7 @@ "haFlatBackConfirmed": false, "liquiditySwept": false, "pulledBackIntoSweetSpot": true, - "rrMultiplePlanned": 2, + "rrMultiplePlanned": 1, "triggerPatternQuality": 0, "weeklyBias": "neutral" }, diff --git a/test/oracle/goldens/enriched/tv-short.registry.json b/test/oracle/goldens/enriched/tv-short.registry.json index fc13b381..492e8764 100644 --- a/test/oracle/goldens/enriched/tv-short.registry.json +++ b/test/oracle/goldens/enriched/tv-short.registry.json @@ -7,7 +7,7 @@ "insight": "Liquidity or volatility context is weak." }, "baseAsset": "BTC", - "conviction": 0.35000000000000003, + "conviction": 0.17500000000000002, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -27,10 +27,10 @@ "uwrAxes": { "execution": 0, "insight": 0.1, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.35000000000000003 + "uwrScore": 0.17500000000000002 }, "canonicalUss": { "facts": { @@ -67,7 +67,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "enrichmentHash": { "algorithm": "sha256", @@ -93,7 +93,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.plugin-set", - "value": "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "value": "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" }, "schema": "afi.composition-ref.v1", "scorerPluginId": "afi-scorer-froggy-trend-pullback", @@ -117,7 +117,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "81a258ce32bd25660b808d6404254a154d8208726851ed08809464a523282e53" + "value": "53510590e062dcbc12eaefbd5f14bdf2e3aad952ca6eb08a4a335a1e2ac7e7c7" }, "schema": "afi.provenance-record.v1", "schemaVersions": { @@ -432,19 +432,19 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-record", - "value": "137ceca886e422a7accc4109bee6de71766accca2fc93b591ab6ca0268924f74" + "value": "f961f8367bf5298131a3832e862d041ff0b2b027746e152b71f4778d769e9966" }, "recordVersion": 1, "replayHash": { "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.evidence-replay", - "value": "45e924410c95665d7d579b8a9357fe7f3569a6be7bdde6bbfbf22543ef7f5f3f" + "value": "c7a83402f2891c326b5bbfbabadd5acdac02a4f39f61cd5c7b44ad5ae9d7d1e9" }, "schema": "afi.scored-signal-evidence.v3", "scoredSignal": { "analystId": "froggy", - "conviction": 0.35000000000000003, + "conviction": 0.17500000000000002, "direction": "short", "provenanceRecordRef": "provenance-record:oracle-tv-short-0001", "providerId": "oracle-provider-tv", @@ -456,10 +456,10 @@ "uwrAxes": { "execution": 0, "insight": 0.1, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.35000000000000003 + "uwrScore": 0.17500000000000002 }, "signalId": "oracle-tv-short-0001", "strategyId": "trend_pullback_v1", @@ -508,7 +508,7 @@ "insight": "Liquidity or volatility context is weak." }, "baseAsset": "BTC", - "conviction": 0.35000000000000003, + "conviction": 0.17500000000000002, "direction": "neutral", "holdingHorizon": "swing", "instrumentType": "linear-perp", @@ -528,10 +528,10 @@ "uwrAxes": { "execution": 0, "insight": 0.1, - "risk": 0.9, + "risk": 0.2, "structure": 0.4 }, - "uwrScore": 0.35000000000000003 + "uwrScore": 0.17500000000000002 }, "decayParams": { "barsPerHalfLife": 12, @@ -672,7 +672,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.scored-output", - "value": "81a258ce32bd25660b808d6404254a154d8208726851ed08809464a523282e53" + "value": "53510590e062dcbc12eaefbd5f14bdf2e3aad952ca6eb08a4a335a1e2ac7e7c7" }, "scorerInput": { "atrRegime": "low", @@ -682,7 +682,7 @@ "haFlatBackConfirmed": false, "liquiditySwept": false, "pulledBackIntoSweetSpot": true, - "rrMultiplePlanned": 2, + "rrMultiplePlanned": 1, "triggerPatternQuality": 0, "weeklyBias": "neutral" }, diff --git a/test/oracle/oracleErrorTable.test.ts b/test/oracle/oracleErrorTable.test.ts index bd5fbe0b..9cdab767 100644 --- a/test/oracle/oracleErrorTable.test.ts +++ b/test/oracle/oracleErrorTable.test.ts @@ -170,6 +170,35 @@ describe("oracle error table — request validation (400/401/422)", () => { expect(res.body.error).toBe("symbol_normalization_failed"); expect(res.body.symbolRaw).toBe("??!!"); }); + + // DEM-PRODUCER-PLAN (DEM-GOV D-DEM-5(6)): the submitted plan is carried + // (afi.trade-plan.v1) or refused — never dropped — and verified against the + // fetched candles by the technical lane or refused. Both refusals are + // honest 422s with NO persisted record. + it("cpj trade plan not conforming to afi.trade-plan.v1 (stop without an entry) → 422 trade_plan_invalid", async () => { + const payload = loadFixture("cpj/cpj-blofin-perp-long.json"); + delete payload.extracted.entry; + const res = await request(app).post(CPJ).send(payload); + expect(res.status).toBe(422); + expect(res.body.error).toBe("trade_plan_invalid"); + expect(res.body.persisted).toBe(false); + }); + + it("cpj trade plan unverifiable against the fetched candles (off-market levels) → 422 trade_plan_unverifiable, no record", async () => { + const store = new OracleEvidenceStore(); + setEvidenceStore(store); + const payload = loadFixture("cpj/cpj-blofin-perp-long.json"); + // Thousands of dollars outside the demo window's observed band. + payload.extracted.entry = 42500; + payload.extracted.stopLoss = 41800; + payload.extracted.takeProfits = [{ price: 43500 }]; + const res = await request(app).post(CPJ).send(payload); + expect(res.status).toBe(422); + expect(res.body.error).toBe("trade_plan_unverifiable"); + expect(res.body.persisted).toBe(false); + expect(res.body.reason).toMatch(/entry 42500 lies outside/); + expect(store.submissions).toHaveLength(0); + }); }); describe("oracle error table — strategy resolution rejections (403; W3 spec section 4)", () => { diff --git a/test/oracle/oracleGoldensEnriched.test.ts b/test/oracle/oracleGoldensEnriched.test.ts index 095e3ae1..9bc59290 100644 --- a/test/oracle/oracleGoldensEnriched.test.ts +++ b/test/oracle/oracleGoldensEnriched.test.ts @@ -74,8 +74,11 @@ import { UWR_PROFILE_SOURCE_ENV, } from "../../src/config/uwrRuntimeProfile.js"; import { scorerFroggyTrendPullbackNode } from "../../src/pipeline/nodes/scorerFroggyTrendPullback.js"; +// DEM-PRODUCER-PLAN: the golden's scorerInput is the input the LIVE scorer +// node actually handed the rubric (captured at the rubric call), not an +// adapter-side recomputation — the retired adapter no longer yields one. // @ts-ignore — afi-core subpath types resolve via package exports; jest maps to source -import { buildFroggyTrendPullbackInputFromEnriched } from "afi-core/analysts/froggy.enrichment_adapter.js"; +import * as froggyRubric from "afi-core/analysts/froggy.trend_pullback_v1.js"; import { OracleEvidenceStore, expectGolden, @@ -92,6 +95,7 @@ let restoreNet: () => void; // path now scores through the registered scorer category node. The node's // input IS the (aiMl-augmented) enriched view — same capture semantics. const analystSpy = jest.spyOn(scorerFroggyTrendPullbackNode, "run"); +const rubricSpy = jest.spyOn(froggyRubric, "scoreFroggyTrendPullback"); beforeAll(() => { restoreEnv = installOracleEnv(); @@ -101,6 +105,7 @@ beforeAll(() => { afterAll(() => { analystSpy.mockRestore(); + rubricSpy.mockRestore(); resetEvidenceStore(); shutdownDedupeCache(); restoreNet(); @@ -139,6 +144,7 @@ describe.each(UWR_MODES)( const store = new OracleEvidenceStore(); setEvidenceStore(store); analystSpy.mockClear(); + rubricSpy.mockClear(); const res = await request(app).post(endpoint).send(loadFixture(file)); expect(res.status).toBe(200); @@ -161,7 +167,8 @@ describe.each(UWR_MODES)( expect(enrichedView.news).toBeDefined(); expect(enrichedView.aiMl).toBeDefined(); - const scorerInput = buildFroggyTrendPullbackInputFromEnriched(enrichedView as never); + expect(rubricSpy).toHaveBeenCalledTimes(1); + const scorerInput = rubricSpy.mock.calls[0][0]; expect(store.submissions).toHaveLength(1); const record = store.submissions[0]; expect(record.uwrProfile.source).toBe(stampSource); diff --git a/test/oracle/oracleInvariance.test.ts b/test/oracle/oracleInvariance.test.ts index 0aa6b3ba..5e5463ad 100644 --- a/test/oracle/oracleInvariance.test.ts +++ b/test/oracle/oracleInvariance.test.ts @@ -60,7 +60,9 @@ import { setEvidenceStore, resetEvidenceStore } from "../../src/evidence/index.j import { shutdownDedupeCache } from "../../src/services/ingestDedupeService.js"; import { scorerFroggyTrendPullbackNode } from "../../src/pipeline/nodes/scorerFroggyTrendPullback.js"; // @ts-ignore — afi-core subpath types resolve via package exports; jest maps to source -import { buildFroggyTrendPullbackInputFromEnriched } from "afi-core/analysts/froggy.enrichment_adapter.js"; +// DEM-PRODUCER-PLAN: the scorer input is captured at the rubric call (what +// the live node actually scored), not recomputed adapter-side. +import * as froggyRubric from "afi-core/analysts/froggy.trend_pullback_v1.js"; import { OracleEvidenceStore, installOracleEnv, @@ -76,6 +78,7 @@ let restoreNet: () => void; // path now scores through the registered scorer category node. The node's // input IS the (aiMl-augmented) enriched view — same capture semantics. const analystSpy = jest.spyOn(scorerFroggyTrendPullbackNode, "run"); +const rubricSpy = jest.spyOn(froggyRubric, "scoreFroggyTrendPullback"); beforeAll(() => { restoreEnv = installOracleEnv(); @@ -85,6 +88,7 @@ beforeAll(() => { afterAll(() => { analystSpy.mockRestore(); + rubricSpy.mockRestore(); resetEvidenceStore(); shutdownDedupeCache(); restoreNet(); @@ -113,19 +117,19 @@ describe.each([ const store = new OracleEvidenceStore(); setEvidenceStore(store); // fresh store: every run is a first write analystSpy.mockClear(); + rubricSpy.mockClear(); const res = await request(app).post(endpoint).send(payload); expect(res.status).toBe(200); expect(store.submissions).toHaveLength(1); const record = store.submissions[0]; - const enrichedView = analystSpy.mock.calls[0][0]; + expect(analystSpy).toHaveBeenCalledTimes(1); + expect(rubricSpy).toHaveBeenCalledTimes(1); captures.push({ inputHash: stableStringify(record.provenanceRecord.inputHash), outputHash: stableStringify(record.provenanceRecord.outputHash), - scorerInput: stableStringify( - buildFroggyTrendPullbackInputFromEnriched(enrichedView as never) - ), + scorerInput: stableStringify(rubricSpy.mock.calls[0][0]), record: stableStringify(normalizeVolatile(record)), // EV3-GOV D-EV3-4(7): identical canonical inputs → identical // record-level commitments run-over-run (the replay separation). diff --git a/test/oracle/support/goldenDiff.mjs b/test/oracle/support/goldenDiff.mjs new file mode 100644 index 00000000..72c2a048 --- /dev/null +++ b/test/oracle/support/goldenDiff.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +/** + * Golden differ — the recursive JSON-path audit every regeneration wave has + * described in test/oracle/INTENTIONAL_DIFFS.md, committed once so the + * per-golden itemization D-DEM-7(3) requires is reproducible instead of + * ad hoc. + * + * Usage: + * node test/oracle/support/goldenDiff.mjs [--ref ] [--dir test/oracle/goldens/enriched] [--json] + * + * Compares every golden file under --dir against the same path at --ref + * (default HEAD, i.e. the committed pre-regeneration bytes). Emits, per + * JSON path, the set of goldens where it changed and the old → new values + * (abbreviated), grouped into the itemization classes used by + * INTENTIONAL_DIFFS.md, plus the list of goldens that are byte-EQUAL. + * + * Pure read-only; never rewrites a golden. Test support only (never shipped). + */ +import { execFileSync } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const args = process.argv.slice(2); +function opt(name, fallback) { + const i = args.indexOf(name); + return i >= 0 && i + 1 < args.length ? args[i + 1] : fallback; +} +const REF = opt("--ref", "HEAD"); +const DIR = opt("--dir", "test/oracle/goldens/enriched"); +const JSON_OUT = args.includes("--json"); + +function gitShow(ref, path) { + try { + return execFileSync("git", ["show", `${ref}:${path}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + } catch { + return null; + } +} + +function walk(a, b, path, out) { + if (a === b) return; + const ta = a === null ? "null" : Array.isArray(a) ? "array" : typeof a; + const tb = b === null ? "null" : Array.isArray(b) ? "array" : typeof b; + if (ta === "object" && tb === "object") { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const k of [...keys].sort()) walk(a[k], b[k], `${path}/${k}`, out); + return; + } + if (ta === "array" && tb === "array" && a.length === b.length) { + for (let i = 0; i < a.length; i++) walk(a[i], b[i], `${path}/${i}`, out); + return; + } + if (JSON.stringify(a) === JSON.stringify(b)) return; + out.push({ path, before: a, after: b }); +} + +function abbreviate(v) { + const s = JSON.stringify(v); + if (s === undefined) return ""; + return s.length > 48 ? `${s.slice(0, 20)}…${s.slice(-8)}` : s; +} + +/** Itemization classes (INTENTIONAL_DIFFS.md vocabulary). Order matters: first match wins. */ +const CLASSES = [ + ["scored-value: axes/uwrScore/conviction/riskBucket", /\/(uwrAxes\/|uwrScore$|conviction$|riskBucket$)/], + ["scored-value: scorerInput", /^\/scorerInput\//], + ["scored-value: notes/rationale/axisNotes/direction", /\/(rationale|axisNotes|notes|direction)(\/|$)/], + ["lens payload (technical/other lane bytes)", /\/(lenses\/\d+\/payload|_priceFeedMetadata)\//], + ["canonical USS (input surface)", /^\/canonicalUss\/|\/rawUss\/|\/httpResponse\/uss\//], + ["inputHash", /inputHash\/value$/], + ["outputHash", /outputHash\/value$/], + ["composition: enrichmentHash", /enrichmentHash\/value$/], + ["composition: executionSummaryHash", /executionSummaryHash\/value$/], + ["composition: analystConfigHash", /analystConfigHash\/value$/], + ["composition: pluginSetHash", /pluginSetHash\/value$/], + ["composition: manifestHash", /manifestHash\/value$/], + ["providerInvocations: invocationInputHash", /invocationInputHash\/value$/], + ["providerInvocations: providerResultHash/categoryResultHash", /(providerResultHash|categoryResultHash)\/value$/], + ["recordHash", /\/recordHash\/value$/], + ["replayHash", /\/replayHash\/value$/], + ["decayParams", /decayParams\//], + ["other", /./], +]; + +function classify(path) { + for (const [name, re] of CLASSES) if (re.test(path)) return name; + return "other"; +} + +const files = readdirSync(DIR).filter((f) => f.endsWith(".json")).sort(); +const perFile = {}; +const equal = []; +const byPath = new Map(); // path -> { files: Set, samples: [] } +for (const f of files) { + const abs = join(DIR, f); + const rel = relative(process.cwd(), abs); + const now = readFileSync(abs, "utf8"); + const before = gitShow(REF, rel); + if (before === null) { + perFile[f] = { status: "new-file" }; + continue; + } + if (before === now) { + equal.push(f); + perFile[f] = { status: "byte-equal" }; + continue; + } + const diffs = []; + walk(JSON.parse(before), JSON.parse(now), "", diffs); + perFile[f] = { status: "changed", count: diffs.length, diffs }; + for (const d of diffs) { + if (!byPath.has(d.path)) byPath.set(d.path, { files: new Set(), samples: [] }); + const e = byPath.get(d.path); + e.files.add(f); + if (e.samples.length < 2) e.samples.push(`${f}: ${abbreviate(d.before)} → ${abbreviate(d.after)}`); + } +} + +const byClass = new Map(); +for (const [path, e] of byPath) { + const c = classify(path); + if (!byClass.has(c)) byClass.set(c, []); + byClass.get(c).push({ path, files: [...e.files].sort(), samples: e.samples }); +} + +if (JSON_OUT) { + const json = { ref: REF, dir: DIR, files: files.length, byteEqual: equal, perFile: Object.fromEntries(Object.entries(perFile).map(([k, v]) => [k, v.status === "changed" ? { status: v.status, count: v.count } : v])), classes: Object.fromEntries([...byClass].map(([c, rows]) => [c, rows])) }; + process.stdout.write(JSON.stringify(json, null, 2) + "\n"); +} else { + console.log(`# goldenDiff — ${DIR} vs ${REF} (${files.length} files)`); + console.log(`byte-EQUAL (${equal.length}): ${equal.join(", ") || "none"}`); + for (const f of files) { + const s = perFile[f]; + if (s.status === "changed") console.log(`changed: ${f} (${s.count} paths)`); + else if (s.status === "new-file") console.log(`new: ${f}`); + } + console.log(""); + for (const [c, rows] of [...byClass].sort((a, b) => a[0].localeCompare(b[0]))) { + console.log(`## ${c} — ${rows.length} path(s)`); + for (const r of rows.sort((a, b) => a.path.localeCompare(b.path))) { + console.log(` ${r.path} [${r.files.length}/${files.length}: ${r.files.map((x) => x.replace(/\.json$/, "")).join(", ")}]`); + for (const s of r.samples) console.log(` ${s}`); + } + } +} diff --git a/test/pipeline/fixtures/afi-config-atlas/registries/analysis-plugins/afi-analysis-technical--2.0.0.json b/test/pipeline/fixtures/afi-config-atlas/registries/analysis-plugins/afi-analysis-technical--2.0.0.json index 625bbcb1..dfdb6dda 100644 --- a/test/pipeline/fixtures/afi-config-atlas/registries/analysis-plugins/afi-analysis-technical--2.0.0.json +++ b/test/pipeline/fixtures/afi-config-atlas/registries/analysis-plugins/afi-analysis-technical--2.0.0.json @@ -2,20 +2,25 @@ "schema": "afi.analysis-plugin.v1", "pluginId": "afi-analysis-technical", "pluginVersion": "2.0.0", - "implementationVersion": "2.0.0", + "implementationVersion": "2.1.0", "category": "technical", - "description": "Official technical lane plugin manifest (vendor-neutral, provider-instance-backed): the node reads its explicit providerInstanceRef, and the runtime provider layer resolves the instance and invokes the trusted registered technical adapter, which computes indicator features (EMA/RSI/ATR-style) over the market candles for the validated signal. The node contains no vendor logic; deterministic given the selected instance's inputs.", + "description": "Official technical lane plugin manifest (vendor-neutral, provider-instance-backed): the node reads its explicit providerInstanceRef, and the runtime provider layer resolves the instance and invokes the trusted registered technical adapter, which computes indicator features (EMA/RSI/ATR-style) over the market candles for the validated signal. The node contains no vendor logic; deterministic given the selected instance's inputs. DEM-PRODUCER-PLAN producer contract: when the canonical signal carries an afi.trade-plan.v1 plan, the lane verifies every submitted level against the fetched candle window (all levels within one observed-range width of the window; stop and targets on opposite sides of entry) and emits technical.plan; an unverifiable plan REFUSES the determination (D-DEM-5(6)). Declared absences (D-DEM-5(4)(b)): technical.plan is absent when the signal carries no plan; technical.plan.rrToFirstTarget is absent when the plan carries no stop or no target.", "inputSchemaRef": "afi.usignal.v1.1", "outputSchemaRef": "afi.enrichment.technical.v1", "deterministic": true, - "capabilities": ["provider:instance-backed"], + "capabilities": [ + "provider:instance-backed" + ], "defaultTimeoutMs": 10000, "defaultRetryPolicy": { "maxRetries": 1, "retryDelayMs": 500, "backoff": "fixed" }, - "permittedFailurePolicies": ["abort", "degrade"], + "permittedFailurePolicies": [ + "abort", + "degrade" + ], "paramsSchema": { "type": "object", "additionalProperties": false, diff --git a/test/pipeline/fixtures/afi-config-atlas/registries/analysis-plugins/afi-merge-enriched-view--1.1.0.json b/test/pipeline/fixtures/afi-config-atlas/registries/analysis-plugins/afi-merge-enriched-view--1.1.0.json index a33a534b..529dd8dd 100644 --- a/test/pipeline/fixtures/afi-config-atlas/registries/analysis-plugins/afi-merge-enriched-view--1.1.0.json +++ b/test/pipeline/fixtures/afi-config-atlas/registries/analysis-plugins/afi-merge-enriched-view--1.1.0.json @@ -2,14 +2,16 @@ "schema": "afi.analysis-plugin.v1", "pluginId": "afi-merge-enriched-view", "pluginVersion": "1.1.0", - "implementationVersion": "1.1.0", + "implementationVersion": "1.2.0", "category": "merge", "description": "Official deterministic merge plugin manifest: joins the five analysis category results (technical, pattern, sentiment, news, aiMl) into one enriched view under the namespace-by-node strategy — exactly one result per category, duplicate categories rejected, degraded/skipped optional parents contribute empty namespaces. Pure structural merge — deterministic, no external I/O, no provider invocation.", "inputSchemaRef": "afi.enrichment.bundle.v1", "outputSchemaRef": "afi.enrichment.enriched-view.v1", "deterministic": true, "defaultTimeoutMs": 2000, - "permittedFailurePolicies": ["abort"], + "permittedFailurePolicies": [ + "abort" + ], "paramsSchema": { "type": "object", "additionalProperties": false, diff --git a/test/pipeline/fixtures/afi-config-atlas/registries/analysis-plugins/afi-scorer-froggy-trend-pullback--1.0.0.json b/test/pipeline/fixtures/afi-config-atlas/registries/analysis-plugins/afi-scorer-froggy-trend-pullback--1.0.0.json index 3d324a1c..45cf960d 100644 --- a/test/pipeline/fixtures/afi-config-atlas/registries/analysis-plugins/afi-scorer-froggy-trend-pullback--1.0.0.json +++ b/test/pipeline/fixtures/afi-config-atlas/registries/analysis-plugins/afi-scorer-froggy-trend-pullback--1.0.0.json @@ -2,7 +2,7 @@ "schema": "afi.analysis-plugin.v1", "pluginId": "afi-scorer-froggy-trend-pullback", "pluginVersion": "1.0.0", - "implementationVersion": "1.3.0", + "implementationVersion": "1.4.0", "category": "scorer", "description": "Official froggy trend-pullback scorer plugin manifest: the single scoring seam of the froggy-trend-pullback pipeline. Consumes the AI/ML-augmented enriched view and produces the scored output on deterministic kernels; performs the sole VALIDATED->SCORED transition in the consuming runtime (LIFE-GOV transition monopoly).", "inputSchemaRef": "afi.enrichment.aiml.v1", diff --git a/test/pipeline/fixtures/afi-config-atlas/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json b/test/pipeline/fixtures/afi-config-atlas/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json index 06b5733d..da1a4ddc 100644 --- a/test/pipeline/fixtures/afi-config-atlas/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json +++ b/test/pipeline/fixtures/afi-config-atlas/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json @@ -22,12 +22,21 @@ }, "mappingRef": { "mappingId": "froggy-trend-pullback", - "version": "1.0.0" + "version": "1.1.0" }, "decayConfig": { "ratio": { "barsPerHalfLife": 12, "unknownTimeframeMinutes": 5 } + }, + "metadata": { + "note": "Non-authoritative, hash-excluded lineage of this configuration version (D-CFG-5(4): the version identity IS analystConfigHash; a superseded version survives on every record sealed under it and in git history).", + "supersedes": [ + { + "analystConfigHash": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479", + "act": "DEM-GOV DEM-BIND step (d) — mappingRef froggy-trend-pullback 1.0.0 (2026-08-23)" + } + ] } } diff --git a/test/pipeline/fixtures/afi-config-atlas/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json b/test/pipeline/fixtures/afi-config-atlas/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json index 8501afd4..d1b1351d 100644 --- a/test/pipeline/fixtures/afi-config-atlas/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json +++ b/test/pipeline/fixtures/afi-config-atlas/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json @@ -7,7 +7,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "configRef": "afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json", "providerBindingPolicy": { diff --git a/test/pipeline/fixtures/afi-config-atlas/registries/enrichment-mappings/froggy-trend-pullback--1.1.0.json b/test/pipeline/fixtures/afi-config-atlas/registries/enrichment-mappings/froggy-trend-pullback--1.1.0.json new file mode 100644 index 00000000..f1e9bca6 --- /dev/null +++ b/test/pipeline/fixtures/afi-config-atlas/registries/enrichment-mappings/froggy-trend-pullback--1.1.0.json @@ -0,0 +1,99 @@ +{ + "schema": "afi.enrichment-mapping.v1", + "mappingId": "froggy-trend-pullback", + "version": "1.1.0", + "description": "DEM-PRODUCER-PLAN: the 1.0.0 bindings plus rrMultiplePlanned bound to technical.plan.rrToFirstTarget, the technical lane's VERIFIED trade-plan fact (the submitted afi.trade-plan.v1 levels checked against the fetched candles, D-DEM-5(6)). Optional under D-DEM-5(4)(b): the producer emits no R:R for a submission carrying no complete plan; the declared default is the rubric floor (1), hash-committed when fired (DEM-GOV §9 D-5).", + "namespaceDefaults": [ + "technical", + "pattern" + ], + "bindings": { + "distanceFromDailyEmaPct": { + "operator": "bind", + "source": { + "lane": "technical", + "path": "emaDistancePct" + }, + "type": "number", + "optionality": { + "ground": "grandfather", + "default": 0 + } + }, + "pulledBackIntoSweetSpot": { + "operator": "bind", + "source": { + "lane": "technical", + "path": "isInValueSweetSpot" + }, + "type": "boolean", + "optionality": { + "ground": "grandfather", + "default": false + } + }, + "triggerPatternQuality": { + "operator": "band", + "source": { + "lane": "pattern", + "path": "patternConfidence" + }, + "rows": [ + { + "when": { + "gte": 75 + }, + "value": 3 + }, + { + "when": { + "gte": 65 + }, + "value": 2 + }, + { + "when": { + "gt": 0 + }, + "value": 1 + } + ], + "otherwise": 0, + "absent": 0 + }, + "atrRegime": { + "operator": "recode", + "source": { + "lane": "technical", + "path": "atrRegime" + }, + "table": { + "low": "low", + "high": "high", + "extreme": "extreme" + }, + "fallback": "normal", + "absent": "normal" + }, + "rrMultiplePlanned": { + "operator": "bind", + "source": { + "lane": "technical", + "path": "plan.rrToFirstTarget", + "producedBy": { + "pluginId": "afi-analysis-technical", + "pluginVersion": "2.0.0" + } + }, + "type": "number", + "optionality": { + "ground": "producer-declared", + "default": 1, + "producerRef": { + "pluginId": "afi-analysis-technical", + "pluginVersion": "2.0.0" + } + } + } + } +} diff --git a/test/pipeline/fixtures/afi-config/registries/analysis-plugins/afi-analysis-technical--2.0.0.json b/test/pipeline/fixtures/afi-config/registries/analysis-plugins/afi-analysis-technical--2.0.0.json index 625bbcb1..dfdb6dda 100644 --- a/test/pipeline/fixtures/afi-config/registries/analysis-plugins/afi-analysis-technical--2.0.0.json +++ b/test/pipeline/fixtures/afi-config/registries/analysis-plugins/afi-analysis-technical--2.0.0.json @@ -2,20 +2,25 @@ "schema": "afi.analysis-plugin.v1", "pluginId": "afi-analysis-technical", "pluginVersion": "2.0.0", - "implementationVersion": "2.0.0", + "implementationVersion": "2.1.0", "category": "technical", - "description": "Official technical lane plugin manifest (vendor-neutral, provider-instance-backed): the node reads its explicit providerInstanceRef, and the runtime provider layer resolves the instance and invokes the trusted registered technical adapter, which computes indicator features (EMA/RSI/ATR-style) over the market candles for the validated signal. The node contains no vendor logic; deterministic given the selected instance's inputs.", + "description": "Official technical lane plugin manifest (vendor-neutral, provider-instance-backed): the node reads its explicit providerInstanceRef, and the runtime provider layer resolves the instance and invokes the trusted registered technical adapter, which computes indicator features (EMA/RSI/ATR-style) over the market candles for the validated signal. The node contains no vendor logic; deterministic given the selected instance's inputs. DEM-PRODUCER-PLAN producer contract: when the canonical signal carries an afi.trade-plan.v1 plan, the lane verifies every submitted level against the fetched candle window (all levels within one observed-range width of the window; stop and targets on opposite sides of entry) and emits technical.plan; an unverifiable plan REFUSES the determination (D-DEM-5(6)). Declared absences (D-DEM-5(4)(b)): technical.plan is absent when the signal carries no plan; technical.plan.rrToFirstTarget is absent when the plan carries no stop or no target.", "inputSchemaRef": "afi.usignal.v1.1", "outputSchemaRef": "afi.enrichment.technical.v1", "deterministic": true, - "capabilities": ["provider:instance-backed"], + "capabilities": [ + "provider:instance-backed" + ], "defaultTimeoutMs": 10000, "defaultRetryPolicy": { "maxRetries": 1, "retryDelayMs": 500, "backoff": "fixed" }, - "permittedFailurePolicies": ["abort", "degrade"], + "permittedFailurePolicies": [ + "abort", + "degrade" + ], "paramsSchema": { "type": "object", "additionalProperties": false, diff --git a/test/pipeline/fixtures/afi-config/registries/analysis-plugins/afi-merge-enriched-view--1.1.0.json b/test/pipeline/fixtures/afi-config/registries/analysis-plugins/afi-merge-enriched-view--1.1.0.json index a33a534b..529dd8dd 100644 --- a/test/pipeline/fixtures/afi-config/registries/analysis-plugins/afi-merge-enriched-view--1.1.0.json +++ b/test/pipeline/fixtures/afi-config/registries/analysis-plugins/afi-merge-enriched-view--1.1.0.json @@ -2,14 +2,16 @@ "schema": "afi.analysis-plugin.v1", "pluginId": "afi-merge-enriched-view", "pluginVersion": "1.1.0", - "implementationVersion": "1.1.0", + "implementationVersion": "1.2.0", "category": "merge", "description": "Official deterministic merge plugin manifest: joins the five analysis category results (technical, pattern, sentiment, news, aiMl) into one enriched view under the namespace-by-node strategy — exactly one result per category, duplicate categories rejected, degraded/skipped optional parents contribute empty namespaces. Pure structural merge — deterministic, no external I/O, no provider invocation.", "inputSchemaRef": "afi.enrichment.bundle.v1", "outputSchemaRef": "afi.enrichment.enriched-view.v1", "deterministic": true, "defaultTimeoutMs": 2000, - "permittedFailurePolicies": ["abort"], + "permittedFailurePolicies": [ + "abort" + ], "paramsSchema": { "type": "object", "additionalProperties": false, diff --git a/test/pipeline/fixtures/afi-config/registries/analysis-plugins/afi-scorer-froggy-trend-pullback--1.0.0.json b/test/pipeline/fixtures/afi-config/registries/analysis-plugins/afi-scorer-froggy-trend-pullback--1.0.0.json index 3d324a1c..45cf960d 100644 --- a/test/pipeline/fixtures/afi-config/registries/analysis-plugins/afi-scorer-froggy-trend-pullback--1.0.0.json +++ b/test/pipeline/fixtures/afi-config/registries/analysis-plugins/afi-scorer-froggy-trend-pullback--1.0.0.json @@ -2,7 +2,7 @@ "schema": "afi.analysis-plugin.v1", "pluginId": "afi-scorer-froggy-trend-pullback", "pluginVersion": "1.0.0", - "implementationVersion": "1.3.0", + "implementationVersion": "1.4.0", "category": "scorer", "description": "Official froggy trend-pullback scorer plugin manifest: the single scoring seam of the froggy-trend-pullback pipeline. Consumes the AI/ML-augmented enriched view and produces the scored output on deterministic kernels; performs the sole VALIDATED->SCORED transition in the consuming runtime (LIFE-GOV transition monopoly).", "inputSchemaRef": "afi.enrichment.aiml.v1", diff --git a/test/pipeline/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json b/test/pipeline/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json index 06b5733d..da1a4ddc 100644 --- a/test/pipeline/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json +++ b/test/pipeline/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json @@ -22,12 +22,21 @@ }, "mappingRef": { "mappingId": "froggy-trend-pullback", - "version": "1.0.0" + "version": "1.1.0" }, "decayConfig": { "ratio": { "barsPerHalfLife": 12, "unknownTimeframeMinutes": 5 } + }, + "metadata": { + "note": "Non-authoritative, hash-excluded lineage of this configuration version (D-CFG-5(4): the version identity IS analystConfigHash; a superseded version survives on every record sealed under it and in git history).", + "supersedes": [ + { + "analystConfigHash": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479", + "act": "DEM-GOV DEM-BIND step (d) — mappingRef froggy-trend-pullback 1.0.0 (2026-08-23)" + } + ] } } diff --git a/test/pipeline/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json b/test/pipeline/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json index 8501afd4..d1b1351d 100644 --- a/test/pipeline/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json +++ b/test/pipeline/fixtures/afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.json @@ -7,7 +7,7 @@ "algorithm": "sha256", "canonicalizationVersion": "afi.hash.v1", "domainTag": "afi.d2.analyst-config", - "value": "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "value": "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" }, "configRef": "afi-config/registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json", "providerBindingPolicy": { diff --git a/test/pipeline/fixtures/afi-config/registries/enrichment-mappings/froggy-trend-pullback--1.1.0.json b/test/pipeline/fixtures/afi-config/registries/enrichment-mappings/froggy-trend-pullback--1.1.0.json new file mode 100644 index 00000000..f1e9bca6 --- /dev/null +++ b/test/pipeline/fixtures/afi-config/registries/enrichment-mappings/froggy-trend-pullback--1.1.0.json @@ -0,0 +1,99 @@ +{ + "schema": "afi.enrichment-mapping.v1", + "mappingId": "froggy-trend-pullback", + "version": "1.1.0", + "description": "DEM-PRODUCER-PLAN: the 1.0.0 bindings plus rrMultiplePlanned bound to technical.plan.rrToFirstTarget, the technical lane's VERIFIED trade-plan fact (the submitted afi.trade-plan.v1 levels checked against the fetched candles, D-DEM-5(6)). Optional under D-DEM-5(4)(b): the producer emits no R:R for a submission carrying no complete plan; the declared default is the rubric floor (1), hash-committed when fired (DEM-GOV §9 D-5).", + "namespaceDefaults": [ + "technical", + "pattern" + ], + "bindings": { + "distanceFromDailyEmaPct": { + "operator": "bind", + "source": { + "lane": "technical", + "path": "emaDistancePct" + }, + "type": "number", + "optionality": { + "ground": "grandfather", + "default": 0 + } + }, + "pulledBackIntoSweetSpot": { + "operator": "bind", + "source": { + "lane": "technical", + "path": "isInValueSweetSpot" + }, + "type": "boolean", + "optionality": { + "ground": "grandfather", + "default": false + } + }, + "triggerPatternQuality": { + "operator": "band", + "source": { + "lane": "pattern", + "path": "patternConfidence" + }, + "rows": [ + { + "when": { + "gte": 75 + }, + "value": 3 + }, + { + "when": { + "gte": 65 + }, + "value": 2 + }, + { + "when": { + "gt": 0 + }, + "value": 1 + } + ], + "otherwise": 0, + "absent": 0 + }, + "atrRegime": { + "operator": "recode", + "source": { + "lane": "technical", + "path": "atrRegime" + }, + "table": { + "low": "low", + "high": "high", + "extreme": "extreme" + }, + "fallback": "normal", + "absent": "normal" + }, + "rrMultiplePlanned": { + "operator": "bind", + "source": { + "lane": "technical", + "path": "plan.rrToFirstTarget", + "producedBy": { + "pluginId": "afi-analysis-technical", + "pluginVersion": "2.0.0" + } + }, + "type": "number", + "optionality": { + "ground": "producer-declared", + "default": 1, + "producerRef": { + "pluginId": "afi-analysis-technical", + "pluginVersion": "2.0.0" + } + } + } + } +} diff --git a/test/pipeline/officialArtifactPins.test.ts b/test/pipeline/officialArtifactPins.test.ts index 9fa35606..d09dd0de 100644 --- a/test/pipeline/officialArtifactPins.test.ts +++ b/test/pipeline/officialArtifactPins.test.ts @@ -31,8 +31,8 @@ import { FIXTURE_CONFIG_ROOT } from "./support/testHarness.js"; const PINS = { manifestHash: "df3372dadaca1595d0e6d2f6bad9464ccc9abb7106e9f5b7111df148a145bc4f", - analystConfigHash: "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479", - pluginSetHash: "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6", + analystConfigHash: "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22", + pluginSetHash: "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93", }; function readJson(path: string): unknown { diff --git a/test/pipeline/registryLoader.test.ts b/test/pipeline/registryLoader.test.ts index 4e26f79f..9d848434 100644 --- a/test/pipeline/registryLoader.test.ts +++ b/test/pipeline/registryLoader.test.ts @@ -90,10 +90,10 @@ describe("validateRuntimeConfig — positive resolution over the fixture registr "df3372dadaca1595d0e6d2f6bad9464ccc9abb7106e9f5b7111df148a145bc4f" ); expect(froggy.analystConfigHash.value).toBe( - "aa8cf5cfd918ae9a80532b735db3236b708ce0cb82778d845b35220627dab479" + "5cb9b7a4652f3cfaa31d8a069c14c3282bbdf9354443badb033b05b01fd01e22" ); expect(froggy.pluginSetHash.value).toBe( - "220c004ed615c58bd3f4187256568bfcc6ea375e49ce467ff06a2467b405c0b6" + "36f911f4fd37eb0d161ab4df7a9650fd1c477bf6dbd71de4a82e0f73c71c4c93" ); expect(froggy.decay).toEqual({ kind: "ratio", diff --git a/test/pipeline/support/froggyMapping.ts b/test/pipeline/support/froggyMapping.ts new file mode 100644 index 00000000..7e9f00c0 --- /dev/null +++ b/test/pipeline/support/froggyMapping.ts @@ -0,0 +1,54 @@ +/** + * Test support (DEM-PRODUCER-PLAN): the registered froggy mapping, resolved + * the way production resolves it — by the fixture analyst config's + * `mappingRef` from the fixture registry tree — instead of the afi-config + * canonical EXAMPLE (which stays the immutable 1.0.0 vector). One truth with + * the pinned analystConfigHash / registry drift guards. + * + * Also the COMPOSITION REFERENCE the seam tests compare the scorer node + * against: registered mapping fragment + residual → composer → rubric — the + * exact production assembly, run out-of-band. (The retired adapter no longer + * yields a full scorer input, so it cannot serve as the reference; the + * reference's independence now rests on the interpreter/composer KATs in + * afi-core.) + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import type { ResolvedMappingCarrier } from "../../../src/pipeline/nodeSdk.js"; +import type { UniversalWeightingRuleConfig } from "../../../node_modules/afi-core/validators/UniversalWeightingRule.js"; +import type { FroggyEnrichedView } from "../../../node_modules/afi-core/analysts/froggy.enrichment_adapter.js"; +import { scoreFroggyTrendPullback } from "../../../node_modules/afi-core/analysts/froggy.trend_pullback_v1.js"; +import { + buildFroggyResidualInput, + composeFroggyTrendPullbackInput, +} from "../../../node_modules/afi-core/analysts/froggy.residual_builder.js"; +import { interpretEnrichmentMapping } from "../../../node_modules/afi-core/validators/EnrichmentMappingInterpreter.js"; + +export const FIXTURE_CONFIG_ROOT = path.resolve(process.cwd(), "test/pipeline/fixtures/afi-config"); +const FROGGY_CONFIG_REL = "registries/analyst-strategies/froggy--trend_pullback_v1--1.0.0.config.json"; + +/** The mapping the fixture froggy registration names (mappingRef), from the fixture registry. */ +export function froggyMappingCarrier(configRoot: string = FIXTURE_CONFIG_ROOT): ResolvedMappingCarrier { + const config = JSON.parse(readFileSync(path.join(configRoot, FROGGY_CONFIG_REL), "utf-8")) as { + mappingRef: { mappingId: string; version: string }; + }; + const { mappingId, version } = config.mappingRef; + const doc = JSON.parse( + readFileSync( + path.join(configRoot, "registries/enrichment-mappings", `${mappingId}--${version}.json`), + "utf-8" + ) + ) as Record; + return { mappingId, version, doc }; +} + +/** The production assembly run out-of-band (mapping fragment + residual → rubric). */ +export function compositionReference( + enriched: FroggyEnrichedView, + uwrConfig: UniversalWeightingRuleConfig, + carrier: ResolvedMappingCarrier = froggyMappingCarrier() +) { + const { fragment, firedDefaults } = interpretEnrichmentMapping(carrier.doc, enriched); + const input = composeFroggyTrendPullbackInput(fragment, buildFroggyResidualInput(enriched)); + return { input, firedDefaults, analysis: scoreFroggyTrendPullback(input, uwrConfig, enriched) }; +} diff --git a/test/pipeline/tradePlanCarrier.test.ts b/test/pipeline/tradePlanCarrier.test.ts new file mode 100644 index 00000000..4ed4c86c --- /dev/null +++ b/test/pipeline/tradePlanCarrier.test.ts @@ -0,0 +1,85 @@ +/** + * DEM-PRODUCER-PLAN — the ingest carrier: the submitted trade plan survives + * the CPJ→USS mapping as `uss.plan` (afi.trade-plan.v1, decimal strings) or is + * refused at ingest; it is never dropped. The TradingView/MarkitTick mappers + * are untouched (they carry no plan fields). + */ +import { describe, it, expect } from "@jest/globals"; +import { buildTradePlanFromCpj, mapCpjToUssV11 } from "../../src/uss/cpjMapper.js"; +import { validateUsignalV11 } from "../../src/uss/ussValidator.js"; +import { validateTradePlanV1 } from "../../src/evidence/provenance/schemaValidation.js"; +import type { CpjV01Payload } from "../../src/cpj/cpjValidator.js"; + +const STRATEGY = { strategyId: "trend_pullback_v1" }; + +function cpj(extracted: Partial): CpjV01Payload { + return { + schema: "afi.cpj.v0.1", + provenance: { + providerType: "telegram", + providerId: "carrier-test-channel", + messageId: "carrier-msg-1", + postedAt: "2026-01-15T10:00:00Z", + }, + extracted: { symbolRaw: "BTCUSDT", side: "long", timeframeHint: "4h", venueHint: "blofin", marketTypeHint: "perp", ...extracted }, + parse: { parserId: "carrier-test", parserVersion: "1.0.0", confidence: 0.9 }, + } as CpjV01Payload; +} + +describe("cpjMapper — uss.plan carrier", () => { + it("carries a full plan as afi.trade-plan.v1 with decimal-string prices, contract-valid, USS-valid", () => { + const r = mapCpjToUssV11(cpj({ entry: 50000, stopLoss: 49300, takeProfits: [{ price: 51000, percentage: 50 }, { price: 52000.5 }], leverageHint: 3 }), STRATEGY); + expect(r.success).toBe(true); + const plan = r.uss!.plan!; + expect(plan).toEqual({ + schema: "afi.trade-plan.v1", + signalId: r.uss!.provenance.signalId, + levels: { + entry: "50000", + stopLoss: "49300", + takeProfits: [{ price: "51000", sizePct: "50" }, { price: "52000.5" }], + }, + leverageHint: "3", + venueHint: "blofin", + marketTypeHint: "perp", + }); + expect(validateTradePlanV1(plan).ok).toBe(true); + expect(validateUsignalV11(r.uss).ok).toBe(true); + // facts stays exactly the closed five-key block (USS schema unchanged). + expect(Object.keys(r.uss!.facts!).sort()).toEqual(["direction", "market", "strategy", "symbol", "timeframe"]); + }); + + it("carries an entry range and a non-integer entry as plain decimal strings (never floats, never exponent notation)", () => { + const range = mapCpjToUssV11(cpj({ entry: { min: 42500, max: 42800 } }), STRATEGY).uss!.plan!; + expect(range.levels.entry).toEqual({ min: "42500", max: "42800" }); + const decimal = mapCpjToUssV11(cpj({ symbolRaw: "ETH-USD", entry: 3001.5 }), STRATEGY).uss!.plan!; + expect(decimal.levels.entry).toBe("3001.5"); + const tiny = buildTradePlanFromCpj(cpj({ entry: 0.0000015 }), "sig")!; + expect(tiny.levels.entry).toBe("0.0000015"); + }); + + it("emits NO plan block when the submission carries no plan field (declared absence)", () => { + const r = mapCpjToUssV11(cpj({}), STRATEGY); + expect(r.success).toBe(true); + expect("plan" in r.uss!).toBe(false); + }); + + it("refuses at ingest: a stop or target without an entry", () => { + const r = mapCpjToUssV11(cpj({ stopLoss: 49300 }), STRATEGY); + expect(r.success).toBe(false); + expect(r.error).toMatchObject({ type: "trade_plan_invalid", reason: expect.stringMatching(/without an entry/) }); + }); + + it("refuses at ingest: a negative or non-finite price (contract pattern cannot express it)", () => { + expect(mapCpjToUssV11(cpj({ entry: -5 }), STRATEGY).error).toMatchObject({ type: "trade_plan_invalid", reason: expect.stringMatching(/negative/) }); + expect(mapCpjToUssV11(cpj({ entry: Number.NaN }), STRATEGY).error).toMatchObject({ type: "trade_plan_invalid" }); + }); + + it("the plan enters the canonical USS (and therefore inputHash) — not ingestHash, which already committed the CPJ levels", () => { + const withPlan = mapCpjToUssV11(cpj({ entry: 50000, stopLoss: 49300, takeProfits: [{ price: 51000 }] }), STRATEGY).uss!; + const withoutPlan = mapCpjToUssV11(cpj({}), STRATEGY).uss!; + expect(withPlan.plan).toBeDefined(); + expect(withoutPlan.plan).toBeUndefined(); + expect(withPlan.provenance.ingestHash).not.toBe(withoutPlan.provenance.ingestHash); + }); +}); diff --git a/test/pipeline/tradePlanProducer.test.ts b/test/pipeline/tradePlanProducer.test.ts new file mode 100644 index 00000000..954c6dbb --- /dev/null +++ b/test/pipeline/tradePlanProducer.test.ts @@ -0,0 +1,127 @@ +/** + * DEM-PRODUCER-PLAN — the technical lane as the plan producer, through the + * real adapter with injected deterministic kernels: + * - the verified plan facts ride `technical.plan` (a provider fact); + * - ROLE SPLIT (analyst/provider): the facts are a function of the submitted + * plan and the fetched candles ONLY — analyst config and mapping cannot + * move them; + * - DIR-GOV D-DIR-3: the submitted side (facts.direction) cannot move them; + * - no plan → no block (declared absence); an unverifiable plan REFUSES the + * lane with a TradePlanVerificationError (pipeline abort, no record); + * - the demo envelope actually contains every committed CPJ fixture plan + * (so the oracle harness cannot drift into refusal silently). + */ +import { describe, it, expect } from "@jest/globals"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { createTechnicalLocalAdapter } from "../../src/providers/adapters/technicalLocalAdapter.js"; +import { computeTechnicalEnrichment } from "../../src/enrichment/technicalIndicators.js"; +import { TradePlanVerificationError, verifyTradePlan } from "../../src/enrichment/tradePlanVerification.js"; +import { SILENT_NODE_LOGGER } from "../../src/pipeline/nodeSdk.js"; +import type { ProviderAdapterContext } from "../../src/providers/types.js"; +import type { PriceFeedAdapter } from "../../src/adapters/exchanges/types.js"; +import { demoPriceFeedAdapter } from "../support/deterministicPriceFeedAdapter.js"; +import { mapCpjToUssV11 } from "../../src/uss/cpjMapper.js"; +import type { CpjV01Payload } from "../../src/cpj/cpjValidator.js"; +import type { AfiCandle } from "../../src/types/AfiCandle.js"; + +function adapter() { + return createTechnicalLocalAdapter({ + resolvePriceSource: () => "demo", + getAdapter: () => demoPriceFeedAdapter as unknown as PriceFeedAdapter, + computeTechnical: computeTechnicalEnrichment, + }); +} + +function ctx(signal: Record, config: Record = { candleLimit: 100 }): ProviderAdapterContext { + return { + signal: signal as ProviderAdapterContext["signal"], + input: undefined, + config, + logger: SILENT_NODE_LOGGER, + abort: new AbortController().signal, + credential: undefined, + } as unknown as ProviderAdapterContext; +} + +async function demoWindow(symbol: string, timeframe: string, limit = 100): Promise { + return (await demoPriceFeedAdapter.getOHLCV({ symbol, timeframe, limit })).map((c) => ({ + timestamp: c.timestamp, open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume, + })); +} + +function signal(direction: "long" | "short" | "neutral", plan?: Record) { + return { + schema: "afi.usignal.v1.1", + provenance: { source: "test", providerId: "plan-producer-test", signalId: "sig-plan-producer" }, + facts: { symbol: "BTC/USDT", market: "perp", timeframe: "4h", strategy: "trend_pullback_v1", direction }, + ...(plan ? { plan } : {}), + }; +} + +const PLAN = { + schema: "afi.trade-plan.v1", + signalId: "sig-plan-producer", + levels: { entry: "50000", stopLoss: "49300", takeProfits: [{ price: "51000" }] }, +}; + +describe("technical lane — the plan producer", () => { + it("emits technical.plan (verified facts) when the signal carries a plan; no block when it does not", async () => { + const withPlan = (await adapter().run(ctx(signal("long", PLAN)))) as unknown as { technical: { plan?: unknown } }; + expect(withPlan.technical.plan).toEqual({ + entryLow: 50000, + entryHigh: 50000, + entryPrice: 50000, + stopPrice: 49300, + firstTargetPrice: 51000, + targetCount: 1, + rrToFirstTarget: 1.4286, + envelopeLow: expect.any(Number), + envelopeHigh: expect.any(Number), + barCount: 100, + }); + const without = (await adapter().run(ctx(signal("long")))) as unknown as { technical: { plan?: unknown } }; + expect("plan" in without.technical).toBe(false); + }); + + it("DIR-GOV D-DIR-3: the submitted side cannot move the plan facts (long/short/neutral → byte-identical technical payload)", async () => { + const outputs = await Promise.all( + (["long", "short", "neutral"] as const).map(async (d) => JSON.stringify((await adapter().run(ctx(signal(d, PLAN)))) as object)) + ); + expect(outputs[1]).toBe(outputs[0]); + expect(outputs[2]).toBe(outputs[0]); + }); + + it("ROLE SPLIT: analyst-side inputs (node config beyond the fetch, mapping content) cannot move the provider fact", async () => { + const a = (await adapter().run(ctx(signal("long", PLAN), { candleLimit: 100 }))) as unknown as { technical: { plan: unknown } }; + const b = (await adapter().run(ctx(signal("long", PLAN), { candleLimit: 100, timeoutMs: 10000 }))) as unknown as { technical: { plan: unknown } }; + expect(b.technical.plan).toEqual(a.technical.plan); + // The facts equal the pure law applied to (plan, fetched candles) and nothing else. + const window = await demoWindow("BTC/USDT", "4h"); + expect(a.technical.plan).toEqual(verifyTradePlan(PLAN as never, window)); + }); + + it("an unverifiable plan REFUSES the lane (TradePlanVerificationError — no retry, no record)", async () => { + const offMarket = { ...PLAN, levels: { entry: "42500", stopLoss: "41800", takeProfits: [{ price: "43500" }] } }; + await expect(adapter().run(ctx(signal("long", offMarket)))).rejects.toBeInstanceOf(TradePlanVerificationError); + }); + + it("a plan on a window below the kernel floor refuses (no computable window to verify against)", async () => { + await expect(adapter().run(ctx(signal("long", PLAN), { candleLimit: 40 }))).rejects.toThrow(/kernel floor/); + }); +}); + +describe("oracle corpus discipline — the demo envelope contains every committed CPJ fixture plan", () => { + const FIXTURES = ["cpj-blofin-perp-long.json", "cpj-coinbase-spot-sell.json", "cpj-blofin-perp-neutral.json"]; + for (const file of FIXTURES) { + it(`${file}: the carried plan (if any) verifies against the fixture's demo window`, async () => { + const cpj = JSON.parse(readFileSync(path.resolve(process.cwd(), "test/oracle/fixtures/cpj", file), "utf-8")) as CpjV01Payload; + const mapped = mapCpjToUssV11(cpj, { strategyId: "trend_pullback_v1" }); + expect(mapped.success).toBe(true); + const uss = mapped.uss!; + if (!uss.plan) return; // no plan submitted — nothing to verify + const window = await demoWindow(uss.facts!.symbol!, uss.facts!.timeframe!); + expect(() => verifyTradePlan(uss.plan!, window)).not.toThrow(); + }); + } +}); diff --git a/test/pipeline/tradePlanVerification.test.ts b/test/pipeline/tradePlanVerification.test.ts new file mode 100644 index 00000000..e9d2894d --- /dev/null +++ b/test/pipeline/tradePlanVerification.test.ts @@ -0,0 +1,194 @@ +/** + * DEM-PRODUCER-PLAN — the trade-plan verification law (D-DEM-5(6)), pinned + * as known-answer vectors over hand-built candle windows. + * + * Every rule is exercised on both sides of its edge; every refusal is a + * TradePlanVerificationError (a NodeConfigurationError — pipeline abort, no + * retry, no record). Geometry is derived from submitted PRICES only: the + * producer never reads a submitted side (DIR-GOV D-DIR-3). + */ +import { describe, it, expect } from "@jest/globals"; +import { + TradePlanVerificationError, + roundRr, + verifyTradePlan, +} from "../../src/enrichment/tradePlanVerification.js"; +import { NodeConfigurationError } from "../../src/pipeline/nodeSdk.js"; +import type { AfiCandle } from "../../src/types/AfiCandle.js"; +import type { TradePlanV1 } from "../../src/types/TradePlan.js"; + +/** A window whose observed extremes are exactly [low, high]. */ +function window(low: number, high: number, bars = 60): AfiCandle[] { + const out: AfiCandle[] = []; + for (let i = 0; i < bars; i++) { + const mid = (low + high) / 2; + out.push({ timestamp: 1_700_000_000_000 + i * 60_000, open: mid, high: mid, low: mid, close: mid, volume: 1 }); + } + out[0] = { ...out[0], low, high: low }; + out[bars - 1] = { ...out[bars - 1], high, low: high }; + return out; +} + +function plan(levels: TradePlanV1["levels"]): TradePlanV1 { + return { schema: "afi.trade-plan.v1", signalId: "sig-plan-kat", levels }; +} + +// Window [90, 110]: W = 20, band = [70, 130]. +const W = window(90, 110); + +describe("verifyTradePlan — envelope band [L − W, H + W]", () => { + it("accepts an entry inside the observed window and stop/target inside the band", () => { + const facts = verifyTradePlan(plan({ entry: "100", stopLoss: "95", takeProfits: [{ price: "110" }] }), W); + expect(facts).toEqual({ + entryLow: 100, + entryHigh: 100, + entryPrice: 100, + stopPrice: 95, + firstTargetPrice: 110, + targetCount: 1, + rrToFirstTarget: 2, + envelopeLow: 90, + envelopeHigh: 110, + barCount: 60, + }); + }); + + it("accepts a breakout entry ABOVE the window and a limit entry BELOW it, inside the band", () => { + expect(verifyTradePlan(plan({ entry: "125", stopLoss: "115", takeProfits: [{ price: "130" }] }), W).rrToFirstTarget).toBe(0.5); + expect(verifyTradePlan(plan({ entry: "75", stopLoss: "70", takeProfits: [{ price: "90" }] }), W).rrToFirstTarget).toBe(3); + }); + + it("accepts levels exactly ON the band edges", () => { + const facts = verifyTradePlan(plan({ entry: "100", stopLoss: "70", takeProfits: [{ price: "130" }] }), W); + expect(facts.stopPrice).toBe(70); + expect(facts.firstTargetPrice).toBe(130); + expect(facts.rrToFirstTarget).toBe(1); + }); + + const BAND_REFUSALS: Array<[string, TradePlanV1["levels"], RegExp]> = [ + ["entry above the band", { entry: "130.01", stopLoss: "120", takeProfits: [{ price: "130" }] }, /entry 130.01 lies outside/], + ["entry below the band", { entry: "69.99", stopLoss: "60", takeProfits: [{ price: "90" }] }, /entry 69.99 lies outside/], + ["stop outside the band", { entry: "100", stopLoss: "69.99", takeProfits: [{ price: "110" }] }, /stopLoss 69.99 lies outside/], + ["target outside the band", { entry: "100", stopLoss: "95", takeProfits: [{ price: "130.5" }] }, /takeProfits\[0\].price 130.5 lies outside/], + ]; + it.each(BAND_REFUSALS)("refuses %s", (_label, levels, re) => { + expect(() => verifyTradePlan(plan(levels), W)).toThrow(re); + }); + + it("refuses a flat window (zero observed range) and an empty window", () => { + expect(() => verifyTradePlan(plan({ entry: "100" }), window(100, 100))).toThrow(/flat/); + expect(() => verifyTradePlan(plan({ entry: "100" }), [])).toThrow(/no fetched candles/); + }); +}); + +describe("verifyTradePlan — plan geometry (from submitted prices only)", () => { + it("long geometry: stop below entry, targets above; first target = nearest", () => { + const facts = verifyTradePlan( + plan({ entry: "100", stopLoss: "96", takeProfits: [{ price: "112" }, { price: "104" }, { price: "108" }] }), + W + ); + expect(facts.firstTargetPrice).toBe(104); + expect(facts.targetCount).toBe(3); + expect(facts.rrToFirstTarget).toBe(1); + }); + + it("short geometry: stop above entry, targets below (direction is never read)", () => { + const facts = verifyTradePlan(plan({ entry: "100", stopLoss: "104", takeProfits: [{ price: "94" }, { price: "90" }] }), W); + expect(facts.firstTargetPrice).toBe(94); + expect(facts.rrToFirstTarget).toBe(1.5); + }); + + const GEOMETRY_REFUSALS: Array<[string, TradePlanV1["levels"], RegExp]> = [ + ["targets on both sides of the stop", { entry: "100", stopLoss: "100.5", takeProfits: [{ price: "95" }, { price: "110" }] }, /both sides of the stop/], + ["a target equal to the stop", { entry: "100", stopLoss: "95", takeProfits: [{ price: "95" }] }, /equals the stop/], + ["stop not below the entry (long)", { entry: "100", stopLoss: "100", takeProfits: [{ price: "110" }] }, /stop 100 is not below the entry 100/], + ["target not above the entry (long)", { entry: "100", stopLoss: "95", takeProfits: [{ price: "110" }, { price: "100" }] }, /target 100 is not above the entry 100/], + ["stop not above the entry (short)", { entry: "100", stopLoss: "99", takeProfits: [{ price: "90" }] }, /is not above the entry 100|is not below the entry 100/], + ]; + it.each(GEOMETRY_REFUSALS)("refuses %s", (_label, levels, re) => { + expect(() => verifyTradePlan(plan(levels), W)).toThrow(re); + }); + + it("entry RANGE: the conservative bound (nearest the targets) is the entry price", () => { + const long = verifyTradePlan(plan({ entry: { min: "98", max: "102" }, stopLoss: "94", takeProfits: [{ price: "110" }] }), W); + expect(long.entryLow).toBe(98); + expect(long.entryHigh).toBe(102); + expect(long.entryPrice).toBe(102); // smallest reward (8), largest risk (8) → rr 1 + expect(long.rrToFirstTarget).toBe(1); + const short = verifyTradePlan(plan({ entry: { min: "98", max: "102" }, stopLoss: "106", takeProfits: [{ price: "90" }] }), W); + expect(short.entryPrice).toBe(98); + expect(short.rrToFirstTarget).toBe(1); + }); + + it("refuses an inverted entry range", () => { + expect(() => verifyTradePlan(plan({ entry: { min: "102", max: "98" } }), W)).toThrow(/inverted/); + }); +}); + +describe("verifyTradePlan — declared producer absences (D-DEM-5(4)(b))", () => { + it("entry-only plan: the entry is verified, no R:R fact is emitted", () => { + const facts = verifyTradePlan(plan({ entry: "100" }), W); + expect(facts).toEqual({ entryLow: 100, entryHigh: 100, entryPrice: 100, targetCount: 0, envelopeLow: 90, envelopeHigh: 110, barCount: 60 }); + expect(facts.rrToFirstTarget).toBeUndefined(); + }); + + it("entry + stop without a target: both verified, no R:R fact", () => { + const facts = verifyTradePlan(plan({ entry: "100", stopLoss: "95" }), W); + expect(facts.stopPrice).toBe(95); + expect(facts.rrToFirstTarget).toBeUndefined(); + expect(facts.firstTargetPrice).toBeUndefined(); + }); + + it("entry range without geometry: bounds verified, no entryPrice, no R:R", () => { + const facts = verifyTradePlan(plan({ entry: { min: "98", max: "102" } }), W); + expect(facts.entryPrice).toBeUndefined(); + expect(facts.rrToFirstTarget).toBeUndefined(); + }); + + it("an entry-only plan outside the band is still refused (an emitted price is always a verified price)", () => { + expect(() => verifyTradePlan(plan({ entry: "50" }), W)).toThrow(TradePlanVerificationError); + }); +}); + +describe("verifyTradePlan — contract discipline and error class", () => { + it("refuses non-decimal-string and non-positive prices", () => { + expect(() => verifyTradePlan(plan({ entry: 100 as unknown as string }), W)).toThrow(/neither a price nor a range/); + expect(() => verifyTradePlan(plan({ entry: "100", stopLoss: 95 as unknown as string }), W)).toThrow(/stopLoss is not a decimal-string/); + expect(() => verifyTradePlan(plan({ entry: "-5" }), W)).toThrow(/not a decimal-string/); + expect(() => verifyTradePlan(plan({ entry: "0" }), W)).toThrow(/not a positive finite/); + expect(() => verifyTradePlan(plan({ entry: "1e3" }), W)).toThrow(/not a decimal-string/); + }); + + it("refuses a non-plan object", () => { + expect(() => verifyTradePlan({ schema: "other" } as unknown as TradePlanV1, W)).toThrow(/not an afi.trade-plan.v1/); + }); + + it("every refusal is a TradePlanVerificationError, i.e. a NodeConfigurationError (no retry, pipeline abort)", () => { + try { + verifyTradePlan(plan({ entry: "50" }), W); + throw new Error("expected refusal"); + } catch (err) { + expect(err).toBeInstanceOf(TradePlanVerificationError); + expect(err).toBeInstanceOf(NodeConfigurationError); + expect((err as TradePlanVerificationError).code).toBe("trade_plan_unverifiable"); + expect((err as TradePlanVerificationError).reason).toMatch(/entry 50 lies outside/); + } + }); + + it("R:R rounds half-up to 4 decimals", () => { + expect(roundRr(1000 / 700)).toBe(1.4286); + expect(roundRr(1.23445)).toBe(1.2345); + expect(roundRr(2)).toBe(2); + const facts = verifyTradePlan(plan({ entry: "50000", stopLoss: "49300", takeProfits: [{ price: "51000" }] }), window(49000, 51000)); + expect(facts.rrToFirstTarget).toBe(1.4286); + }); + + it("is pure and side-effect free: identical inputs → identical facts; inputs untouched", () => { + const p = plan({ entry: "100", stopLoss: "95", takeProfits: [{ price: "110" }] }); + const before = JSON.stringify([p, W]); + const a = verifyTradePlan(p, W); + const b = verifyTradePlan(p, W); + expect(a).toEqual(b); + expect(JSON.stringify([p, W])).toBe(before); + }); +}); From a2602606e32e86b11e117f86342b4764657a5442 Mon Sep 17 00:00:00 2001 From: Giovanni Giovanni Date: Tue, 25 Aug 2026 19:31:00 -0700 Subject: [PATCH 2/2] DEM-PRODUCER-PLAN (reactor): pre-open review fixes to the verification law An adversarial review of the branch (7 lenses, findings independently verified) found five real defects in the D-DEM-5(6) law. All five fixed here; no golden byte moves (every fix is refusal-side or message-side). - MAJOR: an entry RANGE was verified only at its conservative bound, so a range containing (or on the wrong side of) its own stop was ACCEPTED and an R:R emitted from one end. The stop must now clear the FAR bound as well: long -> stop < entryLow, short -> stop > entryHigh. Three refusal KATs (long straddle, short straddle, far bound EQUALS the stop) + the accept-side edge. - MINOR: the inclusive band edge was false on non-integer windows (binary float: 1.1 - 0.2 = 0.9000000000000001 refused an entry of exactly "0.9"). Relative tolerance (1e-12) at both edges + bounded-precision numbers in the refusal message; a non-integer-window KAT pins both edges. - MINOR: a non-array takeProfits (or a non-object levels) threw a bare TypeError, escaping the 422 unwrap. Both refuse properly now, with KATs. - MINOR: "half-up to 4 decimals" was not what Math.round(v*1e4)/1e4 does at binary ties. The law is documented as what it is (deterministic binary quantisation) and the unfavourable tie (0.00015 -> 0.0001) is pinned. - MINOR: missing edge KATs added: empty takeProfits array (no-target case), degenerate entry range (min === max), exotic numeric strings, an overflow-to-Infinity decimal string, malformed candles; the hedged short-geometry regex is tightened to its exact message. 756 -> 764 tests, all green; typecheck green; goldens byte-unchanged. Co-Authored-By: Claude Fable 5 --- src/enrichment/tradePlanVerification.ts | 69 ++++++++++++++++----- test/pipeline/tradePlanVerification.test.ts | 64 +++++++++++++++++-- 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/src/enrichment/tradePlanVerification.ts b/src/enrichment/tradePlanVerification.ts index dfe4b029..3b232c15 100644 --- a/src/enrichment/tradePlanVerification.ts +++ b/src/enrichment/tradePlanVerification.ts @@ -18,12 +18,12 @@ * entries below it are lawful; a level a full range-width away * from anything the market printed is not verifiable; * geometry (only when a stop AND at least one target are submitted) every - * target lies on the same side of the stop; the entry lies - * strictly between the stop and every target. For an entry - * RANGE the conservative bound is used (nearest the targets — - * smallest reward, largest risk); + * target lies on the same side of the stop, and the ENTIRE entry + * — both bounds of a range — lies strictly between the stop and + * every target. R:R is computed at the conservative bound + * (nearest the targets: smallest reward, largest risk); * R:R |firstTarget − entry| / |entry − stop| where firstTarget is the - * target nearest the entry; rounded half-up to 4 decimals. + * target nearest the entry, quantised to 4 decimals (see roundRr). * Any violation REFUSES the determination (TradePlanVerificationError — a * NodeConfigurationError: no retry, pipeline abort, no score, no record). * @@ -63,13 +63,26 @@ function parsePrice(label: string, raw: unknown): number { return value; } -/** Round half-up to 4 decimals (positive inputs only). */ +/** + * The R:R quantisation: `Math.round(value · 1e4) / 1e4` — IEEE-754 binary + * arithmetic, DETERMINISTIC for every input (what a sealed record needs), not + * exact decimal half-up: a decimal tie such as 0.00015 is not representable in + * binary and rounds by its stored value (0.00015 → 0.0001). R:R is a ratio of + * float prices, so an exact decimal tie is not reachable in practice. + */ export function roundRr(value: number): number { return Math.round(value * 1e4) / 1e4; } export function verifyTradePlan(plan: TradePlanV1, candles: readonly AfiCandle[]): TechnicalPlanFacts { - if (!plan || typeof plan !== "object" || plan.schema !== "afi.trade-plan.v1" || !plan.levels) { + if ( + !plan || + typeof plan !== "object" || + plan.schema !== "afi.trade-plan.v1" || + !plan.levels || + typeof plan.levels !== "object" || + Array.isArray(plan.levels) + ) { refuse("plan is not an afi.trade-plan.v1 object"); } if (candles.length === 0) refuse("no fetched candles to verify against"); @@ -87,11 +100,19 @@ export function verifyTradePlan(plan: TradePlanV1, candles: readonly AfiCandle[] if (!(width > 0)) refuse("fetched window is flat (zero observed range)"); const bandLow = low - width; const bandHigh = high + width; + // The band is INCLUSIVE at both edges. `low - width` is computed in binary + // floating point, so a level submitted as the exact decimal edge can land a + // few ulps outside it; compare with a relative tolerance so the inclusive + // law holds for non-integer windows too (fail-closed direction unchanged: + // this only prevents a spurious refusal at the edge itself). + const EDGE_ULPS = 1e-12; + const tolerance = (bound: number): number => Math.abs(bound) * EDGE_ULPS; + const show = (value: number): string => Number(value.toPrecision(12)).toString(); const inBand = (label: string, price: number): number => { - if (price < bandLow || price > bandHigh) { + if (price < bandLow - tolerance(bandLow) || price > bandHigh + tolerance(bandHigh)) { refuse( - `${label} ${price} lies outside the observed band [${bandLow}, ${bandHigh}] ` + - `(window low ${low}, high ${high}, ${candles.length} candles)` + `${label} ${show(price)} lies outside the observed band [${show(bandLow)}, ${show(bandHigh)}] ` + + `(window low ${show(low)}, high ${show(high)}, ${candles.length} candles)` ); } return price; @@ -116,8 +137,12 @@ export function verifyTradePlan(plan: TradePlanV1, candles: readonly AfiCandle[] plan.levels.stopLoss !== undefined ? inBand("stopLoss", parsePrice("stopLoss", plan.levels.stopLoss)) : undefined; + const rawTargets = plan.levels.takeProfits; + if (rawTargets !== undefined && !Array.isArray(rawTargets)) { + refuse(`takeProfits is not an array (${typeof rawTargets})`); + } const targets: number[] = []; - for (const [i, tp] of (plan.levels.takeProfits ?? []).entries()) { + for (const [i, tp] of (rawTargets ?? []).entries()) { targets.push(inBand(`takeProfits[${i}].price`, parsePrice(`takeProfits[${i}].price`, tp?.price))); } @@ -140,13 +165,29 @@ export function verifyTradePlan(plan: TradePlanV1, candles: readonly AfiCandle[] if (above > 0 && below > 0) refuse("targets lie on both sides of the stop"); if (above === 0 && below === 0) refuse("a target equals the stop"); const longGeometry = above > 0; - // Conservative entry bound: nearest the targets. + // Conservative entry bound: nearest the targets (smallest reward, largest + // risk). The stop is checked against the FAR bound as well, so an entry + // RANGE that contains — or sits the wrong side of — its own stop refuses + // rather than being verified at one end (review finding, 2026-08-25). const entryPrice = longGeometry ? entryHigh : entryLow; + const entryFar = longGeometry ? entryLow : entryHigh; if (longGeometry) { - if (!(stopPrice < entryPrice)) refuse(`stop ${stopPrice} is not below the entry ${entryPrice}`); + if (!(stopPrice < entryFar)) { + refuse( + entryFar === entryPrice + ? `stop ${stopPrice} is not below the entry ${entryPrice}` + : `stop ${stopPrice} is not below the whole entry range [${entryLow}, ${entryHigh}]` + ); + } for (const t of targets) if (!(t > entryPrice)) refuse(`target ${t} is not above the entry ${entryPrice}`); } else { - if (!(stopPrice > entryPrice)) refuse(`stop ${stopPrice} is not above the entry ${entryPrice}`); + if (!(stopPrice > entryFar)) { + refuse( + entryFar === entryPrice + ? `stop ${stopPrice} is not above the entry ${entryPrice}` + : `stop ${stopPrice} is not above the whole entry range [${entryLow}, ${entryHigh}]` + ); + } for (const t of targets) if (!(t < entryPrice)) refuse(`target ${t} is not below the entry ${entryPrice}`); } let firstTarget = targets[0]; diff --git a/test/pipeline/tradePlanVerification.test.ts b/test/pipeline/tradePlanVerification.test.ts index e9d2894d..1ac3b01d 100644 --- a/test/pipeline/tradePlanVerification.test.ts +++ b/test/pipeline/tradePlanVerification.test.ts @@ -79,6 +79,21 @@ describe("verifyTradePlan — envelope band [L − W, H + W]", () => { expect(() => verifyTradePlan(plan({ entry: "100" }), window(100, 100))).toThrow(/flat/); expect(() => verifyTradePlan(plan({ entry: "100" }), [])).toThrow(/no fetched candles/); }); + + it("the inclusive edge holds on a NON-integer window too (binary-float tolerance; review finding)", () => { + const w = window(1.1, 1.3); // band [0.9, 1.5] in decimal; 1.1 − 0.2 is 0.9000000000000001 in binary + expect(verifyTradePlan(plan({ entry: "0.9" }), w).entryPrice).toBe(0.9); + expect(verifyTradePlan(plan({ entry: "1.5" }), w).entryPrice).toBe(1.5); + expect(() => verifyTradePlan(plan({ entry: "0.8999" }), w)).toThrow(/lies outside the observed band/); + expect(() => verifyTradePlan(plan({ entry: "1.5001" }), w)).toThrow(/lies outside the observed band/); + }); + + it("refuses a malformed candle window (NaN, non-positive low, inverted bar)", () => { + const bad = (c: Partial): AfiCandle[] => [{ timestamp: 0, open: 100, high: 101, low: 99, close: 100, volume: 1, ...c }, ...W.slice(1)]; + expect(() => verifyTradePlan(plan({ entry: "100" }), bad({ low: Number.NaN }))).toThrow(/malformed candle/); + expect(() => verifyTradePlan(plan({ entry: "100" }), bad({ low: 0 }))).toThrow(/malformed candle/); + expect(() => verifyTradePlan(plan({ entry: "100" }), bad({ high: 98, low: 99 }))).toThrow(/malformed candle/); + }); }); describe("verifyTradePlan — plan geometry (from submitted prices only)", () => { @@ -103,7 +118,12 @@ describe("verifyTradePlan — plan geometry (from submitted prices only)", () => ["a target equal to the stop", { entry: "100", stopLoss: "95", takeProfits: [{ price: "95" }] }, /equals the stop/], ["stop not below the entry (long)", { entry: "100", stopLoss: "100", takeProfits: [{ price: "110" }] }, /stop 100 is not below the entry 100/], ["target not above the entry (long)", { entry: "100", stopLoss: "95", takeProfits: [{ price: "110" }, { price: "100" }] }, /target 100 is not above the entry 100/], - ["stop not above the entry (short)", { entry: "100", stopLoss: "99", takeProfits: [{ price: "90" }] }, /is not above the entry 100|is not below the entry 100/], + ["stop not above the entry (short)", { entry: "100", stopLoss: "99", takeProfits: [{ price: "90" }] }, /stop 99 is not above the entry 100/], + // Review finding 2026-08-25: an entry RANGE must clear the stop at BOTH + // bounds — a range containing its own stop is not a verifiable plan. + ["long entry range straddling its stop", { entry: { min: "90", max: "102" }, stopLoss: "94", takeProfits: [{ price: "110" }] }, /stop 94 is not below the whole entry range \[90, 102\]/], + ["short entry range straddling its stop", { entry: { min: "98", max: "110" }, stopLoss: "106", takeProfits: [{ price: "90" }] }, /stop 106 is not above the whole entry range \[98, 110\]/], + ["long entry range whose far bound EQUALS the stop", { entry: { min: "94", max: "102" }, stopLoss: "94", takeProfits: [{ price: "110" }] }, /stop 94 is not below the whole entry range/], ]; it.each(GEOMETRY_REFUSALS)("refuses %s", (_label, levels, re) => { expect(() => verifyTradePlan(plan(levels), W)).toThrow(re); @@ -120,8 +140,19 @@ describe("verifyTradePlan — plan geometry (from submitted prices only)", () => expect(short.rrToFirstTarget).toBe(1); }); - it("refuses an inverted entry range", () => { + it("accepts an entry range whose FAR bound clears the stop (the edge just outside)", () => { + const facts = verifyTradePlan(plan({ entry: { min: "94.0001", max: "102" }, stopLoss: "94", takeProfits: [{ price: "110" }] }), W); + expect(facts.entryPrice).toBe(102); // conservative bound + expect(facts.rrToFirstTarget).toBe(1); + }); + + it("refuses an inverted entry range; a degenerate range (min === max) is a single price", () => { expect(() => verifyTradePlan(plan({ entry: { min: "102", max: "98" } }), W)).toThrow(/inverted/); + const facts = verifyTradePlan(plan({ entry: { min: "100", max: "100" }, stopLoss: "95", takeProfits: [{ price: "110" }] }), W); + expect(facts.entryLow).toBe(100); + expect(facts.entryHigh).toBe(100); + expect(facts.entryPrice).toBe(100); + expect(facts.rrToFirstTarget).toBe(2); }); }); @@ -145,6 +176,12 @@ describe("verifyTradePlan — declared producer absences (D-DEM-5(4)(b))", () => expect(facts.rrToFirstTarget).toBeUndefined(); }); + it("an EMPTY takeProfits array is the no-target case (no R:R claim), not a refusal", () => { + const facts = verifyTradePlan(plan({ entry: "100", stopLoss: "95", takeProfits: [] }), W); + expect(facts.targetCount).toBe(0); + expect(facts.rrToFirstTarget).toBeUndefined(); + }); + it("an entry-only plan outside the band is still refused (an emitted price is always a verified price)", () => { expect(() => verifyTradePlan(plan({ entry: "50" }), W)).toThrow(TradePlanVerificationError); }); @@ -159,8 +196,22 @@ describe("verifyTradePlan — contract discipline and error class", () => { expect(() => verifyTradePlan(plan({ entry: "1e3" }), W)).toThrow(/not a decimal-string/); }); - it("refuses a non-plan object", () => { + it("refuses exotic numeric strings the contract pattern does not admit", () => { + for (const bad of ["NaN", "Infinity", ".5", "1.", " 100", "1_000", "0x64", ""]) { + expect(() => verifyTradePlan(plan({ entry: bad }), W)).toThrow(/not a decimal-string/); + } + // A syntactically valid decimal string that overflows to Infinity. + expect(() => verifyTradePlan(plan({ entry: "9".repeat(400) }), W)).toThrow(/not a positive finite/); + }); + + it("refuses a non-plan object, a non-object levels block, and a non-array takeProfits (never a TypeError)", () => { expect(() => verifyTradePlan({ schema: "other" } as unknown as TradePlanV1, W)).toThrow(/not an afi.trade-plan.v1/); + expect(() => verifyTradePlan({ schema: "afi.trade-plan.v1", levels: [] } as unknown as TradePlanV1, W)).toThrow(/not an afi.trade-plan.v1/); + for (const shape of [{}, "x", 5, true]) { + expect(() => + verifyTradePlan(plan({ entry: "100", takeProfits: shape as never }), W) + ).toThrow(TradePlanVerificationError); + } }); it("every refusal is a TradePlanVerificationError, i.e. a NodeConfigurationError (no retry, pipeline abort)", () => { @@ -175,10 +226,15 @@ describe("verifyTradePlan — contract discipline and error class", () => { } }); - it("R:R rounds half-up to 4 decimals", () => { + it("R:R is quantised to 4 decimals by Math.round(v·1e4)/1e4 — deterministic binary rounding, not exact decimal half-up", () => { expect(roundRr(1000 / 700)).toBe(1.4286); expect(roundRr(1.23445)).toBe(1.2345); expect(roundRr(2)).toBe(2); + // A decimal "tie" is not representable in binary: 0.00015 * 1e4 stores as + // 1.4999999999999998, so it rounds DOWN. Pinned so the record's law is the + // implementation's law (review finding 2026-08-25). + expect(roundRr(0.00015)).toBe(0.0001); + expect(roundRr(1.00005)).toBe(1.0001); const facts = verifyTradePlan(plan({ entry: "50000", stopLoss: "49300", takeProfits: [{ price: "51000" }] }), window(49000, 51000)); expect(facts.rrToFirstTarget).toBe(1.4286); });