fix(simple): support concurrent symbol setups
This commit is contained in:
parent
e50e906866
commit
92747b76a7
@ -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'}`);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'}`);
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user