Status: ✅ IMPLEMENTED —
src/polyalpha/history/registry.py,view.py;BotHubsrc/polyalpha/bot_hub.py:12/src/polyalpha/bot_hub.py:820,Globalssrc/polyalpha/globals.py:51,Clientsrc/polyalpha/client.py:74,docs/history.md,examples/chainlink_history_shared.py. Companion:01-warmup-strat.md(single strat warmup gate). This plan adds the shared layer: one WS + one SQLite recorder serving many concurrent strats (BotHub / multi-Bot / external API).
Code refsfile:lineat time of writing.
01 solves “my EMA needs N closes” for one Bot. Real usage is BotHub with 20 strats on same asset/TF (src/polyalpha/bot_hub.py:1341 BotHub) or multiple Bot.run_async() collocated. Today each Bot/BotHub opens its own ChainlinkStreamer (src/polyalpha/bot.py:575, src/polyalpha/bot_hub.py:1438) and its own TimeWindow(120) (src/polyalpha/windows.py:22). Issues:
- N WS connections to
wss://ws-live-data.polymarket.com(src/polyalpha/analysis/streaming.py:82) — rate/ham, duplicated ticks. - N×120 s in-mem windows — still no 50-day history for any of them.
- If each strat owned its own recorder DB, N DBs or lock contention; if one per process, they stomp.
- External callers (notebooks, HTTP API,
DataFeed) can’t share the same warm history.
Need a singleton shared recorder + read API: one writer (WS → SQLite candles), many readers (ctx.chainlink_history in each StrategyContext, plus client.chainlink_history for ad-hoc queries).
One recorder, one WS, one SQLite file, N strats read concurrently:
hub = BotHub("BTC","5m", chainlink_history=shared_recorder_or_config)— allhub.strategysee same50×1d/20×1hhistoryclient.chainlink_history.candles("BTC","1d",50)— standalone API outside any bot- No N×WS:
Globals-style sharing (src/polyalpha/globals.py:360Globals.price_feed / Globals.defaults) but for history - Thread-safe, cross-process safe (SQLite WAL), survives restarts
- Not the single-strat warmup gate (that’s
01— this reuses it). - No new WebSocket protocol — still
crypto_prices_chainlinktopic (src/polyalpha/analysis/streaming.py:462). - No cross-asset aggregation v1 (one recorder per asset; multi-asset is N recorders or
Map[asset -> Recorder]).
import polyalpha
from polyalpha.history import ChainlinkRecorder, ChainlinkHistoryConfig
# One writer for the whole process — owns WS + SQLite
shared = ChainlinkRecorder(
db_path="~/.polyalpha/chainlink.db",
timeframes=("1m","1h","1d"),
retention={"1m": 30*86400, "1h": 90*86400, "1d": 400*86400},
)
shared.start("BTC", background=True) # one WS only
# All hub strats share it (no extra WS, no extra DB)
hub = polyalpha.BotHub("BTC", "5m",
chainlink_history=shared) # pass instance → shared, not copied
@hub.strategy("ema50_daily")
def ema50(ctx):
# ctx.chainlink_history is the SAME object as `shared` (plus per-strat view)
if not ctx.chainlink_history.is_ready("1d", 50):
return
if ctx.chainlink_history.close("1d") > ctx.chainlink_history.ema("BTC","1d",50):
ctx.buy("UP", 20)
@hub.strategy("rsi_hourly")
def rsi_h(ctx):
rsi = ctx.chainlink_history.rsi("BTC","1h",14)
...
hub.run() # hub reuses shared WS; recorder outlives hubhub = polyalpha.BotHub("BTC", "5m",
chainlink_history=ChainlinkHistoryConfig(
timeframes=("1m","1h","1d"),
warmup={"1d": 50, "1h": 20},
db_path="~/.polyalpha/chainlink.db",
block="wait", # hub waits once, then all strats start together
shared=True, # hint: reuse if globals exists
))
# hub internally does: recorder = ChainlinkRecorder.from_config(...); recorder.start()If Globals is used, even more explicit:
from polyalpha import Globals
from polyalpha.history import ChainlinkHistoryConfig
g = Globals.defaults(asset="BTC", price_feed=True) # existing shared feeds
g.chainlink_history = ChainlinkRecorder(
db_path="~/.polyalpha/chainlink.db",
timeframes=("1m","1h","1d"))
g.chainlink_history.start("BTC", background=True)
hub = polyalpha.BotHub("BTC","5m", globals=g) # hub reuses g.price_feed AND g.chainlink_history
client = polyalpha.Client(chainlink_history=g.chainlink_history) # same DB via APIimport polyalpha
client = polyalpha.Client(chainlink_history="~/.polyalpha/chainlink.db")
# or
from polyalpha.history import ChainlinkRecorder
rec = ChainlinkRecorder(db_path="~/.polyalpha/chainlink.db", read_only=True)
df = client.chainlink_history.candles("BTC", "1d", 50)
# open high low close count start_ts
# ... 67200 68100 66800 67850 4321 1717977600
print(client.chainlink_history.ema("BTC","1d",50))
print(client.chainlink_history.status()) # {"1m": 432/432, "1h": 20/20, "1d": 50/50}
# no WS needed for reads — pure SQLitectx.chainlink_history is a thin view:
ctx.chainlink_history.candles("1d", 50) # delegates to shared Store
ctx.chainlink_history.ema("BTC","1d",50)
ctx.chainlink_history.is_ready("1d",50)
ctx.chainlink_history.progress("1d",50) # 0.0–1.0
ctx.chainlink_history.status() # {"1d": 50/50, ...}
ctx.name # strat name available for loggingHub-level helpers:
hub.chainlink_history # same shared recorder
hub.on_warmup(lambda s: print(f"hub warmup {s}"))
hub.wait_until_ready({"1d": 50}, timeout=600) wss://ws-live-data.polymarket.com (one connection)
│
ChainlinkStreamer (streaming.py:100)
│ on("price") → _record_price (streaming.py:354)
▼
ChainlinkRecorder (SINGLETON, owns CandleBuilder + Store)
│ ingest(ts, price) → current[(asset,tf)] → on bucket close → Store.insert
│ SQLite WAL ~/.polyalpha/chainlink.db (candles, meta)
│
┌─────────────┼─────────────────┬──────────────────┐
│ │ │ │
BotHub.StrategyContext StrategyContext Client.chainlink_history notebook / API
ctx.chainlink_history ctx2... client.chainlink_history ChainlinkRecorder(read_only=True)
(bot_hub.py:882 (same Store) (client.py) (direct)
cl view) query only
│ │ │
└──── all call Store.get_candles → DataFrame → IndicatorCalculator.ema ─┘
Sharing invariants:
- One writer per (db_path, asset).
ChainlinkRecordertakes a file lock (Storebusy_timeout=5000,PRAGMA journal_mode=WAL). Second writer to samedb_path+asseteither (a) reuses existing singleton if in-process (registrydict[(db_path,asset) -> Recorder]), or (b) returnsReadOnlyStoreif cross-process. - Readers never block writer.
get_candlesisSELECT ... LIMIT nwithread_uncommitted=False; WAL allows concurrent reads. - In-process singleton registry
src/polyalpha/history/registry.py(tiny):get_or_create(db_path, asset, config)— ensuresBotHub+Clientin same process share oneChainlinkStreamerand oneStorehandle (mirrorsGlobalspatternsrc/polyalpha/globals.py:360).
Same candle model as 01 §6 (same candles table, same Store), but now:
Storeis the shared truth.CandleBuilderlives only in the writerRecorder. Readers have no builder, onlyStorequery.- Warmup gate is global:
hubwaits untilStore.count(tf) >= need[tf]once before fanning first tick to anyStrategyContext. Individual strats can still checkctx.chainlink_history.is_ready. BotHubintegration (vsBotin01):
BotHub.__init__(chainlink_history=shared_or_config, globals=g)
→ if isinstance(chainlink_history, ChainlinkRecorder): self._chainlink_history = chainlink_history (shared, do not own)
→ elif isinstance(chainlink_history, ChainlinkHistoryConfig): self._chainlink_history = Registry.get_or_create(config.db_path, asset, config) (own if new)
→ elif globals and hasattr(globals, "chainlink_history"): self._chainlink_history = globals.chainlink_history
→ else: no history
BotHub._discover() (bot_hub.py:1818) → assert asset matches recorder asset
BotHub._stream_prices() (bot_hub.py:1860) → before fan-out: if warmup and not ready → fire hub "warmup" event, optionally block
StrategyContext (bot_hub.py:789) gets chainlink_history view: StrategyContext(..., chainlink_history_view=View(shared_store, strat_name))
BotHub._cleanup() (bot_hub.py:2084) → only stop recorder if hub owns it (shared registry refcount → stop when last owner drops)
View is a 30-line wrapper exposing same candles/close/ema/sma/rsi/is_ready/status but capturing strat_name for logging.
Create (extends 01 files):
src/polyalpha/history/__init__.py # + exports Registry, View
src/polyalpha/history/candle.py # (from 01)
src/polyalpha/history/store.py # + ReadOnlyStore, registry helpers, concurrent tests
src/polyalpha/history/recorder.py # + singleton guard, attach_streamer(), read_only flag
src/polyalpha/history/registry.py # NEW: _REGISTRY: dict[tuple, Recorder], get_or_create(), release()
src/polyalpha/history/view.py # NEW: ChainlinkHistoryView(store, asset, strat_name) — reader facade
src/polyalpha/history/config.py # + shared: bool flag
Modify:
src/polyalpha/__init__.py:147— exportChainlinkRecorder,ChainlinkHistoryConfig,ChainlinkHistoryViewsrc/polyalpha/client.py:269—Client(chainlink_history: str|Path|Config|Recorder|bool)→self.chainlink_history: ChainlinkHistoryView|Recorder|None(read-only unlessClientis writer)src/polyalpha/globals.py:360— addchainlink_history: ChainlinkRecorder|NonetoGlobals, handlestart()/stop()likeprice_feedsrc/polyalpha/bot.py:503+src/polyalpha/bot_hub.py:1341— acceptchainlink_history, create or reuse via registry, inject intoTickContext/StrategyContextaschainlink_historyproperty (alongside existingchainlink:336/cl:369/cl:882)src/polyalpha/bot_hub.py:789—StrategyContextnew argchainlink_history: View|None, propertychainlink_history, keepchainlink/clunchanged for compat
No change to src/polyalpha/windows.py or src/polyalpha/analysis/streaming.py except optional recorder.attach(streamer) convenience.
All readers share same surface; View and Recorder implement same protocol:
class ChainlinkHistoryProto(Protocol):
def candles(self, asset: str, timeframe: str, limit: int) -> pd.DataFrame: ...
def close(self, timeframe: str, asset: str = "BTC") -> float | None: ...
def ema(self, asset: str, timeframe: str, period: int) -> float | None: ...
def sma(self, asset: str, timeframe: str, period: int) -> float | None: ...
def rsi(self, asset: str, timeframe: str, period: int) -> float | None: ...
def macd(self, asset: str, timeframe: str, fast=12, slow=26, signal=9) -> dict | None: ...
def bollinger_bands(self, asset: str, timeframe: str, period=20, std=2.0) -> dict | None: ...
def count(self, timeframe: str, asset: str = "BTC") -> int: ...
def is_ready(self, timeframe: str, need: int, asset: str = "BTC") -> bool: ...
def progress(self, timeframe: str, need: int) -> float: ... # 0.0-1.0
def status(self, need: dict[str,int] | None = None) -> dict[str, str]: ... # {"1d": "50/50 ✅"}
def age_s(self, timeframe: str) -> float: ...
def is_fresh(self, timeframe: str, max_age_s: float) -> bool: ...Implementation: SELECT open,high,low,close,count,start_ts FROM candles WHERE asset=? AND timeframe=? ORDER BY start_ts DESC LIMIT ? → reverse → DataFrame → IndicatorCalculator(df).ema(...) (src/polyalpha/analysis/indicators.py:138) → float|None if len < period (same None semantics as IndicatorAccessor.ema src/polyalpha/bot_hub.py:228).
Convenience need dict for multi-TF: is_ready_map({"1d":50,"1h":20}) -> bool, status(need) → {"1d":"12/50","1h":"20/20"}.
- SQLite WAL
PRAGMA journal_mode=WAL; synchronous=NORMAL; busy_timeout=5000(same assrc/polyalpha/database/connection.py). Writer doesINSERT OR REPLACEbatch every candle close (e.g. 1×/min for1m), not per-tick. Readers doSELECTnever blocking. - In-process sharing:
Registryrefcount.get_or_create(db_path, asset, config)— if exists, return sameRecorder(sameChainlinkStreamerviastreaming.py:180on("price")fan-out). Prevents N WS. - Cross-process: OS file lock not needed v1; SQLite WAL handles it. Second process opening same
db_pathas writer will getSQLITE_BUSYonINSERT; catch and downgrade toReadOnlyStorewithlog.warning("db busy — opening read-only, start writer in this process or use separate db"). - Hub fan-out safety:
BotHub._stream_priceson_price(src/polyalpha/bot_hub.py:1872) already fans to NStrategyContextwith try/except isolation — add warmup check before fan-out so one strat’sis_ready=Falsedoesn’t affect others. - Cleanup ownership:
Registry.release(recorder)decrements refcount; only last owner callsrecorder.stop()(flush + stopChainlinkStreamer). MirrorsGlobals.stop()pattern.
@dataclass
class ChainlinkHistoryConfig:
timeframes: tuple[str, ...] = ("1m","1h","1d")
warmup: dict[str,int] = field(default_factory=dict) # hub waits for this
db_path: str = "~/.polyalpha/chainlink.db"
block: Literal["wait","skip","call_with_flag"] = "wait"
block_timeout: float = 600.0
retention: dict[str,int] | None = None
persist_raw: bool = False
bootstrap: bool = False
shared: bool = True # NEW: try to reuse registry/Globals singleton
read_only: bool = False # for Client/notebook readersBotHub("BTC","5m", chainlink_history=ChainlinkHistoryConfig(warmup={"1d":50}, shared=True)) → registry lookup.
Client(chainlink_history=True) → ChainlinkRecorder(read_only=True, db_path=...) (no WS).
Globals addition:
@dataclass
class Globals:
price_feed: ChainlinkStreamer | None = None # existing
chainlink_history: ChainlinkRecorder | None = None # NEW
# start() also starts chainlink_history if present
# stop() also stops itEnv same as 01 plus POLYALPHA_CHAINLINK_SHARED=1.
- Mixed TF needs: strat A needs
50×1d, strat B needs10×1m— hub waits formaxof allwarmupdicts ifblock="wait"is union ({"1d":50,"1m":10}), then both start together. Ifblock="skip", each strat self-guards. - Asset mismatch:
hub asset="BTC"+recorder asset="ETH"→ValueErroratBotHub.__init__/_discover. - Lag: if WS stalls (
ChainlinkStreamer._check_stale_datasrc/polyalpha/analysis/streaming.py:544>30 s), next candle’scountdrops → readers can filterdf[df.count >= 0.8*expected]if they care. - Hot reload: hub
strategyadded at runtime gets samechainlink_historyview immediately (already warm). - Notebook + hub same DB: notebook opens
read_only=True→ no lock fight.
- Reuse
01unit tests + new:tests/unit/history/test_registry.py— samedb_path+assetreturns same object, refcount, seconddb_pathreturns different recorder, cross-process simulation (twoStorehandles same file → reader sees writer’s commits).tests/unit/history/test_view.py—Viewdelegates toStore,emamatches directIndicatorCalculator,is_ready/statusmulti-TF.tests/unit/bots/test_shared_history.py— mockHubFeed(src/polyalpha/bots/hub_feed.py) + sharedRecorderwith synthetic ticks: hub with 3 strats shares one recorder (assertRecorder.ingestcalled once per tick, not N×), strat A goes live after10×1mwhile strat B withskiplogs warmup.tests/unit/history/test_concurrency.py— 10 threadsget_candleswhile writeringest-loops; noSQLITE_BUSYleak, WAL verified.- Manual:
examples/chainlink_history_shared.py(hub 3 strats + standalone client read).
docs/history.md— single doc for both plans: § Warmup (01) + § Shared (02) with hub diagram,Registry/Globalspattern, “one writer, N readers” rule,read_onlynote.- Update
docs/bot.md— addBotHub(chainlink_history=...)+ctx.chainlink_historyalongsidectx.cl(src/polyalpha/bot.py:369) /ctx.chainlink(src/polyalpha/bot.py:336). - Update
docs/architecture.md— addhistorybox to component diagram, point tosrc/polyalpha/history/. examples/chainlink_history_shared.py:
import polyalpha
from polyalpha.history import ChainlinkRecorder
shared = ChainlinkRecorder(db_path="~/.polyalpha/chainlink.db",
timeframes=("1m","1h","1d"))
shared.start("BTC", background=True)
hub = polyalpha.BotHub("BTC", "5m", chainlink_history=shared)
@hub.strategy("daily_ema")
def daily(ctx):
ema = ctx.chainlink_history.ema("BTC","1d",50)
if ema and ctx.chainlink_history.close("1d") > ema:
ctx.buy("UP", 10)
@hub.strategy("hourly_rsi")
def hourly(ctx):
if ctx.chainlink_history.rsi("BTC","1h",14) and ctx.chainlink_history.rsi("BTC","1h",14) < 30:
ctx.buy("DOWN", 10)
# standalone read while hub runs
client = polyalpha.Client(chainlink_history=shared)
print(client.chainlink_history.status({"1d":50,"1h":20}))
hub.run()- Also
examples/chainlink_history_api.py— pure API read-only demo (no bot).
- Registry + View —
registry.py+view.py, makeStoreWAL-concurrent, addread_onlyflag. - Hub/Globals/Client wiring —
BotHub+Globals+ClientacceptChainlinkRecorder|Config, injectStrategyContext.chainlink_history(src/polyalpha/bot_hub.py:789), hub-levelwarmupunion +Registrysingleton. - Polish — hub
on_warmupevent,status(progress)pretty table (reusecomparison.pystyle),exportfor history, docs, shared example, concurrency tests.
No breaking change: 01’s single-bot API still works; 02 is additive (pass same recorder to N consumers).
- Should hub
block="wait"wait for union of all strats’warmupdicts (all start together) or per-strat staggered start? Proposed: union wait once, then all start. Globals.chainlink_historyowned byGlobals.start()/stop()likeprice_feed— or shouldBotHubown it? Proposed: both — ifglobalsprovides it, hub reuses; otherwise hub owns via registry.- Cross-process writer conflict UX: silent downgrade to read-only + warning, or raise? Proposed: warning + read-only.