main.ts 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466
  1. /**
  2. * --------------------------------------------------------------------------------
  3. * FINANCIAL PORTFOLIO TRACKER (TypeScript Port)
  4. * --------------------------------------------------------------------------------
  5. * * OVERVIEW:
  6. * This script fetches financial trading data from a Notion database, retrieves
  7. * historical price data from Yahoo Finance, calculates performance metrics
  8. * (IRR, current value, dividends), and pushes the results back to Notion
  9. * and a TRMNL dashboard.
  10. * * TYPESCRIPT CONCEPTS COVERED:
  11. * - Interfaces & Type Definitions
  12. * - Async/Await & Promises
  13. * - Strict Null Checks & Type Guards
  14. * - Optional Chaining (?.) and Nullish Coalescing (??)
  15. * - Type Assertions (as ...)
  16. * - External Module Imports
  17. */
  18. // ---------------------------------------------------------
  19. // 0. IMPORTS
  20. // ---------------------------------------------------------
  21. // LEARNING TIP: Default vs Named Imports
  22. // 'yahooFinance' is a default import (export default ...).
  23. // '{ addDays, ... }' are named imports. You must use the exact name exported by the library.
  24. import yahooFinance from 'yahoo-finance2';
  25. import {
  26. addDays,
  27. differenceInDays,
  28. format,
  29. parseISO,
  30. subDays,
  31. isBefore,
  32. isAfter,
  33. isEqual,
  34. startOfDay,
  35. isWeekend
  36. } from 'date-fns';
  37. // ---------------------------------------------------------
  38. // 1. CONFIGURATION
  39. // ---------------------------------------------------------
  40. // LEARNING TIP: 'as const' Assertion
  41. // By adding `as const`, we tell TypeScript that these are READ-ONLY LITERALS.
  42. // Instead of being type 'string', LOG_COLORS.error is literally the string '\x1b[31m'.
  43. // This prevents accidental modification and allows us to create specific Types from keys.
  44. const LOG_COLORS = {
  45. error: '\x1b[31m', // Red
  46. warning: '\x1b[33m', // Yellow
  47. success: '\x1b[32m', // Green
  48. info: '\x1b[90m', // Gray
  49. debug: '\x1b[4m', // Underscore
  50. endcode: '\x1b[0m' // Reset
  51. } as const;
  52. // LEARNING TIP: Lookup Types (keyof typeof)
  53. // We create a new Type 'LogColorKey' that can ONLY be one of: "error" | "warning" | "success" | ...
  54. // This gives us autocomplete support when using these keys later.
  55. type LogColorKey = keyof typeof LOG_COLORS;
  56. const LOGGING_LEVELS = ["none", "error", "success", "warning", "info", "debug"];
  57. // Mutable config object (standard JavaScript object)
  58. const config = {
  59. // Functionality Switches
  60. update_notion: false,
  61. update_TRMNL: true,
  62. calculate_benchmark: true,
  63. // Configuration
  64. programm_cooldown_time: 15, // Minutes
  65. api_cooldown_time: 100, // Milliseconds
  66. trmnl_granularity: 80, // Days
  67. ticker_benchmark: "VGWL.DE",
  68. // Logging
  69. selected_logging_level: "warning",
  70. // API - NOTION
  71. notion_token: "secret_b7PiPL2FqC9QEikqkAEWOht7LmzPMIJMWTzUPWwbw4H",
  72. notion_db_id_trades: "95f7a2b697a249d4892d60d855d31bda",
  73. notion_db_id_investments: "2ba10a5f51bd8160ab9ee982bbef8cc3",
  74. notion_db_id_performance: "1c010a5f51bd806f90d8e76a1286cfd4",
  75. // API - TRMNL
  76. trmnl_url_chart_1: "https://usetrmnl.com/api/custom_plugins/334ea2ed-1f20-459a-bea5-dca2c8cf7714",
  77. trmnl_url_chart_2: "https://usetrmnl.com/api/custom_plugins/72950759-38df-49eb-99fb-b3e2e67c385e",
  78. trmnl_url_chart_3: "https://usetrmnl.com/api/custom_plugins/a975543a-51dc-4793-b7fa-d6a101dc4025",
  79. };
  80. // Headers configuration for API requests
  81. const notionHeaders = {
  82. "Authorization": "Bearer " + config.notion_token,
  83. "Content-Type": "application/json",
  84. "Notion-Version": "2022-02-22"
  85. };
  86. const trmnlHeaders = { "Content-Type": "application/json" };
  87. // ---------------------------------------------------------
  88. // 2. INTERFACES (Type Definitions)
  89. // ---------------------------------------------------------
  90. // LEARNING TIP: Interfaces
  91. // Interfaces describe the "Shape" of an object. TypeScript uses "Structural Typing".
  92. // If an object has the properties defined here, it is considered a 'Trade', even if
  93. // we didn't explicitly say `new Trade()`.
  94. interface Trade {
  95. ticker: string;
  96. date_open: string; // ISO Date string YYYY-MM-DD
  97. // LEARNING TIP: Union Types
  98. // 'date_close' can be a string OR the number 0.
  99. // TypeScript forces us to check which one it is before using it (Type Narrowing).
  100. date_close: string | 0;
  101. course_open: number;
  102. course_close: number;
  103. course_current: number;
  104. irr: number;
  105. units: number;
  106. dividends: number;
  107. }
  108. // LEARNING TIP: Index Signatures
  109. // 'TradesDict' is an object where the Keys are strings (IDs) and Values are 'Trade' objects.
  110. // This is used for Hash Maps / Dictionaries.
  111. interface TradesDict {
  112. [key: string]: Trade;
  113. }
  114. interface Investment {
  115. ticker: string;
  116. total_dividends: number;
  117. current_value: number;
  118. current_irr: number;
  119. total_performanance: number;
  120. }
  121. interface InvestmentsDict {
  122. [key: string]: Investment;
  123. }
  124. interface HistoryEntry {
  125. current_amount: number;
  126. current_invested: number;
  127. total_dividends: number;
  128. current_value: number;
  129. current_irr: number;
  130. current_course: number;
  131. total_performanance: number;
  132. }
  133. interface HistoryDailyTrade {
  134. [tradeId: string]: HistoryEntry;
  135. }
  136. interface HistoryPerTrade {
  137. [date: string]: HistoryDailyTrade;
  138. }
  139. interface TickerHistoryEntry {
  140. current_invested: number;
  141. total_dividends: number;
  142. current_value: number;
  143. current_irr: number;
  144. total_performanance: number;
  145. // LEARNING TIP: Optional Properties (?)
  146. // The '?' indicates these properties might NOT exist on the object.
  147. // When accessing them, the result is 'number | undefined'.
  148. current_amount?: number;
  149. current_course?: number;
  150. // Recursive Type: An entry can contain a nested benchmark entry
  151. benchmark?: TickerHistoryEntry;
  152. }
  153. interface HistoryDailyTicker {
  154. [ticker: string]: TickerHistoryEntry;
  155. }
  156. interface HistoryPerTicker {
  157. [date: string]: HistoryDailyTicker;
  158. }
  159. // Structure for Yahoo Finance Data map
  160. interface YFDataMap {
  161. [ticker: string]: {
  162. [date: string]: {
  163. close: number;
  164. dividends: number;
  165. }
  166. }
  167. }
  168. // ---------------------------------------------------------
  169. // 3. HELPER FUNCTIONS
  170. // ---------------------------------------------------------
  171. /**
  172. * Pauses execution for a set amount of time.
  173. * LEARNING TIP: Promises
  174. * JavaScript is single-threaded. 'setTimeout' schedules code to run later.
  175. * We wrap it in a Promise so we can use 'await sleep(1000)' to pause execution flow.
  176. */
  177. function sleep(ms: number) {
  178. return new Promise(resolve => setTimeout(resolve, ms));
  179. }
  180. /**
  181. * Prints messages to the console with colors.
  182. * Demonstrates default parameter values (newLine = true).
  183. */
  184. function logging(message = "", loggingLevel = "", newLine = true) {
  185. const configLevelIdx = LOGGING_LEVELS.indexOf(config.selected_logging_level);
  186. let messageLevelIdx = LOGGING_LEVELS.indexOf(loggingLevel);
  187. if (messageLevelIdx === -1) {
  188. messageLevelIdx = LOGGING_LEVELS.length;
  189. }
  190. if (messageLevelIdx <= configLevelIdx) {
  191. // LEARNING TIP: Type Assertion (as ...)
  192. // We know 'loggingLevel' is a string, but we need to tell TypeScript
  193. // that it matches one of the keys in LOG_COLORS to allow index access.
  194. const color = LOG_COLORS[loggingLevel as LogColorKey] || LOG_COLORS.info;
  195. const logText = `${color}[${loggingLevel}] ${LOG_COLORS.endcode}${message}`;
  196. if (newLine) {
  197. console.log(logText);
  198. } else {
  199. // process.stdout.write prints without a newline character at the end
  200. process.stdout.write(logText + " ");
  201. }
  202. }
  203. }
  204. /**
  205. * Calculates Internal Rate of Return (IRR) approximation.
  206. */
  207. function calculateIrr(dateNow: Date, dateOpen: Date, valueNow: number, valueOpen: number): number {
  208. let irr = 0.0;
  209. try {
  210. let daysDiff = differenceInDays(dateNow, dateOpen);
  211. if (daysDiff === 0) daysDiff = 1; // Prevent division by zero logic
  212. const years = daysDiff / 365.0;
  213. let ratio = valueNow / valueOpen;
  214. // Basic Annualized Return Formula logic
  215. if (ratio < 0) {
  216. ratio = ratio * -1;
  217. irr = Math.pow(ratio, 1 / years);
  218. irr = irr * -1;
  219. } else {
  220. irr = Math.pow(ratio, 1 / years);
  221. }
  222. return irr;
  223. } catch (e) {
  224. logging("Calculation of irr failed", "error");
  225. return 0.0;
  226. }
  227. }
  228. /**
  229. * Finds the oldest date in a dictionary of trades.
  230. */
  231. function getDateOpenOldestTrade(trades: TradesDict): Date {
  232. let oldest = new Date(); // Start with today
  233. for (const key in trades) {
  234. const trade = trades[key];
  235. // LEARNING TIP: Type Guard
  236. // In strict mode, dictionary access might return undefined.
  237. // We check 'if (!trade) continue' to ensure 'trade' exists before using it.
  238. if (!trade) continue;
  239. const d = parseISO(trade.date_open);
  240. // date-fns comparison function
  241. if (isBefore(d, oldest)) {
  242. oldest = d;
  243. }
  244. }
  245. return oldest;
  246. }
  247. /**
  248. * Extracts a unique list of ticker symbols from trades.
  249. */
  250. function filterListOfTickers(trades: TradesDict): string[] {
  251. const tickers: string[] = [];
  252. try {
  253. for (const key in trades) {
  254. const trade = trades[key];
  255. if (!trade) continue; // Safety check
  256. const t = trade.ticker;
  257. // Only add if not already in list
  258. if (!tickers.includes(t)) {
  259. tickers.push(t);
  260. }
  261. }
  262. logging("", "success");
  263. logging(`${tickers.length} tickers found`, "info");
  264. return tickers;
  265. } catch (e) {
  266. logging(`${e}`, "error");
  267. return [];
  268. }
  269. }
  270. /**
  271. * Creates a list of dates (e.g., weekly) from the oldest trade to today.
  272. */
  273. function createListFilteredDates(trades: TradesDict, daysSeperation: number): string[] {
  274. try {
  275. const stopDate = getDateOpenOldestTrade(trades);
  276. let indexDate = new Date();
  277. const listFilteredDates: string[] = [];
  278. // Loop backwards in time
  279. while (isAfter(indexDate, stopDate) || isEqual(indexDate, stopDate)) {
  280. listFilteredDates.push(format(indexDate, 'yyyy-MM-dd'));
  281. indexDate = subDays(indexDate, daysSeperation);
  282. }
  283. // Reverse to get chronological order (Oldest -> Newest)
  284. listFilteredDates.reverse();
  285. logging("", "success");
  286. logging(`${listFilteredDates.length} dates in weekly list`, "info");
  287. return listFilteredDates;
  288. } catch (e) {
  289. logging(`${e}`, "error");
  290. return [];
  291. }
  292. }
  293. function addBenchmarkTicker(tickers: string[], tickerBenchmark: string): string[] {
  294. if (!tickers.includes(tickerBenchmark)) {
  295. tickers.push(tickerBenchmark);
  296. }
  297. logging("", "success");
  298. return tickers;
  299. }
  300. /**
  301. * Helper to get the very last key in an object (assumes keys are sortable strings like dates).
  302. */
  303. function fetchLastKeyFromDict(dict: any): string {
  304. const keys = Object.keys(dict).sort();
  305. if (keys.length === 0) {
  306. throw new Error("Dictionary is empty, cannot fetch last key.");
  307. }
  308. const lastKey = keys[keys.length - 1];
  309. // LEARNING TIP: Strict Null Checks
  310. // Even though logic suggests lastKey exists, TS Strict Mode demands we verify it isn't undefined.
  311. if (lastKey === undefined) {
  312. throw new Error("Last key was undefined");
  313. }
  314. return lastKey;
  315. }
  316. // ---------------------------------------------------------
  317. // 4. NETWORK DOWNLOAD FUNCTIONS
  318. // ---------------------------------------------------------
  319. /**
  320. * Fetches pages from a Notion Database. Handles pagination automatically.
  321. * Returns Promise<any[]> because Notion objects are complex and we parse them later.
  322. */
  323. async function notionGetPages(dbId: string, numPages: number | null = null): Promise<any[]> {
  324. try {
  325. const url = `https://api.notion.com/v1/databases/${dbId}/query`;
  326. const getAll = numPages === null;
  327. const pageSize = getAll ? 100 : numPages;
  328. let payload: any = { page_size: pageSize };
  329. let results: any[] = [];
  330. let hasMore = true;
  331. let nextCursor = null;
  332. // Loop to fetch all pages if there are more than 100
  333. while (hasMore) {
  334. if (nextCursor) {
  335. payload['start_cursor'] = nextCursor;
  336. }
  337. // Using standard Fetch API
  338. const response = await fetch(url, {
  339. method: 'POST',
  340. headers: notionHeaders as any,
  341. body: JSON.stringify(payload)
  342. });
  343. if (!response.ok) throw new Error(`Notion API error: ${response.status}`);
  344. const data = await response.json();
  345. results.push(...data.results);
  346. // Check if Notion says there are more pages
  347. hasMore = data.has_more && getAll;
  348. nextCursor = data.next_cursor;
  349. if (!getAll && results.length >= (numPages || 0)) break;
  350. }
  351. return results;
  352. } catch (e) {
  353. logging("Notion Fetch Error", "error");
  354. return [];
  355. }
  356. }
  357. /**
  358. * Fetches trades from Notion and formats them into our 'TradesDict' interface.
  359. */
  360. async function fetchFormatNotionTrades(dbIdTrades: string): Promise<TradesDict> {
  361. const trades: TradesDict = {};
  362. let formatErrors = 0;
  363. let numberOfTrades = 0;
  364. // Await the asynchronous fetch
  365. const data = await notionGetPages(dbIdTrades);
  366. if (data.length === 0) return {};
  367. for (const page of data) {
  368. numberOfTrades++;
  369. try {
  370. // Extract properties from Notion's specific JSON structure
  371. const props = page.properties;
  372. // LEARNING TIP: Optional Chaining (?.)
  373. // props["Close"] might not exist. If it doesn't, ?. returns undefined immediately
  374. // instead of crashing with "Cannot read property 'date' of undefined".
  375. let dateClose: string | 0 = 0;
  376. if (props["Close"]?.date?.start) {
  377. dateClose = props["Close"].date.start;
  378. }
  379. const dateOpen = props["Open"]?.date?.start;
  380. if (!dateOpen) throw new Error("Missing Open Date");
  381. // LEARNING TIP: Nullish Coalescing (??)
  382. // If the value on the left is null or undefined, use the default value on the right.
  383. const trade: Trade = {
  384. ticker: props["Ticker"]?.select?.name ?? "UNKNOWN",
  385. date_open: dateOpen,
  386. date_close: dateClose,
  387. course_open: props["Open (€)"]?.number ?? 0,
  388. course_close: props["Close (€)"]?.number ?? 0,
  389. course_current: props["Current (€)"]?.number ?? 0,
  390. irr: props["IRR (%)"]?.number ?? 0,
  391. units: props["Units"]?.number ?? 0,
  392. dividends: props["Dividends (€)"]?.number ?? 0
  393. };
  394. trades[page.id] = trade;
  395. } catch (e) {
  396. formatErrors++;
  397. }
  398. }
  399. if (formatErrors === 0) {
  400. logging("", "success");
  401. logging(`${numberOfTrades} trades received and formatted`, "info");
  402. } else {
  403. logging("", "warning");
  404. logging(`${formatErrors} trades skipped out of ${numberOfTrades}`, "warning");
  405. }
  406. return trades;
  407. }
  408. /**
  409. * Similar to above, but fetches the Investment Overview database.
  410. */
  411. async function fetchFormatNotionInvestments(dbIdInvestments: string): Promise<InvestmentsDict> {
  412. const investments: InvestmentsDict = {};
  413. let formatErrors = 0;
  414. let numberOfInvestments = 0;
  415. const data = await notionGetPages(dbIdInvestments);
  416. for (const page of data) {
  417. numberOfInvestments++;
  418. try {
  419. const props = page.properties;
  420. investments[page.id] = {
  421. ticker: props["Ticker"]?.select?.name ?? "UNKNOWN",
  422. total_dividends: props["Dividends (€)"]?.number ?? 0,
  423. current_value: props["Current (€)"]?.number ?? 0,
  424. current_irr: props["IRR (%)"]?.number ?? 0,
  425. total_performanance: props["Performance (€)"]?.number ?? 0
  426. };
  427. } catch (e) {
  428. formatErrors++;
  429. }
  430. }
  431. if (formatErrors === 0) {
  432. logging("", "success");
  433. logging(`${numberOfInvestments} investments received`, "info");
  434. } else {
  435. logging("", "warning");
  436. logging(`${formatErrors} investments skipped`, "warning");
  437. }
  438. return investments;
  439. }
  440. /**
  441. * Fetches historical price data for all tickers from Yahoo Finance.
  442. */
  443. async function fetchFormatYfData(tickers: string[]): Promise<YFDataMap> {
  444. const yfData: YFDataMap = {};
  445. let fetchErrors = 0;
  446. let formatErrors = 0;
  447. let numberOfTickers = 0;
  448. // LEARNING TIP: Runtime Environment Check & Dynamic Instantiation
  449. // Sometimes Node imports 'yahooFinance' as a Class constructor, sometimes as an Instance object,
  450. // depending on the exact version and module settings (CommonJS vs ESM).
  451. // This code checks if it's a function (Class). If so, it creates a 'new' instance.
  452. const yf = typeof yahooFinance === 'function'
  453. ? new (yahooFinance as any)({ suppressNotices: ['ripHistorical'] })
  454. : yahooFinance;
  455. process.stdout.write(" ");
  456. for (const ticker of tickers) {
  457. numberOfTickers++;
  458. try {
  459. const queryOptions = {
  460. period1: '2000-01-01',
  461. period2: format(new Date(), 'yyyy-MM-dd')
  462. };
  463. // Await the API call
  464. const result = await yf.historical(ticker, queryOptions);
  465. // LEARNING TIP: Local Variable for Strict Typing
  466. // We build the data map in a local variable first.
  467. // Writing directly to yfData[ticker][date] would cause TypeScript errors
  468. // because yfData[ticker] might be undefined in the compiler's eyes.
  469. const tickerDataMap: { [date: string]: { close: number; dividends: number } } = {};
  470. // Explicitly typing 'row' as any to handle incoming data flexbility
  471. result.forEach((row: any) => {
  472. const dateStr = format(row.date, 'yyyy-MM-dd');
  473. tickerDataMap[dateStr] = {
  474. close: row.close,
  475. dividends: 0
  476. };
  477. });
  478. // Assign the completed map to the main object
  479. yfData[ticker] = tickerDataMap;
  480. } catch (e) {
  481. fetchErrors++;
  482. // Check if 'e' is an Error object to safely access .message
  483. console.log(`\n[Error fetching ${ticker}]: ${e instanceof Error ? e.message : String(e)}`);
  484. }
  485. process.stdout.write(".");
  486. // Pause to avoid hitting API Rate Limits
  487. await sleep(config.api_cooldown_time);
  488. }
  489. process.stdout.write(" ");
  490. if (fetchErrors === 0 && formatErrors === 0) {
  491. logging("", "success");
  492. logging(`${numberOfTickers} tickers received`, "info");
  493. } else {
  494. logging("", "warning");
  495. logging(`${fetchErrors} fetch errors`, "warning");
  496. }
  497. return yfData;
  498. }
  499. // ---------------------------------------------------------
  500. // 5. CALCULATION FUNCTIONS
  501. // ---------------------------------------------------------
  502. /**
  503. * Calculates daily performance history for every single trade.
  504. * Iterates day-by-day from the trade opening date to today.
  505. */
  506. function calcHistoryPerTrade(trades: TradesDict, yfData: YFDataMap): HistoryPerTrade {
  507. const historyPerTrade: HistoryPerTrade = {};
  508. let missingDayEntries = 0;
  509. let daysFormatted = 0;
  510. let numberOfTrades = 0;
  511. const dateOpenOldestTrade = getDateOpenOldestTrade(trades);
  512. // Loop over every trade
  513. for (const tradeId in trades) {
  514. numberOfTrades++;
  515. // Strict check: Ensure trade exists
  516. const trade = trades[tradeId];
  517. if (!trade) continue;
  518. // Start from the oldest trade date across the board
  519. let indexDate = startOfDay(new Date(dateOpenOldestTrade));
  520. const today = startOfDay(new Date());
  521. let previousCourse = 0.0;
  522. // Accumulator for dividends specific to this trade
  523. let runningTotalDividends = 0.0;
  524. // Determine closure date (or tomorrow if currently active)
  525. let dateCloseObj = (trade.date_close === 0) ? addDays(today, 1) : parseISO(trade.date_close as string);
  526. const dateOpenObj = parseISO(trade.date_open);
  527. // INNER LOOP: Iterate through every day from start to today
  528. while (isBefore(indexDate, addDays(today, 1))) {
  529. daysFormatted++;
  530. const indexDateIso = format(indexDate, 'yyyy-MM-dd');
  531. let currentCourse = 0.0;
  532. let currentDividendsPerTicker = 0.0;
  533. // 1. Get Price Data
  534. // We check if tickerData exists first to avoid undefined errors
  535. const tickerData = yfData[trade.ticker];
  536. if (tickerData && tickerData[indexDateIso]) {
  537. currentCourse = tickerData[indexDateIso].close;
  538. currentDividendsPerTicker = tickerData[indexDateIso].dividends;
  539. } else {
  540. // Fallback: Use previous day's price
  541. currentCourse = previousCourse;
  542. // FIX: Only count as "missing" if it is NOT a weekend
  543. if (!isWeekend(indexDate)) {
  544. missingDayEntries++;
  545. }
  546. }
  547. // 2. Handle Closed Trades (Lock price to the closing price)
  548. if (isEqual(indexDate, dateCloseObj)) {
  549. currentCourse = trade.course_close;
  550. }
  551. previousCourse = currentCourse;
  552. // 3. Calculate Performance Metrics
  553. let currentAmount = 0;
  554. let currentInvested = 0;
  555. let currentVal = 0;
  556. let currentIrr = 0;
  557. let totalPerf = 0;
  558. // Only calculate if the current date is within the trade's lifespan
  559. if ((isAfter(indexDate, dateOpenObj) || isEqual(indexDate, dateOpenObj)) &&
  560. (isBefore(indexDate, dateCloseObj) || isEqual(indexDate, dateCloseObj))) {
  561. currentAmount = trade.units;
  562. currentInvested = currentAmount * trade.course_open;
  563. runningTotalDividends += currentAmount * currentDividendsPerTicker;
  564. currentVal = currentAmount * currentCourse;
  565. const currentValWithDiv = currentVal + runningTotalDividends;
  566. currentIrr = calculateIrr(indexDate, dateOpenObj, currentValWithDiv, currentInvested);
  567. totalPerf = currentValWithDiv - currentInvested;
  568. }
  569. // 4. Store Results in History Map
  570. // Initialize day object if missing
  571. if (!historyPerTrade[indexDateIso]) historyPerTrade[indexDateIso] = {};
  572. historyPerTrade[indexDateIso][tradeId] = {
  573. current_amount: currentAmount,
  574. current_invested: currentInvested,
  575. total_dividends: runningTotalDividends,
  576. current_value: currentVal,
  577. current_irr: currentIrr,
  578. current_course: currentCourse,
  579. total_performanance: totalPerf
  580. };
  581. // Increment the date (Critical to avoid infinite loops)
  582. indexDate = addDays(indexDate, 1);
  583. }
  584. }
  585. if (missingDayEntries === 0) {
  586. logging("", "success");
  587. logging(`History created with ${daysFormatted} points`, "info");
  588. } else {
  589. logging("", "warning");
  590. logging(`Missing YF data in ${missingDayEntries} cases (using previous day)`, "warning");
  591. }
  592. return historyPerTrade;
  593. }
  594. /**
  595. * Aggregates trade history into a per-ticker summary.
  596. * Combines multiple trades of the same stock into one view.
  597. */
  598. function calcHistoryPerTicker(historyPerTrade: HistoryPerTrade, tickers: string[], trades: TradesDict): HistoryPerTicker {
  599. const historyPerTicker: HistoryPerTicker = {};
  600. let daysFormatted = 0;
  601. // Get all dates and sort them
  602. const dates = Object.keys(historyPerTrade).sort();
  603. for (const dateEntry of dates) {
  604. daysFormatted++;
  605. // Initialize aggregation object for this day
  606. const dictDaily: HistoryDailyTicker = {};
  607. // Initialize all tickers with 0 values
  608. tickers.forEach(t => {
  609. dictDaily[t] = {
  610. current_invested: 0,
  611. total_dividends: 0,
  612. current_value: 0,
  613. current_irr: 0,
  614. total_performanance: 0,
  615. current_amount: 0,
  616. current_course: 0
  617. };
  618. });
  619. // Initialize Portfolio Total
  620. dictDaily["total"] = {
  621. current_invested: 0,
  622. total_dividends: 0,
  623. current_value: 0,
  624. current_irr: 0,
  625. total_performanance: 0
  626. };
  627. const dailyTrades = historyPerTrade[dateEntry];
  628. // Loop through all trades active on this day
  629. for (const tradeId in dailyTrades) {
  630. // Safety checks
  631. const tData = dailyTrades[tradeId];
  632. if (!tData) continue;
  633. const trade = trades[tradeId];
  634. if (!trade) continue;
  635. const ticker = trade.ticker;
  636. if (!dictDaily[ticker]) continue;
  637. const tickD = dictDaily[ticker];
  638. // Safely default to 0 if undefined
  639. const currentAmount = tickD.current_amount ?? 0;
  640. // Accumulate (Sum) Values
  641. tickD.current_amount = currentAmount + tData.current_amount;
  642. tickD.current_invested += tData.current_invested;
  643. tickD.total_dividends += tData.total_dividends;
  644. tickD.current_value += tData.current_value;
  645. tickD.total_performanance += tData.total_performanance;
  646. tickD.current_course = tData.current_course;
  647. // Calculate Weighted Average IRR for this ticker
  648. if (tickD.current_invested === 0 && tData.current_invested === 0) {
  649. tickD.current_irr = 0;
  650. } else {
  651. const prevInvested = tickD.current_invested - tData.current_invested;
  652. const prevIrr = tickD.current_irr;
  653. if (tickD.current_invested !== 0) {
  654. tickD.current_irr = (prevIrr * prevInvested + tData.current_irr * tData.current_invested) / tickD.current_invested;
  655. }
  656. }
  657. }
  658. // Calculate Portfolio Totals (Summing up tickers)
  659. const total = dictDaily["total"];
  660. for (const ticker of tickers) {
  661. const tData = dictDaily[ticker];
  662. if (!tData) continue;
  663. total.total_dividends += tData.total_dividends;
  664. total.current_value += tData.current_value;
  665. total.total_performanance += tData.total_performanance;
  666. const prevTotalInvested = total.current_invested;
  667. total.current_invested += tData.current_invested;
  668. // Weighted Average IRR for Total Portfolio
  669. if (total.current_invested !== 0) {
  670. total.current_irr = (total.current_irr * prevTotalInvested + tData.current_irr * tData.current_invested) / total.current_invested;
  671. }
  672. }
  673. historyPerTicker[dateEntry] = dictDaily;
  674. }
  675. logging("", "success");
  676. logging(`History per ticker created`, "info");
  677. return historyPerTicker;
  678. }
  679. // ---------------------------------------------------------
  680. // 6. BENCHMARK FUNCTIONS
  681. // ---------------------------------------------------------
  682. /**
  683. * Creates 'Shadow Trades' to simulate what would have happened if
  684. * the money was invested in the benchmark ETF instead.
  685. */
  686. async function createBenchmarkTrades(trades: TradesDict, yfData: YFDataMap): Promise<TradesDict> {
  687. const benchmarkTrades: TradesDict = {};
  688. let i = 0;
  689. for (const tradeId in trades) {
  690. i++;
  691. const original = trades[tradeId];
  692. if (!original) continue;
  693. const benchId = "benchmark" + i;
  694. // LEARNING TIP: Spread Operator (...)
  695. // Copies all properties from 'original' to the new object.
  696. benchmarkTrades[benchId] = { ...original };
  697. // Overwrite the ticker
  698. benchmarkTrades[benchId].ticker = config.ticker_benchmark;
  699. const amountInvested = original.units * original.course_open;
  700. // Logic to find valid trading day in YF data for open/close
  701. let indexDate = parseISO(original.date_open);
  702. let courseOpenNew = 0;
  703. let found = false;
  704. const benchData = yfData[config.ticker_benchmark];
  705. // Search forward for valid data (e.g. if trade happened on Sunday, find Monday's price)
  706. while (!found) {
  707. const dStr = format(indexDate, 'yyyy-MM-dd');
  708. if (benchData && benchData[dStr]) {
  709. courseOpenNew = benchData[dStr].close;
  710. found = true;
  711. } else {
  712. indexDate = addDays(indexDate, 1);
  713. // Safety break >10 days
  714. if (differenceInDays(indexDate, parseISO(original.date_open)) > 10) found = true;
  715. }
  716. }
  717. // Avoid division by zero
  718. benchmarkTrades[benchId].course_open = courseOpenNew || 1;
  719. benchmarkTrades[benchId].units = amountInvested / (courseOpenNew || 1);
  720. // Handle Close Logic if trade is closed
  721. if (original.date_close !== 0) {
  722. let closeDate = parseISO(original.date_close as string);
  723. found = false;
  724. let courseCloseNew = 0;
  725. while (!found) {
  726. const dStr = format(closeDate, 'yyyy-MM-dd');
  727. if (benchData && benchData[dStr]) {
  728. courseCloseNew = benchData[dStr].close;
  729. found = true;
  730. } else {
  731. closeDate = addDays(closeDate, 1);
  732. if (differenceInDays(closeDate, parseISO(original.date_close as string)) > 10) found = true;
  733. }
  734. }
  735. benchmarkTrades[benchId].course_close = courseCloseNew;
  736. }
  737. }
  738. logging("", "success");
  739. return benchmarkTrades;
  740. }
  741. /**
  742. * Merges the benchmark performance data into the main ticker history object.
  743. */
  744. function mergeHistories(historyPerTicker: HistoryPerTicker, benchmarkHistory: HistoryPerTicker): HistoryPerTicker {
  745. const benchTicker = config.ticker_benchmark;
  746. let errorCount = 0;
  747. for (const date in historyPerTicker) {
  748. try {
  749. if (benchmarkHistory[date] && benchmarkHistory[date][benchTicker]) {
  750. // LEARNING TIP: Type Casting (as any)
  751. // We attach 'benchmark' to the object. TypeScript might complain if this
  752. // isn't strictly defined in the interface, so we use 'as any' to bypass the check.
  753. (historyPerTicker[date] as any)["benchmark"] = benchmarkHistory[date][benchTicker];
  754. }
  755. } catch {
  756. errorCount++;
  757. }
  758. }
  759. if (errorCount === 0) logging("", "success");
  760. else logging(`Merge error count: ${errorCount}`, "warning");
  761. return historyPerTicker;
  762. }
  763. // ---------------------------------------------------------
  764. // 7. UPLOAD & SELECT FUNCTIONS
  765. // ---------------------------------------------------------
  766. /**
  767. * Generic function to update a Notion Page.
  768. */
  769. async function notionUpdatePage(pageId: string, data: any) {
  770. const url = `https://api.notion.com/v1/pages/${pageId}`;
  771. await fetch(url, {
  772. method: 'PATCH',
  773. headers: notionHeaders as any,
  774. body: JSON.stringify({ properties: data })
  775. });
  776. }
  777. async function pushNotionTradesUpdate(trades: TradesDict) {
  778. let errorCount = 0;
  779. process.stdout.write(" ");
  780. for (const id in trades) {
  781. try {
  782. const trade = trades[id];
  783. if (!trade) continue;
  784. let irrNotion = trade.irr - 1;
  785. irrNotion = Math.round(irrNotion * 10000) / 10000;
  786. const update = {
  787. "Current (€)": { number: trade.course_current },
  788. "IRR (%)": { number: irrNotion },
  789. "Dividends (€)": { number: trade.dividends }
  790. };
  791. await notionUpdatePage(id, update);
  792. } catch {
  793. errorCount++;
  794. }
  795. process.stdout.write(".");
  796. await sleep(config.api_cooldown_time);
  797. }
  798. process.stdout.write(" ");
  799. if(errorCount === 0) logging("", "success");
  800. else logging(`${errorCount} upload errors`, "warning");
  801. }
  802. async function pushNotionInvestmentUpdate(investments: InvestmentsDict) {
  803. let errorCount = 0;
  804. process.stdout.write(" ");
  805. for (const id in investments) {
  806. try {
  807. const invest = investments[id];
  808. if (!invest) continue;
  809. let irrNotion = invest.current_irr - 1;
  810. irrNotion = Math.round(irrNotion * 10000) / 10000;
  811. const update = {
  812. "Current (€)": { number: invest.current_value },
  813. "IRR (%)": { number: irrNotion },
  814. "Performance (€)": { number: invest.total_performanance },
  815. "Dividends (€)": { number: invest.total_dividends }
  816. };
  817. await notionUpdatePage(id, update);
  818. } catch {
  819. errorCount++;
  820. }
  821. process.stdout.write(".");
  822. await sleep(config.api_cooldown_time);
  823. }
  824. process.stdout.write(" ");
  825. if(errorCount === 0) logging("", "success");
  826. else logging(`${errorCount} upload errors`, "warning");
  827. }
  828. /**
  829. * Updates the 'trades' object with the most current calculated values from history.
  830. */
  831. function selectCurrentValuePerTrade(trades: TradesDict, historyPerTrade: HistoryPerTrade): TradesDict {
  832. for (const id in trades) {
  833. try {
  834. const trade = trades[id];
  835. if (!trade) continue;
  836. let dateIso: string;
  837. // If open, use today. If closed, use close date.
  838. if (trade.date_close === 0) {
  839. dateIso = format(new Date(), 'yyyy-MM-dd');
  840. } else {
  841. dateIso = trade.date_close as string;
  842. }
  843. // Fallback if date not found
  844. if (!historyPerTrade[dateIso]) {
  845. dateIso = fetchLastKeyFromDict(historyPerTrade);
  846. }
  847. const h = historyPerTrade[dateIso]?.[id];
  848. if (h) {
  849. trade.course_current = h.current_course;
  850. trade.irr = h.current_irr;
  851. trade.dividends = h.total_dividends;
  852. }
  853. } catch(e) {}
  854. }
  855. logging("", "success");
  856. return trades;
  857. }
  858. function selectCurrentValuePerTicker(investments: InvestmentsDict, historyPerTicker: HistoryPerTicker): InvestmentsDict {
  859. const todayIso = format(new Date(), 'yyyy-MM-dd');
  860. // Determine which date key to use
  861. const lastKey = Object.keys(historyPerTicker).length > 0
  862. ? fetchLastKeyFromDict(historyPerTicker)
  863. : todayIso;
  864. const useDate = historyPerTicker[todayIso] ? todayIso : lastKey;
  865. for (const id in investments) {
  866. try {
  867. const invest = investments[id];
  868. if (!invest) continue;
  869. const ticker = invest.ticker;
  870. if(historyPerTicker[useDate] && historyPerTicker[useDate][ticker]) {
  871. const h = historyPerTicker[useDate][ticker];
  872. invest.total_dividends = h.total_dividends;
  873. invest.current_value = h.current_value;
  874. invest.current_irr = h.current_irr;
  875. invest.total_performanance = h.total_performanance;
  876. }
  877. } catch(e) {}
  878. }
  879. logging("", "success");
  880. return investments;
  881. }
  882. /**
  883. * Filters the detailed daily history down to specific dates (e.g. Weekly)
  884. * and handles data aggregation/averaging.
  885. */
  886. function filterHistoryByList(history: HistoryPerTicker, datesList: string[]): HistoryPerTicker {
  887. const filtered: HistoryPerTicker = {};
  888. // Sort input arrays to ensure processing happens chronologically
  889. const sortedTargetDates = [...datesList].sort();
  890. const allHistoryDates = Object.keys(history).sort();
  891. let lastHistoryIdx = 0;
  892. for (let i = 0; i < sortedTargetDates.length; i++) {
  893. const targetDate = sortedTargetDates[i];
  894. // FIX 1: Strict Check - Ensure targetDate is defined before using it as an index
  895. if (!targetDate) continue;
  896. // CHECK: Is this the last date in our list (usually Today)?
  897. const isLastDate = i === sortedTargetDates.length - 1;
  898. if (isLastDate) {
  899. // --- LOGIC FOR TODAY (Last Entry) ---
  900. // We do not want to average today's data with last week's. We want the exact latest values.
  901. // Try to find exact match
  902. let dataToUse = history[targetDate];
  903. // If not found, use the very last entry in the history (fallback)
  904. if (!dataToUse && allHistoryDates.length > 0) {
  905. const lastAvailableDate = allHistoryDates[allHistoryDates.length - 1];
  906. if (lastAvailableDate) {
  907. dataToUse = history[lastAvailableDate];
  908. }
  909. }
  910. if (dataToUse) {
  911. // Deep clone the object using JSON parse/stringify to prevent reference issues
  912. filtered[targetDate] = JSON.parse(JSON.stringify(dataToUse));
  913. }
  914. } else {
  915. // --- LOGIC FOR PAST DATES (Interval Averaging) ---
  916. let currentHistoryIdx = -1;
  917. // Find the index of the target date in the full history array
  918. for (let j = lastHistoryIdx; j < allHistoryDates.length; j++) {
  919. const hDate = allHistoryDates[j];
  920. if (hDate && hDate <= targetDate) {
  921. currentHistoryIdx = j;
  922. } else {
  923. break; // Optimization: stop searching once we pass the date
  924. }
  925. }
  926. if (currentHistoryIdx !== -1 && currentHistoryIdx >= lastHistoryIdx) {
  927. // Get all history days between the last target and this target
  928. const intervalDates = allHistoryDates.slice(lastHistoryIdx, currentHistoryIdx + 1);
  929. if (intervalDates.length > 0) {
  930. // Initialize Aggregation Accumulator
  931. const aggregation: Record<string, {
  932. wSumInvested: number; wSumValue: number; wSumPerf: number; wSumDiv: number;
  933. totalWeight: number;
  934. wSumIrr: number;
  935. irrTotalWeight: number;
  936. bSumInvested: number; bSumValue: number; bSumPerf: number; bSumDiv: number;
  937. bTotalWeight: number;
  938. bSumIrr: number;
  939. bIrrTotalWeight: number;
  940. }> = {};
  941. // Helper to create or retrieve aggregator for a ticker
  942. const getAgg = (t: string) => {
  943. if (!aggregation[t]) aggregation[t] = {
  944. wSumInvested: 0, wSumValue: 0, wSumPerf: 0, wSumDiv: 0, totalWeight: 0,
  945. wSumIrr: 0, irrTotalWeight: 0,
  946. bSumInvested: 0, bSumValue: 0, bSumPerf: 0, bSumDiv: 0, bTotalWeight: 0,
  947. bSumIrr: 0, bIrrTotalWeight: 0
  948. };
  949. return aggregation[t];
  950. };
  951. // Loop through every day in this interval
  952. for (const date of intervalDates) {
  953. if (!date) continue; // Safety check
  954. const dayData = history[date];
  955. if (!dayData) continue;
  956. for (const ticker in dayData) {
  957. const entry = dayData[ticker];
  958. // FIX 2: Strict Check - Ensure entry exists
  959. if (!entry) continue;
  960. const agg = getAgg(ticker);
  961. // Weight: If invested amount < 1, treat as 0 (ignore dust)
  962. const w = entry.current_invested > 1 ? entry.current_invested : 0;
  963. // Outlier Detection: If IRR > 500% or < -500%, ignore it
  964. const isOutlier = Math.abs(entry.current_irr) > 5.0;
  965. if (w > 0) {
  966. // Add weighted values
  967. agg.wSumInvested += entry.current_invested * w;
  968. agg.wSumValue += entry.current_value * w;
  969. agg.wSumPerf += entry.total_performanance * w;
  970. agg.wSumDiv += entry.total_dividends * w;
  971. agg.totalWeight += w;
  972. // Only add IRR if sane
  973. if (!isOutlier) {
  974. agg.wSumIrr += entry.current_irr * w;
  975. agg.irrTotalWeight += w;
  976. }
  977. }
  978. // Handle Benchmark Aggregation (Same logic)
  979. if (entry.benchmark) {
  980. const b = entry.benchmark;
  981. const bw = b.current_invested > 1 ? b.current_invested : 0;
  982. const isBenchOutlier = Math.abs(b.current_irr) > 5.0;
  983. if (bw > 0) {
  984. agg.bSumInvested += b.current_invested * bw;
  985. agg.bSumValue += b.current_value * bw;
  986. agg.bSumPerf += b.total_performanance * bw;
  987. agg.bSumDiv += b.total_dividends * bw;
  988. agg.bTotalWeight += bw;
  989. if (!isBenchOutlier) {
  990. agg.bSumIrr += b.current_irr * bw;
  991. agg.bIrrTotalWeight += bw;
  992. }
  993. }
  994. }
  995. }
  996. }
  997. // Calculate Final Weighted Averages for the interval
  998. const resultDaily: HistoryDailyTicker = {};
  999. for (const ticker in aggregation) {
  1000. const agg = aggregation[ticker];
  1001. // FIX 3: Strict Check - Ensure agg exists
  1002. if (!agg) continue;
  1003. const div = (n: number, d: number) => d === 0 ? 0 : n / d;
  1004. const entry: TickerHistoryEntry = {
  1005. current_invested: div(agg.wSumInvested, agg.totalWeight),
  1006. current_value: div(agg.wSumValue, agg.totalWeight),
  1007. current_irr: div(agg.wSumIrr, agg.irrTotalWeight),
  1008. total_performanance: div(agg.wSumPerf, agg.totalWeight),
  1009. total_dividends: div(agg.wSumDiv, agg.totalWeight),
  1010. current_amount: 0,
  1011. current_course: 0
  1012. };
  1013. if (agg.bTotalWeight > 0) {
  1014. entry.benchmark = {
  1015. current_invested: div(agg.bSumInvested, agg.bTotalWeight),
  1016. current_value: div(agg.bSumValue, agg.bTotalWeight),
  1017. current_irr: div(agg.bSumIrr, agg.bIrrTotalWeight),
  1018. total_performanance: div(agg.bSumPerf, agg.bTotalWeight),
  1019. total_dividends: div(agg.bSumDiv, agg.bTotalWeight),
  1020. };
  1021. }
  1022. resultDaily[ticker] = entry;
  1023. }
  1024. filtered[targetDate] = resultDaily;
  1025. }
  1026. // Update start index for next iteration
  1027. lastHistoryIdx = currentHistoryIdx + 1;
  1028. }
  1029. }
  1030. }
  1031. logging("", "success");
  1032. return filtered;
  1033. }
  1034. /**
  1035. * Formats the data for the TRMNL dashboard API.
  1036. */
  1037. function prepTrmnlChartUpdate(history: HistoryPerTicker, s1: string = "total", d1: string = "current_value", s2: string = "benchmark", d2: string = "current_value") {
  1038. try {
  1039. const lastKey = fetchLastKeyFromDict(history);
  1040. const lastEntry = history[lastKey];
  1041. if (!lastEntry || !lastEntry["total"]) {
  1042. throw new Error("Missing 'total' data for TRMNL update");
  1043. }
  1044. // Prepare Big Numbers (Top of dashboard)
  1045. const dictBigNumbers = {
  1046. current_value: Math.round(lastEntry["total"].current_value).toString(),
  1047. total_performanance: Math.round(lastEntry["total"].total_performanance).toString(),
  1048. current_irr: String(Math.round((lastEntry["total"].current_irr - 1) * 100 * 100) / 100)
  1049. };
  1050. const chart1Data: any[] = [];
  1051. const chart2Data: any[] = [];
  1052. const sortedDates = Object.keys(history).sort();
  1053. // Convert history map to Chart Data Array
  1054. for (const date of sortedDates) {
  1055. const getVal = (series: string, field: string) => {
  1056. let val = 0;
  1057. const dailyData = history[date];
  1058. // Strict check: Data must exist
  1059. if (!dailyData) return 0;
  1060. if (series === 'benchmark') {
  1061. // Safe access using Optional Chaining and Nullish Coalescing
  1062. val = (dailyData as any)['benchmark']?.[field] ?? 0;
  1063. } else {
  1064. val = (dailyData as any)[series]?.[field] ?? 0;
  1065. }
  1066. if (field === 'current_irr') val = (val - 1) * 100;
  1067. return Math.round(val * 100) / 100;
  1068. };
  1069. const val1 = getVal(s1, d1);
  1070. const val2 = getVal(s2, d2);
  1071. // Format: ISO date + Time (required by TRMNL)
  1072. const jsonDate = date + "T00:00:00";
  1073. chart1Data.push([jsonDate, val1]);
  1074. chart2Data.push([jsonDate, val2]);
  1075. }
  1076. // Construct Titles
  1077. let t1 = s1 === "total" ? "Portfolio" : s1;
  1078. if (s1 === "benchmark") t1 = `Benchmark: ${config.ticker_benchmark}`;
  1079. let t2 = s2 === "total" ? "Portfolio" : s2;
  1080. if (s2 === "benchmark") t2 = `Benchmark: ${config.ticker_benchmark}`;
  1081. const cleanName = (n: string) => n.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase()).replace("Irr", "IRR");
  1082. return {
  1083. merge_variables: {
  1084. big_numbers: dictBigNumbers,
  1085. charts: [
  1086. { name: `${cleanName(d1)} ${t1}`, data: chart1Data },
  1087. { name: `${cleanName(d2)} ${t2}`, data: chart2Data }
  1088. ]
  1089. }
  1090. };
  1091. } catch (e) {
  1092. logging(`${e}`, "error");
  1093. return false;
  1094. }
  1095. }
  1096. /**
  1097. * Sends the formatted data to the TRMNL API.
  1098. */
  1099. async function pushTrmnlUpdateChart(data: any, url: string) {
  1100. try {
  1101. const res = await fetch(url, {
  1102. method: 'POST',
  1103. headers: trmnlHeaders,
  1104. body: JSON.stringify(data)
  1105. });
  1106. if (res.status === 200) logging("", "success");
  1107. else if (res.status === 429) logging("Rate limit exceeded", "warning");
  1108. else if (res.status === 422) logging("Payload too large/invalid", "warning");
  1109. else logging(`TRMNL Error ${res.status}`, "error");
  1110. } catch (e) {
  1111. logging(`${e}`, "error");
  1112. }
  1113. }
  1114. // ---------------------------------------------------------
  1115. // 8. MAIN EXECUTION LOOP
  1116. // ---------------------------------------------------------
  1117. /**
  1118. * Main entry point.
  1119. * LEARNING TIP: Async Main
  1120. * Top-level code cannot normally use 'await' (in older Node versions).
  1121. * Wrapping logic in an async function allows us to use await for all our API calls.
  1122. */
  1123. async function main() {
  1124. console.log("Starting TS-Ported Financial Service...");
  1125. // Infinite Loop to keep service running
  1126. while (true) {
  1127. try {
  1128. // --- PART 1: NOTION TRADES ---
  1129. process.stdout.write("Fetching Data from Notion... ");
  1130. let trades = await fetchFormatNotionTrades(config.notion_db_id_trades);
  1131. process.stdout.write("Creating unique tickers... ");
  1132. let tickers = filterListOfTickers(trades);
  1133. if (config.calculate_benchmark) {
  1134. process.stdout.write("Adding benchmark... ");
  1135. tickers = addBenchmarkTicker(tickers, config.ticker_benchmark);
  1136. }
  1137. process.stdout.write("Fetching YFinance data");
  1138. const yfData = await fetchFormatYfData(tickers);
  1139. process.stdout.write("Calculating history per trade... ");
  1140. const historyPerTrade = calcHistoryPerTrade(trades, yfData);
  1141. if (config.update_notion) {
  1142. process.stdout.write("Selecting current values... ");
  1143. trades = selectCurrentValuePerTrade(trades, historyPerTrade);
  1144. process.stdout.write("Updating Notion Trades");
  1145. await pushNotionTradesUpdate(trades);
  1146. }
  1147. // --- PART 2: NOTION INVESTMENTS ---
  1148. process.stdout.write("Fetching Notion Investments... ");
  1149. let investments = await fetchFormatNotionInvestments(config.notion_db_id_investments);
  1150. process.stdout.write("Calculating history per ticker... ");
  1151. let historyPerTicker = calcHistoryPerTicker(historyPerTrade, tickers, trades);
  1152. if (config.update_notion) {
  1153. process.stdout.write("Calculating current ticker values... ");
  1154. investments = selectCurrentValuePerTicker(investments, historyPerTicker);
  1155. process.stdout.write("Updating Notion Investments");
  1156. await pushNotionInvestmentUpdate(investments);
  1157. }
  1158. // --- PART 3: BENCHMARK ---
  1159. if (config.calculate_benchmark) {
  1160. process.stdout.write("Creating benchmark trades... ");
  1161. const benchmarkTrades = await createBenchmarkTrades(trades, yfData);
  1162. process.stdout.write("Calculating benchmark history per trade... ");
  1163. const historyBenchTrade = calcHistoryPerTrade(benchmarkTrades, yfData);
  1164. process.stdout.write("Calculating benchmark history overall... ");
  1165. const historyBench = calcHistoryPerTicker(historyBenchTrade, tickers, benchmarkTrades);
  1166. process.stdout.write("Merging histories... ");
  1167. historyPerTicker = mergeHistories(historyPerTicker, historyBench);
  1168. }
  1169. // --- PART 4: TRMNL ---
  1170. if (config.update_TRMNL) {
  1171. process.stdout.write("Creating weekly dates... ");
  1172. const datesList = createListFilteredDates(trades, config.trmnl_granularity);
  1173. process.stdout.write("Filtering history... ");
  1174. const historyFiltered = filterHistoryByList(historyPerTicker, datesList);
  1175. // Chart 1: Value
  1176. process.stdout.write("Pushing Chart 1 (Value)... ");
  1177. const data1 = prepTrmnlChartUpdate(historyFiltered, "total", "current_value", "benchmark", "current_value");
  1178. if(data1) await pushTrmnlUpdateChart(data1, config.trmnl_url_chart_1);
  1179. // Chart 2: IRR
  1180. process.stdout.write("Pushing Chart 2 (IRR)... ");
  1181. const data2 = prepTrmnlChartUpdate(historyFiltered, "total", "current_irr", "benchmark", "current_irr");
  1182. if(data2) await pushTrmnlUpdateChart(data2, config.trmnl_url_chart_2);
  1183. // Chart 3: Performance
  1184. process.stdout.write("Pushing Chart 3 (Perf)... ");
  1185. const data3 = prepTrmnlChartUpdate(historyFiltered, "total", "total_performanance", "benchmark", "total_performanance");
  1186. if(data3) await pushTrmnlUpdateChart(data3, config.trmnl_url_chart_3);
  1187. }
  1188. // --- PART 5: COOLDOWN ---
  1189. // Clear vars for Garbage Collection
  1190. trades = {};
  1191. investments = {};
  1192. console.log(`Completed cycle at: ${new Date().toISOString()}`);
  1193. console.log("------------------------------------------------");
  1194. // Wait before next execution
  1195. await sleep(config.programm_cooldown_time * 60 * 1000);
  1196. } catch (e) {
  1197. console.error("CRITICAL MAIN LOOP ERROR:", e);
  1198. console.log("Retrying in 1 minute...");
  1199. await sleep(60000);
  1200. }
  1201. }
  1202. }
  1203. // Start the application
  1204. main();