Ozakboy.TradeKit.Shioaji
0.3.2
dotnet add package Ozakboy.TradeKit.Shioaji --version 0.3.2
NuGet\Install-Package Ozakboy.TradeKit.Shioaji -Version 0.3.2
<PackageReference Include="Ozakboy.TradeKit.Shioaji" Version="0.3.2" />
<PackageVersion Include="Ozakboy.TradeKit.Shioaji" Version="0.3.2" />
<PackageReference Include="Ozakboy.TradeKit.Shioaji" />
paket add Ozakboy.TradeKit.Shioaji --version 0.3.2
#r "nuget: Ozakboy.TradeKit.Shioaji, 0.3.2"
#:package Ozakboy.TradeKit.Shioaji@0.3.2
#addin nuget:?package=Ozakboy.TradeKit.Shioaji&version=0.3.2
#tool nuget:?package=Ozakboy.TradeKit.Shioaji&version=0.3.2
Ozakboy.TradeKit.Shioaji
A .NET client for the official local HTTP server of SinoPac's Shioaji, with no third-party packages.
Shioaji is SinoPac Securities' trading and market-data API. The vendor ships it as a local process: shioaji server start logs in to the broker, loads the certificate, keeps the token and connections alive, and exposes HTTP and SSE
on 127.0.0.1:8080. This package handles what is outside that process: starting it and watching it, speaking
HTTP to it, reading its SSE stream, turning its JSON into typed C# objects, and refusing excess calls with a local
budget before they ever reach the broker.
It is built on the Ozakboy.Http pipeline and the
Ozakboy.Security masker. The policy of this package family is to use
only the .NET BCL, first-party Microsoft packages and Ozakboy.*, judged by the transitive graph rather than by
package name; every dependency here complies, see "Dependency policy" at the end.
Current status: login, accounts, the order-report subscription (stocks and futures), the contract list and details, 1-minute bars, whole-day ticks and the usage query were run with valid credentials against SinoPac's simulation environment (results in "Known server behaviour"). The market-data event fields, the order and order-report response shapes, and the other history endpoints still follow the documentation and are listed in "Limits and unmeasured behaviour"; the path and the response shape of the all-market daily OHLCV in particular are unverified. The portfolio queries added in 0.3.0 (positions, realized profit and loss, settlements, trading limits, futures margin, bank balance) have so far been checked only against the local fake server, not against a real
shioaji server, and their response shapes are inferred from the documentation's field lists. Read that section before going live.
What this release does
| Scope | Status |
|---|---|
Start and supervise the official server process (ShioajiServerSupervisor), credentials only through child-process environment variables |
Done |
| Attach to a server started elsewhere, refusing when its mode disagrees | Done |
| Backoff restarts after an unexpected exit, Faulted after a limit | Done |
/api/v1/info, accounts, usage, stream status, subscribe_trade / unsubscribe_trade |
Done |
| Contracts: one contract, details (with day-trading eligibility), a paged list, the futures list | Done |
ListAllContractsAsync: walks every page of a category at 500 per page, each page through the MarketQuery budget (0.2.0) |
Done |
Historical data (ShioajiHistoryClient, 0.2.0): 1-minute bars (30-day range checked locally), ticks, snapshots (500-contract check), scanner, short-stock sources, credit enquiry, disposition and attention lists |
Done; the response shapes of everything but bars and ticks are unverified |
All-market daily OHLCV (GetDailyQuotesAsync, 0.2.0) |
Done, but the path and the response shape are unverified, not yet checked against a real server's OpenAPI; the path is overridable with ShioajiOptions.DailyQuotesPath |
Column-array responses (datetime[], Open[] …): any column of a different length fails the whole response (shioaji.history.malformed) |
Done |
Traffic meter (ShioajiTrafficMeter, 0.2.0): response bytes bucketed by Taipei day, reset at 08:00, compared with the server's own usage |
Done |
| Market-data subscribe / unsubscribe, with the three silently failing subscriptions refused locally | Done |
| Aggregate SSE stream: ticks, order books and combined quotes for stocks and futures/options, index quotes, order reports | Done; fields follow the documentation and are unverified against a real server |
| Drop and idle detection, backoff reconnection, a post-reconnect replay callback, lifecycle events, a bounded queue | Done |
| Placing orders (stocks and futures), repricing, resizing, cancelling, listing; order requests never retried | Done |
| Local rate budget: market queries, account queries, orders, the subscription ceiling | Done |
Mapping to the neutral TradeKit.Abstractions interfaces (IExchangeClient and so on) |
Not yet; see "Future" |
Portfolio queries (ShioajiPortfolioClient, 0.3.0): stock and futures positions and their details, realized profit and loss with details and a summary, settlements, trading limits, futures margin, bank balance |
Done, but only checked against the local fake server, not yet against a real server; the response shapes are inferred |
| Combo orders, earmarking | Not yet; see "Future" |
Every price and quantity is a decimal and every time is a UTC DateTimeOffset. Expected failures are always
Result / Result<T>, with codes starting with shioaji. that live in ShioajiErrorCodes, each documented in
Chinese and English.
Installation
dotnet add package Ozakboy.TradeKit.Shioaji
Target framework net10.0. You also need the official shioaji command-line tool (uv tool install shioaji; on
Windows there is an official install.ps1). This package does not install it for you.
Quick start
var services = new ServiceCollection();
services.AddLogging();
services.AddShioaji(options =>
{
options.BaseUri = new Uri("http://127.0.0.1:8080");
});
// What to replay after a reconnection: the order-report subscriptions (securities and futures once each) and the
// market-data subscriptions. Shioaji needs subscriptions made again after a drop, and the stream reader calls this
// callback after every successful reconnection.
services.AddSingleton<Func<CancellationToken, Task<Result>>>(provider => async cancellationToken =>
{
var auth = provider.GetRequiredService<ShioajiAuthClient>();
var quotes = provider.GetRequiredService<ShioajiQuoteClient>();
var accounts = await auth.GetAccountsAsync(cancellationToken);
if (accounts.IsFailure)
{
return accounts.ToResult();
}
foreach (var account in accounts.GetValueOrThrow().Where(a => a.IsSigned && a.AccountType is ShioajiAccountType.Stock or ShioajiAccountType.Futures))
{
var subscribed = await auth.SubscribeTradeAsync(account, cancellationToken);
if (subscribed.IsFailure)
{
return subscribed;
}
}
return await quotes.ReplaySubscriptionsAsync(cancellationToken);
});
// You read the credentials from the environment or a secure store; the function is called only at the instant the
// child process starts.
services.AddShioajiServerSupervisor(
options =>
{
options.Production = false; // simulation is the default
options.BindAddress = "127.0.0.1:8080";
},
provider => () => new ShioajiCredentials
{
ApiKey = Environment.GetEnvironmentVariable("MY_SHIOAJI_API_KEY")!,
SecretKey = Environment.GetEnvironmentVariable("MY_SHIOAJI_SECRET_KEY")!,
});
await using var provider = services.BuildServiceProvider();
// 1. Start (or attach to) the official server.
var supervisor = provider.GetRequiredService<ShioajiServerSupervisor>();
var started = await supervisor.StartAsync(cancellationToken);
if (started.IsFailure)
{
Console.WriteLine($"{started.Error.Code}: {started.Error.Message}");
return;
}
// 2. Accounts, and the order-report subscriptions (securities and futures once each, or order_event carries only
// heartbeats).
var auth = provider.GetRequiredService<ShioajiAuthClient>();
var accounts = (await auth.GetAccountsAsync(cancellationToken)).GetValueOrThrow();
foreach (var account in accounts.Where(a => a.IsSigned && a.AccountType is ShioajiAccountType.Stock or ShioajiAccountType.Futures))
{
await auth.SubscribeTradeAsync(account, cancellationToken);
}
// 3. Subscribe to market data. For a continuous-month contract (TXFR1), look the TargetCode up with
// GetContractAsync first and pass it in.
var quotes = provider.GetRequiredService<ShioajiQuoteClient>();
await quotes.SubscribeAsync(
new ShioajiSubscription
{
SecurityType = ShioajiSecurityType.Stock,
Exchange = "TSE",
Code = "2330",
QuoteType = ShioajiQuoteType.Tick,
},
cancellationToken);
// 4. Read the stream. One consumer at a time.
var reader = provider.GetRequiredService<ShioajiStreamReader>();
await foreach (var item in reader.ReadAsync(cancellationToken))
{
if (item.IsFailure)
{
continue; // An event that could not be read: note it; the stream does not end because of it.
}
switch (item.GetValueOrThrow())
{
case ShioajiTickStk tick:
Console.WriteLine($"{tick.Code} {tick.Close} @ {tick.Time:O}");
break;
case ShioajiOrderEvent order:
Console.WriteLine($"report {order.EventId} {order.EventType}"); // de-duplicate on EventId
break;
case ShioajiStreamLifecycleEvent { Kind: ShioajiStreamLifecycleKind.Disconnected }:
break; // Market data between this and the next Connected was not received: a data gap.
}
}
// 5. Place an order (simulation).
var orders = provider.GetRequiredService<ShioajiOrderClient>();
var placed = await orders.PlaceStockOrderAsync(
new ShioajiStockOrderRequest
{
Exchange = "TSE",
Code = "2330",
Action = ShioajiAction.Buy,
Price = 600.5m,
Quantity = 1m,
PriceType = ShioajiPriceType.Limit,
OrderType = ShioajiOrderType.Rod,
OrderLot = ShioajiOrderLot.Common,
OrderCondition = ShioajiOrderCondition.Cash,
CustomField = "sig001",
},
cancellationToken);
if (placed.IsFailure)
{
// A timeout or 5xx carries outcomeUnknown=true: the order may stand. Look it up first; do not resend.
Console.WriteLine($"{placed.Error.Code}: {placed.Error.Message}");
}
A test copies this example line by line (ReadmeQuickStartTests) and runs it against the fake server; if the example
breaks, the test goes red.
Backfilling 1-minute bars and ticks (0.2.0)
// Backfill after the close. One bar query spans at most 30 days (both ends counted); ticks are one day at a time.
// Never use these as live quotes during trading hours: subscribe to the stream for that.
var history = provider.GetRequiredService<ShioajiHistoryClient>();
var contract = new ShioajiContractRef { SecurityType = ShioajiSecurityType.Stock, Exchange = "TSE", Code = "2330" };
var bars = await history.GetKbarsAsync(contract, new DateOnly(2026, 10, 1), new DateOnly(2026, 10, 30), cancellationToken);
if (bars.IsSuccess)
{
var page = bars.GetValueOrThrow();
if (page.PossiblyTrafficExhausted)
{
// An empty answer has two meanings: really no data, or the daily traffic is spent (the broker does not fail, it
// answers with empty values). Check what is left before storing this span as "no market that day".
var usage = (await auth.GetUsageAsync(cancellationToken)).GetValueOrThrow();
Console.WriteLine($"remaining {usage.RemainingBytes} bytes");
}
foreach (var bar in page.Items)
{
Console.WriteLine($"{bar.Time:O} O{bar.Open} H{bar.High} L{bar.Low} C{bar.Close} V{bar.Volume}");
}
}
var ticks = await history.GetTicksAsync(
contract,
new DateOnly(2026, 10, 30),
ShioajiTicksQuery.Range(new TimeOnly(9, 0), new TimeOnly(9, 30)),
cancellationToken);
// Roughly how much traffic this process used today (response body sizes; the broker's own count is GetUsageAsync).
var traffic = provider.GetRequiredService<ShioajiTrafficMeter>().Snapshot();
Console.WriteLine($"used {traffic.Bytes} bytes, resets {traffic.NextResetAt:O}");
auth is the ShioajiAuthClient from the main example. A test copies this example line by line, so it compiles and
runs. Note that bar.Time is the end of its minute (Taiwan stocks run 09:01 to 13:30 each trading day).
Subtract 1 minute to store the open time. The all-market daily OHLCV is history.GetDailyQuotesAsync(date); read the
warning about it under "Limits and unmeasured behaviour" first.
Historical data in brief (0.2.0)
- Local checks come first and cost nothing. A bar range over 30 calendar days (both ends counted) is
shioaji.history.range_too_long; a start after the end isshioaji.validation.invalid_argument; more than 500 contracts in a snapshot isshioaji.history.too_many_contracts; bad tick conditions, an unknown security type and a blank code are refused too. None of them takes a budget unit or reaches the server. - Column-array responses.
kbarsandticksanswer{datetime: […], Open: […], …}. Columns of unequal length fail the whole response withshioaji.history.malformed: a close matched to another row's time is worse than no data. Adatetimeis an ISO string (without an offset it is Taipei wall-clock time, minus 8 hours) or an epoch whose unit is judged by size (seconds, milliseconds, microseconds, nanoseconds). Numbers are read from raw text intodecimal. - An empty page is not "no data". The result is a
ShioajiHistoryPage<T>withItems,ResponseBytes,IsEmptyandPossiblyTrafficExhausted. With nothing known about today's usage an empty page is flagged as possibly exhausted; onceGetUsageAsynchas been called, the flag follows the server's remaining bytes minus what was used since. - Every call takes one
MarketQueryunit (50 per 10 seconds) after its local checks; a POST is never retried. - Traffic meter.
ShioajiTrafficMeteris a singleton fed by every response throughShioajiApiClient(Content-Length, or the bytes read when chunked; non-2xx included; the SSE stream and request bodies are not). It buckets by Taipei traffic day, which resets at 08:00 (= 00:00 UTC), and exposesTodayBytes,Reset()andSnapshot(). It is an estimate: the broker's own number is whatGetUsageAsyncreports, and each such answer is written to the meter'sServerReportedBytes.
Portfolio queries (0.3.0)
Not yet run against a real server. The endpoints below are tested only against the local fake server, which follows the portfolio chapter of the official documentation. That chapter lists the request fields and the response fields of every endpoint but gives no verbatim response (only
settlementsandaccount_balancehave an example), so the shape of a response is inferred. Try each call once in the simulation environment and compare the result with your account before relying on it. In the simulation environment the portfolio numbers are demonstration data.
// Read-only queries; each call takes one Portfolio budget unit (25 per 5 seconds) after its local checks.
// The account parameter defaults to null, which means the server's default account.
var portfolio = provider.GetRequiredService<ShioajiPortfolioClient>();
// Stock positions with unrealized profit and loss (round lots by default; ShioajiPositionUnit.Share for odd lots).
var positions = await portfolio.ListStockPositionsAsync(cancellationToken: cancellationToken);
if (positions.IsSuccess)
{
foreach (var position in positions.GetValueOrThrow())
{
Console.WriteLine($"{position.Code} {position.Direction} x{position.Quantity} cost {position.Price} pnl {position.Pnl}");
}
}
// Realized profit and loss for a date range, then the detail of one row; a detail says whether it was a day trade.
var realized = await portfolio.ListStockProfitLossAsync(
new DateOnly(2026, 10, 1),
new DateOnly(2026, 10, 31),
cancellationToken: cancellationToken);
if (realized.IsSuccess && realized.GetValueOrThrow() is [{ Id: { } id }, ..])
{
var details = await portfolio.ListStockProfitLossDetailsAsync(id, cancellationToken: cancellationToken);
foreach (var detail in details.GetValueOrThrow())
{
Console.WriteLine($"{detail.Code} {detail.TradeType} fee {detail.Fee} tax {detail.Tax}");
}
}
var settlements = await portfolio.ListSettlementsAsync(cancellationToken: cancellationToken);
foreach (var settlement in settlements.GetValueOrThrow())
{
Console.WriteLine($"T+{settlement.TPlus} {settlement.Date:yyyy-MM-dd} {settlement.Amount}");
}
// The documentation says trading limits can be queried 08:30 to 15:00 on trading days.
var limits = await portfolio.GetTradingLimitsAsync(cancellationToken: cancellationToken);
Console.WriteLine($"available {limits.GetValueOrThrow().TradingAvailable}");
A test copies this example line by line, so it compiles and runs against the fake server.
- Methods. Stock and futures have separate methods because their response fields differ:
ListStockPositionsAsync/ListFuturesPositionsAsync(position_unit),ListStockPositionDetailsAsync/ListFuturesPositionDetailsAsync(position_detail),ListStockProfitLossAsync/ListFuturesProfitLossAsync(profit_loss),ListStockProfitLossDetailsAsync/ListFuturesProfitLossDetailsAsync(profit_loss_detail),GetStockProfitLossSummaryAsync/GetFuturesProfitLossSummaryAsync(profitloss_sum), and the single-account-typeListSettlementsAsync,GetTradingLimitsAsync,GetAccountBalanceAsync(securities) andGetMarginAsync(futures). The position and profit-and-loss ids (Id) are what the detail methods take. - Local checks come first and cost nothing. A start date after the end date, or a
BrokerIdgiven without anAccountId(or the other way round), isshioaji.validation.invalid_argument; nothing is sent and no budget unit is taken. - A field the documentation does not promise is nullable and is never filled with zero. A row fails only when its
identifying fields are missing or unreadable (the code and quantity, or a settlement's date, amount and
T); every other missing field isnull, and an enum reads asUnknown. Money, prices, quantities and profit and loss aredecimalread from raw JSON text, whether the server writes29.8or"29.8". - Dates are Taipei calendar days (
DateOnly), taken as written. The bank balance'sQueriedAtcarries a time of day and follows the usual rule (no offset means Taipei wall-clock time, converted to UTC). - Accepted response shapes. Because the shape is inferred, each list endpoint accepts a bare array of objects, an
object wrapping the array (
items,data,results,list,rows), and the column-array object of the Python SDK; the single-object endpoints accept an object, an array holding one object, and a column array of one row. An empty answer is a successful empty list. - The bank balance reports its own errors in band. The documentation says only SinoPac Bank, sub-accounts and LINE
Bank are supported; another bank is expected to answer 200 with a message in
errmsg, which lands inShioajiAccountBalance.ErrorMessagewith anullbalance.
Design decisions worth knowing
Why the official server, rather than a home-grown sidecar or a community package
Login, certificate activation, token caching and Solace reconnection all live inside the official server, and the documentation names C# as a client of exactly this route. A home-grown Python sidecar would be one more thing to maintain that moves with the broker's releases, and a community package breaks the family's dependency policy. So this package covers only the outside of the server, and treats "what the server does" as something to be measured, faked (the test double) and defended against.
Credentials travel only through the child process's environment
The ShioajiServerSupervisor constructor takes a Func<ShioajiCredentials> that is called only at the instant the
child process starts; its values go into ProcessStartInfo.Environment (SJ_API_KEY, SJ_SEC_KEY, SJ_CA_PATH,
SJ_CA_PASSWD).
- No file is written. There is no
.env, and no value goes on the command line (it would show in a process list). A test scans the wholeHomePathafter a start and confirms no file contains a credential literal. - Nothing reaches a log. All four values are registered with the
SecretMaskeras soon as they are obtained, and the child's stdout and stderr are masked line by line before they are logged (stdout at Information, stderr at Warning). Masking is literal replacement, so a value shorter than 8 characters cannot be registered (it would mask swathes of ordinary text); use credentials and passwords that are long enough. ShioajiCredentials.ToString()prints only whether each field is set, andAdditionalEnvironmentrefuses the four keys, so credentials can only travel through the provider.- To require HTTP authentication (
ShioajiServerOptions.RequireAuth, which maps to--auth), setShioajiOptions.UseBearerAuthso every request carriesAuthorization: Bearer <key>:<secret>. The key, the secret and the literalkey:secretare registered on the client's masker, so any of them is replaced in error bodies, messages and logs (a test makes the fake server echo the header back to prove the masking works). Sending the bearer credential over plainhttpto a non-loopback address is refused when the options are validated.
The pipeline: no signing, no rate limiting, GET retried, POST never
- No signing. The server needs none; authentication is the Bearer header.
- No rate limiting in the pipeline. Rate limiting inside the pipeline queues, whereas here the need is to refuse
and say plainly that this one was not sent, so it is
ShioajiRateBudget's job; see the next section. - Retry for GET only. Every POST, meaning orders, repricing, resizing, cancelling, market-data subscriptions and
subscribe_trade, is marked non-idempotent and additionally pinned toRetryPolicy.NoRetry. Two guards: if someone swaps the pipeline's default policy for one that retries everything, an order still goes out once. Tests prove "sent exactly once, whatever the failure" for every order endpoint, and those tests go red when the guard is removed. - An unknown outcome is flagged. An order-type request that times out or meets a 5xx carries
outcomeUnknown = truein the error'sData: the broker may have accepted it, and the caller must look it up (ListTradesAsync) rather than resend. A refused connection (the request certainly never arrived) and a 4xx (an explicit refusal) do not carry it. A 2xx whose body cannot be read is the dedicatedshioaji.order.response_unreadable, flagged the same way.
The local rate budget and the red lines
The broker's penalty for overuse is to suspend the IP and the ID, and logging in again does not lift it, so the
budget errs conservative: better to wait locally than to touch the penalty line. ShioajiRateBudget is a sliding
window over a TimeProvider; a wait that would exceed MaxWait (10 seconds by default) fails at once
(shioaji.rate_budget.exhausted, transient) instead of sleeping first, because a caller placing an order needs to know
immediately that this one was not sent.
| Category | Ceiling | Calls counted today |
|---|---|---|
MarketQuery |
50 per 10 s | every ShioajiHistoryClient call; each page of ListAllContractsAsync |
Portfolio |
25 per 5 s | every ShioajiPortfolioClient call (0.3.0); GetUsageAsync; ListTradesAsync(refresh: true) |
Order |
250 per 10 s | place, reprice, resize, cancel; ListTradesAsync(refresh: false) |
Subscription |
200 at once | SubscribeAsync +1, UnsubscribeAsync −1; a repeat is not counted twice, a failed request returns the unit it took |
Single-contract lookups read the server's local contract file and are not the broker's market queries, so they do not
count against MarketQuery; only ListAllContractsAsync, which turns many pages in a row, takes one unit per page.
The defaults are the broker's published ceilings; raising them hands the penalty-line risk to you. Three more
published red lines are not in the budget and are yours to respect: never use ticks / snapshots / kbars as
live quotes during trading hours (0.2.0 has these endpoints now, and this package does not decide whether the
market is open: ShioajiHistoryClient is meant for after-the-close backfill, live data belongs to the stream),
at most 5 connections per identity (the aggregate stream counts as one SSE connection), and the daily traffic
ceiling (GetUsageAsync tells you what is left, and ShioajiTrafficMeter what this process spent).
Three silently failing subscriptions are refused locally
- A continuous-month contract (code ending in
R1orR2) without aTargetCode: the server answers 200 and then sends only heartbeats. Refused withshioaji.subscription.target_code_required. - An index subscribed for
TickorBidAsk: an index supportsQuoteonly. Refused withshioaji.subscription.index_requires_quote. - More than 200 subscriptions: refused with
shioaji.subscription.limit_exceeded.
All three are refused before the request leaves and consume no budget.
Stream and reconnection semantics
- It reads the aggregate stream
GET /api/v1/stream/data: one connection carries every named event with shared heartbeats. The stream uses a separateHttpClientthat bypasses the pipeline (the pipeline reads a response to its end and an SSE response has none). - Two ways to notice a dead connection: the connection ends or a read fails, and
StreamIdleTimeout(45 seconds by default) passes with no line at all; heartbeats and SSE comment lines both count as "received". - Reconnection: exponential backoff (1 s initial, 30 s ceiling, ±20% jitter). After a connection has delivered data the backoff resets; one that dropped before any data does not, so a server that closes right after accepting cannot be hammered every second.
- Shioaji needs subscriptions made again after a drop. After a successful reconnection the callback you injected
(a
Func<CancellationToken, Task<Result>>, registered in the container or passed to the constructor) runs; use it to replay market-data subscriptions (ShioajiQuoteClient.ReplaySubscriptionsAsync) andsubscribe_trade. A failed result or an expected exception from it is only logged, and the stream carries on. - Lifecycle events (
ShioajiStreamLifecycleEvent:Connected/Disconnected/Reconnecting, with the attempt number and the reason) come through the sameIAsyncEnumerableas the market events. Everything between aDisconnectedand the nextConnectedwas not received; a strategy should treat it as a data gap. - A bounded queue (10,000 by default): when full the oldest is dropped and counted in
DroppedEvents, and reading never blocks. A lifecycle event in the queue can be dropped too, so judge gaps together withDroppedEvents. - Unknown formats are logged once. A comment line, an unrecognised field name and a line with no colon each get one log line the first time their kind appears, and so does the text of the first heartbeat, so the first run against a real server shows in the log what a heartbeat actually looks like.
Event classification
| Event | Type |
|---|---|
tick_stk / bidask_stk / quote_stk |
ShioajiTickStk / ShioajiBidAskStk / ShioajiQuoteStk |
tick_fop / bidask_fop / quote_fop |
ShioajiTickFop / ShioajiBidAskFop / ShioajiQuoteFop |
quote_idx |
ShioajiQuoteIdx |
order_event |
ShioajiOrderEvent (with EventId for de-duplication, and RawJson) |
heartbeat / ping / a blank event name / an unrecognised name whose data is not a JSON object |
ShioajiHeartbeat (original text kept) |
An unrecognised name whose data is a JSON object (such as kbar) |
ShioajiUnknownEvent (passed through, not dropped) |
| A known market or order event name that cannot be read | a failure on the stream (shioaji.stream.malformed_event), never swallowed as a heartbeat |
| Connection lifecycle | ShioajiStreamLifecycleEvent |
A book is an IReadOnlyList<decimal> exactly 5 long; an event with any other length fails rather than handing a
strategy a broken book. A missing required field (code, time, price, book arrays) is always a failure and
never zero, because zero reads as a price of nothing. The order_event event_id is the exception: the server allows
it to be empty (per the official type note), so a missing value gives EventId == "" and the event is still delivered;
the caller builds a substitute de-duplication key from RawJson (since 0.3.1). The real server wraps each order_event in
{"state":"StockOrder","data":{"StockOrder":{…}}} (measured in the simulation on 2026-11-02); the package unwraps it first
and state is the event kind, while the flat shape is still accepted (since 0.3.2).
Numbers and time
- JSON numbers are always read from their raw text into
decimal, with no step throughdouble; a string-typed number ("29.8") reads too. On the way outPrecision.ToPlainStringwrites the raw number with no exponent and no trailing zeros. Tests prove it with29.8and a 25-digit fraction. - Time: Shioaji's
date,timeanddatetimeare Taipei wall-clock time, converted to UTC at a fixed UTC+08:00 (Taiwan has no daylight saving, so no time-zone database is consulted and the host's time zone has no effect). Adatetimecarrying its ownZor±hh:mmuses that. A numeric timestamp (order_ts, a deal'sts) is Unix seconds with fractions, and anything above 1e11 is read as milliseconds.ShioajiStreamEvent.ReceivedAtis when this package received the event;ShioajiMarketEvent.Timeis the exchange's match time.
What the supervisor does
- Attach: if
/api/v1/infoalready answers at start and itssimulationagrees withProduction, the server counts as started externally and the state becomesAttached; its process is not managed, nothing is touched on stop, and the credentials are not even fetched. - A mode mismatch is refused: it answers but
simulationdisagrees withProduction, and the result isshioaji.server.mode_mismatch. This package never kills a server someone else started and never trades on it. A process it started itself that reports a different mode ("wanted simulation, reached production") is refused and killed. - Start:
shioaji server start --no-open(without--no-openthe CLI opens a browser, so it is always passed), plus--production,--authand--receive-windowas configured. Mode, address and authentication are always set explicitly through environment variables rather than inherited from whatever those names hold on the machine. A timeout kills the process and returnsshioaji.server.start_timeoutwith the last 20 output lines (masked) inData; an exit before readiness isshioaji.server.exited_earlywith the exit code and the last 20 lines. - Restart: an unexpected exit after readiness is restarted with exponential backoff (
RestartBackoff, capped at 60 s); more thanMaxRestarts(5 by default) consecutive restarts put it inFaultedand it gives up, which needs a human. A process that ran forStableAfterbefore exiting starts the consecutive count over. - Stop:
shioaji server stopfirst (waitingStopTimeout, 10 s by default), then the whole process tree is killed. - Colour codes are stripped from the output. The real
shioajiwrites ANSI colour codes. Each stdout / stderr line has its ANSI escape sequences stripped first and is masked after, and only then logged; the "last 20 lines" in error data use the stripped text too. - A failed login is a dedicated error. The process exits before readiness and its output contains
Authentication failed(case-insensitive), orlogin validation error,invalid secret_keyorinvalid api_key→shioaji.server.auth_failed, with that line (stripped and masked) inError.Data["detail"]. Retrying the same credentials earns the same answer, so it goes straight toFaultedand never enters the restart loop; an authentication failure during a restart goes toFaultedtoo. secret_keymust be base58.ShioajiCredentials.Validate()(which the supervisor calls before starting any process) checks thatSecretKeyhas only base58 characters; one with0,O,I,l,_,-and the like givesshioaji.server.invalid_credentialsand no process is started. The message says only that the format does not match (base58) and echoes no character. The format ofApiKeyis unknown and it is not checked.- Log file location. The
server log: <path>line printed on stdout at start is parsed intoShioajiServerStatus.LogFilePath, so a host can show the path to the user;nullwhen it cannot be parsed. - The
ShioajiServerStatussnapshot:State,ProcessId,StartedAt,Restarts,LastExitCode,LastError,LogFilePath.
Known server behaviour
The real shioaji server start --no-open shows these behaviours, which the documentation does not state.
Process and output
- The server is a foreground process and does not detach. On a failed login the process exits, and the last
stdout line is
Error: Authentication failed: …. So the "exits 0 while the server still answers → Attached" path does not happen in practice. It stays in the code only as a defence. - The output carries ANSI colour codes. The supervisor strips them before it logs a line.
- The first stdout line at start is
server log: <SJ_HOME_PATH>\logs\server-<port>\server.<UTC date>.log. secret_keymust be base58. A value with a character such as_makes the server fail the login and exit.- The output of
shioaji server statusis in toon format. With no server running it is four lines:
This package does not parse it (health is judged withrunning: false pid: null port: 8080 healthy: falseGET /api/v1/info). The format is recorded here only.
Historical data (simulation environment)
- The
datetimeof a 1-minute bar is the end of its minute, in Taipei time without an offset. Taiwan stocks run 09:01 to 13:30 each trading day. The 09:01 bar holds the opening call auction and the 13:30 bar holds the closing one. A minute with no trade has no bar. - Units: for stocks,
Volumeis in lots andAmountis in TWD (price × shares). For futures,Volumeis in contracts. - Indices and continuous futures also have 1-minute bars. Futures include the night session (15:00 to 05:00 the next day).
- Traffic reference: 30 days of 1-minute bars for one stock take about 0.3 to 0.5 MB. One whole day of ticks for a heavily traded stock takes about 0.4 MB.
- The
bytesofGET /api/v1/auth/usageupdates with a delay (it catches up some seconds after a burst of requests). Judge the remaining traffic mainly with the localShioajiTrafficMeter, and use the server's figure as a check. - Contract details carry the reference price and the up and down limits for stocks and futures. Indices carry
only the reference price. The
target_codeof a continuous future is the actual contract of the month.
Limits and unmeasured behaviour
Each item below is either unwritten in the documentation or not yet confirmed against a real server. The package handles them defensively, which is not the same as correctly. When you first use it in your environment, check each item against the log:
- The heartbeat format and cadence. Undocumented. The package treats event names
heartbeat/ping, a blank name, and unknown non-JSON-object events as heartbeats and keeps their text; SSE comment lines also count as "alive". The text of the first heartbeat and of the first comment line goes into the log. - The shape of a non-2xx error body. Undocumented. The first 2 KB, masked, goes into
Error.Data["body"], and a sentence is assembled only from string fields nameddetail/message/error/msgin a JSON object. - The JSON shape of
order_event. The documentation has a field list and no sample. Whether the deal part sits at the top level or underdealis unclear and both are accepted;EventTypeis inferred from the content (an explicitevent_typefield wins). The whole original text is always kept inRawJson, so a wrong inference can be re-read. - The responses of
place_order,update_price,update_qtyandcancel_order.place_orderis read as aTradeper the documentation, and onlyorder.idis required; a 2xx that cannot be read isshioaji.order.response_unreadable(the order may stand). The format of the reprice, resize and cancel answers is not described, so any 2xx is success: a readableTradeis passed back and an unreadable one givesnull, because an effective cancellation must not be reported as a failure just because the answer looks different. - The response of
subscribe_trade. Undocumented; any 2xx is success and the body is not read. - The
timestampformat of/api/v1/stream/status. Assumed to be Unix seconds or a date-time string;nullwhen unreadable. - Field names.
closing_oddlot_*andfixed_trade_*ontick_stk,avail_borrowingonquote_stk, the finer fields of the*_fopevents and the fields ofquote_idxare written in the documentation as wildcards or not at all; the names come from the Python SDK and are unverified over HTTP. A mismatch leaves that fieldnulland does not fail the event (only code, time, price and the books are required). - The all-market daily OHLCV: its path and its response shape (0.2.0). The documentation mentions only the command
line's
shioaji data daily-quotes --date YYYY-MM-DD --exclude-warrantand no HTTP path.GetDailyQuotesAsyncsendsPOSTShioajiOptions.DailyQuotesPath(default/api/v1/data/daily_quotes, unverified, not yet checked against a real server's OpenAPI) with{date, exclude_warrant}, and assumes an array of objects each carrying at leastcode,open,high,low,closeandvolume; any further fields are inShioajiDailyQuote.Raw. A wrong path answersshioaji.http.404: find the real one on the server's/docsand setDailyQuotesPath. - The shapes of the other query endpoints (0.2.0). Only
kbarsandtickshave a documented shape (column arrays with named fields), and both are confirmed in the simulation environment.snapshots,scanner,short_stock_sources,credit_enquireand the two regulatory lists are read as an array of objects (or an object wrapping one, or for the regulatory lists a column-array object, which is how the Python SDK returns them); their field names come from the Python SDK and are unverified over HTTP, and everything not mapped stays in each item'sRaw. The request bodies ofshort_stock_sourcesandcredit_enquire({contracts: [...]}) are inferred fromsnapshots. - The portfolio queries (0.3.0): response shapes and some field meanings. The documentation lists the request and
response fields of every portfolio endpoint but has a verbatim example for only
settlementsandaccount_balance, and itstrading_limitsexample is cut short. The accepted shapes (bare array, wrapped array, column arrays) are therefore inferences, and so are: whether one path answers the stock or the futures field set according to theaccount_typeyou send (assumed), that a futures query may omitunit(assumed; the stock queries send it), the meaning of the sign of a settlementamount, the unit ofpr_ratioandrisk_indicator, and whatdetail_idis for a given row (theIdof the previous call). Nothing has run against a real server, so none of it is confirmed. A wrong guess surfaces asshioaji.response.malformedorshioaji.response.missing_field(with the field name), or as anullfield, and never as a zero. - What an exhausted traffic allowance looks like. The documentation says market queries answer with "empty
values" and not what that is. An empty body,
null,[],{}, all-null columns and all-empty columns are all read as an empty page; if the server answers some other way (an error body, say), it surfaces as that error instead. - Epoch
datetimecells (0.2.0). A numericdatetimeis read as a true Unix epoch (UTC). The Python SDK's nanosecondtsis known to carry Taipei wall-clock time as if it were UTC; if the HTTP server passes that through unchanged, such cells will be 8 hours late. ISO strings (the form this package expects) are unaffected. - Whether the 30-day bar limit counts both ends. The broker says "≤ 30 days"; this package counts both ends (30 calendar days), the conservative reading.
- The Attached defensive path. The server is in fact a foreground process (see the previous section). The code
still keeps the path where a process that exits 0 while the server still answers counts as Attached, and a stop
then calls
shioaji server stop. Only a scripted probe has exercised it. - The behaviour of
server stop, the--portparameter, whether an SSE connection counts toward the 5 login connections, and the real HTTP / SSE latency. None measured. SJ_AGENT_HARNESShas unknown semantics and is only passed through, viaShioajiServerOptions.AdditionalEnvironment.- Platforms other than Windows. The tests have run only on Windows. The supervisor's process handling and the
HttpListenerfake should be portable and the CI file targets ubuntu, but it has not been run.
Future
Deliberately not in this release, planned for later ones:
- Implementing the neutral
TradeKit.Abstractionsinterfaces:IExchangeClient,IMarketDataFeed,IUserDataFeed. - Confirming the portfolio queries (0.3.0) against a real server and replacing the inferred response shapes with recorded ones.
- Enforcing "no live quotes from historical endpoints during trading hours" in code (0.2.0 documents the rule and leaves the decision to the caller; it needs a trading calendar to enforce).
- Combo orders, earmarking,
watchlist. - The value-added streams (
calculated_index,index_components,kbar,scanner): for now they pass through asShioajiUnknownEvent.
Testing
Unit and contract tests never touch the Internet and need no credentials:
FakeShioajiHttpServer(tests/.../Support) is a stand-in for the server on127.0.0.1:<random port>, built on the BCL'sHttpListener. It registers routes, serves an SSE endpoint that pushes events, simulates drops and records the requests it receives. Its behaviour follows the documentation (the two-lineevent:+data:format, only heartbeats when unsubscribed, only heartbeats for a continuous month withouttarget_code), each behaviour names the documentation section it rests on, and it is apublicclass that can be copied or referenced elsewhere.FakeShioajiServerExe(tests/FakeShioajiServerExe) is a minimal console project that imitates theshioajicommand line: it serves/api/v1/info, exits when toldserver stop, crashes at once, never becomes ready, crashes once and then recovers, or crashes every time. The supervisor's start, timeout, restart, Faulted, forced kill, masking and "credentials never touch disk" are all tested against it.- The fixtures are not recordings.
tests/.../Fixtures/PROVENANCE.mdsays, file by file, what each was built from: with no credentials, all of them are hand-built from the documented field lists. The tests therefore prove that the package reads the shape the documentation describes, not that a real server looks like this.
dotnet test -c Release
dotnet test -c Release -- --coverage --coverage-output-format cobertura
Security
- The package ships no default credentials and writes none to a file, a log or a
ToString. - Broker-login credentials travel only through the child process's environment; see "Credentials travel only through the child process's environment" above.
- Bearer credentials are registered on the client's masker, so no
Errormessage, noDataentry and no exception text carries them, even when the server echoes the header into an error body. - The reason text of a stream reconnection, the child process's output, and the content of an unknown format in a log all pass through the masker before they are written.
- Personal data (
ShioajiAccount.PersonId,Username) lives in memory only and this package's logs never write it; please keep it out of yours too.
Dependency policy
Only the .NET BCL, first-party Microsoft packages and Ozakboy.* packages, judged by the transitive graph.
| Package | Version | Used for |
|---|---|---|
Ozakboy.Http |
0.3.4 | the request pipeline (retry, timeouts, sanitised logging, masking) |
Ozakboy.Core.Abstractions |
0.3.0 | Result / Error / Precision |
Ozakboy.Security |
0.1.1 | SecretMasker |
Ozakboy.TradeKit.Abstractions |
0.8.0 | referenced only; for the neutral-interface mapping see "Future" |
Microsoft.Extensions.DependencyInjection.Abstractions |
10.0.14 | DI registration |
Microsoft.Extensions.Http |
10.0.14 | the named HttpClients |
Microsoft.Extensions.Logging.Abstractions |
10.0.14 | logging |
The test project additionally uses MSTest and Microsoft.Extensions.TimeProvider.Testing, on Microsoft.Testing.Platform
rather than the legacy VSTest (whose path transitively drags in the third-party Newtonsoft.Json).
License
MIT, see LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Http (>= 10.0.14)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.14)
- Ozakboy.Core.Abstractions (>= 0.3.0)
- Ozakboy.Http (>= 0.3.4)
- Ozakboy.Security (>= 0.1.1)
- Ozakboy.TradeKit.Abstractions (>= 0.8.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
0.3.2 — Fixes order reports in the simulation. A real server (1.7.10, simulation, measured on 2026-11-02) wraps each order_event in {"state":"StockOrder","data":{"StockOrder":{...}}}. The data object is keyed by the kind (StockOrder, StockDeal, FuturesOrder or FuturesDeal), state repeats the kind, and event_id has a value. The envelope is now unwrapped first and state becomes the event kind. The flat shape still reads, and RawJson remains the full original text, envelope included. The deal report envelope is inferred from the same enum and is not yet measured. 0.3.1 — A missing, null or blank order_event event_id now reads as EventId == "" and the event is delivered. It used to fail with shioaji.response.missing_field. Callers that de-duplicate should build a substitute key from RawJson. This release did not fix the simulation's reports (see 0.3.2). 0.3.0 — Portfolio queries. New ShioajiPortfolioClient (registered by AddShioaji): ListStockPositionsAsync and ListFuturesPositionsAsync (position_unit), ListStockPositionDetailsAsync and ListFuturesPositionDetailsAsync (position_detail), ListStockProfitLossAsync and ListFuturesProfitLossAsync (profit_loss), ListStockProfitLossDetailsAsync and ListFuturesProfitLossDetailsAsync (profit_loss_detail, with the ordinary or day-trade trade type), GetStockProfitLossSummaryAsync and GetFuturesProfitLossSummaryAsync (profitloss_sum), ListSettlementsAsync, GetTradingLimitsAsync, GetMarginAsync and GetAccountBalanceAsync. Every call counts against the Portfolio budget (25 per 5 seconds) after its local checks, and a POST is never retried. Prices, quantities, amounts and profit and loss are decimal, read from raw JSON text; a field the documentation does not promise is nullable and is never filled with zero. UNVERIFIED: these endpoints are tested only against the local fake server. The documentation lists the fields but gives no verbatim response, so a list endpoint accepts a bare array, an object wrapping an array, or column arrays. Additions only; no existing signature changed. 0.2.1 — Documentation only; the code and its behaviour are unchanged. The READMEs now state what is tested in the simulation environment and add a "Known server behaviour" section (a 1-minute bar's time is the end of its minute; units of volume and amount; 1-minute bars for indices and futures; traffic figures; the update delay of the usage query). Internal milestone codes and application names are removed from the docs and comments. 0.2.0 — Historical data and a traffic meter. New ShioajiHistoryClient: GetKbarsAsync (1-minute bars; a range over 30 calendar days, both ends counted, is refused locally with shioaji.history.range_too_long), GetTicksAsync (AllDay, RangeTime or LastCount), GetSnapshotsAsync (at most 500 contracts, otherwise shioaji.history.too_many_contracts), GetDailyQuotesAsync, GetScannerAsync, GetShortStockSourcesAsync, GetCreditEnquireAsync, GetRegulatoryPunishAsync and GetRegulatoryNoticeAsync; every call goes through the MarketQuery budget after its local checks. Results are ShioajiHistoryPage<T> {Items, ResponseBytes, IsEmpty, PossiblyTrafficExhausted}: over the daily traffic allowance the broker answers market queries with empty values instead of an error, so an empty page may mean exhausted traffic and not "no data". Column-array responses (kbars and ticks) are read by ShioajiColumnarReader: columns of unequal length fail the whole response with shioaji.history.malformed, a datetime is an ISO string (no offset means Taipei wall-clock time, fixed UTC+8) or an epoch in seconds, milliseconds, microseconds or nanoseconds by magnitude, and numbers are read from raw text into decimal, never double. ShioajiTrafficMeter (a singleton on a TimeProvider) records the bytes of every response (Content-Length, or the bytes read when chunked), buckets them by Taipei traffic day with the reset at 08:00, exposes TodayBytes, Reset and Snapshot, and receives the usage ShioajiAuthClient.GetUsageAsync reports as ServerReportedBytes. ShioajiContractClient.ListAllContractsAsync walks every page at 500 per page through the MarketQuery budget. ShioajiOptions.DailyQuotesPath overrides the path of the all-market daily OHLCV. UNVERIFIED: that path (default /api/v1/data/daily_quotes, awaiting confirmation against a real server's OpenAPI; the documentation mentions only the command line's `shioaji data daily-quotes`) and its response shape (assumed an array of objects with at least code, open, high, low, close and volume), the request bodies of short_stock_sources and credit_enquire, and the shapes of scanner, short_stock_sources, credit_enquire and the two regulatory lists. Constructor changes: ShioajiApiClient, ShioajiAuthClient and ShioajiContractClient take optional trailing parameters, so existing code keeps compiling. Measured against a real server (simulation) on 2026-10-31: GetKbarsAsync, GetTicksAsync (AllDay), GetUsageAsync, ListAllContractsAsync and GetContractInfoAsync. A bar's Time is the END of its minute (2330: 09:01 to 13:30 each trading day; subtract 1 minute for the open time), Volume is in lots and Amount in TWD; one day of 2330 ticks is about 7,400 rows and 0.4 MB. The other history endpoints are still tested only against the fake server. 0.1.1 — Adjusted to what a real `shioaji server start --no-open` was measured to do on 2026-10-30 (fake credentials). The server is a foreground process that never detaches, so the "exits 0 while still answering" Attached path stays only as a defence. Its output carries ANSI colour codes: the supervisor now strips them line by line before logging and before keeping the last 20 lines. A failed login (output containing "Authentication failed", "login validation error", "invalid secret_key" or "invalid api_key") now returns the dedicated shioaji.server.auth_failed with the masked, stripped line in Error.Data["detail"], and goes straight to Faulted without entering the restart loop, also when it happens during a restart. The `server log: <path>` line printed at start is parsed into the new ShioajiServerStatus.LogFilePath (null when absent). ShioajiCredentials.Validate() and the supervisor now refuse a SecretKey containing characters outside base58 with shioaji.server.invalid_credentials before starting any process, echoing no character; ApiKey is not checked because its format is unknown. Behaviour change: a SecretKey that is not base58 used to start a process that then failed to log in, and now fails locally; ShioajiServerStatus gains the required LogFilePath property. 0.1.0 — First release. ShioajiServerSupervisor starts and supervises the official `shioaji server start --no-open` process (credentials only through child-process environment variables, never a file or a log; every output line masked; Attach mode for an already-running server; mode mismatch refused; backoff restarts up to a limit, then Faulted). ShioajiAuthClient, ShioajiContractClient, ShioajiQuoteClient and ShioajiOrderClient cover /api/v1/info, accounts, usage, stream status, subscribe_trade, contracts, market-data subscription, place_order, update_price, update_qty, cancel_order and trades. The pipeline neither signs nor rate-limits, retries GET only, and never retries a POST; an order that times out or meets a 5xx carries outcomeUnknown=true. ShioajiStreamReader reads the aggregate SSE stream into typed events (stock and futures/options tick, bid-ask and quote, index quote, order report, heartbeat, unknown, lifecycle) with idle detection, backoff reconnection, a post-reconnect replay callback, and a bounded queue that drops the oldest and counts. ShioajiRateBudget enforces market-query 50/10 s, account 25/5 s, order 250/10 s and at most 200 subscriptions locally. Prices and quantities are decimal parsed from raw JSON text, never double; Taipei date and time convert to UTC at a fixed +8. Not run against a real server: the heartbeat format, error-body shape and several response shapes are unmeasured and handled defensively. Not in this release: portfolio, historical data, combo orders, earmarking, watchlist, regulatory lists, and the neutral TradeKit.Abstractions mapping. Full notes: https://github.com/ozakboy/Ozakboy.TradeKit.Shioaji/blob/main/CHANGELOG.md