diff --git a/Pages/About.razor b/Pages/About.razor
index 1f78e7a..5bd43f1 100644
--- a/Pages/About.razor
+++ b/Pages/About.razor
@@ -9,6 +9,7 @@ This site aims to provide useful tools to people interested in optimizing their
It provides tools to help you:
Funds
+@using System.Text.Json.Serialization
+
+
+Portfolio Review@(stepPath==null?"":": "+stepPath.Replace('-',' ')) - bogle.tools
+
+
+
+ @if (new Uri(Navigation.Uri).PathAndQuery == "/portfolio-review/reload")
+ {
+ // reload is a workaround where the correct data seemed to be deleted, but i needed to navigate away and then back to retirement-assets
+ Navigation.NavigateTo("/portfolio-review/retirement-assets");
+ }
+ @if (steps == null || familyData == null)
+ {
+ Loading...
+ }
+ else
+ {
+ @if (stepPath == null) {
+ var prevStep = steps[steps.Length - 1];
+ var nextStep = steps[0];
+ string prevPage = folderName + prevStep?.step;
+ string nextPage = folderName + nextStep?.step;
+ familyData.UpdatePercentages();
+
+
+ Portfolio Review
+
+
+ Bogleheads.org forum enables you to Ask Portfolio Questions āļø. This tool makes it easy to collect your information and format it properly.
+
+
Press edit (āļø) to complete each step:
+
+
+ @foreach (var step in steps) {
+ var href = folderName + @step.step;
+
+
+
+ āļø
+
+
+ @switch (step.step) {
+ case "age":
+
+ @((MarkupString)bold("Age:"))
+ @if(familyData.PersonCount > 0 && familyData.People[0].Age != null) {
+ @familyData.People[0].Age @getPronoun(familyData.People[0])
+ }
+ @if(familyData.PersonCount > 1 && familyData.People[1].Age != null) {
+ and @familyData.People[1].Age @getPronoun(familyData.People[1])
+ }
+
+
+ break;
+ case "emergency-fund":
+
+ @((MarkupString)bold("Emergency funds:"))
+ @if (familyData.EmergencyFund.CurrentMonths != null) {
+ @familyData.EmergencyFund.CurrentMonthsString months
+ } else if (familyData.EmergencyFund.ShowDollars) {
+ @formatMoney(familyData.EmergencyFund.CurrentBalance)
+ }
+
+
+ break;
+ case "debts":
+
+ @((MarkupString)bold("Debt:"))
+ @if (familyData.Debts.Count == 0 && familyData.DebtsComplete) { no debts }
+ else if (familyData.Debts.Count >= 1) {
+
+ @foreach (var debt in familyData.Debts)
+ {
+ @debt.Name: @formatMoney(debt.Total) @@ @(debt.Rate == null ? "unknown" : debt.Rate)%
+ }
+ }
+
+
+ break;
+ case "tax-status":
+
+
+ @((MarkupString)bold("Tax Filing Status:"))
+ @if (familyData.TaxFilingStatus != TaxFilingStatus.ChoiceNeeded) { @familyData.TaxFilingStatus }
+
+ @((MarkupString)bold("Tax Rate:"))
+ @if (familyData.FederalMarginalTaxBracket != null) { @familyData.FederalMarginalTaxBracket Federal, @familyData.StateMarginalTaxBracket% State }
+
+ @((MarkupString)bold("State of Residence:"))
+ @if (familyData.StateMarginalTaxBracket != null) { @familyData.StateOfResidence }
+
+
+
+ break;
+ case "asset-allocation":
+
+ @((MarkupString)bold("Desired Asset allocation:"))
+ @if (familyData.Stocks != null && familyData.Bonds != null) {
+ @familyData.Stocks% stocks / @familyData.Bonds% bonds
+ } else {
+
+ }
+ Desired International allocation:
+ @if (familyData.International != null) {
+ @familyData.International%
+ }
+
+
+ break;
+ case "retirement-assets":
+
+ @((MarkupString)bold("Portfolio Size:")) @(familyData.ValueStyle==0?formatMoneyThousands(familyData.Value):(familyData.ValueStyle==1?estimatePortfolio(familyData):formatMoney(familyData.Value)))
+
+ @if (familyData.Accounts.Count > 0) {
+
+
+
+ Show Portfolio Analysis
+
+
+
+ @if (showPortfolioAnalysis) {
+ @if (!double.IsNaN(familyData.ActualStockAllocation) || !double.IsNaN(familyData.ActualBondAllocation) || !double.IsNaN(familyData.ActualInternationalStockAllocation)) {
+ @((MarkupString)bold("Actual Asset allocation: "))
+ @formatPercent(familyData.ActualStockAllocation*100.0) stocks / @formatPercent(familyData.ActualBondAllocation*100.0) bonds
+ Actual International allocation:
+ @formatPercent(familyData.ActualInternationalStockAllocation*100.0)
+ Actual Cash allocation:
+ @formatPercent(familyData.ActualCashAllocation*100.0)
+ @if(familyData.ActualOtherAllocation > 0.0) {
+ Actual Unclassified allocation:
+ @formatPercent(familyData.ActualOtherAllocation*100.0)
+ }
+
+ }
+
+ @((MarkupString)bold("Weighted Expense Ratio: "))
+ @formatPercent3(familyData.OverallER) or @formatMoney(familyData.ExpensesTotal) per year
+ @if (familyData.InvestmentsMissingER > 0){ (@familyData.InvestmentsMissingER investment(s) missing ER) }
+ }
+
+
+ }
+
+ @((MarkupString)boldUnderline("Current Retirement Assets:"))
+
+ @foreach (var account in familyData.Accounts) {
+
+ @((MarkupString)bold((account.Identifier != null ? ""+account.Identifier+" " : "") + account.AccountType + (account.Custodian != null ? " at " + account.Custodian : "") + " (" + formatPercent(account.Percentage) + ")" ))
+
+ @foreach (var investment in account.Investments) {
+ @formatPercent(investment.Percentage) @investment.Name (@investment.Ticker) (@(investment.ExpenseRatio!=null?investment.ExpenseRatio:"??")%)
+ }
+ }
+
+
+ break;
+ case "available-funds":
+
+ @((MarkupString)boldUnderline("Available funds:"))
+ @foreach (var account in familyData.Accounts) {
+ @if (account.AvailableFunds.Count > 0) {
+
+ @((MarkupString)bold("Funds available in " + account.FullName))
+
+ @foreach (var investment in account.AvailableFunds) {
+ @investment.Name (@investment.Ticker) (@investment.ExpenseRatio%)
+ }
+ }
+ }
+
+
+ break;
+ case "additional-background":
+ @((MarkupString)boldUnderline("Additional Background:"))
+ @familyData.AdditionalBackground
+
+ break;
+ case "questions":
+
+ @((MarkupString)boldUnderline("Questions:"))
+ @for (int i = 0; i < familyData.Questions.Count; i++) {
+ var question = familyData.Questions[i];
+ @(i+1 + ".") @question
+
+ }
+
+ break;
+ case "contributions":
+ runningTotal = 0;
+ pastTotal = false;
+
+
+
+ @((MarkupString)boldUnderline("Contributions:"))
+
+ @if (familyData.PlannedSavings > 0) {
+ Planned savings: @formatMoney(familyData.PlannedSavings)
+ }
+
+ Prioritized Investments:
+
+ @if (familyData.EmergencyFund.AmountToSave > 0) {
+ @formatMoney(familyData.EmergencyFund.AmountToSave) in Emergency Fund
+ @((MarkupString)checkTotal(familyData.EmergencyFund.AmountToSave))
+ }
+ @if (familyData.PersonCount > 0 && familyData.People[0].EmployerPlan.AmountToSaveForMatch > 0) {
+ @formatMoney(familyData.People[0].EmployerPlan.AmountToSaveForMatch) in @familyData.People[0].PossessiveID 401k (Match: @formatMoney(familyData.People[0].EmployerPlan.MatchAmount))
+ @((MarkupString)checkTotal(familyData.People[0].EmployerPlan.AmountToSaveForMatch))
+ }
+ @if (familyData.PersonCount > 1 && familyData.People[1].EmployerPlan.AmountToSaveForMatch > 0) {
+ @formatMoney(familyData.People[1].EmployerPlan.AmountToSaveForMatch) in @familyData.People[1].PossessiveID 401k (Match: @formatMoney(familyData.People[1].EmployerPlan.MatchAmount))
+ @((MarkupString)checkTotal(familyData.People[1].EmployerPlan.AmountToSaveForMatch))
+ }
+ @if (familyData.HighDebts > 0) {
+ @familyData.HighDebts in High Debts
+ @((MarkupString)checkTotal(familyData.HighDebts))
+ }
+ @if (familyData.PersonCount > 0 && (familyData.People[0].EmployerBenefits.Complete || familyData.People[0].EmployerPlan.AnnualSalary == 0 || familyData.People[0].HealthSavingsAccount.EligibleForHSA || familyData.People[0].HealthSavingsAccount.EligibleForHSACatchUpOnly) && familyData.People[0].HealthSavingsAccount.AmountToSave > 0) {
+ @formatMoney(familyData.People[0].HealthSavingsAccount.AmountToSave) in @familyData.People[0].PossessiveID HSA (Employer: @formatMoney(familyData.People[0].HealthSavingsAccount.EmployerContribution))
+ @((MarkupString)checkTotal(familyData.People[0].HealthSavingsAccount.AmountToSave))
+ }
+ @if (familyData.PersonCount > 1 && (familyData.People[1].EmployerBenefits.Complete || familyData.People[1].EmployerPlan.AnnualSalary == 0 || familyData.People[1].HealthSavingsAccount.EligibleForHSA || familyData.People[1].HealthSavingsAccount.EligibleForHSACatchUpOnly) && familyData.People[1].HealthSavingsAccount.AmountToSave > 0) {
+ @formatMoney(familyData.People[1].HealthSavingsAccount.AmountToSave) in @familyData.People[1].PossessiveID HSA (Employer: @formatMoney(familyData.People[1].HealthSavingsAccount.EmployerContribution))
+ @((MarkupString)checkTotal(familyData.People[1].HealthSavingsAccount.AmountToSave))
+ }
+ @if (familyData.AdjustedGrossIncome != null) {
+ if (familyData.PersonCount > 0) {
+ @((MarkupString)GetRecommendedIRAMarkup(familyData.People[0]))
+ @((MarkupString)checkTotal(GetRecommdedIRAAmount(familyData.People[0])))
+ }
+ if(familyData.PersonCount > 1) {
+ @((MarkupString)GetRecommendedIRAMarkup(familyData.People[1]))
+ @((MarkupString)checkTotal(GetRecommdedIRAAmount(familyData.People[1])))
+ }
+ }
+ @if (familyData.PersonCount > 0 && familyData.People[0].EmployerPlan.AmountToSaveForNonMatched > 0) {
+ @formatMoney(familyData.People[0].EmployerPlan.AmountToSaveForNonMatched) in @familyData.People[0].PossessiveID 401k
+ @((MarkupString)checkTotal(familyData.People[0].EmployerPlan.AmountToSaveForNonMatched))
+ }
+ @if (familyData.PersonCount > 1 && familyData.People[1].EmployerPlan.AmountToSaveForNonMatched > 0) {
+ @formatMoney(familyData.People[1].EmployerPlan.AmountToSaveForNonMatched) in @familyData.People[1].PossessiveID 401k
+ @((MarkupString)checkTotal(familyData.People[1].EmployerPlan.AmountToSaveForNonMatched))
+ }
+ @if (familyData.PersonCount > 0 && familyData.People[0].EmployerBenefits.MegaBackdoorRoth.ContributionLimit > 0) {
+ @formatMoney(familyData.People[0].EmployerBenefits.MegaBackdoorRoth.ContributionLimit) in @familyData.People[0].PossessiveID mega backdoor roth
+ @((MarkupString)checkTotal(familyData.People[0].EmployerBenefits.MegaBackdoorRoth.ContributionLimit))
+ }
+ @if (familyData.PersonCount > 1 && familyData.People[1].EmployerBenefits.MegaBackdoorRoth.ContributionLimit > 0) {
+ @formatMoney(familyData.People[1].EmployerBenefits.MegaBackdoorRoth.ContributionLimit) in @familyData.People[1].PossessiveID mega backdoor roth
+ @((MarkupString)checkTotal(familyData.People[1].EmployerBenefits.MegaBackdoorRoth.ContributionLimit))
+ }
+ @if (familyData.MediumDebts > 0) {
+ @formatMoney(familyData.MediumDebts) in Medium Debts
+ @((MarkupString)checkTotal(familyData.MediumDebts))
+ }
+ @if (familyData.PlannedSavings > runningTotal) {
+ @formatMoney(familyData.PlannedSavings-runningTotal) in Taxable
+ @((MarkupString)checkTotal(familyData.PlannedSavings-runningTotal))
+ } else {
+ @formatMoney(0) in Taxable
+ }
+ @if (familyData.LowDebts > 0) {
+ @formatMoney(familyData.LowDebts) in Low Debts
+ @((MarkupString)checkTotal(familyData.LowDebts))
+ }
+
+
+ break;
+ default:
+ NYI
+ break;
+ }
+
+ }
+
+
+
+
Copy the correct format for your post:
+
Use forum post markup: [b]bold[/b]
+
+
Copy to clipboard
+
+
+
+
+
+ }
+ else
+ {
+ @foreach (var step in steps) {
+ @if (step.step == stepPath) {
+ var firstStepIndex = 0;
+ var lastStepIndex = steps.Length-1;
+ var prevStep = step.number - 2 >= firstStepIndex ? steps[step.number - 2] : null;
+ var nextStep = step.number <= lastStepIndex ? steps[step.number] : null;
+ string prevPage = folderName + prevStep?.step;
+ string nextPage = folderName + nextStep?.step;
+
+
+ Portfolio Review > @step.title (@step.number)
+
+
+
+
+
+ @((MarkupString)markupize(step.priority))
+
+ @((MarkupString)markupize(step.summary))
+
+ @switch (step.step) {
+ case "emergency-fund":
+ Worksheet:
+
+
+ Current Emergency Fund: $
+ ā Monthly Expenses: $
+
+ @familyData.EmergencyFund.CurrentMonthsString months
+ @if(familyData.EmergencyFund.CurrentMonths == null && familyData.EmergencyFund.CurrentBalance != null) {
+
+ Only publish amount, since I haven't yet estimated monthly expenses
+ (Months is MORE useful than a dollar amount.)
+ }
+
+ break;
+ case "debts":
+ Worksheet:
+
+
list of debts (all interest rates):
+
+ @if (familyData.DebtFree == TriState.ChoiceNeeded || familyData.DebtFree == TriState.True) {
+
Debt free:
+ @foreach (var option in Enum.GetValues()) {
+ @option
+ }
+
+
+ }
+
+
+
+
+
+ ā Debt
+
+
+ Amounts to Pay-Off (@step.title):
+ High Interest: @formatMoney(familyData.HighDebts)
+ Medium Interest: @formatMoney(familyData.MediumDebts)
+ Low Interest: @formatMoney(familyData.LowDebts)
+ Unknown Interest: @formatMoney(familyData.UnknownDebts)
+
+
+
+
+ @code{
+ void RemoveDebt(MouseEventArgs e, int debtIndex) {
+ familyData.Debts.RemoveAt(debtIndex);
+ }
+ void addDebt()
+ {
+ familyData?.Debts.Add(new Debt());
+ }
+ }
+ break;
+ case "tax-status":
+ Worksheet:
+
+ Tax Filing Status:
+
+ ChoiceNeeded
+ Single
+ Married filing jointly
+ Married filing seperately
+ Married filing seperately (and living apart)
+ Head of Household
+
+
+
+ Target Year: 2022 2023
+
+ @foreach (var taxFiler in familyData.IRSData.TaxRateData.TaxData.TaxFilers)
+ {
+ bool isMatch = false;
+ switch (familyData.TaxFilingStatus.ToString())
+ {
+ case "MarriedFilingSeperatelyAndLivingApart":
+ isMatch = taxFiler.TaxFilingStatus == "MarriedFilingSeperately";
+ break;
+ default:
+ isMatch = taxFiler.TaxFilingStatus == familyData.TaxFilingStatus.ToString();
+ break;
+ }
+ if (isMatch)
+ {
+ string? lastBracket = null;
+ int? lastStartAmount = null;
+
+
+
+ Tax Bracket (@familyData.TaxFilingStatus):
+
+
+ @foreach (var taxBracket in taxFiler.TaxBrackets)
+ {
+ if (lastBracket != null) {
+ var elName = "bracket"+lastBracket.Substring(0,lastBracket.Length-2);
+ @lastBracket @(lastStartAmount==1?"Up":formatMoney(lastStartAmount)) to @formatMoney(taxBracket.StartAmount)
+ }
+ lastBracket = taxBracket.Rate;
+ lastStartAmount = taxBracket.StartAmount + 1;
+ }
+ @lastBracketOver @formatMoney(lastStartAmount-1)
+
+
+ }
+ }
+
+ State of Residence:
+
+ ChoiceNeeded... Alabama (AL) Alaska (AK) American Samoa (AS) Arizona (AZ) Arkansas (AR) California (CA) Colorado (CO) Connecticut (CT) Delaware (DE) District of Columbia (DC) Florida (FL) Georgia (GA) Guam (GU) Hawaii (HI) Idaho (ID) Illinois (IL) Indiana (IN) Iowa (IA) Kansas (KS) Kentucky (KY) Louisiana (LA) Maine (ME) Maryland (MD) Massachusetts (MA) Michigan (MI) Minnesota (MN) Mississippi (MS) Missouri (MO) Montana (MT) Nebraska (NE) Nevada (NV) New Hampshire (NH) New Jersey (NJ) New Mexico (NM) New York (NY) North Carolina (NC) North Dakota (ND) Northern Mariana Islands (CM) Ohio (OH) Oklahoma (OK) Oregon (OR) Pennsylvania (PA) Puerto Rico (PR) Rhode Island (RI) South Carolina (SC) South Dakota (SD) Tennessee (TN) Texas (TX) U.S. Virgin Islands (VI) Utah (UT) Vermont (VT) Virginia (VA) Washington (WA) West Virginia (WV) Wisconsin (WI) Wyoming (WY)
+
+
+
+ State Marginal Tax Rate: %
+
+
+
+ break;
+ case "age":
+ Prerequisities:
+ Tax Filing Status:
+
+ ChoiceNeeded
+ Single
+ Married filing jointly
+ Married filing seperately
+ Married filing seperately (and living apart)
+ Head of Household
+
+
+ Worksheet:
+ @for (int i = 0; i < familyData.PersonCount; i++) {
+ var person = familyData.People[i];
+ int personIndex = i + 1;
+ Person @personIndex:
+
+
+ Age:
+
+ @if(person?.FamilyData?.PersonCount>1){Pronoun/Unique identifier:
+ ChoiceNeeded
+ person @personIndex
+ him
+ her
+ me
+ them
+ }
+
+
+
+ }
+ break;
+ case "asset-allocation":
+ Worksheet:
+
+
+ Desired Asset Allocation:
+ Stock: %
+ Bond: %
+
+ Desired International allocation (% of Stock):
+ International: %
+
+ break;
+ case "available-funds":
+
+ Account
+
+ Choose Account...
+ @for (int i=0;i
+ @account.FullName
+
+ }
+
+
+
+ @if(currentAccountIndex != null && currentAccountIndex != "Choose Account...") {
+ int index = int.Parse(currentAccountIndex);
+ var currentAccount = familyData.Accounts[index];
+ for (int i=0;i < currentAccount.AvailableFunds.Count; i++) {
+ var investment = currentAccount.AvailableFunds[i];
+ int invBuffer = i;
+
+ deleteAvailableFund(e,currentAccount,invBuffer))'>ā
+
+
+
+
+ (
+
+ %)
+
+ }
+
+ addAvailableFund(e,currentAccount))'>ā
+ }
+
+ break;
+ case "additional-background":
+
+
+
+ break;
+ case "questions":
+
+ @for (int i = 0; i < familyData.Questions.Count; i++) {
+ int iBuffer = i;
+ @(i+1 + ". ")
+
+
+
+ }
+
+ ā Question
+
+ @code {
+ void addQuestion() {
+ familyData.Questions.Add("");
+ }
+ }
+
+ break;
+ case "retirement-assets":
+ @if(ImportResult == null) {
+ Prerequisites:
+
+ Tax Filing Status:
+
+ ChoiceNeeded
+ Single
+ Married filing jointly
+ Married filing seperately
+ Married filing seperately (and living apart)
+ Head of Household
+
+
+
+ Current Retirement Assets:
+
+ accountIndex = 0;
+ @foreach (var account in familyData.Accounts) {
+ int accBuffer = accountIndex;
+ accountIndex++;
+ var accountId = "account"+accBuffer;
+
+
+ deleteAccount(e,accBuffer))'>ā
+ @if (familyData.PersonCount == 2) {
+
+ our
+ @for(int p=0;p@familyData.People[p].PossessiveID
+ }
+
+ }
+
+ Account Type...
+ 401k
+ 403b
+ 457b
+ Annuity (Non-Qualified)
+ Annuity (Qualified)
+ HSA
+ Inherited IRA
+ Inherited Roth IRA
+ IRA
+ Rollover IRA
+ Roth 401k
+ Roth IRA
+ SEP IRA
+ SIMPLE IRA
+ Solo 401k
+ Taxable
+ Traditional IRA
+ Treasury Direct
+
+ at
+ Note:
+
+
+ investmentIndex = 0;
+ @foreach (var investment in account.Investments) {
+ int invBuffer = investmentIndex;
+ investmentIndex++;
+
+ deleteInvestment(e,accBuffer,invBuffer))'>ā
+
+ Balance š° $
+
+
+
+ (
+
+ %)
+
+ }
+ addInvestment(e,accBuffer))'>ā
+
+ Account Total: @formatMoney(account.Value)
+ }
+
+
+ Portfolio Size: @formatMoney(familyData.Value)
+
+
+ ā Account to manually enter another Account's info.
+
+
+
+
+ @if(showImport){Hide }
+ Import Accounts
+ to import Account info from a CSV/XLSX file.
+ @if(showError){
ERROR: must be a CSV file from Ameriprise, eTrade, Fidelity, Merrill Edge, Schwab, or Vanguard or a XLSX file from Morgan Stanley. }
+ @if(showImport){
+
+
+ Download portfolio details from custodian website, and then "choose file", to import:
+
+
+ Currently supports importing Ameriprise, eTrade, Fidelity, Merrill Edge, Morgan Stanley, Schwab, or Vanguard data files
+ If your custodian's format isn't supported yet, we're happy to try to add support. If you can share your custodian's downloaded CSV file format (feel free to anonymize/change the data), please email it to suggestions@bogle.tools with details on what custodian it is from.
+ CAUTION: only share personal info (like these files) to apps worthy of your trust!
+
+
+ }
+
+
+
+ @if(familyData.Value > 0) {
+
+ Preferred technique to represent the approximate size of your total portfolio (in portfolio review ):
+
+ @formatMoneyThousands(familyData.Value)
+ @estimatePortfolio(familyData)
+ @formatMoney(familyData.Value)
+
+ }
+ } else {
+
+ Data Files Contains These Accounts/Investments - Choose Which to Import:
+
+ Import selected accounts
+ Cancel Import
+
+
+
+ @foreach (var error in ImportResult.Errors)
+ {
+ Error: @error.Exception.Message
+ @if(error.Exception.InnerException!=null){ Failure location: @error.Exception.InnerException.StackTrace
};
+ }
+
+ UPDATE these EXISTING accounts in your data file:
+ @foreach (var account in ImportResult.UpdatedAccounts)
+ {
+ @account.Custodian @account.Note => @formatMoney(account.Value)
+ @foreach (var investment in account.Investments)
+ {
+ @investment.Name (@investment.Ticker) @(investment.Shares) shares => @formatMoney(investment.Value)
+ }
+ }
+
+
+ ADD these NEW accounts in your data file:
+ @foreach (var account in ImportResult.NewAccounts)
+ {
+ @account.Custodian @account.Note => @formatMoney(account.Value)
+ @foreach (var investment in account.Investments)
+ {
+ @investment.Name (@investment.Ticker) @(investment.Shares) shares => @formatMoney(investment.Value)
+ }
+ }
+
+ }
+ break;
+ case "contributions":
+
+ Edit Savings Contribution at:
/saving
+
+
+ @((MarkupString)boldUnderline("Contributions:"))
+
+ @if (familyData.PlannedSavings > 0) {
+
Planned savings: @formatMoney(familyData.PlannedSavings)
+ }
+
+
Prioritized Investments:
+
+ @if (familyData.EmergencyFund.AmountToSave > 0) {
+
@formatMoney(familyData.EmergencyFund.AmountToSave) in Emergency Fund
+ @((MarkupString)checkTotal(familyData.EmergencyFund.AmountToSave))
+ }
+ @if (familyData.PersonCount > 0 && familyData.People[0].EmployerPlan.AmountToSaveForMatch > 0) {
+
@formatMoney(familyData.People[0].EmployerPlan.AmountToSaveForMatch) in @familyData.People[0].PossessiveID 401k (Match: @formatMoney(familyData.People[0].EmployerPlan.MatchAmount))
+ @((MarkupString)checkTotal(familyData.People[0].EmployerPlan.AmountToSaveForMatch))
+ }
+ @if (familyData.PersonCount > 1 && familyData.People[1].EmployerPlan.AmountToSaveForMatch > 0) {
+
@formatMoney(familyData.People[1].EmployerPlan.AmountToSaveForMatch) in @familyData.People[1].PossessiveID 401k (Match: @formatMoney(familyData.People[1].EmployerPlan.MatchAmount))
+ @((MarkupString)checkTotal(familyData.People[1].EmployerPlan.AmountToSaveForMatch))
+ }
+ @if (familyData.HighDebts > 0) {
+
@familyData.HighDebts in High Debts
+ @((MarkupString)checkTotal(familyData.HighDebts))
+ }
+ @if (familyData.PersonCount > 0 && (familyData.People[0].EmployerBenefits.Complete || familyData.People[0].EmployerPlan.AnnualSalary == 0 || familyData.People[0].HealthSavingsAccount.EligibleForHSA || familyData.People[0].HealthSavingsAccount.EligibleForHSACatchUpOnly) && familyData.People[0].HealthSavingsAccount.AmountToSave > 0) {
+
@formatMoney(familyData.People[0].HealthSavingsAccount.AmountToSave) in @familyData.People[0].PossessiveID HSA (Employer: @formatMoney(familyData.People[0].HealthSavingsAccount.EmployerContribution))
+ @((MarkupString)checkTotal(familyData.People[0].HealthSavingsAccount.AmountToSave))
+ }
+ @if (familyData.PersonCount > 1 && (familyData.People[1].EmployerBenefits.Complete || familyData.People[1].EmployerPlan.AnnualSalary == 0 || familyData.People[1].HealthSavingsAccount.EligibleForHSA || familyData.People[1].HealthSavingsAccount.EligibleForHSACatchUpOnly) && familyData.People[1].HealthSavingsAccount.AmountToSave > 0) {
+
@formatMoney(familyData.People[1].HealthSavingsAccount.AmountToSave) in @familyData.People[1].PossessiveID HSA (Employer: @formatMoney(familyData.People[1].HealthSavingsAccount.EmployerContribution))
+ @((MarkupString)checkTotal(familyData.People[1].HealthSavingsAccount.AmountToSave))
+ }
+ @if (familyData.AdjustedGrossIncome != null) {
+ if (familyData.PersonCount > 0) {
+
@((MarkupString)GetRecommendedIRAMarkup(familyData.People[0]))
+ @((MarkupString)checkTotal(GetRecommdedIRAAmount(familyData.People[0])))
+ }
+ if(familyData.PersonCount > 1) {
+
@((MarkupString)GetRecommendedIRAMarkup(familyData.People[1]))
+ @((MarkupString)checkTotal(GetRecommdedIRAAmount(familyData.People[1])))
+ }
+ }
+ @if (familyData.PersonCount > 0 && familyData.People[0].EmployerPlan.AmountToSaveForNonMatched > 0) {
+
@formatMoney(familyData.People[0].EmployerPlan.AmountToSaveForNonMatched) in @familyData.People[0].PossessiveID 401k
+ @((MarkupString)checkTotal(familyData.People[0].EmployerPlan.AmountToSaveForNonMatched))
+ }
+ @if (familyData.PersonCount > 1 && familyData.People[1].EmployerPlan.AmountToSaveForNonMatched > 0) {
+
@formatMoney(familyData.People[1].EmployerPlan.AmountToSaveForNonMatched) in @familyData.People[1].PossessiveID 401k
+ @((MarkupString)checkTotal(familyData.People[1].EmployerPlan.AmountToSaveForNonMatched))
+ }
+ @if (familyData.PersonCount > 0 && familyData.People[0].EmployerBenefits.MegaBackdoorRoth.ContributionLimit > 0) {
+
@formatMoney(familyData.People[0].EmployerBenefits.MegaBackdoorRoth.ContributionLimit) in @familyData.People[0].PossessiveID mega backdoor roth
+ @((MarkupString)checkTotal(familyData.People[0].EmployerBenefits.MegaBackdoorRoth.ContributionLimit))
+ }
+ @if (familyData.PersonCount > 1 && familyData.People[1].EmployerBenefits.MegaBackdoorRoth.ContributionLimit > 0) {
+
@formatMoney(familyData.People[1].EmployerBenefits.MegaBackdoorRoth.ContributionLimit) in @familyData.People[1].PossessiveID mega backdoor roth
+ @((MarkupString)checkTotal(familyData.People[1].EmployerBenefits.MegaBackdoorRoth.ContributionLimit))
+ }
+ @if (familyData.MediumDebts > 0) {
+
@formatMoney(familyData.MediumDebts) in Medium Debts
+ @((MarkupString)checkTotal(familyData.MediumDebts))
+ }
+ @if (familyData.PlannedSavings > runningTotal) {
+
@formatMoney(familyData.PlannedSavings-runningTotal) in Taxable
+ @((MarkupString)checkTotal(familyData.PlannedSavings-runningTotal))
+ } else {
+
@formatMoney(0) in Taxable
+ }
+ @if (familyData.LowDebts > 0) {
+
@formatMoney(familyData.LowDebts) in Low Debts
+ @((MarkupString)checkTotal(familyData.LowDebts))
+ }
+
+
+ break;
+ }
+
+ @if(step.description != "") {
+ Details: @((MarkupString)markupize(step.description))
+ }
+
+
+
+ }
+ }
+ }
+ }
+
+@code {
+ public enum Mode {
+ normal = 0,
+ href,
+ text,
+ }
+ public bool pastTotal = false;
+ public int runningTotal = 0;
+
+ public string checkTotal(int? itemTotal) {
+ runningTotal += itemTotal ?? 0;
+ if (!pastTotal) {
+ pastTotal = familyData.PlannedSavings <= runningTotal;
+
+ if (pastTotal) {
+ return "-------
Planned savings " + (familyData.PlannedSavings==runningTotal?"met":"exceeded") + " after " + formatMoney(itemTotal+(familyData.PlannedSavings-runningTotal)) + " towards previous step
-------
";
+ }
+ }
+
+ return "";
+ }
+
+ public int? GetRecommdedIRAAmount(Person person) {
+ switch(person.IRATypeRecommendation) {
+ case IRAType.DeductibleIRAThenBackdoorRoth:
+ case IRAType.DeductibleIRA:
+ return person.IRA.AmountToSave;
+ case IRAType.Roth:
+ return person.RothIRA.AmountToSave;
+ case IRAType.NondeductibleIRAThenBackdoorRoth:
+ case IRAType.NondeductibleIRA:
+ return person.IRA.AmountToSave;
+ default:
+ return null;
+ }
+ }
+
+ public string GetRecommendedIRAMarkup(Person person) {
+ switch(person.IRATypeRecommendation) {
+ case IRAType.DeductibleIRAThenBackdoorRoth:
+ return "" + formatMoney(person.IRA.AmountToSave) + " in " + person.PossessiveID + " IRA (" + formatMoney(person.IRA.DeductionAllowed) + " Deductible), then backdoor roth ";
+ case IRAType.DeductibleIRA:
+ return "" + formatMoney(person.IRA.AmountToSave) + " in " + person.PossessiveID + " IRA (" + formatMoney(person.IRA.DeductionAllowed) + " Deductible) ";
+ case IRAType.Roth:
+ return "" + formatMoney(person.RothIRA.AmountToSave) + " in " + person.PossessiveID + " Roth IRA ";
+ case IRAType.NondeductibleIRAThenBackdoorRoth:
+ return "" + formatMoney(person.IRA.AmountToSave) + " (nondeductible), then backdoor roth ";
+ case IRAType.NondeductibleIRA:
+ return "$" + formatMoney(person.IRA.AmountToSave) + " (nondeductible) ";
+ default:
+ return "do nothing
";
+ }
+ }
+
+ public string getPronoun(Person person)
+ {
+ if (person?.FamilyData != null && person.FamilyData.PersonCount > 1 && person.Identifier != null && person.Identifier != "None") {
+ return "(" + person.Identifier + ")";
+ }
+ else
+ {
+ return "";
+ }
+ }
+
+ public string formatMoney(int? amount)
+ {
+ return String.Format("${0:#,0.##}", amount);
+ }
+ public string formatMoney(double? amount)
+ {
+ return String.Format("${0:#,0.##}", amount);
+ }
+ public string formatMoneyThousands(double? amount)
+ {
+ if (amount == null) return "";
+
+ if (amount >= 1000000) {
+ return String.Format("${0:#,0.##M}", Math.Round((double)amount / 10000.0)/100.0);
+ } else if (amount >= 1000) {
+ return String.Format("${0:#,0.##K}", Math.Round((double)amount / 1000.0));
+ } else {
+ return String.Format("${0:#,0.##}", amount);
+ }
+ }
+ public string formatPercent(double? amount)
+ {
+ return String.Format("{0:#,0.#}%", amount);
+ }
+ public string formatPercent3(double? amount)
+ {
+ return String.Format("{0:#,0.###}%", amount);
+ }
+ public string estimatePortfolio(IFamilyData familyData)
+ {
+ if (familyData.Value >= 10000000) {
+ return "8-figures";
+ } else if (familyData.Value >= 6666666) {
+ return "high 7-figures";
+ } else if (familyData.Value >= 3333333) {
+ return "mid 7-figures";
+ } else if (familyData.Value >= 1000000) {
+ return "low 7-figures";
+ } else if (familyData.Value >= 666666) {
+ return "high 6-figures";
+ } else if (familyData.Value >= 333333) {
+ return "mid 6-figures";
+ } else if (familyData.Value >= 100000) {
+ return "low 6-figures";
+ } else if (familyData.Value >= 66666) {
+ return "high 5-figures";
+ } else if (familyData.Value >= 33333) {
+ return "mid 5-figures";
+ } else if (familyData.Value >= 10000) {
+ return "low 5-figures";
+ } else if (familyData.Value >= 1000) {
+ return "4-figures";
+ } else if (familyData.Value == 0) {
+ return "-";
+ } else {
+ return "less than $1,000";
+ }
+ }
+
+ public string bold(string text) {
+ if (ShowMarkup) {
+ return "[b]" + text + " [/b]";
+ } else {
+ return "" + text + " ";
+ }
+ }
+
+ public string boldUnderline(string text) {
+ if (ShowMarkup) {
+ return "[b][u]" + text + " [/u][/b]";
+ } else {
+ return "" + text + " ";
+ }
+ }
+
+ public string markupize(string? wikiText) {
+ if (wikiText == null) return "";
+ string returnVal = "";
+ char lastChar = 'x';
+ var mode = Mode.normal;
+ string href = "";
+ string text = "";
+ foreach (var c in wikiText) {
+ switch (c) {
+ case '[':
+ if (lastChar == '[') {
+ mode = Mode.href;
+ }
+ break;
+ case ']':
+ if (lastChar == ']') {
+ if (text == "") { text = href; }
+ if (href.StartsWith("https://bogle.tools/"))
+ {
+ returnVal += "" + text + " ";
+ }
+ else if (href.StartsWith("https://"))
+ {
+ returnVal += "" + text + " ";
+ }
+ else
+ {
+ returnVal += "" + text + " ";
+ }
+ href = "";
+ text = "";
+ mode = Mode.normal;
+ }
+ break;
+ case '|':
+ if (mode == Mode.href) {
+ mode = Mode.text;
+ }
+ break;
+ }
+
+ switch (mode) {
+ case Mode.href:
+ switch (c) {
+ case '[':
+ case ']':
+ break;
+ default:
+ href += c;
+ break;
+ }
+ break;
+ case Mode.text:
+ switch (c) {
+ case '[':
+ case ']':
+ case '|':
+ break;
+ default:
+ text += c;
+ break;
+ }
+ break;
+ default:
+ switch (c) {
+ case '[':
+ case ']':
+ break;
+ case '\n':
+ returnVal += " ";
+ break;
+ default:
+ returnVal += c;
+ break;
+ }
+ break;
+ }
+
+ lastChar = c;
+ }
+ returnVal += " ";
+
+ return returnVal;
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (focusTicker)
+ {
+ await tickerN.FocusAsync();
+ focusTicker = false;
+ } else if (focusAccount) {
+ await accountN.FocusAsync();
+ focusAccount = false;
+ }
+ }
+
+ [Parameter]
+ public string? stepPath { get; set; }
+
+ [Parameter]
+ public string? TopicValue { get; set; }
+
+ public bool ShowMarkup { get; set; }
+ public string? currentAccountIndex { get; set; }
+ private Step[]? steps;
+ public int accountIndex = 0;
+ public int investmentIndex = 0;
+ private const string folderName = "/portfolio-review/";
+ private bool focusTicker = false;
+ private bool focusAccount = false;
+ private bool showPortfolioAnalysis = true;
+
+ private FamilyData familyData {
+ get {
+ return appData.FamilyData;
+ }
+ set {
+ appData.FamilyData = value;
+ }
+ }
+
+ protected override async Task OnInitializedAsync()
+ {
+ steps = await Http.GetFromJsonAsync("data/portfolio-review-steps.json");
+
+ if (!string.IsNullOrEmpty(TopicValue)) {
+ await LoadPortfolioForTopic(TopicValue);
+ }
+ }
+
+ private ElementReference tickerN;
+ private ElementReference accountN;
+ private bool showImport = false;
+ private bool showError = false;
+
+ private void ShowImport() {
+ showImport = !showImport;
+ if (!showImport) {
+ showError = false;
+ }
+ }
+
+ private async Task UpdatePrice(MouseEventArgs e, Investment investment) {
+ if (!string.IsNullOrEmpty(familyData.EODHistoricalDataApiKey)) {
+ var quoteDataJson = await Http.GetStreamAsync($"https://api.bogle.tools/api/getquotes?ticker={investment.Ticker}&apikey={familyData.EODHistoricalDataApiKey}");
+ var quoteData = await JsonSerializer.DeserializeAsync(quoteDataJson);
+ if (quoteData?.Close != null) {
+ investment.Price = quoteData.Close;
+ investment.Value = investment.Price * investment.Shares;
+ }
+ }
+ }
+
+ private ImportResult? ImportResult = null;
+
+ private async Task OnDataFilesImport(InputFileChangeEventArgs e)
+ {
+ var files = e.GetMultipleFiles();
+ ImportResult = await Importer.ImportDataFiles(files, Funds, familyData.Accounts);
+ foreach (var account in ImportResult.ImportedAccounts)
+ {
+ account.Import = true;
+ }
+
+ Console.WriteLine($"{ImportResult.DataFilesImported} files imported with {ImportResult.ImportedAccounts.Count} accounts");
+ showImport = false;
+
+ Navigation.NavigateTo("/portfolio-review/reload"); // WORKAROUND
+ }
+
+ private void finishImport()
+ {
+ if (ImportResult == null) { return; }
+
+ foreach (var updatedAccount in ImportResult.UpdatedAccounts)
+ {
+ if (updatedAccount.Import && updatedAccount.ReplaceAccount != null)
+ {
+ updatedAccount.ReplaceAccount.Investments.Clear();
+ updatedAccount.ReplaceAccount.Investments.AddRange(updatedAccount.Investments);
+ }
+ }
+
+ foreach (var newAccount in ImportResult.NewAccounts)
+ {
+ if (newAccount.Import)
+ {
+ familyData.Accounts.Add(newAccount);
+ }
+ }
+
+ ImportResult = null;
+ }
+
+ private void cancelImport()
+ {
+ ImportResult = null;
+ }
+
+ private void addInvestment(MouseEventArgs e, int accountIndex)
+ {
+ var newInvestment = new Investment() { funds = Funds };
+ familyData.Accounts[accountIndex].Investments.Add(newInvestment);
+ focusTicker = true;
+ }
+
+ void deleteInvestment(MouseEventArgs e, int accountIndex, int investmentIndex)
+ {
+ familyData.Accounts[accountIndex].Investments.RemoveAt(investmentIndex);
+ Navigation.NavigateTo("/portfolio-review/reload");
+ familyData.UpdatePercentages();
+ }
+ void addAvailableFund(MouseEventArgs e, Account account)
+ {
+ var newFund = new Investment() { funds = Funds };
+ account.AvailableFunds.Add(newFund);
+ focusTicker = true;
+ }
+ void deleteAvailableFund(MouseEventArgs e, Account account, int investmentIndex)
+ {
+ account.AvailableFunds.RemoveAt(investmentIndex);
+ }
+ void addAccount()
+ {
+ var newAccount = new Account();
+ var newInvestment = new Investment() { funds = Funds };
+ newAccount.Investments.Add(newInvestment);
+ familyData?.Accounts.Add(newAccount);
+ focusAccount = true;
+ }
+ void deleteAccount(MouseEventArgs e, int accountIndex)
+ {
+ familyData.Accounts.RemoveAt(accountIndex);
+ Navigation.NavigateTo("/portfolio-review/reload");
+ familyData.UpdatePercentages();
+ }
+
+ void editAccount(MouseEventArgs e, int index)
+ {
+ familyData.Accounts[index].Edit = true;
+ }
+ private async Task LoadPortfolioForTopic(string topicStr) {
+ var options = new JsonSerializerOptions()
+ {
+ Converters =
+ {
+ new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
+ }
+ };
+ var topicJsonUri = "https://raw.githubusercontent.com/bogle-tools/financial-variables/main/data/portfolios/" + topicStr + ".json";
+ var stream = await Http.GetStreamAsync(topicJsonUri);
+ familyData = await FamilyData.LoadFromJsonStream(familyData, stream, options);
+ }
+
+ private async Task CopyTextToClipboard()
+ {
+ var text = await JS.InvokeAsync("getTableInnerText");
+ text = text.Replace("\nāļø\t","");
+ text = text.Replace("āļø\t","");
+ text = text.Replace("Show Portfolio Analysis\n", "");
+ await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
+ }
+}
diff --git a/Pages/Portfolio.razor b/Pages/Portfolio.razor
index 697f73e..1ca0230 100644
--- a/Pages/Portfolio.razor
+++ b/Pages/Portfolio.razor
@@ -67,9 +67,11 @@ Portfolio@(stepPath==null?": review":": "+stepPath.Replace('-',' ')) - bogle.too
- Organizing information about you and your portfolio will help you optimize your finances.
+ Organizing information about your portfolio will help you optimize your finances.
+ Press Save button to save your data. Enable updates to stock/fund prices .
+
-
Press edit (āļø) to complete each step:
+
Press each āļø button to enter info:
@foreach (var step in steps) {
@@ -83,6 +85,9 @@ Portfolio@(stepPath==null?": review":": "+stepPath.Replace('-',' ')) - bogle.too
@switch (step.step) {
case "age":
+ @((MarkupString)bold("Tax Filing Status:"))
+ @if (familyData.TaxFilingStatus != TaxFilingStatus.ChoiceNeeded) { @familyData.TaxFilingStatus }
+
@((MarkupString)bold("Age:"))
@if(familyData.PersonCount > 0 && familyData.People[0].Age != null) {
@familyData.People[0].Age @getPronoun(familyData.People[0])
@@ -93,46 +98,6 @@ Portfolio@(stepPath==null?": review":": "+stepPath.Replace('-',' ')) - bogle.too
break;
- case "emergency-fund":
-
- @((MarkupString)bold("Emergency funds:"))
- @if (familyData.EmergencyFund.CurrentMonths != null) {
- @familyData.EmergencyFund.CurrentMonthsString months
- } else if (familyData.EmergencyFund.ShowDollars) {
- @formatMoney(familyData.EmergencyFund.CurrentBalance)
- }
-
-
- break;
- case "debts":
-
- @((MarkupString)bold("Debt:"))
- @if (familyData.Debts.Count == 0 && familyData.DebtsComplete) { no debts }
- else if (familyData.Debts.Count >= 1) {
-
- @foreach (var debt in familyData.Debts)
- {
- @debt.Name: @formatMoney(debt.Total) @@ @(debt.Rate == null ? "unknown" : debt.Rate)%
- }
- }
-
-
- break;
- case "tax-status":
-
-
- @((MarkupString)bold("Tax Filing Status:"))
- @if (familyData.TaxFilingStatus != TaxFilingStatus.ChoiceNeeded) { @familyData.TaxFilingStatus }
-
- @((MarkupString)bold("Tax Rate:"))
- @if (familyData.FederalMarginalTaxBracket != null) { @familyData.FederalMarginalTaxBracket Federal, @familyData.StateMarginalTaxBracket% State }
-
- @((MarkupString)bold("State of Residence:"))
- @if (familyData.StateMarginalTaxBracket != null) { @familyData.StateOfResidence }
-
-
-
- break;
case "asset-allocation":
@((MarkupString)bold("Desired Asset allocation:"))
@@ -183,133 +148,33 @@ Portfolio@(stepPath==null?": review":": "+stepPath.Replace('-',' ')) - bogle.too
}
- @((MarkupString)boldUnderline("Current Retirement Assets:"))
+ @((MarkupString)boldUnderline("Current Retirement Assets:"))
+ Refresh prices
- @foreach (var account in familyData.Accounts) {
-
- @((MarkupString)bold((account.Identifier != null ? ""+account.Identifier+" " : "") + account.AccountType + (account.Custodian != null ? " at " + account.Custodian : "") + " (" + formatPercent(account.Percentage) + ")" ))
-
- @foreach (var investment in account.Investments) {
- @formatPercent(investment.Percentage) @investment.Name (@investment.Ticker) (@(investment.ExpenseRatio!=null?investment.ExpenseRatio:"??")%)
- }
- }
-
-
- break;
- case "available-funds":
-
- @((MarkupString)boldUnderline("Available funds:"))
- @foreach (var account in familyData.Accounts) {
- @if (account.AvailableFunds.Count > 0) {
-
- @((MarkupString)bold("Funds available in " + account.FullName))
-
- @foreach (var investment in account.AvailableFunds) {
- @investment.Name (@investment.Ticker) (@investment.ExpenseRatio%)
- }
+
+
+ Account
+ Ticker
+ $ / share
+ Shares
+ Value
+
+ @foreach (var account in familyData.Accounts) {
+ @foreach (var investment in account.Investments) {
+
+ @((MarkupString)((account.Identifier != null ? ""+account.Identifier+" " : "") + account.AccountType + (account.Custodian != null ? " at " + account.Custodian : "")))
+ @investment.Ticker
+ @investment.Price
+ @investment.Shares
+ @investment.Value
+
}
}
-
-
- break;
- case "additional-background":
- @((MarkupString)boldUnderline("Additional Background:"))
- @familyData.AdditionalBackground
-
- break;
- case "questions":
-
- @((MarkupString)boldUnderline("Questions:"))
- @for (int i = 0; i < familyData.Questions.Count; i++) {
- var question = familyData.Questions[i];
- @(i+1 + ".") @question
-
- }
-
- break;
- case "contributions":
- runningTotal = 0;
- pastTotal = false;
-
-
-
- @((MarkupString)boldUnderline("Contributions:"))
-
- @if (familyData.PlannedSavings > 0) {
- Planned savings: @formatMoney(familyData.PlannedSavings)
- }
-
- Prioritized Investments:
-
- @if (familyData.EmergencyFund.AmountToSave > 0) {
- @formatMoney(familyData.EmergencyFund.AmountToSave) in Emergency Fund
- @((MarkupString)checkTotal(familyData.EmergencyFund.AmountToSave))
- }
- @if (familyData.PersonCount > 0 && familyData.People[0].EmployerPlan.AmountToSaveForMatch > 0) {
- @formatMoney(familyData.People[0].EmployerPlan.AmountToSaveForMatch) in @familyData.People[0].PossessiveID 401k (Match: @formatMoney(familyData.People[0].EmployerPlan.MatchAmount))
- @((MarkupString)checkTotal(familyData.People[0].EmployerPlan.AmountToSaveForMatch))
- }
- @if (familyData.PersonCount > 1 && familyData.People[1].EmployerPlan.AmountToSaveForMatch > 0) {
- @formatMoney(familyData.People[1].EmployerPlan.AmountToSaveForMatch) in @familyData.People[1].PossessiveID 401k (Match: @formatMoney(familyData.People[1].EmployerPlan.MatchAmount))
- @((MarkupString)checkTotal(familyData.People[1].EmployerPlan.AmountToSaveForMatch))
- }
- @if (familyData.HighDebts > 0) {
- @familyData.HighDebts in High Debts
- @((MarkupString)checkTotal(familyData.HighDebts))
- }
- @if (familyData.PersonCount > 0 && (familyData.People[0].EmployerBenefits.Complete || familyData.People[0].EmployerPlan.AnnualSalary == 0 || familyData.People[0].HealthSavingsAccount.EligibleForHSA || familyData.People[0].HealthSavingsAccount.EligibleForHSACatchUpOnly) && familyData.People[0].HealthSavingsAccount.AmountToSave > 0) {
- @formatMoney(familyData.People[0].HealthSavingsAccount.AmountToSave) in @familyData.People[0].PossessiveID HSA (Employer: @formatMoney(familyData.People[0].HealthSavingsAccount.EmployerContribution))
- @((MarkupString)checkTotal(familyData.People[0].HealthSavingsAccount.AmountToSave))
- }
- @if (familyData.PersonCount > 1 && (familyData.People[1].EmployerBenefits.Complete || familyData.People[1].EmployerPlan.AnnualSalary == 0 || familyData.People[1].HealthSavingsAccount.EligibleForHSA || familyData.People[1].HealthSavingsAccount.EligibleForHSACatchUpOnly) && familyData.People[1].HealthSavingsAccount.AmountToSave > 0) {
- @formatMoney(familyData.People[1].HealthSavingsAccount.AmountToSave) in @familyData.People[1].PossessiveID HSA (Employer: @formatMoney(familyData.People[1].HealthSavingsAccount.EmployerContribution))
- @((MarkupString)checkTotal(familyData.People[1].HealthSavingsAccount.AmountToSave))
- }
- @if (familyData.AdjustedGrossIncome != null) {
- if (familyData.PersonCount > 0) {
- @((MarkupString)GetRecommendedIRAMarkup(familyData.People[0]))
- @((MarkupString)checkTotal(GetRecommdedIRAAmount(familyData.People[0])))
- }
- if(familyData.PersonCount > 1) {
- @((MarkupString)GetRecommendedIRAMarkup(familyData.People[1]))
- @((MarkupString)checkTotal(GetRecommdedIRAAmount(familyData.People[1])))
- }
- }
- @if (familyData.PersonCount > 0 && familyData.People[0].EmployerPlan.AmountToSaveForNonMatched > 0) {
- @formatMoney(familyData.People[0].EmployerPlan.AmountToSaveForNonMatched) in @familyData.People[0].PossessiveID 401k
- @((MarkupString)checkTotal(familyData.People[0].EmployerPlan.AmountToSaveForNonMatched))
- }
- @if (familyData.PersonCount > 1 && familyData.People[1].EmployerPlan.AmountToSaveForNonMatched > 0) {
- @formatMoney(familyData.People[1].EmployerPlan.AmountToSaveForNonMatched) in @familyData.People[1].PossessiveID 401k
- @((MarkupString)checkTotal(familyData.People[1].EmployerPlan.AmountToSaveForNonMatched))
- }
- @if (familyData.PersonCount > 0 && familyData.People[0].EmployerBenefits.MegaBackdoorRoth.ContributionLimit > 0) {
- @formatMoney(familyData.People[0].EmployerBenefits.MegaBackdoorRoth.ContributionLimit) in @familyData.People[0].PossessiveID mega backdoor roth
- @((MarkupString)checkTotal(familyData.People[0].EmployerBenefits.MegaBackdoorRoth.ContributionLimit))
- }
- @if (familyData.PersonCount > 1 && familyData.People[1].EmployerBenefits.MegaBackdoorRoth.ContributionLimit > 0) {
- @formatMoney(familyData.People[1].EmployerBenefits.MegaBackdoorRoth.ContributionLimit) in @familyData.People[1].PossessiveID mega backdoor roth
- @((MarkupString)checkTotal(familyData.People[1].EmployerBenefits.MegaBackdoorRoth.ContributionLimit))
- }
- @if (familyData.MediumDebts > 0) {
- @formatMoney(familyData.MediumDebts) in Medium Debts
- @((MarkupString)checkTotal(familyData.MediumDebts))
- }
- @if (familyData.PlannedSavings > runningTotal) {
- @formatMoney(familyData.PlannedSavings-runningTotal) in Taxable
- @((MarkupString)checkTotal(familyData.PlannedSavings-runningTotal))
- } else {
- @formatMoney(0) in Taxable
- }
- @if (familyData.LowDebts > 0) {
- @formatMoney(familyData.LowDebts) in Low Debts
- @((MarkupString)checkTotal(familyData.LowDebts))
- }
+
- break;
+ break;
default:
- NYI
break;
}
@@ -317,10 +182,7 @@ Portfolio@(stepPath==null?": review":": "+stepPath.Replace('-',' ')) - bogle.too
-
Bogleheads.org forum enables you to Ask Portfolio Questions āļø. This tool makes it easy to copy the correct format for your post:
-
Use forum post markup: [b]bold[/b]
-
-
Copy to clipboard
+
See the Portfolio Review page for help authoring questions to post. Bogleheads.org forum is a great place to Ask Portfolio Questions āļø.
@@ -489,7 +351,7 @@ Portfolio@(stepPath==null?": review":": "+stepPath.Replace('-',' ')) - bogle.too
break;
case "age":
- Prerequisities:
+ Worksheet:
Tax Filing Status:
ChoiceNeeded
@@ -500,7 +362,6 @@ Portfolio@(stepPath==null?": review":": "+stepPath.Replace('-',' ')) - bogle.too
Head of Household
- Worksheet:
@for (int i = 0; i < familyData.PersonCount; i++) {
var person = familyData.People[i];
int personIndex = i + 1;
@@ -600,19 +461,6 @@ Portfolio@(stepPath==null?": review":": "+stepPath.Replace('-',' ')) - bogle.too
break;
case "retirement-assets":
@if(ImportResult == null) {
- Prerequisites:
-
- Tax Filing Status:
-
- ChoiceNeeded
- Single
- Married filing jointly
- Married filing seperately
- Married filing seperately (and living apart)
- Head of Household
-
-
-
Current Retirement Assets:
accountIndex = 0;
@@ -664,19 +512,11 @@ Portfolio@(stepPath==null?": review":": "+stepPath.Replace('-',' ')) - bogle.too
deleteInvestment(e,accBuffer,invBuffer))'>ā
- Balance š° $
-
-
- shares:
- price:
+ $
+ āļø shares
+ š° $
UpdatePrice(e,investment))'>update
-
-
- (
-
- %)
-
}
addInvestment(e,accBuffer))'>ā
@@ -1115,7 +955,7 @@ Portfolio@(stepPath==null?": review":": "+stepPath.Replace('-',' ')) - bogle.too
protected override async Task OnInitializedAsync()
{
- steps = await Http.GetFromJsonAsync("data/portfolio-steps.json");
+ steps = await Http.GetFromJsonAsync("data/portfolio-assets-steps.json");
if (!string.IsNullOrEmpty(TopicValue)) {
await LoadPortfolioForTopic(TopicValue);
@@ -1134,17 +974,39 @@ Portfolio@(stepPath==null?": review":": "+stepPath.Replace('-',' ')) - bogle.too
}
}
- private async Task UpdatePrice(MouseEventArgs e, Investment investment) {
+ private async Task UpdateInvestmentPrice(Investment investment)
+ {
+ var quoteDataJson = await Http.GetStreamAsync($"https://api.bogle.tools/api/getquotes?ticker={investment.Ticker}&apikey={familyData.EODHistoricalDataApiKey}");
+ var quoteData = await JsonSerializer.DeserializeAsync(quoteDataJson);
+ if (quoteData?.Close != null) {
+ investment.Price = quoteData.Close;
+ investment.Value = investment.Price * investment.Shares;
+ }
+ }
+
+ private async Task UpdateInvestmentsPrice(Dictionary> quotes)
+ {
if (!string.IsNullOrEmpty(familyData.EODHistoricalDataApiKey)) {
- var quoteDataJson = await Http.GetStreamAsync($"https://api.bogle.tools/api/getquotes?ticker={investment.Ticker}&apikey={familyData.EODHistoricalDataApiKey}");
- var quoteData = await JsonSerializer.DeserializeAsync(quoteDataJson);
- if (quoteData?.Close != null) {
- investment.Price = quoteData.Close;
- investment.Value = investment.Price * investment.Shares;
+ foreach (var quote in quotes)
+ {
+ var quoteDataJson = await Http.GetStreamAsync($"https://api.bogle.tools/api/getquotes?ticker={quote.Key}&apikey={familyData.EODHistoricalDataApiKey}");
+ var quoteData = await JsonSerializer.DeserializeAsync(quoteDataJson);
+ if (quoteData?.Close != null) {
+ foreach (var investment in quote.Value) {
+ investment.Price = quoteData.Close;
+ investment.Value = investment.Price * investment.Shares;
+ }
+ }
}
}
}
+ private async Task UpdatePrice(MouseEventArgs e, Investment investment) {
+ if (!string.IsNullOrEmpty(familyData.EODHistoricalDataApiKey)) {
+ UpdateInvestmentPrice(investment);
+ }
+ }
+
private ImportResult? ImportResult = null;
private async Task OnDataFilesImport(InputFileChangeEventArgs e)
@@ -1254,4 +1116,27 @@ Portfolio@(stepPath==null?": review":": "+stepPath.Replace('-',' ')) - bogle.too
text = text.Replace("Show Portfolio Analysis\n", "");
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
}
+
+ private async Task refreshPrices() {
+ Dictionary> quotes = new();
+ foreach (var account in familyData.Accounts)
+ {
+ foreach (var investment in account.Investments)
+ {
+ if (investment.AssetType != AssetType.Cash && investment.Ticker != "IBOND" && !string.IsNullOrEmpty(investment.Ticker)) {
+ if (!quotes.ContainsKey(investment.Ticker))
+ {
+ quotes.Add(investment.Ticker, new List () { investment });
+ }
+ else
+ {
+ var investments = quotes[investment.Ticker];
+ investments.Add(investment);
+ }
+ }
+ }
+ }
+
+ UpdateInvestmentsPrice(quotes);
+ }
}
diff --git a/Pages/Updated-quotes.razor b/Pages/Updated-quotes.razor
new file mode 100644
index 0000000..91627c4
--- /dev/null
+++ b/Pages/Updated-quotes.razor
@@ -0,0 +1,30 @@
+ļ»æ@page "/updated-quotes"
+@inject IAppData appData
+
+
+Updated Quotes
+
+Updated Stock/Fund Quotes
+
+The portfolio tool can have prices of stocks/funds updated, but you need a free account at EODHistoricalData.com
+
+Steps:
+
+ Register for free account for updated quotes at EODHistoricalData.com. They will email a free API key. You'll get 20 free quotes a day. (And can buy another 100,000 quotes for $5 from that site, which is 200 quotes per penny.)
+ Enter key into this tool. Then press Save button at top-right of page.
+
+ EODHistoricalData.com free API key:
+
+
+
+
+@code{
+ private FamilyData familyData {
+ get {
+ return appData.FamilyData;
+ }
+ set {
+ appData.FamilyData = value;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shared/Models/FamilyData/Importer.cs b/Shared/Models/FamilyData/Importer.cs
index 3b68833..83ce812 100644
--- a/Shared/Models/FamilyData/Importer.cs
+++ b/Shared/Models/FamilyData/Importer.cs
@@ -175,6 +175,7 @@ public static async Task> ImportCSV(string[] lines, IList fu
string? investmentName;
double value;
+ double? price = null;
double? shares = null;
double? costBasis = null;
@@ -193,6 +194,7 @@ public static async Task> ImportCSV(string[] lines, IList fu
{
investmentName = chunks[3];
value = ParseDouble(chunks[7], allowCurrency:true);
+ price = ParseDouble(chunks[5], allowCurrency:true);
shares = ParseDouble(chunks[4]);
costBasis = ParseDouble(chunks[13], allowCurrency:true);
}
@@ -209,7 +211,7 @@ public static async Task> ImportCSV(string[] lines, IList fu
lastAccountNumber = accountNumber;
}
- Investment newInvestment = new () { funds = funds, Ticker = symbol, Name = investmentName, Value = value, Shares = shares, CostBasis = costBasis };
+ Investment newInvestment = new () { funds = funds, Ticker = symbol, Name = investmentName, Price = price, Value = value, Shares = shares, CostBasis = costBasis };
newAccount?.Investments.Add(newInvestment);
}
@@ -235,6 +237,7 @@ public static async Task> ImportCSV(string[] lines, IList fu
var symbol = chunks[2];
var investmentName = chunks[1];
double shares = ParseDouble(chunks[3]);
+ double price = ParseDouble(chunks[4], allowCurrency:true);
double value = ParseDouble(chunks[5], allowCurrency:true);
// costBasis not available in CSV
@@ -247,7 +250,7 @@ public static async Task> ImportCSV(string[] lines, IList fu
importedAccounts.Add(newAccount);
}
- Investment newInvestment = new () { funds = funds, Ticker = symbol, Name = investmentName, Value = value, Shares = shares };
+ Investment newInvestment = new () { funds = funds, Ticker = symbol, Name = investmentName, Price = price, Value = value, Shares = shares };
newAccount?.Investments.Add(newInvestment);
}
diff --git a/Shared/NavMenu.razor b/Shared/NavMenu.razor
index 7732390..17535bc 100644
--- a/Shared/NavMenu.razor
+++ b/Shared/NavMenu.razor
@@ -18,6 +18,11 @@
š Portfolio
+
+
+ š¤ Portfolio Review
+
+
š° Saving Plan
diff --git a/Shared/TopLine.razor b/Shared/TopLine.razor
index b8d0a6c..f4e77b6 100644
--- a/Shared/TopLine.razor
+++ b/Shared/TopLine.razor
@@ -7,11 +7,8 @@
@inject IJSRuntime JS
-
- key:
- Save
- Clear
-
+ Your data is
kept private :
Save or
+
Erase
@code{
diff --git a/portfolio.json b/portfolio.json
deleted file mode 100644
index a1e4e7b..0000000
--- a/portfolio.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "EmergencyFund": {
- "TargetMonths": 3
- },
- "Debts": [],
- "TaxFilingStatus": "single",
- "Year": 2023,
- "People": [
- {
- "Age": 51,
- "Identifier": "person 1",
- "EmployerPlan": {
- "AnnualSalary": 100000
- },
- "HealthSavingsAccount": {},
- "EmployerBenefits": {
- "Company": "some uni",
- "Employer401k": {
- "Offered": "true",
- "MatchRules": [],
- "MatchLimit": 0
- },
- "HSA": {
- "EmployerContributionLevels": []
- },
- "MegaBackdoorRoth": {}
- }
- },
- {
- "Identifier": "person 2",
- "EmployerPlan": {},
- "HealthSavingsAccount": {},
- "EmployerBenefits": {
- "Employer401k": {
- "MatchRules": []
- },
- "HSA": {
- "EmployerContributionLevels": []
- },
- "MegaBackdoorRoth": {}
- },
- "PersonIndex": 1
- }
- ],
- "Accounts": [],
- "Questions": []
-}
\ No newline at end of file
diff --git a/wwwroot/data/portfolio-assets-steps.json b/wwwroot/data/portfolio-assets-steps.json
new file mode 100644
index 0000000..8af2c7b
--- /dev/null
+++ b/wwwroot/data/portfolio-assets-steps.json
@@ -0,0 +1,29 @@
+[
+ {
+ "step": "age",
+ "title": "Age",
+ "number": 1,
+ "summary": "",
+ "priority": "",
+ "returns": "",
+ "description": ""
+ },
+ {
+ "step": "asset-allocation",
+ "title": "Asset Allocation",
+ "number": 2,
+ "summary": "What is your desired [[Asset allocation|asset allocation]] (ratio of stocks to bonds)?",
+ "priority": "",
+ "returns": "",
+ "description": ""
+ },
+ {
+ "step": "retirement-assets",
+ "title": "Retirement Assets",
+ "number": 3,
+ "summary": "",
+ "priority": "",
+ "returns": "",
+ "description": ""
+ }
+]
diff --git a/wwwroot/data/portfolio-steps.json b/wwwroot/data/portfolio-review-steps.json
similarity index 100%
rename from wwwroot/data/portfolio-steps.json
rename to wwwroot/data/portfolio-review-steps.json