Files
Notion-Performance-Tracker/typescript version (deprecated)/main.ts

1466 lines
53 KiB
TypeScript

/**
* --------------------------------------------------------------------------------
* 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<any[]> because Notion objects are complex and we parse them later.
*/
async function notionGetPages(dbId: string, numPages: number | null = null): Promise<any[]> {
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<TradesDict> {
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<InvestmentsDict> {
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<YFDataMap> {
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<TradesDict> {
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<string, {
wSumInvested: number; wSumValue: number; wSumPerf: number; wSumDiv: number;
totalWeight: number;
wSumIrr: number;
irrTotalWeight: number;
bSumInvested: number; bSumValue: number; bSumPerf: number; bSumDiv: number;
bTotalWeight: number;
bSumIrr: number;
bIrrTotalWeight: number;
}> = {};
// 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();