Skip to content

Commit dc29f5b

Browse files
authored
fix: sum grouped congress trade ranges (#26)
1 parent 76369dd commit dc29f5b

3 files changed

Lines changed: 86 additions & 19 deletions

File tree

osv-scanner.toml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,37 @@
11
[[IgnoredVulns]]
22
id = "GHSA-2g4f-4pwh-qvx6"
33
reason = "ajv 6.12.6 ReDoS via $data option — transitive dep, no upgrade path available"
4-
ignoreUntil = 2026-06-01
4+
ignoreUntil = 2026-09-01
55

66
[[IgnoredVulns]]
77
id = "GHSA-3ppc-4f35-3m26"
88
reason = "minimatch ReDoS via repeated wildcards — transitive dep of trunk tooling, no direct upgrade path"
9-
ignoreUntil = 2026-06-01
9+
ignoreUntil = 2026-09-01
1010

1111
[[IgnoredVulns]]
1212
id = "GHSA-23c5-xmqv-rm74"
1313
reason = "minimatch ReDoS via nested *() extglobs — transitive dep of trunk tooling, no upgrade path"
14-
ignoreUntil = 2026-06-01
14+
ignoreUntil = 2026-09-01
1515

1616
[[IgnoredVulns]]
1717
id = "GHSA-7r86-cg39-jmmj"
1818
reason = "minimatch ReDoS via GLOBSTAR segments — transitive dep of trunk tooling, no upgrade path"
19-
ignoreUntil = 2026-06-01
19+
ignoreUntil = 2026-09-01
2020

2121
[[IgnoredVulns]]
2222
id = "GHSA-5rq4-664w-9x2c"
2323
reason = "basic-ftp path traversal in downloadToDir() — transitive dep of trunk tooling, no direct upgrade path"
24-
ignoreUntil = 2026-06-01
24+
ignoreUntil = 2026-09-01
2525

2626
[[IgnoredVulns]]
2727
id = "GHSA-fj3w-jwp8-x2g3"
2828
reason = "fast-xml-parser stack overflow — transitive dep of trunk tooling, low severity"
29-
ignoreUntil = 2026-06-01
29+
ignoreUntil = 2026-09-01
30+
31+
[[IgnoredVulns]]
32+
id = "GHSA-58qx-3vcg-4xpx"
33+
reason = "ws uninitialized memory disclosure — transitive via AgentMail/Puppeteer; no direct WebSocket server exposure in this app"
34+
ignoreUntil = 2026-09-01
3035

3136
[[IgnoredVulns]]
3237
id = "GHSA-vpq2-c234-7xj6"

src/sources/congress-trades.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,21 @@ export interface CongressTrade {
6363
* "250K–500K", "500K–1M", "1M–5M", "5M–25M", "25M–50M"
6464
*/
6565
export const parseAmountRange = (range: string): number => {
66+
return parseAmountRangeBounds(range).lower;
67+
};
68+
69+
const parseAmountRangeBounds = (
70+
range: string,
71+
): { lower: number; upper: number } => {
6672
const cleaned = range.replace(/[$,\s]/g, "");
67-
// Extract the lower bound (before the dash/en-dash)
68-
const lowerStr = cleaned.split(/[-]/)[0]?.trim();
69-
if (!lowerStr) return 0;
70-
return parseAmountValue(lowerStr);
73+
const [lowerStr, upperStr] = cleaned.split(/[-]/).map((part) => part.trim());
74+
if (!lowerStr) return { lower: 0, upper: 0 };
75+
76+
const lower = parseAmountValue(lowerStr);
77+
return {
78+
lower,
79+
upper: upperStr ? parseAmountValue(upperStr) : lower,
80+
};
7181
};
7282

7383
export const parseAmountValue = (value: string): number => {
@@ -486,6 +496,13 @@ const formatDate = (date: Date): string => {
486496
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
487497
};
488498

499+
const formatDateSummary = (dates: Date[]): string => {
500+
const formatted = [...new Set(dates.map(formatDate))];
501+
if (formatted.length === 0) return "";
502+
if (formatted.length === 1) return formatted[0] ?? "";
503+
return `${formatted[0]}${formatted.at(-1)}`;
504+
};
505+
489506
const formatPartyState = (trade: CongressTrade): string => {
490507
return `${trade.party}-${trade.state}`;
491508
};
@@ -552,7 +569,13 @@ export const deduplicateTrades = (
552569
const groups = new Map<string, CongressTrade[]>();
553570

554571
for (const trade of trades) {
555-
const key = `${trade.politician}|${trade.ticker}|${trade.type}`;
572+
const key = [
573+
trade.politician,
574+
trade.ticker,
575+
trade.type,
576+
trade.tradeDate.toISOString().split("T")[0],
577+
trade.disclosureDate.toISOString().split("T")[0],
578+
].join("|");
556579
const group = groups.get(key);
557580
if (group) {
558581
group.push(trade);
@@ -599,17 +622,19 @@ export const deduplicateTrades = (
599622
};
600623

601624
const formatGroupedAmount = (trades: CongressTrade[]): string => {
602-
const amounts = trades.map((t) => t.amountLower).sort((a, b) => a - b);
603-
const low = amounts.at(0);
604-
const high = amounts.at(-1);
605-
if (low === undefined || high === undefined)
625+
const ranges = trades.map((t) => parseAmountRangeBounds(t.amountRange));
626+
const low = ranges.reduce((sum, range) => sum + range.lower, 0);
627+
const high = ranges.reduce((sum, range) => sum + range.upper, 0);
628+
if (low === 0 && high === 0)
606629
return formatAmountDisplay(trades[0]?.amountRange ?? "");
607-
if (low === high) return formatAmountDisplay(trades.at(0)?.amountRange ?? "");
608630
return `$${formatCompactAmount(low)}–$${formatCompactAmount(high)} total`;
609631
};
610632

611633
const formatCompactAmount = (amount: number): string => {
612-
if (amount >= 1_000_000) return `${(amount / 1_000_000).toFixed(0)}M`;
634+
if (amount >= 1_000_000) {
635+
const millions = amount / 1_000_000;
636+
return `${Number.isInteger(millions) ? millions.toFixed(0) : millions.toFixed(1)}M`;
637+
}
613638
if (amount >= 1_000) return `${(amount / 1_000).toFixed(0)}K`;
614639
return String(amount);
615640
};
@@ -632,7 +657,18 @@ export const formatDeduplicatedItem = (
632657
const committeeLine = entry.committeeRelevance
633658
? `${entry.committeeRelevance.committee} · `
634659
: "";
635-
const detail = `${committeeLine}Combined from ${entry.count} transactions`;
660+
const tradeDates = formatDateSummary(
661+
entry.trades.map((trade) => trade.tradeDate),
662+
);
663+
const disclosureDates = formatDateSummary(
664+
entry.trades.map((trade) => trade.disclosureDate),
665+
);
666+
const detailParts = [
667+
`Combined from ${entry.count} transactions`,
668+
tradeDates ? `traded ${tradeDates}` : "",
669+
disclosureDates ? `filed ${disclosureDates}` : "",
670+
].filter(Boolean);
671+
const detail = `${committeeLine}${detailParts.join(" · ")}`;
636672
return {
637673
text,
638674
detail,

tests/congress-trades.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,16 +568,42 @@ describe("deduplicateTrades", () => {
568568
expect(result.length).toBe(2);
569569
});
570570

571+
it("does not group same ticker/action across different trade dates", () => {
572+
const trades = [
573+
makeTrade({ tradeDate: new Date("2026-01-15") }),
574+
makeTrade({ tradeDate: new Date("2026-01-16"), score: 10 }),
575+
];
576+
const result = deduplicateTrades(trades);
577+
expect(result.length).toBe(2);
578+
});
579+
571580
it("formats grouped trade correctly", () => {
572581
const trades = [
573582
makeTrade({ amountLower: 1_000_000, score: 15 }),
574-
makeTrade({ amountLower: 500_000, score: 10 }),
583+
makeTrade({
584+
amountRange: "500K–1M",
585+
amountLower: 500_000,
586+
score: 10,
587+
}),
575588
];
576589
const result = deduplicateTrades(trades);
577590
const { text } = formatDeduplicatedItem(defined(result[0]));
578591
expect(text).toContain("Pelosi");
579592
expect(text).toContain("NVDA");
580593
expect(text).toContain("2 trades");
594+
expect(text).toContain("$1.5M–$6M total");
595+
});
596+
597+
it("sums grouped disclosure brackets instead of showing min/max legs", () => {
598+
const trades = [
599+
makeTrade({ amountRange: "250K–500K", amountLower: 250_000 }),
600+
makeTrade({ amountRange: "500K–1M", amountLower: 500_000 }),
601+
];
602+
const result = deduplicateTrades(trades);
603+
const { text, detail } = formatDeduplicatedItem(defined(result[0]));
604+
expect(text).toContain("2 trades, $750K–$1.5M total");
605+
expect(detail).toContain("traded Jan 15");
606+
expect(detail).toContain("filed Feb 20");
581607
});
582608

583609
it("formats grouped trade with TradingView URL and ticker linkText", () => {

0 commit comments

Comments
 (0)