# Score news headlines

Score each headline's sentiment, flag the ones that move the stock, and rank them by what to read first.

> Made-up companies. Not financial advice.

Category: More. Live demo: https://levanto.ai/examples/news-sentiment

A trader sees hundreds of headlines a day and has time to read a few. This guide scores every headline for **sentiment** and whether it's likely to **move the stock today**, in one batch call, then ranks them by what to read first.

## Setup

Base URL: `https://sage.levanto.ai`. Send `Authorization: Bearer $SAGE_API_KEY` on every call. Get a key, free, at https://platform.levanto.ai. Keep the key on your server.

A `null` answer is not an error: Sage2 isn't sure. Each step says what to do with it.

## Step by step

### 1. Score every headline

Two questions, asked about every headline:

- `sentiment` is a Scale question with five `levels`, from very bearish to very bullish.
- `material` is a Yes/No: is there news here that moves the stock today?

`/decide/batch` takes one group per headline in `requests`. A whole feed is one call.

```python
headlines = [
    "Halvorsen Freight (HLVR) raises full-year guidance after record third-quarter bookings.",
    "Regulator rejects Veltrix Bio's (VLTX) lead drug; the company cuts 20% of staff.",
    "Quill Robotics (QUIL) CEO says the company is 'cautiously excited about being excited' in a new interview.",
    "Tessel Energy (TSSL) shares unchanged as the company confirms its previously announced dividend.",
]

QUESTIONS = [
    {
        "id": "sentiment",
        "kind": "scale",
        "instructions": "How does this headline read for the named company's stock?",
        "levels": [
            {"level": 1, "description": "Very bearish"},
            {"level": 2, "description": "Bearish"},
            {"level": 3, "description": "Neutral"},
            {"level": 4, "description": "Bullish"},
            {"level": 5, "description": "Very bullish"},
        ],
    },
    {
        "id": "material",
        "kind": "yesno",
        "instructions": "Does this headline contain new information likely to move the stock today?",
    },
]

scores = sage("/decide/batch", {"requests": [{"content": h, "questions": QUESTIONS} for h in headlines]})
```

```typescript
const headlines = [
  "Halvorsen Freight (HLVR) raises full-year guidance after record third-quarter bookings.",
  "Regulator rejects Veltrix Bio's (VLTX) lead drug; the company cuts 20% of staff.",
  "Quill Robotics (QUIL) CEO says the company is 'cautiously excited about being excited' in a new interview.",
  "Tessel Energy (TSSL) shares unchanged as the company confirms its previously announced dividend.",
];

const QUESTIONS = [
  {
    id: "sentiment",
    kind: "scale",
    instructions: "How does this headline read for the named company's stock?",
    levels: [
      { level: 1, description: "Very bearish" },
      { level: 2, description: "Bearish" },
      { level: 3, description: "Neutral" },
      { level: 4, description: "Bullish" },
      { level: 5, description: "Very bullish" },
    ],
  },
  {
    id: "material",
    kind: "yesno",
    instructions: "Does this headline contain new information likely to move the stock today?",
  },
];

const scores = await sage("/decide/batch", { requests: headlines.map(h => ({ content: h, questions: QUESTIONS })) });
```

Response, abridged (a real run):

```json
{
  "results": [
    {
      "answers": [
        {
          "ok": true,
          "result": {
            "id": "sentiment",
            "kind": "scale",
            "result": {
              "expectation": 4.1947,
              "probabilities": [
                {
                  "level": 1,
                  "probability": 0.0005
                },
                {
                  "level": 2,
                  "probability": 0.0006
                },
                {
                  "level": 3,
                  "probability": 0.0057
                },
                {
                  "level": 4,
                  "probability": 0.7899
                },
                {
                  "level": 5,
                  "probability": 0.2033
                }
              ],
              "confidence": 0.6593
            },
            "meta": {
              "model": "levanto-sage-2"
            }
          },
          "error": null
        },
        {
          "ok": true,
          "result": {
            "id": "material",
            "kind": "yesno",
            "result": {
              "answer": "yes",
              "probability": 0.8871
            },
            "meta": {
              "model": "levanto-sage-2"
            }
          },
          "error": null
        }
      ]
    },
    {
      "answers": [
        {
          "ok": true,
          "result": {
            "id": "sentiment",
            "kind": "scale",
            "result": {
              "expectation": 1.332,
              "probabilities": [
                {
                  "level": 1,
                  "probability": 0.6767
                },
                {
                  "level": 2,
                  "probability": 0.3175
                },
                {
                  "level": 3,
                  "probability": 0.0038
                },
                {
                  "level": 4,
                  "probability": 0.001
                },
                {
                  "level": 5,
                  "probability": 0.001
                }
              ],
              "confidence": 0.5878
            },
            "meta": {
              "model": "levanto-sage-2"
            }
          },
          "error": null
        },
        {
          "ok": true,
          "result": {
            "id": "material",
            "kind": "yesno",
            "result": {
              "answer": "yes",
              "probability": 0.905
            },
            "meta": {
              "model": "levanto-sage-2"
            }
          },
          "error": null
        }
      ]
    },
    "… 2 more, one per request"
  ],
  "meta": {
    "model": "levanto-sage-2",
    "request_count": 4,
    "question_count": 8,
    "latency_ms": 183.2,
    "usage": {
      "billed_input_tokens": 682,
      "image_count": 0,
      "image_tokens": 0,
      "input_tokens": 682,
      "output_tokens": 0
    }
  }
}
```

### 2. Read the score and the yes/no

Results come back in the order you sent them. A Scale answer has `expectation`, the probability-weighted level. A value of 4.2 is between bullish and very bullish. A Yes/No answer has `answer`: `yes`, `no`, or `null` when Sage2 isn't sure.

```python
def answers(out, group=0):
    # Each answer is a full decision. Failed ones (ok is false) are left out: treat a missing id as "not sure".
    return {a["result"]["id"]: a["result"]["result"] for a in out["results"][group]["answers"] if a["ok"]}

def reading(i):
    a = answers(scores, i)  # a missing answer reads as not sure: None
    return a.get("sentiment", {}).get("expectation"), a.get("material", {}).get("answer")
```

```typescript
// Each answer is a full decision. Failed ones (ok is false) are left out: treat a missing id as "not sure".
const answers = (out: any, group = 0): Record<string, any> =>
  Object.fromEntries(out.results[group].answers.filter((a: any) => a.ok).map((a: any) => [a.result.id, a.result.result]));

function reading(i: number): [number | null, string | null] {
  const a = answers(scores, i); // a missing answer reads as not sure: null
  return [a.sentiment?.expectation ?? null, a.material?.answer ?? null];
}
```

### 3. Rank what to read first

Send the headlines as a list, each with an `id`, and ask a Sort question. Here the id is the headline's position in the feed, as a string. `sorted` lists the ids, most important first.

```python
ranked = sage("/decide", {
    "content": {"kind": "list", "value": [{"id": str(i), "content": h} for i, h in enumerate(headlines)]},
    "question": {
        "id": "read_first",
        "kind": "sort",
        "instructions": "Sort by how much a trader needs to read this now, most important first.",
    },
})["result"]["sorted"]
```

```typescript
const ranked: string[] = (await sage("/decide", {
  content: { kind: "list", value: headlines.map((h, i) => ({ id: String(i), content: h })) },
  question: {
    id: "read_first",
    kind: "sort",
    instructions: "Sort by how much a trader needs to read this now, most important first.",
  },
})).result.sorted;
```

Response, abridged (a real run):

```json
{
  "id": "read_first",
  "kind": "sort",
  "result": {
    "sorted": [
      "1",
      "0",
      "2",
      "3"
    ],
    "confidence": 0.5624
  },
  "meta": {
    "model": "levanto-sage-2",
    "latency_ms": 109.5,
    "question_count": 1,
    "usage": {
      "billed_input_tokens": 1186,
      "image_count": 0,
      "image_tokens": 0,
      "input_tokens": 1186,
      "output_tokens": 0
    }
  }
}
```

### 4. Print the reading list

Walk the ranked ids and show each headline with its score. A headline Sage2 isn't sure about gets a `?`. A person reads those.

```python
for i in map(int, ranked):
    sentiment, moves = reading(i)
    score = "  ?  " if sentiment is None else f"{sentiment:.1f}/5"
    flag = {"yes": "moves", "no": "     "}.get(moves, "  ?  ")
    print(score, flag, headlines[i])
```

```typescript
for (const i of ranked.map(Number)) {
  const [sentiment, moves] = reading(i);
  const score = sentiment === null ? "  ?  " : `${sentiment.toFixed(1)}/5`;
  const flag = moves === "yes" ? "moves" : moves === "no" ? "     " : "  ?  ";
  console.log(score, flag, headlines[i]);
}
```

## Before you ship

- **Use your own ids** in the Sort list, such as tickers or article ids. `sorted` then maps directly to your data.
- **Filter before you rank.** On a busy day, sort only the headlines marked `material`.
- **This is a reading list.** The scores decide what a person reads first. People make the trades.
- **Batching saves round trips, not tokens.** Each headline is read once per question, so four headlines with two questions are about 700 tokens. Each call answers in about 0.1 s. See [Batch](https://docs.levanto.ai/decision-model/batch).

## Full recipe (Python, `pip install requests`)

```python
import os
import requests

def sage(path, body):
    r = requests.post(
        "https://sage.levanto.ai" + path,
        headers={"Authorization": f"Bearer {os.environ['SAGE_API_KEY']}"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

headlines = [
    "Halvorsen Freight (HLVR) raises full-year guidance after record third-quarter bookings.",
    "Regulator rejects Veltrix Bio's (VLTX) lead drug; the company cuts 20% of staff.",
    "Quill Robotics (QUIL) CEO says the company is 'cautiously excited about being excited' in a new interview.",
    "Tessel Energy (TSSL) shares unchanged as the company confirms its previously announced dividend.",
]

QUESTIONS = [
    {
        "id": "sentiment",
        "kind": "scale",
        "instructions": "How does this headline read for the named company's stock?",
        "levels": [
            {"level": 1, "description": "Very bearish"},
            {"level": 2, "description": "Bearish"},
            {"level": 3, "description": "Neutral"},
            {"level": 4, "description": "Bullish"},
            {"level": 5, "description": "Very bullish"},
        ],
    },
    {
        "id": "material",
        "kind": "yesno",
        "instructions": "Does this headline contain new information likely to move the stock today?",
    },
]

scores = sage("/decide/batch", {"requests": [{"content": h, "questions": QUESTIONS} for h in headlines]})

def answers(out, group=0):
    # Each answer is a full decision. Failed ones (ok is false) are left out: treat a missing id as "not sure".
    return {a["result"]["id"]: a["result"]["result"] for a in out["results"][group]["answers"] if a["ok"]}

def reading(i):
    a = answers(scores, i)  # a missing answer reads as not sure: None
    return a.get("sentiment", {}).get("expectation"), a.get("material", {}).get("answer")

ranked = sage("/decide", {
    "content": {"kind": "list", "value": [{"id": str(i), "content": h} for i, h in enumerate(headlines)]},
    "question": {
        "id": "read_first",
        "kind": "sort",
        "instructions": "Sort by how much a trader needs to read this now, most important first.",
    },
})["result"]["sorted"]

for i in map(int, ranked):
    sentiment, moves = reading(i)
    score = "  ?  " if sentiment is None else f"{sentiment:.1f}/5"
    flag = {"yes": "moves", "no": "     "}.get(moves, "  ?  ")
    print(score, flag, headlines[i])
```

## Full recipe (TypeScript, Node 22+, nothing to install: save as `recipe.mts`, run `npx tsx recipe.mts`)

```typescript
// Node 22+. Save as recipe.mts and run npx tsx recipe.mts
async function sage(path: string, body: unknown): Promise<any> {
  const r = await fetch("https://sage.levanto.ai" + path, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.SAGE_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
  return r.json();
}

const headlines = [
  "Halvorsen Freight (HLVR) raises full-year guidance after record third-quarter bookings.",
  "Regulator rejects Veltrix Bio's (VLTX) lead drug; the company cuts 20% of staff.",
  "Quill Robotics (QUIL) CEO says the company is 'cautiously excited about being excited' in a new interview.",
  "Tessel Energy (TSSL) shares unchanged as the company confirms its previously announced dividend.",
];

const QUESTIONS = [
  {
    id: "sentiment",
    kind: "scale",
    instructions: "How does this headline read for the named company's stock?",
    levels: [
      { level: 1, description: "Very bearish" },
      { level: 2, description: "Bearish" },
      { level: 3, description: "Neutral" },
      { level: 4, description: "Bullish" },
      { level: 5, description: "Very bullish" },
    ],
  },
  {
    id: "material",
    kind: "yesno",
    instructions: "Does this headline contain new information likely to move the stock today?",
  },
];

const scores = await sage("/decide/batch", { requests: headlines.map(h => ({ content: h, questions: QUESTIONS })) });

// Each answer is a full decision. Failed ones (ok is false) are left out: treat a missing id as "not sure".
const answers = (out: any, group = 0): Record<string, any> =>
  Object.fromEntries(out.results[group].answers.filter((a: any) => a.ok).map((a: any) => [a.result.id, a.result.result]));

function reading(i: number): [number | null, string | null] {
  const a = answers(scores, i); // a missing answer reads as not sure: null
  return [a.sentiment?.expectation ?? null, a.material?.answer ?? null];
}

const ranked: string[] = (await sage("/decide", {
  content: { kind: "list", value: headlines.map((h, i) => ({ id: String(i), content: h })) },
  question: {
    id: "read_first",
    kind: "sort",
    instructions: "Sort by how much a trader needs to read this now, most important first.",
  },
})).result.sorted;

for (const i of ranked.map(Number)) {
  const [sentiment, moves] = reading(i);
  const score = sentiment === null ? "  ?  " : `${sentiment.toFixed(1)}/5`;
  const flag = moves === "yes" ? "moves" : moves === "no" ? "     " : "  ?  ";
  console.log(score, flag, headlines[i]);
}
```

## Requests

### 1. POST /decide/batch

```bash
curl -s https://sage.levanto.ai/decide/batch \
  -H "Authorization: Bearer $SAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "requests": [
    {
      "content": "Halvorsen Freight (HLVR) raises full-year guidance after record third-quarter bookings.",
      "questions": [
        {
          "id": "sentiment",
          "kind": "scale",
          "instructions": "How does this headline read for the named company's stock?",
          "levels": [
            {
              "level": 1,
              "description": "Very bearish"
            },
            {
              "level": 2,
              "description": "Bearish"
            },
            {
              "level": 3,
              "description": "Neutral"
            },
            {
              "level": 4,
              "description": "Bullish"
            },
            {
              "level": 5,
              "description": "Very bullish"
            }
          ]
        },
        {
          "id": "material",
          "kind": "yesno",
          "instructions": "Does this headline contain new information likely to move the stock today?"
        }
      ]
    },
    {
      "content": "Regulator rejects Veltrix Bio's (VLTX) lead drug; the company cuts 20% of staff.",
      "questions": [
        {
          "id": "sentiment",
          "kind": "scale",
          "instructions": "How does this headline read for the named company's stock?",
          "levels": [
            {
              "level": 1,
              "description": "Very bearish"
            },
            {
              "level": 2,
              "description": "Bearish"
            },
            {
              "level": 3,
              "description": "Neutral"
            },
            {
              "level": 4,
              "description": "Bullish"
            },
            {
              "level": 5,
              "description": "Very bullish"
            }
          ]
        },
        {
          "id": "material",
          "kind": "yesno",
          "instructions": "Does this headline contain new information likely to move the stock today?"
        }
      ]
    },
    {
      "content": "Quill Robotics (QUIL) CEO says the company is 'cautiously excited about being excited' in a new interview.",
      "questions": [
        {
          "id": "sentiment",
          "kind": "scale",
          "instructions": "How does this headline read for the named company's stock?",
          "levels": [
            {
              "level": 1,
              "description": "Very bearish"
            },
            {
              "level": 2,
              "description": "Bearish"
            },
            {
              "level": 3,
              "description": "Neutral"
            },
            {
              "level": 4,
              "description": "Bullish"
            },
            {
              "level": 5,
              "description": "Very bullish"
            }
          ]
        },
        {
          "id": "material",
          "kind": "yesno",
          "instructions": "Does this headline contain new information likely to move the stock today?"
        }
      ]
    },
    {
      "content": "Tessel Energy (TSSL) shares unchanged as the company confirms its previously announced dividend.",
      "questions": [
        {
          "id": "sentiment",
          "kind": "scale",
          "instructions": "How does this headline read for the named company's stock?",
          "levels": [
            {
              "level": 1,
              "description": "Very bearish"
            },
            {
              "level": 2,
              "description": "Bearish"
            },
            {
              "level": 3,
              "description": "Neutral"
            },
            {
              "level": 4,
              "description": "Bullish"
            },
            {
              "level": 5,
              "description": "Very bullish"
            }
          ]
        },
        {
          "id": "material",
          "kind": "yesno",
          "instructions": "Does this headline contain new information likely to move the stock today?"
        }
      ]
    }
  ]
}
JSON
```

Response, abridged (a real run):

```json
{
  "results": [
    {
      "answers": [
        {
          "ok": true,
          "result": {
            "id": "sentiment",
            "kind": "scale",
            "result": {
              "expectation": 4.1947,
              "probabilities": [
                {
                  "level": 1,
                  "probability": 0.0005
                },
                {
                  "level": 2,
                  "probability": 0.0006
                },
                {
                  "level": 3,
                  "probability": 0.0057
                },
                {
                  "level": 4,
                  "probability": 0.7899
                },
                {
                  "level": 5,
                  "probability": 0.2033
                }
              ],
              "confidence": 0.6593
            },
            "meta": {
              "model": "levanto-sage-2"
            }
          },
          "error": null
        },
        {
          "ok": true,
          "result": {
            "id": "material",
            "kind": "yesno",
            "result": {
              "answer": "yes",
              "probability": 0.8871
            },
            "meta": {
              "model": "levanto-sage-2"
            }
          },
          "error": null
        }
      ]
    },
    {
      "answers": [
        {
          "ok": true,
          "result": {
            "id": "sentiment",
            "kind": "scale",
            "result": {
              "expectation": 1.332,
              "probabilities": [
                {
                  "level": 1,
                  "probability": 0.6767
                },
                {
                  "level": 2,
                  "probability": 0.3175
                },
                {
                  "level": 3,
                  "probability": 0.0038
                },
                {
                  "level": 4,
                  "probability": 0.001
                },
                {
                  "level": 5,
                  "probability": 0.001
                }
              ],
              "confidence": 0.5878
            },
            "meta": {
              "model": "levanto-sage-2"
            }
          },
          "error": null
        },
        {
          "ok": true,
          "result": {
            "id": "material",
            "kind": "yesno",
            "result": {
              "answer": "yes",
              "probability": 0.905
            },
            "meta": {
              "model": "levanto-sage-2"
            }
          },
          "error": null
        }
      ]
    },
    "… 2 more, one per request"
  ],
  "meta": {
    "model": "levanto-sage-2",
    "request_count": 4,
    "question_count": 8,
    "latency_ms": 183.2,
    "usage": {
      "billed_input_tokens": 682,
      "image_count": 0,
      "image_tokens": 0,
      "input_tokens": 682,
      "output_tokens": 0
    }
  }
}
```

### 2. POST /decide

```bash
curl -s https://sage.levanto.ai/decide \
  -H "Authorization: Bearer $SAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "content": {
    "kind": "list",
    "value": [
      {
        "id": "0",
        "content": "Halvorsen Freight (HLVR) raises full-year guidance after record third-quarter bookings."
      },
      {
        "id": "1",
        "content": "Regulator rejects Veltrix Bio's (VLTX) lead drug; the company cuts 20% of staff."
      },
      {
        "id": "2",
        "content": "Quill Robotics (QUIL) CEO says the company is 'cautiously excited about being excited' in a new interview."
      },
      {
        "id": "3",
        "content": "Tessel Energy (TSSL) shares unchanged as the company confirms its previously announced dividend."
      }
    ]
  },
  "question": {
    "id": "read_first",
    "kind": "sort",
    "instructions": "Sort by how much a trader needs to read this now, most important first."
  }
}
JSON
```

Response, abridged (a real run):

```json
{
  "id": "read_first",
  "kind": "sort",
  "result": {
    "sorted": [
      "1",
      "0",
      "2",
      "3"
    ],
    "confidence": 0.5624
  },
  "meta": {
    "model": "levanto-sage-2",
    "latency_ms": 109.5,
    "question_count": 1,
    "usage": {
      "billed_input_tokens": 1186,
      "image_count": 0,
      "image_tokens": 0,
      "input_tokens": 1186,
      "output_tokens": 0
    }
  }
}
```

## Docs

- Scale: https://docs.levanto.ai/decision-model/scale
- Yes/No: https://docs.levanto.ai/decision-model/yesno
- Sort: https://docs.levanto.ai/decision-model/sort
- Limits and errors: https://docs.levanto.ai/decision-model/limits
- OpenAPI: https://sage.levanto.ai/openapi.json
