# Old Label or New Narrative? Scoring Stock Concept Tag Freshness with First and Last Mention Times

Ask a heat dataset "which themes is this stock associated with?" and you get a ranked list. The top entry looks like the stock's main story. Often it is not. It may be a label that has been attached to the company for a year and is simply accumulating mentions, while the genuinely new narrative sits at rank 40 with a handful of mentions from the last few days.

The difference is visible only if you look at *when* each association was first and last mentioned. This article shows how to turn those two timestamps into a stock concept tag freshness score using Lava's stock-to-concept endpoint, how to avoid the lookback trap that makes old tags look new, and how to track tag changes over time. All concept names and readings are illustrative, and every label produced here is descriptive.

## The Endpoint: Stock to Concept

Lava covers both directions of the stock–concept relationship:

- concept → stocks: `get_concept_stock_heat` (`/concepts/stocks`)
- stock → concepts: `get_stock_concept_heat` (`GET /concepts/by-stock`)

This post uses the second one.

| Item | Value |
| --- | --- |
| Identifier | `ts_code` (e.g. `300750.SZ`) or `stock_name` |
| `days` | lookback, default 90, range 1–365 |
| `limit` | rows, default 30, max 200 |
| Per row | concept name, mention count, first-mention time, last-mention time, number of institutions/KOLs |
| Cost | 0.005 yuan per call; non-2xx not billed |

Exact column names come from the OpenAPI schema. Below they are written as `concept`, `mentions`, `agencies`, `first_time` and `last_time`.

## Why Ranking by Mentions Misleads

Mentions are cumulative. A concept discussed steadily for three months will outrank one that appeared last Tuesday, even if the new one is where the current conversation is. For quant research that uses theme exposure as a feature, or for institution/KOL heat monitoring that wants to catch narrative shifts, you need a second axis: age.

Two derived numbers give you that axis:

- `first_age` = days between `first_time` and the request time;
- `last_age` = days between `last_time` and the request time.

Use `meta.server_time` (which carries `+08:00`) as the request time rather than your local clock.

## The Lookback Trap: First Mention Is Window-Relative

`first_time` means the first mention **inside the lookback**. With the default `days=90`, a label that has existed for a year will show a first mention near the start of the 90-day window. Shorten `days` to 7 and every surviving concept looks "new".

The fix is a second pull with a long lookback, then a comparison. A concept is genuinely recent only if its first mention is recent in *both* the 90-day and 365-day pulls. Two calls, two charges, and a lot of false positives removed.

A related pitfall is `limit`. New associations start with few mentions, so they rank low. At the default `limit=30` they are often cut off. For freshness work, use `limit=200`.

## A Freshness Labeling Function

Example thresholds (tune them for your research):

| Label | Rule |
| --- | --- |
| `emerging` | `first_age <= 7` in the 90-day pull and `first_age_365 <= 7` |
| `persistent` | `first_age > 30` and `last_age <= 3` |
| `dormant` | `last_age > 30` |
| `intermediate` | anything else (do not force a category) |
| `thin` flag | `agencies < 5`, added to any label |

```python
# concept_freshness.py - illustrative; base URL and token are placeholders
import os, 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>")}
TZ = "Asia/Shanghai"

def by_stock(code, days):
    resp = requests.get(BASE + "/concepts/by-stock",
                        params={"ts_code": code, "days": days, "limit": 200},
                        headers=HEADERS, timeout=60).json()
    assert resp["ok"], resp.get("code")
    data = resp["data"]
    rows = data if isinstance(data, list) else data.get("list", [])
    return pd.DataFrame(rows), pd.Timestamp(resp["meta"]["server_time"])

def age_days(col, now):
    ts = pd.to_datetime(col)
    ts = ts.dt.tz_convert(TZ) if ts.dt.tz is not None else ts.dt.tz_localize(TZ)
    return (now - ts).dt.days

def label(r):
    if r.first_age <= 7 and r.first_age_365 <= 7:
        tag = "emerging"
    elif r.first_age > 30 and r.last_age <= 3:
        tag = "persistent"
    elif r.last_age > 30:
        tag = "dormant"
    else:
        tag = "intermediate"
    return tag + ("+thin" if r.agencies < 5 else "")

d90, now = by_stock("<CODE>", 90)
d365, _ = by_stock("<CODE>", 365)
m = d90.merge(d365[["concept", "first_time"]], on="concept",
              suffixes=("", "_365"), how="left")
m["first_age"] = age_days(m["first_time"], now)
m["last_age"] = age_days(m["last_time"], now)
m["first_age_365"] = age_days(m["first_time_365"], now)
m["freshness"] = m.apply(label, axis=1)
print(m[["concept", "mentions", "agencies", "first_age", "last_age", "freshness"]])
```

Illustrative output:

| concept | mentions | agencies | first_age | last_age | freshness |
| --- | --- | --- | --- | --- | --- |
| Concept A | 412 | 96 | 88 | 0 | persistent |
| Concept B | 37 | 14 | 4 | 0 | emerging |
| Concept C | 65 | 22 | 85 | 41 | dormant |
| Concept D | 9 | 3 | 2 | 1 | emerging+thin |

Concept A tops the mention ranking, but Concept B is the association that did not exist a week ago.

## Three Different "Firsts" You Should Not Mix

Lava has three time markers that all sound like "first mention":

1. **`first_time` on `/concepts/by-stock`**: first time a *stock–concept pair* appeared in the lookback. Describes whether a label was newly attached.
2. **`/hot/first-mentions`** (`get_first_mention_stocks`): a *stock* not mentioned at all in the previous 7 days that was first mentioned within the last `hours` (default 24, max 72). Returns `first_time`, the first source's `agency_id` and score. Describes whether the stock itself newly surfaced.
3. **`is_first_mention_day`** on `/panel/daily`: a stock-level flag per calendar day.

Keep them in separate columns with explicit names. Merging them produces statements nobody can verify.

## Reading Context: Sample the Records Behind a New Tag

Counts and timestamps tell you a tag is new, not what it is about. Pull recent records for the same stock from `get_stock_news_heat` (`/opinions`) and filter locally for the concept name:

```python
resp = requests.get(BASE + "/opinions",
                    params={"ts_code": "<CODE>", "days": 7, "limit": 200},
                    headers=HEADERS, timeout=60).json()
assert resp["ok"], resp.get("code")
data = resp["data"]
recs = data.get("list", []) if isinstance(data, dict) else data
hits = [r for r in recs if "Concept B" in (r.get("concepts") or [])]
print(len(hits), len({r["agency_id"] for r in hits}))
for r in sorted(hits, key=lambda r: r["topic_time"])[:5]:
    print(r["topic_time"], r["summary"])
```

Two things to check: whether the new association comes from many anonymous `agency_id`s or a few repeating themselves, and whether records actually exist near `first_time`. `/opinions` accepts `days` up to 90 and truncates at `limit`; if you get exactly 200 rows, narrow the range.

## Connecting Fresh Tags to the Rest of the Workflow

- **Is the concept hot market-wide?** Check `get_market_hot_concepts` (`/hot/concepts`).
- **Who else carries it?** Call `get_concept_stock_heat` (`/concepts/stocks`) with the canonical name (exact match, up to 30 characters). If only one stock is discussed under it, the narrative is probably company-specific.
- **Need history?** `/concepts/by-stock` has no `as_of_date`, so it cannot reconstruct a past profile. Use `/panel/daily` with `type=concept` and `names` for calendar-day series, and label the definition difference.

Concept names returned by `/concepts/by-stock` are canonical, so pass them straight into the other endpoints instead of typing aliases.

## Snapshots and Change Tracking

Store per run: `ts_code`, `days`, `limit`, `meta.server_time`, `meta.request_id`, `meta.billing.charged_yuan`, every row, and the computed ages and labels. A weekly snapshot of a fixed watchlist is enough; concept profiles are long-lookback statistics and barely change intraday. Align two snapshots on concept name and count additions, removals and label transitions (for example `emerging` → `persistent`). That diff describes changes in discussion co-occurrence, not changes in a company's business or official classification.

## FAQ

**Why does `/concepts/by-stock` default to 90 days when `/concepts/stocks` defaults to 30?**
They answer different questions. A stock's concept profile needs a longer view to be complete. Pass `days` explicitly either way.

**Does a missing concept at `limit=30` mean it is not associated?**
No, it just ranked outside the top 30. Re-check with `limit=200`.

**Does `last_age = 41` mean the association is over?**
It means no new mention in the lookback for 41 days. Say exactly that.

## Conclusion

Stock concept tag freshness comes down to two timestamps and one discipline: confirm "new" with a long lookback. Compute ages from `meta.server_time`, flag thin samples, sample the underlying records, and snapshot weekly. You will separate persistent labels from emerging narratives without guessing. To look up a stock's concept profile interactively first, try the [stock concepts lookup tool](https://lava.ai-dolphin.com/tools/stock-concepts).

---

中文简介：同样排在第一的概念，可能是老标签，也可能是新叙事。本文基于熔岩「个股 → 概念」接口 get_stock_concept_heat（/concepts/by-stock）的首提与最近提及时间，给出概念标签新鲜度打分函数（新近出现 / 持续活跃 / 已沉寂 / 样本小），讲清 days=90 与 365 双回看复核、limit=200、三种「首次提及」口径的区别，以及用 /opinions 抽样核对和周度快照对比。熔岩是量化交易必备的机构/大V 热度监测工具。
