Skip to content

Commit 8d7022d

Browse files
adminclaude
andcommitted
feat(tg_bot): /watch resolves and stores stock name from backend
Fixes review issue OpenByteInc#10 — /list previously showed `—` for every code because cmd_watch always passed name=None and watchlist_add was INSERT OR IGNORE. Three coordinated changes: 1. QuantDingerClient.get_symbol_name() hits the public GET /api/market/symbols/search endpoint (unauthed; seed DB lookup), returns the first result's name or None on any error / no match / symbol-echo. Best-effort, never raises. 2. Storage.watchlist_add now uses UPSERT with COALESCE(old, new) so: - existing name is never overwritten (preserves prior intent) - existing NULL name CAN be backfilled by a later add with a real name (matches the lazy-fill use case) added_by / added_at stay from the original insert. 3. cmd_watch awaits get_symbol_name before storing; passes the result through. Reply now echoes the name when available (e.g. "✅ 已加入 watchlist: 600519 貴州茅台"). Pre-existing watchlist rows with NULL name will stay NULL until re-watched; users wanting to backfill can /unwatch + /watch the code. Tests: +5 (1 storage backfill, 4 get_symbol_name covering happy / no-match / symbol-echo / HTTP 500). Full suite 61/61. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b90babe commit 8d7022d

5 files changed

Lines changed: 94 additions & 4 deletions

File tree

tg_bot/handlers/watchlist.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,18 @@ async def _err(msg: Message, text: str):
2828

2929

3030
@router.message(Command("watch"))
31-
async def cmd_watch(msg: Message, storage: Storage):
31+
async def cmd_watch(msg: Message, storage: Storage, quantdinger):
3232
try:
3333
code = parse_code(msg.text or "")
3434
except ValueError as e:
3535
await _err(msg, str(e))
3636
return
37-
storage.watchlist_add(code, name=None, added_by=msg.from_user.id)
38-
await msg.answer(f"✅ 已加入 watchlist:<code>{code}</code>", parse_mode="HTML")
37+
# Best-effort name lookup; on any failure the row still gets added with
38+
# name=None (later /ai or /scan can backfill it via run_analysis).
39+
name = await quantdinger.get_symbol_name(market="CNStock", symbol=code)
40+
storage.watchlist_add(code, name=name, added_by=msg.from_user.id)
41+
label = f"<code>{code}</code>" + (f" {name}" if name else "")
42+
await msg.answer(f"✅ 已加入 watchlist:{label}", parse_mode="HTML")
3943

4044

4145
@router.message(Command("unwatch"))

tg_bot/services/quantdinger.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,39 @@ async def analyze(self, *, market: str, symbol: str, language: str = "zh-TW",
8787
)
8888
raise BackendError(msg, code=resp.status_code, data=data)
8989

90+
async def get_symbol_name(self, *, market: str, symbol: str) -> str | None:
91+
"""Look up a human-readable name for a symbol.
92+
93+
Hits the public-ish endpoint `GET /api/market/symbols/search` which
94+
queries the seeded symbol DB. Returns None on any error (404, timeout,
95+
no match, network) — caller is expected to degrade gracefully.
96+
97+
This endpoint does not require auth, so we don't bother attaching a
98+
Bearer token; staying unauthed avoids cascading 401 retries when the
99+
bot is in a partial-startup state.
100+
"""
101+
try:
102+
resp = await self._client.get(
103+
f"{self.base}/api/market/symbols/search",
104+
params={"market": market, "keyword": symbol, "limit": 1},
105+
timeout=8.0,
106+
)
107+
if resp.status_code != 200:
108+
return None
109+
payload = resp.json() or {}
110+
except Exception:
111+
return None
112+
items = payload.get("data") or []
113+
if not items or not isinstance(items, list):
114+
return None
115+
first = items[0] or {}
116+
name = (first.get("name") or "").strip()
117+
# Backend sometimes echoes the symbol as name when no real name found;
118+
# treat that as "no name" so we don't pollute the watchlist.
119+
if not name or name.upper() == str(symbol).upper():
120+
return None
121+
return name
122+
90123
async def _post_authed(self, path: str, *, json: dict) -> httpx.Response:
91124
"""POST that auto re-logins once on 401."""
92125
token = await self._ensure_token()

tg_bot/services/storage.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,12 @@ def close(self) -> None:
7474

7575
# ---- watchlist ----
7676
def watchlist_add(self, code: str, name: str | None, added_by: int) -> None:
77+
# COALESCE keeps the old name if already set; new non-null name backfills NULL.
78+
# added_by / added_at stay from the original insert.
7779
with self._conn:
7880
self._conn.execute(
79-
"INSERT OR IGNORE INTO watchlist(code, name, added_by, added_at) VALUES (?,?,?,?)",
81+
"INSERT INTO watchlist(code, name, added_by, added_at) VALUES (?,?,?,?) "
82+
"ON CONFLICT(code) DO UPDATE SET name = COALESCE(watchlist.name, excluded.name)",
8083
(code, name, added_by, _now_iso()),
8184
)
8285

tg_bot/tests/test_quantdinger.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,40 @@ async def test_analyze_insufficient_credits(client):
9393
language="zh-TW", timeframe="1D")
9494
assert "credits" in str(exc.value).lower()
9595
await client.aclose()
96+
97+
98+
@respx.mock
99+
async def test_get_symbol_name_returns_name(client):
100+
respx.get(f"{BASE}/api/market/symbols/search").mock(return_value=httpx.Response(
101+
200, json={"code": 1, "msg": "success",
102+
"data": [{"market": "CNStock", "symbol": "600519", "name": "貴州茅台"}]}))
103+
name = await client.get_symbol_name(market="CNStock", symbol="600519")
104+
assert name == "貴州茅台"
105+
await client.aclose()
106+
107+
108+
@respx.mock
109+
async def test_get_symbol_name_no_match_returns_none(client):
110+
respx.get(f"{BASE}/api/market/symbols/search").mock(return_value=httpx.Response(
111+
200, json={"code": 1, "data": []}))
112+
name = await client.get_symbol_name(market="CNStock", symbol="999999")
113+
assert name is None
114+
await client.aclose()
115+
116+
117+
@respx.mock
118+
async def test_get_symbol_name_echo_symbol_treated_as_none(client):
119+
"""When backend echoes the symbol as name (no real match), treat as None."""
120+
respx.get(f"{BASE}/api/market/symbols/search").mock(return_value=httpx.Response(
121+
200, json={"code": 1, "data": [{"symbol": "600519", "name": "600519"}]}))
122+
name = await client.get_symbol_name(market="CNStock", symbol="600519")
123+
assert name is None
124+
await client.aclose()
125+
126+
127+
@respx.mock
128+
async def test_get_symbol_name_http_error_returns_none(client):
129+
respx.get(f"{BASE}/api/market/symbols/search").mock(return_value=httpx.Response(500))
130+
name = await client.get_symbol_name(market="CNStock", symbol="600519")
131+
assert name is None
132+
await client.aclose()

tg_bot/tests/test_storage.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ def test_watchlist_add_dup_is_noop(storage):
2929
assert rows[0]["name"] == "貴州茅台"
3030

3131

32+
def test_watchlist_add_backfills_null_name(storage):
33+
"""If a row was inserted with name=None, a later add with a real name fills it in."""
34+
storage.watchlist_add("600519", None, added_by=111)
35+
rows = storage.watchlist_list()
36+
assert rows[0]["name"] is None
37+
storage.watchlist_add("600519", "貴州茅台", added_by=222)
38+
rows = storage.watchlist_list()
39+
assert len(rows) == 1
40+
assert rows[0]["name"] == "貴州茅台"
41+
# added_by stays from the original insert (we don't overwrite it)
42+
assert rows[0]["added_by"] == 111
43+
44+
3245
def test_watchlist_remove(storage):
3346
storage.watchlist_add("600519", "貴州茅台", added_by=111)
3447
removed = storage.watchlist_remove("600519")

0 commit comments

Comments
 (0)