diff --git a/prod-compose.yml b/prod-compose.yml index 1bd73b1..6444cd5 100644 --- a/prod-compose.yml +++ b/prod-compose.yml @@ -16,6 +16,10 @@ services: PRICE_UPDATE_INTERVAL_MINUTES: "${PRICE_UPDATE_INTERVAL_MINUTES:-15}" ZOMBIE_ZERO_COUNT: "${ZOMBIE_ZERO_COUNT:-1000}" HASHTAG_ACTIVE_HOURS: "${HASHTAG_ACTIVE_HOURS:-24}" + MAX_POSITION_SHARES: "${MAX_POSITION_SHARES:-100}" + MAX_POSITION_VALUE: "${MAX_POSITION_VALUE:-1000}" + FUND_MAX_POSITION_SHARES: "${FUND_MAX_POSITION_SHARES:-1000}" + FUND_MAX_POSITION_VALUE: "${FUND_MAX_POSITION_VALUE:-10000}" depends_on: postgres: condition: service_healthy diff --git a/src/app/api/trade/route.ts b/src/app/api/trade/route.ts index c0ef58b..7ef0cf9 100644 --- a/src/app/api/trade/route.ts +++ b/src/app/api/trade/route.ts @@ -3,8 +3,14 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { calcTrade } from '@/lib/pricing' +import { formatCurrency } from '@/lib/utils' import { z } from 'zod' +const MAX_POSITION_SHARES = parseInt(process.env.MAX_POSITION_SHARES ?? '100', 10) +const MAX_POSITION_VALUE = parseInt(process.env.MAX_POSITION_VALUE ?? '1000', 10) +const FUND_MAX_POSITION_SHARES = parseInt(process.env.FUND_MAX_POSITION_SHARES ?? '1000', 10) +const FUND_MAX_POSITION_VALUE = parseInt(process.env.FUND_MAX_POSITION_VALUE ?? '10000', 10) + const tradeSchema = z.object({ hashtagId: z.string().min(1), type: z.enum(['BUY_LONG', 'SELL_LONG', 'BUY_SHORT', 'SELL_SHORT']), @@ -63,6 +69,25 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Insufficient balance.' }, { status: 400 }) } + if (type === 'BUY_LONG' || type === 'BUY_SHORT') { + const maxShares = fundId ? FUND_MAX_POSITION_SHARES : MAX_POSITION_SHARES + const maxValue = fundId ? FUND_MAX_POSITION_VALUE : MAX_POSITION_VALUE + const newTotalShares = (existingPosition?.shares ?? 0) + shares + const newTotalValue = newTotalShares * hashtag.currentPrice + if (newTotalShares > maxShares) { + return NextResponse.json( + { error: `Position limit: max ${maxShares.toLocaleString()} shares per hashtag.` }, + { status: 400 }, + ) + } + if (newTotalValue > maxValue) { + return NextResponse.json( + { error: `Position limit: max ${formatCurrency(maxValue)} position value per hashtag.` }, + { status: 400 }, + ) + } + } + if (type === 'SELL_LONG') { if (!existingPosition || existingPosition.shares < shares) { return NextResponse.json({ error: 'Insufficient shares to sell.' }, { status: 400 }) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 7cf2948..a0d1fd1 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -6,6 +6,15 @@ export function cn(...inputs: ClassValue[]) { } export function formatCurrency(value: number): string { + const abs = Math.abs(value) + if (abs >= 10_000) { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 2, + }).format(value) + } return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD',