Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
18 changes: 15 additions & 3 deletions hydradx/model/amm/omnipool_amm.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ def __init__(self,
protocol_shares=pool['shares'] if 'shares' in pool else pool['liquidity'],
weight_cap=pool['weight_cap'] if 'weight_cap' in pool 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 Down Expand Up @@ -205,7 +206,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 - 1 for tkn in self.asset_list},
)
else: # value is a number
return DynamicFee(
Expand Down Expand Up @@ -325,9 +327,17 @@ def update(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
)]
for name, oracle in self.oracles.items():
oracle.update(self.current_block)

oracle.update(
block=self.current_block,
assets=oracle_update_assets
)
# update fees
self._lrna_fee.update(
time_step=self.time_step,
Expand Down Expand Up @@ -879,6 +889,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 +989,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
32 changes: 17 additions & 15 deletions hydradx/model/amm/oracle.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def __init__(self, input_state: Exchange):
self.withdrawals = {tkn: 0 for tkn in input_state.asset_list}
self.lps = {tkn: 0 for tkn in input_state.asset_list}
self.asset_list = input_state.asset_list.copy()
self.time_step = input_state.time_step


class Oracle:
Expand Down Expand Up @@ -37,28 +38,29 @@ def __init__(self, first_block: Block = None, decay_factor: float = 0, sma_equiv
else:
raise ValueError('Either last_values or first_block must be specified')
self.age = 0
self.last_updated = {tkn: first_block.time_step - 1 if first_block is not None else 0 for tkn in self.asset_list}

def add_asset(self, tkn: str, liquidity: float):
self.liquidity[tkn] = liquidity
self.volume_in[tkn] = 0
self.volume_out[tkn] = 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], 1) 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 attr in ['liquidity', 'price', 'volume_in', 'volume_out']:

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.

I'm not requesting any change here... This is maybe a level of abstraction that makes it a little less clear what is going on for little benefit? I realize that on some level it saves doing the same thing 4 times, but there is cost to abstraction too...

block_attr = getattr(block, attr)
self_attr = getattr(self, attr)
for tkn in assets:
self_attr[tkn] = (
update_factor[tkn] * self_attr.get(tkn, block_attr[tkn])

Copilot AI Jun 23, 2025

Copy link

Choose a reason for hiding this comment

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

Using self_attr.get(tkn, block_attr[tkn]) as a fallback depends on block_attr guarantees. It would be clearer to explicitly initialize self_attr for all asset tokens during object construction to avoid any unexpected fallbacks.

Suggested change
update_factor[tkn] * self_attr.get(tkn, block_attr[tkn])
update_factor[tkn] * self_attr[tkn]

Copilot uses AI. Check for mistakes.
+ (1 - update_factor[tkn]) * block_attr[tkn]
)
self.last_updated.update({tkn: block.time_step for tkn in assets})
self.age = block.time_step
return self


Expand Down
17 changes: 13 additions & 4 deletions hydradx/model/amm/trade_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -1140,8 +1140,10 @@ def schedule_swaps(pool_id: str, swaps: list[list[dict]]):
[
{'tkn_sell': str, 'tkn_buy': str, <'sell_quantity' OR 'buy_quantity'>: float},
... for each trade at this time step
],
for each time step
]
or {tkn_sell: str, tkn_buy: str, <'sell_quantity' OR 'buy_quantity'>: float} for a single trade
or None for no trades at this time step
... for each time step
]
"""
class Strategy:
Expand All @@ -1151,15 +1153,22 @@ def execute(self, state: GlobalState, agent_id: str):
if self.initial_time_step == -1:
self.initial_time_step = state.time_step
agent = state.agents[agent_id]
for trade in swaps [state.time_step - self.initial_time_step]:
step_swaps = (
swaps[state.time_step - self.initial_time_step]
if isinstance(swaps[state.time_step - self.initial_time_step], list)
else [swaps[state.time_step - self.initial_time_step]]
)
if step_swaps == [None] or len(step_swaps) == 0:
# no trades this step
return state
for trade in step_swaps:
state.pools[pool_id].swap(
tkn_sell=trade['tkn_sell'],
tkn_buy=trade['tkn_buy'],
sell_quantity=trade['sell_quantity'] if 'sell_quantity' in trade else 0,
buy_quantity=trade['buy_quantity'] if 'buy_quantity' in trade else 0,
agent=agent
)

return state

return TradeStrategy(Strategy().execute, name="scheduled_swaps")
162 changes: 100 additions & 62 deletions hydradx/tests/test_omnipool_amm.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from hydradx.model.amm.agents import Agent
from hydradx.model.amm.global_state import GlobalState
from hydradx.model.amm.omnipool_amm import DynamicFee, OmnipoolState, OmnipoolLiquidityPosition, trade_to_price
from hydradx.model.amm.trade_strategies import constant_swaps, omnipool_arbitrage
from hydradx.model.amm.trade_strategies import constant_swaps, omnipool_arbitrage, schedule_swaps
from hydradx.tests.strategies_omnipool import omnipool_reasonable_config, omnipool_config, assets_config

mp.dps = 50
Expand Down Expand Up @@ -1419,27 +1419,28 @@ def test_oracle_one_empty_block(liquidity: list[float], lrna: list[float], oracl

events = run.run(initial_state=initial_state, time_steps=1, silent=True)
omnipool_oracle = events[0].pools['omnipool'].oracles['price']
# manually update oracle - it won't automatically update tokens that weren't used this block
omnipool_oracle.update(events[-1].pools['omnipool'].current_block)
for tkn in ['HDX', 'USD', 'DOT']:
expected_liquidity = init_oracle['liquidity'][tkn] * (1 - alpha) + alpha * init_liquidity[tkn]['liquidity']
if omnipool_oracle.liquidity[tkn] != expected_liquidity:
if omnipool_oracle.liquidity[tkn] != pytest.approx(expected_liquidity, rel=1e-12):
raise AssertionError('Liquidity is not correct.')

expected_vol_in = init_oracle['volume_in'][tkn] * (1 - alpha)
if omnipool_oracle.volume_in[tkn] != expected_vol_in:
if omnipool_oracle.volume_in[tkn] != pytest.approx(expected_vol_in, rel=1e-12):
raise AssertionError('Volume is not correct.')

expected_vol_out = init_oracle['volume_out'][tkn] * (1 - alpha)
if omnipool_oracle.volume_out[tkn] != expected_vol_out:
if omnipool_oracle.volume_out[tkn] != pytest.approx(expected_vol_out, rel=1e-12):
raise AssertionError('Volume is not correct.')

init_price = init_liquidity[tkn]['LRNA'] / init_liquidity[tkn]['liquidity']
expected_price = init_oracle['price'][tkn] * (1 - alpha) + alpha * init_price
if omnipool_oracle.price[tkn] != expected_price:
if omnipool_oracle.price[tkn] != pytest.approx(expected_price, rel=1e-12):
raise AssertionError('Price is not correct.')


@given(
st.lists(asset_quantity_strategy, min_size=3, max_size=3),
st.lists(asset_quantity_bounded_strategy, min_size=3, max_size=3),
st.lists(asset_quantity_strategy, min_size=3, max_size=3),
st.lists(asset_quantity_strategy, min_size=3, max_size=3),
Expand All @@ -1451,15 +1452,15 @@ def test_oracle_one_empty_block(liquidity: list[float], lrna: list[float], oracl
@settings(
print_blob=True
)
def test_oracle_one_block_with_swaps(liquidity: list[float], lrna: list[float], oracle_liquidity: list[float],
def test_oracle_one_block_with_swaps(lrna: list[float], oracle_liquidity: list[float],
oracle_volume_in: list[float], oracle_volume_out: list[float],
oracle_prices: list[float], trade_sizes: list[float], n):
alpha = 2 / (n + 1)

init_liquidity = {
'HDX': {'liquidity': liquidity[0], 'LRNA': lrna[0]},
'USD': {'liquidity': liquidity[1], 'LRNA': lrna[1]},
'DOT': {'liquidity': liquidity[2], 'LRNA': lrna[2]},
'HDX': {'liquidity': 1000, 'LRNA': lrna[0]},
'USD': {'liquidity': 1000, 'LRNA': lrna[1]},
'DOT': {'liquidity': 1000, 'LRNA': lrna[2]},
}

init_oracle = {
Expand All @@ -1481,83 +1482,120 @@ def test_oracle_one_block_with_swaps(liquidity: list[float], lrna: list[float],
}
)

trader1_holdings = {'HDX': 1000000000, 'USD': 1000000000, 'LRNA': 1000000000, 'DOT': 1000000000}
trader2_holdings = {'HDX': 1000000000, 'USD': 1000000000, 'LRNA': 1000000000, 'DOT': 1000000000}

initial_state = GlobalState(
pools={'omnipool': initial_omnipool},
agents={
'Trader1': Agent(
holdings=trader1_holdings,
trade_strategy=constant_swaps(
pool_id='omnipool',
sell_quantity=trade_sizes[0],
sell_asset='LRNA',
buy_asset='DOT'
)
),
'Trader2': Agent(
holdings=trader2_holdings,
trade_strategy=constant_swaps(
pool_id='omnipool',
sell_quantity=trade_sizes[1],
sell_asset='DOT',
buy_asset='LRNA'
)
),
}
)

events = run.run(initial_state=initial_state, time_steps=2, silent=True)
omnipool_oracle_0 = events[0].pools['omnipool'].oracles['price']

vol_in = {
'HDX': 0,
'USD': 0,
'DOT': trader2_holdings['DOT'] - events[0].agents['Trader2'].holdings['DOT']
}

vol_out = {
'HDX': 0,
'USD': 0,
'DOT': events[0].agents['Trader1'].holdings['DOT'] - trader1_holdings['DOT'],
}
omnipool_0 = initial_omnipool.copy()
omnipool_oracle_0 = omnipool_0.oracles['price'].update(omnipool_0.current_block)

for tkn in ['HDX', 'USD', 'DOT']:
# alpha_mod = alpha if vol_in[tkn] != 0 or vol_out[tkn] != 0 else 0
expected_liquidity = init_oracle['liquidity'][tkn] * (1 - alpha) + alpha * init_liquidity[tkn]['liquidity']
if omnipool_oracle_0.liquidity[tkn] != expected_liquidity:
if omnipool_oracle_0.liquidity[tkn] != pytest.approx(expected_liquidity, rel=1e-12):
raise AssertionError('Liquidity is not correct.')

expected_vol_in = init_oracle['volume_in'][tkn] * (1 - alpha)
if omnipool_oracle_0.volume_in[tkn] != expected_vol_in:
if omnipool_oracle_0.volume_in[tkn] != pytest.approx(expected_vol_in, rel=1e-12):
raise AssertionError('Volume is not correct.')

expected_vol_out = init_oracle['volume_out'][tkn] * (1 - alpha)
if omnipool_oracle_0.volume_out[tkn] != expected_vol_out:
if omnipool_oracle_0.volume_out[tkn] != pytest.approx(expected_vol_out, rel=1e-12):
raise AssertionError('Volume is not correct.')

init_price = init_liquidity[tkn]['LRNA'] / init_liquidity[tkn]['liquidity']
expected_price = init_oracle['price'][tkn] * (1 - alpha) + alpha * init_price
if omnipool_oracle_0.price[tkn] != expected_price:
if omnipool_oracle_0.price[tkn] != pytest.approx(expected_price, rel=1e-12):
raise AssertionError('Price is not correct.')

omnipool_oracle_1 = events[1].pools['omnipool'].oracles['price']
trader = Agent(enforce_holdings=False)
omnipool_1 = omnipool_0.copy().swap(
agent=trader,
tkn_sell='DOT',
tkn_buy='LRNA',
sell_quantity=trade_sizes[0]
).swap(
agent=trader,
tkn_sell='LRNA',
tkn_buy='DOT',
buy_quantity=trade_sizes[1]
)
vol_in = omnipool_1.current_block.volume_in
vol_out = omnipool_1.current_block.volume_out
omnipool_oracle_1 = omnipool_1.oracles['price'].update(omnipool_1.current_block)
for tkn in ['HDX', 'USD', 'DOT']:
expected_liquidity = omnipool_oracle_0.liquidity[tkn] * (1 - alpha) + alpha * init_liquidity[tkn]['liquidity']
if omnipool_oracle_1.liquidity[tkn] != pytest.approx(expected_liquidity, 1e-10):
if omnipool_oracle_1.liquidity[tkn] != pytest.approx(expected_liquidity, 1e-12):
raise AssertionError('Liquidity is not correct.')

expected_vol_in = omnipool_oracle_0.volume_in[tkn] * (1 - alpha) + alpha * vol_in[tkn]
if omnipool_oracle_1.volume_in[tkn] != pytest.approx(expected_vol_in, 1e-9):
if omnipool_oracle_1.volume_in[tkn] != pytest.approx(expected_vol_in, 1e-12):
raise AssertionError('Volume is not correct.')

expected_vol_out = omnipool_oracle_0.volume_out[tkn] * (1 - alpha) + alpha * vol_out[tkn]
if omnipool_oracle_1.volume_out[tkn] != pytest.approx(expected_vol_out, 1e-9):
if omnipool_oracle_1.volume_out[tkn] != pytest.approx(expected_vol_out, 1e-12):
raise AssertionError('Volume out is not correct.')

expected_price = omnipool_oracle_0.price[tkn] * (1 - alpha) + alpha * omnipool_1.lrna_price(tkn)
if omnipool_oracle_1.price[tkn] != pytest.approx(expected_price, 1e-12):
raise AssertionError('Price is not correct.')


def test_oracle_multi_block():

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.

Can we fuzz the number of blocks?

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.

Not on this one, because it's testing for exact values. I do fuzz time steps on other tests.

init_liquidity = {
'HDX': {'liquidity': 100000, 'LRNA': 100000},
'USD': {'liquidity': 100000, 'LRNA': 100000},
'DOT': {'liquidity': 100000, 'LRNA': 100000},
}

init_oracle = {
'liquidity': {'HDX': 100000, 'USD': 100000, 'DOT': 100000},
'volume_in': {'HDX': 0, 'USD': 0, 'DOT': 0},
'volume_out': {'HDX': 0, 'USD': 0, 'DOT': 0},
'price': {'HDX': 1.0, 'USD': 1.0, 'DOT': 1.0},
}

initial_omnipool = oamm.OmnipoolState(
tokens=copy.deepcopy(init_liquidity),
oracles={
'price': 10
},
asset_fee=0.0025,
lrna_fee=0.0005,
last_oracle_values={
'price': copy.deepcopy(init_oracle)
}
)

initial_state = GlobalState(
pools={'omnipool': initial_omnipool},
agents={'trader': Agent(
enforce_holdings=False,
trade_strategy=schedule_swaps(
'omnipool', swaps=[
*[None] * 4,
{'tkn_sell': 'DOT', 'tkn_buy': 'HDX', 'sell_quantity': 1000},
*[None] * 4,
{'tkn_sell': 'HDX', 'tkn_buy': 'DOT', 'buy_quantity': 1000},
]
)
)}
)

events = run.run(initial_state=initial_state, time_steps=10, silent=True)
final_omnipool = events[-1].pools['omnipool'].update()
omnipool_oracle = final_omnipool.oracles['price']
expected_vol_in = {'DOT': 232.21719930388855, 'HDX': 622.8414188596074, 'USD': 0}

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.

I know this is harder to set up, but I think it'd be much better to test against our old setup of updating oracles every block, rather than test against hardcoded values? We should test the 1-block oracle update in other ways (potentially against hardcoded values), but those tests should already be in place I think?

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.

That's what this one does:
test_update_every_block

expected_vol_out = {'DOT': 633.3521679467996, 'HDX': 226.98232636186913, 'USD': 0}
expected_liquidity = {'DOT': 100633.35216794681, 'HDX': 99380.92549166107, 'USD': 100000}
expected_price = {'DOT': 0.9954561519474251, 'HDX': 1.0045748061650337, 'USD': 1.0}
for tkn in initial_omnipool.asset_list:
if omnipool_oracle.liquidity[tkn] != pytest.approx(expected_liquidity[tkn], rel=1e-12):
raise AssertionError('Liquidity is not correct.')

if omnipool_oracle.volume_in[tkn] != pytest.approx(expected_vol_in[tkn], rel=1e-12):
raise AssertionError('Volume in is not correct.')

if omnipool_oracle.volume_out[tkn] != pytest.approx(expected_vol_out[tkn], rel=1e-12):
raise AssertionError('Volume out is not correct.')

price_1 = events[0].pools['omnipool'].price(tkn)
expected_price = omnipool_oracle_0.price[tkn] * (1 - alpha) + alpha * price_1
if omnipool_oracle_1.price[tkn] != pytest.approx(expected_price, 1e-10):
if omnipool_oracle.price[tkn] != pytest.approx(expected_price[tkn], rel=1e-12):
raise AssertionError('Price is not correct.')


Expand Down
Loading