feat: Unraid deploy, dealer-flow heatmap, confluence zones, 13F capture
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.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user