Compare commits
3 commits
ebeaa8fdc4
...
8cb809bbb2
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cb809bbb2 | |||
| 932fd06f71 | |||
| f7171c025c |
14 changed files with 1111 additions and 33 deletions
180
README.md
180
README.md
|
|
@ -8,6 +8,14 @@ Parse Kalshi transaction CSV files and generate IRS Form 8949 tax summaries for
|
||||||
pip install kalshi-csv
|
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
|
## Getting Your Transactions CSV
|
||||||
|
|
||||||
Download your transaction history from Kalshi:
|
Download your transaction history from Kalshi:
|
||||||
|
|
@ -32,7 +40,7 @@ Rows without `realized_pnl_without_fees_dollars` are automatically skipped.
|
||||||
|
|
||||||
## CLI Usage
|
## 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
|
```bash
|
||||||
kalshi-csv Kalshi-Transactions-2026.csv
|
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
|
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
|
### Sample Output
|
||||||
|
|
||||||
Default mode (Unicode box-drawing):
|
Default mode (Unicode box-drawing):
|
||||||
|
|
@ -72,6 +92,27 @@ Total Transactions Parsed: 3
|
||||||
Total Exchange Fees Paid: $0.02
|
Total Exchange Fees Paid: $0.02
|
||||||
Internal Tracked Net P&L: $+0.26
|
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 ===
|
=== IRS FORM 8949 / SCHEDULE D AGGREGATE SUMMARY ===
|
||||||
Use these exact aggregates for a single-line summary entry:
|
Use these exact aggregates for a single-line summary entry:
|
||||||
* Box to Check: Box C (Short-term, not reported on Form 1099-B)
|
* 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
|
Total Exchange Fees Paid: $0.02
|
||||||
Internal Tracked Net P&L: $+0.26
|
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 ===
|
=== IRS FORM 8949 / SCHEDULE D AGGREGATE SUMMARY ===
|
||||||
Use these exact aggregates for a single-line summary entry:
|
Use these exact aggregates for a single-line summary entry:
|
||||||
* Box to Check: Box C (Short-term, not reported on Form 1099-B)
|
* Box to Check: Box C (Short-term, not reported on Form 1099-B)
|
||||||
* (a) Description: Kalshi Event Contracts (Aggregate Summary)
|
* (a) Description: Kalshi Event Contracts (Aggregate Summary)
|
||||||
* (b) Date Acquired: 07/07/2026
|
* (b) Date Acquired: VARIOUS
|
||||||
* (c) Date Sold: 07/08/2026
|
* (c) Date Sold: VARIOUS
|
||||||
* (d) Gross Proceeds: $+2.50
|
* (d) Gross Proceeds: $+2.50
|
||||||
* (e) Cost or Other Basis: $2.24
|
* (e) Cost or Other Basis: $2.24
|
||||||
* (h) Gain or (Loss): $+0.26
|
* (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://<your-ip>: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
|
## Library API
|
||||||
|
|
||||||
Use `kalshi-csv` as a Python library in your own scripts:
|
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"Gross Proceeds: ${irs['gross_proceeds']:.2f}")
|
||||||
print(f"Cost Basis: ${irs['cost_basis']:.2f}")
|
print(f"Cost Basis: ${irs['cost_basis']:.2f}")
|
||||||
print(f"Gain/Loss: ${irs['gain_or_loss']:.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
|
### Data Structures
|
||||||
|
|
@ -149,6 +263,9 @@ print(f"Gain/Loss: ${irs['gain_or_loss']:.2f}")
|
||||||
- `pnl_with_fees`: P&L including fees
|
- `pnl_with_fees`: P&L including fees
|
||||||
- `open_fees`: Opening fees
|
- `open_fees`: Opening fees
|
||||||
- `close_fees`: Closing 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`):
|
**Summary dict** (`kalshi.summary`):
|
||||||
- `trade_count`: Number of trades parsed
|
- `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_pnl_with_fees`: Total P&L including fees
|
||||||
- `total_tax_basis`: Total cost basis for IRS reporting
|
- `total_tax_basis`: Total cost basis for IRS reporting
|
||||||
- `total_tax_proceeds`: Total proceeds 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()`):
|
**IRS summary dict** (`kalshi.irs_summary()`):
|
||||||
- `box`: "C" (for Form 8949 Box C)
|
- `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
|
- `cost_basis`: Total cost basis
|
||||||
- `gain_or_loss`: Net gain or loss
|
- `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
|
## 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:
|
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
|
## 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)
|
- **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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
BIN
kalshi-csv.jpg
Normal file
BIN
kalshi-csv.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 994 B |
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kalshi-csv"
|
name = "kalshi-csv"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
description = "Parse Kalshi transaction CSV files and generate IRS Form 8949 tax summaries"
|
description = "Parse Kalshi transaction CSV files and generate IRS Form 8949 tax summaries"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
@ -28,7 +28,7 @@ keywords = ["kalshi", "csv", "tax", "irs", "form-8949"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/MARKMENTAL/kalshi-csv"
|
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"
|
Issues = "https://github.com/MARKMENTAL/kalshi-csv/issues"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
33
requirements.txt
Normal file
33
requirements.txt
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
__version__ = "0.1.2"
|
__version__ = "0.2.0"
|
||||||
|
|
||||||
from .parser import KalshiCSV
|
from .parser import KalshiCSV
|
||||||
|
|
||||||
|
|
|
||||||
44
src/kalshi_csv/categories.py
Normal file
44
src/kalshi_csv/categories.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .parser import KalshiCSV
|
from .parser import KalshiCSV
|
||||||
|
|
@ -13,9 +14,17 @@ from .formatter import (
|
||||||
format_table_separator,
|
format_table_separator,
|
||||||
format_table_row,
|
format_table_row,
|
||||||
get_box_chars,
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Parse Kalshi transaction CSV and generate IRS tax summary."
|
description="Parse Kalshi transaction CSV and generate IRS tax summary."
|
||||||
|
|
@ -35,6 +44,17 @@ def main():
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Use ASCII characters instead of Unicode box-drawing",
|
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()
|
args = parser.parse_args()
|
||||||
no_color = args.no_color
|
no_color = args.no_color
|
||||||
|
|
@ -43,6 +63,13 @@ def main():
|
||||||
kalshi = KalshiCSV(args.csv_path)
|
kalshi = KalshiCSV(args.csv_path)
|
||||||
kalshi.parse()
|
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"]
|
headers = ["Ticker", "Side", "Qty", "Entry", "Exit", "P&L (No Fees)", "Fees"]
|
||||||
widths = [32, 4, 6, 6, 6, 14, 6]
|
widths = [32, 4, 6, 6, 6, 14, 6]
|
||||||
|
|
||||||
|
|
@ -77,13 +104,79 @@ def main():
|
||||||
)
|
)
|
||||||
print("-" * 83)
|
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()
|
irs = kalshi.irs_summary()
|
||||||
print(color_yellow("=== IRS FORM 8949 / SCHEDULE D AGGREGATE SUMMARY ===", no_color))
|
print(color_yellow("=== IRS FORM 8949 / SCHEDULE D AGGREGATE SUMMARY ===", no_color))
|
||||||
print("Use these exact aggregates for a single-line summary entry:")
|
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" * 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" * (a) Description: {irs['description']}")
|
||||||
print(f" * (b) Date Acquired: {irs['date_acquired']}")
|
print(f" * (b) Date Acquired: VARIOUS")
|
||||||
print(f" * (c) Date Sold: {irs['date_sold']}")
|
print(f" * (c) Date Sold: VARIOUS")
|
||||||
print(f" * (d) Gross Proceeds: {format_currency_color(irs['gross_proceeds'], no_color)}")
|
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" * (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)}")
|
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("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" * 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" * (a) Description: {irs['description']}\n")
|
||||||
f.write(f" * (b) Date Acquired: {irs['date_acquired']}\n")
|
f.write(f" * (b) Date Acquired: VARIOUS\n")
|
||||||
f.write(f" * (c) Date Sold: {irs['date_sold']}\n")
|
f.write(f" * (c) Date Sold: VARIOUS\n")
|
||||||
f.write(f" * (d) Gross Proceeds: ${irs['gross_proceeds']:.2f}\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" * (e) Cost or Other Basis: ${irs['cost_basis']:.2f}\n")
|
||||||
f.write(f" * (h) Gain or (Loss): ${irs['gain_or_loss']:.2f}\n")
|
f.write(f" * (h) Gain or (Loss): ${irs['gain_or_loss']:.2f}\n")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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):
|
def truncate_ticker(ticker, max_len=32):
|
||||||
"""Truncates ticker to max_len, using ellipsis if longer than 29 chars."""
|
"""Truncates ticker to max_len, using ellipsis if longer than 29 chars."""
|
||||||
if len(ticker) > 29:
|
if len(ticker) > 29:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import csv
|
import csv
|
||||||
import os
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .categories import categorize_ticker
|
||||||
|
|
||||||
|
|
||||||
class KalshiCSV:
|
class KalshiCSV:
|
||||||
"""Parses Kalshi transaction CSV data and calculates tax-relevant aggregates."""
|
"""Parses Kalshi transaction CSV data and calculates tax-relevant aggregates."""
|
||||||
|
|
@ -18,6 +21,11 @@ class KalshiCSV:
|
||||||
"total_tax_proceeds": 0.0,
|
"total_tax_proceeds": 0.0,
|
||||||
"earliest_open_date": None,
|
"earliest_open_date": None,
|
||||||
"latest_close_date": None,
|
"latest_close_date": None,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0,
|
||||||
|
"pushes": 0,
|
||||||
|
"best_trade": None,
|
||||||
|
"worst_trade": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def parse(self):
|
def parse(self):
|
||||||
|
|
@ -40,8 +48,22 @@ class KalshiCSV:
|
||||||
open_fees = float(row["open_fees_dollars"])
|
open_fees = float(row["open_fees_dollars"])
|
||||||
close_fees = float(row["close_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 = {
|
trade = {
|
||||||
"ticker": row["market_ticker"],
|
"ticker": ticker,
|
||||||
"side": row["side"].upper(),
|
"side": row["side"].upper(),
|
||||||
"qty": qty,
|
"qty": qty,
|
||||||
"entry": entry,
|
"entry": entry,
|
||||||
|
|
@ -50,6 +72,9 @@ class KalshiCSV:
|
||||||
"pnl_with_fees": pnl_with_fees,
|
"pnl_with_fees": pnl_with_fees,
|
||||||
"open_fees": open_fees,
|
"open_fees": open_fees,
|
||||||
"close_fees": close_fees,
|
"close_fees": close_fees,
|
||||||
|
"open_timestamp": open_dt,
|
||||||
|
"close_timestamp": close_dt,
|
||||||
|
"market_category": categorize_ticker(ticker),
|
||||||
}
|
}
|
||||||
self.trades.append(trade)
|
self.trades.append(trade)
|
||||||
|
|
||||||
|
|
@ -60,27 +85,37 @@ class KalshiCSV:
|
||||||
self.summary["total_pnl_with_fees"] += pnl_with_fees
|
self.summary["total_pnl_with_fees"] += pnl_with_fees
|
||||||
self.summary["total_fees"] += open_fees + close_fees
|
self.summary["total_fees"] += open_fees + close_fees
|
||||||
|
|
||||||
if row.get("open_timestamp"):
|
if pnl_with_fees > 0:
|
||||||
try:
|
self.summary["wins"] += 1
|
||||||
open_dt = datetime.fromisoformat(row["open_timestamp"])
|
elif pnl_with_fees < 0:
|
||||||
if (
|
self.summary["losses"] += 1
|
||||||
self.summary["earliest_open_date"] is None
|
else:
|
||||||
or open_dt < self.summary["earliest_open_date"]
|
self.summary["pushes"] += 1
|
||||||
):
|
|
||||||
self.summary["earliest_open_date"] = open_dt
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if row.get("close_timestamp"):
|
if (
|
||||||
try:
|
self.summary["best_trade"] is None
|
||||||
close_dt = datetime.fromisoformat(row["close_timestamp"])
|
or pnl_with_fees > self.summary["best_trade"]["pnl_with_fees"]
|
||||||
if (
|
):
|
||||||
self.summary["latest_close_date"] is None
|
self.summary["best_trade"] = trade
|
||||||
or close_dt > self.summary["latest_close_date"]
|
if (
|
||||||
):
|
self.summary["worst_trade"] is None
|
||||||
self.summary["latest_close_date"] = close_dt
|
or pnl_with_fees < self.summary["worst_trade"]["pnl_with_fees"]
|
||||||
except ValueError:
|
):
|
||||||
pass
|
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
|
return self
|
||||||
|
|
||||||
|
|
@ -101,3 +136,36 @@ class KalshiCSV:
|
||||||
"cost_basis": self.summary["total_tax_basis"],
|
"cost_basis": self.summary["total_tax_basis"],
|
||||||
"gain_or_loss": self.summary["total_pnl_with_fees"],
|
"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]
|
||||||
|
|
|
||||||
294
src/kalshi_csv/web.py
Normal file
294
src/kalshi_csv/web.py
Normal file
|
|
@ -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"""
|
||||||
|
<tr>
|
||||||
|
<td align="left"><font face="Geneva, Verdana, sans-serif" size="2">{html.escape(item['category'])}</font></td>
|
||||||
|
<td align="right"><font face="Courier New, Courier, monospace" size="2">{item['trades']}</font></td>
|
||||||
|
<td align="right"><font face="Courier New, Courier, monospace" size="2">{win_rate_str}</font></td>
|
||||||
|
<td align="right"><font face="Courier New, Courier, monospace" size="2" color="{pnl_color}"><b>{pnl_str}</b></font></td>
|
||||||
|
</tr>"""
|
||||||
|
if i < len(market_breakdown) - 1:
|
||||||
|
rows_html += """
|
||||||
|
<tr><td colspan="4"><hr size="1" color="#E0E0E0" noshade></td></tr>"""
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<tr>
|
||||||
|
<td align="left"><font face="Courier New, Courier, monospace" size="1">{date_str}</font></td>
|
||||||
|
<td align="left"><font face="Courier New, Courier, monospace" size="1">{ticker}</font></td>
|
||||||
|
<td align="center"><font face="Courier New, Courier, monospace" size="1">{side}</font></td>
|
||||||
|
<td align="right"><font face="Courier New, Courier, monospace" size="1">{qty}</font></td>
|
||||||
|
<td align="right"><font face="Courier New, Courier, monospace" size="1">{entry}</font></td>
|
||||||
|
<td align="right"><font face="Courier New, Courier, monospace" size="1">{exit_val}</font></td>
|
||||||
|
<td align="right"><font face="Courier New, Courier, monospace" size="1" color="{pnl_color}"><b>{pnl_str}</b></font></td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
# 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"""
|
||||||
|
<tr><td><br><hr size="1" color="#CCCCCC" noshade><br></td></tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<font face="Georgia, Times New Roman, serif" size="3"><b>IRS Form 8949 / Schedule D Summary</b></font>
|
||||||
|
<br><br>
|
||||||
|
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="4">
|
||||||
|
<tr>
|
||||||
|
<td width="40%" align="left"><font face="Geneva, Verdana, sans-serif" size="1" color="#666666">Box to Check:</font></td>
|
||||||
|
<td width="60%" align="left"><font face="Courier New, Courier, monospace" size="2"><b>{html.escape(irs['box'])}</b></font></td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan="2"><hr size="1" color="#E0E0E0" noshade></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><font face="Geneva, Verdana, sans-serif" size="1" color="#666666">Description:</font></td>
|
||||||
|
<td align="left"><font face="Courier New, Courier, monospace" size="2">{html.escape(irs['description'])}</font></td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan="2"><hr size="1" color="#E0E0E0" noshade></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><font face="Geneva, Verdana, sans-serif" size="1" color="#666666">Date Acquired:</font></td>
|
||||||
|
<td align="left"><font face="Courier New, Courier, monospace" size="2">VARIOUS</font></td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan="2"><hr size="1" color="#E0E0E0" noshade></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><font face="Geneva, Verdana, sans-serif" size="1" color="#666666">Date Sold:</font></td>
|
||||||
|
<td align="left"><font face="Courier New, Courier, monospace" size="2">VARIOUS</font></td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan="2"><hr size="1" color="#E0E0E0" noshade></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><font face="Geneva, Verdana, sans-serif" size="1" color="#666666">Gross Proceeds:</font></td>
|
||||||
|
<td align="left"><font face="Courier New, Courier, monospace" size="2" color="{gross_proceeds_color}"><b>${irs['gross_proceeds']:.2f}</b></font></td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan="2"><hr size="1" color="#E0E0E0" noshade></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><font face="Geneva, Verdana, sans-serif" size="1" color="#666666">Cost or Other Basis:</font></td>
|
||||||
|
<td align="left"><font face="Courier New, Courier, monospace" size="2" color="{cost_basis_color}"><b>${irs['cost_basis']:.2f}</b></font></td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan="2"><hr size="1" color="#E0E0E0" noshade></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><font face="Geneva, Verdana, sans-serif" size="1" color="#666666">Gain or (Loss):</font></td>
|
||||||
|
<td align="left"><font face="Courier New, Courier, monospace" size="2" color="{gain_loss_color}"><b>${irs['gain_or_loss']:+.2f}</b></font></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
page = f"""<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Kalshi Portfolio Statement</title>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
|
||||||
|
</head>
|
||||||
|
<body bgcolor="#FFFFFF" text="#111111" link="#111111" vlink="#444444" alink="#000000" topmargin="20" leftmargin="20" marginwidth="20" marginheight="20">
|
||||||
|
|
||||||
|
<center>
|
||||||
|
<table width="720" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="left">
|
||||||
|
<font face="Courier New, Courier, monospace" size="2"><b>KALSHI DERIVATIVES / ACCOUNT AUDIT</b></font><br>
|
||||||
|
<font face="Georgia, Times New Roman, serif" size="5"><b>Year-End Performance Summary</b></font><br>
|
||||||
|
<font face="Geneva, Verdana, sans-serif" size="1" color="#666666">PERIOD ENDING: {html.escape(period_end_str)} | SOURCE: {html.escape(csv_filename)}</font>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td padding="10">
|
||||||
|
<hr size="2" color="#111111" noshade>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="6">
|
||||||
|
<tr valign="top">
|
||||||
|
<td width="25%">
|
||||||
|
<font face="Geneva, Verdana, sans-serif" size="1" color="#666666">NET REALIZED P&L</font><br>
|
||||||
|
<font face="Courier New, Courier, monospace" size="4" color="{net_pnl_color}"><b>{net_pnl_str}</b></font><br>
|
||||||
|
<font face="Geneva, Verdana, sans-serif" size="1" color="#888888">Includes ${summary['total_fees']:.2f} fees</font>
|
||||||
|
</td>
|
||||||
|
<td width="25%">
|
||||||
|
<font face="Geneva, Verdana, sans-serif" size="1" color="#666666">WIN / LOSS RECORD</font><br>
|
||||||
|
<font face="Courier New, Courier, monospace" size="4"><b>{wins} - {losses}</b></font><br>
|
||||||
|
<font face="Geneva, Verdana, sans-serif" size="1" color="#888888">{pushes} Push{'s' if pushes != 1 else ''} ({win_pct:.1f}% Win)</font>
|
||||||
|
</td>
|
||||||
|
<td width="25%">
|
||||||
|
<font face="Geneva, Verdana, sans-serif" size="1" color="#666666">TOTAL VOLUME</font><br>
|
||||||
|
<font face="Courier New, Courier, monospace" size="4"><b>{total}</b></font><br>
|
||||||
|
<font face="Geneva, Verdana, sans-serif" size="1" color="#888888">Executed Contracts</font>
|
||||||
|
</td>
|
||||||
|
<td width="25%">
|
||||||
|
<font face="Geneva, Verdana, sans-serif" size="1" color="#666666">BEST/WORST SINGLE</font><br>
|
||||||
|
<font face="Courier New, Courier, monospace" size="2"><font color="{best_color}"><b>{best_str}</b></font> / <font color="{worst_color}"><b>{worst_str}</b></font></font><br>
|
||||||
|
<font face="Geneva, Verdana, sans-serif" size="1" color="#888888">{html.escape(best_worst_subtext)}</font>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr><td><br><hr size="1" color="#CCCCCC" noshade><br></td></tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<font face="Georgia, Times New Roman, serif" size="3"><b>Market Breakdown</b></font>
|
||||||
|
<br><br>
|
||||||
|
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="4">
|
||||||
|
<tr bgcolor="#EEEEEE">
|
||||||
|
<td width="45%" align="left"><font face="Geneva, Verdana, sans-serif" size="1"><b>ASSET CLASS / MARKET</b></font></td>
|
||||||
|
<td width="15%" align="right"><font face="Geneva, Verdana, sans-serif" size="1"><b>TRADES</b></font></td>
|
||||||
|
<td width="20%" align="right"><font face="Geneva, Verdana, sans-serif" size="1"><b>WIN RATE</b></font></td>
|
||||||
|
<td width="20%" align="right"><font face="Geneva, Verdana, sans-serif" size="1"><b>NET P&L</b></font></td>
|
||||||
|
</tr>
|
||||||
|
{rows_html}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr><td><br><hr size="1" color="#CCCCCC" noshade><br></td></tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<font face="Georgia, Times New Roman, serif" size="3"><b>Recent Closed Positions</b></font>
|
||||||
|
<br><br>
|
||||||
|
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="3">
|
||||||
|
<tr bgcolor="#EEEEEE">
|
||||||
|
<th align="left"><font face="Geneva, Verdana, sans-serif" size="1">DATE/TIME</font></th>
|
||||||
|
<th align="left"><font face="Geneva, Verdana, sans-serif" size="1">TICKER</font></th>
|
||||||
|
<th align="center"><font face="Geneva, Verdana, sans-serif" size="1">SIDE</font></th>
|
||||||
|
<th align="right"><font face="Geneva, Verdana, sans-serif" size="1">QTY</font></th>
|
||||||
|
<th align="right"><font face="Geneva, Verdana, sans-serif" size="1">ENTRY</font></th>
|
||||||
|
<th align="right"><font face="Geneva, Verdana, sans-serif" size="1">EXIT</font></th>
|
||||||
|
<th align="right"><font face="Geneva, Verdana, sans-serif" size="1">P&L</font></th>
|
||||||
|
</tr>
|
||||||
|
{positions_html}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{irs_html}
|
||||||
|
|
||||||
|
<tr><td><br><hr size="2" color="#111111" noshade></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<font face="Geneva, Verdana, sans-serif" size="1" color="#666666">
|
||||||
|
{html.escape(csv_filename)} • rendered with vanilla HTML 4.01 strict table markup • kalshi-csv v{__version__}
|
||||||
|
</font>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</center>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
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()
|
||||||
48
tests/test_categories.py
Normal file
48
tests/test_categories.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -103,3 +103,94 @@ def test_cli_ascii_flag(sample_csv):
|
||||||
assert "|" in result.stdout
|
assert "|" in result.stdout
|
||||||
assert "┌" not in result.stdout
|
assert "┌" not 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
|
||||||
|
|
|
||||||
|
|
@ -89,3 +89,88 @@ def test_file_not_found():
|
||||||
kalshi = KalshiCSV("/nonexistent/path.csv")
|
kalshi = KalshiCSV("/nonexistent/path.csv")
|
||||||
with pytest.raises(FileNotFoundError):
|
with pytest.raises(FileNotFoundError):
|
||||||
kalshi.parse()
|
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
|
||||||
|
|
|
||||||
130
tests/test_web.py
Normal file
130
tests/test_web.py
Normal file
|
|
@ -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 '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"' in html_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_html_no_css(sample_csv):
|
||||||
|
kalshi = KalshiCSV(sample_csv)
|
||||||
|
kalshi.parse()
|
||||||
|
html_content = render_portfolio_html(kalshi, "test.csv")
|
||||||
|
assert "<style" not in html_content
|
||||||
|
assert "style=" not in html_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_html_shows_win_loss_record(sample_csv):
|
||||||
|
kalshi = KalshiCSV(sample_csv)
|
||||||
|
kalshi.parse()
|
||||||
|
html_content = render_portfolio_html(kalshi, "test.csv")
|
||||||
|
assert "2 - 1" in html_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_html_shows_total_volume(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"] = "<script>alert('xss')</script>"
|
||||||
|
html_content = render_portfolio_html(kalshi, "test.csv")
|
||||||
|
assert "<script>alert('xss')</script>" 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue