# Stock News Timeline in Python: Merging Hourly Heat, Opinion Summaries and Media Hits into One Event Log (CATL Example)

When a stock suddenly gets talked about, the first useful question is not "why" but "in what order did things happen?" Which hour did discussion pick up? Did a media flash come before or after the first institutional comment? How broad was participation that day compared with the past month?

Answering that means building a stock news timeline: one table, sorted by time, where every row says which data feed produced it and what it counted. This tutorial builds one in Python from Lava's read-only heat endpoints, using CATL (`300750.SZ`) purely as the query example. The result is an auditable event log that a quant script or an AI agent can reproduce on demand. All readings shown are illustrative.

## Why a Single Heat Number Is Not Enough

A heat score tells you *how much* a stock was discussed in a window. It does not tell you *when* inside that window, *who* was involved, or whether media coverage and institution/KOL commentary moved together. For quant research and institution/KOL heat monitoring, the ordering of events is often the more useful raw material: it lets you align attention with your own price or volume data later, on your own terms.

Lava splits that information across several feeds. None of them is a timeline on its own, but together they are.

## The Five Feeds and Their Time Granularity

| Role in the timeline | MCP tool | HTTP path | Granularity |
| --- | --- | --- | --- |
| Skeleton: which hour heated up | `get_stock_heat_windows` | `/heat/windows` | six rolling windows plus `hourly_24h` |
| Anchors: what was being said | `get_stock_news_heat` | `/opinions` | per record (`topic_time`) |
| Media anchors: who named the stock | `get_news_stock_hits` | `/news/hits` | per news item (publish time) |
| Breadth: how many distinct sources | `get_agency_coverage` | `/coverage/stocks` | daily |
| Background: where today sits historically | `get_stock_heat_panel` | `/panel/daily` | calendar day |

All five accept the same identifier, either `ts_code` or `stock_name`. Use `ts_code`. Short names change and occasionally collide; codes join cleanly with your own market data.

## Design Rule: Two Clocks, Never Mixed

The biggest source of broken timelines is mixing granularities. Keep two indexes:

- an **hourly index** for things that happen at a moment (opinion records, news hits, hourly buckets);
- a **daily index** for things that are only defined per day (coverage counts, daily panel rows).

Then normalize every timestamp to timezone-aware `Asia/Shanghai`:

- `meta.server_time` in each response envelope carries `+08:00`;
- `topic_time` on `/opinions` records is Beijing time;
- `/news/hits` rows have a publish time (check the exact column name in the OpenAPI schema);
- `hourly_24h` covers the 24 hours before the call, with bucket boundaries as returned;
- coverage and panel dates are Asia/Shanghai calendar days.

Do not spread a daily coverage number across hours. Attach it to the date and join it at read time.

## Step-by-Step: Building the Stock News Timeline in Python

### 1. Shared request helper

```python
# timeline.py - illustrative; base URL and token are placeholders
import os, json, requests, pandas as pd

BASE = os.environ.get("LAVA_BASE", "<YOUR_LAVA_BASE>/subscribe/v1")
HEADERS = {"Authorization": "Bearer " + os.environ.get("LAVA_API_KEY", "<YOUR_TOKEN>")}
CODE = "300750.SZ"
TZ = "Asia/Shanghai"
calls = []  # audit trail

def get(path, **params):
    resp = requests.get(BASE + path, params=params, headers=HEADERS, timeout=60).json()
    assert resp["ok"], (path, resp.get("code"))
    calls.append({"path": path, "params": params,
                  "request_id": resp["meta"]["request_id"],
                  "server_time": resp["meta"]["server_time"]})
    return resp["data"]

def rows(data):
    return data.get("list", []) if isinstance(data, dict) else data
```

Every call appends its parameters, `request_id` and `server_time` to `calls`. That list is what makes the timeline auditable later.

### 2. The hourly skeleton

```python
win = get("/heat/windows", ts_code=CODE, min_score=1050)
hourly = win.get("hourly_24h", [])  # bucket column names: see OpenAPI
```

`min_score=1050` only moves the threshold for the "high-score institutions/KOLs" column. Total mentions are unaffected.

### 3. Opinion records as anchors

```python
ops = get("/opinions", ts_code=CODE, days=3, only_this_stock=0, limit=200)
records = rows(ops)
```

Each record carries `opinion_id`, `topic_time`, `agency_id`, `agency_score`, `stocks`, `concepts`, a `summary` (up to 120 characters) and `detail` (up to 600). `only_this_stock=0` returns records where the stock is the first-mentioned name; `=1` keeps records that mention only this stock. The two give different counts, so write down which one the timeline used.

### 4. Media hits

```python
news = get("/news/hits", ts_code=CODE, hours=72, limit=200)
```

With a stock specified, `hours` defaults to 72 and goes up to 168; `src` pins a single outlet. Rows include `news_id`, publish time, outlet and matched stocks. There is no headline or body text.

### 5. Daily breadth and background

```python
cov = get("/coverage/stocks", ts_code=CODE, days=30, min_score=1050)
panel = get("/panel/daily", type="stock", codes=CODE,
            **{"from": "2026-08-17", "to": "2026-11-14"})
```

Coverage returns `cov_7d`, `cov_30d`, `new_cov_cum` and `high_score_ratio_30d` as counts only. The panel returns `mentions`, `agencies`, `weighted_heat` and `is_first_mention_day`, for ranges of at most 92 days.

### 6. Merge into one event log

```python
def to_ts(value):
    ts = pd.Timestamp(value)
    return ts.tz_convert(TZ) if ts.tzinfo else ts.tz_localize(TZ)

events = []
for r in records:
    events.append({"ts": to_ts(r["topic_time"]), "feed": "opinions",
                   "value": r.get("agency_score"), "ref": r.get("opinion_id")})
for n in rows(news):
    # publish-time and outlet column names assumed; confirm in OpenAPI
    events.append({"ts": to_ts(n.get("pub_time")), "feed": "news",
                   "value": n.get("src"), "ref": n.get("news_id")})

log = pd.DataFrame(events).sort_values("ts").reset_index(drop=True)
log["hour"] = log["ts"].dt.floor("h")
log.to_csv("catl_timeline.csv", index=False)
json.dump(calls, open("catl_timeline_calls.json", "w"), ensure_ascii=False, indent=2)
```

The `to_ts` helper avoids a classic bug: calling `tz_localize` on a string that already has `+08:00` either raises or double-shifts. Left-join the hourly buckets on `hour` and each hour now lists its own records and media hits.

## What the Output Looks Like

| Time (Asia/Shanghai) | Feed | Content |
| --- | --- | --- |
| 11-13 09:00–10:00 | `/heat/windows` | 4 mentions in the hour |
| 11-13 10:12 | `/news/hits` | Outlet A, `news_id=<id>` |
| 11-13 10:20 | `/opinions` | `agency_score=1080`, summary: `<summary>` |
| 11-13 10:00–11:00 | `/heat/windows` | 17 mentions in the hour |
| 11-13 10:47 | `/news/hits` | Outlet B |
| 11-13 (day) | `/coverage/stocks` | `cov_7d=46`, `cov_30d=131` |

The table answers "around the hour discussion rose, when did media hits and opinion records appear?" It deliberately does not answer "why" or "what next".

## Boundaries to Respect

- **No news text.** The only prose in the timeline comes from the `/opinions` `summary` field. Do not backfill articles from elsewhere.
- **Hourly buckets cover only the last 24 hours.** For older hours, count `/opinions` records by `topic_time` yourself and label that series separately; it is not the same definition as `hourly_24h`.
- **Panel weighting is not point-in-time.** `weighted_heat` on `/panel/daily` uses current source scores. For a strict historical weighted view use `/hot/weighted` with `as_of_date`.

## Quality Checks Before You Save

- **Timezone:** spot-check three rows; all should end in `+08:00`.
- **Truncation:** if `/opinions` or `/news/hits` returned exactly `limit` rows, shorten `days`/`hours`, split by date range, or split news by `src`.
- **Plausibility:** an hour's `hourly_24h` count and that hour's record count should be the same order of magnitude; if not, note the `only_this_stock` setting.
- **Audit trail:** five `request_id`s saved. Five calls are billed five times at 0.005 yuan each; non-2xx responses are not billed.

## Agent Prompt Version

```text
Build a 72-hour news timeline for ts_code=<code>:
- call get_stock_heat_windows, get_stock_news_heat(days=3, limit=200),
  get_news_stock_hits(hours=72), get_agency_coverage(days=30)
- convert every time to Asia/Shanghai; output a table: time / feed / count or id
- quote only the summary field; list news as outlet + time
- end with each call's request_id and server_time
- describe ordering only; no directional interpretation
```

## FAQ

**Why do I get fewer `/opinions` rows than `data.heat.mentions`?**
The list is capped by `limit` (max 200) while the summary counts the whole range. If the row count equals the limit, narrow the window.

**Does it matter whether the media hit or the opinion came first?**
Record it as a fact, but the two feeds are collected and matched differently. Order shows when each appeared, not causation.

**Can I run this for a basket?**
Yes, but calls grow linearly with the number of stocks. For the daily background, `/panel/daily` accepts up to 20 codes in one call.

## Conclusion

A stock news timeline is five read-only feeds stitched together: hourly heat as the skeleton, opinion summaries and media hits as anchors, coverage and the daily panel as context. Normalize time first, keep hourly and daily data apart, log every request, and the result is a reproducible event log you can join with your own quant data. If you want to see what the monitored view of this stock looks like before writing code, open the [CATL sentiment monitor](https://lava.ai-dolphin.com/monitor/catl-sentiment).

---

中文简介：本文用 Python 把熔岩的逐小时热度（/heat/windows）、个股消息面摘要（/opinions）、新闻点名（/news/hits）、覆盖度与日频面板合并成一张按时间排序、可审计的单股消息面时间线，以宁德时代 300750.SZ 为查询示例，重点讲时区统一、小时/日两套索引、截断检查与调用元数据留存。熔岩是量化交易必备的机构/大V 热度监测工具。
