197 lines
6.7 KiB
TypeScript
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);
|
|
});
|
|
|