Skip to content

Commit c6ead2c

Browse files
committed
feat(hip-3-pusher): Price fallback logic and staleness checks
1 parent fa07482 commit c6ead2c

File tree

9 files changed

+127
-36
lines changed

9 files changed

+127
-36
lines changed

apps/hip-3-pusher/config/config.toml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
stale_price_threshold_seconds = 5
2+
13
[hyperliquid]
24
market_name = ""
35
market_symbol = "BTC"
@@ -12,12 +14,16 @@ key_path = "/path/to/kms_key.txt"
1214
aws_region_name = "ap-northeast-1"
1315

1416
[lazer]
15-
router_urls = ["wss://pyth-lazer-0.dourolabs.app/v1/stream", "wss://pyth-lazer-1.dourolabs.app/v1/stream"]
17+
lazer_urls = ["wss://pyth-lazer-0.dourolabs.app/v1/stream", "wss://pyth-lazer-1.dourolabs.app/v1/stream"]
1618
api_key = "lazer_api_key"
1719
base_feed_id = 1 # BTC
20+
base_feed_exponent = -8
1821
quote_feed_id = 8 # USDT
22+
quote_feed_exponent = -8
1923

2024
[hermes]
21-
urls = ["wss://hermes.pyth.network/ws"]
22-
base_id = "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" # BTC
23-
quote_id = "2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b" # USDT
25+
hermes_urls = ["wss://hermes.pyth.network/ws"]
26+
base_feed_id = "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" # BTC
27+
base_feed_exponent = -8
28+
quote_feed_id = "2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b" # USDT
29+
quote_feed_exponent = -8

apps/hip-3-pusher/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "hip-3-pusher"
3-
version = "0.1.0"
4-
description = "Add your description here"
3+
version = "0.1.1"
4+
description = "Hyperliquid HIP-3 market oracle pusher"
55
readme = "README.md"
66
requires-python = ">=3.13"
77
dependencies = [

apps/hip-3-pusher/src/hermes_listener.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import json
33
from loguru import logger
4+
import time
45
import websockets
56

67
from price_state import PriceState
@@ -9,26 +10,25 @@
910
class HermesListener:
1011
"""
1112
Subscribe to Hermes price updates for needed feeds.
12-
TODO: Will need to handle specific conversions/factors and exponents.
1313
"""
1414
def __init__(self, config, price_state: PriceState):
15-
self.urls = config["hermes"]["urls"]
16-
self.base_id = config["hermes"]["base_id"]
17-
self.quote_id = config["hermes"]["quote_id"]
15+
self.hermes_urls = config["hermes"]["hermes_urls"]
16+
self.base_feed_id = config["hermes"]["base_feed_id"]
17+
self.quote_feed_id = config["hermes"]["quote_feed_id"]
1818
self.price_state = price_state
1919

2020
def get_subscribe_request(self):
2121
return {
2222
"type": "subscribe",
23-
"ids": [self.base_id, self.quote_id],
23+
"ids": [self.base_feed_id, self.quote_feed_id],
2424
"verbose": False,
2525
"binary": True,
2626
"allow_out_of_order": False,
2727
"ignore_invalid_price_ids": False,
2828
}
2929

3030
async def subscribe_all(self):
31-
await asyncio.gather(*(self.subscribe_single(url) for url in self.urls))
31+
await asyncio.gather(*(self.subscribe_single(url) for url in self.hermes_urls))
3232

3333
async def subscribe_single(self, url):
3434
while True:
@@ -71,9 +71,10 @@ def parse_hermes_message(self, data):
7171
expo = price_object["expo"]
7272
publish_time = price_object["publish_time"]
7373
logger.debug("Hermes update: {} {} {} {}", id, price, expo, publish_time)
74-
if id == self.base_id:
74+
if id == self.base_feed_id:
7575
self.price_state.hermes_base_price = price
76-
if id == self.quote_id:
76+
if id == self.quote_feed_id:
7777
self.price_state.hermes_quote_price = price
78+
self.price_state.latest_hermes_timestamp = time.time()
7879
except Exception as e:
7980
logger.error("parse_hermes_message error: {}", e)

apps/hip-3-pusher/src/hyperliquid_listener.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from loguru import logger
2+
import time
23

34
from hyperliquid.info import Info
45
from hyperliquid.utils.constants import TESTNET_API_URL, MAINNET_API_URL
@@ -28,6 +29,7 @@ def on_activeAssetCtx(self, message):
2829
:return: None
2930
"""
3031
ctx = message["data"]["ctx"]
31-
self.price_state.latest_oracle_price = ctx["oraclePx"]
32-
self.price_state.latest_mark_price = ctx["markPx"]
33-
logger.debug("on_activeAssetCtx: oraclePx: {} marketPx: {}", self.price_state.latest_oracle_price, self.price_state.latest_mark_price)
32+
self.price_state.hl_oracle_price = ctx["oraclePx"]
33+
self.price_state.hl_mark_price = ctx["markPx"]
34+
logger.debug("on_activeAssetCtx: oraclePx: {} marketPx: {}", self.price_state.hl_oracle_price, self.price_state.hl_mark_price)
35+
self.price_state.latest_hl_timestamp = time.time()

apps/hip-3-pusher/src/lazer_listener.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import json
33
from loguru import logger
4+
import time
45
import websockets
56

67
from price_state import PriceState
@@ -9,11 +10,10 @@
910
class LazerListener:
1011
"""
1112
Subscribe to Lazer price updates for needed feeds.
12-
TODO: Will need to handle specific conversions/factors and exponents.
1313
"""
1414
def __init__(self, config, price_state: PriceState):
15-
self.router_urls = config["lazer"]["router_urls"]
16-
self.api_key = config["lazer"]["api_key"]
15+
self.lazer_urls = config["lazer"]["lazer_urls"]
16+
self.api_key = config["lazer"]["lazer_api_key"]
1717
self.base_feed_id = config["lazer"]["base_feed_id"]
1818
self.quote_feed_id = config["lazer"]["quote_feed_id"]
1919
self.price_state = price_state
@@ -32,7 +32,7 @@ def get_subscribe_request(self, subscription_id: int):
3232
}
3333

3434
async def subscribe_all(self):
35-
await asyncio.gather(*(self.subscribe_single(router_url) for router_url in self.router_urls))
35+
await asyncio.gather(*(self.subscribe_single(router_url) for router_url in self.lazer_urls))
3636

3737
async def subscribe_single(self, router_url):
3838
while True:
@@ -52,7 +52,7 @@ async def subscribe_single_inner(self, router_url):
5252
subscribe_request = self.get_subscribe_request(1)
5353

5454
await ws.send(json.dumps(subscribe_request))
55-
logger.info("Sent Lazer subscribe request to {}", self.router_urls[0])
55+
logger.info("Sent Lazer subscribe request to {}", self.lazer_urls[0])
5656

5757
# listen for updates
5858
async for message in ws:
@@ -83,5 +83,6 @@ def parse_lazer_message(self, data):
8383
self.price_state.lazer_base_price = price
8484
if feed_id == self.quote_feed_id:
8585
self.price_state.lazer_quote_price = price
86+
self.price_state.latest_lazer_timestamp = time.time()
8687
except Exception as e:
8788
logger.error("parse_lazer_message error: {}", e)

apps/hip-3-pusher/src/main.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import argparse
22
import asyncio
33
from loguru import logger
4+
import os
5+
import sys
46
import toml
57

68
from hyperliquid_listener import HyperliquidListener
@@ -23,25 +25,36 @@ def load_config():
2325
return config
2426

2527

28+
def init_logging():
29+
logger.remove()
30+
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
31+
# serialize=True if we want json logging
32+
logger.add(sys.stderr, level=log_level, serialize=False)
33+
34+
2635
async def main():
27-
logger.info("Starting hip3-agent...")
36+
init_logging()
37+
logger.info("Starting hip-3-pusher...")
2838
config = load_config()
2939

30-
price_state = PriceState()
40+
price_state = PriceState(config)
3141
publisher = Publisher(config, price_state)
3242
hyperliquid_listener = HyperliquidListener(config, price_state)
3343
lazer_listener = LazerListener(config, price_state)
3444
hermes_listener = HermesListener(config, price_state)
3545

36-
# TODO: Probably pull this out of the sdk.
46+
# TODO: Probably pull this out of the sdk so we can handle reconnects.
3747
hyperliquid_listener.subscribe()
3848
await asyncio.gather(
3949
publisher.run(),
4050
lazer_listener.subscribe_all(),
4151
hermes_listener.subscribe_all(),
4252
)
43-
logger.info("Exiting hip3-agent...")
53+
logger.info("Exiting hip-3-pusher..")
4454

4555

4656
if __name__ == "__main__":
47-
asyncio.run(main())
57+
try:
58+
asyncio.run(main())
59+
except Exception as e:
60+
logger.exception("Uncaught exception, exiting: {}", e)
Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,73 @@
1+
from loguru import logger
2+
import time
3+
4+
DEFAULT_STALE_PRICE_THRESHOLD_SECONDS = 5
5+
6+
17
class PriceState:
28
"""
39
Maintain latest prices seen across listeners and publisher.
410
"""
5-
def __init__(self):
6-
self.latest_oracle_price = None
7-
self.latest_mark_price = None
11+
def __init__(self, config):
12+
self.stale_price_threshold_seconds = config.get("stale_price_threshold_seconds", DEFAULT_STALE_PRICE_THRESHOLD_SECONDS)
13+
now = time.time()
14+
15+
self.hl_oracle_price = None
16+
self.hl_mark_price = None
17+
self.latest_hl_timestamp = now
18+
819
self.lazer_base_price = None
20+
self.lazer_base_exponent = config["lazer"]["base_feed_exponent"]
921
self.lazer_quote_price = None
22+
self.lazer_quote_exponent = config["lazer"]["quote_feed_exponent"]
23+
self.latest_lazer_timestamp = now
24+
1025
self.hermes_base_price = None
26+
self.hermes_base_exponent = config["hermes"]["base_feed_exponent"]
1127
self.hermes_quote_price = None
28+
self.hermes_quote_exponent = config["hermes"]["quote_feed_exponent"]
29+
self.latest_hermes_timestamp = now
30+
31+
def get_current_oracle_price(self):
32+
now = time.time()
33+
if self.hl_oracle_price:
34+
time_diff = now - self.latest_hl_timestamp
35+
if time_diff < self.stale_price_threshold_seconds:
36+
return self.hl_oracle_price
37+
else:
38+
logger.error("Hyperliquid oracle price stale by {} seconds", time_diff)
39+
else:
40+
logger.error("Hyperliquid oracle price not received yet")
41+
42+
# fall back to Hermes
43+
if self.hermes_base_price and self.hermes_quote_price:
44+
time_diff = now - self.latest_hermes_timestamp
45+
if time_diff < self.stale_price_threshold_seconds:
46+
return self.get_hermes_price()
47+
else:
48+
logger.error("Hermes price stale by {} seconds", time_diff)
49+
else:
50+
logger.error("Hermes base/quote prices not received yet")
51+
52+
# fall back to Lazer
53+
if self.lazer_base_price and self.lazer_quote_price:
54+
time_diff = now - self.latest_lazer_timestamp
55+
if time_diff < self.stale_price_threshold_seconds:
56+
return self.get_lazer_price()
57+
else:
58+
logger.error("Lazer price stale by {} seconds", time_diff)
59+
else:
60+
logger.error("Lazer base/quote prices not received yet")
61+
62+
logger.error("All prices missing or stale!")
63+
return None
64+
65+
def get_hermes_price(self):
66+
base_price = float(self.hermes_base_price) / (10.0 ** -self.hermes_base_exponent)
67+
quote_price = float(self.hermes_quote_price) / (10.0 ** -self.hermes_quote_exponent)
68+
return str(round(base_price / quote_price, 2))
69+
70+
def get_lazer_price(self):
71+
base_price = float(self.lazer_base_price) / (10.0 ** -self.lazer_base_exponent)
72+
quote_price = float(self.lazer_quote_price) / (10.0 ** -self.lazer_quote_exponent)
73+
return str(round(base_price / quote_price, 2))

apps/hip-3-pusher/src/publisher.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,21 @@ def __init__(self, config: dict, price_state: PriceState):
4747
async def run(self):
4848
while True:
4949
await asyncio.sleep(self.publish_interval)
50-
logger.debug("publish price_state: {}", vars(self.price_state))
5150

5251
oracle_pxs = {}
52+
oracle_px = self.price_state.get_current_oracle_price()
53+
if not oracle_px:
54+
logger.error("No valid oracle price available!")
55+
return
56+
else:
57+
logger.debug("Current oracle price: {}", oracle_px)
58+
oracle_pxs[self.market_symbol] = oracle_px
59+
5360
mark_pxs = []
61+
#if self.price_state.hl_mark_price:
62+
# mark_pxs.append({self.market_symbol: self.price_state.hl_mark_price})
63+
5464
external_perp_pxs = {}
55-
if self.price_state.latest_oracle_price:
56-
oracle_pxs[self.market_symbol] = self.price_state.latest_oracle_price
57-
if self.price_state.latest_mark_price:
58-
mark_pxs.append({self.market_symbol: self.price_state.latest_mark_price})
5965

6066
if self.enable_publish:
6167
if self.enable_kms:

apps/hip-3-pusher/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)