Metadata-Version: 2.4
Name: fugle-marketdata
Version: 3.0.0rc13
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Topic :: Office/Business :: Financial :: Investment
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: Implementation :: CPython
Requires-Dist: pytest>=7.0 ; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21 ; extra == 'dev'
Requires-Dist: pytest-timeout>=2.1 ; extra == 'dev'
Requires-Dist: vcrpy>=6.0.0 ; extra == 'dev'
Requires-Dist: pytest-benchmark>=4.0.0 ; extra == 'dev'
Requires-Dist: pyyaml>=6.0 ; extra == 'dev'
Requires-Dist: pytest>=7.0 ; extra == 'test'
Requires-Dist: pytest-asyncio>=0.21 ; extra == 'test'
Requires-Dist: pytest-timeout>=2.1 ; extra == 'test'
Requires-Dist: vcrpy>=6.0.0 ; extra == 'test'
Requires-Dist: pytest-benchmark>=4.0.0 ; extra == 'test'
Requires-Dist: pyyaml>=6.0 ; extra == 'test'
Provides-Extra: dev
Provides-Extra: test
License-File: LICENSE-MIT
License-File: LICENSE-APACHE
Summary: Fugle market data REST and WebSocket client, powered by a Rust core
Keywords: fugle,marketdata,taiwan,stock,futures,options,websocket
Author: Fugle Team
License-Expression: MIT OR Apache-2.0
Requires-Python: >=3.8
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Changelog, https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/CHANGELOG.md
Project-URL: Homepage, https://developer.fugle.tw
Project-URL: Repository, https://github.com/fugle-dev/fugle-marketdata-sdk

# fugle-marketdata

Fugle market data REST and WebSocket client for Python, powered by a Rust
core through PyO3.

## Installation

```bash
pip install --pre fugle-marketdata   # 3.x pre-release
```

Wheels are published for CPython 3.8+ (abi3) on Linux glibc and musl
(x86_64, aarch64), macOS (x86_64, arm64) and Windows x64. There is no source
distribution: on other platforms pip installs the pure-Python 2.x SDK instead.
See [Unsupported platforms](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/docs/INSTALL.md#unsupported-platforms) for how
to pin `fugle-marketdata<3` or build 3.x from source.

### Upgrading from 2.x

Most 2.x code runs unchanged, but a few things behave differently. Read the
[migration guide](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/MIGRATION.md#migrating-from-the-legacy-fugle-marketdata-sdks)
before upgrading; the changes most 2.x code runs into:

- The WebSocket `message` callback receives a parsed `dict`, not a JSON
  string ([§3](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/MIGRATION.md#3-python-websocket-message-event-delivers-a-parsed-dict)).
  Code that calls `json.loads(message)` keeps working on the `raw_message`
  event, which delivers the string.
- `HealthCheckConfig` keeps its name but not its fields: `ping_interval` and
  `max_missed_pongs` are ignored, with a `FugleHealthCheckWarning`
  ([drop-in table](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/MIGRATION.md#drop-in-compatible-no-changes-needed)).
- Auto-reconnect is on by default; reconnect code of your own that calls
  `disconnect()` then `connect()` loops with it — keep one or the other
  ([§5](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/MIGRATION.md#5-auto-reconnect-is-on-by-default)).
- `connect()` raises `AuthError` when the credentials are rejected
  ([§9](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/MIGRATION.md#9-python-connect-raises-autherror-on-auth-failure)).
- A callback that blocks (for example `time.sleep()` in a `disconnect`
  callback) holds up all events, and messages are dropped once the queue is
  full ([§15](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/MIGRATION.md#15-python-websocket-callbacks-run-one-at-a-time-on-one-thread)).
- `str(e)` of an exception is its message, without 2.x's `[Fugle API Error]`
  prefix and `URL:` / `Status:` lines
  ([§6](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/MIGRATION.md#6-python-exception-hierarchy-is-finer-grained)).
- A `subscribed` message's `data` can be a list
  ([§16](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/MIGRATION.md#16-python-subscribed-data-can-be-a-list)).

### Development Build

```bash
# Create virtual environment
python -m venv .venv
source .venv/bin/activate

# Install maturin
pip install maturin

# Build and install in development mode
cd py
maturin develop
```

### Release Build From Source

```bash
maturin build --release
pip install target/wheels/fugle_marketdata-*.whl
```

## Quick Start

### REST API

```python
from fugle_marketdata import RestClient, MarketDataError

# Create client with API key
client = RestClient(api_key="your-api-key")

# Get stock quote
quote = client.stock.intraday.quote("2330")
print(f"TSMC Price: {quote['closePrice']}")
print(f"Change: {quote['change']}")
print(f"Volume: {quote['total']['tradeVolume']}")

# Get stock ticker info
ticker = client.stock.intraday.ticker("2330")
print(f"Name: {ticker['name']}")

# Get intraday candles (5-minute)
candles = client.stock.intraday.candles("2330", timeframe="5")
for candle in candles['data'][:3]:
    print(f"  {candle['date']}: O={candle['open']} H={candle['high']} L={candle['low']} C={candle['close']}")

# Get recent trades
trades = client.stock.intraday.trades("2330")
for trade in trades['data'][:5]:
    print(f"  Price: {trade['price']}, Size: {trade['size']}")

# FutOpt (futures/options) data: contract symbols expire, so look up the
# TAIEX futures (TXF) contracts listed today and take the nearest month
txf = client.futopt.intraday.tickers(type="FUTURE", product="TXF", is_spread=False)
front = min(txf['data'], key=lambda t: t['settlementDate'])['symbol']
futopt_quote = client.futopt.intraday.quote(front)
print(f"{front} Price: {futopt_quote['closePrice']}")
```

### WebSocket Streaming

```python
from fugle_marketdata import WebSocketClient
import time

# Create WebSocket client
ws = WebSocketClient(api_key="your-api-key")

# --- Callback Mode ---

def on_message(msg):
    """Handle incoming messages"""
    if msg.get('event') == 'data':
        channel = msg.get('channel')
        data = msg.get('data', {})
        symbol = data.get('symbol')
        print(f"[{channel}] {symbol}: {data}")

def on_connect():
    print("Connected!")

def on_disconnect(code, reason):
    info = ws.stock.last_disconnect  # who closed it, and whether a reconnect follows
    print(f"Disconnected: {code} - {reason} ({info.intent}, will_reconnect={info.will_reconnect})")

def on_error(err):  # a WebSocketError
    print(f"Error [{err.code}]: {err}")

# Register callbacks
stock = ws.stock
stock.on("message", on_message)
stock.on("connect", on_connect)
stock.on("disconnect", on_disconnect)
stock.on("error", on_error)

# Connect and subscribe
stock.connect()
stock.subscribe("trades", "2330")
stock.subscribe("books", "2330")

# Keep running for 10 seconds
time.sleep(10)

# Disconnect
stock.disconnect()

# --- Iterator Mode ---

ws2 = WebSocketClient(api_key="your-api-key")
stock2 = ws2.stock
stock2.connect()
stock2.subscribe("trades", "2330")

# Iterate over messages
for msg in stock2.messages():
    print(msg)
    # Add break condition as needed
```

## Authentication

Three authentication methods are supported:

```python
from fugle_marketdata import RestClient

# 1. API Key (most common)
client = RestClient(api_key="your-api-key")

# 2. Bearer Token
client = RestClient(bearer_token="your-bearer-token")

# 3. SDK Token
client = RestClient(sdk_token="your-sdk-token")
```

### Changing credentials

A token that expires can be replaced without building a new client.
`set_credentials` takes the same keyword arguments, exactly one of them, and
any kind may replace another; zero, several or a blank value raises
`ConfigError` (code 1004) and keeps the current credential.

```python
# WebSocket: from the next connection attempt on — the next connect() or
# automatic reconnect. The live connection is not authenticated again, so
# call it before the token expires.
ws.set_credentials(sdk_token=new_token)        # ws.stock and ws.futopt
ws.stock.set_credentials(sdk_token=new_token)  # ws.stock only

# REST: from the next request on, product clients taken before included.
client.set_credentials(sdk_token=new_token)
```

Rejected credentials still end automatic reconnection; set the new
credential, then call `connect()` again.

## Configuration

### Reconnection Config

Auto-reconnect is on by default: after an unexpected drop the client
reconnects with exponential backoff, without an attempt limit (waits capped
at `max_delay_ms`), and subscribes again. A normal closure by the server
(close code 1000) and rejected credentials end the connection for good and
are not reconnected; every other close is. Pass `reconnect` to tune it:

```python
from fugle_marketdata import WebSocketClient, ReconnectConfig

# Create custom reconnect configuration
reconnect = ReconnectConfig(
    max_attempts=10,
    initial_delay_ms=2000,
    max_delay_ms=120000
)

ws = WebSocketClient(api_key="your-key", reconnect=reconnect)

# Turn auto-reconnect off
ws = WebSocketClient(api_key="your-key", reconnect=ReconnectConfig.disabled())
```

**ReconnectConfig Options:**

- `enabled` (bool): Whether auto-reconnect is enabled (default: True)
- `max_attempts` (int): Maximum reconnection attempts; 0 means unlimited
  (default: 0). With a limit, the `error` callback reports code 3005 once the
  last attempt fails; whatever the limit, it also does so when a reconnect
  attempt's credentials are rejected (after `unauthenticated`)
- `initial_delay_ms` (int): Initial delay for exponential backoff (default: 1000ms, min: 100ms)
- `max_delay_ms` (int): Maximum delay cap (default: 60000ms)

`connect()` / `connect_async()` during an automatic reconnect opens no
connection of its own: it waits for the reconnect and returns once the
connection is back and the subscriptions have been re-sent (#230). So code
that calls `connect()` and subscribes again from a `disconnect` callback
keeps working; the repeated subscriptions are harmless
([migration guide §5](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/MIGRATION.md#5-auto-reconnect-is-on-by-default)).
Called from a callback, it holds up that client's callbacks until the
reconnect ends. It raises if the reconnect does not come back:
`WebSocketError` with code 2010 on `disconnect()`, code 3005 when the
attempts run out, and `AuthError` when the credentials are rejected.

Reconnect code that first calls `disconnect()` — typically a background
thread that runs `disconnect()` then `connect()` after the `disconnect`
callback set a flag — does not mix with auto-reconnect: it closes the
connection the SDK has just restored, which fires `disconnect` again, and
the two loop forever (#226). Remove that code or turn auto-reconnect off.
When `connect()` follows, within 30 seconds, a `disconnect()` that closed a
connection auto-reconnect restored less than 30 seconds earlier, the SDK
issues a `RuntimeWarning` (once per client) pointing at this. A
`disconnect()` with no `connect()` after it is not warned about.

To tell whether the SDK will reconnect after a `disconnect`, read
`ws.stock.last_disconnect.will_reconnect` in the callback rather than the
close code (#293): `True` means a `reconnect` follows, `False` that the
connection is over. A reconnect that gives up ends with an `error` of code
3005, not another `disconnect`.

### Health Check Config

Liveness detection is on by default: when no inbound frame (data, heartbeat or
pong) arrives within `heartbeat_timeout_ms`, the connection is declared dead
and auto-reconnect takes over. The server sends a heartbeat every 30 seconds.

```python
from fugle_marketdata import WebSocketClient, HealthCheckConfig

# Longer timeout
health_check = HealthCheckConfig(heartbeat_timeout_ms=60000)
ws = WebSocketClient(api_key="your-key", health_check=health_check)

# Turn liveness detection off
ws = WebSocketClient(api_key="your-key", health_check=HealthCheckConfig(enabled=False))

# Confirm with a ping before disconnecting; know within 10 seconds
ws = WebSocketClient(api_key="your-key",
                     health_check=HealthCheckConfig(probe_enabled=True,
                                                    idle_probe_after_ms=5000,
                                                    probe_timeout_ms=5000))

# Round trip on demand, in milliseconds (default timeout 5000), once connected
ws.stock.connect()
latency = ws.stock.measure_latency()
```

With `probe_enabled`, a silent connection is asked before it is declared dead:
after `idle_probe_after_ms` of silence one ping is sent, and only if nothing
arrives within `probe_timeout_ms` is the connection declared dead. The defaults
keep detection at 35 seconds and send no ping while the server's heartbeat is on
time, so turning on `probe_enabled` alone only removes false disconnects caused
by a late heartbeat.

**HealthCheckConfig Options:**

- `enabled` (bool): Whether health check is enabled (default: True)
- `heartbeat_timeout_ms` (int): Maximum gap between inbound frames before the
  connection is declared dead (default: 35000ms, min: 5000ms). **Does not
  apply when `probe_enabled` is True.**
- `probe_enabled` (bool): Confirm with a ping before declaring the connection
  dead (default: False). Detection is `idle_probe_after_ms + probe_timeout_ms`.
- `idle_probe_after_ms` (int): Silence before the ping (default: 30000ms, the
  server's heartbeat period; min: 5000ms). Below 30000 a ping is sent in every
  gap between heartbeats while no data flows.
- `probe_timeout_ms` (int): Wait for any inbound frame after the ping
  (default: 5000ms, min: 1000ms)

Probing does not detect a half-open connection (the server still sends, our
writes no longer arrive). See [docs/configuration.md](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/docs/configuration.md#healthcheckconfig--healthcheckoptions)
for the trade-offs and the server cost of short probe intervals.

### Auth Timeout

```python
ws = WebSocketClient(api_key="your-key", auth_timeout_ms=15000)  # default 10000
```

Once the WebSocket is open the client sends its auth frame and waits for the
server's verdict for `auth_timeout_ms` (default 10 s). It applies to the
first connect and to every reconnect; elapsing it fails the attempt with
`TimeoutError` (code 3001). Raise it on a slow route to the server; the
server itself allows 60 s. It must be greater than 0 (`ConfigError`, code
1004, otherwise).

### Combined Configuration

```python
from fugle_marketdata import WebSocketClient, ReconnectConfig, HealthCheckConfig

reconnect = ReconnectConfig(max_attempts=10, initial_delay_ms=2000)
health_check = HealthCheckConfig(heartbeat_timeout_ms=60000)

ws = WebSocketClient(
    api_key="your-key",
    reconnect=reconnect,
    health_check=health_check,
    auth_timeout_ms=15000,
)
```

## API Reference

### RestClient

Every method below blocks and returns the response as a `dict`. Each has an
`_async` sibling that returns a coroutine instead, for asyncio code:
`quote_async`, `candles_async`, ... (`await client.stock.intraday.quote_async("2330")`).

#### Stock

```python
client.stock.intraday.quote(symbol)          # Real-time quote
client.stock.intraday.ticker(symbol)         # Symbol information
client.stock.intraday.tickers(type)          # Symbol list ("EQUITY", "INDEX", ...)
client.stock.intraday.candles(symbol, timeframe="1")  # OHLCV candles
client.stock.intraday.trades(symbol)         # Trade history
client.stock.intraday.volumes(symbol)        # Volume by price

client.stock.historical.candles(symbol, from_date=..., to_date=..., timeframe="D")
client.stock.historical.stats(symbol)        # 52-week statistics

client.stock.snapshot.quotes(market)         # Quotes of a whole market ("TSE", "OTC", ...)
client.stock.snapshot.movers(market, direction, change)  # Top gainers / losers
client.stock.snapshot.actives(market, trade) # Most active by "volume" / "value"

client.stock.technical.sma(symbol, period)   # also rsi(symbol, period), bb(symbol, period),
client.stock.technical.kdj(symbol, r_period, k_period, d_period)
client.stock.technical.macd(symbol, fast, slow, signal)

client.stock.ownership.etf_holdings(symbol)
client.stock.ownership.institutional_trades(symbol)
client.stock.ownership.director_holdings(symbol)
client.stock.ownership.tdcc_distribution(symbol)

client.stock.corporate_actions.capital_changes()   # all keyword arguments
client.stock.corporate_actions.dividends()
client.stock.corporate_actions.listing_applicants()
```

#### FutOpt

```python
client.futopt.intraday.quote(symbol)         # Real-time quote
client.futopt.intraday.ticker(symbol)        # Contract information
client.futopt.intraday.tickers(type)         # Contract list ("FUTURE" or "OPTION")
client.futopt.intraday.candles(symbol, timeframe="1")  # OHLCV candles
client.futopt.intraday.trades(symbol)        # Trade history
client.futopt.intraday.volumes(symbol)       # Volume by price
client.futopt.intraday.products(type)        # Product listing ("FUTURE" or "OPTION")

client.futopt.historical.candles(product, contract_month="1!")  # "TXF"; also product=...
client.futopt.historical.daily(product, date=...)               # one day, a row per contract
```

#### Client

```python
RestClient(api_key=...)                      # or bearer_token= / sdk_token=, plus base_url=
RestClient.with_bearer_token(token)          # same as RestClient(bearer_token=token)
RestClient.with_sdk_token(sdk_token)         # same as RestClient(sdk_token=sdk_token)
client.base_url                              # https://api.fugle.tw/marketdata/v1.0
client.stock.base_url                        # .../v1.0/stock (client.futopt.base_url: .../v1.0/futopt)
```

#### Query parameters

Every REST method takes the endpoint's query parameters as keyword arguments,
under the 3.x snake_case names, the API's own names as documented on
developer.fugle.tw (the spelling the 2.x SDK used), or the 2.x `from_` alias
for the reserved word:

```python
client.stock.intraday.trades("2330", limit=5, sort="asc", is_trial=True)
client.stock.intraday.trades("2330", limit=5, sort="asc", isTrial=True)   # same call
client.stock.intraday.ticker("2330", odd_lot=True)
client.stock.intraday.ticker("2330", type="oddlot")                       # same call
client.stock.technical.sma("2330", from_date="2026-08-01", to_date="2026-09-10", period=5)
client.stock.technical.sma("2330", from_="2026-08-01", to="2026-09-10", period=5)  # same call
```

Only the path parameter (`symbol`, `market`, `type`) and the query
parameters the server requires (`movers(market, direction, change)`,
`actives(market, trade)`, the technical periods — `sma(symbol, period)`,
`kdj(symbol, r_period, k_period, d_period)`, `macd(symbol, fast, slow,
signal)`) may be passed positionally; every optional one is keyword-only,
so `candles("2330", "5")` is a `TypeError` rather than a slot to get wrong.

A keyword the endpoint does not take raises `TypeError` naming the accepted
ones, and so does one parameter given under two spellings. Values are sent as
given and the server reports a bad value, except the two switches behind a
boolean keyword: `type` must be `"oddlot"` and `session` `"afterhours"` /
`"regular"`, or `ValueError`.

Type checkers (mypy, pyright) only know the snake_case keywords: the stubs
list them and no `**kwargs`, so `isTrial=` or `from_=` is flagged even though
it works at runtime. Use the snake_case names in code you type-check; the
other spellings are a runtime compatibility layer for 2.x call sites.

### WebSocketClient

#### Properties

```python
ws.stock    # Access StockWebSocketClient
ws.futopt   # Access FutOptWebSocketClient
```

#### StockWebSocketClient / FutOptWebSocketClient Methods

```python
client.connect()                           # Connect to server
client.disconnect()                        # Disconnect from server
client.is_connected()                      # Check connection status
client.is_closed()                         # Check if client is closed
client.url                                 # Resolved endpoint, e.g. wss://api.fugle.tw/marketdata/v1.0/stock/streaming

client.subscribe(channel, symbol)          # Subscribe to channel
client.subscribe(channel, symbols=[...])   # ... for several symbols in one frame
client.unsubscribe(subscription_id)        # Unsubscribe by server ID (or ids=[...])
client.unsubscribe(channel=channel, symbol=symbol)  # Unsubscribe by subscribe() arguments
client.subscriptions()                     # Ask the server for its list: returns None, the reply
                                           #   arrives as a message ({"event": "subscriptions", ...})
client.local_subscriptions()               # The SDK's own list of active subscriptions, returned now

client.on(event, callback)                 # Register event callback (not async def)
client.off(event, listener=None)           # Unregister one callback (==), or all for the event

client.messages()                          # Get message iterator
client.messages(raw=True)                  # ... yielding each message as the str the server sent

client.ping(state=None)                    # Send a ping frame
client.measure_latency(timeout_ms=None)    # Round trip to the server, in milliseconds
```

Call `disconnect()` before letting go of a client: dropping it does not
close the connection. While connected, the client's background thread keeps
its callbacks alive and keeps calling them, so a callback that refers to the
client (a closure over `ws`, say) keeps the client alive as well. Once
`disconnect()` has returned, the garbage collector frees such a client (after
a `disconnect()` from a callback, once that thread has finished).

Both clients also have async versions, which do not block the event loop:

```python
await client.connect_async()
await client.disconnect_async()
await client.subscribe_async(channel, symbol)  # odd_lot= (stock) / after_hours= (futopt)
await client.measure_latency_async(timeout_ms=None)

async with ws.futopt as client:            # connect_async() on entry, disconnect_async() on exit
    await client.subscribe_async("trades", front)  # a contract symbol, e.g. from Quick Start's REST example
    async for msg in client.messages():
        print(msg)
```

#### Event Types

| Event | Callback Signature | Description |
|-------|-------------------|-------------|
| `message` | `fn(msg: dict)` | Incoming data message |
| `raw_message` | `fn(raw: str)` | The same message as the text the server sent, not turned into a dict |
| `connect` | `fn()` | Connection established |
| `authenticated` | `fn(frame: dict)` | Credentials accepted; the server's frame as in 2.x, `{"event": "authenticated", "data": {...}}` |
| `unauthenticated` | `fn(frame: dict)` | Credentials rejected; the server's frame as in 2.x, `{"event": "error", "code": 1000, "data": {"message": ...}}` |
| `disconnect` | `fn(code: int, reason: str)` | Connection closed |
| `reconnect` | `fn(attempt: int)` | An automatic reconnect attempt starts (1, 2, ...) |
| `error` | `fn(err: WebSocketError)` | Error occurred (`err.args == (message, code)`, `str(err)` is the message) |
| `messages_dropped` | `fn(dropped: int, total: int)` | Messages dropped because you fell behind (at most once per second, and before `disconnect`) |

These are the events `on()` accepts; any other name raises `ValueError`.
Names are matched ignoring case, and `data`, `connected`,
`disconnected` / `close` / `closed` and `reconnecting` are accepted as
aliases of `message`, `connect`, `disconnect` and `reconnect`.

`disconnect`'s `code` is `None` when the connection ended without one. The
rest of the story is `ws.stock.last_disconnect` / `ws.futopt.last_disconnect`
(#293), a `DisconnectInfo` or `None` before the first disconnect:

| Attribute | Value |
|-----------|-------|
| `code` | `int` or `None`, as passed to `disconnect` |
| `reason` | `str`, as passed to `disconnect` |
| `intent` | `"client"` (your `disconnect()`), `"server"` (the server's Close frame, any code) or `"network"` (transport error, EOF without a Close frame, heartbeat timeout) |
| `will_reconnect` | `True` if a `reconnect` follows (unless you call `disconnect()` first), `False` if the connection is over |

It is set before the `disconnect` callbacks run, so a callback reads the
disconnect it is handling, and it is never cleared: it is a record of the last
disconnect, not the connection state (ask `is_connected()` for that). After a
`messages()` iterator ends, it tells you why. A reconnect that gives up
(`error` 3005) leaves it at the drop that started the reconnect.
If you call `disconnect()` in a callback and `connect()` right after, the old
connection's last disconnect can be written after the new connection's, as it
is handed to the callbacks after it; for the connection state use
`is_connected()`.

#### Raw Messages

```python
stock.on("raw_message", lambda raw: sink.write(raw))   # raw is a str

for raw in stock.messages(raw=True):                   # or: async for
    sink.write(raw)
```

`raw_message` hands you each message as the JSON text the server sent;
`json.loads(raw)` equals the dict `message` gets for it. Use it to forward or
store messages, to filter before parsing, or to parse with your own JSON
library. With only `raw_message` callbacks registered the SDK builds no dict.
With both, each message goes to `raw_message` first, then to `message`.
`messages(raw=True)` is the iterator counterpart; `raw` is keyword-only and
belongs to that iterator, so `messages()` on the same client still yields
dicts. A raw and a non-raw iterator open on one client read the same queue:
each message goes to only one of them.

#### Callback Exceptions

An exception raised in a callback does not stop the connection or later
callbacks. It is passed to the `error` callbacks as a `WebSocketError` with
code 3004, `err.event` (the event whose callback raised, e.g. `"message"`),
`err.count` and the original exception as `err.__cause__`:

```python
def on_error(err):
    if err.code == 3004:
        print(f"{err.event} callback failed {err.count}x:", repr(err.__cause__))
```

The first failure is reported at once, later ones at most once per second,
each report counting the failures since the previous one; failures after the
last report are not reported on their own. Without an `error` callback, or
when it raises too, the failure goes to `sys.unraisablehook` (printed with its
traceback by default). `KeyboardInterrupt`, `SystemExit` and other
`BaseException`s that are not `Exception`s are only printed that way.

Callbacks run on the SDK's thread and must be regular functions: `on()`
raises `TypeError` for an `async def` callback, and a callback that returns a
coroutine is reported as a failure. Use `async for msg in stock.messages()`
in asyncio code.

All of a client's callbacks run one at a time on that one thread, so a
callback that blocks (a `time.sleep()` or retry loop in `disconnect`, say)
holds up every later event, and messages past `message_buffer` are dropped
meanwhile (see Message Queue below and
[migration guide §15](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/MIGRATION.md#15-python-websocket-callbacks-run-one-at-a-time-on-one-thread)).
Hand slow work to your own thread.

`disconnect()` returns once the stream readers of the connection it closes,
of a connect it aborts, and of earlier connections left for it have ended,
so their `disconnect` callbacks have fired. A connection that another
thread's `connect()` opens meanwhile is not waited for.

Called from a callback of any client — this one or another, such as a
`ws.stock` callback calling `ws.futopt.disconnect()` — it waits for no stream
reader, since two callbacks waiting for each other's would never return. No
`disconnect` callback is guaranteed to have fired when it returns; those
readers are left to the next `disconnect()` called outside a callback. Do not
wait for `disconnect_async()` synchronously from a callback (with
`asyncio.run(...)`, say): it waits for the callback's own reader and never
completes.

#### Message Queue

```python
ws = WebSocketClient(
    api_key="key",
    message_overflow="drop_newest",  # default; or "unbounded"
    message_buffer=4096,             # unread messages held (default 4096)
)
ws.stock.on("messages_dropped", lambda dropped, total: print(dropped, total))
ws.stock.messages_dropped_total()    # this connection's drops; still readable after disconnect()
```

With `"drop_newest"`, while `message_buffer` messages are unread (your
`message` callback or iterator is behind), new messages are dropped, counted
and reported through `messages_dropped`. `"unbounded"` never drops; memory
grows for as long as you lag.

#### Channels

| Channel | Description |
|---------|-------------|
| `trades` | Real-time trade executions |
| `candles` | Candlestick updates |
| `books` | Order book (5 levels) |
| `aggregates` | Aggregated market data |
| `indices` | Index values (stock only) |

### MessageIterator

```python
# Get iterator from connected client
messages = stock.messages()

# Iterate (blocking): yields messages only, stops once the connection is gone
for msg in messages:
    print(msg)

# Async iteration
async for msg in stock.messages():
    print(msg)

# Manual iteration
msg = next(messages)          # Blocking until a message arrives
msg = messages.try_recv()     # Non-blocking, returns None if no message
msg = messages.recv_timeout(5000)  # Waits up to 5000 ms, returns None on timeout
```

Iteration never yields `None` and does not end while no data arrives; it
raises `StopIteration` / `StopAsyncIteration` once the connection is gone. A
blocked `for` loop or `recv_timeout` still reacts to Ctrl+C, and cancelling
an `async for` step that is still waiting takes no message (steps that are
already done are described below). `messages(timeout_ms=...)` is deprecated
and ignored. For periodic
work while no data arrives, use `message` callbacks or `async for` alongside
other tasks.

While messages are queued, `async for` reads them without waiting on the
event loop, and lets other tasks on the loop run once every 32 messages. It
does so because `messages().__anext__()` then returns an awaitable that is
already done and holds the message. `async for` and a plain `await` of it
lose no message; code that handles the awaitable itself must not discard a
done one:

```python
step = asyncio.ensure_future(messages.__anext__())
done, _ = await asyncio.wait({step, stop}, return_when=asyncio.FIRST_COMPLETED)
if step.done():          # check this first, whatever else finished
    handle(step.result())
else:
    step.cancel()        # pending: takes no message
```

- `step.cancel()` returns False for a done awaitable; the message is
  `step.result()`.
- Cancelling "the rest" after `asyncio.wait` because `stop` finished drops
  the message a done `step` holds.
- A cancelled `asyncio.gather(messages.__anext__(), other)` drops the
  message its done step holds.
- Awaitables of one iterator held at the same time may not get the messages
  in the order they were created. A single `async for` gets them in order.

`asyncio.wait_for(messages.__anext__(), timeout)`:

- loses no message when `timeout` is longer than one turn of the event loop,
  in practice about 1 ms or more. `timeout=0` returns the message when one
  is queued instead of always timing out.
- with a shorter `timeout`, can lose a queued message whose delivery was left
  to the loop (one in 32): the awaitable is resolved one turn before the
  waiting task resumes, and a timeout that expires within that turn cancels
  the task with the message already in the awaitable. Seen on Python 3.12
  with `timeout=1e-6`.
- up to Python 3.11, spends a turn of the loop even on a done awaitable. A
  task cancelled in that turn gets the message back instead of
  `CancelledError` (3.8.6 and later; run on 3.8.10 only), so `task.cancel()`
  on a `while True: await wait_for(...)` loop may only take effect at a step
  with nothing queued. Python 3.8.0 to 3.8.5 raise `CancelledError` there
  and drop that message.

Messages go to `message` and `raw_message` callbacks when any are registered
as they arrive, otherwise to the iterator. The iterator holds at most 4096
unread messages; while it does, lifecycle callbacks (`disconnect`,
`reconnect`, …) that follow those messages wait until you read or call
`disconnect()`. Every wait releases the GIL.

## Error Handling

All API errors raise `MarketDataError` (or a subclass):

```python
from fugle_marketdata import RestClient, MarketDataError

client = RestClient(api_key="invalid-key")

try:
    quote = client.stock.intraday.quote("2330")
except MarketDataError as e:
    print(f"Error [{e.code}] {e.source_kind}: {e.message}")
    if e.status is not None:
        print(e.status, e.body, e.headers.get("retry-after"))
```

Every exception carries the same fields as the other languages:

| Attribute | Type | |
|---|---|---|
| `code` | `int` | Error code (table below); also `args[1]` |
| `source_kind` | `str` | `"network"`, `"protocol"`, `"auth"`, `"rate_limit"` or `"client"` |
| `message` | `str` | Human-readable message; also `args[0]` and `str(e)` (2.x's `str(e)` also had the URL, status and response: see [§6](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/MIGRATION.md#6-python-exception-hierarchy-is-finer-grained)) |
| `status` | `int \| None` | HTTP status |
| `body` | `str \| None` | Raw HTTP response body (REST) |
| `request_id` | `str \| None` | `x-request-id` response header |
| `headers` | `dict[str, str]` | HTTP response headers, lowercase names (REST; else empty) |

`status_code` and `response_text` remain as aliases of `status` and `body`
for code written against the 2.4.1 `FugleAPIError`. The WebSocket `error`
callback receives a `WebSocketError` with these fields, whose `args` stay
`(message, code)`. See the
[error reference](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/docs/errors.md) for all languages.

Every exception class is a `MarketDataError`: `ApiError` (with
`RateLimitError` under it), `AuthError`, `ConfigError`, `ConnectionError`,
`TimeoutError` and `WebSocketError`. None of them is a built-in `ValueError`.
An invalid `ReconnectConfig` or `HealthCheckConfig` value and the credential
rule raise `ConfigError` (code 1004); the other constructor keyword checks
(`base_url`, TLS options, `message_buffer`, ...) and the REST argument checks
keep their built-in `TypeError` / `ValueError`.

### Error Codes

| Code | Error Type | Description |
|------|------------|-------------|
| 1001 | InvalidSymbol | Invalid or unsupported symbol |
| 1002 | DeserializationError | JSON parsing failed |
| 1003 | RuntimeError | Internal runtime error |
| 1004 | ConfigError | Invalid configuration: not exactly one credential, or a `ReconnectConfig` / `HealthCheckConfig` value below its floor |
| 1005 | InvalidParameter | Invalid or missing parameter (including an unknown WebSocket channel) |
| 2001 | ConnectionError | A REST request cannot reach the server (DNS, connection refused, TLS), a WebSocket command is sent while the connection is down (not before `connect()` or after `disconnect()`: that is a `RuntimeError`), or the WebSocket auth handshake fails for a reason other than rejected credentials |
| 2002 | AuthError | Authentication failed |
| 2003 | ApiError | API returned error response |
| 2010 | ClientClosed, ConnectionAborted | Client has been closed, or `connect()` / `connect_async()` was given up because `disconnect()` was called before the connection was established or while it waited on an automatic reconnect (raised as `WebSocketError`, message `Connection aborted: …`) |
| 2011 | AlreadyConnected | `connect()` / `connect_async()` called while connected or while another connect is in progress; during an automatic reconnect it waits instead (raised as `WebSocketError`) |
| 2012 | ConnectionLimit | The server refused the WebSocket connection because the account is at its connection limit (Close 1013, or `error` 1003 / Close 1001 with the message `Maximum number of connections reached`). `connect()` / `connect_async()` raise `ConnectionError`; the `error` callback receives `WebSocketError`; both with code 2012. Automatic reconnect keeps retrying (see [configuration](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/docs/configuration.md#reconnectconfig--reconnectoptions)) |
| 3001 | TimeoutError | Operation timed out |
| 3002 | WebSocketError | WebSocket connect, read or write failed |
| 3003 | HeartbeatTimeout | No inbound WebSocket frame within the heartbeat window. Not reported as an `error`: `disconnect` fires with code `None` and the reason `Heartbeat timeout after …`, and `last_disconnect.intent` is `"network"` |
| 3004 | CallbackFailed | A WebSocket callback raised an exception (`error` callback only) |
| 3005 | ReconnectFailed | Reconnection gave up: after the last attempt, or because an attempt's credentials were rejected (`error` callback); a `connect()` / `connect_async()` waiting on the reconnect raises it (`WebSocketError`) after the last attempt |
| 9999 | Other | Unexpected error |
| -1 | ThreadPanic | A WebSocket worker thread panicked (`error` callback only) |

## Examples

### Full REST Example

```python
from fugle_marketdata import RestClient, MarketDataError
import os

def main():
    api_key = os.environ.get("FUGLE_API_KEY")
    if not api_key:
        print("Set FUGLE_API_KEY environment variable")
        return

    client = RestClient(api_key=api_key)

    try:
        # Stock data
        print("=== Stock Market Data ===")
        quote = client.stock.intraday.quote("2330")
        print(f"TSMC Quote: {quote['closePrice']}")

        ticker = client.stock.intraday.ticker("2330")
        print(f"Ticker: {ticker['name']}")

        candles = client.stock.intraday.candles("2330", timeframe="5")
        print(f"Candles: {len(candles['data'])} entries")

        # FutOpt data
        print("\n=== FutOpt Market Data ===")
        products = client.futopt.intraday.products("FUTURE")
        print(f"Futures products: {len(products['data'])}")

    except MarketDataError as e:
        print(f"Error [{e.code}]: {e}")

if __name__ == "__main__":
    main()
```

### Full WebSocket Example

```python
from fugle_marketdata import WebSocketClient, MarketDataError
import os
import time

def main():
    api_key = os.environ.get("FUGLE_API_KEY")
    if not api_key:
        print("Set FUGLE_API_KEY environment variable")
        return

    ws = WebSocketClient(api_key=api_key)
    stock = ws.stock

    message_count = 0

    def on_message(msg):
        nonlocal message_count
        message_count += 1
        if msg.get('event') == 'data':
            print(f"[{message_count}] {msg.get('channel')}: {msg['data'].get('symbol')}")

    def on_connect():
        print("Connected!")

    def on_error(err):  # a WebSocketError
        print(f"Error [{err.code}]: {err}")

    stock.on("message", on_message)
    stock.on("connect", on_connect)
    stock.on("error", on_error)

    try:
        stock.connect()
        stock.subscribe("trades", "2330")
        stock.subscribe("books", "2330")

        print("Listening for 10 seconds...")
        time.sleep(10)

        print(f"\nReceived {message_count} messages")
        print(f"Subscriptions: {stock.local_subscriptions()}")

    except MarketDataError as e:
        print(f"Error [{e.code}]: {e}")
    finally:
        if stock.is_connected():
            stock.disconnect()
        print("Done")

if __name__ == "__main__":
    main()
```

## Requirements

- Python 3.8+
- Rust toolchain (for building from source)
- maturin (for development builds)

## License

Licensed under either of [MIT](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/LICENSE-MIT) or [Apache-2.0](https://github.com/fugle-dev/fugle-marketdata-sdk/blob/main/LICENSE-APACHE), at your option.

