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
2 changes: 1 addition & 1 deletion book/src/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Requirements

- Rust 1.94.0 or later (pinned via `rust-toolchain.toml`)
- Rust 1.94.0 or later
- Network access to a Nostr relay

## Build from source
Expand Down
31 changes: 26 additions & 5 deletions book/src/metrics/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
# Metrics

Every metric is computed from Nostr events scoped to the queried node: its own pubkey
as author, the kind's expected `z` tag value, and `y=mostro`. Events failing that scope,
or any other kind, are silently excluded.
Everything in this section is computed directly from Nostr events the node itself has
published — nothing here is self-reported by the node operator, and nothing is a
subjective score. Each event is first scoped to the node being queried: its pubkey must
match as the event's author, the event's `z` tag must match what that kind expects
(order, dispute, dev-fee-payment, or instance info), and its `y` tag must read
`mostro`. Any event that fails that scoping, or belongs to any other kind entirely, is
silently excluded before a single metric is computed — so a report never mixes in
another node's activity, or another application's use of the same Nostr kinds.

A metric is `null`/`N/A` when there isn't enough data to compute it, never a fabricated
zero. Each page below documents what triggers that case for its own metrics.
Every metric follows the same rule when there isn't enough underlying data to compute
it: it reports its absence explicitly (`N/A` in console/plain text, `null` in JSON)
rather than a fabricated zero. A `0` you see in the report is always a real, computed
answer — for example, a node can genuinely have zero disputes on a healthy trade
history — never a stand-in for missing data. Each page below explains, for its own
metrics, exactly what triggers the not-applicable case.

The pages are grouped by what they help you evaluate:

- [Longevity and liveness](longevity-liveness.md) — how long the node has run, and
whether it's still active.
- [Trade size and consistency](trade-size-consistency.md) — how much it trades, and
how uniform those trades are.
- [Disputes and bond policy](disputes-bond-policy.md) — how its trades have gone wrong,
and what protection it offers traders.
- [Fiat, payment method, and premium](context-signals.md) — what kind of trading it
does, and at what price.
- [Activity grid](activity-grid.md) — when that activity happened, bucketed over time.
72 changes: 48 additions & 24 deletions book/src/metrics/activity-grid.md
Original file line number Diff line number Diff line change
@@ -1,43 +1,67 @@
# Activity grid

A time-bucketed table of successful trades, volume, and median trade size, built from
the node's qualifying successful orders.
The activity grid is the one part of the report that shows change over time instead of
a single lifetime summary: a table with one row per time bucket, and for each bucket,
how many successful trades happened, how much volume they moved, and the median trade
size within that bucket. Where [cumulative performance](trade-size-consistency.md) tells
you the totals, the activity grid tells you *when* that activity happened — steadily
across the node's history, concentrated in one burst, or trailing off recently.

It's built from the same qualifying successful orders used everywhere else in the
report (see [Trade size and consistency](trade-size-consistency.md#what-counts-as-a-qualifying-order)),
bucketed by each order's timestamp.

## Range

`--since`/`--until` set an explicit range. It always wins, even over what orders exist:
an explicit range with zero orders inside it still renders a real grid with empty
buckets spanning that range, never the null/empty result reserved for a node with no
successful orders at all. With neither flag, the range is inferred from the orders'
own earliest/latest timestamp.
By default, the grid spans the node's own observed lifetime: from its earliest
qualifying order to its latest. `--since` and `--until` narrow that to an explicit
window instead, and once given, that window is authoritative — even if it turns out to
contain zero orders. In that case the grid still renders every bucket across the
requested range, each showing zero trades, rather than collapsing to the empty/null
result that's reserved specifically for a node with no successful orders at all. That
distinction matters: an empty grid over a requested range tells you "nothing happened
here," which is different information from "this node has no order history to
report."

## Granularity and its threshold

`--view` forces `daily`, `monthly`, or `yearly`. Without it, granularity is chosen
automatically from the range's span:
Each row in the grid represents a day, a month, or a year, depending on the
granularity. `--view` lets you force one explicitly; without it, `mostro-score` picks
automatically based on how wide the requested (or inferred) range is:

| Range | Granularity |
|---|---|
| ≤ 90 days | daily |
| ≤ 730 days (~2 years) | monthly |
| beyond that | yearly |

These two boundaries were picked by reasoning about a terminal table's usable row
count — a table with hundreds of rows is unusable — not measured empirically. When a
`--view` override forces daily granularity over a range wider than 90 days anyway, the
tool prints a stderr warning naming the resulting row count, using this same boundary
so the warning and the automatic-selection rule never disagree about what counts as
"too wide."
The reasoning behind these two boundaries is practical, not statistical: a daily grid
over a two-year range would produce over 700 rows, which is unreadable in a terminal
table, so the tool switches to coarser buckets before that happens. The 90-day and
730-day cutoffs were chosen by thinking through what a usable table size looks like,
not derived from a formula.

Because a `--view` override can still force daily granularity over a much wider range
than the automatic rule would ever choose on its own, `mostro-score` prints a stderr
warning whenever that happens, naming the exact number of rows the result will have.
The warning reuses the same 90-day boundary the automatic selection uses, so the two
can never disagree about what counts as "too wide."

A defaulted range (no explicit `--since`/`--until`) still snaps to the enclosing
bucket's start/end once a granularity is known — e.g. a forced monthly view snaps to
the first/last day of the calendar month, not a raw mid-month timestamp.
One more detail worth knowing if you inspect `range_start`/`range_end` closely: even a
range you didn't set explicitly still gets aligned to the chosen granularity's
boundaries. A grid forced to monthly view snaps its displayed range to the first and
last day of the calendar month, not to the raw timestamp of whichever order happened to
be first or last.

## Progress indicator threshold

A separate, unrelated threshold: `mostro-score` prints a "still fetching" message to
stderr if a relay fetch runs past **3 seconds**. This was set from real measurement, not
reasoning: 3 connect-and-fetch round trips against the default relay
(`wss://relay.mostro.network`) with a real pubkey took 2.06s/1.96s/1.69s. Normal
single-relay operation sits around 2 seconds, so 3 seconds is comfortably above that
variance while still catching a genuinely slow fetch. Suppressed by `--quiet`.
Unrelated to the grid itself, but worth documenting here since it's the report's other
numeric threshold: while fetching data from relays, `mostro-score` prints a "still
fetching" message to stderr if the fetch takes longer than **3 seconds**, so you're not
left wondering whether the tool has stalled. Unlike the granularity boundaries above,
this number came from direct measurement rather than reasoning: three real
connect-and-fetch round trips against the default relay
(`wss://relay.mostro.network`) took 2.06s, 1.96s, and 1.69s. Normal single-relay
operation sits around two seconds, so three seconds sits comfortably above that normal
variance while still catching a fetch that's genuinely running slow. Pass `--quiet` to
suppress it along with the tool's other transient status messages.
63 changes: 52 additions & 11 deletions book/src/metrics/context-signals.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,63 @@
# Fiat, payment method, and premium

These three signals read the `f`, `pm`, and `premium` tags on qualifying successful
orders. Comparisons are byte-for-byte: no trimming, no case normalization — `"USD"` and
`"usd"`, or `"Cash"` and `" Cash"`, are distinct values.
These three signals are less about whether a node is trustworthy and more about what
kind of trading it actually does — which currencies it settles in, how traders pay,
and how its pricing compares to the market rate. All three read tags on the same
qualifying successful orders described in
[Trade size and consistency](trade-size-consistency.md#what-counts-as-a-qualifying-order),
and all three compare their values byte-for-byte: no trimming whitespace, no case
folding. `"USD"` and `"usd"` are different values here, and so are `"Cash"` and
`" Cash"` — because the Mostro protocol itself doesn't normalize these values before
publishing them, and silently merging them could hide a real formatting bug in a
node's own software.

## Fiat currency breakdown

Ranked distribution of the `f` tag's value, descending by share, ties broken by
currency name ascending. `N/A` when no qualifying order carries a non-empty `f` value.
This shows which fiat currencies the node's trades settle in, and how much of its
volume each one represents — a currency's order count and its percentage share,
ranked from most to least common (ties are broken alphabetically, so the ranking is
always deterministic). It's built from the `f` tag on each qualifying successful
order.

If no qualifying order carries a non-empty `f` value, there's no distribution to build,
and the field reports as not applicable rather than an empty list.

**How to read it:** this tells you what to expect if you trade with the node — a node
that mostly settles in EUR isn't necessarily a bad fit if you want USD, but it's useful
context before you start.

## Payment method breakdown

Ranked distribution of `pm` mentions (a multi-value Nostr tag: one order can mention
several methods, and each mention counts, not each order). `N/A` when there are no
mentions at all.
Similar in shape to the fiat breakdown, but built from the `pm` tag, which records the
payment methods (bank transfer, cash, a specific app) buyers and sellers have used.
Unlike most tags in this report, `pm` can carry more than one value per order — a
single order might list several accepted methods — so this breakdown counts every
individual *mention* across all qualifying orders, not one count per order. A node
where every order lists three payment methods will show three times as many mentions
as orders, and that's expected, not a bug.

It reports as not applicable when there are no `pm` mentions at all across the node's
qualifying orders.

**How to read it:** use this to gauge whether the node typically supports the payment
method you plan to use, before you commit to a trade.

## Premium signal

Median (`premium_baseline_percent`) and population standard deviation
(`premium_dispersion_percent`) of the `premium` tag, parsed as a signed integer
percentage. Both `N/A` when fewer than 2 orders carry a valid `premium` value.
Mostro orders are usually priced at some premium or discount relative to the market
rate, expressed as a signed percentage in the `premium` tag (a negative value means a
discount, a positive one a markup). This signal reports two numbers computed from that
tag across the node's qualifying successful orders: `premium_baseline_percent`, the
median premium the node has actually charged, and `premium_dispersion_percent`, the
population standard deviation around that median — how much the premium tends to swing
from order to order.

Both numbers need at least two data points to mean anything, so they report as not
applicable whenever fewer than two qualifying orders carry a valid, parseable `premium`
value.

**How to read it:** the baseline tells you roughly what premium to expect from this
node on a typical trade. The dispersion tells you how much that can vary — a low
dispersion means the node prices consistently near its baseline, while a high one means
premiums swing widely between orders, so the baseline alone is a less reliable
predictor of what you'll actually be offered.
59 changes: 45 additions & 14 deletions book/src/metrics/disputes-bond-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,52 @@

## Dispute signals

Total, resolved, active, and unknown-status disputes (kind `38386`, deduplicated to
each dispute's latest status), plus disputes per 100 successful trades.
`disputes_per_100_trades` is `N/A` only when the node has zero successful trades —
there is no denominator — regardless of how many disputes exist. Zero disputes with a
nonzero trade count is a real ratio of `0.0`, not `N/A`.
A dispute means a trade broke down badly enough that a third party had to step in and
resolve it. This section reports how many disputes a node has had, how they were
resolved, and how that compares to the node's trade volume — because a handful of
disputes on a node with thousands of trades reads very differently from the same
handful on a node with only a dozen.

A dispute's status is `resolved` for `settled`/`seller-refunded`/`released`, `active`
for `initiated`/`in-progress`, and `unknown` for anything else or a missing status —
still counted once toward the total, since a valid dispute event exists for it
regardless of status.
The source is the dispute event, kind `38386`, scoped to the node. Like orders, a
dispute is republished every time its status changes, so `mostro-score` deduplicates by
the dispute's `d` tag and keeps only its most recent state. That final state's `s` tag
is then classified into one of three buckets: `resolved` (`settled`,
`seller-refunded`, or `released` — the dispute concluded), `active` (`initiated` or
`in-progress` — it's still open), or `unknown` (any other value, or a missing one). A
dispute lands in `unknown` rather than being dropped, because the event itself proves a
real dispute happened, even if its final outcome can't be classified from the tag.

The report also computes disputes per 100 successful trades, a normalized rate that
lets you compare nodes of very different sizes on equal footing. This rate is not
applicable only when the node has zero successful trades at all — there's no
denominator to divide by. A node with disputes but zero trades in the denominator
(possible if every trade failed or was canceled) reports the rate as not applicable
too, for the same reason. A node with trades but *zero* disputes reports the rate as a
real `0.0`, which is a meaningful, favorable number, not a placeholder for missing data.

**How to read it:** look at the rate, not the raw count, when comparing nodes. A high
rate is a real warning sign; a low one, especially alongside a long trade history, is a
positive signal. `unknown`-status disputes are worth a second look on their own — they
mean something happened that the node's own data doesn't fully explain.

## Bond policy

Whether the node requires a bond deposit from traders: `enabled`, `disabled`, or
`unknown` (kind `38385` instance-status event, reading its `bond_enabled` tag).
`unknown` covers both a missing instance-status event and one whose `bond_enabled`
value fails to parse — never defaulting to `disabled`, and never implying which status
is safer.
Some Mostro nodes require traders to lock a small bond before entering a trade, as a
deterrent against bad-faith behavior. Bond policy reports whether this node does:
`enabled`, `disabled`, or `unknown`.

The source is the instance-status event, kind `38385`, which a Mostro node republishes
periodically with its own operational settings, including a `bond_enabled` tag.
`mostro-score` selects the node's single most recent instance-status event and reads
that tag directly: `true` maps to `enabled`, `false` maps to `disabled`. Anything
else — a missing instance-status event entirely, or a `bond_enabled` value that isn't
recognizably `true`/`false` — maps to `unknown`. `unknown` is deliberately never
collapsed into `disabled`: not knowing whether a bond is required is a different, more
uncertain situation than confirming one isn't, and the report is written to keep that
distinction visible rather than picking a side.

**How to read it:** this metric is descriptive, not a verdict. The report will never
tell you `enabled` is safer than `disabled` or vice versa, because that depends on
context this tool doesn't have — a bond requirement raises the cost of trading but also
raises the cost of bad-faith behavior on both sides. Treat it as one more fact to weigh
alongside the node's dispute history and trade record, not a pass/fail check.
Loading
Loading