美股記帳 App / Part1. Watch List
在構想完後,我決定先從 Watch List 下手,Watch List 可以讓使用者加入想要關注的股票,然後即時跟新股價的相關資訊,下面是我Watch List 會有的功能
Watch List
- current price
- price change
- price change (%)
- day high / low
- search bar (可以透過股票代碼或公司名稱搜尋)
- 加入,新增,刪除,移動 Watch List 裡的股票
- 將資料存進手機資料夾裡
API Source
為了得到美股的價格資訊,我採用了 finnhub 的免費 API ,只要輸入 股票的symbol 就可以得到價格資訊
Search Bar
- 提供使用者方便搜索想要的股票或 ETF
- 即使只搜尋公司名字,也能得到正確的股票代碼
剛開始我打算直接在 search bar 接一個 API ,讓使用者輸入的關鍵字可以接到 Finnhub 的 Symbol Lookup,並得到 下方相關的搜索內容,但試過後發現每多輸入一個字母就連間一次 API 的方式,讓整個 app 變得很卡,搜索出的內容也很廣泛,達不到我心中的預期
最後決定上網搜尋 美股全部股票還有 ETF 的 List, 用 csv file 的方式讀進 App 裡
股票與 ETF 的 資料來源
List of Top 100 ETFs
List of the top 100 largest U.S. Exchange Traded Funds or ETFs
stockmarketmba.com
讀取 csv 裡的資料
- 下載 CodableCSV
https://github.com/dehesa/CodableCSV
2. 把 .csv file 放入 Assets 裡,用 .csv file 裡的 column name 建立一個 struct
struct stockFullName : Codable{
var Symbol : String
let symbolName :String
var CompanyName : String{
symbolName.components(separatedBy: "_")[1]
}
}
3. 建立 extension 把從 csv 裡讀出的檔案變成 array of struct
- csv 裡如果有 header 要設 $0.headerStrategy = .firstLine
extension stockFullName {
static var data :[Self]{
var array = [Self]()
if let data = NSDataAsset(name: "stockList")?.data {
let decoder = CSVDecoder{
$0.headerStrategy = .firstLine
}
do{
array = try decoder.decode([Self].self, from: data)
array = array.filter({!$0.Symbol.contains("^")})
}catch{
print(error)
}
}
return array
}
}
4. 最後叫出data
var stockList = stockFullName.data
讀取 csv 檔案,可能不是每一個 row 都有 data,這裡我將原本的兩個 column 合併,在 struct 裡在建一個 variable 把 company name 取出來,就可以避免讀取 csv 檔案的 error
設定 SearchBar
設定 navigationBar 上的 search Controller
lazy var filterStockList = stockList
var searching = falsefunc searchBarSetting(){
let searchController = UISearchController()
searchController.searchResultsUpdater = self
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
searchController.searchBar.barStyle = .black
searchController.searchBar.searchTextField.textColor = .white
searchController.automaticallyShowsCancelButton = true
}
加一個 extension 來 update 搜尋的結果
extension SearchStockTableViewController : UISearchResultsUpdating{
func updateSearchResults(for searchController: UISearchController) {
if let searchText = searchController.searchBar.text,
searchText.isEmpty == false{
searching = true
filterStockList = stockList.filter({ stock in
stock.symbolName.localizedStandardContains(searchText)
})
}else{
searching = false
filterStockList = stockList
}
tableView.reloadData()
}
}
用 delegate 傳資料回 watchList
因為搜尋股票這個功能之後在記帳的時候也會用到,所以選擇用delegate 傳資料,方便在不同的時候傳 stock symbol 去不同的頁面
透過 API 抓取股價
- 按造 API 的資料架構建一個 struct
- 連接 API
- URLQueryItem 裡的 symbol 用的是從 searchStockTableViewController 傳過來的 stock symbol
func fetchItem(stockInfo:stockFullName){
let token = "c7occ8iad3idf06mr490"
var stockUrlComponent = URLComponents(string: "https://finnhub.io/api/v1/quote")
stockUrlComponent?.queryItems = [
URLQueryItem(name: "symbol", value: stockInfo.Symbol),
URLQueryItem(name: "token", value: token)
]
URLSession.shared.dataTask(with: (stockUrlComponent?.url)!) { data, response, error in
if let data = data {
let decoder = JSONDecoder()
do{
var apiResponse = try decoder.decode(stockPriceInfo.self, from: data)
apiResponse.symbol = stockInfo.Symbol
apiResponse.company = stockInfo.CompanyName
self.watchList.append(apiResponse)
DispatchQueue.main.sync {
self.tableView.reloadData()
}
}catch{
print(error)
}
}
}.resume()
}
把 watchList 的資料存入手機的資料夾
- data container 的資料使可以修改的,所以用 FileManger.default.urls建立存資料的地方
- 用 JSONDecoder 和 JSONEncoder 取出或存入資料
struct stockPriceInfo: Codable{
var symbol : String?
var company : String?
var c: Double // current price
var d: Double // change
var dp: Double // percent change
var h : Double // high price of the day
var l : Double// low price of the day
// let o : Double // open pirce of the day
// let pc : Double // previous close price static var documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
static func loadWatchList() -> [stockPriceInfo]?{
let url = documentDirectory.appendingPathComponent("watchList")
guard let data = try? Data(contentsOf: url) else {return nil}
let decoder = JSONDecoder()
return try? decoder.decode([stockPriceInfo].self, from: data)
}
static func saveWatchList(_ watchList: [Self]){
let encoder = JSONEncoder()
guard let data = try? encoder.encode(watchList) else {return }
let url = documentDirectory.appendingPathComponent("watchList")
try? data.write(to: url)
}
}
在 watchListTableViewController,
- viewDidLoad 時會讀取存在手機的資料
- 當 watchList有任何更改時,didSet 裡會將資料存回去手機資料庫裡
var watchList = [stockPriceInfo](){
didSet{
stockPriceInfo.saveWatchList(watchList)
}
}
override func viewDidLoad() {
super.viewDidLoad()
if let watchList = stockPriceInfo.loadWatchList(){
self.watchList = watchList
}override func viewDidLoad() {
super.viewDidLoad()
if let watchList = stockPriceInfo.loadWatchList(){
self.watchList = watchList
}
}
定時更新資料
- 每五秒更新一次股價
- 記得要 timer.fire(), timer才會啟動
let timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
for stock in self.watchList{
self.fetchExistingItem(stockInfo: stock)
}
self.tableView.reloadData()
}
timer.fire()