learning_ai_invt_trdg/backend/manualOverrideCloseTrades.ts

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