This commit is contained in:
@@ -24,6 +24,8 @@ MAX_PAGES_PER_HASHTAG=5
|
||||
# Price history retention: days to keep for active hashtags, hours for inactive ones
|
||||
PRICE_HISTORY_ACTIVE_DAYS=7
|
||||
PRICE_HISTORY_INACTIVE_HOURS=24
|
||||
# Consecutive zero-post updates before all positions are force-closed and the hashtag retired
|
||||
ZOMBIE_ZERO_COUNT=1000
|
||||
|
||||
# Initial admin user — only used by `npm run db:seed`, not the running app.
|
||||
# Pass these at seed time: docker exec -e ADMIN_USERNAME=x -e ADMIN_PASSWORD=y <container> npm run db:seed
|
||||
|
||||
@@ -177,4 +177,6 @@ enum TradeType {
|
||||
BUY_SHORT
|
||||
SELL_SHORT
|
||||
LOTTERY_WIN
|
||||
LIQUIDATE_LONG
|
||||
LIQUIDATE_SHORT
|
||||
}
|
||||
|
||||
@@ -59,19 +59,22 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
||||
<div className="divide-y divide-surface-border">
|
||||
{trades.map((t) => {
|
||||
const isLottery = t.type === 'LOTTERY_WIN'
|
||||
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
|
||||
return (
|
||||
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
|
||||
isLottery
|
||||
? 'bg-amber-500/15 text-amber-400'
|
||||
: t.type.startsWith('BUY')
|
||||
? 'bg-emerald-500/15 text-emerald-400'
|
||||
: 'bg-red-500/15 text-red-400'
|
||||
isLiquidation
|
||||
? 'bg-orange-500/15 text-orange-400'
|
||||
: isLottery
|
||||
? 'bg-amber-500/15 text-amber-400'
|
||||
: t.type.startsWith('BUY')
|
||||
? 'bg-emerald-500/15 text-emerald-400'
|
||||
: 'bg-red-500/15 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{t.type.replace(/_/g, ' ')}
|
||||
{isLiquidation ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<div>
|
||||
{isLottery ? (
|
||||
@@ -96,7 +99,7 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
||||
<>
|
||||
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
||||
<p className="text-xs text-slate-500">{formatCurrency(t.total)}</p>
|
||||
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
|
||||
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || isLiquidation) && (
|
||||
<p className={`text-xs ${pnlColor(t.profit)}`}>
|
||||
{formatPnl(t.profit)}
|
||||
</p>
|
||||
|
||||
@@ -234,19 +234,22 @@ export default async function ProfilePage({ params }: Props) {
|
||||
<div className="divide-y divide-surface-border">
|
||||
{user.trades.map((t) => {
|
||||
const isLottery = t.type === 'LOTTERY_WIN'
|
||||
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
|
||||
return (
|
||||
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded ${
|
||||
isLottery
|
||||
? 'bg-amber-500/15 text-amber-400'
|
||||
: t.type.startsWith('BUY')
|
||||
? 'bg-emerald-500/15 text-emerald-400'
|
||||
: 'bg-red-500/15 text-red-400'
|
||||
isLiquidation
|
||||
? 'bg-orange-500/15 text-orange-400'
|
||||
: isLottery
|
||||
? 'bg-amber-500/15 text-amber-400'
|
||||
: t.type.startsWith('BUY')
|
||||
? 'bg-emerald-500/15 text-emerald-400'
|
||||
: 'bg-red-500/15 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{t.type.replace(/_/g, ' ')}
|
||||
{isLiquidation ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')}
|
||||
</span>
|
||||
{isLottery ? (
|
||||
<span className="text-amber-300">Lucky Dip</span>
|
||||
@@ -265,7 +268,7 @@ export default async function ProfilePage({ params }: Props) {
|
||||
) : (
|
||||
<>
|
||||
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
||||
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
|
||||
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || isLiquidation) && (
|
||||
<p className={`text-xs ${pnlColor(t.profit)}`}>
|
||||
{formatPnl(t.profit)}
|
||||
</p>
|
||||
|
||||
@@ -245,14 +245,14 @@ export default async function StocksPage({ searchParams }: PageProps) {
|
||||
{/* Column headers */}
|
||||
<div className="grid grid-cols-[2fr_1fr_1fr] sm: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} fund={fund} />
|
||||
<div className="text-right">
|
||||
<div className="flex justify-end">
|
||||
<SortLink field="price" label="Price" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex justify-end">
|
||||
<SortLink field="change" label="Change" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
||||
</div>
|
||||
<div className="text-right hidden sm:block text-slate-400">Posts/hr</div>
|
||||
<div className="text-right hidden sm:block">
|
||||
<div className="hidden sm:flex justify-end">
|
||||
<SortLink field="updated" label="Updated" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,12 +47,14 @@ export default async function GlobalTradesPage({ searchParams }: PageProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
|
||||
t.type.startsWith('BUY')
|
||||
? 'bg-emerald-500/15 text-emerald-400'
|
||||
: 'bg-red-500/15 text-red-400'
|
||||
(t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT')
|
||||
? 'bg-orange-500/15 text-orange-400'
|
||||
: t.type.startsWith('BUY')
|
||||
? 'bg-emerald-500/15 text-emerald-400'
|
||||
: 'bg-red-500/15 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{t.type.replace('_', ' ')}
|
||||
{(t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT') ? 'LIQUIDATED' : t.type.replace('_', ' ')}
|
||||
</span>
|
||||
<Link
|
||||
href={`/hashtag/${t.hashtag!.tag}`}
|
||||
@@ -77,8 +79,8 @@ export default async function GlobalTradesPage({ searchParams }: PageProps) {
|
||||
</div>
|
||||
<span className="shrink-0 tabular-nums ml-3">{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</span>
|
||||
</div>
|
||||
{/* PnL: sell trades only */}
|
||||
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
|
||||
{/* PnL: sell and liquidation trades */}
|
||||
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT') && (
|
||||
<div className={`text-xs text-right ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+58
-2
@@ -13,7 +13,7 @@
|
||||
import { Worker, Queue } from 'bullmq'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { getPostsData } from '../lib/mastodon'
|
||||
import { calcPrice, dailyResearchPoints } from '../lib/pricing'
|
||||
import { calcPrice, calcTrade, dailyResearchPoints } from '../lib/pricing'
|
||||
|
||||
// ── Connection options ────────────────────────────────────────────────────────
|
||||
// Use plain connection options so BullMQ uses its own bundled ioredis,
|
||||
@@ -43,6 +43,7 @@ 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)
|
||||
const ZOMBIE_ZERO_COUNT = parseInt(process.env.ZOMBIE_ZERO_COUNT ?? '1000', 10)
|
||||
|
||||
function activeUntilFromNow(): Date {
|
||||
return new Date(Date.now() + ACTIVE_HOURS * 60 * 60 * 1000)
|
||||
@@ -54,6 +55,47 @@ const priceUpdateQueue = new Queue('hashex-price-updates', { connection: redisOp
|
||||
const maintenanceQueue = new Queue('hashex-maintenance', { connection: redisOpts() })
|
||||
const schedulerQueue = new Queue('hashex-scheduler', { connection: redisOpts() })
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Force-close every open position on a hashtag at the given price.
|
||||
* Creates SELL_LONG / SELL_SHORT trade records and credits users' balances.
|
||||
* Returns the number of positions closed.
|
||||
*/
|
||||
async function forceClosePositions(hashtagId: string, price: number, tag: string): Promise<number> {
|
||||
const positions = await prisma.position.findMany({
|
||||
where: { hashtagId, shares: { gt: 0 } },
|
||||
})
|
||||
for (const pos of positions) {
|
||||
const type = pos.positionType === 'LONG' ? 'LIQUIDATE_LONG' as const : 'LIQUIDATE_SHORT' as const
|
||||
const calcType = pos.positionType === 'LONG' ? 'SELL_LONG' as const : 'SELL_SHORT' as const
|
||||
const { total, balanceDelta, profit } = calcTrade(calcType, pos.shares, price, pos.avgBuyPrice)
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: pos.userId },
|
||||
data: { balance: { increment: balanceDelta } },
|
||||
}),
|
||||
prisma.position.update({
|
||||
where: { id: pos.id },
|
||||
data: { shares: 0 },
|
||||
}),
|
||||
prisma.trade.create({
|
||||
data: {
|
||||
userId: pos.userId,
|
||||
hashtagId,
|
||||
type,
|
||||
shares: pos.shares,
|
||||
price,
|
||||
total,
|
||||
profit,
|
||||
},
|
||||
}),
|
||||
])
|
||||
console.log(`[price] #${tag} force-closed ${type} for user ${pos.userId} — ${pos.shares} sh @ $${price.toFixed(2)}, P&L $${profit.toFixed(2)}`)
|
||||
}
|
||||
return positions.length
|
||||
}
|
||||
|
||||
// ── Workers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -85,8 +127,22 @@ const priceWorker = new Worker(
|
||||
|
||||
if (postsPerHour === 0) {
|
||||
const newZeroCount = hashtag.zeroCount + 1
|
||||
const shouldDeactivate = ttlExpired && ownerCount === 0
|
||||
|
||||
// Zombie threshold: retire the hashtag and force-close any remaining positions
|
||||
if (newZeroCount >= ZOMBIE_ZERO_COUNT) {
|
||||
if (ownerCount > 0) {
|
||||
const closed = await forceClosePositions(hashtagId, hashtag.currentPrice, tag)
|
||||
console.log(`[price] #${tag} zombie threshold (zeroCount=${newZeroCount}) — force-closed ${closed} position(s)`)
|
||||
}
|
||||
await prisma.hashtag.update({
|
||||
where: { id: hashtagId },
|
||||
data: { zeroCount: newZeroCount, isActive: false, lastUpdated: new Date() },
|
||||
})
|
||||
console.log(`[price] #${tag} retired (zeroCount=${newZeroCount})`)
|
||||
return
|
||||
}
|
||||
|
||||
const shouldDeactivate = ttlExpired && ownerCount === 0
|
||||
await prisma.hashtag.update({
|
||||
where: { id: hashtagId },
|
||||
data: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user