Trade only on loaded history; catch early TopstepX fills; fix SI/M2K point values

- Both clients track which accounts this process has loaded history for
  (historyReady). An account restarted, rebuilt, wiped or newly listed is
  ineligible until its fetch lands, instead of trading as a fresh stage-1
  first day; a failed fetch with no cache no longer reads as empty history.
  Waiting accounts get a targeted fetch on the next sync.
- Tradovate holds an account whose start-of-day balance moves without a fill
  (a payout between hourly Cash History runs) until a Cash History books it,
  so a payout-pending account isn't re-traded on its ended cycle.
- Wipe routes forget history through the client and count accounts whose
  history didn't come back as failures.
- TopstepX sendOrder picks up a fill that arrived before the place response,
  and its REST backstop uses the documented Trade/search and Order/search.
- POINT_VALUES: SI 5000 (was 50), M2K 5 (was 10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-24 17:04:09 -05:00
co-authored by Claude Opus 5.5
parent 61384c9fa6
commit 77d8c2ecdb
7 changed files with 292 additions and 46 deletions
+6 -10
View File
@@ -23,23 +23,19 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st
wipeAccountData(accountId);
// Clear the in-memory view too — a failed refetch must leave the
// account visibly empty, not showing the data that was just wiped.
delete client.dailyPnL[accountId];
delete client.fullDailyPnL[accountId];
delete client.daysTraded[accountId];
delete client.priorProfit[accountId];
delete client.lastFundDates[accountId];
delete client.fundTransactions[accountId];
delete client.lastFetchErrors[account.name];
delete client.lastFetchRaw[account.name];
// account visibly empty, not showing the data that was just wiped —
// and out of trading until a fetch rebuilds it.
client.forgetHistory(accountId, account.name);
await client.fetchDaysTraded([accountId]);
const loaded = client.historyReady(accountId);
return NextResponse.json({
ok: true,
entries: (client.dailyPnL[accountId] ?? []).length,
daysTraded: client.daysTraded[accountId] ?? 0,
fetchError: client.lastFetchErrors[account.name] ?? null,
fetchError: client.lastFetchErrors[account.name]
?? (loaded ? null : 'History did not reload — the account stays out of trading until it does'),
});
}
+8 -10
View File
@@ -19,20 +19,18 @@ export async function POST() {
let failedAccounts = 0;
await Promise.all(clients.map(async (client) => {
// Forgotten, the accounts read unloaded and stay out of trading
// until the refetch below (or, if it fails, a later one) lands.
const ids = client.accountList.map((account) => account.id);
for (const account of client.accountList) {
wipeAccountData(account.id);
delete client.dailyPnL[account.id];
delete client.fullDailyPnL[account.id];
delete client.daysTraded[account.id];
delete client.priorProfit[account.id];
delete client.lastFundDates[account.id];
delete client.fundTransactions[account.id];
delete client.lastFetchErrors[account.name];
delete client.lastFetchRaw[account.name];
client.forgetHistory(account.id, account.name);
wiped++;
}
const result = await client.fetchDaysTraded();
failedAccounts += result.failedAccounts;
await client.fetchDaysTraded();
// Counted from the result, not the fetch's own tally: a client with
// no session fetches nothing and reports no failures.
failedAccounts += ids.filter((id) => !client.historyReady(id)).length;
}));
return NextResponse.json({ ok: true, wiped, failedAccounts });
+11
View File
@@ -355,6 +355,10 @@ async function legInstrument(
// ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ──
/** The ineligible reason for an account whose history isn't loaded yet — still
* in play for stop-after-all, since it trades once the fetch lands. */
const HISTORY_PENDING = 'history not loaded yet — waiting for the fetch';
interface AccountEvaluation {
eligible: boolean;
reason: string;
@@ -400,6 +404,10 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
// Another batch is placing this account's order right now; its position
// won't show until the fill comes back, so the check above can't see it.
if (runtime().accountsInFlight.has(acc.id)) return { ...base, eligible: false, reason: 'order in flight' };
// History this client hasn't loaded (a restart, a rebuild, a wipe, a new
// account) or that a payout has overtaken would plan the account as a
// fresh stage-1 first day: an oversized leg, or a re-opened finished cycle.
if (!client.historyReady(acc.id)) return { ...base, eligible: false, reason: HISTORY_PENDING };
// Obfuscation stops: a stop-out shows as realized P&L falling short of the
// legs fired today — the day is booked; the make-back ride is tomorrow.
@@ -1166,6 +1174,9 @@ function hasRemainingConfiguredAccounts(): boolean {
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (ev?.eligible) return true;
// Waiting on its history, not done: it may still owe today's trade.
// Only while the session is open, like the unready firms above.
if (ev?.reason === HISTORY_PENDING && !isInNoTradeWindow()) return true;
}
}
+12
View File
@@ -51,6 +51,18 @@ export interface PlatformClient {
products: { id: number; name: string }[];
fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }>;
/**
* Whether the account's history can be planned on. False until this
* client has loaded it (right after a restart, a client rebuild, a wipe,
* or for an account that just appeared), and on Tradovate while a balance
* change the history can't explain — a payout between reports — waits
* for a Cash History. The missing rows and withdrawals would otherwise
* read as a fresh stage-1 cycle, so such an account must not trade.
*/
historyReady(accountId: number): boolean;
/** Drop the account's history from memory (the wipe routes), so it reads
* unloaded until the next fetch rebuilds it. */
forgetHistory(accountId: number, accountName: string): void;
findFrontMonthContract(productName: string): Promise<PlatformContract | null>;
fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>;
+118 -15
View File
@@ -137,6 +137,17 @@ interface TsxTrade {
const ORDER_FILLED = 2;
const ORDER_GONE = new Set([3, 4, 5]);
/** An order's first fill price (by trade id), or null when it has none —
* the price the hub's first trade event carries, so a whole tick. */
function firstFillPrice(trades: TsxTrade[]): number | null {
let first: TsxTrade | null = null;
for (const t of trades) {
if (t.voided || typeof t.price !== 'number') continue;
if (!first || t.id < first.id) first = t;
}
return first ? first.price : null;
}
const SIDE_BUY = 0;
const SIDE_SELL = 1;
const TYPE_LIMIT = 1;
@@ -220,6 +231,11 @@ export class TopstepXClient implements PlatformClient {
/** The first full daily P&L pass has landed (cache fallbacks included).
* No sync counts as good before it — see requestSync. */
private historyLoaded = false;
/** Accounts whose history this client has loaded (a trade search came
* back, or failed with a DB cache to fall back on). historyLoaded above
* is the firm's first pass; this is per account, so one that appears
* later, or is wiped, stays out of trading until its own fetch lands. */
private historyLoadedIds = new Set<number>();
/** Trades seen for the current CT trade date, per account — realizedPnL is
* recomputed from these on every REST sync and every live trade event. */
@@ -1086,15 +1102,22 @@ export class TopstepXClient implements PlatformClient {
!retryPending &&
this.lastDaysFetchSucceededAt > 0 &&
now - this.lastDaysFetchSucceededAt >= 60 * 60 * 1_000;
// Accounts held out of trading for want of history (new since the
// last pass, or wiped) get their own fetch now rather than wait out
// the hour. A failure falls back to the 5-minute retry.
const waiting = !initialFetchDue && !retryPending
? this.accountList.filter((a) => !this.historyLoadedIds.has(a.id)).map((a) => a.id)
: [];
const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0;
if (!initialFetchDue && !retryDue && !hourlyRefreshDue) return;
if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return;
this.daysFetchInFlight = true;
this.daysFetchInFlightSince = now;
this.lastDaysFetchStartedAt = now;
if (retryDue) this.nextDaysFetchRetryAt = 0;
this.fetchDaysTraded()
this.fetchDaysTraded(targeted ? waiting : undefined)
.then(({ failedAccounts }) => {
if (!this.historyLoaded && !this.intentionalDisconnect) {
// Readiness waited on this; sync again now rather than
@@ -1107,7 +1130,8 @@ export class TopstepXClient implements PlatformClient {
console.warn(`[topstepx] ${failedAccounts} account(s) failed daily P&L fetch — retrying in 5 minutes`);
return;
}
this.lastDaysFetchSucceededAt = Date.now();
// A partial pass doesn't restart the hourly clock.
if (!targeted) this.lastDaysFetchSucceededAt = Date.now();
})
.catch((err) => {
this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000;
@@ -1192,6 +1216,7 @@ export class TopstepXClient implements PlatformClient {
this.applyCycleSlice(account.id);
saveDailyPnL(account.id, account.name, fresh);
delete this.lastFetchErrors[account.name];
this.historyLoadedIds.add(account.id);
this.updateMllFloor(account);
} catch (err) {
const msg = errorMessage(err);
@@ -1202,8 +1227,11 @@ export class TopstepXClient implements PlatformClient {
this.dailyFees[account.id] = new Map(cached.map((e) => [e.date, e.fees ?? 0]));
this.applyCycleSlice(account.id);
this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`;
this.historyLoadedIds.add(account.id);
} else {
this.dailyPnL[account.id] ??= [];
// No history at all is not an empty one: the account
// stays out of trading until a fetch lands, instead of
// trading as a fresh stage-1 cycle.
this.daysTraded[account.id] ??= 0;
this.lastFetchErrors[account.name] = msg;
}
@@ -1213,6 +1241,30 @@ export class TopstepXClient implements PlatformClient {
return { failedAccounts };
}
/** Whether this account's history can be planned on — see PlatformClient.
* (Fund transactions come from reconcileFunds within a sync, so no
* balance-drift hold is needed here.) */
public historyReady(accountId: number): boolean {
return this.historyLoadedIds.has(accountId)
&& this.dailyPnL[accountId] !== undefined
&& this.fundTransactions[accountId] !== undefined;
}
/** Drop the account's history from memory (a wipe): it reads unloaded, and
* stays out of trading, until the next fetch rebuilds it. */
public forgetHistory(accountId: number, accountName: string): void {
this.historyLoadedIds.delete(accountId);
delete this.dailyPnL[accountId];
delete this.fullDailyPnL[accountId];
delete this.daysTraded[accountId];
delete this.priorProfit[accountId];
delete this.lastFundDates[accountId];
delete this.fundTransactions[accountId];
delete this.dailyFees[accountId];
delete this.lastFetchErrors[accountName];
delete this.lastFetchRaw[accountName];
}
// ── contracts ────────────────────────────────────────────────────────────
public async findFrontMonthContract(productName: string): Promise<PlatformContract | null> {
@@ -1360,10 +1412,27 @@ export class TopstepXClient implements PlatformClient {
);
console.log(`[topstepx] order ${orderId}: ${action} ${quantity}x${contractSymbol} ${orderType}${price ? ` @ ${price}` : ''}`);
// A market fill can reach the hub before /api/Order/place answers,
// when no waiter existed yet to hear it. The handler still recorded
// it, so look there first. No await between here and the waiter
// registered below, so nothing can slip in between.
const early = this.bufferedFillPrice(accountId, orderId);
if (early !== null) {
console.log(`[topstepx] order ${orderId} filled @ ${early} (before the place response)`);
return { orderId, price: early, accountId, contractId: contract.id };
}
const since = new Date(Date.now() - 60_000).toISOString();
return new Promise((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
const late = this.bufferedFillPrice(accountId, orderId);
if (late !== null) {
this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId);
settle(late);
return;
}
settled = true;
clearInterval(poll);
this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId);
@@ -1389,29 +1458,63 @@ export class TopstepXClient implements PlatformClient {
this.fillWaiters.push({ orderId, callback: (trade) => settle(trade.price) });
// REST backstop for when the hub is down or the event is missed.
// REST backstop for when the hub is down or the event is missed:
// the order's trades (the same Trade/search fetchDaysTraded
// reads), and every fifth poll whether the order died unfilled.
// Both are documented Gateway endpoints; Order has no searchById.
let polls = 0;
const poll = setInterval(() => {
void this.post<{ order?: { status: number; filledPrice?: number } }>(
'/api/Order/searchById', { accountId, orderId })
.then((res) => {
const order = res.order;
if (!order) return;
if (order.status === ORDER_FILLED && typeof order.filledPrice === 'number') {
polls++;
void this.searchOrderFillPrice(accountId, orderId, since)
.then((fillPrice) => {
if (fillPrice === null) return;
this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId);
settle(order.filledPrice);
} else if (ORDER_GONE.has(order.status) && !settled) {
settle(fillPrice);
})
.catch(() => { /* transient — the next poll tries again */ });
if (polls % 5 !== 0) return;
void this.post<{ orders?: { id: number; status: number }[] }>(
'/api/Order/search', { accountId, startTimestamp: since })
.then(async (res) => {
const order = res.orders?.find((o) => o.id === orderId);
if (!order || !ORDER_GONE.has(order.status) || settled) return;
// Cancelled after a partial fill still left a position.
const fillPrice = this.bufferedFillPrice(accountId, orderId)
?? await this.searchOrderFillPrice(accountId, orderId, since);
this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId);
if (fillPrice !== null) {
settle(fillPrice);
return;
}
if (settled) return;
settled = true;
clearTimeout(timeout);
clearInterval(poll);
this.fillWaiters = this.fillWaiters.filter((w) => w.orderId !== orderId);
reject(new Error(`Order ${orderId} ended without fill (status=${order.status})`));
}
})
.catch(() => { /* transient — the next poll tries again */ });
}, 2_000);
});
}
/** The price of an order's first fill already heard over the hub — the
* price the fill waiter would have resolved with — or null. */
private bufferedFillPrice(accountId: number, orderId: number): number | null {
const fills: TsxTrade[] = [...(this.todayTrades[accountId]?.values() ?? [])];
for (const event of this.recentEntityEvents) {
if (event.entityType === 'trade') fills.push(event.entity as TsxTrade);
}
return firstFillPrice(fills.filter((t) => t?.accountId === accountId && t.orderId === orderId));
}
/** The same, from the Gateway's trade history (REST). */
private async searchOrderFillPrice(accountId: number, orderId: number, since: string): Promise<number | null> {
const res = await this.post<{ trades?: TsxTrade[] }>('/api/Trade/search', {
accountId, startTimestamp: since, endTimestamp: new Date(Date.now() + 60_000).toISOString(),
});
return firstFillPrice((res.trades ?? []).filter((t) => t.orderId === orderId));
}
/** Place an order and resolve on placement (does not wait for fill). */
public async placeOrderNoWait(
accountId: number,
+2 -2
View File
@@ -818,8 +818,8 @@ export function endedCycleCompletion(opts: {
/** Dollar-per-point value for common futures products. */
export const POINT_VALUES: { [symbol: string]: number } = {
NQ: 20, MNQ: 2, ES: 50, MES: 5,
YM: 5, MYM: 0.5, RTY: 50, M2K: 10,
GC: 100, MGC: 10, SI: 50, CL: 1000,
YM: 5, MYM: 0.5, RTY: 50, M2K: 5,
GC: 100, MGC: 10, SI: 5000, CL: 1000,
MCL: 100, NG: 10000, ZB: 1000, ZN: 1000,
ZF: 1000, '6E': 125000, '6J': 12500000, '6B': 62500,
};
+131 -5
View File
@@ -103,6 +103,11 @@ const TOKEN_REUSE_MIN_MS = 5 * 60_000;
/** An order ack later than this is treated as lost (ambiguous — it may have been placed). */
const ACK_TIMEOUT_MS = 15_000;
const FILL_TIMEOUT_MS = 30_000;
/** How far the start-of-day balance may move without a fill before the
* account's history is treated as overtaken (balanceDrifted). Fills move
* amount and realizedPnL together, so this only has to absorb rounding;
* a payout is hundreds. */
const FUND_DRIFT_TOLERANCE = 25;
const CONTRACT_FIND_TIMEOUT_MS = 15_000;
/** On-demand front-month resolve (several Yahoo lookups, then Tradovate). */
const CONTRACT_RESOLVE_DEADLINE_MS = 30_000;
@@ -173,6 +178,18 @@ export class TradovateClient {
private lastDaysFetchStartedAt = 0;
private lastDaysFetchSucceededAt = 0;
private nextDaysFetchRetryAt = 0;
/** Accounts whose history this process has loaded: a report came back, or
* failed with a DB cache to fall back on. Until then the plan would read
* a fresh stage-1 cycle, so historyReady() keeps them out of trading. */
private historyLoadedIds = new Set<number>();
/** Accounts whose balance moved in a way the loaded history can't explain
* (a payout posting between the hourly reports): held out of trading
* until a Cash History books it. */
private historyRefreshIds = new Set<number>();
/** Per account, the start-of-day balance (amount − realizedPnL) the loaded
* history was taken at, and the day's realized P&L as last seen — what the
* daily reset folds into the balance. */
private balanceBaseline: { [accountId: number]: { dayStart: number; realized: number } } = {};
/** Interval handles tracked so they can be cleared on reconnect */
private syncInterval: ReturnType<typeof setInterval> | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
@@ -854,6 +871,9 @@ export class TradovateClient {
this.consecutiveSocketFailures = 0;
console.log(`${this.tag} sync:${this.accountList.length} account(s), ${Object.keys(this.accountCashBalances).length} balance(s)`);
// Ahead of the fetch, so a balance change flagged here is
// fetched on this same pass.
this.observeBalances();
this.maybeFetchDaysTraded();
// The product list only feeds fetchInstrumentFees. Wait for it
@@ -897,20 +917,32 @@ export class TradovateClient {
!retryPending &&
this.lastDaysFetchSucceededAt > 0 &&
now - this.lastDaysFetchSucceededAt >= 60 * 60 * 1_000;
// Accounts held out of trading for want of history — new since the
// last pass, wiped, or flagged by a balance change — get their own
// fetch now rather than wait out the hour. A failure falls back to
// the 5-minute retry like any other.
const waiting = !initialFetchDue && !retryPending
? this.accountList
.filter((a) => !this.historyLoadedIds.has(a.id) || this.historyRefreshIds.has(a.id))
.map((a) => a.id)
: [];
const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0;
if (!initialFetchDue && !retryDue && !hourlyRefreshDue) return;
if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return;
const reason = retryDue
? 'retry after failure'
: initialFetchDue
? 'initial sync'
: 'hourly refresh';
: hourlyRefreshDue
? 'hourly refresh'
: `history for ${waiting.length} waiting account(s)`;
this.daysFetchInFlight = true;
this.lastDaysFetchStartedAt = now;
if (retryDue) this.nextDaysFetchRetryAt = 0;
this.fetchDaysTraded()
this.fetchDaysTraded(targeted ? waiting : undefined)
.then(({ failedAccounts }) => {
if (failedAccounts > 0) {
this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000;
@@ -918,7 +950,8 @@ export class TradovateClient {
return;
}
this.lastDaysFetchSucceededAt = Date.now();
// A partial pass doesn't restart the hourly clock.
if (!targeted) this.lastDaysFetchSucceededAt = Date.now();
})
.catch((err) => {
this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000;
@@ -1178,6 +1211,7 @@ export class TradovateClient {
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
delete this.lastFetchErrors[account.name];
this.markHistoryLoaded(account.id, true);
continue;
} catch {
// fall through to Fills fallback
@@ -1256,16 +1290,108 @@ export class TradovateClient {
this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`;
console.log(`${this.tag} fetchDaysTraded: using ${cached.length} cached entries from DB for ${account.name}`);
} else {
this.dailyPnL[account.id] ??= [];
// No history at all is not an empty one: leave dailyPnL
// unset so the account stays out of trading until a fetch
// lands, instead of trading as a fresh stage-1 cycle.
this.daysTraded[account.id] ??= 0;
this.lastFetchErrors[account.name] = msg;
}
}
// The Fills fallback, or the DB cache after both reports failed.
this.markHistoryLoaded(account.id, false);
}
this.fetchDaysComplete = true;
return { failedAccounts };
}
/**
* Whether this account's history can be planned on — see PlatformClient.
* Loaded by this process, not wiped since, and no balance change the
* history can't explain.
*/
public historyReady(accountId: number): boolean {
if (!this.historyLoadedIds.has(accountId)) return false;
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) return false;
if (this.historyRefreshIds.has(accountId)) return false;
return !this.balanceDrifted(accountId);
}
/** Drop the account's history from memory (a wipe): it reads unloaded, and
* stays out of trading, until the next fetch rebuilds it. */
public forgetHistory(accountId: number, accountName: string): void {
this.historyLoadedIds.delete(accountId);
this.historyRefreshIds.delete(accountId);
delete this.balanceBaseline[accountId];
delete this.dailyPnL[accountId];
delete this.fullDailyPnL[accountId];
delete this.daysTraded[accountId];
delete this.priorProfit[accountId];
delete this.lastFundDates[accountId];
delete this.fundTransactions[accountId];
delete this.lastFetchErrors[accountName];
delete this.lastFetchRaw[accountName];
}
/**
* A fetch pass reached the account. Cash History is the only source that
* books fund transactions, so only it clears a balance-change hold; the
* Fills fallback and the DB cache still count as loaded otherwise.
*/
private markHistoryLoaded(accountId: number, fromCashHistory: boolean): void {
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) {
this.historyLoadedIds.delete(accountId);
return;
}
if (this.historyRefreshIds.has(accountId) && !fromCashHistory) return;
this.historyRefreshIds.delete(accountId);
this.historyLoadedIds.add(accountId);
const cash = this.accountCashBalances[accountId];
if (cash) this.balanceBaseline[accountId] = { dayStart: cash.amount - cash.realizedPnL, realized: cash.realizedPnL };
else delete this.balanceBaseline[accountId];
}
/**
* True when the balance moved in a way the loaded history can't explain —
* a payout or other fund transaction posting between the hourly Cash
* History runs. Planned on as is, the ended cycle's rows against the
* post-payout balance re-open a completed account. Today's trades move
* amount and realizedPnL together, and the daily reset folds the day's
* realized P&L into the balance; any other move past a few dollars holds
* the account until a Cash History books it (maybeFetchDaysTraded fetches
* it on the next sync).
*/
private balanceDrifted(accountId: number): boolean {
const cash = this.accountCashBalances[accountId];
if (!cash) return false;
const dayStart = cash.amount - cash.realizedPnL;
const base = this.balanceBaseline[accountId];
if (!base) {
this.balanceBaseline[accountId] = { dayStart, realized: cash.realizedPnL };
return false;
}
if (Math.abs(dayStart - base.dayStart) <= FUND_DRIFT_TOLERANCE) {
base.realized = cash.realizedPnL;
return false;
}
if (Math.abs(dayStart - (base.dayStart + base.realized)) <= FUND_DRIFT_TOLERANCE) {
this.balanceBaseline[accountId] = { dayStart, realized: cash.realizedPnL };
return false;
}
if (!this.historyRefreshIds.has(accountId)) {
this.historyRefreshIds.add(accountId);
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
console.warn(`${this.tag} ${name}: balance moved $${Math.round((dayStart - base.dayStart) * 100) / 100}`
+ ' that its history does not explain — held out of trading until Cash History refreshes');
}
return true;
}
/** Run the drift check on every loaded account each sync, so the day's
* last realized P&L is on record before the daily reset folds it in. */
private observeBalances(): void {
for (const accountId of this.historyLoadedIds) this.balanceDrifted(accountId);
}
private async login(pTicket?: string): Promise<LoginResponseBody> {
const chl = '' + (Date.now() - 1581e9);
const sec = computeSec(this.name, this.password, this.deviceId, chl);