pmfees 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pmfees/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """pmfees: exact trading fees for Kalshi, Polymarket US and polymarket.com.
2
+
3
+ All rates, tiers, multipliers and rounding rules come from ``prediction_market_fees.json``
4
+ (bundled, or your own file via ``load_rates(path)`` / ``PMFEES_RATES_FILE``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ from .core import PLATFORMS, BreakEven, FeeResult, break_even, fee, platform_key
12
+ from .rates import Rates, RatesError, default_rates, load_rates
13
+
14
+ __all__ = [
15
+ "PLATFORMS",
16
+ "BreakEven",
17
+ "FeeResult",
18
+ "Rates",
19
+ "RatesError",
20
+ "__version__",
21
+ "break_even",
22
+ "default_rates",
23
+ "fee",
24
+ "load_rates",
25
+ "platform_key",
26
+ ]
pmfees/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
pmfees/cli.py ADDED
@@ -0,0 +1,177 @@
1
+ """Command line: ``pmfees kalshi --price 0.50 --contracts 100``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from decimal import Decimal
10
+
11
+ from . import __version__
12
+ from .core import BreakEven, FeeResult, break_even, fee
13
+ from .rates import Rates, RatesError, load_rates
14
+
15
+
16
+ def _money(d: Decimal) -> str:
17
+ # Show at least cents, more when the platform rounds finer (polymarket.com: 5 decimals).
18
+ exp = d.normalize().as_tuple().exponent
19
+ places = max(2, -exp if isinstance(exp, int) else 2)
20
+ return f"${d:,.{places}f}"
21
+
22
+
23
+ def _price(d: Decimal) -> str:
24
+ return f"{(d * 100).normalize():f}¢"
25
+
26
+
27
+ def _render_fee(r: FeeResult) -> str:
28
+ pct = r.fee_pct_of_trade_value
29
+ pct_s = "" if pct is None else f" ({pct.quantize(Decimal('0.01'))}% of trade value at {_price(r.price)})"
30
+ lines = [
31
+ f"{r.platform_name} {r.role} fee: {_money(r.fee)}{pct_s}",
32
+ f" {r.side} {r.contracts.normalize():f} contracts at {_money(r.price)}"
33
+ f" = {_money(r.trade_value)} trade value",
34
+ ]
35
+ for k, v in r.details.items():
36
+ lines.append(f" {k.replace('_', ' ')}: {v}")
37
+ lines.append(
38
+ f" {r.coefficient.normalize():f} × {r.contracts.normalize():f} × {r.price.normalize():f}"
39
+ f" × (1 − {r.price.normalize():f}) = {r.raw_fee.normalize():f}, {r.rounding}: {_money(r.fee)}"
40
+ )
41
+ lines += [f" note: {n}" for n in r.notes]
42
+ lines.append(f" rates as of {r.rates_as_of} · source: {r.source_url}")
43
+ return "\n".join(lines)
44
+
45
+
46
+ def _render_break_even(b: BreakEven) -> str:
47
+ verb = "rise" if b.side == "buy" else "fall"
48
+ if b.exit_price is None or b.move is None or b.exit_fee is None or b.profit_at_exit is None:
49
+ return (
50
+ f"Break-even: no exit price below $1 puts this {b.side} in profit after fees ({b.platform_name})."
51
+ )
52
+ return (
53
+ f"Break-even: the price must {verb} {abs(b.move * 100).normalize():f}¢ "
54
+ f"to {_price(b.exit_price)} (tick {_price(b.tick)}); "
55
+ f"entry fee {_money(b.entry_fee)} + exit fee {_money(b.exit_fee)}, "
56
+ f"profit at that price {_money(b.profit_at_exit)}"
57
+ )
58
+
59
+
60
+ def _parser() -> argparse.ArgumentParser:
61
+ p = argparse.ArgumentParser(
62
+ prog="pmfees",
63
+ description="Exact trading fees for Kalshi, Polymarket US and polymarket.com.",
64
+ )
65
+ p.add_argument("--version", action="version", version=f"pmfees {__version__}")
66
+ sub = p.add_subparsers(dest="platform", required=True, metavar="PLATFORM")
67
+
68
+ common = argparse.ArgumentParser(add_help=False)
69
+ common.add_argument("--price", required=True, help="contract price in dollars, e.g. 0.50")
70
+ common.add_argument("--contracts", required=True, help="number of contracts (shares)")
71
+ common.add_argument("--side", choices=("buy", "sell"), default="buy")
72
+ common.add_argument("--maker", action="store_true", help="maker order instead of taker")
73
+ common.add_argument("--break-even", action="store_true", help="also show the break-even price move")
74
+ common.add_argument("--tick", default="0.01", help="price step for --break-even (default 0.01)")
75
+ common.add_argument("--json", action="store_true", help="print JSON instead of text")
76
+ common.add_argument("--rates", help="path to an updated prediction_market_fees.json")
77
+
78
+ k = sub.add_parser("kalshi", parents=[common], help="Kalshi")
79
+ k.add_argument("--multiplier", help="fee multiplier M (default 1 for takers, 0 for makers)")
80
+
81
+ us = sub.add_parser("polymarket-us", parents=[common], help="Polymarket US")
82
+ grp = us.add_mutually_exclusive_group()
83
+ grp.add_argument("--tier", help="taker tier 1-4, or 'new' for the first 7 days (default 1)")
84
+ grp.add_argument("--volume", help="trailing 30-day taker volume in USD (picks the tier)")
85
+
86
+ g = sub.add_parser("polymarket", parents=[common], help="polymarket.com (international)")
87
+ g.add_argument("--category", required=True, help="market category, e.g. Crypto, Sports, Politics")
88
+
89
+ r = sub.add_parser("rates", help="show the loaded rates and their date")
90
+ r.add_argument("--rates", help="path to an updated prediction_market_fees.json")
91
+ return p
92
+
93
+
94
+ def _rates_summary(rates: Rates) -> str:
95
+ g, us, k = rates.polymarket_global, rates.polymarket_us, rates.kalshi
96
+ lines = [f"Rates current as of {rates.as_of} ({rates.path})", "", f"Kalshi ({k.source_url})"]
97
+ lines.append(
98
+ f" taker {k.taker_rate}, maker {k.maker_rate}, series multipliers "
99
+ f"{', '.join(str(m) for m in k.multipliers)}; {k.rounding.describe()}"
100
+ )
101
+ lines += ["", f"Polymarket US ({us.source_url}, effective {us.effective})"]
102
+ for t in us.tiers:
103
+ rng = f"below ${t.below:,}" if t.below is not None else "the rest"
104
+ lines.append(f" {t.label}: Θ {t.theta} (30-day taker volume {rng})")
105
+ lines.append(f" New account: Θ {us.new_account_theta}; {us.rounding.describe()}")
106
+ lines += ["", f"polymarket.com ({g.source_url})"]
107
+ lines += [f" {cat}: {rate}" for cat, rate in g.rates.items()]
108
+ lines.append(f" {g.rounding.describe()}")
109
+ return "\n".join(lines)
110
+
111
+
112
+ def main(argv: Sequence[str] | None = None) -> int:
113
+ args = _parser().parse_args(argv)
114
+ try:
115
+ rates = load_rates(args.rates)
116
+ if args.platform == "rates":
117
+ print(_rates_summary(rates))
118
+ return 0
119
+ opts: dict[str, str | None] = {
120
+ "multiplier": getattr(args, "multiplier", None),
121
+ "tier": getattr(args, "tier", None),
122
+ "volume_30d": getattr(args, "volume", None),
123
+ "category": getattr(args, "category", None),
124
+ }
125
+ result = fee(
126
+ args.platform,
127
+ args.price,
128
+ args.contracts,
129
+ args.side,
130
+ opts["tier"],
131
+ args.maker,
132
+ category=opts["category"],
133
+ multiplier=opts["multiplier"],
134
+ volume_30d=opts["volume_30d"],
135
+ rates=rates,
136
+ )
137
+ be = None
138
+ if args.break_even:
139
+ be = break_even(
140
+ args.platform,
141
+ args.price,
142
+ args.contracts,
143
+ args.side,
144
+ opts["tier"],
145
+ args.maker,
146
+ tick=args.tick,
147
+ category=opts["category"],
148
+ multiplier=opts["multiplier"],
149
+ volume_30d=opts["volume_30d"],
150
+ rates=rates,
151
+ )
152
+ except (ValueError, ArithmeticError, RatesError, OSError) as exc:
153
+ print(f"pmfees: error: {exc}", file=sys.stderr)
154
+ return 2
155
+
156
+ if args.json:
157
+ out: dict[str, object] = {"fee": result.breakdown(), "notes": list(result.notes)}
158
+ if be is not None:
159
+ out["break_even"] = {
160
+ "entry_price": str(be.entry_price),
161
+ "exit_price": None if be.exit_price is None else str(be.exit_price),
162
+ "move": None if be.move is None else str(be.move),
163
+ "entry_fee_usd": str(be.entry_fee),
164
+ "exit_fee_usd": None if be.exit_fee is None else str(be.exit_fee),
165
+ "profit_at_exit_usd": None if be.profit_at_exit is None else str(be.profit_at_exit),
166
+ "tick": str(be.tick),
167
+ }
168
+ print(json.dumps(out, indent=2, ensure_ascii=False))
169
+ else:
170
+ print(_render_fee(result))
171
+ if be is not None:
172
+ print(_render_break_even(be))
173
+ return 0
174
+
175
+
176
+ if __name__ == "__main__":
177
+ raise SystemExit(main())
pmfees/core.py ADDED
@@ -0,0 +1,373 @@
1
+ """Fee and break-even calculations for Kalshi, Polymarket US and polymarket.com."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from decimal import Decimal
7
+ from typing import Literal
8
+
9
+ from .rates import Rates, Rounding, default_rates
10
+
11
+ Platform = Literal["kalshi", "polymarket-us", "polymarket"]
12
+ Side = Literal["buy", "sell"]
13
+ Number = Decimal | int | float | str
14
+
15
+ PLATFORMS: tuple[Platform, ...] = ("kalshi", "polymarket-us", "polymarket")
16
+
17
+ _ALIASES: dict[str, Platform] = {
18
+ "kalshi": "kalshi",
19
+ "polymarket-us": "polymarket-us",
20
+ "polymarket_us": "polymarket-us",
21
+ "pm-us": "polymarket-us",
22
+ "polymarket": "polymarket",
23
+ "polymarket.com": "polymarket",
24
+ "polymarket-global": "polymarket",
25
+ "polymarket_global": "polymarket",
26
+ }
27
+
28
+ DISPLAY_NAMES: dict[Platform, str] = {
29
+ "kalshi": "Kalshi",
30
+ "polymarket-us": "Polymarket US",
31
+ "polymarket": "polymarket.com",
32
+ }
33
+
34
+ ONE = Decimal(1)
35
+
36
+
37
+ def _num(value: Number, what: str) -> Decimal:
38
+ if isinstance(value, bool):
39
+ raise TypeError(f"{what} must be a number")
40
+ # str() first so 0.1 (float) becomes Decimal("0.1"), not its binary expansion.
41
+ d = value if isinstance(value, Decimal) else Decimal(str(value))
42
+ if not d.is_finite():
43
+ raise ValueError(f"{what} must be finite")
44
+ return d
45
+
46
+
47
+ def platform_key(platform: str) -> Platform:
48
+ key = _ALIASES.get(platform.strip().lower())
49
+ if key is None:
50
+ raise ValueError(f"unknown platform {platform!r}; use one of: {', '.join(PLATFORMS)}")
51
+ return key
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class FeeResult:
56
+ """A fee in USD with everything that went into it."""
57
+
58
+ platform: Platform
59
+ platform_name: str
60
+ side: Side
61
+ role: Literal["taker", "maker"]
62
+ price: Decimal
63
+ contracts: Decimal
64
+ coefficient: Decimal
65
+ """The coefficient applied in coefficient × C × p × (1 − p), multipliers included."""
66
+ raw_fee: Decimal
67
+ """Exact fee before the platform's rounding."""
68
+ fee: Decimal
69
+ """Fee in USD after the platform's rounding (what you pay)."""
70
+ rounding: str
71
+ formula: str
72
+ source_url: str
73
+ rates_as_of: str
74
+ details: dict[str, str] = field(default_factory=dict)
75
+ notes: tuple[str, ...] = ()
76
+
77
+ @property
78
+ def usd(self) -> float:
79
+ return float(self.fee)
80
+
81
+ @property
82
+ def trade_value(self) -> Decimal:
83
+ """C × p: what a buyer pays, or a seller receives, before fees."""
84
+ return self.contracts * self.price
85
+
86
+ @property
87
+ def fee_pct_of_trade_value(self) -> Decimal | None:
88
+ """Fee as a percentage of the trade value at this price (None when the value is 0)."""
89
+ value = self.trade_value
90
+ return None if value == 0 else self.fee / value * 100
91
+
92
+ @property
93
+ def cash_flow(self) -> Decimal:
94
+ """Signed cash for the trade including the fee: negative when you pay."""
95
+ return -(self.trade_value + self.fee) if self.side == "buy" else self.trade_value - self.fee
96
+
97
+ def breakdown(self) -> dict[str, str]:
98
+ """Plain strings, ready for JSON."""
99
+ pct = self.fee_pct_of_trade_value
100
+ out = {
101
+ "platform": self.platform_name,
102
+ "side": self.side,
103
+ "role": self.role,
104
+ "price": str(self.price),
105
+ "contracts": str(self.contracts),
106
+ "trade_value_usd": str(self.trade_value),
107
+ "coefficient": str(self.coefficient),
108
+ "raw_fee_usd": str(self.raw_fee),
109
+ "fee_usd": str(self.fee),
110
+ "fee_pct_of_trade_value": "" if pct is None else str(pct.quantize(Decimal("0.0001"))),
111
+ "cash_flow_usd": str(self.cash_flow),
112
+ "rounding": self.rounding,
113
+ "formula": self.formula,
114
+ "source_url": self.source_url,
115
+ "rates_as_of": self.rates_as_of,
116
+ }
117
+ out.update(self.details)
118
+ return out
119
+
120
+
121
+ def _quadratic(coefficient: Decimal, contracts: Decimal, price: Decimal) -> Decimal:
122
+ return coefficient * contracts * price * (ONE - price)
123
+
124
+
125
+ def _us_tier(rates: Rates, tier: int | str | None, volume_30d: Number | None) -> tuple[Decimal, str]:
126
+ us = rates.polymarket_us
127
+ if tier is not None and volume_30d is not None:
128
+ raise ValueError("give either tier or volume_30d, not both")
129
+ if volume_30d is not None:
130
+ vol = _num(volume_30d, "volume_30d")
131
+ if vol < 0:
132
+ raise ValueError("volume_30d must be >= 0")
133
+ for t in us.tiers:
134
+ if t.below is None or vol < t.below:
135
+ return t.theta, f"{t.label} (30-day taker volume ${vol:,})"
136
+ if tier is None:
137
+ first = us.tiers[0]
138
+ return first.theta, f"{first.label} (default: the highest coefficient)"
139
+ if isinstance(tier, str) and tier.strip().lower() in ("new", "new-account", "new_account"):
140
+ return us.new_account_theta, f"New account ({us.new_account_note})"
141
+ try:
142
+ n = int(str(tier).strip().lower().removeprefix("tier").removeprefix("t").strip())
143
+ except ValueError:
144
+ n = 0
145
+ for t in us.tiers:
146
+ if t.number == n:
147
+ return t.theta, t.label
148
+ valid = ", ".join([str(t.number) for t in us.tiers] + ["new"])
149
+ raise ValueError(f"unknown Polymarket US tier {tier!r}; use one of: {valid}")
150
+
151
+
152
+ def _global_category(rates: Rates, category: str | None) -> tuple[str, Decimal]:
153
+ cats = rates.polymarket_global.rates
154
+ if category is None:
155
+ raise ValueError(f"polymarket.com needs a category; one of: {', '.join(cats)}")
156
+ for name, rate in cats.items():
157
+ if name.lower() == category.strip().lower():
158
+ return name, rate
159
+ raise ValueError(f"unknown polymarket.com category {category!r}; use one of: {', '.join(cats)}")
160
+
161
+
162
+ def fee(
163
+ platform: str,
164
+ price: Number,
165
+ contracts: Number,
166
+ side: Side = "buy",
167
+ tier: int | str | None = None,
168
+ maker: bool = False,
169
+ *,
170
+ category: str | None = None,
171
+ multiplier: Number | None = None,
172
+ volume_30d: Number | None = None,
173
+ rates: Rates | None = None,
174
+ ) -> FeeResult:
175
+ """Trading fee for one order.
176
+
177
+ ``price`` is the price of the contract you trade, in dollars (0 < price < 1).
178
+ ``side`` only changes the cash flow; the fee formulas are symmetric.
179
+
180
+ Platform options:
181
+
182
+ * ``kalshi``: ``multiplier`` is the multiplier M. It defaults to 1 for takers
183
+ and 0 for makers (Kalshi charges makers only where the market says so, so pass
184
+ ``multiplier=1`` with ``maker=True`` for those). The fee is rounded up so that
185
+ fee + position cost (C × P) is a whole centicent ($0.0001).
186
+ * ``polymarket-us``: ``tier`` is 1-4 or ``"new"`` (first 7 days), or give
187
+ ``volume_30d`` (trailing 30-day taker volume in USD). Default: Tier 1.
188
+ Makers pay no fee.
189
+ * ``polymarket`` (polymarket.com): ``category`` is required. Makers pay no fee.
190
+ """
191
+ r = rates if rates is not None else default_rates()
192
+ key = platform_key(platform)
193
+ p = _num(price, "price")
194
+ c = _num(contracts, "contracts")
195
+ if not ONE > p > 0:
196
+ raise ValueError("price must be between 0 and 1 (exclusive), in dollars")
197
+ if c <= 0:
198
+ raise ValueError("contracts must be > 0")
199
+ if side not in ("buy", "sell"):
200
+ raise ValueError("side must be 'buy' or 'sell'")
201
+ if key != "polymarket-us" and (tier is not None or volume_30d is not None):
202
+ raise ValueError("tier and volume_30d only apply to polymarket-us")
203
+ if key != "kalshi" and multiplier is not None:
204
+ raise ValueError("multiplier only applies to kalshi")
205
+ if key != "polymarket" and category is not None:
206
+ raise ValueError("category only applies to polymarket (polymarket.com)")
207
+
208
+ role: Literal["taker", "maker"] = "maker" if maker else "taker"
209
+ details: dict[str, str] = {}
210
+ notes: list[str] = []
211
+ rounding: Rounding
212
+
213
+ if key == "kalshi":
214
+ k = r.kalshi
215
+ default_m = Decimal(0) if maker else ONE
216
+ m = default_m if multiplier is None else _num(multiplier, "multiplier")
217
+ if m not in k.multipliers:
218
+ allowed = ", ".join(str(x) for x in k.multipliers)
219
+ raise ValueError(f"unknown Kalshi series multiplier {m}; the rates file lists: {allowed}")
220
+ base = k.maker_rate if maker else k.taker_rate
221
+ coefficient = m * base
222
+ details["base_rate"] = str(base)
223
+ details["multiplier"] = str(m)
224
+ if k.version:
225
+ details["fee_schedule_version"] = k.version
226
+ if maker and multiplier is None:
227
+ notes.append(
228
+ "Kalshi maker multiplier defaults to 0; pass multiplier=1 for markets with maker fees."
229
+ )
230
+ rounding, formula, source, as_of = k.rounding, k.formula, k.source_url, k.as_of
231
+ elif key == "polymarket-us":
232
+ us = r.polymarket_us
233
+ theta, label = _us_tier(r, tier, volume_30d)
234
+ details["tier"] = label
235
+ details["theta"] = str(theta)
236
+ if maker:
237
+ coefficient = Decimal(0)
238
+ notes.append(f"Makers: {us.makers}. Maker rebates are not modelled.")
239
+ else:
240
+ coefficient = theta
241
+ rounding, formula, source, as_of = us.rounding, us.formula, us.source_url, us.as_of
242
+ else:
243
+ g = r.polymarket_global
244
+ cat, rate = _global_category(r, category)
245
+ details["category"] = cat
246
+ details["category_rate"] = str(rate)
247
+ if maker:
248
+ coefficient = Decimal(0)
249
+ notes.append("polymarket.com: makers are never charged fees.")
250
+ else:
251
+ coefficient = rate
252
+ notes.append(
253
+ "Each market's own feeSchedule is authoritative; series can differ from the category rate."
254
+ )
255
+ rounding, formula, source, as_of = g.rounding, g.formula, g.source_url, g.as_of
256
+
257
+ raw = _quadratic(coefficient, c, p)
258
+ # Kalshi (basis fee_plus_position_cost) rounds fee + C × P; the others round the fee alone.
259
+ rounded, rounding_text = rounding.apply(raw, c * p), rounding.describe()
260
+ return FeeResult(
261
+ platform=key,
262
+ platform_name=DISPLAY_NAMES[key],
263
+ side=side,
264
+ role=role,
265
+ price=p,
266
+ contracts=c,
267
+ coefficient=coefficient,
268
+ raw_fee=raw,
269
+ fee=rounded,
270
+ rounding=rounding_text,
271
+ formula=formula,
272
+ source_url=source,
273
+ rates_as_of=as_of or r.as_of,
274
+ details=details,
275
+ notes=tuple(notes),
276
+ )
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class BreakEven:
281
+ """The smallest price move that puts a round trip in profit after the fees on both orders."""
282
+
283
+ platform_name: str
284
+ side: Side
285
+ contracts: Decimal
286
+ entry_price: Decimal
287
+ entry_fee: Decimal
288
+ exit_price: Decimal | None
289
+ """First price on the tick grid where the round trip is > 0 after fees (None: none exists)."""
290
+ exit_fee: Decimal | None
291
+ move: Decimal | None
292
+ """exit_price − entry_price (positive for a buy, negative for a sell)."""
293
+ profit_at_exit: Decimal | None
294
+ tick: Decimal
295
+
296
+ @property
297
+ def move_cents(self) -> Decimal | None:
298
+ return None if self.move is None else self.move * 100
299
+
300
+
301
+ def break_even(
302
+ platform: str,
303
+ price: Number,
304
+ contracts: Number,
305
+ side: Side = "buy",
306
+ tier: int | str | None = None,
307
+ maker: bool = False,
308
+ *,
309
+ tick: Number = "0.01",
310
+ category: str | None = None,
311
+ multiplier: Number | None = None,
312
+ volume_30d: Number | None = None,
313
+ rates: Rates | None = None,
314
+ ) -> BreakEven:
315
+ """How far the price must move before closing the position is in profit after fees.
316
+
317
+ Opens ``contracts`` at ``price`` (``side="buy"`` buys, ``"sell"`` sells) and closes
318
+ them at a later price on the ``tick`` grid, paying the fee on both orders with the
319
+ same role and options. Returns the first exit price where
320
+ proceeds − cost − entry fee − exit fee > 0.
321
+ """
322
+ r = rates if rates is not None else default_rates()
323
+ t = _num(tick, "tick")
324
+ if t <= 0:
325
+ raise ValueError("tick must be > 0")
326
+
327
+ def leg(at: Number, leg_side: Side) -> FeeResult:
328
+ return fee(
329
+ platform,
330
+ at,
331
+ contracts,
332
+ leg_side,
333
+ tier,
334
+ maker,
335
+ category=category,
336
+ multiplier=multiplier,
337
+ volume_30d=volume_30d,
338
+ rates=r,
339
+ )
340
+
341
+ entry = leg(price, side)
342
+ exit_side: Side = "sell" if side == "buy" else "buy"
343
+ step = t if side == "buy" else -t
344
+ q = entry.price + step
345
+ while ONE > q > 0:
346
+ ex = leg(q, exit_side)
347
+ profit = entry.cash_flow + ex.cash_flow
348
+ if profit > 0:
349
+ return BreakEven(
350
+ platform_name=entry.platform_name,
351
+ side=side,
352
+ contracts=entry.contracts,
353
+ entry_price=entry.price,
354
+ entry_fee=entry.fee,
355
+ exit_price=q,
356
+ exit_fee=ex.fee,
357
+ move=q - entry.price,
358
+ profit_at_exit=profit,
359
+ tick=t,
360
+ )
361
+ q += step
362
+ return BreakEven(
363
+ platform_name=entry.platform_name,
364
+ side=side,
365
+ contracts=entry.contracts,
366
+ entry_price=entry.price,
367
+ entry_fee=entry.fee,
368
+ exit_price=None,
369
+ exit_fee=None,
370
+ move=None,
371
+ profit_at_exit=None,
372
+ tick=t,
373
+ )
@@ -0,0 +1,101 @@
1
+ {
2
+ "as_of": "2026-11-08",
3
+ "polymarket_global": {
4
+ "as_of": "2026-11-08",
5
+ "name": "Polymarket (international, polymarket.com)",
6
+ "source_url": "https://docs.polymarket.com/trading/fees",
7
+ "formula": "fee = C × feeRate × p × (1 − p); only takers pay; C = shares, p = share price",
8
+ "rates": {
9
+ "Crypto": 0.07,
10
+ "Sports": 0.05,
11
+ "Economics": 0.05,
12
+ "Culture": 0.05,
13
+ "Weather": 0.05,
14
+ "Other": 0.05,
15
+ "Combos": 0.05,
16
+ "Finance": 0.04,
17
+ "Politics": 0.04,
18
+ "Mentions": 0.04,
19
+ "Tech": 0.04,
20
+ "Geopolitics": 0.0
21
+ },
22
+ "rounding": {
23
+ "decimals": 5,
24
+ "mode": "half_up",
25
+ "note": "Fees are rounded to 5 decimals (minimum 0.00001 USDC)."
26
+ },
27
+ "maker_rebate_share": {
28
+ "Crypto": 0.2,
29
+ "Sports": 0.15,
30
+ "other fee-enabled categories": 0.25,
31
+ "Combos": 0.0
32
+ },
33
+ "notes": [
34
+ "Some series differ from the category rate (for example NFL and college football markets at 0.03 in November 2026); each market's feeSchedule is authoritative.",
35
+ "Fees apply only to markets created after their category's activation date; older markets can be fee-free."
36
+ ]
37
+ },
38
+ "polymarket_us": {
39
+ "as_of": "2026-11-09",
40
+ "name": "Polymarket US (CFTC-regulated app)",
41
+ "source_url": "https://docs.polymarket.us/fees",
42
+ "effective": "2026-11-06 13:00 ET",
43
+ "formula": "fee = Θ × C × p × (1 − p), one curve for all markets",
44
+ "taker_tiers_30d_volume_usd": [
45
+ {
46
+ "below": 10000,
47
+ "theta": 0.095
48
+ },
49
+ {
50
+ "below": 50000,
51
+ "theta": 0.085
52
+ },
53
+ {
54
+ "below": 250000,
55
+ "theta": 0.075
56
+ },
57
+ {
58
+ "below": null,
59
+ "theta": 0.0695
60
+ }
61
+ ],
62
+ "rounding": {
63
+ "decimals": 2,
64
+ "mode": "half_even",
65
+ "note": "Rounded to the nearest $0.01 with banker's rounding (half to even)."
66
+ },
67
+ "new_accounts": "Θ = 0.0695 for the first 7 days",
68
+ "makers": "no fee; maker rebates start from $10 million in maker volume",
69
+ "previous": "Θ = 0.0695 for all accounts (0.06 until 2026-09-17)",
70
+ "notes": [
71
+ "Combo taker fees (not modelled in the calculator): until 2026-11-11 13:00 ET fee = C × p × [0.0695 × (1 − p) + 0.085 × (1 − p)^4]; from 2026-11-11 13:00 ET fee = C × p × [Θ × (1 − p) + 0.06 × (1 − p)^4], with Θ the account's taker coefficient. Source: docs.polymarket.us/fees (announced as confirmed, seen 2026-11-09)."
72
+ ]
73
+ },
74
+ "kalshi": {
75
+ "as_of": "2026-11-10",
76
+ "name": "Kalshi",
77
+ "source_url": "https://kalshi.com/docs/kalshi-fee-schedule.pdf",
78
+ "source_date": "2026-07-07",
79
+ "version": "7.7.26",
80
+ "formula": "taker fee = round up(M × 0.07 × C × P × (1 − P)), M = 1 by default; maker fee = round up(M × 0.0175 × C × P × (1 − P)), M = 0 by default (only series listed with maker fees). Round up: fee + position cost (C × P) is rounded up to a whole centicent ($0.0001)",
81
+ "taker_rate": 0.07,
82
+ "maker_rate": 0.0175,
83
+ "multipliers": [
84
+ 1,
85
+ 0.5,
86
+ 0
87
+ ],
88
+ "rounding": {
89
+ "decimals": 4,
90
+ "mode": "up",
91
+ "basis": "fee_plus_position_cost",
92
+ "source_url": "https://kalshi.com/docs/kalshi-fee-schedule.pdf",
93
+ "version": "7.7.26",
94
+ "note": "Kalshi rounds the fee up so that fee plus position cost (contracts × price) is a whole centicent ($0.0001), per the fee schedule (7.7.26, p. 2). Accounts cleared through a broker (FCM) keep balances in whole cents, so there the fee rounds to the cent.",
95
+ "broker_source_url": "https://docs.kalshi.com/getting_started/fee_rounding.md"
96
+ },
97
+ "notes": [
98
+ "M is a per-series multiplier (1, 0.5 or 0); default 1 for taker fees, 0 for maker fees."
99
+ ]
100
+ }
101
+ }
pmfees/py.typed ADDED
File without changes
pmfees/rates.py ADDED
@@ -0,0 +1,265 @@
1
+ """Load fee rates from ``prediction_market_fees.json``.
2
+
3
+ The JSON file is the only place rates, tiers, multipliers and rounding rules live.
4
+ Nothing in this package hardcodes a rate: pass ``path=`` (or set ``PMFEES_RATES_FILE``)
5
+ to use an updated file without a new release.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass
13
+ from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP, ROUND_UP, Decimal
14
+ from importlib import resources
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ ENV_VAR = "PMFEES_RATES_FILE"
19
+
20
+ _ROUNDING_MODES = {"up": ROUND_UP, "half_even": ROUND_HALF_EVEN, "half_up": ROUND_HALF_UP}
21
+ _ROUNDING_WORDS = {
22
+ "up": "rounded up",
23
+ "half_even": "rounded half to even (banker's rounding)",
24
+ "half_up": "rounded half up",
25
+ }
26
+
27
+
28
+ class RatesError(ValueError):
29
+ """The rates file is missing a field or has a value the package cannot use."""
30
+
31
+
32
+ BASES = (None, "fee_plus_position_cost")
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Rounding:
37
+ decimals: int
38
+ mode: str
39
+ note: str
40
+ basis: str | None = None
41
+ """``fee_plus_position_cost`` (Kalshi): fee + position cost (C × P) is rounded, not the fee alone."""
42
+
43
+ def apply(self, value: Decimal, position_cost: Decimal = Decimal(0)) -> Decimal:
44
+ q = Decimal(1).scaleb(-self.decimals)
45
+ if self.basis == "fee_plus_position_cost":
46
+ return (position_cost + value).quantize(q, rounding=_ROUNDING_MODES[self.mode]) - position_cost
47
+ return value.quantize(q, rounding=_ROUNDING_MODES[self.mode])
48
+
49
+ def describe(self) -> str:
50
+ unit = {2: "the cent", 4: "a whole centicent ($0.0001)"}.get(
51
+ self.decimals, f"{self.decimals} decimals"
52
+ )
53
+ if self.basis == "fee_plus_position_cost":
54
+ return f"{_ROUNDING_WORDS[self.mode]} so that fee + position cost is {unit}"
55
+ return f"{_ROUNDING_WORDS[self.mode]} to {unit}"
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class UsTier:
60
+ number: int
61
+ below: Decimal | None
62
+ theta: Decimal
63
+
64
+ @property
65
+ def label(self) -> str:
66
+ return f"Tier {self.number}"
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class PolymarketGlobal:
71
+ name: str
72
+ source_url: str
73
+ formula: str
74
+ rates: dict[str, Decimal]
75
+ rounding: Rounding
76
+ maker_rebate_share: dict[str, Decimal]
77
+ notes: tuple[str, ...]
78
+ as_of: str = ""
79
+ """Date these rates were last checked against ``source_url``."""
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class PolymarketUS:
84
+ name: str
85
+ source_url: str
86
+ effective: str
87
+ formula: str
88
+ tiers: tuple[UsTier, ...]
89
+ new_account_theta: Decimal
90
+ new_account_note: str
91
+ makers: str
92
+ rounding: Rounding
93
+ notes: tuple[str, ...]
94
+ as_of: str = ""
95
+ """Date these rates were last checked against ``source_url``."""
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Kalshi:
100
+ name: str
101
+ source_url: str
102
+ source_date: str
103
+ formula: str
104
+ taker_rate: Decimal
105
+ maker_rate: Decimal
106
+ multipliers: tuple[Decimal, ...]
107
+ rounding: Rounding
108
+ rounding_source_url: str | None
109
+ notes: tuple[str, ...]
110
+ as_of: str = ""
111
+ """Date these rates were last checked against ``source_url``."""
112
+ version: str = ""
113
+ """Version of the Kalshi fee schedule the rates come from (for example ``7.7.26``)."""
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class Rates:
118
+ as_of: str
119
+ path: str
120
+ polymarket_global: PolymarketGlobal
121
+ polymarket_us: PolymarketUS
122
+ kalshi: Kalshi
123
+
124
+
125
+ def _get(obj: dict[str, Any], key: str, where: str) -> Any:
126
+ if key not in obj:
127
+ raise RatesError(f"{where}: missing field {key!r}")
128
+ return obj[key]
129
+
130
+
131
+ def _dec(value: Any, where: str) -> Decimal:
132
+ if isinstance(value, bool) or not isinstance(value, (Decimal, int)):
133
+ raise RatesError(f"{where}: expected a number, got {value!r}")
134
+ d = Decimal(value)
135
+ if d < 0:
136
+ raise RatesError(f"{where}: negative value {value!r}")
137
+ return d
138
+
139
+
140
+ def _rounding(obj: dict[str, Any], where: str) -> Rounding:
141
+ mode = _get(obj, "mode", where)
142
+ if mode not in _ROUNDING_MODES:
143
+ raise RatesError(f"{where}: unknown rounding mode {mode!r}")
144
+ decimals = _get(obj, "decimals", where)
145
+ if not isinstance(decimals, int) or decimals < 0:
146
+ raise RatesError(f"{where}: decimals must be a non-negative integer")
147
+ basis = obj.get("basis")
148
+ if basis not in BASES:
149
+ raise RatesError(f"{where}: unknown rounding basis {basis!r}")
150
+ return Rounding(decimals=decimals, mode=mode, note=str(obj.get("note", "")), basis=basis)
151
+
152
+
153
+ def _new_account_theta(text: str, where: str) -> Decimal:
154
+ # The file states it as text, e.g. "Θ = 0.0695 for the first 7 days".
155
+ for token in text.replace("=", " ").split():
156
+ try:
157
+ return _dec(Decimal(token), where)
158
+ except ArithmeticError:
159
+ continue
160
+ raise RatesError(f"{where}: no coefficient found in {text!r}")
161
+
162
+
163
+ def parse_rates(data: dict[str, Any], path: str = "<memory>") -> Rates:
164
+ """Build :class:`Rates` from the decoded JSON (numbers decoded as ``Decimal``)."""
165
+ g = _get(data, "polymarket_global", "root")
166
+ us = _get(data, "polymarket_us", "root")
167
+ k = _get(data, "kalshi", "root")
168
+
169
+ tiers: list[UsTier] = []
170
+ raw_tiers = _get(us, "taker_tiers_30d_volume_usd", "polymarket_us")
171
+ for i, t in enumerate(raw_tiers, start=1):
172
+ where = f"polymarket_us.taker_tiers_30d_volume_usd[{i - 1}]"
173
+ below = _get(t, "below", where)
174
+ tiers.append(
175
+ UsTier(
176
+ number=i,
177
+ below=None if below is None else _dec(below, where),
178
+ theta=_dec(_get(t, "theta", where), where),
179
+ )
180
+ )
181
+ if not tiers or tiers[-1].below is not None:
182
+ raise RatesError("polymarket_us: the last tier must have below = null")
183
+
184
+ new_accounts = str(_get(us, "new_accounts", "polymarket_us"))
185
+ rounding_k = _get(k, "rounding", "kalshi")
186
+ as_of = str(_get(data, "as_of", "root"))
187
+
188
+ return Rates(
189
+ as_of=as_of,
190
+ path=path,
191
+ polymarket_global=PolymarketGlobal(
192
+ name=str(_get(g, "name", "polymarket_global")),
193
+ source_url=str(_get(g, "source_url", "polymarket_global")),
194
+ formula=str(_get(g, "formula", "polymarket_global")),
195
+ rates={
196
+ cat: _dec(r, f"polymarket_global.rates.{cat}")
197
+ for cat, r in _get(g, "rates", "polymarket_global").items()
198
+ },
199
+ rounding=_rounding(_get(g, "rounding", "polymarket_global"), "polymarket_global.rounding"),
200
+ maker_rebate_share={
201
+ cat: _dec(r, f"polymarket_global.maker_rebate_share.{cat}")
202
+ for cat, r in g.get("maker_rebate_share", {}).items()
203
+ },
204
+ notes=tuple(g.get("notes", ())),
205
+ as_of=str(g.get("as_of") or as_of),
206
+ ),
207
+ polymarket_us=PolymarketUS(
208
+ name=str(_get(us, "name", "polymarket_us")),
209
+ source_url=str(_get(us, "source_url", "polymarket_us")),
210
+ effective=str(us.get("effective", "")),
211
+ formula=str(_get(us, "formula", "polymarket_us")),
212
+ tiers=tuple(tiers),
213
+ new_account_theta=_new_account_theta(new_accounts, "polymarket_us.new_accounts"),
214
+ new_account_note=new_accounts,
215
+ makers=str(us.get("makers", "")),
216
+ rounding=_rounding(_get(us, "rounding", "polymarket_us"), "polymarket_us.rounding"),
217
+ notes=tuple(us.get("notes", ())),
218
+ as_of=str(us.get("as_of") or as_of),
219
+ ),
220
+ kalshi=Kalshi(
221
+ name=str(_get(k, "name", "kalshi")),
222
+ source_url=str(_get(k, "source_url", "kalshi")),
223
+ source_date=str(k.get("source_date", "")),
224
+ formula=str(_get(k, "formula", "kalshi")),
225
+ taker_rate=_dec(_get(k, "taker_rate", "kalshi"), "kalshi.taker_rate"),
226
+ maker_rate=_dec(_get(k, "maker_rate", "kalshi"), "kalshi.maker_rate"),
227
+ multipliers=tuple(_dec(m, "kalshi.multipliers") for m in _get(k, "multipliers", "kalshi")),
228
+ rounding=_rounding(rounding_k, "kalshi.rounding"),
229
+ rounding_source_url=rounding_k.get("source_url"),
230
+ notes=tuple(k.get("notes", ())),
231
+ as_of=str(k.get("as_of") or as_of),
232
+ version=str(k.get("version", "")),
233
+ ),
234
+ )
235
+
236
+
237
+ def load_rates(path: str | os.PathLike[str] | None = None) -> Rates:
238
+ """Load rates from ``path``, else ``$PMFEES_RATES_FILE``, else the bundled file."""
239
+ chosen = path if path is not None else os.environ.get(ENV_VAR) or None
240
+ if chosen is not None:
241
+ p = Path(chosen)
242
+ text = p.read_text(encoding="utf-8")
243
+ label = str(p)
244
+ else:
245
+ res = resources.files("pmfees").joinpath("data/prediction_market_fees.json")
246
+ text = res.read_text(encoding="utf-8")
247
+ label = "bundled prediction_market_fees.json"
248
+ try:
249
+ data = json.loads(text, parse_float=Decimal)
250
+ except json.JSONDecodeError as exc:
251
+ raise RatesError(f"{label}: not valid JSON ({exc})") from exc
252
+ if not isinstance(data, dict):
253
+ raise RatesError(f"{label}: expected a JSON object")
254
+ return parse_rates(data, label)
255
+
256
+
257
+ _default: Rates | None = None
258
+
259
+
260
+ def default_rates() -> Rates:
261
+ """The rates used when no ``rates=`` argument is given (loaded once)."""
262
+ global _default
263
+ if _default is None:
264
+ _default = load_rates()
265
+ return _default
@@ -0,0 +1,232 @@
1
+ Metadata-Version: 2.4
2
+ Name: pmfees
3
+ Version: 0.1.0
4
+ Summary: Exact trading fees for Kalshi, Polymarket US and polymarket.com, with a CLI and break-even helper.
5
+ Author-email: SwapHunt <github@swaphunt.dev>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/swaphunt/pmfees
8
+ Project-URL: Source, https://github.com/swaphunt/pmfees
9
+ Project-URL: Issues, https://github.com/swaphunt/pmfees/issues
10
+ Project-URL: Changelog, https://github.com/swaphunt/pmfees/blob/main/CHANGELOG.md
11
+ Project-URL: Fee calculator, https://swaphunt.dev/tools/kalshi-vs-polymarket-fee-calculator
12
+ Keywords: kalshi,polymarket,prediction markets,fees,trading
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Typing :: Typed
16
+ Classifier: Development Status :: 4 - Beta
17
+ Classifier: Topic :: Office/Business :: Financial
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # pmfees
24
+
25
+ Exact trading fees for the prediction market platforms **Kalshi**, **Polymarket US** and
26
+ **polymarket.com** (international), as a small Python library and a command line tool.
27
+
28
+ - The platforms' own formulas and rounding rules: Kalshi rounds up so that fee + position cost
29
+ is a whole centicent ($0.0001), Polymarket US rounds to the cent with banker's rounding,
30
+ polymarket.com rounds to 5 decimals.
31
+ - Exact decimal arithmetic (`decimal.Decimal`), so $1.75 is $1.75 and not $1.7500000000000002.
32
+ - A break-even helper: how far the price has to move before a round trip is in profit after
33
+ the fee on both orders.
34
+ - No dependencies outside the Python standard library. Typed (`py.typed`).
35
+
36
+ Rates current as of: Kalshi 2026-11-10 (fee schedule 7.7.26), Polymarket US 2026-11-09,
37
+ polymarket.com 2026-11-08. Each platform's date and source are in the rates file and in every result.
38
+
39
+ Other ways to get the same numbers, from the same rates file:
40
+
41
+ - In a browser: the [SwapHunt Kalshi vs Polymarket fee calculator](https://swaphunt.dev/tools/kalshi-vs-polymarket-fee-calculator).
42
+ - Over HTTP, for agents: [`GET https://x402.swaphunt.dev/v1/pm/fees`](https://swaphunt.dev/api), paid per call
43
+ with [x402](https://www.x402.org/) in USDC. This package is free; the endpoint exists for agents that
44
+ cannot install a library.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install pmfees
50
+ ```
51
+
52
+ Python 3.10 or newer.
53
+
54
+ ## Command line
55
+
56
+ ```bash
57
+ pmfees kalshi --price 0.50 --contracts 100
58
+ ```
59
+
60
+ ```text
61
+ Kalshi taker fee: $1.75 (3.50% of trade value at 50¢)
62
+ buy 100 contracts at $0.50 = $50.00 trade value
63
+ base rate: 0.07
64
+ multiplier: 1
65
+ fee schedule version: 7.7.26
66
+ 0.07 × 100 × 0.5 × (1 − 0.5) = 1.75, rounded up so that fee + position cost is a whole centicent ($0.0001): $1.75
67
+ rates as of 2026-11-10 · source: https://kalshi.com/docs/kalshi-fee-schedule.pdf
68
+ ```
69
+
70
+ More examples:
71
+
72
+ ```bash
73
+ # Polymarket US, Tier 1 (under $10,000 of 30-day taker volume, the default)
74
+ pmfees polymarket-us --price 0.50 --contracts 100
75
+ # -> Polymarket US taker fee: $2.38 (4.76% of trade value at 50¢) [0.095 × 100 × 0.5 × 0.5 = 2.375]
76
+
77
+ # polymarket.com, Crypto category
78
+ pmfees polymarket --price 0.25 --contracts 100 --category Crypto
79
+ # -> polymarket.com taker fee: $1.3125 (5.25% of trade value at 25¢)
80
+
81
+ # Break-even: buy 100 on Kalshi at 50¢, when is selling in profit?
82
+ pmfees kalshi --price 0.50 --contracts 100 --break-even
83
+ # -> Break-even: the price must rise 4¢ to 54¢ (tick 1¢); entry fee $1.75 + exit fee $1.7388, profit at that price $0.5112
84
+ ```
85
+
86
+ Options:
87
+
88
+ | Option | Platforms | Meaning |
89
+ |---|---|---|
90
+ | `--price` | all | Price of the contract you trade, in dollars (0 < price < 1) |
91
+ | `--contracts` | all | Number of contracts (shares on polymarket.com) |
92
+ | `--side buy\|sell` | all | Changes the cash flow only; the fee formulas are symmetric |
93
+ | `--maker` | all | Maker order. Kalshi: maker rate 0.0175 with M default 0, so pass `--multiplier 1` on markets with maker fees. Polymarket US and polymarket.com: makers pay no fee |
94
+ | `--multiplier` | kalshi | Fee multiplier M (default 1 for takers, 0 for makers) |
95
+ | `--tier 1-4\|new` | polymarket-us | Taker tier, or `new` for the first 7 days of a new account (default 1) |
96
+ | `--volume` | polymarket-us | Trailing 30-day taker volume in USD; picks the tier for you |
97
+ | `--category` | polymarket | Market category, e.g. `Crypto`, `Sports`, `Politics` (required) |
98
+ | `--break-even`, `--tick` | all | Also print the break-even move (price step, default 0.01) |
99
+ | `--json` | all | Machine-readable output |
100
+ | `--rates PATH` | all | Use an updated rates file |
101
+
102
+ `pmfees rates` prints the loaded rates and their date.
103
+
104
+ ## Library
105
+
106
+ ```python
107
+ from pmfees import fee, break_even
108
+
109
+ r = fee("kalshi", 0.50, 100)
110
+ r.fee # Decimal('1.7500') what you pay, in USD
111
+ r.raw_fee # Decimal('1.7500') before rounding
112
+ r.fee_pct_of_trade_value # Decimal('3.500')
113
+ r.breakdown() # dict of strings: coefficient, rounding, formula, source_url, rates_as_of, ...
114
+
115
+ fee("polymarket-us", 0.10, 1000, "buy", tier=4).fee # Decimal('6.26')
116
+ fee("polymarket-us", 0.10, 1000, volume_30d=12_500).fee # Tier 2
117
+ fee("polymarket", 0.25, 100, category="Crypto").fee # Decimal('1.31250')
118
+ fee("kalshi", 0.50, 1).fee # Decimal('0.0175') centicent rounding, not $0.02
119
+ fee("kalshi", 0.50, 100, maker=True).fee # Decimal('0.0000') maker M defaults to 0
120
+ fee("kalshi", 0.50, 100, maker=True, multiplier=1).fee # Decimal('0.4375')
121
+ fee("kalshi", 0.50, 100, multiplier=0.5).fee # Decimal('0.8750')
122
+
123
+ b = break_even("polymarket-us", 0.40, 500, tier=2)
124
+ b.exit_price, b.move_cents # (Decimal('0.45'), Decimal('5.00'))
125
+ ```
126
+
127
+ Signature: `fee(platform, price, contracts, side="buy", tier=None, maker=False, *, category=None,
128
+ multiplier=None, volume_30d=None, rates=None) -> FeeResult`. Platform names: `kalshi`,
129
+ `polymarket-us`, `polymarket` (also `polymarket.com`). Prices and counts can be `str`, `int`,
130
+ `float` or `Decimal`; floats are converted through `str`, so `0.1` means exactly 0.1.
131
+
132
+ `break_even(...)` takes the same arguments plus `tick`. It opens the position at `price`, then
133
+ walks the price in `tick` steps (up for a buy, down for a sell) and returns the first exit price
134
+ where proceeds − cost − entry fee − exit fee > 0, or `exit_price=None` if no price before $1 (or
135
+ $0) works.
136
+
137
+ ## Kalshi: centicent rounding and the multiplier
138
+
139
+ Kalshi does not round the fee on its own. It rounds **fee + position cost** (contracts × price) up to
140
+ a whole centicent ($0.0001), per the fee schedule (version 7.7.26, page 2). Most of the time that
141
+ looks like rounding the fee up to four decimals, but the fee alone is what you see change:
142
+
143
+ ```python
144
+ from pmfees import fee
145
+
146
+ fee("kalshi", 0.50, 1).fee # Decimal('0.0175') 0.07 × 1 × 0.5 × 0.5 = 0.0175 exactly; not $0.02
147
+ fee("kalshi", 0.33, 7).fee # Decimal('0.1084') raw 0.108339; 2.31 + 0.108339 = 2.418339 -> 2.4184
148
+ ```
149
+
150
+ Accounts cleared through a broker (FCM) keep balances in whole cents, so there the fee rounds to the
151
+ cent ([Kalshi docs](https://docs.kalshi.com/getting_started/fee_rounding.md)). pmfees models
152
+ kalshi.com accounts (direct members).
153
+
154
+ Each Kalshi series has a fee multiplier M: 1 (standard), 0.5 or 0. Takers default to M = 1, makers
155
+ to M = 0, because Kalshi charges makers only on series listed with maker fees:
156
+
157
+ ```python
158
+ fee("kalshi", 0.25, 100).fee # Decimal('1.3125') M = 1
159
+ fee("kalshi", 0.25, 100, multiplier=0.5).fee # Decimal('0.6563') raw 0.65625, rounded up
160
+ fee("kalshi", 0.50, 100, maker=True).fee # Decimal('0.0000') maker, M defaults to 0
161
+ fee("kalshi", 0.50, 100, maker=True, multiplier=1).fee # Decimal('0.4375') 0.0175 × 100 × 0.25
162
+ ```
163
+
164
+ ```bash
165
+ pmfees kalshi --price 0.25 --contracts 100 --multiplier 0.5
166
+ # -> Kalshi taker fee: $0.6563 (2.63% of trade value at 25¢)
167
+ ```
168
+
169
+ ## The formulas
170
+
171
+ | Platform | Taker fee | Rounding |
172
+ |---|---|---|
173
+ | Kalshi | M × 0.07 × C × P × (1 − P), M default 1; maker M × 0.0175 × C × P × (1 − P), M default 0 | Up, so that fee + position cost is a whole centicent ($0.0001) |
174
+ | Polymarket US | Θ × C × p × (1 − p), Θ by 30-day taker volume: 0.095 (< $10K), 0.085 (< $50K), 0.075 (< $250K), 0.0695 ($250K+); new accounts 0.0695 for the first 7 days | Nearest cent, half to even |
175
+ | polymarket.com | C × rate × p × (1 − p), rate by category (Crypto 0.07; Sports, Economics, Culture, Weather, Other, Combos 0.05; Finance, Politics, Mentions, Tech 0.04; Geopolitics 0) | 5 decimals |
176
+
177
+ The numbers in this table are for reading only. The code takes every rate, tier, multiplier and
178
+ rounding rule (including Kalshi's fee + position cost basis) from `prediction_market_fees.json`.
179
+
180
+ Not modelled: Polymarket US combo fees and maker rebates, polymarket.com maker and taker rebate
181
+ programs, and per-market exceptions (polymarket.com
182
+ markets carry their own `feeSchedule`, Kalshi series their own multiplier). Check the market when
183
+ it matters.
184
+
185
+ ## Sources
186
+
187
+ | Platform | Source | Rates current as of |
188
+ |---|---|---|
189
+ | Kalshi | <https://kalshi.com/docs/kalshi-fee-schedule.pdf> (version 7.7.26) | 2026-11-10 |
190
+ | Polymarket US | <https://docs.polymarket.us/fees> | 2026-11-09 |
191
+ | polymarket.com | <https://docs.polymarket.com/trading/fees> | 2026-11-08 |
192
+
193
+ The tests lock the formulas to the worked examples and fee tables on these pages, with a link
194
+ to the source in each test.
195
+
196
+ ## Updating the rates
197
+
198
+ All rates live in `src/pmfees/data/prediction_market_fees.json`. Each platform has its own `as_of`
199
+ (the date its rates were last checked against `source_url`). SwapHunt checks the official fee pages
200
+ every week; when a rate changes, the updated file arrives here as a pull request, and a new release
201
+ follows after review. To use newer rates without waiting for a release:
202
+
203
+ ```python
204
+ from pmfees import fee, load_rates
205
+
206
+ rates = load_rates("prediction_market_fees.json")
207
+ fee("kalshi", 0.50, 100, rates=rates)
208
+ ```
209
+
210
+ or set `PMFEES_RATES_FILE=/path/to/prediction_market_fees.json`, or pass `--rates` on the
211
+ command line. A file with a missing field or an unknown rounding mode is rejected with a
212
+ `RatesError` that names the field.
213
+
214
+ ## Development
215
+
216
+ ```bash
217
+ pip install -e . pytest ruff mypy
218
+ pytest
219
+ ruff check . && ruff format --check .
220
+ mypy
221
+ ```
222
+
223
+ Releases go to PyPI from GitHub Actions with trusted publishing: bump `__version__`, add a
224
+ CHANGELOG entry, and push a `v<version>` tag. The publish job waits for approval.
225
+
226
+ ## Disclaimer
227
+
228
+ Fee estimates for information only. The platform's own confirmation is what you pay.
229
+
230
+ ## License
231
+
232
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ pmfees/__init__.py,sha256=I897uC5YPdAsjPeH91a9eFlZkkh7DwQznW9Z2zicNrE,659
2
+ pmfees/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ pmfees/cli.py,sha256=TkAhS6KvRm7NKbyZ0n4Zg2_S4ylKSiPqfWH7m6Chi5A,7482
4
+ pmfees/core.py,sha256=uUPWjXAeg7_m5Wv_Haug-a31udtAnBL1RpWf2A7k9Xs,13258
5
+ pmfees/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ pmfees/rates.py,sha256=FuaPKmqN8JUIMxnG0bTvQVHCG0ZvBIxwV7zN-l4zv0M,9338
7
+ pmfees/data/prediction_market_fees.json,sha256=4cGczSPTImETD_2pF7RUSFifEr1azZ4YF327wmgq8lE,3757
8
+ pmfees-0.1.0.dist-info/licenses/LICENSE,sha256=T-Ys1tHHacpFh5TuhSsZ_4qsGuZasUG5I_RMUG6PK-k,1065
9
+ pmfees-0.1.0.dist-info/METADATA,sha256=dfvX67_NII5EJtoG4X9dbtizDxWAedgRs7pcUpgNoy4,10463
10
+ pmfees-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ pmfees-0.1.0.dist-info/entry_points.txt,sha256=sugxa34guA5WlSdLmAXmXaY_AtAm73i0tskaSLmI5DM,43
12
+ pmfees-0.1.0.dist-info/top_level.txt,sha256=f8789C1LmCwDMPY8onCj6bSk0oHTIpLr6q-1Iyh7UKw,7
13
+ pmfees-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pmfees = pmfees.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SwapHunt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pmfees