From b5ed42314544cc86c1344b98bf0d0a049fc3789f Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Sat, 26 Sep 2026 17:43:51 -0500 Subject: [PATCH] Manual one-leg trigger: POST /api/accounts/[id]/fire-leg Fires one leg on one account through the scheduler's own fire path (sizing, take-profit, daily-loss stop, exit bracket), overriding only the one-trade- per-day rule for unsplit accounts. Flat, history, daily-loss room, trading hours, a flat lane and the lane mutex still apply. The daily-loss check is factored out so both paths share it. Co-Authored-By: Claude Opus 5.5 --- app/api/accounts/[id]/fire-leg/route.ts | 20 ++++ lib/auto-trade.ts | 128 +++++++++++++++++++----- 2 files changed, 124 insertions(+), 24 deletions(-) create mode 100644 app/api/accounts/[id]/fire-leg/route.ts diff --git a/app/api/accounts/[id]/fire-leg/route.ts b/app/api/accounts/[id]/fire-leg/route.ts new file mode 100644 index 0000000..1c74370 --- /dev/null +++ b/app/api/accounts/[id]/fire-leg/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fireManualLeg } from '@/lib/auto-trade'; +import { errorMessage } from '@/lib/platform-client'; + +type Params = { params: Promise<{ id: string }> }; + +/** Fire one leg on this account now, through the scheduler's own path (see + * fireManualLeg). Body: { action?: 'Buy' | 'Sell' | 'Auto' } (default Auto). */ +export async function POST(req: NextRequest, { params }: Params) { + const { id } = await params; + const accountId = Number(id); + if (!Number.isSafeInteger(accountId)) return NextResponse.json({ error: 'bad account id' }, { status: 400 }); + const body = (await req.json().catch(() => ({}))) as { action?: unknown }; + const action = body.action === 'Buy' || body.action === 'Sell' ? body.action : 'Auto'; + try { + return NextResponse.json(await fireManualLeg(accountId, action)); + } catch (err) { + return NextResponse.json({ error: errorMessage(err) }, { status: 409 }); + } +} diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 1c9cbd8..9a890c3 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -491,6 +491,45 @@ interface AccountEvaluation { dllRoom: number; } +/** + * The hard daily-loss check: the room left today, or why there is none to + * trade with. Room is Infinity when the account type has no limit. + */ +function dailyLossGate( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + accountId: number, + cfg: { dailyLossLimit?: number; maxPositionSize: number; firmId: string }, + cash: { amount: number; realizedPnL: number }, + combinedRealized: number, + remainingToday: number, + singleContract: boolean, +): { room: number } | { reason: string } { + const dll = cfg.dailyLossLimit ?? 0; + if (!(dll > 0)) return { room: Infinity }; + const dayStart: number | null | undefined = client.dayStartBalance?.(accountId); + if (dayStart === null) return { reason: DLL_BASE_PENDING }; + const room = dllRoom({ + dailyLossLimit: dll, + closedBalance: cash.amount, + dayStartBalance: dayStart, + // The broker's day, raw — and on a combining firm the worse of it + // and the combined figure, whichever the firm turns out to use. + realizedToday: Math.min(cash.realizedPnL, combinedRealized), + }); + // Measured-fee firms: a typical round trip on the contracts this leg + // could use. Percent-fee firms size by notional, which isn't known + // here — fireLeg re-checks with the real size before any entry. + const contractsBound = singleContract ? 1 + : Math.min(Math.max(1, Math.ceil(remainingToday / 1000)), cfg.maxPositionSize > 0 ? cfg.maxPositionSize : Infinity); + const percentFees = (getCachedFirm(cfg.firmId)?.commissionPct ?? 0) > 0; + const feeEstimate = percentFees ? 0 : 2 * FALLBACK_COMMISSION_PER_CONTRACT * contractsBound; + if (!dllViable(room, dll, feeEstimate)) { + return { reason: `daily loss limit: $${Math.round(room * 100) / 100} of $${dll} left — day booked` }; + } + return { room }; +} + /** * Evaluate whether an account can trade right now. Returns null when the account has no * matching config (not ours). Split-allowed accounts stay eligible mid-day until @@ -608,30 +647,9 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a // (day done, challenge complete, leg cap) win. A daily-loss stop-out // leaves about the reserve plus fees, which is below what a trade needs — // so a stop-out benches the account for the rest of the day by itself. - const dll = cfg.dailyLossLimit ?? 0; - if (dll > 0) { - const dayStart: number | null | undefined = client.dayStartBalance?.(acc.id); - if (dayStart === null) return { ...evaluated, eligible: false, reason: DLL_BASE_PENDING }; - const room = dllRoom({ - dailyLossLimit: dll, - closedBalance: cash.amount, - dayStartBalance: dayStart, - // The broker's day, raw — and on a combining firm the worse of it - // and the combined figure, whichever the firm turns out to use. - realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL), - }); - // Measured-fee firms: a typical round trip on the contracts this leg - // could use. Percent-fee firms size by notional, which isn't known - // here — fireLeg re-checks with the real size before any entry. - const contractsBound = isExtraDay || effective.drain ? 1 - : Math.min(Math.max(1, Math.ceil(plan.remainingToday / 1000)), cfg.maxPositionSize > 0 ? cfg.maxPositionSize : Infinity); - const percentFees = (getCachedFirm(cfg.firmId)?.commissionPct ?? 0) > 0; - const feeEstimate = percentFees ? 0 : 2 * FALLBACK_COMMISSION_PER_CONTRACT * contractsBound; - if (!dllViable(room, dll, feeEstimate)) { - return { ...evaluated, eligible: false, reason: `daily loss limit: $${Math.round(room * 100) / 100} of $${dll} left — day booked` }; - } - return { ...evaluated, eligible: true, reason: '', dllRoom: room }; - } + const gate = dailyLossGate(client, acc.id, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain); + if ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason }; + if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room }; return { ...evaluated, eligible: true, reason: '' }; } @@ -1345,6 +1363,68 @@ async function copyTradeLocked(lease: number, lane: string): Promise return results; } +// ── manual leg ─────────────────────────────────────────────────────────────── + +/** + * Fire one leg on one account now, through the same path the scheduler uses + * — sizing, take-profit, the daily-loss stop, the exit bracket — for someone + * testing an account by hand (POST /api/accounts/[id]/fire-leg). + * + * The one rule it overrides is "one trade per day" for unsplit accounts: an + * account that already booked something today may take another leg toward + * what's left of its target. Everything else still applies — flat, history + * loaded, the daily-loss room, the firm's trading hours, its lane flat and + * no batch in flight there. + */ +export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | 'Auto'): Promise { + const found = firmClients().ready.find(({ client }) => client.accountList.some((a) => a.id === accountId)); + if (!found) throw new Error('account not found on a synced client'); + const { firm, client } = found; + const acc = client.accountList.find((a) => a.id === accountId)!; + if (isInNoTradeWindow(firm)) throw new Error(`${firm.name} is outside its trading hours`); + + const tradeDate = currentTradeDateCT(); + const ev = evaluateAccountForTrade(client, acc, tradeDate); + if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type'); + if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason); + if (ev.isExtraDay) throw new Error('extra-day accounts only take the scheduled in-and-out'); + if (ev.plan.remainingToday <= LEG_COMPLETION_EPSILON) throw new Error(`day target met ($${ev.plan.remainingToday} left)`); + const cfg = getAccountConfig(acc.id).accountType!; + // The once-a-day return comes before the daily-loss check: run it here. + let room = ev.dllRoom; + if (!ev.eligible) { + const gate = dailyLossGate(client, acc.id, cfg, ev.cash, ev.cash.realizedPnL, ev.plan.remainingToday, ev.drain); + if ('reason' in gate) throw new Error(gate.reason); + room = gate.room; + } + + const lane = laneOf(firm); + const open = countOpenPositions(inLane(firmClients().ready, lane)); + if (open > 0) throw new Error(`${open} position(s) still open in the ${laneName(lane)} lane`); + const state = getState(); + const symbol = state.symbol !== 'Auto' ? state.symbol : 'NQ'; + const resolvedAction: 'Buy' | 'Sell' = action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action; + const contract = await client.findFrontMonthContract(symbol); + if (!contract) throw new Error(`no instrument for ${symbol}`); + const pv = contract.pointValue ?? POINT_VALUES[symbol]; + if (!pv) throw new Error(`no point value for ${symbol}`); + + const lease = acquireTradeMutex('[manual-leg]', lane); + if (lease === null) throw new Error('another batch is in flight in this lane'); + try { + const item: LegItem = { + firmName: firm.name, firm, client, acc, contract, pv, + cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain, + prefix: cfg.prefix, dllRoom: room, + }; + console.log(`[manual-leg] ${acc.name} (${firm.name}): firing one leg by hand`); + runtime().lastFireAt = Date.now(); + return await withAccountInFlight(acc.id, () => fireLeg('[manual-leg]', item, resolvedAction, symbol, tradeDate)); + } finally { + releaseTradeMutex(lease, lane); + } +} + // ── eligibility check ──────────────────────────────────────────────────────── /** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */