diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 1d683c4a6..08c51b337 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -16,10 +16,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.10 + - name: Set up Python 3.13.5 uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.13.5" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.gitignore b/.gitignore index 0a306eb65..4274d8de5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,11 @@ *.env /hydradx/apps/fees/data /hydradx/apps/money_market/archive +/hydradx/apps/fees/acct_swaps_5_0x7279fcf9694718e1234d102825dccaf332f0ea36edf1ca7c0358c4b68260d24b.json +/hydradx/apps/omnipool/cached data/all_trades.txt +/hydradx/other tests/cached data/all_trades.txt +/hydradx/apps/omnipool/cached data/lrna_sells.txt +/hydradx/other tests/cached data/lrna_sells.txt +/hydradx/tests/money_market_save_test.json +**/cached\ data/** +/.streamlit/secrets.toml diff --git a/README.md b/README.md index 69020dbd3..d95aafff1 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ The main HydraDX model can be found in "hydradx". Installation: -* Install Python 3.9. Not 3.8, it doesn't support all the features used in this code. Not 3.10, there is a compatibility issue with one of the libraries that you don't want to deal with. Link: https://www.python.org/downloads/release/python-3912/ +* Install Python 3.10 or higher * Clone the repository and navigate to the root folder, HydraDx-simulations * In terminal, enter 'pip install -r requirements.txt' * Alternatively, open the project folder in PyCharm, and you'll be prompted to create a virtual environment for this project. diff --git a/hydradx/apps/everything_is_collateral/other_tests.py b/hydradx/apps/everything_is_collateral/other_tests.py index 17774c243..d92dd9f86 100644 --- a/hydradx/apps/everything_is_collateral/other_tests.py +++ b/hydradx/apps/everything_is_collateral/other_tests.py @@ -12,7 +12,7 @@ from hydradx.model.amm.omnipool_amm import OmnipoolState from hydradx.model.amm.agents import Agent from hydradx.model.plot_utils import color_gradient -from hydradx.model.indexer_utils import get_current_omnipool, get_omnipool_trades, get_current_omnipool_assets, \ +from hydradx.model.indexer_utils import get_current_omnipool, get_omnipool_trades, get_current_omnipool_asset_ids, \ get_asset_info_by_ids st.markdown(""" diff --git a/hydradx/apps/fees/slip_fees_chart.py b/hydradx/apps/fees/slip_fees_chart.py new file mode 100644 index 000000000..1dec8341c --- /dev/null +++ b/hydradx/apps/fees/slip_fees_chart.py @@ -0,0 +1,82 @@ +import random + +from IPython.core.pylabtools import figsize +from matplotlib import pyplot as plt +import sys, os +import streamlit as st +import copy + +from matplotlib.lines import lineStyles +from streamlit import session_state + +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +sys.path.append(project_root) + +from hydradx.model import production_settings +from hydradx.model.indexer_utils import get_current_omnipool_router + +st.markdown(""" + +""", unsafe_allow_html=True) + +@st.cache_data(show_spinner=True) +def load_omnipool_router(): + router = get_current_omnipool_router() + return router + +def run_app(): + st.session_state.router = load_omnipool_router() + st.session_state.omnipool = st.session_state.router.exchanges['omnipool'] + omnipool = st.session_state.omnipool + omnipool.asset_fee = 0 + omnipool.lrna_fee = 0 + omnipool.max_lrna_fee = 1 + omnipool.max_asset_fee = 1 + col1, col2, col3 = st.columns(3) + with col1: + st.session_state.tkn_buy = st.selectbox("Select token to buy:", options=omnipool.asset_list, index=omnipool.asset_list.index('HDX')) + with col2: + st.session_state.tkn_sell = st.selectbox("Select token to sell:", options=omnipool.asset_list, index=omnipool.asset_list.index('DOT')) + with col3: + omnipool.slip_factor = st.number_input("Slip factor:", min_value=0.0, max_value=10.0, value=1.0) + plot_trade_sizes(st.session_state.tkn_buy, st.session_state.tkn_sell, st.session_state.router, st.session_state.omnipool) + +def plot_trade_sizes(tkn_buy, tkn_sell, router, omnipool): + trade_sizes = [10 ** (i / 5) for i in range(0, 26)] + fees = [] + for trade_size in trade_sizes: + sell_quantity = trade_size * router.price('Tether', tkn_sell) + outputs = omnipool.calculate_out_given_in(tkn_buy=tkn_buy, tkn_sell=tkn_sell, sell_quantity=sell_quantity) + buy_quantity, delta_qi, delta_qj, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell = outputs + slip_fee_total = slip_fee_buy + slip_fee_sell + slip_fee_percent = slip_fee_total / -delta_qi + print(f"${trade_size:.2f} worth of {tkn_sell} sold for {tkn_buy} = {slip_fee_percent * 100:.4f}% slip fee") + fees.append(slip_fee_percent) + + fig, ax = plt.subplots(figsize=(10, 6)) + ax.plot(trade_sizes, [fee * 100 for fee in fees], label='Slip Fee %', color='orange') + ax.set_xscale('log') + ax.set_yscale('log') + ax.set_xlabel(f'Value of {tkn_sell} sold for {tkn_buy} (USD)') + ax.set_xticks([1, 10, 100, 1000, 10000, 100000], ['$1', '$10', '$100', '$1000', '$10k', '$100k']) + ax.set_ylabel('Slip Fee (%)') + ax.set_yticks( + [0.0001, 0.001, 0.01, 0.1, 1] + ([10] if max(fees) * 100 > 1 else []), + ['0.0001%', '0.001%', '0.01%', '0.1%', '1%'] + (['10%'] if max(fees) * 100 > 1 else []) + ) + # label the slip fee values at each dollar-value tick + for i, trade_size in enumerate(trade_sizes): + if trade_size in [1, 10, 100, 1000, 10000, 100000]: + ax.text(trade_size, fees[i] * 100, f"{fees[i] * 100:.4f}%", fontsize=8, ha='center', va='bottom') + ax.set_title('Total Slip Fee') + ax.grid(True, which="both", ls="--", linewidth=0.5, color="gray") + ax.legend() + st.pyplot(fig) + +st.set_page_config(layout="wide") +st.title("Omnipool Slip Fees Chart") +run_app() diff --git a/hydradx/apps/omnipool/assets_liquidity_graph.py b/hydradx/apps/omnipool/assets_liquidity_graph.py new file mode 100644 index 000000000..ca6e4881e --- /dev/null +++ b/hydradx/apps/omnipool/assets_liquidity_graph.py @@ -0,0 +1,123 @@ +from hydradx.model.indexer_utils import query_indexer, get_blocks_at_timestamps, get_asset_info_by_ids +import datetime +from matplotlib import pyplot as plt +from pathlib import Path +import streamlit as st +import json +import math + +LIQUIDITY_GRAPH_TARGET_POINTS = 1000 + + +def _sort_key(value): + try: + return int(value) + except (TypeError, ValueError): + return value + + +def compress_liquidity_series(liquidity_series: dict, target_points: int) -> dict: + if target_points <= 0: + raise ValueError("target_points must be > 0") + if len(liquidity_series) <= target_points: + return dict(liquidity_series) + + items = sorted(liquidity_series.items(), key=lambda item: _sort_key(item[0])) + total = len(items) + batch_size = max(1, math.ceil(total / target_points)) + compressed = {} + + for idx in range(0, total, batch_size): + batch = items[idx: idx + batch_size] + first_key = batch[0][0] + avg_value = sum(value for _, value in batch) / len(batch) + compressed[first_key] = avg_value + + last_key, last_value = items[-1] + compressed[last_key] = last_value + return compressed + + +def _to_int_keyed(series: dict) -> dict: + return {int(k): v for k, v in series.items()} + + +def get_liquidity_over_time(): + dates = [ + datetime.datetime(2025, 11, day=i + 1) for i in range(30) + ] + [ + datetime.datetime(year=2025, month=12, day=i + 1) for i in range(31) + ] + block_map = get_blocks_at_timestamps(dates) + ordered_blocks = sorted(block_map.items(), key=lambda item: item[0]) + ordered_dates = [item[0] for item in ordered_blocks] + block_numbers = [item[1] for item in ordered_blocks] + liquidity = {} + + if not Path.exists(Path(__file__).parent / 'cached data' / 'liquidity.json'): + for i, start_block in enumerate(block_numbers[:-1]): + date = dates[i] + print(f"scanning {date}") + blocks_per_query = 1000 + end_block = block_numbers[1 + 1] + for block in range(start_block, end_block, blocks_per_query): + query_start = block + query_end = min(block + blocks_per_query, end_block) + query = f""" + query AssetBalancesByBlockHeight {{ + omnipoolAssetHistoricalData( + filter: {{paraBlockHeight: {{greaterThanOrEqualTo: {query_start}, lessThan: {query_end}}}}} + ) {{ + nodes + {{ + freeBalance + assetId + paraBlockHeight + }} + }} + }} + """ + + results = query_indexer("https://galacticcouncil.squids.live/hydration-pools:unified-prod/api/graphql", query) + for result in results["data"]["omnipoolAssetHistoricalData"]["nodes"]: + asset_id = result["assetId"] + free_balance = int(result["freeBalance"]) + result_block = int(result["paraBlockHeight"]) + if asset_id not in liquidity: + liquidity[asset_id] = {} + liquidity[asset_id][result_block] = free_balance + + with open (Path(__file__).parent / 'cached data' / 'liquidity.json', 'w') as f: + json.dump(liquidity, f) + else: + with open (Path(__file__).parent / 'cached data' / 'liquidity.json', 'r') as f: + liquidity = json.load(f) + + asset_names = {tkn.id: tkn.unique_id for tkn in get_asset_info_by_ids(list(liquidity.keys())).values()} + graph_liquidity = {} + for tkn in liquidity: + raw_series = _to_int_keyed(liquidity[tkn]) + if len(raw_series) > LIQUIDITY_GRAPH_TARGET_POINTS: + graph_liquidity[tkn] = compress_liquidity_series( + raw_series, + target_points=LIQUIDITY_GRAPH_TARGET_POINTS, + ) + else: + graph_liquidity[tkn] = dict(raw_series) + graph_liquidity[tkn][block_numbers[0]] = list(raw_series.values())[0] + graph_liquidity[tkn][block_numbers[-1]] = list(raw_series.values())[-1] + sorted_items = sorted(graph_liquidity[tkn].items(), key=lambda item: _sort_key(item[0])) + fig, ax = plt.subplots(figsize=(12, 4)) + ax.plot([item[0] for item in sorted_items], [item[1] for item in sorted_items]) + ax.set_title(f"Liquidity of {asset_names[tkn]} in Omnipool") + ax.set_xlabel("Date") + ax.set_ylabel("Free Balance") + ax.set_xticks(block_numbers) + ax.set_xticklabels([date.strftime('%Y-%m-%d') for date in ordered_dates]) + ax.tick_params(axis="x", labelsize=8) + + plt.tight_layout() + plt.show() + st.pyplot(fig) + pass + diff --git a/hydradx/apps/omnipool/hdx_buy_burn.py b/hydradx/apps/omnipool/hdx_buy_burn.py new file mode 100644 index 000000000..dee514494 --- /dev/null +++ b/hydradx/apps/omnipool/hdx_buy_burn.py @@ -0,0 +1,169 @@ +import os, sys +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +sys.path.append(project_root) + +from hydradx.model.indexer_utils import get_blocks_at_timestamps, get_omnipool_trades, get_dates_of_blocks, \ + get_current_omnipool_router, get_omnipool_liquidity_at_intervals, get_asset_info_by_ids +from hydradx.model.processing import load_state, save_state +from hydradx.model.amm.agents import Agent +import datetime, json +from pathlib import Path +import streamlit as st +from matplotlib import pyplot as plt + + +st.set_page_config(layout="wide") +print("App start") + + +cache_directory = Path(__file__).parent / 'cached data' / 'omnipools' +omnipool_filename = f'omnipool-{datetime.date.today().isoformat()}.json' +if Path.exists(cache_directory / omnipool_filename): + omnipool_router = load_state(path=str(cache_directory), filename=omnipool_filename) +else: + omnipool_router = get_current_omnipool_router() + save_state(omnipool_router, path=str(cache_directory), filename=omnipool_filename) + +old_router = omnipool_router.copy() + +lrna_trades = [trade for trade in trades if trade['assetIn'] == 'H2O'] +lrna_sellers = { + seller: sum([trade['amountIn'] for trade in lrna_trades if trade['who'] == seller]) + for seller in set([trade['who'] for trade in lrna_trades]) +} +lrna_sellers = dict(sorted(lrna_sellers.items(), key=lambda item: -item[1])) +temp_account = list(lrna_sellers.keys())[0] +last_trade = None +dupes = [] +dupe_dates = set() +temp_acct_trades = [] + +omnipool = omnipool_router.exchanges['omnipool'] +daily_balances = {} +if Path.exists(Path(__file__).parent / 'cached data' / 'omnipool_liquidity.json'): + + with open(Path(__file__).parent / 'cached data' / 'omnipool_liquidity.json', 'r') as f: + daily_balances = json.load(f) + daily_balances = {int(block): daily_balances[block] for block in daily_balances} # convert keys back to int +else: + asset_info = get_asset_info_by_ids() + asset_ids = [asset_info[asset].id for asset in asset_info] + start_time = datetime.datetime.today() - datetime.timedelta(days=366) + end_time = datetime.datetime.today() + daily_balances = {} + blocks = [] + for asset_id in asset_ids: + asset_name = asset_info[asset_id].unique_id + asset_balances = get_omnipool_liquidity_at_intervals( + start_time=start_time, + end_time=end_time, + interval=datetime.timedelta(days=1), + asset_ids=[asset_id] + ) + if blocks == []: + blocks = sorted(list(asset_balances.keys())) + + for block in blocks: + if block not in daily_balances: + daily_balances[block] = {} + daily_balances[block]['time'] = asset_balances[block]['time'] + daily_balances[block][asset_name] = { + 'liquidity': asset_balances[block][asset_name]['liquidity'], + 'lrna': asset_balances[block][asset_name]['LRNA'] + } + daily_balances = {int(block): daily_balances[block] for block in daily_balances} + with open(Path(__file__).parent / 'cached data' / 'omnipool_liquidity.json', 'w') as f: + json.dump(daily_balances, f, indent=4) + +treasury_agent = Agent() +block_per_day = list(daily_balances.keys()) +price_gain_overall = 1 +trade_index = 0 +for todays_block in block_per_day: + hdx_bought_back = 0 + lrna_sold_back = 0 + + current_hdx_balance = daily_balances[todays_block]['HDX']['liquidity'] + current_hdx_lrna = daily_balances[todays_block]['HDX']['lrna'] + hdx_lrna_price = current_hdx_lrna / current_hdx_balance + + while trade_index < len(trades) and trades[trade_index]['block_number'] < todays_block: + trade = trades[trade_index] + trade_index += 1 + + if last_trade: + if trade == last_trade: + dupes.append(trade) + dupe_dates.add(trade['date']) + continue + last_trade = trade + # half of fees go to HDX buyback + if trade['protocolFeeAmount'] > 0: + lrna_sold_back += trade['protocolFeeAmount'] * 0.5 + hdx_bought_back += trade['protocolFeeAmount'] * 0.5 / hdx_lrna_price + if trade['assetFeeAmount'] > 0: + if (daily_balances[todays_block][trade['assetOut']]['liquidity']) == 0: + if trade['assetIn'] == 'H2O': + asset_lrna_price = trade['amountIn'] / trade['amountOut'] + elif daily_balances[todays_block][trade['assetIn']]['lrna'] > 0: + other_asset_lrna_price = daily_balances[todays_block][trade['assetIn']]['lrna'] /\ + daily_balances[todays_block][trade['assetIn']]['liquidity'] + asset_lrna_price = other_asset_lrna_price * trade['amountIn'] / trade['amountOut'] + elif 'hubAmountOut' in trade and trade['hubAmountOut'] > 0: + asset_lrna_price = trade['hubAmountOut'] / trade['amountOut'] + else: + continue + else: + asset_lrna_price = daily_balances[todays_block][trade['assetOut']]['lrna'] /\ + daily_balances[todays_block][trade['assetOut']]['liquidity'] + if asset_lrna_price < 0: + pass + if trade['assetFeeAmount'] < 0: + pass + lrna_this_trade = trade['assetFeeAmount'] * 0.5 * asset_lrna_price / price_gain_overall + hdx_this_trade = trade['assetFeeAmount'] * 0.5 * asset_lrna_price / hdx_lrna_price / price_gain_overall + effective_protocol_fee = lrna_this_trade / (trade['hubAmountIn'] if 'hubAmountIn' in trade and trade['hubAmountIn'] > 0 else trade['amountIn']) + effective_asset_fee = trade['assetFeeAmount'] / trade['amountOut'] + if effective_asset_fee > 0.005 or effective_protocol_fee > 0.005: + pass + if effective_asset_fee < 0.0025 or effective_protocol_fee < 0.0005: + pass + + if ((current_hdx_lrna + lrna_this_trade) / (current_hdx_balance - hdx_this_trade)) / hdx_lrna_price < 0: + pass + if hdx_bought_back < 0: + pass + if lrna_sold_back < 0: + pass + if hdx_bought_back < 0: + pass + lrna_sold_back += lrna_this_trade + hdx_bought_back += hdx_this_trade + if lrna_sold_back > 0: + # 1. Calculate the 'k' for the HDX sub-pool at the start of the day + k = current_hdx_lrna * current_hdx_balance + + # 2. How much HDX is actually removed from the pool by this LRNA? + # This accounts for slippage (the price moving as you buy) + new_hdx_balance = k / (current_hdx_lrna + lrna_sold_back) + hdx_actually_burned = current_hdx_balance - new_hdx_balance + + # 3. The new price is (New LRNA) / (New HDX Balance) + new_price = (current_hdx_lrna + lrna_sold_back) / new_hdx_balance + + # 4. Today's gain relative to the starting price + gains_today = new_price / hdx_lrna_price + price_gain_overall *= gains_today + + # gains_today = ((current_hdx_lrna + lrna_sold_back) / (current_hdx_balance - hdx_bought_back)) / hdx_lrna_price + # price_gain_overall *= gains_today + +fig, ax = plt.subplots(figsize=(16, 6)) +real_trades = [trade for trade in lrna_trades if trade['who'] != temp_account] +trades_by_date = sorted(real_trades, key=lambda trade: datetime.datetime.strptime(trade['date'], '%Y-%m-%d')) +date_strings = sorted(list(set([trade['date'] for trade in trades_by_date]))) +dates = [datetime.datetime.strptime(date, '%Y-%m-%d') for date in date_strings] +sells = [sum([trade['amountIn'] for trade in trades_by_date if trade['date'] == date]) for date in date_strings] +ax.plot(dates, sells) +st.pyplot(fig) +pass diff --git a/hydradx/apps/omnipool/hdx_h2o.py b/hydradx/apps/omnipool/hdx_h2o.py new file mode 100644 index 000000000..69c858b06 --- /dev/null +++ b/hydradx/apps/omnipool/hdx_h2o.py @@ -0,0 +1,119 @@ +import os, sys + +from hydradx.model.amm.omnipool_amm import OmnipoolState + +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +sys.path.append(project_root) + +from hydradx.model.amm.agents import Agent +from hydradx.model.indexer_utils import get_blocks_at_timestamps, get_omnipool_trades, get_dates_of_blocks, \ + get_current_omnipool_router +from hydradx.model.processing import load_state, save_state +import datetime, json +from pathlib import Path +import streamlit as st +from matplotlib import pyplot as plt + + +st.set_page_config(layout="wide") +print("App start") + +def get_trades_for_dates(start_date: datetime.datetime, end_date: datetime.datetime): + loaded_trades = [] + block_dates = [ + start_date + + datetime.timedelta(days=i) + for i in range((end_date - start_date).days + 1) + ] + dates_to_download = [] + for date in block_dates: + date_str = date.strftime('%Y-%m-%d') + cache_file = Path(__file__).parent / 'cached data' / 'trades' / f'trades_{date_str}.txt' + if Path.exists(cache_file): + cached_trades = json.load(open(cache_file, 'r')) + loaded_trades.extend(cached_trades) + # block_dates.remove(date) + else: + dates_to_download.append((date, date + datetime.timedelta(days=1))) + + for i in range(len(dates_to_download) - 1): + first_block, last_block = list(get_blocks_at_timestamps([dates_to_download[i][0], dates_to_download[i][1]]).values()) + new_trades = get_omnipool_trades( + min_block=first_block, max_block=last_block - 1 + ) + new_trades = [{**trade, 'date': dates_to_download[i][0].strftime('%Y-%m-%d')} for trade in new_trades] + loaded_trades.extend(new_trades) + return loaded_trades + +def save_trades_to_cache(trades): + dates = [trade['date'] for trade in trades] + unique_dates = sorted(list(set(dates))) + for date in unique_dates: + savefile = Path(__file__).parent / 'cached data' / 'trades' / f'trades_{date}.txt' + if not Path.exists(savefile): + trades_on_date = [trade for trade in trades if trade['date'] == date] + with open(savefile, 'w') as f: + json.dump(trades_on_date, f) + +# get one year's worth of trades +trades = get_trades_for_dates( + start_date=datetime.datetime.today() - datetime.timedelta(days=365), + end_date=datetime.datetime.today() +) +save_trades_to_cache(trades) + +cache_directory = Path(__file__).parent / 'cached data' / 'omnipools' +omnipool_filename = f'omnipool-{datetime.date.today().isoformat()}.json' +if Path.exists(cache_directory / omnipool_filename): + omnipool_router = load_state(path=str(cache_directory), filename=omnipool_filename) +else: + omnipool_router = get_current_omnipool_router() + save_state(omnipool_router, path=str(cache_directory), filename=omnipool_filename) + +old_router = omnipool_router.copy() + +lrna_trades = [trade for trade in trades if trade['assetIn'] == 'H2O'] +lrna_sellers = { + seller: sum([trade['amountIn'] for trade in lrna_trades if trade['who'] == seller]) + for seller in set([trade['who'] for trade in lrna_trades]) +} +lrna_sellers = dict(sorted(lrna_sellers.items(), key=lambda item: -item[1])) +temp_account = list(lrna_sellers.keys())[0] +last_trade = None +dupes = [] +dupe_dates = set() +temp_acct_trades = [] + +omnipool = omnipool_router.exchanges['omnipool'] +for trade in trades: + if last_trade: + if trade == last_trade: + dupes.append(trade) + dupe_dates.add(trade['date']) + continue + last_trade = trade + if trade['assetIn'] == 'H2O': + # send sold H2O to HDX reserves + if not trade['who'] == temp_account: + tkn_buy = trade['assetOut'] + if tkn_buy in omnipool.lrna: + omnipool.lrna[tkn_buy] -= trade['amountIn'] + omnipool.lrna['HDX'] += trade['amountIn'] + else: + temp_acct_trades.append(trade) + else: + # send protocol fee to HDX reserves + omnipool.lrna['HDX'] += trade['protocolFeeAmount'] + +hdx_price_gain = omnipool_router.price('HDX', 'Tether') / old_router.price('HDX', 'Tether') - 1 +lrna_price_gain = omnipool_router.price('LRNA', 'Tether') / old_router.price('LRNA', 'Tether') - 1 + +fig, ax = plt.subplots(figsize=(16, 6)) +real_trades = [trade for trade in lrna_trades if trade['who'] != temp_account] +trades_by_date = sorted(real_trades, key=lambda trade: datetime.datetime.strptime(trade['date'], '%Y-%m-%d')) +date_strings = sorted(list(set([trade['date'] for trade in trades_by_date]))) +dates = [datetime.datetime.strptime(date, '%Y-%m-%d') for date in date_strings] +sells = [sum([trade['amountIn'] for trade in trades_by_date if trade['date'] == date]) for date in date_strings] +ax.plot(dates, sells) +st.pyplot(fig) +pass diff --git a/hydradx/apps/omnipool/hdx_h2o_undo.py b/hydradx/apps/omnipool/hdx_h2o_undo.py new file mode 100644 index 000000000..fdc965dce --- /dev/null +++ b/hydradx/apps/omnipool/hdx_h2o_undo.py @@ -0,0 +1,541 @@ +import os, sys + +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +sys.path.append(project_root) + +from hydradx.model.amm.omnipool_amm import OmnipoolState +from hydradx.model.amm.agents import Agent +from hydradx.model.indexer_utils import get_blocks_at_timestamps, get_omnipool_trades, get_date_of_block, \ + get_current_omnipool_router, get_asset_info_by_ids, get_current_block_height, get_current_omnipool, \ + get_hollar_liquidity_at, get_block_at_timestamp, query_indexer +from hydradx.model.processing import load_state, save_state +import datetime, json +import time +from pathlib import Path +from hydradx.apps.omnipool.trade_downloader import get_trades_for_dates, save_trades_to_cache +from hydradx.apps.s3_utils import download_file_from_s3, upload_file_to_s3, sync_dir_from_s3, upload_dir_to_s3, s3_join, get_s3_client, list_s3_keys +import streamlit as st +from matplotlib import pyplot as plt +from matplotlib.ticker import MaxNLocator +from hydradx.model.processing import query_sqlPad + +st.set_page_config(layout="wide") + +CACHE_ROOT = Path(__file__).parent / "cached data" +S3_CACHE_PREFIX = "apps/omnipool/hdx_h2o_undo" + + +def _show_cloud_connected_banner() -> None: + if st.session_state.get("cloud_connected_banner_shown"): + return + + client, _cfg = get_s3_client() + if client is None: + return + + st.session_state["cloud_connected_banner_shown"] = True + banner = st.empty() + banner.markdown( + """ +
+ cloud cache connected +
+ """, + unsafe_allow_html=True, + ) + time.sleep(1) + banner.empty() + + +_show_cloud_connected_banner() + + +def _trade_bucket() -> str | None: + try: + cfg = st.secrets.get("s3", {}) + return cfg.get("trade_bucket") or os.environ.get("S3_TRADE_BUCKET") or "trade-storage" + except Exception: + return os.environ.get("S3_TRADE_BUCKET") or "trade-storage" + + +def _omnipool_bucket() -> str | None: + try: + cfg = st.secrets.get("s3", {}) + return cfg.get("omnipool_bucket") or os.environ.get("S3_OMNIPOOL_BUCKET") or "omnipool-storage" + except Exception: + return os.environ.get("S3_OMNIPOOL_BUCKET") or "omnipool-storage" + + +def _cache_key_for(path: Path) -> str: + rel_path = path.relative_to(CACHE_ROOT).as_posix() + return s3_join(S3_CACHE_PREFIX, rel_path) + + +def _ensure_cached_file(path: Path) -> None: + if path.exists(): + return + download_file_from_s3(_cache_key_for(path), path) + + +def _upload_cached_file(path: Path) -> None: + if not path.exists(): + return + upload_file_to_s3(path, _cache_key_for(path)) + + +def _sync_cache_dir(dir_path: Path) -> None: + prefix = s3_join(S3_CACHE_PREFIX, dir_path.relative_to(CACHE_ROOT).as_posix()) + sync_dir_from_s3(dir_path, prefix) + + +def _upload_cache_dir(dir_path: Path) -> None: + prefix = s3_join(S3_CACHE_PREFIX, dir_path.relative_to(CACHE_ROOT).as_posix()) + upload_dir_to_s3(dir_path, prefix) + + +def _omnipool_cache_paths_for_range( + dir_path: Path, + start_date: datetime.datetime, + end_date: datetime.datetime, +) -> list[Path]: + days = (end_date - start_date).days + 1 + return [ + dir_path / f"omnipool-{(start_date + datetime.timedelta(days=i)).strftime('%Y-%m-%d')}.json" + for i in range(days) + ] + + +def _ensure_omnipool_cached_file(path: Path) -> None: + if path.exists(): + return + key = s3_join("omnipools", path.name) + download_file_from_s3(key, path, bucket=_omnipool_bucket()) + + +def _upload_omnipool_cached_file(path: Path) -> None: + if not path.exists(): + return + key = s3_join("omnipools", path.name) + upload_file_to_s3(path, key, bucket=_omnipool_bucket()) + + +def _sync_omnipool_cache_dir( + dir_path: Path, + start_date: datetime.datetime, + end_date: datetime.datetime, +) -> None: + for path in _omnipool_cache_paths_for_range(dir_path, start_date, end_date): + _ensure_omnipool_cached_file(path) + + +def _upload_omnipool_cache_dir( + dir_path: Path, + start_date: datetime.datetime, + end_date: datetime.datetime, +) -> None: + for path in _omnipool_cache_paths_for_range(dir_path, start_date, end_date): + _upload_omnipool_cached_file(path) + + +def _trade_cache_paths_for_range( + dir_path: Path, + start_date: datetime.datetime, + end_date: datetime.datetime, +) -> list[Path]: + days = (end_date - start_date).days + 1 + return [ + dir_path / f"trades_{(start_date + datetime.timedelta(days=i)).strftime('%Y-%m-%d')}.txt" + for i in range(days) + ] + + +def _sync_trades_cache_dir( + dir_path: Path, + start_date: datetime.datetime, + end_date: datetime.datetime, + show_progress: bool = False, +) -> None: + prefix = dir_path.relative_to(CACHE_ROOT).as_posix() + trade_paths = _trade_cache_paths_for_range(dir_path, start_date, end_date) + missing_paths = [path for path in trade_paths if not path.exists()] + total = len(trade_paths) + progress = st.progress(0, text="loading hollar trades") if show_progress and missing_paths else None + + for idx, path in enumerate(trade_paths, start=1): + if not path.exists(): + key = s3_join(prefix, path.name) + download_file_from_s3(key, path, bucket=_trade_bucket()) + if progress is not None: + progress.progress(min(idx / total, 1.0), text=f"loading hollar trades ({idx}/{total})") + + if progress is not None: + if total == 0: + progress.empty() + else: + progress.progress(1.0, text=f"loading hollar trades ({total}/{total})") + time.sleep(0.2) + progress.empty() + + +def _upload_trades_cache_dir( + dir_path: Path, + start_date: datetime.datetime, + end_date: datetime.datetime, +) -> None: + if st.session_state.get("trade_upload_done"): + return + + prefix = dir_path.relative_to(CACHE_ROOT).as_posix() + existing_keys = set(list_s3_keys(prefix, bucket=_trade_bucket())) + trade_paths = _trade_cache_paths_for_range(dir_path, start_date, end_date) + for path in trade_paths: + if not path.exists(): + continue + key = s3_join(prefix, path.name) + if key in existing_keys: + continue + upload_file_to_s3(path, key, bucket=_trade_bucket()) + + st.session_state["trade_upload_done"] = True + +def simulate_trade_before_and_after(): + cache_directory = CACHE_ROOT / "omnipools" + date = datetime.datetime(2026, 2, 1) + omnipool_filename = f'omnipool-{date.isoformat()}.json' + _ensure_omnipool_cached_file(cache_directory / omnipool_filename) + if Path.exists(cache_directory / omnipool_filename): + omnipool_router = load_state(path=str(cache_directory), filename=omnipool_filename) + else: + omnipool_router = get_current_omnipool_router() + save_state(omnipool_router, path=str(cache_directory), filename=omnipool_filename) + _upload_omnipool_cached_file(cache_directory / omnipool_filename) + + + lp = Agent() + omnipool: OmnipoolState = omnipool_router.exchanges['omnipool'] + lp.holdings = {'LRNA': 1000} + omnipool_router.swap( + lp, tkn_sell='LRNA', tkn_buy='Hydrated Dollar', sell_quantity=lp.holdings['LRNA'] + ) + pass + + +def get_omnipools(start_date: datetime.datetime, end_date: datetime.datetime) -> dict[datetime.datetime, OmnipoolState]: + cache_directory = CACHE_ROOT / "omnipools" + _sync_omnipool_cache_dir(cache_directory, start_date, end_date) + omnipools = {} + all_dates = [ + start_date + datetime.timedelta(days=i) for i in range((end_date - start_date).days + 1) + ] + for date in all_dates: + block_number = get_block_at_timestamp(date) + omnipool_filename = f"omnipool-{date.strftime('%Y-%m-%d')}.json" + _ensure_omnipool_cached_file(cache_directory / omnipool_filename) + if Path.exists(cache_directory / omnipool_filename): + omnipool_router = load_state(path=str(cache_directory), filename=omnipool_filename) + else: + omnipool_router = get_current_omnipool_router(block_number=block_number) + omnipool: OmnipoolState = omnipool_router.exchanges['omnipool'] + if "HOLLAR" not in omnipool.liquidity: + hollar_liquidity = get_hollar_liquidity_at(block_number) + omnipool.liquidity['HOLLAR'] = hollar_liquidity['liquidity'] + omnipool.lrna['HOLLAR'] = hollar_liquidity['LRNA'] + omnipool.shares['HOLLAR'] = hollar_liquidity['shares'] + omnipool.protocol_shares['HOLLAR'] = hollar_liquidity['shares'] + + omnipool.add_token( + tkn='HOLLAR', + liquidity=omnipool.liquidity['HOLLAR'], + lrna=omnipool.lrna['HOLLAR'], + shares=omnipool.shares['HOLLAR'], + protocol_shares=omnipool.protocol_shares['HOLLAR'], + weight_cap=1.0 + ).update() + omnipool.time_step = block_number + + if not Path.exists(cache_directory / omnipool_filename): + save_state(omnipool_router, path=str(cache_directory), filename=omnipool_filename) + _upload_omnipool_cached_file(cache_directory / omnipool_filename) + + omnipools[date] = omnipool + + return omnipools + +@st.cache_data(show_spinner="loading hollar trades") +def _build_hollar_trade_summary(start_date: datetime.datetime, end_date: datetime.datetime): + trades = get_trades_for_dates( + start_date=start_date, + end_date=end_date, + save_cache=True, + cache_directory=CACHE_ROOT / "hollar trades", + extra_filter='args: {includes: ":222,"}' + ) + omnipools: dict[datetime.datetime, OmnipoolState] = get_omnipools(start_date, end_date) + + all_dates = [ + start_date + datetime.timedelta(days=i) for i in range((end_date - start_date).days + 1) + ] + + # line them up by block numbers to account for any timezone difference + for i in range(len(all_dates) - 1): + date = all_dates[i] + min_block = omnipools[date].time_step + max_block = omnipools[all_dates[i + 1]].time_step + trades_on_date = [ + trade for trade in trades + if 'block_number' in trade and min_block < trade['block_number'] <= max_block + ] + for trade in trades_on_date: + if trade['date'] != date.strftime('%Y-%m-%d'): + trade['date'] = date.strftime('%Y-%m-%d') + + hollar_h2o_trades = [trade for trade in trades if "H2O" in trade.values()] + hollar_per_day = { + datetime.datetime.strptime(date, '%Y-%m-%d'): sum( + [trade['amountOut'] for trade in hollar_h2o_trades if trade['date'] == date] + ) + for date in set([trade['date'] for trade in hollar_h2o_trades]) + } + hollar_per_day = dict(sorted(hollar_per_day.items(), key=lambda item: item[0])) + + hollar_percentage_per_day = { + date: hollar_per_day[date] / omnipools[date].liquidity['HOLLAR'] if date in hollar_per_day and date in omnipools else 0 + for date in set(list(hollar_per_day.keys()) + list(omnipools.keys())) + } + hollar_percentage_per_day = dict(sorted(hollar_percentage_per_day.items(), key=lambda item: item[0])) + + return all_dates, omnipools, hollar_percentage_per_day + + +def find_hollar_trades(): + start_date = datetime.datetime(2026, 2, 16) + end_date = datetime.datetime(2026, 3, 18) # datetime.datetime.today() - datetime.timedelta(days=1) + + trades_cache_dir = CACHE_ROOT / "hollar trades" + range_key = f"{start_date.date()}_{end_date.date()}" + if not st.session_state.get(f"trades_loaded_{range_key}"): + _sync_trades_cache_dir(trades_cache_dir, start_date, end_date, show_progress=True) + st.session_state[f"trades_loaded_{range_key}"] = True + all_dates, omnipools, hollar_percentage_per_day = _build_hollar_trade_summary(start_date, end_date) + _upload_trades_cache_dir(trades_cache_dir, start_date, end_date) + + selected_range = st.select_slider( + "Date range", + options=all_dates, + value=(all_dates[0], all_dates[-1]), + format_func=lambda d: d.strftime('%Y-%m-%d') + ) + lp_shares = 1000.0 + input_col, text_col = st.columns([1, 4]) + with input_col: + lp_shares = st.number_input( + "LP shares", + min_value=0.0, + value=lp_shares, + step=1.0, + key="lp_shares_input", + label_visibility="collapsed", + ) + with text_col: + st.markdown( + f"LP shares in Hollar = ${st.session_state.get('lp_shares_input', lp_shares) / omnipools[selected_range[0]].shares['HOLLAR'] * omnipools[selected_range[0]].liquidity['HOLLAR']:.2f}" + ) + + percentage_series = [hollar_percentage_per_day.get(date, 0) for date in all_dates] + slider_start_idx = all_dates.index(selected_range[0]) + slider_end_idx = all_dates.index(selected_range[1]) + # lp_losses = ( + # sum(percentage_series[slider_start_idx: slider_end_idx]) + # * lp_shares + # / omnipools[all_dates[slider_start_idx]].shares['HOLLAR'] + # * omnipools[all_dates[slider_start_idx]].liquidity['HOLLAR'] + # / 2 + # ) + lp_losses = 0 + arb_gains = 0 + lp_holdings_pct = 1 + lp_losses_to_arb = 0 + for i in range(slider_start_idx, slider_end_idx): + daily_loss = percentage_series[i] + test_pool = omnipools[all_dates[i]].copy() + arbitrageur = Agent() + daily_lrna_price = test_pool.liquidity['HOLLAR'] / test_pool.lrna['HOLLAR'] + lrna_loss = daily_loss * test_pool.lrna['HOLLAR'] + test_pool.lrna['HOLLAR'] -= lrna_loss + test_pool.slip_factor = 0 + hollar_buy = -test_pool.calculate_trade_to_price("HOLLAR", 1 / daily_lrna_price) + test_pool.swap( + arbitrageur, + tkn_sell='LRNA', + tkn_buy='HOLLAR', + buy_quantity=hollar_buy + ) + arb_gain_today = arbitrageur.get_holdings('HOLLAR') + arbitrageur.get_holdings('LRNA') * daily_lrna_price + if arb_gain_today < 0: + print(f"negative arbitrage gain of {arb_gain_today:.2f} on {all_dates[i].strftime('%Y-%m-%d')}") + arb_gain_today = 0 + arb_gains += arb_gain_today + lp_losses_to_arb += arb_gain_today * lp_shares / test_pool.shares['HOLLAR'] + lp_holdings_pct *= (1 - daily_loss / 2) + + lp_losses = (1 - lp_holdings_pct) * lp_shares / omnipools[all_dates[slider_start_idx]].shares['HOLLAR'] * omnipools[all_dates[slider_start_idx]].liquidity['HOLLAR'] + lp_losses += lp_losses_to_arb + st.metric("Estimated LP losses in dollars", f"{lp_losses:,.2f}") + st.metric("Estimated LP losses to arbitrage:" , f"{lp_losses_to_arb:,.2f}") + st.metric("Estimated arbitrage gains in dollars", f"{arb_gains:,.2f}") + + fig, ax = plt.subplots(figsize=(6.4, 3.36)) + ax.plot(list(hollar_percentage_per_day.keys()), [pct * 100 for pct in list(hollar_percentage_per_day.values())]) + ax.set_title("Percentage of HOLLAR liquidity sold for H2O per day", fontsize=8) + ax.tick_params(axis='x', labelsize=7) + ax.tick_params(axis='y', labelsize=7) + ax.xaxis.label.set_size(7) + ax.yaxis.label.set_size(7) + st.pyplot(fig) + return + + +def simulate_lp_experience(): + start_date = datetime.datetime(2026, 2, 16) + end_date = datetime.datetime.today() - datetime.timedelta(days=1) + trades_cache_dir = CACHE_ROOT / "hollar trades" + _sync_trades_cache_dir(trades_cache_dir, start_date, end_date) + trades = get_trades_for_dates( + start_date=start_date, + end_date=end_date, + save_cache=True, + cache_directory=trades_cache_dir, + extra_filter='args: {includes: ":222,"}' + ) + _upload_trades_cache_dir(trades_cache_dir, start_date, end_date) + + trade_types = set([trade['name'] for trade in trades]) + trades_by_type = { + trade_type: [trade for trade in trades if trade['name'] == trade_type] + for trade_type in trade_types + } + trades_by_type['H2O Sells'] = [trade for trade in trades if 'H2O' in trade.values()] + trades_by_type['Hollar Out'] = [ + trade for trade in trades + if (trade['name'] == 'Omnipool.SellExecuted' or trade['name'] == 'Omnipool.BuyExecuted') + and trade['assetOut'] == 'HOLLAR' + ] + trades_by_type['Hollar In'] = [ + trade for trade in trades + if (trade['name'] == 'Omnipool.SellExecuted' or trade['name'] == 'Omnipool.BuyExecuted') + and trade['assetIn'] == 'HOLLAR' + ] + + relevant_quantity = { + 'Omnipool.LiquidityAdded': 'amount', + 'Omnipool.LiquidityRemoved': 'sharesRemoved', + 'H2O Sells': 'amountOut', + 'Omnipool.PositionCreated': 'shares', + 'Hollar Out': 'amountOut', + 'Hollar In': 'amountIn' + } + quantities_by_type = { + trade_type: sum([float(trade[relevant_quantity[trade_type]]) for trade in trades_by_type[trade_type]]) + for trade_type in relevant_quantity + } + quantities_by_type = { + trade_type: quantity / 10 ** 18 if quantity > 10 ** 18 else quantity + for trade_type, quantity in quantities_by_type.items() + } + + omnipools = list(get_omnipools(start_date, end_date).values()) + starting_omnipool = omnipools[0].copy() + starting_omnipool.withdrawal_fee = False + starting_omnipool.max_withdrawal_per_block = float('inf') + starting_omnipool.max_lp_per_block = float('inf') + lp = Agent(holdings={'HOLLAR': 1000}) + trade_agent = Agent() + starting_omnipool.add_liquidity(lp, tkn_add="HOLLAR", quantity=lp.get_holdings('HOLLAR')) + actual_omnipool = starting_omnipool.copy() + alternate_omnipool = starting_omnipool.copy() + lp_shares = lp.get_holdings(('omnipool', 'HOLLAR')) + trades = sorted(trades, key=lambda trade: trade['date']) + for omnipool in [actual_omnipool, alternate_omnipool]: + for trade in trades: + if trade['name'] == 'Omnipool.SellExecuted' or trade['name'] == 'Omnipool.BuyExecuted': + + if trade['assetIn'] == 'H2O': + assetIn = "LRNA" + else: + assetIn = trade['assetIn'] + assetOut = trade['assetOut'] + + if assetIn not in omnipool.asset_list: + if assetOut != 'HOLLAR': + continue + omnipool.liquidity['HOLLAR'] -= trade['amountOut'] + omnipool.lrna['HOLLAR'] += trade['hubAmountIn'] + continue + + elif assetOut not in omnipool.asset_list: + if assetIn != 'HOLLAR': + continue + omnipool.liquidity['HOLLAR'] += trade['amountIn'] + omnipool.lrna['HOLLAR'] -= trade['hubAmountOut'] + continue + + omnipool.liquidity[assetOut] -= trade['amountOut'] + if assetIn == 'LRNA': + if omnipool == actual_omnipool: + omnipool.lrna['HDX'] += trade['amountIn'] + else: + omnipool.lrna[assetOut] += trade['amountIn'] + else: + omnipool.liquidity[assetIn] += trade['amountIn'] + omnipool.lrna[assetOut] += trade['hubAmountIn'] + omnipool.lrna[assetIn] -= trade['hubAmountOut'] + + elif trade['name'] == 'Omnipool.LiquidityAdded' or trade['name'] == 'Omnipool.PositionCreated': + omnipool.add_liquidity( + trade_agent, + tkn_add='HOLLAR', + quantity=int(trade['amount']) / 10 ** 18 + ) + elif trade['name'] == 'Omnipool.LiquidityRemoved': + omnipool.remove_liquidity( + trade_agent, + quantity=int(trade['sharesRemoved']) / 10 ** 18, + tkn_remove='HOLLAR' + ) + + lp1, lp2 = lp.copy(), lp.copy() + actual_omnipool.remove_liquidity(lp1, quantity=lp1.get_holdings(('omnipool', 'HOLLAR')), tkn_remove='HOLLAR') + alternate_omnipool.remove_liquidity(lp2, quantity=lp2.get_holdings(('omnipool', 'HOLLAR')), tkn_remove='HOLLAR') + + + pass + + +if __name__ == "__main__": + find_hollar_trades() + +# question: how is a sell: H2O, buy: HOLLAR trade actually routed? +# question: is there a simpler way to determine the difference between an LP withdrawing and putting their H2O in the pool vs diverting it to HDX? +# note: to a HOLLAR LP who is withdrawing, there is actually no downside to H2O price falling. +# the situation where H2O price falls is strictly better for them. +# it sounds like trades which went through the router probably do count as selling H2O for Hollar. I will proceed with that. +# I would like to graph: Hollar going out of the pool, H2O going in over time, and the likely impact on LPs from that. +# +# methodology will be: +# the share of hollar pool owned by LP when the change went in. +# the percentage of HOLLAR in the pool that was sold for H2O. +# LP shares / total shares * percentage of HOLLAR sold / 2 = amount of HOLLAR LP lost out on +# +# I want to take one LP through the whole date range and see if my estimate for how much they lost +# matches up decently well with the simulation +# +# so far, no. I'm losing like half the liquidity along the way for some reason, and I don't really know where it went. +# I need to find out. +# +# we look through each day when this change was active and see what percentage of the HOLLAR liquidity was sold for H2O specifically on that day. +# then we apply that percentage to the LP's share of the pool to see how much of their HOLLAR was effectively sold for H2O each day, and sum that up over the date range. +# I find that there aren't any impermanent losses avoided for the LPs, because when the price of H2O falls, the value of their withdrawals actually increases +# (They receive the same amount of Hollar when they withdraw plus more H2O.) +# +# +# diff --git a/hydradx/apps/omnipool/trade_downloader.py b/hydradx/apps/omnipool/trade_downloader.py new file mode 100644 index 000000000..71dfb4bf1 --- /dev/null +++ b/hydradx/apps/omnipool/trade_downloader.py @@ -0,0 +1,67 @@ +import os, sys +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +sys.path.append(project_root) + +from hydradx.model.indexer_utils import get_blocks_at_timestamps, get_omnipool_trades, get_all_trades +from pathlib import Path +import datetime +import json + +def get_trades_for_dates( + start_date: datetime.datetime, + end_date: datetime.datetime, + save_cache=True, + cache_directory: str | Path | None = None, + extra_filter: str | None = 'name: {startsWith: "Omnipool", endsWith: "Executed}' +): + loaded_trades = [] + if cache_directory is None: + cache_directory = Path(__file__).parent / 'cached data' / 'trades' + else: + cache_directory = Path(cache_directory) + block_dates = [ + start_date + + datetime.timedelta(days=i) + for i in range((end_date - start_date).days + 1) + ] + dates_to_download = [] + for date in block_dates: + date_str = date.strftime('%Y-%m-%d') + cache_file = cache_directory / f'trades_{date_str}.txt' + if Path.exists(cache_file): + cached_trades = json.load(open(cache_file, 'r')) + loaded_trades.extend(cached_trades) + # block_dates.remove(date) + else: + dates_to_download.append((date, date + datetime.timedelta(days=1))) + + for i in range(len(dates_to_download) - 1): + first_block, last_block = list(get_blocks_at_timestamps([dates_to_download[i][0], dates_to_download[i][1]]).values()) + new_trades = get_all_trades( + min_block=first_block, max_block=last_block - 1, + extra_filter=extra_filter + ) + new_trades = [{**trade, 'date': dates_to_download[i][0].strftime('%Y-%m-%d')} for trade in new_trades] + if save_cache: + save_trades_to_cache(new_trades, cache_directory=cache_directory) + + loaded_trades.extend(new_trades) + + return loaded_trades + +def save_trades_to_cache(trades, cache_directory: str | Path | None = None): + if cache_directory is None: + cache_directory = Path(__file__).parent / 'cached data' / 'trades' + else: + cache_directory = Path(cache_directory) + dates = [trade['date'] for trade in trades] + unique_dates = sorted(list(set(dates))) + for date in unique_dates: + savefile = cache_directory / f'trades_{date}.txt' + if not Path.exists(savefile): + if not Path.exists(cache_directory): + os.makedirs(cache_directory) + trades_on_date = [trade for trade in trades if trade['date'] == date] + with open(savefile, 'w') as f: + json.dump(trades_on_date, f) + diff --git a/hydradx/apps/s3_utils.py b/hydradx/apps/s3_utils.py new file mode 100644 index 000000000..2ccdc00db --- /dev/null +++ b/hydradx/apps/s3_utils.py @@ -0,0 +1,295 @@ +import mimetypes +import os +from typing import Callable +from datetime import date +from io import StringIO +from pathlib import Path + +import pandas as pd +import streamlit as st + + +# ============================================================================= +# CLOUD STORAGE (S3 / S3-compatible) +# ============================================================================= +# Configure via Streamlit secrets (secrets.toml) or environment variables: +# +# [s3] +# bucket = "my-arb-sim-cache" +# prefix = "price-cache/" # optional, default "" +# aws_access_key_id = "..." +# aws_secret_access_key = "..." +# region_name = "us-east-1" # optional +# endpoint_url = "..." # optional — for R2, MinIO, etc. +# +# If none of the above are set, cloud storage is silently disabled. +# ============================================================================= + + +def _secrets_paths() -> list[Path]: + paths = [] + env_path = os.environ.get("S3_SECRETS_PATH") + if env_path: + paths.append(Path(env_path)) + env_dir = os.environ.get("STREAMLIT_SECRETS_DIR") + if env_dir: + paths.append(Path(env_dir) / "secrets.toml") + + repo_root = Path(__file__).resolve().parents[2] + paths.append(repo_root / ".streamlit" / "secrets.toml") + + cwd_path = Path.cwd() / ".streamlit" / "secrets.toml" + if cwd_path not in paths: + paths.append(cwd_path) + + return paths + + +def _load_secrets_file() -> dict: + try: + import tomllib + except Exception: + try: + import tomli as tomllib + except Exception: + print("[cloud] tomllib/tomli not available; cannot read secrets files.") + return {} + + for secrets_path in _secrets_paths(): + if not secrets_path.exists(): + continue + try: + with secrets_path.open("rb") as handle: + data = tomllib.load(handle) + if not isinstance(data, dict): + continue + return data + except Exception: + print("[cloud] Failed to read secrets file.") + return {} + + missing_paths = ", ".join(str(p) for p in _secrets_paths()) + print("[cloud] No secrets file found in configured paths.") + return {} + + +def get_s3_config() -> dict | None: + """ + Return S3 config dict from st.secrets or environment variables, + or None if cloud storage is not configured. + """ + cfg = {} + bucket = None + try: + cfg = st.secrets.get("s3", {}) + bucket = cfg.get("bucket") + except Exception: + print("[cloud] st.secrets unavailable.") + + if not bucket: + bucket = os.environ.get("S3_BUCKET") + + if not bucket: + file_cfg = _load_secrets_file().get("s3", {}) + if file_cfg: + cfg = {**file_cfg, **cfg} + bucket = cfg.get("bucket") + + if not bucket: + print("[cloud] S3 bucket not configured; skipping cloud cache.") + return None + + def _get(key: str, env_key: str | None = None) -> str | None: + val = cfg.get(key) + if val: + return val + return os.environ.get(env_key or key.upper()) + + return { + "bucket": bucket, + "prefix": _get("prefix", "S3_PREFIX") or "", + "aws_access_key_id": _get("aws_access_key_id", "AWS_ACCESS_KEY_ID"), + "aws_secret_access_key": _get("aws_secret_access_key", "AWS_SECRET_ACCESS_KEY"), + "region_name": _get("region_name", "AWS_DEFAULT_REGION"), + "endpoint_url": _get("endpoint_url", "S3_ENDPOINT_URL"), + } + + +@st.cache_resource(show_spinner=False) +def get_s3_client(): + """Return a boto3 S3 client (cached for the session), or (None, None).""" + cfg = get_s3_config() + if cfg is None: + print("[cloud] S3 config missing; client not created.") + return None, None + + try: + import boto3 + + client_kwargs = {} + for key in ("aws_access_key_id", "aws_secret_access_key", "region_name", "endpoint_url"): + if cfg.get(key): + client_kwargs[key] = cfg[key] + + client = boto3.client("s3", **client_kwargs) + return client, cfg + except ImportError: + st.warning("boto3 is not installed — cloud cache disabled. Run `pip install boto3`.") + return None, None + except Exception: + st.warning("Could not initialise S3 client — cloud cache disabled.") + return None, None + + +def s3_key(exchange: str, day: date, cfg: dict) -> str: + prefix = cfg["prefix"].rstrip("/") + key = f"{exchange}/{day.isoformat()}.csv" + return f"{prefix}/{key}" if prefix else key + + +def s3_join(*parts: str) -> str: + return "/".join(part.strip("/") for part in parts if part) + + +def _resolve_bucket(cfg: dict, bucket_override: str | None) -> str: + return bucket_override or cfg["bucket"] + + +def download_file_from_s3(key: str, dest_path: Path, bucket: str | None = None) -> bool: + client, cfg = get_s3_client() + if client is None: + return False + + bucket_name = _resolve_bucket(cfg, bucket) + dest_path.parent.mkdir(parents=True, exist_ok=True) + try: + response = client.get_object(Bucket=bucket_name, Key=key) + body = response["Body"].read() + dest_path.write_bytes(body) + print("[cloud] Downloaded object from S3.") + return True + except client.exceptions.NoSuchKey: + return False + except Exception: + print("[cloud] S3 download failed.") + return False + + +def upload_file_to_s3(path: Path, key: str, content_type: str | None = None, bucket: str | None = None) -> bool: + client, cfg = get_s3_client() + if client is None: + return False + + if not path.exists(): + return False + + bucket_name = _resolve_bucket(cfg, bucket) + try: + body = path.read_bytes() + guessed_type, _ = mimetypes.guess_type(str(path)) + final_type = content_type or guessed_type or "application/octet-stream" + client.put_object(Bucket=bucket_name, Key=key, Body=body, ContentType=final_type) + print("[cloud] Uploaded object to S3.") + return True + except Exception: + print("[cloud] S3 upload failed.") + return False + + +def list_s3_keys(prefix: str, bucket: str | None = None) -> list[str]: + client, cfg = get_s3_client() + if client is None: + return [] + + bucket_name = _resolve_bucket(cfg, bucket) + keys = [] + try: + paginator = client.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=bucket_name, Prefix=prefix): + for item in page.get("Contents", []): + keys.append(item["Key"]) + except Exception: + print("[cloud] S3 list failed.") + return keys + + +def sync_dir_from_s3( + local_dir: Path, + prefix: str, + bucket: str | None = None, + progress_cb: Callable[[int, int, int], None] | None = None, +) -> int: + local_dir.mkdir(parents=True, exist_ok=True) + downloaded = 0 + keys = list_s3_keys(prefix, bucket=bucket) + total = len(keys) + if progress_cb: + progress_cb(0, total, downloaded) + for idx, key in enumerate(keys, start=1): + rel = key[len(prefix):].lstrip("/") + if not rel: + if progress_cb: + progress_cb(idx, total, downloaded) + continue + dest = local_dir / rel + if dest.exists(): + if progress_cb: + progress_cb(idx, total, downloaded) + continue + if download_file_from_s3(key, dest, bucket=bucket): + downloaded += 1 + if progress_cb: + progress_cb(idx, total, downloaded) + return downloaded + + +def upload_dir_to_s3(local_dir: Path, prefix: str, bucket: str | None = None) -> int: + if not local_dir.exists(): + return 0 + + uploaded = 0 + for path in local_dir.rglob("*"): + if path.is_dir(): + continue + key = s3_join(prefix, path.relative_to(local_dir).as_posix()) + if upload_file_to_s3(path, key, bucket=bucket): + uploaded += 1 + return uploaded + + +def download_from_s3(exchange: str, day: date) -> pd.DataFrame | None: + """ + Try to download a cached CSV from S3. Returns a DataFrame on success, + None if the object doesn't exist or cloud storage is unconfigured. + """ + client, cfg = get_s3_client() + if client is None: + return None + + key = s3_key(exchange, day, cfg) + try: + response = client.get_object(Bucket=cfg["bucket"], Key=key) + body = response["Body"].read().decode("utf-8") + df = pd.read_csv(StringIO(body)) + print("[cloud] Downloaded object from S3.") + return df + except client.exceptions.NoSuchKey: + return None + except Exception: + print("[cloud] S3 download failed.") + return None + + +def upload_to_s3(df: pd.DataFrame, exchange: str, day: date) -> None: + """Upload a price DataFrame as CSV to S3. Silently skips on any error.""" + client, cfg = get_s3_client() + if client is None: + return + + key = s3_key(exchange, day, cfg) + try: + body = df.to_csv(index=False).encode("utf-8") + client.put_object(Bucket=cfg["bucket"], Key=key, Body=body, ContentType="text/csv") + print("[cloud] Uploaded object to S3.") + except Exception: + print("[cloud] S3 upload failed.") diff --git a/hydradx/apps/stableswap/DIA/DIA_data.csv b/hydradx/apps/stableswap/DIA/DIA_data.csv new file mode 100644 index 000000000..e2c0d3e8c --- /dev/null +++ b/hydradx/apps/stableswap/DIA/DIA_data.csv @@ -0,0 +1,1499 @@ +"timestamp","pair","EUR/USD" +2026-03-12 13:40:33,EUR/USD,$1.151144 +2026-03-12 10:44:33,EUR/USD,$1.152427 +2026-03-12 10:12:33,EUR/USD,$1.151104 +2026-03-12 09:00:33,EUR/USD,$1.152292 +2026-03-12 07:22:33,EUR/USD,$1.153923 +2026-03-12 07:06:33,EUR/USD,$1.155116 +2026-03-12 06:46:33,EUR/USD,$1.156279 +2026-03-12 05:24:34,EUR/USD,$1.155113 +2026-03-12 05:01:32,EUR/USD,$1.155740 +2026-03-12 04:48:52,EUR/USD,$1.155619 +2026-03-12 03:05:48,EUR/USD,$1.155191 +2026-03-11 21:37:48,EUR/USD,$1.154018 +2026-03-11 20:27:48,EUR/USD,$1.155224 +2026-03-11 19:07:48,EUR/USD,$1.154061 +2026-03-11 17:51:48,EUR/USD,$1.155249 +2026-03-11 10:51:48,EUR/USD,$1.156423 +2026-03-11 10:05:48,EUR/USD,$1.157607 +2026-03-11 09:01:48,EUR/USD,$1.158808 +2026-03-11 07:55:59,EUR/USD,$1.157552 +2026-03-11 07:55:48,EUR/USD,$1.157552 +2026-03-11 07:13:48,EUR/USD,$1.159041 +2026-03-11 05:19:48,EUR/USD,$1.160285 +2026-03-11 04:09:48,EUR/USD,$1.159083 +2026-03-11 03:47:48,EUR/USD,$1.160385 +2026-03-11 02:41:48,EUR/USD,$1.161783 +2026-03-10 23:57:48,EUR/USD,$1.163216 +2026-03-10 19:19:48,EUR/USD,$1.161965 +2026-03-10 15:21:48,EUR/USD,$1.160796 +2026-03-10 14:05:59,EUR/USD,$1.161964 +2026-03-10 13:31:48,EUR/USD,$1.163183 +2026-03-10 13:21:48,EUR/USD,$1.161517 +2026-03-10 13:17:48,EUR/USD,$1.162915 +2026-03-10 12:41:48,EUR/USD,$1.164422 +2026-03-10 12:21:48,EUR/USD,$1.166073 +2026-03-10 09:57:48,EUR/USD,$1.164635 +2026-03-10 08:37:48,EUR/USD,$1.163420 +2026-03-10 08:25:48,EUR/USD,$1.164760 +2026-03-10 07:55:48,EUR/USD,$1.163409 +2026-03-10 07:05:48,EUR/USD,$1.163193 +2026-03-10 06:07:48,EUR/USD,$1.164358 +2026-03-10 05:39:48,EUR/USD,$1.162786 +2026-03-10 03:59:48,EUR/USD,$1.164387 +2026-03-10 02:53:48,EUR/USD,$1.165768 +2026-03-10 02:41:48,EUR/USD,$1.164435 +2026-03-10 01:41:48,EUR/USD,$1.163143 +2026-03-10 00:31:48,EUR/USD,$1.161981 +2026-03-09 23:03:48,EUR/USD,$1.160611 +2026-03-09 20:27:48,EUR/USD,$1.161778 +2026-03-09 19:41:48,EUR/USD,$1.162973 +2026-03-09 18:45:48,EUR/USD,$1.161657 +2026-03-09 15:59:48,EUR/USD,$1.162995 +2026-03-09 14:41:48,EUR/USD,$1.161505 +2026-03-09 14:23:48,EUR/USD,$1.160226 +2026-03-09 12:19:48,EUR/USD,$1.158041 +2026-03-09 11:05:48,EUR/USD,$1.159236 +2026-03-09 10:07:48,EUR/USD,$1.157901 +2026-03-09 09:47:48,EUR/USD,$1.156689 +2026-03-09 09:09:48,EUR/USD,$1.155442 +2026-03-09 08:57:48,EUR/USD,$1.156946 +2026-03-09 08:35:48,EUR/USD,$1.158216 +2026-03-09 08:17:48,EUR/USD,$1.156364 +2026-03-09 07:55:48,EUR/USD,$1.155145 +2026-03-09 07:53:48,EUR/USD,$1.155055 +2026-03-09 06:09:48,EUR/USD,$1.156430 +2026-03-09 04:43:48,EUR/USD,$1.155153 +2026-03-09 04:37:48,EUR/USD,$1.156333 +2026-03-09 04:23:48,EUR/USD,$1.154684 +2026-03-09 04:13:48,EUR/USD,$1.153416 +2026-03-09 03:31:48,EUR/USD,$1.152250 +2026-03-09 03:23:48,EUR/USD,$1.153420 +2026-03-09 03:17:48,EUR/USD,$1.154612 +2026-03-09 02:55:48,EUR/USD,$1.155881 +2026-03-09 02:11:48,EUR/USD,$1.154708 +2026-03-09 02:07:48,EUR/USD,$1.153544 +2026-03-09 01:23:48,EUR/USD,$1.154964 +2026-03-09 00:51:48,EUR/USD,$1.156226 +2026-03-09 00:25:48,EUR/USD,$1.154879 +2026-03-08 21:43:48,EUR/USD,$1.152020 +2026-03-08 20:07:48,EUR/USD,$1.150796 +2026-03-08 18:45:48,EUR/USD,$1.152038 +2026-03-08 17:09:48,EUR/USD,$1.153212 +2026-03-08 16:09:48,EUR/USD,$1.154370 +2026-03-08 16:05:48,EUR/USD,$1.156034 +2026-03-08 13:33:48,EUR/USD,$1.157577 +2026-03-08 07:55:48,EUR/USD,$1.158802 +2026-03-07 17:45:48,EUR/USD,$1.159252 +2026-03-07 06:55:48,EUR/USD,$1.160451 +2026-03-06 15:15:48,EUR/USD,$1.161016 +2026-03-06 12:41:48,EUR/USD,$1.159807 +2026-03-06 12:19:48,EUR/USD,$1.161059 +2026-03-06 10:21:48,EUR/USD,$1.159723 +2026-03-06 10:07:48,EUR/USD,$1.158535 +2026-03-06 09:53:48,EUR/USD,$1.156884 +2026-03-06 09:03:48,EUR/USD,$1.158125 +2026-03-06 08:55:48,EUR/USD,$1.156823 +2026-03-06 07:59:48,EUR/USD,$1.155272 +2026-03-06 07:55:48,EUR/USD,$1.156526 +2026-03-06 07:33:48,EUR/USD,$1.157874 +2026-03-06 06:55:48,EUR/USD,$1.155904 +2026-03-06 06:37:48,EUR/USD,$1.156251 +2026-03-06 06:13:48,EUR/USD,$1.155040 +2026-03-06 05:47:48,EUR/USD,$1.156296 +2026-03-06 03:39:48,EUR/USD,$1.157617 +2026-03-06 03:13:48,EUR/USD,$1.159022 +2026-03-06 00:05:48,EUR/USD,$1.160296 +2026-03-05 20:19:48,EUR/USD,$1.161583 +2026-03-05 14:31:48,EUR/USD,$1.160317 +2026-03-05 14:13:48,EUR/USD,$1.159114 +2026-03-05 10:59:48,EUR/USD,$1.157666 +2026-03-05 10:11:48,EUR/USD,$1.156314 +2026-03-05 09:29:48,EUR/USD,$1.157794 +2026-03-05 07:49:48,EUR/USD,$1.159142 +2026-03-05 07:33:48,EUR/USD,$1.160555 +2026-03-05 07:15:48,EUR/USD,$1.161841 +2026-03-05 06:55:48,EUR/USD,$1.160614 +2026-03-05 04:41:48,EUR/USD,$1.161224 +2026-03-05 04:09:48,EUR/USD,$1.162495 +2026-03-05 03:43:48,EUR/USD,$1.161138 +2026-03-05 03:09:48,EUR/USD,$1.162488 +2026-03-05 03:07:48,EUR/USD,$1.160348 +2026-03-05 02:25:48,EUR/USD,$1.158524 +2026-03-05 02:03:48,EUR/USD,$1.159805 +2026-03-05 01:45:48,EUR/USD,$1.161061 +2026-03-04 23:53:48,EUR/USD,$1.159815 +2026-03-04 21:33:48,EUR/USD,$1.161195 +2026-03-04 20:27:48,EUR/USD,$1.162386 +2026-03-04 13:59:48,EUR/USD,$1.163673 +2026-03-04 13:01:48,EUR/USD,$1.162244 +2026-03-04 09:31:48,EUR/USD,$1.163414 +2026-03-04 08:51:48,EUR/USD,$1.162033 +2026-03-04 07:51:48,EUR/USD,$1.163478 +2026-03-04 07:11:48,EUR/USD,$1.164650 +2026-03-04 06:55:48,EUR/USD,$1.163314 +2026-03-04 05:53:48,EUR/USD,$1.163496 +2026-03-04 05:25:48,EUR/USD,$1.164715 +2026-03-04 04:11:48,EUR/USD,$1.163362 +2026-03-04 03:17:48,EUR/USD,$1.160950 +2026-03-04 03:05:48,EUR/USD,$1.159453 +2026-03-04 02:07:48,EUR/USD,$1.160865 +2026-03-04 01:33:48,EUR/USD,$1.162065 +2026-03-04 00:35:48,EUR/USD,$1.160894 +2026-03-03 22:45:48,EUR/USD,$1.159624 +2026-03-03 21:59:48,EUR/USD,$1.160913 +2026-03-03 19:55:48,EUR/USD,$1.159512 +2026-03-03 19:25:48,EUR/USD,$1.158297 +2026-03-03 19:03:48,EUR/USD,$1.159515 +2026-03-03 18:05:48,EUR/USD,$1.160681 +2026-03-03 13:43:48,EUR/USD,$1.161895 +2026-03-03 13:19:48,EUR/USD,$1.160560 +2026-03-03 12:43:48,EUR/USD,$1.161869 +2026-03-03 12:11:48,EUR/USD,$1.160488 +2026-03-03 11:13:48,EUR/USD,$1.159182 +2026-03-03 10:49:48,EUR/USD,$1.160390 +2026-03-03 10:19:48,EUR/USD,$1.159051 +2026-03-03 09:43:48,EUR/USD,$1.157870 +2026-03-03 09:35:48,EUR/USD,$1.156481 +2026-03-03 09:27:48,EUR/USD,$1.154900 +2026-03-03 09:21:48,EUR/USD,$1.153653 +2026-03-03 09:13:48,EUR/USD,$1.155427 +2026-03-03 08:59:48,EUR/USD,$1.156805 +2026-03-03 08:19:48,EUR/USD,$1.158225 +2026-03-03 07:57:48,EUR/USD,$1.159531 +2026-03-03 06:57:48,EUR/USD,$1.160921 +2026-03-03 05:58:46,EUR/USD,$1.160188 +2026-03-03 05:44:46,EUR/USD,$1.159550 +2026-03-03 05:36:46,EUR/USD,$1.158391 +2026-03-03 04:40:46,EUR/USD,$1.159816 +2026-03-03 03:44:46,EUR/USD,$1.161049 +2026-03-03 02:50:46,EUR/USD,$1.162283 +2026-03-03 02:14:46,EUR/USD,$1.163468 +2026-03-03 01:52:46,EUR/USD,$1.164670 +2026-03-03 01:34:46,EUR/USD,$1.165898 +2026-03-02 23:42:46,EUR/USD,$1.167077 +2026-03-02 22:50:46,EUR/USD,$1.168445 +2026-03-02 17:20:46,EUR/USD,$1.169712 +2026-03-02 14:24:46,EUR/USD,$1.168475 +2026-03-02 14:10:46,EUR/USD,$1.169685 +2026-03-02 13:30:46,EUR/USD,$1.170923 +2026-03-02 11:12:46,EUR/USD,$1.169721 +2026-03-02 10:00:46,EUR/USD,$1.168524 +2026-03-02 09:42:46,EUR/USD,$1.169705 +2026-03-02 08:08:46,EUR/USD,$1.170943 +2026-03-02 08:00:46,EUR/USD,$1.169643 +2026-03-02 06:50:46,EUR/USD,$1.170836 +2026-03-02 06:20:46,EUR/USD,$1.172163 +2026-03-02 05:58:46,EUR/USD,$1.173356 +2026-03-02 05:18:46,EUR/USD,$1.173081 +2026-03-02 03:24:46,EUR/USD,$1.174553 +2026-03-02 03:06:46,EUR/USD,$1.173260 +2026-03-02 02:46:46,EUR/USD,$1.171893 +2026-03-02 02:34:46,EUR/USD,$1.170195 +2026-03-02 01:40:46,EUR/USD,$1.171594 +2026-03-02 00:48:46,EUR/USD,$1.173290 +2026-03-02 00:26:46,EUR/USD,$1.174482 +2026-03-02 00:06:46,EUR/USD,$1.175698 +2026-03-01 23:46:46,EUR/USD,$1.177195 +2026-03-01 19:14:46,EUR/USD,$1.178378 +2026-03-01 18:20:46,EUR/USD,$1.176921 +2026-03-01 17:34:46,EUR/USD,$1.175619 +2026-03-01 16:10:46,EUR/USD,$1.176964 +2026-03-01 15:02:46,EUR/USD,$1.178295 +2026-03-01 13:28:46,EUR/USD,$1.179528 +2026-03-01 05:58:46,EUR/USD,$1.180846 +2026-03-01 04:50:46,EUR/USD,$1.180765 +2026-03-01 03:28:46,EUR/USD,$1.181970 +2026-03-01 00:42:46,EUR/USD,$1.180607 +2026-03-01 00:38:46,EUR/USD,$1.182308 +2026-02-28 23:28:46,EUR/USD,$1.180616 +2026-02-28 23:08:46,EUR/USD,$1.181807 +2026-02-28 15:42:46,EUR/USD,$1.180410 +2026-02-28 05:58:46,EUR/USD,$1.179058 +2026-02-28 05:34:46,EUR/USD,$1.178986 +2026-02-28 04:10:46,EUR/USD,$1.180231 +2026-02-28 03:42:46,EUR/USD,$1.178984 +2026-02-28 00:26:46,EUR/USD,$1.180213 +2026-02-27 22:44:46,EUR/USD,$1.181505 +2026-02-27 21:28:46,EUR/USD,$1.180306 +2026-02-27 10:08:46,EUR/USD,$1.181522 +2026-02-27 09:50:46,EUR/USD,$1.180085 +2026-02-27 09:44:46,EUR/USD,$1.181526 +2026-02-27 08:58:46,EUR/USD,$1.179985 +2026-02-27 08:44:46,EUR/USD,$1.181353 +2026-02-27 05:58:46,EUR/USD,$1.179831 +2026-02-27 04:42:46,EUR/USD,$1.179552 +2026-02-27 02:50:46,EUR/USD,$1.180746 +2026-02-27 01:50:46,EUR/USD,$1.182071 +2026-02-26 21:42:46,EUR/USD,$1.180802 +2026-02-26 15:46:46,EUR/USD,$1.179515 +2026-02-26 14:54:46,EUR/USD,$1.180717 +2026-02-26 13:40:46,EUR/USD,$1.179170 +2026-02-26 11:22:46,EUR/USD,$1.177824 +2026-02-26 09:40:46,EUR/USD,$1.179273 +2026-02-26 09:30:46,EUR/USD,$1.178061 +2026-02-26 09:20:46,EUR/USD,$1.179859 +2026-02-26 08:58:46,EUR/USD,$1.181155 +2026-02-26 08:10:46,EUR/USD,$1.179967 +2026-02-26 07:12:46,EUR/USD,$1.181256 +2026-02-26 05:58:46,EUR/USD,$1.180040 +2026-02-26 02:56:46,EUR/USD,$1.180253 +2026-02-25 20:42:46,EUR/USD,$1.181472 +2026-02-25 19:26:46,EUR/USD,$1.182720 +2026-02-25 17:00:46,EUR/USD,$1.181454 +2026-02-25 16:56:46,EUR/USD,$1.182697 +2026-02-25 10:04:46,EUR/USD,$1.180550 +2026-02-25 09:34:46,EUR/USD,$1.178929 +2026-02-25 09:16:46,EUR/USD,$1.180266 +2026-02-25 08:38:46,EUR/USD,$1.178665 +2026-02-25 08:04:46,EUR/USD,$1.177272 +2026-02-25 06:30:46,EUR/USD,$1.178536 +2026-02-25 05:58:46,EUR/USD,$1.177346 +2026-02-25 05:48:46,EUR/USD,$1.178129 +2026-02-25 05:26:46,EUR/USD,$1.176832 +2026-02-25 03:32:46,EUR/USD,$1.178026 +2026-02-25 02:02:46,EUR/USD,$1.179250 +2026-02-24 23:56:46,EUR/USD,$1.180591 +2026-02-24 21:36:46,EUR/USD,$1.179409 +2026-02-24 16:34:46,EUR/USD,$1.178145 +2026-02-24 16:26:46,EUR/USD,$1.176889 +2026-02-24 12:44:46,EUR/USD,$1.178068 +2026-02-24 12:36:46,EUR/USD,$1.179320 +2026-02-24 12:12:46,EUR/USD,$1.177660 +2026-02-24 11:14:46,EUR/USD,$1.178941 +2026-02-24 09:36:46,EUR/USD,$1.177560 +2026-02-24 09:20:46,EUR/USD,$1.176350 +2026-02-24 07:00:46,EUR/USD,$1.177556 +2026-02-24 06:58:46,EUR/USD,$1.178905 +2026-02-24 05:58:46,EUR/USD,$1.177659 +2026-02-24 05:28:46,EUR/USD,$1.177395 +2026-02-24 03:42:46,EUR/USD,$1.178621 +2026-02-24 03:32:46,EUR/USD,$1.179933 +2026-02-24 03:02:46,EUR/USD,$1.178288 +2026-02-24 02:56:46,EUR/USD,$1.179554 +2026-02-24 01:32:46,EUR/USD,$1.178371 +2026-02-24 01:08:46,EUR/USD,$1.177183 +2026-02-24 00:48:46,EUR/USD,$1.178402 +2026-02-23 22:28:46,EUR/USD,$1.176851 +2026-02-23 19:16:46,EUR/USD,$1.178073 +2026-02-23 17:10:46,EUR/USD,$1.179271 +2026-02-23 16:00:46,EUR/USD,$1.178082 +2026-02-23 09:56:46,EUR/USD,$1.179277 +2026-02-23 09:14:46,EUR/USD,$1.180703 +2026-02-23 08:02:46,EUR/USD,$1.179512 +2026-02-23 07:12:46,EUR/USD,$1.178186 +2026-02-23 06:28:46,EUR/USD,$1.176989 +2026-02-23 05:58:46,EUR/USD,$1.178214 +2026-02-23 05:42:46,EUR/USD,$1.177728 +2026-02-23 05:14:46,EUR/USD,$1.179080 +2026-02-23 03:18:46,EUR/USD,$1.180274 +2026-02-23 02:20:46,EUR/USD,$1.181473 +2026-02-23 00:16:46,EUR/USD,$1.182818 +2026-02-22 23:52:46,EUR/USD,$1.181354 +2026-02-22 23:50:46,EUR/USD,$1.182897 +2026-02-22 23:44:46,EUR/USD,$1.181445 +2026-02-22 23:42:46,EUR/USD,$1.182638 +2026-02-22 23:24:46,EUR/USD,$1.181115 +2026-02-22 17:42:46,EUR/USD,$1.182548 +2026-02-22 17:22:46,EUR/USD,$1.181109 +2026-02-22 16:08:46,EUR/USD,$1.179485 +2026-02-22 06:00:46,EUR/USD,$1.178157 +2026-02-22 04:44:49,EUR/USD,$1.177780 +2026-02-21 14:48:51,EUR/USD,$1.177832 +2026-02-20 14:48:51,EUR/USD,$1.178643 +2026-02-20 12:30:51,EUR/USD,$1.178375 +2026-02-20 10:36:51,EUR/USD,$1.177065 +2026-02-20 09:44:51,EUR/USD,$1.178359 +2026-02-20 09:20:51,EUR/USD,$1.179599 +2026-02-20 09:12:51,EUR/USD,$1.177555 +2026-02-20 09:08:51,EUR/USD,$1.175682 +2026-02-20 09:06:51,EUR/USD,$1.177300 +2026-02-20 05:16:51,EUR/USD,$1.175906 +2026-02-20 05:08:51,EUR/USD,$1.177176 +2026-02-20 04:22:51,EUR/USD,$1.175628 +2026-02-20 03:20:51,EUR/USD,$1.176909 +2026-02-20 02:32:51,EUR/USD,$1.175603 +2026-02-20 02:12:51,EUR/USD,$1.174284 +2026-02-20 01:14:51,EUR/USD,$1.175644 +2026-02-20 01:12:51,EUR/USD,$1.176834 +2026-02-19 20:14:51,EUR/USD,$1.175164 +2026-02-19 19:18:51,EUR/USD,$1.176402 +2026-02-19 19:16:51,EUR/USD,$1.177594 +2026-02-19 14:48:51,EUR/USD,$1.176356 +2026-02-19 11:36:51,EUR/USD,$1.176672 +2026-02-19 11:04:51,EUR/USD,$1.175407 +2026-02-19 09:10:51,EUR/USD,$1.176657 +2026-02-19 08:12:51,EUR/USD,$1.175310 +2026-02-19 08:00:51,EUR/USD,$1.176499 +2026-02-19 07:12:51,EUR/USD,$1.175045 +2026-02-19 06:58:51,EUR/USD,$1.176734 +2026-02-19 05:34:51,EUR/USD,$1.177938 +2026-02-19 04:22:51,EUR/USD,$1.179178 +2026-02-19 02:36:51,EUR/USD,$1.180456 +2026-02-19 01:04:51,EUR/USD,$1.179180 +2026-02-19 00:32:51,EUR/USD,$1.180380 +2026-02-18 20:16:51,EUR/USD,$1.179110 +2026-02-18 20:14:51,EUR/USD,$1.180457 +2026-02-18 19:28:51,EUR/USD,$1.178752 +2026-02-18 19:24:51,EUR/USD,$1.180136 +2026-02-18 17:20:51,EUR/USD,$1.178416 +2026-02-18 17:16:51,EUR/USD,$1.179618 +2026-02-18 14:48:51,EUR/USD,$1.178236 +2026-02-18 12:34:51,EUR/USD,$1.178923 +2026-02-18 11:40:51,EUR/USD,$1.180147 +2026-02-18 09:22:51,EUR/USD,$1.181534 +2026-02-18 09:10:51,EUR/USD,$1.182788 +2026-02-18 08:58:51,EUR/USD,$1.181213 +2026-02-18 07:58:51,EUR/USD,$1.182409 +2026-02-18 06:26:51,EUR/USD,$1.183605 +2026-02-18 05:28:51,EUR/USD,$1.182327 +2026-02-18 04:28:51,EUR/USD,$1.183696 +2026-02-18 04:26:51,EUR/USD,$1.184896 +2026-02-18 01:40:51,EUR/USD,$1.183379 +2026-02-17 14:48:51,EUR/USD,$1.184591 +2026-02-17 11:32:51,EUR/USD,$1.183834 +2026-02-17 10:30:51,EUR/USD,$1.182616 +2026-02-17 07:48:51,EUR/USD,$1.181423 +2026-02-17 06:00:51,EUR/USD,$1.182672 +2026-02-17 05:08:51,EUR/USD,$1.184171 +2026-02-17 05:06:51,EUR/USD,$1.185563 +2026-02-17 03:02:51,EUR/USD,$1.183974 +2026-02-17 03:00:51,EUR/USD,$1.185367 +2026-02-16 19:30:51,EUR/USD,$1.183869 +2026-02-16 14:48:51,EUR/USD,$1.185075 +2026-02-16 13:14:51,EUR/USD,$1.186017 +2026-02-16 13:10:51,EUR/USD,$1.184773 +2026-02-16 13:08:51,EUR/USD,$1.186396 +2026-02-16 11:42:51,EUR/USD,$1.184325 +2026-02-16 09:30:51,EUR/USD,$1.185633 +2026-02-16 09:26:51,EUR/USD,$1.187005 +2026-02-16 06:24:51,EUR/USD,$1.185537 +2026-02-16 03:40:51,EUR/USD,$1.186857 +2026-02-16 01:26:51,EUR/USD,$1.185484 +2026-02-16 00:34:51,EUR/USD,$1.186683 +2026-02-15 18:34:51,EUR/USD,$1.185492 +2026-02-15 16:24:51,EUR/USD,$1.186799 +2026-02-15 14:48:51,EUR/USD,$1.185568 +2026-02-15 07:06:51,EUR/USD,$1.186051 +2026-02-14 14:48:51,EUR/USD,$1.184717 +2026-02-14 03:14:51,EUR/USD,$1.184953 +2026-02-13 14:48:51,EUR/USD,$1.186167 +2026-02-13 13:44:51,EUR/USD,$1.186232 +2026-02-13 11:14:51,EUR/USD,$1.187430 +2026-02-13 10:24:51,EUR/USD,$1.185899 +2026-02-13 10:00:51,EUR/USD,$1.184692 +2026-02-13 07:44:51,EUR/USD,$1.186160 +2026-02-13 07:34:51,EUR/USD,$1.187436 +2026-02-13 06:42:51,EUR/USD,$1.186248 +2026-02-13 04:26:51,EUR/USD,$1.185052 +2026-02-13 03:10:51,EUR/USD,$1.186247 +2026-02-13 03:08:51,EUR/USD,$1.187975 +2026-02-13 00:36:51,EUR/USD,$1.185574 +2026-02-12 23:42:51,EUR/USD,$1.186855 +2026-02-12 23:40:51,EUR/USD,$1.188073 +2026-02-12 20:36:51,EUR/USD,$1.186740 +2026-02-12 20:26:51,EUR/USD,$1.188013 +2026-02-12 20:22:51,EUR/USD,$1.186571 +2026-02-12 20:20:51,EUR/USD,$1.188406 +2026-02-12 19:28:51,EUR/USD,$1.187218 +2026-02-12 19:26:51,EUR/USD,$1.188425 +2026-02-12 19:20:51,EUR/USD,$1.186675 +2026-02-12 19:18:51,EUR/USD,$1.188835 +2026-02-12 18:56:51,EUR/USD,$1.186717 +2026-02-12 18:54:51,EUR/USD,$1.187989 +2026-02-12 17:28:51,EUR/USD,$1.186696 +2026-02-12 17:20:51,EUR/USD,$1.187950 +2026-02-12 14:48:51,EUR/USD,$1.186651 +2026-02-12 10:36:51,EUR/USD,$1.186697 +2026-02-12 07:12:51,EUR/USD,$1.188071 +2026-02-12 06:56:51,EUR/USD,$1.186883 +2026-02-12 06:02:51,EUR/USD,$1.188187 +2026-02-12 04:56:51,EUR/USD,$1.186920 +2026-02-12 03:28:51,EUR/USD,$1.188319 +2026-02-12 02:40:51,EUR/USD,$1.186982 +2026-02-12 02:36:51,EUR/USD,$1.188589 +2026-02-12 01:30:51,EUR/USD,$1.187055 +2026-02-11 23:38:51,EUR/USD,$1.185637 +2026-02-11 14:48:51,EUR/USD,$1.186854 +2026-02-11 14:44:51,EUR/USD,$1.186834 +2026-02-11 12:10:51,EUR/USD,$1.188023 +2026-02-11 11:12:51,EUR/USD,$1.189272 +2026-02-11 10:36:51,EUR/USD,$1.187472 +2026-02-11 08:26:51,EUR/USD,$1.185884 +2026-02-11 08:10:51,EUR/USD,$1.187337 +2026-02-11 08:04:51,EUR/USD,$1.185918 +2026-02-11 07:50:51,EUR/USD,$1.187197 +2026-02-11 07:40:51,EUR/USD,$1.185504 +2026-02-11 07:36:51,EUR/USD,$1.184208 +2026-02-11 06:26:51,EUR/USD,$1.189655 +2026-02-11 05:20:51,EUR/USD,$1.190891 +2026-02-11 03:50:51,EUR/USD,$1.192128 +2026-02-11 03:14:51,EUR/USD,$1.190581 +2026-02-10 23:20:51,EUR/USD,$1.191832 +2026-02-10 20:48:51,EUR/USD,$1.190490 +2026-02-10 20:44:51,EUR/USD,$1.191892 +2026-02-10 19:58:51,EUR/USD,$1.190042 +2026-02-10 15:32:51,EUR/USD,$1.188741 +2026-02-10 14:48:51,EUR/USD,$1.189949 +2026-02-10 14:36:51,EUR/USD,$1.190173 +2026-02-10 11:48:51,EUR/USD,$1.188961 +2026-02-10 09:52:51,EUR/USD,$1.190220 +2026-02-10 09:34:51,EUR/USD,$1.188982 +2026-02-10 08:48:51,EUR/USD,$1.190316 +2026-02-10 08:34:51,EUR/USD,$1.191576 +2026-02-10 07:38:51,EUR/USD,$1.190034 +2026-02-10 06:48:51,EUR/USD,$1.188672 +2026-02-10 04:58:51,EUR/USD,$1.190197 +2026-02-10 04:40:51,EUR/USD,$1.191587 +2026-02-10 04:34:51,EUR/USD,$1.190249 +2026-02-10 03:24:51,EUR/USD,$1.191574 +2026-02-09 17:28:51,EUR/USD,$1.190128 +2026-02-09 14:48:51,EUR/USD,$1.191441 +2026-02-09 13:00:51,EUR/USD,$1.191046 +2026-02-09 10:22:51,EUR/USD,$1.189790 +2026-02-09 08:28:51,EUR/USD,$1.191076 +2026-02-09 07:54:51,EUR/USD,$1.189667 +2026-02-09 07:14:51,EUR/USD,$1.188384 +2026-02-09 06:20:51,EUR/USD,$1.187154 +2026-02-09 03:00:51,EUR/USD,$1.185873 +2026-02-09 00:40:51,EUR/USD,$1.184393 +2026-02-09 00:08:51,EUR/USD,$1.183090 +2026-02-08 22:22:51,EUR/USD,$1.181867 +2026-02-08 20:06:51,EUR/USD,$1.180667 +2026-02-08 18:04:51,EUR/USD,$1.181938 +2026-02-08 17:04:51,EUR/USD,$1.180408 +2026-02-08 14:48:51,EUR/USD,$1.179161 +2026-02-08 12:00:51,EUR/USD,$1.179139 +2026-02-08 11:54:51,EUR/USD,$1.177895 +2026-02-08 07:38:51,EUR/USD,$1.179307 +2026-02-08 07:00:51,EUR/USD,$1.178010 +2026-02-07 19:02:51,EUR/USD,$1.179362 +2026-02-07 14:48:51,EUR/USD,$1.178068 +2026-02-07 10:18:51,EUR/USD,$1.178276 +2026-02-07 09:08:51,EUR/USD,$1.177027 +2026-02-07 05:58:51,EUR/USD,$1.178302 +2026-02-07 05:04:51,EUR/USD,$1.179502 +2026-02-07 03:08:51,EUR/USD,$1.177994 +2026-02-07 02:00:51,EUR/USD,$1.179239 +2026-02-07 01:26:51,EUR/USD,$1.180422 +2026-02-06 21:14:51,EUR/USD,$1.178919 +2026-02-06 21:06:51,EUR/USD,$1.180124 +2026-02-06 16:46:51,EUR/USD,$1.178926 +2026-02-06 14:48:51,EUR/USD,$1.180183 +2026-02-06 10:48:51,EUR/USD,$1.179691 +2026-02-06 10:44:51,EUR/USD,$1.178381 +2026-02-06 09:42:51,EUR/USD,$1.179825 +2026-02-06 09:26:51,EUR/USD,$1.178184 +2026-02-06 09:08:51,EUR/USD,$1.179364 +2026-02-06 08:40:51,EUR/USD,$1.180950 +2026-02-06 08:10:51,EUR/USD,$1.179706 +2026-02-06 05:36:51,EUR/USD,$1.178449 +2026-02-06 05:32:51,EUR/USD,$1.179972 +2026-02-06 05:28:51,EUR/USD,$1.178596 +2026-02-06 04:54:51,EUR/USD,$1.177417 +2026-02-06 03:44:51,EUR/USD,$1.178821 +2026-02-06 03:40:51,EUR/USD,$1.180245 +2026-02-06 00:38:51,EUR/USD,$1.179044 +2026-02-06 00:28:51,EUR/USD,$1.180356 +2026-02-05 23:56:51,EUR/USD,$1.178927 +2026-02-05 23:54:52,EUR/USD,$1.180783 +2026-02-05 23:38:51,EUR/USD,$1.179040 +2026-02-05 23:22:51,EUR/USD,$1.180452 +2026-02-05 22:56:51,EUR/USD,$1.178853 +2026-02-05 22:52:51,EUR/USD,$1.180072 +2026-02-05 22:28:51,EUR/USD,$1.178302 +2026-02-05 22:14:51,EUR/USD,$1.179603 +2026-02-05 20:12:51,EUR/USD,$1.178259 +2026-02-05 19:24:51,EUR/USD,$1.176974 +2026-02-05 19:06:51,EUR/USD,$1.178207 +2026-02-05 18:22:51,EUR/USD,$1.176180 +2026-02-05 18:08:51,EUR/USD,$1.178338 +2026-02-05 16:58:51,EUR/USD,$1.176929 +2026-02-05 16:56:51,EUR/USD,$1.179172 +2026-02-05 15:36:51,EUR/USD,$1.177169 +2026-02-05 15:34:51,EUR/USD,$1.179077 +2026-02-05 15:06:51,EUR/USD,$1.177374 +2026-02-05 14:50:51,EUR/USD,$1.178770 +2026-02-05 14:49:02,EUR/USD,$1.180124 +2026-02-05 14:48:51,EUR/USD,$1.180124 +2026-02-05 14:22:51,EUR/USD,$1.178011 +2026-02-05 13:30:51,EUR/USD,$1.179908 +2026-02-05 13:28:51,EUR/USD,$1.181701 +2026-02-05 13:10:51,EUR/USD,$1.180456 +2026-02-05 13:06:51,EUR/USD,$1.179267 +2026-02-05 12:06:51,EUR/USD,$1.180508 +2026-02-05 09:54:51,EUR/USD,$1.179049 +2026-02-05 09:50:51,EUR/USD,$1.177832 +2026-02-05 09:32:51,EUR/USD,$1.179321 +2026-02-05 07:36:51,EUR/USD,$1.181080 +2026-02-05 07:14:51,EUR/USD,$1.179760 +2026-02-05 07:12:51,EUR/USD,$1.181233 +2026-02-05 06:50:51,EUR/USD,$1.179815 +2026-02-05 06:48:51,EUR/USD,$1.181379 +2026-02-05 06:24:51,EUR/USD,$1.179261 +2026-02-05 06:10:51,EUR/USD,$1.180796 +2026-02-05 06:06:51,EUR/USD,$1.179164 +2026-02-05 06:04:51,EUR/USD,$1.181206 +2026-02-05 05:58:51,EUR/USD,$1.179038 +2026-02-05 05:54:51,EUR/USD,$1.180442 +2026-02-05 05:52:51,EUR/USD,$1.179232 +2026-02-05 05:42:51,EUR/USD,$1.181039 +2026-02-05 05:10:51,EUR/USD,$1.179824 +2026-02-05 05:08:51,EUR/USD,$1.181163 +2026-02-05 05:04:51,EUR/USD,$1.178995 +2026-02-05 05:02:51,EUR/USD,$1.180656 +2026-02-05 04:56:51,EUR/USD,$1.179047 +2026-02-05 04:46:51,EUR/USD,$1.180228 +2026-02-05 04:44:51,EUR/USD,$1.181998 +2026-02-05 04:38:51,EUR/USD,$1.179683 +2026-02-05 04:36:51,EUR/USD,$1.181746 +2026-02-05 04:30:51,EUR/USD,$1.180410 +2026-02-05 04:28:51,EUR/USD,$1.182221 +2026-02-05 04:20:51,EUR/USD,$1.180141 +2026-02-05 04:16:51,EUR/USD,$1.178867 +2026-02-05 04:14:51,EUR/USD,$1.181337 +2026-02-05 03:40:51,EUR/USD,$1.179478 +2026-02-05 03:28:51,EUR/USD,$1.178233 +2026-02-05 03:02:51,EUR/USD,$1.179490 +2026-02-05 02:38:51,EUR/USD,$1.180696 +2026-02-05 02:36:51,EUR/USD,$1.182936 +2026-02-05 02:10:51,EUR/USD,$1.180590 +2026-02-05 02:06:51,EUR/USD,$1.182574 +2026-02-05 01:58:51,EUR/USD,$1.179643 +2026-02-05 01:56:51,EUR/USD,$1.181023 +2026-02-05 01:46:51,EUR/USD,$1.179532 +2026-02-05 01:42:51,EUR/USD,$1.181334 +2026-02-05 01:26:51,EUR/USD,$1.179734 +2026-02-05 01:24:51,EUR/USD,$1.181321 +2026-02-05 01:22:51,EUR/USD,$1.179361 +2026-02-05 01:18:51,EUR/USD,$1.180868 +2026-02-05 01:04:51,EUR/USD,$1.179556 +2026-02-05 01:00:51,EUR/USD,$1.181231 +2026-02-05 00:52:51,EUR/USD,$1.178934 +2026-02-05 00:46:51,EUR/USD,$1.180417 +2026-02-05 00:14:51,EUR/USD,$1.178775 +2026-02-05 00:10:51,EUR/USD,$1.180185 +2026-02-05 00:08:51,EUR/USD,$1.178739 +2026-02-04 23:56:51,EUR/USD,$1.180579 +2026-02-04 23:26:51,EUR/USD,$1.178358 +2026-02-04 22:56:51,EUR/USD,$1.179541 +2026-02-04 22:12:51,EUR/USD,$1.178213 +2026-02-04 20:44:51,EUR/USD,$1.179517 +2026-02-04 20:38:51,EUR/USD,$1.181704 +2026-02-04 20:06:51,EUR/USD,$1.180027 +2026-02-04 20:04:51,EUR/USD,$1.181849 +2026-02-04 19:46:51,EUR/USD,$1.180418 +2026-02-04 19:44:51,EUR/USD,$1.181605 +2026-02-04 19:14:51,EUR/USD,$1.180301 +2026-02-04 19:00:51,EUR/USD,$1.181868 +2026-02-04 18:58:51,EUR/USD,$1.179958 +2026-02-04 18:56:51,EUR/USD,$1.181778 +2026-02-04 18:22:51,EUR/USD,$1.180110 +2026-02-04 18:20:51,EUR/USD,$1.182613 +2026-02-04 18:14:51,EUR/USD,$1.179892 +2026-02-04 18:12:51,EUR/USD,$1.181457 +2026-02-04 18:10:51,EUR/USD,$1.180205 +2026-02-04 18:06:51,EUR/USD,$1.181448 +2026-02-04 18:02:51,EUR/USD,$1.180126 +2026-02-04 18:00:51,EUR/USD,$1.181549 +2026-02-04 17:52:51,EUR/USD,$1.180351 +2026-02-04 17:50:51,EUR/USD,$1.181986 +2026-02-04 17:06:51,EUR/USD,$1.180511 +2026-02-04 17:04:51,EUR/USD,$1.182431 +2026-02-04 15:40:51,EUR/USD,$1.181137 +2026-02-04 15:04:51,EUR/USD,$1.179901 +2026-02-04 14:48:51,EUR/USD,$1.181138 +2026-02-04 13:54:51,EUR/USD,$1.180874 +2026-02-04 13:26:51,EUR/USD,$1.179245 +2026-02-04 13:12:51,EUR/USD,$1.180513 +2026-02-04 12:50:51,EUR/USD,$1.179221 +2026-02-04 12:24:51,EUR/USD,$1.180453 +2026-02-04 12:22:51,EUR/USD,$1.179237 +2026-02-04 12:18:51,EUR/USD,$1.181250 +2026-02-04 09:36:51,EUR/USD,$1.179972 +2026-02-04 09:14:51,EUR/USD,$1.181184 +2026-02-04 09:12:51,EUR/USD,$1.182698 +2026-02-04 08:14:51,EUR/USD,$1.180710 +2026-02-04 06:56:51,EUR/USD,$1.182040 +2026-02-04 06:50:51,EUR/USD,$1.183289 +2026-02-04 05:44:51,EUR/USD,$1.181871 +2026-02-04 05:42:51,EUR/USD,$1.183061 +2026-02-04 05:40:51,EUR/USD,$1.180897 +2026-02-04 05:38:51,EUR/USD,$1.182420 +2026-02-04 05:20:51,EUR/USD,$1.180272 +2026-02-04 05:18:51,EUR/USD,$1.181718 +2026-02-04 05:14:51,EUR/USD,$1.180322 +2026-02-04 04:56:51,EUR/USD,$1.181651 +2026-02-04 04:34:51,EUR/USD,$1.180319 +2026-02-04 04:30:51,EUR/USD,$1.181791 +2026-02-04 04:26:51,EUR/USD,$1.180467 +2026-02-04 03:38:51,EUR/USD,$1.181710 +2026-02-04 03:18:51,EUR/USD,$1.183180 +2026-02-04 03:08:51,EUR/USD,$1.181895 +2026-02-04 02:52:51,EUR/USD,$1.183272 +2026-02-04 02:34:51,EUR/USD,$1.181988 +2026-02-04 02:18:51,EUR/USD,$1.183479 +2026-02-04 02:16:51,EUR/USD,$1.184831 +2026-02-04 01:22:51,EUR/USD,$1.183116 +2026-02-04 01:16:51,EUR/USD,$1.181852 +2026-02-03 22:50:51,EUR/USD,$1.183522 +2026-02-03 22:44:51,EUR/USD,$1.182230 +2026-02-03 22:38:51,EUR/USD,$1.184673 +2026-02-03 22:32:51,EUR/USD,$1.182443 +2026-02-03 22:30:51,EUR/USD,$1.184126 +2026-02-03 22:24:51,EUR/USD,$1.182384 +2026-02-03 22:20:51,EUR/USD,$1.183857 +2026-02-03 21:20:51,EUR/USD,$1.182295 +2026-02-03 21:18:51,EUR/USD,$1.183493 +2026-02-03 21:04:51,EUR/USD,$1.181933 +2026-02-03 21:02:51,EUR/USD,$1.183716 +2026-02-03 20:38:51,EUR/USD,$1.182230 +2026-02-03 19:46:51,EUR/USD,$1.183427 +2026-02-03 19:34:51,EUR/USD,$1.182051 +2026-02-03 19:32:51,EUR/USD,$1.183728 +2026-02-03 19:10:51,EUR/USD,$1.182057 +2026-02-03 19:08:51,EUR/USD,$1.183335 +2026-02-03 18:54:51,EUR/USD,$1.181999 +2026-02-03 18:52:51,EUR/USD,$1.183904 +2026-02-03 18:46:51,EUR/USD,$1.182026 +2026-02-03 18:44:51,EUR/USD,$1.183761 +2026-02-03 18:24:51,EUR/USD,$1.182567 +2026-02-03 17:56:51,EUR/USD,$1.181223 +2026-02-03 17:52:51,EUR/USD,$1.183144 +2026-02-03 17:48:51,EUR/USD,$1.181314 +2026-02-03 17:42:51,EUR/USD,$1.182638 +2026-02-03 17:30:51,EUR/USD,$1.181056 +2026-02-03 15:12:51,EUR/USD,$1.182302 +2026-02-03 14:48:51,EUR/USD,$1.180556 +2026-02-03 13:04:51,EUR/USD,$1.180557 +2026-02-03 13:00:51,EUR/USD,$1.182174 +2026-02-03 12:58:51,EUR/USD,$1.180769 +2026-02-03 10:42:51,EUR/USD,$1.182017 +2026-02-03 10:34:51,EUR/USD,$1.180757 +2026-02-03 10:08:51,EUR/USD,$1.181984 +2026-02-03 09:54:51,EUR/USD,$1.180801 +2026-02-03 08:38:51,EUR/USD,$1.179495 +2026-02-03 08:08:51,EUR/USD,$1.178142 +2026-02-03 06:30:51,EUR/USD,$1.179545 +2026-02-03 05:48:51,EUR/USD,$1.178283 +2026-02-03 05:30:51,EUR/USD,$1.179495 +2026-02-03 05:12:51,EUR/USD,$1.178245 +2026-02-03 05:08:51,EUR/USD,$1.179502 +2026-02-03 04:56:51,EUR/USD,$1.178183 +2026-02-03 03:40:51,EUR/USD,$1.179601 +2026-02-03 02:10:51,EUR/USD,$1.180877 +2026-02-03 01:30:51,EUR/USD,$1.182079 +2026-02-03 00:58:51,EUR/USD,$1.180745 +2026-02-03 00:44:51,EUR/USD,$1.181968 +2026-02-03 00:02:51,EUR/USD,$1.180402 +2026-02-02 23:50:51,EUR/USD,$1.181780 +2026-02-02 23:22:51,EUR/USD,$1.180137 +2026-02-02 21:50:51,EUR/USD,$1.181352 +2026-02-02 19:06:51,EUR/USD,$1.179882 +2026-02-02 19:04:51,EUR/USD,$1.181388 +2026-02-02 17:36:51,EUR/USD,$1.179663 +2026-02-02 14:48:51,EUR/USD,$1.178261 +2026-02-02 14:00:51,EUR/USD,$1.178515 +2026-02-02 13:06:51,EUR/USD,$1.177335 +2026-02-02 12:06:51,EUR/USD,$1.178541 +2026-02-02 10:42:51,EUR/USD,$1.179872 +2026-02-02 09:46:51,EUR/USD,$1.178596 +2026-02-02 09:04:51,EUR/USD,$1.180460 +2026-02-02 08:46:51,EUR/USD,$1.181889 +2026-02-02 08:14:51,EUR/USD,$1.180664 +2026-02-02 07:34:51,EUR/USD,$1.182160 +2026-02-02 07:06:51,EUR/USD,$1.183923 +2026-02-02 06:16:51,EUR/USD,$1.185205 +2026-02-02 05:36:51,EUR/USD,$1.186842 +2026-02-02 05:34:51,EUR/USD,$1.188199 +2026-02-02 05:28:51,EUR/USD,$1.186448 +2026-02-02 05:26:51,EUR/USD,$1.188170 +2026-02-02 04:40:51,EUR/USD,$1.186379 +2026-02-02 04:36:51,EUR/USD,$1.187915 +2026-02-02 04:34:51,EUR/USD,$1.186305 +2026-02-02 04:30:51,EUR/USD,$1.188257 +2026-02-02 04:18:51,EUR/USD,$1.186921 +2026-02-02 04:12:51,EUR/USD,$1.188193 +2026-02-02 03:52:51,EUR/USD,$1.186961 +2026-02-02 03:14:51,EUR/USD,$1.185680 +2026-02-02 03:08:51,EUR/USD,$1.186942 +2026-02-02 02:56:51,EUR/USD,$1.185471 +2026-02-02 02:50:51,EUR/USD,$1.187096 +2026-02-02 02:30:51,EUR/USD,$1.185872 +2026-02-02 02:16:51,EUR/USD,$1.187457 +2026-02-02 02:08:51,EUR/USD,$1.185886 +2026-02-02 01:38:51,EUR/USD,$1.187348 +2026-02-02 01:36:51,EUR/USD,$1.186105 +2026-02-02 01:00:51,EUR/USD,$1.187339 +2026-02-02 00:16:51,EUR/USD,$1.186119 +2026-02-02 00:14:51,EUR/USD,$1.187644 +2026-02-02 00:08:51,EUR/USD,$1.186275 +2026-02-01 23:54:51,EUR/USD,$1.185004 +2026-02-01 23:38:51,EUR/USD,$1.186561 +2026-02-01 23:34:51,EUR/USD,$1.187996 +2026-02-01 23:30:51,EUR/USD,$1.186551 +2026-02-01 23:24:51,EUR/USD,$1.188704 +2026-02-01 23:16:51,EUR/USD,$1.187244 +2026-02-01 23:14:51,EUR/USD,$1.188444 +2026-02-01 23:08:51,EUR/USD,$1.186524 +2026-02-01 23:04:51,EUR/USD,$1.187830 +2026-02-01 23:02:51,EUR/USD,$1.186640 +2026-02-01 22:58:51,EUR/USD,$1.187911 +2026-02-01 22:38:51,EUR/USD,$1.186532 +2026-02-01 21:56:51,EUR/USD,$1.187802 +2026-02-01 21:54:51,EUR/USD,$1.189434 +2026-02-01 19:36:51,EUR/USD,$1.186806 +2026-02-01 19:08:51,EUR/USD,$1.185589 +2026-02-01 18:12:51,EUR/USD,$1.186852 +2026-02-01 17:38:51,EUR/USD,$1.184943 +2026-02-01 17:24:51,EUR/USD,$1.183664 +2026-02-01 17:18:51,EUR/USD,$1.185161 +2026-02-01 17:12:51,EUR/USD,$1.183770 +2026-02-01 15:18:51,EUR/USD,$1.185154 +2026-02-01 14:48:51,EUR/USD,$1.183926 +2026-02-01 13:38:51,EUR/USD,$1.184400 +2026-02-01 12:58:51,EUR/USD,$1.183093 +2026-02-01 08:48:51,EUR/USD,$1.184291 +2026-02-01 03:22:51,EUR/USD,$1.182863 +2026-02-01 00:10:51,EUR/USD,$1.184052 +2026-01-31 23:20:51,EUR/USD,$1.182765 +2026-01-31 22:58:51,EUR/USD,$1.183949 +2026-01-31 18:10:51,EUR/USD,$1.182355 +2026-01-31 16:28:51,EUR/USD,$1.180803 +2026-01-31 15:12:51,EUR/USD,$1.182088 +2026-01-31 15:02:51,EUR/USD,$1.183383 +2026-01-31 14:48:51,EUR/USD,$1.182080 +2026-01-31 14:34:51,EUR/USD,$1.181886 +2026-01-31 13:18:51,EUR/USD,$1.183111 +2026-01-31 09:14:51,EUR/USD,$1.184427 +2026-01-31 09:02:51,EUR/USD,$1.185661 +2026-01-31 08:18:51,EUR/USD,$1.184327 +2026-01-31 07:38:51,EUR/USD,$1.185585 +2026-01-31 07:36:51,EUR/USD,$1.184357 +2026-01-31 07:32:51,EUR/USD,$1.185862 +2026-01-31 07:28:51,EUR/USD,$1.184391 +2026-01-31 07:25:03,EUR/USD,$1.185934 +2026-01-31 07:06:51,EUR/USD,$1.184524 +2026-01-31 06:46:51,EUR/USD,$1.185844 +2026-01-31 06:40:51,EUR/USD,$1.184622 +2026-01-31 06:24:51,EUR/USD,$1.185937 +2026-01-31 06:20:51,EUR/USD,$1.184577 +2026-01-31 05:58:51,EUR/USD,$1.185792 +2026-01-31 03:40:51,EUR/USD,$1.184481 +2026-01-31 03:38:51,EUR/USD,$1.185783 +2026-01-31 03:28:51,EUR/USD,$1.184579 +2026-01-31 03:22:51,EUR/USD,$1.185931 +2026-01-30 17:00:51,EUR/USD,$1.184553 +2026-01-30 14:48:51,EUR/USD,$1.185785 +2026-01-30 14:14:51,EUR/USD,$1.186079 +2026-01-30 13:24:51,EUR/USD,$1.187286 +2026-01-30 13:06:51,EUR/USD,$1.188838 +2026-01-30 12:52:51,EUR/USD,$1.187337 +2026-01-30 12:40:51,EUR/USD,$1.185364 +2026-01-30 12:18:51,EUR/USD,$1.186574 +2026-01-30 11:30:51,EUR/USD,$1.187851 +2026-01-30 11:26:51,EUR/USD,$1.189314 +2026-01-30 10:32:51,EUR/USD,$1.187662 +2026-01-30 09:06:51,EUR/USD,$1.188964 +2026-01-30 08:58:51,EUR/USD,$1.190167 +2026-01-30 08:04:51,EUR/USD,$1.191510 +2026-01-30 07:52:51,EUR/USD,$1.190133 +2026-01-30 07:14:51,EUR/USD,$1.191361 +2026-01-30 06:44:51,EUR/USD,$1.192664 +2026-01-30 06:20:51,EUR/USD,$1.194348 +2026-01-30 06:12:51,EUR/USD,$1.195605 +2026-01-30 05:12:51,EUR/USD,$1.193610 +2026-01-30 04:16:51,EUR/USD,$1.192253 +2026-01-30 03:50:51,EUR/USD,$1.190563 +2026-01-30 03:24:51,EUR/USD,$1.191868 +2026-01-30 02:28:51,EUR/USD,$1.193163 +2026-01-30 02:26:51,EUR/USD,$1.195007 +2026-01-30 02:16:51,EUR/USD,$1.193770 +2026-01-30 01:36:51,EUR/USD,$1.192282 +2026-01-30 01:34:51,EUR/USD,$1.193668 +2026-01-30 00:30:51,EUR/USD,$1.191846 +2026-01-30 00:10:51,EUR/USD,$1.193162 +2026-01-30 00:00:51,EUR/USD,$1.191790 +2026-01-29 23:54:51,EUR/USD,$1.193020 +2026-01-29 23:46:51,EUR/USD,$1.191429 +2026-01-29 22:10:51,EUR/USD,$1.192808 +2026-01-29 20:50:51,EUR/USD,$1.191439 +2026-01-29 20:24:51,EUR/USD,$1.190071 +2026-01-29 19:58:51,EUR/USD,$1.191756 +2026-01-29 18:52:51,EUR/USD,$1.193021 +2026-01-29 18:34:51,EUR/USD,$1.194295 +2026-01-29 18:32:51,EUR/USD,$1.195636 +2026-01-29 18:24:51,EUR/USD,$1.194242 +2026-01-29 16:20:51,EUR/USD,$1.196159 +2026-01-29 16:06:51,EUR/USD,$1.197408 +2026-01-29 14:48:51,EUR/USD,$1.195902 +2026-01-29 14:22:51,EUR/USD,$1.196202 +2026-01-29 13:50:51,EUR/USD,$1.194864 +2026-01-29 13:00:51,EUR/USD,$1.196081 +2026-01-29 10:48:51,EUR/USD,$1.194881 +2026-01-29 09:58:51,EUR/USD,$1.193604 +2026-01-29 09:38:51,EUR/USD,$1.192003 +2026-01-29 09:32:51,EUR/USD,$1.190580 +2026-01-29 09:22:51,EUR/USD,$1.192122 +2026-01-29 09:18:51,EUR/USD,$1.194246 +2026-01-29 09:10:51,EUR/USD,$1.195576 +2026-01-29 08:30:51,EUR/USD,$1.196910 +2026-01-29 07:58:51,EUR/USD,$1.195687 +2026-01-29 07:52:51,EUR/USD,$1.194390 +2026-01-29 07:38:51,EUR/USD,$1.195727 +2026-01-29 07:06:51,EUR/USD,$1.197098 +2026-01-29 07:00:51,EUR/USD,$1.195728 +2026-01-29 06:58:51,EUR/USD,$1.197303 +2026-01-29 06:54:51,EUR/USD,$1.195493 +2026-01-29 05:00:51,EUR/USD,$1.194078 +2026-01-29 03:52:51,EUR/USD,$1.195504 +2026-01-29 02:36:51,EUR/USD,$1.196933 +2026-01-29 02:12:51,EUR/USD,$1.198132 +2026-01-29 01:42:51,EUR/USD,$1.196689 +2026-01-28 23:26:51,EUR/USD,$1.198034 +2026-01-28 23:10:51,EUR/USD,$1.199320 +2026-01-28 21:58:51,EUR/USD,$1.198116 +2026-01-28 20:36:51,EUR/USD,$1.196767 +2026-01-28 20:08:51,EUR/USD,$1.195468 +2026-01-28 19:02:51,EUR/USD,$1.197000 +2026-01-28 18:34:51,EUR/USD,$1.198309 +2026-01-28 17:30:51,EUR/USD,$1.196847 +2026-01-28 16:26:51,EUR/USD,$1.195616 +2026-01-28 14:48:51,EUR/USD,$1.194398 +2026-01-28 14:12:51,EUR/USD,$1.193809 +2026-01-28 14:04:51,EUR/USD,$1.195112 +2026-01-28 14:00:51,EUR/USD,$1.193788 +2026-01-28 13:38:51,EUR/USD,$1.192487 +2026-01-28 13:28:51,EUR/USD,$1.190558 +2026-01-28 13:20:51,EUR/USD,$1.191838 +2026-01-28 13:06:51,EUR/USD,$1.190643 +2026-01-28 12:48:51,EUR/USD,$1.191878 +2026-01-28 10:26:51,EUR/USD,$1.193209 +2026-01-28 09:22:51,EUR/USD,$1.194618 +2026-01-28 09:10:51,EUR/USD,$1.193341 +2026-01-28 08:56:51,EUR/USD,$1.196220 +2026-01-28 08:46:51,EUR/USD,$1.197448 +2026-01-28 07:22:51,EUR/USD,$1.196207 +2026-01-28 05:52:51,EUR/USD,$1.197784 +2026-01-28 05:38:51,EUR/USD,$1.196311 +2026-01-28 05:08:51,EUR/USD,$1.197804 +2026-01-28 04:44:51,EUR/USD,$1.199030 +2026-01-28 04:28:51,EUR/USD,$1.197569 +2026-01-28 03:36:51,EUR/USD,$1.198966 +2026-01-28 02:48:51,EUR/USD,$1.197753 +2026-01-28 02:14:51,EUR/USD,$1.199307 +2026-01-28 02:10:51,EUR/USD,$1.200646 +2026-01-28 01:40:51,EUR/USD,$1.199246 +2026-01-28 01:30:51,EUR/USD,$1.200518 +2026-01-28 01:18:51,EUR/USD,$1.202035 +2026-01-28 01:14:51,EUR/USD,$1.200804 +2026-01-27 23:52:51,EUR/USD,$1.199373 +2026-01-27 23:50:51,EUR/USD,$1.200613 +2026-01-27 23:04:51,EUR/USD,$1.198301 +2026-01-27 19:36:51,EUR/USD,$1.199903 +2026-01-27 18:08:51,EUR/USD,$1.201198 +2026-01-27 17:28:51,EUR/USD,$1.202494 +2026-01-27 16:12:51,EUR/USD,$1.203788 +2026-01-27 15:52:51,EUR/USD,$1.205129 +2026-01-27 15:50:51,EUR/USD,$1.203902 +2026-01-27 15:46:51,EUR/USD,$1.205519 +2026-01-27 15:00:51,EUR/USD,$1.203881 +2026-01-27 14:56:51,EUR/USD,$1.205576 +2026-01-27 14:52:51,EUR/USD,$1.204106 +2026-01-27 14:50:51,EUR/USD,$1.200815 +2026-01-27 14:48:51,EUR/USD,$1.198499 +2026-01-27 14:18:51,EUR/USD,$1.199148 +2026-01-27 14:02:51,EUR/USD,$1.197595 +2026-01-27 14:00:51,EUR/USD,$1.199049 +2026-01-27 13:20:51,EUR/USD,$1.197312 +2026-01-27 13:14:51,EUR/USD,$1.198828 +2026-01-27 12:48:51,EUR/USD,$1.197507 +2026-01-27 12:44:51,EUR/USD,$1.198915 +2026-01-27 12:14:51,EUR/USD,$1.197628 +2026-01-27 12:10:51,EUR/USD,$1.199230 +2026-01-27 11:48:51,EUR/USD,$1.197827 +2026-01-27 11:26:51,EUR/USD,$1.196431 +2026-01-27 10:54:51,EUR/USD,$1.197821 +2026-01-27 10:42:51,EUR/USD,$1.199099 +2026-01-27 10:12:51,EUR/USD,$1.197322 +2026-01-27 09:34:51,EUR/USD,$1.195796 +2026-01-27 09:18:51,EUR/USD,$1.197294 +2026-01-27 09:06:51,EUR/USD,$1.195740 +2026-01-27 08:48:51,EUR/USD,$1.194316 +2026-01-27 08:38:51,EUR/USD,$1.195673 +2026-01-27 07:48:51,EUR/USD,$1.193074 +2026-01-27 07:42:51,EUR/USD,$1.194411 +2026-01-27 07:10:51,EUR/USD,$1.193194 +2026-01-27 07:04:51,EUR/USD,$1.194812 +2026-01-27 06:56:51,EUR/USD,$1.192588 +2026-01-27 06:50:51,EUR/USD,$1.193799 +2026-01-27 06:32:51,EUR/USD,$1.192501 +2026-01-27 06:26:51,EUR/USD,$1.191246 +2026-01-27 05:54:51,EUR/USD,$1.189899 +2026-01-27 05:26:51,EUR/USD,$1.188209 +2026-01-27 04:44:51,EUR/USD,$1.189404 +2026-01-27 04:38:51,EUR/USD,$1.188055 +2026-01-27 04:36:51,EUR/USD,$1.189647 +2026-01-27 03:56:51,EUR/USD,$1.188199 +2026-01-27 03:14:51,EUR/USD,$1.186935 +2026-01-27 02:24:51,EUR/USD,$1.185693 +2026-01-27 01:48:51,EUR/USD,$1.187295 +2026-01-27 01:44:51,EUR/USD,$1.188545 +2026-01-27 01:38:51,EUR/USD,$1.187347 +2026-01-27 01:34:51,EUR/USD,$1.188539 +2026-01-27 01:28:51,EUR/USD,$1.187189 +2026-01-27 01:26:51,EUR/USD,$1.189141 +2026-01-27 01:10:51,EUR/USD,$1.186998 +2026-01-26 23:06:51,EUR/USD,$1.188389 +2026-01-26 22:42:51,EUR/USD,$1.187079 +2026-01-26 21:14:51,EUR/USD,$1.188330 +2026-01-26 20:20:51,EUR/USD,$1.189968 +2026-01-26 20:14:51,EUR/USD,$1.187943 +2026-01-26 20:12:51,EUR/USD,$1.190480 +2026-01-26 18:48:51,EUR/USD,$1.187933 +2026-01-26 18:12:51,EUR/USD,$1.189129 +2026-01-26 17:48:51,EUR/USD,$1.187515 +2026-01-26 16:18:51,EUR/USD,$1.188780 +2026-01-26 16:16:51,EUR/USD,$1.190073 +2026-01-26 14:48:51,EUR/USD,$1.188297 +2026-01-26 14:06:51,EUR/USD,$1.188661 +2026-01-26 13:14:51,EUR/USD,$1.187386 +2026-01-26 11:30:51,EUR/USD,$1.188657 +2026-01-26 11:22:51,EUR/USD,$1.190297 +2026-01-26 09:50:51,EUR/USD,$1.188978 +2026-01-26 08:24:51,EUR/USD,$1.187745 +2026-01-26 07:58:51,EUR/USD,$1.186472 +2026-01-26 07:44:51,EUR/USD,$1.185237 +2026-01-26 07:04:51,EUR/USD,$1.184021 +2026-01-26 05:12:51,EUR/USD,$1.185472 +2026-01-26 03:30:51,EUR/USD,$1.186871 +2026-01-26 02:34:51,EUR/USD,$1.185506 +2026-01-26 01:54:51,EUR/USD,$1.184136 +2026-01-26 01:00:51,EUR/USD,$1.185541 +2026-01-26 00:36:51,EUR/USD,$1.186822 +2026-01-26 00:10:51,EUR/USD,$1.185591 +2026-01-25 23:24:51,EUR/USD,$1.186898 +2026-01-25 22:08:51,EUR/USD,$1.185640 +2026-01-25 20:48:51,EUR/USD,$1.186912 +2026-01-25 20:44:51,EUR/USD,$1.188170 +2026-01-25 19:00:51,EUR/USD,$1.186279 +2026-01-25 18:06:51,EUR/USD,$1.184884 +2026-01-25 17:40:51,EUR/USD,$1.186138 +2026-01-25 17:26:51,EUR/USD,$1.187350 +2026-01-25 16:56:51,EUR/USD,$1.188578 +2026-01-25 16:36:51,EUR/USD,$1.187187 +2026-01-25 14:48:51,EUR/USD,$1.185533 +2026-01-25 14:20:51,EUR/USD,$1.185192 +2026-01-25 11:54:51,EUR/USD,$1.183857 +2026-01-25 10:36:51,EUR/USD,$1.182667 +2026-01-25 10:18:51,EUR/USD,$1.183853 +2026-01-25 09:24:51,EUR/USD,$1.182664 +2026-01-25 09:18:51,EUR/USD,$1.184059 +2026-01-25 09:08:51,EUR/USD,$1.182676 +2026-01-25 09:04:51,EUR/USD,$1.184028 +2026-01-25 08:42:51,EUR/USD,$1.182642 +2026-01-25 08:18:51,EUR/USD,$1.183949 +2026-01-25 07:58:51,EUR/USD,$1.182764 +2026-01-25 07:56:51,EUR/USD,$1.183952 +2026-01-25 06:44:51,EUR/USD,$1.182736 +2026-01-25 06:40:51,EUR/USD,$1.184289 +2026-01-25 02:54:51,EUR/USD,$1.183053 +2026-01-24 20:18:51,EUR/USD,$1.181859 +2026-01-24 19:28:51,EUR/USD,$1.183050 +2026-01-24 14:48:51,EUR/USD,$1.181868 +2026-01-23 15:26:51,EUR/USD,$1.182685 +2026-01-23 14:48:51,EUR/USD,$1.181445 +2026-01-23 14:24:51,EUR/USD,$1.182330 +2026-01-23 13:58:51,EUR/USD,$1.180985 +2026-01-23 13:12:51,EUR/USD,$1.179555 +2026-01-23 12:10:51,EUR/USD,$1.178337 +2026-01-23 11:52:51,EUR/USD,$1.179526 +2026-01-23 11:18:51,EUR/USD,$1.178280 +2026-01-23 10:48:51,EUR/USD,$1.177099 +2026-01-23 09:04:51,EUR/USD,$1.175558 +2026-01-23 08:48:52,EUR/USD,$1.174022 +2026-01-23 08:42:51,EUR/USD,$1.175240 +2026-01-23 00:52:51,EUR/USD,$1.174046 +2026-01-22 17:54:51,EUR/USD,$1.175226 +2026-01-22 17:04:51,EUR/USD,$1.176443 +2026-01-22 14:48:51,EUR/USD,$1.174904 +2026-01-22 12:28:51,EUR/USD,$1.174848 +2026-01-22 10:12:51,EUR/USD,$1.173616 +2026-01-22 08:42:51,EUR/USD,$1.172344 +2026-01-22 07:34:51,EUR/USD,$1.170863 +2026-01-22 07:24:51,EUR/USD,$1.172078 +2026-01-22 06:00:51,EUR/USD,$1.170800 +2026-01-22 01:52:51,EUR/USD,$1.169533 +2026-01-22 01:16:51,EUR/USD,$1.168246 +2026-01-22 00:42:51,EUR/USD,$1.169461 +2026-01-21 23:52:51,EUR/USD,$1.168265 +2026-01-21 22:22:51,EUR/USD,$1.169478 +2026-01-21 18:36:51,EUR/USD,$1.168289 +2026-01-21 17:34:51,EUR/USD,$1.166986 +2026-01-21 14:48:51,EUR/USD,$1.168328 +2026-01-21 13:34:51,EUR/USD,$1.168390 +2026-01-21 09:24:51,EUR/USD,$1.169932 +2026-01-21 08:16:52,EUR/USD,$1.171760 +2026-01-21 06:36:51,EUR/USD,$1.173272 +2026-01-21 05:36:51,EUR/USD,$1.171742 +2026-01-21 05:16:51,EUR/USD,$1.170322 +2026-01-21 05:04:51,EUR/USD,$1.171656 +2026-01-21 04:20:51,EUR/USD,$1.170417 +2026-01-21 04:18:51,EUR/USD,$1.171593 +2026-01-21 03:58:51,EUR/USD,$1.170233 +2026-01-21 01:14:51,EUR/USD,$1.171511 +2026-01-21 00:20:51,EUR/USD,$1.172810 +2026-01-20 23:52:51,EUR/USD,$1.171230 +2026-01-20 14:48:51,EUR/USD,$1.172408 +2026-01-20 13:42:51,EUR/USD,$1.171975 +2026-01-20 09:38:51,EUR/USD,$1.173169 +2026-01-20 09:00:51,EUR/USD,$1.171684 +2026-01-20 08:06:51,EUR/USD,$1.172859 +2026-01-20 07:52:51,EUR/USD,$1.174117 +2026-01-20 06:52:51,EUR/USD,$1.172874 +2026-01-20 06:24:51,EUR/USD,$1.174349 +2026-01-20 04:48:51,EUR/USD,$1.173021 +2026-01-20 04:18:51,EUR/USD,$1.171823 +2026-01-20 03:40:51,EUR/USD,$1.173013 +2026-01-20 02:54:51,EUR/USD,$1.171704 +2026-01-20 02:34:51,EUR/USD,$1.170422 +2026-01-20 01:40:51,EUR/USD,$1.168934 +2026-01-20 01:26:51,EUR/USD,$1.167696 +2026-01-19 22:58:51,EUR/USD,$1.166418 +2026-01-19 21:28:51,EUR/USD,$1.165218 +2026-01-19 14:48:51,EUR/USD,$1.164000 +2026-01-19 10:14:51,EUR/USD,$1.163922 +2026-01-19 04:20:51,EUR/USD,$1.162632 +2026-01-19 03:20:51,EUR/USD,$1.161455 +2026-01-18 22:28:51,EUR/USD,$1.162653 +2026-01-18 20:24:51,EUR/USD,$1.161370 +2026-01-18 18:36:51,EUR/USD,$1.162684 +2026-01-18 17:36:51,EUR/USD,$1.161144 +2026-01-18 17:08:51,EUR/USD,$1.159868 +2026-01-18 16:22:51,EUR/USD,$1.157917 +2026-01-18 14:48:51,EUR/USD,$1.159205 +2026-01-18 13:30:51,EUR/USD,$1.159582 +2026-01-17 14:48:51,EUR/USD,$1.158337 +2026-01-17 13:08:51,EUR/USD,$1.158442 +2026-01-16 14:48:51,EUR/USD,$1.159644 +2026-01-16 14:36:51,EUR/USD,$1.159789 +2026-01-16 12:16:51,EUR/USD,$1.158618 +2026-01-16 10:10:51,EUR/USD,$1.159803 +2026-01-16 09:44:51,EUR/USD,$1.158610 +2026-01-16 09:26:51,EUR/USD,$1.160485 +2026-01-16 09:00:51,EUR/USD,$1.161703 +2026-01-16 07:22:51,EUR/USD,$1.160533 +2026-01-16 05:18:51,EUR/USD,$1.161864 +2026-01-15 14:48:51,EUR/USD,$1.160391 +2026-01-15 07:40:51,EUR/USD,$1.160464 +2026-01-15 07:00:51,EUR/USD,$1.161655 +2026-01-15 02:32:51,EUR/USD,$1.162842 +2026-01-15 01:32:51,EUR/USD,$1.161585 +2026-01-14 19:26:51,EUR/USD,$1.162762 +2026-01-14 14:48:51,EUR/USD,$1.164174 +2026-01-14 12:48:51,EUR/USD,$1.163518 +2026-01-13 14:50:51,EUR/USD,$1.164781 +2026-01-13 11:44:11,EUR/USD,$1.164736 +2026-01-13 10:22:11,EUR/USD,$1.163564 +2026-01-13 09:36:11,EUR/USD,$1.164374 +2026-01-13 09:14:11,EUR/USD,$1.165608 +2026-01-13 07:36:11,EUR/USD,$1.166860 +2026-01-13 06:58:11,EUR/USD,$1.165660 +2026-01-13 03:24:11,EUR/USD,$1.166856 +2026-01-13 00:14:11,EUR/USD,$1.165637 +2026-01-12 22:40:11,EUR/USD,$1.167130 +2026-01-12 21:36:11,EUR/USD,$1.165914 +2026-01-12 21:34:11,EUR/USD,$1.157903 +2026-01-12 14:56:11,EUR/USD,$1.166539 +2026-01-12 10:22:11,EUR/USD,$1.167743 +2026-01-12 08:32:11,EUR/USD,$1.167597 +2026-01-12 06:36:11,EUR/USD,$1.168871 +2026-01-12 06:02:11,EUR/USD,$1.167584 +2026-01-12 02:52:11,EUR/USD,$1.168774 +2026-01-11 23:48:11,EUR/USD,$1.167594 +2026-01-11 18:58:11,EUR/USD,$1.166396 +2026-01-11 18:40:11,EUR/USD,$1.165195 +2026-01-11 18:38:11,EUR/USD,$1.162565 +2026-01-11 18:36:11,EUR/USD,$1.164651 +2026-01-11 18:10:11,EUR/USD,$1.163231 +2026-01-11 18:06:11,EUR/USD,$1.162053 +2026-01-11 10:22:11,EUR/USD,$1.163224 +2026-01-11 03:42:11,EUR/USD,$1.163579 +2026-01-10 10:22:11,EUR/USD,$1.162393 +2026-01-09 10:22:11,EUR/USD,$1.163350 +2026-01-09 09:40:11,EUR/USD,$1.163518 +2026-01-09 09:14:11,EUR/USD,$1.162171 +2026-01-09 08:44:11,EUR/USD,$1.163530 +2026-01-08 19:48:11,EUR/USD,$1.164728 +2026-01-08 16:04:11,EUR/USD,$1.165969 +2026-01-08 15:04:11,EUR/USD,$1.164725 +2026-01-08 15:02:11,EUR/USD,$1.167128 +2026-01-08 11:34:11,EUR/USD,$1.164510 +2026-01-08 10:22:11,EUR/USD,$1.165773 +2026-01-08 07:52:11,EUR/USD,$1.165921 +2026-01-08 06:06:11,EUR/USD,$1.167163 +2026-01-08 04:10:11,EUR/USD,$1.168387 +2026-01-08 02:38:11,EUR/USD,$1.167032 +2026-01-08 00:16:11,EUR/USD,$1.168256 +2026-01-07 20:22:11,EUR/USD,$1.166929 +2026-01-07 10:22:11,EUR/USD,$1.168147 +2026-01-07 09:20:11,EUR/USD,$1.168244 +2026-01-07 08:52:11,EUR/USD,$1.169653 +2026-01-07 04:12:11,EUR/USD,$1.168379 +2026-01-07 02:16:11,EUR/USD,$1.167180 +2026-01-06 23:10:11,EUR/USD,$1.168576 +2026-01-06 20:16:11,EUR/USD,$1.169842 +2026-01-06 13:06:11,EUR/USD,$1.168670 +2026-01-06 12:02:11,EUR/USD,$1.169869 +2026-01-06 10:22:11,EUR/USD,$1.168678 +2026-01-06 09:28:11,EUR/USD,$1.168500 +2026-01-06 08:32:11,EUR/USD,$1.169732 +2026-01-06 07:38:11,EUR/USD,$1.170956 +2026-01-06 06:32:12,EUR/USD,$1.169384 +2026-01-06 06:12:11,EUR/USD,$1.170582 +2026-01-06 05:18:11,EUR/USD,$1.169408 +2026-01-06 03:18:11,EUR/USD,$1.170818 +2026-01-05 20:54:11,EUR/USD,$1.172732 +2026-01-05 19:48:11,EUR/USD,$1.171419 +2026-01-05 19:46:11,EUR/USD,$1.170158 +2026-01-05 18:20:11,EUR/USD,$1.171334 +2026-01-05 18:18:11,EUR/USD,$1.169965 +2026-01-05 11:24:11,EUR/USD,$1.171300 +2026-01-05 10:22:11,EUR/USD,$1.169735 +2026-01-05 10:08:11,EUR/USD,$1.169805 +2026-01-05 09:08:11,EUR/USD,$1.168508 +2026-01-05 08:12:11,EUR/USD,$1.167049 +2026-01-05 07:12:11,EUR/USD,$1.165879 +2026-01-05 02:56:11,EUR/USD,$1.167504 +2026-01-05 00:48:11,EUR/USD,$1.168769 +2026-01-04 21:24:11,EUR/USD,$1.167567 +2026-01-04 20:38:11,EUR/USD,$1.168754 +2026-01-04 17:08:11,EUR/USD,$1.169928 +2026-01-04 10:22:11,EUR/USD,$1.171132 +2026-01-04 10:14:11,EUR/USD,$1.171193 +2026-01-04 04:32:11,EUR/USD,$1.169964 +2026-01-03 10:22:11,EUR/USD,$1.171203 +2026-01-03 00:30:11,EUR/USD,$1.170947 +2026-01-02 12:30:11,EUR/USD,$1.172132 +2026-01-02 10:22:11,EUR/USD,$1.173579 +2026-01-02 09:58:11,EUR/USD,$1.174263 +2026-01-02 08:54:11,EUR/USD,$1.172793 +2026-01-02 04:24:11,EUR/USD,$1.171537 +2026-01-02 02:22:11,EUR/USD,$1.172800 +2026-01-02 00:32:11,EUR/USD,$1.174210 +2026-01-01 22:38:11,EUR/USD,$1.175554 +2026-01-01 20:24:11,EUR/USD,$1.176849 +2026-01-01 18:16:11,EUR/USD,$1.175350 +2026-01-01 11:18:11,EUR/USD,$1.174140 +2026-01-01 10:58:11,EUR/USD,$1.175584 +2026-01-01 10:54:11,EUR/USD,$1.174339 +2026-01-01 10:52:11,EUR/USD,$1.175538 +2026-01-01 10:50:11,EUR/USD,$1.174067 +2026-01-01 10:42:11,EUR/USD,$1.175249 +2026-01-01 10:38:11,EUR/USD,$1.174055 +2026-01-01 10:28:11,EUR/USD,$1.175360 +2026-01-01 10:22:11,EUR/USD,$1.174039 +2026-01-01 08:32:11,EUR/USD,$1.174093 +2026-01-01 08:20:11,EUR/USD,$1.175402 +2026-01-01 08:18:11,EUR/USD,$1.177120 +2026-01-01 06:40:11,EUR/USD,$1.174988 +2026-01-01 06:36:11,EUR/USD,$1.177075 +2026-01-01 06:20:11,EUR/USD,$1.174681 +2026-01-01 06:18:11,EUR/USD,$1.177704 +2026-01-01 01:32:11,EUR/USD,$1.175107 +2026-01-01 01:28:11,EUR/USD,$1.177123 +2025-12-31 21:22:11,EUR/USD,$1.175633 +2025-12-31 21:16:11,EUR/USD,$1.177171 +2025-12-31 21:14:11,EUR/USD,$1.175624 +2025-12-31 21:08:11,EUR/USD,$1.177173 +2025-12-31 20:04:11,EUR/USD,$1.175814 +2025-12-31 19:42:11,EUR/USD,$1.177535 +2025-12-31 19:02:11,EUR/USD,$1.176258 +2025-12-31 17:24:11,EUR/USD,$1.174649 +2025-12-31 17:08:11,EUR/USD,$1.175872 +2025-12-31 16:56:11,EUR/USD,$1.177132 +2025-12-31 16:48:11,EUR/USD,$1.175711 +2025-12-31 16:44:11,EUR/USD,$1.179484 +2025-12-31 14:54:11,EUR/USD,$1.174892 +2025-12-31 14:52:11,EUR/USD,$1.176390 +2025-12-31 11:26:11,EUR/USD,$1.173979 +2025-12-31 11:24:11,EUR/USD,$1.175231 +2025-12-31 10:48:11,EUR/USD,$1.173404 +2025-12-31 10:22:11,EUR/USD,$1.174686 +2025-12-31 09:34:11,EUR/USD,$1.174174 +2025-12-31 09:24:11,EUR/USD,$1.172859 +2025-12-31 09:22:11,EUR/USD,$1.175349 +2025-12-31 09:04:11,EUR/USD,$1.173334 +2025-12-31 07:52:11,EUR/USD,$1.174580 +2025-12-31 07:48:11,EUR/USD,$1.176182 +2025-12-31 07:36:11,EUR/USD,$1.174752 +2025-12-31 07:16:11,EUR/USD,$1.175999 +2025-12-31 05:40:11,EUR/USD,$1.174784 +2025-12-30 23:00:11,EUR/USD,$1.173426 +2025-12-30 14:10:11,EUR/USD,$1.174601 +2025-12-30 10:22:11,EUR/USD,$1.175795 +2025-12-30 09:38:11,EUR/USD,$1.174782 +2025-12-30 05:58:11,EUR/USD,$1.176171 +2025-12-29 14:12:11,EUR/USD,$1.177519 +2025-12-29 13:44:11,EUR/USD,$1.176329 +2025-12-29 11:58:11,EUR/USD,$1.175096 +2025-12-29 10:22:11,EUR/USD,$1.176320 +2025-12-29 10:02:11,EUR/USD,$1.176927 +2025-12-29 09:12:11,EUR/USD,$1.178109 +2025-12-29 09:06:11,EUR/USD,$1.179373 +2025-12-29 08:12:11,EUR/USD,$1.177874 +2025-12-29 06:18:11,EUR/USD,$1.176032 +2025-12-29 05:10:11,EUR/USD,$1.177286 +2025-12-29 04:06:11,EUR/USD,$1.178521 +2025-12-29 04:04:11,EUR/USD,$1.179775 +2025-12-29 04:02:11,EUR/USD,$1.178397 +2025-12-29 03:56:11,EUR/USD,$1.180489 +2025-12-29 01:36:11,EUR/USD,$1.179195 +2025-12-29 00:24:11,EUR/USD,$1.177944 +2025-12-29 00:20:11,EUR/USD,$1.176435 +2025-12-29 00:06:11,EUR/USD,$1.178109 +2025-12-28 23:58:11,EUR/USD,$1.176167 +2025-12-28 23:52:11,EUR/USD,$1.177852 +2025-12-28 23:44:11,EUR/USD,$1.176646 +2025-12-28 23:38:11,EUR/USD,$1.178042 +2025-12-28 23:30:11,EUR/USD,$1.176699 +2025-12-28 22:56:11,EUR/USD,$1.177990 +2025-12-28 22:52:11,EUR/USD,$1.179292 +2025-12-28 22:50:11,EUR/USD,$1.177182 +2025-12-28 22:48:11,EUR/USD,$1.179267 +2025-12-28 22:34:11,EUR/USD,$1.177410 +2025-12-28 18:42:12,EUR/USD,$1.178873 +2025-12-28 16:10:11,EUR/USD,$1.177631 +2025-12-28 10:22:11,EUR/USD,$1.178828 +2025-12-28 07:26:11,EUR/USD,$1.179234 +2025-12-28 07:20:11,EUR/USD,$1.180529 +2025-12-28 05:12:11,EUR/USD,$1.178866 +2025-12-27 10:22:11,EUR/USD,$1.177504 +2025-12-27 03:54:11,EUR/USD,$1.177082 +2025-12-27 03:50:11,EUR/USD,$1.178624 +2025-12-27 02:50:11,EUR/USD,$1.176975 +2025-12-27 02:46:11,EUR/USD,$1.178461 +2025-12-26 10:22:11,EUR/USD,$1.176985 +2025-12-26 10:14:11,EUR/USD,$1.177193 +2025-12-26 08:52:11,EUR/USD,$1.178419 +2025-12-26 07:46:11,EUR/USD,$1.179628 +2025-12-26 00:50:11,EUR/USD,$1.178242 +2025-12-25 23:56:11,EUR/USD,$1.179606 +2025-12-25 22:22:11,EUR/USD,$1.178304 +2025-12-25 20:46:11,EUR/USD,$1.179688 +2025-12-25 10:22:11,EUR/USD,$1.178334 +2025-12-25 04:36:11,EUR/USD,$1.178818 +2025-12-24 10:22:11,EUR/USD,$1.177628 +2025-12-24 08:58:11,EUR/USD,$1.177777 +2025-12-24 05:56:11,EUR/USD,$1.179088 +2025-12-24 05:22:11,EUR/USD,$1.180318 +2025-12-24 02:50:11,EUR/USD,$1.179052 +2025-12-24 02:22:11,EUR/USD,$1.177805 +2025-12-24 01:34:11,EUR/USD,$1.179045 +2025-12-24 00:10:11,EUR/USD,$1.180232 +2025-12-23 21:42:11,EUR/USD,$1.178926 +2025-12-23 19:36:11,EUR/USD,$1.180126 +2025-12-23 14:36:11,EUR/USD,$1.178935 +2025-12-23 10:22:11,EUR/USD,$1.177753 +2025-12-23 08:26:11,EUR/USD,$1.177128 +2025-12-23 07:34:12,EUR/USD,$1.178344 +2025-12-23 04:56:11,EUR/USD,$1.179664 +2025-12-23 03:16:11,EUR/USD,$1.178481 +2025-12-22 19:44:11,EUR/USD,$1.177239 +2025-12-22 11:36:11,EUR/USD,$1.176045 +2025-12-22 10:56:11,EUR/USD,$1.174800 +2025-12-22 10:22:11,EUR/USD,$1.176069 +2025-12-22 09:56:11,EUR/USD,$1.176394 +2025-12-22 07:58:11,EUR/USD,$1.175084 +2025-12-22 04:22:11,EUR/USD,$1.173896 +2025-12-22 01:52:11,EUR/USD,$1.172634 +2025-12-21 10:22:11,EUR/USD,$1.171336 +2025-12-21 03:04:11,EUR/USD,$1.170795 +2025-12-21 03:00:11,EUR/USD,$1.172126 +2025-12-20 22:54:11,EUR/USD,$1.170746 +2025-12-20 22:52:11,EUR/USD,$1.172367 +2025-12-20 16:06:11,EUR/USD,$1.170991 +2025-12-20 16:04:11,EUR/USD,$1.172402 +2025-12-20 12:10:11,EUR/USD,$1.170889 +2025-12-20 12:08:11,EUR/USD,$1.172396 +2025-12-20 10:22:11,EUR/USD,$1.171140 +2025-12-20 06:32:11,EUR/USD,$1.170854 +2025-12-20 06:28:11,EUR/USD,$1.172271 +2025-12-20 05:44:11,EUR/USD,$1.170738 +2025-12-20 05:30:11,EUR/USD,$1.171918 +2025-12-20 05:28:11,EUR/USD,$1.170710 +2025-12-20 05:22:11,EUR/USD,$1.171899 +2025-12-20 05:20:11,EUR/USD,$1.170715 +2025-12-20 05:18:11,EUR/USD,$1.172479 +2025-12-20 04:48:11,EUR/USD,$1.170513 +2025-12-20 04:24:11,EUR/USD,$1.171874 +2025-12-20 03:36:11,EUR/USD,$1.170556 +2025-12-20 03:04:11,EUR/USD,$1.171861 +2025-12-20 02:44:11,EUR/USD,$1.170662 +2025-12-20 02:42:11,EUR/USD,$1.172414 +2025-12-20 01:54:11,EUR/USD,$1.171057 +2025-12-20 01:48:11,EUR/USD,$1.172536 +2025-12-20 00:28:11,EUR/USD,$1.171262 +2025-12-19 23:44:11,EUR/USD,$1.172495 +2025-12-19 19:56:11,EUR/USD,$1.171115 +2025-12-19 19:46:11,EUR/USD,$1.172784 +2025-12-19 16:24:11,EUR/USD,$1.170963 +2025-12-19 10:22:11,EUR/USD,$1.172139 +2025-12-19 08:06:11,EUR/USD,$1.172672 +2025-12-19 05:14:11,EUR/USD,$1.171294 +2025-12-19 05:08:11,EUR/USD,$1.172540 +2025-12-19 03:44:11,EUR/USD,$1.170915 +2025-12-19 02:42:11,EUR/USD,$1.172173 +2025-12-19 02:12:11,EUR/USD,$1.170881 +2025-12-19 02:10:11,EUR/USD,$1.172740 +2025-12-19 02:02:11,EUR/USD,$1.170515 +2025-12-18 22:26:11,EUR/USD,$1.171719 +2025-12-18 17:28:11,EUR/USD,$1.172988 +2025-12-18 17:26:11,EUR/USD,$1.174243 +2025-12-18 17:00:11,EUR/USD,$1.172378 +2025-12-18 16:58:11,EUR/USD,$1.173857 +2025-12-18 14:04:11,EUR/USD,$1.172321 +2025-12-18 14:02:11,EUR/USD,$1.174168 +2025-12-18 13:58:11,EUR/USD,$1.172285 +2025-12-18 13:54:11,EUR/USD,$1.174103 +2025-12-18 11:22:11,EUR/USD,$1.172247 +2025-12-18 10:52:11,EUR/USD,$1.173442 +2025-12-18 10:22:11,EUR/USD,$1.174706 +2025-12-18 10:08:11,EUR/USD,$1.174126 +2025-12-18 09:38:11,EUR/USD,$1.172567 +2025-12-18 08:46:11,EUR/USD,$1.173749 +2025-12-18 07:54:11,EUR/USD,$1.175145 +2025-12-18 07:20:11,EUR/USD,$1.173439 +2025-12-18 06:10:11,EUR/USD,$1.172073 +2025-12-18 05:08:11,EUR/USD,$1.173321 +2025-12-18 03:22:11,EUR/USD,$1.172104 +2025-12-18 02:12:11,EUR/USD,$1.173518 +2025-12-18 00:52:11,EUR/USD,$1.174705 +2025-12-18 00:46:11,EUR/USD,$1.175929 +2025-12-18 00:42:11,EUR/USD,$1.174457 +2025-12-18 00:40:11,EUR/USD,$1.175646 +2025-12-17 20:06:11,EUR/USD,$1.174327 +2025-12-17 20:04:11,EUR/USD,$1.175751 +2025-12-17 19:56:11,EUR/USD,$1.174515 +2025-12-17 19:48:11,EUR/USD,$1.175815 +2025-12-17 19:40:11,EUR/USD,$1.174079 +2025-12-17 19:32:11,EUR/USD,$1.175526 +2025-12-17 18:48:11,EUR/USD,$1.174250 +2025-12-17 18:14:12,EUR/USD,$1.175588 +2025-12-17 16:30:11,EUR/USD,$1.174268 +2025-12-17 16:28:11,EUR/USD,$1.175509 +2025-12-17 13:38:11,EUR/USD,$1.174201 +2025-12-17 13:06:11,EUR/USD,$1.175515 +2025-12-17 13:04:11,EUR/USD,$1.174212 +2025-12-17 10:22:11,EUR/USD,$1.175414 +2025-12-17 09:38:11,EUR/USD,$1.175569 +2025-12-17 09:08:11,EUR/USD,$1.174252 +2025-12-17 09:06:11,EUR/USD,$1.176210 +2025-12-17 08:54:11,EUR/USD,$1.174196 +2025-12-17 07:58:11,EUR/USD,$1.172965 +2025-12-17 07:54:11,EUR/USD,$1.174230 +2025-12-17 07:32:11,EUR/USD,$1.172441 +2025-12-17 07:28:11,EUR/USD,$1.174730 +2025-12-17 07:04:11,EUR/USD,$1.173122 +2025-12-17 06:48:11,EUR/USD,$1.171631 +2025-12-17 06:24:11,EUR/USD,$1.172953 +2025-12-17 05:44:11,EUR/USD,$1.171170 +2025-12-17 05:00:11,EUR/USD,$1.172395 +2025-12-17 04:56:11,EUR/USD,$1.174522 +2025-12-17 04:14:11,EUR/USD,$1.171886 +2025-12-17 04:08:11,EUR/USD,$1.173701 +2025-12-17 03:54:11,EUR/USD,$1.171955 +2025-12-17 03:50:11,EUR/USD,$1.173277 +2025-12-17 03:38:11,EUR/USD,$1.171742 +2025-12-17 03:36:11,EUR/USD,$1.173707 +2025-12-17 03:30:11,EUR/USD,$1.171620 +2025-12-17 03:24:12,EUR/USD,$1.173370 +2025-12-17 01:00:11,EUR/USD,$1.171189 +2025-12-17 00:52:11,EUR/USD,$1.172395 +2025-12-17 00:50:11,EUR/USD,$1.171144 +2025-12-16 23:34:11,EUR/USD,$1.172696 +2025-12-16 21:26:11,EUR/USD,$1.173931 +2025-12-16 21:22:11,EUR/USD,$1.175303 +2025-12-16 21:12:11,EUR/USD,$1.174119 +2025-12-16 20:58:11,EUR/USD,$1.175321 +2025-12-16 20:54:11,EUR/USD,$1.174125 +2025-12-16 20:46:11,EUR/USD,$1.175454 +2025-12-16 20:42:11,EUR/USD,$1.174092 +2025-12-16 20:38:11,EUR/USD,$1.175362 +2025-12-16 20:36:11,EUR/USD,$1.174138 +2025-12-16 20:18:11,EUR/USD,$1.175409 +2025-12-16 20:14:11,EUR/USD,$1.174195 +2025-12-16 13:44:11,EUR/USD,$1.175374 +2025-12-16 13:30:11,EUR/USD,$1.174196 +2025-12-16 12:18:11,EUR/USD,$1.175390 +2025-12-16 10:22:11,EUR/USD,$1.176693 +2025-12-16 10:00:11,EUR/USD,$1.176798 +2025-12-16 09:22:11,EUR/USD,$1.178042 +2025-12-16 09:00:11,EUR/USD,$1.179246 +2025-12-16 08:36:11,EUR/USD,$1.178057 +2025-12-16 08:00:11,EUR/USD,$1.176850 +2025-12-16 07:52:11,EUR/USD,$1.175662 +2025-12-16 07:46:11,EUR/USD,$1.176999 +2025-12-16 07:36:11,EUR/USD,$1.178401 +2025-12-16 06:58:11,EUR/USD,$1.177011 +2025-12-15 14:16:11,EUR/USD,$1.175497 +2025-12-15 12:34:11,EUR/USD,$1.173954 +2025-12-15 10:40:11,EUR/USD,$1.175231 +2025-12-15 10:22:11,EUR/USD,$1.176775 +2025-12-15 09:32:11,EUR/USD,$1.175918 +2025-12-15 09:02:11,EUR/USD,$1.177222 +2025-12-15 08:44:11,EUR/USD,$1.175752 +2025-12-15 08:04:11,EUR/USD,$1.174477 +2025-12-15 06:52:11,EUR/USD,$1.175850 +2025-12-15 03:58:11,EUR/USD,$1.174647 +2025-12-14 10:42:11,EUR/USD,$1.173035 +2025-12-14 10:40:11,EUR/USD,$1.174226 +2025-12-14 10:22:11,EUR/USD,$1.173011 +2025-12-13 10:22:11,EUR/USD,$1.173157 +2025-12-12 10:22:11,EUR/USD,$1.173292 +2025-12-12 08:48:11,EUR/USD,$1.173585 +2025-12-12 04:24:11,EUR/USD,$1.172377 +2025-12-11 14:02:11,EUR/USD,$1.173591 +2025-12-11 10:22:11,EUR/USD,$1.174841 +2025-12-11 09:36:11,EUR/USD,$1.174379 +2025-12-11 07:52:11,EUR/USD,$1.173200 +2025-12-11 06:46:11,EUR/USD,$1.171879 +2025-12-11 05:06:11,EUR/USD,$1.170632 +2025-12-11 01:40:11,EUR/USD,$1.169437 +2025-12-10 22:28:11,EUR/USD,$1.168047 +2025-12-10 17:10:12,EUR/USD,$1.169314 +2025-12-10 14:08:11,EUR/USD,$1.168080 +2025-12-10 13:58:11,EUR/USD,$1.166421 +2025-12-10 13:46:11,EUR/USD,$1.164848 +2025-12-10 13:12:11,EUR/USD,$1.166483 +2025-12-10 11:56:11,EUR/USD,$1.165227 +2025-12-10 10:22:11,EUR/USD,$1.163934 +2025-12-10 09:08:11,EUR/USD,$1.164401 +2025-12-10 04:04:11,EUR/USD,$1.163230 +2025-12-10 01:56:11,EUR/USD,$1.164450 +2025-12-09 10:22:11,EUR/USD,$1.163284 +2025-12-09 09:32:11,EUR/USD,$1.163060 +2025-12-09 09:10:11,EUR/USD,$1.161698 +2025-12-09 06:14:11,EUR/USD,$1.163377 +2025-12-09 04:16:11,EUR/USD,$1.164564 +2025-12-09 03:44:11,EUR/USD,$1.165848 +2025-12-09 03:30:11,EUR/USD,$1.164525 +2025-12-08 12:00:11,EUR/USD,$1.163351 +2025-12-08 10:22:11,EUR/USD,$1.161935 +2025-12-08 09:20:11,EUR/USD,$1.162134 +2025-12-08 08:56:11,EUR/USD,$1.163507 +2025-12-08 02:32:11,EUR/USD,$1.164936 +2025-12-08 00:02:11,EUR/USD,$1.166146 +2025-12-07 15:44:11,EUR/USD,$1.164341 +2025-12-07 10:22:11,EUR/USD,$1.163172 +2025-12-06 10:22:11,EUR/USD,$1.163346 +2025-12-06 00:44:11,EUR/USD,$1.162774 +2025-12-05 12:14:11,EUR/USD,$1.163960 +2025-12-05 10:22:11,EUR/USD,$1.162727 +2025-12-05 10:12:11,EUR/USD,$1.163658 +2025-12-05 03:28:11,EUR/USD,$1.164826 +2025-12-05 01:14:11,EUR/USD,$1.166055 +2025-12-04 14:24:11,EUR/USD,$1.164754 +2025-12-04 10:24:11,EUR/USD,$1.165995 +2025-12-04 09:48:18,EUR/USD,$11.669135B + diff --git a/hydradx/apps/stableswap/README.md b/hydradx/apps/stableswap/README.md new file mode 100644 index 000000000..4de861874 --- /dev/null +++ b/hydradx/apps/stableswap/README.md @@ -0,0 +1,19 @@ +# Stableswap Streamlit Apps + +## EUR/USD Arbitrage Smoothing + +This app loads EUR/USD prices from Binance and Kraken for a date range and +builds a smoothed series by choosing the value closest to the last accepted +price at each Binance timestamp. + +### Run + +```zsh +streamlit run hydradx/apps/stableswap/eur_usd_arbitrage_sim.py +``` + +### Notes + +- Data is cached per day under `hydradx/apps/stableswap/cached data/`. +- The app also caches loaded ranges in-memory via Streamlit. + diff --git a/hydradx/apps/stableswap/eur_usd.py b/hydradx/apps/stableswap/eur_usd.py new file mode 100644 index 000000000..dc3c698d2 --- /dev/null +++ b/hydradx/apps/stableswap/eur_usd.py @@ -0,0 +1,569 @@ +import streamlit as st +import pandas as pd +import numpy as np +import plotly.graph_objects as go +from pathlib import Path +from datetime import datetime, timedelta, timezone, date, time +import dateutil.parser + +# ============================================================================= +# PURE DATA FUNCTIONS +# ============================================================================= + +def get_kraken_prices( + start_date: str | datetime, + days: int | timedelta = 1, + interval: timedelta | None = None, + save_path: str | Path | None = None +) -> pd.DataFrame: + import requests + import time + + if isinstance(start_date, str): + start_date = dateutil.parser.parse(start_date) + + start_ms = int(start_date.timestamp()) * 1000 + end_ms = int((start_date + (days if isinstance(days, timedelta) else timedelta(days=days))).timestamp()) * 1000 + + start_str_formatted = start_date.astimezone(timezone.utc).strftime("%d %b, %Y %H:%M:%S") + print(f"Fetching Kraken data starting from: {start_str_formatted}") + + interval_ms = int(interval.total_seconds()) * 1000 if interval else None + current_interval = start_ms + kraken_rows = [] + since = int(start_date.timestamp() * 1e9) + + INITIAL_RETRY_DELAY = 2 + MAX_RETRY_DELAY = 60 + MAX_RETRIES = 8 + + while True: + retry_delay = INITIAL_RETRY_DELAY + for attempt in range(MAX_RETRIES): + resp = requests.get( + "https://api.kraken.com/0/public/Trades", + params={"pair": "EURUSD", "since": since}, + timeout=10 + ) + resp.raise_for_status() + data = resp.json() + + if not data.get("error"): + break + + if any("Too many requests" in e for e in data["error"]): + print(f"Rate limited, retrying in {retry_delay}s... (attempt {attempt + 1}/{MAX_RETRIES})") + time.sleep(retry_delay) + retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY) + else: + raise RuntimeError(f"Kraken API error: {data['error']}") + else: + raise RuntimeError(f"Kraken API rate limit not resolved after {MAX_RETRIES} retries") + + result = data["result"] + pair_key = next(k for k in result if k != "last") + trades = result[pair_key] + since = int(result["last"]) + + if not trades: + break + + done = False + for trade in trades: + ts_ms = int(float(trade[2]) * 1000) + + if ts_ms > end_ms: + done = True + break + if interval_ms: + if ts_ms < current_interval: + continue + current_interval += interval_ms + + kraken_rows.append({ + "timestamp_ms": ts_ms, + "readable_time": datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], + "pair": "EURUSD", + "price": float(trade[0]) + }) + + if done: + break + + last_ts_ms = int(float(trades[-1][2]) * 1000) + if last_ts_ms >= end_ms: + break + + df_kraken = pd.DataFrame(kraken_rows) + if save_path: + output_file = Path(save_path) + df_kraken.to_csv(output_file, index=False) + + return df_kraken + + +def get_binance_prices( + start_date: str or datetime, + days: int or timedelta = 1, + interval: timedelta or None = None, + save_path: str or Path or None = None +) -> pd.DataFrame: + from binance.client import Client + client = Client() + + if isinstance(start_date, str): + start_date = dateutil.parser.parse(start_date) + start_ms = int(start_date.timestamp()) * 1000 + end_ms = int((start_date + (days if isinstance(days, timedelta) else timedelta(days=days))).timestamp()) * 1000 + # convert to utc time + start_str_formatted = start_date.astimezone(timezone.utc).strftime("%d %b, %Y %H:%M:%S") + + print(f"Fetching Binance data starting from: {start_str_formatted}") + + agg_trades = client.aggregate_trade_iter( + symbol='EURUSDT', + start_str=start_str_formatted + ) + + interval_ms = int(interval.total_seconds()) * 1000 if interval else None + current_interval = start_ms + binance_rows = [] + for trade in agg_trades: + ts = int(trade['T']) + + # Only include data points that fall within the Kraken range + if ts >= start_ms: + if interval_ms: + # If an interval is specified, only include trades at the specified intervals + if ts < current_interval: + continue + current_interval += interval_ms + # Map to your specific headers: timestamp_ms, readable_time, pair, price + binance_rows.append({ + "timestamp_ms": ts, + "readable_time": datetime.fromtimestamp(ts / 1000, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S.%f')[ + :-3], + "pair": "EURUSD", # Standardizing to match your Kraken 'pair' column + "price": float(trade['p']) + }) + + if ts > end_ms: + break + + df_binance = pd.DataFrame(binance_rows) + if save_path: + output_file = Path(save_path) + df_binance.to_csv(output_file, index=False) + + return df_binance + + +def get_prices_for_day(exchange_name: str, day: date) -> pd.DataFrame: + cache_dir = Path(__file__).parent / 'price data' / exchange_name + cache_file = cache_dir / f'{day.strftime("%Y-%m-%d")}.csv' + + if cache_file.exists(): + print(f"Loading cached {exchange_name} data for {day}") + return pd.read_csv(cache_file) + + cache_dir.mkdir(parents=True, exist_ok=True) + start_dt = datetime.combine(day, time.min, tzinfo=timezone.utc) + + fetchers = { + 'binance': get_binance_prices, + 'kraken': get_kraken_prices, + } + if exchange_name not in fetchers: + raise ValueError(f"Unknown exchange '{exchange_name}'. Expected one of: {list(fetchers)}") + + return fetchers[exchange_name](start_date=start_dt, days=1, save_path=cache_file) + + +def detect_and_load(f, local_tz): + """ + Accepts a file path (str) or a file-like object. + Detects format from column headers and returns a normalized DataFrame + with just 'time' (UTC-aware) and 'price' columns. + """ + df = pd.read_csv(f) + cols = set(df.columns) + + if "timestamp_ms" in cols: + # Format A: timestamp_ms, readable_time, pair, price + df["time"] = pd.to_datetime(df["timestamp_ms"], unit="ms", utc=True) + df["price"] = pd.to_numeric(df["price"], errors="coerce") + + elif "timestamp" in cols: + # Format B: timestamp, pair, EUR/USD + price_col = next((c for c in df.columns if c not in ("timestamp", "pair")), None) + if price_col is None: + raise ValueError("Could not find price column in file.") + df["price"] = pd.to_numeric( + df[price_col].astype(str).str.replace("$", "", regex=False), + errors="coerce" + ) + df["time"] = ( + pd.to_datetime(df["timestamp"], errors="coerce") + .dt.tz_localize(local_tz) + .dt.tz_convert("UTC") + ) + + else: + raise ValueError( + f"Unrecognized columns: {list(df.columns)}. " + "Expected 'timestamp_ms' or 'timestamp' as the time column." + ) + + return df[["time", "price"]].dropna().sort_values("time") + + +def build_merged(df1, df2): + """ + Combine two (time, price) DataFrames so that every exact data point from + either source is paired with the most recent actual reading from the other + source (forward-fill, no interpolation). Only rows where both sources have + been seen at least once are kept, trimmed to the overlapping time window. + """ + a = df1[["time", "price"]].copy(); a["src"] = 1 + b = df2[["time", "price"]].copy(); b["src"] = 2 + combined = pd.concat([a, b]).sort_values("time").reset_index(drop=True) + + combined["price1"] = combined["price"].where(combined["src"] == 1).ffill() + combined["price2"] = combined["price"].where(combined["src"] == 2).ffill() + + overlap_start = max(df1["time"].min(), df2["time"].min()) + overlap_end = min(df1["time"].max(), df2["time"].max()) + + merged = ( + combined[["time", "price1", "price2"]] + .dropna() + .query("@overlap_start <= time <= @overlap_end") + .sort_values("time") + .reset_index(drop=True) + ) + return merged, overlap_start, overlap_end + + +def zero_crossing_segments(times, values): + """ + Split a series into continuous same-sign segments. + At each sign flip, insert an interpolated zero-crossing point so + both adjacent segments share that endpoint — no gaps, clean crossing. + Returns list of (times, values, is_positive) tuples. + """ + segments = [] + seg_t = [times[0]] + seg_v = [values[0]] + + for i in range(1, len(times)): + v_prev, v_curr = values[i - 1], values[i] + if v_prev * v_curr < 0: # sign change + t0 = pd.Timestamp(times[i - 1]).timestamp() + t1 = pd.Timestamp(times[i]).timestamp() + frac = v_prev / (v_prev - v_curr) # fraction of interval where line = 0 + t_zero = pd.Timestamp(t0 + frac * (t1 - t0), unit="s", tz="UTC") + seg_t.append(t_zero); seg_v.append(0.0) + segments.append((seg_t, seg_v, v_prev >= 0)) + seg_t = [t_zero, times[i]] + seg_v = [0.0, v_curr] + else: + seg_t.append(times[i]) + seg_v.append(v_curr) + + segments.append((seg_t, seg_v, seg_v[0] >= 0)) + return segments + + +def decimate_spread(times, values, n_buckets): + """ + Divide the time range into n_buckets equal-width buckets. For each bucket, + keep the timestamp/value of the max positive spread (if any) and the max + negative spread (if any). Results are sorted by time so the line draws + correctly. This caps rendered points at roughly 2 * n_buckets regardless + of how dense the underlying data is. + """ + if not times: + return [], [] + + t0 = pd.Timestamp(times[0]).timestamp() + t1 = pd.Timestamp(times[-1]).timestamp() + span = t1 - t0 + if span == 0: + return list(times), list(values) + + bucket_secs = span / n_buckets + + # For each bucket track the best positive and best negative point + pos_best = {} # bucket_idx -> (time, value) + neg_best = {} + + for t, v in zip(times, values): + idx = min(int((pd.Timestamp(t).timestamp() - t0) / bucket_secs), n_buckets - 1) + if v >= 0: + if idx not in pos_best or v > pos_best[idx][1]: + pos_best[idx] = (t, v) + else: + if idx not in neg_best or v < neg_best[idx][1]: + neg_best[idx] = (t, v) + + # Merge and sort by time + all_points = list(pos_best.values()) + list(neg_best.values()) + all_points.sort(key=lambda p: pd.Timestamp(p[0])) + + keep_t = [p[0] for p in all_points] + keep_v = [p[1] for p in all_points] + return keep_t, keep_v + +def run_comparison(file1_path, file2_path, local_tz="America/Chicago", n_buckets=400): + """ + Load, parse, merge, and compute spread for two EUR/USD CSV files. + Returns a dict with: + df1, df2 — normalized per-source DataFrames (time, price) + merged — combined DataFrame (time, price1, price2, spread_pct) + hover_t/hover_v — decimated time/value lists used for the spread graph + stats — dict of summary statistics + Raises ValueError with a descriptive message on any parsing failure. + """ + df1 = detect_and_load(file1_path, local_tz) + df2 = detect_and_load(file2_path, local_tz) + + merged, overlap_start, overlap_end = build_merged(df1, df2) + + if merged.empty: + raise ValueError( + f"No overlapping time range between files.\n" + f" File 1: {df1['time'].min()} -> {df1['time'].max()}\n" + f" File 2: {df2['time'].min()} -> {df2['time'].max()}\n" + f" Check the local_tz setting (currently '{local_tz}')." + ) + + merged["spread_pct"] = ((merged["price1"] - merged["price2"]) / merged["price2"] * 100).round(4) + + h_t, h_v = decimate_spread( + merged["time"].tolist(), + merged["spread_pct"].tolist(), + n_buckets, + ) + + max_pos_row = merged.loc[merged["spread_pct"].idxmax()] + max_neg_row = merged.loc[merged["spread_pct"].idxmin()] + + stats = { + "n_points": len(merged), + "n_hover_points": len(h_t), + "overlap_start": overlap_start, + "overlap_end": overlap_end, + "max_spread": max_pos_row["spread_pct"], + "max_spread_time": max_pos_row["time"], + "min_spread": max_neg_row["spread_pct"], + "min_spread_time": max_neg_row["time"], + "mean_spread": round(merged["spread_pct"].mean(), 4), + "std_spread": round(merged["spread_pct"].std(), 4), + } + + return { + "df1": df1, + "df2": df2, + "merged": merged, + "hover_t": h_t, + "hover_v": h_v, + "stats": stats, + } + +def _to_price_df(df: pd.DataFrame) -> pd.DataFrame: + df = df.copy() + df["timestamp_ms"] = pd.to_numeric(df["timestamp_ms"], errors="coerce") + df["price"] = pd.to_numeric(df["price"], errors="coerce") + df = df.dropna(subset=["timestamp_ms", "price"]).sort_values("timestamp_ms") + df["time"] = pd.to_datetime(df["timestamp_ms"], unit="ms", utc=True) + return df[["timestamp_ms", "time", "price"]] + + +def _interp_prices(target_ms: np.ndarray, source_ms: np.ndarray, source_prices: np.ndarray) -> np.ndarray: + if len(source_ms) == 0: + return np.full_like(target_ms, np.nan, dtype=float) + if len(source_ms) == 1: + return np.full_like(target_ms, float(source_prices[0]), dtype=float) + + interp = np.interp(target_ms, source_ms, source_prices).astype(float) + out_of_range = (target_ms < source_ms.min()) | (target_ms > source_ms.max()) + interp[out_of_range] = np.nan + return interp + + +def smooth_binance_with_kraken(binance_df: pd.DataFrame, kraken_df: pd.DataFrame) -> pd.DataFrame: + """ + For each Binance timestamp, interpolate the Kraken price and choose + whichever (Binance or interpolated Kraken) is closer to the last + chosen price. Returns a DataFrame keyed on Binance timestamps. + """ + binance = _to_price_df(binance_df) + kraken = _to_price_df(kraken_df) + + if binance.empty: + return pd.DataFrame(columns=[ + "timestamp_ms", "time", "binance_price", "kraken_price", "smoothed_price" + ]) + + target_ms = binance["timestamp_ms"].to_numpy(dtype=np.int64) + b_prices = binance["price"].to_numpy(dtype=float) + + k_times = kraken["timestamp_ms"].to_numpy(dtype=np.int64) + k_prices = kraken["price"].to_numpy(dtype=float) + + interp = _interp_prices(target_ms, k_times, k_prices) + + smoothed = [] + last_value = float(b_prices[0]) + for b_price, k_price in zip(b_prices, interp): + if np.isnan(k_price): + chosen = b_price + else: + if abs(b_price - last_value) <= abs(k_price - last_value): + chosen = b_price + else: + chosen = k_price + smoothed.append(chosen) + last_value = chosen + + out = pd.DataFrame({ + "timestamp_ms": binance["timestamp_ms"], + "time": binance["time"], + "binance_price": b_prices, + "kraken_price": interp, + "smoothed_price": np.array(smoothed, dtype=float), + }) + return out + + +# ============================================================================= +# IMPORTABLE DEBUG ENTRY POINT +# Usage: +# from price_compare import run_comparison +# result = run_comparison("file_a.csv", "file_b.csv", local_tz="America/Chicago") +# print(result["stats"]) +# print(result["merged"].head(20)) +# Also runnable directly: +# python price_compare.py file_a.csv file_b.csv [timezone] +# ============================================================================= + + +if __name__ == "__main__": + import sys, pprint + if len(sys.argv) >= 3: + tz = sys.argv[3] if len(sys.argv) > 3 else "America/Chicago" + result = run_comparison(sys.argv[1], sys.argv[2], local_tz=tz) + pprint.pprint(result["stats"]) + print() + print(result["merged"].head(20).to_string()) + else: + print("Usage: python price_compare.py [timezone]") + + +# ============================================================================= +# STREAMLIT UI (only runs when launched via `streamlit run price_compare.py`) +# ============================================================================= +if st.runtime.exists(): + st.set_page_config(page_title="EUR/USD Price Comparison", layout="wide") + st.title("EUR/USD Price Source Comparison") + + with st.sidebar: + st.header("Settings") + date_range = st.date_input( + "Date range", + value=(date.today() - timedelta(days=1), date.today() - timedelta(days=1)), + max_value=date.today() - timedelta(days=1), + ) + + if isinstance(date_range, (list, tuple)) and len(date_range) == 2: + start_day, end_day = date_range + else: + st.info("Select a start and end date to begin.") + st.stop() + + days_in_range = [start_day + timedelta(days=i) for i in range((end_day - start_day).days + 1)] + + if st.button("Fetch Data", type="primary", key="fetch_binance_kraken"): + st.session_state.pop("binance_df", None) + st.session_state.pop("kraken_df", None) + + with st.spinner("Fetching Binance data..."): + try: + binance_df = pd.concat( + [get_prices_for_day("binance", d) for d in days_in_range], + ignore_index=True + ) + st.session_state["binance_df"] = binance_df + except Exception as exc: + st.error(f"Failed to fetch Binance data: {exc}") + st.stop() + + with st.spinner("Fetching Kraken data..."): + try: + kraken_df = pd.concat( + [get_prices_for_day("kraken", d) for d in days_in_range], + ignore_index=True + ) + st.session_state["kraken_df"] = kraken_df + except Exception as exc: + st.error(f"Failed to fetch Kraken data: {exc}") + st.stop() + + if "binance_df" not in st.session_state or "kraken_df" not in st.session_state: + st.info("Select a date range, then click **Fetch Data**.") + st.stop() + + smoothed_df = smooth_binance_with_kraken( + st.session_state["binance_df"], + st.session_state["kraken_df"], + ) + + if smoothed_df.empty: + st.warning("No data available for the selected range.") + st.stop() + + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=smoothed_df["time"], y=smoothed_df["binance_price"], + name="Binance", + line=dict(color="#4C9BE8", width=1.2, shape="hv"), + hovertemplate="%{x|%Y-%m-%d %H:%M:%S}
Price: %{y:.6f}Binance", + ) + ) + fig.add_trace( + go.Scatter( + x=smoothed_df["time"], y=smoothed_df["kraken_price"], + name="Kraken (interp)", + line=dict(color="#F4A83A", width=1.2, dash="dot", shape="hv"), + hovertemplate="%{x|%Y-%m-%d %H:%M:%S}
Price: %{y:.6f}Kraken", + ) + ) + fig.add_trace( + go.Scatter( + x=smoothed_df["time"], y=smoothed_df["smoothed_price"], + name="Combined", + line=dict(color="#2ECC71", width=1.6), + hovertemplate="%{x|%Y-%m-%d %H:%M:%S}
Price: %{y:.6f}Combined", + ) + ) + + fig.update_layout( + height=650, + template="plotly_dark", + hovermode="x unified", + legend=dict(orientation="h", y=1.05, x=0), + margin=dict(t=60, b=40, l=60, r=20), + ) + fig.update_yaxes(title_text="EUR/USD", tickformat=".5f") + fig.update_xaxes(title_text="Time (UTC)") + + st.plotly_chart(fig, use_container_width=True) + + with st.expander("View combined data table"): + display = smoothed_df[["time", "binance_price", "kraken_price", "smoothed_price"]].copy() + display["time"] = display["time"].dt.strftime("%Y-%m-%d %H:%M:%S UTC") + for col in ["binance_price", "kraken_price", "smoothed_price"]: + display[col] = display[col].map(lambda v: f"{v:.6f}" if pd.notna(v) else "") + display.columns = ["Time (UTC)", "Binance", "Kraken (interp)", "Combined"] + st.dataframe(display, use_container_width=True, hide_index=True) diff --git a/hydradx/apps/stableswap/eur_usd_arbitrage_sim.py b/hydradx/apps/stableswap/eur_usd_arbitrage_sim.py new file mode 100644 index 000000000..5b2bd9c5f --- /dev/null +++ b/hydradx/apps/stableswap/eur_usd_arbitrage_sim.py @@ -0,0 +1,1049 @@ +import os + +import streamlit as st +import pandas as pd +import numpy as np +import plotly.graph_objects as go +import dateutil +from datetime import date, datetime, timedelta, timezone, time +from pathlib import Path + +import sys + +from sympy import false, true + +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +sys.path.append(project_root) + +from hydradx.model.amm.stableswap_amm import StableSwapPoolState +from hydradx.model.amm.fixed_price import FixedPriceExchange +from hydradx.model.amm.agents import Agent +from hydradx.apps.s3_utils import get_s3_client, download_from_s3, upload_to_s3 + + +# ============================================================================= +# PURE DATA FUNCTIONS +# ============================================================================= + +def get_kraken_prices( + start_date: str | datetime, + days: int | timedelta = 1, + interval: timedelta | None = None, + save_path: str | Path | None = None +) -> pd.DataFrame: + import requests + import time + + if isinstance(start_date, str): + start_date = dateutil.parser.parse(start_date) + + start_ms = int(start_date.timestamp()) * 1000 + end_ms = int((start_date + (days if isinstance(days, timedelta) else timedelta(days=days))).timestamp()) * 1000 + + start_str_formatted = start_date.astimezone(timezone.utc).strftime("%d %b, %Y %H:%M:%S") + print(f"Fetching Kraken data starting from: {start_str_formatted}") + + interval_ms = int(interval.total_seconds()) * 1000 if interval else None + current_interval = start_ms + kraken_rows = [] + since = int(start_date.timestamp() * 1e9) + + INITIAL_RETRY_DELAY = 2 + MAX_RETRY_DELAY = 60 + MAX_RETRIES = 8 + + while True: + retry_delay = INITIAL_RETRY_DELAY + for attempt in range(MAX_RETRIES): + resp = requests.get( + "https://api.kraken.com/0/public/Trades", + params={"pair": "EURUSD", "since": since}, + timeout=10 + ) + resp.raise_for_status() + data = resp.json() + + if not data.get("error"): + break + + if any("Too many requests" in e for e in data["error"]): + print(f"Rate limited, retrying in {retry_delay}s... (attempt {attempt + 1}/{MAX_RETRIES})") + time.sleep(retry_delay) + retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY) + else: + raise RuntimeError(f"Kraken API error: {data['error']}") + else: + raise RuntimeError(f"Kraken API rate limit not resolved after {MAX_RETRIES} retries") + + result = data["result"] + pair_key = next(k for k in result if k != "last") + trades = result[pair_key] + since = int(result["last"]) + + if not trades: + break + + done = False + for trade in trades: + ts_ms = int(float(trade[2]) * 1000) + + if ts_ms > end_ms: + done = True + break + if interval_ms: + if ts_ms < current_interval: + continue + current_interval += interval_ms + + kraken_rows.append({ + "timestamp_ms": ts_ms, + "readable_time": datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], + "pair": "EURUSD", + "price": float(trade[0]) + }) + + if done: + break + + last_ts_ms = int(float(trades[-1][2]) * 1000) + if last_ts_ms >= end_ms: + break + + df_kraken = pd.DataFrame(kraken_rows) + if save_path: + output_file = Path(save_path) + df_kraken.to_csv(output_file, index=False) + + return df_kraken + + +def get_binance_prices( + start_date: str or datetime, + days: int or timedelta = 1, + interval: timedelta or None = None, + save_path: str or Path or None = None +) -> pd.DataFrame: + from binance.client import Client + client = Client() + + if isinstance(start_date, str): + start_date = dateutil.parser.parse(start_date) + start_ms = int(start_date.timestamp()) * 1000 + end_ms = int((start_date + (days if isinstance(days, timedelta) else timedelta(days=days))).timestamp()) * 1000 + start_str_formatted = start_date.astimezone(timezone.utc).strftime("%d %b, %Y %H:%M:%S") + + print(f"Fetching Binance data starting from: {start_str_formatted}") + + agg_trades = client.aggregate_trade_iter( + symbol='EURUSDT', + start_str=start_str_formatted + ) + + interval_ms = int(interval.total_seconds()) * 1000 if interval else None + current_interval = start_ms + binance_rows = [] + for trade in agg_trades: + ts = int(trade['T']) + + if ts >= start_ms: + if interval_ms: + if ts < current_interval: + continue + current_interval += interval_ms + binance_rows.append({ + "timestamp_ms": ts, + "readable_time": datetime.fromtimestamp(ts / 1000, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S.%f')[ + :-3], + "pair": "EURUSD", + "price": float(trade['p']) + }) + + if ts > end_ms: + break + + df_binance = pd.DataFrame(binance_rows) + if save_path: + output_file = Path(save_path) + df_binance.to_csv(output_file, index=False) + + return df_binance + + +def get_prices_for_day(exchange_name: str, day: date) -> pd.DataFrame: + """ + Load price data for one exchange/day using a three-tier cache: + 1. Local disk — instant, survives app restarts on the same machine + 2. S3 / R2 — fast, shared across all deployed instances + 3. Exchange API — source of truth, result is written back to both caches + """ + cache_dir = Path(__file__).parent / 'cached data' / exchange_name + cache_file = cache_dir / f'{day.strftime("%Y-%m-%d")}.csv' + + # ── Tier 1: local disk ─────────────────────────────────────────────────── + if cache_file.exists(): + print(f"[local] Loading cached {exchange_name} data for {day}") + return pd.read_csv(cache_file) + + # ── Tier 2: cloud (S3 / R2 / MinIO) ───────────────────────────────────── + cloud_df = download_from_s3(exchange_name, day) + if cloud_df is not None: + # Populate local disk so the next hit is even faster + cache_dir.mkdir(parents=True, exist_ok=True) + cloud_df.to_csv(cache_file, index=False) + return cloud_df + + # ── Tier 3: exchange API ───────────────────────────────────────────────── + cache_dir.mkdir(parents=True, exist_ok=True) + start_dt = datetime.combine(day, time.min, tzinfo=timezone.utc) + + fetchers = { + 'binance': get_binance_prices, + 'kraken': get_kraken_prices, + } + if exchange_name not in fetchers: + raise ValueError(f"Unknown exchange '{exchange_name}'. Expected one of: {list(fetchers)}") + + df = fetchers[exchange_name](start_date=start_dt, days=1, save_path=cache_file) + + # Write to cloud so other instances (or future deployments) skip the API call + upload_to_s3(df, exchange_name, day) + + return df + + +def _load_prices(exchange: str, day_keys: tuple[str, ...]) -> pd.DataFrame: + days = [date.fromisoformat(k) for k in day_keys] + frames = [get_prices_for_day(exchange, d) for d in days] + if not frames: + return pd.DataFrame() + return pd.concat(frames, ignore_index=True) + + +@st.cache_data(show_spinner=False) +def load_prices_cached(exchange: str, day_keys: tuple[str, ...]) -> pd.DataFrame: + return _load_prices(exchange, day_keys) + + +@st.cache_data(show_spinner=False) +def load_dia_cached() -> pd.DataFrame: + dia_path = Path(__file__).parent / "DIA" / "DIA_data.csv" + if not dia_path.exists(): + return pd.DataFrame(columns=["timestamp_ms", "time", "price"]) + + df = pd.read_csv(dia_path, encoding="utf-8-sig") + df.columns = df.columns.str.replace("\ufeff", "", regex=False).str.strip() + if "timestamp" not in df.columns or "EUR/USD" not in df.columns: + return pd.DataFrame(columns=["timestamp_ms", "time", "price"]) + + times = pd.to_datetime(df["timestamp"], errors="coerce") + if times.dt.tz is None: + times = times.dt.tz_localize("US/Central", ambiguous="NaT", nonexistent="NaT") + else: + times = times.dt.tz_convert("US/Central") + times = times.dt.tz_convert("UTC") + + prices = pd.to_numeric(df["EUR/USD"].astype(str).str.replace("$", "", regex=False), errors="coerce") + out = pd.DataFrame({"time": times, "price": prices}).dropna().sort_values("time") + out["timestamp_ms"] = (out["time"].astype("int64") // 1_000_000).astype("int64") + return out[["timestamp_ms", "time", "price"]] + + +def _to_price_df(df: pd.DataFrame) -> pd.DataFrame: + df = df.copy() + df["timestamp_ms"] = pd.to_numeric(df["timestamp_ms"], errors="coerce") + df["price"] = pd.to_numeric(df["price"], errors="coerce") + df = df.dropna(subset=["timestamp_ms", "price"]).sort_values("timestamp_ms") + df["time"] = pd.to_datetime(df["timestamp_ms"], unit="ms", utc=True) + return df[["timestamp_ms", "time", "price"]] + + +def _interp_prices(target_ms: np.ndarray, source_ms: np.ndarray, source_prices: np.ndarray) -> np.ndarray: + if len(source_ms) == 0: + return np.full_like(target_ms, np.nan, dtype=float) + if len(source_ms) == 1: + return np.full_like(target_ms, float(source_prices[0]), dtype=float) + + interp = np.interp(target_ms, source_ms, source_prices).astype(float) + out_of_range = (target_ms < source_ms.min()) | (target_ms > source_ms.max()) + interp[out_of_range] = np.nan + return interp + + +def _step_prices(target_ms: np.ndarray, source_ms: np.ndarray, source_prices: np.ndarray) -> np.ndarray: + if len(source_ms) == 0: + return np.full_like(target_ms, np.nan, dtype=float) + if len(source_ms) == 1: + return np.full_like(target_ms, float(source_prices[0]), dtype=float) + + order = np.argsort(source_ms) + src_ms = source_ms[order] + src_prices = source_prices[order] + + idx = np.searchsorted(src_ms, target_ms, side="right") - 1 + out = np.full_like(target_ms, np.nan, dtype=float) + valid = (idx >= 0) & (idx < len(src_prices)) + out[valid] = src_prices[idx[valid]] + return out + + +def build_simulation_points( + combined_df: pd.DataFrame, + dia_df: pd.DataFrame, + step_seconds: int = 6, +) -> list[dict]: + if combined_df.empty: + return [] + + combined = combined_df[["time", "external_price"]].dropna().copy() + combined["timestamp_ms"] = (combined["time"].astype("int64") // 1_000_000).astype("int64") + + if dia_df.empty: + start_dt = combined["time"].min() + end_dt = combined["time"].max() + else: + start_dt = max(combined["time"].min(), dia_df["time"].min()) + end_dt = min(combined["time"].max(), dia_df["time"].max()) + + if pd.isna(start_dt) or pd.isna(end_dt) or start_dt >= end_dt: + return [] + + grid = pd.date_range(start=start_dt, end=end_dt, freq=f"{step_seconds}S", tz="UTC") + grid_ms = (grid.astype("int64") // 1_000_000).astype("int64") + + combined_interp = _interp_prices( + grid_ms, + combined["timestamp_ms"].to_numpy(dtype=np.int64), + combined["external_price"].to_numpy(dtype=float), + ) + + if dia_df.empty: + dia_interp = np.full_like(combined_interp, np.nan, dtype=float) + else: + dia_interpolated = false + if dia_interpolated: + dia_interp = _interp_prices( + grid_ms, + dia_df["timestamp_ms"].to_numpy(dtype=np.int64), + dia_df["price"].to_numpy(dtype=float), + ) + else: + dia_interp = _step_prices( + grid_ms, + dia_df["timestamp_ms"].to_numpy(dtype=np.int64), + dia_df["price"].to_numpy(dtype=float), + ) + + return [ + { + "time": t, + "timestamp_ms": int(ms), + "external_price": float(sp), + "dia_price": float(dp) if not np.isnan(dp) else np.nan, + } + for t, ms, sp, dp in zip(grid, grid_ms, combined_interp, dia_interp) + ] + + +def run_sim( + steps: list[dict[str, float]], + trade_fee: float=0.0, + amplification: float=100.0, + pool_depth: float=2_000_000.0, + return_series: bool=False, +) -> dict[datetime, float] | tuple[dict[datetime, float], pd.DataFrame]: + dia_start_peg = steps[0]["dia_price"] + eur_usd_stablepool = StableSwapPoolState( + tokens={"USD": pool_depth / 2, "EUR": pool_depth / 2 / dia_start_peg}, + amplification=amplification, + trade_fee=trade_fee, + peg=dia_start_peg, + spot_price_precision=0.00000000001, + precision=1e-12, + max_peg_update=0.0001 + ) + binance = FixedPriceExchange( + tokens={"EUR": steps[0]["external_price"], "USD": 1.0}, + ) + profit_over_time = {} + series_times: list[datetime] = [] + series_external: list[float] = [] + series_dia: list[float] = [] + series_stableswap: list[float] = [] + series_peg: list[float] = [] + series_peg_target: list[float] = [] + last_hour_logged: datetime | None = None + arbitrageur = Agent() + trader = Agent() + for i, step in enumerate(steps): + next_peg = step["dia_price"] + eur_usd_stablepool.set_peg_target(next_peg) + eur_usd_stablepool.update() + binance.prices = {"EUR": step["external_price"], "USD": 1.0} + + # do one random $1 trade to update the price and trigger peg adjustments if needed + buy_or_sell = "buy" if step["external_price"] > step["dia_price"] else "sell" + eur_usd_stablepool.swap( + agent=trader, + tkn_sell="USD" if buy_or_sell == "buy" else "EUR", + tkn_buy="EUR" if buy_or_sell == "buy" else "USD", + sell_quantity=0.00001 + ) + + stableswap_price_before = float(eur_usd_stablepool.price("EUR", "USD")) + hour_key = step["time"].replace(minute=0, second=0, microsecond=0) + if last_hour_logged is None or hour_key > last_hour_logged: + last_hour_logged = hour_key + print( + f"[hourly] {hour_key.isoformat()} " + f"ext={step['external_price']:.6f} " + f"dia={step['dia_price']:.6f} " + f"stableswap={stableswap_price_before:.6f}" + ) + + max_arb_size = 2 ** 10 + arb_size = max_arb_size / 2 + max_iterations = 10 + spread = abs(1 - step['external_price'] / step['dia_price']) + if spread >= eur_usd_stablepool.trade_fee: + buy_or_sell = "buy" if step["external_price"] > eur_usd_stablepool.price("EUR", "USD") else "sell" + best = None + for j in range(2, max_iterations + 2): + delta = max_arb_size / (2 ** j) + try_arbs = [arb_size - delta, arb_size + delta] + trade_opts = [ + { + "usd": -try_arb if buy_or_sell == "buy" else try_arb, + "eur": eur_usd_stablepool.calculate_buy_from_sell( + tkn_buy="EUR", tkn_sell="USD", sell_quantity=try_arb + ) if buy_or_sell == "buy" else -eur_usd_stablepool.calculate_sell_from_buy( + tkn_sell="EUR", tkn_buy="USD", buy_quantity=try_arb + )} for try_arb in try_arbs + ] + if best: + trade_opts.append(best) + results = [ + { + "usd": trade["usd"], + "eur": trade["eur"], + "profit": step["external_price"] * trade["eur"] + trade["usd"] + } for trade in trade_opts + ] + best = max(results, key=lambda item: item["profit"]) + arb_size = abs(best['usd']) + if best["profit"] > 0.1: + start_usd = arbitrageur.get_holdings('USD') + if buy_or_sell == "buy": + eur_usd_stablepool.swap( + agent=arbitrageur, + tkn_sell="USD", + tkn_buy="EUR", + sell_quantity=arb_size + ) + if abs(arbitrageur.get_holdings('EUR') - best['eur']) > 0.00001: + print("arbitrage calculation is off") + binance.swap( + arbitrageur, tkn_sell='EUR', tkn_buy='USD', sell_quantity=arbitrageur.holdings['EUR'] + ) + else: + eur_usd_stablepool.swap( + agent=arbitrageur, + tkn_sell="EUR", + tkn_buy="USD", + buy_quantity=arb_size + ) + if (abs(arbitrageur.get_holdings('EUR') - best['eur']) > 0.00001): + print("arbitrage calculation is off") + binance.swap( + arbitrageur, tkn_buy='EUR', tkn_sell='USD', buy_quantity=-arbitrageur.holdings['EUR'] + ) + profit = arbitrageur.get_holdings('USD') - start_usd + profit_over_time[datetime.fromtimestamp(step["timestamp_ms"] / 1000, tz=timezone.utc)] = profit + stableswap_price_after = float(eur_usd_stablepool.price("EUR", "USD")) + print( + f"[arb] {step['time'].isoformat()} " + f"ext={step['external_price']:.6f} " + f"dia={step['dia_price']:.6f} " + f"stableswap_before={stableswap_price_before:.6f} " + f"arb_usd={arb_size:.6f} " + f"stableswap_after={stableswap_price_after:.6f}" + ) + if profit < 0: + raise ValueError("arbitrage messed up") + + series_times.append(step["time"]) + series_external.append(float(step["external_price"])) + series_dia.append(float(step["dia_price"])) + series_stableswap.append(float(eur_usd_stablepool.price("EUR", "USD"))) + series_peg.append(float(eur_usd_stablepool.peg[1])) + series_peg_target.append(float(eur_usd_stablepool.peg_target[1])) + + if return_series: + series_df = pd.DataFrame({ + "time": series_times, + "external_price": series_external, + "dia_price": series_dia, + "stableswap_price": series_stableswap, + "stableswap_peg": series_peg, + "stableswap_peg_target": series_peg_target, + }) + return profit_over_time, series_df + return profit_over_time + + +def smooth_binance_with_kraken( + binance_df: pd.DataFrame, + kraken_df: pd.DataFrame, + binance_bias_factor: float = 3.0, +) -> pd.DataFrame: + binance = _to_price_df(binance_df) + kraken = _to_price_df(kraken_df) + + if binance.empty: + return pd.DataFrame(columns=[ + "timestamp_ms", "time", "binance_price", "kraken_price", "external_price" + ]) + + target_ms = binance["timestamp_ms"].to_numpy(dtype=np.int64) + b_prices = binance["price"].to_numpy(dtype=float) + + k_times = kraken["timestamp_ms"].to_numpy(dtype=np.int64) + k_prices = kraken["price"].to_numpy(dtype=float) + + k_interp = _interp_prices(target_ms, k_times, k_prices) + + combined = [] + last_value = float(b_prices[0]) + denom = max(abs(binance_bias_factor), 1e-6) + for b_price, k_price in zip(b_prices, k_interp): + if np.isnan(k_price): + chosen = b_price + else: + b_diff = abs(b_price - last_value) / denom + k_diff = abs(k_price - last_value) + if b_diff <= k_diff: + chosen = b_price + else: + chosen = k_price + combined.append(chosen) + last_value = chosen + + out = pd.DataFrame({ + "timestamp_ms": binance["timestamp_ms"], + "time": binance["time"], + "binance_price": b_prices, + "kraken_price": k_interp, + "external_price": np.array(combined, dtype=float), + }) + return out + + +def _downsample_for_plot(df: pd.DataFrame, max_points: int) -> pd.DataFrame: + if df.empty or len(df) <= max_points: + return df + step = int(np.ceil(len(df) / max_points)) + return df.iloc[::step].copy() + + +def _pad_series_to_range(df: pd.DataFrame, start: pd.Timestamp, end: pd.Timestamp) -> pd.DataFrame: + if df.empty: + return df + series = df.sort_values("time").copy() + series["time"] = pd.to_datetime(series["time"], utc=True) + start = pd.Timestamp(start) + end = pd.Timestamp(end) + if start.tzinfo is None: + start = start.tz_localize("UTC") + else: + start = start.tz_convert("UTC") + if end.tzinfo is None: + end = end.tz_localize("UTC") + else: + end = end.tz_convert("UTC") + + in_range = series[(series["time"] >= start) & (series["time"] <= end)].copy() + before_start = series[series["time"] < start].tail(1) + after_end = series[series["time"] > end].head(1) + + pieces = [] + if not before_start.empty: + start_row = before_start.copy() + start_row["time"] = start + start_row["timestamp_ms"] = int(start.value // 1_000_000) + pieces.append(start_row) + + if not in_range.empty: + pieces.append(in_range) + + if not after_end.empty: + end_row = after_end.copy() + end_row["time"] = end + end_row["timestamp_ms"] = int(end.value // 1_000_000) + pieces.append(end_row) + + if not pieces: + return in_range + + window = pd.concat(pieces, ignore_index=True) + return window.drop_duplicates(subset=["time"]).sort_values("time") + + +# ============================================================================= +# STREAMLIT FRAGMENT +# ============================================================================= +@st.fragment +def simulation_section() -> None: + combined_df = st.session_state.get("combined_df", pd.DataFrame()) + dia_df = st.session_state.get("dia_df", pd.DataFrame()) + + if combined_df.empty or dia_df.empty: + st.info("Configure the parameters in the sidebar, then click **Run simulation**.") + return + + # Sim controls live in the main body, not the sidebar + col_a, col_b = st.columns(2) + pool_amplification = col_a.slider( + "StableSwap amplification factor", + min_value=10, max_value=500, + value=st.session_state.get("pool_amplification", 100), + step=10, key="pool_amplification", + ) + trade_fee = col_b.slider( + "StableSwap trade fee", + min_value=0.0, max_value=0.001, + value=st.session_state.get("trade_fee", 0.0005), + step=0.0001, key="trade_fee", format="%0.4f" + ) + pool_depth = col_a.slider( + "StableSwap total liquidity (USD)", + min_value=100_000, max_value=10_000_000, + value=st.session_state.get("pool_depth", 2_000_000), + step=100_000, key="pool_depth" + ) + + # Small preview panel that refreshes only when StableSwap settings change. + preview_box = col_b.container() + preview_box.markdown( + """ + + """, + unsafe_allow_html=True, + ) + preview_row = preview_box.columns([2, 1, 2, 2]) + preview_row[0].markdown("Price of a") + usd_trade_size_raw = preview_row[1].text_input( + "USD trade size", + value=st.session_state.get("preview_usd_trade_size", "10000"), + key="preview_usd_trade_size", + label_visibility="collapsed", + ) + preview_row[2].markdown("USD trade:") + preview_value_slot = preview_row[3].empty() + try: + usd_trade_size = float(usd_trade_size_raw) + except (TypeError, ValueError): + usd_trade_size = None + + preview_key = ( + pool_amplification, + trade_fee, + st.session_state.get("pool_depth", 2_000_000), + usd_trade_size, + ) + preview_data_key = st.session_state.get("day_keys") + preview_needs_update = ( + st.session_state.get("preview_key") != preview_key + or st.session_state.get("preview_data_key") != preview_data_key + or st.session_state.get("preview_result") is None + ) + + if preview_needs_update and usd_trade_size is not None and not combined_df.empty and not dia_df.empty: + with preview_box: + with st.spinner("Updating preview..."): + combined_small = combined_df[["time", "external_price"]].dropna().sort_values("time") + dia_small = dia_df[["time", "price"]].dropna().sort_values("time") + + preview_start = max(combined_small["time"].min(), dia_small["time"].min()) + dia_overlap = dia_small[dia_small["time"] >= preview_start] + if dia_overlap.empty: + preview_payload = None + else: + dia_price = float(dia_overlap["price"].iloc[0]) + eur_usd_stableswap = StableSwapPoolState( + tokens={"USD": pool_depth / 2, "EUR": pool_depth / 2 / dia_price}, + amplification=pool_amplification, + trade_fee=trade_fee, + peg=dia_price, + spot_price_precision=0.00000000001, + precision=1e-12, + max_peg_update=0.0001, + ) + pool_after = eur_usd_stableswap.copy() + pool_after.swap( + agent=Agent(), + tkn_buy="EUR", + tkn_sell="USD", + sell_quantity=usd_trade_size, + ) + eur_received = ( + eur_usd_stableswap.liquidity["EUR"] + - pool_after.liquidity["EUR"] + ) + trade_value = eur_received * dia_price + cost = usd_trade_size - trade_value + preview_payload = { + "cost": float(cost), + "dia_price": dia_price, + "trade_size": usd_trade_size, + } + + st.session_state["preview_key"] = preview_key + st.session_state["preview_data_key"] = preview_data_key + st.session_state["preview_result"] = preview_payload + + preview_payload = st.session_state.get("preview_result") if usd_trade_size is not None else None + if preview_payload is None: + preview_value_slot.markdown("—") + else: + preview_value_slot.markdown(f"${preview_payload['cost']:,.6f} ({(preview_payload['cost'] / preview_payload['trade_size']):,.4f}%)") + + run_sim_clicked = st.button("Run simulation", type="primary") + + # Invalidate results when sim params change + sim_param_key = (pool_amplification, trade_fee) + if st.session_state.get("sim_param_key") != sim_param_key: + st.session_state["sim_param_key"] = sim_param_key + st.session_state["simulation_ran"] = False + st.session_state["simulation_results"] = None + + if run_sim_clicked: + simulation_points = build_simulation_points(combined_df, dia_df, step_seconds=6) + if not simulation_points: + st.warning("Could not build simulation points — check that price and DIA data overlap in time.") + else: + st.session_state["simulation_start_time"] = simulation_points[0]["time"] + st.session_state["simulation_end_time"] = simulation_points[-1]["time"] + with st.spinner("Running simulation..."): + sim_results, sim_series = run_sim( + steps=simulation_points, + trade_fee=trade_fee, + amplification=pool_amplification, + pool_depth=pool_depth, + return_series=True, + ) + st.session_state["simulation_results"] = sim_results + st.session_state["simulation_series"] = sim_series + st.session_state["simulation_ran"] = True + + if not st.session_state.get("simulation_ran"): + st.info("Configure the parameters in the sidebar, then click **Run simulation**.") + else: + sim_results = st.session_state.get("simulation_results") or {} + sim_start = st.session_state.get("simulation_start_time") + sim_end = st.session_state.get("simulation_end_time") + if not sim_results: + if sim_start is None or sim_end is None: + st.info("Simulation completed, but no profitable arbitrage events were found.") + return + profit_df = pd.DataFrame({"time": [sim_start, sim_end], "profit": [0.0, 0.0]}) + else: + profit_df = ( + pd.DataFrame( + {"time": list(sim_results.keys()), "profit": list(sim_results.values())} + ) + .sort_values("time") + ) + if sim_start is not None and (profit_df.empty or sim_start < profit_df["time"].iloc[0]): + start_sentinel = pd.DataFrame({"time": [sim_start], "profit": [0.0]}) + profit_df = pd.concat([start_sentinel, profit_df], ignore_index=True) + if sim_end is not None and (profit_df.empty or sim_end > profit_df["time"].iloc[-1]): + end_sentinel = pd.DataFrame({"time": [sim_end], "profit": [0.0]}) + profit_df = pd.concat([profit_df, end_sentinel], ignore_index=True) + + profit_df["cumulative_profit"] = profit_df["profit"].cumsum() + + total = profit_df["cumulative_profit"].iloc[-1] + n_events = len(sim_results) + col1, col2 = st.columns(2) + col1.metric("Total arbitrage profit (USD)", f"${total:,.4f}") + col2.metric("Arbitrage events", f"{n_events:,}") + + profit_fig = go.Figure() + profit_fig.add_trace( + go.Scatter( + x=profit_df["time"], + y=profit_df["cumulative_profit"], + name="Arbitrageur profit (cumulative)", + line=dict(color="#00B3FF", width=2.0), + hovertemplate="%{x|%Y-%m-%d %H:%M:%S}
Total Profit: %{y:.4f}", + ) + ) + profit_fig.update_layout( + height=500, template="plotly_dark", hovermode="x unified", + margin=dict(t=40, b=40, l=60, r=20), + ) + profit_fig.update_yaxes(title_text="USD Profit (Cumulative)", tickformat=".2f") + profit_fig.update_xaxes(title_text="Time (UTC)") + st.plotly_chart(profit_fig, use_container_width=True) + + series_df = st.session_state.get("simulation_series") + if isinstance(series_df, pd.DataFrame) and not series_df.empty: + st.subheader("Spread between price sources") + series_map = { + "DIA oracle price": "dia_price", + "Binance/combined price": "external_price", + "StableSwap price": "stableswap_price", + "StableSwap peg": "stableswap_peg", + "StableSwap peg target": "stableswap_peg_target", + } + left_col, right_col = st.columns(2) + left_label = left_col.selectbox( + "Series A", + list(series_map.keys()), + index=0, + key="spread_series_a", + ) + right_options = [k for k in series_map.keys() if k != left_label] + right_label = right_col.selectbox( + "Series B", + right_options, + index=0, + key="spread_series_b", + ) + + spread_df = series_df[["time", series_map[left_label], series_map[right_label]]].dropna() + spread_df["spread"] = spread_df[series_map[left_label]] - spread_df[series_map[right_label]] + + spread_fig = go.Figure() + spread_fig.add_trace( + go.Scatter( + x=spread_df["time"], + y=spread_df["spread"], + name="Spread", + line=dict(color="#FF8C00", width=1.8), + hovertemplate="%{x|%Y-%m-%d %H:%M:%S}
Spread: %{y:.6f}", + ) + ) + spread_fig.update_layout( + height=420, template="plotly_dark", hovermode="x unified", + margin=dict(t=40, b=40, l=60, r=20), + ) + spread_fig.update_yaxes(title_text="Price Spread", tickformat=".6f") + spread_fig.update_xaxes(title_text="Time (UTC)") + st.plotly_chart(spread_fig, use_container_width=True) + else: + st.info("Run the simulation to view the spread chart.") + +# ============================================================================= +# STREAMLIT UI +# ============================================================================= +if st.runtime.exists(): + st.set_page_config(page_title="EUR/USD Arbitrage Simulation", layout="wide") + st.title("EUR/USD Arbitrage Simulation") + + # Show cloud storage status unobtrusively in the sidebar + _client, _cfg = get_s3_client() + sidebar = st.sidebar.container() + if _cfg: + sidebar.success(f"☁️ Cloud cache: `{_cfg['bucket']}/{_cfg['prefix']}`", icon=None) + else: + sidebar.info("☁️ Cloud cache: not configured (local only)", icon=None) + + sidebar.header("Settings") + + date_range = sidebar.date_input( + "Date range", + value=(date.today() - timedelta(days=1), date.today() - timedelta(days=1)), + max_value=date.today() - timedelta(days=1), + key="date_range", + ) + + if not (isinstance(date_range, (list, tuple)) and len(date_range) == 2): + st.info("Select a start and end date to begin.") + st.stop() + + start_day, end_day = date_range + range_key = (start_day.isoformat(), end_day.isoformat()) + + if st.session_state.get("selected_range_key") != range_key: + st.session_state["selected_range_key"] = range_key + st.session_state["data_ready"] = False + st.session_state["simulation_ran"] = False + st.session_state["simulation_results"] = None + st.session_state["binance_df"] = pd.DataFrame() + st.session_state["kraken_df"] = pd.DataFrame() + st.session_state["dia_df"] = pd.DataFrame() + + if not st.session_state.get("data_ready", False): + days_in_range = [start_day + timedelta(days=i) for i in range((end_day - start_day).days + 1)] + day_keys = tuple(d.isoformat() for d in days_in_range) + + with st.spinner("Loading Binance data..."): + try: + binance_df = load_prices_cached("binance", day_keys) + except Exception as exc: + st.error(f"Failed to fetch Binance data: {exc}") + st.stop() + + with st.spinner("Loading Kraken data..."): + try: + kraken_df = load_prices_cached("kraken", day_keys) + except Exception as exc: + st.error(f"Failed to fetch Kraken data: {exc}") + st.stop() + + dia_df = load_dia_cached() + dia_range = None + if not dia_df.empty: + dia_range = (dia_df["time"].min(), dia_df["time"].max()) + + st.session_state["dia_range"] = dia_range + st.session_state["day_keys"] = day_keys + st.session_state["binance_df"] = binance_df + st.session_state["kraken_df"] = kraken_df + st.session_state["dia_df"] = dia_df + st.session_state["data_ready"] = True + + binance_df = st.session_state["binance_df"] + kraken_df = st.session_state["kraken_df"] + dia_df = st.session_state["dia_df"] + dia_range = st.session_state.get("dia_range") + + if binance_df.empty or kraken_df.empty: + st.warning("No price data available for the selected range.") + st.stop() + + if dia_df.empty: + if dia_range is not None: + st.warning( + "No DIA data in the selected range. Available DIA range: " + f"{dia_range[0].strftime('%Y-%m-%d %H:%M:%S UTC')} to " + f"{dia_range[1].strftime('%Y-%m-%d %H:%M:%S UTC')}." + ) + else: + st.warning("No DIA oracle data available. Simulation requires DIA data.") + st.stop() + + if dia_range is not None: + chart_start = pd.Timestamp(start_day, tz="UTC") + chart_end = pd.Timestamp(end_day + timedelta(days=1), tz="UTC") + if chart_start < dia_range[0] or chart_end > dia_range[1]: + st.warning( + "Selected range exceeds available DIA data. Available DIA range: " + f"{dia_range[0].strftime('%Y-%m-%d %H:%M:%S UTC')} to " + f"{dia_range[1].strftime('%Y-%m-%d %H:%M:%S UTC')}." + ) + + # ── Binance bias factor lives OUTSIDE the fragment because it affects the + # price chart. Changing it redraws the chart and resets the simulation. + binance_bias_slider = sidebar.slider( + "Binance vs Kraken price bias", + min_value=-10.0, max_value=10.0, + value=st.session_state.get("binance_bias_slider", 3.0), + step=0.5, + format=" ", + help=( + "0 is neutral (snaps within ±0.2). Nonzero values shift by ±1 internally " + "to avoid the -1..1 zone; positive biases Binance, negative biases Kraken." + ), + key="binance_bias_slider", + ) + + label_cols = sidebar.columns(3) + label_cols[0].markdown("Kraken", unsafe_allow_html=True) + label_cols[1].markdown( + f"
{binance_bias_slider:+.1f}
", + unsafe_allow_html=True, + ) + label_cols[2].markdown("
Binance
", unsafe_allow_html=True) + + if abs(binance_bias_slider) < 0.2: + effective_slider = 0.0 + else: + effective_slider = binance_bias_slider + (1.0 if binance_bias_slider > 0 else -1.0) + + if effective_slider == 0.0: + binance_bias_factor = 1.0 + elif effective_slider > 0: + binance_bias_factor = effective_slider + else: + binance_bias_factor = 1.0 / abs(effective_slider) + + combined_df = smooth_binance_with_kraken(binance_df, kraken_df, binance_bias_factor) + st.session_state["combined_df"] = combined_df + st.session_state["dia_df"] = dia_df + if not combined_df.empty: + st.session_state["simulation_start_time"] = combined_df["time"].min() + st.session_state["simulation_end_time"] = combined_df["time"].max() + + if st.session_state.get("combined_df_hash") != binance_bias_slider: + st.session_state["combined_df_hash"] = binance_bias_slider + st.session_state["simulation_ran"] = False + st.session_state["simulation_results"] = None + + if not combined_df.empty: + dia_norm = _to_price_df(dia_df) if not dia_df.empty else pd.DataFrame(columns=["timestamp_ms", "time", "price"]) + + toggle_cols = st.columns(4) + show_binance = toggle_cols[0].checkbox("Binance", value=True, key="show_binance") + show_kraken = toggle_cols[1].checkbox("Kraken", value=True, key="show_kraken") + show_combined = toggle_cols[2].checkbox("Combined", value=True, key="show_combined") + show_dia = toggle_cols[3].checkbox("DIA", value=True, key="show_dia") + + # Limit plot points to keep redraws responsive. + max_plot_points = 1200 + plot_combined = _downsample_for_plot(combined_df, max_plot_points) + if not dia_norm.empty: + plot_start = combined_df["time"].min() + plot_end = combined_df["time"].max() + dia_norm = _pad_series_to_range(dia_norm, plot_start, plot_end) + plot_dia = _downsample_for_plot(dia_norm, max_plot_points) + + fig = go.Figure() + if show_binance: + fig.add_trace(go.Scatter( + x=plot_combined["time"], y=plot_combined["binance_price"], + name="Binance", line=dict(color="#58D68D", width=1.2), + hovertemplate="%{x|%Y-%m-%d %H:%M:%S}
Price: %{y:.6f}Binance", + )) + if show_kraken: + fig.add_trace(go.Scatter( + x=plot_combined["time"], y=plot_combined["kraken_price"], + name="Kraken (interp)", line=dict(color="#F4D03F", width=1.2, dash="dash"), + hovertemplate="%{x|%Y-%m-%d %H:%M:%S}
Price: %{y:.6f}Kraken", + )) + if show_combined: + fig.add_trace(go.Scatter( + x=plot_combined["time"], y=plot_combined["external_price"], + name="combined", line=dict(color="#E74C3C", width=1.6), + hovertemplate="%{x|%Y-%m-%d %H:%M:%S}
Price: %{y:.6f}combined", + )) + if show_dia and not plot_dia.empty: + fig.add_trace(go.Scatter( + x=plot_dia["time"], y=plot_dia["price"], + name="DIA", line=dict(color="#B86BFF", width=1.4, dash="dot", shape="hv"), + hovertemplate="%{x|%Y-%m-%d %H:%M:%S}
Price: %{y:.6f}DIA", + )) + fig.update_layout( + height=500, template="plotly_dark", hovermode="x unified", + legend=dict(orientation="h", y=1.05, x=0), + margin=dict(t=60, b=40, l=60, r=20), + ) + fig.update_yaxes(title_text="EUR/USD", tickformat=".5f") + fig.update_xaxes(title_text="Time (UTC)") + st.plotly_chart(fig, use_container_width=True) + st.divider() + + simulation_section() + +# ============================================================================= +# CLI DEMO +# ============================================================================= +if __name__ == "__main__" and not st.runtime.exists(): + demo_day = date.today() - timedelta(days=1) + binance_demo = get_prices_for_day("binance", demo_day) + kraken_demo = get_prices_for_day("kraken", demo_day) + demo = smooth_binance_with_kraken(binance_demo, kraken_demo, binance_bias_factor=3.0) + print(demo.head(5).to_string(index=False)) diff --git a/hydradx/apps/stableswap/eur_usd_arbitrage_sim_demo.py b/hydradx/apps/stableswap/eur_usd_arbitrage_sim_demo.py new file mode 100644 index 000000000..c385ef625 --- /dev/null +++ b/hydradx/apps/stableswap/eur_usd_arbitrage_sim_demo.py @@ -0,0 +1,16 @@ +from datetime import date, timedelta + +from hydradx.apps.stableswap.eur_usd import get_prices_for_day +from hydradx.apps.stableswap.eur_usd_arbitrage_sim import smooth_binance_with_kraken + + +def main() -> None: + demo_day = date.today() - timedelta(days=1) + binance_demo = get_prices_for_day("binance", demo_day) + kraken_demo = get_prices_for_day("kraken", demo_day) + demo = smooth_binance_with_kraken(binance_demo, kraken_demo) + print(demo.head(10).to_string(index=False)) + + +if __name__ == "__main__": + main() diff --git a/hydradx/model/amm/agents.py b/hydradx/model/amm/agents.py index 7b89749d3..395b13df1 100644 --- a/hydradx/model/amm/agents.py +++ b/hydradx/model/amm/agents.py @@ -5,13 +5,13 @@ class Agent: unique_id: str = '' def __init__(self, - holdings: dict[str: float] = None, - share_prices: dict[str: float] = None, - delta_r: dict[str: float] = None, + holdings: dict[str, float] = None, + share_prices: dict[str, float] = None, + delta_r: dict[str, float] = None, trade_strategy: any = None, unique_id: str = 'agent', - nfts: dict[str: any] = None, - enforce_holdings: bool = True, + nfts: dict[str, any] = None, + enforce_holdings: bool = None, immune_to_fees: bool = False ): """ @@ -34,7 +34,13 @@ def __init__(self, self.asset_list = list(self.holdings.keys()) self.unique_id = unique_id self.nfts = nfts or {} - self.enforce_holdings = enforce_holdings + if enforce_holdings is not None: + self.enforce_holdings = enforce_holdings + else: + if holdings is None: + self.enforce_holdings = False + else: + self.enforce_holdings = True self.immune_to_fees = immune_to_fees def __repr__(self): diff --git a/hydradx/model/amm/basilisk_amm.py b/hydradx/model/amm/basilisk_amm.py index 6abadb9b2..4bfda9ef8 100644 --- a/hydradx/model/amm/basilisk_amm.py +++ b/hydradx/model/amm/basilisk_amm.py @@ -130,7 +130,7 @@ def swap( if self.liquidity[tkn_sell] + sell_quantity <= 0 or self.liquidity[tkn_buy] - buy_quantity <= 0: return self.fail_transaction('Not enough liquidity in the pool.') - if agent.holdings[tkn_sell] - sell_quantity < 0 or agent.holdings[tkn_buy] + buy_quantity < 0: + if not agent.validate_holdings(tkn_sell, sell_quantity) or agent.validate_holdings(tkn_buy, -buy_quantity): return self.fail_transaction('Agent has insufficient holdings.') agent.holdings[tkn_buy] += buy_quantity diff --git a/hydradx/model/amm/global_state.py b/hydradx/model/amm/global_state.py index b83ee8efb..67bddd95a 100644 --- a/hydradx/model/amm/global_state.py +++ b/hydradx/model/amm/global_state.py @@ -12,18 +12,26 @@ class GlobalState: def __init__(self, - agents: dict[str: Agent], - pools: dict[str: Exchange] or list[Exchange], - otcs: list[OTC] = [], - external_market: dict[str: float] = None, + agents: dict[str, Agent] | list[Agent], + pools: dict[str, Exchange] | list[Exchange], + otcs: list[OTC] = None, + external_market: dict[str, float] = None, evolve_function: Callable = None, save_data: dict = None, archive_all: bool = True, ): self.external_market = external_market or {} + if otcs is None: + otcs = [] if 'USD' not in self.external_market: self.external_market = {'USD': 1, **self.external_market} + if isinstance(agents, list): + self.agents = {agent.unique_id: agent for agent in agents} + else: + self.agents = agents + for agent_name in self.agents: + self.agents[agent_name].unique_id = agent_name # get a list of all assets contained in any member of the state if isinstance(pools, list): self.pools = {pool.unique_id: pool for pool in pools} @@ -33,16 +41,14 @@ def __init__(self, self.pools[pool_name].unique_id = pool_name self.asset_list = list(set( [asset for pool in self.pools.values() for asset in pool.asset_list] - + [asset for agent in agents.values() for asset in agent.asset_list] + + [asset for agent in self.agents.values() for asset in agent.asset_list] + list(self.external_market.keys()) )) - self.agents = agents - for agent_name in self.agents: - self.agents[agent_name].unique_id = agent_name - for agent in self.agents.values(): - for asset in self.asset_list: - if asset not in agent.holdings: - agent.holdings[asset] = 0 + + # for agent in self.agents.values(): + # for asset in self.asset_list: + # if asset not in agent.holdings: + # agent.holdings[asset] = 0 self.evolve_function = evolve_function self.datastreams = save_data self.save_data = { @@ -240,16 +246,16 @@ def external_market_trade( if tkn_buy not in agent.holdings: agent.holdings[tkn_buy] = 0 - if agent.holdings[tkn_sell] - sell_quantity < 0: + if agent.get_holdings(tkn_sell) - sell_quantity < 0: # insufficient funds, reduce quantity to match - sell_quantity = agent.holdings[tkn_sell] - elif agent.holdings[tkn_buy] + buy_quantity < 0: + sell_quantity = agent.get_holdings(tkn_sell) + elif agent.get_holdings(tkn_buy) + buy_quantity < 0: # also insufficient funds - buy_quantity = -agent.holdings[tkn_buy] + buy_quantity = -agent.get_holdings(tkn_buy) # there could probably be a fee or something here, but for now you can sell infinite quantities for free - agent.holdings[tkn_buy] += buy_quantity - agent.holdings[tkn_sell] -= sell_quantity + agent.add(tkn_buy, buy_quantity) + agent.remove(tkn_sell, sell_quantity) return self diff --git a/hydradx/model/amm/omnipool_amm.py b/hydradx/model/amm/omnipool_amm.py index 1e8bd0d44..17cca58b4 100644 --- a/hydradx/model/amm/omnipool_amm.py +++ b/hydradx/model/amm/omnipool_amm.py @@ -12,7 +12,7 @@ def __call__(self, tkn: str, delta_tkn: float = 0) -> float: ... class DynamicFee: - current: dict[str: float] + current: dict[str, float] def __init__( self, minimum: float = 0, @@ -20,7 +20,7 @@ def __init__( amplification: float = 0, decay: float = 0, # ^^ if these four are provided, we can figure the rest out. - current: dict[str: float] = None, + current: dict[str, float] = None, liquidity: dict = None, net_volume: dict = None, last_updated: dict = None @@ -47,15 +47,15 @@ def update(self, time_step: int, volume: dict, liquidity: dict): class OmnipoolState(Exchange): unique_id: str = 'omnipool' - fee_accumulator: dict[str: float] + fee_accumulator: dict[str, float] def __init__(self, - tokens: dict[str: dict], + tokens: dict[str, dict], tvl_cap: float = float('inf'), preferred_stablecoin: str = None, - asset_fee: dict or DynamicFee or float = 0.0, - lrna_fee: dict or DynamicFee or float = 0.0, - oracles: dict[str: int] = None, + asset_fee: dict | DynamicFee | float = 0.0, + lrna_fee: dict | DynamicFee | float = 0.0, + oracles: dict[str, int] = None, trade_limit_per_block: float = float('inf'), update_function: Callable = None, last_oracle_values: dict = None, @@ -67,7 +67,7 @@ def __init__(self, lrna_mint_pct: float = 1.0, unique_id: str = 'omnipool', lrna_fee_burn: float = 0.5, - lrna_fee_destination: Agent = None, + lrna_fee_destination: Agent | None = None, dynamic_fee_precision: int = 20, slip_factor: float = None ): @@ -169,8 +169,9 @@ def __init__(self, self.lrna_fee_burn = lrna_fee_burn if lrna_fee_burn > 1 or lrna_fee_burn < 0: raise ValueError('lrna_fee_burn must be >= 0 and <= 1') + if lrna_fee_destination is None: - lrna_fee_destination = Agent(holdings={'LRNA': 0}) + lrna_fee_destination = Agent() self.lrna_fee_destination = lrna_fee_destination self.dynamic_fee_precision = dynamic_fee_precision @@ -179,7 +180,7 @@ def __init__(self, self.current_block = Block(self) self.unique_id = unique_id - def _create_dynamic_fee(self, value: DynamicFee or dict or float, fee_type: Literal['lrna', 'asset']) -> DynamicFee: + def _create_dynamic_fee(self, value: DynamicFee | dict | float, fee_type: Literal['lrna', 'asset']) -> DynamicFee: raise_oracle = 'price' def get_last_volume(): return { @@ -230,7 +231,7 @@ def lrna_fee(self) -> AssetFeeCallable: return self._get_lrna_fee @lrna_fee.setter - def lrna_fee(self, value: DynamicFee or dict or float): + def lrna_fee(self, value: DynamicFee | dict | float): self._lrna_fee = self._create_dynamic_fee(value, 'lrna') def set_lrna_fee(self, tkn: str, value: float): @@ -363,7 +364,7 @@ def compute_slip_fee(self, tkn: str, delta_q: float) -> float: if self.slip_factor is None: return 0.0 else: - delta_q += self.lrna[tkn] - self.current_block.lrna[tkn] + delta_q += self.current_block.lrna_in[tkn] - self.current_block.lrna_out[tkn] return self.slip_factor * abs(delta_q) / (self.current_block.lrna[tkn] + delta_q) def add_token( @@ -399,6 +400,8 @@ def add_token( self.current_block.volume_in[tkn] = 0 self.current_block.volume_out[tkn] = 0 self.current_block.lrna[tkn] = self.lrna[tkn] + self.current_block.lrna_out[tkn] = 0 + self.current_block.lrna_in[tkn] = 0 for oracle in self.oracles.values() if self.oracles else []: oracle.liquidity[tkn] = self.liquidity[tkn] oracle.price[tkn] = self.lrna[tkn] / self.liquidity[tkn] @@ -494,17 +497,24 @@ def __repr__(self): return ( f'Omnipool: {self.unique_id}\n' f'********************************\n' - f'tvl cap: {self.tvl_cap}\n' - f'lrna fee:\n\n' + f'tvl cap: {self.tvl_cap}\n\n' + f'lrna fee:\n' f'{newline.join([f" {tkn}: {self.last_lrna_fee[tkn]}" for tkn in self.liquidity])}\n\n' - f'asset fee:\n\n' + f'asset fee:\n' f'{newline.join([f" {tkn}: {self.last_fee[tkn]}" for tkn in self.liquidity])}\n\n' - f'asset pools: (\n\n' + f'asset pools: \n' + ' {\n' ) + '\n'.join( [( - f' *{tkn}*\n' - f' asset quantity: {liquidity[tkn]}\n' - f' lrna quantity: {lrna[tkn]}\n' + f""" '{tkn}': {{ + 'liquidity': {self.liquidity[tkn]}, + 'LRNA': {self.lrna[tkn]}, + 'shares': {self.shares[tkn]}, + }}""" + ) for tkn in self.liquidity] + )+ '\n }\n other stats:\n' + '\n'.join( + [( + f' {tkn} (\n' + f' USD price: {usd_prices[tkn]}\n' + # f' tvl: ${lrna[tkn] * liquidity[self.stablecoin] / lrna[self.stablecoin]}\n' f' weight: {lrna[tkn]}/{lrna_total} ({lrna[tkn] / lrna_total})\n' @@ -517,69 +527,161 @@ def __repr__(self): for name, oracle in self.oracles.items() ]) + f'\n)\n\nerror message: {self.fail or "None"}' + def _invert_buy_side_slip_for_net(self, tkn_buy: str, D_net: float) -> float: + if self.slip_factor is None or self.slip_factor == 0.0: + return D_net + + s = self.slip_factor + L0 = self.current_block.lrna[tkn_buy] + C = self.current_block.lrna_in[tkn_buy] - self.current_block.lrna_out[tkn_buy] + max_fee = self.max_lrna_fee + eps = 1e-18 + + candidates_D: list[float] = [] + + if abs(s - 1.0) < eps: + denom = (L0 - D_net) + if denom > 0.0: + u = L0 * (C + D_net) / denom + if u >= 0.0 and (L0 + u) > 0.0: + D = u - C + if D > 0.0: + candidates_D.append(D) + else: + A1 = s - 1.0 + B1 = C * (1.0 - s) + D_net - L0 + C1 = L0 * (C + D_net) + disc1 = B1 * B1 - 4.0 * A1 * C1 + if disc1 >= 0.0: + sd1 = disc1 ** 0.5 + for u in ((-B1 + sd1) / (2.0 * A1), (-B1 - sd1) / (2.0 * A1)): + if u >= 0.0 and (L0 + u) > 0.0: + D = u - C + if D > 0.0: + candidates_D.append(D) + + A2 = 1.0 + s + B2 = L0 - (1.0 + s) * C - D_net + C2 = -L0 * (C + D_net) + disc2 = B2 * B2 - 4.0 * A2 * C2 + if disc2 >= 0.0: + sd2 = disc2 ** 0.5 + for u in ((-B2 + sd2) / (2.0 * A2), (-B2 - sd2) / (2.0 * A2)): + if u <= 0.0 and (L0 + u) > 0.0: + D = u - C + if D > 0.0: + candidates_D.append(D) + + valid_Ds: list[float] = [] + for D in candidates_D: + slip_uncapped = self.compute_slip_fee(tkn_buy, D) + if slip_uncapped < max_fee and slip_uncapped < 1.0: + valid_Ds.append(D) + + if valid_Ds: + D_gross = min(valid_Ds) + else: + k_sat = 1.0 - max_fee + if k_sat <= 0.0: + return math.inf + D_gross = D_net / k_sat + + return D_gross + def calculate_in_given_out( self, tkn_buy: str, tkn_sell: str, buy_quantity: float - ) -> tuple[float, float, float, float]: - asset_fee_total, lrna_fee_total, slip_fee_total = 0, 0, 0 + ) -> tuple[float, float, float, float, float, float, float]: + """ + Given a desired buy_quantity, compute required sell_quantity. + Returns: + (sell_quantity, delta_qi, delta_qj, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell) + + Sign convention matches calculate_out_given_in: + - delta_qi: LRNA delta in the SELL pool (negative when LRNA leaves that pool) + - delta_qj: LRNA delta in the BUY pool (positive when LRNA enters that pool) + If tkn_buy == "LRNA", delta_qj is negative (LRNA paid out to user, no buy pool). + """ + asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell = 0.0, 0.0, 0.0, 0.0 + if tkn_buy == "LRNA": D = buy_quantity + delta_qj = -buy_quantity else: A = self.liquidity[tkn_buy] Qb = self.lrna[tkn_buy] - asset_fee = self.asset_fee(tkn_buy) - b = buy_quantity / (1 - asset_fee) + + asset_fee = self.compute_dynamic_fee(self._asset_fee, tkn_buy) + b = buy_quantity / (1.0 - asset_fee) asset_fee_total = b - buy_quantity if b >= A: - return math.inf, 0, 0, 0 # infeasible: not enough liquidity to buy this much - D = (b * Qb) / (A - b) + return math.inf, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + D_net = (b * Qb) / (A - b) + + D = self._invert_buy_side_slip_for_net(tkn_buy, D_net) + if not math.isfinite(D): + return math.inf, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + + if self.slip_factor is not None and self.slip_factor != 0.0: + max_fee = self.max_lrna_fee + slip_rate_buy = min(self.compute_slip_fee(tkn_buy, D), max_fee) + slip_fee_buy += D * slip_rate_buy + + delta_qj = D - slip_fee_buy if tkn_sell == "LRNA": - return D, asset_fee_total, 0, 0 + return D, 0.0, delta_qj, asset_fee_total, 0.0, slip_fee_buy, 0.0 - lrna_fee = self.compute_dynamic_fee(self._lrna_fee, tkn_sell) - L = self.liquidity[tkn_sell] last_block_h2o = self.current_block.lrna[tkn_sell] - current_h2o = self.lrna[tkn_sell] + current_h2o = last_block_h2o + self.current_block.lrna_in[tkn_sell] - self.current_block.lrna_out[tkn_sell] + max_fee = self.max_lrna_fee + L = self.liquidity[tkn_sell] + + lrna_fee = self.compute_dynamic_fee(self._lrna_fee, tkn_sell) k = 1.0 - lrna_fee s = (self.slip_factor or 0.0) C = current_h2o - last_block_h2o - max_fee = self.max_lrna_fee - if lrna_fee >= max_fee: - # this would happen if dynamic fee has already reached saturatioon k_sat = 1.0 - max_fee - if k_sat <= 0: return math.inf, 0, 0, 0 + if k_sat <= 0.0: + return math.inf, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 x = D / k_sat else: if abs(D - C * k) < 1e-20: - # below rounding error threshold x = C else: p = (k - s) if (D < C * k) else (k + s) q = (k * last_block_h2o + D - p * C) r = last_block_h2o * (D - C * k) - disc = q * q - 4 * p * r - if disc < 0: return math.inf, 0, 0, 0 # infeasible: no real roots + disc = q * q - 4.0 * p * r + if disc < 0.0: + return math.inf, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 sd = disc ** 0.5 - u = (2 * r) / (-q - (sd if q >= 0 else -sd)) # small, stable root + u = (2.0 * r) / (-q - (sd if q >= 0 else -sd)) # small, stable root x = C - u - # If the uncapped slip would push (lrna_fee + slip) over max_fee, use saturated linear x - if lrna_fee + self.compute_slip_fee(tkn_sell, x) > max_fee: + # Saturate if lrna_fee + slip would exceed max_fee + if lrna_fee + self.compute_slip_fee(tkn_sell, -x) > max_fee: k_sat = 1.0 - max_fee - if k_sat <= 0: return math.inf, 0, 0, 0 + if k_sat <= 0.0: + return math.inf, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 x = D / k_sat - if not (0 < x < current_h2o): - return math.inf, 0, 0, 0 - if (last_block_h2o + (C - x)) <= 0: - return math.inf, 0, 0, 0 - # lrna_fee = self.lrna_fee(tkn_sell) - lrna_fee_total = x * lrna_fee if tkn_sell != "LRNA" else 0.0 - slip_fee_total = x * min(self.compute_slip_fee(tkn_sell, -x), max_fee - lrna_fee) if tkn_sell != "LRNA" else 0.0 + if not (0.0 < x < current_h2o): + return math.inf, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + if (last_block_h2o + (C - x)) <= 0.0: + return math.inf, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 sell_quantity = (L * x) / (current_h2o - x) - return sell_quantity, asset_fee_total, lrna_fee_total, slip_fee_total + + lrna_fee_total += x * lrna_fee + slip_rate_sell = min(self.compute_slip_fee(tkn_sell, -x), max_fee - lrna_fee) + slip_fee_sell += x * slip_rate_sell + + delta_qi = -(delta_qj + lrna_fee_total + slip_fee_sell + slip_fee_buy) + if tkn_buy == "LRNA": + delta_qj = 0 + delta_qi = -x + return sell_quantity, delta_qi, delta_qj, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell def calculate_sell_from_buy(self, tkn_buy, tkn_sell, buy_quantity): return self.calculate_in_given_out(tkn_buy, tkn_sell, buy_quantity)[0] @@ -589,30 +691,70 @@ def calculate_out_given_in( tkn_buy: str, tkn_sell: str, sell_quantity: float - ) -> tuple[float, float, float, float]: + ) -> tuple[float, float, float, float, float, float, float]: """ Given a sell quantity, calculate the effective price, so we can execute it as a buy Returns: (buy_quantity, asset_fee_total, lrna_fee_total, slip_fee_total) """ - asset_fee_total, lrna_fee_total, slip_fee_total = 0, 0, 0 + asset_fee_total = 0.0 + lrna_fee_total = 0.0 + slip_fee_buy = 0.0 + slip_fee_sell = 0.0 + max_fee = self.max_lrna_fee + if tkn_sell != "LRNA": - delta_qi = self.lrna[tkn_sell] * sell_quantity / (self.liquidity[tkn_sell] + sell_quantity) - lrna_fee = self.compute_dynamic_fee(self._lrna_fee, tkn_sell) # returns dynamic + slip fee, capped at self.max_lrna_fee - lrna_fee_total = delta_qi * lrna_fee - slip_fee = self.compute_slip_fee(tkn_sell, -delta_qi) - slip_fee_total = delta_qi * min(slip_fee, self.max_lrna_fee - lrna_fee) - delta_q = delta_qi - lrna_fee_total - slip_fee_total + delta_qi = -self.lrna[tkn_sell] * sell_quantity / (self.liquidity[tkn_sell] + sell_quantity) + if delta_qi >= 0.0: + return 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + + lrna_fee_rate = self.compute_dynamic_fee(self._lrna_fee, tkn_sell) + + if self.slip_factor: + slip_rate_uncapped = self.compute_slip_fee(tkn_sell, delta_qi) + else: + slip_rate_uncapped = 0.0 + + # cap total LRNA-side fee at max_fee on the sell pool + slip_rate_sell = max(0.0, min(slip_rate_uncapped, max_fee - lrna_fee_rate)) + + if lrna_fee_rate + slip_rate_sell >= 1.0: + return math.inf, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + + lrna_fee_total += -delta_qi * lrna_fee_rate + slip_fee_sell += -delta_qi * slip_rate_sell + delta_qj = -delta_qi - lrna_fee_total - slip_fee_sell else: - delta_q = sell_quantity + delta_qi = 0.0 + delta_qj = sell_quantity - if tkn_buy != "LRNA": - delta_ra = self.liquidity[tkn_buy] * delta_q / (delta_q + self.lrna[tkn_buy]) - asset_fee = self.asset_fee(tkn_buy) - asset_fee_total = delta_ra * asset_fee - delta_ra -= asset_fee_total - return delta_ra, asset_fee_total, lrna_fee_total, slip_fee_total + if tkn_buy == "LRNA": + + return delta_qj, delta_qi, 0.0, 0.0, lrna_fee_total, slip_fee_buy, slip_fee_sell + + if delta_qj <= 0.0: + return 0.0, 0.0, 0.0, 0.0, lrna_fee_total, 0, slip_fee_sell + + Lb = self.liquidity[tkn_buy] + Hb = self.lrna[tkn_buy] + + if self.slip_factor: + slip_rate_buy_uncapped = self.compute_slip_fee(tkn_buy, delta_qj) + slip_rate_buy = min(slip_rate_buy_uncapped, max_fee) else: - return delta_q, 0, lrna_fee_total, slip_fee_total + slip_rate_buy = 0.0 + + if slip_rate_buy >= 1.0: + return math.inf, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + + slip_fee_buy = delta_qj * slip_rate_buy + delta_qj -= slip_fee_buy + delta_ra = Lb * delta_qj / (Hb + delta_qj) + + asset_fee_rate = self.compute_dynamic_fee(self._asset_fee, tkn_buy) + asset_fee_total = delta_ra * asset_fee_rate + delta_ra -= asset_fee_total + + return delta_ra, delta_qi, delta_qj, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell def calculate_buy_from_sell(self, tkn_buy, tkn_sell, sell_quantity): return self.calculate_out_given_in(tkn_buy, tkn_sell, sell_quantity)[0] @@ -680,186 +822,101 @@ def swap( buy_quantity = 0 if sell_quantity is None: sell_quantity = 0 - old_buy_liquidity = self.liquidity[tkn_buy] if tkn_buy in self.liquidity else 0 - old_sell_liquidity = self.liquidity[tkn_sell] if tkn_sell in self.liquidity else 0 + buy_liquidity_start = self.liquidity[tkn_buy] if tkn_buy in self.liquidity else 0 + sell_liquidity_start = self.liquidity[tkn_sell] if tkn_sell in self.liquidity else 0 + buy_lrna_start = self.lrna[tkn_buy] if tkn_buy in self.lrna else 0 + sell_lrna_start = self.lrna[tkn_sell] if tkn_sell in self.lrna else 0 if (tkn_buy not in self.asset_list and tkn_buy != 'LRNA') or (tkn_sell not in self.asset_list and tkn_sell != 'LRNA'): raise ValueError(f'Invalid token pair: {tkn_buy}, {tkn_sell}') - elif tkn_sell == 'LRNA': - return_val = self._lrna_swap(agent, buy_quantity, -sell_quantity, tkn_buy) - elif tkn_buy == 'LRNA': - return_val = self._lrna_swap(agent, -sell_quantity, buy_quantity, tkn_sell) + + if sell_quantity < 0: + raise ValueError("Sell quantity can't be less than 0.") + if buy_quantity < 0: + raise ValueError("Buy quantity can't be less than 0.") elif buy_quantity and not sell_quantity: - # back into correct delta_Ri, then execute sell - delta_Ri = self.calculate_sell_from_buy(tkn_buy, tkn_sell, buy_quantity) - if delta_Ri < 0: + + sell_quantity, delta_qi, delta_qj, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell = \ + self.calculate_in_given_out(tkn_buy=tkn_buy, tkn_sell=tkn_sell, buy_quantity=buy_quantity) + if sell_quantity < 0: return self.fail_transaction(f'insufficient LRNA in {tkn_sell}') - if delta_Ri == float('inf'): - return self.fail_transaction('not enough liquidity in sell pool to buy that much') - # including both buy_quantity and sell_quantity potentially introduces a 'hack' - # where you could include both and *not* have them match, but we're not worried about that - # because this is not a production environment. Just don't do it. - # Here, we are doing it to ensure that buy_quantity is exactly what the user wanted despite rounding error. - return self.swap( - agent=agent, - tkn_buy=tkn_buy, - tkn_sell=tkn_sell, - buy_quantity=buy_quantity, - sell_quantity=delta_Ri + if sell_quantity == float('inf'): + return self.fail_transaction('not enough liquidity to buy that much.') + else: + buy_quantity, delta_qi, delta_qj, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell = \ + self.calculate_out_given_in(tkn_buy=tkn_buy, tkn_sell=tkn_sell, sell_quantity=sell_quantity) + if math.isinf(buy_quantity): + return self.fail_transaction('not enough liquidity to buy that much.') + + if not agent.validate_holdings(tkn_sell, sell_quantity): + return self.fail_transaction(f"Agent doesn't have enough {tkn_sell}") + + slip_fee_total = slip_fee_buy + slip_fee_sell + lrna_fee_burn = (lrna_fee_total + slip_fee_total) * self.lrna_fee_burn + lrna_fee_deposit = lrna_fee_total + slip_fee_total - lrna_fee_burn + # delta_qi -= lrna_fee_burn + + if tkn_buy != "LRNA": + # minting + D_net = delta_qj - slip_fee_buy + asset_fee_rate = self.asset_fee(tkn_buy) + + if self.lrna[tkn_buy] <= 0: + return self.fail_transaction('Invalid LRNA balance in pool') + delta_qm = ( + (self.lrna[tkn_buy] + D_net) * D_net * + asset_fee_rate / self.lrna[tkn_buy] * + self.lrna_mint_pct ) + delta_qj += delta_qm + + if self.lrna_fee_destination: + self.lrna_fee_destination.add("LRNA", lrna_fee_deposit) + # delta_qi -= lrna_fee_deposit else: - # basic Omnipool swap - i = tkn_sell - j = tkn_buy - delta_Ri = sell_quantity - if delta_Ri <= 0: - return self.fail_transaction('sell amount must be greater than zero') - if not agent.validate_holdings(i, delta_Ri): - return self.fail_transaction(f"Agent doesn't have enough {i}") - - delta_qi = self.lrna[tkn_sell] * -delta_Ri / (self.liquidity[tkn_sell] + delta_Ri) - - # get the fees we will be using - asset_fee = self.compute_dynamic_fee(self._asset_fee, tkn_buy) - lrna_fee = self.compute_dynamic_fee(self._lrna_fee, tkn_sell) - # also update both fees for each asset, because that's what they do in production - self.compute_dynamic_fee(self._asset_fee, tkn_sell) - self.compute_dynamic_fee(self._lrna_fee, tkn_buy) - - lrna_fee_total = -delta_qi * lrna_fee - lrna_fee_burn = self.lrna_fee_burn * lrna_fee_total - slip_fee = min(self.compute_slip_fee(tkn_sell, delta_qi), self.max_lrna_fee - lrna_fee) - slip_fee_total = -delta_qi * slip_fee - fee_deposit = lrna_fee_total - lrna_fee_burn + slip_fee_total - - # slip_fee_total = -delta_qi * self.compute_slip_fee(tkn_sell, delta_qi) - # we could do something different with this fee, but for now it just stays in the pool - - delta_Qt = -delta_qi - lrna_fee_total - slip_fee_total - delta_Qm = (self.lrna[tkn_buy] + delta_Qt) * delta_Qt * asset_fee / self.lrna[ - tkn_buy] * self.lrna_mint_pct - delta_Qj = delta_Qt + delta_Qm - delta_Rj = self.liquidity[tkn_buy] * -delta_Qt / (self.lrna[tkn_buy] + delta_Qt) * (1 - asset_fee) - delta_QH = 0 # -lrna_fee * delta_Qi - if self.lrna_fee_destination: - self.lrna_fee_destination.holdings['LRNA'] += fee_deposit - - # 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] - ): - 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] - ): - return self.fail_transaction( - f'{self.trade_limit_per_block * 100}% per block trade limit exceeded in {tkn_sell}.' - ) - self.lrna[i] += delta_qi - self.lrna[j] += delta_Qj - self.liquidity[i] += delta_Ri - self.liquidity[j] += -buy_quantity or delta_Rj - self.lrna['HDX'] += delta_QH + # deposit to sell pool + delta_qi += lrna_fee_deposit + + # ---------- per-block trade limits ---------- + if ( + tkn_buy in self.liquidity and + self.current_block.volume_in[tkn_buy] - self.current_block.volume_out[tkn_buy] + buy_quantity + > self.trade_limit_per_block * self.current_block.liquidity[tkn_buy] + ): + return self.fail_transaction( + f'{self.trade_limit_per_block * 100}% per block trade limit exceeded in {tkn_buy}.' + ) + elif ( + tkn_sell in self.liquidity and + self.current_block.volume_in[tkn_sell] - self.current_block.volume_out[tkn_sell] + sell_quantity + > self.trade_limit_per_block * self.current_block.liquidity[tkn_sell] + ): + return self.fail_transaction( + f'{self.trade_limit_per_block * 100}% per block trade limit exceeded in {tkn_sell}.' + ) + if tkn_sell != 'LRNA': + self.lrna[tkn_sell] += delta_qi + self.liquidity[tkn_sell] += sell_quantity - agent.remove(i, delta_Ri) - agent.add(j, buy_quantity or -delta_Rj) + if tkn_buy != 'LRNA': + self.lrna[tkn_buy] += delta_qj + self.liquidity[tkn_buy] -= buy_quantity - return_val = self + agent.remove(tkn_sell, sell_quantity) + agent.add(tkn_buy, buy_quantity) # update oracle if tkn_buy in self.liquidity: - buy_quantity = old_buy_liquidity - self.liquidity[tkn_buy] + buy_quantity = buy_liquidity_start - self.liquidity[tkn_buy] self.current_block.volume_out[tkn_buy] += buy_quantity + self.current_block.lrna_in[tkn_buy] += self.lrna[tkn_buy] - buy_lrna_start self.current_block.price[tkn_buy] = self.lrna[tkn_buy] / self.liquidity[tkn_buy] if tkn_sell in self.liquidity: - sell_quantity = self.liquidity[tkn_sell] - old_sell_liquidity + sell_quantity = self.liquidity[tkn_sell] - sell_liquidity_start self.current_block.volume_in[tkn_sell] += sell_quantity + self.current_block.lrna_out[tkn_sell] += sell_lrna_start - self.lrna[tkn_sell] self.current_block.price[tkn_sell] = self.lrna[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 = '' - ): - """ - Execute LRNA swap in place (modify and return) - """ - asset_fee = self.asset_fee(tkn) if tkn in self.liquidity else 0 - lrna_fee = self.lrna_fee(tkn) if tkn in self.liquidity else 0 - - if delta_qa < 0: - # selling LRNA - if not agent.validate_holdings('LRNA', -delta_qa): - return self.fail_transaction('Agent has insufficient lrna') - delta_ra = -self.liquidity[tkn] * delta_qa / (-delta_qa + self.lrna[tkn]) * (1 - asset_fee) - delta_qm = asset_fee * (-delta_qa) / self.lrna[tkn] * (self.lrna[tkn] - delta_qa) * self.lrna_mint_pct - delta_qi = delta_qm - delta_qa - - self.lrna[tkn] += delta_qi - self.liquidity[tkn] -= delta_ra - - elif delta_ra > 0: - # buying asset - if -delta_ra + self.liquidity[tkn] <= 0: - return self.fail_transaction('insufficient assets in pool') - denom = (self.liquidity[tkn] * (1 - asset_fee) - delta_ra) - delta_qa = -self.lrna[tkn] * delta_ra / denom - delta_qm = -asset_fee * (1 - asset_fee) * (self.liquidity[tkn] / denom) * delta_qa * self.lrna_mint_pct - delta_qi = -delta_qa + delta_qm - - self.lrna[tkn] += delta_qi - self.liquidity[tkn] -= delta_ra - - # buying LRNA - elif delta_qa > 0: - # buying LRNA - delta_ra, _, lrna_fee_total, slip_fee_total = self.calculate_in_given_out( - tkn_buy='LRNA', tkn_sell=tkn, buy_quantity=delta_qa - ) - delta_ra = -delta_ra - delta_qi = -delta_qa - lrna_fee_total - slip_fee_total - lrna_fee_burn = self.lrna_fee_burn * lrna_fee_total - fee_deposit = lrna_fee_total - lrna_fee_burn + slip_fee_total - if delta_qi + self.lrna[tkn] <= 0: - return self.fail_transaction('insufficient lrna in pool') - if not agent.validate_holdings(tkn, -delta_ra): - return self.fail_transaction('Agent has insufficient assets') - self.lrna[tkn] += delta_qi # burn the LRNA fee - self.liquidity[tkn] += -delta_ra - if self.lrna_fee_destination: - self.lrna_fee_destination.holdings['LRNA'] += fee_deposit - - elif delta_ra < 0: - lrna_fee = self.compute_dynamic_fee(self._lrna_fee, tkn) - # selling asset - if not agent.validate_holdings(tkn, delta_ra): - return self.fail_transaction('agent has insufficient assets') - delta_qi = self.lrna[tkn] * delta_ra / (self.liquidity[tkn] - delta_ra) - lrna_fee_total = -delta_qi * lrna_fee - slip_fee = min(self.compute_slip_fee(tkn, delta_qi), self.max_lrna_fee - lrna_fee) - slip_fee_total = -delta_qi * slip_fee - lrna_fee_burn = lrna_fee_total * self.lrna_fee_burn - fee_deposit = lrna_fee_total - lrna_fee_burn + slip_fee_total - delta_qa = -delta_qi - lrna_fee_total - slip_fee_total - self.lrna[tkn] += delta_qi - self.liquidity[tkn] -= delta_ra - if self.lrna_fee_destination: - self.lrna_fee_destination.holdings['LRNA'] += fee_deposit - - else: - return self.fail_transaction('All deltas are zero.') - - agent.add('LRNA', delta_qa) - agent.add(tkn, delta_ra) - return self def calculate_remove_liquidity(self, agent: Agent, quantity: float = None, tkn_remove: str = None, @@ -1029,6 +1086,7 @@ def add_liquidity( else: shares_added = delta_Q self.shares[tkn_add] += shares_added + self.current_block.shares[tkn_add] += shares_added # LRNA add (mint) self.lrna[tkn_add] += delta_Q @@ -1108,7 +1166,7 @@ def remove_liquidity(self, agent: Agent, quantity: float = None, tkn_remove: str delta_qa, delta_r, delta_q, delta_s, delta_b, nft_ids = val[:6] max_remove = ( - self.max_withdrawal_per_block * self.shares[tkn_remove] - self.current_block.withdrawals[tkn_remove] + self.max_withdrawal_per_block * self.current_block.shares[tkn_remove] - self.current_block.withdrawals[tkn_remove] ) if abs(delta_s) > max_remove: return self.fail_transaction( @@ -1124,13 +1182,8 @@ def remove_liquidity(self, agent: Agent, quantity: float = None, tkn_remove: str self.lrna[tkn_remove] += delta_q # distribute tokens to agent - if delta_qa > 0: - if 'LRNA' not in agent.holdings: - agent.holdings['LRNA'] = 0 - agent.holdings['LRNA'] += delta_qa - if tkn_remove not in agent.holdings: - agent.holdings[tkn_remove] = 0 - agent.holdings[tkn_remove] -= delta_r + agent.add('LRNA', delta_qa) + agent.add(tkn_remove, -delta_r) # remove lp position(s) if nft_id is None: @@ -1211,6 +1264,8 @@ def value_assets(self, assets: dict[str, float], equivalency_map: dict[str, str] numeraire_synonyms.append(eq) value = 0 for tkn in assets: + if assets[tkn] == 0: + continue equivalents = [tkn] if tkn in equivalency_map: equivalents += [equivalency_map[tkn]] @@ -1228,13 +1283,11 @@ def value_assets(self, assets: dict[str, float], equivalency_map: dict[str, str] value += tkn_value return value - def cash_out(self, agent: Agent, prices: dict[str: float] = None, denomination: str = None) -> float: + def cash_out(self, agent: Agent, prices: dict[str, float] = None, denomination: str = 'LRNA') -> float: """ return the value of the agent's holdings if they withdraw all liquidity and then sell at current spot prices """ - if denomination is None: - denomination = 'LRNA' if prices is None: prices = {tkn: self.price(tkn, denomination) for tkn in self.asset_list} @@ -1304,7 +1357,7 @@ def cash_out(self, agent: Agent, prices: dict[str: float] = None, denomination: return value_assets(prices, new_holdings) - def cash_out_mod(self, agent: Agent, prices: dict[str: float] = None, denomination: str = None) -> float: + def cash_out_mod(self, agent: Agent, prices: dict[str, float] = None, denomination: str = None) -> float: """ return the value of the agent's holdings if they withdraw all liquidity and then sell at current spot prices @@ -1442,24 +1495,6 @@ def __init__(self, state: OmnipoolState): self.price = OmnipoolState.price self.usd_price = OmnipoolState.usd_price - -def asset_invariant(state: OmnipoolState, i: str) -> float: - """Invariant for specific asset""" - return state.liquidity[i] * state.lrna[i] - - -def swap_lrna_delta_Qi(state: OmnipoolState, delta_ri: float, i: str) -> float: - return state.lrna[i] * (- delta_ri / (state.liquidity[i] + delta_ri)) - - -def swap_lrna_delta_Ri(state: OmnipoolState, delta_qi: float, i: str) -> float: - return state.liquidity[i] * (- delta_qi / (state.lrna[i] + delta_qi)) - - -def weight_i(state: OmnipoolState, i: str) -> float: - return state.lrna[i] / state.lrna_total - - def simulate_swap( old_state: OmnipoolState, old_agent: Agent, diff --git a/hydradx/model/amm/oracle.py b/hydradx/model/amm/oracle.py index 5fa8522d7..7f329f545 100644 --- a/hydradx/model/amm/oracle.py +++ b/hydradx/model/amm/oracle.py @@ -5,9 +5,12 @@ class Block: def __init__(self, input_state: Exchange): self.liquidity = {tkn: input_state.liquidity[tkn] for tkn in input_state.liquidity} self.lrna = {tkn: input_state.lrna[tkn] if hasattr(input_state, 'lrna') else 0 for tkn in input_state.liquidity} + self.shares = {tkn: input_state.shares[tkn] if hasattr(input_state, 'shares') else 0 for tkn in input_state.liquidity} self.price = {tkn: input_state.price(tkn) for tkn in input_state.liquidity} self.volume_in = {tkn: 0 for tkn in input_state.liquidity} self.volume_out = {tkn: 0 for tkn in input_state.liquidity} + self.lrna_in = {tkn: 0 for tkn in input_state.liquidity} + self.lrna_out = {tkn: 0 for tkn in input_state.liquidity} self.withdrawals = {tkn: 0 for tkn in input_state.liquidity} self.lps = {tkn: 0 for tkn in input_state.liquidity} self.asset_list = input_state.asset_list.copy() diff --git a/hydradx/model/amm/stableswap_amm.py b/hydradx/model/amm/stableswap_amm.py index 023ed64f5..2d12b2e65 100644 --- a/hydradx/model/amm/stableswap_amm.py +++ b/hydradx/model/amm/stableswap_amm.py @@ -201,14 +201,32 @@ def buy_limit(self, tkn_buy, tkn_sell): return self.liquidity[tkn_buy] def calculate_buy_from_sell(self, tkn_buy, tkn_sell, sell_quantity): - fee = self.calculate_fee() - reserves = self.modified_balances(delta={tkn_sell: sell_quantity}, omit=[tkn_buy]) - return (self.liquidity[tkn_buy] - self.calculate_y(reserves, self.d)) * (1 - fee) + # 1. Calculate what the new peg and fee will be at the time of the swap + new_peg, fee = self._calculate_new_peg() + + # 2. Store original peg to restore it later + original_peg = self.peg + self.peg = new_peg + + try: + # 3. Now self.d and calculate_y will use the updated peg, exactly matching swap() + reserves = self.modified_balances(delta={tkn_sell: sell_quantity}, omit=[tkn_buy]) + return (self.liquidity[tkn_buy] - self.calculate_y(reserves, self.d)) * (1 - fee) + finally: + # 4. Revert the state + self.peg = original_peg def calculate_sell_from_buy(self, tkn_buy, tkn_sell, buy_quantity): - fee = self.calculate_fee() - reserves = self.modified_balances(delta={tkn_buy: -buy_quantity}, omit=[tkn_sell]) - return (self.calculate_y(reserves, self.d) - self.liquidity[tkn_sell]) / (1 - fee) + new_peg, fee = self._calculate_new_peg() + + original_peg = self.peg + self.peg = new_peg + + try: + reserves = self.modified_balances(delta={tkn_buy: -buy_quantity}, omit=[tkn_sell]) + return (self.calculate_y(reserves, self.d) - self.liquidity[tkn_sell]) / (1 - fee) + finally: + self.peg = original_peg def price(self, tkn, denomination: str = ''): """ diff --git a/hydradx/model/amm/trade_strategies.py b/hydradx/model/amm/trade_strategies.py index c0d9a9762..90fbfe256 100644 --- a/hydradx/model/amm/trade_strategies.py +++ b/hydradx/model/amm/trade_strategies.py @@ -124,7 +124,8 @@ def strategy(state: GlobalState, agent_id: str): agent_id=agent_id, tkn_sell=sell_asset, tkn_buy=buy_asset, - sell_quantity=sell_quantity + sell_quantity=sell_quantity if sell_quantity > 0 else 0, + buy_quantity=-sell_quantity if sell_quantity < 0 else 0 ) return TradeStrategy(strategy, name=f'constant swaps (${sell_quantity})') diff --git a/hydradx/model/cache/omnipool_block_cache.json b/hydradx/model/cache/omnipool_block_cache.json new file mode 100644 index 000000000..98493d84c --- /dev/null +++ b/hydradx/model/cache/omnipool_block_cache.json @@ -0,0 +1,852 @@ +{ + "2023-12-01": 3944651, + "2023-12-02": 3951442, + "2023-12-03": 3958290, + "2023-12-04": 3965342, + "2023-12-05": 3972364, + "2023-12-06": 3979318, + "2023-12-07": 3986049, + "2023-12-08": 3992754, + "2023-12-09": 3999502, + "2023-12-10": 4006246, + "2023-12-11": 4013264, + "2023-12-12": 4020372, + "2023-12-13": 4027501, + "2023-12-14": 4034626, + "2023-12-15": 4041738, + "2023-12-16": 4048820, + "2023-12-17": 4055935, + "2023-12-18": 4063041, + "2023-12-19": 4070142, + "2023-12-20": 4077256, + "2023-12-21": 4084321, + "2023-12-22": 4091363, + "2023-12-23": 4098380, + "2023-12-24": 4105441, + "2023-12-25": 4112518, + "2023-12-26": 4119602, + "2023-12-27": 4126666, + "2023-12-28": 4133732, + "2023-12-29": 4140797, + "2023-12-30": 4147854, + "2023-12-31": 4154925, + "2024-01-01": 4161952, + "2024-01-02": 4169042, + "2024-01-03": 4176107, + "2024-01-04": 4183193, + "2024-01-05": 4190288, + "2024-01-06": 4197383, + "2024-01-07": 4204451, + "2024-01-08": 4211518, + "2024-01-09": 4218600, + "2024-01-10": 4225675, + "2024-01-11": 4232648, + "2024-01-12": 4239498, + "2024-01-13": 4246573, + "2024-01-14": 4253652, + "2024-01-15": 4260718, + "2024-01-16": 4267776, + "2024-01-17": 4274850, + "2024-01-18": 4281856, + "2024-01-19": 4288761, + "2024-01-20": 4295794, + "2024-01-21": 4302859, + "2024-01-22": 4309912, + "2024-01-23": 4316981, + "2024-01-24": 4324040, + "2024-01-25": 4331109, + "2024-01-26": 4338170, + "2024-01-27": 4344960, + "2024-01-28": 4351892, + "2024-01-29": 4358904, + "2024-01-30": 4366011, + "2024-01-31": 4373127, + "2024-02-01": 4380260, + "2024-02-02": 4387382, + "2024-02-03": 4394518, + "2024-02-04": 4401651, + "2024-02-05": 4408787, + "2024-02-06": 4415907, + "2024-02-07": 4423036, + "2024-02-08": 4430169, + "2024-02-09": 4437296, + "2024-02-10": 4444402, + "2024-02-11": 4451517, + "2024-02-12": 4458644, + "2024-02-13": 4465763, + "2024-02-14": 4472852, + "2024-02-15": 4479972, + "2024-02-16": 4487087, + "2024-02-17": 4494213, + "2024-02-18": 4501338, + "2024-02-19": 4508464, + "2024-02-20": 4515599, + "2024-02-21": 4522713, + "2024-02-22": 4529850, + "2024-02-23": 4536983, + "2024-02-24": 4544102, + "2024-02-25": 4551221, + "2024-02-26": 4558357, + "2024-02-27": 4565488, + "2024-02-28": 4572613, + "2024-02-29": 4579727, + "2024-03-01": 4586858, + "2024-03-02": 4593982, + "2024-03-03": 4601113, + "2024-03-04": 4608237, + "2024-03-05": 4615337, + "2024-03-06": 4622446, + "2024-03-07": 4629576, + "2024-03-08": 4636685, + "2024-03-09": 4643816, + "2024-03-10": 4650949, + "2024-03-11": 4658090, + "2024-03-12": 4665231, + "2024-03-13": 4672355, + "2024-03-14": 4679489, + "2024-03-15": 4686626, + "2024-03-16": 4693773, + "2024-03-17": 4700913, + "2024-03-18": 4708059, + "2024-03-19": 4715200, + "2024-03-20": 4722345, + "2024-03-21": 4729469, + "2024-03-22": 4736612, + "2024-03-23": 4743747, + "2024-03-24": 4750606, + "2024-03-25": 4757442, + "2024-03-26": 4764223, + "2024-03-27": 4771060, + "2024-03-28": 4777891, + "2024-03-29": 4784952, + "2024-03-30": 4792080, + "2024-03-31": 4799226, + "2024-04-01": 4806374, + "2024-04-02": 4813506, + "2024-04-03": 4820616, + "2024-04-04": 4827696, + "2024-04-05": 4834777, + "2024-04-06": 4841880, + "2024-04-07": 4849018, + "2024-04-08": 4856144, + "2024-04-09": 4863283, + "2024-04-10": 4870422, + "2024-04-11": 4877561, + "2024-04-12": 4884704, + "2024-04-13": 4891808, + "2024-04-14": 4898908, + "2024-04-15": 4906054, + "2024-04-16": 4913191, + "2024-04-17": 4920324, + "2024-04-18": 4927461, + "2024-04-19": 4934607, + "2024-04-20": 4941763, + "2024-04-21": 4948920, + "2024-04-22": 4955701, + "2024-04-23": 4962695, + "2024-04-24": 4969616, + "2024-04-25": 4976755, + "2024-04-26": 4983896, + "2024-04-27": 4991038, + "2024-04-28": 4998193, + "2024-04-29": 5005335, + "2024-04-30": 5012455, + "2024-05-01": 5019592, + "2024-05-02": 5026756, + "2024-05-03": 5033926, + "2024-05-04": 5041081, + "2024-05-05": 5048226, + "2024-05-06": 5055352, + "2024-05-07": 5062490, + "2024-05-08": 5069611, + "2024-05-09": 5076586, + "2024-05-10": 5083662, + "2024-05-11": 5090786, + "2024-05-12": 5097912, + "2024-05-13": 5105051, + "2024-05-14": 5112171, + "2024-05-15": 5119273, + "2024-05-16": 5126379, + "2024-05-17": 5133474, + "2024-05-18": 5140592, + "2024-05-19": 5147697, + "2024-05-20": 5154825, + "2024-05-21": 5161955, + "2024-05-22": 5169070, + "2024-05-23": 5176155, + "2024-05-24": 5183240, + "2024-05-25": 5190329, + "2024-05-26": 5197414, + "2024-05-27": 5204521, + "2024-05-28": 5211658, + "2024-05-29": 5218792, + "2024-05-30": 5225945, + "2024-05-31": 5233079, + "2024-06-01": 5239984, + "2024-06-02": 5246780, + "2024-06-03": 5253570, + "2024-06-04": 5260370, + "2024-06-05": 5267162, + "2024-06-06": 5273949, + "2024-06-07": 5280726, + "2024-06-08": 5287521, + "2024-06-09": 5294301, + "2024-06-10": 5301125, + "2024-06-11": 5308249, + "2024-06-12": 5315396, + "2024-06-13": 5322538, + "2024-06-14": 5329685, + "2024-06-15": 5336812, + "2024-06-16": 5343937, + "2024-06-17": 5351055, + "2024-06-18": 5358148, + "2024-06-19": 5365211, + "2024-06-20": 5372334, + "2024-06-21": 5379469, + "2024-06-22": 5386603, + "2024-06-23": 5393711, + "2024-06-24": 5400820, + "2024-06-25": 5407929, + "2024-06-26": 5415025, + "2024-06-27": 5422149, + "2024-06-28": 5429246, + "2024-06-29": 5436401, + "2024-06-30": 5443549, + "2024-07-01": 5450684, + "2024-07-02": 5457827, + "2024-07-03": 5464950, + "2024-07-04": 5472069, + "2024-07-05": 5479116, + "2024-07-06": 5486171, + "2024-07-07": 5493257, + "2024-07-08": 5500414, + "2024-07-09": 5507562, + "2024-07-10": 5514706, + "2024-07-11": 5521856, + "2024-07-12": 5528988, + "2024-07-13": 5536138, + "2024-07-14": 5543238, + "2024-07-15": 5550351, + "2024-07-16": 5557468, + "2024-07-17": 5564591, + "2024-07-18": 5571722, + "2024-07-19": 5578844, + "2024-07-20": 5585953, + "2024-07-21": 5593062, + "2024-07-22": 5600179, + "2024-07-23": 5607281, + "2024-07-24": 5614362, + "2024-07-25": 5621483, + "2024-07-26": 5628594, + "2024-07-27": 5635712, + "2024-07-28": 5642757, + "2024-07-29": 5649708, + "2024-07-30": 5656842, + "2024-07-31": 5663961, + "2024-08-01": 5671099, + "2024-08-02": 5678227, + "2024-08-03": 5685334, + "2024-08-04": 5692448, + "2024-08-05": 5699569, + "2024-08-06": 5706685, + "2024-08-07": 5713825, + "2024-08-08": 5720987, + "2024-08-09": 5728146, + "2024-08-10": 5735286, + "2024-08-11": 5742451, + "2024-08-12": 5749617, + "2024-08-13": 5756769, + "2024-08-14": 5763911, + "2024-08-15": 5771033, + "2024-08-16": 5778162, + "2024-08-17": 5785205, + "2024-08-18": 5792321, + "2024-08-19": 5799456, + "2024-08-20": 5806564, + "2024-08-21": 5813683, + "2024-08-22": 5820819, + "2024-08-23": 5827959, + "2024-08-24": 5835106, + "2024-08-25": 5842250, + "2024-08-26": 5849382, + "2024-08-27": 5856519, + "2024-08-28": 5863616, + "2024-08-29": 5870747, + "2024-08-30": 5877874, + "2024-08-31": 5884985, + "2024-09-01": 5892039, + "2024-09-02": 5899063, + "2024-09-03": 5906070, + "2024-09-04": 5913103, + "2024-09-05": 5920131, + "2024-09-06": 5927149, + "2024-09-07": 5934168, + "2024-09-08": 5941221, + "2024-09-09": 5948244, + "2024-09-10": 5955308, + "2024-09-11": 5962353, + "2024-09-12": 5969397, + "2024-09-13": 5976444, + "2024-09-14": 5983497, + "2024-09-15": 5990524, + "2024-09-16": 5997568, + "2024-09-17": 6004605, + "2024-09-18": 6011304, + "2024-09-19": 6018007, + "2024-09-20": 6024807, + "2024-09-21": 6031572, + "2024-09-22": 6038003, + "2024-09-23": 6044525, + "2024-09-24": 6051335, + "2024-09-25": 6058144, + "2024-09-26": 6064959, + "2024-09-27": 6072116, + "2024-09-28": 6079278, + "2024-09-29": 6086447, + "2024-09-30": 6093569, + "2024-10-01": 6100674, + "2024-10-02": 6107810, + "2024-10-03": 6114960, + "2024-10-04": 6122109, + "2024-10-05": 6129211, + "2024-10-06": 6136318, + "2024-10-07": 6143440, + "2024-10-08": 6150524, + "2024-10-09": 6157627, + "2024-10-10": 6164720, + "2024-10-11": 6171801, + "2024-10-12": 6178921, + "2024-10-13": 6186028, + "2024-10-14": 6193132, + "2024-10-15": 6200220, + "2024-10-16": 6207341, + "2024-10-17": 6214435, + "2024-10-18": 6221555, + "2024-10-19": 6228548, + "2024-10-20": 6235473, + "2024-10-21": 6242390, + "2024-10-22": 6249331, + "2024-10-23": 6256037, + "2024-10-24": 6261917, + "2024-10-25": 6267381, + "2024-10-26": 6273217, + "2024-10-27": 6279491, + "2024-10-28": 6285679, + "2024-10-29": 6291952, + "2024-10-30": 6298103, + "2024-10-31": 6304251, + "2024-11-01": 6310505, + "2024-11-02": 6316704, + "2024-11-03": 6322890, + "2024-11-04": 6329137, + "2024-11-05": 6335380, + "2024-11-06": 6341650, + "2024-11-07": 6347874, + "2024-11-08": 6354161, + "2024-11-09": 6360406, + "2024-11-10": 6366591, + "2024-11-11": 6372859, + "2024-11-12": 6379103, + "2024-11-13": 6385343, + "2024-11-14": 6391586, + "2024-11-15": 6397646, + "2024-11-16": 6403367, + "2024-11-17": 6409176, + "2024-11-18": 6414910, + "2024-11-19": 6420679, + "2024-11-20": 6426954, + "2024-11-21": 6433092, + "2024-11-22": 6439226, + "2024-11-23": 6445338, + "2024-11-24": 6451577, + "2024-11-25": 6457851, + "2024-11-26": 6464062, + "2024-11-27": 6470282, + "2024-11-28": 6476493, + "2024-11-29": 6482637, + "2024-11-30": 6488872, + "2024-12-01": 6495059, + "2024-12-02": 6501262, + "2024-12-03": 6507373, + "2024-12-04": 6513533, + "2024-12-05": 6519724, + "2024-12-06": 6525863, + "2024-12-07": 6532088, + "2024-12-08": 6538293, + "2024-12-09": 6544530, + "2024-12-10": 6550796, + "2024-12-11": 6557007, + "2024-12-12": 6563172, + "2024-12-13": 6569376, + "2024-12-14": 6575604, + "2024-12-15": 6581782, + "2024-12-16": 6587982, + "2024-12-17": 6594137, + "2024-12-18": 6600212, + "2024-12-19": 6606415, + "2024-12-20": 6612636, + "2024-12-21": 6618862, + "2024-12-22": 6625065, + "2024-12-23": 6631296, + "2024-12-24": 6637521, + "2024-12-25": 6643778, + "2024-12-26": 6649985, + "2024-12-27": 6656213, + "2024-12-28": 6662421, + "2024-12-29": 6668634, + "2024-12-30": 6674887, + "2024-12-31": 6681062, + "2025-01-01": 6687261, + "2025-01-02": 6693440, + "2025-01-03": 6699605, + "2025-01-04": 6705835, + "2025-01-05": 6712027, + "2025-01-06": 6718221, + "2025-01-07": 6724368, + "2025-01-08": 6730563, + "2025-01-09": 6736744, + "2025-01-10": 6742986, + "2025-01-11": 6749228, + "2025-01-12": 6755482, + "2025-01-13": 6761676, + "2025-01-14": 6767925, + "2025-01-15": 6774121, + "2025-01-16": 6780364, + "2025-01-17": 6786572, + "2025-01-18": 6792822, + "2025-01-19": 6799019, + "2025-01-20": 6805183, + "2025-01-21": 6811399, + "2025-01-22": 6817575, + "2025-01-23": 6823765, + "2025-01-24": 6829986, + "2025-01-25": 6836243, + "2025-01-26": 6842431, + "2025-01-27": 6848746, + "2025-01-28": 6854806, + "2025-01-29": 6860675, + "2025-01-30": 6866605, + "2025-01-31": 6872437, + "2025-02-01": 6878269, + "2025-02-02": 6884155, + "2025-02-03": 6890028, + "2025-02-04": 6895846, + "2025-02-05": 6902045, + "2025-02-06": 6908310, + "2025-02-07": 6914519, + "2025-02-08": 6920717, + "2025-02-09": 6926875, + "2025-02-10": 6933058, + "2025-02-11": 6939307, + "2025-02-12": 6945524, + "2025-02-13": 6951766, + "2025-02-14": 6957977, + "2025-02-15": 6964194, + "2025-02-16": 6970423, + "2025-02-17": 6976387, + "2025-02-18": 6982315, + "2025-02-19": 6988532, + "2025-02-20": 6994780, + "2025-02-21": 7001016, + "2025-02-22": 7007185, + "2025-02-23": 7013404, + "2025-02-24": 7019664, + "2025-02-25": 7025999, + "2025-02-26": 7032315, + "2025-02-27": 7038645, + "2025-02-28": 7044849, + "2025-03-01": 7051127, + "2025-03-02": 7057343, + "2025-03-03": 7063550, + "2025-03-04": 7069803, + "2025-03-05": 7076053, + "2025-03-06": 7082350, + "2025-03-07": 7088589, + "2025-03-08": 7094839, + "2025-03-09": 7101193, + "2025-03-10": 7107452, + "2025-03-11": 7113692, + "2025-03-12": 7120021, + "2025-03-13": 7126253, + "2025-03-14": 7132552, + "2025-03-15": 7138977, + "2025-03-16": 7145405, + "2025-03-17": 7151826, + "2025-03-18": 7158361, + "2025-03-19": 7164831, + "2025-03-20": 7171310, + "2025-03-21": 7177293, + "2025-03-22": 7183476, + "2025-03-23": 7189674, + "2025-03-24": 7195858, + "2025-03-25": 7202248, + "2025-03-26": 7208637, + "2025-03-27": 7215263, + "2025-03-28": 7221871, + "2025-03-29": 7228363, + "2025-03-30": 7234925, + "2025-03-31": 7241546, + "2025-04-01": 7248112, + "2025-04-02": 7254621, + "2025-04-03": 7261049, + "2025-04-04": 7267583, + "2025-04-05": 7274207, + "2025-04-06": 7280839, + "2025-04-07": 7287494, + "2025-04-08": 7294043, + "2025-04-09": 7300428, + "2025-04-10": 7306729, + "2025-04-11": 7313036, + "2025-04-12": 7319387, + "2025-04-13": 7325711, + "2025-04-14": 7332107, + "2025-04-15": 7338515, + "2025-04-16": 7344907, + "2025-04-17": 7350913, + "2025-04-18": 7357569, + "2025-04-19": 7364306, + "2025-04-20": 7371025, + "2025-04-21": 7377661, + "2025-04-22": 7384274, + "2025-04-23": 7390961, + "2025-04-24": 7397692, + "2025-04-25": 7404442, + "2025-04-26": 7411217, + "2025-04-27": 7417959, + "2025-04-28": 7424788, + "2025-04-29": 7431573, + "2025-04-30": 7438243, + "2025-05-01": 7445077, + "2025-05-02": 7451923, + "2025-05-03": 7458811, + "2025-05-04": 7464991, + "2025-05-05": 7471899, + "2025-05-06": 7478756, + "2025-05-07": 7484520, + "2025-05-08": 7490742, + "2025-05-09": 7497474, + "2025-05-10": 7504327, + "2025-05-11": 7511214, + "2025-05-12": 7518119, + "2025-05-13": 7525022, + "2025-05-14": 7531907, + "2025-05-15": 7538728, + "2025-05-16": 7545502, + "2025-05-17": 7552307, + "2025-05-18": 7559120, + "2025-05-19": 7565892, + "2025-05-20": 7572674, + "2025-05-21": 7579549, + "2025-05-22": 7590252, + "2025-05-23": 7603979, + "2025-05-24": 7617670, + "2025-05-25": 7631215, + "2025-05-26": 7645040, + "2025-05-27": 7658775, + "2025-05-28": 7672585, + "2025-05-29": 7686452, + "2025-05-30": 7700283, + "2025-05-31": 7714057, + "2025-06-01": 7727523, + "2025-06-02": 7739642, + "2025-06-03": 7753450, + "2025-06-04": 7767284, + "2025-06-05": 7781096, + "2025-06-06": 7794961, + "2025-06-07": 7808701, + "2025-06-08": 7822485, + "2025-06-09": 7836239, + "2025-06-10": 7850067, + "2025-06-11": 7863838, + "2025-06-12": 7877643, + "2025-06-13": 7891594, + "2025-06-14": 7905598, + "2025-06-15": 7919697, + "2025-06-16": 7933764, + "2025-06-17": 7947899, + "2025-06-18": 7961980, + "2025-06-19": 7975995, + "2025-06-20": 7989773, + "2025-06-21": 8003653, + "2025-06-22": 8017781, + "2025-06-23": 8031937, + "2025-06-24": 8046008, + "2025-06-25": 8060146, + "2025-06-26": 8074315, + "2025-06-27": 8088435, + "2025-06-28": 8102559, + "2025-06-29": 8116682, + "2025-06-30": 8130810, + "2025-07-01": 8144938, + "2025-07-02": 8159057, + "2025-07-03": 8173195, + "2025-07-04": 8187369, + "2025-07-05": 8201510, + "2025-07-06": 8215646, + "2025-07-07": 8229776, + "2025-07-08": 8243941, + "2025-07-09": 8258080, + "2025-07-10": 8272231, + "2025-07-11": 8286373, + "2025-07-12": 8300539, + "2025-07-13": 8314723, + "2025-07-14": 8328888, + "2025-07-15": 8343070, + "2025-07-16": 8357237, + "2025-07-17": 8371432, + "2025-07-18": 8385652, + "2025-07-19": 8399857, + "2025-07-20": 8414145, + "2025-07-21": 8428415, + "2025-07-22": 8442691, + "2025-07-23": 8456929, + "2025-07-24": 8471093, + "2025-07-25": 8485244, + "2025-07-26": 8499490, + "2025-07-27": 8513727, + "2025-07-28": 8527965, + "2025-07-29": 8542243, + "2025-07-30": 8556479, + "2025-07-31": 8570674, + "2025-08-01": 8584898, + "2025-08-02": 8599104, + "2025-08-03": 8613402, + "2025-08-04": 8627652, + "2025-08-05": 8641882, + "2025-08-06": 8656125, + "2025-08-07": 8670378, + "2025-08-08": 8684597, + "2025-08-09": 8698854, + "2025-08-10": 8713113, + "2025-08-11": 8727372, + "2025-08-12": 8741609, + "2025-08-13": 8755822, + "2025-08-14": 8770043, + "2025-08-15": 8784253, + "2025-08-16": 8798491, + "2025-08-17": 8812739, + "2025-08-18": 8826964, + "2025-08-19": 8841195, + "2025-08-20": 8855411, + "2025-08-21": 8869634, + "2025-08-22": 8883866, + "2025-08-23": 8898128, + "2025-08-24": 8912366, + "2025-08-25": 8926592, + "2025-08-26": 8940811, + "2025-08-27": 8955081, + "2025-08-28": 8969337, + "2025-08-29": 8983619, + "2025-08-30": 8997878, + "2025-08-31": 9012097, + "2025-09-01": 9026361, + "2025-09-02": 9040622, + "2025-09-03": 9054819, + "2025-09-04": 9069046, + "2025-09-05": 9083273, + "2025-09-06": 9097504, + "2025-09-07": 9111717, + "2025-09-08": 9125915, + "2025-09-09": 9140170, + "2025-09-10": 9154438, + "2025-09-11": 9168651, + "2025-09-12": 9182834, + "2025-09-13": 9197043, + "2025-09-14": 9211270, + "2025-09-15": 9225504, + "2025-09-16": 9239686, + "2025-09-17": 9253841, + "2025-09-18": 9267989, + "2025-09-19": 9282136, + "2025-09-20": 9296275, + "2025-09-21": 9310364, + "2025-09-22": 9324514, + "2025-09-23": 9338679, + "2025-09-24": 9352721, + "2025-09-25": 9366796, + "2025-09-26": 9380898, + "2025-09-27": 9394954, + "2025-09-28": 9409024, + "2025-09-29": 9423107, + "2025-09-30": 9437159, + "2025-10-01": 9451236, + "2025-10-02": 9465343, + "2025-10-03": 9479432, + "2025-10-04": 9493541, + "2025-10-05": 9507624, + "2025-10-06": 9521681, + "2025-10-07": 9535846, + "2025-10-08": 9550012, + "2025-10-09": 9564139, + "2025-10-10": 9578341, + "2025-10-11": 9592542, + "2025-10-12": 9606707, + "2025-10-13": 9620846, + "2025-10-14": 9634953, + "2025-10-15": 9649047, + "2025-10-16": 9663223, + "2025-10-17": 9677369, + "2025-10-18": 9691452, + "2025-10-19": 9705577, + "2025-10-20": 9719779, + "2025-10-21": 9733978, + "2025-10-22": 9748182, + "2025-10-23": 9762415, + "2025-10-24": 9776616, + "2025-10-25": 9790846, + "2025-10-26": 9805068, + "2025-10-27": 9819261, + "2025-10-28": 9833416, + "2025-10-29": 9847572, + "2025-10-30": 9861814, + "2025-10-31": 9875964, + "2025-11-01": 9890152, + "2025-11-02": 9904343, + "2025-11-03": 9918540, + "2025-11-04": 9932723, + "2025-11-05": 9946824, + "2025-11-06": 9960956, + "2025-11-07": 9975188, + "2025-11-08": 9989358, + "2025-11-09": 10003511, + "2025-11-10": 10017715, + "2025-11-11": 10031838, + "2025-11-12": 10045888, + "2025-11-13": 10059938, + "2025-11-14": 10073976, + "2025-11-15": 10088081, + "2025-11-16": 10102251, + "2025-11-17": 10116349, + "2025-11-18": 10130464, + "2025-11-19": 10144602, + "2025-11-20": 10158705, + "2025-11-21": 10172814, + "2025-11-22": 10186892, + "2025-11-23": 10200981, + "2025-11-24": 10215061, + "2025-11-25": 10229149, + "2025-11-26": 10243206, + "2025-11-27": 10257282, + "2025-11-28": 10271250, + "2025-11-29": 10285285, + "2025-11-30": 10299344, + "2025-12-01": 10313409, + "2025-12-02": 10327456, + "2025-12-03": 10341423, + "2025-12-04": 10355414, + "2025-12-05": 10369453, + "2025-12-06": 10383454, + "2025-12-07": 10397465, + "2025-12-08": 10411470, + "2025-12-09": 10425426, + "2025-12-10": 10439405, + "2025-12-11": 10453337, + "2025-12-12": 10467255, + "2025-12-13": 10481273, + "2025-12-14": 10495354, + "2025-12-15": 10509436, + "2025-12-16": 10523403, + "2025-12-17": 10537393, + "2025-12-18": 10551473, + "2025-12-19": 10565609, + "2025-12-20": 10579782, + "2025-12-21": 10593913, + "2025-12-22": 10608086, + "2025-12-23": 10622139, + "2025-12-24": 10636242, + "2025-12-25": 10650446, + "2025-12-26": 10664598, + "2025-12-27": 10678739, + "2025-12-28": 10692831, + "2025-12-29": 10706980, + "2025-12-30": 10719827, + "2025-12-31": 10717053, + "2026-01-01": 10717053, + "2026-01-02": 10761852, + "2026-01-03": 10777277, + "2026-01-04": 10791315, + "2026-01-05": 10805308, + "2026-01-06": 10819283, + "2026-01-07": 10833282, + "2026-01-08": 10847288, + "2026-01-09": 10861373, + "2026-01-10": 10874857, + "2026-01-11": 10889329, + "2026-01-12": 10903283, + "2026-01-13": 10914394, + "2026-01-14": 10926268, + "2026-01-15": 10944951, + "2026-01-16": 10956124, + "2026-01-17": 10972778, + "2026-01-18": 10986814, + "2026-01-19": 11000887, + "2026-01-20": 11014860, + "2026-01-21": 11028846, + "2026-01-22": 11042905, + "2026-01-23": 11056967, + "2026-01-24": 11071061, + "2026-01-25": 11085095, + "2026-01-26": 11099092, + "2026-01-27": 11113117, + "2026-01-28": 11127146, + "2026-01-29": 11141239, + "2026-01-30": 11155328, + "2026-01-31": 11169437, + "2026-02-01": 11183658, + "2026-02-02": 11197764, + "2026-02-03": 11211944, + "2026-02-04": 11226007, + "2026-02-05": 11240149, + "2026-02-06": 11254289, + "2026-02-07": 11268448, + "2026-02-08": 11282624, + "2026-02-09": 11296832, + "2026-02-10": 11309160, + "2026-02-11": 11319609, + "2026-02-12": 11337609, + "2026-02-13": 11346501, + "2026-02-14": 11363554, + "2026-02-15": 11376138, + "2026-02-16": 11388692, + "2026-02-17": 11401253, + "2026-02-18": 11413810, + "2026-02-19": 11426305, + "2026-02-20": 11438701, + "2026-02-21": 11451119, + "2026-02-22": 11463718, + "2026-02-23": 11476278, + "2026-02-24": 11488737, + "2026-02-25": 11501292, + "2026-02-26": 11514021, + "2026-02-27": 11526910, + "2026-02-28": 11539775, + "2026-03-01": 11552390, + "2026-03-02": 11564908, + "2026-03-03": 11577279, + "2026-03-04": 11589649, + "2026-03-05": 11602221, + "2026-03-06": 11614698, + "2026-03-07": 11627306, + "2026-03-08": 11640030, + "2026-03-09": 11652705, + "2026-03-10": 11665370, + "2026-03-11": 11678032, + "2026-03-12": 11690780, + "2026-03-13": 11703281, + "2026-03-14": 11715707, + "2026-03-15": 11728439, + "2026-03-16": 11741077, + "2026-03-17": 11753627, + "2026-03-18": 11766159, + "2026-03-19": 11778320, + "2026-03-20": 11790370, + "2026-03-21": 11795745, + "2026-03-22": 11814525, + "2026-03-23": 11826640, + "2026-03-24": 11833217, + "2026-03-25": 11846329, + "2026-03-26": 11862310, + "2026-03-27": 11871252, + "2026-03-28": 11883132, + "2026-03-29": 11897899 +} \ No newline at end of file diff --git a/hydradx/model/indexer_utils.py b/hydradx/model/indexer_utils.py index fa3df1bc8..977482bc8 100644 --- a/hydradx/model/indexer_utils.py +++ b/hydradx/model/indexer_utils.py @@ -1,16 +1,18 @@ +import datetime import json import requests - +import concurrent.futures +from pathlib import Path from hydradx.model.amm.omnipool_amm import OmnipoolState, DynamicFee from hydradx.model.amm.omnipool_router import OmnipoolRouter from hydradx.model.amm.stableswap_amm import StableSwapPoolState import hydradx.model.production_settings as settings -URL_UNIFIED_PROD = 'https://galacticcouncil.squids.live/hydration-pools:unified-prod/api/graphql' -URL_OMNIPOOL_STORAGE = 'https://galacticcouncil.squids.live/hydration-storage-dictionary:omnipool-v2/api/graphql' -URL_STABLESWAP_STORAGE = 'https://galacticcouncil.squids.live/hydration-storage-dictionary:stablepool-v2/api/graphql' -URL_GENERIC_DATA = 'https://galacticcouncil.squids.live/hydration-storage-dictionary:generic-data-v2/api/graphql' +URL_UNIFIED_PROD = 'https://unified-main-aggr-indx.indexer.hydration.cloud/graphql' +URL_OMNIPOOL_STORAGE = 'https://storage-dict-omnipool-hist-data-v2.orca.hydration.cloud/graphql' +URL_STABLESWAP_STORAGE = 'https://storage-dict-stableswap-hist-data-v2.orca.hydration.cloud/graphql' +URL_GENERIC_DATA = 'https://storage-dict-generic-hist-data-v2.orca.hydration.cloud/graphql' class AssetInfo: def __init__( @@ -51,7 +53,195 @@ def query_indexer(url: str, query: str, variables: dict = None) -> dict: return return_val -def get_asset_info_by_ids(asset_ids: list = None) -> dict[str: AssetInfo]: +BASE_DIR = Path(__file__).resolve().parent +CACHE_DIR = BASE_DIR / "cache" +BLOCK_CACHE_FILE = CACHE_DIR / "omnipool_block_cache.json" + + +def chunks(lst, n): + for i in range(0, len(lst), n): + yield lst[i:i + n] + + +def load_block_cache(): + if BLOCK_CACHE_FILE.exists(): + with open(BLOCK_CACHE_FILE, 'r') as f: + return json.load(f) + return {} + + +def save_block_cache(cache): + with open(BLOCK_CACHE_FILE, 'w') as f: + json.dump(cache, f, indent=2, sort_keys=True) + +def is_decimal(s): + try: + float(s) # or int(s) if you only want integers + return True + except ValueError: + return False + + +def _normalize_timestamps(timestamps: list[datetime.datetime] | datetime.datetime | datetime.date): + if not isinstance(timestamps, list): + timestamps = [timestamps] + return [ + ts if isinstance(ts, datetime.datetime) else datetime.datetime.combine(ts, datetime.time()) + for ts in timestamps + ] + + +def _ensure_block_anchors(timestamps: list[datetime.datetime], cache: dict | None = None, max_workers: int = 10, save_cache: bool = True): + cache = cache if cache is not None else load_block_cache() + + needed_anchors = {} + for ts in timestamps: + start_of_day = ts.replace(hour=0, minute=0, second=0, microsecond=0) + end_of_day = start_of_day + datetime.timedelta(days=1) + needed_anchors[start_of_day.date().isoformat()] = start_of_day + needed_anchors[end_of_day.date().isoformat()] = end_of_day + + missing_date_keys = [k for k in needed_anchors if k not in cache] + + if missing_date_keys: + print(f"Phase 1: Cache miss. Fetching {len(missing_date_keys)} daily anchors...") + + def _fetch_anchor(chunk_keys): + local_res = {} + for date_key in chunk_keys: + ts_obj = needed_anchors[date_key] + ts_iso = ts_obj.isoformat() + + query = f""" + query {{ + blocks(last: 1, orderBy: ID_ASC, filter: {{timestamp: {{lessThanOrEqualTo: \"{ts_iso}\"}}}}) {{ + nodes {{ id }} + }} + }} + """ + resp = query_indexer(url=URL_UNIFIED_PROD, query=query) + try: + node = resp['data']['blocks']['nodes'][0] + local_res[date_key] = int(node['id'].split('-')[0]) + except (KeyError, IndexError, TypeError): + print(f"Warning: Could not fetch block for {date_key}") + return local_res + + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [executor.submit(_fetch_anchor, chunk) for chunk in chunks(missing_date_keys, 5)] + for future in concurrent.futures.as_completed(futures): + cache.update(future.result()) + + if save_cache: + save_block_cache(cache) + print(f"Cache updated and saved to {BLOCK_CACHE_FILE}") + else: + print("Cache updated in memory (not saved).") + + return cache + + +def _get_block_at_timestamp_from_cache( + ts: datetime.datetime, + cache: dict, + verbose: bool = True +) -> int | None: + start_of_day = ts.replace(hour=0, minute=0, second=0, microsecond=0) + end_of_day = start_of_day + datetime.timedelta(days=1) + + start_key = start_of_day.date().isoformat() + end_key = end_of_day.date().isoformat() + + try: + block_start = cache[start_key] + block_end = cache[end_key] + + day_duration_sec = (end_of_day - start_of_day).total_seconds() + day_block_diff = block_end - block_start + + if day_duration_sec <= 0: + return block_start + blocks_per_sec = day_block_diff / day_duration_sec + target_offset_sec = (ts - start_of_day).total_seconds() + return int(block_start + (target_offset_sec * blocks_per_sec)) + except KeyError: + if verbose: + print(f"Warning: Missing block anchors for {ts.isoformat()}") + return None + + +def get_block_at_timestamp( + timestamp: datetime.datetime | datetime.date, + cache: dict | None = None, + save_cache: bool = True, + verbose: bool = True, + ensure_anchors: bool = True +) -> int | None: + normalized = _normalize_timestamps(timestamp) + ts = normalized[0] + cache = cache if cache is not None else load_block_cache() + if cache is None: + cache = {} + + if ensure_anchors: + cache = _ensure_block_anchors(normalized, cache=cache, save_cache=save_cache) + + return _get_block_at_timestamp_from_cache(ts, cache=cache, verbose=verbose) + + +def get_hollar_liquidity_at(block_number=None): + query = f""" + query MyQuery {{ + omnipoolAssetHistoricalData( + filter: {{ + assetId: {{equalTo: "0x531a654d1696ed52e7275a8cede955e82620f99a"}}, + paraBlockHeight: {{lessThanOrEqualTo: {block_number if block_number else 'null'}}} + }} + orderBy: PARA_BLOCK_HEIGHT_DESC + first: 1 + ) {{ + nodes {{ + paraBlockHeight + freeBalance + tvlInRefAssetNorm + assetHubReserve + assetShares + }} + }} + }} + """ + data = query_indexer(URL_UNIFIED_PROD, query) + hollar_liquidity = data['data']['omnipoolAssetHistoricalData']['nodes'][0] + return { + 'block': hollar_liquidity['paraBlockHeight'], + 'liquidity': int(hollar_liquidity['freeBalance']) / 10 ** 18, + 'LRNA': int(hollar_liquidity['assetHubReserve']) / 10 ** 12, + 'shares': int(hollar_liquidity['assetShares']) / 10 ** 18, + } + + +def get_blocks_at_timestamps(timestamps: list[datetime.datetime]) -> dict[datetime.datetime, int]: + """ + Returns a dict {timestamp: block_number}. + Uses a persistent daily cache: {"YYYY-MM-DD": block_int} + """ + normalized = _normalize_timestamps(timestamps) + cache = _ensure_block_anchors(normalized) + + results = {} + for ts in normalized: + results[ts] = get_block_at_timestamp( + ts, + cache=cache, + save_cache=False, + verbose=False, + ensure_anchors=False + ) + + return results + + +def get_asset_info_by_ids(asset_ids: list = None) -> dict[str, AssetInfo]: asset_query = f""" query assetInfoByAssetIds{'($assetIds: [String!]!)' if asset_ids else ''} {{ @@ -93,8 +283,11 @@ def get_asset_info_by_ids(asset_ids: list = None) -> dict[str: AssetInfo]: def get_omnipool_asset_data( min_block_id: int, max_block_id: int, - asset_ids: list[str] or list[int] = None + asset_ids: list[str] | list[int] = None ) -> list: + """ + + """ variables: dict[str, object] = { "minBlock": min_block_id, @@ -172,7 +365,7 @@ def get_omnipool_data_by_asset( def get_current_block_height(): - url = 'https://galacticcouncil.squids.live/hydration-pools:unified-prod/api/graphql' + url = URL_UNIFIED_PROD latest_block_query = """ query BlockHeight { @@ -272,7 +465,7 @@ def get_latest_stableswap_data( return pool_data_formatted -def get_current_omnipool_assets() -> list[str]: +def get_current_omnipool_asset_ids() -> list[str]: query = """ query assetInfoByAssetIds { omnipoolAssets(filter: {isRemoved: {equalTo: false}}) { @@ -284,11 +477,11 @@ def get_current_omnipool_assets() -> list[str]: """ data = query_indexer(URL_UNIFIED_PROD, query) ids = [node['assetId'] for node in data['data']['omnipoolAssets']['nodes']] - for i in ids: - try: - int(i) - except ValueError: - ids.remove(i) + # for i in ids: + # try: + # int(i) + # except ValueError: + # ids.remove(i) # hub token doesn't count ids.remove('1') return ids @@ -399,131 +592,108 @@ def get_fee_pcts(data, asset_id): return fee_pcts -def get_current_stableswap_pools(block_number): +def get_stableswap_pools(block_number, stableswap_ids: list | str | int = None) -> dict[str, StableSwapPoolState]: all_assets = get_asset_info_by_ids() - stableswap_ids = [asset.id for asset in all_assets.values() if asset.asset_type == 'StableSwap'] + if isinstance(stableswap_ids, int): + stableswap_ids = [str(stableswap_ids)] + if isinstance(stableswap_ids, str): + stableswap_ids = [stableswap_ids] + if stableswap_ids is None: + stableswap_ids = [asset.id for asset in all_assets.values() if asset.asset_type == 'StableSwap'] + pool_ids = {} + asset_ids = set() + stableswap_pools = {} + + # first get asset info stableswap_asset_query = """ query assetInfoByAssetIds { - stableswapAssets { - nodes { - pool { - id - } - asset { - name - symbol - id - decimals - } - } + assets { + nodes { + decimals + name + symbol + id + assetType } + } } """ - stableswap_query_data = query_indexer(URL_UNIFIED_PROD, stableswap_asset_query)['data']['stableswapAssets']['nodes'] - stableswap_asset_data = {} - for node in sorted( - stableswap_query_data, - key=lambda n: n['asset']['id'] - ): - asset_id = str(node['asset']['id']) - if asset_id not in stableswap_asset_data: - stableswap_asset_data[asset_id] = { - 'pool_id': [node['pool']['id']], - 'decimals': node['asset']['decimals'], - 'symbol': node['asset']['symbol'], - 'name': node['asset']['name'] - } - else: - # same asset can be in more than one pool - stableswap_asset_data[asset_id]['pool_id'].append(node['pool']['id']) - - stableswap_pools = { - str(asset.id): StableSwapPoolState( - unique_id=asset.name, - tokens={node['name']: 0 for node in stableswap_asset_data.values() if asset.id in node['pool_id']}, - amplification=0 - ) for asset in get_asset_info_by_ids(stableswap_ids).values() + asset_data = { + asset['id']: { + 'decimals': asset['decimals'], + 'name': asset['name'], + 'symbol': asset['symbol'], + 'assetType': asset['assetType'] + } for asset in + query_indexer(URL_STABLESWAP_STORAGE, stableswap_asset_query)['data']['assets']['nodes'] } - current_block = block_number - max_queries = 20 - blocks_per_query = 100 - queries = 0 - asset_ids_remaining = list(stableswap_asset_data.keys()) - while len(asset_ids_remaining) > 0 and queries < max_queries: - query = f""" - query StableSwapPoolData {{ - stableswapAssetData( - filter: {{ - paraBlockHeight: {{ - lessThanOrEqualTo: {current_block}, - greaterThan: {current_block - blocks_per_query} - }} - }}, orderBy: PARA_BLOCK_HEIGHT_DESC - ) {{ - nodes {{ - balances - assetId - pool {{ - poolId - paraBlockHeight - pegs - fee - finalAmplification - }} - }} + for pool_id in stableswap_ids: + stableswap_id_query = f""" + query assetInfoByAssetIds {{ + stableswaps( + filter: {{id: {{startsWith: "{pool_id}-"}}, paraBlockHeight: {{lessThanOrEqualTo: {block_number}}}}} + last: 1 + ) {{ + nodes {{ + id + paraBlockHeight }} + }} + }} + """ + try: + data = query_indexer(URL_STABLESWAP_STORAGE, stableswap_id_query)['data']['stableswaps']['nodes'][0] + except IndexError: + print(f"No stableswap data found for pool_id {pool_id} at block {block_number}") + continue + pool_ids[pool_id] = data['id'] + + stableswap_pool_query = f""" + query assetInfoByAssetIds {{ + stableswap(id: "{pool_ids[pool_id]}") {{ + pegs + stableswapAssetDataByPoolId {{ + nodes {{ + assetId + balances + id + }} + }} + finalAmplification + fee + }} }} """ + data = query_indexer(URL_STABLESWAP_STORAGE, stableswap_pool_query)['data']['stableswap'] - # variables = {'assetIds': asset_ids_remaining} - data = query_indexer(URL_STABLESWAP_STORAGE, query) - for node in data['data']['stableswapAssetData']['nodes']: - asset_id = str(node['assetId']) - if asset_id not in asset_ids_remaining: - continue - asset_name = stableswap_asset_data[asset_id]['name'] - pool_id = str(node['pool']['poolId']) - block = node['pool']['paraBlockHeight'] - pool = stableswap_pools[pool_id] - pool.liquidity[asset_name] = int(node['balances']['d'][0]) / 10 ** stableswap_asset_data[asset_id]['decimals'] - if block > pool.time_step: - pool.trade_fee = float(node['pool']['fee'] / 1000000) - pool.amplification = node['pool']['finalAmplification'] - pegs = [ - int(node['pool']['pegs'][i][0]) / int(node['pool']['pegs'][i][1]) - for i in range(len(node['pool']['pegs']) - 1) - ] - if len(pegs) != len(pool.asset_list) - 1: - pass - pool.set_peg_target(pegs) - pool.set_peg(pegs) - pool.time_step = block - asset_ids_remaining.remove(asset_id) - - queries += 1 - current_block -= blocks_per_query + pegs = [ + int(data['pegs'][i][0]) / int(data['pegs'][i][1]) + for i in range(len(data['pegs']) - 1) + ] + if len(pegs) != len(data['stableswapAssetDataByPoolId']['nodes']) - 1: + raise ValueError("Peg length does not match asset list length") + + pool = StableSwapPoolState( + unique_id=asset_data[pool_id]['name'], + peg=pegs, + peg_target=pegs, + amplification=data['finalAmplification'], + trade_fee=data['fee'] / 1000000, + tokens={ + asset_data[str(asset['assetId'])]['name']: int(asset['balances']['d'][0]) / (10 ** asset_data[str(asset['assetId'])]['decimals']) + for asset in data['stableswapAssetDataByPoolId']['nodes'] + } + ) + stableswap_pools[pool_id] = pool - # there may be some tokens we can't get liquidity values for... in that case, guesstimate - for pool in stableswap_pools.values(): - for tkn in pool.liquidity: - if pool.liquidity[tkn] == 0: - pool.liquidity[tkn] = sum( - [ - pool.liquidity[t] * (1 if i == 0 else pool.peg[i]) - for i, t in enumerate(pool.asset_list) if t != tkn - ] - ) / (len(pool.asset_list) - 1) - - # get total issuance of shares - stableswap_share_assets = {tkn: all_assets[tkn] for tkn in all_assets if all_assets[tkn].id in stableswap_ids} - for pool_id in stableswap_ids: + # get total issuance of shares query = f""" query GetTotalIssuance {{ assetHistoricalData( first: 1 orderBy: PARA_BLOCK_HEIGHT_DESC - filter: {{assetId: {{equalTo: "{pool_id}"}}}} + filter: {{assetId: {{equalTo: "{pool_id}"}}, paraBlockHeight: {{lessThanOrEqualTo: {block_number}}}}} ) {{ nodes {{ assetId @@ -533,20 +703,28 @@ def get_current_stableswap_pools(block_number): }} }} """ - shares = int(query_indexer(URL_GENERIC_DATA, query)['data']['assetHistoricalData']['nodes'][0]['totalIssuance']) - shares /= 10 ** stableswap_share_assets[pool_id].decimals + data = query_indexer(URL_GENERIC_DATA, query) + try: + shares = int(data['data']['assetHistoricalData']['nodes'][0]['totalIssuance']) + except: + shares = sum(stableswap_pools[pool_id].liquidity.values()) # fallback if we can't get total issuance, not ideal but better than nothing + shares /= 10 ** asset_data[pool_id]["decimals"] stableswap_pools[pool_id].shares = shares return stableswap_pools -def get_omnipool_liquidity(block_number: int = None, assets: dict[str: AssetInfo] = None, max_queries: int = 10): +def get_omnipool_liquidity( + block_number: int = None, assets: dict[str, AssetInfo] = None, max_queries: int = 10 +) -> dict[str, dict]: asset_info = assets if assets else get_asset_info_by_ids() - asset_ids_remaining = [asset.id for asset in assets.values()] if assets else get_current_omnipool_assets() + asset_ids_remaining = [asset.id for asset in assets.values()] if assets else get_current_omnipool_asset_ids() if '1' not in asset_info: asset_info.update(get_asset_info_by_ids(['1'])) # ensure hub token info is present if '1' in asset_ids_remaining: asset_ids_remaining.remove('1') # hub token not needed + # remove asset_ids that can't convert into base 10 decimals + asset_ids_remaining = [asset_id for asset_id in asset_ids_remaining] liquidity = {} lrna = {} shares = {} @@ -583,13 +761,24 @@ def get_omnipool_liquidity(block_number: int = None, assets: dict[str: AssetInfo def get_current_omnipool(block_number = None): - asset_ids = get_current_omnipool_assets() + asset_ids = get_current_omnipool_asset_ids() asset_info = get_asset_info_by_ids(asset_ids + ['1']) max_block = get_current_block_height() if block_number is None else block_number liquidity_data = get_omnipool_liquidity(block_number=max_block, assets=asset_info) + if block_number >= 11666784: + hollar = AssetInfo( + asset_type="Hollar", + decimals=18, + id='0x531a654d1696ed52e7275a8cede955e82620f99a', + is_sufficient=False, + name="Hollar", + symbol="HOLLAR" + ) + asset_info[hollar.id] = hollar + liquidity_data["HOLLAR"] = get_hollar_liquidity_at(max_block) asset_fee, lrna_fee = get_current_omnipool_fees( - asset_info={tkn: asset_info[tkn] for tkn in asset_ids}, + asset_info={tkn: asset_info[tkn] for tkn in asset_info}, block_number=block_number ) @@ -603,9 +792,19 @@ def get_current_omnipool(block_number = None): def get_omnipool_trades( - asset_info: dict[str: AssetInfo] = None, + asset_info: dict[str, AssetInfo] = None, + min_block: int = None, + max_block: int = None, +): + extra_filter = 'name: {startsWith: "Omnipool", endsWith: "Executed"}' + return get_all_trades(asset_info, min_block, max_block, extra_filter) + + +def get_all_trades( + asset_info: dict[str, AssetInfo] = None, min_block: int = None, - max_block: int = None + max_block: int = None, + extra_filter: str = None ): if asset_info and isinstance(list(asset_info.keys())[0], int): raise TypeError("Asset info keys must be str.") @@ -622,7 +821,7 @@ def get_omnipool_trades( after: $after, orderBy: PARA_BLOCK_HEIGHT_ASC, filter: {{ - name: {{includes: "Omnipool"}}, + {extra_filter}{', ' if extra_filter else ''} paraBlockHeight: {{ greaterThanOrEqualTo: {min_block}, lessThanOrEqualTo: {max_block} @@ -658,28 +857,38 @@ def get_omnipool_trades( after_cursor = page_info['endCursor'] for trade in data_all: - args = { - arg[:arg.index(':')].strip('"'): arg[arg.index(':') + 1:].strip('"') - for arg in trade['args'].strip('}').strip('{').split(',') - } + if ':' in trade['args']: + args = json.loads(trade['args']) + else: + args = {} trade.pop("args") trade.update(args) - sell_id = args['assetIn'] - buy_id = args['assetOut'] - tkn_sell = asset_info[sell_id] - tkn_buy = asset_info[buy_id] - trade['assetIn'] = tkn_sell.unique_id - trade['assetOut'] = tkn_buy.unique_id - trade['amountIn'] = int(args['amountIn']) / (10 ** tkn_sell.decimals) if tkn_sell else None - trade['amountOut'] = int(args['amountOut']) / (10 ** tkn_buy.decimals) if tkn_buy else None - trade['protocolFeeAmount'] = int(args['protocolFeeAmount']) / (10 ** asset_info['1'].decimals) - trade['assetFeeAmount'] = int(args['assetFeeAmount']) / (10 ** tkn_buy.decimals) if tkn_buy else None - trade['hubAmountOut'] = int(args['hubAmountOut']) / (10 ** asset_info['1'].decimals) - trade['hubAmountIn'] = int(args['hubAmountIn']) / (10 ** asset_info['1'].decimals) - trade['assetFee'] = float(trade['assetFeeAmount']) / (trade['amountOut'] + trade['assetFeeAmount']) - if trade['hubAmountOut'] > 0: + if 'assetIn' in args and 'assetOut' in args: + sell_id = str(args['assetIn']) + buy_id = str(args['assetOut']) + if sell_id not in asset_info or buy_id not in asset_info: + continue + tkn_sell = asset_info[sell_id] + tkn_buy = asset_info[buy_id] + trade['assetIn'] = tkn_sell.unique_id + trade['assetOut'] = tkn_buy.unique_id + if 'amountIn' in args: + trade['amountIn'] = int(args['amountIn']) / (10 ** tkn_sell.decimals) if tkn_sell else None + if 'amountOut' in args: + trade['amountOut'] = int(args['amountOut']) / (10 ** tkn_buy.decimals) if tkn_buy else None + if 'protocolFeeAmount' in args: + trade['protocolFeeAmount'] = int(args['protocolFeeAmount']) / (10 ** asset_info['1'].decimals) + if 'assetFeeAmount' in args: + trade['assetFeeAmount'] = int(args['assetFeeAmount']) / (10 ** tkn_buy.decimals) if tkn_buy else None + if 'hubAmountOut' in args: + trade['hubAmountOut'] = int(args['hubAmountOut']) / (10 ** asset_info['1'].decimals) + if 'hubAmountIn' in args: + trade['hubAmountIn'] = int(args['hubAmountIn']) / (10 ** asset_info['1'].decimals) + if 'assetFeeAmount' in trade: + trade['assetFee'] = float(trade['assetFeeAmount']) / (trade['amountOut'] + trade['assetFeeAmount']) + if 'hubAmountOut' in trade and trade['hubAmountOut'] > 0 and 'protocolFeeAmount' in trade: trade['protocolFee'] = float(trade['protocolFeeAmount']) / trade['hubAmountOut'] - if trade['amountIn'] > 0: + if 'amountIn' in trade and trade['amountIn'] > 0 and 'assetFeeAmount' in trade: trade['asset_fee'] = float(trade['assetFeeAmount']) / (float(trade['amountOut']) + float(trade['assetFeeAmount'])) trade['block_number'] = trade.pop('paraBlockHeight') return data_all @@ -695,7 +904,7 @@ def get_current_omnipool_fees( if block_number is None: block_number = get_current_block_height() if asset_info is None: - asset_info = get_asset_info_by_ids(get_current_omnipool_assets()) + asset_info = get_asset_info_by_ids(get_current_omnipool_asset_ids()) asset_fee = settings.omnipool_asset_fee lrna_fee = settings.omnipool_lrna_fee @@ -740,22 +949,23 @@ def get_current_omnipool_fees( for arg in trade['args'].strip('}').strip('{').split(',') } block = trade['paraBlockHeight'] - sell_id = args['assetIn'] - buy_id = args['assetOut'] - tkn_sell = asset_info[sell_id] if sell_id in asset_info else None - tkn_buy = asset_info[buy_id] if buy_id in asset_info else None - if tkn_sell and tkn_sell.id in asset_ids_remaining: - if tkn_sell.symbol not in lrna_fee.current and float(args['hubAmountOut']) > 0: - lrna_fee.current[tkn_sell.symbol] = float(args['protocolFeeAmount']) / float(args['hubAmountOut']) - lrna_fee.last_updated[tkn_sell.symbol] = block - if args['assetIn'] in asset_fee.current: - asset_ids_remaining.remove(args['assetIn']) - if tkn_buy and tkn_buy.id in asset_ids_remaining: - if tkn_buy.symbol not in asset_fee.current and float(args['amountIn']) > 0: - asset_fee.current[tkn_buy.symbol] = float(args['assetFeeAmount']) / (float(args['amountOut']) + float(args['assetFeeAmount'])) - asset_fee.last_updated[tkn_buy.symbol] = block - if tkn_buy.symbol in lrna_fee.current: - asset_ids_remaining.remove(args['assetOut']) + if 'assetIn' in args and 'assetOut' in args: + sell_id = args['assetIn'] + buy_id = args['assetOut'] + tkn_sell = asset_info[sell_id] if sell_id in asset_info else None + tkn_buy = asset_info[buy_id] if buy_id in asset_info else None + if tkn_sell and tkn_sell.id in asset_ids_remaining: + if tkn_sell.symbol not in lrna_fee.current and float(args['hubAmountOut']) > 0: + lrna_fee.current[tkn_sell.symbol] = float(args['protocolFeeAmount']) / float(args['hubAmountOut']) + lrna_fee.last_updated[tkn_sell.symbol] = block + if args['assetIn'] in asset_fee.current: + asset_ids_remaining.remove(args['assetIn']) + if tkn_buy and tkn_buy.id in asset_ids_remaining: + if tkn_buy.symbol not in asset_fee.current and float(args['amountIn']) > 0: + asset_fee.current[tkn_buy.symbol] = float(args['assetFeeAmount']) / (float(args['amountOut']) + float(args['assetFeeAmount'])) + asset_fee.last_updated[tkn_buy.symbol] = block + if tkn_buy.symbol in lrna_fee.current: + asset_ids_remaining.remove(args['assetOut']) queries += 1 current_block -= blocks_per_query @@ -777,7 +987,7 @@ def get_current_omnipool_router(block_number: int = None): if block_number is None: block_number = get_current_block_height() omnipool = get_current_omnipool(block_number) - stableswap_pools = get_current_stableswap_pools(block_number).values() + stableswap_pools = get_stableswap_pools(block_number).values() return OmnipoolRouter( exchanges=[ @@ -1186,3 +1396,136 @@ def download_acct_trades(asset_id: str, acct: str, path: str, min_block: int = N with open(f"{path}acct_swaps_{asset_id}_{acct}.json", "w") as f: json.dump(trades, f) + + +def get_omnipool_liquidity_at_intervals( + interval: datetime.timedelta, + start_time: datetime.datetime, + asset_ids: str or list = None, + end_time: datetime.datetime = None, + max_workers: int = 10 +) -> dict[int, dict[str, dict]]: + """ + Fetches historical liquidity using threaded batched GraphQL queries. + """ + if end_time is None: + end_time = datetime.datetime.now() + if isinstance(asset_ids, str): + asset_ids = [asset_ids] + if asset_ids is None: + asset_ids = get_current_omnipool_asset_ids() + asset_info = get_asset_info_by_ids(asset_ids) + + def _fetch_liquidity_batch(chunk): + """Worker to fetch liquidity for a batch of (ts, block, asset_id) tuples.""" + local_results = [] + query_parts = [] + + for i, (ts, block, asset_id) in enumerate(chunk): + query_parts.append(f""" + q_{i}: omnipoolAssetData( + last: 1, + filter: {{ + assetId: {{equalTo: {asset_id}}}, + paraBlockHeight: {{equalTo: {block}}} + }} + ) {{ + nodes {{ + assetId + balances + assetState + }} + }} + """) + + full_query = "query batch_liquidity { " + ",".join(query_parts) + " }" + + response = query_indexer(url=URL_OMNIPOOL_STORAGE, query=full_query) + + for i, (ts, block, asset_id) in enumerate(chunk): + asset_obj = asset_info[str(asset_id)] + try: + node = response['data'][f"q_{i}"]['nodes'][0] + hub_decimals = asset_info['1'].decimals if '1' in asset_info else 12 + + balance_d = int(node['balances']['d'][0]) if node.get('balances') else 0 + state_d = node['assetState']['d'] if node.get('assetState') else [0, 0, 0] + + record = { + "liquidity": balance_d / 10 ** asset_obj.decimals, + "LRNA": int(state_d[0]) / 10 ** hub_decimals, + "shares": int(state_d[1]) / 10 ** asset_obj.decimals, + "protocol_shares": int(state_d[2]) / 10 ** asset_obj.decimals, + } + local_results.append((ts, asset_obj.unique_id, record)) + + except (KeyError, IndexError, TypeError): + zero_record = {"liquidity": 0, "LRNA": 0, "shares": 0, "protocol_shares": 0} + local_results.append((ts, asset_obj.unique_id, zero_record)) + + return local_results + + print("Resolving timestamps to block numbers (Threaded)...") + + timestamps = [] + curr = start_time + while curr <= end_time: + timestamps.append(curr) + curr += interval + timestamp_to_block = get_blocks_at_timestamps(timestamps) + + print("Fetching liquidity snapshots (Threaded)...") + + tasks = [] + batch_size = 10 + for ts, block in timestamp_to_block.items(): + if block is None: continue + for asset_id in asset_ids: + tasks.append((ts, block, asset_id)) + + results_map = {ts: {} for ts in timestamps} + + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit(_fetch_liquidity_batch, chunk) + for chunk in chunks(tasks, batch_size) + ] + + for future in concurrent.futures.as_completed(futures): + try: + batch_results = future.result() + for (ts, unique_id, record) in batch_results: + results_map[ts][unique_id] = record + except Exception as e: + print(f"Error in Phase 2 worker: {e}") + + final_output = {} + for ts in sorted(results_map.keys()): + final_output[timestamp_to_block[ts]] = {'time': ts.strftime("%Y-%m-%d-%H:%M"), **results_map[ts]} + + return final_output + + +def get_dates_of_blocks(block_numbers: list[int]) -> dict[int, datetime.datetime]: + """Fetches the dates for a list of block numbers.""" + block_to_date = {} + # check omnipool_block_cache.json + with open(Path(__file__).parent / 'cache' / 'omnipool_block_cache.json', 'r') as f: + block_cache = json.load(f) + dates = list(block_cache.keys()) + blocks = list(block_cache.values()) + for block in block_numbers: + # find the next lowest block in cache and return that date + for i in range(len(blocks)): + if int(block) <= blocks[i] and block not in block_to_date: + date_str = dates[i - 1] + block_to_date[block] = date_str + break + + return block_to_date + + +def get_date_of_block(block_number: int) -> datetime.datetime: + """Fetches the date for a single block number.""" + block_to_date = get_dates_of_blocks([block_number]) + return block_to_date[block_number] diff --git a/hydradx/model/processing.py b/hydradx/model/processing.py index a7ab6be63..bf225a9a0 100644 --- a/hydradx/model/processing.py +++ b/hydradx/model/processing.py @@ -1,11 +1,13 @@ import base64 -import datetime +from datetime import datetime, timedelta, timezone import dateutil.parser import json import os import time from csv import reader from zipfile import ZipFile +from pathlib import Path +import pandas as pd import requests from dotenv import load_dotenv @@ -107,7 +109,7 @@ def import_binance_prices( stablecoin: str = 'USDT', return_as_dict: bool = False ) -> dict[str: list[float]]: start_date = dateutil.parser.parse(start_date) - dates = [datetime.datetime.strftime(start_date + datetime.timedelta(days=i), "%Y-%m-%d") for i in range(days)] + dates = [datetime.strftime(start_date + timedelta(days=i), "%Y-%m-%d") for i in range(days)] if isinstance(assets, str): assets = [assets] # find the data folder while not os.path.exists("./data"): @@ -159,8 +161,8 @@ def import_monthly_binance_prices( ) -> dict[str: list[float]]: start_mth, start_year = start_month.split(' ') - start_date = datetime.datetime.strptime(start_mth + ' 15 ' + start_year, "%b %d %Y") - dates = [datetime.datetime.strftime(start_date + datetime.timedelta(days=i * 30), "%Y-%m") for i in range(months)] + start_date = datetime.strptime(start_mth + ' 15 ' + start_year, "%b %d %Y") + dates = [datetime.strftime(start_date + timedelta(days=i * 30), "%Y-%m") for i in range(months)] # find the data folder while not os.path.exists("./data"): @@ -462,14 +464,19 @@ def get_current_omnipool_router(rpc='wss://rpc.hydradx.cloud') -> OmnipoolRouter return router -def save_omnipool(omnipool_router: OmnipoolRouter, path: str = './archive'): - ts = time.time() +def save_state(omnipool_router: OmnipoolRouter, path: str or Path = './archive', filename: str = ''): + filepath = Path(path) + + if not filename: + ts = time.time() + filename = f'omnipool_savefile_{ts}.json' + omnipool = omnipool_router.exchanges.get('omnipool', None) stableswap_pools = [] for exchange_id in omnipool_router.exchanges: if isinstance(omnipool_router.exchanges[exchange_id], StableSwapPoolState): stableswap_pools.append(omnipool_router.exchanges[exchange_id]) - with open(os.path.join(path, f'omnipool_savefile_{ts}.json'), 'w+') as output_file: + with open(filepath / filename, 'w+') as output_file: json.dump( { 'liquidity': omnipool.liquidity, @@ -493,11 +500,15 @@ def save_omnipool(omnipool_router: OmnipoolRouter, path: str = './archive'): ) -def load_omnipool(path: str = './archive', filename: str = '') -> OmnipoolRouter: +def load_state(path: str or Path = './cached data', filename: str = '') -> OmnipoolRouter: + filepath = Path(path) + if filepath.is_file(): + filename = filepath.name if filename: file_ls = [filename] else: - file_ls = list(filter(lambda file: file.startswith('omnipool_savefile'), os.listdir(path))) + # get files starting with 'omnipool' from the directory + file_ls = [f for f in os.listdir(path) if f.startswith('omnipool')] for filename in reversed(sorted(file_ls)): # by default, load the latest first with open(os.path.join(path, filename), 'r') as input_file: json_state = json.load(input_file) diff --git a/hydradx/other tests/misc.py b/hydradx/other tests/misc.py new file mode 100644 index 000000000..97af16c9b --- /dev/null +++ b/hydradx/other tests/misc.py @@ -0,0 +1,775 @@ +import datetime +import pytest +from hypothesis import given, strategies as st, assume, settings, reproduce_failure +from mpmath import mp, mpf +import os +from pathlib import Path + +os.chdir('../..') + +from hydradx.model.indexer_utils import get_current_omnipool, get_current_omnipool_router, get_blocks_at_timestamps \ + , get_omnipool_trades, get_asset_info_by_ids, get_omnipool_liquidity_at_intervals \ + , get_omnipool_liquidity, get_stableswap_pools +from hydradx.model import run +from hydradx.model.processing import load_state +from hydradx.model.amm import omnipool_amm as oamm +from hydradx.model.amm.agents import Agent +from hydradx.model.amm.global_state import GlobalState +from hydradx.model.run import run +from hydradx.model.amm.omnipool_amm import OmnipoolState, OmnipoolLiquidityPosition +from hydradx.model.amm.trade_strategies import omnipool_arbitrage + +def test_price_change_from_trade(): + liquidity = {'HDX': mpf(10000000), 'USD': mpf(1000000)} + lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000)} + initial_state = oamm.OmnipoolState( + tokens={ + tkn: {'liquidity': liquidity[tkn], 'LRNA': lrna[tkn]} for tkn in lrna + }, + asset_fee=0.0025, + lrna_fee=0.0005, + slip_factor=0, + lrna_mint_pct=1.0, + lrna_fee_burn=0 + ) + initial_state.max_lrna_fee = 0.01 + agent = Agent(enforce_holdings=False) + sell_quantity = mpf(10000) + omnipool = initial_state.copy() + for i in range(1000): + omnipool.swap( + agent=agent, + tkn_sell='USD', + tkn_buy='HDX', + sell_quantity=sell_quantity + ) + omnipool.swap( + agent=agent, + tkn_sell='HDX', + tkn_buy='USD', + sell_quantity=agent.holdings['HDX'] + ) + omnipool.swap(tkn_sell="HDX", tkn_buy="USD", buy_quantity=-agent.holdings['USD'] / 2, agent=agent) + start_price_usd = initial_state.liquidity['USD'] / initial_state.lrna['USD'] + end_price_usd = omnipool.liquidity['USD'] / omnipool.lrna['USD'] + start_price_hdx = initial_state.liquidity['HDX'] / initial_state.lrna['HDX'] + end_price_hdx = omnipool.liquidity['HDX'] / omnipool.lrna['HDX'] + omnipool.lrna['USD'] += omnipool.lrna_fee_destination.holdings['LRNA'] / 2 + omnipool.lrna['HDX'] += omnipool.lrna_fee_destination.holdings['LRNA'] / 2 + projected_end_price_usd = omnipool.liquidity['USD'] / omnipool.lrna['USD'] + projected_end_price_hdx = omnipool.liquidity['HDX'] / omnipool.lrna['HDX'] + pass + + +def test_price_after_fees(): + omnipool = OmnipoolState( + tokens={ + 'HDX': {'liquidity': mpf(10000000), 'LRNA': mpf(10000)}, + 'USD': {'liquidity': mpf(100000), 'LRNA': mpf(10000)} + }, + asset_fee=0.00125, + lrna_fee=0.00025, + slip_factor=1.0, # 1.0, + lrna_fee_burn=1.0, + lrna_mint_pct=1.0 + ) + omnipool.max_lrna_fee = 0.01 + agent = Agent(enforce_holdings=False) + start_price = omnipool.lrna_price("HDX") + for _ in range(10): + omnipool.swap(agent, tkn_sell='HDX', tkn_buy='USD', sell_quantity=mpf(100000)) + + for _ in range(10): + omnipool.swap(agent, tkn_sell='USD', tkn_buy='HDX', buy_quantity=mpf(100000)) + end_price = omnipool.lrna_price("HDX") + pass + + +def test_adot_minting_lp(): + initial_omnipool = OmnipoolState( + tokens={ + 'HDX': {'liquidity': mpf(100000000), 'LRNA': mpf(1000000)}, + 'aDOT': {'liquidity': mpf(1000000), 'LRNA': mpf(1000000)}, + 'USD': {'liquidity': mpf(20000000), 'LRNA': mpf(20000000)} + }, + preferred_stablecoin='USD' + ) + omnipool = initial_omnipool.copy() + no_lp_omnipool = omnipool.copy() + yield_per_block = mpf(1) / 100 + sim_length = 1 + lp = Agent(holdings={'aDOT': mpf(10000)}) + holder = lp.copy() + + omnipool.add_liquidity( + agent = lp, + tkn_add = 'aDOT', + quantity = lp.holdings['aDOT'] + ) + for _ in range(sim_length): + omnipool.liquidity['aDOT'] *= 1 + yield_per_block # simulate aDOT appreciation + no_lp_omnipool.liquidity['aDOT'] *= 1 + yield_per_block # simulate aDOT appreciation + holder.holdings['aDOT'] *= 1 + yield_per_block + # omnipool.lrna['aDOT'] *= 1 + yield_per_block # simulate LRNA appreciation + + arbitrageur = Agent( + enforce_holdings=False, + trade_strategy=omnipool_arbitrage('omnipool') + ) + early_exit_lp = lp.copy() + no_exit_omnipool = omnipool.copy() + omnipool.copy().remove_liquidity( + agent=early_exit_lp, + tkn_remove='aDOT' + ) + end_state = GlobalState( + pools=[omnipool], + agents=[arbitrageur], + external_market={tkn: initial_omnipool.usd_price(tkn) for tkn in initial_omnipool.liquidity} + ).evolve() + omnipool.remove_liquidity( + agent = lp, + tkn_remove = 'aDOT' + ) + diff = lp.holdings['aDOT'] - holder.holdings['aDOT'] + holder_gain = holder.holdings['aDOT'] - holder.initial_holdings['aDOT'] + reward_gain_pct = (holder_gain + diff) / holder_gain * 100 + pass + + +def calculate_arb( + omnipool: OmnipoolState, + tkn_sell: str, + tkn_buy: str, + target_price: float +): + overshot = False + sell_quantity = 1 + delta = 0.5 + for j in range(200): + buy_quantity, delta_q_hdx, delta_q_usd, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell = omnipool.calculate_out_given_in( + tkn_buy=tkn_buy, tkn_sell=tkn_sell, sell_quantity=sell_quantity + ) + after_state = { + tkn_sell: { + 'liquidity': omnipool.liquidity[tkn_sell] + sell_quantity, + 'LRNA': omnipool.lrna[tkn_sell] + delta_q_hdx + }, + tkn_buy: { + 'liquidity': omnipool.liquidity[tkn_buy] - buy_quantity, + 'LRNA': omnipool.lrna[tkn_buy] + delta_q_usd + } + } + after_price = after_state[tkn_sell]['LRNA'] / after_state[tkn_sell]['liquidity'] * after_state[tkn_buy]['liquidity'] / \ + after_state[tkn_buy]['LRNA'] + if after_price < target_price: + if not overshot: + delta /= 2 + overshot = True + sell_quantity -= delta + else: + sell_quantity += delta + if overshot: + delta /= 2 + else: + delta *= 2 + + return sell_quantity + + +def test_lp_results_with_slip_fees(): + initial_omnipool = OmnipoolState( + tokens={ + 'HDX': {'liquidity': mpf(90000), 'LRNA': mpf(900)}, + 'USD': {'liquidity': mpf(90), 'LRNA': mpf(90)} + }, + preferred_stablecoin='USD', + slip_factor=1.0, + lrna_fee=0.0005, + asset_fee=0.00125, + # lrna_mint_pct=0, + # lrna_fee_burn=0, + withdrawal_fee=False + ) + initial_omnipool.max_lrna_fee = 0.05 + initial_omnipool.max_asset_fee = 0.05 + lp_usd = Agent(holdings={'USD': mpf(10)}) + lp_hdx = Agent(holdings={'HDX': mpf(10000)}) + initial_omnipool.add_liquidity( + agent=lp_usd, + tkn_add='USD', + quantity=lp_usd.holdings['USD'] + ) + initial_omnipool.add_liquidity( + agent=lp_hdx, + tkn_add='HDX', + quantity=lp_hdx.holdings['HDX'] + ) + omnipool = initial_omnipool.copy() + treasury_agent = Agent(enforce_holdings=False, unique_id='treasury') + omnipool.lrna_fee_destination = treasury_agent + + trader = Agent(enforce_holdings=False, unique_id='trader') + for i in range(1): + omnipool.swap( + agent=trader, + tkn_sell='USD', + tkn_buy='HDX', + sell_quantity=mpf(10) + ) + target_price = initial_omnipool.usd_price('HDX') + sell_quantity = calculate_arb( + omnipool=omnipool, + tkn_sell='HDX', + tkn_buy='USD', + target_price=target_price + ) + omnipool.swap( + agent=trader, + tkn_sell='HDX', + tkn_buy='USD', + sell_quantity=sell_quantity + ) + + print("adding LRNA back into pools...") + usd_lrna_ratio = omnipool.lrna['USD'] / sum(omnipool.lrna.values()) + omnipool.lrna['USD'] += treasury_agent.holdings['LRNA'] * usd_lrna_ratio + omnipool.lrna['HDX'] += treasury_agent.holdings['LRNA'] * (1 - usd_lrna_ratio) + + pre_withdraw_omnipool = omnipool.copy() + print(f""" + after swaps: + (HDX: {round(omnipool.liquidity['HDX'], 6)}, LRNA: {round(omnipool.lrna['HDX'], 6)}) + (USD: {round(omnipool.liquidity['USD'], 6)}, LRNA: {round(omnipool.lrna['USD'], 6)}) + HDX/USD price: {pre_withdraw_omnipool.usd_price('HDX')} + """) + + omnipool.remove_liquidity( + agent=lp_hdx, + tkn_remove='HDX' + ) + omnipool.remove_liquidity( + agent=lp_usd, + tkn_remove='USD' + ) + diff_usd = lp_usd.get_holdings('USD') - lp_usd.initial_holdings['USD'] + diff_hdx = (lp_hdx.get_holdings('HDX') - lp_hdx.initial_holdings['HDX']) * omnipool.usd_price('HDX') + print(f""" + LPs withdraw: + USD: {lp_usd.holdings['USD']} + HDX: {lp_hdx.holdings['HDX'] * omnipool.usd_price('HDX')} + """) + pass + + +def test_out_given_in(): + omnipool = OmnipoolState( + tokens={ + 'HDX': {'liquidity': mpf(9000000), 'LRNA': mpf(900000)}, + 'USD': {'liquidity': mpf(90000), 'LRNA': mpf(90000)} + }, + preferred_stablecoin='USD', + slip_factor=1, + lrna_fee_burn=0.5, + lrna_mint_pct=0, + asset_fee=0.0025, + lrna_fee=0.0005, + # lrna_fee_destination=Agent(enforce_holdings=False, unique_id='treasury') + ) + omnipool.max_lrna_fee = 0.1 + omnipool.max_asset_fee = 0.1 + + trader = Agent(enforce_holdings=False, unique_id='trader') + hdx_sell_quantity = mpf(1002340) + usd_buy, delta_q_hdx, delta_q_usd, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell \ + = omnipool.calculate_out_given_in( + tkn_buy='USD', tkn_sell='HDX', sell_quantity=hdx_sell_quantity + ) + after_state = { + 'HDX': { + 'liquidity': omnipool.liquidity['HDX'] + hdx_sell_quantity, + 'LRNA': omnipool.lrna['HDX'] + delta_q_hdx + }, + 'USD': { + 'liquidity': omnipool.liquidity['USD'] - usd_buy, + 'LRNA': omnipool.lrna['USD'] + delta_q_usd + } + } + + omnipool.swap( + agent=trader, + tkn_sell='HDX', + tkn_buy='USD', + sell_quantity=hdx_sell_quantity + ) + assert omnipool.lrna['HDX'] == pytest.approx(after_state['HDX']['LRNA'], rel=1e-12) + assert omnipool.liquidity['HDX'] == pytest.approx(after_state['HDX']['liquidity'], rel=1e-12) + assert omnipool.lrna['USD'] == pytest.approx(after_state['USD']['LRNA'], rel=1e-12) + assert omnipool.liquidity['USD'] == pytest.approx(after_state['USD']['liquidity'], rel=1e-12) + pass + + +def test_lp_loss_with_deposit(): + treasury_agent = Agent(enforce_holdings=False, unique_id='treasury') + initial_omnipool = OmnipoolState( + tokens={ + 'HDX': {'liquidity': mpf(10000000), 'LRNA': mpf(1000000)}, + 'USD': {'liquidity': mpf(100000), 'LRNA': mpf(1000000)} + }, + preferred_stablecoin='USD', + # slip_factor=1, + lrna_fee=0, + asset_fee=0, + lrna_fee_destination=treasury_agent, + withdrawal_fee=False, + lrna_mint_pct=0, + lrna_fee_burn=0 + ) + omnipool = initial_omnipool.copy() + lp_hdx = Agent(holdings={'HDX': mpf(10000)}) + lp_usd = Agent(holdings={'USD': mpf(1000)}) + start_value_hdx = lp_hdx.holdings['HDX'] * omnipool.usd_price('HDX') + omnipool.add_liquidity( + agent=lp_hdx, + tkn_add='HDX', + quantity=lp_hdx.holdings['HDX'] + ) + omnipool.add_liquidity( + agent=lp_usd, + tkn_add='USD', + quantity=lp_usd.holdings['USD'] + ) + omnipool.lrna['HDX'] *= 2 # dump some free LRNA into HDX pool + sell_quantity = calculate_arb( + omnipool=omnipool, + tkn_sell='HDX', + tkn_buy='USD', + target_price=initial_omnipool.usd_price('HDX') + ) + omnipool.swap( + agent=Agent(enforce_holdings=False), + tkn_sell='HDX', + tkn_buy='USD', + sell_quantity=sell_quantity + ) + end_value_hdx = omnipool.cash_out(lp_hdx, denomination='USD') # gains, but less than 50% of the liquidity increase + end_value_usd = omnipool.cash_out(lp_usd, denomination='USD') # loses a bit + pass + + +def test_lrna_price_on_withdrawal(): + initial_omnipool = OmnipoolState( + tokens={ + **{f"token{i}": {'liquidity': mpf(1100000), 'LRNA': mpf(1100000)} for i in range(1, 10)}, + 'USD': {'liquidity': mpf(10000000), 'LRNA': mpf(100000000)}, + 'HDX': {'liquidity': mpf(10000000), 'LRNA': mpf(1000000)} + }, + preferred_stablecoin='USD', + slip_factor=1, + lrna_fee=0, + asset_fee=0, + lrna_mint_pct=0.0, + lrna_fee_burn=0.0, + withdrawal_fee=False + ) + lps = { + i: Agent(enforce_holdings=False, unique_id=f'lp_{i}') for i in range(1, 10) + } + trader = Agent(enforce_holdings=False, unique_id='trader') + omnipool = initial_omnipool.copy() + # lp_hdx = Agent(holdings={'HDX': omnipool.liquidity['HDX']}) + initial_lrna_price = omnipool.usd_price('LRNA') + for i in range(1, 10): + omnipool.add_liquidity( + agent=lps[i], + tkn_add=f'token{i}', + quantity=omnipool.liquidity[f'token{i}'] + ) + for i in range(1, 10): + omnipool.swap( + agent=trader, + tkn_sell=f'token{i}', + tkn_buy='USD', + sell_quantity=omnipool.liquidity[f'token{i}'] / 10 + ) + for i in range(1, 10): + omnipool.remove_liquidity( + agent=lps[i], + tkn_remove=f'token{i}' + ) + final_lrna_price = omnipool.usd_price('LRNA') + lrna_price_drop = 1 - final_lrna_price / initial_lrna_price + + tkn_price_drop = { + f'token{i}': 1 - omnipool.usd_price(f'token{i}') / initial_omnipool.usd_price(f'token{i}') for i in range(1, 10) + } + pass + + +def test_bitcoin_pump(): + router = get_current_omnipool_router(block_number=6600000) + omnipool = router.exchanges['omnipool'] + initial_omnipool = omnipool.copy() + arbitrageur = Agent( + enforce_holdings=False, + trade_strategy=omnipool_arbitrage('omnipool'), + unique_id='arbitrageur' + ) + trader = Agent(enforce_holdings=False, unique_id='trader') + initial_state = GlobalState( + pools=[omnipool], + agents=[arbitrageur, trader], + external_market={tkn: router.price(tkn, 'USDT') for tkn in router.asset_list} + ) + router.swap( + agent=trader, + tkn_sell='USD', + tkn_buy='BTC', + buy_quantity=mpf(10000) + ) + # simulate a BTC pump + omnipool.liquidity['BTC'] *= 1.5 + + sell_quantity = calculate_arb( + omnipool=omnipool, + tkn_sell='BTC', + tkn_buy='USD', + target_price=initial_omnipool.usd_price('BTC') + ) + omnipool.swap( + agent=trader, + tkn_sell='BTC', + tkn_buy='USD', + sell_quantity=sell_quantity + ) + pass + + +def test_bitcoin_pump_2(): + omnipool = OmnipoolState( + tokens={ + 'BTC': {'liquidity': 1, 'LRNA': 1000}, + 'HDX': {'liquidity': 100000, 'LRNA': 1000}, + 'USD': {'liquidity': 1000, 'LRNA': 1000} + }, + asset_fee=0, + lrna_fee=0, + lrna_mint_pct=0, + lrna_fee_burn=0, + slip_factor=0, + preferred_stablecoin='USD' + ) + arbitrageur = Agent( + trade_strategy=omnipool_arbitrage('omnipool', skip_assets='HDX'), + enforce_holdings=False, + unique_id='arbitrageur' + ) + initial_lrna_price = omnipool.usd_price('LRNA') + initial_state = GlobalState( + pools=[omnipool], + agents=[arbitrageur], + external_market={ + 'USD': 1, + 'HDX': 0.01, + 'BTC': 500 + } + ) + end_state = run(initial_state, 1)[0] + final_lrna_price = end_state.pools['omnipool'].usd_price('LRNA') + pass + + +def test_slip_fees_november(): + dates = [ + datetime.datetime(2025, 11,i+1) for i in range(30) + ] + [ + datetime.datetime(year=2025, month=12, day=i+1) for i in range(31) + ] + block_numbers = list(get_blocks_at_timestamps(dates).values()) + slip_fees_lrna = [] + regular_fees_lrna = [] + trade_volume = [] + for i in range(len(block_numbers) - 1): + slip_fees_today = mpf(0) + regular_fees_today = mpf(0) + trade_volume_today = mpf(0) + start_block = block_numbers[i] + end_block = block_numbers[i + 1] + omnipool = get_current_omnipool(block_number=start_block) + omnipool.slip_factor = 1.0 + omnipool.max_asset_fee = 0.05 + omnipool.max_lrna_fee = 0.01 + lrna_price_in_usd = 1 / omnipool.lrna_price('2-Pool-Stbl') + trades = get_omnipool_trades( + min_block=start_block, + max_block=end_block + ) + for trade in trades: + tkn_buy = trade['assetOut'] + tkn_sell = trade['assetIn'] + sell_quantity = trade['amountIn'] + + if tkn_sell == 'H2O': + tkn_sell = 'LRNA' + if tkn_sell in omnipool.asset_list and tkn_buy in omnipool.asset_list: + asset_fee_lrna = trade['assetFeeAmount'] * omnipool.lrna_price(tkn_buy) + lrna_fee = trade['protocolFeeAmount'] + regular_fees_today += asset_fee_lrna + lrna_fee + trade_volume_today += sell_quantity * omnipool.lrna_price(tkn_sell) * lrna_price_in_usd + outputs = omnipool.calculate_out_given_in( + tkn_buy=tkn_buy, + tkn_sell=tkn_sell, + sell_quantity=sell_quantity + ) + slip_fees_today += outputs[-2] + outputs[-1] + else: + pass + + print(f"{dates[i]} slip fees (USD) {slip_fees_today * lrna_price_in_usd}") + print(f"regular fees: {regular_fees_today * lrna_price_in_usd} ({round(slip_fees_today / regular_fees_today * 100, 3)}%)") + regular_fees_lrna.append(regular_fees_today) + slip_fees_lrna.append(slip_fees_today) + trade_volume.append(trade_volume_today) + pass + +def test_dot_crash(): + import datetime + from hydradx.model.indexer_utils import get_omnipool_liquidity_at_intervals + interval = datetime.timedelta(days=365) + end_date = datetime.datetime.today() + blocks = list(get_blocks_at_timestamps([end_date - interval, end_date]).values()) + balances = [get_omnipool_liquidity(block) for block in blocks] + omnipool_2024 = OmnipoolState( + tokens={tkn: {'liquidity': balances[0][tkn]['liquidity'], 'LRNA': balances[0][tkn]['LRNA']} for tkn in balances[0]} + ) + omnipool_2025 = OmnipoolState( + tokens={tkn: {'liquidity': balances[1][tkn]['liquidity'], 'LRNA': balances[1][tkn]['LRNA']} for tkn in balances[1]} + ) + stablepool_2024 = get_stableswap_pools(blocks[0], 102)['102'] + stablepool_2025 = get_stableswap_pools(blocks[1], 102)['102'] + share_price_2024 = sum(stablepool_2024.liquidity.values()) / stablepool_2024.shares + share_price_2025 = sum(stablepool_2025.liquidity.values()) / stablepool_2025.shares + omnipool_2024.liquidity['USD'] = 1 / omnipool_2024.lrna_price('2-Pool-Stbl') * share_price_2024 + omnipool_2025.liquidity['USD'] = 1 / omnipool_2025.lrna_price('2-Pool-Stbl') * share_price_2025 + omnipool_2024.lrna['USD'] = 1 + omnipool_2025.lrna['USD'] = 1 + omnipool_2024.stablecoin = 'USD' + omnipool_2025.stablecoin = 'USD' + dot_price_2024 = omnipool_2024.price('DOT', '2-Pool-Stbl') * share_price_2024 + dot_price_2025 = omnipool_2025.price('DOT', '2-Pool-Stbl') * share_price_2025 + sell_quantity = calculate_arb( + omnipool_2024, tkn_sell="DOT", tkn_buy="2-Pool-Stbl", target_price=omnipool_2025.price("DOT", "2-Pool-Stbl") + ) + omnipool_dot_selloff = omnipool_2024.copy().swap( + agent = Agent(enforce_holdings=False), + tkn_sell="DOT", + tkn_buy="2-Pool-Stbl", + sell_quantity=sell_quantity + ) + hdx_price_2024 = omnipool_2024.usd_price("HDX") + hdx_price_2025 = omnipool_2025.usd_price("HDX") + hdx_price_dot_selloff = omnipool_dot_selloff.price("HDX", "2-Pool-Stbl") * share_price_2025 + avg_asset_price_2024 = sum([ + omnipool_2024.usd_price(tkn) * omnipool_2024.lrna[tkn] / sum(omnipool_2024.lrna.values()) + for tkn in omnipool_2024.liquidity + ]) + avg_asset_price_2025 = sum([ + omnipool_2025.usd_price(tkn) * omnipool_2025.lrna[tkn] / sum(omnipool_2025.lrna.values()) + for tkn in omnipool_2025.liquidity + ]) + pass + + +def test_progressive_liquidity_removal(): + agents = [Agent(holdings={'USD': 1000, 'HDX': 1000}) for i in range(10)] + omnipool = OmnipoolState( + tokens={ + 'HDX': {'liquidity': 10000, 'LRNA': 10}, + 'USD': {'liquidity': 1000, 'LRNA': 100}, + 'DOT': {'liquidity': 500, 'LRNA': 100} + }, + preferred_stablecoin='USD', + withdrawal_fee=False + ) + start_hdx_price = omnipool.usd_price('HDX') + for agent in agents: + omnipool.add_liquidity( + agent, quantity=agent.holdings['HDX'], tkn_add='HDX' + ) + omnipool.add_liquidity( + agent, quantity=agent.holdings['USD'], tkn_add='USD' + ) + omnipool.swap( + agent=Agent(), + tkn_buy='HDX', + tkn_sell='DOT', + buy_quantity=10000 + ) + mid_hdx_price = omnipool.usd_price('HDX') + lrna_holdings = [] + for agent in agents: + omnipool.remove_liquidity( + agent, quantity=agent.holdings[('omnipool', 'HDX')], tkn_remove='HDX' + ) + if agent.get_holdings('LRNA') > 0: + lrna_holdings.append(agent.get_holdings('LRNA')) + omnipool.swap(agent, tkn_buy='USD', tkn_sell='LRNA', sell_quantity=agent.get_holdings('LRNA')) + + last_hdx_price = omnipool.usd_price('HDX') + pass + + start_lrna_price = omnipool.usd_price('LRNA') + omnipool.update() + + lrna_holdings = [] + for agent in agents: + omnipool.remove_liquidity( + agent, quantity=agent.holdings[('omnipool', 'USD')], tkn_remove='USD' + ) + if agent.get_holdings('LRNA') > 0: + lrna_holdings.append(agent.get_holdings('LRNA')) + omnipool.swap(agent, tkn_buy='USD', tkn_sell='LRNA', sell_quantity=agent.get_holdings('LRNA')) + + last_lrna_price = omnipool.usd_price('LRNA') + pass + + +def test_hdx_lrna_balance(): + assets = get_asset_info_by_ids() + balances = get_omnipool_liquidity_at_intervals( + interval=datetime.timedelta(days=1), + asset_ids=['0'], + start_time=datetime.datetime(2023, 12, 1), + end_time=datetime.datetime(2025, 1, 1) + ) + from matplotlib import pyplot as plt + plt.figure(figsize=(20, 5)) + plt.xticks([i * 30 for i in range(14)], ['December', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', 'January']) + plt.plot([b['HDX']['LRNA'] / b['HDX']['liquidity'] for b in balances.values()]) + plt.show() + pass + + +@given( + pct_hdx_pool_bought=st.floats(min_value=0.001, max_value=0.99), + lrna_holdings=st.integers(min_value=100, max_value=50000) +) +def test_hdx_sandwich(pct_hdx_pool_bought: float, lrna_holdings: int): + pct_hdx_pool_bought = 0.5 + lrna_holdings = 50000 + output_token = 'tBTC' + + path = Path(__file__).parent / 'cached data' / 'omnipools' + router = load_state( + path=path, filename='omnipool-2026-02-03.json' + ) + + # router = get_current_omnipool_router() + # save_state( + # router, Path(__file__).parent / 'cached data' / 'omnipools', + # filename=f"omnipool-{datetime.date.today().strftime('%Y-%m-%d')}.json" + # ) + + omnipool: OmnipoolState = router.exchanges['omnipool'] + omnipool.lrna_mint_pct = 0.0 # disable LRNA minting for test + omnipool.lrna_fee_burn = 0.0 # disable LRNA burning for test + initial_omnipool = omnipool.copy() + + sell_quantity = omnipool.calculate_sell_from_buy( + tkn_buy='HDX', tkn_sell=output_token, buy_quantity=omnipool.liquidity['HDX'] * pct_hdx_pool_bought + ) + lrna_seller = Agent(enforce_holdings=False, holdings={'LRNA': lrna_holdings, output_token: sell_quantity}) + + hdx_initial_price = router.price('HDX', 'Tether') + output_initial_price = router.price(output_token, 'Tether') + + router.swap( + agent=lrna_seller, + tkn_buy='HDX', + tkn_sell=output_token, + sell_quantity=sell_quantity + ) + + hdx_buy_price = router.price('HDX', 'Tether') + output_sell_price = router.price(output_token, 'Tether') + + router.swap( + agent=lrna_seller, + tkn_sell='LRNA', + tkn_buy=output_token, + sell_quantity=lrna_holdings + ) + + # checkpoint what the price *would* be without the extra mechanism + hdx_otherwise_price = router.price('HDX', 'Tether') + output_otherwise_price = router.price(output_token, 'Tether') + + # move LRNA into HDX pool as per proposed mechanism + omnipool.lrna[output_token] -= lrna_holdings + omnipool.lrna['HDX'] += lrna_holdings + + hdx_price_after_transfer = router.price('HDX', 'Tether') + output_price_after_transfer = router.price(output_token, 'Tether') + + gain_from_lrna = lrna_seller.get_holdings(output_token) + + router.swap(lrna_seller, tkn_sell='HDX', tkn_buy=output_token, sell_quantity=lrna_seller.get_holdings('HDX')) + buy_quantity = lrna_seller.get_holdings(output_token) - gain_from_lrna + gain_from_arb = lrna_seller.get_holdings(output_token) - gain_from_lrna - lrna_seller.initial_holdings[output_token] + + hdx_final_price = router.price('HDX', 'Tether') + output_final_price = router.price(output_token, 'Tether') + if gain_from_arb > 0: + profit = gain_from_arb * router.price(output_token, 'Tether') + test_summary = f""" + LRNA holdings: {lrna_holdings} + {output_token} holdings: {sell_quantity} + + bought {pct_hdx_pool_bought * 100:.2f}% of HDX pool for {sell_quantity} {output_token} + sold {lrna_holdings} LRNA for {gain_from_lrna} {output_token} + {lrna_holdings} LRNA moved into HDX pool, raising price by {round(hdx_final_price / hdx_buy_price, 6)}% + sold all HDX for {buy_quantity} {output_token} + + HDX initial price: {hdx_initial_price} + HDX price after buying {pct_hdx_pool_bought * 100}% of pool: {hdx_buy_price} + HDX price after LRNA transfer: {hdx_price_after_transfer} + HDX price if no transfer: {hdx_otherwise_price} + HDX final price: {hdx_final_price} + + {output_token} initial price: {output_initial_price} + {output_token} price after buying HDX: {output_sell_price} + {output_token} price after LRNA transfer: {output_price_after_transfer} + {output_token} price if no transfer: {output_otherwise_price} + {output_token} final price: {output_final_price} + + trader profit: {profit} + HDX pools gained LRNA: {omnipool.lrna['HDX'] - initial_omnipool.lrna['HDX']} + """ + print(test_summary) + pass + pass + + +def test_h2o_tokenomics(): + # we have to look at the past month's transactions, particularly the ones that involve selling h2o + # determine what the impacts on LPs may have been + dates = [ + datetime.datetime(2025, 11, day=i+1) for i in range(30) + ] + [ + datetime.datetime(year=2025, month=12, day=i+1) for i in range(31) + ] + block_numbers = list(get_blocks_at_timestamps(dates).values()) + h2o_sold = {} + stablepool_trades = [] + hollar_trades = [] + for i in range(len(block_numbers) - 1): + date = dates[i] + trades = get_omnipool_trades( + min_block=block_numbers[i], + max_block=block_numbers[i + 1] + ) + stablepool_trades.extend([trade for trade in trades if trade['assetOut'] == '2-Pool-Stbl' and trade['assetIn'] == 'H2O']) + hollar_trades.extend([trade for trade in trades if trade['assetOut'] == 'HOLLAR' and trade['assetIn'] == 'H2O']) + + stablepool_trades.sort(key=lambda k: -k['amountOut']) + pass diff --git a/hydradx/other tests/test_apps.py b/hydradx/other tests/test_apps.py index b9913a30d..fea1cd0f6 100644 --- a/hydradx/other tests/test_apps.py +++ b/hydradx/other tests/test_apps.py @@ -1,4 +1,8 @@ +import json + import numpy as np +import pandas as pd +import pytest from hydradx.apps.gigadot_modeling.utils import simulate_route, get_omnipool_minus_vDOT, get_slippage_dict from hydradx.model.amm.money_market import MoneyMarket, MoneyMarketAsset, CDP @@ -7,6 +11,9 @@ from hydradx.model.amm.agents import Agent from hypothesis import given, strategies as strat, assume, settings, reproduce_failure import os +import datetime +from pathlib import Path +from hydradx.model.indexer_utils import get_omnipool_trades, query_indexer from hydradx.tests.utils import find_test_directory @@ -197,3 +204,145 @@ def test_slip_fees(): from hydradx.apps.fees import slip_fees_comparison slip_fees_comparison.run_and_plot() + +def test_slip_fees_chart(): + from hydradx.apps.fees import slip_fees_chart + router = slip_fees_chart.load_omnipool_router() + omnipool = router.exchanges['omnipool'] + omnipool.asset_fee = 0 + omnipool.lrna_fee = 0 + omnipool.max_lrna_fee = 1 + omnipool.max_asset_fee = 1 + omnipool.slip_factor = 1.0 + slip_fees_chart.plot_trade_sizes("HDX", "DOT", router, omnipool) + + +def test_hdx_h2o(): + import hydradx.apps.omnipool.hdx_h2o + +def test_hdx_buy_burn(): + from hydradx.apps.omnipool import hdx_buy_burn + +def test_eur_usd(): + from hydradx.apps.stableswap.eur_usd import run_comparison, get_kraken_prices, get_binance_prices + binance_prices = get_binance_prices(start_date=datetime.datetime.fromisoformat("2026-03-03"), days=1) + kraken_prices = get_kraken_prices(start_date=datetime.datetime.fromisoformat("2026-03-03"), days=1) + result = run_comparison( + file1_path=Path(__file__).parent / "cached data" / "binance_prices.csv", + file2_path=Path(__file__).parent / "cached data" / "kraken_eur_usd_data.csv" + ) + print(result["stats"]) + print(result["merged"].head(20)) + +def test_arbitrage_sim(): + from datetime import datetime, timedelta + from hydradx.apps.stableswap.eur_usd_arbitrage_sim import ( + run_sim, + get_prices_for_day, + smooth_binance_with_kraken, + build_simulation_points, + load_dia_cached, + ) + + start_day = datetime.fromisoformat("2026-03-01") + end_day = datetime.fromisoformat("2026-03-03") + days = [start_day + timedelta(days=i) for i in range((end_day - start_day).days + 1)] + + binance_frames = [get_prices_for_day("binance", day) for day in days] + kraken_frames = [get_prices_for_day("kraken", day) for day in days] + binance_demo = pd.concat(binance_frames, ignore_index=True) if binance_frames else pd.DataFrame() + kraken_demo = pd.concat(kraken_frames, ignore_index=True) if kraken_frames else pd.DataFrame() + + master_df = smooth_binance_with_kraken( + binance_demo, + kraken_demo, + binance_bias_factor=3.0 + ) + dia_prices = load_dia_cached() + steps = build_simulation_points(master_df, dia_prices) + run_sim(steps, trade_fee=0.0001, amplification=1000) + + +def test_arbitrage_series_matches_subset(): + from datetime import datetime, timedelta, timezone + from hydradx.apps.stableswap.eur_usd_arbitrage_sim import ( + get_prices_for_day, + smooth_binance_with_kraken, + ) + + full_start = datetime.fromisoformat("2026-03-01").replace(tzinfo=timezone.utc) + full_end = datetime.fromisoformat("2026-03-03").replace(tzinfo=timezone.utc) + subset_start = datetime.fromisoformat("2026-03-02").replace(tzinfo=timezone.utc) + subset_end = datetime.fromisoformat("2026-03-03").replace(tzinfo=timezone.utc) + + def _load_range(start_day: datetime, end_day: datetime) -> pd.DataFrame: + days = [start_day + timedelta(days=i) for i in range((end_day - start_day).days + 1)] + frames = [get_prices_for_day("binance", day) for day in days] + return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame() + + full_df = _load_range(full_start, full_end) + subset_df = _load_range(subset_start, subset_end) + if full_df.empty or subset_df.empty: + pytest.skip("No cached Binance data available for the requested range.") + + full_combined = smooth_binance_with_kraken(full_df, full_df, binance_bias_factor=3.0) + subset_combined = smooth_binance_with_kraken(subset_df, subset_df, binance_bias_factor=3.0) + + full_slice = full_combined[ + (full_combined["time"] >= subset_start) & (full_combined["time"] <= subset_end) + ].copy() + + if full_slice.empty or subset_combined.empty: + pytest.skip("No overlapping data to compare for the subset window.") + + aligned = full_slice.merge( + subset_combined, + on="timestamp_ms", + suffixes=("_full", "_subset"), + how="inner", + ) + if aligned.empty: + pytest.skip("No aligned timestamps to compare between full and subset series.") + + max_diff = (aligned["external_price_full"] - aligned["external_price_subset"]).abs().max() + if max_diff > 1e-12: + pass + + +def test_swap_price(): + dia_price = 1.1 + pool_amplification = 50 + trade_fee = 0.0005 + usd_trade_size = 10000 + eur_usd_stableswap = StableSwapPoolState( + tokens={"USD": 1_000_000 * dia_price, "EUR": 1_000_000}, + amplification=pool_amplification, + trade_fee=trade_fee, + peg=dia_price, + spot_price_precision=0.00000000001, + precision=1e-12, + max_peg_update=0.0001, + ) + pool_after = eur_usd_stableswap.copy() + pool_after.swap( + agent=Agent(), + tkn_buy="EUR", + tkn_sell="USD", + sell_quantity=usd_trade_size, + ) + eur_received = ( + eur_usd_stableswap.liquidity["EUR"] + - pool_after.liquidity["EUR"] + ) + trade_value = eur_received * dia_price + cost = usd_trade_size - trade_value + pass + +def test_liquidity_graph(): + from hydradx.apps.omnipool.assets_liquidity_graph import get_liquidity_over_time + get_liquidity_over_time() + +def test_hdx_h2o_undo(): + from hydradx.apps.omnipool.hdx_h2o_undo import find_hollar_trades, simulate_lp_experience + find_hollar_trades() + # simulate_lp_experience() diff --git a/hydradx/tests/test_indexer_utils.py b/hydradx/other tests/test_indexer_utils.py similarity index 89% rename from hydradx/tests/test_indexer_utils.py rename to hydradx/other tests/test_indexer_utils.py index e1c95de75..ae0174102 100644 --- a/hydradx/tests/test_indexer_utils.py +++ b/hydradx/other tests/test_indexer_utils.py @@ -11,8 +11,10 @@ os.chdir('../..') from hydradx.model.indexer_utils import get_latest_stableswap_data, get_current_block_height, get_current_omnipool, \ - get_current_omnipool_assets, get_current_stableswap_pools, get_current_omnipool_router, get_fee_history, \ - get_executed_trades, get_stableswap_liquidity_events, get_fee_pcts, get_omnipool_asset_data + get_current_omnipool_asset_ids, get_stableswap_pools, get_current_omnipool_router, get_fee_history, \ + get_executed_trades, get_stableswap_liquidity_events, get_fee_pcts, get_omnipool_asset_data, get_omnipool_liquidity, \ + get_blocks_at_timestamps, is_decimal + def test_get_latest_stableswap_data(): """ @@ -28,8 +30,8 @@ def test_get_latest_stableswap_data(): def test_get_current_stableswap_pools(): - from hydradx.model.indexer_utils import get_current_stableswap_pools - pools = get_current_stableswap_pools(block_number=8450000) + from hydradx.model.indexer_utils import get_stableswap_pools + pools = get_stableswap_pools(block_number=8450000) assert len(pools) > 0 @@ -87,7 +89,7 @@ def test_download_stableswap_exec_prices(): def test_get_stableswap_pools(): - stableswap_pools = get_current_stableswap_pools(8400000) + stableswap_pools = get_stableswap_pools(8400000) for pool in stableswap_pools.values(): assert isinstance(pool, StableSwapPoolState) price = stableswap_pools['690'].price( @@ -174,8 +176,8 @@ def test_bucket_values(): def test_get_current_omnipool_assets(): - ids_str = get_current_omnipool_assets() - ids = [int(x) for x in ids_str] + ids_str = get_current_omnipool_asset_ids() + ids = [int(x) for x in ids_str if is_decimal(x)] print(ids) assert 0 in ids @@ -190,3 +192,11 @@ def test_get_current_omnipool_fees(): def test_get_omnipool_asset_data(): data = get_omnipool_asset_data(min_block_id=8400000, max_block_id=8401000) + + +def test_get_omnipool_trades(): + from hydradx.model.indexer_utils import get_omnipool_trades + current_block = get_current_block_height() + data = get_omnipool_trades(min_block=current_block - 10000, max_block=current_block - 9000) + pass + diff --git a/hydradx/tests/strategies_omnipool.py b/hydradx/tests/strategies_omnipool.py index ee7e3c3eb..c52660a0e 100644 --- a/hydradx/tests/strategies_omnipool.py +++ b/hydradx/tests/strategies_omnipool.py @@ -132,34 +132,10 @@ def omnipool_config( lrna_fee=None, asset_fee=None, tvl_cap_usd=0, - sub_pools: dict = None, - imbalance: float = None, withdrawal_fee=True ): asset_dict: dict = asset_dict or draw(assets_config(token_count)) - sub_pool_instances: dict['str', ssamm.StableSwapPoolState] = {} - if sub_pools: - for i, (name, pool) in enumerate(sub_pools.items()): - base_token = list(asset_dict.keys())[i + 1] - sub_pool_instance = draw(stableswap_config( - asset_dict=pool['asset_dict'] if 'asset_dict' in pool else None, - token_count=pool['token_count'] if 'token_count' in pool else None, - amplification=pool['amplification'] if 'amplification' in pool else None, - trade_fee=pool['trade_fee'] if 'trade_fee' in pool else None, - base_token=base_token - )) - asset_dict.update({tkn: { - 'liquidity': sub_pool_instance.liquidity[tkn], - 'LRNA': ( - asset_dict[base_token]['LRNA'] * sub_pool_instance.liquidity[tkn] - / asset_dict[base_token]['liquidity'] - if 'LRNA' in asset_dict[base_token] else - asset_dict[base_token]['LRNA_price'] * sub_pool_instance.liquidity[tkn] - ) - } for tkn in sub_pool_instance.asset_list}) - sub_pool_instances[name] = sub_pool_instance - test_state = oamm.OmnipoolState( tokens=asset_dict, tvl_cap=tvl_cap_usd or float('inf'), @@ -168,15 +144,6 @@ def omnipool_config( withdrawal_fee=withdrawal_fee ) - for name, pool in sub_pool_instances.items(): - test_state.create_sub_pool( - tkns_migrate=pool.asset_list, - unique_id=name, - amplification=pool.amplification, - trade_fee=pool.trade_fee - ) - - test_state.lrna_imbalance = -draw(asset_quantity_strategy) test_state.update() return test_state diff --git a/hydradx/tests/test_amm.py b/hydradx/tests/test_amm.py index f58578d2e..cf665a98b 100644 --- a/hydradx/tests/test_amm.py +++ b/hydradx/tests/test_amm.py @@ -178,7 +178,7 @@ def test_simulation(initial_state: GlobalState): ) } )) -def test_LP(initial_state: GlobalState): +def test_lp(initial_state: GlobalState): initial_state.agents['LP'].holdings = { tkn: quantity for tkn, quantity in initial_state.pools['HDX/USD'].liquidity.items() } @@ -187,7 +187,7 @@ def test_LP(initial_state: GlobalState): events = run.run(old_state, time_steps=10, silent=True) final_state: GlobalState = events[-1] - if sum([final_state.agents['LP'].holdings[i] for i in initial_state.asset_list]) > 0: + if sum([final_state.agents['LP'].holdings[i] for i in initial_state.pools['omnipool'].liquidity]) > 0: print('failed, not invested') raise AssertionError('Why does this LP not have all its assets in the pool???') if final_state.cash_out('LP') < initial_state.cash_out('LP'): @@ -196,122 +196,6 @@ def test_LP(initial_state: GlobalState): # print('test passed.') -@given(global_state_config( - pools={ - 'HDX/BSX': ConstantProductPoolState( - { - 'HDX': 2000000, - 'BSX': 1000000 - }, - trade_fee=0 - ) - }, - agents={ - 'arbitrageur': Agent( - holdings={'USD': float('inf')}, - trade_strategy=constant_product_arbitrage('HDX/BSX') - ) - }, - external_market={'HDX': 0, 'BSX': 0}, # config function will fill these in with random values - evolve_function=fluctuate_prices(volatility={'HDX': 1, 'BSX': 1}), - skip_omnipool=True -)) -def test_arbitrage_pool_balance(initial_state): - # there are actually two things we would like to test: - # one: with no fees, does the agent succeed in keeping the pool ratio balanced to the market prices? - # two: with fees added, does the agent succeed in making money on every trade? - # this test will focus on the first question - - events = run.run(initial_state, time_steps=50, silent=True) - final_state = events[-1] - final_pool_state = final_state.pools['HDX/BSX'] - if (pytest.approx(final_pool_state.liquidity['HDX'] / final_pool_state.liquidity['BSX']) - != final_state.price('BSX') / final_state.price('HDX')): - raise AssertionError('Price ratio does not match ratio in the pool!') - - -@settings(deadline=500) -@given( - bsx_balance=st.floats(min_value=1000, max_value=100000), - hdx_balance=st.floats(min_value=1000, max_value=100000), - hdx_price=st.floats(min_value=0.01, max_value=1000), - bsx_price=st.floats(min_value=0.01, max_value=1000) -) -def test_arbitrage_profitability(hdx_balance, bsx_balance, hdx_price, bsx_price): - initial_state = GlobalState( - pools={ - 'HDX/BSX': ConstantProductPoolState( - { - 'HDX': hdx_balance, - 'BSX': bsx_balance - }, - trade_fee=0.1 - ) - }, - agents={ - 'arbitrageur': Agent( - holdings={'USD': 10000000000}, # lots - trade_strategy=constant_product_arbitrage('HDX/BSX') - ) - }, - external_market={'HDX': hdx_price, 'BSX': bsx_price} - ) - arb = initial_state.agents['arbitrageur'] - state = initial_state - for i in range(50): - prev_holdings = arb.holdings['USD'] - arb.trade_strategy.execute(state, 'arbitrageur') - if arb.holdings['USD'] < prev_holdings: - raise AssertionError('Arbitrageur lost money :(') - - -@given(global_state_config( - external_market={'X': 0, 'Y': 0}, # config function will fill these in with random values - pools={ - 'X/Y': ConstantProductPoolState( - { - 'X': 0, # random via draw(asset_quantity_strategy) - 'Y': 0 - }, - trade_fee=0 # i.e. choose one randomly via draw(fee_strategy) - ) - }, - agents={ - 'arbitrager': Agent() - } -), asset_price_strategy, st.floats(min_value=0, max_value=0.1)) -def test_arbitrage_accuracy(initial_state: GlobalState, target_price: float, trade_fee: float): - initial_state.trade_fee = trade_fee - initial_state.external_market['Y'] = initial_state.price('X') * target_price - algebraic_function = constant_product_arbitrage('X/Y', minimum_profit=0) - - def sell_spot(state: GlobalState): - return ( - state.pools['X/Y'].liquidity['X'] - / state.pools['X/Y'].liquidity['Y'] - * (1 - state.pools['X/Y'].trade_fee()) - ) - - def buy_spot(state: GlobalState): - return ( - state.pools['X/Y'].liquidity['X'] - / state.pools['X/Y'].liquidity['Y'] - / (1 - state.pools['X/Y'].trade_fee()) - ) - - algebraic_state = copy.deepcopy(algebraic_function.execute(initial_state.copy(), 'arbitrager')) - algebraic_result = (algebraic_state.pools['X/Y'].liquidity['X'] - / algebraic_state.pools['X/Y'].liquidity['Y']) - - if target_price < sell_spot(initial_state): - if sell_spot(algebraic_state) != pytest.approx(target_price): - raise AssertionError("Arbitrage calculation doesn't match expected result.") - - elif target_price > buy_spot(initial_state): - if buy_spot(algebraic_state) != pytest.approx(target_price): - raise AssertionError("Arbitrage calculation doesn't match expected result.") - - @given(global_state_config( external_market={'R1': 2, 'R2': 3, 'R3': 4}, agents={'trader': Agent(holdings={'USD': 1000, 'R1': 1000, 'R2': 1000, 'R3': 1000})}, diff --git a/hydradx/tests/test_arbitrage_agent.py b/hydradx/tests/test_arbitrage_agent.py index a64b372bb..ac58f0272 100644 --- a/hydradx/tests/test_arbitrage_agent.py +++ b/hydradx/tests/test_arbitrage_agent.py @@ -10,7 +10,7 @@ from hydradx.model.amm.centralized_market import OrderBook, CentralizedMarket from hydradx.model.amm.omnipool_amm import OmnipoolState from hydradx.model.processing import get_omnipool_data_from_file, get_orderbooks_from_file -from hydradx.model.processing import load_omnipool +from hydradx.model.processing import load_state from mpmath import mp, mpf from hydradx.tests.utils import find_test_directory @@ -822,7 +822,7 @@ def test_stableswap_router_arbitrage(): # router = get_current_omnipool_router() # save_omnipool(omnipool) archive_path = os.path.join(find_test_directory(), 'archive') - router = load_omnipool(archive_path) + router = load_state(archive_path) omnipool = router.exchanges['omnipool'] fourpool = router.exchanges['4-Pool'] input_path = os.path.join(find_test_directory(), 'data') diff --git a/hydradx/tests/test_exploits.py b/hydradx/tests/test_exploits.py index 88e146188..227ef867c 100644 --- a/hydradx/tests/test_exploits.py +++ b/hydradx/tests/test_exploits.py @@ -1,5 +1,8 @@ import copy +import datetime +import pathlib import math +import pytest from hypothesis import given, strategies as st, settings, reproduce_failure from mpmath import mp, mpf @@ -9,10 +12,12 @@ from hydradx.model.amm.global_state import GlobalState from hydradx.model.amm.omnipool_amm import OmnipoolState, OmnipoolLiquidityPosition from hydradx.model.amm.trade_strategies import constant_swaps, omnipool_arbitrage +from hydradx.model.indexer_utils import get_current_omnipool_router from hydradx.tests.strategies_omnipool import omnipool_reasonable_config, omnipool_config +from hydradx.model.processing import save_state, load_state mp.dps = 50 -# @settings(max_examples=1) +@settings(max_examples=200) @given( st.floats(min_value=0, max_value=0.10, exclude_min=True), st.floats(min_value=0, max_value=0.01, exclude_min=True), @@ -20,8 +25,6 @@ ) def test_add_liquidity_exploit(lp_multiplier, trade_mult): oracle_mult = 1.0 - # lp_multiplier = 0.5 - # trade_mult = 0.5 tokens = { 'HDX': {'liquidity': 44000000, 'LRNA': 275143}, @@ -49,10 +52,11 @@ def test_add_liquidity_exploit(lp_multiplier, trade_mult): 'price': copy.deepcopy(init_oracle), 'volatility': copy.deepcopy(init_oracle), }, - withdrawal_fee=True, - min_withdrawal_fee=0.0001, + withdrawal_fee=False, + # min_withdrawal_fee=0.0001, lrna_fee_burn=0.5, - lrna_mint_pct=1.0 + lrna_mint_pct=1.0, + max_withdrawal_per_block=float('inf') ) market_prices = {tkn: omnipool.usd_price(tkn) for tkn in omnipool.asset_list} @@ -114,11 +118,11 @@ def test_add_liquidity_exploit_sell(lp_multiplier, trade_mult): # trade_mult = 0.5 tokens = { - 'HDX': {'liquidity': 44000000, 'LRNA': 275143}, - 'WETH': {'liquidity': 1400, 'LRNA': 2276599}, - 'DAI': {'liquidity': 2268262, 'LRNA': 2268262}, - 'DOT': {'liquidity': 88000, 'LRNA': 546461}, - 'WBTC': {'liquidity': 47, 'LRNA': 1145210}, + 'HDX': {'liquidity': mpf(44000000), 'LRNA': mpf(275143)}, + 'WETH': {'liquidity': mpf(1400), 'LRNA': mpf(2276599)}, + 'DAI': {'liquidity': mpf(2268262), 'LRNA': mpf(2268262)}, + 'DOT': {'liquidity': mpf(88000), 'LRNA': mpf(546461)}, + 'WBTC': {'liquidity': mpf(47), 'LRNA': mpf(1145210)}, } prices = {tkn: tokens[tkn]['LRNA'] / tokens[tkn]['liquidity'] for tkn in tokens} @@ -139,16 +143,17 @@ def test_add_liquidity_exploit_sell(lp_multiplier, trade_mult): 'price': copy.deepcopy(init_oracle), 'volatility': copy.deepcopy(init_oracle), }, - withdrawal_fee=True, + withdrawal_fee=False, min_withdrawal_fee=0.0001, + asset_fee=0.0025, + lrna_fee=0.0005, lrna_fee_burn=0.5, lrna_mint_pct=1.0 ) market_prices = {tkn: omnipool.usd_price(tkn) for tkn in omnipool.asset_list} - holdings = {tkn: 1000000000 for tkn in omnipool.asset_list + ['LRNA']} - agent = Agent(holdings=holdings) + agent = Agent() swap_state, swap_agent = oamm.simulate_swap( old_state=omnipool.copy(), @@ -189,7 +194,7 @@ def test_add_liquidity_exploit_sell(lp_multiplier, trade_mult): initial_value = omnipool.cash_out(agent, market_prices) final_value = remove_state.cash_out(remove_agent, market_prices) profit = final_value - initial_value - if profit > 0: + if profit > 1e-50: raise @@ -468,7 +473,7 @@ def test_withdraw_manipulation( @given( - omnipool_config(imbalance=0, asset_fee=0.0015, lrna_fee=0.0005), + omnipool_config(asset_fee=0.0015, lrna_fee=0.0005), st.floats(min_value=0, max_value=0.02), st.floats(min_value=0.001, max_value=0.1) ) @@ -554,7 +559,7 @@ def test_add_manipulation( @given( - omnipool_config(imbalance=0), + omnipool_config(), st.integers(min_value=1, max_value=7), st.floats(min_value=0.1, max_value=1.0), st.floats(min_value=1000, max_value=1000000), diff --git a/hydradx/tests/test_hollar.py b/hydradx/tests/test_hollar.py index a50db1517..bf07168ba 100644 --- a/hydradx/tests/test_hollar.py +++ b/hydradx/tests/test_hollar.py @@ -365,7 +365,7 @@ def test_arb_loop_known_profitable(ratios, buyback_speed, max_buy_price_coef, su pools = [usdt_pool.copy(), susds_pool.copy()] hsm = StabilityModule(liquidity, buyback_speed, pools, sell_fee, max_buy_price_coef, buy_fee) - agent = Agent() + agent = Agent(enforce_holdings=True) hsm.arb(agent, tkn) if not agent.validate_holdings(tkn): raise ValueError("Agent should have positive USDT holdings after arb") @@ -393,7 +393,7 @@ def test_arb_loop_known(ratios, buyback_speed, max_buy_price_coef, susds_price, hsm = StabilityModule(liquidity, buyback_speed, pools, sell_fee, max_buy_price_coef, buy_fee) # signature of arb function is arb(self, agent: Agent, tkn: str) -> None: - agent = Agent() + agent = Agent(enforce_holdings=True) hsm.arb(agent, tkn) # agent should have no HOLLAR after arb if agent.validate_holdings('HOLLAR'): diff --git a/hydradx/tests/test_omnipool_amm.py b/hydradx/tests/test_omnipool_amm.py index e7c0ad97d..2f13c6c76 100644 --- a/hydradx/tests/test_omnipool_amm.py +++ b/hydradx/tests/test_omnipool_amm.py @@ -5,18 +5,15 @@ from hypothesis import given, strategies as st, assume, settings, reproduce_failure from mpmath import mp, mpf import os - os.chdir('../..') -from hydradx.model import run, processing +from hydradx.model import run from hydradx.model.amm import omnipool_amm as oamm 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 from hydradx.model.amm.trade_strategies import constant_swaps, omnipool_arbitrage from hydradx.tests.strategies_omnipool import omnipool_reasonable_config, omnipool_config, assets_config -import hydradx.model.production_settings as production_settings -from hydradx.model.indexer_utils import get_current_omnipool_router mp.dps = 50 @@ -28,47 +25,8 @@ fee_strategy = st.floats(min_value=0.0001, max_value=0.1, allow_nan=False, allow_infinity=False) -@given(omnipool_config(asset_fee=0, lrna_fee=0, token_count=3), asset_quantity_strategy) -def test_swap_lrna_delta_Qi_respects_invariant(d: oamm.OmnipoolState, delta_ri: float): - i = d.asset_list[-1] - assume(i in d.asset_list) - assume(d.liquidity[i] > delta_ri > -d.liquidity[i]) - d2 = copy.deepcopy(d) - delta_Qi = oamm.swap_lrna_delta_Qi(d, delta_ri, i) - d2.liquidity[i] += delta_ri - d2.lrna[i] += delta_Qi - - # Test basics - for j in d2.liquidity: - assert d2.liquidity[j] > 0 - assert d2.lrna[j] > 0 - assert not (delta_ri > 0 and delta_Qi > 0) - assert not (delta_ri < 0 and delta_Qi < 0) - - # Test that the pool invariant is respected - assert oamm.asset_invariant(d2, i) == pytest.approx(oamm.asset_invariant(d, i)) - - -@given(omnipool_config(asset_fee=0, lrna_fee=0, token_count=3), asset_quantity_strategy) -def test_swap_lrna_delta_Ri_respects_invariant(d: oamm.OmnipoolState, delta_qi: float): - i = d.asset_list[-1] - assume(i in d.asset_list) - assume(d.lrna[i] > delta_qi > -d.lrna[i]) - d2 = copy.deepcopy(d) - delta_Ri = oamm.swap_lrna_delta_Ri(d, delta_qi, i) - d2.lrna[i] += delta_qi - d2.liquidity[i] += delta_Ri - - # Test basics - for j in d.liquidity: - assert d2.liquidity[j] > 0 - assert d2.lrna[j] > 0 - assert not (delta_Ri > 0 and delta_qi > 0) - assert not (delta_Ri < 0 and delta_qi < 0) - - # Test that the pool invariant is respected - assert oamm.asset_invariant(d2, i) == pytest.approx(oamm.asset_invariant(d, i)) - +def asset_invariant(state: oamm.OmnipoolState, tkn: str): + return state.liquidity[tkn] * state.lrna[tkn] @given(omnipool_config()) def test_sell_accuracy(initial_state): @@ -89,14 +47,6 @@ def test_sell_accuracy(initial_state): raise AssertionError('Asset sold is wrong.') -@given(omnipool_config(asset_fee=0, lrna_fee=0)) -def test_weights(initial_state: oamm.OmnipoolState): - old_state = initial_state - for i in old_state.liquidity: - assert oamm.weight_i(old_state, i) >= 0 - assert sum([oamm.weight_i(old_state, i) for i in old_state.liquidity]) == pytest.approx(1.0) - - @given(omnipool_config()) def test_prices(market_state: oamm.OmnipoolState): for i in market_state.asset_list: @@ -572,6 +522,8 @@ def test_remove_liquidity_dynamic_fee(price_diff: float, asset_dict: dict): @given(omnipool_config(token_count=3, withdrawal_fee=False), st.floats(min_value=0.001, max_value=0.2)) def test_remove_liquidity_no_fee_different_price(initial_state: oamm.OmnipoolState, trade_size_ratio: float): + initial_state.lrna_fee_destination = Agent(holdings={'LRNA': 0, 'HDX': 0, 'USD': 0, 'DOT': 0}) + i = initial_state.asset_list[2] initial_agent = Agent( holdings={token: 1000 for token in initial_state.asset_list + ['LRNA']}, @@ -693,12 +645,13 @@ def test_swap_lrna(delta_qa: float, buy_index: int): tkn_buy=i, tkn_sell='LRNA' ) - if oamm.asset_invariant(feeless_swap_state, i) != pytest.approx(oamm.asset_invariant(old_state, i)): + + if asset_invariant(feeless_swap_state, i) != pytest.approx(asset_invariant(old_state, i)): raise AssertionError('Invariant not respected in feeless trade.') for j in old_state.liquidity: if min(new_state.liquidity[j] - feeless_swap_state.liquidity[j], 0) != pytest.approx(0): raise AssertionError('Liquidity decreased.') - if min(oamm.asset_invariant(new_state, i) / oamm.asset_invariant(old_state, i), 1) != pytest.approx(1): + if min(asset_invariant(new_state, i) / asset_invariant(old_state, i), 1) != pytest.approx(1): raise AssertionError('Invariant decreased.') delta_qi = new_state.lrna[i] - old_state.lrna[i] @@ -815,7 +768,8 @@ def test_lrna_swap_sell_with_lrna_mint( tvl_cap=float('inf'), asset_fee=asset_fee, lrna_fee=lrna_fee, - lrna_mint_pct=1.0 + lrna_mint_pct=1.0, + lrna_fee_burn=0 ) old_agent = Agent( @@ -828,25 +782,25 @@ def test_lrna_swap_sell_with_lrna_mint( feeless_state = initial_state.copy() feeless_state.asset_fee = 0 - for asset in feeless_state.asset_list: - feeless_state.last_fee[asset] = 0 + feeless_state.lrna_fee = 0 + feeless_state.slip_factor = 0 # Test with trader buying asset i swap_state, swap_agent = oamm.simulate_swap( initial_state, old_agent, tkn_buy=i, tkn_sell='LRNA', - sell_quantity=delta_qa + sell_quantity=-delta_qa ) feeless_swap_state, feeless_swap_agent = oamm.simulate_swap( feeless_state, old_agent, tkn_buy=i, tkn_sell='LRNA', - sell_quantity=delta_qa + sell_quantity=-delta_qa ) feeless_spot_price = feeless_swap_state.price(i) spot_price = swap_state.price(i) - if feeless_swap_state.fail == '' and swap_state.fail == '': + if not(feeless_swap_state.fail or swap_state.fail): if feeless_spot_price != pytest.approx(spot_price, rel=1e-16): raise AssertionError('Spot price is wrong.') @@ -1043,7 +997,6 @@ def test_sell_with_partial_lrna_mint( @given(omnipool_reasonable_config(token_count=3, lrna_fee=0.0005, asset_fee=0.0025)) def test_lrna_buy_nonzero_fee(initial_state: oamm.OmnipoolState): - initial_state.lrna_fee_burn = 0 old_state = initial_state old_agent = Agent( holdings={token: 1000000 for token in initial_state.asset_list + ['LRNA']} @@ -1110,7 +1063,7 @@ def test_swap_assets(initial_state: oamm.OmnipoolState, i): if min(new_state.liquidity[j] - asset_fee_only_state.liquidity[j], 0) != pytest.approx(0): raise AssertionError("asset in pool {j} is lesser when LRNA fee is added vs only asset fee") # invariant does not decrease - if min(oamm.asset_invariant(new_state, j) / oamm.asset_invariant(old_state, j), 1) != pytest.approx(1): + if min(asset_invariant(new_state, j) / asset_invariant(old_state, j), 1) != pytest.approx(1): raise AssertionError("invariant ratio less than zero") # total quantity of R_i remains unchanged if (old_state.liquidity[j] + old_agent.holdings[j] @@ -1998,12 +1951,13 @@ def test_lowering_price(lp_multiplier, price_movement, oracle_mult): }, withdrawal_fee=True, min_withdrawal_fee=0.0001, + slip_factor=1 ) market_prices = {tkn: omnipool.usd_price(tkn) for tkn in omnipool.asset_list} - holdings = {tkn: 1000000000 for tkn in omnipool.asset_list + ['LRNA']} - agent = Agent(holdings=holdings) + # start with an equal value of each asset + agent = Agent(holdings={"DOT": 1000000, "DAI": 1000000 * omnipool.price("DOT", "DAI")}) swap_state, swap_agent = oamm.simulate_swap( old_state=omnipool.copy(), @@ -2045,7 +1999,14 @@ def test_lowering_price(lp_multiplier, price_movement, oracle_mult): final_value = remove_state.cash_out(remove_agent, market_prices) profit = final_value - initial_value if profit > 0: - raise + cash_out_state = remove_state.copy() + cash_out_agent = remove_agent.copy() + for tkn in omnipool.liquidity: + if cash_out_agent.get_holdings(tkn) < 0: + cash_out_state.swap(cash_out_agent, tkn_buy=tkn, tkn_sell='LRNA', buy_quantity=-cash_out_agent.holdings[tkn]) + elif cash_out_agent.get_holdings(tkn) > 0: + cash_out_state.swap(cash_out_agent, tkn_sell=tkn, tkn_buy='LRNA', sell_quantity=cash_out_agent.holdings[tkn]) + raise AssertionError("Attacker was able to profit from adding and removing liquidity after a price drop.") def test_add_and_remove_liquidity(): @@ -2112,12 +2073,12 @@ def test_add_and_remove_liquidity(): ) def test_calculate_sell_from_buy(list_offset: int, buy_quantities: list[float]): swaps = [ + {"tkn_sell": "HDX", "tkn_buy": "USDT"}, + {"tkn_sell": "USDT", "tkn_buy": "HDX"}, {"tkn_sell": "USDT", "tkn_buy": "LRNA"}, {"tkn_sell": "LRNA", "tkn_buy": "HDX"}, - {"tkn_sell": "HDX", "tkn_buy": "USDT"}, - {"tkn_sell": "USDT", "tkn_buy": "HDX"} ] - swaps = swaps[list_offset:] + swaps[:list_offset] + # swaps = swaps[list_offset:] + swaps[:list_offset] omnipool = OmnipoolState( tokens={ "HDX": {"liquidity": mpf(10000000), "LRNA": mpf(1000000)}, @@ -2153,12 +2114,18 @@ def test_calculate_sell_from_buy(list_offset: int, buy_quantities: list[float]): raise AssertionError(f'buy quantity {buy_quantity} != actual quantity {agent.holdings[tkn_buy]}') agent.holdings = {} + expected_buy_quantity = omnipool.calculate_buy_from_sell( + tkn_sell=tkn_sell, + tkn_buy=tkn_buy, + sell_quantity=sell_quantity + ) omnipool.swap( # don't copy this time: accumulate trade volume agent=agent, tkn_sell=tkn_sell, tkn_buy=tkn_buy, sell_quantity=sell_quantity ) + actual_buy_quantity = agent.get_holdings(tkn_buy) actual_sell_quantity = -agent.get_holdings(tkn_sell) if buy_quantity != pytest.approx(actual_buy_quantity, rel=1e-40): @@ -2512,34 +2479,36 @@ def test_fee_application(): @given(st.integers(min_value=1, max_value=10), st.integers(min_value=1, max_value=10)) def test_lrna_swap_equivalency(lrna_burn_rate, min_fee_fraction): + treasury_agent = Agent() initial_state = OmnipoolState( tokens={'HDX': {'liquidity': mpf(1000000), 'LRNA': mpf(1000)}, 'USD': {'liquidity': mpf(3000), 'LRNA': mpf(150)}}, lrna_fee=DynamicFee( current={'HDX': mpf(1) / 2000, 'USD': mpf(1) / 1000}, minimum=mpf(1) / 2000 / min_fee_fraction, - maximum=mpf(1) / 1000 / min_fee_fraction + maximum=mpf(1) / 100 ), asset_fee=DynamicFee( current={'HDX': mpf(1) / 1000 * 7, 'USD': mpf(1) / 400} ), lrna_fee_burn=mpf(1) / lrna_burn_rate / min_fee_fraction / 2000, - slip_factor=1.0 + slip_factor=1.0, + lrna_fee_destination=treasury_agent ) - agent = Agent(holdings={'HDX': mpf(1000000), 'LRNA': mpf(0)}) + agent = Agent(enforce_holdings=False) sell_quantity = 1000 - sell_agent = agent.copy() - sell_state = initial_state.copy().swap( + split_sell_agent = agent.copy() + mid_sell_state = initial_state.copy().swap( sell_quantity=sell_quantity, - agent=sell_agent, + agent=split_sell_agent, tkn_sell='HDX', tkn_buy='LRNA' ) - mid_sell_agent = sell_agent.copy() - sell_state.swap( - sell_quantity=sell_agent.holdings['LRNA'], - agent=sell_agent, + mid_sell_agent = split_sell_agent.copy() + split_sell_state = mid_sell_state.copy().swap( + sell_quantity=split_sell_agent.holdings['LRNA'], + agent=split_sell_agent, tkn_buy='USD', tkn_sell='LRNA' ) @@ -2551,41 +2520,43 @@ def test_lrna_swap_equivalency(lrna_burn_rate, min_fee_fraction): sell_quantity=sell_quantity ) buy_quantity = direct_sell_agent.holdings['USD'] - if sell_state.liquidity['USD'] != pytest.approx(direct_sell_state.liquidity['USD'], rel=1e-12): + if split_sell_state.liquidity['USD'] != pytest.approx(direct_sell_state.liquidity['USD'], rel=1e-40): raise AssertionError("Direct sell was not equivalent to two LRNA swaps (USD liquidity).") - elif sell_state.lrna['USD'] != pytest.approx(direct_sell_state.lrna['USD'], rel=1e-12): + elif split_sell_state.lrna['USD'] != pytest.approx(direct_sell_state.lrna['USD'], rel=1e-40): raise AssertionError("Direct sell was not equivalent to two LRNA swaps (USD LRNA).") - elif sell_state.lrna['HDX'] != pytest.approx(direct_sell_state.lrna['HDX'], rel=1e-12): + elif split_sell_state.lrna['HDX'] != pytest.approx(direct_sell_state.lrna['HDX'], rel=1e-40): raise AssertionError("Direct sell was not equivalent to two LRNA swaps (HDX LRNA).") - elif sell_state.liquidity['HDX'] != pytest.approx(direct_sell_state.liquidity['HDX'], rel=1e-12): + elif split_sell_state.liquidity['HDX'] != pytest.approx(direct_sell_state.liquidity['HDX'], rel=1e-40): raise AssertionError("Direct sell was not equivalent to two LRNA swaps (HDX liquidity).") - elif sell_agent.holdings['USD'] != pytest.approx(direct_sell_agent.holdings['USD'], rel=1e-12): + elif split_sell_agent.holdings['USD'] != pytest.approx(direct_sell_agent.holdings['USD'], rel=1e-40): raise AssertionError("Direct sell was not equivalent to two LRNA swaps (agent USD).") - elif sell_agent.holdings['HDX'] != pytest.approx(direct_sell_agent.holdings['HDX'], rel=1e-12): + elif split_sell_agent.holdings['HDX'] != pytest.approx(direct_sell_agent.holdings['HDX'], rel=1e-40): raise AssertionError("Direct sell was not equivalent to two LRNA swaps (agent HDX).") - elif sell_agent.holdings['LRNA'] != pytest.approx(direct_sell_agent.holdings['LRNA'], rel=1e-12): + elif split_sell_agent.get_holdings('LRNA') != pytest.approx(direct_sell_agent.get_holdings('LRNA'), rel=1e-40): raise AssertionError("Direct sell was not equivalent to two LRNA swaps (agent LRNA).") - elif sell_state.lrna_fee_destination.holdings['LRNA'] != pytest.approx( - direct_sell_state.lrna_fee_destination.holdings['LRNA'], rel=1e-12): + elif split_sell_state.lrna_fee_destination.holdings['LRNA'] != pytest.approx( + direct_sell_state.lrna_fee_destination.holdings['LRNA'], rel=1e-40): raise AssertionError("Direct sell was not equivalent to two LRNA swaps (fee destination LRNA).") elif direct_sell_state.fail: raise AssertionError("Sell failed.") else: er = 'no problem' - buy_agent = agent.copy() - buy_state = initial_state.copy().swap( + mid_buy_agent = agent.copy() + mid_buy_state = initial_state.copy().swap( buy_quantity=mid_sell_agent.holdings['LRNA'], - agent=buy_agent, + agent=mid_buy_agent, tkn_sell='HDX', tkn_buy='LRNA' - ).swap( + ) + split_buy_agent = mid_buy_agent.copy() + split_buy_state = mid_buy_state.copy().swap( buy_quantity=buy_quantity, - agent=buy_agent, + agent=split_buy_agent, tkn_buy='USD', tkn_sell='LRNA' ) - buy_quantity = buy_agent.holdings['USD'] + buy_quantity = split_buy_agent.holdings['USD'] direct_buy_agent = agent.copy() direct_buy_state = initial_state.copy().swap( agent=direct_buy_agent, @@ -2593,22 +2564,22 @@ def test_lrna_swap_equivalency(lrna_burn_rate, min_fee_fraction): tkn_buy='USD', buy_quantity=buy_quantity ) - if buy_state.liquidity['USD'] != pytest.approx(direct_buy_state.liquidity['USD'], rel=1e-12): + if split_buy_state.liquidity['USD'] != pytest.approx(direct_buy_state.liquidity['USD'], rel=1e-12): raise AssertionError("Direct buy was not equivalent to two LRNA swaps (USD liquidity).") - elif buy_state.lrna['USD'] != pytest.approx(direct_buy_state.lrna['USD'], rel=1e-12): + elif split_buy_state.lrna['USD'] != pytest.approx(direct_buy_state.lrna['USD'], rel=1e-12): raise AssertionError("Direct buy was not equivalent to two LRNA swaps (USD LRNA).") - elif buy_state.liquidity['HDX'] != pytest.approx(direct_buy_state.liquidity['HDX'], rel=1e-12): + elif split_buy_state.liquidity['HDX'] != pytest.approx(direct_buy_state.liquidity['HDX'], rel=1e-12): raise AssertionError("Direct buy was not equivalent to two LRNA swaps (HDX liquidity).") - elif buy_state.lrna['HDX'] != pytest.approx(direct_buy_state.lrna['HDX'], rel=1e-12): + elif split_buy_state.lrna['HDX'] != pytest.approx(direct_buy_state.lrna['HDX'], rel=1e-12): raise AssertionError("Direct buy was not equivalent to two LRNA swaps (HDX lrna).") - elif buy_agent.holdings['USD'] != pytest.approx(direct_buy_agent.holdings['USD'], rel=1e-12): + elif split_buy_agent.get_holdings('USD') != pytest.approx(direct_buy_agent.get_holdings('USD'), rel=1e-12): raise AssertionError("Direct buy was not equivalent to two LRNA swaps (agent USD).") - elif buy_agent.holdings['HDX'] != pytest.approx(direct_buy_agent.holdings['HDX'], rel=1e-12): + elif split_buy_agent.get_holdings('HDX') != pytest.approx(direct_buy_agent.get_holdings('HDX'), rel=1e-12): raise AssertionError("Direct buy was not equivalent to two LRNA swaps (agent HDX).") - elif buy_agent.holdings['LRNA'] != pytest.approx(direct_buy_agent.holdings['LRNA'], rel=1e-12): + elif split_buy_agent.get_holdings('LRNA') != pytest.approx(direct_buy_agent.get_holdings('LRNA'), rel=1e-12): raise AssertionError("Direct buy was not equivalent to two LRNA swaps (agent LRNA).") - elif buy_state.lrna_fee_destination.holdings['LRNA'] != pytest.approx( - direct_buy_state.lrna_fee_destination.holdings['LRNA'], rel=1e-12): + elif split_buy_state.lrna_fee_destination.get_holdings('LRNA') != pytest.approx( + direct_buy_state.lrna_fee_destination.get_holdings('LRNA'), rel=1e-12): raise AssertionError("Direct buy was not equivalent to two LRNA swaps (fee destination LRNA).") elif direct_buy_state.fail: raise AssertionError("Buy failed.") @@ -2616,6 +2587,84 @@ def test_lrna_swap_equivalency(lrna_burn_rate, min_fee_fraction): er = 'no problem' +def test_lrna_split_sell_calculation(): + omnipool = OmnipoolState( + tokens={ + "HDX": {"liquidity": mpf(10000000), "LRNA": mpf(1000000)}, + "USDT": {"liquidity": mpf(250000), "LRNA": mpf(2000000)}, + }, + lrna_fee=mpf(1) / 2000, # 0.0005 + asset_fee=mpf(1) / 400, # 0.0025 + slip_factor=mpf(1.0) + ) + + output_1 = list(omnipool.calculate_out_given_in(tkn_buy="LRNA", tkn_sell="HDX", sell_quantity=1000)) + outputs = [ + output_1, + omnipool.calculate_out_given_in(tkn_buy="USDT", tkn_sell="LRNA", sell_quantity=output_1[0]) + ] + output_1[0] = 0 # zero out buy quantity to avoid double counting + buy_quantity, delta_qi, delta_qj, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell = [ + outputs[0][i] + outputs[1][i] for i in range(7) + ] + buy_quantity_2, delta_qi_2, delta_qj_2, asset_fee_total_2, lrna_fee_total_2, slip_fee_buy_2, slip_fee_sell_2 = \ + omnipool.calculate_out_given_in(tkn_sell="HDX", tkn_buy="USDT", sell_quantity=1000) + if buy_quantity != pytest.approx(buy_quantity_2, rel=1e-40): + raise AssertionError("LRNA split swap sell quantity incorrect.") + if delta_qi != pytest.approx(delta_qi_2, rel=1e-40): + raise AssertionError("LRNA split swap delta_qi incorrect.") + if delta_qj != pytest.approx(delta_qj_2, rel=1e-40): + raise AssertionError("LRNA split swap delta_qj incorrect.") + if asset_fee_total != pytest.approx(asset_fee_total_2, rel=1e-40): + raise AssertionError("LRNA split swap asset_fee_total incorrect.") + if lrna_fee_total != pytest.approx(lrna_fee_total_2, rel=1e-40): + raise AssertionError("LRNA split swap lrna_fee_total incorrect.") + if slip_fee_buy != pytest.approx(slip_fee_buy_2, rel=1e-40): + raise AssertionError("LRNA split swap slip_fee_buy incorrect.") + if slip_fee_sell != pytest.approx(slip_fee_sell_2, rel=1e-40): + raise AssertionError("LRNA split swap slip_fee_sell incorrect.") + + pass + + +def test_lrna_split_buy_calculation(): + omnipool = OmnipoolState( + tokens={ + "HDX": {"liquidity": mpf(10000000), "LRNA": mpf(1000000)}, + "USDT": {"liquidity": mpf(250000), "LRNA": mpf(2000000)}, + }, + lrna_fee=mpf(1) / 2000, # 0.0005 + asset_fee=mpf(1) / 400, # 0.0025 + slip_factor=mpf(1.0) + ) + omnipool.max_lrna_fee = 0.01 + output_1 = list(omnipool.calculate_in_given_out(tkn_buy="HDX", tkn_sell="LRNA", buy_quantity=1000)) + outputs = [ + output_1, + omnipool.calculate_in_given_out(tkn_sell="USDT", tkn_buy="LRNA", buy_quantity=output_1[0]) + ] + output_1[0] = 0 # zero out sell quantity to avoid double counting + sell_quantity, delta_qi, delta_qj, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell = [ + outputs[0][i] + outputs[1][i] for i in range(7) + ] + sell_quantity_2, delta_qi_2, delta_qj_2, asset_fee_total_2, lrna_fee_total_2, slip_fee_buy_2, slip_fee_sell_2 = \ + omnipool.calculate_in_given_out(tkn_sell="USDT", tkn_buy="HDX", buy_quantity=1000) + if sell_quantity != pytest.approx(sell_quantity_2, rel=1e-40): + raise AssertionError("LRNA split swap buy quantity incorrect.") + if delta_qi != pytest.approx(delta_qi_2, rel=1e-40): + raise AssertionError("LRNA split swap delta_qi incorrect.") + if delta_qj != pytest.approx(delta_qj_2, rel=1e-40): + raise AssertionError("LRNA split swap delta_qj incorrect.") + if asset_fee_total != pytest.approx(asset_fee_total_2, rel=1e-40): + raise AssertionError("LRNA split swap asset_fee_total incorrect.") + if lrna_fee_total != pytest.approx(lrna_fee_total_2, rel=1e-40): + raise AssertionError("LRNA split swap lrna_fee_total incorrect.") + if slip_fee_buy != pytest.approx(slip_fee_buy_2, rel=1e-40): + raise AssertionError("LRNA split swap slip_fee_buy incorrect.") + if slip_fee_sell != pytest.approx(slip_fee_sell_2, rel=1e-40): + raise AssertionError("LRNA split swap slip_fee_sell incorrect.") + + def test_cash_out_omnipool_exact(): liquidity = {'HDX': mpf(10000000), 'USD': mpf(1000000), 'DOT': mpf(100000)} lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(1000000)} @@ -2829,7 +2878,14 @@ def test_lrna_fee_burn(lrna_fee, burn_rate): if lrna_received_1 + lrna_fee_total_1 != pytest.approx(lrna_paid_out_1, rel=1e-20): raise AssertionError(f'LRNA fee not calculated correctly.') if lrna_burned_1 / lrna_fee_total_1 != pytest.approx(burn_rate, rel=1e-20): - raise AssertionError(f'LRNA burn rate not calculated correctly.') + print (f"lrna received: {lrna_received_1}") + print (f"lrna deposited: {lrna_deposited_1}") + print (f"lrna burned: {lrna_burned_1}") + print (f"lrna fee total: {lrna_fee_total_1}") + print (f"initial state lrna values: {initial_state.lrna}") + print (f"sell_tkn state lrna values: {sell_tkn_state.lrna}") + print (f"sell_tkn agent lrna holdings: {sell_tkn_agent.holdings['LRNA']}") + raise AssertionError(f'LRNA burn rate not calculated correctly ({lrna_burned_1 / lrna_fee_total_1} != {burn_rate} (intended).') buy_lrna_state, buy_lrna_agent = oamm.simulate_swap( old_state=initial_state, @@ -2984,7 +3040,8 @@ def test_trade_to_price(): 'USD': {'liquidity': mpf(1000000), 'LRNA': mpf(1000000)} }, asset_fee=0.0025, - lrna_fee=0.0005 + lrna_fee=0.0005, + lrna_fee_destination=Agent() ) agent = Agent(enforce_holdings=False) @@ -3017,27 +3074,159 @@ def test_slip_fee_works(): raise AssertionError('Slip fee did not reduce output amount.') +def test_buy_sell_equivalency(): + omnipool = oamm.OmnipoolState( + tokens={ + 'USD': {'liquidity': mpf(1000000000), 'LRNA': mpf(1000000000)}, + 'HDX': {'liquidity': mpf(10000000000), 'LRNA': mpf(100000000)} + }, + lrna_fee=0.0025, + asset_fee=0.0025, + slip_factor=1.0 + ) + omnipool.max_asset_fee = 0.05 + omnipool.max_lrna_fee = 0.05 + buy_quantity = mpf(10000) + sell_quantity = omnipool.calculate_sell_from_buy( + tkn_buy='HDX', + tkn_sell='USD', + buy_quantity=buy_quantity + ) + reverse_buy = omnipool.calculate_buy_from_sell( + tkn_buy='HDX', + tkn_sell='USD', + sell_quantity=sell_quantity + ) + if reverse_buy != pytest.approx(buy_quantity, rel=1e-12): + raise AssertionError('Buy/sell equivalency failed.') + + def test_calculate_buy_vs_sell(): omnipool = OmnipoolState( tokens={ 'HDX': {'liquidity': mpf(1000000), 'LRNA': mpf(1000000)}, 'USD': {'liquidity': mpf(1000000), 'LRNA': mpf(1000000)} }, - asset_fee=0, - lrna_fee=0, - slip_factor=1.0 + asset_fee=mpf(1) / 400, + lrna_fee=mpf(1) / 2000, + slip_factor=0, ) omnipool.max_lrna_fee = 0.01 # set LRNA fee cap for this test sell_quantity = mpf(1000) + omnipool.swap(Agent(enforce_holdings=False), tkn_sell='USD', tkn_buy='HDX', sell_quantity=sell_quantity) outputs = omnipool.calculate_out_given_in(tkn_buy='HDX', tkn_sell='USD', sell_quantity=sell_quantity) - buy_quantity, asset_fee_total, lrna_fee_total, slip_fee_total = outputs + buy_quantity, delta_qi, delta_qj, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell = outputs + slip_fee_total = slip_fee_buy + slip_fee_sell outputs = omnipool.calculate_in_given_out(tkn_buy='HDX', tkn_sell='USD', buy_quantity=buy_quantity) - sell_quantity_2, asset_fee_total_2, lrna_fee_total_2, slip_fee_total_2 = outputs - if sell_quantity != pytest.approx(sell_quantity_2, rel=1e-12): + sell_quantity_2, delta_qi_2, delta_qj_2, asset_fee_total_2, lrna_fee_total_2, slip_fee_buy, slip_fee_sell = outputs + slip_fee_total_2 = slip_fee_sell + slip_fee_buy + if sell_quantity != pytest.approx(sell_quantity_2, rel=1e-40): raise AssertionError('Calculate buy vs sell quantities do not match.') - if asset_fee_total != pytest.approx(asset_fee_total_2, rel=1e-12): + if asset_fee_total != pytest.approx(asset_fee_total_2, rel=1e-40): raise AssertionError('Calculate buy vs sell asset fees do not match.') - if lrna_fee_total != pytest.approx(lrna_fee_total_2, rel=1e-12): + if lrna_fee_total != pytest.approx(lrna_fee_total_2, rel=1e-40): raise AssertionError('Calculate buy vs sell LRNA fees do not match.') - if slip_fee_total != pytest.approx(slip_fee_total_2, rel=1e-12): + if slip_fee_total != pytest.approx(slip_fee_total_2, rel=1e-40): raise AssertionError('Calculate buy vs sell slip fees do not match.') + if delta_qj != pytest.approx(delta_qj_2, rel=1e-40): + raise AssertionError('Calculate buy vs sell delta_qj do not match.') + if delta_qi != pytest.approx(delta_qi_2, rel=1e-40): + raise AssertionError('Calculate buy vs sell delta_qi do not match.') + + +def test_lrna_without_mint_or_burn_is_constant(): + treasury_agent = Agent() + omnipool = OmnipoolState( + tokens={ + 'HDX': {'liquidity': mpf(10000000), 'LRNA': mpf(10000)}, + 'USD': {'liquidity': mpf(100000), 'LRNA': mpf(10000)} + }, + asset_fee=0.00125, + lrna_fee=0.00025, + slip_factor=1.0, + lrna_fee_burn=0.0, + lrna_mint_pct=0, + lrna_fee_destination=treasury_agent + ) + omnipool.max_lrna_fee = 0.01 + initial_total_lrna = sum(omnipool.lrna.values()) + agent = Agent(enforce_holdings=False) + swaps = [ + ('HDX', 'USD', 'buy', mpf(1000)), + ('LRNA', 'HDX', 'buy', mpf(50)), + ('HDX', 'USD', 'sell', mpf(2000)), + ('USD', 'LRNA', 'sell', mpf(100)), + ('LRNA', 'USD', 'buy', mpf(3000)), + ('USD', 'HDX', 'buy', mpf(150)) + ] + for tkn_sell, tkn_buy, mode, quantity in swaps: + if mode == 'buy': + omnipool.swap(agent, tkn_sell=tkn_sell, tkn_buy=tkn_buy, buy_quantity=quantity) + else: + omnipool.swap(agent, tkn_sell=tkn_sell, tkn_buy=tkn_buy, sell_quantity=quantity) + total_lrna = sum(omnipool.lrna.values()) + agent.get_holdings('LRNA') + omnipool.lrna_fee_destination.get_holdings('LRNA') + if total_lrna != pytest.approx(initial_total_lrna, rel=1e-40): + raise AssertionError('LRNA amount changed despite no burn or mint.') + + +def test_lrna_outputs_sum_to_zero(): + omnipool = OmnipoolState( + tokens={ + 'HDX': {'liquidity': mpf(1000000), 'LRNA': mpf(1000000)}, + 'USD': {'liquidity': mpf(1000000), 'LRNA': mpf(1000000)} + }, + asset_fee=mpf(1) / 400, + lrna_fee=mpf(1) / 2000, + slip_factor=1.0, + lrna_fee_burn=0.0, + lrna_mint_pct=0, + ) + omnipool.max_lrna_fee = 0.01 + sell_quantity = mpf(1000) + outputs = omnipool.calculate_out_given_in(tkn_buy='HDX', tkn_sell='USD', sell_quantity=sell_quantity) + buy_quantity, delta_qi, delta_qj, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell = outputs + total = delta_qi + delta_qj + lrna_fee_total + slip_fee_buy + slip_fee_sell + if total != pytest.approx(0, rel=1e-40): + raise AssertionError('Outputs do not sum to zero.') + + buy_output = omnipool.calculate_in_given_out(tkn_buy='HDX', tkn_sell='USD', buy_quantity=buy_quantity) + sell_quantity_2, delta_qi_2, delta_qj_2, asset_fee_total_2, lrna_fee_total_2, slip_fee_buy_2, slip_fee_sell_2 = buy_output + total_2 = delta_qi_2 + delta_qj_2 + lrna_fee_total_2 + slip_fee_buy_2 + slip_fee_sell_2 + if total_2 != pytest.approx(0, rel=1e-40): + raise AssertionError('Outputs do not sum to zero.') + + +def test_no_slip_fee(): + omnipool = OmnipoolState( + tokens={ + 'HDX': {'liquidity': mpf(1000000), 'LRNA': mpf(1000000)}, + 'USD': {'liquidity': mpf(1000000), 'LRNA': mpf(1000000)} + }, + asset_fee=mpf(1) / 400, + lrna_fee=mpf(1) / 2000, + slip_factor=0.0, + lrna_fee_burn=0.0, + lrna_mint_pct=0, + ) + initial_omnipool = omnipool.copy() + omnipool.max_lrna_fee = 0.01 + buy_quantity = mpf(1000) + outputs = omnipool.calculate_in_given_out(tkn_buy='HDX', tkn_sell='USD', buy_quantity=buy_quantity) + sell_quantity, delta_qi, delta_qj, asset_fee_total, lrna_fee_total, slip_fee_buy, slip_fee_sell = outputs + agent = Agent() + omnipool.swap(agent, tkn_sell='USD', tkn_buy='HDX', buy_quantity=buy_quantity) + if slip_fee_buy != 0: + raise AssertionError('Slip fee buy should be zero.') + if slip_fee_sell != 0: + raise AssertionError('Slip fee sell should be zero.') + if (initial_omnipool.lrna['USD'] - omnipool.lrna['USD']) * mpf(1) / 2000 != pytest.approx(lrna_fee_total, rel=1e-40): + raise AssertionError('LRNA fee total not calculated correctly.') + if asset_fee_total != pytest.approx(0.0025 * (asset_fee_total + buy_quantity), rel=1e-40): + raise AssertionError('Asset fee total not calculated correctly.') + + buy_output = omnipool.calculate_in_given_out(tkn_buy='HDX', tkn_sell='USD', buy_quantity=buy_quantity) + sell_quantity_2, delta_qi_2, delta_qj_2, asset_fee_total_2, lrna_fee_total_2, slip_fee_buy_2, slip_fee_sell_2 = buy_output + if slip_fee_buy_2 != 0: + raise AssertionError('Slip fee buy should be zero.') + if slip_fee_sell_2 != 0: + raise AssertionError('Slip fee sell should be zero.') diff --git a/hydradx/tests/test_omnipool_state.py b/hydradx/tests/test_omnipool_state.py index fa6ee07ac..6b7fd4583 100644 --- a/hydradx/tests/test_omnipool_state.py +++ b/hydradx/tests/test_omnipool_state.py @@ -9,7 +9,7 @@ from hydradx.model.amm.omnipool_amm import OmnipoolState, value_assets, DynamicFee from hydradx.tests.strategies_omnipool import reasonable_market_dict, omnipool_reasonable_config, reasonable_holdings from hydradx.tests.strategies_omnipool import reasonable_pct, asset_number_strategy -from hydradx.model.processing import save_omnipool, load_omnipool +from hydradx.model.processing import save_state, load_state from hydradx.model.indexer_utils import get_current_omnipool_router from hydradx.tests.utils import find_test_directory @@ -298,8 +298,8 @@ def test_cash_out_accuracy(omnipool: oamm.OmnipoolState, share_price_ratio, lp_i def test_save_load(): path = find_test_directory() omnipool_router = get_current_omnipool_router(block_number=8450000) - save_omnipool(omnipool_router, path=path) - omnipool_router2 = load_omnipool(path=path) + save_state(omnipool_router, path=path) + omnipool_router2 = load_state(path=path) for exchange_id in omnipool_router2.exchanges: if repr(omnipool_router2.exchanges[exchange_id]) != repr(omnipool_router.exchanges[exchange_id]): raise AssertionError('Save and load failed') diff --git a/hydradx/tests/test_stableswap.py b/hydradx/tests/test_stableswap.py index c50915125..4bf2a1db8 100644 --- a/hydradx/tests/test_stableswap.py +++ b/hydradx/tests/test_stableswap.py @@ -98,7 +98,7 @@ def test_swap_agent_changes(): if new_agent.holdings['B'] + new_pool.liquidity['B'] != agent.holdings['B'] + pool.liquidity['B']: raise AssertionError('Agent holdings not updated properly.') - empty_agent = Agent() + empty_agent = Agent(enforce_holdings=True) fail_pool, fail_agent = simulate_swap(pool, empty_agent, tkn_sell='A', tkn_buy='B', sell_quantity=sell_amt) if not fail_pool.fail: raise AssertionError('Swap should have failed due to insufficient funds.') @@ -1315,3 +1315,26 @@ def test_balance_ratio_at_price(price, amp): pool = StableSwapPoolState(tokens, amp) spot = pool.price('HOLLAR', 'USDT') assert spot == pytest.approx(price, rel=1e-15) + + +def test_calculate_buy_from_sell(): + stableswap = StableSwapPoolState( + tokens={'a': 1000, 'b': 2000}, + amplification=100, + peg=0.5 + ) + for i in range(1000): + agent = Agent() + stableswap.set_peg_target(0.5 + i * 0.0001) + stableswap.update() + buy_quantity = stableswap.calculate_buy_from_sell(tkn_buy='a', tkn_sell='b', sell_quantity=1) + stableswap.swap( + agent=agent, + tkn_buy='a', + tkn_sell='b', + sell_quantity=1 + ) + real_buy_quantity = agent.holdings['a'] + if buy_quantity != pytest.approx(real_buy_quantity, rel=1e-12): + print('wrong price') + pass diff --git a/requirements.txt b/requirements.txt index 53f0515b0..8def4b9c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,3 +24,5 @@ web3>=6.14.0 websockets==10.0 eth-utils~=5.2.0 streamlit>=1.42.2 +python-binance +boto3