// // KissConsole.swift // KissMeConsole // // Created by ened-book-m1 on 2023/05/17. // import Foundation import KissMe class KissConsole { private var credential: Credential? = nil var account: KissAccount? = nil private var shop: KissShop? = nil private var productsLock = NSLock() /// 전체 종목 정보 private var products = [String: [DomesticShop.Product]]() /// 현재 기본으로 선택된 productNo private var currentShortCode: String? /// 현재 candle 파일로 저장 중인 productNo var currentCandleShortCode: String? /// CSV 파일을 저장할 때, field name 에 대해서 한글(true) 또는 영문(false)로 기록할지 설정 var localized: Bool = false 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 buyCheck = "buy check" case sell = "sell" case cancel = "cancel" case modify = "modify" // 보유 종목 case openBag = "open bag" // 종목 시세 case now = "now" case candle = "candle" case candleAll = "candle all" case candleDay = "candle day" case candleWeek = "candle week" case candleValidate = "candle validate" // 종목 열람 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 // 기타 case localizeNames = "localize names" case localizeOnOff = "localize" var needLogin: Bool { switch self { case .quit, .loginMock, .loginReal: return false case .logout, .top, .buy, .buyCheck, .sell, .cancel, .modify: return true case .openBag: return true case .now, .candle, .candleAll, .candleDay, .candleWeek: return true case .candleValidate: return false case .loadShop, .updateShop, .look: return false case .showcase: return false case .loves, .love, .hate, .localizeNames, .localizeOnOff: return false } } } private var isLogined: Bool { account != nil } init() { let jsonUrl = URL.currentDirectory().appending(path: "shop-server.json") shop = try? KissShop(jsonUrl: jsonUrl) KissConsole.createSubpath("log") KissConsole.createSubpath("data") lastLogin() loadLocalName() let semaphore = DispatchSemaphore(value: 0) Task { await onLoadShop() semaphore.signal() } semaphore.wait() // 005930: 삼성전자 setCurrent(productNo: "005930") } 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() { guard CommandLine.argc == 1 else { let line = CommandLine.arguments.suffix(Int(CommandLine.argc)-1).joined(separator: " ") let (cmd, args) = getCommand(line) if cmd?.needLogin == true { guard isLogined else { print("Need to login") exit(1) } } let semaphore = DispatchSemaphore(value: 0) Task { let success = await run(command: cmd, args: args, line: line) if !success { exit(2) } semaphore.signal() } semaphore.wait() return } 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 { _ = await run(command: cmd, args: args, line: line) semaphore.signal() } semaphore.wait() loop = (cmd != .quit) } } private func run(command: KissCommand?, args: [String], line: String) async -> Bool { switch command { 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 .buyCheck: await onBuyCheck(args) case .sell: await onSell(args) case .cancel: await onCancel(args) case .modify: await onModify(args) case .openBag: await onOpenBag() case .now: await onNow(args) case .candle: await onCandle(args) case .candleAll: onCancleAll(args) case .candleDay: onCandleDay(args) case .candleWeek: onCandleWeek(args) case .candleValidate: onCandleValidate(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) case .localizeNames: await onLocalizeNames() case .localizeOnOff: await onLocalizeOnOff(args) default: print("Unknown command: \(line)") return false } return true } } extension KissConsole { static func createSubpath(_ name: String) { let subPath = URL.currentDirectory().appending(path: name) try? FileManager.default.createDirectory(at: subPath, withIntermediateDirectories: true) } 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 func getAllProducts() -> [DomesticShop.Product] { productsLock.lock() defer { productsLock.unlock() } var all = [DomesticShop.Product]() for items in products.values { all.append(contentsOf: items) } return all } 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)") } } private func loadLocalName() { _ = LocalContext.shared.localNamesDic } private func setCurrent(productNo: String) { productsLock.lock() currentShortCode = productNo productsLock.unlock() let productName = getProduct(shortCode: productNo)?.itemName ?? "" print("current product \(productNo) \(productName)") } } 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(_ args: [String]) async { var belongCode = "0" if args.count == 1, let code = Int(args[0]) { belongCode = String(code) } guard let belongClass = BelongClassCode(rawValue: belongCode) else { print("Incorrect belong type: \(belongCode)") return } print("TOP: \(belongClass.description)") let option = RankingOption(divisionClass: .all, belongClass: belongClass) 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))") } let fileUrl = KissConsole.topProductsUrl(belongClass) try output.writeCsv(toFile: fileUrl, localized: localized) print("wrote \(fileUrl.lastPathComponent) with \(output.count)") } catch { print("\(error)") } } private func onBuy(_ args: [String]) async { guard args.count == 3 else { print("Missing buy paramters: buy (PNO) (PRICE) (QUANTITY)") return } let productNo = args[0] guard let product = getProduct(shortCode: productNo) else { print("No product \(productNo)") return } guard let price = Int(args[1]), (price == -8282 || price >= 100) else { print("Invalid price: \(args[1])") return } guard let quantity = Int(args[2]), (quantity == -82 || quantity > 0) else { print("Invalid quantity: \(args[2])") return } let division: OrderDivision = (price == -8282 ? .marketPrice: .limits) let contract = Contract(productNo: productNo, orderType: .buy, orderDivision: division, orderQuantity: quantity, orderPrice: price) do { let result = try await account!.orderStock(contract: contract) if let output = result.output { print("Success \(product.itemName) orderNo: \(output.orderNo) at \(output.orderTime)") } else { print("Failed \(result.resultCode) \(result.messageCode) \(result.message)") } } catch { print("\(error)") } } private func onBuyCheck(_ args: [String]) async { guard args.count == 2 else { print("Missing buy check paramters: buy check (PNO) (PRICE)") return } let productNo = args[0] guard let product = getProduct(shortCode: productNo) else { print("No product \(productNo)") return } guard let price = Int(args[1]), price >= 100 else { print("Invalid price: \(args[1])") return } do { let result = try await account!.canOrderStock(productNo: productNo, division: .limits, price: price) if let output = result.output { print("Success \(product.itemName) \(output)") } else { print("Failed \(result.resultCode) \(result.messageCode) \(result.message)") } } catch { print("\(error)") } } private func onSell(_ args: [String]) async { guard args.count == 3 else { print("Missing sell paramters: sell (PNO) (PRICE) (QUANTITY)") return } let productNo = args[0] guard let product = getProduct(shortCode: productNo) else { print("No product \(productNo)") return } guard let price = Int(args[1]), (price == -8282 || price >= 100) else { print("Invalid price: \(args[1])") return } guard let quantity = Int(args[2]), quantity > 0 else { print("Invalid quantity: \(args[2])") return } let division: OrderDivision = (price == -8282 ? .marketPrice: .limits) let contract = Contract(productNo: productNo, orderType: .sell, orderDivision: division, orderQuantity: quantity, orderPrice: price) do { let result = try await account!.orderStock(contract: contract) if let output = result.output { print("Success \(product.itemName) orderNo: \(output.orderNo) at \(output.orderTime)") } else { print("Failed \(result.resultCode) \(result.messageCode) \(result.message)") } } catch { print("\(error)") } } private func onCancel(_ args: [String]) async { guard args.count == 3 else { print("Missing cancel paramters: cancel (PNO) (QUANTITY)") return } let productNo = args[0] guard let product = getProduct(shortCode: productNo) else { print("No product \(productNo)") return } let orderNo = args[1] guard orderNo.count >= 1 else { print("Invalid orderNo: \(args[1])") return } guard let quantity = Int(args[2]), (quantity == -82 || quantity > 0) else { print("Invalid quantity: \(args[2])") return } do { let cancel = ContractCancel(productNo: productNo, orderNo: orderNo, orderQuantity: (quantity == -82 ? 0: quantity)) let result = try await account!.cancelOrder(cancel: cancel) if let output = result.output { print("Success \(product.itemName) orderNo: \(output.orderNo) at \(output.orderTime)") } else { print("Failed \(result.resultCode) \(result.messageCode) \(result.message)") } } catch { print("\(error)") } } private func onModify(_ args: [String]) async { // TODO: work } private func onOpenBag() async { do { let result = try await account!.getStockBalance() if let stocks = result.output1 { for item in stocks { print("\(item)") } let fileUrl = KissConsole.accountStocksUrl try stocks.writeCsv(toFile: fileUrl, localized: localized) print("wrote \(fileUrl.lastPathComponent) with \(stocks.count)") } if let amounts = result.output2 { for item in amounts { print("\(item)") } let fileUrl = KissConsole.accountAmountUrl try amounts.writeCsv(toFile: fileUrl, localized: localized) print("wrote \(fileUrl.lastPathComponent) with \(amounts.count)") } } 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) guard let output = result.output else { print("Invalid result's output") return } 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.stockOpenningPrice) 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) setCurrent(productNo: output.shortProductCode) let fileUrl = KissConsole.productPriceUrl(productNo: productNo) try [output].writeCsv(toFile: fileUrl, appendable: true, localized: localized) } catch { print("\(error)") } } private func onCancleAll(_ args: [String]) { let semaphore = DispatchSemaphore(value: 0) Task { await KissContext.shared.update(candleResuming: false) if args.count == 1, args[0] == "resume" { await KissContext.shared.update(candleResuming: true) } semaphore.signal() } semaphore.wait() let all = getAllProducts() for item in all { let semaphore = DispatchSemaphore(value: 0) Task { let holiday = try? await checkHoliday(Date()) if holiday == true { print("DONE today is holiday") return } if await KissContext.shared.isCandleResuming { let curDate = Date() let url = KissConsole.candleFileUrl(productNo: item.shortCode, period: .minute, day: curDate.yyyyMMdd) let r = validateCsv(filePriod: .minute, url: url) switch r { case .ok, .invalidFileName: return default: break } } let success = await getCandle(productNo: item.shortCode) print("DONE \(success) \(item.shortCode)") semaphore.signal() } semaphore.wait() } } private func onCandleDay(_ args: [String]) { if args.count == 1, args[0] == "all" { onCandleDayAll() return } let productNo: String? = (args.isEmpty ? currentShortCode: args[0]) guard let productNo = productNo else { print("Invalid productNo") return } let semaphore = DispatchSemaphore(value: 0) Task { let success = await getRecentCandle(productNo: productNo, period: .daily, count: 250) print("DONE \(success) \(productNo)") semaphore.signal() } semaphore.wait() } private func onCandleDayAll() { let all = getAllProducts() for item in all { let semaphore = DispatchSemaphore(value: 0) Task { let holiday = try? await checkHoliday(Date()) if holiday == true { print("DONE today is holiday") return } let success = await getRecentCandle(productNo: item.shortCode, period: .daily, count: 250) print("DONE \(success) \(item.shortCode)") semaphore.signal() #if DEBUG if !success { exit(99) } #endif } semaphore.wait() } } private func onCandleWeek(_ args: [String]) { if args.count == 1, args[0] == "all" { onCandleWeekAll() return } let productNo: String? = (args.isEmpty ? currentShortCode: args[0]) guard let productNo = productNo else { print("Invalid productNo") return } let semaphore = DispatchSemaphore(value: 0) Task { let success = await getRecentCandle(productNo: productNo, period: .weekly, count: 52) print("DONE \(success) \(productNo)") semaphore.signal() } semaphore.wait() } private func onCandleWeekAll() { let all = getAllProducts() for item in all { let semaphore = DispatchSemaphore(value: 0) Task { let holiday = try? await checkHoliday(Date()) if holiday == true { print("DONE today is holiday") return } let success = await getRecentCandle(productNo: item.shortCode, period: .weekly, count: 52) print("DONE \(success) \(item.shortCode)") semaphore.signal() #if DEBUG if !success { exit(99) } #endif } semaphore.wait() } } func validateAllCsvs(filePriod: CandleFilePeriod) throws { let urls = try FileManager.collectCsv(period: filePriod, candleDate: nil) var lastTime = Date.appTime for (index, url) in urls.enumerated() { let r = validateCsv(filePriod: filePriod, url: url) switch r { case .ok, .invalidFileName: break default: print("csv invalid: \(r) at \(url)") throw GeneralError.invalidCandleCsvFile(r.description) } let curTime = Date.appTime if (curTime - lastTime) > 5 { lastTime = curTime print("checking... \(index+1)/\(urls.count)") } } print("DONE csv valid \(urls.count)") } func validateCsv(filePriod: CandleFilePeriod, url: URL) -> CandleValidation { switch filePriod { case .minute: return KissConsole.validateCandleMinute(url) case .day: return KissConsole.validateCandleDay(url) case .weak: return KissConsole.validateCandleWeek(url) } } private func onCandleValidate(_ args: [String]) { let period: CandleFilePeriod? if args.count == 1 { period = CandleFilePeriod(rawValue: args[0]) } else { period = .minute } guard let period = period else { return } do { try validateAllCsvs(filePriod: period) } 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 } _ = await getCandle(productNo: productNo) } 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 } } do { let fileUrl = KissConsole.shopProductsUrl try shopItems.writeCsv(toFile: fileUrl, localized: localized) print("wrote \(fileUrl.lastPathComponent) with \(shopItems.count)") } catch { print(error) } } 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 { print("\tISIN: ", first.isinCode) print("\t상품명: ", item.key.maxSpace(20)) print("\t단축코드: ", first.shortCode) print("\t시장구분: ", first.marketCategory, "\t기준일자: ", first.baseDate) setCurrent(productNo: first.shortCode) } } } 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() setCurrent(productNo: 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() setCurrent(productNo: 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 func onLocalizeNames() async { var symbols = Set() symbols.formUnion(DomesticShop.ProductResponse.Item.symbols()) symbols.formUnion(BalanceResult.OutputStock.symbols()) symbols.formUnion(BalanceResult.OutputAmount.symbols()) symbols.formUnion(MinutePriceResult.OutputPrice.symbols()) symbols.formUnion(PeriodPriceResult.OutputPrice.symbols()) symbols.formUnion(VolumeRankResult.OutputDetail.symbols()) symbols.formUnion(CurrentPriceResult.OutputDetail.symbols()) let newNames = symbols.sorted(by: { $0 < $1 }) let nameUrl = KissConsole.localNamesUrl var addedNameCount = 0 var curNames = try! [LocalName].readCsv(fromFile: nameUrl, verifyHeader: true) for name in newNames { if false == curNames.contains(where: { $0.fieldName == name }) { let item = try! LocalName(array: [name, ""]) curNames.append(item) addedNameCount += 1 } } if addedNameCount > 0 { do { try curNames.writeCsv(toFile: nameUrl, localized: localized) } catch { print(error) } } print("Success \(nameUrl.lastPathComponent) total: \(curNames.count), new: \(addedNameCount)") } private func onLocalizeOnOff(_ args: [String]) async { guard args.count == 1 else { print("Missing on/off option") return } guard let option = OnOff(rawValue: args[0]) else { print("Invalid on/off option") return } switch option { case .on: localized = true case .off: localized = false } print("Localization \(option.rawValue)") } } private extension Array { func suffixStrings(from: Int) -> [String] where Element == String.SubSequence { guard from < count else { return [] } return suffix(from: from).map { String($0) } } }