Skip to content

Commit 8754c47

Browse files
feat: add publish_interval logic (#121)
* feat: add publish_interval logic * refactor: rename PublisherPermissions to PricePublishingMetadata * fix: pre-commit * chore: bump pyth-agent version * feat: use NativeTime instead of unix timestamp to have more accurate price timestamps * refactor: use match to avoid unwrap
1 parent 3d35c1b commit 8754c47

File tree

10 files changed

+197
-74
lines changed

10 files changed

+197
-74
lines changed

Cargo.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.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pyth-agent"
3-
version = "2.6.2"
3+
version = "2.7.0"
44
edition = "2021"
55

66
[[bin]]

integration-tests/tests/test_integration.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,19 @@
8181
},
8282
"metadata": {"jump_id": "78876711", "jump_symbol": "SOLUSD", "price_exp": -8, "min_publishers": 1},
8383
}
84+
PYTH_USD = {
85+
"account": "",
86+
"attr_dict": {
87+
"symbol": "Crypto.PYTH/USD",
88+
"asset_type": "Crypto",
89+
"base": "PYTH",
90+
"quote_currency": "USD",
91+
"generic_symbol": "PYTHUSD",
92+
"description": "PYTH/USD",
93+
"publish_interval": "2",
94+
},
95+
"metadata": {"jump_id": "78876712", "jump_symbol": "PYTHUSD", "price_exp": -8, "min_publishers": 1},
96+
}
8497
AAPL_USD = {
8598
"account": "",
8699
"attr_dict": {
@@ -110,7 +123,7 @@
110123
},
111124
"metadata": {"jump_id": "78876710", "jump_symbol": "ETHUSD", "price_exp": -8, "min_publishers": 1},
112125
}
113-
ALL_PRODUCTS=[BTC_USD, AAPL_USD, ETH_USD, SOL_USD]
126+
ALL_PRODUCTS=[BTC_USD, AAPL_USD, ETH_USD, SOL_USD, PYTH_USD]
114127

115128
asyncio.set_event_loop(asyncio.new_event_loop())
116129

@@ -293,6 +306,7 @@ def refdata_permissions(self, refdata_path):
293306
"BTCUSD": {"price": ["some_publisher_b", "some_publisher_a"]}, # Reversed order helps ensure permission discovery works correctly for publisher A
294307
"ETHUSD": {"price": ["some_publisher_b"]},
295308
"SOLUSD": {"price": ["some_publisher_a"]},
309+
"PYTHUSD": {"price": ["some_publisher_a"]},
296310
}))
297311
f.flush()
298312
yield f.name
@@ -820,3 +834,53 @@ async def test_agent_respects_holiday_hours(self, client: PythAgentClient):
820834
assert final_price_account["price"] == 0
821835
assert final_price_account["conf"] == 0
822836
assert final_price_account["status"] == "unknown"
837+
838+
@pytest.mark.asyncio
839+
async def test_agent_respects_publish_interval(self, client: PythAgentClient):
840+
'''
841+
Similar to test_agent_respects_market_hours, but using PYTH_USD.
842+
This test asserts that consecutive price updates will only get published
843+
if it's after the specified publish interval.
844+
'''
845+
846+
# Fetch all products
847+
products = {product["attr_dict"]["symbol"]: product for product in await client.get_all_products()}
848+
849+
# Find the product account ID corresponding to the AAPL/USD symbol
850+
product = products[PYTH_USD["attr_dict"]["symbol"]]
851+
product_account = product["account"]
852+
853+
# Get the price account with which to send updates
854+
price_account = product["price_accounts"][0]["account"]
855+
856+
# Send an "update_price" request
857+
await client.update_price(price_account, 42, 2, "trading")
858+
time.sleep(1)
859+
860+
# Send another update_price request to "trigger" aggregation
861+
# (aggregation would happen if publish interval were to fail, but
862+
# we want to catch that happening if there's a problem)
863+
await client.update_price(price_account, 81, 1, "trading")
864+
time.sleep(2)
865+
866+
# Confirm that the price account has not been updated
867+
final_product_state = await client.get_product(product_account)
868+
869+
final_price_account = final_product_state["price_accounts"][0]
870+
assert final_price_account["price"] == 0
871+
assert final_price_account["conf"] == 0
872+
assert final_price_account["status"] == "unknown"
873+
874+
875+
# Send another update_price request to "trigger" aggregation
876+
# Now it is after the publish interval, so the price should be updated
877+
await client.update_price(price_account, 81, 1, "trading")
878+
time.sleep(2)
879+
880+
# Confirm that the price account has been updated
881+
final_product_state = await client.get_product(product_account)
882+
883+
final_price_account = final_product_state["price_accounts"][0]
884+
assert final_price_account["price"] == 42
885+
assert final_price_account["conf"] == 2
886+
assert final_price_account["status"] == "trading"

src/agent/dashboard.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,7 @@ impl MetricsServer {
102102
};
103103

104104
let last_local_update_string = if let Some(local_data) = price_data.local_data {
105-
if let Some(datetime) = DateTime::from_timestamp(local_data.timestamp, 0) {
106-
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
107-
} else {
108-
format!("Invalid timestamp {}", local_data.timestamp)
109-
}
105+
local_data.timestamp.format("%Y-%m-%d %H:%M:%S").to_string()
110106
} else {
111107
"no data".to_string()
112108
};

src/agent/metrics.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,7 @@ use {
4040
},
4141
warp::{
4242
hyper::StatusCode,
43-
reply::{
44-
self,
45-
},
43+
reply,
4644
Filter,
4745
Rejection,
4846
Reply,
@@ -428,7 +426,7 @@ impl PriceLocalMetrics {
428426
.get_or_create(&PriceLocalLabels {
429427
pubkey: price_key.to_string(),
430428
})
431-
.set(price_info.timestamp);
429+
.set(price_info.timestamp.and_utc().timestamp());
432430
update_count
433431
.get_or_create(&PriceLocalLabels {
434432
pubkey: price_key.to_string(),

src/agent/pythd/adapter.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ mod tests {
462462
)
463463
.unwrap(),
464464
solana::oracle::ProductEntry {
465-
account_data: pyth_sdk_solana::state::ProductAccount {
465+
account_data: pyth_sdk_solana::state::ProductAccount {
466466
magic: 0xa1b2c3d4,
467467
ver: 6,
468468
atype: 4,
@@ -499,8 +499,9 @@ mod tests {
499499
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
500500
],
501501
},
502-
schedule: Default::default(),
503-
price_accounts: vec![
502+
schedule: Default::default(),
503+
publish_interval: None,
504+
price_accounts: vec![
504505
solana_sdk::pubkey::Pubkey::from_str(
505506
"GVXRSBjFk6e6J3NbVPXohDJetcTjaeeuykUpbQF8UoMU",
506507
)
@@ -522,7 +523,7 @@ mod tests {
522523
)
523524
.unwrap(),
524525
solana::oracle::ProductEntry {
525-
account_data: pyth_sdk_solana::state::ProductAccount {
526+
account_data: pyth_sdk_solana::state::ProductAccount {
526527
magic: 0xa1b2c3d4,
527528
ver: 5,
528529
atype: 3,
@@ -559,8 +560,9 @@ mod tests {
559560
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
560561
],
561562
},
562-
schedule: Default::default(),
563-
price_accounts: vec![
563+
schedule: Default::default(),
564+
publish_interval: None,
565+
price_accounts: vec![
564566
solana_sdk::pubkey::Pubkey::from_str(
565567
"GG3FTE7xhc9Diy7dn9P6BWzoCrAEE4D3p5NBYrDAm5DD",
566568
)

src/agent/pythd/adapter/api.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ impl AdapterApi for Adapter {
375375
status: Adapter::map_status(&status)?,
376376
price,
377377
conf,
378-
timestamp: Utc::now().timestamp(),
378+
timestamp: Utc::now().naive_utc(),
379379
},
380380
})
381381
.await

src/agent/solana/exporter.rs

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,11 @@ use {
88
},
99
key_store,
1010
network::Network,
11+
oracle::PricePublishingMetadata,
1112
},
12-
crate::agent::{
13-
market_schedule::MarketSchedule,
14-
remote_keypair_loader::{
15-
KeypairRequest,
16-
RemoteKeypairLoader,
17-
},
13+
crate::agent::remote_keypair_loader::{
14+
KeypairRequest,
15+
RemoteKeypairLoader,
1816
},
1917
anyhow::{
2018
anyhow,
@@ -68,7 +66,6 @@ use {
6866
sync::{
6967
mpsc::{
7068
self,
71-
error::TryRecvError,
7269
Sender,
7370
},
7471
oneshot,
@@ -174,7 +171,9 @@ pub fn spawn_exporter(
174171
network: Network,
175172
rpc_url: &str,
176173
rpc_timeout: Duration,
177-
publisher_permissions_rx: watch::Receiver<HashMap<Pubkey, HashMap<Pubkey, MarketSchedule>>>,
174+
publisher_permissions_rx: watch::Receiver<
175+
HashMap<Pubkey, HashMap<Pubkey, PricePublishingMetadata>>,
176+
>,
178177
key_store: KeyStore,
179178
local_store_tx: Sender<store::local::Message>,
180179
global_store_tx: Sender<store::global::Lookup>,
@@ -262,10 +261,11 @@ pub struct Exporter {
262261
inflight_transactions_tx: Sender<Signature>,
263262

264263
/// publisher => { permissioned_price => market hours } as read by the oracle module
265-
publisher_permissions_rx: watch::Receiver<HashMap<Pubkey, HashMap<Pubkey, MarketSchedule>>>,
264+
publisher_permissions_rx:
265+
watch::Receiver<HashMap<Pubkey, HashMap<Pubkey, PricePublishingMetadata>>>,
266266

267267
/// Currently known permissioned prices of this publisher along with their market hours
268-
our_prices: HashMap<Pubkey, MarketSchedule>,
268+
our_prices: HashMap<Pubkey, PricePublishingMetadata>,
269269

270270
/// Interval to update the dynamic price (if enabled)
271271
dynamic_compute_unit_price_update_interval: Interval,
@@ -289,7 +289,9 @@ impl Exporter {
289289
global_store_tx: Sender<store::global::Lookup>,
290290
network_state_rx: watch::Receiver<NetworkState>,
291291
inflight_transactions_tx: Sender<Signature>,
292-
publisher_permissions_rx: watch::Receiver<HashMap<Pubkey, HashMap<Pubkey, MarketSchedule>>>,
292+
publisher_permissions_rx: watch::Receiver<
293+
HashMap<Pubkey, HashMap<Pubkey, PricePublishingMetadata>>,
294+
>,
293295
keypair_request_tx: mpsc::Sender<KeypairRequest>,
294296
logger: Logger,
295297
) -> Self {
@@ -432,22 +434,30 @@ impl Exporter {
432434
async fn get_permissioned_updates(&mut self) -> Result<Vec<(PriceIdentifier, PriceInfo)>> {
433435
let local_store_contents = self.fetch_local_store_contents().await?;
434436

435-
let now = Utc::now().timestamp();
437+
let publish_keypair = self.get_publish_keypair().await?;
438+
self.update_our_prices(&publish_keypair.pubkey());
439+
440+
let now = Utc::now().naive_utc();
441+
442+
debug!(self.logger, "Exporter: filtering prices permissioned to us";
443+
"our_prices" => format!("{:?}", self.our_prices.keys()),
444+
"publish_pubkey" => publish_keypair.pubkey().to_string(),
445+
);
436446

437447
// Filter the contents to only include information we haven't already sent,
438448
// and to ignore stale information.
439-
let fresh_updates = local_store_contents
449+
Ok(local_store_contents
440450
.into_iter()
441451
.filter(|(_identifier, info)| {
442452
// Filter out timestamps that are old
443-
(now - info.timestamp) < self.config.staleness_threshold.as_secs() as i64
453+
now < info.timestamp + self.config.staleness_threshold
444454
})
445455
.filter(|(identifier, info)| {
446456
// Filter out unchanged price data if the max delay wasn't reached
447457

448458
if let Some(last_info) = self.last_published_state.get(identifier) {
449-
if info.timestamp.saturating_sub(last_info.timestamp)
450-
> self.config.unchanged_publish_threshold.as_secs() as i64
459+
if info.timestamp
460+
> last_info.timestamp + self.config.unchanged_publish_threshold
451461
{
452462
true // max delay since last published state reached, we publish anyway
453463
} else {
@@ -457,33 +467,17 @@ impl Exporter {
457467
true // No prior data found, letting the price through
458468
}
459469
})
460-
.collect::<Vec<_>>();
461-
462-
let publish_keypair = self.get_publish_keypair().await?;
463-
464-
self.update_our_prices(&publish_keypair.pubkey());
465-
466-
debug!(self.logger, "Exporter: filtering prices permissioned to us";
467-
"our_prices" => format!("{:?}", self.our_prices.keys()),
468-
"publish_pubkey" => publish_keypair.pubkey().to_string(),
469-
);
470-
471-
// Get a fresh system time
472-
let now = Utc::now();
473-
474-
// Filter out price accounts we're not permissioned to update
475-
Ok(fresh_updates
476-
.into_iter()
477470
.filter(|(id, _data)| {
478471
let key_from_id = Pubkey::from((*id).clone().to_bytes());
479-
if let Some(schedule) = self.our_prices.get(&key_from_id) {
480-
let ret = schedule.can_publish_at(&now);
472+
if let Some(publisher_permission) = self.our_prices.get(&key_from_id) {
473+
let now_utc = Utc::now();
474+
let ret = publisher_permission.schedule.can_publish_at(&now_utc);
481475

482476
if !ret {
483477
debug!(self.logger, "Exporter: Attempted to publish price outside market hours";
484478
"price_account" => key_from_id.to_string(),
485-
"schedule" => format!("{:?}", schedule),
486-
"utc_time" => now.format("%c").to_string(),
479+
"schedule" => format!("{:?}", publisher_permission.schedule),
480+
"utc_time" => now_utc.format("%c").to_string(),
487481
);
488482
}
489483

@@ -501,6 +495,33 @@ impl Exporter {
501495
false
502496
}
503497
})
498+
.filter(|(id, info)| {
499+
// Filtering out prices that are being updated too frequently according to publisher_permission.publish_interval
500+
let last_info = match self.last_published_state.get(id) {
501+
Some(last_info) => last_info,
502+
None => {
503+
// No prior data found, letting the price through
504+
return true;
505+
}
506+
};
507+
508+
let key_from_id = Pubkey::from((*id).clone().to_bytes());
509+
let publisher_metadata = match self.our_prices.get(&key_from_id) {
510+
Some(metadata) => metadata,
511+
None => {
512+
// Should never happen since we have filtered out the price above
513+
return false;
514+
}
515+
};
516+
517+
if let Some(publish_interval) = publisher_metadata.publish_interval {
518+
if info.timestamp < last_info.timestamp + publish_interval {
519+
// Updating the price too soon after the last update, skipping
520+
return false;
521+
}
522+
}
523+
true
524+
})
504525
.collect::<Vec<_>>())
505526
}
506527

@@ -623,9 +644,9 @@ impl Exporter {
623644
let network_state = *self.network_state_rx.borrow();
624645
for (identifier, price_info_result) in refreshed_batch {
625646
let price_info = price_info_result?;
647+
let now = Utc::now().naive_utc();
626648

627-
let stale_price = (Utc::now().timestamp() - price_info.timestamp)
628-
> self.config.staleness_threshold.as_secs() as i64;
649+
let stale_price = now > price_info.timestamp + self.config.staleness_threshold;
629650
if stale_price {
630651
continue;
631652
}

0 commit comments

Comments
 (0)