From b3feec08db3597df1711446da1a643c5282ece40 Mon Sep 17 00:00:00 2001 From: Hana Date: Tue, 9 Jun 2026 18:18:07 +0900 Subject: [PATCH] Add stock scoring commands --- README.md | 62 +++++++ skills/openstock-agent-cli/SKILL.md | 1 + .../references/commands/guide.md | 2 + .../references/commands/score/delete/guide.md | 17 ++ .../references/commands/score/get/guide.md | 17 ++ .../references/commands/score/guide.md | 41 +++++ .../references/commands/score/list/guide.md | 15 ++ .../references/commands/score/set/guide.md | 17 ++ src/commands/mod.rs | 2 + src/commands/score.rs | 160 ++++++++++++++++++ src/core/mod.rs | 1 + src/core/paths.rs | 10 ++ src/core/scores.rs | 150 ++++++++++++++++ src/main.rs | 8 + 14 files changed, 503 insertions(+) create mode 100644 skills/openstock-agent-cli/references/commands/score/delete/guide.md create mode 100644 skills/openstock-agent-cli/references/commands/score/get/guide.md create mode 100644 skills/openstock-agent-cli/references/commands/score/guide.md create mode 100644 skills/openstock-agent-cli/references/commands/score/list/guide.md create mode 100644 skills/openstock-agent-cli/references/commands/score/set/guide.md create mode 100644 src/commands/score.rs create mode 100644 src/core/scores.rs diff --git a/README.md b/README.md index 62293ba..d9de853 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,19 @@ docker compose logs --tail=120 openstock-runner | `reuters_code` | string | Reuters style code입니다. 없으면 빈 문자열일 수 있습니다. | | `url` | string | Naver 모바일 종목 페이지 URL입니다. | +#### `score set`, `score get`, `score list`, `score delete` + +| Field | Type | Meaning | +| --- | --- | --- | +| `path` | string | 종목 평가 점수를 저장하는 파일 경로입니다. 기본값은 `~/.config/openstock/scores.json`입니다. | +| `symbol` | string | 점수를 매긴 종목코드 또는 종목 ID입니다. 영문자는 대문자로 정규화됩니다. | +| `score` | number | 종목 평가 점수입니다. 0은 최저, 100은 최고입니다. | +| `updated_at_unix` | number | 점수를 저장하거나 갱신한 Unix timestamp(초)입니다. | +| `count` | number | `score list`가 반환한 저장 점수 개수입니다. | +| `scores` | array | `score list`가 반환한 점수 목록입니다. 점수 내림차순, 종목 ID 오름차순으로 정렬됩니다. | +| `deleted` | boolean | `score delete`에서 저장된 점수가 실제로 삭제되었는지 여부입니다. | +| `removed` | object/null | `score delete`에서 삭제된 기존 점수 기록입니다. | + #### `universe sync`, `universe status` | Field | Type | Meaning | @@ -566,6 +579,7 @@ cp -R .openstock/* ~/.config/openstock/cache/ | Path | Writer | Reader | Retention | Description | | --- | --- | --- | --- | --- | +| `~/.config/openstock/scores.json` | `score set`, `score delete` | `score get`, `score list` | protected | 종목 ID별 0~100 평가 점수와 갱신시각입니다. `.env`와 같은 설정 디렉터리에 저장됩니다. | | `~/.config/openstock/cache/universe/kind/latest.json` | `universe sync` | `universe status/list/chunks/validate` | protected | KIND 상장법인목록 최신 normalized stock list입니다. | | `~/.config/openstock/cache/universe/kind/meta.json` | `universe sync` | `universe status/*` | protected | universe cache metadata입니다. | | `~/.config/openstock/cache/universe/kind/YYYY-MM-DD.json` | `universe sync` | audit/manual use | max 7 files or 25MB | 날짜별 universe snapshot입니다. | @@ -661,6 +675,54 @@ Naver 모바일 주식 검색 API로 종목 후보를 검색합니다. `stocks` 항목은 가능한 경우 `code`, `name`, `market`, `market_code`, `nation_code`, `category`, `reuters_code`, `url`을 포함합니다. +### `openstock score set ` + +종목 ID에 0~100점 평가 점수를 저장하거나 갱신합니다. + +| Direction | Data | +| --- | --- | +| Input | `symbol`, `score` | +| File read/write | `~/.config/openstock/scores.json` | +| Output fields | `path`, `symbol`, `score`, `updated_at_unix` | +| Raw | `null` | +| Side effect | 종목 평가 점수 파일을 생성하거나 갱신합니다. | + +### `openstock score get ` + +종목 ID의 저장된 평가 점수를 조회합니다. + +| Direction | Data | +| --- | --- | +| Input | `symbol` | +| File read | `~/.config/openstock/scores.json` | +| Output fields | `path`, `symbol`, `score`, `updated_at_unix` | +| Raw | `null` | +| Side effect | 없음 | + +### `openstock score list` + +저장된 종목 평가 점수 목록을 점수 내림차순으로 조회합니다. + +| Direction | Data | +| --- | --- | +| Input | 없음 | +| File read | `~/.config/openstock/scores.json` | +| Output fields | `path`, `count`, `scores` | +| Raw | `null` | +| Side effect | 없음 | + +### `openstock score delete ` + +종목 ID의 저장된 평가 점수를 삭제합니다. + +| Direction | Data | +| --- | --- | +| Input | `symbol` | +| File read/write | `~/.config/openstock/scores.json` | +| Output fields | `path`, `symbol`, `deleted`, `removed` | +| Raw | `null` | +| Side effect | 저장된 점수가 있으면 해당 기록을 삭제합니다. | + ### `openstock universe sync [--force]` KIND 상장법인목록을 받아 stock universe cache를 갱신합니다. diff --git a/skills/openstock-agent-cli/SKILL.md b/skills/openstock-agent-cli/SKILL.md index 2c4c39a..164c133 100644 --- a/skills/openstock-agent-cli/SKILL.md +++ b/skills/openstock-agent-cli/SKILL.md @@ -73,6 +73,7 @@ Commands are documented by CLI depth under `references/commands`. | `openstock version` | `references/commands/version/guide.md` | | `openstock update` | `references/commands/update/guide.md` | | `openstock search` | `references/commands/search/guide.md` | +| `openstock score *` | `references/commands/score/guide.md` | | `openstock api *` | `references/commands/api/guide.md` | | `openstock account *` | `references/commands/account/guide.md` | | `openstock market *` | `references/commands/market/guide.md` | diff --git a/skills/openstock-agent-cli/references/commands/guide.md b/skills/openstock-agent-cli/references/commands/guide.md index 1e79308..119a305 100644 --- a/skills/openstock-agent-cli/references/commands/guide.md +++ b/skills/openstock-agent-cli/references/commands/guide.md @@ -7,6 +7,7 @@ This directory mirrors the `openstock` CLI command depth for agent execution. | `openstock version` | `version/` | | `openstock update` | `update/` | | `openstock search` | `search/` | +| `openstock score ...` | `score/` | | `openstock api ...` | `api/` | | `openstock account ...` | `account/` | | `openstock market ...` | `market/` | @@ -20,6 +21,7 @@ Subcommands also have leaf folders matching CLI depth, for example: | CLI Depth | Folder | | --- | --- | | `openstock api login` | `api/login/` | +| `openstock score set` | `score/set/` | | `openstock account status` | `account/status/` | | `openstock market history` | `market/history/` | | `openstock universe chunks` | `universe/chunks/` | diff --git a/skills/openstock-agent-cli/references/commands/score/delete/guide.md b/skills/openstock-agent-cli/references/commands/score/delete/guide.md new file mode 100644 index 0000000..c66667e --- /dev/null +++ b/skills/openstock-agent-cli/references/commands/score/delete/guide.md @@ -0,0 +1,17 @@ +# `openstock score delete` + +Purpose: delete a saved stock evaluation score. + +Usage: + +```bash +openstock score delete 005930 +``` + +Inputs: `symbol`. + +Writes: `~/.config/openstock/scores.json` when a record exists. + +Output fields: `path`, `symbol`, `deleted`, `removed`. + +Agent rule: deletion changes local evaluation state; run only when requested. diff --git a/skills/openstock-agent-cli/references/commands/score/get/guide.md b/skills/openstock-agent-cli/references/commands/score/get/guide.md new file mode 100644 index 0000000..73232ea --- /dev/null +++ b/skills/openstock-agent-cli/references/commands/score/get/guide.md @@ -0,0 +1,17 @@ +# `openstock score get` + +Purpose: read a saved stock evaluation score. + +Usage: + +```bash +openstock score get 005930 +``` + +Inputs: `symbol`. + +Reads: `~/.config/openstock/scores.json`. + +Output fields: `path`, `symbol`, `score`, `updated_at_unix`. + +Agent rule: if the command fails with no saved score, do not infer a score of 0. diff --git a/skills/openstock-agent-cli/references/commands/score/guide.md b/skills/openstock-agent-cli/references/commands/score/guide.md new file mode 100644 index 0000000..73c07e9 --- /dev/null +++ b/skills/openstock-agent-cli/references/commands/score/guide.md @@ -0,0 +1,41 @@ +# `openstock score` + +## Purpose + +Store and query local stock evaluation scores by stock ID. + +## Storage + +Scores are stored beside `.env` under the OpenStock config directory: + +```text +~/.config/openstock/scores.json +``` + +Each score is an integer from 0 to 100. `0` is the lowest evaluation and `100` is the highest. + +## Subcommands + +| Command | Purpose | +| --- | --- | +| `openstock score set ` | Save or update a stock score. | +| `openstock score get ` | Read one stock score. | +| `openstock score list` | List all saved scores. | +| `openstock score delete ` | Delete one stock score. | + +## Output Fields + +| Field | Meaning | +| --- | --- | +| `path` | Score file path. | +| `symbol` | Normalized stock ID. | +| `score` | 0 to 100 score. | +| `updated_at_unix` | Update Unix timestamp in seconds. | +| `count` | Number of saved scores. | +| `scores` | Score records sorted by score descending. | +| `deleted` | Whether a record was deleted. | +| `removed` | Deleted record, if any. | + +## Agent Notes + +Use this for user- or strategy-defined evaluation state. Do not treat the score as market evidence unless the workflow defines how it was produced. diff --git a/skills/openstock-agent-cli/references/commands/score/list/guide.md b/skills/openstock-agent-cli/references/commands/score/list/guide.md new file mode 100644 index 0000000..d8727cb --- /dev/null +++ b/skills/openstock-agent-cli/references/commands/score/list/guide.md @@ -0,0 +1,15 @@ +# `openstock score list` + +Purpose: list saved stock evaluation scores. + +Usage: + +```bash +openstock score list +``` + +Reads: `~/.config/openstock/scores.json`. + +Output fields: `path`, `count`, `scores`. + +Agent rule: scores are sorted by score descending, then symbol ascending. diff --git a/skills/openstock-agent-cli/references/commands/score/set/guide.md b/skills/openstock-agent-cli/references/commands/score/set/guide.md new file mode 100644 index 0000000..4340d53 --- /dev/null +++ b/skills/openstock-agent-cli/references/commands/score/set/guide.md @@ -0,0 +1,17 @@ +# `openstock score set` + +Purpose: save or update a stock evaluation score. + +Usage: + +```bash +openstock score set 005930 87 +``` + +Inputs: `symbol`, `score` from 0 to 100. + +Writes: `~/.config/openstock/scores.json`. + +Output fields: `path`, `symbol`, `score`, `updated_at_unix`. + +Agent rule: use only when the user or scoring workflow has produced a concrete score. diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 65734f0..ce7a064 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod cache; pub mod dart; pub mod market; pub mod order; +pub mod score; pub mod search; pub mod universe; pub mod update; @@ -15,6 +16,7 @@ pub use cache::handle_cache; pub use dart::handle_dart; pub use market::handle_market; pub use order::handle_order; +pub use score::handle_score; pub use search::handle_search; pub use universe::handle_universe; pub use update::handle_update; diff --git a/src/commands/score.rs b/src/commands/score.rs new file mode 100644 index 0000000..6b28b34 --- /dev/null +++ b/src/commands/score.rs @@ -0,0 +1,160 @@ +use clap::{Args, Subcommand}; + +#[derive(Subcommand)] +pub enum ScoreCommands { + /// 종목 평가 점수 저장 또는 갱신 + Set(ScoreSetCommand), + + /// 종목 평가 점수 조회 + Get(ScoreSymbolCommand), + + /// 저장된 종목 평가 점수 목록 조회 + List, + + /// 종목 평가 점수 삭제 + Delete(ScoreSymbolCommand), +} + +#[derive(Args)] +pub struct ScoreSetCommand { + /// 종목코드 또는 종목 ID + pub symbol: String, + + /// 종목 평가 점수 (0~100) + pub score: u16, +} + +#[derive(Args)] +pub struct ScoreSymbolCommand { + /// 종목코드 또는 종목 ID + pub symbol: String, +} + +pub fn handle_score(sub: &ScoreCommands) { + match sub { + ScoreCommands::Set(command) => { + match crate::core::scores::set(&command.symbol, command.score) { + Ok(record) => print_record("score set", "종목 평가 점수 저장 결과", record), + Err(err) => eprintln!( + "{}", + crate::core::output::error("score set", "종목 평가 점수 저장 실패", &err) + ), + } + } + ScoreCommands::Get(command) => match crate::core::scores::get(&command.symbol) { + Ok(Some(record)) => print_record("score get", "종목 평가 점수 조회 결과", record), + Ok(None) => eprintln!( + "{}", + crate::core::output::error( + "score get", + "종목 평가 점수 조회 실패", + "해당 종목의 저장된 점수가 없습니다.", + ) + ), + Err(err) => eprintln!( + "{}", + crate::core::output::error("score get", "종목 평가 점수 조회 실패", &err) + ), + }, + ScoreCommands::List => match crate::core::scores::list() { + Ok(scores) => println!( + "{}", + crate::core::output::explained_with_raw( + "score list", + "저장된 종목 평가 점수 목록", + vec![ + crate::core::output::field( + "path", + "종목 평가 점수를 저장하는 파일 경로", + serde_json::json!(crate::core::scores::path()), + ), + crate::core::output::field( + "count", + "저장된 종목 평가 점수 개수", + serde_json::json!(scores.len()), + ), + crate::core::output::field( + "scores", + "점수 내림차순으로 정렬한 종목 평가 목록", + serde_json::json!(scores), + ), + ], + serde_json::Value::Null, + ) + ), + Err(err) => eprintln!( + "{}", + crate::core::output::error("score list", "종목 평가 점수 목록 조회 실패", &err) + ), + }, + ScoreCommands::Delete(command) => match crate::core::scores::delete(&command.symbol) { + Ok(removed) => println!( + "{}", + crate::core::output::explained_with_raw( + "score delete", + "종목 평가 점수 삭제 결과", + vec![ + crate::core::output::field( + "path", + "종목 평가 점수를 저장하는 파일 경로", + serde_json::json!(crate::core::scores::path()), + ), + crate::core::output::field( + "symbol", + "삭제 요청한 종목코드 또는 종목 ID", + serde_json::json!(command.symbol.trim().to_ascii_uppercase()), + ), + crate::core::output::field( + "deleted", + "저장된 점수가 존재해서 삭제되었는지 여부", + serde_json::json!(removed.is_some()), + ), + crate::core::output::field( + "removed", + "삭제된 기존 점수 기록", + serde_json::json!(removed), + ), + ], + serde_json::Value::Null, + ) + ), + Err(err) => eprintln!( + "{}", + crate::core::output::error("score delete", "종목 평가 점수 삭제 실패", &err) + ), + }, + } +} + +fn print_record(command: &str, description: &str, record: crate::core::scores::StockScore) { + println!( + "{}", + crate::core::output::explained_with_raw( + command, + description, + vec![ + crate::core::output::field( + "path", + "종목 평가 점수를 저장하는 파일 경로", + serde_json::json!(crate::core::scores::path()), + ), + crate::core::output::field( + "symbol", + "점수를 매긴 종목코드 또는 종목 ID", + serde_json::json!(record.symbol), + ), + crate::core::output::field( + "score", + "종목 평가 점수. 0은 최저, 100은 최고", + serde_json::json!(record.score), + ), + crate::core::output::field( + "updated_at_unix", + "점수를 저장하거나 갱신한 Unix timestamp(초)", + serde_json::json!(record.updated_at_unix), + ), + ], + serde_json::Value::Null, + ) + ); +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 4adc6b7..51abc7f 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -4,6 +4,7 @@ pub mod http; pub mod output; pub mod paths; pub mod registry; +pub mod scores; pub mod stock; pub mod trader; diff --git a/src/core/paths.rs b/src/core/paths.rs index 65dc3e2..e315982 100644 --- a/src/core/paths.rs +++ b/src/core/paths.rs @@ -20,6 +20,10 @@ pub fn env_file() -> PathBuf { config_dir().join(".env") } +pub fn score_file() -> PathBuf { + config_dir().join("scores.json") +} + pub fn cache_dir() -> PathBuf { config_dir().join("cache") } @@ -49,4 +53,10 @@ mod tests { let path = cache_namespace("universe/kind"); assert!(path.ends_with("cache/universe/kind")); } + + #[test] + fn score_file_lives_under_config_dir() { + let path = score_file(); + assert!(path.ends_with("scores.json")); + } } diff --git a/src/core/scores.rs b/src/core/scores.rs new file mode 100644 index 0000000..5bff501 --- /dev/null +++ b/src/core/scores.rs @@ -0,0 +1,150 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StockScoreStore { + pub version: u32, + pub scores: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StockScore { + pub symbol: String, + pub score: u8, + pub updated_at_unix: u64, +} + +impl Default for StockScoreStore { + fn default() -> Self { + Self { + version: 1, + scores: BTreeMap::new(), + } + } +} + +pub fn set(symbol: &str, score: u16) -> Result { + validate_score(score)?; + let symbol = normalize_symbol(symbol)?; + let mut store = load()?; + let record = StockScore { + symbol: symbol.clone(), + score: score as u8, + updated_at_unix: current_unix_timestamp(), + }; + store.scores.insert(symbol, record.clone()); + save(&store)?; + Ok(record) +} + +pub fn get(symbol: &str) -> Result, String> { + let symbol = normalize_symbol(symbol)?; + Ok(load()?.scores.get(&symbol).cloned()) +} + +pub fn list() -> Result, String> { + let mut scores = load()?.scores.into_values().collect::>(); + scores.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.symbol.cmp(&b.symbol))); + Ok(scores) +} + +pub fn delete(symbol: &str) -> Result, String> { + let symbol = normalize_symbol(symbol)?; + let mut store = load()?; + let removed = store.scores.remove(&symbol); + if removed.is_some() { + save(&store)?; + } + Ok(removed) +} + +pub fn path() -> std::path::PathBuf { + crate::core::paths::score_file() +} + +fn load() -> Result { + let path = path(); + if !path.exists() { + return Ok(StockScoreStore::default()); + } + let text = fs::read_to_string(&path) + .map_err(|err| format!("종목 점수 파일 읽기 실패 ({}): {}", path.display(), err))?; + serde_json::from_str(&text).map_err(|err| { + format!( + "종목 점수 파일 JSON 파싱 실패 ({}): {}", + path.display(), + err + ) + }) +} + +fn save(store: &StockScoreStore) -> Result<(), String> { + let path = path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("설정 디렉터리 생성 실패 ({}): {}", parent.display(), err))?; + } + let text = serde_json::to_string_pretty(store) + .map_err(|err| format!("종목 점수 JSON 직렬화 실패: {}", err))?; + write_atomic(&path, &text) +} + +fn write_atomic(path: &Path, text: &str) -> Result<(), String> { + let tmp_path = path.with_extension("json.tmp"); + fs::write(&tmp_path, text).map_err(|err| { + format!( + "종목 점수 임시 파일 쓰기 실패 ({}): {}", + tmp_path.display(), + err + ) + })?; + fs::rename(&tmp_path, path).map_err(|err| { + format!( + "종목 점수 파일 교체 실패 ({} -> {}): {}", + tmp_path.display(), + path.display(), + err + ) + }) +} + +fn normalize_symbol(symbol: &str) -> Result { + let symbol = symbol.trim().to_ascii_uppercase(); + crate::core::stock::validate_symbol(&symbol)?; + Ok(symbol) +} + +fn validate_score(score: u16) -> Result<(), String> { + if score <= 100 { + Ok(()) + } else { + Err("종목 점수는 0 이상 100 이하이어야 합니다.".to_string()) + } +} + +fn current_unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_symbol_to_uppercase() { + assert_eq!(normalize_symbol(" a123 ").unwrap(), "A123"); + } + + #[test] + fn accepts_score_bounds() { + assert!(validate_score(0).is_ok()); + assert!(validate_score(100).is_ok()); + assert!(validate_score(101).is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 6c3a849..88353ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use commands::cache::CacheCommands; use commands::dart::DartCommands; use commands::market::MarketCommand; use commands::order::OrderCommands; +use commands::score::ScoreCommands; use commands::universe::UniverseCommands; mod apis; @@ -74,6 +75,12 @@ enum Commands { query: String, }, + /// 종목 평가 점수 저장 및 조회 + Score { + #[command(subcommand)] + sub: ScoreCommands, + }, + /// 종목 정보 및 기업정보 조회 Market(MarketCommand), } @@ -91,6 +98,7 @@ fn main() { Some(Commands::Order { sub }) => commands::handle_order(sub), Some(Commands::Universe { sub }) => commands::handle_universe(sub), Some(Commands::Search { query }) => commands::handle_search(query), + Some(Commands::Score { sub }) => commands::handle_score(sub), Some(Commands::Market(command)) => commands::handle_market(command), None => { println!(