Vest: a stale sync keeps the bot's fill, a cut-off open is still the bot's, and the day turns with the numbers
A sync whose positions read went out before the bot's own open or close finished no longer writes that account's positions back (ownOrderSeq, as claimSeq guards balances): the record a flatten goes by stays, so a read lagging the fill still gives FLATTEN FAILED, not "nothing to flatten". An open is noted in account_meta before its POST and dropped once its outcome is known. After a restart the first sync takes the note up as an open of unknown outcome, so the position it made is marked the bot's and guardClose sees it. Such an open is matched by the position id its answer named, else only to a position with no exit legs opened between the send and a few seconds past the error (was two minutes): an outside copy-trader's open half a minute on isn't taken for the bot's, and several matches adopt none, loudly. VestClient.tradeDate follows the last sync's numbers: past 8 PM ET the day turns once a sync has worked out the fresh day's realized P&L, so the engine, reporter and dashboard never pair the new day with the ended day's numbers. A stalled sync holds the day back only while isClientReady still trades on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ab588adbac
commit
032ba1ca9c
+131
-21
@@ -113,11 +113,18 @@ 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;
|
||||
/** An open of unknown outcome is the position opened this close to its send
|
||||
* and its error — the gateway's clock and lag (unknownOpenFill). */
|
||||
const UNKNOWN_OPEN_SLACK_MS = 2 * 60_000;
|
||||
/** An open of unknown outcome is the position opened between its send and
|
||||
* its error, widened this much for the gateway's clock against this
|
||||
* machine's (unknownOpenFill). */
|
||||
const UNKNOWN_OPEN_SLACK_MS = 5_000;
|
||||
/** The longest an open's POST can be out — an account token minted first,
|
||||
* a retry (request()) — for one a restart cut off with no error to time. */
|
||||
const OPEN_IN_FLIGHT_MS = 2 * 60_000;
|
||||
/** account_meta key of the profit claims this client made, per account. */
|
||||
const CLAIMS_META = 'vest_claims';
|
||||
/** account_meta key of the open in flight, or of unknown outcome, per
|
||||
* account (savePendingOpen). */
|
||||
const PENDING_OPEN_META = 'vest_pending_open';
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
@@ -210,8 +217,21 @@ export class VestClient implements PlatformClient {
|
||||
private closeGuardInFlight = false;
|
||||
/** Opens whose outcome came back unknown, by account: the position one
|
||||
* made is the bot's once a read shows it (adoptUnknownOpen) — no exits
|
||||
* went on it, so it's the leg guardClose most needs to see. */
|
||||
private unknownOpens = new Map<number, { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number }>();
|
||||
* went on it, so it's the leg guardClose most needs to see. Kept in
|
||||
* account_meta too, from before the send (savePendingOpen), so a
|
||||
* restart takes them up again (restorePendingOpen). */
|
||||
private unknownOpens = new Map<number, {
|
||||
accountId: string; symbol: string; side: 'long' | 'short'; sentAt: number; from: number; until: number; positionId?: string;
|
||||
}>();
|
||||
/** Accounts whose open from before a restart has been looked for. */
|
||||
private pendingOpenRestored = new Set<number>();
|
||||
/** The sync generation current when each account's own open or close
|
||||
* last finished: a sync of that generation or older may have read
|
||||
* positions before the fill. */
|
||||
private ownOrderSeq = new Map<number, number>();
|
||||
/** When the last sync worked out the accounts' numbers (realizedSince's
|
||||
* `now`): the Vest day they hold (tradeDate). */
|
||||
private numbersAt = 0;
|
||||
private capital = new Map<number, VestCapitalAccount>();
|
||||
private positionsDetail = new Map<number, VestPosition[]>();
|
||||
private openOrders = new Map<number, VestOpenOrder[]>();
|
||||
@@ -584,6 +604,7 @@ export class VestClient implements PlatformClient {
|
||||
}
|
||||
this.uuidById.set(id, a.id);
|
||||
this.capital.set(id, a);
|
||||
this.restorePendingOpen(id, vestAccountName(a));
|
||||
if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) {
|
||||
this.unknownStatusLogged.add(`${a.id}:${a.status}`);
|
||||
console.warn(`${this.tag} ${vestAccountName(a)} has status ${a.status} — not trading it until that status is known`);
|
||||
@@ -592,9 +613,16 @@ export class VestClient implements PlatformClient {
|
||||
list.push(acc);
|
||||
|
||||
const mine = positions.filter((p) => p.accountId === a.id);
|
||||
this.positionsDetail.set(id, mine);
|
||||
// An open or close of this client's that finished while this
|
||||
// sync was out: what it read may predate the fill, and writing
|
||||
// it back would lose the position the order saw (or bring back
|
||||
// the one it closed) — the record a flatten goes by
|
||||
// (orderEffect). The order's own stands until a sync that
|
||||
// started after it.
|
||||
const ownSince = (this.ownOrderSeq.get(id) ?? -1) >= gen;
|
||||
if (!ownSince) this.positionsDetail.set(id, mine);
|
||||
this.openOrders.set(id, orders.filter((o) => o.accountId === a.id));
|
||||
this.applyPosition(acc, mine);
|
||||
if (!ownSince) this.applyPosition(acc, mine);
|
||||
this.adoptUnknownOpen(id, mine, startedAt);
|
||||
|
||||
const amount = free.get(a.id);
|
||||
@@ -624,6 +652,7 @@ export class VestClient implements PlatformClient {
|
||||
this.lastFetchRaw[`floors:${acc.name}`] = `dd ${a.maxDrawdownLimit ?? '?'}, daily ${this.dailyFloorOf(a, balance, now) ?? 'none'}`
|
||||
+ `${a.targetEquity ? `, target ${a.targetEquity}` : ''}`;
|
||||
}
|
||||
this.numbersAt = now;
|
||||
const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id));
|
||||
this.accountList = list;
|
||||
if (shrank) void this.closeOutDropped();
|
||||
@@ -735,10 +764,17 @@ export class VestClient implements PlatformClient {
|
||||
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
|
||||
}
|
||||
|
||||
/** See PlatformClient: Vest's own day (vestDayOf, 20:00 ET to 20:00 ET),
|
||||
* which its daily rows, realized P&L (realizedSince) and floors follow. */
|
||||
/**
|
||||
* See PlatformClient: Vest's own day (vestDayOf, 20:00 ET to 20:00 ET),
|
||||
* which its daily rows, realized P&L (realizedSince) and floors follow —
|
||||
* as of the last sync's numbers. Past 20:00 ET they still hold the day
|
||||
* that ended until a sync works them out again, and the fresh day would
|
||||
* count it as a prior day and as today's P&L both. A sync that stalls
|
||||
* holds the day back no longer than the scheduler trades on its numbers
|
||||
* (isClientReady). Before any sync, the day now.
|
||||
*/
|
||||
public tradeDate(now: number): string {
|
||||
return vestDayOf(now);
|
||||
return vestDayOf(this.numbersAt > 0 ? Math.min(now, this.numbersAt) : now);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1192,21 +1228,81 @@ export class VestClient implements PlatformClient {
|
||||
|
||||
/** An open of unknown outcome that a read (started at `readAt`) shows
|
||||
* filled after all: the position is the bot's (markOwn). One the read
|
||||
* doesn't show by then never filled — a market open is IOC. */
|
||||
* doesn't show by then never filled — a market open is IOC. Several
|
||||
* that could be it: none is marked, and it's said loudly. */
|
||||
private adoptUnknownOpen(accountId: number, positions: VestPosition[], readAt: number): void {
|
||||
const sent = this.unknownOpens.get(accountId);
|
||||
if (!sent) return;
|
||||
const pos = unknownOpenFill(sent, positions);
|
||||
if (pos) {
|
||||
this.unknownOpens.delete(accountId);
|
||||
const found = unknownOpenFill(sent, positions);
|
||||
const name = this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId);
|
||||
if (found.length === 1) {
|
||||
const pos = found[0];
|
||||
this.forgetUnknownOpen(accountId, sent.sentAt);
|
||||
this.markOwn('opened', accountId, pos);
|
||||
const name = this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId);
|
||||
console.warn(`${this.tag} ${name}: the open of unknown outcome filled after all — ${pos.side} ${pos.quantity} ${pos.symbol} (${pos.positionId}) marked as the bot's`);
|
||||
} else if (found.length > 1) {
|
||||
this.forgetUnknownOpen(accountId, sent.sentAt);
|
||||
console.error(`${this.tag} ${name}: the ${sent.side} ${sent.symbol} open of unknown outcome could be any of ${found.length} positions opened with it`
|
||||
+ ` (${found.map((p) => p.positionId).join(', ')}) — none marked as the bot's, so none is closed before a market closure; check them on Vest`);
|
||||
} else if (readAt > sent.until) {
|
||||
this.unknownOpens.delete(accountId);
|
||||
this.forgetUnknownOpen(accountId, sent.sentAt);
|
||||
}
|
||||
}
|
||||
|
||||
private forgetUnknownOpen(accountId: number, sentAt: number): void {
|
||||
this.unknownOpens.delete(accountId);
|
||||
this.dropPendingOpen(accountId, sentAt);
|
||||
}
|
||||
|
||||
/** Note an open before it goes out, and what is learned of it (its
|
||||
* position id, an unknown outcome's window): a restart before its
|
||||
* outcome is known still finds the position it made. */
|
||||
private savePendingOpen(accountId: number, open: { accountId: string; symbol: string; side: 'long' | 'short'; sentAt: number; positionId?: string; until?: number }): void {
|
||||
// This run's own record from here: nothing from before to take up.
|
||||
this.pendingOpenRestored.add(accountId);
|
||||
try {
|
||||
saveAccountMeta(accountId, PENDING_OPEN_META, JSON.stringify(open));
|
||||
} catch (err) {
|
||||
console.warn(`${this.tag} couldn't note the open in flight: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The open sent at `sentAt` has an outcome: its note goes — not a later
|
||||
* open's. */
|
||||
private dropPendingOpen(accountId: number, sentAt: number): void {
|
||||
try {
|
||||
const open = JSON.parse(loadAccountMeta(accountId, PENDING_OPEN_META) ?? 'null');
|
||||
if (open?.sentAt === sentAt) saveAccountMeta(accountId, PENDING_OPEN_META, 'null');
|
||||
} catch (err) {
|
||||
console.warn(`${this.tag} couldn't drop the note of an open: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** An open still out when the app last stopped (a restart mid-entry):
|
||||
* taken up as one of unknown outcome — its window the send to the most
|
||||
* its POST could be out, when no error timed it — once per account per
|
||||
* run. */
|
||||
private restorePendingOpen(accountId: number, name: string): void {
|
||||
if (this.pendingOpenRestored.has(accountId)) return;
|
||||
this.pendingOpenRestored.add(accountId);
|
||||
let open: Record<string, unknown> | null;
|
||||
try {
|
||||
open = JSON.parse(loadAccountMeta(accountId, PENDING_OPEN_META) ?? 'null');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!open || typeof open.accountId !== 'string' || typeof open.symbol !== 'string'
|
||||
|| (open.side !== 'long' && open.side !== 'short') || typeof open.sentAt !== 'number' || !Number.isFinite(open.sentAt)) return;
|
||||
const sentAt = open.sentAt;
|
||||
this.unknownOpens.set(accountId, {
|
||||
accountId: open.accountId, symbol: open.symbol, side: open.side, sentAt,
|
||||
from: sentAt - UNKNOWN_OPEN_SLACK_MS,
|
||||
until: typeof open.until === 'number' ? open.until : sentAt + OPEN_IN_FLIGHT_MS + UNKNOWN_OPEN_SLACK_MS,
|
||||
...(typeof open.positionId === 'string' ? { positionId: open.positionId } : {}),
|
||||
});
|
||||
console.warn(`${this.tag} ${name}: a ${open.side} ${open.symbol} open was out when the app last stopped — a position it made is marked the bot's once a read shows it`);
|
||||
}
|
||||
|
||||
private settleSuspects(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, since] of this.suspectAccounts) {
|
||||
@@ -1895,9 +1991,14 @@ export class VestClient implements PlatformClient {
|
||||
this.pendingFills++;
|
||||
const orderNumber = this.nextOrderNumber++;
|
||||
const sentAt = Date.now();
|
||||
// An open is noted before it goes out: a restart before its outcome
|
||||
// is known still finds the position it made (restorePendingOpen).
|
||||
if (opening) this.savePendingOpen(accountId, { accountId: uuid, symbol, side, sentAt });
|
||||
let placed: { orderId?: string; positionId?: string } | undefined;
|
||||
try {
|
||||
const placed = await this.submit(uuid, path, body);
|
||||
placed = await this.submit(uuid, path, body);
|
||||
console.log(`${this.tag} order ${placed.orderId ?? '?'}: ${side === 'long' ? 'BUY' : 'SELL'} ${unitsOrdered} ${symbol} ${orderType}${existing ? ' CLOSE' : ' OPEN'}`);
|
||||
if (opening && placed.positionId) this.savePendingOpen(accountId, { accountId: uuid, symbol, side, sentAt, positionId: placed.positionId });
|
||||
const fill = await this.waitForFill(uuid, placed, symbol, side, existing ?? null, unitsOrdered, sentAt);
|
||||
if (opening) {
|
||||
const pos = fill.position ?? {
|
||||
@@ -1908,10 +2009,13 @@ export class VestClient implements PlatformClient {
|
||||
this.positions[accountId] = {
|
||||
contractId: symbol, netPos: side === 'long' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(),
|
||||
};
|
||||
this.ownOrderSeq.set(accountId, this.syncGeneration);
|
||||
this.markOwn('opened', accountId, pos);
|
||||
this.dropPendingOpen(accountId, sentAt);
|
||||
} else if (unitsOrdered >= existing.quantity - step / 2) {
|
||||
this.positionsDetail.set(accountId, []);
|
||||
delete this.positions[accountId];
|
||||
this.ownOrderSeq.set(accountId, this.syncGeneration);
|
||||
this.markOwn('closed', accountId, existing);
|
||||
}
|
||||
const result = {
|
||||
@@ -1922,11 +2026,17 @@ export class VestClient implements PlatformClient {
|
||||
return result;
|
||||
} catch (err) {
|
||||
// An open that may have gone in: no exits will go on it, so the
|
||||
// position it made, once a read shows it, is marked the bot's.
|
||||
// position it made, once a read shows it, is marked the bot's —
|
||||
// the one its answer named, else one opened by now (IOC).
|
||||
if (opening && !isOrderNotPlaced(err)) {
|
||||
this.unknownOpens.set(accountId, {
|
||||
accountId: uuid, symbol, side, from: sentAt - UNKNOWN_OPEN_SLACK_MS, until: Date.now() + UNKNOWN_OPEN_SLACK_MS,
|
||||
});
|
||||
const open = {
|
||||
accountId: uuid, symbol, side, sentAt, until: Date.now() + UNKNOWN_OPEN_SLACK_MS,
|
||||
...(placed?.positionId ? { positionId: placed.positionId } : {}),
|
||||
};
|
||||
this.unknownOpens.set(accountId, { ...open, from: sentAt - UNKNOWN_OPEN_SLACK_MS });
|
||||
this.savePendingOpen(accountId, open);
|
||||
} else if (opening) {
|
||||
this.dropPendingOpen(accountId, sentAt);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user