From 03ee361f29c938aaff99de17a9f9727bb7baf7d9 Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Wed, 18 Mar 2026 19:59:15 -0400 Subject: [PATCH] feat: add hedge fund management features including creation, deletion, and manager assignments --- README.md | 29 +++ prisma/schema.prisma | 28 +++ prod-compose.yml | 1 + src/app/admin/funds/AdminFundActions.tsx | 230 ++++++++++++++++++++++ src/app/admin/funds/page.tsx | 24 +++ src/app/admin/layout.tsx | 1 + src/app/api/admin/funds/[fundId]/route.ts | 97 +++++++++ src/app/api/admin/funds/route.ts | 99 ++++++++++ src/app/api/trade/route.ts | 19 +- src/app/fund/[slug]/page.tsx | 218 ++++++++++++++++++++ src/app/hashtag/[tag]/TradePanel.tsx | 13 +- src/app/hashtag/[tag]/page.tsx | 52 +++-- 12 files changed, 794 insertions(+), 17 deletions(-) create mode 100644 src/app/admin/funds/AdminFundActions.tsx create mode 100644 src/app/admin/funds/page.tsx create mode 100644 src/app/api/admin/funds/[fundId]/route.ts create mode 100644 src/app/api/admin/funds/route.ts create mode 100644 src/app/fund/[slug]/page.tsx diff --git a/README.md b/README.md index 84dd0d4..ec3b0c7 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,35 @@ Accessible only to users with `isAdmin = true`. - Live view of all three BullMQ queues - Shows waiting, active, delayed, completed, and failed counts - Lists active, waiting, and recent failed jobs with payload and failure reason +- **Retry failed** button per queue to requeue all failed jobs + +### Hedge Funds (`/admin/funds`) +- Create a named hedge fund with an initial starting balance +- Add or remove managers (by username) per fund +- Delete a fund (removes all its positions and trade history) + +--- + +## Hedge Funds + +Multiple players can collaborate via a **Hedge Fund** — a shared pool of capital with its own balance, positions, and trade history. + +### How it works +1. An admin creates a fund at `/admin/funds` (providing a name and initial balance). +2. The admin adds one or more players as **managers** of the fund. +3. A manager visits `/fund/[slug]` to see the fund's portfolio. +4. From there they can click any held position (or browse all stocks) — the link includes `?fund=[slug]`, putting the hashtag trade page in **Fund Mode**. +5. In Fund Mode a banner confirms which fund the trade will be on behalf of. All buys and sells deduct from / credit to the fund's balance, not the manager's. +6. The manager can return to their `/profile` at any time to trade under their own account. + +### Fund page (`/fund/[slug]`) +- Public — anyone can view the fund name, balance, positions, managers, and trade history. +- Managers see a management panel with quick links to trade each held position in Fund Mode. + +### Key rules +- A fund account cannot sign in directly — it is a shadow account controlled via the manager interface. +- A user can manage multiple funds simultaneously. +- Funds do not earn research points or play the daily lottery. --- diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7f03ab6..fe8902f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,9 +19,37 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + isFund Boolean @default(false) + positions Position[] trades Trade[] passwordResets PasswordReset[] + managedFunds FundManager[] + fund HedgeFund? +} + +model HedgeFund { + id String @id @default(cuid()) + name String @unique + slug String @unique // lowercase, URL-safe + userId String @unique // shadow User account that holds positions/trades/balance + user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + + managers FundManager[] + + @@index([slug]) +} + +model FundManager { + id String @id @default(cuid()) + fundId String + fund HedgeFund @relation(fields: [fundId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + addedAt DateTime @default(now()) + + @@unique([fundId, userId]) } model PasswordReset { diff --git a/prod-compose.yml b/prod-compose.yml index c31b17a..dc9c7fa 100644 --- a/prod-compose.yml +++ b/prod-compose.yml @@ -5,6 +5,7 @@ services: command: sh -c "npx prisma db push --accept-data-loss && npm start" ports: - "12131:3000" + - "12555:5555" environment: DATABASE_URL: "${DATABASE_URL}" NEXTAUTH_SECRET: "${NEXTAUTH_SECRET}" diff --git a/src/app/admin/funds/AdminFundActions.tsx b/src/app/admin/funds/AdminFundActions.tsx new file mode 100644 index 0000000..7db4e20 --- /dev/null +++ b/src/app/admin/funds/AdminFundActions.tsx @@ -0,0 +1,230 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { formatCurrency } from '@/lib/utils' +import { Building2, Plus, Trash2, UserPlus, UserMinus } from 'lucide-react' + +interface Manager { + id: string + userId: string + user: { id: string; username: string; displayUsername: string | null } +} + +interface Fund { + id: string + name: string + slug: string + user: { balance: number } + managers: Manager[] +} + +export default function AdminFundActions({ funds: initialFunds }: { funds: Fund[] }) { + const router = useRouter() + const [funds, setFunds] = useState(initialFunds) + const [creating, setCreating] = useState(false) + const [newName, setNewName] = useState('') + const [newBalance, setNewBalance] = useState('10000') + const [addUsername, setAddUsername] = useState>({}) + const [expandedId, setExpandedId] = useState(null) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function createFund() { + if (!newName.trim()) return + setLoading(true) + setError('') + const res = await fetch('/api/admin/funds', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName.trim(), initialBalance: parseFloat(newBalance) || 10000 }), + }) + const data = await res.json() + setLoading(false) + if (!res.ok) { setError(data.error ?? 'Failed'); return } + setFunds([...funds, data]) + setNewName('') + setNewBalance('10000') + setCreating(false) + router.refresh() + } + + async function addManager(fundId: string) { + const username = (addUsername[fundId] ?? '').trim() + if (!username) return + setLoading(true) + setError('') + const res = await fetch(`/api/admin/funds/${fundId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ addManagerUsername: username }), + }) + const data = await res.json() + setLoading(false) + if (!res.ok) { setError(data.error ?? 'Failed'); return } + setFunds(funds.map((f) => f.id === fundId ? { ...f, managers: data.managers } : f)) + setAddUsername({ ...addUsername, [fundId]: '' }) + } + + async function removeManager(fundId: string, userId: string) { + setLoading(true) + const res = await fetch(`/api/admin/funds/${fundId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ removeManagerUserId: userId }), + }) + const data = await res.json() + setLoading(false) + if (!res.ok) { setError(data.error ?? 'Failed'); return } + setFunds(funds.map((f) => f.id === fundId ? { ...f, managers: data.managers } : f)) + } + + async function deleteFund(fundId: string) { + if (!confirm('Delete this fund? This will remove all its positions and trade history.')) return + setLoading(true) + await fetch(`/api/admin/funds/${fundId}`, { method: 'DELETE' }) + setLoading(false) + setFunds(funds.filter((f) => f.id !== fundId)) + router.refresh() + } + + return ( +
+ {error && ( +

{error}

+ )} + + {/* Create form */} + {creating ? ( +
+

New Hedge Fund

+
+
+ + setNewName(e.target.value)} + placeholder="TechAlpha Capital" + className="w-full bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+
+ + setNewBalance(e.target.value)} + className="w-full bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+
+
+ + +
+
+ ) : ( + + )} + + {/* Funds list */} + {funds.length === 0 && !creating && ( +

No hedge funds yet.

+ )} + + {funds.map((fund) => ( +
+
setExpandedId(expandedId === fund.id ? null : fund.id)} + > +
+ +
+

{fund.name}

+

/fund/{fund.slug} · {formatCurrency(fund.user.balance)}

+
+
+
+ {fund.managers.length} manager{fund.managers.length !== 1 ? 's' : ''} + e.stopPropagation()} + className="text-indigo-400 hover:text-indigo-300" + > + View → + + +
+
+ + {expandedId === fund.id && ( +
+ {/* Current managers */} +
+

Managers

+ {fund.managers.length === 0 ? ( +

None yet.

+ ) : ( +
+ {fund.managers.map((m) => ( +
+ {m.user.displayUsername ?? m.user.username} + +
+ ))} +
+ )} +
+ + {/* Add manager */} +
+ setAddUsername({ ...addUsername, [fund.id]: e.target.value })} + placeholder="username" + onKeyDown={(e) => e.key === 'Enter' && addManager(fund.id)} + className="flex-1 bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + +
+
+ )} +
+ ))} +
+ ) +} diff --git a/src/app/admin/funds/page.tsx b/src/app/admin/funds/page.tsx new file mode 100644 index 0000000..bb15cb7 --- /dev/null +++ b/src/app/admin/funds/page.tsx @@ -0,0 +1,24 @@ +import { prisma } from '@/lib/prisma' +import AdminFundActions from './AdminFundActions' + +export const dynamic = 'force-dynamic' + +export default async function AdminFundsPage() { + const funds = await prisma.hedgeFund.findMany({ + orderBy: { createdAt: 'asc' }, + include: { + user: { select: { balance: true } }, + managers: { + include: { user: { select: { id: true, username: true, displayUsername: true } } }, + orderBy: { addedAt: 'asc' }, + }, + }, + }) + + return ( +
+

Hedge Funds

+ +
+ ) +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 2399948..0af8775 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -20,6 +20,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN { href: '/admin', label: 'Overview' }, { href: '/admin/users', label: 'Users' }, { href: '/admin/stocks', label: 'Stocks' }, + { href: '/admin/funds', label: 'Funds' }, { href: '/admin/queue', label: 'Queue' }, ].map((link) => ( null) + const parsed = patchSchema.safeParse(body) + if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 }) + + const { addManagerUsername, removeManagerUserId, balance } = parsed.data + + if (addManagerUsername) { + const user = await prisma.user.findUnique({ + where: { username: addManagerUsername.toLowerCase() }, + select: { id: true, isFund: true }, + }) + if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 }) + if (user.isFund) return NextResponse.json({ error: 'Cannot add a fund as a manager.' }, { status: 400 }) + + await prisma.fundManager.upsert({ + where: { fundId_userId: { fundId: fund.id, userId: user.id } }, + create: { fundId: fund.id, userId: user.id }, + update: {}, + }) + } + + if (removeManagerUserId) { + await prisma.fundManager.deleteMany({ + where: { fundId: fund.id, userId: removeManagerUserId }, + }) + } + + if (typeof balance === 'number') { + await prisma.user.update({ where: { id: fund.userId }, data: { balance } }) + } + + const updated = await prisma.hedgeFund.findUnique({ + where: { id: fund.id }, + include: { + user: { select: { balance: true } }, + managers: { + include: { user: { select: { id: true, username: true, displayUsername: true } } }, + orderBy: { addedAt: 'asc' }, + }, + }, + }) + + return NextResponse.json(updated) +} + +/** + * DELETE /api/admin/funds/[fundId] + * Deletes the fund and its shadow user (cascades positions/trades). + */ +export async function DELETE( + req: NextRequest, + { params }: { params: { fundId: string } } +) { + const session = await getServerSession(authOptions) + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const fund = await prisma.hedgeFund.findUnique({ where: { id: params.fundId } }) + if (!fund) return NextResponse.json({ error: 'Fund not found.' }, { status: 404 }) + + // Delete shadow user cascades positions, trades, and the fund record + await prisma.user.delete({ where: { id: fund.userId } }) + + return NextResponse.json({ ok: true }) +} diff --git a/src/app/api/admin/funds/route.ts b/src/app/api/admin/funds/route.ts new file mode 100644 index 0000000..59b3514 --- /dev/null +++ b/src/app/api/admin/funds/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' +import bcrypt from 'bcryptjs' + +const createSchema = z.object({ + name: z.string().min(1).max(60), + initialBalance: z.number().min(0).default(10_000), +}) + +function toSlug(name: string) { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') +} + +/** + * POST /api/admin/funds + * Body: { name: string, initialBalance?: number } + * Creates a HedgeFund with a shadow User account. + */ +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions) + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const body = await req.json().catch(() => null) + const parsed = createSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request.' }, { status: 400 }) + } + + const { name, initialBalance } = parsed.data + const slug = toSlug(name) + + if (!slug) { + return NextResponse.json({ error: 'Fund name produces an empty slug.' }, { status: 400 }) + } + + // Check for existing fund + const existing = await prisma.hedgeFund.findFirst({ + where: { OR: [{ name }, { slug }] }, + }) + if (existing) { + return NextResponse.json({ error: 'A fund with that name already exists.' }, { status: 409 }) + } + + // Shadow username must also be unique + const shadowUsername = `fund:${slug}` + const existingUser = await prisma.user.findUnique({ where: { username: shadowUsername } }) + if (existingUser) { + return NextResponse.json({ error: 'Username conflict — try a different name.' }, { status: 409 }) + } + + // Create shadow user + fund in a transaction + const fund = await prisma.$transaction(async (tx) => { + const shadowUser = await tx.user.create({ + data: { + username: shadowUsername, + displayUsername: name, + passwordHash: await bcrypt.hash(crypto.randomUUID(), 10), // random, non-loginable + balance: initialBalance, + isFund: true, + }, + }) + + return tx.hedgeFund.create({ + data: { name, slug, userId: shadowUser.id }, + include: { user: { select: { balance: true } }, managers: true }, + }) + }) + + return NextResponse.json(fund, { status: 201 }) +} + +/** + * GET /api/admin/funds + * Returns all funds with their managers. + */ +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions) + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const funds = await prisma.hedgeFund.findMany({ + orderBy: { createdAt: 'asc' }, + include: { + user: { select: { balance: true } }, + managers: { + include: { user: { select: { id: true, username: true, displayUsername: true } } }, + orderBy: { addedAt: 'asc' }, + }, + }, + }) + + return NextResponse.json(funds) +} diff --git a/src/app/api/trade/route.ts b/src/app/api/trade/route.ts index 260b0c7..c0ef58b 100644 --- a/src/app/api/trade/route.ts +++ b/src/app/api/trade/route.ts @@ -9,6 +9,7 @@ const tradeSchema = z.object({ hashtagId: z.string().min(1), type: z.enum(['BUY_LONG', 'SELL_LONG', 'BUY_SHORT', 'SELL_SHORT']), shares: z.number().positive().max(1_000_000), + fundId: z.string().optional(), // if set: trade on behalf of this fund }) /** @@ -24,12 +25,24 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Invalid request.' }, { status: 400 }) } - const { hashtagId, type, shares } = parsed.data + const { hashtagId, type, shares, fundId } = parsed.data - // Fetch hashtag and user together + // Fetch hashtag and determine which user account to trade as + let actorId = session.user.id + if (fundId) { + const membership = await prisma.fundManager.findUnique({ + where: { fundId_userId: { fundId, userId: session.user.id } }, + include: { fund: { select: { userId: true } } }, + }) + if (!membership) { + return NextResponse.json({ error: 'You are not a manager of this fund.' }, { status: 403 }) + } + actorId = membership.fund.userId + } + // Fetch hashtag and actor account together const [hashtag, user] = await Promise.all([ prisma.hashtag.findUnique({ where: { id: hashtagId, isActive: true } }), - prisma.user.findUnique({ where: { id: session.user.id } }), + prisma.user.findUnique({ where: { id: actorId } }), ]) if (!hashtag) return NextResponse.json({ error: 'Hashtag not found or inactive.' }, { status: 404 }) diff --git a/src/app/fund/[slug]/page.tsx b/src/app/fund/[slug]/page.tsx new file mode 100644 index 0000000..6c0128c --- /dev/null +++ b/src/app/fund/[slug]/page.tsx @@ -0,0 +1,218 @@ +import { prisma } from '@/lib/prisma' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { notFound } from 'next/navigation' +import { formatCurrency, formatPnl, pnlColor } from '@/lib/utils' +import { formatDistanceToNow } from 'date-fns' +import Link from 'next/link' +import { Building2, TrendingUp, TrendingDown } from 'lucide-react' + +export const dynamic = 'force-dynamic' + +export default async function FundPage({ params }: { params: { slug: string } }) { + const session = await getServerSession(authOptions) + const slug = decodeURIComponent(params.slug).toLowerCase() + + const fund = await prisma.hedgeFund.findUnique({ + where: { slug }, + include: { + user: { + select: { + balance: true, + positions: { + where: { shares: { gt: 0 } }, + include: { hashtag: { select: { tag: true, displayTag: true, currentPrice: true, isActive: true } } }, + }, + trades: { + orderBy: { createdAt: 'desc' }, + take: 30, + include: { hashtag: { select: { tag: true, displayTag: true } } }, + }, + }, + }, + managers: { + include: { user: { select: { id: true, username: true, displayUsername: true } } }, + orderBy: { addedAt: 'asc' }, + }, + }, + }) + + if (!fund) notFound() + + const isManager = session + ? fund.managers.some((m) => m.userId === session.user.id) + : false + + const positions = fund.user.positions + const portfolioValue = 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 unrealizedPnl = positions.reduce((sum, p) => { + const pnl = p.positionType === 'LONG' + ? (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares + : (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares + return sum + pnl + }, 0) + + const totalValue = fund.user.balance + portfolioValue + + return ( +
+ {/* Header */} +
+
+
+ +

{fund.name}

+
+

Hedge Fund

+ {isManager && ( + + You are a manager of this fund + + )} +
+
+

{formatCurrency(totalValue)}

+

total fund value

+
+
+ + {/* Stats */} +
+ {[ + { label: 'Cash', value: formatCurrency(fund.user.balance) }, + { label: 'Invested', value: formatCurrency(portfolioValue) }, + { label: 'Unrealized P&L', value: formatPnl(unrealizedPnl), colorClass: pnlColor(unrealizedPnl) }, + { label: 'Managers', value: String(fund.managers.length) }, + ].map(({ label, value, colorClass }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {/* Manager trading panel */} + {isManager && ( +
+

+ + Trade on behalf of this fund +

+

+ Search for a hashtag below and trade using the fund's balance. + All positions and profit belong to the fund. +

+
+ {positions.map((p) => ( + + #{p.hashtag.displayTag} + + ))} + + Browse all stocks → + +
+
+ )} + + {/* Open positions */} + {positions.length > 0 && ( +
+

+ Open Positions +

+
+ {positions.map((p) => { + const pnl = p.positionType === 'LONG' + ? (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares + : (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares + return ( +
+
+ + #{p.hashtag.displayTag} + + + {p.positionType} + +
+
+

{formatCurrency(p.shares * p.hashtag.currentPrice)}

+

{formatPnl(pnl)}

+
+
+ ) + })} +
+
+ )} + + {/* Managers */} +
+

+ Managers +

+ {fund.managers.length === 0 ? ( +

No managers assigned yet.

+ ) : ( +
+ {fund.managers.map((m) => ( +
+ + {m.user.displayUsername ?? m.user.username} + + + since {formatDistanceToNow(new Date(m.addedAt), { addSuffix: true })} + +
+ ))} +
+ )} +
+ + {/* Recent trades */} + {fund.user.trades.length > 0 && ( +
+

+ Recent Trades +

+
+ {fund.user.trades.map((t) => ( +
+
+ + {t.type.replace('_', ' ')} + + {t.hashtag && ( + + #{t.hashtag.displayTag} + + )} +
+
+

{formatCurrency(t.total)}

+

{formatDistanceToNow(new Date(t.createdAt), { addSuffix: true })}

+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/src/app/hashtag/[tag]/TradePanel.tsx b/src/app/hashtag/[tag]/TradePanel.tsx index 2f44686..792e12a 100644 --- a/src/app/hashtag/[tag]/TradePanel.tsx +++ b/src/app/hashtag/[tag]/TradePanel.tsx @@ -9,11 +9,13 @@ interface Props { balance: number longPosition: { shares: number; avgBuyPrice: number } | null shortPosition: { shares: number; avgBuyPrice: number } | null + fundId?: string + fundName?: string } type Tab = 'BUY_LONG' | 'SELL_LONG' | 'BUY_SHORT' | 'SELL_SHORT' -export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Props) { +export function TradePanel({ hashtag, balance, longPosition, shortPosition, fundId, fundName }: Props) { const router = useRouter() const [tab, setTab] = useState('BUY_LONG') const [shares, setShares] = useState('') @@ -38,7 +40,7 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Pr const res = await fetch('/api/trade', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hashtagId: hashtag.id, type: tab, shares: sharesNum }), + body: JSON.stringify({ hashtagId: hashtag.id, type: tab, shares: sharesNum, ...(fundId ? { fundId } : {}) }), }) const data = await res.json() @@ -54,6 +56,13 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Pr return (
+ {fundName && ( +
+ 🏦 + Trading as {fundName} + Fund mode +
+ )}

Trade #{hashtag.displayTag}

diff --git a/src/app/hashtag/[tag]/page.tsx b/src/app/hashtag/[tag]/page.tsx index d04b834..f2b081c 100644 --- a/src/app/hashtag/[tag]/page.tsx +++ b/src/app/hashtag/[tag]/page.tsx @@ -14,11 +14,13 @@ export const dynamic = 'force-dynamic' interface Props { params: { tag: string } + searchParams: { fund?: string } } -export default async function HashtagPage({ params }: Props) { +export default async function HashtagPage({ params, searchParams }: Props) { const session = await getServerSession(authOptions) const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '') + const fundSlug = searchParams.fund const [hashtag, userBalance, userPosition] = await Promise.all([ prisma.hashtag.findUnique({ @@ -51,6 +53,29 @@ export default async function HashtagPage({ params }: Props) { : [], ]) + // Resolve fund context if ?fund= is provided + let fundContext: { id: string; name: string; balance: number; positions: { hashtagId: string; positionType: string; shares: number; avgBuyPrice: number }[] } | null = null + if (fundSlug && session) { + const fund = await prisma.hedgeFund.findUnique({ + where: { slug: fundSlug }, + include: { + user: { select: { balance: true } }, + managers: { where: { userId: session.user.id }, select: { userId: true } }, + }, + }) + if (fund && fund.managers.length > 0) { + const fundPositions = await prisma.position.findMany({ + where: { userId: fund.userId }, + }) + fundContext = { + id: fund.id, + name: fund.name, + balance: fund.user.balance, + positions: fundPositions, + } + } + } + // Unknown hashtag — show research panel if (!hashtag || !hashtag.isActive) { return ( @@ -87,6 +112,15 @@ export default async function HashtagPage({ params }: Props) { const longPosition = positions.find((p) => p.positionType === 'LONG') const shortPosition = positions.find((p) => p.positionType === 'SHORT') + // When trading as a fund, show the fund's positions instead + const activeLong = fundContext + ? fundContext.positions.find((p) => p.hashtagId === hashtag.id && p.positionType === 'LONG' && p.shares > 0) ?? null + : longPosition ?? null + const activeShort = fundContext + ? fundContext.positions.find((p) => p.hashtagId === hashtag.id && p.positionType === 'SHORT' && p.shares > 0) ?? null + : shortPosition ?? null + const activeBalance = fundContext ? fundContext.balance : (userBalance?.balance ?? 0) + return (
{/* Header */} @@ -125,17 +159,11 @@ export default async function HashtagPage({ params }: Props) { displayTag: hashtag.displayTag, currentPrice: hashtag.currentPrice, }} - balance={userBalance?.balance ?? 0} - longPosition={ - longPosition - ? { shares: longPosition.shares, avgBuyPrice: longPosition.avgBuyPrice } - : null - } - shortPosition={ - shortPosition - ? { shares: shortPosition.shares, avgBuyPrice: shortPosition.avgBuyPrice } - : null - } + balance={activeBalance} + longPosition={activeLong ? { shares: activeLong.shares, avgBuyPrice: activeLong.avgBuyPrice } : null} + shortPosition={activeShort ? { shares: activeShort.shares, avgBuyPrice: activeShort.avgBuyPrice } : null} + fundId={fundContext?.id} + fundName={fundContext?.name} /> ) : (