From 3847ae55d67867cba6e114802690888d767f47b2 Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Fri, 30 Jan 2026 16:29:22 +0200 Subject: [PATCH 1/5] Add Redemption Orderbook (ROB) incentive rewards distribution --- README.md | 31 +++++++++- indy_rewards/analytics_api/raw.py | 35 ++++++++++++ indy_rewards/cli.py | 23 +++++++- indy_rewards/config.py | 7 +++ indy_rewards/rob/__init__.py | 1 + indy_rewards/rob/distribution.py | 95 +++++++++++++++++++++++++++++++ indy_rewards/summary.py | 22 +++++-- indy_rewards/time_utils.py | 5 ++ tests/test_summary.py | 4 ++ 9 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 indy_rewards/rob/__init__.py create mode 100644 indy_rewards/rob/distribution.py diff --git a/README.md b/README.md index 54278c9..118e4c1 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ of the wallet's first address.
All rewards for all wallets -Entire epoch. Daily SP, LP, governance rewards for 5 days: +Entire epoch. Daily SP, LP, governance and ROB rewards: ```console $ indy-rewards all 414 @@ -204,6 +204,35 @@ $ indy-rewards gov 415
+
+ROB (Redemption Orderbook) incentive rewards + +Rewards for Redemption Orderbook positions that are "in range" during an epoch. +Each epoch is split into 480 periods of 15 minutes, and INDY is distributed +pro-rata based on each owner's lovelaceAmount share per period. + +```console +$ indy-rewards rob 608 + Period Address Purpose Date Amount Expiration AvailableAt + 609 aada39748edc9f40ec53f879499a837f6badf180413fc03a7a345609 ROB reward for iUSD 2026-01-24 1.234567 2026-04-25 21:45 2026-01-24 23:00 + 609 6699280ab41b732e26e7d3cb02d57f61a76bc8e9a0ceccca4997b812 ROB reward for iUSD 2026-01-24 0.567890 2026-04-25 21:45 2026-01-24 23:00 + … +``` + +PKH filter also works: + +```console +$ indy-rewards rob --pkh aada 608 +``` + +File output: + +```console +$ indy-rewards rob --outfile rob-608.csv 608 +``` + +
+
LP token staking INDY APR diff --git a/indy_rewards/analytics_api/raw.py b/indy_rewards/analytics_api/raw.py index 9f3f8bd..f65fa39 100644 --- a/indy_rewards/analytics_api/raw.py +++ b/indy_rewards/analytics_api/raw.py @@ -5,6 +5,7 @@ import requests BASE_URL = "https://analytics.indigoprotocol.io/api" +BASE_URL_V1 = "https://analytics.indigoprotocol.io/api/v1" TIMEOUT = 20 # Seconds. @@ -428,3 +429,37 @@ def rewards_staking(snapshot_unix_time: float) -> list[dict]: ) response.raise_for_status() return response.json() + + +def redemption_orders( + at_unix_time: float, in_range: bool = True +) -> list[dict]: + """Redemption orderbook positions at a given time. + + Args: + at_unix_time: Unix time (in seconds) for the snapshot. + in_range: If True, only return orders that are "in range". + + Returns: + List of dicts, each representing a redemption order position. + + Dict structure (at minimum): + + owner (str): PaymentKeyHash of the owner, in hex. + lovelaceAmount (int): Amount in lovelaces for the position. + + Examples: + >>> orders = redemption_orders(1737752700) + >>> orders[0] + {'owner': '...', 'lovelaceAmount': 123456789, ...} + """ + params: dict[str, float | str] = {"timestamp": at_unix_time} + if in_range: + params["in_range"] = "true" + response = requests.get( + BASE_URL_V1 + "/rewards/redemption-orders", + params=params, + timeout=TIMEOUT, + ) + response.raise_for_status() + return response.json() diff --git a/indy_rewards/cli.py b/indy_rewards/cli.py index 73ca847..d84a58c 100644 --- a/indy_rewards/cli.py +++ b/indy_rewards/cli.py @@ -10,6 +10,7 @@ from indy_rewards import gov as gov_module from indy_rewards import lp as lp_module from indy_rewards import polygon_api +from indy_rewards import rob as rob_module from indy_rewards import sp as sp_module from indy_rewards import summary, time_utils, volatility from indy_rewards.models import ( @@ -206,6 +207,21 @@ def gov(indy: float, pkh: tuple[str], outfile: str, epoch: int): _output(rewards, outfile) +@rewards.command() +@pkh_option +@outfile_option +@click.argument("epoch", type=int) +def rob(pkh: tuple[str], outfile: str, epoch: int): + """Print or save Redemption Orderbook (ROB) incentive rewards. + + EPOCH: Epoch to get rewards for. + """ + _error_on_future(epoch) + rewards = rob_module.get_epoch_rewards_per_staker(epoch, config.ROB_EPOCH_INDY) + rewards = _pkh_filter(rewards, pkh) + _output(rewards, outfile) + + @rewards.command() @sp_indy_option() @pkh_option @@ -256,7 +272,7 @@ def sp_apr(indy: float, epoch_or_date: int | datetime.date): @outfile_option @epoch_or_date_arg def all(pkh: tuple[str], outfile: str, epoch_or_date: int | datetime.date): - """Print or save SP, LP and governance staking rewards.""" + """Print or save SP, LP, governance and ROB staking rewards.""" _load_polygon_api_key_or_fail(epoch_or_date) if isinstance(epoch_or_date, int): @@ -265,6 +281,7 @@ def all(pkh: tuple[str], outfile: str, epoch_or_date: int | datetime.date): sp_epoch_emission(epoch_or_date), config.LP_EPOCH_INDY, gov_epoch_emission(epoch_or_date), + config.ROB_EPOCH_INDY, ) else: rewards = summary.get_day_all_rewards( @@ -272,6 +289,7 @@ def all(pkh: tuple[str], outfile: str, epoch_or_date: int | datetime.date): sp_epoch_emission(time_utils.date_to_epoch(epoch_or_date)), config.LP_EPOCH_INDY, gov_epoch_emission(time_utils.date_to_epoch(epoch_or_date)), + config.ROB_EPOCH_INDY, ) rewards = _pkh_filter(rewards, pkh) @@ -314,6 +332,7 @@ def summary_command( sp_indy, lp_indy, gov_indy, + config.ROB_EPOCH_INDY, ) epoch_rewards = _pkh_filter(epoch_rewards, pkh) sum_table = summary.get_summary(epoch_rewards) @@ -323,7 +342,7 @@ def summary_command( if gov_indy == -1: gov_indy = gov_epoch_emission(time_utils.date_to_epoch(epoch_or_date)) day_rewards = summary.get_day_all_rewards( - epoch_or_date, sp_indy, lp_indy, gov_indy + epoch_or_date, sp_indy, lp_indy, gov_indy, config.ROB_EPOCH_INDY ) day_rewards = _pkh_filter(day_rewards, pkh) sum_table = summary.get_summary(day_rewards) diff --git a/indy_rewards/config.py b/indy_rewards/config.py index e9ce4e8..eadb79b 100644 --- a/indy_rewards/config.py +++ b/indy_rewards/config.py @@ -6,6 +6,13 @@ LP_EPOCH_INDY: Final[int] = 4795 +ROB_EPOCH_INDY: dict[IAsset, float] = { + IAsset.iUSD: 500.0, + IAsset.iBTC: 0.0, + IAsset.iETH: 0.0, + IAsset.iSOL: 0.0, +} + IASSET_LAUNCH_DATES = { IAsset.iUSD: datetime.date(2022, 11, 21), # Epoch 377's first day. IAsset.iBTC: datetime.date(2022, 11, 21), diff --git a/indy_rewards/rob/__init__.py b/indy_rewards/rob/__init__.py new file mode 100644 index 0000000..fa6fb66 --- /dev/null +++ b/indy_rewards/rob/__init__.py @@ -0,0 +1 @@ +from .distribution import get_epoch_rewards_per_staker diff --git a/indy_rewards/rob/distribution.py b/indy_rewards/rob/distribution.py new file mode 100644 index 0000000..92334e7 --- /dev/null +++ b/indy_rewards/rob/distribution.py @@ -0,0 +1,95 @@ +"""Redemption Orderbook (ROB) incentive reward distribution. + +Distributes INDY to owners with in-range redemption orderbook positions. +Each epoch is divided into 480 periods of 15 minutes (900 seconds). +For each period, INDY is distributed pro-rata based on each owner's share +of total lovelaceAmount across all in-range positions. +""" + +from collections import defaultdict + +from .. import analytics_api, time_utils +from ..models import IAsset, IndividualReward + + +NUM_PERIODS = 480 +PERIOD_SECONDS = 900 # 15 minutes + + +def get_epoch_rewards_per_staker( + epoch: int, rob_indy_per_iasset: dict[IAsset, float] +) -> list[IndividualReward]: + """Get individual ROB INDY rewards for an epoch. + + Args: + epoch: Epoch to calculate rewards for. + rob_indy_per_iasset: INDY amount to distribute per iAsset for the epoch. + E.g. {IAsset.iUSD: 500.0, IAsset.iBTC: 0.0, ...} + + Returns: + List of IndividualReward, one per owner per iAsset (aggregated across + all 480 periods). + """ + epoch_start_date = time_utils.get_epoch_start_date(epoch) + epoch_start_unix = time_utils.get_snapshot_unix_time(epoch_start_date) + epoch_end_date = time_utils.get_epoch_end_date(epoch) + + rewards: list[IndividualReward] = [] + + for iasset, epoch_indy in rob_indy_per_iasset.items(): + if epoch_indy <= 0: + continue + + owner_totals = _distribute_across_periods(epoch_start_unix, epoch_indy) + + for owner, total_indy in owner_totals.items(): + rewards.append( + IndividualReward( + indy=total_indy, + day=epoch_end_date, + pkh=owner, + expiration=time_utils.get_reward_expiration(epoch_end_date), + description=f"ROB reward for {iasset.name}", + ) + ) + + return rewards + + +def _distribute_across_periods( + epoch_start_unix: float, epoch_indy: float +) -> dict[str, float]: + """Distribute INDY across 480 periods and aggregate by owner. + + Args: + epoch_start_unix: Unix timestamp of epoch start (21:45 UTC). + epoch_indy: Total INDY to distribute for this iAsset this epoch. + + Returns: + Dict mapping owner PKH to total INDY earned across all periods. + """ + indy_per_period = epoch_indy / NUM_PERIODS + owner_totals: dict[str, float] = defaultdict(float) + + for i in range(NUM_PERIODS): + timestamp = epoch_start_unix + (i * PERIOD_SECONDS) + orders = analytics_api.raw.redemption_orders(timestamp, in_range=True) + + # No in-range orders for a period + if not orders: + continue + + # Group by owner and sum lovelaceAmount - Multiple positions per owner + owner_amounts: dict[str, int] = defaultdict(int) + for order in orders: + owner_amounts[order["owner"]] += order["lovelaceAmount"] + + total_amount = sum(owner_amounts.values()) + if total_amount == 0: + continue + + # Distribute pro-rata + for owner, amount in owner_amounts.items(): + owner_totals[owner] += indy_per_period * amount / total_amount + + return dict(owner_totals) diff --git a/indy_rewards/summary.py b/indy_rewards/summary.py index 89d2b0d..065772d 100644 --- a/indy_rewards/summary.py +++ b/indy_rewards/summary.py @@ -2,8 +2,8 @@ import pandas as pd -from . import gov, lp, sp, time_utils -from .models import IndividualReward +from . import config, gov, lp, rob, sp, time_utils +from .models import IAsset, IndividualReward def get_epoch_all_rewards( @@ -11,14 +11,17 @@ def get_epoch_all_rewards( sp_indy: float, lp_indy: float, gov_indy: float, + rob_indy: dict[IAsset, float] | None = None, ) -> list[IndividualReward]: - """Returns list of SP, LP and gov INDY rewards for accounts for an epoch. + """Returns list of SP, LP, gov and ROB INDY rewards for accounts for an epoch. Args: epoch: Epoch to calculate rewards for. sp_indy: INDY amount to distribute to SP stakers. E.g. 28768. lp_indy: INDY to distribute to LP stakers. gov_indy: INDY to distribute to INDY governance stakers. + rob_indy: INDY amounts per iAsset to distribute to ROB position holders. + If None, uses config.ROB_EPOCH_INDY. Returns: Pandas DataFrame with columns: @@ -31,10 +34,14 @@ def get_epoch_all_rewards( - Expiration: Date and time after which this reward is no longer claimable, e.g. 2023-06-20 21:45. """ + if rob_indy is None: + rob_indy = config.ROB_EPOCH_INDY + gov_rewards = gov.get_epoch_rewards_per_staker(epoch, gov_indy) sp_rewards = sp.get_epoch_rewards_per_staker(epoch, sp_indy) + rob_rewards = rob.get_epoch_rewards_per_staker(epoch, rob_indy) - all_rewards = sp_rewards + gov_rewards + all_rewards = sp_rewards + gov_rewards + rob_rewards if epoch < 422: all_rewards += lp.get_epoch_rewards_per_staker(epoch, lp_indy) @@ -47,13 +54,18 @@ def get_day_all_rewards( sp_indy_per_epoch: float, lp_indy_per_epoch: float, gov_indy_per_epoch: float, + rob_indy: dict[IAsset, float] | None = None, ) -> list[IndividualReward]: + if rob_indy is None: + rob_indy = config.ROB_EPOCH_INDY + rewards = [] epoch = time_utils.date_to_epoch(day) epoch_end_date = time_utils.get_epoch_end_date(epoch) if epoch_end_date == day: rewards += gov.get_epoch_rewards_per_staker(epoch, gov_indy_per_epoch) + rewards += rob.get_epoch_rewards_per_staker(epoch, rob_indy) rewards += sp.get_rewards_per_staker(day, sp_indy_per_epoch) if day <= datetime.date(2023, 7, 4): @@ -127,5 +139,7 @@ def _split_purpose(purpose: str) -> tuple[str, ...] | tuple[str, None]: return ("LP reward", split[3] + " on " + split[-1]) elif purpose.startswith("SP reward for "): return ("SP reward", purpose.removeprefix("SP reward for ")) + elif purpose.startswith("ROB reward for "): + return ("ROB reward", purpose.removeprefix("ROB reward for ")) else: return (purpose, None) diff --git a/indy_rewards/time_utils.py b/indy_rewards/time_utils.py index bcd910d..93ae316 100644 --- a/indy_rewards/time_utils.py +++ b/indy_rewards/time_utils.py @@ -23,6 +23,11 @@ def date_to_epoch(date: datetime.date) -> int: return epoch +def get_epoch_start_date(epoch: int) -> datetime.date: + """Get UTC date of the epoch's first block (= end of previous epoch).""" + return get_epoch_end_date(epoch - 1) + + def get_epoch_end_date(epoch: int) -> datetime.date: """Get UTC date of the epoch's last block.""" days_from_ref = (epoch + 1) * 5 diff --git a/tests/test_summary.py b/tests/test_summary.py index ddccde5..b10af2c 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -180,6 +180,10 @@ def mock_summary_dependencies(mocker: pytest_mock.MockerFixture): "indy_rewards.summary.gov.get_epoch_rewards_per_staker", wraps=mocked_gov_distribution, ) + mocker.patch( + "indy_rewards.summary.rob.get_epoch_rewards_per_staker", + return_value=[], + ) def get_expiration( From b2db35be0574932c7844e445fd7a767089717ec1 Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Mon, 2 Feb 2026 18:29:38 +0200 Subject: [PATCH 2/5] Parallelize Redemption Orderbook (ROB) period fetching for faster incentive rewards distribution --- indy_rewards/rob/distribution.py | 57 +++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/indy_rewards/rob/distribution.py b/indy_rewards/rob/distribution.py index 92334e7..d42fc8e 100644 --- a/indy_rewards/rob/distribution.py +++ b/indy_rewards/rob/distribution.py @@ -6,7 +6,9 @@ of total lovelaceAmount across all in-range positions. """ +import sys from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, as_completed from .. import analytics_api, time_utils from ..models import IAsset, IndividualReward @@ -14,6 +16,7 @@ NUM_PERIODS = 480 PERIOD_SECONDS = 900 # 15 minutes +MAX_WORKERS = 20 def get_epoch_rewards_per_staker( @@ -56,11 +59,18 @@ def get_epoch_rewards_per_staker( return rewards +def _fetch_orders(timestamp: float) -> list[dict]: + """Fetch redemption orders for a single timestamp.""" + return analytics_api.raw.redemption_orders(timestamp, in_range=True) + + def _distribute_across_periods( epoch_start_unix: float, epoch_indy: float ) -> dict[str, float]: """Distribute INDY across 480 periods and aggregate by owner. + Fetches all 480 periods in parallel using a thread pool. + Args: epoch_start_unix: Unix timestamp of epoch start (21:45 UTC). epoch_indy: Total INDY to distribute for this iAsset this epoch. @@ -71,25 +81,40 @@ def _distribute_across_periods( indy_per_period = epoch_indy / NUM_PERIODS owner_totals: dict[str, float] = defaultdict(float) - for i in range(NUM_PERIODS): - timestamp = epoch_start_unix + (i * PERIOD_SECONDS) - orders = analytics_api.raw.redemption_orders(timestamp, in_range=True) + timestamps = [ + epoch_start_unix + (i * PERIOD_SECONDS) for i in range(NUM_PERIODS) + ] + + completed = 0 + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: + future_to_ts = { + executor.submit(_fetch_orders, ts): ts for ts in timestamps + } + for future in as_completed(future_to_ts): + completed += 1 + print( + f"\rFetching ROB periods: {completed}/{NUM_PERIODS}", + end="", + file=sys.stderr, + flush=True, + ) + orders = future.result() - # No in-range orders for a period - if not orders: - continue + if not orders: + continue - # Group by owner and sum lovelaceAmount - Multiple positions per owner - owner_amounts: dict[str, int] = defaultdict(int) - for order in orders: - owner_amounts[order["owner"]] += order["lovelaceAmount"] + # Group by owner and sum lovelaceAmount - Multiple positions per owner + owner_amounts: dict[str, int] = defaultdict(int) + for order in orders: + owner_amounts[order["owner"]] += order["lovelaceAmount"] - total_amount = sum(owner_amounts.values()) - if total_amount == 0: - continue + total_amount = sum(owner_amounts.values()) + if total_amount == 0: + continue - # Distribute pro-rata - for owner, amount in owner_amounts.items(): - owner_totals[owner] += indy_per_period * amount / total_amount + # Distribute pro-rata + for owner, amount in owner_amounts.items(): + owner_totals[owner] += indy_per_period * amount / total_amount + print(file=sys.stderr) # Newline after progress return dict(owner_totals) From c742741dc52c52aecd6876e9629937d707815e98 Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Mon, 2 Feb 2026 18:47:15 +0200 Subject: [PATCH 3/5] Refactor ROB emission logic for flexibility and remove unused progress output --- indy_rewards/cli.py | 19 ++++++++++++------- indy_rewards/config.py | 7 +++++++ indy_rewards/rob/distribution.py | 10 ---------- indy_rewards/summary.py | 6 +++--- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/indy_rewards/cli.py b/indy_rewards/cli.py index d84a58c..ecbcfca 100644 --- a/indy_rewards/cli.py +++ b/indy_rewards/cli.py @@ -217,7 +217,7 @@ def rob(pkh: tuple[str], outfile: str, epoch: int): EPOCH: Epoch to get rewards for. """ _error_on_future(epoch) - rewards = rob_module.get_epoch_rewards_per_staker(epoch, config.ROB_EPOCH_INDY) + rewards = rob_module.get_epoch_rewards_per_staker(epoch, config.rob_epoch_emission(epoch)) rewards = _pkh_filter(rewards, pkh) _output(rewards, outfile) @@ -281,15 +281,16 @@ def all(pkh: tuple[str], outfile: str, epoch_or_date: int | datetime.date): sp_epoch_emission(epoch_or_date), config.LP_EPOCH_INDY, gov_epoch_emission(epoch_or_date), - config.ROB_EPOCH_INDY, + config.rob_epoch_emission(epoch_or_date), ) else: + _epoch = time_utils.date_to_epoch(epoch_or_date) rewards = summary.get_day_all_rewards( epoch_or_date, - sp_epoch_emission(time_utils.date_to_epoch(epoch_or_date)), + sp_epoch_emission(_epoch), config.LP_EPOCH_INDY, - gov_epoch_emission(time_utils.date_to_epoch(epoch_or_date)), - config.ROB_EPOCH_INDY, + gov_epoch_emission(_epoch), + config.rob_epoch_emission(_epoch), ) rewards = _pkh_filter(rewards, pkh) @@ -332,7 +333,7 @@ def summary_command( sp_indy, lp_indy, gov_indy, - config.ROB_EPOCH_INDY, + config.rob_epoch_emission(epoch_or_date), ) epoch_rewards = _pkh_filter(epoch_rewards, pkh) sum_table = summary.get_summary(epoch_rewards) @@ -342,7 +343,11 @@ def summary_command( if gov_indy == -1: gov_indy = gov_epoch_emission(time_utils.date_to_epoch(epoch_or_date)) day_rewards = summary.get_day_all_rewards( - epoch_or_date, sp_indy, lp_indy, gov_indy, config.ROB_EPOCH_INDY + epoch_or_date, + sp_indy, + lp_indy, + gov_indy, + config.rob_epoch_emission(time_utils.date_to_epoch(epoch_or_date)), ) day_rewards = _pkh_filter(day_rewards, pkh) sum_table = summary.get_summary(day_rewards) diff --git a/indy_rewards/config.py b/indy_rewards/config.py index eadb79b..492ef4f 100644 --- a/indy_rewards/config.py +++ b/indy_rewards/config.py @@ -13,6 +13,13 @@ IAsset.iSOL: 0.0, } + +def rob_epoch_emission(epoch: int) -> dict[IAsset, float]: + """Get ROB INDY emission amounts per iAsset for a given epoch.""" + if epoch >= 608: + return ROB_EPOCH_INDY + return {} + IASSET_LAUNCH_DATES = { IAsset.iUSD: datetime.date(2022, 11, 21), # Epoch 377's first day. IAsset.iBTC: datetime.date(2022, 11, 21), diff --git a/indy_rewards/rob/distribution.py b/indy_rewards/rob/distribution.py index d42fc8e..53d3b30 100644 --- a/indy_rewards/rob/distribution.py +++ b/indy_rewards/rob/distribution.py @@ -6,7 +6,6 @@ of total lovelaceAmount across all in-range positions. """ -import sys from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, as_completed @@ -85,19 +84,11 @@ def _distribute_across_periods( epoch_start_unix + (i * PERIOD_SECONDS) for i in range(NUM_PERIODS) ] - completed = 0 with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: future_to_ts = { executor.submit(_fetch_orders, ts): ts for ts in timestamps } for future in as_completed(future_to_ts): - completed += 1 - print( - f"\rFetching ROB periods: {completed}/{NUM_PERIODS}", - end="", - file=sys.stderr, - flush=True, - ) orders = future.result() if not orders: @@ -116,5 +107,4 @@ def _distribute_across_periods( for owner, amount in owner_amounts.items(): owner_totals[owner] += indy_per_period * amount / total_amount - print(file=sys.stderr) # Newline after progress return dict(owner_totals) diff --git a/indy_rewards/summary.py b/indy_rewards/summary.py index 065772d..9a1c3eb 100644 --- a/indy_rewards/summary.py +++ b/indy_rewards/summary.py @@ -21,7 +21,7 @@ def get_epoch_all_rewards( lp_indy: INDY to distribute to LP stakers. gov_indy: INDY to distribute to INDY governance stakers. rob_indy: INDY amounts per iAsset to distribute to ROB position holders. - If None, uses config.ROB_EPOCH_INDY. + If None, uses config.rob_epoch_emission(epoch). Returns: Pandas DataFrame with columns: @@ -35,7 +35,7 @@ def get_epoch_all_rewards( e.g. 2023-06-20 21:45. """ if rob_indy is None: - rob_indy = config.ROB_EPOCH_INDY + rob_indy = config.rob_epoch_emission(epoch) gov_rewards = gov.get_epoch_rewards_per_staker(epoch, gov_indy) sp_rewards = sp.get_epoch_rewards_per_staker(epoch, sp_indy) @@ -57,7 +57,7 @@ def get_day_all_rewards( rob_indy: dict[IAsset, float] | None = None, ) -> list[IndividualReward]: if rob_indy is None: - rob_indy = config.ROB_EPOCH_INDY + rob_indy = config.rob_epoch_emission(time_utils.date_to_epoch(day)) rewards = [] From a013272aebc1cc6355c6e3baf233aa606515e071 Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Mon, 2 Feb 2026 19:07:58 +0200 Subject: [PATCH 4/5] Filter ROB rewards distribution by asset name to ensure correct allocation --- indy_rewards/rob/distribution.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/indy_rewards/rob/distribution.py b/indy_rewards/rob/distribution.py index 53d3b30..3c538fe 100644 --- a/indy_rewards/rob/distribution.py +++ b/indy_rewards/rob/distribution.py @@ -42,7 +42,9 @@ def get_epoch_rewards_per_staker( if epoch_indy <= 0: continue - owner_totals = _distribute_across_periods(epoch_start_unix, epoch_indy) + owner_totals = _distribute_across_periods( + epoch_start_unix, epoch_indy, iasset.name + ) for owner, total_indy in owner_totals.items(): rewards.append( @@ -64,7 +66,7 @@ def _fetch_orders(timestamp: float) -> list[dict]: def _distribute_across_periods( - epoch_start_unix: float, epoch_indy: float + epoch_start_unix: float, epoch_indy: float, asset_name: str ) -> dict[str, float]: """Distribute INDY across 480 periods and aggregate by owner. @@ -73,6 +75,7 @@ def _distribute_across_periods( Args: epoch_start_unix: Unix timestamp of epoch start (21:45 UTC). epoch_indy: Total INDY to distribute for this iAsset this epoch. + asset_name: Only include orders matching this asset (e.g. "iUSD"). Returns: Dict mapping owner PKH to total INDY earned across all periods. @@ -94,10 +97,11 @@ def _distribute_across_periods( if not orders: continue - # Group by owner and sum lovelaceAmount - Multiple positions per owner + # Filter by asset and group by owner, summing lovelaceAmount owner_amounts: dict[str, int] = defaultdict(int) for order in orders: - owner_amounts[order["owner"]] += order["lovelaceAmount"] + if order["asset"] == asset_name: + owner_amounts[order["owner"]] += order["lovelaceAmount"] total_amount = sum(owner_amounts.values()) if total_amount == 0: From 5543e1c4924fec57112eb01dabd9bb0a8b9431a4 Mon Sep 17 00:00:00 2001 From: "a.dacapo21" Date: Mon, 2 Feb 2026 19:25:25 +0200 Subject: [PATCH 5/5] Fetch ROB periods once and distribute per asset to avoid redundant API calls --- indy_rewards/rob/distribution.py | 93 ++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 35 deletions(-) diff --git a/indy_rewards/rob/distribution.py b/indy_rewards/rob/distribution.py index 3c538fe..0de3069 100644 --- a/indy_rewards/rob/distribution.py +++ b/indy_rewards/rob/distribution.py @@ -23,6 +23,10 @@ def get_epoch_rewards_per_staker( ) -> list[IndividualReward]: """Get individual ROB INDY rewards for an epoch. + Fetches all 480 periods once, then distributes INDY per iAsset from + the same data. This avoids redundant API calls when multiple iAssets + have non-zero emissions. + Args: epoch: Epoch to calculate rewards for. rob_indy_per_iasset: INDY amount to distribute per iAsset for the epoch. @@ -32,18 +36,25 @@ def get_epoch_rewards_per_staker( List of IndividualReward, one per owner per iAsset (aggregated across all 480 periods). """ + # Filter to only iAssets with non-zero INDY + active_iassets = { + iasset: indy for iasset, indy in rob_indy_per_iasset.items() if indy > 0 + } + if not active_iassets: + return [] + epoch_start_date = time_utils.get_epoch_start_date(epoch) epoch_start_unix = time_utils.get_snapshot_unix_time(epoch_start_date) epoch_end_date = time_utils.get_epoch_end_date(epoch) - rewards: list[IndividualReward] = [] + # Fetch all 480 periods once + all_period_orders = _fetch_all_periods(epoch_start_unix) - for iasset, epoch_indy in rob_indy_per_iasset.items(): - if epoch_indy <= 0: - continue + rewards: list[IndividualReward] = [] - owner_totals = _distribute_across_periods( - epoch_start_unix, epoch_indy, iasset.name + for iasset, epoch_indy in active_iassets.items(): + owner_totals = _distribute_for_asset( + all_period_orders, epoch_indy, iasset.name ) for owner, total_indy in owner_totals.items(): @@ -65,50 +76,62 @@ def _fetch_orders(timestamp: float) -> list[dict]: return analytics_api.raw.redemption_orders(timestamp, in_range=True) -def _distribute_across_periods( - epoch_start_unix: float, epoch_indy: float, asset_name: str -) -> dict[str, float]: - """Distribute INDY across 480 periods and aggregate by owner. - - Fetches all 480 periods in parallel using a thread pool. - - Args: - epoch_start_unix: Unix timestamp of epoch start (21:45 UTC). - epoch_indy: Total INDY to distribute for this iAsset this epoch. - asset_name: Only include orders matching this asset (e.g. "iUSD"). +def _fetch_all_periods(epoch_start_unix: float) -> list[list[dict]]: + """Fetch redemption orders for all 480 periods in parallel. Returns: - Dict mapping owner PKH to total INDY earned across all periods. + List of 480 order lists (one per period). """ - indy_per_period = epoch_indy / NUM_PERIODS - owner_totals: dict[str, float] = defaultdict(float) - timestamps = [ epoch_start_unix + (i * PERIOD_SECONDS) for i in range(NUM_PERIODS) ] + # Use dict to preserve period ordering by timestamp + results: dict[float, list[dict]] = {} + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: future_to_ts = { executor.submit(_fetch_orders, ts): ts for ts in timestamps } for future in as_completed(future_to_ts): - orders = future.result() + ts = future_to_ts[future] + results[ts] = future.result() - if not orders: - continue + return [results[ts] for ts in timestamps] - # Filter by asset and group by owner, summing lovelaceAmount - owner_amounts: dict[str, int] = defaultdict(int) - for order in orders: - if order["asset"] == asset_name: - owner_amounts[order["owner"]] += order["lovelaceAmount"] - total_amount = sum(owner_amounts.values()) - if total_amount == 0: - continue +def _distribute_for_asset( + all_period_orders: list[list[dict]], epoch_indy: float, asset_name: str +) -> dict[str, float]: + """Distribute INDY for a single asset across all periods. + + Args: + all_period_orders: Pre-fetched orders for all 480 periods. + epoch_indy: Total INDY to distribute for this iAsset this epoch. + asset_name: Only include orders matching this asset (e.g. "iUSD"). + + Returns: + Dict mapping owner PKH to total INDY earned across all periods. + """ + indy_per_period = epoch_indy / NUM_PERIODS + owner_totals: dict[str, float] = defaultdict(float) + + for orders in all_period_orders: + if not orders: + continue + + # Filter by asset and group by owner, summing lovelaceAmount + owner_amounts: dict[str, int] = defaultdict(int) + for order in orders: + if order["asset"] == asset_name: + owner_amounts[order["owner"]] += order["lovelaceAmount"] + + total_amount = sum(owner_amounts.values()) + if total_amount == 0: + continue - # Distribute pro-rata - for owner, amount in owner_amounts.items(): - owner_totals[owner] += indy_per_period * amount / total_amount + # Distribute pro-rata + for owner, amount in owner_amounts.items(): + owner_totals[owner] += indy_per_period * amount / total_amount return dict(owner_totals)