aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorstuppie2026-10-01 18:43:37 -0600
committerstuppie2026-10-01 18:43:37 -0600
commit569bf733f2288c2892aac1b09224275d52ebac43 (patch)
tree058a4e4f9141f7bf25e54c33d9422365fe2460b6
parent219b31843c0f3b12ad90e76f8cc0f84cbbb7a268 (diff)
downloadgeneralresearch-569bf733f2288c2892aac1b09224275d52ebac43.tar.gz
generalresearch-569bf733f2288c2892aac1b09224275d52ebac43.zip
sketching out UserWalletBalances and ProductUserWalletBalances + balance histograms for reporting
-rw-r--r--generalresearch/models/thl/finance.py330
1 files changed, 329 insertions, 1 deletions
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)