網頁

2024年4月16日 星期二

Python:台股ETF爬蟲(2)持股頁面

要了解一檔ETF為何績效好(壞)一個很重要的原因是它的持股,就拿最近AI當家,基本上持有這類股票的ETF績效都不會太差,這篇就接著 Python:台股ETF爬蟲 來講解如何把ETF持股爬下來。

我以 MoneyDJ 網站說明,首先來看看元大S&P500持股頁面,網址是:https://www.moneydj.com/ETF/X/Basic/Basic0007B.xdjhtm?etfid=00646.TW

和淨值頁面https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=00646.TW就是差在淨值頁面網址是Basic0003,持股頁面是Basic0007B。

接著一樣用BeautifulSoup把網頁爬下來:

url = 'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=00757.TW'
url = re.sub('[B|b]asic0003', 'Basic0007B', url)
response = requests.get(url)
response.encoding = 'utf-8'
web_content = response.text
soup = BeautifulSoup(web_content, 'lxml')

看一下soup裡面放了哪些東西:

持股表格的id是"ctl00_ctl00_MainContent_MainContent_sdate3",文字屬性就是"資料日期:2024/03/28",可以這麼寫:

# 取出表格文字屬性 
soup.find('div', id='ctl00_ctl00_MainContent_MainContent_sdate3').text

輸出結果:

表格可以這樣取出:

soup.find_all('table', id='ctl00_ctl00_MainContent_MainContent_stable3')[0]

也可以:

soup.select('table')[3]

輸出結果:

最後將這些整理放入變數:

lst = pd.read_html(data.prettify())
df = lst[0]
print(f'{update_str}')
df.head(10)

輸出結果 (因為頁面是前20大持股,我只列出前10筆):

我挑選了六檔ETF當範例,程式如下:

from bs4 import BeautifulSoup
import urllib.request
import pandas as pd
# ETF 名稱
fund_dict = {'國泰北美科技':
'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=00770.TW',
'富邦NASDAQ':
'https://www.moneydj.com/etf/x/basic/basic0003.xdjhtm?etfid=00662.tw',
'元大S&P500':
'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=00646.TW',
'元大全球AI ':
'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=00762.TW',
'國泰費城半導體':
'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=00830.TW',
'統一FANG+':
'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=00757.TW'}
# 取得持股明細
def get_etf_components(url):
url = re.sub('[B|b]asic0003', 'Basic0007B', url)
response = requests.get(url)
response.encoding = 'utf-8'
web_content = response.text
soup = BeautifulSoup(web_content, 'lxml')
update_str = soup.find('div', id='ctl00_ctl00_MainContent_MainContent_sdate3').text
data = soup.find_all('table', id='ctl00_ctl00_MainContent_MainContent_stable3')[0]
lst = pd.read_html(data.prettify())
df = lst[0]
return df.head(10), update_str

for name in fund_dict.keys():
url = fund_dict[name]
compon_df, update_str = get_etf_components(url)
print(f'{name} 持股明細 {update_str}')
print(compon_df.to_string(), '\n')

輸出結果:




2024年4月15日 星期一

Python:五種Dataframe循環效率比較

下面是一個身高體重的DataFrame,資料共有10萬筆:

 

如果要計算每一筆的BMI並增加BMI欄位, 有幾種做法呢? 還有哪一種最快或最慢呢?

首先 BMI 公式是:體重/(身高*身高)

方法1:使用Numpy 向量計算,這方法是將Pandas的數據轉化為Numpy的Array,然後使用Numpy的內置函數進行向量化操作。10萬筆資料大約0.0020sec

方法2:DataFrame 向量計算。10萬筆資料大約0.00350sec

方法3:使用apply函數,用法是接一個 lambda匿名函數,從而對dataframe的每一行都進行循環處理。10萬筆資料大約1.02239sec

方法4:也是 apply,但 lambda傳入2個參數。10萬筆資料大約1.03280sec

方法5:是最笨的方式也是最簡單的方式 for loop,跑一個迴圈依序對10萬筆資料循序處理,將結果填入對應的行列中。10萬筆資料大約18.22036sec

結果蠻出乎我意料的,
for loop(最慢) >> apply lambda >> DataFrame Vector >> Numpy(最快)
我以為向量或lambda會最快,沒想到Numpy的函數最快。

輸出結果:




 








2024年4月14日 星期日

Python:台股ETF爬蟲

搭最近很熱門的00940之亂,來講解一下如何爬取台股ETF的資料

我用MoneyDJ這個網站做範例,以元大0050來說淨值的網頁是:
https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=0050.TW
基本資料的網頁是:
https://www.moneydj.com/ETF/X/Basic/Basic0004.xdjhtm?etfid=0050.TW
報酬分析的網頁是:
https://www.moneydj.com/ETF/X/Basic/Basic0008.xdjhtm?etfid=0050.TW

網址的部分差異就是Basic003, Basic004, Basic008,等一下就用替換字串的方式處理

淨值網頁畫面如下:

html標籤分別是table3(最後一日淨值資料),table5,table6(過去30天的淨值資料),ttitle(ETF名稱),那我用BeautifulSoup去實作就如下:

# ETF淨值表格
url = 'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=0050.TW'
response = requests.get(url)
response.encoding = 'utf-8'
web_content = response.text
soup = BeautifulSoup(web_content, 'lxml')
# 取得 title
title = soup.title.string.strip().split('-')
print(f'ETF名稱: {title[0]} ({title[1]})')
# 日期較近的15天
data1 = soup.select('table')[5]
lst1 = pd.read_html(data1.prettify())
df1 = lst1[0]
# 日期較遠的15天
data2 = soup.select('table')[6]
lst2 = pd.read_html(data2.prettify())
df2 = lst2[0]
# 合併為30天, 並依日期排序
df = pd.concat([df1, df2]).sort_values(by=['日期']).reset_index(drop=True)
# 日期取 mm/dd
pattern = r'\d{4}/(\d{2}/\d{2})'
df['日期'] = df['日期'].replace(pattern, r'\1', regex=True)

min = df['淨值'].min() # 提取淨值30日最低
max = df['淨值'].max() # 提取淨值30日最高
min_date = df[df['淨值'] == min]['日期'].values[0] # 提取淨值最低日期
max_date = df[df['淨值'] == max]['日期'].values[0] # 提取淨值最高日期
df.tail(10)

其中日期 yyyy/mm/dd 的格式我用正規式替換成 mm/dd 格式

輸出結果(最後10筆(日)資料): 

 接著是基本資料的網頁畫面,我要抓取的是ETF經理人資料:

這個html標籤是table3裡面的資訊,寫法就是:

# ETF經理人
url = 'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=0050.TW'
url = re.sub('[B|b]asic0003', 'Basic0004', url)
response = requests.get(url)
response.encoding = 'utf-8'
web_content = response.text
soup = BeautifulSoup(web_content, 'lxml')
data = soup.select('table')[3]
lst = pd.read_html(data.prettify())
dfs = lst[0]
print('ETF名稱:', dfs.iloc[0, 1])
print('經理人:', dfs.iloc[15, 1])

剛剛已說了網址的部分就是把Basic0003換成Basic0004,我看了這個網站的網址也些是大寫有些小寫所以避免沒換替換成功就用正規式表示待替換的[B|b] (大寫B或小寫b)換成'Basic0004',取出的html表格放在lst變數長得像這樣:

我只要取出 list0 的第15筆資料經理人名稱就好,寫法如下:

# 取出ETF經理人
url = 'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=0050.TW'
url = re.sub('[B|b]asic0003', 'Basic0004', url)
response = requests.get(url)
response.encoding = 'utf-8'
web_content = response.text
soup = BeautifulSoup(web_content, 'lxml')
data = soup.select('table')[3]
lst = pd.read_html(data.prettify())
dfs = lst[0]
print('ETF名稱:', dfs.iloc[0, 1])
print('經理人:', dfs.iloc[15, 1])

輸出結果:


最後一個是"報酬分析"頁面的畫面如下:

這個html標籤是table3裡面的資訊,當中有市價與淨值兩行,我取淨值的資料,至於差異各位自行去查,寫法如下:

# ETF報酬分析 Total returns
url = 'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=0050.TW'
url = re.sub('[B|b]asic0003', 'Basic0008', url)
response = requests.get(url)
response.encoding = 'utf-8'
web_content = response.text
soup = BeautifulSoup(web_content, 'lxml')
data = soup.select('table')[3]
lst = pd.read_html(data.prettify())
df = lst[0]
df

輸出結果:

將淨值的資料存入變數:

# 取淨值資料(Net Asset Value,NAV)
tr_d = df['一日'][1]
tr_w = df['一週'][1]
tr_m = df['一個月'][1]
tr_3m = df['三個月'][1]
tr_6m = df['六個月'][1]
tr_1y = df['一年'][1]
tr_3y = df['三年'][1]
tr_5y = df['五年'][1]
tr_10y = df['十年'][1]
tr_si = df['成立日'][1]

將上面三個部分寫成函數就可以了,我以台灣前7大熱門ETF來當範例,完整程式如下:

from bs4 import BeautifulSoup
import urllib.request
import pandas as pd
import json, requests, os, re

fund_dict = {'元大高股息':'https://www.moneydj.com/etf/x/basic/basic0003.xdjhtm?etfid=0056.tw',
'國泰永續高股息':'https://www.moneydj.com/etf/x/basic/basic0003.xdjhtm?etfid=00878.tw',
'元大台灣50':'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=0050.TW',
'富邦台50':'https://www.moneydj.com/etf/x/basic/basic0003.xdjhtm?etfid=006208.tw',
'元大台灣高息低波':'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=00713.TW',
'群益台灣精選高息':'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=00919.TW',
'元大台灣價值高息':'https://www.moneydj.com/ETF/X/Basic/Basic0003.xdjhtm?etfid=00940.TW'}

#(1)取得ETF經理人
def get_fund_manager(url):
url = re.sub('[B|b]asic0003', 'Basic0004', url)
response = requests.get(url)
response.encoding = 'utf-8'
web_content = response.text
soup = BeautifulSoup(web_content, 'lxml')
data = soup.select('table')[3]
lst = pd.read_html(data.prettify())
dfs = lst[0]
return dfs.iloc[15, 1] #ETF manager

#(2)取得ETF淨值資料
def get_fund_price(url, name):
response = requests.get(url)
response.encoding = 'utf-8'
web_content = response.text
soup = BeautifulSoup(web_content, 'lxml')
# 取得 title
title = soup.title.string.strip().split('-')
fund_name = f'{title[0]} ({title[1]})'
# 日期較近的15天
data1 = soup.select('table')[5]
lst1 = pd.read_html(data1.prettify())
df1 = lst1[0]
# 日期較遠的15天
data2 = soup.select('table')[6]
lst2 = pd.read_html(data2.prettify())
df2 = lst2[0]
# 合併為30天, 並依日期排序
df = pd.concat([df1, df2]).sort_values(by=['日期']).reset_index(drop=True)
# 日期取 mm/dd
pattern = r'\d{4}/(\d{2}/\d{2})'
df['日期'] = df['日期'].replace(pattern, r'\1', regex=True)

min = df['淨值'].min() # 提取30日淨值最低
max = df['淨值'].max() # 提取30日淨值最高
min_date = df[df['淨值'] == min]['日期'].values[0] # 提取淨值最低日期
max_date = df[df['淨值'] == max]['日期'].values[0] # 提取淨值最高日期
date = df.tail(1).values.tolist()[0][0] # 最後資料日期
nav = df.tail(1).values.tolist()[0][1] # 最後淨值(Net Asser Value)
price = df.tail(1).values.tolist()[0][2] # 最後市價(Market price)
# 取得漲跌百分比表格
data = soup.select('table')[3]
lst = pd.read_html(data.prettify())
df3 = lst[0]
updown = df3['漲跌'].loc[1] #漲/跌 NAV Change
pct = df3['漲跌幅(%)'].loc[1] #漲跌百分比 NAV Change Percent
return fund_name, [date, nav, updown, pct, max, min]

#(3)取得ETF報酬分析資料
def get_fund_total_return(url):
url = re.sub('[B|b]asic0003', 'Basic0008', url)
response = requests.get(url)
response.encoding = 'utf-8'
web_content = response.text
soup = BeautifulSoup(web_content, 'lxml')
data = soup.select('table')[3]
lst = pd.read_html(data.prettify())
df = lst[0]
tr_1d = df['一日'][1]
tr_1w = df['一週'][1]
tr_1m = df['一個月'][1]
tr_3m = df['三個月'][1]
tr_6m = df['六個月'][1]
tr_1y = df['一年'][1]
tr_3y = df['三年'][1]
tr_5y = df['五年'][1]
tr_10y = df['十年'][1]
tr_si = df['成立日'][1]
return [tr_1m, tr_3m, tr_6m, tr_1y, tr_3y, tr_si]
df = pd.DataFrame()
for name in fund_dict.keys():
url = fund_dict[name]
manager_df = pd.DataFrame()
#(1)取得ETF淨值資料
fund_name, price_list = get_fund_price(url, name)
print(f'讀取: {fund_name} 資料 ...')
price_list.insert(0, fund_name)
cols = ['名稱', '最後日期', '淨值', '漲/跌', '百分比', '30日最高', '30日最低']
price_df = pd.DataFrame([price_list], columns=cols)
#(2)取得ETF經理人
fund_manager = get_fund_manager(url)
manager_df = pd.DataFrame([fund_name], columns=['名稱']).set_index('名稱')
manager_df['經理人'] = fund_manager
# 合併 manager_df price_df
manager_df = manager_df.merge(price_df, on='名稱')
#(3)取得報酬分析資料
returns_list = get_fund_total_return(url)
returns_list.insert(0, fund_name)
cols = ['名稱', '一個月', '三個月', '六個月', '一年', '三年', '成立至今']
returns_df = pd.DataFrame([returns_list], columns=cols)
# 合併 manager_df, returns_df
manager_df = manager_df.merge(returns_df, on='名稱' ).copy()
manager_df.set_index('名稱', inplace=True)
df = pd.concat([df, manager_df])
df

輸出結果如下: 


這樣就可以看到這些發行的ETF的淨值,報酬資料,將程式放入定時執行後產生報表用email寄出,就可以每天知道他們的變化。


2024年3月31日 星期日

Python:Dataframe 操作

續上一篇,現有一個DataFrame A,B兩個欄位,
當A > B欄位值時 A + B + (B欄位的上一個值),如果沒有上一欄就 A+B欄;
當A < B 則 A+B欄位值;將結果存入 result 欄位。

 DataFrame如下:

期望的 result 欄應該是:16,10,15,16,10

作法如下:

方法1:


方法2:先將 df['B'] 欄做一次 shift,結果放入 df['B_shift'],

將Nan值填入0,
df['B_shift'].fillna(0, inplace=True)



 

方法3:同上先將 df['B'] 欄做一次 shift,然後向量計算結果放入 df['B_shift'],







2024年3月26日 星期二

Python:基金持股爬蟲

今天來講一講用 BeautifulSoup來取得基金的持股成分,首先一樣載入相關的套件:

from bs4 import BeautifulSoup
import json, requests
import pandas as pd

我選定的基金網頁是:
https://fund.hncb.com.tw/w/wr/wr04.djhtm?a=ACDD04-005003

用 requests.get() 來爬取網頁內容:
url = 'https://fund.hncb.com.tw/w/wr/wr04.djhtm?a=ACDD04-005003'
response = requests.get(url)
web_content = response.text
soup = BeautifulSoup(web_content, 'lxml')
soup

輸出結果:

選取 table,然後將 table 轉成 list 形式:

data = soup.select('table')[1]
lst = pd.read_html(data.prettify())
lst

輸出結果: 

然後我們要取出 list[0] 4~9列股票名稱,然後放入 dataframe:

df = lst[0].iloc[4:9]
df

輸出結果: 


處理索引值:

df.reset_index(drop=True)

輸出結果:


完整程式碼:

url = 'https://fund.hncb.com.tw/w/wr/wr04.djhtm?a=ACDD04-005003'
response = requests.get(url)
web_content = response.text
soup = BeautifulSoup(web_content, 'lxml')
data = soup.select('table')[1]
lst = pd.read_html(data.prettify())
title = lst[0].iloc[2][0]
df = lst[0].iloc[4:9].reset_index()
fund_list = df[0].iloc[0:5].tolist()
fund_list.extend(df[4].iloc[0:5].tolist())
print('基金名稱:', title)
print('持股資料(Dataframe):\n', df)
print()
print('股票名稱:', fund)

輸出結果:

 
是不是很簡單!
 
這種網頁畫面:

只要選擇 table[0] 即可:
soup.select('table')[0]
 
另外 title 的部分只要直接用  soup.title.text 取出即可:
soup.title.text
 
輸出結果: