Metadata-Version: 2.4
Name: Fabrisk
Version: 0.1.3
Summary: Official Python client for the FabRisk API
Author: 42 Advisors
License-Expression: LicenseRef-Proprietary
Project-URL: Homepage, https://fabrisk-crypto.42qlabs.com
Project-URL: Documentation, https://fabrisk-crypto.42qlabs.com/crypto
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Operating System :: OS Independent
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx<1,>=0.27
Dynamic: license-file

# Fabrisk

Official Python client for the FabRisk API.

## Install

```bash
pip install fabrisk
```

FabRisk supports Python 3.11 through 3.14.

## Create an API key

A FabRisk administrator creates client accounts and their library keys in
**Admin → Users**. Ask your administrator for the generated key. The full key
is displayed only once.

## Use

```python
from fabrisk import FabriskClient

with FabriskClient(api_key="frk_live_...") as client:
    latest = client.latest_model_date()
    print(latest)
```

## Calling any operation

`FabriskClient.request()` is the canonical path and covers **every** supported
operation. `latest_model_date()`, `universe()`, `portfolio_risk()` and
`backtest()` are shortcuts for the four most frequent calls; the package
deliberately stops there rather than mirroring the whole catalog as methods.
Everything else is reached by name through `request()`, which is the supported
way to call it — not a workaround.

Version 0.1.3 adds the reference workspace through the same authenticated API.
Use `reference_models`, `reference_histories`, `reference_create_history`,
`reference_create_model`, `reference_compute`, `reference_analyze` and the
`reference_job` / `reference_analysis_table` readers. `available_operations()`
lists all 46 supported operations. Dynamic IDs belong in `path_params`:

```python
models = client.request("reference_models")
job = client.request(
    "reference_analyze",
    json_body={
        "subject_id": "catalog:btc",
        "model_id": "cryp1-core",
        "as_of_date": "2026-09-14",
        "forecast_only": True,
    },
    request_token="btc-forecast-1",
)
status = client.request("reference_job", path_params={"job_id": job["id"]})
# After status["status"] == "succeeded":
risk = client.request(
    "reference_analysis_table",
    path_params={"analysis_id": status["result_id"], "table": "risk"},
    query={"scope": "subject"},
)
```

Choose an as-of date supported by the selected model. Workspace writes are
never retried automatically, because they persist records or submit jobs.
Calibration/analysis request tokens reuse identical requests; change the token
to explicitly request another run. Read operations retain bounded retries.
Portfolio weights in `reference_create_history` are percentages summing to
100 on each effective date. The package performs no local financial calculation.

```python
from fabrisk import FabriskClient, available_operations

print(available_operations())

with FabriskClient(api_key="frk_live_...") as client:
    result = client.request(
        "portfolio_risk",
        json_body={"assets": ["BTC", "ETH"], "weights": [0.6, 0.4]},
    )
```

The package contains only the public client. It does not ship the FabRisk
administration, ETL, XL Bridge, internal models, or server route catalog.

## Errors

```
FabriskError                 base class -- catch this to catch everything
├── FabriskApiError          FabRisk answered with an error status
└── FabriskTransportError    no response: timeout, connection failure, redirect loop
```

`FabriskApiError` also inherits from `RuntimeError`, so code written against an
earlier release that catches `RuntimeError` keeps working.

Both carry `.status_code` (`None` for transport errors), `.request_id`, and
`.retryable`. `FabriskApiError` additionally carries:

- `.code`, the machine code of the failure, for example
  `optimization_infeasible`, `factor_exposure_infeasible`, `solver_failure`,
  `portfolio_ruined`, `max_sharpe_no_defined_ratio`, `asri_value_unavailable`,
  or an overload code such as `optimizer_overloaded`. Every JSON error body
  FabRisk sends carries four common keys, `error_code`, `message`, `retryable`
  and `retry_after_seconds`; `.code`, the exception message, `.retryable` and
  `.retry_after_seconds` are read from them (`.retryable` is also true for a
  429, 502, 503 or 504). An error that has no specific code reads
  `http_<status>` (for example `http_404`); the exception type of an unexpected
  500 is its code. Non-JSON error responses (for example the plain-text 400 of a
  rejected CORS preflight) and error bodies over 1 MiB do not carry these keys.
  A response without them has `.code` `None`. A JSON object without them (from
  a FabRisk server older than this contract, for example) keeps its message (a
  top-level `message`, a string `detail`, or the first validation error) and its retry delay (under
  the body, `detail` or `details`, else the `Retry-After` header); a response
  that is empty or not a JSON object (a proxy's HTML error page) has the
  message `FabRisk HTTP <status>` and the `Retry-After` header as its delay;
- `.details`, the error's detail object or validation list;
- `.body`, the decoded JSON error body exactly as FabRisk sent it (`None` when
  the body was empty or not JSON). Use it for fields the flat attributes do not
  carry, for example `body["detail"]["ruin_date"]` of a `portfolio_ruined` 422
  or `details["achievable_ranges"]` of a `factor_exposure_infeasible` 400;
- the quota fields `.retry_after_seconds`, `.rate_limit_limit`,
  `.rate_limit_remaining` and `.rate_limit_reset` (all `None` when the
  response did not carry them).

`FabriskTransportError.timed_out` is true when the call timed out. The request
may have reached FabRisk, which may still be running it.
`FabriskTransportError.request_sent` is true when the failure happened while
waiting for the answer (connection lost, read timeout), so FabRisk may have
received the request; it is false when the connection could not be opened or
the request could not be written. No `httpx` exception is ever propagated, and
no error message contains the API key, the URL, or a header.

```python
from fabrisk import FabriskApiError, FabriskClient, FabriskTransportError

try:
    result = client.request("optimize_markowitz", json_body=payload)
except FabriskTransportError as exc:
    print("network problem, retryable:", exc.retryable, "timed out:", exc.timed_out)
except FabriskApiError as exc:
    if exc.code == "optimization_infeasible":
        print("relax the constraints:", exc)
    else:
        print(exc.status_code, exc.code, exc.request_id)
```

## Timeouts

Each operation has a default deadline for the whole call, above the time
FabRisk may need to answer it. The client can then report FabRisk's own answer
rather than a client-side timeout:

| Operations | Default |
|------------|---------|
| `optimize_efficient_frontier`, `optimize_maximum_sharpe` | 130 s |
| every other `optimize_*` operation, `backtest_walkforward`, `backtest_walkforward_table` | 325 s |
| `backtest`, `portfolio_risk`, `stress_run`, `stress_compare` | 325 s |
| everything else | 30 s |

An optimizer request may first wait up to 25 s for a free optimizer slot, and a
backtest, portfolio-risk or stress request up to 25 s for a free data slot. The
frontier and maximum-Sharpe searches stop at server deadlines of 45 s. The
other optimizer operations, the walk-forward runs, and the backtest,
portfolio-risk and stress runs are bounded by FabRisk's 300 s gateway timeout.
`FabriskClient(timeout=...)` sets one deadline for every operation, and
`request(..., timeout=...)` wins over both.

## Retries and Retry-After

Overloaded responses are retried automatically, with the same policy the FabRisk
gateway uses against the same backend:

- retried when the status is 429, 502, 503, or 504, **or** when the JSON body
  sets `retryable` (directly or under `detail`) — except a 504, a timeout or a
  lost connection of a heavy computation, see below;
- at most 2 extra attempts, which is also the hard cap on `max_retries=`;
- the delay is the body's `retry_after_seconds` if present, otherwise the
  `Retry-After` header, otherwise `0.4 × attempt` seconds — each capped at 5
  seconds. The body wins over the header on purpose: it comes from the overload
  gate that knows the real queue depth, whereas the header can be rewritten by
  an intermediate proxy;
- `timeout=` is a deadline for the **whole call**, retries and sleeps included.
  It is taken once when the call starts, and each attempt only gets what is left
  of it. A call with `timeout=30` returns or raises within about 30 seconds, no
  matter how many retries it made — and also when the response dribbles in one
  chunk at a time, which resets `httpx`'s per-read timeout indefinitely. When
  the deadline expires with no response at all you get
  `FabriskTransportError("FabRisk request timed out", retryable=True)` with
  `.timed_out` true;
- every attempt reuses the same `X-Request-Id`, so retries of one logical call
  stay correlated in FabRisk's logs.

Pass `max_retries=0` to disable retrying and handle overload yourself.
Transport failures that the server can recover from — connection cut mid-flight
by a rolling deploy or a load balancer, read/write errors, timeouts, client-side
pool exhaustion — are replayed on the same counter and the same deadline, except
for the heavy computations below. A redirect loop or a client closed under an
in-flight request is not: those raise `FabriskTransportError` with `.retryable`
false.

### Heavy computations are never resubmitted automatically

Every `optimize_*` operation, `backtest_walkforward`,
`backtest_walkforward_table`, `backtest`, `portfolio_risk`, `stress_run` and
`stress_compare` is **not** retried after a 504, a timeout, or a connection lost
while waiting for the answer. A 504 means that FabRisk's gateway stopped
waiting, not that the computation stopped, and a connection cut after the
request was sent does not stop it either. FabRisk may still be running the
first request and holding its slot, so a resubmit would start a second run. The
error is raised instead: a 504 as `FabriskApiError` with `.status_code == 504`,
a timeout as `FabriskTransportError` with `.timed_out` true, a lost connection
as `FabriskTransportError` with `.request_sent` true. Whether to call again is
your decision. For example, you can wait, then send the same request once, with
a longer `timeout=` if needed:

```python
import time

from fabrisk import FabriskApiError, FabriskTransportError

try:
    table = client.request("backtest_walkforward_table", json_body=payload)
except (FabriskApiError, FabriskTransportError) as exc:
    if (
        getattr(exc, "status_code", None) == 504
        or getattr(exc, "timed_out", False)
        or getattr(exc, "request_sent", False)
    ):
        time.sleep(60)  # give the first run time to finish
        table = client.request("backtest_walkforward_table", json_body=payload)
    else:
        raise
```

The other retry rules apply to these operations unchanged: 429, 502, 503
(including an `optimizer_overloaded` refusal, where the run never started),
retryable bodies, and connections that could not be opened are still retried.

### Pacing a batch on a 429

```python
import time

from fabrisk import FabriskApiError

try:
    result = client.request("optimize_markowitz", json_body=payload)
except FabriskApiError as exc:
    if exc.status_code == 429:
        # The real delay the server asked for, uncapped -- the 5-second cap only
        # bounds how long this client blocks on its own.
        time.sleep(exc.retry_after_seconds or 60)
        print("quota", exc.rate_limit_remaining, "of", exc.rate_limit_limit,
              "resets at", exc.rate_limit_reset)
```

## Proxies and TLS interception

Environment proxy variables are ignored (`trust_env` is off), so a stray
`HTTPS_PROXY` can never silently capture your API key. Behind a corporate egress
proxy, name it explicitly:

```python
client = FabriskClient(api_key="frk_live_...", proxy="http://proxy.corp.example:3128")
```

If your network also intercepts TLS (Zscaler, Netskope, Palo Alto), add the
corporate root with `verify=`. `SSL_CERT_FILE` is *not* read, for the same
reason environment proxies are not:

```python
import ssl

context = ssl.create_default_context(cafile=r"C:\corp\root.pem")
client = FabriskClient(
    api_key="frk_live_...",
    proxy="http://proxy.corp.example:3128",
    verify=context,
)
```

`verify=` accepts `True` (the default, system trust store) or an
`ssl.SSLContext`. Anything else — including `verify=False` — is rejected with a
`ValueError`: certificate verification cannot be turned off. `proxy=` and
`transport=` are mutually exclusive and also raise `ValueError` together,
because `httpx` would resolve the proxy mount and silently ignore the transport.

## Size limits

Request and response bounds are independent. `max_response_bytes=` caps what the
client will read; `max_request_bytes=` caps the encoded JSON body it will send.
Both default to 32 MiB.

## Security

- The production API URL is fixed by default.
- Custom endpoints require both `base_url=` and
  `allow_custom_base_url=True`, because the API key will be sent to that host.
- Redirects are never followed.
- Remote cleartext HTTP is rejected.
- Responses are bounded to 32 MiB by default.
- Closing the client removes its in-memory credential reference.
- Administrator-generated client keys expire after 90 days by default.

Never commit an API key to source control. Load it from an environment variable
or a secret manager, rotate it if it may have been exposed, and close the client
when finished.

## Changes in 0.1.3

No public name was removed and existing calls keep their signature. Behaviour
changes to check when upgrading from 0.1.2:

- **Default host.** The client now talks to
  `https://fabrisk-crypto.42qlabs.com/crypto/api/v1` (0.1.2 used
  `https://fabrisk.42capital.io/crypto/api/v1`, which only remains as a
  temporary route). To keep the previous host, pass it as `base_url=` with
  `allow_custom_base_url=True`.
- **Timeouts.** The default deadline is now per operation (see
  [Timeouts](#timeouts)) instead of a fixed 30 s. `FabriskClient(timeout=30)`
  restores the previous behaviour.
- **Heavy computations.** They are no longer resubmitted after a 504, a
  timeout or a lost connection (0.1.2 retried them up to 2 more times); the
  error is raised instead (see
  [Heavy computations are never resubmitted automatically](#heavy-computations-are-never-resubmitted-automatically)).
- **Errors.** `FabriskApiError.details` holds the validation list again on a
  422 answered with FabRisk's current error envelope, and `FabriskApiError.body`
  exposes the decoded JSON error body. `FabriskTransportError` adds `.timed_out`
  and `.request_sent`.
- **New.** 21 `reference_*` operations (the reference workspace), and two
  keyword-only arguments of `request()`: `path_params=` and `request_token=`
  (sent as `Idempotency-Key`).

## Licence

Proprietary. Copyright (c) 2026 42 Advisors. All rights reserved. Use is
permitted only under an active FabRisk subscription or agreement. See `LICENSE`.
