DEV Community

Cover image for Learning VisionPro: Let's Build a Stock Market App
Quinton
Quinton

Posted on

Learning VisionPro: Let's Build a Stock Market App

I joined Polygon.io a few months back and have been looking for a project that I can dive in to help me learn the platform’s APIs better. Then, when watching this year’s WWDC, Apple’s annual Developer conference, the idea to create a Vision Pro app to track the stock market stuck in my head.

I’ve spent most of my career in Developer Relations and consider myself an Apple (mostly iOS and WatchOS) dev by background. Building a Vision Pro app just made sense. Sharing my progress in a sort of ongoing, open Dev Diary also made sense. This series isn’t going to be an end-to-end tutorial. It will be more of an experiment in progress, where I will share learnings, trial and error, and tonnes of code snippets and utilities that help other Swift developers, and how best to utilize Polygon.io.

DISCLAIMER: I mentioned it at the start, but I do work for Polygon.io. I have a biased reason to be building with it, but I am also a developer. I joined them because the APIs are clean and modern, and they offer a free plan which is a win for developers. Also, much of the learnings in this series are about Swift and VisionPro, as much as they are about learning Polygon.io.

Enough rambling, here we are, ready to get started…

A Swift Wrapper

Polygon.io has a number of client libraries that wrap the RESTful APIs. However, currently there is not one for Swift. I’ve fallen into the habit of spending too much time creating a full featured package for things like this before, but this time I really want to jump in and proof things out.

After doing some research, I also discovered Apple has a Swift-OpenAPI generator project on GitHub. Polygon provides an OpenAPI spec already. It’s how we generate things like our Postman collection. I bookmarked the Apple project and will come back to once I get the basics up and running.

Authentication

To access the APIs, you need an API key, which which you can get with the free Basic plan. Once you have it, authorization is via a Bearer token making it easy to create a simple wrapper client using Alamofire.

The first piece of data I know I am going to need is Aggregates. Generally, I’ll test the endpoint using Postman or the interactive docs just to make sure I’m seeing the payload I expect.

Image description

PolygonClient

For now, let’s create a pretty basic API client, PolygonClient.swift, to fetch data. Before we do that, we need to store the API key somewhere. I’m going to use Info.plist and add a new key named Polygon API key, which I can reference from my code.

Image description

I have to come back to this approach later and use .xcsecrets or keychain, but for now this works fine but I can't really add Info.plist to my .gitignore. Adding a todo to come back and revisit this soon.

Now I have my API key set up, I can add it to my client code

import Foundation
import Alamofire

class PolygonClient {
    static let shared = PolygonClient()
    private let baseURL = "https://api.polygon.io/v2"

    func getAPIKey() -> String {
        if let apiKey = Bundle.main.infoDictionary?["Polygon API Key"] as? String {
           return apiKey
        } else {
            print("No Polygon API key found. Add it to Info.plist")
            return ""
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Let’s add the call to fetch aggregate information. You’ll notice I uppercase the symbol passed in to the function. Polygon is case sensitive on ticker symbols. That got me stuck for a while, despite it being clearly called out in the docs (yep, I'm a dev, I don't already read the docs...) Once I get the response from the server, I send it back via a Promise.

 func fetchAggregates(symbol: String, multiplier: Int, timespan: String, from: String, to: String, sort: String, completion: @escaping (Result<StockData, Error>) -> Void) {
        let headers: HTTPHeaders = [
            "Authorization": "Bearer \(getAPIKey())"
        ]

         let endpointURI = "/aggs/ticker"

        //polygon is case sensitive re ticker symbols. Always uppercase, just in case
        let aggsurl = baseURL + endpointURI+"/\(symbol.uppercased())/range/\(multiplier)/\(timespan)/\(from)/\(to)?sort=\(sort)"


        AF.request(aggsurl, headers: headers).responseDecodable(of: StockData.self) { response in
            switch response.result {
            case .success(let stockData):
                completion(.success(stockData))
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Strongly typed structs

Once the JSON response is returned, I’m taking advantage of Swift’s Codable protocol to dynamically map JSON elements to a strongly typed struct which I’ve creatively called StockData. It’s this struct that my app will use any time I am working with data. This decouples the API payload from my app allowing me to encapsulate changes to a single class. Right now the struct is super simple, but it is much better than peppering your app logic with JSON parsing logic which may change as a provider changes their endpoints. Creating strongly typed structs that conform to the Codable protocol is going to be really importance as I build a more fully fledged Swift wrapper for the Polygon APIs too.

import Foundation

struct StockData: Codable{
    let results: [StockResult]


    struct StockResult: Codable, Identifiable  {
        let id = UUID()
        let t: Double //polygon returns a universal date format.
        let c: Double

        enum CodingKeys: String, CodingKey {
            case t = "t"
            case c = "c"
        }
    }   
}
Enter fullscreen mode Exit fullscreen mode

That all looks good for right now. Pretty simple, but it's all I need.

The VisionPro app

I’m far from an expert at VisionPro. Thankfully SwiftUI works pretty universally across different platforms from MacOS, iOS, and now VisionOS. The overall plan is to have multiple floating windows for the user to interact with. The main window will be the control pane where you enter a ticker symbol and adjust criteria like date ranges and submit your request to return the results in a chart. Then, to the side, I wanted to experiment with ancillary information like company news.

Image description

Exciting huh? It’s an iterative process. I want to use this project as a learning tool. My thought was to really take advantage of VisionPro’s use of space to add contextual information, and experiment with gestures to do some dynamic activities like zooming into a specific time period and have data retrieved based on that new slice of time. Enough talking, let’s get coding.

A basic ContentView

Everything in SwiftUI starts with a ContentView. My layout for the main screen is pretty typically too with a split view and few fields to accept user input.

import SwiftUI
import RealityKit
import RealityKitContent
import Charts

struct ContentView: View {
    @State private var tickerText = ""
    @State private var stockData: StockData?

var body: some View {
        NavigationSplitView {
            VStack {
                        TextField("Enter stock ticker", text: $tickerText)
                            .textFieldStyle(RoundedBorderTextFieldStyle())
                            .padding()

                        Button(action: {
                            submitAction()
                        }) {
                            Text("Submit")
                                .padding()
                                .foregroundColor(.white)
                                .cornerRadius(10)
                        }
                    }
            .navigationTitle("Polygon.io Demo")
        } detail: {
            VStack {
                //my chart will go here
            }
            .navigationTitle("Stock Information")
            .padding(10)
            .onAppear {
               //do something
            }
        }

    }
Enter fullscreen mode Exit fullscreen mode

Handling button clicks and fetching data

So far so good. The sidebar let’s the user add a ticker symbol, and I hooked up a button action to submitAction() func. This is where I will call the PolygonClient we created earlier. Let’s implement this action now.


 func submitAction() {
        fetchAggregates(symbol: tickerText)
 }

private func fetchAggregates(symbol: String) {
        print("Fetching aggregates for "+symbol)

        PolygonClient.shared.fetchAggregates(symbol: symbol, multiplier: 1, timespan: "month", from: "2022-06-01", to: "2024-06-05", sort: "asc") { Result in
            switch Result {
            case .success(let data):
                DispatchQueue.main.async {
                    self.stockData = data
                }
            case .failure(let error):
                print("Error fetching stock data: \(error)")
            }
        }

    }

Enter fullscreen mode Exit fullscreen mode

Generally, I try to add my actual logic separate from say a button click func. This way if I need to call the logic from somewhere else in my code, it is not tied to the UI at all. In this instance, the submitAction which is called from the button simply calls fetchAggregate, which does all the heavy lifting.

Here’s where I fetch the data and wait for the promise to return with the payload. Once I have the data, I need to map it to a chart. That’s what I’ll work on next.

Visualize the data in a bar chart

I am going to visualize the data in a pretty simple bar chart which will appear in the detail section of my split view. I already added a VStack placeholder.All I need to do is loop through the results and create a series of Bar Marks, or points, for the chart.

if let stockData = stockData {
                    Chart {
                        ForEach(stockData.results) { dataPoint in


                            BarMark(
                                x: .value("X", dataPoint.t),
                                y: .value("Y", dataPoint.c)
                            )
                        }

                    }
                    .chartYAxis {
                        AxisMarks(values: .stride(by: 20)) {

                            AxisValueLabel(format: Decimal.FormatStyle.Currency(code: "USD"))
                        }
                    }

                } else {
                    Text("Enter a stocker ticker")
                }
Enter fullscreen mode Exit fullscreen mode

Looking a little wonky

If you run the app now, everything should be working great in regards to retrieving the aggregate information, but the chart looks wrong. The problem is the X axis. It looks kinda wonky. Yep, that's a technical term... The reason things look odd is that Polygon returns data with unix timestamps in milliseconds. We need to convert it to a date we can work with before binding it to the chart.

Image description

Jump back to the PolygonClient wrapper and the following helper func.

    // Polygon returns a unix timestamp in milliseconds. We need to convert this to a date before working with it.
    func convertUnixTimeToDateString(unixTime: Double) -> String {
        let date = Date(timeIntervalSince1970: unixTime / 1000)
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM" // Specify desired date format
        let dateString = dateFormatter.string(from: date)

        return dateString
   }

Enter fullscreen mode Exit fullscreen mode

Then, I can update BarMark point for the x axis like this.

 BarMark(
      x: .value("X", PolygonClient.shared.convertUnixTimeToDateString(unixTime: dataPoint.t)),
      y: .value("Y", dataPoint.c)
 )
Enter fullscreen mode Exit fullscreen mode

Take two. Dewonkified!

Running the app again, everything looks much better!

Image description

Wrapping up

That’s a pretty good start. I got my client wrapper working using the Polygon.io free plan and mapping to a struct, plus created ,an albeit simple, interface to show aggregate data. I’m a long way from done, but the bones are there. Next, I’ll look at incorporating some gestures like changing date ranges via zooming, and add the company info news feed. If you want to follow along, make sure you star and watch the GitHub repo.

Top comments (0)