1466 lines
53 KiB
TypeScript
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(); |