From 932fd06f71b1f11cb25940c4ee609cf98c0c36c6 Mon Sep 17 00:00:00 2001 From: mark Date: Wed, 29 Jul 2026 09:33:22 -0400 Subject: [PATCH] Finalizing of 0.2.0 changes --- README.md | 123 ++++++++++++++++++++++++++++++++++- kalshi-csv.jpg | Bin 0 -> 994 bytes src/kalshi_csv/categories.py | 2 +- src/kalshi_csv/cli.py | 74 +++++++++++++++++++++ src/kalshi_csv/formatter.py | 22 +++++++ src/kalshi_csv/web.py | 55 ++++++++++++++++ tests/test_categories.py | 5 +- tests/test_cli.py | 70 ++++++++++++++++++++ tests/test_web.py | 34 ++++++++++ 9 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 kalshi-csv.jpg diff --git a/README.md b/README.md index c31fdb5..38d791d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,13 @@ Parse Kalshi transaction CSV files and generate IRS Form 8949 tax summaries for pip install kalshi-csv ``` +## What's New in 0.2.0 + +- **Summary Cards**: View key metrics at a glance - Net Realized P&L, Win/Loss Record, Total Volume, and Best/Worst Single Trade +- **Market Breakdown**: See performance by market category with trade counts, win rates, and net P&L +- **Legacy Web Mode**: Browse your portfolio in a retro HTML 4.01 web interface compatible with older browsers (Netscape Navigator, IE 4+) +- **Market Categorization**: Automatic categorization of tickers into 7 market types (Global Soccer, MLB, NPB, NBA Summer League, WNBA, S&P 500, Multivariate Events, Other Markets) + ## Getting Your Transactions CSV Download your transaction history from Kalshi: @@ -32,7 +39,7 @@ Rows without `realized_pnl_without_fees_dollars` are automatically skipped. ## CLI Usage -Parse a Kalshi transactions CSV and display the trade matrix with IRS summary: +Parse a Kalshi transactions CSV and display the trade matrix with summary cards, market breakdown, and IRS summary: ```bash kalshi-csv Kalshi-Transactions-2026.csv @@ -56,6 +63,18 @@ Use ASCII characters instead of Unicode box-drawing (for terminals without UTF-8 kalshi-csv Kalshi-Transactions-2026.csv --ascii ``` +Start a legacy web server to view your portfolio in a browser (HTML 4.01 compatible with older browsers): + +```bash +kalshi-csv Kalshi-Transactions-2026.csv --legacy-web +``` + +Specify a custom port for the legacy web server: + +```bash +kalshi-csv Kalshi-Transactions-2026.csv --legacy-web --legacy-web-port 3000 +``` + ### Sample Output Default mode (Unicode box-drawing): @@ -72,6 +91,27 @@ Total Transactions Parsed: 3 Total Exchange Fees Paid: $0.02 Internal Tracked Net P&L: $+0.26 ----------------------------------------------------------------------------------- + +┌────────────────────────────┬────────────────────────────┬────────────────────────────┬────────────────────────────┐ +│ NET REALIZED P&L │ WIN / LOSS RECORD │ TOTAL VOLUME │ BEST/WORST SINGLE │ +├────────────────────────────┼────────────────────────────┼────────────────────────────┼────────────────────────────┤ +│ $+0.26 │ 2 - 1 │ 3 │ $+0.39 / $-0.10 │ +│ Includes $0.02 fees │ 0 Pushes (66.7% Win) │ Executed Contracts │ MLB Baseball / S&P 500 │ +└────────────────────────────┴────────────────────────────┴────────────────────────────┴────────────────────────────┘ + +┌─────────────────────────────────────┬────────────┬──────────────┬──────────────────┐ +│ ASSET CLASS / MARKET │ TRADES │ WIN RATE │ NET P&L │ +├─────────────────────────────────────┼────────────┼──────────────┼──────────────────┤ +│ Global Soccer / Football │ 168 │ 54.8% │ $+15.15 │ +│ S&P 500 (INXU Intraday) │ 110 │ 50.9% │ $-32.21 │ +│ Other Markets │ 87 │ 35.6% │ $-28.63 │ +│ MLB Baseball │ 44 │ 40.9% │ $-13.06 │ +│ NBA Summer League │ 43 │ 46.5% │ $-7.09 │ +│ NPB Baseball (Japan) │ 40 │ 47.5% │ $-10.68 │ +│ Multivariate Events │ 23 │ 8.7% │ $-13.48 │ +│ WNBA Basketball │ 6 │ 0.0% │ $-5.87 │ +└─────────────────────────────────────┴────────────┴──────────────┴──────────────────┘ + === IRS FORM 8949 / SCHEDULE D AGGREGATE SUMMARY === Use these exact aggregates for a single-line summary entry: * Box to Check: Box C (Short-term, not reported on Form 1099-B) @@ -98,6 +138,27 @@ Total Transactions Parsed: 3 Total Exchange Fees Paid: $0.02 Internal Tracked Net P&L: $+0.26 ----------------------------------------------------------------------------------- + ++----------------------------+----------------------------+----------------------------+----------------------------+ +| NET REALIZED P&L | WIN / LOSS RECORD | TOTAL VOLUME | BEST/WORST SINGLE | ++----------------------------+----------------------------+----------------------------+----------------------------+ +| $+0.26 | 2 - 1 | 3 | $+0.39 / $-0.10 | +| Includes $0.02 fees | 0 Pushes (66.7% Win) | Executed Contracts | MLB Baseball / S&P 500 | ++----------------------------+----------------------------+----------------------------+----------------------------+ + ++-------------------------------------+------------+--------------+------------------+ +| ASSET CLASS / MARKET | TRADES | WIN RATE | NET P&L | ++-------------------------------------+------------+--------------+------------------+ +| Global Soccer / Football | 168 | 54.8% | $+15.15 | +| S&P 500 (INXU Intraday) | 110 | 50.9% | $-32.21 | +| Other Markets | 87 | 35.6% | $-28.63 | +| MLB Baseball | 44 | 40.9% | $-13.06 | +| NBA Summer League | 43 | 46.5% | $-7.09 | +| NPB Baseball (Japan) | 40 | 47.5% | $-10.68 | +| Multivariate Events | 23 | 8.7% | $-13.48 | +| WNBA Basketball | 6 | 0.0% | $-5.87 | ++-------------------------------------+------------+--------------+------------------+ + === IRS FORM 8949 / SCHEDULE D AGGREGATE SUMMARY === Use these exact aggregates for a single-line summary entry: * Box to Check: Box C (Short-term, not reported on Form 1099-B) @@ -110,6 +171,48 @@ Use these exact aggregates for a single-line summary entry: ==================================================== ``` +## Legacy Web Mode + +View your portfolio in a web browser with a retro HTML 4.01 interface compatible with older browsers (Netscape Navigator, IE 4+): + +```bash +kalshi-csv Kalshi-Transactions-2026.csv --legacy-web +``` + +This starts an HTTP server on `0.0.0.0:8080` by default. Access it from any machine on your network by navigating to `http://:8080`. + +To use a different port: + +```bash +kalshi-csv Kalshi-Transactions-2026.csv --legacy-web --legacy-web-port 3000 +``` + +### What's Displayed + +The web interface shows: + +- **Summary Cards**: Net Realized P&L, Win/Loss Record, Total Volume, Best/Worst Single Trade +- **Market Breakdown**: Performance by category with trade counts, win rates, and net P&L +- **Recent Closed Positions**: Last 20 trades with timestamps, tickers, sides, quantities, entry/exit prices, and P&L +- **IRS Form 8949 Summary**: Tax reporting data including gross proceeds, cost basis, and gain/loss + +The interface uses pure HTML 4.01 table layout with no CSS or JavaScript, ensuring compatibility with legacy browsers. + +## Market Categorization + +The tool automatically categorizes market tickers into the following categories: + +- **Global Soccer / Football**: World Cup, Champions League, Europa League, Brasileirão, Argentino, Liga MX, and other soccer leagues +- **MLB Baseball**: Major League Baseball games and derivatives +- **NPB Baseball (Japan)**: Nippon Professional Baseball +- **NBA Summer League**: NBA Summer League games +- **WNBA Basketball**: Women's National Basketball Association +- **S&P 500 (INXU Intraday)**: S&P 500 index intraday contracts +- **Multivariate Events**: Multivariate Event (MVE) markets - parlay-style markets linking multiple individual event outcomes together +- **Other Markets**: Weather, politics, crypto, and all other markets + +Categories are determined by analyzing ticker prefixes (e.g., `KXMLBGAME` → MLB Baseball, `KXINXU` → S&P 500). + ## Library API Use `kalshi-csv` as a Python library in your own scripts: @@ -135,6 +238,16 @@ irs = kalshi.irs_summary() print(f"Gross Proceeds: ${irs['gross_proceeds']:.2f}") print(f"Cost Basis: ${irs['cost_basis']:.2f}") print(f"Gain/Loss: ${irs['gain_or_loss']:.2f}") + +# Get market breakdown by category +breakdown = kalshi.market_breakdown() +for item in breakdown: + print(f"{item['category']}: {item['trades']} trades, {item['win_rate']:.1f}% win, ${item['net_pnl']:+.2f}") + +# Get recent closed positions +recent = kalshi.recent_closed_positions(10) +for trade in recent: + print(f"{trade['close_timestamp']}: {trade['ticker']} ${trade['pnl_with_fees']:+.2f}") ``` ### Data Structures @@ -149,6 +262,9 @@ print(f"Gain/Loss: ${irs['gain_or_loss']:.2f}") - `pnl_with_fees`: P&L including fees - `open_fees`: Opening fees - `close_fees`: Closing fees +- `open_timestamp`: When the position was opened (datetime object or None) +- `close_timestamp`: When the position was closed (datetime object or None) +- `market_category`: Categorized market type (e.g., "MLB Baseball", "Global Soccer / Football") **Summary dict** (`kalshi.summary`): - `trade_count`: Number of trades parsed @@ -157,6 +273,11 @@ print(f"Gain/Loss: ${irs['gain_or_loss']:.2f}") - `total_pnl_with_fees`: Total P&L including fees - `total_tax_basis`: Total cost basis for IRS reporting - `total_tax_proceeds`: Total proceeds for IRS reporting +- `wins`: Number of winning trades (pnl_with_fees > 0) +- `losses`: Number of losing trades (pnl_with_fees < 0) +- `pushes`: Number of break-even trades (pnl_with_fees == 0) +- `best_trade`: Trade dict with highest pnl_with_fees (or None) +- `worst_trade`: Trade dict with lowest pnl_with_fees (or None) **IRS summary dict** (`kalshi.irs_summary()`): - `box`: "C" (for Form 8949 Box C) diff --git a/kalshi-csv.jpg b/kalshi-csv.jpg new file mode 100644 index 0000000000000000000000000000000000000000..33fa2d069cbad7b96875b25448744f4077813945 GIT binary patch literal 994 zcmex=U!p$SCCnLYAwxzYjKY8Np{)Sm)jV<*cLm0)x#pR{tZ4?!4 z>O(w2>PZIw4=@OFFvv4RFf$4=FbOg;3o`yc!XU-Kz{Cu6ED~U5Vqs-u1In2QFfcJP zu`si6urWjA8JU4OxRGi7EvqvI&bQt2h=OGKdsfy{yo_6!S!e{X%a z^76Eu^V6l@6r?fz4F0uhjZxO28OtAe^PCpedX^o%Gvd;*MYlF?`pEI&g;b~mUjyrs z$+euWrxzUw?o19BT*13ySJ&w+>X{d99#6D#71QPcQ(}_Gl}fsFbd394UW!CSq|0<( z(%7)k;`on9omITsnP%1W*-lkpSfKty>&C{F+tXIvxO3?0wUw2U=L`=kw==gL=U&qh zZlPX~eWourGL>IuZgpwj#i^~%M-0;D;=K9;!e={$5(%I8x+hIJ@;SxZCTrXCvW>rJImBAYM9&4 zdiI~;+?5}jVy8K zQ|Yz~j2e<9J#PIA3CL7Zb9$hoy?{k_`=WOLC(~>gnQ8D<%XlQ9UovgS<*a3G z!MU3bRXHU0D4lxfutCz*_gBf?ErB*GgHpqfJyf0It+d(hXU8fHPc1i-Guad!^yCyvKSH|rLxgOTpoTvVpF4EdT%j literal 0 HcmV?d00001 diff --git a/src/kalshi_csv/categories.py b/src/kalshi_csv/categories.py index 061b3ae..7cf0b5a 100644 --- a/src/kalshi_csv/categories.py +++ b/src/kalshi_csv/categories.py @@ -8,7 +8,7 @@ TICKER_CATEGORY_MAP = { "KXWNBA": "WNBA Basketball", "KXINXU": "S&P 500 (INXU Intraday)", "KXINX": "S&P 500 (INXU Intraday)", - "KXMVESPORTS": "Esports & Gaming", + "KXMVE": "Multivariate Events", } SOCCER_PREFIXES = [ diff --git a/src/kalshi_csv/cli.py b/src/kalshi_csv/cli.py index 0466fdf..c474236 100644 --- a/src/kalshi_csv/cli.py +++ b/src/kalshi_csv/cli.py @@ -14,9 +14,17 @@ from .formatter import ( format_table_separator, format_table_row, get_box_chars, + pad_colored_text, ) +def truncate_text(text, max_len): + """Truncates text to max_len, using ellipsis if longer than max_len-3 chars.""" + if len(text) > max_len: + return text[:max_len-3] + "..." + return text + + def main(): parser = argparse.ArgumentParser( description="Parse Kalshi transaction CSV and generate IRS tax summary." @@ -96,6 +104,72 @@ def main(): ) print("-" * 83) + # Summary cards section + print() + card_widths = [25, 25, 25, 25] + + best_pnl = kalshi.summary["best_trade"]["pnl_with_fees"] if kalshi.summary["best_trade"] else 0 + worst_pnl = kalshi.summary["worst_trade"]["pnl_with_fees"] if kalshi.summary["worst_trade"] else 0 + best_cat = kalshi.summary["best_trade"]["market_category"] if kalshi.summary["best_trade"] else "N/A" + worst_cat = kalshi.summary["worst_trade"]["market_category"] if kalshi.summary["worst_trade"] else "N/A" + + card_labels = ["NET REALIZED P&L", "WIN / LOSS RECORD", "TOTAL VOLUME", "BEST/WORST SINGLE"] + + best_worst_combined = format_currency_color(best_pnl, no_color) + " / " + format_currency_color(worst_pnl, no_color) + + card_values = [ + format_currency_color_padded(kalshi.summary["total_pnl_with_fees"], 25, no_color), + truncate_text(f"{kalshi.summary['wins']} - {kalshi.summary['losses']}", 25), + truncate_text(str(kalshi.summary['trade_count']), 25), + pad_colored_text(best_worst_combined, 25, no_color), + ] + card_subtext = [ + truncate_text(f"Includes ${kalshi.summary['total_fees']:.2f} fees", 25), + truncate_text(f"{kalshi.summary['pushes']} Push{'s' if kalshi.summary['pushes'] != 1 else ''} ({kalshi.summary['wins'] / kalshi.summary['trade_count'] * 100 if kalshi.summary['trade_count'] > 0 else 0:.1f}% Win)", 25), + truncate_text("Executed Contracts", 25), + truncate_text(f"{best_cat} / {worst_cat}", 25), + ] + + print(format_table_separator(card_widths, ascii_mode, "top")) + print(format_table_row(card_labels, card_widths, ascii_mode)) + print(format_table_separator(card_widths, ascii_mode, "middle")) + + box = get_box_chars(ascii_mode) + value_row = ( + f" {card_values[0]} " + f"{box['vertical']} {card_values[1]:<25} " + f"{box['vertical']} {card_values[2]:<25} " + f"{box['vertical']} {card_values[3]} " + ) + print(box["vertical"] + value_row + box["vertical"]) + + print(format_table_row(card_subtext, card_widths, ascii_mode)) + print(format_table_separator(card_widths, ascii_mode, "bottom")) + print() + + # Market breakdown section + market_headers = ["ASSET CLASS / MARKET", "TRADES", "WIN RATE", "NET P&L"] + market_widths = [35, 10, 12, 16] + print(format_table_separator(market_widths, ascii_mode, "top")) + print(format_table_header(market_headers, market_widths, ascii_mode)) + print(format_table_separator(market_widths, ascii_mode, "middle")) + + for item in kalshi.market_breakdown(): + category = item["category"][:33] + ".." if len(item["category"]) > 35 else item["category"] + trades = str(item["trades"]) + win_rate = f"{item['win_rate']:.1f}%" + net_pnl = format_currency_color_padded(item["net_pnl"], 16, no_color) + row = ( + f" {category:<35} " + f"{get_box_chars(ascii_mode)['vertical']} {trades:<10} " + f"{get_box_chars(ascii_mode)['vertical']} {win_rate:<12} " + f"{get_box_chars(ascii_mode)['vertical']} {net_pnl} " + ) + print(get_box_chars(ascii_mode)['vertical'] + row + get_box_chars(ascii_mode)['vertical']) + + print(format_table_separator(market_widths, ascii_mode, "bottom")) + print() + irs = kalshi.irs_summary() print(color_yellow("=== IRS FORM 8949 / SCHEDULE D AGGREGATE SUMMARY ===", no_color)) print("Use these exact aggregates for a single-line summary entry:") diff --git a/src/kalshi_csv/formatter.py b/src/kalshi_csv/formatter.py index 7aa8c59..111c3d6 100644 --- a/src/kalshi_csv/formatter.py +++ b/src/kalshi_csv/formatter.py @@ -46,6 +46,28 @@ def format_currency_color_padded(value, width, no_color=False): return color_green(padded_str, no_color) if value >= 0 else color_red(padded_str, no_color) +def pad_colored_text(text, width, no_color=False): + """Pads a colored string to a specific visible width, accounting for ANSI codes.""" + if no_color: + return f"{text:<{width}}" + + # Strip ANSI codes to get visible length + import re + ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') + visible_text = ansi_escape.sub('', text) + visible_len = len(visible_text) + + if visible_len >= width: + return text + + # Add padding to the end (before the final reset code if present) + padding = ' ' * (width - visible_len) + if text.endswith('\x1B[0m'): + return text[:-4] + padding + '\x1B[0m' + else: + return text + padding + + def truncate_ticker(ticker, max_len=32): """Truncates ticker to max_len, using ellipsis if longer than 29 chars.""" if len(ticker) > 29: diff --git a/src/kalshi_csv/web.py b/src/kalshi_csv/web.py index 4e12aca..04384b3 100644 --- a/src/kalshi_csv/web.py +++ b/src/kalshi_csv/web.py @@ -87,6 +87,59 @@ def render_portfolio_html(kalshi, csv_filename): {pnl_str} """ + # IRS summary section + irs = kalshi.irs_summary() + gross_proceeds_color = "#006600" if irs["gross_proceeds"] >= 0 else "#990000" + cost_basis_color = "#006600" if irs["cost_basis"] >= 0 else "#990000" + gain_loss_color = "#006600" if irs["gain_or_loss"] >= 0 else "#990000" + irs_html = f""" +


+ + + + IRS Form 8949 / Schedule D Summary +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Box to Check:{html.escape(irs['box'])}

Description:{html.escape(irs['description'])}

Date Acquired:{html.escape(irs['date_acquired'])}

Date Sold:{html.escape(irs['date_sold'])}

Gross Proceeds:${irs['gross_proceeds']:.2f}

Cost or Other Basis:${irs['cost_basis']:.2f}

Gain or (Loss):${irs['gain_or_loss']:+.2f}
+ + +""" + page = f""" @@ -182,6 +235,8 @@ def render_portfolio_html(kalshi, csv_filename): + {irs_html} +

diff --git a/tests/test_categories.py b/tests/test_categories.py index 644ed71..e8a50e4 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -23,8 +23,9 @@ def test_sp500_categorization(): assert categorize_ticker("KXINX-26JUL08H1400-T7479.9999") == "S&P 500 (INXU Intraday)" -def test_esports_categorization(): - assert categorize_ticker("KXMVESPORTSMULTIGAMEEXTENDED-S2026769CE3FA3F9-6D4DB2E2128") == "Esports & Gaming" +def test_multivariate_events_categorization(): + assert categorize_ticker("KXMVESPORTSMULTIGAMEEXTENDED-S2026769CE3FA3F9-6D4DB2E2128") == "Multivariate Events" + assert categorize_ticker("KXMVECROSSCATEGORY-S2026AC77F3A8C7A-6D4DB2E2128") == "Multivariate Events" def test_soccer_categorization(): diff --git a/tests/test_cli.py b/tests/test_cli.py index 5e67d76..2276964 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -124,3 +124,73 @@ def test_cli_legacy_web_port_default_in_help(): ) assert result.returncode == 0 assert "8080" in result.stdout + + +def test_cli_summary_cards_output(sample_csv): + result = subprocess.run( + [sys.executable, "-m", "kalshi_csv.cli", sample_csv, "--no-color"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "NET REALIZED P&L" in result.stdout + assert "WIN / LOSS RECORD" in result.stdout + assert "TOTAL VOLUME" in result.stdout + assert "BEST/WORST SINGLE" in result.stdout + + +def test_cli_market_breakdown_output(sample_csv): + result = subprocess.run( + [sys.executable, "-m", "kalshi_csv.cli", sample_csv, "--no-color"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "ASSET CLASS / MARKET" in result.stdout + assert "TRADES" in result.stdout + assert "WIN RATE" in result.stdout + assert "NET P&L" in result.stdout + assert "Other Markets" in result.stdout + + +def test_cli_summary_cards_box_drawing(sample_csv): + result = subprocess.run( + [sys.executable, "-m", "kalshi_csv.cli", sample_csv, "--no-color"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "┌" in result.stdout + assert "└" in result.stdout + assert "┬" in result.stdout + assert "┴" in result.stdout + + +def test_cli_summary_cards_before_irs(sample_csv): + result = subprocess.run( + [sys.executable, "-m", "kalshi_csv.cli", sample_csv, "--no-color"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + output = result.stdout + summary_pos = output.find("NET REALIZED P&L") + irs_pos = output.find("IRS FORM 8949") + assert summary_pos > 0 + assert irs_pos > 0 + assert summary_pos < irs_pos + + +def test_cli_market_breakdown_before_irs(sample_csv): + result = subprocess.run( + [sys.executable, "-m", "kalshi_csv.cli", sample_csv, "--no-color"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + output = result.stdout + market_pos = output.find("ASSET CLASS / MARKET") + irs_pos = output.find("IRS FORM 8949") + assert market_pos > 0 + assert irs_pos > 0 + assert market_pos < irs_pos diff --git a/tests/test_web.py b/tests/test_web.py index d8779ba..b1fddbb 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -94,3 +94,37 @@ def test_render_html_escapes_html_in_tickers(sample_csv): html_content = render_portfolio_html(kalshi, "test.csv") assert "" not in html_content assert "<script>" in html_content + + +def test_render_html_contains_irs_section(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + html_content = render_portfolio_html(kalshi, "test.csv") + assert "IRS Form 8949 / Schedule D Summary" in html_content + assert "Box to Check:" in html_content + assert "Description:" in html_content + assert "Date Acquired:" in html_content + assert "Date Sold:" in html_content + assert "Gross Proceeds:" in html_content + assert "Cost or Other Basis:" in html_content + assert "Gain or (Loss):" in html_content + + +def test_render_html_irs_values(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + html_content = render_portfolio_html(kalshi, "test.csv") + assert ">C<" in html_content + assert "Kalshi Event Contracts (Aggregate Summary)" in html_content + assert "07/07/2026" in html_content + + +def test_render_html_irs_after_positions(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + html_content = render_portfolio_html(kalshi, "test.csv") + positions_pos = html_content.find("Recent Closed Positions") + irs_pos = html_content.find("IRS Form 8949") + assert positions_pos > 0 + assert irs_pos > 0 + assert positions_pos < irs_pos