diff --git a/.env.example b/.env.example
index 6e22182..917e93a 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1c777fe..7d87c98 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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())
diff --git a/src/app/api/research/route.ts b/src/app/api/research/route.ts
index 708cfc4..967898a 100644
--- a/src/app/api/research/route.ts
+++ b/src/app/api/research/route.ts
@@ -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 },
diff --git a/src/app/api/trade/route.ts b/src/app/api/trade/route.ts
index c92b86f..260b0c7 100644
--- a/src/app/api/trade/route.ts
+++ b/src/app/api/trade/route.ts
@@ -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 })
}
diff --git a/src/app/stocks/page.tsx b/src/app/stocks/page.tsx
new file mode 100644
index 0000000..245aa7e
--- /dev/null
+++ b/src/app/stocks/page.tsx
@@ -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 (
+
+ {label}
+
+ {up ? '+' : ''}{formatCurrency(change)} +
++ {up ? '+' : ''}{changePct!.toFixed(2)}% +
+