Ship Node production images, Unraid compose, and Gitea CI/CD (test then push registry images; cron script if no runner). Rebuild dealer flow as a heatmap-first map with integrity gates and chart helpers. Add confluence zone rules, session clock, capture evidence, and tighter 13F/queue/options paths, plus the matching UI and tests.
128 lines
4.9 KiB
JavaScript
128 lines
4.9 KiB
JavaScript
#!/usr/bin/env -S node --experimental-strip-types
|
|
/**
|
|
* Q2 13F deadline helper.
|
|
*
|
|
* node --experimental-strip-types scripts/sync-13f-deadline.ts
|
|
* node --experimental-strip-types scripts/sync-13f-deadline.ts --watch
|
|
*
|
|
* Syncs every enabled tracked fund (Alpine Fox first), then requeues
|
|
* institution-filing refreshes for open portfolio symbols.
|
|
* --watch keeps polling tracked-fund 13Fs until 2026-Q2 lands or 23:59 ET.
|
|
*/
|
|
import { createDb } from '../src/db/client.ts';
|
|
import { queueSecFetch } from '../src/admin/admin.ts';
|
|
import { fetchFund13F } from '../src/mirror/fund13fFetcher.ts';
|
|
import { seedCuratedCusips } from '../src/services/cusipRegistry.ts';
|
|
|
|
const TARGET_QUARTER = '2026-Q2';
|
|
const POLL_MS = 15 * 60_000;
|
|
|
|
function hasQuarter(db: ReturnType<typeof createDb>, cik: string, quarter: string): boolean {
|
|
const row = db
|
|
.prepare(
|
|
`SELECT COUNT(*) AS n FROM institution_filings
|
|
WHERE filer_cik IN (?, ?) AND form='13F-HR' AND reported_quarter=?`,
|
|
)
|
|
.get(cik, cik.replace(/^0+/, ''), quarter) as { n: number };
|
|
return row.n > 0;
|
|
}
|
|
|
|
async function syncFunds(db: ReturnType<typeof createDb>): Promise<{
|
|
funds: Array<{ id: string; name: string; cik: string; result: unknown; hasTarget: boolean }>;
|
|
}> {
|
|
const funds = db
|
|
.prepare(`SELECT id, fund_name, ci_key FROM tracked_funds WHERE enabled=1 ORDER BY fund_name`)
|
|
.all() as Array<{ id: string; fund_name: string; ci_key: string }>;
|
|
const out = [];
|
|
for (const f of funds) {
|
|
if (!/^\d+$/.test(f.ci_key.replace(/^0+/, '')) && !/^\d{10}$/.test(f.ci_key)) {
|
|
console.log(`[skip] ${f.fund_name} has no SEC CIK (${f.ci_key})`);
|
|
continue;
|
|
}
|
|
console.log(`[fund] syncing ${f.fund_name} (${f.ci_key})…`);
|
|
try {
|
|
const result = await fetchFund13F(db, f.id);
|
|
const hasTarget = hasQuarter(db, f.ci_key, TARGET_QUARTER);
|
|
console.log(
|
|
`[fund] ${f.fund_name}: filings=${result.fetched_filings} holdings=${result.holdings_seen} upserted=${result.symbols_upserted} records=${result.records_inserted} ${TARGET_QUARTER}=${hasTarget ? 'YES' : 'not yet'}`,
|
|
);
|
|
if (result.unresolved.length) {
|
|
const uniq = [...new Map(result.unresolved.map((u) => [u.cusip, u])).values()];
|
|
console.log(`[fund] unresolved CUSIPs (${uniq.length}): ${uniq.slice(0, 12).map((u) => `${u.cusip}/${u.issuerName}`).join('; ')}`);
|
|
}
|
|
out.push({ id: f.id, name: f.fund_name, cik: f.ci_key, result, hasTarget });
|
|
} catch (e) {
|
|
console.error(`[fund] ${f.fund_name} FAILED:`, e instanceof Error ? e.message : e);
|
|
out.push({ id: f.id, name: f.fund_name, cik: f.ci_key, result: { error: String(e) }, hasTarget: false });
|
|
}
|
|
}
|
|
return { funds: out };
|
|
}
|
|
|
|
function requeuePortfolio(db: ReturnType<typeof createDb>): string[] {
|
|
const rows = db
|
|
.prepare(`SELECT DISTINCT symbol FROM portfolio_holdings WHERE status='open' ORDER BY symbol`)
|
|
.all() as Array<{ symbol: string }>;
|
|
const symbols = rows.map((r) => r.symbol.toUpperCase());
|
|
db.prepare("DELETE FROM queue_state WHERE key LIKE 'source_cooldown:%' AND key LIKE '%sec%'").run();
|
|
for (const sym of symbols) {
|
|
queueSecFetch(db, sym);
|
|
}
|
|
console.log(`[queue] pending sec-fetch for ${symbols.join(', ')}`);
|
|
return symbols;
|
|
}
|
|
|
|
function etDeadlineMs(now = Date.now()): number {
|
|
const parts = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: 'America/New_York',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).formatToParts(new Date(now));
|
|
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '';
|
|
const y = get('year');
|
|
const m = get('month');
|
|
const d = get('day');
|
|
return Date.parse(`${y}-${m}-${d}T23:59:00-04:00`);
|
|
}
|
|
|
|
async function main() {
|
|
const watch = process.argv.includes('--watch');
|
|
const db = createDb();
|
|
seedCuratedCusips(db);
|
|
|
|
const first = await syncFunds(db);
|
|
requeuePortfolio(db);
|
|
|
|
const alpine = first.funds.find((f) => /alpine/i.test(f.name));
|
|
if (alpine?.hasTarget) {
|
|
console.log(`DONE Alpine Fox ${TARGET_QUARTER} is in institution_filings`);
|
|
return;
|
|
}
|
|
|
|
if (!watch) {
|
|
console.log(`${TARGET_QUARTER} not filed yet for Alpine Fox. Re-run with --watch to poll.`);
|
|
return;
|
|
}
|
|
|
|
const until = etDeadlineMs();
|
|
console.log(`[watch] polling every ${POLL_MS / 60000}m until ${new Date(until).toISOString()}`);
|
|
while (Date.now() < until) {
|
|
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
const next = await syncFunds(db);
|
|
const hit = next.funds.find((f) => /alpine/i.test(f.name) && f.hasTarget);
|
|
if (hit) {
|
|
requeuePortfolio(db);
|
|
console.log(`DONE Alpine Fox ${TARGET_QUARTER} ingested`);
|
|
return;
|
|
}
|
|
console.log(`[watch] still no ${TARGET_QUARTER} for Alpine Fox at ${new Date().toISOString()}`);
|
|
}
|
|
console.log(`FAILED Alpine Fox ${TARGET_QUARTER} not on EDGAR before deadline`);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e instanceof Error ? e.message : e);
|
|
process.exit(1);
|
|
});
|