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 retention: days to keep for active hashtags, hours for inactive ones
|
||||||
PRICE_HISTORY_ACTIVE_DAYS=7
|
PRICE_HISTORY_ACTIVE_DAYS=7
|
||||||
PRICE_HISTORY_INACTIVE_HOURS=24
|
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.
|
# 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
|
# 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
|
BUY_SHORT
|
||||||
SELL_SHORT
|
SELL_SHORT
|
||||||
LOTTERY_WIN
|
LOTTERY_WIN
|
||||||
|
LIQUIDATE_LONG
|
||||||
|
LIQUIDATE_SHORT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,19 +59,22 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
<div className="divide-y divide-surface-border">
|
<div className="divide-y divide-surface-border">
|
||||||
{trades.map((t) => {
|
{trades.map((t) => {
|
||||||
const isLottery = t.type === 'LOTTERY_WIN'
|
const isLottery = t.type === 'LOTTERY_WIN'
|
||||||
|
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
|
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
|
||||||
isLottery
|
isLiquidation
|
||||||
? 'bg-amber-500/15 text-amber-400'
|
? 'bg-orange-500/15 text-orange-400'
|
||||||
: t.type.startsWith('BUY')
|
: isLottery
|
||||||
? 'bg-emerald-500/15 text-emerald-400'
|
? 'bg-amber-500/15 text-amber-400'
|
||||||
: 'bg-red-500/15 text-red-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>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
{isLottery ? (
|
{isLottery ? (
|
||||||
@@ -96,7 +99,7 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
<>
|
<>
|
||||||
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
||||||
<p className="text-xs text-slate-500">{formatCurrency(t.total)}</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)}`}>
|
<p className={`text-xs ${pnlColor(t.profit)}`}>
|
||||||
{formatPnl(t.profit)}
|
{formatPnl(t.profit)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -234,19 +234,22 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
<div className="divide-y divide-surface-border">
|
<div className="divide-y divide-surface-border">
|
||||||
{user.trades.map((t) => {
|
{user.trades.map((t) => {
|
||||||
const isLottery = t.type === 'LOTTERY_WIN'
|
const isLottery = t.type === 'LOTTERY_WIN'
|
||||||
|
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium px-2 py-0.5 rounded ${
|
className={`text-xs font-medium px-2 py-0.5 rounded ${
|
||||||
isLottery
|
isLiquidation
|
||||||
? 'bg-amber-500/15 text-amber-400'
|
? 'bg-orange-500/15 text-orange-400'
|
||||||
: t.type.startsWith('BUY')
|
: isLottery
|
||||||
? 'bg-emerald-500/15 text-emerald-400'
|
? 'bg-amber-500/15 text-amber-400'
|
||||||
: 'bg-red-500/15 text-red-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>
|
</span>
|
||||||
{isLottery ? (
|
{isLottery ? (
|
||||||
<span className="text-amber-300">Lucky Dip</span>
|
<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>
|
<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)}`}>
|
<p className={`text-xs ${pnlColor(t.profit)}`}>
|
||||||
{formatPnl(t.profit)}
|
{formatPnl(t.profit)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -245,14 +245,14 @@ export default async function StocksPage({ searchParams }: PageProps) {
|
|||||||
{/* Column headers */}
|
{/* 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">
|
<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} />
|
<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} />
|
<SortLink field="price" label="Price" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="flex justify-end">
|
||||||
<SortLink field="change" label="Change" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
<SortLink field="change" label="Change" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right hidden sm:block text-slate-400">Posts/hr</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} />
|
<SortLink field="updated" label="Updated" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,12 +47,14 @@ export default async function GlobalTradesPage({ searchParams }: PageProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
|
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
|
||||||
t.type.startsWith('BUY')
|
(t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT')
|
||||||
? 'bg-emerald-500/15 text-emerald-400'
|
? 'bg-orange-500/15 text-orange-400'
|
||||||
: 'bg-red-500/15 text-red-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>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/hashtag/${t.hashtag!.tag}`}
|
href={`/hashtag/${t.hashtag!.tag}`}
|
||||||
@@ -77,8 +79,8 @@ export default async function GlobalTradesPage({ searchParams }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 tabular-nums ml-3">{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</span>
|
<span className="shrink-0 tabular-nums ml-3">{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* PnL: sell trades only */}
|
{/* PnL: sell and liquidation trades */}
|
||||||
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
|
{(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 className={`text-xs text-right ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+58
-2
@@ -13,7 +13,7 @@
|
|||||||
import { Worker, Queue } from 'bullmq'
|
import { Worker, Queue } from 'bullmq'
|
||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import { getPostsData } from '../lib/mastodon'
|
import { getPostsData } from '../lib/mastodon'
|
||||||
import { calcPrice, dailyResearchPoints } from '../lib/pricing'
|
import { calcPrice, calcTrade, dailyResearchPoints } from '../lib/pricing'
|
||||||
|
|
||||||
// ── Connection options ────────────────────────────────────────────────────────
|
// ── Connection options ────────────────────────────────────────────────────────
|
||||||
// Use plain connection options so BullMQ uses its own bundled ioredis,
|
// 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 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)
|
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 {
|
function activeUntilFromNow(): Date {
|
||||||
return new Date(Date.now() + ACTIVE_HOURS * 60 * 60 * 1000)
|
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 maintenanceQueue = new Queue('hashex-maintenance', { connection: redisOpts() })
|
||||||
const schedulerQueue = new Queue('hashex-scheduler', { 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 ───────────────────────────────────────────────────────────────────
|
// ── Workers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,8 +127,22 @@ const priceWorker = new Worker(
|
|||||||
|
|
||||||
if (postsPerHour === 0) {
|
if (postsPerHour === 0) {
|
||||||
const newZeroCount = hashtag.zeroCount + 1
|
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({
|
await prisma.hashtag.update({
|
||||||
where: { id: hashtagId },
|
where: { id: hashtagId },
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user