feat: add hashtag active duration and extend active window on sell
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s
This commit is contained in:
@@ -17,6 +17,8 @@ MASTODON_ACCESS_TOKEN="your-mastodon-access-token"
|
||||
WORKER_RATE_LIMIT_MS=2000
|
||||
# How often (minutes) to queue a full price-update sweep (default: 60)
|
||||
PRICE_UPDATE_INTERVAL_MINUTES=60
|
||||
# How long (hours) a hashtag stays active after being researched or after its last position closes (default: 24)
|
||||
HASHTAG_ACTIVE_HOURS=24
|
||||
# Max pagination pages to fetch when counting posts (default: 5 = up to 200 posts)
|
||||
MAX_PAGES_PER_HASHTAG=5
|
||||
|
||||
|
||||
@@ -43,8 +43,10 @@ model Hashtag {
|
||||
currentPrice Float @default(0.25)
|
||||
isActive Boolean @default(true)
|
||||
isBanned Boolean @default(false)
|
||||
// Consecutive zero-result count; after 3 failed updates the hashtag auto-deactivates
|
||||
// Consecutive zero-result count (informational)
|
||||
zeroCount Int @default(0)
|
||||
// Earliest time this hashtag can be deactivated (set on research + when last position closes)
|
||||
activeUntil DateTime?
|
||||
lastUpdated DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const price = calcPrice(postsPerHour)
|
||||
const activeUntil = new Date(Date.now() + parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10) * 60 * 60 * 1000)
|
||||
|
||||
// Upsert the hashtag and deduct point atomically
|
||||
const [hashtag] = await prisma.$transaction([
|
||||
@@ -82,6 +83,7 @@ export async function POST(req: NextRequest) {
|
||||
displayTag: raw.trim().replace(/^#+/, ''),
|
||||
currentPrice: price,
|
||||
isActive: true,
|
||||
activeUntil,
|
||||
priceHistory: {
|
||||
create: { price, postsPerHour },
|
||||
},
|
||||
@@ -90,6 +92,7 @@ export async function POST(req: NextRequest) {
|
||||
isActive: true,
|
||||
currentPrice: price,
|
||||
zeroCount: 0,
|
||||
activeUntil,
|
||||
lastUpdated: new Date(),
|
||||
priceHistory: {
|
||||
create: { price, postsPerHour },
|
||||
|
||||
@@ -116,5 +116,20 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
})
|
||||
|
||||
// When a sell closes the last position on this hashtag globally, extend the active window
|
||||
if (type === 'SELL_LONG' || type === 'SELL_SHORT') {
|
||||
const newShares = (existingPosition?.shares ?? 0) - shares
|
||||
if (newShares <= 0) {
|
||||
const remaining = await prisma.position.count({ where: { hashtagId, shares: { gt: 0 } } })
|
||||
if (remaining === 0) {
|
||||
const hours = parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10)
|
||||
await prisma.hashtag.update({
|
||||
where: { id: hashtagId },
|
||||
data: { activeUntil: new Date(Date.now() + hours * 60 * 60 * 1000) },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { ArrowUp, ArrowDown, ArrowUpDown, BarChart2 } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
type SortField = 'price' | 'tag' | 'change' | 'updated'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: { page?: string; sort?: string; dir?: string }
|
||||
}
|
||||
|
||||
function SortLink({
|
||||
field,
|
||||
label,
|
||||
currentSort,
|
||||
currentDir,
|
||||
page,
|
||||
}: {
|
||||
field: SortField
|
||||
label: string
|
||||
currentSort: SortField
|
||||
currentDir: SortDir
|
||||
page: number
|
||||
}) {
|
||||
const isActive = currentSort === field
|
||||
const nextDir: SortDir = isActive && currentDir === 'desc' ? 'asc' : 'desc'
|
||||
const Icon = isActive ? (currentDir === 'desc' ? ArrowDown : ArrowUp) : ArrowUpDown
|
||||
return (
|
||||
<Link
|
||||
href={`/stocks?page=1&sort=${field}&dir=${nextDir}`}
|
||||
className={`flex items-center gap-1 hover:text-slate-200 transition-colors select-none ${isActive ? 'text-indigo-400' : 'text-slate-400'}`}
|
||||
>
|
||||
{label}
|
||||
<Icon className="h-3 w-3" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function StocksPage({ searchParams }: PageProps) {
|
||||
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10))
|
||||
const sort = (['price', 'tag', 'change', 'updated'].includes(searchParams.sort ?? '')
|
||||
? searchParams.sort
|
||||
: 'price') as SortField
|
||||
const dir = (searchParams.dir === 'asc' ? 'asc' : 'desc') as SortDir
|
||||
|
||||
// For change sort: fetch all active hashtags with last 2 price points and sort in-memory
|
||||
// For other sorts: use DB-native orderBy + pagination
|
||||
let stocks: {
|
||||
id: string
|
||||
tag: string
|
||||
displayTag: string
|
||||
currentPrice: number
|
||||
lastUpdated: Date
|
||||
previousPrice: number | null
|
||||
postsPerHour: number | null
|
||||
holderCount: number
|
||||
}[]
|
||||
let total: number
|
||||
|
||||
if (sort === 'change') {
|
||||
// Fetch all active hashtags for in-memory sort
|
||||
const all = await prisma.hashtag.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
tag: true,
|
||||
displayTag: true,
|
||||
currentPrice: true,
|
||||
lastUpdated: true,
|
||||
priceHistory: {
|
||||
orderBy: { recordedAt: 'desc' },
|
||||
take: 2,
|
||||
select: { price: true, postsPerHour: true },
|
||||
},
|
||||
_count: { select: { positions: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const computed = all.map((h) => ({
|
||||
id: h.id,
|
||||
tag: h.tag,
|
||||
displayTag: h.displayTag,
|
||||
currentPrice: h.currentPrice,
|
||||
lastUpdated: h.lastUpdated,
|
||||
previousPrice: h.priceHistory[1]?.price ?? null,
|
||||
postsPerHour: h.priceHistory[0]?.postsPerHour ?? null,
|
||||
holderCount: h._count.positions,
|
||||
changePct:
|
||||
h.priceHistory[1]?.price != null
|
||||
? ((h.currentPrice - h.priceHistory[1].price) / h.priceHistory[1].price) * 100
|
||||
: 0,
|
||||
}))
|
||||
|
||||
computed.sort((a, b) =>
|
||||
dir === 'desc' ? b.changePct - a.changePct : a.changePct - b.changePct,
|
||||
)
|
||||
|
||||
total = computed.length
|
||||
stocks = computed.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
} else {
|
||||
const orderBy =
|
||||
sort === 'price'
|
||||
? { currentPrice: dir }
|
||||
: sort === 'tag'
|
||||
? { tag: dir }
|
||||
: { lastUpdated: dir }
|
||||
|
||||
;[total, stocks] = await Promise.all([
|
||||
prisma.hashtag.count({ where: { isActive: true } }),
|
||||
prisma.hashtag
|
||||
.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy,
|
||||
take: PAGE_SIZE,
|
||||
skip: (page - 1) * PAGE_SIZE,
|
||||
select: {
|
||||
id: true,
|
||||
tag: true,
|
||||
displayTag: true,
|
||||
currentPrice: true,
|
||||
lastUpdated: true,
|
||||
priceHistory: {
|
||||
orderBy: { recordedAt: 'desc' },
|
||||
take: 2,
|
||||
select: { price: true, postsPerHour: true },
|
||||
},
|
||||
_count: { select: { positions: true } },
|
||||
},
|
||||
})
|
||||
.then((rows) =>
|
||||
rows.map((h) => ({
|
||||
id: h.id,
|
||||
tag: h.tag,
|
||||
displayTag: h.displayTag,
|
||||
currentPrice: h.currentPrice,
|
||||
lastUpdated: h.lastUpdated,
|
||||
previousPrice: h.priceHistory[1]?.price ?? null,
|
||||
postsPerHour: h.priceHistory[0]?.postsPerHour ?? null,
|
||||
holderCount: h._count.positions,
|
||||
})),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<BarChart2 className="h-6 w-6 text-indigo-400" />
|
||||
<h1 className="text-2xl font-bold">Markets</h1>
|
||||
<span className="text-slate-500 text-sm">({total} active)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||
{/* Column headers */}
|
||||
<div className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr] gap-4 px-4 py-2.5 border-b border-surface-border text-xs font-medium">
|
||||
<SortLink field="tag" label="Hashtag" currentSort={sort} currentDir={dir} page={page} />
|
||||
<div className="text-right">
|
||||
<SortLink field="price" label="Price" currentSort={sort} currentDir={dir} page={page} />
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<SortLink field="change" label="Change" currentSort={sort} currentDir={dir} page={page} />
|
||||
</div>
|
||||
<div className="text-right hidden sm:block text-slate-400">Posts/hr</div>
|
||||
<div className="text-right">
|
||||
<SortLink field="updated" label="Updated" currentSort={sort} currentDir={dir} page={page} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stocks.length === 0 ? (
|
||||
<div className="text-center py-16 text-slate-500">No active hashtags yet.</div>
|
||||
) : (
|
||||
<div className="divide-y divide-surface-border">
|
||||
{stocks.map((stock, i) => {
|
||||
const prev = stock.previousPrice
|
||||
const change = prev != null ? stock.currentPrice - prev : null
|
||||
const changePct = prev != null && prev > 0 ? ((stock.currentPrice - prev) / prev) * 100 : null
|
||||
const up = change == null ? null : change >= 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stock.id}
|
||||
className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr] gap-4 items-center px-4 py-3 hover:bg-surface-border/30 transition-colors"
|
||||
>
|
||||
{/* Rank + hashtag name */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-slate-600 text-xs w-6 shrink-0 text-right">
|
||||
{(page - 1) * PAGE_SIZE + i + 1}
|
||||
</span>
|
||||
<Link
|
||||
href={`/hashtag/${stock.tag}`}
|
||||
className="font-medium hover:text-indigo-300 transition-colors truncate"
|
||||
>
|
||||
#{stock.displayTag}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="text-right font-medium tabular-nums">
|
||||
{formatCurrency(stock.currentPrice)}
|
||||
</div>
|
||||
|
||||
{/* Change */}
|
||||
<div className="text-right tabular-nums">
|
||||
{change == null ? (
|
||||
<span className="text-slate-600 text-xs">—</span>
|
||||
) : (
|
||||
<div>
|
||||
<p className={`text-sm ${up ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{up ? '+' : ''}{formatCurrency(change)}
|
||||
</p>
|
||||
<p className={`text-xs ${up ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{up ? '+' : ''}{changePct!.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Posts/hr */}
|
||||
<div className="text-right hidden sm:block text-slate-400 text-sm tabular-nums">
|
||||
{stock.postsPerHour != null ? stock.postsPerHour.toFixed(1) : '—'}
|
||||
</div>
|
||||
|
||||
{/* Last updated */}
|
||||
<div className="text-right text-xs text-slate-500">
|
||||
{formatDistanceToNow(stock.lastUpdated, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{page > 1 && (
|
||||
<Link
|
||||
href={`/stocks?page=${page - 1}&sort=${sort}&dir=${dir}`}
|
||||
className="px-3 py-1.5 text-sm bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
|
||||
>
|
||||
← Prev
|
||||
</Link>
|
||||
)}
|
||||
<span className="text-slate-500 text-sm">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
{page < totalPages && (
|
||||
<Link
|
||||
href={`/stocks?page=${page + 1}&sort=${sort}&dir=${dir}`}
|
||||
className="px-3 py-1.5 text-sm bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
|
||||
>
|
||||
Next →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useSession, signOut } from 'next-auth/react'
|
||||
import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react'
|
||||
import { TrendingUp, Search, User, LogOut, Shield, Trophy, BarChart2 } from 'lucide-react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
@@ -61,6 +61,14 @@ export function Navbar() {
|
||||
<span className="font-bold text-lg hidden sm:block">HashEx</span>
|
||||
</Link>
|
||||
|
||||
{/* Markets link */}
|
||||
<Link
|
||||
href="/stocks"
|
||||
className="text-slate-400 hover:text-slate-200 transition-colors text-sm hidden sm:block shrink-0"
|
||||
>
|
||||
Markets
|
||||
</Link>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
|
||||
+21
-6
@@ -41,6 +41,11 @@ const prisma = new PrismaClient({
|
||||
|
||||
const RATE_LIMIT_MS = parseInt(process.env.WORKER_RATE_LIMIT_MS ?? '2000', 10)
|
||||
const UPDATE_INTERVAL_MIN = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
|
||||
const ACTIVE_HOURS = parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10)
|
||||
|
||||
function activeUntilFromNow(): Date {
|
||||
return new Date(Date.now() + ACTIVE_HOURS * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
// ── Queues (worker side) ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -73,13 +78,13 @@ const priceWorker = new Worker(
|
||||
const hashtag = await prisma.hashtag.findUnique({ where: { id: hashtagId } })
|
||||
if (!hashtag) return
|
||||
|
||||
const now = new Date()
|
||||
const ttlExpired = !hashtag.activeUntil || hashtag.activeUntil <= now
|
||||
const ownerCount = await prisma.position.count({ where: { hashtagId, shares: { gt: 0 } } })
|
||||
|
||||
if (postsPerHour === 0) {
|
||||
const newZeroCount = hashtag.zeroCount + 1
|
||||
// Auto-deactivate after 3 consecutive zero-result updates with no owners
|
||||
const ownerCount = await prisma.position.count({
|
||||
where: { hashtagId, shares: { gt: 0 } },
|
||||
})
|
||||
const shouldDeactivate = newZeroCount >= 3 && ownerCount === 0
|
||||
const shouldDeactivate = ttlExpired && ownerCount === 0
|
||||
|
||||
await prisma.hashtag.update({
|
||||
where: { id: hashtagId },
|
||||
@@ -89,7 +94,17 @@ const priceWorker = new Worker(
|
||||
lastUpdated: new Date(),
|
||||
},
|
||||
})
|
||||
console.log(`[price] #${tag} got 0 posts (zeroCount=${newZeroCount})${shouldDeactivate ? ' — deactivated' : ''}`)
|
||||
console.log(`[price] #${tag} got 0 posts (zeroCount=${newZeroCount})${shouldDeactivate ? ' — deactivated (TTL expired, no holders)' : ''}`)
|
||||
return
|
||||
}
|
||||
|
||||
// If TTL expired and no holders, deactivate instead of updating
|
||||
if (ttlExpired && ownerCount === 0) {
|
||||
await prisma.hashtag.update({
|
||||
where: { id: hashtagId },
|
||||
data: { isActive: false, lastUpdated: new Date() },
|
||||
})
|
||||
console.log(`[price] #${tag} deactivated — TTL expired, no holders`)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user