use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::client::Client;
use crate::types::{ResponseColumn, SCREEN_STRING_KEYS, json_value_to_string};
const QUERY_SCREEN: &str = include_str!("graphql/screen.graphql");
const QUERY_SCREENS: &str = include_str!("graphql/screens.graphql");
const QUERY_RUN_SCREEN: &str = include_str!("graphql/run_screen.graphql");
const QUERY_MARKET_DATA_SCREEN: &str = include_str!("graphql/market_data_screen.graphql");
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ScreenVariables {
site: String,
screen_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
coach_screen: Option<bool>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ScreensVariables {
site: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "type")]
screen_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sort_dir: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RunScreenVariables {
input: RunScreenInput,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MarketDataScreenVariables {
screen_name: String,
parameters: Vec<ScreenerParameter>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResponseValue {
#[serde(default, deserialize_with = "deserialize_cell_value")]
pub value: Option<String>,
pub md_item: Option<MdItem>,
}
fn deserialize_cell_value<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
Ok(value.and_then(|value| json_value_to_string(value, SCREEN_STRING_KEYS)))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MdItem {
#[serde(rename = "mdItemID")]
pub md_item_id: Option<serde_json::Value>,
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenResponse {
pub user: Option<ScreenUser>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenUser {
pub screen: Option<ScreenDetail>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenDetail {
pub id: Option<String>,
pub name: Option<String>,
pub site: Option<String>,
pub description: Option<String>,
pub filter_criteria: Option<ScreenFilterCriteria>,
pub result_config: Option<ScreenResultConfig>,
pub result: Option<ScreenResultSummary>,
#[serde(rename = "type")]
pub screen_type: Option<String>,
pub source: Option<ScreenDetailSource>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenFilterCriteria {
#[serde(default)]
pub terms: Vec<ScreenFilterTerm>,
#[serde(rename = "type")]
pub criteria_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenFilterTerm {
pub left: Option<ScreenFilterTermLeft>,
pub operand: Option<String>,
pub right: Option<ScreenFilterTermRight>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenFilterTermLeft {
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenFilterTermRight {
pub value: Option<String>,
pub maximum_value: Option<String>,
pub minimum_value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenResultConfig {
pub limit: Option<i64>,
pub sort_by: Option<ScreenSortBy>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenSortBy {
pub field: Option<String>,
pub direction: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenResultSummary {
pub count: Option<i64>,
pub description: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenDetailSource {
pub exclude_msr_database: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreensResponse {
pub user: Option<ScreensUser>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreensUser {
#[serde(default)]
pub screens: Vec<ScreenEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenEntry {
pub site: Option<String>,
pub id: Option<String>,
pub name: Option<String>,
#[serde(rename = "type")]
pub screen_type: Option<String>,
pub source: Option<ScreenSource>,
pub updated_at: Option<String>,
pub filter_criteria: Option<ScreenFilterCriteria>,
pub description: Option<String>,
pub created_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenSource {
pub id: Option<String>,
#[serde(rename = "type")]
pub source_type: Option<String>,
#[serde(rename = "pub")]
pub source_pub: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunScreenIncludeSource {
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunScreenInput {
pub correlation_tag: String,
pub coach_account: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_source: Option<RunScreenIncludeSource>,
pub page_size: i64,
pub result_limit: i64,
pub screen_id: String,
pub site: String,
pub skip: i64,
#[serde(default)]
pub response_columns: Vec<ResponseColumn>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunScreenResponse {
pub user: Option<RunScreenUser>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunScreenUser {
pub run_screen: Option<RunScreenResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunScreenResult {
pub number_of_matching_instruments: Option<i64>,
#[serde(default)]
pub response_values: Vec<Vec<ResponseValue>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenerParameter {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarketDataScreenResponse {
pub market_data_screen: Option<MarketDataScreenResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarketDataScreenResult {
pub screen_name: Option<String>,
#[serde(default)]
pub response_values: Vec<Vec<ResponseValue>>,
pub number_of_instruments_in_source: Option<i64>,
#[serde(default)]
pub error_values: Vec<String>,
pub elapsed_time: Option<String>,
}
impl Client {
pub async fn screen(
&self,
site: &str,
screen_id: &str,
coach_screen: Option<bool>,
) -> crate::error::Result<ScreenResponse> {
let variables = ScreenVariables {
site: site.to_string(),
screen_id: screen_id.to_string(),
coach_screen,
};
self.graphql_operation("Screen", variables, QUERY_SCREEN)
.await
}
pub async fn screens(&self, site: &str) -> crate::error::Result<ScreensResponse> {
let variables = ScreensVariables {
site: site.to_string(),
screen_type: None,
sort_dir: None,
};
self.graphql_operation("Screens", variables, QUERY_SCREENS)
.await
}
pub async fn run_screen(
&self,
input: RunScreenInput,
) -> crate::error::Result<RunScreenResponse> {
let variables = RunScreenVariables { input };
self.graphql_operation("RunScreen", variables, QUERY_RUN_SCREEN)
.await
}
pub async fn market_data_screen(
&self,
screen_name: &str,
parameters: Vec<ScreenerParameter>,
) -> crate::error::Result<MarketDataScreenResponse> {
let variables = MarketDataScreenVariables {
screen_name: screen_name.to_string(),
parameters,
};
self.graphql_operation("MarketDataScreen", variables, QUERY_MARKET_DATA_SCREEN)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::mock_test;
#[tokio::test]
async fn screen_parses_response() {
let (_server, client, mock) = mock_test("Screen").await;
let resp = client
.screen("marketsurge", "screen-Peter Lynch", Some(true))
.await
.expect("screen should succeed");
let user = resp.user.as_ref().expect("user");
let detail = user.screen.as_ref().expect("screen");
assert_eq!(detail.id.as_deref(), Some("screen-Peter Lynch"));
assert_eq!(detail.name.as_deref(), Some("Peter Lynch"));
assert_eq!(detail.screen_type.as_deref(), Some("STOCK_SCREEN"));
let config = detail.result_config.as_ref().expect("result_config");
assert_eq!(config.limit, Some(500));
let sort = config.sort_by.as_ref().expect("sort_by");
assert_eq!(sort.field.as_deref(), Some("RSRating"));
assert_eq!(sort.direction.as_deref(), Some("DESCENDING"));
let result = detail.result.as_ref().expect("result");
assert_eq!(result.count, Some(42));
let criteria = detail.filter_criteria.as_ref().expect("filter_criteria");
assert_eq!(criteria.criteria_type.as_deref(), Some("AND"));
assert_eq!(criteria.terms.len(), 2);
assert_eq!(
criteria.terms[0].left.as_ref().unwrap().name.as_deref(),
Some("RSRating")
);
let source = detail.source.as_ref().expect("source");
assert_eq!(source.exclude_msr_database, Some(false));
mock.assert();
}
#[tokio::test]
async fn screens_parses_response() {
let (_server, client, mock) = mock_test("Screens").await;
let resp = client
.screens("marketsurge")
.await
.expect("screens should succeed");
let user = resp.user.as_ref().expect("user");
assert_eq!(user.screens.len(), 2);
let first = &user.screens[0];
assert_eq!(first.id.as_deref(), Some("scr-001"));
assert_eq!(first.name.as_deref(), Some("Growth Leaders"));
assert_eq!(first.screen_type.as_deref(), Some("CUSTOM"));
let source = first.source.as_ref().expect("source");
assert_eq!(source.id.as_deref(), Some("src-001"));
assert_eq!(source.source_type.as_deref(), Some("USER"));
assert_eq!(source.source_pub.as_deref(), Some("msr"));
let second = &user.screens[1];
assert_eq!(second.id.as_deref(), Some("scr-002"));
assert!(second.source.is_none());
assert!(second.filter_criteria.is_none());
mock.assert();
}
#[tokio::test]
async fn run_screen_parses_response() {
let (_server, client, mock) = mock_test("RunScreen").await;
let input = RunScreenInput {
correlation_tag: "marketsurge".to_string(),
coach_account: true,
include_source: Some(RunScreenIncludeSource { source: None }),
page_size: 1000,
result_limit: 1_000_000,
screen_id: "screen-abc-123".to_string(),
site: "marketsurge".to_string(),
skip: 0,
response_columns: vec![
ResponseColumn {
name: "Symbol".to_string(),
sort_information: None,
},
ResponseColumn {
name: "CompanyName".to_string(),
sort_information: None,
},
],
};
let resp = client
.run_screen(input)
.await
.expect("run_screen should succeed");
let user = resp.user.as_ref().expect("user");
let result = user.run_screen.as_ref().expect("run_screen");
assert_eq!(result.number_of_matching_instruments, Some(3));
assert_eq!(result.response_values.len(), 2);
assert_eq!(result.response_values[0].len(), 2);
let first_cell = &result.response_values[0][0];
assert_eq!(first_cell.value.as_deref(), Some("NVDA"));
let md_item = first_cell.md_item.as_ref().expect("md_item");
assert_eq!(md_item.name.as_deref(), Some("Symbol"));
let second_row = &result.response_values[1][0];
assert_eq!(second_row.value.as_deref(), Some("GOOGL"));
mock.assert();
}
#[test]
fn response_value_parses_object_value() {
let cell: ResponseValue = serde_json::from_str(
r#"{
"value": {
"value": 12.34,
"formattedValue": "12.34%"
},
"mdItem": {
"mdItemID": 632,
"name": "HoldingsPctFundAssetsHeld"
}
}"#,
)
.expect("response value should parse object value");
assert_eq!(cell.value.as_deref(), Some("12.34%"));
}
#[test]
fn response_value_parses_numeric_value() {
let cell: ResponseValue = serde_json::from_str(
r#"{
"value": 12345,
"mdItem": {
"mdItemID": 453,
"name": "NumberOfFunds1QAgo"
}
}"#,
)
.expect("response value should parse numeric value");
assert_eq!(cell.value.as_deref(), Some("12345"));
}
#[test]
fn response_value_unwraps_single_value_array() {
let cell: ResponseValue = serde_json::from_str(
r#"{
"value": [{"formattedValue": "12.34%"}],
"mdItem": {
"mdItemID": 632,
"name": "HoldingsPctFundAssetsHeld"
}
}"#,
)
.expect("response value should parse single-value array");
assert_eq!(cell.value.as_deref(), Some("12.34%"));
}
#[test]
fn response_value_parses_empty_array_as_none() {
let cell: ResponseValue = serde_json::from_str(
r#"{
"value": [],
"mdItem": {
"mdItemID": 632,
"name": "HoldingsPctFundAssetsHeld"
}
}"#,
)
.expect("response value should parse empty array");
assert!(cell.value.is_none());
}
#[cfg(not(coverage))]
#[tokio::test]
#[ignore]
async fn integration_screen() {
let client = crate::test_support::live_client().await;
let resp = client
.screen("marketsurge", "screen-Peter Lynch", Some(true))
.await
.expect("live screen should succeed");
assert!(resp.user.and_then(|user| user.screen).is_some());
}
#[cfg(not(coverage))]
#[tokio::test]
#[ignore]
async fn integration_screens() {
let client = crate::test_support::live_client().await;
let resp = client
.screens("marketsurge")
.await
.expect("live screens should succeed");
let user = resp.user.expect("user");
assert!(!user.screens.is_empty());
}
#[cfg(not(coverage))]
#[tokio::test]
#[ignore]
async fn integration_run_screen() {
let client = crate::test_support::live_client().await;
let input = RunScreenInput {
correlation_tag: "marketsurge".to_string(),
coach_account: true,
include_source: Some(RunScreenIncludeSource { source: None }),
page_size: 1000,
result_limit: 1_000_000,
screen_id: "screen-Peter Lynch".to_string(),
site: "marketsurge".to_string(),
skip: 0,
response_columns: vec![ResponseColumn {
name: "Symbol".to_string(),
sort_information: None,
}],
};
let resp = client
.run_screen(input)
.await
.expect("live run_screen should succeed");
assert!(resp.user.and_then(|user| user.run_screen).is_some());
}
#[tokio::test]
async fn market_data_screen_parses_response() {
let (_server, client, mock) = mock_test("MarketDataScreen").await;
let parameters = vec![
ScreenerParameter {
name: "DowJonesExchange".to_string(),
value: "13".to_string(),
},
ScreenerParameter {
name: "DowJonesId".to_string(),
value: "4698".to_string(),
},
];
let resp = client
.market_data_screen(
"MarketSurge.RelatedInformation.MUTIFundOwnership",
parameters,
)
.await
.expect("market_data_screen should succeed");
let result = resp
.market_data_screen
.as_ref()
.expect("market_data_screen");
assert_eq!(
result.screen_name.as_deref(),
Some("MarketSurge.RelatedInformation.MUTIFundOwnership")
);
assert_eq!(result.number_of_instruments_in_source, Some(5));
assert_eq!(result.elapsed_time.as_deref(), Some("PT0.0157223S"));
assert!(result.error_values.is_empty());
assert_eq!(result.response_values.len(), 2);
assert_eq!(result.response_values[0].len(), 12);
let symbol = &result.response_values[0][0];
assert_eq!(symbol.value.as_deref(), Some("SEEGX"));
let md = symbol.md_item.as_ref().expect("md_item");
assert_eq!(md.name.as_deref(), Some("Symbol"));
let name = &result.response_values[0][2];
assert_eq!(
name.value.as_deref(),
Some("JPMorgan Large-Cap Growth Fund I Cl")
);
let pct = &result.response_values[0][3];
assert_eq!(pct.value.as_deref(), Some("0.86"));
let second_symbol = &result.response_values[1][0];
assert_eq!(second_symbol.value.as_deref(), Some("PRCOX"));
let q3 = &result.response_values[0][8];
assert_eq!(q3.value.as_deref(), Some(""));
mock.assert();
}
#[cfg(not(coverage))]
#[tokio::test]
#[ignore]
async fn integration_market_data_screen() {
let client = crate::test_support::live_client().await;
let parameters = vec![
ScreenerParameter {
name: "DowJonesExchange".to_string(),
value: "13".to_string(),
},
ScreenerParameter {
name: "DowJonesId".to_string(),
value: "4698".to_string(),
},
];
let resp = client
.market_data_screen(
"MarketSurge.RelatedInformation.MUTIFundOwnership",
parameters,
)
.await
.expect("live market_data_screen should succeed");
assert!(resp.market_data_screen.is_some());
}
}