Skip to content

Commit 3bc2d5a

Browse files
authored
exporter: Use market hours information in price filtering (#95)
* exporter: Use market hours information in price filtering * Rename MarketHours->WeeklySchedule, market_hours->weekly_schedule * market_hours: verify DST behavior using EU and US lag example * market_hours.rs: true positives where markets agree on market hours
1 parent 75497df commit 3bc2d5a

File tree

5 files changed

+247
-56
lines changed

5 files changed

+247
-56
lines changed

integration-tests/tests/test_integration.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"nasdaq_symbol": "AAPL",
7979
"symbol": "Equity.US.AAPL/USD",
8080
"base": "AAPL",
81+
"weekly_schedule": "America/New_York,C,C,C,C,C,C,C" # Should never be published due to all-closed market hours
8182
},
8283
"metadata": {"jump_id": "186", "jump_symbol": "AAPL", "price_exp": -5, "min_publishers": 1},
8384
}
@@ -732,3 +733,37 @@ async def test_agent_migrate_config(self,
732733
# Continue with the simple test case, which must succeed
733734
await self.test_update_price_simple(client_no_spawn)
734735
await client_no_spawn.close()
736+
737+
@pytest.mark.asyncio
738+
async def test_agent_respects_market_hours(self, client: PythAgentClient):
739+
'''
740+
Similar to test_update_price_simple, but using AAPL_USD and
741+
asserting that nothing is published due to the symbol's
742+
all-closed market hours.
743+
'''
744+
745+
# Fetch all products
746+
products = {product["attr_dict"]["symbol"]: product for product in await client.get_all_products()}
747+
748+
# Find the product account ID corresponding to the AAPL/USD symbol
749+
product = products[AAPL_USD["attr_dict"]["symbol"]]
750+
product_account = product["account"]
751+
752+
# Get the price account with which to send updates
753+
price_account = product["price_accounts"][0]["account"]
754+
755+
# Send an "update_price" request
756+
await client.update_price(price_account, 42, 2, "trading")
757+
time.sleep(2)
758+
759+
# Send another "update_price" request to trigger aggregation
760+
await client.update_price(price_account, 81, 1, "trading")
761+
time.sleep(2)
762+
763+
# Confirm that the price account has been updated with the values from the first "update_price" request
764+
final_product_state = await client.get_product(product_account)
765+
766+
final_price_account = final_product_state["price_accounts"][0]
767+
assert final_price_account["price"] == 0
768+
assert final_price_account["conf"] == 0
769+
assert final_price_account["status"] == "unknown"

src/agent/market_hours.rs

Lines changed: 133 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ use {
99
chrono::{
1010
naive::NaiveTime,
1111
DateTime,
12+
Datelike,
1213
Duration,
13-
TimeZone,
14+
Utc,
1415
Weekday,
1516
},
1617
chrono_tz::Tz,
@@ -27,8 +28,8 @@ lazy_static! {
2728
}
2829

2930
/// Weekly market hours schedule
30-
#[derive(Default, Debug, Eq, PartialEq)]
31-
pub struct MarketHours {
31+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
32+
pub struct WeeklySchedule {
3233
pub timezone: Tz,
3334
pub mon: MHKind,
3435
pub tue: MHKind,
@@ -39,7 +40,7 @@ pub struct MarketHours {
3940
pub sun: MHKind,
4041
}
4142

42-
impl MarketHours {
43+
impl WeeklySchedule {
4344
pub fn all_closed() -> Self {
4445
Self {
4546
timezone: Default::default(),
@@ -53,13 +54,11 @@ impl MarketHours {
5354
}
5455
}
5556

56-
pub fn can_publish_at<Tz: TimeZone>(&self, when: &DateTime<Tz>) -> Result<bool> {
57+
pub fn can_publish_at(&self, when: &DateTime<Utc>) -> bool {
5758
// Convert to time local to the market
5859
let when_market_local = when.with_timezone(&self.timezone);
5960

60-
// NOTE(2023-11-21): Strangely enough, I couldn't find a
61-
// method that gets the programmatic Weekday from a DateTime.
62-
let market_weekday: Weekday = when_market_local.format("%A").to_string().parse()?;
61+
let market_weekday: Weekday = when_market_local.date_naive().weekday();
6362

6463
let market_time = when_market_local.time();
6564

@@ -73,11 +72,11 @@ impl MarketHours {
7372
Weekday::Sun => self.sun.can_publish_at(market_time),
7473
};
7574

76-
Ok(ret)
75+
ret
7776
}
7877
}
7978

80-
impl FromStr for MarketHours {
79+
impl FromStr for WeeklySchedule {
8180
type Err = anyhow::Error;
8281
fn from_str(s: &str) -> Result<Self> {
8382
let mut split_by_commas = s.split(",");
@@ -163,7 +162,7 @@ impl FromStr for MarketHours {
163162
}
164163

165164
/// Helper enum for denoting per-day schedules: time range, all-day open and all-day closed.
166-
#[derive(Debug, Eq, PartialEq)]
165+
#[derive(Clone, Debug, Eq, PartialEq)]
167166
pub enum MHKind {
168167
Open,
169168
Closed,
@@ -236,9 +235,9 @@ mod tests {
236235
// Mon-Fri 9-5, inconsistent leading space on Tuesday, leading 0 on Friday (expected to be fine)
237236
let s = "Europe/Warsaw,9:00-17:00, 9:00-17:00,9:00-17:00,9:00-17:00,09:00-17:00,C,C";
238237

239-
let parsed: MarketHours = s.parse()?;
238+
let parsed: WeeklySchedule = s.parse()?;
240239

241-
let expected = MarketHours {
240+
let expected = WeeklySchedule {
242241
timezone: Tz::Europe__Warsaw,
243242
mon: MHKind::TimeRange(
244243
NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
@@ -274,7 +273,7 @@ mod tests {
274273
// Valid but missing a timezone
275274
let s = "O,C,O,C,O,C,O";
276275

277-
let parsing_result: Result<MarketHours> = s.parse();
276+
let parsing_result: Result<WeeklySchedule> = s.parse();
278277

279278
dbg!(&parsing_result);
280279
assert!(parsing_result.is_err());
@@ -285,7 +284,7 @@ mod tests {
285284
// One day short
286285
let s = "Asia/Hong_Kong,C,O,C,O,C,O";
287286

288-
let parsing_result: Result<MarketHours> = s.parse();
287+
let parsing_result: Result<WeeklySchedule> = s.parse();
289288

290289
dbg!(&parsing_result);
291290
assert!(parsing_result.is_err());
@@ -295,7 +294,7 @@ mod tests {
295294
fn test_parsing_gibberish_timezone_is_error() {
296295
// Pretty sure that one's extinct
297296
let s = "Pangea/New_Dino_City,O,O,O,O,O,O,O";
298-
let parsing_result: Result<MarketHours> = s.parse();
297+
let parsing_result: Result<WeeklySchedule> = s.parse();
299298

300299
dbg!(&parsing_result);
301300
assert!(parsing_result.is_err());
@@ -304,7 +303,7 @@ mod tests {
304303
#[test]
305304
fn test_parsing_gibberish_day_schedule_is_error() {
306305
let s = "Europe/Amsterdam,mondays are alright I guess,O,O,O,O,O,O";
307-
let parsing_result: Result<MarketHours> = s.parse();
306+
let parsing_result: Result<WeeklySchedule> = s.parse();
308307

309308
dbg!(&parsing_result);
310309
assert!(parsing_result.is_err());
@@ -314,7 +313,7 @@ mod tests {
314313
fn test_parsing_too_many_days_is_error() {
315314
// One day too many
316315
let s = "Europe/Lisbon,O,O,O,O,O,O,O,O,C";
317-
let parsing_result: Result<MarketHours> = s.parse();
316+
let parsing_result: Result<WeeklySchedule> = s.parse();
318317

319318
dbg!(&parsing_result);
320319
assert!(parsing_result.is_err());
@@ -323,7 +322,7 @@ mod tests {
323322
#[test]
324323
fn test_market_hours_happy_path() -> Result<()> {
325324
// Prepare a schedule of narrow ranges
326-
let mh: MarketHours = "America/New_York,00:00-1:00,1:00-2:00,2:00-3:00,3:00-4:00,4:00-5:00,5:00-6:00,6:00-7:00".parse()?;
325+
let wsched: WeeklySchedule = "America/New_York,00:00-1:00,1:00-2:00,2:00-3:00,3:00-4:00,4:00-5:00,5:00-6:00,6:00-7:00".parse()?;
327326

328327
// Prepare UTC datetimes that fall before, within and after market hours
329328
let format = "%Y-%m-%d %H:%M";
@@ -357,7 +356,7 @@ mod tests {
357356
NaiveDateTime::parse_from_str("2023-11-26 12:30", format)?.and_utc(),
358357
];
359358

360-
dbg!(&mh);
359+
dbg!(&wsched);
361360

362361
for ((before_dt, ok_dt), after_dt) in bad_datetimes_before
363362
.iter()
@@ -368,9 +367,9 @@ mod tests {
368367
dbg!(&ok_dt);
369368
dbg!(&after_dt);
370369

371-
assert!(!mh.can_publish_at(before_dt)?);
372-
assert!(mh.can_publish_at(ok_dt)?);
373-
assert!(!mh.can_publish_at(after_dt)?);
370+
assert!(!wsched.can_publish_at(before_dt));
371+
assert!(wsched.can_publish_at(ok_dt));
372+
assert!(!wsched.can_publish_at(after_dt));
374373
}
375374

376375
Ok(())
@@ -380,7 +379,8 @@ mod tests {
380379
#[test]
381380
fn test_market_hours_midnight_00_24() -> Result<()> {
382381
// Prepare a schedule of midnight-neighboring ranges
383-
let mh: MarketHours = "Europe/Amsterdam,23:00-24:00,00:00-01:00,O,C,C,C,C".parse()?;
382+
let wsched: WeeklySchedule =
383+
"Europe/Amsterdam,23:00-24:00,00:00-01:00,O,C,C,C,C".parse()?;
384384

385385
let format = "%Y-%m-%d %H:%M";
386386
let ok_datetimes = vec![
@@ -408,16 +408,122 @@ mod tests {
408408
.unwrap(),
409409
];
410410

411-
dbg!(&mh);
411+
dbg!(&wsched);
412412

413413
for (ok_dt, bad_dt) in ok_datetimes.iter().zip(bad_datetimes.iter()) {
414414
dbg!(&ok_dt);
415415
dbg!(&bad_dt);
416416

417-
assert!(mh.can_publish_at(ok_dt)?);
418-
assert!(!mh.can_publish_at(bad_dt)?);
417+
assert!(wsched.can_publish_at(&ok_dt.with_timezone(&Utc)));
418+
assert!(!wsched.can_publish_at(&bad_dt.with_timezone(&Utc)));
419419
}
420420

421421
Ok(())
422422
}
423+
424+
/// Performs a scenario on 2023 autumn DST change. During that
425+
/// time, most of the EU switched on the weekend one week earlier
426+
/// (Oct 28-29) than most of the US (Nov 4-5).
427+
#[test]
428+
fn test_market_hours_dst_shenanigans() -> Result<()> {
429+
// The Monday schedule is equivalent between Amsterdam and
430+
// Chicago for most of 2023 (7h difference), except for two
431+
// instances of Amsterdam/Chicago DST change lag:
432+
// * Spring 2023: Mar12(US)-Mar26(EU) (clocks go forward 1h,
433+
// CDT/CET 6h offset in use for 2 weeks, CDT/CEST 7h offset after)
434+
// * Autumn 2023: Oct29(EU)-Nov5(US) (clocks go back 1h,
435+
// CDT/CET 6h offset in use 1 week, CST/CET 7h offset after)
436+
let wsched_eu: WeeklySchedule = "Europe/Amsterdam,9:00-17:00,O,O,O,O,O,O".parse()?;
437+
let wsched_us: WeeklySchedule = "America/Chicago,2:00-10:00,O,O,O,O,O,O".parse()?;
438+
439+
let format = "%Y-%m-%d %H:%M";
440+
441+
// Monday after EU change, before US change, from Amsterdam
442+
// perspective. Okay for publishing Amsterdam market, outside hours for Chicago market
443+
let dt1 = NaiveDateTime::parse_from_str("2023-10-30 16:01", format)?
444+
.and_local_timezone(Tz::Europe__Amsterdam)
445+
.unwrap();
446+
dbg!(&dt1);
447+
448+
assert!(wsched_eu.can_publish_at(&dt1.with_timezone(&Utc)));
449+
assert!(!wsched_us.can_publish_at(&dt1.with_timezone(&Utc)));
450+
451+
// Same point in time, from Chicago perspective. Still okay
452+
// for Amsterdam, still outside hours for Chicago.
453+
let dt2 = NaiveDateTime::parse_from_str("2023-10-30 10:01", format)?
454+
.and_local_timezone(Tz::America__Chicago)
455+
.unwrap();
456+
dbg!(&dt2);
457+
458+
assert!(wsched_eu.can_publish_at(&dt2.with_timezone(&Utc)));
459+
assert!(!wsched_us.can_publish_at(&dt2.with_timezone(&Utc)));
460+
461+
assert_eq!(dt1, dt2);
462+
463+
// Monday after EU change, before US change, from Chicago
464+
// perspective. Okay for publishing Chicago market, outside
465+
// hours for publishing Amsterdam market.
466+
let dt3 = NaiveDateTime::parse_from_str("2023-10-30 02:01", format)?
467+
.and_local_timezone(Tz::America__Chicago)
468+
.unwrap();
469+
dbg!(&dt3);
470+
471+
assert!(!wsched_eu.can_publish_at(&dt3.with_timezone(&Utc)));
472+
assert!(wsched_us.can_publish_at(&dt3.with_timezone(&Utc)));
473+
474+
// Same point in time, from Amsterdam perspective. Still okay
475+
// for Chicago, still outside hours for Amsterdam.
476+
let dt4 = NaiveDateTime::parse_from_str("2023-10-30 08:01", format)?
477+
.and_local_timezone(Tz::Europe__Amsterdam)
478+
.unwrap();
479+
dbg!(&dt4);
480+
481+
assert!(!wsched_eu.can_publish_at(&dt4.with_timezone(&Utc)));
482+
assert!(wsched_us.can_publish_at(&dt4.with_timezone(&Utc)));
483+
484+
assert_eq!(dt3, dt4);
485+
486+
// Monday after both Amsterdam and Chicago get over their DST
487+
// change, from Amsterdam perspective. Okay for publishing
488+
// both markets.
489+
let dt5 = NaiveDateTime::parse_from_str("2023-11-06 09:01", format)?
490+
.and_local_timezone(Tz::Europe__Amsterdam)
491+
.unwrap();
492+
dbg!(&dt5);
493+
assert!(wsched_eu.can_publish_at(&dt5.with_timezone(&Utc)));
494+
assert!(wsched_us.can_publish_at(&dt5.with_timezone(&Utc)));
495+
496+
// Same point in time, from Chicago perspective
497+
let dt6 = NaiveDateTime::parse_from_str("2023-11-06 02:01", format)?
498+
.and_local_timezone(Tz::America__Chicago)
499+
.unwrap();
500+
dbg!(&dt6);
501+
assert!(wsched_eu.can_publish_at(&dt6.with_timezone(&Utc)));
502+
assert!(wsched_us.can_publish_at(&dt6.with_timezone(&Utc)));
503+
504+
assert_eq!(dt5, dt6);
505+
506+
// Monday after both Amsterdam and Chicago get over their DST
507+
// change, from Amsterdam perspective. Outside both markets'
508+
// hours.
509+
let dt7 = NaiveDateTime::parse_from_str("2023-11-06 17:01", format)?
510+
.and_local_timezone(Tz::Europe__Amsterdam)
511+
.unwrap();
512+
dbg!(&dt7);
513+
assert!(!wsched_eu.can_publish_at(&dt7.with_timezone(&Utc)));
514+
assert!(!wsched_us.can_publish_at(&dt7.with_timezone(&Utc)));
515+
516+
// Same point in time, from Chicago perspective, still outside
517+
// hours for both markets.
518+
let dt8 = NaiveDateTime::parse_from_str("2023-11-06 10:01", format)?
519+
.and_local_timezone(Tz::America__Chicago)
520+
.unwrap();
521+
dbg!(&dt8);
522+
assert!(!wsched_eu.can_publish_at(&dt8.with_timezone(&Utc)));
523+
assert!(!wsched_us.can_publish_at(&dt8.with_timezone(&Utc)));
524+
525+
assert_eq!(dt7, dt8);
526+
527+
Ok(())
528+
}
423529
}

src/agent/pythd/adapter.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -955,7 +955,7 @@ mod tests {
955955
)
956956
.unwrap(),
957957
solana::oracle::ProductEntry {
958-
account_data: pyth_sdk_solana::state::ProductAccount {
958+
account_data: pyth_sdk_solana::state::ProductAccount {
959959
magic: 0xa1b2c3d4,
960960
ver: 6,
961961
atype: 4,
@@ -992,7 +992,8 @@ mod tests {
992992
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
993993
],
994994
},
995-
price_accounts: vec![
995+
weekly_schedule: Default::default(),
996+
price_accounts: vec![
996997
solana_sdk::pubkey::Pubkey::from_str(
997998
"GVXRSBjFk6e6J3NbVPXohDJetcTjaeeuykUpbQF8UoMU",
998999
)
@@ -1014,7 +1015,7 @@ mod tests {
10141015
)
10151016
.unwrap(),
10161017
solana::oracle::ProductEntry {
1017-
account_data: pyth_sdk_solana::state::ProductAccount {
1018+
account_data: pyth_sdk_solana::state::ProductAccount {
10181019
magic: 0xa1b2c3d4,
10191020
ver: 5,
10201021
atype: 3,
@@ -1051,7 +1052,8 @@ mod tests {
10511052
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
10521053
],
10531054
},
1054-
price_accounts: vec![
1055+
weekly_schedule: Default::default(),
1056+
price_accounts: vec![
10551057
solana_sdk::pubkey::Pubkey::from_str(
10561058
"GG3FTE7xhc9Diy7dn9P6BWzoCrAEE4D3p5NBYrDAm5DD",
10571059
)

0 commit comments

Comments
 (0)