...
This commit is contained in:
parent
d4770af62f
commit
2bb94e2856
@ -27,6 +27,9 @@ dependencies {
|
||||
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
|
||||
implementation("io.ktor:ktor-client-logging:${ktorVersion}")
|
||||
|
||||
implementation("ch.qos.logback:logback-classic:1.4.11")
|
||||
|
||||
// Database (Exposed & SQLite)
|
||||
// H2 Database (네이티브 라이브러리 없는 순수 자바 DB)
|
||||
implementation("com.h2database:h2:2.2.224")
|
||||
|
||||
@ -3,21 +3,14 @@ import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import kotlinx.coroutines.launch
|
||||
import network.KisAuthService
|
||||
import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import model.AppConfig
|
||||
import model.KisSession
|
||||
import network.LlamaServerManager
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import ui.DashboardScreen
|
||||
import ui.SettingsScreen
|
||||
|
||||
@ -25,58 +18,58 @@ import ui.SettingsScreen
|
||||
enum class AppScreen { Settings, Dashboard }
|
||||
|
||||
fun main() = application {
|
||||
// 앱 경로 기준 리소스 위치 설정
|
||||
// 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치)
|
||||
val binPath = "./src/main/resources/bin/llama-server"
|
||||
val modelPath = "./src/main/resources/models/gemma-2-9b-it-Q4_K_M.gguf"
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LlamaServerManager.startServer(binPath, modelPath)
|
||||
}
|
||||
|
||||
Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매") {
|
||||
var currentScreen by remember { mutableStateOf(AppScreen.Settings) }
|
||||
// 1. 초기 상태를 null로 두어 로딩 전임을 표시
|
||||
var savedConfig by remember { mutableStateOf<AppConfig?>(null) }
|
||||
var token by remember { mutableStateOf("") }
|
||||
var selectedStockCode by remember { mutableStateOf<String?>(null) }
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 앱 시작 시 DB에서 마지막 설정 로드
|
||||
// 1. 앱 시작 시 DB에서 마지막 설정 로드 (KisSession에 주입)
|
||||
LaunchedEffect(Unit) {
|
||||
DatabaseFactory.init()
|
||||
val loaded = transaction {
|
||||
transaction {
|
||||
ConfigTable.selectAll().lastOrNull()?.let {
|
||||
AppConfig(
|
||||
appKey = it[ConfigTable.appKey],
|
||||
secretKey = it[ConfigTable.secretKey],
|
||||
accountNo = it[ConfigTable.accountNo],
|
||||
KisSession.config = AppConfig(
|
||||
realAppKey = it[ConfigTable.realAppKey],
|
||||
realSecretKey = it[ConfigTable.realSecretKey],
|
||||
realAccountNo = it[ConfigTable.realAccountNo],
|
||||
vtsAppKey = it[ConfigTable.vtsAppKey],
|
||||
vtsSecretKey = it[ConfigTable.vtsSecretKey],
|
||||
vtsAccountNo = it[ConfigTable.vtsAccountNo],
|
||||
isSimulation = it[ConfigTable.isSimulation],
|
||||
modelPath = it[ConfigTable.modelPath]
|
||||
)
|
||||
}
|
||||
}
|
||||
// 로드된 값이 있으면 업데이트, 없으면 기본 객체 생성
|
||||
savedConfig = loaded ?: AppConfig()
|
||||
isLoaded = true
|
||||
}
|
||||
|
||||
// savedConfig가 로드될 때까지 기다림 (깜빡임 방지 및 데이터 보장)
|
||||
if (savedConfig == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
if (!isLoaded) {
|
||||
// 로딩 중 표시
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
when (currentScreen) {
|
||||
AppScreen.Settings -> {
|
||||
SettingsScreen(
|
||||
initialConfig = savedConfig!!, // !! 사용하여 null 아님을 보장
|
||||
onAuthSuccess = { config, accessToken ->
|
||||
savedConfig = config
|
||||
token = accessToken
|
||||
onAuthSuccess = {
|
||||
// 2. 설정 및 인증 완료 시점의 처리
|
||||
val config = KisSession.config
|
||||
|
||||
// LLM 서버 시작 (설정된 모델 경로 사용)
|
||||
if (config.modelPath.isNotEmpty()) {
|
||||
LlamaServerManager.startServer(binPath, config.modelPath)
|
||||
}
|
||||
|
||||
// 대시보드로 화면 전환
|
||||
currentScreen = AppScreen.Dashboard
|
||||
}
|
||||
)
|
||||
}
|
||||
AppScreen.Dashboard -> {
|
||||
DashboardScreen(config = savedConfig!!, token = token)
|
||||
// 이제 모든 서비스는 KisSession.config를 전역 참조함
|
||||
DashboardScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import model.AppConfig
|
||||
import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.javatime.datetime
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
@ -7,13 +8,17 @@ import java.time.LocalDateTime
|
||||
// 1. 앱 설정 테이블
|
||||
object ConfigTable : Table("app_config") {
|
||||
val id = integer("id").autoIncrement()
|
||||
val appKey = varchar("app_key", 255)
|
||||
val secretKey = varchar("secret_key", 255)
|
||||
val accountNo = varchar("account_no", 20)
|
||||
val isSimulation = bool("is_simulation")
|
||||
val modelPath = varchar("model_path", 512).default("") // 이 라인이 있어야 합니다.
|
||||
val realAppKey = varchar("real_app_key", 255).default("")
|
||||
val realSecretKey = varchar("real_secret_key", 255).default("")
|
||||
val realAccountNo = varchar("real_account_no", 20).default("")
|
||||
val vtsAppKey = varchar("vts_app_key", 255).default("")
|
||||
val vtsSecretKey = varchar("vts_secret_key", 255).default("")
|
||||
val vtsAccountNo = varchar("vts_account_no", 20).default("")
|
||||
val isSimulation = bool("is_simulation").default(true)
|
||||
val modelPath = varchar("model_path", 512).default("")
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
// 2. 거래 내역 테이블 (대량 데이터용)
|
||||
object TradeLogTable : Table("trade_logs") {
|
||||
val id = long("id").autoIncrement()
|
||||
@ -56,14 +61,40 @@ object DatabaseFactory {
|
||||
}
|
||||
}
|
||||
|
||||
// fun fetchRecentLogs(limit: Int = 50): List<TradeLog> {
|
||||
// return transaction {
|
||||
// TradeLogTable.selectAll()
|
||||
// .orderBy(TradeLogTable.timestamp to SortOrder.DESC)
|
||||
// .limit(limit)
|
||||
// .map {
|
||||
// // ResultRow를 객체로 변환
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
fun findConfigByAccount(accountNo: String): AppConfig? {
|
||||
return transaction {
|
||||
ConfigTable.select {
|
||||
(ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo)
|
||||
}.lastOrNull()?.let {
|
||||
AppConfig(
|
||||
realAppKey = it[ConfigTable.realAppKey],
|
||||
realSecretKey = it[ConfigTable.realSecretKey],
|
||||
realAccountNo = it[ConfigTable.realAccountNo],
|
||||
vtsAppKey = it[ConfigTable.vtsAppKey],
|
||||
vtsSecretKey = it[ConfigTable.vtsSecretKey],
|
||||
vtsAccountNo = it[ConfigTable.vtsAccountNo],
|
||||
isSimulation = it[ConfigTable.isSimulation],
|
||||
modelPath = it[ConfigTable.modelPath]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveConfig(config: AppConfig) {
|
||||
transaction {
|
||||
// 기존 설정을 모두 지우고 최신 설정 하나만 유지
|
||||
ConfigTable.deleteAll()
|
||||
ConfigTable.insert {
|
||||
it[realAppKey] = config.realAppKey
|
||||
it[realSecretKey] = config.realSecretKey
|
||||
it[vtsAppKey] = config.vtsAppKey
|
||||
it[vtsSecretKey] = config.vtsSecretKey
|
||||
it[realAccountNo] = config.realAccountNo
|
||||
it[vtsAccountNo] = config.vtsAccountNo
|
||||
it[isSimulation] = config.isSimulation
|
||||
it[modelPath] = config.modelPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,11 +1,52 @@
|
||||
package model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class AppConfig(
|
||||
val appKey: String = "",
|
||||
val secretKey: String = "",
|
||||
val accountNo: String = "",
|
||||
// [DB 저장 데이터]
|
||||
// 실전 3종
|
||||
val realAppKey: String = "",
|
||||
val realSecretKey: String = "",
|
||||
val realAccountNo: String = "",
|
||||
|
||||
// 모의 3종
|
||||
val vtsAppKey: String = "",
|
||||
val vtsSecretKey: String = "",
|
||||
val vtsAccountNo: String = "",
|
||||
|
||||
// [세션 데이터 - 메모리에서만 관리]
|
||||
var marketToken: String = "",
|
||||
var marketTokenExpiredAt: LocalDateTime? = null, // 만료 시간 추가
|
||||
|
||||
var tradeToken: String = "",
|
||||
var tradeTokenExpiredAt: LocalDateTime? = null,
|
||||
|
||||
var websocketToken: String = "",
|
||||
val isSimulation: Boolean = true,
|
||||
val modelPath: String = "" // 추가된 필드
|
||||
)
|
||||
val modelPath: String = "") {
|
||||
|
||||
val accountNo : String
|
||||
get() {
|
||||
return if (isSimulation) vtsAccountNo else realAccountNo
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// [신규] 전역에서 참조할 단일 세션 객체
|
||||
object KisSession {
|
||||
var config: AppConfig = AppConfig()
|
||||
|
||||
// 시장 데이터 토큰 유효성 검사 (만료 5분 전부터는 유효하지 않은 것으로 간주)
|
||||
fun isMarketTokenValid(): Boolean {
|
||||
return config.marketToken.isNotEmpty() &&
|
||||
config.marketTokenExpiredAt?.isAfter(LocalDateTime.now().plusMinutes(5)) ?: false
|
||||
}
|
||||
|
||||
// 매매용 토큰 유효성 검사
|
||||
fun isTradeTokenValid(): Boolean {
|
||||
return config.tradeToken.isNotEmpty() &&
|
||||
config.tradeTokenExpiredAt?.isAfter(LocalDateTime.now().plusMinutes(5)) ?: false
|
||||
}
|
||||
}
|
||||
@ -17,5 +17,5 @@ data class TokenResponse(
|
||||
val access_token: String,
|
||||
val access_token_token_expired: String? = null,
|
||||
val token_type: String? = null,
|
||||
val expires_in: Int? = null
|
||||
val expires_in: Long = 0L
|
||||
)
|
||||
@ -22,7 +22,7 @@ data class CandleData(
|
||||
val stck_hgpr: String, // 고가
|
||||
val stck_lwpr: String, // 저가
|
||||
val stck_clpr: String, // 종가
|
||||
val acml_vol: String // 누적 거래량
|
||||
val acml_vol: String ="" // 누적 거래량
|
||||
)
|
||||
@Serializable
|
||||
data class OverseasCandleData(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@ -30,28 +31,53 @@ data class BalanceSummary(
|
||||
)
|
||||
@Serializable
|
||||
data class RankingResponse(
|
||||
var rt_cd : String,
|
||||
var msg1 : String,
|
||||
var msg_cd : String,
|
||||
val output1: List<RankingStock> = emptyList(),
|
||||
val output: List<RankingStock> = emptyList()
|
||||
)
|
||||
|
||||
enum class RankingType(val code: String, val title: String) {
|
||||
RISE("0", "상승"),
|
||||
FALL("1", "하락"),
|
||||
VOLUME("2", "거래량"),
|
||||
AMOUNT("3", "금액"),
|
||||
OVERTIME("4", "시간외"), // 웹 소스의 시간외 상승 TR 연동
|
||||
SHORT_HOT("5", "단기추천") // 단기 과열 및 추천 종목
|
||||
) {
|
||||
val list = output + output1 + emptyList()
|
||||
}
|
||||
|
||||
enum class RankingType(
|
||||
val title: String,
|
||||
val trId: String,
|
||||
val scrNo: String,
|
||||
val path: String,
|
||||
val sortCode: String // 추가: 각 TR ID에 맞는 정렬 코드
|
||||
) {
|
||||
VOLUME("거래량순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank", "0"),
|
||||
VALUE("거래대금순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank", "3"),
|
||||
RISE("상승률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation", "0"),
|
||||
FALL("하락률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation", "1"),
|
||||
// MARKET_CAP("시가총액순", "FHPST01740000", "20174", "/uapi/domestic-stock/v1/quotations/market-cap", "0"),
|
||||
// HTS_TOP20("HTS조회상위", "HHMCM000100C0", "20175", "/uapi/domestic-stock/v1/ranking/hts-top-view", "0"),
|
||||
// 링크로 전달주신 추가 기능 보완
|
||||
VOLUME_POWER("체결강도순", "FHPST01680000", "20168", "/uapi/domestic-stock/v1/ranking/volume-power", "0"),
|
||||
// BEFORE("장전예상", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "0"),
|
||||
// AFTER("장후예상", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "1")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RankingStock(
|
||||
val hts_kor_isnm: String = "", // 종목명
|
||||
val hts_kor_alph_nm: String = "", // 종목명
|
||||
val mkrtc_objt_iscd: String = "", // 종목코드
|
||||
val mksc_shrn_iscd: String = "", // 종목코드
|
||||
val stck_prpr: String = "0", // 현재가
|
||||
val prdy_ctrt: String = "0.0" // 등락률
|
||||
)
|
||||
val prdy_ctrt: String = "0.0", // 등락률
|
||||
val mrkt_div_cls_code : String = "J",
|
||||
) {
|
||||
val name : String
|
||||
get() = hts_kor_isnm ?: hts_kor_alph_nm ?: mkrtc_objt_iscd ?: ""
|
||||
val code : String
|
||||
get() = mksc_shrn_iscd ?: mkrtc_objt_iscd ?: hts_kor_isnm ?: ""
|
||||
}
|
||||
@Serializable
|
||||
data class OverseasRankingResponse(
|
||||
val rt_cd: String = "",
|
||||
val msg1: String = "",
|
||||
val output: List<OverseasRankingStock> = emptyList()
|
||||
)
|
||||
|
||||
@ -70,4 +96,23 @@ data class OverseasRankingStock(
|
||||
stck_prpr = last,
|
||||
prdy_ctrt = rate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UnifiedStockHolding(
|
||||
val code: String, // 종목코드
|
||||
val name: String, // 종목명
|
||||
val quantity: String, // 보유수량
|
||||
val avgPrice: String, // 매입단가
|
||||
val currentPrice: String, // 현재가
|
||||
val profitRate: String, // 수익률
|
||||
val evalAmount: String, // 평가금액
|
||||
val isDomestic: Boolean // 국내/해외 구분
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UnifiedBalance(
|
||||
val totalAsset: String, // 총 평가자산
|
||||
val totalProfitRate: String, // 총 수익률
|
||||
val holdings: List<UnifiedStockHolding> // 통합 보유 종목 리스트
|
||||
)
|
||||
|
||||
@ -4,65 +4,83 @@ import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.DEFAULT
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import model.KisSession
|
||||
import model.TokenRequest
|
||||
import model.TokenResponse
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class KisAuthService {
|
||||
private val client = HttpClient(CIO) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true // 기본값(grant_type)이 누락되지 않도록 설정
|
||||
encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
|
||||
})
|
||||
}
|
||||
// 디버깅을 위해 로그 추가 (인텔 맥 콘솔에서 전송 데이터 확인 가능)
|
||||
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
|
||||
install(Logging) {
|
||||
level = LogLevel.BODY
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBaseUrl(isSimulation: Boolean): String {
|
||||
return if (isSimulation) {
|
||||
"https://openapivts.koreainvestment.com:29443" // 'openapi' 추가됨
|
||||
private fun getBaseUrl(isSimulation: Boolean) =
|
||||
if (isSimulation) "https://openapivts.koreainvestment.com:29443"
|
||||
else "https://openapi.koreainvestment.com:9443"
|
||||
|
||||
/**
|
||||
* 실전(시세용)과 매매(모의/실전 선택) 토큰을 모두 갱신합니다.
|
||||
*/
|
||||
suspend fun refreshAllTokens(): Boolean = coroutineScope {
|
||||
val config = KisSession.config
|
||||
|
||||
// 1. 실전 시세용 토큰 발급 (Market Token)
|
||||
val marketTokenJob = async { fetchAccessToken(config.realAppKey, config.realSecretKey, false) }
|
||||
|
||||
// 2. 매매용 토큰 발급 (Trade Token - 설정에 따라 VTS 또는 Real 사용)
|
||||
val tradeTokenJob = async {
|
||||
if (config.isSimulation) fetchAccessToken(config.vtsAppKey, config.vtsSecretKey, true)
|
||||
else marketTokenJob.await() // 실전 매매면 시세용 토큰과 동일함
|
||||
}
|
||||
|
||||
val mResult = marketTokenJob.await()
|
||||
val tResult = tradeTokenJob.await()
|
||||
|
||||
if (mResult.isSuccess && tResult.isSuccess) {
|
||||
val mData = mResult.getOrThrow()
|
||||
val tData = tResult.getOrThrow()
|
||||
|
||||
// KisSession 업데이트
|
||||
KisSession.config = KisSession.config.copy(
|
||||
marketToken = mData.access_token,
|
||||
marketTokenExpiredAt = LocalDateTime.now().plusSeconds(mData.expires_in),
|
||||
tradeToken = tData.access_token,
|
||||
tradeTokenExpiredAt = LocalDateTime.now().plusSeconds(tData.expires_in),
|
||||
)
|
||||
true
|
||||
} else {
|
||||
"https://openapi.koreainvestment.com:9443"
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAccessToken(
|
||||
appKey: String,
|
||||
secretKey: String,
|
||||
isSimulation: Boolean
|
||||
): Result<TokenResponse> {
|
||||
private suspend fun fetchAccessToken(appKey: String, secretKey: String, isSim: Boolean): Result<TokenResponse> {
|
||||
return try {
|
||||
val url = "${getBaseUrl(isSimulation)}/oauth2/tokenP"
|
||||
|
||||
val response = client.post(url) {
|
||||
// 헤더 설정 (매우 중요)
|
||||
val response = client.post("${getBaseUrl(isSim)}/oauth2/tokenP") {
|
||||
contentType(ContentType.Application.Json)
|
||||
|
||||
// 요청 바디 (TokenRequest 객체 전달)
|
||||
setBody(TokenRequest(
|
||||
"client_credentials",
|
||||
appKey,
|
||||
secretKey
|
||||
))
|
||||
}
|
||||
|
||||
if (response.status == HttpStatusCode.OK) {
|
||||
Result.success(response.body())
|
||||
} else {
|
||||
val errorBody = response.bodyAsText()
|
||||
println("HTTP ${response.status}: $errorBody")
|
||||
Result.failure(Exception("HTTP ${response.status}: $errorBody"))
|
||||
setBody(TokenRequest("client_credentials", appKey, secretKey))
|
||||
}
|
||||
if (response.status == HttpStatusCode.OK) Result.success(response.body())
|
||||
else Result.failure(Exception("인증 실패: ${response.status}"))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
@ -9,167 +9,333 @@ import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import model.AppConfig
|
||||
import model.CandleData
|
||||
import model.ChartResponse
|
||||
import model.OverseasChartResponse
|
||||
import model.OverseasRankingResponse
|
||||
import model.RankingResponse
|
||||
import model.RankingStock
|
||||
import model.RankingType
|
||||
import model.StockBalanceResponse
|
||||
|
||||
class KisTradeService(private val isSimulation: Boolean) {
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import model.*
|
||||
|
||||
class KisTradeService {
|
||||
private val client = HttpClient(CIO) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true })
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
|
||||
})
|
||||
}
|
||||
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.INFO // 상세 로그 원하면 LogLevel.BODY
|
||||
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun fetchDomesticPreviousDayRanking(token: String, config: AppConfig): Result<List<RankingStock>> {
|
||||
return try {
|
||||
// [수정] URL 경로 확인: /uapi/domestic-stock/v1/quotations/pdy-rank
|
||||
val url = "$baseUrl/uapi/domestic-stock/v1/quotations/pdy-rank"
|
||||
println("📡 [REQ] 국내 전일 등락 조회: $url")
|
||||
|
||||
val response = client.get(url) {
|
||||
header("authorization", "Bearer $token")
|
||||
header("appkey", config.appKey)
|
||||
header("appsecret", config.secretKey)
|
||||
header("tr_id", "HHPST01710000")
|
||||
header("custtype", "P")
|
||||
header("Content-Type", "application/json; charset=utf-8") // 헤더 명시
|
||||
|
||||
parameter("fid_cond_mrkt_div_code", "J")
|
||||
parameter("fid_cond_scr_div_code", "20171")
|
||||
parameter("fid_input_iscd", "0000")
|
||||
parameter("fid_rank_sort_cls_code", "0")
|
||||
parameter("fid_input_cntstr_value", "")
|
||||
parameter("fid_prc_cls_code", "1")
|
||||
}
|
||||
|
||||
if (response.status != HttpStatusCode.OK) {
|
||||
val errorBody = response.bodyAsText()
|
||||
println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody")
|
||||
return Result.failure(Exception("HTTP ${response.status}: $errorBody"))
|
||||
}
|
||||
|
||||
val body = response.body<RankingResponse>()
|
||||
Result.success(body.output.take(20))
|
||||
} catch (e: Exception) {
|
||||
println("❌ [ERR] 국내 전일 등락 실패: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
private val prodUrl = "https://openapi.koreainvestment.com:9443"
|
||||
private val vtsUrl = "https://openapivts.koreainvestment.com:29443"
|
||||
|
||||
/**
|
||||
* [2] 국내 실시간 마켓 랭킹 (장중용)
|
||||
* TR ID: FHPST01700000
|
||||
* [1] 통합 잔고 조회 (국내 + 해외 합산)
|
||||
*/
|
||||
suspend fun fetchMarketRanking(
|
||||
token: String,
|
||||
config: AppConfig,
|
||||
type: RankingType,
|
||||
isDomestic: Boolean
|
||||
): Result<List<RankingStock>> {
|
||||
if (!isDomestic) return Result.failure(Exception("Domestic only"))
|
||||
suspend fun fetchIntegratedBalance(): Result<UnifiedBalance> = coroutineScope {
|
||||
val config = KisSession.config
|
||||
|
||||
return try {
|
||||
// [수정] URL 경로 확인: /uapi/domestic-stock/v1/quotations/volume-rank
|
||||
val url = "$baseUrl/uapi/domestic-stock/v1/quotations/volume-rank"
|
||||
println("📡 [REQ] 국내 실시간 랭킹 조회: $url")
|
||||
// 국내와 해외 잔고를 비동기로 동시 호출
|
||||
val domesticJob = async { fetchDomesticRawBalance() }
|
||||
val overseasJob = async { fetchOverseasRawBalance() }
|
||||
|
||||
val response = client.get(url) {
|
||||
header("authorization", "Bearer $token")
|
||||
header("appkey", config.appKey)
|
||||
header("appsecret", config.secretKey)
|
||||
header("tr_id", "FHPST01700000")
|
||||
header("custtype", "P")
|
||||
header("Content-Type", "application/json; charset=utf-8")
|
||||
try {
|
||||
val domRes = domesticJob.await().getOrNull()
|
||||
val ovsRes = overseasJob.await().getOrNull()
|
||||
|
||||
parameter("fid_cond_mrkt_div_code", "J")
|
||||
parameter("fid_cond_scr_div_code", "20170")
|
||||
parameter("fid_input_iscd", "0000")
|
||||
parameter("fid_div_cls_code", "0")
|
||||
parameter("fid_rank_sort_cls_code", type.code)
|
||||
parameter("fid_etc_cls_code", "0")
|
||||
val combinedHoldings = mutableListOf<UnifiedStockHolding>()
|
||||
|
||||
// 국내 종목 매핑
|
||||
domRes?.output1?.forEach {
|
||||
combinedHoldings.add(UnifiedStockHolding(
|
||||
code = it.pdno, name = it.prdt_name, quantity = it.hldg_qty,
|
||||
avgPrice = it.pchs_avg_pric, currentPrice = it.prpr,
|
||||
profitRate = it.evlu_pfls_rt, evalAmount = it.evlu_amt, isDomestic = true
|
||||
))
|
||||
}
|
||||
|
||||
if (response.status != HttpStatusCode.OK) {
|
||||
val errorBody = response.bodyAsText()
|
||||
println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody")
|
||||
return Result.failure(Exception("HTTP ${response.status}"))
|
||||
// 해외 종목 매핑 (해외 API 응답 모델 구조에 따라 필드 매핑)
|
||||
ovsRes?.output1?.forEach {
|
||||
combinedHoldings.add(UnifiedStockHolding(
|
||||
code = it.pdno, name = it.prdt_name, quantity = it.hldg_qty,
|
||||
avgPrice = it.pchs_avg_pric, currentPrice = it.prpr,
|
||||
profitRate = it.evlu_pfls_rt, evalAmount = it.evlu_amt, isDomestic = false
|
||||
))
|
||||
}
|
||||
|
||||
val body = response.body<RankingResponse>()
|
||||
Result.success(body.output.take(20))
|
||||
} catch (e: Exception) {
|
||||
println("❌ [ERR] 실시간 랭킹 실패: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
private val prodBaseUrl = "https://openapi.koreainvestment.com:9443"
|
||||
// 해외 실시간/전일 등락 상위
|
||||
suspend fun fetchOverseasRanking(token: String, config: AppConfig): Result<List<RankingStock>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/uapi/overseas-stock/v1/quotations/rank-fluctuation") {
|
||||
header("authorization", "Bearer $token")
|
||||
header("appkey", config.appKey)
|
||||
header("appsecret", config.secretKey)
|
||||
header("tr_id", "HHDFS76240000")
|
||||
parameter("EXCD", "NAS") // 나스닥 기준
|
||||
parameter("GUBN", "0") // 상승률순
|
||||
}
|
||||
val body = response.body<OverseasRankingResponse>()
|
||||
Result.success(body.output.map { it.toRankingStock() }.take(20))
|
||||
val totalAmt = (domRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) +
|
||||
(ovsRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L)
|
||||
|
||||
Result.success(UnifiedBalance(
|
||||
totalAsset = String.format("%,d", totalAmt),
|
||||
totalProfitRate = domRes?.output2?.firstOrNull()?.evlu_pfls_rt ?: "0.0",
|
||||
holdings = combinedHoldings
|
||||
))
|
||||
} catch (e: Exception) { Result.failure(e) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [통합 순위 조회] 국내/해외 분기 처리
|
||||
*/
|
||||
suspend fun fetchMarketRanking(type: RankingType, isDomestic: Boolean): Result<List<RankingStock>> {
|
||||
return if (isDomestic) {
|
||||
fetchDomesticRanking(type)
|
||||
} else {
|
||||
fetchOverseasRanking(type)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private val baseUrl = if (isSimulation) "https://openapivts.koreainvestment.com:29443"
|
||||
else "https://openapi.koreainvestment.com:9443"
|
||||
|
||||
suspend fun fetchBalance(
|
||||
token: String,
|
||||
appKey: String,
|
||||
appSecret: String,
|
||||
accountNo: String
|
||||
): Result<StockBalanceResponse> {
|
||||
/**
|
||||
* [국내 주식 순위] 명세서 기반 파라미터 최적화
|
||||
*/
|
||||
private suspend fun fetchDomesticRanking(type: RankingType): Result<List<RankingStock>> {
|
||||
val config = KisSession.config
|
||||
return try {
|
||||
val cleanAccount = accountNo.filter { it.isDigit() }
|
||||
if (cleanAccount.length != 10) {
|
||||
return Result.failure(Exception("계좌번호 10자리를 입력해주세요."))
|
||||
val response = client.get("$prodUrl${type.path}") {
|
||||
header("authorization", "Bearer ${config.marketToken}")
|
||||
header("appkey", config.realAppKey)
|
||||
header("appsecret", config.realSecretKey)
|
||||
header("tr_id", type.trId)
|
||||
header("custtype", "P")
|
||||
|
||||
parameter("FID_COND_MRKT_DIV_CODE", "J")
|
||||
parameter("FID_COND_SCR_DIV_CODE", type.scrNo)
|
||||
parameter("FID_INPUT_ISCD", "0000") // 전체 시장
|
||||
parameter("FID_DIV_CLS_CODE", "0") // 전체
|
||||
|
||||
parameter("FID_ETC_CLS_CODE", "0")
|
||||
parameter("FID_PRC_CLS_CODE", "0")
|
||||
when(type) {
|
||||
RankingType.VALUE -> {
|
||||
parameter("FID_BLNG_CLS_CODE", type.sortCode)
|
||||
}
|
||||
RankingType.VOLUME -> {
|
||||
parameter("FID_BLNG_CLS_CODE",type.sortCode)
|
||||
}
|
||||
RankingType.FALL -> {
|
||||
parameter("FID_RANK_SORT_CLS_CODE", type.sortCode)
|
||||
|
||||
}
|
||||
RankingType.RISE -> {
|
||||
parameter("FID_RANK_SORT_CLS_CODE", type.sortCode)
|
||||
}
|
||||
// RankingType.AFTER -> {
|
||||
// parameter("FID_MKOP_CLS_CODE", type.sortCode)
|
||||
// }
|
||||
// RankingType.BEFORE -> {
|
||||
// parameter("FID_MKOP_CLS_CODE", type.sortCode)
|
||||
// }
|
||||
else -> {
|
||||
|
||||
}
|
||||
}
|
||||
parameter("FID_PBMN", "")
|
||||
parameter("FID_APLY_RANG_PRC_1", "")
|
||||
parameter("FID_TRGT_CLS_CODE", "11111111")
|
||||
parameter("FID_TRGT_EXLS_CLS_CODE", "000000")
|
||||
parameter("FID_RSFL_RATE2", "")
|
||||
parameter("FID_RSFL_RATE1", "")
|
||||
parameter("FID_INPUT_CNT_1", "0")
|
||||
parameter("FID_INPUT_PRICE_1", "")
|
||||
parameter("FID_INPUT_PRICE_2", "")
|
||||
parameter("FID_VOL_CNT", "")
|
||||
parameter("FID_INPUT_DATE_1", "")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 상승/하락률 순위(HHPST01710000)일 경우 추가 파라미터
|
||||
if (type.trId == "HHPST01710000") {
|
||||
parameter("fid_diff_div_code", "00") // 00: 전일 대비
|
||||
}
|
||||
}
|
||||
val cano = cleanAccount.take(8)
|
||||
val acntCd = cleanAccount.takeLast(2)
|
||||
val body = response.body<RankingResponse>()
|
||||
if (body.rt_cd == "0") Result.success(body.list) else Result.failure(Exception(body.msg1))
|
||||
} catch (e: Exception) { Result.failure(e) }
|
||||
}
|
||||
|
||||
// 웹 소스(KisApiService.kt) 54행 로직 적용
|
||||
// 실전: TTTC8434R / 모의: VTTC8434R (VTRP 아님)
|
||||
val trId = if (isSimulation) "VTTC8434R" else "TTTC8434R"
|
||||
/**
|
||||
* [해외 주식 순위] 모델 매핑 오류 수정
|
||||
*/
|
||||
private suspend fun fetchOverseasRanking(type: RankingType): Result<List<RankingStock>> {
|
||||
val config = KisSession.config
|
||||
val path = "/uapi/overseas-stock/v1/quotations/rank-fluctuation"
|
||||
val trId = "HHDFS76240000"
|
||||
|
||||
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
|
||||
header("authorization", "Bearer $token")
|
||||
header("appkey", appKey)
|
||||
header("appsecret", appSecret)
|
||||
return try {
|
||||
val response = client.get("$prodUrl$path") {
|
||||
header("authorization", "Bearer ${config.marketToken}")
|
||||
header("appkey", config.realAppKey)
|
||||
header("appsecret", config.realSecretKey)
|
||||
header("tr_id", trId)
|
||||
header("custtype", "P")
|
||||
|
||||
// 웹 소스 61~72행 파라미터 명칭과 동일하게 세팅
|
||||
parameter("CANO", cano)
|
||||
parameter("ACNT_PRDT_CD", acntCd)
|
||||
parameter("AFHR_FLPR_YN", "N") // 명칭 수정: AFHR_FLG -> AFHR_FLPR_YN
|
||||
parameter("OFL_YN", "N") // 명칭 수정: OFL_FLG -> OFL_YN
|
||||
parameter("EXCD", "NAS") // 기본 나스닥
|
||||
|
||||
val gubn = when (type) {
|
||||
RankingType.RISE -> "0"
|
||||
RankingType.FALL -> "1"
|
||||
RankingType.VOLUME -> "2"
|
||||
RankingType.VALUE -> "3"
|
||||
else -> "0"
|
||||
}
|
||||
parameter("GUBN", gubn)
|
||||
}
|
||||
|
||||
// [수정] OverseasRankingResponse로 정확히 파싱 후 변환
|
||||
val body = response.body<OverseasRankingResponse>()
|
||||
if (body.rt_cd == "0") {
|
||||
Result.success(body.output.map { it.toRankingStock() })
|
||||
} else {
|
||||
Result.failure(Exception("해외 랭킹 에러: ${body.msg1}"))
|
||||
}
|
||||
} catch (e: Exception) { Result.failure(e) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [3] 통합 주문 (지정가/시장가 매수/매도)
|
||||
*/
|
||||
suspend fun postOrder(
|
||||
stockCode: String,
|
||||
qty: String,
|
||||
price: String, // "0" 이면 시장가
|
||||
isBuy: Boolean
|
||||
): Result<String> {
|
||||
val config = KisSession.config
|
||||
val isDomestic = stockCode.length == 6 && stockCode.all { it.isDigit() }
|
||||
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
|
||||
|
||||
val trId = when {
|
||||
isDomestic && config.isSimulation -> if (isBuy) "VTRP0001U" else "VTRP0002U"
|
||||
isDomestic && !config.isSimulation -> if (isBuy) "TTTC0802U" else "TTTC0801U"
|
||||
!isDomestic && config.isSimulation -> if (isBuy) "VTTT3001U" else "VTTT3002U"
|
||||
else -> if (isBuy) "TTTS3001U" else "TTTS3002U"
|
||||
}
|
||||
|
||||
return try {
|
||||
val response = client.post("$baseUrl/uapi/${if(isDomestic) "domestic" else "overseas"}-stock/v1/trading/order-cash") {
|
||||
header("authorization", "Bearer ${config.tradeToken}")
|
||||
header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
|
||||
header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey)
|
||||
header("tr_id", trId)
|
||||
header("Content-Type", "application/json")
|
||||
|
||||
setBody(mapOf(
|
||||
"CANO" to config.accountNo.take(8),
|
||||
"ACNT_PRDT_CD" to config.accountNo.takeLast(2),
|
||||
"PDNO" to stockCode,
|
||||
"ORD_DVSN" to if (price == "0") "01" else "00",
|
||||
"ORD_QTY" to qty,
|
||||
"ORD_UNPR" to price
|
||||
))
|
||||
}
|
||||
val body = response.body<Map<String, Any>>()
|
||||
if (body["rt_cd"] == "0") Result.success("✅ 주문 성공: ${body["msg1"]}")
|
||||
else Result.failure(Exception("${body["msg1"]}"))
|
||||
} catch (e: Exception) { Result.failure(e) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [4] 웹소켓 승인키(Approval Key) 발급
|
||||
*/
|
||||
suspend fun refreshWebsocketKey(): Boolean {
|
||||
val config = KisSession.config
|
||||
return try {
|
||||
val response = client.post("$prodUrl/oauth2/Approval") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(mapOf("grant_type" to "client_credentials", "appkey" to config.realAppKey, "secretkey" to config.realSecretKey))
|
||||
}
|
||||
if (response.status == HttpStatusCode.OK) {
|
||||
val approvalKey = response.body<Map<String, String>>()["approval_key"]
|
||||
if (approvalKey != null) {
|
||||
KisSession.config = KisSession.config.copy(websocketToken = approvalKey)
|
||||
true
|
||||
} else false
|
||||
} else false
|
||||
} catch (e: Exception) { false }
|
||||
}
|
||||
|
||||
/**
|
||||
* [5] 차트 데이터 조회 (일봉 기준)
|
||||
*/
|
||||
suspend fun fetchChartData(stockCode: String, isDomestic: Boolean): Result<List<CandleData>> {
|
||||
val config = KisSession.config
|
||||
// 국내 주식 분봉 조회 TR ID: FHKST03010200
|
||||
val trId = if (isDomestic) "FHKST03010200" else "HHDFS76240000"
|
||||
val path = if (isDomestic)
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
|
||||
else "/uapi/overseas-stock/v1/quotations/inquire-time-itemchartprice"
|
||||
|
||||
return try {
|
||||
val response = client.get("$prodUrl$path") {
|
||||
header("authorization", "Bearer ${config.marketToken}")
|
||||
header("appkey", config.realAppKey)
|
||||
header("appsecret", config.realSecretKey)
|
||||
header("tr_id", trId)
|
||||
header("custtype", "P")
|
||||
header("content-type", "application/json; charset=utf-8")
|
||||
|
||||
parameter("FID_ETC_CLS_CODE", "")
|
||||
parameter("FID_COND_MRKT_DIV_CODE", "J")
|
||||
parameter("FID_INPUT_ISCD", stockCode)
|
||||
parameter("FID_INPUT_HOUR_1", "153000") // 장 마감 시간까지
|
||||
parameter("FID_PW_DATA_INCU_YN", "Y") // 전일 데이터 포함 여부
|
||||
}
|
||||
|
||||
// API 응답에서 output2(캔들 리스트)를 CandleData로 변환 (역순으로 오므로 reverse 필요)
|
||||
val body = response.body<JsonObject>()
|
||||
val output2 = body["output2"]?.jsonArray
|
||||
|
||||
val candles = output2?.map { element ->
|
||||
val obj = element.jsonObject
|
||||
CandleData(
|
||||
stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "",
|
||||
stck_clpr = obj["stck_prpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가
|
||||
stck_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0",
|
||||
stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0",
|
||||
stck_lwpr = obj["stck_lwpr"]?.jsonPrimitive?.content ?: "0",
|
||||
acml_vol = obj["cntg_vol"]?.jsonPrimitive?.content ?: "0" // 필수 필드 누락 방지
|
||||
)
|
||||
}?.reversed() ?: emptyList()
|
||||
|
||||
Result.success(candles)
|
||||
} catch (e: Exception) { Result.failure(e) }
|
||||
}
|
||||
|
||||
// --- 내부 Raw 호출용 (통합 잔고에서 사용) ---
|
||||
private suspend fun fetchDomesticRawBalance(): Result<StockBalanceResponse> {
|
||||
val config = KisSession.config
|
||||
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
|
||||
val trId = if (config.isSimulation) "VTTC8434R" else "TTTC8434R"
|
||||
return try {
|
||||
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
|
||||
header("authorization", "Bearer ${config.tradeToken}")
|
||||
header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
|
||||
header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey)
|
||||
header("tr_id", trId)
|
||||
parameter("CANO", config.accountNo.take(8))
|
||||
parameter("ACNT_PRDT_CD", config.accountNo.takeLast(2))
|
||||
parameter("AFHR_FLPR_YN", "N")
|
||||
parameter("OFL_YN", "N")
|
||||
parameter("INQR_DVSN", "02")
|
||||
parameter("UNPR_DVSN", "01")
|
||||
parameter("FUND_STTL_ICLD_YN", "N")
|
||||
@ -178,143 +344,12 @@ class KisTradeService(private val isSimulation: Boolean) {
|
||||
parameter("CTX_AREA_FK100", "")
|
||||
parameter("CTX_AREA_NK100", "")
|
||||
}
|
||||
|
||||
if (response.status == HttpStatusCode.OK) {
|
||||
val body = response.body<StockBalanceResponse>()
|
||||
if (body.rt_cd == "0") {
|
||||
Result.success(body)
|
||||
} else {
|
||||
Result.failure(Exception("API 에러: ${body.msg1} (코드:${body.rt_cd})"))
|
||||
}
|
||||
} else {
|
||||
Result.failure(Exception("HTTP 오류: ${response.status}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchChartData(
|
||||
token: String,
|
||||
appKey: String,
|
||||
appSecret: String,
|
||||
stockCode: String
|
||||
): Result<ChartResponse> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice") {
|
||||
header("authorization", "Bearer $token")
|
||||
header("appkey", appKey)
|
||||
header("appsecret", appSecret)
|
||||
header("tr_id", "FHKST03010100") // 국내주식 기간별 시세 TR ID
|
||||
header("custtype", "P")
|
||||
|
||||
parameter("FID_COND_SCR_DIV_CODE", "16.4")
|
||||
parameter("FID_INPUT_ISCD", stockCode)
|
||||
parameter("FID_INPUT_DATE_1", "20240101") // 시작일 (예시)
|
||||
parameter("FID_INPUT_DATE_2", "20260110") // 종료일
|
||||
parameter("FID_PERIOD_DIV_CODE", "D") // 일봉
|
||||
parameter("FID_ORG_ADJ_PRC", "0") // 수정주가 반영
|
||||
}
|
||||
Result.success(response.body())
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
} catch (e: Exception) { Result.failure(e) }
|
||||
}
|
||||
|
||||
suspend fun fetchApprovalKey(appKey: String, appSecret: String): String? {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/oauth2/Approval") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(mapOf("grant_type" to "client_credentials", "appkey" to appKey, "secretkey" to appSecret))
|
||||
}
|
||||
// 응답에서 approval_key만 추출 (실제 모델 정의 필요)
|
||||
val json = response.body<Map<String, String>>()
|
||||
json["approval_key"]
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchOverseasChartData(
|
||||
token: String,
|
||||
appKey: String,
|
||||
appSecret: String,
|
||||
stockCode: String,
|
||||
excd: String = "NAS" // 기본 나스닥
|
||||
): Result<List<CandleData>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/uapi/overseas-stock/v1/quotations/inquire-daily-chartprice") {
|
||||
header("authorization", "Bearer $token")
|
||||
header("appkey", appKey)
|
||||
header("appsecret", appSecret)
|
||||
header("tr_id", "HHDFS76240000") // 해외 주식 기간별 시세 TR ID
|
||||
header("custtype", "P")
|
||||
|
||||
parameter("EXCD", excd)
|
||||
parameter("SYMB", stockCode)
|
||||
parameter("GUBN", "0") // 0: 일봉, 1: 주봉, 2: 월봉
|
||||
parameter("BYMD", "") // 공백 시 현재일 기준
|
||||
parameter("MODP", "Y") // 수정주가 반영
|
||||
}
|
||||
|
||||
val body = response.body<OverseasChartResponse>()
|
||||
// 해외 데이터를 공통 CandleData 형식으로 변환하여 차트 컴포저블 재사용
|
||||
val converted = body.output2.map {
|
||||
CandleData(
|
||||
stck_bsop_date = it.xy_date,
|
||||
stck_oprc = it.open,
|
||||
stck_hgpr = it.high,
|
||||
stck_lwpr = it.low,
|
||||
stck_clpr = it.last,
|
||||
acml_vol = it.t_vol
|
||||
)
|
||||
}.reversed()
|
||||
Result.success(converted)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun postOrder(
|
||||
token: String,
|
||||
config: AppConfig,
|
||||
stockCode: String,
|
||||
qty: String,
|
||||
price: String, // "0"이면 시장가
|
||||
isBuy: Boolean
|
||||
): Result<String> {
|
||||
return try {
|
||||
val cleanAccount = config.accountNo.filter { it.isDigit() }
|
||||
val trId = if (config.isSimulation) {
|
||||
if (isBuy) "VTRP0001U" else "VTRP0002U" // 모의: 매수/매도
|
||||
} else {
|
||||
if (isBuy) "TTTC0802U" else "TTTC0801U" // 실전: 매수/매도
|
||||
}
|
||||
|
||||
val response = client.post("$baseUrl/uapi/domestic-stock/v1/trading/order-cash") {
|
||||
header("authorization", "Bearer $token")
|
||||
header("appkey", config.appKey)
|
||||
header("appsecret", config.secretKey)
|
||||
header("tr_id", trId)
|
||||
header("Content-Type", "application/json")
|
||||
|
||||
setBody(mapOf(
|
||||
"CANO" to cleanAccount.take(8),
|
||||
"ACNT_PRDT_CD" to cleanAccount.takeLast(2),
|
||||
"PDNO" to stockCode,
|
||||
"ORD_DVSN" to if (price == "0") "01" else "00", // 01:시장가, 00:지정가
|
||||
"ORD_QTY" to qty,
|
||||
"ORD_UNPR" to price
|
||||
))
|
||||
}
|
||||
val body = response.body<Map<String, Any>>()
|
||||
if (body["rt_cd"] == "0") {
|
||||
Result.success("주문 성공: ${body["msg1"]}")
|
||||
} else {
|
||||
Result.failure(Exception("${body["msg1"]}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
private suspend fun fetchOverseasRawBalance(): Result<StockBalanceResponse> {
|
||||
// 해외 잔고 조회 API 명세에 맞춰 구현 (국내와 유사하나 TR ID 및 파라미터 다름)
|
||||
return Result.failure(Exception("Not Implemented"))
|
||||
}
|
||||
}
|
||||
@ -6,45 +6,62 @@ import androidx.compose.ui.graphics.Color
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.logging.DEFAULT
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.websocket.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.websocket.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import model.AppConfig
|
||||
import model.KisSession
|
||||
import model.RealTimeTrade
|
||||
import model.TradeType
|
||||
|
||||
class KisWebSocketManager(private val isSimulation: Boolean) {
|
||||
val client = HttpClient(CIO) {
|
||||
class KisWebSocketManager {
|
||||
private val client = HttpClient(CIO) {
|
||||
install(WebSockets) {
|
||||
// 타임아웃 설정 (필요 시)
|
||||
pingInterval = 20_000
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 15_000
|
||||
connectTimeoutMillis = 15_000 // 연결 시도 시간을 15초로 늘림
|
||||
connectTimeoutMillis = 15_000
|
||||
socketTimeoutMillis = 15_000
|
||||
}
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
|
||||
}
|
||||
}
|
||||
private var session: DefaultClientWebSocketSession? = null
|
||||
|
||||
// Coroutine 관리용 스코프 정의
|
||||
private var session: DefaultClientWebSocketSession? = null
|
||||
private val scope = CoroutineScope(Dispatchers.Default + Job())
|
||||
|
||||
// UI에서 관찰할 상태값들
|
||||
// UI 관찰 상태값
|
||||
val currentPrice = mutableStateOf("0")
|
||||
val priceChangeColor = mutableStateOf(Color.Transparent)
|
||||
val tradeLogs = mutableStateListOf<RealTimeTrade>() // 실시간 체결 내역 리스트
|
||||
val tradeLogs = mutableStateListOf<RealTimeTrade>()
|
||||
suspend fun connect() {
|
||||
val config = KisSession.config
|
||||
val approvalKey = config.websocketToken
|
||||
|
||||
suspend fun connect(approvalKey: String) {
|
||||
val hostUrl = if (isSimulation) "ops.koreainvestment.com" else "ops.koreainvestment.com"
|
||||
val port = if (isSimulation) 21001 else 21000
|
||||
if (approvalKey.isEmpty()) {
|
||||
println("⚠️ 웹소켓 승인키가 없습니다. 먼저 발급받아야 합니다.")
|
||||
return
|
||||
}
|
||||
|
||||
// 시세 데이터는 항상 실전 서버(21000)를 권장합니다.
|
||||
val hostUrl = "ops.koreainvestment.com"
|
||||
val port = 21000
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") {
|
||||
session = this
|
||||
// 서버로부터 오는 메시지 수신 루프
|
||||
println("✅ 웹소켓 연결 성공")
|
||||
|
||||
incoming.consumeAsFlow().collect { frame ->
|
||||
if (frame is Frame.Text) {
|
||||
parseTradeData(frame.readText())
|
||||
@ -52,8 +69,7 @@ class KisWebSocketManager(private val isSimulation: Boolean) {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("⚠️ 웹소켓 연결 실패 (장외 시간 또는 서버 점검): ${e.localizedMessage}")
|
||||
e.printStackTrace()
|
||||
println("❌ 웹소켓 연결 오류: ${e.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -96,19 +112,38 @@ class KisWebSocketManager(private val isSimulation: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [2] 실시간 시세 구독 (Registration)
|
||||
* tr_type = "1" (등록)
|
||||
*/
|
||||
suspend fun subscribeStock(stockCode: String) {
|
||||
val session = session ?: return
|
||||
sendRequest(stockCode, trType = "1")
|
||||
println("📡 실시간 시세 구독 시작: $stockCode")
|
||||
}
|
||||
|
||||
// 이전 구독이 있다면 해지 로직이 필요할 수 있으나,
|
||||
// 기본적으로 새로운 종목 구독 메시지를 전송합니다.
|
||||
val approvalKey = "" // 연결 시 저장해둔 키 사용 (필요시 클래스 변수로 저장)
|
||||
/**
|
||||
* [3] 실시간 시세 구독 취소 (Unsubscription)
|
||||
* tr_type = "2" (해제)
|
||||
*/
|
||||
suspend fun unsubscribeStock(stockCode: String) {
|
||||
if (stockCode.isEmpty()) return
|
||||
sendRequest(stockCode, trType = "2")
|
||||
println("🚫 실시간 시세 구독 해제: $stockCode")
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 요청 전송 함수
|
||||
*/
|
||||
private suspend fun sendRequest(stockCode: String, trType: String) {
|
||||
val currentSession = session ?: return
|
||||
val config = KisSession.config
|
||||
|
||||
val requestJson = """
|
||||
{
|
||||
"header": {
|
||||
"approval_key": "$approvalKey",
|
||||
"approval_key": "${config.websocketToken}",
|
||||
"custtype": "P",
|
||||
"tr_type": "1",
|
||||
"tr_type": "$trType",
|
||||
"content-type": "utf-8"
|
||||
},
|
||||
"body": {
|
||||
@ -118,14 +153,12 @@ class KisWebSocketManager(private val isSimulation: Boolean) {
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
""".trimIndent()
|
||||
|
||||
try {
|
||||
session.send(Frame.Text(requestJson))
|
||||
// 기존 체결 로그 초기화
|
||||
tradeLogs.clear()
|
||||
currentSession.send(Frame.Text(requestJson))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
println("❌ 웹소켓 요청 실패 ($trType): ${e.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ object LlamaServerManager {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + Job())
|
||||
|
||||
fun startServer(binPath: String, modelPath: String) {
|
||||
if (process != null) return // 이미 실행 중이면 무시
|
||||
if (process != null || modelPath.isNullOrBlank()) return // 이미 실행 중이면 무시
|
||||
|
||||
val command = listOf(
|
||||
binPath,
|
||||
|
||||
@ -21,25 +21,26 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.KisSession
|
||||
import model.RealTimeTrade
|
||||
import network.AiService
|
||||
|
||||
@Composable
|
||||
fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<RealTimeTrade>) {
|
||||
fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>) {
|
||||
var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 1. 모델 경로 유효성 체크
|
||||
val isModelConfigured = remember {
|
||||
val path = util.AppConfigManager.modelPath
|
||||
// KisSession의 전역 설정을 참조
|
||||
val isModelConfigured = remember(KisSession.config.modelPath) {
|
||||
val path = KisSession.config.modelPath
|
||||
path.isNotEmpty() && java.io.File(path).exists()
|
||||
}
|
||||
|
||||
Card(
|
||||
elevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE)
|
||||
backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@ -49,38 +50,22 @@ fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<RealTim
|
||||
color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
// 2. 경로가 정상일 때만 버튼 활성화
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isLoading = true
|
||||
aiOpinion = "Gemma가 데이터를 읽고 있습니다..."
|
||||
aiOpinion = "데이터 분석 중..."
|
||||
aiOpinion = network.AiService.fetchAnalysis(stockName, currentPrice, trades)
|
||||
isLoading = false
|
||||
}
|
||||
},
|
||||
enabled = isModelConfigured && !isLoading, // 유효성 체크 반영
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = Color.White,
|
||||
disabledBackgroundColor = Color(0xFFE0E0E0)
|
||||
)
|
||||
enabled = isModelConfigured && !isLoading
|
||||
) {
|
||||
Text(if (isLoading) "분석 중" else "분석 실행", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isModelConfigured) {
|
||||
Text(
|
||||
"설정에서 .gguf 모델 파일을 먼저 등록해주세요.",
|
||||
color = Color.Red,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
} else {
|
||||
Divider(Modifier.padding(vertical = 8.dp))
|
||||
Text(text = aiOpinion, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
Divider(Modifier.padding(vertical = 8.dp))
|
||||
Text(text = aiOpinion, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
}
|
||||
}
|
||||
146
src/main/kotlin/ui/BalanceSection.kt
Normal file
146
src/main/kotlin/ui/BalanceSection.kt
Normal file
@ -0,0 +1,146 @@
|
||||
// src/main/kotlin/ui/BalanceSection.kt
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.UnifiedBalance
|
||||
import network.KisTradeService
|
||||
|
||||
@Composable
|
||||
fun BalanceSection(
|
||||
tradeService: KisTradeService,
|
||||
onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit
|
||||
) {
|
||||
var balanceData by remember { mutableStateOf<UnifiedBalance?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
// 화면 진입 시 및 갱신 시 데이터 로드
|
||||
LaunchedEffect(Unit) {
|
||||
isLoading = true
|
||||
tradeService.fetchIntegratedBalance().onSuccess {
|
||||
balanceData = it
|
||||
}.onFailure {
|
||||
println("❌ 잔고 로드 실패: ${it.localizedMessage}")
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = "나의 자산",
|
||||
style = MaterialTheme.typography.h6,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
// 1. 자산 요약 카드
|
||||
BalanceSummaryCard(balanceData)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 2. 통합 보유 종목 리스트
|
||||
Text(
|
||||
text = "보유 종목",
|
||||
style = MaterialTheme.typography.subtitle1,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.weight(1f, true)) {
|
||||
items(balanceData?.holdings ?: emptyList()) { holding ->
|
||||
UnifiedStockItemRow(holding) {
|
||||
onStockSelect(holding.code, holding.name, holding.isDomestic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BalanceSummaryCard(summary: UnifiedBalance?) {
|
||||
Card(
|
||||
elevation = 2.dp,
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
backgroundColor = androidx.compose.ui.graphics.Color.White
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
|
||||
Text(
|
||||
text = "${summary?.totalAsset ?: "0"} 원",
|
||||
style = MaterialTheme.typography.h5,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
val rate = summary?.totalProfitRate?.toDoubleOrNull() ?: 0.0
|
||||
val color = if (rate > 0) androidx.compose.ui.graphics.Color.Red
|
||||
else if (rate < 0) androidx.compose.ui.graphics.Color.Blue
|
||||
else androidx.compose.ui.graphics.Color.DarkGray
|
||||
|
||||
Text(
|
||||
text = "수익률: ${if (rate > 0) "+" else ""}$rate%",
|
||||
color = color,
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
|
||||
elevation = 1.dp
|
||||
) {
|
||||
Row(modifier = Modifier.padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
// 국내/해외 구분 배지
|
||||
Surface(
|
||||
color = if (holding.isDomestic) androidx.compose.ui.graphics.Color(0xFFE3F2FD)
|
||||
else androidx.compose.ui.graphics.Color(0xFFF3E5F5),
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (holding.isDomestic) "국내" else "해외",
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
fontSize = 10.sp,
|
||||
color = if (holding.isDomestic) androidx.compose.ui.graphics.Color.Blue
|
||||
else androidx.compose.ui.graphics.Color(0xFF7B1FA2)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
}
|
||||
Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) {
|
||||
Text("${holding.currentPrice} 원")
|
||||
val rate = holding.profitRate.toDoubleOrNull() ?: 0.0
|
||||
Text(
|
||||
text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${holding.profitRate}%",
|
||||
color = if (rate > 0) androidx.compose.ui.graphics.Color.Red
|
||||
else if (rate < 0) androidx.compose.ui.graphics.Color.Blue
|
||||
else androidx.compose.ui.graphics.Color.DarkGray,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -22,19 +22,22 @@ fun CandleChart(data: List<CandleData>, modifier: Modifier = Modifier) {
|
||||
val spacing = candleWidth * 0.2f // 캔들 사이 간격
|
||||
|
||||
// 1. 가격 범위 계산 (스케일링용)
|
||||
val maxPrice = data.maxOf { it.stck_hgpr.toDouble() }
|
||||
val minPrice = data.minOf { it.stck_lwpr.toDouble() }
|
||||
val maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 }
|
||||
val minPrice = data.minOf { it.stck_lwpr.toDoubleOrNull() ?: 0.0 }
|
||||
val priceRange = maxPrice - minPrice
|
||||
|
||||
// priceRange가 0일 경우(데이터가 모두 같을 때) 분모가 0이 되는 것 방지
|
||||
fun getY(price: Double): Float {
|
||||
if (priceRange == 0.0) return height / 2f
|
||||
return (height - ((price - minPrice) / priceRange * height)).toFloat()
|
||||
}
|
||||
|
||||
// 루프 내부에서도 동일하게 적용
|
||||
data.forEachIndexed { index, candle ->
|
||||
val open = candle.stck_oprc.toDouble()
|
||||
val close = candle.stck_clpr.toDouble()
|
||||
val high = candle.stck_hgpr.toDouble()
|
||||
val low = candle.stck_lwpr.toDouble()
|
||||
val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0
|
||||
val close = candle.stck_clpr.toDoubleOrNull() ?: 0.0
|
||||
val high = candle.stck_hgpr.toDoubleOrNull() ?: 0.0
|
||||
val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0
|
||||
|
||||
val isRising = close >= open
|
||||
val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF)
|
||||
|
||||
@ -1,302 +1,78 @@
|
||||
// src/main/kotlin/ui/DashboardScreen.kt
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.AppConfig
|
||||
import model.BalanceSummary
|
||||
import model.RankingStock
|
||||
import model.RankingType
|
||||
import model.StockHolding
|
||||
import model.KisSession
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
import util.MarketUtil
|
||||
|
||||
@Composable
|
||||
fun DashboardScreen(config: AppConfig, token: String) {
|
||||
val wsManager = remember { KisWebSocketManager(config.isSimulation) }
|
||||
val tradeService = remember { KisTradeService(config.isSimulation) }
|
||||
fun DashboardScreen() {
|
||||
val tradeService = remember { KisTradeService() }
|
||||
val wsManager = remember { KisWebSocketManager() }
|
||||
|
||||
// 전역 상태: 현재 선택된 종목
|
||||
// 전역 상태: 현재 선택된 종목 정보
|
||||
var selectedStockCode by remember { mutableStateOf("") }
|
||||
var selectedStockName by remember { mutableStateOf("") }
|
||||
var isDomestic by remember { mutableStateOf(true) }
|
||||
|
||||
// 잔고 데이터 상태
|
||||
var holdings by remember { mutableStateOf<List<StockHolding>>(emptyList()) }
|
||||
var summary by remember { mutableStateOf<BalanceSummary?>(null) }
|
||||
|
||||
// 초기 데이터 로드 및 웹소켓 연결
|
||||
// 초기 웹소켓 연결
|
||||
LaunchedEffect(Unit) {
|
||||
val approvalKey = tradeService.fetchApprovalKey(config.appKey, config.secretKey)
|
||||
approvalKey?.let { wsManager.connect(it) }
|
||||
|
||||
tradeService.fetchBalance(token, config.appKey, config.secretKey, config.accountNo)
|
||||
.onSuccess {
|
||||
holdings = it.output1
|
||||
summary = it.output2.firstOrNull()
|
||||
}
|
||||
wsManager.connect()
|
||||
}
|
||||
|
||||
// 메인 3분할 레이아웃
|
||||
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
|
||||
|
||||
// [좌측 25%] 나의 자산 및 잔고
|
||||
// [좌측 25%] 내 자산 및 통합 잔고
|
||||
Column(modifier = Modifier.weight(0.25f).fillMaxHeight().padding(8.dp)) {
|
||||
Text("나의 잔고", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
BalanceSummaryCard(summary)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
MyStockList(holdings) { code, name ->
|
||||
BalanceSection(tradeService) { code, name, isDom ->
|
||||
selectedStockCode = code
|
||||
selectedStockName = name
|
||||
isDomestic = isDom
|
||||
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
|
||||
}
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// [중앙 45%] 실시간 차트 및 주문 (가장 중요)
|
||||
Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White).padding(12.dp)) {
|
||||
// [중앙 45%] 실시간 정보 및 주문
|
||||
Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White)) {
|
||||
if (selectedStockCode.isNotEmpty()) {
|
||||
StockDetailArea(config, token, selectedStockCode, selectedStockName, wsManager)
|
||||
StockDetailSection(
|
||||
stockCode = selectedStockCode,
|
||||
stockName = selectedStockName,
|
||||
isDomestic = isDomestic,
|
||||
tradeService = tradeService,
|
||||
wsManager = wsManager
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("좌측 잔고나 우측 추천 종목을 클릭하세요", color = Color.Gray)
|
||||
Text("분석할 종목을 선택하세요", color = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// [우측 30%] 시장 추천 리스트 (탭 방식)
|
||||
// [우측 30%] 시장 추천 TOP 20 (실전 데이터)
|
||||
Column(modifier = Modifier.weight(0.3f).fillMaxHeight().padding(8.dp)) {
|
||||
Text("시장 추천 TOP 20", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
RecommendationTabs(config, token) { code, name ->
|
||||
MarketSection(tradeService) { code, name, isDom ->
|
||||
selectedStockCode = code
|
||||
selectedStockName = name
|
||||
isDomestic = isDom
|
||||
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StockItemRow(stock: StockHolding) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stock.prdt_name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Text(stock.pdno, fontSize = 12.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text("${stock.prpr} 원", fontWeight = FontWeight.Bold)
|
||||
|
||||
// 수익률에 따른 색상 처리 (웹 소스 format.color 로직 이식)
|
||||
val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0
|
||||
val color = when {
|
||||
rate > 0 -> Color(0xFFE03E2D) // 웹 소스의 빨간색
|
||||
rate < 0 -> Color(0xFF0E62CF) // 웹 소스의 파란색
|
||||
else -> Color.DarkGray
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${if(rate > 0) "▲" else if(rate < 0) "▼" else ""} ${stock.evlu_pfls_rt}%",
|
||||
color = color,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun RankingItemRow(
|
||||
index: Int, // 순위 표시를 위해 index 추가
|
||||
rank: RankingStock,
|
||||
isDomestic: Boolean,
|
||||
type: RankingType,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val displayColor = when {
|
||||
type == RankingType.FALL -> Color(0xFF0E62CF) // 하락 탭은 무조건 파랑
|
||||
rank.prdy_ctrt.toDoubleOrNull() ?: 0.0 > 0 -> Color(0xFFE03E2D) // 그 외 양수면 빨강
|
||||
rank.prdy_ctrt.toDoubleOrNull() ?: 0.0 < 0 -> Color(0xFF0E62CF) // 음수면 파랑
|
||||
else -> Color.DarkGray
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() },
|
||||
elevation = 0.dp,
|
||||
backgroundColor = Color.White
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 10.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// [1] 순위 표시 (1~20)
|
||||
Text(
|
||||
text = "${index + 1}",
|
||||
style = MaterialTheme.typography.caption,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (index < 3) displayColor else Color.Gray, // 1~3위 강조
|
||||
modifier = Modifier.width(24.dp)
|
||||
)
|
||||
|
||||
// [2] 종목명 및 코드
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = rank.hts_kor_alph_nm,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = rank.mkrtc_objt_iscd,
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
// [3] 등락률 배지
|
||||
Surface(
|
||||
color = displayColor.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${if (rank.prdy_ctrt.toDouble() > 0) "+" else ""}${rank.prdy_ctrt}%",
|
||||
color = displayColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerticalDivider(modifier: Modifier = Modifier) {
|
||||
Box(modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
|
||||
}
|
||||
@Composable
|
||||
fun BalanceSummaryCard(summary: BalanceSummary?) {
|
||||
Card(
|
||||
elevation = 4.dp,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
backgroundColor = Color(0xFFF8F9FA) // 가벼운 배경색
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text("총 평가 자산", style = MaterialTheme.typography.caption, color = Color.Gray)
|
||||
Text(
|
||||
text = "${summary?.tot_evlu_amt ?: "0"} 원",
|
||||
style = MaterialTheme.typography.h5,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF333333)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val profitRate = summary?.evlu_pfls_rt?.toDoubleOrNull() ?: 0.0
|
||||
val profitColor = if (profitRate > 0) Color(0xFFE03E2D) else if (profitRate < 0) Color(0xFF0E62CF) else Color.DarkGray
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("실현 수익률: ", style = MaterialTheme.typography.body2)
|
||||
Text(
|
||||
text = "${if (profitRate > 0) "+" else ""}$profitRate%",
|
||||
style = MaterialTheme.typography.body1,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = profitColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StockItemRow(stock: StockHolding, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clickable { onClick() },
|
||||
elevation = 2.dp,
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 1. 종목명 및 코드 (왼쪽)
|
||||
Column(modifier = Modifier.weight(1.2f)) {
|
||||
Text(
|
||||
text = stock.prdt_name,
|
||||
style = MaterialTheme.typography.body1,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = stock.pdno,
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
// 2. 보유 수량 및 현재가 (중앙)
|
||||
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
|
||||
Text("${stock.hldg_qty} 주", style = MaterialTheme.typography.body2)
|
||||
Text(
|
||||
text = "${stock.prpr} 원",
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = Color.DarkGray
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 수익률 (오른쪽)
|
||||
val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0
|
||||
val color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray
|
||||
|
||||
Box(
|
||||
modifier = Modifier.weight(0.8f),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Surface(
|
||||
color = color.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${stock.evlu_pfls_rt}%",
|
||||
color = color,
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun VerticalDivider() {
|
||||
Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
|
||||
}
|
||||
101
src/main/kotlin/ui/MarketSection.kt
Normal file
101
src/main/kotlin/ui/MarketSection.kt
Normal file
@ -0,0 +1,101 @@
|
||||
// src/main/kotlin/ui/MarketSection.kt
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.RankingStock
|
||||
import model.RankingType
|
||||
import network.KisTradeService
|
||||
|
||||
@Composable
|
||||
fun MarketSection(
|
||||
tradeService: KisTradeService,
|
||||
onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit
|
||||
) {
|
||||
var selectedTab by remember { mutableStateOf(RankingType.VOLUME) }
|
||||
var isDomestic by remember { mutableStateOf(true) }
|
||||
var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
// 탭 또는 국가 변경 시 데이터 로드
|
||||
LaunchedEffect(selectedTab, isDomestic) {
|
||||
isLoading = true
|
||||
tradeService.fetchMarketRanking(selectedTab, isDomestic).onSuccess {
|
||||
rankingList = it
|
||||
}.onFailure {
|
||||
rankingList = emptyList()
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// [1] 상단 타이틀 및 국내/해외 토글
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("시장 랭킹 (TOP 20)", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
|
||||
|
||||
// 국내/해외 전환 스위치 방식
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(if (isDomestic) "국내" else "해외", fontSize = 12.sp, fontWeight = FontWeight.Medium)
|
||||
Switch(
|
||||
checked = !isDomestic,
|
||||
onCheckedChange = { isDomestic = !it },
|
||||
colors = SwitchDefaults.colors(checkedThumbColor = Color(0xFF0E62CF))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// [2] 랭킹 타입 탭 (상승, 하락, 거래량 등)
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = selectedTab.ordinal,
|
||||
backgroundColor = Color.Transparent,
|
||||
contentColor = Color.Black,
|
||||
edgePadding = 0.dp,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
Modifier.tabIndicatorOffset(tabPositions[selectedTab.ordinal]),
|
||||
color = Color(0xFFE03E2D)
|
||||
)
|
||||
}
|
||||
) {
|
||||
RankingType.values().forEach { type ->
|
||||
Tab(
|
||||
selected = selectedTab == type,
|
||||
onClick = { selectedTab = type },
|
||||
text = { Text(type.title, fontSize = 12.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// [3] 랭킹 리스트
|
||||
if (isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(rankingList.withIndex().toList()) { (index, stock) ->
|
||||
MarketStockItemRow(index + 1, stock) {
|
||||
onStockSelect(stock.code, stock.name, isDomestic)
|
||||
}
|
||||
Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/main/kotlin/ui/MarketStockItemRow.kt
Normal file
62
src/main/kotlin/ui/MarketStockItemRow.kt
Normal file
@ -0,0 +1,62 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.RankingStock
|
||||
import model.RankingType
|
||||
import network.KisTradeService
|
||||
|
||||
|
||||
// src/main/kotlin/ui/MarketStockItemRow.kt
|
||||
@Composable
|
||||
fun MarketStockItemRow(
|
||||
rank: Int,
|
||||
stock: RankingStock,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(vertical = 10.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 순위 표시
|
||||
Text(
|
||||
text = rank.toString(),
|
||||
modifier = Modifier.width(24.dp),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (rank <= 3) Color(0xFFE03E2D) else Color.Gray
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stock.name, fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1)
|
||||
Text(stock.code, fontSize = 10.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = String.format("%,d", stock.stck_prpr.toLongOrNull() ?: 0L),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0
|
||||
Text(
|
||||
text = "${if (rate > 0) "+" else ""}${stock.prdy_ctrt}%",
|
||||
fontSize = 11.sp,
|
||||
color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -33,80 +33,61 @@ import kotlin.collections.isNotEmpty
|
||||
|
||||
@Composable
|
||||
fun OrderSection(
|
||||
config: AppConfig,
|
||||
token: String,
|
||||
stockCode: String,
|
||||
currentPrice: String,
|
||||
onOrderResult: (String, Boolean) -> Unit // 결과 메시지와 성공 여부 전달
|
||||
onOrderResult: (String, Boolean) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope() // 에러 해결: scope 정의
|
||||
val tradeService = remember { KisTradeService(config.isSimulation) } // 에러 해결: 서비스 정의
|
||||
val scope = rememberCoroutineScope()
|
||||
val tradeService = remember { KisTradeService() } // 전역 세션 참조 버전
|
||||
var orderQty by remember { mutableStateOf("1") }
|
||||
var orderPrice by remember { mutableStateOf("0") } // 0은 시장가
|
||||
var isSubmitting by remember { mutableStateOf(false) }
|
||||
var orderPrice by remember { mutableStateOf("0") } // "0"은 시장가
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFFF8F9FA))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// 수량 입력
|
||||
OutlinedTextField(
|
||||
value = orderQty,
|
||||
onValueChange = { if(it.all { c -> c.isDigit() }) orderQty = it },
|
||||
label = { Text("수량", fontSize = 10.sp) },
|
||||
modifier = Modifier.width(100.dp).height(50.dp),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
// 가격 입력 (시장가 체크박스 기능 포함 가능)
|
||||
OutlinedTextField(
|
||||
value = if(orderPrice == "0") "시장가" else orderPrice,
|
||||
onValueChange = { if(it.all { c -> c.isDigit() }) orderPrice = it },
|
||||
label = { Text("가격", fontSize = 10.sp) },
|
||||
modifier = Modifier.weight(1f).height(50.dp),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.width(200.dp).background(Color(0xFFF8F9FA)).padding(8.dp)) {
|
||||
Text("주문 설정", fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||
|
||||
OutlinedTextField(
|
||||
value = orderQty,
|
||||
onValueChange = { if(it.all { c -> c.isDigit() }) orderQty = it },
|
||||
label = { Text("수량") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = if(orderPrice == "0") "시장가" else orderPrice,
|
||||
onValueChange = { if(it.all { c -> c.isDigit() }) orderPrice = it },
|
||||
label = { Text("가격") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// 매수 버튼
|
||||
Button(
|
||||
onClick = {
|
||||
isSubmitting = true
|
||||
scope.launch {
|
||||
val res = tradeService.postOrder(token, config, stockCode, orderQty, orderPrice, true)
|
||||
res.onSuccess { onOrderResult(it, true) }.onFailure { onOrderResult(it.message ?: "에러", false) }
|
||||
isSubmitting = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(45.dp),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)),
|
||||
enabled = !isSubmitting
|
||||
) {
|
||||
Text("현금매수", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
// KisSession을 사용하는 postOrder 호출
|
||||
tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = true)
|
||||
.onSuccess { onOrderResult(it, true) }
|
||||
.onFailure { onOrderResult(it.message ?: "에러", false) }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
|
||||
) {
|
||||
Text("매수", color = Color.White)
|
||||
}
|
||||
|
||||
// 매도 버튼
|
||||
Button(
|
||||
onClick = {
|
||||
isSubmitting = true
|
||||
scope.launch {
|
||||
val res = tradeService.postOrder(token, config, stockCode, orderQty, orderPrice, false)
|
||||
res.onSuccess { onOrderResult(it, true) }.onFailure { onOrderResult(it.message ?: "에러", false) }
|
||||
isSubmitting = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(45.dp),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)),
|
||||
enabled = !isSubmitting
|
||||
) {
|
||||
Text("현금매도", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = false)
|
||||
.onSuccess { onOrderResult(it, true) }
|
||||
.onFailure { onOrderResult(it.message ?: "에러", false) }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF))
|
||||
) {
|
||||
Text("매도", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/main/kotlin/ui/RealTimeTradeList.kt
Normal file
43
src/main/kotlin/ui/RealTimeTradeList.kt
Normal file
@ -0,0 +1,43 @@
|
||||
// src/main/kotlin/ui/RealTimeTradeList.kt
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.RealTimeTrade
|
||||
|
||||
@Composable
|
||||
fun RealTimeTradeList(tradeLogs: List<RealTimeTrade>) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// [1] 리스트 헤더 영역
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFFEEEEEE)) // 연한 회색 배경
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
|
||||
Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
|
||||
Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
|
||||
Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
// [2] 실제 데이터 리스트
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
// 최신 데이터가 위로 오도록 표시 (이미 tradeLogs에 add(0, new)로 들어옴)
|
||||
items(tradeLogs) { trade ->
|
||||
TradeLogRow(trade) // 기존에 만드신 행(Row) 컴포넌트 재사용
|
||||
Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,152 +1,152 @@
|
||||
package ui
|
||||
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.runtime.*
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.AppConfig
|
||||
import model.BalanceSummary
|
||||
import model.RankingStock
|
||||
import model.RankingType
|
||||
import model.StockHolding
|
||||
import network.KisTradeService
|
||||
import util.MarketUtil
|
||||
|
||||
@Composable
|
||||
fun RecommendationTabs(
|
||||
config: AppConfig,
|
||||
token: String,
|
||||
onSelect: (String, String) -> Unit
|
||||
) {
|
||||
var isDomestic by remember { mutableStateOf(true) }
|
||||
var selectedType by remember { mutableStateOf(RankingType.RISE) }
|
||||
var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
|
||||
|
||||
val tradeService = remember { KisTradeService(config.isSimulation) }
|
||||
val isKoreaOpen = MarketUtil.isKoreanMarketOpen()
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) } // 에러 메시지 상태 추가
|
||||
|
||||
// 데이터 로드 로직
|
||||
LaunchedEffect(isDomestic, selectedType, isKoreaOpen) {
|
||||
errorMessage = null // 로딩 시작 시 에러 초기화
|
||||
if (isDomestic) {
|
||||
if (isKoreaOpen) {
|
||||
tradeService.fetchMarketRanking(token, config, selectedType, true)
|
||||
.onSuccess { rankingList = it.take(20) }
|
||||
.onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." }
|
||||
} else {
|
||||
tradeService.fetchDomesticPreviousDayRanking(token, config)
|
||||
.onSuccess { rankingList = it }
|
||||
.onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" }
|
||||
}
|
||||
} else {
|
||||
tradeService.fetchOverseasRanking(token, config)
|
||||
.onSuccess { rankingList = it }
|
||||
.onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." }
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// [1] 국내/해외 전환 버튼 (항상 노출)
|
||||
Row(Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false }
|
||||
}
|
||||
|
||||
// [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출)
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = selectedType.ordinal,
|
||||
edgePadding = 8.dp,
|
||||
backgroundColor = Color.White,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]),
|
||||
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)
|
||||
)
|
||||
}
|
||||
) {
|
||||
RankingType.values().forEach { type ->
|
||||
Tab(
|
||||
selected = selectedType == type,
|
||||
onClick = { selectedType = type },
|
||||
text = { Text(type.title, fontSize = 12.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// [3] 장외 시간 안내 바
|
||||
if (isDomestic && !isKoreaOpen) {
|
||||
Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.",
|
||||
fontSize = 11.sp, modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// [4] 추천 리스트 영역
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
if (errorMessage != null) {
|
||||
// 에러 발생 시 안내
|
||||
Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) {
|
||||
Text(errorMessage!!, color = Color.Gray)
|
||||
Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") }
|
||||
}
|
||||
} else if (rankingList.isEmpty()) {
|
||||
// 로딩 중
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
// [성공] 리스트 노출
|
||||
LazyColumn {
|
||||
itemsIndexed(rankingList) { index, stock ->
|
||||
RankingItemRow(index, stock, isDomestic, selectedType) {
|
||||
onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent
|
||||
),
|
||||
modifier = Modifier.height(36.dp),
|
||||
border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = if (isSelected) activeColor else Color.Gray,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
//package ui
|
||||
//
|
||||
//
|
||||
//import androidx.compose.foundation.BorderStroke
|
||||
//import androidx.compose.foundation.background
|
||||
//import androidx.compose.foundation.clickable
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.lazy.LazyColumn
|
||||
//import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
|
||||
//import androidx.compose.foundation.lazy.itemsIndexed
|
||||
//import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
|
||||
//import androidx.compose.runtime.*
|
||||
//import io.ktor.client.engine.cio.CIO
|
||||
//// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
//import androidx.compose.runtime.getValue
|
||||
//import androidx.compose.runtime.setValue
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.font.FontWeight
|
||||
//import androidx.compose.ui.text.style.TextOverflow
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//import kotlinx.coroutines.launch
|
||||
//import model.AppConfig
|
||||
//import model.BalanceSummary
|
||||
//import model.RankingStock
|
||||
//import model.RankingType
|
||||
//import model.StockHolding
|
||||
//import network.KisTradeService
|
||||
//import util.MarketUtil
|
||||
//
|
||||
//@Composable
|
||||
//fun RecommendationTabs(
|
||||
// config: AppConfig,
|
||||
// token: String,
|
||||
// onSelect: (String, String) -> Unit
|
||||
//) {
|
||||
// var isDomestic by remember { mutableStateOf(true) }
|
||||
// var selectedType by remember { mutableStateOf(RankingType.RISE) }
|
||||
// var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
|
||||
//
|
||||
// val tradeService = remember { KisTradeService(config.isSimulation) }
|
||||
// val isKoreaOpen = MarketUtil.isKoreanMarketOpen()
|
||||
// var errorMessage by remember { mutableStateOf<String?>(null) } // 에러 메시지 상태 추가
|
||||
//
|
||||
// // 데이터 로드 로직
|
||||
// LaunchedEffect(isDomestic, selectedType, isKoreaOpen) {
|
||||
// errorMessage = null // 로딩 시작 시 에러 초기화
|
||||
// if (isDomestic) {
|
||||
// if (isKoreaOpen) {
|
||||
// tradeService.fetchMarketRanking(token, config, selectedType, true)
|
||||
// .onSuccess { rankingList = it.take(20) }
|
||||
// .onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." }
|
||||
// } else {
|
||||
// tradeService.fetchDomesticPreviousDayRanking(token, config)
|
||||
// .onSuccess { rankingList = it }
|
||||
// .onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" }
|
||||
// }
|
||||
// } else {
|
||||
// tradeService.fetchOverseasRanking(token, config)
|
||||
// .onSuccess { rankingList = it }
|
||||
// .onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Column(modifier = Modifier.fillMaxSize()) {
|
||||
// // [1] 국내/해외 전환 버튼 (항상 노출)
|
||||
// Row(Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
// MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true }
|
||||
// Spacer(Modifier.width(8.dp))
|
||||
// MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false }
|
||||
// }
|
||||
//
|
||||
// // [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출)
|
||||
// ScrollableTabRow(
|
||||
// selectedTabIndex = selectedType.ordinal,
|
||||
// edgePadding = 8.dp,
|
||||
// backgroundColor = Color.White,
|
||||
// indicator = { tabPositions ->
|
||||
// TabRowDefaults.Indicator(
|
||||
// modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]),
|
||||
// color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)
|
||||
// )
|
||||
// }
|
||||
// ) {
|
||||
// RankingType.values().forEach { type ->
|
||||
// Tab(
|
||||
// selected = selectedType == type,
|
||||
// onClick = { selectedType = type },
|
||||
// text = { Text(type.title, fontSize = 12.sp) }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // [3] 장외 시간 안내 바
|
||||
// if (isDomestic && !isKoreaOpen) {
|
||||
// Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) {
|
||||
// Text(
|
||||
// "현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.",
|
||||
// fontSize = 11.sp, modifier = Modifier.padding(8.dp)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // [4] 추천 리스트 영역
|
||||
// Box(modifier = Modifier.weight(1f)) {
|
||||
// if (errorMessage != null) {
|
||||
// // 에러 발생 시 안내
|
||||
// Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) {
|
||||
// Text(errorMessage!!, color = Color.Gray)
|
||||
// Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") }
|
||||
// }
|
||||
// } else if (rankingList.isEmpty()) {
|
||||
// // 로딩 중
|
||||
// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
// CircularProgressIndicator()
|
||||
// }
|
||||
// } else {
|
||||
// // [성공] 리스트 노출
|
||||
// LazyColumn {
|
||||
// itemsIndexed(rankingList) { index, stock ->
|
||||
// RankingItemRow(index, stock, isDomestic, selectedType) {
|
||||
// onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) {
|
||||
// OutlinedButton(
|
||||
// onClick = onClick,
|
||||
// colors = ButtonDefaults.outlinedButtonColors(
|
||||
// backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent
|
||||
// ),
|
||||
// modifier = Modifier.height(36.dp),
|
||||
// border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray)
|
||||
// ) {
|
||||
// Text(
|
||||
// text = title,
|
||||
// color = if (isSelected) activeColor else Color.Gray,
|
||||
// fontSize = 12.sp,
|
||||
// fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
||||
// )
|
||||
// }
|
||||
//}
|
||||
|
||||
@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.DragData
|
||||
@ -14,151 +12,113 @@ import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.onExternalDrag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.AppConfig
|
||||
import model.KisSession
|
||||
import network.KisAuthService
|
||||
import network.KisTradeService
|
||||
import org.jetbrains.exposed.sql.deleteAll
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.filechooser.FileNameExtensionFilter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog // 파일 선택기용
|
||||
|
||||
|
||||
// src/main/kotlin/ui/SettingsScreen.kt
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
initialConfig: AppConfig, // 모델 경로가 포함된 확장된 AppConfig 필요
|
||||
onAuthSuccess: (AppConfig, String) -> Unit
|
||||
) {
|
||||
fun SettingsScreen(onAuthSuccess: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val authService = remember { KisAuthService() }
|
||||
var config by remember { mutableStateOf(KisSession.config) }
|
||||
var statusMessage by remember { mutableStateOf("정보를 입력하세요.") }
|
||||
|
||||
// 화면 입력 상태값
|
||||
var appKey by remember { mutableStateOf(initialConfig.appKey) }
|
||||
var secretKey by remember { mutableStateOf(initialConfig.secretKey) }
|
||||
var accountNo by remember { mutableStateOf(initialConfig.accountNo) }
|
||||
var isSimulation by remember { mutableStateOf(initialConfig.isSimulation) }
|
||||
var modelPath by remember { mutableStateOf(initialConfig.modelPath ?: "") } // AI 모델 경로
|
||||
|
||||
var statusMessage by remember { mutableStateOf("설정 정보를 입력하세요.") }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
// 계좌번호 입력 시 데이터 자동 로드 함수
|
||||
fun checkAndLoadConfig(accountNo: String, isReal: Boolean) {
|
||||
val loaded = DatabaseFactory.findConfigByAccount(accountNo)
|
||||
if (loaded != null) {
|
||||
config = loaded
|
||||
statusMessage = "✅ 기존 데이터를 불러왔습니다."
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
||||
item {
|
||||
Text("API 및 계좌 설정", style = MaterialTheme.typography.h6)
|
||||
OutlinedTextField(value = appKey, onValueChange = { appKey = it }, label = { Text("App Key") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = secretKey, onValueChange = { secretKey = it }, label = { Text("Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
|
||||
OutlinedTextField(value = accountNo, onValueChange = { accountNo = it }, label = { Text("계좌번호") }, modifier = Modifier.fillMaxWidth())
|
||||
Text("거래 방식 선택", style = MaterialTheme.typography.h6)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = isSimulation, onCheckedChange = { isSimulation = it })
|
||||
Text("모의투자 서버 사용")
|
||||
RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false) })
|
||||
Text("실전투자")
|
||||
Spacer(Modifier.width(16.dp))
|
||||
RadioButton(selected = config.isSimulation, onClick = { config = config.copy(isSimulation = true) })
|
||||
Text("모의투자")
|
||||
}
|
||||
Divider(Modifier.padding(vertical = 12.dp))
|
||||
|
||||
// 실전 3종 입력
|
||||
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
|
||||
OutlinedTextField(value = config.realAccountNo, onValueChange = {
|
||||
config = config.copy(realAccountNo = it)
|
||||
if(it.length >= 8) checkAndLoadConfig(it, true)
|
||||
}, label = { Text("실전 계좌번호") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = config.realAppKey, onValueChange = { config = config.copy(realAppKey = it) }, label = { Text("실전 App Key") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// 모의 3종 입력
|
||||
Text("모의투자 정보", fontWeight = FontWeight.Bold)
|
||||
OutlinedTextField(value = config.vtsAccountNo, onValueChange = {
|
||||
config = config.copy(vtsAccountNo = it)
|
||||
if(it.length >= 8) checkAndLoadConfig(it, false)
|
||||
}, label = { Text("모의 계좌번호") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = config.vtsAppKey, onValueChange = { config = config.copy(vtsAppKey = it) }, label = { Text("모의 App Key") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
|
||||
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
// --- 추가된 AI 모델 설정 섹션 ---
|
||||
Text("AI 모델 설정 (Gemma-2-9b)", style = MaterialTheme.typography.h6)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = modelPath,
|
||||
onValueChange = { modelPath = it },
|
||||
label = { Text("GGUF 모델 경로") },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("파일을 선택하거나 드래그하세요") }
|
||||
)
|
||||
IconButton(onClick = {
|
||||
val chooser = JFileChooser().apply {
|
||||
fileFilter = FileNameExtensionFilter("GGUF 모델", "gguf")
|
||||
}
|
||||
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||
modelPath = chooser.selectedFile.absolutePath
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.FolderOpen, contentDescription = "파일 선택")
|
||||
}
|
||||
}
|
||||
|
||||
// 드래그 앤 드롭 영역
|
||||
// AI 모델 경로 및 드래그 앤 드롭
|
||||
Text("AI 모델 설정", fontWeight = FontWeight.Bold)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp)
|
||||
.padding(top = 8.dp)
|
||||
.border(1.dp, Color.LightGray, RoundedCornerShape(8.dp))
|
||||
modifier = Modifier.fillMaxWidth().height(100.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
|
||||
.onExternalDrag(onDrop = { state ->
|
||||
val data = state.dragData
|
||||
if (data is DragData.FilesList) {
|
||||
val path = data.readFiles().firstOrNull()?.removePrefix("file:")
|
||||
if (path?.endsWith(".gguf") == true) modelPath = path
|
||||
if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path)
|
||||
}
|
||||
}),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("여기에 .gguf 파일을 드래그하여 놓으세요", fontSize = 12.sp, color = Color.Gray)
|
||||
Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// 저장 및 접속 버튼
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
enabled = !isLoading,
|
||||
onClick = {
|
||||
isLoading = true
|
||||
scope.launch {
|
||||
// 1. 새로운 설정 객체 생성 (순서 주의: isSimulation 다음 modelPath)
|
||||
val config = AppConfig(
|
||||
appKey = appKey.trim(),
|
||||
secretKey = secretKey.trim(),
|
||||
accountNo = accountNo.trim(),
|
||||
isSimulation = isSimulation,
|
||||
modelPath = modelPath
|
||||
)
|
||||
// isLoading = true
|
||||
// 1. KisSession.config 업데이트 및 DB 저장
|
||||
KisSession.config = config
|
||||
DatabaseFactory.saveConfig(config)
|
||||
val authService = KisAuthService()
|
||||
val tradeService = KisTradeService()
|
||||
val authSuccess = authService.refreshAllTokens()
|
||||
val wsKeySuccess = tradeService.refreshWebsocketKey()
|
||||
|
||||
transaction {
|
||||
ConfigTable.deleteAll()
|
||||
ConfigTable.insert {
|
||||
it[ConfigTable.appKey] = config.appKey
|
||||
it[ConfigTable.secretKey] = config.secretKey
|
||||
it[ConfigTable.accountNo] = config.accountNo
|
||||
it[ConfigTable.isSimulation] = config.isSimulation
|
||||
it[ConfigTable.modelPath] = config.modelPath
|
||||
}
|
||||
if (authSuccess && wsKeySuccess) {
|
||||
statusMessage = "✅ 인증 성공! LLM 시작 중..."
|
||||
onAuthSuccess()
|
||||
} else {
|
||||
statusMessage = "❌ 인증 실패. 키 정보를 확인하세요."
|
||||
}
|
||||
|
||||
statusMessage = "인증 토큰 발급 시도 중..."
|
||||
authService.fetchAccessToken(appKey, secretKey, isSimulation)
|
||||
.onSuccess { response ->
|
||||
statusMessage = "✅ 인증 성공!"
|
||||
onAuthSuccess(config, response.access_token)
|
||||
}.onFailure {
|
||||
statusMessage = "❌ 인증 실패(정보 저장됨): ${it.localizedMessage}"
|
||||
}
|
||||
isLoading = false
|
||||
// isLoading = false
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isLoading) {
|
||||
// [수정된 프로그래스 바] size -> Modifier.size
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("설정 저장 및 접속 시작")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(statusMessage, color = if (statusMessage.contains("✅")) Color.Green else Color.Gray)
|
||||
) { Text("설정 저장 및 실행") }
|
||||
Text(statusMessage, color = Color.Gray, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,149 +34,116 @@ import network.KisWebSocketManager
|
||||
import kotlin.collections.isNotEmpty
|
||||
|
||||
@Composable
|
||||
fun StockDetailArea(
|
||||
config: AppConfig,
|
||||
token: String,
|
||||
code: String,
|
||||
name: String,
|
||||
wsManager: KisWebSocketManager // 매니저 수신
|
||||
fun StockDetailSection(
|
||||
stockCode: String,
|
||||
stockName: String,
|
||||
isDomestic: Boolean,
|
||||
tradeService: KisTradeService,
|
||||
wsManager: KisWebSocketManager
|
||||
) {
|
||||
val currentPrice by wsManager.currentPrice
|
||||
val priceColor by wsManager.priceChangeColor
|
||||
val tradeLogs = wsManager.tradeLogs // Manager의 상태를 직접 참조
|
||||
val tradeService = remember { KisTradeService(config.isSimulation) }
|
||||
var chartData by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var resultMessage by remember { mutableStateOf("") }
|
||||
var isSuccess by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(code) {
|
||||
if (code.isEmpty()) return@LaunchedEffect
|
||||
// 이전 종목 코드를 기억하기 위한 상태
|
||||
var previousCode by remember { mutableStateOf("") }
|
||||
|
||||
// 종목 변경 시 데이터 로드 및 웹소켓 구독 관리
|
||||
LaunchedEffect(stockCode) {
|
||||
if (stockCode.isEmpty()) return@LaunchedEffect
|
||||
|
||||
isLoading = true
|
||||
if (code.isNotEmpty()) {
|
||||
// 기존 종목 구독 해지 및 새 종목 구독 메시지 전송
|
||||
// (KisWebSocketManager에 해당 기능을 하는 함수를 만들어서 호출)
|
||||
wsManager.subscribeStock(code)
|
||||
}
|
||||
// 종목 코드 판별 (숫자 6자리면 국내, 아니면 해외로 간주)
|
||||
val isDomestic = code.all { it.isDigit() } && code.length == 6
|
||||
|
||||
val result = if (isDomestic) {
|
||||
tradeService.fetchChartData(token, config.appKey, config.secretKey, code)
|
||||
.map { it.output2.reversed() }
|
||||
} else {
|
||||
// 해외 주식 처리 (우선 NAS 나스닥 기준으로 호출)
|
||||
tradeService.fetchOverseasChartData(token, config.appKey, config.secretKey, code)
|
||||
// 1. 웹소켓 구독 관리: 이전 종목 해제 -> 새 종목 구독
|
||||
if (previousCode.isNotEmpty()) {
|
||||
wsManager.unsubscribeStock(previousCode)
|
||||
}
|
||||
wsManager.subscribeStock(stockCode)
|
||||
previousCode = stockCode
|
||||
|
||||
result.onSuccess { chartData = it }
|
||||
.onFailure { println("차트 로드 실패: ${it.message}") }
|
||||
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
|
||||
tradeService.fetchChartData(stockCode, isDomestic)
|
||||
.onSuccess { data ->
|
||||
println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력
|
||||
chartData = data
|
||||
}
|
||||
.onFailure { error ->
|
||||
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
|
||||
chartData = emptyList()
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(resultMessage) {
|
||||
if (resultMessage.isNotEmpty()) {
|
||||
delay(3000)
|
||||
resultMessage = ""
|
||||
val latestPrice by wsManager.currentPrice // 웹소켓에서 업데이트되는 현재가
|
||||
|
||||
LaunchedEffect(latestPrice) {
|
||||
println("latestPrice $latestPrice")
|
||||
|
||||
if (chartData.isNotEmpty() && latestPrice != "0") {
|
||||
|
||||
// 마지막 캔들 정보 업데이트
|
||||
val priceDouble = latestPrice.replace(",", "").toDoubleOrNull() ?: return@LaunchedEffect
|
||||
val lastCandle = chartData.last()
|
||||
|
||||
val updatedCandle = lastCandle.copy(
|
||||
stck_clpr = latestPrice,
|
||||
stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr,
|
||||
stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr
|
||||
)
|
||||
|
||||
chartData = chartData.dropLast(1) + updatedCandle
|
||||
println("chartData.size $chartData.size")
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// [상단 정보] 국내/해외 구분 배지 추가
|
||||
if (resultMessage.isNotEmpty()) {
|
||||
Surface(
|
||||
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = resultMessage,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// [상단] 종목명 및 상태 메시지
|
||||
StockHeader(stockName, stockCode, isDomestic, resultMessage, isSuccess)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val isDomestic = code.all { it.isDigit() } && code.length == 6
|
||||
Badge(backgroundColor = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)) {
|
||||
Text(if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(name, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
|
||||
Text(" ($code)", color = Color.Gray)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// [차트 영역] CandleChart 컴포저블 재사용
|
||||
// [중앙] 캔들 차트 (Card 내부)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().height(350.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(300.dp),
|
||||
backgroundColor = Color(0xFF121212)
|
||||
) {
|
||||
if (isLoading) {
|
||||
Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) }
|
||||
} else if (chartData.isNotEmpty()) {
|
||||
CandleChart(data = chartData, modifier = Modifier.padding(16.dp))
|
||||
} else {
|
||||
Box(contentAlignment = Alignment.Center) { Text("데이터가 없습니다.", color = Color.Gray) }
|
||||
CandleChart(data = chartData, modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// [중앙 하단] AI 투자 전략
|
||||
AiAnalysisView(
|
||||
stockName = name,
|
||||
stockName = stockName,
|
||||
currentPrice = wsManager.currentPrice.value,
|
||||
trades = wsManager.tradeLogs
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
// 웹 소스 스타일의 주문 박스
|
||||
// Card(modifier = Modifier.fillMaxWidth(), backgroundColor = Color(0xFFF8F9FA)) {
|
||||
// Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Text("주문 설정", fontWeight = FontWeight.Bold)
|
||||
// // 수량 입력, 매수/매도 버튼 배치 (detail.html 참고)
|
||||
// Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Button(onClick = { /* 매수 */ }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(Color(0xFFE03E2D))) {
|
||||
// Text("매수", color = Color.White)
|
||||
// }
|
||||
// Button(onClick = { /* 매도 */ }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(Color(0xFF0E62CF))) {
|
||||
// Text("매도", color = Color.White)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
Column(modifier = Modifier.weight(0.4f)) {
|
||||
Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
|
||||
|
||||
// 헤더 영역
|
||||
Row(modifier = Modifier.fillMaxWidth().background(Color(0xFFEEEEEE)).padding(vertical = 4.dp)) {
|
||||
Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp)
|
||||
Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp)
|
||||
Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp)
|
||||
Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// [하단] 실시간 체결 내역 및 주문 섹션
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
// 실시간 체결 리스트
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
|
||||
RealTimeTradeList(wsManager.tradeLogs)
|
||||
}
|
||||
|
||||
// 실시간 리스트
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(tradeLogs) { trade ->
|
||||
TradeLogRow(trade)
|
||||
Divider(color = Color(0xFFF5F5F5))
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 주문 섹션 (인자 간소화)
|
||||
OrderSection(
|
||||
stockCode = stockCode,
|
||||
currentPrice = wsManager.currentPrice.value,
|
||||
onOrderResult = { msg, success ->
|
||||
resultMessage = msg
|
||||
isSuccess = success
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
OrderSection(
|
||||
config = config,
|
||||
token = token,
|
||||
stockCode = code,
|
||||
currentPrice = currentPrice,
|
||||
onOrderResult = { msg, success ->
|
||||
resultMessage = msg
|
||||
isSuccess = success
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
80
src/main/kotlin/ui/StockHeader.kt
Normal file
80
src/main/kotlin/ui/StockHeader.kt
Normal file
@ -0,0 +1,80 @@
|
||||
// src/main/kotlin/ui/StockHeader.kt
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun StockHeader(
|
||||
name: String,
|
||||
code: String,
|
||||
isDomestic: Boolean,
|
||||
resultMessage: String,
|
||||
isSuccess: Boolean
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// [1] 알림 메시지 영역 (주문 성공/실패 시 상단에 표시)
|
||||
if (resultMessage.isNotEmpty()) {
|
||||
Surface(
|
||||
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), // 성공 초록, 실패 빨강
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = resultMessage,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// [2] 종목명 및 국가 배지 영역
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
) {
|
||||
// 국가 구분 배지
|
||||
Surface(
|
||||
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF), // 국내 빨강, 해외 파랑
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isDomestic) "국내" else "해외",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// 종목 이름
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.h5,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
// 종목 코드
|
||||
Text(
|
||||
text = "($code)",
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user