liquidation rules hahaha
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m25s

This commit is contained in:
2026-03-19 01:58:45 -04:00
parent da568646e2
commit 6bfbfcc8a0
8 changed files with 94 additions and 26 deletions
+2
View File
@@ -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
+2
View File
@@ -177,4 +177,6 @@ enum TradeType {
BUY_SHORT
SELL_SHORT
LOTTERY_WIN
LIQUIDATE_LONG
LIQUIDATE_SHORT
}
+10 -7
View File
@@ -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>
+10 -7
View File
@@ -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>
+3 -3
View File
@@ -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>
+8 -6
View File
@@ -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
View File
@@ -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: {
+1 -1
View File
File diff suppressed because one or more lines are too long