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