/** * -------------------------------------------------------------------------------- * FINANCIAL PORTFOLIO TRACKER (TypeScript Port) * -------------------------------------------------------------------------------- * * OVERVIEW: * This script fetches financial trading data from a Notion database, retrieves * historical price data from Yahoo Finance, calculates performance metrics * (IRR, current value, dividends), and pushes the results back to Notion * and a TRMNL dashboard. * * TYPESCRIPT CONCEPTS COVERED: * - Interfaces & Type Definitions * - Async/Await & Promises * - Strict Null Checks & Type Guards * - Optional Chaining (?.) and Nullish Coalescing (??) * - Type Assertions (as ...) * - External Module Imports */ // --------------------------------------------------------- // 0. IMPORTS // --------------------------------------------------------- // LEARNING TIP: Default vs Named Imports // 'yahooFinance' is a default import (export default ...). // '{ addDays, ... }' are named imports. You must use the exact name exported by the library. import yahooFinance from 'yahoo-finance2'; import { addDays, differenceInDays, format, parseISO, subDays, isBefore, isAfter, isEqual, startOfDay, isWeekend } from 'date-fns'; // --------------------------------------------------------- // 1. CONFIGURATION // --------------------------------------------------------- // LEARNING TIP: 'as const' Assertion // By adding `as const`, we tell TypeScript that these are READ-ONLY LITERALS. // Instead of being type 'string', LOG_COLORS.error is literally the string '\x1b[31m'. // This prevents accidental modification and allows us to create specific Types from keys. const LOG_COLORS = { error: '\x1b[31m', // Red warning: '\x1b[33m', // Yellow success: '\x1b[32m', // Green info: '\x1b[90m', // Gray debug: '\x1b[4m', // Underscore endcode: '\x1b[0m' // Reset } as const; // LEARNING TIP: Lookup Types (keyof typeof) // We create a new Type 'LogColorKey' that can ONLY be one of: "error" | "warning" | "success" | ... // This gives us autocomplete support when using these keys later. type LogColorKey = keyof typeof LOG_COLORS; const LOGGING_LEVELS = ["none", "error", "success", "warning", "info", "debug"]; // Mutable config object (standard JavaScript object) const config = { // Functionality Switches update_notion: false, update_TRMNL: true, calculate_benchmark: true, // Configuration programm_cooldown_time: 15, // Minutes api_cooldown_time: 100, // Milliseconds trmnl_granularity: 80, // Days ticker_benchmark: "VGWL.DE", // Logging selected_logging_level: "warning", // API - NOTION notion_token: "secret_b7PiPL2FqC9QEikqkAEWOht7LmzPMIJMWTzUPWwbw4H", notion_db_id_trades: "95f7a2b697a249d4892d60d855d31bda", notion_db_id_investments: "2ba10a5f51bd8160ab9ee982bbef8cc3", notion_db_id_performance: "1c010a5f51bd806f90d8e76a1286cfd4", // API - TRMNL trmnl_url_chart_1: "https://usetrmnl.com/api/custom_plugins/334ea2ed-1f20-459a-bea5-dca2c8cf7714", trmnl_url_chart_2: "https://usetrmnl.com/api/custom_plugins/72950759-38df-49eb-99fb-b3e2e67c385e", trmnl_url_chart_3: "https://usetrmnl.com/api/custom_plugins/a975543a-51dc-4793-b7fa-d6a101dc4025", }; // Headers configuration for API requests const notionHeaders = { "Authorization": "Bearer " + config.notion_token, "Content-Type": "application/json", "Notion-Version": "2022-02-22" }; const trmnlHeaders = { "Content-Type": "application/json" }; // --------------------------------------------------------- // 2. INTERFACES (Type Definitions) // --------------------------------------------------------- // LEARNING TIP: Interfaces // Interfaces describe the "Shape" of an object. TypeScript uses "Structural Typing". // If an object has the properties defined here, it is considered a 'Trade', even if // we didn't explicitly say `new Trade()`. interface Trade { ticker: string; date_open: string; // ISO Date string YYYY-MM-DD // LEARNING TIP: Union Types // 'date_close' can be a string OR the number 0. // TypeScript forces us to check which one it is before using it (Type Narrowing). date_close: string | 0; course_open: number; course_close: number; course_current: number; irr: number; units: number; dividends: number; } // LEARNING TIP: Index Signatures // 'TradesDict' is an object where the Keys are strings (IDs) and Values are 'Trade' objects. // This is used for Hash Maps / Dictionaries. interface TradesDict { [key: string]: Trade; } interface Investment { ticker: string; total_dividends: number; current_value: number; current_irr: number; total_performanance: number; } interface InvestmentsDict { [key: string]: Investment; } interface HistoryEntry { current_amount: number; current_invested: number; total_dividends: number; current_value: number; current_irr: number; current_course: number; total_performanance: number; } interface HistoryDailyTrade { [tradeId: string]: HistoryEntry; } interface HistoryPerTrade { [date: string]: HistoryDailyTrade; } interface TickerHistoryEntry { current_invested: number; total_dividends: number; current_value: number; current_irr: number; total_performanance: number; // LEARNING TIP: Optional Properties (?) // The '?' indicates these properties might NOT exist on the object. // When accessing them, the result is 'number | undefined'. current_amount?: number; current_course?: number; // Recursive Type: An entry can contain a nested benchmark entry benchmark?: TickerHistoryEntry; } interface HistoryDailyTicker { [ticker: string]: TickerHistoryEntry; } interface HistoryPerTicker { [date: string]: HistoryDailyTicker; } // Structure for Yahoo Finance Data map interface YFDataMap { [ticker: string]: { [date: string]: { close: number; dividends: number; } } } // --------------------------------------------------------- // 3. HELPER FUNCTIONS // --------------------------------------------------------- /** * Pauses execution for a set amount of time. * LEARNING TIP: Promises * JavaScript is single-threaded. 'setTimeout' schedules code to run later. * We wrap it in a Promise so we can use 'await sleep(1000)' to pause execution flow. */ function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Prints messages to the console with colors. * Demonstrates default parameter values (newLine = true). */ function logging(message = "", loggingLevel = "", newLine = true) { const configLevelIdx = LOGGING_LEVELS.indexOf(config.selected_logging_level); let messageLevelIdx = LOGGING_LEVELS.indexOf(loggingLevel); if (messageLevelIdx === -1) { messageLevelIdx = LOGGING_LEVELS.length; } if (messageLevelIdx <= configLevelIdx) { // LEARNING TIP: Type Assertion (as ...) // We know 'loggingLevel' is a string, but we need to tell TypeScript // that it matches one of the keys in LOG_COLORS to allow index access. const color = LOG_COLORS[loggingLevel as LogColorKey] || LOG_COLORS.info; const logText = `${color}[${loggingLevel}] ${LOG_COLORS.endcode}${message}`; if (newLine) { console.log(logText); } else { // process.stdout.write prints without a newline character at the end process.stdout.write(logText + " "); } } } /** * Calculates Internal Rate of Return (IRR) approximation. */ function calculateIrr(dateNow: Date, dateOpen: Date, valueNow: number, valueOpen: number): number { let irr = 0.0; try { let daysDiff = differenceInDays(dateNow, dateOpen); if (daysDiff === 0) daysDiff = 1; // Prevent division by zero logic const years = daysDiff / 365.0; let ratio = valueNow / valueOpen; // Basic Annualized Return Formula logic if (ratio < 0) { ratio = ratio * -1; irr = Math.pow(ratio, 1 / years); irr = irr * -1; } else { irr = Math.pow(ratio, 1 / years); } return irr; } catch (e) { logging("Calculation of irr failed", "error"); return 0.0; } } /** * Finds the oldest date in a dictionary of trades. */ function getDateOpenOldestTrade(trades: TradesDict): Date { let oldest = new Date(); // Start with today for (const key in trades) { const trade = trades[key]; // LEARNING TIP: Type Guard // In strict mode, dictionary access might return undefined. // We check 'if (!trade) continue' to ensure 'trade' exists before using it. if (!trade) continue; const d = parseISO(trade.date_open); // date-fns comparison function if (isBefore(d, oldest)) { oldest = d; } } return oldest; } /** * Extracts a unique list of ticker symbols from trades. */ function filterListOfTickers(trades: TradesDict): string[] { const tickers: string[] = []; try { for (const key in trades) { const trade = trades[key]; if (!trade) continue; // Safety check const t = trade.ticker; // Only add if not already in list if (!tickers.includes(t)) { tickers.push(t); } } logging("", "success"); logging(`${tickers.length} tickers found`, "info"); return tickers; } catch (e) { logging(`${e}`, "error"); return []; } } /** * Creates a list of dates (e.g., weekly) from the oldest trade to today. */ function createListFilteredDates(trades: TradesDict, daysSeperation: number): string[] { try { const stopDate = getDateOpenOldestTrade(trades); let indexDate = new Date(); const listFilteredDates: string[] = []; // Loop backwards in time while (isAfter(indexDate, stopDate) || isEqual(indexDate, stopDate)) { listFilteredDates.push(format(indexDate, 'yyyy-MM-dd')); indexDate = subDays(indexDate, daysSeperation); } // Reverse to get chronological order (Oldest -> Newest) listFilteredDates.reverse(); logging("", "success"); logging(`${listFilteredDates.length} dates in weekly list`, "info"); return listFilteredDates; } catch (e) { logging(`${e}`, "error"); return []; } } function addBenchmarkTicker(tickers: string[], tickerBenchmark: string): string[] { if (!tickers.includes(tickerBenchmark)) { tickers.push(tickerBenchmark); } logging("", "success"); return tickers; } /** * Helper to get the very last key in an object (assumes keys are sortable strings like dates). */ function fetchLastKeyFromDict(dict: any): string { const keys = Object.keys(dict).sort(); if (keys.length === 0) { throw new Error("Dictionary is empty, cannot fetch last key."); } const lastKey = keys[keys.length - 1]; // LEARNING TIP: Strict Null Checks // Even though logic suggests lastKey exists, TS Strict Mode demands we verify it isn't undefined. if (lastKey === undefined) { throw new Error("Last key was undefined"); } return lastKey; } // --------------------------------------------------------- // 4. NETWORK DOWNLOAD FUNCTIONS // --------------------------------------------------------- /** * Fetches pages from a Notion Database. Handles pagination automatically. * Returns Promise because Notion objects are complex and we parse them later. */ async function notionGetPages(dbId: string, numPages: number | null = null): Promise { try { const url = `https://api.notion.com/v1/databases/${dbId}/query`; const getAll = numPages === null; const pageSize = getAll ? 100 : numPages; let payload: any = { page_size: pageSize }; let results: any[] = []; let hasMore = true; let nextCursor = null; // Loop to fetch all pages if there are more than 100 while (hasMore) { if (nextCursor) { payload['start_cursor'] = nextCursor; } // Using standard Fetch API const response = await fetch(url, { method: 'POST', headers: notionHeaders as any, body: JSON.stringify(payload) }); if (!response.ok) throw new Error(`Notion API error: ${response.status}`); const data = await response.json(); results.push(...data.results); // Check if Notion says there are more pages hasMore = data.has_more && getAll; nextCursor = data.next_cursor; if (!getAll && results.length >= (numPages || 0)) break; } return results; } catch (e) { logging("Notion Fetch Error", "error"); return []; } } /** * Fetches trades from Notion and formats them into our 'TradesDict' interface. */ async function fetchFormatNotionTrades(dbIdTrades: string): Promise { const trades: TradesDict = {}; let formatErrors = 0; let numberOfTrades = 0; // Await the asynchronous fetch const data = await notionGetPages(dbIdTrades); if (data.length === 0) return {}; for (const page of data) { numberOfTrades++; try { // Extract properties from Notion's specific JSON structure const props = page.properties; // LEARNING TIP: Optional Chaining (?.) // props["Close"] might not exist. If it doesn't, ?. returns undefined immediately // instead of crashing with "Cannot read property 'date' of undefined". let dateClose: string | 0 = 0; if (props["Close"]?.date?.start) { dateClose = props["Close"].date.start; } const dateOpen = props["Open"]?.date?.start; if (!dateOpen) throw new Error("Missing Open Date"); // LEARNING TIP: Nullish Coalescing (??) // If the value on the left is null or undefined, use the default value on the right. const trade: Trade = { ticker: props["Ticker"]?.select?.name ?? "UNKNOWN", date_open: dateOpen, date_close: dateClose, course_open: props["Open (€)"]?.number ?? 0, course_close: props["Close (€)"]?.number ?? 0, course_current: props["Current (€)"]?.number ?? 0, irr: props["IRR (%)"]?.number ?? 0, units: props["Units"]?.number ?? 0, dividends: props["Dividends (€)"]?.number ?? 0 }; trades[page.id] = trade; } catch (e) { formatErrors++; } } if (formatErrors === 0) { logging("", "success"); logging(`${numberOfTrades} trades received and formatted`, "info"); } else { logging("", "warning"); logging(`${formatErrors} trades skipped out of ${numberOfTrades}`, "warning"); } return trades; } /** * Similar to above, but fetches the Investment Overview database. */ async function fetchFormatNotionInvestments(dbIdInvestments: string): Promise { const investments: InvestmentsDict = {}; let formatErrors = 0; let numberOfInvestments = 0; const data = await notionGetPages(dbIdInvestments); for (const page of data) { numberOfInvestments++; try { const props = page.properties; investments[page.id] = { ticker: props["Ticker"]?.select?.name ?? "UNKNOWN", total_dividends: props["Dividends (€)"]?.number ?? 0, current_value: props["Current (€)"]?.number ?? 0, current_irr: props["IRR (%)"]?.number ?? 0, total_performanance: props["Performance (€)"]?.number ?? 0 }; } catch (e) { formatErrors++; } } if (formatErrors === 0) { logging("", "success"); logging(`${numberOfInvestments} investments received`, "info"); } else { logging("", "warning"); logging(`${formatErrors} investments skipped`, "warning"); } return investments; } /** * Fetches historical price data for all tickers from Yahoo Finance. */ async function fetchFormatYfData(tickers: string[]): Promise { const yfData: YFDataMap = {}; let fetchErrors = 0; let formatErrors = 0; let numberOfTickers = 0; // LEARNING TIP: Runtime Environment Check & Dynamic Instantiation // Sometimes Node imports 'yahooFinance' as a Class constructor, sometimes as an Instance object, // depending on the exact version and module settings (CommonJS vs ESM). // This code checks if it's a function (Class). If so, it creates a 'new' instance. const yf = typeof yahooFinance === 'function' ? new (yahooFinance as any)({ suppressNotices: ['ripHistorical'] }) : yahooFinance; process.stdout.write(" "); for (const ticker of tickers) { numberOfTickers++; try { const queryOptions = { period1: '2000-01-01', period2: format(new Date(), 'yyyy-MM-dd') }; // Await the API call const result = await yf.historical(ticker, queryOptions); // LEARNING TIP: Local Variable for Strict Typing // We build the data map in a local variable first. // Writing directly to yfData[ticker][date] would cause TypeScript errors // because yfData[ticker] might be undefined in the compiler's eyes. const tickerDataMap: { [date: string]: { close: number; dividends: number } } = {}; // Explicitly typing 'row' as any to handle incoming data flexbility result.forEach((row: any) => { const dateStr = format(row.date, 'yyyy-MM-dd'); tickerDataMap[dateStr] = { close: row.close, dividends: 0 }; }); // Assign the completed map to the main object yfData[ticker] = tickerDataMap; } catch (e) { fetchErrors++; // Check if 'e' is an Error object to safely access .message console.log(`\n[Error fetching ${ticker}]: ${e instanceof Error ? e.message : String(e)}`); } process.stdout.write("."); // Pause to avoid hitting API Rate Limits await sleep(config.api_cooldown_time); } process.stdout.write(" "); if (fetchErrors === 0 && formatErrors === 0) { logging("", "success"); logging(`${numberOfTickers} tickers received`, "info"); } else { logging("", "warning"); logging(`${fetchErrors} fetch errors`, "warning"); } return yfData; } // --------------------------------------------------------- // 5. CALCULATION FUNCTIONS // --------------------------------------------------------- /** * Calculates daily performance history for every single trade. * Iterates day-by-day from the trade opening date to today. */ function calcHistoryPerTrade(trades: TradesDict, yfData: YFDataMap): HistoryPerTrade { const historyPerTrade: HistoryPerTrade = {}; let missingDayEntries = 0; let daysFormatted = 0; let numberOfTrades = 0; const dateOpenOldestTrade = getDateOpenOldestTrade(trades); // Loop over every trade for (const tradeId in trades) { numberOfTrades++; // Strict check: Ensure trade exists const trade = trades[tradeId]; if (!trade) continue; // Start from the oldest trade date across the board let indexDate = startOfDay(new Date(dateOpenOldestTrade)); const today = startOfDay(new Date()); let previousCourse = 0.0; // Accumulator for dividends specific to this trade let runningTotalDividends = 0.0; // Determine closure date (or tomorrow if currently active) let dateCloseObj = (trade.date_close === 0) ? addDays(today, 1) : parseISO(trade.date_close as string); const dateOpenObj = parseISO(trade.date_open); // INNER LOOP: Iterate through every day from start to today while (isBefore(indexDate, addDays(today, 1))) { daysFormatted++; const indexDateIso = format(indexDate, 'yyyy-MM-dd'); let currentCourse = 0.0; let currentDividendsPerTicker = 0.0; // 1. Get Price Data // We check if tickerData exists first to avoid undefined errors const tickerData = yfData[trade.ticker]; if (tickerData && tickerData[indexDateIso]) { currentCourse = tickerData[indexDateIso].close; currentDividendsPerTicker = tickerData[indexDateIso].dividends; } else { // Fallback: Use previous day's price currentCourse = previousCourse; // FIX: Only count as "missing" if it is NOT a weekend if (!isWeekend(indexDate)) { missingDayEntries++; } } // 2. Handle Closed Trades (Lock price to the closing price) if (isEqual(indexDate, dateCloseObj)) { currentCourse = trade.course_close; } previousCourse = currentCourse; // 3. Calculate Performance Metrics let currentAmount = 0; let currentInvested = 0; let currentVal = 0; let currentIrr = 0; let totalPerf = 0; // Only calculate if the current date is within the trade's lifespan if ((isAfter(indexDate, dateOpenObj) || isEqual(indexDate, dateOpenObj)) && (isBefore(indexDate, dateCloseObj) || isEqual(indexDate, dateCloseObj))) { currentAmount = trade.units; currentInvested = currentAmount * trade.course_open; runningTotalDividends += currentAmount * currentDividendsPerTicker; currentVal = currentAmount * currentCourse; const currentValWithDiv = currentVal + runningTotalDividends; currentIrr = calculateIrr(indexDate, dateOpenObj, currentValWithDiv, currentInvested); totalPerf = currentValWithDiv - currentInvested; } // 4. Store Results in History Map // Initialize day object if missing if (!historyPerTrade[indexDateIso]) historyPerTrade[indexDateIso] = {}; historyPerTrade[indexDateIso][tradeId] = { current_amount: currentAmount, current_invested: currentInvested, total_dividends: runningTotalDividends, current_value: currentVal, current_irr: currentIrr, current_course: currentCourse, total_performanance: totalPerf }; // Increment the date (Critical to avoid infinite loops) indexDate = addDays(indexDate, 1); } } if (missingDayEntries === 0) { logging("", "success"); logging(`History created with ${daysFormatted} points`, "info"); } else { logging("", "warning"); logging(`Missing YF data in ${missingDayEntries} cases (using previous day)`, "warning"); } return historyPerTrade; } /** * Aggregates trade history into a per-ticker summary. * Combines multiple trades of the same stock into one view. */ function calcHistoryPerTicker(historyPerTrade: HistoryPerTrade, tickers: string[], trades: TradesDict): HistoryPerTicker { const historyPerTicker: HistoryPerTicker = {}; let daysFormatted = 0; // Get all dates and sort them const dates = Object.keys(historyPerTrade).sort(); for (const dateEntry of dates) { daysFormatted++; // Initialize aggregation object for this day const dictDaily: HistoryDailyTicker = {}; // Initialize all tickers with 0 values tickers.forEach(t => { dictDaily[t] = { current_invested: 0, total_dividends: 0, current_value: 0, current_irr: 0, total_performanance: 0, current_amount: 0, current_course: 0 }; }); // Initialize Portfolio Total dictDaily["total"] = { current_invested: 0, total_dividends: 0, current_value: 0, current_irr: 0, total_performanance: 0 }; const dailyTrades = historyPerTrade[dateEntry]; // Loop through all trades active on this day for (const tradeId in dailyTrades) { // Safety checks const tData = dailyTrades[tradeId]; if (!tData) continue; const trade = trades[tradeId]; if (!trade) continue; const ticker = trade.ticker; if (!dictDaily[ticker]) continue; const tickD = dictDaily[ticker]; // Safely default to 0 if undefined const currentAmount = tickD.current_amount ?? 0; // Accumulate (Sum) Values tickD.current_amount = currentAmount + tData.current_amount; tickD.current_invested += tData.current_invested; tickD.total_dividends += tData.total_dividends; tickD.current_value += tData.current_value; tickD.total_performanance += tData.total_performanance; tickD.current_course = tData.current_course; // Calculate Weighted Average IRR for this ticker if (tickD.current_invested === 0 && tData.current_invested === 0) { tickD.current_irr = 0; } else { const prevInvested = tickD.current_invested - tData.current_invested; const prevIrr = tickD.current_irr; if (tickD.current_invested !== 0) { tickD.current_irr = (prevIrr * prevInvested + tData.current_irr * tData.current_invested) / tickD.current_invested; } } } // Calculate Portfolio Totals (Summing up tickers) const total = dictDaily["total"]; for (const ticker of tickers) { const tData = dictDaily[ticker]; if (!tData) continue; total.total_dividends += tData.total_dividends; total.current_value += tData.current_value; total.total_performanance += tData.total_performanance; const prevTotalInvested = total.current_invested; total.current_invested += tData.current_invested; // Weighted Average IRR for Total Portfolio if (total.current_invested !== 0) { total.current_irr = (total.current_irr * prevTotalInvested + tData.current_irr * tData.current_invested) / total.current_invested; } } historyPerTicker[dateEntry] = dictDaily; } logging("", "success"); logging(`History per ticker created`, "info"); return historyPerTicker; } // --------------------------------------------------------- // 6. BENCHMARK FUNCTIONS // --------------------------------------------------------- /** * Creates 'Shadow Trades' to simulate what would have happened if * the money was invested in the benchmark ETF instead. */ async function createBenchmarkTrades(trades: TradesDict, yfData: YFDataMap): Promise { const benchmarkTrades: TradesDict = {}; let i = 0; for (const tradeId in trades) { i++; const original = trades[tradeId]; if (!original) continue; const benchId = "benchmark" + i; // LEARNING TIP: Spread Operator (...) // Copies all properties from 'original' to the new object. benchmarkTrades[benchId] = { ...original }; // Overwrite the ticker benchmarkTrades[benchId].ticker = config.ticker_benchmark; const amountInvested = original.units * original.course_open; // Logic to find valid trading day in YF data for open/close let indexDate = parseISO(original.date_open); let courseOpenNew = 0; let found = false; const benchData = yfData[config.ticker_benchmark]; // Search forward for valid data (e.g. if trade happened on Sunday, find Monday's price) while (!found) { const dStr = format(indexDate, 'yyyy-MM-dd'); if (benchData && benchData[dStr]) { courseOpenNew = benchData[dStr].close; found = true; } else { indexDate = addDays(indexDate, 1); // Safety break >10 days if (differenceInDays(indexDate, parseISO(original.date_open)) > 10) found = true; } } // Avoid division by zero benchmarkTrades[benchId].course_open = courseOpenNew || 1; benchmarkTrades[benchId].units = amountInvested / (courseOpenNew || 1); // Handle Close Logic if trade is closed if (original.date_close !== 0) { let closeDate = parseISO(original.date_close as string); found = false; let courseCloseNew = 0; while (!found) { const dStr = format(closeDate, 'yyyy-MM-dd'); if (benchData && benchData[dStr]) { courseCloseNew = benchData[dStr].close; found = true; } else { closeDate = addDays(closeDate, 1); if (differenceInDays(closeDate, parseISO(original.date_close as string)) > 10) found = true; } } benchmarkTrades[benchId].course_close = courseCloseNew; } } logging("", "success"); return benchmarkTrades; } /** * Merges the benchmark performance data into the main ticker history object. */ function mergeHistories(historyPerTicker: HistoryPerTicker, benchmarkHistory: HistoryPerTicker): HistoryPerTicker { const benchTicker = config.ticker_benchmark; let errorCount = 0; for (const date in historyPerTicker) { try { if (benchmarkHistory[date] && benchmarkHistory[date][benchTicker]) { // LEARNING TIP: Type Casting (as any) // We attach 'benchmark' to the object. TypeScript might complain if this // isn't strictly defined in the interface, so we use 'as any' to bypass the check. (historyPerTicker[date] as any)["benchmark"] = benchmarkHistory[date][benchTicker]; } } catch { errorCount++; } } if (errorCount === 0) logging("", "success"); else logging(`Merge error count: ${errorCount}`, "warning"); return historyPerTicker; } // --------------------------------------------------------- // 7. UPLOAD & SELECT FUNCTIONS // --------------------------------------------------------- /** * Generic function to update a Notion Page. */ async function notionUpdatePage(pageId: string, data: any) { const url = `https://api.notion.com/v1/pages/${pageId}`; await fetch(url, { method: 'PATCH', headers: notionHeaders as any, body: JSON.stringify({ properties: data }) }); } async function pushNotionTradesUpdate(trades: TradesDict) { let errorCount = 0; process.stdout.write(" "); for (const id in trades) { try { const trade = trades[id]; if (!trade) continue; let irrNotion = trade.irr - 1; irrNotion = Math.round(irrNotion * 10000) / 10000; const update = { "Current (€)": { number: trade.course_current }, "IRR (%)": { number: irrNotion }, "Dividends (€)": { number: trade.dividends } }; await notionUpdatePage(id, update); } catch { errorCount++; } process.stdout.write("."); await sleep(config.api_cooldown_time); } process.stdout.write(" "); if(errorCount === 0) logging("", "success"); else logging(`${errorCount} upload errors`, "warning"); } async function pushNotionInvestmentUpdate(investments: InvestmentsDict) { let errorCount = 0; process.stdout.write(" "); for (const id in investments) { try { const invest = investments[id]; if (!invest) continue; let irrNotion = invest.current_irr - 1; irrNotion = Math.round(irrNotion * 10000) / 10000; const update = { "Current (€)": { number: invest.current_value }, "IRR (%)": { number: irrNotion }, "Performance (€)": { number: invest.total_performanance }, "Dividends (€)": { number: invest.total_dividends } }; await notionUpdatePage(id, update); } catch { errorCount++; } process.stdout.write("."); await sleep(config.api_cooldown_time); } process.stdout.write(" "); if(errorCount === 0) logging("", "success"); else logging(`${errorCount} upload errors`, "warning"); } /** * Updates the 'trades' object with the most current calculated values from history. */ function selectCurrentValuePerTrade(trades: TradesDict, historyPerTrade: HistoryPerTrade): TradesDict { for (const id in trades) { try { const trade = trades[id]; if (!trade) continue; let dateIso: string; // If open, use today. If closed, use close date. if (trade.date_close === 0) { dateIso = format(new Date(), 'yyyy-MM-dd'); } else { dateIso = trade.date_close as string; } // Fallback if date not found if (!historyPerTrade[dateIso]) { dateIso = fetchLastKeyFromDict(historyPerTrade); } const h = historyPerTrade[dateIso]?.[id]; if (h) { trade.course_current = h.current_course; trade.irr = h.current_irr; trade.dividends = h.total_dividends; } } catch(e) {} } logging("", "success"); return trades; } function selectCurrentValuePerTicker(investments: InvestmentsDict, historyPerTicker: HistoryPerTicker): InvestmentsDict { const todayIso = format(new Date(), 'yyyy-MM-dd'); // Determine which date key to use const lastKey = Object.keys(historyPerTicker).length > 0 ? fetchLastKeyFromDict(historyPerTicker) : todayIso; const useDate = historyPerTicker[todayIso] ? todayIso : lastKey; for (const id in investments) { try { const invest = investments[id]; if (!invest) continue; const ticker = invest.ticker; if(historyPerTicker[useDate] && historyPerTicker[useDate][ticker]) { const h = historyPerTicker[useDate][ticker]; invest.total_dividends = h.total_dividends; invest.current_value = h.current_value; invest.current_irr = h.current_irr; invest.total_performanance = h.total_performanance; } } catch(e) {} } logging("", "success"); return investments; } /** * Filters the detailed daily history down to specific dates (e.g. Weekly) * and handles data aggregation/averaging. */ function filterHistoryByList(history: HistoryPerTicker, datesList: string[]): HistoryPerTicker { const filtered: HistoryPerTicker = {}; // Sort input arrays to ensure processing happens chronologically const sortedTargetDates = [...datesList].sort(); const allHistoryDates = Object.keys(history).sort(); let lastHistoryIdx = 0; for (let i = 0; i < sortedTargetDates.length; i++) { const targetDate = sortedTargetDates[i]; // FIX 1: Strict Check - Ensure targetDate is defined before using it as an index if (!targetDate) continue; // CHECK: Is this the last date in our list (usually Today)? const isLastDate = i === sortedTargetDates.length - 1; if (isLastDate) { // --- LOGIC FOR TODAY (Last Entry) --- // We do not want to average today's data with last week's. We want the exact latest values. // Try to find exact match let dataToUse = history[targetDate]; // If not found, use the very last entry in the history (fallback) if (!dataToUse && allHistoryDates.length > 0) { const lastAvailableDate = allHistoryDates[allHistoryDates.length - 1]; if (lastAvailableDate) { dataToUse = history[lastAvailableDate]; } } if (dataToUse) { // Deep clone the object using JSON parse/stringify to prevent reference issues filtered[targetDate] = JSON.parse(JSON.stringify(dataToUse)); } } else { // --- LOGIC FOR PAST DATES (Interval Averaging) --- let currentHistoryIdx = -1; // Find the index of the target date in the full history array for (let j = lastHistoryIdx; j < allHistoryDates.length; j++) { const hDate = allHistoryDates[j]; if (hDate && hDate <= targetDate) { currentHistoryIdx = j; } else { break; // Optimization: stop searching once we pass the date } } if (currentHistoryIdx !== -1 && currentHistoryIdx >= lastHistoryIdx) { // Get all history days between the last target and this target const intervalDates = allHistoryDates.slice(lastHistoryIdx, currentHistoryIdx + 1); if (intervalDates.length > 0) { // Initialize Aggregation Accumulator const aggregation: Record = {}; // Helper to create or retrieve aggregator for a ticker const getAgg = (t: string) => { if (!aggregation[t]) aggregation[t] = { wSumInvested: 0, wSumValue: 0, wSumPerf: 0, wSumDiv: 0, totalWeight: 0, wSumIrr: 0, irrTotalWeight: 0, bSumInvested: 0, bSumValue: 0, bSumPerf: 0, bSumDiv: 0, bTotalWeight: 0, bSumIrr: 0, bIrrTotalWeight: 0 }; return aggregation[t]; }; // Loop through every day in this interval for (const date of intervalDates) { if (!date) continue; // Safety check const dayData = history[date]; if (!dayData) continue; for (const ticker in dayData) { const entry = dayData[ticker]; // FIX 2: Strict Check - Ensure entry exists if (!entry) continue; const agg = getAgg(ticker); // Weight: If invested amount < 1, treat as 0 (ignore dust) const w = entry.current_invested > 1 ? entry.current_invested : 0; // Outlier Detection: If IRR > 500% or < -500%, ignore it const isOutlier = Math.abs(entry.current_irr) > 5.0; if (w > 0) { // Add weighted values agg.wSumInvested += entry.current_invested * w; agg.wSumValue += entry.current_value * w; agg.wSumPerf += entry.total_performanance * w; agg.wSumDiv += entry.total_dividends * w; agg.totalWeight += w; // Only add IRR if sane if (!isOutlier) { agg.wSumIrr += entry.current_irr * w; agg.irrTotalWeight += w; } } // Handle Benchmark Aggregation (Same logic) if (entry.benchmark) { const b = entry.benchmark; const bw = b.current_invested > 1 ? b.current_invested : 0; const isBenchOutlier = Math.abs(b.current_irr) > 5.0; if (bw > 0) { agg.bSumInvested += b.current_invested * bw; agg.bSumValue += b.current_value * bw; agg.bSumPerf += b.total_performanance * bw; agg.bSumDiv += b.total_dividends * bw; agg.bTotalWeight += bw; if (!isBenchOutlier) { agg.bSumIrr += b.current_irr * bw; agg.bIrrTotalWeight += bw; } } } } } // Calculate Final Weighted Averages for the interval const resultDaily: HistoryDailyTicker = {}; for (const ticker in aggregation) { const agg = aggregation[ticker]; // FIX 3: Strict Check - Ensure agg exists if (!agg) continue; const div = (n: number, d: number) => d === 0 ? 0 : n / d; const entry: TickerHistoryEntry = { current_invested: div(agg.wSumInvested, agg.totalWeight), current_value: div(agg.wSumValue, agg.totalWeight), current_irr: div(agg.wSumIrr, agg.irrTotalWeight), total_performanance: div(agg.wSumPerf, agg.totalWeight), total_dividends: div(agg.wSumDiv, agg.totalWeight), current_amount: 0, current_course: 0 }; if (agg.bTotalWeight > 0) { entry.benchmark = { current_invested: div(agg.bSumInvested, agg.bTotalWeight), current_value: div(agg.bSumValue, agg.bTotalWeight), current_irr: div(agg.bSumIrr, agg.bIrrTotalWeight), total_performanance: div(agg.bSumPerf, agg.bTotalWeight), total_dividends: div(agg.bSumDiv, agg.bTotalWeight), }; } resultDaily[ticker] = entry; } filtered[targetDate] = resultDaily; } // Update start index for next iteration lastHistoryIdx = currentHistoryIdx + 1; } } } logging("", "success"); return filtered; } /** * Formats the data for the TRMNL dashboard API. */ function prepTrmnlChartUpdate(history: HistoryPerTicker, s1: string = "total", d1: string = "current_value", s2: string = "benchmark", d2: string = "current_value") { try { const lastKey = fetchLastKeyFromDict(history); const lastEntry = history[lastKey]; if (!lastEntry || !lastEntry["total"]) { throw new Error("Missing 'total' data for TRMNL update"); } // Prepare Big Numbers (Top of dashboard) const dictBigNumbers = { current_value: Math.round(lastEntry["total"].current_value).toString(), total_performanance: Math.round(lastEntry["total"].total_performanance).toString(), current_irr: String(Math.round((lastEntry["total"].current_irr - 1) * 100 * 100) / 100) }; const chart1Data: any[] = []; const chart2Data: any[] = []; const sortedDates = Object.keys(history).sort(); // Convert history map to Chart Data Array for (const date of sortedDates) { const getVal = (series: string, field: string) => { let val = 0; const dailyData = history[date]; // Strict check: Data must exist if (!dailyData) return 0; if (series === 'benchmark') { // Safe access using Optional Chaining and Nullish Coalescing val = (dailyData as any)['benchmark']?.[field] ?? 0; } else { val = (dailyData as any)[series]?.[field] ?? 0; } if (field === 'current_irr') val = (val - 1) * 100; return Math.round(val * 100) / 100; }; const val1 = getVal(s1, d1); const val2 = getVal(s2, d2); // Format: ISO date + Time (required by TRMNL) const jsonDate = date + "T00:00:00"; chart1Data.push([jsonDate, val1]); chart2Data.push([jsonDate, val2]); } // Construct Titles let t1 = s1 === "total" ? "Portfolio" : s1; if (s1 === "benchmark") t1 = `Benchmark: ${config.ticker_benchmark}`; let t2 = s2 === "total" ? "Portfolio" : s2; if (s2 === "benchmark") t2 = `Benchmark: ${config.ticker_benchmark}`; const cleanName = (n: string) => n.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase()).replace("Irr", "IRR"); return { merge_variables: { big_numbers: dictBigNumbers, charts: [ { name: `${cleanName(d1)} ${t1}`, data: chart1Data }, { name: `${cleanName(d2)} ${t2}`, data: chart2Data } ] } }; } catch (e) { logging(`${e}`, "error"); return false; } } /** * Sends the formatted data to the TRMNL API. */ async function pushTrmnlUpdateChart(data: any, url: string) { try { const res = await fetch(url, { method: 'POST', headers: trmnlHeaders, body: JSON.stringify(data) }); if (res.status === 200) logging("", "success"); else if (res.status === 429) logging("Rate limit exceeded", "warning"); else if (res.status === 422) logging("Payload too large/invalid", "warning"); else logging(`TRMNL Error ${res.status}`, "error"); } catch (e) { logging(`${e}`, "error"); } } // --------------------------------------------------------- // 8. MAIN EXECUTION LOOP // --------------------------------------------------------- /** * Main entry point. * LEARNING TIP: Async Main * Top-level code cannot normally use 'await' (in older Node versions). * Wrapping logic in an async function allows us to use await for all our API calls. */ async function main() { console.log("Starting TS-Ported Financial Service..."); // Infinite Loop to keep service running while (true) { try { // --- PART 1: NOTION TRADES --- process.stdout.write("Fetching Data from Notion... "); let trades = await fetchFormatNotionTrades(config.notion_db_id_trades); process.stdout.write("Creating unique tickers... "); let tickers = filterListOfTickers(trades); if (config.calculate_benchmark) { process.stdout.write("Adding benchmark... "); tickers = addBenchmarkTicker(tickers, config.ticker_benchmark); } process.stdout.write("Fetching YFinance data"); const yfData = await fetchFormatYfData(tickers); process.stdout.write("Calculating history per trade... "); const historyPerTrade = calcHistoryPerTrade(trades, yfData); if (config.update_notion) { process.stdout.write("Selecting current values... "); trades = selectCurrentValuePerTrade(trades, historyPerTrade); process.stdout.write("Updating Notion Trades"); await pushNotionTradesUpdate(trades); } // --- PART 2: NOTION INVESTMENTS --- process.stdout.write("Fetching Notion Investments... "); let investments = await fetchFormatNotionInvestments(config.notion_db_id_investments); process.stdout.write("Calculating history per ticker... "); let historyPerTicker = calcHistoryPerTicker(historyPerTrade, tickers, trades); if (config.update_notion) { process.stdout.write("Calculating current ticker values... "); investments = selectCurrentValuePerTicker(investments, historyPerTicker); process.stdout.write("Updating Notion Investments"); await pushNotionInvestmentUpdate(investments); } // --- PART 3: BENCHMARK --- if (config.calculate_benchmark) { process.stdout.write("Creating benchmark trades... "); const benchmarkTrades = await createBenchmarkTrades(trades, yfData); process.stdout.write("Calculating benchmark history per trade... "); const historyBenchTrade = calcHistoryPerTrade(benchmarkTrades, yfData); process.stdout.write("Calculating benchmark history overall... "); const historyBench = calcHistoryPerTicker(historyBenchTrade, tickers, benchmarkTrades); process.stdout.write("Merging histories... "); historyPerTicker = mergeHistories(historyPerTicker, historyBench); } // --- PART 4: TRMNL --- if (config.update_TRMNL) { process.stdout.write("Creating weekly dates... "); const datesList = createListFilteredDates(trades, config.trmnl_granularity); process.stdout.write("Filtering history... "); const historyFiltered = filterHistoryByList(historyPerTicker, datesList); // Chart 1: Value process.stdout.write("Pushing Chart 1 (Value)... "); const data1 = prepTrmnlChartUpdate(historyFiltered, "total", "current_value", "benchmark", "current_value"); if(data1) await pushTrmnlUpdateChart(data1, config.trmnl_url_chart_1); // Chart 2: IRR process.stdout.write("Pushing Chart 2 (IRR)... "); const data2 = prepTrmnlChartUpdate(historyFiltered, "total", "current_irr", "benchmark", "current_irr"); if(data2) await pushTrmnlUpdateChart(data2, config.trmnl_url_chart_2); // Chart 3: Performance process.stdout.write("Pushing Chart 3 (Perf)... "); const data3 = prepTrmnlChartUpdate(historyFiltered, "total", "total_performanance", "benchmark", "total_performanance"); if(data3) await pushTrmnlUpdateChart(data3, config.trmnl_url_chart_3); } // --- PART 5: COOLDOWN --- // Clear vars for Garbage Collection trades = {}; investments = {}; console.log(`Completed cycle at: ${new Date().toISOString()}`); console.log("------------------------------------------------"); // Wait before next execution await sleep(config.programm_cooldown_time * 60 * 1000); } catch (e) { console.error("CRITICAL MAIN LOOP ERROR:", e); console.log("Retrying in 1 minute..."); await sleep(60000); } } } // Start the application main();