diff --git a/OandaChandler.xcodeproj/project.pbxproj b/OandaChandler.xcodeproj/project.pbxproj index 52522c5..eeb60a3 100644 --- a/OandaChandler.xcodeproj/project.pbxproj +++ b/OandaChandler.xcodeproj/project.pbxproj @@ -7,15 +7,23 @@ objects = { /* Begin PBXBuildFile section */ + 3B5B5B20296199840084E90F /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5B5B1F296199840084E90F /* Request.swift */; }; + 3B5B5B222961B6500084E90F /* Granularity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5B5B212961B6500084E90F /* Granularity.swift */; }; 3B714143295B24BC00B5358A /* OandaChandlerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B714142295B24BC00B5358A /* OandaChandlerApp.swift */; }; 3B714145295B24BC00B5358A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B714144295B24BC00B5358A /* ContentView.swift */; }; 3B714147295B24BE00B5358A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3B714146295B24BE00B5358A /* Assets.xcassets */; }; - 3B71414A295B24BE00B5358A /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3B714149295B24BE00B5358A /* Preview Assets.xcassets */; }; 3B714152295B254400B5358A /* Candles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B714151295B254400B5358A /* Candles.swift */; }; - 3B79971C295C6DB500AD242A /* CreatingPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B79971B295C6DB500AD242A /* CreatingPhase.swift */; }; + 3B79971C295C6DB500AD242A /* Creating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B79971B295C6DB500AD242A /* Creating.swift */; }; + 3B87BEC52961001800CCE50D /* AuthData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B87BEC42961001800CCE50D /* AuthData.swift */; }; + 3B87BEC62961019500CCE50D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3B714149295B24BE00B5358A /* Preview Assets.xcassets */; }; + 3BD8BCCA2960779800B7A908 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BD8BCC92960779800B7A908 /* Date.swift */; }; + 3BD8BCCE29607E5600B7A908 /* Auth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BD8BCCD29607E5600B7A908 /* Auth.swift */; }; + 3BD8BCD029608A3F00B7A908 /* KeychainController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BD8BCCF29608A3F00B7A908 /* KeychainController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 3B5B5B1F296199840084E90F /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; + 3B5B5B212961B6500084E90F /* Granularity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Granularity.swift; sourceTree = ""; }; 3B71413F295B24BC00B5358A /* OandaChandler.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OandaChandler.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3B714142295B24BC00B5358A /* OandaChandlerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OandaChandlerApp.swift; sourceTree = ""; }; 3B714144295B24BC00B5358A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -23,7 +31,11 @@ 3B714149295B24BE00B5358A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 3B71414B295B24BE00B5358A /* OandaChandler.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OandaChandler.entitlements; sourceTree = ""; }; 3B714151295B254400B5358A /* Candles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Candles.swift; sourceTree = ""; }; - 3B79971B295C6DB500AD242A /* CreatingPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatingPhase.swift; sourceTree = ""; }; + 3B79971B295C6DB500AD242A /* Creating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Creating.swift; sourceTree = ""; }; + 3B87BEC42961001800CCE50D /* AuthData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthData.swift; sourceTree = ""; }; + 3BD8BCC92960779800B7A908 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; + 3BD8BCCD29607E5600B7A908 /* Auth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Auth.swift; sourceTree = ""; }; + 3BD8BCCF29608A3F00B7A908 /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -56,10 +68,13 @@ 3B714141295B24BC00B5358A /* OandaChandler */ = { isa = PBXGroup; children = ( + 3BD8BCCC2960781700B7A908 /* Data */, + 3BD8BCCB296077EE00B7A908 /* Views */, + 3B5B5B1F296199840084E90F /* Request.swift */, + 3BD8BCCF29608A3F00B7A908 /* KeychainController.swift */, 3B714142295B24BC00B5358A /* OandaChandlerApp.swift */, 3B714144295B24BC00B5358A /* ContentView.swift */, - 3B79971B295C6DB500AD242A /* CreatingPhase.swift */, - 3B714151295B254400B5358A /* Candles.swift */, + 3B5B5B212961B6500084E90F /* Granularity.swift */, 3B714146295B24BE00B5358A /* Assets.xcassets */, 3B71414B295B24BE00B5358A /* OandaChandler.entitlements */, 3B714148295B24BE00B5358A /* Preview Content */, @@ -75,6 +90,25 @@ path = "Preview Content"; sourceTree = ""; }; + 3BD8BCCB296077EE00B7A908 /* Views */ = { + isa = PBXGroup; + children = ( + 3B79971B295C6DB500AD242A /* Creating.swift */, + 3BD8BCCD29607E5600B7A908 /* Auth.swift */, + ); + path = Views; + sourceTree = ""; + }; + 3BD8BCCC2960781700B7A908 /* Data */ = { + isa = PBXGroup; + children = ( + 3BD8BCC92960779800B7A908 /* Date.swift */, + 3B714151295B254400B5358A /* Candles.swift */, + 3B87BEC42961001800CCE50D /* AuthData.swift */, + ); + path = Data; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -133,7 +167,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3B71414A295B24BE00B5358A /* Preview Assets.xcassets in Resources */, + 3B87BEC62961019500CCE50D /* Preview Assets.xcassets in Resources */, 3B714147295B24BE00B5358A /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -145,10 +179,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3B79971C295C6DB500AD242A /* CreatingPhase.swift in Sources */, + 3B79971C295C6DB500AD242A /* Creating.swift in Sources */, + 3BD8BCCA2960779800B7A908 /* Date.swift in Sources */, + 3B5B5B222961B6500084E90F /* Granularity.swift in Sources */, 3B714145295B24BC00B5358A /* ContentView.swift in Sources */, 3B714152295B254400B5358A /* Candles.swift in Sources */, + 3BD8BCD029608A3F00B7A908 /* KeychainController.swift in Sources */, 3B714143295B24BC00B5358A /* OandaChandlerApp.swift in Sources */, + 3BD8BCCE29607E5600B7A908 /* Auth.swift in Sources */, + 3B5B5B20296199840084E90F /* Request.swift in Sources */, + 3B87BEC52961001800CCE50D /* AuthData.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/OandaChandler.xcodeproj/project.xcworkspace/xcuserdata/pawelkaras.xcuserdatad/UserInterfaceState.xcuserstate b/OandaChandler.xcodeproj/project.xcworkspace/xcuserdata/pawelkaras.xcuserdatad/UserInterfaceState.xcuserstate index fe14ac5..7252536 100644 Binary files a/OandaChandler.xcodeproj/project.xcworkspace/xcuserdata/pawelkaras.xcuserdatad/UserInterfaceState.xcuserstate and b/OandaChandler.xcodeproj/project.xcworkspace/xcuserdata/pawelkaras.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/OandaChandler/Candles.swift b/OandaChandler/Candles.swift deleted file mode 100644 index e8e1aad..0000000 --- a/OandaChandler/Candles.swift +++ /dev/null @@ -1,172 +0,0 @@ -import SwiftUI -import Combine -import Foundation -import Dispatch - -struct CandleData: Decodable { - let candles: [Candle] -} - -struct Mid: Decodable { - let o: String - let h: String - let l: String - let c: String -} - -struct Candle: Decodable { - let complete: Bool - let volume: Int - let time: String - let mid: Mid -} - -extension Date { - static func ISOStringFromDate(date: Date) -> String { - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.timeZone = TimeZone(abbreviation: "GMT") - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" - - return dateFormatter.string(from: date).appending("Z") - } - - static func dateFromISOString(string: String) -> Date? { - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.timeZone = TimeZone.autoupdatingCurrent - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" - - return dateFormatter.date(from: string) - } -} - -class Candles { - var candles: [Candle]? = nil - var error: String? = nil - var granularity = "" - var instrument = "" - var api_key = "" - var mode = OandaMode.FXTrader - - func getRequest(from: Date, to: Date) -> URLRequest { - let fromString = Date.ISOStringFromDate(date: from) - let toString = Date.ISOStringFromDate(date: to) - var mode = ""; - switch self.mode { - case .FXTrader: - mode = "fxtrade" - case .FXPractice: - mode = "fxpractice" - } - let uri = [ - "https://api-\(mode).oanda.com/v3/instruments/\(self.instrument)/candles?", - "granularity=\(self.granularity)&", - "from=\(fromString)&", - "to=\(toString)" - ].joined(separator: "") - let url = URL(string: uri)! - var req = URLRequest(url: url) - req.addValue("Bearer \(api_key)", forHTTPHeaderField: "Authorization") - return req - } - - func hour() -> TimeInterval { - return 60 * 60 - } - - // TODO: Move granularity and instrument to it's own UI Elements - func fetchCandles(from: Date, to: Date, granularity: String, instrument: String, api_key: String, mode: OandaMode, cb: (Double) -> Void) async -> Bool { - let fromDate = Calendar.current.date(bySettingHour: 1, minute: 0, second: 0, of: from)! - let toDate = Calendar.current.date(bySettingHour: 1, minute: 0, second: 0, of: to)! - self.granularity = granularity - self.instrument = instrument - self.api_key = api_key - self.mode = mode - var currentDate = fromDate - var nextDate = fromDate + self.hour() - var index = 0 - let interval = toDate.timeIntervalSince(fromDate) - let hours = interval / 3600 - var result: [(Int, [Candle])] = [] - while (nextDate <= to) { - let batch = await withTaskGroup(of: (Int, [Candle])?.self) { group -> [(Int, [Candle])] in - for _ in 0...25 { - if (currentDate >= to) { - break - } - let request = self.getRequest(from: currentDate, to: nextDate) - let idx = index - index += 1 - group.addTask { - do { - let (data, _) = try await URLSession.shared.data(for: request) - let decoded = String(decoding: data, as: UTF8.self) - // TODO: Do this in a reasonable way - if decoded.starts(with: "{\"errorMessage") { - self.error = decoded - } -// print(String(decoding: data, as: UTF8.self)) - let candleData = try JSONDecoder().decode(CandleData.self, from: data) - return (idx, candleData.candles) - } catch { - return nil - } - } - currentDate = nextDate - nextDate += self.hour() - } - var result: [(Int, [Candle])] = [] - for await value in group { - if let candles = value { - result.append(candles) - } - } - return result - } - cb(Double(index) / hours) - result += batch - } -// print(result.sorted(by: { (a, b) in a.0 < b.0 }).map { ($0.0, $0.1.first?.time, $0.1.last?.time, $0.1.count) }) -// print(result.map { $0.1.map { $0.time } }) -// print(result.map { $0.count }) -// print(result) - self.candles = result - .sorted(by: { (a, b) in a.0 < b.0 }) - .map { $0.1 } - .reduce([], +) - - print(self.candles?.first) - return self.candles?.isEmpty == false - } - - func formatDateString(_ dateString: String) -> String { - var res = dateString.replacingOccurrences(of: "-", with: "."); - res = dateString.replacingOccurrences(of: ":", with: "."); - res = dateString.replacingOccurrences(of: "T", with: ","); - return String(res[.. Void) throws { - // Reset the contents of the target file -// try "".data(using: .utf8)!.write(to: url); -// let fileHandle = try FileHandle(forWritingTo: url) - var row = "" - for (index, candle) in self.candles!.enumerated() { - row += [ - self.formatDateString(candle.time), - candle.mid.o, - candle.mid.h, - candle.mid.l, - candle.mid.c, - String(candle.volume) - ].joined(separator: ",") + "\n" - if index % 100 == 0 { - callback(Double(index) / Double(self.candles!.count)) - } - } - try row.data(using: .utf8)!.write(to: url) - } - -} - diff --git a/OandaChandler/ContentView.swift b/OandaChandler/ContentView.swift index 4a567e9..0d9519c 100644 --- a/OandaChandler/ContentView.swift +++ b/OandaChandler/ContentView.swift @@ -39,13 +39,37 @@ struct ContentView: View { @State var downloadProgress = 0.0 @State var saveProgress = 0.0 @State var isSavingFile = false - @State var candles = Candles() + @State var candles: [Candle] = [] + @State var error: String? = nil + + func getPricing(candle: Candle) -> CandlePricing { + return (candle.mid ?? candle.bid ?? candle.ask)! + } + + func generateCSV(_ url: URL, callback: (Double) -> Void) throws { + var row = "" + for (index, candle) in self.candles.enumerated() { + let pricing = getPricing(candle: candle) + row += [ + formatDateString(candle.time), + pricing.o, + pricing.h, + pricing.l, + pricing.c, + String(candle.volume) + ].joined(separator: ",") + "\n" + if index % 100 == 0 { + callback(Double(index) / Double(self.candles.count)) + } + } + try row.data(using: .utf8)!.write(to: url) + } var body: some View { ZStack { switch state { case .Creating: - CreatingPhase(state: $state, candles: $candles, progress: $downloadProgress) + CreatingPhase(state: $state, candles: $candles, error: $error, progress: $downloadProgress) case .Fetching: ProgressView(value: downloadProgress, label: { Text("Downloading...") @@ -65,7 +89,7 @@ struct ContentView: View { isSavingFile = true backgroundQueue.async { do { - try self.candles.generateCSV(save) { value in + try self.generateCSV(save) { value in DispatchQueue.main.async { self.saveProgress = value } @@ -101,7 +125,7 @@ struct ContentView: View { } Button(action: { self.state = .Creating - self.candles.candles = nil + self.candles = [] }) { Text("Back") }.padding().disabled(isSavingFile) } } @@ -113,10 +137,10 @@ struct ContentView: View { .frame(width: 50, height: 50) .padding() Text("Could not download the data") - Text(self.candles.error ?? "[unknown reason]").monospaced().padding() + Text(self.error ?? "[unknown reason]").monospaced().padding() Button(action: { self.state = .Creating - self.candles.candles = nil + self.candles = [] }) { Text("Back") }.padding() } } diff --git a/OandaChandler/CreatingPhase.swift b/OandaChandler/CreatingPhase.swift deleted file mode 100644 index 4f102a1..0000000 --- a/OandaChandler/CreatingPhase.swift +++ /dev/null @@ -1,153 +0,0 @@ -// -// CreatingPhase.swift -// TradingChronical -// -// Created by Paweł Karaś on 28/12/2022. -// - -import Foundation -import SwiftUI -import Security - -import Foundation -import Security - -let keychainService = Bundle.main.bundleIdentifier // replace with your app's unique identifier - -// Store data in the keychain -func storeDataInKeychain(_ key: String, data: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService!, - kSecAttrAccount as String: key, - kSecValueData as String: data.data(using: .utf8)! - ] - let status = SecItemAdd(query as CFDictionary, nil) - if status != errSecSuccess { - print("Error storing data in keychain: \(status)") - } -} - -// Retrieve data from the keychain -func getDataFromKeychain(_ key: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService!, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecSuccess { - if let res = result as? Data { - return String(decoding: res, as: UTF8.self) - } else { - return nil - } - } else if status == errSecItemNotFound { - return nil - } else { - print("Error retrieving data from keychain: \(status)") - return nil - } -} - -// Test the functions -//let testData = "This is a test".data(using: .utf8)! -//storeDataInKeychain(data: testData) -//if let retrievedData = getDataFromKeychain() { -// print(String(data: retrievedData, encoding: .utf8)!) -//} else { -// print("Data not found in keychain") -//} - -enum OandaMode { - case FXTrader - case FXPractice -} - -func modeToValue(_ mode: OandaMode) -> String { - switch mode { - case .FXTrader: - return "fxt" - case .FXPractice: - return "fxp" - } -} - -func valueToMode(_ value: String) -> OandaMode { - switch value { - case "fxt": - return OandaMode.FXTrader - default: - return OandaMode.FXPractice - } -} - -struct CreatingPhase: View { - @Binding var state: AppState - @Binding var candles: Candles - @Binding var progress: Double - @State var from = Date() - @State var to = Date() - @State var granularity = "M1" - @State var instrument = "GBP_USD" - @State var token = getDataFromKeychain("token") ?? "" - @State var mode = valueToMode(getDataFromKeychain("mode") ?? "fxt") - - var body: some View { - VStack { - Text("Specify the configuration").font(.largeTitle).padding() - HStack { - SecureField("Your API Token", text: $token).frame(width: 200) - Picker("Choose mode", selection: $mode) { - Text("FX Trade").tag(OandaMode.FXTrader) - Text("FX Practice").tag(OandaMode.FXPractice) - }.frame(width: 200) - } - HStack { - TextField("Granularity", text: $granularity).frame(width: 50) - TextField("Instrument", text: $instrument).frame(width: 100) - } - HStack{ - DatePicker( - "From", - selection: $from, - displayedComponents: [.date] - ).datePickerStyle(.graphical).frame(width: 200) - DatePicker( - "To", - selection: $to, - displayedComponents: [.date] - ).datePickerStyle(.graphical).frame(width: 200) - }.padding() - Button(action: { - storeDataInKeychain("token", data: self.token) - storeDataInKeychain("mode", data: modeToValue(self.mode)) - self.state = .Fetching - Task { - let result = await self.candles.fetchCandles( - from: self.from, - to: self.to, - granularity: self.granularity, - instrument: self.instrument, - api_key: self.token, - mode: self.mode, - cb: { value in - DispatchQueue.main.async { - self.progress = value - } - } - ) - if result { - self.state = .Success - } else { - print(self.candles.candles!) - self.state = .Error - } - } - }) { Text("Submit") } - }.padding() - } -} diff --git a/OandaChandler/Data/AuthData.swift b/OandaChandler/Data/AuthData.swift new file mode 100644 index 0000000..8c4612c --- /dev/null +++ b/OandaChandler/Data/AuthData.swift @@ -0,0 +1,29 @@ +// +// Auth.swift +// OandaChandler +// +// Created by Paweł Karaś on 01/01/2023. +// + +import Foundation + +class AuthData: ObservableObject { + @Published var token = getDataFromKeychain("token") ?? ""; + @Published var mode = valueToMode(getDataFromKeychain("mode") ?? "fxt") + @Published var account_id: String? = nil + + func updateAccountId() { + Task { + self.account_id = try await Request(self).getAccount() + } + } + + func modeToUrl() -> String { + switch mode { + case .FXTrade: + return "fxtrade" + case .FXPractice: + return "fxpractice" + } + } +} diff --git a/OandaChandler/Data/Candles.swift b/OandaChandler/Data/Candles.swift new file mode 100644 index 0000000..2ec85a6 --- /dev/null +++ b/OandaChandler/Data/Candles.swift @@ -0,0 +1,54 @@ +import SwiftUI +import Combine +import Foundation +import Dispatch + +struct CandleData: Decodable { + let candles: [Candle] +} + +struct CandlePricing: Decodable { + let o: String + let h: String + let l: String + let c: String +} + +struct Candle: Decodable { + let complete: Bool + let volume: Int + let time: String + let mid: CandlePricing? + let bid: CandlePricing? + let ask: CandlePricing? +} + +enum CandlePricingType: String, CaseIterable, Identifiable { + var id: String { rawValue } + + case mid + case bid + case ask + + func getName() -> String { + switch self { + case .mid: + return "Mid" + case .bid: + return "Bid" + case .ask: + return "Ask" + } + } + + func getUrl() -> String { + switch self { + case .mid: + return "M" + case .bid: + return "B" + case .ask: + return "A" + } + } +} diff --git a/OandaChandler/Data/Date.swift b/OandaChandler/Data/Date.swift new file mode 100644 index 0000000..70d4cc2 --- /dev/null +++ b/OandaChandler/Data/Date.swift @@ -0,0 +1,28 @@ +import Foundation + +extension Date { + static func ISOStringFromDate(date: Date) -> String { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(abbreviation: "GMT") + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + + return dateFormatter.string(from: date).appending("Z") + } + + static func dateFromISOString(string: String) -> Date? { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone.autoupdatingCurrent + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + + return dateFormatter.date(from: string) + } +} + +func formatDateString(_ dateString: String) -> String { + var res = dateString.replacingOccurrences(of: "-", with: "."); + res = dateString.replacingOccurrences(of: ":", with: "."); + res = dateString.replacingOccurrences(of: "T", with: ","); + return String(res[.. String { + switch self { + case .m1: + return "1 Minute" + case .m2: + return "2 Minute" + case .m5: + return "5 Minute" + case .m10: + return "10 Minute" + case .m15: + return "15 Minute" + case .m30: + return "30 Minute" + case .h1: + return "1 Hour" + case .h2: + return "2 Hour" + case .h4: + return "4 Hour" + } + } + + func getUrl() -> String { + return self.rawValue + } +} + + diff --git a/OandaChandler/KeychainController.swift b/OandaChandler/KeychainController.swift new file mode 100644 index 0000000..0a3ebcd --- /dev/null +++ b/OandaChandler/KeychainController.swift @@ -0,0 +1,59 @@ +// +// KeychainController.swift +// OandaChandler +// +// Created by Paweł Karaś on 31/12/2022. +// + +import Foundation +import Security + +let keychainService = Bundle.main.bundleIdentifier // replace with your app's unique identifier + +// Store data in the keychain +func storeDataInKeychain(_ key: String, data: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService!, + kSecAttrAccount as String: key, + kSecValueData as String: data.data(using: .utf8)! + ] + Task { + let status = SecItemAdd(query as CFDictionary, nil) + if status != errSecSuccess { + let attributes: [String: Any] = [ + kSecAttrAccount as String: key, + kSecValueData as String: data.data(using: .utf8)! + ] + let result = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if result != errSecSuccess { + print("Error storing data in keychain: \(status)") + } + } + } +} + +// Retrieve data from the keychain +func getDataFromKeychain(_ key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService!, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecSuccess { + if let res = result as? Data { + return String(decoding: res, as: UTF8.self) + } else { + return nil + } + } else if status == errSecItemNotFound { + return nil + } else { + print("Error retrieving data from keychain: \(status)") + return nil + } +} diff --git a/OandaChandler/OandaChandlerApp.swift b/OandaChandler/OandaChandlerApp.swift index 63e35f2..7b2f931 100644 --- a/OandaChandler/OandaChandlerApp.swift +++ b/OandaChandler/OandaChandlerApp.swift @@ -1,23 +1,82 @@ -// -// TradingCronicalApp.swift -// TradingCronical -// -// Created by Paweł Karaś on 27/12/2022. -// - import SwiftUI +enum SideBarItem: String, Identifiable, CaseIterable { + var id: String { rawValue } + + case export = "Export" + case auth = "Authenticate" +} + +func sideBarIcon(_ item: SideBarItem) -> String { + switch item { + case .export: + return "square.and.arrow.down" + case .auth: + return "key" + } +} + + @main struct TradingCronicalApp: App { + @State var sideBarVisibility: NavigationSplitViewVisibility = .doubleColumn + @State var selected = getDataFromKeychain("token") != nil ? SideBarItem.export : SideBarItem.auth + @StateObject var authData = AuthData(); + @State var granuality = Granularity.m1 + @State var instruments = [] + var body: some Scene { WindowGroup { ZStack { - LinearGradient(colors: [ - Color(red: 95/255, green: 42/255, blue: 0), - Color(red: 40/255, green: 17/255, blue: 0) - ], startPoint: .topLeading, endPoint: .bottom) - ContentView() - }.edgesIgnoringSafeArea(.all) - }.windowStyle(HiddenTitleBarWindowStyle()) + // Background Gradient +// LinearGradient(colors: [ +// Color(red: 95/255, green: 42/255, blue: 0), +// Color(red: 40/255, green: 17/255, blue: 0) +// ], startPoint: .topLeading, endPoint: .bottom) + NavigationSplitView(columnVisibility: $sideBarVisibility) { + List(SideBarItem.allCases, selection: $selected) { item in + NavigationLink(value: item) { + Image(systemName: sideBarIcon(item)).foregroundColor(.accentColor) + Text(item.rawValue.localizedCapitalized) + }.disabled(self.authData.account_id == nil && item == .export) + } + } detail: { + switch selected { + case .auth: + Auth() + .environmentObject(authData) + .frame(maxHeight: .infinity, alignment: .top) + case .export: + ContentView() + .environmentObject(authData) + } + }.onChange(of: authData.account_id) { auth in + if auth == nil { + selected = SideBarItem.auth + } + }.onAppear { + if authData.account_id == nil { + selected = SideBarItem.auth + } + } + } + .edgesIgnoringSafeArea(.all) + .toolbar { + ToolbarItemGroup(placement: .navigation, content: { + Button(action: { + self.authData.updateAccountId() + }, label: { + if self.authData.account_id != nil { + Image(systemName: "wifi") + } else { + Image(systemName: "wifi.slash") + } + }).help("Reconnect") + .animation(.default, value: self.authData.account_id) + .onAppear(perform: self.authData.updateAccountId) + }) + } + }.windowStyle(.hiddenTitleBar) + .windowToolbarStyle(.unified) } } diff --git a/OandaChandler/Request.swift b/OandaChandler/Request.swift new file mode 100644 index 0000000..64134a9 --- /dev/null +++ b/OandaChandler/Request.swift @@ -0,0 +1,74 @@ +// +// Request.swift +// OandaChandler +// +// Created by Paweł Karaś on 01/01/2023. +// + +import Foundation +import SwiftUI + +struct RequestError: Error { + var value: String +} + +class Request: ObservableObject { + var authData: AuthData + + init(_ authData: AuthData) { + self.authData = authData + } + + private func request(_ url: [String], query rawQuery: [String:String] = [:]) async throws -> (Data, URLResponse) { + let query = rawQuery.map { (key, value) in "\(key)=\(value)" } + var uri = (["https://api-\(authData.modeToUrl()).oanda.com/v3/"] + url).joined(separator: "/") + if !query.isEmpty { uri += "?\(query.joined(separator: "&"))" } + let url = URL(string: uri)! + var req = URLRequest(url: url) + req.addValue("Bearer \(authData.token)", forHTTPHeaderField: "Authorization") + return try await URLSession.shared.data(for: req) + } + + func getAccount() async throws -> String? { + let (data, _) = try await self.request(["accounts"]) + if let data = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any], + let array = data["accounts"] as? [[String: Any]], + let id = array[0]["id"] as? String { + return id + } + return nil + } + + func getInstruments() async throws -> [String]? { + guard let account_id = authData.account_id else { + return nil + } + var result: [String] = [] + let (data, _) = try await self.request(["accounts", account_id, "instruments"]) + if let data = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any], + let instruments = data["instruments"] as? [[String: Any]] { + for instrument in instruments { + if let name = instrument["name"] as? String { + result.append(name) + } + } + } + return result + } + + func getCandles(from: Date, to: Date, gran: Granularity, instrument: String, pricing: CandlePricingType) async throws -> Result { + let fromString = Date.ISOStringFromDate(date: from) + let toString = Date.ISOStringFromDate(date: to) + let (data, _) = try await self.request(["instruments", instrument, "candles"], query: [ + "granularity": gran.getUrl(), + "from": fromString, + "to": toString, + "price": pricing.getUrl() + ]) + do { + return .success(try JSONDecoder().decode(CandleData.self, from: data)) + } catch { + return .failure(RequestError(value: String(decoding: data, as: UTF8.self))) + } + } +} diff --git a/OandaChandler/Views/Auth.swift b/OandaChandler/Views/Auth.swift new file mode 100644 index 0000000..607eda1 --- /dev/null +++ b/OandaChandler/Views/Auth.swift @@ -0,0 +1,55 @@ +// +// Auth.swift +// OandaChandler +// +// Created by Paweł Karaś on 31/12/2022. +// + +import Foundation +import SwiftUI + +struct Auth: View { + @EnvironmentObject var authData: AuthData + @FocusState var isTokenFocused: Bool + + func getAccountId() { + Task { + authData.account_id = try await Request(authData).getAccount() + } + } + + var body: some View { + VStack { + Image(systemName: "key.viewfinder") + .resizable() + .frame(width: 50, height: 50) + .foregroundColor(.accentColor) + .padding() + Text("Oanda API Token").font(.largeTitle) + .padding(.bottom) + VStack { + HStack { + SecureField("Insert here your Oanda API Token", text: $authData.token) + .focused($isTokenFocused).onChange(of: authData.token) { item in + storeDataInKeychain("token", data: authData.token) + getAccountId() + }.frame(width: 200) + Picker("", selection: $authData.mode) { + Text("FX Trade").tag(OandaMode.FXTrade) + Text("FX Practice").tag(OandaMode.FXPractice) + } .onChange(of: authData.mode) { mode in + storeDataInKeychain("mode", data: modeToValue(authData.mode)) + getAccountId() + }.frame(width: 200) + } + Text("The api token will be stored securely in your keychain.").font(.footnote) + } + } + } +} + +struct Auth_Previews: PreviewProvider { + static var previews: some View { + Auth() + } +} diff --git a/OandaChandler/Views/Creating.swift b/OandaChandler/Views/Creating.swift new file mode 100644 index 0000000..4f5c2ec --- /dev/null +++ b/OandaChandler/Views/Creating.swift @@ -0,0 +1,185 @@ +// +// CreatingPhase.swift +// TradingChronical +// +// Created by Paweł Karaś on 28/12/2022. +// + +import Foundation +import SwiftUI + +enum OandaMode { + case FXTrade + case FXPractice +} + +func modeToValue(_ mode: OandaMode) -> String { + switch mode { + case .FXTrade: + return "fxt" + case .FXPractice: + return "fxp" + } +} + +func valueToMode(_ value: String) -> OandaMode { + switch value { + case "fxt": + return OandaMode.FXTrade + default: + return OandaMode.FXPractice + } +} + +func hour(_ amount: Double) -> TimeInterval { + return 60 * 60 * amount +} + +struct CreatingPhase: View { + @EnvironmentObject var authData: AuthData + @Binding var state: AppState + @Binding var candles: [Candle] + @Binding var error: String? + @Binding var progress: Double + @State var from = Date() + @State var to = Date() + @State var granularity = Granularity.m1 + @State var instruments: [String] = [] + @State var instrument = "GBP_USD" + @State var candlePricing = CandlePricingType.bid + + func fetchCandles(cb: (Double) -> Void) async { + let request = Request(authData) + let hour_count: Double = 8 + let fromDate = Calendar.current.date(bySettingHour: 1, minute: 0, second: 0, of: from)! + let toDate = Calendar.current.date(bySettingHour: 1, minute: 0, second: 0, of: to)! + // Get the total amount of time units to calculate + let interval = toDate.timeIntervalSince(fromDate) + let hours = interval / hour(hour_count) as Double + // Currently processed time unit + var index = 0 + // Variables used to iterate over time + var currentDate = fromDate + var nextDate = fromDate + hour(hour_count) + // Cannot calculate the range of just today + if self.from == self.to { + self.error = "Please select a bigger timestamp" + return + } + // Cannot download candles from the future + if self.to > Date() { + self.error = "Cannot download candles from the future (You wish... 💸)" + return + } + // Result of this function containing all fetched candles + var result: [(Int, [Candle])] = [] + while (nextDate < to) { + let batch = await withTaskGroup(of: Result<(Int, [Candle]), RequestError>?.self) { group -> [(Int, [Candle])] in + // Create eight parallel workers that fetch the candles + for _ in 0...8 { + if (nextDate >= to) { + break + } + let from = currentDate + let to = nextDate + let idx = index + index += 1 + group.addTask { + do { + return try await request + .getCandles(from: from, to: to, gran: self.granularity, instrument: self.instrument, pricing: self.candlePricing) + .map { (idx, $0.candles) } + } catch { + return nil + } + } + currentDate = nextDate + nextDate += hour(hour_count) + } + var result: [(Int, [Candle])] = [] + // Aggregate the results + for await value in group { + if let resultVal = value { + switch resultVal { + case .success(let candles): + result.append(candles) + case .failure(let error): + self.error = error.value + } + } + } + return result + } + cb(Double(index) / hours) + result += batch + } + // Sort the concurrently fetched candles + self.candles = result + .sorted(by: { (a, b) in a.0 < b.0 }) + .map { $0.1 } + .reduce([], +) + } + + var body: some View { + VStack { + Text("Specify the configuration").font(.largeTitle).padding() + HStack { + Picker("", selection: $granularity) { + ForEach(Granularity.allCases) { gran in + Text(gran.getName()).tag(gran) + } + }.frame(width: 100) + if !self.instruments.isEmpty { + Picker("", selection: $instrument) { + ForEach(instruments, id: \.self) { instr in + Text(instr).tag(instr) + } + }.frame(width: 100) + } else { + ProgressView().onAppear { + Task { + self.authData.account_id = try await Request(authData).getAccount() + if let instruments = try await Request(authData).getInstruments() { + self.instruments = instruments + } + } + }.padding() + } + Picker("", selection: $candlePricing) { + ForEach(CandlePricingType.allCases) { pricing in + Text(pricing.getName()).tag(pricing) + } + }.frame(width: 100) + } + HStack{ + DatePicker( + "From", + selection: $from, + displayedComponents: [.date] + ).datePickerStyle(.graphical).frame(width: 200) + DatePicker( + "To", + selection: $to, + displayedComponents: [.date] + ).datePickerStyle(.graphical).frame(width: 200) + }.padding() + Button(action: { + storeDataInKeychain("token", data: self.authData.token) + storeDataInKeychain("mode", data: modeToValue(self.authData.mode)) + self.state = .Fetching + Task { + await self.fetchCandles { value in + DispatchQueue.main.async { + self.progress = value + } + } + if !self.candles.isEmpty { + self.state = .Success + } else { + self.state = .Error + } + } + }) { Text("Submit") } + }.padding().frame(maxHeight: .infinity, alignment: .top) + } +}