From 569bf733f2288c2892aac1b09224275d52ebac43 Mon Sep 17 00:00:00 2001 From: stuppie Date: Thu, 1 Oct 2026 18:43:37 -0600 Subject: sketching out UserWalletBalances and ProductUserWalletBalances + balance histograms for reporting --- generalresearch/models/thl/finance.py | 330 +++++++++++++++++++++++++++++++++- 1 file changed, 329 insertions(+), 1 deletion(-) diff --git a/generalresearch/models/thl/finance.py b/generalresearch/models/thl/finance.py index f012cbf..4586460 100644 --- a/generalresearch/models/thl/finance.py +++ b/generalresearch/models/thl/finance.py @@ -22,12 +22,52 @@ from generalresearch.currency import USDCent from generalresearch.decorators import LOG from generalresearch.models.custom_types import AwareDatetimeISO, UUIDStr from generalresearch.models.thl.definitions import SessionAdjustedStatus +from generalresearch.models.thl.user_identifiers import BPUIDStr payout_example = random.randint(150, 750 * 100) adjustment_example = random.randint(-1_000, 50 * 100) -if TYPE_CHECKING: +# Keep these boundaries stable so Prometheus can aggregate histograms across +# Products and Grafana can compare the same ranges over time. Values are USD +# cents; ``None`` represents the required +Inf bucket. +USER_WALLET_BALANCE_BUCKETS: tuple[int | None, ...] = ( + -1000_00, + -500_00, + -250_00, + -100_00, + -50_00, + -25_00, + -10_00, + -5_00, + -1_00, + -1, + 0, + 1, + 5, + 1_00, + 2_00, + 3_00, + 4_00, + 5_00, + 6_00, + 7_00, + 8_00, + 9_00, + 10_00, + 15_00, + 20_00, + 25_00, + 30_00, + 40_00, + 50_00, + 75_00, + 100_00, + 250_00, + 500_00, + None, +) +if TYPE_CHECKING: from generalresearch.managers.thl.product import ProductManager from generalresearch.models.thl.ledger import LedgerAccount from generalresearch.models.thl.product import Product @@ -183,6 +223,294 @@ class POPFinancial(BaseModel): return res +class UserWalletBalances(BaseModel): + """Cumulative ledger activity and balance for one user's USD wallet. + + All monetary values are integer USD cents and are expressed from the user's + perspective: credits increase the wallet balance and debits decrease it. + A positive balance is an outstanding liability for the Brokerage Product; + a negative balance is tracked separately and must not offset liabilities to + other users when producing Product-level totals. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + product_id: UUIDStr = Field( + description="Brokerage Product that owns this user wallet.", + examples=[uuid4().hex], + ) + product_user_id: BPUIDStr = Field() + + last_event: AwareDatetimeISO | None = Field( + default=None, + description=( + "Timestamp of the most recent ledger event included in these totals, " + "or null when the wallet has no events." + ), + ) + + bp_payment_credit: NonNegativeInt = Field( + default=0, + validation_alias="bp_payment.CREDIT", + description="Total task-completion earnings credited to this wallet.", + examples=[18_837], + ) + + adjustment_credit: NonNegativeInt = Field( + default=0, + validation_alias="bp_adjustment.CREDIT", + description="Total positive task reconciliations credited to this wallet.", + examples=[2], + ) + + adjustment_debit: NonNegativeInt = Field( + default=0, + validation_alias="bp_adjustment.DEBIT", + description="Total negative task reconciliations debited from this wallet.", + examples=[753], + ) + + user_bonus_credit: NonNegativeInt = Field( + default=0, + validation_alias="user_bonus.CREDIT", + description="Total non-task bonuses credited to this wallet.", + examples=[0], + ) + + user_bonus_debit: NonNegativeInt = Field( + default=0, + validation_alias="user_bonus.DEBIT", + description="Total bonus reversals debited from this wallet.", + examples=[2_745], + ) + + user_payout_request: NonNegativeInt = Field( + default=0, + validation_alias="user_payout_request.DEBIT", + description=( + "Total payout requests debited from this wallet. These amounts are no " + "longer part of the unredeemed wallet balance." + ), + examples=[18_837], + ) + + user_payout_cancel: NonNegativeInt = Field( + default=0, + validation_alias="user_payout_cancel.CREDIT", + description="Total cancelled payout requests returned to this wallet.", + examples=[18_837], + ) + + user_enter_contest_debit: NonNegativeInt = Field( + default=0, + validation_alias="user_enter_contest.DEBIT", + description="Total cash entries transferred from this wallet to contests.", + examples=[500], + ) + + close_contest_credit: NonNegativeInt = Field( + default=0, + validation_alias="close_contest.CREDIT", + description="Total cash prizes credited when contests closed.", + examples=[2_500], + ) + + user_milestone_credit: NonNegativeInt = Field( + default=0, + validation_alias="user_milestone.CREDIT", + description="Total cash milestone awards credited to this wallet.", + examples=[1_000], + ) + + @computed_field(description="Total debits that decreased this wallet.") + @property + def debit(self) -> int: + return ( + self.adjustment_debit + + self.user_bonus_debit + + self.user_payout_request + + self.user_enter_contest_debit + ) + + @computed_field(description="Total credits that increased this wallet.") + @property + def credit(self) -> int: + return ( + self.bp_payment_credit + + self.adjustment_credit + + self.user_bonus_credit + + self.user_payout_cancel + + self.close_contest_credit + + self.user_milestone_credit + ) + + @computed_field( + title="Wallet Balance", + description=( + "Current wallet balance from the user's perspective. A positive value " + "is owed to the user; a negative value means the user owes or must earn " + "back that amount." + ), + examples=[5_341], + ) + @property + def balance(self) -> int: + return self.credit + (self.debit * -1) + + +class UserWalletBalanceHistogramBucket(BaseModel): + """One cumulative bucket in the user-wallet balance distribution. + + Monetary boundaries are integer USD cents. ``upper_bound=None`` represents + the required +Inf bucket containing every wallet in the distribution. + """ + + upper_bound: int | None = Field( + description="Inclusive upper boundary, or null for +Inf." + ) + cumulative_count: NonNegativeInt = Field( + description="Number of wallets at or below the upper boundary." + ) + + +def _empty_user_wallet_balance_histogram() -> list[UserWalletBalanceHistogramBucket]: + return [ + UserWalletBalanceHistogramBucket(upper_bound=bound, cumulative_count=0) + for bound in USER_WALLET_BALANCE_BUCKETS + ] + + +class ProductUserWalletBalances(BaseModel): + """Aggregate user-wallet activity for one Brokerage Product. + + All monetary values are integer USD cents. + + These totals should be computed directly by the data layer rather than by + materializing every ``UserWalletBalances`` instance. Products may have many + thousands of user wallets. + + Positive and negative wallet balances are aggregated separately because + a negative user balance MUST not reduce the BP's outstanding liability to + users with positive balances. + + Pending payouts are reported separately because a payout request removes funds + from the user's wallet before disbursement. + """ + + debit: NonNegativeInt = Field( + default=0, + description="Sum of ledger debits across the Product's user wallets.", + ) + credit: NonNegativeInt = Field( + default=0, + description="Sum of ledger credits across the Product's user wallets.", + ) + + outstanding_liability: NonNegativeInt = Field( + default=0, + description="Sum of positive user-wallet balances owed by the Product.", + ) + negative_balance_total: NonNegativeInt = Field( + default=0, + description=( + "Absolute sum of negative user-wallet balances. This does not reduce " + "outstanding_liability." + ), + ) + + positive_wallet_count: NonNegativeInt = Field( + default=0, + description="Number of user wallets with a positive balance.", + ) + zero_wallet_count: NonNegativeInt = Field( + default=0, + description="Number of user wallets with a zero balance.", + ) + negative_wallet_count: NonNegativeInt = Field( + default=0, + description="Number of user wallets with a negative balance.", + ) + + wallet_balance_buckets: list[UserWalletBalanceHistogramBucket] = Field( + default_factory=_empty_user_wallet_balance_histogram, + description=( + "Cumulative current-balance buckets for a Prometheus gauge histogram." + ), + ) + + oldest_event: AwareDatetimeISO | None = Field( + default=None, + description=( + "Oldest wallet-event timestamp included in this aggregation, or null " + "when no events were included." + ), + ) + newest_event: AwareDatetimeISO | None = Field( + default=None, + description=( + "Newest wallet-event timestamp included in this aggregation, or null " + "when no events were included." + ), + ) + + pending_payout_amount: NonNegativeInt = Field( + default=0, + description=( + "Total value of requested payouts that have not completed or been " + "cancelled." + ), + ) + pending_payout_count: NonNegativeInt = Field( + default=0, + description=( + "Number of payout requests that have not completed or been cancelled." + ), + ) + + @computed_field(description="Number of wallets represented by the histogram.") + @property + def wallet_balance_count(self) -> int: + return ( + self.positive_wallet_count + + self.zero_wallet_count + + self.negative_wallet_count + ) + + @computed_field(description="Sum of balances represented by the histogram.") + @property + def wallet_balance_sum(self) -> int: + return self.balance + + @model_validator(mode="after") + def validate_wallet_balance_buckets(self) -> ProductUserWalletBalances: + boundaries = tuple(bucket.upper_bound for bucket in self.wallet_balance_buckets) + assert boundaries == USER_WALLET_BALANCE_BUCKETS, ( + "wallet_balance_buckets must use USER_WALLET_BALANCE_BUCKETS" + ) + + counts = [bucket.cumulative_count for bucket in self.wallet_balance_buckets] + assert counts == sorted(counts), ( + "wallet balance bucket counts must be cumulative" + ) + assert counts[-1] == self.wallet_balance_count, ( + "+Inf bucket must equal wallet_balance_count" + ) + return self + + @computed_field( + title="Wallet Balance", + description=( + "Net balance across all user wallets (credits minus debits). This is a " + "net position, not the BP's outstanding liability when any individual " + "wallet has a negative balance." + ), + examples=[5_341], + ) + @property + def balance(self) -> int: + return self.credit + (self.debit * -1) + + class ProductBalances(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) -- cgit v1.2.3