Skip to content

Commit 19c5063

Browse files
authored
feat: add PublisherStalledCheck (#68)
* add PublisherStalledCheck * update sample.config.yaml with per symbol config sample * update comment
1 parent c125d78 commit 19c5063

File tree

4 files changed

+101
-2
lines changed

4 files changed

+101
-2
lines changed

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ ignore_missing_imports = true
44

55
[tool.poetry]
66
name = "pyth-observer"
7-
version = "0.2.4"
7+
version = "0.2.5"
88
description = "Alerts and stuff"
99
authors = []
1010
readme = "README.md"

pyth_observer/check/publisher.py

+42
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import time
12
from dataclasses import dataclass
23
from typing import Dict, Protocol, runtime_checkable
34

@@ -6,6 +7,8 @@
67

78
PUBLISHER_EXCLUSION_DISTANCE = 25
89

10+
PUBLISHER_CACHE = {}
11+
912

1013
@dataclass
1114
class PublisherState:
@@ -216,9 +219,48 @@ def ci_adjusted_price_diff(self) -> float:
216219
return max(price_only_diff - self.__state.confidence_interval, 0)
217220

218221

222+
class PublisherStalledCheck(PublisherCheck):
223+
def __init__(self, state: PublisherState, config: PublisherCheckConfig):
224+
self.__state = state
225+
self.__stall_time_limit: int = int(
226+
config["stall_time_limit"]
227+
) # Time in seconds
228+
229+
def state(self) -> PublisherState:
230+
return self.__state
231+
232+
def run(self) -> bool:
233+
publisher_key = (self.__state.publisher_name, self.__state.symbol)
234+
current_time = time.time()
235+
previous_price, last_change_time = PUBLISHER_CACHE.get(
236+
publisher_key, (None, None)
237+
)
238+
239+
if previous_price is None or self.__state.price != previous_price:
240+
PUBLISHER_CACHE[publisher_key] = (self.__state.price, current_time)
241+
return True
242+
243+
if (current_time - last_change_time) > self.__stall_time_limit:
244+
return False
245+
246+
return True
247+
248+
def error_message(self) -> dict:
249+
return {
250+
"msg": f"{self.__state.publisher_name} has been publishing the same price for too long.",
251+
"type": "PublisherStalledCheck",
252+
"publisher": self.__state.publisher_name,
253+
"symbol": self.__state.symbol,
254+
"price": self.__state.price,
255+
"stall_duration": time.time()
256+
- PUBLISHER_CACHE[(self.__state.publisher_name, self.__state.symbol)][1],
257+
}
258+
259+
219260
PUBLISHER_CHECKS = [
220261
PublisherWithinAggregateConfidenceCheck,
221262
PublisherConfidenceIntervalCheck,
222263
PublisherOfflineCheck,
223264
PublisherPriceCheck,
265+
PublisherStalledCheck,
224266
]

sample.config.yaml

+6
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,16 @@ checks:
4444
enable: true
4545
max_slot_distance: 25
4646
max_aggregate_distance: 6
47+
PublisherStalledCheck:
48+
enable: true
49+
stall_time_limit: 60
4750
# Per-symbol config
4851
Crypto.MNGO/USD:
4952
PriceFeedOfflineCheck:
5053
max_slot_distance: 10000
5154
FX.USD/HKD:
5255
PriceFeedOfflineCheck:
5356
max_slot_distance: 10000
57+
Crypto.BTC/USD:
58+
PublisherStalledCheck:
59+
stall_time_limit: 10 # This will override the global stall_time_limit for Crypto.BTC/USD

tests/test_checks_publisher.py

+52-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
import time
2+
from unittest.mock import patch
3+
14
from pythclient.pythaccounts import PythPriceStatus
25
from pythclient.solana import SolanaPublicKey
36

4-
from pyth_observer.check.publisher import PublisherPriceCheck, PublisherState
7+
from pyth_observer.check.publisher import (
8+
PUBLISHER_CACHE,
9+
PublisherPriceCheck,
10+
PublisherStalledCheck,
11+
PublisherState,
12+
)
513

614

715
def make_state(
@@ -44,3 +52,46 @@ def check_is_ok(
4452
state1 = make_state(1, 100.0, 2.0, 1, 110.0, 1.0)
4553
assert check_is_ok(state1, 10, 25)
4654
assert not check_is_ok(state1, 6, 25)
55+
56+
57+
def test_publisher_stalled_check():
58+
current_time = time.time()
59+
60+
def simulate_time_pass(seconds):
61+
nonlocal current_time
62+
current_time += seconds
63+
return current_time
64+
65+
def setup_check(state, stall_time_limit):
66+
check = PublisherStalledCheck(state, {"stall_time_limit": stall_time_limit})
67+
PUBLISHER_CACHE[(state.publisher_name, state.symbol)] = (
68+
state.price,
69+
current_time,
70+
)
71+
return check
72+
73+
def run_check(check, seconds, expected):
74+
with patch("time.time", new=lambda: simulate_time_pass(seconds)):
75+
assert check.run() == expected
76+
77+
PUBLISHER_CACHE.clear()
78+
state_a = make_state(1, 100.0, 2.0, 1, 100.0, 1.0)
79+
check_a = setup_check(state_a, 5)
80+
run_check(check_a, 5, True) # Should pass as it hits the limit exactly
81+
82+
PUBLISHER_CACHE.clear()
83+
state_b = make_state(1, 100.0, 2.0, 1, 100.0, 1.0)
84+
check_b = setup_check(state_b, 5)
85+
run_check(check_b, 6, False) # Should fail as it exceeds the limit
86+
87+
PUBLISHER_CACHE.clear()
88+
state_c = make_state(1, 100.0, 2.0, 1, 100.0, 1.0)
89+
check_c = setup_check(state_c, 5)
90+
run_check(check_c, 2, True) # Initial check should pass
91+
state_c.price = 105.0 # Change the price
92+
run_check(check_c, 3, True) # Should pass as price changes
93+
state_c.price = 100.0 # Change back to original price
94+
run_check(check_c, 4, True) # Should pass as price changes
95+
run_check(
96+
check_c, 8, False
97+
) # Should fail as price stalls for too long after last change

0 commit comments

Comments
 (0)