learning_ai_invt_trdg/backend/reconcileClosedOrderFillData.ts

197 lines
6.7 KiB
TypeScript

import 'dotenv/config';
import Alpaca from '@alpacahq/alpaca-trade-api';
import { createClient } from '@supabase/supabase-js';
type OrderRow = {
id?: string;
order_id?: string | null;
symbol?: string | null;
status?: string | null;
qty?: number | string | null;
price?: number | string | null;
source?: string | null;
created_at?: string | null;
updated_at?: string | null;
filled_at?: string | null;
};
const args = process.argv.slice(2);
const applyMode = args.includes('--apply');
const lookbackArg = args.find((arg) => arg.startsWith('--lookback-hours='));
const LOOKBACK_HOURS = Number((lookbackArg ? lookbackArg.split('=')[1] : '') || '48');
const PAGE_SIZE = 1000;
const SYNTHETIC_PREFIXES = ['BFILL-', 'MANOVR-', 'RECON-BF', 'RECON-', 'SYNC-'];
const supabaseUrl = String(process.env.SUPABASE_URL || '').trim();
const supabaseKey = String(
process.env.SUPABASE_KEY
|| process.env.SUPABASE_SERVICE_ROLE_KEY
|| process.env.SUPABASE_ANON_KEY
|| ''
).trim();
const alpacaApiKey = String(process.env.ALPACA_API_KEY || '').trim();
const alpacaApiSecret = String(process.env.ALPACA_API_SECRET || '').trim();
const paperTrading = String(process.env.PAPER_TRADING || 'true').toLowerCase() === 'true';
if (!supabaseUrl || !supabaseKey) {
throw new Error('Missing Supabase credentials.');
}
if (!alpacaApiKey || !alpacaApiSecret) {
throw new Error('Missing Alpaca credentials.');
}
if (!Number.isFinite(LOOKBACK_HOURS) || LOOKBACK_HOURS <= 0) {
throw new Error(`Invalid --lookback-hours value: ${lookbackArg}`);
}
const supabase = createClient(supabaseUrl, supabaseKey);
const alpaca = new (Alpaca as any)({
keyId: alpacaApiKey,
secretKey: alpacaApiSecret,
paper: paperTrading
});
const toNumber = (value: unknown): number => {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : 0;
};
const normalizeStatus = (value: unknown): string => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'partially-filled') return 'partially_filled';
return normalized;
};
const isSyntheticOrderId = (orderId: string): boolean => {
const normalized = String(orderId || '').trim().toUpperCase();
if (!normalized) return false;
return SYNTHETIC_PREFIXES.some((prefix) => normalized.startsWith(prefix));
};
const pctDiff = (left: number, right: number): number => {
const denom = Math.max(Math.abs(left), Math.abs(right), 1e-9);
return Math.abs(left - right) / denom;
};
const run = async () => {
const sinceIso = new Date(Date.now() - LOOKBACK_HOURS * 60 * 60 * 1000).toISOString();
const rows: OrderRow[] = [];
let offset = 0;
for (;;) {
const { data, error } = await supabase
.from('orders')
.select('id,order_id,symbol,status,qty,price,source,created_at,updated_at,filled_at')
.in('status', ['filled', 'partially_filled', 'partially-filled'])
.gte('created_at', sinceIso)
.order('created_at', { ascending: false })
.range(offset, offset + PAGE_SIZE - 1);
if (error) throw error;
const chunk = (data || []) as OrderRow[];
if (!chunk.length) break;
rows.push(...chunk);
if (chunk.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
const candidates = rows.filter((row) => {
const orderId = String(row.order_id || '').trim();
if (!orderId) return false;
if (isSyntheticOrderId(orderId)) return false;
const source = String(row.source || '').trim().toUpperCase();
if (source === 'MANUAL') return false;
return true;
});
const summary = {
mode: applyMode ? 'apply' : 'dry-run',
lookbackHours: LOOKBACK_HOURS,
sinceIso,
totalRows: rows.length,
candidates: candidates.length,
checked: 0,
exchangeMissing: 0,
noFillOnExchange: 0,
driftDetected: 0,
updated: 0,
samples: [] as Array<Record<string, unknown>>
};
for (const row of candidates) {
const orderId = String(row.order_id || '').trim();
summary.checked += 1;
let exchangeOrder: any;
try {
exchangeOrder = await (alpaca as any).getOrder(orderId);
} catch {
summary.exchangeMissing += 1;
continue;
}
if (!exchangeOrder) {
summary.exchangeMissing += 1;
continue;
}
const exchangeStatus = normalizeStatus(exchangeOrder.status);
const exchangeQty = toNumber(exchangeOrder.filled_qty ?? exchangeOrder.filledQty ?? exchangeOrder.filled_quantity);
const exchangePrice = toNumber(exchangeOrder.filled_avg_price);
if (!(exchangeQty > 0) || !(exchangePrice > 0)) {
summary.noFillOnExchange += 1;
continue;
}
const dbStatus = normalizeStatus(row.status);
const dbQty = toNumber(row.qty);
const dbPrice = toNumber(row.price);
const qtyDrift = dbQty <= 0 || pctDiff(exchangeQty, dbQty) > 1e-6;
const priceDrift = dbPrice <= 0 || pctDiff(exchangePrice, dbPrice) > 5e-4;
const statusDrift = exchangeStatus && exchangeStatus !== dbStatus;
if (!qtyDrift && !priceDrift && !statusDrift) continue;
summary.driftDetected += 1;
if (summary.samples.length < 20) {
summary.samples.push({
order_id: orderId,
symbol: row.symbol,
db_status: row.status,
ex_status: exchangeOrder.status,
db_qty: dbQty,
ex_qty: exchangeQty,
db_price: dbPrice,
ex_price: exchangePrice
});
}
if (!applyMode) continue;
const filledAtRaw = String(exchangeOrder.filled_at || '').trim();
const parsedFilledAt = filledAtRaw ? Date.parse(filledAtRaw) : NaN;
const filledAtIso = Number.isFinite(parsedFilledAt) && parsedFilledAt > 0
? new Date(parsedFilledAt).toISOString()
: new Date().toISOString();
const { error: updateError } = await supabase
.from('orders')
.update({
status: exchangeStatus || 'filled',
qty: exchangeQty,
price: exchangePrice,
filled_at: filledAtIso,
updated_at: new Date().toISOString()
})
.eq('order_id', orderId);
if (updateError) throw updateError;
summary.updated += 1;
}
console.log(JSON.stringify(summary, null, 2));
};
run().catch((error) => {
console.error(JSON.stringify({
error: error instanceof Error ? error.message : String(error)
}, null, 2));
process.exit(1);
});