Initial commit: stocks-query skill

Add scripts for querying Taiwan stock market data via Yahoo Finance:
- Individual stock quotes (price, OHLC, volume, bid/ask)
- Three major institutional investors (foreign, trust, dealer)
- Technical indicators (RSI14, ATR14, KD, Bollinger Bands, MACD, MA)
- ETF holdings analysis (top 10, industry distribution, asset allocation)
- TAIEX weighted index market summary

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 18:10:52 +08:00
commit 48eba04300
10 changed files with 1324 additions and 0 deletions

View File

@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""
用 Browserless 透過 Yahoo Chart API 抓技術面摘要(代號可變)
輸出內容:
- 近 5 個交易日 K 線(開高低收)
- RSI14
- ATR14
- KD9,3,3
- 布林通道20,2
用法:
python3 skills/stocks-query/scripts/yahoo-technical-browserless.py 3481
python3 skills/stocks-query/scripts/yahoo-technical-browserless.py 2330
"""
import datetime as dt
import html as html_lib
import json
import math
import os
from pathlib import Path
import re
import subprocess
import sys
def load_dotenv() -> None:
"""載入 .env優先目前目錄其次 workspace 根目錄)。"""
candidates = [
Path.cwd() / ".env",
Path(__file__).resolve().parents[3] / ".env",
]
for env_path in candidates:
if not env_path.exists():
continue
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
if k and k not in os.environ:
os.environ[k] = v
break
load_dotenv()
ENDPOINT = os.getenv("BROWSERLESS_ENDPOINT")
TOKEN = os.getenv("BROWSERLESS_TOKEN")
if not ENDPOINT or not TOKEN:
raise RuntimeError("缺少必要環境變數BROWSERLESS_ENDPOINT / BROWSERLESS_TOKEN")
STOCK_NO = sys.argv[1] if len(sys.argv) > 1 else "3481"
def fetch_chart_json(stock_no: str) -> dict:
api_url = f"https://query1.finance.yahoo.com/v8/finance/chart/{stock_no}.TW?range=3mo&interval=1d"
browserless_api = f"{ENDPOINT}/content?token={TOKEN}"
payload = json.dumps(
{
"url": api_url,
"gotoOptions": {"waitUntil": "domcontentloaded", "timeout": 60000},
},
ensure_ascii=False,
)
cmd = [
"curl",
"-sS",
"--max-time",
"90",
"-X",
"POST",
browserless_api,
"-H",
"Content-Type: application/json",
"-d",
payload,
]
try:
raw = subprocess.check_output(cmd, text=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Browserless 呼叫失敗: {e}") from e
except subprocess.TimeoutExpired as e:
raise RuntimeError("Browserless 呼叫逾時") from e
m = re.search(r"<pre>([\s\S]+)</pre>", raw)
json_text = html_lib.unescape(m.group(1) if m else raw).strip()
data = json.loads(json_text)
if data.get("chart", {}).get("error"):
raise RuntimeError(f"Yahoo API 錯誤: {data['chart']['error']}")
return data
def compute_rsi14(closes: list[float]) -> float | None:
if len(closes) < 15:
return None
gains = []
losses = []
for prev_close, close in zip(closes[:-1], closes[1:]):
change = close - prev_close
gains.append(max(change, 0.0))
losses.append(max(-change, 0.0))
period = 14
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
for gain, loss in zip(gains[period:], losses[period:]):
avg_gain = ((avg_gain * (period - 1)) + gain) / period
avg_loss = ((avg_loss * (period - 1)) + loss) / period
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
return 100 - (100 / (1 + rs))
def compute_atr14(highs: list[float], lows: list[float], closes: list[float]) -> float | None:
if len(highs) < 15 or len(lows) < 15 or len(closes) < 15:
return None
true_ranges = []
prev_close = closes[0]
for high, low, close in zip(highs[1:], lows[1:], closes[1:]):
tr = max(
high - low,
abs(high - prev_close),
abs(low - prev_close),
)
true_ranges.append(tr)
prev_close = close
period = 14
if len(true_ranges) < period:
return None
atr = sum(true_ranges[:period]) / period
for tr in true_ranges[period:]:
atr = ((atr * (period - 1)) + tr) / period
return atr
def compute_kd(highs: list[float], lows: list[float], closes: list[float]) -> tuple[float | None, float | None]:
period = 9
if len(highs) < period or len(lows) < period or len(closes) < period:
return None, None
k_value = 50.0
d_value = 50.0
rsv_values = []
for idx in range(period - 1, len(closes)):
window_high = max(highs[idx - period + 1 : idx + 1])
window_low = min(lows[idx - period + 1 : idx + 1])
close = closes[idx]
if window_high == window_low:
rsv = 50.0
else:
rsv = ((close - window_low) / (window_high - window_low)) * 100
rsv_values.append(rsv)
for rsv in rsv_values:
k_value = ((2 / 3) * k_value) + ((1 / 3) * rsv)
d_value = ((2 / 3) * d_value) + ((1 / 3) * k_value)
return k_value, d_value
def compute_bollinger_bands(closes: list[float]) -> tuple[float | None, float | None, float | None]:
period = 20
if len(closes) < period:
return None, None, None
window = closes[-period:]
middle = sum(window) / period
variance = sum((price - middle) ** 2 for price in window) / period
std_dev = math.sqrt(variance)
upper = middle + (2 * std_dev)
lower = middle - (2 * std_dev)
return upper, middle, lower
def parse_technical_data(data: dict):
result = data["chart"]["result"][0]
symbol = result.get("meta", {}).get("symbol", f"{STOCK_NO}.TW")
timestamps = result.get("timestamp", [])
quote = result.get("indicators", {}).get("quote", [{}])[0]
opens = quote.get("open", [])
closes = quote.get("close", [])
highs = quote.get("high", [])
lows = quote.get("low", [])
kline_rows = []
close_values = []
high_values = []
low_values = []
for ts, open_price, close, high, low in zip(timestamps, opens, closes, highs, lows):
if None in (open_price, close, high, low):
continue
d = dt.datetime.fromtimestamp(ts, tz=dt.timezone(dt.timedelta(hours=8)))
open_float = float(open_price)
close_float = float(close)
high_float = float(high)
low_float = float(low)
kline_rows.append(
(d.strftime("%m/%d"), open_float, high_float, low_float, close_float)
)
close_values.append(close_float)
high_values.append(high_float)
low_values.append(low_float)
if len(kline_rows) < 5:
raise RuntimeError("可用 K 線資料不足 5 筆")
rsi14 = compute_rsi14(close_values)
atr14 = compute_atr14(high_values, low_values, close_values)
k_value, d_value = compute_kd(high_values, low_values, close_values)
boll_upper, boll_middle, boll_lower = compute_bollinger_bands(close_values)
return (
symbol,
kline_rows[-5:],
rsi14,
atr14,
k_value,
d_value,
boll_upper,
boll_middle,
boll_lower,
)
def print_summary(
stock_no: str,
symbol: str,
kline_rows,
rsi14: float | None,
atr14: float | None,
k_value: float | None,
d_value: float | None,
boll_upper: float | None,
boll_middle: float | None,
boll_lower: float | None,
):
print(f"{stock_no}{symbol})|技術面摘要")
print("• 近 5 個交易日 K 線(開/高/低/收)")
for d, o, h, l, c in kline_rows:
print(f" - {d}{o:.2f}{h:.2f}{l:.2f}{c:.2f}")
if rsi14 is None:
print("• RSI14無資料")
else:
print(f"• RSI14{rsi14:.2f}")
if atr14 is None:
print("• ATR14無資料")
else:
print(f"• ATR14{atr14:.2f}")
if k_value is None or d_value is None:
print("• KD無資料")
else:
print(f"• K{k_value:.2f}")
print(f"• D{d_value:.2f}")
if boll_upper is None or boll_middle is None or boll_lower is None:
print("• 布林通道:無資料")
else:
print(f"• 布林上軌:{boll_upper:.2f}")
print(f"• 布林中軌:{boll_middle:.2f}")
print(f"• 布林下軌:{boll_lower:.2f}")
if __name__ == "__main__":
try:
(
symbol,
kline_rows,
rsi14,
atr14,
k_value,
d_value,
boll_upper,
boll_middle,
boll_lower,
) = parse_technical_data(fetch_chart_json(STOCK_NO))
print_summary(
STOCK_NO,
symbol,
kline_rows,
rsi14,
atr14,
k_value,
d_value,
boll_upper,
boll_middle,
boll_lower,
)
except Exception as e:
print(f"取得失敗:{e}", file=sys.stderr)
sys.exit(1)