// // KissConsole.swift // KissMeConsole // // Created by ened-book-m1 on 2023/05/17. // import Foundation import KissMe class KissConsole { private var credential: Credential? = nil private var account: KissAccount? = nil private var shop: KissShop? = nil private var productsLock = NSLock() private var products = [String: [DomesticShop.Product]]() private var currentShortCode: String? private enum KissCommand: String { case quit = "quit" // 로그인 case loginMock = "login mock" case loginReal = "login real" case logout = "logout" case top = "top" // 매매 case buy = "buy" case sell = "sell" case cancel = "cancel" // 보유 종목 case openBag = "open bag" // 종목 시세 case now = "now" case candle = "candle" // 종목 열람 case loadShop = "load shop" case updateShop = "update shop" case look = "look" // 진열 종목 (시스템에서 제공하는 추천 리스트) case showcase = "showcase" // 관심 종목 case loves = "loves" // 열람 case love = "love" // love nuts.1 ISCD case hate = "hate" // hate nuts.1 ISCD var needLogin: Bool { switch self { case .quit, .loginMock, .loginReal: return false case .logout, .top, .buy, .sell, .cancel: return true case .openBag: return true case .now, .candle: return true case .loadShop, .updateShop, .look: return false case .showcase: return false case .loves, .love, .hate: return false } } } private var isLogined: Bool { account != nil } init() { let jsonUrl = URL.currentDirectory().appending(path: "shop-server.json") shop = try? KissShop(jsonUrl: jsonUrl) createSubpath("log") createSubpath("data") lastLogin() Task { await onLoadShop() } } private func getCommand(_ line: String) -> (KissCommand?, [String]) { let args = line.split(separator: " ") let double = args.prefix(upTo: min(2, args.count)).joined(separator: " ") if let cmd = KissCommand(rawValue: double) { return (cmd, args.suffixStrings(from: 2)) } let single = args.prefix(upTo: min(1, args.count)).joined(separator: " ") if let cmd = KissCommand(rawValue: single) { return (cmd, args.suffixStrings(from: 1)) } return (nil, []) } func run() { print("Enter command:") let semaphore = DispatchSemaphore(value: 0) var loop = true while loop { guard let line = readLine(strippingNewline: true) else { continue } let (cmd, args) = getCommand(line) if cmd?.needLogin == true { guard isLogined else { print("Need to login") continue } } Task { switch cmd { case .quit: break case .loginMock: await onLogin(isMock: true) case .loginReal: await onLogin(isMock: false) case .logout: await onLogout() case .top: await onTop(args) 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) case .showcase: await onShowcase() case .loves: await onLoves() case .love: await onLove(args) case .hate: await onHate(args) default: print("Unknown command: \(line)") } semaphore.signal() } semaphore.wait() loop = (cmd != .quit) } } } extension KissConsole { private func createSubpath(_ name: String) { let subPath = URL.currentDirectory().appending(path: name) try? FileManager.default.createDirectory(at: subPath, withIntermediateDirectories: true) } private func setProducts(_ products: [String: [DomesticShop.Product]]) { productsLock.lock() self.products = products productsLock.unlock() } private func getProducts(similarName: String) -> [String: [DomesticShop.Product]]? { productsLock.lock() defer { productsLock.unlock() } return products.filter { $0.key.contains(similarName) } //return products.filter { $0.key.decomposedStringWithCanonicalMapping.contains(similarName) } } private func getProduct(isin: String) -> DomesticShop.Product? { productsLock.lock() defer { productsLock.unlock() } 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 var shopProductsUrl: URL { URL.currentDirectory().appending(path: "data/shop-products.csv") } private func loadShop(_ profile: Bool = false) { let appTime1 = Date.appTime guard let stringCsv = try? String(contentsOfFile: shopProductsUrl.path) else { return } let appTime2 = Date.appTime if profile { print("\tloading file \(appTime2 - appTime1) elapsed") } var products = [String: [DomesticShop.Product]]() let rows = stringCsv.split(separator: "\n") let appTime3 = Date.appTime if profile { print("\trow split \(appTime3 - appTime2) elapsed") } for row in rows { let array = row.split(separator: ",").map { String($0) } let product = DomesticShop.Product(array) if var value = products[product.itemName] { value.append(product) products.updateValue(value, forKey: product.itemName) } else { products[product.itemName] = [product] } } let appTime4 = Date.appTime if profile { print("\tparse product \(appTime4 - appTime3) elapsed") } setProducts(products) let totalCount = products.reduce(0, { $0 + $1.value.count }) print("load products \(totalCount) with \(products.count) key") } private func lastLogin() { let profile = KissProfile() guard let isMock = profile.isMock else { return } do { let lastCredential = try KissCredential(isMock: isMock) let lastAccount = KissAccount(credential: lastCredential) guard let expiredAt = lastAccount.accessTokenExpiredDate else { return } if expiredAt > Date() { credential = lastCredential account = lastAccount let expiredAt = account?.accessTokenExpiredDate?.yyyyMMdd_HHmmss_forTime ?? "" print("resume \(isMock ? "mock login": "real login") expired at \(expiredAt)") } } catch { print("\(error)") } } } extension KissConsole { private func onLogin(isMock: Bool) async { guard !isLogined else { print("Already loginged") return } do { credential = try KissCredential(isMock: isMock) account = KissAccount(credential: credential!) if try await account!.login() { print("Success") } } catch { print("\(error)") } } private func onLogout() async { do { _ = try await account?.logout() credential = nil account = nil print("Success") } catch { print("\(error)") } } private func onTop(_ arg: [String]) async { let option = RankingOption(divisionClass: .all, belongClass: .averageVolume) do { let rank = try await account!.getVolumeRanking(option: option) guard let output = rank.output else { print("Error \(rank.messageCode) \(rank.message)") return } print("랭킹 단축상품코드 상품명 가격 평균거래량 누적거래대금") for item in output { print("\(item.dataRank) \(item.shortProductNo) \(item.htsProductName.maxSpace(20)) \(item.currentStockPrice.maxSpace(10, digitBy: 3)) \(item.averageVolume.maxSpace(15, digitBy: 3)) \(item.accumulatedTradingAmount.maxSpace(25, digitBy: 3))") } } catch { print("\(error)") } } private func onBuy(_ args: [String]) async { guard args.count == 3 else { return } guard let price = Int(args[1]) else { return } guard let quantity = Int(args[2]) else { return } let productNo = args[0] if price < 100 || quantity <= 0 { print("Invalid price or quantity") return } let contract = Contract(productNo: productNo, orderType: .buy, orderDivision: .limits, orderQuantity: quantity, orderPrice: price) do { let result = try await account?.orderStock(contract: contract) // TODO: 매수처리 이후 print(result) } catch { print("\(error)") } } private func onSell(_ args: [String]) async { // TODO: work } private func onCancel(_ args: [String]) async { // TODO: work do { let _ = try await account?.cancelOrder() } catch { print("\(error)") } } private func onOpenBag() async { do { let result = try await account!.getStockBalance() if let output = result.output1 { for item in output { print("\(item)") } } if let output = result.output2 { for item in output { print("\(item)") } } } catch { print("\(error)") } } 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 { currentShortCode = output.shortProductCode 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 { let productNo: String? = (args.isEmpty ? currentShortCode: args[0]) guard let productNo = productNo else { print("Invalid productNo") return } do { var nextTime = Date() nextTime.change(hour: 17, min: 0, sec: 0) var candles = [MinutePriceResult.OutputPrice]() var count = 0 while count < 3 { let more = (count > 0) count += 1 print("minute price \(productNo) from \(nextTime.yyyyMMdd_HHmmss_forTime) \(more)") let result = try await account!.getMinutePrice(productNo: productNo, startTodayTime: nextTime, more: more) if let prices = result.output2 { candles.append(contentsOf: prices) if let last = prices.last { if let (hh, mm, ss) = last.stockConclusionTime.HHmmss { print("next: \(last.stockConclusionTime) / \(hh) \(mm) \(ss)") nextTime.change(hour: hh, min: mm-1, sec: ss) } } } } candles.sort(by: { $0.stockBusinessDate < $1.stockBusinessDate }) guard let minTime = candles.first?.stockBusinessDate else { print("No price items") return } let subPath = "data/\(productNo)" let subFile = "\(subPath)/candle-\(minTime).csv" let fileUrl = URL.currentDirectory().appending(path: subFile) createSubpath(subPath) writeCandle(candles, fileUrl: fileUrl) } catch { print("\(error)") } } private func onLoadShop() async { return await withUnsafeContinuation { continuation in self.loadShop() continuation.resume() } } private func onUpdateShop() async { guard let _ = shop else { print("Invalid shop instance") return } var baseDate = Date() var shopItems = [DomesticShop.Product]() for _ in 0 ..< 7 { print("try to get shop at date \(baseDate.yyyyMMdd)") shopItems = await getAllProduct(baseDate: baseDate) if shopItems.isEmpty { let oneDaySeconds: TimeInterval = 60 * 60 * 24 baseDate = baseDate.addingTimeInterval(-oneDaySeconds) } else { break } } writeShop(shopItems, fileUrl: shopProductsUrl) } private func getAllProduct(baseDate: Date) async -> [DomesticShop.Product] { var pageNo = 0 var shopItems = [DomesticShop.Product]() do { while true { pageNo += 1 print("get pageNo: \(pageNo)") let (totalCount, items) = try await shop!.getProduct(baseDate: baseDate, pageNo: pageNo) shopItems.append(contentsOf: items) print("got pageNo: \(pageNo) - \(shopItems.count)/\(totalCount)") if totalCount == 0 || shopItems.count >= totalCount { break } } } catch { print("\(error)") } return shopItems } private func onLook(_ args: [String]) async { guard args.count >= 1 else { print("No target name") return } let productName = String(args[0]) //print(args, productName, "\(productName.count)") guard let items = getProducts(similarName: productName), items.isEmpty == false else { print("No products like \(productName)") return } for item in items { if let first = item.value.first { currentShortCode = first.shortCode print("\tISIN: ", first.isinCode) print("\t상품명: ", item.key.maxSpace(20)) print("\t단축코드: ", first.shortCode) print("\t시장구분: ", first.marketCategory, "\t기준일자: ", first.baseDate) } } } private func onShowcase() async { // TODO: write } private func onLoves() async { guard let account = account else { return } let loves = account.getLoves() for tab in loves.sorted(by: { $0.key < $1.key }) { printLoveTab(tab.key, loves: tab.value) } } private func printLoveTab(_ key: String, loves: [KissProfile.Love]) { print("Page: \(key)") for item in loves { print(" \(item.isin) \(item.name)") } } private func onLove(_ args: [String]) async { guard let account = account else { return } guard args.count > 0 else { print("Invalid love\nlove ") return } if args.count == 1 { let key = args[0] guard let loves = account.getLoves(for: key) else { return } printLoveTab(key, loves: loves) } else if args.count == 2 { let keys = args[0].split(separator: ".").map { String($0) } let isin = args[1] if keys.count == 2 { /// key 탭의 index 위치에 Love 를 추가 /// let key = keys[0] let index = keys[1] if let loves = account.getLoves(for: key), let index = Int(index) { if index < loves.count { guard let product = getProduct(isin: isin) else { print("No product about isin: \(isin)") return } 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)") } } } } else { /// key 탭의 맨뒤에 Love 를 추가 /// guard let product = getProduct(isin: isin) else { print("No product about isin: \(isin)") return } account.addLove(KissProfile.Love(isin: isin, name: product.itemName), for: keys[0]) print("Success \(product.itemName)") account.saveProfile() currentShortCode = product.shortCode } } } private func onHate(_ args: [String]) async { guard let account = account else { return } guard args.count > 1 else { print("Invalid hate") return } let key = args[0] let isin = args[1] if let love = account.removeLove(isin: isin, for: key) { print("Success \(love.name)") } } } private extension Array { func suffixStrings(from: Int) -> [String] where Element == String.SubSequence { guard from < count else { return [] } return suffix(from: from).map { String($0) } } }