Implement "invest", "invest all" command

This commit is contained in:
2023-06-11 16:39:55 +09:00
parent 3880033e4d
commit 89f398862c
10 changed files with 442 additions and 41 deletions

View File

@@ -139,7 +139,6 @@ extension Domestic {
}
public var result: KResult? = nil
public let credential: Credential
public var responseDataLoggable: Bool { true }
private var trId: String {

View File

@@ -61,6 +61,20 @@ public enum BelongClassCode: String, CustomStringConvertible {
}
///
public enum MarketDivisionCode: String, Codable {
///
case all = "0000"
///
case kospi = "0001"
///
case kosdaq = "1001"
// TODO: ()
// https://apiportal.koreainvestment.com/community/10000000-0000-0011-0000-000000000002
}
extension Domestic {
/// - [v1_-047]
@@ -90,7 +104,7 @@ extension Domestic {
[
"FID_COND_MRKT_DIV_CODE": "J",
"FID_COND_SCR_DIV_CODE": "20171",
"FID_INPUT_ISCD": "0000", // TODO: ()
"FID_INPUT_ISCD": MarketDivisionCode.all.rawValue,
"FID_DIV_CLS_CODE": divisionClass.rawValue,
"FID_BLNG_CLS_CODE": belongClass.rawValue,
"FID_TRGT_CLS_CODE": "000000000",
@@ -163,6 +177,136 @@ extension Domestic {
self.baseDate = baseDate
}
}
/// - [v1_-012]
///
public struct InvestorVolumeRequest: OrderRequest {
public typealias KResult = InvestorVolumeResult
public var url: String { "/uapi/domestic-stock/v1/quotations/inquire-investor" }
public var method: Method { .get }
public var header: [String: String?] {
[
"authorization": "Bearer \(accessToken)",
"appkey": credential.appKey,
"appsecret": credential.appSecret,
"tr_id": trId,
"custtype": CustomerType.personal.rawValue,
]
}
public var body: [String: Any] {
[
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": productNo,
]
}
public var result: KResult? = nil
public let credential: Credential
public var responseDataLoggable: Bool {
return false
}
private var trId: String {
"FHKST01010900"
}
public let accessToken: String
let productNo: String
public init(credential: Credential, accessToken: String, productNo: String) {
self.credential = credential
self.accessToken = accessToken
self.productNo = productNo
}
}
/// - _ [-037]
///
public struct ForeignInstitutionVolumeRequest: OrderRequest {
public typealias KResult = String
public var isMockAvailable: Bool {
credential.isMock == false
}
public var url: String { "/uapi/domestic-stock/v1/quotations/foreign-institution-total" }
public var method: Method { .get }
public var header: [String: String?] {
[
"authorization": "Bearer \(accessToken)",
"appkey": credential.appKey,
"appsecret": credential.appSecret,
"tr_id": trId,
"custtype": CustomerType.personal.rawValue,
]
}
public var body: [String: Any] {
[
"FID_COND_MRKT_DIV_CODE": "V",
"FID_COND_SCR_DIV_CODE": "16449",
"FID_INPUT_ISCD": marketDivision.rawValue,
"FID_DIV_CLS_CODE": order.rawValue,
"FID_RANK_SORT_CLS_CODE": tradeType.rawValue,
"FID_ETC_CLS_CODE": target.rawValue,
]
}
public var result: KResult? = nil
public let credential: Credential
private var trId: String {
"FHPTJ04400000"
}
public let accessToken: String
let marketDivision: MarketDivisionCode
let order: Order
let tradeType: TradeType
let target: Target
///
public enum Order: String {
///
case volume = "0"
///
case amount = "1"
}
///
public enum TradeType: String {
///
case topBuying = "0"
///
case topSelling = "1"
}
///
public enum Target: String {
///
case all = "0"
///
case foreigner = "1"
///
case institution = "2"
///
case etc = "3"
}
public init(credential: Credential, accessToken: String, marketDivision: MarketDivisionCode, order: Order, tradeType: TradeType, target: Target) {
self.credential = credential
self.accessToken = accessToken
self.marketDivision = marketDivision
self.order = order
self.tradeType = tradeType
self.target = target
}
}
}
@@ -188,7 +332,7 @@ extension KissAccount {
continuation.resume(throwing: GeneralError.invalidAccessToken)
return
}
let request = Domestic.StockVolumeRankRequest(credential: credential, accessToken: accessToken, divisionClass: option.divisionClass, belongClass: option.belongClass)
request.query { result in
switch result {
@@ -221,4 +365,25 @@ extension KissAccount {
}
}
}
///
///
public func getInvestorVolume(productNo: String) async throws -> InvestorVolumeResult {
return try await withUnsafeThrowingContinuation { continuation in
guard let accessToken = accessToken else {
continuation.resume(throwing: GeneralError.invalidAccessToken)
return
}
let request = Domestic.InvestorVolumeRequest(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)
}
}
}
}
}

View File

@@ -190,3 +190,148 @@ public struct HolidyResult: Codable {
}
}
}
public struct InvestorVolumeResult: Codable {
public let resultCode: String
public let messageCode: String
public let message: String
public let output: [OutputDetail]?
private enum CodingKeys: String, CodingKey {
case resultCode = "rt_cd"
case messageCode = "msg_cd"
case message = "msg1"
case output = "output"
}
public struct OutputDetail: Codable, PropertyIterable, ArrayDecodable {
///
public let stockBusinessDate: String
///
public let stockClosingPrice: String
///
public let previousDayVariableRatio: String
///
public let previousDayVariableRatioSign: String
///
public let personalNetBuyingQuantity: String
///
public let foreignNetBuyingQuantity: String
///
public let organizationNetBuyingQuantity: String
///
public let personalNetBuyingTradingAmount: String
///
public let foreignNetBuyingTradingAmount: String
///
public let organizationNetBuyingTradingAmount: String
/// 2
public let personalBuyingVolume: String
/// 2
public let foreignBuyingVolume: String
/// 2
public let organizationBuyingVolume: String
/// 2
public let personalBuyingTradingAmount: String
/// 2
public let foreignBuyingTradingAmount: String
/// 2
public let organizationBuyingTradingAmount: String
///
public let personalSellingVolume: String
///
public let foreignSellingVolume: String
///
public let organizationSellingVolume: String
///
public let personalSellingTradingAmount: String
///
public let foreignSellingTradingAmount: String
///
public let organizationSellingTradingAmount: String
private enum CodingKeys: String, CodingKey {
case stockBusinessDate = "stck_bsop_date"
case stockClosingPrice = "stck_clpr"
case previousDayVariableRatio = "prdy_vrss"
case previousDayVariableRatioSign = "prdy_vrss_sign"
case personalNetBuyingQuantity = "prsn_ntby_qty"
case foreignNetBuyingQuantity = "frgn_ntby_qty"
case organizationNetBuyingQuantity = "orgn_ntby_qty"
case personalNetBuyingTradingAmount = "prsn_ntby_tr_pbmn"
case foreignNetBuyingTradingAmount = "frgn_ntby_tr_pbmn"
case organizationNetBuyingTradingAmount = "orgn_ntby_tr_pbmn"
case personalBuyingVolume = "prsn_shnu_vol"
case foreignBuyingVolume = "frgn_shnu_vol"
case organizationBuyingVolume = "orgn_shnu_vol"
case personalBuyingTradingAmount = "prsn_shnu_tr_pbmn"
case foreignBuyingTradingAmount = "frgn_shnu_tr_pbmn"
case organizationBuyingTradingAmount = "orgn_shnu_tr_pbmn"
case personalSellingVolume = "prsn_seln_vol"
case foreignSellingVolume = "frgn_seln_vol"
case organizationSellingVolume = "orgn_seln_vol"
case personalSellingTradingAmount = "prsn_seln_tr_pbmn"
case foreignSellingTradingAmount = "frgn_seln_tr_pbmn"
case organizationSellingTradingAmount = "orgn_seln_tr_pbmn"
}
public init(array: [String]) throws {
guard array.count == 22 else {
throw GeneralError.incorrectArrayItems
}
self.stockBusinessDate = array[0]
self.stockClosingPrice = array[1]
self.previousDayVariableRatio = array[2]
self.previousDayVariableRatioSign = array[3]
self.personalNetBuyingQuantity = array[4]
self.foreignNetBuyingQuantity = array[5]
self.organizationNetBuyingQuantity = array[6]
self.personalNetBuyingTradingAmount = array[7]
self.foreignNetBuyingTradingAmount = array[8]
self.organizationNetBuyingTradingAmount = array[9]
self.personalBuyingVolume = array[10]
self.foreignBuyingVolume = array[11]
self.organizationBuyingVolume = array[12]
self.personalBuyingTradingAmount = array[13]
self.foreignBuyingTradingAmount = array[14]
self.organizationBuyingTradingAmount = array[15]
self.personalSellingVolume = array[16]
self.foreignSellingVolume = array[17]
self.organizationSellingVolume = array[18]
self.personalSellingTradingAmount = array[19]
self.foreignSellingTradingAmount = array[20]
self.organizationSellingTradingAmount = array[21]
}
public static func symbols() -> [String] {
let i = try! OutputDetail(array: Array(repeating: "", count: 22))
return Mirror(reflecting: i).children.compactMap { $0.label }
}
public static func localizedSymbols() -> [String: String] {
[:]
}
}
}

View File

@@ -32,7 +32,7 @@ extension KissConsole {
}
static func productPriceUrl(productNo: String) -> URL {
let subPath = "data/\(productNo)"
let subPath = "data/\(productNo)/price"
let subFile = "\(subPath)/prices.csv"
let fileUrl = URL.currentDirectory().appending(path: subFile)
@@ -40,6 +40,15 @@ extension KissConsole {
return fileUrl
}
static func investorFileUrl(productNo: String, day: String) -> URL {
let subPath = "data/\(productNo)/investor"
let subFile = "\(subPath)/investor-\(day).csv"
let fileUrl = URL.currentDirectory().appending(path: subFile)
createSubpath(subPath)
return fileUrl
}
static func candleFileUrl(productNo: String, period: CandleFilePeriod, day: String) -> URL {
assert(day.count == 8)
let subPath = "data/\(productNo)/\(period.rawValue)"

View File

@@ -78,12 +78,12 @@ extension KissConsole {
}
candles.sort(by: { $0.stockBusinessDate > $1.stockBusinessDate })
guard let maxTime = candles.first?.stockBusinessDate else {
guard let recentDay = candles.first?.stockBusinessDate else {
print("No price items")
return false
}
let fileUrl = KissConsole.candleFileUrl(productNo: productNo, period: period.filePeriod, day: maxTime)
let fileUrl = KissConsole.candleFileUrl(productNo: productNo, period: period.filePeriod, day: recentDay)
try candles.writeCsv(toFile: fileUrl, localized: localized)
print("wrote \(fileUrl.lastPathComponent) with \(candles.count)")
@@ -159,6 +159,7 @@ extension KissConsole {
}
}
func checkHoliday(_ date: Date) async throws -> Bool {
guard await KissContext.shared.targetDate.yyyyMMdd != date.yyyyMMdd else {
return await KissContext.shared.isHoliday
@@ -167,6 +168,40 @@ extension KissConsole {
await KissContext.shared.updateHoliday(isHoliday, targetDate: date)
return isHoliday
}
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)
}
}
}

View File

@@ -0,0 +1,28 @@
//
// KissConsole+Investor.swift
// KissMeConsole
//
// Created by ened-book-m1 on 2023/06/11.
//
import Foundation
import KissMe
extension KissConsole {
func getInvestor(productNo: String) async throws -> Bool {
let result = try await account!.getInvestorVolume(productNo: productNo)
if let output = result.output {
print(output.count)
guard let recentDay = output.first?.stockBusinessDate else {
print("No investor items")
return false
}
let fileUrl = KissConsole.investorFileUrl(productNo: productNo, day: recentDay)
try output.writeCsv(toFile: fileUrl, localized: localized)
}
return true
}
}

View File

@@ -55,6 +55,10 @@ class KissConsole {
case candleWeek = "candle week"
case candleValidate = "candle validate"
//
case investor = "investor"
case investorAll = "investor all"
//
case loadShop = "load shop"
case updateShop = "update shop"
@@ -85,6 +89,8 @@ class KissConsole {
return true
case .candleValidate:
return false
case .investor, .investorAll:
return true
case .loadShop, .updateShop, .look:
return false
case .showcase:
@@ -203,6 +209,9 @@ class KissConsole {
case .candleDay: onCandleDay(args)
case .candleWeek: onCandleWeek(args)
case .candleValidate: onCandleValidate(args)
case .investor: await onInvestor(args)
case .investorAll: onInvestorAll(args)
case .loadShop: await onLoadShop()
case .updateShop: await onUpdateShop()
@@ -736,40 +745,6 @@ extension KissConsole {
}
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 {
@@ -788,6 +763,44 @@ extension KissConsole {
}
private func onInvestor(_ args: [String]) async {
let productNo: String? = (args.isEmpty ? currentShortCode: args[0])
guard let productNo = productNo else {
print("Invalid productNo")
return
}
do {
_ = try await getInvestor(productNo: productNo)
} catch {
print(error)
}
}
private func onInvestorAll(_ args: [String]) {
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
}
do {
let success = try await getInvestor(productNo: item.shortCode)
print("DONE \(success) \(item.shortCode)")
} catch {
print(error)
}
semaphore.signal()
}
semaphore.wait()
}
}
private func onCandle(_ args: [String]) async {
let productNo: String? = (args.isEmpty ? currentShortCode: args[0])
guard let productNo = productNo else {
@@ -983,6 +996,7 @@ extension KissConsole {
symbols.formUnion(PeriodPriceResult.OutputPrice.symbols())
symbols.formUnion(VolumeRankResult.OutputDetail.symbols())
symbols.formUnion(CurrentPriceResult.OutputDetail.symbols())
symbols.formUnion(InvestorVolumeResult.OutputDetail.symbols())
let newNames = symbols.sorted(by: { $0 < $1 })
let nameUrl = KissConsole.localNamesUrl

View File

@@ -32,6 +32,8 @@ 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** 파일로 저장.
`candle validate (기간)` | (기간) 타입의 모든 csv 파일에 대해서 데이터가 유효한지 검사. (기간) 으로는 **min**, **day**, **week** 을 지정하고, 생략되면 **min** 으로 간주.
`investor [PNO]` | 종목의 투자자 거래량 열람. PNO 은 생략 가능. **data/(PNO)/investor/investor-(yyyyMMdd).csv** 파일로 저장.
`investor all` | 모든 종목의 투자자 거래량 열람. **data/(PNO)/investor/investor-(yyyyMMdd).csv** 파일로 저장.
`load shop` | data/shop-products.csv 로부터 전체 상품을 로딩.
`update shop` | **금융위원회_KRX상장종목정보** 로부터 전체 상품을 얻어서 **data/shop-products.csv** 로 저장.
`look (상품명)` | (상품명) 에 해당되는 PNO 를 표시함.

View File

@@ -10,6 +10,7 @@
341F5ED42A0A8B9000962D48 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 341F5ED32A0A8B9000962D48 /* main.swift */; };
341F5F052A13B82F00962D48 /* test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 341F5F042A13B82F00962D48 /* test.swift */; };
341F5F092A1463A100962D48 /* KissConsole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 341F5F082A1463A100962D48 /* KissConsole.swift */; };
3435A7F22A35A8A900D604F1 /* KissConsole+Investor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3435A7F12A35A8A900D604F1 /* KissConsole+Investor.swift */; };
348168492A2F92AC00A50BD3 /* KissContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348168482A2F92AC00A50BD3 /* KissContext.swift */; };
348168692A3420BD00A50BD3 /* LocalContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348168682A3420BD00A50BD3 /* LocalContext.swift */; };
349327F72A20E3E300097063 /* Foundation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349327F62A20E3E300097063 /* Foundation+Extensions.swift */; };
@@ -48,6 +49,7 @@
341F5EDB2A0A8C4600962D48 /* KissMe.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = KissMe.framework; sourceTree = BUILT_PRODUCTS_DIR; };
341F5F042A13B82F00962D48 /* test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = test.swift; sourceTree = "<group>"; };
341F5F082A1463A100962D48 /* KissConsole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KissConsole.swift; sourceTree = "<group>"; };
3435A7F12A35A8A900D604F1 /* KissConsole+Investor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KissConsole+Investor.swift"; sourceTree = "<group>"; };
348168482A2F92AC00A50BD3 /* KissContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KissContext.swift; sourceTree = "<group>"; };
348168682A3420BD00A50BD3 /* LocalContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalContext.swift; sourceTree = "<group>"; };
349327F62A20E3E300097063 /* Foundation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Foundation+Extensions.swift"; sourceTree = "<group>"; };
@@ -96,6 +98,7 @@
341F5F082A1463A100962D48 /* KissConsole.swift */,
34D3680C2A280801005E6756 /* KissConsole+Candle.swift */,
349843202A242AC900E85B08 /* KissConsole+CSV.swift */,
3435A7F12A35A8A900D604F1 /* KissConsole+Investor.swift */,
349327F62A20E3E300097063 /* Foundation+Extensions.swift */,
);
name = KissMeConsole;
@@ -182,6 +185,7 @@
341F5ED42A0A8B9000962D48 /* main.swift in Sources */,
349327F72A20E3E300097063 /* Foundation+Extensions.swift in Sources */,
341F5F092A1463A100962D48 /* KissConsole.swift in Sources */,
3435A7F22A35A8A900D604F1 /* KissConsole+Investor.swift in Sources */,
341F5F052A13B82F00962D48 /* test.swift in Sources */,
348168692A3420BD00A50BD3 /* LocalContext.swift in Sources */,
349843212A242AC900E85B08 /* KissConsole+CSV.swift in Sources */,