diff --git a/book/src/installation.md b/book/src/installation.md index dde8ec2..10e6744 100644 --- a/book/src/installation.md +++ b/book/src/installation.md @@ -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 diff --git a/book/src/metrics/README.md b/book/src/metrics/README.md index 24052a4..5bdd40f 100644 --- a/book/src/metrics/README.md +++ b/book/src/metrics/README.md @@ -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. diff --git a/book/src/metrics/activity-grid.md b/book/src/metrics/activity-grid.md index 3aef1bc..fea1c07 100644 --- a/book/src/metrics/activity-grid.md +++ b/book/src/metrics/activity-grid.md @@ -1,20 +1,33 @@ # 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 | |---|---| @@ -22,22 +35,33 @@ automatically from the range's span: | ≤ 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. diff --git a/book/src/metrics/context-signals.md b/book/src/metrics/context-signals.md index 0107dfa..e154560 100644 --- a/book/src/metrics/context-signals.md +++ b/book/src/metrics/context-signals.md @@ -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. diff --git a/book/src/metrics/disputes-bond-policy.md b/book/src/metrics/disputes-bond-policy.md index 6f47bb1..7a7bb9d 100644 --- a/book/src/metrics/disputes-bond-policy.md +++ b/book/src/metrics/disputes-bond-policy.md @@ -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. diff --git a/book/src/metrics/longevity-liveness.md b/book/src/metrics/longevity-liveness.md index 196cc68..cff5969 100644 --- a/book/src/metrics/longevity-liveness.md +++ b/book/src/metrics/longevity-liveness.md @@ -2,23 +2,73 @@ ## Longevity -`first_seen_at` and `days_active` measure how long the node has been operating. +Longevity answers a simple question: how long has this node actually been running? +It's reported as two numbers, `first_seen_at` (a date) and `days_active` (a count), +and both exist to give a trader a sense of track record before they trust a node with +a trade. -The primary anchor is the oldest dev-fee-payment event (kind `8383`). When none exists, -it falls back to the elapsed time between the node's first qualifying successful order -and now — the same "elapsed to now" semantic as the primary path, not first-order to -last-order, since that would stop increasing after the node's last trade and read `0` -for a node with exactly one successful order. When neither anchor exists, both fields -are `N/A`. +The primary source is the dev-fee-payment event, kind `8383` with `z=dev-fee-payment` +and `y=mostro`. Every time a Mostro instance completes a trade, it pays a small +development fee, and that payment is published as a Nostr event carrying a timestamp. +`mostro-score` looks at every dev-fee event the node has ever published and takes the +oldest one — that timestamp becomes `first_seen_at`, and `days_active` is simply the +number of days between it and now. This is the most reliable anchor available, because +dev-fee payments are tied to the protocol's own fee-splitting logic, not to anything the +node operator controls or could omit. + +Not every node has a dev-fee history to draw on — a very new node, or one running a +build that predates the dev-fee mechanism, might have none. When that happens, +`mostro-score` falls back to the node's own order history: it looks at the oldest +qualifying successful order (see [Trade size and consistency](trade-size-consistency.md) +for what "qualifying" means) and measures from there to now instead. This fallback +deliberately measures from the order to *now*, not from the first order to the *last* +order, because the latter would freeze at whatever the gap between two trades happened +to be — a node with exactly one successful trade would always show zero days active, +which misrepresents a node that traded once and then kept running. + +If neither a dev-fee event nor a qualifying successful order exists at all, there's +nothing to measure from, and both fields print as not applicable (`N/A` in +console/plain text, `null` in JSON). That's not an error; it just means the node has no +publicly visible trading history yet. + +**How to read it:** a longer `days_active` generally means more chances for the node's +behavior to have been tested by real trades, but it says nothing on its own about +whether those trades went well — pair it with the [dispute signals](disputes-bond-policy.md) +and [cumulative performance](trade-size-consistency.md) before drawing a conclusion. ## Liveness -Last successful trade, days since it, and rolling 7/30/90-day successful-trade counts. -`N/A` when the node has zero successful orders. +Where longevity looks at the whole lifetime, liveness looks at whether the node is +*currently* active. It reports the timestamp of the node's last successful trade, how +many days have passed since then, and how many successful trades happened in the last +7, 30, and 90 days. + +The source is the same order events (kind `38383`) used everywhere else in the report, +filtered down to the ones whose final, deduplicated status is `success`. A node with +zero successful orders reports every liveness field as not applicable — there's no last +trade to measure from, and the three rolling counts are all zero by definition rather +than missing. + +**How to read it:** liveness is the most direct signal of whether a node is still being +actively used right now. A node with strong historical longevity but no successful +trades in the last 90 days may have gone quiet, been abandoned, or simply be in a slow +period — the report doesn't guess which, it just gives you the raw numbers to judge for +yourself. ## Activity consistency -Active days and the longest gap of consecutive inactive days, both over a fixed window -of exactly 30 UTC calendar days ending on and including today — not a rolling -30×86400-second cutoff, which would include an extra day whenever the current instant -isn't exactly at a UTC day boundary. +Activity consistency measures how evenly a node's trading is spread out, rather than +clustered in a burst and then silent. It's reported as two numbers: how many distinct +calendar days had at least one successful trade in the last 30 days, and the longest +stretch of consecutive inactive days within that same window. + +The window is exactly 30 UTC calendar days, ending on and including today — not a +rolling 30×86400-second cutoff. That distinction matters at the edges: measuring by raw +seconds instead of calendar days can silently include an extra day whenever the current +moment isn't exactly at midnight UTC, so `mostro-score` aligns both ends of the window +to day boundaries first. + +**How to read it:** a node active on most of the last 30 days, with a short maximum +gap, is trading steadily. A node with only one or two active days and a gap of 28 days +either trades in occasional bursts or has mostly gone quiet — again, the numbers don't +label which one it is, but they give you enough to notice the pattern. diff --git a/book/src/metrics/trade-size-consistency.md b/book/src/metrics/trade-size-consistency.md index d5528ff..7e0d653 100644 --- a/book/src/metrics/trade-size-consistency.md +++ b/book/src/metrics/trade-size-consistency.md @@ -1,19 +1,59 @@ # Trade size and consistency +## What counts as a qualifying order + +Both metrics on this page draw from the same underlying set: order events, kind +`38383`, scoped to the queried node as author with `z=order` and `y=mostro`. Mostro +republishes an order event every time its status changes, so the same order can appear +on a relay several times over its lifetime. `mostro-score` deduplicates by the order's +`d` tag, keeping only each order's most recent published state, and then counts an +order as "qualifying" only if that final state's `s` tag reads `success`. An order that +was created, matched, and then canceled never contributes to these numbers — only +trades that actually completed do. + ## Cumulative performance -Total successful trades and total volume in sats, over the node's full history. +This is the simplest metric in the report: how many trades has the node completed, +ever, and how much sats volume did they move in total. It has no time window and no +"N/A" case — a node with zero successful trades reports `0` for both, which is a real, +meaningful answer (this node has no completed trade history), not a missing value. + +**How to read it:** cumulative performance is a floor, not a full picture. A node could +have moved a large volume years ago and gone dormant since — pair this with +[liveness](longevity-liveness.md) to see whether that volume reflects an active node or +a historical one. ## Trade size -Min, max, mean, median, and population standard deviation of trade amounts (sats), -plus the coefficient of variation (std dev / median): lower is more consistent. +Trade size describes the shape of the node's individual trades: the smallest and +largest amounts (in sats), the mean and median, the standard deviation, and the +coefficient of variation — the standard deviation divided by the median, which +collapses "how spread out are the trade sizes" into a single, scale-independent number. +A coefficient of variation near zero means the node's trades tend to be similar in +size; a high one means trade sizes swing widely, from tiny to very large, on the same +node. + +The amount comes from the `amt` tag on each qualifying successful order, parsed as an +integer number of sats. Not every order publishes a parseable `amt` — when one doesn't, +that order still counts toward [cumulative performance](#cumulative-performance) and +[liveness](longevity-liveness.md), but it's simply excluded from this specific +calculation, since there's nothing valid to average in. + +When there isn't enough data, the fields report their absence rather than a misleading +number. With zero qualifying orders carrying a parseable `amt`, every field here is not +applicable. The coefficient of variation has an even stricter rule on top of that: it's +not applicable whenever fewer than two orders exist (variation needs at least two +points to mean anything) or whenever the median trade size is exactly zero, since +dividing by a zero median is mathematically undefined no matter how many samples you +have. -All fields are `N/A` when there are zero qualifying successful orders with a parseable -`amt`. The coefficient of variation has its own, stricter rule: `N/A` when fewer than 2 -orders exist, or when the median is exactly `0` (dividing by a zero median is -undefined regardless of sample size). +One deliberate precision detail: when the number of qualifying orders is even, the +median is the average of the two middle values, and that average is reported exactly +as computed, including a fractional sats value like `0.5` — it is never rounded down to +a whole number, since doing so would also quietly corrupt the coefficient of variation +that's computed from it. -The median over an even-sized set is the average of its two middle values, which can be -a genuine fraction (e.g. `[0, 1]` medians to `0.5`) — it is never truncated to an -integer. +**How to read it:** a low coefficient of variation suggests a node that handles +similarly-sized trades consistently. A very high one isn't necessarily bad — it might +just mean the node serves both small retail trades and large ones — but it's worth +knowing before you send a trade far outside what the node normally handles.