SLOPSHOPPER

market-tracker-th

ราคาทองไทย ทองโลก หุ้น SET/US คริปโต และค่าเงินบาทจากแหล่งข้อมูลจริง พร้อม watchlist พอร์ต และแจ้งเตือนราคา

newpanecommandtoastnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · market-tracker-th
│ ┃ 📊 Market ✕ › fix the failing auth test and add an audit log call │ ┃ พิมพ์ /market เพื่อโหลดราคา │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ รีเฟรช ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /market │ ⎿ market-tracker-th: 📊 Watchlist │ ⎿ market-tracker-th: ---------------------------------------- │ ⎿ market-tracker-th: ⚠️ คริปโต: Error: HTTP 0 │ ⎿ market-tracker-th: ⚠️ ทองไทย: Error: HTTP 0 │ ⎿ market-tracker-th: ⚠️ USDTHB: HTTP 0 │ ⎿ market-tracker-th: ⚠️ PTT.BK: HTTP 0 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 📊 Market
พิมพ์ /market เพื่อโหลดราคา [ รีเฟรช ]
README

Mod 17: Market Tracker TH

ดูราคาทองคำแท่งและทองรูปพรรณของไทย ทองโลก หุ้น SET และหุ้นอเมริกา คริปโต (ราคาเป็นบาท) และค่าเงิน USD/THB จากแหล่งข้อมูลจริง ทุกราคาบอกแหล่งที่มาและเวลาของข้อมูล มี watchlist ในแผงด้านข้าง พอร์ตที่คำนวณกำไร/ขาดทุนรวมเป็นบาท และแจ้งเตือนเมื่อราคาข้ามระดับที่ตั้งไว้

คำสั่ง

คำสั่งทำอะไร
/marketwatchlist + เปิดแผง
/market goldทองคำแท่ง ทองรูปพรรณ และทองโลก
/market oilน้ำมันดิบ WTI และ Brent (USD ต่อบาร์เรล)
/market crypto [BTC ETH ...]คริปโต ราคาเป็นบาท และการเปลี่ยนแปลง 24 ชม.
/market stock PTT.BK AAPLหุ้น (หุ้นไทยลงท้าย .BK)
/market set PTT KBANKหุ้นไทยแบบไม่ต้องใส่ .BK
/market stocksหุ้นไทยขนาดใหญ่ที่คนดูบ่อย
/market fxค่าเงิน USD/THB
/market BTC PTT.BKดูราคาตามชื่อที่พิมพ์
`/market watch add\remove <ชื่อ>`จัดการ watchlist
/market buy <ชื่อ> <จำนวน> <ราคาต่อหน่วย>บันทึกเข้าพอร์ต
/market sell <ชื่อ>เอาออกจากพอร์ต
/market portfolioกำไร/ขาดทุน รวมเป็นบาท
`/market alert <ชื่อ> above\below <ราคา>`แจ้งเตือนเมื่อราคาข้ามระดับ (เช็คทันที แล้วเช็คทุก 5 นาที)
/market alerts / alert remove <id>ดู / ลบการแจ้งเตือน

ชื่อพิเศษ: GOLD-BAR (ทองคำแท่ง), GOLD (ทองรูปพรรณ), XAU (ทองโลก), OIL/WTI และ BRENT (น้ำมัน), SILVER (เงิน), USDTHB, BTC ETH SOL BNB XRP DOGE ADA USDT

ติดตั้ง

/plugin install market-tracker-th --marketplace tksoft11/claude-code-mods-th

ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)

แหล่งข้อมูล

ข้อมูลแหล่งหมายเหตุ
คริปโตCoinGecko APIฟรี มีจำกัดจำนวนครั้งต่อนาที
ค่าเงินopen.er-api.comอัปเดตวันละครั้ง
ทองไทยapi.chnwt.dev/thai-gold-apiไม่เป็นทางการ อ้างอิงราคาจากเว็บสมาคมค้าทองคำ
หุ้นและทองโลกYahoo Finance chart APIไม่เป็นทางการ อาจล่าช้า ~15 นาที

โค้ดสอนอะไร

เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. ดึงข้อมูลจากหลายแหล่ง แล้วแปลง JSON ที่หน้าตาต่างกันให้เป็นรูปแบบ Quote เดียวกัน
  2. เลือกแหล่งข้อมูลจากชื่อ, cache 60 วินาที และดึงพร้อมกันด้วย Promise.all
  3. จัดรูปแบบตัวเลขและเวลา
  4. watchlist, พอร์ต และแจ้งเตือน เก็บใน $.store และเช็คแจ้งเตือนด้วย $.clock.every
  5. แผง watchlist ที่มีปุ่มรีเฟรช

หมายเหตุ

  • ⚠️ ข้อมูลอาจล่าช้าหรือคลาดเคลื่อน ไม่ใช่คำแนะนำการลงทุน
  • แจ้งเตือนจะทำงานเฉพาะตอนที่เปิด Claude Code อยู่ เมื่อแจ้งแล้วจะลบตัวเองออก
  • API ที่ไม่เป็นทางการอาจเปลี่ยนรูปแบบหรือเลิกให้บริการได้ ถ้าราคาไหนดึงไม่ได้ จะขึ้น ⚠️ บอกแทนการแสดงตัวเลขผิด
Source 1 files
hooks/register.tsx 408 lines
1/**
2 * ============================================================================
3 * MOD 17: Market Tracker TH - ราคาทอง หุ้น คริปโต และค่าเงินจากแหล่งข้อมูลจริง (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: แอปดูราคาตลาดบนมือถือ
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. ราคาทองคำแท่งและทองรูปพรรณของไทย, ทองโลก, หุ้น SET และหุ้น US, คริปโต, ค่าเงิน USD/THB
9 * 2. Watchlist ในแผงด้านข้าง
10 * 3. พอร์ต: บันทึกต้นทุน แล้วดูกำไร/ขาดทุนตามราคาล่าสุด (รวมเป็นบาท)
11 * 4. แจ้งเตือนเมื่อราคาข้ามระดับที่ตั้งไว้ (เช็คทุก 5 นาทีขณะเปิด Claude Code)
12 *
13 * แหล่งข้อมูล (ฟรี ไม่ต้องใช้ key):
14 * - คริปโต: CoinGecko API
15 * - ค่าเงิน: open.er-api.com (อัปเดตวันละครั้ง)
16 * - ทองไทย: api.chnwt.dev/thai-gold-api (ไม่เป็นทางการ อ้างอิงราคาสมาคมค้าทองคำ)
17 * - หุ้นและทองโลก: Yahoo Finance chart API (ไม่เป็นทางการ อาจล่าช้า ~15 นาที)
18 *
19 * ⚠️ ข้อมูลอาจล่าช้าหรือคลาดเคลื่อน ไม่ใช่คำแนะนำการลงทุน
20 */
21
22import type { EngineInterface, Register, Timer } from 'claude-code'
23
24interface Quote {
25  symbol: string
26  name: string
27  price: number
28  currency: string
29  changePct?: number
30  source: string
31  time: number // ms
32}
33
34interface Holding {
35  symbol: string
36  amount: number
37  cost: number // ราคาต่อหน่วยตอนซื้อ (สกุลเดียวกับ quote)
38}
39
40interface PriceAlert {
41  id: number
42  symbol: string
43  condition: 'above' | 'below'
44  target: number
45}
46
47const PANE = 'market-tracker-th'
48const UA = { 'User-Agent': 'Mozilla/5.0 (claude-code market-tracker-th)' }
49
50// ชื่อย่อคริปโต -> id ของ CoinGecko
51const CRYPTO: Record<string, string> = { BTC: 'bitcoin', ETH: 'ethereum', SOL: 'solana', BNB: 'binancecoin', XRP: 'ripple', DOGE: 'dogecoin', ADA: 'cardano', USDT: 'tether' }
52
53// ==========================================================================
54// บทที่ 1: ดึงข้อมูลแต่ละแหล่ง (แต่ละแหล่งตอบ JSON คนละแบบ ต้องแปลงเป็น Quote เดียวกัน)
55// ==========================================================================
56async function getJson($: EngineInterface, url: string): Promise<Record<string, unknown>> {
57  const res = await $.http.fetch(url, { headers: UA })
58  if (!res.ok) throw new Error(`HTTP ${res.status}`)
59  return JSON.parse(res.text) as Record<string, unknown>
60}
61
62async function cryptoQuotes($: EngineInterface, symbols: string[]): Promise<Quote[]> {
63  const ids = symbols.map(s => CRYPTO[s] ?? s.toLowerCase())
64  const body = await getJson($, `https://api.coingecko.com/api/v3/simple/price?ids=${ids.join(',')}&vs_currencies=thb,usd&include_24hr_change=true&include_last_updated_at=true`)
65  const now = await $.clock.now()
66  return symbols.flatMap((symbol, i) => {
67    const row = body[ids[i] ?? ''] as { thb?: number; usd?: number; thb_24h_change?: number; last_updated_at?: number } | undefined
68    if (!row?.thb) return []
69    return [{ symbol, name: ids[i] ?? symbol, price: row.thb, currency: 'THB', changePct: row.thb_24h_change, source: 'CoinGecko', time: (row.last_updated_at ?? 0) * 1000 || now }]
70  })
71}
72
73async function yahooQuote($: EngineInterface, symbol: string): Promise<Quote> {
74  const body = await getJson($, `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=1d&interval=1d`)
75  const chart = body.chart as { result?: { meta?: Record<string, unknown> }[] | null; error?: { description?: string } | null }
76  const meta = chart.result?.[0]?.meta
77  if (!meta || typeof meta.regularMarketPrice !== 'number') throw new Error(chart.error?.description ?? `ไม่พบ ${symbol}`)
78  const price = meta.regularMarketPrice
79  const prev = Number(meta.chartPreviousClose ?? meta.previousClose ?? 0)
80  return {
81    symbol,
82    name: String(meta.shortName ?? meta.longName ?? symbol),
83    price,
84    currency: String(meta.currency ?? 'USD'),
85    changePct: prev ? ((price - prev) / prev) * 100 : undefined,
86    source: 'Yahoo Finance',
87    time: Number(meta.regularMarketTime ?? 0) * 1000,
88  }
89}
90
91async function thaiGold($: EngineInterface): Promise<Quote[]> {
92  const body = await getJson($, 'https://api.chnwt.dev/thai-gold-api/latest')
93  const r = body.response as { update_date?: string; update_time?: string; price?: Record<string, { buy?: string; sell?: string }> } | undefined
94  const num = (s: string | undefined) => Number((s ?? '').replace(/,/g, ''))
95  const bar = r?.price?.gold_bar
96  const ornament = r?.price?.gold
97  if (!bar?.sell) throw new Error('รูปแบบข้อมูลทองเปลี่ยน')
98  const label = `สมาคมค้าทองคำ ${r?.update_date ?? ''} ${r?.update_time ?? ''}`.trim()
99  const now = await $.clock.now()
100  return [
101    { symbol: 'GOLD-BAR', name: `ทองคำแท่ง 96.5% (รับซื้อ ฿${bar.buy})`, price: num(bar.sell), currency: 'THB', source: label, time: now },
102    { symbol: 'GOLD', name: `ทองรูปพรรณ 96.5% (รับซื้อ ฿${ornament?.buy ?? '-'})`, price: num(ornament?.sell), currency: 'THB', source: label, time: now },
103  ]
104}
105
106async function usdThb($: EngineInterface): Promise<Quote> {
107  const body = await getJson($, 'https://open.er-api.com/v6/latest/USD')
108  const rate = (body.rates as Record<string, number> | undefined)?.THB
109  if (!rate) throw new Error('ไม่มีอัตรา THB')
110  return { symbol: 'USDTHB', name: 'ดอลลาร์ / บาท', price: rate, currency: 'THB', source: 'open.er-api.com (รายวัน)', time: Number(body.time_last_update_unix ?? 0) * 1000 }
111}
112
113// ==========================================================================
114// บทที่ 2: ตัวเลือกแหล่งข้อมูลจากชื่อ + cache 60 วินาที (ไม่ยิง API ถี่เกิน)
115// ==========================================================================
116const cache = new Map<string, { at: number; quote: Quote }>()
117
118// ชื่อเรียกง่ายๆ -> สัญลักษณ์ของ Yahoo (ราคาสัญญาซื้อขายล่วงหน้า เป็น USD)
119const ALIASES: Record<string, string> = { XAU: 'GC=F', 'GOLD-WORLD': 'GC=F', OIL: 'CL=F', WTI: 'CL=F', BRENT: 'BZ=F', SILVER: 'SI=F', XAG: 'SI=F' }
120
121// AAPL คงเดิม, BTC -> CoinGecko, GOLD -> ทองไทย, XAU -> ทองโลก, OIL -> น้ำมัน WTI
122function normalize(input: string): string {
123  const s = input.toUpperCase()
124  return ALIASES[s] ?? s
125}
126
127async function quotes($: EngineInterface, inputs: string[]): Promise<{ quotes: Quote[]; errors: string[] }> {
128  const now = await $.clock.now()
129  const wanted = inputs.map(normalize)
130  const result: Quote[] = []
131  const errors: string[] = []
132  const missing = wanted.filter(s => {
133    const hit = cache.get(s)
134    if (hit && now - hit.at < 60_000) return result.push(hit.quote), false
135    return true
136  })
137
138  const cryptoSyms = missing.filter(s => CRYPTO[s])
139  const goldSyms: string[] = missing.filter(s => s === 'GOLD' || s === 'GOLD-BAR')
140  const others = missing.filter(s => !CRYPTO[s] && !goldSyms.includes(s))
141  const save = (q: Quote) => {
142    cache.set(q.symbol, { at: now, quote: q })
143    if (wanted.includes(q.symbol)) result.push(q)
144  }
145
146  await Promise.all([
147    cryptoSyms.length ? cryptoQuotes($, cryptoSyms).then(qs => qs.forEach(save)).catch(err => errors.push(`คริปโต: ${String(err)}`)) : undefined,
148    goldSyms.length ? thaiGold($).then(qs => qs.forEach(save)).catch(err => errors.push(`ทองไทย: ${String(err)}`)) : undefined,
149    ...others.map(s =>
150      (s === 'USDTHB' ? usdThb($) : yahooQuote($, s)).then(save).catch(err => {
151        const message = err instanceof Error ? err.message : String(err)
152        errors.push(`${s}: ${message === 'HTTP 404' ? 'ไม่พบชื่อนี้ (หุ้นไทยต้องลงท้าย .BK หรือใช้ /market set)' : message}`)
153      }),
154    ),
155  ])
156  // เรียงตามลำดับที่ขอ
157  result.sort((a, b) => wanted.indexOf(a.symbol) - wanted.indexOf(b.symbol))
158  return { quotes: result, errors }
159}
160
161// ==========================================================================
162// บทที่ 3: จัดรูปแบบตัวเลข
163// ==========================================================================
164function fmt(n: number, currency: string): string {
165  const digits = n >= 1000 ? 2 : n >= 1 ? 2 : 6
166  const symbol = currency === 'THB' ? '฿' : currency === 'USD' ? '$' : `${currency} `
167  return `${symbol}${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: digits })}`
168}
169
170function change(pct: number | undefined): string {
171  return pct === undefined || Number.isNaN(pct) ? '' : `${pct >= 0 ? '📈 +' : '📉 '}${pct.toFixed(2)}%`
172}
173
174function quoteLine(q: Quote): string {
175  return `${q.symbol.padEnd(9)} ${fmt(q.price, q.currency).padStart(14)} ${change(q.changePct).padEnd(10)} ${q.name}`
176}
177
178function age(ms: number, now: number): string {
179  const min = Math.floor((now - ms) / 60000)
180  return min < 1 ? 'เมื่อสักครู่' : min < 60 ? `${min} นาทีที่แล้ว` : `${Math.floor(min / 60)} ชม.ที่แล้ว`
181}
182
183const DISCLAIMER = 'ℹ️ ข้อมูลอาจล่าช้าหรือคลาดเคลื่อน ไม่ใช่คำแนะนำการลงทุน'
184
185// ==========================================================================
186// บทที่ 4: watchlist, พอร์ต และแจ้งเตือน (เก็บใน $.store)
187// ==========================================================================
188let watchlist: string[] = ['GOLD-BAR', 'BTC', 'USDTHB', 'PTT.BK', 'AAPL']
189let portfolio: Holding[] = []
190let alerts: PriceAlert[] = []
191let nextAlertId = 0
192let alertTimer: Timer | undefined
193let paneQuotes: Quote[] = []
194
195async function persist($: EngineInterface): Promise<void> {
196  await $.store.set('market', { watchlist, portfolio, alerts, nextAlertId })
197}
198
199async function checkAlerts($: EngineInterface): Promise<void> {
200  if (alerts.length === 0) return
201  const { quotes: qs } = await quotes($, [...new Set(alerts.map(a => a.symbol))])
202  const fired = alerts.filter(a => {
203    const q = qs.find(x => x.symbol === a.symbol)
204    return q && (a.condition === 'above' ? q.price >= a.target : q.price <= a.target)
205  })
206  if (fired.length === 0) return
207  alerts = alerts.filter(a => !fired.includes(a)) // แจ้งครั้งเดียวแล้วลบ (ลบก่อน กันแจ้งซ้ำถ้าขั้นต่อไปพัง)
208  await persist($)
209  for (const a of fired) {
210    const q = qs.find(x => x.symbol === a.symbol)
211    const text = `🔔 ${a.symbol} ${a.condition === 'above' ? 'ขึ้นถึง' : 'ลงถึง'} ${fmt(a.target, q?.currency ?? 'THB')} แล้ว (ตอนนี้ ${fmt(q?.price ?? 0, q?.currency ?? 'THB')})`
212    $.ui.toast(text)
213    try {
214      void $.ui.notify(text, { title: 'Market Tracker' }).catch(() => undefined)
215    } catch {
216      // บางหน้าจอแจ้งเตือนของระบบไม่ได้ ไม่เป็นไร มี toast แล้ว
217    }
218  }
219  startAlertLoop($)
220}
221
222function startAlertLoop($: EngineInterface): void {
223  alertTimer?.cancel()
224  alertTimer = alerts.length ? $.clock.every(5 * 60 * 1000, () => void checkAlerts($).catch(() => undefined)) : undefined
225}
226
227async function refreshPane($: EngineInterface): Promise<string[]> {
228  const { quotes: qs, errors } = await quotes($, watchlist)
229  paneQuotes = qs
230  $.ui.invalidate('ui.render')
231  return errors
232}
233
234const HELP = `📊 Market Tracker TH (Mod 17)
235========================================
236/market                    watchlist + เปิดแผง
237/market gold               ทองคำแท่ง ทองรูปพรรณ ทองโลก
238/market oil                น้ำมัน WTI และ Brent (USD/บาร์เรล)
239/market crypto [BTC ETH]   คริปโต (ราคาเป็นบาท)
240/market stock <ชื่อ...>     หุ้น เช่น PTT.BK CPALL.BK AAPL NVDA
241/market set <ชื่อ...>       หุ้นไทย ไม่ต้องใส่ .BK เช่น /market set PTT KBANK
242/market stocks             หุ้นไทยขนาดใหญ่ที่คนดูบ่อย
243/market fx                 ค่าเงิน USD/THB
244/market watch add|remove <ชื่อ>
245/market buy <ชื่อ> <จำนวน> <ราคาต่อหน่วย>  บันทึกเข้าพอร์ต
246/market sell <ชื่อ>         เอาออกจากพอร์ต
247/market portfolio          กำไร/ขาดทุน (รวมเป็นบาท)
248/market alert <ชื่อ> above|below <ราคา>   แจ้งเตือนเมื่อราคาข้าม
249/market alerts | alert remove <id>
250ชื่อพิเศษ: GOLD-BAR, GOLD (ทองไทย), XAU (ทองโลก), OIL/WTI, BRENT, SILVER, USDTHB, BTC ETH SOL BNB XRP DOGE ADA USDT`
251
252export const register: Register = on => {
253  on('session.start', async ($, e, next) => {
254    const saved = (await $.store.get('market')) as { watchlist?: string[]; portfolio?: Holding[]; alerts?: PriceAlert[]; nextAlertId?: number } | undefined
255    if (saved?.watchlist) watchlist = saved.watchlist
256    portfolio = saved?.portfolio ?? []
257    alerts = saved?.alerts ?? []
258    nextAlertId = saved?.nextAlertId ?? 0
259    startAlertLoop($)
260    await $.command.register({ name: 'market', description: 'ราคาทอง หุ้น คริปโต ค่าเงิน (/market help)', argumentHint: 'gold|crypto|stock|fx|portfolio|alert' })
261    return next(e)
262  })
263
264  // ==========================================================================
265  // บทที่ 5: แผง watchlist
266  // ==========================================================================
267  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
268    const { Box, Text, Button } = $.ui.resolve(e)
269    const now = await $.clock.now()
270    return (
271      <Box flexDirection="column">
272        {paneQuotes.length === 0 && <Text dimColor>พิมพ์ /market เพื่อโหลดราคา</Text>}
273        {paneQuotes.map(q => (
274          <Box justifyContent="space-between" gap={1}>
275            <Text bold>{q.symbol}</Text>
276            <Text>{fmt(q.price, q.currency)}</Text>
277            <Text color={(q.changePct ?? 0) >= 0 ? '#2e7d32' : '#c62828'}>{change(q.changePct)}</Text>
278          </Box>
279        ))}
280        <Text dimColor>{paneQuotes[0] ? `อัปเดต ${age(Math.min(...paneQuotes.map(q => q.time)), now)}` : ''}</Text>
281        <Button key="refresh" label="รีเฟรช" hotkey="r" onPress={() => void refreshPane($)} />
282      </Box>
283    )
284  })
285
286  on('command.run', { command: 'market' }, async ($, e) => {
287    const [sub = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
288    const now = await $.clock.now()
289
290    // แสดงชุดราคา พร้อมแหล่งที่มาและ error ถ้ามี
291    const show = async (title: string, symbols: string[]) => {
292      const { quotes: qs, errors } = await quotes($, symbols)
293      const sources = [...new Set(qs.map(q => `${q.source} (${age(q.time, now)})`))]
294      return {
295        text: [title, '----------------------------------------', ...qs.map(quoteLine), ...errors.map(err => `⚠️ ${err}`), '', `แหล่งข้อมูล: ${sources.join(', ') || '-'}`, DISCLAIMER].join('\n'),
296      }
297    }
298
299    switch (sub) {
300      case 'help':
301        return { text: HELP }
302      case '': {
303        await $.ui.open({ id: PANE, title: '📊 Market' })
304        await refreshPane($)
305        return show('📊 Watchlist', watchlist)
306      }
307      case 'gold':
308        return show('🥇 ราคาทอง', ['GOLD-BAR', 'GOLD', 'XAU'])
309      case 'oil':
310        return show('🛢️ น้ำมันดิบ (USD/บาร์เรล)', ['OIL', 'BRENT'])
311      case 'crypto':
312        return show('₿ คริปโต (บาท, เปลี่ยนแปลง 24 ชม.)', rest.length ? rest : ['BTC', 'ETH', 'SOL', 'BNB', 'XRP', 'DOGE'])
313      case 'stock':
314        return rest.length ? show('📈 หุ้น', rest) : { text: 'วิธีใช้: /market stock PTT.BK AAPL' }
315      case 'set':
316        return rest.length ? show('📈 หุ้นไทย (SET)', rest.map(s => (s.includes('.') ? s : `${s}.BK`))) : { text: 'วิธีใช้: /market set PTT KBANK' }
317      case 'stocks':
318        // หุ้นไทยขนาดใหญ่ที่คนดูบ่อย (ไม่ใช่คำแนะนำให้ซื้อ)
319        return show('📈 หุ้นไทยขนาดใหญ่ (SET)', ['PTT.BK', 'AOT.BK', 'CPALL.BK', 'ADVANC.BK', 'KBANK.BK', 'SCB.BK', 'BDMS.BK', 'GULF.BK'])
320      case 'fx':
321        return show('💱 ค่าเงิน', ['USDTHB'])
322      case 'watch': {
323        const [action, symbol] = rest
324        if (!symbol || (action !== 'add' && action !== 'remove')) return { text: `👀 Watchlist: ${watchlist.join(', ')}\nวิธีใช้: /market watch add|remove <ชื่อ>` }
325        const s = normalize(symbol)
326        watchlist = action === 'add' ? [...new Set([...watchlist, s])] : watchlist.filter(w => w !== s)
327        await persist($)
328        await refreshPane($)
329        return { text: `👀 Watchlist: ${watchlist.join(', ')}` }
330      }
331      case 'buy': {
332        const [symbol, amountText, costText] = rest
333        const amount = Number(amountText)
334        const cost = Number(costText)
335        if (!symbol || !(amount > 0) || !(cost > 0)) return { text: 'วิธีใช้: /market buy BTC 0.01 2500000  (ชื่อ จำนวน ราคาต่อหน่วยตอนซื้อ)' }
336        portfolio.push({ symbol: normalize(symbol), amount, cost })
337        await persist($)
338        return { text: `✅ บันทึก ${normalize(symbol)} ${amount} หน่วย ต้นทุน ${cost.toLocaleString('en-US')} ต่อหน่วย` }
339      }
340      case 'sell': {
341        const s = normalize(rest[0] ?? '')
342        const before = portfolio.length
343        portfolio = portfolio.filter(h => h.symbol !== s)
344        await persist($)
345        return { text: before === portfolio.length ? `❌ ไม่มี ${s} ในพอร์ต` : `🗑️ เอา ${s} ออกจากพอร์ตแล้ว` }
346      }
347      case 'portfolio': {
348        if (portfolio.length === 0) return { text: '📊 พอร์ตว่าง\nบันทึกด้วย /market buy BTC 0.01 2500000' }
349        const { quotes: qs, errors } = await quotes($, [...new Set([...portfolio.map(h => h.symbol), 'USDTHB'])])
350        const usd = qs.find(q => q.symbol === 'USDTHB')?.price ?? 0
351        const toThb = (n: number, currency: string) => (currency === 'THB' ? n : currency === 'USD' && usd ? n * usd : NaN)
352        let costThb = 0
353        let valueThb = 0
354        const rows = portfolio.map(h => {
355          const q = qs.find(x => x.symbol === h.symbol)
356          if (!q) return `⚠️ ${h.symbol}: ดึงราคาไม่ได้`
357          const pnl = (q.price - h.cost) * h.amount
358          costThb += toThb(h.cost * h.amount, q.currency)
359          valueThb += toThb(q.price * h.amount, q.currency)
360          return `${h.symbol} ${h.amount} × ${fmt(h.cost, q.currency)} → ${fmt(q.price, q.currency)}  ${pnl >= 0 ? '📈 +' : '📉 '}${fmt(pnl, q.currency)} (${(((q.price - h.cost) / h.cost) * 100).toFixed(2)}%)`
361        })
362        const total = valueThb - costThb
363        return {
364          text: [
365            '📊 พอร์ต',
366            '----------------------------------------',
367            ...rows,
368            '',
369            Number.isNaN(total) ? 'รวมเป็นบาทไม่ได้ (มีสกุลเงินที่ไม่รู้อัตราแลกเปลี่ยน)' : `💰 ต้นทุน ${fmt(costThb, 'THB')} | มูลค่า ${fmt(valueThb, 'THB')} | ${total >= 0 ? '📈 +' : '📉 '}${fmt(total, 'THB')} (${((total / costThb) * 100).toFixed(2)}%)`,
370            ...errors.map(err => `⚠️ ${err}`),
371            DISCLAIMER,
372          ].join('\n'),
373        }
374      }
375      case 'alert': {
376        if (rest[0] === 'remove') {
377          alerts = alerts.filter(a => a.id !== Number(rest[1]))
378          await persist($)
379          startAlertLoop($)
380          return { text: `🗑️ ลบการแจ้งเตือน #${rest[1] ?? ''} แล้ว` }
381        }
382        const [symbol, condition, targetText] = rest
383        const target = Number((targetText ?? '').replace(/,/g, ''))
384        if (!symbol || (condition !== 'above' && condition !== 'below') || !(target > 0)) return { text: 'วิธีใช้: /market alert BTC above 3000000  หรือ  /market alert GOLD-BAR below 60000' }
385        alerts.push({ id: nextAlertId++, symbol: normalize(symbol), condition, target })
386        await persist($)
387        startAlertLoop($)
388        await checkAlerts($).catch(() => undefined) // เช็คครั้งแรกทันที ไม่ต้องรอ 5 นาที
389        return { text: `🔔 ตั้งแจ้งเตือน ${normalize(symbol)} ${condition === 'above' ? 'ขึ้นถึง' : 'ลงถึง'} ${target.toLocaleString('en-US')} (เช็คทุก 5 นาทีขณะเปิด Claude Code)` }
390      }
391      case 'alerts':
392        return { text: alerts.length ? ['🔔 การแจ้งเตือน', ...alerts.map(a => `  #${a.id} ${a.symbol} ${a.condition === 'above' ? '≥' : '≤'} ${a.target.toLocaleString('en-US')}`)].join('\n') : 'ยังไม่มีการแจ้งเตือน' }
393      default:
394        // /market BTC PTT.BK = ดูราคาตามชื่อที่พิมพ์
395        return show('📊 ราคา', [sub, ...rest])
396    }
397  })
398}
399
400/**
401 * ========================================================================
402 * การบ้าน / ต่อยอด
403 * ========================================================================
404 * 1. วาดกราฟราคาย้อนหลัง: Yahoo chart API ใส่ range=1mo&interval=1d ได้ราคาปิดรายวัน
405 * 2. ส่งแจ้งเตือนราคาเข้า LINE ด้วย Flex Message ของ Mod 12
406 * 3. คำนวณ DCA: บันทึกการซื้อหลายครั้ง แล้วหาต้นทุนเฉลี่ย
407 */
408