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.)

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:

  1. 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_bucket and 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.
  2. Cash-out-of-account stake. The displayed "Risk" is what actually leaves the account: contract_cost + fee for Kalshi game positions; just contract_cost for fee-exempt buckets. Manual book lots have no Kalshi fee but their stake field carries the book's actual stake. Stakes are summed in cash terms, not contract-count terms.
  3. Net-profit win. win is profit on resolution, not gross payout. For a Kalshi position with raw stake st_raw and fee fee, the win uses (st_raw + fee) × profit_per_dollar(am) where am is the AM-column American odds, fee-adjusted for game buckets. For manual lots, win is the book's own stake-to-win ratio (no Kalshi-style fee).
  4. Side routing. For spread/total buckets, the aggregator splits totals into YES and NO by inspecting _orig_side (or side_lbl) on Kalshi rows and _source_side on manual lots, with the latter routed via yes_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

Gotchas

Open questions

Cross-references