Use user config directory for runtime state
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
use crate::core::dotenv;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) struct AccountConfig {
|
||||
pub(crate) number: String,
|
||||
@@ -13,7 +12,7 @@ impl AccountConfig {
|
||||
}
|
||||
|
||||
pub(crate) fn read_account_config() -> Result<AccountConfig, String> {
|
||||
let env = dotenv::read_env(Path::new(".env"));
|
||||
let env = dotenv::read_env(&crate::core::paths::env_file());
|
||||
|
||||
if let Some(account) = env
|
||||
.get("KIS_ACCOUNT")
|
||||
@@ -38,7 +37,10 @@ pub(crate) fn read_account_config() -> Result<AccountConfig, String> {
|
||||
});
|
||||
}
|
||||
|
||||
Err("계좌 설정이 없습니다. .env에 KIS_ACCOUNT=12345678-01 또는 KIS_CANO/KIS_ACNT_PRDT_CD를 설정하세요.".to_string())
|
||||
Err(format!(
|
||||
"계좌 설정이 없습니다. {}에 KIS_ACCOUNT=12345678-01 또는 KIS_CANO/KIS_ACNT_PRDT_CD를 설정하세요.",
|
||||
crate::core::paths::env_file().display()
|
||||
))
|
||||
}
|
||||
|
||||
fn split_account(account: &str) -> (&str, &str) {
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
use crate::apis::kis::KisApi;
|
||||
use crate::core::dotenv;
|
||||
use std::path::Path;
|
||||
|
||||
const KIS_BASE_URL: &str = "https://openapi.koreainvestment.com:9443";
|
||||
|
||||
pub fn call(api: &KisApi, endpoint: &str, params: &[(&str, &str)]) -> Result<String, String> {
|
||||
let env = dotenv::read_env(Path::new(".env"));
|
||||
let env = dotenv::read_env(&crate::core::paths::env_file());
|
||||
let token = api
|
||||
.token()
|
||||
.or_else(|| env.get("KIS_ACCESS_TOKEN").map(String::as_str))
|
||||
.ok_or("로그인이 필요합니다. `openstock api login`을 먼저 실행하세요.")?;
|
||||
let appkey = env
|
||||
.get("KIS_APPKEY")
|
||||
.ok_or("KIS_APPKEY가 .env에 없습니다.")?;
|
||||
.ok_or("KIS_APPKEY가 설정 파일에 없습니다.")?;
|
||||
let appsecret = env
|
||||
.get("KIS_APPSECRET")
|
||||
.ok_or("KIS_APPSECRET가 .env에 없습니다.")?;
|
||||
.ok_or("KIS_APPSECRET가 설정 파일에 없습니다.")?;
|
||||
let tr_id = params
|
||||
.iter()
|
||||
.find(|(key, _)| key.eq_ignore_ascii_case("tr_id"))
|
||||
|
||||
+9
-10
@@ -1,6 +1,5 @@
|
||||
use crate::core::{dotenv, LoginArguments};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const KIS_BASE_URL: &str = "https://openapi.koreainvestment.com:9443";
|
||||
@@ -34,10 +33,10 @@ impl LoginArguments for KisLoginArguments {
|
||||
}
|
||||
}
|
||||
|
||||
/// KIS 실전 REST 접근토큰을 발급하고 .env에 저장한다.
|
||||
/// KIS 실전 REST 접근토큰을 발급하고 설정 파일에 저장한다.
|
||||
pub fn login(args: &KisLoginArguments) -> Result<String, String> {
|
||||
let env_path = Path::new(".env");
|
||||
let env = dotenv::read_env(env_path);
|
||||
let env_path = crate::core::paths::env_file();
|
||||
let env = dotenv::read_env(&env_path);
|
||||
if !args.force {
|
||||
if let Some(access_token) = valid_access_token(&env) {
|
||||
return Ok(access_token.to_string());
|
||||
@@ -99,16 +98,16 @@ pub fn login(args: &KisLoginArguments) -> Result<String, String> {
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
||||
dotenv::write_env(env_path, "KIS_APPKEY", &args.appkey)?;
|
||||
dotenv::write_env(env_path, "KIS_APPSECRET", &args.appsecret)?;
|
||||
dotenv::write_env(env_path, args.token_env_key(), &access_token)?;
|
||||
dotenv::write_env(env_path, "KIS_TOKEN_TYPE", token_type)?;
|
||||
dotenv::write_env(&env_path, "KIS_APPKEY", &args.appkey)?;
|
||||
dotenv::write_env(&env_path, "KIS_APPSECRET", &args.appsecret)?;
|
||||
dotenv::write_env(&env_path, args.token_env_key(), &access_token)?;
|
||||
dotenv::write_env(&env_path, "KIS_TOKEN_TYPE", token_type)?;
|
||||
if !expires_at.is_empty() {
|
||||
dotenv::write_env(env_path, KIS_ACCESS_TOKEN_EXPIRED_AT_KEY, expires_at)?;
|
||||
dotenv::write_env(&env_path, KIS_ACCESS_TOKEN_EXPIRED_AT_KEY, expires_at)?;
|
||||
}
|
||||
if expires_in > 0 {
|
||||
dotenv::write_env(
|
||||
env_path,
|
||||
&env_path,
|
||||
"KIS_ACCESS_TOKEN_EXPIRES_IN",
|
||||
&expires_in.to_string(),
|
||||
)?;
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::apis::kis::KisApi;
|
||||
use crate::core::dotenv;
|
||||
use crate::core::TraderApi;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
const KIS_BASE_URL: &str = "https://openapi.koreainvestment.com:9443";
|
||||
const ORDER_CASH_ENDPOINT: &str = "/uapi/domestic-stock/v1/trading/order-cash";
|
||||
@@ -72,17 +71,17 @@ pub(crate) fn order_cash(
|
||||
}
|
||||
|
||||
fn post_order(api: &KisApi, tr_id: &str, body: &serde_json::Value) -> Result<String, String> {
|
||||
let env = dotenv::read_env(Path::new(".env"));
|
||||
let env = dotenv::read_env(&crate::core::paths::env_file());
|
||||
let token = api
|
||||
.token()
|
||||
.or_else(|| env.get("KIS_ACCESS_TOKEN").map(String::as_str))
|
||||
.ok_or("로그인이 필요합니다. `openstock api login`을 먼저 실행하세요.")?;
|
||||
let appkey = env
|
||||
.get("KIS_APPKEY")
|
||||
.ok_or("KIS_APPKEY가 .env에 없습니다.")?;
|
||||
.ok_or("KIS_APPKEY가 설정 파일에 없습니다.")?;
|
||||
let appsecret = env
|
||||
.get("KIS_APPSECRET")
|
||||
.ok_or("KIS_APPSECRET가 .env에 없습니다.")?;
|
||||
.ok_or("KIS_APPSECRET가 설정 파일에 없습니다.")?;
|
||||
let url = format!("{}{}", KIS_BASE_URL, ORDER_CASH_ENDPOINT);
|
||||
|
||||
let response = crate::core::http::agent()
|
||||
|
||||
+14
-14
@@ -2,7 +2,6 @@ use crate::apis::create_default_registry;
|
||||
use crate::apis::kis::{login::KisLoginArguments, KisApi};
|
||||
use crate::core::TraderApi;
|
||||
use clap::{Args, Subcommand};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ApiCommands {
|
||||
@@ -18,11 +17,11 @@ pub enum ApiCommands {
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct KisLoginCommand {
|
||||
/// 한국투자증권 Open API 앱키 (.env의 KIS_APPKEY보다 우선)
|
||||
/// 한국투자증권 Open API 앱키 (설정 파일의 KIS_APPKEY보다 우선)
|
||||
#[arg(long)]
|
||||
appkey: Option<String>,
|
||||
|
||||
/// 한국투자증권 Open API 앱시크릿 (.env의 KIS_APPSECRET보다 우선)
|
||||
/// 한국투자증권 Open API 앱시크릿 (설정 파일의 KIS_APPSECRET보다 우선)
|
||||
#[arg(long)]
|
||||
appsecret: Option<String>,
|
||||
|
||||
@@ -71,7 +70,7 @@ pub fn handle_api(sub: &ApiCommands) {
|
||||
);
|
||||
}
|
||||
ApiCommands::Login(command) => {
|
||||
let env = crate::core::dotenv::read_env(Path::new(".env"));
|
||||
let env = crate::core::dotenv::read_env(&crate::core::paths::env_file());
|
||||
|
||||
let appkey = command
|
||||
.appkey
|
||||
@@ -111,10 +110,10 @@ pub fn handle_api(sub: &ApiCommands) {
|
||||
),
|
||||
crate::core::output::field(
|
||||
"credential_source",
|
||||
"appkey/appsecret 입력 출처. CLI 옵션이 있으면 우선 사용하고 없으면 .env의 KIS_APPKEY/KIS_APPSECRET을 사용한다.",
|
||||
"appkey/appsecret 입력 출처. CLI 옵션이 있으면 우선 사용하고 없으면 설정 파일의 KIS_APPKEY/KIS_APPSECRET을 사용한다.",
|
||||
serde_json::json!({
|
||||
"appkey": if command.appkey.is_some() { "cli_argument" } else { ".env:KIS_APPKEY" },
|
||||
"appsecret": if command.appsecret.is_some() { "cli_argument" } else { ".env:KIS_APPSECRET" },
|
||||
"appkey": if command.appkey.is_some() { "cli_argument" } else { "config_file:KIS_APPKEY" },
|
||||
"appsecret": if command.appsecret.is_some() { "cli_argument" } else { "config_file:KIS_APPSECRET" },
|
||||
}),
|
||||
),
|
||||
crate::core::output::field(
|
||||
@@ -122,13 +121,14 @@ pub fn handle_api(sub: &ApiCommands) {
|
||||
"발급 또는 재사용된 접근토큰이 저장되는 위치와 키",
|
||||
serde_json::json!({
|
||||
"file": ".env",
|
||||
"path": crate::core::paths::env_file().display().to_string(),
|
||||
"access_token_key": "KIS_ACCESS_TOKEN",
|
||||
"expiration_key": "KIS_ACCESS_TOKEN_EXPIRED_AT",
|
||||
}),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"side_effect",
|
||||
"명령의 외부 부작용. 인증 토큰과 인증 정보를 .env에 저장할 수 있지만 금융 주문은 발생하지 않는다.",
|
||||
"명령의 외부 부작용. 인증 토큰과 인증 정보를 설정 파일에 저장할 수 있지만 금융 주문은 발생하지 않는다.",
|
||||
serde_json::json!("writes_auth_state"),
|
||||
),
|
||||
],
|
||||
@@ -234,25 +234,25 @@ fn api_catalog(api: &dyn TraderApi) -> serde_json::Value {
|
||||
"name": "KIS_APPKEY",
|
||||
"description": "KIS Open API application key used for authentication and every broker API request.",
|
||||
"required_for": ["api login", "api call", "account status", "market", "market history", "order buy", "order sell", "order status"],
|
||||
"source": ".env or api login --appkey"
|
||||
"source": "~/.config/openstock/.env or api login --appkey"
|
||||
},
|
||||
{
|
||||
"name": "KIS_APPSECRET",
|
||||
"description": "KIS Open API application secret used for authentication and every broker API request.",
|
||||
"required_for": ["api login", "api call", "account status", "market", "market history", "order buy", "order sell", "order status"],
|
||||
"source": ".env or api login --appsecret"
|
||||
"source": "~/.config/openstock/.env or api login --appsecret"
|
||||
},
|
||||
{
|
||||
"name": "KIS_ACCESS_TOKEN",
|
||||
"description": "Bearer access token issued by api login. Read commands and order commands require it.",
|
||||
"required_for": ["api call", "account status", "market", "market history", "order buy", "order sell", "order status"],
|
||||
"source": ".env written by api login"
|
||||
"source": "~/.config/openstock/.env written by api login"
|
||||
},
|
||||
{
|
||||
"name": "KIS_ACCOUNT",
|
||||
"description": "Live account identifier in CANO-ACNT_PRDT_CD format, for example 12345678-01. Required for account and order commands.",
|
||||
"required_for": ["account status", "order buy", "order sell", "order status"],
|
||||
"source": ".env"
|
||||
"source": "~/.config/openstock/.env"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
@@ -260,8 +260,8 @@ fn api_catalog(api: &dyn TraderApi) -> serde_json::Value {
|
||||
"command": "api login",
|
||||
"purpose": "Prepare authentication by issuing or reusing a live KIS access token.",
|
||||
"inputs": [
|
||||
{"name": "--appkey", "required": false, "description": "Overrides .env KIS_APPKEY."},
|
||||
{"name": "--appsecret", "required": false, "description": "Overrides .env KIS_APPSECRET."},
|
||||
{"name": "--appkey", "required": false, "description": "Overrides configured KIS_APPKEY."},
|
||||
{"name": "--appsecret", "required": false, "description": "Overrides configured KIS_APPSECRET."},
|
||||
{"name": "--force", "required": false, "description": "Issue a new token even if a valid token already exists."}
|
||||
],
|
||||
"output_contract": "Explained JSON with broker, status, force, credential_source, token_storage, and side_effect fields.",
|
||||
|
||||
+4
-5
@@ -4,8 +4,6 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
const CACHE_ROOT: &str = ".openstock";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CacheStatus {
|
||||
pub root: String,
|
||||
@@ -51,10 +49,11 @@ struct SnapshotFile {
|
||||
}
|
||||
|
||||
pub fn status() -> Result<CacheStatus, String> {
|
||||
let root = Path::new(CACHE_ROOT);
|
||||
let root = crate::core::paths::cache_dir();
|
||||
let root = root.as_path();
|
||||
if !root.exists() {
|
||||
return Ok(CacheStatus {
|
||||
root: CACHE_ROOT.to_string(),
|
||||
root: root.display().to_string(),
|
||||
exists: false,
|
||||
total_files: 0,
|
||||
total_bytes: 0,
|
||||
@@ -74,7 +73,7 @@ pub fn status() -> Result<CacheStatus, String> {
|
||||
)?;
|
||||
|
||||
Ok(CacheStatus {
|
||||
root: CACHE_ROOT.to_string(),
|
||||
root: root.display().to_string(),
|
||||
exists: true,
|
||||
total_files,
|
||||
total_bytes,
|
||||
|
||||
@@ -32,5 +32,8 @@ pub fn write_env(path: &Path, key: &str, value: &str) -> Result<(), String> {
|
||||
for (k, v) in &map {
|
||||
content.push_str(&format!("{}={}\n", k, v));
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!(".env 디렉터리 생성 실패: {}", e))?;
|
||||
}
|
||||
fs::write(path, content).map_err(|e| format!(".env 쓰기 실패: {}", e))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod cache;
|
||||
pub mod dotenv;
|
||||
pub mod http;
|
||||
pub mod output;
|
||||
pub mod paths;
|
||||
pub mod registry;
|
||||
pub mod stock;
|
||||
pub mod trader;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const APP_DIR: &str = "openstock";
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
if let Ok(value) = env::var("OPENSTOCK_CONFIG_DIR") {
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
return PathBuf::from(value);
|
||||
}
|
||||
}
|
||||
|
||||
home_dir()
|
||||
.map(|home| home.join(".config").join(APP_DIR))
|
||||
.unwrap_or_else(|| PathBuf::from(".").join(".config").join(APP_DIR))
|
||||
}
|
||||
|
||||
pub fn env_file() -> PathBuf {
|
||||
config_dir().join(".env")
|
||||
}
|
||||
|
||||
pub fn cache_dir() -> PathBuf {
|
||||
config_dir().join("cache")
|
||||
}
|
||||
|
||||
pub fn cache_namespace(path: &str) -> PathBuf {
|
||||
cache_dir().join(path)
|
||||
}
|
||||
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
env::var_os("HOME")
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn env_file_lives_under_config_dir() {
|
||||
let path = env_file();
|
||||
assert!(path.ends_with(".env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_dir_lives_under_config_dir() {
|
||||
let path = cache_namespace("universe/kind");
|
||||
assert!(path.ends_with("cache/universe/kind"));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ impl TraderBase {
|
||||
|
||||
/// 증권사별 로그인에 필요한 인자들의 공통 트레이트
|
||||
pub trait LoginArguments {
|
||||
/// .env 파일에 저장할 토큰 키 이름 (예: "KIS_ACCESS_TOKEN")
|
||||
/// 설정 파일에 저장할 토큰 키 이름 (예: "KIS_ACCESS_TOKEN")
|
||||
fn token_env_key(&self) -> &'static str;
|
||||
|
||||
/// 구체적인 타입으로 다운캐스팅하기 위한 Any 변환
|
||||
|
||||
@@ -11,7 +11,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const KIND_UNIVERSE_URL: &str =
|
||||
"https://kind.krx.co.kr/corpgeneral/corpList.do?method=download&searchType=13";
|
||||
const CACHE_DIR: &str = ".openstock/universe/kind";
|
||||
const LATEST_FILE: &str = "latest.json";
|
||||
const META_FILE: &str = "meta.json";
|
||||
const SNAPSHOT_RETENTION_FILES: usize = 7;
|
||||
@@ -365,7 +364,7 @@ fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||
}
|
||||
|
||||
fn cache_dir() -> PathBuf {
|
||||
PathBuf::from(CACHE_DIR)
|
||||
crate::core::paths::cache_namespace("universe/kind")
|
||||
}
|
||||
|
||||
fn counts_by_market(stocks: &[Stock]) -> BTreeMap<String, usize> {
|
||||
|
||||
@@ -6,7 +6,6 @@ use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const OPENDART_CORP_CODE_URL: &str = "https://opendart.fss.or.kr/api/corpCode.xml";
|
||||
const CACHE_DIR: &str = ".openstock/opendart";
|
||||
const CORP_CODES_FILE: &str = "corp_codes.json";
|
||||
const META_FILE: &str = "corp_codes_meta.json";
|
||||
|
||||
@@ -116,11 +115,16 @@ pub fn find_by_stock_code<'a>(corps: &'a [DartCorpCode], symbol: &str) -> Option
|
||||
}
|
||||
|
||||
fn api_key() -> Result<String, String> {
|
||||
dotenv::read_env(Path::new(".env"))
|
||||
dotenv::read_env(&crate::core::paths::env_file())
|
||||
.get("OPENDART_API_KEY")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or("OPENDART_API_KEY가 .env에 없습니다.".to_string())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"OPENDART_API_KEY가 설정 파일에 없습니다: {}",
|
||||
crate::core::paths::env_file().display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_corp_code_zip(bytes: &[u8]) -> Result<Vec<DartCorpCode>, String> {
|
||||
@@ -249,7 +253,7 @@ fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||
}
|
||||
|
||||
fn cache_dir() -> PathBuf {
|
||||
PathBuf::from(CACHE_DIR)
|
||||
crate::core::paths::cache_namespace("opendart")
|
||||
}
|
||||
|
||||
fn current_utc_date_string() -> String {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::core::dotenv;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
const OPENDART_LIST_URL: &str = "https://opendart.fss.or.kr/api/list.json";
|
||||
|
||||
@@ -54,11 +53,16 @@ pub fn list(query: &DisclosureQuery) -> Result<serde_json::Value, String> {
|
||||
}
|
||||
|
||||
fn api_key() -> Result<String, String> {
|
||||
dotenv::read_env(Path::new(".env"))
|
||||
dotenv::read_env(&crate::core::paths::env_file())
|
||||
.get("OPENDART_API_KEY")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or("OPENDART_API_KEY가 .env에 없습니다.".to_string())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"OPENDART_API_KEY가 설정 파일에 없습니다: {}",
|
||||
crate::core::paths::env_file().display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_query(query: &DisclosureQuery) -> Result<(), String> {
|
||||
|
||||
@@ -6,7 +6,6 @@ use std::io::{Cursor, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const OPENDART_DOCUMENT_URL: &str = "https://opendart.fss.or.kr/api/document.xml";
|
||||
const CACHE_DIR: &str = ".openstock/opendart/documents";
|
||||
const DOCUMENT_RETENTION_FILES: usize = 100;
|
||||
const DOCUMENT_RETENTION_BYTES: u64 = 200 * 1024 * 1024;
|
||||
|
||||
@@ -302,15 +301,20 @@ fn cache_path(rcept_no: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
fn cache_dir() -> PathBuf {
|
||||
PathBuf::from(CACHE_DIR)
|
||||
crate::core::paths::cache_namespace("opendart/documents")
|
||||
}
|
||||
|
||||
fn api_key() -> Result<String, String> {
|
||||
dotenv::read_env(Path::new(".env"))
|
||||
dotenv::read_env(&crate::core::paths::env_file())
|
||||
.get("OPENDART_API_KEY")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or("OPENDART_API_KEY가 .env에 없습니다.".to_string())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"OPENDART_API_KEY가 설정 파일에 없습니다: {}",
|
||||
crate::core::paths::env_file().display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_rcept_no(value: &str) -> Result<(), String> {
|
||||
|
||||
Reference in New Issue
Block a user