Skip to content

Instantly share code, notes, and snippets.

@branw
Last active April 17, 2024 20:43
Show Gist options
  • Save branw/22244fa900cc38bb58d4399ba3d8f744 to your computer and use it in GitHub Desktop.
Save branw/22244fa900cc38bb58d4399ba3d8f744 to your computer and use it in GitHub Desktop.
Semi-automated portfolio contribution rebalancer for Fidelity Investments (last updated April 2024)

robofidelity

A bookmarklet for semi-automating ETF and Mutual Fund purchases through Fidelity.

  • Distributes your contribution across all positions to best achieve your desired portfolio
    • This is in contrast to traditional rebalancing, which involves selling shares to achieve a balanced portfolio
  • Assumes that cash-to-invest has already been transfered into the core positions of relevant accounts
  • Ignores non-brokerage and non-Fidelity accounts

Usage

WARNING! I do not recommend using this script unless you understand everything that it is doing. You should audit the entire script (<400 LOC) and create your own bookmarklet link before running anything.

This script intentionally requires manual actions so that you can verify every step of the process. Regardless, you must use this script at your own risk. I am not responsible if this script divests your entire portfolio into TSLA options or if it suddenly wire transfers your entire balance to an overseas bank account.

Setup

Approach 1. Embed Script Fully into Bookmarklet (Recommended)

  1. Copy the script below into a text editor
  2. Update getDesiredDistributionForPositions to return your desired portfolios. My settings are to use a consistent portfolio with support for both ETF and Mutual Fund accounts.
  3. Paste the script into the "Code" section of https://caiorss.github.io/bookmarklet-maker/
  4. Create a bookmark using the URL from the "Output" field that starts with javascript:

Approach 2. Embed Script from Gist

  1. Create a fork of this Gist
  2. Update getDesiredDistributionForPositions to return your desired portfolios. My settings are to use a consistent portfolio with support for both ETF and Mutual Fund accounts.
  3. Create a bookmark with the following URL, substituting <<GIST_ID_GOES_HERE>> with the forked Gist ID (e.g. 22244fa900cc38bb58d4399ba3d8f744) and <<GIST_USERNAME_GOES_HERE>> with your GitHub username (e.g. branw):
javascript:var%20id%3D%22<<GIST_ID_GOES_HERE>>%22%2Cfile%3D%22robofidelity.js%22%2Cuser%3D%22<<GIST_USERNAME_GOES_HERE>>%22%2Cxhr%3Dnew%20XMLHttpRequest%3Bxhr.overrideMimeType(%22application%2Fjson%22)%3Bxhr.open(%22GET%22%2C%22https%3A%2F%2Fgist.githubusercontent.com%2F%22%2Buser%2B%22%2F%22%2Bid%2B%22%2Fraw%2F%22%2Bfile%2B%22%3F%22%2BMath.random())%3Bxhr.onreadystatechange%3Dfunction()%7Bif(4%3D%3D%3Dxhr.readyState)if(200%3D%3D%3Dxhr.status)console.log(%22Successfully%20loaded%20gist%3A%22%2C%7Bid%3Aid%2Cfile%3Afile%2Cuser%3Auser%2Cresponse%3Axhr.responseText%7D)%2C(0%2Ceval)(xhr.responseText)%3Belse%7Bvar%20a%3D%22GitHub%20Gist%20file%20did%20not%20load%20successfully%20and%20instead%20returned%20a%20status%20code%20of%20%22%2Bxhr.status%2B%22.%22%3Bconsole.error(a%2C%7Bid%3Aid%2Cfile%3Afile%2Cuser%3Auser%7D)%3Balert(a)%7D%7D%3Bxhr.send(null)%3Bvoid+0

For example, to run this Gist in particular:

javascript:var%20id%3D%2222244fa900cc38bb58d4399ba3d8f744%22%2Cfile%3D%22robofidelity.js%22%2Cuser%3D%22branw%22%2Cxhr%3Dnew%20XMLHttpRequest%3Bxhr.overrideMimeType(%22application%2Fjson%22)%3Bxhr.open(%22GET%22%2C%22https%3A%2F%2Fgist.githubusercontent.com%2F%22%2Buser%2B%22%2F%22%2Bid%2B%22%2Fraw%2F%22%2Bfile%2B%22%3F%22%2BMath.random())%3Bxhr.onreadystatechange%3Dfunction()%7Bif(4%3D%3D%3Dxhr.readyState)if(200%3D%3D%3Dxhr.status)console.log(%22Successfully%20loaded%20gist%3A%22%2C%7Bid%3Aid%2Cfile%3Afile%2Cuser%3Auser%2Cresponse%3Axhr.responseText%7D)%2C(0%2Ceval)(xhr.responseText)%3Belse%7Bvar%20a%3D%22GitHub%20Gist%20file%20did%20not%20load%20successfully%20and%20instead%20returned%20a%20status%20code%20of%20%22%2Bxhr.status%2B%22.%22%3Bconsole.error(a%2C%7Bid%3Aid%2Cfile%3Afile%2Cuser%3Auser%7D)%3Balert(a)%7D%7D%3Bxhr.send(null)%3Bvoid+0

which beautifies to:

var id = "22244fa900cc38bb58d4399ba3d8f744",
    file = "robofidelity.js",
    user = "branw",
    xhr = new XMLHttpRequest;
xhr.overrideMimeType("application/json");
xhr.open("GET", "https://gist.githubusercontent.com/" + user + "/" + id + "/raw/" + file + "?" + Math.random());
xhr.onreadystatechange = function() {
    if (4 === xhr.readyState)
        if (200 === xhr.status) console.log("Successfully loaded gist:", {
            id: id,
            file: file,
            user: user,
            response: xhr.responseText
        }), (0, eval)(xhr.responseText);
        else {
            var a = "GitHub Gist file did not load successfully and instead returned a status code of " + xhr.status + ".";
            console.error(a, {
                id: id,
                file: file,
                user: user
            });
            alert(a)
        }
};
xhr.send(null);
void 0

Regular Usage

  1. Go to your portfolio summary: https://digital.fidelity.com/ftgw/digital/portfolio/summary (you can click the bookmark to get here from any other page)
  2. Click the bookmark to see a list of all trading accounts and their cash balances
  3. Navigate to the positions of an account you would like to trade on (either through the Fidelity UI, or by clicking links in the bookmarklet's UI)
  4. Click one of the "Invest ... in ..." buttons and wait for the auto-fill to complete
  5. Click "Preview Order" and manually place the order
  6. Repeat steps 5 and 6 for the other positions in the account
  7. Move to the next account and repeat steps 4-7

Check browser console for errors if something is not working as expected.

License

This is free and unencumbered software released into the public domain.

Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.

In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

For more information, please refer to <http://unlicense.org/>
(async () => {
const GET_CONTEXT_QUERY = "query GetContext {\\n getContext {\\n sysStatus {\\n balance\\n backend {\\n account\\n feature\\n __typename\\n }\\n account {\\n Brokerage\\n StockPlans\\n ExternalLinked\\n ExternalManual\\n WorkplaceContributions\\n WorkplaceBenefits\\n Annuity\\n FidelityCreditCards\\n Charitable\\n BrokerageLending\\n InternalDigital\\n ExternalDigital\\n __typename\\n }\\n __typename\\n }\\n person {\\n id\\n sysMsgs {\\n message\\n source\\n code\\n type\\n __typename\\n }\\n relationships {\\n type\\n subType\\n __typename\\n }\\n balances {\\n hasIntradayPricing\\n balanceDetail {\\n gainLossBalanceDetail {\\n totalMarketVal\\n todaysGainLoss\\n todaysGainLossPct\\n fidelityTotalMktVal\\n hasUnpricedPositions\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n assets {\\n acctNum\\n acctType\\n acctSubType\\n acctSubTypeDesc\\n acctCreationDate\\n parentBrokAcctNum\\n linkedAcctDetails {\\n acctNum\\n isLinked\\n __typename\\n }\\n brokerageLendingAcctDetail {\\n institutionName\\n creditLineAmount\\n lineAvailablityAmount\\n endInterestRate\\n baseInterestRate\\n spreadToBaseRate\\n baseIndexName\\n nextPaymentDueDate\\n lastPaymentDate\\n paymentAmountDue\\n loanStatus\\n pledgedAccountNumber\\n fullLoanId\\n __typename\\n }\\n acctStateDetail {\\n statusCode\\n __typename\\n }\\n preferenceDetail {\\n name\\n isHidden\\n isDefaultAcct\\n acctGroupId\\n __typename\\n }\\n gainLossBalanceDetail {\\n totalMarketVal\\n todaysGainLoss\\n todaysGainLossPct\\n asOfDateTime\\n hasUnpricedPositions\\n hasIntradayPricing\\n __typename\\n }\\n acctRelAttrDetail {\\n relCategoryCode\\n relRoleTypeCode\\n __typename\\n }\\n acctLegacyAttrDetail {\\n legacyHouseHoldCostBasisCode\\n __typename\\n }\\n annuityProductDetail {\\n systemOfRecord\\n planTypeCode\\n planCode\\n productCode\\n productDesc\\n __typename\\n }\\n workplacePlanDetail {\\n planInTransitionInd\\n planTypeName\\n planTypeCode\\n planId\\n clientId\\n clientTickerSymbol\\n enrollmentStatusCode\\n isCrossoverEnabled\\n isEnrollmentEligible\\n nonQualifiedInd\\n isRollup\\n planName\\n navigationKey\\n url\\n __typename\\n }\\n acctTypesIndDetail {\\n isRetirement\\n isYouthAcct\\n hasSPSPlans\\n __typename\\n }\\n acctAttrDetail {\\n regTypeDesc\\n costBasisCode\\n addlBrokAcctCode\\n taxTreatmentCode\\n coreSymbolCode\\n __typename\\n }\\n acctIndDetail {\\n isAdvisorAcct\\n isAuthorizedAcct\\n isMultiCurrencyAllowed\\n isGuidedPortfolioSummEnabled\\n isFFOSAcct\\n isPrimaryCustomer\\n __typename\\n }\\n acctTrustIndDetail {\\n isAdvisorTrustTLAAcct\\n isTrustAcct\\n __typename\\n }\\n acctLegalAttrDetail {\\n accountTypeCode\\n legalConstructCode\\n legalConstructModifierCode\\n offeringCode\\n serviceSegmentCode\\n lineOfBusinessCode\\n __typename\\n }\\n acctTradeAttrDetail {\\n optionAgrmntCode\\n optionLevelCode\\n borrowFullyPaidCode\\n portfolioMarginCode\\n isTradable\\n mrgnAgrmntCode\\n isSpecificShrTradingEligible\\n isSpreadsAllowed\\n limitedMrgnCode\\n __typename\\n }\\n annuityPolicyDetail {\\n policyStatus\\n isImmediateLiquidityEnabled\\n regTypeCode\\n __typename\\n }\\n externalAcctDetail {\\n acctType\\n acctSubType\\n isManualAccount\\n __typename\\n }\\n creditCardDetail {\\n creditCardAcctNumber\\n memberId\\n twelveMonthRewards\\n webIdPrefix\\n __typename\\n }\\n managedAcctDetail {\\n invstApproach\\n invstUniverse\\n productCode\\n svcModelCode\\n smaStrategy\\n isTaxable\\n productFullName\\n strategyName\\n __typename\\n }\\n acctFeature {\\n featureDetails {\\n established {\\n marginOptionSpreadsDetail {\\n hasMargin\\n hasLimitedMargin\\n hasOption\\n hasMarginDebtProtection\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n groups {\\n id\\n name\\n items {\\n acctNum\\n acctType\\n acctSubType\\n acctSubTypeDesc\\n acctCreationDate\\n parentBrokAcctNum\\n linkedAcctDetails {\\n acctNum\\n isLinked\\n __typename\\n }\\n acctStateDetail {\\n statusCode\\n __typename\\n }\\n acctTradeAttrDetail {\\n isTradable\\n __typename\\n }\\n acctAttrDetail {\\n addlBrokAcctCode\\n regTypeDesc\\n cryptoAssociatedCode\\n taxTreatmentCode\\n __typename\\n }\\n acctIndDetail {\\n isAdvisorAcct\\n isAuthorizedAcct\\n isMultiCurrencyAllowed\\n isGuidedPortfolioSummEnabled\\n isFFOSAcct\\n isPrimaryCustomer\\n __typename\\n }\\n acctTrustIndDetail {\\n isAdvisorTrustTLAAcct\\n isTrustAcct\\n isAdvisorTrustTLAAcct\\n __typename\\n }\\n acctTypesIndDetail {\\n isRetirement\\n isYouthAcct\\n hasSPSPlans\\n __typename\\n }\\n acctRelAttrDetail {\\n relCategoryCode\\n relRoleTypeCode\\n __typename\\n }\\n acctEligibilityDetail {\\n isEligibleForMoneyMovement\\n __typename\\n }\\n acctLegacyAttrDetail {\\n legacyHouseHoldCostBasisCode\\n __typename\\n }\\n preferenceDetail {\\n name\\n isHidden\\n isDefaultAcct\\n acctGroupId\\n __typename\\n }\\n acctLegalAttrDetail {\\n legalConstructCode\\n legalConstructModifierCode\\n serviceSegmentCode\\n accountTypeCode\\n offeringCode\\n lineOfBusinessCode\\n __typename\\n }\\n workplacePlanDetail {\\n planTypeName\\n planTypeCode\\n planId\\n clientId\\n clientTickerSymbol\\n enrollmentStatusCode\\n isCrossoverEnabled\\n isEnrollmentEligible\\n nonQualifiedInd\\n isRollup\\n planName\\n navigationKey\\n url\\n __typename\\n }\\n gainLossBalanceDetail {\\n totalMarketVal\\n todaysGainLoss\\n todaysGainLossPct\\n asOfDateTime\\n hasUnpricedPositions\\n hasIntradayPricing\\n __typename\\n }\\n annuityProductDetail {\\n systemOfRecord\\n planTypeCode\\n planCode\\n productCode\\n productDesc\\n __typename\\n }\\n annuityPolicyDetail {\\n policyStatus\\n isImmediateLiquidityEnabled\\n __typename\\n }\\n externalAcctDetail {\\n acctType\\n acctSubType\\n isManualAccount\\n __typename\\n }\\n managedAcctDetail {\\n invstApproach\\n invstUniverse\\n productCode\\n svcModelCode\\n smaStrategy\\n isTaxable\\n __typename\\n }\\n digiAcctAttrDetail {\\n currencyCode\\n currencyType\\n amount\\n __typename\\n }\\n __typename\\n }\\n balanceDetail {\\n hasIntradayPricing\\n gainLossBalanceDetail {\\n totalMarketVal\\n todaysGainLoss\\n todaysGainLossPct\\n fidelityTotalMktVal\\n hasUnpricedPositions\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n customerAttrDetail {\\n externalCustomerID\\n isShowWorkplaceSavingAccts\\n isShowExternalAccts\\n pledgedAcctNums\\n __typename\\n }\\n groupDetails {\\n groupId\\n groupName\\n typeCode\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n}\\n";
const GET_POSITIONS_QUERY = "query GetPositions($acctList: [PositionAccountInput], $customerId: String) {\\n getPosition(acctList: $acctList, customerId: $customerId) {\\n sysMsgs {\\n sysMsg {\\n code\\n detail\\n message\\n source\\n type\\n __typename\\n }\\n __typename\\n }\\n position {\\n portfolioDetail {\\n portfolioPositionCount\\n __typename\\n }\\n acctDetails {\\n acctDetail {\\n acctNum\\n positionDetails {\\n positionDetail {\\n symbol\\n cusip\\n holdingPct\\n optionUnderlyingSymbol\\n securityType\\n securitySubType\\n hasIntradayPricingInd\\n marketValDetail {\\n marketVal\\n totalGainLoss\\n __typename\\n }\\n securityDetail {\\n isLoaned\\n isHardToBorrow\\n bondDetail {\\n maturityDate\\n hasAutoRoll\\n __typename\\n }\\n __typename\\n }\\n securityDescription\\n quantity\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n topBottomPositions {\\n symbol\\n securityDescription\\n lastPrice\\n todaysGainLossPct\\n todaysGainLoss\\n totalGainLoss\\n totalGainLossPct\\n hasIntradayPricingInd\\n href\\n quantity\\n __typename\\n }\\n __typename\\n }\\n}\\n";
// Return a desired distribution of a portfolio given a list of existing
// symbols in the portfolio. All percentages of the distribution should
// add up to 1.00 (100%). Symbols not included in the returned distribution
// will be ignored and not considered in the final distribution.
function getDesiredDistributionForPositions(allPositionSymbols) {
const desiredDistributions = [
{
"ITOT": 0.60,
"IXUS": 0.40,
},
{
"FZROX": 0.60,
"FZILX": 0.40,
},
];
return desiredDistributions
.find(distribution => Object.keys(distribution)
.every(symbol => allPositionSymbols.includes(symbol)));
}
// Get info of all accounts, mainly used to enumerate which accounts
// are available
async function getAllAccounts() {
const contextResponse = await fetch("https://digital.fidelity.com/ftgw/digital/portfolio/api/graphql?ref_at=portsum", {
"credentials": "include",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.5",
"content-type": "application/json",
"apollographql-client-version": "0.0.0",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Sec-GPC": "1"
},
"referrer": "https://digital.fidelity.com/ftgw/digital/portfolio/balances",
"body": "{\"operationName\":\"GetContext\",\"variables\":{},\"query\":\"" + GET_CONTEXT_QUERY + "\"}",
"method": "POST",
"mode": "cors"
});
// A cached version of this is also available through:
// window.PortSumContainer.PicoService.getPersonContext().then(S => ...)
const contextData = await contextResponse.json();
return contextData.data.getContext.person.assets;
}
// Get positions for a set of accounts
async function getPositionsForAccounts(accounts) {
const balanceAccountList = accounts.map(a => {
var account = {"acctNum": a.acctNum, "acctType": a.acctType, "acctSubType": a.acctSubType };
// not really sure
account["preferenceDetail"] = !a?.preferenceDetail?.isHidden ?? true;
if (a.workplacePlanDetail != null) {
account["planInTransitionInd"] = a.workplacePlanDetail.planInTransitionInd;
}
return account;
});
const positionsResponse = await fetch("https://digital.fidelity.com/ftgw/digital/portfolio/api/graphql?ref_at=portsum", {
"credentials": "include",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.5",
"content-type": "application/json",
"apollographql-client-version": "0.0.0",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Sec-GPC": "1"
},
"referrer": "https://digital.fidelity.com/ftgw/digital/portfolio/summary",
"body": "[{\"operationName\":\"GetPositions\",\"variables\":{\"acctList\":" + JSON.stringify(balanceAccountList) + "},\"query\":\"" + GET_POSITIONS_QUERY + "\"}]",
"method": "POST",
"mode": "cors"
});
const positionsData = await positionsResponse.json();
return positionsData[0].data.getPosition.position.acctDetails.acctDetail;
}
// Get balances for an account
async function getCashAvailableToTrade(accountId) {
// This API is simpler to call than `https://digital.fidelity.com/ftgw/digital/balwebex/api/balances`,
// but at the cost of not supporting batched accounts
const request = await fetch("https://digital.fidelity.com/ftgw/digital/trade-equity/balance", {
"credentials": "include",
"headers": {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0",
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.5",
"Content-Type": "application/json",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Sec-GPC": "1",
"X-CSRF-Token": window.EQUITY_ENV_MAP.CSURF_TOKEN
},
"referrer": "https://digital.fidelity.com/ftgw/digital/portfolio/balances",
"body": "[{\"acctNum\":\"" + accountId + "\"}]",
"method": "POST",
"mode": "cors"
});
const response = await request.json();
// "Settled Cash" is response.balances[0].brokerageAcctDetail.recentBalanceDetail.cashDetail.settledAmt
return response.balances[0].brokerageAcctDetail.recentBalanceDetail.buyingPowerDetail.cash;
}
// Open and fill out the trade window to buy a certain dollar amount of
// a given ETF (market order) or MF. This mimics the actual user experience
// as much as possible, so that the "Preview Order" button and everything
// works like normal -- not through some obscure API endpoints.
async function populateTradeWindow(symbol, dollarAmount) {
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function typeIntoInput(elem, text) {
elem.dispatchEvent(new FocusEvent("focus"));
await sleep(500);
elem.value = text;
await sleep(500);
elem.dispatchEvent(new KeyboardEvent("input"));
await sleep(500);
elem.dispatchEvent(new FocusEvent("blur"));
await sleep(500);
}
// Open the trade modal
document.querySelector(".trade").click();
await sleep(1000);
// Wait for the symbol input to appear and enter our symbol
let elem = null;
while (elem == null) {
elem = document.querySelector("#eq-ticket-dest-symbol");
await sleep(100);
}
typeIntoInput(elem, symbol);
await sleep(3000);
// If ETF fields are visible
const buyInputElem = document.querySelector("#buy-segment");
if (buyInputElem.offsetParent != null) {
// Select "Buy"
buyInputElem.nextElementSibling.click();
await sleep(500);
// Select "Dollars"
const dollarsInputElem = document.querySelector("#dollars-segment");
dollarsInputElem.nextElementSibling.click();
await sleep(500);
// Select "Market"
const marketInputElem = document.querySelector("#market-yes-segment");
marketInputElem.nextElementSibling.click();
await sleep(500);
// Enter our dollar amount
const quantityElem = document.querySelector("#eqt-shared-quantity");
typeIntoInput(quantityElem, dollarAmount);
}
// Otherwise, it's probably an MF
else {
// Select "Buy"
const actionElem = document.querySelector(".mf-ticket__action-dropdown #mf-dropdownlist-button");
actionElem.click();
await sleep(500);
const buyActionElem = [...document.querySelectorAll(".dropdownlist_items--item")]
.find(elem => elem.innerText == "Buy");
buyActionElem.dispatchEvent(new MouseEvent("mousedown"));
await sleep(500);
// Enter our dollar amount
const quantityElem = document.querySelector("#mf-shared-quantity");
typeIntoInput(quantityElem, dollarAmount);
}
}
// Redirect to the portfolio page if not already there
const PORTFOLIO_SUMMARY_URL = "https://digital.fidelity.com/ftgw/digital/portfolio/summary";
const PORTFOLIO_POSITIONS_URL = "https://digital.fidelity.com/ftgw/digital/portfolio/positions";
const PORTFOLIO_BALANCES_URL = "https://digital.fidelity.com/ftgw/digital/portfolio/balances";
const base_url = `${window.location.origin}${window.location.pathname}`;
if (base_url != PORTFOLIO_SUMMARY_URL && base_url != PORTFOLIO_POSITIONS_URL && base_url != PORTFOLIO_BALANCES_URL) {
window.location = PORTFOLIO_SUMMARY_URL;
return;
}
// Create a sticky element for the bookmark to output to
function resetInfoElement() {
let robofidelityElem = document.querySelector("#robofidelity");
if (robofidelityElem != null) {
document.body.removeChild(robofidelityElem);
}
robofidelityElem = document.createElement("div");
robofidelityElem.id = "robofidelity";
robofidelityElem.style = "position: absolute;top: 1rem;right: 1rem;background: rgba(255, 255, 255, 0.9);padding: 1rem;border: 2px solid black;z-index:99999999999999;";
document.body.appendChild(robofidelityElem);
return robofidelityElem;
}
function formatMoney(value) {
return "$" + value.toLocaleString('en-US', {maximumFractionDigits: 2, roundingMode: "floor"});
}
let robofidelityElem = resetInfoElement();
robofidelityElem.appendChild(document.createTextNode("Loading account info..."));
const allAccounts = await getAllAccounts();
const accountsById = Object.fromEntries(allAccounts.map(a => [a.acctNum, a]));
//TODO maybe this isn't the best axis for filtering
const relevantAccountSubTypes = new Set(["Brokerage", "Brokerage Link", "Health Savings"]);
const relevantAccounts = allAccounts.filter(a => relevantAccountSubTypes.has(a.acctSubType));
const positionsForAllAccounts = await getPositionsForAccounts(relevantAccounts);
const positionsByAccountId = Object.fromEntries(positionsForAllAccounts.map(p => [p.acctNum, p]));
async function refresh() {
const cashAvailableToTradeByAccount = Object.fromEntries(
await Promise.all(relevantAccounts.map(async (a) => {
const accountId = a.acctNum;
return [accountId, await getCashAvailableToTrade(accountId)];
})));
const selectedAccountId = window.PortSumContainer.StateContext.current("currentAccount").acctNum;
let robofidelityElem = resetInfoElement();
const header = document.createElement('span');
header.innerHTML = "<em>Accounts with uninvested cash</em><br/>";
robofidelityElem.appendChild(header);
relevantAccounts.forEach(a => {
const accountId = a.acctNum;
const accountName = a.preferenceDetail?.name ?? "<no name>";
const cashAvailableToTrade = cashAvailableToTradeByAccount[accountId];
// Skip accounts with no cash
if (cashAvailableToTrade <= 0) {
return;
}
const link = document.createElement('a');
link.href = `${base_url}#${accountId}`;
link.text = `[${accountName}] ${formatMoney(cashAvailableToTrade)} uninvested`;
// Highlight the currently selected account
if (selectedAccountId == accountId) {
link.removeAttribute("href");
link.style = "font-weight: bold;";
}
robofidelityElem.appendChild(link);
robofidelityElem.appendChild(document.createElement("br"));
});
// If an account is selected, analyze the account's positions
let accountId = selectedAccountId;
if (accountId) {
const a = accountsById[accountId];
const accountName = a.preferenceDetail?.name ?? "<no name>";
console.log(`Analyzing selected account "${accountName}" (${accountId})`);
const accountInfo = document.createElement("span");
accountInfo.innerHTML =
"<hr>" +
"Selected Account: <strong>" + accountName + "</strong> (<em>" + accountId + "</em>)<br/>" +
"<br/>";
robofidelityElem.appendChild(accountInfo);
if (!(accountId in positionsByAccountId) || positionsByAccountId[accountId].positionDetails == null) {
console.warn(`[${accountId} ${accountName}] failed to find positions; skipping`);
accountInfo.innerHTML += "Error: failed to load account positions";
return;
}
const cashAvailableToTrade = cashAvailableToTradeByAccount[accountId];
console.log(`[${accountId} ${accountName}] have $${cashAvailableToTrade} available to trade`)
const positions = positionsByAccountId[accountId].positionDetails.positionDetail;
const currentPositions = Object.fromEntries(positions.map(p => [p.symbol, p.marketValDetail.marketVal]));
console.log(`[${accountId} ${accountName}] have ${JSON.stringify(currentPositions)}`);
const desiredDistribution = getDesiredDistributionForPositions(Object.keys(currentPositions));
if (desiredDistribution == undefined) {
console.warn(`[${accountId} ${accountName}] failed to find matching distribution; skipping`);
accountInfo.innerHTML += `No matching distribution`;
return;
}
console.log(`[${accountId} ${accountName}] want distribution ${JSON.stringify(desiredDistribution)}`);
const symbols = Object.keys(desiredDistribution);
const currentInvested = symbols.map(symbol => currentPositions[symbol]).reduce((a, b) => a + b, 0);
const currentDistribution = Object.fromEntries(symbols.map(symbol => [symbol, currentPositions[symbol]/currentInvested]));
// Calculate what our portfolio would look like at the desired distribution
const finalInvested = currentInvested + cashAvailableToTrade;
const desiredPositions = Object.fromEntries(
symbols.map(symbol => [symbol, desiredDistribution[symbol] * finalInvested]));
console.log(`[${accountId} ${accountName}] want ${JSON.stringify(desiredPositions)}`);
// Calculate how far our actual position is away from the desired position
const delta = Object.fromEntries(
symbols.map(symbol => [symbol, Math.max(0, desiredPositions[symbol] - currentPositions[symbol])]));
const totalDelta = Object.values(delta).reduce((a, b) => a + b, 0);
// Split the available cash fairly between the positions
const split = Object.fromEntries(
symbols.map(symbol => [symbol, (delta[symbol]/totalDelta) * cashAvailableToTrade]));
const finalDistribution = Object.fromEntries(
symbols.map(symbol => [symbol, (currentPositions[symbol] + split[symbol])/finalInvested]));
console.info(`[${accountId} ${accountName}] split`, split);
accountInfo.innerHTML +=
"Invested: <strong>" + formatMoney(currentInvested) + "</strong><br/>" +
"Available to Trade: <strong>" + formatMoney(cashAvailableToTrade) + "</strong><br/>" +
"<br/>" +
"Desired Distribution: " + Object.entries(desiredDistribution).map(([symbol, percentage]) => `${(percentage*100).toFixed(0)}% ${symbol}`).join(", ") + "<br/>" +
"Current Distribution: " + Object.entries(currentDistribution).map(([symbol, percentage]) => `${(percentage*100).toFixed(0)}% ${symbol}`).join(", ") + "<br/>" +
"<br/>";
if (cashAvailableToTrade <= 0) {
return;
}
let runningTotal = 0;
for (let i = 0; i < symbols.length; i++) {
const symbol = symbols[i];
let value = split[symbol];
if (i == symbols.length - 1) {
value = Math.min(value, cashAvailableToTrade - runningTotal);
}
const button = document.createElement("button");
button.style = "display:block;";
button.innerText = "Invest " + formatMoney(value) + " in " + symbol + " (" + (delta[symbol]/totalDelta*100).toFixed(0) + "%)";
button.addEventListener("click", async () => {
await populateTradeWindow(symbol, value.toFixed(2));
});
robofidelityElem.appendChild(button);
console.info("[" + accountName + "] Invest " + formatMoney(value) + " in " + symbol + " (" + (delta[symbol]/totalDelta*100).toFixed(0) + "%)");
runningTotal += value;
}
robofidelityElem.appendChild(document.createElement("br"));
robofidelityElem.appendChild(document.createTextNode("Final Distribution: " + Object.entries(finalDistribution).map(([symbol, percentage]) => `${(percentage*100).toFixed(0)}% ${symbol}`).join(", ")));
robofidelityElem.appendChild(document.createElement("br"));
}
}
// Detect when the URL changes and refresh
//TODO use Navigation API once shipped in Firefox
let previousUrl = '';
const observer = new MutationObserver(async function () {
if (location.href !== previousUrl) {
previousUrl = location.href;
await refresh();
}
});
const config = {subtree: true, childList: true};
observer.observe(document, config);
refresh();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment