Spaces:
Running
on
Zero
feat: implement duplicate ticker aggregation and mixed entry handling
Browse filesAdds 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.
|
@@ -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 |
-
|
| 779 |
-
|
| 780 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;'>
|
| 788 |
-
<p style='margin: 0.25rem 0 0 0; font-size: 28px; font-weight: 600;'>{
|
| 789 |
-
<p style='margin: 0.5rem 0 0 0; font-size: 11px; opacity: 0.6;'>{
|
| 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
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 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 = [
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;'>
|
| 882 |
-
<p style='margin: 0.25rem 0 0 0; font-size: 28px; font-weight: 600;'>{
|
| 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
|
| 893 |
-
|
| 894 |
-
current_value =
|
|
|
|
| 895 |
|
| 896 |
-
if
|
| 897 |
-
detail = f"${
|
| 898 |
-
elif
|
|
|
|
|
|
|
| 899 |
price = prices.get(ticker, 0)
|
| 900 |
if price > 0:
|
| 901 |
-
detail = f"{
|
| 902 |
else:
|
| 903 |
-
detail = f"{
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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;'
|
| 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:
|
| 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
|
| 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 |
|