Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: calculate a more realistic pnl #8

Merged
merged 1 commit into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -335,12 +335,7 @@ poetry.toml
pyrightconfig.json

### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
.vscode

# Local History for Visual Studio Code
.history/
Expand Down
Binary file not shown.
6 changes: 3 additions & 3 deletions portfolio_analytics/dashboard/app/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,18 @@ def get_currency_symbol(currency: Currency) -> str:
return symbols.get(currency, "$")


def create_pnl_figure(df_plot, pnl_column):
def create_pnl_figure(df_plot):
"""
Creates a styled line chart for PnL visualization.

Args:
df_plot (pd.DataFrame): DataFrame containing Date and PnL columns
pnl_column (str): Column name for PnL values to plot

Returns:
plotly.graph_objects.Figure: Styled line chart
"""
fig = px.line(df_plot, x="Date", y=pnl_column)
df = df_plot.copy().reset_index()
fig = px.line(df, x="Date", y="PnL")
fig.update_layout(xaxis_title="", yaxis_title="", showlegend=False)
fig.update_traces(
fill="tozeroy", fillcolor="rgba(0,100,80,0.2)", line_color="rgb(0,100,80)"
Expand Down
24 changes: 1 addition & 23 deletions portfolio_analytics/dashboard/app/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,28 +223,6 @@ def create_layout(app):
),
dbc.Row(
[
dbc.Col(
[
dcc.Dropdown(
id="pnl-type-selector",
options=[
{"label": "Unrealized PnL", "value": "unrealized"},
{"label": "Realized PnL", "value": "realized"},
],
value="unrealized",
clearable=False,
style={
"width": "200px",
"backgroundColor": "white",
"borderRadius": "8px",
"fontWeight": "500",
},
className="custom-dropdown mb-2",
),
],
width=3,
className="offset-1",
),
dbc.Col(
[
html.Div(
Expand Down Expand Up @@ -299,7 +277,7 @@ def create_layout(app):
),
],
width=3,
className="offset-4 d-flex justify-content-end",
className="offset-8 d-flex justify-content-end",
),
]
),
Expand Down
63 changes: 41 additions & 22 deletions portfolio_analytics/dashboard/core/data_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,27 @@ def validate_and_load(
raise MetricsCalculationError(f"Error loading data: {str(e)}") from e


def prepare_positions_prices_data(
def prepare_data(
portfolio_path: Path,
equity_path: Path,
fx_path: Path,
target_currency: Currency = Currency.USD,
) -> pd.DataFrame:
"""
Prepares and caches raw data, including portfolio positions,
trades, and FX converted portfolio values and cash flows.
"""# noqa: E501,W505 # pylint: disable=line-too-long
Prepares and caches raw data used to calculate PnL.
This includes portfolio positions, trades, and FX converted
prices, portfolio values and cash flows.

The data is indexed on (Date, Ticker).

Returns an expanded view of the PnL in the following shape:

(Date, Ticker) Positions Trades EquityIndex Mid ... PortfolioValues CashFlow
2024-10-30 QRVO 0 0.0 SP500 121 ... 0.000000 0.0
ROP 65 0.0 SP500 122 ... 27410.009023 0.0
SMCI 0 0.0 SP500 120 ... 0.000000 0.0
TSLA 0 0.0 SP500 121 ... 0.000000 0.0
UAL 0 0.0 SP500 123 ... 0.000000 0.0
"""
try:
# Create cache directory if it doesn't exist
Expand All @@ -122,18 +134,28 @@ def prepare_positions_prices_data(
portfolio_path, equity_path, fx_path
)

prepared_data = join_positions_and_prices(positions_df, prices_df, fx_df)
df = join_positions_and_prices(positions_df, prices_df, fx_df)

# Calculate USD-converted values
df["PortfolioValues"] = df["Positions"] * df["MidUsd"]
df["CashFlow"] = (df["Trades"] * df["MidUsd"]).apply(lambda x: -x if x else 0)

# Convert to target currency if needed
if target_currency != Currency.USD:
fx_rates = prepared_data.groupby("Date")[
f"USD{target_currency.name}=X"
].first()
prepared_data["PortfolioValues"] *= fx_rates
prepared_data["CashFlow"] *= fx_rates
fx_rates = df.groupby("Date")[f"USD{target_currency.name}=X"].first()
df["PortfolioValues"] *= fx_rates
df["CashFlow"] *= fx_rates

df["Currency"] = target_currency.value

# Drop fx columns and rename to Mid
df = df.drop(columns=[col for col in df.columns if col.endswith("=X")])
df = df.rename(columns={"MidUsd": "Mid"})

prepared_data.to_parquet(cache_file_path)
return prepared_data
log.debug(f"Prepared DataFrame: {df.head()}\n{df.tail()}")

df.to_parquet(cache_file_path)
return df

except Exception as e:
raise MetricsCalculationError(str(e)) from e
Expand Down Expand Up @@ -175,6 +197,11 @@ def join_positions_and_prices(
]
combined_df = combined_df.join(fx_pivot, on="Date")

# Forward Fill FX to cover full portfolio date ranges
for col in combined_df.columns:
if col.endswith("=X"):
combined_df[col].ffill(inplace=True)

# Edge case when first row has null values, remove it
combined_df = combined_df[
combined_df["Mid"].notna() & combined_df["Currency"].notna()
Expand All @@ -195,15 +222,7 @@ def get_fx_rate(row):
# Calculate USD-converted values
combined_df["MidUsd"] = combined_df["Mid"] * combined_df["FxRate"]

# Drop intermediate FX rate after conversion
combined_df = combined_df.drop(columns=["EURUSD=X", "GBPUSD=X", "FxRate"])

# Calculate USD-converted values
combined_df["PortfolioValues"] = combined_df["Positions"] * combined_df["MidUsd"]
combined_df["CashFlow"] = (combined_df["Trades"] * combined_df["MidUsd"]).apply(
lambda x: 0 if x == 0 else -x
)

log.info(f"Combined DataFrame: {combined_df.head()}\n{combined_df.tail()}")
# Drop Mid and intermediate FX rate after conversion
combined_df = combined_df.drop(columns=["Mid", "EURUSD=X", "GBPUSD=X", "FxRate"])

return combined_df
87 changes: 77 additions & 10 deletions portfolio_analytics/dashboard/core/pnl.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
Profit and Loss calculation module for the Portfolio Analytics Dashboard.
"""

import datetime as dtm
from typing import List, Optional

import pandas as pd

from portfolio_analytics.common.utils.logging_config import setup_logger
Expand All @@ -13,20 +16,84 @@
log = setup_logger(__name__)


def calculate_pnl(positions_prices_df: pd.DataFrame) -> pd.DataFrame:
def _validate_date_range(
df: pd.DataFrame, start_date: Optional[dtm.date], end_date: Optional[dtm.date]
) -> None:
"""Validate the provided date range against the DataFrame's date range."""
portfolio_start, portfolio_end = df["Date"].min(), df["Date"].max()

if start_date and end_date and start_date > end_date:
raise MetricsCalculationError(
f"Start date {start_date} is after end date {end_date}"
)

if (start_date and start_date < portfolio_start) or (
end_date and end_date > portfolio_end
):
raise MetricsCalculationError(
f"Date range [{start_date or portfolio_start} -"
f" {end_date or portfolio_end}] outside portfolio range"
f" [{portfolio_start} - {portfolio_end}]"
)


def _filter_dataframe(
df: pd.DataFrame,
start_date: Optional[dtm.date],
end_date: Optional[dtm.date],
tickers: Optional[List[str]],
) -> pd.DataFrame:
"""Apply date and ticker filters to the DataFrame."""
filtered_df = df.copy()
portfolio_start, portfolio_end = df["Date"].min(), df["Date"].max()

# Apply date filters
filtered_df = filtered_df[
(filtered_df["Date"] >= (start_date or portfolio_start))
& (filtered_df["Date"] <= (end_date or portfolio_end))
]

# Apply ticker filter if provided
if tickers:
filtered_df = filtered_df[filtered_df["Ticker"].isin(tickers)]
if filtered_df.empty:
raise MetricsCalculationError(
f"No data found for provided tickers: {tickers}"
)

return filtered_df


def calculate_pnl_expanded(
raw_df: pd.DataFrame,
start_date: Optional[dtm.date] = None,
end_date: Optional[dtm.date] = None,
tickers: Optional[List[str]] = None,
):
"""
Filter raw dataframe based on dates and tickers, then calculate PnL raw values.
"""
df_sorted = raw_df.copy().reset_index()

# Calculate cumulative cash flows and PnL per ticker
_validate_date_range(df_sorted, start_date, end_date)
df_sorted = _filter_dataframe(df_sorted, start_date, end_date, tickers)

df_sorted["CashFlowCumSum"] = df_sorted.groupby("Ticker")["CashFlow"].cumsum()
df_sorted["PnL"] = df_sorted.apply(
lambda row: row["PortfolioValues"] - row["CashFlowCumSum"], axis=1
)

return df_sorted


def calculate_daily_pnl(pnl_expanded: pd.DataFrame) -> pd.DataFrame:
"""
Calculates portfolio PnL from position data with currency conversion.
Calculates daily PnL given full PnL data
"""
try:
# Calculate base PnL
pnl_df = pd.DataFrame()
pnl_df["pnl_unrealised"] = positions_prices_df.groupby("Date")[
"PortfolioValues"
].sum()
pnl_df["pnl_realised"] = (
positions_prices_df.groupby("Date")["CashFlow"].sum().cumsum()
)

pnl_df["PnL"] = pnl_expanded.groupby("Date")["PnL"].sum()
return pnl_df

except Exception as e:
Expand Down
Loading
Loading