feat(data): period-earnings aggregation for the Balance tab
Adds src/data/earnings_series.h: a pure, header-only aggregator that sums wallet inflows (received + mined) over the current calendar period (hour / day / month / year), optionally net of sends, both per receiving address and wallet-wide, at "as seen" values. Attribution is best-effort: inflows map to the receiving address; in net mode a send debits from_address when present (a send with no from_address still counts in the wallet total). Includes a revision-keyed EarningsCache (mirrors ui::MiningEarningsCache) and a 13-assertion unit test covering inflows vs net, per-address attribution, period exclusion, period nesting, and cache invalidation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
#pragma once
|
||||||
|
// Pure period-earnings aggregation for the Balance tab's inline earnings figures. Header-only and
|
||||||
|
// dependency-free (beyond TransactionInfo + the std lib) so it can be unit-tested without the GUI.
|
||||||
|
//
|
||||||
|
// It sums wallet INFLOWS (received + mined) over the CURRENT calendar period (hour / day / month /
|
||||||
|
// year), optionally NET of sends, both per receiving address and wallet-wide. Values are "as seen"
|
||||||
|
// (no confirmation/maturity gate), matching the balances the wallet already shows.
|
||||||
|
//
|
||||||
|
// Attribution is best-effort: inflows attribute to TransactionInfo::address (our receiving address);
|
||||||
|
// in Net mode a send debits TransactionInfo::from_address when present (our source address). A send
|
||||||
|
// with no from_address still counts in the wallet TOTAL but not in any per-address figure, so the
|
||||||
|
// per-address numbers can be less complete than the total in Net mode.
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <ctime>
|
||||||
|
#include <cmath>
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "data/wallet_state.h" // TransactionInfo
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace data {
|
||||||
|
|
||||||
|
enum class EarningsInterval { Hour = 0, Day = 1, Month = 2, Year = 3 };
|
||||||
|
enum class EarningsMode { Inflows = 0, Net = 1 };
|
||||||
|
|
||||||
|
// Start (Unix seconds, local calendar) of the current period containing `nowSec` for `interval`:
|
||||||
|
// Hour = top of the current clock hour; Day = local midnight; Month/Year = the 1st of the local
|
||||||
|
// calendar month / Jan 1 of the local year. DST quirks follow std::mktime (as LocalDayStartMemo does).
|
||||||
|
inline std::int64_t earningsPeriodStart(std::int64_t nowSec, EarningsInterval interval) {
|
||||||
|
std::time_t t = (std::time_t)nowSec;
|
||||||
|
std::tm lt{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
localtime_s(<, &t);
|
||||||
|
#else
|
||||||
|
localtime_r(&t, <);
|
||||||
|
#endif
|
||||||
|
lt.tm_sec = 0;
|
||||||
|
lt.tm_min = 0; // all intervals zero out seconds + minutes
|
||||||
|
if (interval != EarningsInterval::Hour) lt.tm_hour = 0; // Day/Month/Year
|
||||||
|
if (interval == EarningsInterval::Month || interval == EarningsInterval::Year) lt.tm_mday = 1;
|
||||||
|
if (interval == EarningsInterval::Year) lt.tm_mon = 0; // January
|
||||||
|
lt.tm_isdst = -1; // let mktime resolve DST for the normalized time
|
||||||
|
return (std::int64_t)std::mktime(<);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EarningsResult {
|
||||||
|
double total = 0.0; // wallet-wide earnings for the period (mode applied)
|
||||||
|
std::map<std::string, double> perAddress; // per receiving (Inflows) / source-debited (Net) address
|
||||||
|
int inflowCount = 0; // inflow txs counted in the period
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sum earnings over the current `interval` period. Inflows = received + mined (>= 0). Net also
|
||||||
|
// subtracts sends (so the total can be negative). `nowSec` is passed in for testability.
|
||||||
|
inline EarningsResult computeEarnings(const std::vector<TransactionInfo>& txs, std::int64_t nowSec,
|
||||||
|
EarningsInterval interval, EarningsMode mode) {
|
||||||
|
EarningsResult r;
|
||||||
|
const std::int64_t start = earningsPeriodStart(nowSec, interval);
|
||||||
|
const bool net = (mode == EarningsMode::Net);
|
||||||
|
double totalIn = 0.0, totalOut = 0.0;
|
||||||
|
for (const auto& tx : txs) {
|
||||||
|
if (tx.timestamp < start) continue;
|
||||||
|
if (tx.type == "receive" || tx.type == "mined") {
|
||||||
|
const double a = std::fabs(tx.amount);
|
||||||
|
totalIn += a;
|
||||||
|
r.inflowCount++;
|
||||||
|
if (!tx.address.empty()) r.perAddress[tx.address] += a;
|
||||||
|
} else if (net && tx.type == "send") {
|
||||||
|
const double a = std::fabs(tx.amount);
|
||||||
|
totalOut += a;
|
||||||
|
if (!tx.from_address.empty()) r.perAddress[tx.from_address] -= a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.total = net ? (totalIn - totalOut) : totalIn;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience: the earnings for a single address in a result (0 if the address earned nothing).
|
||||||
|
inline double earningsForAddress(const EarningsResult& r, const std::string& address) {
|
||||||
|
auto it = r.perAddress.find(address);
|
||||||
|
return (it != r.perAddress.end()) ? it->second : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every input the result depends on: equal keys => the cached result is still valid. `periodStart` is
|
||||||
|
// computed by the caller (earningsPeriodStart) so a period roll-over invalidates the cache.
|
||||||
|
struct EarningsKey {
|
||||||
|
std::uint64_t txRevision = 0; // WalletState::tx_revision (bumped by every write to transactions)
|
||||||
|
std::size_t txCount = 0; // belt: a writer that skips the bump but changes the row count
|
||||||
|
EarningsInterval interval = EarningsInterval::Day;
|
||||||
|
EarningsMode mode = EarningsMode::Inflows;
|
||||||
|
std::int64_t periodStart = 0;
|
||||||
|
bool operator==(const EarningsKey& o) const {
|
||||||
|
return txRevision == o.txRevision && txCount == o.txCount && interval == o.interval &&
|
||||||
|
mode == o.mode && periodStart == o.periodStart;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Revision-keyed cache (mirrors ui::MiningEarningsCache): the O(transactions) pass reruns only when an
|
||||||
|
// input changes, plus a staleness safety net for a future writer that skips the revision bump.
|
||||||
|
class EarningsCache {
|
||||||
|
public:
|
||||||
|
static constexpr std::int64_t kMaxAgeMs = 2000;
|
||||||
|
const EarningsResult& get(const EarningsKey& key, const std::vector<TransactionInfo>& txs,
|
||||||
|
std::int64_t nowSec, std::int64_t nowMs) {
|
||||||
|
if (!valid_ || !(key_ == key) || (nowMs - computedAtMs_) > kMaxAgeMs) {
|
||||||
|
result_ = computeEarnings(txs, nowSec, key.interval, key.mode);
|
||||||
|
key_ = key;
|
||||||
|
valid_ = true;
|
||||||
|
computedAtMs_ = nowMs;
|
||||||
|
computes_++;
|
||||||
|
}
|
||||||
|
return result_;
|
||||||
|
}
|
||||||
|
int computeCount() const { return computes_; } // tests
|
||||||
|
private:
|
||||||
|
bool valid_ = false;
|
||||||
|
EarningsKey key_;
|
||||||
|
std::int64_t computedAtMs_ = 0;
|
||||||
|
EarningsResult result_;
|
||||||
|
int computes_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace data
|
||||||
|
} // namespace dragonx
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
#include "util/secure_vault.h"
|
#include "util/secure_vault.h"
|
||||||
#include "daemon/lifecycle_adapters.h"
|
#include "daemon/lifecycle_adapters.h"
|
||||||
#include "data/wallet_state.h"
|
#include "data/wallet_state.h"
|
||||||
|
#include "data/earnings_series.h"
|
||||||
#include "rpc/connection.h"
|
#include "rpc/connection.h"
|
||||||
#include "rpc/rpc_failure.h"
|
#include "rpc/rpc_failure.h"
|
||||||
#include "rpc/rpc_busy_gate.h"
|
#include "rpc/rpc_busy_gate.h"
|
||||||
@@ -18808,6 +18809,83 @@ void testReadFileExact()
|
|||||||
fs::remove_all(dir, ec);
|
fs::remove_all(dir, ec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Period-earnings aggregation (data/earnings_series.h) ----
|
||||||
|
void testEarningsSeries() {
|
||||||
|
using namespace dragonx::data;
|
||||||
|
using dragonx::TransactionInfo;
|
||||||
|
|
||||||
|
const std::int64_t now = 1700000000; // fixed epoch; periods derived from the function under test
|
||||||
|
|
||||||
|
auto mk = [](const char* type, double amt, std::int64_t ts, const char* addr, const char* from) {
|
||||||
|
TransactionInfo t; t.type = type; t.amount = amt; t.timestamp = ts;
|
||||||
|
t.address = addr; t.from_address = from; return t;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Day period: inflows vs net, per-address attribution, yesterday excluded.
|
||||||
|
{
|
||||||
|
const std::int64_t dayStart = earningsPeriodStart(now, EarningsInterval::Day);
|
||||||
|
EXPECT_TRUE(dayStart <= now);
|
||||||
|
EXPECT_TRUE(now - dayStart < 24 * 3600);
|
||||||
|
|
||||||
|
std::vector<TransactionInfo> txs;
|
||||||
|
txs.push_back(mk("receive", 2.0, dayStart + 10, "zA", ""));
|
||||||
|
txs.push_back(mk("mined", 1.0, dayStart + 20, "zMine", ""));
|
||||||
|
txs.push_back(mk("send", -0.5, dayStart + 30, "zB", "zA")); // stored negative; counterparty in address
|
||||||
|
txs.push_back(mk("receive", 99.0, dayStart - 10, "zA", "")); // yesterday: excluded
|
||||||
|
|
||||||
|
EarningsResult inflow = computeEarnings(txs, now, EarningsInterval::Day, EarningsMode::Inflows);
|
||||||
|
EXPECT_NEAR(inflow.total, 3.0, 1e-9);
|
||||||
|
EXPECT_EQ(inflow.inflowCount, 2);
|
||||||
|
EXPECT_NEAR(earningsForAddress(inflow, "zA"), 2.0, 1e-9);
|
||||||
|
EXPECT_NEAR(earningsForAddress(inflow, "zMine"), 1.0, 1e-9);
|
||||||
|
EXPECT_NEAR(earningsForAddress(inflow, "zB"), 0.0, 1e-9); // sends not attributed in Inflows
|
||||||
|
|
||||||
|
EarningsResult net = computeEarnings(txs, now, EarningsInterval::Day, EarningsMode::Net);
|
||||||
|
EXPECT_NEAR(net.total, 2.5, 1e-9); // 3 - 0.5
|
||||||
|
EXPECT_NEAR(earningsForAddress(net, "zA"), 1.5, 1e-9); // +2 received - 0.5 sent-from
|
||||||
|
EXPECT_NEAR(earningsForAddress(net, "zMine"), 1.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hour period excludes a tx from the previous hour.
|
||||||
|
{
|
||||||
|
const std::int64_t hourStart = earningsPeriodStart(now, EarningsInterval::Hour);
|
||||||
|
EXPECT_TRUE(now - hourStart < 3600);
|
||||||
|
std::vector<TransactionInfo> txs;
|
||||||
|
txs.push_back(mk("receive", 5.0, hourStart + 1, "z1", ""));
|
||||||
|
txs.push_back(mk("receive", 7.0, hourStart - 1, "z1", ""));
|
||||||
|
EarningsResult r = computeEarnings(txs, now, EarningsInterval::Hour, EarningsMode::Inflows);
|
||||||
|
EXPECT_NEAR(r.total, 5.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Period starts nest: year <= month <= day <= hour <= now.
|
||||||
|
{
|
||||||
|
std::int64_t y = earningsPeriodStart(now, EarningsInterval::Year);
|
||||||
|
std::int64_t m = earningsPeriodStart(now, EarningsInterval::Month);
|
||||||
|
std::int64_t d = earningsPeriodStart(now, EarningsInterval::Day);
|
||||||
|
std::int64_t h = earningsPeriodStart(now, EarningsInterval::Hour);
|
||||||
|
EXPECT_TRUE(y <= m); EXPECT_TRUE(m <= d); EXPECT_TRUE(d <= h); EXPECT_TRUE(h <= now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache recomputes only on key change / period roll-over (not on a within-window re-get).
|
||||||
|
{
|
||||||
|
std::vector<TransactionInfo> txs;
|
||||||
|
txs.push_back(mk("receive", 1.0, now, "z1", ""));
|
||||||
|
EarningsCache cache;
|
||||||
|
EarningsKey k;
|
||||||
|
k.txRevision = 1; k.txCount = txs.size(); k.interval = EarningsInterval::Day;
|
||||||
|
k.mode = EarningsMode::Inflows; k.periodStart = earningsPeriodStart(now, EarningsInterval::Day);
|
||||||
|
cache.get(k, txs, now, 1000);
|
||||||
|
cache.get(k, txs, now, 1500); // same key, within kMaxAgeMs -> no recompute
|
||||||
|
EXPECT_EQ(cache.computeCount(), 1);
|
||||||
|
EarningsKey k2 = k; k2.txRevision = 2; // tx changed -> recompute
|
||||||
|
cache.get(k2, txs, now, 1600);
|
||||||
|
EXPECT_EQ(cache.computeCount(), 2);
|
||||||
|
EarningsKey k3 = k2; k3.periodStart += 86400; // period rolled over -> recompute
|
||||||
|
cache.get(k3, txs, now, 1700);
|
||||||
|
EXPECT_EQ(cache.computeCount(), 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int main()
|
int main()
|
||||||
{
|
{
|
||||||
testConnectionConfig();
|
testConnectionConfig();
|
||||||
@@ -19134,6 +19212,7 @@ int main()
|
|||||||
testCaptureExtent();
|
testCaptureExtent();
|
||||||
testResolveExchangeSelection();
|
testResolveExchangeSelection();
|
||||||
testTransactionCsv();
|
testTransactionCsv();
|
||||||
|
testEarningsSeries();
|
||||||
|
|
||||||
if (g_failures != 0) {
|
if (g_failures != 0) {
|
||||||
std::cerr << g_failures << " test assertion(s) failed\n";
|
std::cerr << g_failures << " test assertion(s) failed\n";
|
||||||
|
|||||||
Reference in New Issue
Block a user