From 1d80457b9182e4c2becf2bf9b0a689eb8b9958cf Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 25 Aug 2026 18:54:10 +0800 Subject: [PATCH] feat(signal): add SignalContext for strategy signals and catalyst facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone `SignalContext` covering the three signal APIs: - `signals` (`GET /v1/signals`) — query signals with symbol / strategy / catalyst / time-range filters and `limit`/`offset` paging. - `signal` (`GET /v1/signals/{signal_id}`) — one signal, including the full strategy analysis carried in `json_data`. - `security_facts` (`GET /v1/facts/security_facts`) — the fact (catalyst) events behind signals for one security. `Signal` is fully typed against the live response. `counter_id` is dropped in favour of the `symbol` the API already returns. `created_at` / `updated_at` arrive as millisecond epochs, so `serde_utils` gains a `timestamp_ms` helper alongside the existing second-resolution one. Facts are returned verbatim as JSON objects — the payload is fact-type specific (news / fundamental / technical) and carries different fields per type. Only the Rust SDK (async + blocking) is wired up; the Python / Node.js / Java / C bindings are not included. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + rust/src/blocking/mod.rs | 2 + rust/src/blocking/signal.rs | 48 ++++++++ rust/src/lib.rs | 2 + rust/src/serde_utils.rs | 21 ++++ rust/src/signal/context.rs | 121 ++++++++++++++++++++ rust/src/signal/mod.rs | 8 ++ rust/src/signal/types.rs | 213 ++++++++++++++++++++++++++++++++++++ 8 files changed, 416 insertions(+) create mode 100644 rust/src/blocking/signal.rs create mode 100644 rust/src/signal/context.rs create mode 100644 rust/src/signal/mod.rs create mode 100644 rust/src/signal/types.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fea73b90..5e792a851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Rust:** add `SignalContext` — strategy signals and the catalyst facts behind them. `signals` (`GET /v1/signals`) queries signals with symbol / strategy / catalyst / time-range filters and paging; `signal` (`GET /v1/signals/{signal_id}`) returns one signal including the full strategy analysis in `json_data`; `security_facts` (`GET /v1/facts/security_facts`) lists a security's fact (catalyst) events. Bindings for the other languages are not wired up yet - **All languages:** add `TradeContext.submit_multileg` (`POST /v1/trade/order/multileg`) — submit a multi-leg option combination order (vertical spreads, straddles, strangles, collars, etc.) whose legs are placed together as a single strategy order. Takes `side`, `order_type`, `submitted_quantity`, `strategy` (`MultiLegStrategy`), a list of legs (`symbol` + `ratio_quantity`), and optional `submitted_price` / `remark` / `client_request_id`; returns the existing `SubmitOrderResponse` - **All languages:** order queries and the order push now expose multi-leg strategy information. `Order` (from `today_orders` / `history_orders`), `OrderDetail` (from `order_detail`), and the `PushOrderChanged` order-changed event gain an optional `multi_leg` field (`MultiLegInfo`) — present only for multi-leg option combination orders — carrying the `strategy`, `strategy_name`, `multileg_id`, `code`, and the combination `legs` (each with `symbol`, `side`, `position`, `ratio_quantity`, `strike_price`, `expire_date`, and `contract_direction`). Adds the `MultiLegStrategy`, `MultiLegPosition`, and `ContractDirection` enums - **All languages:** add grid-trading support via a standalone `GridContext` — submit / replace / cancel / suspend / restart grid orders, list orders (paged and by IDs), fetch order detail and trigger history, submit the strategy risk-disclosure questionnaire, and query the security (symbol) info (`symbol_info` → `GridSymbolInfo`: name, last price, lot sizes, price-step rules, channel/authorization) needed to build a grid order. Available in the Rust, Python, Node.js, Java, and C/C++ bindings diff --git a/rust/src/blocking/mod.rs b/rust/src/blocking/mod.rs index 1aa1390a1..11cc01fdf 100644 --- a/rust/src/blocking/mod.rs +++ b/rust/src/blocking/mod.rs @@ -15,6 +15,7 @@ mod quote; mod runtime; mod screener; mod sharelist; +mod signal; mod trade; pub use agent::AgentContextSync; @@ -31,4 +32,5 @@ pub use portfolio::PortfolioContextSync; pub use quote::QuoteContextSync; pub use screener::ScreenerContextSync; pub use sharelist::SharelistContextSync; +pub use signal::SignalContextSync; pub use trade::TradeContextSync; diff --git a/rust/src/blocking/signal.rs b/rust/src/blocking/signal.rs new file mode 100644 index 000000000..fdf03c708 --- /dev/null +++ b/rust/src/blocking/signal.rs @@ -0,0 +1,48 @@ +use std::sync::Arc; + +use tokio::sync::mpsc; + +use crate::{ + Config, Result, + blocking::runtime::BlockingRuntime, + signal::{SignalContext, types::*}, +}; + +/// Blocking signal context +pub struct SignalContextSync { + rt: BlockingRuntime, +} + +impl SignalContextSync { + /// Create a [`SignalContextSync`] + pub fn new(config: Arc) -> Result { + let rt = BlockingRuntime::try_new( + move || { + let ctx = SignalContext::new(config); + let (tx, rx) = mpsc::unbounded_channel::(); + std::mem::forget(tx); + Ok::<_, crate::Error>((ctx, rx)) + }, + |_: std::convert::Infallible| {}, + )?; + Ok(Self { rt }) + } + + /// Query signals + pub fn signals(&self, opts: SignalsOptions) -> Result { + self.rt + .call(move |ctx| async move { ctx.signals(opts).await }) + } + + /// Get one signal by ID + pub fn signal(&self, signal_id: impl Into + Send + 'static) -> Result { + self.rt + .call(move |ctx| async move { ctx.signal(signal_id).await }) + } + + /// List the fact (catalyst) events for one security + pub fn security_facts(&self, opts: SecurityFactsOptions) -> Result> { + self.rt + .call(move |ctx| async move { ctx.security_facts(opts).await }) + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 4a61c82a0..55e015b62 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -36,6 +36,7 @@ pub mod portfolio; pub mod quote; pub mod screener; pub mod sharelist; +pub mod signal; pub mod trade; pub use agent::AgentContext; @@ -62,6 +63,7 @@ pub use quote::{QuoteContext, USCryptoOverview}; pub use rust_decimal::Decimal; pub use screener::ScreenerContext; pub use sharelist::SharelistContext; +pub use signal::SignalContext; pub use trade::{ GetUSHistoryOrders, GetUSRealizedPLOptions, QueryUSOrdersOptions, QueryUSOrdersResponse, TradeContext, USAssetOverview, USCashEntry, USCryptoEntry, USOrderDetailResponse, diff --git a/rust/src/serde_utils.rs b/rust/src/serde_utils.rs index 9d093b5a7..6085113b3 100644 --- a/rust/src/serde_utils.rs +++ b/rust/src/serde_utils.rs @@ -111,6 +111,27 @@ pub(crate) mod timestamp { } } +pub(crate) mod timestamp_ms { + use super::*; + + pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let value = value.parse::().map_err(D::Error::custom)?; + OffsetDateTime::from_unix_timestamp_nanos(i128::from(value) * 1_000_000) + .map_err(D::Error::custom) + } + + pub(crate) fn serialize(datetime: &OffsetDateTime, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(&(datetime.unix_timestamp_nanos() / 1_000_000)) + } +} + pub(crate) mod timestamp_opt { use super::*; diff --git a/rust/src/signal/context.rs b/rust/src/signal/context.rs new file mode 100644 index 000000000..ea9df7be1 --- /dev/null +++ b/rust/src/signal/context.rs @@ -0,0 +1,121 @@ +use std::sync::Arc; + +use longbridge_httpcli::{HttpClient, Json, Method}; +use serde::Deserialize; +use tracing::{Subscriber, dispatcher, instrument::WithSubscriber}; + +use crate::{Config, Result, signal::types::*}; + +struct InnerSignalContext { + http_cli: HttpClient, + log_subscriber: Arc, +} + +impl Drop for InnerSignalContext { + fn drop(&mut self) { + dispatcher::with_default(&self.log_subscriber.clone().into(), || { + tracing::info!("signal context dropped"); + }); + } +} + +/// Signal context — strategy signals and the catalyst facts behind them. +#[derive(Clone)] +pub struct SignalContext(Arc); + +impl SignalContext { + /// Create a [`SignalContext`] + pub fn new(config: Arc) -> Self { + let log_subscriber = config.create_log_subscriber("signal"); + dispatcher::with_default(&log_subscriber.clone().into(), || { + tracing::info!(language = ?config.language, "creating signal context"); + }); + let ctx = Self(Arc::new(InnerSignalContext { + http_cli: config.create_http_client(), + log_subscriber, + })); + dispatcher::with_default(&ctx.0.log_subscriber.clone().into(), || { + tracing::info!("signal context created"); + }); + ctx + } + + /// Returns the log subscriber + #[inline] + pub fn log_subscriber(&self) -> Arc { + self.0.log_subscriber.clone() + } + + /// Query signals, filtered by symbol, strategy, catalyst and time range. + /// + /// Path: `GET /v1/signals` + pub async fn signals(&self, opts: SignalsOptions) -> Result { + Ok(self + .0 + .http_cli + .request(Method::GET, "/v1/signals") + .query_params(opts) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } + + /// Get one signal by ID, including the full analysis in + /// [`Signal::json_data`]. + /// + /// Path: `GET /v1/signals/{signal_id}` + pub async fn signal(&self, signal_id: impl Into) -> Result { + #[derive(Debug, Deserialize)] + struct Response { + signal: Signal, + } + + let signal_id = signal_id.into(); + Ok(self + .0 + .http_cli + .request(Method::GET, format!("/v1/signals/{signal_id}")) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0 + .signal) + } + + /// List the fact (catalyst) events for one security — anomaly detections, + /// factor readings, data sources and natural-language summaries. + /// + /// Facts are what strategies react to: a signal names the fact that + /// triggered it in [`Signal::key_fact_id`]. + /// + /// Each fact is returned verbatim as a JSON object; the payload is + /// fact-type specific (news, fundamental, technical) and carries different + /// fields per type. + /// + /// Path: `GET /v1/facts/security_facts` + pub async fn security_facts( + &self, + opts: SecurityFactsOptions, + ) -> Result> { + #[derive(Debug, Deserialize)] + struct Response { + #[serde(default)] + facts: Vec, + } + + Ok(self + .0 + .http_cli + .request(Method::GET, "/v1/facts/security_facts") + .query_params(opts) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0 + .facts) + } +} diff --git a/rust/src/signal/mod.rs b/rust/src/signal/mod.rs new file mode 100644 index 000000000..550ac3a54 --- /dev/null +++ b/rust/src/signal/mod.rs @@ -0,0 +1,8 @@ +//! Strategy signals and the catalyst facts behind them + +mod context; +/// Signal and fact types +pub mod types; + +pub use context::SignalContext; +pub use types::{Outlook, SecurityFactsOptions, Signal, SignalsOptions, SignalsResponse}; diff --git a/rust/src/signal/types.rs b/rust/src/signal/types.rs new file mode 100644 index 000000000..6e5a0ebd1 --- /dev/null +++ b/rust/src/signal/types.rs @@ -0,0 +1,213 @@ +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumString}; +use time::OffsetDateTime; + +use crate::serde_utils; + +/// Options for [`crate::SignalContext::signals`] +/// +/// Every field is a filter; leaving one unset removes that filter. +#[derive(Debug, Clone, Default, Serialize)] +pub struct SignalsOptions { + /// Filter by stock symbol in `ticker.region` format, e.g. `AAPL.US` or + /// `700.HK`. If omitted, returns signals for all symbols. + #[serde(skip_serializing_if = "Option::is_none")] + pub symbol_name: Option, + /// Filter by strategy id, e.g. `buffett-value`. Preferred over the + /// deprecated `strategy_name`; takes precedence when both are provided. + #[serde(skip_serializing_if = "Option::is_none")] + pub strategy_id: Option, + /// Filter by strategy name. If omitted, returns signals from all + /// strategies. + /// + /// Deprecated in favour of [`SignalsOptions::strategy_id`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub strategy_name: Option, + /// Filter by the catalyst name that triggered the signal. If omitted, + /// signals with any catalyst name are returned. + #[serde(skip_serializing_if = "Option::is_none")] + pub catalyst_name: Option, + /// Filter by the catalyst type that triggered the signal, e.g. `News`, + /// `Fundamental`, `Technical`. If omitted, signals with any catalyst type + /// are returned. + #[serde(skip_serializing_if = "Option::is_none")] + pub catalyst_type: Option, + /// Only return signals created at or after this time. If omitted, no + /// lower bound. + #[serde( + skip_serializing_if = "Option::is_none", + with = "serde_utils::rfc3339_opt" + )] + pub start_time: Option, + /// Only return signals created at or before this time. If omitted, no + /// upper bound. + #[serde( + skip_serializing_if = "Option::is_none", + with = "serde_utils::rfc3339_opt" + )] + pub end_time: Option, + /// Maximum number of results to return. Defaults to 20. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Number of results to skip for pagination. Defaults to 0. + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, +} + +/// Direction a strategy expects the security to take. +/// +/// Wire values are the five labels the API returns, matching the +/// `core_conclusion.outlook_enum` scale 1..=5 inside [`Signal::json_data`]. +#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, EnumString, Display)] +pub enum Outlook { + /// Unknown + Unknown, + /// Strong bullish (`outlook_enum` 1) + #[strum(serialize = "Strong bullish")] + StrongBullish, + /// Bullish (`outlook_enum` 2) + Bullish, + /// Neutral (`outlook_enum` 3) + Neutral, + /// Bearish (`outlook_enum` 4) + Bearish, + /// Strong bearish (`outlook_enum` 5) + #[strum(serialize = "Strong bearish")] + StrongBearish, +} + +impl_default_for_enum_string!(Outlook); +impl_serde_for_enum_string!(Outlook); + +/// One signal: a strategy's take on a security, triggered by a catalyst. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Signal { + /// Signal ID, e.g. `sign_992_1a00c9425c3_48ab`. Pass it to + /// [`crate::SignalContext::signal`] for the full record. + pub id: String, + /// Security symbol, e.g. `992.HK` + #[serde(default)] + pub symbol: String, + /// Company name + #[serde(default)] + pub company_name: String, + /// Market the security trades in, e.g. `HK` + #[serde(default)] + pub market: String, + /// Signal headline + #[serde(default)] + pub title: String, + /// Natural-language summary of the signal, in Markdown + #[serde(default)] + pub summary: String, + /// Strategy ID that produced the signal + #[serde(default)] + pub strategy_id: String, + /// Strategy name that produced the signal + #[serde(default)] + pub strategy_name: String, + /// Who recommended the signal; empty for strategy-generated signals + #[serde(default)] + pub recommend_by: String, + /// Strategy expression, e.g. `992.HK:GROWTH:long` + #[serde(default)] + pub expression: String, + /// ID of the fact that triggered the signal + #[serde(default)] + pub key_fact_id: String, + /// Display name of the catalyst that triggered the signal + #[serde(default)] + pub key_catalyst: String, + /// Price the analysis was based on + #[serde(default)] + pub analysis_price: f64, + /// Conservative-scenario target price + #[serde(default)] + pub conservative_price: f64, + /// Benchmark-scenario target price + #[serde(default)] + pub benchmark_price: f64, + /// Optimistic-scenario target price + #[serde(default)] + pub optimistic_price: f64, + /// Outlook the strategy takes on the security + #[serde(default)] + pub outlook: Outlook, + /// Outlook label in the caller's language — the localized rendering of + /// [`Signal::outlook`] + #[serde(default)] + pub outlook_desc: String, + /// Risk level, e.g. `R4` + #[serde(default)] + pub risk_level: String, + /// Signal status + #[serde(default)] + pub status: i32, + /// Display control flag + #[serde(default)] + pub display_control: i32, + /// Full analysis behind the signal, as a JSON document: strategy fit + /// scores, valuation scenarios, evidence sources and related fact IDs. + /// Carried verbatim because its shape is strategy-specific. + #[serde(default)] + pub json_data: String, + /// Creation time + #[serde( + serialize_with = "time::serde::rfc3339::serialize", + deserialize_with = "serde_utils::timestamp_ms::deserialize" + )] + pub created_at: OffsetDateTime, + /// Last update time + #[serde( + serialize_with = "time::serde::rfc3339::serialize", + deserialize_with = "serde_utils::timestamp_ms::deserialize" + )] + pub updated_at: OffsetDateTime, +} + +/// A page of signals returned by [`crate::SignalContext::signals`] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignalsResponse { + /// Signals on this page + #[serde(default)] + pub signals: Vec, + /// Total number of signals matching the filters, for paging with + /// [`SignalsOptions::offset`] + #[serde(default)] + pub total: i64, +} + +/// Options for [`crate::SignalContext::security_facts`] +#[derive(Debug, Clone, Default, Serialize)] +pub struct SecurityFactsOptions { + /// The security to query, in `ticker.region` format, e.g. `AAPL.US` or + /// `700.HK`. Required. + pub symbol: String, + /// Start of the query window. If omitted, the query includes the earliest + /// available data. + #[serde( + skip_serializing_if = "Option::is_none", + with = "serde_utils::rfc3339_opt" + )] + pub begin_time: Option, + /// End of the query window. If omitted, the query returns the latest data. + #[serde( + skip_serializing_if = "Option::is_none", + with = "serde_utils::rfc3339_opt" + )] + pub end_time: Option, + /// Maximum number of facts to return. When more facts fall inside the time + /// range, only the latest `limit` are returned. Defaults to 100. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +impl SecurityFactsOptions { + /// Create a [`SecurityFactsOptions`] for one security + pub fn new(symbol: impl Into) -> Self { + Self { + symbol: symbol.into(), + ..Default::default() + } + } +}