From aa872aba676cc6b5b923f0fbf3e980c5c592eabf Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Wed, 1 Apr 2026 22:51:21 -0500 Subject: [PATCH] Initial Checkin --- .env.example | 3 + .gitignore | 2 + README.md | 155 ++++ backend/__pycache__/app.cpython-313.pyc | Bin 0 -> 13729 bytes backend/__pycache__/flex_lots.cpython-313.pyc | Bin 0 -> 10167 bytes .../__pycache__/flex_query.cpython-313.pyc | Bin 0 -> 7265 bytes backend/__pycache__/ib_client.cpython-313.pyc | Bin 0 -> 34904 bytes backend/app.py | 260 +++++++ backend/flex_lots.py | 183 +++++ backend/flex_query.py | 141 ++++ backend/ib_client.py | 723 ++++++++++++++++++ frontend/app.js | 573 ++++++++++++++ frontend/index.html | 208 +++++ requirements.txt | 2 + 14 files changed, 2250 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/__pycache__/app.cpython-313.pyc create mode 100644 backend/__pycache__/flex_lots.cpython-313.pyc create mode 100644 backend/__pycache__/flex_query.cpython-313.pyc create mode 100644 backend/__pycache__/ib_client.cpython-313.pyc create mode 100644 backend/app.py create mode 100644 backend/flex_lots.py create mode 100644 backend/flex_query.py create mode 100644 backend/ib_client.py create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..84792df --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +IB_FLEX_TOKEN=your_flex_web_service_token_here +IB_FLEX_QUERY_ID=your_saved_flex_query_id_here +IB_FLEX_DATA_DIR=data/flex diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1ac045 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +data/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3699aa4 --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# IB Dashboard + +Interactive Brokers dashboard with a small Flask backend and a vanilla JavaScript frontend. + +The project currently uses two data paths: +- TWS / IB Gateway API for live positions and pricing +- IB Flex Web Service infrastructure for durable historical trade retrieval + +The Flex path is important for lot history because the socket API execution stream is not reliable enough on its own to reconstruct all historical lots in every setup. + +## Requirements + +- Python 3.8+ +- IB Gateway or TWS running locally on port `7496` +- For full historical lots: a saved IB Flex Query plus a Flex Web Service token + +## Install + +```bash +python -m pip install -r requirements.txt +``` + +## Run + +```bash +python backend/app.py +``` + +Then open `http://127.0.0.1:8000` in your browser. + +## Current API Endpoints + +- `GET /api/quotes` +- `GET /api/positions` +- `GET /api/lots` +- `GET /api/executions-debug` +- `GET /api/flex/status` +- `POST /api/flex/sync` + +## Flex Query Setup + +This app now includes the infrastructure needed to pull historical trade data from IB Flex Web Service and save the raw XML locally. This is the recommended foundation for reconstructing lots regardless of how long ago they were acquired. + +### 1. Create a Flex Query in IB Client Portal + +In IB Client Portal: + +1. Open `Performance & Reports`. +2. Go to `Flex Queries`. +3. Create a new query for trades / executions. +4. Include enough fields to reconstruct lots. At minimum, include: + - account + - symbol + - underlying symbol if available + - asset / security type + - trade date / trade time + - quantity + - trade price + - buy/sell side + - strike + - expiry + - put/call + - multiplier + - conid if available + - local symbol / trading class if available +5. Save the query and note the `Query ID`. + +### 2. Create a Flex Web Service Token + +In IB Client Portal: + +1. Open the Flex Web Service section. +2. Generate or copy your Flex Web Service token. +3. Keep it secure. Treat it like a credential. + +IB reference: +- Flex Web Service overview: `https://ibkrcampus.com/campus/glossary-terms/flex-web-service/` + +### 3. Configure the App + +Use the included `.env.example` as a template. + +Set these environment variables before starting the backend: + +- `IB_FLEX_TOKEN` +- `IB_FLEX_QUERY_ID` +- `IB_FLEX_DATA_DIR` + +Example PowerShell session: + +```powershell +$env:IB_FLEX_TOKEN="your-token" +$env:IB_FLEX_QUERY_ID="123456" +$env:IB_FLEX_DATA_DIR="data/flex" +python backend/app.py +``` + +### 4. Verify Configuration + +Check the Flex status endpoint: + +```text +GET /api/flex/status +``` + +If configured correctly, it returns: +- `configured: true` +- the configured query id +- the local data directory +- the latest downloaded file if one exists + +### 5. Trigger a Flex Sync + +Trigger a manual download of the saved Flex Query: + +```text +POST /api/flex/sync +``` + +On success, the backend saves the raw XML file under `data/flex/` and returns: +- `ok: true` +- the saved file path +- the request reference code + +## What The Flex Infrastructure Does Today + +The current implementation provides: + +- environment-driven Flex configuration +- a backend Flex client for `SendRequest` and `GetStatement` +- local persistence of the downloaded XML files under `data/flex/` +- Flask endpoints to inspect config and trigger a sync + +Files added for this: + +- [backend/flex_query.py](backend/flex_query.py) +- [.env.example](.env.example) +- [data/flex/.gitkeep](data/flex/.gitkeep) + +## What Still Needs To Be Wired In + +The Flex Query data is not yet merged into `/api/lots`. + +The next implementation step is: + +1. Parse the saved Flex XML. +2. Normalize trades into a local fill ledger. +3. Build lots from that persistent history. +4. Use that ledger as the primary source for `buy_time`, `cost_basis`, `CAGR`, and true open lots. + +## Notes + +- The frontend currently focuses on the lot table. +- The backend still uses the IB socket API for live positions and prices. +- The Flex path is intended to become the historical source of truth for lots. diff --git a/backend/__pycache__/app.cpython-313.pyc b/backend/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..be56aa7cd6cf6136cae39d1427738a4b1fac02a0 GIT binary patch literal 13729 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 W=ZTYo_l8lDbpQLCIt$s0XZv5HnDXiX literal 0 HcmV?d00001 diff --git a/backend/__pycache__/flex_query.cpython-313.pyc b/backend/__pycache__/flex_query.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..20b345cac95976683a4e34fc6c274661f25457bf GIT binary patch literal 7265 zcmbU`TXPdxdfn=lT6g&(*|M>52)A8FgC{F4C`fVKlQU2XDH z0(ZoQnn}wEt(L34Fx5*5u1-pD57|s=hU(OzRx;;-Iq%ir5MvIbkkBglI;kJAs7(k8 ze$r|eLPCJF!a|$SL|Pp}yUK}NR+`3aqnpkM-|Z1y()i7e;Io^Gvx(h`s;AsJTYV3yZZLXDKq z^GQkJZ%Ek;pqKfis-!au1@jySVB^ff0#FLeZY#+HZb?b;c{o0hM%E+gCVzSQB9CM( zuPPdxF{=W>u=~6WXORgIp&PE_W(EPxa8G1!cUIMTSy{*^@}T=Ue!U27cqBzpb#jSX z4Eip-Hkp+)4La<{l778-{<^N`wUg1Pl1ip-D}(sn>yI;UzRh4#%|*wONwuKp(~=@B z$T?Zjqxi+(!Rqf-^hO0)NyX()3$mscebJ9(U4WelY_iT{rIdIHBhvJvYdN2YO!8^C51*9|;(4OxRg+@6S5` zk--2z7_ zX&89rg+vkg0Y3u0K^Y-Y)bPtCMM(#?nJ?(F1^_KEw*@}{n3s}pq*OEw=Y?G`*XC~{ z4(3RO&#t`#)jjH4kN=Cwn!ovr>6*Xgi&(Aiof6xzHdJBXsfF4XCziCY*>;5C$->uO z3xpRJdMgO^>#rr)5DOB{8h(7^L~OkoIX7%wmURS)Yj)79OSt>A& z?`%i0)S~jo;AVbL+;oE4U*A^(8WObCOTnoYs5zHK)E{v^_LbGdBw<`b_2-QHEX>%?))gFDJ@V}kvl2gKBYYwIR0{EBT3GchTb>4Z(-PB8sc#2GVu6KBNp7soG)vollU zF@rLitbrdS#^YDSiF4Z{=f-Bo#B&pIgT-QMu$a+*#vpte1e;RG3suBr*i_BnWaUN% zsfxiY$hu(%kqtF*m7*`!^dYT-e}c3RQ3*4yu*v*8-eDP;&O52&hnko z-a}7%D}kYEV5A%vsRTwBFFf-!{k^jmXx#|3{_U9uldF>tVym%I-_XD4|9at{?^K7U z%fr)^;hF02rSkBl>hQJl@U_wp)8*lGDWjH$)k-j534FSEVZ#$z8-IGM?72{4FAy2o zT|EBR6p#9|!$Sf6xF}SDN>0J^ff%$>muSrvr8H<=I0isz<05j zAl_ix?N!#k7K2N$!}M{?@*mr#Pf?VI;$eS<7T7^sKX^up4Sr05iZ@ty8`t+SZv%gV zqUHkDnz!I-u&4zG_hGkK*!n7Lf^A@Z4=4s(g!PGHpTnw+ltg2dVoTWLA!~g5Yn?xi z_1})7Faie@Vx_pFyt2ctH(}A3uq7D5IcV>PJ2dYx4RR4XAF-hxK#BI@iiavoTngE1+WkNAj; zAS@*4U0mTiLlInIDK#AOEpT!)!=b52hppL#tU8Y{rx+}@bA}Ue=P*99XT)JROhOG7 z8&7l?Lwf7m5;(&J)Dm(4t2v=L01CtyiYdtfPzI(T<2?!*It>8$VA?aNK#+ai6<^Qd z`5NcDd*|EEo)-?P`wYEhXT$#g>>9g&Zlk^9L2HJ1#_gea=?=MZ&_UwB!@^Iv*qt!hh zl=pnFbn)A^&RVeZ!OZH+MzDR&@hI>xPzmm@wfEM#_P_8rypB!EVR!uIf4YGt4Zq#r z44*yb_{#5qI@u6v7{?by>}MBI5D5x`1^N%SG6~vZK|ghb0j&>c%@W5u>?pe_2Cxk_ zfjI<%f5N6PzhgZ<(T)O)Zxi4Mz4O9yLXQ?Tk>ZkTm2O1U_+uSsWPY9o-EB5Fm{jxNr?L=Qq#|~MfdOK! zq?~3r(-6vtFoMp*E{3xaC18DonZC~TE)o0mBJe(n!3?N*O1lZwB2{Y%-4ETD{?75p z`_Nkr50=A&k8f1M$4c$PrND78EB@e$vaD47{bhfD&Dm6I?k@FwP-;57X{Q1Mn-mjp zEPnLTNx7R=_Ac+Ox_Zj4o^|d?O9{7*J_W{<1uj7UHL z!3ELs^S8nFfKaHa4i03Mpq0vfiIk$G9;vcB`-+$av^&Kwz4p)3fiG8^D+1<~+xC5|U?}oo? zZT8W%hu0pTu5_QQc3&=?zFhHNDLJpy+BzTPR&(n!mA0eRwv$h7)zDZuG*$_nU5q{R zbil$=_hiLCRdP<%Jk2G~z7o67^xj?-iH36iZy@sz772V0JBmP`ybP>6g8i-CL;%8- z`u=Z-4D)>=fknn*I_Wj0OEfUb^a3l`!N1uhwaUH-iD$*hxn#JJwnGHsl7SJW!C5{M=A`M3h zLiv;&VGvvig|6T+A6YAcFN691^Eqx(_!!|wGvm5yW8j#E#2tKqZd@Yzaue98CB6W(ay*Sjk%(ULb>3xsQ)!2OPD z^S*NPzV&yXL`u#3O3kNAo>L|E)SG@j_MeFIyblTvIHkcwmeL}AK7JX1GK?qa>h+5zkA!^yCr{QJ4g&y1Mdn@c5&VG=RXee(&$)$w7!QmiT35UY))J*>z4irg= zW3Jj!u$hYERMC!NQOr}*;OXF)C&46Nf1QTnnH%ak%e` z6}yOKAh{tPQkIa6Y@|fx)GUM}oEeIPb3+0IQT@cAk3<-Q)(!eogT7IW&VnbE;=z~V zZ%U9uRu)KB*R(j6O!DBELDmxIYKy*sD+9TKlsIr$1tCrq=qjAW5XNKY#P~SmE`(Vz z5x=;doG|Q2QXtn$l6h?e_@nVzQJ5W@9iJYLnb25u7%cV`A!U(;1T#qh;#df!$*B7# z%ILC4j3Wt_7;qjz*AWLY;zB1d0$Ma__hHLORHuj!i}f3%PE-Xv&AfCQCp8Qg#tLA$ zyx{?x3D;>RhcRw!JV`NQr(jyvlh?PSCxRH=1;8J{Pdfr{z9WDl=53_nKUH#``j&Oy zo4GqvWjo4j$9n&hAY>fN?6F^k-~XqH4QF=^9L?-#O8DfmZ;5^83Dmm!mZpF1ZQgKtSKL2wV?5DvYxHa9p%<-`e_*qXa)!!W z7oj><@z0c;GrwvIKJfjuZ%wT<4K7aLZKA8ub@}nc$7ZbEL3B9qy~A{n zip`jj&ys94{}~^H$AX~!&&`+1B=v{`O+tqR%4iB&MSlv0=YT;D@K9YNuqY2R5!SS5 zxJ^cj0lkpV%Gu4hd)jLYZL#~%q8QT9f|jeyqc1W<&CiLj_kAHJY}Zr zPoot8!Rk!=E2uUZnxwg3PC literal 0 HcmV?d00001 diff --git a/backend/__pycache__/ib_client.cpython-313.pyc b/backend/__pycache__/ib_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61b5bb1ca827b6ea3a4934af3dcbca75f99040ec GIT binary patch literal 34904 zcmcJ&33OXmdM5a=ks$a;fCNbZTpxFWJ0((^wNa!NYN2dGkZDUL3M3&376r-=ptV%R zN8zIbx1L>5PYR51K_Y^P3Lk9khrR=Fd5l ze~=e>fpc|ywWKQ zg_}ddq(2lm8}uXLw15(3Lhz1X5qy>qDxMPdP5DCSg>jVOr^3$p!a~R|zTl_Ujf>Nl z@F7h4Lg&V(ec~jVKRg}whqhTdghPJ+MHG0>_d;M=6heUu7pMH&vNaXi(bO|YbWxms zAux&hLsu@0Pfvw9QH&=%E&7EiA4&}i&(Bcl=z?%MnETkY4QizZO7Gm{ud92K5gU`l%MV=zmt@m=hI!`e|%&4o_ zV@LgVPYK)-Pbu6|PZ``Yj{~m5TXR~7!M@t1qD zu-ELVp*Zxd5;Y4fT@`$_%vbHHqjVl>X*~<6@$`5a5LVcc%VD9gn8r*Af~N^})_R)Z z)}f3RmZRR|#&?6K6>g(vgQx9e6I#@s$+54xQ+3;M^P2=Ak{(s`#W7Gn6)- zn+{=yFHVc$be^=<#sZTQnPti5ko*SxeZEak2;l?^BxeNQtDibF&7HsqNRL59j5k z%u){2YrvNbkKBn8Ieer{_NnhWt}pX}JZOdVbW?c+E4~WluP%zSuK2RaUnlBipQhAe zIgFKRU-6~1jm6Onu_tReO6#-6Iv$(Xsl;xOeIvcHPkq;MC)D^&YPjt47`N&K?krc! zolr(Qm-AtMfb{i2eGNC9I>YTS)Np5XZqx9Wc|998cXs1n&nE|}IPMdgRA3O(l>Y_) zl)F--iA|fw#(>GgV`FJc0N6eh_5~;WX+t0g2;F;m!hex)vzU*hB2AD;^ODXT3!fAH zzR5H{7W7{ZkBR>00mtb)DHJp#ZQA<+2ua#DHZdIx`X|Ev$uZQ5kg~ChK-WkzMwA1z zfmIMn=SUbcou{D2w8ekfKQY5-L)ri$lP(-nLQq{OT?B#zBpjIVO^r=VO^5s;bcfn< z!FMrjAJfFjfyER(1B8wCWKcO0s$sr~iRqbOIFv5c1ghnvbEl@yo~8cfkNZM_3E<^3 zfwO7j(D1&aZe!X=sDIU1g=j$wc+w>qQq>h0M{9}Bc3!+95=5qpWHiw!&jx{8Xc^8+ z+>+UvG&?^qJLgO8n;ZVlYERj#X9t(8uK9+y3`uK4+}g0LHxyJZIm+jIe|TlixSYe4 zx?agkl{n$J>Ru^)=%|=AzQU&}t7eV&&8|l|Ty5*y*59)xgdPg0LWV8d$X5?vODodm zry85_Dty?~f|qrvy7q>j5>%u%_R+f<3EgdoD#!!~OLdKJHd0j0Qhn2#ERnF(*z%^A z!s<|Eo-Jvv_`qE8R`c8Kx7ritt}h<#;!3JM=X3>?DM!T*jLQc2z6=$i)Bi4iFrRxr zpC2qYyzkP%ozNmbLOcvbmII=JA~VQ}0|MN!0tI4O=wwtjLOE7FGMZR{D3u%@{kBn# z^8rhGbt4M0Q;U#u%V^Yxsq}(J^B9d>A?Go{Z}j9S{v3~<{2NUToCYncwdJtd>b`Mp zScutc+h}M&49HLJaGLvsFoj5?_Xz=FTG#Oj;cx(}AruzVCOCnMX~UF1n9dD^CIe>! zVYgmvqog_h%NGOUl@MhX1hJjMipGM|BDk->RX^4uMW@SE*j*jB3t2+1!2u8#mCPM~ z{p7ruD5^^qwZw~B5=E`EIZOG4voHMi6Z5r+{EA4<+l99ZlR|e~=uVYY&ObH({M`Au zZ$)Y&`=UM3ry_?UI~H^cO^Zc~Jqv9MRWbXn7{BY+EFfm@iSa!tYw2vk=V%u?Df+`R zVsHg~5x36ZYk|R+hOFZItWFDW9=(U_%Rp98@6{`?0Ne^fsKp1xzS8)W5}PaBRtc`RKC86td4^iBE)!N(sJfzTPFeFX1wNQ%{Q-k&y! zBp;7p4_8Lz0uY6-{vGNeju0matOO zIq(soG@~(NVYEivjdbD`at6p5Bxf(25OGjMHrOLAz5*hqte~;;{wwJ+b>V5l_fzhF zfb$afvDJ~Xmo61K=be$w(ZNJf&uq@e`9-PHiurx>TVnQx7~il|QZauZ5?Clnlx#|t zY+tNP+6UtHfrNeUtXayrC3-ATv>{ovWnp|aCzWrXn@QwXrb=DO(zbYM+mH7z9Ej~a z89OzeXrD-wPR{OIDy^I!ja){FrJL}@%0C+oCrWygCEFJDvwN3ZLc|%}zF3lQ?M%7` z@6^p6L~-*?Xk}4kQ=}zY8{HS(ve2{OUD&_av-s@d;h23W#t)^6%ag_R^Zr;-L!>`) zCNdhccE-$|;uGlXiUmwOA6vk6aDN13C@)Lmi@Uj13legYyoA?)mj1B|QojPU4UZ|u z7z8r#m>7x&OaBcPWR6;zvLKBs>eDRQ^_S|J9vL6oqjeW{F!v}BKoH0)2*eXa>(*b` zK{NW7g=`g(P-}GZ(-S6Jiq&QI)&lqziy2 zXT(7G3PYCZqM6{NU!1zaqz9>X1$CzLr`nQIIXuDljinvqTT8 zC(|PE0bS5bd>8luU>v^bfr%t8Q^*r!oS z=r^!FWHK;2v=Y_tT5L?(_r>k|67~cCLa3f0{!0tSL}_2L^ohmdWXYa*$(}^X;7XkT z^1+9tuGxLS`EW{{vwKsns@a1}j_Uc#(W*sb!m(qPe^_0YtnQ9icP}^-)!UZJs^-r{ zb3szdHYLk;E;fKaOjR|eYMbGCJs2^jnmZPrO*HR%%|E~IwF`HSr7DD*J0gRrj?If# z6CFcu)XzWnM%TS?sZm2q=b%33^Y zVYEpc!(6N&P=p*Afii<25rG;}I7Gx`9#wW(gN(k0EUi(y_}k{<1a zRApl1OyVzZ0f}U9zk*UV^-1~7;7_yXQ4kbJn!>YsP3tSKe66V8G@=jydGt6%wImaN z9~3+%6oh|5L60iQkoz0VIcoEo#)+^f>D5#qEaqU&I_^}=$lrWS?+AX(PLPrFT+0aqiA~C#Uc!^Yg$P7ouMGO;(rx22^Q87-Pa-O2LJ3j?!< zfK%p1A%4Cve=g#SS|Z;8D|%w#NX)(`#_wUW<64%}h|aPV{lCNW-(W@SJ%(+U!0Z4| zoLz72D6CJ=Pz{LkDCO6t__#{7l1;)^%rMpV{p;ihd-Mz>*+RB#jnA+sLm1AQ5UsMJDSQK%^)9W2&^J z)iJD&o23HI!uc9dljRnBnG8556Q!-Q7O2khT&b1~v2BMFEr(z0n=en0T=aBoOHw6J?+=|!rA+>P7P+c1`R@nig8_{rw|~tp0FQ#Jh}K%7g6?+{}&nw zO*Tqk?hsi9D=A(-pU9k*c) z97+y_(4!n>7iXPvtV%g%uX)9c|99%1&pLT+O5Qwgo|2c^g6$3I(MUK(b)RUR@`{x5 zEMALJ9zow*Qz$i4V)ofCR`TY1^H=2kgDFyBvuB-p?MhzW%dg1${*QuO&w885clmsRwlhOv6%KtDL#6&0YS&E?DQmC(=B0mXs?h*|ZB3#7SASSX(60eey zv0CX|(GR(A!Y@g^jBKf*FJq&KUWreVGeXWWIO!7XH=Xx`X_}rmugGccg4Lvsx|`Nb zNWI)bxmnr7XJzY0h!|9v_hN5}JeEtfDJs7+;`9YsBCJ>WGS;MQiGF}$L;naq;%f+| z#GB1Kp|EFK!zG*ZhnCs?r7|#DKRiF%|H}T9Q+Q=4<*K~txbBFQCtU4G*XD&>#2idH z%3r^9?b7_!grjx#0I4CX8eg-d>?N}Ym;&*Ber2Y(Ov+fzhJ<5d(s5uR^wUc}xwLpW zvH1WBI*@W!;k8aBD{2O3$!gYcg~`(PXdM{Ej*ah}x_xSK;7)O(V{o?rKHn;{0K`rr zfweSltzD`RB4syr%;{b;Yk9)iLmykqQ{|1X<;p#+Ot?B{4}NTQE!EUTw%zzHa`35& z>YJOdZ;tFtRBV_%{IRtnCA7a*kgBP>dFlG4So79I%{EAdA6siu4ZZhmwW-RQoBOZt zj~q=@_Rb#p*jkyY?R>3J8Xv1&Vq#Og_0|0^@1Hx6;H#4Sj)?tj$1O+9y**L8LvH@F ziPDy2>Bi{(l>t4`z00|r&ArSSZ5B9rk{CKXfk{^$qGpU?J%dniCtNlWROS?995wj{ za4lA}^o%Hcf*it;xseWKDRf_rL8w{%l6|t~74(=JQG_iy7NXs+Q5MoF)JH8rt>J`X zMKGdZcQsud)umuxH5^+!hOMw$(NIWkq(qIEeIp7zltWYzly>dUg8Lo5T0J1`heL%$ zW=$};I*W~mOTH^YV`y7r=-6$2Iw$NCA|=ARU4F4D$85P}zt z7u%Ij=VGPPHapv%7hRMj=a{rbZxCmKBDIY+`+Q>fwvHjD5CMa{tCz_=2CFQfF(lG( z+-u_g1Q|miI6xo-tTl0KP10IRONh6>dieW?W3H`>y??UxkG9^K{IkH_zc58WDyhTnPd_KWxVol6xB$%?iQD%uu$f4b!-TkZ_TD~4tVUpbt~ zljN)8eDwofSkB{Yb&}wvg=2z22FD^iKY|?#W>FiDP1(8vZo!vT(5rQSynhPtnh5uBOB)|=SAD}cJ2VifC=2( z2z;v_uOe3o+!3t8d`^KYh2vJ?YWR{2SAaWhd<97?CkU$8GeSb>s)u1-gUr#B7=Pp_ zr@$#R^jF>ce1R#+oD%ko6aG+0$k^h@#*a*c4ou`O0J50D_GCJr?0b%5!+&yUQlwf% zG=&odau|?T!k0Eq!4M~%^MY>*wl&y!o$~v{bZN%GXB?tDv-}(*b4m$+<@3_x*{k*htP6u5=(QzGA+R2j6 zcu8mU`43BaJ~MG;RUpTF^B0dmlwG7vP!{M{kBT{GErO5@+WBNj;|C>;v8JbDo@2=p zFV--^zYNu*L%(0XXCwD+Wzm2^|L#W10B3l2mk#cGhT4G^(|gT2xGT&El_gDFt9C(W zHzV}uMvDQe8B|9gN&r&@@&x@M>|a}8BDJG2Buq?C`tf#eBOuXBb7^8pGO8WymHVF` zf+-ja96<7PKJ$98l^#61Qq?2Aj>e@6m6>D-{~BQ-l2@7K)2jvFFNl@(EzI1jcwqKQ z=%2treyGI^bs2%Wz{VKH(R1j&bZn~?VP^?}$f-5&Y^YyFa$0r7q+Cj7bToxz-{v%R zD<+l@zV#mc6UahldZU{>29oE{CcVd~2{kF9IhxR1CDg15%~L`xtdCN;`BEt6jUWEx z|HFgX=|NSW48pOCwnN7VLUL2G=#1{8DIghul}Tw zd`4Qpw~Zn}q9jp~zzub=KRoF_bi6enMMT0MK8q~ z2@QmD;iWZ=N0MvuDASXq`AZio5TS(q9g>G$h4T{kFyHp_j`_WjksF6nd@=TpZdP5d zO1fI(uGVN@!qqcd__4K$VyyO8zy0#Jlh($#wJ{Q07*ANY&KgPnxRl7R#tzY3+4m2p z3QFf4i2@;2Sv%+W{t@M~yk@TS`-if87XS9COjgj`!b%AuwU9x629e)~hcVW8uYSG; z`ix$qOyGWP=rd}d&otWS(Mgbj^$hz&*lmgbGaBR0QKw7%F#^PYOAgJC_zs*9;SV-B znb~1e>Oppmts+eh1^s8ruoVs%!e4hbV3~OQ`@A%Y+C3Zs4>pRYrFE^c9K^WkvaRSf zBS!gGJ(?As2czG-y*kOfz-#iFJ-SZQUR}_lDoTMlXdG5OGRpwcKge-2ST7g<^3Ctw{4SvtM@7=n6?b$km=lhzv;49?*J?=>mMN6z)|PQ{oafKAAbeBS-^?BRS!Tb6^$dTus>DDKt)M4*8J5wto`b0xehZJLCYm zISE956F#lPlrMwi-r%H$KlEQ;EZ4 z56q|6@(r{Y+35Tj1u(HuwcUggKx4wg&R@w&gcUf3M22iQMp^YiZ~|W76f$5UTis3r z@Jr4&9cK-Gx$HG(#V?AA)|=xouE8(a^C-K7UdAIaY()6wbXNSrco5(S{jZ5%6jdcD0?96xIkLCuT#c&G$mk1QZEZe?%b;{9)O-=%C7+`$%q%C4UUJM1_LTXWT?fB z16CFfj*Abte{Q-LNE{fA zot$`ZU?SDh_WJO(;Yd%sr17H?7x0~H1Mpo@DPh5+tubzEj7&VRwaOc_dvs7bCHblk z_^Md-?hpBXQhe-z(BwvkzUSK0&Ari|5BF}H9qzk&E4js0O{if#WB?DCddP?3YCRvantnB;Ua`>{^S98d0= zl$c=Yfna3zj)BLK#u|plKbwm`(G`L>jd0 ztX`A40&G!MJ#khhM%i~{tdD|X)P{{%Rln@>Y7h%UO^Pm9K}{e4(vwZk3q4DLJO|8J zLPbr5SKl1)U1W$xIjDqFtYN5KLhKmn_)K7mEFXob=`hZaT23p;P763&>%-~Mz4Do- z)57%lb5Q9@IE$UZ6)v3%Fv=Q4k-?CqGjz@;`a|tPClj;q4&jh0UR3PRBs}531;5a8 z0qSKOp!Hw47``IoFFM{qiD2Z;3Skx;;=e(^|6cAr$qP^xcHuj1q>|D(PzhZanuN~g zX9#4e#W?vsASVImYOShdlFSqZrH`4RaOZ1~AKPOi&QPp{lQ#G!Cq?kaTvjxgF<&AX zlbKY4#gs=&;ydqB87A0~;#^8D#nCY_GXf=1Miw>mDQZxf7Y$pX3Ecp|3=sneoXD5v z4d_-HwSR1PWx^{q z*8&l9qRc(J|6^;}{N`lS)_Bv_M8#G#4FOONCvEj{TYb{j9Je(`bMM=_NvD4F<)cZy zF3#6Q`X2CZ8IPieDjx0skl#aibO3mic&qP~40Lm^?nRL^*j3w+RqXZXIqy5^RGq3O)I&Y1M;1u;g~iFjns{N&tRZFQlV;Zk zX4m{vadY(pvrA*{x*LhrJ@^&0m8xcaJq(Zb=w2Vb=?NM~OQukx^mufzD4{&;fi)84 zmHfS`l@eJ>kOlgF!~ivs;3J`fUF3U;9FG*m=6LstP%(^8MZ+wOiWovz8ro0k>~?3i z{ryoImIkE5g!3i8eQ*{$j_Vb3s>C&`!?DZKs#h$MW3|nirAb#hf?R}@*g$N9s~rd| z2qm2?X(_}M9xM`K^jAF^y@no=25yk()WZNQyM9uYPmD)9kVdO_$&{-tPh$hC9N_pS zWsmO8wxObseQKSuA**)haho;rI81Jag!Xkr8qAqIegZezo$dI!*Vv4T*|rp9lGTq4 zl0Z1rRzef29yvwOguV)^BJt^EdRpOgkL?8KY^P;{4+$Gw8z~UGuRlq+NUy0Y};MVizD~M>p{lNmirSz9E>g7ch#-* zo?Vk7DelycW6LJ9b>qHTrKwCiWH3CS-JBAUj2a|kal(J>kc&72A2jy3m_#H1WxIdr z92l2F^{-Lk&?JiABZo<@*U0w^atQ9zMW8Q7*cKp8S^C(6$*SIXRc|7{7rMlUWIsU%_>AtN6C-DpH4~t5ZMYZvw z+C))34rvOVuNJ&qFn0#WP?n83c_TWuVab@jp8I@bh7FwG7ad<{Tr9n#zt0b_6!mew zUassuzgcO?;GN(D{##!>a%2wdpU^GY97$X42e#T+-7^nt&!R8OCLLP#)gxzSlXFDN zQSMlLG08s_=b!qD_{tIRl|+i)<7|8Lz)bc#_jYlAnkVdSH~eXvj@&NGzH-B#ZguP{ zHT=g?6a3k2mtFv=q}y(eed8@Y8D%S^1lkwX!XujsjP}(vh*Tk#sFO4Ws~%O+B@-XH zCLaihdQK6!vk<=%Ho3UfxhFeTv{OOna(ZTYrO)1`>~*Q=^(>AHAWf-Smh6*9i^vY{ z?V)>v%(yH;Aq^v0kvA9+Xd$-hA4f8YsIpo?4pu*Et!If45k_H;LWqoV$U1fq8p4M= zQBKLk;-6KPVkNthsJZj54)=%7<;1O@Y(FsVhR@jFwzn^obx;*lU3r0aOa67=g*F zokKz9$)0eVK-UrNXvFna*bTvZ_-eO?MvxttHY2+SOS!Bo)2In~D(Fq~V#nX-{|*k> z5do^;Tm58HnyYI}SucQb1|eD4T@8d_>kIR$Y||+)VwP*owo0+46(*R?t7<1>82*GR z)EdjI9e>{$475Bp*z|(h3RLovFAz@iNUf-!SLtz8i?Ej@>B~Ge>Bl)!|75ydp;VdL z#$@$usB=3z#~Rw%DX0Dc(Jc?^`~otW-I27n#O*Cfdq>>f5%neQ-En(2Oi@ao)}@@4 zNvAvRbU$#mEt|NKo-d$?sBDD62_m6mK;#EbcdXU>q4PKpGXlR1k;?2%TOap_Lch~+ z*P(-ZOuS4(QlSFJnjvu)NrpjxhlCowfUy+$6xK@)spupVY_j}V%`gxTF1|?4OXPe8 zN0!S!ho8x^mCoI|}0WdchA0V%5WJpXr(Slh4FXj3rM@#!pP%xA~!ep$mCn z2}CNG44M9w*;Mef?h(%wlqLDf5BSQ+frVpnep`~?9p`u7$H6j?P*^1GV1&3T&R0F) zYrc3?k)gP-4^k=au@7xz9E2LadQ_Pqw>vcYn2D-TSs%?X% z+qQK`9Ek&nDNdK`DxivZ^c)Psn3mR~GgQ)m~u#ODWB5y1G;0`HAKb?q)mLU778x z43Uu}7@rWj=${Cjfl!o*?u3}4F61?02VwbD1s%KEqGpg+{;D(?i3U4WO&DMeE>ML6 znqiS7111mjOE+zK*=~GDk^wSGxIadk6-p|Hyr46cj!7HMp)^QuMn$tuiU=+IpP)S2 z!!Mg}PB!j{H||K3W0jG8&|2o37VfbX3zzZNWPR(=aM8U{$9>!*M8<;+alRqRx5W9D zsN(?-44a=>vd6SbcI1K0lVOsNVb$`PRqN3*$p`K=f5?x}!u0?TljO8$>)*oNwRG&M z=I(A0_EZ?&E!UA-T|1yRyrAsIwAsQoM1;Na9>TuB6p?kTsWX1E zU?9CRE<;u8=@gEE-NTLx4BkQ(pC|@i0H+o338yv6;L~bd&=C+O!kv58Rh3+=T)U#w zdWPNRv{kxwRS6XT3rdpqSxB%>7XadwLPM(<4cVynE6PI|)5bICocJ-kt8|^|VtMH( zRfWkrq}XP#bwFjx_EJ2qPSsJ zk}S!He?FwyaM-D#SKB}2cM#a`l(>)YJ+YJf4?=&f;jW;AJ3&w^KMGYMYMwFom*X_= zY%HdV;k8F&CHp#cGZmJZxHv`@j%bS)_M7N3SuJRe%PJxUR)9h&)CPDncAktm{jY_7 zl{v*}>)%5qr0o7(ytSKcvPHLgSs~K`;mCgjQc6af2gvs=a_Y!osu~3=%P>Q`Yp?|c zbbBcg)wddC3e`$Tfuq?JSs8JcK!tQkbbP^#?IflS>5EtOC9Af^tF|sSCaQMN4u5Q~ z1ByTw9(pW$w=4(^H_2?b4VNBZ@4U$TP}z=W1J9Sqgh1yqea=tgEmce0-?EKR(ndF7|ZT#@|wa} zQmYiwp*AhsKEKQ?5`fdAFqTEqx7L-@(ma0Mb^vy$x(xZP(?i`0DI#Xv~HEa+DubO&*xvt>++DQN4UXNs# zKywCVw_0e1XbG#!k@-;DYHjumN|03Av{mwW231mB(?Vu^DdSfUC|AwWI+c;-CzR$O zISg6;BYdlorBkM_YEz}lYSXoAsO;(_Kcn?t0|%Ro`K9Ztr(Zs>MhOi|?4+wL?rMu3 zNVxi#O?;#}x$#JR$|k;k#W-fZk|P9C(*bmw1V95$<^%{VpNwL@ZlBgf->J7a~L z;(Sw*Z;kVFwd9mT{M8{9{;@1u|5()fV2NsFH5XHOHXOyPkdET1w?N4xfNXYgYA36D z9yM)6O)ziEra|y1<>LZGgCaYO?)OxkH(C?;58GbIs)29 zwzpkeFi&l**YG%Xy*y%G6J2W`#N`%tZh!6pj2&8L$kui*?J;MNc@TF~WltlQyZU+u zj2N%E1OpS`&G+bN9**j~e6YaVt85+=YSLjVMZH91^gnoVw(5`S&`&G=Z1^kkTG4LZ zfhf(c*O;wMFm}Y4(X+Zu9`gw46IVUTEEk^u1W1qO@{sIxKpdCHW=$>1){xwep#5vh zvv~5?El;_~C0Mdfk9no0Qg5-RfSnP6)DC|kI}dgZetWRYTZS`muR(4(hwq}6OkaII z`jX}*=s2M;9o{l^JiHE%mF7;aM{aA-NijS0JJ?AUualV_8A5u3Qg0c)9hj%bl;g2& z1H-}&UKfqjW*Vet)^&Zz-5psE5nL3VBDar^PrLW8gm1{=G8dsMq9Fcq=AAGuBC0B10-#^SE@SE7D>8 zqwR7tG#$;>7ERmbI{0kqJY{MfFdSFdew_6pV!!I~IONo8@QR8DsxTATb35hS<6zjo zwk{>LoD26D76+@n)xjFCRUQT(n0~KSg8r;6R6*RG6M%Ye%{YWmZpmq}SOPNB`?s0ne+4DaEs zl-cZU>;SJ5Y{B@t@z<)st&Q^N2iv@DN-Yp+Q69zubKT-?qq;mUR-4;f{`h$awtL%^ za(=|hA&k(L!ELWlnVxcRE)WQV9oSIq9ihQq^?2L89SWZB6u6`8ywB5}6IdlIPuHpp z_|(ioyWA`J9qjaWjvqmq9yuTC-6;Fjw@i<{oit)2y=si?^Hi|Ws8m|lg)wULwlIk4 z_I79bNpRbp8M!KN7n?22lBbHzM31+}Q_aS+^{A0%T~-JGY!Tab0kYvkUI z={&+ZX#_cqW(8p73xsQ6wYd6Gu_?-|U8l@ixlE*~TPIDOCQbc1Y3em;8rDhEph?rX zP8tP<(H^7M4A<(dhuh?BfD7n{+v074>-ILoZS}UmZS%U}wtHLQc6i&z6wXN?D)wI_ znzY|^gs8EyAHisx>Bwn>t;3OnKg3@0*Yo5!O?wI42hmW%{MkA9_3^PTSier5PI<&M zGtLw!>!k0>k{+$Xt-ATah7~kJnXll672(Pp1vh#(csDY*R`Hx2crH6uR(NHW16XjJ z_{I$~20W?EqlEyS6cQo z%FLmzhIQ&vW)W!`*GZFYrENg^ChtbLU$fFY#YofP6;`dZ|1q=Da*u$@uv4A50v-xL z+P7s7l!-=y#~NV|$E zk*@2ZpKPRKXu_pH_#A%zklm->E<7K;Lg!y$Swp7OI9rB8rem4oWs-Gq=F5`#Vwe83 z;~KL-aBOmVYRV@f6Z^tpG1@>yalW%6e)cQ#H9mvu{IOfDY>wtWBP@|NmyZ1 z0;W@4p|B%XkF+SC{}miEFaxI3F`!-HsFEa(&8V~lDbGTo4qm7p6hjZb#*l4eXS7)6 zHL`7h$Q(;ZFK`WP{Qx(EbCVu*>toH`%9%BFWcLcl1sqBbRvqLyw%e^9D8zo>2Z&}m z@Y{x~w#H`~FEmaHLgRsLjYqaMdahbCA*%yA1Va%ZK{l+S{|D*_Omk`iZ+u@_yZuv2q4GUiAL2@ zjVht!`5-2t8B{^0Y9*zw@4dDc*GC?i(|zo4rYb7`y23j*K(=bNshZ|wO?SMed!Z;% zvn5%xCtkBBQ8V~~bMR9WS9x5wY%vs7(H)INH@3_jrMr|QJGR=iQ-Bt@wDzk<=FDA6 zRUcJUBB-cJGNQOu-EY(1HCg&=xw|$?f3@jum5$t6OMj=~Zifyopw2+QJQ1hCCRSH- zzA;9hGz)3No}X7%}NIBqbacZ&IG$Bj+|bi{$*2 zoIir|$t3wI2^!(yxY!H3&;HK;{fCi%`i~1c#pft)ik#i#oFoSv5hp%P&K7b8$Qgx` z&QsQA+M+JmG_PH!$`@OwXojea*Qs^&v{@P#2->r zF2nJ#70u7Y;L?Jrv~FB%qv~|er+KC2NRTd7zO;xsU3nfu6qIjdV?cp36&HZLK|Rve zGXW8(pPJ5AJo~|vm3Oh{Da%~~dpC`3D>b3af94E9#0$!WZ;Wn;?UbT}oKAAOC~Y37 zi=V-g*iC`86sVsJr%hLIcU%agC;dpI1hqzj%(M~RI+M02Kz~NURD_X*f5x+0vcWA{ zgT^#F@N44!5$z1U349NH_NziJul+C0jlV3ZdCQn6X`IbV;hut;*J^OT3tt*@w#3Wa z*b7VYJ#oHgq4q<*_frE`Tnju^S#zT=W_G5kYGUSc%2fSYH8NFI-}GMh#_BgFs(P7O z#78z~imy!aLYx;O)%W@Cl(p)Cb#tn36Kk$Oxe!a{H1C)PMwrgy?*4{kx1u4U81B9#uW~C($NujbVP&rv6Z9)H{;@#-kOHF zzWJgbY>Bqa4*tqmP*C;%RCC+=b#H8nG=5OA8NbGG_@S!;Kh;%UIeTcSv=)~-I9)eQ z*G=%b zC1=&V@23B{Ke9jSNmTbFtG6t863(r2CeVbNeb@WgO}u8P z%2^jV8+UfhTb3%S=bw+1UcY=}N3=Il(S<^89=(1v+JC>YbGd-4Xr!{=IT1avT*x6) z{|{e8Ude!Zq4my8?D(m8>sZox`ZELPY=T{2y?gEut_F_kZglt#RLlBnChwl(#UOL1rCd_KB5@4dbyGI?Vc^4=W2J{;}2U)ezoX`&+Ec`o`KHKacB z)c^3^l(RnRY>hix7Y;3KztVombY`Z>bFnaI+3uF;dhZsy@D`& zzoZu}trTVt(`}*o*YfGc&`4pz)-~JzvDKEU5pK?0pNSm5@f&k|%3c$c_w*t|L3 z+&Oo6Wl-6hI}l{8h+FGZHTB7wzIaVvvSw?%X6s^SqGkvsWa64{e&_mkqDOG;@f=S_ zmg<*k@as8mpS*Q)p*7LCBiT53$DXL!i^v>9K}Tx%_|K;Pd@6S0Tw*v7AD;QKDSGam zsoPVFI})8k_gfCezWv>ptq~V9w)g8|e9Ol>hUTZDJ=dp`)jjd*o`t6S)m!g{XOAjP zT9{03+P8QvQL`VHF8@6)W3Ft9R>UiN=kk_Zb#whoy~*=sJng@Q*zIY|xec;|i?ASAZem>syZ0uX7<6Wnt#?uM6r;1mkkJd=xm8Lywh^KCAM)O(K?uH4cs#(4@|@lOvEP7 zB@P6ZO-O`ByIP_p?>KKe7rGMeJ+c=f>4} z{I0N})VnCqIN7F?;ia-obTPle;B!m*hb5~nW3k6xlhL)2z#su^Ty zFy1_PoqyQ89ZMtoBKT(TPrq@z0#a(~v8E;FgdCLlg(Ut%cs9# z`SOh)Y%e}tl#?jTg+EbNe!9_^=ntoBDBiG4~wU~a|i)}h`ck-tTa~~Gy$SvYe*X2E|GNR;%wL0?G z8OYzrpYF?j*u(t2I`a21{}$e7&i$oPhcv&;)sf#!;lIr1eHFRCEZ31+&HFlYf7ziU zw}&6s<^C0?BiG1}m*oDHT?bb_lvRsykj#Lcd!8U(2`^>PCG?DQeQ}NA*oW> zd3EVr*?&);*#6?!EyLmh`Tl^MKPBfLIZPx^k?-f^{DPbiIdkOvik$yUjt)$}Xd=f# zj+LBZa!TQ(jpNhPQ{pq^EyZQk_!;29#F%v9Ewm#cbhRLJmGuM!wy`l>$?_UXWoB5C z3?h>Lh&j~Z5UI4j_}AR0`esx2yl0uiYhi-D?sryNG@^TSQ0Fr3 zSl(1<^5~*Csz>hy^oQP`3Rct3sAr+~_Or_zymxwkMX%55@~F--ooU0PoD%$4^=Ium zv@b_zYWOtgoWWGG*!xc$y+3CzGgV`QjGr9M9fPiN0$@g4r|kCqS literal 0 HcmV?d00001 diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..c29d6dc --- /dev/null +++ b/backend/app.py @@ -0,0 +1,260 @@ +"""Flask backend for IB dashboard. + +Provides simple REST endpoints that the vanilla-JS frontend polls: +- GET /api/quotes -> latest market quotes +- GET /api/positions -> latest positions +- POST /api/subscribe { symbols: ["AAPL","MSFT"] } -> start subscribing + +This app expects an IB Gateway or TWS running locally on port 7496. +""" +from flask import Flask, jsonify, request, send_from_directory +import os +import threading + +from flex_lots import load_latest_flex_lots +from flex_query import FlexQueryError, fetch_flex_statement, get_flex_status +from ib_client import IBClient +import time + + +def load_dotenv_file() -> None: + env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env")) + if not os.path.exists(env_path): + return + try: + with open(env_path, "r", encoding="utf-8") as handle: + for raw_line in handle: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + except Exception: + pass + + +load_dotenv_file() + + +# Use an absolute path for the static folder so Flask can reliably serve files +STATIC_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "frontend")) +app = Flask(__name__, static_folder=STATIC_FOLDER, static_url_path="/static") + +# instantiate the IB client with a process-unique client id to avoid +# collisions when Flask's reloader spawns multiple processes. +ib = IBClient(client_id=os.getpid()) +# Start connection only in the main process (Werkzeug sets WERKZEUG_RUN_MAIN) +if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not os.environ.get("WERKZEUG_RUN_MAIN"): + # attempt to start the connection; failures are logged by the client + try: + ib.connect_and_start() + except Exception: + pass + + +@app.route("/") +def index(): + return send_from_directory(app.static_folder, "index.html") + + +@app.route("/api/quotes") +def api_quotes(): + return jsonify(ib.get_quotes()) + + +@app.route("/api/positions") +def api_positions(): + # request a fresh positions snapshot from IB and wait briefly for callbacks + try: + ib.request_positions() + except Exception: + pass + # wait up to 2 seconds for positions to arrive + deadline = time.time() + 2.0 + while time.time() < deadline: + positions = ib.get_positions() + if positions: + return jsonify(positions) + time.sleep(0.1) + # return whatever we have (possibly empty) + return jsonify(ib.get_positions()) + + +@app.route("/api/lots") +def api_lots(): + flex_status = get_flex_status() + if flex_status.get("configured") and flex_status.get("data_dir"): + latest_file, flex_lots = load_latest_flex_lots(flex_status["data_dir"]) + if latest_file and flex_lots: + return jsonify(flex_lots) + + # request a fresh positions snapshot first so all account holdings are covered, + # even when IB does not return historical executions for every open position. + try: + ib.request_positions() + except Exception: + pass + positions_deadline = time.time() + 2.0 + while time.time() < positions_deadline: + if ib.get_positions(): + break + time.sleep(0.1) + + for p in ib.get_positions(): + account = p.get("account") if isinstance(p, dict) else None + if account: + try: + ib.request_account_updates(account) + except Exception: + pass + + # subscribe market data for all current positions to improve gain calculations + for p in ib.get_positions(): + instrument_key = p.get("instrument_key") if isinstance(p, dict) else None + if instrument_key: + try: + ib.subscribe_market_data(p) + except Exception: + pass + quotes_deadline = time.time() + 2.0 + position_symbols = { + p.get("instrument_key") + for p in ib.get_positions() + if isinstance(p, dict) and p.get("instrument_key") + } + while time.time() < quotes_deadline: + quotes = ib.get_quotes() + if any(instrument_key in quotes for instrument_key in position_symbols): + break + time.sleep(0.1) + + # request prior close for positions still missing a usable quote + for p in ib.get_positions(): + instrument_key = p.get("instrument_key") if isinstance(p, dict) else None + if not instrument_key: + continue + quote = ib.get_quotes().get(instrument_key, {}) + if quote.get("last") is None and quote.get("close") is None and quote.get("mark") is None: + try: + ib.request_historical_close(p) + except Exception: + pass + historical_deadline = time.time() + 2.0 + while time.time() < historical_deadline: + missing = [] + quotes = ib.get_quotes() + for p in ib.get_positions(): + instrument_key = p.get("instrument_key") if isinstance(p, dict) else None + if not instrument_key: + continue + quote = quotes.get(instrument_key, {}) + if ( + quote.get("last") is None + and quote.get("close") is None + and quote.get("mark") is None + and instrument_key not in ib.historical_closes + ): + missing.append(instrument_key) + if not missing: + break + time.sleep(0.1) + + # request executions and wait briefly for callbacks + try: + ib.request_executions(start_time="20000101 00:00:00") + except Exception: + pass + deadline = time.time() + 3.0 + while time.time() < deadline: + lots = ib.compute_open_lots(include_position_fallback=False) + if lots: + return jsonify(lots) + time.sleep(0.1) + return jsonify(ib.compute_open_lots(include_position_fallback=False)) + + +@app.route("/api/executions-debug") +def api_executions_debug(): + start_time = request.args.get("start_time", "20000101 00:00:00") + try: + ib.request_executions(start_time=start_time) + except Exception: + pass + deadline = time.time() + 5.0 + while time.time() < deadline: + time.sleep(0.1) + executions = list(ib.executions) + summary = {} + for e in executions: + key = e.get("instrument_key") or e.get("symbol") or "UNKNOWN" + item = summary.setdefault(key, { + "instrument_key": e.get("instrument_key"), + "symbol": e.get("symbol"), + "security_type": e.get("security_type"), + "display_symbol": e.get("display_symbol"), + "count": 0, + "first_time": None, + "last_time": None, + }) + item["count"] += 1 + etime = e.get("time") + if etime: + if item["first_time"] is None or etime < item["first_time"]: + item["first_time"] = etime + if item["last_time"] is None or etime > item["last_time"]: + item["last_time"] = etime + return jsonify({ + "start_time": start_time, + "execution_count": len(executions), + "executions": executions, + "summary": list(summary.values()), + }) + + +@app.route("/api/flex/status") +def api_flex_status(): + return jsonify(get_flex_status()) + + +@app.route("/api/flex/lots-debug") +def api_flex_lots_debug(): + status = get_flex_status() + if not status.get("configured") or not status.get("data_dir"): + return jsonify({"configured": False, "lots": [], "latest_file": None}) + latest_file, lots = load_latest_flex_lots(status["data_dir"]) + return jsonify({ + "configured": True, + "latest_file": latest_file, + "lot_count": len(lots), + "lots": lots, + }) + + +@app.route("/api/flex/sync", methods=["POST"]) +def api_flex_sync(): + try: + result = fetch_flex_statement() + return jsonify({"ok": True, **result}) + except FlexQueryError as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 500 + + +@app.route("/api/subscribe", methods=["POST"]) +def api_subscribe(): + data = request.get_json(force=True) + symbols = data.get("symbols", []) if isinstance(data, dict) else [] + if not isinstance(symbols, list): + return jsonify({"error": "symbols must be a list"}), 400 + for s in symbols: + ib.subscribe_market_data(s) + return jsonify({"subscribed": symbols}) + + +if __name__ == "__main__": + # simple dev server; disable reloader to avoid duplicate IB client instances + app.run(debug=True, port=8000, use_reloader=False) diff --git a/backend/flex_lots.py b/backend/flex_lots.py new file mode 100644 index 0000000..ec08067 --- /dev/null +++ b/backend/flex_lots.py @@ -0,0 +1,183 @@ +"""Parse lot-level open positions from IB Flex XML files.""" +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +import xml.etree.ElementTree as ET + + +def _as_float(value, default=None): + try: + if value in (None, ""): + return default + return float(value) + except Exception: + return default + + +def _normalize_expiry(expiry: str | None) -> str | None: + expiry = (expiry or "").strip() + if len(expiry) == 8 and expiry.isdigit(): + return f"{expiry[0:4]}-{expiry[4:6]}-{expiry[6:8]}" + return expiry or None + + +def _normalize_buy_time(open_dt: str | None) -> str | None: + open_dt = (open_dt or "").strip() + if not open_dt or ";" not in open_dt: + return open_dt or None + date_part, time_part = open_dt.split(";", 1) + if len(date_part) == 8 and len(time_part) == 6: + return f"{date_part[0:4]}-{date_part[4:6]}-{date_part[6:8]} {time_part[0:2]}:{time_part[2:4]}:{time_part[4:6]}" + return open_dt + + +def _compute_cagr_percent(cost_basis, gain_dollars, market_value, buy_time: str | None): + if cost_basis in (None, 0) or gain_dollars is None or not buy_time: + return None + try: + start = datetime.fromisoformat(buy_time).replace(tzinfo=timezone.utc) + now = datetime.now(timezone.utc) + years = max((now - start).total_seconds() / (365.25 * 24 * 3600), 1e-9) + gain_percent = (float(gain_dollars) / abs(float(cost_basis))) * 100.0 + if years < 1.0: + return gain_percent + start_value = abs(float(cost_basis)) + if float(cost_basis) < 0: + end_liability = abs(float(market_value)) if market_value is not None else abs(float(cost_basis) + float(gain_dollars)) + if start_value <= 0 or end_liability <= 0: + return None + return (((start_value / end_liability) ** (1.0 / years)) - 1.0) * 100.0 + end_value = start_value + float(gain_dollars) + if start_value <= 0 or end_value <= 0: + return None + return ((end_value / start_value) ** (1.0 / years) - 1.0) * 100.0 + except Exception: + return None + + +def _compute_days_since_buy(buy_time: str | None): + if not buy_time: + return None + try: + start = datetime.fromisoformat(buy_time).replace(tzinfo=timezone.utc) + now = datetime.now(timezone.utc) + return max((now - start).days, 0) + except Exception: + return None + + +def _display_symbol(attrs: dict) -> str: + description = (attrs.get("description") or "").strip() + if description: + return description + return (attrs.get("underlyingSymbol") or attrs.get("symbol") or "").strip() + + +def _instrument_key(attrs: dict) -> str: + conid = (attrs.get("conid") or "").strip() + if conid: + return f"CONID:{conid}" + return (attrs.get("symbol") or "").strip() + + +def _signed_quantity(raw_position, side: str, cost_basis, market_value): + quantity = abs(raw_position or 0.0) + if quantity == 0: + return 0.0 + normalized_side = (side or "").strip().lower() + if normalized_side == "short" or (raw_position or 0.0) < 0: + return -quantity + if cost_basis is not None and float(cost_basis) < 0: + return -quantity + if market_value is not None and float(market_value) < 0: + return -quantity + return quantity + + +def parse_flex_lots(xml_path: str | Path) -> list[dict]: + root = ET.parse(xml_path).getroot() + lots: list[dict] = [] + for node in root.findall(".//OpenPosition"): + attrs = node.attrib + if (attrs.get("levelOfDetail") or "").upper() != "LOT": + continue + + security_type = (attrs.get("assetCategory") or "").upper() or "STK" + multiplier = _as_float(attrs.get("multiplier"), 1.0) or 1.0 + fx_rate_to_base = _as_float(attrs.get("fxRateToBase"), 1.0) or 1.0 + raw_position = _as_float(attrs.get("position"), 0.0) or 0.0 + side = (attrs.get("side") or "").strip().lower() + + mark_price = _as_float(attrs.get("markPrice")) + market_price = mark_price + market_value = _as_float(attrs.get("positionValue")) + cost_basis_base = _as_float(attrs.get("costBasisMoney")) + capital_gain_base = _as_float(attrs.get("unrealizedCapitalGainsPnl")) + gain_dollars = _as_float(attrs.get("fifoPnlUnrealized")) + qty = _signed_quantity(raw_position, side, cost_basis_base, market_value) + + # Flex OpenPosition rows mix currencies for non-base holdings: + # mark/positionValue are in instrument currency, while costBasis/openPrice + # are in account base. Reconstruct local-cost values so they line up with TWS. + cost_basis = cost_basis_base + if market_value is not None and capital_gain_base is not None and fx_rate_to_base: + cost_basis = market_value - (capital_gain_base / fx_rate_to_base) + + avg_price = None + if qty: + divisor = abs(qty) * multiplier if security_type == "OPT" else abs(qty) + avg_price = abs(cost_basis / divisor) if cost_basis is not None and divisor else None + if avg_price is None: + avg_price = _as_float(attrs.get("costBasisPrice")) + if avg_price is None: + avg_price = _as_float(attrs.get("openPrice")) + buy_time = _normalize_buy_time(attrs.get("openDateTime")) + + # Prefer local-currency capital gain to keep gain/cost/value in one currency. + if capital_gain_base is not None and fx_rate_to_base: + gain_dollars = capital_gain_base / fx_rate_to_base + + gain_percent = None + if cost_basis not in (None, 0) and gain_dollars is not None: + gain_percent = (gain_dollars / abs(cost_basis)) * 100.0 + cagr_percent = _compute_cagr_percent(cost_basis, gain_dollars, market_value, buy_time) + days_since_buy = _compute_days_since_buy(buy_time) + ltcg = "X" if days_since_buy is not None and days_since_buy >= 365 else "" + + lots.append({ + "instrument_key": _instrument_key(attrs), + "con_id": _as_float(attrs.get("conid")), + "symbol": attrs.get("symbol"), + "display_symbol": _display_symbol(attrs), + "currency": attrs.get("currency") or None, + "security_type": security_type, + "underlying_symbol": attrs.get("underlyingSymbol") or attrs.get("symbol"), + "expiry": _normalize_expiry(attrs.get("expiry")), + "strike": _as_float(attrs.get("strike")), + "option_type": (attrs.get("putCall") or "").upper() or None, + "multiplier": str(int(multiplier)) if float(multiplier).is_integer() else str(multiplier), + "qty": qty, + "avg_price": avg_price, + "cost_basis": cost_basis, + "market_price": market_price, + "market_value": market_value, + "gain_dollars": gain_dollars, + "gain_percent": gain_percent, + "cagr_percent": cagr_percent, + "days_since_buy": days_since_buy, + "ltcg": ltcg, + "buy_time": buy_time, + }) + return lots + + +def load_latest_flex_lots(data_dir: str | Path) -> tuple[str | None, list[dict]]: + data_path = Path(data_dir) + if not data_path.exists(): + return None, [] + xml_files = sorted(data_path.glob("flex_*.xml")) + if not xml_files: + return None, [] + latest = xml_files[-1] + return str(latest), parse_flex_lots(latest) diff --git a/backend/flex_query.py b/backend/flex_query.py new file mode 100644 index 0000000..7dbafb1 --- /dev/null +++ b/backend/flex_query.py @@ -0,0 +1,141 @@ +"""Utilities for Interactive Brokers Flex Web Service integration. + +This module provides the first layer of infrastructure for pulling +historical trade data from a saved Flex Query. It intentionally keeps the +surface area small so the app can validate configuration, trigger a sync, +and persist the raw XML responses locally before the data is wired into the +lot engine. +""" +from __future__ import annotations + +import os +import time +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + + +FLEX_BASE_URL = "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService" +FLEX_SEND_REQUEST_URL = f"{FLEX_BASE_URL}/SendRequest" +FLEX_GET_STATEMENT_URL = f"{FLEX_BASE_URL}/GetStatement" + + +@dataclass +class FlexConfig: + token: str + query_id: str + data_dir: Path + poll_interval_seconds: float = 2.0 + max_polls: int = 30 + + +class FlexQueryError(RuntimeError): + pass + + +def load_flex_config() -> FlexConfig | None: + token = os.environ.get("IB_FLEX_TOKEN", "").strip() + query_id = os.environ.get("IB_FLEX_QUERY_ID", "").strip() + if not token or not query_id: + return None + data_dir = Path(os.environ.get("IB_FLEX_DATA_DIR", Path(__file__).resolve().parent.parent / "data" / "flex")) + return FlexConfig(token=token, query_id=query_id, data_dir=data_dir) + + +def get_flex_status() -> dict: + config = load_flex_config() + if config is None: + return { + "configured": False, + "missing": [ + name + for name in ("IB_FLEX_TOKEN", "IB_FLEX_QUERY_ID") + if not os.environ.get(name, "").strip() + ], + } + latest_file = None + latest_mtime = None + if config.data_dir.exists(): + xml_files = sorted(config.data_dir.glob("flex_*.xml")) + if xml_files: + latest = xml_files[-1] + latest_file = str(latest) + latest_mtime = latest.stat().st_mtime + return { + "configured": True, + "query_id": config.query_id, + "data_dir": str(config.data_dir), + "latest_file": latest_file, + "latest_timestamp": latest_mtime, + } + + +def _http_get(url: str, params: dict[str, str]) -> bytes: + full_url = f"{url}?{urllib.parse.urlencode(params)}" + request = urllib.request.Request( + full_url, + headers={"User-Agent": "IB-Dashboard/1.0"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + return response.read() + + +def _parse_send_request(xml_bytes: bytes) -> tuple[str, str]: + root = ET.fromstring(xml_bytes) + status = (root.findtext("Status") or "").strip() + if status.lower() != "success": + raise FlexQueryError(root.findtext("ErrorMessage") or "Flex send-request failed") + reference_code = (root.findtext("ReferenceCode") or "").strip() + if not reference_code: + raise FlexQueryError("Flex send-request returned no ReferenceCode") + return status, reference_code + + +def _parse_statement_status(xml_bytes: bytes) -> tuple[bool, str]: + root = ET.fromstring(xml_bytes) + if root.tag == "FlexStatementResponse": + error_message = (root.findtext("ErrorMessage") or "").strip() + if error_message: + raise FlexQueryError(error_message) + return False, "" + return True, xml_bytes.decode("utf-8", errors="replace") + + +def fetch_flex_statement() -> dict: + config = load_flex_config() + if config is None: + raise FlexQueryError("Flex Query is not configured") + + config.data_dir.mkdir(parents=True, exist_ok=True) + + _, reference_code = _parse_send_request(_http_get( + FLEX_SEND_REQUEST_URL, + {"t": config.token, "q": config.query_id, "v": "3"}, + )) + + statement_xml = None + for _ in range(config.max_polls): + xml_bytes = _http_get( + FLEX_GET_STATEMENT_URL, + {"t": config.token, "q": reference_code, "v": "3"}, + ) + done, payload = _parse_statement_status(xml_bytes) + if done: + statement_xml = payload + break + time.sleep(config.poll_interval_seconds) + + if statement_xml is None: + raise FlexQueryError("Timed out waiting for Flex statement generation") + + timestamp = time.strftime("%Y%m%d_%H%M%S") + destination = config.data_dir / f"flex_{timestamp}.xml" + destination.write_text(statement_xml, encoding="utf-8") + + return { + "saved_to": str(destination), + "reference_code": reference_code, + "bytes": len(statement_xml.encode("utf-8")), + } diff --git a/backend/ib_client.py b/backend/ib_client.py new file mode 100644 index 0000000..6a12b92 --- /dev/null +++ b/backend/ib_client.py @@ -0,0 +1,723 @@ +"""Lightweight IB client wrapper using ibapi. + +This wraps a minimal subset of EClient/EWrapper to subscribe to market +data and track positions in-memory. It's designed to be used by a +small Flask backend that serves a browser dashboard. + +Notes: +- Keep behavior simple: subscribe to market data for provided symbols. +- Store latest quotes in `self.quotes` and positions in `self.positions`. +""" +import logging +import threading +import time +from typing import Dict, List + +from ibapi.client import EClient +from ibapi.contract import Contract +from ibapi.wrapper import EWrapper + + +class IBClient(EWrapper, EClient): + def __init__(self, host: str = "127.0.0.1", port: int = 7496, client_id: int = 1): + EClient.__init__(self, self) + # normalize/validate connection params + if not host: + host = "127.0.0.1" + if not isinstance(port, int): + try: + port = int(port) + except Exception: + port = 7496 + self.host = host + self.port = port + self.client_id = client_id + + self._thread = None + self._next_req_id = 1 + + # connection sync + self._connected_event = threading.Event() + + # subscriptions requested before connection + self._pending_subscriptions: Dict[str, Dict] = {} + + # in-memory stores + self.quotes: Dict[str, Dict] = {} + self.positions: List[Dict] = [] + # store executions (append as execDetails callbacks arrive) + self.executions: List[Dict] = [] + self._execution_ids = set() + self.historical_closes: Dict[str, float] = {} + + # tracking for subscriptions: reqId -> symbol + self._req_map: Dict[int, str] = {} + self._historical_req_map: Dict[int, str] = {} + self._requested_symbols = set() + self._requested_accounts = set() + self._requested_historical = set() + + # basic logging + logging.basicConfig(level=logging.INFO) + + def _normalize_expiry(self, expiry): + expiry = str(expiry or "").strip() + if len(expiry) == 8 and expiry.isdigit(): + return f"{expiry[0:4]}-{expiry[4:6]}-{expiry[6:8]}" + if len(expiry) == 6 and expiry.isdigit(): + return f"{expiry[0:4]}-{expiry[4:6]}" + return expiry + + def _contract_key(self, contract) -> str: + if not contract: + return "" + con_id = getattr(contract, "conId", None) + if con_id: + return f"CONID:{con_id}" + sec_type = (getattr(contract, "secType", None) or "").upper() + symbol = getattr(contract, "symbol", None) or "" + if sec_type == "OPT": + expiry = self._normalize_expiry(getattr(contract, "lastTradeDateOrContractMonth", None)) + strike = getattr(contract, "strike", None) + right = getattr(contract, "right", None) or "" + multiplier = getattr(contract, "multiplier", None) or "" + return f"OPT:{symbol}:{expiry}:{strike}:{right}:{multiplier}" + return f"{sec_type or 'STK'}:{symbol}" + + def _contract_to_record(self, contract) -> Dict: + if not contract: + return {} + sec_type = (getattr(contract, "secType", None) or "STK").upper() + symbol = getattr(contract, "symbol", None) or "" + if sec_type == "OPT": + expiry = self._normalize_expiry(getattr(contract, "lastTradeDateOrContractMonth", None)) + strike = getattr(contract, "strike", None) + right = (getattr(contract, "right", None) or "").upper() + multiplier = getattr(contract, "multiplier", None) + else: + expiry = None + strike = None + right = None + multiplier = None + local_symbol = getattr(contract, "localSymbol", None) + rec = { + "instrument_key": self._contract_key(contract), + "con_id": getattr(contract, "conId", None) or None, + "symbol": symbol, + "security_type": sec_type, + "underlying_symbol": symbol, + "expiry": expiry or None, + "strike": float(strike) if strike not in (None, "") else None, + "option_type": right or None, + "multiplier": str(multiplier) if multiplier not in (None, "") else None, + "local_symbol": local_symbol or None, + "exchange": getattr(contract, "exchange", None) or None, + "primary_exchange": getattr(contract, "primaryExchange", None) or None, + "currency": getattr(contract, "currency", None) or None, + "trading_class": getattr(contract, "tradingClass", None) or None, + } + rec["display_symbol"] = self._format_display_symbol(rec) + return rec + + def _format_display_symbol(self, rec: Dict) -> str: + sec_type = (rec.get("security_type") or "STK").upper() + symbol = rec.get("underlying_symbol") or rec.get("symbol") or "" + if sec_type == "OPT": + expiry = rec.get("expiry") or "?" + strike = rec.get("strike") + if isinstance(strike, float) and strike.is_integer(): + strike_str = str(int(strike)) + else: + strike_str = str(strike) if strike is not None else "?" + option_type = rec.get("option_type") or "?" + return f"{symbol} {expiry} {strike_str} {option_type}" + return symbol + + def _price_multiplier(self, rec: Dict) -> float: + sec_type = (rec.get("security_type") or "STK").upper() + if sec_type != "OPT": + return 1.0 + multiplier = rec.get("multiplier") + try: + return float(multiplier) if multiplier not in (None, "", 0, "0") else 100.0 + except Exception: + return 100.0 + + def _make_contract_from_record(self, rec: Dict) -> Contract: + sec_type = (rec.get("security_type") or "STK").upper() + con_id = rec.get("con_id") + if con_id: + c = Contract() + c.conId = int(con_id) + c.secType = sec_type + c.exchange = rec.get("exchange") or "SMART" + currency = rec.get("currency") + if currency: + c.currency = currency + return c + if sec_type == "OPT": + c = Contract() + c.symbol = rec.get("underlying_symbol") or rec.get("symbol") + c.secType = "OPT" + c.exchange = rec.get("exchange") or "SMART" + c.currency = rec.get("currency") or "USD" + expiry = rec.get("expiry") or "" + c.lastTradeDateOrContractMonth = expiry.replace("-", "") + strike = rec.get("strike") + if strike is not None: + c.strike = float(strike) + option_type = rec.get("option_type") + if option_type: + c.right = option_type + multiplier = rec.get("multiplier") + if multiplier: + c.multiplier = str(multiplier) + local_symbol = rec.get("local_symbol") + if local_symbol: + c.localSymbol = local_symbol + primary_exchange = rec.get("primary_exchange") + if primary_exchange: + c.primaryExchange = primary_exchange + trading_class = rec.get("trading_class") + if trading_class: + c.tradingClass = trading_class + return c + return self._make_stock_contract( + rec.get("symbol") or rec.get("underlying_symbol"), + exchange=rec.get("exchange") or "SMART", + currency=rec.get("currency") or "USD", + ) + + def connect_and_start(self) -> None: + if not self.isConnected(): + logging.info("Connecting to IB gateway %s:%s", self.host, self.port) + self.connect(self.host, self.port, self.client_id) + self._thread = threading.Thread(target=self.run, daemon=True) + self._thread.start() + # don't block here; nextValidId will mark connection ready + + def disconnect_and_stop(self) -> None: + try: + if self.isConnected(): + self.disconnect() + except Exception: + pass + + # ---- EWrapper overrides (minimal) ---- + def nextValidId(self, orderId: int): + super().nextValidId(orderId) + # ensure we start with a reasonably large req id to avoid collisions + if orderId and orderId > self._next_req_id: + self._next_req_id = orderId + # mark connection as ready + try: + self._connected_event.set() + except Exception: + pass + # process any pending subscriptions + if self._pending_subscriptions: + pending = list(self._pending_subscriptions.values()) + self._pending_subscriptions.clear() + for s in pending: + try: + self.subscribe_market_data(s) + except Exception: + logging.exception("Failed to process pending subscription %s", s) + + def error(self, reqId, errorCode, errorString): + logging.error("IB error (req=%s code=%s): %s", reqId, errorCode, errorString) + + def tickPrice(self, reqId, tickType, price, attrib): + # store last or closing price when available + if price is None or price == -1: + return + instrument_key = self._req_map.get(reqId) + if not instrument_key: + return + data = self.quotes.setdefault(instrument_key, {}) + if tickType in (4, 68): + data["last"] = price + elif tickType in (9, 75): + data["close"] = price + elif tickType in (1, 66): + data["bid"] = price + elif tickType in (2, 67): + data["ask"] = price + elif tickType == 37: + data["mark"] = price + + def tickSize(self, reqId, tickType, size): + instrument_key = self._req_map.get(reqId) + if not instrument_key: + return + data = self.quotes.setdefault(instrument_key, {}) + data["size"] = size + + def updatePortfolio(self, contract, position, marketPrice, marketValue, averageCost, unrealizedPNL, realizedPNL, accountName): + # simple representation of the position + if not contract or not contract.symbol: + return + contract_rec = self._contract_to_record(contract) + pos = { + **contract_rec, + "position": position, + "marketPrice": marketPrice, + "marketValue": marketValue, + "averageCost": averageCost, + "account": accountName, + } + # replace existing entry for the symbol + for i, p in enumerate(self.positions): + if p.get("instrument_key") == pos["instrument_key"] and p.get("account") == accountName: + self.positions[i] = pos + break + else: + self.positions.append(pos) + + def positionEnd(self): + # called when a batch of position updates is finished + logging.info("Position update batch complete. %d positions stored", len(self.positions)) + + def position(self, account, contract, position, avgCost): + """Handle positions returned by `reqPositions()`. + + Signature from EWrapper: position(self, account, contract, position, avgCost) + """ + try: + if not contract or not getattr(contract, 'symbol', None): + return + contract_rec = self._contract_to_record(contract) + pos = { + **contract_rec, + "position": position, + "averageCost": avgCost, + "account": account, + } + # replace existing entry for the symbol + for i, p in enumerate(self.positions): + if p.get("instrument_key") == pos["instrument_key"] and p.get("account") == account: + self.positions[i] = pos + break + else: + self.positions.append(pos) + except Exception: + logging.exception("Error handling position callback") + + def execDetails(self, reqId, contract, execution): + """Capture execution details for building lots. + + `execution` is an `Execution` object from ibapi which contains + .shares, .price, .side, .execId, .time (may be empty) + """ + try: + symbol = getattr(contract, "symbol", None) + if not symbol: + return + contract_rec = self._contract_to_record(contract) + entry = { + "execId": getattr(execution, "execId", None), + **contract_rec, + "shares": float(getattr(execution, "shares", 0)), + "price": float(getattr(execution, "price", 0.0)), + "side": getattr(execution, "side", ""), + "time": getattr(execution, "time", None), + "permId": getattr(execution, "permId", None), + } + exec_id = entry.get("execId") + if exec_id and exec_id in self._execution_ids: + return + if exec_id: + self._execution_ids.add(exec_id) + self.executions.append(entry) + except Exception: + logging.exception("Failed to record execution detail") + + def execDetailsEnd(self, reqId): + logging.info("Finished receiving executions (req=%s). %d execs total", reqId, len(self.executions)) + + # ---- helpers ---- + def _new_req_id(self) -> int: + rid = self._next_req_id + self._next_req_id += 1 + return rid + + def _make_stock_contract(self, symbol: str, exchange: str = "SMART", currency: str = "USD") -> Contract: + c = Contract() + c.symbol = symbol + c.secType = "STK" + c.exchange = exchange + c.currency = currency + return c + + def subscribe_market_data(self, contract_or_record) -> None: + """Subscribe to basic market data for a contract or simple symbol string. + + Repeated calls for the same symbol are ignored. + """ + if isinstance(contract_or_record, str): + contract = self._make_stock_contract(contract_or_record) + queued_record = {"symbol": contract_or_record, "security_type": "STK"} + instrument_key = self._contract_key(contract) + else: + contract = self._make_contract_from_record(contract_or_record) + queued_record = dict(contract_or_record) + instrument_key = self._contract_key(contract) + if instrument_key in self._requested_symbols: + return + # if not connected yet, queue the subscription + if not self._connected_event.is_set(): + logging.info("Not connected yet; queuing subscription for %s", instrument_key) + self._pending_subscriptions[instrument_key] = queued_record + # ensure we start connection + try: + self.connect_and_start() + except Exception: + logging.exception("Failed to start connection while queuing subscription") + return + + req_id = self._new_req_id() + self._req_map[req_id] = instrument_key + # prefer delayed data when live market data is not available + try: + self.reqMarketDataType(3) + self.reqMktData(req_id, contract, "", False, False, []) + self._requested_symbols.add(instrument_key) + logging.info("Subscribed to market data for %s (req=%s)", instrument_key, req_id) + except Exception as e: + logging.exception("Failed to subscribe to %s: %s", instrument_key, e) + + def request_positions(self) -> None: + """Ask the IB API to send current positions (triggers updatePortfolio callbacks).""" + if not self._connected_event.is_set(): + logging.info("Not connected yet; will request positions only if connection parameters are valid") + # validate host/port before trying to connect + if not isinstance(self.host, str) or not isinstance(self.port, int): + logging.error("Invalid connection parameters: host=%r port=%r", self.host, self.port) + return + try: + self.connect_and_start() + except Exception: + logging.exception("Failed to start connection for positions request") + # schedule a delayed reqPositions after connection is established + def _delayed(): + if self._connected_event.wait(5): + try: + self.reqPositions() + except Exception: + logging.exception("Failed to request positions after connect") + threading.Thread(target=_delayed, daemon=True).start() + return + + try: + self.reqPositions() + except Exception: + logging.exception("Failed to request positions") + + def request_account_updates(self, account: str) -> None: + """Subscribe to portfolio/account updates for a specific account.""" + if not account or account in self._requested_accounts: + return + if not self._connected_event.is_set(): + try: + self.connect_and_start() + except Exception: + logging.exception("Failed to start connection for account updates") + return + try: + self.reqAccountUpdates(True, account) + self._requested_accounts.add(account) + except Exception: + logging.exception("Failed to request account updates for %s", account) + + def request_executions(self, start_time: str | None = None) -> None: + """Request executions from IB (will trigger execDetails callbacks). + + Uses an empty ExecutionFilter to fetch recent executions. The results + arrive via `execDetails` callbacks. + """ + try: + # import here to avoid module-level dependency issues + from ibapi.execution import ExecutionFilter + filt = ExecutionFilter() + if start_time: + filt.time = start_time + rid = self._new_req_id() + self.reqExecutions(rid, filt) + except Exception: + logging.exception("Failed to request executions") + + def historicalData(self, reqId, bar): + try: + instrument_key = self._historical_req_map.get(reqId) + if not instrument_key: + return + close = getattr(bar, "close", None) + if close not in (None, -1): + self.historical_closes[instrument_key] = float(close) + except Exception: + logging.exception("Failed to process historical data") + + def historicalDataEnd(self, reqId, start, end): + self._historical_req_map.pop(reqId, None) + + def request_historical_close(self, contract_or_record) -> None: + if isinstance(contract_or_record, str): + contract = self._make_stock_contract(contract_or_record) + else: + contract = self._make_contract_from_record(contract_or_record) + instrument_key = self._contract_key(contract) + if not instrument_key or instrument_key in self._requested_historical: + return + if not self._connected_event.is_set(): + try: + self.connect_and_start() + except Exception: + logging.exception("Failed to start connection for historical close") + return + try: + req_id = self._new_req_id() + self._historical_req_map[req_id] = instrument_key + self._requested_historical.add(instrument_key) + self.reqHistoricalData(req_id, contract, "", "2 D", "1 day", "TRADES", 0, 1, False, []) + except Exception: + logging.exception("Failed to request historical close for %s", instrument_key) + + def get_quotes(self): + return dict(self.quotes) + + def get_positions(self): + return list(self.positions) + + def compute_open_lots(self, include_position_fallback: bool = False): + """Compute open lots using FIFO matching from recorded executions. + + Returns a list of lots with: symbol, qty, cost_basis, avg_price, market_price, + market_value, gain_dollars, gain_percent, cagr_percent, buy_time + """ + # group executions by symbol + from collections import defaultdict + from datetime import datetime + + by_symbol = defaultdict(list) + for e in self.executions: + by_symbol[e["instrument_key"]].append(e) + + # include symbols from positions as well so we can fallback + position_symbols = set() + position_by_symbol = {} + for p in self.positions: + instrument_key = p.get("instrument_key") + if not instrument_key: + continue + position_symbols.add(instrument_key) + if instrument_key not in position_by_symbol: + position_by_symbol[instrument_key] = p + + lots = [] + all_symbols = set(by_symbol.keys()) | position_symbols + for instrument_key in all_symbols: + execs = by_symbol.get(instrument_key, []) + position = position_by_symbol.get(instrument_key) or {} + meta = execs[0] if execs else position + # sort by time if available, else keep order + def _time_key(x): + t = x.get("time") + if not t: + return 0 + # try parsing common formats + for fmt in ("%Y%m%d %H:%M:%S", "%Y%m%d %H:%M:%S", "%Y%m%d-%H:%M:%S"): + try: + return datetime.strptime(t, fmt).timestamp() + except Exception: + continue + try: + return float(t) + except Exception: + return 0 + + execs_sorted = sorted(execs, key=_time_key) if execs else [] + # build open lots using FIFO for both long and short positions + open_longs = [] + open_shorts = [] + for e in execs_sorted: + side = (e.get("side") or "").upper() + shares = abs(float(e.get("shares", 0) or 0)) + price = float(e.get("price", 0.0) or 0.0) + time_str = e.get("time") + if side == "BOT" or side == "BUY": + qty_to_buy = shares + while qty_to_buy > 0 and open_shorts: + s = open_shorts[0] + matched = min(qty_to_buy, abs(s["qty"])) + s["qty"] += matched + qty_to_buy -= matched + if abs(s["qty"]) < 1e-9: + open_shorts.pop(0) + if qty_to_buy > 0: + open_longs.append({"qty": qty_to_buy, "price": price, "time": time_str}) + else: + qty_to_sell = shares + while qty_to_sell > 0 and open_longs: + b = open_longs[0] + matched = min(qty_to_sell, b["qty"]) + b["qty"] -= matched + qty_to_sell -= matched + if b["qty"] < 1e-9: + open_longs.pop(0) + if qty_to_sell > 0: + open_shorts.append({"qty": -qty_to_sell, "price": price, "time": time_str}) + + # remaining buys are open lots + # ensure market data available for symbol + if instrument_key not in self.quotes: + try: + self.subscribe_market_data(meta) + except Exception: + pass + market_price = None + q = self.quotes.get(instrument_key) + if q: + market_price = q.get("last") + if market_price is None: + market_price = q.get("close") + if market_price is None: + market_price = q.get("mark") + if market_price is None: + market_price = self.historical_closes.get(instrument_key) + if market_price is None: + market_price = position.get("marketPrice") + + try: + position_qty = float(position.get("position") or 0.0) + except Exception: + position_qty = 0.0 + try: + position_market_value = float(position.get("marketValue")) if position.get("marketValue") is not None else None + except Exception: + position_market_value = None + + known_market_value_remaining = position_market_value + first_exec_time = execs_sorted[0].get("time") if execs_sorted else None + price_multiplier = self._price_multiplier(meta or position) + + if execs_sorted: + open_lots = open_longs + open_shorts + for b in open_lots: + qty = float(b["qty"]) + avg_price = float(b["price"]) * price_multiplier + cost_basis = qty * avg_price + effective_market_price = None if market_price is None else market_price * price_multiplier + market_value = None if effective_market_price is None else qty * effective_market_price + if market_value is None and known_market_value_remaining is not None and position_qty: + market_value = known_market_value_remaining * (qty / position_qty) + if known_market_value_remaining is not None and market_value is not None: + known_market_value_remaining -= market_value + gain_dollars = None if market_value is None else market_value - cost_basis + gain_percent = None + cagr_percent = None + base_cost = abs(cost_basis) + if base_cost and market_value is not None: + try: + gain_percent = (gain_dollars / base_cost) * 100 + except Exception: + gain_percent = None + # estimate CAGR using buy time if available + buy_time = b.get("time") + if buy_time and market_value is not None and base_cost > 0: + try: + # parse time similar to above + for fmt in ("%Y%m%d %H:%M:%S", "%Y%m%d %H:%M:%S", "%Y%m%d-%H:%M:%S"): + try: + dt = datetime.strptime(buy_time, fmt) + break + except Exception: + dt = None + if dt is None: + # try numeric timestamp + try: + dt = datetime.fromtimestamp(float(buy_time)) + except Exception: + dt = None + if dt is not None: + years = max((datetime.utcnow() - dt).days / 365.0, 1e-6) + try: + ending_value = base_cost + gain_dollars + if ending_value > 0: + cagr = (ending_value / base_cost) ** (1.0 / years) - 1.0 + cagr_percent = cagr * 100.0 + else: + cagr_percent = None + except Exception: + cagr_percent = None + except Exception: + cagr_percent = None + + lots.append({ + "symbol": meta.get("symbol"), + "display_symbol": meta.get("display_symbol") or self._format_display_symbol(meta), + "security_type": meta.get("security_type"), + "underlying_symbol": meta.get("underlying_symbol"), + "expiry": meta.get("expiry"), + "strike": meta.get("strike"), + "option_type": meta.get("option_type"), + "instrument_key": instrument_key, + "qty": qty, + "avg_price": avg_price, + "cost_basis": cost_basis, + "market_price": effective_market_price, + "market_value": market_value, + "gain_dollars": gain_dollars, + "gain_percent": gain_percent, + "cagr_percent": cagr_percent, + "buy_time": buy_time, + }) + elif include_position_fallback: + # fallback: use positions data to create a single lot per symbol + for p in self.positions: + if p.get("instrument_key") != instrument_key: + continue + qty = p.get("position") or 0 + avg_price = p.get("averageCost") or p.get("average_cost") or 0.0 + try: + qtyf = float(qty) + except Exception: + qtyf = 0.0 + try: + avg_pricef = float(avg_price) + except Exception: + avg_pricef = 0.0 + cost_basis = qtyf * avg_pricef + effective_market_price = None if market_price is None else market_price * self._price_multiplier(p) + if effective_market_price is not None: + market_value = qtyf * effective_market_price + else: + market_value = position_market_value + gain_dollars = None if market_value is None else market_value - cost_basis + gain_percent = None + base_cost = abs(cost_basis) + if base_cost and market_value is not None: + try: + gain_percent = (gain_dollars / base_cost) * 100 + except Exception: + gain_percent = None + lots.append({ + "symbol": p.get("symbol"), + "display_symbol": p.get("display_symbol") or self._format_display_symbol(p), + "security_type": p.get("security_type"), + "underlying_symbol": p.get("underlying_symbol"), + "expiry": p.get("expiry"), + "strike": p.get("strike"), + "option_type": p.get("option_type"), + "instrument_key": instrument_key, + "qty": qtyf, + "avg_price": avg_pricef, + "cost_basis": cost_basis, + "market_price": effective_market_price, + "market_value": market_value, + "gain_dollars": gain_dollars, + "gain_percent": gain_percent, + "cagr_percent": None, + "buy_time": first_exec_time, + }) + + return lots diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..98cf6c8 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,573 @@ +const qs = (s) => document.querySelector(s); +const ptbody = qs('#positions-table tbody'); +const pthead = qs('#positions-table thead'); +const aggregateCheckbox = qs('#aggregate-same-day'); +const refreshButton = qs('#refresh-lots'); +let latestPositions = []; +let sortState = []; +let filterState = {}; +let aggregateSameDay = true; +let columnOrder = []; +let dragColumnKey = null; +let isRefreshing = false; + +const STORAGE_KEY = 'ib-dashboard-open-lots-table-state'; +const numericColumns = new Set([ + 'qty', + 'avg_price', + 'cost_basis', + 'market_price', + 'market_value', + 'gain_dollars', + 'gain_percent', + 'cagr_percent', + 'days_since_buy', +]); +const dateColumns = new Set(['buy_time']); + +const columnDefs = [ + { key: 'security_type', label: 'Type', filter: 'select', options: [{ value: '', label: 'All' }, { value: 'STK', label: 'STK' }, { value: 'OPT', label: 'OPT' }] }, + { key: 'display_symbol', label: 'Security', filter: 'text', placeholder: 'AM*' }, + { key: 'currency', label: 'Currency', filter: 'select-dynamic', options: [{ value: '', label: 'All' }] }, + { key: 'underlying_symbol', label: 'Underlying', filter: 'text', placeholder: 'A*' }, + { key: 'qty', label: 'Qty', filter: 'text', placeholder: '>100', numeric: true }, + { key: 'avg_price', label: 'Avg Price', filter: 'text', placeholder: '<20', numeric: true }, + { key: 'cost_basis', label: 'Cost Basis', filter: 'text', placeholder: '>1000', numeric: true }, + { key: 'market_price', label: 'Market Price', filter: 'text', placeholder: '<10', numeric: true }, + { key: 'market_value', label: 'Market Value', filter: 'text', placeholder: '>5000', numeric: true }, + { key: 'gain_dollars', label: 'Gain $', filter: 'text', placeholder: '>0', numeric: true }, + { key: 'gain_percent', label: 'Gain %', filter: 'text', placeholder: '>10', numeric: true }, + { key: 'cagr_percent', label: 'CAGR %', filter: 'text', placeholder: '>5', numeric: true }, + { key: 'days_since_buy', label: 'Days Since Buy', filter: 'text', placeholder: '>365', numeric: true }, + { key: 'ltcg', label: 'LTCG', filter: 'select', options: [{ value: '', label: 'All' }, { value: 'X', label: 'Long-term' }, { value: '!X', label: 'Short-term' }] }, + { key: 'buy_time', label: 'Buy Time', filter: 'text', placeholder: '>2025-01-01', date: true }, +]; + +const columnAccessors = { + security_type: (p) => p.security_type ?? '', + display_symbol: (p) => p.display_symbol ?? p.symbol ?? '', + currency: (p) => p.currency ?? '', + underlying_symbol: (p) => p.underlying_symbol ?? '', + qty: (p) => p.qty, + avg_price: (p) => p.avg_price, + cost_basis: (p) => p.cost_basis, + market_price: (p) => p.market_price, + market_value: (p) => p.market_value, + gain_dollars: (p) => p.gain_dollars, + gain_percent: (p) => p.gain_percent, + cagr_percent: (p) => p.cagr_percent, + days_since_buy: (p) => p.days_since_buy, + ltcg: (p) => p.ltcg ?? '', + buy_time: (p) => p.buy_time ?? '', +}; + +const defaultColumnOrder = columnDefs.map((column) => column.key); + +function getActiveColumns() { + const ordered = columnOrder.length ? columnOrder : defaultColumnOrder; + return ordered + .map((key) => columnDefs.find((column) => column.key === key)) + .filter(Boolean); +} + +function asNumber(value) { + if (value == null || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function formatNumber(value, digits = 2) { + const num = asNumber(value); + if (num == null) return ''; + return num.toLocaleString(undefined, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +function formatPercent(value) { + const num = asNumber(value); + if (num == null) return ''; + return `${formatNumber(num, 2)}%`; +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function saveTableState() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + sortState, + filterState, + aggregateSameDay, + columnOrder, + })); + } catch (error) { + console.warn('Failed to save table state', error); + } +} + +function loadTableState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed.sortState)) { + sortState = parsed.sortState.filter((rule) => rule && typeof rule.column === 'string' && typeof rule.direction === 'string'); + } + if (parsed.filterState && typeof parsed.filterState === 'object') { + filterState = Object.fromEntries( + Object.entries(parsed.filterState).filter(([, value]) => typeof value === 'string' && value.trim() !== '') + ); + } + if (typeof parsed.aggregateSameDay === 'boolean') { + aggregateSameDay = parsed.aggregateSameDay; + } + if (Array.isArray(parsed.columnOrder)) { + const valid = parsed.columnOrder.filter((key) => defaultColumnOrder.includes(key)); + if (valid.length) { + const missing = defaultColumnOrder.filter((key) => !valid.includes(key)); + columnOrder = [...valid, ...missing]; + } + } + } catch (error) { + console.warn('Failed to load table state', error); + } +} + +async function fetchJson(path) { + const res = await fetch(path); + if (!res.ok) throw new Error('Network response not ok'); + return res.json(); +} + +function compareValues(a, b) { + const aMissing = a == null || a === ''; + const bMissing = b == null || b === ''; + if (aMissing && bMissing) return 0; + if (aMissing) return 1; + if (bMissing) return -1; + if (typeof a === 'number' && typeof b === 'number') return a - b; + return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); +} + +function escapeRegex(text) { + return text.replace(/[.+^${}()|[\]\\]/g, '\\$&'); +} + +function wildcardToRegex(pattern) { + const escaped = escapeRegex(pattern).replace(/\*/g, '.*').replace(/\?/g, '.'); + return new RegExp(`^${escaped}$`, 'i'); +} + +function parseComparator(expression) { + const match = String(expression).trim().match(/^(<=|>=|<|>|=)\s*(.+)$/); + if (!match) return null; + return { operator: match[1], value: match[2].trim() }; +} + +function compareByOperator(left, operator, right) { + if (left == null || right == null) return false; + switch (operator) { + case '<': return left < right; + case '<=': return left <= right; + case '>': return left > right; + case '>=': return left >= right; + case '=': return left === right; + default: return false; + } +} + +function matchesFilter(column, rawValue, expression) { + const filter = String(expression ?? '').trim(); + if (!filter) return true; + const value = rawValue ?? ''; + + if (column === 'ltcg' && filter === '!X') { + return String(value).trim().toUpperCase() !== 'X'; + } + + if (numericColumns.has(column)) { + const numericValue = asNumber(value); + const comparator = parseComparator(filter); + if (comparator) return compareByOperator(numericValue, comparator.operator, asNumber(comparator.value)); + return numericValue === asNumber(filter); + } + + if (dateColumns.has(column)) { + const normalizedValue = String(value); + const comparator = parseComparator(filter); + if (comparator) return compareByOperator(normalizedValue, comparator.operator, comparator.value); + return wildcardToRegex(filter).test(normalizedValue); + } + + return wildcardToRegex(filter).test(String(value)); +} + +function filterPositions(list) { + return list.filter((row) => Object.entries(filterState).every(([column, expression]) => { + const accessor = columnAccessors[column]; + if (!accessor) return true; + return matchesFilter(column, accessor(row), expression); + })); +} + +function toDateOnly(value) { + return String(value ?? '').slice(0, 10); +} + +function aggregateLots(list) { + if (!aggregateSameDay) return [...list]; + const groups = new Map(); + for (const lot of list) { + const buyDate = toDateOnly(lot.buy_time); + const key = `${lot.instrument_key ?? ''}|${buyDate}`; + const existing = groups.get(key); + if (!existing) { + groups.set(key, { + ...lot, + buy_time: buyDate || lot.buy_time, + }); + continue; + } + + existing.qty = (existing.qty ?? 0) + (lot.qty ?? 0); + existing.cost_basis = (existing.cost_basis ?? 0) + (lot.cost_basis ?? 0); + existing.market_value = (existing.market_value ?? 0) + (lot.market_value ?? 0); + existing.gain_dollars = (existing.gain_dollars ?? 0) + (lot.gain_dollars ?? 0); + existing.days_since_buy = Math.min( + asNumber(existing.days_since_buy) ?? Number.MAX_SAFE_INTEGER, + asNumber(lot.days_since_buy) ?? Number.MAX_SAFE_INTEGER, + ); + existing.ltcg = existing.ltcg === 'X' && lot.ltcg === 'X' ? 'X' : ''; + + const qty = asNumber(existing.qty); + const costBasis = asNumber(existing.cost_basis); + const marketValue = asNumber(existing.market_value); + existing.avg_price = qty ? Math.abs(costBasis / qty) : null; + existing.market_price = qty ? Math.abs(marketValue / qty) : null; + existing.gain_percent = costBasis ? (existing.gain_dollars / Math.abs(costBasis)) * 100 : null; + + const cagrBase = Math.abs(costBasis ?? 0); + if (cagrBase > 0 && existing.gain_dollars != null && existing.days_since_buy != null) { + const years = Math.max(existing.days_since_buy / 365.25, 1e-9); + if (years < 1) { + existing.cagr_percent = existing.gain_percent; + } else if ((costBasis ?? 0) < 0) { + const endLiability = Math.abs(existing.market_value ?? 0); + existing.cagr_percent = endLiability > 0 + ? (((cagrBase / endLiability) ** (1 / years)) - 1) * 100 + : null; + } else { + const endingValue = cagrBase + existing.gain_dollars; + existing.cagr_percent = endingValue > 0 + ? (((endingValue / cagrBase) ** (1 / years)) - 1) * 100 + : null; + } + } else { + existing.cagr_percent = null; + } + } + return Array.from(groups.values()); +} + +function sortPositions(list) { + if (!sortState.length) return [...list]; + return [...list].sort((left, right) => { + for (const rule of sortState) { + const accessor = columnAccessors[rule.column]; + if (!accessor) continue; + const leftValue = accessor(left); + const rightValue = accessor(right); + const result = numericColumns.has(rule.column) + ? compareValues(asNumber(leftValue), asNumber(rightValue)) + : compareValues(leftValue, rightValue); + if (result !== 0) return rule.direction === 'asc' ? result : -result; + } + return 0; + }); +} + +function getCurrencyOptions() { + const currencies = [...new Set(latestPositions.map((position) => position.currency).filter(Boolean))].sort((a, b) => a.localeCompare(b)); + return [{ value: '', label: 'All' }, ...currencies.map((currency) => ({ value: currency, label: currency }))]; +} + +function captureFocusState() { + const active = document.activeElement; + if (!active || !active.classList || !active.classList.contains('filter-input')) { + return null; + } + return { + column: active.dataset.column, + selectionStart: typeof active.selectionStart === 'number' ? active.selectionStart : null, + selectionEnd: typeof active.selectionEnd === 'number' ? active.selectionEnd : null, + }; +} + +function restoreFocusState(state) { + if (!state?.column) return; + const input = document.querySelector(`.filter-input[data-column="${state.column}"]`); + if (!input) return; + input.focus(); + if (typeof state.selectionStart === 'number' && typeof state.selectionEnd === 'number' && typeof input.setSelectionRange === 'function') { + input.setSelectionRange(state.selectionStart, state.selectionEnd); + } +} + +function createFilterControl(column) { + const value = filterState[column.key] ?? ''; + if (column.filter === 'select' || column.filter === 'select-dynamic') { + const select = document.createElement('select'); + select.className = 'filter-input filter-select'; + select.dataset.column = column.key; + const options = column.filter === 'select-dynamic' ? getCurrencyOptions() : column.options; + for (const option of options) { + const element = document.createElement('option'); + element.value = option.value; + element.textContent = option.label; + select.appendChild(element); + } + select.value = value; + if (value) { + select.classList.add('filter-active'); + } + select.addEventListener('change', onFilterInput); + return select; + } + + const input = document.createElement('input'); + input.className = 'filter-input filter-text'; + input.dataset.column = column.key; + input.placeholder = column.placeholder ?? ''; + input.value = value; + if (value) { + input.classList.add('filter-active'); + } + input.addEventListener('input', onFilterInput); + return input; +} + +function renderHeader() { + pthead.innerHTML = ''; + const sortRow = document.createElement('tr'); + sortRow.className = 'sort-row'; + const filterRow = document.createElement('tr'); + filterRow.className = 'filter-row'; + + for (const column of getActiveColumns()) { + const sortTh = document.createElement('th'); + sortTh.dataset.column = column.key; + sortTh.draggable = true; + if (column.numeric) sortTh.classList.add('num'); + + const label = document.createElement('span'); + label.textContent = column.label; + sortTh.appendChild(label); + + const ruleIndex = sortState.findIndex((rule) => rule.column === column.key); + if (ruleIndex !== -1) { + const rule = sortState[ruleIndex]; + const meta = document.createElement('span'); + meta.className = 'sort-meta'; + meta.textContent = `${rule.direction === 'asc' ? '▲' : '▼'} ${ruleIndex + 1}`; + sortTh.appendChild(meta); + } + + sortTh.addEventListener('click', (event) => { + if (event.target.closest('.drag-handle')) return; + cycleSort(column.key, event.shiftKey); + }); + sortTh.addEventListener('dragstart', onHeaderDragStart); + sortTh.addEventListener('dragover', onHeaderDragOver); + sortTh.addEventListener('drop', onHeaderDrop); + sortTh.addEventListener('dragend', onHeaderDragEnd); + sortRow.appendChild(sortTh); + + const filterTh = document.createElement('th'); + if (column.numeric) filterTh.classList.add('num'); + filterTh.appendChild(createFilterControl(column)); + filterRow.appendChild(filterTh); + } + + pthead.appendChild(sortRow); + pthead.appendChild(filterRow); +} + +function renderCell(columnKey, row) { + switch (columnKey) { + case 'security_type': + return escapeHtml(row.security_type ?? ''); + case 'display_symbol': + return escapeHtml(row.display_symbol ?? row.symbol ?? ''); + case 'currency': + return escapeHtml(row.currency ?? ''); + case 'underlying_symbol': + return escapeHtml(row.underlying_symbol ?? ''); + case 'qty': + return formatNumber(row.qty, 2); + case 'avg_price': + return formatNumber(row.avg_price ?? row.averageCost ?? row.average_cost, 2); + case 'cost_basis': + return formatNumber(row.cost_basis, 2); + case 'market_price': + return formatNumber(row.market_price, 2); + case 'market_value': + return formatNumber(row.market_value, 2); + case 'gain_dollars': + return formatNumber(row.gain_dollars, 2); + case 'gain_percent': + return formatPercent(row.gain_percent); + case 'cagr_percent': + return formatPercent(row.cagr_percent); + case 'days_since_buy': + return formatNumber(row.days_since_buy, 0); + case 'ltcg': + return row.ltcg === 'X' + ? '' + : ''; + case 'buy_time': + return escapeHtml(row.buy_time ?? ''); + default: + return ''; + } +} + +function renderPositions(list) { + const focusState = captureFocusState(); + renderHeader(); + ptbody.innerHTML = ''; + const activeColumns = getActiveColumns(); + for (const rowData of sortPositions(filterPositions(aggregateLots(list)))) { + const row = document.createElement('tr'); + for (const column of activeColumns) { + const cell = document.createElement('td'); + if (column.numeric) cell.className = 'num'; + if (column.key === 'ltcg') cell.className = 'icon-cell'; + cell.innerHTML = renderCell(column.key, rowData); + row.appendChild(cell); + } + ptbody.appendChild(row); + } + restoreFocusState(focusState); +} + +function cycleSort(column, multi) { + const existingIndex = sortState.findIndex((rule) => rule.column === column); + if (!multi) { + if (existingIndex === -1) { + sortState = [{ column, direction: 'asc' }]; + } else { + const current = sortState[existingIndex]; + if (current.direction === 'asc') { + sortState = [{ column, direction: 'desc' }]; + } else { + sortState = []; + } + } + renderPositions(latestPositions); + saveTableState(); + return; + } + + if (existingIndex === -1) { + sortState.push({ column, direction: 'asc' }); + } else { + const current = sortState[existingIndex]; + if (current.direction === 'asc') { + sortState[existingIndex] = { column, direction: 'desc' }; + } else { + sortState.splice(existingIndex, 1); + } + } + renderPositions(latestPositions); + saveTableState(); +} + +function onFilterInput(event) { + const input = event.currentTarget; + const value = input.value.trim(); + if (value) { + filterState[input.dataset.column] = value; + } else { + delete filterState[input.dataset.column]; + } + saveTableState(); + renderPositions(latestPositions); +} + +function onHeaderDragStart(event) { + dragColumnKey = event.currentTarget.dataset.column; + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', dragColumnKey); + event.currentTarget.classList.add('dragging'); +} + +function onHeaderDragOver(event) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; +} + +function onHeaderDrop(event) { + event.preventDefault(); + const targetKey = event.currentTarget.dataset.column; + const sourceKey = dragColumnKey || event.dataTransfer.getData('text/plain'); + if (!sourceKey || sourceKey === targetKey) return; + + const currentOrder = getActiveColumns().map((column) => column.key); + const sourceIndex = currentOrder.indexOf(sourceKey); + const targetIndex = currentOrder.indexOf(targetKey); + if (sourceIndex === -1 || targetIndex === -1) return; + + currentOrder.splice(targetIndex, 0, currentOrder.splice(sourceIndex, 1)[0]); + columnOrder = currentOrder; + saveTableState(); + renderPositions(latestPositions); +} + +function onHeaderDragEnd(event) { + dragColumnKey = null; + event.currentTarget.classList.remove('dragging'); +} + +async function pollPositions() { + if (isRefreshing) return; + isRefreshing = true; + if (refreshButton) refreshButton.disabled = true; + try { + const positions = await fetchJson('/api/lots'); + latestPositions = Array.isArray(positions) ? positions : []; + renderPositions(latestPositions); + } catch (e) { + console.warn('Positions polling error', e); + } finally { + isRefreshing = false; + if (refreshButton) refreshButton.disabled = false; + } +} + +loadTableState(); +if (!columnOrder.length) { + columnOrder = [...defaultColumnOrder]; +} +if (aggregateCheckbox) { + aggregateCheckbox.checked = aggregateSameDay; + aggregateCheckbox.addEventListener('change', () => { + aggregateSameDay = aggregateCheckbox.checked; + saveTableState(); + renderPositions(latestPositions); + }); +} +if (refreshButton) { + refreshButton.addEventListener('click', () => { + pollPositions(); + }); +} + +pollPositions(); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8fd549e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,208 @@ + + + + + + IB Dashboard + + + +

Interactive Brokers Dashboard

+ +
+
+

Open Lots

+
+ ? +
+

Sorting: click a column header to sort ascending, click again for descending, click a third time to remove it. Hold Shift while clicking to add second and third sort levels.

+

Text filters: use wildcards with * and ?. Example: AM* or *2027*.

+

Numeric filters: use values like >100, <=0, 50.

+

Date filters: use either wildcards like 2025-07* or comparisons like >2025-01-01.

+
+
+ + +
+
+ + + + + + + + +
+
+
+ + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..823b594 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +ibapi>=9.80 +flask>=2.0.0 \ No newline at end of file