269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
export interface RuntimePositionSnapshot {
|
|
id: string;
|
|
symbol: string;
|
|
side: 'BUY' | 'SELL';
|
|
size: number;
|
|
entryPrice: number;
|
|
currentPrice: number;
|
|
stopLoss: number;
|
|
takeProfit: number;
|
|
unrealizedPnl: number;
|
|
unrealizedPnlPercent: number;
|
|
marketValue: number;
|
|
userId?: string;
|
|
profileId?: string;
|
|
profileName?: string;
|
|
tradeId?: string;
|
|
}
|
|
|
|
export interface RuntimeOrderSnapshot {
|
|
id: string;
|
|
symbol: string;
|
|
type: string;
|
|
side: string;
|
|
qty: number;
|
|
price: number;
|
|
status: string;
|
|
timestamp: number;
|
|
userId?: string;
|
|
profileId?: string;
|
|
trade_id?: string;
|
|
subTag?: string;
|
|
action?: string;
|
|
source?: 'BOT' | 'MANUAL';
|
|
created_at?: string;
|
|
}
|
|
|
|
const STABLE_SYNC_SUFFIX = '-SYNC';
|
|
|
|
const normalizeTradeId = (value?: string): string => String(value || '').trim();
|
|
|
|
const hasStableTradeId = (tradeId?: string): boolean => {
|
|
const normalized = normalizeTradeId(tradeId);
|
|
return normalized.length > 0 && !normalized.endsWith(STABLE_SYNC_SUFFIX);
|
|
};
|
|
|
|
const toTimestamp = (value: number | string | undefined, createdAt?: string): number => {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
if (typeof value === 'string') {
|
|
const numeric = Number(value);
|
|
if (Number.isFinite(numeric) && numeric > 0) return numeric;
|
|
const parsed = Date.parse(value);
|
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
}
|
|
if (createdAt) {
|
|
const parsedCreatedAt = Date.parse(createdAt);
|
|
if (Number.isFinite(parsedCreatedAt) && parsedCreatedAt > 0) return parsedCreatedAt;
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const toNumber = (value: unknown): number => {
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) ? numeric : 0;
|
|
};
|
|
|
|
const positive = (value: unknown): number => {
|
|
const numeric = toNumber(value);
|
|
return numeric > 0 ? numeric : 0;
|
|
};
|
|
|
|
const positionScore = (position: RuntimePositionSnapshot): number => {
|
|
const tradeScore = hasStableTradeId(position.tradeId) ? 4 : (normalizeTradeId(position.tradeId) ? 2 : 0);
|
|
const profileScore = position.profileId ? 3 : 0;
|
|
const userScore = position.userId ? 2 : 0;
|
|
const namedScore = position.profileName ? 1 : 0;
|
|
const priceScore = positive(position.currentPrice) > 0 ? 1 : 0;
|
|
const notionalScore = positive(position.entryPrice) * positive(position.size);
|
|
return tradeScore + profileScore + userScore + namedScore + priceScore + Math.min(notionalScore, 100_000);
|
|
};
|
|
|
|
const mergePosition = (
|
|
left: RuntimePositionSnapshot,
|
|
right: RuntimePositionSnapshot
|
|
): RuntimePositionSnapshot => {
|
|
const leftScore = positionScore(left);
|
|
const rightScore = positionScore(right);
|
|
const preferred = rightScore >= leftScore ? right : left;
|
|
const fallback = preferred === right ? left : right;
|
|
|
|
return {
|
|
...fallback,
|
|
...preferred,
|
|
id: preferred.id || fallback.id,
|
|
symbol: preferred.symbol || fallback.symbol,
|
|
side: preferred.side || fallback.side,
|
|
size: positive(preferred.size) > 0 ? preferred.size : fallback.size,
|
|
entryPrice: positive(preferred.entryPrice) > 0 ? preferred.entryPrice : fallback.entryPrice,
|
|
currentPrice: positive(preferred.currentPrice) > 0 ? preferred.currentPrice : fallback.currentPrice,
|
|
stopLoss: positive(preferred.stopLoss) > 0 ? preferred.stopLoss : fallback.stopLoss,
|
|
takeProfit: positive(preferred.takeProfit) > 0 ? preferred.takeProfit : fallback.takeProfit,
|
|
marketValue: positive(preferred.marketValue) > 0 ? preferred.marketValue : fallback.marketValue,
|
|
profileId: preferred.profileId || fallback.profileId,
|
|
userId: preferred.userId || fallback.userId,
|
|
profileName: preferred.profileName || fallback.profileName,
|
|
tradeId: normalizeTradeId(preferred.tradeId) || normalizeTradeId(fallback.tradeId) || undefined
|
|
};
|
|
};
|
|
|
|
const fallbackPositionKey = (position: RuntimePositionSnapshot): string => {
|
|
const owner = `${position.userId || 'global'}:${position.profileId || 'global'}`;
|
|
return `${owner}:${position.symbol}:${position.side}`;
|
|
};
|
|
|
|
const orderStatusRank = (status?: string): number => {
|
|
const normalized = String(status || '').trim().toLowerCase();
|
|
if (normalized === 'filled') return 6;
|
|
if (normalized === 'partially_filled' || normalized === 'partially-filled') return 5;
|
|
if (normalized === 'canceled' || normalized === 'cancelled' || normalized === 'rejected' || normalized === 'expired') return 4;
|
|
if (normalized === 'pending_new' || normalized === 'pending' || normalized === 'accepted' || normalized === 'new') return 2;
|
|
if (normalized === 'unknown') return 1;
|
|
return 2;
|
|
};
|
|
|
|
const pickReliableStatus = (left: RuntimeOrderSnapshot, right: RuntimeOrderSnapshot): string => {
|
|
const leftStatus = String(left.status || '').trim().toLowerCase() || 'unknown';
|
|
const rightStatus = String(right.status || '').trim().toLowerCase() || 'unknown';
|
|
const leftRank = orderStatusRank(leftStatus);
|
|
const rightRank = orderStatusRank(rightStatus);
|
|
if (leftRank !== rightRank) {
|
|
return rightRank > leftRank ? rightStatus : leftStatus;
|
|
}
|
|
const leftTs = toTimestamp(left.timestamp, left.created_at);
|
|
const rightTs = toTimestamp(right.timestamp, right.created_at);
|
|
return rightTs >= leftTs ? rightStatus : leftStatus;
|
|
};
|
|
|
|
const normalizeOrderSource = (order: RuntimeOrderSnapshot): 'BOT' | 'MANUAL' => {
|
|
const normalized = String(order.source || '').trim().toUpperCase();
|
|
if (normalized === 'BOT' || normalized === 'MANUAL') return normalized;
|
|
return order.profileId ? 'BOT' : 'MANUAL';
|
|
};
|
|
|
|
const orderScore = (order: RuntimeOrderSnapshot): number => {
|
|
const profileScore = order.profileId ? 2 : 0;
|
|
const userScore = order.userId ? 1 : 0;
|
|
const tradeScore = normalizeTradeId(order.trade_id) ? 2 : 0;
|
|
const actionScore = order.action ? 1 : 0;
|
|
const statusScore = orderStatusRank(order.status);
|
|
return profileScore + userScore + tradeScore + actionScore + statusScore;
|
|
};
|
|
|
|
const mergeOrder = (
|
|
left: RuntimeOrderSnapshot,
|
|
right: RuntimeOrderSnapshot
|
|
): RuntimeOrderSnapshot => {
|
|
const leftScore = orderScore(left);
|
|
const rightScore = orderScore(right);
|
|
const preferred = rightScore >= leftScore ? right : left;
|
|
const fallback = preferred === right ? left : right;
|
|
const preferredTs = toTimestamp(preferred.timestamp, preferred.created_at);
|
|
const fallbackTs = toTimestamp(fallback.timestamp, fallback.created_at);
|
|
|
|
return {
|
|
...fallback,
|
|
...preferred,
|
|
id: preferred.id || fallback.id,
|
|
symbol: preferred.symbol || fallback.symbol,
|
|
type: preferred.type || fallback.type,
|
|
side: preferred.side || fallback.side,
|
|
qty: positive(preferred.qty) > 0 ? preferred.qty : fallback.qty,
|
|
price: positive(preferred.price) > 0 ? preferred.price : fallback.price,
|
|
timestamp: Math.max(preferredTs, fallbackTs),
|
|
status: pickReliableStatus(left, right),
|
|
userId: preferred.userId || fallback.userId,
|
|
profileId: preferred.profileId || fallback.profileId,
|
|
trade_id: normalizeTradeId(preferred.trade_id) || normalizeTradeId(fallback.trade_id) || undefined,
|
|
subTag: preferred.subTag || fallback.subTag,
|
|
action: preferred.action || fallback.action,
|
|
source: normalizeOrderSource({
|
|
...fallback,
|
|
...preferred,
|
|
profileId: preferred.profileId || fallback.profileId
|
|
})
|
|
};
|
|
};
|
|
|
|
export const mergePositionSnapshots = (
|
|
snapshotsBySource: RuntimePositionSnapshot[][]
|
|
): RuntimePositionSnapshot[] => {
|
|
const mergedByKey = new Map<string, RuntimePositionSnapshot>();
|
|
for (const position of snapshotsBySource.flat()) {
|
|
const tradeId = normalizeTradeId(position.tradeId);
|
|
const key = hasStableTradeId(tradeId)
|
|
? `trade:${tradeId}`
|
|
: `fallback:${fallbackPositionKey(position)}`;
|
|
const existing = mergedByKey.get(key);
|
|
if (!existing) {
|
|
mergedByKey.set(key, { ...position, tradeId: tradeId || undefined });
|
|
continue;
|
|
}
|
|
mergedByKey.set(key, mergePosition(existing, { ...position, tradeId: tradeId || undefined }));
|
|
}
|
|
|
|
const groupedByOwnerSymbol = new Map<string, RuntimePositionSnapshot[]>();
|
|
for (const position of mergedByKey.values()) {
|
|
const groupKey = fallbackPositionKey(position);
|
|
const group = groupedByOwnerSymbol.get(groupKey) || [];
|
|
group.push(position);
|
|
groupedByOwnerSymbol.set(groupKey, group);
|
|
}
|
|
|
|
const output: RuntimePositionSnapshot[] = [];
|
|
for (const group of groupedByOwnerSymbol.values()) {
|
|
const stable = group.filter((position) => hasStableTradeId(position.tradeId));
|
|
if (stable.length > 0) {
|
|
output.push(...stable);
|
|
continue;
|
|
}
|
|
|
|
const reduced = group.reduce((acc, current) => (acc ? mergePosition(acc, current) : current), undefined as RuntimePositionSnapshot | undefined);
|
|
if (reduced) output.push(reduced);
|
|
}
|
|
|
|
return output.sort((a, b) => {
|
|
const profileCompare = String(a.profileId || '').localeCompare(String(b.profileId || ''));
|
|
if (profileCompare !== 0) return profileCompare;
|
|
const symbolCompare = String(a.symbol || '').localeCompare(String(b.symbol || ''));
|
|
if (symbolCompare !== 0) return symbolCompare;
|
|
return String(a.tradeId || a.id).localeCompare(String(b.tradeId || b.id));
|
|
});
|
|
};
|
|
|
|
export const mergeOrderSnapshots = (
|
|
snapshotsBySource: RuntimeOrderSnapshot[][]
|
|
): RuntimeOrderSnapshot[] => {
|
|
const mergedByOrderId = new Map<string, RuntimeOrderSnapshot>();
|
|
|
|
for (const order of snapshotsBySource.flat()) {
|
|
const orderId = String(order.id || '').trim();
|
|
if (!orderId) continue;
|
|
|
|
const normalizedOrder: RuntimeOrderSnapshot = {
|
|
...order,
|
|
id: orderId,
|
|
userId: order.userId || undefined,
|
|
profileId: order.profileId || undefined,
|
|
trade_id: normalizeTradeId(order.trade_id) || undefined,
|
|
subTag: String((order as any).subTag || (order as any).subtag || (order as any).sub_tag || '').trim() || undefined,
|
|
status: String(order.status || 'unknown').toLowerCase(),
|
|
timestamp: toTimestamp(order.timestamp, order.created_at),
|
|
source: normalizeOrderSource(order)
|
|
};
|
|
|
|
const existing = mergedByOrderId.get(orderId);
|
|
if (!existing) {
|
|
mergedByOrderId.set(orderId, normalizedOrder);
|
|
continue;
|
|
}
|
|
|
|
mergedByOrderId.set(orderId, mergeOrder(existing, normalizedOrder));
|
|
}
|
|
|
|
return Array.from(mergedByOrderId.values()).sort((a, b) => {
|
|
const tsDiff = toTimestamp(b.timestamp, b.created_at) - toTimestamp(a.timestamp, a.created_at);
|
|
if (tsDiff !== 0) return tsDiff;
|
|
return String(a.id || '').localeCompare(String(b.id || ''));
|
|
});
|
|
};
|