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
# 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:

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_ids 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.

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