Vest: a split day's next leg gives back the day's gain plus the limit
On a split day the next Vest leg's daily-loss room now adds the day's gain still in the account to the bot's limit (floorRoom's `gain`), never past Vest's own floor, and the leg sizes up for the same target hold (Dingo's next eval leg: $455k -> $842k at 1.5 h, win 28% -> 56%). Other platforms are unchanged: dllRoom still never widens on a green day. The gain only widens a leg all but sure to close before Vest's 8 PM ET reset (resetClearStop, held to the shortest leg the day may carve), and none of a hand claim's profit counts. The last-stand margin is measured against the limit, so a green day can't drop a leg's stop. Around the reset: no new Vest position opens in the last 7 minutes, and the guard re-fits every 15 s on fresh positions instead of one pass. An account's positions share the next-day room (stops first, least at risk first; locked-in profit counts), a hand-moved stop is fitted again, and a close or profit-side stop that doesn't take is retried or closed. The dashboard's next stop now reserves for the leg's notional, as fireLeg does, so DXtrade labels read smaller (display only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1e844178de
commit
f0c1569d96
+6
-5
@@ -18,7 +18,7 @@ import {
|
||||
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
|
||||
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
||||
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
|
||||
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom,
|
||||
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom, shortestNextLeg,
|
||||
type DayPlan, type FinishedCycleMark,
|
||||
} from './trading-logic';
|
||||
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
|
||||
@@ -621,7 +621,7 @@ function dailyLossGate(
|
||||
cfg: { maxPositionSize: number; firmId: string },
|
||||
cash: { amount: number; realizedPnL: number },
|
||||
combinedRealized: number,
|
||||
remainingToday: number,
|
||||
plan: { remainingToday: number; splitAllowed: boolean },
|
||||
singleContract: boolean,
|
||||
): { room: number } | { reason: string } {
|
||||
if (!(dll > 0)) return { room: Infinity };
|
||||
@@ -634,6 +634,7 @@ function dailyLossGate(
|
||||
room = floorRoom({
|
||||
dailyLossLimit: dll, closedBalance: cash.amount, floor,
|
||||
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
||||
gain: client.dayGainForRoom?.(accountId, { leg: shortestNextLeg(plan), limit: dll }) ?? 0,
|
||||
});
|
||||
} else {
|
||||
const dayStart: number | null | undefined = client.dayStartBalance?.(accountId);
|
||||
@@ -651,7 +652,7 @@ function dailyLossGate(
|
||||
// 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);
|
||||
: 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)) {
|
||||
@@ -837,7 +838,7 @@ 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 gate = dailyLossGate(client, acc.id, effective.dailyLossLimit, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain);
|
||||
const gate = dailyLossGate(client, acc.id, effective.dailyLossLimit, cfg, cash, combined.realizedPnL, plan, 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 };
|
||||
|
||||
@@ -1868,7 +1869,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
||||
// 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, ev.dll, cfg, ev.cash, ev.cash.realizedPnL, ev.plan.remainingToday, ev.drain);
|
||||
const gate = dailyLossGate(client, acc.id, ev.dll, cfg, ev.cash, ev.cash.realizedPnL, ev.plan, ev.drain);
|
||||
if ('reason' in gate) throw new Error(gate.reason);
|
||||
room = gate.room;
|
||||
}
|
||||
|
||||
+5
-2
@@ -11,7 +11,7 @@ import type { PlatformContract } from './platform-client';
|
||||
import type { SymbolStats } from './master-protocol';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom,
|
||||
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, stageInputsFor, floorRoom,
|
||||
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, stageInputsFor, floorRoom, shortestNextLeg,
|
||||
FALLBACK_COMMISSION_PER_CONTRACT,
|
||||
} from './trading-logic';
|
||||
|
||||
@@ -27,6 +27,8 @@ export function dayLossRoom(
|
||||
dll: number,
|
||||
cash: { amount: number; realizedPnL: number },
|
||||
combinedRealized: number,
|
||||
/** The shortest take-profit the next leg may have (shortestNextLeg). */
|
||||
nextLeg: number,
|
||||
): number | null {
|
||||
if (!(dll > 0)) return null;
|
||||
const floor: number | null | undefined = client.dailyLossFloor?.(accountId);
|
||||
@@ -35,6 +37,7 @@ export function dayLossRoom(
|
||||
return floorRoom({
|
||||
dailyLossLimit: dll, closedBalance: cash.amount, floor,
|
||||
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
||||
gain: client.dayGainForRoom?.(accountId, { leg: nextLeg, limit: dll }) ?? 0,
|
||||
});
|
||||
}
|
||||
const dayStart = client.dayStartBalance?.(accountId);
|
||||
@@ -143,7 +146,7 @@ function accountPlan(
|
||||
);
|
||||
if (!plan) return null;
|
||||
const dll = effective.dailyLossLimit;
|
||||
const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL);
|
||||
const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, shortestNextLeg(plan));
|
||||
const obfuscation = obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||
return { cfg, cash, plan, dll, room, obfuscation };
|
||||
}
|
||||
|
||||
+10
-1
@@ -125,12 +125,21 @@ export interface PlatformClient {
|
||||
* The equity the account may not fall below today, where the platform
|
||||
* states its daily-loss floor itself and moves it with the day's
|
||||
* transfers (Vest: reset balance × (1 − rate), lowered by a payout). The
|
||||
* engine's room is then min(the stage's limit, balance − floor), so a
|
||||
* engine's room is then what's left of the stage's limit (widened by
|
||||
* dayGainForRoom), never more than balance − floor (floorRoom), so a
|
||||
* same-day payout that lowers the floor frees room to trade again. null =
|
||||
* the platform has one but it isn't known yet (hold); undefined = none
|
||||
* here (the engine falls back to dayStartBalance / realized P&L).
|
||||
*/
|
||||
dailyLossFloor?(accountId: number): number | null | undefined;
|
||||
/**
|
||||
* With dailyLossFloor: how much of the day's gain a leg starting now, its
|
||||
* take-profit `leg` away and its room `limit` before any gain, may add to
|
||||
* its daily-loss room (floorRoom's `gain`) — the gain still in the account
|
||||
* since the day's base, no more than keeps the leg all but sure to close
|
||||
* before the platform's daily reset (resetClearStop).
|
||||
*/
|
||||
dayGainForRoom?(accountId: number, o: { leg: number; limit: number }): number;
|
||||
/** Payouts made since the firm's last daily reset — what picks the stage
|
||||
* of an account type whose stages reset each day. */
|
||||
sameDayClaims?(accountId: number): number;
|
||||
|
||||
+57
-13
@@ -1339,17 +1339,6 @@ export function dllReserve(dailyLossLimit: number, notional = 0): number {
|
||||
* anything tighter just spends the day's room on fees. */
|
||||
export const DLL_MIN_STOP = 50;
|
||||
|
||||
/**
|
||||
* How much the firm's day may still lose before a hard daily-loss breach.
|
||||
* Infinity when the account type has no limit.
|
||||
*
|
||||
* The day's P&L is the closed balance against the platform's snapshot at the
|
||||
* firm's daily reset when the client knows it (DXTrade: 22:00 UTC), else the
|
||||
* broker's realized P&L for the session. Only a red day counts: a green day
|
||||
* does NOT widen the room past the limit, because a position still open at
|
||||
* the next reset has its whole move counted against the NEW day — room
|
||||
* borrowed from today's profit would be a breach tomorrow.
|
||||
*/
|
||||
/** The stage inputs for one account: the type's daily loss limit, and for a
|
||||
* type whose stages reset each day, the client's count of today's payouts. */
|
||||
export function stageInputsFor(
|
||||
@@ -1365,6 +1354,37 @@ export function stageInputsFor(
|
||||
};
|
||||
}
|
||||
|
||||
/** A leg is given this many of its target holds to be done before a daily
|
||||
* reset counts its open loss against the next day: under a fair walk, a leg
|
||||
* whose take-profit and stop are within 2:1 is still open after four holds
|
||||
* about 1–1.5% of the time, and the reset guard fits those. */
|
||||
export const RESET_CLEAR_HOLDS = 4;
|
||||
|
||||
/** The shortest take-profit the plan's next leg may have — what
|
||||
* resetClearStop is held to: a split day can carve a leg as small as
|
||||
* MIN_LEG (pickLegAmount), else the leg is all that remains. */
|
||||
export function shortestNextLeg(plan: { remainingToday: number; splitAllowed: boolean }): number {
|
||||
return plan.splitAllowed ? Math.min(plan.remainingToday, MIN_LEG) : plan.remainingToday;
|
||||
}
|
||||
|
||||
/**
|
||||
* The widest stop a leg whose take-profit is `leg` away may carry and still
|
||||
* be all but surely closed by a daily reset `resetInMs` away; 0 when the
|
||||
* reset is under RESET_CLEAR_HOLDS holds off. The walk's exit time has a
|
||||
* tail that decays as e^(−π²·t/(2g·hold)), g = (A+B)²/(A·B): a lopsided leg
|
||||
* (a $5,000 stop on a $500 take-profit) lingers far longer than its hold. So
|
||||
* the leg may be as lopsided as k = holds-to-reset ÷ RESET_CLEAR_HOLDS times
|
||||
* the 2:1 shape (g = 4.5) allows — g ≤ 4.5k — which keeps its chance of
|
||||
* still being open at the reset what a 2:1 leg's is at four holds.
|
||||
*/
|
||||
export function resetClearStop(o: { leg: number; resetInMs: number; holdHours: number }): number {
|
||||
if (!(o.leg > 0) || !(o.holdHours > 0) || !Number.isFinite(o.resetInMs)) return 0;
|
||||
const k = o.resetInMs / (RESET_CLEAR_HOLDS * o.holdHours * 3_600_000);
|
||||
if (k < 1) return 0;
|
||||
const c = 4.5 * k - 2;
|
||||
return (o.leg * (c + Math.sqrt(c * c - 4))) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* dllRoom where the platform states its daily-loss floor itself (Vest: the
|
||||
* equity the account may not fall below today, already moved by the day's
|
||||
@@ -1373,6 +1393,14 @@ export function stageInputsFor(
|
||||
* limit set below the firm's (Vest's 3% is $1,500; the bot's day stops at
|
||||
* $1,229) would let a day that already lost $1,111 trade the $389 Vest still
|
||||
* allows. Infinity when there is no limit.
|
||||
*
|
||||
* `gain` widens it: on a split day the next leg may give back what the
|
||||
* earlier legs made, plus the limit — a $1,400 day up $2,604 gets $4,004 —
|
||||
* as Vest's own floor does. The client says how much (dayGainForRoom): the
|
||||
* gain still in the account, no more than keeps the leg all but sure to be
|
||||
* closed by the reset (resetClearStop) — a leg still open there has all of
|
||||
* its loss counted against the next day. Never more than the day's realized
|
||||
* gain either way.
|
||||
*/
|
||||
export function floorRoom(o: {
|
||||
dailyLossLimit: number;
|
||||
@@ -1381,11 +1409,25 @@ export function floorRoom(o: {
|
||||
/** The day's realized P&L since its base (Vest: since this client's
|
||||
* latest claim today, else since the reset). */
|
||||
realizedToday: number;
|
||||
/** What of the day's gain the room may grow by; 0 (default) = none. */
|
||||
gain?: number;
|
||||
}): number {
|
||||
if (!(o.dailyLossLimit > 0)) return Infinity;
|
||||
return round2(Math.min(o.dailyLossLimit + Math.min(0, o.realizedToday), o.closedBalance - o.floor));
|
||||
const widen = Math.min(Math.max(0, o.realizedToday), Math.max(0, o.gain ?? 0));
|
||||
return round2(Math.min(o.dailyLossLimit + Math.min(0, o.realizedToday) + widen, o.closedBalance - o.floor));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much the firm's day may still lose before a hard daily-loss breach.
|
||||
* Infinity when the account type has no limit.
|
||||
*
|
||||
* The day's P&L is the closed balance against the platform's snapshot at the
|
||||
* firm's daily reset when the client knows it (DXTrade: 22:00 UTC), else the
|
||||
* broker's realized P&L for the session. Only a red day counts: a green day
|
||||
* does NOT widen the room past the limit, because a position still open at
|
||||
* the next reset has its whole move counted against the NEW day — room
|
||||
* borrowed from today's profit would be a breach tomorrow.
|
||||
*/
|
||||
export function dllRoom(o: {
|
||||
dailyLossLimit: number;
|
||||
closedBalance: number;
|
||||
@@ -1447,7 +1489,9 @@ export const LAST_STAND_FRACTION = 0.15;
|
||||
*/
|
||||
export function lastStand(o: { dllRoom: number; dailyLossLimit: number; maxLossRoom: number | null }): boolean {
|
||||
if (o.maxLossRoom === null || !(o.dailyLossLimit > 0) || !Number.isFinite(o.dllRoom) || !(o.dllRoom > 0)) return false;
|
||||
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * o.dllRoom;
|
||||
// A full day's loss is the limit, however much a green day widened the
|
||||
// room (floorRoom): a stop-out leaving $300 over the floor keeps a day.
|
||||
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * Math.min(o.dllRoom, o.dailyLossLimit);
|
||||
}
|
||||
|
||||
/** On a last stand: how far price can move against the leg before the firm's
|
||||
|
||||
+173
-51
@@ -38,7 +38,7 @@
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import axios from './http-client';
|
||||
import { currentTradeDateCT, rankForTarget, dllReserve } from './trading-logic';
|
||||
import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop } from './trading-logic';
|
||||
import { sizingHoldHours } from './person-size';
|
||||
import {
|
||||
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
||||
@@ -105,6 +105,12 @@ const ASSUMED_SPREAD_TICKS = 1;
|
||||
/** How long before the daily reset open positions are fitted to the room
|
||||
* the reset will leave (guardReset). */
|
||||
const RESET_GUARD_MS = 5 * 60_000;
|
||||
/** No new position opens this close to the reset: the guard's window, and a
|
||||
* couple of minutes before it for an entry in flight to place its exits. */
|
||||
const RESET_ENTRY_BLOCK_MS = RESET_GUARD_MS + 2 * 60_000;
|
||||
/** How long a stop or close the guard just sent counts before a read of
|
||||
* open positions shows it. */
|
||||
const GUARD_ECHO_MS = 30_000;
|
||||
/** account_meta key of the profit claims this client made, per account. */
|
||||
const CLAIMS_META = 'vest_claims';
|
||||
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
|
||||
@@ -200,8 +206,14 @@ export class VestClient implements PlatformClient {
|
||||
/** Each account's WITHDRAW transactions at the last history read, so a
|
||||
* claim made by hand on Vest counts toward today's stage too. */
|
||||
private withdrawals = new Map<number, { at: number; amount: number }[]>();
|
||||
/** The reset guardReset last finished for (epoch ms). */
|
||||
private resetGuardedFor = 0;
|
||||
/** The reset guardReset is fitting positions for (epoch ms), and the
|
||||
* stops it placed and closes it sent, by position, with when — a read
|
||||
* just after may not show them yet. */
|
||||
private resetGuard = {
|
||||
reset: 0, stops: new Map<string, { price: number; at: number }>(), closes: new Map<string, number>(),
|
||||
noSessionLogged: false, unfittable: new Set<string>(),
|
||||
};
|
||||
private resetGuardInFlight = false;
|
||||
private capital = new Map<number, VestCapitalAccount>();
|
||||
private positionsDetail = new Map<number, VestPosition[]>();
|
||||
private openOrders = new Map<number, VestOpenOrder[]>();
|
||||
@@ -708,8 +720,8 @@ export class VestClient implements PlatformClient {
|
||||
/**
|
||||
* See PlatformClient: Vest states the day's floor itself — reset balance
|
||||
* × (1 − rate), lowered by a payout the same day — so the engine's room
|
||||
* is min(the stage's limit, balance − floor), never more than Vest
|
||||
* leaves. null until the account has been read; undefined for an account
|
||||
* is what's left of the stage's limit, widened by dayGainForRoom, never
|
||||
* more than balance − floor (floorRoom). null until the account has been read; undefined for an account
|
||||
* Vest gives no daily limit.
|
||||
*/
|
||||
public dailyLossFloor(accountId: number): number | null | undefined {
|
||||
@@ -720,6 +732,28 @@ export class VestClient implements PlatformClient {
|
||||
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* See PlatformClient: the gain since the day's base that is still in the
|
||||
* account — since this client's own claim today, else since the reset,
|
||||
* with no add-back for a claim made by hand (that profit is gone) — up to
|
||||
* the stop that keeps the leg all but sure to close before the 8 PM ET
|
||||
* reset at the firm's target hold (resetClearStop); none before Vest has
|
||||
* captured today's reset balance.
|
||||
*/
|
||||
public dayGainForRoom(accountId: number, o: { leg: number; limit: number }): number {
|
||||
const now = Date.now();
|
||||
const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId));
|
||||
const allowed = resetClearStop({ leg: o.leg, resetInMs: nextVestResetMs(now) - now, holdHours: hold }) - o.limit;
|
||||
if (!(allowed > 0)) return 0;
|
||||
const a = this.capital.get(accountId);
|
||||
const cash = this.accountCashBalances[accountId];
|
||||
if (!a || !cash) return 0;
|
||||
const mine = this.madeClaims(accountId).filter((c) => c.at >= lastVestResetMs(now));
|
||||
const base = mine.length > 0 ? mine[mine.length - 1].base
|
||||
: Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now) ? a.dailyResetBalance : undefined;
|
||||
return base === undefined || !Number.isFinite(base) ? 0 : Math.max(0, Math.min(round2(cash.amount - base), round2(allowed)));
|
||||
}
|
||||
|
||||
private claimsOf(accountId: number): OwnClaim[] {
|
||||
let list = this.claims.get(accountId);
|
||||
if (!list) {
|
||||
@@ -888,62 +922,139 @@ export class VestClient implements PlatformClient {
|
||||
* Before each 20:00 ET reset, fit every open position on an account with
|
||||
* a daily limit into the room the reset will leave (resetSafeStop): a
|
||||
* leg carrying more room than that — a same-day stage on the room a
|
||||
* payout freed, a last stand — would otherwise breach the moment the
|
||||
* floor moves up to 97% of the realized balance. Its stop comes in, or
|
||||
* the position closes when there is no room or the mark is already past.
|
||||
* Runs in the last RESET_GUARD_MS; a pass that fails retries next tick.
|
||||
* payout freed, a last stand, a split day's leg widened by its gain, a
|
||||
* hand trade — would otherwise breach the moment the floor moves up to
|
||||
* 97% of the realized balance. Its stop comes in — on the profit side of
|
||||
* the entry when the account's other positions took the room — or the
|
||||
* position closes when the mark is already past that price. An account's
|
||||
* positions share the room: those with a stop first, least at risk
|
||||
* first, then those with none, oldest first.
|
||||
*
|
||||
* Every tick of the last RESET_GUARD_MS, on positions read afresh, so one
|
||||
* opened by hand after the first pass is fitted too; no new position
|
||||
* opens in that window (RESET_ENTRY_BLOCK_MS).
|
||||
*/
|
||||
private async guardReset(): Promise<void> {
|
||||
if (!this.syncComplete || this.intentionalDisconnect) return;
|
||||
if (!this.syncComplete || this.intentionalDisconnect || this.resetGuardInFlight) return;
|
||||
const now = Date.now();
|
||||
const next = nextVestResetMs(now);
|
||||
if (!Number.isFinite(next) || next - now > RESET_GUARD_MS || this.resetGuardedFor === next) return;
|
||||
if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) return;
|
||||
const specs = await this.symbolSpecs();
|
||||
let failed = 0;
|
||||
for (const acc of this.accountList) {
|
||||
const a = this.capital.get(acc.id);
|
||||
const cash = this.accountCashBalances[acc.id];
|
||||
const uuid = this.uuidById.get(acc.id);
|
||||
if (!a || !cash || !uuid || !(a.maxDailyLossPct > 0)) continue;
|
||||
for (const pos of this.positionsDetail.get(acc.id) ?? []) {
|
||||
const spec = specs.get(pos.symbol);
|
||||
if (!spec) continue;
|
||||
await this.refreshMarks([pos.symbol]).catch(() => { /* the last mark stands */ });
|
||||
const mark = this.marks.get(pos.symbol)?.mark ?? NaN;
|
||||
const stopLeg = pos.stopLosses.find((l) => l.status === 'awaiting') ?? null;
|
||||
const notional = pos.quantity * (Number.isFinite(mark) ? mark : pos.openPrice);
|
||||
const decision = resetSafeStop({
|
||||
side: pos.side, quantity: pos.quantity, openPrice: pos.openPrice, mark,
|
||||
balance: cash.amount, dailyLossPct: a.maxDailyLossPct, maxDrawdownFloor: a.maxDrawdownLimit,
|
||||
reserve: dllReserve(cash.amount * a.maxDailyLossPct, notional),
|
||||
exitFeeRate: spec.takerFee, currentStop: stopLeg?.triggerPrice ?? null, tick: spec.tick,
|
||||
});
|
||||
if (decision.action === 'none') continue;
|
||||
try {
|
||||
if (decision.action === 'close') {
|
||||
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
|
||||
this.markOwn('closed', acc.id, pos);
|
||||
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — no room for it past the reset`);
|
||||
} else {
|
||||
const triggerPrice = formatDecimal(decision.price, spec.priceDecimals);
|
||||
if (stopLeg) {
|
||||
await this.request('trade', 'PUT', '/v3/positions/stop-loss', {
|
||||
account: uuid,
|
||||
body: { positionId: pos.positionId, executionType: 'market', triggerPrice, stopLossId: stopLeg.id },
|
||||
});
|
||||
if (!Number.isFinite(next) || next - now > RESET_GUARD_MS) return;
|
||||
if (this.resetGuard.reset !== next) {
|
||||
this.resetGuard = { reset: next, stops: new Map(), closes: new Map(), noSessionLogged: false, unfittable: new Set() };
|
||||
}
|
||||
const guard = this.resetGuard;
|
||||
if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) {
|
||||
const open = [...this.positionsDetail.values()].flat().length;
|
||||
if (open > 0 && !guard.noSessionLogged) {
|
||||
guard.noSessionLogged = true;
|
||||
console.error(`${this.tag} ${open} open position(s) can't be fitted to the 8 PM ET reset — no Vest session (open the Vest tab)`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.resetGuardInFlight = true;
|
||||
try {
|
||||
const specs = await this.symbolSpecs();
|
||||
const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened');
|
||||
const open = (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null);
|
||||
const now2 = Date.now();
|
||||
for (const acc of this.accountList) {
|
||||
const a = this.capital.get(acc.id);
|
||||
const cash = this.accountCashBalances[acc.id];
|
||||
const uuid = this.uuidById.get(acc.id);
|
||||
if (!a || !cash || !uuid || !(a.maxDailyLossPct > 0)) continue;
|
||||
// The account's positions share the room: each is fitted to
|
||||
// what the ones before it leave — their loss at their stops,
|
||||
// and a reserve for the slippage of all of them together.
|
||||
// Those with a stop go first, the least at risk first, so one
|
||||
// that already fits keeps it; those with none get the rest.
|
||||
// The stop Vest shows wins (moved by hand after the guard, it
|
||||
// is fitted again); one the guard just placed counts only
|
||||
// while a read may not show it yet. A close just sent is left
|
||||
// for the same while.
|
||||
const stopOf = (p: VestPosition): number | null => {
|
||||
const placed = guard.stops.get(p.positionId);
|
||||
return p.stopLosses.find((l) => l.status === 'awaiting')?.triggerPrice
|
||||
?? (placed && now2 - placed.at < GUARD_ECHO_MS ? placed.price : null);
|
||||
};
|
||||
// What the position loses at its stop — negative when the stop
|
||||
// locks in a profit, which the others may then use.
|
||||
const lossAt = (p: VestPosition, stop: number) => (p.side === 'long' ? p.openPrice - stop : stop - p.openPrice) * p.quantity;
|
||||
const held = open
|
||||
.filter((p) => p.accountId === uuid && now2 - (guard.closes.get(p.positionId) ?? 0) >= GUARD_ECHO_MS)
|
||||
.map((p) => ({ pos: p, currentStop: stopOf(p) }))
|
||||
.sort((x, y) => (x.currentStop === null ? 1 : 0) - (y.currentStop === null ? 1 : 0)
|
||||
|| (x.currentStop !== null && y.currentStop !== null ? lossAt(x.pos, x.currentStop) - lossAt(y.pos, y.currentStop) : 0)
|
||||
|| x.pos.openedAt - y.pos.openedAt || x.pos.positionId.localeCompare(y.pos.positionId));
|
||||
let committed = 0;
|
||||
let notionalSoFar = 0;
|
||||
for (const { pos, currentStop } of held) {
|
||||
const spec = specs.get(pos.symbol);
|
||||
if (!spec) {
|
||||
// Can't be fitted without its tick, but it still holds
|
||||
// its loss against the room the others share.
|
||||
notionalSoFar += pos.quantity * pos.openPrice;
|
||||
if (currentStop !== null) committed += lossAt(pos, currentStop);
|
||||
else if (!guard.unfittable.has(pos.positionId)) {
|
||||
guard.unfittable.add(pos.positionId);
|
||||
console.error(`${this.tag} ${acc.name}: ${pos.symbol} has no stop and no symbol spec — it can't be fitted to the 8 PM ET reset; close it or add a stop on Vest`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await this.refreshMarks([pos.symbol]).catch(() => { /* the last mark stands */ });
|
||||
const mark = this.marks.get(pos.symbol)?.mark ?? NaN;
|
||||
const stopLeg = pos.stopLosses.find((l) => l.status === 'awaiting') ?? null;
|
||||
const notional = pos.quantity * (Number.isFinite(mark) ? mark : pos.openPrice);
|
||||
notionalSoFar += notional;
|
||||
const exitFee = spec.takerFee * notional;
|
||||
const decision = resetSafeStop({
|
||||
side: pos.side, quantity: pos.quantity, openPrice: pos.openPrice, mark,
|
||||
balance: cash.amount - committed, dailyLossPct: a.maxDailyLossPct, maxDrawdownFloor: a.maxDrawdownLimit,
|
||||
floorBalance: cash.amount,
|
||||
reserve: dllReserve(cash.amount * a.maxDailyLossPct, notionalSoFar),
|
||||
exitFeeRate: spec.takerFee, currentStop, tick: spec.tick,
|
||||
});
|
||||
const stopAt = decision.action === 'stop' ? decision.price : decision.action === 'none' ? currentStop : null;
|
||||
if (stopAt !== null) committed += lossAt(pos, stopAt) + exitFee;
|
||||
if (decision.action === 'none') continue;
|
||||
try {
|
||||
if (decision.action === 'close') {
|
||||
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
|
||||
guard.closes.set(pos.positionId, Date.now());
|
||||
this.markOwn('closed', acc.id, pos);
|
||||
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — no room for it past the reset`);
|
||||
} else {
|
||||
await this.submit(uuid, '/v3/positions/stop-loss', { positionId: pos.positionId, executionType: 'market', triggerPrice });
|
||||
const triggerPrice = formatDecimal(decision.price, spec.priceDecimals);
|
||||
if (stopLeg) {
|
||||
await this.request('trade', 'PUT', '/v3/positions/stop-loss', {
|
||||
account: uuid,
|
||||
body: { positionId: pos.positionId, executionType: 'market', triggerPrice, stopLossId: stopLeg.id },
|
||||
});
|
||||
} else {
|
||||
await this.submit(uuid, '/v3/positions/stop-loss', { positionId: pos.positionId, executionType: 'market', triggerPrice });
|
||||
}
|
||||
guard.stops.set(pos.positionId, { price: decision.price, at: Date.now() });
|
||||
console.warn(`${this.tag} ${acc.name}: stop on ${pos.symbol} moved to ${triggerPrice} for the 8 PM ET reset (was ${currentStop ?? 'none'})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`${this.tag} ${acc.name}: couldn't fit ${pos.symbol} to the reset: ${errorMessage(err)}`);
|
||||
// A stop on the profit side that Vest won't take: the
|
||||
// position can't carry its old one past the reset.
|
||||
if (decision.action === 'stop' && lossAt(pos, decision.price) < 0) {
|
||||
try {
|
||||
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
|
||||
guard.closes.set(pos.positionId, Date.now());
|
||||
this.markOwn('closed', acc.id, pos);
|
||||
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — its profit-side stop was refused`);
|
||||
} catch (closeErr) {
|
||||
console.error(`${this.tag} ${acc.name}: couldn't close ${pos.symbol} either: ${errorMessage(closeErr)}`);
|
||||
}
|
||||
}
|
||||
console.warn(`${this.tag} ${acc.name}: stop on ${pos.symbol} moved to ${triggerPrice} for the 8 PM ET reset (was ${stopLeg?.triggerPrice ?? 'none'})`);
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error(`${this.tag} ${acc.name}: couldn't fit ${pos.symbol} to the reset: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.resetGuardInFlight = false;
|
||||
}
|
||||
if (failed === 0) this.resetGuardedFor = next;
|
||||
}
|
||||
|
||||
private settleSuspects(): void {
|
||||
@@ -1374,6 +1485,13 @@ export class VestClient implements PlatformClient {
|
||||
const now = Date.now();
|
||||
|
||||
const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = [];
|
||||
// No new position this close to the reset: nothing picked, and the
|
||||
// last survey's candidates stay up for the dashboard.
|
||||
const toReset = nextVestResetMs(now) - now;
|
||||
if (root !== 'survey' && toReset <= RESET_ENTRY_BLOCK_MS) {
|
||||
console.log(`${this.tag} ${root} → nothing tradable — Vest's 8 PM ET reset in ${Math.ceil(toReset / 60_000)} min`);
|
||||
return null;
|
||||
}
|
||||
for (const symbol of symbols) {
|
||||
const spec = specs.get(symbol);
|
||||
if (!spec) { board.push({ symbol, score: null, note: 'unknown symbol' }); continue; }
|
||||
@@ -1552,6 +1670,10 @@ export class VestClient implements PlatformClient {
|
||||
if (!meta || meta.status !== VEST_STATUS_ACTIVE) {
|
||||
throw new OrderNotPlacedError(`account status is ${meta?.status ?? 'unknown'} — order not sent`);
|
||||
}
|
||||
const toReset = nextVestResetMs(Date.now()) - Date.now();
|
||||
if (toReset <= RESET_ENTRY_BLOCK_MS) {
|
||||
throw new OrderNotPlacedError(`Vest's 8 PM ET reset is ${Math.ceil(toReset / 60_000)} min away — no new position until it passes`);
|
||||
}
|
||||
const opposite = side === 'long' ? 'short' : 'long';
|
||||
const hedged = [...this.positionsDetail.values()].flat().some((p) => p.symbol === symbol && p.side === opposite)
|
||||
|| this.openingSides.get(symbol)?.side === opposite;
|
||||
|
||||
+13
-4
@@ -112,15 +112,20 @@ export function nextVestResetMs(now: number): number {
|
||||
* 'none' — its stop is already at least that tight;
|
||||
* 'stop' — move (or place) the stop at `price`, on the tick grid, rounded
|
||||
* toward the position's safe side;
|
||||
* 'close' — no room at all, or the mark is already past that price.
|
||||
* 'close' — the mark is already past that price (with no room left by
|
||||
* the account's other positions, it sits on the profit side),
|
||||
* or no room and no mark to tell.
|
||||
*/
|
||||
export function resetSafeStop(o: {
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number;
|
||||
mark: number;
|
||||
/** Realized balance, collateral included — what the reset captures. */
|
||||
/** Realized balance, collateral included — what the reset captures —
|
||||
* less what the account's other positions may lose at their stops. */
|
||||
balance: number;
|
||||
/** The realized balance the floor is set from; default `balance`. */
|
||||
floorBalance?: number;
|
||||
dailyLossPct: number;
|
||||
maxDrawdownFloor: number | undefined;
|
||||
reserve: number;
|
||||
@@ -129,10 +134,14 @@ export function resetSafeStop(o: {
|
||||
currentStop: number | null;
|
||||
tick: number;
|
||||
}): { action: 'none' } | { action: 'stop'; price: number } | { action: 'close' } {
|
||||
const floor = Math.max(o.balance * (1 - o.dailyLossPct), o.maxDrawdownFloor ?? -Infinity);
|
||||
const floor = Math.max((o.floorBalance ?? o.balance) * (1 - o.dailyLossPct), o.maxDrawdownFloor ?? -Infinity);
|
||||
const exitFee = o.exitFeeRate * o.quantity * (Number.isFinite(o.mark) && o.mark > 0 ? o.mark : o.openPrice);
|
||||
const allowed = o.balance - floor - o.reserve - exitFee;
|
||||
if (!(allowed > 0) || !(o.quantity > 0)) return { action: 'close' };
|
||||
if (!(o.quantity > 0)) return { action: 'close' };
|
||||
// No room to lose (the account's other positions took it): the stop must
|
||||
// lock in at least the shortfall — on the profit side of the entry,
|
||||
// which a position already that far up can still carry.
|
||||
if (!(allowed > 0) && !Number.isFinite(o.mark)) return { action: 'close' };
|
||||
const long = o.side === 'long';
|
||||
const raw = long ? o.openPrice - allowed / o.quantity : o.openPrice + allowed / o.quantity;
|
||||
const price = o.tick > 0
|
||||
|
||||
Reference in New Issue
Block a user