diff --git a/src/data/earnings_series.h b/src/data/earnings_series.h new file mode 100644 index 0000000..3e2f7f2 --- /dev/null +++ b/src/data/earnings_series.h @@ -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 +#include +#include +#include +#include +#include +#include + +#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 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& 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& 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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 8f2c7dd..8054e2a 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -34,6 +34,7 @@ #include "util/secure_vault.h" #include "daemon/lifecycle_adapters.h" #include "data/wallet_state.h" +#include "data/earnings_series.h" #include "rpc/connection.h" #include "rpc/rpc_failure.h" #include "rpc/rpc_busy_gate.h" @@ -18808,6 +18809,83 @@ void testReadFileExact() 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 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 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 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() { testConnectionConfig(); @@ -19134,6 +19212,7 @@ int main() testCaptureExtent(); testResolveExchangeSelection(); testTransactionCsv(); + testEarningsSeries(); if (g_failures != 0) { std::cerr << g_failures << " test assertion(s) failed\n";