# SDEX Asset Intelligence v1

## Purpose and scope

Asset Intelligence v1 describes recent market structure for a selected Stellar issued asset and quote pair before a swap or order is signed. Scores are deterministic SDEX heuristics. They are neither fraud findings nor forecasts. The trading and local-signing paths do not depend on the intelligence service.

The version string is `sdex-intelligence-v1`. The authoritative constants are in `functions/lib/intelligence/config.mjs`; the pure calculations are in `functions/lib/intelligence/scoring.mjs`. The API also publishes the constants at `/api/intelligence/methodology`.

## Architecture

The existing static frontend calls `GET /api/intelligence/asset/:code/:issuer?quote=native` or `?quote=CODE:ISSUER`. Production `sdex.exchange` runs through the existing Cloudflare Worker in `worker-static.js`; that Worker routes the intelligence and existing Stellar.Expert market endpoints before passing other requests to its static asset binding. The Cloudflare Pages Function handlers in `functions/api/` expose the same intelligence endpoints on the Pages preview project. The asset in the path must be issued; `quote` defaults to XLM. The handler validates the asset code and the Stellar G-address checksum. It calls fixed upstream origins only. `home_domain` is rendered as text and is never fetched.

`collectors.mjs` acquires and normalizes public data. `service.mjs` coordinates requests and caches results. `scoring.mjs` accepts normalized objects without network dependencies. `app.js` renders the compact summary inside the existing trading screen. The trade actions continue to construct and sign transactions in the browser. Neither wallet secrets nor transaction XDR are sent to the intelligence API.

The top-bar **MARKETS** button opens the `/markets` page in the existing application. It displays a full-width, searchable table from `GET /api/intelligence/markets`, ordered by actual SDEX overall score, then confidence. Columns show the overall and four component scores, confidence, source/critical notes, and assessment time. `INSUFFICIENT_DATA` markets follow rated markets with no headline score. XLM is the common quote so trade and liquidity inputs are comparable. The table includes markets seen through the normal pair API and a background scan of the 77 issued assets in `asset-catalog.json`. It is an assessed subset, not every Stellar market. Selecting a table market returns to the existing XLM trading screen. The table does not start per-asset calculations in each visitor's browser. The old `/api/intelligence/assets` endpoint remains as a compatibility alias.

The response has component scores, underlying metrics, confidence, signals, source availability, limitations, a score-cap reason if applicable, and a generation time. An unavailable numerical value is `null`. A returned score can be recomputed from the returned metrics and constants. Percentages used as score inputs and wash/liquidity subcomponent scores are returned with up to eight decimal places; holder percentages are returned to two decimal places before scoring. Final component and overall scores are rounded to whole integers with JavaScript `Math.round`. The swap and order handlers wait for the already-started intelligence request to settle before constructing and locally signing a transaction. The browser request has a 45-second timeout; an unavailable result is shown and trading continues.

## Data sources and sampling

Primary data comes from the same public mainnet [Horizon](https://developers.stellar.org/docs/data/apis/horizon) origin already used by SDEX:

- `/trades` for the chosen pair, ordered descending, in up to five pages of 200 records. The API records the exact count, which can be less than 1,000. A failed later page yields a partial sample and an explicit source error. The normalized records retain trade ID, time, both accounts, both offer IDs, both pool IDs, both amounts, and price.
- `/order_book` for current bids and asks, with up to 200 levels per side. It is the sole source of the v1 liquidity score.
- `/trade_aggregations` for 24 hourly buckets; these are context metrics and do not add to the wash score.
- `/liquidity_pools` for pair reserve context; pool reserves are reported separately and are not counted as order-book depth.
- `/accounts/:issuer` for issuer existence, home domain, and authorization flags.
- `/assets` for total balance held in trustline accounts. This is the holder-percentage denominator.

External enrichment comes from [Stellar.Expert’s public API](https://stellar.expert/openapi): the top holder list (`/explorer/public/asset/:asset/holders`) and issuer address directory (`/explorer/directory/:address`). The holder API’s integer balance values are converted from stroops by dividing by 10,000,000; decimal values are used as given. The holder collector retains only valid classic G-account addresses for scoring, because Horizon's `balances` denominator covers trustline accounts and reports contract holdings separately in `contracts_amount`. Excluded contract/non-account records and their balance are returned as metrics. Stellar.Expert’s asset rating is neither requested nor used as an SDEX score. Directory tags are labeled as external assertions in both the API and UI.

The upstream request timeout is seven seconds. The pair result is cached for two minutes, holder data for 30 minutes, and directory data for 60 minutes in bounded isolate memory. Concurrent identical requests share a promise. Cache entries and pending results are bounded (150 source entries, 100 pair results). The API additionally stores successful full responses in Cloudflare's Cache API using a normalized asset-and-quote key; this shares results across Worker isolates at the same edge location. Every cache hit checks the response's `generatedAt` against a two-minute limit. Edge locations do not share Cache API contents. The browser retains at most 80 pair results for two minutes and deduplicates simultaneous selected-market requests. A failed enrichment request produces a partial result with affected scores unavailable and reduced confidence; it never disables trading.

The ranking code is separate from scoring and storage. `rankings.mjs` validates and sorts compact summaries; `ranking-refresh.mjs` chooses catalog assets for refresh. The production Worker uses a bound KV namespace with one key per XLM asset. Its `list()` metadata contains only public code, issuer, overall/component scores, label, confidence, partial/critical flags, and timestamps. The value stores the full public analysis. A viewer's freshly calculated XLM result publishes both to KV in the background; identical summaries are not rewritten within 15 minutes. A Cron Trigger runs every five minutes, scoring at most two least-recently-checked catalog assets. The API reads KV metadata once per page (up to 5,000 keys), sorts current assessments, and edge-caches the list for 60 seconds. A score older than 12 hours is withheld from ranking until refreshed; entries expire after 24 hours. KV propagation between regions can take up to 60 seconds. A viewed result therefore becomes visible to other users after KV propagation and the list cache refresh. A full analysis from KV is reused only if its `generatedAt` is less than two minutes old; older detail is recalculated. Full detailed pair results also retain the two-minute edge-local cache.

`createRankingStore` accepts a KV-like binding; with none, it uses a bounded in-process store for local development or Pages previews. That fallback is ephemeral and is reported as `shared: false` by the API. A host outside Cloudflare can supply a persistent store implementing `get`, `put`, and `list` without changing scoring, the ranking algorithm, or UI. The live KV binding ID resides in `wrangler.jsonc`; it is public configuration, not a credential. The list API exposes `coverage.assessed`, `rated`, `limitedData`, and `stale`, so empty or partial coverage is visible.

## Wash resistance

Only order-book account-to-account trades with both accounts present enter wallet metrics. Any trade with either liquidity-pool ID is counted as an AMM trade and excluded from wallet metrics. Other records missing an account are counted as `unknownParticipantTradeCount`, not interpreted as suspicious. A pair key is the two account addresses sorted lexicographically, so A→B and B→A are the same pair. Direction counts are retained separately.

Let `N` be `accountTradeCount`, `A` be `uniqueAccounts`, `P` be `uniqueAccountPairs`, and `V` be the sum of positive sampled counter amounts in quote units. Each trade contributes two participant slots, including two slots when the same account is on both sides. Account volume uses the same two-slot convention. Where `V = 0`, volume shares are `null`, and concentration scoring uses trade shares alone.

| Metric | Exact definition |
| --- | --- |
| `sameAccountTradeShare` | `100 × sameAccountTradeCount / N` |
| `topAccountTradeShare` | `100 × max(account appearance count) / (2N)` |
| `topAccountVolumeShare` | `100 × max(account quote-volume appearances) / (2V)` |
| `topPairTradeShare` | `100 × max(unordered pair trade count) / N` |
| `topPairVolumeShare` | `100 × max(unordered pair quote volume) / V` |
| `uniquePairRatio` | `100 × P / N` |
| `repeatedPairRatio` | `100 × (N − P) / N` |
| `reciprocalTradeShare` | `100 × Σ[2 × min(forward count, reverse count)] / N` over unordered pairs |

When `N = 0`, wash resistance is `null`. All wash subcomponents are on a 0–100 scale. `clamp(x)` means limit to the interval `[0, 100]`.

```text
participantDiversity =
  50 × min(1, A / max(8, sqrt(N))) +
  50 × min(1, P / max(4, sqrt(N)))
traderConcentrationResistance =
  100 − max(topAccountTradeShare, topAccountVolumeShare if available)
pairConcentrationResistance =
  100 − max(topPairTradeShare, topPairVolumeShare if available)
selfTradeResistance = 100 − sameAccountTradeShare
repeatedPairResistance = 100 − repeatedPairRatio

washResistance = round(
  0.25 × participantDiversity +
  0.20 × traderConcentrationResistance +
  0.20 × pairConcentrationResistance +
  0.20 × selfTradeResistance +
  0.15 × repeatedPairResistance)
```

The returned `componentScores` object contains the five subcomponent values used in the final weighted sum. These are heuristics; reciprocal trading can arise naturally, and a score is not a claim of wash trading by any wallet.

Wash signals use these v1 thresholds: same-account share at least 10% (`SELF_TRADING_ACTIVITY`), fewer than five account participants (`LOW_PARTICIPANT_DIVERSITY`), maximum account trade/volume share at least 35% (`HIGH_TRADER_CONCENTRATION`), maximum pair trade/volume share at least 30% (`HIGH_PAIR_CONCENTRATION`), reciprocal share at least 30% (`REPEATED_RECIPROCAL_TRADING`), and fewer than 30 pair trades or 10 account trades (`INSUFFICIENT_TRADE_SAMPLE`). The latter signal reports account, AMM, and unknown-participant counts separately so an AMM-heavy sample is not described as low overall trading activity.

## Liquidity

The book’s `price` is quote units per base unit. Horizon ask `amount` is base units; bid `amount` is quote units. The scorer converts each level to base and quote depth, sorts asks low to high and bids high to low, and calculates `mid = (bestAsk + bestBid)/2` and `spreadPct = 100 × (bestAsk − bestBid)/mid` when both sides exist.

Tiers are quote-asset notionals: `[100, 1000, 5000]` for XLM and USDC quotes, and `[10, 100, 1000]` for other quotes. They are never labeled USD unless the quote asset itself is USDC. Buying spends the quoted tier through ask levels. Selling starts with `tier / mid` base units and walks bid levels. For each filled side, average execution price is `quote exchanged / base exchanged`; buy slippage is `max(0, 100 × (averagePrice/mid − 1))`, and sell slippage is `max(0, 100 × (1 − averagePrice/mid))`. An unfillable tier has `filled: false` and `slippagePct: null`. The middle tier is used for scoring.

Near-mid depth includes bid levels priced at least `0.98 × mid` and ask levels priced at most `1.02 × mid`, expressed in quote units. For `downScore(x, good, bad) = clamp(100 × (bad − x)/(bad − good))`:

```text
spreadComponent = downScore(spreadPct, 0.5, 10)
buyComponent = filled ? downScore(buySlippagePct, 0.5, 10) : 0
sellComponent = filled ? downScore(sellSlippagePct, 0.5, 10) : 0
depthComponent =
  50 × min(1, nearMidBidQuoteDepth / middleTier) +
  50 × min(1, nearMidAskQuoteDepth / middleTier)
liquidity = round(0.30 × spreadComponent + 0.25 × buyComponent +
                  0.25 × sellComponent + 0.20 × depthComponent)
```

An available book lacking either side scores zero and emits `NO_ACTIVE_MARKET`. A failed book request returns `null`, not zero. Other signals: spread at least 5% (`WIDE_SPREAD`), unfillable middle buy/sell tier (`SHALLOW_BUY_LIQUIDITY` / `SHALLOW_SELL_LIQUIDITY`), and filled middle-tier slippage at least 5% (`HIGH_SLIPPAGE`). Pools, path payments, and order-book changes after sampling are excluded from the simulation.

## Holder distribution

The holder list is sorted by balance. V1 requests the first 200 holder records, which may mix classic G accounts and Soroban C contracts. Only valid G-account rows enter the numerator; C contracts and malformed account identifiers are excluded and counted separately. A full mixed page remains an incomplete classic-account sample, even if filtering leaves fewer than 200 G accounts. The issuer account is removed from the account list and its returned balance, if present, is removed from the Horizon account-held balance denominator. This prevents an issuer-held balance from being treated as an ordinary holder. The top 1/5/10 percentages are each `100 × sum(top N account balances) / adjusted Horizon account balance`; a top-N percentage is `null` when fewer than N holders are returned. `hhiLowerBound = Σ(account balance / adjusted total)²` over returned accounts. When fewer than all holders are fetched, it is explicitly a lower bound, not the full HHI. Contract holders are outside the v1 distribution score, so this is an account-held distribution measure rather than total circulating supply concentration.

Ten non-issuer holders and a positive Horizon denominator are required to score. If returned holders exceed Horizon account-held balance by more than 1%, the sources are treated as inconsistent and the score is unavailable. Given `holderPart(p, bad) = clamp(100 × (bad − p)/bad)`:

```text
holderDistribution = round(
  0.40 × holderPart(top1Pct, 50) +
  0.35 × holderPart(top5Pct, 80) +
  0.25 × holderPart(top10Pct, 95))
```

Signals trigger at top-one 35%, top-five 70%, and top-ten 85%. `LIMITED_HOLDER_DATA` covers unavailable or small samples. A custodian, exchange, or distribution account can legitimately hold a large amount; concentration alone is not evidence of fraud. Issuer balance outside the returned top 200 cannot be excluded, which remains a v1 limitation.

## Issuer signals

The issuer section reports the account address, existence, `home_domain`, `auth_required`, `auth_revocable`, `auth_immutable`, and `auth_clawback_enabled`. These are Horizon facts. The issuer score is available only when account existence is known and, for an existing account, all four flags are present. For an existing account:

```text
issuer = 40 for account existence
       + 25 if home_domain is nonempty
       + 20 if auth_immutable is true
       + 15 if both auth_revocable and auth_clawback_enabled are false
```

If Horizon confirms that the account does not exist, the score is zero. This is a transparency/control heuristic, not a judgment about the issuer’s legal or business status. A Stellar.Expert directory tag of `malicious` or `unsafe` creates a `critical` signal with `source: stellar-expert`, the exact tag, and the external attribution. It caps an otherwise computed overall score at 25. The response returns `uncappedOverallScore` and `capReason`. Other directory tags (such as exchange, anchor, custodian, issuer) are reported as external facts but do not affect the issuer score.

## Overall score and confidence

Available components have base weights: wash resistance 40%, liquidity 30%, holder distribution 20%, and issuer 10%. Missing components are omitted and the remaining weights are renormalized:

```text
uncappedOverallScore = round(Σ(score_i × weight_i) / Σ(available weight_i))
overallScore = min(uncappedOverallScore, 25) only with a malicious/unsafe external tag
```

No available component means `overallScore: null`. Labels are `STRONG` for 80–100, `MODERATE` for 60–79, `WEAK` for 40–59, and `HIGH_RISK_SIGNALS` for 0–39 or an applicable critical external cap. The label is `INSUFFICIENT_DATA` if fewer than 30 trades, 10 account trades, or confidence below 45, regardless of the numerical score. The UI presents the insufficient-data label as the summary; the expanded panel retains the numerical calculation for reproducibility.

Confidence is a separate 0–100 number, not a multiplier of integrity:

```text
confidence = round(
  35 × min(1, tradeCount/200) +
  20 × min(1, uniqueAccounts/20) +
  10 × holderScoreAvailable +
  15 × orderBookAvailable +
  10 × issuerScoreAvailable +
   5 × directoryAvailable +
   5 × sampledTradeWithinSevenDays)
```

Each availability term is zero or one. Confidence labels are `HIGH` at 85–100, `MEDIUM` at 60–84, and `LOW` below 60. Reasons explain missing sources, stale activity, or small samples. A five-trade market is labeled `INSUFFICIENT_DATA`; its confidence cannot become high under this formula.

## Operational use and limits

`npm test` runs deterministic fixture tests without network access, including the production Worker routes. `npm run intelligence:inspect -- CODE ISSUER [native|QUOTE_CODE:QUOTE_ISSUER]` prints the full live JSON result for a public asset and pair. `npm run intelligence:fixtures` prints three synthetic fixture responses; `npm run intelligence:fixtures -- --write` regenerates the three complete JSON files in `tests/example-responses/`. The fixture data is used only by tests and the fixture command, never by the production API. `npm run build` copies only an allowlist of public static files to `dist/`, leaving fixtures and scripts outside the published static asset set.

`npm run intelligence:rankings:warm` requests the 77 catalog XLM markets through the deployed intelligence API with two concurrent requests. Each real result is published by the API to the shared index. `-- --limit=20` warms a smaller subset. Failures are reported and retried later by the scheduled scan; the command never invents or uploads scores.

For a local production-path run, execute `npm run build` and then `wrangler dev`. To deploy the live `sdex.exchange` Worker, run `npm run build` and `wrangler deploy --keep-vars` with credentials for the SDEX Cloudflare account. The separate Pages project can be previewed with `wrangler pages dev dist --compatibility-date 2026-04-16`; without a KV binding it has only ephemeral local rankings. The service requires outbound access from the Worker to `horizon.stellar.org` and `api.stellar.expert`. A plain static or Apache-only host will serve the trading UI but cannot execute the intelligence functions.

### Market icons

The curated catalog keeps the issuer's original image URL as source metadata. `npm run icons:sync` copies available HTTPS raster images of at most 256 KiB into `asset-icons/` and writes a content-hashed `cachedImage` path into `asset-catalog.json`. `npm run icons:sync -- --refresh` checks those sources again. The site build includes only icons referenced by the catalog. The Markets table uses those SDEX-hosted files and shows an initial when an image is unavailable. Catalog loading no longer prefetches every issuer image in each visitor's browser. The static asset `_headers` rule sends a one-year immutable browser cache header for hashed icon paths; new image content gets a new path. This cache is part of the portable site files and does not require a separate image service or KV binding. Asset detail views and assets outside the curated catalog may still load an image from issuer metadata when a visitor opens them.

Horizon has a bounded recent view; the newest 1,000 pair trades can cover minutes on one market or years on another. Account links do not prove common ownership or intent. AMM executions lack account-pair evidence and are deliberately excluded from wash metrics. Issuer flags can change except where immutable; no TOML verification is done in v1. Holder and Horizon snapshots may differ in time. Large accounts can be custodians. The order book can change between analysis and signing. The API itself is advisory; it neither modifies quotes nor blocks the trade action.

This differs from a Stellar.Expert-style asset rating based on popularity or activity counts. SDEX’s primary score is independently calculated from sampled participant/pair structure and current executable book depth. Stellar.Expert is used only for holder and directory enrichment; its rating is not copied, requested, or transformed into SDEX’s score.

## Future indexer

V1 is live, on-demand Horizon sampling with limited external enrichment. A later architecture can replace the collectors while preserving normalized inputs, scoring, API shape, and UI:

```text
SDEX-owned ingestion/indexing service
  → Stellar ledger and trade history
  → persistent trade/account relationship data
  → rolling market statistics
  → wallet relationship graph
  → scoring API
  → trading interface
```

The future indexer would permit longer windows, cohort comparisons, and graph analysis. It is not part of v1.
