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
|
WORKER_RATE_LIMIT_MS=2000
|
||||||
# How often (minutes) to queue a full price-update sweep (default: 60)
|
# How often (minutes) to queue a full price-update sweep (default: 60)
|
||||||
PRICE_UPDATE_INTERVAL_MINUTES=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 pagination pages to fetch when counting posts (default: 5 = up to 200 posts)
|
||||||
MAX_PAGES_PER_HASHTAG=5
|
MAX_PAGES_PER_HASHTAG=5
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ model Hashtag {
|
|||||||
currentPrice Float @default(0.25)
|
currentPrice Float @default(0.25)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
isBanned Boolean @default(false)
|
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)
|
zeroCount Int @default(0)
|
||||||
|
// Earliest time this hashtag can be deactivated (set on research + when last position closes)
|
||||||
|
activeUntil DateTime?
|
||||||
lastUpdated DateTime @default(now())
|
lastUpdated DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const price = calcPrice(postsPerHour)
|
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
|
// Upsert the hashtag and deduct point atomically
|
||||||
const [hashtag] = await prisma.$transaction([
|
const [hashtag] = await prisma.$transaction([
|
||||||
@@ -82,6 +83,7 @@ export async function POST(req: NextRequest) {
|
|||||||
displayTag: raw.trim().replace(/^#+/, ''),
|
displayTag: raw.trim().replace(/^#+/, ''),
|
||||||
currentPrice: price,
|
currentPrice: price,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
activeUntil,
|
||||||
priceHistory: {
|
priceHistory: {
|
||||||
create: { price, postsPerHour },
|
create: { price, postsPerHour },
|
||||||
},
|
},
|
||||||
@@ -90,6 +92,7 @@ export async function POST(req: NextRequest) {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
currentPrice: price,
|
currentPrice: price,
|
||||||
zeroCount: 0,
|
zeroCount: 0,
|
||||||
|
activeUntil,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
priceHistory: {
|
priceHistory: {
|
||||||
create: { price, postsPerHour },
|
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 })
|
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 Link from 'next/link'
|
||||||
import { useSession, signOut } from 'next-auth/react'
|
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 { useState, useRef } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { formatCurrency } from '@/lib/utils'
|
import { formatCurrency } from '@/lib/utils'
|
||||||
@@ -61,6 +61,14 @@ export function Navbar() {
|
|||||||
<span className="font-bold text-lg hidden sm:block">HashEx</span>
|
<span className="font-bold text-lg hidden sm:block">HashEx</span>
|
||||||
</Link>
|
</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 */}
|
{/* Search */}
|
||||||
<form onSubmit={handleSearch} className="flex-1 max-w-md">
|
<form onSubmit={handleSearch} className="flex-1 max-w-md">
|
||||||
<div className="relative">
|
<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 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 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) ──────────────────────────────────────────────────────
|
// ── Queues (worker side) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -73,13 +78,13 @@ const priceWorker = new Worker(
|
|||||||
const hashtag = await prisma.hashtag.findUnique({ where: { id: hashtagId } })
|
const hashtag = await prisma.hashtag.findUnique({ where: { id: hashtagId } })
|
||||||
if (!hashtag) return
|
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) {
|
if (postsPerHour === 0) {
|
||||||
const newZeroCount = hashtag.zeroCount + 1
|
const newZeroCount = hashtag.zeroCount + 1
|
||||||
// Auto-deactivate after 3 consecutive zero-result updates with no owners
|
const shouldDeactivate = ttlExpired && ownerCount === 0
|
||||||
const ownerCount = await prisma.position.count({
|
|
||||||
where: { hashtagId, shares: { gt: 0 } },
|
|
||||||
})
|
|
||||||
const shouldDeactivate = newZeroCount >= 3 && ownerCount === 0
|
|
||||||
|
|
||||||
await prisma.hashtag.update({
|
await prisma.hashtag.update({
|
||||||
where: { id: hashtagId },
|
where: { id: hashtagId },
|
||||||
@@ -89,7 +94,17 @@ const priceWorker = new Worker(
|
|||||||
lastUpdated: new Date(),
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user