From 18b282cb195d0bbb8a67c52fbe72ecae4de706ee Mon Sep 17 00:00:00 2001 From: Guillaume Bournique Date: Tue, 17 Dec 2024 22:58:27 +0100 Subject: [PATCH] feat: calculate a more realistic pnl --- .gitignore | 7 +- ...c610e017d8c32fd31a6b03c60ede52a5cc.parquet | Bin 40430 -> 0 bytes .../dashboard/app/components.py | 6 +- portfolio_analytics/dashboard/app/layout.py | 24 +--- .../dashboard/core/data_loader.py | 63 ++++++---- portfolio_analytics/dashboard/core/pnl.py | 87 ++++++++++++-- portfolio_analytics/dashboard/core/stats.py | 111 +++--------------- .../dashboard/dashboard_main.py | 103 ++++++++-------- scripts/run_analysis.py | 54 +++++++++ 9 files changed, 243 insertions(+), 212 deletions(-) delete mode 100644 portfolio_analytics/common/data/cache/7f61ffc610e017d8c32fd31a6b03c60ede52a5cc.parquet create mode 100644 scripts/run_analysis.py diff --git a/.gitignore b/.gitignore index 77f2dfe..a326a14 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/portfolio_analytics/common/data/cache/7f61ffc610e017d8c32fd31a6b03c60ede52a5cc.parquet b/portfolio_analytics/common/data/cache/7f61ffc610e017d8c32fd31a6b03c60ede52a5cc.parquet deleted file mode 100644 index ad9410b74b5584a2742469fcabba6485b98decfb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40430 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%0A*-|!I)PNvPjaE+tOJ(#;Q zAx1oso>mDu5ZD81_TTS(MRWh#9Y7rf#RXNX16>|}e#h?P41H%0b2oZ65(d{&y)e9P z$?nZT-N4wRPZnN=WlrngJ72uZV6$>_PBQe%h`!&H?QrIGg8$ETP4MH&>g6B1s^Od8 zhZB+O?qMO6XWIOecqOlH{W^p^7W-#eb-^63Dp<3)8~UX#58l@qQuS6pR{ftJ<7d

D~W5sEKVzD48 z>pndG=LI;@xzk_vkhVEH{Hh`6q;@zD|Am8=G&RZCryoM{b@TlWYYPmC#5%)jIHcv} zTJ(>$N?!zjPV(tH@T%>yrLTK6W*2539eWu*=6`5tP`m-+jwEnm&OzX%9iAM0BnL7a z9H#t`x{vupPiKsw^^aojxiqvssf+0Q08S&VbW)!T>7OXPuMtjcG_m3>8R~fX6QOtD zLX9ZBVO=%oim2WKNI!AYeSh067}QLu-?CsYIPJc-!0P2r_&z@}tM&9_EM3bj%5G@% zDwKX;wDj2s8!H?KVYPGX^N3L|prmJoqWCM?TJi5a&uRnFwWDo{QU{>y^~0xAFE@i) z!=|hm{6pZcS8-*1M+Im<-?;n2RwjoPtoK}=u)?%nCuLRP)1=O1?+2)S(_lGy*mKYd zIK6Gr9cuwwm^!( zDz>EtI^2#JWQ@E9n<`9PqSqIK%L&!d)14b3Z=Z?E%;m3@5Uomj}lZ6zgD;<>_LQAF>oSp?Mbm~g5nJq&d%AC z4_A51FDzSA3-?TuB(lzTB3tcifB2qefb)&!$ECn3GxT!Lq}P>X&+;W5^sk^x6_nx> z{^Lhcd=J{_4*e(!a}we2o!S4S(uGKe|M`KO^bDNB7=)#QR0jQ!KB0(^q0Rw}3(=1~ zUib{gh&~H^CC0cEA_`q>3-J$dNH!6Q31tZJszNG31t26H3y~wH;XQ;B7Y?T>Bg7C4 zEt0-u4Ej)Ktmi4M#JIIQ70B%M8BR)SzPPgA*z1JvHzeJ*ZLg!8j8Q zB?(9#Bi(enk4fl#EF?>gkmZl6BE;L}a0`S`4A%G+YG+q>%krrZrF@%Z@YTp;HAx|AuN6T!+}^j4KNZ%WW~&rIEQND z(TTsH2Pr5PB1BL5B$?=miM(!g+NF+0|Nd1-*+Ku8NBamOOhdQ-tO=`i8h2r4PP77g z8YQm95@i@cVTcJ{ebjBv#%TKVk?8ZDgou?zk0efntQKoIyE}OPK};HyrWF%EEQ6s) zf|!&L21@ij_P)cPNKwdu!Mze625Dieohyy!fvVerPQJdi;+>y`1f3Msr}%9IbC#D8?}t#8#T^dUcU6d~u3QWT02`cpNG ztXy=bf*7`>2XR8jjC8j zDg0*|!f&6YFecPo{By(C0UU9BO(Oh;l;0ov_mVbBt>CCs$@=|kSMC2}$C3K8<4FD4 zaisq2I8yjHz2ivz_T=tJ{n>G({%gmv|2Bi(a-@EHXFvAu)+2ReUi$>G!gcUwLT~^5 zGr9k6MN zG0pkvyy~;mpRGx%EXO|Z((DSV(Cy#_N%JFs`RF}L>bIx#3<~e*ElSG7uD3?5T|wlJyR9?ehwyM;+{qg-zsSy4fk9}qP81ohij zc>`ti{)EN-LyKVfnC0DV!}d^rwlt~VzRJt79uk&T81e6xCVg*i9Mc5b#N}-T(OJ}x zCvuBJ==}D^MzyP@|88y4`2wDes)dh5Yd@CM)KFy|!*w4{IST!I`{s@9*h#tWc&F*< zpZM?ACY^tIP+lzcQZXowV|ZMaHmUv9)0^U#?*YSEDUK^_5~wz#7Dof&N$R&t@H9X%*~!Gk z!RXAv;YP9h8G=6FeK7nMG|bwgmTy-=y?nHmQ^$BniS9HhzZ%E@^|RThYR9+yao@e> z-yKiNUS-6^Ybn^VeL7@Ej_MRX%>5 zPu?G=+Z0`kU(gN#_0x6tW}f>&T;w?VLj$y>93SE+_TdTXsB_L+(y{{0Juf+LjqP=RBAhn#kT8N3WI%y2$pR(;qF}a0-Zj zADA4V{%m&A=4>MVHIxL26M#uY6{|*?2H#@=&GCZw{C)B&)~ebxzQ8~kn=6Dm6~OCvv9qP2u_SE zn|RGd8<)oGx|0tw1cvB0Z7=1#-u0FKiV{YWY^L|OW4P2zjAN8iSu)xk>~)u6@P+Y{odnj28EN|dyYvKQ=CcXw;!LA zLq#4Qrm+0#3E)2LzVb=p9Ow%A(l5GGhg>$uugJ-P>T#27xxY?Q>QApJM{|c>eYlY} z87Kqcdc}YoY&&`4@*O*s-V%(ffpBe&DN_?mAikXcYJcExKmwBd6$HMJ?@OXB`B zV3fCkQ`eA(&k2_WosEYjV_GDovSaa{>~1Wn`{M*U;*ZujK` zTl9k@T_E`Ex{FEs)-ibKENN~brP;lsH=+JGwQ=#K`@J=~eXN#4y1G2{hn61;vgG{i zVDzvcUwSc<5!L5QZ+lYOo=JvrXG^GQmzN)lsz|0}vn1+jALjg*TjpEs^HSS2>ySNa z_vkh0)X9J9mpt4()JCK4?v3zag8B>g!94Jb&09Pp@-W>DIj2`;48E*Ryi_iO)q|U# z+0^&dSvED9^{`ZVL-!on|JB=*fHie&eJ>;!Fe)M-SX4xfRHFnC1nY8>5Fii&1V{qf zCm{(WkN`qh;?}s(qNQ%Am8vZ+C@NL77I5vwtyQtl1+CVl+P+$~whygUYhB)%n*<@m zF8}X)`8boAb7tn8nK^T2?g`(yJBT{|39R+|c|_BO(F=EM+(-7C^jaNb@M;oTLpbGk zh)IjH)L&=@)h1wHuzzSJKPYVc8sGAh$Tcs zR@XACs@BZLo7k-_m6t5Bj+;U4Zk>*wi^~}h?6^!GePO+SCi%l}k=K5dA&0rC_Veta zrRDb$gFYsr3!EPMNp_Gw6;A6N_-PsGz#pd^|KmPu9AkoUbJ{Ys7xVt}$?xHE-IJBS zG?Vvo_Ah@6Vkc3NilcTwhggF!atVy*$@AZ56O~N|zhl*GgO`l-dVl$DoUyp^z?B_D z-mH1G<+C7DJG`-uoD}HhtTY}W1lMQ~gP6m3TM5~2(TA$S zCej>bcs9ZL_j!4gcq=^4@nV7$kJ_G^zEW;GNH)$}SNBO|8L{F*b=9Wq9HMT5{2MlF zC+Rf5GO4leF_PLP*xT@pg?+NInrPPQ-A@E>C5=7-OqZGG35%zbyhV9BDMJnse@h6c zUwO=UoJ@G{^Pk?hxP%CA5gz&Q-jenhzn(*U_D=IziTaIcdQghKL$6>K^>&myK?%05%Em7= z6K9C_57=$8dO%5Y0Nq+Vzm1PPpyb;jIhPJT*+#zJ|KgaNG!xN3Ay*XI_fz7t#W@Q` zJSGX~ogJJr8F4uiEILUR*ZFRaom!3XyPiGV39mgmT~Ss?Y<=L&nmB7Oc`oh#^oegC z#Jw1;Epy8f%2iHV8;SF}oZ|1ceQb$qDchR{cZtfg2DeqXoQYhbxWA_EYQF|zAFZu^<)B*f_n)uL3Ou}( ztb1?pNsp@nk{NkjIuGaQ*p*cmiQ~gUo8yLjY>6FPL2lgB`=VcEH6C3HL1POnfnndb zdPm8Tlf(nJ!msO9SqIkkZ#{s<{OI|_GJ=VGqj(s*=~MAHgnQf+zRTV1xN9`>jFQ%9 zuZM1D<=pWb%BV+vQPLdAHH#ea<n>$Cqn~aMCJE-OCpE7Thd>C^o!JZM>5CyW>r&DW7bkA< zt|Kj;QL^nuzgov(O9{_P&GeK&`{PtG-1oTD`y#=XRwH5e1cNCki2L!;Ma z@{2gpl!mJLxGNh=7<7WjrbQ&JIk^q=7S8V@CJpk3lH5C!FWelvjtDhL z(xFArj4@5WKi_JiTTyC%{cfYd-%MK5Tq$k~FXGi}j;xnO)IXYL$&%AbAb+6hvdyG7vg zRE^?pzdIXFwqZB+IyB==5P8tBi98+H``fRVyDfIntf~4(4e8uEmAge;PgSNXSdbjd&uE=9Pan&JMePO>MF^8 za`(A~Zp*H1AO=;J=M*d<#&_Rw)^Cb zTO>BZnFFytm*R{e6Nl#=0?+NK;bT0@UD!8!f8I#meH#0GM_moBfnb3ZHAFdvDH;jM z*2c=I{Wp=W5!J<`h8u`e3$I*wRzHjIt!w>g^+C6K7e+|%S_R>1TAQz2ffMW%;d8zw zmo3*CKRU4xclC!~+m8`-OLK(Rua%MP@h*aTvd!T7>^|*Nd~tc$@u?duuFpDR;4MNk z{)e|n&$*Y*ykA&P&N_NI%H_D*1qS27`s$ud-I*pg`dPPhXVJ!ltgnfR1smUHej6?yoOMIi%pI_wu&rz zgDCJQX&`I!zTI#(_H$5zo*BKcsSuP{(6rO7z9)NfZRj^dXxXp*@{VoBquIyx$Hg+g-uI^k-NFGjLY#vrc%I3tfu3TM0EO&VGp5j3jxu_+2^-p_|vB!Q) z{BZV4rs<0opRz~ns;b^c$qB^{lZ_L%;|UOM^LGx<66?`S6OPXb3I}FF8$}} zjk5^iv1CTUv3CjXouHX3B5EEnD-TYBmrF8dxPPg`HJm*Y>RJiM`SfjDwrnB6^?9R@ z$DBSku1}0IUS`)Py8c`KDb)rGg8$;2;0Rw3|IuYEyww!kAS4tC49C5|XXphkGQ4}3 z1422tut~xZ3v0vZxI3+PG)B_!?kYOGO4mfkCc_I0F;#W56LiJxHUQFY_<*()#-MTx zqR%c)nBxpDR>WN3c%9=i0($En3Vzm+O^^$~Fg`P=1$qvF$?q}d8sNeKu(5X_83!+{ z#3nq!B3yY$$Ov~F-n5ISsW2bNL}QERxy5o$&+8KpRoxS#0hdG{fF;oP-fzGY$~7JZ zY=9AbIoR~J)9(z#p1}Of#(WvrTvti?pm%&72)_kvn4OBz4yM7_ICz2O5_sJzR+%7~ zKlJ@{AXI4}S(WcV48uwt$YIr(s|&WSg6D$OtOUZQYDhNMI*`M$dIw^}HmuDBt3T{_ zg1xI12-pQk8m~DJqabe@wHG71WBVW7>&|Irz>*zuh2)4=Au|Z5M9^^zw6A6KcP+uE zB7ks41W9YM@+3?u+=VV;)jhCF`Dfo7^Nk4z*UKUK4kN_WL}NFw{GQm&4eP%gduJCA ze%=R3+u_0gq$VHx6-)1hJ-k-AXu|JpKzNEeeD=gKfxsa27Uqb9$Q z77tqsx!3V(H*Ca?)}^rl%}@+H21zhZ^uaVt*f+SMn1{BKa4h=Vhmi~F`F zV6Ko8;OXvI)S%_dB(bPxQ3&9~c$<52R-%wjCL;l`nR=Hb~#|&Z0ArfMVU8jk9P(^G9Mq{x?rRerUcS^;Sao2 zSOj2~#zC?SNXt{5NHvgFsyBLLtEK{WjS-TMfK*-PWYPd>Jx-))H?9F}Z4D$_fV6eT z`44h{v>nGL>2{t3?5=Z=Gytjb`ifP#^Z{Ob?>Z5A2c9Ypxzf8~*g>Ei>MdJ66&ig$ z=>63NCjtR`G8~eB0_l{Xo5=vAvx%}bMPDfZdr=F?B_Lfc`r%3meE{cL*|4jn|5^^% z8*94J-N1o*vu4xPX?Jk{aX;;TIAF%zV?g@lG$g;`?(Pq8jGXxhcNzEFt;T_GJ^c;P z&v6F)&@y|GyF*WSTRk0UP6Nu*=eUdlG&2;Ep3trrk3r0H!(BvuB=WUoeNg~A?`%k@ z54cXRIe6T35P8Tc&uKm0w*6@wbSj3QWdIxjXaID8ZU77bX8;#~?f^XjdI7is^agMP z@Br`x=nLQl;0@pd&>vs`z(4?BfWZJm0EPmv0EPh!2N(gs1{eu23Lp?52p||>G(ZSI z7(h6{SO75MBo1-?IvQ~rB6!izP{4S1D8Z=H+or%yL`M6+6u*x%0;cs_2ajDatq@_- z4a0WeKCsO&E;m60kN-TL)Dd^}5s8qQ;&E{gCQm5j;T{ZrJiOzU79qfUFe0R4ya!Dn z!h84&5|d#+U{Ue$Q4r@ZS}4TBnL=?gA0NnwjYz|SZYLvWmpuD+)D{y1Ww1!vQMMB7o6QrbuTM$SGN? z%uyFu#PK9uy?wkdvoIeReF)~{>fK?$9uOJY)w5-|XKjDh+dWteA1}s0WGLa&{#z^3 zSvy$Fn+=KuA|RD_g)i7Qz{ADD!!n6x1`V%y9`;~87|g2g4;-lOP0>Vq&U&SRqp>6Q zPdK2JF?TycuVG z(2T?Ay9Q)Mqwk7+$>^;s*siB$E-M(GkDXWxKgU_u4k9ivn>HayUlOR){$M(Cq13;f z%RMKS=}VmT^)U+)l%$*gVvc4Y0@=4f6Xn)ws6O2fPKP{|Ha82kO-YU%8Rj>0<_lpu zOX1gk8li4F3DcQmBa=4Q5IPz)=`2w@x`RMypflMZmt~NC<^!)QzZZBLW|_J?g2t!}{+nbC+4Q-V3_2?MQjWJgwIbJ4`#0Y`B7^stsD$ z1zq&VN(mi3si^QxME|38wO(zya_>m7VctP!I_qu+!k;juP7u3H+bOS@DJUyk)4H;J zfq%seZLWe{Ioql5ibb&AJG=7kNO{Fftv0-~CA4B~m?3RN(skA&yH>f&cC{Q5(7H3L z7kC?H{|ECx8GW=5^9rbgL8r|qP!t%^xsY#E6cpr@AnA+M1%>c9g0O*MqXI{9{J>iK zS-5u~28{|D73?UBkFQPR=!AaQX< zwA7H18>fxe@-l=u5{)Vk+FeW4uKSnAQq#EM+G2xlgPO?u!^<}|@+IG44F^L($;(S$~gQ3i%{)N-B&kE6fOr{3Dt4 z8ByUHRYa=Yd4(|)W<As;)A`5_pgm+q|IvPheXBF1C8u&^!P04R^H~4dUXY3f z5~(yPL26z*JGv)L;YcE-Qen8vyf*gc&_>_8+8G-i{p+%EViNyEw&+A1_kS*1vP_pF zm5Xy;wl6x-|93V7Y;FuEO&?yUkcG=qWu<{>@;|b@_Uqc!!~cqn5o?q|nkcZ9!7rP~ z#GuG&3IW*LVC73~i;ZsCu`M?C`Y+kP7w5H|Jt07Qj*{8#+P-LOXTKJw>hpD}!Ityv zugId4fbBHslv>Jm>J>6+VOnJP3tQcpZf}47zwQBM8yBcT_9rzsMh9ar23bO>^D9cH zRq7*+U|-SwphPW;1ecNf&!94-> z$rl<$*}|eYV@x>Qs^Ct=Hr2bJScIRV|* zBH~be9ZIO4;PM6^>b@pU4i5+jgOVT;Y6FtG&rw-amM4L| zrZP*awzTJSBW>El=&k&y92fP?v$y}bImKfF0#JWh{5VU0JZ>q~3JtQO{M0O2Z5IeV zqMg6UhCjOaTE>eq=KC-YofT$#OyMG|WL{77PJ8ss1zl9L=$wrFCjXp z2qkKMd;2)55j=#z=S6!;D+Pi1Y21jUc6sBbLAHawbXjk+e!|{^X9h~mkqU!osTNfR z7FkG$M3_9J{Yq{GVrvx& 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)" diff --git a/portfolio_analytics/dashboard/app/layout.py b/portfolio_analytics/dashboard/app/layout.py index e4f351f..a89c1e0 100644 --- a/portfolio_analytics/dashboard/app/layout.py +++ b/portfolio_analytics/dashboard/app/layout.py @@ -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( @@ -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", ), ] ), diff --git a/portfolio_analytics/dashboard/core/data_loader.py b/portfolio_analytics/dashboard/core/data_loader.py index 9b7014e..ea69743 100644 --- a/portfolio_analytics/dashboard/core/data_loader.py +++ b/portfolio_analytics/dashboard/core/data_loader.py @@ -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 @@ -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 @@ -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() @@ -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 diff --git a/portfolio_analytics/dashboard/core/pnl.py b/portfolio_analytics/dashboard/core/pnl.py index 5c979e0..39f05ce 100644 --- a/portfolio_analytics/dashboard/core/pnl.py +++ b/portfolio_analytics/dashboard/core/pnl.py @@ -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 @@ -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: diff --git a/portfolio_analytics/dashboard/core/stats.py b/portfolio_analytics/dashboard/core/stats.py index 00fa2a9..9b21775 100644 --- a/portfolio_analytics/dashboard/core/stats.py +++ b/portfolio_analytics/dashboard/core/stats.py @@ -4,7 +4,7 @@ import datetime as dtm from dataclasses import dataclass -from typing import List, Optional, Tuple +from typing import Tuple import numpy as np import pandas as pd @@ -29,9 +29,7 @@ class PortfolioStats: period_pnl: float -def _calculate_drawdown( - df_sorted: pd.DataFrame, pnl_column: str -) -> Tuple[float, dtm.date, dtm.date]: +def _calculate_drawdown(df_sorted: pd.DataFrame) -> Tuple[float, dtm.date, dtm.date]: """Helper function to calculate drawdown metrics. Calculates the maximum drawdown and its corresponding dates from a DataFrame @@ -41,8 +39,6 @@ def _calculate_drawdown( Args: df_sorted (pd.DataFrame): A sorted DataFrame containing PnL data with a 'Date' column and the specified PnL column. - pnl_column (str): Name of the column containing PnL values to calculate - drawdown from. Returns: Tuple[float, dtm.date, dtm.date]: A tuple containing: @@ -55,100 +51,38 @@ def _calculate_drawdown( a 'Date' column of type datetime.date. """ # Calculate drawdown - df_sorted["pnl_max"] = df_sorted[pnl_column].cummax() - df_sorted["drawdown"] = df_sorted["pnl_max"] - df_sorted[pnl_column] + df_sorted["PnLMax"] = df_sorted["PnL"].cummax() + df_sorted["Drawdown"] = df_sorted["PnLMax"] - df_sorted["PnL"] # Find maximum drawdown - max_drawdown = df_sorted["drawdown"].max() - max_drawdown_idx = df_sorted["drawdown"].idxmax() + max_drawdown = df_sorted["Drawdown"].max() + max_drawdown_idx = df_sorted["Drawdown"].idxmax() max_drawdown_date = df_sorted.at[max_drawdown_idx, "Date"] # Find drawdown start - cumulative_max = df_sorted.at[max_drawdown_idx, "pnl_max"] + cumulative_max = df_sorted.at[max_drawdown_idx, "PnLMax"] drawdown_start_idx = df_sorted[ - (df_sorted[pnl_column] == cumulative_max) & (df_sorted.index <= max_drawdown_idx) + (df_sorted["PnL"] == cumulative_max) & (df_sorted.index <= max_drawdown_idx) ].index[0] drawdown_start_date = df_sorted.at[drawdown_start_idx, "Date"] return max_drawdown, max_drawdown_date, drawdown_start_date -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_stats( - pnl_df: pd.DataFrame, - use_realized: bool = True, - start_date: Optional[dtm.date] = None, - end_date: Optional[dtm.date] = None, - tickers: Optional[List[str]] = None, -) -> PortfolioStats: +def calculate_stats(pnl_daily_df: pd.DataFrame) -> PortfolioStats: """ Calculates performance statistics with optional date range and ticker filtering. """ - df_sorted = pnl_df.copy() + df_sorted = pnl_daily_df.copy() df_sorted.reset_index(inplace=True) - pnl_column = "pnl_realised" if use_realized else "pnl_unrealised" - - # Validate and filter the DataFrame - _validate_date_range(df_sorted, start_date, end_date) - df_sorted = _filter_dataframe(df_sorted, start_date, end_date, tickers) - # Calculate metrics - max_drawdown, max_drawdown_date, drawdown_start_date = _calculate_drawdown( - df_sorted, pnl_column - ) + max_drawdown, max_drawdown_date, drawdown_start_date = _calculate_drawdown(df_sorted) - daily_return = df_sorted[pnl_column].diff().copy() + daily_return = df_sorted["PnL"].diff().copy() sharpe_ratio = (daily_return.mean() / daily_return.std()) * np.sqrt(252) - period_pnl = df_sorted[pnl_column].iloc[-1] - df_sorted[pnl_column].iloc[0] + period_pnl = df_sorted["PnL"].iloc[-1] - df_sorted["PnL"].iloc[0] return PortfolioStats( max_drawdown=max_drawdown, @@ -160,35 +94,24 @@ def calculate_stats( def get_winners_and_losers( - positions_prices_df: pd.DataFrame, - start_date: Optional[dtm.date] = None, - end_date: Optional[dtm.date] = None, + pnl_expanded: pd.DataFrame, top_n: int = 5, ) -> Tuple[pd.DataFrame, pd.DataFrame]: """ Calculate top winners and losers based on portfolio values (unrealised PnL). Args: - positions_prices_df: DataFrame containing portfolio positions and their values - start_date: Optional start date for filtering - end_date: Optional end date for filtering + pnl_expanded: DataFrame containing full PnL data top_n: Number of top/bottom performers to return Returns: Tuple of (winners_df, losers_df) """ try: - # Filter by date if provided - df = positions_prices_df.copy() - if start_date or end_date: - dates = pd.to_datetime(df.index.get_level_values("Date")).date - df = df[ - (dates >= (start_date or dates.min())) - & (dates <= (end_date or dates.max())) - ] # Calculate PnL per ticker - ticker_pnl = df.groupby("Ticker")["PortfolioValues"].sum() + pnl_expanded.sort_index(inplace=True) + ticker_pnl = pnl_expanded.groupby("Ticker")["PnL"].last() # Create DataFrames for winners and losers winners = pd.DataFrame( diff --git a/portfolio_analytics/dashboard/dashboard_main.py b/portfolio_analytics/dashboard/dashboard_main.py index 752f5f9..b2925c4 100644 --- a/portfolio_analytics/dashboard/dashboard_main.py +++ b/portfolio_analytics/dashboard/dashboard_main.py @@ -33,8 +33,11 @@ create_stats_row, ) from portfolio_analytics.dashboard.app.layout import STYLES, create_layout -from portfolio_analytics.dashboard.core.data_loader import prepare_positions_prices_data -from portfolio_analytics.dashboard.core.pnl import calculate_pnl +from portfolio_analytics.dashboard.core.data_loader import prepare_data +from portfolio_analytics.dashboard.core.pnl import ( + calculate_daily_pnl, + calculate_pnl_expanded, +) from portfolio_analytics.dashboard.core.stats import ( calculate_stats, get_winners_and_losers, @@ -108,35 +111,34 @@ def _handle_button_styles(ctx, date_picker_style, trigger_source=None) -> dict: def _handle_date_range(ctx, new_min_date, new_max_date, start_date, end_date): - """Calculate date range and date picker visibility based on user interactions. - - Args: - ctx (dash.callback_context): The Dash callback context with trigger details - new_min_date (datetime.date): The earliest possible date in the dataset - new_max_date (datetime.date): The latest possible date in the dataset - start_date (datetime.date | str): The current start date selection - end_date (datetime.date | str): The current end date selection - - Returns: - tuple: A tuple containing: - - start_date (datetime.date): The calculated start date - - end_date (datetime.date): The calculated end date - - date_picker_style (dict): Style dictionary controlling date - picker visibility - """ + """Calculate date range and date picker visibility based on user interactions.""" date_picker_style = {"display": "none"} current_date = dtm.datetime.now().date() - # Set default to max range when no trigger or changing portfolio/currency/pnl - if not ctx.triggered or ctx.triggered[0]["prop_id"].split(".")[0] in [ - "portfolio-selector", - "currency-selector", - "pnl-type-selector", - ]: + # Convert string dates if needed + if isinstance(start_date, str): + start_date = dtm.datetime.strptime(start_date, "%Y-%m-%d").date() + if isinstance(end_date, str): + end_date = dtm.datetime.strptime(end_date, "%Y-%m-%d").date() + + # Get trigger information + if not ctx.triggered: return new_min_date, new_max_date, date_picker_style button_id = ctx.triggered[0]["prop_id"].split(".")[0] + # Keep date picker visible if it's already shown and we're interacting with it + if button_id == "date-range": + date_picker_style = {"display": "block"} + # Ensure dates are within valid range + start_date = max(start_date, new_min_date) + end_date = min(end_date, new_max_date) + return start_date, end_date, date_picker_style + + # Handle other triggers + if button_id in ["portfolio-selector", "currency-selector", "pnl-type-selector"]: + return new_min_date, new_max_date, date_picker_style + date_range_mapping = { "1m-button": relativedelta(months=1), "6m-button": relativedelta(months=6), @@ -149,18 +151,11 @@ def _handle_date_range(ctx, new_min_date, new_max_date, start_date, end_date): start_date = max(end_date - date_range_mapping[button_id], new_min_date) elif button_id == "max-button": start_date, end_date = new_min_date, new_max_date - elif button_id in ["custom-button", "date-range"]: + elif button_id == "custom-button": date_picker_style = {"display": "block"} - - # Convert string dates if needed - if isinstance(start_date, str): - start_date = dtm.datetime.strptime(start_date, "%Y-%m-%d").date() - if isinstance(end_date, str): - end_date = dtm.datetime.strptime(end_date, "%Y-%m-%d").date() - - # Ensure dates are within valid range - start_date = max(start_date, new_min_date) - end_date = min(end_date, new_max_date) + # Ensure dates are within valid range + start_date = max(start_date, new_min_date) + end_date = min(end_date, new_max_date) return start_date, end_date, date_picker_style @@ -187,7 +182,6 @@ def _handle_date_range(ctx, new_min_date, new_max_date, start_date, end_date): ], [ Input("portfolio-selector", "value"), - Input("pnl-type-selector", "value"), Input("date-range", "start_date"), Input("date-range", "end_date"), Input("currency-selector", "value"), @@ -201,7 +195,6 @@ def _handle_date_range(ctx, new_min_date, new_max_date, start_date, end_date): ) def update_graph( # pylint: disable=unused-argument,too-many-locals portfolio_name, - pnl_type, start_date, end_date, currency, @@ -223,25 +216,35 @@ def update_graph( # pylint: disable=unused-argument,too-many-locals # Prepare data and handle errors try: - prepared_data = prepare_positions_prices_data( + prepared_data = prepare_data( Path(portfolio_name), EQUITY_FILE_PATH, FX_DATA_PATH, target_currency=target_currency, ) - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: # pylint: disable=broad-except return create_error_state(dropdown_options, str(e)) - # Calculate PnL and get date ranges - pnl_df = calculate_pnl(prepared_data) - new_min_date = pnl_df.index.min() - new_max_date = pnl_df.index.max() + # Get full date range from prepared data + new_min_date = prepared_data.index.get_level_values("Date").min() + new_max_date = prepared_data.index.get_level_values("Date").max() # Handle date ranges and picker visibility start_date, end_date, date_picker_style = _handle_date_range( ctx, new_min_date, new_max_date, start_date, end_date ) + # Filter data between start and end dates + prepared_data = prepared_data[ + (prepared_data.index.get_level_values("Date") >= start_date) + & (prepared_data.index.get_level_values("Date") <= end_date) + ] + + pnl_expanded_df = calculate_pnl_expanded(prepared_data) + + # Calculate PnL and get date ranges + pnl_df = calculate_daily_pnl(pnl_expanded_df) + # Handle button styles button_styles = _handle_button_styles(ctx, date_picker_style, trigger_source) @@ -250,22 +253,14 @@ def update_graph( # pylint: disable=unused-argument,too-many-locals df_plot = df_plot[(df_plot["Date"] >= start_date) & (df_plot["Date"] <= end_date)] # Create figure with selected PnL type - pnl_column = "pnl_realised" if pnl_type == "realized" else "pnl_unrealised" - fig = create_pnl_figure(df_plot, pnl_column) + fig = create_pnl_figure(df_plot) # Calculate stats and add drawdown indicators - stats = calculate_stats( - pnl_df, - use_realized=(pnl_type == "realized"), - start_date=start_date, - end_date=end_date, - ) + stats = calculate_stats(pnl_df) add_drawdown_indicators(fig, stats, start_date, end_date) # Get winners and losers - winners, losers = get_winners_and_losers( - prepared_data, start_date=start_date, end_date=end_date - ) + winners, losers = get_winners_and_losers(pnl_expanded_df) # Create tables winners_table = create_performance_table( diff --git a/scripts/run_analysis.py b/scripts/run_analysis.py new file mode 100644 index 0000000..bf766cf --- /dev/null +++ b/scripts/run_analysis.py @@ -0,0 +1,54 @@ +""" +Intended for running analysis on a portfolio file +instead of relying on the Dashboard +""" + +from portfolio_analytics.common.utils.instruments import Currency +from portfolio_analytics.common.utils.logging_config import setup_logger + +from portfolio_analytics.dashboard.core.data_loader import prepare_data +from portfolio_analytics.dashboard.core.pnl import calculate_pnl_expanded, calculate_daily_pnl +from portfolio_analytics.dashboard.core.stats import ( + calculate_stats, + get_winners_and_losers, +) +from portfolio_analytics.dashboard.utils.dashboard_exceptions import ( + MissingTickersException, + MetricsCalculationError, +) + +# Configure logging +log = setup_logger(__name__) + +try: + + from portfolio_analytics.common.utils.filesystem import ( + EQUITY_FILE_PATH, + FX_DATA_PATH, + PORTFOLIO_SAMPLES_DIR, + ) + + # Prepare and cache the base data (cash flows and portfolio values) + prepared_data = prepare_data( + PORTFOLIO_SAMPLES_DIR / "sample_portfolio.csv", + EQUITY_FILE_PATH, + FX_DATA_PATH, + target_currency=Currency.GBP, + ) + + pnl_expanded_df = calculate_pnl_expanded(prepared_data) + + pnl_df = calculate_daily_pnl(pnl_expanded_df) + winners_df, losers_df = get_winners_and_losers(pnl_expanded_df) + + # Calculate stats with currency conversion + stats = calculate_stats(pnl_df) + + log.info(pnl_df.head()) + log.info(f"Analysis complete. Stats:\n{stats}") + +except MissingTickersException as e: + log.error(f"Portfolio Analysis Failed: {str(e)}") + log.error("Please ensure all portfolio tickers have corresponding price data.") +except MetricsCalculationError as e: + log.error(f"Portfolio Analysis Failed: {str(e)}")