learning_ai_invt_trdg/backend/src/services/stateMerge.ts

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 || ''));
});
};