diff --git a/README.md b/README.md index c31fdb5..e70fb69 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ 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) +- **Handling of Sold + Acquired Dates**: Instead of handling the aggregated Kalshi trades through approximated dates, we use `VARIOUS` to signal to the IRS that every single underlying transaction in that row independently satisfies the short-term holding period rule (one year or less), even though they were purchased at different times. + ## Getting Your Transactions CSV Download your transaction history from Kalshi: @@ -32,7 +40,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 +64,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 +92,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,18 +139,81 @@ 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) * (a) Description: Kalshi Event Contracts (Aggregate Summary) - * (b) Date Acquired: 07/07/2026 - * (c) Date Sold: 07/08/2026 + * (b) Date Acquired: VARIOUS + * (c) Date Sold: VARIOUS * (d) Gross Proceeds: $+2.50 * (e) Cost or Other Basis: $2.24 * (h) Gain or (Loss): $+0.26 ==================================================== ``` +## 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 +239,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 +263,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 +274,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) @@ -165,6 +287,54 @@ print(f"Gain/Loss: ${irs['gain_or_loss']:.2f}") - `cost_basis`: Total cost basis - `gain_or_loss`: Net gain or loss +## Development & Testing + +For developers who want to contribute or run the test suite: + +### Installing Dependencies + +```bash +pip install -r requirements.txt +``` + +### Running Tests + +The project uses pytest for testing. Run the full test suite: + +```bash +pytest +``` + +Or with verbose output: + +```bash +pytest -v +``` + +### Sample Test Data + +The test suite uses sample data located at `tests/fixtures/sample.csv`. If you want to create this file manually or modify it for testing: + +**File location**: `tests/fixtures/sample.csv` + +```csv +type,quantity_fp,market_ticker,side,entry_price_dollars,exit_price_dollars,open_fees_dollars,close_fees_dollars,realized_pnl_without_fees_dollars,realized_pnl_with_fees_dollars,close_timestamp,open_timestamp +trade,1.00,TESTMARKET-WIN,yes,0.5000,1.0000,0.010000,0.020000,0.500000,0.470000,2026-07-07T12:19:57-04:00,2026-07-07T09:48:19-04:00 +trade,2.00,TESTMARKET-LOSS,yes,0.4000,0.0000,0.020000,0.000000,-0.800000,-0.820000,2026-07-07T12:56:23-04:00,2026-07-07T12:37:41-04:00 +trade,0.50,TESTMARKET-SMALL,no,0.6000,0.8000,0.010000,0.010000,0.100000,0.080000,2026-07-07T14:07:41-04:00,2026-07-07T12:26:45-04:00 +``` + +This sample contains 3 trades: +- **TESTMARKET-WIN**: A winning trade (+$0.47 P&L with fees) +- **TESTMARKET-LOSS**: A losing trade (-$0.82 P&L with fees) +- **TESTMARKET-SMALL**: A small winning trade (+$0.08 P&L with fees) + +You can also test this sample data directly with the CLI: + +```bash +kalshi-csv tests/fixtures/sample.csv +``` + ## IRS Form 8949 Kalshi event contracts are typically reported on **IRS Form 8949, Box C** (short-term transactions not reported on Form 1099-B). The tool calculates: @@ -179,9 +349,9 @@ Use the aggregate summary for a single-line entry on Form 8949, or export to a f ## Source Code -This project is hosted in two locations: +This project is hosted in two locations, GitHub and my home Forgejo server, contributions are easiest through GitHub, but you are welcome to clone from my Forgejo as well: - **GitHub**: [https://github.com/MARKMENTAL/kalshi-csv](https://github.com/MARKMENTAL/kalshi-csv) -- **Codeberg**: [https://codeberg.org/markmental/kalshi-csv](https://codeberg.org/markmental/kalshi-csv) +- **MentalNet Forgejo v2**: [https://mentalnet.xyz/forgejo-v2/markmental/kalshi-csv](https://mentalnet.xyz/forgejo-v2/markmental/kalshi-csv) ## License diff --git a/kalshi-csv.jpg b/kalshi-csv.jpg new file mode 100644 index 0000000..33fa2d0 Binary files /dev/null and b/kalshi-csv.jpg differ diff --git a/pyproject.toml b/pyproject.toml index c1de816..9b646dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "kalshi-csv" -version = "0.1.2" +version = "0.2.0" description = "Parse Kalshi transaction CSV files and generate IRS Form 8949 tax summaries" readme = "README.md" license = "MIT" @@ -28,7 +28,7 @@ keywords = ["kalshi", "csv", "tax", "irs", "form-8949"] [project.urls] Homepage = "https://github.com/MARKMENTAL/kalshi-csv" -Source = "https://codeberg.org/markmental/kalshi-csv" +Source = "https://mentalnet.xyz/forgejo-v2/markmental/kalshi-csv" Issues = "https://github.com/MARKMENTAL/kalshi-csv/issues" [project.scripts] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ab693ec --- /dev/null +++ b/requirements.txt @@ -0,0 +1,33 @@ +build==1.5.0 +certifi==2026.6.17 +cffi==2.1.0 +charset-normalizer==3.4.9 +cryptography==49.0.0 +docutils==0.23 +id==1.6.1 +idna==3.18 +iniconfig==2.3.0 +jaraco.classes==3.4.0 +jaraco.context==6.1.2 +jaraco.functools==4.6.0 +jeepney==0.9.0 +-e git+https://codeberg.org/markmental/kalshi-csv.git@f7171c025c23d8d46931042f6dba48c4e6beeda9#egg=kalshi_csv +keyring==25.7.0 +markdown-it-py==4.2.0 +mdurl==0.1.2 +more-itertools==11.1.0 +nh3==0.3.6 +packaging==26.2 +pluggy==1.6.0 +pycparser==3.0 +Pygments==2.20.0 +pyproject_hooks==1.2.0 +pytest==9.1.1 +readme_renderer==45.0 +requests==2.34.2 +requests-toolbelt==1.0.0 +rfc3986==2.0.0 +rich==15.0.0 +SecretStorage==3.5.0 +twine==6.2.0 +urllib3==2.7.0 diff --git a/src/kalshi_csv/__init__.py b/src/kalshi_csv/__init__.py index 3f9929b..c0492ee 100644 --- a/src/kalshi_csv/__init__.py +++ b/src/kalshi_csv/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.1.2" +__version__ = "0.2.0" from .parser import KalshiCSV diff --git a/src/kalshi_csv/categories.py b/src/kalshi_csv/categories.py new file mode 100644 index 0000000..7cf0b5a --- /dev/null +++ b/src/kalshi_csv/categories.py @@ -0,0 +1,44 @@ +TICKER_CATEGORY_MAP = { + "KXMLB": "MLB Baseball", + "KXMLBHR": "MLB Baseball", + "KXMLBMEN": "MLB Baseball", + "KXNPB": "NPB Baseball (Japan)", + "KXNBASUMMER": "NBA Summer League", + "KXNEXTTEAMNBA": "NBA Summer League", + "KXWNBA": "WNBA Basketball", + "KXINXU": "S&P 500 (INXU Intraday)", + "KXINX": "S&P 500 (INXU Intraday)", + "KXMVE": "Multivariate Events", +} + +SOCCER_PREFIXES = [ + "KXWC", + "KXUCL", + "KXUECL", + "KXBRASILEIRO", + "KXALLSVENSKAN", + "KXELITESERIEN", + "KXECULP", + "KXLIGAMX", + "KXLIGAEXP", + "KXKLEAGUE", + "KXCLUBF", + "KXSCOCUP", + "KXURYPD", + "KXDIMAYOR", + "KXARGPREM", + "KXBOLP", +] + + +def categorize_ticker(ticker): + """Maps a Kalshi market ticker to a human-readable category.""" + for prefix, category in TICKER_CATEGORY_MAP.items(): + if ticker.startswith(prefix): + return category + + for prefix in SOCCER_PREFIXES: + if ticker.startswith(prefix): + return "Global Soccer / Football" + + return "Other Markets" diff --git a/src/kalshi_csv/cli.py b/src/kalshi_csv/cli.py index 276ae3d..008a366 100644 --- a/src/kalshi_csv/cli.py +++ b/src/kalshi_csv/cli.py @@ -1,4 +1,5 @@ import argparse +import os import sys from .parser import KalshiCSV @@ -13,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." @@ -35,6 +44,17 @@ def main(): action="store_true", help="Use ASCII characters instead of Unicode box-drawing", ) + parser.add_argument( + "--legacy-web", + action="store_true", + help="Start a legacy web server (HTML 4.01) to view portfolio in browser", + ) + parser.add_argument( + "--legacy-web-port", + type=int, + default=8080, + help="Port for legacy web server (default: 8080)", + ) args = parser.parse_args() no_color = args.no_color @@ -43,6 +63,13 @@ def main(): kalshi = KalshiCSV(args.csv_path) kalshi.parse() + if args.legacy_web: + from .web import LegacyWebServer + csv_filename = os.path.basename(args.csv_path) + server = LegacyWebServer(kalshi, csv_filename, port=args.legacy_web_port) + server.serve() + return + headers = ["Ticker", "Side", "Qty", "Entry", "Exit", "P&L (No Fees)", "Fees"] widths = [32, 4, 6, 6, 6, 14, 6] @@ -77,13 +104,79 @@ 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:") print(f" * Box to Check: {color_white('Box C', no_color)} (Short-term, not reported on Form 1099-B)") print(f" * (a) Description: {irs['description']}") - print(f" * (b) Date Acquired: {irs['date_acquired']}") - print(f" * (c) Date Sold: {irs['date_sold']}") + print(f" * (b) Date Acquired: VARIOUS") + print(f" * (c) Date Sold: VARIOUS") print(f" * (d) Gross Proceeds: {format_currency_color(irs['gross_proceeds'], no_color)}") print(f" * (e) Cost or Other Basis: {color_cyan(f'${irs['cost_basis']:.2f}', no_color)}") print(f" * (h) Gain or (Loss): {format_currency_color(irs['gain_or_loss'], no_color)}") @@ -96,8 +189,8 @@ def main(): f.write("Use these exact aggregates for a single-line summary entry:\n") f.write(f" * Box to Check: Box C (Short-term, not reported on Form 1099-B)\n") f.write(f" * (a) Description: {irs['description']}\n") - f.write(f" * (b) Date Acquired: {irs['date_acquired']}\n") - f.write(f" * (c) Date Sold: {irs['date_sold']}\n") + f.write(f" * (b) Date Acquired: VARIOUS\n") + f.write(f" * (c) Date Sold: VARIOUS\n") f.write(f" * (d) Gross Proceeds: ${irs['gross_proceeds']:.2f}\n") f.write(f" * (e) Cost or Other Basis: ${irs['cost_basis']:.2f}\n") f.write(f" * (h) Gain or (Loss): ${irs['gain_or_loss']:.2f}\n") 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/parser.py b/src/kalshi_csv/parser.py index d072147..9f60c13 100644 --- a/src/kalshi_csv/parser.py +++ b/src/kalshi_csv/parser.py @@ -1,7 +1,10 @@ import csv import os +from collections import defaultdict from datetime import datetime +from .categories import categorize_ticker + class KalshiCSV: """Parses Kalshi transaction CSV data and calculates tax-relevant aggregates.""" @@ -18,6 +21,11 @@ class KalshiCSV: "total_tax_proceeds": 0.0, "earliest_open_date": None, "latest_close_date": None, + "wins": 0, + "losses": 0, + "pushes": 0, + "best_trade": None, + "worst_trade": None, } def parse(self): @@ -40,8 +48,22 @@ class KalshiCSV: open_fees = float(row["open_fees_dollars"]) close_fees = float(row["close_fees_dollars"]) + open_dt = None + close_dt = None + if row.get("open_timestamp"): + try: + open_dt = datetime.fromisoformat(row["open_timestamp"]) + except ValueError: + pass + if row.get("close_timestamp"): + try: + close_dt = datetime.fromisoformat(row["close_timestamp"]) + except ValueError: + pass + + ticker = row["market_ticker"] trade = { - "ticker": row["market_ticker"], + "ticker": ticker, "side": row["side"].upper(), "qty": qty, "entry": entry, @@ -50,6 +72,9 @@ class KalshiCSV: "pnl_with_fees": pnl_with_fees, "open_fees": open_fees, "close_fees": close_fees, + "open_timestamp": open_dt, + "close_timestamp": close_dt, + "market_category": categorize_ticker(ticker), } self.trades.append(trade) @@ -60,27 +85,37 @@ class KalshiCSV: self.summary["total_pnl_with_fees"] += pnl_with_fees self.summary["total_fees"] += open_fees + close_fees - if row.get("open_timestamp"): - try: - open_dt = datetime.fromisoformat(row["open_timestamp"]) - if ( - self.summary["earliest_open_date"] is None - or open_dt < self.summary["earliest_open_date"] - ): - self.summary["earliest_open_date"] = open_dt - except ValueError: - pass + if pnl_with_fees > 0: + self.summary["wins"] += 1 + elif pnl_with_fees < 0: + self.summary["losses"] += 1 + else: + self.summary["pushes"] += 1 - if row.get("close_timestamp"): - try: - close_dt = datetime.fromisoformat(row["close_timestamp"]) - if ( - self.summary["latest_close_date"] is None - or close_dt > self.summary["latest_close_date"] - ): - self.summary["latest_close_date"] = close_dt - except ValueError: - pass + if ( + self.summary["best_trade"] is None + or pnl_with_fees > self.summary["best_trade"]["pnl_with_fees"] + ): + self.summary["best_trade"] = trade + if ( + self.summary["worst_trade"] is None + or pnl_with_fees < self.summary["worst_trade"]["pnl_with_fees"] + ): + self.summary["worst_trade"] = trade + + if open_dt is not None: + if ( + self.summary["earliest_open_date"] is None + or open_dt < self.summary["earliest_open_date"] + ): + self.summary["earliest_open_date"] = open_dt + + if close_dt is not None: + if ( + self.summary["latest_close_date"] is None + or close_dt > self.summary["latest_close_date"] + ): + self.summary["latest_close_date"] = close_dt return self @@ -101,3 +136,36 @@ class KalshiCSV: "cost_basis": self.summary["total_tax_basis"], "gain_or_loss": self.summary["total_pnl_with_fees"], } + + def market_breakdown(self): + """Returns a list of dicts with market category breakdown sorted by trade count.""" + categories = defaultdict(lambda: {"trades": 0, "wins": 0, "net_pnl": 0.0}) + + for trade in self.trades: + cat = trade["market_category"] + categories[cat]["trades"] += 1 + categories[cat]["net_pnl"] += trade["pnl_with_fees"] + if trade["pnl_with_fees"] > 0: + categories[cat]["wins"] += 1 + + breakdown = [] + for cat, data in categories.items(): + win_rate = (data["wins"] / data["trades"] * 100) if data["trades"] > 0 else 0 + breakdown.append({ + "category": cat, + "trades": data["trades"], + "win_rate": win_rate, + "net_pnl": data["net_pnl"], + }) + + return sorted(breakdown, key=lambda x: x["trades"], reverse=True) + + def recent_closed_positions(self, n=20): + """Returns the last n trades sorted by close_timestamp descending.""" + trades_with_close = [t for t in self.trades if t["close_timestamp"] is not None] + sorted_trades = sorted( + trades_with_close, + key=lambda t: t["close_timestamp"], + reverse=True, + ) + return sorted_trades[:n] diff --git a/src/kalshi_csv/web.py b/src/kalshi_csv/web.py new file mode 100644 index 0000000..bcfa16d --- /dev/null +++ b/src/kalshi_csv/web.py @@ -0,0 +1,294 @@ +import html +from datetime import datetime +from http.server import HTTPServer, BaseHTTPRequestHandler + +from . import __version__ + + +def render_portfolio_html(kalshi, csv_filename): + """Renders the full HTML 4.01 portfolio page from parsed Kalshi data.""" + summary = kalshi.summary + market_breakdown = kalshi.market_breakdown() + recent_positions = kalshi.recent_closed_positions(20) + + period_end = summary["latest_close_date"] + if period_end: + period_end_str = period_end.strftime("%B %d, %Y").upper() + else: + period_end_str = "N/A" + + net_pnl = summary["total_pnl_with_fees"] + net_pnl_color = "#006600" if net_pnl >= 0 else "#990000" + net_pnl_str = f"${net_pnl:+.2f}" + + wins = summary["wins"] + losses = summary["losses"] + pushes = summary["pushes"] + total = summary["trade_count"] + win_pct = (wins / total * 100) if total > 0 else 0 + push_note = f" ({pushes} Push{'s' if pushes != 1 else ''})" if pushes > 0 else "" + + best_trade = summary["best_trade"] + worst_trade = summary["worst_trade"] + if best_trade and worst_trade: + best_pnl = best_trade["pnl_with_fees"] + worst_pnl = worst_trade["pnl_with_fees"] + best_cat = best_trade["market_category"] + worst_cat = worst_trade["market_category"] + best_color = "#006600" if best_pnl >= 0 else "#990000" + worst_color = "#006600" if worst_pnl >= 0 else "#990000" + best_str = f"${best_pnl:+.2f}" + worst_str = f"${worst_pnl:+.2f}" + best_worst_subtext = f"{best_cat} / {worst_cat}" + else: + best_str = "$0.00" + worst_str = "$0.00" + best_color = "#006600" + worst_color = "#990000" + best_worst_subtext = "N/A" + + rows_html = "" + for i, item in enumerate(market_breakdown): + pnl = item["net_pnl"] + pnl_color = "#006600" if pnl >= 0 else "#990000" + pnl_str = f"${pnl:+.2f}" + win_rate_str = f"{item['win_rate']:.1f}%" + rows_html += f""" + + {html.escape(item['category'])} + {item['trades']} + {win_rate_str} + {pnl_str} + """ + if i < len(market_breakdown) - 1: + rows_html += """ +
""" + + positions_html = "" + for trade in recent_positions: + close_dt = trade["close_timestamp"] + date_str = close_dt.strftime("%m/%d %H:%M") if close_dt else "N/A" + ticker = html.escape(trade["ticker"]) + side = trade["side"] + qty = f"{trade['qty']:.2f}" + entry = f"${trade['entry']:.2f}" + exit_val = f"${trade['exit']:.2f}" + pnl = trade["pnl_with_fees"] + pnl_color = "#006600" if pnl >= 0 else "#990000" + pnl_str = f"${pnl:+.2f}" + positions_html += f""" + + {date_str} + {ticker} + {side} + {qty} + {entry} + {exit_val} + {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:VARIOUS

Date Sold:VARIOUS

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

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

Gain or (Loss):${irs['gain_or_loss']:+.2f}
+ + +""" + + page = f""" + + + Kalshi Portfolio Statement + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + {irs_html} + + + + + + +
+ KALSHI DERIVATIVES / ACCOUNT AUDIT
+ Year-End Performance Summary
+ PERIOD ENDING: {html.escape(period_end_str)}  |  SOURCE: {html.escape(csv_filename)} +
+
+
+ + + + + + + +
+ NET REALIZED P&L
+ {net_pnl_str}
+ Includes ${summary['total_fees']:.2f} fees +
+ WIN / LOSS RECORD
+ {wins} - {losses}
+ {pushes} Push{'s' if pushes != 1 else ''} ({win_pct:.1f}% Win) +
+ TOTAL VOLUME
+ {total}
+ Executed Contracts +
+ BEST/WORST SINGLE
+ {best_str} / {worst_str}
+ {html.escape(best_worst_subtext)} +
+



+ Market Breakdown +

+ + + + + + + + + {rows_html} +
ASSET CLASS / MARKETTRADESWIN RATENET P&L
+



+ Recent Closed Positions +

+ + + + + + + + + + + + {positions_html} +
DATE/TIMETICKERSIDEQTYENTRYEXITP&L
+


+ + {html.escape(csv_filename)} • rendered with vanilla HTML 4.01 strict table markup • kalshi-csv v{__version__} + +
+
+ + +""" + return page + + +class LegacyWebHandler(BaseHTTPRequestHandler): + """HTTP request handler that serves the legacy portfolio page.""" + + def do_GET(self): + if self.path == "/" or self.path == "/index.html": + self.send_response(200) + self.send_header("Content-type", "text/html; charset=iso-8859-1") + self.end_headers() + html_content = self.server.html_content + self.wfile.write(html_content.encode("iso-8859-1")) + else: + self.send_error(404, "Not Found") + + def log_message(self, format, *args): + pass + + +class LegacyWebServer: + """HTTP server for the legacy portfolio view.""" + + def __init__(self, kalshi, csv_filename, host="0.0.0.0", port=8080): + self.kalshi = kalshi + self.csv_filename = csv_filename + self.host = host + self.port = port + self.html_content = render_portfolio_html(kalshi, csv_filename) + + def serve(self): + """Starts the HTTP server and blocks until interrupted.""" + server = HTTPServer((self.host, self.port), LegacyWebHandler) + server.html_content = self.html_content + print(f"Serving legacy portfolio view at http://{self.host}:{self.port}/") + print("Press Ctrl+C to stop.") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down server.") + server.server_close() diff --git a/tests/test_categories.py b/tests/test_categories.py new file mode 100644 index 0000000..e8a50e4 --- /dev/null +++ b/tests/test_categories.py @@ -0,0 +1,48 @@ +from kalshi_csv.categories import categorize_ticker + + +def test_mlb_categorization(): + assert categorize_ticker("KXMLBGAME-26JUL081940BOSCWS-BOS") == "MLB Baseball" + assert categorize_ticker("KXMLBHRDERBY-26-KSCHWARBER12") == "MLB Baseball" + + +def test_npb_categorization(): + assert categorize_ticker("KXNPBGAME-26JUL150500YOMYAK-YAK") == "NPB Baseball (Japan)" + + +def test_nba_summer_categorization(): + assert categorize_ticker("KXNBASUMMERGAME-26JUL14MEMGSW-GSW") == "NBA Summer League" + + +def test_wnba_categorization(): + assert categorize_ticker("KXWNBAGAME-26JUL13PHXMIN-PHX") == "WNBA Basketball" + + +def test_sp500_categorization(): + assert categorize_ticker("KXINXU-26JUL08H1400-T7479.9999") == "S&P 500 (INXU Intraday)" + assert categorize_ticker("KXINX-26JUL08H1400-T7479.9999") == "S&P 500 (INXU Intraday)" + + +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(): + assert categorize_ticker("KXWCADVANCE-26JUL07ARGEGY-ARG") == "Global Soccer / Football" + assert categorize_ticker("KXUCLADVANCE-26JUL14KUPSVAR-VAR") == "Global Soccer / Football" + assert categorize_ticker("KXBRASILEIROBGAME-26JUL13AMGLON-LON") == "Global Soccer / Football" + assert categorize_ticker("KXECULPGAME-26JUL14MACMUR-MUR") == "Global Soccer / Football" + assert categorize_ticker("KXALLSVENSKANGAME-26JUL12BROSIR-SIR") == "Global Soccer / Football" + assert categorize_ticker("KXCLUBFGAME-26JUL27GALVEN-VEN") == "Global Soccer / Football" + + +def test_other_markets_categorization(): + assert categorize_ticker("KXRAIN-26JUL15-ATL") == "Other Markets" + assert categorize_ticker("KXTRUMPMENTION-26JUL15") == "Other Markets" + assert categorize_ticker("KXTEMPNYCH-26JUL15") == "Other Markets" + assert categorize_ticker("KXHIGHCHI-26JUL15") == "Other Markets" + + +def test_unknown_ticker_defaults_to_other(): + assert categorize_ticker("UNKNOWN-TICKER-123") == "Other Markets" diff --git a/tests/test_cli.py b/tests/test_cli.py index 13ac9ac..2276964 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -103,3 +103,94 @@ def test_cli_ascii_flag(sample_csv): assert "|" in result.stdout assert "┌" not in result.stdout assert "│" not in result.stdout + + +def test_cli_legacy_web_flag_in_help(): + result = subprocess.run( + [sys.executable, "-m", "kalshi_csv.cli", "--help"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "--legacy-web" in result.stdout + assert "--legacy-web-port" in result.stdout + + +def test_cli_legacy_web_port_default_in_help(): + result = subprocess.run( + [sys.executable, "-m", "kalshi_csv.cli", "--help"], + capture_output=True, + text=True, + ) + 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_parser.py b/tests/test_parser.py index 83a77a6..f35eb92 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -89,3 +89,88 @@ def test_file_not_found(): kalshi = KalshiCSV("/nonexistent/path.csv") with pytest.raises(FileNotFoundError): kalshi.parse() + + +def test_market_breakdown_returns_list(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + breakdown = kalshi.market_breakdown() + assert isinstance(breakdown, list) + assert len(breakdown) > 0 + + +def test_market_breakdown_structure(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + breakdown = kalshi.market_breakdown() + for item in breakdown: + assert "category" in item + assert "trades" in item + assert "win_rate" in item + assert "net_pnl" in item + + +def test_market_breakdown_sorted_by_trades(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + breakdown = kalshi.market_breakdown() + trade_counts = [item["trades"] for item in breakdown] + assert trade_counts == sorted(trade_counts, reverse=True) + + +def test_recent_closed_positions_returns_list(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + positions = kalshi.recent_closed_positions() + assert isinstance(positions, list) + + +def test_recent_closed_positions_limit(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + positions = kalshi.recent_closed_positions(n=2) + assert len(positions) <= 2 + + +def test_recent_closed_positions_sorted_by_date(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + positions = kalshi.recent_closed_positions() + if len(positions) > 1: + timestamps = [p["close_timestamp"] for p in positions] + assert timestamps == sorted(timestamps, reverse=True) + + +def test_summary_tracks_wins_losses_pushes(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + assert kalshi.summary["wins"] >= 0 + assert kalshi.summary["losses"] >= 0 + assert kalshi.summary["pushes"] >= 0 + assert kalshi.summary["wins"] + kalshi.summary["losses"] + kalshi.summary["pushes"] == kalshi.summary["trade_count"] + + +def test_summary_tracks_best_worst_trade(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + assert kalshi.summary["best_trade"] is not None + assert kalshi.summary["worst_trade"] is not None + assert "pnl_with_fees" in kalshi.summary["best_trade"] + assert "pnl_with_fees" in kalshi.summary["worst_trade"] + assert kalshi.summary["best_trade"]["pnl_with_fees"] >= kalshi.summary["worst_trade"]["pnl_with_fees"] + + +def test_trade_has_market_category(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + for trade in kalshi.trades: + assert "market_category" in trade + assert isinstance(trade["market_category"], str) + + +def test_trade_has_timestamps(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + for trade in kalshi.trades: + assert "open_timestamp" in trade + assert "close_timestamp" in trade diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..b1fddbb --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,130 @@ +from kalshi_csv import KalshiCSV +from kalshi_csv.web import render_portfolio_html + + +def test_render_html_contains_header(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + html_content = render_portfolio_html(kalshi, "test.csv") + assert "KALSHI DERIVATIVES / ACCOUNT AUDIT" in html_content + assert "Year-End Performance Summary" in html_content + + +def test_render_html_contains_summary_metrics(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + html_content = render_portfolio_html(kalshi, "test.csv") + assert "NET REALIZED P&L" in html_content + assert "WIN / LOSS RECORD" in html_content + assert "TOTAL VOLUME" in html_content + assert "BEST/WORST SINGLE" in html_content + + +def test_render_html_contains_market_breakdown(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + html_content = render_portfolio_html(kalshi, "test.csv") + assert "Market Breakdown" in html_content + assert "ASSET CLASS / MARKET" in html_content + assert "TRADES" in html_content + assert "WIN RATE" in html_content + + +def test_render_html_contains_recent_positions(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + html_content = render_portfolio_html(kalshi, "test.csv") + assert "Recent Closed Positions" in html_content + assert "DATE/TIME" in html_content + assert "TICKER" in html_content + assert "SIDE" in html_content + assert "ENTRY" in html_content + assert "EXIT" in html_content + + +def test_render_html_contains_trade_data(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + html_content = render_portfolio_html(kalshi, "test.csv") + assert "TESTMARKET-WIN" in html_content + assert "TESTMARKET-LOSS" in html_content + assert "TESTMARKET-SMALL" in html_content + + +def test_render_html_html401_doctype(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + html_content = render_portfolio_html(kalshi, "test.csv") + assert '3<" in html_content + + +def test_render_html_shows_csv_filename(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + html_content = render_portfolio_html(kalshi, "my-kalshi-data.csv") + assert "my-kalshi-data.csv" in html_content + + +def test_render_html_escapes_html_in_tickers(sample_csv): + kalshi = KalshiCSV(sample_csv) + kalshi.parse() + kalshi.trades[0]["ticker"] = "" + 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