News, data and AI

Beyond prices, an agent can read news, follow accounts, query fundamental and macro data, and call a language model. This page covers when to use each and the patterns that keep them correct and affordable. Signatures and return shapes are in the Manager API and Types.

Code first, the model for what code cannot express

Prefer deterministic code whenever the trigger and the action are a computation over structured data: prices, indicators, positions, schedules, fundamentals. It is cheaper, repeatable and usually backtestable. Reach for manager.llm only when the value of the agent is the reasoning itself: summarising, explaining, classifying free text, judging a post, choosing assets from open research.

Before adding a model call, ask:

  • Did the user leave it a job? "Every time @account posts, buy" states both the trigger and the action: wire them directly. A model earns its place when the request leaves something to interpret ("when the tone is bearish").
  • Does removing it change what the agent does? If the orders and notifications would be identical without it, it only adds cost and a failure mode.
  • Who decides? When the model decides (picks assets, classifies), a missing answer must abort the action. When the code decides (a price alert, a scheduled rebalance), a failed model call must never cancel the action: act, and add the model's text only when it arrived.

News

Poll news on a schedule with searchNews. It searches the whole news store (financial press and monitored posts) and returns structured items you filter yourself.

// Every 15 minutes, one digest of new NVIDIA and AMD headlines, never the same story twice.
interface NewsState { since?: number; seen?: string[] }
const QUERIES = ['NVDA NVIDIA', 'AMD Advanced Micro Devices'];

manager.cron('*/15 * * * *', async () => {
  const state = manager.getStatus<NewsState>('default');
  const now = manager.getCurrentTime();
  const since = state.since ?? now - 15 * 60 * 1000;
  state.since = now; // advance the window before processing
  const seen = state.seen ?? [];
  const lines: string[] = [];
  for (const query of QUERIES) {
    try {
      const items = await manager.searchNews(query, { since, limit: 10 });
      for (const item of items) {
        if (seen.includes(item.id)) continue;
        seen.push(item.id);
        lines.push(`**${item.title}** (${item.source}) ${item.url}`);
      }
    } catch (error) {
      manager.handleError(error); // news service unavailable: skip this query
    }
  }
  state.seen = seen.slice(-200);
  if (lines.length > 0) manager.sendNotification(`New headlines:\n\n${lines.join('\n')}`);
});
  • One query per company or theme: a query mixing dozens of names finds less.
  • Each query is billed; a very frequent schedule times many tickers adds up.
  • Send one digest per run, never one notification per item.
  • searchNews rejects when the service is down; [] means nothing matched.

onNews pushes one precise event as it breaks, with no schedule: manager.onNews({ source: 'x', matchPrompt: 'A major crypto exchange halts withdrawals' }, (news) => …). It is fed only by posts from monitored X accounts, never by press articles, so an event that surfaces as press coverage (an earnings release, a regulator's ruling) belongs to the searchNews pattern. The match is already judged when the callback runs: act on news.content directly.

News agents are not backtestable; validate them in a demo run.

Following an account or a channel

manager.onMessage fires for every post of one X account or public Telegram channel, with no filtering: filter in code.

// Buy $50 of DOGE each time @elonmusk mentions dogecoin.
const symbol = 'DOGE';
manager.onPreciseCandle(symbol, () => {});

manager.onMessage({ source: 'x', handle: 'elonmusk' }, (message) => {
  if (!/doge/i.test(message.content)) return;
  manager.placeOrder({ symbol, type: 'MARKET', position: 'buy', amount: 50, amountCurrency: 'USD' });
});
  • handle is the username without @; keep it a literal so the agent stays backtestable (past posts are replayed at their original time).
  • message is { content, author, url, publishedAt, media }. media ids can be passed to manager.llm(..., { images }) when the pictures matter; never print them.
  • An X follow bills per delivered post, whether or not your code acts on it. Telegram is free but not backtestable.
  • manager.llm is allowed in this callback (there is no upstream filter), and it is the one place a model call works in a backtest.

Fundamental and macro data

Need Method Backtestable
One number for one company (P/E, revenue, margin) getPieuvreMetric(concept, { entity }) yes, point in time
A ranked universe (top 10 by market cap) getPieuvreRank({ metric, universe, order, limit }) yes, point in time
A macro time series (rates, fear and greed) getPieuvreSeries(sourceId, { from, to }) no
What is scheduled (earnings, CPI, Fed) getPieuvreUpcoming({ withinMs, eventTypes, tickers }) no
React when a release lands onPieuvreEvent({ matchPrompt, sourceIds, minSeverity }, cb) no

Rules that avoid silent failures:

  • Read .value. getPieuvreMetric returns an object ({ value, unit?, period?, asOf, sourceId }) or undefined; comparing the object itself to a number never matches.
  • Ratios are fractions. Margins, yields and returns come back as 0.20 for 20%. Compare against 0.20. The list is in Types.
  • Use real metric names. getPieuvreRank accepts only the listed names (the validator checks literals: TS9027 and the type). There is no RSI or volume-spike ranking: compute those from candles.
  • Use real universe tags, such as 'sp500', 'us_equities', 'eu_equities', 'asia_equities', 'crypto_top30'. An unknown tag returns [] without error.
  • Trade rank entries by their key: entry.snaptradeSymbol ?? entry.backtestSymbol, skipping entries where both are null, and size with a cash amount. Name them to users by entry.ticker.
  • An outage is not an empty answer. These reads reject when the data service is down. Catch, and skip the cycle: never treat an error as "no constituents" (a rebalancer would sell everything).
  • Narrow the calendar. getPieuvreUpcoming without tickers or eventTypes returns thousands of rows on a busy day and the ones you want may be cut. For macro releases read sourceIds: ['eodhd.economic_calendar'], eventTypes: ['economic_release'] and classify by payload.country and payload.type (for example 'Inflation Rate', 'Non Farm Payrolls', 'Fed Interest Rate Decision').
  • A calendar ticker is not a trading key. To trade it, map it onto a declared symbol, or resolve it with resolveAssets.
// React to a US CPI surprise: buy TLT when inflation prints below consensus, sell what the agent bought when above.
const symbol = 'TLT';
manager.onPreciseCandle(symbol, () => {});

manager.onPieuvreEvent({ matchPrompt: 'US CPI inflation release', sourceIds: ['eodhd.economic_calendar'], minSeverity: 'medium' }, (event) => {
  const actual = event.payload?.['actual'];
  const estimate = event.payload?.['estimate'];
  if (typeof actual !== 'number' || typeof estimate !== 'number') return;
  const open = manager.getSymbolPendingTrades(symbol);
  if (actual < estimate && open.length === 0) {
    manager.openTrade({ symbol, type: 'MARKET', position: 'buy', positionSize: { type: 'amount', amount: 2000 }, comment: `CPI ${actual} below the ${estimate} consensus` });
  } else if (actual > estimate) {
    for (const trade of open) manager.closeTrade(trade, { cause: `CPI ${actual} above the ${estimate} consensus` });
  }
});

Calling a language model

manager.llm(name, systemPrompt, userPrompt, schema, options?) returns an object matching a z schema, or undefined. The patterns:

  • Guard the result. undefined covers every failure. Validate the whole answer before acting on any of it; never interleave validation and orders.
  • Hard numbers come from feeds, never from the model. Read prices with getCurrentPrice or the model's marketData tool, fundamentals with getPieuvreMetric. Any number the model may not know must be .nullable() in the schema (a required z.number() forces it to invent one); check for null before comparing, and reject values far from a real reference.
  • Cap lists in code. If the request says "3 stocks", slice(0, 3) the model's list.
  • Trade only keys you can trust: a declared symbol, a rank entry's key, or a tradingKey the model copied from its search_assets tool (with marketData: true). Never trade a ticker string the model wrote.
  • Every schema key is required; make optional ones .nullable(). The result type is inferred; to name it, write type Verdict = z.infer<typeof verdictSchema>.
  • On a schedule or candle, claim a period key first (TS9026, see Schedules).
  • Facts first. When the code decides a notice is due, the code writes its facts (what, when, the figures) and the model only adds analysis below them. If the call fails, send the facts alone.
  • Tools: webSearch (open web, billed), news (Obside news search, billed), pieuvre (fundamentals and the economic calendar), marketData (quotes, daily history, asset resolution). Enable only what the task needs.
  • Limits: 12 model turns per call and a per-call spend cap; beyond them the result is undefined. Out of AI usage in a live or demo run, the call also resolves to undefined until the monthly reset; the agent keeps running and keeps its positions.
  • Backtests: a model call works only inside an onMessage callback. An agent calling a model from a schedule or candle is validated in a demo run.
// Alert when NVDA closes below a level, with a short AI explanation under the facts.
const symbol = 'NVDA';
const zone = 'America/New_York';
const level = manager.getVariable('level', 120);
const daily = manager.getIntervalData(symbol, 1440, zone);

interface AlertState { lastCloseTime?: number; below?: boolean }
const explanationSchema = z.object({
  explanation: z.string().describe('Two sentences on what likely drove the move. No prices or dates.'),
});

manager.onIntervalCandle(symbol, 1440, async () => {
  if (!daily.hasValue()) return;
  const candle = daily.getValue();
  const state = manager.getStatus<AlertState>('default');
  if (state.lastCloseTime === candle.closeTime) return;
  state.lastCloseTime = candle.closeTime; // claimed before any await
  const below = candle.close < level;
  if (!below || state.below === true) {
    state.below = below; // a close back above re-arms the alert
    return;
  }
  state.below = true;
  const day = DateTime.fromMillis(candle.openTime).setZone(zone).toFormat('yyyy-LL-dd');
  const facts = `**NVDA** closed at **$${candle.close.toFixed(2)}** on ${day}, below your $${level} level.`;
  let explanation: string | undefined;
  try {
    const result = await manager.llm('default', 'You explain a stock move in two sentences. Never state a price or a date.', `NVDA closed at ${candle.close.toFixed(2)} on ${day}. Explain the session.`, explanationSchema, { news: true });
    explanation = result?.explanation.trim();
  } catch (error) {
    manager.handleError(error);
  }
  manager.sendNotification(explanation === undefined || explanation === '' ? facts : `${facts}\n\n${explanation}`);
}, zone);

The model id: when you hand code to create_agent, Obside's builder sets it (name the model you want in the prompt). Any string passes validation, so 'default' is a clear placeholder in a draft.

Notifications, journal and webhooks

  • Notify only when asked. An agent the user did not ask to alert them stays silent.
  • One notification per callback run. Collect lines in an array and send one digest; a notification sent from inside a loop is refused when the agent is built.
  • Say what happened, with runtime values: the asset, the quantity or amount, the price. Bold the one or two key figures with **…**.
  • Alert once per change of state. Latch the state in the status bag so a condition that stays true does not alert on every tick, and re-arm when it clears.
  • Never show internal identifiers: trading keys (from rankings, resolved assets or held positions), data-source ids, media ids. Name assets by your symbol, ticker or name.
  • manager.comment(text) writes to the agent's journal (live and demo runs), the right place for an autonomous agent's reasoning. manager.debugLog is for backtests only.
  • manager.sendWebhook(payload) posts JSON to the URL the user configured; it does nothing without one and never throws. Keep payloads small and stable. It does nothing in a backtest.
Browse the documentation