Skip to main content

longport/screener/
context.rs

1use std::sync::Arc;
2
3use longport_httpcli::{HttpClient, Json, Method};
4use serde::{Serialize, de::DeserializeOwned};
5use tracing::{Subscriber, dispatcher, instrument::WithSubscriber};
6
7use crate::{Config, Result, screener::types::*};
8
9struct InnerScreenerContext {
10    http_cli: HttpClient,
11    log_subscriber: Arc<dyn Subscriber + Send + Sync>,
12}
13
14impl Drop for InnerScreenerContext {
15    fn drop(&mut self) {
16        dispatcher::with_default(&self.log_subscriber.clone().into(), || {
17            tracing::info!("screener context dropped");
18        });
19    }
20}
21
22/// Screener context — stock screener strategies, search, and indicators.
23#[derive(Clone)]
24pub struct ScreenerContext(Arc<InnerScreenerContext>);
25
26impl ScreenerContext {
27    /// Create a [`ScreenerContext`]
28    pub fn new(config: Arc<Config>) -> Self {
29        let log_subscriber = config.create_log_subscriber("screener");
30        dispatcher::with_default(&log_subscriber.clone().into(), || {
31            tracing::info!(language = ?config.language, "creating screener context");
32        });
33        let ctx = Self(Arc::new(InnerScreenerContext {
34            http_cli: config.create_http_client(),
35            log_subscriber,
36        }));
37        dispatcher::with_default(&ctx.0.log_subscriber.clone().into(), || {
38            tracing::info!("screener context created");
39        });
40        ctx
41    }
42
43    /// Returns the log subscriber
44    #[inline]
45    pub fn log_subscriber(&self) -> Arc<dyn Subscriber + Send + Sync> {
46        self.0.log_subscriber.clone()
47    }
48
49    async fn get<R, Q>(&self, path: &str, query: Q) -> Result<R>
50    where
51        R: DeserializeOwned + Send + Sync + 'static,
52        Q: Serialize + Send + Sync,
53    {
54        Ok(self
55            .0
56            .http_cli
57            .request(Method::GET, path)
58            .query_params(query)
59            .response::<Json<R>>()
60            .send()
61            .with_subscriber(self.0.log_subscriber.clone())
62            .await?
63            .0)
64    }
65
66    async fn post<R, B>(&self, path: &str, body: B) -> Result<R>
67    where
68        R: DeserializeOwned + Send + Sync + 'static,
69        B: std::fmt::Debug + Serialize + Send + Sync + 'static,
70    {
71        Ok(self
72            .0
73            .http_cli
74            .request(Method::POST, path)
75            .body(Json(body))
76            .response::<Json<R>>()
77            .send()
78            .with_subscriber(self.0.log_subscriber.clone())
79            .await?
80            .0)
81    }
82
83    // ── screener_recommend_strategies ─────────────────────────────
84
85    /// Get preset built-in screener strategies.
86    ///
87    /// Path: `GET /v1/quote/ai/screener/strategies/recommend`
88    pub async fn screener_recommend_strategies(
89        &self,
90        market: impl Into<String>,
91    ) -> Result<ScreenerRecommendStrategiesResponse> {
92        #[derive(Serialize)]
93        struct Query {
94            market: String,
95        }
96        let raw: serde_json::Value = self
97            .get(
98                "/v1/quote/ai/screener/strategies/recommend",
99                Query {
100                    market: market.into(),
101                },
102            )
103            .await?;
104        Ok(ScreenerRecommendStrategiesResponse { data: raw })
105    }
106
107    // ── screener_user_strategies ──────────────────────────────────
108
109    /// Get the current user's saved screener strategies.
110    ///
111    /// Path: `GET /v1/quote/ai/screener/strategies/mine`
112    pub async fn screener_user_strategies(
113        &self,
114        market: impl Into<String>,
115    ) -> Result<ScreenerUserStrategiesResponse> {
116        #[derive(Serialize)]
117        struct Query {
118            market: String,
119        }
120        let raw: serde_json::Value = self
121            .get(
122                "/v1/quote/ai/screener/strategies/mine",
123                Query {
124                    market: market.into(),
125                },
126            )
127            .await?;
128        Ok(ScreenerUserStrategiesResponse { data: raw })
129    }
130
131    // ── screener_strategy ─────────────────────────────────────────
132
133    /// Get detail for one screener strategy by ID.
134    ///
135    /// Path: `GET /v1/quote/ai/screener/strategy/{id}`
136    ///
137    /// The `filter_` prefix is stripped from every `filters[].key` before
138    /// returning so callers see clean keys like `pettm` instead of
139    /// `filter_pettm`.
140    pub async fn screener_strategy(&self, id: i64) -> Result<ScreenerStrategyResponse> {
141        let path = format!("/v1/quote/ai/screener/strategy/{id}");
142        #[derive(Serialize)]
143        struct Empty {}
144        let mut raw: serde_json::Value = self.get(&path, Empty {}).await?;
145        // Strip filter_ prefix from filter.filters[].key
146        if let Some(filters) = raw["filter"]["filters"].as_array_mut() {
147            for f in filters.iter_mut() {
148                if let Some(k) = f["key"].as_str() {
149                    let stripped = k.strip_prefix("filter_").unwrap_or(k).to_string();
150                    f["key"] = serde_json::Value::String(stripped);
151                }
152            }
153        }
154        Ok(ScreenerStrategyResponse { data: raw })
155    }
156
157    // ── screener_search ───────────────────────────────────────────
158
159    /// Default return columns always included in a screener search request.
160    const DEFAULT_RETURNS: &'static [&'static str] = &[
161        "filter_prevclose",
162        "filter_prevchg",
163        "filter_marketcap",
164        "filter_salesgrowthyoy",
165        "filter_pettm",
166        "filter_pbmrq",
167        "filter_industry",
168    ];
169
170    /// Search / screen securities using a strategy or custom conditions.
171    ///
172    /// Path: `POST /v1/quote/ai/screener/search`
173    ///
174    /// ## Mode A — strategy ID given
175    ///
176    /// When `strategy_id` is `Some`, the strategy is fetched from
177    /// `GET /v1/quote/ai/screener/strategy/{id}` and its `filter.filters[]`
178    /// are forwarded to the search endpoint together with
179    /// [`DEFAULT_RETURNS`].  The `market` is taken from the strategy
180    /// response (falls back to `"US"` if absent or `"-"`).
181    ///
182    /// ## Mode B — custom conditions
183    ///
184    /// When `strategy_id` is `None` and `conditions` is non-empty each
185    /// element is either a `"KEY:MIN:MAX"` string **or** a JSON object with
186    /// `key`, `min`, `max`, and optional `tech_values` fields.  The
187    /// supplied `market` is used directly.  `DEFAULT_RETURNS` plus every
188    /// condition key are added to the `returns` list.
189    ///
190    /// The `filter_` prefix is stripped from every `items[].indicators[].key`
191    /// in the response before it is returned to the caller.
192    ///
193    /// `page` is 0-indexed.
194    pub async fn screener_search(
195        &self,
196        market: impl Into<String>,
197        strategy_id: Option<i64>,
198        conditions: Vec<ScreenerCondition>,
199        show: Vec<String>,
200        page: u32,
201        size: u32,
202    ) -> Result<ScreenerSearchResponse> {
203        let market: String = market.into();
204
205        // ── build filters and effective market ──────────────────────────────
206        let (effective_market, filters) = if let Some(sid) = strategy_id {
207            // Mode A: fetch strategy from AI endpoint
208            let path = format!("/v1/quote/ai/screener/strategy/{sid}");
209            #[derive(Serialize)]
210            struct Empty {}
211            let strategy: serde_json::Value = self.get(&path, Empty {}).await?;
212
213            let mkt_val = strategy["market"].as_str().unwrap_or("US").to_uppercase();
214            let mkt = if mkt_val.is_empty() || mkt_val == "-" {
215                "US".to_string()
216            } else {
217                mkt_val
218            };
219
220            let mut filters: Vec<serde_json::Value> = Vec::new();
221            if let Some(f) = strategy["filter"]["filters"].as_array() {
222                for ind in f {
223                    let key = ind["key"].as_str().unwrap_or("").to_string();
224                    if key.is_empty() {
225                        continue;
226                    }
227                    let min = ind["min"].as_str().unwrap_or("").to_string();
228                    let max = ind["max"].as_str().unwrap_or("").to_string();
229                    let tech_values = if ind["tech_values"].is_object() {
230                        ind["tech_values"].clone()
231                    } else {
232                        serde_json::json!({})
233                    };
234                    filters.push(serde_json::json!({
235                        "key": key,
236                        "min": min,
237                        "max": max,
238                        "tech_values": tech_values,
239                    }));
240                }
241            }
242            (mkt, filters)
243        } else {
244            // Mode B: typed condition objects
245            let filters: Vec<serde_json::Value> = conditions
246                .iter()
247                .filter(|c| !c.key.is_empty())
248                .map(|c| {
249                    let api_key = if c.key.starts_with("filter_") {
250                        c.key.clone()
251                    } else {
252                        format!("filter_{}", c.key)
253                    };
254                    let tv = if c.tech_values.is_object() {
255                        c.tech_values.clone()
256                    } else {
257                        serde_json::json!({})
258                    };
259                    serde_json::json!({
260                        "key": api_key,
261                        "min": c.min,
262                        "max": c.max,
263                        "tech_values": tv,
264                    })
265                })
266                .collect();
267            (market, filters)
268        };
269
270        // ── build returns list ───────────────────────────────────────────────
271        let mut returns: Vec<String> = Self::DEFAULT_RETURNS
272            .iter()
273            .map(|s| s.to_string())
274            .collect();
275        // add keys from filters (with filter_ prefix for the API)
276        for f in &filters {
277            if let Some(k) = f["key"].as_str() {
278                let api_key = if k.starts_with("filter_") {
279                    k.to_string()
280                } else {
281                    format!("filter_{k}")
282                };
283                if !returns.contains(&api_key) {
284                    returns.push(api_key);
285                }
286            }
287        }
288        // add extra columns requested by the caller
289        for s in &show {
290            let api_key = if s.starts_with("filter_") {
291                s.clone()
292            } else {
293                format!("filter_{s}")
294            };
295            if !returns.contains(&api_key) {
296                returns.push(api_key);
297            }
298        }
299
300        // ── POST request ────────────────────────────────────────────────────
301        let body = serde_json::json!({
302            "market": effective_market,
303            "filters": filters,
304            "returns": returns,
305            "page": page,
306            "size": size,
307        });
308
309        let raw: serde_json::Value = self.post("/v1/quote/ai/screener/search", body).await?;
310        Ok(ScreenerSearchResponse {
311            data: strip_filter_prefix_from_search_results(raw),
312        })
313    }
314
315    // ── screener_indicators ───────────────────────────────────────
316
317    /// Get all available screener indicator definitions.
318    ///
319    /// Path: `GET /v1/quote/ai/screener/indicators`
320    ///
321    /// Post-processing applied before returning:
322    /// - `filter_` prefix is stripped from every `groups[].indicators[].key`
323    /// - `tech_values` is built from `tech_indicators`: `{tech_key: [{value,
324    ///   label}]}`
325    pub async fn screener_indicators(&self) -> Result<ScreenerIndicatorsResponse> {
326        #[derive(Serialize)]
327        struct Empty {}
328        let mut raw: serde_json::Value = self
329            .get("/v1/quote/ai/screener/indicators", Empty {})
330            .await?;
331        if let Some(groups) = raw["groups"].as_array_mut() {
332            for group in groups.iter_mut() {
333                if let Some(indicators) = group["indicators"].as_array_mut() {
334                    for ind in indicators.iter_mut() {
335                        // Strip filter_ prefix from key
336                        if let Some(k) = ind["key"].as_str() {
337                            let stripped = k.strip_prefix("filter_").unwrap_or(k).to_string();
338                            ind["key"] = serde_json::Value::String(stripped);
339                        }
340                        // Build tech_values from tech_indicators
341                        if let Some(tech_inds) = ind["tech_indicators"].as_array().cloned() {
342                            let tv: serde_json::Map<String, serde_json::Value> = tech_inds
343                                .iter()
344                                .filter_map(|ti| {
345                                    let key = ti["tech_key"].as_str()?.to_string();
346                                    let opts: Vec<serde_json::Value> = ti["tech_items"]
347                                        .as_array()
348                                        .unwrap_or(&vec![])
349                                        .iter()
350                                        .map(|item| {
351                                            serde_json::json!({
352                                                "value": item["item_value"].as_str().unwrap_or(""),
353                                                "label": item["item_name"].as_str().unwrap_or(""),
354                                            })
355                                        })
356                                        .collect();
357                                    Some((key, serde_json::Value::Array(opts)))
358                                })
359                                .collect();
360                            if !tv.is_empty() {
361                                ind["tech_values"] = serde_json::Value::Object(tv);
362                            }
363                        }
364                    }
365                }
366            }
367        }
368        Ok(ScreenerIndicatorsResponse { data: raw })
369    }
370}
371
372/// Strip `filter_` prefix from every `items[].indicators[].key` in a raw
373/// screener search result.
374fn strip_filter_prefix_from_search_results(mut raw: serde_json::Value) -> serde_json::Value {
375    if let Some(items) = raw["items"].as_array_mut() {
376        for item in items.iter_mut() {
377            if let Some(indicators) = item["indicators"].as_array_mut() {
378                for ind in indicators.iter_mut() {
379                    if let Some(k) = ind["key"].as_str() {
380                        let stripped = k.strip_prefix("filter_").unwrap_or(k).to_string();
381                        ind["key"] = serde_json::Value::String(stripped);
382                    }
383                }
384            }
385        }
386    }
387    raw
388}