{
  "openapi": "3.1.0",
  "info": {
    "title": "FXNewsBias API",
    "version": "1.1.0",
    "summary": "AI-scored sentiment and settled session bias for the 8 major currencies, 15 forex pairs and gold (XAU/USD).",
    "description": "Sentiment scores for the 8 majors, refreshed every 3 hours, plus the settled session bias scorecard. Gold (XAU/USD) is served on its own Pro-only Markets endpoints under `/api/v1/markets`, so the sentiment endpoint keeps exactly 8 rows.\n\nThe `/v1` response shapes are frozen: nothing on this path changes shape or meaning. Additive OPTIONAL fields are permitted, breaking changes get a `/v2` path. The authoritative contract is https://fxnewsbias.com/developers.\n\nEvery response carries an `attribution` object. Consumers displaying or republishing the data must keep it.",
    "contact": { "name": "FXNewsBias", "email": "contact@fxnewsbias.com", "url": "https://fxnewsbias.com/developers" },
    "termsOfService": "https://fxnewsbias.com/terms",
    "license": { "name": "Proprietary, see terms of service", "url": "https://fxnewsbias.com/terms" }
  },
  "servers": [{ "url": "https://fxnewsbias.com", "description": "Production" }],
  "externalDocs": { "description": "Developer documentation and key panel", "url": "https://fxnewsbias.com/developers" },
  "security": [{ "bearerAuth": [] }],
  "tags": [
    { "name": "Sentiment", "description": "Currency sentiment scores, 0 to 100." },
    { "name": "Session bias", "description": "Per-pair directional bias for a trading session, and how it settled." },
    { "name": "Account", "description": "What your key is allowed and what it has spent." },
    { "name": "Markets", "description": "Pro only. Instruments beyond the 8 currencies, starting with gold (XAU). Gold's own sentiment on the same 0 to 100 scale, the XAU/USD pair read against the US dollar, and XAU/USD session calls. Same key and same daily allowance as every other endpoint." }
  ],
  "paths": {
    "/api/v1/sentiment": {
      "get": {
        "tags": ["Sentiment"],
        "operationId": "getSentiment",
        "summary": "Current sentiment for the 8 majors",
        "description": "The current snapshot, always exactly 8 rows in the order USD, EUR, GBP, JPY, AUD, CAD, CHF, NZD.\n\nScores refresh on the 3-hour cycle at 00:00, 03:00, 06:00 and so on UTC. Polling more often returns the same values, which is expected and not an error. Free keys are served the previous cycle and the response says so via `delayed` and `delay_hours`.\n\nQuery parameters are ignored on this path.",
        "responses": {
          "200": {
            "description": "Current snapshot.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SentimentResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/v1/sentiment/history": {
      "get": {
        "tags": ["Sentiment"],
        "operationId": "getSentimentHistory",
        "summary": "Historical sentiment scores",
        "description": "Every 3-hour cycle in the requested window, oldest first. Paid tiers only.\n\nThe window is inclusive of both `from` and `to`. A malformed parameter is rejected before the daily allowance is claimed, so a bad request does not cost a call.",
        "parameters": [
          { "$ref": "#/components/parameters/Currency" },
          { "$ref": "#/components/parameters/From" },
          { "$ref": "#/components/parameters/To" },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Offset" }
        ],
        "responses": {
          "200": {
            "description": "Scores in the requested window.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SentimentHistoryResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/UpgradeRequired" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/Upstream" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/v1/session-bias": {
      "get": {
        "tags": ["Session bias"],
        "operationId": "getSessionBias",
        "summary": "Session bias for the newest session",
        "description": "Per-pair bias for the most recent published session. Pro tier only.\n\nReturns the newest session group only, so the shape stays stable and the ledger cannot be walked backwards one request at a time. Use the history endpoint for a range.",
        "responses": {
          "200": {
            "description": "Newest published session.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionBiasResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/ProOnly" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/Upstream" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/v1/session-bias/history": {
      "get": {
        "tags": ["Session bias"],
        "operationId": "getSessionBiasHistory",
        "summary": "Settled session bias history",
        "description": "Settled session rows in the requested window, oldest first, with entry and result prices and how each call settled. Paid tiers only.\n\nOnly settled rows are returned: an open session never appears here. The `summary` block counts the whole requested window, not the returned page, so a paged caller and a single-page caller are told the same hit rate. Quiet and unscored sessions settle but are not directional, so they are excluded from `aligned_pct`. If any count cannot be computed, `summary` is null and `summary_unavailable` explains why, rather than reporting a breakdown whose parts do not add up.",
        "parameters": [
          { "$ref": "#/components/parameters/Pair" },
          { "$ref": "#/components/parameters/From" },
          { "$ref": "#/components/parameters/To" },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Offset" }
        ],
        "responses": {
          "200": {
            "description": "Settled sessions in the requested window.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionBiasHistoryResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/UpgradeRequired" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/Upstream" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/v1/usage": {
      "get": {
        "tags": ["Account"],
        "operationId": "getUsage",
        "summary": "Your tier, allowance and spend today",
        "description": "What this key is allowed and what it has spent in the current UTC day.\n\nThis endpoint does not count against the daily allowance, so it is safe to poll before backing off and safe to put behind a dashboard. It reads the same counter the allowance is claimed from, so its numbers always agree with the `X-RateLimit-*` headers on a real call.",
        "responses": {
          "200": {
            "description": "Current allowance and spend.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UsageResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/v1/markets": {
      "get": {
        "tags": ["Markets"],
        "operationId": "getMarkets",
        "summary": "Latest read for every market (Pro)",
        "description": "The latest sentiment read for every market. Today `data` holds gold (XAU) only. It is a list that grows as instruments are added and lists only markets that already have a reading, so it can be empty before a market's first read: find a market by `symbol` and never assume a fixed length or position.\n\nGold is scored on the same 3-hour cycle as the currencies. Pro keys only: any other tier gets `403` with `error` `pro-only` before the allowance is claimed. Query parameters are ignored on this path.",
        "responses": {
          "200": {
            "description": "Latest read per market.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MarketsResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/MarketsProOnly" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/Upstream" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/v1/markets/history": {
      "get": {
        "tags": ["Markets"],
        "operationId": "getMarketsHistory",
        "summary": "Historical market sentiment (Pro)",
        "description": "Every 3-hour read for one market in the requested window, oldest first. Same window, paging and validation rules as `/api/v1/sentiment/history`. `symbol` is required. A malformed parameter is rejected before the daily allowance is claimed.",
        "parameters": [
          { "$ref": "#/components/parameters/MarketSymbol" },
          { "$ref": "#/components/parameters/From" },
          { "$ref": "#/components/parameters/To" },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Offset" }
        ],
        "responses": {
          "200": {
            "description": "Reads in the requested window.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MarketsHistoryResponse" } } }
          },
          "400": { "$ref": "#/components/responses/MarketsBadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/MarketsProOnly" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/Upstream" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/v1/markets/session-bias": {
      "get": {
        "tags": ["Markets"],
        "operationId": "getMarketsSessionBias",
        "summary": "Newest session call for one market (Pro)",
        "description": "The newest published XAU/USD session call, open or settled. `data` is null until a first call exists. Calls are published on weekdays for the Asia, London and New York sessions and are never mixed into the forex session scorecard.",
        "parameters": [
          { "$ref": "#/components/parameters/MarketSymbol" }
        ],
        "responses": {
          "200": {
            "description": "Newest session call.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MarketsSessionBiasResponse" } } }
          },
          "400": { "$ref": "#/components/responses/MarketsBadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/MarketsProOnly" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/Upstream" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/v1/markets/session-bias/history": {
      "get": {
        "tags": ["Markets"],
        "operationId": "getMarketsSessionBiasHistory",
        "summary": "Settled session calls for one market (Pro)",
        "description": "Settled XAU/USD session calls in the requested window, oldest first, with entry and result prices, the move in percent, US dollars and pips, and how each call settled. Same window and paging rules as `/api/v1/session-bias/history`. `summary` counts the whole requested window for this market only; it is null, with `summary_unavailable`, if any count cannot be computed.\n\nPip convention for gold: 1 pip = $0.10 per ounce, so `move_pips` = `move_usd` times 10.",
        "parameters": [
          { "$ref": "#/components/parameters/MarketSymbol" },
          { "$ref": "#/components/parameters/From" },
          { "$ref": "#/components/parameters/To" },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Offset" }
        ],
        "responses": {
          "200": {
            "description": "Settled calls in the requested window.",
            "headers": {
              "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
              "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
              "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MarketsSessionBiasHistoryResponse" } } }
          },
          "400": { "$ref": "#/components/responses/MarketsBadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/MarketsProOnly" },
          "405": { "$ref": "#/components/responses/MethodNotAllowed" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/Upstream" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Send your key as `Authorization: Bearer fxnb_live_<64 hex characters>`.\n\nThe header is the only accepted form: keys are never read from the URL or query string. Create a key from the panel on https://fxnewsbias.com/developers. The raw key is shown once at creation because only its fingerprint is stored, so it cannot be recovered. Creating a new key replaces the previous one, and an integration still sending the old string will start getting 401."
      }
    },
    "headers": {
      "XRateLimitLimit": { "description": "Requests allowed per UTC day on this key's tier.", "schema": { "type": "integer" } },
      "XRateLimitRemaining": { "description": "Requests left in the current UTC day.", "schema": { "type": "integer" } },
      "XRateLimitReset": { "description": "Unix epoch seconds at which the allowance resets, which is the next UTC midnight.", "schema": { "type": "integer" } },
      "RetryAfter": { "description": "Seconds to wait before retrying.", "schema": { "type": "integer" } }
    },
    "parameters": {
      "Currency": {
        "name": "currency", "in": "query", "required": false,
        "description": "Restrict to one currency. Omit for all 8.",
        "schema": { "type": "string", "enum": ["USD", "EUR", "GBP", "JPY", "AUD", "CAD", "CHF", "NZD"] }
      },
      "Pair": {
        "name": "pair", "in": "query", "required": false,
        "description": "Restrict to one pair, written as GBPJPY or GBP/JPY. Omit for all 15. An unrecognised pair is rejected with 400 rather than ignored.",
        "schema": { "type": "string", "pattern": "^[A-Za-z]{3}/?[A-Za-z]{3}$" },
        "example": "GBP/JPY"
      },
      "From": {
        "name": "from", "in": "query", "required": false,
        "description": "First day of the window, inclusive. Defaults to 30 days before `to`.",
        "schema": { "type": "string", "format": "date", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
        "example": "2026-08-01"
      },
      "To": {
        "name": "to", "in": "query", "required": false,
        "description": "Last day of the window, inclusive. Defaults to today. The default `from` is anchored to this date, so `?to=2026-06-01` alone means the 30 days up to that date.",
        "schema": { "type": "string", "format": "date", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
        "example": "2026-08-31"
      },
      "Limit": {
        "name": "limit", "in": "query", "required": false,
        "description": "Rows per page.",
        "schema": { "type": "integer", "minimum": 1, "maximum": 5000, "default": 500 }
      },
      "Offset": {
        "name": "offset", "in": "query", "required": false,
        "description": "Rows to skip. Pass the `paging.next_offset` from the previous response to walk a window.",
        "schema": { "type": "integer", "minimum": 0, "maximum": 10000000, "default": 0 }
      },
      "MarketSymbol": {
        "name": "symbol", "in": "query", "required": true,
        "description": "The market. Today only `XAU` (gold). Case-insensitive. Missing or unsupported values are rejected with 400 `bad-symbol`.",
        "schema": { "type": "string", "enum": ["XAU"] },
        "example": "XAU"
      }
    },
    "schemas": {
      "Attribution": {
        "type": "object",
        "description": "Fixed server-side. Consumers displaying or republishing the data must keep it.",
        "required": ["required", "text", "url"],
        "properties": {
          "required": { "type": "boolean", "const": true },
          "text": { "type": "string", "const": "Data by FXNewsBias" },
          "url": { "type": "string", "format": "uri", "const": "https://fxnewsbias.com" }
        }
      },
      "Paging": {
        "type": "object",
        "required": ["offset", "limit", "returned", "total_matching", "has_more", "next_offset"],
        "properties": {
          "offset": { "type": "integer" },
          "limit": { "type": "integer" },
          "returned": { "type": "integer", "description": "Rows in this response." },
          "total_matching": { "type": ["integer", "null"], "description": "Rows matching the whole window, or null if the count could not be computed." },
          "has_more": { "type": "boolean" },
          "next_offset": { "type": ["integer", "null"], "description": "Offset for the next page, or null when the window is exhausted." }
        }
      },
      "SentimentResponse": {
        "type": "object",
        "required": ["schema", "generated_at", "next_update_expected", "attribution", "data"],
        "properties": {
          "schema": { "type": "string", "const": "fxnb.sentiment.v1" },
          "generated_at": { "type": "string", "format": "date-time", "description": "Server time of this response, ISO-8601 UTC." },
          "next_update_expected": { "type": "string", "format": "date-time", "description": "Next scheduled 3-hour refresh, ISO-8601 UTC." },
          "delayed": { "type": "boolean", "description": "Present and true on the free tier only." },
          "delay_hours": { "type": "integer", "description": "Present on the free tier only. Always 3.", "const": 3 },
          "attribution": { "$ref": "#/components/schemas/Attribution" },
          "data": {
            "type": "array", "minItems": 8, "maxItems": 8,
            "description": "Always exactly 8 rows, in the order USD, EUR, GBP, JPY, AUD, CAD, CHF, NZD.",
            "items": {
              "type": "object",
              "required": ["currency", "score", "bias", "updated_at"],
              "properties": {
                "currency": { "type": "string", "enum": ["USD", "EUR", "GBP", "JPY", "AUD", "CAD", "CHF", "NZD"] },
                "score": { "type": "integer", "minimum": 0, "maximum": 100, "description": "0 to 100. 50 is the neutral midpoint." },
                "bias": { "type": "string", "enum": ["Bullish", "Bearish", "Neutral"] },
                "updated_at": { "type": "string", "format": "date-time", "description": "When this score was computed, ISO-8601 UTC." }
              }
            }
          }
        }
      },
      "SentimentHistoryResponse": {
        "type": "object",
        "required": ["schema", "generated_at", "query", "coverage_from", "paging", "attribution", "data"],
        "properties": {
          "schema": { "type": "string", "const": "fxnb.sentiment.history.v1" },
          "generated_at": { "type": "string", "format": "date-time" },
          "query": {
            "type": "object",
            "description": "The window actually used, after defaults were applied.",
            "properties": {
              "from": { "type": "string", "format": "date" },
              "to": { "type": "string", "format": "date" },
              "currency": { "type": ["string", "null"] }
            }
          },
          "coverage_from": { "type": "string", "format": "date", "description": "Earliest day held for this dataset. Asking for anything before it returns nothing, not an error." },
          "paging": { "$ref": "#/components/schemas/Paging" },
          "attribution": { "$ref": "#/components/schemas/Attribution" },
          "data": {
            "type": "array",
            "description": "Oldest first, then by currency.",
            "items": {
              "type": "object",
              "required": ["currency", "score", "bias", "scored_at"],
              "properties": {
                "currency": { "type": "string", "enum": ["USD", "EUR", "GBP", "JPY", "AUD", "CAD", "CHF", "NZD"] },
                "score": { "type": "integer", "minimum": 0, "maximum": 100 },
                "bias": { "type": "string", "enum": ["Bullish", "Bearish", "Neutral"] },
                "scored_at": { "type": "string", "format": "date-time", "description": "The 3-hour cycle this score belongs to, ISO-8601 UTC." }
              }
            }
          }
        }
      },
      "SessionBiasResponse": {
        "type": "object",
        "required": ["schema", "generated_at", "session", "session_date", "attribution", "data"],
        "properties": {
          "schema": { "type": "string", "const": "fxnb.session_bias.v1" },
          "generated_at": { "type": "string", "format": "date-time" },
          "session": { "type": ["string", "null"], "enum": ["asean", "london", "newyork", null], "description": "Trading session. `asean` is the Asia session." },
          "session_date": { "type": ["string", "null"], "format": "date" },
          "attribution": { "$ref": "#/components/schemas/Attribution" },
          "data": {
            "type": "array",
            "description": "One row per scored pair in the newest session.",
            "items": {
              "type": "object",
              "required": ["pair", "tone", "strength"],
              "properties": {
                "pair": { "type": "string", "description": "Written as BASE/QUOTE.", "example": "GBP/JPY" },
                "tone": { "type": "string", "enum": ["Bullish", "Bearish", "Neutral"] },
                "strength": { "type": "integer", "minimum": 0, "maximum": 5, "description": "Conviction, 0 to 5. Neutral calls carry 0." }
              }
            }
          }
        }
      },
      "SessionBiasHistoryResponse": {
        "type": "object",
        "required": ["schema", "generated_at", "query", "coverage_from", "paging", "attribution", "data"],
        "properties": {
          "schema": { "type": "string", "const": "fxnb.session_bias.history.v1" },
          "generated_at": { "type": "string", "format": "date-time" },
          "query": {
            "type": "object",
            "properties": {
              "from": { "type": "string", "format": "date" },
              "to": { "type": "string", "format": "date" },
              "pair": { "type": ["string", "null"] },
              "status": { "type": "string", "const": "settled" }
            }
          },
          "coverage_from": { "type": "string", "format": "date" },
          "paging": { "$ref": "#/components/schemas/Paging" },
          "summary": {
            "type": ["object", "null"],
            "description": "Counts over the whole requested window, not the returned page. Null when a count could not be computed.",
            "properties": {
              "settled": { "type": "integer" },
              "aligned": { "type": "integer", "description": "Calls the market agreed with." },
              "contra": { "type": "integer", "description": "Calls the market went against." },
              "directional": { "type": "integer", "description": "aligned + contra. Quiet and unscored sessions are excluded." },
              "aligned_pct": { "type": ["number", "null"], "description": "aligned as a percentage of directional, to one decimal place." }
            }
          },
          "summary_unavailable": { "type": "string", "description": "Present only when `summary` is null, explaining why no breakdown is reported." },
          "attribution": { "$ref": "#/components/schemas/Attribution" },
          "data": {
            "type": "array",
            "description": "Oldest first, then by entry time, then by pair.",
            "items": { "$ref": "#/components/schemas/SessionBiasRow" }
          }
        }
      },
      "SessionBiasRow": {
        "type": "object",
        "required": ["pair", "session", "session_date", "tone", "strength", "status"],
        "properties": {
          "pair": { "type": "string", "example": "GBP/JPY" },
          "session": { "type": "string", "enum": ["asean", "london", "newyork"], "description": "`asean` is the Asia session." },
          "session_date": { "type": "string", "format": "date" },
          "tone": { "type": "string", "enum": ["Bullish", "Bearish", "Neutral"] },
          "strength": { "type": "integer", "minimum": 0, "maximum": 5 },
          "entry_price": { "type": ["number", "null"], "description": "Price when the call was published." },
          "entry_time": { "type": ["string", "null"], "format": "date-time" },
          "result_price": { "type": ["number", "null"], "description": "Price when the session settled." },
          "result_time": { "type": ["string", "null"], "format": "date-time" },
          "move_pct": { "type": ["number", "null"], "description": "Move from entry to result, percent." },
          "move_pips": { "type": ["number", "null"], "description": "Move from entry to result, pips." },
          "alignment": {
            "type": ["string", "null"],
            "enum": ["aligned", "contra", "quiet", "na", null],
            "description": "How the call settled. `aligned` the market agreed, `contra` it went against, `quiet` the move was too small to count either way, `na` the session was not scored directionally."
          },
          "status": { "type": "string", "const": "settled", "description": "This endpoint returns settled rows only." }
        }
      },
      "UsageResponse": {
        "type": "object",
        "required": ["schema", "tier", "requests_per_day", "used_today", "remaining_today", "resets_at", "delayed"],
        "properties": {
          "schema": { "type": "string", "const": "fxnb.usage.v1" },
          "tier": { "type": "string", "description": "The tier this key is on." },
          "requests_per_day": { "type": "integer", "description": "Allowance per UTC day." },
          "used_today": { "type": "integer" },
          "remaining_today": { "type": "integer" },
          "resets_at": { "type": "string", "format": "date-time", "description": "Next UTC midnight, ISO-8601." },
          "delayed": { "type": "boolean", "description": "True when this key is served one cycle behind." },
          "delay_hours": { "type": "integer", "description": "Present only when `delayed` is true." },
          "note": { "type": "string" }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": { "type": "string", "description": "Stable machine-readable code. Branch on this, not on `message`." },
          "message": { "type": "string", "description": "Human-readable explanation. Wording may change." },
          "docs": { "type": "string", "format": "uri" }
        }
      },
      "MarketPairRead": {
        "type": "object",
        "description": "The market priced against its quote currency, read with the same thresholds as every pair on the site.",
        "required": ["name", "quote", "quote_score", "gap", "bias"],
        "properties": {
          "name": { "type": "string", "example": "XAU/USD" },
          "quote": { "type": "string", "example": "USD" },
          "quote_score": { "type": ["integer", "null"], "minimum": 0, "maximum": 100, "description": "The latest sentiment score of the quote currency used for this read. Null if none was available." },
          "gap": { "type": ["integer", "null"], "description": "The market's score minus `quote_score`. Null when `quote_score` is null." },
          "bias": { "type": ["string", "null"], "enum": ["Bullish", "Bearish", "Neutral", null], "description": "Bullish above +10, Bearish below -10, Neutral from -10 to +10 inclusive. Null when `gap` is null." }
        }
      },
      "MarketsResponse": {
        "type": "object",
        "required": ["schema", "generated_at", "next_update_expected", "attribution", "data"],
        "properties": {
          "schema": { "type": "string", "const": "fxnb.markets.v1" },
          "generated_at": { "type": "string", "format": "date-time", "description": "Server time of this response, ISO-8601 UTC." },
          "next_update_expected": { "type": "string", "format": "date-time", "description": "Next scheduled 3-hour refresh, ISO-8601 UTC." },
          "attribution": { "$ref": "#/components/schemas/Attribution" },
          "data": {
            "type": "array",
            "description": "One entry per market. Today gold only. The list grows as instruments are added: do not assume a fixed length or position.",
            "items": {
              "type": "object",
              "required": ["symbol", "name", "score", "bias", "drivers", "updated_at", "pair"],
              "properties": {
                "symbol": { "type": "string", "description": "Market code. Today `XAU`. New codes may be added.", "example": "XAU" },
                "name": { "type": "string", "example": "Gold" },
                "score": { "type": "integer", "minimum": 0, "maximum": 100, "description": "The market's own sentiment, 0 to 100, on the same scale as the currencies. 50 is the neutral midpoint." },
                "bias": { "type": "string", "enum": ["Bullish", "Bearish", "Neutral"], "description": "From the score: Bullish at 60 or higher, Bearish at 40 or lower, Neutral in between." },
                "drivers": { "type": "array", "maxItems": 3, "items": { "type": "string" }, "description": "Up to 3 short phrases naming the catalysts behind the score." },
                "updated_at": { "type": "string", "format": "date-time", "description": "When this read was computed, ISO-8601 UTC." },
                "pair": { "$ref": "#/components/schemas/MarketPairRead" }
              }
            }
          }
        }
      },
      "MarketsHistoryResponse": {
        "type": "object",
        "required": ["schema", "generated_at", "query", "coverage_from", "paging", "attribution", "data"],
        "properties": {
          "schema": { "type": "string", "const": "fxnb.markets.history.v1" },
          "generated_at": { "type": "string", "format": "date-time" },
          "query": {
            "type": "object",
            "description": "The window actually used, after defaults were applied.",
            "properties": {
              "from": { "type": "string", "format": "date" },
              "to": { "type": "string", "format": "date" },
              "symbol": { "type": "string", "example": "XAU" }
            }
          },
          "coverage_from": { "type": ["string", "null"], "format": "date", "description": "Earliest day held for this market. Null only if the market has no reads yet." },
          "paging": { "$ref": "#/components/schemas/Paging" },
          "attribution": { "$ref": "#/components/schemas/Attribution" },
          "data": {
            "type": "array",
            "description": "Oldest first.",
            "items": {
              "type": "object",
              "required": ["symbol", "score", "bias", "pair_gap", "pair_bias", "scored_at"],
              "properties": {
                "symbol": { "type": "string", "example": "XAU" },
                "score": { "type": "integer", "minimum": 0, "maximum": 100 },
                "bias": { "type": "string", "enum": ["Bullish", "Bearish", "Neutral"] },
                "pair_gap": { "type": ["integer", "null"], "description": "Score minus the quote currency's score for the same read." },
                "pair_bias": { "type": ["string", "null"], "enum": ["Bullish", "Bearish", "Neutral", null] },
                "scored_at": { "type": "string", "format": "date-time", "description": "When this read was computed, ISO-8601 UTC." }
              }
            }
          }
        }
      },
      "MarketsSessionBiasResponse": {
        "type": "object",
        "required": ["schema", "generated_at", "symbol", "attribution", "data"],
        "properties": {
          "schema": { "type": "string", "const": "fxnb.markets.session_bias.v1" },
          "generated_at": { "type": "string", "format": "date-time" },
          "symbol": { "type": "string", "example": "XAU" },
          "attribution": { "$ref": "#/components/schemas/Attribution" },
          "data": {
            "type": ["object", "null"],
            "description": "The newest call, open or settled. Null until a first call exists.",
            "required": ["pair", "tone", "strength", "session", "session_date", "entry_time"],
            "properties": {
              "pair": { "type": "string", "example": "XAU/USD" },
              "tone": { "type": "string", "enum": ["Bullish", "Bearish", "Neutral"] },
              "strength": { "type": "integer", "minimum": 0, "maximum": 5, "description": "Conviction, 0 to 5. Neutral calls carry 0." },
              "session": { "type": "string", "enum": ["asean", "london", "newyork"], "description": "`asean` is the Asia session." },
              "session_date": { "type": "string", "format": "date" },
              "entry_time": { "type": "string", "format": "date-time" }
            }
          }
        }
      },
      "MarketsSessionBiasHistoryResponse": {
        "type": "object",
        "required": ["schema", "generated_at", "query", "coverage_from", "pip_convention", "paging", "attribution", "data"],
        "properties": {
          "schema": { "type": "string", "const": "fxnb.markets.session_bias.history.v1" },
          "generated_at": { "type": "string", "format": "date-time" },
          "query": {
            "type": "object",
            "properties": {
              "from": { "type": "string", "format": "date" },
              "to": { "type": "string", "format": "date" },
              "symbol": { "type": "string", "example": "XAU" },
              "status": { "type": "string", "const": "settled" }
            }
          },
          "coverage_from": { "type": ["string", "null"], "format": "date", "description": "First session date held for this market. Null only if no call exists yet." },
          "pip_convention": { "type": "string", "description": "How `move_pips` is defined for this market.", "example": "XAU/USD: 1 pip = $0.10 per ounce, so move_pips = move_usd * 10" },
          "paging": { "$ref": "#/components/schemas/Paging" },
          "summary": {
            "type": ["object", "null"],
            "description": "Counts over the whole requested window for this market only, not the returned page. Null when a count could not be computed.",
            "properties": {
              "settled": { "type": "integer" },
              "aligned": { "type": "integer", "description": "Calls the market agreed with." },
              "contra": { "type": "integer", "description": "Calls the market went against." },
              "directional": { "type": "integer", "description": "aligned + contra. Quiet and unscored sessions are excluded." },
              "aligned_pct": { "type": ["number", "null"], "description": "aligned as a percentage of directional, to one decimal place." }
            }
          },
          "summary_unavailable": { "type": "string", "description": "Present only when `summary` is null, explaining why no breakdown is reported." },
          "attribution": { "$ref": "#/components/schemas/Attribution" },
          "data": {
            "type": "array",
            "description": "Oldest first, then by entry time.",
            "items": { "$ref": "#/components/schemas/MarketSessionBiasRow" }
          }
        }
      },
      "MarketSessionBiasRow": {
        "type": "object",
        "required": ["pair", "session", "session_date", "tone", "strength", "status"],
        "properties": {
          "pair": { "type": "string", "example": "XAU/USD" },
          "session": { "type": "string", "enum": ["asean", "london", "newyork"], "description": "`asean` is the Asia session." },
          "session_date": { "type": "string", "format": "date" },
          "tone": { "type": "string", "enum": ["Bullish", "Bearish", "Neutral"] },
          "strength": { "type": "integer", "minimum": 0, "maximum": 5 },
          "entry_price": { "type": ["number", "null"], "description": "Hourly gold reference price, US dollars per ounce, when the call was published." },
          "entry_time": { "type": ["string", "null"], "format": "date-time" },
          "result_price": { "type": ["number", "null"], "description": "Hourly gold reference price when the call settled." },
          "result_time": { "type": ["string", "null"], "format": "date-time" },
          "move_pct": { "type": ["number", "null"], "description": "Move from entry to result, percent." },
          "move_usd": { "type": ["number", "null"], "description": "Move from entry to result, US dollars per ounce." },
          "move_pips": { "type": ["number", "null"], "description": "Move from entry to result in pips, where 1 pip = $0.10 per ounce, so move_pips = move_usd * 10." },
          "alignment": {
            "type": ["string", "null"],
            "enum": ["aligned", "contra", "quiet", "na", null],
            "description": "How the call settled. `aligned` the market agreed, `contra` it went against, `quiet` the move was inside the gold volatility band, `na` the call was Neutral and not scored directionally."
          },
          "status": { "type": "string", "const": "settled", "description": "This endpoint returns settled rows only." }
        }
      },
      "ErrorWithUpgrade": {
        "allOf": [
          { "$ref": "#/components/schemas/Error" },
          {
            "type": "object",
            "properties": {
              "upgrade": { "type": "string", "format": "uri", "description": "Where to get the tier that includes this endpoint." }
            }
          }
        ]
      }
    },
    "responses": {
      "BadRequest": {
        "description": "A parameter was rejected. Parameters are validated before the daily allowance is claimed, so a bad request does not cost a call. `error` is one of `bad-from`, `bad-to`, `bad-range`, `bad-limit`, `bad-offset`, `bad-currency`, `bad-pair`.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" },
          "example": { "error": "bad-from", "message": "from must be a real calendar date as YYYY-MM-DD." } } }
      },
      "Unauthorized": {
        "description": "Missing, malformed, unknown or revoked key. `message` says which. Remember that creating a new key replaces the previous one.",
        "headers": { "WWW-Authenticate": { "description": "Always `Bearer`.", "schema": { "type": "string" } } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" },
          "example": { "error": "unauthorized", "message": "This key is not active. Regenerating a key replaces the previous one, and a revoked key stops working immediately. Create a new key on the developer page and update your integration.", "docs": "https://fxnewsbias.com/developers" } } }
      },
      "UpgradeRequired": {
        "description": "The key's tier does not include this endpoint. The body lists what Pro unlocks and links the pricing page.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" },
          "example": { "error": "upgrade-required", "message": "Sentiment history is included with FXNewsBias Pro. Your key is on the free tier, which serves the current cycle only and one cycle behind.", "docs": "https://fxnewsbias.com/developers" } } }
      },
      "ProOnly": {
        "description": "This endpoint is on the Pro tier.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" },
          "example": { "error": "pro-only", "message": "The session scorecard is available on the Pro tier." } } }
      },
      "NotFound": {
        "description": "No endpoint at this path. Paths are exact and carry no trailing slash.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" },
          "example": { "error": "not-found", "message": "No API endpoint at /api/v1/sentiments. Paths are exact: no trailing slash.", "docs": "https://fxnewsbias.com/developers" } } }
      },
      "MethodNotAllowed": {
        "description": "Every endpoint is GET only.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" },
          "example": { "error": "method-not-allowed" } } }
      },
      "RateLimited": {
        "description": "The daily allowance is spent. It resets at the next UTC midnight.",
        "headers": {
          "Retry-After": { "$ref": "#/components/headers/RetryAfter" },
          "X-RateLimit-Limit": { "$ref": "#/components/headers/XRateLimitLimit" },
          "X-RateLimit-Remaining": { "$ref": "#/components/headers/XRateLimitRemaining" },
          "X-RateLimit-Reset": { "$ref": "#/components/headers/XRateLimitReset" }
        },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" },
          "example": { "error": "rate-limited", "retry_after_seconds": 18240 } } }
      },
      "Upstream": {
        "description": "A dependency did not answer. Safe to retry.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "upstream" } } }
      },
      "ServerError": {
        "description": "Unexpected error on our side. Safe to retry.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "server-error" } } }
      },
      "MarketsBadRequest": {
        "description": "A parameter was rejected. Parameters are validated before the daily allowance is claimed, so a bad request does not cost a call. `error` is `bad-symbol` for a missing or unsupported `symbol`, otherwise one of `bad-from`, `bad-to`, `bad-range`, `bad-limit`, `bad-offset`.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" },
          "example": { "error": "bad-symbol", "message": "symbol is required and must be one of XAU." } } }
      },
      "MarketsProOnly": {
        "description": "The key is valid but not on the Pro tier. Returned before the daily allowance is claimed.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorWithUpgrade" },
          "example": { "error": "pro-only", "message": "Gold and other markets are a Pro feature.", "upgrade": "https://fxnewsbias.com/pricing" } } }
      }
    }
  }
}
