Unknown entries: judge only on a fresh good sync; all live orders count as working
Seventh review round (57835c0):
- An entry of unknown outcome is judged — filled after all, or never
filled — only on a good sync that started after its settle window and
with no order still working. A partial pass (position read failed or
predated the fill, trades read after it) can no longer release it as
"filled and flat" into a second entry.
- Tradovate counts every order not Filled/Canceled/Rejected/Expired/
Completed as working (PendingCancel, Unknown included).
- TopstepX: an account whose order ended with its outcome unknown gets its
open orders checked (Order/searchOpen) on each sync until none show, and
counts as holding a working order until then.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
57835c01f0
commit
809f337055
+7
-6
@@ -158,19 +158,20 @@ function unknownEntryHolds(client: any, accountId: number, tradeDate: string): b
|
|||||||
const outcome = entryOutcomeToday(accountId, tradeDate);
|
const outcome = entryOutcomeToday(accountId, tradeDate);
|
||||||
if (!outcome || outcome.state !== 'unknown') return false;
|
if (!outcome || outcome.state !== 'unknown') return false;
|
||||||
const cash = client.accountCashBalances[accountId];
|
const cash = client.accountCashBalances[accountId];
|
||||||
|
// Nothing is judged before a good sync that started after the window:
|
||||||
|
// until then a position read can predate the fill (or have failed) while
|
||||||
|
// the trades read after it already show the P&L it booked.
|
||||||
|
const syncedFrom = client.lastSyncStartedAt ?? client.lastSyncAt;
|
||||||
|
if (syncedFrom < (outcome.unknownAt ?? outcome.at) + UNKNOWN_ENTRY_SETTLE_MS || cash === undefined) return true;
|
||||||
|
if (client.hasWorkingOrders?.(accountId)) return true;
|
||||||
// The broker shows the fill (the position, or the P&L it booked): it
|
// The broker shows the fill (the position, or the P&L it booked): it
|
||||||
// filled after all. From here it's an ordinary filled entry — the
|
// filled after all. From here it's an ordinary filled entry — the
|
||||||
// position gate holds it while open, one-and-done is done for the day.
|
// position gate holds it while open, one-and-done is done for the day.
|
||||||
if (client.positions[accountId] || (cash && Math.abs(cash.realizedPnL - outcome.realizedBefore) >= 0.005)) {
|
if (client.positions[accountId] || Math.abs(cash.realizedPnL - outcome.realizedBefore) >= 0.005) {
|
||||||
outcome.state = 'filled';
|
outcome.state = 'filled';
|
||||||
console.log(`[auto-trade] account ${accountId}: the entry of unknown outcome filled after all`);
|
console.log(`[auto-trade] account ${accountId}: the entry of unknown outcome filled after all`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const syncedFrom = client.lastSyncStartedAt ?? client.lastSyncAt;
|
|
||||||
const settled = syncedFrom >= (outcome.unknownAt ?? outcome.at) + UNKNOWN_ENTRY_SETTLE_MS
|
|
||||||
&& cash !== undefined
|
|
||||||
&& !client.hasWorkingOrders?.(accountId);
|
|
||||||
if (!settled) return true;
|
|
||||||
runtime().entryOutcomes?.delete(accountId);
|
runtime().entryOutcomes?.delete(accountId);
|
||||||
unrecordLeg(accountId, tradeDate, outcome.legAmount);
|
unrecordLeg(accountId, tradeDate, outcome.legAmount);
|
||||||
console.log(`[auto-trade] account ${accountId}: the entry of unknown outcome never filled — free to trade again`);
|
console.log(`[auto-trade] account ${accountId}: the entry of unknown outcome never filled — free to trade again`);
|
||||||
|
|||||||
+42
-1
@@ -244,6 +244,10 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
private reconcilePendingIds = new Set<number>();
|
private reconcilePendingIds = new Set<number>();
|
||||||
/** See PlatformClient: when the last good pass started reading. */
|
/** See PlatformClient: when the last good pass started reading. */
|
||||||
public lastSyncStartedAt = 0;
|
public lastSyncStartedAt = 0;
|
||||||
|
/** Accounts whose last order ended with its outcome unknown: each sync
|
||||||
|
* checks their open orders (Order/searchOpen) until it shows none. The
|
||||||
|
* value is the open count last seen (undefined until checked). */
|
||||||
|
private suspectOrderAccounts = new Map<number, number | undefined>();
|
||||||
/** Settles when the sync in flight ends (requestSync hands it to a caller
|
/** Settles when the sync in flight ends (requestSync hands it to a caller
|
||||||
* that arrives mid-pass). */
|
* that arrives mid-pass). */
|
||||||
private syncRunning: Promise<void> = Promise.resolve();
|
private syncRunning: Promise<void> = Promise.resolve();
|
||||||
@@ -864,6 +868,19 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
}
|
}
|
||||||
if (superseded()) return;
|
if (superseded()) return;
|
||||||
|
|
||||||
|
if (this.suspectOrderAccounts.has(acc.id)) {
|
||||||
|
try {
|
||||||
|
const open = await this.post<{ orders?: unknown[] }>('/api/Order/searchOpen', { accountId: acc.id });
|
||||||
|
if (superseded()) return;
|
||||||
|
const count = (open.orders ?? []).length;
|
||||||
|
if (count === 0) this.suspectOrderAccounts.delete(acc.id);
|
||||||
|
else this.suspectOrderAccounts.set(acc.id, count);
|
||||||
|
} catch (err) {
|
||||||
|
accountFresh = false;
|
||||||
|
console.error(`[topstepx] open-order check failed for ${acc.name}: ${errorMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tradeRes = await this.post<{ trades: TsxTrade[] }>(
|
const tradeRes = await this.post<{ trades: TsxTrade[] }>(
|
||||||
'/api/Trade/search', { accountId: acc.id, startTimestamp: start, endTimestamp: end });
|
'/api/Trade/search', { accountId: acc.id, startTimestamp: start, endTimestamp: end });
|
||||||
@@ -1460,8 +1477,16 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** See PlatformClient: an account whose last order's outcome is unknown
|
||||||
|
* counts as holding a working order until a sync's Order/searchOpen shows
|
||||||
|
* none. */
|
||||||
|
public hasWorkingOrders(accountId: number): boolean {
|
||||||
|
return this.suspectOrderAccounts.has(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Place an order and wait for its fill (trade event, with a REST poll as
|
/** Place an order and wait for its fill (trade event, with a REST poll as
|
||||||
* backstop). Resolves with an object exposing the fill `price`. */
|
* backstop). Resolves with an object exposing the fill `price`. An order
|
||||||
|
* whose outcome stays unknown marks the account for open-order checks. */
|
||||||
public async sendOrder(
|
public async sendOrder(
|
||||||
accountId: number,
|
accountId: number,
|
||||||
contractSymbol: string,
|
contractSymbol: string,
|
||||||
@@ -1469,6 +1494,22 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
action: 'Buy' | 'Sell',
|
action: 'Buy' | 'Sell',
|
||||||
orderType: 'Market' | 'Limit',
|
orderType: 'Market' | 'Limit',
|
||||||
price?: number
|
price?: number
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price);
|
||||||
|
} catch (err) {
|
||||||
|
if (!isOrderNotPlaced(err)) this.suspectOrderAccounts.set(accountId, undefined);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendOrderAndWait(
|
||||||
|
accountId: number,
|
||||||
|
contractSymbol: string,
|
||||||
|
quantity: number,
|
||||||
|
action: 'Buy' | 'Sell',
|
||||||
|
orderType: 'Market' | 'Limit',
|
||||||
|
price?: number
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const contract = await this.contractForOrder(contractSymbol);
|
const contract = await this.contractForOrder(contractSymbol);
|
||||||
const orderId = await this.placeOrder(
|
const orderId = await this.placeOrder(
|
||||||
|
|||||||
@@ -103,8 +103,9 @@ const TOKEN_REUSE_MIN_MS = 5 * 60_000;
|
|||||||
/** An order ack later than this is treated as lost (ambiguous — it may have been placed). */
|
/** An order ack later than this is treated as lost (ambiguous — it may have been placed). */
|
||||||
const ACK_TIMEOUT_MS = 15_000;
|
const ACK_TIMEOUT_MS = 15_000;
|
||||||
const FILL_TIMEOUT_MS = 30_000;
|
const FILL_TIMEOUT_MS = 30_000;
|
||||||
/** Order states that can still fill (Tradovate ordStatus). */
|
/** Order states that can no longer fill (Tradovate ordStatus); any other —
|
||||||
const WORKING_ORDER_STATUSES = new Set(['Working', 'PendingNew', 'PendingReplace', 'Suspended']);
|
* Working, PendingNew/Replace/Cancel, Suspended, Unknown — still might. */
|
||||||
|
const FINISHED_ORDER_STATUSES = new Set(['Filled', 'Canceled', 'Rejected', 'Expired', 'Completed']);
|
||||||
/** How far the start-of-day balance may move without a fill before the
|
/** How far the start-of-day balance may move without a fill before the
|
||||||
* account's history is treated as overtaken (balanceDrifted). Fills move
|
* account's history is treated as overtaken (balanceDrifted). Fills move
|
||||||
* amount and realizedPnL together, so this only has to absorb rounding;
|
* amount and realizedPnL together, so this only has to absorb rounding;
|
||||||
@@ -1017,7 +1018,7 @@ export class TradovateClient {
|
|||||||
|
|
||||||
const working: { [accountId: number]: number } = {};
|
const working: { [accountId: number]: number } = {};
|
||||||
for (const order of (response.orders ?? []) as { accountId?: number; ordStatus?: string }[]) {
|
for (const order of (response.orders ?? []) as { accountId?: number; ordStatus?: string }[]) {
|
||||||
if (order.accountId && WORKING_ORDER_STATUSES.has(order.ordStatus ?? '')) {
|
if (order.accountId && !FINISHED_ORDER_STATUSES.has(order.ordStatus ?? '')) {
|
||||||
working[order.accountId] = (working[order.accountId] ?? 0) + 1;
|
working[order.accountId] = (working[order.accountId] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user