Skip to content

feat(signal): Type SecurityFact, and align Signal with the RPC contract - #578

Merged
huacnlee merged 1 commit into
mainfrom
feat/signal-facts-types
Aug 25, 2026
Merged

feat(signal): Type SecurityFact, and align Signal with the RPC contract#578
huacnlee merged 1 commit into
mainfrom
feat/signal-facts-types

Conversation

@huacnlee

@huacnlee huacnlee commented Aug 25, 2026

Copy link
Copy Markdown
Member

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:

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

`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
huacnlee force-pushed the feat/signal-facts-types branch from d7c6bda to 39a8809 Compare August 25, 2026 12:14
@huacnlee huacnlee changed the title feat(signal): Type SecurityFact instead of returning raw JSON feat(signal): Type SecurityFact, and align Signal with the RPC contract Aug 25, 2026
@huacnlee
huacnlee merged commit 5929410 into main Aug 25, 2026
56 checks passed
@huacnlee
huacnlee deleted the feat/signal-facts-types branch August 25, 2026 12:21
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant