Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 22 additions & 55 deletions book/src/metrics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,28 @@
Every metric is derived from Nostr events published by the node itself. No value is
self-reported by the operator, and no value is a composite or weighted score.

## Index

| Metric | Measures |
|---|---|
| [Longevity](longevity-liveness.md#longevity) | How long the node has been operating |
| [Liveness](longevity-liveness.md#liveness) | Whether it is still trading now |
| [Activity consistency](longevity-liveness.md#activity-consistency) | How evenly its trading is spread across the last 30 days |
| [Cumulative performance](trade-size-consistency.md#cumulative-performance) | Lifetime trade count and total volume |
| [Trade size](trade-size-consistency.md#trade-size) | The distribution of individual trade amounts |
| [Dispute signals](disputes-bond-policy.md#dispute-signals) | Dispute counts by outcome, and the rate per 100 trades |
| [Bond policy](disputes-bond-policy.md#bond-policy) | Whether the node requires a trader bond |
| [Fiat currency breakdown](context-signals.md#fiat-currency-breakdown) | Which currencies its trades settle in |
| [Payment method breakdown](context-signals.md#payment-method-breakdown) | Which payment methods its orders accept |
| [Premium signal](context-signals.md#premium-signal) | Its pricing relative to market rate, and how much that varies |
| [Activity grid](activity-grid.md) | When that trading actually happened, over time |

## Event scoping

Before any computation, each fetched event must satisfy three conditions or it is
Before any computation, a fetched event must satisfy three conditions or it is
discarded: its author must be the queried pubkey, its `z` tag must match the value
expected for its kind, and its `y` tag must be `mostro`. Events of any other kind are
excluded outright. This guarantees a report never mixes in another node's activity or
excluded outright. A report therefore never mixes in another node's activity, or
another application's use of the same kinds.

| Kind | `z` value | Backs |
Expand All @@ -18,58 +34,9 @@ another application's use of the same kinds.
| `38385` | `info` | Bond policy |
| `38386` | `dispute` | Dispute signals |

## Not-applicable semantics
## Not-applicable values

A metric that cannot be computed reports its absence explicitly: `N/A` in
console/plain-text output, `null` in JSON. A `0` is always a computed result, never a
placeholder — a node can genuinely have zero disputes across a long trade history. The
table below states the exact condition per field.

## Field reference

| Field | Type | Source | Not applicable when |
|---|---|---|---|
| `stats.longevity.first_seen_at` | string, RFC 3339 UTC | `8383` `created_at` | No dev-fee event exists |
| `stats.longevity.days_active` | number, days | `8383`, falling back to `38383` | No dev-fee event and no qualifying order |
| `stats.cumulative.total_successful_trades` | integer | `38383` `s=success` | Never |
| `stats.cumulative.total_volume_sats` | integer, sats | `38383` tag `amt` | Never |
| `stats.trade_size.min_trade_sats` | integer, sats | `38383` tag `amt` | No qualifying order with a parseable `amt` |
| `stats.trade_size.max_trade_sats` | integer, sats | `38383` tag `amt` | No qualifying order with a parseable `amt` |
| `stats.trade_size.mean_trade_sats` | number, sats | `38383` tag `amt` | No qualifying order with a parseable `amt` |
| `stats.trade_size.median_trade_sats` | number, sats | `38383` tag `amt` | No qualifying order with a parseable `amt` |
| `stats.trade_size.std_dev_trade_sats` | number, sats | `38383` tag `amt` | No qualifying order with a parseable `amt` |
| `stats.trade_size.coefficient_of_variation` | number, ratio | Derived from std dev and median | Fewer than 2 orders, or median is exactly `0` |
| `stats.liveness.last_successful_trade_at` | string, RFC 3339 UTC | `38383` `created_at` | No successful order |
| `stats.liveness.days_since_last_trade` | integer, days | Derived from `last_successful_trade_at` | No successful order |
| `stats.liveness.successful_trades_last_7d` | integer | `38383` `created_at` | Never |
| `stats.liveness.successful_trades_last_30d` | integer | `38383` `created_at` | Never |
| `stats.liveness.successful_trades_last_90d` | integer | `38383` `created_at` | Never |
| `stats.consistency.active_days_last_30d` | integer, days | `38383` `created_at` | Never |
| `stats.consistency.max_consecutive_inactive_days_last_30d` | integer, days | `38383` `created_at` | Never |
| `stats.disputes.total_disputes` | integer | `38386`, deduplicated by `d` | Never |
| `stats.disputes.resolved_disputes` | integer | `38386` tag `s` | Never |
| `stats.disputes.active_disputes` | integer | `38386` tag `s` | Never |
| `stats.disputes.unknown_status_disputes` | integer | `38386` tag `s` | Never |
| `stats.disputes.disputes_per_100_trades` | number, rate | `38386` and `38383` | Zero successful trades |
| `stats.fiat_breakdown.orders_considered` | integer | `38383` tag `f` | Never |
| `stats.fiat_breakdown.distribution` | array of `{currency, orders, share_percent}` | `38383` tag `f` | No order carries a non-empty `f` |
| `stats.payment_method_breakdown.total_mentions` | integer | `38383` tag `pm` | Never |
| `stats.payment_method_breakdown.distribution` | array of `{method, mentions, share_percent}` | `38383` tag `pm` | No `pm` mentions exist |
| `stats.premium.premium_baseline_percent` | number, percent | `38383` tag `premium` | Fewer than 2 orders with a valid `premium` |
| `stats.premium.premium_dispersion_percent` | number, percent | `38383` tag `premium` | Fewer than 2 orders with a valid `premium` |
| `stats.bond_policy.status` | string enum | `38385` tag `bond_enabled` | Never (`unknown` is itself a value) |
| `activity.granularity` | string enum | Derived from the range span | Zero successful orders and no explicit range |
| `activity.range_start` | string, RFC 3339 UTC | Range bounds or order timestamps | Zero successful orders and no explicit range |
| `activity.range_end` | string, RFC 3339 UTC | Range bounds or order timestamps | Zero successful orders and no explicit range |
| `activity.buckets` | array of `{bucket_start, successful_trades, volume_sats, median_trade_sats}` | `38383` | Zero successful orders and no explicit range |

## Pages

Each page below defines its metrics, states how they are computed, and describes what
decision they support.

- [Longevity and liveness](longevity-liveness.md)
- [Trade size and consistency](trade-size-consistency.md)
- [Disputes and bond policy](disputes-bond-policy.md)
- [Fiat, payment method, and premium](context-signals.md)
- [Activity grid](activity-grid.md)
console and plain-text output, `null` in JSON. A `0` is always a computed result, never
a placeholder — a node can genuinely have zero disputes across a long trade history.
Each metric states its own condition.
60 changes: 27 additions & 33 deletions book/src/metrics/activity-grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,71 +2,65 @@

**Definition.** A time-bucketed view of trading activity: one row per interval, each
reporting the successful-trade count, sats volume, and median trade size within that
interval.
interval. Where cumulative performance reports lifetime totals, the grid reports how
those totals are distributed over time.

**Computation.** Built from the
[qualifying successful orders](trade-size-consistency.md#qualifying-orders), assigned to
buckets by `created_at`. Buckets are contiguous and gap-free across the full range — an
interval with no trades is emitted with zero counts and a null median, not omitted, so
row position corresponds to elapsed time.
row position corresponds to elapsed time. Range and bucket width are resolved by the
rules below.

Where cumulative performance reports totals, the grid reports their distribution over
time.
**Not applicable.** The whole grid — granularity, both range bounds, and the bucket
list — when the node has no qualifying orders and no explicit range was requested.

## Range resolution
**Usability.** Distinguishes sustained activity from a single burst at equal lifetime
totals, and locates when a node's trading started, peaked, or stopped.

**Definition.** The interval the grid spans, reported as `activity.range_start` and
`activity.range_end`.
## Range resolution

**Computation.** With neither `--since` nor `--until`, the range is inferred from the
earliest and latest qualifying order. An explicit bound overrides inference and is
authoritative even when it contains no orders: the grid then emits every bucket across
the requested range with zero counts.
With neither `--since` nor `--until`, the range is inferred from the earliest and latest
qualifying order. An explicit bound overrides inference and is authoritative even when
it contains no orders: the grid then emits every bucket across the requested range with
zero counts.

That case is distinct from the null result reserved for a node with no qualifying
orders and no explicit range. An empty populated grid asserts that no activity occurred
in a specific interval; a null grid asserts that no interval could be determined. The
two are not interchangeable.
That case is distinct from the null result described above. An empty populated grid
asserts that no activity occurred in a specific interval; a null grid asserts that no
interval could be determined. The two are not interchangeable.

## Granularity

**Definition.** The bucket width: `daily`, `monthly`, or `yearly`.

**Computation.** `--view` sets it explicitly. Otherwise it is selected from the range
span:
`--view` sets the bucket width explicitly. Otherwise it is selected from the range span:

| Range span | Granularity |
|---|---|
| ≤ 90 days | `daily` |
| ≤ 730 days | `monthly` |
| > 730 days | `yearly` |

Once granularity is known, both range bounds are snapped outward to the enclosing
bucket boundary — a monthly grid reports the first and last instant of the enclosing
calendar months, not the raw order timestamps. When snapping widens the range, the
widened interval is also what gets counted, so the grid never claims to cover a period
it excludes orders from.
Once granularity is known, both range bounds are snapped outward to the enclosing bucket
boundary — a monthly grid reports the first and last instant of the enclosing calendar
months, not the raw order timestamps. When snapping widens the range, the widened
interval is also what gets counted, so the grid never claims to cover a period it
excludes orders from.

**Threshold derivation.** The two boundaries are reasoned from output legibility, not
measured: a daily grid over a two-year span yields more than 700 rows, which exceeds
what a terminal table can usefully present. They are selected to keep row count bounded
at each tier, and are not derived from a statistical property of the data.

Because `--view` can force `daily` over a span the automatic rule would never select,
a warning naming the resulting row count is written to stderr whenever that occurs. The
Because `--view` can force `daily` over a span the automatic rule would never select, a
warning naming the resulting row count is written to stderr whenever that occurs. The
warning reuses the same 90-day boundary, so it cannot disagree with the selection rule.

**Usability.** Distinguishes sustained activity from a single burst at equal totals,
and locates when a node's activity started, peaked, or stopped.

## Progress indicator threshold

**Definition.** A status line written to stderr when a relay fetch exceeds 3 seconds,
distinguishing a slow fetch from a stalled process. Suppressed by `--quiet`.
Not a metric — process feedback, documented here because it is the report's other
numeric threshold. A status line is written to stderr when a relay fetch exceeds 3
seconds, distinguishing a slow fetch from a stalled process. Suppressed by `--quiet`.

**Threshold derivation.** Measured, not reasoned: three connect-and-fetch round trips
against `wss://relay.mostro.network` completed in 2.06 s, 1.96 s, and 1.69 s. Nominal
single-relay operation centers near 2 seconds, so 3 seconds sits above normal variance
while still surfacing a genuinely degraded fetch.

This threshold governs process feedback only and does not affect any reported metric.
9 changes: 9 additions & 0 deletions book/src/metrics/context-signals.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ empty-string bucket. Results are ranked by descending share, with ties broken by
currency name ascending, so the ordering is deterministic despite the underlying tally
being unordered.

**Not applicable.** The distribution, when no qualifying order carries a non-empty `f`
value. The considered-order count is always computed.

**Usability.** Establishes which currencies the node actually settles in, and in what
proportion, before committing to a trade denominated in one of them.

Expand All @@ -42,6 +45,9 @@ orders, and a node whose orders each list three methods reports three times as m
mentions as orders. Empty values are filtered defensively, as relay data is untrusted.
Ranking follows the same rule as the fiat breakdown.

**Not applicable.** The distribution, when no `pm` mentions exist across the qualifying
orders. The mention count is always computed.

**Usability.** Establishes whether the node supports an intended payment method, and
how central that method is to its trading.

Expand All @@ -60,6 +66,9 @@ undefined for a single point, and a single-point median would misrepresent a bas
The standard deviation is population, consistent with
[trade size](trade-size-consistency.md#trade-size).

**Not applicable.** Both figures, when fewer than two qualifying orders carry a valid
`premium` value.

**Usability.** The baseline is the premium to expect on a typical order. The dispersion
qualifies that expectation: high dispersion means quoted premiums vary substantially
between orders, so the baseline is a weak predictor of any individual quote.
6 changes: 6 additions & 0 deletions book/src/metrics/disputes-bond-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ The rate normalizes the total against `stats.cumulative.total_successful_trades`
not applicable only when that denominator is zero. A node with successful trades and no
disputes reports `0.0`, which is a computed result, not an absence.

**Not applicable.** The rate alone, when the node has zero successful trades. The four
counts are always computed.

**Usability.** The rate is the comparable figure across nodes; the raw total is not,
since it scales with volume. A non-zero `unknown_status_disputes` warrants separate
attention: it indicates dispute activity the node's own published data does not fully
Expand All @@ -44,6 +47,9 @@ maps to `unknown`.
states — one confirms no bond is required, the other confirms nothing — and merging
them would present an absence of data as a positive finding.

**Not applicable.** Never. Absence of data is reported as `unknown`, which is itself
one of the three values.

**Usability.** Descriptive only. The report does not rank the three statuses, because a
bond requirement raises the cost of trading and the cost of bad-faith behavior
simultaneously, and which tradeoff is preferable depends on context outside this tool's
Expand Down
9 changes: 9 additions & 0 deletions book/src/metrics/longevity-liveness.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ would freeze the value at the interval between two trades and report `0` for a n
with exactly one. The fallback path leaves `first_seen_at` unset, since no dev-fee
event exists to date it.

**Not applicable.** Both fields, when the node has neither a dev-fee event nor a
qualifying successful order. `first_seen_at` alone, whenever the fallback path applies.

**Usability.** Establishes how much operating history exists to evaluate. It is a
precondition for interpreting the other metrics, not a quality signal on its own: a
high `days_active` with zero successful trades describes a node that has existed
Expand All @@ -30,6 +33,9 @@ trade, elapsed days since it, and successful-trade counts over rolling 7, 30, an
orders. The three rolling counts are independent windows, not cumulative buckets — a
trade three days old is counted in all three.

**Not applicable.** The last-trade timestamp and elapsed days, when no successful order
exists. The three rolling counts are always computed; zero is a result, not an absence.

**Usability.** Distinguishes an actively used node from a historically active one.
Read alongside longevity: strong `days_active` with zero trades in the 90-day window
indicates a node that has stopped trading, whatever the reason.
Expand All @@ -47,6 +53,9 @@ indices whenever report-generation time is not exactly midnight UTC. The inactiv
calculation includes the gap preceding the first active day and the gap following the
last, not only the gaps between active days.

**Not applicable.** Never. A node with no trades in the window reports zero active days
and a 30-day inactive run, both computed results.

**Usability.** Separates steady trading from bursty trading at equal trade counts. Two
nodes with the same 30-day volume differ materially if one traded on 20 days and the
other on 2.
8 changes: 8 additions & 0 deletions book/src/metrics/trade-size-consistency.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ of their sats amounts.
accumulated with saturating addition, so a crafted extreme `amt` on an untrusted relay
event cannot overflow or wrap the total.

**Not applicable.** Never. A node with no qualifying orders reports zero for both,
which is a computed result.

**Usability.** Establishes scale. It is a lower bound on realized activity and carries
no recency information — pair it with liveness to determine whether the volume is
current or historical.
Expand All @@ -45,6 +48,11 @@ Over an even-sized set the median is the mean of the two central values and is r
at full precision, including fractional sats. Truncating it to an integer would both
misstate the median and distort the coefficient of variation computed from it.

**Not applicable.** All six figures, when no qualifying order carries a parseable
`amt`. The coefficient of variation additionally, when fewer than two such orders exist
or the median is exactly `0`, since a zero denominator leaves the ratio undefined
regardless of sample size.

**Usability.** The coefficient of variation is the operative figure: it answers whether
a node's trades cluster around a typical size or span a wide range. A low value
indicates predictable sizing. A high value is not itself adverse — it may reflect a node
Expand Down
Loading