Add stock scoring commands
CI / test (push) Successful in 2m13s

This commit is contained in:
2026-06-09 18:18:07 +09:00
parent 347c9546ed
commit b3feec08db
14 changed files with 503 additions and 0 deletions
+62
View File
@@ -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 <symbol> <score>`
종목 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 <symbol>`
종목 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 <symbol>`
종목 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를 갱신합니다.
+1
View File
@@ -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` |
@@ -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/` |
@@ -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.
@@ -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.
@@ -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 <symbol> <score>` | Save or update a stock score. |
| `openstock score get <symbol>` | Read one stock score. |
| `openstock score list` | List all saved scores. |
| `openstock score delete <symbol>` | 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.
@@ -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.
@@ -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.
+2
View File
@@ -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;
+160
View File
@@ -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,
)
);
}
+1
View File
@@ -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;
+10
View File
@@ -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"));
}
}
+150
View File
@@ -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<String, StockScore>,
}
#[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<StockScore, String> {
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<Option<StockScore>, String> {
let symbol = normalize_symbol(symbol)?;
Ok(load()?.scores.get(&symbol).cloned())
}
pub fn list() -> Result<Vec<StockScore>, String> {
let mut scores = load()?.scores.into_values().collect::<Vec<_>>();
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<Option<StockScore>, 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<StockScoreStore, String> {
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<String, String> {
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());
}
}
+8
View File
@@ -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!(