diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go index 271246b..73aa0a3 100644 --- a/api/cmd/api/main.go +++ b/api/cmd/api/main.go @@ -103,6 +103,10 @@ func main() { Version: cfg.Version, MaxAge: cfg.HealthMaxAge, })) + indexDeps := handlers.IndexDeps{Clickhouse: ch, Redis: rds} + v1.Get("/index/:id/latest", handlers.IndexLatest(indexDeps)) + v1.Get("/index/:id/history", handlers.IndexHistory(indexDeps)) + v1.Get("/options/strip", handlers.OptionsStrip(indexDeps)) // Run the listener in its own goroutine so `main` can wait on // signal + shutdown sequentially. diff --git a/api/internal/handlers/index.go b/api/internal/handlers/index.go new file mode 100644 index 0000000..102b207 --- /dev/null +++ b/api/internal/handlers/index.go @@ -0,0 +1,245 @@ +package handlers + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v3" + + "github.com/obchain/volx/api/internal/storage" +) + +// Engine publish cadence per `METHODOLOGY.md` §5. Used to compute the +// `next_update_eta_seconds` field on the `/latest` response — the +// frontend's countdown clock keys on it. +const enginePublishIntervalSecs = 60 + +// HistoryDefaultLimit is the default `limit` when the query param is +// absent. Picks 1000 to match `lightweight-charts`' typical default +// page on the frontend (#27). +const HistoryDefaultLimit = 1000 + +// IndexDeps holds the wiring for every `/v1/index/*` and +// `/v1/options/*` handler. Same shape rule as `HealthDeps` — see +// `health.go`. +type IndexDeps struct { + Clickhouse *storage.ClickHouse + Redis *storage.Redis +} + +// indexTickers is the allowlist for the `{id}` path segment. The +// `ClickHouse` `LowCardinality(String)` column matches against the +// upper-case form (`"BVOL" / "EVOL"`); the URL accepts the +// lower-case form per PRD §6 (`/v1/index/bvol/latest`). +var indexTickers = map[string]string{ + "bvol": "BVOL", + "evol": "EVOL", +} + +func normalizeTicker(raw string) (string, bool) { + t, ok := indexTickers[strings.ToLower(raw)] + return t, ok +} + +// --- /v1/index/{id}/latest ---------------------------------------- + +// latestResponse is the PRD §6 envelope. +type latestResponse struct { + Index string `json:"index"` + Value float64 `json:"value"` + Ts string `json:"ts"` + Confidence float64 `json:"confidence"` + SourceStripHash string `json:"source_strip_hash"` + NextUpdateEtaSeconds int `json:"next_update_eta_seconds"` +} + +// engineIndexValue mirrors the JSON written by the Rust engine's +// `IndexSinks::publish` (#20 + #23). We keep the deserialization +// shape narrow — only fields the API actually reads. +type engineIndexValue struct { + IndexID string `json:"index_id"` + Value float64 `json:"value"` + Confidence float64 `json:"confidence"` + StripHash string `json:"strip_hash"` + Ts string `json:"ts"` +} + +// IndexLatest wires `GET /v1/index/{id}/latest`. +func IndexLatest(d IndexDeps) fiber.Handler { + return func(c fiber.Ctx) error { + ticker, ok := normalizeTicker(c.Params("id")) + if !ok { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "unknown index id (allowed: bvol, evol)", + }) + } + ctx, cancel := context.WithTimeout(c.Context(), 1*time.Second) + defer cancel() + + raw, err := d.Redis.GetLatest(ctx, ticker) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": fmt.Sprintf("no latest value for %s (engine has not published yet)", ticker), + }) + } + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "redis lookup failed", + }) + } + + var src engineIndexValue + if err := json.Unmarshal([]byte(raw), &src); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "stored value is not parseable JSON", + }) + } + + // `next_update_eta_seconds` = how long until the engine is + // expected to publish the next tick. Clamp on both ends: + // `< 0` (engine paused, ts is far in the past) → 0; + // `> 60` (clock skew or future-dated test ts) → 60. The + // countdown UI (#27) keys on this; an out-of-range value + // would flicker the dashboard or show a > 60 s countdown + // for an index that publishes every 60 s. + eta := enginePublishIntervalSecs + if t, err := time.Parse(time.RFC3339Nano, src.Ts); err == nil { + elapsed := int(time.Since(t).Seconds()) + eta = enginePublishIntervalSecs - elapsed + if eta < 0 { + eta = 0 + } + if eta > enginePublishIntervalSecs { + eta = enginePublishIntervalSecs + } + } + + return c.JSON(latestResponse{ + Index: src.IndexID, + Value: src.Value, + Ts: src.Ts, + Confidence: src.Confidence, + SourceStripHash: formatStripHash(src.StripHash), + NextUpdateEtaSeconds: eta, + }) + } +} + +// formatStripHash converts the engine's lowercase hex strip-hash +// (64 chars, no prefix) into the PRD §6 `0x`-prefixed form. If the +// engine ever changed encoding (e.g., base64), this is the only +// place the API needs to mirror that change. +func formatStripHash(raw string) string { + if raw == "" { + return "" + } + // Validate that it's hex and 32 bytes; if so prefix with `0x`. + // On parse failure, return as-is so the consumer sees what the + // store actually contains rather than a silent rewrite. + if b, err := hex.DecodeString(raw); err == nil && len(b) == 32 { + return "0x" + raw + } + return raw +} + +// --- /v1/index/{id}/history --------------------------------------- + +// IndexHistory wires `GET /v1/index/{id}/history?interval=1m&limit=N`. +func IndexHistory(d IndexDeps) fiber.Handler { + return func(c fiber.Ctx) error { + ticker, ok := normalizeTicker(c.Params("id")) + if !ok { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "unknown index id (allowed: bvol, evol)", + }) + } + + intervalRaw := c.Query("interval", "1m") + hi, err := storage.ParseHistoryInterval(intervalRaw) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + limit := HistoryDefaultLimit + if v := c.Query("limit"); v != "" { + n, perr := strconv.Atoi(v) + if perr != nil || n <= 0 || n > 10_000 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "limit must be an integer in [1, 10000]", + }) + } + limit = n + } + + // 2 s ClickHouse timeout — history reads scan the + // `index_1m` rollup which is small (~1.4k rows / day × N + // retention days). Longer than this means the table is + // unhealthy or the limit is being abused. + ctx, cancel := context.WithTimeout(c.Context(), 2*time.Second) + defer cancel() + + rows, err := d.Clickhouse.IndexHistory(ctx, ticker, hi, limit) + if err != nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "clickhouse history query failed", + }) + } + // `order: "oldest_first"` makes the pagination contract + // explicit: `limit=N` returns the **most recent N bars**, + // then orders them oldest → newest. Frontend + // (lightweight-charts) requires ascending time on + // `setData()`, so this matches the consumer's expectation + // without forcing a reverse pass client-side. + return c.JSON(fiber.Map{ + "index": ticker, + "interval": intervalRaw, + "order": "oldest_first", + "bars": rows, + }) + } +} + +// --- /v1/options/strip -------------------------------------------- + +// OptionsStrip wires `GET /v1/options/strip?index=bvol`. Pure +// passthrough of the engine's `index:{id}:last_strip` Redis envelope +// — the API does not synthesise this; it would require duplicating +// the strip builder, which we deliberately avoid. +func OptionsStrip(d IndexDeps) fiber.Handler { + return func(c fiber.Ctx) error { + ticker, ok := normalizeTicker(c.Query("index", "bvol")) + if !ok { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "unknown index (allowed: bvol, evol)", + }) + } + ctx, cancel := context.WithTimeout(c.Context(), 1*time.Second) + defer cancel() + + raw, err := d.Redis.GetLastStrip(ctx, ticker) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": fmt.Sprintf("no strip yet for %s", ticker), + }) + } + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "redis lookup failed", + }) + } + // `raw` is already a JSON document (engine wrote + // serde_json::to_string). Pass-through; fiber's content-type + // defaults to text/plain on `SendString` so set it + // explicitly. + c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) + return c.SendString(raw) + } +} diff --git a/api/internal/storage/clickhouse.go b/api/internal/storage/clickhouse.go index 8acf331..5a8fc24 100644 --- a/api/internal/storage/clickhouse.go +++ b/api/internal/storage/clickhouse.go @@ -95,5 +95,154 @@ func (c *ClickHouse) LastIndexTickAge(ctx context.Context) (time.Duration, error return time.Since(maxTs), nil } +// OHLCRow is one bar of an aggregated index series. `Ts` is the +// bucket start (UTC). `Count` is the number of underlying 60 s +// engine ticks that landed in the bar; `AvgConfidence` is their +// average confidence value. Lightweight-charts on the frontend +// (#27) consumes `Ts / Open / High / Low / Close` directly. +type OHLCRow struct { + Ts time.Time `json:"ts"` + Open float64 `json:"open"` + High float64 `json:"high"` + Low float64 `json:"low"` + Close float64 `json:"close"` + Count uint64 `json:"count"` + AvgConfidence float64 `json:"avg_confidence"` +} + +// HistoryInterval is the validated aggregation interval. The string +// form is the wire-format value the API exposes and the SQL form is +// the `INTERVAL N MINUTE` literal we re-bucket the underlying +// `index_1m` rollup with. +type HistoryInterval struct { + wire string + mins int +} + +// AllowedHistoryIntervals lists the buckets supported by +// `/v1/index/{id}/history`. Pinned per PRD §6 — extending requires a +// wire-format bump. +// +// `toStartOfInterval(ts, INTERVAL N MINUTE)` aligns each bucket to a +// multiple of N minutes counted from the Unix epoch +// (`1970-01-01 00:00:00 UTC`). For the four pinned intervals this +// produces the expected calendar alignment: +// +// 1m → minute-of-hour boundary +// 5m → 00, 05, 10, … minutes of the hour +// 1h → top of the hour (UTC) +// 1d → 00:00 UTC (1440 min × N is always a multiple of one UTC day) +// +// The 1-day case is correct by epoch-alignment coincidence — `1440` +// minutes divides every UTC day exactly. If a future interval like +// `12h` is added it would also align cleanly (720 min → 00:00 and +// 12:00 UTC). Non-day multiples (e.g. `90m`) would NOT respect +// calendar boundaries; add a translation layer there if needed. +var AllowedHistoryIntervals = []HistoryInterval{ + {"1m", 1}, + {"5m", 5}, + {"1h", 60}, + {"1d", 24 * 60}, +} + +// ParseHistoryInterval validates the `interval` query param. +func ParseHistoryInterval(s string) (HistoryInterval, error) { + for _, hi := range AllowedHistoryIntervals { + if hi.wire == s { + return hi, nil + } + } + return HistoryInterval{}, fmt.Errorf("invalid interval %q (allowed: 1m, 5m, 1h, 1d)", s) +} + +// IndexHistory pulls an OHLC series from the `index_1m` +// AggregatingMergeTree rollup (#15), re-bucketed to the requested +// interval. `limit` is the maximum number of bars returned, in +// chronological order (oldest first) so the chart consumer can +// `setData()` directly. +// +// `tickerID` is the ClickHouse `LowCardinality(String)` value +// (`"BVOL" / "EVOL"`). The function trusts the caller has already +// validated it against a known set (the handler does, via +// `IndexId::from_ticker`-equivalent in the handler). +// +// `index_1m` stores Aggregate* state columns; merging them happens +// inside the inner SELECT so the outer aggregation operates on +// scalar `value` doubles. +func (c *ClickHouse) IndexHistory( + ctx context.Context, + tickerID string, + hi HistoryInterval, + limit int, +) ([]OHLCRow, error) { + if !validDBName.MatchString(c.DB) { + // Defence-in-depth — the same guard ran at OpenClickHouse, + // but a refactor that mutated c.DB would slip the check + // otherwise. + return nil, fmt.Errorf("clickhouse: invalid DB name %q", c.DB) + } + if limit <= 0 || limit > 10_000 { + return nil, fmt.Errorf("clickhouse: invalid limit %d (must be 1..=10_000)", limit) + } + + // The inner SELECT merges the AggregateFunction states into + // per-minute scalars. The outer SELECT re-buckets those scalars + // to the requested interval. For `1m` the outer aggregation is + // a no-op (1-minute buckets re-grouped by 1 minute = identity). + q := fmt.Sprintf(` + SELECT + toStartOfInterval(bucket, INTERVAL ? MINUTE) AS ts, + argMin(open, bucket) AS open, + max(high) AS high, + min(low) AS low, + argMax(close, bucket) AS close, + sum(count_v) AS cnt, + avg(avg_conf_v) AS avg_conf + FROM ( + SELECT + bucket, + argMinMerge(open_state) AS open, + argMaxMerge(close_state) AS close, + max(high) AS high, + min(low) AS low, + countMerge(count_state) AS count_v, + avgMerge(avg_conf_state) AS avg_conf_v + FROM %s.index_1m + WHERE index_id = ? + GROUP BY index_id, bucket + ) + GROUP BY ts + ORDER BY ts DESC + LIMIT ? + `, c.DB) + + rows, err := c.Conn.Query(ctx, q, hi.mins, tickerID, limit) + if err != nil { + return nil, fmt.Errorf("clickhouse: history query: %w", err) + } + defer rows.Close() + + // `index_1m` query above returns rows in DESC order so a small + // `limit` brings the newest bars. Reverse in-place at the end + // so the wire format is oldest-first (lightweight-charts + // requires ascending time). + out := make([]OHLCRow, 0, limit) + for rows.Next() { + var r OHLCRow + if err := rows.Scan(&r.Ts, &r.Open, &r.High, &r.Low, &r.Close, &r.Count, &r.AvgConfidence); err != nil { + return nil, fmt.Errorf("clickhouse: history scan: %w", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("clickhouse: history rows iter: %w", err) + } + // Reverse to ascending time. + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out, nil +} + // Close releases the underlying pool. func (c *ClickHouse) Close() error { return c.Conn.Close() } diff --git a/api/internal/storage/redis.go b/api/internal/storage/redis.go index 595afd6..faf2f46 100644 --- a/api/internal/storage/redis.go +++ b/api/internal/storage/redis.go @@ -2,6 +2,7 @@ package storage import ( "context" + "errors" "fmt" "time" @@ -47,5 +48,35 @@ func (r *Redis) Ping(ctx context.Context) error { return r.Client.Ping(ctx).Err() } +// ErrNotFound is returned from the `Get*` helpers below when the key +// does not exist. The handlers in `internal/handlers` translate it +// to HTTP 404 — they never expose `redis.Nil` directly. +var ErrNotFound = errors.New("storage: key not found") + +// GetLatest reads `index:{ticker}:latest`. Engine (#20) writes this +// key on every 60s tick; the value is the `IndexValue` JSON +// envelope. +func (r *Redis) GetLatest(ctx context.Context, ticker string) (string, error) { + return r.getKey(ctx, "index:"+ticker+":latest") +} + +// GetLastStrip reads `index:{ticker}:last_strip`. Engine writes this +// alongside `latest` (#23); the value is the dense-grid strip +// envelope (`{index_id, ts, near, next}`). +func (r *Redis) GetLastStrip(ctx context.Context, ticker string) (string, error) { + return r.getKey(ctx, "index:"+ticker+":last_strip") +} + +func (r *Redis) getKey(ctx context.Context, key string) (string, error) { + v, err := r.Client.Get(ctx, key).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return "", ErrNotFound + } + return "", err + } + return v, nil +} + // Close releases the underlying pool. func (r *Redis) Close() error { return r.Client.Close() } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 06b5af5..db7976d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -34,7 +34,7 @@ pub mod variance; pub use interpolate::{ExpiryVariance, InterpError, bvol, interpolate_30d}; pub use sinks::{IndexSinks, SinkError}; -pub use snapshot::{SnapshotError, run_snapshot}; +pub use snapshot::{SnapshotError, SnapshotResult, run_snapshot}; pub use strip::{ BuildError, ChainLeg, DENSE_GRID_POINTS, ExpiryChain, build_strip, build_strip_with_rate, }; diff --git a/crates/engine/src/main.rs b/crates/engine/src/main.rs index aa2e7f8..a924512 100644 --- a/crates/engine/src/main.rs +++ b/crates/engine/src/main.rs @@ -115,8 +115,8 @@ async fn run_tick(reader: &ChClient, sinks: &mut IndexSinks, now: OffsetDateTime for index in [IndexId::Bvol, IndexId::Evol] { let ticker = index.ticker(); match snapshot::run_snapshot(&chains, index, now) { - Ok(iv) => { - if let Err(e) = sinks.publish(&iv).await { + Ok(res) => { + if let Err(e) = sinks.publish(&res.value, &res.near, &res.next).await { error!(index_id = ticker, error = %e, "publish failed"); metrics::counter!( "volx_engine_publish_errors_total", @@ -126,8 +126,8 @@ async fn run_tick(reader: &ChClient, sinks: &mut IndexSinks, now: OffsetDateTime } else { info!( index_id = ticker, - value = iv.value, - confidence = iv.confidence, + value = res.value.value, + confidence = res.value.confidence, "snapshot published" ); } diff --git a/crates/engine/src/sinks.rs b/crates/engine/src/sinks.rs index abc4d84..cd0bc87 100644 --- a/crates/engine/src/sinks.rs +++ b/crates/engine/src/sinks.rs @@ -16,9 +16,11 @@ use ::redis::{AsyncCommands, Client as RedisClient, aio::MultiplexedConnection}; use clickhouse::Client as ChClient; use serde::Serialize; +use serde_json::json; use time::OffsetDateTime; use tracing::warn; use volx_shared_types::index::IndexValue; +use volx_shared_types::strip::Strip; /// `volx.index_ticks` row mirror. Field order + names must match the /// `CREATE TABLE` in `docker/clickhouse-init.sql`; `clickhouse::Row` @@ -88,13 +90,24 @@ impl IndexSinks { /// Insert one row into `index_ticks` and fan out to Redis. /// + /// In addition to the `latest` SET + `stream` PUBLISH from #20, + /// this also writes `index:{id}:last_strip` — a JSON envelope of + /// the near + next dense-grid strips used to compute the value. + /// Consumed by the API's `/v1/options/strip` transparency + /// endpoint (#23). + /// /// # Errors /// /// Returns the `ClickHouse` error if the durable insert fails. /// Redis publish / SET errors are logged + counter-emitted but do /// **not** surface here — the system of record is `index_ticks`, /// and the Redis surfaces are best-effort live caches. - pub async fn publish(&mut self, iv: &IndexValue) -> Result<(), SinkError> { + pub async fn publish( + &mut self, + iv: &IndexValue, + near: &Strip, + next: &Strip, + ) -> Result<(), SinkError> { let ticker = iv.index_id.ticker(); let row = IndexRow { index_id: ticker, @@ -117,14 +130,16 @@ impl IndexSinks { ) .increment(1); - // 2. Redis SET + PUBLISH. Best-effort; on error increment a - // counter and continue. Encoding matches the per-tick - // options pubsub envelope from the normalizer: compact JSON - // with RFC 3339 timestamps + hex strip_hash. + // 2. Redis SET + PUBLISH for `latest` + `stream`. Best-effort; + // on error increment a counter and continue. Encoding + // matches the per-tick options pubsub envelope from the + // normalizer: compact JSON with RFC 3339 timestamps + hex + // strip_hash. let payload = serde_json::to_string(iv)?; let latest_key = format!("index:{ticker}:latest"); let stream_topic = format!("index:{ticker}:stream"); + let strip_key = format!("index:{ticker}:last_strip"); if let Err(e) = self.redis.set::<_, _, ()>(&latest_key, &payload).await { warn!(error = %e, key = %latest_key, "redis SET failed"); @@ -135,6 +150,33 @@ impl IndexSinks { .increment(1); } + // 3. Strip envelope for the `/v1/options/strip` endpoint. + // Dense grid is 801 points × 2 strips = ~150 KB JSON; we + // overwrite the key every 60s and consumers are + // expected to fetch on demand, not subscribe — the size + // is fine at the engine's publish cadence. + let strip_payload = strip_envelope(iv, near, next); + match serde_json::to_string(&strip_payload) { + Ok(s) => { + if let Err(e) = self.redis.set::<_, _, ()>(&strip_key, &s).await { + warn!(error = %e, key = %strip_key, "redis SET last_strip failed"); + metrics::counter!( + "volx_engine_redis_errors_total", + "op" => "set_strip", + ) + .increment(1); + } + } + Err(e) => { + warn!(error = %e, "strip envelope encode failed"); + metrics::counter!( + "volx_engine_redis_errors_total", + "op" => "encode_strip", + ) + .increment(1); + } + } + if let Err(e) = self .redis .publish::<_, _, i64>(&stream_topic, &payload) @@ -157,3 +199,33 @@ impl IndexSinks { Ok(()) } } + +/// Build the `index:{id}:last_strip` JSON envelope. Field shape is +/// the public-API contract for `/v1/options/strip` (#23) — pinning +/// it inline keeps a refactor of `Strip` field names from silently +/// breaking the wire format. +fn strip_envelope(iv: &IndexValue, near: &Strip, next: &Strip) -> serde_json::Value { + json!({ + "index_id": iv.index_id.ticker(), + "ts": iv.ts, + "near": leg_envelope(near), + "next": leg_envelope(next), + }) +} + +fn leg_envelope(s: &Strip) -> serde_json::Value { + // Map every dense-grid point to a compact tuple form. JSON + // arrays of arrays serialize ~30 % smaller than arrays of + // objects at 801 points (no key repetition). + let quotes: Vec<[f64; 3]> = s.quotes.iter().map(|q| [q.strike, q.q_usd, q.iv]).collect(); + json!({ + "forward": s.forward, + "k_zero": s.k_zero, + "time_to_expiry_y": s.time_to_expiry.0, + // `quotes` is a `[K, Q(K), iv]` triple per dense-grid point. + // The triple ordering is pinned in this comment because the + // public-API consumers (the methodology page, third-party + // verifiers) cannot read the type definition. + "quotes": quotes, + }) +} diff --git a/crates/engine/src/snapshot.rs b/crates/engine/src/snapshot.rs index 3059438..69f53b0 100644 --- a/crates/engine/src/snapshot.rs +++ b/crates/engine/src/snapshot.rs @@ -87,13 +87,25 @@ impl SnapshotError { } } +/// Full pipeline result: the published [`IndexValue`] plus the two +/// `Strip`s that produced it. The strips travel to the scheduler so +/// it can persist them to the Redis `index:{id}:last_strip` key for +/// the `/v1/options/strip` transparency endpoint (#23). +#[derive(Debug)] +pub struct SnapshotResult { + pub value: IndexValue, + pub near: Strip, + pub next: Strip, +} + /// Run the full pipeline for one index. Returns the published -/// [`IndexValue`] on success. +/// [`IndexValue`] plus the near + next strips (for downstream +/// persistence). pub fn run_snapshot( chains: &AssetChains, index: IndexId, now: OffsetDateTime, -) -> Result { +) -> Result { let asset = index.asset(); let per_asset = chains .get(&asset) @@ -127,12 +139,16 @@ pub fn run_snapshot( "snapshot computed" ); - Ok(IndexValue { - index_id: index, - value: bvol_value, - confidence, - strip_hash, - ts: now, + Ok(SnapshotResult { + value: IndexValue { + index_id: index, + value: bvol_value, + confidence, + strip_hash, + ts: now, + }, + near: near_strip, + next: next_strip, }) } @@ -292,16 +308,19 @@ mod tests { let next_t = 60.0 / 365.0; // 60 days let chains = build_chains_for_btc(near_t, next_t, 0.5); let now = time::macros::datetime!(2026-05-25 12:00:00 UTC); - let iv_row = run_snapshot(&chains, IndexId::Bvol, now).unwrap(); - assert_eq!(iv_row.index_id, IndexId::Bvol); + let res = run_snapshot(&chains, IndexId::Bvol, now).unwrap(); + assert_eq!(res.value.index_id, IndexId::Bvol); // Tolerance is loose (5 vol points) because the synthetic // chain's wing truncation feeds into both σ² components. assert!( - (iv_row.value - 50.0).abs() < 5.0, + (res.value.value - 50.0).abs() < 5.0, "BVOL={} (expected ≈ 50)", - iv_row.value + res.value.value ); - assert!(iv_row.confidence >= 0.0 && iv_row.confidence <= 1.0); + assert!(res.value.confidence >= 0.0 && res.value.confidence <= 1.0); + // Strips travel out for the #23 strip-persist path. + assert_eq!(res.near.quotes.len(), crate::DENSE_GRID_POINTS); + assert_eq!(res.next.quotes.len(), crate::DENSE_GRID_POINTS); } #[test] @@ -385,7 +404,7 @@ mod tests { let now = time::macros::datetime!(2026-05-25 12:00:00 UTC); let a = run_snapshot(&chains, IndexId::Bvol, now).unwrap(); let b = run_snapshot(&chains, IndexId::Bvol, now).unwrap(); - assert_eq!(a.strip_hash, b.strip_hash); + assert_eq!(a.value.strip_hash, b.value.strip_hash); } #[test] @@ -395,7 +414,7 @@ mod tests { let now = time::macros::datetime!(2026-05-25 12:00:00 UTC); let a = run_snapshot(&chains_lo, IndexId::Bvol, now).unwrap(); let b = run_snapshot(&chains_hi, IndexId::Bvol, now).unwrap(); - assert_ne!(a.strip_hash, b.strip_hash); + assert_ne!(a.value.strip_hash, b.value.strip_hash); } /// Lock the wire-format `reason` labels — dashboards key on them.