716 lines
23 KiB
Swift
716 lines
23 KiB
Swift
//
|
|
// 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 var currentCandleShortCode: 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()
|
|
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
Task {
|
|
await onLoadShop()
|
|
semaphore.signal()
|
|
}
|
|
semaphore.wait()
|
|
}
|
|
|
|
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 .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)")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
guard currentCandleShortCode == nil else {
|
|
print("Already candle collecting")
|
|
return
|
|
}
|
|
currentCandleShortCode = productNo
|
|
defer {
|
|
currentCandleShortCode = nil
|
|
}
|
|
|
|
var nextTime = Date()
|
|
//nextTime.change(hour: 17, min: 0, sec: 0)
|
|
//nextTime.change(year: 2023, month: 5, day: 26)
|
|
//nextTime.change(hour: 9, min: 1, sec: 0)
|
|
|
|
var candles = [MinutePriceResult.OutputPrice]()
|
|
var count = 0
|
|
|
|
while true {
|
|
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, prices.isEmpty == false {
|
|
candles.append(contentsOf: prices)
|
|
if let last = prices.last {
|
|
if nextTime.yyyyMMdd != last.stockBusinessDate {
|
|
if let (yyyy, mm, dd) = last.stockBusinessDate.yyyyMMdd {
|
|
print("next: \(last.stockBusinessDate)")
|
|
nextTime.change(year: yyyy, month: mm, day: dd)
|
|
}
|
|
}
|
|
if let (hh, mm, ss) = last.stockConclusionTime.HHmmss {
|
|
print("next: \(last.stockConclusionTime) / \(hh) \(mm) \(ss)")
|
|
nextTime.change(hour: hh, min: mm-1, sec: ss)
|
|
if hh == 9, mm == 0, ss == 0 {
|
|
print("minute price finished")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
/// Limit to request queries with 5 tps
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
}
|
|
else {
|
|
print("minute price finished")
|
|
break
|
|
}
|
|
}
|
|
|
|
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) }
|
|
}
|
|
}
|