feat(signal): Type SecurityFact, and align Signal with the RPC contract - #578
Merged
Conversation
This was referenced Aug 25, 2026
huacnlee
force-pushed
the
feat/signal-facts-types
branch
from
August 25, 2026 11:57
8b0095a to
d7c6bda
Compare
`security_facts` returned `Vec<serde_json::Value>` because no payload was
available when the API landed. Now that it serves data — and the RPC
definition is at hand — the shape is modelled:
- `SecurityFact` — fact id, type, direction, occurrence time, the securities
it is about, the factors behind it, its data sources, and `nl_info`.
- `FactFactor` carries `factor_groups`, the side it points to, its trigger
condition, and the `AnomalyDetection` behind it.
- `FactType` (News / Fundamental / Technical) and `FactDirection` (long /
short / neutral) are enums; both fall back to `Unknown` rather than failing
the request.
- `occur_time` is an `OffsetDateTime` parsed from RFC3339.
`nl_info.summary`, `invest_anal` and `eli_explain` arrive as a JSON array of
`{tag, value}` carried inside a string. The raw strings are kept as-is so an
upstream change can never fail the call, with `summary_tags()` /
`invest_anal_tags()` / `eli_explain_tags()` to read them — they return an
empty list rather than erroring on anything unexpected.
The same pass aligns `Signal` with the RPC contract:
- `status` becomes a `SignalStatus` enum, with an `Unknown` fallback.
- `total` is `i32`, matching `QuerySignalsResponse`.
- `risk_level` and `display_control` are dropped — neither is in the contract
and neither is served in production.
- `catalyst_name` filters on the triggering factor's name (e.g.
`EARNINGS_RELEASED`), not the prose `key_catalyst` a signal displays. Both
fields now say so, since the two are easy to confuse.
`symbols_info` drops `counter_id` in favour of the `symbol` already present,
matching how `Signal` is modelled. Symbol parameter docs say "security symbol"
rather than naming a `ticker.region` format.
Verified against production: 100 facts for `700.HK` — every field in the
payload is modelled, every `fact_type` and `direction` resolves to a variant,
and all three `nl_info` documents parse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
huacnlee
force-pushed
the
feat/signal-facts-types
branch
from
August 25, 2026 12:14
d7c6bda to
39a8809
Compare
SecurityFact instead of returning raw JSONSecurityFact, and align Signal with the RPC contract
huacnlee
added a commit
to longbridge/longbridge-mcp
that referenced
this pull request
Aug 26, 2026
Three read-only tools over the SDK's `SignalContext`.
| Tool | Endpoint | Notes |
| --- | --- | --- |
| `signals` | `GET /v1/signals` | Symbol / strategy / catalyst /
time-range filters, `limit` + `offset` paging |
| `signal_detail` | `GET /v1/signals/{signal_id}` | Adds `analysis` —
the strategy analysis as real JSON |
| `security_facts` | `GET /v1/facts/security_facts` | The catalyst
events a signal reacts to |
Two decisions worth review:
- **The list drops `json_data`.** The per-signal analysis document is
~10 KB, so a default page of 20 would be ~200 KB of tool output. The
list view omits it and its description points at `signal_detail`;
`signal_detail` keeps it.
- **Embedded documents are unwrapped.** `json_data`, and the `{tag,
value}` documents in a fact's `nl_info.summary` / `nl_info.invest_anal`,
all arrive as JSON inside a string. They are parsed so the caller does
not pay a second parse, and anything that fails to parse survives as its
original string (covered by tests).
Time-range parameters are taken as RFC3339 strings and rejected with
`invalid_params` when malformed, instead of being forwarded verbatim.
All three are read-only analysis tools, so they join the `/v2` allowlist
and the General scope, with zh-CN / zh-HK translations.
## Verification
`cargo test` (151 passed), `cargo clippy --all-features --all-targets`
clean, and `/mcp/tools.json` lists all three tools with their
translations and scope membership. The underlying endpoints were
exercised against production through the CLI: `signals` returns real
data and its filters work.
Two server-side issues found while testing, neither caused by this
change:
- `?limit=` / `?offset=` return `500 code 13` on both access points, so
the paging parameters this tool exposes currently fail upstream (the
default page of 20 comes back fine).
- `/v1/facts/security_facts` returns `{"facts": []}` for every symbol,
including ones whose signals carry a `key_fact_id`.
Related: longbridge/openapi#578 types `SecurityFact`; this tool passes
the payload through either way, so it does not block.
🤖 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.
Follow-up to #577, now checked against the
lb.facts.mcp/QuerySignalsRPC definitions and against production data.SecurityFactis typedsecurity_factsreturnedVec<serde_json::Value>because the endpoint served an emptyfactsarray for every symbol. It now serves data:Matches the
Fact/Factor/AnomalyDetection/SymbolInfo/DataSource/NLInfomessages field for field.NLInfo's optionalinvest_anal/eli_explaindeserialize from an absent key.nl_infokeeps its raw strings.summary,invest_analandeli_explaineach 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 andsummary_tags()/invest_anal_tags()/eli_explain_tags()read them, returning an empty list on anything unexpected.Signalfollows the contractstatusis aSignalStatusenum — pending / active / deleted / ai-failed / filtered-by-manual / ai-submit-failed, per the field's documented values, with anUnknownfallback.totalisi32, matchingQuerySignalsResponse.risk_levelanddisplay_controlare dropped. Neither is in theSignalmessage, and production returns neither — they only ever appeared on staging.catalyst_namevskey_catalystis now documented. The filter matches the triggering factor's name (EARNINGS_RELEASED,macd_12_26_9);key_catalystis prose for display (Q1 Revenue Surge). Filtering by the value you see inkey_catalystreturns nothing, so both fields say which is which.symbols_infodropscounter_idin favour of thesymbolalready present, matching howSignalis modelled. Symbol parameter docs say "security symbol" instead of naming aticker.regionformat.Verification
Against 100 production facts for
700.HK:fact_typeresolves for all 100 (News 91 / Technical 7 / Fundamental 2);directionresolves for all 100 (long 85 / short 9 / neutral 6), and so does every nestedfactors[].long_short_direction. Nothing fell through toUnknown.summary_tags()andinvest_anal_tags()parse on all 100,eli_explain_tags()on the 93 carrying the field.neutralandnl_info.eli_explainwere both found this way — neither appeared in the first sample this PR started from.Two things to flag on the server side
json_data. TheSignalmessage is annotated as list-scenario abbreviated info "不含 json_data / recommend_by / expression", butGET /v1/signalsreturns all three;json_dataaverages 11 KB per signal, so a default page of 20 is ~220 KB.limit/offsetreturn500 code 13on both access points, while every other filter works. Reported separately.🤖 Generated with Claude Code