From 169ec2702010cc41bb10278fd0f0ae62b4f62370 Mon Sep 17 00:00:00 2001 From: Hogan Date: Sat, 23 May 2026 19:58:42 +0800 Subject: [PATCH 1/3] feat(screener): migrate to /v1/quote/ai/screener/* endpoints (#530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Migrates all screener endpoints from `/v1/quote/screener/*` to `/v1/quote/ai/screener/*` per [longbridge-terminal PR #217](https://github.com/longbridge/longbridge-terminal/pull/217). ## Changes | Old endpoint | New endpoint | |---|---| | `GET /v1/quote/screener/strategies/recommend` | `GET /v1/quote/ai/screener/strategies/recommend` | | `GET /v1/quote/screener/strategies/mine` | `GET /v1/quote/ai/screener/strategies/mine` | | `GET /v1/quote/screener/strategy?id=N` | `GET /v1/quote/ai/screener/strategy/{id}` | | `POST /v1/quote/screener/search` | `POST /v1/quote/ai/screener/search` | | `GET /v1/quote/screener/indicators` | `GET /v1/quote/ai/screener/indicators` | ### Breaking changes - `screener_recommend_strategies(market)` and `screener_user_strategies(market)` now require a `market` parameter (Rust/Python/Node.js/Java/C) - `screener_strategy(id)` uses path param instead of query param 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- CHANGELOG.md | 10 + Cargo.toml | 2 +- c/csrc/include/longbridge.h | 2 + c/src/screener_context/context.rs | 15 +- cpp/include/screener_context.hpp | 8 +- cpp/src/screener_context.cpp | 11 +- .../main/java/com/longbridge/SdkNative.java | 2 + .../fundamental/OperatingFinancial.java | 4 +- .../longbridge/screener/ScreenerContext.java | 18 +- java/src/screener_context.rs | 14 +- java/src/types/classes.rs | 2 +- nodejs/index.d.ts | 35 ++- nodejs/src/screener/context.rs | 28 +- nodejs/src/screener/types.rs | 30 ++ python/pysrc/longbridge/openapi.pyi | 69 +++- python/src/screener/context.rs | 19 +- python/src/screener/context_async.rs | 19 +- python/src/screener/mod.rs | 1 + python/src/screener/types.rs | 44 +++ rust/src/blocking/screener.rs | 19 +- rust/src/fundamental/types.rs | 8 +- rust/src/market/context.rs | 29 +- rust/src/screener/context.rs | 294 +++++++++++++++--- rust/src/screener/types.rs | 25 ++ 24 files changed, 601 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7b058502..90b9bfb04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.2.1] + +### Changed + +- `ScreenerContext`: screener endpoints migrated to `/v1/quote/ai/screener/*`; `screener_recommend_strategies` / `screener_user_strategies` now accept a `market` parameter; `screener_search` accepts typed `ScreenerCondition` objects (Mode B) instead of raw strings + +### Fixed + +- `OperatingFinancial`: renamed `counter_id` → `symbol` (converts `ST/US/AAPL` → `AAPL.US`) + ## [4.2.0] ### Added diff --git a/Cargo.toml b/Cargo.toml index 613175113..fabc86433 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["rust", "python", "nodejs", "java", "c"] [workspace.package] -version = "4.2.0" +version = "4.2.1" edition = "2024" [profile.release] diff --git a/c/csrc/include/longbridge.h b/c/csrc/include/longbridge.h index 5cfcd77ac..cb8db05c0 100644 --- a/c/csrc/include/longbridge.h +++ b/c/csrc/include/longbridge.h @@ -10193,6 +10193,7 @@ void lb_screener_context_release(const struct lb_screener_context_t *ctx); * Returns `CScreenerRecommendStrategiesResponse`. */ void lb_screener_context_recommend_strategies(const struct lb_screener_context_t *ctx, + const char *market, lb_async_callback_t callback, void *userdata); @@ -10201,6 +10202,7 @@ void lb_screener_context_recommend_strategies(const struct lb_screener_context_t * Returns `CScreenerUserStrategiesResponse`. */ void lb_screener_context_user_strategies(const struct lb_screener_context_t *ctx, + const char *market, lb_async_callback_t callback, void *userdata); diff --git a/c/src/screener_context/context.rs b/c/src/screener_context/context.rs index 4a027ebee..cb3c44579 100644 --- a/c/src/screener_context/context.rs +++ b/c/src/screener_context/context.rs @@ -38,14 +38,16 @@ pub unsafe extern "C" fn lb_screener_context_release(ctx: *const CScreenerContex #[unsafe(no_mangle)] pub unsafe extern "C" fn lb_screener_context_recommend_strategies( ctx: *const CScreenerContext, + market: *const c_char, callback: CAsyncCallback, userdata: *mut c_void, ) { let ctx_inner = (*ctx).ctx.clone(); + let market = cstr_to_rust(market); execute_async(callback, ctx, userdata, async move { let resp: CCow = CCow::new(CScreenerRecommendStrategiesResponseOwned::from( - ctx_inner.screener_recommend_strategies().await?, + ctx_inner.screener_recommend_strategies(market).await?, )); Ok(resp) }); @@ -56,14 +58,17 @@ pub unsafe extern "C" fn lb_screener_context_recommend_strategies( #[unsafe(no_mangle)] pub unsafe extern "C" fn lb_screener_context_user_strategies( ctx: *const CScreenerContext, + market: *const c_char, callback: CAsyncCallback, userdata: *mut c_void, ) { let ctx_inner = (*ctx).ctx.clone(); + let market = cstr_to_rust(market); execute_async(callback, ctx, userdata, async move { - let resp: CCow = CCow::new( - CScreenerUserStrategiesResponseOwned::from(ctx_inner.screener_user_strategies().await?), - ); + let resp: CCow = + CCow::new(CScreenerUserStrategiesResponseOwned::from( + ctx_inner.screener_user_strategies(market).await?, + )); Ok(resp) }); } @@ -110,7 +115,7 @@ pub unsafe extern "C" fn lb_screener_context_search( let resp: CCow = CCow::new(CScreenerSearchResponseOwned::from( ctx_inner - .screener_search(market, strategy_id, page, size) + .screener_search(market, strategy_id, vec![], vec![], page, size) .await?, )); Ok(resp) diff --git a/cpp/include/screener_context.hpp b/cpp/include/screener_context.hpp index 843bb0fc5..2c6adb121 100644 --- a/cpp/include/screener_context.hpp +++ b/cpp/include/screener_context.hpp @@ -24,11 +24,13 @@ class ScreenerContext static ScreenerContext create(const Config& config); - /// Get recommended built-in screener strategies (raw JSON string) - void screener_recommend_strategies(AsyncCallback callback) const; + /// Get preset screener strategies for a given market (raw JSON string) + void screener_recommend_strategies(const std::string& market, + AsyncCallback callback) const; /// Get the current user's saved screener strategies (raw JSON string) - void screener_user_strategies(AsyncCallback callback) const; + void screener_user_strategies(const std::string& market, + AsyncCallback callback) const; /// Get detail for one screener strategy by ID (raw JSON string) void screener_strategy(int64_t id, AsyncCallback callback) const; diff --git a/cpp/src/screener_context.cpp b/cpp/src/screener_context.cpp index 46054aacb..886438b2e 100644 --- a/cpp/src/screener_context.cpp +++ b/cpp/src/screener_context.cpp @@ -8,8 +8,7 @@ extern "C" { const lb_screener_context_t* lb_screener_context_new(const lb_config_t* config); void lb_screener_context_retain(const lb_screener_context_t* ctx); void lb_screener_context_release(const lb_screener_context_t* ctx); -void lb_screener_context_recommend_strategies(const lb_screener_context_t*, lb_async_callback_t, void*); -void lb_screener_context_user_strategies(const lb_screener_context_t*, lb_async_callback_t, void*); +// Declarations already present in longbridge.h (with market param) — no need to redeclare. void lb_screener_context_strategy(const lb_screener_context_t*, int64_t, lb_async_callback_t, void*); void lb_screener_context_search(const lb_screener_context_t*, const char*, int64_t, bool, uint32_t, uint32_t, lb_async_callback_t, void*); void lb_screener_context_indicators(const lb_screener_context_t*, lb_async_callback_t, void*); @@ -39,12 +38,12 @@ ScreenerContext ScreenerContext::create(const Config& config) { else{(*cb)(AsyncResult(sctx,std::move(status),nullptr));} \ }, new AsyncCallback(callback)) -void ScreenerContext::screener_recommend_strategies(AsyncCallback callback) const { - S_JSON(lb_screener_context_recommend_strategies, lb_screener_recommend_strategies_response_t, ctx_); +void ScreenerContext::screener_recommend_strategies(const std::string& market, AsyncCallback callback) const { + S_JSON(lb_screener_context_recommend_strategies, lb_screener_recommend_strategies_response_t, ctx_, market.c_str()); } -void ScreenerContext::screener_user_strategies(AsyncCallback callback) const { - S_JSON(lb_screener_context_user_strategies, lb_screener_user_strategies_response_t, ctx_); +void ScreenerContext::screener_user_strategies(const std::string& market, AsyncCallback callback) const { + S_JSON(lb_screener_context_user_strategies, lb_screener_user_strategies_response_t, ctx_, market.c_str()); } void ScreenerContext::screener_strategy(int64_t id, AsyncCallback callback) const { diff --git a/java/javasrc/src/main/java/com/longbridge/SdkNative.java b/java/javasrc/src/main/java/com/longbridge/SdkNative.java index 777647bae..4ed90791a 100644 --- a/java/javasrc/src/main/java/com/longbridge/SdkNative.java +++ b/java/javasrc/src/main/java/com/longbridge/SdkNative.java @@ -335,9 +335,11 @@ public static native void marketContextRankList(long context, Object opts, public static native void freeScreenerContext(long context); public static native void screenerContextRecommendStrategies(long context, + String market, AsyncCallback callback); public static native void screenerContextUserStrategies(long context, + String market, AsyncCallback callback); public static native void screenerContextStrategy(long context, Object opts, diff --git a/java/javasrc/src/main/java/com/longbridge/fundamental/OperatingFinancial.java b/java/javasrc/src/main/java/com/longbridge/fundamental/OperatingFinancial.java index 3a95d115a..db4e55224 100644 --- a/java/javasrc/src/main/java/com/longbridge/fundamental/OperatingFinancial.java +++ b/java/javasrc/src/main/java/com/longbridge/fundamental/OperatingFinancial.java @@ -4,8 +4,8 @@ public class OperatingFinancial { /** Ticker code (may be empty). */ public String code; - /** Raw counter ID (may be empty). */ - public String counterId; + /** Symbol in CODE.MARKET format (may be empty). */ + public String symbol; /** Reporting currency. */ public String currency; /** Company name. */ diff --git a/java/javasrc/src/main/java/com/longbridge/screener/ScreenerContext.java b/java/javasrc/src/main/java/com/longbridge/screener/ScreenerContext.java index 7fad58470..c97d66f4a 100644 --- a/java/javasrc/src/main/java/com/longbridge/screener/ScreenerContext.java +++ b/java/javasrc/src/main/java/com/longbridge/screener/ScreenerContext.java @@ -20,14 +20,24 @@ public void close() throws Exception { SdkNative.freeScreenerContext(raw); } - /** Get platform-recommended screener strategies. */ + /** Get platform-preset screener strategies for the given market (default "US"). */ + public CompletableFuture getRecommendStrategies(String market) throws OpenApiException { + return AsyncCallback.executeTask((callback) -> SdkNative.screenerContextRecommendStrategies(raw, market, callback)); + } + + /** Get platform-preset screener strategies (defaults to US market). */ public CompletableFuture getRecommendStrategies() throws OpenApiException { - return AsyncCallback.executeTask((callback) -> SdkNative.screenerContextRecommendStrategies(raw, callback)); + return getRecommendStrategies("US"); + } + + /** Get the current user's saved screener strategies for the given market (default "US"). */ + public CompletableFuture getUserStrategies(String market) throws OpenApiException { + return AsyncCallback.executeTask((callback) -> SdkNative.screenerContextUserStrategies(raw, market, callback)); } - /** Get the current user's saved screener strategies. */ + /** Get the current user's saved screener strategies (defaults to US market). */ public CompletableFuture getUserStrategies() throws OpenApiException { - return AsyncCallback.executeTask((callback) -> SdkNative.screenerContextUserStrategies(raw, callback)); + return getUserStrategies("US"); } /** Get detail for one screener strategy by ID. */ diff --git a/java/src/screener_context.rs b/java/src/screener_context.rs index c6b66330d..2482094d1 100644 --- a/java/src/screener_context.rs +++ b/java/src/screener_context.rs @@ -2,14 +2,14 @@ use std::sync::Arc; use jni::{ JNIEnv, - objects::{JClass, JObject}, + objects::{JClass, JObject, JString}, }; use longbridge::{Config, ScreenerContext}; use crate::{ async_util, error::jni_result, - types::{JavaInteger, get_field}, + types::{FromJValue, JavaInteger, get_field}, }; struct ContextObj { @@ -41,12 +41,14 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_screenerContextRecom mut env: JNIEnv, _class: JClass, context: i64, + market: JString, callback: JObject, ) { jni_result(&mut env, (), |env| { let context = &*(context as *const ContextObj); + let market: String = FromJValue::from_jvalue(env, market.into())?; async_util::execute(env, callback, async move { - let resp = context.ctx.screener_recommend_strategies().await?; + let resp = context.ctx.screener_recommend_strategies(market).await?; Ok(resp) })?; Ok(()) @@ -58,12 +60,14 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_screenerContextUserS mut env: JNIEnv, _class: JClass, context: i64, + market: JString, callback: JObject, ) { jni_result(&mut env, (), |env| { let context = &*(context as *const ContextObj); + let market: String = FromJValue::from_jvalue(env, market.into())?; async_util::execute(env, callback, async move { - let resp = context.ctx.screener_user_strategies().await?; + let resp = context.ctx.screener_user_strategies(market).await?; Ok(resp) })?; Ok(()) @@ -107,7 +111,7 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_screenerContextSearc async_util::execute(env, callback, async move { let resp = context .ctx - .screener_search(market, strategy_id, page, size) + .screener_search(market, strategy_id, vec![], vec![], page, size) .await?; Ok(resp) })?; diff --git a/java/src/types/classes.rs b/java/src/types/classes.rs index 2b1dbab87..29c7751c2 100644 --- a/java/src/types/classes.rs +++ b/java/src/types/classes.rs @@ -2208,7 +2208,7 @@ impl_java_class!( longbridge::fundamental::OperatingFinancial, [ code, - counter_id, + symbol, currency, name, region, diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index 8650cc6be..40056b695 100644 --- a/nodejs/index.d.ts +++ b/nodejs/index.d.ts @@ -2067,13 +2067,25 @@ export declare class ScreenerContext { /** Create a new `ScreenerContext` */ static new(config: Config): ScreenerContext /** Get recommended built-in screener strategies */ - screenerRecommendStrategies(): Promise + screenerRecommendStrategies(market: string): Promise /** Get the current user's saved screener strategies */ - screenerUserStrategies(): Promise + screenerUserStrategies(market: string): Promise /** Get detail for one screener strategy by ID */ screenerStrategy(id: number): Promise - /** Search / screen securities using a strategy */ - screenerSearch(market: string, strategyId: number | undefined | null, page: number, size: number): Promise + /** + * Search / screen securities using a strategy or custom conditions. + * + * When `strategyId` is given (Mode A), the strategy is fetched from the AI + * endpoint and its filters drive the search. The market is taken from the + * strategy response. + * + * When `strategyId` is `null` / `undefined` (Mode B), `conditions` must be + * `ScreenerCondition` objects and `market` is used directly. + * + * `filter_` is stripped from every `items[].indicators[].key` in the + * response before it is returned. + */ + screenerSearch(market: string, strategyId: number | undefined | null, conditions: Array, show: Array, page: number, size: number): Promise /** Get all available screener indicator definitions */ screenerIndicators(): Promise } @@ -5107,6 +5119,21 @@ export interface ReplaceOrderOptions { remark?: string } +/** A filter condition for screener_search Mode B. */ +export interface ScreenerCondition { + /** Indicator key without filter_ prefix, e.g. "pettm", "roe", "macd_day" */ + key: string + /** Lower bound (empty = no lower bound) */ + min: string + /** Upper bound (empty = no upper bound) */ + max: string + /** + * Technical indicator params as JSON string (empty object "{}" for + * fundamental indicators) + */ + techValues: string +} + /** Screener indicator definitions response. `data` is a JSON string. */ export interface ScreenerIndicatorsResponse { /** Raw indicator definitions data (JSON string) */ diff --git a/nodejs/src/screener/context.rs b/nodejs/src/screener/context.rs index b5b16fbbd..92ab8b7ef 100644 --- a/nodejs/src/screener/context.rs +++ b/nodejs/src/screener/context.rs @@ -25,10 +25,11 @@ impl ScreenerContext { #[napi] pub async fn screener_recommend_strategies( &self, + market: String, ) -> Result { Ok(self .ctx - .screener_recommend_strategies() + .screener_recommend_strategies(market) .await .map_err(ErrorNewType)? .into()) @@ -36,10 +37,13 @@ impl ScreenerContext { /// Get the current user's saved screener strategies #[napi] - pub async fn screener_user_strategies(&self) -> Result { + pub async fn screener_user_strategies( + &self, + market: String, + ) -> Result { Ok(self .ctx - .screener_user_strategies() + .screener_user_strategies(market) .await .map_err(ErrorNewType)? .into()) @@ -56,18 +60,32 @@ impl ScreenerContext { .into()) } - /// Search / screen securities using a strategy + /// Search / screen securities using a strategy or custom conditions. + /// + /// When `strategyId` is given (Mode A), the strategy is fetched from the AI + /// endpoint and its filters drive the search. The market is taken from the + /// strategy response. + /// + /// When `strategyId` is `null` / `undefined` (Mode B), `conditions` must be + /// `ScreenerCondition` objects and `market` is used directly. + /// + /// `filter_` is stripped from every `items[].indicators[].key` in the + /// response before it is returned. #[napi] pub async fn screener_search( &self, market: String, strategy_id: Option, + conditions: Vec, + show: Vec, page: u32, size: u32, ) -> Result { + let lb_conditions: Vec = + conditions.into_iter().map(Into::into).collect(); Ok(self .ctx - .screener_search(market, strategy_id, page, size) + .screener_search(market, strategy_id, lb_conditions, show, page, size) .await .map_err(ErrorNewType)? .into()) diff --git a/nodejs/src/screener/types.rs b/nodejs/src/screener/types.rs index 3956b8887..f2a239c50 100644 --- a/nodejs/src/screener/types.rs +++ b/nodejs/src/screener/types.rs @@ -89,3 +89,33 @@ impl From for ScreenerIndicatorsResponse { } } } + +// ── ScreenerCondition ───────────────────────────────────────────── + +/// A filter condition for screener_search Mode B. +#[napi_derive::napi(object)] +#[derive(Debug, Clone, Default)] +pub struct ScreenerCondition { + /// Indicator key without filter_ prefix, e.g. "pettm", "roe", "macd_day" + pub key: String, + /// Lower bound (empty = no lower bound) + pub min: String, + /// Upper bound (empty = no upper bound) + pub max: String, + /// Technical indicator params as JSON string (empty object "{}" for + /// fundamental indicators) + pub tech_values: String, +} + +impl From for longbridge::screener::ScreenerCondition { + fn from(v: ScreenerCondition) -> Self { + let tv: serde_json::Value = + serde_json::from_str(&v.tech_values).unwrap_or(serde_json::json!({})); + Self { + key: v.key, + min: v.min, + max: v.max, + tech_values: tv, + } + } +} diff --git a/python/pysrc/longbridge/openapi.pyi b/python/pysrc/longbridge/openapi.pyi index aa3b1cb2d..fa4bb05fd 100644 --- a/python/pysrc/longbridge/openapi.pyi +++ b/python/pysrc/longbridge/openapi.pyi @@ -9570,6 +9570,8 @@ class OperatingFinancial: code: str """Ticker code""" + symbol: str + """Symbol in CODE.MARKET format (e.g. ``AAPL.US``)""" currency: str """Reporting currency""" name: str @@ -10530,6 +10532,28 @@ class RankListResponse: # ── ScreenerContext ─────────────────────────────────────────────── +class ScreenerCondition: + """A filter condition for :meth:`ScreenerContext.screener_search` Mode B.""" + + key: str + """Indicator key without ``filter_`` prefix, e.g. ``"pettm"``, ``"roe"``, ``"macd_day"``""" + min: str + """Lower bound (empty string = no lower bound)""" + max: str + """Upper bound (empty string = no upper bound)""" + tech_values: str + """Technical indicator params as JSON string. Use ``"{}"`` for fundamental indicators. + Example: ``'{"category": "goldenfork", "period": "day"}'``""" + + def __init__( + self, + key: str, + min: str = "", + max: str = "", + tech_values: str = "{}", + ) -> None: ... + + class ScreenerRecommendStrategiesResponse: """Recommended screener strategies response. ``data`` is a Python dict/list from JSON.""" @@ -10570,11 +10594,11 @@ class ScreenerContext: def __init__(self, config: Config) -> None: ... - def screener_recommend_strategies(self) -> ScreenerRecommendStrategiesResponse: - """Get recommended built-in screener strategies.""" + def screener_recommend_strategies(self, market: str) -> ScreenerRecommendStrategiesResponse: + """Get preset built-in screener strategies.""" ... - def screener_user_strategies(self) -> ScreenerUserStrategiesResponse: + def screener_user_strategies(self, market: str) -> ScreenerUserStrategiesResponse: """Get the current user's saved screener strategies.""" ... @@ -10586,10 +10610,23 @@ class ScreenerContext: self, market: str, strategy_id: Optional[int] = None, - page: int = 1, + conditions: List["ScreenerCondition"] = [], + show: List[str] = [], + page: int = 0, size: int = 20, ) -> ScreenerSearchResponse: - """Search / screen securities using a strategy.""" + """Search / screen securities using a strategy or custom conditions. + + When *strategy_id* is given (Mode A), the strategy is fetched from the AI + endpoint and its filters are forwarded to the search request. The + ``market`` is taken from the strategy response. + + When *strategy_id* is ``None`` (Mode B), *conditions* must be provided as + :class:`ScreenerCondition` objects and *market* is used directly. + + ``filter_`` is stripped from every ``items[].indicators[].key`` in the + response before it is returned. + """ ... def screener_indicators(self) -> ScreenerIndicatorsResponse: @@ -10605,12 +10642,14 @@ class AsyncScreenerContext: def screener_recommend_strategies( self, + market: str, ) -> Awaitable[ScreenerRecommendStrategiesResponse]: - """Get recommended built-in screener strategies. Returns awaitable.""" + """Get preset built-in screener strategies. Returns awaitable.""" ... def screener_user_strategies( self, + market: str, ) -> Awaitable[ScreenerUserStrategiesResponse]: """Get the current user's saved screener strategies. Returns awaitable.""" ... @@ -10625,10 +10664,24 @@ class AsyncScreenerContext: self, market: str, strategy_id: Optional[int] = None, - page: int = 1, + conditions: List["ScreenerCondition"] = [], + show: List[str] = [], + page: int = 0, size: int = 20, ) -> Awaitable[ScreenerSearchResponse]: - """Search / screen securities using a strategy. Returns awaitable.""" + """Search / screen securities using a strategy or custom conditions. + Returns awaitable. + + When *strategy_id* is given (Mode A), the strategy is fetched from the AI + endpoint and its filters are forwarded to the search request. The + ``market`` is taken from the strategy response. + + When *strategy_id* is ``None`` (Mode B), *conditions* must be provided as + :class:`ScreenerCondition` objects and *market* is used directly. + + ``filter_`` is stripped from every ``items[].indicators[].key`` in the + response before it is returned. + """ ... def screener_indicators(self) -> Awaitable[ScreenerIndicatorsResponse]: diff --git a/python/src/screener/context.rs b/python/src/screener/context.rs index 4030702f7..864f3210b 100644 --- a/python/src/screener/context.rs +++ b/python/src/screener/context.rs @@ -21,19 +21,22 @@ impl ScreenerContext { } /// Get recommended built-in screener strategies. - fn screener_recommend_strategies(&self) -> PyResult { + fn screener_recommend_strategies( + &self, + market: String, + ) -> PyResult { Ok(self .ctx - .screener_recommend_strategies() + .screener_recommend_strategies(market) .map_err(ErrorNewType)? .into()) } /// Get the current user's saved screener strategies. - fn screener_user_strategies(&self) -> PyResult { + fn screener_user_strategies(&self, market: String) -> PyResult { Ok(self .ctx - .screener_user_strategies() + .screener_user_strategies(market) .map_err(ErrorNewType)? .into()) } @@ -44,17 +47,21 @@ impl ScreenerContext { } /// Search / screen securities using a strategy. - #[pyo3(signature = (market, strategy_id = None, page = 1, size = 20))] + #[pyo3(signature = (market, strategy_id = None, conditions = vec![], show = vec![], page = 0, size = 20))] fn screener_search( &self, market: String, strategy_id: Option, + conditions: Vec, + show: Vec, page: u32, size: u32, ) -> PyResult { + let lb_conditions: Vec = + conditions.into_iter().map(Into::into).collect(); Ok(self .ctx - .screener_search(market, strategy_id, page, size) + .screener_search(market, strategy_id, lb_conditions, show, page, size) .map_err(ErrorNewType)? .into()) } diff --git a/python/src/screener/context_async.rs b/python/src/screener/context_async.rs index ddd026999..5c14848df 100644 --- a/python/src/screener/context_async.rs +++ b/python/src/screener/context_async.rs @@ -22,11 +22,11 @@ impl AsyncScreenerContext { } /// Get recommended built-in screener strategies. Returns awaitable. - fn screener_recommend_strategies(&self, py: Python<'_>) -> PyResult> { + fn screener_recommend_strategies(&self, py: Python<'_>, market: String) -> PyResult> { let ctx = self.ctx.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { Ok(ScreenerRecommendStrategiesResponse::from( - ctx.screener_recommend_strategies() + ctx.screener_recommend_strategies(market) .await .map_err(ErrorNewType)?, )) @@ -35,11 +35,13 @@ impl AsyncScreenerContext { } /// Get the current user's saved screener strategies. Returns awaitable. - fn screener_user_strategies(&self, py: Python<'_>) -> PyResult> { + fn screener_user_strategies(&self, py: Python<'_>, market: String) -> PyResult> { let ctx = self.ctx.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { Ok(ScreenerUserStrategiesResponse::from( - ctx.screener_user_strategies().await.map_err(ErrorNewType)?, + ctx.screener_user_strategies(market) + .await + .map_err(ErrorNewType)?, )) }) .map(|b| b.unbind()) @@ -57,19 +59,24 @@ impl AsyncScreenerContext { } /// Search / screen securities using a strategy. Returns awaitable. - #[pyo3(signature = (market, strategy_id = None, page = 1, size = 20))] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (market, strategy_id = None, conditions = vec![], show = vec![], page = 0, size = 20))] fn screener_search( &self, py: Python<'_>, market: String, strategy_id: Option, + conditions: Vec, + show: Vec, page: u32, size: u32, ) -> PyResult> { let ctx = self.ctx.clone(); + let lb_conditions: Vec = + conditions.into_iter().map(Into::into).collect(); pyo3_async_runtimes::tokio::future_into_py(py, async move { Ok(ScreenerSearchResponse::from( - ctx.screener_search(market, strategy_id, page, size) + ctx.screener_search(market, strategy_id, lb_conditions, show, page, size) .await .map_err(ErrorNewType)?, )) diff --git a/python/src/screener/mod.rs b/python/src/screener/mod.rs index 37c3193e9..9d7ca43e5 100644 --- a/python/src/screener/mod.rs +++ b/python/src/screener/mod.rs @@ -11,6 +11,7 @@ pub(crate) fn register_types(parent: &Bound) -> PyResult<()> { parent.add_class::()?; parent.add_class::()?; parent.add_class::()?; + parent.add_class::()?; parent.add_class::()?; parent.add_class::()?; Ok(()) diff --git a/python/src/screener/types.rs b/python/src/screener/types.rs index b87ca8600..69d0eecce 100644 --- a/python/src/screener/types.rs +++ b/python/src/screener/types.rs @@ -105,3 +105,47 @@ impl From for ScreenerIndicatorsResponse { } } } + +// ── ScreenerCondition ───────────────────────────────────────────── + +/// A filter condition for screener_search Mode B. +#[pyclass(get_all, set_all, from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct ScreenerCondition { + /// Indicator key without filter_ prefix, e.g. "pettm", "roe", "macd_day" + pub key: String, + /// Lower bound (empty = no lower bound) + pub min: String, + /// Upper bound (empty = no upper bound) + pub max: String, + /// Technical indicator params as JSON string (empty object "{}" for + /// fundamental indicators) + pub tech_values: String, +} + +#[pymethods] +impl ScreenerCondition { + #[new] + #[pyo3(signature = (key, min="", max="", tech_values="{}"))] + pub fn new(key: String, min: &str, max: &str, tech_values: &str) -> Self { + Self { + key, + min: min.to_string(), + max: max.to_string(), + tech_values: tech_values.to_string(), + } + } +} + +impl From for longbridge::screener::ScreenerCondition { + fn from(v: ScreenerCondition) -> Self { + let tv: serde_json::Value = + serde_json::from_str(&v.tech_values).unwrap_or(serde_json::json!({})); + Self { + key: v.key, + min: v.min, + max: v.max, + tech_values: tv, + } + } +} diff --git a/rust/src/blocking/screener.rs b/rust/src/blocking/screener.rs index b5cbd3e1b..b80ca3fd9 100644 --- a/rust/src/blocking/screener.rs +++ b/rust/src/blocking/screener.rs @@ -35,15 +35,21 @@ impl ScreenerContextSync { } /// Get recommended built-in screener strategies - pub fn screener_recommend_strategies(&self) -> Result { + pub fn screener_recommend_strategies( + &self, + market: impl Into + Send + 'static, + ) -> Result { self.rt - .call(|ctx| async move { ctx.screener_recommend_strategies().await }) + .call(|ctx| async move { ctx.screener_recommend_strategies(market).await }) } /// Get the current user's saved screener strategies - pub fn screener_user_strategies(&self) -> Result { + pub fn screener_user_strategies( + &self, + market: impl Into + Send + 'static, + ) -> Result { self.rt - .call(|ctx| async move { ctx.screener_user_strategies().await }) + .call(|ctx| async move { ctx.screener_user_strategies(market).await }) } /// Get detail for one screener strategy by ID @@ -57,11 +63,14 @@ impl ScreenerContextSync { &self, market: impl Into + Send + 'static, strategy_id: Option, + conditions: Vec, + show: Vec, page: u32, size: u32, ) -> Result { self.rt.call(move |ctx| async move { - ctx.screener_search(market, strategy_id, page, size).await + ctx.screener_search(market, strategy_id, conditions, show, page, size) + .await }) } diff --git a/rust/src/fundamental/types.rs b/rust/src/fundamental/types.rs index b6332d4d2..bbae2ec51 100644 --- a/rust/src/fundamental/types.rs +++ b/rust/src/fundamental/types.rs @@ -873,8 +873,12 @@ pub struct OperatingItem { pub struct OperatingFinancial { /// Ticker code (may be empty) pub code: String, - /// Raw counter ID (may be empty) - pub counter_id: String, + /// Symbol in `CODE.MARKET` format (may be empty) + #[serde( + rename = "counter_id", + deserialize_with = "deserialize_counter_id_as_symbol" + )] + pub symbol: String, /// Reporting currency pub currency: String, /// Company name diff --git a/rust/src/market/context.rs b/rust/src/market/context.rs index 4edba7306..e0a27b470 100644 --- a/rust/src/market/context.rs +++ b/rust/src/market/context.rs @@ -391,9 +391,27 @@ impl MarketContext { pub async fn rank_categories(&self) -> Result { #[derive(Serialize)] struct Empty {} - let raw: serde_json::Value = self + let mut raw: serde_json::Value = self .get("/v1/quote/market/rank/categories", Empty {}) .await?; + // Strip the "ib_" prefix from all key fields so callers get clean keys + // that can be passed back to rank_list without the prefix. + if let Some(tags) = raw["first_tags"].as_array_mut() { + for tag in tags.iter_mut() { + if let Some(k) = tag["key"].as_str() { + let stripped = k.strip_prefix("ib_").unwrap_or(k).to_string(); + tag["key"] = serde_json::Value::String(stripped); + } + if let Some(subs) = tag["second_tags"].as_array_mut() { + for sub in subs.iter_mut() { + if let Some(sk) = sub["key"].as_str() { + let stripped = sk.strip_prefix("ib_").unwrap_or(sk).to_string(); + sub["key"] = serde_json::Value::String(stripped); + } + } + } + } + } Ok(RankCategoriesResponse { data: raw }) } @@ -413,11 +431,18 @@ impl MarketContext { delay_bmp: &'static str, need_article: &'static str, } + let key_str = key.into(); + // Add "ib_" prefix if the caller passed a clean key (without it). + let api_key = if key_str.starts_with("ib_") { + key_str + } else { + format!("ib_{key_str}") + }; let raw: serde_json::Value = self .get( "/v1/quote/market/rank/list", Query { - key: key.into(), + key: api_key, delay_bmp: "false", need_article: if need_article { "true" } else { "false" }, }, diff --git a/rust/src/screener/context.rs b/rust/src/screener/context.rs index d17746aaf..6506f7fa0 100644 --- a/rust/src/screener/context.rs +++ b/rust/src/screener/context.rs @@ -46,7 +46,7 @@ impl ScreenerContext { self.0.log_subscriber.clone() } - async fn get(&self, path: &'static str, query: Q) -> Result + async fn get(&self, path: &str, query: Q) -> Result where R: DeserializeOwned + Send + Sync + 'static, Q: Serialize + Send + Sync, @@ -63,7 +63,7 @@ impl ScreenerContext { .0) } - async fn post(&self, path: &'static str, body: B) -> Result + async fn post(&self, path: &str, body: B) -> Result where R: DeserializeOwned + Send + Sync + 'static, B: std::fmt::Debug + Serialize + Send + Sync + 'static, @@ -82,16 +82,24 @@ impl ScreenerContext { // ── screener_recommend_strategies ───────────────────────────── - /// Get recommended built-in screener strategies. + /// Get preset built-in screener strategies. /// - /// Path: `GET /v1/quote/screener/strategies/recommend` + /// Path: `GET /v1/quote/ai/screener/strategies/recommend` pub async fn screener_recommend_strategies( &self, + market: impl Into, ) -> Result { #[derive(Serialize)] - struct Empty {} + struct Query { + market: String, + } let raw: serde_json::Value = self - .get("/v1/quote/screener/strategies/recommend", Empty {}) + .get( + "/v1/quote/ai/screener/strategies/recommend", + Query { + market: market.into(), + }, + ) .await?; Ok(ScreenerRecommendStrategiesResponse { data: raw }) } @@ -100,12 +108,22 @@ impl ScreenerContext { /// Get the current user's saved screener strategies. /// - /// Path: `GET /v1/quote/screener/strategies/mine` - pub async fn screener_user_strategies(&self) -> Result { + /// Path: `GET /v1/quote/ai/screener/strategies/mine` + pub async fn screener_user_strategies( + &self, + market: impl Into, + ) -> Result { #[derive(Serialize)] - struct Empty {} + struct Query { + market: String, + } let raw: serde_json::Value = self - .get("/v1/quote/screener/strategies/mine", Empty {}) + .get( + "/v1/quote/ai/screener/strategies/mine", + Query { + market: market.into(), + }, + ) .await?; Ok(ScreenerUserStrategiesResponse { data: raw }) } @@ -114,65 +132,257 @@ impl ScreenerContext { /// Get detail for one screener strategy by ID. /// - /// Path: `GET /v1/quote/screener/strategy?id=` + /// Path: `GET /v1/quote/ai/screener/strategy/{id}` + /// + /// The `filter_` prefix is stripped from every `filters[].key` before + /// returning so callers see clean keys like `pettm` instead of + /// `filter_pettm`. pub async fn screener_strategy(&self, id: i64) -> Result { + let path = format!("/v1/quote/ai/screener/strategy/{id}"); #[derive(Serialize)] - struct Query { - id: i64, + struct Empty {} + let mut raw: serde_json::Value = self.get(&path, Empty {}).await?; + // Strip filter_ prefix from filter.filters[].key + if let Some(filters) = raw["filter"]["filters"].as_array_mut() { + for f in filters.iter_mut() { + if let Some(k) = f["key"].as_str() { + let stripped = k.strip_prefix("filter_").unwrap_or(k).to_string(); + f["key"] = serde_json::Value::String(stripped); + } + } } - let raw: serde_json::Value = self - .get("/v1/quote/screener/strategy", Query { id }) - .await?; Ok(ScreenerStrategyResponse { data: raw }) } // ── screener_search ─────────────────────────────────────────── - /// Search / screen securities using a strategy. + /// Default return columns always included in a screener search request. + const DEFAULT_RETURNS: &'static [&'static str] = &[ + "filter_prevclose", + "filter_prevchg", + "filter_marketcap", + "filter_salesgrowthyoy", + "filter_pettm", + "filter_pbmrq", + "filter_industry", + ]; + + /// Search / screen securities using a strategy or custom conditions. + /// + /// Path: `POST /v1/quote/ai/screener/search` + /// + /// ## Mode A — strategy ID given /// - /// Path: `POST /v1/quote/screener/search` + /// When `strategy_id` is `Some`, the strategy is fetched from + /// `GET /v1/quote/ai/screener/strategy/{id}` and its `filter.filters[]` + /// are forwarded to the search endpoint together with + /// [`DEFAULT_RETURNS`]. The `market` is taken from the strategy + /// response (falls back to `"US"` if absent or `"-"`). /// - /// When `strategy_id` is `Some`, it is included in the request body. - /// When `None`, only `market`, `page`, and `size` are sent (custom - /// filter support is out of scope for this SDK). + /// ## Mode B — custom conditions + /// + /// When `strategy_id` is `None` and `conditions` is non-empty each + /// element is either a `"KEY:MIN:MAX"` string **or** a JSON object with + /// `key`, `min`, `max`, and optional `tech_values` fields. The + /// supplied `market` is used directly. `DEFAULT_RETURNS` plus every + /// condition key are added to the `returns` list. + /// + /// The `filter_` prefix is stripped from every `items[].indicators[].key` + /// in the response before it is returned to the caller. + /// + /// `page` is 0-indexed. pub async fn screener_search( &self, market: impl Into, strategy_id: Option, + conditions: Vec, + show: Vec, page: u32, size: u32, ) -> Result { - #[derive(Debug, Serialize)] - struct Body { - market: String, - #[serde(skip_serializing_if = "Option::is_none")] - strategy_id: Option, - page: u32, - size: u32, + let market: String = market.into(); + + // ── build filters and effective market ────────────────────────────── + let (effective_market, filters) = if let Some(sid) = strategy_id { + // Mode A: fetch strategy from AI endpoint + let path = format!("/v1/quote/ai/screener/strategy/{sid}"); + #[derive(Serialize)] + struct Empty {} + let strategy: serde_json::Value = self.get(&path, Empty {}).await?; + + let mkt_val = strategy["market"].as_str().unwrap_or("US").to_uppercase(); + let mkt = if mkt_val.is_empty() || mkt_val == "-" { + "US".to_string() + } else { + mkt_val + }; + + let mut filters: Vec = Vec::new(); + if let Some(f) = strategy["filter"]["filters"].as_array() { + for ind in f { + let key = ind["key"].as_str().unwrap_or("").to_string(); + if key.is_empty() { + continue; + } + let min = ind["min"].as_str().unwrap_or("").to_string(); + let max = ind["max"].as_str().unwrap_or("").to_string(); + let tech_values = if ind["tech_values"].is_object() { + ind["tech_values"].clone() + } else { + serde_json::json!({}) + }; + filters.push(serde_json::json!({ + "key": key, + "min": min, + "max": max, + "tech_values": tech_values, + })); + } + } + (mkt, filters) + } else { + // Mode B: typed condition objects + let filters: Vec = conditions + .iter() + .filter(|c| !c.key.is_empty()) + .map(|c| { + let api_key = if c.key.starts_with("filter_") { + c.key.clone() + } else { + format!("filter_{}", c.key) + }; + let tv = if c.tech_values.is_object() { + c.tech_values.clone() + } else { + serde_json::json!({}) + }; + serde_json::json!({ + "key": api_key, + "min": c.min, + "max": c.max, + "tech_values": tv, + }) + }) + .collect(); + (market, filters) + }; + + // ── build returns list ─────────────────────────────────────────────── + let mut returns: Vec = Self::DEFAULT_RETURNS + .iter() + .map(|s| s.to_string()) + .collect(); + // add keys from filters (with filter_ prefix for the API) + for f in &filters { + if let Some(k) = f["key"].as_str() { + let api_key = if k.starts_with("filter_") { + k.to_string() + } else { + format!("filter_{k}") + }; + if !returns.contains(&api_key) { + returns.push(api_key); + } + } } - let raw: serde_json::Value = self - .post( - "/v1/quote/screener/search", - Body { - market: market.into(), - strategy_id, - page, - size, - }, - ) - .await?; - Ok(ScreenerSearchResponse { data: raw }) + // add extra columns requested by the caller + for s in &show { + let api_key = if s.starts_with("filter_") { + s.clone() + } else { + format!("filter_{s}") + }; + if !returns.contains(&api_key) { + returns.push(api_key); + } + } + + // ── POST request ──────────────────────────────────────────────────── + let body = serde_json::json!({ + "market": effective_market, + "filters": filters, + "returns": returns, + "page": page, + "size": size, + }); + + let raw: serde_json::Value = self.post("/v1/quote/ai/screener/search", body).await?; + Ok(ScreenerSearchResponse { + data: strip_filter_prefix_from_search_results(raw), + }) } // ── screener_indicators ─────────────────────────────────────── /// Get all available screener indicator definitions. /// - /// Path: `GET /v1/quote/screener/indicators` + /// Path: `GET /v1/quote/ai/screener/indicators` + /// + /// Post-processing applied before returning: + /// - `filter_` prefix is stripped from every `groups[].indicators[].key` + /// - `tech_values` is built from `tech_indicators`: `{tech_key: [{value, + /// label}]}` pub async fn screener_indicators(&self) -> Result { #[derive(Serialize)] struct Empty {} - let raw: serde_json::Value = self.get("/v1/quote/screener/indicators", Empty {}).await?; + let mut raw: serde_json::Value = self + .get("/v1/quote/ai/screener/indicators", Empty {}) + .await?; + if let Some(groups) = raw["groups"].as_array_mut() { + for group in groups.iter_mut() { + if let Some(indicators) = group["indicators"].as_array_mut() { + for ind in indicators.iter_mut() { + // Strip filter_ prefix from key + if let Some(k) = ind["key"].as_str() { + let stripped = k.strip_prefix("filter_").unwrap_or(k).to_string(); + ind["key"] = serde_json::Value::String(stripped); + } + // Build tech_values from tech_indicators + if let Some(tech_inds) = ind["tech_indicators"].as_array().cloned() { + let tv: serde_json::Map = tech_inds + .iter() + .filter_map(|ti| { + let key = ti["tech_key"].as_str()?.to_string(); + let opts: Vec = ti["tech_items"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .map(|item| { + serde_json::json!({ + "value": item["item_value"].as_str().unwrap_or(""), + "label": item["item_name"].as_str().unwrap_or(""), + }) + }) + .collect(); + Some((key, serde_json::Value::Array(opts))) + }) + .collect(); + if !tv.is_empty() { + ind["tech_values"] = serde_json::Value::Object(tv); + } + } + } + } + } + } Ok(ScreenerIndicatorsResponse { data: raw }) } } + +/// Strip `filter_` prefix from every `items[].indicators[].key` in a raw +/// screener search result. +fn strip_filter_prefix_from_search_results(mut raw: serde_json::Value) -> serde_json::Value { + if let Some(items) = raw["items"].as_array_mut() { + for item in items.iter_mut() { + if let Some(indicators) = item["indicators"].as_array_mut() { + for ind in indicators.iter_mut() { + if let Some(k) = ind["key"].as_str() { + let stripped = k.strip_prefix("filter_").unwrap_or(k).to_string(); + ind["key"] = serde_json::Value::String(stripped); + } + } + } + } + } + raw +} diff --git a/rust/src/screener/types.rs b/rust/src/screener/types.rs index 0f71a2c0a..a60ac41da 100644 --- a/rust/src/screener/types.rs +++ b/rust/src/screener/types.rs @@ -39,6 +39,31 @@ pub struct ScreenerStrategyResponse { pub data: serde_json::Value, } +// ── screener_condition ─────────────────────────────────────────── + +/// A filter condition for [`crate::ScreenerContext::screener_search`] Mode B. +/// +/// `key` is the indicator key (without the `filter_` prefix, e.g. `"pettm"`). +/// `min` / `max` bound the range; leave empty for an open bound. +/// `tech_values` is used for technical indicators (e.g. MACD/RSI); pass an +/// empty map `{}` for fundamental indicators. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ScreenerCondition { + /// Indicator key without `filter_` prefix, e.g. `"pettm"`, `"roe"`, + /// `"macd_day"` + pub key: String, + /// Lower bound (empty string = no lower bound) + #[serde(default)] + pub min: String, + /// Upper bound (empty string = no upper bound) + #[serde(default)] + pub max: String, + /// Technical indicator parameters (empty map for fundamental indicators). + /// Example: `{"category": "goldenfork", "period": "day"}` + #[serde(default)] + pub tech_values: serde_json::Value, +} + // ── screener_search ─────────────────────────────────────────────── /// Response for [`crate::ScreenerContext::screener_search`] From ae02853eebb739586da82f48ed54e7a3c0748052 Mon Sep 17 00:00:00 2001 From: Hogan Date: Tue, 2 Jun 2026 17:21:41 +0800 Subject: [PATCH 2/3] fix(calendar): expose next_date cursor in finance_calendar response (#532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - The `/v1/quote/finance_calendar` endpoint paginates via a `next_date` cursor, but all SDK bindings were silently dropping it, making multi-page range queries return incomplete data (e.g. CRM.US, PDD.US, MRVL.US missing for a 2026-05-23→05-30 range) - `next_date` is now returned as-is in `CalendarEventsResponse` across all language SDKs so callers can follow the cursor themselves ## Changes | File | Change | |------|--------| | `rust/src/calendar/types.rs` | Add `next_date` to `CalendarEventsResponse` | | `rust/src/calendar/context.rs` | Revert to single request (no internal pagination) | | `c/src/calendar_context/types.rs` | Add `next_date` to C binding owned/FFI types | | `c/csrc/include/longbridge.h` | Add `next_date` to `lb_calendar_events_response_t` | | `cpp/include/calendar_context.hpp` | Add `next_date` to C++ `CalendarEventsResponse` struct | | `cpp/src/calendar_context.cpp` | Populate `next_date` from C response in callback | | `nodejs/index.d.ts` | Add `nextDate: string` to `CalendarEventsResponse` | | `python/pysrc/longbridge/openapi.pyi` | Add `next_date: str` to `CalendarEventsResponse` | | `java/.../CalendarEventsResponse.java` | Add `public String nextDate` | ## Related - Go SDK fix: longbridge/openapi-go#fix/finance-calendar-pagination - Issue: https://github.com/longbridge/developers/issues/1045#issuecomment-4591933632 --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- c/csrc/include/longbridge.h | 4 ++++ c/src/calendar_context/types.rs | 6 ++++++ cpp/include/calendar_context.hpp | 3 ++- cpp/src/calendar_context.cpp | 1 + .../com/longbridge/calendar/CalendarEventsResponse.java | 2 ++ java/src/types/classes.rs | 3 ++- nodejs/index.d.ts | 2 ++ python/pysrc/longbridge/openapi.pyi | 2 ++ rust/src/calendar/context.rs | 3 +++ rust/src/calendar/types.rs | 4 ++++ 10 files changed, 28 insertions(+), 2 deletions(-) diff --git a/c/csrc/include/longbridge.h b/c/csrc/include/longbridge.h index cb8db05c0..81fefdb02 100644 --- a/c/csrc/include/longbridge.h +++ b/c/csrc/include/longbridge.h @@ -7110,6 +7110,10 @@ typedef struct lb_calendar_events_response_t { * Number of elements in the `list` array. */ uintptr_t num_list; + /** + * Pagination cursor; pass as start to fetch the next page, empty when there are no more pages. + */ + const char *next_date; } lb_calendar_events_response_t; /** diff --git a/c/src/calendar_context/types.rs b/c/src/calendar_context/types.rs index 6d1c559e7..014839660 100644 --- a/c/src/calendar_context/types.rs +++ b/c/src/calendar_context/types.rs @@ -198,16 +198,21 @@ pub struct CCalendarEventsResponse { pub list: *const CCalendarDateGroup, /// Number of elements in the `list` array. pub num_list: usize, + /// Pagination cursor; pass as start to fetch the next page, empty when + /// there are no more pages. + pub next_date: *const c_char, } pub(crate) struct CCalendarEventsResponseOwned { date: CString, list: CVec, + next_date: CString, } impl From for CCalendarEventsResponseOwned { fn from(v: CalendarEventsResponse) -> Self { Self { date: v.date.into(), list: v.list.into(), + next_date: v.next_date.into(), } } } @@ -218,6 +223,7 @@ impl ToFFI for CCalendarEventsResponseOwned { date: self.date.to_ffi_type(), list: self.list.to_ffi_type(), num_list: self.list.len(), + next_date: self.next_date.to_ffi_type(), } } } diff --git a/cpp/include/calendar_context.hpp b/cpp/include/calendar_context.hpp index f4c4ea65a..b42e28e53 100644 --- a/cpp/include/calendar_context.hpp +++ b/cpp/include/calendar_context.hpp @@ -26,7 +26,8 @@ struct CalendarEventInfo { std::string symbol; std::string market; std::string c /// Calendar events grouped by date. struct CalendarDateGroup { std::string date; int32_t count; std::vector infos; }; /// Response for finance_calendar — events grouped by date within the requested range. -struct CalendarEventsResponse { std::string date; std::vector list; }; +/// Response for finance_calendar — events grouped by date within the requested range. +struct CalendarEventsResponse { std::string date; std::vector list; std::string next_date; }; /// Financial calendar context — earnings, dividends, splits, IPOs, macro data. class CalendarContext { diff --git a/cpp/src/calendar_context.cpp b/cpp/src/calendar_context.cpp index ed9163f44..a4e48a29e 100644 --- a/cpp/src/calendar_context.cpp +++ b/cpp/src/calendar_context.cpp @@ -29,6 +29,7 @@ void CalendarContext::finance_calendar(CalendarCategory category, const std::str auto* r = (const lb_calendar_events_response_t*)res->data; CalendarEventsResponse resp; resp.date = r->date; + resp.next_date = r->next_date ? r->next_date : ""; for (size_t i = 0; i < r->num_list; ++i) { CalendarDateGroup grp; grp.date = r->list[i].date; grp.count = r->list[i].count; for (size_t j = 0; j < r->list[i].num_infos; ++j) { diff --git a/java/javasrc/src/main/java/com/longbridge/calendar/CalendarEventsResponse.java b/java/javasrc/src/main/java/com/longbridge/calendar/CalendarEventsResponse.java index 830a06e0d..b290b5065 100644 --- a/java/javasrc/src/main/java/com/longbridge/calendar/CalendarEventsResponse.java +++ b/java/javasrc/src/main/java/com/longbridge/calendar/CalendarEventsResponse.java @@ -6,4 +6,6 @@ public class CalendarEventsResponse { public String date; /** Per-day event groups. */ public CalendarDateGroup[] list; + /** Pagination cursor; pass as start to fetch the next page, empty when there are no more pages. */ + public String nextDate; } diff --git a/java/src/types/classes.rs b/java/src/types/classes.rs index 29c7751c2..141d5f839 100644 --- a/java/src/types/classes.rs +++ b/java/src/types/classes.rs @@ -1261,7 +1261,8 @@ impl_java_class!( [ date, #[java(objarray)] - list + list, + next_date ] ); diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index 40056b695..5de5f15f7 100644 --- a/nodejs/index.d.ts +++ b/nodejs/index.d.ts @@ -3397,6 +3397,8 @@ export interface CalendarEventsResponse { date: string /** Per-day event groups */ list: Array + /** Pagination cursor; pass as start to fetch the next page, empty when there are no more pages */ + nextDate: string } export declare const enum CashFlowDirection { diff --git a/python/pysrc/longbridge/openapi.pyi b/python/pysrc/longbridge/openapi.pyi index fa4bb05fd..5641e2bf9 100644 --- a/python/pysrc/longbridge/openapi.pyi +++ b/python/pysrc/longbridge/openapi.pyi @@ -10759,6 +10759,8 @@ class CalendarEventsResponse: """Start date of the query window""" list: list[CalendarDateGroup] """Per-day event groups""" + next_date: str + """Pagination cursor; pass as start to fetch the next page, empty when there are no more pages""" class CalendarCategory: diff --git a/rust/src/calendar/context.rs b/rust/src/calendar/context.rs index 6ed5bea05..5cbf6d90f 100644 --- a/rust/src/calendar/context.rs +++ b/rust/src/calendar/context.rs @@ -48,6 +48,9 @@ impl CalendarContext { /// Get financial calendar events. /// + /// The endpoint is paginated via `next_date`. When the returned + /// `next_date` is non-empty, pass it as `start` to fetch the next page. + /// /// Path: `GET /v1/quote/finance_calendar` pub async fn finance_calendar( &self, diff --git a/rust/src/calendar/types.rs b/rust/src/calendar/types.rs index 889f3e47e..b68a6488e 100644 --- a/rust/src/calendar/types.rs +++ b/rust/src/calendar/types.rs @@ -12,6 +12,10 @@ pub struct CalendarEventsResponse { pub date: String, /// Per-day event groups pub list: Vec, + /// Pagination cursor; pass as `start` to fetch the next page, empty when + /// there are no more pages + #[serde(default)] + pub next_date: String, } /// Events for one calendar date From e6c84eccc6d9f7766aad8bcd7b45ab2e13c964ad Mon Sep 17 00:00:00 2001 From: Hogan Date: Tue, 2 Jun 2026 18:48:11 +0800 Subject: [PATCH 3/3] chore: release 4.2.2 (#533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Bump version to `4.2.2` and add CHANGELOG entry. ## Changes in 4.2.2 ### Fixed - **All languages:** `CalendarEventsResponse` now exposes `next_date` cursor — callers can pass it as `start` (with the same `end`) to fetch the next page of `/v1/quote/finance_calendar` results - **All languages:** `CalendarEventInfo.symbol` now returns standard symbol format (e.g. `CRM.US`) instead of raw `counter_id` format (e.g. `ST/US/CRM`) ## Related - PR #532 --- CHANGELOG.md | 7 +++++++ Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b9bfb04..661e9fc3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.2.2] + +### Fixed + +- **All languages:** `CalendarEventsResponse` now exposes `next_date` cursor — callers can pass it as `start` (with the same `end`) to fetch the next page of `/v1/quote/finance_calendar` results +- **All languages:** `CalendarEventInfo.symbol` now returns standard symbol format (e.g. `CRM.US`) instead of raw `counter_id` format (e.g. `ST/US/CRM`) + ## [4.2.1] ### Changed diff --git a/Cargo.toml b/Cargo.toml index fabc86433..932f6f842 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["rust", "python", "nodejs", "java", "c"] [workspace.package] -version = "4.2.1" +version = "4.2.2" edition = "2024" [profile.release]