Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ Rust SDK core for Korea Investment & Securities Open API.

`kis-sdk` is an early Rust client and local mock contract harness for KIS Open
API integrations. The current typed SDK surface is intentionally narrow: it
focuses on OAuth token issuance and a small domestic stock slice while the
bundled mock server tracks the broader official endpoint inventory captured for
this project.
focuses on OAuth token issuance and a small domestic stock slice. A lower-level
inventory-backed execution API can address and call the broader bundled
official endpoint inventory by stable operation id while follow-on work adds
more ergonomic typed wrappers.

## Current Status

Expand All @@ -27,6 +28,9 @@ this project.
injection for tests and mock workflows.
- Typed domestic stock methods for quotation price, balance inquiry, and cash
order calls.
- Inventory-backed `execute_inventory` support for the bundled official
endpoint inventory, including required input/header validation and TR ID
selection rules from the captured metadata.
- Local mock server generated from the bundled official endpoint inventory.
- Explicit `RetryPolicy` and `FallbackPolicy` options. Retry is disabled by
default. `RetryPolicy::conservative_reads()` retries retryable GET/read
Expand Down Expand Up @@ -103,9 +107,31 @@ The typed SDK currently exposes:
| `inquire_domestic_stock_balance` | `/uapi/domestic-stock/v1/trading/inquire-balance` | Domestic stock balance read. |
| `place_domestic_stock_cash_order` | `/uapi/domestic-stock/v1/trading/order-cash` | Mock cash orders are supported; real cash orders are locally blocked by `KisError::LiveTradingDisabled`. |

The bundled contract and mock route inventory cover 338 official endpoints
across 22 collections. Endpoints outside the typed SDK surface are available as
contract/mock evidence, not as first-class typed Rust request methods yet.
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:

```rust
use kis_sdk::endpoint::InventoryRequest;
use serde_json::json;

let response = client
.execute_inventory::<serde_json::Value>(
"domestic_stock_quotation.get_domestic_stock_quotations_inquire_price",
InventoryRequest::new().query(json!({
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": "005930"
})),
)
.await?;
```

The inventory execution API follows the same safety boundary as the typed
methods: required query/body/non-standard header fields are validated before
network I/O, standard KIS headers are filled by the client, ambiguous TR IDs
require an explicit override, real-only endpoints are rejected in mock mode, and
real trading mutations are locally blocked.

## Credentials And Safety

Expand Down
39 changes: 36 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ their own secret-management path.

The typed SDK surface currently covers OAuth token issuance, domestic stock
price inquiry, domestic stock balance inquiry, and domestic stock cash-order
requests. The bundled mock server covers the captured official endpoint
inventory more broadly, but endpoints outside the typed surface are not yet
available as first-class Rust methods.
requests. 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.

## Prerequisites

Expand Down Expand Up @@ -97,6 +97,39 @@ async fn inquire_price(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisEr
The current output type preserves provider fields as `serde_json::Value` so the
SDK can expose the endpoint before broad typed response structs are finalized.

## Call An Inventory Endpoint

Use `InventoryCatalog` to inspect generated operation ids and
`execute_inventory` to call a captured endpoint directly:

```rust
use kis_sdk::endpoint::InventoryRequest;
use serde_json::json;

async fn inventory_quote(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisError> {
let response = client
.execute_inventory::<serde_json::Value>(
"domestic_stock_quotation.get_domestic_stock_quotations_inquire_price",
InventoryRequest::new().query(json!({
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": "005930"
})),
)
.await?;

assert!(response.is_success());
Ok(())
}
```

The inventory layer validates required query, body, and non-standard header
fields before network I/O. Standard KIS headers such as `appkey`, `appsecret`,
`authorization`, `custtype`, `content-type`, and unambiguous `tr_id` values are
filled by the client. Endpoints with ambiguous TR IDs require
`InventoryRequest::tr_id_override(...)`. Real-only endpoints are rejected in
`Environment::Mock`, and real trading mutations remain blocked locally by
`KisError::LiveTradingDisabled`.

## Inquire A Domestic Stock Balance

```rust
Expand Down
82 changes: 66 additions & 16 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use tokio::sync::Mutex;
use crate::{
config::{Environment, KisConfig},
credentials::AppCredentials,
endpoint::{EndpointSpec, OperationKind, PreparedRequest},
endpoint::{EndpointSpec, InventoryCatalog, InventoryRequest, OperationKind, PreparedRequest},
error::KisError,
fallback::FallbackPolicy,
retry::RetryPolicy,
Expand Down Expand Up @@ -139,19 +139,69 @@ impl KisClient {
T: DeserializeOwned,
{
let request = endpoint.prepare(self.config.environment, query, body, tr_id_override)?;
let fallback_request =
if self.should_fallback_to_mock(endpoint.operation_kind, request.method.as_str()) {
Some(endpoint.prepare(Environment::Mock, query, body, tr_id_override)?)
} else {
None
};
self.execute_prepared(
endpoint.id,
endpoint.operation_kind,
request,
fallback_request,
)
.await
}

pub async fn execute_inventory<T>(
&self,
operation_id: &str,
request: InventoryRequest,
) -> Result<KisEnvelope<T>, KisError>
where
T: DeserializeOwned,
{
let catalog = InventoryCatalog::bundled()?;
let (operation_kind, prepared) =
catalog.prepare(operation_id, self.config.environment, &request)?;
let fallback_request =
if self.should_fallback_to_mock(operation_kind, prepared.method.as_str()) {
Some(
catalog
.prepare(operation_id, Environment::Mock, &request)?
.1,
)
} else {
None
};
self.execute_prepared(operation_id, operation_kind, prepared, fallback_request)
.await
}

async fn execute_prepared<T>(
&self,
endpoint_id: &str,
operation_kind: OperationKind,
request: PreparedRequest,
fallback_request: Option<PreparedRequest>,
) -> Result<KisEnvelope<T>, KisError>
where
T: DeserializeOwned,
{
if self.config.environment == Environment::Real
&& endpoint.operation_kind == OperationKind::TradingMutation
&& operation_kind == OperationKind::TradingMutation
{
return Err(KisError::LiveTradingDisabled {
endpoint: endpoint.id.to_string(),
endpoint: endpoint_id.to_string(),
});
}

let mut attempt = 1;
loop {
let result = self
.send_prepared(
endpoint.operation_kind,
operation_kind,
&request,
&self.config.base_url,
CredentialScope::Primary,
Expand All @@ -165,7 +215,7 @@ impl KisClient {
Err(error)
if self.config.retry_policy.should_retry(
request.method.as_str(),
endpoint.operation_kind,
operation_kind,
&error,
attempt,
) =>
Expand All @@ -175,16 +225,12 @@ impl KisClient {
tokio::time::sleep(self.config.retry_policy.backoff()).await;
}
}
Err(error)
if error.retryable()
&& self.should_fallback_to_mock(endpoint, request.method.as_str()) =>
{
let fallback_request =
endpoint.prepare(Environment::Mock, query, body, tr_id_override)?;
Err(error) if error.retryable() && fallback_request.is_some() => {
let fallback_request = fallback_request.as_ref().expect("checked is_some");
let mut response = self
.send_prepared(
endpoint.operation_kind,
&fallback_request,
operation_kind,
fallback_request,
&self.config.fallback_base_url,
CredentialScope::Fallback,
)
Expand All @@ -203,13 +249,13 @@ impl KisClient {
}
}

fn should_fallback_to_mock(&self, endpoint: &EndpointSpec, method: &str) -> bool {
fn should_fallback_to_mock(&self, operation_kind: OperationKind, method: &str) -> bool {
self.config.environment == Environment::Real
&& endpoint.operation_kind == OperationKind::Read
&& operation_kind == OperationKind::Read
&& self
.config
.fallback_policy
.allows_real_to_mock(method, endpoint.operation_kind)
.allows_real_to_mock(method, operation_kind)
}

async fn send_prepared<T>(
Expand Down Expand Up @@ -247,6 +293,10 @@ impl KisClient {
builder = builder.header("tr_id", tr_id);
}

for (name, value) in &request.headers {
builder = builder.header(name, value);
}

if let Some(body) = &request.body {
builder = builder.json(body);
}
Expand Down
Loading
Loading