position-aggregation draft
Combining multiple fills, lots, and venues into a single per-side exposure view (qty, stake, win) — Kairos's bucket-keyed reconstruction
- Tags
- position aggregation fills lots kalshi manual exposure
- Vocabulary
- fill
- A single trade execution — one contract or one bet transacted at one price. The atomic unit of position state; if you bought 100 contracts in three batches, you had three fills.
- lot
- An open piece of a position that hasn't been closed yet. If you bought 100 then sold 30, the remaining 70 sit as one or more lots, each with its own entry price.
- bucket
- Kairos's market-type partition: 'game' (moneyline — who wins), 'spread' (margin-of-victory line), 'total' (combined-score line). Each carries different fee rules; in particular, Kalshi spread/total buckets are fee-exempt today while game buckets are not.
- yes_source_side
- Within a Side A / Side B panel layout, the side that holds the Kalshi YES contract for this market. Determined dynamically because Kalshi's YES designation can map to either team depending on the market.
- effective_position
- The per-side, per-bucket totals (qty, stake, win) you actually want to read on screen, after the aggregator has applied fee handling and venue-format conversion. Stake = cash out of your account; win = net profit on resolution.
- cost_basis
- The price you actually paid per contract on average across all fills, weighted by quantity. Used to know what your position is worth relative to the current market price.
Position aggregation is the running list of contracts and bets you currently hold, summarized so the rest of the system can ask simple questions like "how much money do I have on Side A of this market?" without re-walking every individual fill that built the position. The job is combining many individual fills into a single "effective" position with one average price and one net size — and then doing the same combining across venues (Kalshi contracts, sportsbook bets) so the operator sees one consolidated number per market side.
Three things make this non-trivial: each fill may include a fee that changes the cash-out-of-account number; different markets (game moneylines vs. spreads vs. totals) have different fee rules; and venues post prices in different formats (Kalshi cents, American odds at sportsbooks) that need to be reconciled before the totals make sense. Aggregation centralizes that arithmetic so each downstream consumer — the risk display, EV calculation, P&L summary, blended-position payoff curve — can reason in plain dollars.
Where this lives in Kairos
In Kairos, aggregation lives in kairos/core/position_agg.py and produces three different per-bucket views depending on the consumer. (A bucket in Kairos is a market type — game, spread, total — each with its own fee rules and aggregation path.)
aggregate_kalshi_side— total(win, stake)for one side panel, optionally filtered by bucket. Used by the side header to display "Risk" (cash out of account) and "Win" (net profit on resolution).aggregate_spread_total_sides— six totals(qty, stake, win)for both YES and NO of a spread/total bucket, combining Kalshi rows with manual book lots. Feedspnl_if_yes/pnl_if_nofor the cross-portfolio P&L row. Seeblended-spread-positionfor how multiple strike buckets combine into a payoff curve.aggregate_manual_lots_by_site— one record per book/site, with implied American odds back-computed from(stake, win). Used to display "you have $X at FanDuel at -120" lines per book.
There is also aggregate_kalshi_qty_side (just sums qty for a side, useful for contract-count displays) and detect_yes_source_side (the dynamic YES-side detector — see blended-spread-position for why this matters). The _orig_side field on Kalshi rows tracks the canonical YES/NO assignment when display logic rewrites side_lbl; aggregation reads _orig_side first and falls back to side_lbl.
What "effective position" means in Kairos
The phrase has a specific meaning here. An effective position is the per-side, per-bucket dollar exposure as it appears to the operator's risk view, after the aggregator has applied:
- Per-position fee handling. Kalshi game-bucket positions pay the half-maker fee (0.875% net of Kairos's 50% rebate); spread/total positions are fee-exempt today. The aggregator routes through
is_fee_exempt_bucketand uses the right American-conversion prefix ("k"for fee-adjusted,"p"for raw / no-fee) so the per-position win matches what the operator sees in the AM column. - Cash-out-of-account stake. The displayed "Risk" is what actually leaves the account:
contract_cost + feefor Kalshi game positions; justcontract_costfor fee-exempt buckets. Manual book lots have no Kalshi fee but theirstakefield carries the book's actual stake. Stakes are summed in cash terms, not contract-count terms. - Net-profit win.
winis profit on resolution, not gross payout. For a Kalshi position with raw stakest_rawand feefee, the win uses(st_raw + fee) × profit_per_dollar(am)whereamis the AM-column American odds, fee-adjusted for game buckets. For manual lots,winis the book's own stake-to-win ratio (no Kalshi-style fee). - Side routing. For spread/total buckets, the aggregator splits totals into YES and NO by inspecting
_orig_side(orside_lbl) on Kalshi rows and_source_sideon manual lots, with the latter routed viayes_source_side(which side panel holds Kalshi YES).
The output is a small dict of scalars per bucket (or per-side, depending on the function) that downstream consumers use without re-deriving any of this.
The math on a single fill
For a Kalshi game-bucket position at quantity q contracts and price px cents:
st_raw = q × (px / 100) # raw contract cost, no fee
fee = kalshi_fee_total(q, px) # half-maker, net of rebate
stake = st_raw + fee # cash out of account ("Risk")
am = parse_odds_any(f"k{px}") # AM column's fee-adjusted American
win = (st_raw + fee) × profit_per_dollar(am)
The same code path with is_fee_exempt_bucket returning True (spread/total) replaces the prefix "k" with "p" and sets fee = 0, so stake = st_raw and win = st_raw × profit_per_dollar(am_raw).
Per-fill win is net profit, not gross payout — profit_per_dollar(am) returns (d − 1) where d is decimal odds, i.e. the multiplier on stake-to-profit. Total payout if you win the bet is stake + win.
For a manual book lot, the aggregator trusts the lot's own (stake, win) fields verbatim — no fee adjustment is applied because external books don't carry a Kalshi-style separable fee.
Worked example: three fills aggregated
Operator has three fills on a moneyline market, all on Side A (Kalshi YES). Game bucket → fee applies.
| Fill | Action | qty | px (cents) | st_raw | fee (approx) | stake = st_raw + fee | am ("k" prefix) | profit_per_dollar(am) | win |
|---|---|---|---|---|---|---|---|---|---|
| 1 | Buy | 100 | 38 | $38.00 | $0.27 | $38.27 | fee-adjusted, ≈ +160 | ≈ 1.60 | $38.27 × 1.60 = $61.23 |
| 2 | Buy | 100 | 41 | $41.00 | $0.30 | $41.30 | fee-adjusted, ≈ +140 | ≈ 1.40 | $41.30 × 1.40 = $57.82 |
| 3 | Buy | 50 | 39 | $19.50 | $0.14 | $19.64 | fee-adjusted, ≈ +152 | ≈ 1.52 | $19.64 × 1.52 = $29.85 |
(Fee values are illustrative — kalshi_fee_total depends on the actual schedule. Actual fees on these fills would be a few dimes apiece, not load-bearing for the example.)
Aggregated effective position for this side, this bucket:
qty = 100 + 100 + 50 = 250 contracts
stake = $38.27 + $41.30 + $19.64 = $99.21 # cash out of account
win = $61.23 + $57.82 + $29.85 = $148.90 # net profit if Side A wins
If the operator now closes 50 contracts at price 45 (a partial sell), that's an exit fill that should subtract from qty and from a corresponding portion of stake/win — but Kairos's position_agg.py aggregates from a snapshot of kalshi_items (the surviving lots), not from a fill ledger. The reconciliation that maps fills → surviving lots happens upstream of position_agg.py; the aggregator just sums what's currently on the panel. (See "Open questions" — the precise location of that reconciliation isn't in the file under review.)
The "average price" view some operators expect is implicit in the totals: weighted-average price = stake / qty (in the fee-free case, or with appropriate fee handling otherwise). For the example: $99.21 / 250 = $0.397 per contract = 39.7¢, the volume-weighted average entry price across the three fills.
Worked example: aggregating across YES and NO
For a spread bucket, both YES and NO sides may have positions. Take a small case: Kalshi YES on Patriots -3.5 has 100 contracts at 52¢, and a manual book lot on the other side (Jets +3.5 at -110, $50 stake) is recorded on the opposite panel.
Through aggregate_spread_total_sides:
# Kalshi rows (YES side, spread → fee-free, "p" prefix)
yes_qty += 100
st = 100 × 0.52 = $52.00
yes_stake += $52.00
am = parse_odds_any("p52") # raw American at 52¢ ≈ -108
profit/$1 ≈ 0.92 (a -108 line returns ~92c profit per $1)
yes_win += $52.00 × 0.92 = $47.84
# Manual book lot (NO side via _source_side routing)
no_stake += $50.00
no_win += $45.45 # win field on the lot, computed by the book at -110
no_qty += $50 + $45.45 = $95.45 # for spread bucket, qty defaults to stake+win
# because contract count isn't meaningful for book lots
Result: {yes_qty: 100, yes_stake: 52.00, yes_win: 47.84, no_qty: 95.45, no_stake: 50.00, no_win: 45.45}.
Cross-portfolio P&L:
pnl_if_yes = yes_win − no_stake = $47.84 − $50.00 = −$2.16
pnl_if_no = no_win − yes_stake = $45.45 − $52.00 = −$6.55
The position is slightly negative both ways — as it should be, since both legs are -110-ish on opposite sides and the operator is paying vig on both. Use this number, not the per-side totals, to reason about net exposure.
Edge cases
- Partial closes. When the operator sells back part of a position, Kairos's reconciliation (upstream of
position_agg.py) reduces the survivingkalshi_itemsfor that bucket. The aggregator just sums what's left. Realized P&L on the closed portion is not reflected in the aggregator's output; that's the realm of a P&L ledger, not a position summary. - Fills at different times in fast markets. The aggregator is a snapshot reducer. It doesn't care when fills happened or in what order; it only sees the current open lots. WS-fill ordering only matters during reconciliation; once reconciliation has settled, the aggregator's input is well-defined.
- Hedges across panels. A position on Side A panel and a position on Side B panel may economically hedge. The aggregator computes per-side per-bucket totals; cross-panel netting is the consumer's job (and is exactly what
pnl_if_yes/pnl_if_noformulas do for spread/total buckets). - Scratches. A "scratch" is a position closed at the same price it was opened. The lot disappears from
kalshi_items; the aggregator no longer sees it. There's no special handling — scratches just don't contribute. - Mixed-bucket panels. A side panel can hold positions on multiple buckets simultaneously (a moneyline + a spread + a total, all on the same side).
aggregate_kalshi_side(panel, bucket_filter="game")filters to just one bucket;aggregate_kalshi_side(panel, None)would sum them all together, which is wrong because game and spread/total positions resolve on different events. Callers always pass a bucket filter; the bucket-less case is essentially never used in production. - Manual lot site rollup.
aggregate_manual_lots_by_sitegroups bysitefield and back-computes the lot's American odds from(stake, win):am = (win/stake) × 100ifwin >= stake, elseam = -(stake/win) × 100. It also carries through a_side_label(e.g. "Patriots -3.5") when all lots at the site agree, so the display row can show a meaningful label instead of generic "MAN."
Gotchas
- Average price is not fair price. A volume-weighted average entry across three fills tells you what you paid on average; it does not tell you what the position is "worth" relative to current market or relative to the devigged fair line. Average price is a cost-basis input; fair price is a valuation input. They differ by the spread between when you got in and where the line is now.
stakereturned from the aggregator is cash out of account, not contract cost. For Kalshi game-bucket positions,stake = st_raw + fee. If a downstream consumer treatsstakeas raw contract cost (forgetting the fee), it will under-report Risk by exactly the fee amount. The aggregator's docstring is explicit about this; the consumer must be too.winis net profit, not gross payout. Total settlement on a winning bet isstake + win. Operators sometimes display "Win" in the header expecting it to be the dollar received; in Kairos it's the profit over the stake. Don't confuse them.- Fee-exempt today doesn't mean fee-exempt always.
is_fee_exempt_bucketreturns True for spread and total buckets because Kalshi currently charges no fee on them. If the schedule changes, the aggregator's "p"-prefix path under-reports stake and over-reports win. The aggregator is correct under current rules; it's not robust to a schedule change. _orig_sidevs.side_lbl. Kalshi rows use_orig_sidewhen present, falling back toside_lbl. The two are not always identical (the row builder may rewriteside_lblfor display while preserving_orig_sideas the canonical YES/NO assignment). Aggregation logic uses_orig_sidefirst; downstream code that introspects panel rows should do the same.- Stale bucket state can produce ghost positions. If reconciliation upstream fails to remove a closed lot from
kalshi_items, the aggregator will include it. The aggregator doesn't validate that lots correspond to currently open positions on Kalshi's side; it trusts the snapshot. Reconciliation bugs surface as phantom risk. - Manual lots without
stakeorwin≤ 0 are silently skipped.aggregate_spread_total_sidesfilters out lots with non-positivestakeorwin. This prevents bad data from corrupting totals, but a lot that was supposed to be included but had a typo'd stake is just gone — no warning. For audit purposes, the loader-side validation should catch this earlier. - The
qtyfield for manual lots in spread/total aggregation =stake + win. This is a convention that letsqtyalign with "total payout if win" for non-Kalshi rows, since contract count isn't meaningful for book lots. Don't interpretqtyas a contract count when it includes manual lots; the units are dollars in that case. - Filtering by bucket is case-insensitive and trims whitespace.
normalize_space(...).lower()is applied to both the panel item's bucket and the filter. Don't pass a case-sensitive comparison key — Kairos's bucket strings are user-data-derived and not always normalized at source.
Open questions
- The aggregator operates on a snapshot of surviving lots (
kalshi_items, manual lots). The reconciliation that turns a fill stream into surviving lots — including WS-fill bridging into provisional buckets, deduplication of overlapping fill events, and the average-cost-basis rebuild — does not live inposition_agg.py. Mimir's "what is position aggregation" entry should point at the reconciliation layer for the part of the question about how lots come into existence; the aggregator is purely the reducer at the end. Locating and documenting the reconciler is a follow-on task. - Realized P&L (closed legs) is not part of any aggregator output — only open exposure. A separate ledger handles closed-leg accounting. Whether Mimir wants a separate
realized-pnlentry, or whether realized P&L should be folded into a broader "P&L accounting" entry, is open. - The
qty = stake + winconvention for manual lots conflates contract count with payout-on-win. This is a UI-display compromise (so the column "Qty" has something to show) but it's not semantically clean. A cleaner design would carry contract-count and dollar-payout as separate fields and let the display layer pick. Whether to flag this as a design wart in Mimir or just document the convention is a judgment call. - Kairos's fee logic depends on the position being maker-side at half-maker fee. If the order ends up taker-side, the actual fee is higher than
kalshi_fee_totalreports. Reconciliation against the actual venue-reported fee is not inposition_agg.py. Whether the aggregator's fee numbers ever drift from venue-truth is not visible here. aggregate_manual_lots_by_siteback-computes American odds from(stake, win)usingam = (win/stake) × 100for plus-money and-(stake/win) × 100for minus-money. This recovers the original American only when the lot's stake-and-win were derived from a clean American input; if the lot's win was rounded to whole cents or computed at a venue with non-standard payout rules, the back-computed American can drift slightly from the original. For display purposes this is fine; for audit reconciliation against original tickets, it isn't.