v0.3.1 - Expand on the web view, add a section for S&P trade analysis

This commit is contained in:
mark 2026-10-01 16:23:43 -04:00
commit 7a44d9e101
13 changed files with 760 additions and 27 deletions

View file

@ -2,6 +2,13 @@
Parse Kalshi transaction CSV files and generate IRS Form 8949 tax summaries for event contract trading.
## What's New in 0.3.1
- **`--local` Flag**: Add `--local` to either `--legacy-web` or `--modern-web` to bind the server to `127.0.0.1` instead of all network interfaces.
- **S&P 500 Hourly Setups**: The modern dashboard reveals up to five historically profitable setups, grouped by entry-price tier and the contract target hour encoded in the market ticker. Each setup shows its trade record, win rate, net P&L, average S&P 500 index prices at trade open and close, and an expandable list of its top winning trades.
- **Yahoo Finance Integration**: S&P 500 index prices come from Yahoo Finance (`^GSPC`). Only ticker symbols and trade timestamps are requested; transaction CSV data is never sent to a third party, and unavailable market data is reported rather than guessed.
- **Dashboard Data-Flow Documentation**: Developer notes now describe how parsed data is serialized into the HTML template and consumed by Alpine.js.
## What's New in 0.3.0
- **S&P 500 Contract Performance by Price Tier**: The HTML view groups S&P 500 contracts by entry-price tier and reports trade count, win rate, and net P&L.
@ -13,14 +20,6 @@ Parse Kalshi transaction CSV files and generate IRS Form 8949 tax summaries for
pip install kalshi-csv
```
## What's New in 0.2.0
- **Summary Cards**: View key metrics at a glance - Net Realized P&L, Win/Loss Record, Total Volume, and Best/Worst Single Trade
- **Market Breakdown**: See performance by market category with trade counts, win rates, and net P&L
- **Legacy Web Mode**: Browse your portfolio in a retro HTML 4.01 web interface compatible with older browsers (Netscape Navigator, IE 4+)
- **Market Categorization**: Automatic categorization of tickers into 7 market types (Global Soccer, MLB, NPB, NBA Summer League, WNBA, S&P 500, Multivariate Events, Other Markets)
- **Handling of Sold + Acquired Dates**: Instead of handling the aggregated Kalshi trades through approximated dates, we use `VARIOUS` to signal to the IRS that every single underlying transaction in that row independently satisfies the short-term holding period rule (one year or less), even though they were purchased at different times.
## Getting Your Transactions CSV
Download your transaction history from Kalshi:
@ -87,12 +86,18 @@ Start the modern interactive dashboard:
kalshi-csv Kalshi-Transactions-2026.csv --modern-web
```
The modern dashboard is served at `http://127.0.0.1:8080/` by default. It binds to the local machine only because it includes your full trade history. Select a theme in the header; your selection is remembered in that browser. To choose another port:
By default, the modern dashboard binds to `0.0.0.0:8080`, so it may be reachable from other devices on your network. Visit `http://127.0.0.1:8080/` on the same machine, or add `--local` to restrict access to that machine. Select a theme in the header; your selection is remembered in that browser. To choose another port:
```bash
kalshi-csv Kalshi-Transactions-2026.csv --modern-web --modern-web-port 3000
```
To bind the modern dashboard to this machine only:
```bash
kalshi-csv Kalshi-Transactions-2026.csv --modern-web --local
```
### Sample Output
Default mode (Unicode box-drawing):
@ -197,7 +202,7 @@ View your portfolio in a web browser with a retro HTML 4.01 interface compatible
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`.
This starts an HTTP server on `0.0.0.0:8080` by default, which may be reachable from other devices on your network. Access it locally at `http://127.0.0.1:8080`, or from another network device at `http://<your-ip>:8080`.
To use a different port:
@ -205,6 +210,12 @@ To use a different port:
kalshi-csv Kalshi-Transactions-2026.csv --legacy-web --legacy-web-port 3000
```
Use `--local` with either web mode to bind only to this machine (`127.0.0.1`):
```bash
kalshi-csv Kalshi-Transactions-2026.csv --legacy-web --local
```
### What's Displayed
The web interface shows:
@ -224,11 +235,14 @@ For S&P 500 contracts, the HTML view groups trades into four entry-price tiers:
The modern dashboard is a separate HTML5 interface; `--legacy-web` continues to serve the original HTML 4.01 page. It includes:
- Portfolio summary cards, an interactive market P&L chart, trade record distribution, and the S&P 500 entry-price analysis
- An interactive S&P 500 hourly suggestion card, which reveals up to five historically profitable tier/hour setups
- The full closed-trade history with ticker search, market and side filters, sortable columns, and pagination
- An expandable IRS Form 8949 summary
- All supplied color palettes, with the selected theme saved in the browser
Alpine.js is bundled with the package and served locally; the dashboard does not load scripts, fonts, or transaction data from third-party services.
Alpine.js is bundled with the package and served locally; the dashboard does not load scripts, fonts, or transaction data from third-party services. The only external request is a read-only S&P 500 price lookup from Yahoo Finance, which sends ticker symbols and trade timestamps but never your transaction CSV. That lookup needs network access and may be unavailable for some dates, in which case the dashboard shows `N/A` instead of a value.
The S&P 500 suggestion groups trades by entry-price tier and the contract target hour encoded by the ticker's `Hhhmm` field; each trade's actual open time is shown separately in its trade details. Suggested setups must have at least five trades and positive net P&L; candidates are ranked by win rate, then net P&L, and up to five are shown. Each setup shows the average S&P 500 index price (via Yahoo Finance `^GSPC` data) sampled near the trades' open and close timestamps, plus an expandable list of up to three winning trades ranked by net P&L. Win rates count positive-P&L trades over all trades, including pushes in the denominator, and net P&L includes fees. This is a summary of historical results, not a prediction or trading advice.
## Market Categorization
@ -322,6 +336,17 @@ for trade in recent:
For developers who want to contribute or run the test suite:
### Modern Dashboard Data Flow
The modern dashboard does not use a server-side templating engine. Its data moves through these steps:
1. `KalshiCSV.parse()` populates the Python trade list and summary. In `src/kalshi_csv/web.py`, `render_modern_dashboard_html()` assembles these structures and the market, S&P 500, IRS, and theme data into the `dashboard_data` dictionary.
2. The renderer serializes that dictionary with `json.dumps()` and escapes `&`, `<`, and `>` as Unicode escapes. This keeps CSV-controlled strings inside the JSON data block rather than allowing them to terminate the script element or become markup.
3. The renderer reads `src/kalshi_csv/templates/modern.html` and uses plain string replacement to inject the serialized JSON into `{{DASHBOARD_DATA}}` and the package version into `{{APP_VERSION}}`. These are simple placeholders, not template-engine directives.
4. The page stores the JSON in `<script id="dashboard-data" type="application/json">`. The locally bundled `dashboard.js` reads its `textContent`, parses it with `JSON.parse()`, and exposes the result as the Alpine.js component state. Alpine.js then drives the page’s `x-data`, `x-for`, and other interactive bindings.
When changing this flow, preserve the JSON escaping and render CSV-controlled values as text (for example, with Alpine’s `x-text`), not executable HTML. Keep the version in `pyproject.toml` and `src/kalshi_csv/__init__.py` synchronized.
### Quick Start with Make
The project includes a Makefile that automates the common build workflow. From the project root:
@ -418,3 +443,4 @@ This project is hosted in two locations, GitHub and my home Forgejo server, cont
## License
[MIT](LICENSE)

View file

@ -4,11 +4,16 @@ build-backend = "hatchling.build"
[project]
name = "kalshi-csv"
version = "0.3.0"
version = "0.3.1"
description = "Parse Kalshi transaction CSV files and generate IRS Form 8949 tax summaries"
readme = "README.md"
license = "MIT"
requires-python = ">=3.8"
dependencies = [
"backports.zoneinfo; python_version < '3.9'",
"tzdata; python_version < '3.9'",
"yfinance>=0.2.51",
]
authors = [
{ name = "markmental", email = "marky611@gmail.com" }
]

View file

@ -1,3 +1,4 @@
backports.zoneinfo; python_version < "3.9"
build==1.5.0
certifi==2026.6.17
cffi==2.1.0
@ -29,4 +30,6 @@ rfc3986==2.0.0
rich==15.0.0
SecretStorage==3.5.0
twine==7.0.0
tzdata; python_version < "3.9"
urllib3==2.7.0
yfinance>=0.2.51

View file

@ -1,4 +1,4 @@
__version__ = "0.3.0"
__version__ = "0.3.1"
from .parser import KalshiCSV

View file

@ -67,8 +67,15 @@ def main():
default=8080,
help="Port for modern web dashboard (default: 8080)",
)
parser.add_argument(
"--local",
action="store_true",
help="Bind the selected web server to 127.0.0.1 instead of all interfaces",
)
args = parser.parse_args()
if args.local and not (args.legacy_web or args.modern_web):
parser.error("--local can only be used with --legacy-web or --modern-web")
no_color = args.no_color
ascii_mode = args.ascii
@ -78,14 +85,16 @@ def main():
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)
host = "127.0.0.1" if args.local else "0.0.0.0"
server = LegacyWebServer(kalshi, csv_filename, host=host, port=args.legacy_web_port)
server.serve()
return
if args.modern_web:
from .web import ModernWebServer
csv_filename = os.path.basename(args.csv_path)
server = ModernWebServer(kalshi, csv_filename, port=args.modern_web_port)
host = "127.0.0.1" if args.local else "0.0.0.0"
server = ModernWebServer(kalshi, csv_filename, host=host, port=args.modern_web_port)
server.serve()
return

View file

@ -1,7 +1,15 @@
import csv
import os
import re
from collections import defaultdict
from datetime import datetime
from datetime import datetime, timedelta
try:
from zoneinfo import ZoneInfo
except ImportError:
from backports.zoneinfo import ZoneInfo
import yfinance as yf
from .categories import categorize_ticker
@ -13,6 +21,30 @@ SP500_PRICE_TIERS = (
">$0.70-$0.85 (Likely / Moderate ITM)",
">$0.85 (Deep ITM / High Probability)",
)
NEW_YORK_TZ = ZoneInfo("America/New_York")
SP500_TICKER_RE = re.compile(
r"^KXINXU-(?P<date>\d{2}[A-Z]{3}\d{2})H(?P<hour>\d{4})-T(?P<strike>\d+(?:\.\d+)?)$"
)
def _parse_sp500_ticker_date(date_str):
"""Parse a Kalshi YYMMMDD date string (e.g. 26SEP24 -> 2026-09-24)."""
try:
return datetime.strptime(date_str, "%y%b%d").date()
except ValueError:
return None
def _sp500_price_tier(entry):
if not 0 <= entry <= 1:
return None
if entry <= 0.40:
return SP500_PRICE_TIERS[0]
if entry <= 0.70:
return SP500_PRICE_TIERS[1]
if entry <= 0.85:
return SP500_PRICE_TIERS[2]
return SP500_PRICE_TIERS[3]
class KalshiCSV:
@ -187,19 +219,10 @@ class KalshiCSV:
if trade["market_category"] != SP500_MARKET_CATEGORY:
continue
entry = trade["entry"]
if not 0 <= entry <= 1:
label = _sp500_price_tier(trade["entry"])
if label is None:
continue
if entry <= 0.40:
label = SP500_PRICE_TIERS[0]
elif entry <= 0.70:
label = SP500_PRICE_TIERS[1]
elif entry <= 0.85:
label = SP500_PRICE_TIERS[2]
else:
label = SP500_PRICE_TIERS[3]
pnl = trade["pnl_with_fees"]
tier = tiers[label]
tier["trades"] += 1
@ -227,6 +250,216 @@ class KalshiCSV:
return breakdown
def sp500_hourly_performance(self, min_trades=5):
"""Summarize S&P 500 performance by entry-price tier and target hour.
Contract target hours come from the Hhhmm field in the market ticker.
The top five qualifying tier/hour combinations are marked as
historical recommendations. Eligible groups must meet the sample
threshold and have positive net P&L; ranking uses win rate and then
net P&L.
"""
groups = {}
for trade in self.trades:
if trade["market_category"] != SP500_MARKET_CATEGORY:
continue
price_tier = _sp500_price_tier(trade["entry"])
ticker_data = self._parse_sp500_ticker(trade.get("ticker", ""))
if price_tier is None or ticker_data is None:
continue
target_hour = ticker_data["cutoff_hour"]
key = (price_tier, target_hour)
group = groups.setdefault(key, {
"price_tier": price_tier,
"hour": target_hour,
"trades": [],
})
group["trades"].append(trade)
breakdown = []
trades_by_group = {}
for key, group in groups.items():
trades = group["trades"]
trades_by_group[key] = trades
group["trades"] = len(trades)
group["wins"] = sum(1 for t in trades if t["pnl_with_fees"] > 0)
group["losses"] = sum(1 for t in trades if t["pnl_with_fees"] < 0)
group["pushes"] = sum(1 for t in trades if t["pnl_with_fees"] == 0)
group["net_pnl"] = sum(t["pnl_with_fees"] for t in trades)
group["win_rate"] = group["wins"] / group["trades"] * 100
group["average_pnl"] = group["net_pnl"] / group["trades"]
group["target_hour"] = "{:02d}:00 ET".format(group["hour"])
group["recommended"] = False
group["market_context"] = self._sp500_market_context(trades)
avg_open, avg_close = self._sp500_avg_prices(group)
group["avg_sp500_open"] = avg_open
group["avg_sp500_close"] = avg_close
breakdown.append(group)
tier_order = {label: index for index, label in enumerate(SP500_PRICE_TIERS)}
breakdown.sort(key=lambda item: (
tier_order[item["price_tier"]],
item["hour"],
))
eligible = [
item for item in breakdown
if item["trades"] >= min_trades and item["net_pnl"] > 0
]
eligible.sort(key=lambda item: (item["win_rate"], item["net_pnl"]), reverse=True)
for item in eligible[:5]:
item["recommended"] = True
key = (item["price_tier"], item["hour"])
item["top_trades"] = self._sp500_top_trades(trades_by_group[key])
return breakdown
@staticmethod
def _sp500_top_trades(trades, limit=3):
"""Return up to `limit` winning trades sorted by net P&L descending."""
winners = [t for t in trades if t["pnl_with_fees"] > 0]
winners.sort(key=lambda t: t["pnl_with_fees"], reverse=True)
top_trades = []
for trade in winners[:limit]:
open_dt = trade.get("open_timestamp")
if open_dt is not None:
if open_dt.tzinfo is None:
open_dt = open_dt.replace(tzinfo=NEW_YORK_TZ)
else:
open_dt = open_dt.astimezone(NEW_YORK_TZ)
open_time_et = open_dt.strftime("%b %d, %Y %I:%M %p ET")
else:
open_time_et = ""
top_trades.append({
"ticker": trade["ticker"],
"side": trade["side"],
"entry": trade["entry"],
"exit": trade["exit"],
"pnl": trade["pnl_with_fees"],
"open_time_et": open_time_et,
})
return top_trades
@staticmethod
def _parse_sp500_ticker(ticker):
"""Extract target date, cutoff hour, and strike from an S&P ticker."""
match = SP500_TICKER_RE.match(ticker)
if not match:
return None
date = _parse_sp500_ticker_date(match.group("date"))
if date is None:
return None
hour = int(match.group("hour")[:2])
if hour > 23:
return None
return {
"target_date": date,
"cutoff_hour": hour,
"strike": float(match.group("strike")),
}
def _fetch_sp500_prices(self, start, end):
"""Fetch intraday ^GSPC prices, returning an empty series on failure."""
try:
data = yf.download(
"^GSPC",
start=start,
end=end,
interval="1h",
progress=False,
auto_adjust=False,
)
if data.empty:
return None
close = data["Close"]
if hasattr(close, "columns"):
close = close.iloc[:, 0]
return close.dropna()
except Exception:
return None
def _sp500_price_at(self, prices, dt):
"""Return the closest available S&P price at or before dt."""
if prices is None or prices.empty:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=NEW_YORK_TZ)
dt = dt.astimezone(ZoneInfo("UTC"))
idx = prices.index
if idx.tzinfo is None:
idx = idx.tz_localize("UTC")
pos = idx.searchsorted(dt, side="right") - 1
if pos < 0:
return None
return float(prices.iloc[pos])
def _sp500_market_context(self, trades):
"""Build S&P price context for a group of S&P trades."""
if not trades:
return None
parsed = [self._parse_sp500_ticker(t["ticker"]) for t in trades]
parsed = [p for p in parsed if p]
if not parsed:
return None
strikes = [p["strike"] for p in parsed]
strike = sum(strikes) / len(strikes)
opens = [t["open_timestamp"] for t in trades if t.get("open_timestamp")]
closes = [t["close_timestamp"] for t in trades if t.get("close_timestamp")]
if not opens:
return None
min_dt = min(opens)
max_dt = max(closes) if closes else max(opens)
start = min_dt.astimezone(NEW_YORK_TZ).date() - timedelta(days=1)
end = max_dt.astimezone(NEW_YORK_TZ).date() + timedelta(days=2)
prices = self._fetch_sp500_prices(start, end)
if prices is None:
return {"strike": strike, "available": False}
open_prices = [self._sp500_price_at(prices, dt) for dt in opens]
open_prices = [p for p in open_prices if p is not None]
close_prices = [self._sp500_price_at(prices, dt) for dt in closes]
close_prices = [p for p in close_prices if p is not None]
if not open_prices or not close_prices:
return {"strike": strike, "available": False}
avg_open = sum(open_prices) / len(open_prices)
avg_close = sum(close_prices) / len(close_prices)
# Compare at the ticker's scheduled cutoff time when available.
cutoff_prices = []
for p in parsed:
cutoff_dt = datetime(
p["target_date"].year,
p["target_date"].month,
p["target_date"].day,
p["cutoff_hour"],
tzinfo=NEW_YORK_TZ,
)
cp = self._sp500_price_at(prices, cutoff_dt)
if cp is not None:
cutoff_prices.append(cp)
cutoff_above = None
if cutoff_prices:
avg_cutoff = sum(cutoff_prices) / len(cutoff_prices)
cutoff_above = avg_cutoff > strike
return {
"strike": strike,
"available": True,
"avg_open": avg_open,
"avg_close": avg_close,
"open_above": avg_open > strike,
"close_above": avg_close > strike,
"move": avg_close - avg_open,
"cutoff_above": cutoff_above,
}
@staticmethod
def _sp500_avg_prices(group):
"""Return average S&P prices at trade open and close for a group."""
ctx = group.get("market_context")
if not ctx or not ctx.get("available"):
return None, None
return ctx.get("avg_open"), ctx.get("avg_close")
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]

View file

@ -10,6 +10,8 @@
summary: payload.summary,
markets: payload.markets,
sp500Tiers: payload.sp500_tiers,
sp500Hourly: payload.sp500_hourly,
sp500SuggestionRevealed: false,
trades: payload.trades,
irs: payload.irs,
themes: payload.themes,
@ -55,6 +57,10 @@
return [...new Set(this.trades.map((trade) => trade.market_category))].sort((a, b) => a.localeCompare(b));
},
get sp500Suggestions() {
return this.sp500Hourly.filter((item) => item.recommended);
},
get filteredTrades() {
const needle = this.search.trim().toLocaleLowerCase();
const filtered = this.trades.filter((trade) => {
@ -125,6 +131,13 @@
return `${Number(value || 0).toFixed(1)}%`;
},
formatSp500Price(value) {
return value == null ? "N/A" : Number(value).toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
},
pnlClass(value) {
return Number(value) < 0 ? "negative-text" : "positive-text";
},

View file

@ -148,6 +148,50 @@ h2 { margin: 0; font: 650 19px/1.25 Manrope, "Segoe UI", sans-serif; letter-spac
.data-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.tier-name { min-width: 285px; font-size: 11px !important; font-weight: 600; }
.align-right { text-align: right !important; }
.sp500-suggestion-panel { margin-bottom: 18px; }
.historical-note { max-width: 900px; margin: -8px 0 16px; color: var(--muted); font-size: 11px; }
.suggestion-button, .suggestion-dismiss {
min-height: 38px; padding: 0 14px; border: 1px solid var(--accent); border-radius: 8px;
background: color-mix(in srgb, var(--accent) 12%, var(--surface-raised)); color: var(--app-fg);
font-size: 11px; font-weight: 650; transition: background .2s ease, border-color .2s ease;
}
.suggestion-button:hover, .suggestion-dismiss:hover { background: color-mix(in srgb, var(--accent) 22%, var(--surface-raised)); }
.suggestion-empty { padding: 14px; border: 1px dashed var(--border); border-radius: 9px; color: var(--muted); font-size: 11px; }
.suggestion-result { max-width: 900px; padding: 16px; border: 1px solid color-mix(in srgb, var(--positive) 35%, var(--border)); border-radius: 11px; background: color-mix(in srgb, var(--positive) 5%, var(--surface-raised)); }
.suggestion-row { display: grid; grid-template-columns: 48px 1fr; gap: 14px; padding: 14px 0; border-bottom: 1px solid var(--border); }
.suggestion-row:last-of-type { border-bottom: 0; }
.suggestion-rank { display: flex; align-items: flex-start; justify-content: center; width: 40px; height: 40px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-raised); color: var(--accent); font: 700 13px/1 "DM Mono", monospace; }
.suggestion-rank span { margin-top: 11px; }
.suggestion-primary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; padding-bottom: 12px; }
.suggestion-primary div, .suggestion-stats div { display: grid; gap: 4px; min-width: 0; }
.suggestion-primary span, .suggestion-stats span { color: var(--muted); font: 500 8px "DM Mono", monospace; letter-spacing: .08em; }
.suggestion-primary strong { font-size: 12px; }
.suggestion-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.suggestion-stats strong { font-size: 11px; }
.suggestion-trades { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.suggestion-trades-toggle {
display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 10px;
border: 1px solid var(--border); border-radius: 6px; background: var(--surface-raised);
color: var(--muted); font-size: 10px; font-weight: 600;
}
.suggestion-trades-toggle:hover { border-color: var(--accent); color: var(--accent); }
.suggestion-trades-chevron { font-size: 8px; }
.suggestion-trades-list { margin-top: 10px; display: grid; gap: 8px; }
.suggestion-trade {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px;
background: var(--surface-raised);
}
.suggestion-trade-main { display: flex; align-items: center; gap: 10px; min-width: 0; }
.suggestion-trade-date { color: var(--muted); font-size: 10px; }
.suggestion-trade-ticker { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
.suggestion-trade-side {
display: inline-block; min-width: 30px; padding: 2px 5px; border: 1px solid var(--border);
border-radius: 4px; text-align: center; font: 600 9px "DM Mono", monospace;
}
.suggestion-trade-stats { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: 10px; }
.suggestion-trade-stats b { color: var(--app-fg); font-size: 10px; }
.suggestion-dismiss { min-height: 30px; padding: 0 10px; border-color: var(--border); background: transparent; color: var(--muted); font-size: 10px; }
.positions-panel { padding: 0; overflow: hidden; }
.positions-heading { margin: 0; padding: 22px 22px 16px; }
@ -218,6 +262,7 @@ h2 { margin: 0; font: 650 19px/1.25 Manrope, "Segoe UI", sans-serif; letter-spac
.content-grid { grid-template-columns: 1fr; }
.market-chart { grid-template-columns: 1fr; }
.panel { padding: 18px; }
.suggestion-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.positions-panel { padding: 0; }
.positions-heading { padding: 18px 18px 14px; }
.table-toolbar { padding: 0 18px 14px; }

View file

@ -135,6 +135,62 @@
</div>
</section>
<section class="panel sp500-suggestion-panel" x-show="sp500Tiers.length" x-cloak>
<div class="section-heading">
<div><p class="eyebrow">HISTORICAL ENTRY TIMING</p><h2>S&amp;P 500 top hourly setups</h2></div>
<span class="section-count">P&amp;L after fees</span>
</div>
<p class="historical-note">This historical summary is not a forecast or trading advice. Each suggested tier/hour needs at least five trades and positive net P&amp;L; candidates are ranked by win rate, then net P&amp;L. Opening times are converted to America/New York.</p>
<div class="suggestion-empty" x-show="sp500Suggestions.length === 0">
No historical setup meets the minimum sample and positive net P&amp;L requirements.
</div>
<button class="suggestion-button" x-show="sp500Suggestions.length > 0 && !sp500SuggestionRevealed" @click="sp500SuggestionRevealed = true">Show top 5 historical setups</button>
<div class="suggestion-result" x-show="sp500Suggestions.length > 0 && sp500SuggestionRevealed" x-cloak aria-live="polite">
<template x-for="(setup, index) in sp500Suggestions" :key="setup.price_tier + '-' + setup.target_hour">
<div class="suggestion-row">
<div class="suggestion-rank"><span x-text="'#' + (index + 1)"></span></div>
<div class="suggestion-details">
<div class="suggestion-primary">
<div><span>ENTRY PRICE TIER</span><strong x-text="setup.price_tier"></strong></div>
<div><span>CONTRACT TARGET HOUR</span><strong class="mono" x-text="setup.target_hour"></strong></div>
</div>
<div class="suggestion-stats">
<div><span>TRADES</span><strong class="mono" x-text="setup.trades"></strong></div>
<div><span>RECORD (W–L–P)</span><strong class="mono" x-text="setup.wins + '–' + setup.losses + '–' + setup.pushes"></strong></div>
<div><span>WIN RATE</span><strong class="mono" x-text="formatPercent(setup.win_rate)"></strong></div>
<div><span>NET P&amp;L</span><strong class="mono" :class="pnlClass(setup.net_pnl)" x-text="formatPnl(setup.net_pnl)"></strong></div>
<div><span>AVG S&amp;P OPEN</span><strong class="mono" x-text="setup.avg_sp500_open != null ? formatSp500Price(setup.avg_sp500_open) : 'N/A'"></strong></div>
<div><span>AVG S&amp;P CLOSE</span><strong class="mono" x-text="setup.avg_sp500_close != null ? formatSp500Price(setup.avg_sp500_close) : 'N/A'"></strong></div>
</div>
<div class="suggestion-trades" x-show="setup.top_trades && setup.top_trades.length > 0">
<button class="suggestion-trades-toggle" @click="setup.tradesRevealed = !setup.tradesRevealed">
<span x-text="setup.tradesRevealed ? 'Hide top winning trades' : 'Show top winning trades'"></span>
<span class="suggestion-trades-chevron" x-text="setup.tradesRevealed ? '▲' : '▼'"></span>
</button>
<div class="suggestion-trades-list" x-show="setup.tradesRevealed" x-cloak>
<template x-for="(trade, tradeIndex) in setup.top_trades" :key="trade.ticker + '-' + trade.open_time_et + '-' + tradeIndex">
<div class="suggestion-trade">
<div class="suggestion-trade-main">
<span class="suggestion-trade-date mono" x-text="trade.open_time_et || 'Date unavailable'"></span>
<span class="suggestion-trade-ticker mono" x-text="trade.ticker"></span>
<span class="suggestion-trade-side" :class="trade.side === 'YES' ? 'side-yes' : 'side-no'" x-text="trade.side"></span>
</div>
<div class="suggestion-trade-stats">
<span>Entry <b class="mono" x-text="formatCurrency(trade.entry)"></b></span>
<span>Exit <b class="mono" x-text="formatCurrency(trade.exit)"></b></span>
<span>Net P&amp;L <b class="mono positive-text" x-text="formatPnl(trade.pnl)"></b></span>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<button class="suggestion-dismiss" @click="sp500SuggestionRevealed = false">Hide setups</button>
</div>
</section>
<section class="panel positions-panel">
<div class="section-heading positions-heading">
<div><p class="eyebrow">FULL ACCOUNT HISTORY</p><h2>Closed positions</h2></div>

View file

@ -338,6 +338,7 @@ def render_modern_dashboard_html(kalshi, csv_filename):
},
"markets": kalshi.market_breakdown(),
"sp500_tiers": kalshi.sp500_price_tier_breakdown(),
"sp500_hourly": kalshi.sp500_hourly_performance(),
"trades": [
{
"ticker": trade["ticker"],

View file

@ -2,6 +2,8 @@ import subprocess
import sys
from pathlib import Path
import pytest
def test_cli_runs_successfully(sample_csv):
result = subprocess.run(
@ -125,6 +127,47 @@ def test_cli_modern_web_flags_in_help():
assert result.returncode == 0
assert "--modern-web" in result.stdout
assert "--modern-web-port" in result.stdout
assert "--local" in result.stdout
@pytest.mark.parametrize(
("web_flag", "server_attr"),
[("--legacy-web", "LegacyWebServer"), ("--modern-web", "ModernWebServer")],
)
@pytest.mark.parametrize(
("local_args", "expected_host"),
[([], "0.0.0.0"), (["--local"], "127.0.0.1")],
)
def test_cli_selects_web_host(
sample_csv, monkeypatch, web_flag, server_attr, local_args, expected_host
):
from kalshi_csv import cli, web
captured = {}
class StubServer:
def __init__(self, kalshi, csv_filename, host, port):
captured["host"] = host
def serve(self):
pass
monkeypatch.setattr(web, server_attr, StubServer)
monkeypatch.setattr(sys, "argv", ["kalshi-csv", sample_csv, web_flag] + local_args)
cli.main()
assert captured["host"] == expected_host
def test_cli_local_requires_a_web_mode(sample_csv):
result = subprocess.run(
[sys.executable, "-m", "kalshi_csv.cli", sample_csv, "--local"],
capture_output=True,
text=True,
)
assert result.returncode == 2
assert "--local can only be used" in result.stderr
def test_cli_web_modes_are_mutually_exclusive(sample_csv):

View file

@ -1,4 +1,7 @@
from datetime import datetime
import pytest
from kalshi_csv import KalshiCSV
@ -173,6 +176,221 @@ def test_sp500_price_tier_breakdown_is_empty_without_sp500_trades():
assert kalshi.sp500_price_tier_breakdown() == []
def test_parse_sp500_ticker_extracts_strike_and_cutoff():
from kalshi_csv.parser import KalshiCSV
parsed = KalshiCSV._parse_sp500_ticker("KXINXU-26SEP24H1300-T7659")
assert parsed == {
"target_date": datetime(2026, 9, 24).date(),
"cutoff_hour": 13,
"strike": 7659.0,
}
assert KalshiCSV._parse_sp500_ticker("INVALID") is None
def test_parse_sp500_ticker_accepts_decimal_strike():
from kalshi_csv.parser import KalshiCSV
parsed = KalshiCSV._parse_sp500_ticker("KXINXU-26SEP30H1500-T7684.9999")
assert parsed == {
"target_date": datetime(2026, 9, 30).date(),
"cutoff_hour": 15,
"strike": 7684.9999,
}
def test_sp500_hourly_performance_groups_by_contract_target_hour(monkeypatch):
monkeypatch.setattr(KalshiCSV, "_sp500_market_context", lambda self, trades: None)
kalshi = KalshiCSV("unused.csv")
trades = []
def add_trade(entry, pnl, timestamp, target_hour, category="S&P 500 (INXU Intraday)"):
trades.append({
"entry": entry,
"exit": entry,
"side": "YES",
"pnl_with_fees": pnl,
"market_category": category,
"ticker": "KXINXU-26JUL07H{:02d}00-T7615".format(target_hour),
"open_timestamp": datetime.fromisoformat(timestamp) if timestamp else None,
})
# Different trade-entry times share the same contract target hour.
for pnl, timestamp in zip(
(1.0, 1.0, 0.0, -1.0, -1.0),
(
"2026-07-07T12:49:00-04:00",
"2026-07-07T12:31:00-04:00",
"2026-07-07T11:15:00-04:00",
"2026-07-07T12:05:00-04:00",
"2026-07-07T12:55:00-04:00",
),
):
add_trade(0.40, pnl, timestamp, 13)
# The same trade-entry hour but a different ticker target remains separate.
for pnl in (1.0, 1.0, 1.0, 1.0, -1.0):
add_trade(0.40, pnl, "2026-07-07T12:49:00-04:00", 14)
# High win rate with only four trades does not qualify for a suggestion.
for _ in range(4):
add_trade(0.41, 1.0, "2026-07-07T12:49:00-04:00", 13)
add_trade(0.50, 1.0, "2026-07-07T12:49:00-04:00", 13, "Other Markets")
kalshi.trades = trades
breakdown = kalshi.sp500_hourly_performance()
assert len(breakdown) == 3
first_tier = [item for item in breakdown if item["price_tier"].startswith("<=")]
assert [item["target_hour"] for item in first_tier] == [
"13:00 ET",
"14:00 ET",
]
assert [item["trades"] for item in first_tier] == [5, 5]
assert [item["win_rate"] for item in first_tier] == [40.0, 80.0]
assert first_tier[0]["pushes"] == 1
assert first_tier[0]["net_pnl"] == pytest.approx(0.0)
assert first_tier[0]["recommended"] is False
assert first_tier[1]["recommended"] is True
assert first_tier[1]["avg_sp500_open"] is None
assert first_tier[1]["avg_sp500_close"] is None
second_tier = [item for item in breakdown if item["price_tier"].startswith(">")]
assert second_tier[0]["trades"] == 4
assert second_tier[0]["win_rate"] == 100.0
assert second_tier[0]["recommended"] is False
def test_sp500_market_context_compares_prices_to_strike(monkeypatch):
import pandas as pd
from kalshi_csv.parser import KalshiCSV
idx = pd.to_datetime([
"2026-07-07 09:00:00+00:00",
"2026-07-07 10:00:00+00:00",
"2026-07-07 13:00:00+00:00",
"2026-07-07 14:00:00+00:00",
])
prices = pd.Series([7600.0, 7610.0, 7620.0, 7630.0], index=idx)
monkeypatch.setattr(KalshiCSV, "_fetch_sp500_prices", lambda self, s, e: prices)
trades = [{
"ticker": "KXINXU-26JUL26H1600-T7615",
"open_timestamp": datetime.fromisoformat("2026-07-07T09:15:00-04:00"),
"close_timestamp": datetime.fromisoformat("2026-07-07T13:15:00-04:00"),
} for _ in range(5)]
ctx = KalshiCSV("unused.csv")._sp500_market_context(trades)
assert ctx["available"] is True
assert ctx["strike"] == 7615.0
assert ctx["avg_open"] == 7620.0
assert ctx["avg_close"] == 7630.0
assert ctx["open_above"] is True
assert ctx["close_above"] is True
assert ctx["move"] == 10.0
assert ctx["cutoff_above"] is True
def test_sp500_top_trades_returns_winners_sorted_by_pnl():
from kalshi_csv.parser import KalshiCSV
trades = [
{"ticker": "KXINXU-26JUL26H1600-T7615", "side": "YES", "entry": 0.50, "exit": 1.00, "pnl_with_fees": 1.00, "open_timestamp": datetime.fromisoformat("2026-07-07T09:15:00-04:00")},
{"ticker": "KXINXU-26JUL26H1600-T7615", "side": "NO", "entry": 0.60, "exit": 0.00, "pnl_with_fees": 3.00, "open_timestamp": datetime.fromisoformat("2026-07-07T10:15:00-04:00")},
{"ticker": "KXINXU-26JUL26H1600-T7615", "side": "YES", "entry": 0.70, "exit": 0.00, "pnl_with_fees": -1.00, "open_timestamp": datetime.fromisoformat("2026-07-07T11:15:00-04:00")},
{"ticker": "KXINXU-26JUL26H1600-T7615", "side": "NO", "entry": 0.80, "exit": 1.00, "pnl_with_fees": 2.00, "open_timestamp": datetime.fromisoformat("2026-07-07T12:15:00-04:00")},
]
top = KalshiCSV._sp500_top_trades(trades)
assert len(top) == 3
assert top[0]["pnl"] == 3.00
assert top[1]["pnl"] == 2.00
assert top[2]["pnl"] == 1.00
assert top[0]["open_time_et"] == "Jul 07, 2026 10:15 AM ET"
assert all(t["pnl"] > 0 for t in top)
def test_sp500_hourly_performance_selects_top_five_profitable_global_suggestions(monkeypatch):
monkeypatch.setattr(KalshiCSV, "_sp500_market_context", lambda self, trades: None)
kalshi = KalshiCSV("unused.csv")
trades = []
def add_group(entry, hour, pnls):
for pnl in pnls:
trades.append({
"entry": entry,
"exit": entry,
"side": "YES",
"pnl_with_fees": pnl,
"market_category": "S&P 500 (INXU Intraday)",
"ticker": "KXINXU-26JUL07H{:02d}00-T7615".format(hour),
"open_timestamp": datetime.fromisoformat(
"2026-07-07T{:02d}:15:00-04:00".format(hour)
),
})
# Highest win rate, but a net loss: this group must not be suggested.
add_group(0.40, 9, (1.0, 1.0, 1.0, 1.0, -10.0))
# Same win rate, different profits: the larger net P&L wins the tie.
add_group(0.50, 10, (2.0, 2.0, 2.0, -0.1, -0.1))
add_group(0.80, 11, (3.0, 3.0, 3.0, -0.1, -0.1))
# Higher total profit but lower win rate: win rate has priority.
add_group(0.90, 12, (10.0, 10.0, -0.1, -0.1, -0.1))
# Additional qualifying groups to verify top-5 selection.
add_group(0.40, 13, (1.0, 1.0, 1.0, 1.0, 1.0))
add_group(0.50, 14, (1.0, 1.0, 1.0, 1.0, -0.1))
add_group(0.80, 15, (1.0, 1.0, 1.0, -0.1, -0.1))
kalshi.trades = trades
breakdown = kalshi.sp500_hourly_performance()
suggestions = [item for item in breakdown if item["recommended"]]
assert len(suggestions) == 5
# Suggestions are in breakdown order (tier, then hour), not ranking order.
assert suggestions[0]["price_tier"].startswith("<=")
assert suggestions[0]["target_hour"] == "13:00 ET"
assert suggestions[0]["win_rate"] == 100.0
assert suggestions[0]["net_pnl"] == pytest.approx(5.0)
assert suggestions[1]["price_tier"].startswith(">$0.40")
assert suggestions[1]["target_hour"] == "10:00 ET"
assert suggestions[1]["win_rate"] == 60.0
assert suggestions[1]["net_pnl"] == pytest.approx(5.8)
assert suggestions[2]["price_tier"].startswith(">$0.40")
assert suggestions[2]["target_hour"] == "14:00 ET"
assert suggestions[2]["win_rate"] == 80.0
assert suggestions[2]["net_pnl"] == pytest.approx(3.9)
assert suggestions[3]["price_tier"].startswith(">$0.70")
assert suggestions[3]["target_hour"] == "11:00 ET"
assert suggestions[3]["win_rate"] == 60.0
assert suggestions[3]["net_pnl"] == pytest.approx(8.8)
assert suggestions[4]["price_tier"].startswith(">$0.70")
assert suggestions[4]["target_hour"] == "15:00 ET"
assert suggestions[4]["win_rate"] == 60.0
assert suggestions[4]["net_pnl"] == pytest.approx(2.8)
assert len(suggestions[0]["top_trades"]) == 3
assert [trade["pnl"] for trade in suggestions[0]["top_trades"]] == [1.0, 1.0, 1.0]
assert all(trade["pnl"] > 0 for setup in suggestions for trade in setup["top_trades"])
assert all("_trades" not in setup for setup in breakdown)
def test_sp500_hourly_performance_has_no_suggestion_for_nonpositive_net_pnl(monkeypatch):
monkeypatch.setattr(KalshiCSV, "_sp500_market_context", lambda self, trades: None)
kalshi = KalshiCSV("unused.csv")
kalshi.trades = [{
"entry": 0.50,
"pnl_with_fees": -0.1,
"market_category": "S&P 500 (INXU Intraday)",
"ticker": "KXINXU-26JUL07H1600-T7615",
"open_timestamp": datetime.fromisoformat("2026-07-07T09:15:00-04:00"),
} for _ in range(5)]
breakdown = kalshi.sp500_hourly_performance()
assert len(breakdown) == 1
assert breakdown[0]["recommended"] is False
def test_recent_closed_positions_returns_list(sample_csv):
kalshi = KalshiCSV(sample_csv)
kalshi.parse()

View file

@ -77,6 +77,87 @@ def test_modern_dashboard_includes_all_trades_and_interactive_controls(sample_cs
assert "alpine.min.js" in html_content
assert "x-model=\"search\"" in html_content
assert "S&amp;P 500 price tiers" in html_content
assert "S&amp;P 500 top hourly setups" in html_content
assert "CONTRACT TARGET HOUR" in html_content
assert "Show top 5 historical setups" in html_content
assert "No historical setup meets the minimum sample" in html_content
assert "This historical summary is not a forecast" in html_content
assert "AVG S&amp;P OPEN" in html_content
assert "AVG S&amp;P CLOSE" in html_content
assert "Show top winning trades" in html_content
assert "Hide top winning trades" in html_content
assert "sp500_hourly" in html_content
def test_modern_dashboard_serializes_hourly_sp500_performance(sample_csv, monkeypatch):
from kalshi_csv import parser as parser_module
monkeypatch.setattr(
parser_module.KalshiCSV,
"_sp500_market_context",
lambda self, trades: None,
)
kalshi = KalshiCSV(sample_csv)
kalshi.parse()
kalshi.trades[0]["market_category"] = "S&P 500 (INXU Intraday)"
kalshi.trades[0]["ticker"] = "KXINXU-26JUL07H1300-T7615"
html_content = render_modern_dashboard_html(kalshi, "test.csv")
data_block = html_content.split(
'<script id="dashboard-data" type="application/json">', 1
)[1].split("</script>", 1)[0]
payload = json.loads(data_block)
assert payload["sp500_hourly"] == [{
"price_tier": ">$0.40-$0.70 (At-the-Money / Coincident)",
"hour": 13,
"trades": 1,
"wins": 1,
"losses": 0,
"pushes": 0,
"net_pnl": 0.47,
"win_rate": 100.0,
"average_pnl": 0.47,
"target_hour": "13:00 ET",
"recommended": False,
"market_context": None,
"avg_sp500_open": None,
"avg_sp500_close": None,
}]
def test_modern_dashboard_serializes_top_winning_suggestion_trades(sample_csv, monkeypatch):
from kalshi_csv import parser as parser_module
monkeypatch.setattr(
parser_module.KalshiCSV,
"_sp500_market_context",
lambda self, trades: None,
)
kalshi = KalshiCSV(sample_csv)
kalshi.parse()
base_trade = dict(kalshi.trades[0])
base_trade["market_category"] = "S&P 500 (INXU Intraday)"
base_trade["ticker"] = "KXINXU-26JUL07H1600-T7615"
base_trade["side"] = "YES"
base_trade["entry"] = 0.50
base_trade["exit"] = 1.0
kalshi.trades = [
dict(base_trade, pnl_with_fees=pnl)
for pnl in (0.2, 0.5, 0.3, 0.4, 0.1)
]
html_content = render_modern_dashboard_html(kalshi, "test.csv")
data_block = html_content.split(
'<script id="dashboard-data" type="application/json">', 1
)[1].split("</script>", 1)[0]
payload = json.loads(data_block)
suggestion = next(item for item in payload["sp500_hourly"] if item["recommended"])
assert [trade["pnl"] for trade in suggestion["top_trades"]] == [0.5, 0.4, 0.3]
assert suggestion["top_trades"][0]["open_time_et"] == "Jul 07, 2026 09:48 AM ET"
assert all(trade["pnl"] > 0 for trade in suggestion["top_trades"])
assert "_trades" not in suggestion
def test_modern_dashboard_escapes_csv_values_inside_json_data(sample_csv):