diff --git a/KissMe/Sources/Common/Foundation+Extensions.swift b/KissMe/Sources/Common/Foundation+Extensions.swift index 0d28579..1765d0f 100644 --- a/KissMe/Sources/Common/Foundation+Extensions.swift +++ b/KissMe/Sources/Common/Foundation+Extensions.swift @@ -59,6 +59,63 @@ extension Date { let fullDate = yyyyMMdd + HHmmss return dateFormatter.date(from: fullDate) } + + public var yyyyMMdd_split: (year: Int, month: Int, day: Int)? { + let sets: Set = [.year, .month, .day, .hour, .minute, .second] + let components = Calendar.current.dateComponents(sets, from: self) + guard let year = components.year, let month = components.month, let day = components.day else { + return nil + } + return (year, month, day) + } + + public var HHmmss_split: (hour: Int, minute: Int, second: Int)? { + let sets: Set = [.year, .month, .day, .hour, .minute, .second] + let components = Calendar.current.dateComponents(sets, from: self) + guard let hour = components.hour, let minute = components.minute, let second = components.second else { + return nil + } + return (hour, minute, second) + } + + public func changing(hour: Int?, min: Int?, sec: Int?, timeZone: String = "KST") -> Date? { + let sets: Set = [.year, .month, .day, .hour, .minute, .second] + var components = Calendar.current.dateComponents(sets, from: self) + components.timeZone = TimeZone(abbreviation: timeZone) + if let hour = hour { + components.hour = hour + } + if let min = min { + components.minute = min + } + if let sec = sec { + components.second = sec + } + components.nanosecond = 0 + return Calendar.current.date(from: components) + } + + public mutating func change(hour: Int, min: Int, sec: Int, timeZone: String = "KST") { + if let newDate = changing(hour: hour, min: min, sec: sec, timeZone: timeZone) { + self = newDate + } + } + + public func changing(year: Int, month: Int, day: Int, timeZone: String = "KST") -> Date? { + let sets: Set = [.year, .month, .day, .hour, .minute, .second] + var components = Calendar.current.dateComponents(sets, from: self) + components.timeZone = TimeZone(abbreviation: timeZone) + components.year = year + components.month = month + components.day = day + return Calendar.current.date(from: components) + } + + public mutating func change(year: Int, month: Int, day: Int, timeZone: String = "KST") { + if let newDate = changing(year: year, month: month, day: day, timeZone: timeZone) { + self = newDate + } + } } diff --git a/KissMe/Sources/Domestic/DomesticStock.swift b/KissMe/Sources/Domestic/DomesticStock.swift index bcf3bf3..dad1e42 100644 --- a/KissMe/Sources/Domestic/DomesticStock.swift +++ b/KissMe/Sources/Domestic/DomesticStock.swift @@ -13,6 +13,7 @@ public struct Domestic { public typealias CandlePeriod = PeriodPriceResult.OutputPrice public typealias Top = VolumeRankResult.OutputDetail public typealias CurrentPrice = CurrentPriceResult.OutputDetail + public typealias Investor = InvestorVolumeResult.OutputDetail } diff --git a/KissMeConsole/Sources/Foundation+Extensions.swift b/KissMeConsole/Sources/Foundation+Extensions.swift index c78d5c7..48db03e 100644 --- a/KissMeConsole/Sources/Foundation+Extensions.swift +++ b/KissMeConsole/Sources/Foundation+Extensions.swift @@ -60,66 +60,6 @@ extension String { } -extension Date { - public var yyyyMMdd_split: (year: Int, month: Int, day: Int)? { - let sets: Set = [.year, .month, .day, .hour, .minute, .second] - let components = Calendar.current.dateComponents(sets, from: self) - guard let year = components.year, let month = components.month, let day = components.day else { - return nil - } - return (year, month, day) - } - - public var HHmmss_split: (hour: Int, minute: Int, second: Int)? { - let sets: Set = [.year, .month, .day, .hour, .minute, .second] - let components = Calendar.current.dateComponents(sets, from: self) - guard let hour = components.hour, let minute = components.minute, let second = components.second else { - return nil - } - return (hour, minute, second) - } - - public func changing(hour: Int?, min: Int?, sec: Int?, timeZone: String = "KST") -> Date? { - let sets: Set = [.year, .month, .day, .hour, .minute, .second] - var components = Calendar.current.dateComponents(sets, from: self) - components.timeZone = TimeZone(abbreviation: timeZone) - if let hour = hour { - components.hour = hour - } - if let min = min { - components.minute = min - } - if let sec = sec { - components.second = sec - } - components.nanosecond = 0 - return Calendar.current.date(from: components) - } - - public mutating func change(hour: Int, min: Int, sec: Int, timeZone: String = "KST") { - if let newDate = changing(hour: hour, min: min, sec: sec, timeZone: timeZone) { - self = newDate - } - } - - public func changing(year: Int, month: Int, day: Int, timeZone: String = "KST") -> Date? { - let sets: Set = [.year, .month, .day, .hour, .minute, .second] - var components = Calendar.current.dateComponents(sets, from: self) - components.timeZone = TimeZone(abbreviation: timeZone) - components.year = year - components.month = month - components.day = day - return Calendar.current.date(from: components) - } - - public mutating func change(year: Int, month: Int, day: Int, timeZone: String = "KST") { - if let newDate = changing(year: year, month: month, day: day, timeZone: timeZone) { - self = newDate - } - } -} - - extension FileManager { /// period: If nil, all period of csv collected. diff --git a/KissMeConsole/Sources/KissConsole+Investor.swift b/KissMeConsole/Sources/KissConsole+Investor.swift index 8ab05e7..a8a0112 100644 --- a/KissMeConsole/Sources/KissConsole+Investor.swift +++ b/KissMeConsole/Sources/KissConsole+Investor.swift @@ -15,7 +15,7 @@ extension KissConsole { if let output = result.output { print("Total output: \(output.count) productNo: \(productNo)") - var months = [String: [InvestorVolumeResult.OutputDetail]]() + var months = [String: [Domestic.Investor]]() for item in output { let yyyyMM = String(item.stockBusinessDate.prefix(6)) if let _ = months[yyyyMM] { diff --git a/KissMeConsole/Sources/KissConsole+Price.swift b/KissMeConsole/Sources/KissConsole+Price.swift index 68c1e40..bff4465 100644 --- a/KissMeConsole/Sources/KissConsole+Price.swift +++ b/KissMeConsole/Sources/KissConsole+Price.swift @@ -46,7 +46,7 @@ extension KissConsole { return true } - private func printCurrentPrice(_ output: CurrentPriceResult.OutputDetail) { + private func printCurrentPrice(_ output: Domestic.CurrentPrice) { let productName = getProduct(shortCode: output.shortProductCode)?.itemName ?? "" print("\t종목명: ", productName) print("\t업종명: ", output.koreanMarketName, output.koreanBusinessTypeName ?? "") diff --git a/KissMeConsole/Sources/KissConsole+Test.swift b/KissMeConsole/Sources/KissConsole+Test.swift index 51157c5..4854b09 100644 --- a/KissMeConsole/Sources/KissConsole+Test.swift +++ b/KissMeConsole/Sources/KissConsole+Test.swift @@ -23,7 +23,7 @@ extension KissConsole { } -// MARK: 모든 prices.csv 에 컴마 데이터를 보정하는 임시코드 +// MARK: 모든 prices-yyyyMM01.csv 에 컴마 데이터를 보정하는 임시코드 extension KissConsole { private func onTest_001() { diff --git a/KissMeConsole/Sources/main.swift b/KissMeConsole/Sources/main.swift index 8b5a0ce..59b95cf 100644 --- a/KissMeConsole/Sources/main.swift +++ b/KissMeConsole/Sources/main.swift @@ -8,21 +8,3 @@ import Foundation KissConsole().run() - - -// 액면가가 1,000원 이상인 종목들 추려보자. (미챠...) -// 액면가가 넘어가면, prices.csv 에 저장된 comma 값을 다시 수정해야 함. -/* -import KissMe - -let path = URL(filePath: "/Users/ened/Kiss/KissMe/bin/data/065350/price/prices.csv") -let data = try [CapturePrice].readCsv(fromFile: path, verifyHeader: true) - -if let last = data.last { - print(last) -} - -let shopProductsUrl = URL.currentDirectory().appending(path: "data/shop-products.csv") -let context = ShopContext() -context.loadShop(url: shopProductsUrl) -*/ diff --git a/KissMeConsole/Sources/test.swift b/KissMeConsole/Sources/test.swift index 3b6d189..8bc4972 100644 --- a/KissMeConsole/Sources/test.swift +++ b/KissMeConsole/Sources/test.swift @@ -274,9 +274,9 @@ private func split_investor_csv() { let productNoDir = investorDir.deletingLastPathComponent() let productNo = productNoDir.lastPathComponent - let output = try [InvestorVolumeResult.OutputDetail].readCsv(fromFile: url, verifyHeader: true) + let output = try [Domestic.Investor].readCsv(fromFile: url, verifyHeader: true) - var months = [String: [InvestorVolumeResult.OutputDetail]]() + var months = [String: [Domestic.Investor]]() for item in output { let yyyyMM = String(item.stockBusinessDate.prefix(6)) if let _ = months[yyyyMM] { diff --git a/KissMeIndex/Sources/KissIndex+0004.swift b/KissMeIndex/Sources/KissIndex+0004.swift index 12a7bac..b26cdd6 100644 --- a/KissMeIndex/Sources/KissIndex+0004.swift +++ b/KissMeIndex/Sources/KissIndex+0004.swift @@ -15,23 +15,71 @@ extension KissIndex { loadShop(url: KissIndex.shopProductsUrl) } + // TODO: config 를 로딩하여, 두 개의 전략중에 하나를 선택 + let semaphore = DispatchSemaphore(value: 0) Task { + let netSumLock = NSLock() + var netSum = [String: Int]() + + /// 외국인 매수의 물량이 3일 연속 증가이면, 긍정적 신호로 사용한다. + /// 반대로 3일 연속 감소이면, 부정적 신호로 사용한다. + /// + let investors = try await collectInvestors(date: date, recentCount: 3) { productNo, investors in + if investors.count == 3, + let netBuying1 = Int(investors[0].foreignNetBuyingQuantity), + let netBuying2 = Int(investors[1].foreignNetBuyingQuantity), + let netBuying3 = Int(investors[2].foreignNetBuyingQuantity) { + + if (netBuying1 > 0 && netBuying2 > 0 && netBuying3 > 0) || + (netBuying1 < 0 && netBuying2 < 0 && netBuying3 < 0) { + + let sum = netBuying1 + netBuying2 + netBuying3 + + netSumLock.lock() + if let _ = netSum[productNo] { + netSum[productNo]! += sum + } + else { + netSum[productNo] = sum + } + netSumLock.unlock() + return true + } + } + return false + } + var scoreMap = [String: Double]() + for (productNo, _) in investors { + let score: Double + if let sum = netSum[productNo] { + if sum < 0 { + score = -log10(abs(Double(sum))) + } + else { + score = log10(Double(sum)) + } + } + else { + score = 0 + } + + if let _ = scoreMap[productNo] { + scoreMap[productNo]! += score + } + else { + scoreMap[productNo] = score + } + } - // TODO: work - + normalizeAndWrite(scoreMap: scoreMap, includeName: true, kmi: kmi) + + /// <<다른 전략>> + /// 대상 날짜의 공매도 물량이 3일전의 공매도 물량보다 크면 선별한다. + semaphore.signal() } semaphore.wait() } - - private func pickNearInvestorUrl(productNo: String, date: Date) { - let subPath = "data/\(productNo)/investor" - let subFile = "investor-\(date.yyyyMM01).csv" - - let fileUrl = URL.currentDirectory().appending(path: subFile) - - // TODO: work - } } diff --git a/KissMeIndex/Sources/KissIndex.swift b/KissMeIndex/Sources/KissIndex.swift index 01a5fc4..6e52da4 100644 --- a/KissMeIndex/Sources/KissIndex.swift +++ b/KissMeIndex/Sources/KissIndex.swift @@ -155,6 +155,53 @@ extension KissIndex { } return prices } + + func collectInvestors(date: Date, recentCount: Int, filter: @escaping (String, [Domestic.Investor]) -> Bool) async throws -> [(String,Domestic.Investor)] { + let investors = try await withThrowingTaskGroup(of: (String, Domestic.Investor)?.self, returning: [(String, Domestic.Investor)].self) { taskGroup in + let all = getAllProducts() + let (yyyy, mm, dd) = date.yyyyMMdd_split! + + for item in all { + taskGroup.addTask { + let investorUrl = KissIndex.pickNearInvestorUrl(productNo: item.shortCode, date: date) + + let prevMonthDate = date.changing(year: yyyy, month: mm-1, day: dd)! + let prevMonthInvestorUrl = KissIndex.pickNearInvestorUrl(productNo: item.shortCode, date: prevMonthDate) + + var investors = try [Domestic.Investor].readCsv(fromFile: investorUrl) + if let prevInvestors = try? [Domestic.Investor].readCsv(fromFile: prevMonthInvestorUrl) { + investors.append(contentsOf: prevInvestors) + } + + var targetInvestors = [Domestic.Investor]() + var prevDays = 1 + var desiredDate: Date? = date + + while desiredDate != nil, prevDays < 20 { + let selected = investors.filter { $0.stockBusinessDate == desiredDate!.yyyyMMdd } + targetInvestors.append(contentsOf: selected) + if targetInvestors.count >= recentCount { + break + } + desiredDate = desiredDate!.changing(year: yyyy, month: mm, day: dd-prevDays) + prevDays += 1 + } + + if filter(item.shortCode, targetInvestors), let first = targetInvestors.first { + return (item.shortCode, first) + } + return nil + } + } + + var taskResult = [(String, Domestic.Investor)]() + for try await result in taskGroup.compactMap( { $0 }) { + taskResult.append(result) + } + return taskResult + } + return investors + } } @@ -173,13 +220,17 @@ extension KissIndex { private static func pickNearPricesUrl(productNo: String, date: Date) -> URL { let subPath = "data/\(productNo)/price" - let priceFile = "prices.csv" - - // TODO: work month file - //let monthFile = "prices-\(date.yyyyMM01).csv" + let priceFile = "prices-\(date.yyyyMM01).csv" return URL.currentDirectory().appending(path: "\(subPath)/\(priceFile)") } + + private static func pickNearInvestorUrl(productNo: String, date: Date) -> URL { + let subPath = "data/\(productNo)/investor" + let investorFile = "investor-\(date.yyyyMM01).csv" + + return URL.currentDirectory().appending(path: "\(subPath)/\(investorFile)") + } } @@ -187,12 +238,13 @@ extension KissIndex { func normalizeAndWrite(scoreMap: [String: Double], includeName: Bool = false, kmi: KissIndexType) { - let totalScores = scoreMap.reduce(0, { $0 + $1.value }) + let positiveTotalScores = scoreMap.reduce(0, { $0 + ($1.value > 0 ? $1.value: 0) }) + let negativeTotalScores = abs(scoreMap.reduce(0, { $0 + ($1.value < 0 ? $1.value: 0) })) let scoreArray = scoreMap.map { ($0.key, $0.value) }.sorted(by: { $0.1 > $1.1 }) var outputs = [KissIndexResult.Output]() for array in scoreArray { - let weight = Double(array.1) / Double(totalScores) + let weight = Double(array.1) / (array.1 > 0 ? Double(positiveTotalScores): Double(negativeTotalScores)) let name: String? = (includeName ? getProduct(shortCode: array.0)?.itemName: nil) let output = KissIndexResult.Output(shortCode: array.0, productName: name, weight: weight) outputs.append(output) diff --git a/README.md b/README.md index 61e418f..49d6458 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Command | 설명 `cancel (PNO) (ONO) (수량)` | 주문 내역의 일부를 취소. (수량) 에 -82 로 입력하면 전체수량. WIP `modify (PNO) (ONO) (가격) (수량)` | 주문 내역을 변경. (수량) 에 -82 로 입력하면 전체수량. `open bag` | 보유 종목 열람. 보유 주식은 **data/account-stocks.csv** 파일로 저장. 잔고 상황은 **data/account-amount.csv** 파일로 저장. -`now [PNO]` | 종목의 현재가 열람. PNO 은 생략 가능. **data/(PNO)/price/prices.csv** 파일로 저장. +`now [PNO]` | 종목의 현재가 열람. PNO 은 생략 가능. **data/(PNO)/price/prices-(yyyyMM01).csv** 파일로 저장. `candle [PNO]` | 종목의 분봉 열람. PNO 은 생략 가능. **data/(PNO)/min/candle-(yyyyMMdd).csv** 파일로 저장. `candle all [resume]` | 모든 종목의 분봉 열람. cron job 으로 돌리기 위해서 추가. **data/(PNO)/min/candle-(yyyyMMdd).csv** 파일로 저장. (resume) 을 기입하면, 이미 받은 파일은 검사하여, 데이터에 오류가 있으면 다시 받고 오류가 없으면 새롭게 열람하지 않음. `candle day [PNO]` | 종목의 최근 250일 동안의 일봉 열람. PNO 은 생략 가능. **data/(PNO)/day/candle-(yyyyMMdd).csv** 파일로 저장. @@ -32,10 +32,10 @@ WIP `modify (PNO) (ONO) (가격) (수량)` | 주문 내역을 변경. (수량) `candle week [PNO]` | 종목의 최근 52주 동안의 주봉 열람. PNO 은 생략 가능. **data/(PNO)/week/candle-(yyyyMMdd).csv** 파일로 저장. `candle week all [resume]` | 모든 종목의 최근 52주 동안의 주봉 열람. cron job 으로 오전 장이 시작전에 미리 수집. **data/(PNO)/week/candle-(yyyyMMdd).csv** 파일로 저장. `candle validate (기간)` | (기간) 타입의 모든 csv 파일에 대해서 데이터가 유효한지 검사. (기간) 으로는 **min**, **day**, **week** 을 지정하고, 생략되면 **min** 으로 간주. -`investor [PNO]` | 종목의 투자자 거래량 열람. PNO 은 생략 가능. **data/(PNO)/investor/investor-(yyyyMMdd).csv** 파일로 저장. -`investor all` | 모든 종목의 투자자 거래량 열람. **data/(PNO)/investor/investor-(yyyyMMdd).csv** 파일로 저장. -`shorts [PNO]` | 공매도 잔고를 열람. PNO 은 생략 가능. **data/shorts/(yyyy)/shorts-(yyyyMMdd).csv** 파일로 저장. -`shorts all [resume]` | 모든 종목의 공매도 잔고를 열람. **data/shorts/(yyyy)/shorts-(yyyyMMdd).csv** 파일로 저장. +`investor [PNO]` | 종목의 투자자 거래량 열람. PNO 은 생략 가능. **data/(PNO)/investor/investor-(yyyyMM01).csv** 파일로 저장. +`investor all` | 모든 종목의 투자자 거래량 열람. **data/(PNO)/investor/investor-(yyyyMM01).csv** 파일로 저장. +`shorts [PNO]` | 공매도 잔고를 열람. PNO 은 생략 가능. **data/shorts/(yyyy)/shorts-(yyyyMM01).csv** 파일로 저장. +`shorts all [resume]` | 모든 종목의 공매도 잔고를 열람. **data/shorts/(yyyy)/shorts-(yyyyMM01).csv** 파일로 저장. `load shop` | data/shop-products.csv 로부터 전체 상품을 로딩. `update shop` | **금융위원회_KRX상장종목정보** 로부터 전체 상품을 얻어서 **data/shop-products.csv** 로 저장. `look (상품명)` | (상품명) 에 해당되는 PNO 를 표시함. @@ -67,24 +67,12 @@ KISS_ASSERT_COMMA_CSV_DATA | `true`, `1` 이면, CSV 데이터에 comma 문자 KissMeIndex 는 지표 집합(index set)을 추출하는 도구입니다. -## INPUT +다음은 지표 데이터를 추출하는 예제입니다. -INPUT 으로는 다음과 같은 값을 제공합니다. - -* 환경설정 : config.json 파일로 특정 지표에서 보정으로 필요로 하는 설정을 기입합니다. -* 현재시간 : timestamp 값을 반드시 필요로 합니다. simulator 에서도 이 기능을 활용할 수 있습니다. - -## OUTPUT - -OUTPUT 은 다음과 같은 값을 json 형태로 제공합니다. - -* shortCode : 추천종목 코드 번호입니다. -* weight : [-1.0, 1.0] 사이의 가중치 값입니다. 음수이면 매도 성향이고, 양수이면 매수성향입니다. - -## Example +첫라인은 INPUT 이고, 이후의 json 데이터는 OUTPUT 입니다. ```bash -./KissMeIndex KMI-0001 20230616 100000 config.json +./KissMeIndex KMI-0005 20230616 105900 config.json { "code": 200, "message": "OK", @@ -102,6 +90,27 @@ OUTPUT 은 다음과 같은 값을 json 형태로 제공합니다. } ``` +### INPUT + +* (indexApp) KMI-(number) (date) (time) (config.json) + * (indexApp) 는 지표를 추출하는 app binary 입니다. INPUT, OUTPUT 형식만 맞출 수 있다면, 다양한 도구를 통해서 만들 수 있습니다. + * KMI-(number) 는 고유의 지표 번호입니다. 하나의 app 에서 여러 지표를 추출할 수 있습니다. + * (date) 는 yyyyMMdd 형식의 날짜입니다. + * (time) 는 HHmmss 형식의 시간입니다. + * (config.json) 는 config 로 상세한 설정을 할 수 있는 json 파일입니다. 여기의 내용은 (indexApp) 과 KMI-(number) 에 따라서 다르게 구성될 수 있습니다. + +### OUTPUT + +json 파일 형식으로 결과를 제공합니다. + +* `code`: 에러코드입니다. `200` 은 성공입니다. +* `message`: 상세한 메시지를 의미합니다. 성공하면 `OK` 로 표시합니다. +* `kmi`: 데이터로 제공되는 KMI 지표입니다. +* `output`: 지표 데이터 배열입니다. + * `shortCode` : 추천종목 코드 번호 + * `productName` : 종목명 + * `weight` : [-1.0, 1.0] 사이의 가중치 값입니다. 음수이면 매도 성향이고, 양수이면 매수성향입니다. + # KissMeMatrix diff --git a/documents/KMI/KMI-0005.md b/documents/KMI/KMI-0005.md index 0e3b770..326f379 100644 --- a/documents/KMI/KMI-0005.md +++ b/documents/KMI/KMI-0005.md @@ -39,26 +39,6 @@ } ``` -### INPUT -* (indexApp) KMI-(number) (date) (time) (config.json) - * (indexApp) 는 지표를 추출하는 app binary 입니다. INPUT, OUTPUT 형식만 맞출 수 있다면, 다양한 도구를 통해서 만들 수 있습니다. - * KMI-(number) 는 고유의 지표 번호입니다. 하나의 app 에서 여러 지표를 추출할 수 있습니다. - * (date) 는 yyyyMMdd 형식의 날짜입니다. - * (time) 는 HHmmss 형식의 시간입니다. - * (config.json) 는 config 로 상세한 설정을 할 수 있는 json 파일입니다. 여기의 내용은 (indexApp) 과 KMI-(number) 에 따라서 다르게 구성될 수 있습니다. - -### OUTPUT - -json 파일 형식으로 결과를 제공합니다. - -* `code`: 에러코드. `200` 은 성공. -* `message`: 상세한 메시지. 성공하면 `OK`. -* `kmi`: 요청에 제공되는 KMI 지표 -* `output`: 지표 데이터 - * `shortCode` : 추천종목 코드 번호 - * `productName` : 종목명 - * `weight` : [-1.0, 1.0] 사이의 가중치 값. 음수이면 매도 성향이고, 양수이면 매수성향. - ### Configuration (config.json) 현재 지원하는 환경설정 정보가 없습니다. diff --git a/projects/macos/KissMeIndex.xcodeproj/xcshareddata/xcschemes/KissMeIndex.xcscheme b/projects/macos/KissMeIndex.xcodeproj/xcshareddata/xcschemes/KissMeIndex.xcscheme index 0a12f9d..0147ad6 100644 --- a/projects/macos/KissMeIndex.xcodeproj/xcshareddata/xcschemes/KissMeIndex.xcscheme +++ b/projects/macos/KissMeIndex.xcodeproj/xcshareddata/xcschemes/KissMeIndex.xcscheme @@ -53,7 +53,7 @@