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
11 changes: 10 additions & 1 deletion build/dashboard/mining_dashboard/service/earnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x
confirmed yet" (shows 0.000000).

Windows: ``24h``/``7d``/``30d`` are trailing spans from ``now``, ``yesterday`` is the previous
full local day (:func:`previous_local_day`), ``all`` is everything stored.
full local day (:func:`previous_local_day`), ``all`` is everything stored. Each window also
carries its payout **count** (``n_24h`` … ``n_30d``, #808): for solo-merge-mined Tari a payout
IS a found block, so the count is the honest actual to hold against the expected block count —
the all-time count stays ``count``.

``partial`` marks each running window whose span begins before the oldest payout on record
(``since_ts``) — the sum then covers only part of the window it is labelled with, so the UI
Expand All @@ -135,20 +138,25 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x
day, week, month = now - 86_400, now - 7 * 86_400, now - 30 * 86_400
y_start, y_end = previous_local_day(now)
atomic = dict.fromkeys(("24h", "yesterday", "7d", "30d", "all"), 0)
counts = dict.fromkeys(("24h", "yesterday", "7d", "30d"), 0)
for p in payouts:
amt = p.get("amount_atomic", 0) or 0
ts = p.get("ts", 0) or 0
atomic["all"] += amt
if ts >= month:
atomic["30d"] += amt
counts["30d"] += 1
if ts >= week:
atomic["7d"] += amt
counts["7d"] += 1
if ts >= day:
atomic["24h"] += amt
counts["24h"] += 1
# Half-open [start, end) so a payout landing exactly at midnight belongs to the day it
# starts, never to both days.
if y_start <= ts < y_end:
atomic["yesterday"] += amt
counts["yesterday"] += 1
# Only positive stamps: a row with a missing/zero ts can't date the history, and letting it
# win the min would mark every window complete on the strength of a broken row.
stamps = [t for t in (p.get("ts", 0) or 0 for p in payouts) if t > 0]
Expand All @@ -164,4 +172,5 @@ def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="x
"partial": {w: since_ts <= 0 or since_ts > starts[w] for w in RUNNING_WINDOWS},
}
summary.update({f"{unit}_{w}": v / divisor for w, v in atomic.items()})
summary.update({f"n_{w}": v for w, v in counts.items()})
return summary
11 changes: 11 additions & 0 deletions build/dashboard/mining_dashboard/service/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ class Metrics:
# Defaulted so direct Metrics(...) constructors needn't set them.
tari_difficulty: float = 0.0 # Tari AUX-chain difficulty (not P2Pool sidechain, not Monero)
tari_reward: float = 0.0 # Tari block reward, XTM (collector converts p2pool's µT figure)
# Window-matched routed averages for the expected-vs-actual card (#808): the expectation over
# a trailing 7d/30d window must use the hashrate that actually ran THAT window, not the
# current 1h figure — a fleet that grew or shrank mid-window would otherwise be judged
# against the wrong baseline. History retention (30 days) covers both windows. Defaulted so
# direct Metrics(...) constructors needn't set them.
p2pool_7d: float = 0.0
p2pool_30d: float = 0.0


def build_metrics(latest_data, state_mgr, history=None):
Expand Down Expand Up @@ -143,6 +150,8 @@ def build_metrics(latest_data, state_mgr, history=None):
# stratum estimate or a total-minus-XvB subtraction (Issue #27).
p2pool_1h = _avg_p2pool_over_window(history, 3600)
p2pool_24h = _avg_p2pool_over_window(history, 86400)
p2pool_7d = _avg_p2pool_over_window(history, 7 * 86400)
p2pool_30d = _avg_p2pool_over_window(history, 30 * 86400)

# The XvB raffle qualifies a tier on BOTH the 1h and 24h credited average, and terminates a win
# if the 1h average drops below the round minimum — so Current Tier is the one cleared by the
Expand Down Expand Up @@ -200,6 +209,8 @@ def build_metrics(latest_data, state_mgr, history=None):
total_h15=total_h15,
p2pool_1h=p2pool_1h,
p2pool_24h=p2pool_24h,
p2pool_7d=p2pool_7d,
p2pool_30d=p2pool_30d,
xvb_1h=xvb_1h,
xvb_24h=xvb_24h,
xvb_routed_1h=xvb_routed_1h,
Expand Down
95 changes: 95 additions & 0 deletions build/dashboard/mining_dashboard/web/static/components.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,100 @@ function XvbTierBlock({ calc, hr, coeffDay, energy, est }) {
</div>`;
}

// Expected vs actual (#808): the comparison the operator otherwise assembles by hand across the
// Earnings tabs, compact enough to be the Simple view's one earnings card. Deliberately carries
// NEITHER view class — card-simple and card-advanced are disjoint (each hides in the other's
// mode), and this is the one earnings surface both views share.
// The server rolls the rows up (build_earnings_vs_actual — window-matched
// hashrate, same confirmed roll-up as the Earnings card); this renders them. Per-stream windows
// match cadence: Monero XMR over 7d with a percent-of-expected, Tari BLOCKS over 30d (solo
// merge-mining pays whole blocks — a count, not a percent), XvB wins over 30d beside XvB's
// published estimate with deliberately NO ratio (a win pays out through ordinary small payouts
// the payout table cannot attribute). A stream with payout confirmation off shows the config key
// to set instead of a zero that would read as "earned nothing"; the card yields to nothing when
// no stream has anything to compare.
function ExpectedVsActualCard({ summary }) {
if (!summary) return null;
const { xmr, tari, xvb } = summary;
if (!xmr.available && !tari.available && !xvb.enabled) return null;
const partialMark = (row, text) => (row.partial ? text + " *" : text);
const anyPartial = (xmr.enabled && xmr.partial) || (tari.enabled && tari.partial);
const rows = [];
rows.push({
label: "Monero (7d)",
expected: xmr.available ? formatXmr(xmr.expected_7d) : "—",
actual: !xmr.enabled
? "set monero.view_key"
: partialMark(xmr, formatXmr(xmr.actual_7d) + (xmr.pct !== null ? ` (${xmr.pct}%)` : "")),
dim: !xmr.enabled,
title:
"Confirmed on-chain payouts over the trailing 7 days vs the linear expectation at your " +
"7-day average P2Pool hashrate. P2Pool pays when the pool finds blocks, so a single week " +
"swings with luck — a sustained gap is the signal worth checking, not one short window.",
});
rows.push({
label: "Tari (30d)",
// Two significant digits, not a fixed decimal: at real Tari difficulty the expectation is a
// FRACTION of a block per month, and "0.0" would erase exactly the number this row exists
// to show (≈ 0.0052 blocks is the honest, legible form).
expected: tari.available ? `≈ ${Number(tari.expected_blocks_30d.toPrecision(2))} blocks` : "—",
actual: !tari.enabled
? "set tari.view_key"
: partialMark(
tari,
`${tari.blocks_30d} block${tari.blocks_30d === 1 ? "" : "s"} · ${formatXtm(tari.xtm_30d)}`,
),
dim: !tari.enabled,
title:
"Tari is merge-mined SOLO: a confirmed payout IS a found block, all at once. Expected is " +
"your 30-day average hashrate × 30 days ÷ the Tari difficulty — at fractions of a block " +
"per month, zero found is the normal case, not a fault.",
});
if (xvb.enabled) {
rows.push({
label: "XvB wins (30d)",
expected: xvb.published_day !== null ? formatXmr(xvb.published_day) + "/day (XvB est.)" : "—",
actual:
`${xvb.wins_30d} win${xvb.wins_30d === 1 ? "" : "s"}` +
(xvb.last_win_ts ? ` · last ${formatAgo(xvb.last_win_ts)}` : ""),
dim: false,
title:
"Raffle wins recorded in the last 30 days. A win pays out through ordinary small " +
"payouts that cannot be told apart from P2Pool payouts, so no XvB XMR figure is shown. " +
"The published estimate is XvB's own raffle-wide expectation for your current tier — " +
"not a promise.",
});
}
return html`
<div class="card" id="card-expected-vs-actual">
<h3>Earnings — Expected vs Actual</h3>
<div class="est-scroll">
<table class="est-table">
<thead><tr>
<th></th>
<th scope="col">Expected</th>
<th scope="col">Actual</th>
</tr></thead>
<tbody>
${rows.map(
(r) => html`
<tr title=${r.title}>
<th scope="row">${r.label}</th>
<td class="c-accent">${r.expected}</td>
<td class=${r.dim ? "text-muted" : ""}>${r.actual}</td>
</tr>`,
)}
</tbody>
</table>
</div>
${
anyPartial
? html`<p class="text-muted text-xs">* covers only the payout history on record, not the window's full span.</p>`
: null
}
</div>`;
}

// P2Pool earnings calculator (Issue #12). A power-user card (Advanced view) over the metrics
// layer that estimates XMR from *P2Pool mining only* — explicitly not XvB — plus the Tari the
// same hashrate merge-mines alongside it (#117; "—" while merge-mining is inactive). The server
Expand Down Expand Up @@ -1129,6 +1223,7 @@ function DashboardView({
<div class="grid">
<div class="grid-section-label">Your Stack</div>
<${Overview} state=${state} />
<${ExpectedVsActualCard} summary=${state.earnings_summary} />
<${NodeStats} state=${state} />
<${XvBStats} state=${state} />
<${EarningsCard} earnings=${state.earnings} xvb=${state.xvb_calc} energy=${state.energy} />
Expand Down
93 changes: 82 additions & 11 deletions build/dashboard/mining_dashboard/web/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from mining_dashboard.service.earnings import (
ATOMIC_PER_XMR,
MICRO_PER_XTM,
SECONDS_PER_DAY,
confirmed_payouts_summary,
tari_seconds_to_block_per_hs,
xmr_per_hs_day,
Expand Down Expand Up @@ -1570,6 +1571,70 @@ def build_earnings(data, metrics, payouts=None, tari_payouts=None, xvb_day=None)
}


def build_earnings_vs_actual(metrics, earnings, raffle_wins, now=None):
"""Expected-vs-actual summary — one row per income stream, for both views (#808).

The comparison the operator otherwise assembles by hand across the Earnings tabs: the linear
expectation over a trailing window beside what the view-only wallets confirmed over the SAME
window (#381/#462). Expected uses the time-weighted routed P2Pool average over the window
itself (``metrics.p2pool_7d``/``p2pool_30d``), not the current 1h figure — a fleet that grew
or shrank mid-window would otherwise be judged against the wrong baseline.

Per-stream windows match each stream's cadence. **Monero** compares XMR over 7d — P2Pool pays
whenever the pool finds blocks, so a week is long enough to mean something and short enough to
act on; ``pct`` is the actual as a percent of expected, and ``partial`` carries the confirmed
window's may-be-incomplete flag so a short history is never presented as a full week.
**Tari** compares BLOCK COUNTS over 30d: solo merge-mining pays whole blocks, so the honest
unit is blocks (expected = hashrate × window ÷ aux difficulty; actual = confirmed payout
count, each payout being a found block), with the XTM sum alongside — no percent, a count that
small is luck either way. **XvB** shows wins in the trailing 30d beside XvB's published
per-day estimate for the current tier — deliberately NO ratio: a win pays out through ordinary
small payouts the payout table cannot attribute, so actual XvB XMR is not separable from
P2Pool XMR, and the published figure is XvB's own raffle-wide expectation, not a promise.

Raw numbers out; the client formats. ``actual``/``blocks``/``xtm`` are None while the matching
payout-confirmation feature is off — the card then hints at the view key instead of showing a
zero that would read as "earned nothing"."""
now = now if now is not None else time.time()
conf = earnings["confirmed"]
tari_conf = earnings["tari_confirmed"]
expected_xmr = earnings["coeff_day"] * metrics.p2pool_7d * 7
xmr = {
"available": expected_xmr > 0,
"expected_7d": expected_xmr,
"enabled": bool(conf.get("enabled")),
"actual_7d": conf.get("xmr_7d") if conf.get("enabled") else None,
"partial": bool((conf.get("partial") or {}).get("7d")),
"pct": None,
}
if xmr["available"] and xmr["enabled"]:
xmr["pct"] = round((xmr["actual_7d"] or 0.0) / expected_xmr * 100)
# Expected Tari blocks over the window: hashrate × seconds ÷ difficulty (hashes-per-block).
# Gated on tari_mining like the calculator, so a dead merge-mine channel shows "—", not 0.
expected_blocks = (
metrics.p2pool_30d * 30 * SECONDS_PER_DAY / metrics.tari_difficulty
if metrics.tari_mining and metrics.tari_difficulty > 0
else 0.0
)
tari = {
"available": expected_blocks > 0,
"expected_blocks_30d": expected_blocks,
"enabled": bool(tari_conf.get("enabled")),
"blocks_30d": tari_conf.get("n_30d") if tari_conf.get("enabled") else None,
"xtm_30d": tari_conf.get("xtm_30d") if tari_conf.get("enabled") else None,
"partial": bool((tari_conf.get("partial") or {}).get("30d")),
}
stamps = [w.get("ts", 0) or 0 for w in (raffle_wins or [])]
xvb = {
"enabled": metrics.xvb_enabled,
"wins_30d": sum(1 for t in stamps if t >= now - 30 * SECONDS_PER_DAY),
"last_win_ts": max(stamps, default=0),
# XvB's published per-day estimate for the current tier — None unless fresh (#712).
"published_day": earnings["xvb_day"],
}
return {"xmr": xmr, "tari": tari, "xvb": xvb}


# XvB tier calculator copy (#118). A tier is RAFFLE status, never an XMR payout, and the winner is
# drawn at random among qualifiers — donating above the threshold buys zero extra win chance, so
# the honest framing is cost, not reward.
Expand Down Expand Up @@ -1762,6 +1827,18 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH
mode_tok, p2p_tok, xvb_tok = _mode_palette(metrics.mode)
pool_net = build_pool_network(data, metrics)

# Built once, consumed twice: the Earnings card reads the full payload, the expected-vs-actual
# summary (#808) rolls the same figures up — one build, so the two can't disagree.
earnings = build_earnings(
data,
metrics,
payouts=monero_payouts,
tari_payouts=(
state_mgr.get_payouts("tari") if config.TARI_PAYOUT_CONFIRM_ENABLED else None
),
xvb_day=xvb_current_tier_reward_day(metrics, state_mgr),
)

egress = egress_posture_from_config() # per-component egress route + privacy roll-up (#170)
topology = (
topology_from_config()
Expand Down Expand Up @@ -1809,17 +1886,11 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH
"raffle_eligible": build_raffle_eligibility(metrics),
"raffle_wins": build_raffle_log(raffle_wins),
"proxy_workers": metrics.workers_online,
# Confirmed payouts (#381): pass the stored list when the feature is on, else None (feature
# off → earnings shows only the estimate). config read at call time so tests can flip it.
"earnings": build_earnings(
data,
metrics,
payouts=monero_payouts,
tari_payouts=(
state_mgr.get_payouts("tari") if config.TARI_PAYOUT_CONFIRM_ENABLED else None
),
xvb_day=xvb_current_tier_reward_day(metrics, state_mgr),
),
# Confirmed payouts (#381): the stored list rides in when the feature is on, else None
# (feature off → earnings shows only the estimate). Built above, before the payload.
"earnings": earnings,
# Expected vs actual, one row per stream (#808) — the Simple view's earnings figure.
"earnings_summary": build_earnings_vs_actual(metrics, earnings, raffle_wins),
"xvb_calc": build_xvb_calc(metrics, state_mgr),
# On a backup stack, the XvB controller state last pulled from the primary (#249) — held as
# standby, adopted only at failover. None on a single stack (nothing pulls). Inspectable so
Expand Down
10 changes: 10 additions & 0 deletions build/dashboard/tests/service/test_earnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ def test_empty_list_is_on_with_zeros(self):
"xmr_7d": 0.0,
"xmr_30d": 0.0,
"xmr_all": 0.0,
"n_24h": 0,
"n_yesterday": 0,
"n_7d": 0,
"n_30d": 0,
"last_ts": 0,
"since_ts": 0,
"partial": {"yesterday": True, "7d": True, "30d": True},
Expand All @@ -187,6 +191,11 @@ def test_trailing_windows_bucket_by_age(self):
assert s["xmr_7d"] == 3.0
assert s["xmr_30d"] == 7.0
assert s["xmr_all"] == 15.0
# Per-window counts mirror the sums (#808) — for Tari a payout IS a found block, so the
# count is the actual the expected-vs-actual card holds against the expected block count.
assert s["n_24h"] == 1
assert s["n_7d"] == 2
assert s["n_30d"] == 3
assert s["last_ts"] == self.NOW - 3_600
assert s["since_ts"] == self.NOW - 40 * 86_400

Expand All @@ -202,6 +211,7 @@ def test_yesterday_is_the_previous_calendar_day(self):
]
s = confirmed_payouts_summary(payouts, now=self.NOW)
assert s["xmr_yesterday"] == 6.0
assert s["n_yesterday"] == 2
# The trailing 24h is a different span and deliberately disagrees: it reaches back into
# yesterday noon and includes today's midnight payout.
assert s["xmr_24h"] == 12.0
Expand Down
Loading