Collections
Collection endpoints expose indexed collection summaries, paginated asset lists, aggregated health, and background indexing status.
Base URL: https://solana-metadata-platform-production.up.railway.app
All collection routes are **public**. Collection address parameters must be valid Solana public keys.
Endpoints
| Method | Path | Rate tier | Auth | Description |
|---|---|---|---|---|
GET | /v1/collections/:address | expensive | none | Collection entity + embedded health |
GET | /v1/collections/:address/assets | expensive | none | Paginated indexed assets |
GET | /v1/collections/:address/health | expensive | none | Aggregated health summary |
GET | /v1/collections/:address/market | expensive | none | Multi-provider floor snapshot (Magic Eden + Tensor) |
POST | /v1/collections/:address/index | expensive | none | Queue background indexing |
GET | /v1/collections/:address/index | lightweight | none | Latest index job status |
GET | /v1/collection-index-jobs/:jobId | lightweight | none | Job status by ID |
Collection summary
GET /v1/collections/:address returns:
{
"data": {
"address": "...",
"name": "...",
"image": "...",
"metadataUri": "...",
"verified": true,
"supply": 10000,
"indexedAssetCount": 842,
"indexing": {
"status": "complete",
"discoveredCount": 842,
"processedCount": 842,
"failedCount": 0,
"startedAt": "...",
"completedAt": "...",
"updatedAt": "..."
},
"health": { /* CollectionHealthSummary or null */ },
"lastIndexedAt": "..."
}
}Index status values
not_started, queued, running, partial, complete, failed
- **complete** — indexing finished for discovered assets
- **partial** — interrupted or incomplete; health reflects indexed assets only
- **not_started** — collection registered but never indexed
Paginated assets
GET /v1/collections/:address/assets
| Query | Values | Description |
|---|---|---|
limit | 1–100 (default 50) | Page size |
cursor | opaque string | Pagination cursor from prior response |
healthStatus | healthy, warning, degraded, critical | Filter by band |
sort | health_asc, health_desc, name | Sort order |
needsAttention | true / false | Assets with degraded/critical health |
Response:
{
"data": [
{
"mint": "...",
"name": "...",
"image": "...",
"assetType": "nft",
"health": { "score": 74, "status": "warning" }
}
],
"pagination": { "nextCursor": "...", "limit": 50 }
}Returns **404** COLLECTION_NOT_INDEXED when indexStatus === 'not_started'.
Collection health
GET /v1/collections/:address/health returns aggregated metrics:
{
"data": {
"address": "...",
"score": 72,
"status": "warning",
"coverage": { "discovered": 842, "indexed": 842, "percent": 100 },
"totals": { "assets": 842, "healthy": 400, "warning": 300, "degraded": 100, "critical": 42 },
"metadata": { "missingImages": 12, "missingMetadataUris": 3, "offchainFailures": 8, "missingDescriptions": 20, "invalidAttributes": 1 },
"collection": { "verified": 800, "unverified": 30, "unknown": 12 },
"authorities": { "mintAuthorityActive": 0, "freezeAuthorityActive": 2, "permanentDelegateActive": 0, "transferHookActive": 1 },
"standards": { "metaplex": 800, "programmable_nft": 40, "compressed_nft": 2, "unknown": 0 },
"tokenPrograms": { "spl_token": 820, "token_2022": 22, "unknown": 0 },
"primaryStandard": "metaplex",
"primaryTokenProgram": "spl_token",
"calculatedAt": "..."
}
}Standards vs token programs (keep separate)
Two orthogonal distributions:
| Field | Buckets | Source |
|---|---|---|
standards | metaplex, programmable_nft, compressed_nft, unknown | Metadata standard from NormalizedAsset.assetType + standard |
tokenPrograms | spl_token, token_2022, unknown | On-chain program from NormalizedAsset.tokenProgram.type |
Do not conflate metadata standard counts with token program counts.
Indexing
Start or resume
POST /v1/collections/:address/index?refresh=false
- **202** — new job queued
- **200** — active job already exists (returns existing job)
- **409**
INDEX_ALREADY_RUNNING— active job exists andrefresh=truewas requested
Status
GET /v1/collections/:address/index or GET /v1/collection-index-jobs/:jobId
{
"data": {
"jobId": "...",
"collection": "...",
"status": "running",
"discovered": 500,
"processed": 320,
"failed": 2,
"page": 4,
"cursor": "...",
"progressPercent": 64.4,
"lastError": null,
"startedAt": "...",
"updatedAt": "...",
"completedAt": null
}
}Indexing runs in a **bounded background worker**. Large collections may remain partial until the worker catches up.
Collection monitors and freshness
When a collection monitor is active and the index is older than COLLECTION_MONITOR_FRESHNESS_SECONDS (default **3600**), the worker re-enqueues indexing. Monitors may enter waitingForIndex until coverage is sufficient. See [monitoring.md](./monitoring.md).
Cache headers
Collection summary and health responses include X-Cache and X-Cache-TTL when Redis caching is enabled.
Collection market (Sprint 2)
GET /v1/collections/:address/market queries enabled marketplace providers concurrently for a **canonical Solana collection address** Owlviz already understands. Callers pass the same collection address used for summary/health routes — not Magic Eden symbols or Tensor slugs.
Owlviz maps the collection address to each provider internally using an indexed or Helius-discovered sample mint. Provider failures are isolated: one marketplace outage does not block the other. The response exposes each provider independently in sources[] and the **best current observed floor** in aggregate.bestFloor with full provenance.
Server configuration
| Variable | Description |
|---|---|
MAGIC_EDEN_API_BASE_URL | Magic Eden REST base (default https://api-mainnet.magiceden.dev/v2) |
MAGIC_EDEN_API_KEY | Optional Magic Eden API key |
TENSOR_API_BASE_URL | Tensor Alpha REST base (default https://api.mainnet.tensordev.io) |
TENSOR_API_KEY | Tensor API key (x-tensor-api-key header). When omitted, Tensor is auto-disabled even if listed in MARKET_ENABLED_PROVIDERS. |
TENSOR_IDENTITY_CACHE_TTL_SECONDS | Tensor verified-mint identity mapping cache TTL (default **86400** / 24h). Invalidated only on definitive unmapped resolution, not on 429/timeouts/5xx. |
MARKET_ENABLED_PROVIDERS | Comma-separated provider ids (default magic_eden,tensor) |
Never expose provider API keys via NEXT_PUBLIC_* or client bundles.
Query parameters
| Query | Values | Description |
|---|---|---|
scope | provider_only (default), provider, magic_eden, tensor, aggregated, all_markets | Provider listing mode. Magic Eden aggregated uses ME listing aggregation; Tensor always reads active listings sorted by PriceAsc (limit=1). |
Response statuses
Top-level status / floor / provider remain the **Magic Eden** observation for backward compatibility with Sprint 1 clients. Multi-provider consumers should read aggregate.bestFloor and sources[].
| Status | Meaning |
|---|---|
success | SOL floor price available (floor.sol + floor.lamports) |
no_listings | Collection mapped on the provider but no active SOL floor listings |
unmapped | Provider could not map this collection address (Tensor: verified mint + sample mint both failed) |
unavailable | Provider upstream error; stale cached floor may be returned per provider with freshness: stale |
Tensor collection identity (Sprint 3.1)
Tensor resolves collection identity in strict order — **never** fuzzy-matching collection names or slugs:
1. **Verified on-chain mint** — GET /api/v1/collections?vocs={collectionAddress}&sortBy=slugDisplay:asc&limit=1 (official Tensor API).
2. **Sample mint fallback** — indexed or Helius-discovered asset mint via GET /api/v1/collections/by-nfts?mints=….
Positive and negative identity mappings cache independently in Redis (tensor-identity:v1:{address}, default TTL **24h**). Transient Tensor errors (429, timeouts, 5xx) do **not** overwrite cached mappings. Floor reads use SOL listings only (currencies=So111… + response filtering).
Provider failures remain isolated: Magic Eden and Tensor resolve independently in sources[].
Example (multi-provider):
{
"data": {
"address": "AxFuniPo7RaDgPH6Gizf4GZmLQFc4M5ipckeeZfkrPNn",
"status": "success",
"floor": { "sol": 3.89, "lamports": "3890000000" },
"listingCount": 482,
"observedAt": "2026-09-08T16:00:00.000Z",
"scope": "provider_only",
"provider": {
"id": "magic_eden",
"symbol": "degods",
"sampleMint": "Cuyvxd6G3fmKE6qNFy7UdjHYCxfoMcuCizRvJxX1pC9y",
"attribution": {
"id": "magic_eden",
"name": "Magic Eden",
"url": "https://magiceden.io"
}
},
"aggregate": {
"bestFloor": {
"floor": { "sol": 3.75, "lamports": "3750000000" },
"status": "success"
}
},
"sources": [
{
"status": "success",
"floor": { "sol": 3.89, "lamports": "3890000000" },
"listingCount": 482,
"observedAt": "2026-09-08T16:00:00.000Z",
"provider": { "id": "magic_eden", "symbol": "degods", "sampleMint": "...", "attribution": { "id": "magic_eden", "name": "Magic Eden", "url": "https://magiceden.io" } },
"freshness": "fresh"
},
{
"status": "success",
"floor": { "sol": 3.75, "lamports": "3750000000" },
"listingCount": 410,
"observedAt": "2026-09-08T16:00:01.000Z",
"provider": { "id": "tensor", "symbol": "degods", "sampleMint": "...", "attribution": { "id": "tensor", "name": "Tensor", "url": "https://tensor.trade" } },
"freshness": "fresh"
}
],
"provenance": {
"bestFloorProviderId": "tensor",
"method": "lowest_fresh_lamports",
"comparedProviders": ["magic_eden", "tensor"]
},
"freshness": {
"allFresh": true,
"staleProviders": [],
"observedAt": "2026-09-08T16:00:01.000Z"
},
"comparison": {
"spreadLamports": "140000000",
"spreadSol": 0.14,
"providerCountWithFreshFloors": 2
},
"cache": {
"fresh": true,
"cachedAt": "2026-09-08T16:00:00.000Z",
"ttlSeconds": 120,
"staleMaxAgeSeconds": 900
}
}
}Best floor policy
- Compare floors in lamports across providers.
- Only **fresh** observations compete for
aggregate.bestFloor. Stale snapshots may surface per-provider insources[]but never beat a fresh floor. - Listing counts are **never summed** across providers.
Cache headers
Market responses include X-Cache (HIT, MISS, or STALE), X-Cache-TTL, and X-Cache-Age when Redis is configured. Each provider maintains an independent Redis key (collection-market:v1:{provider}:{scope}:{address}). Fresh TTL defaults to **120s**; stale snapshots may be served for up to **900s** when a provider is unavailable.
Sprint 2 scope boundary
**In scope:** Magic Eden + Tensor concurrent floor reads, per-provider cache/stale fallback, best observed floor + provenance/spread, dashboard Simple/Dev display.
**Out of scope (explicit STOP):** Owltopia dynamic raffle ticket pricing, raffle formulas, USD conversion, rarity/trait pricing, WebSockets, trading, separate marketplace indexer, Sprint 4 Market Watch.
See docs/api/examples/10-collection-market.ts for a runnable example. Smoke validation uses the curated list in @metadata-platform/market-intelligence (CURATED_SOLANA_COLLECTIONS, ≥10 entries); CI mocks provider HTTP. Live Tensor validation requires TENSOR_API_KEY in the deployment environment.
Collection market reference (Sprint 3)
GET /v1/collections/:address/market/reference?window=15m returns **Spot Floor** and **Reference Floor** for a canonical collection address. Owlviz reports market facts only — no fair value, intrinsic value, or buy/sell guidance.
- **Spot Floor** —
aggregate.bestFloorfrom the concurrent market snapshot (lowest fresh provider lamports). - **Reference Floor** — integer-lamport rolling median over time-bucketed provider observations stored in Postgres (
market_observations).
Server configuration
| Variable | Description |
|---|---|
MARKET_HISTORY_RETENTION_DAYS | Observation retention (default **7**) |
MARKET_REFERENCE_MIN_BUCKETS | Minimum distinct temporal buckets before status becomes ready (default **5**) |
MARKET_OBSERVATION_BUCKET_SECONDS | Provider bucket width (default **60**) |
MARKET_REFERENCE_MAX_GAP_MULTIPLIER | Sparse gap threshold multiplier vs expected cadence (default **3**) |
Observations persist only at upstream refresh boundaries (after real provider fetches). Redis cache hits do not insert duplicates. Persistence failures are non-fatal — /market continues to work.
Query parameters
| Query | Values | Description |
|---|---|---|
window | 5m, 15m (default), 1h | Rolling observation window |
Reference algorithm
1. Only success observations with a qualifying SOL floor and freshness: fresh enter reference math. unavailable / no_listings are never treated as zero.
2. Observations are bucketed per provider at ~60s boundaries; the latest valid fresh floor per bucket is kept so chatty providers do not dominate.
3. Reference floor = **rolling median** of bucket lamport values (integer-safe). Even bucket counts use the integer average of the two central values: (a + b) / 2 with BigInt division.
4. Status: ready (enough **distinct temporal buckets** + fresh data), warming_up (insufficient distinct buckets), stale (enough buckets but newest observation exceeds stale threshold), unavailable (no qualifying observations).
5. bucketCount counts provider×time buckets used in the median; distinctTimeBucketCount counts unique temporal buckets across providers.
6. Coverage semantics (additive, Sprint 5): coverage.status (sufficient | sparse | insufficient | stale), coverage.mode (single_provider | multi_provider | none), temporal span/gap stats, and per-provider contribution counts separate from current provider snapshot status.
Example:
{
"data": {
"address": "AxFuniPo7RaDgPH6Gizf4GZmLQFc4M5ipckeeZfkrPNn",
"status": "ready",
"spot": {
"floor": { "sol": 3.75, "lamports": "3750000000" },
"status": "success"
},
"reference": {
"floor": { "sol": 3.82, "lamports": "3820000000" },
"method": "rolling_median_lamports",
"window": "15m",
"sampleCount": 24,
"bucketCount": 8,
"distinctTimeBucketCount": 6,
"providerCount": 2,
"computedAt": "2026-09-08T16:00:00.000Z"
},
"coverage": {
"status": "sufficient",
"mode": "multi_provider",
"distinctTimeBucketCount": 6,
"maxGapSeconds": 120,
"expectedMaxGapSeconds": 360,
"historySpanSeconds": 480,
"oldestObservedAt": "2026-09-08T15:52:00.000Z",
"newestObservedAt": "2026-09-08T16:00:00.000Z"
},
"movement": {
"spotVsReferenceLamports": "-70000000",
"spotVsReferenceBps": -183
},
"range": {
"low": { "sol": 3.70, "lamports": "3700000000" },
"high": { "sol": 3.95, "lamports": "3950000000" },
"spreadBps": 675
},
"providers": {
"observed": ["magic_eden", "tensor"],
"contributions": { "magic_eden": 4, "tensor": 4 },
"current": { "magic_eden": "success", "tensor": "success" }
}
}
}See docs/api/examples/11-collection-market-reference.ts for a runnable example.
Sprint 3 scope boundary
**In scope:** market_observations table, observation persistence at refresh boundary, reference endpoint, dashboard Spot/Reference display, retention cleanup via worker.
**Out of scope (explicit STOP):** Owltopia pricing / raffle ticket prices, fair-value language, separate marketplace indexer, Sprint 4+ work.
Market Watch — continuous sampling (Sprint 4)
Opt-in **market watches** schedule genuine Magic Eden + Tensor upstream refreshes from the **existing Railway worker** (no separate service). Each tick bypasses fresh Redis market cache, updates provider caches after real fetches, and persists market_observations only at upstream refresh boundaries (Sprint 3 rules still apply — cache HIT / stale fallback never creates history).
**Provider API usage:** continuous sampling increases Magic Eden and Tensor API call volume proportionally to (active watches / interval). Default interval is **120s** with a hard cap of **20** active watches.
Server configuration
| Variable | Default | Description |
|---|---|---|
MARKET_WATCH_ENABLED | false | Worker polls market_watches when true |
MARKET_WATCH_DEFAULT_INTERVAL_SECONDS | 120 | Default watch interval |
MARKET_WATCH_MIN_INTERVAL_SECONDS | 60 | Minimum allowed interval |
MARKET_WATCH_MAX_ACTIVE | 20 | Bounded active watch cap |
MARKET_WATCH_WORKER_CONCURRENCY | 2 | Parallel watch ticks per worker |
MARKET_WATCH_POLL_INTERVAL_MS | 5000 | Worker claim loop interval |
MARKET_WATCH_MAX_BACKOFF_SECONDS | 900 | Max backoff after total provider failure |
MARKET_WATCH_DEFAULT_JITTER_SECONDS | 15 | Random jitter added to successful tick scheduling |
PUBLIC_MARKET_WATCH_WRITES | false | Private-beta gate for watch CRUD |
MONITORING_ADMIN_TOKEN | — | Bearer token for watch writes (never expose via NEXT_PUBLIC_*) |
Market Watch ticks reuse the same provider configuration as GET /market: set MARKET_ENABLED_PROVIDERS (default magic_eden,tensor) and TENSOR_API_KEY on the **worker** service. When TENSOR_API_KEY is present, Tensor is always queried on worker ticks even if MARKET_ENABLED_PROVIDERS lists only magic_eden (a common split when the API and worker env differ). Without TENSOR_API_KEY, Tensor is omitted from ticks and lastTick.providerStatuses will not include tensor.
Watch endpoints
| Method | Path | Auth |
|---|---|---|
POST | /v1/market-watches | Bearer write |
GET | /v1/market-watches | public |
GET | /v1/market-watches/:id | public |
GET | /v1/collections/:address/market-watch | public |
PATCH | /v1/market-watches/:id | Bearer write (pause/resume/interval/refreshNow) |
DELETE | /v1/market-watches/:id | Bearer write |
POST | /v1/market-watches/:id/tick | Bearer write (manual immediate tick) |
GET /v1/collections/:address/market and GET /v1/collections/:address/market/reference remain unchanged for public clients.
Validation collections (manual smoke)
| Collection | Address | Notes |
|---|---|---|
| Mad Lads | J1S9H3QjnRtBbbuD4HjPV6RpRhwuk4zKbxsnCHuTgh9w | Expect ME success, Tensor no_listings is valid (not unmapped) |
| Owltopia | Index first; ME symbol owltopia | Sample mint 3PEKevzcSuxnTGVujkvVbFrm8kg858Y6KSVW1Wo8NXuC |
| Famous Fox (classic Metaplex) | A7p8451ktDCHq5yYaHczMUMtAeF5EoS3MUJS9tYoXsE | Token-metadata collection |
Sprint 4 scope boundary
**In scope:** market_watches table + migration, worker claim loop, genuine observation accumulation, admin CRUD, docs/tests.
**Out of scope (explicit STOP):** Owltopia dynamic raffle ticket pricing, fair/recommended pricing, floor-manipulation policy, trust scores, alerts, market-change webhooks, Tensor/ME WebSockets, third provider, global all-collection polling/discovery, 24h volume, sales/bid history, trait pricing, USD conversion, trading, scraping, billing, public self-service subscriptions, **Sprint 6+ work**.
Reference integrity & coverage (Sprint 5)
Sprint 5 hardens GET /v1/collections/:address/market/reference so clients can distinguish single-provider vs multi-provider history, sparse temporal gaps, and readiness — without changing Spot/Reference floor math or Owltopia pricing.
Coverage fields
| Field | Description |
|---|---|
reference.distinctTimeBucketCount | Unique temporal buckets across providers (readiness threshold uses this, not raw bucketCount) |
coverage.status | sufficient · sparse · insufficient · stale |
coverage.mode | single_provider · multi_provider · none (based on providers contributing numeric floors to reference math) |
coverage.maxGapSeconds | Largest gap between consecutive distinct temporal buckets |
coverage.expectedMaxGapSeconds | Active watch intervalSeconds × MARKET_REFERENCE_MAX_GAP_MULTIPLIER (default cadence **120s** when no active watch) |
coverage.historySpanSeconds | Seconds between oldest and newest qualifying observations |
providers.contributions | Per-provider bucket counts used in reference math |
providers.current | Current snapshot status per provider (no_listings ≠ unavailable) |
Validation notes
| Collection | Address | Expected |
|---|---|---|
| Mad Lads | J1S9H3QjnRtBbbuD4HjPV6RpRhwuk4zKbxsnCHuTgh9w | ME success, Tensor no_listings; reference may be ready with coverage.mode: single_provider |
| Dual-provider floor | Any collection with fresh ME + Tensor floors | coverage.mode: multi_provider when both contribute numeric buckets |
| Famous Fox | A7p8451ktDCHq5yYaHczMUMtAeF5EoS3MUJS9tYoXsE | Retain unmapped / no_listings semantics; do not force a floor |
Sprint 5 scope boundary
**In scope:** additive reference integrity/coverage fields, deterministic tests, Dev/Simple dashboard display, docs/OpenAPI.
**Out of scope (explicit STOP):** Owltopia pricing, ticket formulas, fair/safe/trusted floors, confidence/trust scores, recommendations, manipulation detection, market events/webhooks/alerts, WebSockets, new providers, sales/volume/bids/traits/rarity, USD, trading, scraping, broad polling, billing.
---
Sprint 6 — Market Change Events (internal durable events)
Sprint 6 adds **durable, queryable market change events** emitted by the Market Watch observation pipeline. Events are persisted internally first; **webhook delivery is deferred to Sprint 6.1**.
Event types
| Type | When emitted |
|---|---|
spot_floor_changed | Aggregate spot (best observed) floor moves beyond bps + lamport thresholds for MARKET_EVENT_HYSTERESIS_TICKS consecutive ticks |
reference_floor_changed | Rolling reference floor moves beyond reference thresholds with hysteresis |
provider_floor_changed | Per-provider floor (ME/Tensor) moves beyond provider thresholds with hysteresis |
provider_status_changed | Provider status transition (e.g. unavailable → success) sustained across hysteresis ticks |
Detection semantics (Sprint 6H)
- **Thresholds:** configurable minimum bps **and** lamports per event class (
MARKET_EVENT_*_MIN_BPS,MARKET_EVENT_*_MIN_LAMPORTS) — both must be met. - **Moving-target hysteresis:** floor moves track an **anchor** (lane baseline) plus **direction** and
consecutiveQualifyingTicks. Sustained moves with shifting targets (e.g. 7.20→7.00→6.95) accumulate toward emit without requiring identical target lamports each tick. Re-entry inside threshold or direction reversal resets the pending candidate. - **Independent lane baselines:** state version **2** stores per-lane baselines (spot, reference, per-provider floor/status). Emitting one lane does not advance other lanes' baselines.
- **Reference stability:**
reference_floor_changedemits only when reference status is **ready**. - **Provider semantics:**
no_listings/unmapped→ floor appearance is tracked; provider status detection runs on normalizedunavailableresponses even when the watch tick fails (all providers unavailable). Fetch **exceptions** do not advance status hysteresis. - **Occurrence-safe fingerprints:** SHA-256 fingerprint includes
candidateStartedAtso repeated identical transitions later produce distinct events; crash/retry of the same occurrence remains idempotent (created=false). - **Baseline:** first tick establishes
market_watches.last_market_state_json(v2) without emitting events. Legacy v1 state is converted on read with zero synthetic events.
Storage
Table: market_events
| Column | Description |
|---|---|
collection_address | Collection the event belongs to |
watch_id | Optional FK to the emitting market watch |
event_type | One of the types above |
payload_json | Snapshot facts: spot/reference/provider deltas, spotVsReferenceBps |
fingerprint | Dedupe key |
occurred_at | Tick timestamp |
Read API (public)
| Method | Path | Description |
|---|---|---|
| GET | /v1/market-events | List events (collectionAddress, watchId, eventType, since, cursor pagination) |
| GET | /v1/market-events/{id} | Get one event |
| GET | /v1/collections/{address}/market/events | Collection-scoped list (requires indexed collection) |
Configuration
| Variable | Default | Purpose |
|---|---|---|
MARKET_EVENTS_ENABLED | true | Master gate for detection + persistence |
MARKET_EVENT_SPOT_FLOOR_MIN_BPS | 50 | Spot floor change threshold (bps) |
MARKET_EVENT_SPOT_FLOOR_MIN_LAMPORTS | 10000000 | Spot floor change threshold (lamports) |
MARKET_EVENT_REFERENCE_FLOOR_MIN_BPS | 50 | Reference floor threshold (bps) |
MARKET_EVENT_REFERENCE_FLOOR_MIN_LAMPORTS | 10000000 | Reference floor threshold (lamports) |
MARKET_EVENT_PROVIDER_FLOOR_MIN_BPS | 50 | Provider floor threshold (bps) |
MARKET_EVENT_PROVIDER_FLOOR_MIN_LAMPORTS | 10000000 | Provider floor threshold (lamports) |
MARKET_EVENT_HYSTERESIS_TICKS | 2 | Consecutive qualifying ticks before emit |
MARKET_EVENT_RETENTION_DAYS | 30 | Worker deletes market_events older than this (hourly job; failures logged, non-blocking) |
Dashboard
Dev mode on the collection scanner market card shows **Recent market events** (latest 5) when available. Simple mode unchanged.
Sprint 6H scope boundary
**In scope:** production hardening of Sprint 6 event semantics (moving hysteresis, independent baselines, occurrence fingerprints, failure-path status detection, pagination, retention cleanup, legacy state migration), tests, docs.
**STOP — Sprint 6.1 deferred:** webhook delivery, external reactions, Discord/Slack pings, market-change webhook subscriptions, new event types, pricing, analytics, new providers. Ship 6H only; soak before 6.1 webhooks.
**Out of scope (explicit STOP):** Owltopia pricing, fair/safe/trusted language, trading, scraping, new marketplaces, WebSockets, billing, Sprint 7.