mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
28 changed files with 2070 additions and 131 deletions
@ -0,0 +1,22 @@ |
|||||
|
import { Transform, TransformFnParams } from 'class-transformer'; |
||||
|
import { IsArray, IsIn, IsOptional, IsString } from 'class-validator'; |
||||
|
|
||||
|
import type { IntradayDateRange } from './intraday/intraday.helper'; |
||||
|
|
||||
|
export class GetIntradayDto { |
||||
|
/** Comma-separated ids of the asset profiles of the benchmarks */ |
||||
|
@IsArray() |
||||
|
@IsOptional() |
||||
|
@IsString({ each: true }) |
||||
|
@Transform(({ value }: TransformFnParams) => { |
||||
|
return typeof value === 'string' |
||||
|
? value.split(',').filter((id) => { |
||||
|
return id.length > 0; |
||||
|
}) |
||||
|
: value; |
||||
|
}) |
||||
|
benchmarks?: string[]; |
||||
|
|
||||
|
@IsIn(['1d', 'mtd', 'wtd'] as IntradayDateRange[]) |
||||
|
range: IntradayDateRange; |
||||
|
} |
||||
@ -0,0 +1,246 @@ |
|||||
|
import { |
||||
|
getBaselinePrice, |
||||
|
getInstrumentIntradayItems, |
||||
|
getIntradayGrid, |
||||
|
getIntradaySessions, |
||||
|
getPortfolioIntradayItems, |
||||
|
getPriceAt, |
||||
|
IntradayInstrument, |
||||
|
isAnyMarketOpen |
||||
|
} from './intraday.helper'; |
||||
|
|
||||
|
const MINUTE = 60 * 1000; |
||||
|
const time = (aIso: string) => { |
||||
|
return Date.parse(aIso); |
||||
|
}; |
||||
|
|
||||
|
// Thursday, 22 October 2026
|
||||
|
const europe: IntradayInstrument = { |
||||
|
prices: [ |
||||
|
{ price: 100, time: time('2026-10-21T15:29:00Z') }, |
||||
|
{ price: 101, time: time('2026-10-22T07:00:00Z') }, |
||||
|
{ price: 102, time: time('2026-10-22T08:00:00Z') } |
||||
|
], |
||||
|
tradingPeriods: [ |
||||
|
{ |
||||
|
end: time('2026-10-21T15:30:00Z'), |
||||
|
start: time('2026-10-21T07:00:00Z'), |
||||
|
type: 'regular' |
||||
|
}, |
||||
|
{ |
||||
|
end: time('2026-10-22T15:30:00Z'), |
||||
|
start: time('2026-10-22T07:00:00Z'), |
||||
|
type: 'regular' |
||||
|
} |
||||
|
] |
||||
|
}; |
||||
|
|
||||
|
const unitedStates: IntradayInstrument = { |
||||
|
prices: [ |
||||
|
{ price: 50, time: time('2026-10-21T19:59:00Z') }, |
||||
|
// Post-market of the previous day
|
||||
|
{ price: 55, time: time('2026-10-21T23:00:00Z') }, |
||||
|
// Pre-market
|
||||
|
{ price: 51, time: time('2026-10-22T08:30:00Z') } |
||||
|
], |
||||
|
tradingPeriods: [ |
||||
|
{ |
||||
|
end: time('2026-10-21T20:00:00Z'), |
||||
|
start: time('2026-10-21T13:30:00Z'), |
||||
|
type: 'regular' |
||||
|
}, |
||||
|
{ |
||||
|
end: time('2026-10-22T00:00:00Z'), |
||||
|
start: time('2026-10-21T20:00:00Z'), |
||||
|
type: 'post' |
||||
|
}, |
||||
|
{ |
||||
|
end: time('2026-10-22T13:30:00Z'), |
||||
|
start: time('2026-10-22T08:00:00Z'), |
||||
|
type: 'pre' |
||||
|
}, |
||||
|
{ |
||||
|
end: time('2026-10-22T20:00:00Z'), |
||||
|
start: time('2026-10-22T13:30:00Z'), |
||||
|
type: 'regular' |
||||
|
}, |
||||
|
{ |
||||
|
end: time('2026-10-23T00:00:00Z'), |
||||
|
start: time('2026-10-22T20:00:00Z'), |
||||
|
type: 'post' |
||||
|
} |
||||
|
] |
||||
|
}; |
||||
|
|
||||
|
describe('intraday helper', () => { |
||||
|
const now = time('2026-10-22T09:00:00Z'); |
||||
|
|
||||
|
it('spans the trading sessions of the day including the pre-market and the post-market', () => { |
||||
|
expect( |
||||
|
getIntradaySessions({ |
||||
|
now, |
||||
|
dateRange: '1d', |
||||
|
instruments: [europe, unitedStates] |
||||
|
}) |
||||
|
).toEqual([ |
||||
|
{ |
||||
|
end: time('2026-10-23T00:00:00Z'), |
||||
|
start: time('2026-10-22T07:00:00Z') |
||||
|
} |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('uses the previous trading day before the first session has started', () => { |
||||
|
expect( |
||||
|
getIntradaySessions({ |
||||
|
dateRange: '1d', |
||||
|
instruments: [europe], |
||||
|
now: time('2026-10-22T05:00:00Z') |
||||
|
}) |
||||
|
).toEqual([ |
||||
|
{ |
||||
|
end: time('2026-10-21T15:30:00Z'), |
||||
|
start: time('2026-10-21T07:00:00Z') |
||||
|
} |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('spans the trading sessions of the whole week', () => { |
||||
|
const sessions = getIntradaySessions({ |
||||
|
now, |
||||
|
dateRange: 'wtd', |
||||
|
instruments: [europe] |
||||
|
}); |
||||
|
|
||||
|
// Wednesday and Thursday are known, Friday is projected
|
||||
|
expect(sessions.map(({ start }) => new Date(start).toISOString())).toEqual([ |
||||
|
'2026-10-21T07:00:00.000Z', |
||||
|
'2026-10-22T07:00:00.000Z', |
||||
|
'2026-10-23T07:00:00.000Z' |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('builds a grid of the intervals of the sessions', () => { |
||||
|
const grid = getIntradayGrid({ |
||||
|
intervalInMs: 30 * MINUTE, |
||||
|
sessions: [ |
||||
|
{ |
||||
|
end: time('2026-10-22T08:00:00Z'), |
||||
|
start: time('2026-10-22T07:00:00Z') |
||||
|
} |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
expect(grid).toEqual([ |
||||
|
time('2026-10-22T07:00:00Z'), |
||||
|
time('2026-10-22T07:30:00Z') |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('takes the last price at or before a time', () => { |
||||
|
expect( |
||||
|
getPriceAt({ prices: europe.prices, time: time('2026-10-22T07:59:00Z') }) |
||||
|
).toBe(101); |
||||
|
expect( |
||||
|
getPriceAt({ prices: europe.prices, time: time('2026-10-20T00:00:00Z') }) |
||||
|
).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('takes the close of the regular session as baseline, not the post-market', () => { |
||||
|
expect( |
||||
|
getBaselinePrice({ |
||||
|
instrument: unitedStates, |
||||
|
start: time('2026-10-22T07:00:00Z') |
||||
|
}) |
||||
|
).toBe(50); |
||||
|
}); |
||||
|
|
||||
|
it('computes the change of an instrument while it is traded', () => { |
||||
|
const grid = [ |
||||
|
time('2026-10-22T06:00:00Z'), |
||||
|
time('2026-10-22T07:00:00Z'), |
||||
|
time('2026-10-22T08:00:00Z'), |
||||
|
time('2026-10-22T08:30:00Z'), |
||||
|
time('2026-10-22T10:00:00Z') |
||||
|
]; |
||||
|
|
||||
|
const items = getInstrumentIntradayItems({ |
||||
|
grid, |
||||
|
now, |
||||
|
instrument: europe, |
||||
|
intervalInMs: MINUTE, |
||||
|
start: time('2026-10-22T07:00:00Z') |
||||
|
}); |
||||
|
|
||||
|
// Not drawn before the first and after the last price
|
||||
|
expect(items.map(({ value }) => value)).toEqual([ |
||||
|
null, |
||||
|
expect.closeTo(0.01, 10), |
||||
|
expect.closeTo(0.02, 10), |
||||
|
null, |
||||
|
null |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('computes the change of the portfolio adjusted for a buy', () => { |
||||
|
const start = time('2026-10-22T07:00:00Z'); |
||||
|
|
||||
|
const items = getPortfolioIntradayItems({ |
||||
|
now, |
||||
|
start, |
||||
|
cashFlows: [{ amount: 102, time: time('2026-10-22T08:00:00Z') }], |
||||
|
grid: [start, time('2026-10-22T08:00:00Z')], |
||||
|
holdings: [ |
||||
|
{ |
||||
|
...europe, |
||||
|
exchangeRate: 1, |
||||
|
// One share bought at 08:00 for 102
|
||||
|
quantity: 2, |
||||
|
quantityChanges: [{ quantity: 1, time: time('2026-10-22T08:00:00Z') }] |
||||
|
} |
||||
|
], |
||||
|
intervalInMs: MINUTE |
||||
|
}); |
||||
|
|
||||
|
// 07:00: 1 share from 100 to 101
|
||||
|
expect(items[0].value).toBeCloseTo(0.01); |
||||
|
|
||||
|
// 08:00: 2 shares at 102 (204) - 100 - 102 = 2 on an investment of 202
|
||||
|
expect(items[1].value).toBeCloseTo(2 / 202); |
||||
|
}); |
||||
|
|
||||
|
it('detects an open market including the pre-market', () => { |
||||
|
expect( |
||||
|
isAnyMarketOpen({ |
||||
|
instruments: [unitedStates], |
||||
|
now: time('2026-10-22T09:00:00Z') |
||||
|
}) |
||||
|
).toBe(true); |
||||
|
|
||||
|
expect( |
||||
|
isAnyMarketOpen({ |
||||
|
instruments: [unitedStates], |
||||
|
now: time('2026-10-22T05:00:00Z') |
||||
|
}) |
||||
|
).toBe(false); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('intraday helper with several trading days', () => { |
||||
|
it('leaves a gap while the market of an instrument is closed', () => { |
||||
|
const items = getInstrumentIntradayItems({ |
||||
|
grid: [ |
||||
|
time('2026-10-21T15:00:00Z'), |
||||
|
time('2026-10-21T20:00:00Z'), |
||||
|
time('2026-10-22T07:00:00Z') |
||||
|
], |
||||
|
instrument: europe, |
||||
|
intervalInMs: MINUTE, |
||||
|
now: time('2026-10-22T09:00:00Z'), |
||||
|
start: time('2026-10-21T07:00:00Z') |
||||
|
}); |
||||
|
|
||||
|
expect(items[1].value).toBeNull(); |
||||
|
expect(items[2].value).not.toBeNull(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,457 @@ |
|||||
|
import type { PortfolioIntradayItem } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
export type IntradayDateRange = '1d' | 'mtd' | 'wtd'; |
||||
|
|
||||
|
export interface IntradayPeriod { |
||||
|
end: number; |
||||
|
start: number; |
||||
|
type: 'post' | 'pre' | 'regular'; |
||||
|
} |
||||
|
|
||||
|
export interface IntradayInstrument { |
||||
|
/** Realtime price, used for the interval which contains the current time */ |
||||
|
livePrice?: number; |
||||
|
|
||||
|
/** Sorted by time */ |
||||
|
prices: { price: number; time: number }[]; |
||||
|
|
||||
|
tradingPeriods: IntradayPeriod[]; |
||||
|
} |
||||
|
|
||||
|
export interface IntradayHolding extends IntradayInstrument { |
||||
|
/** Exchange rate from the currency of the holding to the base currency */ |
||||
|
exchangeRate: number; |
||||
|
|
||||
|
/** Current quantity */ |
||||
|
quantity: number; |
||||
|
|
||||
|
/** Quantity bought (positive) or sold (negative) per time */ |
||||
|
quantityChanges: { quantity: number; time: number }[]; |
||||
|
} |
||||
|
|
||||
|
export interface IntradayCashFlow { |
||||
|
/** Positive for a buy, negative for a sell (in the base currency) */ |
||||
|
amount: number; |
||||
|
time: number; |
||||
|
} |
||||
|
|
||||
|
const DAY_IN_MS = 24 * 60 * 60 * 1000; |
||||
|
|
||||
|
export const INTRADAY_INTERVALS: Record< |
||||
|
IntradayDateRange, |
||||
|
{ interval: '5m' | '15m' | '60m'; intervalInMs: number } |
||||
|
> = { |
||||
|
// About 200 to 400 points per chart, as on the charts of the brokers
|
||||
|
'1d': { interval: '5m', intervalInMs: 5 * 60 * 1000 }, |
||||
|
mtd: { interval: '60m', intervalInMs: 60 * 60 * 1000 }, |
||||
|
wtd: { interval: '15m', intervalInMs: 15 * 60 * 1000 } |
||||
|
}; |
||||
|
|
||||
|
function getDayKey(aTime: number) { |
||||
|
return new Date(aTime).toISOString().slice(0, 10); |
||||
|
} |
||||
|
|
||||
|
function getTimeOfDayKey(aDayKey: string) { |
||||
|
return Date.parse(`${aDayKey}T00:00:00.000Z`); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Returns the merged trading sessions (including the pre-market and the |
||||
|
* post-market) of the date range. The sessions of the remaining days of the |
||||
|
* week or month are projected from the latest known trading day. |
||||
|
*/ |
||||
|
export function getIntradaySessions({ |
||||
|
dateRange, |
||||
|
instruments, |
||||
|
now |
||||
|
}: { |
||||
|
dateRange: IntradayDateRange; |
||||
|
instruments: IntradayInstrument[]; |
||||
|
now: number; |
||||
|
}): { end: number; start: number }[] { |
||||
|
const periodsByDay = new Map<string, IntradayPeriod[]>(); |
||||
|
|
||||
|
for (const { tradingPeriods } of instruments) { |
||||
|
for (const period of tradingPeriods) { |
||||
|
const key = getDayKey(period.start); |
||||
|
|
||||
|
if (!periodsByDay.has(key)) { |
||||
|
periodsByDay.set(key, []); |
||||
|
} |
||||
|
|
||||
|
periodsByDay.get(key).push(period); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const startedDays = [...periodsByDay.keys()] |
||||
|
.filter((key) => { |
||||
|
return ( |
||||
|
Math.min(...periodsByDay.get(key).map(({ start }) => start)) <= now |
||||
|
); |
||||
|
}) |
||||
|
.sort(); |
||||
|
|
||||
|
if (startedDays.length === 0) { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
const latestStartedDay = startedDays.at(-1); |
||||
|
let days: string[]; |
||||
|
|
||||
|
if (dateRange === '1d') { |
||||
|
days = [latestStartedDay]; |
||||
|
} else { |
||||
|
const today = new Date(now); |
||||
|
let firstDay: Date; |
||||
|
let lastDay: Date; |
||||
|
|
||||
|
if (dateRange === 'wtd') { |
||||
|
// Monday to Friday of the current week
|
||||
|
const weekday = (today.getUTCDay() + 6) % 7; |
||||
|
firstDay = new Date( |
||||
|
getTimeOfDayKey(getDayKey(now)) - weekday * DAY_IN_MS |
||||
|
); |
||||
|
lastDay = new Date(firstDay.getTime() + 4 * DAY_IN_MS); |
||||
|
} else { |
||||
|
firstDay = new Date( |
||||
|
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1) |
||||
|
); |
||||
|
lastDay = new Date( |
||||
|
Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + 1, 0) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const firstKey = getDayKey(firstDay.getTime()); |
||||
|
const lastKey = getDayKey(lastDay.getTime()); |
||||
|
|
||||
|
days = startedDays.filter((key) => { |
||||
|
return key >= firstKey && key <= lastKey; |
||||
|
}); |
||||
|
|
||||
|
// Project the trading sessions of the remaining weekdays
|
||||
|
const pattern = periodsByDay.get([...periodsByDay.keys()].sort().at(-1)); |
||||
|
const patternDay = getTimeOfDayKey(getDayKey(pattern[0].start)); |
||||
|
|
||||
|
for ( |
||||
|
let time = getTimeOfDayKey(latestStartedDay) + DAY_IN_MS; |
||||
|
getDayKey(time) <= lastKey; |
||||
|
time += DAY_IN_MS |
||||
|
) { |
||||
|
const weekday = new Date(time).getUTCDay(); |
||||
|
|
||||
|
if (weekday === 0 || weekday === 6) { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const key = getDayKey(time); |
||||
|
|
||||
|
if (!periodsByDay.has(key)) { |
||||
|
periodsByDay.set( |
||||
|
key, |
||||
|
pattern.map((period) => { |
||||
|
return { |
||||
|
...period, |
||||
|
end: period.end + (time - patternDay), |
||||
|
start: period.start + (time - patternDay) |
||||
|
}; |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
days.push(key); |
||||
|
} |
||||
|
|
||||
|
if (days.length === 0) { |
||||
|
days = [latestStartedDay]; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const periods = days |
||||
|
.flatMap((key) => { |
||||
|
return periodsByDay.get(key); |
||||
|
}) |
||||
|
.filter(({ end, start }) => { |
||||
|
return end > start; |
||||
|
}) |
||||
|
.sort((a, b) => { |
||||
|
return a.start - b.start; |
||||
|
}); |
||||
|
|
||||
|
const sessions: { end: number; start: number }[] = []; |
||||
|
|
||||
|
for (const { end, start } of periods) { |
||||
|
const last = sessions.at(-1); |
||||
|
|
||||
|
if (last && start <= last.end) { |
||||
|
last.end = Math.max(last.end, end); |
||||
|
} else { |
||||
|
sessions.push({ end, start }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return sessions; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Returns the start times of the intervals of the trading sessions |
||||
|
*/ |
||||
|
export function getIntradayGrid({ |
||||
|
intervalInMs, |
||||
|
sessions |
||||
|
}: { |
||||
|
intervalInMs: number; |
||||
|
sessions: { end: number; start: number }[]; |
||||
|
}) { |
||||
|
const grid = new Set<number>(); |
||||
|
|
||||
|
for (const { end, start } of sessions) { |
||||
|
for ( |
||||
|
let time = Math.floor(start / intervalInMs) * intervalInMs; |
||||
|
time < end; |
||||
|
time += intervalInMs |
||||
|
) { |
||||
|
grid.add(time); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return [...grid].sort((a, b) => { |
||||
|
return a - b; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Returns the closing price before the start of the date range, preferring |
||||
|
* the close of the regular session over the post-market |
||||
|
*/ |
||||
|
export function getBaselinePrice({ |
||||
|
instrument: { livePrice, prices, tradingPeriods }, |
||||
|
start |
||||
|
}: { |
||||
|
instrument: IntradayInstrument; |
||||
|
start: number; |
||||
|
}) { |
||||
|
const lastRegularEnd = Math.max( |
||||
|
...tradingPeriods |
||||
|
.filter(({ end, type }) => { |
||||
|
return type === 'regular' && end <= start; |
||||
|
}) |
||||
|
.map(({ end }) => { |
||||
|
return end; |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
const limit = Number.isFinite(lastRegularEnd) ? lastRegularEnd : start; |
||||
|
|
||||
|
return ( |
||||
|
getPriceAt({ prices, time: limit - 1 }) ?? |
||||
|
getPriceAt({ prices, time: start - 1 }) ?? |
||||
|
prices[0]?.price ?? |
||||
|
livePrice |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Returns the last price at or before the given time |
||||
|
*/ |
||||
|
export function getPriceAt({ |
||||
|
prices, |
||||
|
time |
||||
|
}: { |
||||
|
prices: IntradayInstrument['prices']; |
||||
|
time: number; |
||||
|
}) { |
||||
|
let low = 0; |
||||
|
let high = prices.length - 1; |
||||
|
let result: number | undefined; |
||||
|
|
||||
|
while (low <= high) { |
||||
|
const middle = Math.floor((low + high) / 2); |
||||
|
|
||||
|
if (prices[middle].time <= time) { |
||||
|
result = prices[middle].price; |
||||
|
low = middle + 1; |
||||
|
} else { |
||||
|
high = middle - 1; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
function getPriceOfInterval({ |
||||
|
baseline, |
||||
|
instrument, |
||||
|
intervalInMs, |
||||
|
now, |
||||
|
time |
||||
|
}: { |
||||
|
baseline: number; |
||||
|
instrument: IntradayInstrument; |
||||
|
intervalInMs: number; |
||||
|
now: number; |
||||
|
time: number; |
||||
|
}) { |
||||
|
if ( |
||||
|
instrument.livePrice !== undefined && |
||||
|
now >= time && |
||||
|
now < time + intervalInMs |
||||
|
) { |
||||
|
return instrument.livePrice; |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
getPriceAt({ prices: instrument.prices, time: time + intervalInMs - 1 }) ?? |
||||
|
baseline |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Returns the change of the value of the holdings since the start of the date |
||||
|
* range per interval, adjusted for the buys and sells in the date range |
||||
|
*/ |
||||
|
export function getPortfolioIntradayItems({ |
||||
|
cashFlows, |
||||
|
grid, |
||||
|
holdings, |
||||
|
intervalInMs, |
||||
|
now, |
||||
|
start |
||||
|
}: { |
||||
|
cashFlows: IntradayCashFlow[]; |
||||
|
grid: number[]; |
||||
|
holdings: IntradayHolding[]; |
||||
|
intervalInMs: number; |
||||
|
now: number; |
||||
|
start: number; |
||||
|
}): PortfolioIntradayItem[] { |
||||
|
const getQuantityAt = (aHolding: IntradayHolding, aTime: number) => { |
||||
|
// Revert the changes after the given time
|
||||
|
return aHolding.quantityChanges.reduce((quantity, change) => { |
||||
|
return change.time > aTime ? quantity - change.quantity : quantity; |
||||
|
}, aHolding.quantity); |
||||
|
}; |
||||
|
|
||||
|
const baselines = holdings.map((holding) => { |
||||
|
return getBaselinePrice({ start, instrument: holding }) ?? 0; |
||||
|
}); |
||||
|
|
||||
|
const startValue = holdings.reduce((sum, holding, index) => { |
||||
|
return ( |
||||
|
sum + |
||||
|
getQuantityAt(holding, start) * baselines[index] * holding.exchangeRate |
||||
|
); |
||||
|
}, 0); |
||||
|
|
||||
|
return grid.map((time) => { |
||||
|
if (time > now) { |
||||
|
return { date: new Date(time).toISOString(), value: null }; |
||||
|
} |
||||
|
|
||||
|
const value = holdings.reduce((sum, holding, index) => { |
||||
|
return ( |
||||
|
sum + |
||||
|
getQuantityAt(holding, time + intervalInMs - 1) * |
||||
|
getPriceOfInterval({ |
||||
|
intervalInMs, |
||||
|
now, |
||||
|
time, |
||||
|
baseline: baselines[index], |
||||
|
instrument: holding |
||||
|
}) * |
||||
|
holding.exchangeRate |
||||
|
); |
||||
|
}, 0); |
||||
|
|
||||
|
const cashFlow = cashFlows.reduce((sum, { amount, time: flowTime }) => { |
||||
|
return flowTime >= start && flowTime < time + intervalInMs |
||||
|
? sum + amount |
||||
|
: sum; |
||||
|
}, 0); |
||||
|
|
||||
|
const investment = startValue + Math.max(cashFlow, 0); |
||||
|
|
||||
|
return { |
||||
|
date: new Date(time).toISOString(), |
||||
|
value: investment > 0 ? (value - startValue - cashFlow) / investment : 0 |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Returns the change of the price of an instrument since the start of the |
||||
|
* date range per interval |
||||
|
*/ |
||||
|
export function getInstrumentIntradayItems({ |
||||
|
grid, |
||||
|
instrument, |
||||
|
intervalInMs, |
||||
|
now, |
||||
|
start |
||||
|
}: { |
||||
|
grid: number[]; |
||||
|
instrument: IntradayInstrument; |
||||
|
intervalInMs: number; |
||||
|
now: number; |
||||
|
start: number; |
||||
|
}): PortfolioIntradayItem[] { |
||||
|
const baseline = getBaselinePrice({ instrument, start }); |
||||
|
|
||||
|
// The line of an instrument is only drawn while it is traded
|
||||
|
const pricesInRange = instrument.prices.filter(({ time }) => { |
||||
|
return time >= start; |
||||
|
}); |
||||
|
const firstTime = pricesInRange[0]?.time ?? Number.POSITIVE_INFINITY; |
||||
|
const lastTime = Math.max( |
||||
|
pricesInRange.at(-1)?.time ?? Number.NEGATIVE_INFINITY, |
||||
|
instrument.livePrice !== undefined ? now : Number.NEGATIVE_INFINITY |
||||
|
); |
||||
|
|
||||
|
const isTraded = (aTime: number) => { |
||||
|
return ( |
||||
|
instrument.tradingPeriods.length === 0 || |
||||
|
instrument.tradingPeriods.some(({ end, start }) => { |
||||
|
return aTime + intervalInMs > start && aTime < end; |
||||
|
}) |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
return grid.map((time) => { |
||||
|
if ( |
||||
|
time > now || |
||||
|
!baseline || |
||||
|
time + intervalInMs <= firstTime || |
||||
|
time > lastTime || |
||||
|
!isTraded(time) |
||||
|
) { |
||||
|
return { date: new Date(time).toISOString(), value: null }; |
||||
|
} |
||||
|
|
||||
|
const price = getPriceOfInterval({ |
||||
|
baseline, |
||||
|
instrument, |
||||
|
intervalInMs, |
||||
|
now, |
||||
|
time |
||||
|
}); |
||||
|
|
||||
|
return { date: new Date(time).toISOString(), value: price / baseline - 1 }; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Whether the current time is in a trading period (including the pre-market |
||||
|
* and the post-market) of one of the instruments |
||||
|
*/ |
||||
|
export function isAnyMarketOpen({ |
||||
|
instruments, |
||||
|
now |
||||
|
}: { |
||||
|
instruments: IntradayInstrument[]; |
||||
|
now: number; |
||||
|
}) { |
||||
|
return instruments.some(({ tradingPeriods }) => { |
||||
|
return tradingPeriods.some(({ end, start }) => { |
||||
|
return now >= start && now < end; |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,251 @@ |
|||||
|
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; |
||||
|
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { RedisCacheService } from '@ghostfolio/api/services/redis-cache/redis-cache.service'; |
||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
||||
|
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; |
||||
|
import { PortfolioIntradayResponse } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { Injectable, Logger } from '@nestjs/common'; |
||||
|
import { AssetSubClass, DataSource } from '@prisma/client'; |
||||
|
import { startOfMonth, startOfWeek, subDays } from 'date-fns'; |
||||
|
|
||||
|
import { PortfolioService } from '../portfolio.service'; |
||||
|
import { |
||||
|
getInstrumentIntradayItems, |
||||
|
getIntradayGrid, |
||||
|
getIntradaySessions, |
||||
|
getPortfolioIntradayItems, |
||||
|
INTRADAY_INTERVALS, |
||||
|
IntradayDateRange, |
||||
|
IntradayHolding, |
||||
|
IntradayInstrument, |
||||
|
isAnyMarketOpen |
||||
|
} from './intraday.helper'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class IntradayService { |
||||
|
private static readonly CACHE_TTL_IN_MS = 60 * 1000; |
||||
|
|
||||
|
private readonly logger = new Logger(IntradayService.name); |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly activitiesService: ActivitiesService, |
||||
|
private readonly exchangeRateDataService: ExchangeRateDataService, |
||||
|
private readonly portfolioService: PortfolioService, |
||||
|
private readonly redisCacheService: RedisCacheService, |
||||
|
private readonly symbolProfileService: SymbolProfileService, |
||||
|
private readonly yahooFinanceService: YahooFinanceService |
||||
|
) {} |
||||
|
|
||||
|
public async getIntraday({ |
||||
|
benchmarkIds = [], |
||||
|
dateRange, |
||||
|
userCurrency = DEFAULT_CURRENCY, |
||||
|
userId |
||||
|
}: { |
||||
|
benchmarkIds?: string[]; |
||||
|
dateRange: IntradayDateRange; |
||||
|
userCurrency?: string; |
||||
|
userId: string; |
||||
|
}): Promise<PortfolioIntradayResponse> { |
||||
|
const now = Date.now(); |
||||
|
const { interval, intervalInMs } = INTRADAY_INTERVALS[dateRange]; |
||||
|
const from = this.getFrom(dateRange); |
||||
|
|
||||
|
const [{ holdings }, benchmarkProfiles] = await Promise.all([ |
||||
|
this.portfolioService.getDetails({ |
||||
|
userId, |
||||
|
filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }] |
||||
|
}), |
||||
|
benchmarkIds.length > 0 |
||||
|
? this.symbolProfileService.getSymbolProfilesByIds(benchmarkIds) |
||||
|
: Promise.resolve([]) |
||||
|
]); |
||||
|
|
||||
|
const positions = Object.values(holdings).filter(({ assetProfile }) => { |
||||
|
return assetProfile.assetSubClass !== AssetSubClass.CASH; |
||||
|
}); |
||||
|
|
||||
|
const { activities } = await this.activitiesService.getActivities({ |
||||
|
userCurrency, |
||||
|
userId, |
||||
|
startDate: subDays(from, 1), |
||||
|
types: ['BUY', 'SELL'] |
||||
|
}); |
||||
|
|
||||
|
const intradayHoldings: IntradayHolding[] = await Promise.all( |
||||
|
positions.map(async ({ assetProfile, marketPrice, quantity }) => { |
||||
|
const instrument = await this.getInstrument({ |
||||
|
from, |
||||
|
interval, |
||||
|
dataSource: assetProfile.dataSource, |
||||
|
symbol: assetProfile.symbol |
||||
|
}); |
||||
|
|
||||
|
const activitiesOfHolding = activities.filter( |
||||
|
({ assetProfile: { dataSource, symbol } }) => { |
||||
|
return ( |
||||
|
dataSource === assetProfile.dataSource && |
||||
|
symbol === assetProfile.symbol |
||||
|
); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
...instrument, |
||||
|
quantity, |
||||
|
exchangeRate: this.exchangeRateDataService.toCurrency( |
||||
|
1, |
||||
|
assetProfile.currency, |
||||
|
userCurrency |
||||
|
), |
||||
|
livePrice: marketPrice, |
||||
|
quantityChanges: activitiesOfHolding.map( |
||||
|
({ date, quantity: activityQuantity, type }) => { |
||||
|
return { |
||||
|
quantity: |
||||
|
type === 'SELL' ? -activityQuantity : activityQuantity, |
||||
|
time: new Date(date).getTime() |
||||
|
}; |
||||
|
} |
||||
|
) |
||||
|
}; |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
const benchmarks = await Promise.all( |
||||
|
benchmarkProfiles.map(async ({ dataSource, id, symbol }) => { |
||||
|
return { |
||||
|
id, |
||||
|
instrument: await this.getInstrument({ |
||||
|
dataSource, |
||||
|
from, |
||||
|
interval, |
||||
|
symbol |
||||
|
}) |
||||
|
}; |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
const sessions = getIntradaySessions({ |
||||
|
dateRange, |
||||
|
now, |
||||
|
instruments: [ |
||||
|
...intradayHoldings, |
||||
|
...benchmarks.map(({ instrument }) => { |
||||
|
return instrument; |
||||
|
}) |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
const grid = getIntradayGrid({ intervalInMs, sessions }); |
||||
|
const start = sessions[0]?.start ?? now; |
||||
|
|
||||
|
return { |
||||
|
interval, |
||||
|
benchmarks: benchmarks.map(({ id, instrument }) => { |
||||
|
return { |
||||
|
id, |
||||
|
items: getInstrumentIntradayItems({ |
||||
|
grid, |
||||
|
instrument, |
||||
|
intervalInMs, |
||||
|
now, |
||||
|
start |
||||
|
}) |
||||
|
}; |
||||
|
}), |
||||
|
chart: getPortfolioIntradayItems({ |
||||
|
grid, |
||||
|
intervalInMs, |
||||
|
now, |
||||
|
start, |
||||
|
cashFlows: activities.map( |
||||
|
({ date, feeInBaseCurrency, type, valueInBaseCurrency }) => { |
||||
|
return { |
||||
|
amount: |
||||
|
type === 'SELL' |
||||
|
? -(valueInBaseCurrency - feeInBaseCurrency) |
||||
|
: valueInBaseCurrency + feeInBaseCurrency, |
||||
|
time: new Date(date).getTime() |
||||
|
}; |
||||
|
} |
||||
|
), |
||||
|
holdings: intradayHoldings |
||||
|
}), |
||||
|
isMarketOpen: isAnyMarketOpen({ now, instruments: intradayHoldings }) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private getFrom(aDateRange: IntradayDateRange) { |
||||
|
const today = new Date(); |
||||
|
|
||||
|
// A few days more to get the close before the start of the date range
|
||||
|
switch (aDateRange) { |
||||
|
case '1d': |
||||
|
return subDays(today, 5); |
||||
|
case 'mtd': |
||||
|
return subDays(startOfMonth(today), 5); |
||||
|
case 'wtd': |
||||
|
return subDays(startOfWeek(today, { weekStartsOn: 1 }), 5); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async getInstrument({ |
||||
|
dataSource, |
||||
|
from, |
||||
|
interval, |
||||
|
symbol |
||||
|
}: { |
||||
|
dataSource: DataSource; |
||||
|
from: Date; |
||||
|
interval: '5m' | '15m' | '60m'; |
||||
|
symbol: string; |
||||
|
}): Promise<IntradayInstrument> { |
||||
|
if (dataSource !== DataSource.YAHOO) { |
||||
|
// No intraday data: the value is constant
|
||||
|
return { prices: [], tradingPeriods: [] }; |
||||
|
} |
||||
|
|
||||
|
const key = `intraday-${dataSource}-${symbol}-${interval}-${from |
||||
|
.toISOString() |
||||
|
.slice(0, 10)}`;
|
||||
|
|
||||
|
try { |
||||
|
const cached = await this.redisCacheService.get(key); |
||||
|
|
||||
|
if (cached) { |
||||
|
return JSON.parse(cached) as IntradayInstrument; |
||||
|
} |
||||
|
} catch {} |
||||
|
|
||||
|
try { |
||||
|
const { prices, tradingPeriods } = |
||||
|
await this.yahooFinanceService.getIntraday({ from, interval, symbol }); |
||||
|
|
||||
|
const instrument: IntradayInstrument = { |
||||
|
prices: prices.map(({ date, price }) => { |
||||
|
return { price, time: new Date(date).getTime() }; |
||||
|
}), |
||||
|
tradingPeriods: tradingPeriods.map(({ end, start, type }) => { |
||||
|
return { type, end: end.getTime(), start: start.getTime() }; |
||||
|
}) |
||||
|
}; |
||||
|
|
||||
|
await this.redisCacheService.set( |
||||
|
key, |
||||
|
JSON.stringify(instrument), |
||||
|
IntradayService.CACHE_TTL_IN_MS |
||||
|
); |
||||
|
|
||||
|
return instrument; |
||||
|
} catch (error) { |
||||
|
this.logger.warn( |
||||
|
`Could not get the intraday prices of ${symbol} (${dataSource}): ${error?.message}` |
||||
|
); |
||||
|
|
||||
|
return { prices: [], tradingPeriods: [] }; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,141 @@ |
|||||
|
import { addDays, format } from 'date-fns'; |
||||
|
|
||||
|
import { |
||||
|
getTimeAxisUnit, |
||||
|
getTimeSeriesTickCallback, |
||||
|
getTradingDayItems |
||||
|
} from './chart-helper'; |
||||
|
import { DATE_FORMAT } from './helper'; |
||||
|
|
||||
|
describe('getTimeAxisUnit', () => { |
||||
|
it.each([ |
||||
|
['1d', 'hour'], |
||||
|
['wtd', 'day'], |
||||
|
['mtd', 'day'], |
||||
|
['ytd', 'month'], |
||||
|
['1y', false], |
||||
|
['2025', 'month'], |
||||
|
['5y', false], |
||||
|
['max', false], |
||||
|
[undefined, 'year'] |
||||
|
])('uses the unit of %s', (dateRange, unit) => { |
||||
|
expect(getTimeAxisUnit(dateRange)).toBe(unit); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('getTradingDayItems', () => { |
||||
|
// Thursday
|
||||
|
const now = new Date(2026, 11, 24); |
||||
|
|
||||
|
it('skips the weekends', () => { |
||||
|
const items = getTradingDayItems({ |
||||
|
dateRange: '1y', |
||||
|
now, |
||||
|
items: [ |
||||
|
{ date: '2026-12-18', value: 1 }, |
||||
|
{ date: '2026-12-19', value: 1 }, |
||||
|
{ date: '2026-12-20', value: 1 }, |
||||
|
{ date: '2026-12-21', value: 2 } |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
expect(items.map(({ date }) => date)).toEqual(['2026-12-18', '2026-12-21']); |
||||
|
}); |
||||
|
|
||||
|
it('adds the remaining trading days of the year', () => { |
||||
|
const items = getTradingDayItems({ |
||||
|
dateRange: 'ytd', |
||||
|
now, |
||||
|
items: [{ date: '2026-12-24', value: 1 }] |
||||
|
}); |
||||
|
|
||||
|
expect(items).toEqual([ |
||||
|
{ date: '2026-12-24', value: 1 }, |
||||
|
{ date: '2026-12-25', value: null }, |
||||
|
{ date: '2026-12-28', value: null }, |
||||
|
{ date: '2026-12-29', value: null }, |
||||
|
{ date: '2026-12-30', value: null }, |
||||
|
{ date: '2026-12-31', value: null } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('getTimeSeriesTickCallback', () => { |
||||
|
it('labels the first point of each hour', () => { |
||||
|
const ticks = [ |
||||
|
new Date(2026, 9, 22, 9, 0), |
||||
|
new Date(2026, 9, 22, 9, 30), |
||||
|
new Date(2026, 9, 22, 10, 0), |
||||
|
new Date(2026, 9, 22, 10, 30) |
||||
|
].map((date) => { |
||||
|
return { value: date.getTime() }; |
||||
|
}); |
||||
|
|
||||
|
const callback = getTimeSeriesTickCallback('hour'); |
||||
|
|
||||
|
expect( |
||||
|
ticks.map(({ value }, index) => { |
||||
|
return callback(value, index, ticks); |
||||
|
}) |
||||
|
).toEqual(['09:00', '', '10:00', '']); |
||||
|
}); |
||||
|
|
||||
|
it('labels the start of each trading day, also after a post-market past midnight', () => { |
||||
|
const ticks = [ |
||||
|
new Date(2026, 9, 22, 21, 0), |
||||
|
new Date(2026, 9, 22, 23, 0), |
||||
|
new Date(2026, 9, 23, 1, 0), |
||||
|
new Date(2026, 9, 23, 9, 0) |
||||
|
].map((date) => { |
||||
|
return { value: date.getTime() }; |
||||
|
}); |
||||
|
|
||||
|
const callback = getTimeSeriesTickCallback('day'); |
||||
|
|
||||
|
expect( |
||||
|
ticks.map(({ value }, index) => { |
||||
|
return callback(value, index, ticks); |
||||
|
}) |
||||
|
).toEqual(['Thu 22', '', '', 'Fri 23']); |
||||
|
}); |
||||
|
|
||||
|
it('thins out the labels', () => { |
||||
|
const ticks = Array.from({ length: 20 }, (_, index) => { |
||||
|
return { value: new Date(2026, 0, 1 + index).getTime() }; |
||||
|
}); |
||||
|
|
||||
|
const callback = getTimeSeriesTickCallback('day', 5); |
||||
|
const labels = ticks |
||||
|
.map(({ value }, index) => { |
||||
|
return callback(value, index, ticks); |
||||
|
}) |
||||
|
.filter(Boolean); |
||||
|
|
||||
|
expect(labels.length).toBeLessThanOrEqual(5); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('getTradingDayItems with many items', () => { |
||||
|
it('keeps one point per week (the last trading day)', () => { |
||||
|
const items = Array.from({ length: 28 }, (_, index) => { |
||||
|
return { |
||||
|
date: format(addDays(new Date(2026, 0, 5), index), DATE_FORMAT), |
||||
|
value: index |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
const result = getTradingDayItems({ dateRange: '1y', items, maxItems: 10 }); |
||||
|
|
||||
|
expect( |
||||
|
result.map(({ date }) => { |
||||
|
return date; |
||||
|
}) |
||||
|
).toEqual([ |
||||
|
'2026-01-05', |
||||
|
'2026-01-09', |
||||
|
'2026-01-16', |
||||
|
'2026-01-23', |
||||
|
'2026-01-30' |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,7 @@ |
|||||
|
import { NullableLineChartItem } from './line-chart-item.interface'; |
||||
|
|
||||
|
export interface BenchmarkSeries { |
||||
|
id: string; |
||||
|
items: NullableLineChartItem[]; |
||||
|
name: string; |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
export interface PortfolioIntradayItem { |
||||
|
/** Start of the interval (ISO 8601) */ |
||||
|
date: string; |
||||
|
|
||||
|
/** Change since the start of the date range, `null` for the future */ |
||||
|
value: number | null; |
||||
|
} |
||||
|
|
||||
|
export interface PortfolioIntradayResponse { |
||||
|
benchmarks: { |
||||
|
id: string; |
||||
|
items: PortfolioIntradayItem[]; |
||||
|
}[]; |
||||
|
|
||||
|
/** Change of the portfolio since the start of the date range */ |
||||
|
chart: PortfolioIntradayItem[]; |
||||
|
|
||||
|
interval: '5m' | '15m' | '60m'; |
||||
|
|
||||
|
/** Whether a market (including the pre-market and the post-market) of a holding is open */ |
||||
|
isMarketOpen: boolean; |
||||
|
} |
||||
Loading…
Reference in new issue