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:
co-authored by
Claude Opus 5.5
parent
59af95f0b1
commit
b5ed423145
@@ -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
@@ -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<unknown[]>
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
|
||||
|
||||
Reference in New Issue
Block a user