DennisChan0909 Claude Sonnet 4.6 commited on
Commit
b72deed
·
1 Parent(s): 6c4b505

feat: market tabs (台股/美股/基金), autocomplete search, favorites filter tabs

Browse files

Frontend:
- Header: add 台股🇹🇼/美股🇺🇸/基金📊 market tabs + autocomplete dropdown (350ms debounce)
- FavoritesPage: add market filter tabs (全部/台股/美股/基金) with counts
- App: add market state, pass market to Header and store in favorites on add
- useFirestoreFavorites: persist market field in Firestore
- stockApi: add market param to searchStocks

Backend:
- data/fund_fetcher.py: MoneyDJ NAV fetcher with hardcoded list
(PIM91/PIM90/ALBL2/ALBL4/PIM95/FLZI4/TLZ64)
- data/fetcher.py: route fund codes through fund_fetcher before yfinance
- routers/stock.py: use search_funds for fund market search

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

data/fetcher.py CHANGED
@@ -10,6 +10,8 @@ from typing import Optional
10
  import pandas as pd
11
  import yfinance as yf
12
 
 
 
13
  logger = logging.getLogger(__name__)
14
 
15
 
@@ -122,6 +124,9 @@ def fetch_history(stock_no: str, months: int = 24) -> pd.DataFrame:
122
  date (datetime.date), open, high, low, close, volume (float)
123
  Sorted ascending by date, no duplicates.
124
  """
 
 
 
125
  if is_us_ticker(stock_no):
126
  ticker = stock_no.upper().strip()
127
  period = f"{months}mo"
@@ -229,6 +234,9 @@ def get_stock_info(stock_no: str) -> dict:
229
  Return basic stock info: name, exchange, stock_no.
230
  Uses yfinance Ticker.info for metadata, with exchange auto-detection fallback.
231
  """
 
 
 
232
  if is_us_ticker(stock_no):
233
  ticker = stock_no.upper().strip()
234
  try:
@@ -273,6 +281,10 @@ def get_stock_info(stock_no: str) -> dict:
273
  def get_current_price(stock_no: str) -> Optional[float]:
274
  """Return the most recent closing price."""
275
  try:
 
 
 
 
276
  if is_us_ticker(stock_no):
277
  ticker = stock_no.upper().strip()
278
  t = yf.Ticker(ticker)
 
10
  import pandas as pd
11
  import yfinance as yf
12
 
13
+ from data.fund_fetcher import is_moneydj_fund, fetch_fund_history, get_fund_info as _get_fund_info_moneydj
14
+
15
  logger = logging.getLogger(__name__)
16
 
17
 
 
124
  date (datetime.date), open, high, low, close, volume (float)
125
  Sorted ascending by date, no duplicates.
126
  """
127
+ if is_moneydj_fund(stock_no):
128
+ return fetch_fund_history(stock_no, months)
129
+
130
  if is_us_ticker(stock_no):
131
  ticker = stock_no.upper().strip()
132
  period = f"{months}mo"
 
234
  Return basic stock info: name, exchange, stock_no.
235
  Uses yfinance Ticker.info for metadata, with exchange auto-detection fallback.
236
  """
237
+ if is_moneydj_fund(stock_no):
238
+ return _get_fund_info_moneydj(stock_no)
239
+
240
  if is_us_ticker(stock_no):
241
  ticker = stock_no.upper().strip()
242
  try:
 
281
  def get_current_price(stock_no: str) -> Optional[float]:
282
  """Return the most recent closing price."""
283
  try:
284
+ if is_moneydj_fund(stock_no):
285
+ info = _get_fund_info_moneydj(stock_no)
286
+ return info.get("current_price")
287
+
288
  if is_us_ticker(stock_no):
289
  ticker = stock_no.upper().strip()
290
  t = yf.Ticker(ticker)
data/fund_fetcher.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MoneyDJ 基金爬蟲 — 支援台灣境外基金代號 (如 PIM91)
3
+ 取得歷史 NAV 淨值,轉為 OHLCV 格式供 ML 模型使用
4
+ """
5
+
6
+ import re
7
+ import json
8
+ import logging
9
+ import time
10
+
11
+ import requests
12
+ import pandas as pd
13
+ from datetime import datetime
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # MoneyDJ 代號格式: 2~5 字母 + 2~3 數字 (e.g. PIM91, JPM12, ALBL4, TLZ64)
18
+ _MONEYDJ_RE = re.compile(r'^[A-Za-z]{2,5}\d{2,3}$')
19
+
20
+ # Hardcoded known funds (fallback names when MoneyDJ unreachable)
21
+ KNOWN_FUNDS: dict[str, str] = {
22
+ "PIM91": "PIMCO 全球債券基金",
23
+ "PIM90": "PIMCO 投資級債券基金",
24
+ "PIM95": "PIMCO 高收益債券基金",
25
+ "ALBL2": "聯博全球高收益債券基金",
26
+ "ALBL4": "聯博美元短期債券基金",
27
+ "FLZI4": "富達美元利率基金",
28
+ "TLZ64": "富達美元利率基金 II",
29
+ }
30
+
31
+ _BROWSER_HEADERS = {
32
+ "User-Agent": (
33
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
34
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
35
+ "Chrome/124.0.0.0 Safari/537.36"
36
+ ),
37
+ "Accept-Language": "zh-TW,zh;q=0.9,en;q=0.8",
38
+ "Accept-Encoding": "gzip, deflate, br",
39
+ "Connection": "keep-alive",
40
+ }
41
+
42
+
43
+ def is_moneydj_fund(code: str) -> bool:
44
+ """Return True if code looks like a MoneyDJ fund code (e.g. PIM91)."""
45
+ return bool(_MONEYDJ_RE.match(code.strip()))
46
+
47
+
48
+ def _session() -> requests.Session:
49
+ s = requests.Session()
50
+ s.headers.update(_BROWSER_HEADERS)
51
+ s.verify = False # MoneyDJ SSL cert missing Subject Key Identifier
52
+ return s
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Fund info
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def get_fund_info(fund_code: str) -> dict:
60
+ """Return fund name and basic info from MoneyDJ (with hardcoded fallback)."""
61
+ code = fund_code.upper().strip()
62
+ fallback_name = KNOWN_FUNDS.get(code, code)
63
+ base = {"stock_no": code, "name": fallback_name, "exchange": "基金", "current_price": None}
64
+ try:
65
+ s = _session()
66
+ url = f"https://www.moneydj.com/funddj/ya/yp010000.djhtm?a={code}"
67
+ resp = s.get(url, timeout=10)
68
+ resp.encoding = "utf-8"
69
+ m = re.search(r"<title>\s*(.*?)\s*(?:\||-|–).*?</title>", resp.text, re.IGNORECASE | re.DOTALL)
70
+ if m:
71
+ name = m.group(1).strip()
72
+ if name and name != code:
73
+ base["name"] = name
74
+ if base["name"] == code:
75
+ m2 = re.search(r'<h1[^>]*>\s*(.*?)\s*</h1>', resp.text, re.IGNORECASE | re.DOTALL)
76
+ if m2:
77
+ base["name"] = re.sub(r'<[^>]+>', '', m2.group(1)).strip()
78
+
79
+ # Try to get current NAV
80
+ nav_m = re.search(r'最新淨值[^>]*>.*?(\d[\d,]*\.\d+)', resp.text, re.DOTALL)
81
+ if nav_m:
82
+ base["current_price"] = float(nav_m.group(1).replace(",", ""))
83
+ except Exception as exc:
84
+ logger.warning("get_fund_info failed for %s: %s", code, exc)
85
+ return base
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Historical NAV
90
+ # ---------------------------------------------------------------------------
91
+
92
+ def fetch_fund_history(fund_code: str, months: int = 24) -> pd.DataFrame:
93
+ """
94
+ Fetch historical NAV from MoneyDJ.
95
+ NAV is a single daily price → open/high/low/close all set to NAV.
96
+ volume = 1 (placeholder, avoids division-by-zero in volume_ratio).
97
+ """
98
+ code = fund_code.upper().strip()
99
+ s = _session()
100
+ fund_url = f"https://www.moneydj.com/funddj/ya/yp010000.djhtm?a={code}"
101
+
102
+ try:
103
+ s.get(fund_url, timeout=10)
104
+ time.sleep(0.3)
105
+ except Exception:
106
+ pass
107
+
108
+ df = _try_fundhis_axd(code, s, fund_url)
109
+ if df is not None and not df.empty:
110
+ return _trim(df, months)
111
+
112
+ df = _scrape_html_table(code, s, months)
113
+ if df is not None and not df.empty:
114
+ return _trim(df, months)
115
+
116
+ raise ValueError(
117
+ f"無法取得基金 {code} 的淨值資料。"
118
+ "請確認代號是否正確(MoneyDJ 格式,如 PIM91)。"
119
+ )
120
+
121
+
122
+ def _try_fundhis_axd(code: str, s: requests.Session, referer: str) -> pd.DataFrame | None:
123
+ url = "https://www.moneydj.com/InfoSVC/FundHis.axd"
124
+ try:
125
+ resp = s.get(
126
+ url,
127
+ params={"a": code},
128
+ headers={
129
+ "Referer": referer,
130
+ "Accept": "*/*",
131
+ "X-Requested-With": "XMLHttpRequest",
132
+ },
133
+ timeout=15,
134
+ )
135
+ resp.raise_for_status()
136
+ m = re.search(r"BehaviorF\.His1\s*=\s*(\[[\s\S]*?\]);", resp.text)
137
+ if not m or m.group(1).strip() == "[]":
138
+ return None
139
+ raw = json.loads(m.group(1))
140
+ return _parse_nav_rows(raw, code)
141
+ except Exception as exc:
142
+ logger.warning("FundHis.axd error for %s: %s", code, exc)
143
+ return None
144
+
145
+
146
+ def _scrape_html_table(code: str, s: requests.Session, months: int) -> pd.DataFrame | None:
147
+ urls_to_try = [
148
+ f"https://www.moneydj.com/funddj/ya/YP011.djhtm?a={code}",
149
+ f"https://www.moneydj.com/funddj/yb/YP012.djhtm?a={code}&b={months}M",
150
+ ]
151
+ for url in urls_to_try:
152
+ try:
153
+ resp = s.get(url, timeout=15)
154
+ resp.encoding = "utf-8"
155
+ records = _parse_html_nav(resp.text)
156
+ if records:
157
+ df = pd.DataFrame(records)
158
+ df = df.sort_values("date").reset_index(drop=True)
159
+ df = df.drop_duplicates(subset=["date"])
160
+ return df
161
+ except Exception as exc:
162
+ logger.warning("HTML scrape failed for %s: %s", code, exc)
163
+ return None
164
+
165
+
166
+ def _parse_html_nav(html: str) -> list[dict]:
167
+ records = []
168
+ date_re = re.compile(r'(\d{4})[/\-](\d{1,2})[/\-](\d{1,2})')
169
+ nav_re = re.compile(r'^[\d,]+\.\d+$')
170
+ cells = re.findall(r'<td[^>]*>(.*?)</td>', html, re.IGNORECASE | re.DOTALL)
171
+ texts = [re.sub(r'<[^>]+>', '', c).strip() for c in cells]
172
+ i = 0
173
+ while i < len(texts) - 1:
174
+ dm = date_re.match(texts[i])
175
+ if dm:
176
+ nav_text = texts[i + 1].replace(',', '')
177
+ nm = nav_re.match(nav_text)
178
+ if nm:
179
+ try:
180
+ date = datetime(int(dm.group(1)), int(dm.group(2)), int(dm.group(3))).date()
181
+ nav = float(nav_text)
182
+ records.append({
183
+ "date": date,
184
+ "open": nav, "high": nav, "low": nav, "close": nav,
185
+ "volume": 1.0,
186
+ })
187
+ i += 2
188
+ continue
189
+ except ValueError:
190
+ pass
191
+ i += 1
192
+ return records
193
+
194
+
195
+ def _parse_nav_rows(raw: list, code: str) -> pd.DataFrame:
196
+ records = []
197
+ for row in raw:
198
+ if not row or len(row) < 2:
199
+ continue
200
+ try:
201
+ date_str = str(row[0]).strip()
202
+ for fmt in ("%Y/%m/%d", "%Y%m%d", "%Y-%m-%d"):
203
+ try:
204
+ date = datetime.strptime(date_str, fmt).date()
205
+ break
206
+ except ValueError:
207
+ continue
208
+ else:
209
+ continue
210
+ nav = float(str(row[1]).replace(",", ""))
211
+ records.append({
212
+ "date": date,
213
+ "open": nav, "high": nav, "low": nav, "close": nav,
214
+ "volume": 1.0,
215
+ })
216
+ except (ValueError, TypeError, IndexError):
217
+ continue
218
+ if not records:
219
+ raise ValueError(f"No NAV data parsed for {code}")
220
+ df = pd.DataFrame(records)
221
+ df = df.sort_values("date").reset_index(drop=True)
222
+ df = df.drop_duplicates(subset=["date"])
223
+ return df
224
+
225
+
226
+ def _trim(df: pd.DataFrame, months: int) -> pd.DataFrame:
227
+ from dateutil.relativedelta import relativedelta
228
+ cutoff = (datetime.today() - relativedelta(months=months)).date()
229
+ return df[df["date"] >= cutoff].reset_index(drop=True)
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # Fund search
234
+ # ---------------------------------------------------------------------------
235
+
236
+ def search_funds(query: str) -> list[dict]:
237
+ """Search known funds + MoneyDJ by name or code."""
238
+ q = query.strip().upper()
239
+ results = []
240
+
241
+ # Search hardcoded known funds first
242
+ for code, name in KNOWN_FUNDS.items():
243
+ if q in code or q.lower() in name.lower():
244
+ results.append({"stock_no": code, "name": name, "exchange": "基金"})
245
+
246
+ # Direct code lookup via MoneyDJ
247
+ if is_moneydj_fund(q) and not any(r["stock_no"] == q for r in results):
248
+ try:
249
+ info = get_fund_info(q)
250
+ results.append({"stock_no": info["stock_no"], "name": info["name"], "exchange": "基金"})
251
+ except Exception:
252
+ pass
253
+
254
+ # Name search via MoneyDJ search page
255
+ if not results:
256
+ try:
257
+ s = _session()
258
+ url = "https://www.moneydj.com/funddjx/fundsearch.xdjhtm"
259
+ resp = s.get(url, params={"SearchType": "MatchAll", "Key": query.strip()}, timeout=10)
260
+ resp.encoding = "utf-8"
261
+ pattern = re.compile(
262
+ r'yp010000\.djhtm\?a=([A-Z0-9]+)[^>]*>([^<]+)<', re.IGNORECASE
263
+ )
264
+ for m in pattern.finditer(resp.text):
265
+ code = m.group(1).strip()
266
+ name = m.group(2).strip()
267
+ if name and code:
268
+ results.append({"stock_no": code, "name": name, "exchange": "基金"})
269
+ if len(results) >= 10:
270
+ break
271
+ except Exception as exc:
272
+ logger.warning("Fund search error: %s", exc)
273
+
274
+ return results[:10]
frontend/src/App.tsx ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useCallback, useEffect } from 'react'
2
+ import { TrendingUp, Activity, BarChart2, Loader2, Star } from 'lucide-react'
3
+ import { onAuthStateChanged, signOut, User as FirebaseUser } from 'firebase/auth'
4
+ import { auth, firebaseReady } from './firebase'
5
+ import {
6
+ fetchStockHistory,
7
+ fetchPrediction,
8
+ fetchStockInfo,
9
+ OHLCVPoint,
10
+ Prediction,
11
+ StockInfo,
12
+ } from './api/stockApi'
13
+
14
+ // Components
15
+ import { CandlestickChart } from './components/CandlestickChart'
16
+ import { VolumeChart } from './components/VolumeChart'
17
+ import { RSIChart } from './components/RSIChart'
18
+ import { MACDChart } from './components/MACDChart'
19
+ import { PredictionPanel } from './components/PredictionPanel'
20
+ import { ChartCard } from './components/ChartCard'
21
+ import { Login } from './components/Login'
22
+ import { Sidebar } from './components/Sidebar'
23
+ import { Header, Market } from './components/Header'
24
+ import { FavoritesPage, FavoriteItem } from './components/FavoritesPage'
25
+ import { useFirestoreFavorites } from './hooks/useFirestoreFavorites'
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ interface AppState {
32
+ loading: boolean
33
+ predicting: boolean
34
+ error: string | null
35
+ predictionError: string | null
36
+ stockInfo: StockInfo | null
37
+ history: OHLCVPoint[]
38
+ prediction: Prediction | null
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // localStorage fallback (used when Firebase is not configured)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ const LS_SESSION = 'stock_predictor_session'
46
+ const LS_USERS = 'stock_predictor_users'
47
+
48
+ function lsGetUsers(): Array<{ username: string; password: string }> {
49
+ try {
50
+ const raw = localStorage.getItem(LS_USERS)
51
+ const users = raw ? JSON.parse(raw) : []
52
+ if (!users.find((u: any) => u.username === 'admin')) {
53
+ users.unshift({ username: 'admin', password: 'stock123' })
54
+ localStorage.setItem(LS_USERS, JSON.stringify(users))
55
+ }
56
+ return users
57
+ } catch { return [{ username: 'admin', password: 'stock123' }] }
58
+ }
59
+
60
+ function lsFavKey(username: string) { return `favorites_${username}` }
61
+
62
+ function useLsFavorites(username: string | null) {
63
+ const [favorites, setFavorites] = useState<FavoriteItem[]>(() => {
64
+ if (!username) return []
65
+ try { return JSON.parse(localStorage.getItem(lsFavKey(username)) ?? '[]') } catch { return [] }
66
+ })
67
+
68
+ const persist = (u: string, favs: FavoriteItem[]) =>
69
+ localStorage.setItem(lsFavKey(u), JSON.stringify(favs))
70
+
71
+ const toggleFavorite = (id: string, name?: string, market?: Market) => {
72
+ if (!username) return
73
+ setFavorites(prev => {
74
+ const next = prev.find(f => f.id === id)
75
+ ? prev.filter(f => f.id !== id)
76
+ : [...prev, { id, name: name ?? id, market }]
77
+ persist(username, next)
78
+ return next
79
+ })
80
+ }
81
+ const removeFavorite = (id: string) => {
82
+ if (!username) return
83
+ setFavorites(prev => {
84
+ const next = prev.filter(f => f.id !== id)
85
+ persist(username, next)
86
+ return next
87
+ })
88
+ }
89
+ const updateNote = (id: string, note: string) => {
90
+ if (!username) return
91
+ setFavorites(prev => {
92
+ const next = prev.map(f => f.id === id ? { ...f, note } : f)
93
+ persist(username, next)
94
+ return next
95
+ })
96
+ }
97
+ return { favorites, toggleFavorite, removeFavorite, updateNote }
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Main App
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export default function App() {
105
+ // --- Auth state ---
106
+ const [firebaseUser, setFirebaseUser] = useState<FirebaseUser | null | undefined>(
107
+ firebaseReady ? undefined : null
108
+ )
109
+ const [lsUser, setLsUser] = useState<string | null>(() =>
110
+ firebaseReady ? null : localStorage.getItem(LS_SESSION)
111
+ )
112
+
113
+ const isLoggedIn = firebaseReady ? !!firebaseUser : !!lsUser
114
+ const uid = firebaseReady ? (firebaseUser?.uid ?? null) : null
115
+ const displayName = firebaseReady
116
+ ? (firebaseUser?.displayName || firebaseUser?.email || '使用者')
117
+ : lsUser ?? ''
118
+ const photoURL = firebaseUser?.photoURL ?? undefined
119
+
120
+ useEffect(() => {
121
+ if (!firebaseReady) return
122
+ return onAuthStateChanged(auth, setFirebaseUser)
123
+ }, [])
124
+
125
+ const handleLogout = () => {
126
+ if (firebaseReady) {
127
+ signOut(auth)
128
+ } else {
129
+ localStorage.removeItem(LS_SESSION)
130
+ setLsUser(null)
131
+ }
132
+ setView('main')
133
+ }
134
+
135
+ const handleLsLogin = (username: string) => {
136
+ localStorage.setItem(LS_SESSION, username)
137
+ setLsUser(username)
138
+ }
139
+
140
+ const handleLsRegister = (username: string, password: string): string | null => {
141
+ const users = lsGetUsers()
142
+ if (users.find(u => u.username === username)) return '此帳號名稱已被使用'
143
+ if (password.length < 4) return '密碼至少需要 4 個字元'
144
+ users.push({ username, password })
145
+ localStorage.setItem(LS_USERS, JSON.stringify(users))
146
+ return null
147
+ }
148
+
149
+ const verifyLsLogin = (username: string, password: string): boolean => {
150
+ const users = lsGetUsers()
151
+ return !!users.find(u => u.username === username && u.password === password)
152
+ }
153
+
154
+ // --- Favorites ---
155
+ const firestore = useFirestoreFavorites(firebaseReady ? uid : null)
156
+ const ls = useLsFavorites(firebaseReady ? null : lsUser)
157
+ const { favorites, toggleFavorite, removeFavorite, updateNote } =
158
+ firebaseReady ? firestore : ls
159
+
160
+ // --- Market + View + search state ---
161
+ const [market, setMarket] = useState<Market>('tw')
162
+ const [view, setView] = useState<'main' | 'favorites'>('main')
163
+ const [input, setInput] = useState('')
164
+ const [months, setMonths] = useState(6)
165
+ const [state, setState] = useState<AppState>({
166
+ loading: false, predicting: false,
167
+ error: null, predictionError: null,
168
+ stockInfo: null, history: [], prediction: null,
169
+ })
170
+
171
+ const handleSearch = useCallback(async (stockNo: string, monthsCount: number) => {
172
+ const sno = stockNo.trim()
173
+ if (!sno) return
174
+ setInput(sno)
175
+ setView('main')
176
+ setState(s => ({ ...s, loading: true, predicting: true, error: null, predictionError: null, history: [], prediction: null }))
177
+
178
+ let info: StockInfo | null = null
179
+ let history: OHLCVPoint[] = []
180
+ let historyError: string | null = null
181
+ try {
182
+ const [infoRes, historyRes] = await Promise.allSettled([
183
+ fetchStockInfo(sno),
184
+ fetchStockHistory(sno, monthsCount),
185
+ ])
186
+ if (infoRes.status === 'fulfilled') info = infoRes.value
187
+ if (historyRes.status === 'fulfilled') history = historyRes.value
188
+ else historyError = (historyRes.reason as Error)?.message ?? 'Failed to fetch history'
189
+ } catch (err: any) { historyError = err?.message ?? 'Unknown error' }
190
+
191
+ setState(s => ({ ...s, loading: false, stockInfo: info, history, error: historyError }))
192
+
193
+ try {
194
+ const pred = await fetchPrediction(sno)
195
+ setState(s => ({ ...s, prediction: pred, predicting: false }))
196
+ } catch (err: any) {
197
+ const msg = err?.response?.data?.detail ?? err?.message ?? 'Prediction failed'
198
+ setState(s => ({ ...s, predictionError: msg, predicting: false }))
199
+ }
200
+ }, [])
201
+
202
+ const onSubmit = (e: React.FormEvent) => { e.preventDefault(); handleSearch(input, months) }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Auth gate
206
+ // ---------------------------------------------------------------------------
207
+
208
+ if (firebaseUser === undefined) {
209
+ return (
210
+ <div className="min-h-screen bg-gray-950 flex items-center justify-center">
211
+ <Loader2 size={32} className="animate-spin text-blue-400" />
212
+ </div>
213
+ )
214
+ }
215
+
216
+ if (!isLoggedIn) {
217
+ return (
218
+ <Login
219
+ mode={firebaseReady ? 'firebase' : 'local'}
220
+ onLsLogin={verifyLsLogin}
221
+ onLsLoginSuccess={handleLsLogin}
222
+ onLsRegister={handleLsRegister}
223
+ />
224
+ )
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Main UI
229
+ // ---------------------------------------------------------------------------
230
+
231
+ const { loading, predicting, error, predictionError, stockInfo, history, prediction } = state
232
+ const hasData = history.length > 0
233
+ const isFav = stockInfo && favorites.some(f => f.id === stockInfo.stock_no)
234
+
235
+ return (
236
+ <div className="min-h-screen bg-gray-950 text-gray-100 font-sans">
237
+ <Header
238
+ input={input} setInput={setInput}
239
+ months={months} setMonths={setMonths}
240
+ loading={loading}
241
+ onSubmit={onSubmit}
242
+ onLogout={handleLogout}
243
+ currentUser={displayName}
244
+ userPhotoURL={photoURL}
245
+ view={view} onViewChange={setView}
246
+ favoritesCount={favorites.length}
247
+ market={market}
248
+ onMarketChange={setMarket}
249
+ onSelect={(stockNo) => handleSearch(stockNo, months)}
250
+ />
251
+
252
+ {view === 'favorites' ? (
253
+ <FavoritesPage
254
+ favorites={favorites}
255
+ onView={(id) => { handleSearch(id, months); setView('main') }}
256
+ onRemove={removeFavorite}
257
+ onUpdateNote={updateNote}
258
+ />
259
+ ) : (
260
+ <div className="max-w-7xl mx-auto px-4 py-6 grid grid-cols-1 lg:grid-cols-4 gap-6">
261
+ <Sidebar
262
+ favorites={favorites}
263
+ onSearch={handleSearch}
264
+ onToggleFavorite={(id) => toggleFavorite(id)}
265
+ onViewFavorites={() => setView('favorites')}
266
+ months={months}
267
+ />
268
+
269
+ <main className="lg:col-span-3 space-y-6">
270
+ {stockInfo && (
271
+ <div className="flex flex-wrap items-center gap-3">
272
+ <h2 className="text-2xl font-black text-white">
273
+ {stockInfo.stock_no}
274
+ {stockInfo.name && stockInfo.name !== stockInfo.stock_no && (
275
+ <span className="ml-2 text-slate-300 font-semibold text-xl">{stockInfo.name}</span>
276
+ )}
277
+ </h2>
278
+ <button
279
+ onClick={() => toggleFavorite(stockInfo.stock_no, stockInfo.name, market)}
280
+ className={`p-2 rounded-full transition-colors ${isFav ? 'text-yellow-500' : 'text-slate-600 hover:text-slate-400'}`}
281
+ title={isFav ? '從最愛移除' : '加入最愛'}
282
+ >
283
+ <Star size={24} fill={isFav ? 'currentColor' : 'none'} />
284
+ </button>
285
+ <span className="text-xs bg-slate-700 text-slate-300 rounded px-2 py-0.5">{stockInfo.exchange}</span>
286
+ {stockInfo.current_price != null && (
287
+ <span className="text-2xl font-bold text-green-400 ml-auto">
288
+ {market === 'tw' ? 'NT$' : 'USD'} {stockInfo.current_price.toFixed(2)}
289
+ </span>
290
+ )}
291
+ </div>
292
+ )}
293
+
294
+ {error && (
295
+ <div className="bg-red-900/30 border border-red-700 rounded-xl p-4 text-red-300 text-sm">
296
+ <strong>資料載入失敗:</strong> {error}
297
+ </div>
298
+ )}
299
+
300
+ {loading && (
301
+ <div className="flex flex-col items-center justify-center py-20 gap-4">
302
+ <Loader2 size={40} className="animate-spin text-blue-400" />
303
+ <p className="text-slate-400 text-sm">
304
+ {market === 'fund' ? '正在從 MoneyDJ 取得基金淨值...' : '正在從 TPEX/TWSE 取得資料...'}
305
+ </p>
306
+ </div>
307
+ )}
308
+
309
+ {hasData && (
310
+ <div className="space-y-4">
311
+ <ChartCard title="價格走勢 & 均線" subtitle="Price & Moving Averages (MA5/MA20/MA60) + Bollinger Bands" icon={<TrendingUp size={16} className="text-blue-400" />}>
312
+ <CandlestickChart data={history} />
313
+ </ChartCard>
314
+ <ChartCard title="成交量" subtitle="Volume" icon={<BarChart2 size={16} className="text-green-400" />}>
315
+ <VolumeChart data={history} />
316
+ </ChartCard>
317
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
318
+ <ChartCard title="RSI (14)" subtitle="Relative Strength Index — 30 oversold / 70 overbought" icon={<Activity size={16} className="text-purple-400" />}>
319
+ <RSIChart data={history} />
320
+ </ChartCard>
321
+ <ChartCard title="MACD (12/26/9)" subtitle="MACD Line · Signal Line · Histogram" icon={<Activity size={16} className="text-orange-400" />}>
322
+ <MACDChart data={history} />
323
+ </ChartCard>
324
+ </div>
325
+ </div>
326
+ )}
327
+
328
+ {predicting && !prediction && (
329
+ <div className="bg-slate-900 border border-slate-700 rounded-xl p-6 flex items-center gap-4">
330
+ <Loader2 size={24} className="animate-spin text-blue-400 flex-shrink-0" />
331
+ <div>
332
+ <p className="text-white font-medium">正在訓練 ML 模型...</p>
333
+ <p className="text-slate-400 text-sm mt-0.5">Training RandomForest model (this may take 15–60 seconds)</p>
334
+ </div>
335
+ </div>
336
+ )}
337
+
338
+ {predictionError && (
339
+ <div className="bg-red-900/30 border border-red-700 rounded-xl p-4 text-red-300 text-sm">
340
+ <strong>預測失敗:</strong> {predictionError}
341
+ </div>
342
+ )}
343
+
344
+ {prediction && stockInfo && (
345
+ <PredictionPanel prediction={prediction} stockName={`${stockInfo.stock_no} ${stockInfo.name ?? ''}`} />
346
+ )}
347
+
348
+ {!loading && !hasData && !error && !state.stockInfo && (
349
+ <div className="flex flex-col items-center justify-center py-24 gap-4 text-center">
350
+ <TrendingUp size={52} className="text-slate-700" />
351
+ <h3 className="text-xl font-semibold text-slate-500">輸入代號開始分析</h3>
352
+ <p className="text-slate-600 text-sm">支援台股、美股、境外基金 (MoneyDJ 代號)</p>
353
+ </div>
354
+ )}
355
+ </main>
356
+ </div>
357
+ )}
358
+
359
+ <footer className="mt-16 border-t border-slate-800 py-6 text-center text-xs text-slate-600">
360
+ <p>資料來源: TWSE / TPEX / Yahoo Finance / MoneyDJ &nbsp;|&nbsp; 本系統僅供學術研究用途,不構成投資建議</p>
361
+ </footer>
362
+ </div>
363
+ )
364
+ }
frontend/src/api/stockApi.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Interfaces
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface OHLCVPoint {
8
+ date: string
9
+ open: number
10
+ high: number
11
+ low: number
12
+ close: number
13
+ volume: number
14
+ ma5?: number
15
+ ma10?: number
16
+ ma20?: number
17
+ ma60?: number
18
+ rsi?: number
19
+ macd?: number
20
+ macd_signal?: number
21
+ macd_hist?: number
22
+ k?: number
23
+ d?: number
24
+ bb_upper?: number
25
+ bb_lower?: number
26
+ bb_middle?: number
27
+ bb_pct_b?: number
28
+ volume_ma20?: number
29
+ volume_ratio?: number
30
+ }
31
+
32
+ export interface Prediction {
33
+ signal: 'BUY' | 'SELL' | 'HOLD'
34
+ signal_probability: number
35
+ predicted_price: number
36
+ predicted_change_pct: number
37
+ sell_target: number
38
+ stop_loss: number
39
+ confidence: 'HIGH' | 'MEDIUM' | 'LOW'
40
+ current_price: number
41
+ training_accuracy: number
42
+ disclaimer: string
43
+ }
44
+
45
+ export interface StockInfo {
46
+ stock_no: string
47
+ name: string
48
+ current_price: number | null
49
+ exchange: string
50
+ }
51
+
52
+ export interface SearchResult {
53
+ stock_no: string
54
+ name: string
55
+ exchange: string
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // API functions
60
+ // ---------------------------------------------------------------------------
61
+
62
+ // In production (Vercel), VITE_API_URL points to the Render backend.
63
+ // In local dev, Vite proxy forwards /api → localhost:8000.
64
+ const api = axios.create({
65
+ baseURL: import.meta.env.VITE_API_URL ?? '',
66
+ })
67
+
68
+ export async function fetchStockHistory(
69
+ stockNo: string,
70
+ months: number = 6
71
+ ): Promise<OHLCVPoint[]> {
72
+ const res = await api.get<{ data: OHLCVPoint[] }>(
73
+ `/api/stock/${stockNo}/history`,
74
+ { params: { months } }
75
+ )
76
+ return res.data.data
77
+ }
78
+
79
+ export async function fetchPrediction(
80
+ stockNo: string
81
+ ): Promise<Prediction> {
82
+ const res = await api.get<{ prediction: Prediction }>(
83
+ `/api/stock/${stockNo}/predict`
84
+ )
85
+ return res.data.prediction
86
+ }
87
+
88
+ export async function fetchStockInfo(stockNo: string): Promise<StockInfo> {
89
+ const res = await api.get<StockInfo>(`/api/stock/${stockNo}/info`)
90
+ return res.data
91
+ }
92
+
93
+ export async function searchStocks(query: string, market: 'tw' | 'us' | 'fund' = 'tw'): Promise<SearchResult[]> {
94
+ const res = await api.get<{ results: SearchResult[] }>(
95
+ `/api/stock/search`,
96
+ { params: { q: query, market } }
97
+ )
98
+ return res.data.results
99
+ }
frontend/src/components/CandlestickChart.tsx ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useMemo } from 'react'
2
+ import {
3
+ ComposedChart,
4
+ Line,
5
+ Area,
6
+ XAxis,
7
+ YAxis,
8
+ CartesianGrid,
9
+ Tooltip,
10
+ Legend,
11
+ ResponsiveContainer,
12
+ } from 'recharts'
13
+ import { OHLCVPoint } from '../api/stockApi'
14
+
15
+ interface Props {
16
+ data: OHLCVPoint[]
17
+ }
18
+
19
+ // Show every Nth date label to avoid crowding
20
+ function tickFormatter(value: string, index: number, total: number): string {
21
+ const step = Math.max(1, Math.floor(total / 10))
22
+ if (index % step === 0) return value
23
+ return ''
24
+ }
25
+
26
+ const CustomTooltip = ({ active, payload, label }: any) => {
27
+ if (!active || !payload?.length) return null
28
+ const d: OHLCVPoint = payload[0]?.payload
29
+ if (!d) return null
30
+ return (
31
+ <div className="bg-slate-800 border border-slate-600 rounded p-3 text-xs space-y-1 shadow-lg">
32
+ <p className="text-slate-300 font-semibold">{label}</p>
33
+ <p>O: <span className="text-white">{d.open?.toFixed(2)}</span></p>
34
+ <p>H: <span className="text-green-400">{d.high?.toFixed(2)}</span></p>
35
+ <p>L: <span className="text-red-400">{d.low?.toFixed(2)}</span></p>
36
+ <p>C: <span className="text-yellow-300 font-bold">{d.close?.toFixed(2)}</span></p>
37
+ {d.ma5 != null && <p>MA5: <span className="text-yellow-400">{d.ma5.toFixed(2)}</span></p>}
38
+ {d.ma20 != null && <p>MA20: <span className="text-blue-400">{d.ma20.toFixed(2)}</span></p>}
39
+ {d.ma60 != null && <p>MA60: <span className="text-red-400">{d.ma60.toFixed(2)}</span></p>}
40
+ </div>
41
+ )
42
+ }
43
+
44
+ export const CandlestickChart: React.FC<Props> = ({ data }) => {
45
+ const len = data.length
46
+
47
+ // Build Bollinger band area data: pairs of [lower, upper] for Area fill
48
+ const chartData = useMemo(
49
+ () =>
50
+ data.map((d) => ({
51
+ ...d,
52
+ bb_band: d.bb_upper != null && d.bb_lower != null
53
+ ? [d.bb_lower, d.bb_upper]
54
+ : [null, null],
55
+ })),
56
+ [data]
57
+ )
58
+
59
+ const allPrices = data.flatMap((d) => [d.low, d.high, d.bb_upper, d.bb_lower].filter(Boolean) as number[])
60
+ const yMin = Math.floor(Math.min(...allPrices) * 0.995)
61
+ const yMax = Math.ceil(Math.max(...allPrices) * 1.005)
62
+
63
+ return (
64
+ <ResponsiveContainer width="100%" height={320}>
65
+ <ComposedChart data={chartData} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
66
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
67
+ <XAxis
68
+ dataKey="date"
69
+ tick={{ fill: '#94a3b8', fontSize: 11 }}
70
+ tickLine={false}
71
+ tickFormatter={(val, idx) => tickFormatter(val, idx, len)}
72
+ interval={0}
73
+ />
74
+ <YAxis
75
+ domain={[yMin, yMax]}
76
+ tick={{ fill: '#94a3b8', fontSize: 11 }}
77
+ tickLine={false}
78
+ tickFormatter={(v) => v.toFixed(0)}
79
+ width={55}
80
+ />
81
+ <Tooltip content={<CustomTooltip />} />
82
+ <Legend
83
+ wrapperStyle={{ fontSize: 12, paddingTop: 4 }}
84
+ formatter={(value) => <span style={{ color: '#94a3b8' }}>{value}</span>}
85
+ />
86
+
87
+ {/* Bollinger Band fill — rendered first so it's behind price lines */}
88
+ <Area
89
+ dataKey="bb_upper"
90
+ stroke="#334155"
91
+ strokeWidth={1}
92
+ fill="#1e293b"
93
+ fillOpacity={0.5}
94
+ dot={false}
95
+ legendType="none"
96
+ name="BB Upper"
97
+ isAnimationActive={false}
98
+ />
99
+ <Area
100
+ dataKey="bb_lower"
101
+ stroke="#334155"
102
+ strokeWidth={1}
103
+ fill="#0f172a"
104
+ fillOpacity={1}
105
+ dot={false}
106
+ legendType="none"
107
+ name="BB Lower"
108
+ isAnimationActive={false}
109
+ />
110
+
111
+ {/* Close price as thick line */}
112
+ <Line
113
+ dataKey="close"
114
+ stroke="#e2e8f0"
115
+ strokeWidth={2}
116
+ dot={false}
117
+ name="Close"
118
+ isAnimationActive={false}
119
+ />
120
+
121
+ {/* Moving averages */}
122
+ <Line
123
+ dataKey="ma5"
124
+ stroke="#facc15"
125
+ strokeWidth={1.5}
126
+ dot={false}
127
+ name="MA5"
128
+ isAnimationActive={false}
129
+ connectNulls
130
+ />
131
+ <Line
132
+ dataKey="ma20"
133
+ stroke="#60a5fa"
134
+ strokeWidth={1.5}
135
+ dot={false}
136
+ name="MA20"
137
+ isAnimationActive={false}
138
+ connectNulls
139
+ />
140
+ <Line
141
+ dataKey="ma60"
142
+ stroke="#f87171"
143
+ strokeWidth={1.5}
144
+ dot={false}
145
+ name="MA60"
146
+ isAnimationActive={false}
147
+ connectNulls
148
+ />
149
+ </ComposedChart>
150
+ </ResponsiveContainer>
151
+ )
152
+ }
frontend/src/components/ChartCard.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ interface ChartCardProps {
4
+ title: string
5
+ subtitle: string
6
+ icon: React.ReactNode
7
+ children: React.ReactNode
8
+ }
9
+
10
+ export const ChartCard: React.FC<ChartCardProps> = ({ title, subtitle, icon, children }) => (
11
+ <div className="bg-slate-900 border border-slate-800 rounded-xl p-4 space-y-3">
12
+ <div className="flex items-center gap-2">
13
+ {icon}
14
+ <div>
15
+ <h3 className="text-sm font-semibold text-white leading-tight">{title}</h3>
16
+ <p className="text-xs text-slate-500 leading-tight">{subtitle}</p>
17
+ </div>
18
+ </div>
19
+ {children}
20
+ </div>
21
+ )
frontend/src/components/FavoritesPage.tsx ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react'
2
+ import { Star, Trash2, ExternalLink, RefreshCw, FileText } from 'lucide-react'
3
+ import { fetchStockInfo, StockInfo } from '../api/stockApi'
4
+ import type { Market } from './Header'
5
+
6
+ export interface FavoriteItem {
7
+ id: string
8
+ name: string
9
+ note?: string
10
+ market?: Market
11
+ }
12
+
13
+ const MARKET_FILTER_TABS: { key: Market | 'all'; label: string; flag: string }[] = [
14
+ { key: 'all', label: '全部', flag: '⭐' },
15
+ { key: 'tw', label: '台股', flag: '🇹🇼' },
16
+ { key: 'us', label: '美股', flag: '🇺🇸' },
17
+ { key: 'fund', label: '基金', flag: '📊' },
18
+ ]
19
+
20
+ interface FavoritesPageProps {
21
+ favorites: FavoriteItem[]
22
+ onView: (id: string) => void
23
+ onRemove: (id: string) => void
24
+ onUpdateNote: (id: string, note: string) => void
25
+ }
26
+
27
+ const FavoriteCard: React.FC<{
28
+ fav: FavoriteItem
29
+ onView: (id: string) => void
30
+ onRemove: (id: string) => void
31
+ onUpdateNote: (id: string, note: string) => void
32
+ }> = ({ fav, onView, onRemove, onUpdateNote }) => {
33
+ const [info, setInfo] = useState<StockInfo | null>(null)
34
+ const [fetching, setFetching] = useState(false)
35
+ const [note, setNote] = useState(fav.note ?? '')
36
+
37
+ const loadInfo = async () => {
38
+ setFetching(true)
39
+ try {
40
+ const data = await fetchStockInfo(fav.id)
41
+ setInfo(data)
42
+ } catch { /* ignore */ }
43
+ setFetching(false)
44
+ }
45
+
46
+ useEffect(() => { loadInfo() }, [fav.id])
47
+
48
+ const market = fav.market ?? 'tw'
49
+ const exchangeStyle =
50
+ market === 'fund'
51
+ ? 'bg-yellow-900/50 text-yellow-300'
52
+ : market === 'us'
53
+ ? 'bg-green-900/50 text-green-300'
54
+ : info?.exchange === 'TPEX'
55
+ ? 'bg-purple-900/50 text-purple-300'
56
+ : 'bg-blue-900/50 text-blue-300'
57
+
58
+ const marketLabel = info?.exchange ?? (market === 'fund' ? '基金' : market === 'us' ? 'US' : '台股')
59
+
60
+ const pricePrefix = market === 'fund' ? 'USD ' : market === 'us' ? 'USD ' : 'NT$ '
61
+
62
+ return (
63
+ <div className="bg-slate-900 border border-slate-800 hover:border-slate-600 rounded-xl p-4 space-y-3 transition-colors flex flex-col">
64
+ {/* Top row */}
65
+ <div className="flex items-start justify-between">
66
+ <div className="space-y-0.5">
67
+ <div className="flex items-center gap-2 flex-wrap">
68
+ <span className="font-mono font-bold text-white text-lg leading-none">{fav.id}</span>
69
+ <span className={`text-xs rounded px-1.5 py-0.5 font-medium ${exchangeStyle}`}>
70
+ {marketLabel}
71
+ </span>
72
+ </div>
73
+ <p className="text-slate-300 text-sm truncate max-w-[160px]">
74
+ {info?.name || fav.name}
75
+ </p>
76
+ </div>
77
+ <div className="flex items-center gap-0.5 flex-shrink-0 ml-2">
78
+ <button onClick={loadInfo} disabled={fetching} className="p-1.5 text-slate-500 hover:text-slate-300 transition-colors rounded" title="重新整理價格">
79
+ <RefreshCw size={13} className={fetching ? 'animate-spin' : ''} />
80
+ </button>
81
+ <button onClick={() => onRemove(fav.id)} className="p-1.5 text-slate-500 hover:text-red-400 transition-colors rounded" title="從最愛移除">
82
+ <Trash2 size={13} />
83
+ </button>
84
+ </div>
85
+ </div>
86
+
87
+ {/* Price row */}
88
+ <div className="flex items-center justify-between">
89
+ <div>
90
+ {fetching ? (
91
+ <span className="text-slate-600 text-sm">載入中...</span>
92
+ ) : info?.current_price != null ? (
93
+ <span className="text-xl font-bold text-green-400">
94
+ {pricePrefix}{info.current_price.toFixed(2)}
95
+ </span>
96
+ ) : (
97
+ <span className="text-slate-600 text-sm">— 無報價 —</span>
98
+ )}
99
+ </div>
100
+ <button
101
+ onClick={() => onView(fav.id)}
102
+ className="flex items-center gap-1.5 bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
103
+ >
104
+ <ExternalLink size={12} />
105
+ 查看分析
106
+ </button>
107
+ </div>
108
+
109
+ {/* Note */}
110
+ <div className="relative mt-auto">
111
+ <FileText size={12} className="absolute left-2.5 top-2.5 text-slate-600 pointer-events-none" />
112
+ <textarea
113
+ value={note}
114
+ onChange={e => setNote(e.target.value)}
115
+ onBlur={() => onUpdateNote(fav.id, note)}
116
+ placeholder="備註 (失焦自動儲存)..."
117
+ rows={2}
118
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-7 pr-3 py-2 text-xs text-slate-300 resize-none focus:outline-none focus:border-slate-500 placeholder-slate-600"
119
+ />
120
+ </div>
121
+ </div>
122
+ )
123
+ }
124
+
125
+ export const FavoritesPage: React.FC<FavoritesPageProps> = ({
126
+ favorites,
127
+ onView,
128
+ onRemove,
129
+ onUpdateNote,
130
+ }) => {
131
+ const [activeTab, setActiveTab] = useState<Market | 'all'>('all')
132
+
133
+ const filtered = activeTab === 'all'
134
+ ? favorites
135
+ : favorites.filter(f => (f.market ?? 'tw') === activeTab)
136
+
137
+ const countByMarket = (m: Market | 'all') =>
138
+ m === 'all' ? favorites.length : favorites.filter(f => (f.market ?? 'tw') === m).length
139
+
140
+ return (
141
+ <div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
142
+ <div className="flex items-center gap-3">
143
+ <Star className="text-yellow-500" size={22} fill="currentColor" />
144
+ <h2 className="text-2xl font-bold text-white">我的最愛</h2>
145
+ <span className="bg-slate-800 text-slate-400 text-xs rounded-full px-2.5 py-0.5 font-medium">
146
+ {favorites.length} 支
147
+ </span>
148
+ </div>
149
+
150
+ {/* Market filter tabs */}
151
+ <div className="flex items-center gap-1 bg-slate-900 border border-slate-800 rounded-xl p-1 w-fit">
152
+ {MARKET_FILTER_TABS.map(tab => {
153
+ const count = countByMarket(tab.key)
154
+ return (
155
+ <button
156
+ key={tab.key}
157
+ onClick={() => setActiveTab(tab.key)}
158
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
159
+ activeTab === tab.key
160
+ ? 'bg-slate-700 text-white shadow-sm'
161
+ : 'text-slate-400 hover:text-white'
162
+ }`}
163
+ >
164
+ <span>{tab.flag}</span>
165
+ <span>{tab.label}</span>
166
+ {count > 0 && (
167
+ <span className={`text-[10px] font-bold rounded-full min-w-[16px] h-4 px-1 flex items-center justify-center ${
168
+ activeTab === tab.key ? 'bg-blue-500 text-white' : 'bg-slate-700 text-slate-300'
169
+ }`}>
170
+ {count}
171
+ </span>
172
+ )}
173
+ </button>
174
+ )
175
+ })}
176
+ </div>
177
+
178
+ {filtered.length === 0 ? (
179
+ <div className="flex flex-col items-center justify-center py-28 gap-4 text-center">
180
+ <Star size={52} className="text-slate-700" />
181
+ <p className="text-slate-400 font-medium">
182
+ {activeTab === 'all' ? '尚未加入任何股票' : `尚未加入任何${MARKET_FILTER_TABS.find(t => t.key === activeTab)?.label}項目`}
183
+ </p>
184
+ <p className="text-slate-600 text-sm">在股票分析頁面點擊 ★ 按鈕即可加入最愛</p>
185
+ </div>
186
+ ) : (
187
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
188
+ {filtered.map(fav => (
189
+ <FavoriteCard
190
+ key={fav.id}
191
+ fav={fav}
192
+ onView={onView}
193
+ onRemove={onRemove}
194
+ onUpdateNote={onUpdateNote}
195
+ />
196
+ ))}
197
+ </div>
198
+ )}
199
+ </div>
200
+ )
201
+ }
frontend/src/components/Header.tsx ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react'
2
+ import { Search, TrendingUp, Loader2, LogOut, Star, BarChart2, User, X } from 'lucide-react'
3
+ import { searchStocks, SearchResult } from '../api/stockApi'
4
+
5
+ export type Market = 'tw' | 'us' | 'fund'
6
+
7
+ const MARKET_TABS: { key: Market; label: string; flag: string }[] = [
8
+ { key: 'tw', label: '台股', flag: '🇹🇼' },
9
+ { key: 'us', label: '美股', flag: '🇺🇸' },
10
+ { key: 'fund', label: '基金', flag: '📊' },
11
+ ]
12
+
13
+ const MARKET_PLACEHOLDER: Record<Market, string> = {
14
+ tw: '台股代號 (e.g. 7856)',
15
+ us: '美股代號 (e.g. AAPL)',
16
+ fund: '基金代號 (e.g. PIM91)',
17
+ }
18
+
19
+ interface HeaderProps {
20
+ input: string
21
+ setInput: (val: string) => void
22
+ months: number
23
+ setMonths: (val: number) => void
24
+ loading: boolean
25
+ onSubmit: (e: React.FormEvent) => void
26
+ onLogout: () => void
27
+ currentUser: string
28
+ userPhotoURL?: string
29
+ view: 'main' | 'favorites'
30
+ onViewChange: (v: 'main' | 'favorites') => void
31
+ favoritesCount: number
32
+ market: Market
33
+ onMarketChange: (m: Market) => void
34
+ onSelect: (stockNo: string) => void
35
+ }
36
+
37
+ export const Header: React.FC<HeaderProps> = ({
38
+ input, setInput,
39
+ months, setMonths,
40
+ loading,
41
+ onSubmit,
42
+ onLogout,
43
+ currentUser,
44
+ userPhotoURL,
45
+ view, onViewChange,
46
+ favoritesCount,
47
+ market, onMarketChange,
48
+ onSelect,
49
+ }) => {
50
+ const [suggestions, setSuggestions] = useState<SearchResult[]>([])
51
+ const [showDrop, setShowDrop] = useState(false)
52
+ const [searching, setSearching] = useState(false)
53
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
54
+ const wrapperRef = useRef<HTMLDivElement>(null)
55
+
56
+ // Close dropdown when clicking outside
57
+ useEffect(() => {
58
+ const handler = (e: MouseEvent) => {
59
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
60
+ setShowDrop(false)
61
+ }
62
+ }
63
+ document.addEventListener('mousedown', handler)
64
+ return () => document.removeEventListener('mousedown', handler)
65
+ }, [])
66
+
67
+ const fetchSuggestions = useCallback(async (q: string, mkt: Market) => {
68
+ if (!q.trim()) { setSuggestions([]); setShowDrop(false); return }
69
+ // For TW: need at least 1 char; for US/fund: 1 char
70
+ if (q.length < 1) return
71
+ setSearching(true)
72
+ try {
73
+ const results = await searchStocks(q, mkt)
74
+ setSuggestions(results)
75
+ setShowDrop(results.length > 0)
76
+ } catch {
77
+ setSuggestions([])
78
+ setShowDrop(false)
79
+ } finally {
80
+ setSearching(false)
81
+ }
82
+ }, [])
83
+
84
+ const handleInputChange = (val: string) => {
85
+ setInput(val)
86
+ if (debounceRef.current) clearTimeout(debounceRef.current)
87
+ debounceRef.current = setTimeout(() => fetchSuggestions(val, market), 350)
88
+ }
89
+
90
+ const handleSelect = (stockNo: string) => {
91
+ setInput(stockNo)
92
+ setSuggestions([])
93
+ setShowDrop(false)
94
+ onSelect(stockNo)
95
+ }
96
+
97
+ const handleMarketChange = (m: Market) => {
98
+ onMarketChange(m)
99
+ setSuggestions([])
100
+ setShowDrop(false)
101
+ setInput('')
102
+ }
103
+
104
+ const handleFormSubmit = (e: React.FormEvent) => {
105
+ setShowDrop(false)
106
+ onSubmit(e)
107
+ }
108
+
109
+ const exchangeColor: Record<string, string> = {
110
+ TWSE: 'text-blue-400', TPEX: 'text-purple-400',
111
+ 基金: 'text-yellow-400', MoneyDJ: 'text-yellow-400',
112
+ NYSE: 'text-green-400', NASDAQ: 'text-green-400',
113
+ }
114
+
115
+ return (
116
+ <header className="bg-slate-900 border-b border-slate-800 sticky top-0 z-20">
117
+ <div className="max-w-7xl mx-auto px-4 py-3 flex flex-col sm:flex-row items-start sm:items-center gap-3">
118
+
119
+ {/* Brand */}
120
+ <div className="flex items-center gap-2 mr-2 flex-shrink-0">
121
+ <TrendingUp className="text-blue-400" size={22} />
122
+ <h1 className="text-lg font-bold tracking-tight text-white whitespace-nowrap hidden sm:block">台股 ML 預測</h1>
123
+ </div>
124
+
125
+ {/* View nav (分析 / 最愛) */}
126
+ <nav className="flex items-center gap-1 flex-shrink-0">
127
+ <button
128
+ onClick={() => onViewChange('main')}
129
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
130
+ view === 'main' ? 'bg-blue-600/20 text-blue-400' : 'text-slate-400 hover:text-white hover:bg-slate-800'
131
+ }`}
132
+ >
133
+ <BarChart2 size={14} /> 分析
134
+ </button>
135
+ <button
136
+ onClick={() => onViewChange('favorites')}
137
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
138
+ view === 'favorites' ? 'bg-yellow-500/20 text-yellow-400' : 'text-slate-400 hover:text-white hover:bg-slate-800'
139
+ }`}
140
+ >
141
+ <Star size={14} fill={view === 'favorites' ? 'currentColor' : 'none'} />
142
+ 最愛
143
+ {favoritesCount > 0 && (
144
+ <span className="bg-yellow-500 text-black text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center leading-none">
145
+ {favoritesCount > 9 ? '9+' : favoritesCount}
146
+ </span>
147
+ )}
148
+ </button>
149
+ </nav>
150
+
151
+ {/* Market tabs + search (only on main view) */}
152
+ {view === 'main' && (
153
+ <div className="flex items-center gap-2 flex-1">
154
+ {/* Market tabs */}
155
+ <div className="flex items-center bg-slate-800 rounded-lg p-0.5 flex-shrink-0">
156
+ {MARKET_TABS.map(tab => (
157
+ <button
158
+ key={tab.key}
159
+ onClick={() => handleMarketChange(tab.key)}
160
+ className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${
161
+ market === tab.key
162
+ ? 'bg-slate-700 text-white shadow-sm'
163
+ : 'text-slate-400 hover:text-white'
164
+ }`}
165
+ >
166
+ <span>{tab.flag}</span>
167
+ <span className="hidden sm:inline">{tab.label}</span>
168
+ </button>
169
+ ))}
170
+ </div>
171
+
172
+ {/* Search with autocomplete */}
173
+ <form onSubmit={handleFormSubmit} className="flex items-center gap-2 flex-1">
174
+ <div className="relative flex-1 sm:flex-none" ref={wrapperRef}>
175
+ <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none z-10" />
176
+ <input
177
+ type="text"
178
+ value={input}
179
+ onChange={(e) => handleInputChange(e.target.value)}
180
+ onFocus={() => suggestions.length > 0 && setShowDrop(true)}
181
+ placeholder={MARKET_PLACEHOLDER[market]}
182
+ className="bg-slate-800 border border-slate-600 rounded-lg pl-8 pr-7 py-2 text-sm w-full sm:w-52 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-white placeholder-slate-500"
183
+ />
184
+ {input && (
185
+ <button
186
+ type="button"
187
+ onClick={() => { setInput(''); setSuggestions([]); setShowDrop(false) }}
188
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white"
189
+ >
190
+ <X size={12} />
191
+ </button>
192
+ )}
193
+
194
+ {/* Autocomplete dropdown */}
195
+ {showDrop && (
196
+ <div className="absolute top-full left-0 right-0 mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto">
197
+ {searching && (
198
+ <div className="px-3 py-2 text-xs text-slate-500 flex items-center gap-2">
199
+ <Loader2 size={12} className="animate-spin" /> 搜尋中...
200
+ </div>
201
+ )}
202
+ {suggestions.map((s) => (
203
+ <button
204
+ key={s.stock_no}
205
+ type="button"
206
+ onClick={() => handleSelect(s.stock_no)}
207
+ className="w-full px-3 py-2 text-left hover:bg-slate-700 flex items-center justify-between gap-2 transition-colors"
208
+ >
209
+ <div className="flex items-center gap-2 min-w-0">
210
+ <span className="font-mono font-bold text-white text-sm flex-shrink-0">{s.stock_no}</span>
211
+ <span className="text-slate-300 text-xs truncate">{s.name}</span>
212
+ </div>
213
+ <span className={`text-[10px] font-medium flex-shrink-0 ${exchangeColor[s.exchange] ?? 'text-slate-400'}`}>
214
+ {s.exchange}
215
+ </span>
216
+ </button>
217
+ ))}
218
+ </div>
219
+ )}
220
+ </div>
221
+
222
+ <select
223
+ value={months}
224
+ onChange={(e) => setMonths(Number(e.target.value))}
225
+ className="bg-slate-800 border border-slate-600 rounded-lg px-2 py-2 text-sm focus:outline-none focus:border-blue-500 text-white flex-shrink-0"
226
+ >
227
+ <option value={3}>3個月</option>
228
+ <option value={6}>6個月</option>
229
+ <option value={12}>12個月</option>
230
+ <option value={24}>24個月</option>
231
+ </select>
232
+
233
+ <button
234
+ type="submit"
235
+ disabled={loading}
236
+ className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 disabled:cursor-not-allowed text-white rounded-lg px-4 py-2 text-sm font-medium transition-colors flex-shrink-0"
237
+ >
238
+ {loading ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
239
+ <span className="hidden sm:inline">查詢</span>
240
+ </button>
241
+ </form>
242
+ </div>
243
+ )}
244
+
245
+ {/* User avatar + logout */}
246
+ <div className="flex items-center gap-2 sm:ml-auto flex-shrink-0">
247
+ {userPhotoURL ? (
248
+ <img src={userPhotoURL} alt={currentUser} className="w-7 h-7 rounded-full border border-slate-600 object-cover" referrerPolicy="no-referrer" />
249
+ ) : (
250
+ <div className="w-7 h-7 rounded-full bg-slate-700 flex items-center justify-center">
251
+ <User size={14} className="text-slate-400" />
252
+ </div>
253
+ )}
254
+ <span className="hidden sm:inline text-xs font-medium text-slate-300 max-w-[100px] truncate">{currentUser}</span>
255
+ <button
256
+ onClick={onLogout}
257
+ className="p-2 text-slate-400 hover:text-white transition-colors rounded-lg hover:bg-slate-800"
258
+ title="登出"
259
+ type="button"
260
+ >
261
+ <LogOut size={18} />
262
+ </button>
263
+ </div>
264
+ </div>
265
+ </header>
266
+ )
267
+ }
frontend/src/components/Login.tsx ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react'
2
+ import { Lock, Mail, TrendingUp, UserPlus, LogIn, User } from 'lucide-react'
3
+ import { signInWithPopup, signInWithEmailAndPassword, createUserWithEmailAndPassword, updateProfile } from 'firebase/auth'
4
+ import { auth, googleProvider, firebaseReady } from '../firebase'
5
+
6
+ // Google "G" SVG icon
7
+ const GoogleIcon = () => (
8
+ <svg viewBox="0 0 24 24" width="18" height="18">
9
+ <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
10
+ <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
11
+ <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
12
+ <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
13
+ </svg>
14
+ )
15
+
16
+ interface LoginProps {
17
+ mode: 'firebase' | 'local'
18
+ onLsLogin: (username: string, password: string) => boolean
19
+ onLsLoginSuccess: (username: string) => void
20
+ onLsRegister: (username: string, password: string) => string | null
21
+ }
22
+
23
+ export const Login: React.FC<LoginProps> = ({ mode, onLsLogin, onLsLoginSuccess, onLsRegister }) => {
24
+ const [tab, setTab] = useState<'login' | 'register'>('login')
25
+ const [displayName, setDisplayName] = useState('')
26
+ const [emailOrUser, setEmailOrUser] = useState('')
27
+ const [password, setPassword] = useState('')
28
+ const [confirm, setConfirm] = useState('')
29
+ const [error, setError] = useState('')
30
+ const [regSuccess, setRegSuccess] = useState(false)
31
+ const [loading, setLoading] = useState(false)
32
+
33
+ const switchTab = (t: 'login' | 'register') => {
34
+ setTab(t); setError(''); setRegSuccess(false); setPassword(''); setConfirm('')
35
+ }
36
+
37
+ // ── Firebase: Google popup ────────────────────────────────────────────────
38
+ const handleGoogle = async () => {
39
+ setError(''); setLoading(true)
40
+ try { await signInWithPopup(auth, googleProvider) }
41
+ catch (e: any) { setError(friendlyFirebaseError(e.code)) }
42
+ setLoading(false)
43
+ }
44
+
45
+ // ── Firebase: email/password ──────────────────────────────────────────────
46
+ const handleFirebaseSubmit = async (e: React.FormEvent) => {
47
+ e.preventDefault(); setError('')
48
+ if (!emailOrUser || !password) { setError('請填寫所有欄位'); return }
49
+ setLoading(true)
50
+ try {
51
+ if (tab === 'register') {
52
+ if (password.length < 6) { setError('密碼至少需要 6 個字元'); setLoading(false); return }
53
+ if (password !== confirm) { setError('兩次密碼不一致'); setLoading(false); return }
54
+ const cred = await createUserWithEmailAndPassword(auth, emailOrUser, password)
55
+ if (displayName.trim()) await updateProfile(cred.user, { displayName: displayName.trim() })
56
+ setRegSuccess(true); switchTab('login')
57
+ } else {
58
+ await signInWithEmailAndPassword(auth, emailOrUser, password)
59
+ }
60
+ } catch (e: any) { setError(friendlyFirebaseError(e.code)) }
61
+ setLoading(false)
62
+ }
63
+
64
+ // ── localStorage fallback ─────────────────────────────────────────────────
65
+ const handleLocalSubmit = (e: React.FormEvent) => {
66
+ e.preventDefault(); setError('')
67
+ const u = emailOrUser.trim()
68
+ if (!u || !password) { setError('請填寫所有欄位'); return }
69
+ if (tab === 'register') {
70
+ if (password !== confirm) { setError('兩次密碼不一致'); return }
71
+ const err = onLsRegister(u, password)
72
+ if (err) { setError(err); return }
73
+ setRegSuccess(true); switchTab('login')
74
+ } else {
75
+ if (!onLsLogin(u, password)) { setError('帳號或密碼錯誤'); return }
76
+ onLsLoginSuccess(u)
77
+ }
78
+ }
79
+
80
+ const isFirebase = mode === 'firebase'
81
+
82
+ return (
83
+ <div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
84
+ <div className="w-full max-w-sm space-y-6">
85
+ {/* Brand */}
86
+ <div className="text-center space-y-1">
87
+ <div className="flex items-center justify-center gap-2 mb-2">
88
+ <TrendingUp className="text-blue-400" size={28} />
89
+ <h1 className="text-2xl font-bold text-white">台股 ML 預測系統</h1>
90
+ </div>
91
+ <p className="text-slate-500 text-sm">AI 技術分析 · RandomForest 預測</p>
92
+ </div>
93
+
94
+ <div className="bg-slate-900 border border-slate-800 rounded-2xl p-8 shadow-2xl space-y-5">
95
+ {/* Tabs */}
96
+ <div className="flex bg-slate-800 rounded-lg p-1 gap-1">
97
+ {(['login', 'register'] as const).map(t => (
98
+ <button key={t} type="button" onClick={() => switchTab(t)}
99
+ className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-md text-sm font-medium transition-colors ${
100
+ tab === t ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'
101
+ }`}>
102
+ {t === 'login' ? <><LogIn size={14} /> 登入</> : <><UserPlus size={14} /> 註冊</>}
103
+ </button>
104
+ ))}
105
+ </div>
106
+
107
+ {/* Google button — only when Firebase is configured */}
108
+ {isFirebase && (
109
+ <>
110
+ <button type="button" onClick={handleGoogle} disabled={loading}
111
+ className="w-full flex items-center justify-center gap-2.5 bg-white hover:bg-gray-100 disabled:bg-gray-200 text-gray-800 font-medium py-2.5 rounded-lg transition-colors text-sm">
112
+ <GoogleIcon />
113
+ 使用 Google 帳號{tab === 'register' ? '註冊' : '登入'}
114
+ </button>
115
+ <div className="flex items-center gap-3">
116
+ <div className="flex-1 h-px bg-slate-700" />
117
+ <span className="text-xs text-slate-500">或用電子郵件</span>
118
+ <div className="flex-1 h-px bg-slate-700" />
119
+ </div>
120
+ </>
121
+ )}
122
+
123
+ {regSuccess && (
124
+ <div className="bg-green-900/30 border border-green-700 rounded-lg px-3 py-2 text-green-400 text-sm text-center">
125
+ 帳號建立成功!請登入
126
+ </div>
127
+ )}
128
+
129
+ {/* Form */}
130
+ <form onSubmit={isFirebase ? handleFirebaseSubmit : handleLocalSubmit} className="space-y-3">
131
+ {tab === 'register' && isFirebase && (
132
+ <div className="relative">
133
+ <User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
134
+ <input type="text" value={displayName} onChange={e => setDisplayName(e.target.value)}
135
+ placeholder="顯示名稱(選填)"
136
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-8 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 placeholder-slate-500" />
137
+ </div>
138
+ )}
139
+
140
+ <div className="relative">
141
+ {isFirebase
142
+ ? <Mail size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
143
+ : <User size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
144
+ }
145
+ <input
146
+ type={isFirebase ? 'email' : 'text'}
147
+ value={emailOrUser}
148
+ onChange={e => setEmailOrUser(e.target.value)}
149
+ placeholder={isFirebase ? '電子郵件' : '帳號'}
150
+ autoFocus autoComplete="username"
151
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-8 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 placeholder-slate-500"
152
+ />
153
+ </div>
154
+
155
+ <div className="relative">
156
+ <Lock size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
157
+ <input type="password" value={password} onChange={e => setPassword(e.target.value)}
158
+ placeholder="密碼" autoComplete={tab === 'register' ? 'new-password' : 'current-password'}
159
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-8 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 placeholder-slate-500" />
160
+ </div>
161
+
162
+ {tab === 'register' && (
163
+ <div className="relative">
164
+ <Lock size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
165
+ <input type="password" value={confirm} onChange={e => setConfirm(e.target.value)}
166
+ placeholder="確認密碼" autoComplete="new-password"
167
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-8 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 placeholder-slate-500" />
168
+ </div>
169
+ )}
170
+
171
+ {error && <p className="text-red-400 text-xs">{error}</p>}
172
+
173
+ <button type="submit" disabled={loading}
174
+ className="w-full bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-bold py-2.5 rounded-lg transition-colors text-sm">
175
+ {loading ? '處理中...'
176
+ : tab === 'login' ? (isFirebase ? '電子郵件登入' : '確認登入')
177
+ : '建立帳號'}
178
+ </button>
179
+ </form>
180
+
181
+ </div>
182
+ </div>
183
+ </div>
184
+ )
185
+ }
186
+
187
+ function friendlyFirebaseError(code: string): string {
188
+ const map: Record<string, string> = {
189
+ 'auth/user-not-found': '找不到此帳號',
190
+ 'auth/wrong-password': '密碼錯誤',
191
+ 'auth/invalid-credential': '帳號或密碼錯誤',
192
+ 'auth/email-already-in-use': '此電子郵件已被使用',
193
+ 'auth/invalid-email': '電子郵件格式不正確',
194
+ 'auth/weak-password': '密碼強度不足(至少 6 字元)',
195
+ 'auth/popup-closed-by-user': '登入視窗已關閉,請再試一次',
196
+ 'auth/network-request-failed': '網路連線錯誤,請稍後再試',
197
+ 'auth/too-many-requests': '嘗試次數過多,請稍後再試',
198
+ }
199
+ return map[code] ?? `登入失敗 (${code})`
200
+ }
201
+
202
+ // suppress unused warning — firebaseReady is imported for side-effect (init guard)
203
+ void firebaseReady
frontend/src/components/MACDChart.tsx ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import {
3
+ ComposedChart,
4
+ Bar,
5
+ Line,
6
+ Cell,
7
+ XAxis,
8
+ YAxis,
9
+ CartesianGrid,
10
+ Tooltip,
11
+ Legend,
12
+ ReferenceLine,
13
+ ResponsiveContainer,
14
+ } from 'recharts'
15
+ import { OHLCVPoint } from '../api/stockApi'
16
+
17
+ interface Props {
18
+ data: OHLCVPoint[]
19
+ }
20
+
21
+ const CustomTooltip = ({ active, payload, label }: any) => {
22
+ if (!active || !payload?.length) return null
23
+ const d: OHLCVPoint = payload[0]?.payload
24
+ return (
25
+ <div className="bg-slate-800 border border-slate-600 rounded p-2 text-xs shadow-lg">
26
+ <p className="text-slate-300">{label}</p>
27
+ {d.macd != null && (
28
+ <p>MACD: <span className="text-blue-400">{d.macd.toFixed(3)}</span></p>
29
+ )}
30
+ {d.macd_signal != null && (
31
+ <p>Signal: <span className="text-orange-400">{d.macd_signal.toFixed(3)}</span></p>
32
+ )}
33
+ {d.macd_hist != null && (
34
+ <p>
35
+ Hist:{' '}
36
+ <span className={d.macd_hist >= 0 ? 'text-green-400' : 'text-red-400'}>
37
+ {d.macd_hist.toFixed(3)}
38
+ </span>
39
+ </p>
40
+ )}
41
+ </div>
42
+ )
43
+ }
44
+
45
+ export const MACDChart: React.FC<Props> = ({ data }) => {
46
+ const len = data.length
47
+
48
+ return (
49
+ <ResponsiveContainer width="100%" height={180}>
50
+ <ComposedChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
51
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
52
+ <XAxis
53
+ dataKey="date"
54
+ tick={{ fill: '#94a3b8', fontSize: 10 }}
55
+ tickLine={false}
56
+ tickFormatter={(val, idx) => {
57
+ const step = Math.max(1, Math.floor(len / 10))
58
+ return idx % step === 0 ? val : ''
59
+ }}
60
+ interval={0}
61
+ />
62
+ <YAxis
63
+ tick={{ fill: '#94a3b8', fontSize: 10 }}
64
+ tickLine={false}
65
+ tickFormatter={(v) => v.toFixed(1)}
66
+ width={40}
67
+ />
68
+ <Tooltip content={<CustomTooltip />} />
69
+ <Legend
70
+ wrapperStyle={{ fontSize: 12 }}
71
+ formatter={(value) => <span style={{ color: '#94a3b8' }}>{value}</span>}
72
+ />
73
+
74
+ <ReferenceLine y={0} stroke="#475569" strokeWidth={1} />
75
+
76
+ {/* MACD Histogram */}
77
+ <Bar dataKey="macd_hist" name="Histogram" isAnimationActive={false} maxBarSize={4}>
78
+ {data.map((d, i) => (
79
+ <Cell
80
+ key={i}
81
+ fill={(d.macd_hist ?? 0) >= 0 ? '#22c55e' : '#ef4444'}
82
+ fillOpacity={0.7}
83
+ />
84
+ ))}
85
+ </Bar>
86
+
87
+ {/* MACD Line */}
88
+ <Line
89
+ dataKey="macd"
90
+ stroke="#60a5fa"
91
+ strokeWidth={1.5}
92
+ dot={false}
93
+ name="MACD"
94
+ isAnimationActive={false}
95
+ connectNulls
96
+ />
97
+
98
+ {/* Signal Line */}
99
+ <Line
100
+ dataKey="macd_signal"
101
+ stroke="#fb923c"
102
+ strokeWidth={1.5}
103
+ dot={false}
104
+ name="Signal"
105
+ isAnimationActive={false}
106
+ connectNulls
107
+ />
108
+ </ComposedChart>
109
+ </ResponsiveContainer>
110
+ )
111
+ }
frontend/src/components/PredictionPanel.tsx ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import { TrendingUp, TrendingDown, Minus, AlertTriangle } from 'lucide-react'
3
+ import { Prediction } from '../api/stockApi'
4
+
5
+ interface Props {
6
+ prediction: Prediction
7
+ stockName: string
8
+ }
9
+
10
+ const signalConfig = {
11
+ BUY: {
12
+ label: 'BUY',
13
+ labelZh: '買入',
14
+ bg: 'bg-green-500/10',
15
+ border: 'border-green-500',
16
+ text: 'text-green-400',
17
+ badgeBg: 'bg-green-600',
18
+ icon: TrendingUp,
19
+ },
20
+ SELL: {
21
+ label: 'SELL',
22
+ labelZh: '賣出',
23
+ bg: 'bg-red-500/10',
24
+ border: 'border-red-500',
25
+ text: 'text-red-400',
26
+ badgeBg: 'bg-red-600',
27
+ icon: TrendingDown,
28
+ },
29
+ HOLD: {
30
+ label: 'HOLD',
31
+ labelZh: '持觀望',
32
+ bg: 'bg-yellow-500/10',
33
+ border: 'border-yellow-500',
34
+ text: 'text-yellow-400',
35
+ badgeBg: 'bg-yellow-600',
36
+ icon: Minus,
37
+ },
38
+ }
39
+
40
+ const confidenceColor = {
41
+ HIGH: 'text-green-400',
42
+ MEDIUM: 'text-yellow-400',
43
+ LOW: 'text-red-400',
44
+ }
45
+
46
+ const confidenceZh = {
47
+ HIGH: '高信心',
48
+ MEDIUM: '中信心',
49
+ LOW: '低信心',
50
+ }
51
+
52
+ export const PredictionPanel: React.FC<Props> = ({ prediction, stockName }) => {
53
+ const cfg = signalConfig[prediction.signal]
54
+ const Icon = cfg.icon
55
+ const probPct = Math.round(prediction.signal_probability * 100)
56
+ const changePositive = prediction.predicted_change_pct >= 0
57
+
58
+ return (
59
+ <div className={`rounded-xl border-2 ${cfg.border} ${cfg.bg} p-5 space-y-4`}>
60
+ {/* Header */}
61
+ <div className="flex items-center justify-between">
62
+ <div>
63
+ <h2 className="text-lg font-bold text-white">{stockName} — ML 預測信號</h2>
64
+ <p className="text-xs text-slate-400 mt-0.5">預測期間:未來 5 個交易日</p>
65
+ </div>
66
+ <div className={`flex items-center gap-2 px-4 py-2 rounded-lg ${cfg.badgeBg} shadow-lg`}>
67
+ <Icon size={20} className="text-white" />
68
+ <span className="text-white font-black text-xl tracking-wider">{cfg.label}</span>
69
+ <span className="text-white/80 text-sm">({cfg.labelZh})</span>
70
+ </div>
71
+ </div>
72
+
73
+ {/* Price grid */}
74
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
75
+ <PriceCard
76
+ label="目前價格"
77
+ labelEn="Current Price"
78
+ value={`NT$ ${prediction.current_price.toFixed(2)}`}
79
+ valueClass="text-white"
80
+ />
81
+ <PriceCard
82
+ label="預測價格 (5日)"
83
+ labelEn="Predicted Price"
84
+ value={`NT$ ${prediction.predicted_price.toFixed(2)}`}
85
+ sub={
86
+ <span className={changePositive ? 'text-green-400' : 'text-red-400'}>
87
+ {changePositive ? '+' : ''}{prediction.predicted_change_pct.toFixed(2)}%
88
+ </span>
89
+ }
90
+ valueClass={changePositive ? 'text-green-400' : 'text-red-400'}
91
+ />
92
+ <PriceCard
93
+ label="建議賣出目標"
94
+ labelEn="Sell Target"
95
+ value={`NT$ ${prediction.sell_target.toFixed(2)}`}
96
+ valueClass="text-green-300"
97
+ />
98
+ <PriceCard
99
+ label="停損價位"
100
+ labelEn="Stop Loss"
101
+ value={`NT$ ${prediction.stop_loss.toFixed(2)}`}
102
+ valueClass="text-red-300"
103
+ />
104
+ </div>
105
+
106
+ {/* Signal probability bar */}
107
+ <div className="space-y-1.5">
108
+ <div className="flex justify-between items-center text-xs">
109
+ <span className="text-slate-400">買入機率 (Buy Probability)</span>
110
+ <span className={`font-bold ${cfg.text}`}>{probPct}%</span>
111
+ </div>
112
+ <div className="h-2.5 bg-slate-700 rounded-full overflow-hidden">
113
+ <div
114
+ className={`h-full rounded-full transition-all duration-700 ${
115
+ probPct >= 60 ? 'bg-green-500' : probPct <= 35 ? 'bg-red-500' : 'bg-yellow-500'
116
+ }`}
117
+ style={{ width: `${probPct}%` }}
118
+ />
119
+ </div>
120
+ <div className="flex justify-between text-xs text-slate-500">
121
+ <span>強力賣出 (Strong Sell)</span>
122
+ <span>強力買入 (Strong Buy)</span>
123
+ </div>
124
+ </div>
125
+
126
+ {/* Confidence + accuracy */}
127
+ <div className="flex flex-wrap gap-3 text-sm">
128
+ <div className="flex items-center gap-1.5 bg-slate-800 rounded-lg px-3 py-1.5">
129
+ <span className="text-slate-400">信心度:</span>
130
+ <span className={`font-bold ${confidenceColor[prediction.confidence]}`}>
131
+ {prediction.confidence} ({confidenceZh[prediction.confidence]})
132
+ </span>
133
+ </div>
134
+ <div className="flex items-center gap-1.5 bg-slate-800 rounded-lg px-3 py-1.5">
135
+ <span className="text-slate-400">模型準確率:</span>
136
+ <span className="text-slate-200 font-semibold">
137
+ {(prediction.training_accuracy * 100).toFixed(1)}%
138
+ </span>
139
+ </div>
140
+ </div>
141
+
142
+ {/* Disclaimer */}
143
+ <div className="flex items-start gap-2 bg-slate-900/70 rounded-lg p-3 border border-slate-700">
144
+ <AlertTriangle size={14} className="text-yellow-400 mt-0.5 flex-shrink-0" />
145
+ <p className="text-xs text-slate-400 leading-relaxed">
146
+ <span className="text-yellow-400 font-semibold">免責聲明:</span>
147
+ 本系統所有預測訊號僅供學術研究與教育用途,不構成任何投資建議。
148
+ 機器學習模型無法保證未來績效,投資人應自行判斷並承擔風險。
149
+ {' '}
150
+ <span className="text-slate-500 italic">
151
+ (ML signals are for educational purposes only and do NOT constitute financial advice.)
152
+ </span>
153
+ </p>
154
+ </div>
155
+ </div>
156
+ )
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Helper sub-component
161
+ // ---------------------------------------------------------------------------
162
+
163
+ interface PriceCardProps {
164
+ label: string
165
+ labelEn: string
166
+ value: string
167
+ valueClass?: string
168
+ sub?: React.ReactNode
169
+ }
170
+
171
+ const PriceCard: React.FC<PriceCardProps> = ({
172
+ label,
173
+ labelEn,
174
+ value,
175
+ valueClass = 'text-white',
176
+ sub,
177
+ }) => (
178
+ <div className="bg-slate-900/60 rounded-lg p-3 space-y-1 border border-slate-700">
179
+ <p className="text-xs text-slate-400 leading-tight">{label}</p>
180
+ <p className="text-xs text-slate-500">{labelEn}</p>
181
+ <p className={`text-base font-bold ${valueClass} leading-tight`}>{value}</p>
182
+ {sub && <div className="text-xs font-semibold">{sub}</div>}
183
+ </div>
184
+ )
frontend/src/components/RSIChart.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import {
3
+ ComposedChart,
4
+ Line,
5
+ XAxis,
6
+ YAxis,
7
+ CartesianGrid,
8
+ Tooltip,
9
+ ReferenceLine,
10
+ ReferenceArea,
11
+ ResponsiveContainer,
12
+ } from 'recharts'
13
+ import { OHLCVPoint } from '../api/stockApi'
14
+
15
+ interface Props {
16
+ data: OHLCVPoint[]
17
+ }
18
+
19
+ const CustomTooltip = ({ active, payload, label }: any) => {
20
+ if (!active || !payload?.length) return null
21
+ const rsi: number | undefined = payload[0]?.value
22
+ const zone =
23
+ rsi == null ? '' : rsi >= 70 ? ' (Overbought)' : rsi <= 30 ? ' (Oversold)' : ''
24
+ return (
25
+ <div className="bg-slate-800 border border-slate-600 rounded p-2 text-xs shadow-lg">
26
+ <p className="text-slate-300">{label}</p>
27
+ <p>
28
+ RSI:{' '}
29
+ <span
30
+ className={
31
+ rsi == null
32
+ ? 'text-white'
33
+ : rsi >= 70
34
+ ? 'text-red-400'
35
+ : rsi <= 30
36
+ ? 'text-green-400'
37
+ : 'text-white'
38
+ }
39
+ >
40
+ {rsi?.toFixed(1)}
41
+ {zone}
42
+ </span>
43
+ </p>
44
+ </div>
45
+ )
46
+ }
47
+
48
+ export const RSIChart: React.FC<Props> = ({ data }) => {
49
+ const len = data.length
50
+
51
+ return (
52
+ <ResponsiveContainer width="100%" height={180}>
53
+ <ComposedChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
54
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
55
+ <XAxis
56
+ dataKey="date"
57
+ tick={{ fill: '#94a3b8', fontSize: 10 }}
58
+ tickLine={false}
59
+ tickFormatter={(val, idx) => {
60
+ const step = Math.max(1, Math.floor(len / 10))
61
+ return idx % step === 0 ? val : ''
62
+ }}
63
+ interval={0}
64
+ />
65
+ <YAxis
66
+ domain={[0, 100]}
67
+ tick={{ fill: '#94a3b8', fontSize: 10 }}
68
+ tickLine={false}
69
+ ticks={[0, 30, 50, 70, 100]}
70
+ width={32}
71
+ />
72
+ <Tooltip content={<CustomTooltip />} />
73
+
74
+ {/* Overbought background zone */}
75
+ <ReferenceArea y1={70} y2={100} fill="#ef444422" fillOpacity={0.4} />
76
+ {/* Oversold background zone */}
77
+ <ReferenceArea y1={0} y2={30} fill="#22c55e22" fillOpacity={0.4} />
78
+
79
+ {/* Reference lines */}
80
+ <ReferenceLine y={70} stroke="#ef4444" strokeDasharray="4 4" strokeWidth={1} />
81
+ <ReferenceLine y={30} stroke="#22c55e" strokeDasharray="4 4" strokeWidth={1} />
82
+ <ReferenceLine y={50} stroke="#475569" strokeDasharray="2 4" strokeWidth={1} />
83
+
84
+ <Line
85
+ dataKey="rsi"
86
+ stroke="#a78bfa"
87
+ strokeWidth={1.5}
88
+ dot={false}
89
+ name="RSI"
90
+ isAnimationActive={false}
91
+ connectNulls
92
+ />
93
+ </ComposedChart>
94
+ </ResponsiveContainer>
95
+ )
96
+ }
frontend/src/components/Sidebar.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import { Star, Trash2, ChevronRight } from 'lucide-react'
3
+
4
+ interface FavoriteItem {
5
+ id: string
6
+ name: string
7
+ }
8
+
9
+ interface SidebarProps {
10
+ favorites: FavoriteItem[]
11
+ onSearch: (id: string, months: number) => void
12
+ onToggleFavorite: (id: string) => void
13
+ onViewFavorites: () => void
14
+ months: number
15
+ }
16
+
17
+ export const Sidebar: React.FC<SidebarProps> = ({
18
+ favorites,
19
+ onSearch,
20
+ onToggleFavorite,
21
+ onViewFavorites,
22
+ months,
23
+ }) => (
24
+ <aside className="lg:col-span-1 space-y-4">
25
+ <div className="bg-slate-900 border border-slate-800 rounded-xl p-4">
26
+ <div className="flex items-center justify-between mb-4">
27
+ <div className="flex items-center gap-2">
28
+ <Star className="text-yellow-500" size={16} fill="currentColor" />
29
+ <h2 className="font-bold text-white text-sm">我的最愛</h2>
30
+ </div>
31
+ {favorites.length > 0 && (
32
+ <button
33
+ onClick={onViewFavorites}
34
+ className="flex items-center gap-0.5 text-xs text-slate-500 hover:text-blue-400 transition-colors"
35
+ >
36
+ 全部 <ChevronRight size={12} />
37
+ </button>
38
+ )}
39
+ </div>
40
+
41
+ {favorites.length === 0 ? (
42
+ <p className="text-xs text-slate-600 italic">尚未加入任何股票</p>
43
+ ) : (
44
+ <div className="space-y-1">
45
+ {favorites.map((fav) => (
46
+ <div key={fav.id} className="flex items-center justify-between group py-1">
47
+ <button
48
+ onClick={() => onSearch(fav.id, months)}
49
+ className="text-sm text-slate-300 hover:text-blue-400 transition-colors flex-1 text-left truncate"
50
+ >
51
+ <span className="font-mono font-bold mr-2 text-white">{fav.id}</span>
52
+ <span className="text-xs text-slate-400">{fav.name}</span>
53
+ </button>
54
+ <button
55
+ onClick={() => onToggleFavorite(fav.id)}
56
+ className="opacity-0 group-hover:opacity-100 p-1 text-slate-600 hover:text-red-400 transition-all flex-shrink-0"
57
+ title="移除"
58
+ >
59
+ <Trash2 size={13} />
60
+ </button>
61
+ </div>
62
+ ))}
63
+
64
+ <button
65
+ onClick={onViewFavorites}
66
+ className="w-full mt-2 pt-2 border-t border-slate-800 flex items-center justify-center gap-1 text-xs text-slate-500 hover:text-yellow-400 transition-colors py-1"
67
+ >
68
+ <Star size={11} />
69
+ 管理我的最愛
70
+ </button>
71
+ </div>
72
+ )}
73
+ </div>
74
+ </aside>
75
+ )
frontend/src/components/VolumeChart.tsx ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import {
3
+ BarChart,
4
+ Bar,
5
+ XAxis,
6
+ YAxis,
7
+ CartesianGrid,
8
+ Tooltip,
9
+ Cell,
10
+ ResponsiveContainer,
11
+ } from 'recharts'
12
+ import { OHLCVPoint } from '../api/stockApi'
13
+
14
+ interface Props {
15
+ data: OHLCVPoint[]
16
+ }
17
+
18
+ function formatVolume(v: number): string {
19
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
20
+ if (v >= 1_000) return `${(v / 1_000).toFixed(0)}K`
21
+ return String(v)
22
+ }
23
+
24
+ const CustomTooltip = ({ active, payload, label }: any) => {
25
+ if (!active || !payload?.length) return null
26
+ const vol: number = payload[0]?.value
27
+ return (
28
+ <div className="bg-slate-800 border border-slate-600 rounded p-2 text-xs shadow-lg">
29
+ <p className="text-slate-300">{label}</p>
30
+ <p className="text-white font-semibold">Vol: {formatVolume(vol)}</p>
31
+ </div>
32
+ )
33
+ }
34
+
35
+ export const VolumeChart: React.FC<Props> = ({ data }) => {
36
+ const len = data.length
37
+
38
+ return (
39
+ <ResponsiveContainer width="100%" height={160}>
40
+ <BarChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
41
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
42
+ <XAxis
43
+ dataKey="date"
44
+ tick={{ fill: '#94a3b8', fontSize: 10 }}
45
+ tickLine={false}
46
+ tickFormatter={(val, idx) => {
47
+ const step = Math.max(1, Math.floor(len / 10))
48
+ return idx % step === 0 ? val : ''
49
+ }}
50
+ interval={0}
51
+ />
52
+ <YAxis
53
+ tick={{ fill: '#94a3b8', fontSize: 10 }}
54
+ tickLine={false}
55
+ tickFormatter={formatVolume}
56
+ width={48}
57
+ />
58
+ <Tooltip content={<CustomTooltip />} />
59
+ <Bar dataKey="volume" isAnimationActive={false} maxBarSize={6}>
60
+ {data.map((d, i) => (
61
+ <Cell
62
+ key={i}
63
+ fill={d.close >= d.open ? '#22c55e' : '#ef4444'}
64
+ fillOpacity={0.8}
65
+ />
66
+ ))}
67
+ </Bar>
68
+ </BarChart>
69
+ </ResponsiveContainer>
70
+ )
71
+ }
frontend/src/firebase.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { initializeApp, FirebaseApp } from 'firebase/app'
2
+ import { getAuth, GoogleAuthProvider, Auth } from 'firebase/auth'
3
+ import { getFirestore, Firestore } from 'firebase/firestore'
4
+
5
+ const apiKey = import.meta.env.VITE_FIREBASE_API_KEY as string | undefined
6
+ const authDomain = import.meta.env.VITE_FIREBASE_AUTH_DOMAIN as string | undefined
7
+ const projectId = import.meta.env.VITE_FIREBASE_PROJECT_ID as string | undefined
8
+
9
+ // True only when all required env vars are present
10
+ export const firebaseReady = !!(apiKey && authDomain && projectId)
11
+
12
+ let app: FirebaseApp | null = null
13
+ let _auth: Auth | null = null
14
+ let _db: Firestore | null = null
15
+
16
+ if (firebaseReady) {
17
+ app = initializeApp({
18
+ apiKey,
19
+ authDomain,
20
+ projectId,
21
+ storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET as string | undefined,
22
+ messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID as string | undefined,
23
+ appId: import.meta.env.VITE_FIREBASE_APP_ID as string | undefined,
24
+ })
25
+ _auth = getAuth(app)
26
+ _db = getFirestore(app)
27
+ }
28
+
29
+ export const auth = _auth!
30
+ export const db = _db!
31
+ export const googleProvider = new GoogleAuthProvider()
frontend/src/hooks/useFirestoreFavorites.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react'
2
+ import {
3
+ collection,
4
+ doc,
5
+ onSnapshot,
6
+ setDoc,
7
+ deleteDoc,
8
+ updateDoc,
9
+ serverTimestamp,
10
+ } from 'firebase/firestore'
11
+ import { db } from '../firebase'
12
+ import { FavoriteItem } from '../components/FavoritesPage'
13
+ import type { Market } from '../components/Header'
14
+
15
+ // Firestore path: users/{uid}/favorites/{stockId}
16
+
17
+ export function useFirestoreFavorites(uid: string | null) {
18
+ const [favorites, setFavorites] = useState<FavoriteItem[]>([])
19
+
20
+ // Real-time listener — updates automatically across tabs/devices
21
+ useEffect(() => {
22
+ if (!uid) { setFavorites([]); return }
23
+ const ref = collection(db, 'users', uid, 'favorites')
24
+ const unsub = onSnapshot(ref, (snap) => {
25
+ const items: FavoriteItem[] = snap.docs.map(d => d.data() as FavoriteItem)
26
+ // Sort by addedAt descending
27
+ items.sort((a, b) => ((b as any).addedAt?.seconds ?? 0) - ((a as any).addedAt?.seconds ?? 0))
28
+ setFavorites(items)
29
+ })
30
+ return unsub
31
+ }, [uid])
32
+
33
+ const addFavorite = async (id: string, name: string, market?: Market) => {
34
+ if (!uid) return
35
+ await setDoc(doc(db, 'users', uid, 'favorites', id), {
36
+ id,
37
+ name,
38
+ market: market ?? 'tw',
39
+ note: '',
40
+ addedAt: serverTimestamp(),
41
+ })
42
+ }
43
+
44
+ const removeFavorite = async (id: string) => {
45
+ if (!uid) return
46
+ await deleteDoc(doc(db, 'users', uid, 'favorites', id))
47
+ }
48
+
49
+ const toggleFavorite = async (id: string, name?: string, market?: Market) => {
50
+ const exists = favorites.find(f => f.id === id)
51
+ if (exists) {
52
+ await removeFavorite(id)
53
+ } else {
54
+ await addFavorite(id, name ?? id, market)
55
+ }
56
+ }
57
+
58
+ const updateNote = async (id: string, note: string) => {
59
+ if (!uid) return
60
+ await updateDoc(doc(db, 'users', uid, 'favorites', id), { note })
61
+ }
62
+
63
+ return { favorites, toggleFavorite, removeFavorite, updateNote }
64
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ body { @apply bg-gray-950 text-gray-100; }
frontend/src/main.tsx ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode><App /></React.StrictMode>
8
+ )
routers/stock.py CHANGED
@@ -18,6 +18,7 @@ from data.fetcher import (
18
  detect_exchange,
19
  is_us_ticker,
20
  )
 
21
  from indicators.technical import add_all_indicators
22
  from models.predictor import StockPredictor
23
 
@@ -77,7 +78,9 @@ def _get_predictor(stock_no: str, df_with_indicators) -> StockPredictor:
77
  async def search_endpoint(q: str = Query(..., min_length=1), market: str = Query(default="tw")):
78
  """Search stocks by name or number. Returns up to 20 matches."""
79
  try:
80
- if market in ("us", "fund"):
 
 
81
  results = search_us(q)
82
  else:
83
  results = search_stocks(q)
 
18
  detect_exchange,
19
  is_us_ticker,
20
  )
21
+ from data.fund_fetcher import search_funds
22
  from indicators.technical import add_all_indicators
23
  from models.predictor import StockPredictor
24
 
 
78
  async def search_endpoint(q: str = Query(..., min_length=1), market: str = Query(default="tw")):
79
  """Search stocks by name or number. Returns up to 20 matches."""
80
  try:
81
+ if market == "fund":
82
+ results = search_funds(q)
83
+ elif market == "us":
84
  results = search_us(q)
85
  else:
86
  results = search_stocks(q)
static/assets/{index-DpNIV5k-.css → index-BnFVDQ43.css} RENAMED
@@ -1 +1 @@
1
- *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.left-0{left:0}.left-2\.5{left:.625rem}.left-3{left:.75rem}.top-0{top:0}.top-1\/2{top:50%}.top-2\.5{top:.625rem}.top-full{top:100%}.z-10{z-index:10}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-auto{margin-right:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-auto{margin-top:auto}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-2\.5{height:.625rem}.h-4{height:1rem}.h-7{height:1.75rem}.h-full{height:100%}.h-px{height:1px}.max-h-72{max-height:18rem}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-4{width:1rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-full{width:100%}.max-w-7xl{max-width:80rem}.max-w-\[120px\]{max-width:120px}.max-w-\[160px\]{max-width:160px}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-green-500{--tw-border-opacity: 1;border-color:rgb(34 197 94 / var(--tw-border-opacity, 1))}.border-green-700{--tw-border-opacity: 1;border-color:rgb(21 128 61 / var(--tw-border-opacity, 1))}.border-red-500{--tw-border-opacity: 1;border-color:rgb(239 68 68 / var(--tw-border-opacity, 1))}.border-red-600{--tw-border-opacity: 1;border-color:rgb(220 38 38 / var(--tw-border-opacity, 1))}.border-red-700{--tw-border-opacity: 1;border-color:rgb(185 28 28 / var(--tw-border-opacity, 1))}.border-slate-600{--tw-border-opacity: 1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.border-slate-700{--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.border-slate-800{--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.border-yellow-500{--tw-border-opacity: 1;border-color:rgb(234 179 8 / var(--tw-border-opacity, 1))}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-blue-600\/20{background-color:#2563eb33}.bg-blue-900\/50{background-color:#1e3a8a80}.bg-gray-950{--tw-bg-opacity: 1;background-color:rgb(3 7 18 / var(--tw-bg-opacity, 1))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-green-900\/30{background-color:#14532d4d}.bg-green-900\/50{background-color:#14532d80}.bg-indigo-600{--tw-bg-opacity: 1;background-color:rgb(79 70 229 / var(--tw-bg-opacity, 1))}.bg-purple-900\/50{background-color:#581c8780}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-red-900\/30{background-color:#7f1d1d4d}.bg-red-900\/40{background-color:#7f1d1d66}.bg-red-900\/50{background-color:#7f1d1d80}.bg-slate-700{--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.bg-slate-800{--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.bg-slate-900{--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.bg-slate-900\/60{background-color:#0f172a99}.bg-slate-900\/70{background-color:#0f172ab3}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.bg-yellow-500\/10{background-color:#eab3081a}.bg-yellow-500\/20{background-color:#eab30833}.bg-yellow-600{--tw-bg-opacity: 1;background-color:rgb(202 138 4 / var(--tw-bg-opacity, 1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-28{padding-top:7rem;padding-bottom:7rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-0{padding-bottom:0}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pl-7{padding-left:1.75rem}.pl-8{padding-left:2rem}.pr-3{padding-right:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity, 1))}.text-blue-300{--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-gray-100{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.text-gray-800{--tw-text-opacity: 1;color:rgb(31 41 55 / var(--tw-text-opacity, 1))}.text-green-300{--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-indigo-400{--tw-text-opacity: 1;color:rgb(129 140 248 / var(--tw-text-opacity, 1))}.text-orange-400{--tw-text-opacity: 1;color:rgb(251 146 60 / var(--tw-text-opacity, 1))}.text-purple-300{--tw-text-opacity: 1;color:rgb(216 180 254 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-slate-200{--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-700{--tw-text-opacity: 1;color:rgb(51 65 85 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-white\/80{color:#fffc}.text-yellow-300{--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}.text-yellow-400{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.placeholder-slate-500::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(100 116 139 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-500::placeholder{--tw-placeholder-opacity: 1;color:rgb(100 116 139 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-600::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(71 85 105 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-600::placeholder{--tw-placeholder-opacity: 1;color:rgb(71 85 105 / var(--tw-placeholder-opacity, 1))}.opacity-0{opacity:0}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-700{transition-duration:.7s}body{--tw-bg-opacity: 1;background-color:rgb(3 7 18 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.hover\:border-slate-600:hover{--tw-border-opacity: 1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.hover\:bg-blue-500:hover{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.hover\:bg-blue-600\/40:hover{background-color:#2563eb66}.hover\:bg-gray-100:hover{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.hover\:bg-indigo-500:hover{--tw-bg-opacity: 1;background-color:rgb(99 102 241 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-700:hover{--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-800:hover{--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-800\/50:hover{background-color:#1e293b80}.hover\:text-blue-400:hover{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.hover\:text-red-400:hover{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.hover\:text-slate-300:hover{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.hover\:text-slate-400:hover{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:text-yellow-400:hover{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.focus\:border-blue-500:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.focus\:border-slate-500:focus{--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-blue-800:disabled{--tw-bg-opacity: 1;background-color:rgb(30 64 175 / var(--tw-bg-opacity, 1))}.disabled\:bg-gray-200:disabled{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity, 1))}.disabled\:bg-indigo-800:disabled{--tw-bg-opacity: 1;background-color:rgb(55 48 163 / var(--tw-bg-opacity, 1))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width: 640px){.sm\:ml-2{margin-left:.5rem}.sm\:inline{display:inline}.sm\:w-48{width:12rem}.sm\:w-auto{width:auto}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (min-width: 1024px){.lg\:col-span-1{grid-column:span 1 / span 1}.lg\:col-span-3{grid-column:span 3 / span 3}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}@media (min-width: 1280px){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}
 
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.left-0{left:0}.left-2\.5{left:.625rem}.left-3{left:.75rem}.right-0{right:0}.right-2{right:.5rem}.top-0{top:0}.top-1\/2{top:50%}.top-2\.5{top:.625rem}.top-full{top:100%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-auto{margin-top:auto}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-2\.5{height:.625rem}.h-4{height:1rem}.h-7{height:1.75rem}.h-full{height:100%}.h-px{height:1px}.max-h-64{max-height:16rem}.min-h-screen{min-height:100vh}.w-4{width:1rem}.w-7{width:1.75rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[16px\]{min-width:16px}.max-w-7xl{max-width:80rem}.max-w-\[100px\]{max-width:100px}.max-w-\[160px\]{max-width:160px}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-green-500{--tw-border-opacity: 1;border-color:rgb(34 197 94 / var(--tw-border-opacity, 1))}.border-green-700{--tw-border-opacity: 1;border-color:rgb(21 128 61 / var(--tw-border-opacity, 1))}.border-red-500{--tw-border-opacity: 1;border-color:rgb(239 68 68 / var(--tw-border-opacity, 1))}.border-red-700{--tw-border-opacity: 1;border-color:rgb(185 28 28 / var(--tw-border-opacity, 1))}.border-slate-600{--tw-border-opacity: 1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.border-slate-700{--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.border-slate-800{--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.border-yellow-500{--tw-border-opacity: 1;border-color:rgb(234 179 8 / var(--tw-border-opacity, 1))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-blue-600\/20{background-color:#2563eb33}.bg-blue-900\/50{background-color:#1e3a8a80}.bg-gray-950{--tw-bg-opacity: 1;background-color:rgb(3 7 18 / var(--tw-bg-opacity, 1))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-green-900\/30{background-color:#14532d4d}.bg-green-900\/50{background-color:#14532d80}.bg-purple-900\/50{background-color:#581c8780}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-red-900\/30{background-color:#7f1d1d4d}.bg-slate-700{--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.bg-slate-800{--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.bg-slate-900{--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.bg-slate-900\/60{background-color:#0f172a99}.bg-slate-900\/70{background-color:#0f172ab3}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.bg-yellow-500\/10{background-color:#eab3081a}.bg-yellow-500\/20{background-color:#eab30833}.bg-yellow-600{--tw-bg-opacity: 1;background-color:rgb(202 138 4 / var(--tw-bg-opacity, 1))}.bg-yellow-900\/50{background-color:#713f1280}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-28{padding-top:7rem;padding-bottom:7rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pl-7{padding-left:1.75rem}.pl-8{padding-left:2rem}.pr-3{padding-right:.75rem}.pr-7{padding-right:1.75rem}.pt-2{padding-top:.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity, 1))}.text-blue-300{--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-gray-100{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.text-gray-800{--tw-text-opacity: 1;color:rgb(31 41 55 / var(--tw-text-opacity, 1))}.text-green-300{--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-orange-400{--tw-text-opacity: 1;color:rgb(251 146 60 / var(--tw-text-opacity, 1))}.text-purple-300{--tw-text-opacity: 1;color:rgb(216 180 254 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-slate-200{--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-700{--tw-text-opacity: 1;color:rgb(51 65 85 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-white\/80{color:#fffc}.text-yellow-300{--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}.text-yellow-400{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.placeholder-slate-500::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(100 116 139 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-500::placeholder{--tw-placeholder-opacity: 1;color:rgb(100 116 139 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-600::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(71 85 105 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-600::placeholder{--tw-placeholder-opacity: 1;color:rgb(71 85 105 / var(--tw-placeholder-opacity, 1))}.opacity-0{opacity:0}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-700{transition-duration:.7s}body{--tw-bg-opacity: 1;background-color:rgb(3 7 18 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.hover\:border-slate-600:hover{--tw-border-opacity: 1;border-color:rgb(71 85 105 / var(--tw-border-opacity, 1))}.hover\:bg-blue-500:hover{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.hover\:bg-blue-600\/40:hover{background-color:#2563eb66}.hover\:bg-gray-100:hover{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-700:hover{--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-800:hover{--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.hover\:text-blue-400:hover{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.hover\:text-red-400:hover{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.hover\:text-slate-300:hover{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.hover\:text-slate-400:hover{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:text-yellow-400:hover{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.focus\:border-blue-500:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.focus\:border-slate-500:focus{--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-blue-800:disabled{--tw-bg-opacity: 1;background-color:rgb(30 64 175 / var(--tw-bg-opacity, 1))}.disabled\:bg-gray-200:disabled{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity, 1))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width: 640px){.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-52{width:13rem}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (min-width: 1024px){.lg\:col-span-1{grid-column:span 1 / span 1}.lg\:col-span-3{grid-column:span 3 / span 3}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width: 1280px){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}
static/assets/{index-DsCDWin_.js → index-BrxmzIxi.js} RENAMED
The diff for this file is too large to render. See raw diff
 
static/index.html CHANGED
@@ -4,8 +4,8 @@
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>台股預測系統</title>
7
- <script type="module" crossorigin src="/assets/index-DsCDWin_.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-DpNIV5k-.css">
9
  </head>
10
  <body>
11
  <div id="root"></div>
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>台股預測系統</title>
7
+ <script type="module" crossorigin src="/assets/index-BrxmzIxi.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BnFVDQ43.css">
9
  </head>
10
  <body>
11
  <div id="root"></div>