ibapi 5.4.0

An unofficial Rust client for the Interactive Brokers TWS API: market data, orders, and account data, with async (Tokio) and blocking clients for TWS and IB Gateway.
Documentation
use std::collections::HashMap;

use time::macros::datetime;
use time::{OffsetDateTime, PrimitiveDateTime};
use time_tz::{timezones, TimeZone};

use super::{find_timezone, map_timezone_name_with, parse_env_aliases, register_timezone_alias, resolve_local};

fn resolved_name(name: &str) -> Option<&'static str> {
    find_timezone(name).map(|tz| tz.name())
}

#[test]
fn find_timezone_standard() {
    assert_eq!(resolved_name("America/New_York"), Some("America/New_York"));
    assert_eq!(resolved_name("US/Eastern"), Some("US/Eastern"));
    assert_eq!(resolved_name("Etc/GMT+4"), Some("Etc/GMT+4"));
}

#[test]
fn find_timezone_china_utf8() {
    assert_eq!(resolved_name("中国标准时间"), Some("Asia/Shanghai"));
    assert_eq!(resolved_name("北京时间"), Some("Asia/Shanghai"));
}

#[test]
fn find_timezone_china_english() {
    assert_eq!(resolved_name("China Standard Time"), Some("Asia/Shanghai"));
}

#[test]
fn find_timezone_gmt() {
    assert_eq!(resolved_name("Greenwich Mean Time"), Some("Europe/London"));
    assert_eq!(resolved_name("GMT Standard Time"), Some("Europe/London"));
    assert_eq!(resolved_name("British Summer Time"), Some("Europe/London"));
}

#[test]
fn find_timezone_mojibake() {
    // Simulate GB2312 decoded as UTF-8 lossy (contains replacement characters)
    let mojibake = "test\u{FFFD}\u{FFFD}zone";
    assert_eq!(resolved_name(mojibake), Some("Asia/Shanghai"));
}

#[test]
fn find_timezone_singapore() {
    assert_eq!(resolved_name("SGT"), Some("Asia/Singapore"));
}

#[test]
fn find_timezone_brazil() {
    // `E. South America Standard Time` is not in the alias table: time_tz maps
    // Windows registry ids itself.
    for name in ["BRT", "Brasilia Standard Time", "E. South America Standard Time"] {
        assert_eq!(resolved_name(name), Some("America/Sao_Paulo"), "wrong mapping for {name}");
    }
}

#[test]
fn find_timezone_european_continental() {
    let cases = [
        ("E. Europe Standard Time", "Europe/Bucharest"),
        ("Eastern European Standard Time", "Europe/Athens"),
        ("Eastern European Summer Time", "Europe/Athens"),
        ("FLE Standard Time", "Europe/Helsinki"),
        ("GTB Standard Time", "Europe/Athens"),
        ("Central European Standard Time", "Europe/Warsaw"),
        ("Central European Summer Time", "Europe/Warsaw"),
        ("W. Europe Standard Time", "Europe/Berlin"),
        ("Romance Standard Time", "Europe/Paris"),
    ];
    for (windows_name, expected_iana) in cases {
        assert_eq!(resolved_name(windows_name), Some(expected_iana), "wrong mapping for {windows_name}");
    }
}

#[test]
fn find_timezone_passthrough() {
    // Unknown timezone names pass through unchanged
    assert!(resolved_name("Unknown/Timezone").is_none());
}

#[test]
fn find_timezone_no_partial_match() {
    // time_tz's `find_by_name` is a substring match, which resolved these to
    // whichever containing zone hashed first (#809). Lookup is exact now.
    for name in ["GMT+1", "York", "Eastern", "BST", "JST", "IST"] {
        assert_eq!(resolved_name(name), None, "{name} must not resolve");
    }
    assert_eq!(resolved_name("GMT"), Some("GMT"));
    assert_eq!(resolved_name("GB"), Some("GB"));
}

#[test]
fn find_timezone_us_abbreviations() {
    // Both halves of each pair map to one DST-observing zone, so the zone a
    // client reports does not depend on the season it connected in.
    let cases = [
        ("EST", "America/New_York"),
        ("EDT", "America/New_York"),
        ("CST", "America/Chicago"),
        ("CDT", "America/Chicago"),
        ("MST", "America/Denver"),
        ("MDT", "America/Denver"),
        ("PST", "America/Los_Angeles"),
        ("PDT", "America/Los_Angeles"),
    ];
    for (abbreviation, expected_iana) in cases {
        assert_eq!(resolved_name(abbreviation), Some(expected_iana), "wrong mapping for {abbreviation}");
    }
}

#[test]
fn find_timezone_registry_value_is_exact() {
    // A registered alias resolves its target exactly too: a partial IANA name
    // is not completed to some zone that contains it.
    register_timezone_alias("__rust_ibapi_test_alias_partial", "New_York");
    assert_eq!(resolved_name("__rust_ibapi_test_alias_partial"), None);
}

#[test]
fn registry_overrides_builtin() {
    let mut reg = HashMap::new();
    reg.insert("China Standard Time".to_string(), "Europe/London".to_string());
    assert_eq!(map_timezone_name_with(&reg, "China Standard Time"), "Europe/London");
}

#[test]
fn registry_adds_new_alias() {
    let mut reg = HashMap::new();
    reg.insert("Made Up Time".to_string(), "Asia/Tokyo".to_string());
    assert_eq!(map_timezone_name_with(&reg, "Made Up Time"), "Asia/Tokyo");
}

#[test]
fn registry_falls_through_to_builtin() {
    let reg = HashMap::new();
    assert_eq!(map_timezone_name_with(&reg, "China Standard Time"), "Asia/Shanghai");
}

#[test]
fn registry_falls_through_to_mojibake() {
    let reg = HashMap::new();
    assert_eq!(map_timezone_name_with(&reg, "test\u{FFFD}\u{FFFD}"), "Asia/Shanghai");
}

#[test]
fn registry_passthrough_unknown() {
    let reg = HashMap::new();
    assert_eq!(map_timezone_name_with(&reg, "Some/Unknown"), "Some/Unknown");
}

#[test]
fn register_timezone_alias_smoke() {
    // Unique key avoids collision with other tests touching the registry.
    register_timezone_alias("__rust_ibapi_test_alias_xyz", "America/New_York");
    assert_eq!(resolved_name("__rust_ibapi_test_alias_xyz"), Some("America/New_York"));
}

#[test]
fn parse_env_aliases_basic() {
    let pairs = parse_env_aliases("Foo=Asia/Tokyo;Bar=Europe/Berlin");
    assert_eq!(
        pairs,
        vec![
            ("Foo".to_string(), "Asia/Tokyo".to_string()),
            ("Bar".to_string(), "Europe/Berlin".to_string()),
        ]
    );
}

#[test]
fn parse_env_aliases_skips_malformed() {
    let pairs = parse_env_aliases("Foo=Asia/Tokyo;garbage;Bar=Europe/Berlin");
    assert_eq!(
        pairs,
        vec![
            ("Foo".to_string(), "Asia/Tokyo".to_string()),
            ("Bar".to_string(), "Europe/Berlin".to_string()),
        ]
    );
}

#[test]
fn parse_env_aliases_trims_whitespace() {
    let pairs = parse_env_aliases(" Foo Standard Time = Asia/Tokyo ; Bar = Europe/Berlin ");
    assert_eq!(
        pairs,
        vec![
            ("Foo Standard Time".to_string(), "Asia/Tokyo".to_string()),
            ("Bar".to_string(), "Europe/Berlin".to_string()),
        ]
    );
}

#[test]
fn parse_env_aliases_empty() {
    assert!(parse_env_aliases("").is_empty());
    assert!(parse_env_aliases(";;;").is_empty());
}

#[test]
fn parse_env_aliases_skips_empty_sides() {
    let pairs = parse_env_aliases("=Asia/Tokyo;Foo=;Bar=Europe/Berlin");
    assert_eq!(pairs, vec![("Bar".to_string(), "Europe/Berlin".to_string())]);
}

/// One policy for the two non-unique `assume_timezone` outcomes: a fold takes
/// the earlier occurrence, a gap is pushed forward by its length. Both DST
/// intervals are half-open in time-tz, so the gap's leading endpoint is the
/// transition instant, not a nonexistent time.
#[test]
fn resolve_local_dst_fold_and_gap_policy() {
    let eastern = timezones::db::america::NEW_YORK;
    let cases: &[(PrimitiveDateTime, OffsetDateTime, &str)] = &[
        (datetime!(2026-08-29 04:33:35), datetime!(2026-08-29 08:33:35 UTC), "unique reading, EDT"),
        // Spring forward 2026-03-08: 01:59:59 EST -> 03:00:00 EDT.
        (
            datetime!(2026-03-08 01:59:59),
            datetime!(2026-03-08 06:59:59 UTC),
            "last reading before the gap",
        ),
        (
            datetime!(2026-03-08 02:00:00),
            datetime!(2026-03-08 07:00:00 UTC),
            "gap leading endpoint is the transition instant",
        ),
        (
            datetime!(2026-03-08 02:00:01),
            datetime!(2026-03-08 07:00:01 UTC),
            "gap interior, pushed forward",
        ),
        (
            datetime!(2026-03-08 02:30:00),
            datetime!(2026-03-08 07:30:00 UTC),
            "gap interior, pushed forward to 03:30 EDT",
        ),
        (
            datetime!(2026-03-08 02:59:59),
            datetime!(2026-03-08 07:59:59 UTC),
            "gap trailing edge, pushed forward",
        ),
        (
            datetime!(2026-03-08 03:00:00),
            datetime!(2026-03-08 07:00:00 UTC),
            "first reading after the gap",
        ),
        // Fall back 2025-11-02: 01:59:59 EDT -> 01:00:00 EST; the fold takes EDT.
        (
            datetime!(2025-11-02 00:59:59),
            datetime!(2025-11-02 04:59:59 UTC),
            "last unique reading before the fold",
        ),
        (datetime!(2025-11-02 01:00:00), datetime!(2025-11-02 05:00:00 UTC), "fold start takes EDT"),
        (
            datetime!(2025-11-02 01:40:26),
            datetime!(2025-11-02 05:40:26 UTC),
            "#790 live input: fold interior takes EDT",
        ),
        (datetime!(2025-11-02 01:59:59), datetime!(2025-11-02 05:59:59 UTC), "fold end takes EDT"),
        (
            datetime!(2025-11-02 02:00:00),
            datetime!(2025-11-02 07:00:00 UTC),
            "first unique reading after the fold, EST",
        ),
    ];
    for (reading, expected, label) in cases {
        assert_eq!(resolve_local(*reading, eastern), *expected, "{label}: {reading}");
    }

    // A pushed-forward gap reading renders with the post-transition offset.
    let pushed = resolve_local(datetime!(2026-03-08 02:30:00), eastern);
    assert_eq!(pushed.offset().whole_hours(), -4, "rendered post-transition: {pushed}");
    assert_eq!(pushed.hour(), 3);
}

/// The push-forward is by the gap's actual length, not a fixed hour: Lord Howe
/// Island shifts by 30 minutes (02:00 +10:30 -> 02:30 +11:00 on 2025-10-05).
#[test]
fn resolve_local_half_hour_gap() {
    let lord_howe = timezones::db::australia::LORD_HOWE;
    let pushed = resolve_local(datetime!(2025-10-05 02:15:00), lord_howe);
    assert_eq!(pushed, datetime!(2025-10-04 15:45:00 UTC));
    assert_eq!(pushed.hour(), 2);
    assert_eq!(pushed.minute(), 45);
    assert_eq!(pushed.offset().whole_minutes(), 11 * 60);
}