Skip to content

Commit 4aa010c

Browse files
authored
Bug fix + improvement (#46)
* Bump version * Update calendar for 2023 * Bugfix + refactor * add a little comment * Make 25 constant var * Fix tests
1 parent 140ce22 commit 4aa010c

File tree

8 files changed

+141
-89
lines changed

8 files changed

+141
-89
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.1.1"
7+
version = "0.1.2"
88
description = "Alerts and stuff"
99
authors = []
1010
readme = "README.md"

pyth_observer/__init__.py

+17-3
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,20 @@ async def run(self):
100100
if not price_account.aggregate_price_info:
101101
raise RuntimeError("Aggregate price info is missing")
102102

103+
# When min_publishers is high it means that the price is not production-ready
104+
# yet and it is still being tested. We need no alerting for these prices.
105+
if price_account.min_publishers >= 10:
106+
continue
107+
103108
states.append(
104109
PriceFeedState(
105110
symbol=product.attrs["symbol"],
106111
asset_type=product.attrs["asset_type"],
107112
public_key=price_account.key,
108113
status=price_account.aggregate_price_status,
109-
slot_aggregate_attempted=price_account.valid_slot,
110-
slot_aggregate=price_account.aggregate_price_info.pub_slot,
114+
# this is the solana block slot when price account was fetched
115+
latest_block_slot=price_account.slot,
116+
latest_trading_slot=price_account.last_slot,
111117
price_aggregate=price_account.aggregate_price_info.price,
112118
confidence_interval_aggregate=price_account.aggregate_price_info.confidence_interval,
113119
coingecko_price=coingecko_prices.get(product.attrs["base"]),
@@ -119,17 +125,25 @@ async def run(self):
119125
)
120126

121127
for component in price_account.price_components:
128+
publisher_name = (
129+
self.publishers.get(component.publisher_key.key, "")
130+
+ f" ({component.publisher_key.key})"
131+
).strip()
122132
states.append(
123133
PublisherState(
134+
publisher_name=publisher_name,
124135
symbol=product.attrs["symbol"],
125136
public_key=component.publisher_key,
126137
confidence_interval=component.latest_price_info.confidence_interval,
127138
confidence_interval_aggregate=component.last_aggregate_price_info.confidence_interval,
128139
price=component.latest_price_info.price,
129140
price_aggregate=price_account.aggregate_price_info.price,
130141
slot=component.latest_price_info.pub_slot,
131-
slot_aggregate=component.last_aggregate_price_info.pub_slot,
142+
aggregate_slot=price_account.last_slot,
143+
# this is the solana block slot when price account was fetched
144+
latest_block_slot=price_account.slot,
132145
status=component.latest_price_info.price_status,
146+
aggregate_status=price_account.aggregate_price_status,
133147
)
134148
)
135149

pyth_observer/calendar.py

+16-10
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,24 @@
99
EQUITY_EARLY_CLOSE = datetime.time(13, 0, 0, tzinfo=TZ)
1010

1111
# EQUITY_HOLIDAYS and EQUITY_EARLY_HOLIDAYS will need to be updated each year
12+
# From https://www.nyse.com/markets/hours-calendars
1213
EQUITY_HOLIDAYS = [
13-
datetime.datetime(2022, 1, 17, tzinfo=TZ).date(),
14-
datetime.datetime(2022, 2, 21, tzinfo=TZ).date(),
15-
datetime.datetime(2022, 4, 15, tzinfo=TZ).date(),
16-
datetime.datetime(2022, 5, 30, tzinfo=TZ).date(),
17-
datetime.datetime(2022, 6, 20, tzinfo=TZ).date(),
18-
datetime.datetime(2022, 7, 4, tzinfo=TZ).date(),
19-
datetime.datetime(2022, 9, 5, tzinfo=TZ).date(),
20-
datetime.datetime(2022, 11, 24, tzinfo=TZ).date(),
21-
datetime.datetime(2022, 12, 26, tzinfo=TZ).date(),
14+
datetime.datetime(2023, 1, 2, tzinfo=TZ).date(),
15+
datetime.datetime(2023, 1, 16, tzinfo=TZ).date(),
16+
datetime.datetime(2023, 2, 20, tzinfo=TZ).date(),
17+
datetime.datetime(2023, 4, 7, tzinfo=TZ).date(),
18+
datetime.datetime(2023, 5, 29, tzinfo=TZ).date(),
19+
datetime.datetime(2023, 6, 19, tzinfo=TZ).date(),
20+
datetime.datetime(2023, 7, 4, tzinfo=TZ).date(),
21+
datetime.datetime(2022, 9, 4, tzinfo=TZ).date(),
22+
datetime.datetime(2023, 11, 23, tzinfo=TZ).date(),
23+
datetime.datetime(2023, 12, 25, tzinfo=TZ).date(),
24+
]
25+
26+
EQUITY_EARLY_HOLIDAYS = [
27+
datetime.datetime(2023, 7, 3, tzinfo=TZ).date(),
28+
datetime.datetime(2023, 11, 24, tzinfo=TZ).date(),
2229
]
23-
EQUITY_EARLY_HOLIDAYS = [datetime.datetime(2022, 11, 25, tzinfo=TZ).date()]
2430

2531

2632
class HolidayCalendar:

pyth_observer/check/price_feed.py

+19-12
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ class PriceFeedState:
1919
asset_type: str
2020
public_key: SolanaPublicKey
2121
status: PythPriceStatus
22-
slot_aggregate_attempted: int
23-
slot_aggregate: int
22+
latest_block_slot: int
23+
latest_trading_slot: int
2424
price_aggregate: float
2525
confidence_interval_aggregate: float
2626
coingecko_price: Optional[float]
@@ -46,10 +46,11 @@ def error_message(self) -> str:
4646
...
4747

4848

49-
class PriceFeedAggregateCheck(PriceFeedCheck):
49+
class PriceFeedOfflineCheck(PriceFeedCheck):
5050
def __init__(self, state: PriceFeedState, config: PriceFeedCheckConfig):
5151
self.__state = state
5252
self.__max_slot_distance: int = int(config["max_slot_distance"])
53+
self.__abandoned_slot_distance: int = int(config["abandoned_slot_distance"])
5354

5455
def state(self) -> PriceFeedState:
5556
return self.__state
@@ -64,28 +65,30 @@ def run(self) -> bool:
6465
if not is_market_open:
6566
return True
6667

67-
# Skip if not trading
68-
if self.__state.status != PythPriceStatus.TRADING:
69-
return True
70-
7168
distance = abs(
72-
self.__state.slot_aggregate_attempted - self.__state.slot_aggregate
69+
self.__state.latest_block_slot - self.__state.latest_trading_slot
7370
)
7471

7572
# Pass if distance is less than max slot distance
7673
if distance < self.__max_slot_distance:
7774
return True
7875

76+
# Pass if price has been stale for a long time
77+
if distance > self.__abandoned_slot_distance:
78+
return True
79+
7980
# Fail
8081
return False
8182

8283
def error_message(self) -> str:
84+
distance = self.__state.latest_block_slot - self.__state.latest_trading_slot
8385
return dedent(
8486
f"""
85-
{self.__state.symbol} is offline.
87+
{self.__state.symbol} is offline (either non-trading/stale).
88+
It is not updated for {distance} slots.
8689
87-
Valid aggregate slot: {self.__state.slot_aggregate}
88-
Attempted aggregate slot: {self.__state.slot_aggregate_attempted}
90+
Latest trading slot: {self.__state.latest_trading_slot}
91+
Block slot: {self.__state.latest_block_slot}
8992
"""
9093
).strip()
9194

@@ -174,6 +177,10 @@ def state(self) -> PriceFeedState:
174177
return self.__state
175178

176179
def run(self) -> bool:
180+
# Skip if not trading
181+
if self.__state.status != PythPriceStatus.TRADING:
182+
return True
183+
177184
# Skip if publish time is zero
178185
if not self.__state.crosschain_price["publish_time"]:
179186
return True
@@ -264,5 +271,5 @@ def error_message(self) -> str:
264271
PriceFeedCoinGeckoCheck,
265272
PriceFeedCrossChainDeviationCheck,
266273
PriceFeedCrossChainOnlineCheck,
267-
PriceFeedAggregateCheck,
274+
PriceFeedOfflineCheck,
268275
]

pyth_observer/check/publisher.py

+65-25
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@
55
from pythclient.pythaccounts import PythPriceStatus
66
from pythclient.solana import SolanaPublicKey
77

8+
PUBLISHER_EXCLUSION_DISTANCE = 25
9+
810

911
@dataclass
1012
class PublisherState:
13+
publisher_name: str
1114
symbol: str
1215
public_key: SolanaPublicKey
1316
status: PythPriceStatus
17+
aggregate_status: PythPriceStatus
1418
slot: int
15-
slot_aggregate: int
19+
aggregate_slot: int
20+
latest_block_slot: int
1621
price: float
1722
price_aggregate: float
1823
confidence_interval: float
@@ -33,11 +38,11 @@ def state(self) -> PublisherState:
3338
def run(self) -> bool:
3439
...
3540

36-
def error_message(self, publishers: Dict[str, str]) -> str:
41+
def error_message(self) -> str:
3742
...
3843

3944

40-
class PublisherAggregateCheck(PublisherCheck):
45+
class PublisherWithinAggregateConfidenceCheck(PublisherCheck):
4146
def __init__(self, state: PublisherState, config: PublisherCheckConfig):
4247
self.__state = state
4348
self.__max_interval_distance: int = int(config["max_interval_distance"])
@@ -46,10 +51,23 @@ def state(self) -> PublisherState:
4651
return self.__state
4752

4853
def run(self) -> bool:
54+
# Skip if not trading
55+
if self.__state.status != PythPriceStatus.TRADING:
56+
return True
57+
58+
# Skip if aggregate is not trading
59+
if self.__state.aggregate_status != PythPriceStatus.TRADING:
60+
return True
61+
4962
# Skip if confidence interval is zero
5063
if self.__state.confidence_interval == 0:
5164
return True
5265

66+
# Pass if publisher slot is far from aggregate slot
67+
distance = abs(self.__state.slot - self.__state.aggregate_slot)
68+
if distance > PUBLISHER_EXCLUSION_DISTANCE:
69+
return True
70+
5371
diff = self.__state.price - self.__state.price_aggregate
5472
intervals_away = abs(diff / self.__state.confidence_interval_aggregate)
5573

@@ -60,11 +78,16 @@ def run(self) -> bool:
6078
# Fail
6179
return False
6280

63-
def error_message(self, publishers) -> str:
81+
def error_message(self) -> str:
82+
diff = self.__state.price - self.__state.price_aggregate
83+
intervals_away = abs(diff / self.__state.confidence_interval_aggregate)
84+
6485
return dedent(
6586
f"""
66-
{publishers[self.__state.public_key.key]} price is too far from aggregate.
87+
{self.__state.publisher_name} price not within aggregate confidence.
88+
It is {intervals_away} times away from confidence.
6789
90+
Symbol: {self.__state.symbol}
6891
Publisher price: {self.__state.price} ± {self.__state.confidence_interval}
6992
Aggregate price: {self.__state.price_aggregate} ± {self.__state.confidence_interval_aggregate}
7093
"""
@@ -81,7 +104,12 @@ def state(self) -> PublisherState:
81104

82105
def run(self) -> bool:
83106
# Skip if not trading
84-
if not self.__state.status == PythPriceStatus.TRADING:
107+
if self.__state.status != PythPriceStatus.TRADING:
108+
return True
109+
110+
# Pass if publisher slot is far from aggregate slot
111+
distance = abs(self.__state.slot - self.__state.aggregate_slot)
112+
if distance > PUBLISHER_EXCLUSION_DISTANCE:
85113
return True
86114

87115
# Pass if confidence interval is greater than min_confidence_interval
@@ -91,11 +119,12 @@ def run(self) -> bool:
91119
# Fail
92120
return False
93121

94-
def error_message(self, publishers) -> str:
122+
def error_message(self) -> str:
95123
return dedent(
96124
f"""
97-
{publishers[self.__state.public_key.key]} confidence interval is too tight.
125+
{self.__state.publisher_name} confidence interval is too tight.
98126
127+
Symbol: {self.__state.symbol}
99128
Price: {self.__state.price}
100129
Confidence interval: {self.__state.confidence_interval}
101130
"""
@@ -106,27 +135,34 @@ class PublisherOfflineCheck(PublisherCheck):
106135
def __init__(self, state: PublisherState, config: PublisherCheckConfig):
107136
self.__state = state
108137
self.__max_slot_distance: int = int(config["max_slot_distance"])
138+
self.__abandoned_slot_distance: int = int(config["abandoned_slot_distance"])
109139

110140
def state(self) -> PublisherState:
111141
return self.__state
112142

113143
def run(self) -> bool:
114-
distance = abs(self.__state.slot - self.__state.slot_aggregate)
144+
distance = self.__state.latest_block_slot - self.__state.slot
115145

116146
# Pass if publisher slot is not too far from aggregate slot
117-
if distance < 25:
147+
if distance < self.__max_slot_distance:
148+
return True
149+
150+
# Pass if publisher has been inactive for a long time
151+
if distance > self.__abandoned_slot_distance:
118152
return True
119153

120154
# Fail
121-
return True
155+
return False
122156

123-
def error_message(self, publishers) -> str:
157+
def error_message(self) -> str:
158+
distance = self.__state.latest_block_slot - self.__state.slot
124159
return dedent(
125160
f"""
126-
{publishers[self.__state.public_key.key]} hasn't published recently.
161+
{self.__state.publisher_name} hasn't published recently for {distance} slots.
127162
163+
Symbol: {self.__state.symbol}
128164
Publisher slot: {self.__state.slot}
129-
Aggregate slot: {self.__state.slot_aggregate}
165+
Aggregate slot: {self.__state.aggregate_slot}
130166
"""
131167
).strip()
132168

@@ -141,47 +177,51 @@ def state(self) -> PublisherState:
141177
return self.__state
142178

143179
def run(self) -> bool:
144-
price_diff = abs(self.__state.price - self.__state.price_aggregate)
145-
slot_diff = abs(self.__state.slot - self.__state.slot_aggregate)
180+
# Skip if aggregate status is not trading
181+
if self.__state.aggregate_status != PythPriceStatus.TRADING:
182+
return True
146183

147184
# Skip if not trading
148185
if self.__state.status != PythPriceStatus.TRADING:
149186
return True
150187

151188
# Skip if publisher is too far behind
189+
slot_diff = abs(self.__state.slot - self.__state.aggregate_slot)
152190
if slot_diff > self.__max_slot_distance:
153191
return True
154192

155-
# Skip if no aggregate
156-
if self.__state.price_aggregate == 0:
193+
# Skip if published price is zero
194+
if self.__state.price == 0:
157195
return True
158196

159-
distance = (price_diff / self.__state.price_aggregate) * 100
197+
price_diff = abs(self.__state.price - self.__state.price_aggregate)
198+
deviation = (price_diff / self.__state.price_aggregate) * 100
160199

161200
# Pass if deviation is less than max distance
162-
if distance <= self.__max_aggregate_distance:
201+
if deviation <= self.__max_aggregate_distance:
163202
return True
164203

165204
# Fail
166205
return False
167206

168-
def error_message(self, publishers) -> str:
207+
def error_message(self) -> str:
169208
price_diff = abs(self.__state.price - self.__state.price_aggregate)
170-
distance = (price_diff / self.__state.price_aggregate) * 100
209+
deviation = (price_diff / self.__state.price_aggregate) * 100
171210

172211
return dedent(
173212
f"""
174-
{publishers[self.__state.public_key.key]} price is too far from aggregate.
213+
{self.__state.publisher_name} price is too far from aggregate price.
175214
215+
Symbol: {self.__state.symbol}
176216
Publisher price: {self.__state.price}
177217
Aggregate price: {self.__state.price_aggregate}
178-
Distance: {distance}%
218+
Deviation: {deviation}%
179219
"""
180220
).strip()
181221

182222

183223
PUBLISHER_CHECKS = [
184-
PublisherAggregateCheck,
224+
PublisherWithinAggregateConfidenceCheck,
185225
PublisherConfidenceIntervalCheck,
186226
PublisherOfflineCheck,
187227
PublisherPriceCheck,

0 commit comments

Comments
 (0)