add some fund graph history for transparency
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
This commit is contained in:
@@ -41,6 +41,7 @@ model HedgeFund {
|
||||
|
||||
managers FundManager[]
|
||||
investments FundInvestment[]
|
||||
navHistory FundNavHistory[]
|
||||
|
||||
@@index([slug])
|
||||
}
|
||||
@@ -131,6 +132,17 @@ model PriceHistory {
|
||||
@@index([hashtagId, recordedAt])
|
||||
}
|
||||
|
||||
model FundNavHistory {
|
||||
id String @id @default(cuid())
|
||||
fundId String
|
||||
fund HedgeFund @relation(fields: [fundId], references: [id], onDelete: Cascade)
|
||||
nav Float
|
||||
totalValue Float
|
||||
recordedAt DateTime @default(now())
|
||||
|
||||
@@index([fundId, recordedAt])
|
||||
}
|
||||
|
||||
model Position {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
|
||||
@@ -8,6 +8,7 @@ import Link from 'next/link'
|
||||
import { Building2, TrendingUp, TrendingDown } from 'lucide-react'
|
||||
import { calcFundNav } from '@/lib/pricing'
|
||||
import InvestPanel from './InvestPanel'
|
||||
import { PriceChart } from '@/components/PriceChart'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -42,6 +43,16 @@ export default async function FundPage({ params }: { params: { slug: string } })
|
||||
|
||||
if (!fund) notFound()
|
||||
|
||||
// Fetch NAV history for the chart (last 7 days)
|
||||
const navHistory = await prisma.fundNavHistory.findMany({
|
||||
where: {
|
||||
fundId: fund.id,
|
||||
recordedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||
},
|
||||
orderBy: { recordedAt: 'asc' },
|
||||
select: { nav: true, recordedAt: true },
|
||||
})
|
||||
|
||||
// Fetch current user's balance and investment in this fund
|
||||
const [currentUser, userInvestment] = session
|
||||
? await Promise.all([
|
||||
@@ -97,6 +108,15 @@ export default async function FundPage({ params }: { params: { slug: string } })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NAV history chart */}
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-3">NAV / Share — Last 7 Days</h2>
|
||||
<PriceChart
|
||||
data={navHistory.map((p) => ({ price: p.nav, recordedAt: p.recordedAt.toISOString() }))}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{[
|
||||
|
||||
@@ -42,3 +42,11 @@ export const schedulerQueue = new Queue('hashex-scheduler', {
|
||||
removeOnFail: { count: 5 },
|
||||
},
|
||||
})
|
||||
|
||||
export const fundNavSnapshotQueue = new Queue('hashex-fund-nav-snapshot', {
|
||||
connection: redisOpts(),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: { count: 10 },
|
||||
removeOnFail: { count: 10 },
|
||||
},
|
||||
})
|
||||
|
||||
+71
-5
@@ -13,7 +13,7 @@
|
||||
import { Worker, Queue } from 'bullmq'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { getPostsData } from '../lib/mastodon'
|
||||
import { calcPrice, calcTrade, dailyResearchPoints } from '../lib/pricing'
|
||||
import { calcPrice, calcTrade, dailyResearchPoints, calcFundNav } from '../lib/pricing'
|
||||
|
||||
// ── Connection options ────────────────────────────────────────────────────────
|
||||
// Use plain connection options so BullMQ uses its own bundled ioredis,
|
||||
@@ -54,6 +54,7 @@ function activeUntilFromNow(): Date {
|
||||
const priceUpdateQueue = new Queue('hashex-price-updates', { connection: redisOpts() })
|
||||
const maintenanceQueue = new Queue('hashex-maintenance', { connection: redisOpts() })
|
||||
const schedulerQueue = new Queue('hashex-scheduler', { connection: redisOpts() })
|
||||
const fundNavSnapshotQueue = new Queue('hashex-fund-nav-snapshot', { connection: redisOpts() })
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -284,6 +285,54 @@ const maintenanceWorker = new Worker(
|
||||
{ connection: redisOpts() },
|
||||
)
|
||||
|
||||
/**
|
||||
* Fund NAV snapshot worker — records the current NAV of every fund once per hour.
|
||||
*/
|
||||
const fundNavSnapshotWorker = new Worker(
|
||||
'hashex-fund-nav-snapshot',
|
||||
async (job) => {
|
||||
console.log(`[fund-nav] snapshotting all fund NAVs (job ${job.id})`)
|
||||
|
||||
const funds = await prisma.hedgeFund.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
sharesOutstanding: true,
|
||||
user: {
|
||||
select: {
|
||||
balance: true,
|
||||
positions: {
|
||||
where: { shares: { gt: 0 } },
|
||||
select: {
|
||||
shares: true,
|
||||
positionType: true,
|
||||
avgBuyPrice: true,
|
||||
hashtag: { select: { currentPrice: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for (const fund of funds) {
|
||||
const portfolioValue = fund.user.positions.reduce((sum, p) => {
|
||||
const val = p.positionType === 'LONG'
|
||||
? p.shares * p.hashtag.currentPrice
|
||||
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
||||
return sum + val
|
||||
}, 0)
|
||||
const totalValue = fund.user.balance + portfolioValue
|
||||
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
|
||||
await prisma.fundNavHistory.create({
|
||||
data: { fundId: fund.id, nav, totalValue },
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[fund-nav] snapshotted ${funds.length} fund(s)`)
|
||||
},
|
||||
{ connection: redisOpts() },
|
||||
)
|
||||
|
||||
/**
|
||||
* Scheduler worker — triggered on a timer to enqueue price-update jobs.
|
||||
* Orders hashtags by lastUpdated ASC so the most stale ones go first.
|
||||
@@ -333,7 +382,12 @@ const schedulerWorker = new Worker(
|
||||
// ── Error handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
// Worker-level connection errors (separate from per-job failures)
|
||||
for (const [name, worker] of [['price', priceWorker], ['maintenance', maintenanceWorker], ['scheduler', schedulerWorker]] as const) {
|
||||
for (const [name, worker] of [
|
||||
['price', priceWorker],
|
||||
['maintenance', maintenanceWorker],
|
||||
['fund-nav', fundNavSnapshotWorker],
|
||||
['scheduler', schedulerWorker],
|
||||
] as const) {
|
||||
worker.on('error', (err) => {
|
||||
console.error(`[${name}-worker] connection error:`, err.message)
|
||||
})
|
||||
@@ -352,16 +406,18 @@ async function setupRepeatableJobs() {
|
||||
// Always wipe existing repeatable registrations first so that:
|
||||
// - stale entries from old PRICE_UPDATE_INTERVAL_MINUTES values don't persist
|
||||
// - jobs exhausted by BullMQ retry limits get rescheduled cleanly
|
||||
const [existingScheduler, existingMaintenance] = await Promise.all([
|
||||
const [existingScheduler, existingMaintenance, existingFundNav] = await Promise.all([
|
||||
schedulerQueue.getRepeatableJobs(),
|
||||
maintenanceQueue.getRepeatableJobs(),
|
||||
fundNavSnapshotQueue.getRepeatableJobs(),
|
||||
])
|
||||
await Promise.all([
|
||||
...existingScheduler.map((j) => schedulerQueue.removeRepeatableByKey(j.key)),
|
||||
...existingMaintenance.map((j) => maintenanceQueue.removeRepeatableByKey(j.key)),
|
||||
...existingFundNav.map((j) => fundNavSnapshotQueue.removeRepeatableByKey(j.key)),
|
||||
])
|
||||
if (existingScheduler.length || existingMaintenance.length) {
|
||||
console.log(`[worker] cleared ${existingScheduler.length} scheduler + ${existingMaintenance.length} maintenance repeatable(s)`)
|
||||
if (existingScheduler.length || existingMaintenance.length || existingFundNav.length) {
|
||||
console.log(`[worker] cleared ${existingScheduler.length} scheduler + ${existingMaintenance.length} maintenance + ${existingFundNav.length} fund-nav repeatable(s)`)
|
||||
}
|
||||
|
||||
// Price update sweep — every N minutes
|
||||
@@ -382,6 +438,15 @@ async function setupRepeatableJobs() {
|
||||
},
|
||||
)
|
||||
|
||||
// Hourly fund NAV snapshot — every hour on the hour
|
||||
await fundNavSnapshotQueue.add(
|
||||
'fund-nav-snapshot',
|
||||
{},
|
||||
{
|
||||
repeat: { pattern: '0 * * * *' },
|
||||
},
|
||||
)
|
||||
|
||||
// Immediately trigger a sweep on startup so prices are fresh
|
||||
await schedulerQueue.add('trigger-sweep', {}, { jobId: `sweep-startup-${Date.now()}` })
|
||||
|
||||
@@ -401,6 +466,7 @@ async function shutdown() {
|
||||
console.log('[worker] shutting down…')
|
||||
await priceWorker.close()
|
||||
await maintenanceWorker.close()
|
||||
await fundNavSnapshotWorker.close()
|
||||
await schedulerWorker.close()
|
||||
await prisma.$disconnect()
|
||||
process.exit(0)
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user