美股記帳 App / Part1. Watch List

Julia
彼得潘的 Swift iOS / Flutter App 開發教室
13 min readMar 12, 2022

在構想完後,我決定先從 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 的 資料來源

讀取 csv 裡的資料

  1. 下載 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 = false
func 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 抓取股價

  1. 按造 API 的資料架構建一個 struct
  2. 連接 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()

Github:

作品 Link:

--

--