|
(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 isPriorDayGainLossReset\\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 vestedAcctValEOD\\n isVested100Pct\\n __typename\\n }\\n acctTypesIndDetail {\\n isRetailHSA\\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 digiAcctAttrDetail {\\n fdasAcctReg\\n fdasAcctSubReg\\n lineOfBusinessCode\\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 isRetailHSA\\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 vestedAcctValEOD\\n isVested100Pct\\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 fdasAcctReg\\n fdasAcctSubReg\\n lineOfBusinessCode\\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>"; |
|
|
|
const closeButton = document.createElement('button'); |
|
closeButton.style = "float: right;"; |
|
closeButton.innerText = "X"; |
|
closeButton.addEventListener("click", () => { |
|
robofidelityElem.remove(); |
|
}); |
|
header.appendChild(closeButton); |
|
|
|
header.appendChild(document.createElement('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(); |
|
})(); |
A bookmarklet for semi-automating ETF and Mutual Fund purchases through Fidelity