From be8cda69478995d8500084d13f20091efda53f3a Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Tue, 23 Jun 2026 15:57:09 -0300 Subject: [PATCH 1/2] improve examples --- Makefile | 92 ++-- examples/consumer-test/README.md | 153 +++--- examples/consumer-test/build.gradle.kts | 88 +-- .../marketdata/consumer/ConcurrencyApp.java | 93 ---- .../marketdata/consumer/DemoAndConfigApp.java | 197 ------- .../marketdata/consumer/ExceptionsApp.java | 241 --------- .../com/marketdata/consumer/FundsApp.java | 267 --------- .../com/marketdata/consumer/LiveSmokeApp.java | 134 ----- .../com/marketdata/consumer/MarketsApp.java | 264 --------- .../com/marketdata/consumer/OptionsApp.java | 353 ------------ .../marketdata/consumer/QuickstartApp.java | 506 ------------------ .../consumer/ResponseFeaturesApp.java | 142 ----- .../marketdata/consumer/RetryBehaviorApp.java | 222 -------- .../com/marketdata/consumer/StocksApp.java | 358 ------------- .../marketdata/consumer/shared/Console.java | 81 --- .../consumer/shared/MockServerControl.java | 235 -------- .../common/ConcurrentRequestsExample.java | 66 +++ .../examples/common/ConfigurationExample.java | 57 ++ .../examples/common/ErrorHandlingExample.java | 94 ++++ .../common/ResponseFormatsExample.java | 59 ++ .../common/RetryAndBackoffExample.java | 70 +++ .../examples/common/SyncVsAsyncExample.java | 52 ++ .../resources/FundsAdvancedExample.java | 60 +++ .../examples/resources/FundsExample.java | 45 ++ .../resources/MarketsAdvancedExample.java | 56 ++ .../examples/resources/MarketsExample.java | 43 ++ .../resources/OptionsAdvancedExample.java | 80 +++ .../examples/resources/OptionsExample.java | 65 +++ .../resources/StocksAdvancedExample.java | 84 +++ .../examples/resources/StocksExample.java | 61 +++ .../examples/resources/UtilitiesExample.java | 41 ++ .../marketdata/examples/util/MockServer.java | 202 +++++++ .../com/marketdata/examples/Quickstart.kt | 34 ++ 33 files changed, 1337 insertions(+), 3258 deletions(-) delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/DemoAndConfigApp.java delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/FundsApp.java delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/MarketsApp.java delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/OptionsApp.java delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/StocksApp.java delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/shared/Console.java delete mode 100644 examples/consumer-test/src/main/java/com/marketdata/consumer/shared/MockServerControl.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/common/ConcurrentRequestsExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/common/ConfigurationExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/common/ErrorHandlingExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/common/ResponseFormatsExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/common/RetryAndBackoffExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/common/SyncVsAsyncExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/resources/FundsAdvancedExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/resources/FundsExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/resources/MarketsAdvancedExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/resources/MarketsExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/resources/StocksAdvancedExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/resources/StocksExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/resources/UtilitiesExample.java create mode 100644 examples/consumer-test/src/main/java/com/marketdata/examples/util/MockServer.java create mode 100644 examples/consumer-test/src/main/kotlin/com/marketdata/examples/Quickstart.kt diff --git a/Makefile b/Makefile index ca4eabf..b9ecd9f 100644 --- a/Makefile +++ b/Makefile @@ -19,8 +19,9 @@ help: ## Show this help @echo "" @echo "Typical workflow:" @echo " make publish # publish SDK to mavenLocal" - @echo " make mock-server # in another terminal" - @echo " make demo-config # in a third terminal" + @echo " make example-stocks # a resource example (live API, needs a token)" + @echo " make mock-server # in another terminal, then:" + @echo " make example-concurrency # a cross-cutting example (needs the mock server)" # --------------------------------------------------------------------------- ## --- SDK build --- @@ -56,53 +57,66 @@ mock-server: ## Start the FastAPI mock server (blocks, Ctrl+C to stop) cd $(MOCK_DIR) && ./run.sh # --------------------------------------------------------------------------- -## --- Consumer demos (need `make publish` first) --- +## --- Examples (need `make publish` first) --- # --------------------------------------------------------------------------- +# Resource examples hit the LIVE API (need a token). The cross-cutting examples that show otherwise- +# invisible behavior (concurrency, retry, errors) drive the mock server — run `make mock-server` +# first for those. `make example-list` prints every runnable example with a one-line description. -.PHONY: demo-quickstart -demo-quickstart: ## Idiomatic per-resource usage tour (live API; grows as resources land) - cd $(CONSUMER_DIR) && ./gradlew runQuickstart +.PHONY: example-list +example-list: ## List every runnable example with a description + cd $(CONSUMER_DIR) && ./gradlew tasks --group examples -.PHONY: demo-live -demo-live: ## Live API smoke (needs MARKETDATA_TOKEN in examples/consumer-test/.env) - cd $(CONSUMER_DIR) && ./gradlew runLive +# --- resource examples (live API) --- +.PHONY: example-utilities +example-utilities: ## utilities: health, quota, request echo (live) + cd $(CONSUMER_DIR) && ./gradlew runUtilities -.PHONY: demo-config -demo-config: ## Demo mode, cascade, validation (needs mock-server) - cd $(CONSUMER_DIR) && ./gradlew runDemoConfig +.PHONY: example-stocks +example-stocks: ## stocks: candles, quote, batch quotes (live) + cd $(CONSUMER_DIR) && ./gradlew runStocks -.PHONY: demo-exceptions -demo-exceptions: ## Every MarketDataException subtype (needs mock-server) - cd $(CONSUMER_DIR) && ./gradlew runExceptions +.PHONY: example-options +example-options: ## options: lookup, expirations, chain, quote (live) + cd $(CONSUMER_DIR) && ./gradlew runOptions -.PHONY: demo-retry -demo-retry: ## Retry, Retry-After, preflight (needs mock-server) - cd $(CONSUMER_DIR) && ./gradlew runRetry +.PHONY: example-funds +example-funds: ## funds: NAV candles (live) + cd $(CONSUMER_DIR) && ./gradlew runFunds -.PHONY: demo-response -demo-response: ## MarketDataResponse surface features (needs mock-server) - cd $(CONSUMER_DIR) && ./gradlew runResponse +.PHONY: example-markets +example-markets: ## markets: open/closed calendar (live) + cd $(CONSUMER_DIR) && ./gradlew runMarkets -.PHONY: demo-concurrency -demo-concurrency: ## 50-permit semaphore (needs mock-server) - cd $(CONSUMER_DIR) && ./gradlew runConcurrency +.PHONY: example-kotlin +example-kotlin: ## the same SDK from Kotlin: sync + async (live) + cd $(CONSUMER_DIR) && ./gradlew runKotlinQuickstart -.PHONY: demo-options -demo-options: ## Full options surface: every endpoint + all params, CSV facet, columns, Option A (needs mock-server) - cd $(CONSUMER_DIR) && ./gradlew runOptions +# --- cross-cutting examples --- +.PHONY: example-sync-async +example-sync-async: ## sync vs async, parallel fan-out (live) + cd $(CONSUMER_DIR) && ./gradlew runSyncVsAsync -.PHONY: demo-stocks -demo-stocks: ## Full stocks surface: every endpoint + all params, CSV facet, columns, Option A (needs mock-server) - cd $(CONSUMER_DIR) && ./gradlew runStocks +.PHONY: example-config +example-config: ## constructors, cascade, redaction, validation (offline) + cd $(CONSUMER_DIR) && ./gradlew runConfiguration -.PHONY: demo-markets -demo-markets: ## Full markets surface: status + all params (open/closed calendar, null cells), CSV facet, columns, Option A (needs mock-server) - cd $(CONSUMER_DIR) && ./gradlew runMarkets +.PHONY: example-response +example-response: ## response wrapper: data, metadata, formats, saveToFile (live) + cd $(CONSUMER_DIR) && ./gradlew runResponseFormats -.PHONY: demo-funds -demo-funds: ## Full funds surface: candles + all params (no volume/intraday/chunking), CSV facet, columns, Option A (needs mock-server) - cd $(CONSUMER_DIR) && ./gradlew runFunds +.PHONY: example-concurrency +example-concurrency: ## fan-out async + 50-permit cap, observed (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runConcurrency + +.PHONY: example-retry +example-retry: ## automatic retry + backoff + Retry-After (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runRetry + +.PHONY: example-errors +example-errors: ## the sealed exception hierarchy and how to handle it (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runErrors -.PHONY: demos-all -demos-all: ## Run every mock-server-based demo back-to-back (needs mock-server) - cd $(CONSUMER_DIR) && ./gradlew runDemoConfig runExceptions runRetry runResponse runConcurrency runOptions runStocks runMarkets runFunds +.PHONY: examples-mock +examples-mock: ## Run the three mock-server examples back-to-back (needs mock-server) + cd $(CONSUMER_DIR) && ./gradlew runConcurrency runRetry runErrors diff --git a/examples/consumer-test/README.md b/examples/consumer-test/README.md index d2e3627..3b55317 100644 --- a/examples/consumer-test/README.md +++ b/examples/consumer-test/README.md @@ -1,112 +1,81 @@ -# consumer-test +# Examples -A collection of small runnable apps that exercise every consumer-facing -behavior of `marketdata-sdk-java`. Each app stands alone — pick the one that -matches the scenario you want to see, run it, read the console output. +Small, self-contained programs that show how to use `marketdata-sdk-java`. Each one is meant to be +**read and copied** — pick the file that matches what you want to do, run it, look at the output. -Lives under `examples/` rather than as a `src/test` source set on purpose: it -consumes the SDK as an *external* artifact (via `mavenLocal`), so the demos -exercise exactly the shape a published JAR exposes — no accidental package- -private leaks, no access to internal seams. +They consume the SDK as a published artifact (via `mavenLocal`), exactly as your own project would, +so what you see is the real public API. -## One-time setup +## Layout + +``` +src/main/java/com/marketdata/examples/ + resources/ one file per resource — the calls you'd write first, plus an "Advanced" file each + common/ cross-cutting topics that apply to every resource (do these once, not per resource) +src/main/kotlin/com/marketdata/examples/ + Quickstart.kt the same SDK, from Kotlin +``` + +### Resource examples (live API) + +| Example | Shows | +|---|---| +| `UtilitiesExample` | API health, your account quota, request echo | +| `StocksExample` | candles, a quote, a multi-symbol batch quote | +| `OptionsExample` | symbol lookup, expirations, a filtered chain, a contract quote | +| `FundsExample` | mutual-fund NAV candles | +| `MarketsExample` | the exchange open/closed calendar | +| `…AdvancedExample` (one per resource) | universal params, date windows, column projection, CSV output, sealed chain filters | + +### Cross-cutting examples (`common/`) + +| Example | Shows | Needs | +|---|---|---| +| `SyncVsAsyncExample` | the sync call vs its async (`CompletableFuture`) variant, and a parallel fan-out | live API | +| `ConfigurationExample` | constructors, the config cascade, token redaction, fail-fast validation | offline | +| `ResponseFormatsExample` | the response wrapper: typed data, metadata, format predicates, raw body, `saveToFile` | live API | +| `ConcurrentRequestsExample` | firing many requests at once and the SDK's 50-request concurrency cap | mock server | +| `RetryAndBackoffExample` | automatic retry, exponential backoff, `Retry-After` | mock server | +| `ErrorHandlingExample` | the sealed `MarketDataException` hierarchy and how to branch on it | mock server | + +The cross-cutting examples cover these behaviors **once**, using whichever resource is handy as the +vehicle — they're not repeated per resource. + +## Running ```bash -# 1. Publish the SDK to your local Maven cache. Run from the SDK root -# (two directories up). The Makefile wraps it: -cd ../.. +# 1. Publish the SDK to your local Maven cache (run once, from the SDK root, two dirs up): make publish -# 2. (For runLive only) put your token in this directory's .env: +# 2. For the live examples, put your token where the SDK's cascade can find it: echo "MARKETDATA_TOKEN=your-token-here" > examples/consumer-test/.env ``` -## Running - -From the SDK root, the easy path is `make` (see `make help` for the full list): +Then run any example by its Gradle task (from this directory) or its `make` target (from the SDK +root): ```bash -make demo-quickstart # idiomatic per-resource tour — live API, no mock -make demo-live # full plumbing smoke — live API, no mock -make demo-config # config, validation, demo mode — needs mock server -make demo-exceptions # every MarketDataException subtype — needs mock server -make demo-retry # retry, Retry-After, preflight — needs mock server -make demo-response # Response features — needs mock server -make demo-concurrency # 50-permit semaphore — needs mock server -make demos-all # the five mock-server demos back-to-back +./gradlew runStocks # or: make example-stocks +./gradlew runSyncVsAsync # or: make example-sync-async +./gradlew tasks --group examples # list them all (make example-list) ``` -Or directly from this directory, bypassing the Makefile: +`UtilitiesExample`'s `status()` call, `SyncVsAsyncExample`, and `ResponseFormatsExample` use a public +endpoint and run **without** a token. The other resource examples need one (and options/funds data +needs the matching entitlements); without it they print a one-line hint instead of crashing. -```bash -./gradlew tasks --group "consumer demos" # list all apps -./gradlew runLive # same as `make demo-live` -./gradlew runDemoConfig # etc. -``` +## The mock server (for the three behavior examples) -Apps that say "needs mock server" require the mock running in another -terminal. Easiest: +`ConcurrentRequestsExample`, `RetryAndBackoffExample` and `ErrorHandlingExample` demonstrate things +you can't see against the live API — the concurrency cap, deterministic retry timing, each error +type on demand. They point the SDK at a local mock server whose responses are scripted up front. +Start it in another terminal first: ```bash -make mock-server # from the SDK root -# or, equivalently: -cd ../mock-server && ./run.sh +make mock-server # from the SDK root +# or: cd ../mock-server && ./run.sh ``` -Without it the demo fails fast with a clear "server not reachable" message. - -## What each app shows - -| App | Scenario | What you should see | -|---|---|---| -| **QuickstartApp** | Idiomatic per-resource usage. Designed to **grow** — each new SDK resource adds a section. Start here. | For each wired resource, one short snippet per typical call + console output of the typed data the consumer would actually use. Today: `utilities` (status / user / headers) and `options` (lookup / expirations / strikes / chain incl. sealed filters + `expiration=all` + `rho` / quote / quotes with `countback`). | -| **LiveSmokeApp** | The happy path against the real API | client.toString redacted; sync + async parity on status/user/headers; parallel calls completing in ≈ slowest-single-call wall-time; final rate-limit snapshot populated | -| **DemoAndConfigApp** | Construction-time behavior | demo-mode skip; §16 token redaction (short ≤8 → full; >8 → ***…***ABCD); cascade (explicit wins); IAE on invalid baseUrl / CRLF API key; validateOnStartup 200 vs 401 paths | -| **ExceptionsApp** | Every sealed exception subtype | 401 → AuthenticationError; 400 → BadRequestError; 429 → RateLimitError (+ Retry-After); 500 → ServerError (no retry); 503×4 → ServerError after ≈7s; malformed JSON → ParseError; empty body → ParseError (§29 fix message); connection refused → NetworkError after retries; ADR-002 sealed routing via instanceof | -| **RetryBehaviorApp** | The §9 retry contract | 503→503→200 recovers in ≈3s; Retry-After delta overrides exponential; Retry-After HTTP-date honored; pathological Retry-After (1 day) capped at 10 min — falls back to exponential; §10.3 preflight blocks the 2nd request when snapshot reports remaining=0 (0ms wall-time, 0 server-side requests) | -| **ResponseFeaturesApp** | §13.5 Response surface | isJson / isCsv / isHtml mutually exclusive; 404 + `{"s":"no_data"}` returns successfully with isNoData=true; rawBody() is a defensive copy (consumer mutations don't leak); saveToFile() writes verbatim; toString() omits data + redacts query (§16) | -| **ConcurrencyApp** | §12 / ADR-007 50-permit semaphore | 60 parallel calls; server observes peak in-flight = exactly 50; total wall-time ≈ 2× per-call delay (two batches of 50+10) | - -## Adding a section to QuickstartApp - -`QuickstartApp` is the only app in this directory designed to be **extended** as -the SDK grows. The other six prove a fixed contract; this one is the running -catalog of "what each resource looks like in consumer code". - -When a new resource lands on the SDK: - -1. Open `src/main/java/com/marketdata/consumer/QuickstartApp.java`. -2. Uncomment the matching placeholder line in `main(...)` (e.g. - `// stocksExamples(client);`). -3. Implement `xxxExamples(MarketDataClient client)` following the - `utilitiesExamples` shape: one `Console.step` + one short SDK call + one - `Console.ok` per typical use case. Catch `AuthenticationError` separately - when the endpoint needs a token, so the demo stays runnable in demo mode. -4. Keep each example to **3–5 lines of SDK code** — the goal is "what you'd - copy-paste into your own app", not exhaustive coverage. Edge cases belong - in the other demos. - -## How the mock server fits - -The mock server (FastAPI, `../mock-server/`) is what makes the -non-live demos deterministic. Each demo POSTs a list of scripted responses -to `/_admin/script`, then makes its real SDK call against the same host — -the server's catch-all pops exactly the scripted response. Apps that need to -see specific status codes, headers (Retry-After, x-api-ratelimit-*), or -timing behavior depend on this scripting. - -The control plane (`/_admin/*`) is hit with a plain `java.net.http.HttpClient`, -not the SDK — keeping the SDK's surface uncluttered. - -> Note: `MockServerControl` forces HTTP/1.1 because uvicorn's HTTP/2 upgrade -> handling drops POST bodies during the negotiation. The SDK itself stays on -> its ADR-004 HTTP/2 default — only the admin client downgrades. - -## Caveats - -- The local `.env` has a token that's been used during development. If the - token is no longer valid against api.marketdata.app, `runLive` will show - `AuthenticationError` on calls that need it (status/ stays public). -- "Demo mode" (no token at all) is hard to see if you have a token in your - env or `.env` — the cascade picks it up. The demo detects this and prints - a skip note rather than constructing a misleading client. +Without it those three examples fail fast with a clear "mock server not reachable" message. The mock +is a teaching aid (`com.marketdata.examples.util.MockServer`) — it is **not** part of the SDK and you +never need it in your own code. diff --git a/examples/consumer-test/build.gradle.kts b/examples/consumer-test/build.gradle.kts index 1efc131..1fa4d97 100644 --- a/examples/consumer-test/build.gradle.kts +++ b/examples/consumer-test/build.gradle.kts @@ -1,5 +1,6 @@ plugins { application + kotlin("jvm") version "2.1.0" } java { @@ -8,54 +9,73 @@ java { } } +kotlin { + jvmToolchain(17) +} + dependencies { implementation("com.marketdata:marketdata-sdk-java:0.1.0-SNAPSHOT") } -// Default `./gradlew run` lands on the live-API smoke. The other apps are -// reachable via the named tasks below — each one is its own self-contained -// scenario walk-through. +// Default `./gradlew run` lands on the stocks resource example (live API). application { - mainClass = "com.marketdata.consumer.LiveSmokeApp" + mainClass = "com.marketdata.examples.resources.StocksExample" } -// Each demo gets its own JavaExec task in the same Gradle group so -// `./gradlew tasks --group "consumer demos"` lists them all. -val demoApps = mapOf( - "runQuickstart" to ("com.marketdata.consumer.QuickstartApp" to - "Idiomatic per-resource usage. Grows as new resources land."), - "runLive" to ("com.marketdata.consumer.LiveSmokeApp" to - "Live API smoke (needs MARKETDATA_TOKEN)."), - "runDemoConfig" to ("com.marketdata.consumer.DemoAndConfigApp" to - "Demo mode, configuration cascade, validation. Needs mock server."), - "runExceptions" to ("com.marketdata.consumer.ExceptionsApp" to - "Round-trip each MarketDataException subtype. Needs mock server."), - "runRetry" to ("com.marketdata.consumer.RetryBehaviorApp" to - "Retry policy, Retry-After header, preflight gate. Needs mock server."), - "runResponse" to ("com.marketdata.consumer.ResponseFeaturesApp" to - "MarketDataResponse surface: predicates, isNoData, json, saveToFile, toString. Needs mock server."), - "runConcurrency" to ("com.marketdata.consumer.ConcurrencyApp" to - "§12 / ADR-007: 50-permit semaphore observed end-to-end. Needs mock server."), - "runOptions" to ("com.marketdata.consumer.OptionsApp" to - "Full options surface: every endpoint + all params, CSV facet, columns projection, Option A. Needs mock server."), - "runStocks" to ("com.marketdata.consumer.StocksApp" to - "Full stocks surface: candles/quote/quotes/prices/news/earnings + all params, CSV facet, columns projection, Option A. Needs mock server."), - "runMarkets" to ("com.marketdata.consumer.MarketsApp" to - "Full markets surface: status + all params (open/closed calendar, null cells), CSV facet, columns projection, Option A. Needs mock server."), - "runFunds" to ("com.marketdata.consumer.FundsApp" to - "Full funds surface: candles + all params (no volume / no intraday / no chunking), CSV facet, columns projection, Option A. Needs mock server.") +// Each example gets its own JavaExec task in the "examples" group so +// `./gradlew tasks --group examples` lists them all. +// +// Resource examples hit the LIVE API (need MARKETDATA_TOKEN). Cross-cutting examples that need +// deterministic behavior (concurrency, retry, errors) drive a local mock server — start it with +// `cd ../mock-server && ./run.sh`. +val examples = mapOf( + // --- resources: the first calls per resource --- + "runUtilities" to ("com.marketdata.examples.resources.UtilitiesExample" to + "utilities: API health, account quota, request echo (live)."), + "runStocks" to ("com.marketdata.examples.resources.StocksExample" to + "stocks: candles, quote, batch quotes (live)."), + "runOptions" to ("com.marketdata.examples.resources.OptionsExample" to + "options: lookup, expirations, chain, quote (live)."), + "runFunds" to ("com.marketdata.examples.resources.FundsExample" to + "funds: NAV candles (live)."), + "runMarkets" to ("com.marketdata.examples.resources.MarketsExample" to + "markets: open/closed calendar (live)."), + // --- resources: less common parameters --- + "runStocksAdvanced" to ("com.marketdata.examples.resources.StocksAdvancedExample" to + "stocks: universal params, windows, columns, CSV, rate limit (live)."), + "runOptionsAdvanced" to ("com.marketdata.examples.resources.OptionsAdvancedExample" to + "options: sealed filters, fan-out, columns, CSV (live)."), + "runFundsAdvanced" to ("com.marketdata.examples.resources.FundsAdvancedExample" to + "funds: date windows, columns, CSV (live)."), + "runMarketsAdvanced" to ("com.marketdata.examples.resources.MarketsAdvancedExample" to + "markets: windows, country, columns, CSV (live)."), + // --- common: cross-cutting behavior and configuration --- + "runSyncVsAsync" to ("com.marketdata.examples.common.SyncVsAsyncExample" to + "sync vs async, parallel fan-out (live)."), + "runConcurrency" to ("com.marketdata.examples.common.ConcurrentRequestsExample" to + "fan-out async + 50-permit concurrency cap, observed. Needs mock server."), + "runRetry" to ("com.marketdata.examples.common.RetryAndBackoffExample" to + "automatic retry + backoff + Retry-After. Needs mock server."), + "runErrors" to ("com.marketdata.examples.common.ErrorHandlingExample" to + "the sealed exception hierarchy and how to handle it. Needs mock server."), + "runConfiguration" to ("com.marketdata.examples.common.ConfigurationExample" to + "constructors, config cascade, token redaction, fail-fast validation (offline)."), + "runResponseFormats" to ("com.marketdata.examples.common.ResponseFormatsExample" to + "the response wrapper: data, metadata, formats, raw body, saveToFile (live)."), + // --- Kotlin --- + "runKotlinQuickstart" to ("com.marketdata.examples.QuickstartKt" to + "the same SDK from Kotlin: sync + async (live).") ) -demoApps.forEach { (taskName, app) -> +examples.forEach { (taskName, app) -> val (mainClassName, taskDescription) = app tasks.register(taskName) { - group = "consumer demos" + group = "examples" description = taskDescription mainClass = mainClassName classpath = sourceSets["main"].runtimeClasspath - // Inherit stdio so the demo's println output is visible in the console - // and matches what a real consumer would see when they run their own app. + // Inherit stdio so the example's println output is visible and matches what a real consumer + // would see running their own app. standardInput = System.`in` } } - diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java deleted file mode 100644 index e9494be..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/ConcurrencyApp.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.marketdata.consumer; - -import com.marketdata.consumer.shared.Console; -import com.marketdata.consumer.shared.MockServerControl; -import com.marketdata.consumer.shared.MockServerControl.Step; -import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.UtilitiesStatusResponse; -import com.marketdata.sdk.utilities.ApiStatus; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; - -/** - * §12 / ADR-007 concurrency: the SDK's AsyncSemaphore holds at most 50 - * in-flight HTTP requests. This demo fires 60 calls in parallel against the - * mock server, asks each one to hang for 800 ms, and then reads - * /_admin/stats — the {@code peak_in_flight} the server observed should be - * exactly 50. - * - *

The other 10 requests sit in the semaphore's wait queue and drain after - * the first batch completes, so the total wall-clock is ≈ 2 × 800 ms even - * though all 60 were dispatched at t=0. - * - *

Run: {@code ./gradlew runConcurrency} - */ -public final class ConcurrencyApp { - private ConcurrencyApp() {} - - public static void main(String[] args) { - MockServerControl mock = new MockServerControl(); - mock.requireUp(); - mock.reset(); - - // Script 60 identical slow responses so the SDK's semaphore is the only thing throttling. - int fanout = 60; - int delayMs = 800; - String okBody = validStatusBody(); - List steps = new ArrayList<>(fanout); - for (int i = 0; i < fanout; i++) { - steps.add(Step.of(200, okBody).delayMs(delayMs)); - } - mock.script(steps); - - Console.header( - "Firing " + fanout + " parallel async calls; each response delayed " + delayMs + " ms"); - Console.info( - "Expectation: peak_in_flight = 50 (the ADR-007 semaphore cap), total wall-clock ≈ " - + (delayMs * 2) - + " ms (2 batches: 50 then 10)."); - - try (var client = - new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { - long t0 = System.nanoTime(); - List> futures = new ArrayList<>(fanout); - for (int i = 0; i < fanout; i++) { - futures.add(client.utilities().statusAsync()); - } - CompletableFuture all = - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); - all.join(); - long elapsedMs = (System.nanoTime() - t0) / 1_000_000; - - Console.ok("all " + fanout + " calls completed in " + elapsedMs + " ms"); - MockServerControl.Stats stats = mock.stats(); - Console.info("server saw " + stats.requests() + " requests (expected " + fanout + ")"); - Console.info("peak_in_flight observed by server: " + stats.peakInFlight()); - - if (stats.peakInFlight() == 50) { - Console.ok("§12 honored exactly: 50 concurrent, no more, no less."); - } else if (stats.peakInFlight() < 50) { - Console.fail( - "peak below cap (" - + stats.peakInFlight() - + ") — system was slow to dispatch, retry on a quiet machine"); - } else { - Console.fail("peak ABOVE cap (" + stats.peakInFlight() + ") — §12 violated"); - } - } - } - - private static String validStatusBody() { - long now = System.currentTimeMillis() / 1000L; - return "{\"s\":\"ok\"," - + "\"service\":[\"/v1/markets/status/\"]," - + "\"status\":[\"online\"]," - + "\"online\":[true]," - + "\"uptimePct30d\":[1.0]," - + "\"uptimePct90d\":[1.0]," - + "\"updated\":[" - + now - + "]}"; - } -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/DemoAndConfigApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/DemoAndConfigApp.java deleted file mode 100644 index 63d968a..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/DemoAndConfigApp.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.marketdata.consumer; - -import com.marketdata.consumer.shared.Console; -import com.marketdata.consumer.shared.MockServerControl; -import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.exception.AuthenticationError; - -/** - * Configuration cascade, demo mode, validation, and the §16 token-redaction - * promises — all things that fire at construction time, before any request is - * made. - * - *

Some scenarios use the mock server (start it first: {@code cd - * ../mock-server && ./run.sh}) so the live API can't accidentally - * influence the outcome. - * - *

Run: {@code ./gradlew runDemoConfig} - */ -public final class DemoAndConfigApp { - private DemoAndConfigApp() {} - - public static void main(String[] args) { - new MockServerControl().requireUp(); - - demoModeNoToken(); - tokenRedactionShort(); - tokenRedactionLong(); - explicitOverridesEnv(); - invalidBaseUrlFailsAtConstruct(); - invalidApiKeyCrlfFailsAtConstruct(); - validateOnStartupSucceedsAgainstMockServer(); - validateOnStartupFailsOn401(); - } - - // ---------- demo mode ---------- - - private static void demoModeNoToken() { - Console.header("Demo mode: no token → demoMode=true, validateOnStartup is a no-op"); - // The §4 cascade resolves the token from explicit → MARKETDATA_TOKEN env → .env → null. - // If any earlier rung populated a token (the local .env in this repo always does), the - // 4-arg constructor with apiKey=null still picks it up — that's correct cascade behavior, - // but it means "demo mode" can only be observed when ALL upstream sources are empty. - if (anyTokenSourcePopulated()) { - Console.info( - "Skipping live demo-mode construction: a token is available somewhere in the cascade"); - Console.info( - "(env var MARKETDATA_TOKEN and/or .env file), so the 4-arg ctor with apiKey=null still"); - Console.info( - "resolves a real token. To see demo mode live: unset the env var AND remove .env, then"); - Console.info("re-run this app."); - Console.info(""); - Console.info("Static verification of demo mode's existence:"); - Console.info( - " - MarketDataClient.toString() prints `demoMode=true` when Configuration.apiKey() is null"); - Console.info( - " - runStartupValidation() short-circuits in demo mode (see DemoMode.isDemo)"); - return; - } - try (var client = new MarketDataClient(null, MockServerControl.BASE_URL, null, true)) { - Console.info(client.toString()); - Console.ok("constructor succeeded — demo mode skipped the /user/ probe"); - } - } - - /** True if either the env var or a readable {@code .env} in CWD contains MARKETDATA_TOKEN. */ - private static boolean anyTokenSourcePopulated() { - String envValue = System.getenv("MARKETDATA_TOKEN"); - if (envValue != null && !envValue.isBlank()) { - return true; - } - java.nio.file.Path dotEnv = java.nio.file.Path.of(".env"); - if (java.nio.file.Files.isReadable(dotEnv)) { - try { - for (String line : java.nio.file.Files.readAllLines(dotEnv)) { - if (line.trim().startsWith("MARKETDATA_TOKEN=")) { - String value = line.substring(line.indexOf('=') + 1).trim(); - if (!value.isEmpty() && !value.equals("\"\"")) { - return true; - } - } - } - } catch (java.io.IOException ignored) { - // Treat unreadable .env as "no token there". - } - } - return false; - } - - // ---------- §16 token redaction ---------- - - private static void tokenRedactionShort() { - Console.header("§16: short tokens (≤8 chars) redact entirely — no last-4 leak"); - try (var client = new MarketDataClient("abcd", MockServerControl.BASE_URL, null, false)) { - String repr = client.toString(); - Console.info(repr); - if (repr.contains("abcd")) { - Console.fail("token leaked into toString — expected ***…*** alone"); - } else { - Console.ok("token fully redacted (length 4 ≤ 8)"); - } - } - } - - private static void tokenRedactionLong() { - Console.header("§16: tokens > 8 chars show the trailing 4"); - try (var client = - new MarketDataClient( - "supersecret-token-YKT0", MockServerControl.BASE_URL, null, false)) { - String repr = client.toString(); - Console.info(repr); - if (repr.contains("supersecret") || repr.contains("token-")) { - Console.fail("token prefix leaked"); - } else if (!repr.contains("YKT0")) { - Console.fail("trailing 4 missing — expected ***…***YKT0"); - } else { - Console.ok("redacted as ***…***YKT0 — enough to disambiguate, not enough to use"); - } - } - } - - // ---------- §4 configuration cascade ---------- - - private static void explicitOverridesEnv() { - Console.header("§4 cascade: explicit constructor args win over env / .env"); - String explicitUrl = MockServerControl.BASE_URL; - try (var client = new MarketDataClient("any-token", explicitUrl, "v1", false)) { - String repr = client.toString(); - if (!repr.contains("baseUrl=" + explicitUrl)) { - Console.fail("explicit baseUrl not honored: " + repr); - } else { - Console.ok("explicit baseUrl applied: " + explicitUrl); - } - } - } - - // ---------- early-fail validation ---------- - - private static void invalidBaseUrlFailsAtConstruct() { - Console.header("Validation: malformed baseUrl fails at construct, not at first request"); - try { - new MarketDataClient("token", "not-a-url", null, false).close(); - Console.fail("constructor returned for a baseUrl that isn't even a URL"); - } catch (IllegalArgumentException e) { - Console.ok("IAE at construct: " + e.getMessage()); - } - } - - private static void invalidApiKeyCrlfFailsAtConstruct() { - Console.header("Validation: API key with CRLF rejected at construct (§23 fix)"); - try { - new MarketDataClient("good-prefix\rinjected", MockServerControl.BASE_URL, null, false).close(); - Console.fail("constructor accepted an API key containing CR"); - } catch (IllegalArgumentException e) { - Console.ok("IAE at construct: " + e.getMessage()); - if (e.getMessage().contains("good-prefix") || e.getMessage().contains("injected")) { - Console.fail("token leaked into IAE message — §16 violation"); - } else { - Console.ok("token NOT echoed in the message — §16 honored"); - } - } - } - - // ---------- §5 validateOnStartup ---------- - - private static void validateOnStartupSucceedsAgainstMockServer() { - Console.header("§5: validateOnStartup=true → /user/ probe on construct (mock returns 200)"); - new MockServerControl().reset(); - try (var client = - new MarketDataClient("any-token", MockServerControl.BASE_URL, null, true)) { - Console.ok("constructor returned — probe succeeded"); - Console.info("rateLimits captured from /user/ response: " + client.getRateLimits()); - } - } - - private static void validateOnStartupFailsOn401() { - Console.header("§5: validateOnStartup=true + 401 on /user/ → AuthenticationError at construct"); - MockServerControl mock = new MockServerControl(); - mock.reset(); - mock.script( - MockServerControl.Step.of( - 401, "{\"s\":\"error\",\"errmsg\":\"Unauthorized\"}") - .forPath("/user/")); - Console.info("server queue before construct: " + mock.stats().requests() + " requests, scripted step queued"); - try { - new MarketDataClient("bad-token", MockServerControl.BASE_URL, null, true).close(); - Console.fail("constructor returned despite 401 on /user/"); - Console.info("server stats after: " + mock.stats().requests() + " requests"); - } catch (AuthenticationError e) { - Console.ok("AuthenticationError at construct: " + e.getMessage()); - Console.info("statusCode: " + e.getStatusCode() + ", requestId: " + e.getRequestId()); - } catch (Throwable t) { - Console.fail( - "unexpected throwable type: " + t.getClass().getName() + " — " + t.getMessage()); - Console.info("server stats after: " + mock.stats().requests() + " requests"); - } - } -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java deleted file mode 100644 index 460e992..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/ExceptionsApp.java +++ /dev/null @@ -1,241 +0,0 @@ -package com.marketdata.consumer; - -import com.marketdata.consumer.shared.Console; -import com.marketdata.consumer.shared.MockServerControl; -import com.marketdata.consumer.shared.MockServerControl.Step; -import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.exception.AuthenticationError; -import com.marketdata.sdk.exception.BadRequestError; -import com.marketdata.sdk.exception.NetworkError; -import com.marketdata.sdk.exception.NotFoundError; -import com.marketdata.sdk.exception.ParseError; -import com.marketdata.sdk.exception.RateLimitError; -import com.marketdata.sdk.exception.ServerError; -import java.net.ServerSocket; - -/** - * Round-trips every one of the §6 / ADR-002 sealed exception subtypes through - * the SDK. Each scenario: - * - *

    - *
  • scripts the mock server (or chooses an unreachable address for the - * network-error case) to produce the trigger condition, - *
  • fires a call through the SDK and asserts the exception type, - *
  • prints the §6 support-info dump so the wire-level diagnostic surface is - * visible to a human. - *
- * - *

The exhaustive switch at the bottom is the consumer-facing proof of the - * sealed hierarchy — adding an 8th subtype to the SDK would break this switch - * at compile time, exactly the contract ADR-002 promised. - * - *

Run: {@code ./gradlew runExceptions} - */ -public final class ExceptionsApp { - private ExceptionsApp() {} - - public static void main(String[] args) { - MockServerControl mock = new MockServerControl(); - mock.requireUp(); - - try (var client = - new MarketDataClient("any-token", MockServerControl.BASE_URL, null, false)) { - - authenticationError401(mock, client); - badRequest400(mock, client); - notFound404WithRealError(mock, client); - rateLimit429WithRetryAfter(mock, client); - serverError500NotRetriable(mock, client); - serverError503Retriable(mock, client); - parseErrorMalformedBody(mock, client); - parseErrorEmptyBody(mock, client); - sealedSwitchDemo(mock, client); - } - - networkErrorConnectionRefused(); - } - - // ---------- 401 ---------- - - private static void authenticationError401(MockServerControl mock, MarketDataClient client) { - Console.header("AuthenticationError on HTTP 401"); - mock.reset(); - mock.script(Step.of(401, "{\"s\":\"error\",\"errmsg\":\"Unauthorized\"}")); - Console.expectException("AuthenticationError", () -> client.utilities().user()); - } - - // ---------- 400 ---------- - - private static void badRequest400(MockServerControl mock, MarketDataClient client) { - Console.header("BadRequestError on HTTP 400"); - mock.reset(); - mock.script(Step.of(400, "{\"s\":\"error\",\"errmsg\":\"invalid params\"}")); - Console.expectException("BadRequestError", () -> client.utilities().status()); - } - - // ---------- 404 (real error, not no_data) ---------- - - private static void notFound404WithRealError(MockServerControl mock, MarketDataClient client) { - Console.header("NotFoundError on HTTP 404 — wait, actually..."); - Console.info( - "Spec §11: 404 + {\"s\":\"no_data\"} is a SUCCESSFUL response. The SDK returns a"); - Console.info( - "a MarketDataResponse with isNoData() = true. To see NotFoundError, we'd need a 404 that"); - Console.info( - "ISN'T the no-data envelope — but the current routing maps all 404s to a successful"); - Console.info( - "envelope (see HttpTransport.routeAndEnvelope). So in practice consumers see"); - Console.info( - "NotFoundError only if a future endpoint maps it differently."); - Console.info("Skipping this scenario — see ResponseFeaturesApp for the 404+no_data path."); - // Suppress 'unused parameter' warnings by referencing the locals once. - if (false) { - mock.reset(); - Console.expectException("NotFoundError", () -> client.utilities().status()); - } - } - - // ---------- 429 ---------- - - private static void rateLimit429WithRetryAfter(MockServerControl mock, MarketDataClient client) { - Console.header("RateLimitError on HTTP 429 — Retry-After surfaces on the exception"); - mock.reset(); - mock.script( - Step.of(429, "{\"s\":\"error\",\"errmsg\":\"rate limited\"}") - .withHeader("Retry-After", "5")); - try { - client.utilities().status(); - Console.fail("expected RateLimitError, call returned"); - } catch (RateLimitError e) { - Console.ok("RateLimitError caught"); - Console.info("Retry-After parsed: " + e.getRetryAfter()); - Console.info("statusCode: " + e.getStatusCode()); - } - } - - // ---------- 500 (not retriable per §9) ---------- - - private static void serverError500NotRetriable(MockServerControl mock, MarketDataClient client) { - Console.header("ServerError on HTTP 500 — §9 says 500 is NOT retriable"); - mock.reset(); - mock.script(Step.of(500, "{\"s\":\"error\",\"errmsg\":\"internal\"}")); - Console.expectException("ServerError (no retry)", () -> client.utilities().status()); - Console.info("server saw exactly " + mock.stats().requests() + " request(s) — should be 1"); - } - - // ---------- 503 (retriable, exhausted) ---------- - - private static void serverError503Retriable(MockServerControl mock, MarketDataClient client) { - Console.header("ServerError on HTTP 503 — retried 3x by default policy, then surfaces"); - mock.reset(); - // 4 attempts (1 initial + 3 retries) of 503 — all fail. - mock.script( - java.util.List.of( - Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}"), - Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}"), - Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}"), - Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}"))); - long t0 = System.nanoTime(); - try { - client.utilities().status(); - Console.fail("expected ServerError"); - } catch (ServerError e) { - long elapsed = (System.nanoTime() - t0) / 1_000_000; - Console.ok("ServerError after " + elapsed + " ms (exponential 1s + 2s + 4s ≈ 7s)"); - Console.info("server saw " + mock.stats().requests() + " requests (expected 4)"); - } - } - - // ---------- ParseError: malformed body ---------- - - private static void parseErrorMalformedBody(MockServerControl mock, MarketDataClient client) { - Console.header("ParseError on malformed JSON"); - mock.reset(); - mock.script(Step.of(200, "{this-is-not-json")); - Console.expectException("ParseError", () -> client.utilities().user()); - } - - // ---------- ParseError: empty body (#29 fix) ---------- - - private static void parseErrorEmptyBody(MockServerControl mock, MarketDataClient client) { - Console.header("ParseError on empty body (#29 fix) — explicit 'Empty response body' message"); - mock.reset(); - mock.script(Step.of(200, "")); - try { - client.utilities().user(); - Console.fail("expected ParseError"); - } catch (ParseError e) { - if (e.getMessage().contains("Empty response body")) { - Console.ok("explicit empty-body message: " + e.getMessage()); - } else { - Console.fail("generic message, #29 fix not engaged: " + e.getMessage()); - } - } - } - - // ---------- NetworkError ---------- - - private static void networkErrorConnectionRefused() { - Console.header("NetworkError on connection refused (then retried 3x)"); - int closedPort; - try (ServerSocket probe = new ServerSocket(0)) { - closedPort = probe.getLocalPort(); - } catch (java.io.IOException e) { - Console.fail("couldn't reserve a closed port: " + e.getMessage()); - return; - } - String unreachable = "http://127.0.0.1:" + closedPort; - try (var client = new MarketDataClient("token", unreachable, null, false)) { - long t0 = System.nanoTime(); - try { - client.utilities().status(); - Console.fail("expected NetworkError"); - } catch (NetworkError e) { - long elapsed = (System.nanoTime() - t0) / 1_000_000; - Console.ok("NetworkError after " + elapsed + " ms (IOException retried per §9)"); - Console.info("cause: " + (e.getCause() == null ? "(none)" : e.getCause().getClass().getName())); - } - } - } - - // ---------- exhaustive switch over the sealed hierarchy ---------- - - private static void sealedSwitchDemo(MockServerControl mock, MarketDataClient client) { - Console.header("ADR-002 sealed hierarchy — consumer-side routing"); - mock.reset(); - mock.script(Step.of(401, "{\"s\":\"error\",\"errmsg\":\"nope\"}")); - try { - client.utilities().user(); - } catch (com.marketdata.sdk.exception.MarketDataException e) { - // JDK 17 (the SDK's minimum): use instanceof patterns. The hierarchy is sealed, so a - // future SDK release that adds an 8th subtype cannot do so silently — it'd require an - // amendment to ADR-002 and would break consumer compilations that DO use the pattern - // switch (JDK 21+). - // - // JDK 21+ version (kept here as a reference for consumers on a newer JDK; pattern - // switches over a sealed type are exhaustiveness-checked at compile time): - // - // String routed = switch (e) { - // case AuthenticationError a -> "→ AUTH"; - // case BadRequestError b -> "→ BAD_REQUEST"; - // case NotFoundError n -> "→ NOT_FOUND"; - // case RateLimitError r -> "→ RATE_LIMITED"; - // case ServerError s -> "→ SERVER"; - // case NetworkError n -> "→ NETWORK"; - // case ParseError p -> "→ PARSE"; - // }; - String routed; - if (e instanceof AuthenticationError) routed = "→ AUTH"; - else if (e instanceof BadRequestError) routed = "→ BAD_REQUEST"; - else if (e instanceof NotFoundError) routed = "→ NOT_FOUND"; - else if (e instanceof RateLimitError r) - routed = "→ RATE_LIMITED (retryAfter=" + r.getRetryAfter() + ")"; - else if (e instanceof ServerError s) routed = "→ SERVER (status=" + s.getStatusCode() + ")"; - else if (e instanceof NetworkError) routed = "→ NETWORK"; - else if (e instanceof ParseError) routed = "→ PARSE"; - else routed = "→ UNKNOWN (sealed permits drift!)"; - Console.ok("instanceof chain routed: " + routed); - Console.info("(JDK 21+ pattern-switch reference in the source comments)"); - } - } -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/FundsApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/FundsApp.java deleted file mode 100644 index dfc52d2..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/FundsApp.java +++ /dev/null @@ -1,267 +0,0 @@ -package com.marketdata.consumer; - -import com.marketdata.consumer.shared.Console; -import com.marketdata.consumer.shared.MockServerControl; -import com.marketdata.consumer.shared.MockServerControl.Step; -import com.marketdata.sdk.DateFormat; -import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.Mode; -import com.marketdata.sdk.exception.ParseError; -import com.marketdata.sdk.funds.FundCandle; -import com.marketdata.sdk.funds.FundCandlesRequest; -import com.marketdata.sdk.funds.FundResolution; -import java.time.LocalDate; -import java.util.List; - -/** - * Exhaustive {@code funds} resource demo against the mock server. Covers: - * - *

    - *
  • the single endpoint ({@code candles}) with its full parameter surface — universal params - * (dateFormat/mode/limit/offset) + the three window shapes ({@code from}/{@code to}, {@code - * date}, {@code to}+{@code countback}); - *
  • what funds do NOT have: no volume column (NAV series), no intraday resolutions (the API - * rejects them), and therefore no §12 auto-chunking — a multi-decade daily range is one - * request; - *
  • the CSV facet ({@code asCsv()}) including the output-shaping {@code columns}/{@code - * human}/{@code headers} params; - *
  • {@code columns} projection: requested fields populate, fields you did not ask for - * come back {@code null} with no error; - *
  • Option A failures: a required column you did request (or didn't project away) that - * the API omits raises a {@link ParseError}; - *
  • §8.2 per-response rate limits: {@code rateLimit()} parsed from each response's headers. - *
- * - *

Each scenario scripts the mock server's response with {@link MockServerControl#script}. - * - *

Run: {@code ./gradlew runFunds} (needs the mock server up). - */ -public final class FundsApp { - - private FundsApp() {} - - // OHLC only — funds are NAV series, the backend never emits a volume column for them. - private static final String CANDLES = - "{\"s\":\"ok\",\"t\":[1705276800,1705363200]," - + "\"o\":[451.21,452.84],\"h\":[452.84,454.12],\"l\":[450.97,452.1]," - + "\"c\":[452.84,453.97]}"; - - public static void main(String[] args) { - MockServerControl mock = new MockServerControl(); - mock.requireUp(); - - try (var client = new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { - candlesWithParams(mock, client); - noIntradayNoChunking(mock, client); - perResponseRateLimit(mock, client); - csvFacet(mock, client); - columnsProjectionDoesNotFail(mock, client); - optionARequestedColumnMissingFails(mock, client); - strictByDefaultMissingColumnFails(mock, client); - } - } - - // ---------- the candles endpoint, all parameter shapes ---------- - - private static void candlesWithParams(MockServerControl mock, MarketDataClient client) { - Console.header("funds.candles — the parameter surface"); - - // from/to window + universal params, set fluently. - Console.step("candles(...) — daily OHLC + universal params (dateFormat/mode/limit)"); - mock.reset(); - mock.script(Step.of(200, CANDLES)); - var candles = - client - .funds() - .dateFormat(DateFormat.UNIX) // universal param (type-preserving) - .mode(Mode.DELAYED) // universal param - .limit(500) // universal param - .candles( - FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") // required: resolution + symbol - .from(LocalDate.of(2025, 1, 1)) - .to(LocalDate.of(2025, 1, 31)) - .build()); - List bars = candles.values(); // List - Console.ok("candles.values() → " + bars.size() + " bars; iterating (note: no volume column):"); - for (FundCandle bar : bars) { - Console.info( - " " + bar.time() + " O=" + bar.open() + " H=" + bar.high() + " L=" + bar.low() - + " C=" + bar.close()); - } - - // Single-day lookup: date is mutually exclusive with from/to/countback. - Console.step("candles(date=...) — single trading day"); - mock.reset(); - mock.script(Step.of(200, CANDLES)); - var single = - client - .funds() - .candles( - FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") - .date(LocalDate.of(2025, 1, 17)) - .build()); - Console.ok("candles.values() → " + single.values().size() + " bars for one session"); - - // to + countback: "the last N candles before `to`" — no left edge needed. - Console.step("candles(to=..., countback=20) — last 20 sessions, weekly resolution"); - mock.reset(); - mock.script(Step.of(200, CANDLES)); - var counted = - client - .funds() - .candles( - FundCandlesRequest.builder(FundResolution.WEEKLY, "VFINX") - .to(LocalDate.of(2025, 1, 31)) - .countback(20) - .build()); - Console.ok("candles.values() → " + counted.values().size() + " weekly bars"); - } - - // ---------- funds-specific: no intraday → no §12 chunking ---------- - - private static void noIntradayNoChunking(MockServerControl mock, MarketDataClient client) { - Console.header("No intraday for funds — and therefore no §12 auto-chunking"); - mock.reset(); - mock.script(Step.of(200, CANDLES)); - Console.step("candles(DAILY, from=2000-01-01, to=2024-01-01) — 24 years, ONE request"); - var resp = - client - .funds() - .candles( - FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") - .from(LocalDate.of(2000, 1, 1)) - .to(LocalDate.of(2024, 1, 1)) - .build()); - Console.ok( - "candles.values() → " - + resp.values().size() - + " bars from " - + mock.stats().requests() - + " request (the year-span split only applies to intraday candles, which funds" - + " don't serve)"); - Console.info( - "FundResolution has no minutes()/hours() factories; the API rejects intraday tokens with" - + " \"Intraday resolutions are not available for fund candles.\""); - } - - // ---------- §8.2 per-response rate limit ---------- - - private static void perResponseRateLimit(MockServerControl mock, MarketDataClient client) { - Console.header("§8.2 per-response rate limit — rateLimit() off each response"); - mock.reset(); - // Script the four x-api-ratelimit-* headers on the response; the SDK parses them per response. - mock.script( - Step.of(200, CANDLES) - .withHeader("x-api-ratelimit-limit", "100000") - .withHeader("x-api-ratelimit-remaining", "99997") - .withHeader("x-api-ratelimit-reset", "1735689600") - .withHeader("x-api-ratelimit-consumed", "3")); - Console.step("candles(...).rateLimit() — parsed from THIS response's headers"); - var resp = client.funds().candles(FundCandlesRequest.of(FundResolution.DAILY, "VFINX")); - var rl = resp.rateLimit(); - if (rl != null) { - Console.ok( - "rateLimit() → remaining=" - + rl.remaining() - + "/" - + rl.limit() - + " consumed=" - + rl.consumed() - + " reset=" - + rl.reset()); - } else { - Console.fail("expected a rate-limit snapshot from the response headers"); - } - Console.info( - "Request-scoped — distinct from client.getRateLimits() (the client-wide latest snapshot)."); - } - - // ---------- CSV facet ---------- - - private static void csvFacet(MockServerControl mock, MarketDataClient client) { - Console.header("CSV facet — client.funds().asCsv()"); - - Console.step("asCsv().candles(...) — plain CSV"); - mock.reset(); - mock.script(Step.of(200, "t,o,h,l,c\n1705276800,451.21,452.84,450.97,452.84")); - var csv = client.funds().asCsv().candles(FundCandlesRequest.of(FundResolution.DAILY, "VFINX")); - Console.ok("→ CsvResponse (" + csv.csv().length() + " chars):"); - Console.info(csv.csv()); - - // columns / human / headers reshape the output, so they live ONLY on the CSV facet. - Console.step("asCsv().columns(...).human(true).headers(true) — output-shaping params (CSV-only)"); - mock.reset(); - mock.script(Step.of(200, "Date,Close\n2025-01-15,452.84\n2025-01-16,453.97")); - var shaped = - client - .funds() - .asCsv() - .columns("t", "c") - .human(true) - .headers(true) - .candles(FundCandlesRequest.of(FundResolution.DAILY, "VFINX")); - Console.ok("→ CSV with human headers + projected columns:"); - Console.info(shaped.csv()); - } - - // ---------- columns projection: no failure when a non-requested field is absent ---------- - - private static void columnsProjectionDoesNotFail(MockServerControl mock, MarketDataClient client) { - Console.header("columns projection — non-requested fields come back null, NO error"); - mock.reset(); - // The mock returns ONLY the projected columns (as the real API would for ?columns=...). - mock.script(Step.of(200, "{\"s\":\"ok\",\"t\":[1705276800],\"c\":[452.84]}")); - - FundCandle bar = - client - .funds() - .columns("t", "c") - .candles(FundCandlesRequest.of(FundResolution.DAILY, "VFINX")) - .values() - .get(0); - Console.ok("requested → t=" + bar.time() + " c=" + bar.close()); - Console.ok( - "NOT requested (null, decoded cleanly) → o=" + bar.open() + " h=" + bar.high() + " l=" - + bar.low()); - } - - // ---------- Option A: requested column missing → ParseError ---------- - - private static void optionARequestedColumnMissingFails( - MockServerControl mock, MarketDataClient client) { - Console.header("Option A — requested a column the API omitted → ParseError"); - mock.reset(); - // Consumer asks for o, but the body omits it → anomaly, not a projection. - mock.script(Step.of(200, "{\"s\":\"ok\",\"t\":[1705276800],\"c\":[452.84]}")); - - try { - client - .funds() - .columns("t", "c", "o") - .candles(FundCandlesRequest.of(FundResolution.DAILY, "VFINX")); - Console.fail("expected a ParseError — 'o' was requested but the API did not return it"); - } catch (ParseError e) { - Console.ok("ParseError as expected: " + e.getMessage()); - } - } - - // ---------- strict by default: no columns filter still requires all structural columns ---------- - - private static void strictByDefaultMissingColumnFails( - MockServerControl mock, MarketDataClient client) { - Console.header("Strict by default — no columns filter, but a required column is missing"); - mock.reset(); - mock.script( - Step.of(200, "{\"s\":\"ok\",\"t\":[1705276800],\"o\":[451.21],\"h\":[452.84],\"c\":[452.84]}")); - - try { - // No .columns(...) → every required column is implicitly requested, so the missing `l` fails. - client.funds().candles(FundCandlesRequest.of(FundResolution.DAILY, "VFINX")); - Console.fail("expected a ParseError — required columns are missing and none were projected away"); - } catch (ParseError e) { - Console.ok( - "ParseError as expected (nullable fields did NOT weaken the strict default): " - + e.getMessage()); - } - } -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java deleted file mode 100644 index 6b5fc85..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/LiveSmokeApp.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.marketdata.consumer; - -import com.marketdata.consumer.shared.Console; -import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.MarketDataResponse; -import com.marketdata.sdk.UtilitiesHeadersResponse; -import com.marketdata.sdk.UtilitiesStatusResponse; -import com.marketdata.sdk.UtilitiesUserResponse; -import java.util.Map; -import com.marketdata.sdk.utilities.ApiStatus; -import com.marketdata.sdk.utilities.RequestHeaders; -import com.marketdata.sdk.utilities.ServiceStatus; -import com.marketdata.sdk.utilities.User; -import java.util.List; -import java.util.concurrent.CompletableFuture; - -/** - * Live-API smoke test against the real {@code api.marketdata.app}. Requires - * a valid {@code MARKETDATA_TOKEN} in the environment or in {@code .env}. - * - *

Exercises every public endpoint on {@code client.utilities()} once sync - * and once async, plus the §13.5 response surface ({@code data()}, - * {@code rawBody()}, {@code requestId()}, {@code isJson()}, {@code isNoData()}, - * {@code requestUrl()}, {@code statusCode()}). Concludes with the §8 rate-limit - * snapshot the most recent call left on the client. - * - *

Run: {@code ./gradlew runLive} - */ -public final class LiveSmokeApp { - private LiveSmokeApp() {} - - public static void main(String[] args) { - // validateOnStartup = false on purpose for this smoke. The §5 probe is exercised by - // DemoAndConfigApp against the mock server. Keeping it off here so a transient backend hiccup - // on /user/ (5xx, slow response) surfaces as a per-row failure instead of a constructor crash - // that takes down the rest of the smoke. - try (var client = new MarketDataClient(null, null, null, false)) { - Console.header("Client snapshot"); - Console.info("toString: " + client); - Console.info("rateLimits before any call: " + client.getRateLimits()); - - Console.header("/status/ (sync) — unversioned, no token required"); - Console.run( - () -> client.utilities().status(), - r -> "data() has " + r.values().size() + " services; " + describe(r)); - - Console.header("/status/ (async) — same call via the async surface"); - Console.run( - () -> joinResponse(client.utilities().statusAsync()), - r -> "data() has " + r.values().size() + " services; " + describe(r)); - - Console.header("/user/ (sync) — needs a token"); - Console.run( - () -> client.utilities().user(), - r -> { - User u = r.values(); - return "requestsRemaining=" - + u.requestsRemaining() - + ", requestsLimit=" - + u.requestsLimit() - + ", optionsDataPermissions=" - + (u.optionsDataPermissions().isEmpty() ? "(real-time)" : u.optionsDataPermissions()) - + "; " - + describe(r); - }); - - Console.header("/headers/ (sync) — what the server saw on this call"); - Console.run( - () -> client.utilities().headers(), - r -> { - Map rh = r.values(); - String auth = rh.getOrDefault("authorization", "(absent)"); - return "headers=" - + rh.size() - + " entries (authorization echoed back: " - + auth - + "); " - + describe(r); - }); - - Console.header("Parallel async — fan out 3 calls, await all"); - long t0 = System.nanoTime(); - CompletableFuture a = client.utilities().statusAsync(); - CompletableFuture b = client.utilities().userAsync(); - CompletableFuture c = client.utilities().headersAsync(); - // exceptionally() turns a failure into a null sentinel so allOf doesn't short-circuit on - // the first failing call — we still want to see whether the others succeeded. - CompletableFuture aSafe = a.thenApply(r -> (Object) r).exceptionally(t -> t); - CompletableFuture bSafe = b.thenApply(r -> (Object) r).exceptionally(t -> t); - CompletableFuture cSafe = c.thenApply(r -> (Object) r).exceptionally(t -> t); - CompletableFuture.allOf(aSafe, bSafe, cSafe).join(); - long elapsedMs = (System.nanoTime() - t0) / 1_000_000; - Console.ok( - "all 3 completed in " - + elapsedMs - + " ms (≈ slowest single call, not sum — proves true parallelism)"); - describeResult("status", aSafe.join(), r -> { - List services = ((UtilitiesStatusResponse) r).values(); - return services.size() + " services; first: " + services.get(0).service(); - }); - describeResult("user", bSafe.join(), r -> "remaining=" + ((UtilitiesUserResponse) r).values().requestsRemaining()); - describeResult("headers", cSafe.join(), r -> ((UtilitiesHeadersResponse) r).values().size() + " entries"); - - Console.header("Final rate-limit snapshot"); - Console.info("rateLimits after the calls: " + client.getRateLimits()); - } - } - - @SuppressWarnings("unchecked") - private static void describeResult(String label, Object resultOrThrowable, java.util.function.Function describe) { - if (resultOrThrowable instanceof Throwable t) { - Throwable cause = t.getCause() != null ? t.getCause() : t; - Console.fail(label + " failed: " + cause.getClass().getSimpleName() + " — " + cause.getMessage()); - } else { - Console.ok(label + ": " + describe.apply(resultOrThrowable)); - } - } - - private static String describe(MarketDataResponse r) { - return "status=" + r.statusCode() + ", requestId=" + r.requestId() + ", url=" + r.requestUrl(); - } - - private static R joinResponse(CompletableFuture f) { - // CompletableFuture.join wraps the cause in CompletionException, but the SDK's joinSync - // contract is to surface MarketDataException directly. We mimic that here so the demo's - // exception output matches what a sync caller would see. - try { - return f.join(); - } catch (java.util.concurrent.CompletionException e) { - if (e.getCause() instanceof RuntimeException re) throw re; - throw e; - } - } -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/MarketsApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/MarketsApp.java deleted file mode 100644 index 99f5d75..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/MarketsApp.java +++ /dev/null @@ -1,264 +0,0 @@ -package com.marketdata.consumer; - -import com.marketdata.consumer.shared.Console; -import com.marketdata.consumer.shared.MockServerControl; -import com.marketdata.consumer.shared.MockServerControl.Step; -import com.marketdata.sdk.DateFormat; -import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.Mode; -import com.marketdata.sdk.exception.ParseError; -import com.marketdata.sdk.markets.MarketStatus; -import com.marketdata.sdk.markets.MarketStatusRequest; -import java.time.LocalDate; -import java.util.List; - -/** - * Exhaustive {@code markets} resource demo against the mock server. Covers: - * - *
    - *
  • the single endpoint ({@code status}) with its full parameter surface — universal params - * (dateFormat/mode/limit/offset) + the three window shapes ({@code from}/{@code to}, {@code - * date}, {@code to}+{@code countback}) and {@code country}, including the bare no-args - * request (today's status); - *
  • the null-status-cell case: days outside the backend's holiday-calendar coverage come back - * with a {@code null} status cell — decoded to {@code null}, not an error; - *
  • the CSV facet ({@code asCsv()}) including the output-shaping {@code columns}/{@code - * human}/{@code headers} params; - *
  • {@code columns} projection: requested fields populate, fields you did not ask for - * come back {@code null} with no error; - *
  • Option A failures: a required column you did request (or didn't project away) - * that the API omits raises a {@link ParseError}; - *
  • §8.2 per-response rate limits: {@code rateLimit()} parsed from each response's headers. - *
- * - *

Each scenario scripts the mock server's response with {@link MockServerControl#script}. - * - *

Run: {@code ./gradlew runMarkets} (needs the mock server up). - */ -public final class MarketsApp { - - private MarketsApp() {} - - // Fri open, Sat/Sun closed — the market open/closed calendar, NOT the API health endpoint. - private static final String STATUS = - "{\"s\":\"ok\"," - + "\"date\":[1705035600,1705122000,1705208400]," - + "\"status\":[\"open\",\"closed\",\"closed\"]}"; - - public static void main(String[] args) { - MockServerControl mock = new MockServerControl(); - mock.requireUp(); - - try (var client = new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { - statusWithParams(mock, client); - nullStatusOutsideCalendarCoverage(mock, client); - perResponseRateLimit(mock, client); - csvFacet(mock, client); - columnsProjectionDoesNotFail(mock, client); - optionARequestedColumnMissingFails(mock, client); - strictByDefaultMissingColumnFails(mock, client); - } - } - - // ---------- the status endpoint, all parameter shapes ---------- - - private static void statusWithParams(MockServerControl mock, MarketDataClient client) { - Console.header("markets.status — the parameter surface"); - - // Bare request: every parameter is optional — today's status, US calendar. - Console.step("status(MarketStatusRequest.of()) — no params: today's status"); - mock.reset(); - mock.script(Step.of(200, STATUS)); - var today = client.markets().status(MarketStatusRequest.of()); - Console.ok("status.values() → " + today.values().size() + " day(s); iterating:"); - for (MarketStatus day : today.values()) { - Console.info( - " " + day.date().toLocalDate() + " status=" + day.status() + " isOpen=" - + day.isOpen()); - } - - // from/to range + country + universal params, set fluently. - Console.step("status(from/to + country) + universal params (dateFormat/mode/limit)"); - mock.reset(); - mock.script(Step.of(200, STATUS)); - var range = - client - .markets() - .dateFormat(DateFormat.UNIX) // universal param (type-preserving) - .mode(Mode.DELAYED) // universal param - .limit(500) // universal param - .status( - MarketStatusRequest.builder() - .country("US") - .from(LocalDate.of(2024, 1, 12)) - .to(LocalDate.of(2024, 1, 14)) - .build()); - List days = range.values(); // List - long open = days.stream().filter(MarketStatus::isOpen).count(); - Console.ok("status.values() → " + days.size() + " days, " + open + " open"); - - // Single-day lookup: date is mutually exclusive with from/to/countback. - Console.step("status(date=...) — was the market open on a specific day?"); - mock.reset(); - mock.script(Step.of(200, "{\"s\":\"ok\",\"date\":[1705035600],\"status\":[\"open\"]}")); - var single = - client - .markets() - .status(MarketStatusRequest.builder().date(LocalDate.of(2024, 1, 12)).build()); - Console.ok("→ " + (single.values().get(0).isOpen() ? "open" : "closed")); - - // to + countback: "the last N days ending at `to`" — no left edge needed. - Console.step("status(to=..., countback=30) — the last 30 days"); - mock.reset(); - mock.script(Step.of(200, STATUS)); - var counted = - client - .markets() - .status( - MarketStatusRequest.builder() - .to(LocalDate.of(2024, 1, 14)) - .countback(30) - .build()); - Console.ok("status.values() → " + counted.values().size() + " days"); - } - - // ---------- markets-specific: null status cells outside calendar coverage ---------- - - private static void nullStatusOutsideCalendarCoverage( - MockServerControl mock, MarketDataClient client) { - Console.header("Null status cells — days outside the holiday-calendar coverage"); - mock.reset(); - // The backend's holiday data is bounded; days beyond it get a null status CELL (the column is - // present, so Option A is satisfied — this is data, not an anomaly). - mock.script( - Step.of(200, "{\"s\":\"ok\",\"date\":[1705035600,4102462800],\"status\":[\"open\",null]}")); - Console.step("status(...) — second day beyond the calendar bounds"); - var resp = client.markets().status(MarketStatusRequest.of()); - MarketStatus known = resp.values().get(0); - MarketStatus unknown = resp.values().get(1); - Console.ok("inside coverage → status=" + known.status() + " isOpen=" + known.isOpen()); - Console.ok( - "outside coverage → status=" - + unknown.status() - + " (null cell decoded cleanly; isOpen=" - + unknown.isOpen() - + ", isClosed=" - + unknown.isClosed() - + ")"); - Console.info("A null status means \"the calendar has no answer\", never a decode failure."); - } - - // ---------- §8.2 per-response rate limit ---------- - - private static void perResponseRateLimit(MockServerControl mock, MarketDataClient client) { - Console.header("§8.2 per-response rate limit — rateLimit() off each response"); - mock.reset(); - // Script the four x-api-ratelimit-* headers on the response; the SDK parses them per response. - mock.script( - Step.of(200, STATUS) - .withHeader("x-api-ratelimit-limit", "100000") - .withHeader("x-api-ratelimit-remaining", "99997") - .withHeader("x-api-ratelimit-reset", "1735689600") - .withHeader("x-api-ratelimit-consumed", "3")); - Console.step("status(...).rateLimit() — parsed from THIS response's headers"); - var resp = client.markets().status(MarketStatusRequest.of()); - var rl = resp.rateLimit(); - if (rl != null) { - Console.ok( - "rateLimit() → remaining=" - + rl.remaining() - + "/" - + rl.limit() - + " consumed=" - + rl.consumed() - + " reset=" - + rl.reset()); - } else { - Console.fail("expected a rate-limit snapshot from the response headers"); - } - Console.info( - "Request-scoped — distinct from client.getRateLimits() (the client-wide latest snapshot)."); - } - - // ---------- CSV facet ---------- - - private static void csvFacet(MockServerControl mock, MarketDataClient client) { - Console.header("CSV facet — client.markets().asCsv()"); - - Console.step("asCsv().status(...) — plain CSV"); - mock.reset(); - mock.script(Step.of(200, "date,status\n1705035600,open\n1705122000,closed")); - var csv = client.markets().asCsv().status(MarketStatusRequest.of()); - Console.ok("→ CsvResponse (" + csv.csv().length() + " chars):"); - Console.info(csv.csv()); - - // columns / human / headers reshape the output, so they live ONLY on the CSV facet. - Console.step( - "asCsv().columns(...).human(true).headers(true) — output-shaping params (CSV-only)"); - mock.reset(); - mock.script(Step.of(200, "Date,Status\n2024-01-12,open\n2024-01-13,closed")); - var shaped = - client - .markets() - .asCsv() - .columns("date", "status") - .human(true) - .headers(true) - .status(MarketStatusRequest.of()); - Console.ok("→ CSV with human headers + projected columns:"); - Console.info(shaped.csv()); - } - - // ---------- columns projection: no failure when a non-requested field is absent ---------- - - private static void columnsProjectionDoesNotFail( - MockServerControl mock, MarketDataClient client) { - Console.header("columns projection — non-requested fields come back null, NO error"); - mock.reset(); - // The mock returns ONLY the projected columns (as the real API would for ?columns=...). - mock.script(Step.of(200, "{\"s\":\"ok\",\"status\":[\"open\"]}")); - - MarketStatus day = - client.markets().columns("status").status(MarketStatusRequest.of()).values().get(0); - Console.ok("requested → status=" + day.status()); - Console.ok("NOT requested (null, decoded cleanly) → date=" + day.date()); - } - - // ---------- Option A: requested column missing → ParseError ---------- - - private static void optionARequestedColumnMissingFails( - MockServerControl mock, MarketDataClient client) { - Console.header("Option A — requested a column the API omitted → ParseError"); - mock.reset(); - // Consumer asks for date, but the body omits it → anomaly, not a projection. - mock.script(Step.of(200, "{\"s\":\"ok\",\"status\":[\"open\"]}")); - - try { - client.markets().columns("date", "status").status(MarketStatusRequest.of()); - Console.fail("expected a ParseError — 'date' was requested but the API did not return it"); - } catch (ParseError e) { - Console.ok("ParseError as expected: " + e.getMessage()); - } - } - - // ---------- strict by default: no columns filter still requires all structural columns ---------- - - private static void strictByDefaultMissingColumnFails( - MockServerControl mock, MarketDataClient client) { - Console.header("Strict by default — no columns filter, but a required column is missing"); - mock.reset(); - mock.script(Step.of(200, "{\"s\":\"ok\",\"date\":[1705035600]}")); - - try { - // No .columns(...) → every required column is implicitly requested, so the missing `status` - // fails. - client.markets().status(MarketStatusRequest.of()); - Console.fail( - "expected a ParseError — required columns are missing and none were projected away"); - } catch (ParseError e) { - Console.ok( - "ParseError as expected (nullable fields did NOT weaken the strict default): " - + e.getMessage()); - } - } -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/OptionsApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/OptionsApp.java deleted file mode 100644 index fa18bcc..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/OptionsApp.java +++ /dev/null @@ -1,353 +0,0 @@ -package com.marketdata.consumer; - -import com.marketdata.consumer.shared.Console; -import com.marketdata.consumer.shared.MockServerControl; -import com.marketdata.consumer.shared.MockServerControl.Step; -import com.marketdata.sdk.DateFormat; -import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.Mode; -import com.marketdata.sdk.exception.ParseError; -import com.marketdata.sdk.options.ExpirationFilter; -import com.marketdata.sdk.options.OptionQuote; -import com.marketdata.sdk.options.OptionSide; -import com.marketdata.sdk.options.OptionsChainRequest; -import com.marketdata.sdk.options.OptionsExpirationsRequest; -import com.marketdata.sdk.options.OptionsLookupRequest; -import com.marketdata.sdk.options.OptionsQuoteRequest; -import com.marketdata.sdk.options.OptionsQuotesRequest; -import com.marketdata.sdk.options.OptionsStrikesRequest; -import com.marketdata.sdk.options.StrikeFilter; -import com.marketdata.sdk.options.StrikeRange; -import java.time.LocalDate; -import java.util.List; - -/** - * Exhaustive {@code options} resource demo against the mock server. Covers: - * - *

    - *
  • every endpoint (lookup, expirations, strikes, quote, quotes, chain) with the full - * parameter surface — universal params (dateFormat/mode/limit/offset) + the rich chain - * filters (sealed expiration/strike groups, side, liquidity/price filters, …); - *
  • the CSV facet ({@code asCsv()}); - *
  • {@code columns} projection: requested fields populate, fields you did not ask for - * come back {@code null} with no error; - *
  • Option A failures: a required column you did request (or didn't project away) that - * the API omits raises a {@link ParseError}. - *
- * - *

Each scenario scripts the mock server's response with {@link MockServerControl#script}, so the - * "API" returns exactly the body the scenario needs. - * - *

Run: {@code ./gradlew runOptions} (needs the mock server up). - */ -public final class OptionsApp { - - private OptionsApp() {} - - // A full option-quote row (every column present) — used by chain/quote. - private static final String FULL_ROW = - "{\"s\":\"ok\"," - + "\"optionSymbol\":[\"AAPL250117C00150000\"],\"underlying\":[\"AAPL\"]," - + "\"expiration\":[1737136800],\"side\":[\"call\"],\"strike\":[150]," - + "\"firstTraded\":[1663118400],\"dte\":[45],\"updated\":[1705449600]," - + "\"bid\":[12.55],\"bidSize\":[10],\"mid\":[12.7],\"ask\":[12.85],\"askSize\":[8]," - + "\"last\":[12.8],\"openInterest\":[15234],\"volume\":[289],\"inTheMoney\":[true]," - + "\"intrinsicValue\":[3.38],\"extrinsicValue\":[9.32],\"underlyingPrice\":[153.38]," - + "\"iv\":[0.2432],\"delta\":[0.5862],\"gamma\":[0.015],\"theta\":[-0.1347]," - + "\"vega\":[0.4152],\"rho\":[0.0891]}"; - - // A chain with three contracts — so iterating values() shows more than one row. - private static final String THREE_ROWS = - "{\"s\":\"ok\"," - + "\"optionSymbol\":[\"AAPL250117C00150000\",\"AAPL250117C00155000\",\"AAPL250117C00160000\"]," - + "\"underlying\":[\"AAPL\",\"AAPL\",\"AAPL\"]," - + "\"expiration\":[1737136800,1737136800,1737136800]," - + "\"side\":[\"call\",\"call\",\"call\"]," - + "\"strike\":[150,155,160]," - + "\"firstTraded\":[1663118400,1663118400,1663118400]," - + "\"dte\":[45,45,45],\"updated\":[1705449600,1705449600,1705449600]," - + "\"bid\":[12.55,8.90,6.10],\"bidSize\":[10,12,8],\"mid\":[12.7,9.0,6.2]," - + "\"ask\":[12.85,9.10,6.30],\"askSize\":[8,9,7],\"last\":[12.8,9.0,6.2]," - + "\"openInterest\":[15234,9921,7044],\"volume\":[289,144,98]," - + "\"inTheMoney\":[true,false,false],\"intrinsicValue\":[3.38,0,0]," - + "\"extrinsicValue\":[9.32,9.0,6.2],\"underlyingPrice\":[153.38,153.38,153.38]," - + "\"iv\":[0.2432,0.2401,0.2380],\"delta\":[0.5862,0.5096,0.4401]," - + "\"gamma\":[0.015,0.0155,0.0150],\"theta\":[-0.1347,-0.1343,-0.1320]," - + "\"vega\":[0.4152,0.4251,0.4180],\"rho\":[0.0891,0.0810,0.0732]}"; - - private static final String EXPIRATIONS = - "{\"s\":\"ok\",\"expirations\":[1737072000,1739491200],\"updated\":1705449600}"; - private static final String STRIKES = - "{\"s\":\"ok\",\"updated\":1705449600,\"2025-01-17\":[145.0,150.0,155.0]}"; - private static final String LOOKUP = "{\"s\":\"ok\",\"optionSymbol\":\"AAPL250117C00150000\"}"; - - public static void main(String[] args) { - MockServerControl mock = new MockServerControl(); - mock.requireUp(); - - try (var client = new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { - everyEndpointWithAllParams(mock, client); - csvFacet(mock, client); - columnsProjectionDoesNotFail(mock, client); - optionARequestedColumnMissingFails(mock, client); - strictByDefaultMissingColumnFails(mock, client); - } - } - - // ---------- every endpoint, all params ---------- - - private static void everyEndpointWithAllParams(MockServerControl mock, MarketDataClient client) { - Console.header("Every options endpoint with the full parameter surface"); - - // chain — the richest filter surface, plus universal params set fluently on the resource. - Console.step("chain(...) — sealed filters + side/liquidity/price filters + universal params"); - mock.reset(); - mock.script(Step.of(200, THREE_ROWS)); - var chain = - client - .options() - .dateFormat(DateFormat.TIMESTAMP) // universal param (type-preserving) - .mode(Mode.DELAYED) // universal param - .limit(50) // universal param - .offset(0) // universal param - .chain( - OptionsChainRequest.builder("AAPL") // required: underlying - .expirationFilter(ExpirationFilter.dte(45)) // sealed mutex: pick ONE - .strikeFilter(StrikeFilter.range(150, 250)) // sealed mutex: pick ONE - .strikeRange(StrikeRange.ITM) - .side(OptionSide.CALL) - .strikeLimit(10) // (strikeLimit/delta are alternative strike selectors) - .delta(0.5) - .weekly(true) - .monthly(true) - .quarterly(true) - .am(true) - .pm(true) - .nonstandard(true) - .minBid(0.1) - .maxBid(100.0) - .minAsk(0.1) - .maxAsk(100.0) - .maxBidAskSpread(5.0) - .maxBidAskSpreadPct(50.0) - .minOpenInterest(100) - .minVolume(10) - .date(LocalDate.of(2025, 1, 2)) // historical snapshot - .build()); - // .values() returns a List — iterate it row by row. - List rows = chain.values(); - Console.ok("chain.values() → List with " + rows.size() + " contracts; iterating:"); - for (OptionQuote row : rows) { - Console.info( - " " - + row.optionSymbol() - + " strike=" - + row.strike() - + " bid/ask=" - + row.bid() - + "/" - + row.ask() - + " inTheMoney=" - + row.inTheMoney() - + " delta=" - + row.delta() - + " greeks=" - + row.presentGreeks()); - } - Console.info("statusCode=" + chain.statusCode() + " isNoData=" + chain.isNoData()); - - // quote — single OCC symbol, date-window params. - Console.step("quote(...) — single OCC symbol + date window"); - mock.reset(); - mock.script(Step.of(200, FULL_ROW)); - var quote = - client - .options() - .dateFormat(DateFormat.UNIX) - .quote( - OptionsQuoteRequest.builder("AAPL250117C00150000") - .from(LocalDate.of(2025, 1, 1)) // from+to = a date range (date window is mutex) - .to(LocalDate.of(2025, 1, 10)) - .build()); - List quoteRows = quote.values(); // also a List - Console.ok("quote.values() → List with " + quoteRows.size() + " row(s); iterating:"); - for (OptionQuote row : quoteRows) { - Console.info(" " + row.optionSymbol() + " last=" + row.last() + " iv=" + row.iv()); - } - - // quotes — fan-out over several OCC symbols → one request per symbol → per-symbol map. - Console.step("quotes(...) — multi OCC symbol fan-out (Map)"); - mock.reset(); - mock.script(List.of(Step.of(200, FULL_ROW), Step.of(200, FULL_ROW))); - var quotes = - client - .options() - .quotes( - OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000").build()); - quotes.forEach((sym, resp) -> Console.info(" " + sym + " → " + resp.values().size() + " row(s)")); - - // strikes — underlying + optional expiration/date; response is per-expiration strike lists. - Console.step("strikes(...) — strike ladder per expiration"); - mock.reset(); - mock.script(Step.of(200, STRIKES)); - var strikes = - client - .options() - .strikes( - OptionsStrikesRequest.builder("AAPL") - .expiration(LocalDate.of(2025, 1, 17)) - .date(LocalDate.of(2025, 1, 2)) - .build()); - Console.ok( - "strikes → " - + strikes.values().size() - + " expiration(s), updated=" - + strikes.updated()); - - // expirations — underlying + optional strike/date; response is a list of dates. - Console.step("expirations(...) — available expiration dates"); - mock.reset(); - mock.script(Step.of(200, EXPIRATIONS)); - var exps = - client - .options() - .expirations( - OptionsExpirationsRequest.builder("AAPL") - .strike(150.0) - .date(LocalDate.of(2025, 1, 2)) - .build()); - Console.ok("expirations → " + exps.values().size() + " date(s), updated=" + exps.updated()); - - // lookup — free-text → OCC symbol (scalar; no universal params, no facet). - Console.step("lookup(...) — human description → OCC symbol"); - mock.reset(); - mock.script(Step.of(200, LOOKUP)); - var sym = client.options().lookup(OptionsLookupRequest.of("AAPL 1/17/25 $150 call")); - Console.ok("lookup → " + sym.values()); - } - - // ---------- CSV facet ---------- - - private static void csvFacet(MockServerControl mock, MarketDataClient client) { - Console.header("CSV facet — client.options().asCsv()"); - - Console.step("asCsv().chain(...) — plain CSV"); - mock.reset(); - mock.script(Step.of(200, "optionSymbol,strike,bid\nAAPL250117C00150000,150,12.55")); - var csv = client - .options() - .asCsv() - .chain( - OptionsChainRequest.of("AAPL") - ); - Console.ok("→ CsvResponse (" + csv.csv().length() + " chars):"); - Console.info(csv.csv()); - - // columns / human / headers reshape the output, so they live ONLY on the CSV facet. - Console.step("asCsv().columns(...).human(true).headers(true) — output-shaping params (CSV-only)"); - mock.reset(); - mock.script(Step.of(200, "Option Symbol,Strike Price,Bid\nAAPL250117C00150000,150,12.55")); - var shaped = - client - .options() - .asCsv() - .columns("optionSymbol", "strike", "bid") // project to a subset - .human(true) // human-readable column names - .headers(true) // include the header row - .chain(OptionsChainRequest.of("AAPL")); - Console.ok("→ CSV with human headers + projected columns:"); - Console.info(shaped.csv()); - - // Fan-out in CSV mirrors the typed map: one CsvResponse per symbol. - Console.step("asCsv().quotes(...) — fan-out → Map"); - mock.reset(); - mock.script( - List.of( - Step.of(200, "optionSymbol,bid\nAAPL250117C00150000,12.55"), - Step.of(200, "optionSymbol,bid\nAAPL250117P00150000,3.10"))); - var csvMap = - client - .options() - .asCsv() - .quotes( - OptionsQuotesRequest.builder("AAPL250117C00150000", "AAPL250117P00150000").build()); - csvMap.forEach( - (sym, resp) -> Console.info(" " + sym + " → " + resp.csv().replace("\n", " ⏎ "))); - } - - // ---------- columns projection: no failure when a non-requested field is absent ---------- - - private static void columnsProjectionDoesNotFail(MockServerControl mock, MarketDataClient client) { - Console.header("columns projection — non-requested fields come back null, NO error"); - mock.reset(); - // The mock returns ONLY the projected columns (as the real API would for ?columns=...). - mock.script( - Step.of( - 200, - "{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]," - + "\"delta\":[0.5862]}")); - - var proj = - client - .options() - .columns("optionSymbol", "strike", "delta") - .chain(OptionsChainRequest.of("AAPL")); - - List rows = proj.values(); // still a List, just with most fields null - for (OptionQuote row : rows) { - Console.ok( - "requested → optionSymbol=" - + row.optionSymbol() - + " strike=" - + row.strike() - + " delta=" - + row.delta()); - Console.ok( - "NOT requested (null, decoded cleanly) → bid=" - + row.bid() - + " volume=" - + row.volume() - + " iv=" - + row.iv()); - } - } - - // ---------- Option A: requested column missing → ParseError ---------- - - private static void optionARequestedColumnMissingFails( - MockServerControl mock, MarketDataClient client) { - Console.header("Option A — requested a column the API omitted → ParseError"); - mock.reset(); - // Consumer asks for bid, but the body omits it → anomaly, not a projection. - mock.script( - Step.of(200, "{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}")); - - try { - client - .options() - .columns("optionSymbol", "strike", "bid") - .chain(OptionsChainRequest.of("AAPL")); - Console.fail("expected a ParseError — 'bid' was requested but the API did not return it"); - } catch (ParseError e) { - Console.ok("ParseError as expected: " + e.getMessage()); - } - } - - // ---------- strict by default: no columns filter still requires all structural columns ---------- - - private static void strictByDefaultMissingColumnFails( - MockServerControl mock, MarketDataClient client) { - Console.header("Strict by default — no columns filter, but a required column is missing"); - mock.reset(); - mock.script( - Step.of(200, "{\"s\":\"ok\",\"optionSymbol\":[\"AAPL250117C00150000\"],\"strike\":[150]}")); - - try { - // No .columns(...) → every required column is implicitly requested, so a missing one fails. - client.options().chain(OptionsChainRequest.of("AAPL")); - Console.fail("expected a ParseError — required columns are missing and none were projected away"); - } catch (ParseError e) { - Console.ok("ParseError as expected (nullable fields did NOT weaken the strict default): " + e.getMessage()); - } - } -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java deleted file mode 100644 index 4ece153..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/QuickstartApp.java +++ /dev/null @@ -1,506 +0,0 @@ -package com.marketdata.consumer; - -import com.marketdata.consumer.shared.Console; -import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.exception.AuthenticationError; -import com.marketdata.sdk.exception.MarketDataException; -import com.marketdata.sdk.funds.FundCandlesRequest; -import com.marketdata.sdk.funds.FundResolution; -import com.marketdata.sdk.markets.MarketStatus; -import com.marketdata.sdk.markets.MarketStatusRequest; -import com.marketdata.sdk.options.ExpirationFilter; -import com.marketdata.sdk.options.ExpirationStrikes; -import com.marketdata.sdk.options.OptionQuote; -import com.marketdata.sdk.options.OptionSide; -import com.marketdata.sdk.options.OptionsChain; -import com.marketdata.sdk.options.OptionsChainRequest; -import com.marketdata.sdk.options.OptionsExpirations; -import com.marketdata.sdk.options.OptionsExpirationsRequest; -import com.marketdata.sdk.options.OptionsLookup; -import com.marketdata.sdk.options.OptionsLookupRequest; -import com.marketdata.sdk.options.OptionsQuoteRequest; -import com.marketdata.sdk.options.OptionsQuotes; -import com.marketdata.sdk.options.OptionsQuotesRequest; -import com.marketdata.sdk.options.OptionsStrikes; -import com.marketdata.sdk.options.OptionsStrikesRequest; -import com.marketdata.sdk.options.StrikeFilter; -import com.marketdata.sdk.options.StrikeRange; -import com.marketdata.sdk.stocks.StockCandlesRequest; -import com.marketdata.sdk.stocks.StockEarning; -import com.marketdata.sdk.stocks.StockEarningsRequest; -import com.marketdata.sdk.stocks.StockNewsRequest; -import com.marketdata.sdk.stocks.StockPricesRequest; -import com.marketdata.sdk.stocks.StockQuote; -import com.marketdata.sdk.stocks.StockQuoteRequest; -import com.marketdata.sdk.stocks.StockQuotesRequest; -import com.marketdata.sdk.stocks.StockResolution; -import com.marketdata.sdk.utilities.ApiStatus; -import com.marketdata.sdk.utilities.RequestHeaders; -import com.marketdata.sdk.utilities.ServiceStatus; -import com.marketdata.sdk.utilities.User; -import java.time.LocalDate; -import java.util.List; -import java.util.Map; - -/** - * Idiomatic consumer-style examples — one short snippet per SDK resource showing - * the typical "first call you'd write" against it. - * - *

This is the growth surface for resource coverage. Each new - * resource that lands on the SDK (stocks, options, funds, markets) gets a new - * private {@code xxxExamples(client)} method below and a call from {@link #main}. - * The other demos in this directory each prove one cross-cutting behavior - * (retry, concurrency, etc.); this one shows the per-resource shape. - * - *

Hits the real API at {@code api.marketdata.app}. {@code MARKETDATA_TOKEN} - * in the env or {@code .env} is needed for endpoints that require auth; without - * it, the demo runs in demo mode — public endpoints succeed and the rest skip - * with a clear note instead of crashing. - * - *

The {@code Console.*} helpers used below are demo formatting only. In a - * real consumer app, the data lines would be plain {@code System.out.println} - * or your logger of choice — the SDK call itself is what to copy. - * - *

Run: {@code make demo-quickstart} (or {@code ./gradlew runQuickstart}). - */ -public final class QuickstartApp { - - private QuickstartApp() {} - - public static void main(String[] args) { - Console.header("Quickstart — idiomatic SDK usage, one section per resource"); - Console.info( - "As stocks / options / funds / markets land on the SDK, each gets a new section below."); - - // The no-arg constructor is the idiomatic path: it reads MARKETDATA_TOKEN - // from the env or .env, falls back to demo mode if neither is set, and - // validates the token by firing one /user/ probe at construct time - // (validateOnStartup=true by default). A failure here means the token is - // invalid — caught and reported below so the rest of the demo still runs. - try (MarketDataClient client = buildClient()) { - if (client == null) { - return; - } - utilitiesExamples(client); - optionsExamples(client); - stocksExamples(client); - marketsExamples(client); - fundsExamples(client); - } - } - - // ---------- utilities ---------- - - private static void utilitiesExamples(MarketDataClient client) { - Console.header("utilities — service health, quota, request diagnostics"); - - // 1) Public endpoint: no token required. Useful as a liveness check. - Console.step("client.utilities().status() — per-service health snapshot"); - try { - var health = client.utilities().status(); - long online = health.values().stream().filter(ServiceStatus::online).count(); - Console.ok(online + " of " + health.values().size() + " services online"); - } catch (MarketDataException e) { - Console.fail("status() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - - // 2) Authenticated endpoint: returns your quota state. Catching - // AuthenticationError is the consumer pattern for "token missing or - // invalid" — surface a hint to the user rather than crashing. - Console.step("client.utilities().user() — your quota & permissions"); - try { - var me = client.utilities().user(); - User u = me.values(); - Console.ok( - u.requestsRemaining() + " requests remaining of " + u.requestsLimit() + " (today)"); - } catch (AuthenticationError e) { - Console.info( - "401 — set MARKETDATA_TOKEN (env var or .env) to see your real quota." - + " Demo mode reaches this endpoint and gets rejected, as designed."); - } catch (MarketDataException e) { - Console.fail("user() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - - // 3) Diagnostic endpoint: echoes back the headers the server saw. Handy - // when debugging "is my Authorization header actually getting through?". - Console.step("client.utilities().headers() — what the server saw on this call"); - try { - var echo = client.utilities().headers(); - Console.ok( - "server received " - + echo.values().size() - + " request headers (Authorization echoed back redacted)"); - } catch (AuthenticationError e) { - Console.info("401 — needs a token (same reason as utilities().user())."); - } catch (MarketDataException e) { - Console.fail("headers() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - } - - // ---------- options ---------- - - /** - * One short snippet per options endpoint, in the order a consumer typically discovers them: - * lookup → expirations → strikes → chain → quote/quotes. The {@code chain} examples show the two - * sealed filter groups ({@link ExpirationFilter}, {@link StrikeFilter}), the {@code - * expiration=all} span, the optional nullable {@code rho} greek, and the {@code countback} - * window. Options data needs entitlements, so each step catches {@link AuthenticationError} - * separately and prints a hint — the tour stays runnable in demo mode. - */ - private static void optionsExamples(MarketDataClient client) { - Console.header("options — lookup, expirations, strikes, chain, quote, quotes"); - Console.info( - "Entry point is client.options(); every endpoint takes a Builder-based request object" - + " (no String overloads) and returns a typed MarketDataResponse (access the payload via .values())."); - - // 1) lookup — turn a human description into a well-formed OCC symbol. - Console.step("client.options().lookup(...) — human description → OCC symbol"); - try { - var r = - client.options().lookup(OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call")); - Console.ok("resolved to " + r.values()); - } catch (AuthenticationError e) { - Console.info("401 — set MARKETDATA_TOKEN (env or .env) to exercise the options endpoints."); - } catch (MarketDataException e) { - Console.fail("lookup() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - - // 2) expirations — the expiration calendar for an underlying. - Console.step("client.options().expirations(\"AAPL\") — expiration dates"); - try { - var r = - client.options().expirations(OptionsExpirationsRequest.of("AAPL")); - Console.ok( - r.values().size() + " expirations; updated " + r.updated()); - } catch (AuthenticationError e) { - Console.info("401 — needs a token."); - } catch (MarketDataException e) { - Console.fail("expirations() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - - // 3) strikes — the strike ladder, grouped per expiration. - Console.step("client.options().strikes(\"AAPL\") — strike ladder per expiration"); - try { - var r = client.options().strikes(OptionsStrikesRequest.of("AAPL")); - if (r.values().isEmpty()) { - Console.ok("no strikes returned"); - } else { - ExpirationStrikes first = r.values().get(0); - Console.ok( - first.expiration().toLocalDate() + " has " + first.strikes().size() + " strikes"); - } - } catch (AuthenticationError e) { - Console.info("401 — needs a token."); - } catch (MarketDataException e) { - Console.fail("strikes() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - - // 4) chain — the rich filter surface. The mutually-exclusive groups are sealed types: you pick - // one ExpirationFilter and one StrikeFilter variant, enforced by the compiler. Here: ITM-ish - // calls within 45 DTE, strikes 150–250, the 5 nearest the money. - Console.step( - "client.options().chain(...) — filtered chain via sealed ExpirationFilter / StrikeFilter"); - try { - var r = - client - .options() - .chain( - OptionsChainRequest.builder("AAPL") - .expirationFilter(ExpirationFilter.dte(45)) - .strikeFilter(StrikeFilter.range(150, 250)) - .side(OptionSide.CALL) - .strikeLimit(5) - .build()); - Console.ok(r.values().size() + " contracts"); - if (!r.values().isEmpty()) { - OptionQuote q = r.values().get(0); - // rho is an optional column — may be null when the feed omits it. - Console.ok(q.optionSymbol() + " delta=" + q.delta() + " rho=" + q.rho()); - } - } catch (AuthenticationError e) { - Console.info("401 — needs a token."); - } catch (MarketDataException e) { - Console.fail("chain() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - - // 5) chain with ExpirationFilter.all() — the whole chain across every expiration, distinct from - // omitting the filter (which the API narrows to the front-month). strikeLimit(1) keeps it small. - Console.step("client.options().chain(... ExpirationFilter.all()) — every expiration at once"); - try { - var chain = - client - .options() - .chain( - OptionsChainRequest.builder("AAPL") - .expirationFilter(ExpirationFilter.all()) - .side(OptionSide.CALL) - .strikeLimit(1) - .build()) - .values(); - long distinct = chain.stream().map(OptionQuote::expiration).distinct().count(); - Console.ok("spans " + distinct + " distinct expirations (front-month-only would be 1)"); - } catch (AuthenticationError e) { - Console.info("401 — needs a token."); - } catch (MarketDataException e) { - Console.fail("chain(all) failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - - // 6) quote / quotes — single contract, then concurrent multi-contract fan-out. Real symbols are - // pulled from a tiny chain query so the contracts are guaranteed to exist. quotes returns a - // Map keyed by symbol; countback caps each per-symbol series to the N most recent rows. - Console.step( - "client.options().quote(...) / quotes(...) — single + concurrent multi-contract (countback)"); - try { - List sample = - client - .options() - .chain( - OptionsChainRequest.builder("AAPL") - .side(OptionSide.CALL) - .strikeRange(StrikeRange.ITM) - .strikeLimit(2) - .build()) - .values(); - if (sample.size() < 2) { - Console.info("not enough contracts returned to demo quote/quotes — skipping"); - return; - } - String s1 = sample.get(0).optionSymbol(); - String s2 = sample.get(1).optionSymbol(); - - var one = client.options().quote(OptionsQuoteRequest.of(s1)); - Console.ok("quote(" + s1 + ") → " + one.values().size() + " row"); - - var many = - client - .options() - .quotes( - OptionsQuotesRequest.builder(s1, s2) - .to(LocalDate.now()) - .countback(5) - .build()); - Console.ok( - "quotes(" + s1 + ", " + s2 + ") → " + many.size() + " symbols, <=5 rows each (countback)"); - } catch (AuthenticationError e) { - Console.info("401 — needs a token."); - } catch (MarketDataException e) { - Console.fail("quote/quotes failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - } - - // ---------- stocks ---------- - - /** - * One short snippet per stocks endpoint, in the order a consumer typically reaches for them: - * candles → quote/quotes → prices → news → earnings. Entry point is {@code client.stocks()}; every - * endpoint takes a Builder-based request and returns a typed {@code MarketDataResponse} (payload - * via {@code .values()}). Stock data needs entitlements, so each step catches {@link - * AuthenticationError} separately and prints a hint — the tour stays runnable in demo mode. - */ - private static void stocksExamples(MarketDataClient client) { - Console.header("stocks — candles, quote, quotes, prices, news, earnings"); - - // 1) candles — historical OHLCV. Resolution is a value type (StockResolution.DAILY, .hours(1), - // .minutes(15), ...); the window is from/to (or date, or to+countback). - Console.step("client.stocks().candles(...) — daily OHLCV for the last month"); - try { - var r = - client - .stocks() - .candles( - StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") - .from(LocalDate.now().minusMonths(1)) - .to(LocalDate.now()) - .build()); - Console.ok(r.values().size() + " daily candles fetched"); - // §8.2: each response carries its own rate-limit snapshot (request-scoped). - if (r.rateLimit() != null) { - Console.info( - "rate limit (from this response): " - + r.rateLimit().remaining() - + "/" - + r.rateLimit().limit() - + " remaining"); - } - } catch (AuthenticationError e) { - Console.info("401 — set MARKETDATA_TOKEN (env or .env) to exercise the stocks endpoints."); - } catch (MarketDataException e) { - Console.fail("candles() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - - // 2) quote — single symbol. candle(true)/week52(true) add the opt-in OHLC / 52-week columns. - Console.step("client.stocks().quote(\"AAPL\") — latest quote"); - try { - var r = client.stocks().quote(StockQuoteRequest.of("AAPL")); - if (r.values().isEmpty()) { - Console.ok("no quote returned"); - } else { - StockQuote q = r.values().get(0); - Console.ok(q.symbol() + " last=" + q.last() + " bid/ask=" + q.bid() + "/" + q.ask()); - } - } catch (AuthenticationError e) { - Console.info("401 — needs a token."); - } catch (MarketDataException e) { - Console.fail("quote() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - - // 3) quotes — multiple symbols in ONE request (the stocks backend batches a comma list), so the - // result is a single response with one row per symbol (NOT a per-symbol map like options). - Console.step("client.stocks().quotes(\"AAPL\", \"MSFT\") — multi-symbol batch (single request)"); - try { - var r = client.stocks().quotes(StockQuotesRequest.builder("AAPL", "MSFT").build()); - Console.ok(r.values().size() + " quote rows in one response"); - } catch (AuthenticationError e) { - Console.info("401 — needs a token."); - } catch (MarketDataException e) { - Console.fail("quotes() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - - // 4) prices — a lighter snapshot (mid/change/updated) for several symbols, also batched. - Console.step("client.stocks().prices(\"AAPL\", \"MSFT\") — light price snapshot"); - try { - var r = client.stocks().prices(StockPricesRequest.of("AAPL", "MSFT")); - Console.ok(r.values().size() + " prices fetched"); - } catch (AuthenticationError e) { - Console.info("401 — needs a token."); - } catch (MarketDataException e) { - Console.fail("prices() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - - // 5) news — recent articles. The feed's latest-update time is a scalar off the response - // (response.updated()), distinct from each article's publicationDate. - Console.step("client.stocks().news(\"AAPL\") — recent articles + scalar updated()"); - try { - var r = client.stocks().news(StockNewsRequest.of("AAPL")); - Console.ok(r.values().size() + " articles; feed updated " + r.updated()); - } catch (AuthenticationError e) { - Console.info("401 — needs a token."); - } catch (MarketDataException e) { - Console.fail("news() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - - // 6) earnings — history + forward calendar. Fundamentals/report fields are nullable on - // synthesized forward-quarter rows; they decode to null without error. - Console.step("client.stocks().earnings(\"AAPL\") — EPS history (nullable forward fields)"); - try { - var r = client.stocks().earnings(StockEarningsRequest.of("AAPL")); - Console.ok(r.values().size() + " earnings rows"); - if (!r.values().isEmpty()) { - StockEarning e = r.values().get(r.values().size() - 1); - Console.ok( - "latest: FY" + e.fiscalYear() + " Q" + e.fiscalQuarter() + " reportedEPS=" - + e.reportedEPS()); - } - } catch (AuthenticationError e) { - Console.info("401 — needs a token."); - } catch (MarketDataException e) { - Console.fail("earnings() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - } - - // ---------- markets ---------- - - /** - * Markets expose a single endpoint: {@code status} — the exchange open/closed calendar (was/is - * the market open on these days?). Distinct from {@code utilities().status()}, which reports the - * API's own per-service health. Entry point is {@code client.markets()}; every parameter is - * optional (a bare request returns today's status, US calendar). - */ - private static void marketsExamples(MarketDataClient client) { - Console.header("markets — status (the exchange open/closed calendar)"); - - Console.step("client.markets().status(...) — open/closed for the last week"); - try { - var r = - client - .markets() - .status( - MarketStatusRequest.builder() - .from(LocalDate.now().minusDays(7)) - .to(LocalDate.now()) - .build()); - long open = r.values().stream().filter(MarketStatus::isOpen).count(); - Console.ok(r.values().size() + " days fetched; " + open + " open"); - if (!r.values().isEmpty()) { - MarketStatus today = r.values().get(r.values().size() - 1); - Console.ok("latest: " + today.date().toLocalDate() + " → " + today.status()); - } - } catch (AuthenticationError e) { - Console.info("401 — set MARKETDATA_TOKEN (env or .env) to exercise the markets endpoint."); - } catch (MarketDataException e) { - Console.fail("status() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - } - - // ---------- funds ---------- - - /** - * Funds expose a single endpoint: {@code candles}. NAV-based OHLC — no volume column, no - * intraday resolutions ({@code FundResolution} only models daily and up), and no §12 - * auto-chunking (it only applies to intraday candles). Entry point is {@code client.funds()}. - */ - private static void fundsExamples(MarketDataClient client) { - Console.header("funds — candles (NAV series: OHLC, no volume)"); - - Console.step("client.funds().candles(...) — daily NAV candles for the last month"); - try { - var r = - client - .funds() - .candles( - FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") - .from(LocalDate.now().minusMonths(1)) - .to(LocalDate.now()) - .build()); - Console.ok(r.values().size() + " daily candles fetched"); - if (!r.values().isEmpty()) { - var bar = r.values().get(r.values().size() - 1); - Console.ok("latest: " + bar.time() + " close=" + bar.close()); - } - } catch (AuthenticationError e) { - Console.info("401 — set MARKETDATA_TOKEN (env or .env) to exercise the funds endpoint."); - } catch (MarketDataException e) { - Console.fail("candles() failed: " + e.getExceptionType() + " — " + e.getMessage()); - } - } - - // ---------- helpers ---------- - - /** - * Build the client. Idiomatic path is the no-arg constructor (cascade + startup - * validation on). The fallback to the 4-arg constructor with {@code - * validateOnStartup=false} exists so any future startup-probe surprise - * (transient 5xx, slow API) doesn't kill the demo before the per-resource - * examples run. A real consumer app would normally just let the exception - * propagate to its top-level error handler. - */ - private static MarketDataClient buildClient() { - try { - return new MarketDataClient(); - } catch (AuthenticationError e) { - Console.fail("Constructor failed: " + e.getMessage()); - Console.info( - "MARKETDATA_TOKEN is set but the API rejected it. Fix the token, or unset it to use" - + " demo mode."); - return null; - } catch (MarketDataException e) { - // ParseError on /user/ (payload drift), NetworkError, etc. Retry with the - // startup probe disabled so the rest of the demo can run. - Console.info( - "Startup probe failed (" - + e.getExceptionType() - + "): " - + e.getMessage() - + ". Retrying with validateOnStartup=false so the demo can continue."); - try { - return new MarketDataClient(null, null, null, false); - } catch (Throwable t) { - Console.fail("Fallback construction failed: " + t.getClass().getSimpleName()); - return null; - } - } catch (Throwable t) { - Console.fail("Constructor failed: " + t.getClass().getSimpleName() + " — " + t.getMessage()); - return null; - } - } -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java deleted file mode 100644 index 98d1e09..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/ResponseFeaturesApp.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.marketdata.consumer; - -import com.marketdata.consumer.shared.Console; -import com.marketdata.consumer.shared.MockServerControl; -import com.marketdata.consumer.shared.MockServerControl.Step; -import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.utilities.ApiStatus; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; - -/** - * §13.5 {@code MarketDataResponse} surface: format predicates ({@code isJson}, - * {@code isCsv}, {@code isHtml}), the no-data envelope ({@code isNoData} - * from a 404 + {@code s:no_data}), the raw body via {@code json()}, the - * {@code saveToFile} helper, and the redacted {@code toString} shape. - * - *

Run: {@code ./gradlew runResponse} - */ -public final class ResponseFeaturesApp { - private ResponseFeaturesApp() {} - - public static void main(String[] args) throws Exception { - MockServerControl mock = new MockServerControl(); - mock.requireUp(); - - try (var client = - new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { - formatPredicates(mock, client); - noDataEnvelope(mock, client); - jsonReturnsRawBody(mock, client); - saveToFileWritesVerbatim(mock, client); - toStringIsLogSafe(mock, client); - } - } - - // ---------- format predicates ---------- - - private static void formatPredicates(MockServerControl mock, MarketDataClient client) { - Console.header("§13.5 format predicates"); - mock.reset(); - mock.script(Step.of(200, "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}")); - - var resp = client.utilities().user(); - Console.info("isJson(): " + resp.isJson()); - Console.info("isCsv(): " + resp.isCsv()); - Console.info("isHtml(): " + resp.isHtml()); - if (resp.isJson() && !resp.isCsv() && !resp.isHtml()) { - Console.ok("JSON response detected — other predicates are false (mutually exclusive)"); - } else { - Console.fail("expected isJson=true and the others false"); - } - Console.info( - "(isCsv/isHtml are not reachable through the utilities resource today — utility"); - Console.info( - " endpoints are JSON-only. The wiring is there for future endpoints that negotiate format.)"); - } - - // ---------- 404 + s:no_data ---------- - - private static void noDataEnvelope(MockServerControl mock, MarketDataClient client) { - Console.header("§11: 404 + {\"s\":\"no_data\"} is a SUCCESSFUL response"); - mock.reset(); - mock.script(Step.of(404, "{\"s\":\"no_data\"}")); - - try { - var resp = client.utilities().status(); - Console.ok( - "no exception thrown; statusCode=" - + resp.statusCode() - + ", isNoData=" - + resp.isNoData()); - Console.info("data().services() = " + resp.values() + " (empty list as designed)"); - } catch (Exception e) { - Console.fail("404+no_data became an exception: " + e.getClass().getSimpleName()); - } - } - - // ---------- defensive rawBody copy ---------- - - private static void jsonReturnsRawBody(MockServerControl mock, MarketDataClient client) { - Console.header("json() returns the raw response body verbatim"); - mock.reset(); - String payload = "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}"; - mock.script(Step.of(200, payload)); - - var resp = client.utilities().user(); - String body = resp.json(); - if (body.equals(payload)) { - Console.ok("json() matches the original payload (" + body.length() + " chars)"); - } else { - Console.fail("json() differs from the original payload"); - } - } - - // ---------- saveToFile ---------- - - private static void saveToFileWritesVerbatim(MockServerControl mock, MarketDataClient client) - throws Exception { - Console.header("saveToFile writes the raw body verbatim"); - mock.reset(); - String payload = "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}"; - mock.script(Step.of(200, payload)); - - var resp = client.utilities().user(); - Path tmp = Files.createTempFile("sdk-consumer-", ".json"); - try { - resp.saveToFile(tmp); - String on_disk = Files.readString(tmp); - if (on_disk.equals(payload)) { - Console.ok("on-disk content matches the original: " + tmp); - } else { - Console.fail("on-disk content differs from payload"); - } - } finally { - Files.deleteIfExists(tmp); - } - } - - // ---------- toString is log-safe (§16) ---------- - - private static void toStringIsLogSafe(MockServerControl mock, MarketDataClient client) { - Console.header("§16: toString omits data + redacts query strings"); - mock.reset(); - String payload = "{\"x-ratelimit-requests-remaining\":1,\"x-ratelimit-requests-limit\":1,\"x-options-data-permissions\":\"\"}"; - mock.script(Step.of(200, payload)); - - var resp = client.utilities().user(); - String repr = resp.toString(); - Console.info(repr); - if (repr.contains("requestsRemaining")) { - Console.fail("response toString leaked typed data (field names visible)"); - } else { - Console.ok("typed payload NOT in toString"); - } - if (repr.contains("bytes=") && repr.contains("status=")) { - Console.ok("metadata visible (status, bytes, format, url)"); - } else { - Console.fail("metadata missing"); - } - } -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java deleted file mode 100644 index d7c0a6c..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/RetryBehaviorApp.java +++ /dev/null @@ -1,222 +0,0 @@ -package com.marketdata.consumer; - -import com.marketdata.consumer.shared.Console; -import com.marketdata.consumer.shared.MockServerControl; -import com.marketdata.consumer.shared.MockServerControl.Step; -import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.exception.RateLimitError; -import com.marketdata.sdk.exception.ServerError; -import java.time.Duration; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.util.List; - -/** - * Walks through the §9 retry policy: which statuses retry, when {@code - * Retry-After} overrides the exponential backoff, the §21-fix cap on - * pathological values, and the §10.3 preflight that fails fast when the - * latest rate-limit snapshot reports remaining=0. - * - *

Reading the wall-clock printed for each scenario is the point — the SDK's - * timing IS the spec behavior here. - * - *

Run: {@code ./gradlew runRetry} - */ -public final class RetryBehaviorApp { - private RetryBehaviorApp() {} - - public static void main(String[] args) { - MockServerControl mock = new MockServerControl(); - mock.requireUp(); - - try (var client = - new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { - retryRecovers503Then200(mock, client); - retryAfterDeltaOverridesExponential(mock, client); - retryAfterHttpDateHonored(mock, client); - retryAfterPathologicalIsCapped(mock, client); - preflightBlocksWhenSnapshotExhausted(mock, client); - } - } - - // ---------- 503 → 200: retry recovers ---------- - - private static void retryRecovers503Then200(MockServerControl mock, MarketDataClient client) { - Console.header("Retry recovers: 503 → 503 → 200 (≈ 3s wall-time from 1s + 2s backoff)"); - mock.reset(); - String okBody = validStatusBody(); - mock.script(MockServerControl.failNTimesThenSucceed(2, 503, okBody)); - - long t0 = System.nanoTime(); - try { - var resp = client.utilities().status(); - long elapsed = (System.nanoTime() - t0) / 1_000_000; - Console.ok( - "succeeded after retries; data.services()=" - + resp.values().size() - + ", wall-time=" - + elapsed - + " ms"); - Console.info("server saw " + mock.stats().requests() + " requests (expected 3)"); - } catch (ServerError e) { - Console.fail("retries did not recover the call: " + e.getMessage()); - } - } - - // ---------- Retry-After: delta-seconds overrides exponential ---------- - - private static void retryAfterDeltaOverridesExponential( - MockServerControl mock, MarketDataClient client) { - Console.header( - "§9.4: Retry-After: 3 on a 503 overrides the calculated 1s backoff (≈ 3s wait, not 1s)"); - mock.reset(); - mock.script( - List.of( - Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}") - .withHeader("Retry-After", "3"), - Step.of(200, validStatusBody()))); - - long t0 = System.nanoTime(); - try { - client.utilities().status(); - long elapsed = (System.nanoTime() - t0) / 1_000_000; - Console.ok("succeeded after " + elapsed + " ms (≈ 3000 — server's hint was honored)"); - } catch (ServerError e) { - Console.fail("call failed: " + e.getMessage()); - } - } - - // ---------- Retry-After: HTTP-date variant ---------- - - private static void retryAfterHttpDateHonored(MockServerControl mock, MarketDataClient client) { - Console.header("§9.4: Retry-After accepts HTTP-date (RFC 1123)"); - mock.reset(); - // Pick a future time large enough that the parse-on-the-server-side latency doesn't shrink - // the resulting delta below the exponential 1s floor (in which case we couldn't tell from - // wall-clock alone whether the date was honored or the SDK fell back to exponential). With - // +4s, the delta the SDK computes is always > 1s even after server round-trip latency. - String inFourSeconds = - ZonedDateTime.now(ZoneOffset.UTC) - .plusSeconds(4) - .format(DateTimeFormatter.RFC_1123_DATE_TIME); - mock.script( - List.of( - Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}") - .withHeader("Retry-After", inFourSeconds), - Step.of(200, validStatusBody()))); - - long t0 = System.nanoTime(); - try { - client.utilities().status(); - long elapsed = (System.nanoTime() - t0) / 1_000_000; - Console.ok( - "succeeded after " - + elapsed - + " ms (HTTP-date parsed → delta from now; ~3-4s wall-time honors the date)"); - if (elapsed < 1500) { - Console.info( - " note: wall-time below ~1.5s suggests the parse failed silently and exponential 1s"); - Console.info( - " fired instead — open the SDK log to confirm."); - } - } catch (ServerError e) { - Console.fail("call failed: " + e.getMessage()); - } - } - - // ---------- Retry-After: pathological value capped (#21 fix) ---------- - - private static void retryAfterPathologicalIsCapped( - MockServerControl mock, MarketDataClient client) { - Console.header( - "#21 fix: Retry-After of 1 day on a 503 → capped, SDK falls back to exponential (≈ 1s)"); - mock.reset(); - mock.script( - List.of( - Step.of(503, "{\"s\":\"error\",\"errmsg\":\"down\"}") - // 86400s = 1 day — well above the 10-minute cap. - .withHeader("Retry-After", "86400"), - Step.of(200, validStatusBody()))); - - long t0 = System.nanoTime(); - try { - client.utilities().status(); - long elapsed = (System.nanoTime() - t0) / 1_000_000; - if (elapsed > 30_000) { - Console.fail("call took " + elapsed + " ms — cap did NOT engage"); - } else { - Console.ok( - "succeeded after " - + elapsed - + " ms (≈ 1000 — SDK ignored the 1-day directive and used exponential 1s backoff)"); - Console.info( - "the consumer can still see the raw value on the ServerError via getRetryAfter()"); - } - } catch (ServerError e) { - Console.fail("call failed: " + e.getMessage()); - } - } - - // ---------- §10.3 preflight ---------- - - private static void preflightBlocksWhenSnapshotExhausted( - MockServerControl mock, MarketDataClient client) { - Console.header( - "§10.3: snapshot says remaining=0 → preflight fails fast, server sees ZERO additional requests"); - mock.reset(); - // First call: 200 + rate-limit headers reporting remaining=0 with reset in the future. - long resetEpoch = (System.currentTimeMillis() / 1000L) + 3600L; - mock.script( - Step.of(200, validStatusBody()) - .withHeader("x-api-ratelimit-limit", "1000") - .withHeader("x-api-ratelimit-remaining", "0") - .withHeader("x-api-ratelimit-reset", String.valueOf(resetEpoch)) - .withHeader("x-api-ratelimit-consumed", "1000")); - - try { - client.utilities().status(); - Console.ok("first call succeeded; snapshot now says remaining=0"); - Console.info("rateLimits: " + client.getRateLimits()); - } catch (Exception e) { - Console.fail("first call failed: " + e.getMessage()); - } - - int before = mock.stats().requests(); - Console.step("second call: preflight should block it before it hits the wire"); - long t0 = System.nanoTime(); - try { - client.utilities().status(); - Console.fail("second call returned — preflight did not engage"); - } catch (RateLimitError e) { - long elapsed = (System.nanoTime() - t0) / 1_000_000; - Console.ok( - "RateLimitError raised after " - + elapsed - + " ms (instant — no network round-trip)"); - Console.info("message: " + e.getMessage()); - } - int after = mock.stats().requests(); - if (after == before) { - Console.ok("server saw 0 additional requests — preflight blocked at the SDK boundary"); - } else { - Console.fail("server saw " + (after - before) + " additional requests — preflight failed"); - } - } - - // ---------- helpers ---------- - - /** Minimal /status/ payload that ApiStatusDeserializer accepts. */ - private static String validStatusBody() { - long now = System.currentTimeMillis() / 1000L; - return "{\"s\":\"ok\"," - + "\"service\":[\"/v1/markets/status/\"]," - + "\"status\":[\"online\"]," - + "\"online\":[true]," - + "\"uptimePct30d\":[1.0]," - + "\"uptimePct90d\":[1.0]," - + "\"updated\":[" - + now - + "]}"; - } -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/StocksApp.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/StocksApp.java deleted file mode 100644 index 156ae48..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/StocksApp.java +++ /dev/null @@ -1,358 +0,0 @@ -package com.marketdata.consumer; - -import com.marketdata.consumer.shared.Console; -import com.marketdata.consumer.shared.MockServerControl; -import com.marketdata.consumer.shared.MockServerControl.Step; -import com.marketdata.sdk.DateFormat; -import com.marketdata.sdk.MarketDataClient; -import com.marketdata.sdk.Mode; -import com.marketdata.sdk.exception.ParseError; -import com.marketdata.sdk.stocks.StockCandle; -import com.marketdata.sdk.stocks.StockCandlesRequest; -import com.marketdata.sdk.stocks.StockEarning; -import com.marketdata.sdk.stocks.StockEarningsRequest; -import com.marketdata.sdk.stocks.StockNewsArticle; -import com.marketdata.sdk.stocks.StockNewsRequest; -import com.marketdata.sdk.stocks.StockPrice; -import com.marketdata.sdk.stocks.StockPricesRequest; -import com.marketdata.sdk.stocks.StockQuote; -import com.marketdata.sdk.stocks.StockQuoteRequest; -import com.marketdata.sdk.stocks.StockQuotesRequest; -import com.marketdata.sdk.stocks.StockResolution; -import java.time.LocalDate; -import java.util.List; - -/** - * Exhaustive {@code stocks} resource demo against the mock server. Covers: - * - *

    - *
  • every endpoint (candles, quote, quotes, prices, news, earnings) with its parameter surface — - * universal params (dateFormat/mode/limit/offset) + the candle window, the quote opt-in - * columns ({@code candle}/{@code 52week}), and the news/earnings date windows; - *
  • the CSV facet ({@code asCsv()}) including the output-shaping {@code columns}/{@code - * human}/{@code headers} params; - *
  • {@code columns} projection: requested fields populate, fields you did not ask for - * come back {@code null} with no error; - *
  • Option A failures: a required column you did request (or didn't project away) that - * the API omits raises a {@link ParseError}; - *
  • §12 candle auto-chunking: an intraday range over a year splits into concurrent sub-requests - * and merges into one response; - *
  • §8.2 per-response rate limits: {@code rateLimit()} parsed from each response's headers. - *
- * - *

Each scenario scripts the mock server's response with {@link MockServerControl#script}. - * - *

Run: {@code ./gradlew runStocks} (needs the mock server up). - */ -public final class StocksApp { - - private StocksApp() {} - - private static final String CANDLES = - "{\"s\":\"ok\",\"t\":[1705276800,1705363200]," - + "\"o\":[216.5,218.0],\"h\":[218.55,220.12],\"l\":[215.78,217.32]," - + "\"c\":[217.83,219.68],\"v\":[62130000,58240000]}"; - - // A single-symbol quote WITH the opt-in candle (o/h/l/c) and 52-week columns present. - private static final String QUOTE_FULL = - "{\"s\":\"ok\",\"symbol\":[\"AAPL\"]," - + "\"ask\":[221.55],\"askSize\":[200],\"bid\":[221.5],\"bidSize\":[300]," - + "\"mid\":[221.525],\"last\":[221.52],\"change\":[1.38],\"changepct\":[0.0063]," - + "\"volume\":[58240000],\"updated\":[1705449600]," - + "\"o\":[219.0],\"h\":[222.3],\"l\":[218.5],\"c\":[221.52]," - + "\"52weekHigh\":[260.1],\"52weekLow\":[164.08]}"; - - private static final String QUOTES = - "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"MSFT\"]," - + "\"ask\":[221.55,415.2],\"askSize\":[200,100],\"bid\":[221.5,415.05]," - + "\"bidSize\":[300,150],\"mid\":[221.525,415.125],\"last\":[221.52,415.1]," - + "\"change\":[1.38,-2.4],\"changepct\":[0.0063,-0.0057]," - + "\"volume\":[58240000,22150000],\"updated\":[1705449600,1705449600]}"; - - private static final String PRICES = - "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"MSFT\"]," - + "\"mid\":[221.525,415.125],\"change\":[1.38,-2.4]," - + "\"changepct\":[0.0063,-0.0057],\"updated\":[1705449600,1705449600]}"; - - private static final String NEWS = - "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"AAPL\"]," - + "\"headline\":[\"Apple Reports Record Q4\",\"Apple Announces New Product Line\"]," - + "\"content\":[\"Apple reported record revenue...\",\"Apple unveiled new products...\"]," - + "\"source\":[\"https://example.com/a\",\"https://example.com/b\"]," - + "\"publicationDate\":[1705449600,1705363200],\"updated\":1705449600}"; - - // Two rows: a settled historical report, then a synthesized forward quarter whose fundamentals - // and report fields are null — showing the nullable earnings columns decode cleanly. - private static final String EARNINGS = - "{\"s\":\"ok\",\"symbol\":[\"AAPL\",\"AAPL\"]," - + "\"fiscalYear\":[2024,null],\"fiscalQuarter\":[3,null]," - + "\"date\":[1706659200,1714521600],\"reportDate\":[1706832000,null]," - + "\"reportTime\":[\"after close\",null],\"currency\":[\"USD\",\"USD\"]," - + "\"reportedEPS\":[2.18,null],\"estimatedEPS\":[2.1,2.3]," - + "\"surpriseEPS\":[0.08,null],\"surpriseEPSpct\":[3.81,null]," - + "\"updated\":[1706832000,1706832000]}"; - - public static void main(String[] args) { - MockServerControl mock = new MockServerControl(); - mock.requireUp(); - - try (var client = new MarketDataClient("token", MockServerControl.BASE_URL, null, false)) { - everyEndpointWithParams(mock, client); - candleAutoChunking(mock, client); - perResponseRateLimit(mock, client); - csvFacet(mock, client); - columnsProjectionDoesNotFail(mock, client); - optionARequestedColumnMissingFails(mock, client); - strictByDefaultMissingColumnFails(mock, client); - } - } - - // ---------- §12 candle auto-chunking ---------- - - private static void candleAutoChunking(MockServerControl mock, MarketDataClient client) { - Console.header("§12 candle auto-chunking — intraday range > 1 year splits + merges"); - mock.reset(); - // A 3-year HOURLY (intraday) range splits into 4 year-sized sub-requests, fetched concurrently - // and merged. Script one candle body per slice; each returns 2 rows → 8 merged. - mock.script( - List.of( - Step.of(200, CANDLES), Step.of(200, CANDLES), Step.of(200, CANDLES), - Step.of(200, CANDLES))); - Console.step("candles(hours(1), from=2020-01-01, to=2023-01-01) — auto-split"); - var resp = - client - .stocks() - .candles( - StockCandlesRequest.builder(StockResolution.hours(1), "AAPL") - .from(LocalDate.of(2020, 1, 1)) - .to(LocalDate.of(2023, 1, 1)) - .build()); - Console.ok( - "candles.values() → " - + resp.values().size() - + " bars merged from " - + mock.stats().requests() - + " concurrent sub-requests (one continuous series, transparent to the caller)"); - Console.info("Daily/weekly/… resolutions or no `from` bound → a single request, no chunking."); - } - - // ---------- §8.2 per-response rate limit ---------- - - private static void perResponseRateLimit(MockServerControl mock, MarketDataClient client) { - Console.header("§8.2 per-response rate limit — rateLimit() off each response"); - mock.reset(); - // Script the four x-api-ratelimit-* headers on the response; the SDK parses them per response. - mock.script( - Step.of(200, QUOTE_FULL) - .withHeader("x-api-ratelimit-limit", "100000") - .withHeader("x-api-ratelimit-remaining", "99997") - .withHeader("x-api-ratelimit-reset", "1735689600") - .withHeader("x-api-ratelimit-consumed", "3")); - Console.step("quote(\"AAPL\").rateLimit() — parsed from THIS response's headers"); - var resp = client.stocks().quote(StockQuoteRequest.of("AAPL")); - var rl = resp.rateLimit(); - if (rl != null) { - Console.ok( - "rateLimit() → remaining=" - + rl.remaining() - + "/" - + rl.limit() - + " consumed=" - + rl.consumed() - + " reset=" - + rl.reset()); - } else { - Console.fail("expected a rate-limit snapshot from the response headers"); - } - Console.info( - "Request-scoped — distinct from client.getRateLimits() (the client-wide latest snapshot)."); - } - - // ---------- every endpoint ---------- - - private static void everyEndpointWithParams(MockServerControl mock, MarketDataClient client) { - Console.header("Every stocks endpoint with its parameter surface"); - - // candles — OHLCV series. Resolution is a value type; universal params set fluently. - Console.step("candles(...) — daily OHLCV + universal params (dateFormat/mode/limit)"); - mock.reset(); - mock.script(Step.of(200, CANDLES)); - var candles = - client - .stocks() - .dateFormat(DateFormat.UNIX) // universal param (type-preserving) - .mode(Mode.DELAYED) // universal param - .limit(500) // universal param - .candles( - StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") // required: resolution + symbol - .from(LocalDate.of(2025, 1, 1)) - .to(LocalDate.of(2025, 1, 31)) - .adjustSplits(true) - .adjustDividends(true) - .build()); - List bars = candles.values(); // List - Console.ok("candles.values() → " + bars.size() + " bars; iterating:"); - for (StockCandle bar : bars) { - Console.info( - " " + bar.time() + " O=" + bar.open() + " H=" + bar.high() + " L=" + bar.low() - + " C=" + bar.close() + " V=" + bar.volume()); - } - - // quote — single symbol, opt-in candle + 52-week columns. - Console.step("quote(...) — single symbol with candle=true & 52week=true opt-in columns"); - mock.reset(); - mock.script(Step.of(200, QUOTE_FULL)); - StockQuote q = - client - .stocks() - .quote(StockQuoteRequest.builder("AAPL").candle(true).week52(true).build()) - .values() - .get(0); - Console.ok( - "quote → " + q.symbol() + " bid/ask=" + q.bid() + "/" + q.ask() + " last=" + q.last()); - Console.info( - " opt-in candle: O=" + q.open() + " H=" + q.high() + " L=" + q.low() + " C=" + q.close()); - Console.info(" opt-in 52week: high=" + q.week52High() + " low=" + q.week52Low()); - - // quotes — multi-symbol BATCH in ONE request (comma list) → one response with N rows. - Console.step("quotes(...) — multi-symbol batch (single request, one response with N rows)"); - mock.reset(); - mock.script(Step.of(200, QUOTES)); - var quotes = client.stocks().quotes(StockQuotesRequest.builder("AAPL", "MSFT").build()); - Console.ok("quotes.values() → " + quotes.values().size() + " rows (single request):"); - for (StockQuote row : quotes.values()) { - Console.info(" " + row.symbol() + " mid=" + row.mid() + " vol=" + row.volume()); - } - - // prices — lighter than quotes (mid/change/updated), also a single batched request. - Console.step("prices(...) — light multi-symbol price snapshot (mid/change)"); - mock.reset(); - mock.script(Step.of(200, PRICES)); - var prices = client.stocks().prices(StockPricesRequest.of("AAPL", "MSFT")); - for (StockPrice p : prices.values()) { - Console.info(" " + p.symbol() + " mid=" + p.mid() + " change=" + p.change()); - } - - // news — per-row articles + a scalar `updated` exposed off the response (not on each row). - Console.step("news(...) — articles + scalar updated() on the response"); - mock.reset(); - mock.script(Step.of(200, NEWS)); - var news = client.stocks().news(StockNewsRequest.of("AAPL")); - Console.ok("news.values() → " + news.values().size() + " articles; updated=" + news.updated()); - for (StockNewsArticle a : news.values()) { - Console.info(" " + a.publicationDate().toLocalDate() + " " + a.headline()); - } - - // earnings — history + forward calendar; nullable fundamentals/report fields decode to null. - Console.step("earnings(...) — history + forward quarter (nullable fields decode cleanly)"); - mock.reset(); - mock.script(Step.of(200, EARNINGS)); - var earnings = - client - .stocks() - .earnings( - StockEarningsRequest.builder("AAPL") - .to(LocalDate.of(2025, 6, 1)) - .countback(8) - .build()); - for (StockEarning e : earnings.values()) { - Console.info( - " FY" - + e.fiscalYear() - + " Q" - + e.fiscalQuarter() - + " reportedEPS=" - + e.reportedEPS() - + " estimatedEPS=" - + e.estimatedEPS() - + " reportTime=" - + e.reportTime()); - } - } - - // ---------- CSV facet ---------- - - private static void csvFacet(MockServerControl mock, MarketDataClient client) { - Console.header("CSV facet — client.stocks().asCsv()"); - - Console.step("asCsv().candles(...) — plain CSV"); - mock.reset(); - mock.script( - Step.of(200, "t,o,h,l,c,v\n1705276800,216.5,218.55,215.78,217.83,62130000")); - var csv = - client.stocks().asCsv().candles(StockCandlesRequest.of(StockResolution.DAILY, "AAPL")); - Console.ok("→ CsvResponse (" + csv.csv().length() + " chars):"); - Console.info(csv.csv()); - - // columns / human / headers reshape the output, so they live ONLY on the CSV facet. - Console.step("asCsv().columns(...).human(true).headers(true) — output-shaping params (CSV-only)"); - mock.reset(); - mock.script(Step.of(200, "Symbol,Mid Price\nAAPL,221.525\nMSFT,415.125")); - var shaped = - client - .stocks() - .asCsv() - .columns("symbol", "mid") - .human(true) - .headers(true) - .quotes(StockQuotesRequest.builder("AAPL", "MSFT").build()); - Console.ok("→ CSV with human headers + projected columns:"); - Console.info(shaped.csv()); - } - - // ---------- columns projection: no failure when a non-requested field is absent ---------- - - private static void columnsProjectionDoesNotFail(MockServerControl mock, MarketDataClient client) { - Console.header("columns projection — non-requested fields come back null, NO error"); - mock.reset(); - // The mock returns ONLY the projected columns (as the real API would for ?columns=...). - mock.script(Step.of(200, "{\"s\":\"ok\",\"symbol\":[\"AAPL\"],\"mid\":[221.525]}")); - - StockQuote row = - client.stocks().columns("symbol", "mid").quote(StockQuoteRequest.of("AAPL")).values().get(0); - Console.ok("requested → symbol=" + row.symbol() + " mid=" + row.mid()); - Console.ok( - "NOT requested (null, decoded cleanly) → bid=" - + row.bid() - + " volume=" - + row.volume() - + " updated=" - + row.updated()); - } - - // ---------- Option A: requested column missing → ParseError ---------- - - private static void optionARequestedColumnMissingFails( - MockServerControl mock, MarketDataClient client) { - Console.header("Option A — requested a column the API omitted → ParseError"); - mock.reset(); - // Consumer asks for bid, but the body omits it → anomaly, not a projection. - mock.script(Step.of(200, "{\"s\":\"ok\",\"symbol\":[\"AAPL\"],\"mid\":[221.525]}")); - - try { - client.stocks().columns("symbol", "mid", "bid").quote(StockQuoteRequest.of("AAPL")); - Console.fail("expected a ParseError — 'bid' was requested but the API did not return it"); - } catch (ParseError e) { - Console.ok("ParseError as expected: " + e.getMessage()); - } - } - - // ---------- strict by default: no columns filter still requires all structural columns ---------- - - private static void strictByDefaultMissingColumnFails( - MockServerControl mock, MarketDataClient client) { - Console.header("Strict by default — no columns filter, but a required column is missing"); - mock.reset(); - mock.script(Step.of(200, "{\"s\":\"ok\",\"symbol\":[\"AAPL\"],\"mid\":[221.525]}")); - - try { - // No .columns(...) → every required column is implicitly requested, so a missing one fails. - client.stocks().quote(StockQuoteRequest.of("AAPL")); - Console.fail("expected a ParseError — required columns are missing and none were projected away"); - } catch (ParseError e) { - Console.ok( - "ParseError as expected (nullable fields did NOT weaken the strict default): " - + e.getMessage()); - } - } -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/shared/Console.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/shared/Console.java deleted file mode 100644 index 7650833..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/shared/Console.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.marketdata.consumer.shared; - -import com.marketdata.sdk.exception.MarketDataException; -import java.time.Duration; -import java.util.function.Supplier; - -/** - * Pretty-print helpers shared across every demo app. The output is plain - * text, no ANSI colors — these demos are meant to be readable in any - * terminal and copy-pastable into bug reports. - */ -public final class Console { - - private Console() {} - - /** Print a section header to make demo output scan-able. */ - public static void header(String title) { - System.out.println(); - System.out.println("==== " + title + " ===="); - } - - /** Print a sub-step under the current header. */ - public static void step(String description) { - System.out.println(); - System.out.println(" -- " + description); - } - - /** Indented success line. */ - public static void ok(String message) { - System.out.println(" ✓ " + message); - } - - /** Indented failure line — used when an exception is the expected outcome. */ - public static void fail(String message) { - System.out.println(" ✗ " + message); - } - - /** Indented info line. */ - public static void info(String message) { - System.out.println(" · " + message); - } - - /** - * Run {@code body} and either print {@code expected} on a thrown - * {@link MarketDataException}, or "no exception" if it succeeds. The full - * support-info dump is printed under the exception line so consumers can - * see the §6 shape end-to-end. - */ - public static void expectException(String expected, Runnable body) { - try { - body.run(); - fail("expected " + expected + " but call returned normally"); - } catch (MarketDataException e) { - ok("got " + e.getExceptionType() + " — message: " + e.getMessage()); - System.out.println(e.getSupportInfo().indent(6).stripTrailing()); - } catch (RuntimeException e) { - fail("expected " + expected + " but got " + e.getClass().getSimpleName() + ": " + e.getMessage()); - } - } - - /** Run {@code body}, print {@code printer.toString(result)} on success, or the exception on failure. */ - public static void run(Supplier body, java.util.function.Function printer) { - try { - ok(printer.apply(body.get())); - } catch (MarketDataException e) { - fail(e.getExceptionType() + ": " + e.getMessage()); - } - } - - /** Same as {@link #run} but prints elapsed wall-clock — useful for retry/backoff demos. */ - public static void runTimed(Supplier body, java.util.function.Function printer) { - long startNanos = System.nanoTime(); - try { - ok(printer.apply(body.get())); - } catch (MarketDataException e) { - fail(e.getExceptionType() + ": " + e.getMessage()); - } - Duration elapsed = Duration.ofNanos(System.nanoTime() - startNanos); - info("wall-time: " + elapsed.toMillis() + " ms"); - } -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/consumer/shared/MockServerControl.java b/examples/consumer-test/src/main/java/com/marketdata/consumer/shared/MockServerControl.java deleted file mode 100644 index 0ff03f7..0000000 --- a/examples/consumer-test/src/main/java/com/marketdata/consumer/shared/MockServerControl.java +++ /dev/null @@ -1,235 +0,0 @@ -package com.marketdata.consumer.shared; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -/** - * Tiny client for the FastAPI mock server's /_admin/* endpoints. Each demo - * that scripts behavior uses this class to: - * - *

    - *
  • verify the mock server is up before the demo runs (fail-fast with a - * clear "did you forget to start the server?" message) - *
  • queue scripted responses via /_admin/script - *
  • read the request counter and peak in-flight via /_admin/stats - *
  • reset between demo steps - *
- * - *

Uses {@link java.net.http.HttpClient} directly — not the SDK — because - * the admin plane is intentionally outside the SDK's surface area. - */ -public final class MockServerControl { - - public static final String BASE_URL = "http://127.0.0.1:8765"; - - // Force HTTP/1.1 — uvicorn doesn't speak HTTP/2 out of the box. Java's default of HTTP/2 makes - // the first request attempt an upgrade that uvicorn rejects, and at least in some scenarios - // the body gets dropped during the fallback. Plain 1.1 sidesteps the whole dance for the admin - // control plane, which is the only thing this class talks to. - private final HttpClient http = - HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .connectTimeout(Duration.ofSeconds(2)) - .build(); - - /** - * Throw a helpful error if the mock server isn't running. Call this at the - * top of every demo that needs it. - */ - public void requireUp() { - try { - HttpResponse resp = - http.send( - HttpRequest.newBuilder(URI.create(BASE_URL + "/_admin/stats")) - .timeout(Duration.ofSeconds(2)) - .GET() - .build(), - HttpResponse.BodyHandlers.ofString()); - if (resp.statusCode() != 200) { - throw new IllegalStateException( - "Mock server responded with HTTP " + resp.statusCode() + " — expected 200"); - } - } catch (Exception e) { - throw new IllegalStateException( - "Mock server is not reachable at " - + BASE_URL - + ". Start it in another terminal: cd ../mock-server && ./run.sh", - e); - } - } - - /** Drop the script queue and reset request counters. */ - public void reset() { - post("/_admin/reset", "{}"); - } - - /** Snapshot of the server's request counter + peak concurrency. */ - public Stats stats() { - String body = get("/_admin/stats"); - int requests = parseIntField(body, "\"requests\":"); - int peak = parseIntField(body, "\"peak_in_flight\":"); - return new Stats(requests, peak); - } - - /** Replace the script queue with {@code steps}. */ - public void script(List steps) { - StringBuilder json = new StringBuilder("{\"steps\":["); - for (int i = 0; i < steps.size(); i++) { - if (i > 0) json.append(','); - json.append(steps.get(i).toJson()); - } - json.append("]}"); - post("/_admin/script", json.toString()); - } - - /** Convenience overload for a single step. */ - public void script(Step step) { - script(List.of(step)); - } - - // ---------- internals ---------- - - private String get(String path) { - try { - HttpResponse resp = - http.send( - HttpRequest.newBuilder(URI.create(BASE_URL + path)).GET().build(), - HttpResponse.BodyHandlers.ofString()); - return resp.body(); - } catch (Exception e) { - throw new RuntimeException("GET " + path + " failed", e); - } - } - - private void post(String path, String body) { - try { - HttpResponse resp = - http.send( - HttpRequest.newBuilder(URI.create(BASE_URL + path)) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(), - HttpResponse.BodyHandlers.ofString()); - if (resp.statusCode() < 200 || resp.statusCode() >= 300) { - throw new RuntimeException( - "POST " - + path - + " returned HTTP " - + resp.statusCode() - + " — body sent: " - + body - + " — server response: " - + resp.body()); - } - } catch (Exception e) { - throw new RuntimeException("POST " + path + " failed", e); - } - } - - /** Cheap JSON extractor — pulls the integer that follows {@code marker} in {@code json}. */ - private static int parseIntField(String json, String marker) { - int idx = json.indexOf(marker); - if (idx < 0) return -1; - int start = idx + marker.length(); - while (start < json.length() && (json.charAt(start) == ' ' || json.charAt(start) == '\t')) { - start++; - } - int end = start; - while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) { - end++; - } - return Integer.parseInt(json.substring(start, end)); - } - - /** Single scripted response. */ - public static final class Step { - private int status = 200; - private String body = "{}"; - private Map headers = Map.of(); - private int delayMs = 0; - private String path = null; - - public static Step of(int status, String body) { - Step s = new Step(); - s.status = status; - s.body = body; - return s; - } - - public Step withHeader(String name, String value) { - Map next = new java.util.LinkedHashMap<>(this.headers); - next.put(name, value); - this.headers = next; - return this; - } - - public Step delayMs(int ms) { - this.delayMs = ms; - return this; - } - - /** Restrict this step to requests for an exact path (e.g. "/user/"). */ - public Step forPath(String path) { - this.path = path; - return this; - } - - String toJson() { - StringBuilder sb = new StringBuilder("{"); - sb.append("\"status\":").append(status); - sb.append(",\"body\":").append(jsonString(body)); - sb.append(",\"delay_ms\":").append(delayMs); - if (path != null) { - sb.append(",\"path\":").append(jsonString(path)); - } - if (!headers.isEmpty()) { - sb.append(",\"headers\":{"); - boolean first = true; - for (var e : headers.entrySet()) { - if (!first) sb.append(','); - sb.append(jsonString(e.getKey())).append(':').append(jsonString(e.getValue())); - first = false; - } - sb.append('}'); - } - sb.append('}'); - return sb.toString(); - } - - private static String jsonString(String s) { - StringBuilder sb = new StringBuilder().append('"'); - for (char c : s.toCharArray()) { - switch (c) { - case '"' -> sb.append("\\\""); - case '\\' -> sb.append("\\\\"); - case '\n' -> sb.append("\\n"); - case '\r' -> sb.append("\\r"); - case '\t' -> sb.append("\\t"); - default -> { - if (c < 0x20) sb.append(String.format("\\u%04x", (int) c)); - else sb.append(c); - } - } - } - return sb.append('"').toString(); - } - } - - /** Convenience builder for "fail with status X N times, then succeed". */ - public static List failNTimesThenSucceed(int n, int failStatus, String successBody) { - List steps = new ArrayList<>(n + 1); - for (int i = 0; i < n; i++) { - steps.add(Step.of(failStatus, "{\"s\":\"error\",\"errmsg\":\"transient\"}")); - } - steps.add(Step.of(200, successBody)); - return steps; - } - - public record Stats(int requests, int peakInFlight) {} -} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/common/ConcurrentRequestsExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/common/ConcurrentRequestsExample.java new file mode 100644 index 0000000..b0450c4 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/common/ConcurrentRequestsExample.java @@ -0,0 +1,66 @@ +package com.marketdata.examples.common; + +import com.marketdata.examples.util.MockServer; +import com.marketdata.examples.util.MockServer.Step; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.UtilitiesStatusResponse; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Firing many requests at once — and what the SDK does about it. + * + *

Every endpoint has an async variant ({@code statusAsync()}, {@code quoteAsync()}, …) + * returning a {@link CompletableFuture}. You can launch hundreds at once and join them with + * {@link CompletableFuture#allOf}; the SDK caps how many actually hit the network at the same time + * at 50, queuing the rest. You don't manage that — this example just makes it visible. + * + *

It points the SDK at a local mock server so each response can be delayed deterministically and + * the server can report the peak number of simultaneous requests it saw. Start the mock first: + * {@code cd ../mock-server && ./run.sh}. + * + *

Run: {@code ./gradlew runConcurrency} + */ +public final class ConcurrentRequestsExample { + + private ConcurrentRequestsExample() {} + + public static void main(String[] args) { + MockServer mock = new MockServer(); + mock.requireUp(); + + // Script 60 identical responses, each held 800ms so the requests genuinely overlap. + int fanout = 60; + mock.scriptRepeated(fanout, Step.of(200, statusBody()).delayMs(800)); + + // base URL points at the mock; validateOnStartup=false skips the construct-time probe. + try (var client = new MarketDataClient("token", MockServer.BASE_URL, null, false)) { + + // Launch all 60 at once — nothing blocks here, every call returns a future immediately. + List> futures = new ArrayList<>(); + long start = System.nanoTime(); + for (int i = 0; i < fanout; i++) { + futures.add(client.utilities().statusAsync()); + } + + // Wait for all of them to finish. + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + System.out.println("Launched " + fanout + " async requests at once."); + System.out.println("All completed in " + elapsedMs + " ms."); + System.out.println("Peak requests in flight (observed by the server): " + mock.peakInFlight()); + System.out.println(); + System.out.println("The SDK held in-flight requests to 50; the other " + + (fanout - 50) + " waited and ran once a slot freed up. That's why the wall-clock is"); + System.out.println("about two 800ms waves, not one — and why you can fan out freely without"); + System.out.println("overwhelming the API yourself."); + } + } + + private static String statusBody() { + return "{\"s\":\"ok\",\"service\":[\"/v1/markets/status/\"],\"status\":[\"online\"]," + + "\"online\":[true],\"uptimePct30d\":[1.0],\"uptimePct90d\":[1.0],\"updated\":[1705449600]}"; + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/common/ConfigurationExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/common/ConfigurationExample.java new file mode 100644 index 0000000..df58884 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/common/ConfigurationExample.java @@ -0,0 +1,57 @@ +package com.marketdata.examples.common; + +import com.marketdata.sdk.MarketDataClient; + +/** + * How to configure the client, and what the SDK validates up front. + * + *

The idiomatic path is the no-arg constructor: it reads your token and settings from a cascade + * — explicit value → {@code MARKETDATA_*} environment variables → a {@code .env} + * file in the working directory → built-in defaults — and validates the token by firing + * one request when it's built. A four-arg constructor lets you set everything explicitly, which is + * handy for tests and the snippets below. + * + *

Nothing here makes a real API call (every client uses {@code validateOnStartup=false}), so it + * runs offline. + * + *

Run: {@code ./gradlew runConfiguration} + */ +public final class ConfigurationExample { + + private ConfigurationExample() {} + + public static void main(String[] args) { + // The no-arg constructor is what you'd use in production: + // + // try (MarketDataClient client = new MarketDataClient()) { ... } + // + // It resolves the token from the cascade and validates it on startup. We don't run it here + // because it needs a token and network; the explicit constructor below shows the same settings. + + // Four-arg constructor: (apiKey, baseUrl, apiVersion, validateOnStartup). null means "use the + // cascade / default" for that slot. + System.out.println("=== Explicit configuration ==="); + try (var client = new MarketDataClient("my-token", "https://api.marketdata.app", "v1", false)) { + System.out.println(client); + } + + // Tokens are never printed in full. Long tokens show only the last 4 chars; short ones are + // hidden entirely. This applies anywhere the SDK logs or renders the client. + System.out.println("\n=== Token redaction ==="); + try (var client = new MarketDataClient("supersecret-token-YKT0", "https://api.marketdata.app", "v1", false)) { + System.out.println("Long token → " + client); // ...***…***YKT0 + } + try (var client = new MarketDataClient("abcd", "https://api.marketdata.app", "v1", false)) { + System.out.println("Short token → " + client); // fully hidden + } + + // Misconfiguration fails immediately at construction with a clear message, not later on the + // first request. + System.out.println("\n=== Fail-fast validation ==="); + try { + new MarketDataClient("token", "not-a-url", null, false).close(); + } catch (IllegalArgumentException e) { + System.out.println("Bad base URL rejected at construction: " + e.getMessage()); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/common/ErrorHandlingExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/common/ErrorHandlingExample.java new file mode 100644 index 0000000..c8e46f2 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/common/ErrorHandlingExample.java @@ -0,0 +1,94 @@ +package com.marketdata.examples.common; + +import com.marketdata.examples.util.MockServer; +import com.marketdata.examples.util.MockServer.Step; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.exception.BadRequestError; +import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.exception.NetworkError; +import com.marketdata.sdk.exception.NotFoundError; +import com.marketdata.sdk.exception.ParseError; +import com.marketdata.sdk.exception.RateLimitError; +import com.marketdata.sdk.exception.ServerError; + +/** + * How errors surface, and how to handle them. + * + *

Everything the SDK throws is a {@link MarketDataException}. It's a sealed hierarchy: + * the seven subtypes below are the complete set, so you can branch on them exhaustively and the + * compiler will tell you if a future version adds one. Each carries support context + * ({@code getStatusCode()}, {@code getRequestId()}, {@code getSupportInfo()}, …). + * + *

This example scripts a local server to produce a couple of error conditions on demand. Start + * the mock first: {@code cd ../mock-server && ./run.sh}. + * + *

Run: {@code ./gradlew runErrors} + */ +public final class ErrorHandlingExample { + + private ErrorHandlingExample() {} + + public static void main(String[] args) { + MockServer mock = new MockServer(); + mock.requireUp(); + + try (var client = new MarketDataClient("token", MockServer.BASE_URL, null, false)) { + + // Catch a specific subtype when you want to react to one condition — e.g. a bad token. + System.out.println("=== Catching one specific error ==="); + mock.script(Step.of(401, "{\"s\":\"error\",\"errmsg\":\"Unauthorized\"}")); + try { + client.utilities().user(); + } catch (AuthenticationError e) { + System.out.println("Got AuthenticationError (HTTP " + e.getStatusCode() + "): " + e.getMessage()); + } + + // Or branch over the whole hierarchy. Because it's sealed, this covers every case the SDK can + // throw. (On JDK 21+ you can write this as an exhaustive `switch` pattern — see the comment.) + System.out.println("\n=== Routing by type ==="); + mock.script(Step.of(429, "{\"s\":\"error\",\"errmsg\":\"rate limited\"}").header("Retry-After", "5")); + try { + client.utilities().status(); + } catch (MarketDataException e) { + System.out.println(route(e)); + System.out.println("\nSupport context for a bug report:\n" + e.getSupportInfo().stripTrailing()); + } + } + } + + // JDK 21+ equivalent, exhaustiveness-checked by the compiler over the sealed type: + // + // return switch (e) { + // case AuthenticationError a -> "Authentication failed — check your token"; + // case BadRequestError b -> "Bad request — check your parameters"; + // case NotFoundError n -> "Not found"; + // case RateLimitError r -> "Rate limited — retry after " + retryHint(r); + // case ServerError s -> "Server error (HTTP " + s.getStatusCode() + ")"; + // case NetworkError n -> "Network problem — is the API reachable?"; + // case ParseError p -> "Could not parse the response"; + // }; + private static String route(MarketDataException e) { + if (e instanceof AuthenticationError) { + return "Authentication failed — check your token"; + } else if (e instanceof BadRequestError) { + return "Bad request — check your parameters"; + } else if (e instanceof NotFoundError) { + return "Not found"; + } else if (e instanceof RateLimitError r) { + return "Rate limited — retry after " + retryHint(r); + } else if (e instanceof ServerError s) { + return "Server error (HTTP " + s.getStatusCode() + ")"; + } else if (e instanceof NetworkError) { + return "Network problem — is the API reachable?"; + } else if (e instanceof ParseError) { + return "Could not parse the response"; + } + return "Unknown error"; + } + + /** getRetryAfter() is an Optional — render it as seconds, or a fallback if absent. */ + private static String retryHint(RateLimitError e) { + return e.getRetryAfter().map(d -> d.toSeconds() + "s").orElse("a moment"); + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/common/ResponseFormatsExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/common/ResponseFormatsExample.java new file mode 100644 index 0000000..c1a1263 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/common/ResponseFormatsExample.java @@ -0,0 +1,59 @@ +package com.marketdata.examples.common; + +import com.marketdata.sdk.MarketDataClient; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Every call returns a response wrapper around the typed data, with a uniform surface regardless of + * endpoint or format. This shows what's on it: the typed payload, request metadata for logging and + * support, format predicates, the raw body, the per-request rate limit, and {@code saveToFile}. + * + *

Uses the public {@code utilities().status()} endpoint, so it runs without a token. + * + *

Run: {@code ./gradlew runResponseFormats} + */ +public final class ResponseFormatsExample { + + private ResponseFormatsExample() {} + + public static void main(String[] args) throws Exception { + try (MarketDataClient client = new MarketDataClient(null, null, null, false)) { + + var response = client.utilities().status(); + + // The typed payload — the part you usually want. + System.out.println("Typed data: " + response.values().size() + " services"); + + // Request metadata — useful for logging and for support tickets. + System.out.println("\nMetadata:"); + System.out.println(" statusCode = " + response.statusCode()); + System.out.println(" requestId = " + response.requestId()); + System.out.println(" requestUrl = " + response.requestUrl()); + + // Format predicates — mutually exclusive. Utility endpoints are JSON; other resources can + // return CSV via asCsv(). + System.out.println("\nFormat: isJson=" + response.isJson() + + " isCsv=" + response.isCsv() + " isHtml=" + response.isHtml()); + + // The per-request rate limit, parsed from this response's headers (null if not present). + if (response.rateLimit() != null) { + System.out.println("Rate limit: " + response.rateLimit().remaining() + + "/" + response.rateLimit().limit() + " remaining"); + } + + // The raw response body, exactly as the server sent it. + System.out.println("\nRaw body (first 80 chars): " + + response.json().substring(0, Math.min(80, response.json().length())) + "..."); + + // saveToFile writes that raw body verbatim — handy for caching or debugging. + Path tmp = Files.createTempFile("market-data-", ".json"); + response.saveToFile(tmp); + System.out.println("Saved raw response to " + tmp); + Files.deleteIfExists(tmp); + + } catch (Exception e) { + System.out.println("Call failed: " + e.getClass().getSimpleName() + " — " + e.getMessage()); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/common/RetryAndBackoffExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/common/RetryAndBackoffExample.java new file mode 100644 index 0000000..d142e78 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/common/RetryAndBackoffExample.java @@ -0,0 +1,70 @@ +package com.marketdata.examples.common; + +import com.marketdata.examples.util.MockServer; +import com.marketdata.examples.util.MockServer.Step; +import com.marketdata.sdk.MarketDataClient; +import java.util.List; + +/** + * What the SDK does when the API has a transient hiccup: it retries automatically. + * + *

Server errors (HTTP 503 and other 5xx) and network errors are retried with exponential backoff + * (≈1s, then 2s, then 4s) up to 3 times before the SDK gives up and throws. A {@code Retry-After} + * header on the response overrides that schedule. You don't write any of this — it just + * happens around every call. This example makes it visible with a scripted local server. + * + *

Start the mock first: {@code cd ../mock-server && ./run.sh}. + * + *

Run: {@code ./gradlew runRetry} + */ +public final class RetryAndBackoffExample { + + private RetryAndBackoffExample() {} + + public static void main(String[] args) { + MockServer mock = new MockServer(); + mock.requireUp(); + + try (var client = new MarketDataClient("token", MockServer.BASE_URL, null, false)) { + retryRecovers(mock, client); + retryAfterHonored(mock, client); + } + } + + /** Two 503s then a 200: the call recovers on its own, after the backoff waits. */ + private static void retryRecovers(MockServer mock, MarketDataClient client) { + System.out.println("=== Transient 503s, then success ==="); + mock.script(List.of( + Step.of(503, "{\"s\":\"error\",\"errmsg\":\"temporarily down\"}"), + Step.of(503, "{\"s\":\"error\",\"errmsg\":\"temporarily down\"}"), + Step.of(200, statusBody()))); + + long start = System.nanoTime(); + var resp = client.utilities().status(); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + System.out.println("Call succeeded with " + resp.values().size() + " services."); + System.out.println("It took ~" + elapsedMs + " ms — the SDK silently retried twice (≈1s + 2s " + + "backoff) before the third attempt returned 200.\n"); + } + + /** A 503 carrying Retry-After: 3 — the SDK waits exactly that long instead of its own schedule. */ + private static void retryAfterHonored(MockServer mock, MarketDataClient client) { + System.out.println("=== Server says Retry-After: 3 ==="); + mock.script(List.of( + Step.of(503, "{\"s\":\"error\",\"errmsg\":\"slow down\"}").header("Retry-After", "3"), + Step.of(200, statusBody()))); + + long start = System.nanoTime(); + client.utilities().status(); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + System.out.println("Call succeeded after ~" + elapsedMs + " ms (≈3000) — the SDK honored the " + + "server's Retry-After hint instead of its default 1s backoff."); + } + + private static String statusBody() { + return "{\"s\":\"ok\",\"service\":[\"/v1/markets/status/\"],\"status\":[\"online\"]," + + "\"online\":[true],\"uptimePct30d\":[1.0],\"uptimePct90d\":[1.0],\"updated\":[1705449600]}"; + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/common/SyncVsAsyncExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/common/SyncVsAsyncExample.java new file mode 100644 index 0000000..182b654 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/common/SyncVsAsyncExample.java @@ -0,0 +1,52 @@ +package com.marketdata.examples.common; + +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.UtilitiesStatusResponse; +import java.util.concurrent.CompletableFuture; + +/** + * Every endpoint comes in two flavours: a blocking call ({@code status()}) and an async one + * ({@code statusAsync()}) returning a {@link CompletableFuture}. They share all the same validation, + * retry and rate-limit logic — pick whichever fits your code. + * + *

Uses the public {@code utilities().status()} endpoint, so it runs without a token. + * + *

Run: {@code ./gradlew runSyncVsAsync} + */ +public final class SyncVsAsyncExample { + + private SyncVsAsyncExample() {} + + public static void main(String[] args) { + try (MarketDataClient client = new MarketDataClient(null, null, null, false)) { + + // Sync: the call blocks until the response is ready, then returns it. + var sync = client.utilities().status(); + System.out.println("Sync: " + sync.values().size() + " services online"); + + // Async: the call returns immediately with a future. Attach a callback, or join() to block. + // Same return type as the sync call once it completes. + CompletableFuture future = client.utilities().statusAsync(); + future.thenAccept(resp -> + System.out.println("Async: " + resp.values().size() + " services online (callback)")); + future.join(); // wait so the example doesn't exit before the callback runs + + // Where async pays off: fire several calls at once and wait for all of them. Total time is + // about the slowest single call, not the sum — they overlap on the network. (Here it's three + // status calls so the example needs no token; in a real app you'd fan out different symbols or + // endpoints the same way.) + System.out.println("\nFanning out 3 calls in parallel:"); + long start = System.nanoTime(); + CompletableFuture a = client.utilities().statusAsync(); + CompletableFuture b = client.utilities().statusAsync(); + CompletableFuture c = client.utilities().statusAsync(); + + CompletableFuture.allOf(a, b, c).join(); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + System.out.println("All 3 done in " + elapsedMs + " ms (≈ one round-trip, not three)."); + + } catch (Exception e) { + System.out.println("Call failed: " + e.getClass().getSimpleName() + " — " + e.getMessage()); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/FundsAdvancedExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/FundsAdvancedExample.java new file mode 100644 index 0000000..73e763d --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/FundsAdvancedExample.java @@ -0,0 +1,60 @@ +package com.marketdata.examples.resources; + +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.funds.FundCandle; +import com.marketdata.sdk.funds.FundCandlesRequest; +import com.marketdata.sdk.funds.FundResolution; +import java.time.LocalDate; + +/** + * Less common {@code funds} features, beyond {@code FundsExample}: the different date windows, CSV + * output and column projection. Runs against the live API — set {@code MARKETDATA_TOKEN} first. + * + *

Run: {@code ./gradlew runFundsAdvanced} + */ +public final class FundsAdvancedExample { + + private FundsAdvancedExample() {} + + public static void main(String[] args) { + try (MarketDataClient client = new MarketDataClient()) { + + // --- Single trading day --- + // date(...) is mutually exclusive with from/to/countback: ask for one specific session. + var oneDay = client.funds().candles( + FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") + .date(LocalDate.now().minusDays(7)) + .build()); + System.out.println("Single day: " + oneDay.values().size() + " candle(s)"); + + // --- Last N candles (countback), weekly resolution --- + var weekly = client.funds().candles( + FundCandlesRequest.builder(FundResolution.WEEKLY, "VFINX") + .to(LocalDate.now()) + .countback(8) + .build()); + System.out.println("Last 8 weekly NAV candles:"); + for (FundCandle bar : weekly.values()) { + System.out.println(" " + bar.time() + " close=" + bar.close()); + } + + // --- CSV output + column projection --- + // Project to just the date and close columns; human(true) gives readable header names, and + // dateFormat(TIMESTAMP) renders the date column as readable timestamps instead of unix epochs. + var csv = client.funds().asCsv() + .dateFormat(com.marketdata.sdk.DateFormat.TIMESTAMP) + .columns("t", "c") + .human(true) + .headers(true) + .candles(FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") + .to(LocalDate.now()) + .countback(5) + .build()); + System.out.println("\nCSV output:\n" + csv.csv()); + + } catch (AuthenticationError e) { + System.out.println("Set MARKETDATA_TOKEN (env var or .env) to run this example."); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/FundsExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/FundsExample.java new file mode 100644 index 0000000..1340f43 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/FundsExample.java @@ -0,0 +1,45 @@ +package com.marketdata.examples.resources; + +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.funds.FundCandle; +import com.marketdata.sdk.funds.FundCandlesRequest; +import com.marketdata.sdk.funds.FundResolution; +import java.time.LocalDate; + +/** + * The {@code funds} resource exposes a single endpoint: {@code candles} — a mutual fund's NAV + * series. Note there is no volume column (funds report NAV, not traded volume) and no intraday + * resolutions ({@link FundResolution} only models daily and coarser). + * + *

Set {@code MARKETDATA_TOKEN} (env var or {@code .env}) before running. For the different date + * windows (single day, {@code to}+{@code countback}), CSV output and column projection, see + * {@code FundsAdvancedExample}. + * + *

Run: {@code ./gradlew runFunds} + */ +public final class FundsExample { + + private FundsExample() {} + + public static void main(String[] args) { + try (MarketDataClient client = new MarketDataClient()) { + + // candles — daily NAV OHLC over a date range. Same shape as stocks/funds candles, minus volume. + var candles = client.funds().candles( + FundCandlesRequest.builder(FundResolution.DAILY, "VFINX") + .from(LocalDate.now().minusWeeks(2)) + .to(LocalDate.now()) + .build()); + + System.out.println("Daily NAV candles for VFINX (last two weeks):"); + for (FundCandle bar : candles.values()) { + System.out.printf(" %s O=%.2f H=%.2f L=%.2f C=%.2f%n", + bar.time(), bar.open(), bar.high(), bar.low(), bar.close()); + } + + } catch (AuthenticationError e) { + System.out.println("Set MARKETDATA_TOKEN (env var or .env) to run this example against the live API."); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/MarketsAdvancedExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/MarketsAdvancedExample.java new file mode 100644 index 0000000..5cd7389 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/MarketsAdvancedExample.java @@ -0,0 +1,56 @@ +package com.marketdata.examples.resources; + +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.markets.MarketStatus; +import com.marketdata.sdk.markets.MarketStatusRequest; +import java.time.LocalDate; + +/** + * Less common {@code markets} features, beyond {@code MarketsExample}: the single-day and countback + * windows, country selection, CSV output and column projection. Runs against the live API — + * set {@code MARKETDATA_TOKEN} first. + * + *

Run: {@code ./gradlew runMarketsAdvanced} + */ +public final class MarketsAdvancedExample { + + private MarketsAdvancedExample() {} + + public static void main(String[] args) { + try (MarketDataClient client = new MarketDataClient()) { + + // --- Was the market open on a specific day? --- + var oneDay = client.markets().status( + MarketStatusRequest.builder().date(LocalDate.now().minusDays(3)).build()); + MarketStatus day = oneDay.values().get(0); + System.out.println(day.date().toLocalDate() + " was " + (day.isOpen() ? "open" : "closed")); + + // --- Last N days (countback) for a specific country's calendar --- + var counted = client.markets().status( + MarketStatusRequest.builder() + .country("US") + .to(LocalDate.now()) + .countback(10) + .build()); + long open = counted.values().stream().filter(MarketStatus::isOpen).count(); + System.out.println("Last 10 calendar days: " + open + " open of " + counted.values().size()); + + // --- CSV output + column projection --- + // dateFormat(TIMESTAMP) renders the date column as readable dates instead of unix epochs. + var csv = client.markets().asCsv() + .dateFormat(com.marketdata.sdk.DateFormat.TIMESTAMP) + .columns("date", "status") + .human(true) + .headers(true) + .status(MarketStatusRequest.builder() + .from(LocalDate.now().minusDays(7)) + .to(LocalDate.now()) + .build()); + System.out.println("\nCSV output:\n" + csv.csv()); + + } catch (AuthenticationError e) { + System.out.println("Set MARKETDATA_TOKEN (env var or .env) to run this example."); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/MarketsExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/MarketsExample.java new file mode 100644 index 0000000..d48f6d0 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/MarketsExample.java @@ -0,0 +1,43 @@ +package com.marketdata.examples.resources; + +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.markets.MarketStatus; +import com.marketdata.sdk.markets.MarketStatusRequest; +import java.time.LocalDate; + +/** + * The {@code markets} resource exposes a single endpoint: {@code status} — the exchange + * open/closed calendar. (This is distinct from {@code utilities().status()}, which reports the + * Market Data API's own service health.) Every parameter is optional. + * + *

Set {@code MARKETDATA_TOKEN} (env var or {@code .env}) before running. For the other date + * windows, country selection, CSV output and column projection, see {@code MarketsAdvancedExample}. + * + *

Run: {@code ./gradlew runMarkets} + */ +public final class MarketsExample { + + private MarketsExample() {} + + public static void main(String[] args) { + try (MarketDataClient client = new MarketDataClient()) { + + // status — was the market open on each day in a range? A bare request would return today. + var status = client.markets().status( + MarketStatusRequest.builder() + .from(LocalDate.now().minusDays(7)) + .to(LocalDate.now()) + .build()); + + System.out.println("Market open/closed over the last week:"); + for (MarketStatus day : status.values()) { + System.out.println(" " + day.date().toLocalDate() + " " + day.status() + + (day.isOpen() ? " (trading)" : "")); + } + + } catch (AuthenticationError e) { + System.out.println("Set MARKETDATA_TOKEN (env var or .env) to run this example against the live API."); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java new file mode 100644 index 0000000..c4f0977 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsAdvancedExample.java @@ -0,0 +1,80 @@ +package com.marketdata.examples.resources; + +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.options.ExpirationFilter; +import com.marketdata.sdk.options.OptionQuote; +import com.marketdata.sdk.options.OptionSide; +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsQuotesRequest; +import com.marketdata.sdk.options.StrikeFilter; +import com.marketdata.sdk.options.StrikeRange; +import java.util.List; + +/** + * Less common {@code options} features, beyond {@code OptionsExample}: the rich chain filter surface + * (sealed expiration/strike groups), multi-contract fan-out, CSV output and column projection. Runs + * against the live API — set {@code MARKETDATA_TOKEN} with options entitlements first. + * + *

Run: {@code ./gradlew runOptionsAdvanced} + */ +public final class OptionsAdvancedExample { + + private OptionsAdvancedExample() {} + + public static void main(String[] args) { + try (MarketDataClient client = new MarketDataClient()) { + + // --- Sealed filter groups --- + // The chain has two mutually-exclusive filter axes, modeled as sealed types so the compiler + // lets you pick exactly one variant of each: an ExpirationFilter (here: within 45 days to + // expiry) and a StrikeFilter (here: strikes between 150 and 250). + List chain = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.dte(45)) + .strikeFilter(StrikeFilter.range(150, 250)) + .side(OptionSide.CALL) + .strikeRange(StrikeRange.ITM) + .strikeLimit(5) + .build()) + .values(); + System.out.println("Filtered chain: " + chain.size() + " contracts"); + + // ExpirationFilter.all() spans every expiration at once — distinct from omitting the filter, + // which the API narrows to the front month. + long spans = client.options().chain( + OptionsChainRequest.builder("AAPL") + .expirationFilter(ExpirationFilter.all()) + .side(OptionSide.CALL) + .strikeLimit(1) + .build()) + .values().stream().map(OptionQuote::expiration).distinct().count(); + System.out.println("ExpirationFilter.all() spans " + spans + " distinct expirations"); + + if (chain.size() < 2) { + System.out.println("Not enough contracts to demo the rest — try a more liquid underlying."); + return; + } + String s1 = chain.get(0).optionSymbol(); + String s2 = chain.get(1).optionSymbol(); + + // --- Multi-contract fan-out --- + // quotes(...) takes several OCC symbols and fires one request per symbol concurrently, so the + // result is a Map keyed by symbol (unlike stocks.quotes, which batches into one request). + var quotes = client.options().quotes(OptionsQuotesRequest.builder(s1, s2).build()); + quotes.forEach((sym, resp) -> + System.out.println(" " + sym + " → " + resp.values().size() + " row(s)")); + + // --- CSV output + column projection --- + var csv = client.options().asCsv() + .columns("optionSymbol", "bid", "ask") + .human(true) + .headers(true) + .chain(OptionsChainRequest.builder("AAPL").side(OptionSide.CALL).strikeLimit(3).build()); + System.out.println("\nCSV output:\n" + csv.csv()); + + } catch (AuthenticationError e) { + System.out.println("Set MARKETDATA_TOKEN with options entitlements to run this example."); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsExample.java new file mode 100644 index 0000000..69d4a52 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/OptionsExample.java @@ -0,0 +1,65 @@ +package com.marketdata.examples.resources; + +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.options.OptionQuote; +import com.marketdata.sdk.options.OptionSide; +import com.marketdata.sdk.options.OptionsChainRequest; +import com.marketdata.sdk.options.OptionsExpirationsRequest; +import com.marketdata.sdk.options.OptionsLookupRequest; +import com.marketdata.sdk.options.OptionsQuoteRequest; +import java.util.List; + +/** + * The first calls you'd write against the {@code options} resource: resolve a symbol, list + * expirations, pull a filtered chain, and quote a single contract. Options data needs an entitled + * token; set {@code MARKETDATA_TOKEN} (env var or {@code .env}) before running. + * + *

For the full chain filter surface (DTE/strike/liquidity filters), multi-contract fan-out, CSV + * output and column projection, see {@code OptionsAdvancedExample}. + * + *

Run: {@code ./gradlew runOptions} + */ +public final class OptionsExample { + + private OptionsExample() {} + + public static void main(String[] args) { + try (MarketDataClient client = new MarketDataClient()) { + + // lookup — turn a human description into a well-formed OCC option symbol. + String occSymbol = client.options() + .lookup(OptionsLookupRequest.of("AAPL 1/16/2026 $200 Call")) + .values(); + System.out.println("Lookup resolved to: " + occSymbol); + + // expirations — the expiration calendar for an underlying. + var expirations = client.options().expirations(OptionsExpirationsRequest.of("AAPL")); + System.out.println("AAPL has " + expirations.values().size() + " expiration dates"); + + // chain — the option chain, with a couple of common filters: calls only, the 5 nearest the + // money. The chain is where most option workflows start. + List chain = client.options().chain( + OptionsChainRequest.builder("AAPL") + .side(OptionSide.CALL) + .strikeLimit(5) + .build()) + .values(); + System.out.println("\nChain (5 calls nearest the money):"); + for (OptionQuote c : chain) { + System.out.println(" " + c.optionSymbol() + + " strike=" + c.strike() + " bid/ask=" + c.bid() + "/" + c.ask() + " delta=" + c.delta()); + } + + // quote — a single contract. Use a real symbol pulled from the chain above. + if (!chain.isEmpty()) { + String symbol = chain.get(0).optionSymbol(); + OptionQuote q = client.options().quote(OptionsQuoteRequest.of(symbol)).values().get(0); + System.out.println("\nQuote " + q.optionSymbol() + ": last=" + q.last() + " iv=" + q.iv()); + } + + } catch (AuthenticationError e) { + System.out.println("Set MARKETDATA_TOKEN with options entitlements to run this example."); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/StocksAdvancedExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/StocksAdvancedExample.java new file mode 100644 index 0000000..0e2d829 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/StocksAdvancedExample.java @@ -0,0 +1,84 @@ +package com.marketdata.examples.resources; + +import com.marketdata.sdk.DateFormat; +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.Mode; +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.stocks.StockCandlesRequest; +import com.marketdata.sdk.stocks.StockQuote; +import com.marketdata.sdk.stocks.StockQuoteRequest; +import com.marketdata.sdk.stocks.StockQuotesRequest; +import com.marketdata.sdk.stocks.StockResolution; +import java.time.LocalDate; + +/** + * Less common {@code stocks} features, beyond {@code StocksExample}: universal parameters, the + * different candle windows, CSV output, column projection, and the per-response rate limit. Runs + * against the live API — set {@code MARKETDATA_TOKEN} first. + * + *

Run: {@code ./gradlew runStocksAdvanced} + */ +public final class StocksAdvancedExample { + + private StocksAdvancedExample() {} + + public static void main(String[] args) { + try (MarketDataClient client = new MarketDataClient()) { + + // --- Universal parameters --- + // dateFormat / mode / limit / offset apply to any endpoint. They're set on the resource + // (client.stocks()) before the call. dateFormat is type-preserving (it changes how dates are + // sent on the wire, not the Java type you get back); mode selects live vs delayed data. (For + // candles the result size comes from the from/to/countback window, not from limit.) + var candles = client.stocks() + .dateFormat(DateFormat.TIMESTAMP) + .mode(Mode.DELAYED) + .candles(StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") + .from(LocalDate.now().minusMonths(1)) + .to(LocalDate.now()) + .build()); + System.out.println("Candles with universal params applied: " + candles.values().size()); + + // --- Candle window by countback --- + // Instead of from/to, ask for the last N candles ending at a date — no left edge needed. + var lastTen = client.stocks().candles( + StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") + .to(LocalDate.now()) + .countback(10) + .build()); + System.out.println("Last 10 sessions (countback): " + lastTen.values().size() + " candles"); + + // --- Column projection --- + // Ask the API for only the columns you need. Fields you didn't request come back null — no + // error. Lighter payloads when you only want a couple of fields. + StockQuote projected = client.stocks() + .columns("symbol", "last") + .quote(StockQuoteRequest.of("AAPL")) + .values().get(0); + System.out.println("Projected quote: symbol=" + projected.symbol() + + " last=" + projected.last() + " (bid not requested → " + projected.bid() + ")"); + + // --- CSV output --- + // asCsv() switches the whole resource to CSV. The response exposes the raw CSV text. The + // CSV-only shaping params (human-readable headers, header row, column order) live here too. + var csv = client.stocks().asCsv() + .columns("symbol", "last") + .human(true) + .headers(true) + .quotes(StockQuotesRequest.builder("AAPL", "MSFT").build()); + System.out.println("\nCSV output:\n" + csv.csv()); + + // --- Per-response rate limit --- + // Every response carries the rate-limit snapshot from its own headers (request-scoped), + // distinct from client.getRateLimits() which is the client-wide latest. + var quote = client.stocks().quote(StockQuoteRequest.of("AAPL")); + if (quote.rateLimit() != null) { + System.out.println("\nThis request's rate limit: " + + quote.rateLimit().remaining() + "/" + quote.rateLimit().limit() + " remaining"); + } + + } catch (AuthenticationError e) { + System.out.println("Set MARKETDATA_TOKEN (env var or .env) to run this example."); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/StocksExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/StocksExample.java new file mode 100644 index 0000000..fa5ab97 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/StocksExample.java @@ -0,0 +1,61 @@ +package com.marketdata.examples.resources; + +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.stocks.StockCandle; +import com.marketdata.sdk.stocks.StockCandlesRequest; +import com.marketdata.sdk.stocks.StockQuote; +import com.marketdata.sdk.stocks.StockQuoteRequest; +import com.marketdata.sdk.stocks.StockQuotesRequest; +import com.marketdata.sdk.stocks.StockResolution; +import java.time.LocalDate; + +/** + * The first calls you'd write against the {@code stocks} resource: a candle series, a single quote, + * and a multi-symbol batch. Runs against the live API, so set {@code MARKETDATA_TOKEN} in your + * environment (or a {@code .env} file in this directory) before running. + * + *

For less common parameters (column projection, CSV/HTML output, candle windows by countback) + * see {@code StocksAdvancedExample}. + * + *

Run: {@code ./gradlew runStocks} + */ +public final class StocksExample { + + private StocksExample() {} + + public static void main(String[] args) { + // The no-arg constructor reads your token from the environment (or .env), then validates it. + try (MarketDataClient client = new MarketDataClient()) { + + // candles — historical OHLCV. The resolution is a value type (DAILY, hours(1), minutes(15)); + // the window is a from/to date range. + var candles = client.stocks().candles( + StockCandlesRequest.builder(StockResolution.DAILY, "AAPL") + .from(LocalDate.now().minusWeeks(1)) + .to(LocalDate.now()) + .build()); + System.out.println("Daily candles for AAPL (last week):"); + for (StockCandle bar : candles.values()) { + System.out.printf(" %s O=%.2f H=%.2f L=%.2f C=%.2f V=%d%n", + bar.time(), bar.open(), bar.high(), bar.low(), bar.close(), bar.volume()); + } + + // quote — the latest quote for one symbol. quote(...) returns a list; a single symbol is row 0. + StockQuote q = client.stocks().quote(StockQuoteRequest.of("AAPL")).values().get(0); + System.out.printf("%nQuote: %s last=%.2f bid/ask=%.2f/%.2f%n", + q.symbol(), q.last(), q.bid(), q.ask()); + + // quotes — several symbols in ONE request. The stocks backend batches a comma list, so the + // result is a single response with one row per symbol. + var quotes = client.stocks().quotes(StockQuotesRequest.builder("AAPL", "MSFT", "GOOGL").build()); + System.out.println("\nBatch quotes (one request):"); + for (StockQuote row : quotes.values()) { + System.out.printf(" %-6s last=%.2f%n", row.symbol(), row.last()); + } + + } catch (AuthenticationError e) { + System.out.println("Set MARKETDATA_TOKEN (env var or .env) to run this example against the live API."); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/resources/UtilitiesExample.java b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/UtilitiesExample.java new file mode 100644 index 0000000..510b227 --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/resources/UtilitiesExample.java @@ -0,0 +1,41 @@ +package com.marketdata.examples.resources; + +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.exception.AuthenticationError; +import com.marketdata.sdk.utilities.ServiceStatus; +import com.marketdata.sdk.utilities.User; + +/** + * The {@code utilities} resource: API health, your account quota, and a request-echo for debugging. + * + *

{@code status()} is public (no token); {@code user()} and {@code headers()} need a token. Set + * {@code MARKETDATA_TOKEN} in your environment (or a {@code .env} file here) to exercise all three. + * + *

Run: {@code ./gradlew runUtilities} + */ +public final class UtilitiesExample { + + private UtilitiesExample() {} + + public static void main(String[] args) { + try (MarketDataClient client = new MarketDataClient()) { + + // status — per-service health. Public, so it works without a token; handy as a liveness check. + var status = client.utilities().status(); + long online = status.values().stream().filter(ServiceStatus::online).count(); + System.out.println("API health: " + online + " of " + status.values().size() + " services online"); + + // user — your account: how much of your quota is left. Needs a token. + User me = client.utilities().user().values(); + System.out.println("Quota: " + me.requestsRemaining() + " of " + me.requestsLimit() + " requests left today"); + + // headers — echoes back the headers the server received. Useful to confirm your auth header + // actually reached the API. + var headers = client.utilities().headers().values(); + System.out.println("Server saw " + headers.size() + " request headers (Authorization echoed back redacted)"); + + } catch (AuthenticationError e) { + System.out.println("Set MARKETDATA_TOKEN (env var or .env) to call user() and headers()."); + } + } +} diff --git a/examples/consumer-test/src/main/java/com/marketdata/examples/util/MockServer.java b/examples/consumer-test/src/main/java/com/marketdata/examples/util/MockServer.java new file mode 100644 index 0000000..158962b --- /dev/null +++ b/examples/consumer-test/src/main/java/com/marketdata/examples/util/MockServer.java @@ -0,0 +1,202 @@ +package com.marketdata.examples.util; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * A tiny teaching aid for the cross-cutting examples (concurrency, retry, error handling). + * + *

Some SDK behaviors are invisible against the live API: you can't see the 50-permit + * concurrency cap, force a deterministic 503→503→200 retry sequence, or reproduce each + * error code on demand. This class points the SDK at a local mock server (FastAPI, under + * {@code ../mock-server/}) whose responses you script up front, so those behaviors become + * observable. + * + *

It is not part of the SDK and a consumer never needs it — it exists only + * so these examples are runnable without a paid token or live market conditions. Resource examples + * ({@code StocksExample}, etc.) talk to the real API instead. + */ +public final class MockServer { + + /** Where {@code ../mock-server/run.sh} listens. Point the client's base URL here. */ + public static final String BASE_URL = "http://127.0.0.1:8765"; + + // Force HTTP/1.1: uvicorn's HTTP/2 upgrade can drop POST bodies during negotiation. Only this + // admin client downgrades; the SDK under test keeps its HTTP/2 default. + private final HttpClient http = + HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + /** + * Fail fast with a clear hint if the mock server isn't running. Call this first; without the + * server these examples can't demonstrate anything. + */ + public void requireUp() { + try { + HttpResponse resp = send("GET", "/_admin/stats", null); + if (resp.statusCode() != 200) { + throw new IllegalStateException("mock server replied HTTP " + resp.statusCode()); + } + } catch (Exception e) { + throw new IllegalStateException( + "Mock server not reachable at " + + BASE_URL + + ". Start it in another terminal: cd ../mock-server && ./run.sh", + e); + } + } + + /** Queue the responses the server will hand back, in order, one per incoming request. */ + public void script(List steps) { + StringBuilder json = new StringBuilder("{\"steps\":["); + for (int i = 0; i < steps.size(); i++) { + if (i > 0) { + json.append(','); + } + json.append(steps.get(i).toJson()); + } + json.append("]}"); + sendOrThrow("POST", "/_admin/script", json.toString()); + } + + /** Convenience for scripting a single response. */ + public void script(Step step) { + script(List.of(step)); + } + + /** The same {@code body} repeated {@code count} times — handy for fan-out demos. */ + public void scriptRepeated(int count, Step step) { + List steps = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + steps.add(step); + } + script(steps); + } + + /** + * The maximum number of requests the server saw in flight at the same moment. Lets the + * concurrency example observe the SDK's 50-permit cap instead of asserting it. + */ + public int peakInFlight() { + String body = sendOrThrow("GET", "/_admin/stats", null).body(); + return intField(body, "\"peak_in_flight\":"); + } + + // ---------- internals ---------- + + private HttpResponse send(String method, String path, String body) throws Exception { + HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE_URL + path)) + .timeout(Duration.ofSeconds(2)); + if (body == null) { + b.method(method, HttpRequest.BodyPublishers.noBody()); + } else { + b.header("Content-Type", "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(body)); + } + return http.send(b.build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse sendOrThrow(String method, String path, String body) { + try { + HttpResponse resp = send(method, path, body); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + throw new RuntimeException(method + " " + path + " → HTTP " + resp.statusCode()); + } + return resp; + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(method + " " + path + " failed", e); + } + } + + private static int intField(String json, String marker) { + int idx = json.indexOf(marker); + if (idx < 0) { + return -1; + } + int start = idx + marker.length(); + while (start < json.length() && Character.isWhitespace(json.charAt(start))) { + start++; + } + int end = start; + while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) { + end++; + } + return Integer.parseInt(json.substring(start, end)); + } + + /** One scripted response: an HTTP status, a JSON body, an optional header, an optional delay. */ + public static final class Step { + private final int status; + private final String body; + private int delayMs = 0; + private final List headers = new ArrayList<>(); + + private Step(int status, String body) { + this.status = status; + this.body = body; + } + + public static Step of(int status, String body) { + return new Step(status, body); + } + + public Step header(String name, String value) { + headers.add(new String[] {name, value}); + return this; + } + + /** Hold the response for {@code ms} before replying — lets fan-out demos overlap. */ + public Step delayMs(int ms) { + this.delayMs = ms; + return this; + } + + String toJson() { + StringBuilder sb = new StringBuilder("{\"status\":").append(status); + sb.append(",\"body\":").append(jsonString(body)); + sb.append(",\"delay_ms\":").append(delayMs); + if (!headers.isEmpty()) { + sb.append(",\"headers\":{"); + for (int i = 0; i < headers.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(jsonString(headers.get(i)[0])).append(':').append(jsonString(headers.get(i)[1])); + } + sb.append('}'); + } + return sb.append('}').toString(); + } + + private static String jsonString(String s) { + StringBuilder sb = new StringBuilder("\""); + for (char c : s.toCharArray()) { + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + return sb.append('"').toString(); + } + } + +} diff --git a/examples/consumer-test/src/main/kotlin/com/marketdata/examples/Quickstart.kt b/examples/consumer-test/src/main/kotlin/com/marketdata/examples/Quickstart.kt new file mode 100644 index 0000000..c07e15e --- /dev/null +++ b/examples/consumer-test/src/main/kotlin/com/marketdata/examples/Quickstart.kt @@ -0,0 +1,34 @@ +package com.marketdata.examples + +import com.marketdata.sdk.MarketDataClient +import com.marketdata.sdk.exception.AuthenticationError +import com.marketdata.sdk.stocks.StockQuoteRequest + +/** + * The same SDK, from Kotlin. The Java API is designed to be idiomatic from Kotlin: the client is an + * `AutoCloseable` (so `use {}` works), nullability is honored (no platform types), and the async + * variants return `CompletableFuture`, which Kotlin interops with directly. + * + * Set `MARKETDATA_TOKEN` (env var or `.env`) before running. + * + * Run: `./gradlew runKotlinQuickstart` + */ +fun main() { + // `use {}` closes the client when the block ends — the Kotlin equivalent of try-with-resources. + MarketDataClient().use { client -> + try { + // Sync: blocks and returns the typed response. + val quote = client.stocks().quote(StockQuoteRequest.of("AAPL")).values()[0] + println("Sync: ${quote.symbol()} last=${quote.last()}") + + // Async: returns a CompletableFuture. Join it, or attach a callback. (Kotlin consumers + // using coroutines can `await()` it via kotlinx-coroutines-jdk8.) + client.stocks() + .quoteAsync(StockQuoteRequest.of("AAPL")) + .thenAccept { resp -> println("Async: ${resp.values()[0].last()}") } + .join() + } catch (e: AuthenticationError) { + println("Set MARKETDATA_TOKEN (env var or .env) to run this example.") + } + } +} From 5c866781bc984cd1287374c30298d580e5b2aa53 Mon Sep 17 00:00:00 2001 From: Lucas Giordano Date: Thu, 25 Jun 2026 10:24:55 -0300 Subject: [PATCH 2/2] fix kt example --- .../kotlin/com/marketdata/examples/Quickstart.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/consumer-test/src/main/kotlin/com/marketdata/examples/Quickstart.kt b/examples/consumer-test/src/main/kotlin/com/marketdata/examples/Quickstart.kt index c07e15e..7a2f2f6 100644 --- a/examples/consumer-test/src/main/kotlin/com/marketdata/examples/Quickstart.kt +++ b/examples/consumer-test/src/main/kotlin/com/marketdata/examples/Quickstart.kt @@ -14,9 +14,11 @@ import com.marketdata.sdk.stocks.StockQuoteRequest * Run: `./gradlew runKotlinQuickstart` */ fun main() { - // `use {}` closes the client when the block ends — the Kotlin equivalent of try-with-resources. - MarketDataClient().use { client -> - try { + try { + // `use {}` closes the client when the block ends — the Kotlin equivalent of try-with-resources. + // Construction is inside the `try` so the no-arg constructor's startup validation (a real + // `/user/` probe) is covered by the catch below on a missing/expired token. + MarketDataClient().use { client -> // Sync: blocks and returns the typed response. val quote = client.stocks().quote(StockQuoteRequest.of("AAPL")).values()[0] println("Sync: ${quote.symbol()} last=${quote.last()}") @@ -27,8 +29,8 @@ fun main() { .quoteAsync(StockQuoteRequest.of("AAPL")) .thenAccept { resp -> println("Async: ${resp.values()[0].last()}") } .join() - } catch (e: AuthenticationError) { - println("Set MARKETDATA_TOKEN (env var or .env) to run this example.") } + } catch (e: AuthenticationError) { + println("Set MARKETDATA_TOKEN (env var or .env) to run this example.") } }