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;
|
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). */
|
||||||
|
|||||||
Reference in New Issue
Block a user