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.
421 lines
9.5 KiB
421 lines
9.5 KiB
import { PrismaClient, Provider, Role, Type } from '@prisma/client';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
const DEFAULT_ACCESS_TOKEN = 'mvp-ai-demo-token';
|
|
const PRIMARY_ACCOUNT_NAME = 'MVP Portfolio';
|
|
const SECONDARY_ACCOUNT_NAME = 'Income Portfolio';
|
|
const SEED_COMMENT_PREFIX = 'ai-mvp-seed:';
|
|
const DEFAULT_SETTINGS = {
|
|
baseCurrency: 'USD',
|
|
benchmark: 'SPY',
|
|
dateRange: 'max',
|
|
isExperimentalFeatures: true,
|
|
language: 'en',
|
|
locale: 'en-US'
|
|
};
|
|
|
|
const SEED_TRANSACTIONS = [
|
|
{
|
|
accountName: PRIMARY_ACCOUNT_NAME,
|
|
date: '2024-01-15T00:00:00.000Z',
|
|
name: 'Apple Inc.',
|
|
seedKey: 'mvp-aapl-buy-20240115',
|
|
quantity: 8,
|
|
symbol: 'AAPL',
|
|
type: Type.BUY,
|
|
unitPrice: 186.2
|
|
},
|
|
{
|
|
accountName: PRIMARY_ACCOUNT_NAME,
|
|
date: '2024-03-01T00:00:00.000Z',
|
|
name: 'Microsoft Corporation',
|
|
seedKey: 'mvp-msft-buy-20240301',
|
|
quantity: 5,
|
|
symbol: 'MSFT',
|
|
type: Type.BUY,
|
|
unitPrice: 410.5
|
|
},
|
|
{
|
|
accountName: PRIMARY_ACCOUNT_NAME,
|
|
date: '2024-04-10T00:00:00.000Z',
|
|
name: 'Tesla, Inc.',
|
|
seedKey: 'mvp-tsla-buy-20240410',
|
|
quantity: 6,
|
|
symbol: 'TSLA',
|
|
type: Type.BUY,
|
|
unitPrice: 175.15
|
|
},
|
|
{
|
|
accountName: PRIMARY_ACCOUNT_NAME,
|
|
date: '2024-05-20T00:00:00.000Z',
|
|
name: 'NVIDIA Corporation',
|
|
seedKey: 'mvp-nvda-buy-20240520',
|
|
quantity: 4,
|
|
symbol: 'NVDA',
|
|
type: Type.BUY,
|
|
unitPrice: 892.5
|
|
},
|
|
{
|
|
accountName: PRIMARY_ACCOUNT_NAME,
|
|
date: '2024-09-03T00:00:00.000Z',
|
|
name: 'Apple Inc.',
|
|
seedKey: 'mvp-aapl-sell-20240903',
|
|
quantity: 2,
|
|
symbol: 'AAPL',
|
|
type: Type.SELL,
|
|
unitPrice: 222.4
|
|
},
|
|
{
|
|
accountName: PRIMARY_ACCOUNT_NAME,
|
|
date: '2024-11-15T00:00:00.000Z',
|
|
name: 'Tesla, Inc.',
|
|
seedKey: 'mvp-tsla-sell-20241115',
|
|
quantity: 1,
|
|
symbol: 'TSLA',
|
|
type: Type.SELL,
|
|
unitPrice: 248.75
|
|
},
|
|
{
|
|
accountName: SECONDARY_ACCOUNT_NAME,
|
|
date: '2024-02-01T00:00:00.000Z',
|
|
name: 'Vanguard Total Stock Market ETF',
|
|
seedKey: 'income-vti-buy-20240201',
|
|
quantity: 12,
|
|
symbol: 'VTI',
|
|
type: Type.BUY,
|
|
unitPrice: 242.3
|
|
},
|
|
{
|
|
accountName: SECONDARY_ACCOUNT_NAME,
|
|
date: '2024-03-18T00:00:00.000Z',
|
|
name: 'Schwab U.S. Dividend Equity ETF',
|
|
seedKey: 'income-schd-buy-20240318',
|
|
quantity: 16,
|
|
symbol: 'SCHD',
|
|
type: Type.BUY,
|
|
unitPrice: 77.85
|
|
},
|
|
{
|
|
accountName: SECONDARY_ACCOUNT_NAME,
|
|
date: '2024-06-03T00:00:00.000Z',
|
|
name: 'Johnson & Johnson',
|
|
seedKey: 'income-jnj-buy-20240603',
|
|
quantity: 7,
|
|
symbol: 'JNJ',
|
|
type: Type.BUY,
|
|
unitPrice: 148.2
|
|
},
|
|
{
|
|
accountName: SECONDARY_ACCOUNT_NAME,
|
|
date: '2024-07-08T00:00:00.000Z',
|
|
name: 'Coca-Cola Company',
|
|
seedKey: 'income-ko-buy-20240708',
|
|
quantity: 10,
|
|
symbol: 'KO',
|
|
type: Type.BUY,
|
|
unitPrice: 61.4
|
|
},
|
|
{
|
|
accountName: SECONDARY_ACCOUNT_NAME,
|
|
date: '2024-12-04T00:00:00.000Z',
|
|
name: 'Schwab U.S. Dividend Equity ETF',
|
|
seedKey: 'income-schd-sell-20241204',
|
|
quantity: 4,
|
|
symbol: 'SCHD',
|
|
type: Type.SELL,
|
|
unitPrice: 80.95
|
|
},
|
|
{
|
|
accountName: SECONDARY_ACCOUNT_NAME,
|
|
date: '2025-01-14T00:00:00.000Z',
|
|
name: 'Vanguard Total Stock Market ETF',
|
|
seedKey: 'income-vti-buy-20250114',
|
|
quantity: 6,
|
|
symbol: 'VTI',
|
|
type: Type.BUY,
|
|
unitPrice: 258.1
|
|
}
|
|
];
|
|
|
|
async function ensureUsers() {
|
|
const existingUsers = await prisma.user.findMany({
|
|
include: {
|
|
settings: true
|
|
},
|
|
orderBy: {
|
|
createdAt: 'asc'
|
|
}
|
|
});
|
|
|
|
if (existingUsers.length === 0) {
|
|
const createdUser = await prisma.user.create({
|
|
data: {
|
|
accessToken: DEFAULT_ACCESS_TOKEN,
|
|
provider: Provider.ANONYMOUS,
|
|
role: Role.ADMIN,
|
|
settings: {
|
|
create: {
|
|
settings: DEFAULT_SETTINGS
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return [createdUser.id];
|
|
}
|
|
|
|
for (const user of existingUsers) {
|
|
if (!user.accessToken) {
|
|
await prisma.user.update({
|
|
data: {
|
|
accessToken: DEFAULT_ACCESS_TOKEN
|
|
},
|
|
where: {
|
|
id: user.id
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!user.settings) {
|
|
await prisma.settings.create({
|
|
data: {
|
|
settings: DEFAULT_SETTINGS,
|
|
userId: user.id
|
|
}
|
|
});
|
|
} else {
|
|
await prisma.settings.update({
|
|
data: {
|
|
settings: {
|
|
...(user.settings.settings ?? {}),
|
|
isExperimentalFeatures: true
|
|
}
|
|
},
|
|
where: {
|
|
userId: user.id
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return existingUsers.map(({ id }) => id);
|
|
}
|
|
|
|
async function buildSeedResult({ perUserResults }) {
|
|
const orderedResults = perUserResults.sort((a, b) => {
|
|
return a.userId.localeCompare(b.userId);
|
|
});
|
|
const primaryUserResult = orderedResults[0];
|
|
const primaryUser = primaryUserResult
|
|
? await prisma.user.findUnique({
|
|
where: {
|
|
id: primaryUserResult.userId
|
|
}
|
|
})
|
|
: undefined;
|
|
|
|
return {
|
|
createdOrders: orderedResults.reduce((acc, current) => {
|
|
return acc + current.createdOrders;
|
|
}, 0),
|
|
existingSeedOrders: orderedResults.reduce((acc, current) => {
|
|
return acc + current.existingSeedOrders;
|
|
}, 0),
|
|
message:
|
|
'AI MVP data is ready. Use /portfolio/analysis and /portfolio/activities to test.',
|
|
perUserResults: orderedResults,
|
|
seededUsers: orderedResults.length,
|
|
userAccessToken: primaryUser?.accessToken ?? DEFAULT_ACCESS_TOKEN
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const userIds = await ensureUsers();
|
|
const perUserResults = [];
|
|
const accountNames = [...new Set(SEED_TRANSACTIONS.map(({ accountName }) => {
|
|
return accountName;
|
|
}))];
|
|
|
|
for (const userId of userIds) {
|
|
const accountsByName = {};
|
|
|
|
for (const accountName of accountNames) {
|
|
accountsByName[accountName] = await ensureAccount({
|
|
accountName,
|
|
userId
|
|
});
|
|
}
|
|
|
|
const { createdOrders, existingSeedOrders } = await ensurePositions({
|
|
accountsByName,
|
|
userId
|
|
});
|
|
|
|
perUserResults.push({
|
|
accounts: Object.values(accountsByName).map(({ id, name }) => {
|
|
return { accountId: id, accountName: name };
|
|
}),
|
|
createdOrders,
|
|
existingSeedOrders,
|
|
userId
|
|
});
|
|
}
|
|
|
|
const result = await buildSeedResult({
|
|
perUserResults
|
|
});
|
|
|
|
console.log(JSON.stringify(result, null, 2));
|
|
}
|
|
|
|
async function ensureAccount({ accountName, userId }) {
|
|
const existingNamedAccount = await prisma.account.findFirst({
|
|
where: {
|
|
name: accountName,
|
|
userId
|
|
}
|
|
});
|
|
|
|
if (existingNamedAccount) {
|
|
if (existingNamedAccount.currency) {
|
|
return existingNamedAccount;
|
|
}
|
|
|
|
return prisma.account.update({
|
|
data: {
|
|
currency: 'USD'
|
|
},
|
|
where: {
|
|
id_userId: {
|
|
id: existingNamedAccount.id,
|
|
userId
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (accountName === PRIMARY_ACCOUNT_NAME) {
|
|
const fallbackAccount = await prisma.account.findFirst({
|
|
orderBy: {
|
|
createdAt: 'asc'
|
|
},
|
|
where: {
|
|
userId
|
|
}
|
|
});
|
|
|
|
if (fallbackAccount) {
|
|
return prisma.account.update({
|
|
data: {
|
|
currency: fallbackAccount.currency ?? 'USD',
|
|
name: accountName
|
|
},
|
|
where: {
|
|
id_userId: {
|
|
id: fallbackAccount.id,
|
|
userId
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return prisma.account.create({
|
|
data: {
|
|
currency: 'USD',
|
|
name: accountName,
|
|
userId
|
|
}
|
|
});
|
|
}
|
|
|
|
async function ensurePositions({ accountsByName, userId }) {
|
|
let createdCount = 0;
|
|
|
|
for (const transaction of SEED_TRANSACTIONS) {
|
|
const account = accountsByName[transaction.accountName];
|
|
|
|
if (!account) {
|
|
throw new Error(`Missing account mapping for ${transaction.accountName}`);
|
|
}
|
|
|
|
const symbolProfile = await prisma.symbolProfile.upsert({
|
|
create: {
|
|
assetClass: 'EQUITY',
|
|
assetSubClass:
|
|
transaction.symbol.endsWith('ETF') || ['VTI', 'SCHD'].includes(transaction.symbol)
|
|
? 'ETF'
|
|
: 'STOCK',
|
|
currency: 'USD',
|
|
dataSource: 'YAHOO',
|
|
name: transaction.name,
|
|
symbol: transaction.symbol
|
|
},
|
|
update: {
|
|
assetClass: 'EQUITY',
|
|
assetSubClass:
|
|
transaction.symbol.endsWith('ETF') || ['VTI', 'SCHD'].includes(transaction.symbol)
|
|
? 'ETF'
|
|
: 'STOCK',
|
|
currency: 'USD',
|
|
isActive: true,
|
|
name: transaction.name
|
|
},
|
|
where: {
|
|
dataSource_symbol: {
|
|
dataSource: 'YAHOO',
|
|
symbol: transaction.symbol
|
|
}
|
|
}
|
|
});
|
|
|
|
const seedComment = `${SEED_COMMENT_PREFIX}${transaction.seedKey}`;
|
|
const existingOrder = await prisma.order.findFirst({
|
|
where: {
|
|
comment: seedComment,
|
|
userId
|
|
}
|
|
});
|
|
|
|
if (!existingOrder) {
|
|
await prisma.order.create({
|
|
data: {
|
|
accountId: account.id,
|
|
accountUserId: userId,
|
|
comment: seedComment,
|
|
currency: 'USD',
|
|
date: new Date(transaction.date),
|
|
fee: 1,
|
|
quantity: transaction.quantity,
|
|
symbolProfileId: symbolProfile.id,
|
|
type: transaction.type,
|
|
unitPrice: transaction.unitPrice,
|
|
userId
|
|
}
|
|
});
|
|
|
|
createdCount += 1;
|
|
}
|
|
}
|
|
|
|
const existingSeedOrders = await prisma.order.count({
|
|
where: {
|
|
comment: {
|
|
startsWith: SEED_COMMENT_PREFIX
|
|
},
|
|
userId
|
|
}
|
|
});
|
|
|
|
return { createdOrders: createdCount, existingSeedOrders };
|
|
}
|
|
|
|
main()
|
|
.catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|