diff --git a/README.md b/README.md index c17e327..c3b2583 100644 --- a/README.md +++ b/README.md @@ -188,4 +188,28 @@ Example: Retrieve all accounts with balances greater than 50M from the network d ```sh python -m network.richlist_symbol --resources templates/symbol.mainnet.yaml --min-balance 50000000 --output 50M.csv -```` +``` + +## treasury + +### serve + +_runs a self-contained webapp for monitoring account balances, scraping price data, and visualizing future account values in aggregate_ + +Default location for storage is `treasury/data` but any alternative location can be provided. Configuration is located at `treasury/treasury_config.json`. **All config fields other than cm_key are required.** + +Price data download is relatively lightweight for default configuration. If no data is present, the app will attempt to collect prices for all assets defined in configuration on first load. Price data is cached to disk as collected, so data for any particular asset/date combination should be download no more than once. + +Server can be invoked with defaults via: + +```sh +treasury/serve.sh +``` + +or manually with: + +```sh +python -m treasury.app --config './treasury_config.json' --account-data-loc './data/accounts.csv' --price-data-loc './data/price_data.csv' --serve --host '0.0.0.0' +``` + +With default settings the app will listen for requests at [http:\\\\localhost:8080](http:\\\\localhost:8080) diff --git a/requirements.txt b/requirements.txt index 13745d9..65f05a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,9 @@ +dash==2.0.0 +dash-bootstrap-components==1.0.3 +numpy==1.21.2 +pandas==1.3.3 PyYAML==5.4.1 requests==2.26.0 symbol-sdk-core-python==2.0.1 -zenlog==1.1 +tqdm==4.62.3 +zenlog==1.1 \ No newline at end of file diff --git a/treasury/data/accounts.csv b/treasury/data/accounts.csv new file mode 100644 index 0000000..57ed85d --- /dev/null +++ b/treasury/data/accounts.csv @@ -0,0 +1,6 @@ +Asset,Name,Address +XYM,XYM Treasury,NCHEST3QRQS4JZGOO64TH7NFJ2A63YA7TPM5PXI +XYM,XYM Sink (Mosaic),NAVORTEX3IPBAUWQBBI3I3BDIOS4AVHPZLCFC7Y +XYM,XYM Sink (Fees),NCVORTEX4XD5IQASZQEHDWUXT33XBOTBMKFDCLI +XEM,XEM Treasury,NCHESTYVD2P6P646AMY7WSNG73PCPZDUQNSD6JAK +XEM,XEM Rewards,NCPAYOUTH2BGEGT3Q7K75PV27QKMVNN2IZRVZWMD \ No newline at end of file diff --git a/treasury/serve.sh b/treasury/serve.sh new file mode 100755 index 0000000..b695de6 --- /dev/null +++ b/treasury/serve.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +python3 -m treasury.app --config './treasury_config.json' --account-data-loc './data/accounts.csv' --price-data-loc './data/price_data.csv' --serve --host '0.0.0.0' diff --git a/treasury/setup.py b/treasury/setup.py new file mode 100644 index 0000000..3c6249a --- /dev/null +++ b/treasury/setup.py @@ -0,0 +1,19 @@ +from setuptools import setup + +setup( + name='symbol-treasury-analysis', + version='1.0', + packages=['treasury'], + package_data={ + '': ['*.json', '*.csv'], + 'treasury': ['treasury/*'] + }, + install_requires=[ + 'requests', + 'dash', + 'dash-bootstrap-components', + 'numpy', + 'pandas', + 'tqdm', + ] +) diff --git a/treasury/treasury/__init__.py b/treasury/treasury/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/treasury/treasury/app.py b/treasury/treasury/app.py new file mode 100644 index 0000000..de9f02b --- /dev/null +++ b/treasury/treasury/app.py @@ -0,0 +1,295 @@ +import argparse +import json + +import dash +import dash_bootstrap_components as dbc +import pandas as pd +from dash import dcc, html +from dash.dependencies import Input, Output, State + +from treasury.callbacks import (download_full, download_full_prices, download_small, download_small_prices, get_update_balances, + get_update_prices, update_forecast_chart, update_price_chart, update_summary) +from treasury.data import get_gecko_spot, get_gecko_prices, lookup_balance + +THEME = dbc.themes.VAPOR +TITLE = 'Symbol Treasury Analysis Tool v1.0' + + +def get_app(price_data_loc, account_data_loc, config, serve, base_path, start_date, end_date, auto_update_delay_seconds=600): + + app = dash.Dash(__name__, serve_locally=serve, url_base_pathname=base_path, external_stylesheets=[THEME]) + app.title = TITLE + + # preprocess data for fast load + try: + prices = pd.read_csv(price_data_loc, header=0, index_col=0, parse_dates=True) + except FileNotFoundError: + print('No price data found, pulling fresh data for assets in config (this may take a while)') + if len(config['assets']) > 0: + prices = [] + for asset in config['assets']: + prices.append(get_gecko_prices( + asset, + start_date, + end_date, + config['max_api_tries'], + config['retry_delay_seconds'])) + prices = pd.concat(prices, axis=1).sort_index(axis=0).sort_index(axis=1) + print(f'Prices acquired successfully; writing to {price_data_loc}') + prices.to_csv(price_data_loc) + else: + print('No assets found in config; aborting!') + raise + + lookback_prices = prices.loc[start_date:end_date] + + accounts = pd.read_csv(account_data_loc, header=0, index_col=None) + accounts['Balance'] = [int(lookup_balance(row.Address, row.Asset, config['api_hosts'])) for row in accounts.itertuples()] + asset_values = accounts.groupby('Asset')['Balance'].sum().to_dict() + + summary_df = pd.DataFrame.from_records({ + 'Latest XYM Price': [f'${get_gecko_spot("XYM"):.4}'], + 'Latest XEM Price': [f'${get_gecko_spot("XEM"):.4}'], + 'Reference Trend (Daily)': [f'{prices[config["default_ref_ticker"]].pct_change().mean():.3%}'], + 'Reference Vol (Daily)': [f'{prices[config["default_ref_ticker"]].pct_change().std():.3%}']}) + + app.layout = dbc.Container([ + dbc.Row([html.H1(TITLE)], justify='center'), + dbc.Row([ + dbc.Table.from_dataframe(summary_df, bordered=True, color='dark') + ], id='summary-table'), + dbc.Row([ + dbc.Col([ + dbc.Spinner(html.Div([], id='address-table')), + dbc.FormText( + 'Select the asset used to seed the simulation. Historical data from this asset will be used ' + + 'to fit a model for future price changes, which samples the possible future price paths.'), + dbc.InputGroup( + [ + dbc.InputGroupText('Reference Asset:'), + dbc.Select( + id='ref-ticker', + options=[{'label': ticker, 'value': ticker} for ticker in prices], + value=config['default_ref_ticker']) + ], + className='mb-3', + ), + dbc.FormText('Choose how many days into the future you wish to forecast.'), + dbc.InputGroup( + [ + dbc.InputGroupText('Forecast Days:'), + dbc.Input( + id='forecast-days', + value=config['default_forecast_periods'], + type='number', + min=1, + max=1000, + step=1, + debounce=True) + ], + className='mb-3', + ), + dbc.FormText( + 'Choose how many price simulations you wish to run. More simulations will take slightly ' + + 'longer to run, but will allow for better estimation of probabilities.'), + dbc.InputGroup( + [ + dbc.InputGroupText('Number of Simulations:'), + dbc.Input(id='num-sims', value=config['default_num_sims'], type='number', min=1, step=1, debounce=True) + ], + className='mb-3', + ), + dbc.FormText( + 'Choose the date from which historical data will be collected. A longer data collection ' + + 'period will result in better estimation. Can also be used to perform hypothetical analysis of past scenarios.'), + dbc.InputGroup( + [ + dbc.InputGroupText('Data Start:'), + dbc.Input(id='start-date', value=start_date, type='text', debounce=True) + ], + className='mb-3', + ), + dbc.FormText( + 'Choose the end date for the historical data. The simulation will start from this date. ' + + 'Values in the future or too far in the past may result in errors.'), + dbc.InputGroup( + [ + dbc.InputGroupText('Data End:'), + dbc.Input(id='end-date', value=end_date, type='text', debounce=True) + ], + className='mb-3', + ), + dbc.FormText( + 'Pick a threshold percentage used to calculate the best and worst case estimates. A value ' + + 'of 95% means that the high and low bars shown will contain an (estimated) 95% of possible scenarios. ' + + 'Set to 100% to see the absolute minimum and maximum from the simulation.'), + dbc.InputGroup( + [ + dbc.InputGroupText('Risk Threshold:'), + dbc.Input(id='risk-threshold', value=0.95, type='number', min=0.0, max=1.0, debounce=True) + ], + className='mb-3', + ), + dbc.FormText( + 'Set a value that scales the trend seen in the historical data. For example, a value of 3 ' + + 'will cause the simulation to trend 3 times as strongly as the historical data, and a value of -1 will ' + + 'reverse the trend in the historical data.'), + dbc.InputGroup( + [ + dbc.InputGroupText('Trend Multiplier:'), + dbc.Input(id='trend-scale', value=1.0, type='number', debounce=True) + ], + className='mb-3', + ), + dbc.FormText( + 'Set a value that scales the volatility seen in the historical data. For example, value of ' + + '2 will cause the simulation to be twice as volatile as the historical data. Must be greater than zero.'), + dbc.InputGroup( + [ + dbc.InputGroupText('Volatility Multiplier:'), + dbc.Input(id='vol-scale', value=1.0, type='number', min=0.1, debounce=True) + ], + className='mb-3', + ), + dbc.Row([ + dbc.Button('Download Sim Balances', id='download-button-full', color='primary', className='me-1'), + dbc.Button('Download High/Low/Mid Balances', id='download-button-small', color='secondary', className='me-1'), + dbc.Button('Download Simulated Ref Asset Prices', id='price-button-full', color='success', className='me-1'), + dbc.Button('Download High/Low/Mid Ref Asset Prices', id='price-button-small', color='warning', className='me-1'), + ]), + dcc.Download(id='download-small-dataframe'), + dcc.Download(id='download-full-dataframe'), + dcc.Download(id='download-small-prices'), + dcc.Download(id='download-full-prices'), + ], + className='col-lg-4 col-md-12', + ), + dbc.Col([ + dbc.Spinner( + dcc.Graph( + id='forecast-graph', + style={'width': '100%', 'height': '60vh'}, + config={'scrollZoom': False}, + responsive=True)), + dbc.Spinner( + dcc.Graph( + id='price-graph', + style={'width': '100%', 'height': '60vh'}, + config={'scrollZoom': False}, + responsive=True)), + ], + className='p-3 col-lg-8 col-md-12 col-sm-12', + width=8, + ), + ], className='p-3'), + dcc.Store(id='ref-prices', data=prices.to_json(date_format='iso', orient='split')), + dcc.Store(id='lookback-prices', data=lookback_prices.to_json(date_format='iso', orient='split')), + dcc.Store(id='asset-values', data=asset_values), + dcc.Store(id='full-prices'), + dcc.Store(id='small-prices'), + dcc.Store(id='full-sims'), + dcc.Store(id='small-sims'), + dcc.Interval( + id='auto-update-trigger', + interval=auto_update_delay_seconds*1000, + n_intervals=0) + ], fluid=True) + + app.callback( + Output('download-full-prices', 'data'), + Input('price-button-full', 'n_clicks'), + State('full-prices', 'data'), + prevent_initial_call=True)(download_full_prices) + + app.callback( + Output('download-small-prices', 'data'), + Input('price-button-small', 'n_clicks'), + State('small-prices', 'data'), + prevent_initial_call=True)(download_small_prices) + + app.callback( + Output('download-full-dataframe', 'data'), + Input('download-button-full', 'n_clicks'), + State('full-sims', 'data'), + prevent_initial_call=True)(download_full) + + app.callback( + Output('download-small-dataframe', 'data'), + Input('download-button-small', 'n_clicks'), + State('small-sims', 'data'), + prevent_initial_call=True)(download_small) + + app.callback( + Output('summary-table', 'children'), + Input('lookback-prices', 'data'), + Input('ref-ticker', 'value'))(update_summary) + + app.callback( + Output('address-table', 'children'), + Output('asset-values', 'data'), + Input('auto-update-trigger', 'n_intervals'))( + get_update_balances(account_data_loc, config['api_hosts'], config['explorer_url_map'])) + + app.callback( + Output('ref-prices', 'data'), + Output('lookback-prices', 'data'), + Input('start-date', 'value'), + Input('end-date', 'value'), + State('ref-prices', 'data'), + State('lookback-prices', 'data'))(get_update_prices(price_data_loc, config['max_api_tries'], config['retry_delay_seconds'])) + + app.callback( + Output('forecast-graph', 'figure'), + Output('full-sims', 'data'), + Output('small-sims', 'data'), + Output('full-prices', 'data'), + Output('small-prices', 'data'), + Input('lookback-prices', 'data'), + Input('ref-ticker', 'value'), + Input('forecast-days', 'value'), + Input('num-sims', 'value'), + Input('trend-scale', 'value'), + Input('vol-scale', 'value'), + Input('risk-threshold', 'value'), + State('forecast-graph', 'figure'), + State('asset-values', 'data'))(update_forecast_chart) + + app.callback( + Output('price-graph', 'figure'), + Input('lookback-prices', 'data'), + State('price-graph', 'figure'))(update_price_chart) + + return app + + +def main(): + parser = argparse.ArgumentParser(description='webapp that monitors treasury balances and crypto asset prices') + parser.add_argument('--config', '-c', help='configuration file location', default='../treasury_config.json') + parser.add_argument('--host', help='host ip, defaults to localhost', default='127.0.0.1') + parser.add_argument('--port', type=int, help='port for webserver', default=8080) + parser.add_argument('--proxy', help='proxy spec of the form ip:port::gateway to render urls', default=None) + parser.add_argument('--base-path', help='extension if server is not at root of url', default=None) + parser.add_argument('--serve', action='store_true', help='flag to indicate whether server will recieve external requests') + parser.add_argument('--price-data-loc', help='path to flat file storing collected data', default='../data/price_data.csv') + parser.add_argument('--account-data-loc', help='path to csv with account information', default='../data/accounts.csv') + parser.add_argument('--start-date', help='default start date', default='2021-12-01') + parser.add_argument('--end-date', help='default end date', default=None) + args = parser.parse_args() + + if args.end_date is None: + args.end_date = (pd.to_datetime('today')-pd.Timedelta(1, unit='D')).strftime('%Y-%m-%d') + + try: + with open(args.config) as config_file: + args.config = json.load(config_file) + except FileNotFoundError: + print(f'No configuration file found at {args.config}') + print('Configuration is required to run the app!') + raise + + app = get_app(args.price_data_loc, args.account_data_loc, args.config, args.serve, args.base_path, args.start_date, args.end_date) + app.run_server(host=args.host, port=args.port, threaded=True, proxy=args.proxy, debug=True) + + +if __name__ == '__main__': + main() diff --git a/treasury/treasury/callbacks.py b/treasury/treasury/callbacks.py new file mode 100644 index 0000000..3c1489b --- /dev/null +++ b/treasury/treasury/callbacks.py @@ -0,0 +1,313 @@ +import dash_bootstrap_components as dbc +import pandas as pd +import plotly.graph_objects as go +from dash import dcc, html + +from treasury.data import get_gecko_prices, get_gecko_spot, lookup_balance +from treasury.models import get_mean_variance_forecasts + + +def download_full_prices(_, full_prices): + """Callback to feed the full price simulation download feature""" + full_prices = pd.read_json(full_prices, orient='split') + return dcc.send_data_frame(full_prices.to_csv, 'simulated_prices.csv') + + +def download_small_prices(_, small_prices): + """Callback to feed the price simulation boundary download feature""" + small_prices = pd.read_json(small_prices, orient='split') + return dcc.send_data_frame(small_prices.to_csv, 'high_low_mid_prices.csv') + + +def download_full(_, full_sims): + """Callback to feed the full simulation download feature""" + full_sims = pd.read_json(full_sims, orient='split') + return dcc.send_data_frame(full_sims.to_csv, 'full_simulations.csv') + + +def download_small(_, small_sims): + """Callback to feed the simulation boundary download feature""" + small_sims = pd.read_json(small_sims, orient='split') + return dcc.send_data_frame(small_sims.to_csv, 'high_low_mid_simulations.csv') + + +def update_summary(lookback_prices, ref_ticker): + """Callback that produces the headline summary table""" + lookback_prices = pd.read_json(lookback_prices, orient='split') + summary_dict = { + 'Latest XYM Price': [f'${get_gecko_spot("XYM"):.4}'], + 'Latest XEM Price': [f'${get_gecko_spot("XEM"):.4}'], + 'Reference Trend (Daily)': [f'{lookback_prices[ref_ticker].pct_change().mean():.3%}'], + 'Reference Vol (Daily)': [f'{lookback_prices[ref_ticker].pct_change().std():.3%}'] + } + return [dbc.Table.from_dataframe(pd.DataFrame.from_records(summary_dict), bordered=True, color='dark')] + + +def get_update_balances(account_data_loc, api_hosts, explorer_url_map): + """Wrapper to inject location dependency into account balance callback""" + + def update_balances(_): + accounts = pd.read_csv(account_data_loc, header=0, index_col=None) + accounts['Balance'] = [int(lookup_balance(row.Address, row.Asset, api_hosts)) for row in accounts.itertuples()] + asset_values = accounts.groupby('Asset')['Balance'].sum().to_dict() + updated_addresses = [] + for _, row in accounts.iterrows(): + updated_addresses.append(html.A(f'{row.Address[:10]}...', href=f'{explorer_url_map[row.Asset]}{row.Address}')) + accounts['Address'] = updated_addresses + return [dbc.Table.from_dataframe(accounts[['Name', 'Balance', 'Address']], bordered=True, color='dark')], asset_values + + return update_balances + + +def get_update_prices(price_data_loc, max_api_tries, retry_delay_seconds): + """Wrapper to inject location dependency into price data callback""" + + def update_prices( + start_date, + end_date, + ref_prices, + lookback_prices): + """Callback that slices price data and fetches new bars from coingecko as needed""" + + ref_prices = pd.read_json(ref_prices, orient='split') + price_len = len(ref_prices) + + start_date = pd.to_datetime(start_date).tz_localize('UTC') + end_date = pd.to_datetime(end_date).tz_localize('UTC') + + if start_date < ref_prices.index[0]: + new_prices = [] + for asset in ref_prices.columns: + new_prices.append(get_gecko_prices( + asset, + start_date, + ref_prices.index[0]-pd.Timedelta(days=1), + max_api_tries, + retry_delay_seconds)) + new_prices = pd.concat(new_prices, axis=1) + ref_prices = pd.concat([new_prices, ref_prices], axis=0).sort_index().drop_duplicates() + if end_date > ref_prices.index[-1]: + new_prices = [] + for asset in ref_prices.columns: + new_prices.append(get_gecko_prices( + asset, + ref_prices.index[-1]+pd.Timedelta(days=1), + end_date, + max_api_tries, + retry_delay_seconds)) + new_prices = pd.concat(new_prices, axis=1) + ref_prices = pd.concat([ref_prices, new_prices], axis=0).sort_index().drop_duplicates() + + if len(ref_prices) != price_len: + ref_prices.to_csv(price_data_loc) + + lookback_prices = ref_prices.loc[start_date:end_date] + + return ref_prices.to_json(date_format='iso', orient='split'), lookback_prices.to_json(date_format='iso', orient='split') + + return update_prices + + +def update_forecast_chart( + lookback_prices, + ref_ticker, + forecast_window, + num_sims, + trend_scale, + vol_scale, + risk_threshold, + forecast_fig, + asset_values): + """Callback that runs forecasting algorithm, builds forecast chart, and returns simulations for export""" + + lookback_prices = pd.read_json(lookback_prices, orient='split') + + # run models + forecasts = get_mean_variance_forecasts( + lookback_prices, + ref_ticker, + forecast_window=forecast_window, + trend_scale=trend_scale, + vol_scale=vol_scale, + num_sims=num_sims) + + historical_prices = lookback_prices[ref_ticker].copy() + forecast_prices = forecasts * lookback_prices[ref_ticker].iloc[-1] + price_quantiles = forecast_prices.quantile([(1-risk_threshold)/2, (1+risk_threshold)/2], axis=1).T + historical_prices = pd.concat([historical_prices, forecast_prices.quantile(0.5, axis=1).T], axis=0) + historical_prices.drop_duplicates(inplace=True) + + historical_value = lookback_prices.apply(lambda x: x*asset_values.get(x.name, 0)).sum(axis=1) + forecasts = forecasts * historical_value.iloc[-1] + forecast_quantiles = forecasts.quantile([(1-risk_threshold)/2, (1+risk_threshold)/2], axis=1).T + forecast_ranks = forecasts.rank(axis=1, pct=True) + historical_value = pd.concat([historical_value, forecasts.quantile(0.5, axis=1).T], axis=0) + historical_value.drop_duplicates(inplace=True) + + forecast_traces = [] + for sim in forecasts: + forecast_traces.append( + go.Scattergl( + x=forecasts.index.values, + y=forecasts[sim].values, + line=dict(width=1.5, color='#6f42c1'), + opacity=0.1, + customdata=forecast_ranks[sim], + hovertemplate=( + 'Date: %{x}
' + + 'Treasury Value: $%{y:,.6r}
' + + 'Quantile: %{customdata:.4p}
' + + ''), + showlegend=False, + mode='lines')) + + for quantile in forecast_quantiles: + forecast_traces.append( + go.Scattergl( + x=forecast_quantiles.index.values, + y=forecast_quantiles[quantile].values, + line=dict( + width=3, + color='#e44d56' if quantile < 0.5 else '#33ce6e', + dash='solid'), + opacity=1.0, + name=f'{float(quantile):.2%} risk level', + showlegend=True, + legendrank=1 - quantile, + hovertemplate=( + f'{float(quantile):.2%} risk level
' + + 'Date: %{x}
' + + 'Treasury Value: $%{y:,.6r}
' + + ''), + mode='lines')) + + forecast_traces.append( + go.Scattergl( + x=historical_value.index.values, + y=historical_value.values, + line=dict(width=3, color='#1ea2f3'), + opacity=1, + name='Historical Value / Average', + hovertemplate=( + 'Historical Value / Average
' + + 'Date: %{x}
' + + 'Treasury Value: $%{y:,.6r}
' + + ''), + showlegend=True, + legendrank=0, + mode='lines')) + + if forecast_fig is None: + forecast_fig = go.Figure( + data=forecast_traces, + layout=go.Layout( + plot_bgcolor='#140223', + paper_bgcolor='rgba(0,0,0,0)', + legend=dict( + bgcolor='#140223', + yanchor='top', + y=0.99, + xanchor='left', + x=0.01), + showlegend=True, + hovermode='closest', + font=dict(size=16, color='#32fbe2'), + margin=dict(b=20, l=5, r=5, t=40), + xaxis=dict( + linecolor='#46d4e6', + gridcolor='#46d4e6', + showgrid=True, + showticklabels=True), + yaxis=dict( + linecolor='#46d4e6', + gridcolor='#46d4e6', + showgrid=True, + zeroline=True, + zerolinecolor='#46d4e6', + showticklabels=True))) + forecast_fig.update_layout( + title='Historical Treasury Value With Future Simulations', + xaxis_title='Date', + yaxis_title='Value ($USD)', + legend_title='Quantiles') + else: + forecast_fig['data'] = forecast_traces + + historical_value.name = 'Historical Value / Median Forecast' + forecast_quantiles.columns = [f'{float(quantile):.2%} risk level' for quantile in forecast_quantiles.columns] + historical_value = pd.concat([historical_value, forecast_quantiles], axis=1) + forecasts.columns = [f'Balance Sim {col}' for col in forecasts.columns] + + historical_prices.name = f'Historical {ref_ticker} Price / Median Forecast' + price_quantiles.columns = [f'{float(quantile):.2%} {ref_ticker} Forecast' for quantile in price_quantiles.columns] + historical_prices = pd.concat([historical_prices, price_quantiles], axis=1) + forecast_prices.columns = [f'{ref_ticker} Price Sim {col}' for col in forecast_prices.columns] + + return ( + forecast_fig, + forecasts.to_json(date_format='iso', orient='split'), + historical_value.to_json(date_format='iso', orient='split'), + forecast_prices.to_json(date_format='iso', orient='split'), + historical_prices.to_json(date_format='iso', orient='split')) + + +def update_price_chart(lookback_prices, price_fig): + """Callback that builds and styles a chart containing asset returns""" + + lookback_prices = pd.read_json(lookback_prices, orient='split') + lookback_returns = (lookback_prices.pct_change().fillna(0.0)+1).cumprod() - 1 + + price_traces = [] + for asset in lookback_prices.columns: + price_traces.append( + go.Scattergl( + x=lookback_returns.index.values, + y=lookback_returns[asset].values, + line=dict(width=2), + name=asset, + customdata=lookback_prices[asset], + hovertemplate=( + ''+asset+'
' + + 'Date: %{x}
' + + 'Pct. Return: %{y:.2%}
' + + 'Price: $%{customdata:.4f}
' + + ''), + mode='lines')) + + if price_fig is None: + price_fig = go.Figure( + data=price_traces, + layout=go.Layout( + plot_bgcolor='#140223', + paper_bgcolor='rgba(0,0,0,0)', + legend=dict( + bgcolor='#140223', + yanchor='top', + y=0.99, + xanchor='left', + x=0.01), + showlegend=True, + hovermode='closest', + font=dict(size=16, color='#32fbe2'), + margin=dict(b=20, l=5, r=5, t=40), + xaxis=dict( + linecolor='#46d4e6', + gridcolor='#46d4e6', + showgrid=True, + showticklabels=True), + yaxis=dict( + linecolor='#46d4e6', + gridcolor='#46d4e6', + showgrid=True, + zeroline=True, + zerolinecolor='#46d4e6', + showticklabels=True))) + price_fig.update_layout( + title='Asset Price Change over Lookback Period', + xaxis_title='Date', + yaxis_title='% Change', + legend_title='Asset') + else: + price_fig['data'] = price_traces + + return price_fig diff --git a/treasury/treasury/data.py b/treasury/treasury/data.py new file mode 100644 index 0000000..18a2dbd --- /dev/null +++ b/treasury/treasury/data.py @@ -0,0 +1,130 @@ +import datetime +import time + +import pandas as pd +import requests +from tqdm import tqdm + +GECKO_TICKER_MAP = { + 'ADA': 'cardano', + 'AVAX': 'avalanche-2', + 'BTC': 'bitcoin', + 'DOT': 'polkadot', + 'ETH': 'ethereum', + 'LTC': 'litecoin', + 'MATIC': 'matic-network', + 'SOL': 'solana', + 'TRX': 'tron', + 'XEM': 'nem', + 'XMR': 'monero', + 'XYM': 'symbol', +} + +XYM_MOSAIC_ID = '6BED913FA20223F8' + + +def lookup_balance(address, asset, api_hosts): + asset = asset.lower() + if asset in ['symbol', 'xym']: + return lookup_xym_balance(address, api_hosts['XYM']) + if asset in ['nem', 'xem']: + return lookup_xem_balance(address, api_hosts['XEM']) + raise ValueError(f'Asset not supported for balance lookup: {asset}') + + +def lookup_xym_balance(address, xym_api_host): + balance = 0 + json_account = requests.get('https://' + xym_api_host + ':3001/accounts/' + address).json() + json_mosaics = json_account['account']['mosaics'] + for json_mosaic in json_mosaics: + if XYM_MOSAIC_ID == json_mosaic['id']: + balance = float(json_mosaic['amount']) / 1000000 + break + + return balance + + +def lookup_xem_balance(address, xem_api_host): + response = requests.get('http://' + xem_api_host + ':7890/account/get?address=' + address).json() + balance = float(response['account']['balance']) / 1000000 + return balance + + +def get_cm_prices(ticker, date_time, cm_key): + + response = requests.get( + 'https://api.coinmetrics.io/v4/timeseries/asset-metrics?assets=' + + ticker + + '&start_time=' + + date_time + + '&limit_per_asset=1&metrics=PriceUSD&api_key=' + cm_key).json() + ref_rate = response['data'][0]['PriceUSD'] + return ref_rate + + +def get_cm_metrics(assets, metrics, cm_key, start_time='2016-01-01', end_time=None, frequency='1d'): + if end_time is None: + end_time = datetime.date.today() + data = [] + url = ( + 'https://api.coinmetrics.io/v4/timeseries/asset-metrics?' + + f'assets={",".join(assets)}&' + + f'start_time={start_time}&' + + f'end_time={end_time}&' + + f'metrics={",".join(metrics)}&' + + f'frequency={frequency}&' + + f'pretty=true&api_key={cm_key}') + while True: + response = requests.get(url).json() + data.extend(response['data']) + if 'next_page_url' in response: + url = response['next_page_url'] + else: + break + return data + + +def fix_ticker(ticker): + return GECKO_TICKER_MAP.get(ticker, ticker) + + +def get_gecko_spot(ticker, max_api_tries=6, retry_delay_seconds=15, currency='usd'): + ticker = fix_ticker(ticker) + tries = 1 + while tries <= max_api_tries: + try: + response = requests.get('https://api.coingecko.com/api/v3/simple/price?ids=' + ticker + '&vs_currencies=' + currency).json() + return response[ticker][currency] + except (KeyError, requests.exceptions.RequestException) as _: + time.sleep(retry_delay_seconds) + tries += 1 + return None + + +def get_gecko_price(ticker, date, max_api_tries=6, retry_delay_seconds=15, currency='usd'): + ticker = fix_ticker(ticker) + tries = 1 + while tries <= max_api_tries: + try: + response = requests.get( + 'https://api.coingecko.com/api/v3/coins/' + + ticker + + '/history?date=' + + date + + '&localization=false').json() + if 'name' in response and 'market_data' not in response: + return None + return response['market_data']['current_price'][currency] + except (KeyError, requests.exceptions.RequestException) as _: + print(f'Failed to get gecko price {ticker} : {date} on try {tries}; retrying in {retry_delay_seconds}s') + time.sleep(retry_delay_seconds) + tries += 1 + return None + + +def get_gecko_prices(ticker, start_date, end_date, max_api_tries=6, retry_delay_seconds=15, currency='usd'): + dates = pd.date_range(start_date, end_date, freq='D') + prices = {'date': dates, ticker: []} + for date_time in tqdm(dates): + prices[ticker].append(get_gecko_price(ticker, date_time.strftime('%d-%m-%Y'), max_api_tries, retry_delay_seconds, currency)) + return pd.DataFrame.from_records(prices).set_index('date') diff --git a/treasury/treasury/models.py b/treasury/treasury/models.py new file mode 100644 index 0000000..e1be784 --- /dev/null +++ b/treasury/treasury/models.py @@ -0,0 +1,12 @@ +import numpy as np +import pandas as pd + + +def get_mean_variance_forecasts(prices, ref_ticker, forecast_window=60, trend_scale=1.0, vol_scale=1.0, num_sims=1000): + forecast_dates = pd.date_range(prices.index[-1], periods=forecast_window+1) + logret = np.log(prices[ref_ticker].pct_change().dropna()+1) + loc = logret.mean() + sigma = logret.std() + data = np.random.normal(loc=loc*trend_scale, scale=sigma*vol_scale, size=[forecast_window+1, num_sims]) + data[0] = 0.0 + return np.e**pd.DataFrame(data, index=forecast_dates).cumsum() diff --git a/treasury/treasury_config.json b/treasury/treasury_config.json new file mode 100644 index 0000000..e049d88 --- /dev/null +++ b/treasury/treasury_config.json @@ -0,0 +1,30 @@ +{ + "assets": [ + "ADA", + "AVAX", + "BTC", + "DOT", + "ETH", + "LTC", + "MATIC", + "SOL", + "TRX", + "XEM", + "XMR", + "XYM" + ], + "api_hosts" : { + "XYM" : "wolf.importance.jp", + "XEM" : "bigalice3.nem.ninja" + }, + "cm_key" : "", + "default_forecast_periods" : 90, + "default_num_sims" : 1000, + "default_ref_ticker" : "XEM", + "explorer_url_map" : { + "XYM": "https://symbol.fyi/accounts/", + "XEM": "https://explorer.nemtool.com/#/s_account?account=" + }, + "max_api_tries" : 6, + "retry_delay_seconds" : 15 +} \ No newline at end of file