diff --git a/DXFeedFramework.xcodeproj/project.pbxproj b/DXFeedFramework.xcodeproj/project.pbxproj index d12c03983..cfbb5f8b1 100644 --- a/DXFeedFramework.xcodeproj/project.pbxproj +++ b/DXFeedFramework.xcodeproj/project.pbxproj @@ -115,6 +115,10 @@ 643A329B2BD0137000F6F790 /* Optional+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643A329A2BD0137000F6F790 /* Optional+Ext.swift */; }; 643A329F2BD2A04300F6F790 /* OnDemandService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643A329E2BD2A04300F6F790 /* OnDemandService.swift */; }; 643A32A22BD2AEFB00F6F790 /* NativeOnDemandService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643A32A12BD2AEFB00F6F790 /* NativeOnDemandService.swift */; }; + 643F41F52BDFE1B000A2176D /* DXFeedCandleChartApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643F41F42BDFE1B000A2176D /* DXFeedCandleChartApp.swift */; }; + 643F41F92BDFE1B200A2176D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 643F41F82BDFE1B200A2176D /* Assets.xcassets */; }; + 643F41FC2BDFE1B200A2176D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 643F41FB2BDFE1B200A2176D /* Preview Assets.xcassets */; }; + 643F42012BDFE25D00A2176D /* CandleStickChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643F42002BDFE25D00A2176D /* CandleStickChart.swift */; }; 64437A8F2A9DEE6F005929B2 /* InstrumentProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64437A8E2A9DEE6F005929B2 /* InstrumentProfile.swift */; }; 64437A922A9DF1DE005929B2 /* NativeInstrumentProfileReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64437A912A9DF1DE005929B2 /* NativeInstrumentProfileReader.swift */; }; 6447A5DB2A8E559000739CCF /* ILastingEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6447A5DA2A8E559000739CCF /* ILastingEvent.swift */; }; @@ -679,6 +683,11 @@ 643A329C2BD15F2900F6F790 /* LastEventsConsole.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = LastEventsConsole.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 643A329E2BD2A04300F6F790 /* OnDemandService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnDemandService.swift; sourceTree = ""; }; 643A32A12BD2AEFB00F6F790 /* NativeOnDemandService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeOnDemandService.swift; sourceTree = ""; }; + 643F41F22BDFE1B000A2176D /* DXFeedCandleChart.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DXFeedCandleChart.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 643F41F42BDFE1B000A2176D /* DXFeedCandleChartApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DXFeedCandleChartApp.swift; sourceTree = ""; }; + 643F41F82BDFE1B200A2176D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 643F41FB2BDFE1B200A2176D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 643F42002BDFE25D00A2176D /* CandleStickChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CandleStickChart.swift; sourceTree = ""; }; 64437A8E2A9DEE6F005929B2 /* InstrumentProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstrumentProfile.swift; sourceTree = ""; }; 64437A912A9DF1DE005929B2 /* NativeInstrumentProfileReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeInstrumentProfileReader.swift; sourceTree = ""; }; 644551C92B973A0D0069E3A2 /* FetchDailyCandles.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = FetchDailyCandles.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; @@ -951,6 +960,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 643F41EF2BDFE1B000A2176D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 644BD7572A44726F00A0BF99 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1208,6 +1224,25 @@ path = OnDemandService; sourceTree = ""; }; + 643F41F32BDFE1B000A2176D /* DXFeedCandleChart */ = { + isa = PBXGroup; + children = ( + 643F41F42BDFE1B000A2176D /* DXFeedCandleChartApp.swift */, + 643F42002BDFE25D00A2176D /* CandleStickChart.swift */, + 643F41F82BDFE1B200A2176D /* Assets.xcassets */, + 643F41FA2BDFE1B200A2176D /* Preview Content */, + ); + path = DXFeedCandleChart; + sourceTree = ""; + }; + 643F41FA2BDFE1B200A2176D /* Preview Content */ = { + isa = PBXGroup; + children = ( + 643F41FB2BDFE1B200A2176D /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; 64437A902A9DF1C4005929B2 /* Ipf */ = { isa = PBXGroup; children = ( @@ -1533,6 +1568,7 @@ 641E45FD2B1DF67E00649363 /* Playgrounds */, 6469F8D12A3B400100846831 /* Utils */, 644BD75B2A44726F00A0BF99 /* ARQuoteTableApp */, + 643F41F32BDFE1B000A2176D /* DXFeedCandleChart */, 64D8BB3C2A39BB730071BC88 /* LatencyTestApp */, 64B6275D2A3761A000196D07 /* PertTestApp */, 64B627162A375BBA00196D07 /* QuoteTableApp */, @@ -1626,6 +1662,7 @@ 64B4364B2AB9D3410003919E /* ScheduleSampleApp.app */, 64148B642ABB5C320063110E /* Tools */, 6455C3B82B20A44D00257986 /* QdsTools.app */, + 643F41F22BDFE1B000A2176D /* DXFeedCandleChart.app */, ); name = Products; sourceTree = ""; @@ -1885,6 +1922,23 @@ productReference = 642DC9252AAA21C000974F5C /* DXIpfTableApp.app */; productType = "com.apple.product-type.application"; }; + 643F41F12BDFE1B000A2176D /* DXFeedCandleChart */ = { + isa = PBXNativeTarget; + buildConfigurationList = 643F41FD2BDFE1B200A2176D /* Build configuration list for PBXNativeTarget "DXFeedCandleChart" */; + buildPhases = ( + 643F41EE2BDFE1B000A2176D /* Sources */, + 643F41EF2BDFE1B000A2176D /* Frameworks */, + 643F41F02BDFE1B000A2176D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = DXFeedCandleChart; + productName = DXFeedCandleChart; + productReference = 643F41F22BDFE1B000A2176D /* DXFeedCandleChart.app */; + productType = "com.apple.product-type.application"; + }; 644BD7592A44726F00A0BF99 /* DXARQuoteTableApp */ = { isa = PBXNativeTarget; buildConfigurationList = 644BD76C2A44727000A0BF99 /* Build configuration list for PBXNativeTarget "DXARQuoteTableApp" */; @@ -2048,7 +2102,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; CLASSPREFIX = DX; - LastSwiftUpdateCheck = 1430; + LastSwiftUpdateCheck = 1520; LastUpgradeCheck = 1420; TargetAttributes = { 64148B632ABB5C320063110E = { @@ -2057,6 +2111,9 @@ 642DC9242AAA21C000974F5C = { CreatedOnToolsVersion = 15.0; }; + 643F41F12BDFE1B000A2176D = { + CreatedOnToolsVersion = 15.2; + }; 644BD7592A44726F00A0BF99 = { CreatedOnToolsVersion = 14.3; }; @@ -2112,6 +2169,7 @@ 64B4364A2AB9D3410003919E /* ScheduleSampleApp */, 6455C3B72B20A44D00257986 /* QdsTools */, 64148B632ABB5C320063110E /* Tools */, + 643F41F12BDFE1B000A2176D /* DXFeedCandleChart */, ); }; /* End PBXProject section */ @@ -2128,6 +2186,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 643F41F02BDFE1B000A2176D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 643F41FC2BDFE1B200A2176D /* Preview Assets.xcassets in Resources */, + 643F41F92BDFE1B200A2176D /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 644BD7582A44726F00A0BF99 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2313,6 +2380,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 643F41EE2BDFE1B000A2176D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 643F41F52BDFE1B000A2176D /* DXFeedCandleChartApp.swift in Sources */, + 643F42012BDFE25D00A2176D /* CandleStickChart.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 644BD7562A44726F00A0BF99 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2948,6 +3024,76 @@ }; name = Release; }; + 643F41FE2BDFE1B200A2176D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Samples/DXFeedCandleChart/Preview Content\""; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.dxfeed.DXFeedCandleChart; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 643F41FF2BDFE1B200A2176D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Samples/DXFeedCandleChart/Preview Content\""; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.dxfeed.DXFeedCandleChart; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 644BD76A2A44727000A0BF99 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3658,6 +3804,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 643F41FD2BDFE1B200A2176D /* Build configuration list for PBXNativeTarget "DXFeedCandleChart" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 643F41FE2BDFE1B200A2176D /* Debug */, + 643F41FF2BDFE1B200A2176D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 644BD76C2A44727000A0BF99 /* Build configuration list for PBXNativeTarget "DXARQuoteTableApp" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/DXFeedFrameworkTests/EndpointTest.swift b/DXFeedFrameworkTests/EndpointTest.swift index 8d715ab6a..97b225e9d 100644 --- a/DXFeedFrameworkTests/EndpointTest.swift +++ b/DXFeedFrameworkTests/EndpointTest.swift @@ -101,4 +101,15 @@ final class EndpointTest: XCTestCase { endpoint.add(listener: stateListener!) wait(for: [connectedExpectation], timeout: 1) } + + func testRoleConvert() throws { + let roles: [DXEndpoint.Role] = [.feed, .onDemandFeed, .streamFeed, .publisher, .streamPublisher, .localHub] + let nativeCodes = roles.map { role in + role.toNatie() + } + XCTAssertEqual(nativeCodes.map { nativeRole in + DXEndpoint.Role.fromNative(nativeRole) + }, roles) + } + } diff --git a/Samples/DXFeedCandleChart/Assets.xcassets/AccentColor.colorset/Contents.json b/Samples/DXFeedCandleChart/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Samples/DXFeedCandleChart/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Samples/DXFeedCandleChart/Assets.xcassets/AppIcon.appiconset/Contents.json b/Samples/DXFeedCandleChart/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..b5b77380c --- /dev/null +++ b/Samples/DXFeedCandleChart/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "dxfeed_black-sym.svg.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Samples/DXFeedCandleChart/Assets.xcassets/AppIcon.appiconset/dxfeed_black-sym.svg.png b/Samples/DXFeedCandleChart/Assets.xcassets/AppIcon.appiconset/dxfeed_black-sym.svg.png new file mode 100644 index 000000000..08b2b8448 Binary files /dev/null and b/Samples/DXFeedCandleChart/Assets.xcassets/AppIcon.appiconset/dxfeed_black-sym.svg.png differ diff --git a/Samples/DXFeedCandleChart/Assets.xcassets/Contents.json b/Samples/DXFeedCandleChart/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Samples/DXFeedCandleChart/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Samples/DXFeedCandleChart/CandleStickChart.swift b/Samples/DXFeedCandleChart/CandleStickChart.swift new file mode 100644 index 000000000..a3db0613e --- /dev/null +++ b/Samples/DXFeedCandleChart/CandleStickChart.swift @@ -0,0 +1,430 @@ +// +// +// Copyright (C) 2024 Devexperts LLC. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. +// + +import SwiftUI +import Charts +import DXFeedFramework + +enum Constants { + static let previewChartHeight: CGFloat = 100 +} + +extension Decimal { + var asDouble: Double { Double(truncating: self as NSNumber) } +} + +extension StockPrice { + + var isClosingHigher: Bool { + self.open < self.close + } + + var accessibilityTrendSummary: String { + "Price movement: \(isClosingHigher ? "up" : "down")" + } + + var accessibilityDescription: String { + return "Open: \(self.open.formatted(.currency(code: currency))), Close: \(self.close.formatted(.currency(code: currency))), High: \(self.high.formatted(.currency(code: currency))), Low: \(self.low.formatted(.currency(code: currency)))" + } +} + +extension Array { + mutating func safeReplace(_ newElement: Element, at:Int) { + if at >= 0 && at < self.count { + self[at] = newElement + } else { + print("error during replace") + } + } +} + +enum CandleType: CaseIterable, Identifiable { + case week, month, year + var id: Self { self } + func toDxFeedValue() -> CandlePeriod { + switch self { + case .week: + return CandlePeriod.valueOf(value: 1, type: .week) + case .month: + return CandlePeriod.valueOf(value: 1, type: .month) + case .year: + return CandlePeriod.valueOf(value: 1, type: .year) + } + } +} + +struct StockPrice: Identifiable { + let timestamp: Date + let open: Decimal + let high: Decimal + let low: Decimal + let close: Decimal + let currency: String + var id: Date { timestamp } +} + + +class CandleList: ObservableObject, SnapshotDelegate { + func receiveEvents(_ events: [DXFeedFramework.MarketEvent], isSnapshot: Bool) { + + var result = [Candle]() + events.forEach { event in + let candle = event.candle + result.append(candle) + } + if isSnapshot { + DispatchQueue.main.async { + self.candles = result.map({ candle in + let price = StockPrice(timestamp: Date(millisecondsSince1970: candle.time), open: Decimal(candle.open), high: Decimal(candle.high), low: Decimal(candle.low), close: Decimal(candle.close), currency: self.currency) + return price + }) + } + + } else { + DispatchQueue.main.async { + result.forEach { candle in + let newPrice = StockPrice(timestamp: Date(millisecondsSince1970: candle.time), open: Decimal(candle.open), high: Decimal(candle.high), low: Decimal(candle.low), close: Decimal(candle.close), currency: self.currency) + if let index = self.candles.firstIndex(where: { price in + price.timestamp == newPrice.timestamp + }) { + self.candles.safeReplace(newPrice, at: index) + } + } + } + } + } + + let symbol: String = "AAPL" + public private(set) var currency = "" + public private(set) var descriptionString = "" + + var endpoint: DXEndpoint! + var feed: DXFeed! + var subscription: DXFeedSubscription? + var snapshotProcessor: SnapshotProcessor! + + @Published var candles: [StockPrice] + + init() { + self.candles = [StockPrice]() + try? createSubscription() + fetchInfo() + } + + func createSubscription() throws { + endpoint = try DXEndpoint.create().connect("demo.dxfeed.com:7300") + feed = endpoint.getFeed() + subscription = try feed?.createSubscription([Candle.self]) + snapshotProcessor = SnapshotProcessor() + snapshotProcessor.add(self) + try subscription?.add(listener: snapshotProcessor) + } + + func fetchInfo() { + let reader = DXInstrumentProfileReader() + let result = try? reader.readFromFile(address: "https://demo:demo@tools.dxfeed.com/ipf?SYMBOL=\(symbol)") + guard let result = result else { + return + } + result.forEach { profile in + currency = profile.currency + descriptionString = profile.descriptionStr + } + } + + func updateDate(date: Date, type: CandleType) { + let candleSymbol = CandleSymbol.valueOf(symbol, type.toDxFeedValue()) + let symbol = TimeSeriesSubscriptionSymbol(symbol: candleSymbol, date: date) + try? subscription?.setSymbols([symbol]) + } +} + +struct CandleStickChart: View { + @ObservedObject var list: CandleList + @State private var selectedPrice: StockPrice? + @State private var date = Calendar.current.date(byAdding: .month, value: -12, to: Date())! + @State private var type: CandleType = .month + + init() { + self.list = CandleList() + self.list.updateDate(date: self.date, type: self.type) + } + + var body: some View { + GeometryReader { reader in + List { + Section { + DatePicker( + "Start Date", + selection: $date, + displayedComponents: [.date] + ).onChange(of: date) { oldValue, newValue in + selectedPrice = nil + list.updateDate(date: newValue,type: type) + } + Picker("Type", selection: $type) { + Text("Week").tag(CandleType.week) + Text("Month").tag(CandleType.month) + Text("Year").tag(CandleType.year) + }.onChange(of: type) { oldValue, newValue in + selectedPrice = nil + list.updateDate(date: date, type: newValue) + } + } + Section { + chart.frame(height: reader.size.height/2) + } + Section { + Text(""" +Candles \(String(describing: type)) +\(list.descriptionString) +from \(date) +""" ) + .font(.callout) + } + } + } + } + private var chart: some View { + Chart($list.candles) { binding in + let price = binding.wrappedValue + + CandleStickMark( + timestamp: .value("Date", price.timestamp), + open: .value("Open", price.open), + high: .value("High", price.high), + low: .value("Low", price.low), + close: .value("Close", price.close) + ) + .accessibilityLabel("\(price.timestamp.formatted(date: .complete, time: .omitted)): \(price.accessibilityTrendSummary)") + .accessibilityValue(price.accessibilityDescription) + .accessibilityHidden(false) + .foregroundStyle( price.close >= price.open ? .green : .red) + } + .chartYAxis { AxisMarks(preset: .extended) } + .chartOverlay { proxy in + GeometryReader { geo in + Rectangle().fill(.clear).contentShape(Rectangle()) + .gesture( + SpatialTapGesture() + .onEnded { value in + let element = findElement(location: value.location, proxy: proxy, geometry: geo) + if selectedPrice?.timestamp == element?.timestamp { + // If tapping the same element, clear the selection. + selectedPrice = nil + } else { + selectedPrice = element + } + } + .exclusively( + before: DragGesture() + .onChanged { value in + selectedPrice = findElement(location: value.location, proxy: proxy, geometry: geo) + } + ) + ) + } + } + .chartOverlay { proxy in + ZStack(alignment: .topLeading) { + GeometryReader { geo in + if let selectedPrice { + let dateInterval = Calendar.current.dateInterval(of: .day, for: selectedPrice.timestamp)! + let startPositionX1 = proxy.position(forX: dateInterval.start) ?? 0 + + let lineX = startPositionX1 + geo[proxy.plotAreaFrame].origin.x + let lineHeight = geo[proxy.plotAreaFrame].maxY + let boxWidth: CGFloat = geo.size.width + let boxOffset = max(0, min(geo.size.width - boxWidth, lineX - boxWidth / 2)) + + Rectangle() + .fill(.gray.opacity(0.5)) + .frame(width: 2, height: lineHeight) + .position(x: lineX, y: lineHeight / 2) + + PriceAnnotation(for: selectedPrice, currency: list.currency) + .frame(width: boxWidth, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 13) + .foregroundStyle(.thickMaterial) + .padding(.horizontal, -8) + .padding(.vertical, -4) + } + .offset(x: boxOffset) + .gesture( + TapGesture() + .onEnded { _ in + self.selectedPrice = nil + } + ) + } + } + } + } + .accessibilityChartDescriptor(self) + .chartYAxis(.automatic) + .chartXAxis(.automatic) + + } + + private func findElement(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> StockPrice? { + let relativeXPosition = location.x - geometry[proxy.plotAreaFrame].origin.x + if let date = proxy.value(atX: relativeXPosition) as Date? { + // Find the closest date element. + var minDistance: TimeInterval = .infinity + var index: Int? = nil + for dataIndex in list.candles.indices { + let nthSalesDataDistance = list.candles[dataIndex].timestamp.distance(to: date) + if abs(nthSalesDataDistance) < minDistance { + minDistance = abs(nthSalesDataDistance) + index = dataIndex + } + } + if let index { + return list.candles[index] + } + } + return nil + } +} + +struct CandleStickMark: ChartContent { + let timestamp: PlottableValue + let open: PlottableValue + let high: PlottableValue + let low: PlottableValue + let close: PlottableValue + + var body: some ChartContent { + Plot { + // Composite ChartContent MUST be grouped into a plot for accessibility to work + BarMark( + x: timestamp, + yStart: open, + yEnd: close, + width: 4 + ) + BarMark( + x: timestamp, + yStart: high, + yEnd: low, + width: 1 + ) + } + } +} + +// MARK: - Accessibility + +extension CandleStickChart: AXChartDescriptorRepresentable { + func makeChartDescriptor() -> AXChartDescriptor { + + let dateStringConverter: ((Date) -> (String)) = { date in + date.formatted(date: .abbreviated, time: .omitted) + } + + // These closures help find the min/max for each axis + let lowestValue: ((KeyPath) -> (Double)) = { path in + return list.candles.map { $0[keyPath: path]} .min()?.asDouble ?? 0 + } + let highestValue: ((KeyPath) -> (Double)) = { path in + return list.candles.map { $0[keyPath: path]} .max()?.asDouble ?? 0 + } + + let xAxis = AXCategoricalDataAxisDescriptor( + title: "Date", + categoryOrder: list.candles.map { dateStringConverter($0.timestamp) } + ) + + // Add axes for each data point captured in the candlestick + let closeAxis = AXNumericDataAxisDescriptor( + title: "Closing Price", + range: 0...highestValue(\.close), + gridlinePositions: [] + ) { value in "Closing: \(value.formatted(.currency(code: list.currency)))" } + + let openAxis = AXNumericDataAxisDescriptor( + title: "Opening Price", + range: lowestValue(\.open)...highestValue(\.open), + gridlinePositions: [] + ) { value in "Opening: \(value.formatted(.currency(code: list.currency)))" } + + let highAxis = AXNumericDataAxisDescriptor( + title: "Highest Price", + range: lowestValue(\.high)...highestValue(\.high), + gridlinePositions: [] + ) { value in "High: \(value.formatted(.currency(code: list.currency)))" } + + let lowAxis = AXNumericDataAxisDescriptor( + title: "Lowest Price", + range: lowestValue(\.low)...highestValue(\.low), + gridlinePositions: [] + ) { value in "Low: \(value.formatted(.currency(code: list.currency)))" } + + let series = AXDataSeriesDescriptor( + name: list.descriptionString, + isContinuous: false, + dataPoints: list.candles.map { + .init(x: dateStringConverter($0.timestamp), + y: $0.close.asDouble, + additionalValues: [.number($0.open.asDouble), + .number($0.high.asDouble), + .number($0.low.asDouble)]) + } + ) + + return AXChartDescriptor( + title: list.descriptionString, + summary: nil, + xAxis: xAxis, + yAxis: closeAxis, + additionalAxes: [openAxis, highAxis, lowAxis], + series: [series] + ) + } +} + +// MARK: - Preview + +struct PriceAnnotation: View { + let price: StockPrice + let currency: String + + init(for price: StockPrice, currency: String) { + self.price = price + self.currency = currency + } + + var body: some View { + VStack(alignment: .center, spacing: 4) { + Text(price.timestamp.formatted(date: .abbreviated, time: .omitted)) + + HStack(spacing: 0) { + Text("Open: \(price.open.formatted(.currency(code: currency)))" ).foregroundColor(.secondary) .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + Text("Close: \(price.close.formatted(.currency(code: currency)))").foregroundColor(.secondary) .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + } + + HStack(spacing: 0) { + Text("High: \(price.high.formatted(.currency(code: currency)))").foregroundColor(.secondary) .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + Text("Low: \(price.low.formatted(.currency(code: currency)))").foregroundColor(.secondary) .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + } + } + .lineLimit(1) + .font(.headline) + .padding(.vertical) + } +} + +struct CandleStickChart_Previews: PreviewProvider { + static var previews: some View { + CandleStickChart() + } +} diff --git a/Samples/DXFeedCandleChart/DXFeedCandleChartApp.swift b/Samples/DXFeedCandleChart/DXFeedCandleChartApp.swift new file mode 100644 index 000000000..73d68f69b --- /dev/null +++ b/Samples/DXFeedCandleChart/DXFeedCandleChartApp.swift @@ -0,0 +1,20 @@ +// +// +// Copyright (C) 2024 Devexperts LLC. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. +// + +import SwiftUI + +@main +struct DXFeedCandleChartApp: App { + var body: some Scene { + WindowGroup { + CandleStickChart() + } + } +} + + + diff --git a/Samples/DXFeedCandleChart/Preview Content/Preview Assets.xcassets/Contents.json b/Samples/DXFeedCandleChart/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Samples/DXFeedCandleChart/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +}