Skip to content

Commit 1dd88bc

Browse files
gisk0chapati23
authored andcommitted
fix(congress-trades): address all review findings
- Move response.ok check inside backOff callback so 429/5xx are retried - fetchCapitolTradesPages() throws on any page failure — no partial caching - Cache string[] directly; remove PAGE_BREAK sentinel join/split - Export mergePageTrades() as a pure function for testability - timeoutMs: 90_000 → 210_000 (5 pages × 3 attempts × 15s + backoff) - Extract MIN_SIGNIFICANT_SCORE constant (was magic number 2) - Fix tautological test → real predicate verifying filter invariants - Add Cleo Fields watchlist surfacing test (score 2 = threshold) - Add mergePageTrades unit tests: single page, multi-page, URL dedup, duplicate URLs across pages, empty pages, pages with no trades - Fix error handling test: stub globalThis.fetch instead of relying on test-env network timeout (was 7.5s, now instant)
1 parent 7fd050c commit 1dd88bc

2 files changed

Lines changed: 178 additions & 64 deletions

File tree

src/sources/congress-trades.ts

Lines changed: 58 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import tickerSectors from "../data/ticker-sectors.json";
2121
const CAPITOL_TRADES_URL = "https://www.capitoltrades.com/trades";
2222
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
2323
const PAGES_TO_FETCH = 5; // ~60 trades instead of ~12
24+
const MIN_SIGNIFICANT_SCORE = 2;
2425

2526
// ============================================================================
2627
// Types
@@ -459,7 +460,7 @@ export const filterTrades = (trades: CongressTrade[]): CongressTrade[] => {
459460
.filter((t) => t.ticker && t.ticker !== "N/A")
460461
.filter((t) => !excludedTickerSet.has(t.ticker))
461462
.filter((t) => t.amountLower >= 100_000)
462-
.filter((t) => t.score >= 2)
463+
.filter((t) => t.score >= MIN_SIGNIFICANT_SCORE)
463464
.sort((a, b) => b.score - a.score);
464465
};
465466

@@ -602,22 +603,36 @@ export const formatDeduplicatedItem = (
602603
// Data Source
603604
// ============================================================================
604605

606+
/**
607+
* Fetch a single page from Capitol Trades. Throws on any non-OK response,
608+
* so the caller (backOff) can retry correctly on retriable HTTP statuses.
609+
*/
605610
const fetchCapitolTradesPage = async (page: number): Promise<string> => {
606611
const url =
607612
page === 1 ? CAPITOL_TRADES_URL : `${CAPITOL_TRADES_URL}?page=${page}`;
608613

609-
const response = await backOff(
610-
() =>
611-
fetch(url, {
614+
return backOff(
615+
async () => {
616+
const response = await fetch(url, {
612617
headers: {
613618
"User-Agent":
614619
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
615620
Accept:
616621
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
617622
"Accept-Language": "en-US,en;q=0.9",
618623
},
619-
signal: AbortSignal.timeout(30_000),
620-
}),
624+
signal: AbortSignal.timeout(15_000),
625+
});
626+
627+
// Throw inside the backOff callback so retriable statuses (429, 5xx) are retried.
628+
if (!response.ok) {
629+
throw new Error(
630+
`Capitol Trades page ${page} returned ${response.status}`,
631+
);
632+
}
633+
634+
return response.text();
635+
},
621636
{
622637
numOfAttempts: 3,
623638
startingDelay: 1000,
@@ -632,71 +647,64 @@ const fetchCapitolTradesPage = async (page: number): Promise<string> => {
632647
},
633648
},
634649
);
635-
636-
if (!response.ok) {
637-
throw new Error(`Capitol Trades page ${page} returned ${response.status}`);
638-
}
639-
640-
return response.text();
641650
};
642651

643-
const fetchCapitolTradesHTML = async (): Promise<string> => {
644-
// Fetch multiple pages sequentially to avoid rate limiting
652+
/**
653+
* Fetch PAGES_TO_FETCH pages from Capitol Trades and return them as an array
654+
* of HTML strings. Throws if page 1 fails; throws if any later page fails (no
655+
* partial caching).
656+
*/
657+
const fetchCapitolTradesPages = async (): Promise<string[]> => {
645658
const pages: string[] = [];
646659
for (let i = 1; i <= PAGES_TO_FETCH; i++) {
647-
try {
648-
const html = await fetchCapitolTradesPage(i);
649-
pages.push(html);
650-
// Small delay between pages to be polite
651-
if (i < PAGES_TO_FETCH) {
652-
await new Promise((resolve) => setTimeout(resolve, 500));
653-
}
654-
} catch (error) {
655-
const message = error instanceof Error ? error.message : String(error);
656-
console.warn(
657-
`[congress-trades] Failed to fetch page ${i}, stopping pagination: ${message}`,
658-
);
659-
break;
660+
const html = await fetchCapitolTradesPage(i);
661+
pages.push(html);
662+
// Small delay between pages to avoid hammering the server
663+
if (i < PAGES_TO_FETCH) {
664+
await new Promise((resolve) => setTimeout(resolve, 500));
660665
}
661666
}
662-
if (pages.length === 0) {
663-
throw new Error("Failed to fetch any pages from Capitol Trades");
664-
}
665667
console.log(`[congress-trades] Fetched ${pages.length} pages`);
666-
// Return pages joined — parseCapitolTradesHTML will parse each independently
667-
return pages.join("\n<!-- PAGE_BREAK -->\n");
668+
return pages;
669+
};
670+
671+
/**
672+
* Merge parsed trades from multiple HTML pages, deduplicating by trade URL.
673+
* Trades without a URL are always included (Capitol Trades always has URLs,
674+
* but defensive against layout changes).
675+
*/
676+
export const mergePageTrades = (htmlPages: string[]): CongressTrade[] => {
677+
const seenUrls = new Set<string>();
678+
const trades: CongressTrade[] = [];
679+
for (const html of htmlPages) {
680+
for (const trade of parseCapitolTradesHTML(html)) {
681+
if (!trade.url || !seenUrls.has(trade.url)) {
682+
if (trade.url) seenUrls.add(trade.url);
683+
trades.push(trade);
684+
}
685+
}
686+
}
687+
return trades;
668688
};
669689

670690
export const congressTradesSource: DataSource = {
671691
name: "Congress Trades",
672692
priority: 6,
673-
timeoutMs: 90_000, // 5 pages × ~15s each worst case
693+
// Budget: 5 pages × 3 attempts × 15s + backoff + 500ms delays ≈ 3.5 min worst-case
694+
timeoutMs: 210_000,
674695

675696
fetch: async (date: Date): Promise<BriefingSection> => {
676697
const dateKey = date.toISOString().split("T")[0];
677698
const cacheKey = `congress-trades-${dateKey}`;
678699

679700
try {
680-
const combinedHtml = await withCache(cacheKey, fetchCapitolTradesHTML, {
701+
const htmlPages = await withCache(cacheKey, fetchCapitolTradesPages, {
681702
ttlMs: CACHE_TTL_MS,
682703
});
683704

684-
// Parse each page separately and merge, deduplicating by trade URL
685-
const pages = combinedHtml.split("\n<!-- PAGE_BREAK -->\n");
686-
const seenUrls = new Set<string>();
687-
const allTrades: CongressTrade[] = [];
688-
for (const html of pages) {
689-
const pageTrades = parseCapitolTradesHTML(html);
690-
for (const trade of pageTrades) {
691-
if (!trade.url || !seenUrls.has(trade.url)) {
692-
if (trade.url) seenUrls.add(trade.url);
693-
allTrades.push(trade);
694-
}
695-
}
696-
}
697-
705+
const allTrades = mergePageTrades(htmlPages);
698706
console.log(
699-
`[congress-trades] Parsed ${allTrades.length} trades across ${pages.length} pages, filtering...`,
707+
`[congress-trades] Parsed ${allTrades.length} trades across ${htmlPages.length} pages, filtering...`,
700708
);
701709

702710
const filtered = filterTrades(allTrades);
@@ -709,7 +717,7 @@ export const congressTradesSource: DataSource = {
709717
items: [
710718
{
711719
text: "No significant trades recently",
712-
detail: `${allTrades.length} trades checked across ${pages.length} pages, none passed filters`,
720+
detail: `${allTrades.length} trades checked across ${htmlPages.length} pages, none passed filters`,
713721
},
714722
],
715723
};

tests/congress-trades.test.ts

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
formatDeduplicatedItem,
1717
formatTradeItem,
1818
getCommitteeRelevance,
19+
mergePageTrades,
1920
mockCongressTradesSource,
2021
parseAmountRange,
2122
parseAmountValue,
@@ -608,17 +609,108 @@ describe("formatTradeItem", () => {
608609
// ============================================================================
609610

610611
describe("congressTradesSource.fetch error handling", () => {
611-
it("returns empty items on fetch failure", async () => {
612-
// The real source will fail in test env (no network / no real URL)
613-
// We test that it gracefully returns empty items
614-
const result = await congressTradesSource.fetch(new Date());
615-
const section = result as {
616-
title: string;
617-
items: readonly { text: string }[];
618-
};
619-
expect(section.title).toBe("Congress Trades");
620-
// Either empty or has "No significant trades" message — both are valid error handling
621-
expect(Array.isArray(section.items)).toBe(true);
612+
it("returns empty items when all pages fail to fetch", async () => {
613+
// Stub global fetch to always reject
614+
const origFetch = globalThis.fetch;
615+
globalThis.fetch = Object.assign(
616+
async () => {
617+
throw new Error("Network error");
618+
},
619+
{ preconnect: () => {} },
620+
) as unknown as typeof fetch;
621+
try {
622+
// Use a unique date so cache doesn't serve a prior result
623+
const result = await congressTradesSource.fetch(new Date("2099-01-01"));
624+
const section = result as {
625+
title: string;
626+
items: readonly { text: string }[];
627+
};
628+
expect(section.title).toBe("Congress Trades");
629+
expect(Array.isArray(section.items)).toBe(true);
630+
expect(section.items.length).toBe(0);
631+
} finally {
632+
globalThis.fetch = origFetch;
633+
}
634+
});
635+
});
636+
637+
// ============================================================================
638+
// mergePageTrades — multi-page merge and deduplication
639+
// ============================================================================
640+
641+
describe("mergePageTrades", () => {
642+
const fixturePath = path.join(__dirname, "fixtures", "capitoltrades.html");
643+
const singlePageHtml = fs.readFileSync(fixturePath, "utf8");
644+
645+
it("returns same trades as single-page parseCapitolTradesHTML", () => {
646+
const expected = parseCapitolTradesHTML(singlePageHtml);
647+
const result = mergePageTrades([singlePageHtml]);
648+
expect(result.length).toBe(expected.length);
649+
});
650+
651+
it("merges trades from two pages into one array", () => {
652+
const singlePageTrades = parseCapitolTradesHTML(singlePageHtml);
653+
const result = mergePageTrades([singlePageHtml, singlePageHtml]);
654+
// All trades on page 2 have the same URL as page 1 → all deduped
655+
// (if any trade lacks a URL it would be duplicated; fixture trades all have URLs)
656+
const uniqueUrls = new Set(
657+
singlePageTrades.map((t) => t.url).filter(Boolean),
658+
);
659+
expect(result.length).toBe(
660+
uniqueUrls.size + (singlePageTrades.length - uniqueUrls.size),
661+
);
662+
});
663+
664+
it("deduplicates trades with the same URL across pages", () => {
665+
// Build two HTML snippets that produce one trade each with identical URLs
666+
const tradeHtml = (url: string, ticker: string) => `
667+
<html><body><table>
668+
<tr><th>H</th></tr>
669+
<tr>
670+
<td><h2 class="politician-name"><a href="/politicians/1">Nancy Pelosi</a></h2>
671+
<div class="politician-info">
672+
<span class="q-field party">Democrat</span>
673+
<span class="q-field chamber">House</span>
674+
<span class="q-field us-state-compact">CA</span>
675+
</div>
676+
</td>
677+
<td>
678+
<h3 class="issuer-name"><a>Corp</a></h3>
679+
<span class="issuer-ticker">${ticker}:US</span>
680+
</td>
681+
<td>Yesterday</td>
682+
<td>15 Mar2026</td>
683+
<td>5 days</td>
684+
<td>Self</td>
685+
<td>buy</td>
686+
<td>1M–5M</td>
687+
<td>$100</td>
688+
<td><a href="${url}">detail</a></td>
689+
</tr>
690+
</table></body></html>`;
691+
692+
const page1 = tradeHtml("/trades/999", "NVDA");
693+
const page2 = tradeHtml("/trades/999", "NVDA"); // same URL → duplicate
694+
const page3 = tradeHtml("/trades/888", "AAPL"); // different URL → not a duplicate
695+
696+
const result = mergePageTrades([page1, page2, page3]);
697+
const tickers = result.map((t) => t.ticker);
698+
// /trades/999 should appear once, /trades/888 once
699+
expect(tickers.filter((t) => t === "NVDA").length).toBe(1);
700+
expect(tickers.filter((t) => t === "AAPL").length).toBe(1);
701+
expect(result.length).toBe(2);
702+
});
703+
704+
it("handles empty pages array", () => {
705+
expect(mergePageTrades([])).toEqual([]);
706+
});
707+
708+
it("handles a page with no parseable trades", () => {
709+
const noTrades =
710+
"<html><body><table><tr><th>H</th></tr></table></body></html>";
711+
const result = mergePageTrades([singlePageHtml, noTrades]);
712+
const expected = parseCapitolTradesHTML(singlePageHtml);
713+
expect(result.length).toBe(expected.length);
622714
});
623715
});
624716

@@ -990,9 +1082,23 @@ describe("sanity check: no significant trades (2026-02-25 default page)", () =>
9901082
expect(names).toContain("Jonathan Jackson");
9911083
});
9921084

993-
it("all trades are below $100K or score below 2", () => {
994-
for (const t of trades) {
995-
expect(t.amountLower < 100_000 || t.score < 2 || t.score >= 2).toBe(true);
1085+
it("Cleo Fields $100K watchlist trades surface at threshold 2", () => {
1086+
// Cleo Fields (2× politician multiplier) × $100K base score 1 = score 2.
1087+
// These trades should now surface since MIN_SIGNIFICANT_SCORE = 2.
1088+
const fieldsTrades = filtered.filter((t) => t.politician === "Cleo Fields");
1089+
expect(fieldsTrades.length).toBeGreaterThanOrEqual(1);
1090+
for (const t of fieldsTrades) {
1091+
expect(t.score).toBe(2);
1092+
expect(t.amountLower).toBe(100_000);
1093+
}
1094+
});
1095+
1096+
it("non-watchlist $100K trades are still excluded (score 1 < threshold)", () => {
1097+
const excluded = trades.filter(
1098+
(t) => t.amountLower >= 100_000 && t.score < 2,
1099+
);
1100+
for (const t of excluded) {
1101+
expect(filtered).not.toContain(t);
9961102
}
9971103
});
9981104
});

0 commit comments

Comments
 (0)