├── README.md ├── .gitignore ├── Sources └── FtxConnector │ ├── Extensions │ ├── URLError.swift │ └── HashedAuthenticationCode.swift │ ├── Codables │ ├── FtxResponse.swift │ ├── FtxSubaccount.swift │ ├── FtxAccount.swift │ └── FtxPosition.swift │ ├── FtxConnector.swift │ ├── FtxEndpoint.swift │ └── FtxApiClient.swift ├── Tests └── FtxConnectorTests │ ├── ConnectorTests.swift │ └── RestApiTests.swift ├── .github └── workflows │ └── swift.yml ├── LICENSE └── Package.swift /README.md: -------------------------------------------------------------------------------- 1 | # FtxConnector 2 | 3 | Swift bindings to the FTX API 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /Sources/FtxConnector/Extensions/URLError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URLError.Code { 4 | static var rateLimited: URLError.Code { 5 | return URLError.Code(rawValue: 429) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/FtxConnector/Codables/FtxResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct FtxResponse: Codable, Equatable where T: Codable & Equatable { 4 | public let success: Bool 5 | public let result: T 6 | } 7 | -------------------------------------------------------------------------------- /Tests/FtxConnectorTests/ConnectorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import FtxConnector 3 | 4 | final class ConnectorTests: XCTestCase { 5 | func testTime() async throws { 6 | let time = try await FtxConnector.time() 7 | XCTAssertNotNil(time) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/FtxConnector/Extensions/HashedAuthenticationCode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CryptoKit 3 | 4 | extension HashedAuthenticationCode { 5 | var hex: String { 6 | return self.map({ return String(format: "%02hhx", $0) }).joined() 7 | } 8 | 9 | var data: Data { 10 | return Data(self) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: macos-latest 12 | environment: readonly-ftx 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Build 16 | run: swift build -v 17 | - name: Run tests 18 | run: swift test -v 19 | -------------------------------------------------------------------------------- /Sources/FtxConnector/Codables/FtxSubaccount.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | * - SeeAlso: https://docs.ftx.com/#subaccounts 5 | */ 6 | public struct FtxSubaccount: Codable, Equatable { 7 | /// subaccount name 8 | public let nickname: String 9 | /// whether the subaccount can be deleted 10 | public let deletable: Bool 11 | /// whether the nickname of the subaccount can be changed 12 | public let editable: Bool 13 | /// whether the subaccount was created for a competition 14 | public let competition: Bool 15 | 16 | public var encodedNickname: String { 17 | return self.nickname.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? self.nickname 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Not for commericial use without valid license. If you would like to use this on the App Store then you must purchase a commercial license. 2 | 3 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 6 | -------------------------------------------------------------------------------- /Tests/FtxConnectorTests/RestApiTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import FtxConnector 3 | 4 | final class RestApiTests: XCTestCase { 5 | var client: FtxRestApiClient! 6 | 7 | override func setUp() { 8 | super.setUp() 9 | self.client = FtxConnector.restClient(key: ProcessInfo.processInfo.environment["FTX_KEY"]!, secret: ProcessInfo.processInfo.environment["FTX_SECRET"]!) 10 | } 11 | 12 | func testAccount() async throws { 13 | let account = try await client.account() 14 | XCTAssertNotNil(account) 15 | } 16 | 17 | func testPositions() async throws { 18 | let positions_avg_price = try await client.positions(showAvgPrice: true) 19 | XCTAssertNotNil(positions_avg_price) 20 | 21 | let positions = try await client.positions(showAvgPrice: false) 22 | XCTAssertNotNil(positions) 23 | } 24 | 25 | func testSubaccounts() async throws { 26 | let subaccounts = try await self.client.subaccounts() 27 | XCTAssertNotNil(subaccounts) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "FtxConnector", 8 | platforms: [ 9 | .macOS(.v12), .iOS(.v15) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "FtxConnector", 15 | targets: ["FtxConnector"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "FtxConnector", 26 | dependencies: []), 27 | .testTarget( 28 | name: "FtxConnectorTests", 29 | dependencies: ["FtxConnector"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /Sources/FtxConnector/FtxConnector.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CryptoKit 3 | 4 | public struct FtxConnector { 5 | public static func restClient(key: String, secret: String) -> FtxRestApiClient { 6 | return FtxRestApiClient(key: key, secret: secret) 7 | } 8 | 9 | public static var timeEndpoint = "https://otc.ftx.com/api/time" 10 | 11 | /// Gets the time from `timeEndpoint` 12 | public static func time() async throws -> FtxResponse { 13 | let (data, _) = try await URLSession.shared.data(from: URL(string: Self.timeEndpoint)!) 14 | 15 | // todo: convert to actual timestamp/date 16 | let decoder = JSONDecoder() 17 | decoder.dateDecodingStrategy = .custom { decoder in 18 | let container = try decoder.singleValueContainer() 19 | let dateString = try container.decode(String.self) 20 | 21 | let formatter = DateFormatter() 22 | // 2021-10-09T20:46:59.545652+00:00 23 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" 24 | 25 | guard let date = formatter.date(from: dateString) else { 26 | throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)") 27 | } 28 | 29 | return date 30 | } 31 | return try decoder.decode(FtxResponse.self, from: data) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/FtxConnector/Codables/FtxAccount.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | * Account Information 5 | * 6 | * - SeeAlso: https://docs.ftx.com/#account 7 | */ 8 | public struct FtxAccount: Codable, Equatable { 9 | /// whether or not the account is a registered backstop liquidity provider 10 | public let backstopProvider: Bool 11 | /// amount of collateral 12 | public let collateral: Double 13 | /// amount of free collateral 14 | public let freeCollateral: Double 15 | /// average of initialMarginRequirement for individual futures, weighed by position notional. Cannot open new positions if openMarginFraction falls below this value. 16 | public let initialMarginRequirement: Double 17 | /// Max account leverage 18 | public let leverage: Double 19 | /// True if the account is currently being liquidated 20 | public let liquidating: Bool 21 | /// Average of maintenanceMarginRequirement for individual futures, weighed by position notional. Account enters liquidation mode if margin fraction falls below this value. 22 | public let maintenanceMarginRequirement: Double 23 | public let makerFee: Double 24 | public let takerFee: Double 25 | /// ratio between total account value and total account position notional. 26 | public let marginFraction: Double? 27 | /// Ratio between total realized account value and total open position notional 28 | public let openMarginFraction: Double? 29 | /// total value of the account, using mark price for positions 30 | public let totalAccountValue: Double 31 | /// total size of positions held by the account, using mark price 32 | public let totalPositionSize: Double 33 | public let username: String 34 | public let positions: [FtxPosition] 35 | } 36 | -------------------------------------------------------------------------------- /Sources/FtxConnector/FtxEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CryptoKit 3 | 4 | public enum FtxEndpoint: String { 5 | case account = "/api/account" 6 | case subaccounts = "/api/subaccounts" 7 | case positions = "/api/positions" 8 | 9 | public enum FtxEndpointRequestType: String { 10 | case GET = "GET" 11 | case POST = "POST" 12 | } 13 | 14 | /** 15 | * FTX Signature 16 | * 17 | * SHA256 HMAC of the following four strings, using your API secret, as a hex string: 18 | * * Request timestamp (e.g. 1528394229375) 19 | * * HTTP method in uppercase (e.g. GET or POST) 20 | * * Request path, including leading slash and any URL parameters but not including the hostname (e.g. /account) 21 | * * (POST only) Request body (JSON-encoded) 22 | * 23 | * - SeeAlso: https://docs.ftx.com/#authentication 24 | * 25 | * - parameter ts: The request timestamp 26 | * - parameter secret: API secret, as a hex string 27 | * - parameter requestType: HTTP method 28 | * - parameter queryItems: URL parameters 29 | * 30 | * - returns: The SHA256 HMAC 31 | */ 32 | public func signature(ts: Int, secret: String, requestType: FtxEndpointRequestType = .GET, queryItems: [URLQueryItem]?=nil) -> String? { 33 | var queryString = "" 34 | if let queryItems = queryItems { 35 | var c = URLComponents() 36 | c.queryItems = queryItems 37 | queryString = c.url?.absoluteString ?? "" 38 | } 39 | // todo: Manage POST data 40 | guard let payload = "\(ts)\(requestType.rawValue)\(self.rawValue)\(queryString)".data(using: .utf8) else { 41 | return nil 42 | } 43 | guard let secretData = secret.data(using: .utf8) else { 44 | return nil 45 | } 46 | let key = SymmetricKey(data: secretData) 47 | var hmac = HMAC(key: key) 48 | hmac.update(data: payload) 49 | let sig = hmac.finalize() 50 | return sig.hex 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/FtxConnector/Codables/FtxPosition.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | * - SeeAlso: https://docs.ftx.com/#get-positions 5 | */ 6 | public struct FtxPosition: Codable, Equatable { 7 | /// Amount that was paid to enter this position, equal to size * entry_price. Positive if long, negative if short. 8 | public let cost: Double 9 | public let cumulativeBuySize: Double? 10 | public let cumulativeSellSize: Double? 11 | /// Average cost of this position after pnl was last realized: whenever unrealized pnl gets realized, this field gets set to mark price, unrealizedPnL is set to 0, and realizedPnl changes by the previous value for unrealizedPnl. 12 | public let entryPrice: Double? 13 | public let estimatedLiquidationPrice: Double? 14 | /// future name 15 | public let future: String 16 | /// Minimum margin fraction for opening new positions 17 | public let initialMarginRequirement: Double 18 | /// Cumulative size of all open bids 19 | public let longOrderSize: Double 20 | /// Minimum margin fraction to avoid liquidations 21 | public let maintenanceMarginRequirement: Double 22 | /// Size of position. Positive if long, negative if short. 23 | public let netSize: Double 24 | /// Maximum possible absolute position size if some subset of open orders are filled 25 | public let openSize: Double 26 | public let realizedPnl: Double 27 | public let recentAverageOpenPrice: Double? 28 | public let recentBreakEvenPrice: Double? 29 | public let recentPnl: Double? 30 | /// Cumulative size of all open offers 31 | public let shortOrderSize: Double 32 | /// sell if short, buy if long 33 | public let side: String 34 | /// Absolute value of netSize 35 | public let size: Double 36 | public let unrealizedPnl: Double 37 | /** 38 | * Is equal to: 39 | * * For PRESIDENT: initialMarginRequirement * openSize * (risk price) 40 | * * For MOVE: initialMarginRequirement * openSize * (index price) 41 | * * Otherwise: initialMarginRequirement * openSize * (mark price) 42 | */ 43 | public let collateralUsed: Double 44 | } 45 | -------------------------------------------------------------------------------- /Sources/FtxConnector/FtxApiClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct FtxRestApiClient { 4 | public let key: String 5 | public let secret: String 6 | public var session: URLSession = URLSession.shared 7 | internal var host = "https://ftx.com" 8 | 9 | internal func ts() -> Int { 10 | return Int(Date().timeIntervalSince1970*1000) 11 | } 12 | 13 | internal func setupRequest(endpoint: FtxEndpoint, queryItems: [URLQueryItem]?=nil, subaccount: FtxSubaccount? = nil) throws -> URLRequest { 14 | guard var components = URLComponents(string: self.host) else { 15 | throw URLError(.badURL) 16 | } 17 | components.path = components.path + endpoint.rawValue 18 | components.queryItems = queryItems 19 | guard let url = components.url else { 20 | throw URLError(.badURL) 21 | } 22 | let ts = self.ts() 23 | guard let hmac = endpoint.signature(ts: ts, secret: self.secret, queryItems: queryItems) else { 24 | throw URLError(.userAuthenticationRequired) 25 | } 26 | var request = URLRequest(url: url) 27 | request.addValue(self.key, forHTTPHeaderField: "FTX-KEY") 28 | request.addValue(hmac, forHTTPHeaderField: "FTX-SIGN") 29 | print("hmac", hmac) 30 | request.addValue("\(ts)", forHTTPHeaderField: "FTX-TS") 31 | if let subaccount = subaccount { 32 | request.addValue(subaccount.encodedNickname, forHTTPHeaderField: "FTX-SUBACCOUNT") 33 | } 34 | return request 35 | } 36 | 37 | internal func check(response: URLResponse) throws { 38 | // You are being rate limited 39 | // https://help.ftx.com/hc/en-us/articles/360052595091-2020-11-20-Ratelimit-Updates 40 | if (response as? HTTPURLResponse)?.statusCode == 429 { 41 | throw URLError(.rateLimited) 42 | } 43 | 44 | guard (response as? HTTPURLResponse)?.statusCode == 200 else { 45 | throw URLError(.badServerResponse) 46 | } 47 | } 48 | 49 | public func account(subaccount: FtxSubaccount? = nil) async throws -> FtxResponse { 50 | let request = try self.setupRequest(endpoint: .account, subaccount: subaccount) 51 | 52 | let (data, response) = try await self.session.data(for: request) 53 | 54 | try self.check(response: response) 55 | 56 | return try JSONDecoder().decode(FtxResponse.self, from: data) 57 | } 58 | 59 | public func positions(showAvgPrice: Bool = false, subaccount: FtxSubaccount? = nil) async throws -> FtxResponse<[FtxPosition]> { 60 | let request = try self.setupRequest(endpoint: .positions, queryItems: [URLQueryItem(name: "showAvgPrice", value: "\(showAvgPrice)".lowercased())], subaccount: subaccount) 61 | 62 | let (data, response) = try await self.session.data(for: request) 63 | 64 | try self.check(response: response) 65 | 66 | return try JSONDecoder().decode(FtxResponse<[FtxPosition]>.self, from: data) 67 | } 68 | 69 | public func subaccounts(subaccount: FtxSubaccount? = nil) async throws -> FtxResponse<[FtxSubaccount]> { 70 | let request = try self.setupRequest(endpoint: .subaccounts, subaccount: subaccount) 71 | 72 | let (data, response) = try await self.session.data(for: request) 73 | 74 | try self.check(response: response) 75 | 76 | return try JSONDecoder().decode(FtxResponse<[FtxSubaccount]>.self, from: data) 77 | } 78 | } 79 | --------------------------------------------------------------------------------