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,
|
entry.user_id,
|
||||||
undefined,
|
undefined,
|
||||||
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) {
|
if (!result.success) {
|
||||||
logger.warn(`[SimpleWorker] Buy trigger failed for ${symbol}: ${result.error || 'unknown error'}`);
|
logger.warn(`[SimpleWorker] Buy trigger failed for ${symbol}: ${result.error || 'unknown error'}`);
|
||||||
|
|||||||
@ -67,6 +67,8 @@ export class ManualTrader {
|
|||||||
tp?: number,
|
tp?: number,
|
||||||
options?: {
|
options?: {
|
||||||
allowExistingPosition?: boolean;
|
allowExistingPosition?: boolean;
|
||||||
|
tradeIdHint?: string;
|
||||||
|
concurrencyKey?: string;
|
||||||
}
|
}
|
||||||
): Promise<{ success: boolean; orderId?: string; tradeId?: string; error?: string; adjustedQty?: number; requestedQty?: number; remainingCapitalUsd?: number }> {
|
): Promise<{ success: boolean; orderId?: string; tradeId?: string; error?: string; adjustedQty?: number; requestedQty?: number; remainingCapitalUsd?: number }> {
|
||||||
const signalSide = (side.toLowerCase() === 'buy') ? SignalDirection.BUY : SignalDirection.SELL;
|
const signalSide = (side.toLowerCase() === 'buy') ? SignalDirection.BUY : SignalDirection.SELL;
|
||||||
|
|||||||
@ -80,6 +80,7 @@ export interface PendingOrder {
|
|||||||
placedAt: number;
|
placedAt: number;
|
||||||
action: 'ENTRY' | 'EXIT';
|
action: 'ENTRY' | 'EXIT';
|
||||||
reservedAmount?: number;
|
reservedAmount?: number;
|
||||||
|
concurrencyKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExitLifecycleState =
|
export type ExitLifecycleState =
|
||||||
@ -120,6 +121,14 @@ export class TradeExecutor {
|
|||||||
private static warnedCapabilities = new Set<string>();
|
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(
|
constructor(
|
||||||
private exchange: IExchangeConnector,
|
private exchange: IExchangeConnector,
|
||||||
private apiServer?: ApiServer,
|
private apiServer?: ApiServer,
|
||||||
@ -943,6 +952,8 @@ export class TradeExecutor {
|
|||||||
userIdOverride?: string,
|
userIdOverride?: string,
|
||||||
options?: {
|
options?: {
|
||||||
allowExistingPosition?: boolean;
|
allowExistingPosition?: boolean;
|
||||||
|
tradeIdHint?: string;
|
||||||
|
concurrencyKey?: string;
|
||||||
}
|
}
|
||||||
): Promise<{ success: boolean, orderId?: string, tradeId?: string, error?: string }> {
|
): Promise<{ success: boolean, orderId?: string, tradeId?: string, error?: string }> {
|
||||||
if (healthTracker.isPaused()) {
|
if (healthTracker.isPaused()) {
|
||||||
@ -955,14 +966,15 @@ export class TradeExecutor {
|
|||||||
return { success: false, error: 'Invalid order quantity' };
|
return { success: false, error: 'Invalid order quantity' };
|
||||||
}
|
}
|
||||||
const ledgerProfileId = this.getLedgerProfileId();
|
const ledgerProfileId = this.getLedgerProfileId();
|
||||||
const tradeId = this.buildDeterministicTradeId(symbol, side);
|
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;
|
const finalUserId = userIdOverride || this.userId;
|
||||||
let reservedEstimate = ledgerProfileId ? this.estimateOrderCost(symbol, executionQty, price, type) : 0;
|
let reservedEstimate = ledgerProfileId ? this.estimateOrderCost(symbol, executionQty, price, type) : 0;
|
||||||
let activeOrderId: string | undefined;
|
let activeOrderId: string | undefined;
|
||||||
let pendingCaptured = false;
|
let pendingCaptured = false;
|
||||||
let capitalReserved = false;
|
let capitalReserved = false;
|
||||||
let capitalReservationAmount = reservedEstimate;
|
let capitalReservationAmount = reservedEstimate;
|
||||||
const normalizedSymbol = String(symbol || '').trim();
|
|
||||||
let lockAcquired = false;
|
let lockAcquired = false;
|
||||||
let lockOwner: string | undefined;
|
let lockOwner: string | undefined;
|
||||||
let orderSubTag: string | undefined;
|
let orderSubTag: string | undefined;
|
||||||
@ -1053,7 +1065,13 @@ export class TradeExecutor {
|
|||||||
capitalReserved = true;
|
capitalReserved = true;
|
||||||
capitalReservationAmount = reservedEstimate;
|
capitalReservationAmount = reservedEstimate;
|
||||||
}
|
}
|
||||||
if (this.hasPendingAction(symbol, 'ENTRY')) {
|
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}`);
|
logger.warn(`[Executor] Duplicate ENTRY request blocked for ${symbol}`);
|
||||||
await releaseCapitalOnAbort();
|
await releaseCapitalOnAbort();
|
||||||
await releaseLockIfHeld();
|
await releaseLockIfHeld();
|
||||||
@ -1222,7 +1240,8 @@ export class TradeExecutor {
|
|||||||
subTag: orderSubTag,
|
subTag: orderSubTag,
|
||||||
reservedAmount: pendingReservationAmount,
|
reservedAmount: pendingReservationAmount,
|
||||||
placedAt: Date.now(),
|
placedAt: Date.now(),
|
||||||
action: 'ENTRY'
|
action: 'ENTRY',
|
||||||
|
concurrencyKey
|
||||||
});
|
});
|
||||||
pendingCaptured = true;
|
pendingCaptured = true;
|
||||||
activeOrderId = order.id;
|
activeOrderId = order.id;
|
||||||
@ -1513,7 +1532,8 @@ export class TradeExecutor {
|
|||||||
subTag: extractOrderSubTag(order) || undefined,
|
subTag: extractOrderSubTag(order) || undefined,
|
||||||
placedAt: Number(order.timestamp || Date.now()),
|
placedAt: Number(order.timestamp || Date.now()),
|
||||||
action: 'ENTRY',
|
action: 'ENTRY',
|
||||||
reservedAmount: Math.max(resolvedQty * resolvedPrice, this.computeReservedAmount(order, resolvedPrice))
|
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);
|
this.pendingOrders.set(orderId, pending);
|
||||||
@ -1566,7 +1586,8 @@ export class TradeExecutor {
|
|||||||
public async closePosition(
|
public async closePosition(
|
||||||
symbol: string,
|
symbol: string,
|
||||||
reason: string = 'Exit Signal',
|
reason: string = 'Exit Signal',
|
||||||
tradeId?: string
|
tradeId?: string,
|
||||||
|
currentPriceHint?: number
|
||||||
): Promise<{ success: boolean, exitPrice?: number, error?: string }> {
|
): Promise<{ success: boolean, exitPrice?: number, error?: string }> {
|
||||||
const selected = this.resolvePositionSelection(symbol, tradeId);
|
const selected = this.resolvePositionSelection(symbol, tradeId);
|
||||||
if (!selected) return { success: false, error: "No active position" };
|
if (!selected) return { success: false, error: "No active position" };
|
||||||
@ -1620,6 +1641,16 @@ export class TradeExecutor {
|
|||||||
// We do NOT mutate pos.size here to avoid capital leaks in finalizeTrade.
|
// We do NOT mutate pos.size here to avoid capital leaks in finalizeTrade.
|
||||||
// We use a local variable for the order volume.
|
// We use a local variable for the order volume.
|
||||||
} else {
|
} 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}).`);
|
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) {
|
if (config.REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE) {
|
||||||
this.setExitLifecycle(symbol, 'quarantined', 'EXCHANGE_STATE_MISMATCH', `Exchange inventory: ${exchangeQty}`);
|
this.setExitLifecycle(symbol, 'quarantined', 'EXCHANGE_STATE_MISMATCH', `Exchange inventory: ${exchangeQty}`);
|
||||||
@ -1952,7 +1983,7 @@ export class TradeExecutor {
|
|||||||
// --- Aliases for Compatibility/Clarity ---
|
// --- Aliases for Compatibility/Clarity ---
|
||||||
public async executeExit(symbol: string, currentPrice: number, reason: string, tradeId?: string) {
|
public async executeExit(symbol: string, currentPrice: number, reason: string, tradeId?: string) {
|
||||||
// We ignore currentPrice for market order, but could log it
|
// We ignore currentPrice for market order, but could log it
|
||||||
return this.closePosition(symbol, reason, tradeId);
|
return this.closePosition(symbol, reason, tradeId, currentPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async markTradeComplete(symbol: string, exitPrice: number, reason: string = 'Target Reached', tradeId?: string) {
|
public async markTradeComplete(symbol: string, exitPrice: number, reason: string = 'Target Reached', tradeId?: string) {
|
||||||
@ -2485,7 +2516,8 @@ export class TradeExecutor {
|
|||||||
userId: p.user_id,
|
userId: p.user_id,
|
||||||
subTag: extractOrderSubTag(p) || undefined,
|
subTag: extractOrderSubTag(p) || undefined,
|
||||||
placedAt: new Date(p.created_at || Date.now()).getTime(),
|
placedAt: new Date(p.created_at || Date.now()).getTime(),
|
||||||
action: resolvedAction
|
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.`);
|
logger.info(`[Executor] 🔄 Recovered pending order ${p.order_id} for ${p.symbol} into monitoring map.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,6 +69,51 @@ function getLegacyClient() {
|
|||||||
return getLegacySupabaseClient();
|
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 {
|
function normalizeProfile(row: Partial<TradeProfileRecord> | null | undefined): TradeProfileRecord | null {
|
||||||
const id = String(row?.id || '').trim();
|
const id = String(row?.id || '').trim();
|
||||||
const userId = String(row?.user_id || '').trim();
|
const userId = String(row?.user_id || '').trim();
|
||||||
@ -616,7 +661,7 @@ export async function getCurrentUserProfile(
|
|||||||
try {
|
try {
|
||||||
const cosmosProfile = await getTradingUserProfileFromCosmos(userId);
|
const cosmosProfile = await getTradingUserProfileFromCosmos(userId);
|
||||||
if (cosmosProfile) {
|
if (cosmosProfile) {
|
||||||
return cosmosProfile;
|
return mergeTradingUserProfiles(cosmosProfile, fallback, userId) || cosmosProfile;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`[Profiles] Cosmos user profile read failed for ${userId}: ${error instanceof Error ? error.message : 'unknown 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();
|
.maybeSingle();
|
||||||
|
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
const normalized = {
|
const normalized = mergeTradingUserProfiles(data as any, fallback, userId);
|
||||||
user_id: String((data as any).user_id || userId),
|
if (!normalized) {
|
||||||
first_name: String((data as any).first_name || fallback.first_name || ''),
|
throw new Error(`Invalid user profile for ${userId}`);
|
||||||
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),
|
|
||||||
};
|
|
||||||
await upsertTradingUserProfileToCosmos(normalized);
|
await upsertTradingUserProfileToCosmos(normalized);
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@ -657,21 +690,21 @@ export async function getCurrentUserProfile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return mergeTradingUserProfiles({}, fallback, userId) || {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
first_name: String(fallback.first_name || ''),
|
first_name: '',
|
||||||
last_name: String(fallback.last_name || ''),
|
last_name: '',
|
||||||
email: String(fallback.email || ''),
|
email: '',
|
||||||
role: String(fallback.role || 'member'),
|
role: 'member',
|
||||||
trade_enable: Boolean(fallback.trade_enable ?? true),
|
trade_enable: Boolean(fallback.trade_enable ?? true),
|
||||||
FMP_API_KEY: fallback.FMP_API_KEY,
|
FMP_API_KEY: '',
|
||||||
ALPACA_API_KEY: fallback.ALPACA_API_KEY,
|
ALPACA_API_KEY: '',
|
||||||
ALPACA_SECRET_KEY: fallback.ALPACA_SECRET_KEY,
|
ALPACA_SECRET_KEY: '',
|
||||||
REAL_ALPACA_API_KEY: fallback.REAL_ALPACA_API_KEY,
|
REAL_ALPACA_API_KEY: '',
|
||||||
REAL_ALPACA_SECRET_KEY: fallback.REAL_ALPACA_SECRET_KEY,
|
REAL_ALPACA_SECRET_KEY: '',
|
||||||
drop_threshold_for_buy: Number(fallback.drop_threshold_for_buy ?? 0),
|
drop_threshold_for_buy: 0,
|
||||||
gain_threshold_for_sell: Number(fallback.gain_threshold_for_sell ?? 0),
|
gain_threshold_for_sell: 0,
|
||||||
market_poll_interval_in_seconds: Number(fallback.market_poll_interval_in_seconds ?? 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 CLOSED_ORDER_STATUSES = ['filled', 'canceled', 'expired', 'rejected', 'unknown'];
|
||||||
const FILLED_ORDER_STATUSES = ['filled', 'partially_filled', 'partially-filled'];
|
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 {
|
function cosmosEnabled(): boolean {
|
||||||
return isCosmosConfigured();
|
return isCosmosConfigured();
|
||||||
}
|
}
|
||||||
@ -1209,7 +1217,7 @@ export async function getVirtualOpenPosition(profileId: string, symbol: string):
|
|||||||
const aggregateBySide = new Map<'BUY' | 'SELL', SideAggregate>();
|
const aggregateBySide = new Map<'BUY' | 'SELL', SideAggregate>();
|
||||||
for (const tradeLedger of ledgerByTrade.values()) {
|
for (const tradeLedger of ledgerByTrade.values()) {
|
||||||
const remainingQty = tradeLedger.entryQty - tradeLedger.exitQty;
|
const remainingQty = tradeLedger.entryQty - tradeLedger.exitQty;
|
||||||
if (remainingQty <= 1e-8) continue;
|
if (remainingQty <= dustThresholdQty()) continue;
|
||||||
const weightedEntryPrice = tradeLedger.entryQty > 0 && tradeLedger.entryNotional > 0
|
const weightedEntryPrice = tradeLedger.entryQty > 0 && tradeLedger.entryNotional > 0
|
||||||
? tradeLedger.entryNotional / tradeLedger.entryQty
|
? tradeLedger.entryNotional / tradeLedger.entryQty
|
||||||
: tradeLedger.entryLastPrice;
|
: tradeLedger.entryLastPrice;
|
||||||
@ -1317,7 +1325,7 @@ export async function getVirtualOpenPositionForTrade(profileId: string, symbol:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remainingQty = entryQty - exitQty;
|
const remainingQty = entryQty - exitQty;
|
||||||
if (!(remainingQty > 1e-8) || !(entryNotional > 0) || !entrySide) return null;
|
if (!(remainingQty > dustThresholdQty()) || !(entryNotional > 0) || !entrySide) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profileId,
|
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[]> {
|
export async function listActiveTradingUsers(): Promise<UserConfig[]> {
|
||||||
if (isCosmosConfigured()) {
|
if (isCosmosConfigured()) {
|
||||||
try {
|
try {
|
||||||
@ -56,7 +97,33 @@ export async function listActiveTradingUsers(): Promise<UserConfig[]> {
|
|||||||
.map((row) => normalizeUser(row as UserConfig))
|
.map((row) => normalizeUser(row as UserConfig))
|
||||||
.filter((user): user is UserConfig => Boolean(user));
|
.filter((user): user is UserConfig => Boolean(user));
|
||||||
if (normalized.length > 0) {
|
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) {
|
} catch (error) {
|
||||||
logger.warn(`[Users] Cosmos active trading user lookup failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
logger.warn(`[Users] Cosmos active trading user lookup failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
|||||||
@ -701,6 +701,38 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
|||||||
|
|
||||||
const historyTradeKeySet = useMemo(() => new Set(historyTradeKeys), [historyTradeKeys]);
|
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 entryOrdersLookup = useMemo(() => {
|
||||||
const byScopedTrade = new Map<string, NormalizedOrder>();
|
const byScopedTrade = new Map<string, NormalizedOrder>();
|
||||||
const byTrade = new Map<string, NormalizedOrder>();
|
const byTrade = 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">
|
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div className="tab-header">
|
<div className="tab-header">
|
||||||
<h2 className="text-2xl font-bold text-white tracking-tight">Positions & Orders</h2>
|
<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>
|
||||||
|
|
||||||
<div className="flex gap-2 border-b border-white/5 items-end">
|
<div className="flex gap-2 border-b border-white/5 items-end">
|
||||||
@ -1206,30 +1238,20 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stale Orders Warning Banner */}
|
{/* Stale Orders Warning Banner */}
|
||||||
{(() => {
|
{staleWarningOrders.length > 0 && (
|
||||||
const staleOrders = finalOrders.filter((o) => {
|
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 flex items-start gap-3">
|
||||||
const isPendingNew = isPendingLikeStatus(o.status);
|
<span className="text-yellow-400 text-xl">⚠️</span>
|
||||||
const orderAge = o.timestamp ? Date.now() - o.timestamp : 0;
|
<div className="flex-1">
|
||||||
return isPendingNew && orderAge > 5 * 60 * 1000;
|
<h4 className="text-yellow-400 font-bold text-sm mb-1">
|
||||||
});
|
{staleWarningOrders.length} Stale Order{staleWarningOrders.length > 1 ? 's' : ''} Detected
|
||||||
|
</h4>
|
||||||
if (staleOrders.length === 0) return null;
|
<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
|
||||||
return (
|
without stronger fill or position evidence. The background sync service is re-checking their exchange status.
|
||||||
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 flex items-start gap-3">
|
</p>
|
||||||
<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>
|
</div>
|
||||||
);
|
</div>
|
||||||
})()}
|
)}
|
||||||
|
|
||||||
{positionMismatches.length > 0 && (
|
{positionMismatches.length > 0 && (
|
||||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4">
|
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user