Browse Source

Merge 4b46ffd549 into c2cb508f22

pull/8279/merge
Mister_Ideas 1 day ago
committed by GitHub
parent
commit
fd72e28188
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 22
      apps/api/src/app/portfolio/get-intraday.dto.ts
  3. 246
      apps/api/src/app/portfolio/intraday/intraday.helper.spec.ts
  4. 457
      apps/api/src/app/portfolio/intraday/intraday.helper.ts
  5. 251
      apps/api/src/app/portfolio/intraday/intraday.service.ts
  6. 19
      apps/api/src/app/portfolio/portfolio.controller.ts
  7. 2
      apps/api/src/app/portfolio/portfolio.module.ts
  8. 100
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  9. 134
      apps/client/src/app/components/home-overview/home-overview.component.ts
  10. 5
      apps/client/src/app/components/home-overview/home-overview.html
  11. 141
      libs/common/src/lib/chart-helper.spec.ts
  12. 262
      libs/common/src/lib/chart-helper.ts
  13. 14
      libs/common/src/lib/helper.spec.ts
  14. 13
      libs/common/src/lib/helper.ts
  15. 6
      libs/common/src/lib/interfaces/index.ts
  16. 22
      libs/common/src/lib/interfaces/responses/portfolio-intraday-response.interface.ts
  17. 41
      libs/ui/src/lib/line-chart/line-chart.component.ts
  18. 20
      libs/ui/src/lib/services/data.service.ts

4
CHANGELOG.md

@ -9,10 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the endpoint `GET api/v1/portfolio/intraday` to draw the performance chart of the current day (5 minutes), week (15 minutes) and month (60 minutes) on the overview page from the intraday prices of _Yahoo Finance_, including the pre-market and the post-market
- Added a time axis to the performance chart on the overview page which only contains the trading days, spans the whole current year and uses hours, days, months or years as unit according to the selected date range
- Added a link to the holdings tab in the holdings table on the overview tab of the home page (experimental)
### Changed
- Hid the performance chart on the overview page if it has less than three data points
- Reduced the performance chart on the overview page to one point per week above 400 trading days
- Improved the market price in the benchmark detail dialog by adding the currency and reducing the precision on mobile
- Improved the style of the close button in the dialog footer component
- Improved the style of the close button in the dialog header component

22
apps/api/src/app/portfolio/get-intraday.dto.ts

@ -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;
}

246
apps/api/src/app/portfolio/intraday/intraday.helper.spec.ts

@ -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();
});
});

457
apps/api/src/app/portfolio/intraday/intraday.helper.ts

@ -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;
});
});
}

251
apps/api/src/app/portfolio/intraday/intraday.service.ts

@ -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: [] };
}
}
}

19
apps/api/src/app/portfolio/portfolio.controller.ts

@ -23,6 +23,7 @@ import {
PortfolioDividendsResponse,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
PortfolioIntradayResponse,
PortfolioInvestmentsResponse,
PortfolioPerformanceResponse,
PortfolioReportResponse
@ -54,8 +55,10 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { GetDetailsDto } from './get-details.dto';
import { GetDividendsDto } from './get-dividends.dto';
import { GetHoldingsDto } from './get-holdings.dto';
import { GetIntradayDto } from './get-intraday.dto';
import { GetInvestmentsDto } from './get-investments.dto';
import { GetPerformanceDto } from './get-performance.dto';
import { IntradayService } from './intraday/intraday.service';
import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@ -65,6 +68,7 @@ export class PortfolioController {
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly intradayService: IntradayService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -410,6 +414,21 @@ export class PortfolioController {
return { holdings };
}
@Get('intraday')
@RequiresScope(scopes.portfolioRead)
@UseInterceptors(PerformanceLoggingInterceptor)
public async getIntraday(
@Impersonation() { userId }: ImpersonationContext,
@Query() { benchmarks, range }: GetIntradayDto
): Promise<PortfolioIntradayResponse> {
return this.intradayService.getIntraday({
userId,
benchmarkIds: benchmarks,
dateRange: range,
userCurrency: this.request.user.settings.settings.baseCurrency
});
}
@Get('investments')
@RequiresScope(scopes.portfolioRead)
@UseInterceptors(TransformDataSourceInRequestInterceptor)

2
apps/api/src/app/portfolio/portfolio.module.ts

@ -26,6 +26,7 @@ import { Module } from '@nestjs/common';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { CurrentRateService } from './current-rate.service';
import { IntradayService } from './intraday/intraday.service';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
import { RulesService } from './rules.service';
@ -60,6 +61,7 @@ import { RulesService } from './rules.service';
AccountBalanceService,
AccountService,
CurrentRateService,
IntradayService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService

100
apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts

@ -38,6 +38,12 @@ import type {
} from 'yahoo-finance2/modules/quoteSummary';
import type { SearchQuoteNonYahoo } from 'yahoo-finance2/modules/search';
export interface IntradayTradingPeriod {
end: Date;
start: Date;
type: 'post' | 'pre' | 'regular';
}
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
private static readonly DELISTED_ERROR_MESSAGE =
@ -196,6 +202,100 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
/**
* Returns the intraday prices (including the pre-market and the
* post-market) and the trading periods of a symbol
*/
public async getIntraday({
from,
interval,
symbol
}: {
from: Date;
interval: '5m' | '15m' | '60m';
symbol: string;
}): Promise<{
prices: { date: Date; price: number }[];
tradingPeriods: IntradayTradingPeriod[];
}> {
const { meta, quotes } = await this.yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol),
{ interval, includePrePost: true, period1: from }
);
const prices = quotes
.filter(({ close }) => {
return typeof close === 'number' && Number.isFinite(close);
})
.map(({ close, date }) => {
return { date, price: close };
});
// Depending on the exchange, the times are dates or Unix timestamps
const toDate = (aTime: unknown) => {
return typeof aTime === 'number'
? new Date(aTime < 1e12 ? aTime * 1000 : aTime)
: new Date(aTime as Date | string);
};
const toPeriod = (aPeriod?: { end: unknown; start: unknown }) => {
return aPeriod?.start && aPeriod?.end
? { end: toDate(aPeriod.end), start: toDate(aPeriod.start) }
: undefined;
};
// The trading periods are an array of days (each an array of regular
// periods), or an object with the arrays of the pre-market, regular and
// post-market periods
const tradingPeriods: IntradayTradingPeriod[] = [];
const collect = (
aValue: unknown,
aType: IntradayTradingPeriod['type'] = 'regular'
) => {
if (Array.isArray(aValue)) {
for (const item of aValue) {
collect(item, aType);
}
} else if (aValue && typeof aValue === 'object') {
const period = toPeriod(aValue as { end: unknown; start: unknown });
if (period) {
tradingPeriods.push({ ...period, type: aType });
} else {
for (const [key, item] of Object.entries(aValue)) {
collect(item, key === 'pre' || key === 'post' ? key : 'regular');
}
}
}
};
collect(meta.tradingPeriods);
// The trading periods of the current (or next) trading day
for (const [type, period] of Object.entries(
meta.currentTradingPeriod ?? {}
)) {
const currentPeriod = toPeriod(
period as { end: unknown; start: unknown }
);
if (
currentPeriod &&
!tradingPeriods.some(({ start }) => {
return start.getTime() === currentPeriod.start.getTime();
})
) {
tradingPeriods.push({
...currentPeriod,
type: type === 'pre' || type === 'post' ? type : 'regular'
});
}
}
return { prices, tradingPeriods };
}
public getMaxNumberOfSymbolsPerRequest() {
return 50;
}

134
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -2,16 +2,18 @@ import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/p
import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getTradingDayItems } from '@ghostfolio/common/chart-helper';
import {
DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE,
DEFAULT_LOCALE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config';
import { isCashPosition } from '@ghostfolio/common/helper';
import { isCashPosition, isIntradayDateRange } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
LineChartItem,
HistoricalDataItem,
NullableLineChartItem,
PortfolioPerformance,
PortfolioPosition,
User
@ -19,6 +21,7 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { DateRange } from '@ghostfolio/common/types';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services';
@ -40,6 +43,9 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { arrowForwardOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { catchError, forkJoin, map, Observable, of } from 'rxjs';
const MINIMUM_CHART_ITEMS = 3;
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -59,7 +65,23 @@ import { DeviceDetectorService } from 'ngx-device-detector';
export class GfHomeOverviewComponent implements OnInit {
protected readonly errors = signal<AssetProfileIdentifier[]>([]);
protected readonly hasImpersonationId = signal(false);
protected readonly historicalDataItems = signal<LineChartItem[] | null>(null);
protected readonly defaultDateRange = DEFAULT_DATE_RANGE;
protected readonly historicalDataItems = signal<
NullableLineChartItem[] | null
>(null);
// A line through only two points (e.g. the close of the previous day and
// now) is drawn as a misleading slope across the whole width
protected readonly hasEnoughChartData = computed(() => {
return (
(this.historicalDataItems() ?? []).filter(({ value }) => {
return value !== null;
}).length >=
(isIntradayDateRange(this.user()?.settings?.dateRange)
? 1
: MINIMUM_CHART_ITEMS)
);
});
protected readonly holdings = signal<PortfolioPosition[] | undefined>(
undefined
);
@ -171,6 +193,53 @@ export class GfHomeOverviewComponent implements OnInit {
}
}
/**
* The current day, week and month are drawn from intraday prices, with a
* fallback to the daily chart
*/
private fetchIntradayChartItems(
aDateRange: DateRange
): Observable<NullableLineChartItem[] | null> {
if (!isIntradayDateRange(aDateRange)) {
return of(null);
}
return this.dataService.fetchPortfolioIntraday({ range: aDateRange }).pipe(
map(({ chart }) => {
return chart.map(({ date, value }) => {
return { date, value: value === null ? null : value * 100 };
});
}),
catchError(() => {
return of(null);
})
);
}
private setChartItems({
chart,
dateRange
}: {
chart?: HistoricalDataItem[];
dateRange?: DateRange;
}) {
// Only the trading days, and the remaining ones of the current year
this.historicalDataItems.set(
getTradingDayItems({
dateRange,
items:
chart?.map(
({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return {
date,
value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
};
}
) ?? []
})
);
}
private update() {
const userSettings = this.user()?.settings;
@ -201,37 +270,38 @@ export class GfHomeOverviewComponent implements OnInit {
});
}
this.dataService
.fetchPortfolioPerformance({
range: userSettings?.dateRange ?? DEFAULT_DATE_RANGE
const dateRange = userSettings?.dateRange ?? DEFAULT_DATE_RANGE;
forkJoin({
intradayChart: this.fetchIntradayChartItems(dateRange),
response: this.dataService.fetchPortfolioPerformance({
range: dateRange
})
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ chart, errors, performance }) => {
this.errors.set(errors ?? []);
this.performance.set(performance);
this.historicalDataItems.set(
chart?.map(
({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return {
date,
value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
};
}
) ?? null
);
this.precision.set(2);
if (
this.deviceType() === 'mobile' &&
performance.currentValueInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.precision.set(0);
.subscribe(
({ intradayChart, response: { chart, errors, performance } }) => {
this.errors.set(errors ?? []);
this.performance.set(performance);
if (intradayChart) {
this.historicalDataItems.set(intradayChart);
} else {
this.setChartItems({ chart, dateRange });
}
this.precision.set(2);
if (
this.deviceType() === 'mobile' &&
performance.currentValueInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.precision.set(0);
}
this.isLoadingPerformance.set(false);
}
this.isLoadingPerformance.set(false);
});
);
}
}

5
apps/client/src/app/components/home-overview/home-overview.html

@ -72,14 +72,15 @@
unit="%"
[class.pe-3]="deviceType() === 'mobile'"
[colorScheme]="user()?.settings?.colorScheme"
[hidden]="historicalDataItems()?.length === 0"
[dateRange]="user()?.settings?.dateRange ?? defaultDateRange"
[hidden]="!hasEnoughChartData()"
[historicalDataItems]="historicalDataItems()"
[isAnimated]="user()?.settings?.dateRange === '1d' ? false : true"
[label]="performanceLabel"
[locale]="locale()"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showXAxis]="true"
[showYAxis]="false"
/>
</div>

141
libs/common/src/lib/chart-helper.spec.ts

@ -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'
]);
});
});

262
libs/common/src/lib/chart-helper.ts

@ -12,8 +12,9 @@ import type {
TooltipPosition
} from 'chart.js';
import type { AnnotationOptions } from 'chartjs-plugin-annotation';
import { format } from 'date-fns';
import { addDays, format, startOfWeek } from 'date-fns';
import { secondaryColorRgb } from './config';
import {
DATE_FORMAT,
DATE_FORMAT_MONTHLY,
@ -21,9 +22,10 @@ import {
getBackgroundColor,
getDateFormatString,
getLocale,
getTextColor
getTextColor,
parseDate
} from './helper';
import { ColorScheme, GroupBy } from './types';
import { ColorScheme, DateRange, GroupBy } from './types';
export function formatGroupedDate({
date,
@ -41,6 +43,21 @@ export function formatGroupedDate({
return format(date, DATE_FORMAT);
}
// Colors for the lines of the compared benchmarks, the portfolio uses the
// primary color
const BENCHMARK_COLORS = [
`rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
'rgb(255, 159, 64)',
'rgb(75, 192, 112)',
'rgb(153, 102, 255)',
'rgb(255, 99, 132)',
'rgb(201, 203, 207)'
];
export function getBenchmarkColor(index: number) {
return BENCHMARK_COLORS[index % BENCHMARK_COLORS.length];
}
export function getChartBorderColor(colorScheme?: ColorScheme) {
return `rgba(${getTextColor(colorScheme)}, 0.1)`;
}
@ -60,17 +77,221 @@ export function getChartElementsOptions(
};
}
/**
* Returns the unit of the ticks of the time axis, so that the labels fit the
* length of the date range: days up to a month, months up to a year and years
* above
*/
/**
* Returns the daily items of the trading days (Monday to Friday) and, for a
* date range with a fixed end (the current year), the remaining trading days
* without a value, so that the chart shows the whole period
*/
function getWeekKey(aDate?: Date) {
return aDate
? format(startOfWeek(aDate, { weekStartsOn: 1 }), DATE_FORMAT)
: '';
}
export function getTradingDayItems<T extends { date: string }>({
dateRange,
items,
maxItems = 400,
now = new Date()
}: {
dateRange?: DateRange;
items: T[];
/** Above, one point per week (the last trading day) is kept */
maxItems?: number;
now?: Date;
}): (T | { date: string; value: null })[] {
const isWeekend = (aDate: Date) => {
return aDate.getDay() === 0 || aDate.getDay() === 6;
};
const result: (T | { date: string; value: null })[] = items.filter(
({ date }) => {
const parsedDate = parseDate(date);
return !parsedDate || !isWeekend(parsedDate);
}
);
if (result.length > maxItems) {
const weekly = result.filter(({ date }, index) => {
const next = result[index + 1];
return (
index === 0 ||
!next ||
getWeekKey(parseDate(date)) !== getWeekKey(parseDate(next.date))
);
});
result.splice(0, result.length, ...weekly);
}
let end: Date | undefined;
let start: Date | undefined;
if (dateRange === 'ytd') {
end = new Date(now.getFullYear(), 11, 31);
start = new Date(now.getFullYear(), 0, 1);
} else if (/^\d{4}$/.test(dateRange ?? '')) {
end = new Date(Number(dateRange), 11, 31);
start = new Date(Number(dateRange), 0, 1);
}
const firstDate = result[0] ? parseDate(result[0].date) : undefined;
if (start && firstDate && firstDate < start) {
// The baseline (the close of the previous year) is shown at the start
result[0] = { ...result[0], date: format(start, DATE_FORMAT) };
}
const lastDate = items.at(-1)?.date;
const last = lastDate ? parseDate(lastDate) : undefined;
if (end && last) {
for (
let date = addDays(last, 1);
date.getTime() <= end.getTime();
date = addDays(date, 1)
) {
if (!isWeekend(date)) {
result.push({ date: format(date, DATE_FORMAT), value: null });
}
}
}
return result;
}
export function getTimeAxisUnit(
aDateRange?: DateRange
): 'day' | 'hour' | 'month' | 'year' | false {
switch (aDateRange) {
case undefined:
return 'year';
case '1d':
return 'hour';
case 'mtd':
case 'wtd':
return 'day';
case '1y':
case '5y':
case 'max':
// Chosen by the chart according to the length of the period
return false;
default:
// 'ytd' and single years
return 'month';
}
}
const TIME_SERIES_LABEL_FORMATS = {
day: 'EEE d',
hour: 'HH:mm',
month: 'MMM',
year: 'yyyy'
} as const;
/**
* Returns the labels of an axis with equidistant points (without the closed
* markets): a label at the first point of a period (hour, day, month or
* year), thinned out to at most a few labels
*/
export function getTimeSeriesTickCallback(
aUnit: 'day' | 'hour' | 'month' | 'year' | false,
aMaxLabels = 7
) {
let cache: { key: string; labels: Map<number, string> } | undefined;
return (
_value: number | string,
index: number,
ticks: { value: number }[]
) => {
const key = `${ticks.length}-${ticks[0]?.value}-${ticks.at(-1)?.value}`;
if (cache?.key !== key) {
const span = (ticks.at(-1)?.value ?? 0) - (ticks[0]?.value ?? 0);
const unit =
aUnit ||
(span > 3 * 365 * 24 * 60 * 60 * 1000
? 'year'
: span > 45 * 24 * 60 * 60 * 1000
? 'month'
: 'day');
const getPeriod = (aTime: number) => {
const date = new Date(aTime);
switch (unit) {
case 'hour':
return `${date.toDateString()} ${date.getHours()}`;
case 'month':
return `${date.getFullYear()}-${date.getMonth()}`;
default:
return `${date.getFullYear()}`;
}
};
// The first point of each period
const starts: number[] = [];
ticks.forEach(({ value }, tickIndex) => {
const previous = ticks[tickIndex - 1]?.value;
if (
tickIndex === 0 ||
(unit === 'day'
? // A trading day starts after the closed market (the post-market
// can last after midnight)
value - previous > 2 * 60 * 60 * 1000
: getPeriod(value) !== getPeriod(previous))
) {
starts.push(tickIndex);
}
});
const step = Math.max(1, Math.ceil(starts.length / aMaxLabels));
const labels = new Map<number, string>();
starts.forEach((tickIndex, startIndex) => {
if (startIndex % step === 0) {
labels.set(
tickIndex,
format(ticks[tickIndex].value, TIME_SERIES_LABEL_FORMATS[unit])
);
}
});
cache = { key, labels };
}
return cache.labels.get(index) ?? '';
};
}
export function getTimeAxisOptions({
borderWidth = 1,
colorScheme,
display = true,
locale = getLocale()
locale = getLocale(),
type = 'time',
unit = 'year',
withTime = false
}: {
borderWidth?: number;
colorScheme?: ColorScheme;
display?: boolean;
locale?: string;
}): ScaleOptions<'time'> {
type?: 'time' | 'timeseries';
unit?: 'day' | 'hour' | 'month' | 'year' | false;
/** Whether the points are intraday (the tooltip shows the time) */
withTime?: boolean;
}): ScaleOptions<'time' | 'timeseries'> {
return {
display,
border: {
@ -80,11 +301,36 @@ export function getTimeAxisOptions({
grid: {
display: false
},
ticks:
type === 'timeseries'
? {
// A label at the first point of an hour, day, month or year
autoSkip: false,
callback: getTimeSeriesTickCallback(unit),
maxRotation: 0,
source: 'data'
}
: {
autoSkip: true,
maxRotation: 0,
maxTicksLimit: 6
},
time: {
tooltipFormat: getDateFormatString(locale),
unit: 'year'
tooltipFormat: withTime
? unit === 'hour'
? 'HH:mm'
: `${getDateFormatString(locale)} HH:mm`
: getDateFormatString(locale),
displayFormats: {
day: 'EEE d',
hour: 'HH:mm',
month: unit === 'month' ? 'MMM' : 'MMM yy',
week: 'd MMM',
year: 'yyyy'
},
unit
},
type: 'time'
type
};
}

14
libs/common/src/lib/helper.spec.ts

@ -13,6 +13,7 @@ import {
getStringOrNull,
getStringOrUndefined,
isAccountBalanceInFuture,
isIntradayDateRange,
isAccountExcluded,
isCurrency,
isCurrencySymbol,
@ -857,3 +858,16 @@ describe('Helper', () => {
});
});
});
describe('isIntradayDateRange', () => {
it.each([
['1d', true],
['wtd', true],
['mtd', true],
['ytd', false],
['max', false],
[undefined, false]
] as const)('returns %s for %s', (dateRange, expected) => {
expect(isIntradayDateRange(dateRange)).toBe(expected);
});
});

13
libs/common/src/lib/helper.ts

@ -63,7 +63,7 @@ import {
PortfolioPosition,
UserSettings
} from './interfaces';
import { BenchmarkTrend, ColorScheme } from './types';
import { BenchmarkTrend, ColorScheme, DateRange } from './types';
export const DATE_FORMAT = 'yyyy-MM-dd';
export const DATE_FORMAT_MONTHLY = 'MMMM yyyy';
@ -880,3 +880,14 @@ export function resolveUserSettings({
)
};
}
/**
* Whether the date range is drawn from intraday prices (5 minutes for the
* current day, 15 minutes for the current week, 60 minutes for the current
* month) instead of daily prices
*/
export function isIntradayDateRange(
aDateRange?: DateRange
): aDateRange is '1d' | 'mtd' | 'wtd' {
return aDateRange === '1d' || aDateRange === 'mtd' || aDateRange === 'wtd';
}

6
libs/common/src/lib/interfaces/index.ts

@ -81,6 +81,10 @@ import type { PlatformsResponse } from './responses/platforms-response.interface
import type { PortfolioDividendsResponse } from './responses/portfolio-dividends-response.interface';
import type { PortfolioHoldingResponse } from './responses/portfolio-holding-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type {
PortfolioIntradayItem,
PortfolioIntradayResponse
} from './responses/portfolio-intraday-response.interface';
import type { PortfolioInvestmentsResponse } from './responses/portfolio-investments.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import type { PortfolioReportResponse } from './responses/portfolio-report.interface';
@ -180,6 +184,8 @@ export {
PortfolioDividendsResponse,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
PortfolioIntradayItem,
PortfolioIntradayResponse,
PortfolioInvestmentsResponse,
PortfolioPerformance,
PortfolioPerformanceResponse,

22
libs/common/src/lib/interfaces/responses/portfolio-intraday-response.interface.ts

@ -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;
}

41
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -1,16 +1,18 @@
import {
getChartBorderColor,
getTimeAxisOptions,
getTimeAxisUnit,
getValueAxisOptions,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper';
import {
LineChartItem,
NullableLineChartItem
} from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types';
getBackgroundColor,
getLocale,
isIntradayDateRange
} from '@ghostfolio/common/helper';
import { NullableLineChartItem } from '@ghostfolio/common/interfaces';
import { ColorScheme, DateRange } from '@ghostfolio/common/types';
import {
AfterViewInit,
@ -34,6 +36,7 @@ import {
LineElement,
PointElement,
TimeScale,
TimeSeriesScale,
Tooltip,
type TooltipOptions
} from 'chart.js';
@ -60,7 +63,8 @@ export class GfLineChartComponent
@Input() benchmarkLabel = '';
@Input() colorScheme?: ColorScheme;
@Input() currency?: string;
@Input() historicalDataItems: LineChartItem[] | null;
@Input() dateRange?: DateRange;
@Input() historicalDataItems: NullableLineChartItem[] | null;
@Input() isAnimated = false;
@Input() label: string;
@Input() locale = getLocale();
@ -90,6 +94,7 @@ export class GfLineChartComponent
PointElement,
LinearScale,
TimeScale,
TimeSeriesScale,
Tooltip
);
@ -140,7 +145,7 @@ export class GfLineChartComponent
this.isLoading = true;
const benchmarkPrices: (number | null)[] = [];
const labels: string[] = [];
const marketPrices: number[] = [];
const marketPrices: (number | null)[] = [];
this.historicalDataItems?.forEach((historicalDataItem, index) => {
benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value ?? null);
@ -207,6 +212,10 @@ export class GfLineChartComponent
? animations
: undefined;
// The date range can change while the chart stays alive
this.chart.options.scales ??= {};
this.chart.options.scales['x'] = this.getTimeAxisConfiguration();
this.chart.update();
} else {
this.chart = new Chart<'line'>(this.chartCanvas.nativeElement, {
@ -233,11 +242,7 @@ export class GfLineChartComponent
}
},
scales: {
x: getTimeAxisOptions({
colorScheme: this.colorScheme,
display: this.showXAxis,
locale: this.locale
}),
x: this.getTimeAxisConfiguration(),
y: {
...getValueAxisOptions({
colorScheme: this.colorScheme,
@ -287,6 +292,18 @@ export class GfLineChartComponent
this.isLoading = false;
}
private getTimeAxisConfiguration() {
return getTimeAxisOptions({
colorScheme: this.colorScheme,
display: this.showXAxis,
locale: this.locale,
// Equidistant points: the closed markets (nights, weekends) are skipped
type: this.dateRange ? 'timeseries' : 'time',
unit: getTimeAxisUnit(this.dateRange),
withTime: isIntradayDateRange(this.dateRange)
});
}
private getAnimationConfigurationForAxis({
axis,
labels

20
libs/ui/src/lib/services/data.service.ts

@ -53,6 +53,7 @@ import {
PortfolioDividendsResponse,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
PortfolioIntradayResponse,
PortfolioInvestmentsResponse,
PortfolioPerformanceResponse,
PortfolioReportResponse,
@ -721,6 +722,25 @@ export class DataService {
);
}
public fetchPortfolioIntraday({
benchmarks = [],
range
}: {
benchmarks?: string[];
range: '1d' | 'mtd' | 'wtd';
}) {
let params = new HttpParams().append('range', range);
if (benchmarks.length > 0) {
params = params.append('benchmarks', benchmarks.join(','));
}
return this.http.get<PortfolioIntradayResponse>(
'/api/v1/portfolio/intraday',
{ params }
);
}
public fetchPortfolioPerformance({
filters,
groupBy,

Loading…
Cancel
Save