Skip to content

feat(signal): Add SignalContext for strategy signals and catalyst facts - #577

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

feat(signal): Add SignalContext for strategy signals and catalyst facts#577
huacnlee merged 1 commit into
mainfrom
feat/signal-context

Conversation

@huacnlee

@huacnlee huacnlee commented Aug 25, 2026

Copy link
Copy Markdown
Member

Adds a standalone SignalContext covering the three signal APIs.

What you get

Method Endpoint Purpose
signals(opts) GET /v1/signals Query signals — filter by symbol, strategy, catalyst and time range; page with limit / offset. Returns the page plus a total.
signal(id) GET /v1/signals/{signal_id} One signal, including the full strategy analysis in json_data.
security_facts(opts) GET /v1/facts/security_facts A security's fact (catalyst) events — anomaly detections, factor readings, sources and summaries.

A 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, not counter_id. The response carries counter_id, stock_symbol and symbol with the same value; only symbol is exposed.
  • Outlook is an enum. All 250 signals available on staging map onto exactly five labels, matching the core_conclusion.outlook_enum scale 1..=5 inside json_data: Strong bullish, Bullish, Neutral, Bearish, Strong bearish. Serialization round-trips back to the wire label.
  • Time-range parameters are OffsetDateTime, following GetHistoryExecutionsOptions, and serialize as RFC3339 (start_time=2024-01-15T10:30:00Z).
  • Millisecond timestamps. created_at / updated_at arrive as millisecond epoch strings, which the existing second-resolution serde_utils::timestamp cannot parse, so a timestamp_ms helper was added next to it.
  • risk_level and status stay untyped — staging shows only "" / "R4" for the former and a constant 1 for the latter, which is not enough to pin down either domain. Happy to turn them into enums once the value sets are confirmed.
  • json_data stays a string. The per-signal analysis document is strategy-specific and runs to ~10 KB; it is carried verbatim rather than modelled.
  • Facts are raw JSON objects. The payload is fact-type specific (news / fundamental / technical) with different fields per type.

Verification

signals and signal were 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_facts returns {"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.
  • On staging the signals filters (symbol_name, strategy_id, strategy_name, catalyst_name, catalyst_type, start_time, end_time) appear to be ignored — total and the returned rows are unchanged even for a nonexistent symbol. limit / offset do 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

@huacnlee
huacnlee force-pushed the feat/signal-context branch from 6bbf03d to dbc0cc5 Compare August 25, 2026 10:58
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
huacnlee force-pushed the feat/signal-context branch from dbc0cc5 to 1d80457 Compare August 25, 2026 11:02
@huacnlee
huacnlee merged commit 7aa17e3 into main Aug 25, 2026
56 checks passed
@huacnlee
huacnlee deleted the feat/signal-context branch August 25, 2026 11:26
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>
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