Files
deploy/pages/orchestration/portfolio/app.py
2025-07-11 02:57:57 +03:00

362 lines
14 KiB
Python

import pandas as pd
import plotly.express as px
import streamlit as st
from frontend.st_utils import get_backend_api_client, initialize_st_page
initialize_st_page(title="Portfolio", icon="💰")
# Page content
client = get_backend_api_client()
NUM_COLUMNS = 4
# Convert portfolio state to DataFrame for easier manipulation
def portfolio_state_to_df(portfolio_state):
data = []
for account, exchanges in portfolio_state.items():
for exchange, tokens_info in exchanges.items():
for info in tokens_info:
data.append({
"account": account,
"exchange": exchange,
"token": info["token"],
"price": info["price"],
"units": info["units"],
"value": info["value"],
"available_units": info["available_units"],
})
return pd.DataFrame(data)
# Convert historical portfolio states to DataFrame
def portfolio_history_to_df(history):
data = []
for record in history:
timestamp = record["timestamp"]
for account, exchanges in record["state"].items():
for exchange, tokens_info in exchanges.items():
for info in tokens_info:
data.append({
"timestamp": timestamp,
"account": account,
"exchange": exchange,
"token": info["token"],
"price": info["price"],
"units": info["units"],
"value": info["value"],
"available_units": info["available_units"],
})
return pd.DataFrame(data)
# Aggregate portfolio history by grouping nearby timestamps
def aggregate_portfolio_history(history_df, time_window_seconds=10):
"""
Aggregate portfolio history by grouping timestamps within a time window.
This solves the issue where different exchanges are logged at slightly different times.
"""
if len(history_df) == 0:
return history_df
# Convert timestamp to pandas datetime if not already
history_df['timestamp'] = pd.to_datetime(history_df['timestamp'])
# Sort by timestamp
history_df = history_df.sort_values('timestamp')
# Create time groups by rounding timestamps to the nearest time window
history_df['time_group'] = history_df['timestamp'].dt.floor(f'{time_window_seconds}s')
# For each time group, aggregate the data
aggregated_data = []
for time_group in history_df['time_group'].unique():
group_data = history_df[history_df['time_group'] == time_group]
# Aggregate by account, exchange, token within this time group
agg_group = group_data.groupby(['account', 'exchange', 'token']).agg({
'value': 'sum',
'units': 'sum',
'available_units': 'sum',
'price': 'mean' # Use mean price for the time group
}).reset_index()
# Add the time group as timestamp
agg_group['timestamp'] = time_group
aggregated_data.append(agg_group)
if aggregated_data:
return pd.concat(aggregated_data, ignore_index=True)
else:
return pd.DataFrame()
# Global filters (outside fragments to avoid duplication)
def get_portfolio_filters():
"""Get portfolio filters that are shared between fragments"""
# Get available accounts
try:
accounts_list = client.accounts.list_accounts()
except Exception as e:
st.error(f"Failed to fetch accounts: {e}")
return None, None, None
if len(accounts_list) == 0:
st.warning("No accounts found.")
return None, None, None
# Account selection
selected_accounts = st.multiselect("Select Accounts", accounts_list, accounts_list, key="main_accounts")
if len(selected_accounts) == 0:
st.warning("Please select at least one account.")
return None, None, None
# Get portfolio state for available exchanges and tokens
try:
portfolio_state = client.portfolio.get_state(account_names=selected_accounts)
except Exception as e:
st.error(f"Failed to fetch portfolio state: {e}")
return None, None, None
# Extract available exchanges
exchanges_available = []
for account in selected_accounts:
if account in portfolio_state:
exchanges_available.extend(portfolio_state[account].keys())
exchanges_available = list(set(exchanges_available))
if len(exchanges_available) == 0:
st.warning("No exchanges found for selected accounts.")
return None, None, None
selected_exchanges = st.multiselect("Select Exchanges", exchanges_available, exchanges_available, key="main_exchanges")
# Extract available tokens
tokens_available = []
for account in selected_accounts:
if account in portfolio_state:
for exchange in selected_exchanges:
if exchange in portfolio_state[account]:
tokens_available.extend([info["token"] for info in portfolio_state[account][exchange]])
tokens_available = list(set(tokens_available))
selected_tokens = st.multiselect("Select Tokens", tokens_available, tokens_available, key="main_tokens")
return selected_accounts, selected_exchanges, selected_tokens
# Get filters once at the top level
st.header("Portfolio Filters")
selected_accounts, selected_exchanges, selected_tokens = get_portfolio_filters()
if not selected_accounts:
st.stop()
@st.fragment
def portfolio_overview():
"""Fragment for portfolio overview and metrics"""
st.markdown("---")
# Get portfolio state and summary
try:
portfolio_state = client.portfolio.get_state(account_names=selected_accounts)
portfolio_summary = client.portfolio.get_portfolio_summary()
except Exception as e:
st.error(f"Failed to fetch portfolio data: {e}")
return
# Filter portfolio state
filtered_portfolio_state = {}
for account in selected_accounts:
if account in portfolio_state:
filtered_portfolio_state[account] = {}
for exchange in selected_exchanges:
if exchange in portfolio_state[account]:
filtered_portfolio_state[account][exchange] = [
token_info for token_info in portfolio_state[account][exchange]
if token_info["token"] in selected_tokens
]
if len(filtered_portfolio_state) == 0:
st.warning("No data available for selected filters.")
return
# Convert to DataFrame
portfolio_df = portfolio_state_to_df(filtered_portfolio_state)
total_balance_usd = round(portfolio_df["value"].sum(), 2)
# Display metrics
col1, col2, col3, col4 = st.columns(4)
with col1:
st.metric("Total Balance (USD)", f"${total_balance_usd:,.2f}")
with col2:
st.metric("Accounts", len(selected_accounts))
with col3:
st.metric("Exchanges", len(selected_exchanges))
with col4:
st.metric("Tokens", len(selected_tokens))
# Create visualizations
c1, c2 = st.columns([1, 1])
with c1:
# Portfolio allocation pie chart
portfolio_df['% Allocation'] = (portfolio_df['value'] / total_balance_usd) * 100
portfolio_df['label'] = portfolio_df['token'] + ' ($' + portfolio_df['value'].apply(
lambda x: f'{x:,.2f}') + ')'
fig = px.sunburst(portfolio_df,
path=['account', 'exchange', 'label'],
values='value',
hover_data={'% Allocation': ':.2f'},
title='Portfolio Allocation',
color='account',
color_discrete_sequence=px.colors.qualitative.Vivid)
fig.update_traces(textinfo='label+percent entry')
fig.update_layout(margin=dict(t=50, l=0, r=0, b=0), height=600)
st.plotly_chart(fig, use_container_width=True)
with c2:
# Token distribution
token_distribution = portfolio_df.groupby('token')['value'].sum().reset_index()
token_distribution = token_distribution.sort_values('value', ascending=False)
fig = px.bar(token_distribution, x='token', y='value',
title='Token Distribution',
color='value',
color_continuous_scale='Blues')
fig.update_layout(xaxis_title='Token', yaxis_title='Value (USD)', height=600)
st.plotly_chart(fig, use_container_width=True)
# Portfolio details table
st.subheader("Portfolio Details")
st.dataframe(
portfolio_df[['account', 'exchange', 'token', 'units', 'price', 'value', 'available_units']],
use_container_width=True
)
@st.fragment
def portfolio_history():
"""Fragment for portfolio history and charts"""
st.markdown("---")
st.subheader("Portfolio History")
# Date range selection
col1, col2, col3 = st.columns(3)
with col1:
days_back = st.selectbox("Time Period", [7, 30, 90, 180, 365], index=1, key="history_days")
with col2:
limit = st.number_input("Max Records", min_value=10, max_value=1000, value=100, key="history_limit")
with col3:
time_window = st.selectbox("Time Aggregation Window", [5, 10, 30, 60, 300], index=1, key="time_window",
help="Seconds to group nearby timestamps (fixes exchange timing differences)")
# Get portfolio history
try:
from datetime import datetime, timezone, timedelta
# Calculate start time for filtering
start_time = datetime.now(timezone.utc) - timedelta(days=days_back)
response = client.portfolio.get_history(
selected_accounts, # account_names
None, # connector_names
limit, # limit
None, # cursor
int(start_time.timestamp()), # start_time
None # end_time
)
# Extract data from response
history_data = response.get("data", [])
except Exception as e:
st.error(f"Failed to fetch portfolio history: {e}")
return
if not history_data or len(history_data) == 0:
st.warning("No historical data available.")
return
# Convert to DataFrame
history_df = portfolio_history_to_df(history_data)
history_df['timestamp'] = pd.to_datetime(history_df['timestamp'], format='ISO8601')
# Filter by selected exchanges and tokens
history_df = history_df[
(history_df['exchange'].isin(selected_exchanges)) &
(history_df['token'].isin(selected_tokens))
]
# Aggregate timestamps to solve the "electrocardiogram" issue
history_df = aggregate_portfolio_history(history_df, time_window_seconds=time_window)
if len(history_df) == 0:
st.warning("No historical data available for selected filters.")
return
# Portfolio evolution by account (area chart)
st.subheader("Portfolio Evolution by Account")
account_evolution_df = history_df.groupby(['timestamp', 'account'])['value'].sum().reset_index()
account_evolution_df = account_evolution_df.sort_values('timestamp')
fig = px.area(account_evolution_df, x='timestamp', y='value', color='account',
title='Portfolio Value Evolution by Account',
color_discrete_sequence=px.colors.qualitative.Set3)
fig.update_layout(xaxis_title='Time', yaxis_title='Value (USD)', height=400)
st.plotly_chart(fig, use_container_width=True)
# Portfolio evolution by token (area chart)
st.subheader("Portfolio Evolution by Token")
token_evolution_df = history_df.groupby(['timestamp', 'token'])['value'].sum().reset_index()
token_evolution_df = token_evolution_df.sort_values('timestamp')
# Show only top 10 tokens by average value to avoid clutter
top_tokens = token_evolution_df.groupby('token')['value'].mean().sort_values(ascending=False).head(10).index
token_evolution_filtered = token_evolution_df[token_evolution_df['token'].isin(top_tokens)]
fig = px.area(token_evolution_filtered, x='timestamp', y='value', color='token',
title='Portfolio Value Evolution by Token (Top 10)',
color_discrete_sequence=px.colors.qualitative.Vivid)
fig.update_layout(xaxis_title='Time', yaxis_title='Value (USD)', height=400)
st.plotly_chart(fig, use_container_width=True)
# Portfolio evolution by exchange (area chart)
st.subheader("Portfolio Evolution by Exchange")
exchange_evolution_df = history_df.groupby(['timestamp', 'exchange'])['value'].sum().reset_index()
exchange_evolution_df = exchange_evolution_df.sort_values('timestamp')
fig = px.area(exchange_evolution_df, x='timestamp', y='value', color='exchange',
title='Portfolio Value Evolution by Exchange',
color_discrete_sequence=px.colors.qualitative.Pastel)
fig.update_layout(xaxis_title='Time', yaxis_title='Value (USD)', height=400)
st.plotly_chart(fig, use_container_width=True)
# Portfolio evolution table - total values
st.subheader("Portfolio Total Value Over Time")
total_evolution_df = history_df.groupby('timestamp')['value'].sum().reset_index()
total_evolution_df = total_evolution_df.sort_values('timestamp')
evolution_table = total_evolution_df.copy()
evolution_table['timestamp'] = evolution_table['timestamp'].dt.strftime('%Y-%m-%d %H:%M:%S')
evolution_table['value'] = evolution_table['value'].round(2)
evolution_table = evolution_table.rename(columns={'timestamp': 'Time', 'value': 'Total Value (USD)'})
st.dataframe(evolution_table, use_container_width=True)
# Main portfolio page
st.header("Portfolio Overview")
portfolio_overview()
st.header("Portfolio History")
portfolio_history()