Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a414add
test multi block update
jepidoptera May 30, 2025
6fc4ca3
implement multi block update
jepidoptera May 30, 2025
7fb30f6
add time_step property
jepidoptera May 30, 2025
63a8104
allow updates on a per-asset basis
jepidoptera Jun 19, 2025
3e5c713
update oracles only for assets which have changed
jepidoptera Jun 20, 2025
6a75308
track last_updated for each token
jepidoptera Jun 20, 2025
1c51bba
minimum update steps is 1
jepidoptera Jun 20, 2025
ddb79ce
fix test_oracle_one_empty_block
jepidoptera Jun 20, 2025
2fb9ddd
fix test_oracle_one_block_with_swaps
jepidoptera Jun 23, 2025
bf0486a
Merge branch 'refs/heads/main' into oracle_update
jepidoptera Jun 23, 2025
f2b4399
add test_oracle_multi_block
jepidoptera Jun 23, 2025
3ce134a
add flexibility to schedule_swaps
jepidoptera Jun 23, 2025
0aeb5b6
considerable changes
jepidoptera Jul 1, 2025
990c33d
two-part oracle update
jepidoptera Jul 1, 2025
30db8c5
outsource hypothesis strategies
jepidoptera Jul 1, 2025
407b592
move and add tests
jepidoptera Jul 1, 2025
03dafb6
allow len(swaps) < time_steps
jepidoptera Jul 1, 2025
acc81ef
add test_dynamic_fee_test_case
jepidoptera Jul 7, 2025
d1a0843
rearrange fee or oracle updates
jepidoptera Jul 7, 2025
ad22d33
include output values in test_dynamic_fee_test_case
jepidoptera Jul 7, 2025
f31a3a7
include oracle values in test_dynamic_fee_test_case
jepidoptera Jul 8, 2025
a3d2f37
update oracle values in comment
jepidoptera Jul 8, 2025
d16f99f
separate out update_oracles function
jepidoptera Jul 15, 2025
2f4d7d0
fix initial oracle update
jepidoptera Jul 18, 2025
39501a1
fix block copy from self
jepidoptera Jul 18, 2025
7479f8b
replace continuous fee update in test
jepidoptera Jul 18, 2025
08c5da1
add one extra test case
jepidoptera Jul 18, 2025
2ae6e16
update constant_swaps
jepidoptera Jul 18, 2025
4b6307d
fix fee updates (always update oracles first)
jepidoptera Jul 21, 2025
f881406
fix test_dynamic_fees_empty_block
jepidoptera Jul 21, 2025
89504b9
don't update oracles on fee calculation
jepidoptera Jul 28, 2025
802eebb
require pytest-xdist (needed for -n auto multi-core testing option)
jepidoptera Jul 28, 2025
9697d8b
fix test_dynamic_fees
jepidoptera Jul 28, 2025
036280e
fix test_dynamic_fees_empty_block
jepidoptera Jul 28, 2025
c8f3910
fix test_dynamic_fees_with_trade
jepidoptera Jul 29, 2025
cc4c4ef
update oracles when fees are updated
jepidoptera Jul 29, 2025
abf9120
fix test_oracle_one_empty_block
jepidoptera Jul 29, 2025
08cd1fb
fuzz timesteps for test_update_every_block
jepidoptera Jul 29, 2025
f96993a
add docstring to test_update_every_block
jepidoptera Jul 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions hydradx/model/amm/exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Exchange:
asset_list: list[str]
liquidity: dict[str: float]
update_function: Callable = None
time_step: int = 0

def __init__(self):
self.fail = ''
Expand Down
118 changes: 85 additions & 33 deletions hydradx/model/amm/omnipool_amm.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .exchange import Exchange
from .oracle import Oracle, Block, OracleArchiveState


class AssetFeeCallable(Protocol):
def __call__(self, tkn: str) -> float: ...

Expand Down Expand Up @@ -116,22 +117,23 @@ def __init__(self,

self.oracles = {}

for token, pool in tokens.items():
assert pool['liquidity'], f'token {token} missing required parameter: liquidity'
if 'LRNA' in pool:
lrna = pool['LRNA']
elif 'LRNA_price' in pool:
lrna = pool['liquidity'] * pool['LRNA_price']
for token, attrs in tokens.items():
assert attrs['liquidity'], f'token {token} missing required parameter: liquidity'
if 'LRNA' in attrs:
lrna = attrs['LRNA']
elif 'LRNA_price' in attrs:
lrna = attrs['liquidity'] * attrs['LRNA_price']
else:
raise ValueError("token {name} missing required parameter: ('LRNA' or 'LRNA_price)")
self.add_token(
token,
liquidity=pool['liquidity'],
liquidity=attrs['liquidity'],
lrna=lrna,
shares=pool['shares'] if 'shares' in pool else pool['liquidity'],
protocol_shares=pool['shares'] if 'shares' in pool else pool['liquidity'],
weight_cap=pool['weight_cap'] if 'weight_cap' in pool else 1
shares=attrs['shares'] if 'shares' in attrs else attrs['liquidity'],
protocol_shares=attrs['shares'] if 'shares' in attrs else attrs['liquidity'],
weight_cap=attrs['weight_cap'] if 'weight_cap' in attrs else 1
)
self.oracles = {}

if oracles is None or 'price' not in oracles:
if last_oracle_values is None or 'price' not in last_oracle_values:
Expand All @@ -143,7 +145,7 @@ def __init__(self,
self.oracles.update({
name: Oracle(
sma_equivalent_length=period,
last_values=last_oracle_values[name] if name in last_oracle_values else None
last_values=last_oracle_values[name] if name in last_oracle_values else None,
)
for name, period in oracles.items()
})
Expand All @@ -152,6 +154,8 @@ def __init__(self,
name: Oracle(sma_equivalent_length=period, first_block=Block(self))
for name, period in oracles.items()
})
for oracle in self.oracles.values():
oracle.last_updated = {tkn: -1 for tkn in self.asset_list}

# trades per block cannot exceed this fraction of the pool's liquidity
self.trade_limit_per_block = trade_limit_per_block
Expand All @@ -167,19 +171,26 @@ def __init__(self,
self.lrna_fee_destination = lrna_fee_destination
self.dynamic_fee_precision = dynamic_fee_precision

# given provided oracle values, calculate initial fees
if last_oracle_values:
self.update_fees()

self.current_block = Block(self)
self.last_block = self.current_block.copy()
self.last_block.time_step = -1
self.unique_id = unique_id

def _create_dynamic_fee(self, value: DynamicFee or dict or float, fee_type: Literal['lrna', 'asset']) -> DynamicFee:
raise_oracle = 'price'

def get_last_volume():
return {
tkn: (self.oracles[raise_oracle].volume_in[tkn]
- self.oracles[raise_oracle].volume_out[tkn])
if fee_type == 'lrna' else
(self.oracles[raise_oracle].volume_out[tkn]
- self.oracles[raise_oracle].volume_in[tkn])
for tkn in self.asset_list
return {tkn: (
self.oracles[raise_oracle].volume_in[tkn]
- self.oracles[raise_oracle].volume_out[tkn]
) if fee_type == 'lrna' else (
self.oracles[raise_oracle].volume_out[tkn]
- self.oracles[raise_oracle].volume_in[tkn]
) for tkn in self.asset_list
}
if isinstance(value, DynamicFee):
return_val = DynamicFee(
Expand All @@ -205,7 +216,8 @@ def get_last_volume():
minimum=min(current.values()),
maximum=max(current.values()),
liquidity={tkn: self.liquidity[tkn] for tkn in self.liquidity},
net_volume=get_last_volume()
net_volume=get_last_volume(),
last_updated={tkn: self.time_step for tkn in self.asset_list},
)
else: # value is a number
return DynamicFee(
Expand Down Expand Up @@ -322,12 +334,37 @@ def remove_token(self, tkn: str):
return self

def update(self):

# run update function if present
if self.update_function:
self.update_function(self)

# update oracles
self.current_block.price['HDX'] = self.lrna['HDX'] / self.liquidity['HDX']
oracle_update_assets = [tkn for tkn in self.asset_list if (
self.current_block.volume_in[tkn] != 0
or self.current_block.volume_out[tkn] != 0
or self.current_block.lps[tkn] != 0
or self.current_block.withdrawals[tkn] != 0
or self._asset_fee.last_updated[tkn] == self.time_step
or self._lrna_fee.last_updated[tkn] == self.time_step
)]

for name, oracle in self.oracles.items():
oracle.update(self.current_block)
if len(oracle_update_assets) > 0:
self.update_oracles(oracle_update_assets)

self.update_fees()

# update current block
self.last_block = self.current_block
self.last_block.volume_in = {tkn: 0 for tkn in self.asset_list}
self.last_block.volume_out = {tkn: 0 for tkn in self.asset_list}
self.time_step += 1
self.current_block = Block(self)
self.fail = ''

return self

def update_fees(self):
# update fees
self._lrna_fee.update(
time_step=self.time_step,
Expand All @@ -345,17 +382,29 @@ def update(self):
},
liquidity=self.oracles['price'].liquidity
)
return self

# update current block
self.time_step += 1
self.current_block = Block(self)

self.fail = ''
if self.update_function:
self.update_function(self)
def update_oracles(self, assets: list[str] = None):
"""
Update oracles for the current block.
If assets is None, update all oracles for all assets.
"""
if assets is None:
assets = self.asset_list

for name, oracle in self.oracles.items():
if max(oracle.last_updated.values()) < self.last_block.time_step:
oracle.update(
block=self.last_block,
assets=assets
)
oracle.update(
block=self.current_block,
assets=assets
)
return self


@property
def lrna_total(self):
return sum(self.lrna.values())
Expand Down Expand Up @@ -580,14 +629,14 @@ def swap(
# per-block trade limits
if (
-delta_Rj - self.current_block.volume_in[tkn_buy] + self.current_block.volume_out[tkn_buy]
> self.trade_limit_per_block * self.current_block.liquidity[tkn_buy]
> self.trade_limit_per_block * self.last_block.liquidity[tkn_buy]
):
return self.fail_transaction(
f'{self.trade_limit_per_block * 100}% per block trade limit exceeded in {tkn_buy}.'
)
elif (
delta_Ri + self.current_block.volume_in[tkn_sell] - self.current_block.volume_out[tkn_sell]
> self.trade_limit_per_block * self.current_block.liquidity[tkn_sell]
> self.trade_limit_per_block * self.last_block.liquidity[tkn_sell]
):
return self.fail_transaction(
f'{self.trade_limit_per_block * 100}% per block trade limit exceeded in {tkn_sell}.'
Expand All @@ -608,19 +657,20 @@ def swap(
buy_quantity = old_buy_liquidity - self.liquidity[tkn_buy]
self.current_block.volume_out[tkn_buy] += buy_quantity
self.current_block.price[tkn_buy] = self.lrna[tkn_buy] / self.liquidity[tkn_buy]
self.current_block.liquidity[tkn_buy] = self.liquidity[tkn_buy]
if tkn_sell in self.asset_list:
sell_quantity = self.liquidity[tkn_sell] - old_sell_liquidity
self.current_block.volume_in[tkn_sell] += sell_quantity
self.current_block.price[tkn_sell] = self.lrna[tkn_sell] / self.liquidity[tkn_sell]
self.current_block.liquidity[tkn_sell] = self.liquidity[tkn_sell]
return return_val

def _lrna_swap(
self,
agent: Agent,
delta_ra: float = 0,
delta_qa: float = 0,
tkn: str = '',
modify_imbalance: bool = True
tkn: str = ''
):
"""
Execute LRNA swap in place (modify and return)
Expand Down Expand Up @@ -879,6 +929,7 @@ def add_liquidity(

# update block
self.current_block.lps[tkn_add] += quantity
self.current_block.liquidity[tkn_add] += quantity
# update fees
self.asset_fee(tkn_add)
self.lrna_fee(tkn_add)
Expand Down Expand Up @@ -978,6 +1029,7 @@ def remove_liquidity(self, agent: Agent, quantity: float = None, tkn_remove: str
del agent.nfts[nft_id]

self.current_block.withdrawals[tkn_remove] += abs(delta_s)
self.current_block.liquidity[tkn_remove] += delta_r
# update fees
self.asset_fee(tkn_remove)
self.lrna_fee(tkn_remove)
Expand Down
104 changes: 74 additions & 30 deletions hydradx/model/amm/oracle.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,35 @@
from .exchange import Exchange
from typing import Protocol, Callable


class ExchangeLike(Protocol):
liquidity: dict
asset_list: list
price: dict or Callable[[str], float]
time_step: int


class Block:
def __init__(self, input_state: Exchange):
def __init__(self, input_state: ExchangeLike):
price = {tkn: input_state.price(tkn) for tkn in input_state.asset_list} \
if isinstance(input_state, Exchange) else input_state.price
self.liquidity = {tkn: input_state.liquidity[tkn] for tkn in input_state.asset_list}
self.price = {tkn: input_state.price(tkn) for tkn in input_state.asset_list}
self.volume_in = {tkn: 0 for tkn in input_state.asset_list}
self.volume_out = {tkn: 0 for tkn in input_state.asset_list}
self.withdrawals = {tkn: 0 for tkn in input_state.asset_list}
self.lps = {tkn: 0 for tkn in input_state.asset_list}
self.price = {tkn: price[tkn] for tkn in input_state.asset_list}
self.volume_in = {
tkn: input_state.volume_in[tkn]
if hasattr(input_state, 'volume_in') else 0.0 for tkn in input_state.asset_list
}
self.volume_out = {
tkn: input_state.volume_out[tkn]
if hasattr(input_state, 'volume_out') else 0.0 for tkn in input_state.asset_list
}
self.withdrawals = {tkn: 0.0 for tkn in input_state.asset_list}
self.lps = {tkn: 0.0 for tkn in input_state.asset_list}
self.asset_list = input_state.asset_list.copy()
self.time_step = input_state.time_step

def copy(self):
return Block(input_state=self)


class Oracle:
Expand All @@ -28,44 +48,68 @@ def __init__(self, first_block: Block = None, decay_factor: float = 0, sma_equiv
self.price = {k: v for (k, v) in last_values['price'].items()}
self.volume_in = {k: v for (k, v) in last_values['volume_in'].items()}
self.volume_out = {k: v for (k, v) in last_values['volume_out'].items()}
self.last_updated = (
{k: v for (k, v) in last_values['last_updated'].items()}
if 'last_updated' in last_values
else {tkn: 0 for tkn in self.asset_list}
)
self.time_step = last_values['time_step'] if 'time_step' in last_values else 0
elif first_block is not None:
self.asset_list = first_block.asset_list
self.liquidity = first_block.liquidity
self.price = first_block.price
self.volume_in = first_block.volume_in
self.volume_out = first_block.volume_out
self.asset_list = [tkn for tkn in first_block.asset_list]
self.liquidity = {k: v for (k, v) in first_block.liquidity.items()}
self.price = {k: v for (k, v) in first_block.price.items()}
self.volume_in = {tkn: first_block.volume_in[tkn] for tkn in self.asset_list}
self.volume_out = {tkn: first_block.volume_out[tkn] for tkn in self.asset_list}
self.time_step = first_block.time_step
self.last_updated = {tkn: first_block.time_step for tkn in self.asset_list}
else:
raise ValueError('Either last_values or first_block must be specified')
self.age = 0

def add_asset(self, tkn: str, liquidity: float):
self.liquidity[tkn] = liquidity
self.volume_in[tkn] = 0
self.volume_out[tkn] = 0
self.volume_in[tkn] = 0.0
self.volume_out[tkn] = 0.0
self.asset_list.append(tkn)

def update(self, block: Block):
self.age += 1
for tkn in block.liquidity:
self.liquidity[tkn] = (
(1 - self.decay_factor) * self.liquidity[tkn] + self.decay_factor * block.liquidity[tkn]
) if tkn in self.liquidity else block.liquidity[tkn]
self.price[tkn] = (
(1 - self.decay_factor) * self.price[tkn] + self.decay_factor * block.price[tkn]
) if tkn in self.price else block.price[tkn]
self.volume_in[tkn] = (
(1 - self.decay_factor) * self.volume_in[tkn] + self.decay_factor * block.volume_in[tkn]
) if tkn in self.volume_in else block.volume_in[tkn]
self.volume_out[tkn] = (
(1 - self.decay_factor) * self.volume_out[tkn] + self.decay_factor * block.volume_out[tkn]
) if tkn in self.volume_out else block.volume_out[tkn]
def update(self, block: Block, assets: list[str] = None):
if assets is None:
assets = self.asset_list
update_steps = {tkn: max(block.time_step - self.last_updated[tkn], 0) for tkn in assets}
update_factor = {tkn: (1 - self.decay_factor) ** update_steps[tkn] for tkn in assets}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have sometimes used math.pow() instead of "**", I don't exactly remember if it was for accuracy or what?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked it up, and it seems that the difference is that while ** is slightly faster, math.pow will always return a float (regardless of input type, including int or mpf). In this case it seems like ** is best.

for tkn in assets:
if update_steps[tkn] == 0:
continue
# we assume price and liquidity have stayed the same since last update
self.liquidity[tkn] = update_factor[tkn] * self.liquidity[tkn] + (1 - update_factor[tkn]) * block.liquidity[tkn]
self.price[tkn] = update_factor[tkn] * self.price[tkn] + (1 - update_factor[tkn]) * block.price[tkn]
# we assume volume_in and volume_out have been 0 since last update
self.volume_in[tkn] = update_factor[tkn] * self.volume_in[tkn] + self.decay_factor * block.volume_in[tkn]
self.volume_out[tkn] = update_factor[tkn] * self.volume_out[tkn] + self.decay_factor * block.volume_out[tkn]
self.last_updated[tkn] = block.time_step
self.time_step = block.time_step
return self

def copy(self):
new_oracle = Oracle(
first_block=None,
decay_factor=self.decay_factor,
sma_equivalent_length=self.length,
last_values={
'liquidity': self.liquidity,
'price': self.price,
'volume_in': self.volume_in,
'volume_out': self.volume_out,
'last_updated': self.last_updated
}
)
new_oracle.time_step = self.time_step
return new_oracle


class OracleArchiveState:
def __init__(self, oracle: Oracle):
self.liquidity = {tkn: oracle.liquidity[tkn] for tkn in oracle.asset_list}
self.price = {tkn: oracle.price[tkn] for tkn in oracle.asset_list}
self.volume_in = {tkn: oracle.volume_in[tkn] for tkn in oracle.asset_list}
self.volume_out = {tkn: oracle.volume_out[tkn] for tkn in oracle.asset_list}
self.age = oracle.age
self.age = oracle.time_step
Loading
Loading