From f7171c025c23d8d46931042f6dba48c4e6beeda9 Mon Sep 17 00:00:00 2001 From: mark Date: Tue, 28 Jul 2026 13:27:25 -0400 Subject: [PATCH 1/3] Start of work on 0.2.0, add legacy compatible web view --- pyproject.toml | 2 +- src/kalshi_csv/__init__.py | 2 +- src/kalshi_csv/categories.py | 44 +++++++ src/kalshi_csv/cli.py | 19 +++ src/kalshi_csv/parser.py | 110 +++++++++++++--- src/kalshi_csv/web.py | 239 +++++++++++++++++++++++++++++++++++ tests/test_categories.py | 47 +++++++ tests/test_cli.py | 21 +++ tests/test_parser.py | 85 +++++++++++++ tests/test_web.py | 96 ++++++++++++++ 10 files changed, 642 insertions(+), 23 deletions(-) create mode 100644 src/kalshi_csv/categories.py create mode 100644 src/kalshi_csv/web.py create mode 100644 tests/test_categories.py create mode 100644 tests/test_web.py diff --git a/pyproject.toml b/pyproject.toml index c1de816..60facd6 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" 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..061b3ae --- /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)", + "KXMVESPORTS": "Esports & Gaming", +} + +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..0466fdf 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 @@ -35,6 +36,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 +55,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] 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..4e12aca --- /dev/null +++ b/src/kalshi_csv/web.py @@ -0,0 +1,239 @@ +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} + """ + + page = f""" + + + Kalshi Portfolio Statement + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 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..644ed71 --- /dev/null +++ b/tests/test_categories.py @@ -0,0 +1,47 @@ +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_esports_categorization(): + assert categorize_ticker("KXMVESPORTSMULTIGAMEEXTENDED-S2026769CE3FA3F9-6D4DB2E2128") == "Esports & Gaming" + + +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..5e67d76 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -103,3 +103,24 @@ 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 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..d8779ba --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,96 @@ +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 From 932fd06f71b1f11cb25940c4ee609cf98c0c36c6 Mon Sep 17 00:00:00 2001 From: mark Date: Wed, 29 Jul 2026 09:33:22 -0400 Subject: [PATCH 2/3] 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 From 8cb809bbb222d8b96f44d2340f4320bece6f31c3 Mon Sep 17 00:00:00 2001 From: mark Date: Fri, 31 Jul 2026 10:53:30 -0400 Subject: [PATCH 3/3] Add new handling of acquired/sold dates --- README.md | 57 ++++++++++++++++++++++++++++++++++++++++--- pyproject.toml | 2 +- requirements.txt | 33 +++++++++++++++++++++++++ src/kalshi_csv/cli.py | 8 +++--- src/kalshi_csv/web.py | 4 +-- 5 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 requirements.txt diff --git a/README.md b/README.md index 38d791d..e70fb69 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ pip install kalshi-csv - **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 @@ -163,8 +164,8 @@ Internal Tracked Net P&L: $+0.26 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 @@ -286,6 +287,54 @@ for trade in recent: - `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: @@ -300,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/pyproject.toml b/pyproject.toml index 60facd6..9b646dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/cli.py b/src/kalshi_csv/cli.py index c474236..008a366 100644 --- a/src/kalshi_csv/cli.py +++ b/src/kalshi_csv/cli.py @@ -175,8 +175,8 @@ def main(): 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)}") @@ -189,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/web.py b/src/kalshi_csv/web.py index 04384b3..bcfa16d 100644 --- a/src/kalshi_csv/web.py +++ b/src/kalshi_csv/web.py @@ -113,12 +113,12 @@ def render_portfolio_html(kalshi, csv_filename):
Date Acquired: - {html.escape(irs['date_acquired'])} + VARIOUS
Date Sold: - {html.escape(irs['date_sold'])} + VARIOUS