langbot-sdk 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.
Files changed (47) hide show
  1. langbot/__init__.py +147 -0
  2. langbot/adapter.py +310 -0
  3. langbot/backtest.py +272 -0
  4. langbot/cli.py +2785 -0
  5. langbot/client.py +1490 -0
  6. langbot/config.py +144 -0
  7. langbot/kalshi.py +890 -0
  8. langbot/lab/__init__.py +90 -0
  9. langbot/lab/base.py +346 -0
  10. langbot/lab/engine.py +228 -0
  11. langbot/lab/env.py +151 -0
  12. langbot/lab/fees.py +310 -0
  13. langbot/lab/history.py +42 -0
  14. langbot/lab/paper.py +351 -0
  15. langbot/lab/payrolls.py +257 -0
  16. langbot/lab/scorecard.py +383 -0
  17. langbot/lab/strategies.py +524 -0
  18. langbot/live/__init__.py +76 -0
  19. langbot/live/audit.py +108 -0
  20. langbot/live/guard.py +782 -0
  21. langbot/live/kalshi_venue.py +124 -0
  22. langbot/live/killswitch.py +95 -0
  23. langbot/live/ledger.py +304 -0
  24. langbot/live/limits.py +235 -0
  25. langbot/live/polymarket_venue.py +141 -0
  26. langbot/live/venue.py +171 -0
  27. langbot/notify.py +151 -0
  28. langbot/polymarket_trading.py +879 -0
  29. langbot/py.typed +0 -0
  30. langbot/receipts.py +662 -0
  31. langbot/runtime.py +650 -0
  32. langbot/schemas.py +1233 -0
  33. langbot/sim/__init__.py +153 -0
  34. langbot/sim/bench.py +297 -0
  35. langbot/sim/engine.py +1389 -0
  36. langbot/sim/local.py +112 -0
  37. langbot/sim/scorecard.py +390 -0
  38. langbot/sim/stats.py +148 -0
  39. langbot/sim/strategies.py +367 -0
  40. langbot/sim/study.py +581 -0
  41. langbot/strategy.py +691 -0
  42. langbot/x402.py +276 -0
  43. langbot_sdk-0.1.0.dist-info/METADATA +410 -0
  44. langbot_sdk-0.1.0.dist-info/RECORD +47 -0
  45. langbot_sdk-0.1.0.dist-info/WHEEL +4 -0
  46. langbot_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  47. langbot_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
langbot/__init__.py ADDED
@@ -0,0 +1,147 @@
1
+ """Langbot SDK: data and simulation for every market on Kalshi and Polymarket.
2
+
3
+ Quick start (one strategy protocol for ``langbot run``, ``langbot backtest`` and
4
+ ``langbot sim run``)::
5
+
6
+ class Mention:
7
+ markets = ["KXEARNINGSMENTIONNVDA-*"]
8
+ phrases = ["blackwell"]
9
+
10
+ def on_event(self, evt, ctx):
11
+ if evt["type"] == "phrase_detected":
12
+ for ticker in evt["market_refs"]:
13
+ book = ctx.book(ticker)
14
+ if book and book.yes_ask and book.yes_ask < 0.60:
15
+ ctx.buy(ticker, "yes", 10, limit=book.yes_ask, tif="ioc")
16
+
17
+ Run it with ``langbot run strategy.py --mode demo``.
18
+ """
19
+
20
+ from . import receipts
21
+ from .backtest import Backtester, BacktestResult, backtest_events, backtest_file, load_jsonl
22
+ from .client import (
23
+ Langbot,
24
+ LangbotAPIError,
25
+ LangbotApiError,
26
+ LangbotClient,
27
+ LangbotError,
28
+ StreamRefused,
29
+ )
30
+ from .kalshi import KalshiAuth, KalshiClient, KalshiError, KalshiRateLimiter, TokenBucket
31
+ from .notify import (
32
+ DiscordNotifier,
33
+ MultiNotifier,
34
+ NullNotifier,
35
+ SlackNotifier,
36
+ TelegramNotifier,
37
+ notifier_from_env,
38
+ )
39
+ from .schemas import (
40
+ BaseRateMonth,
41
+ BaseRates,
42
+ Book,
43
+ CalendarEntry,
44
+ Candle,
45
+ CandlesPage,
46
+ Event,
47
+ EventType,
48
+ Fact,
49
+ FactKind,
50
+ Fill,
51
+ LiveEvent,
52
+ Market,
53
+ MarketBrief,
54
+ MarketContext,
55
+ MarketOutcome,
56
+ Mention,
57
+ Order,
58
+ OutcomesPage,
59
+ SourceKind,
60
+ Speaker,
61
+ SpeakerProfile,
62
+ Subscription,
63
+ TextPosted,
64
+ Transcript,
65
+ TranscriptWord,
66
+ Verdict,
67
+ new_event_id,
68
+ new_id,
69
+ now_ms,
70
+ )
71
+ from .strategy import (
72
+ KalshiBroker,
73
+ LiveModeRefused,
74
+ PaperBroker,
75
+ Position,
76
+ RiskRails,
77
+ RiskViolation,
78
+ Strategy,
79
+ )
80
+ from .x402 import PaymentRequired
81
+
82
+ __version__ = "0.1.0"
83
+
84
+ __all__ = [
85
+ "BacktestResult",
86
+ "Backtester",
87
+ "BaseRateMonth",
88
+ "BaseRates",
89
+ "Book",
90
+ "CalendarEntry",
91
+ "DiscordNotifier",
92
+ "Event",
93
+ "EventType",
94
+ "Fact",
95
+ "FactKind",
96
+ "Fill",
97
+ "KalshiAuth",
98
+ "KalshiBroker",
99
+ "KalshiClient",
100
+ "KalshiError",
101
+ "KalshiRateLimiter",
102
+ "Langbot",
103
+ "LangbotAPIError",
104
+ "LangbotApiError",
105
+ "LangbotClient",
106
+ "LangbotError",
107
+ "PaymentRequired",
108
+ "LiveEvent",
109
+ "LiveModeRefused",
110
+ "Market",
111
+ "MarketBrief",
112
+ "MarketContext",
113
+ "MarketOutcome",
114
+ "Mention",
115
+ "MultiNotifier",
116
+ "NullNotifier",
117
+ "Order",
118
+ "OutcomesPage",
119
+ "Candle",
120
+ "CandlesPage",
121
+ "PaperBroker",
122
+ "Position",
123
+ "RiskRails",
124
+ "RiskViolation",
125
+ "SlackNotifier",
126
+ "SourceKind",
127
+ "Speaker",
128
+ "SpeakerProfile",
129
+ "Strategy",
130
+ "StreamRefused",
131
+ "Subscription",
132
+ "TextPosted",
133
+ "TelegramNotifier",
134
+ "TokenBucket",
135
+ "Transcript",
136
+ "TranscriptWord",
137
+ "Verdict",
138
+ "__version__",
139
+ "backtest_events",
140
+ "backtest_file",
141
+ "load_jsonl",
142
+ "new_event_id",
143
+ "new_id",
144
+ "notifier_from_env",
145
+ "now_ms",
146
+ "receipts",
147
+ ]
langbot/adapter.py ADDED
@@ -0,0 +1,310 @@
1
+ """One strategy protocol for `langbot sim run`, `langbot run` and `langbot backtest`.
2
+
3
+ A strategy is any object with a synchronous ``on_event(evt, ctx)`` (``evt`` is the wire dict, as
4
+ the stream sends it) and, optionally, ``on_book(ticker, book, ctx)``, ``on_fill(fill, ctx)``,
5
+ ``on_start(ctx)`` and ``on_end(ctx)``: the sim's protocol (:mod:`langbot.sim`). ``ctx`` is the
6
+ same surface everywhere: ``now``, ``tickers``, ``book(ticker)``, ``position(ticker)``,
7
+ ``orders(ticker)``, ``buy(ticker, side, qty, limit=None, tif=None)``, ``sell(...)``,
8
+ ``cancel_all(ticker)``.
9
+
10
+ :func:`adapt` wraps such an object as a :class:`langbot.strategy.Strategy` so the live runner and
11
+ the backtester drive it unchanged: orders a hook places are sent, through the risk rails, as soon
12
+ as the hook returns. The older async ``Strategy`` subclass protocol keeps working, with a
13
+ deprecation warning (:func:`warn_legacy`).
14
+
15
+ Mapping onto the YES book the brokers trade: buying No at ``p`` is offering Yes at ``1 - p``;
16
+ selling No is bidding Yes at ``1 - p``. A market order (``limit=None``) is immediate-or-cancel at
17
+ the top of the book; a limit order rests unless ``tif="ioc"``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import warnings
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ from .schemas import Book, Event, Fill, new_id, now_ms
29
+ from .sim.engine import BookState
30
+ from .sim.engine import Position as SimPosition
31
+ from .strategy import Strategy
32
+
33
+ log = logging.getLogger("langbot.adapter")
34
+
35
+ LEGACY_MESSAGE = (
36
+ "{name}: the async Strategy subclass protocol is deprecated and will be removed in a later "
37
+ "release; write the sim protocol instead: a class with a synchronous on_event(evt, ctx) "
38
+ "(and optional on_book(ticker, book, ctx)), which `langbot run`, `langbot backtest` and "
39
+ "`langbot sim run` all drive. See docs/strategies.md."
40
+ )
41
+
42
+
43
+ def warn_legacy(cls: type) -> None:
44
+ """Warn once per class that it uses the old async ``Strategy`` protocol."""
45
+ if getattr(cls, "_langbot_legacy_warned", False):
46
+ return
47
+ cls._langbot_legacy_warned = True # type: ignore[attr-defined]
48
+ msg = LEGACY_MESSAGE.format(name=cls.__name__)
49
+ warnings.warn(msg, DeprecationWarning, stacklevel=3)
50
+ log.warning(msg)
51
+
52
+
53
+ @dataclass
54
+ class PendingOrder:
55
+ """What ``ctx.buy`` / ``ctx.sell`` return live: the order is sent when the hook returns."""
56
+
57
+ ticker: str
58
+ side: str # "yes" | "no"
59
+ action: str # "buy" | "sell"
60
+ qty: float
61
+ limit: float | None
62
+ tif: str | None
63
+ id: str = field(default_factory=lambda: new_id("ord"))
64
+ status: str = "pending"
65
+ order_id: str | None = None # the broker's id once sent
66
+
67
+ @property
68
+ def open(self) -> bool:
69
+ return self.status in ("pending", "resting")
70
+
71
+
72
+ class LiveContext:
73
+ """The sim's ``ctx`` over a live (or paper) broker."""
74
+
75
+ def __init__(self, host: SimStrategyAdapter) -> None:
76
+ self._host = host
77
+ self.queue: list[PendingOrder] = []
78
+ self.event_id: str | None = None
79
+ self._now: int | None = None
80
+ self.latency_ms = 0
81
+
82
+ @property
83
+ def now(self) -> int:
84
+ return self._now if self._now is not None else now_ms()
85
+
86
+ @property
87
+ def tickers(self) -> list[str]:
88
+ return sorted(self._host.known_tickers)
89
+
90
+ def market(self, ticker: str) -> dict[str, Any] | None:
91
+ return {"ticker": ticker, "title": None} if ticker in self._host.known_tickers else None
92
+
93
+ @property
94
+ def cash(self) -> float | None:
95
+ """Unknown live (the venue holds it); ``None``."""
96
+ return None
97
+
98
+ def book(self, ticker: str) -> BookState | None:
99
+ b = self._host.books.get(ticker)
100
+ return None if b is None else BookState.from_row(b.model_dump())
101
+
102
+ def position(self, ticker: str) -> SimPosition:
103
+ count = self._host.position(ticker).count
104
+ return SimPosition(ticker, yes=float(max(count, 0)), no=float(max(-count, 0)))
105
+
106
+ def orders(self, ticker: str | None = None) -> list[PendingOrder]:
107
+ return [o for o in self._host.sent if o.open and (ticker is None or o.ticker == ticker)]
108
+
109
+ def buy(
110
+ self,
111
+ ticker: str,
112
+ side: str = "yes",
113
+ qty: float = 1,
114
+ limit: float | None = None,
115
+ tif: str | None = None,
116
+ ) -> PendingOrder:
117
+ o = PendingOrder(ticker, side, "buy", qty, limit, tif)
118
+ self.queue.append(o)
119
+ return o
120
+
121
+ def sell(
122
+ self,
123
+ ticker: str,
124
+ side: str = "yes",
125
+ qty: float = 1,
126
+ limit: float | None = None,
127
+ tif: str | None = None,
128
+ ) -> PendingOrder:
129
+ o = PendingOrder(ticker, side, "sell", qty, limit, tif)
130
+ self.queue.append(o)
131
+ return o
132
+
133
+ def cancel(self, order_id: str) -> bool:
134
+ before = len(self.queue)
135
+ self.queue = [o for o in self.queue if o.id != order_id]
136
+ return len(self.queue) < before
137
+
138
+ def cancel_all(self, ticker: str | None = None) -> int:
139
+ self._host.cancel_requests.append(ticker)
140
+ return len(self.orders(ticker))
141
+
142
+
143
+ class SimStrategyAdapter(Strategy):
144
+ """A sim-protocol strategy object driven as a :class:`Strategy` (see :func:`adapt`)."""
145
+
146
+ factory: Callable[[], Any] = staticmethod(lambda: None) # type: ignore[assignment]
147
+
148
+ def __init__(self) -> None:
149
+ super().__init__()
150
+ self.inner: Any = type(self).factory()
151
+ self.name = getattr(self.inner, "name", None) or type(self.inner).__name__
152
+ self.markets = list(getattr(self.inner, "markets", None) or [])
153
+ self.phrases = list(getattr(self.inner, "phrases", None) or [])
154
+ self.known_tickers: set[str] = set()
155
+ self.sent: list[PendingOrder] = []
156
+ self.cancel_requests: list[str | None] = []
157
+ self.ctx = LiveContext(self)
158
+
159
+ def __getattr__(self, name: str) -> Any:
160
+ # Only called for attributes the adapter does not have: read them off the strategy.
161
+ if name.startswith("__") or name == "inner" or "inner" not in self.__dict__:
162
+ raise AttributeError(name)
163
+ return getattr(self.__dict__["inner"], name)
164
+
165
+ # -------------------------------------------------------------- hooks → sim protocol
166
+
167
+ async def on_start(self) -> None:
168
+ await self._call("on_start", self.ctx)
169
+
170
+ async def on_stop(self) -> None:
171
+ await self._call("on_end", self.ctx)
172
+
173
+ async def on_event(self, evt: Event) -> None:
174
+ wire = evt.to_wire()
175
+ self.ctx._now = evt.t_emitted or evt.t_spoken
176
+ self.ctx.event_id = evt.event_id
177
+ self.known_tickers |= {t for t in evt.market_refs if self.matches_market(t)}
178
+ await self._call("on_event", wire, self.ctx)
179
+
180
+ async def on_book(self, book: Book) -> None:
181
+ if self.matches_market(book.ticker):
182
+ self.known_tickers.add(book.ticker)
183
+ if getattr(self.inner, "on_book", None) is None:
184
+ return
185
+ self.ctx._now = book.ts
186
+ await self._call("on_book", book.ticker, BookState.from_row(book.model_dump()), self.ctx)
187
+
188
+ async def on_fill(self, fill: Fill) -> None:
189
+ for o in self.sent:
190
+ if o.order_id == fill.order_id and o.status == "resting":
191
+ o.status = "filled"
192
+ break
193
+ await self._call("on_fill", fill.model_dump(), self.ctx)
194
+
195
+ # ------------------------------------------------------------------ order flushing
196
+
197
+ async def _call(self, hook: str, *args: Any) -> None:
198
+ fn = getattr(self.inner, hook, None)
199
+ if fn is None:
200
+ return
201
+ fn(*args)
202
+ await self._flush()
203
+
204
+ async def _flush(self) -> None:
205
+ while self.cancel_requests:
206
+ await self.cancel_all(self.cancel_requests.pop(0))
207
+ for o in self.sent:
208
+ o.status = "canceled" if o.open else o.status
209
+ queue, self.ctx.queue = self.ctx.queue, []
210
+ for o in queue:
211
+ await self._send(o)
212
+
213
+ async def _send(self, o: PendingOrder) -> None:
214
+ book = self.books.get(o.ticker)
215
+ # (YES-book side, YES price) for each intent
216
+ yes_buy = (o.action == "buy") == (o.side == "yes")
217
+ if o.limit is not None:
218
+ price = o.limit if o.side == "yes" else round(1.0 - o.limit, 4)
219
+ elif book is not None:
220
+ price = book.yes_ask if yes_buy else book.yes_bid
221
+ else:
222
+ price = None
223
+ if price is None:
224
+ o.status = "rejected"
225
+ self.log(f"no price for {o.action} {o.side} {o.ticker}: no limit and no book")
226
+ return
227
+ tif = "immediate_or_cancel" if (o.limit is None or o.tif == "ioc") else "good_till_canceled"
228
+ count = int(round(o.qty))
229
+ order = await (self.buy_yes if yes_buy else self.sell_yes)(
230
+ o.ticker, price, count, time_in_force=tif
231
+ )
232
+ o.order_id = getattr(order, "order_id", None)
233
+ status = str(getattr(order, "status", "")).lower()
234
+ o.status = "resting" if status == "resting" else "done"
235
+ self.sent.append(o)
236
+
237
+
238
+ def _class_constants(cls: type) -> dict[str, Any]:
239
+ """The public, non-callable class attributes of ``cls`` and its bases (``markets``,
240
+ ``phrases``, thresholds ...), so the adapted class reads like the strategy it wraps."""
241
+ out: dict[str, Any] = {}
242
+ for klass in reversed(cls.__mro__[:-1]): # skip object
243
+ for k, v in vars(klass).items():
244
+ if (
245
+ k.startswith("_")
246
+ or callable(v)
247
+ or isinstance(v, (property, staticmethod, classmethod))
248
+ ):
249
+ continue
250
+ if k in _ADAPTER_OWN:
251
+ continue
252
+ out[k] = _FromInner(k, v)
253
+ return out
254
+
255
+
256
+ class _FromInner:
257
+ """A class constant of the wrapped strategy: the class value on the adapted class, the
258
+ wrapped instance's current value on an adapter instance."""
259
+
260
+ def __init__(self, key: str, default: Any) -> None:
261
+ self.key, self.default = key, default
262
+
263
+ def __get__(self, obj: Any, owner: type | None = None) -> Any:
264
+ if obj is None or "inner" not in obj.__dict__:
265
+ return self.default
266
+ return getattr(obj.__dict__["inner"], self.key, self.default)
267
+
268
+
269
+ def adapt(factory: Callable[[], Any], name: str | None = None) -> type[Strategy]:
270
+ """A :class:`Strategy` subclass that builds the sim-protocol strategy from ``factory`` and
271
+ drives it (the live runner and the backtester instantiate it with no arguments).
272
+
273
+ When ``factory`` is the strategy class itself, the adapted class takes its name and its
274
+ class constants (``markets``, ``phrases``, ``max_price`` ...); instances read any other
275
+ attribute (``ended``, a ``price_ceiling`` property) from the wrapped strategy."""
276
+ import inspect
277
+
278
+ attrs: dict[str, Any] = {"factory": staticmethod(factory), "__module__": __name__}
279
+ if inspect.isclass(factory):
280
+ cls_name = factory.__name__
281
+ attrs.update(_class_constants(factory))
282
+ attrs["__doc__"] = factory.__doc__
283
+ else:
284
+ cls_name = f"{name or getattr(factory, '__name__', None) or 'SimStrategy'}Live"
285
+ return type(cls_name, (SimStrategyAdapter,), attrs)
286
+
287
+
288
+ _ADAPTER_OWN = frozenset(
289
+ {"factory", "name", "inner", "ctx", "known_tickers", "sent", "cancel_requests"}
290
+ )
291
+
292
+
293
+ def is_sim_protocol(obj: Any) -> bool:
294
+ """True for a class or object whose ``on_event`` is synchronous (the sim protocol)."""
295
+ import inspect
296
+
297
+ if inspect.isclass(obj) and issubclass(obj, Strategy):
298
+ return False
299
+ fn = getattr(obj, "on_event", None)
300
+ return fn is not None and not inspect.iscoroutinefunction(fn)
301
+
302
+
303
+ __all__ = [
304
+ "LiveContext",
305
+ "PendingOrder",
306
+ "SimStrategyAdapter",
307
+ "adapt",
308
+ "is_sim_protocol",
309
+ "warn_legacy",
310
+ ]