Compare commits

...

2 Commits

Author SHA1 Message Date
ThaMunsta 03ee361f29 feat: add hedge fund management features including creation, deletion, and manager assignments
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
2026-03-18 19:59:15 -04:00
ThaMunsta 1ce1511954 feat: implement balance tier system and update research points allocation in maintenance worker 2026-03-18 19:28:29 -04:00
16 changed files with 874 additions and 46 deletions
+29
View File
@@ -246,6 +246,35 @@ Accessible only to users with `isAdmin = true`.
- Live view of all three BullMQ queues - Live view of all three BullMQ queues
- Shows waiting, active, delayed, completed, and failed counts - Shows waiting, active, delayed, completed, and failed counts
- Lists active, waiting, and recent failed jobs with payload and failure reason - 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.
--- ---
+28
View File
@@ -19,9 +19,37 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
isFund Boolean @default(false)
positions Position[] positions Position[]
trades Trade[] trades Trade[]
passwordResets PasswordReset[] 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 { model PasswordReset {
+1
View File
@@ -5,6 +5,7 @@ services:
command: sh -c "npx prisma db push --accept-data-loss && npm start" command: sh -c "npx prisma db push --accept-data-loss && npm start"
ports: ports:
- "12131:3000" - "12131:3000"
- "12555:5555"
environment: environment:
DATABASE_URL: "${DATABASE_URL}" DATABASE_URL: "${DATABASE_URL}"
NEXTAUTH_SECRET: "${NEXTAUTH_SECRET}" NEXTAUTH_SECRET: "${NEXTAUTH_SECRET}"
+230
View File
@@ -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<Fund[]>(initialFunds)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [newBalance, setNewBalance] = useState('10000')
const [addUsername, setAddUsername] = useState<Record<string, string>>({})
const [expandedId, setExpandedId] = useState<string | null>(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 (
<div className="space-y-4">
{error && (
<p className="text-red-400 text-sm bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">{error}</p>
)}
{/* Create form */}
{creating ? (
<div className="bg-surface-card border border-surface-border rounded-xl p-4 space-y-3">
<h3 className="font-medium text-sm">New Hedge Fund</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 block mb-1">Fund Name</label>
<input
value={newName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-1">Initial Balance</label>
<input
type="number"
value={newBalance}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={createFund}
disabled={loading || !newName.trim()}
className="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg disabled:opacity-50 transition-colors"
>
Create Fund
</button>
<button onClick={() => setCreating(false)} className="px-3 py-1.5 text-xs text-slate-400 hover:text-slate-100">
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setCreating(true)}
className="flex items-center gap-2 text-sm px-3 py-1.5 bg-indigo-600/20 border border-indigo-500/30 hover:bg-indigo-600/30 text-indigo-300 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" /> New Fund
</button>
)}
{/* Funds list */}
{funds.length === 0 && !creating && (
<p className="text-slate-500 text-sm">No hedge funds yet.</p>
)}
{funds.map((fund) => (
<div key={fund.id} className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-surface/50 transition-colors"
onClick={() => setExpandedId(expandedId === fund.id ? null : fund.id)}
>
<div className="flex items-center gap-3">
<Building2 className="h-4 w-4 text-indigo-400" />
<div>
<p className="font-medium text-sm">{fund.name}</p>
<p className="text-xs text-slate-500">/fund/{fund.slug} · {formatCurrency(fund.user.balance)}</p>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
<span>{fund.managers.length} manager{fund.managers.length !== 1 ? 's' : ''}</span>
<a
href={`/fund/${fund.slug}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-indigo-400 hover:text-indigo-300"
>
View
</a>
<button
onClick={(e) => { e.stopPropagation(); deleteFund(fund.id) }}
disabled={loading}
className="text-red-500 hover:text-red-400 disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{expandedId === fund.id && (
<div className="border-t border-surface-border px-4 py-3 space-y-3">
{/* Current managers */}
<div>
<p className="text-xs text-slate-500 mb-2 font-medium">Managers</p>
{fund.managers.length === 0 ? (
<p className="text-xs text-slate-600">None yet.</p>
) : (
<div className="flex flex-wrap gap-2">
{fund.managers.map((m) => (
<div key={m.id} className="flex items-center gap-1.5 text-xs bg-surface border border-surface-border rounded-full px-2.5 py-1">
<span>{m.user.displayUsername ?? m.user.username}</span>
<button
onClick={() => removeManager(fund.id, m.userId)}
disabled={loading}
className="text-slate-500 hover:text-red-400 disabled:opacity-50"
>
<UserMinus className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
{/* Add manager */}
<div className="flex gap-2">
<input
value={addUsername[fund.id] ?? ''}
onChange={(e) => 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"
/>
<button
onClick={() => addManager(fund.id)}
disabled={loading || !(addUsername[fund.id] ?? '').trim()}
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600/20 border border-emerald-500/30 hover:bg-emerald-600/30 text-emerald-300 text-xs rounded-lg disabled:opacity-50 transition-colors"
>
<UserPlus className="h-3 w-3" /> Add Manager
</button>
</div>
</div>
)}
</div>
))}
</div>
)
}
+24
View File
@@ -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 (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Hedge Funds</h2>
<AdminFundActions funds={funds} />
</div>
)
}
+1
View File
@@ -20,6 +20,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
{ href: '/admin', label: 'Overview' }, { href: '/admin', label: 'Overview' },
{ href: '/admin/users', label: 'Users' }, { href: '/admin/users', label: 'Users' },
{ href: '/admin/stocks', label: 'Stocks' }, { href: '/admin/stocks', label: 'Stocks' },
{ href: '/admin/funds', label: 'Funds' },
{ href: '/admin/queue', label: 'Queue' }, { href: '/admin/queue', label: 'Queue' },
].map((link) => ( ].map((link) => (
<a <a
+97
View File
@@ -0,0 +1,97 @@
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'
const patchSchema = z.object({
addManagerUsername: z.string().optional(),
removeManagerUserId: z.string().optional(),
balance: z.number().min(0).optional(),
})
/**
* PATCH /api/admin/funds/[fundId]
* Add/remove manager, or adjust fund balance.
*/
export async function PATCH(
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 },
include: { user: true },
})
if (!fund) return NextResponse.json({ error: 'Fund not found.' }, { status: 404 })
const body = await req.json().catch(() => 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 })
}
+99
View File
@@ -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)
}
+16 -3
View File
@@ -9,6 +9,7 @@ const tradeSchema = z.object({
hashtagId: z.string().min(1), hashtagId: z.string().min(1),
type: z.enum(['BUY_LONG', 'SELL_LONG', 'BUY_SHORT', 'SELL_SHORT']), type: z.enum(['BUY_LONG', 'SELL_LONG', 'BUY_SHORT', 'SELL_SHORT']),
shares: z.number().positive().max(1_000_000), 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 }) 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([ const [hashtag, user] = await Promise.all([
prisma.hashtag.findUnique({ where: { id: hashtagId, isActive: true } }), 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 }) if (!hashtag) return NextResponse.json({ error: 'Hashtag not found or inactive.' }, { status: 404 })
+218
View File
@@ -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 (
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Building2 className="h-6 w-6 text-indigo-400" />
<h1 className="text-3xl font-bold">{fund.name}</h1>
</div>
<p className="text-slate-500 text-sm">Hedge Fund</p>
{isManager && (
<span className="inline-block mt-2 text-xs px-2 py-0.5 rounded bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
You are a manager of this fund
</span>
)}
</div>
<div className="text-right">
<p className="text-3xl font-bold">{formatCurrency(totalValue)}</p>
<p className="text-sm text-slate-400">total fund value</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ 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 }) => (
<div key={label} className="bg-surface-card border border-surface-border rounded-xl p-4">
<p className="text-xs text-slate-500 mb-1">{label}</p>
<p className={`text-lg font-semibold ${colorClass ?? ''}`}>{value}</p>
</div>
))}
</div>
{/* Manager trading panel */}
{isManager && (
<div className="bg-indigo-500/5 border border-indigo-500/20 rounded-xl p-5">
<h2 className="font-semibold mb-1 flex items-center gap-2">
<Building2 className="h-4 w-4 text-indigo-400" />
Trade on behalf of this fund
</h2>
<p className="text-sm text-slate-400 mb-4">
Search for a hashtag below and trade using the fund&apos;s balance.
All positions and profit belong to the fund.
</p>
<div className="flex gap-2">
{positions.map((p) => (
<Link
key={p.id}
href={`/hashtag/${p.hashtag.tag}?fund=${fund.slug}`}
className="text-xs bg-surface border border-surface-border hover:border-indigo-500/50 text-slate-300 hover:text-indigo-300 px-3 py-1.5 rounded-full transition-colors"
>
#{p.hashtag.displayTag}
</Link>
))}
<Link
href={`/stocks?fund=${fund.slug}`}
className="text-xs bg-indigo-600/20 border border-indigo-500/30 hover:bg-indigo-600/30 text-indigo-300 px-3 py-1.5 rounded-full transition-colors"
>
Browse all stocks
</Link>
</div>
</div>
)}
{/* Open positions */}
{positions.length > 0 && (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<h2 className="text-sm font-medium text-slate-400 px-4 py-3 border-b border-surface-border">
Open Positions
</h2>
<div className="divide-y divide-surface-border">
{positions.map((p) => {
const pnl = p.positionType === 'LONG'
? (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
: (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return (
<div key={p.id} className="flex items-center justify-between px-4 py-3 text-sm">
<div className="flex items-center gap-3">
<Link
href={isManager ? `/hashtag/${p.hashtag.tag}?fund=${fund.slug}` : `/hashtag/${p.hashtag.tag}`}
className="font-medium text-indigo-300 hover:text-indigo-200"
>
#{p.hashtag.displayTag}
</Link>
<span className={`text-xs px-1.5 py-0.5 rounded ${p.positionType === 'LONG' ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'}`}>
{p.positionType}
</span>
</div>
<div className="text-right">
<p className="font-medium">{formatCurrency(p.shares * p.hashtag.currentPrice)}</p>
<p className={`text-xs ${pnlColor(pnl)}`}>{formatPnl(pnl)}</p>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Managers */}
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<h2 className="text-sm font-medium text-slate-400 px-4 py-3 border-b border-surface-border">
Managers
</h2>
{fund.managers.length === 0 ? (
<p className="text-slate-500 text-sm px-4 py-3">No managers assigned yet.</p>
) : (
<div className="divide-y divide-surface-border">
{fund.managers.map((m) => (
<div key={m.id} className="flex items-center justify-between px-4 py-3 text-sm">
<Link href={`/profile/${m.user.username}`} className="text-slate-300 hover:text-white">
{m.user.displayUsername ?? m.user.username}
</Link>
<span className="text-xs text-slate-500">
since {formatDistanceToNow(new Date(m.addedAt), { addSuffix: true })}
</span>
</div>
))}
</div>
)}
</div>
{/* Recent trades */}
{fund.user.trades.length > 0 && (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<h2 className="text-sm font-medium text-slate-400 px-4 py-3 border-b border-surface-border">
Recent Trades
</h2>
<div className="divide-y divide-surface-border">
{fund.user.trades.map((t) => (
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<div className="flex items-center gap-3">
<span className={`text-xs font-medium px-2 py-0.5 rounded ${t.type.startsWith('BUY') ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'}`}>
{t.type.replace('_', ' ')}
</span>
{t.hashtag && (
<Link href={`/hashtag/${t.hashtag.tag}`} className="text-slate-300 hover:text-white">
#{t.hashtag.displayTag}
</Link>
)}
</div>
<div className="text-right text-xs text-slate-400">
<p>{formatCurrency(t.total)}</p>
<p>{formatDistanceToNow(new Date(t.createdAt), { addSuffix: true })}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
+11 -2
View File
@@ -9,11 +9,13 @@ interface Props {
balance: number balance: number
longPosition: { shares: number; avgBuyPrice: number } | null longPosition: { shares: number; avgBuyPrice: number } | null
shortPosition: { 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' 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 router = useRouter()
const [tab, setTab] = useState<Tab>('BUY_LONG') const [tab, setTab] = useState<Tab>('BUY_LONG')
const [shares, setShares] = useState('') const [shares, setShares] = useState('')
@@ -38,7 +40,7 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Pr
const res = await fetch('/api/trade', { const res = await fetch('/api/trade', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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() const data = await res.json()
@@ -54,6 +56,13 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Pr
return ( return (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-5"> <div className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-5">
{fundName && (
<div className="flex items-center gap-2 text-xs bg-indigo-500/10 border border-indigo-500/30 rounded-lg px-3 py-2 text-indigo-300">
<span className="text-lg">🏦</span>
Trading as <span className="font-semibold">{fundName}</span>
<span className="text-indigo-500 ml-auto">Fund mode</span>
</div>
)}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="font-semibold">Trade #{hashtag.displayTag}</h2> <h2 className="font-semibold">Trade #{hashtag.displayTag}</h2>
<span className="text-sm text-slate-400"> <span className="text-sm text-slate-400">
+40 -12
View File
@@ -14,11 +14,13 @@ export const dynamic = 'force-dynamic'
interface Props { interface Props {
params: { tag: string } 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 session = await getServerSession(authOptions)
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '') const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
const fundSlug = searchParams.fund
const [hashtag, userBalance, userPosition] = await Promise.all([ const [hashtag, userBalance, userPosition] = await Promise.all([
prisma.hashtag.findUnique({ 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 // Unknown hashtag — show research panel
if (!hashtag || !hashtag.isActive) { if (!hashtag || !hashtag.isActive) {
return ( return (
@@ -87,6 +112,15 @@ export default async function HashtagPage({ params }: Props) {
const longPosition = positions.find((p) => p.positionType === 'LONG') const longPosition = positions.find((p) => p.positionType === 'LONG')
const shortPosition = positions.find((p) => p.positionType === 'SHORT') 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Header */} {/* Header */}
@@ -125,17 +159,11 @@ export default async function HashtagPage({ params }: Props) {
displayTag: hashtag.displayTag, displayTag: hashtag.displayTag,
currentPrice: hashtag.currentPrice, currentPrice: hashtag.currentPrice,
}} }}
balance={userBalance?.balance ?? 0} balance={activeBalance}
longPosition={ longPosition={activeLong ? { shares: activeLong.shares, avgBuyPrice: activeLong.avgBuyPrice } : null}
longPosition shortPosition={activeShort ? { shares: activeShort.shares, avgBuyPrice: activeShort.avgBuyPrice } : null}
? { shares: longPosition.shares, avgBuyPrice: longPosition.avgBuyPrice } fundId={fundContext?.id}
: null fundName={fundContext?.name}
}
shortPosition={
shortPosition
? { shares: shortPosition.shares, avgBuyPrice: shortPosition.avgBuyPrice }
: null
}
/> />
) : ( ) : (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 text-center"> <div className="bg-surface-card border border-surface-border rounded-xl p-6 text-center">
+33 -20
View File
@@ -5,8 +5,7 @@ import { Loader2, Dices, CheckCircle2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Link from 'next/link' import Link from 'next/link'
import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery'
const GRID_SIZE = 25
type PickResult = { type PickResult = {
box: number box: number
@@ -86,11 +85,24 @@ export default function LotteryPage() {
) )
} }
const prizeLabels: Record<number, string> = { const PRIZE_EMOJIS = ['🏆', '🥈', '🎁', '✨', '⭐']
200: '🏆 $200', const uniquePrizes = [...new Set(Object.values(PRIZE_MAP))].filter(v => v > 0).sort((a, b) => b - a)
50: '🥈 $50', const prizeCounts = uniquePrizes.reduce<Record<number, number>>((acc, amt) => {
10: '🎁 $10', acc[amt] = Object.values(PRIZE_MAP).filter(v => v === amt).length
} return acc
}, {})
const prizeBoxCount = Object.values(PRIZE_MAP).filter(v => v > 0).length
const emptyBoxCount = GRID_SIZE - prizeBoxCount
const TIER_COLORS = [
{ bg: 'bg-amber-500/10', border: 'border-amber-500/20', text: 'text-amber-400' },
{ bg: 'bg-slate-500/10', border: 'border-slate-500/20', text: 'text-slate-300' },
{ bg: 'bg-emerald-500/10', border: 'border-emerald-500/20', text: 'text-emerald-400' },
{ bg: 'bg-indigo-500/10', border: 'border-indigo-500/20', text: 'text-indigo-400' },
{ bg: 'bg-pink-500/10', border: 'border-pink-500/20', text: 'text-pink-400' },
]
const prizeLabels: Record<number, string> = Object.fromEntries(
uniquePrizes.map((amt, i) => [amt, `${PRIZE_EMOJIS[i] ?? '🎁'} ${formatCurrency(amt)}`])
)
return ( return (
<div className="max-w-xl mx-auto space-y-8"> <div className="max-w-xl mx-auto space-y-8">
@@ -107,21 +119,22 @@ export default function LotteryPage() {
{/* Prize table */} {/* Prize table */}
<div className="bg-surface-card border border-surface-border rounded-xl p-4"> <div className="bg-surface-card border border-surface-border rounded-xl p-4">
<p className="text-xs text-slate-500 mb-3 font-medium uppercase tracking-wider">Prize pool</p> <p className="text-xs text-slate-500 mb-3 font-medium uppercase tracking-wider">Prize pool</p>
<div className="grid grid-cols-3 gap-2 text-sm text-center"> <div
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-2"> className="grid gap-2 text-sm text-center"
<p className="font-bold text-amber-400">$200</p> style={{ gridTemplateColumns: `repeat(${uniquePrizes.length}, 1fr)` }}
<p className="text-xs text-slate-500">×1 box</p> >
{uniquePrizes.map((amt, i) => {
const color = TIER_COLORS[i] ?? TIER_COLORS[TIER_COLORS.length - 1]
const count = prizeCounts[amt]
return (
<div key={amt} className={`${color.bg} border ${color.border} rounded-lg p-2`}>
<p className={`font-bold ${color.text}`}>{formatCurrency(amt)}</p>
<p className="text-xs text-slate-500">×{count} box{count !== 1 ? 'es' : ''}</p>
</div> </div>
<div className="bg-slate-500/10 border border-slate-500/20 rounded-lg p-2"> )
<p className="font-bold text-slate-300">$50</p> })}
<p className="text-xs text-slate-500">×2 boxes</p>
</div> </div>
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-lg p-2"> <p className="text-xs text-slate-600 mt-2 text-center">All remaining {emptyBoxCount} boxes: $0</p>
<p className="font-bold text-emerald-400">$10</p>
<p className="text-xs text-slate-500">×3 boxes</p>
</div>
</div>
<p className="text-xs text-slate-600 mt-2 text-center">Remaining 19 boxes: $0</p>
</div> </div>
{/* Result banner */} {/* Result banner */}
+21 -1
View File
@@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils' import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils'
import { getBalanceTier } from '@/lib/pricing'
import Link from 'next/link' import Link from 'next/link'
import { TrendingUp, TrendingDown, Coins } from 'lucide-react' import { TrendingUp, TrendingDown, Coins } from 'lucide-react'
import ChangePasswordForm from './ChangePasswordForm' import ChangePasswordForm from './ChangePasswordForm'
@@ -74,9 +75,28 @@ export default async function ProfilePage({ params }: Props) {
<p className="text-slate-500 text-sm">@{user.username}</p> <p className="text-slate-500 text-sm">@{user.username}</p>
)} )}
{isOwn && ( {isOwn && (
<p className="text-slate-400 text-sm mt-1"> <div className="mt-1 text-sm text-slate-400 space-y-0.5">
{(() => {
const tier = getBalanceTier(user.balance)
return (
<>
<p>
<span className="text-slate-300 font-medium">Tier {tier.level}</span>
<span className="text-slate-600 mx-1.5">&middot;</span>
{tier.pointsPerDay} research pt{tier.pointsPerDay !== 1 ? 's' : ''}/day
{tier.nextThreshold && (
<span className="text-slate-600 text-xs ml-1.5">
(next tier at {formatCurrency(tier.nextThreshold)})
</span>
)}
</p>
<p>
{user.researchPoints} research point{user.researchPoints !== 1 ? 's' : ''} available {user.researchPoints} research point{user.researchPoints !== 1 ? 's' : ''} available
</p> </p>
</>
)
})()}
</div>
)} )}
</div> </div>
<div className="text-right"> <div className="text-right">
+14
View File
@@ -25,6 +25,20 @@ export function dailyResearchPoints(balance: number): number {
return 1 return 1
} }
export interface BalanceTier {
level: number
pointsPerDay: number
nextThreshold: number | null
}
/** Returns the tier info for a given balance. */
export function getBalanceTier(balance: number): BalanceTier {
if (balance >= 1_000_000) return { level: 4, pointsPerDay: 5, nextThreshold: null }
if (balance >= 100_000) return { level: 3, pointsPerDay: 3, nextThreshold: 1_000_000 }
if (balance >= 10_000) return { level: 2, pointsPerDay: 2, nextThreshold: 100_000 }
return { level: 1, pointsPerDay: 1, nextThreshold: 10_000 }
}
/** /**
* Calculate the cost/proceeds and realized P&L for a trade. * Calculate the cost/proceeds and realized P&L for a trade.
* *
+6 -2
View File
@@ -170,14 +170,18 @@ const maintenanceWorker = new Worker(
async (job) => { async (job) => {
console.log(`[maintenance] running daily maintenance (job ${job.id})`) console.log(`[maintenance] running daily maintenance (job ${job.id})`)
const users = await prisma.user.findMany({ select: { id: true, balance: true } }) const MAX_RESEARCH_POINTS = 10
const users = await prisma.user.findMany({ select: { id: true, balance: true, researchPoints: true } })
for (const user of users) { for (const user of users) {
const points = dailyResearchPoints(user.balance) const points = dailyResearchPoints(user.balance)
const newTotal = Math.min(user.researchPoints + points, MAX_RESEARCH_POINTS)
if (newTotal !== user.researchPoints) {
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { researchPoints: { increment: points } }, data: { researchPoints: newTotal },
}) })
} }
}
console.log(`[maintenance] awarded research points to ${users.length} users`) console.log(`[maintenance] awarded research points to ${users.length} users`)
}, },