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 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-26 17:43:51 -05:00
co-authored by Claude Opus 5.5
parent 59af95f0b1
commit b5ed423145
2 changed files with 124 additions and 24 deletions
+20
View File
@@ -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 });
}
}
+104 -24
View File
@@ -491,6 +491,45 @@ interface AccountEvaluation {
dllRoom: number; 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 * 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 * 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 // (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 — // 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. // so a stop-out benches the account for the rest of the day by itself.
const dll = cfg.dailyLossLimit ?? 0; const gate = dailyLossGate(client, acc.id, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain);
if (dll > 0) { if ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason };
const dayStart: number | null | undefined = client.dayStartBalance?.(acc.id); if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room };
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 };
}
return { ...evaluated, eligible: true, reason: '' }; return { ...evaluated, eligible: true, reason: '' };
} }
@@ -1345,6 +1363,68 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
return results; 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<LegResult> {
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 ──────────────────────────────────────────────────────── // ── eligibility check ────────────────────────────────────────────────────────
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */ /** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */