mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
469 lines
11 KiB
469 lines
11 KiB
import {
|
|
CreateAccountWithBalancesDto,
|
|
CreateAssetProfileWithMarketDataDto,
|
|
CreateOrderDto,
|
|
CreateTagDto
|
|
} from '@ghostfolio/common/dtos';
|
|
import { parseDate as parseDateHelper } from '@ghostfolio/common/helper';
|
|
import { Activity } from '@ghostfolio/common/interfaces';
|
|
|
|
import { HttpClient } from '@angular/common/http';
|
|
import { inject, Injectable } from '@angular/core';
|
|
import { Account, DataSource, Type as ActivityType } from '@prisma/client';
|
|
import { isFinite, isNumber, isString } from 'lodash';
|
|
import { parse as csvToJson } from 'papaparse';
|
|
import { firstValueFrom } from 'rxjs';
|
|
|
|
@Injectable({
|
|
providedIn: 'root'
|
|
})
|
|
export class ImportActivitiesService {
|
|
private static ACCOUNT_KEYS = ['account', 'accountid'];
|
|
private static COMMENT_KEYS = ['comment', 'note'];
|
|
private static CURRENCY_KEYS = ['ccy', 'currency', 'currencyprimary'];
|
|
private static DATA_SOURCE_KEYS = ['datasource'];
|
|
private static DATE_KEYS = ['date', 'tradedate'];
|
|
private static FEE_KEYS = ['commission', 'fee', 'ibcommission'];
|
|
private static QUANTITY_KEYS = ['qty', 'quantity', 'shares', 'units'];
|
|
private static SYMBOL_KEYS = ['code', 'symbol', 'ticker'];
|
|
private static TYPE_KEYS = ['action', 'buy/sell', 'type'];
|
|
private static UNIT_PRICE_KEYS = [
|
|
'price',
|
|
'tradeprice',
|
|
'unitprice',
|
|
'value'
|
|
];
|
|
|
|
private readonly http = inject(HttpClient);
|
|
|
|
public async importCsv({
|
|
fileContent,
|
|
isDryRun = false,
|
|
userAccounts
|
|
}: {
|
|
fileContent: string;
|
|
isDryRun?: boolean;
|
|
userAccounts: Account[];
|
|
}): Promise<{
|
|
activities: Activity[];
|
|
assetProfiles: CreateAssetProfileWithMarketDataDto[];
|
|
}> {
|
|
const content = csvToJson<Record<string, unknown>>(fileContent, {
|
|
dynamicTyping: true,
|
|
header: true,
|
|
skipEmptyLines: true
|
|
}).data;
|
|
|
|
const activities: CreateOrderDto[] = [];
|
|
const assetProfiles: CreateAssetProfileWithMarketDataDto[] = [];
|
|
|
|
for (const [index, item] of content.entries()) {
|
|
const currency = this.parseCurrency({ content, index, item });
|
|
const dataSource = this.parseDataSource({ item });
|
|
const symbol = this.parseSymbol({ content, index, item });
|
|
const type = this.parseType({ content, index, item });
|
|
|
|
activities.push({
|
|
currency,
|
|
dataSource,
|
|
symbol,
|
|
type,
|
|
accountId: this.parseAccount({ item, userAccounts }),
|
|
comment: this.parseComment({ item }),
|
|
date: this.parseDate({ content, index, item }),
|
|
fee: this.parseFee({ content, index, item }),
|
|
quantity: this.parseQuantity({ content, index, item }),
|
|
unitPrice: this.parseUnitPrice({ content, index, item }),
|
|
updateAccountBalance: false
|
|
});
|
|
|
|
if (dataSource === DataSource.MANUAL) {
|
|
// Create synthetic asset profile for MANUAL data source
|
|
assetProfiles.push({
|
|
currency,
|
|
symbol,
|
|
assetClass: undefined,
|
|
assetSubClass: undefined,
|
|
comment: undefined,
|
|
countries: [],
|
|
cusip: undefined,
|
|
dataSource: DataSource.MANUAL,
|
|
figi: undefined,
|
|
figiComposite: undefined,
|
|
figiShareClass: undefined,
|
|
holdings: [],
|
|
isActive: true,
|
|
isin: undefined,
|
|
marketData: [],
|
|
name: symbol,
|
|
sectors: [],
|
|
url: undefined
|
|
});
|
|
}
|
|
}
|
|
|
|
const result = await this.importJson({
|
|
activities,
|
|
assetProfiles,
|
|
isDryRun
|
|
});
|
|
return { ...result, assetProfiles };
|
|
}
|
|
|
|
public importJson({
|
|
accounts,
|
|
activities,
|
|
assetProfiles,
|
|
isDryRun = false,
|
|
tags
|
|
}: {
|
|
activities: CreateOrderDto[];
|
|
accounts?: CreateAccountWithBalancesDto[];
|
|
assetProfiles?: CreateAssetProfileWithMarketDataDto[];
|
|
isDryRun?: boolean;
|
|
tags?: CreateTagDto[];
|
|
}): Promise<{
|
|
activities: Activity[];
|
|
}> {
|
|
return firstValueFrom(
|
|
this.postImport(
|
|
{
|
|
accounts,
|
|
activities,
|
|
assetProfiles,
|
|
tags
|
|
},
|
|
isDryRun
|
|
)
|
|
);
|
|
}
|
|
|
|
public importSelectedActivities({
|
|
accounts,
|
|
activities,
|
|
assetProfiles,
|
|
tags
|
|
}: {
|
|
accounts?: CreateAccountWithBalancesDto[];
|
|
activities: Activity[];
|
|
assetProfiles?: CreateAssetProfileWithMarketDataDto[];
|
|
tags?: CreateTagDto[];
|
|
}): Promise<{
|
|
activities: Activity[];
|
|
}> {
|
|
const importData = activities.map((activity) =>
|
|
this.convertToCreateOrderDto(activity)
|
|
);
|
|
|
|
return this.importJson({
|
|
accounts,
|
|
assetProfiles,
|
|
tags,
|
|
activities: importData
|
|
});
|
|
}
|
|
|
|
private convertToCreateOrderDto({
|
|
accountId,
|
|
assetProfile,
|
|
comment,
|
|
currency,
|
|
date,
|
|
fee,
|
|
quantity,
|
|
tags,
|
|
type,
|
|
unitPrice,
|
|
updateAccountBalance
|
|
}: Activity): CreateOrderDto {
|
|
return {
|
|
fee,
|
|
quantity,
|
|
type,
|
|
unitPrice,
|
|
updateAccountBalance,
|
|
accountId: accountId ?? undefined,
|
|
comment: comment ?? undefined,
|
|
currency: currency ?? assetProfile.currency ?? '',
|
|
dataSource: assetProfile.dataSource,
|
|
date: date.toString(),
|
|
symbol: assetProfile.symbol,
|
|
tags: tags?.map(({ id }) => {
|
|
return id;
|
|
})
|
|
};
|
|
}
|
|
|
|
private lowercaseKeys(
|
|
aObject: Record<string, unknown>
|
|
): Record<string, unknown> {
|
|
return Object.fromEntries(
|
|
Object.entries(aObject).map(([key, val]) => {
|
|
return [key.toLowerCase(), val];
|
|
})
|
|
);
|
|
}
|
|
|
|
private parseAccount({
|
|
item,
|
|
userAccounts
|
|
}: {
|
|
item: Record<string, unknown>;
|
|
userAccounts: Account[];
|
|
}) {
|
|
item = this.lowercaseKeys(item);
|
|
|
|
for (const key of ImportActivitiesService.ACCOUNT_KEYS) {
|
|
const value = item[key];
|
|
|
|
if (isNumber(value) || isString(value)) {
|
|
return userAccounts.find(({ id, name }) => {
|
|
return (
|
|
id === value || name?.toLowerCase() === String(value).toLowerCase()
|
|
);
|
|
})?.id;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
private parseComment({ item }: { item: Record<string, unknown> }) {
|
|
item = this.lowercaseKeys(item);
|
|
|
|
for (const key of ImportActivitiesService.COMMENT_KEYS) {
|
|
const value = item[key];
|
|
|
|
if (isNumber(value) || isString(value)) {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
private parseCurrency({
|
|
content,
|
|
index,
|
|
item
|
|
}: {
|
|
content: Record<string, unknown>[];
|
|
index: number;
|
|
item: Record<string, unknown>;
|
|
}) {
|
|
item = this.lowercaseKeys(item);
|
|
|
|
for (const key of ImportActivitiesService.CURRENCY_KEYS) {
|
|
const value = item[key];
|
|
|
|
if (isString(value)) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
throw {
|
|
activities: content,
|
|
message: `activities.${index}.currency is not valid`
|
|
};
|
|
}
|
|
|
|
private parseDataSource({ item }: { item: Record<string, unknown> }) {
|
|
item = this.lowercaseKeys(item);
|
|
|
|
for (const key of ImportActivitiesService.DATA_SOURCE_KEYS) {
|
|
const value = item[key];
|
|
|
|
if (isString(value)) {
|
|
return DataSource[value.toUpperCase() as keyof typeof DataSource];
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
private parseDate({
|
|
content,
|
|
index,
|
|
item
|
|
}: {
|
|
content: Record<string, unknown>[];
|
|
index: number;
|
|
item: Record<string, unknown>;
|
|
}) {
|
|
item = this.lowercaseKeys(item);
|
|
|
|
for (const key of ImportActivitiesService.DATE_KEYS) {
|
|
const value = item[key];
|
|
|
|
if (isNumber(value) || isString(value)) {
|
|
try {
|
|
const parsedDate = parseDateHelper(String(value));
|
|
|
|
if (parsedDate) {
|
|
return parsedDate.toISOString();
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
throw {
|
|
activities: content,
|
|
message: `activities.${index}.date is not valid`
|
|
};
|
|
}
|
|
|
|
private parseFee({
|
|
content,
|
|
index,
|
|
item
|
|
}: {
|
|
content: Record<string, unknown>[];
|
|
index: number;
|
|
item: Record<string, unknown>;
|
|
}) {
|
|
item = this.lowercaseKeys(item);
|
|
|
|
for (const key of ImportActivitiesService.FEE_KEYS) {
|
|
const value = item[key];
|
|
|
|
if (isNumber(value) && isFinite(value)) {
|
|
return Math.abs(value);
|
|
}
|
|
}
|
|
|
|
throw {
|
|
activities: content,
|
|
message: `activities.${index}.fee is not valid`
|
|
};
|
|
}
|
|
|
|
private parseQuantity({
|
|
content,
|
|
index,
|
|
item
|
|
}: {
|
|
content: Record<string, unknown>[];
|
|
index: number;
|
|
item: Record<string, unknown>;
|
|
}) {
|
|
item = this.lowercaseKeys(item);
|
|
|
|
for (const key of ImportActivitiesService.QUANTITY_KEYS) {
|
|
const value = item[key];
|
|
|
|
if (isNumber(value) && isFinite(value)) {
|
|
return Math.abs(value);
|
|
}
|
|
}
|
|
|
|
throw {
|
|
activities: content,
|
|
message: `activities.${index}.quantity is not valid`
|
|
};
|
|
}
|
|
|
|
private parseSymbol({
|
|
content,
|
|
index,
|
|
item
|
|
}: {
|
|
content: Record<string, unknown>[];
|
|
index: number;
|
|
item: Record<string, unknown>;
|
|
}) {
|
|
item = this.lowercaseKeys(item);
|
|
|
|
for (const key of ImportActivitiesService.SYMBOL_KEYS) {
|
|
const value = item[key];
|
|
|
|
if (isNumber(value) || isString(value)) {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
throw {
|
|
activities: content,
|
|
message: `activities.${index}.symbol is not valid`
|
|
};
|
|
}
|
|
|
|
private parseType({
|
|
content,
|
|
index,
|
|
item
|
|
}: {
|
|
content: Record<string, unknown>[];
|
|
index: number;
|
|
item: Record<string, unknown>;
|
|
}): ActivityType {
|
|
item = this.lowercaseKeys(item);
|
|
|
|
for (const key of ImportActivitiesService.TYPE_KEYS) {
|
|
const value = item[key];
|
|
|
|
if (isString(value)) {
|
|
switch (value.toLowerCase()) {
|
|
case 'buy':
|
|
return 'BUY';
|
|
case 'dividend':
|
|
return 'DIVIDEND';
|
|
case 'fee':
|
|
return 'FEE';
|
|
case 'interest':
|
|
return 'INTEREST';
|
|
case 'liability':
|
|
return 'LIABILITY';
|
|
case 'sell':
|
|
return 'SELL';
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
throw {
|
|
activities: content,
|
|
message: `activities.${index}.type is not valid`
|
|
};
|
|
}
|
|
|
|
private parseUnitPrice({
|
|
content,
|
|
index,
|
|
item
|
|
}: {
|
|
content: Record<string, unknown>[];
|
|
index: number;
|
|
item: Record<string, unknown>;
|
|
}) {
|
|
item = this.lowercaseKeys(item);
|
|
|
|
for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) {
|
|
const value = item[key];
|
|
|
|
if (isNumber(value) && isFinite(value)) {
|
|
return Math.abs(value);
|
|
}
|
|
}
|
|
|
|
throw {
|
|
activities: content,
|
|
message: `activities.${index}.unitPrice is not valid`
|
|
};
|
|
}
|
|
|
|
private postImport(
|
|
aImportData: {
|
|
accounts?: CreateAccountWithBalancesDto[];
|
|
activities: CreateOrderDto[];
|
|
assetProfiles?: CreateAssetProfileWithMarketDataDto[];
|
|
tags?: CreateTagDto[];
|
|
},
|
|
aIsDryRun = false
|
|
) {
|
|
return this.http.post<{ activities: Activity[] }>(
|
|
`/api/v1/import?dryRun=${aIsDryRun}`,
|
|
aImportData
|
|
);
|
|
}
|
|
}
|
|
|