298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
import 'dotenv/config';
|
|
import { createHash, randomUUID } from 'crypto';
|
|
import { config, loadDynamicConfig } from '../src/config/index.js';
|
|
import { normalizeOrderAction, normalizeTradeSide } from '../src/domain/tradingEnums.js';
|
|
import { healthTracker } from '../src/services/healthTracker.js';
|
|
import {
|
|
ReconciliationBackfillAuditInsert,
|
|
ReconciliationBackfillOrderInsert,
|
|
supabaseService
|
|
} from '../src/services/SupabaseService.js';
|
|
import { buildAlpacaSubTag } from '../src/utils/alpacaSubTag.js';
|
|
|
|
type CliOptions = {
|
|
apply: boolean;
|
|
tradeIds: string[];
|
|
};
|
|
|
|
type TradeSnapshot = {
|
|
profileId: string;
|
|
userId: string;
|
|
tradeId: string;
|
|
symbol: string;
|
|
entrySide: 'BUY' | 'SELL';
|
|
entryQty: number;
|
|
exitQty: number;
|
|
openQty: number;
|
|
entryAvgPrice: number;
|
|
};
|
|
|
|
const EPSILON = 1e-8;
|
|
const ORDER_ID_PREFIX = 'MANOVR';
|
|
|
|
const parseOptions = (argv: string[]): CliOptions => {
|
|
const tradeIds = new Set<string>();
|
|
let apply = false;
|
|
|
|
for (const arg of argv) {
|
|
if (arg === '--apply') {
|
|
apply = true;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--trade=')) {
|
|
const value = String(arg.slice('--trade='.length) || '').trim();
|
|
if (value) tradeIds.add(value);
|
|
}
|
|
}
|
|
|
|
return {
|
|
apply,
|
|
tradeIds: Array.from(tradeIds)
|
|
};
|
|
};
|
|
|
|
const toNumber = (value: unknown): number => {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
};
|
|
|
|
const expectedExitSide = (entrySide: 'BUY' | 'SELL'): 'BUY' | 'SELL' => {
|
|
return entrySide === 'BUY' ? 'SELL' : 'BUY';
|
|
};
|
|
|
|
const buildManualOverrideOrderId = (profileId: string, tradeId: string): string => {
|
|
const digest = createHash('md5')
|
|
.update(`${profileId}:${tradeId}:manual_override_v1`)
|
|
.digest('hex');
|
|
return `${ORDER_ID_PREFIX}-${digest}`;
|
|
};
|
|
|
|
const buildTradeSnapshots = (rows: any[]): Map<string, TradeSnapshot> => {
|
|
const byTrade = new Map<string, TradeSnapshot>();
|
|
|
|
for (const row of rows || []) {
|
|
const tradeId = String(row.trade_id || '').trim();
|
|
const profileId = String(row.profile_id || '').trim();
|
|
if (!tradeId || !profileId) continue;
|
|
|
|
const key = `${profileId}::${tradeId}`;
|
|
const qty = toNumber(row.qty ?? row.quantity);
|
|
if (!(qty > EPSILON)) continue;
|
|
|
|
const side = normalizeTradeSide(String(row.side || 'BUY'));
|
|
const action = normalizeOrderAction(row.action || undefined);
|
|
const symbol = String(row.symbol || '').trim();
|
|
const userId = String(row.user_id || '').trim();
|
|
const price = toNumber(row.price);
|
|
|
|
let snapshot = byTrade.get(key);
|
|
if (!snapshot) {
|
|
snapshot = {
|
|
profileId,
|
|
userId,
|
|
tradeId,
|
|
symbol,
|
|
entrySide: side,
|
|
entryQty: 0,
|
|
exitQty: 0,
|
|
openQty: 0,
|
|
entryAvgPrice: 0
|
|
};
|
|
byTrade.set(key, snapshot);
|
|
}
|
|
|
|
const resolvedAction = action || (side === snapshot.entrySide ? 'ENTRY' : 'EXIT');
|
|
if (resolvedAction === 'ENTRY') {
|
|
if (!(snapshot.entryQty > EPSILON)) {
|
|
snapshot.entrySide = side;
|
|
}
|
|
const nextQty = snapshot.entryQty + qty;
|
|
snapshot.entryAvgPrice = nextQty > EPSILON
|
|
? ((snapshot.entryAvgPrice * snapshot.entryQty) + (price * qty)) / nextQty
|
|
: snapshot.entryAvgPrice;
|
|
snapshot.entryQty = nextQty;
|
|
} else {
|
|
snapshot.exitQty += qty;
|
|
}
|
|
|
|
if (!snapshot.symbol && symbol) snapshot.symbol = symbol;
|
|
if (!snapshot.userId && userId) snapshot.userId = userId;
|
|
snapshot.openQty = Number((snapshot.entryQty - snapshot.exitQty).toFixed(8));
|
|
}
|
|
|
|
for (const [key, snapshot] of Array.from(byTrade.entries())) {
|
|
if (!(snapshot.openQty > EPSILON)) {
|
|
byTrade.delete(key);
|
|
}
|
|
}
|
|
|
|
return byTrade;
|
|
};
|
|
|
|
const run = async (): Promise<void> => {
|
|
const options = parseOptions(process.argv.slice(2));
|
|
if (options.tradeIds.length === 0) {
|
|
throw new Error('Provide at least one --trade=<TRADE_ID>.');
|
|
}
|
|
|
|
await loadDynamicConfig(supabaseService);
|
|
|
|
healthTracker.recordTradingControl({
|
|
mode: 'PAUSED',
|
|
lastChangedBy: 'maintenance-script',
|
|
lastChangedAt: Date.now(),
|
|
reason: 'Manual override close cycle'
|
|
});
|
|
|
|
const client = supabaseService.getClient();
|
|
if (!client) {
|
|
throw new Error('Supabase client is not available.');
|
|
}
|
|
|
|
const { data: lifecycleRows, error: lifecycleError } = await client
|
|
.from('orders')
|
|
.select('profile_id,user_id,trade_id,symbol,side,action,qty,quantity,price,status')
|
|
.in('trade_id', options.tradeIds)
|
|
.in('status', ['filled', 'partially_filled', 'partially-filled']);
|
|
|
|
if (lifecycleError) {
|
|
throw new Error(`Failed to fetch lifecycle rows: ${lifecycleError.message}`);
|
|
}
|
|
|
|
const snapshots = buildTradeSnapshots(lifecycleRows || []);
|
|
const batchId = `MANOVR-BATCH-${randomUUID()}`;
|
|
const nowIso = new Date().toISOString();
|
|
const nowTs = Date.now();
|
|
|
|
const candidateOrders: ReconciliationBackfillOrderInsert[] = [];
|
|
const preAuditRows: ReconciliationBackfillAuditInsert[] = [];
|
|
const skipped: Array<Record<string, any>> = [];
|
|
|
|
for (const tradeId of options.tradeIds) {
|
|
const matching = Array.from(snapshots.values()).filter((row) => row.tradeId === tradeId);
|
|
if (matching.length === 0) {
|
|
skipped.push({ tradeId, reason: 'trade_not_open_or_not_found' });
|
|
continue;
|
|
}
|
|
if (matching.length > 1) {
|
|
skipped.push({ tradeId, reason: 'ambiguous_trade_multiple_profiles' });
|
|
continue;
|
|
}
|
|
|
|
const trade = matching[0];
|
|
if (!(trade.openQty > EPSILON)) {
|
|
skipped.push({ tradeId, reason: 'trade_not_open' });
|
|
continue;
|
|
}
|
|
|
|
const orderId = buildManualOverrideOrderId(trade.profileId, trade.tradeId);
|
|
const side = expectedExitSide(trade.entrySide);
|
|
const subTag = buildAlpacaSubTag({
|
|
profileId: trade.profileId,
|
|
tradeId: trade.tradeId,
|
|
intent: 'EXIT'
|
|
}) || undefined;
|
|
const fillPrice = trade.entryAvgPrice > EPSILON ? trade.entryAvgPrice : 0;
|
|
|
|
const order: ReconciliationBackfillOrderInsert = {
|
|
user_id: trade.userId,
|
|
profile_id: trade.profileId,
|
|
order_id: orderId,
|
|
symbol: trade.symbol,
|
|
type: 'market',
|
|
side,
|
|
qty: Number(trade.openQty.toFixed(8)),
|
|
quantity: Number(trade.openQty.toFixed(8)),
|
|
price: Number(fillPrice.toFixed(8)),
|
|
status: 'filled',
|
|
timestamp: nowTs,
|
|
filled_at: nowIso,
|
|
trade_id: trade.tradeId,
|
|
action: 'EXIT',
|
|
source: 'BOT',
|
|
sub_tag: subTag
|
|
};
|
|
candidateOrders.push(order);
|
|
|
|
preAuditRows.push({
|
|
batch_id: batchId,
|
|
profile_id: trade.profileId,
|
|
symbol: trade.symbol,
|
|
trade_id: trade.tradeId,
|
|
exchange_order_id: null,
|
|
exchange_client_order_id: null,
|
|
backfill_order_id: orderId,
|
|
filled_qty: order.qty,
|
|
filled_price: order.price,
|
|
filled_at: order.filled_at || null,
|
|
dry_run: !options.apply,
|
|
decision: options.apply ? 'MANUAL_OVERRIDE_PENDING' : 'MANUAL_OVERRIDE_DRY',
|
|
reason: 'manual_override_user_approved_no_exchange_evidence',
|
|
metadata: {
|
|
openQtyBefore: trade.openQty,
|
|
entryQty: trade.entryQty,
|
|
exitQty: trade.exitQty,
|
|
fillPriceBasis: 'entry_weighted_avg_price'
|
|
}
|
|
});
|
|
}
|
|
|
|
if (preAuditRows.length > 0) {
|
|
const preSaved = await supabaseService.insertReconciliationBackfillAuditRows(preAuditRows);
|
|
if (!preSaved) {
|
|
throw new Error('Failed to save manual override pre-audit rows.');
|
|
}
|
|
}
|
|
|
|
let insertedRows = 0;
|
|
if (options.apply && candidateOrders.length > 0) {
|
|
const orderIds = candidateOrders.map((row) => row.order_id);
|
|
const existingBefore = await supabaseService.getExistingOrderIds(orderIds);
|
|
const ok = await supabaseService.upsertReconciliationBackfillOrders(candidateOrders);
|
|
if (!ok) {
|
|
throw new Error('Failed to apply manual override rows.');
|
|
}
|
|
const existingAfter = await supabaseService.getExistingOrderIds(orderIds);
|
|
insertedRows = candidateOrders.filter((row) => !existingBefore.has(row.order_id) && existingAfter.has(row.order_id)).length;
|
|
|
|
const postAuditRows: ReconciliationBackfillAuditInsert[] = candidateOrders.map((row) => ({
|
|
batch_id: batchId,
|
|
profile_id: row.profile_id,
|
|
symbol: row.symbol,
|
|
trade_id: row.trade_id,
|
|
exchange_order_id: null,
|
|
exchange_client_order_id: null,
|
|
backfill_order_id: row.order_id,
|
|
filled_qty: row.qty,
|
|
filled_price: row.price,
|
|
filled_at: row.filled_at || null,
|
|
dry_run: false,
|
|
decision: existingBefore.has(row.order_id) ? 'MANUAL_OVERRIDE_SKIP_EXISTING' : 'MANUAL_OVERRIDE_APPLIED',
|
|
reason: existingBefore.has(row.order_id) ? 'already_exists' : 'manual_override_inserted',
|
|
metadata: {
|
|
matchedBy: 'manual_override'
|
|
},
|
|
applied_at: !existingBefore.has(row.order_id) ? new Date().toISOString() : null
|
|
}));
|
|
const postSaved = await supabaseService.insertReconciliationBackfillAuditRows(postAuditRows);
|
|
if (!postSaved) {
|
|
throw new Error('Failed to save manual override post-audit rows.');
|
|
}
|
|
}
|
|
|
|
console.log(JSON.stringify({
|
|
mode: options.apply ? 'apply' : 'dry-run',
|
|
batchId,
|
|
requestedTrades: options.tradeIds,
|
|
proposedRows: candidateOrders.length,
|
|
insertedRows,
|
|
skipped
|
|
}, null, 2));
|
|
};
|
|
|
|
run().catch((error) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(JSON.stringify({ error: message }, null, 2));
|
|
process.exit(1);
|
|
});
|