diff --git a/README.md b/README.md index 8a60a2a..5b047d2 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ more ergonomic typed wrappers. workflows. - Typed domestic stock methods for quotation price, balance inquiry, and cash order calls. +- Domain-scoped domestic futures/options inventory methods for 44 + order/account, quotation, and realtime quotation endpoints. - Domain-scoped inventory helpers for 29 domestic stock realtime tryitout endpoints and 18 listed bond endpoints. - Collection-specific overseas futures/options inventory wrapper covering all @@ -123,10 +125,25 @@ The typed SDK currently exposes: | `execute_bond_realtime_tryitout` | `/tryitout/*` | Domain-scoped inventory execution for 3 listed bond realtime tryitout/mock-contract endpoints. This is not a live WebSocket subscription API. | | `execute_overseas_futures_options` | 35 overseas futures/options inventory endpoints | Collection-specific wrapper keyed by `OverseasFuturesOptionsEndpoint`; all bundled endpoints are real-only, required fields are validated from inventory, and real trading mutations are locally blocked. | +The domestic futures/options SDK surface exposes inventory-backed domain +methods for all 44 endpoints in these bundled official collections: + +| Collection | Endpoint count | SDK entry point | +| --- | ---: | --- | +| Domestic futures/options trading/account | 15 | `execute_domestic_futures_options_trading_account` | +| Domestic futures/options quotations | 9 | `execute_domestic_futures_options_quotation` | +| Domestic futures/options realtime quotations | 20 | `execute_domestic_futures_options_realtime_quotation` | + +The same endpoints are also available through the combined +`execute_domestic_futures_options` method. Operation ids are exposed through +`kis_sdk::apis::domestic_futures_options::{TRADING_ACCOUNT_OPERATION_IDS, +QUOTATION_OPERATION_IDS, REALTIME_QUOTATION_OPERATION_IDS}`. + The bundled inventory covers 338 official endpoints across 22 collections. -Endpoints outside the typed SDK surface do not yet have ergonomic typed Rust -request methods, but they can be addressed and called through the lower-level -inventory execution API with stable operation ids: +Endpoints outside the typed domestic stock methods and domain-scoped inventory +surfaces do not yet have ergonomic typed Rust request methods, but they can be +addressed and called through the lower-level inventory execution API with +stable operation ids: ```rust use kis_sdk::endpoint::InventoryRequest; diff --git a/docs/contract-quality-report.md b/docs/contract-quality-report.md index c79695d..c981782 100644 --- a/docs/contract-quality-report.md +++ b/docs/contract-quality-report.md @@ -49,6 +49,26 @@ The initial typed SDK surface intentionally exposes a narrow domestic stock slic | `place_domestic_stock_cash_order` | POST | `/uapi/domestic-stock/v1/trading/order-cash` | Covered | Buy/sell TR IDs are selected by side and environment. Real trading is locally blocked by `KisError::LiveTradingDisabled` before network I/O. | | `execute_overseas_futures_options` | Mixed | 35 `[해외선물옵션]` order/account, quotation, and realtime endpoints | Covered | Uses `OverseasFuturesOptionsEndpoint` enum plus bundled inventory validation. The whole slice is real-only in the captured contract, mock mode rejects it locally, live trading mutations remain disabled, and ambiguous revision/cancel TR IDs require caller override. | +## Domestic Futures/Options SDK Coverage + +The domestic futures/options child surface exposes scoped inventory-backed SDK +methods for all 44 endpoints in the requested official collections: + +| Collection | Endpoint count | Mock support in inventory | SDK coverage | +| --- | ---: | ---: | --- | +| Domestic futures/options trading/account | 15 | 5 real+mock, 10 real-only | Covered by `execute_domestic_futures_options_trading_account` and `TRADING_ACCOUNT_OPERATION_IDS`. | +| Domestic futures/options quotations | 9 | 3 real+mock, 6 real-only | Covered by `execute_domestic_futures_options_quotation` and `QUOTATION_OPERATION_IDS`. | +| Domestic futures/options realtime | 20 | 1 real+mock, 19 real-only | Covered by `execute_domestic_futures_options_realtime_quotation` and `REALTIME_QUOTATION_OPERATION_IDS`. | + +The combined `execute_domestic_futures_options` method accepts the same 44 +operation ids and rejects operation ids outside the domestic futures/options +collections before network I/O. Order-changing endpoints retain local +`KisError::LiveTradingDisabled` protection in `Environment::Real`, and +side/session-specific TR ID metadata requires explicit +`InventoryRequest::tr_id_override(...)` selection. + +## Additional Domain-Scoped SDK Coverage + Domain-scoped inventory helpers also expose stable operation-id constants and execution methods for these follow-on slices: @@ -61,7 +81,7 @@ execution methods for these follow-on slices: The remaining official endpoints are represented in the bundled contract and mock route inventory, but are not yet promoted to typed SDK request/response -methods. +methods or domain-scoped wrappers. ## Domestic Stock REST Inventory API @@ -89,8 +109,8 @@ Executable coverage added in `tests/mock_server_contract.rs` and - Validates source metadata: official URL, checked date, 338 endpoints, 22 collections. - Verifies route index cardinality equals the official endpoint count. -- Verifies domestic stock realtime and listed bond domain helper constants cover - their 47 targeted inventory endpoints exactly. +- Verifies domestic futures/options, domestic stock realtime, and listed bond + domain helper constants cover their 91 targeted inventory endpoints exactly. - Starts the mock server and requests every bundled endpoint. - Confirms 3 auth endpoints return success. - Confirms 43 non-auth `real+mock` endpoints return KIS success envelopes when required headers/TR IDs are supplied. @@ -103,6 +123,8 @@ Executable coverage added in `tests/mock_server_contract.rs` and - Real-to-mock fallback is opt-in and read-only. POST trading fallback is rejected by policy. - Fallback requires separate fallback credentials and fallback bearer token, preventing primary real credentials from crossing into the mock fallback target. - Real cash orders are blocked locally by `KisError::LiveTradingDisabled` before network I/O. +- Domestic futures/options order mutations are blocked locally by `KisError::LiveTradingDisabled` before network I/O. +- Domestic futures/options side/session-specific order TR ID metadata requires the caller to choose the concrete TR ID. - Overseas futures/options order mutations are blocked locally by `KisError::LiveTradingDisabled` before network I/O. - Overseas futures/options revision/cancel keeps the captured ambiguous TR ID boundary and requires the caller to choose the concrete TR ID. - Account/order request validation rejects malformed account, product, quantity, and price fields before network I/O. diff --git a/docs/usage.md b/docs/usage.md index 440effe..f23928d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -8,9 +8,9 @@ their own secret-management path. The typed SDK surface currently covers OAuth token issuance and revoke, WebSocket approval-key issuance, domestic stock price inquiry, domestic stock balance inquiry, and domestic stock cash-order requests. Domain-scoped -inventory helpers cover 29 domestic stock realtime tryitout endpoints and 18 -listed bond endpoints. The shared inventory-backed execution API can also call -every endpoint captured in +inventory helpers cover domestic futures/options, 29 domestic stock realtime +tryitout endpoints, and 18 listed bond endpoints. The shared inventory-backed +execution API can also call every endpoint captured in `contracts/kis_official_endpoint_inventory.compact.json` by stable operation id while follow-on work adds more ergonomic typed wrappers. @@ -202,6 +202,44 @@ filled by the client. Endpoints with ambiguous TR IDs require `Environment::Mock`, and real trading mutations remain blocked locally by `KisError::LiveTradingDisabled`. +## Call A Domestic Futures/Options Endpoint + +Domestic futures/options coverage is exposed as a scoped inventory API for 44 +official endpoints: 15 trading/account endpoints, 9 quotation endpoints, and 20 +realtime quotation endpoints. The operation id constants are available from +`kis_sdk::apis::domestic_futures_options`. + +```rust +use kis_sdk::{ + apis::domestic_futures_options::QUOTATION_OPERATION_IDS, + endpoint::InventoryRequest, +}; +use serde_json::json; + +async fn domestic_futures_options_quote( + client: &kis_sdk::KisClient, +) -> Result<(), kis_sdk::KisError> { + let response = client + .execute_domestic_futures_options_quotation::( + QUOTATION_OPERATION_IDS[0], + InventoryRequest::new().query(json!({ + "FID_COND_MRKT_DIV_CODE": "F", + "FID_INPUT_ISCD": "101W09" + })), + ) + .await?; + + assert!(response.is_success()); + Ok(()) +} +``` + +Order-changing domestic futures/options endpoints keep the same SDK safety +rules as other trading mutations. Inventory metadata with side/session-specific +TR ID text requires `InventoryRequest::tr_id_override(...)`, and real +environment trading mutations are blocked locally by +`KisError::LiveTradingDisabled`. + ## Call A Realtime Tryitout Endpoint Realtime domain helpers use the REST-style `/tryitout/*` shape preserved in the diff --git a/src/apis.rs b/src/apis.rs index 6332496..2e3b434 100644 --- a/src/apis.rs +++ b/src/apis.rs @@ -1,4 +1,5 @@ pub mod bond; +pub mod domestic_futures_options; pub mod domestic_stock; pub mod domestic_stock_realtime; pub mod overseas_futures_options; diff --git a/src/apis/domestic_futures_options.rs b/src/apis/domestic_futures_options.rs new file mode 100644 index 0000000..d5d62e2 --- /dev/null +++ b/src/apis/domestic_futures_options.rs @@ -0,0 +1,153 @@ +use serde::de::DeserializeOwned; + +use crate::{ + client::{KisClient, KisEnvelope}, + endpoint::InventoryRequest, + error::KisError, +}; + +pub const TRADING_ACCOUNT_OPERATION_IDS: [&str; 15] = [ + "domestic_futures_options_trading_account.post_domestic_futureoption_trading_order", + "domestic_futures_options_trading_account.post_domestic_futureoption_trading_order_rvsecncl", + "domestic_futures_options_trading_account.get_domestic_futureoption_trading_inquire_ccnl", + "domestic_futures_options_trading_account.get_domestic_futureoption_trading_inquire_balance", + "domestic_futures_options_trading_account.get_domestic_futureoption_trading_inquire_psbl_order", + "domestic_futures_options_trading_account.get_domestic_futureoption_trading_inquire_ngt_ccnl", + "domestic_futures_options_trading_account.get_domestic_futureoption_trading_inquire_ngt_balance", + "domestic_futures_options_trading_account.get_domestic_futureoption_trading_inquire_psbl_ngt_order", + "domestic_futures_options_trading_account.get_domestic_futureoption_trading_ngt_margin_detail", + "domestic_futures_options_trading_account.get_domestic_futureoption_trading_inquire_balance_settlement_pl", + "domestic_futures_options_trading_account.get_domestic_futureoption_trading_inquire_deposit", + "domestic_futures_options_trading_account.get_domestic_futureoption_trading_inquire_balance_valuation_pl", + "domestic_futures_options_trading_account.get_domestic_futureoption_trading_inquire_ccnl_bstime", + "domestic_futures_options_trading_account.get_domestic_futureoption_trading_inquire_daily_amount_fee", + "domestic_futures_options_trading_account.get_domestic_futureoption_quotations_margin_rate", +]; + +pub const QUOTATION_OPERATION_IDS: [&str; 9] = [ + "domestic_futures_options_quotation.get_domestic_futureoption_quotations_inquire_price", + "domestic_futures_options_quotation.get_domestic_futureoption_quotations_inquire_asking_price", + "domestic_futures_options_quotation.get_domestic_futureoption_quotations_inquire_daily_fuopchartprice", + "domestic_futures_options_quotation.get_domestic_futureoption_quotations_inquire_time_fuopchartprice", + "domestic_futures_options_quotation.get_domestic_futureoption_quotations_display_board_option_list", + "domestic_futures_options_quotation.get_domestic_futureoption_quotations_display_board_top", + "domestic_futures_options_quotation.get_domestic_futureoption_quotations_display_board_callput", + "domestic_futures_options_quotation.get_domestic_futureoption_quotations_display_board_futures", + "domestic_futures_options_quotation.get_domestic_futureoption_quotations_exp_price_trend", +]; + +pub const REALTIME_QUOTATION_OPERATION_IDS: [&str; 20] = [ + "domestic_futures_options_realtime_quotation.post_tryitout_h0ifasp0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0ifcnt0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0ioasp0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0iocnt0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0ifcni0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0cfasp0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0cfcnt0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0zfasp0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0zfcnt0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0zfanc0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0zoasp0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0zocnt0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0zoanc0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0euasp0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0eucnt0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0euanc0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0eucni0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0mfasp0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0mfcnt0", + "domestic_futures_options_realtime_quotation.post_tryitout_h0mfcni0", +]; + +pub fn operation_ids() -> impl Iterator { + TRADING_ACCOUNT_OPERATION_IDS + .into_iter() + .chain(QUOTATION_OPERATION_IDS) + .chain(REALTIME_QUOTATION_OPERATION_IDS) +} + +impl KisClient { + pub async fn execute_domestic_futures_options( + &self, + operation_id: &str, + request: InventoryRequest, + ) -> Result, KisError> + where + T: DeserializeOwned, + { + validate_domestic_futures_options_operation(operation_id)?; + self.execute_inventory(operation_id, request).await + } + + pub async fn execute_domestic_futures_options_trading_account( + &self, + operation_id: &str, + request: InventoryRequest, + ) -> Result, KisError> + where + T: DeserializeOwned, + { + validate_collection_operation( + "domestic futures/options trading/account", + operation_id, + &TRADING_ACCOUNT_OPERATION_IDS, + )?; + self.execute_inventory(operation_id, request).await + } + + pub async fn execute_domestic_futures_options_quotation( + &self, + operation_id: &str, + request: InventoryRequest, + ) -> Result, KisError> + where + T: DeserializeOwned, + { + validate_collection_operation( + "domestic futures/options quotation", + operation_id, + "ATION_OPERATION_IDS, + )?; + self.execute_inventory(operation_id, request).await + } + + pub async fn execute_domestic_futures_options_realtime_quotation( + &self, + operation_id: &str, + request: InventoryRequest, + ) -> Result, KisError> + where + T: DeserializeOwned, + { + validate_collection_operation( + "domestic futures/options realtime quotation", + operation_id, + &REALTIME_QUOTATION_OPERATION_IDS, + )?; + self.execute_inventory(operation_id, request).await + } +} + +fn validate_domestic_futures_options_operation(operation_id: &str) -> Result<(), KisError> { + if operation_ids().any(|candidate| candidate == operation_id) { + Ok(()) + } else { + Err(KisError::Validation(format!( + "operation id {operation_id} is not a domestic futures/options endpoint" + ))) + } +} + +fn validate_collection_operation( + collection: &str, + operation_id: &str, + allowed: &[&str], +) -> Result<(), KisError> { + if allowed.contains(&operation_id) { + Ok(()) + } else { + Err(KisError::Validation(format!( + "operation id {operation_id} is not a {collection} endpoint" + ))) + } +} diff --git a/tests/sdk_core.rs b/tests/sdk_core.rs index 8a676a6..2dccc8b 100644 --- a/tests/sdk_core.rs +++ b/tests/sdk_core.rs @@ -9,6 +9,10 @@ use kis_sdk::{ self, BOND_QUOTATION_OPERATIONS, BOND_REALTIME_TRYITOUT_OPERATIONS, BOND_TRADING_ACCOUNT_OPERATIONS, }, + domestic_futures_options::{ + operation_ids as domestic_futures_options_operation_ids, QUOTATION_OPERATION_IDS, + REALTIME_QUOTATION_OPERATION_IDS, TRADING_ACCOUNT_OPERATION_IDS, + }, domestic_stock::{ domestic_stock_rest_endpoints, CashOrderRequest, CashOrderSide, InquireBalanceRequest, InquirePriceRequest, DOMESTIC_STOCK_REST_COLLECTIONS, @@ -829,6 +833,159 @@ async fn inventory_execute_rejects_real_only_endpoint_in_mock_before_network() { assert!(matches!(error, KisError::UnsupportedEnvironment { .. })); } +#[test] +fn domestic_futures_options_operation_ids_cover_inventory_collections() { + let catalog = InventoryCatalog::bundled().expect("inventory catalog builds"); + let actual = catalog + .endpoints() + .iter() + .filter(|endpoint| endpoint.collection_name.starts_with("[국내선물옵션]")) + .map(|endpoint| endpoint.operation_id.as_str()) + .collect::>(); + let expected = + domestic_futures_options_operation_ids().collect::>(); + + assert_eq!(TRADING_ACCOUNT_OPERATION_IDS.len(), 15); + assert_eq!(QUOTATION_OPERATION_IDS.len(), 9); + assert_eq!(REALTIME_QUOTATION_OPERATION_IDS.len(), 20); + assert_eq!(expected.len(), 44); + assert_eq!(actual, expected); +} + +#[tokio::test] +async fn domestic_futures_options_quotation_executes_real_mock_endpoint() { + let server = MockServer::start().await.expect("mock server starts"); + let client = KisClient::builder(Environment::Mock) + .base_url(server.base_url()) + .app_credentials(AppCredentials::new("test_app_key", "test_app_secret")) + .static_bearer_token("test_access_token") + .build() + .expect("client builds"); + + let response = client + .execute_domestic_futures_options_quotation::( + "domestic_futures_options_quotation.get_domestic_futureoption_quotations_inquire_price", + InventoryRequest::new().query(json!({ + "FID_COND_MRKT_DIV_CODE": "F", + "FID_INPUT_ISCD": "101W09" + })), + ) + .await + .expect("domestic futures/options quote succeeds"); + + assert!(response.is_success()); + assert!(response.output.is_some()); + + server.shutdown().await; +} + +#[tokio::test] +async fn domestic_futures_options_scope_rejects_non_domain_operation_id() { + let client = KisClient::builder(Environment::Mock) + .base_url("http://127.0.0.1:9") + .app_credentials(AppCredentials::new("test_app_key", "test_app_secret")) + .static_bearer_token("test_access_token") + .build() + .expect("client builds"); + + let error = client + .execute_domestic_futures_options::( + "domestic_stock_quotation.get_domestic_stock_quotations_inquire_price", + InventoryRequest::new(), + ) + .await + .expect_err("non-domain operation should fail locally"); + + assert!(matches!(error, KisError::Validation(_))); +} + +#[tokio::test] +async fn domestic_futures_options_order_requires_explicit_tr_id_override() { + let client = KisClient::builder(Environment::Mock) + .base_url("http://127.0.0.1:9") + .app_credentials(AppCredentials::new("test_app_key", "test_app_secret")) + .static_bearer_token("test_access_token") + .build() + .expect("client builds"); + + let body = json!({ + "ACNT_PRDT_CD": "03", + "CANO": "12345678", + "ORD_DVSN_CD": "01", + "ORD_PRCS_DVSN_CD": "02", + "ORD_QTY": "1", + "SHTN_PDNO": "101W09", + "SLL_BUY_DVSN_CD": "02", + "UNIT_PRICE": "350.00" + }); + + let error = client + .execute_domestic_futures_options_trading_account::( + "domestic_futures_options_trading_account.post_domestic_futureoption_trading_order", + InventoryRequest::new().body(body), + ) + .await + .expect_err("ambiguous domestic futures/options order TR ID should require override"); + + assert!(matches!(error, KisError::AmbiguousTrId { .. })); +} + +#[tokio::test] +async fn domestic_futures_options_order_with_override_hits_live_trading_guard_in_real() { + let client = KisClient::builder(Environment::Real) + .base_url("http://127.0.0.1:9") + .app_credentials(AppCredentials::new("test_app_key", "test_app_secret")) + .static_bearer_token("test_access_token") + .build() + .expect("client builds"); + + let body = json!({ + "ACNT_PRDT_CD": "03", + "CANO": "12345678", + "ORD_DVSN_CD": "01", + "ORD_PRCS_DVSN_CD": "02", + "ORD_QTY": "1", + "SHTN_PDNO": "101W09", + "SLL_BUY_DVSN_CD": "02", + "UNIT_PRICE": "350.00" + }); + + let error = client + .execute_domestic_futures_options_trading_account::( + "domestic_futures_options_trading_account.post_domestic_futureoption_trading_order", + InventoryRequest::new() + .body(body) + .tr_id_override("TTTO1101U"), + ) + .await + .expect_err("real trading mutation should be locally blocked"); + + assert!(matches!(error, KisError::LiveTradingDisabled { .. })); +} + +#[tokio::test] +async fn domestic_futures_options_realtime_validates_required_headers() { + let client = KisClient::builder(Environment::Real) + .base_url("http://127.0.0.1:9") + .app_credentials(AppCredentials::new("test_app_key", "test_app_secret")) + .static_bearer_token("test_access_token") + .build() + .expect("client builds"); + + let error = client + .execute_domestic_futures_options_realtime_quotation::( + "domestic_futures_options_realtime_quotation.post_tryitout_h0ifasp0", + InventoryRequest::new().body(json!({ + "tr_id": "H0IFASP0", + "tr_key": "101W09" + })), + ) + .await + .expect_err("missing approval_key and tr_type should fail locally"); + + assert!(matches!(error, KisError::Validation(_))); +} + #[tokio::test] async fn client_uses_static_token_and_redacts_secret_values() { let server = MockServer::start().await.expect("mock server starts");