Skip to content

Commit fbc4418

Browse files
committed
fix: Update predict model
1 parent 7b9eee0 commit fbc4418

15 files changed

Lines changed: 3061 additions & 77 deletions

.env.example

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,19 @@ PAPER_TRADING=0
3131
MARKET_INTERVAL_SECONDS=300
3232
ASSET = btc
3333

34+
# Selective-bet gates (arb_bot_paper_predict.py)
35+
# Only trade when model fair prob, edge vs market, entry price, regime, and signals align.
36+
SELECTIVE_MIN_FAIR_PROB=0.53
37+
SELECTIVE_MIN_EDGE=0.12
38+
SELECTIVE_MAX_ENTRY_PRICE=0.40
39+
SELECTIVE_MIN_CONFIDENCE=53
40+
SELECTIVE_MIN_SIGNALS=2
41+
3442
CHAINLINK_BTC_FEED_ID=
43+
CHAINLINK_TWAP_30S_FEED_ID=
44+
CHAINLINK_TWAP_WINDOW_SECONDS=30
3545
CHAINLINK_STREAMS_API_KEY=
36-
CHAINLINK_STREAMS_API_SECRET=
46+
CHAINLINK_STREAMS_API_SECRET=
3747

3848

3949

ecosystem.config.cjs

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,23 @@
44
* Requires: cwd = src/ so `config`, `service`, `client` imports work.
55
* python-dotenv walks up from cwd to find `.env` at the repo root.
66
*
7-
* Usage:
7+
* Usage (run from the project root so the config file is found):
88
* mkdir -p logs
9-
* pm2 start ecosystem.config.cjs
10-
* pm2 start ecosystem.config.cjs --only pm-scalp
11-
* pm2 start ecosystem.config.cjs --only pm-scalp --env live # PAPER_TRADING=0
9+
* pm2 start ecosystem.config.cjs --only crypto-analytics # analytics watcher
10+
* pm2 start ecosystem.config.cjs --only arb-predict # paper bot + prediction model
11+
* pm2 logs arb-predict
1212
*
13-
* Logs: ./logs/pm-scalp-*.log
13+
* Logs: ./logs/<app>-out.log and ./logs/<app>-error.log
1414
*/
1515
const path = require("path");
1616

1717
const projectRoot = path.resolve(__dirname);
1818
const srcRoot = path.join(projectRoot, "src");
1919
const botScript = path.join(srcRoot, "script", "market_maker_arb.py");
20+
const analyticsScript = path.join(srcRoot, "script", "crypto_analytics.py");
21+
const predictBotScript = path.join(srcRoot, "script", "arb_bot_paper_predict.py");
22+
// Use the project venv interpreter so requests/websocket-client are available.
23+
const venvPython = path.join(projectRoot, "venv", "bin", "python");
2024

2125
module.exports = {
2226
apps: [
@@ -43,5 +47,43 @@ module.exports = {
4347
merge_logs: true,
4448
time: true,
4549
},
50+
{
51+
// Real-time Coinbase analytics + per-market UP/DOWN prediction logging.
52+
// pm2 start ecosystem.config.cjs --only crypto-analytics
53+
name: "crypto-analytics",
54+
script: analyticsScript,
55+
args: "--watch --no-clear --refresh 5",
56+
cwd: srcRoot,
57+
interpreter: venvPython,
58+
interpreter_args: "-u",
59+
instances: 1,
60+
autorestart: true,
61+
watch: false,
62+
max_restarts: 20,
63+
min_uptime: "15s",
64+
error_file: path.join(projectRoot, "logs", "crypto-analytics-error.log"),
65+
out_file: path.join(projectRoot, "logs", "crypto-analytics-out.log"),
66+
merge_logs: true,
67+
time: true,
68+
},
69+
{
70+
// Paper arb bot WITH the integrated prediction model (logs token +
71+
// score + confidence each market, then the real chainlink result).
72+
// pm2 start ecosystem.config.cjs --only arb-predict
73+
name: "arb-predict",
74+
script: predictBotScript,
75+
cwd: srcRoot,
76+
interpreter: venvPython,
77+
interpreter_args: "-u",
78+
instances: 1,
79+
autorestart: true,
80+
watch: false,
81+
max_restarts: 20,
82+
min_uptime: "15s",
83+
error_file: path.join(projectRoot, "logs", "arb-predict-error.log"),
84+
out_file: path.join(projectRoot, "logs", "arb-predict-out.log"),
85+
merge_logs: true,
86+
time: true,
87+
},
4688
],
4789
};

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ py-clob-client-v2>=1.0.0
1212
# WebSocket support
1313
websocket-client==1.6.4
1414
py-chainlink-streams>=0.3.4
15+
polymarket-client>=0.3.0
1516
# Date/time utilities
1617
python-dateutil>=2.8.2
1718
black==24.4.2

src/client/chainlink_client/chainlink_client.py

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,53 @@
3838
STREAM_RESTART_DELAY = 5
3939

4040

41+
def _decode_v2_report_data(report_blob: bytes) -> dict:
42+
"""Decode v2 report blob (TWAP feeds). py_chainlink_streams only supports v3."""
43+
from eth_abi import decode # pyright: ignore[reportMissingImports]
44+
45+
types = [
46+
"bytes32", # feedId
47+
"uint32", # validFromTimestamp
48+
"uint32", # observationsTimestamp
49+
"uint192", # nativeFee
50+
"uint192", # linkFee
51+
"uint32", # expiresAt
52+
"int192", # benchmarkPrice (TWAP)
53+
]
54+
decoded = decode(types, report_blob)
55+
return {
56+
"observationsTimestamp": decoded[2],
57+
"benchmarkPrice": decoded[6],
58+
}
59+
60+
61+
def _extract_report_price(report: "ReportResponse") -> tuple[float, Optional[int]]:
62+
"""Decode benchmark (v3) or TWAP (v2) price from a Chainlink report."""
63+
schema = ReportResponse.get_schema_version(report.feed_id)
64+
if schema == 3:
65+
prices = report.get_decoded_prices()
66+
obs_ts = prices.get("observationsTimestamp")
67+
return float(prices.get("benchmarkPrice", 0.0)), int(obs_ts) if obs_ts is not None else None
68+
if schema == 2:
69+
structure = ReportResponse._decode_report_structure(report.full_report)
70+
data = _decode_v2_report_data(structure["reportBlob"])
71+
price = ReportResponse.convert_fixed_point_to_decimal(data["benchmarkPrice"])
72+
return price, int(data.get("observationsTimestamp", report.observations_timestamp))
73+
raise ValueError(f"Unsupported Chainlink report schema v{schema} for feed {report.feed_id}")
74+
75+
4176
class ChainlinkClient:
42-
def __init__(self, feed_id: str, start_stream_thread: bool = True):
77+
def __init__(
78+
self,
79+
feed_id: str,
80+
start_stream_thread: bool = True,
81+
price_config_key: str = "CURRENT_PRICE",
82+
):
4383
self.streams_config = None
4484
self.client = None
4585
self.feed_id = feed_id
4686
self.feed_ids = [feed_id] if feed_id else []
87+
self.price_config_key = price_config_key
4788
self._price_history: deque = deque(maxlen=3000)
4889
self._last_price: Optional[float] = None
4990
self._last_update_time: Optional[int] = None
@@ -68,9 +109,20 @@ def __init__(self, feed_id: str, start_stream_thread: bool = True):
68109
else:
69110
logger.warning("Chainlink streams client unavailable; stream features are disabled.")
70111

71-
if start_stream_thread and self.client is not None:
72-
self.thread = threading.Thread(target=self._stream, daemon=True)
112+
if not self.feed_id:
113+
logger.warning(
114+
"Chainlink feed_id missing for %s; stream and historical fetch disabled.",
115+
self.price_config_key,
116+
)
117+
118+
if start_stream_thread and self.client is not None and self.feed_id:
119+
self.thread = threading.Thread(
120+
target=self._stream,
121+
daemon=True,
122+
name=f"chainlink-{self.price_config_key}",
123+
)
73124
self.thread.start()
125+
74126
def _price_at_ago_ms(self, history: List[Tuple[int, float]], now_ms: int, ago_ms: int) -> Optional[float]:
75127
"""Return price from history closest to (now_ms - ago_ms)."""
76128
target = now_ms - ago_ms
@@ -85,7 +137,6 @@ def _price_at_ago_ms(self, history: List[Tuple[int, float]], now_ms: int, ago_ms
85137
best_price = price
86138
return best_price
87139

88-
89140
@property
90141
def last_price(self) -> Optional[float]:
91142
"""Last received trade price, or None if no update yet."""
@@ -115,8 +166,8 @@ def get_price_at_timestamp(self, timestamp: int | float | str) -> float:
115166
logger.warning("Invalid timestamp for Chainlink report: %r", timestamp)
116167
return 0.0
117168
report = self.client.get_report(self.feed_id, ts_int)
118-
prices = report.get_decoded_prices()
119-
return float(prices.get("benchmarkPrice", 0.0))
169+
price, _obs_ts = _extract_report_price(report)
170+
return price
120171

121172
def _stream(self) -> None:
122173
if self.client is None:
@@ -142,14 +193,20 @@ def _stream(self) -> None:
142193

143194
def _on_connection_status(self, is_connected: bool, host: str, _origin: str) -> None:
144195
if is_connected:
145-
logger.info("Chainlink stream connected to %s", host)
196+
logger.info("Chainlink stream connected to %s (%s)", host, self.price_config_key)
146197
else:
147-
logger.warning("Chainlink stream disconnected from %s", host)
198+
logger.warning("Chainlink stream disconnected from %s (%s)", host, self.price_config_key)
148199

149200
async def _process_report(self, report_data: dict) -> None:
150201
if ReportResponse is None:
151202
return
152-
report = ReportResponse.from_dict(report_data)
153-
prices = report.get_decoded_prices()
154-
price = float(prices.get("benchmarkPrice", 0.0))
155-
config.CURRENT_PRICE = price
203+
try:
204+
report = ReportResponse.from_dict(report_data)
205+
price, obs_ts = _extract_report_price(report)
206+
except Exception:
207+
logger.exception("Failed to decode Chainlink report for %s", self.price_config_key)
208+
return
209+
self._last_price = price
210+
if obs_ts is not None:
211+
self._last_update_time = int(obs_ts) * 1000
212+
setattr(config, self.price_config_key, price)

src/client/coinbase_client/coinbase_client.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import sys
3333
import threading
3434
import time
35+
from collections import deque
3536
from pathlib import Path
3637
from typing import Any, Optional
3738

@@ -73,20 +74,29 @@ class CoinbaseClient:
7374
of that update so callers can detect staleness.
7475
"""
7576

76-
def __init__(self, debug: bool = False) -> None:
77+
def __init__(self, debug: bool = False, buffer_seconds: float = 0.0) -> None:
7778
"""
7879
Initialize CoinbaseClient.
7980
8081
Args:
8182
debug: If True, log status/error events received from the
8283
child process to stdout. Off by default to keep the
8384
trading logs uncluttered.
85+
buffer_seconds: If > 0, keep a rolling, thread-safe buffer of recent
86+
trades (price/size/side/ts) spanning this many seconds. This is
87+
what the prediction model reads from, so a single Coinbase WS
88+
feeds both the live price and the analytics — no second socket.
8489
"""
8590
asset = (os.getenv("ASSET") or "BTC").strip().upper() or "BTC"
8691
self.symbol = f"{asset}-USD"
8792

8893
self._debug = debug
8994

95+
# Optional rolling trade buffer (enabled when buffer_seconds > 0).
96+
self._buffer_seconds = float(buffer_seconds)
97+
self._trade_buffer: deque = deque()
98+
self._buffer_lock = threading.Lock()
99+
90100
# Child-process plumbing — created lazily in start().
91101
self._ws_proc: Optional[mp.Process] = None
92102
self._event_q: Optional[Any] = None # mp.Queue
@@ -190,6 +200,61 @@ def last_update_time(self) -> Optional[int]:
190200
"""Wall-clock ms timestamp of last price update, or None."""
191201
return self._last_update_time
192202

203+
# ------------------------------------------------------------------
204+
# Rolling trade buffer (read by the prediction model)
205+
# ------------------------------------------------------------------
206+
207+
def _append_trade(self, ts_s: float, price: float, size: Any, side: Any, tid: Any) -> None:
208+
"""Append one trade and prune to the retention window. Thread-safe."""
209+
try:
210+
size_f = float(size) if size is not None else 0.0
211+
except (TypeError, ValueError):
212+
size_f = 0.0
213+
rec = {
214+
"ts": ts_s,
215+
"price": price,
216+
"size": size_f,
217+
"side": str(side) if side is not None else "",
218+
"tid": int(tid) if isinstance(tid, (int, float)) else None,
219+
}
220+
cutoff = time.time() - self._buffer_seconds
221+
with self._buffer_lock:
222+
self._trade_buffer.append(rec)
223+
while self._trade_buffer and self._trade_buffer[0]["ts"] < cutoff:
224+
self._trade_buffer.popleft()
225+
226+
def get_trades(self, since: Optional[float] = None) -> list[dict]:
227+
"""Return a copy of buffered trades (oldest -> newest).
228+
229+
Each item is ``{"ts", "price", "size", "side", "tid"}`` with ``ts`` in
230+
epoch seconds. Pass ``since`` (epoch seconds) to filter to recent trades.
231+
Returns an empty list if buffering is disabled.
232+
"""
233+
with self._buffer_lock:
234+
trades = list(self._trade_buffer)
235+
if since is not None:
236+
trades = [t for t in trades if t["ts"] >= since]
237+
return trades
238+
239+
def seed_trades(self, trades: list[dict]) -> None:
240+
"""Merge externally-fetched (e.g. REST backfill) trades into the buffer,
241+
de-duplicating by ``tid`` and keeping the buffer time-sorted/pruned."""
242+
if not trades:
243+
return
244+
cutoff = time.time() - self._buffer_seconds
245+
with self._buffer_lock:
246+
seen = {t["tid"] for t in self._trade_buffer if t.get("tid") is not None}
247+
merged = list(self._trade_buffer)
248+
for t in trades:
249+
tid = t.get("tid")
250+
if tid is not None and tid in seen:
251+
continue
252+
if tid is not None:
253+
seen.add(tid)
254+
merged.append(t)
255+
merged.sort(key=lambda r: r["ts"])
256+
self._trade_buffer = deque(t for t in merged if t["ts"] >= cutoff)
257+
193258
# ------------------------------------------------------------------
194259
# REST helpers (unchanged behaviour)
195260
# ------------------------------------------------------------------
@@ -288,6 +353,9 @@ def _dispatch_loop(self) -> None:
288353
self._last_update_time = (
289354
int(ts_ms) if isinstance(ts_ms, (int, float)) else int(time.time() * 1000)
290355
)
356+
if self._buffer_seconds > 0:
357+
ts_s = (float(ts_ms) / 1000.0) if isinstance(ts_ms, (int, float)) else time.time()
358+
self._append_trade(ts_s, price_f, msg.get("size"), side, msg.get("trade_id"))
291359
continue
292360

293361
if etype == "_status":

src/client/coinbase_client/coinbase_ws_worker.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
1616
Wire protocol on ``event_q`` (child -> parent)
1717
----------------------------------------------
18-
- ``{"event_type": "match", "price": float, "ts_ms": int}`` per trade.
18+
- ``{"event_type": "match", "price": float, "side": str, "size": float,
19+
"trade_id": int|None, "ts_ms": int}`` per trade.
1920
- ``{"event_type": "_status", "connected": bool}`` on (dis)connect.
2021
- ``{"event_type": "_error", "error": str}`` on protocol/library errors.
2122
"""
@@ -133,9 +134,13 @@ def on_message(ws, message): # noqa: ARG001
133134
return
134135
try:
135136
# print("data", data)
136-
# size = float(data["last_size"])
137137
side = data["side"]
138138
price = float(data["price"])
139+
try:
140+
size = float(data.get("last_size") or 0.0)
141+
except (TypeError, ValueError):
142+
size = 0.0
143+
tid = data.get("trade_id")
139144
# best_bid = float(data["best_bid"])
140145
# best_ask = float(data["best_ask"])
141146
# best_bid_size = float(data["best_bid_size"])
@@ -149,6 +154,8 @@ def on_message(ws, message): # noqa: ARG001
149154
"event_type": "match",
150155
"price": price,
151156
"side": side,
157+
"size": size,
158+
"trade_id": tid,
152159
"ts_ms": int(time.time() * 1000),
153160
})
154161

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .polymarket_twap_client import PolymarketTwapClient
2+
3+
__all__ = ["PolymarketTwapClient"]

0 commit comments

Comments
 (0)