diff --git a/KissMe/Sources/Common/PropertyIterable.swift b/KissMe/Sources/Common/PropertyIterable.swift index b1360f4..c812e1e 100644 --- a/KissMe/Sources/Common/PropertyIterable.swift +++ b/KissMe/Sources/Common/PropertyIterable.swift @@ -31,3 +31,8 @@ public extension PropertyIterable { return result } } + + +public protocol ArrayDecodable { + init(array: [String]) throws +} diff --git a/KissMe/Sources/Common/Request.swift b/KissMe/Sources/Common/Request.swift index 2d28338..10f161e 100644 --- a/KissMe/Sources/Common/Request.swift +++ b/KissMe/Sources/Common/Request.swift @@ -33,8 +33,11 @@ public enum GeneralError: Error { case invalidAccountNo case unsupportedQueryAtMockServer case emptyData + case incorrectArrayItems + case headerNoFiendName(String) } + public enum QueryError: Error { case invalidUrl case invalidJson diff --git a/KissMe/Sources/Domestic/DomesticStockPriceResult.swift b/KissMe/Sources/Domestic/DomesticStockPriceResult.swift index 2562ce1..593a61e 100644 --- a/KissMe/Sources/Domestic/DomesticStockPriceResult.swift +++ b/KissMe/Sources/Domestic/DomesticStockPriceResult.swift @@ -63,8 +63,10 @@ public enum PeriodDivision: String, Codable, CustomStringConvertible { /// 락 구분 코드 public enum ExDivision: String, Codable { + case none = "" + /// 해당사항없음 (락이 발생안한 경우) - case none = "00" + case notApplicable = "00" /// 권리락 case exRights = "01" /// 배당락 @@ -160,7 +162,7 @@ public struct CurrentPriceResult: Codable { /// ELW 발행 여부 public let elwPublished: YesNo - /// 주식 현재가 + /// 주식 현재가 (주식 종가) public let currentStockPrice: String /// 전일 대비 @@ -182,7 +184,7 @@ public struct CurrentPriceResult: Codable { public let previousDayDiffVolumeRatio: String /// 주식 시가 - public let stockPrice: String + public let stockOpenningPrice: String /// 주식 최고가 public let highestStockPrice: String @@ -389,7 +391,7 @@ public struct CurrentPriceResult: Codable { case accumulatedVolume = "acml_vol" case previousDayDiffVolumeRatio = "prdy_vrss_vol_rate" - case stockPrice = "stck_oprc" + case stockOpenningPrice = "stck_oprc" case highestStockPrice = "stck_hgpr" case lowestStockPrice = "stck_lwpr" case maximumStockPrice = "stck_mxpr" @@ -526,11 +528,11 @@ public struct MinutePriceResult: Codable { /// 누적 거래 대금 public let accumulatedTradingAmount: String - /// 주식 현재가 + /// 주식 현재가 (주식종가) public let currentStockPrice: String - /// 주식 시가2 - public let secondStockPrice: String + /// 주식 시가 + public let stockOpenningPrice: String /// 주식 최고가 public let highestStockPrice: String @@ -546,7 +548,7 @@ public struct MinutePriceResult: Codable { case stockConclusionTime = "stck_cntg_hour" case accumulatedTradingAmount = "acml_tr_pbmn" case currentStockPrice = "stck_prpr" - case secondStockPrice = "stck_oprc" + case stockOpenningPrice = "stck_oprc" case highestStockPrice = "stck_hgpr" case lowestStockPrice = "stck_lwpr" case conclusionVolume = "cntg_vol" @@ -556,16 +558,28 @@ public struct MinutePriceResult: Codable { return stockBusinessDate + stockConclusionTime } - public init(array: [String]) { + public init(array: [String]) throws { + guard array.count == 8 else { + throw GeneralError.incorrectArrayItems + } self.stockBusinessDate = array[0] self.stockConclusionTime = array[1] self.accumulatedTradingAmount = array[2] self.currentStockPrice = array[3] - self.secondStockPrice = array[4] + self.stockOpenningPrice = array[4] self.highestStockPrice = array[5] self.lowestStockPrice = array[6] self.conclusionVolume = array[7] } + + public static func symbols() -> [String] { + let i = try! OutputPrice(array: Array(repeating: "", count: 8)) + return Mirror(reflecting: i).children.compactMap { $0.label } + } + + public static func localizedSymbols() -> [String: String] { + [:] + } } } @@ -623,7 +637,7 @@ public struct PeriodPriceResult: Codable { public let minimumStockPrice: String /// 주식 시가 - public let stockPrice: String + public let stockOpenningPrice: String /// 주식 최고가 public let highestStockPrice: String @@ -690,7 +704,7 @@ public struct PeriodPriceResult: Codable { case previousDayVolume = "prdy_vol" case maximumStockPrice = "stck_mxpr" case minimumStockPrice = "stck_llam" - case stockPrice = "stck_oprc" + case stockOpenningPrice = "stck_oprc" case highestStockPrice = "stck_hgpr" case lowestStockPrice = "stck_lwpr" case yesterdayStockPrice = "stck_prdy_oprc" @@ -845,7 +859,10 @@ public struct PeriodPriceResult: Codable { case revaluationIssueReason = "revl_issu_reas" } - public init(array: [String]) { + public init(array: [String]) throws { + guard array.count == 13 else { + throw GeneralError.incorrectArrayItems + } self.stockBusinessDate = array[0] self.stockClosingPrice = array[1] self.stockOpenningPrice = array[2] @@ -861,6 +878,15 @@ public struct PeriodPriceResult: Codable { self.revaluationIssueReason = array[12] } + public static func symbols() -> [String] { + let i = try! OutputPrice(array: Array(repeating: "", count: 13)) + return Mirror(reflecting: i).children.compactMap { $0.label } + } + + public static func localizedSymbols() -> [String: String] { + [:] + } + init(stockBusinessDate: String, stockClosingPrice: String, stockOpenningPrice: String, highestStockPrice: String, lowestStockPrice: String, accumulatedVolume: String, accumulatedTradingAmount: String, exDivision: ExDivision, partitionRate: String, partitionModifiable: YesNo, previousDayVariableRatioSign: String, previousDayVariableRatio: String, revaluationIssueReason: String) { self.stockBusinessDate = stockBusinessDate self.stockClosingPrice = stockClosingPrice diff --git a/KissMe/Sources/Domestic/DomesticStockResult.swift b/KissMe/Sources/Domestic/DomesticStockResult.swift index c19e010..48aefad 100644 --- a/KissMe/Sources/Domestic/DomesticStockResult.swift +++ b/KissMe/Sources/Domestic/DomesticStockResult.swift @@ -194,6 +194,47 @@ public struct BalanceResult: Codable { case substitutePrice = "sbst_pric" case stockLoanPrice = "stck_loan_unpr" } + + init(array: [String]) throws { + guard array.count == 26 else { + throw GeneralError.incorrectArrayItems + } + self.productNo = array[0] + self.productName = array[1] + self.tradeDivisionCode = array[2] + self.previousDayBuyQuantity = array[3] + self.previousDaySellQuantity = array[4] + self.todayBuyQuantity = array[5] + self.todaySellQuantity = array[6] + self.holdingQuantity = array[7] + self.orderPossibleQuantity = array[8] + self.averagePurchasePrice = array[9] + self.purchaseAmount = array[10] + self.currentPrice = array[11] + self.evaluationAmount = array[12] + self.evaluationProfitLossAmount = array[13] + self.evaluationProfitLossRate = array[14] + self.evaluationEarningRate = array[15] + self.loanDate = array[16] + self.loanAmount = array[17] + self.shortSellingAmount = array[18] + self.expiredDate = array[19] + self.fluctuationRate = array[20] + self.netChangeFluctuation = array[21] + self.marginRequirementRatioName = array[22] + self.depositRateName = array[23] + self.substitutePrice = array[24] + self.stockLoanPrice = array[25] + } + + public static func symbols() -> [String] { + let i = try! OutputStock(array: Array(repeating: "", count: 26)) + return Mirror(reflecting: i).children.compactMap { $0.label } + } + + public static func localizedSymbols() -> [String: String] { + [:] + } } public struct OutputAmount: Codable, PropertyIterable { @@ -295,6 +336,45 @@ public struct BalanceResult: Codable { case assetFluctuationAmount = "asst_icdc_amt" case assetFluctuationRate = "asst_icdc_erng_rt" } + + init(array: [String]) throws { + guard array.count == 24 else { + throw GeneralError.incorrectArrayItems + } + self.depositTotalAmount = array[0] + self.nextDayCalcAmount = array[1] + self.nextTwoDayCalcAmount = array[2] + self.cmaEvaluationAmount = array[3] + self.previousDayBuyAmount = array[4] + self.todayBuyAmount = array[5] + self.nextDayAutoRedemptionAmount = array[6] + self.previousDaySellAmount = array[7] + self.todaySellAmount = array[8] + self.nextTwoDayAutoRedemptionAmount = array[9] + self.previousDayExpensesAmount = array[10] + self.todayExpensesAmount = array[11] + self.totalLoanAmount = array[12] + self.securitiesEvaluationAmount = array[13] + self.totalEvaluationAmount = array[14] + self.netAssetAmount = array[15] + self.loanAutoRedemptionAllowable = YesNo(rawValue: array[16])! + self.purchaseAmountSum = array[17] + self.evaluationAmountSum = array[18] + self.evaluationProfitLossAmountSum = array[19] + self.shortSellingAmountSum = array[20] + self.previousDayAssetEvalutionSum = array[21] + self.assetFluctuationAmount = array[22] + self.assetFluctuationRate = array[23] + } + + public static func symbols() -> [String] { + let i = try! OutputAmount(array: Array(repeating: "", count: 24)) + return Mirror(reflecting: i).children.compactMap { $0.label } + } + + public static func localizedSymbols() -> [String: String] { + [:] + } } } diff --git a/KissMe/Sources/Domestic/DomesticStockSearchResult.swift b/KissMe/Sources/Domestic/DomesticStockSearchResult.swift index 104a874..05a2239 100644 --- a/KissMe/Sources/Domestic/DomesticStockSearchResult.swift +++ b/KissMe/Sources/Domestic/DomesticStockSearchResult.swift @@ -102,6 +102,40 @@ public struct VolumeRankResult: Codable { case nDayTradingAmountTurnoverRate = "nday_tr_pbmn_tnrt" case accumulatedTradingAmount = "acml_tr_pbmn" } + + init(array: [String]) throws { + guard array.count == 19 else { + throw GeneralError.incorrectArrayItems + } + self.htsProductName = array[0] + self.shortProductNo = array[1] + self.dataRank = array[2] + self.currentStockPrice = array[3] + self.previousDayVariableRatioSign = array[4] + self.previousDayVariableRatio = array[5] + self.previousDayDiffRatio = array[6] + self.accumulatedVolume = array[7] + self.previousDayVolume = array[8] + self.listedStockCount = array[9] + self.averageVolume = array[10] + self.nDayBeforeClosingPriceDiffRatio = array[11] + self.volumeIncreaseRate = array[12] + self.volumeTurnoverRate = array[13] + self.nDayVolumeTurnoverRate = array[14] + self.averageTradingAmount = array[15] + self.tradingAmountTurnoverRate = array[16] + self.nDayTradingAmountTurnoverRate = array[17] + self.accumulatedTradingAmount = array[18] + } + + public static func symbols() -> [String] { + let i = try! OutputDetail(array: Array(repeating: "", count: 19)) + return Mirror(reflecting: i).children.compactMap { $0.label } + } + + public static func localizedSymbols() -> [String: String] { + [:] + } } } diff --git a/KissMe/Sources/Domestic/Shop/DomesticShopProduct.swift b/KissMe/Sources/Domestic/Shop/DomesticShopProduct.swift index 8324a39..9fb4a53 100644 --- a/KissMe/Sources/Domestic/Shop/DomesticShopProduct.swift +++ b/KissMe/Sources/Domestic/Shop/DomesticShopProduct.swift @@ -107,14 +107,14 @@ extension DomesticShop { /// 시장 구분 public let marketCategory: String - /// 종목명 (상품명) + /// 종목명 public let itemName: String /// 법인등록번호 public let corporationNo: String - private enum CodingKeys: String, CodingKey { + private enum CodingKeys: String, CodingKey, CaseIterable { case baseDate = "basDt" case shortCode = "srtnCd" case isinCode = "isinCd" @@ -123,7 +123,10 @@ extension DomesticShop { case corporationNo = "crno" } - public init(array: [String]) { + public init(array: [String]) throws { + guard array.count == 6 else { + throw GeneralError.incorrectArrayItems + } self.baseDate = array[0] /// shortCode 단축코드 명에 A000000 형태로 A문자가 붙어 있다. @@ -135,6 +138,15 @@ extension DomesticShop { self.itemName = array[4] self.corporationNo = array[5] } + + public static func symbols() -> [String] { + let i = try! Item(array: Array(repeating: "", count: 6)) + return Mirror(reflecting: i).children.compactMap { $0.label } + } + + public static func localizedSymbols() -> [String: String] { + [:] + } } } diff --git a/KissMeConsole/Sources/Foundation+Extensions.swift b/KissMeConsole/Sources/Foundation+Extensions.swift index 6bdde8e..417b954 100644 --- a/KissMeConsole/Sources/Foundation+Extensions.swift +++ b/KissMeConsole/Sources/Foundation+Extensions.swift @@ -109,4 +109,36 @@ extension Array where Element: PropertyIterable { } try stringCsv.write(toFile: toFile.path, atomically: true, encoding: .utf8) } + + static func readCsv(fromFile: URL, verifyHeader: Bool = true) throws -> [Element] where Element: ArrayDecodable { + let stringCsv = try String(contentsOfFile: fromFile.path, encoding: .utf8) + let items = stringCsv.split(separator: "\n") + guard items.count > 0 else { + return [] + } + + var headerItems = [String]() + var elements = [Element]() + + for (index, item) in items.enumerated() { + if index == 0 { + headerItems = item.split(separator: ",").map { String($0) } + continue + } + let array = item.split(separator: ",").map { String($0) } + let element = try Element(array: array) + + if index == 1, verifyHeader { + // Validate property with header + let properties = try element.allProperties() + for (label, _) in properties { + if false == headerItems.contains(where: { $0 == label }) { + throw GeneralError.headerNoFiendName(label) + } + } + } + elements.append(element) + } + return elements + } } diff --git a/KissMeConsole/Sources/KissConsole+CSV.swift b/KissMeConsole/Sources/KissConsole+CSV.swift index 1edbdff..36ddf9d 100644 --- a/KissMeConsole/Sources/KissConsole+CSV.swift +++ b/KissMeConsole/Sources/KissConsole+CSV.swift @@ -26,6 +26,28 @@ extension KissConsole { var accountAmountUrl: URL { URL.currentDirectory().appending(path: "data/account-amount.csv") } + + var localNamesUrl: URL { + URL.currentDirectory().appending(path: "data/localized-names.csv") + } + + func candleFileUrl(productNo: String, period: CandleFilePeriod, day: String) -> URL { + assert(day.count == 8) + let subPath = "data/\(productNo)/\(period.rawValue)" + let subFile: String + + switch period { + case .minute: + subFile = "\(subPath)/candle-\(day).csv" + case .day: + subFile = "\(subPath)/candle-day-250.csv" + case .weak: + subFile = "\(subPath)/candle-week-42.csv" + } + let fileUrl = URL.currentDirectory().appending(path: subFile) + createSubpath(subPath) + return fileUrl + } } @@ -56,7 +78,7 @@ extension KissConsole { continue } - let product = DomesticShop.Product(array: array) + let product = try! DomesticShop.Product(array: array) if var value = products[product.itemName] { value.append(product) products.updateValue(value, forKey: product.itemName) @@ -135,7 +157,7 @@ extension KissConsole { continue } - let candle = Domestic.Candle(array: array) + let candle = try! Domestic.Candle(array: array) candles.append(candle) } @@ -177,3 +199,16 @@ extension BelongClassCode { } } } + +struct LocalName: Codable, PropertyIterable, ArrayDecodable { + let fieldName: String + let localizedName: String + + init(array: [String]) throws { + guard array.count == 2 else { + throw GeneralError.incorrectArrayItems + } + fieldName = array[0] + localizedName = array[1] + } +} diff --git a/KissMeConsole/Sources/KissConsole+Candle.swift b/KissMeConsole/Sources/KissConsole+Candle.swift index f72209d..c93b9cd 100644 --- a/KissMeConsole/Sources/KissConsole+Candle.swift +++ b/KissMeConsole/Sources/KissConsole+Candle.swift @@ -25,25 +25,6 @@ extension KissConsole { } - func candleFileUrl(productNo: String, period: CandleFilePeriod, day: String) -> URL { - assert(day.count == 8) - let subPath = "data/\(productNo)/\(period.rawValue)" - let subFile: String - - switch period { - case .minute: - subFile = "\(subPath)/candle-\(day).csv" - case .day: - subFile = "\(subPath)/candle-day-250.csv" - case .weak: - subFile = "\(subPath)/candle-week-42.csv" - } - let fileUrl = URL.currentDirectory().appending(path: subFile) - createSubpath(subPath) - return fileUrl - } - - func getRecentCandle(productNo: String, period: PeriodDivision, count: Int) async -> Bool { do { guard currentCandleShortCode == nil else { diff --git a/KissMeConsole/Sources/KissConsole.swift b/KissMeConsole/Sources/KissConsole.swift index 763da89..1a854c7 100644 --- a/KissMeConsole/Sources/KissConsole.swift +++ b/KissMeConsole/Sources/KissConsole.swift @@ -58,6 +58,10 @@ class KissConsole { case love = "love" // love nuts.1 ISCD case hate = "hate" // hate nuts.1 ISCD + // 기타 + case localizeNames = "localize names" + + var needLogin: Bool { switch self { case .quit, .loginMock, .loginReal: @@ -72,7 +76,7 @@ class KissConsole { return false case .showcase: return false - case .loves, .love, .hate: + case .loves, .love, .hate, .localizeNames: return false } } @@ -193,6 +197,7 @@ class KissConsole { case .loves: await onLoves() case .love: await onLove(args) case .hate: await onHate(args) + case .localizeNames: await onLocalizeNames() default: print("Unknown command: \(line)") @@ -547,7 +552,7 @@ extension KissConsole { print("\t누적 거래 대금: ", output.accumulatedTradingAmount) print("\t누적 거래량: ", output.accumulatedVolume) print("\t전일 대비 거래량 비율: ", output.previousDayDiffVolumeRatio) - print("\t주식 시가: ", output.stockPrice) + print("\t주식 시가: ", output.stockOpenningPrice) print("\t주식 최고가: ", output.highestStockPrice) print("\t주식 최저가: ", output.lowestStockPrice) print("\t외국인 순매수 수량: ", output.foreignNetBuyingQuantity) @@ -846,6 +851,37 @@ extension KissConsole { 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()) + let newNames = symbols.sorted(by: { $0 < $1 }) + + var added = 0 + var curNames = try! [LocalName].readCsv(fromFile: localNamesUrl, verifyHeader: true) + for name in newNames { + if false == curNames.contains(where: { $0.fieldName == name }) { + let item = try! LocalName(array: [name, ""]) + curNames.append(item) + added += 1 + } + } + if added > 0 { + do { + try curNames.writeCsv(toFile: localNamesUrl) + } catch { + print(error) + } + } + print("Success \(localNamesUrl.lastPathComponent) total: \(curNames.count), new: \(added)") + } } diff --git a/README.md b/README.md index 448f7da..8f3b198 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,13 @@ WIP `modify (PNO) (ONO) (가격) (수량)` | 주문 내역을 변경. (수량) `candle week [PNO]` | 종목의 최근 52주 동안의 주봉 열람. PNO 은 생략 가능. **data/(PNO)/week/candle-(yyyyMMdd).csv** 파일로 저장. `candle week all` | 모든 종목의 최근 52주 동안의 주봉 열람. cron job 으로 오전 장이 시작전에 미리 수집. **data/(PNO)/week/candle-(yyyyMMdd).csv** 파일로 저장. `load shop` | data/shop-products.csv 로부터 전체 상품을 로딩. -`update shop` | **금융위원회_KRX상장종목정보** 로부터 전체 상품을 얻어서 data/shop-products.csv 로 저장. +`update shop` | **금융위원회_KRX상장종목정보** 로부터 전체 상품을 얻어서 **data/shop-products.csv** 로 저장. `look (상품명)` | (상품명) 에 해당되는 PNO 를 표시함. WIP `showcase` | 추천 상품을 제안함. `loves` | 관심 종목 전체를 열람. profile.json 에 저장된 관심 종목을 표시함. `love (탭).(번호) (PNO)` | 관심 종목에 추가함. (번호) 를 지정하지 않으면 (탭) 마지막에 추가함. `hate (탭) (PNO)` | 관심 종목에서 삭제함. +`localize names` | csv field name 에 대해서 한글명을 제공하는 **data/local-names.csv** 를 저장. * PNO 는 `Product NO` 의 약자이고, 상품의 `단축코드` (shortCode) 와 동일합니다. * ONO 는 `Order NO` 의 약자이고, 고유한 주문번호 입니다. diff --git a/bin/data b/bin/data index feebeed..01238b7 160000 --- a/bin/data +++ b/bin/data @@ -1 +1 @@ -Subproject commit feebeed12f33a062679ac3a241f33a944449ca87 +Subproject commit 01238b796d88985d872f00ab2b320ac6f1598743