diff --git a/apps/presentation/dashboard/smoke/dashboard-coverage-runner.mjs b/apps/presentation/dashboard/smoke/dashboard-coverage-runner.mjs new file mode 100644 index 0000000000..8c16ecef02 --- /dev/null +++ b/apps/presentation/dashboard/smoke/dashboard-coverage-runner.mjs @@ -0,0 +1,4 @@ +await import("../src/features/personal-workspace/agent-family.test.mjs"); +await import( + "../node_modules/.cache/loopx-decision-research-coverage/decision-research-surface-coverage.js" +); diff --git a/apps/presentation/dashboard/smoke/decision-research-surface-coverage.tsx b/apps/presentation/dashboard/smoke/decision-research-surface-coverage.tsx new file mode 100644 index 0000000000..7ea240333d --- /dev/null +++ b/apps/presentation/dashboard/smoke/decision-research-surface-coverage.tsx @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { DecisionResearchView } from "../src/data/decision-research"; +import { + SourcePeriodMetricsCard, + SpotMarketIdentityCard, +} from "../src/views/decision-research-surface"; + +type SourcePeriodMetric = DecisionResearchView["source_period_metrics"][number]; +type SpotMarketIdentity = NonNullable; + +const periodMetric: SourcePeriodMetric = { + metric_id: "synthetic-fees", + label: "Synthetic fees", + event_namespace: "synthetic.period.metric", + event_id: "event-2026w01", + event_at: "2026-01-07T23:00:00Z", + instrument_id: "SYNTH-USD", + scope_id: "synthetic-scope", + period_start: "2026-01-01", + period_end: "2026-01-07", + source_state: "ok", + value: 125, + unit: "USD", + metric_basis: "period_estimate", + metric_semantics: "generic", + value_origin: "derived", + value_precision: "exact", + observation_authority: "derived_exact", + sign_basis: "not_signed", + fee_inclusion: "not_applicable", + account_scope: "not_applicable", + account_value_role: "not_applicable", + includes_isolated_margin: false, + expected_components: ["core", "secondary"], + observed_components: ["core"], + double_counted_components: ["secondary"], + numerator_scope: ["core"], + denominator_scope: [], + lineage_id: "synthetic-upstream-week", + source_ref: "source:synthetic-fees", + methodology_state: "verified", + anomaly_state: "unverified", + event_identity: { + namespace: "synthetic.period.metric", + source_event_id: "event-2026w01", + event_at: "2026-01-07T23:00:00Z", + instrument_id: "SYNTH-USD", + scope_id: "synthetic-scope", + }, + coverage_state: "partial", + missing_components: ["secondary"], + lineage_state: "primary", + duplicate_of: null, + independent_evidence: true, + gap_reasons: ["anomaly:unverified"], + account_nav_treatment: "not_account_value", + ready_eligible: false, + admission_reason: "source_period_metric_is_evidence_only", +}; + +assert.equal(renderToStaticMarkup(), ""); +const metricsMarkup = renderToStaticMarkup( + , +); +assert.match(metricsMarkup, /125 USD/); +assert.match(metricsMarkup, /2026-01-01 → 2026-01-07/); +assert.match(metricsMarkup, /missing \(not zero\)/); +assert.match(metricsMarkup, /Excluded double-counted components:.*secondary/); +assert.match(metricsMarkup, /Holds:.*anomaly:unverified/); +assert.match(metricsMarkup, /capital/); + +const spotIdentity: SpotMarketIdentity = { + pairs: [], + tokens: [], + contexts: [], + markets: [ + { + pair_name: "SYNTH-SPOT", + context_coin: "SYNTH-SPOT", + base_asset: { index: 3, symbol: "SYN" }, + quote_asset: { index: 0, symbol: "USDC" }, + observed_at: "2026-01-15T12:00:00Z", + mark_price: 4.25, + canonicality: "noncanonical_name", + backing_inference: "not_inferred", + source_refs: ["source:pair", "source:base", "source:quote", "source:context"], + ready_eligible: false, + }, + { + pair_name: "MISSING-SPOT", + context_coin: "MISSING-SPOT", + base_asset: { index: 4, symbol: "MISS" }, + quote_asset: { index: 0, symbol: "USDC" }, + observed_at: "2026-01-15T12:00:00Z", + mark_price: null, + canonicality: "canonical_name", + backing_inference: "not_inferred", + source_refs: ["source:pair-2", "source:base-2", "source:quote", "source:context-2"], + ready_eligible: false, + }, + ], +}; + +assert.equal(renderToStaticMarkup(), ""); +assert.equal( + renderToStaticMarkup(), + "", +); +const spotMarkup = renderToStaticMarkup( + , +); +assert.match(spotMarkup, /SYN \/ USDC/); +assert.match(spotMarkup, /4.25/); +assert.match(spotMarkup, /missing \(not zero\)/); + +console.log("Decision research source-period rendering coverage passed"); diff --git a/apps/presentation/dashboard/src/data/decision-research.ts b/apps/presentation/dashboard/src/data/decision-research.ts index 9df68a8cc9..2268f59d9b 100644 --- a/apps/presentation/dashboard/src/data/decision-research.ts +++ b/apps/presentation/dashboard/src/data/decision-research.ts @@ -46,6 +46,145 @@ const researchMetricSchema = z.object({ tone: researchToneSchema, }).strict(); +const sourcePeriodMetricSchema = z.object({ + metric_id: z.string().min(1), + label: z.string().min(1), + event_namespace: z.string().min(1), + event_id: z.string().min(1), + event_at: z.string().min(1), + instrument_id: z.string().min(1), + scope_id: z.string().min(1), + period_start: z.string().min(1), + period_end: z.string().min(1), + source_state: z.enum(["ok", "error"]), + value: z.number().finite().nullable(), + unit: z.string().min(1), + metric_basis: z.enum([ + "realized_cash", + "period_estimate", + "annualized_estimate", + ]), + metric_semantics: z.enum([ + "generic", + "entry_price", + "cash_delta", + "cumulative_funding_cost", + "fill_fee", + "account_nav", + "account_component", + "withdrawable", + "external_asset_coverage", + ]), + value_origin: z.enum(["source_reported", "derived"]), + value_precision: z.enum(["exact", "rounded"]), + observation_authority: z.enum([ + "fill_vwap", + "source_reported_exact", + "derived_exact", + "source_reported_rounded", + "rounded_position_entry", + "derived_rounded", + ]), + sign_basis: z.enum([ + "not_signed", + "account_cash_change", + "funding_cost", + "fee_cost", + ]), + fee_inclusion: z.enum(["not_applicable", "builder_included"]), + account_scope: z.enum([ + "not_applicable", + "product", + "venue", + "unified_account", + "external_asset", + ]), + account_value_role: z.enum([ + "not_applicable", + "nav_owner", + "composition", + "reconciliation", + "withdrawable", + "coverage", + ]), + includes_isolated_margin: z.boolean(), + expected_components: z.array(z.string().min(1)).min(1).max(32), + observed_components: z.array(z.string().min(1)).max(32), + double_counted_components: z.array(z.string().min(1)).max(32), + numerator_scope: z.array(z.string().min(1)).max(32), + denominator_scope: z.array(z.string().min(1)).max(32), + lineage_id: z.string().min(1), + source_ref: z.string().min(1), + methodology_state: z.enum([ + "verified", + "declared_only", + "unverified", + "conflicting", + ]), + anomaly_state: z.enum(["clear", "unverified", "confirmed"]), + event_identity: z.object({ + namespace: z.string().min(1), + source_event_id: z.string().min(1), + event_at: z.string().min(1), + instrument_id: z.string().min(1), + scope_id: z.string().min(1), + }).strict(), + coverage_state: z.enum(["complete", "partial", "missing", "source_error"]), + missing_components: z.array(z.string().min(1)).max(32), + lineage_state: z.enum(["primary", "duplicate_upstream"]), + duplicate_of: z.string().min(1).nullable(), + independent_evidence: z.boolean(), + gap_reasons: z.array(z.string().min(1)).max(40), + account_nav_treatment: z.enum([ + "authoritative_total", + "composition_only", + "reconciliation_only", + "venue_liquidity_only", + "external_asset_coverage_only", + "not_account_value", + ]), + ready_eligible: z.literal(false), + admission_reason: z.literal("source_period_metric_is_evidence_only"), +}).strict(); + +const spotMarketIdentitySchema = z.object({ + pairs: z.array(z.object({ + name: z.string().min(1), + asset_indexes: z.array(z.number().int().nonnegative()).length(2), + is_canonical: z.boolean(), + source_ref: z.string().min(1), + }).strict()).max(64), + tokens: z.array(z.object({ + index: z.number().int().nonnegative(), + symbol: z.string().min(1), + source_ref: z.string().min(1), + }).strict()).max(128), + contexts: z.array(z.object({ + coin: z.string().min(1), + observed_at: z.string().min(1), + mark_price: z.number().finite().positive().nullable(), + source_ref: z.string().min(1), + }).strict()).max(64), + markets: z.array(z.object({ + pair_name: z.string().min(1), + context_coin: z.string().min(1), + base_asset: z.object({ + index: z.number().int().nonnegative(), + symbol: z.string().min(1), + }).strict(), + quote_asset: z.object({ + index: z.number().int().nonnegative(), + symbol: z.string().min(1), + }).strict(), + observed_at: z.string().min(1), + mark_price: z.number().finite().positive().nullable(), + canonicality: z.enum(["canonical_name", "noncanonical_name"]), + backing_inference: z.literal("not_inferred"), + source_refs: z.array(z.string().min(1)).length(4), + ready_eligible: z.literal(false), + }).strict()).max(64), +}).strict(); + const researchSummarySchema = z.object({ id: z.string().min(1), label: z.string().min(1), @@ -198,6 +337,8 @@ export const decisionResearchViewSchema = z.object({ confidence: researchConfidenceSchema, }).strict(), metrics: z.array(researchMetricSchema).min(1).max(12), + source_period_metrics: z.array(sourcePeriodMetricSchema).max(24).optional().default([]), + spot_market_identity: spotMarketIdentitySchema.optional(), dashboard_summaries: z.array(researchSummarySchema).max(3), layers: z.array(researchLayerSchema).min(1).max(12), entities: z.array(researchEntitySchema).max(24), diff --git a/apps/presentation/dashboard/src/data/fixtures/presentation-projection.example.json b/apps/presentation/dashboard/src/data/fixtures/presentation-projection.example.json index 563b2e7f71..df08f531c3 100644 --- a/apps/presentation/dashboard/src/data/fixtures/presentation-projection.example.json +++ b/apps/presentation/dashboard/src/data/fixtures/presentation-projection.example.json @@ -196,7 +196,7 @@ "revision": "candidate-v1", "summary": "Active method unchanged." }, - "metrics": [ + "metrics": [ { "detail": "No company-specific residual passed every frozen gate.", "id": "validated-alpha", @@ -209,10 +209,125 @@ "id": "method-state", "label": "Active method", "tone": "neutral", - "value": "unchanged" - } + "value": "unchanged" + } + ], + "source_period_metrics": [ + { + "account_nav_treatment": "venue_liquidity_only", + "account_scope": "venue", + "account_value_role": "withdrawable", + "admission_reason": "source_period_metric_is_evidence_only", + "anomaly_state": "clear", + "coverage_state": "complete", + "denominator_scope": [], + "double_counted_components": [], + "duplicate_of": null, + "event_at": "2026-01-15T12:00:00+00:00", + "event_id": "venue-liquidity-20260115", + "event_identity": { + "event_at": "2026-01-15T12:00:00+00:00", + "instrument_id": "SYN-USDC", + "namespace": "synthetic.account.metric", + "scope_id": "synthetic-venue", + "source_event_id": "venue-liquidity-20260115" + }, + "event_namespace": "synthetic.account.metric", + "expected_components": [ + "venue-withdrawable" + ], + "fee_inclusion": "not_applicable", + "gap_reasons": [], + "includes_isolated_margin": false, + "independent_evidence": true, + "instrument_id": "SYN-USDC", + "label": "Venue withdrawable", + "lineage_id": "synthetic-venue-liquidity", + "lineage_state": "primary", + "methodology_state": "verified", + "metric_basis": "realized_cash", + "metric_id": "synthetic-venue-withdrawable", + "metric_semantics": "withdrawable", + "missing_components": [], + "numerator_scope": [ + "venue-withdrawable" + ], + "observation_authority": "source_reported_exact", + "observed_components": [ + "venue-withdrawable" ], - "research_ledger": [ + "period_end": "2026-01-15", + "period_start": "2026-01-15", + "ready_eligible": false, + "scope_id": "synthetic-venue", + "sign_basis": "not_signed", + "source_ref": "source:synthetic-venue-liquidity", + "source_state": "ok", + "unit": "USDC", + "value": 0, + "value_origin": "source_reported", + "value_precision": "exact" + } + ], + "spot_market_identity": { + "contexts": [ + { + "coin": "SYN-SPOT", + "mark_price": 4.25, + "observed_at": "2026-01-15T12:00:00+00:00", + "source_ref": "source:synthetic-spot-context" + } + ], + "markets": [ + { + "backing_inference": "not_inferred", + "base_asset": { + "index": 4, + "symbol": "SYN" + }, + "canonicality": "noncanonical_name", + "context_coin": "SYN-SPOT", + "mark_price": 4.25, + "observed_at": "2026-01-15T12:00:00+00:00", + "pair_name": "SYN-SPOT", + "quote_asset": { + "index": 0, + "symbol": "USDC" + }, + "ready_eligible": false, + "source_refs": [ + "source:synthetic-spot-pair", + "source:synthetic-syn", + "source:synthetic-usdc", + "source:synthetic-spot-context" + ] + } + ], + "pairs": [ + { + "asset_indexes": [ + 4, + 0 + ], + "is_canonical": false, + "name": "SYN-SPOT", + "source_ref": "source:synthetic-spot-pair" + } + ], + "tokens": [ + { + "index": 0, + "source_ref": "source:synthetic-usdc", + "symbol": "USDC" + }, + { + "index": 4, + "source_ref": "source:synthetic-syn", + "symbol": "SYN" + } + ] + }, + "research_ledger": [ { "case_id": "case-rejected-residual", "decision": "rejected", diff --git a/apps/presentation/dashboard/src/views/decision-research-surface.tsx b/apps/presentation/dashboard/src/views/decision-research-surface.tsx index 29eedc9325..9cbccd0591 100644 --- a/apps/presentation/dashboard/src/views/decision-research-surface.tsx +++ b/apps/presentation/dashboard/src/views/decision-research-surface.tsx @@ -9,18 +9,28 @@ type BadgeVariant = "neutral" | "success" | "warning" | "info" | "danger"; const stateVariant: Record = { blocked: "danger", + clear: "success", + complete: "success", + confirmed: "danger", + conflicting: "danger", + declared_only: "warning", + duplicate_upstream: "neutral", empty: "neutral", failed: "danger", insufficient_evidence: "warning", invalid: "danger", + missing: "warning", partial: "warning", pending: "neutral", ready: "success", rejected: "danger", review_due: "warning", selected: "success", + source_error: "danger", supported: "success", superseded: "neutral", + unverified: "warning", + verified: "success", }; const toneVariant: Record = { @@ -116,6 +126,192 @@ function ProvenanceCard({ surface }: { surface: PresentationSurface }) { ); } +export function SourcePeriodMetricsCard({ + metrics, +}: { + metrics: DecisionResearchView["source_period_metrics"]; +}) { + if (!metrics.length) { + return null; + } + + return ( + + +
+ Source-period evidence +

+ Period completeness, calculation basis, and upstream lineage stay + explicit. Missing values are not zero, and these rows never grant + ready status. +

+
+ {metrics.length} +
+ +
+ {metrics.map((metric) => ( +
+
+
+

{metric.label}

+

+ {metric.period_start === metric.period_end + ? metric.period_start + : `${metric.period_start} → ${metric.period_end}`} +

+
+ +
+
+ {metric.value === null + ? "missing (not zero)" + : `${metric.value} ${metric.unit}`} +
+
+ {metric.metric_basis} + {metric.metric_semantics} + {metric.value_origin} + {metric.value_precision} + {metric.observation_authority} + + + +
+
+
+
Component coverage
+
+ {metric.observed_components.length}/{metric.expected_components.length} +
+
+
+
Independent evidence
+
{String(metric.independent_evidence)}
+
+
+
Composite event
+
+ {metric.event_identity.namespace} / {metric.event_identity.source_event_id} + {" · "}{metric.event_identity.instrument_id}{" · "}{metric.event_identity.event_at} +
+
+
+
Signed / account basis
+
+ {metric.sign_basis} · {metric.account_nav_treatment} +
+
+
+
Numerator scope
+
{metric.numerator_scope.join(", ")}
+
+
+
Denominator scope
+
+ {metric.denominator_scope.length + ? metric.denominator_scope.join(", ") + : "none"} +
+
+
+ {metric.missing_components.length ? ( +

+ Missing components: {metric.missing_components.join(", ")} +

+ ) : null} + {metric.double_counted_components.length ? ( +

+ Excluded double-counted components: {metric.double_counted_components.join(", ")} +

+ ) : null} + {metric.gap_reasons.length ? ( +

+ Holds: {metric.gap_reasons.join(", ")} +

+ ) : null} +

+ source: {metric.source_ref} +

+
+ ))} +
+
+
+ ); +} + +export function SpotMarketIdentityCard({ + identity, +}: { + identity: DecisionResearchView["spot_market_identity"]; +}) { + if (!identity?.markets.length) { + return null; + } + + return ( + + +
+ Spot identity joins +

+ Contexts match pair names and assets resolve by explicit index. + Array position and naming canonicality never establish backing. +

+
+ {identity.markets.length} +
+ +
+ {identity.markets.map((market) => ( +
+
+
+

{market.pair_name}

+

+ {market.base_asset.symbol} / {market.quote_asset.symbol} +

+
+ {market.canonicality} +
+

+ {market.mark_price === null ? "missing (not zero)" : market.mark_price} +

+
+
+
Context identity
+
{market.context_coin}
+
+
+
Observed
+
{market.observed_at}
+
+
+
Asset indexes
+
+ {market.base_asset.index} / {market.quote_asset.index} +
+
+
+
Backing inference
+
{market.backing_inference}
+
+
+
+ ))} +
+
+
+ ); +} + export function DecisionResearchSurface({ goals, surface, @@ -306,6 +502,10 @@ export function DecisionResearchSurface({ + + + +
diff --git a/package.json b/package.json index 392ce67e99..755455e6ff 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test:control-plane": "node --no-warnings --experimental-sqlite --experimental-strip-types --test tests/control_plane_ts/*.test.ts", "test:postgresql-authority-service": "node --no-warnings --experimental-strip-types --test tests/control_plane_ts/postgresql_authority_service.test.ts tests/control_plane_ts/postgresql_authority_service.integration.test.ts", "test:control-plane:coverage": "c8 --all --include=loopx/control_plane/**/*.ts --exclude=loopx/control_plane/**/*.generated.ts --reporter=lcov --reporter=text --reports-dir=coverage/control-plane node --no-warnings --experimental-sqlite --experimental-strip-types --test tests/control_plane_ts/*.test.ts", - "test:dashboard:coverage": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --sourceMap --inlineSources --outDir apps/presentation/dashboard/node_modules/.cache/loopx-agent-family apps/presentation/dashboard/src/features/personal-workspace/agent-family.ts && c8 --exclude-node-modules=false --include=apps/presentation/dashboard/node_modules/.cache/loopx-agent-family/agent-family.js --reporter=lcov --reporter=text --reports-dir=coverage/dashboard node apps/presentation/dashboard/src/features/personal-workspace/agent-family.test.mjs", + "test:dashboard:coverage": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --sourceMap --inlineSources --outDir apps/presentation/dashboard/node_modules/.cache/loopx-agent-family apps/presentation/dashboard/src/features/personal-workspace/agent-family.ts && cd apps/presentation/dashboard && npx vite build --ssr smoke/decision-research-surface-coverage.tsx --outDir node_modules/.cache/loopx-decision-research-coverage --emptyOutDir --sourcemap && cd ../../.. && c8 --exclude-node-modules=false --include=apps/presentation/dashboard/node_modules/.cache/loopx-agent-family/agent-family.js --include=apps/presentation/dashboard/node_modules/.cache/loopx-decision-research-coverage/decision-research-surface-coverage.js --reporter=lcov --reporter=text --reports-dir=coverage/dashboard node apps/presentation/dashboard/smoke/dashboard-coverage-runner.mjs", "test:postgresql-authority-store": "node --no-warnings --experimental-sqlite --experimental-strip-types --test tests/control_plane_ts/postgresql_authority_store.integration.test.ts", "typecheck:control-plane": "tsc --project tsconfig.control-plane.json --noEmit" }, diff --git a/packages/loopx-finance-value-discovery/CONTRACT.md b/packages/loopx-finance-value-discovery/CONTRACT.md index 4fbc1d9902..e5d7dd52fe 100644 --- a/packages/loopx-finance-value-discovery/CONTRACT.md +++ b/packages/loopx-finance-value-discovery/CONTRACT.md @@ -140,6 +140,92 @@ No new collector, scheduler, builtin capability, or CLI command is added. Existing `evaluate`, `replay`, and the managed extension entrypoint consume the same input. Replay binds the requirement, receipt metadata and computed report. +### Source-period metric projection / 来源期次指标投影 (extension 0.6.0) + +`finance_research_dashboard_input_v0` may add `source_period_metrics`. This is +an optional Finance-owned read model, not a new Core capability. Each row binds +one metric to a calendar period, an explicit `metric_basis` +(`realized_cash`, `period_estimate`, or `annualized_estimate`), value origin and +precision, numerator/denominator scope, expected and observed components, +double-count exclusions, an upstream lineage id, methodology state, anomaly +state, and a public-safe evidence reference. Each row also carries a composite +event identity: namespace, source event id, event timestamp, instrument and an +anonymous scope id. A source event id or hour is never sufficient on its own. + +The validator derives rather than trusts `coverage_state`, missing components, +lineage deduplication, gap reasons, and readiness. A missing value remains JSON +`null`; it is never rewritten to zero. A non-null value with an omitted child +is `partial`. Source errors cannot carry a value or observed components. +Components marked double-counted must be observed and cannot remain in the +numerator scope. Rows share one upstream evidence chain only when lineage, +composite event identity, period, metric semantics and unit all match. The +primary first prefers a complete, verified and anomaly-clear row, then explicit +observation authority and stable metric id; an available fill-derived VWAP +outranks a rounded position entry instead of relying on row order or a +lexicographic naming accident, while an unavailable fill does not suppress a +usable fallback. Conflicting exact values within +one chain receive a typed `lineage_value_conflict` hold; a rounded fallback may +differ without overriding or falsely invalidating the exact primary. + +Metric semantics keep signed bases separate: account cash change, cumulative +funding cost and fill fee are distinct conventions. A fill fee declares the +builder fee included, preventing a second addition. Account-value rows also +declare scope and role. Only a unified-account row that already includes +isolated margin may be the authoritative NAV total; product/venue rows are +composition or reconciliation evidence, a venue withdrawable amount is +venue-liquidity evidence only, and external-asset coverage is not account NAV. +Units remain exact, so USD and USDC are never silently collapsed. + +The optional `spot_market_identity` section keeps source pairs, token metadata +and contexts in the same validated view. It joins a context only by exact +`context.coin == pair.name`, then resolves both assets through explicit token +indexes. Pair, context and token identities must be unique and complete; +positional zip, unmatched/perpetual context rows and missing token indexes fail +closed. `is_canonical=false` is projected only as a noncanonical naming fact; +it does not infer fraud, missing backing or investment risk. + +Methodology that is only declared, unverified, or conflicting remains visible +as a typed hold. Rounded values and unresolved anomalies remain visible too; +the reducer does not silently correct or promote them. Every row has +`ready_eligible=false` and +`admission_reason=source_period_metric_is_evidence_only`. A complete row can +support later research, but it cannot create a ready candidate, investment +recommendation, order, signature, transfer, or performance claim. + +`finance_research_dashboard_input_v0` 可选携带 +`source_period_metrics`。这是 Finance 所有的只读视图,不是新的 Core +能力。每行把一个指标绑定到明确日历周期,并机器化记录:收益口径 +(实际现金、周期估算或年化估算)、数值来源与精度、分子/分母范围、 +预期与已观察子项、重复计数排除、上游 lineage、方法学状态、异常状态和 +公开安全证据引用。 + +完整性、缺失子项、lineage 去重、hold 原因和 ready 资格都由校验器计算, +不接受调用方自报。缺失值保持 `null`,绝不改写成 0;子项未齐即使已有 +数值也只能是 `partial`;source error 不得携带数值或已观察子项;标记为 +double-counted 的子项必须从分子范围排除。同一周期、同一 lineage 的多层 +封装只有在事件 namespace、source event id、事件时间、instrument、匿名 +scope、指标语义和单位也一致时才算一条上游证据链;不能只用 event hash +或小时去重。主记录按显式 observation authority 选择,fill-derived VWAP +高于 rounded position entry,不再依赖数组顺序或名称字典序。现金变动、 +累计 funding cost 与 fill fee 使用不同 signed basis;fill fee 明确已含 +builder fee。只有已包含 isolated margin 的 unified-account 总值可作为 NAV +owner;产品/场所小计只作 composition/reconciliation,单场所 withdrawable +只说明该场所即时流动性,外部资产覆盖也不是账户 NAV。USD 与 USDC 保持 +不同单位。方法学未互证、页面口径冲突、rounded 数值和 +未解释异常都会保留为 typed hold,不做静默修正。所有行固定 +`ready_eligible=false`,只能服务后续研究,不能自动升级 ready,也不授予 +投资建议、下单、签名、转账或绩效声明权限。 + +可选 `spot_market_identity` 在同一 view 中保存 pair、token metadata 与 +context。context 只能按 `context.coin == pair.name` 精确连接,两侧资产再按 +显式 token index 解析;缺失、重复、未匹配/perp context 与 positional zip +均 fail closed。`is_canonical=false` 只表示命名不是 canonical,不能推导为 +欺诈、无 backing 或投资风险。 + +CLI/managed extension 产生规范 view;Dashboard 解析并显示同一 view; +`render-lark-card` 只从该规范 view 生成 Lark 卡片,不读取 provider 原始 +payload、不发送消息,也不维护第二套口径。 + The replay receipt binds three SHA-256 values: - the normalized contract; @@ -169,3 +255,9 @@ must be replayed with their original contract and implementation version. To disable the entire optional workflow, use `loopx extension disable loopx-finance-value-discovery --execute`; this does not authorize any financial operation or remove evidence already stored by its owner. + +The 0.6.0 `source_period_metrics` view is also opt-in. Inputs that omit it keep +their prior view shape. To roll back this surface, omit the field and republish +the previous projection, or install/enable the prior extension revision through +the existing extension lifecycle. Rendering a Lark card is read-only and does +not change the Goal Channel or send an external message. diff --git a/packages/loopx-finance-value-discovery/README.md b/packages/loopx-finance-value-discovery/README.md index a4afd08a01..2d6f0ea583 100644 --- a/packages/loopx-finance-value-discovery/README.md +++ b/packages/loopx-finance-value-discovery/README.md @@ -74,6 +74,37 @@ recommendation. fully synthetic public-safe example. It deliberately reports zero validated company alpha and an unchanged active method. +Extension 0.6.0 adds the optional `source_period_metrics` section to that same +view. It records calendar-period completeness, realized-versus-estimated basis, +value origin and precision, numerator/denominator scope, component coverage, +double-count exclusions, upstream lineage, methodology verification, and +anomaly state. The validator recomputes coverage and lineage status. Missing is +`null`, not zero; repeated wrappers around one upstream period are not +independent evidence; and every row remains evidence-only with +`ready_eligible=false`. Deduplication uses the full event namespace/id/time, +instrument, anonymous scope, period, semantics and unit. Explicit authority +makes fill-derived VWAP outrank rounded position entry. Signed cash change, +cumulative funding cost and inclusive fill fees stay distinct; unified-account +NAV, venue composition, venue withdrawable and external-asset coverage cannot +be added or relabeled as one another. + +0.6.0 版本在同一规范 view 中增加可选的 `source_period_metrics`:显式记录 +日历周期完整性、实际现金/估算口径、数值来源与精度、分子分母范围、子项 +覆盖、double-count 排除、上游 lineage、方法学互证和异常状态。完整性与 +lineage 去重由校验器重算;缺失保持 `null` 而不是 0;同一上游周期的多层 +封装不算独立证据;复合事件 identity 包含 namespace/id/time、instrument、 +匿名 scope、周期、语义和单位,fill-derived VWAP 显式高于 rounded entry。 +现金变化、累计 funding、已含 builder 的 fill fee,以及 unified-account NAV、 +venue 组成/可取金额、外部资产覆盖均保持不同口径;每行固定 +`ready_eligible=false`,不能自动升级 ready。 + +The same view can carry an optional spot identity join. Contexts are matched by +pair name and assets by explicit token indexes; input order is irrelevant and +missing, duplicate or unmatched identities fail closed. Noncanonical naming is +shown without inferring fraud or backing. 中文:spot context 按 pair name、 +资产按 token index 连接,不做 positional zip;`is_canonical=false` 不被解释 +为欺诈或无 backing。 + After separately installing, enabling, and doctor-validating the extension, publish a validated local projection with: @@ -92,6 +123,22 @@ from status and Dashboard surfaces. Publishing does not install or enable the extension, activate or replace a Finance method, spend LoopX quota, consume a learning queue, create a trade, or place an order. +Render the exact published-view semantics for an authorized Lark delivery +without sending anything: + +```bash +loopx-finance-value-discovery render-lark-card \ + --input-json owner-research.json +``` + +The command returns a card payload only. Existing Goal Channel routing and +message authority still own any external send. Dashboard and Lark both consume +the same validated Finance view; neither reads raw provider material or carries +a separate readiness rule. 中文:该命令只生成卡片、不发消息;外部发送仍需 +既有 Goal Channel 授权。停用可继续使用 +`loopx extension disable loopx-finance-value-discovery --execute`;若只回退 +期次指标,删除可选字段并重新发布旧 view 即可。 + ## Worked Method: How PayPal Surfaced The historical PayPal exercise started with a fresh de-beta scout, not a diff --git a/packages/loopx-finance-value-discovery/extension.toml b/packages/loopx-finance-value-discovery/extension.toml index 341b4b6004..91fb6c56a1 100644 --- a/packages/loopx-finance-value-discovery/extension.toml +++ b/packages/loopx-finance-value-discovery/extension.toml @@ -1,6 +1,6 @@ schema_version = "loopx_extension_manifest_v0" id = "loopx-finance-value-discovery" -version = "0.5.0" +version = "0.6.0" requires_loopx_api = ">=1,<2" permissions = [] diff --git a/packages/loopx-finance-value-discovery/pyproject.toml b/packages/loopx-finance-value-discovery/pyproject.toml index 58423cd6ae..05c2b4e898 100644 --- a/packages/loopx-finance-value-discovery/pyproject.toml +++ b/packages/loopx-finance-value-discovery/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "loopx-finance-value-discovery" -version = "0.5.0" +version = "0.6.0" description = "Public-safe finance value-discovery extension for LoopX." readme = "README.md" requires-python = ">=3.11" diff --git a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py index f1b353c4ad..64b143ce34 100644 --- a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py +++ b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py @@ -121,6 +121,15 @@ def _direct_parser() -> argparse.ArgumentParser: pack_replay_parser.add_argument("--input-json", required=True) pack_replay_parser.add_argument("--expected-json", required=True) sub.add_parser("list-packs", help="List bundled industry metric packs.") + lark_parser = sub.add_parser( + "render-lark-card", + help="Render source-period evidence from the canonical dashboard view.", + ) + lark_parser.add_argument( + "--input-json", + required=True, + help=f"Path to a {FINANCE_RESEARCH_DASHBOARD_INPUT_SCHEMA_VERSION} object.", + ) return parser @@ -182,10 +191,21 @@ def run(argv: Sequence[str] | None = None) -> int: ) elif args.command == "list-packs": packet = list_finance_metric_packs() + elif args.command == "render-lark-card": + from .dashboard import build_finance_research_dashboard_packet + from .lark_projection import build_source_period_metrics_lark_card + + dashboard = build_finance_research_dashboard_packet( + _load_json(args.input_json) + ) + packet = build_source_period_metrics_lark_card( + dashboard["presentation_projection"]["view"] + ) else: raise ValueError( "use --doctor, reduce, evaluate, replay, attribute-beta, " - "replay-beta, evaluate-pack, replay-pack, or list-packs" + "replay-beta, evaluate-pack, replay-pack, list-packs, or " + "render-lark-card" ) except Exception as exc: print(json.dumps(_error_packet(exc), indent=2, sort_keys=True)) diff --git a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/dashboard.py b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/dashboard.py index 5976de4a33..c56199db72 100644 --- a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/dashboard.py +++ b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/dashboard.py @@ -10,6 +10,7 @@ require_presentation_api() from .presentation_view import validate_decision_research_view # noqa: E402 +from .source_coverage import SOURCE_PERIOD_METRIC_FIELDS # noqa: E402 FINANCE_RESEARCH_DASHBOARD_INPUT_SCHEMA_VERSION = "finance_research_dashboard_input_v0" @@ -545,9 +546,7 @@ def _artifacts(value: Any) -> list[dict[str, Any]]: if value is None: return [] rows: list[dict[str, Any]] = [] - for index, item in enumerate( - _list(value, context="input.artifacts", maximum=20) - ): + for index, item in enumerate(_list(value, context="input.artifacts", maximum=20)): context = f"input.artifacts[{index}]" record = _record( item, @@ -734,6 +733,8 @@ def build_finance_research_dashboard_packet( "subtitle", "adjudication", "metrics", + "source_period_metrics", + "spot_market_identity", "dashboard_summaries", "research_layers", "entities", @@ -790,6 +791,23 @@ def build_finance_research_dashboard_packet( minimum=1, maximum=12, ), + **( + { + "source_period_metrics": _simple_records( + record.get("source_period_metrics"), + context="input.source_period_metrics", + allowed=SOURCE_PERIOD_METRIC_FIELDS, + maximum=24, + ) + } + if "source_period_metrics" in record + else {} + ), + **( + {"spot_market_identity": record.get("spot_market_identity")} + if "spot_market_identity" in record + else {} + ), "dashboard_summaries": _simple_records( record.get("dashboard_summaries"), context="input.dashboard_summaries", diff --git a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/lark_projection.py b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/lark_projection.py new file mode 100644 index 0000000000..99a4afd9b9 --- /dev/null +++ b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/lark_projection.py @@ -0,0 +1,131 @@ +"""Lark card projection for the finance-owned source-period view. + +The renderer consumes the same validated ``decision_research_dashboard_v0`` +view as the Dashboard. It does not read provider payloads or define a second +finance schema. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import re +from typing import Any + +from loopx.extensions.lark.presentation.message_card import ( + build_lark_markdown_reply_card, +) + +from .presentation_view import validate_decision_research_view + + +_LARK_MARKDOWN_CONTROL_RE = re.compile(r"([\\`*_{}\[\]!|>~])") + + +def _lark_plain_text(value: object) -> str: + """Keep validated display text from changing the surrounding card markup.""" + + compact = " ".join(str(value).split()) + return _LARK_MARKDOWN_CONTROL_RE.sub(r"\\\1", compact) + + +def _display_value(metric: Mapping[str, Any]) -> str: + value = metric.get("value") + if value is None: + return "missing (not zero)" + if isinstance(value, float): + return f"{value:g} {metric['unit']}" + return f"{value} {metric['unit']}" + + +def render_source_period_metrics_markdown(view: Mapping[str, Any]) -> str: + """Render a bounded source-period summary from the canonical finance view.""" + + validated = validate_decision_research_view(view) + metrics = validated.get("source_period_metrics", []) + spot_markets = validated.get("spot_market_identity", {}).get("markets", []) + if not metrics and not spot_markets: + return ( + "**Source-period evidence**\n\n" + "No source-period metrics were projected. Missing evidence is not zero." + ) + lines = [ + "**Source-period evidence**", + "", + "These rows are evidence-only and never grant ready or trading authority.", + ] + for metric in metrics: + period = ( + metric["period_start"] + if metric["period_start"] == metric["period_end"] + else f"{metric['period_start']} → {metric['period_end']}" + ) + lines.extend( + [ + "", + f"**{_lark_plain_text(metric['label'])}** · `{period}`", + f"- Value: {_lark_plain_text(_display_value(metric))} · basis `{metric['metric_basis']}` / `{metric['metric_semantics']}` · {metric['value_origin']} / {metric['value_precision']}", + f"- Coverage: `{metric['coverage_state']}` · observed {len(metric['observed_components'])}/{len(metric['expected_components'])} · methodology `{metric['methodology_state']}` · anomaly `{metric['anomaly_state']}`", + f"- Event: `{metric['event_identity']['namespace']}` / `{metric['event_identity']['source_event_id']}` · `{metric['event_identity']['instrument_id']}` · `{metric['event_identity']['event_at']}`", + f"- Lineage: `{metric['lineage_state']}` · authority `{metric['observation_authority']}` · independent evidence `{str(metric['independent_evidence']).lower()}` · source {_lark_plain_text(metric['source_ref'])}", + f"- Accounting: sign `{metric['sign_basis']}` · fee `{metric['fee_inclusion']}` · NAV treatment `{metric['account_nav_treatment']}`", + ] + ) + if metric["missing_components"]: + lines.append( + "- Missing components: " + + ", ".join( + _lark_plain_text(component) + for component in metric["missing_components"] + ) + ) + if metric["double_counted_components"]: + lines.append( + "- Excluded double-counted components: " + + ", ".join( + _lark_plain_text(component) + for component in metric["double_counted_components"] + ) + ) + if metric["gap_reasons"]: + lines.append( + "- Holds: " + + ", ".join( + _lark_plain_text(reason) for reason in metric["gap_reasons"] + ) + ) + if spot_markets: + lines.extend( + [ + "", + "**Spot identity joins**", + "Contexts join by `context.coin == pair.name`; assets resolve by explicit index, never array position.", + ] + ) + for market in spot_markets: + price = ( + "missing (not zero)" + if market["mark_price"] is None + else f"{market['mark_price']:g}" + ) + lines.extend( + [ + "", + f"**{_lark_plain_text(market['pair_name'])}** · {_lark_plain_text(market['base_asset']['symbol'])} / {_lark_plain_text(market['quote_asset']['symbol'])}", + f"- Context: {_lark_plain_text(market['context_coin'])} · observed `{market['observed_at']}` · mark `{price}`", + f"- Canonicality: `{market['canonicality']}` · backing `{market['backing_inference']}`", + ] + ) + return "\n".join(lines) + + +def build_source_period_metrics_lark_card( + view: Mapping[str, Any], +) -> dict[str, Any]: + """Build a send-ready card without performing an external message write.""" + + return build_lark_markdown_reply_card( + render_source_period_metrics_markdown(view), + title="Finance source-period evidence", + template="yellow", + footer="LoopX finance projection · evidence only · never auto-ready", + ) diff --git a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/presentation_view.py b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/presentation_view.py index ece843040c..62411eaffa 100644 --- a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/presentation_view.py +++ b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/presentation_view.py @@ -6,6 +6,7 @@ ``decision_research_dashboard_v0``. Publishing a finance surface therefore validates the finance view here, in the extension, rather than in Core. """ + from __future__ import annotations from collections.abc import Mapping @@ -25,6 +26,10 @@ required_text as _required_text, text_list as _text_list, ) +from .source_coverage import ( + validate_source_period_metrics, + validate_spot_market_identity, +) DECISION_RESEARCH_VIEW_SCHEMA_VERSION = "decision_research_dashboard_v0" @@ -72,7 +77,9 @@ def _identity(value: Any) -> dict[str, Any]: allowed={"title", "subtitle", "as_of", "evidence_cutoff"}, ) return { - "title": _required_text(record, "title", context="view.identity", max_length=160), + "title": _required_text( + record, "title", context="view.identity", max_length=160 + ), "subtitle": _required_text( record, "subtitle", @@ -137,8 +144,12 @@ def _metrics(value: Any) -> list[dict[str, Any]]: metrics.append( { "id": _identifier(record.get("id"), context=f"{context}.id"), - "label": _required_text(record, "label", context=context, max_length=80), - "value": _required_text(record, "value", context=context, max_length=120), + "label": _required_text( + record, "label", context=context, max_length=80 + ), + "value": _required_text( + record, "value", context=context, max_length=120 + ), "detail": _required_text( record, "detail", @@ -182,8 +193,12 @@ def _dashboard_summaries(value: Any) -> list[dict[str, Any]]: summaries.append( { "id": _identifier(record.get("id"), context=f"{context}.id"), - "label": _required_text(record, "label", context=context, max_length=80), - "title": _required_text(record, "title", context=context, max_length=160), + "label": _required_text( + record, "label", context=context, max_length=80 + ), + "title": _required_text( + record, "title", context=context, max_length=160 + ), "summary": _required_text( record, "summary", @@ -219,7 +234,9 @@ def _layers(value: Any) -> list[dict[str, Any]]: { "id": _identifier(record.get("id"), context=f"{context}.id"), "order": order, - "label": _required_text(record, "label", context=context, max_length=100), + "label": _required_text( + record, "label", context=context, max_length=100 + ), "status": _enum( record.get("status"), _LAYER_STATES, @@ -289,9 +306,13 @@ def _observations(value: Any, *, entity_context: str) -> list[dict[str, Any]]: observations.append( { "id": _identifier(record.get("id"), context=f"{context}.id"), - "label": _required_text(record, "label", context=context, max_length=100), + "label": _required_text( + record, "label", context=context, max_length=100 + ), "kind": kind, - "value": _required_text(record, "value", context=context, max_length=200), + "value": _required_text( + record, "value", context=context, max_length=200 + ), "as_of": _iso_value(record.get("as_of"), context=f"{context}.as_of"), "source_ref": _evidence_reference( record.get("source_ref"), @@ -368,8 +389,12 @@ def _scenario_estimates( scenarios.append( { "scenario": scenario, - "label": _required_text(record, "label", context=context, max_length=100), - "value": _required_text(record, "value", context=context, max_length=160), + "label": _required_text( + record, "label", context=context, max_length=100 + ), + "value": _required_text( + record, "value", context=context, max_length=160 + ), "horizon": _required_text( record, "horizon", @@ -568,7 +593,9 @@ def _research_ledger(value: Any) -> list[dict[str, Any]]: record.get("case_id"), context=f"{context}.case_id", ), - "label": _required_text(record, "label", context=context, max_length=180), + "label": _required_text( + record, "label", context=context, max_length=180 + ), "gate_states": gate_states, "decision": _enum( record.get("decision"), @@ -688,7 +715,9 @@ def _event_gates(value: Any) -> list[dict[str, Any]]: record.get("event_id"), context=f"{context}.event_id", ), - "label": _required_text(record, "label", context=context, max_length=180), + "label": _required_text( + record, "label", context=context, max_length=180 + ), "status": _enum( record.get("status"), _EVENT_STATES, @@ -820,6 +849,8 @@ def validate_decision_research_view( "identity", "adjudication", "metrics", + "source_period_metrics", + "spot_market_identity", "dashboard_summaries", "layers", "entities", @@ -830,13 +861,11 @@ def validate_decision_research_view( "boundary", }, ) - return { + result = { "identity": _identity(record.get("identity")), "adjudication": _adjudication(record.get("adjudication")), "metrics": _metrics(record.get("metrics")), - "dashboard_summaries": _dashboard_summaries( - record.get("dashboard_summaries") - ), + "dashboard_summaries": _dashboard_summaries(record.get("dashboard_summaries")), "layers": _layers(record.get("layers")), "entities": _entities(record.get("entities")), "research_ledger": _research_ledger(record.get("research_ledger")), @@ -845,3 +874,12 @@ def validate_decision_research_view( "method_state": _method_state(record.get("method_state")), "boundary": _boundary(record.get("boundary")), } + if "source_period_metrics" in record: + result["source_period_metrics"] = validate_source_period_metrics( + record.get("source_period_metrics") + ) + if "spot_market_identity" in record: + result["spot_market_identity"] = validate_spot_market_identity( + record.get("spot_market_identity") + ) + return result diff --git a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/source_coverage.py b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/source_coverage.py index bcfea9d61a..58bacd008f 100644 --- a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/source_coverage.py +++ b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/source_coverage.py @@ -15,10 +15,25 @@ from typing import Any from .boundary import reject_forbidden_material +from .presentation_validation import ( + boolean, + bounded_list, + enum, + evidence_reference, + identifier, + iso_value, + number, + plain_text, + record, +) MAX_PAGES = 128 MAX_ROWS_PER_PAGE = 500 MAX_ROWS = 5000 +MAX_PERIOD_METRICS = 24 +MAX_METRIC_COMPONENTS = 32 +MAX_SPOT_MARKETS = 64 +MAX_SPOT_TOKENS = 128 PAGE_FIELDS = { "request_identity", "page_number", @@ -32,6 +47,65 @@ "snapshot_evidence_ref", } ROW_FIELDS = {"id", "market", "content_sha256", "publication_at"} +SOURCE_PERIOD_METRIC_FIELDS = { + "metric_id", + "label", + "event_namespace", + "event_id", + "event_at", + "instrument_id", + "scope_id", + "period_start", + "period_end", + "source_state", + "value", + "unit", + "metric_basis", + "metric_semantics", + "value_origin", + "value_precision", + "observation_authority", + "sign_basis", + "fee_inclusion", + "account_scope", + "account_value_role", + "includes_isolated_margin", + "expected_components", + "observed_components", + "double_counted_components", + "numerator_scope", + "denominator_scope", + "lineage_id", + "source_ref", + "methodology_state", + "anomaly_state", +} +SOURCE_PERIOD_METRIC_DERIVED_FIELDS = { + "event_identity", + "coverage_state", + "missing_components", + "lineage_state", + "duplicate_of", + "independent_evidence", + "gap_reasons", + "account_nav_treatment", + "ready_eligible", + "admission_reason", +} + +_OBSERVATION_AUTHORITY_RANK = { + "fill_vwap": 0, + "source_reported_exact": 1, + "derived_exact": 2, + "source_reported_rounded": 3, + "rounded_position_entry": 4, + "derived_rounded": 5, +} + +SPOT_MARKET_IDENTITY_FIELDS = {"pairs", "tokens", "contexts", "markets"} +SPOT_PAIR_FIELDS = {"name", "asset_indexes", "is_canonical", "source_ref"} +SPOT_TOKEN_FIELDS = {"index", "symbol", "source_ref"} +SPOT_CONTEXT_FIELDS = {"coin", "observed_at", "mark_price", "source_ref"} class CoverageState(StrEnum): @@ -41,6 +115,605 @@ class CoverageState(StrEnum): SOURCE_ERROR = "source_error" +def _period_date(value: object, *, context: str) -> str: + result = iso_value(value, context=context) + if "T" in result: + raise ValueError(f"{context} must be an ISO-8601 calendar date") + return result + + +def _identifier_list( + value: object, + *, + context: str, + minimum: int = 0, +) -> list[str]: + items = [ + identifier(item, context=f"{context}[{index}]") + for index, item in enumerate( + bounded_list( + value, + context=context, + minimum=minimum, + maximum=MAX_METRIC_COMPONENTS, + ) + ) + ] + if len(items) != len(set(items)): + raise ValueError(f"{context} must not contain duplicate identifiers") + return items + + +def _source_period_metric(value: object, *, index: int) -> dict[str, Any]: + context = f"source_period_metrics[{index}]" + item = record( + value, + context=context, + allowed=SOURCE_PERIOD_METRIC_FIELDS | SOURCE_PERIOD_METRIC_DERIVED_FIELDS, + ) + period_start = _period_date( + item.get("period_start"), context=f"{context}.period_start" + ) + period_end = _period_date(item.get("period_end"), context=f"{context}.period_end") + if period_start > period_end: + raise ValueError(f"{context} period_start must not be after period_end") + expected = _identifier_list( + item.get("expected_components"), + context=f"{context}.expected_components", + minimum=1, + ) + observed = _identifier_list( + item.get("observed_components"), + context=f"{context}.observed_components", + ) + double_counted = _identifier_list( + item.get("double_counted_components"), + context=f"{context}.double_counted_components", + ) + numerator_scope = _identifier_list( + item.get("numerator_scope"), + context=f"{context}.numerator_scope", + ) + denominator_scope = _identifier_list( + item.get("denominator_scope"), + context=f"{context}.denominator_scope", + ) + expected_set, observed_set = set(expected), set(observed) + if not observed_set <= expected_set: + raise ValueError(f"{context}.observed_components must be expected") + if not set(double_counted) <= observed_set: + raise ValueError(f"{context}.double_counted_components must be observed") + if set(double_counted) & set(numerator_scope): + raise ValueError( + f"{context}.numerator_scope must exclude double-counted components" + ) + source_state = enum( + item.get("source_state"), + {"ok", "error"}, + context=f"{context}.source_state", + ) + metric_value = ( + None + if item.get("value") is None + else number(item.get("value"), context=f"{context}.value") + ) + if metric_value is not None and not numerator_scope: + raise ValueError(f"{context}.numerator_scope is required with a value") + if source_state == "ok" and not set(numerator_scope) <= observed_set: + raise ValueError(f"{context}.numerator_scope must be observed") + if source_state == "error" and (metric_value is not None or observed): + raise ValueError( + f"{context} source_error cannot carry a value or observed components" + ) + metric_semantics = enum( + item.get("metric_semantics"), + { + "generic", + "entry_price", + "cash_delta", + "cumulative_funding_cost", + "fill_fee", + "account_nav", + "account_component", + "withdrawable", + "external_asset_coverage", + }, + context=f"{context}.metric_semantics", + ) + value_origin = enum( + item.get("value_origin"), + {"source_reported", "derived"}, + context=f"{context}.value_origin", + ) + value_precision = enum( + item.get("value_precision"), + {"exact", "rounded"}, + context=f"{context}.value_precision", + ) + observation_authority = enum( + item.get("observation_authority"), + set(_OBSERVATION_AUTHORITY_RANK), + context=f"{context}.observation_authority", + ) + authority_shape = { + "source_reported_exact": ("source_reported", "exact"), + "source_reported_rounded": ("source_reported", "rounded"), + "derived_exact": ("derived", "exact"), + "derived_rounded": ("derived", "rounded"), + "fill_vwap": ("derived", "exact"), + "rounded_position_entry": ("source_reported", "rounded"), + }[observation_authority] + if (value_origin, value_precision) != authority_shape: + raise ValueError( + f"{context}.observation_authority conflicts with value origin/precision" + ) + if observation_authority in {"fill_vwap", "rounded_position_entry"} and ( + metric_semantics != "entry_price" + ): + raise ValueError( + f"{context}.{observation_authority} requires entry_price semantics" + ) + + sign_basis = enum( + item.get("sign_basis"), + {"not_signed", "account_cash_change", "funding_cost", "fee_cost"}, + context=f"{context}.sign_basis", + ) + required_sign_basis = { + "cash_delta": "account_cash_change", + "cumulative_funding_cost": "funding_cost", + "fill_fee": "fee_cost", + }.get(metric_semantics, "not_signed") + if sign_basis != required_sign_basis: + raise ValueError( + f"{context}.sign_basis must be {required_sign_basis} for {metric_semantics}" + ) + + fee_inclusion = enum( + item.get("fee_inclusion"), + {"not_applicable", "builder_included"}, + context=f"{context}.fee_inclusion", + ) + required_fee_inclusion = ( + "builder_included" if metric_semantics == "fill_fee" else "not_applicable" + ) + if fee_inclusion != required_fee_inclusion: + raise ValueError( + f"{context}.fee_inclusion must be {required_fee_inclusion} for " + f"{metric_semantics}" + ) + + account_scope = enum( + item.get("account_scope"), + {"not_applicable", "product", "venue", "unified_account", "external_asset"}, + context=f"{context}.account_scope", + ) + account_value_role = enum( + item.get("account_value_role"), + { + "not_applicable", + "nav_owner", + "composition", + "reconciliation", + "withdrawable", + "coverage", + }, + context=f"{context}.account_value_role", + ) + includes_isolated_margin = boolean( + item.get("includes_isolated_margin"), + context=f"{context}.includes_isolated_margin", + ) + account_shape = { + "account_nav": ({"unified_account"}, {"nav_owner"}), + "account_component": ( + {"product", "venue"}, + {"composition", "reconciliation"}, + ), + "withdrawable": ({"venue"}, {"withdrawable"}), + "external_asset_coverage": ({"external_asset"}, {"coverage"}), + }.get(metric_semantics, ({"not_applicable"}, {"not_applicable"})) + if ( + account_scope not in account_shape[0] + or account_value_role not in account_shape[1] + ): + raise ValueError( + f"{context} account scope/role conflicts with {metric_semantics} semantics" + ) + if metric_semantics == "account_nav" and not includes_isolated_margin: + raise ValueError( + f"{context}.account_nav must declare that isolated margin is included" + ) + if metric_semantics not in {"account_nav", "account_component"} and ( + includes_isolated_margin + ): + raise ValueError( + f"{context}.includes_isolated_margin is invalid for {metric_semantics}" + ) + + event_at = iso_value( + item.get("event_at"), + context=f"{context}.event_at", + date_only_allowed=False, + ) + return { + "metric_id": identifier(item.get("metric_id"), context=f"{context}.metric_id"), + "label": plain_text( + item.get("label"), context=f"{context}.label", max_length=120 + ), + "event_namespace": identifier( + item.get("event_namespace"), context=f"{context}.event_namespace" + ), + "event_id": identifier(item.get("event_id"), context=f"{context}.event_id"), + "event_at": event_at, + "instrument_id": identifier( + item.get("instrument_id"), context=f"{context}.instrument_id" + ), + "scope_id": identifier(item.get("scope_id"), context=f"{context}.scope_id"), + "period_start": period_start, + "period_end": period_end, + "source_state": source_state, + "value": metric_value, + "unit": plain_text(item.get("unit"), context=f"{context}.unit", max_length=40), + "metric_basis": enum( + item.get("metric_basis"), + {"realized_cash", "period_estimate", "annualized_estimate"}, + context=f"{context}.metric_basis", + ), + "metric_semantics": metric_semantics, + "value_origin": value_origin, + "value_precision": value_precision, + "observation_authority": observation_authority, + "sign_basis": sign_basis, + "fee_inclusion": fee_inclusion, + "account_scope": account_scope, + "account_value_role": account_value_role, + "includes_isolated_margin": includes_isolated_margin, + "expected_components": expected, + "observed_components": observed, + "double_counted_components": double_counted, + "numerator_scope": numerator_scope, + "denominator_scope": denominator_scope, + "lineage_id": identifier( + item.get("lineage_id"), context=f"{context}.lineage_id" + ), + "source_ref": evidence_reference( + item.get("source_ref"), context=f"{context}.source_ref" + ), + "methodology_state": enum( + item.get("methodology_state"), + {"verified", "declared_only", "unverified", "conflicting"}, + context=f"{context}.methodology_state", + ), + "anomaly_state": enum( + item.get("anomaly_state"), + {"clear", "unverified", "confirmed"}, + context=f"{context}.anomaly_state", + ), + } + + +def validate_source_period_metrics(value: object) -> list[dict[str, Any]]: + """Normalize period metrics without turning source observations into readiness.""" + + metrics = [ + _source_period_metric(item, index=index) + for index, item in enumerate( + bounded_list( + value, + context="source_period_metrics", + maximum=MAX_PERIOD_METRICS, + ) + ) + ] + metric_ids = [str(item["metric_id"]) for item in metrics] + if len(metric_ids) != len(set(metric_ids)): + raise ValueError("source_period_metrics metric_id values must be unique") + primary_by_lineage: dict[tuple[str, ...], str] = {} + exact_values_by_lineage: dict[tuple[str, ...], set[float]] = {} + + def primary_rank(row: Mapping[str, Any]) -> tuple[int, int, int, int, str]: + missing_count = len( + set(row["expected_components"]) - set(row["observed_components"]) + ) + coverage_rank = ( + 3 + if row["source_state"] == "error" + else 2 + if row["value"] is None + else 1 + if missing_count + else 0 + ) + return ( + coverage_rank, + 0 if row["methodology_state"] == "verified" else 1, + 0 if row["anomaly_state"] == "clear" else 1, + _OBSERVATION_AUTHORITY_RANK[str(row["observation_authority"])], + str(row["metric_id"]), + ) + + for item in sorted(metrics, key=primary_rank): + key = ( + str(item["lineage_id"]), + str(item["event_namespace"]), + str(item["event_id"]), + str(item["event_at"]), + str(item["instrument_id"]), + str(item["scope_id"]), + str(item["period_start"]), + str(item["period_end"]), + str(item["metric_semantics"]), + str(item["unit"]), + ) + primary_by_lineage.setdefault(key, str(item["metric_id"])) + if item["value"] is not None and item["value_precision"] == "exact": + exact_values_by_lineage.setdefault(key, set()).add(float(item["value"])) + + normalized: list[dict[str, Any]] = [] + for item in metrics: + missing = sorted( + set(item["expected_components"]) - set(item["observed_components"]) + ) + coverage_state = ( + "source_error" + if item["source_state"] == "error" + else "missing" + if item["value"] is None + else "partial" + if missing + else "complete" + ) + lineage_key = ( + str(item["lineage_id"]), + str(item["event_namespace"]), + str(item["event_id"]), + str(item["event_at"]), + str(item["instrument_id"]), + str(item["scope_id"]), + str(item["period_start"]), + str(item["period_end"]), + str(item["metric_semantics"]), + str(item["unit"]), + ) + primary_id = primary_by_lineage[lineage_key] + duplicate_of = None if primary_id == item["metric_id"] else primary_id + gaps: list[str] = [] + if coverage_state != "complete": + gaps.append(coverage_state) + gaps.extend(f"missing_component:{component}" for component in missing) + if item["methodology_state"] != "verified": + gaps.append(f"methodology:{item['methodology_state']}") + if item["anomaly_state"] != "clear": + gaps.append(f"anomaly:{item['anomaly_state']}") + if item["value_precision"] == "rounded": + gaps.append("rounded_value") + if duplicate_of is not None: + gaps.append("duplicate_upstream") + if len(exact_values_by_lineage.get(lineage_key, set())) > 1: + gaps.append("lineage_value_conflict") + account_nav_treatment = { + "nav_owner": "authoritative_total", + "composition": "composition_only", + "reconciliation": "reconciliation_only", + "withdrawable": "venue_liquidity_only", + "coverage": "external_asset_coverage_only", + "not_applicable": "not_account_value", + }[str(item["account_value_role"])] + normalized.append( + { + **item, + "event_identity": { + "namespace": item["event_namespace"], + "source_event_id": item["event_id"], + "event_at": item["event_at"], + "instrument_id": item["instrument_id"], + "scope_id": item["scope_id"], + }, + "coverage_state": coverage_state, + "missing_components": missing, + "lineage_state": ( + "primary" if duplicate_of is None else "duplicate_upstream" + ), + "duplicate_of": duplicate_of, + "independent_evidence": duplicate_of is None, + "gap_reasons": gaps, + "account_nav_treatment": account_nav_treatment, + "ready_eligible": False, + "admission_reason": "source_period_metric_is_evidence_only", + } + ) + return normalized + + +def _nonnegative_integer(value: object, *, context: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"{context} must be a non-negative integer") + return value + + +def validate_spot_market_identity(value: object) -> dict[str, Any]: + """Join spot contexts by pair identity and tokens by explicit indexes.""" + + item = record( + value, + context="spot_market_identity", + allowed=SPOT_MARKET_IDENTITY_FIELDS, + ) + tokens: list[dict[str, Any]] = [] + for index, raw_token in enumerate( + bounded_list( + item.get("tokens"), + context="spot_market_identity.tokens", + maximum=MAX_SPOT_TOKENS, + ) + ): + context = f"spot_market_identity.tokens[{index}]" + token = record(raw_token, context=context, allowed=SPOT_TOKEN_FIELDS) + tokens.append( + { + "index": _nonnegative_integer( + token.get("index"), context=f"{context}.index" + ), + "symbol": plain_text( + token.get("symbol"), context=f"{context}.symbol", max_length=40 + ), + "source_ref": evidence_reference( + token.get("source_ref"), context=f"{context}.source_ref" + ), + } + ) + token_indexes = [token["index"] for token in tokens] + if len(token_indexes) != len(set(token_indexes)): + raise ValueError("spot_market_identity.tokens index values must be unique") + token_by_index = {token["index"]: token for token in tokens} + + contexts: list[dict[str, Any]] = [] + for index, raw_context in enumerate( + bounded_list( + item.get("contexts"), + context="spot_market_identity.contexts", + maximum=MAX_SPOT_MARKETS, + ) + ): + context = f"spot_market_identity.contexts[{index}]" + context_item = record( + raw_context, + context=context, + allowed=SPOT_CONTEXT_FIELDS, + ) + mark_price = ( + None + if context_item.get("mark_price") is None + else number( + context_item.get("mark_price"), + context=f"{context}.mark_price", + ) + ) + if mark_price is not None and mark_price <= 0: + raise ValueError(f"{context}.mark_price must be positive or null") + contexts.append( + { + "coin": plain_text( + context_item.get("coin"), + context=f"{context}.coin", + max_length=80, + ), + "observed_at": iso_value( + context_item.get("observed_at"), + context=f"{context}.observed_at", + date_only_allowed=False, + ), + "mark_price": mark_price, + "source_ref": evidence_reference( + context_item.get("source_ref"), + context=f"{context}.source_ref", + ), + } + ) + context_names = [context["coin"] for context in contexts] + if len(context_names) != len(set(context_names)): + raise ValueError("spot_market_identity.contexts coin values must be unique") + context_by_coin = {context["coin"]: context for context in contexts} + + pairs: list[dict[str, Any]] = [] + for index, raw_pair in enumerate( + bounded_list( + item.get("pairs"), + context="spot_market_identity.pairs", + maximum=MAX_SPOT_MARKETS, + ) + ): + context = f"spot_market_identity.pairs[{index}]" + pair = record(raw_pair, context=context, allowed=SPOT_PAIR_FIELDS) + asset_indexes = [ + _nonnegative_integer( + asset_index, + context=f"{context}.asset_indexes[{asset_position}]", + ) + for asset_position, asset_index in enumerate( + bounded_list( + pair.get("asset_indexes"), + context=f"{context}.asset_indexes", + minimum=2, + maximum=2, + ) + ) + ] + if asset_indexes[0] == asset_indexes[1]: + raise ValueError(f"{context}.asset_indexes must identify two assets") + pairs.append( + { + "name": plain_text( + pair.get("name"), context=f"{context}.name", max_length=80 + ), + "asset_indexes": asset_indexes, + "is_canonical": boolean( + pair.get("is_canonical"), context=f"{context}.is_canonical" + ), + "source_ref": evidence_reference( + pair.get("source_ref"), context=f"{context}.source_ref" + ), + } + ) + pair_names = [pair["name"] for pair in pairs] + if len(pair_names) != len(set(pair_names)): + raise ValueError("spot_market_identity.pairs name values must be unique") + missing_contexts = sorted(set(pair_names) - set(context_names)) + extra_contexts = sorted(set(context_names) - set(pair_names)) + if missing_contexts or extra_contexts: + raise ValueError( + "spot_market_identity requires exact pair.name/context.coin identity; " + f"missing={missing_contexts}, unmatched={extra_contexts}" + ) + + markets: list[dict[str, Any]] = [] + for pair in pairs: + missing_indexes = [ + asset_index + for asset_index in pair["asset_indexes"] + if asset_index not in token_by_index + ] + if missing_indexes: + raise ValueError( + "spot_market_identity pair asset indexes are missing from tokens: " + f"{missing_indexes}" + ) + base = token_by_index[pair["asset_indexes"][0]] + quote = token_by_index[pair["asset_indexes"][1]] + market_context = context_by_coin[pair["name"]] + markets.append( + { + "pair_name": pair["name"], + "context_coin": market_context["coin"], + "base_asset": {"index": base["index"], "symbol": base["symbol"]}, + "quote_asset": { + "index": quote["index"], + "symbol": quote["symbol"], + }, + "observed_at": market_context["observed_at"], + "mark_price": market_context["mark_price"], + "canonicality": ( + "canonical_name" if pair["is_canonical"] else "noncanonical_name" + ), + "backing_inference": "not_inferred", + "source_refs": [ + pair["source_ref"], + base["source_ref"], + quote["source_ref"], + market_context["source_ref"], + ], + "ready_eligible": False, + } + ) + return { + "pairs": pairs, + "tokens": tokens, + "contexts": contexts, + "markets": markets, + } + + def timestamp(value: object) -> datetime: if not isinstance(value, str) or len(value) > 40 or "T" not in value: raise ValueError("coverage timestamps require ISO datetime with timezone") diff --git a/tests/extensions/test_finance_presentation_view.py b/tests/extensions/test_finance_presentation_view.py index 742f88896f..da27b289ff 100644 --- a/tests/extensions/test_finance_presentation_view.py +++ b/tests/extensions/test_finance_presentation_view.py @@ -14,6 +14,9 @@ DECISION_RESEARCH_VIEW_SCHEMA_VERSION, validate_decision_research_view, ) +from loopx_finance_value_discovery.lark_projection import ( # noqa: E402 + build_source_period_metrics_lark_card, +) def _valid_view() -> dict[str, object]: @@ -231,6 +234,144 @@ def test_finance_view_schema_version_is_stable() -> None: assert DECISION_RESEARCH_VIEW_SCHEMA_VERSION == "decision_research_dashboard_v0" +def _source_period_metric() -> dict[str, object]: + return { + "metric_id": "synthetic-parent-fees", + "label": "Synthetic parent fees", + "event_namespace": "synthetic.period.metric", + "event_id": "parent-fees-20260115", + "event_at": "2026-01-15T12:00:00Z", + "instrument_id": "SYNTH-USD", + "scope_id": "synthetic-scope", + "period_start": "2026-01-15", + "period_end": "2026-01-15", + "source_state": "ok", + "value": 15.0, + "unit": "USD", + "metric_basis": "period_estimate", + "metric_semantics": "generic", + "value_origin": "source_reported", + "value_precision": "rounded", + "observation_authority": "source_reported_rounded", + "sign_basis": "not_signed", + "fee_inclusion": "not_applicable", + "account_scope": "not_applicable", + "account_value_role": "not_applicable", + "includes_isolated_margin": False, + "expected_components": ["primary", "secondary"], + "observed_components": ["primary"], + "double_counted_components": [], + "numerator_scope": ["primary"], + "denominator_scope": [], + "lineage_id": "synthetic-upstream-day", + "source_ref": "source:synthetic-parent-day", + "methodology_state": "declared_only", + "anomaly_state": "unverified", + } + + +def _spot_market_identity() -> dict[str, object]: + return { + "pairs": [ + { + "name": "SYNTH-SPOT", + "asset_indexes": [3, 0], + "is_canonical": False, + "source_ref": "source:synthetic-spot-pair", + } + ], + "tokens": [ + {"index": 0, "symbol": "USDC", "source_ref": "source:synthetic-usdc"}, + {"index": 3, "symbol": "SYN", "source_ref": "source:synthetic-syn"}, + ], + "contexts": [ + { + "coin": "SYNTH-SPOT", + "observed_at": "2026-01-15T12:00:00Z", + "mark_price": None, + "source_ref": "source:synthetic-spot-context", + } + ], + } + + +def test_decision_research_view_projects_period_metrics_idempotently() -> None: + view = _valid_view() + view["source_period_metrics"] = [_source_period_metric()] + + validated = validate_decision_research_view(view) + metric = validated["source_period_metrics"][0] + assert metric["coverage_state"] == "partial" + assert metric["missing_components"] == ["secondary"] + assert metric["value"] == 15.0 + assert metric["ready_eligible"] is False + assert validate_decision_research_view(validated) == validated + + +def test_lark_card_consumes_the_same_period_metric_projection() -> None: + view = _valid_view() + view["source_period_metrics"] = [_source_period_metric()] + + card = build_source_period_metrics_lark_card(view) + markdown = card["elements"][0]["text"]["content"] + + assert card["header"]["title"]["content"] == ("Finance source-period evidence") + assert "missing (not zero)" not in markdown + assert "Coverage: `partial`" in markdown + assert "Missing components: secondary" in markdown + assert "never grant ready" in markdown + + +def test_lark_card_calls_missing_period_evidence_missing_not_zero() -> None: + view = _valid_view() + metric = _source_period_metric() + metric.update(value=None, observed_components=[], numerator_scope=[]) + view["source_period_metrics"] = [metric] + + card = build_source_period_metrics_lark_card(view) + markdown = card["elements"][0]["text"]["content"] + + assert "missing (not zero)" in markdown + + +def test_lark_card_escapes_dynamic_markdown_without_changing_the_view() -> None: + view = _valid_view() + metric = _source_period_metric() + metric["label"] = "Synthetic\n**READY** [approve](https://example.com)" + metric["unit"] = "USD_`spoof`" + view["source_period_metrics"] = [metric] + + validated = validate_decision_research_view(view) + card = build_source_period_metrics_lark_card(view) + markdown = card["elements"][0]["text"]["content"] + + assert validated["source_period_metrics"][0]["label"] == metric["label"] + assert "\n**READY**" not in markdown + assert "\\*\\*READY\\*\\*" in markdown + assert "[approve](https://example.com)" not in markdown + assert "USD\\_\\`spoof\\`" in markdown + + +def test_dashboard_view_and_lark_share_spot_identity_join() -> None: + view = _valid_view() + view["spot_market_identity"] = _spot_market_identity() + + validated = validate_decision_research_view(view) + market = validated["spot_market_identity"]["markets"][0] + assert market["base_asset"] == {"index": 3, "symbol": "SYN"} + assert market["quote_asset"] == {"index": 0, "symbol": "USDC"} + assert market["canonicality"] == "noncanonical_name" + assert market["backing_inference"] == "not_inferred" + assert validate_decision_research_view(validated) == validated + + card = build_source_period_metrics_lark_card(view) + markdown = card["elements"][0]["text"]["content"] + assert "Spot identity joins" in markdown + assert "SYN / USDC" in markdown + assert "missing (not zero)" in markdown + assert "noncanonical_name" in markdown + + def test_decision_research_view_preserves_strict_research_truth() -> None: validated = validate_decision_research_view(_valid_view()) @@ -243,7 +384,9 @@ def test_decision_research_view_preserves_strict_research_truth() -> None: assert validated["artifacts"][0]["artifact_ref"] == ( "artifact:synthetic-research-packet" ) - assert [item["probability"] for item in validated["entities"][0]["scenario_estimates"]] == [ + assert [ + item["probability"] for item in validated["entities"][0]["scenario_estimates"] + ] == [ 0.25, 0.5, 0.25, @@ -290,15 +433,11 @@ def test_decision_research_view_defaults_missing_artifacts_to_empty() -> None: "sum to 1", ), ( - lambda view: view["entities"][0].update( - {"counterevidence": []} - ), + lambda view: view["entities"][0].update({"counterevidence": []}), "counterevidence", ), ( - lambda view: view["entities"][0].update( - {"thesis_breakers": []} - ), + lambda view: view["entities"][0].update({"thesis_breakers": []}), "thesis_breakers", ), ( @@ -344,9 +483,7 @@ def test_decision_research_view_defaults_missing_artifacts_to_empty() -> None: "local path", ), ( - lambda view: view["layers"][0].update( - {"raw_provider_response": "secret"} - ), + lambda view: view["layers"][0].update({"raw_provider_response": "secret"}), "forbidden key", ), ( diff --git a/tests/extensions/test_finance_source_coverage.py b/tests/extensions/test_finance_source_coverage.py index 2952d35987..ca5d45ac08 100644 --- a/tests/extensions/test_finance_source_coverage.py +++ b/tests/extensions/test_finance_source_coverage.py @@ -22,8 +22,11 @@ ) from loopx_finance_value_discovery.source_coverage import ( # noqa: E402 MAX_PAGES, + MAX_PERIOD_METRICS, MAX_ROWS, MAX_ROWS_PER_PAGE, + validate_source_period_metrics, + validate_spot_market_identity, ) @@ -414,3 +417,421 @@ def test_offsets_compare_as_instants_and_date_only_cutoff_is_midnight(): payload["contract"]["evaluation_as_of"] = "2026-01-02" with pytest.raises(ValueError, match="observation_clock"): result(payload) + + +def _period_metric(metric_id: str = "synthetic-fee-value") -> dict[str, object]: + return { + "metric_id": metric_id, + "label": "Synthetic fee value", + "event_namespace": "synthetic.period.metric", + "event_id": "event-2026w01", + "event_at": "2026-01-07T23:00:00Z", + "instrument_id": "SYNTH-USD", + "scope_id": "synthetic-scope", + "period_start": "2026-01-01", + "period_end": "2026-01-07", + "source_state": "ok", + "value": 125.0, + "unit": "USD", + "metric_basis": "period_estimate", + "metric_semantics": "generic", + "value_origin": "derived", + "value_precision": "exact", + "observation_authority": "derived_exact", + "sign_basis": "not_signed", + "fee_inclusion": "not_applicable", + "account_scope": "not_applicable", + "account_value_role": "not_applicable", + "includes_isolated_margin": False, + "expected_components": ["core", "secondary"], + "observed_components": ["core", "secondary"], + "double_counted_components": [], + "numerator_scope": ["core", "secondary"], + "denominator_scope": [], + "lineage_id": "upstream-fee-period-2026w01", + "source_ref": "source:synthetic-fee-period", + "methodology_state": "verified", + "anomaly_state": "clear", + } + + +def test_period_metric_distinguishes_complete_partial_missing_and_zero() -> None: + complete = validate_source_period_metrics([_period_metric()])[0] + assert complete["coverage_state"] == "complete" + assert complete["value"] == 125.0 + assert complete["ready_eligible"] is False + + partial_input = _period_metric() + partial_input["observed_components"] = ["core"] + partial_input["numerator_scope"] = ["core"] + partial = validate_source_period_metrics([partial_input])[0] + assert partial["coverage_state"] == "partial" + assert partial["missing_components"] == ["secondary"] + assert partial["value"] == 125.0 + + missing_input = _period_metric() + missing_input["value"] = None + missing_input["observed_components"] = [] + missing_input["numerator_scope"] = [] + missing = validate_source_period_metrics([missing_input])[0] + assert missing["coverage_state"] == "missing" + assert missing["value"] is None + + zero_input = _period_metric() + zero_input["value"] = 0 + zero = validate_source_period_metrics([zero_input])[0] + assert zero["coverage_state"] == "complete" + assert zero["value"] == 0.0 + + +def test_period_metric_uses_explicit_authority_for_lineage_deduplication() -> None: + primary = _period_metric("z-fill-vwap") + primary["metric_semantics"] = "entry_price" + primary["observation_authority"] = "fill_vwap" + primary["double_counted_components"] = ["secondary"] + primary["numerator_scope"] = ["core"] + duplicate = deepcopy(primary) + duplicate.update( + metric_id="a-rounded-position-entry", + value_origin="source_reported", + value_precision="rounded", + observation_authority="rounded_position_entry", + ) + + projected = validate_source_period_metrics([duplicate, primary]) + by_id = {item["metric_id"]: item for item in projected} + assert by_id["z-fill-vwap"]["lineage_state"] == "primary" + assert by_id["z-fill-vwap"]["independent_evidence"] is True + assert by_id["a-rounded-position-entry"]["lineage_state"] == ("duplicate_upstream") + assert by_id["a-rounded-position-entry"]["duplicate_of"] == "z-fill-vwap" + assert "duplicate_upstream" in by_id["a-rounded-position-entry"]["gap_reasons"] + assert by_id["z-fill-vwap"]["double_counted_components"] == ["secondary"] + + next_period = _period_metric("next-period") + next_period["period_start"] = "2026-01-08" + next_period["period_end"] = "2026-01-14" + assert ( + validate_source_period_metrics([primary, next_period])[1][ + "independent_evidence" + ] + is True + ) + + +def test_period_metric_composite_identity_separates_reused_event_ids() -> None: + first = _period_metric("first-asset") + second = deepcopy(first) + second.update(metric_id="second-asset", instrument_id="OTHER-USD") + third = deepcopy(first) + third.update(metric_id="next-hour", event_at="2026-01-08T00:00:00Z") + + projected = validate_source_period_metrics([first, second, third]) + assert all(item["independent_evidence"] for item in projected) + assert projected[0]["event_identity"] == { + "namespace": "synthetic.period.metric", + "source_event_id": "event-2026w01", + "event_at": "2026-01-07T23:00:00Z", + "instrument_id": "SYNTH-USD", + "scope_id": "synthetic-scope", + } + + +def test_period_metric_prefers_usable_fallback_over_unavailable_authority() -> None: + unavailable_fill = _period_metric("fill-vwap-unavailable") + unavailable_fill.update( + metric_semantics="entry_price", + observation_authority="fill_vwap", + source_state="error", + value=None, + observed_components=[], + numerator_scope=[], + ) + rounded = _period_metric("rounded-position-available") + rounded.update( + metric_semantics="entry_price", + value_origin="source_reported", + value_precision="rounded", + observation_authority="rounded_position_entry", + ) + + by_id = { + item["metric_id"]: item + for item in validate_source_period_metrics([unavailable_fill, rounded]) + } + assert by_id["rounded-position-available"]["lineage_state"] == "primary" + assert by_id["fill-vwap-unavailable"]["duplicate_of"] == ( + "rounded-position-available" + ) + + +def test_period_metric_holds_conflicting_exact_values_but_not_rounded_fallback() -> ( + None +): + primary = _period_metric("exact-primary") + conflicting = deepcopy(primary) + conflicting.update(metric_id="exact-conflict", value=126.0) + projected = validate_source_period_metrics([primary, conflicting]) + assert all("lineage_value_conflict" in item["gap_reasons"] for item in projected) + + rounded = deepcopy(conflicting) + rounded.update( + metric_id="rounded-fallback", + value_origin="source_reported", + value_precision="rounded", + observation_authority="source_reported_rounded", + ) + projected = validate_source_period_metrics([primary, rounded]) + assert all( + "lineage_value_conflict" not in item["gap_reasons"] for item in projected + ) + + +def test_period_metric_preserves_basis_precision_methodology_and_anomaly_holds() -> ( + None +): + metric = _period_metric() + metric.update( + metric_basis="annualized_estimate", + value_origin="source_reported", + value_precision="rounded", + observation_authority="source_reported_rounded", + denominator_scope=["conflicting-public-denominator"], + methodology_state="conflicting", + anomaly_state="unverified", + ) + projected = validate_source_period_metrics([metric])[0] + assert projected["metric_basis"] == "annualized_estimate" + assert projected["value_precision"] == "rounded" + assert projected["methodology_state"] == "conflicting" + assert projected["anomaly_state"] == "unverified" + assert projected["gap_reasons"] == [ + "methodology:conflicting", + "anomaly:unverified", + "rounded_value", + ] + assert projected["admission_reason"] == ("source_period_metric_is_evidence_only") + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ( + lambda item: item.update(observed_components=["unknown"]), + "observed_components must be expected", + ), + ( + lambda item: item.update( + double_counted_components=["core"], numerator_scope=["core"] + ), + "must exclude double-counted", + ), + ( + lambda item: item.update(source_state="error"), + "source_error cannot carry", + ), + ( + lambda item: item.update(period_start="2026-01-08"), + "must not be after", + ), + ( + lambda item: item.update(value=float("nan")), + "must be finite", + ), + ( + lambda item: item.update( + metric_semantics="cash_delta", sign_basis="funding_cost" + ), + "sign_basis must be account_cash_change", + ), + ( + lambda item: item.update( + metric_semantics="fill_fee", sign_basis="fee_cost" + ), + "fee_inclusion must be builder_included", + ), + ( + lambda item: item.update( + metric_semantics="account_nav", + account_scope="product", + account_value_role="nav_owner", + includes_isolated_margin=True, + ), + "account scope/role conflicts", + ), + ], +) +def test_period_metric_rejects_inconsistent_source_semantics(mutation, message) -> None: + metric = _period_metric() + mutation(metric) + with pytest.raises(ValueError, match=message): + validate_source_period_metrics([metric]) + + +def test_period_metric_recomputes_caller_asserted_derived_fields_and_bounds_rows() -> ( + None +): + metric = _period_metric() + metric.update( + ready_eligible=True, + coverage_state="complete", + independent_evidence=True, + ) + projected = validate_source_period_metrics([metric])[0] + assert projected["ready_eligible"] is False + assert projected["admission_reason"] == ("source_period_metric_is_evidence_only") + + oversized_metrics = [ + _period_metric(f"metric-{index}") for index in range(MAX_PERIOD_METRICS + 1) + ] + with pytest.raises(ValueError, match="at most"): + validate_source_period_metrics(oversized_metrics) + + +def test_period_metric_projects_signed_fee_and_unified_account_semantics() -> None: + fill_fee = _period_metric("fill-fee") + fill_fee.update( + metric_semantics="fill_fee", + sign_basis="fee_cost", + fee_inclusion="builder_included", + ) + nav = _period_metric("unified-nav") + nav.update( + metric_semantics="account_nav", + account_scope="unified_account", + account_value_role="nav_owner", + includes_isolated_margin=True, + ) + withdrawable = _period_metric("venue-withdrawable") + withdrawable.update( + value=0, + metric_semantics="withdrawable", + account_scope="venue", + account_value_role="withdrawable", + ) + + by_id = { + item["metric_id"]: item + for item in validate_source_period_metrics([fill_fee, nav, withdrawable]) + } + assert by_id["fill-fee"]["fee_inclusion"] == "builder_included" + assert by_id["unified-nav"]["account_nav_treatment"] == "authoritative_total" + assert by_id["venue-withdrawable"]["value"] == 0.0 + assert by_id["venue-withdrawable"]["account_nav_treatment"] == ( + "venue_liquidity_only" + ) + + +def test_period_metric_keeps_currency_and_account_component_scopes_distinct() -> None: + usd = _period_metric("account-usd") + usd.update( + metric_semantics="account_component", + account_scope="product", + account_value_role="composition", + includes_isolated_margin=True, + ) + usdc = deepcopy(usd) + usdc.update(metric_id="external-usdc", unit="USDC") + + projected = validate_source_period_metrics([usd, usdc]) + assert all(item["independent_evidence"] for item in projected) + assert projected[0]["account_nav_treatment"] == "composition_only" + assert {item["unit"] for item in projected} == {"USD", "USDC"} + + +def _spot_market_identity() -> dict[str, object]: + return { + "pairs": [ + { + "name": "PAIR-B", + "asset_indexes": [7, 0], + "is_canonical": False, + "source_ref": "source:synthetic-pair-b", + }, + { + "name": "PAIR-A", + "asset_indexes": [2, 0], + "is_canonical": True, + "source_ref": "source:synthetic-pair-a", + }, + ], + "tokens": [ + {"index": 0, "symbol": "USDC", "source_ref": "source:synthetic-usdc"}, + {"index": 2, "symbol": "AAA", "source_ref": "source:synthetic-aaa"}, + {"index": 7, "symbol": "BBB", "source_ref": "source:synthetic-bbb"}, + ], + "contexts": [ + { + "coin": "PAIR-A", + "observed_at": "2026-01-15T12:00:00Z", + "mark_price": 2.5, + "source_ref": "source:synthetic-context-a", + }, + { + "coin": "PAIR-B", + "observed_at": "2026-01-15T12:00:00Z", + "mark_price": 7.5, + "source_ref": "source:synthetic-context-b", + }, + ], + } + + +def test_spot_identity_joins_by_names_and_explicit_token_indexes() -> None: + projected = validate_spot_market_identity(_spot_market_identity()) + markets = projected["markets"] + assert [market["pair_name"] for market in markets] == ["PAIR-B", "PAIR-A"] + assert markets[0]["context_coin"] == "PAIR-B" + assert markets[0]["base_asset"] == {"index": 7, "symbol": "BBB"} + assert markets[0]["quote_asset"] == {"index": 0, "symbol": "USDC"} + assert markets[0]["mark_price"] == 7.5 + assert markets[0]["canonicality"] == "noncanonical_name" + assert markets[0]["backing_inference"] == "not_inferred" + assert validate_spot_market_identity(projected) == projected + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ( + lambda payload: payload["contexts"].pop(), + "exact pair.name/context.coin identity", + ), + ( + lambda payload: payload["contexts"].append( + { + "coin": "PERP-ARRAY-ROW", + "observed_at": "2026-01-15T12:00:00Z", + "mark_price": 1, + "source_ref": "source:synthetic-unmatched", + } + ), + "unmatched", + ), + ( + lambda payload: payload["tokens"].append( + { + "index": 7, + "symbol": "DUP", + "source_ref": "source:synthetic-duplicate", + } + ), + "index values must be unique", + ), + ( + lambda payload: payload["pairs"][0].update(asset_indexes=[99, 0]), + "missing from tokens", + ), + ( + lambda payload: payload["contexts"][0].update(mark_price=0), + "mark_price must be positive or null", + ), + ], +) +def test_spot_identity_rejects_positional_or_ambiguous_joins( + mutation, + message, +) -> None: + payload = _spot_market_identity() + mutation(payload) + with pytest.raises(ValueError, match=message): + validate_spot_market_identity(payload) diff --git a/tests/extensions/test_finance_value_discovery_extension.py b/tests/extensions/test_finance_value_discovery_extension.py index e83984c5ab..035fd156d9 100644 --- a/tests/extensions/test_finance_value_discovery_extension.py +++ b/tests/extensions/test_finance_value_discovery_extension.py @@ -404,6 +404,67 @@ def test_manifest_and_paypal_example_preserve_extension_boundary() -> None: assert packet["boundary"]["continuous_watch_allowed"] is False +def _research_source_period_metric() -> dict[str, object]: + return { + "metric_id": "synthetic-value-capture", + "label": "Synthetic value capture", + "event_namespace": "synthetic.period.metric", + "event_id": "value-capture-2026w02", + "event_at": "2026-01-14T23:00:00Z", + "instrument_id": "SYNTH-USD", + "scope_id": "synthetic-scope", + "period_start": "2026-01-08", + "period_end": "2026-01-14", + "source_state": "ok", + "value": 0.0, + "unit": "USD", + "metric_basis": "realized_cash", + "metric_semantics": "cash_delta", + "value_origin": "source_reported", + "value_precision": "exact", + "observation_authority": "source_reported_exact", + "sign_basis": "account_cash_change", + "fee_inclusion": "not_applicable", + "account_scope": "not_applicable", + "account_value_role": "not_applicable", + "includes_isolated_margin": False, + "expected_components": ["primary", "overlap"], + "observed_components": ["primary", "overlap"], + "double_counted_components": ["overlap"], + "numerator_scope": ["primary"], + "denominator_scope": [], + "lineage_id": "synthetic-upstream-week", + "source_ref": "source:synthetic-value-capture", + "methodology_state": "verified", + "anomaly_state": "clear", + } + + +def _research_spot_market_identity() -> dict[str, object]: + return { + "pairs": [ + { + "name": "SYNTH-PAIR", + "asset_indexes": [4, 0], + "is_canonical": False, + "source_ref": "source:synthetic-pair", + } + ], + "tokens": [ + {"index": 0, "symbol": "USDC", "source_ref": "source:synthetic-usdc"}, + {"index": 4, "symbol": "SYN", "source_ref": "source:synthetic-syn"}, + ], + "contexts": [ + { + "coin": "SYNTH-PAIR", + "observed_at": "2026-01-15T12:00:00Z", + "mark_price": 4.25, + "source_ref": "source:synthetic-context", + } + ], + } + + def test_finance_research_dashboard_mapping_preserves_research_truth() -> None: packet = build_finance_research_dashboard_packet(_research_dashboard_input()) assert packet == build_finance_research_dashboard_packet( @@ -449,6 +510,37 @@ def test_finance_research_dashboard_mapping_preserves_research_truth() -> None: assert "frozen_at" not in serialized +def test_dashboard_cli_and_lark_card_share_period_metric_projection( + tmp_path: Path, + capsys, +) -> None: + payload = _research_dashboard_input() + payload["source_period_metrics"] = [_research_source_period_metric()] + payload["spot_market_identity"] = _research_spot_market_identity() + packet = build_finance_research_dashboard_packet(payload) + metric = packet["presentation_projection"]["view"]["source_period_metrics"][0] + assert metric["coverage_state"] == "complete" + assert metric["value"] == 0.0 + assert metric["double_counted_components"] == ["overlap"] + assert metric["ready_eligible"] is False + market = packet["presentation_projection"]["view"]["spot_market_identity"][ + "markets" + ][0] + assert market["context_coin"] == "SYNTH-PAIR" + assert market["base_asset"] == {"index": 4, "symbol": "SYN"} + + input_path = tmp_path / "research-dashboard.json" + input_path.write_text(json.dumps(payload), encoding="utf-8") + assert run(["render-lark-card", "--input-json", str(input_path)]) == 0 + card = json.loads(capsys.readouterr().out) + markdown = card["elements"][0]["text"]["content"] + assert "0 USD" in markdown + assert "Excluded double-counted components: overlap" in markdown + assert "Spot identity joins" in markdown + assert "SYN / USDC" in markdown + assert "evidence-only" in markdown + + @pytest.mark.parametrize( ("mutator", "message"), [ @@ -863,9 +955,10 @@ def test_declared_minimum_core_without_presentation_api_can_import_and_doctor( check=False, ) assert doctor.returncode == 1 - assert "does not provide the extension presentation API" in json.loads( - doctor.stdout - )["error"] + assert ( + "does not provide the extension presentation API" + in json.loads(doctor.stdout)["error"] + ) def test_dashboard_requires_only_public_presentation_validator_api(