BrianIsaac commited on
Commit
fd8bbd4
·
1 Parent(s): eb98c5c

feat: implement duplicate ticker aggregation and mixed entry handling

Browse files

Adds intelligent aggregation of duplicate ticker entries while preserving
tax lot level data internally. Implements:

- aggregate_holdings() function that groups holdings by ticker and detects
duplicate entries and mixed entry types (shares + dollars)
- Updated update_live_preview() to display aggregated positions and show
warnings for duplicates/mixed entries
- Updated fetch_and_update_preview() to aggregate holdings when calculating
portfolio values with real-time prices
- Validation warnings displayed inline in portfolio preview when:
- Duplicate tickers appear multiple times
- Mixed entry types detected (shares and dollars for same ticker)
- Added portfolio allocation percentages in price summary view

Follows industry best practice of keeping separate tax lots internally
while providing aggregated display to users, with expandable warning
section for data quality transparency.

Files changed (1) hide show
  1. app.py +151 -45
app.py CHANGED
@@ -184,6 +184,59 @@ def parse_portfolio_input(portfolio_text: str) -> List[Dict[str, Any]]:
184
  return holdings
185
 
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  async def run_analysis(
188
  portfolio_text: str,
189
  roast_mode: bool = False,
@@ -753,6 +806,9 @@ async def run_analysis_with_ui_update(
753
  def update_live_preview(portfolio_text: str) -> str:
754
  """Update live preview with parsed portfolio summary (without prices).
755
 
 
 
 
756
  Args:
757
  portfolio_text: Raw portfolio input text
758
 
@@ -775,31 +831,45 @@ def update_live_preview(portfolio_text: str) -> str:
775
  </div>
776
  """
777
 
778
- holdings_count = len(holdings)
779
- shares_count = sum(1 for h in holdings if h.get('quantity') and not h.get('dollar_amount'))
780
- dollar_count = sum(1 for h in holdings if h.get('dollar_amount'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
781
 
782
  html = f"""
783
  <div style='padding: 1.5rem; display: flex; flex-direction: column;'>
784
  <h3 style='color: #048CFC; margin-bottom: 1rem; margin-top: 0; font-size: 1.25rem;'>Portfolio Preview</h3>
785
  <div style='display: grid; gap: 1rem; grid-template-rows: auto 1fr auto; height: 100%;'>
786
  <div style='border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 0.75rem;'>
787
- <p style='margin: 0; font-size: 12px; opacity: 0.7; text-transform: uppercase;'>Holdings</p>
788
- <p style='margin: 0.25rem 0 0 0; font-size: 28px; font-weight: 600;'>{holdings_count}</p>
789
- <p style='margin: 0.5rem 0 0 0; font-size: 11px; opacity: 0.6;'>{shares_count} by shares • {dollar_count} by dollar</p>
790
  </div>
791
  <div style='padding-right: 0.5rem; overflow-y: auto; min-height: 0;'>
792
- <p style='margin: 0 0 0.75rem 0; font-size: 12px; opacity: 0.7; text-transform: uppercase;'>Assets</p>
793
  """
794
 
795
- for holding in holdings:
796
- ticker = holding['ticker']
797
- if holding.get('dollar_amount'):
798
- value_text = f"${holding['dollar_amount']:,.0f}"
799
- elif holding.get('quantity'):
800
- value_text = f"{holding['quantity']:.2f} sh"
801
  else:
802
- value_text = ""
803
 
804
  html += f"""
805
  <div style='display: flex; justify-content: space-between; padding: 0.65rem 0; border-bottom: 1px solid rgba(255,255,255,0.05);'>
@@ -808,11 +878,12 @@ def update_live_preview(portfolio_text: str) -> str:
808
  </div>
809
  """
810
 
811
- html += """
812
  </div>
813
  <div style='margin-top: 0.5rem; padding: 0.75rem; background: rgba(4, 140, 252, 0.1); border-radius: 6px; text-align: center;'>
814
  <p style='margin: 0; font-size: 11px; opacity: 0.8;'>Click "Get Current Prices" for live valuation</p>
815
  </div>
 
816
  </div>
817
  </div>
818
  """
@@ -825,6 +896,9 @@ def update_live_preview(portfolio_text: str) -> str:
825
  async def fetch_and_update_preview(portfolio_text: str) -> str:
826
  """Fetch current prices and update preview with calculated values.
827
 
 
 
 
828
  Args:
829
  portfolio_text: Raw portfolio input text
830
 
@@ -839,8 +913,13 @@ async def fetch_and_update_preview(portfolio_text: str) -> str:
839
  if not holdings:
840
  return update_live_preview(portfolio_text)
841
 
 
 
842
  # Get tickers that need price lookup (have quantity but no dollar_amount)
843
- tickers_needing_prices = [h['ticker'] for h in holdings if h.get('quantity') and not h.get('dollar_amount')]
 
 
 
844
 
845
  # Fetch current prices
846
  prices = {}
@@ -859,48 +938,66 @@ async def fetch_and_update_preview(portfolio_text: str) -> str:
859
  except Exception as e:
860
  logger.error(f"Error fetching prices: {e}")
861
 
862
- # Calculate values
863
  total_value = 0
864
- for holding in holdings:
865
- if holding.get('dollar_amount'):
866
- holding['current_value'] = holding['dollar_amount']
867
- total_value += holding['dollar_amount']
868
- elif holding.get('quantity'):
869
- price = prices.get(holding['ticker'], 0)
870
- holding['current_value'] = holding['quantity'] * price
871
- total_value += holding['current_value']
872
-
873
- # Generate HTML with prices
874
- holdings_count = len(holdings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
875
 
876
  html = f"""
877
  <div style='padding: 1.5rem; display: flex; flex-direction: column;'>
878
  <h3 style='color: #048CFC; margin-bottom: 1rem; margin-top: 0; font-size: 1.25rem;'>Portfolio Summary</h3>
879
  <div style='display: grid; gap: 1rem; grid-template-rows: auto auto 1fr auto; height: 100%;'>
880
  <div style='border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 0.75rem;'>
881
- <p style='margin: 0; font-size: 12px; opacity: 0.7; text-transform: uppercase;'>Holdings</p>
882
- <p style='margin: 0.25rem 0 0 0; font-size: 28px; font-weight: 600;'>{holdings_count}</p>
883
  </div>
884
  <div style='border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 0.75rem;'>
885
  <p style='margin: 0; font-size: 12px; opacity: 0.7; text-transform: uppercase;'>Total Value</p>
886
  <p style='margin: 0.25rem 0 0 0; font-size: 24px; font-weight: 600; color: #10b981;'>${total_value:,.2f}</p>
887
  </div>
888
  <div style='padding-right: 0.5rem; overflow-y: auto; min-height: 0;'>
889
- <p style='margin: 0 0 0.75rem 0; font-size: 12px; opacity: 0.7; text-transform: uppercase;'>Holdings Breakdown</p>
890
  """
891
 
892
- for holding in holdings:
893
- ticker = holding['ticker']
894
- current_value = holding.get('current_value', 0)
 
895
 
896
- if holding.get('dollar_amount'):
897
- detail = f"${holding['dollar_amount']:,.2f}"
898
- elif holding.get('quantity'):
 
 
899
  price = prices.get(ticker, 0)
900
  if price > 0:
901
- detail = f"{holding['quantity']:.2f} sh × ${price:.2f}"
902
  else:
903
- detail = f"{holding['quantity']:.2f} sh (price unavailable)"
904
  else:
905
  detail = "—"
906
 
@@ -910,15 +1007,19 @@ async def fetch_and_update_preview(portfolio_text: str) -> str:
910
  <div style='font-weight: 500; font-size: 0.95rem;'>{ticker}</div>
911
  <div style='font-size: 11px; opacity: 0.6; margin-top: 2px;'>{detail}</div>
912
  </div>
913
- <span style='font-weight: 600; color: #10b981; font-size: 0.95rem;'>${current_value:,.2f}</span>
 
 
 
914
  </div>
915
  """
916
 
917
- html += """
918
  </div>
919
  <div style='margin-top: 0.5rem; padding: 0.75rem; background: rgba(16, 185, 129, 0.1); border-radius: 6px; text-align: center;'>
920
- <p style='margin: 0; font-size: 11px; color: #10b981;'>✓ Prices updated</p>
921
  </div>
 
922
  </div>
923
  </div>
924
  """
@@ -1085,13 +1186,14 @@ def create_interface() -> gr.Blocks:
1085
  border: 1px solid rgba(255, 255, 255, 0.2);
1086
  border-radius: 12px;
1087
  padding: 0;
1088
- flex: 0 0 auto !important;
1089
  max-height: 800px !important;
1090
  min-height: 0;
1091
  overflow-y: auto;
1092
  overflow-x: hidden;
1093
  display: flex !important;
1094
  flex-direction: column !important;
 
1095
  }
1096
 
1097
  /* Custom scrollbar for preview card */
@@ -1158,6 +1260,9 @@ def create_interface() -> gr.Blocks:
1158
  flex: 1 1 0% !important;
1159
  height: 100% !important;
1160
  min-height: 100% !important;
 
 
 
1161
  }
1162
 
1163
  .portfolio-row > * {
@@ -1172,9 +1277,10 @@ def create_interface() -> gr.Blocks:
1172
 
1173
  /* Button fixed sizing - prevent vertical expansion */
1174
  .portfolio-row button {
1175
- flex: 0 0 auto !important;
1176
- align-self: stretch !important;
1177
  width: 100% !important;
 
 
1178
  max-height: 45px !important;
1179
  }
1180
 
 
184
  return holdings
185
 
186
 
187
+ def aggregate_holdings(holdings: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
188
+ """Aggregate holdings by ticker while preserving lot-level data.
189
+
190
+ Groups multiple entries for the same ticker together and tracks
191
+ individual tax lots. Useful for handling duplicate entries and
192
+ mixed entry types (shares + dollars).
193
+
194
+ Args:
195
+ holdings: List of holding dictionaries from parse_portfolio_input
196
+
197
+ Returns:
198
+ Dictionary with ticker as key and aggregated data as value,
199
+ containing: ticker, total_quantity, total_dollar_amount, lots, warnings
200
+ """
201
+ aggregated = {}
202
+
203
+ for holding in holdings:
204
+ ticker = holding['ticker']
205
+
206
+ if ticker not in aggregated:
207
+ aggregated[ticker] = {
208
+ 'ticker': ticker,
209
+ 'total_quantity': 0,
210
+ 'total_dollar_amount': 0,
211
+ 'lots': [],
212
+ 'warnings': []
213
+ }
214
+
215
+ aggregated[ticker]['total_quantity'] += holding.get('quantity', 0)
216
+ aggregated[ticker]['total_dollar_amount'] += holding.get('dollar_amount', 0)
217
+ aggregated[ticker]['lots'].append(holding)
218
+
219
+ # Check for duplicates and mixed entry types
220
+ for ticker, data in aggregated.items():
221
+ if len(data['lots']) > 1:
222
+ has_shares = any(lot['quantity'] > 0 for lot in data['lots'])
223
+ has_dollars = any(lot['dollar_amount'] > 0 for lot in data['lots'])
224
+
225
+ if has_shares and has_dollars:
226
+ data['warnings'].append(
227
+ f"{ticker} has mixed entry types (shares and dollars). "
228
+ f"Total: {data['total_quantity']:.2f} sh + ${data['total_dollar_amount']:,.2f}"
229
+ )
230
+ else:
231
+ data['warnings'].append(
232
+ f"{ticker} appears {len(data['lots'])} times. "
233
+ f"Aggregated: {data['total_quantity']:.2f} shares" if has_shares
234
+ else f"Aggregated: ${data['total_dollar_amount']:,.2f}"
235
+ )
236
+
237
+ return aggregated
238
+
239
+
240
  async def run_analysis(
241
  portfolio_text: str,
242
  roast_mode: bool = False,
 
806
  def update_live_preview(portfolio_text: str) -> str:
807
  """Update live preview with parsed portfolio summary (without prices).
808
 
809
+ Aggregates duplicate tickers and detects mixed entry types,
810
+ displaying warnings for data quality issues.
811
+
812
  Args:
813
  portfolio_text: Raw portfolio input text
814
 
 
831
  </div>
832
  """
833
 
834
+ aggregated = aggregate_holdings(holdings)
835
+ unique_tickers = len(aggregated)
836
+ shares_tickers = sum(1 for d in aggregated.values() if d['total_quantity'] > 0)
837
+ dollar_tickers = sum(1 for d in aggregated.values() if d['total_dollar_amount'] > 0)
838
+
839
+ warnings = []
840
+ for data in aggregated.values():
841
+ warnings.extend(data['warnings'])
842
+
843
+ warnings_html = ""
844
+ if warnings:
845
+ warnings_html = f"""
846
+ <div style='margin-top: 0.75rem; padding: 0.75rem; background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.3); border-radius: 6px;'>
847
+ <p style='margin: 0 0 0.5rem 0; font-size: 11px; text-transform: uppercase; color: #fb923c; font-weight: 600;'>Duplicate/Mixed Entries</p>
848
+ """
849
+ for warning in warnings:
850
+ warnings_html += f"<p style='margin: 0.25rem 0 0 0; font-size: 10px; opacity: 0.9;'>{warning}</p>"
851
+ warnings_html += "</div>"
852
 
853
  html = f"""
854
  <div style='padding: 1.5rem; display: flex; flex-direction: column;'>
855
  <h3 style='color: #048CFC; margin-bottom: 1rem; margin-top: 0; font-size: 1.25rem;'>Portfolio Preview</h3>
856
  <div style='display: grid; gap: 1rem; grid-template-rows: auto 1fr auto; height: 100%;'>
857
  <div style='border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 0.75rem;'>
858
+ <p style='margin: 0; font-size: 12px; opacity: 0.7; text-transform: uppercase;'>Unique Tickers</p>
859
+ <p style='margin: 0.25rem 0 0 0; font-size: 28px; font-weight: 600;'>{unique_tickers}</p>
860
+ <p style='margin: 0.5rem 0 0 0; font-size: 11px; opacity: 0.6;'>{shares_tickers} by shares • {dollar_tickers} by dollar</p>
861
  </div>
862
  <div style='padding-right: 0.5rem; overflow-y: auto; min-height: 0;'>
863
+ <p style='margin: 0 0 0.75rem 0; font-size: 12px; opacity: 0.7; text-transform: uppercase;'>Assets (Aggregated)</p>
864
  """
865
 
866
+ for ticker, data in sorted(aggregated.items()):
867
+ if data['total_dollar_amount'] > 0 and data['total_quantity'] > 0:
868
+ value_text = f"{data['total_quantity']:.2f} sh + ${data['total_dollar_amount']:,.0f}"
869
+ elif data['total_dollar_amount'] > 0:
870
+ value_text = f"${data['total_dollar_amount']:,.0f}"
 
871
  else:
872
+ value_text = f"{data['total_quantity']:.2f} sh"
873
 
874
  html += f"""
875
  <div style='display: flex; justify-content: space-between; padding: 0.65rem 0; border-bottom: 1px solid rgba(255,255,255,0.05);'>
 
878
  </div>
879
  """
880
 
881
+ html += f"""
882
  </div>
883
  <div style='margin-top: 0.5rem; padding: 0.75rem; background: rgba(4, 140, 252, 0.1); border-radius: 6px; text-align: center;'>
884
  <p style='margin: 0; font-size: 11px; opacity: 0.8;'>Click "Get Current Prices" for live valuation</p>
885
  </div>
886
+ {warnings_html}
887
  </div>
888
  </div>
889
  """
 
896
  async def fetch_and_update_preview(portfolio_text: str) -> str:
897
  """Fetch current prices and update preview with calculated values.
898
 
899
+ Aggregates duplicate tickers and displays combined positions
900
+ with pricing information from market data sources.
901
+
902
  Args:
903
  portfolio_text: Raw portfolio input text
904
 
 
913
  if not holdings:
914
  return update_live_preview(portfolio_text)
915
 
916
+ aggregated = aggregate_holdings(holdings)
917
+
918
  # Get tickers that need price lookup (have quantity but no dollar_amount)
919
+ tickers_needing_prices = [
920
+ ticker for ticker, data in aggregated.items()
921
+ if data['total_quantity'] > 0 and data['total_dollar_amount'] == 0
922
+ ]
923
 
924
  # Fetch current prices
925
  prices = {}
 
938
  except Exception as e:
939
  logger.error(f"Error fetching prices: {e}")
940
 
941
+ # Calculate aggregated values
942
  total_value = 0
943
+ aggregated_values = {}
944
+
945
+ for ticker, data in aggregated.items():
946
+ if data['total_dollar_amount'] > 0:
947
+ aggregated_values[ticker] = data['total_dollar_amount']
948
+ total_value += data['total_dollar_amount']
949
+ elif data['total_quantity'] > 0:
950
+ price = prices.get(ticker, 0)
951
+ value = data['total_quantity'] * price
952
+ aggregated_values[ticker] = value
953
+ total_value += value
954
+
955
+ unique_tickers = len(aggregated)
956
+ warnings = []
957
+ for data in aggregated.values():
958
+ warnings.extend(data['warnings'])
959
+
960
+ warnings_html = ""
961
+ if warnings:
962
+ warnings_html = f"""
963
+ <div style='margin-top: 0.75rem; padding: 0.75rem; background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.3); border-radius: 6px;'>
964
+ <p style='margin: 0 0 0.5rem 0; font-size: 11px; text-transform: uppercase; color: #fb923c; font-weight: 600;'>Duplicate/Mixed Entries</p>
965
+ """
966
+ for warning in warnings:
967
+ warnings_html += f"<p style='margin: 0.25rem 0 0 0; font-size: 10px; opacity: 0.9;'>{warning}</p>"
968
+ warnings_html += "</div>"
969
 
970
  html = f"""
971
  <div style='padding: 1.5rem; display: flex; flex-direction: column;'>
972
  <h3 style='color: #048CFC; margin-bottom: 1rem; margin-top: 0; font-size: 1.25rem;'>Portfolio Summary</h3>
973
  <div style='display: grid; gap: 1rem; grid-template-rows: auto auto 1fr auto; height: 100%;'>
974
  <div style='border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 0.75rem;'>
975
+ <p style='margin: 0; font-size: 12px; opacity: 0.7; text-transform: uppercase;'>Unique Tickers</p>
976
+ <p style='margin: 0.25rem 0 0 0; font-size: 28px; font-weight: 600;'>{unique_tickers}</p>
977
  </div>
978
  <div style='border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 0.75rem;'>
979
  <p style='margin: 0; font-size: 12px; opacity: 0.7; text-transform: uppercase;'>Total Value</p>
980
  <p style='margin: 0.25rem 0 0 0; font-size: 24px; font-weight: 600; color: #10b981;'>${total_value:,.2f}</p>
981
  </div>
982
  <div style='padding-right: 0.5rem; overflow-y: auto; min-height: 0;'>
983
+ <p style='margin: 0 0 0.75rem 0; font-size: 12px; opacity: 0.7; text-transform: uppercase;'>Holdings Breakdown (Aggregated)</p>
984
  """
985
 
986
+ for ticker in sorted(aggregated.keys()):
987
+ data = aggregated[ticker]
988
+ current_value = aggregated_values.get(ticker, 0)
989
+ weight = (current_value / total_value * 100) if total_value > 0 else 0
990
 
991
+ if data['total_dollar_amount'] > 0 and data['total_quantity'] > 0:
992
+ detail = f"{data['total_quantity']:.2f} sh + ${data['total_dollar_amount']:,.2f}"
993
+ elif data['total_dollar_amount'] > 0:
994
+ detail = f"${data['total_dollar_amount']:,.2f}"
995
+ elif data['total_quantity'] > 0:
996
  price = prices.get(ticker, 0)
997
  if price > 0:
998
+ detail = f"{data['total_quantity']:.2f} sh × ${price:.2f}"
999
  else:
1000
+ detail = f"{data['total_quantity']:.2f} sh (price unavailable)"
1001
  else:
1002
  detail = "—"
1003
 
 
1007
  <div style='font-weight: 500; font-size: 0.95rem;'>{ticker}</div>
1008
  <div style='font-size: 11px; opacity: 0.6; margin-top: 2px;'>{detail}</div>
1009
  </div>
1010
+ <div style='text-align: right;'>
1011
+ <div style='font-weight: 600; color: #10b981; font-size: 0.95rem;'>${current_value:,.2f}</div>
1012
+ <div style='font-size: 11px; opacity: 0.6; margin-top: 2px;'>{weight:.1f}%</div>
1013
+ </div>
1014
  </div>
1015
  """
1016
 
1017
+ html += f"""
1018
  </div>
1019
  <div style='margin-top: 0.5rem; padding: 0.75rem; background: rgba(16, 185, 129, 0.1); border-radius: 6px; text-align: center;'>
1020
+ <p style='margin: 0; font-size: 11px; color: #10b981;'>Prices updated</p>
1021
  </div>
1022
+ {warnings_html}
1023
  </div>
1024
  </div>
1025
  """
 
1186
  border: 1px solid rgba(255, 255, 255, 0.2);
1187
  border-radius: 12px;
1188
  padding: 0;
1189
+ flex: 1 1 auto !important;
1190
  max-height: 800px !important;
1191
  min-height: 0;
1192
  overflow-y: auto;
1193
  overflow-x: hidden;
1194
  display: flex !important;
1195
  flex-direction: column !important;
1196
+ box-sizing: border-box !important;
1197
  }
1198
 
1199
  /* Custom scrollbar for preview card */
 
1260
  flex: 1 1 0% !important;
1261
  height: 100% !important;
1262
  min-height: 100% !important;
1263
+ max-height: 100% !important;
1264
+ gap: 0 !important;
1265
+ overflow: hidden !important;
1266
  }
1267
 
1268
  .portfolio-row > * {
 
1277
 
1278
  /* Button fixed sizing - prevent vertical expansion */
1279
  .portfolio-row button {
1280
+ flex: 0 0 45px !important;
 
1281
  width: 100% !important;
1282
+ height: 45px !important;
1283
+ min-height: 45px !important;
1284
  max-height: 45px !important;
1285
  }
1286