Skip to content

Commit cbb5dd5

Browse files
authored
feat(hip-3-pusher): Mark price support (#3206)
1 parent 93a3ce9 commit cbb5dd5

File tree

10 files changed

+94
-27
lines changed

10 files changed

+94
-27
lines changed

apps/hip-3-pusher/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "hip-3-pusher"
3-
version = "0.2.0"
3+
version = "0.2.1"
44
description = "Hyperliquid HIP-3 market oracle pusher"
55
readme = "README.md"
66
requires-python = "==3.13.*"

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class SedaConfig(BaseModel):
5656
poll_interval: float
5757
poll_failure_interval: float
5858
poll_timeout: float
59-
feeds: dict[str, SedaFeedConfig]
59+
feeds: Optional[dict[str, SedaFeedConfig]] = {}
6060

6161

6262
class PriceSource(BaseModel):
@@ -81,7 +81,12 @@ class ConstantSourceConfig(BaseModel):
8181
value: str
8282

8383

84-
PriceSourceConfig = SingleSourceConfig | PairSourceConfig | ConstantSourceConfig
84+
class OracleMidAverageConfig(BaseModel):
85+
source_type: Literal["oracle_mid_average"]
86+
symbol: str
87+
88+
89+
PriceSourceConfig = SingleSourceConfig | PairSourceConfig | ConstantSourceConfig | OracleMidAverageConfig
8590

8691

8792
class PriceConfig(BaseModel):

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ def get_subscribe_request(self):
3030
}
3131

3232
async def subscribe_all(self):
33+
if not self.feed_ids:
34+
logger.info("No Hermes subscriptions needed")
35+
return
36+
3337
await asyncio.gather(*(self.subscribe_single(url) for url in self.hermes_urls))
3438

3539
@retry(

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

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ class HyperliquidListener:
2020
Subscribe to any relevant Hyperliquid websocket streams
2121
See https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket
2222
"""
23-
def __init__(self, config: Config, hl_oracle_state: PriceSourceState, hl_mark_state: PriceSourceState):
23+
def __init__(self, config: Config, hl_oracle_state: PriceSourceState, hl_mark_state: PriceSourceState, hl_mid_state: PriceSourceState):
24+
self.market_name = config.hyperliquid.market_name
2425
self.hyperliquid_ws_urls = config.hyperliquid.hyperliquid_ws_urls
2526
self.asset_context_symbols = config.hyperliquid.asset_context_symbols
2627
self.hl_oracle_state = hl_oracle_state
2728
self.hl_mark_state = hl_mark_state
29+
self.hl_mid_state = hl_mid_state
2830

2931
def get_subscribe_request(self, asset):
3032
return {
@@ -50,6 +52,13 @@ async def subscribe_single_inner(self, url):
5052
await ws.send(json.dumps(subscribe_request))
5153
logger.info("Sent subscribe request for symbol: {} to {}", symbol, url)
5254

55+
subscribe_all_mids_request = {
56+
"method": "subscribe",
57+
"subscription": {"type": "allMids", "dex": self.market_name}
58+
}
59+
await ws.send(json.dumps(subscribe_all_mids_request))
60+
logger.info("Sent subscribe request for allMids for dex: {} to {}", self.market_name, url)
61+
5362
# listen for updates
5463
while True:
5564
try:
@@ -63,7 +72,9 @@ async def subscribe_single_inner(self, url):
6372
elif channel == "error":
6473
logger.error("Received Hyperliquid error response: {}", data)
6574
elif channel == "activeAssetCtx":
66-
self.parse_hyperliquid_ws_message(data)
75+
self.parse_hyperliquid_active_asset_ctx_update(data)
76+
elif channel == "allMids":
77+
self.parse_hyperliquid_all_mids_update(data)
6778
else:
6879
logger.error("Received unknown channel: {}", channel)
6980
except asyncio.TimeoutError:
@@ -75,13 +86,23 @@ async def subscribe_single_inner(self, url):
7586
except Exception as e:
7687
logger.error("Unexpected exception: {}", e)
7788

78-
def parse_hyperliquid_ws_message(self, message):
89+
def parse_hyperliquid_active_asset_ctx_update(self, message):
7990
try:
8091
ctx = message["data"]["ctx"]
8192
symbol = message["data"]["coin"]
8293
now = time.time()
8394
self.hl_oracle_state.put(symbol, PriceUpdate(ctx["oraclePx"], now))
8495
self.hl_mark_state.put(symbol, PriceUpdate(ctx["markPx"], now))
85-
logger.debug("on_activeAssetCtx: oraclePx: {} marketPx: {}", ctx["oraclePx"], ctx["markPx"])
96+
logger.debug("activeAssetCtx symbol: {} oraclePx: {} markPx: {}", symbol, ctx["oraclePx"], ctx["markPx"])
97+
except Exception as e:
98+
logger.error("parse_hyperliquid_active_asset_ctx_update error: message: {} e: {}", message, e)
99+
100+
def parse_hyperliquid_all_mids_update(self, message):
101+
try:
102+
mids = message["data"]["mids"]
103+
now = time.time()
104+
for mid in mids:
105+
self.hl_mid_state.put(mid, PriceUpdate(mids[mid], now))
106+
logger.debug("allMids: {}", mids)
86107
except Exception as e:
87-
logger.error("parse_hyperliquid_ws_message error: message: {} e: {}", message, e)
108+
logger.error("parse_hyperliquid_all_mids_update error: message: {} e: {}", message, e)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ def get_subscribe_request(self, subscription_id: int):
3434
}
3535

3636
async def subscribe_all(self):
37+
if not self.feed_ids:
38+
logger.info("No Lazer subscriptions needed")
39+
return
40+
3741
await asyncio.gather(*(self.subscribe_single(router_url) for router_url in self.lazer_urls))
3842

3943
@retry(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async def main():
4646
metrics = Metrics(config)
4747

4848
publisher = Publisher(config, price_state, metrics)
49-
hyperliquid_listener = HyperliquidListener(config, price_state.hl_oracle_state, price_state.hl_mark_state)
49+
hyperliquid_listener = HyperliquidListener(config, price_state.hl_oracle_state, price_state.hl_mark_state, price_state.hl_mid_state)
5050
lazer_listener = LazerListener(config, price_state.lazer_state)
5151
hermes_listener = HermesListener(config, price_state.hermes_state)
5252
seda_listener = SedaListener(config, price_state.seda_state)

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

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import time
44

55
from pusher.config import Config, PriceSource, PriceSourceConfig, ConstantSourceConfig, SingleSourceConfig, \
6-
PairSourceConfig
6+
PairSourceConfig, OracleMidAverageConfig
77

88
DEFAULT_STALE_PRICE_THRESHOLD_SECONDS = 5
99

@@ -17,6 +17,13 @@ def time_diff(self, now):
1717
return now - self.timestamp
1818

1919

20+
@dataclass
21+
class OracleUpdate:
22+
oracle: dict[str, str]
23+
mark: dict[str, str]
24+
external: dict[str, str]
25+
26+
2027
class PriceSourceState:
2128
def __init__(self, name: str):
2229
self.name = name
@@ -35,6 +42,7 @@ def put(self, symbol: str, value: PriceUpdate):
3542
class PriceState:
3643
HL_ORACLE = "hl_oracle"
3744
HL_MARK = "hl_mark"
45+
HL_MID = "hl_mid"
3846
LAZER = "lazer"
3947
HERMES = "hermes"
4048
SEDA = "seda"
@@ -43,50 +51,56 @@ class PriceState:
4351
Maintain latest prices seen across listeners and publisher.
4452
"""
4553
def __init__(self, config: Config):
54+
self.market_name = config.hyperliquid.market_name
4655
self.stale_price_threshold_seconds = config.stale_price_threshold_seconds
4756
self.price_config = config.price
4857

4958
self.hl_oracle_state = PriceSourceState(self.HL_ORACLE)
5059
self.hl_mark_state = PriceSourceState(self.HL_MARK)
60+
self.hl_mid_state = PriceSourceState(self.HL_MID)
5161
self.lazer_state = PriceSourceState(self.LAZER)
5262
self.hermes_state = PriceSourceState(self.HERMES)
5363
self.seda_state = PriceSourceState(self.SEDA)
5464

5565
self.all_states = {
5666
self.HL_ORACLE: self.hl_oracle_state,
5767
self.HL_MARK: self.hl_mark_state,
68+
self.HL_MID: self.hl_mid_state,
5869
self.LAZER: self.lazer_state,
5970
self.HERMES: self.hermes_state,
6071
self.SEDA: self.seda_state,
6172
}
6273

63-
def get_all_prices(self, market_name):
74+
def get_all_prices(self) -> OracleUpdate:
6475
logger.debug("get_all_prices state: {}", self.all_states)
6576

66-
return (
67-
self.get_prices(self.price_config.oracle, market_name),
68-
self.get_prices(self.price_config.mark, market_name),
69-
self.get_prices(self.price_config.external, market_name)
70-
)
77+
oracle_update = OracleUpdate({}, {}, {})
78+
oracle_update.oracle = self.get_prices(self.price_config.oracle, oracle_update)
79+
oracle_update.mark = self.get_prices(self.price_config.mark, oracle_update)
80+
oracle_update.external = self.get_prices(self.price_config.external, oracle_update)
7181

72-
def get_prices(self, symbol_configs: dict[str, list[PriceSourceConfig]], market_name: str):
82+
return oracle_update
83+
84+
def get_prices(self, symbol_configs: dict[str, list[PriceSourceConfig]], oracle_update: OracleUpdate):
7385
pxs = {}
7486
for symbol in symbol_configs:
7587
for source_config in symbol_configs[symbol]:
7688
# find first valid price in the waterfall
77-
px = self.get_price(source_config)
89+
px = self.get_price(source_config, oracle_update)
7890
if px is not None:
79-
pxs[f"{market_name}:{symbol}"] = px
91+
pxs[f"{self.market_name}:{symbol}"] = str(px)
8092
break
8193
return pxs
8294

83-
def get_price(self, price_source_config: PriceSourceConfig):
95+
def get_price(self, price_source_config: PriceSourceConfig, oracle_update: OracleUpdate):
8496
if isinstance(price_source_config, ConstantSourceConfig):
8597
return price_source_config.value
8698
elif isinstance(price_source_config, SingleSourceConfig):
8799
return self.get_price_from_single_source(price_source_config.source)
88100
elif isinstance(price_source_config, PairSourceConfig):
89101
return self.get_price_from_pair_source(price_source_config.base_source, price_source_config.quote_source)
102+
elif isinstance(price_source_config, OracleMidAverageConfig):
103+
return self.get_price_from_oracle_mid_average(price_source_config.symbol, oracle_update)
90104
else:
91105
raise ValueError
92106

@@ -115,3 +129,19 @@ def get_price_from_pair_source(self, base_source: PriceSource, quote_source: Pri
115129
return None
116130

117131
return str(round(float(base_price) / float(quote_price), 2))
132+
133+
def get_price_from_oracle_mid_average(self, symbol: str, oracle_update: OracleUpdate):
134+
oracle_price = oracle_update.oracle.get(symbol)
135+
if oracle_price is None:
136+
return None
137+
138+
mid_price_update: PriceUpdate | None = self.hl_mid_state.get(symbol)
139+
if mid_price_update is None:
140+
logger.warning("mid price for {} is missing", symbol)
141+
return None
142+
time_diff = mid_price_update.time_diff(time.time())
143+
if time_diff >= self.stale_price_threshold_seconds:
144+
logger.warning("mid price for {} is stale by {} seconds", symbol, time_diff)
145+
return None
146+
147+
return (float(oracle_price) + float(mid_price_update.price)) / 2.0

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,10 @@ async def run(self):
6969
logger.exception("Publisher.publish() exception: {}", repr(e))
7070

7171
def publish(self):
72-
oracle_pxs, mark_pxs, external_perp_pxs = self.price_state.get_all_prices(self.market_name)
73-
logger.debug("oracle_pxs: {}", oracle_pxs)
74-
logger.debug("mark_pxs: {}", mark_pxs)
75-
logger.debug("external_perp_pxs: {}", external_perp_pxs)
72+
oracle_update = self.price_state.get_all_prices()
73+
logger.debug("oracle_update: {}", oracle_update)
7674

75+
oracle_pxs, mark_pxs, external_perp_pxs = oracle_update.oracle, oracle_update.mark, oracle_update.external
7776
if not oracle_pxs:
7877
logger.error("No valid oracle prices available")
7978
self.metrics.no_oracle_price_counter.add(1, self.metrics_labels)
@@ -130,13 +129,13 @@ def _send_update(self, oracle_pxs, all_mark_pxs, external_perp_pxs):
130129
raise PushError("all push endpoints failed")
131130

132131
def _handle_response(self, response):
133-
logger.debug("publish: push response: {} {}", response, type(response))
132+
logger.debug("oracle update response: {}", response)
134133
status = response.get("status")
135134
if status == "ok":
136135
self.metrics.successful_push_counter.add(1, self.metrics_labels)
137136
elif status == "err":
138137
self.metrics.failed_push_counter.add(1, self.metrics_labels)
139-
logger.error("publish: publish error: {}", response)
138+
logger.error("oracle update error response: {}", response)
140139

141140
def _record_push_interval_metric(self):
142141
now = time.time()

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ def __init__(self, config: Config, seda_state: PriceSourceState):
2525
self.seda_state = seda_state
2626

2727
async def run(self):
28+
if not self.feeds:
29+
logger.info("No SEDA feeds needed")
30+
return
31+
2832
await asyncio.gather(*[self._run_single(feed_name, self.feeds[feed_name]) for feed_name in self.feeds])
2933

3034
async def _run_single(self, feed_name: str, feed_config: SedaFeedConfig) -> None:

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)