feat(signal): Add SignalContext for strategy signals and catalyst facts - #577
Merged
Conversation
huacnlee
force-pushed
the
feat/signal-context
branch
from
August 25, 2026 10:58
6bbf03d to
dbc0cc5
Compare
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) <noreply@anthropic.com>
huacnlee
force-pushed
the
feat/signal-context
branch
from
August 25, 2026 11:02
dbc0cc5 to
1d80457
Compare
huacnlee
added a commit
that referenced
this pull request
Aug 25, 2026
…ntract (#578) Follow-up to #577, now checked against the `lb.facts.mcp` / `QuerySignals` RPC definitions and against production data. ## `SecurityFact` is typed `security_facts` returned `Vec<serde_json::Value>` because the endpoint served an empty `facts` array for every symbol. It now serves data: ```rust pub struct SecurityFact { pub fact_id: String, pub fact_type: FactType, // News | Fundamental | Technical pub direction: FactDirection, // long | short | neutral pub occur_time: OffsetDateTime, pub symbols_info: Vec<FactSymbol>, pub factors: Vec<FactFactor>, pub data_source: Vec<FactDataSource>, pub nl_info: FactNlInfo, } ``` Matches the `Fact` / `Factor` / `AnomalyDetection` / `SymbolInfo` / `DataSource` / `NLInfo` messages field for field. `NLInfo`'s optional `invest_anal` / `eli_explain` deserialize from an absent key. **`nl_info` keeps its raw strings.** `summary`, `invest_anal` and `eli_explain` each carry a JSON array of `{tag, value}` *inside* a string. Parsing them during deserialization would let an upstream change fail the whole call, so the strings are kept verbatim and `summary_tags()` / `invest_anal_tags()` / `eli_explain_tags()` read them, returning an empty list on anything unexpected. ## `Signal` follows the contract - **`status` is a `SignalStatus` enum** — pending / active / deleted / ai-failed / filtered-by-manual / ai-submit-failed, per the field's documented values, with an `Unknown` fallback. - **`total` is `i32`**, matching `QuerySignalsResponse`. - **`risk_level` and `display_control` are dropped.** Neither is in the `Signal` message, and production returns neither — they only ever appeared on staging. - **`catalyst_name` vs `key_catalyst` is now documented.** The filter matches the triggering factor's name (`EARNINGS_RELEASED`, `macd_12_26_9`); `key_catalyst` is prose for display (`Q1 Revenue Surge`). Filtering by the value you see in `key_catalyst` returns nothing, so both fields say which is which. `symbols_info` drops `counter_id` in favour of the `symbol` already present, matching how `Signal` is modelled. Symbol parameter docs say "security symbol" instead of naming a `ticker.region` format. ## Verification Against **100 production facts** for `700.HK`: - Every field in the response is modelled — the response's field-path set matches the struct exactly. - `fact_type` resolves for all 100 (News 91 / Technical 7 / Fundamental 2); `direction` resolves for all 100 (long 85 / short 9 / neutral 6), and so does every nested `factors[].long_short_direction`. Nothing fell through to `Unknown`. - `summary_tags()` and `invest_anal_tags()` parse on all 100, `eli_explain_tags()` on the 93 carrying the field. `neutral` and `nl_info.eli_explain` were both found this way — neither appeared in the first sample this PR started from. ## Two things to flag on the server side - **The list still returns `json_data`.** The `Signal` message is annotated as list-scenario abbreviated info "不含 json_data / recommend_by / expression", but `GET /v1/signals` returns all three; `json_data` averages **11 KB per signal**, so a default page of 20 is ~220 KB. - **`limit` / `offset` return `500 code 13`** on both access points, while every other filter works. Reported separately. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a standalone
SignalContextcovering the three signal APIs.What you get
signals(opts)GET /v1/signalslimit/offset. Returns the page plus atotal.signal(id)GET /v1/signals/{signal_id}json_data.security_facts(opts)GET /v1/facts/security_factsA signal is a strategy's take on a security, triggered by a catalyst — it carries a title, a Markdown summary, an outlook, a risk level and conservative / benchmark / optimistic target prices, and names the fact that triggered it in
key_fact_id.Notes on the modelling
symbol, notcounter_id. The response carriescounter_id,stock_symbolandsymbolwith the same value; onlysymbolis exposed.Outlookis an enum. All 250 signals available on staging map onto exactly five labels, matching thecore_conclusion.outlook_enumscale 1..=5 insidejson_data:Strong bullish,Bullish,Neutral,Bearish,Strong bearish. Serialization round-trips back to the wire label.OffsetDateTime, followingGetHistoryExecutionsOptions, and serialize as RFC3339 (start_time=2024-01-15T10:30:00Z).created_at/updated_atarrive as millisecond epoch strings, which the existing second-resolutionserde_utils::timestampcannot parse, so atimestamp_mshelper was added next to it.risk_levelandstatusstay untyped — staging shows only""/"R4"for the former and a constant1for the latter, which is not enough to pin down either domain. Happy to turn them into enums once the value sets are confirmed.json_datastays a string. The per-signal analysis document is strategy-specific and runs to ~10 KB; it is carried verbatim rather than modelled.Verification
signalsandsignalwere verified against staging (LONGBRIDGE_ENV=staging) — real responses deserialize, round-trip, and produce RFC3339 timestamps; the query builder emits the documented parameter names.Two things could not be verified there and are worth a second pair of eyes:
GET /v1/facts/security_factsreturns{"facts": []}on staging for every symbol tried (including symbols that have signals, with and without a time range), so only the envelope is confirmed — not the item shape.signalsfilters (symbol_name,strategy_id,strategy_name,catalyst_name,catalyst_type,start_time,end_time) appear to be ignored —totaland the returned rows are unchanged even for a nonexistent symbol.limit/offsetdo work. The SDK sends all of them as documented.Scope
Only the Rust SDK (async + blocking). The Python / Node.js / Java / C bindings are not included — happy to follow up with those.
🤖 Generated with Claude Code