diff --git a/prisma/seed.mts b/prisma/seed.mts index 81f34d05a..b8ae87745 100644 --- a/prisma/seed.mts +++ b/prisma/seed.mts @@ -1,5 +1,11 @@ import { PrismaPg } from '@prisma/adapter-pg'; -import { PrismaClient } from '@prisma/client'; +import { + AssetClass, + AssetSubClass, + DataSource, + PrismaClient, + Type +} from '@prisma/client'; const adapter = new PrismaPg({ connectionString: process.env.DIRECT_URL ?? process.env.DATABASE_URL @@ -7,7 +13,61 @@ const adapter = new PrismaPg({ const prisma = new PrismaClient({ adapter }); -async function main() { +const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; +const PRICE_HISTORY_DAYS = 730; + +function createSeededRandom(seed: number) { + return () => { + seed = (seed * 9301 + 49297) % 233280; + return seed / 233280; + }; +} + +function getToday() { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + return today; +} + +function getDateDaysAgo(daysAgo: number) { + return new Date(getToday().getTime() - daysAgo * ONE_DAY_IN_MS); +} + +function generatePriceHistory({ + drift, + seed, + startPrice, + volatility +}: { + drift: number; + seed: number; + startPrice: number; + volatility: number; +}) { + const random = createSeededRandom(seed); + let price = startPrice; + const prices: { date: Date; price: number }[] = []; + + for (let daysAgo = PRICE_HISTORY_DAYS; daysAgo >= 0; daysAgo--) { + price = Math.max(price * (1 + drift + (random() - 0.5) * volatility), 0.01); + prices.push({ + date: getDateDaysAgo(daysAgo), + price: Math.round(price * 100) / 100 + }); + } + + return prices; +} + +function getPriceDaysAgo( + priceHistory: { date: Date; price: number }[], + daysAgo: number +) { + const index = priceHistory.length - 1 - daysAgo; + return priceHistory[Math.max(index, 0)].price; +} + +async function seedTags() { await prisma.tag.createMany({ data: [ { @@ -23,6 +83,361 @@ async function main() { }); } +async function seedPortfolio() { + const user = await prisma.user.findFirst({ orderBy: { createdAt: 'asc' } }); + + if (!user) { + console.warn( + 'No user found. Sign in once to create a user, then run the seed again to add sample portfolio data.' + ); + return; + } + + const platformBrokerage = await prisma.platform.upsert({ + create: { + id: '9fe1a2ce-b5b3-41c7-a61f-73d6c8c346f5', + name: 'Interactive Brokers', + url: 'https://www.interactivebrokers.com' + }, + update: {}, + where: { url: 'https://www.interactivebrokers.com' } + }); + + const platformCrypto = await prisma.platform.upsert({ + create: { + id: '1ef9a79a-61e6-4499-89eb-74b665cc8261', + name: 'Binance', + url: 'https://www.binance.com' + }, + update: {}, + where: { url: 'https://www.binance.com' } + }); + + const accountBrokerageId = '6798f55e-b2b5-40ef-8270-7f245e4da4d1'; + const accountCryptoId = '0c660823-a36f-4903-9f24-71c6211399d8'; + + await prisma.account.upsert({ + create: { + id: accountBrokerageId, + balance: 2500, + currency: 'USD', + name: 'Brokerage Account', + platformId: platformBrokerage.id, + userId: user.id + }, + update: {}, + where: { id_userId: { id: accountBrokerageId, userId: user.id } } + }); + + await prisma.account.upsert({ + create: { + id: accountCryptoId, + balance: 500, + currency: 'USD', + name: 'Crypto Exchange', + platformId: platformCrypto.id, + userId: user.id + }, + update: {}, + where: { id_userId: { id: accountCryptoId, userId: user.id } } + }); + + const symbols = { + AAPL: { + id: 'd7ae0976-9f0e-4a38-81a3-7cec78806436', + symbol: 'AAPL.SEED', + name: 'Apple Inc.', + assetClass: AssetClass.EQUITY, + assetSubClass: AssetSubClass.STOCK, + priceHistory: generatePriceHistory({ + drift: 0.0006, + seed: 1, + startPrice: 150, + volatility: 0.02 + }) + }, + MSFT: { + id: '4c84f62e-20c5-4d5f-8d51-63221dc64c8a', + symbol: 'MSFT.SEED', + name: 'Microsoft Corporation', + assetClass: AssetClass.EQUITY, + assetSubClass: AssetSubClass.STOCK, + priceHistory: generatePriceHistory({ + drift: 0.0007, + seed: 2, + startPrice: 300, + volatility: 0.018 + }) + }, + VTI: { + id: 'bc960e73-2ed5-4f46-82df-2a86595148e5', + symbol: 'VTI.SEED', + name: 'Vanguard Total Stock Market ETF', + assetClass: AssetClass.EQUITY, + assetSubClass: AssetSubClass.ETF, + priceHistory: generatePriceHistory({ + drift: 0.0004, + seed: 3, + startPrice: 220, + volatility: 0.01 + }) + }, + BTC: { + id: 'f55270af-eb6c-4328-8411-71bf08f4803a', + symbol: 'BTC.SEED', + name: 'Bitcoin', + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CRYPTOCURRENCY, + priceHistory: generatePriceHistory({ + drift: 0.001, + seed: 4, + startPrice: 28000, + volatility: 0.04 + }) + } + } as const; + + const custodyFeeProfileId = 'e9cec288-3486-4f12-9dc5-9a57f3883b43'; + const custodyFeeSymbol = '2160cb24-5906-499e-b854-9c7278cb35b1'; + + for (const { + id, + symbol, + name, + assetClass, + assetSubClass, + priceHistory + } of Object.values(symbols)) { + await prisma.symbolProfile.upsert({ + create: { + id, + symbol, + assetClass, + assetSubClass, + currency: 'USD', + dataSource: DataSource.MANUAL, + name, + userId: user.id + }, + update: {}, + where: { dataSource_symbol: { dataSource: DataSource.MANUAL, symbol } } + }); + + await prisma.marketData.createMany({ + data: priceHistory.map(({ date, price }) => { + return { + date, + dataSource: DataSource.MANUAL, + marketPrice: price, + symbol + }; + }), + skipDuplicates: true + }); + } + + await prisma.symbolProfile.upsert({ + create: { + id: custodyFeeProfileId, + symbol: custodyFeeSymbol, + currency: 'USD', + dataSource: DataSource.MANUAL, + name: 'Custody Fee', + userId: user.id + }, + update: {}, + where: { + dataSource_symbol: { + dataSource: DataSource.MANUAL, + symbol: custodyFeeSymbol + } + } + }); + + const orders: { + id: string; + accountId: string; + symbolProfileId: string; + type: Type; + daysAgo: number; + quantity: number; + fee: number; + unitPrice?: number; + }[] = [ + { + id: 'bbe70675-1ff7-48e2-9af3-99e2c9bf01ba', + accountId: accountBrokerageId, + symbolProfileId: symbols.AAPL.id, + type: Type.BUY, + daysAgo: 700, + quantity: 20, + fee: 4.95 + }, + { + id: '15aa3ddc-4212-4a54-83fa-f2fad8791dfd', + accountId: accountBrokerageId, + symbolProfileId: symbols.MSFT.id, + type: Type.BUY, + daysAgo: 650, + quantity: 10, + fee: 4.95 + }, + { + id: '7a7c2607-20a2-4f00-a0ff-7ae2f85f2ff3', + accountId: accountBrokerageId, + symbolProfileId: symbols.VTI.id, + type: Type.BUY, + daysAgo: 600, + quantity: 15, + fee: 0 + }, + { + id: '46c66fe3-f12c-40d1-bb6d-8e1eee9b115f', + accountId: accountBrokerageId, + symbolProfileId: symbols.AAPL.id, + type: Type.BUY, + daysAgo: 400, + quantity: 10, + fee: 4.95 + }, + { + id: '5f835cc2-f7da-481c-8043-7011074f5045', + accountId: accountBrokerageId, + symbolProfileId: symbols.VTI.id, + type: Type.BUY, + daysAgo: 300, + quantity: 15, + fee: 0 + }, + { + id: '98d0d61c-ab30-49d1-b57c-629930a140bf', + accountId: accountBrokerageId, + symbolProfileId: symbols.MSFT.id, + type: Type.DIVIDEND, + daysAgo: 200, + quantity: 1, + fee: 0, + unitPrice: 18.5 + }, + { + id: 'ce733963-672a-4569-880a-7fc92de8baa5', + accountId: accountBrokerageId, + symbolProfileId: symbols.MSFT.id, + type: Type.BUY, + daysAgo: 150, + quantity: 5, + fee: 4.95 + }, + { + id: 'f82b04dd-ab56-4061-a67e-2fabd0303068', + accountId: accountBrokerageId, + symbolProfileId: symbols.AAPL.id, + type: Type.DIVIDEND, + daysAgo: 100, + quantity: 1, + fee: 0, + unitPrice: 9.6 + }, + { + id: '1b49f226-89bf-41e2-8613-3446e3722b34', + accountId: accountBrokerageId, + symbolProfileId: custodyFeeProfileId, + type: Type.FEE, + daysAgo: 90, + quantity: 1, + fee: 0, + unitPrice: 5 + }, + { + id: 'ca5ef988-3595-4d76-b9b1-19541ebf4fad', + accountId: accountBrokerageId, + symbolProfileId: symbols.AAPL.id, + type: Type.BUY, + daysAgo: 50, + quantity: 5, + fee: 4.95 + }, + { + id: '8352549c-68a0-4c9f-b4d3-910970219c0b', + accountId: accountCryptoId, + symbolProfileId: symbols.BTC.id, + type: Type.BUY, + daysAgo: 500, + quantity: 0.05, + fee: 10 + }, + { + id: 'c8f9bcda-215e-4a63-af43-a47de6517fcf', + accountId: accountCryptoId, + symbolProfileId: symbols.BTC.id, + type: Type.BUY, + daysAgo: 300, + quantity: 0.03, + fee: 8 + }, + { + id: '75dfaa3d-e73d-4a73-a2ef-d5cc98243b4f', + accountId: accountCryptoId, + symbolProfileId: symbols.BTC.id, + type: Type.BUY, + daysAgo: 100, + quantity: 0.02, + fee: 5 + }, + { + id: '7adf12c3-839c-4a0d-872d-13083a9b84b7', + accountId: accountCryptoId, + symbolProfileId: symbols.BTC.id, + type: Type.SELL, + daysAgo: 30, + quantity: 0.01, + fee: 5 + } + ]; + + const priceHistoryBySymbolProfileId = Object.fromEntries( + Object.values(symbols).map(({ id, priceHistory }) => [id, priceHistory]) + ); + + for (const order of orders) { + const unitPrice = + order.unitPrice ?? + getPriceDaysAgo( + priceHistoryBySymbolProfileId[order.symbolProfileId], + order.daysAgo + ); + + const data = { + accountId: order.accountId, + accountUserId: user.id, + currency: 'USD', + date: getDateDaysAgo(order.daysAgo), + fee: order.fee, + isDraft: false, + quantity: order.quantity, + symbolProfileId: order.symbolProfileId, + type: order.type, + unitPrice, + userId: user.id + }; + + await prisma.order.upsert({ + create: { id: order.id, ...data }, + update: data, + where: { id: order.id } + }); + } + + console.log( + `Seeded sample portfolio data for user ${user.id} (2 accounts, ${Object.keys(symbols).length} symbols, ${orders.length} activities).` + ); +} + +async function main() { + await seedTags(); + await seedPortfolio(); +} + main() .catch((e) => { console.error(e);