fix(simple): support concurrent symbol setups

This commit is contained in:
root 2026-05-06 07:56:03 +00:00
parent e50e906866
commit 92747b76a7
7 changed files with 282 additions and 114 deletions

View File

@ -308,7 +308,11 @@ async function main() {
entry.user_id,
undefined,
undefined,
{ allowExistingPosition: true }
{
allowExistingPosition: true,
tradeIdHint: String(entry.linked_trade_id || '').trim() || `TRD-SIMPLE-${String(entry.stock_instance_id || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 24)}`,
concurrencyKey: String(entry.stock_instance_id || '').trim() || symbol
}
);
if (!result.success) {
logger.warn(`[SimpleWorker] Buy trigger failed for ${symbol}: ${result.error || 'unknown error'}`);

View File

@ -67,6 +67,8 @@ export class ManualTrader {
tp?: number,
options?: {
allowExistingPosition?: boolean;
tradeIdHint?: string;
concurrencyKey?: string;
}
): Promise<{ success: boolean; orderId?: string; tradeId?: string; error?: string; adjustedQty?: number; requestedQty?: number; remainingCapitalUsd?: number }> {
const signalSide = (side.toLowerCase() === 'buy') ? SignalDirection.BUY : SignalDirection.SELL;

View File

@ -64,7 +64,7 @@ export interface PositionState {
tradeId?: string;
}
export interface PendingOrder {
export interface PendingOrder {
orderId: string;
symbol: string;
side: SignalDirection;
@ -77,10 +77,11 @@ export interface PendingOrder {
userId?: string;
profileId?: string;
subTag?: string;
placedAt: number;
action: 'ENTRY' | 'EXIT';
reservedAmount?: number;
}
placedAt: number;
action: 'ENTRY' | 'EXIT';
reservedAmount?: number;
concurrencyKey?: string;
}
export type ExitLifecycleState =
| 'idle'
@ -113,12 +114,20 @@ export class TradeExecutor {
private pendingOrders: Map<string, PendingOrder> = new Map(); // orderId -> PendingOrder
private exitLifecycle: Map<string, ExitLifecycleRecord> = new Map();
private entryAutoReduceLastAlertAt: Map<string, number> = new Map();
private tradeSequence = 0;
private tradeSequence = 0;
private notifier: Notifier;
private static readonly POSITION_KEY_SEPARATOR = '::';
private static readonly POSITION_KEY_SEPARATOR = '::';
private accountSnapshotTimer?: NodeJS.Timeout;
private static warnedCapabilities = new Set<string>();
private profileSettings?: any;
private profileSettings?: any;
private getDustQtyThreshold(): number {
const configured = Number(config.MIN_POSITION_QTY || 0.0001);
if (Number.isFinite(configured) && configured > 0) {
return configured;
}
return 0.0001;
}
constructor(
private exchange: IExchangeConnector,
@ -943,6 +952,8 @@ export class TradeExecutor {
userIdOverride?: string,
options?: {
allowExistingPosition?: boolean;
tradeIdHint?: string;
concurrencyKey?: string;
}
): Promise<{ success: boolean, orderId?: string, tradeId?: string, error?: string }> {
if (healthTracker.isPaused()) {
@ -951,19 +962,20 @@ export class TradeExecutor {
}
console.log('[TradeExecutor] openPosition called', { symbol, side, qty, type, price, userIdOverride });
let executionQty = this.roundDownQty(Number(qty));
if (!(executionQty > 0)) {
return { success: false, error: 'Invalid order quantity' };
}
const ledgerProfileId = this.getLedgerProfileId();
const tradeId = this.buildDeterministicTradeId(symbol, side);
const finalUserId = userIdOverride || this.userId;
if (!(executionQty > 0)) {
return { success: false, error: 'Invalid order quantity' };
}
const ledgerProfileId = this.getLedgerProfileId();
const normalizedSymbol = String(symbol || '').trim();
const tradeId = String(options?.tradeIdHint || '').trim() || this.buildDeterministicTradeId(symbol, side);
const concurrencyKey = String(options?.concurrencyKey || '').trim() || normalizedSymbol;
const finalUserId = userIdOverride || this.userId;
let reservedEstimate = ledgerProfileId ? this.estimateOrderCost(symbol, executionQty, price, type) : 0;
let activeOrderId: string | undefined;
let pendingCaptured = false;
let capitalReserved = false;
let capitalReservationAmount = reservedEstimate;
const normalizedSymbol = String(symbol || '').trim();
let lockAcquired = false;
let lockAcquired = false;
let lockOwner: string | undefined;
let orderSubTag: string | undefined;
@ -1053,12 +1065,18 @@ export class TradeExecutor {
capitalReserved = true;
capitalReservationAmount = reservedEstimate;
}
if (this.hasPendingAction(symbol, 'ENTRY')) {
logger.warn(`[Executor] Duplicate ENTRY request blocked for ${symbol}`);
await releaseCapitalOnAbort();
await releaseLockIfHeld();
return { success: false, error: 'Duplicate entry request blocked (pending action)' };
}
const hasConflictingPendingEntry = Array.from(this.pendingOrders.values()).some((pending) => {
if (pending.symbol !== symbol) return false;
if ((pending.action || '').toUpperCase() !== 'ENTRY') return false;
const pendingKey = String(pending.concurrencyKey || pending.tradeId || pending.symbol || '').trim();
return pendingKey === concurrencyKey;
});
if (hasConflictingPendingEntry) {
logger.warn(`[Executor] Duplicate ENTRY request blocked for ${symbol}`);
await releaseCapitalOnAbort();
await releaseLockIfHeld();
return { success: false, error: 'Duplicate entry request blocked (pending action)' };
}
const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER);
const clientOrderId = `bytelyst-${ledgerProfileId || this.profileId || 'global'}-${tradeId}`;
@ -1220,10 +1238,11 @@ export class TradeExecutor {
userId: finalUserId,
profileId: ledgerProfileId,
subTag: orderSubTag,
reservedAmount: pendingReservationAmount,
placedAt: Date.now(),
action: 'ENTRY'
});
reservedAmount: pendingReservationAmount,
placedAt: Date.now(),
action: 'ENTRY',
concurrencyKey
});
pendingCaptured = true;
activeOrderId = order.id;
await releaseLockIfHeld();
@ -1498,7 +1517,7 @@ export class TradeExecutor {
const resolvedPrice = Number.isFinite(fillPrice) && fillPrice > 0 ? fillPrice : getReconciliationFillPrice(order);
if (resolvedQty <= 0 || resolvedPrice <= 0 || !resolvedSymbol) return;
const pending: PendingOrder = {
const pending: PendingOrder = {
orderId,
symbol: resolvedSymbol,
side: this.normalizeSignalDirection(order.side),
@ -1509,12 +1528,13 @@ export class TradeExecutor {
takeProfit: Number(order.take_profit || 0),
tradeId: order.trade_id || order.tradeId,
userId: order.user_id || this.userId,
profileId: order.profile_id || this.profileId,
subTag: extractOrderSubTag(order) || undefined,
placedAt: Number(order.timestamp || Date.now()),
action: 'ENTRY',
reservedAmount: Math.max(resolvedQty * resolvedPrice, this.computeReservedAmount(order, resolvedPrice))
};
profileId: order.profile_id || this.profileId,
subTag: extractOrderSubTag(order) || undefined,
placedAt: Number(order.timestamp || Date.now()),
action: 'ENTRY',
reservedAmount: Math.max(resolvedQty * resolvedPrice, this.computeReservedAmount(order, resolvedPrice)),
concurrencyKey: String(order.trade_id || order.tradeId || orderId || resolvedSymbol).trim() || resolvedSymbol
};
this.pendingOrders.set(orderId, pending);
try {
@ -1563,11 +1583,12 @@ export class TradeExecutor {
/**
* Closes an existing position.
*/
public async closePosition(
symbol: string,
reason: string = 'Exit Signal',
tradeId?: string
): Promise<{ success: boolean, exitPrice?: number, error?: string }> {
public async closePosition(
symbol: string,
reason: string = 'Exit Signal',
tradeId?: string,
currentPriceHint?: number
): Promise<{ success: boolean, exitPrice?: number, error?: string }> {
const selected = this.resolvePositionSelection(symbol, tradeId);
if (!selected) return { success: false, error: "No active position" };
const pos = selected.position;
@ -1620,6 +1641,16 @@ export class TradeExecutor {
// We do NOT mutate pos.size here to avoid capital leaks in finalizeTrade.
// We use a local variable for the order volume.
} else {
const dustThreshold = this.getDustQtyThreshold();
if (pos.size <= dustThreshold) {
const fallbackExitPrice = Number.isFinite(Number(currentPriceHint)) && Number(currentPriceHint) > 0
? Number(currentPriceHint)
: (this.apiServer?.getState?.().symbols?.[symbol]?.price || pos.entryPrice);
logger.warn(`[Hardening] Auto-finalizing dust remainder for ${symbol}: local=${pos.size}, exchange=${exchangeQty}, threshold=${dustThreshold}.`);
await this.finalizeTrade(symbol, fallbackExitPrice, `${reason} (dust auto-close)`, positionTradeId);
this.setExitLifecycle(symbol, 'filled', reason, `dust_autoclose_threshold=${dustThreshold}`);
return { success: true, exitPrice: fallbackExitPrice };
}
logger.error(`[Guardrail] Aborting SELL Exit for ${symbol}: Insufficient exchange balance (Requested: ${pos.size}, Exchange: ${exchangeQty}).`);
if (config.REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE) {
this.setExitLifecycle(symbol, 'quarantined', 'EXCHANGE_STATE_MISMATCH', `Exchange inventory: ${exchangeQty}`);
@ -1950,10 +1981,10 @@ export class TradeExecutor {
}
// --- Aliases for Compatibility/Clarity ---
public async executeExit(symbol: string, currentPrice: number, reason: string, tradeId?: string) {
// We ignore currentPrice for market order, but could log it
return this.closePosition(symbol, reason, tradeId);
}
public async executeExit(symbol: string, currentPrice: number, reason: string, tradeId?: string) {
// We ignore currentPrice for market order, but could log it
return this.closePosition(symbol, reason, tradeId, currentPrice);
}
public async markTradeComplete(symbol: string, exitPrice: number, reason: string = 'Target Reached', tradeId?: string) {
return await this.finalizeTrade(symbol, exitPrice, reason, tradeId);
@ -2472,7 +2503,7 @@ export class TradeExecutor {
const normalizedSide = normalizeTradeSide(p.side || 'BUY');
const resolvedAction = normalizeOrderAction(p.action || undefined)
|| (normalizedSide === SignalDirection.SELL ? 'EXIT' : 'ENTRY');
this.pendingOrders.set(pendingOrderId, {
this.pendingOrders.set(pendingOrderId, {
orderId: pendingOrderId,
symbol: p.symbol,
side: normalizedSide as SignalDirection,
@ -2482,11 +2513,12 @@ export class TradeExecutor {
stopLoss: p.stop_loss || 0,
takeProfit: p.take_profit || 0,
tradeId: p.trade_id || '',
userId: p.user_id,
subTag: extractOrderSubTag(p) || undefined,
placedAt: new Date(p.created_at || Date.now()).getTime(),
action: resolvedAction
});
userId: p.user_id,
subTag: extractOrderSubTag(p) || undefined,
placedAt: new Date(p.created_at || Date.now()).getTime(),
action: resolvedAction,
concurrencyKey: String(p.trade_id || p.order_id || p.symbol || '').trim() || p.symbol
});
logger.info(`[Executor] 🔄 Recovered pending order ${p.order_id} for ${p.symbol} into monitoring map.`);
}
}

View File

@ -69,6 +69,51 @@ function getLegacyClient() {
return getLegacySupabaseClient();
}
function coalesceString(...values: Array<unknown>): string {
for (const value of values) {
const normalized = String(value || '').trim();
if (normalized) return normalized;
}
return '';
}
function coalesceNumber(...values: Array<unknown>): number {
for (const value of values) {
if (value === null || value === undefined || value === '') continue;
const normalized = Number(value);
if (Number.isFinite(normalized)) return normalized;
}
return 0;
}
function mergeTradingUserProfiles(
primary: Partial<TradingUserProfile> | null | undefined,
fallback: Partial<TradingUserProfile> | null | undefined,
explicitUserId?: string
): TradingUserProfile | null {
const userId = coalesceString(primary?.user_id, fallback?.user_id, explicitUserId);
if (!userId) {
return null;
}
return {
user_id: userId,
first_name: coalesceString(primary?.first_name, fallback?.first_name),
last_name: coalesceString(primary?.last_name, fallback?.last_name),
email: coalesceString(primary?.email, fallback?.email),
role: coalesceString(primary?.role, fallback?.role, 'member'),
trade_enable: Boolean(primary?.trade_enable ?? fallback?.trade_enable ?? true),
FMP_API_KEY: coalesceString(primary?.FMP_API_KEY, fallback?.FMP_API_KEY),
ALPACA_API_KEY: coalesceString(primary?.ALPACA_API_KEY, fallback?.ALPACA_API_KEY),
ALPACA_SECRET_KEY: coalesceString(primary?.ALPACA_SECRET_KEY, fallback?.ALPACA_SECRET_KEY),
REAL_ALPACA_API_KEY: coalesceString(primary?.REAL_ALPACA_API_KEY, fallback?.REAL_ALPACA_API_KEY),
REAL_ALPACA_SECRET_KEY: coalesceString(primary?.REAL_ALPACA_SECRET_KEY, fallback?.REAL_ALPACA_SECRET_KEY),
drop_threshold_for_buy: coalesceNumber(primary?.drop_threshold_for_buy, fallback?.drop_threshold_for_buy, 0),
gain_threshold_for_sell: coalesceNumber(primary?.gain_threshold_for_sell, fallback?.gain_threshold_for_sell, 0),
market_poll_interval_in_seconds: coalesceNumber(primary?.market_poll_interval_in_seconds, fallback?.market_poll_interval_in_seconds, 0),
};
}
function normalizeProfile(row: Partial<TradeProfileRecord> | null | undefined): TradeProfileRecord | null {
const id = String(row?.id || '').trim();
const userId = String(row?.user_id || '').trim();
@ -616,7 +661,7 @@ export async function getCurrentUserProfile(
try {
const cosmosProfile = await getTradingUserProfileFromCosmos(userId);
if (cosmosProfile) {
return cosmosProfile;
return mergeTradingUserProfiles(cosmosProfile, fallback, userId) || cosmosProfile;
}
} catch (error) {
logger.warn(`[Profiles] Cosmos user profile read failed for ${userId}: ${error instanceof Error ? error.message : 'unknown error'}`);
@ -633,22 +678,10 @@ export async function getCurrentUserProfile(
.maybeSingle();
if (!error && data) {
const normalized = {
user_id: String((data as any).user_id || userId),
first_name: String((data as any).first_name || fallback.first_name || ''),
last_name: String((data as any).last_name || fallback.last_name || ''),
email: String((data as any).email || fallback.email || ''),
role: String((data as any).role || fallback.role || 'member'),
trade_enable: Boolean((data as any).trade_enable ?? fallback.trade_enable ?? true),
FMP_API_KEY: (data as any).FMP_API_KEY || fallback.FMP_API_KEY,
ALPACA_API_KEY: (data as any).ALPACA_API_KEY || fallback.ALPACA_API_KEY,
ALPACA_SECRET_KEY: (data as any).ALPACA_SECRET_KEY || fallback.ALPACA_SECRET_KEY,
REAL_ALPACA_API_KEY: (data as any).REAL_ALPACA_API_KEY || fallback.REAL_ALPACA_API_KEY,
REAL_ALPACA_SECRET_KEY: (data as any).REAL_ALPACA_SECRET_KEY || fallback.REAL_ALPACA_SECRET_KEY,
drop_threshold_for_buy: Number((data as any).drop_threshold_for_buy ?? fallback.drop_threshold_for_buy ?? 0),
gain_threshold_for_sell: Number((data as any).gain_threshold_for_sell ?? fallback.gain_threshold_for_sell ?? 0),
market_poll_interval_in_seconds: Number((data as any).market_poll_interval_in_seconds ?? fallback.market_poll_interval_in_seconds ?? 0),
};
const normalized = mergeTradingUserProfiles(data as any, fallback, userId);
if (!normalized) {
throw new Error(`Invalid user profile for ${userId}`);
}
await upsertTradingUserProfileToCosmos(normalized);
return normalized;
}
@ -657,21 +690,21 @@ export async function getCurrentUserProfile(
}
}
return {
return mergeTradingUserProfiles({}, fallback, userId) || {
user_id: userId,
first_name: String(fallback.first_name || ''),
last_name: String(fallback.last_name || ''),
email: String(fallback.email || ''),
role: String(fallback.role || 'member'),
first_name: '',
last_name: '',
email: '',
role: 'member',
trade_enable: Boolean(fallback.trade_enable ?? true),
FMP_API_KEY: fallback.FMP_API_KEY,
ALPACA_API_KEY: fallback.ALPACA_API_KEY,
ALPACA_SECRET_KEY: fallback.ALPACA_SECRET_KEY,
REAL_ALPACA_API_KEY: fallback.REAL_ALPACA_API_KEY,
REAL_ALPACA_SECRET_KEY: fallback.REAL_ALPACA_SECRET_KEY,
drop_threshold_for_buy: Number(fallback.drop_threshold_for_buy ?? 0),
gain_threshold_for_sell: Number(fallback.gain_threshold_for_sell ?? 0),
market_poll_interval_in_seconds: Number(fallback.market_poll_interval_in_seconds ?? 0),
FMP_API_KEY: '',
ALPACA_API_KEY: '',
ALPACA_SECRET_KEY: '',
REAL_ALPACA_API_KEY: '',
REAL_ALPACA_SECRET_KEY: '',
drop_threshold_for_buy: 0,
gain_threshold_for_sell: 0,
market_poll_interval_in_seconds: 0,
};
}

View File

@ -84,6 +84,14 @@ const OPEN_ORDER_STATUSES = ['pending_new', 'accepted', 'pending', 'new', 'parti
const CLOSED_ORDER_STATUSES = ['filled', 'canceled', 'expired', 'rejected', 'unknown'];
const FILLED_ORDER_STATUSES = ['filled', 'partially_filled', 'partially-filled'];
function dustThresholdQty(): number {
const configured = Number(config.MIN_POSITION_QTY || 0.0001);
if (Number.isFinite(configured) && configured > 0) {
return configured;
}
return 0.0001;
}
function cosmosEnabled(): boolean {
return isCosmosConfigured();
}
@ -1209,7 +1217,7 @@ export async function getVirtualOpenPosition(profileId: string, symbol: string):
const aggregateBySide = new Map<'BUY' | 'SELL', SideAggregate>();
for (const tradeLedger of ledgerByTrade.values()) {
const remainingQty = tradeLedger.entryQty - tradeLedger.exitQty;
if (remainingQty <= 1e-8) continue;
if (remainingQty <= dustThresholdQty()) continue;
const weightedEntryPrice = tradeLedger.entryQty > 0 && tradeLedger.entryNotional > 0
? tradeLedger.entryNotional / tradeLedger.entryQty
: tradeLedger.entryLastPrice;
@ -1317,7 +1325,7 @@ export async function getVirtualOpenPositionForTrade(profileId: string, symbol:
}
const remainingQty = entryQty - exitQty;
if (!(remainingQty > 1e-8) || !(entryNotional > 0) || !entrySide) return null;
if (!(remainingQty > dustThresholdQty()) || !(entryNotional > 0) || !entrySide) return null;
return {
profileId,

View File

@ -40,6 +40,47 @@ function normalizeUser(row: Partial<UserConfig> | null | undefined): UserConfig
};
}
function mergeUsers(primary: Partial<UserConfig> | null | undefined, fallback: Partial<UserConfig> | null | undefined): UserConfig | null {
const userId = String(primary?.user_id || fallback?.user_id || '').trim();
if (!userId) {
return null;
}
const take = (...values: Array<unknown>): string => {
for (const value of values) {
const normalized = String(value || '').trim();
if (normalized) return normalized;
}
return '';
};
const takeNumber = (...values: Array<unknown>): number => {
for (const value of values) {
if (value === null || value === undefined || value === '') continue;
const normalized = Number(value);
if (Number.isFinite(normalized)) return normalized;
}
return 0;
};
return {
user_id: userId,
first_name: take(primary?.first_name, fallback?.first_name),
last_name: take(primary?.last_name, fallback?.last_name),
email: take(primary?.email, fallback?.email),
FMP_API_KEY: take(primary?.FMP_API_KEY, fallback?.FMP_API_KEY),
ALPACA_API_KEY: take(primary?.ALPACA_API_KEY, fallback?.ALPACA_API_KEY),
ALPACA_SECRET_KEY: take(primary?.ALPACA_SECRET_KEY, fallback?.ALPACA_SECRET_KEY),
REAL_ALPACA_API_KEY: take(primary?.REAL_ALPACA_API_KEY, fallback?.REAL_ALPACA_API_KEY),
REAL_ALPACA_SECRET_KEY: take(primary?.REAL_ALPACA_SECRET_KEY, fallback?.REAL_ALPACA_SECRET_KEY),
role: take(primary?.role, fallback?.role, 'member'),
trade_enable: Boolean(primary?.trade_enable ?? fallback?.trade_enable ?? true),
drop_threshold_for_buy: takeNumber(primary?.drop_threshold_for_buy, fallback?.drop_threshold_for_buy, 0),
gain_threshold_for_sell: takeNumber(primary?.gain_threshold_for_sell, fallback?.gain_threshold_for_sell, 0),
market_poll_interval_in_seconds: takeNumber(primary?.market_poll_interval_in_seconds, fallback?.market_poll_interval_in_seconds, 0),
};
}
export async function listActiveTradingUsers(): Promise<UserConfig[]> {
if (isCosmosConfigured()) {
try {
@ -56,7 +97,33 @@ export async function listActiveTradingUsers(): Promise<UserConfig[]> {
.map((row) => normalizeUser(row as UserConfig))
.filter((user): user is UserConfig => Boolean(user));
if (normalized.length > 0) {
return normalized;
const client = getLegacySupabaseClient();
if (!client) {
return normalized;
}
try {
const { data, error } = await client
.from('users')
.select('*')
.eq('trade_enable', true);
if (error || !Array.isArray(data) || data.length === 0) {
return normalized;
}
const legacyById = new Map(
data
.map((row) => normalizeUser(row as UserConfig))
.filter((user): user is UserConfig => Boolean(user))
.map((user) => [user.user_id, user])
);
return normalized.map((user) => mergeUsers(user, legacyById.get(user.user_id)) || user);
} catch (error) {
logger.warn(`[Users] Legacy user merge failed: ${error instanceof Error ? error.message : 'unknown error'}`);
return normalized;
}
}
} catch (error) {
logger.warn(`[Users] Cosmos active trading user lookup failed: ${error instanceof Error ? error.message : 'unknown error'}`);

View File

@ -694,12 +694,44 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
setOrdersPage((current) => Math.min(current, ordersTotalPages));
}, [ordersTotalPages]);
const finalOrders = useMemo(() => {
const startIndex = (ordersPage - 1) * ORDER_ACTIVITY_PAGE_SIZE;
return sortedOrdersForActivity.slice(startIndex, startIndex + ORDER_ACTIVITY_PAGE_SIZE);
}, [sortedOrdersForActivity, ordersPage, ORDER_ACTIVITY_PAGE_SIZE]);
const historyTradeKeySet = useMemo(() => new Set(historyTradeKeys), [historyTradeKeys]);
const finalOrders = useMemo(() => {
const startIndex = (ordersPage - 1) * ORDER_ACTIVITY_PAGE_SIZE;
return sortedOrdersForActivity.slice(startIndex, startIndex + ORDER_ACTIVITY_PAGE_SIZE);
}, [sortedOrdersForActivity, ordersPage, ORDER_ACTIVITY_PAGE_SIZE]);
const historyTradeKeySet = useMemo(() => new Set(historyTradeKeys), [historyTradeKeys]);
const activePositionTradeKeys = useMemo(() => {
const keys = new Set<string>();
for (const position of filteredBotPositions) {
const tradeId = String(position.tradeId || '').trim();
if (!tradeId) continue;
keys.add(`${position.profileId || 'global'}|${tradeId}`);
keys.add(`global|${tradeId}`);
}
return keys;
}, [filteredBotPositions]);
const staleWarningOrders = useMemo(() => {
return resolvedOrders.filter((order) => {
if (!isPendingLikeStatus(order.status)) return false;
const orderAge = order.timestamp ? Date.now() - order.timestamp : 0;
if (orderAge <= 5 * 60 * 1000) return false;
const tradeId = String(order.tradeId || '').trim();
if (tradeId) {
const scopedTradeKey = `${order.profileId || 'global'}|${tradeId}`;
if (activePositionTradeKeys.has(scopedTradeKey) || activePositionTradeKeys.has(`global|${tradeId}`)) {
return false;
}
if (historyTradeKeySet.has(scopedTradeKey) || historyTradeKeySet.has(`global|${tradeId}`)) {
return false;
}
}
return true;
});
}, [resolvedOrders, activePositionTradeKeys, historyTradeKeySet]);
const entryOrdersLookup = useMemo(() => {
const byScopedTrade = new Map<string, NormalizedOrder>();
@ -1165,7 +1197,7 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="tab-header">
<h2 className="text-2xl font-bold text-white tracking-tight">Positions & Orders</h2>
<p className="text-gray-400 text-sm">Real-time isolation by strategy profile.</p>
<p className="text-gray-400 text-sm">Live positions, orders, and lifecycle status scoped by strategy profile.</p>
</div>
<div className="flex gap-2 border-b border-white/5 items-end">
@ -1205,31 +1237,21 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
</div>
)}
{/* Stale Orders Warning Banner */}
{(() => {
const staleOrders = finalOrders.filter((o) => {
const isPendingNew = isPendingLikeStatus(o.status);
const orderAge = o.timestamp ? Date.now() - o.timestamp : 0;
return isPendingNew && orderAge > 5 * 60 * 1000;
});
if (staleOrders.length === 0) return null;
return (
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 flex items-start gap-3">
<span className="text-yellow-400 text-xl"></span>
<div className="flex-1">
<h4 className="text-yellow-400 font-bold text-sm mb-1">
{staleOrders.length} Stale Order{staleOrders.length > 1 ? 's' : ''} Detected
</h4>
<p className="text-gray-400 text-xs">
Some orders have been in <code className="bg-black/30 px-1 rounded">pending_new</code> status for more than 5 minutes.
The background sync service is checking their actual status with the exchange.
</p>
</div>
</div>
);
})()}
{/* Stale Orders Warning Banner */}
{staleWarningOrders.length > 0 && (
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 flex items-start gap-3">
<span className="text-yellow-400 text-xl"></span>
<div className="flex-1">
<h4 className="text-yellow-400 font-bold text-sm mb-1">
{staleWarningOrders.length} Stale Order{staleWarningOrders.length > 1 ? 's' : ''} Detected
</h4>
<p className="text-gray-400 text-xs">
Some orders have remained in <code className="bg-black/30 px-1 rounded">pending_new</code> for more than 5 minutes
without stronger fill or position evidence. The background sync service is re-checking their exchange status.
</p>
</div>
</div>
)}
{positionMismatches.length > 0 && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4">