use std::fmt;
use chrono::{DateTime, FixedOffset, NaiveDate};
use pretty_simple_display::{DebugPretty, DisplaySimple};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::api::query::QueryBuilder;
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub struct SymbolSearchResult {
pub symbol: String,
pub description: Option<String>,
pub listed_market: Option<String>,
pub price_increments: Option<String>,
pub trading_hours: Option<String>,
pub options: Option<bool>,
pub instrument_type: Option<String>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub struct InstrumentSearchResult {
pub symbol: String,
pub description: Option<String>,
pub category: Option<String>,
pub sub_category: Option<String>,
pub exchange: Option<String>,
pub instrument_type: Option<String>,
pub external_id: Option<String>,
pub event_product_external_id: Option<String>,
pub strike_types: Option<String>,
pub underlying_product: Option<String>,
pub underlying_product_type: Option<String>,
pub underlying_streamer_symbol: Option<String>,
#[serde(default, with = "crate::types::wire::datetime_option")]
pub stops_trading_at: Option<DateTime<FixedOffset>>,
}
pub const MAX_SEARCH_RESULTS: u32 = 100;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InstrumentSearchFilter {
query: Option<String>,
types: Vec<String>,
categories: Vec<String>,
exchanges: Vec<String>,
sub_types: Vec<String>,
from_date: Option<NaiveDate>,
limit: Option<u32>,
}
impl InstrumentSearchFilter {
pub fn new() -> Self {
Self::default()
}
pub fn for_query(query: impl Into<String>) -> Self {
Self::new().with_query(query)
}
#[must_use]
pub fn with_query(mut self, query: impl Into<String>) -> Self {
self.query = Some(query.into());
self
}
#[must_use]
pub fn with_types(mut self, types: &[impl AsRef<str>]) -> Self {
self.types
.extend(types.iter().map(|value| value.as_ref().to_owned()));
self
}
#[must_use]
pub fn with_categories(mut self, categories: &[impl AsRef<str>]) -> Self {
self.categories
.extend(categories.iter().map(|value| value.as_ref().to_owned()));
self
}
#[must_use]
pub fn with_exchanges(mut self, exchanges: &[impl AsRef<str>]) -> Self {
self.exchanges
.extend(exchanges.iter().map(|value| value.as_ref().to_owned()));
self
}
#[must_use]
pub fn with_instrument_sub_types(mut self, sub_types: &[impl AsRef<str>]) -> Self {
self.sub_types
.extend(sub_types.iter().map(|value| value.as_ref().to_owned()));
self
}
#[must_use]
pub fn with_from_date(mut self, from_date: NaiveDate) -> Self {
self.from_date = Some(from_date);
self
}
#[must_use]
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
pub fn limit(&self) -> Option<u32> {
self.limit
}
pub(crate) fn validate(&self) -> crate::TastyResult<()> {
if let Some(limit) = self.limit
&& limit > MAX_SEARCH_RESULTS
{
return Err(crate::TastyTradeError::Precondition(format!(
"instrument search accepts at most {MAX_SEARCH_RESULTS} results, \
and {limit} were asked for; lower the limit or page through \
the listing endpoints instead"
)));
}
Ok(())
}
pub(crate) fn to_query(&self) -> QueryBuilder {
let mut query = QueryBuilder::new();
query.push_opt("query", self.query.as_ref());
push_joined(&mut query, "type", &self.types);
push_joined(&mut query, "category", &self.categories);
push_joined(&mut query, "exchange", &self.exchanges);
push_joined(&mut query, "instrument-sub-type", &self.sub_types);
query.push_opt("from-date", self.from_date.map(|date| date.to_string()));
query.push_opt("limit", self.limit);
query
}
}
fn push_joined(query: &mut QueryBuilder, key: &'static str, values: &[String]) {
if !values.is_empty() {
query.push(key, values.join(","));
}
}
#[derive(Clone, PartialEq, Eq, Deserialize)]
#[serde(transparent)]
pub struct AiSearchToken {
raw: Value,
}
impl AiSearchToken {
pub fn expose(&self) -> &Value {
&self.raw
}
pub fn field(&self, name: &str) -> Option<&Value> {
self.raw.get(name)
}
pub fn expires_at(&self) -> Option<DateTime<FixedOffset>> {
["expires-at", "expires_at"]
.iter()
.find_map(|key| self.raw.get(*key))
.and_then(Value::as_str)
.and_then(|text| DateTime::parse_from_rfc3339(text).ok())
}
pub fn len(&self) -> usize {
self.raw.to_string().len()
}
pub fn is_empty(&self) -> bool {
match &self.raw {
Value::Null => true,
Value::Object(map) => map.is_empty(),
_ => false,
}
}
}
impl fmt::Debug for AiSearchToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "AiSearchToken(<redacted, {} bytes>)", self.len())
}
}
impl fmt::Display for AiSearchToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "<redacted, {} bytes>", self.len())
}
}
impl Serialize for AiSearchToken {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(&format_args!("<redacted, {} bytes>", self.len()))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SENTINEL: &str = "SENTINEL-telescope-token-3Qv7";
fn token() -> AiSearchToken {
serde_json::from_str(&format!(
r#"{{"token":"{SENTINEL}","expires-at":"2026-08-03T12:00:00.000+00:00"}}"#
))
.expect("valid JSON")
}
#[test]
fn the_ai_search_token_never_renders_itself() {
let token = token();
let rendered = format!("{token:?} {token} {}", format_args!("{token:#?}"));
assert!(
!rendered.contains(SENTINEL),
"the token reached a rendering: {rendered}"
);
assert!(rendered.contains("redacted"), "{rendered}");
}
#[test]
fn the_ai_search_token_never_serializes_itself() {
let token = token();
let written = serde_json::to_string(&token).expect("a token must serialize");
assert!(
!written.contains(SENTINEL),
"the token was written: {written}"
);
assert!(written.contains("redacted"), "{written}");
#[derive(Serialize)]
struct Envelope {
token: AiSearchToken,
}
let written = serde_json::to_string(&Envelope { token }).expect("must serialize");
assert!(
!written.contains(SENTINEL),
"the token was written: {written}"
);
}
#[test]
fn exposing_it_returns_what_arrived() {
assert_eq!(
token().field("token").and_then(Value::as_str),
Some(SENTINEL)
);
assert_eq!(
token().expose().get("token").and_then(Value::as_str),
Some(SENTINEL)
);
}
#[test]
fn a_token_inside_an_error_message_stays_redacted() {
let error = crate::TastyTradeError::Precondition(format!("minted {}", token()));
assert!(!format!("{error} {error:?}").contains(SENTINEL));
}
#[test]
fn the_expiry_probe_finds_both_spellings_and_invents_nothing() {
assert!(token().expires_at().is_some());
let snake: AiSearchToken =
serde_json::from_str(r#"{"expires_at":"2026-08-03T12:00:00.000+00:00"}"#)
.expect("valid JSON");
assert!(snake.expires_at().is_some());
let absent: AiSearchToken = serde_json::from_str(r#"{"token":"x"}"#).expect("valid JSON");
assert_eq!(absent.expires_at(), None);
let garbage: AiSearchToken =
serde_json::from_str(r#"{"expires-at":"soon"}"#).expect("valid JSON");
assert_eq!(garbage.expires_at(), None);
}
#[test]
fn an_empty_answer_is_recognisable() {
let empty: AiSearchToken = serde_json::from_str("{}").expect("valid JSON");
assert!(empty.is_empty());
assert!(!token().is_empty());
}
#[test]
fn classification_filters_are_comma_joined_into_one_parameter() {
let filter = InstrumentSearchFilter::for_query("apple")
.with_types(&["Equity", "Equity Option"])
.with_instrument_sub_types(&["ETF", "Index"]);
assert_eq!(
filter.to_query().pairs(),
vec![
("query", "apple"),
("type", "Equity,Equity Option"),
("instrument-sub-type", "ETF,Index"),
]
);
}
#[test]
fn every_documented_search_parameter_is_reachable() {
let filter = InstrumentSearchFilter::for_query("gold")
.with_types(&["Future"])
.with_categories(&["Metals"])
.with_exchanges(&["CME"])
.with_instrument_sub_types(&["Index"])
.with_from_date(NaiveDate::from_ymd_opt(2026, 1, 31).expect("a real date"))
.with_limit(10);
assert_eq!(
filter.to_query().pairs(),
vec![
("query", "gold"),
("type", "Future"),
("category", "Metals"),
("exchange", "CME"),
("instrument-sub-type", "Index"),
("from-date", "2026-01-31"),
("limit", "10"),
]
);
}
#[test]
fn an_empty_filter_sends_nothing() {
assert!(InstrumentSearchFilter::new().to_query().pairs().is_empty());
}
#[test]
fn an_over_large_limit_fails_locally_and_is_not_retryable() {
let filter = InstrumentSearchFilter::new().with_limit(MAX_SEARCH_RESULTS + 1);
let error = filter
.validate()
.expect_err("the cap must be enforced before anything is sent");
assert!(matches!(error, crate::TastyTradeError::Precondition(_)));
assert!(
!error.is_retryable(),
"a local refusal sent nothing, so retrying it changes nothing"
);
assert!(
format!("{error}").contains("100"),
"the message must name the cap: {error}"
);
}
#[test]
fn the_cap_itself_is_accepted() {
assert!(
InstrumentSearchFilter::new()
.with_limit(MAX_SEARCH_RESULTS)
.validate()
.is_ok()
);
}
#[test]
fn a_search_result_decodes_and_keeps_its_offset() {
let row: InstrumentSearchResult = serde_json::from_str(
r#"{
"symbol": "/ESZ4",
"description": "E-mini S&P 500",
"category": "Equity Index",
"instrument-type": "Future",
"stops-trading-at": "2026-12-19T14:30:00.000-05:00"
}"#,
)
.expect("the row must decode");
assert_eq!(row.symbol, "/ESZ4");
assert_eq!(row.exchange, None, "an absent field is absent, not empty");
assert_eq!(
row.stops_trading_at
.expect("a timestamp")
.offset()
.local_minus_utc(),
-5 * 3600
);
}
#[test]
fn a_symbol_search_row_needs_only_its_symbol() {
let row: SymbolSearchResult =
serde_json::from_str(r#"{"symbol":"AAPL"}"#).expect("the row must decode");
assert_eq!(row.symbol, "AAPL");
assert_eq!(row.options, None, "an omitted flag is unknown, not false");
assert_eq!(row.description, None);
}
}