diff --git a/KissMe/Common/KissExtensions.swift b/KissMe/Common/KissExtensions.swift index 9dbec87..f3463b4 100644 --- a/KissMe/Common/KissExtensions.swift +++ b/KissMe/Common/KissExtensions.swift @@ -27,6 +27,12 @@ extension Date { return dateFormatter.string(from: self) } + public var HHmmss: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HHmmss" + return dateFormatter.string(from: self) + } + public static var appTime: TimeInterval { ProcessInfo.processInfo.systemUptime } diff --git a/KissMe/Common/Request.swift b/KissMe/Common/Request.swift index 898c72e..10002ab 100644 --- a/KissMe/Common/Request.swift +++ b/KissMe/Common/Request.swift @@ -137,7 +137,7 @@ extension Request { } let stringData = String(data: data, encoding: .utf8) ?? "" - print(stringData.prefix(1024)) + //print(stringData.prefix(1024)) if responseDataLoggable { let logName = "log/\(Date().yyyyMMdd_HHmmssSSSS_forFile)_\(url.lastPathComponent).json" let logUrl = URL.currentDirectory().appending(path: logName) diff --git a/KissMe/Common/SeibroRequest.swift b/KissMe/Common/SeibroRequest.swift index 6b8f004..ed4a017 100644 --- a/KissMe/Common/SeibroRequest.swift +++ b/KissMe/Common/SeibroRequest.swift @@ -76,7 +76,7 @@ extension SeibroRequest { } let stringData = String(data: data, encoding: .utf8) ?? "" - print(stringData) + //print(stringData) if responseDataLoggable { let logName = "log/\(Date().yyyyMMdd_HHmmssSSSS_forFile)_\(url.lastPathComponent).json" let logUrl = URL.currentDirectory().appending(path: logName) diff --git a/KissMe/Domestic/DomesticStock.swift b/KissMe/Domestic/DomesticStock.swift index d42efb6..6e00b5b 100644 --- a/KissMe/Domestic/DomesticStock.swift +++ b/KissMe/Domestic/DomesticStock.swift @@ -285,6 +285,8 @@ public struct Contract { // MARK: Stock Order extension KissAccount { + /// 주식 주문하기 + /// public func orderStock(contract: Contract) async throws -> OrderResult { return try await withUnsafeThrowingContinuation { continuation in @@ -332,6 +334,8 @@ extension KissAccount { } + /// 주식 잔고 조회하기 + /// public func getStockBalance() async throws -> BalanceResult { return try await withUnsafeThrowingContinuation { continuation in @@ -353,6 +357,8 @@ extension KissAccount { } + /// 주식을 주문할 수 있는지 판단하기 + /// public func canOrderStock(productNo: String, division: OrderDivision, price: Int) async throws -> PossibleOrderResult { return try await withUnsafeThrowingContinuation { continuation in diff --git a/KissMe/Domestic/DomesticStockPrice.swift b/KissMe/Domestic/DomesticStockPrice.swift index a1972db..b8dc7b0 100644 --- a/KissMe/Domestic/DomesticStockPrice.swift +++ b/KissMe/Domestic/DomesticStockPrice.swift @@ -77,7 +77,7 @@ extension Domestic { "FID_ETC_CLS_CODE": "", "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": productNo, - "FID_INPUT_HOUR_1": startTime, + "FID_INPUT_HOUR_1": startTodayTime, "FID_PW_DATA_INCU_YN": "N", ] } @@ -91,13 +91,13 @@ extension Domestic { public let accessToken: String let productNo: String - let startTime: String // HHMMSS + let startTodayTime: String // HHMMSS - public init(credential: Credential, accessToken: String, productNo: String, startTime: String) { + public init(credential: Credential, accessToken: String, productNo: String, startTodayTime: String) { self.credential = credential self.accessToken = accessToken self.productNo = productNo - self.startTime = startTime + self.startTodayTime = startTodayTime } } } @@ -106,23 +106,48 @@ extension Domestic { // MARK: Stock Price extension KissAccount { - - public func getCurrentPrice(completion: @escaping (Result) -> Void) { - guard let accessToken = accessToken else { - completion(.failure(GeneralError.invalidAccessToken)) - return + /// 현재 종목 시세를 가져오기 + /// + public func getCurrentPrice(productNo: String) async throws -> CurrentPriceResult { + return try await withUnsafeThrowingContinuation { continuation in + + guard let accessToken = accessToken else { + continuation.resume(throwing: GeneralError.invalidAccessToken) + return + } + + let request = Domestic.StockCurrentPriceRequest(credential: credential, accessToken: accessToken, productNo: productNo) + request.query { result in + switch result { + case .success(let result): + continuation.resume(returning: result) + case .failure(let error): + continuation.resume(throwing: error) + } + } } - - // TODO: 현재 시세 정보를 가져오기 } - public func getMinutePrice(completion: @escaping (Result) -> Void) { - guard let accessToken = accessToken else { - completion(.failure(GeneralError.invalidAccessToken)) - return + /// 현재 종목 분봉을 가져오기 + /// + public func getMinutePrice(productNo: String, startTodayTime: Date) async throws -> MinutePriceResult { + return try await withUnsafeThrowingContinuation { continuation in + + guard let accessToken = accessToken else { + continuation.resume(throwing: GeneralError.invalidAccessToken) + return + } + + let request = Domestic.StockTodayMinutePriceRequest(credential: credential, accessToken: accessToken, productNo: productNo, startTodayTime: startTodayTime.HHmmss) + request.query { result in + switch result { + case .success(let result): + continuation.resume(returning: result) + case .failure(let error): + continuation.resume(throwing: error) + } + } } - - // TODO: 분봉 정보를 가져오기 } } diff --git a/KissMe/Domestic/DomesticStockPriceResult.swift b/KissMe/Domestic/DomesticStockPriceResult.swift index 323acb0..fdcaf0f 100644 --- a/KissMe/Domestic/DomesticStockPriceResult.swift +++ b/KissMe/Domestic/DomesticStockPriceResult.swift @@ -43,7 +43,7 @@ public struct CurrentPriceResult: Codable { public let resultCode: String public let messageCode: String public let message: String - public let output: [OutputDetail]? + public let output: OutputDetail? private enum CodingKeys: String, CodingKey { case resultCode = "rt_cd" @@ -85,7 +85,7 @@ public struct CurrentPriceResult: Codable { public let koreanMarketName: String /// 신 고가 저가 구분 코드 - public let newHighLowPriceClassCode: String + public let newHighLowPriceClassCode: String? /// 업종 한글 종목명 public let koreanBusinessTypeName: String @@ -292,7 +292,7 @@ public struct CurrentPriceResult: Codable { public let capitalCurrency: String /// 접근도 - public let approachRate: String + public let approachRate: String? /// 외국인 보유 수량 public let foreignHoldQuantity: String diff --git a/KissMe/Domestic/Shop/DomesticShopProduct.swift b/KissMe/Domestic/Shop/DomesticShopProduct.swift index 7d3f613..2a1e960 100644 --- a/KissMe/Domestic/Shop/DomesticShopProduct.swift +++ b/KissMe/Domestic/Shop/DomesticShopProduct.swift @@ -96,13 +96,25 @@ extension DomesticShop { } public struct Item: Codable { + /// 기준일자 public let baseDate: String + + /// 단축코드 public let shortCode: String + + /// ISIN public let isinCode: String + + /// 시장 구분 public let marketCategory: String + + /// 종목명 (상품명) public let itemName: String + + /// 법인등록번호 public let corporationNo: String + private enum CodingKeys: String, CodingKey { case baseDate = "basDt" case shortCode = "srtnCd" @@ -114,7 +126,11 @@ extension DomesticShop { public init(_ array: [String]) { self.baseDate = array[0] - self.shortCode = array[1] + + /// shortCode 단축코드 명에 A000000 형태로 A문자가 붙어 있다. + /// 7자리 코드일 경우, 맨 앞자리 코드를 강제로 지운다. + /// + self.shortCode = (array[1].count == 7 ? String(array[1].suffix(6)): array[1]) self.isinCode = array[2] self.marketCategory = array[3] self.itemName = array[4] diff --git a/KissMeConsole/KissMeConsole/KissConsole.swift b/KissMeConsole/KissMeConsole/KissConsole.swift index fc757da..0b911c2 100644 --- a/KissMeConsole/KissMeConsole/KissConsole.swift +++ b/KissMeConsole/KissMeConsole/KissConsole.swift @@ -16,6 +16,7 @@ class KissConsole { var productsLock = NSLock() var products = [String: [DomesticShop.Product]]() + var currentShortCode: String? enum KissCommand: String { case quit = "quit" @@ -34,6 +35,10 @@ class KissConsole { // 보유 종목 case openBag = "open bag" + // 종목 시세 + case now = "now" + case candle = "candle" + // 종목 열람 case loadShop = "load shop" case updateShop = "update shop" @@ -55,6 +60,8 @@ class KissConsole { return true case .openBag: return true + case .now, .candle: + return true case .loadShop, .updateShop, .look: return false case .showcase: @@ -123,8 +130,12 @@ class KissConsole { case .buy: await onBuy(args) case .sell: await onSell(args) case .cancel: await onCancel(args) + case .openBag: await onOpenBag() + case .now: await onNow(args) + case .candle: await onCandle(args) + case .loadShop: await onLoadShop() case .updateShop: await onUpdateShop() case .look: await onLook(args) @@ -172,13 +183,22 @@ extension KissConsole { //return products.filter { $0.key.decomposedStringWithCanonicalMapping.contains(similarName) } } - private func getProductName(isin: String) -> String? { + private func getProduct(isin: String) -> DomesticShop.Product? { productsLock.lock() defer { productsLock.unlock() } - return products.compactMap { $0.value.first(where: { $0.isinCode == isin })?.itemName }.first + return products.compactMap { $0.value.first(where: { $0.isinCode == isin }) }.first + } + + private func getProduct(shortCode: String) -> DomesticShop.Product? { + productsLock.lock() + defer { + productsLock.unlock() + } + + return products.compactMap { $0.value.first(where: { $0.shortCode == shortCode }) }.first } private func loadShop(_ profile: Bool = false) { @@ -366,6 +386,49 @@ extension KissConsole { } + private func onNow(_ args: [String]) async { + let productNo: String? = (args.isEmpty ? currentShortCode: args[0]) + guard let productNo = productNo else { + print("Invalid productNo") + return + } + + do { + let result = try await account!.getCurrentPrice(productNo: productNo) + if let output = result.output { + let productName = getProduct(shortCode: output.shortProductCode)?.itemName ?? "" + print("\t종목명: ", productName) + print("\t업종명: ", output.koreanMarketName, output.koreanBusinessTypeName) + print("\t주식 현재가: ", output.currentStockPrice) + print("\t전일 대비: ", output.previousDayVariableRatio) + print("\t누적 거래 대금: ", output.accumulatedTradingAmount) + print("\t누적 거래량: ", output.accumulatedVolume) + print("\t전일 대비 거래량 비율: ", output.previousDayDiffVolumeRatio) + print("\t주식 시가: ", output.stockPrice) + print("\t주식 최고가: ", output.highestStockPrice) + print("\t주식 최저가: ", output.lowestStockPrice) + print("\t외국인 순매수 수량: ", output.foreignNetBuyingQuantity) + print("\t외국인 보유 수량: ", output.foreignHoldQuantity) + print("\t최종 공매도 체결 수량: ", output.lastShortSellingConclusionQuantity) + print("\t프로그램매매 순매수 수량: ", output.programTradeNetBuyingQuantity) + print("\t자본금: ", output.capital) + print("\t상장 주수: ", output.listedStockCount) + print("\tHTS 시가총액: ", output.htsTotalMarketValue) + print("\tPER: ", output.per) + print("\tPBR: ", output.pbr) + print("\t주식 단축 종목코드", output.shortProductCode) + } + } catch { + print("\(error)") + } + } + + + private func onCandle(_ args: [String]) async { + + } + + private func onLoadShop() async { return await withUnsafeContinuation { continuation in self.loadShop() @@ -449,7 +512,11 @@ extension KissConsole { } for item in items { if let first = item.value.first { - print("\(first.isinCode) \(item.key.maxSpace(20)) \(first.marketCategory) \(first.baseDate)") + currentShortCode = first.shortCode + print("\tISIN: ", first.isinCode) + print("\t상품명: ", item.key.maxSpace(20)) + print("\t단축코드: ", first.shortCode) + print("\t시장구분: ", first.marketCategory, "\t기준일자: ", first.baseDate) } } } @@ -497,13 +564,14 @@ extension KissConsole { let index = keys[1] if let loves = account.getLoves(for: key), let index = Int(index) { if index < loves.count { - guard let name = getProductName(isin: isin) else { + guard let product = getProduct(isin: isin) else { print("No product about isin: \(isin)") return } - if account.setLove(KissProfile.Love(isin: isin, name: name), index: index, for: key) { - print("Success \(name)") + if account.setLove(KissProfile.Love(isin: isin, name: product.itemName), index: index, for: key) { + print("Success \(product.itemName)") account.saveProfile() + currentShortCode = product.shortCode } else { print("Invalid index: \(index) for \(key)") @@ -514,13 +582,14 @@ extension KissConsole { else { /// key 탭의 맨뒤에 Love 를 추가 /// - guard let name = getProductName(isin: isin) else { + guard let product = getProduct(isin: isin) else { print("No product about isin: \(isin)") return } - account.addLove(KissProfile.Love(isin: isin, name: name), for: keys[0]) - print("Success \(name)") + account.addLove(KissProfile.Love(isin: isin, name: product.itemName), for: keys[0]) + print("Success \(product.itemName)") account.saveProfile() + currentShortCode = product.shortCode } } } diff --git a/README.md b/README.md index e510525..5c36d1d 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,19 @@ command | 설명 `login real` | Real 서버로 로그인. real-server.json 을 credential 로 사용. `logout` | 접속한 서버에서 로그아웃 `top` | 상위 거래량 30종목 (평균거래량) -WIP `buy (ISCD) (수량)` | 구매 -WIP `sell (ISCD) (수량)` | 판매 -WIP `cancel (ISCD)` | 주문 취소 +WIP `buy (ISIN) (수량)` | 구매 +WIP `sell (ISIN) (수량)` | 판매 +WIP `cancel (ISIN)` | 주문 취소 `open bag` | 보유 종목 열람 +`now [ISIN]` | 종목의 현재가 열람. ISIN 은 생략 가능 +`candle [ISIN]` | 종목의 분봉 열람. ISIN 은 생략 가능 `load shop` | data/shop-products.csv 로부터 전체 상품을 로딩 `update shop` | **금융위원회_KRX상장종목정보** 로부터 전체 상품을 얻어서 data/shop-products.csv 로 저장 -`look (상품명)` | (상품명) 에 해당되는 ISCD 를 표시함 +`look (상품명)` | (상품명) 에 해당되는 ISIN 를 표시함 WIP `showcase` | 추천 상품을 제안함 `loves` | 관심 종목 전체를 열람. profile.json 에 저장된 관심 종목을 표시함. -`love (탭).(번호) (ISCD)` | 관심 종목에 추가함. (번호) 를 지정하지 않으면 (탭) 마지막에 추가함. -`hate (탭) (ISCD)` | 관심 종목에서 삭제함. +`love (탭).(번호) (ISIN)` | 관심 종목에 추가함. (번호) 를 지정하지 않으면 (탭) 마지막에 추가함. +`hate (탭) (ISIN)` | 관심 종목에서 삭제함. # KissCredential