openvibers/openvibe.trade Commits
Trade: SEC's filed numbers and AI briefs that cite them (#69)
Changes
@@@ -4,10 +4,10 @@**Status:** alpha (roadmap Wave 19). The service runs, its tests pass, and it is **public at itsdomain, [openvibe.trade](https://openvibe.trade), since 2026-10-09**, after an independent--pre-launch security review whose fixes shipped first. `/api/ready` reports `degraded:--["freshness"]` because there is no feed, and the database is empty (0 instruments) until editors add--the first ones. Its capabilities and service manifest are registered in openvibe-contracts. No--market data feed is configured yet (see "What it shows today").++pre-launch security review whose fixes shipped first. Since 2026-10-15 it carries about 8,000++US-listed companies from the SEC's own ticker list, their XBRL filings as they arrive, the key numbers++in those filings, and AI briefs that cite them (see "What it shows today"). Its capabilities and++service manifest are registered in openvibe-contracts. No price feed is configured.**Domain:** `openvibe.trade` · **Port:** 4860 · **Service id:** `trade`**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.11;roadmap Wave 19, §15.13, §29, §32. **Binding decision:** ADR-025 (marketplace and commerce scope).@@@ -20,7 +20,8 @@ roadmap Wave 19, §15.13, §29, §32. **Binding decision:** ADR-025 (marketplaceAn informational product: instruments and their aliases with deterministic resolution, marketobservations that always show when they were observed, when they were retrieved and which sourcestated them, filings and other source documents from OpenVibe.Sources, private watchlists, alert--rules, and short context written by editors (or drafted by OpenVibe.AI and reviewed by a person).++rules, short AI briefs written from SEC's filed numbers and filings (labelled, not reviewed by a person), and++context written by editors (or drafted by OpenVibe.AI and reviewed by a person).Per ADR-025 Trade is informational **only**. There is no custody, no order execution, no escrow, nolisting between users and no personalised financial advice — not as a feature, not as an endpoint,@@@ -120,10 +121,29 @@ Other tables in the same database:### What it shows today--Trade shows only what a source stated. OpenVibe.Sources' one trade source (`sec-xbrl-filings`) is--**disabled** until a person verifies its terms, and it carries filings, not prices. So a fresh--deployment has no instruments (editors add them), no documents until the source is enabled, and--**no numbers at all** until a market data source that states values exists — which is correct.++Trade shows only what a source stated:++++- **Companies:** SEC's ticker list (Sources `sec-company-tickers`, daily): one instrument per CIK, other share++ classes as ticker aliases (`domain/ingest.js`).++- **Filings:** SEC's XBRL filings feed (Sources `sec-xbrl-filings`, every 15 minutes), matched by CIK.++- **Numbers:** SEC's XBRL company facts, read straight from `data.sec.gov` (`clients/sec.js`, `domain/facts.js`)++ when a company files a 10-K, 10-Q, 20-F or 40-F, or when a reader asks for a brief:++ - revenue, net income, operating income, diluted EPS and operating cash flow for the last five quarters and two++ years;++ - assets, liabilities, equity, cash, long-term debt and shares outstanding at the last five quarter ends.++++ Only SEC's framed facts are used (one per period), each an immutable observation with its period, its filing and++ `source_key sec-companyfacts`.++- **Rates:** euro reference rates from the ECB and Frankfurter.++- **AI briefs** (`domain/briefs.js`, OpenVibe.AI `trade.summarize_market_context`):++ - written from a company's numbers and filings list only, when a report brings new numbers (the worker,++ `TRADE_BRIEFS_PER_TICK`) or when a reader asks (`POST /i/:symbol/brief`: same origin, 5 per address per 10++ minutes), within `TRADE_BRIEFS_PER_HOUR` for the whole site;++ - every kept point cites Trade's own observation or document rows, and an uncited point is dropped and listed;++ - text that reads as advice is refused, and so is a placeholder run;++ - shown labelled "AI-written, not reviewed by a person". They do not make a page indexable: that still needs a++ person-reviewed context.++- **No prices:** no price feed is configured.### Routes (server-rendered, useful without JavaScript)@@@ -268,7 +288,11 @@ services need to call Trade, is under [Grants the Network must hold](#grants-the- **OpenVibe.Network notifications:** Network consumes `trade.alert.triggered` (its `network`subscription) and puts a "Trade alert: <symbol>" notification in the rule owner's inbox, linking theinstrument page; deliveries are also listed at `/watchlists`.--- **OpenVibe.AI:** optional, for drafts.++- **OpenVibe.AI:** `ai.run.create` (namespace `trade`) for the AI briefs (`OV_AI_INTERNAL_URL`; off without it), and++ optionally the drafts below.++- **SEC EDGAR** (`data.sec.gov`, read directly, no key): XBRL company facts, public domain. Fair access: the++ User-Agent `TRADE_SEC_USER_AGENT` names us and a contact; requests are spaced `TRADE_SEC_MIN_INTERVAL_MS` (1 s)++ apart, at most `TRADE_SEC_PER_TICK` companies a tick. `TRADE_SEC_FACTS=off` turns it off.### Grants the Network must hold@@@ -279,6 +303,7 @@ Each grant is `[client, capability, audience]`:- `[trade, network.account.export.contribute, openvibe.network]` and `[trade, network.account.deletion.confirm, openvibe.network]` (ADR-033; granted last, once this release is live)- `[trade, sources.item.read, openvibe.sources]`- `[trade, sources.source.read, openvibe.sources]`++- `[trade, ai.run.create, openvibe.ai, ['trade.*']]` (the AI briefs)- For OpenVibe.AI to deliver drafts: `[ai, trade.context.read, openvibe.trade]` and`[ai, trade.context.propose, openvibe.trade]`- For a first-party market data feed, when one exists: `[<feed client>, trade.observation.write, openvibe.trade]`nnew file mode 100644@@@ -0,0 +1,29 @@++-- phase: expand++-- AI market context (server/domain/briefs.js, facts.js): a company's key numbers are read from SEC's XBRL company facts++-- when it files a report (trade_facts_state: when, and whether it worked), and OpenVibe.AI writes a short brief from++-- those numbers and the filings list. A brief cites Trade's own observations and documents and is shown labelled as++-- AI-written and not reviewed by a person; editor-reviewed context (trade_context_revisions) is separate.++CREATE TABLE IF NOT EXISTS trade_facts_state (++ instrument_id text COLLATE "C" PRIMARY KEY REFERENCES trade_instruments(id),++ fetched_at bigint NOT NULL,++ ok bigint NOT NULL,++ error text,++ observations bigint NOT NULL DEFAULT 0++);++++CREATE TABLE IF NOT EXISTS trade_ai_briefs (++ id text COLLATE "C" PRIMARY KEY, -- tbr_<ULID>++ instrument_id text COLLATE "C" NOT NULL REFERENCES trade_instruments(id),++ summary text NOT NULL,++ points jsonb NOT NULL DEFAULT '[]', -- [{ text, observed_at, cites: [{ kind, id }] }]++ cites jsonb NOT NULL DEFAULT '[]', -- [{ kind: document|observation, id }]++ gaps jsonb NOT NULL DEFAULT '[]',++ as_of bigint, -- the newest observation or document time it cites++ inputs_hash text NOT NULL, -- what it was written from: same inputs, no new brief++ run_id text,++ model text,++ trigger text NOT NULL CHECK (trigger IN ('report', 'reader')),++ created_at bigint NOT NULL++);++CREATE INDEX IF NOT EXISTS trade_ai_briefs_latest ON trade_ai_briefs (instrument_id, created_at DESC);++CREATE INDEX IF NOT EXISTS trade_ai_briefs_recent ON trade_ai_briefs (created_at DESC);@@@ -31,6 +31,11 @@ tr.is-stale td, tr.is-stale th { background: rgba(245, 158, 11, .07); }.docs { list-style: none; padding: 0; }.docs li { padding: 8px 0; border-bottom: 1px solid var(--border, #1f2d47); }.docs li:target, tr:target { outline: 2px solid var(--accent, #3b82f6); }++.briefs { list-style: none; padding: 0; display: grid; gap: 10px; }++.briefs li { padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); }++.briefs p { margin: 6px 0 4px; }++.ai-brief { border-left: 3px solid var(--accent, #3b82f6); padding: 2px 0 2px 14px; }++.ai-brief .cite { font-size: .8rem; text-decoration: none; }.context-body { border-left: 3px solid var(--border-light, #2c3d5c); padding-left: 14px; }.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 6px 12px; }.notice { border-left: 3px solid var(--info, #38bdf8); padding: 6px 12px; background: var(--bg-card, #131c2e); }@@@ -99,6 +99,11 @@ async function createApp(opts = {}) {ctx.reading = createReading({ store, ctx });ctx.sources = opts.sourcesClient || createSourcesClient({ config, fetchImpl, timeoutMs: 15_000 });ctx.sync = createIngest({ store, config, ctx, sources: ctx.sources, log });++ // SEC's filed numbers and the AI briefs written from them (domain/facts.js, domain/briefs.js).++ ctx.sec = opts.sec || require('./clients/sec').createSec({ config, fetchImpl });++ ctx.facts = require('./domain/facts').createFacts({ store, ctx, sec: ctx.sec, log });++ ctx.ai = opts.ai || require('./clients/ai').createAi({ config, fetchImpl });++ ctx.briefs = require('./domain/briefs').createBriefs({ store, config, ctx, ai: ctx.ai, log });// Sign-in with OpenVibe.Network (openvibe-sdk/sso): routes, offline session verification through the// shared JWKS client, state + PKCE. ctx.jwks is the same client viewer.js verifies service tokens with.ctx.jwksUrl = `${String(config.networkInternalUrl || config.networkUrl).replace(/\/+$/, '')}/api/.well-known/jwks`;@@@ -113,7 +118,7 @@ async function createApp(opts = {}) {ctx.jwks.keys().catch(() => {}); // warm the key cache at boot (non-fatal if the Network is down)ctx.viewers = createViewerResolver({ auth: ctx.auth, config, jwksUrl: ctx.jwksUrl });ctx.common = createCommon({ config, store });-- ctx.worker = createWorker({ config, sync: ctx.sync, freshness: ctx.freshness, indexing: ctx.indexing, outbox: ctx.outbox, log });++ ctx.worker = createWorker({ config, sync: ctx.sync, freshness: ctx.freshness, indexing: ctx.indexing, outbox: ctx.outbox, facts: ctx.facts, briefs: ctx.briefs, sec: ctx.sec, log });const app = express();app.disable('x-powered-by');nnew file mode 100644@@@ -0,0 +1,53 @@++'use strict';++++/**++ * OpenVibe.AI seam: run trade.summarize_market_context over what Trade itself recorded for one instrument.++ *++ * POST {OV_AI_INTERNAL_URL}/api/v1/runs?wait=… { workflow, input: { instrument, stale_after_hours, sources } }++ * (this service's token for audience openvibe.ai, capability ai.run.create, namespace trade)++ *++ * Only Trade's own rows go to the model: SEC's filed numbers (observations) and the filings list (documents),++ * each with its dates and source. A failed or unfinished run produces nothing.++ */++const { serviceAuth } = require('openvibe-contracts');++++const WORKFLOW = 'trade.summarize_market_context';++++class AiError extends Error {++ constructor(code, message, status = 502) { super(message); this.code = code; this.status = status; }++}++++function createAi({ config, fetchImpl = globalThis.fetch }) {++ const enabled = Boolean(config.ai.internalUrl && config.oauth.clientSecret);++ const tokens = enabled ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.ai', scope: 'ai.run.create', fetchImpl,++ }) : null;++++ /** input: { instrument: { symbol, name, exchange }, stale_after_hours, sources: [AI source] } → { runId, model, stub, output } */++ async function run(input) {++ if (!enabled) throw new AiError('ai.not_configured', 'OpenVibe.AI is not configured (OV_AI_INTERNAL_URL)', 503);++ let res;++ try {++ res = await fetchImpl(`${config.ai.internalUrl}/api/v1/runs?wait=${config.ai.waitMs}`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },++ body: JSON.stringify({ workflow: WORKFLOW, input }),++ signal: AbortSignal.timeout(config.ai.waitMs + 5000),++ });++ } catch (err) {++ throw new AiError('ai.unavailable', `OpenVibe.AI is unreachable: ${err.message}`);++ }++ const body = await res.json().catch(() => null);++ if (!res.ok && res.status !== 202) throw new AiError(`ai.http_${res.status}`, `OpenVibe.AI answered ${res.status}${body && body.code ? ` (${body.code})` : ''}`);++ const r = body && body.run;++ if (!r || r.status !== 'succeeded' || !r.output || typeof r.output !== 'object') {++ throw new AiError('ai.no_result', `The ${WORKFLOW} run did not finish with a result (${r ? r.status : 'no run'})`);++ }++ return { runId: String(r.id), model: r.provenance && r.provenance.model ? String(r.provenance.model) : null, stub: Boolean(r.synthetic), output: r.output };++ }++++ return { enabled, WORKFLOW, run };++}++++module.exports = { createAi, AiError, WORKFLOW };nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++++/**++ * SEC EDGAR's XBRL API, read directly: https://data.sec.gov/api/xbrl/companyfacts/CIK##########.json. The host is++ * fixed (never a URL a person chose), so this is not an open fetcher. SEC's fair-access policy asks for a User-Agent++ * that names the requester and gives a contact (TRADE_SEC_USER_AGENT), and at most 10 requests a second: requests++ * here are spaced at least TRADE_SEC_MIN_INTERVAL_MS apart (1 s by default). A body over 40 MB is refused.++ *++ * companyFacts(cik) → { doc, retrievedAt } — doc is null when SEC has no XBRL facts for the company (404).++ */++const BASE = 'https://data.sec.gov/api/xbrl/companyfacts';++const MAX_BYTES = 40 * 1024 * 1024;++++function createSec({ config, fetchImpl = globalThis.fetch, now = () => Date.now() }) {++ const enabled = config.sec.enabled;++ let last = 0;++ let chain = Promise.resolve();++++ /** One request at a time, at least minIntervalMs after the previous one started. */++ function spaced(fn) {++ const run = chain.then(async () => {++ const wait = last + config.sec.minIntervalMs - now();++ if (wait > 0) await new Promise((r) => setTimeout(r, wait));++ last = now();++ return await fn();++ });++ chain = run.catch(() => {});++ return run;++ }++++ async function companyFacts(cik) {++ if (!enabled) throw new Error('SEC facts are off (TRADE_SEC_FACTS=off)');++ const padded = String(cik).replace(/\D/g, '').padStart(10, '0');++ if (!/^\d{10}$/.test(padded)) throw new Error('not a CIK');++ return await spaced(async () => {++ const res = await fetchImpl(`${BASE}/CIK${padded}.json`, {++ headers: { 'User-Agent': config.sec.userAgent, Accept: 'application/json' },++ signal: AbortSignal.timeout(30_000),++ redirect: 'error',++ });++ const retrievedAt = now();++ if (res.status === 404) return { doc: null, retrievedAt };++ if (!res.ok) throw new Error(`SEC answered ${res.status}`);++ const len = Number(res.headers.get('content-length') || 0);++ if (len > MAX_BYTES) throw new Error(`SEC facts too large (${len} bytes)`);++ const text = await res.text();++ if (text.length > MAX_BYTES) throw new Error('SEC facts too large');++ let doc;++ try { doc = JSON.parse(text); } catch { throw new Error('SEC facts are not JSON'); }++ return { doc, retrievedAt };++ });++ }++++ return { enabled, companyFacts };++}++++module.exports = { createSec };@@@ -96,6 +96,29 @@ function load(env = process.env) {freshnessIntervalMs: int(env.TRADE_FRESHNESS_INTERVAL_MS, 60_000),},++ // SEC EDGAR's XBRL company facts (domain/facts.js, clients/sec.js): read for a company when it files a report++ // (the briefs tick, at most perTick a tick) or when a reader asks for a brief. SEC's fair access wants a++ // User-Agent with a contact and at most 10 requests a second; requests here are spaced minIntervalMs apart.++ sec: {++ enabled: env.TRADE_SEC_FACTS !== 'off',++ userAgent: String(env.TRADE_SEC_USER_AGENT || 'OpenVibe.Trade/1 (+https://openvibe.trade; [email protected])'),++ minIntervalMs: Math.max(int(env.TRADE_SEC_MIN_INTERVAL_MS, 1000), 150),++ perTick: Math.max(int(env.TRADE_SEC_PER_TICK, 10), 0),++ },++++ // OpenVibe.AI (clients/ai.js): trade.summarize_market_context writes the AI briefs. Off without the URL.++ ai: {++ internalUrl: trim(env.OV_AI_INTERNAL_URL || ''),++ waitMs: int(env.TRADE_AI_WAIT_MS, 60_000),++ },++ // AI briefs (domain/briefs.js): the worker writes at most perTick each intervalMs, and the whole site (the++ // worker and readers together) at most perHour.++ briefs: {++ intervalMs: Math.max(int(env.TRADE_BRIEFS_INTERVAL_MS, 10 * 60_000), 10_000),++ perTick: Math.max(int(env.TRADE_BRIEFS_PER_TICK, 6), 0),++ perHour: Math.max(int(env.TRADE_BRIEFS_PER_HOUR, 40), 0),++ },++// Browser origins that may call /api/v1 with a Bearer Network JWT (no cookies cross origins).apiCorsOrigins: list(env.API_CORS_ORIGINS || 'https://openvibe.network'),nnew file mode 100644@@@ -0,0 +1,194 @@++'use strict';++++/**++ * AI briefs: a short market context for one instrument, written by OpenVibe.AI (trade.summarize_market_context) from++ * what Trade itself recorded — SEC's filed numbers (observations, ./facts.js) and the filings list (documents) — and++ * shown on the instrument page labelled as AI-written and not reviewed by a person.++ *++ * sources sent one per metric series (its last values with their periods and accessions) and one per recent++ * filing, at most 50; nothing else about the company, and nothing about any reader++ * kept the summary, and each point the model tied to a source (a point without a valid citation is++ * dropped and listed as a gap); citations become Trade's own { kind, id } refs++ * refused text that reads as advice or a recommendation (context.js ADVICE_RE, ADR-025), a stub run, or++ * a run with no result: no brief at all, never a partial one++ * once per input the inputs' hash is stored; the same numbers and filings make no second brief++ *++ * Briefs are made after a company's report brings new numbers (the worker, trigger 'report') or when a reader asks++ * (POST /i/:symbol/brief, trigger 'reader'), within TRADE_BRIEFS_PER_HOUR for the whole site.++ */++const crypto = require('crypto');++const { ADVICE_RE } = require('./context');++const { labelOf, SOURCE_KEY: FACTS_KEY } = require('./facts');++const { newId, iso } = require('./helpers');++const { ApiError } = require('../http/errors');++++const MAX_SOURCES = 50;++const MAX_DOCS = 12;++const PER_SERIES = 7;++++function createBriefs({ store, config, ctx, ai, log = console }) {++ const { db } = store;++ const q = {++ latest: db.prepare('SELECT * FROM trade_ai_briefs WHERE instrument_id = ? ORDER BY created_at DESC, id DESC LIMIT 1'),++ recent: db.prepare(`SELECT b.*, i.symbol, i.name FROM trade_ai_briefs b JOIN trade_instruments i ON i.id = b.instrument_id++ WHERE i.status = 'active' AND b.id = (SELECT id FROM trade_ai_briefs x WHERE x.instrument_id = b.instrument_id ORDER BY created_at DESC, id DESC LIMIT 1)++ ORDER BY b.created_at DESC LIMIT ?`),++ lastHour: db.prepare('SELECT COUNT(*) AS n FROM trade_ai_briefs WHERE created_at > ?'),++ insert: db.prepare(`INSERT INTO trade_ai_briefs (id, instrument_id, summary, points, cites, gaps, as_of, inputs_hash, run_id, model, trigger, created_at)++ VALUES (@id, @instrument_id, @summary, @points, @cites, @gaps, @as_of, @inputs_hash, @run_id, @model, @trigger, @created_at)`),++ series: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = ?++ ORDER BY metric, observed_at DESC, recorded_at DESC`),++ docs: db.prepare(`SELECT * FROM trade_source_documents WHERE instrument_id = ? AND removed_at IS NULL++ ORDER BY COALESCE(published_at, retrieved_at) DESC LIMIT ?`),++ // Companies whose numbers were read after their last brief (or that have none): a report brought new numbers.++ due: db.prepare(`SELECT i.* FROM trade_instruments i JOIN trade_facts_state s ON s.instrument_id = i.id++ WHERE i.status = 'active' AND s.ok = 1 AND s.observations > 0++ AND NOT EXISTS (SELECT 1 FROM trade_ai_briefs b WHERE b.instrument_id = i.id AND b.created_at >= s.fetched_at)++ ORDER BY s.fetched_at ASC LIMIT ?`),++ };++++ const fmtDay = (t) => (t == null ? 'no date' : new Date(Number(t)).toISOString().slice(0, 10));++++ /** The model's sources, in citation order, and what each one cites on Trade's side. */++ async function inputsFor(instrument) {++ const sources = [];++ const refs = [];++ const groups = new Map();++ for (const o of await q.series.all(instrument.id)) {++ const key = `${o.metric}|${o.unit}`;++ if (!groups.has(key)) groups.set(key, []);++ const g = groups.get(key);++ // One value per period (a restatement under a later accession is newer: the first row seen is kept).++ if (g.length < PER_SERIES && !g.some((x) => x.observed_at === o.observed_at && (x.period || '') === (o.period || ''))) g.push(o);++ }++ for (const rows of groups.values()) {++ if (sources.length >= MAX_SOURCES - MAX_DOCS) break;++ const o = rows[0];++ const fromSec = o.source_key === FACTS_KEY;++ sources.push({++ source_type: fromSec ? 'trade.sec_fact' : 'trade.observation',++ source_id: o.id,++ title: `${instrument.symbol}: ${labelOf(o.metric)}${fromSec ? ', from SEC filings' : ''}`,++ observed_at: iso(o.observed_at),++ retrieved_at: iso(o.retrieved_at),++ ...(o.source_url ? { url: o.source_url } : {}),++ content: rows.map((r) => `${r.period || iso(r.observed_at)}: ${r.value} ${r.unit}${fromSec ? ` (filing ${String(r.source_ref).split(':')[1]})` : ` (${r.source_key})`}`).join('\n'),++ provenance: { service: 'trade', source_key: o.source_key },++ });++ refs.push(rows.map((r) => ({ kind: 'observation', id: r.id, at: r.observed_at })));++ }++ for (const d of await q.docs.all(instrument.id, MAX_DOCS)) {++ if (sources.length >= MAX_SOURCES) break;++ sources.push({++ source_type: d.kind === 'filing' ? 'trade.sec_filing' : 'trade.document',++ source_id: d.id,++ title: `${d.form_type ? `${d.form_type}: ` : ''}${d.title || d.filer_name || 'document'}`,++ ...(d.published_at ? { published_at: iso(d.published_at) } : {}),++ retrieved_at: iso(d.retrieved_at),++ ...(d.url ? { url: d.url } : {}),++ snippet: `${d.form_type || 'Document'} filed ${fmtDay(d.published_at)}${d.accession ? `, accession ${d.accession}` : ''}.`,++ provenance: { service: 'trade', source_key: d.source_key },++ });++ refs.push([{ kind: 'document', id: d.id, at: d.published_at || d.retrieved_at }]);++ }++ const hash = crypto.createHash('sha256').update(JSON.stringify(sources.map((s) => [s.source_id, s.content || s.snippet]))).digest('hex');++ return { sources, refs, hash };++ }++++ const lastHour = async () => (await q.lastHour.get(store.now() - 3600_000)).n;++++ /**++ * Write a brief for the instrument now. → { brief, created } (created false: the inputs are unchanged since the++ * last brief). Throws ApiError: 409 nothing to write from, 429 the hourly budget is spent, 422 advice refused,++ * 502/503 OpenVibe.AI.++ */++ async function make(instrument, { trigger = 'report' } = {}) {++ const { sources, refs, hash } = await inputsFor(instrument);++ if (!sources.length) throw new ApiError(409, 'brief.no_inputs', `Trade has no SEC numbers or filings for ${instrument.symbol} yet, so there is nothing to write a brief from`);++ const prior = await q.latest.get(instrument.id);++ if (prior && prior.inputs_hash === hash) return { brief: view(prior), created: false };++ if (await lastHour() >= config.briefs.perHour) throw new ApiError(429, 'brief.budget', 'The AI briefs budget for this hour is used up; try again later');++++ let r;++ try {++ r = await ai.run({++ instrument: { symbol: instrument.symbol, name: String(instrument.name).slice(0, 200), ...(instrument.exchange ? { exchange: instrument.exchange } : {}) },++ stale_after_hours: 24 * 120,++ sources,++ });++ } catch (err) {++ throw new ApiError(err.status || 502, err.code || 'ai.error', `No AI brief: ${err.message}`);++ }++ if (r.stub) throw new ApiError(503, 'brief.stub', 'OpenVibe.AI answered with a placeholder (no model is configured); no brief was made');++ const out = r.output || {};++ const gaps = Array.isArray(out.gaps) ? out.gaps.map((g) => String(g).slice(0, 300)).slice(0, 20) : [];++ const citeRefs = (list) => {++ const seen = new Set();++ const got = [];++ for (const n of Array.isArray(list) ? list : []) {++ if (!Number.isInteger(n) || n < 0 || n >= refs.length) continue;++ for (const ref of refs[n]) {++ const k = `${ref.kind}:${ref.id}`;++ if (!seen.has(k)) { seen.add(k); got.push(ref); }++ }++ }++ return got;++ };++ const summary = String(out.summary || '').trim().slice(0, 3000);++ const points = [];++ for (const p of Array.isArray(out.observations) ? out.observations : []) {++ const text = String(p && p.text || '').trim().slice(0, 600);++ if (!text) continue;++ const cites = citeRefs(p.citations);++ if (!cites.length) { gaps.push(`Left out (no source cited): ${text.slice(0, 200)}`); continue; }++ points.push({ text, observed_at: p.observed_at ? String(p.observed_at).slice(0, 40) : null, cites: cites.map(({ kind, id }) => ({ kind, id })) });++ }++ if (!summary && !points.length) throw new ApiError(502, 'brief.empty', 'OpenVibe.AI returned no text it could support from the sources');++ if (ADVICE_RE.test(summary) || points.some((p) => ADVICE_RE.test(p.text))) {++ log.warn(`[Trade] AI brief for ${instrument.symbol} refused: it read as advice`);++ throw new ApiError(422, 'brief.advice_refused', 'The AI text read as investment advice, so it was not kept (Trade publishes information only)');++ }++ const all = citeRefs(out.citations).concat(points.flatMap((p) => p.cites.map((c) => refs.flat().find((x) => x.kind === c.kind && x.id === c.id))).filter(Boolean));++ const cites = [];++ const seen = new Set();++ let asOf = null;++ for (const c of all) {++ const k = `${c.kind}:${c.id}`;++ if (seen.has(k)) continue;++ seen.add(k);++ cites.push({ kind: c.kind, id: c.id });++ if (c.at != null && (asOf == null || c.at > asOf)) asOf = c.at;++ }++ const row = {++ id: newId('tbr', store.now()), instrument_id: instrument.id, summary, points: JSON.stringify(points), cites: JSON.stringify(cites),++ gaps: JSON.stringify(gaps.slice(0, 20)), as_of: asOf, inputs_hash: hash, run_id: r.runId, model: r.model, trigger, created_at: store.now(),++ };++ await q.insert.run(row);++ return { brief: view(await q.latest.get(instrument.id)), created: true };++ }++++ const parse = (v, d) => { if (v == null) return d; if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return d; } };++++ function view(row) {++ if (!row) return null;++ return {++ id: row.id, summary: row.summary, points: parse(row.points, []), cites: parse(row.cites, []), gaps: parse(row.gaps, []),++ as_of: iso(row.as_of), created_at: iso(row.created_at), model: row.model || null, trigger: row.trigger,++ authorship: { kind: 'ai', reviewed: false, workflow: ai.WORKFLOW, run_id: row.run_id || null },++ disclaimer: 'Written by OpenVibe.AI from the SEC numbers and filings on this page, not reviewed by a person. Informational only; not investment advice.',++ ...(row.symbol ? { symbol: row.symbol, name: row.name } : {}),++ };++ }++++ return {++ enabled: ai.enabled,++ make,++ inputsFor,++ latest: async (instrument) => view(await q.latest.get(instrument.id)),++ recent: async (limit = 8) => (await q.recent.all(limit)).map(view),++ due: async (limit) => await q.due.all(limit),++ lastHour,++ };++}++++module.exports = { createBriefs };nnew file mode 100644@@@ -0,0 +1,174 @@++'use strict';++++/**++ * A listed company's key numbers from its own SEC filings: EDGAR's XBRL "company facts"++ * (https://data.sec.gov/api/xbrl/companyfacts/CIK##########.json, public domain, the SEC's fair-access++ * policy: a User-Agent that names us and a contact, at most 10 requests a second; Trade makes at most one++ * a second and only when a company files a report or a reader asks).++ *++ * SEC tags one fact per company per calendar period with a `frame` (CY2025 a year, CY2026Q2 a quarter,++ * CY2026Q2I an instant): the fact that period is best described by, already de-duplicated across filings.++ * Only framed facts are read, so a comparative repeated in later reports is never counted twice.++ *++ * flows revenue, net income, operating income, diluted EPS, operating cash flow:++ * the last five quarters (metric <name>.q) and the last two years (<name>.fy)++ * instants total assets, total liabilities, stockholders' equity, cash, long-term debt, shares++ * outstanding: the last five quarter ends (<name>)++ *++ * Each becomes an observation (domain/observations.js): the value exactly as filed, its unit, the period++ * ("3 months to 2026-06-27"), observed_at the period's end, source_key sec-companyfacts and source_ref++ * <CIK>:<accession>:<concept>:<frame> (one observation per filed fact; a restatement under a new accession is a++ * new observation and both stay on record). Nothing is computed here: growth and margins are left to the++ * reader and the AI brief, which cite these rows.++ */++const { iso, parseTime } = require('./helpers');++++const SOURCE_KEY = 'sec-companyfacts';++const SOURCE_NAME = 'SEC EDGAR: company financial facts (XBRL)';++const TERMS = 'Public EDGAR data (U.S. Securities and Exchange Commission), public domain. Fair access: a User-Agent naming the requester and a contact, at most 10 requests per second; Trade asks once per company report. Informational only: not investment advice.';++const LICENSE = 'US government work: public domain (17 U.S.C. 105). Cite the U.S. Securities and Exchange Commission.';++++/** metric → { label, concepts (first with the newest framed fact wins), unit, kind: flow | instant } */++const METRICS = Object.freeze({++ revenue: { label: 'Revenue', kind: 'flow', unit: 'USD', concepts: ['us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax', 'us-gaap:Revenues', 'us-gaap:SalesRevenueNet', 'us-gaap:RevenueFromContractWithCustomerIncludingAssessedTax'] },++ net_income: { label: 'Net income', kind: 'flow', unit: 'USD', concepts: ['us-gaap:NetIncomeLoss', 'us-gaap:ProfitLoss'] },++ operating_income: { label: 'Operating income', kind: 'flow', unit: 'USD', concepts: ['us-gaap:OperatingIncomeLoss'] },++ eps_diluted: { label: 'Diluted EPS', kind: 'flow', unit: 'USD/shares', concepts: ['us-gaap:EarningsPerShareDiluted'] },++ operating_cash_flow: { label: 'Operating cash flow', kind: 'flow', unit: 'USD', concepts: ['us-gaap:NetCashProvidedByUsedInOperatingActivities'] },++ total_assets: { label: 'Total assets', kind: 'instant', unit: 'USD', concepts: ['us-gaap:Assets'] },++ total_liabilities: { label: 'Total liabilities', kind: 'instant', unit: 'USD', concepts: ['us-gaap:Liabilities'] },++ equity: { label: 'Stockholders\' equity', kind: 'instant', unit: 'USD', concepts: ['us-gaap:StockholdersEquity'] },++ cash: { label: 'Cash and equivalents', kind: 'instant', unit: 'USD', concepts: ['us-gaap:CashAndCashEquivalentsAtCarryingValue'] },++ long_term_debt: { label: 'Long-term debt', kind: 'instant', unit: 'USD', concepts: ['us-gaap:LongTermDebtNoncurrent'] },++ shares_outstanding: { label: 'Shares outstanding', kind: 'instant', unit: 'shares', concepts: ['dei:EntityCommonStockSharesOutstanding'] },++});++++const QUARTER = /^CY\d{4}Q[1-4]$/;++const YEAR = /^CY\d{4}$/;++const INSTANT = /^CY\d{4}Q[1-4]I$/;++++/** "Revenue (quarter)" for revenue.q; the label for anything else Trade records. */++function labelOf(metric) {++ const [base, span] = String(metric).split('.');++ const m = METRICS[base];++ if (!m) return metric;++ return span === 'q' ? `${m.label} (quarter)` : span === 'fy' ? `${m.label} (year)` : m.label;++}++++function periodText(f, span) {++ if (span === 'instant') return `at ${f.end}`;++ return `${span === 'q' ? '3' : '12'} months to ${f.end}`;++}++++/** The framed facts of one concept in one unit, newest period first. */++function framed(facts, concept, unit, re) {++ const [ns, name] = concept.split(':');++ const units = facts && facts[ns] && facts[ns][name] && facts[ns][name].units;++ const list = units && Array.isArray(units[unit]) ? units[unit] : [];++ return list.filter((f) => f && typeof f.frame === 'string' && re.test(f.frame) && f.end && f.val != null && f.accn)++ .sort((a, b) => (a.end < b.end ? 1 : a.end > b.end ? -1 : 0));++}++++/**++ * companyfacts JSON → [{ metric, label, concept, value, unit, currency, period, end, accn, form, filed, frame }],++ * newest first within each metric. Pure: reads only what the document states.++ */++function extract(doc) {++ const facts = doc && doc.facts;++ if (!facts || typeof facts !== 'object') return [];++ const out = [];++ for (const [name, m] of Object.entries(METRICS)) {++ const spans = m.kind === 'flow' ? [['q', QUARTER, 5], ['fy', YEAR, 2]] : [['instant', INSTANT, 5]];++ for (const [span, re, keep] of spans) {++ // The concept whose newest framed fact is the most recent (companies switch revenue tags over the years).++ let best = null;++ for (const concept of m.concepts) {++ const list = framed(facts, concept, m.unit, re);++ if (list.length && (!best || list[0].end > best.list[0].end)) best = { concept, list };++ }++ if (!best) continue;++ for (const f of best.list.slice(0, keep)) {++ out.push({++ metric: span === 'instant' ? name : `${name}.${span}`, label: labelOf(span === 'instant' ? name : `${name}.${span}`),++ concept: best.concept, value: String(f.val), unit: m.unit, currency: m.unit === 'USD' ? 'USD' : null,++ period: periodText(f, span), end: f.end, accn: String(f.accn), form: f.form || null, filed: f.filed || null, frame: f.frame,++ });++ }++ }++ }++ return out;++}++++/** The filing's folder on EDGAR, for a person following the source. */++function filingUrl(cik, accn) {++ return `https://www.sec.gov/Archives/edgar/data/${Number(cik)}/${String(accn).replace(/-/g, '')}/`;++}++++/**++ * ctx: instruments, observations, freshness. sec: { companyFacts(cik) → { doc, retrievedAt } } (clients/sec.js).++ * record(instrument) fetches and records; → { recorded, unchanged, skipped, error? }. Errors are kept in++ * trade_facts_state, never thrown into the caller's loop.++ */++function createFacts({ store, ctx, sec, log = console }) {++ const { db } = store;++ const q = {++ state: db.prepare('SELECT * FROM trade_facts_state WHERE instrument_id = ?'),++ save: db.prepare(`INSERT INTO trade_facts_state (instrument_id, fetched_at, ok, error, observations)++ VALUES (@id, @at, @ok, @error, @n)++ ON CONFLICT (instrument_id) DO UPDATE SET fetched_at = @at, ok = @ok, error = @error, observations = @n`),++ // Companies with a new financial report (10-K, 10-Q, 20-F, 40-F and their amendments) since their facts were++ // last read, or never read: oldest report first, so a burst of filings is worked through in order.++ due: db.prepare(`SELECT i.*, MAX(d.retrieved_at) AS report_at FROM trade_instruments i++ JOIN trade_source_documents d ON d.instrument_id = i.id AND d.removed_at IS NULL++ LEFT JOIN trade_facts_state s ON s.instrument_id = i.id++ WHERE i.status = 'active' AND i.cik IS NOT NULL AND d.kind = 'filing'++ AND d.form_type IN ('10-K', '10-K/A', '10-Q', '10-Q/A', '20-F', '20-F/A', '40-F', '40-F/A')++ AND (s.fetched_at IS NULL OR d.retrieved_at > s.fetched_at)++ GROUP BY i.id ORDER BY report_at ASC LIMIT ?`),++ };++++ async function record(instrument) {++ if (!instrument || !instrument.cik) return { recorded: 0, unchanged: 0, skipped: 0, error: 'no CIK' };++ let got;++ try {++ got = await sec.companyFacts(instrument.cik);++ } catch (err) {++ await q.save.run({ id: instrument.id, at: store.now(), ok: 0, error: String(err.message).slice(0, 300), n: 0 });++ log.warn(`[Trade] SEC facts for ${instrument.symbol} failed:`, err.message);++ return { recorded: 0, unchanged: 0, skipped: 0, error: err.message };++ }++ const rows = got.doc ? extract(got.doc) : [];++ const retrieved = iso(got.retrievedAt);++ let recorded = 0;++ let unchanged = 0;++ let skipped = 0;++ for (const r of rows) {++ const observedAt = parseTime(`${r.end}T00:00:00Z`);++ if (observedAt == null) { skipped++; continue; }++ try {++ const out = await ctx.observations.record({++ metric: r.metric, value: r.value, unit: r.unit, currency: r.currency, period: r.period,++ observed_at: iso(observedAt), retrieved_at: retrieved,++ source_key: SOURCE_KEY, source_url: filingUrl(instrument.cik, r.accn), source_ref: `${instrument.cik}:${r.accn}:${r.concept}:${r.frame}`,++ }, instrument, { recordedBy: 'svc:trade' });++ if (out.created) recorded++; else unchanged++;++ } catch (err) {++ if (err && (err.status === 409 || err.status === 422)) { skipped++; continue; }++ throw err;++ }++ }++ await q.save.run({ id: instrument.id, at: store.now(), ok: 1, error: got.doc ? null : 'SEC has no XBRL facts for this company', n: rows.length });++ await ctx.freshness.report(SOURCE_KEY, { name: SOURCE_NAME, status: 'ok', lastSuccessAt: got.retrievedAt, staleAfterSec: 7 * 86400, termsNote: TERMS, licenseNote: LICENSE });++ return { recorded, unchanged, skipped };++ }++++ return {++ SOURCE_KEY,++ record,++ state: async (instrument) => await q.state.get(instrument.id) || null,++ /** Companies whose facts should be read now (a new report since the last read), at most `limit`. */++ due: async (limit) => await q.due.all(limit),++ };++}++++module.exports = { createFacts, extract, labelOf, filingUrl, METRICS, SOURCE_KEY, SOURCE_NAME };@@@ -33,7 +33,8 @@ const OWNER = 'trade';function createIndexing({ store, config, ctx, indexnow }) {const { db } = store;-- const latestMonetary = db.prepare(`SELECT observed_at FROM trade_market_observations WHERE instrument_id = ? AND currency IS NOT NULL++ // A price or rate (no period): a filed figure for a quarter or a year is history, not a price that can go stale.++ const latestMonetary = db.prepare(`SELECT observed_at FROM trade_market_observations WHERE instrument_id = ? AND currency IS NOT NULL AND period IS NULLORDER BY observed_at DESC LIMIT 1`);async function publishedContext(instrument) {@@@ -34,6 +34,8 @@ const NOTICES = {context_published: 'Context published.',context_rejected: 'Review recorded: rejected.',context_retracted: 'Published context retracted.',++ brief_made: 'AI brief written.',++ brief_current: 'The AI brief already covers the latest figures and filings.',};function createCommon({ config, store }) {@@@ -71,7 +71,7 @@ function createDiscovery(ctx) {res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({name: SITE_NAME,summary: SITE_SUMMARY,-- details: 'Every number on an instrument page is an observation with observed_at, retrieved_at and its source; an instrument without observations shows no number. Stale sources are labelled "stale since <time>" and never replaced. Each instrument page has a JSON twin at /i/<SYMBOL>.json with the same data. AI-written context is labelled and is not published before a person reviews it. There is no custody, order execution, escrow, marketplace or personal advice.',++ details: 'Every number on an instrument page is an observation with observed_at, retrieved_at and its source; an instrument without observations shows no number. Stale sources are labelled "stale since <time>" and never replaced. Each instrument page has a JSON twin at /i/<SYMBOL>.json with the same data. AI briefs (OpenVibe.AI) are written only from the SEC numbers and filings on the page, cite each one, are labelled as AI-written and not reviewed by a person, and never give advice; editor context drafted by AI is not published before a person reviews it. There is no custody, order execution, escrow, marketplace or personal advice.',sections: [{ title: 'Start here', links: [{ title: 'Instruments', url: abs('/'), note: 'all instruments' },@@@ -18,6 +18,8 @@ const showcase = require('openvibe-shared/showcase');const frame = require('openvibe-shared/frame');const seo = require('openvibe-publishing/seo');const cache = require('openvibe-shared/cache-policy');++const rateLimit = require('express-rate-limit');++const { ApiError } = require('./errors');const { paginate } = require('openvibe-publishing/ssr');const views = require('../render/views');const { SITE_SUMMARY } = require('../render/layout');@@@ -57,7 +59,7 @@ function createPages(ctx) {styles: pageNo === 1 ? [showcase.STYLESHEET] : [],feeds: [{ type: 'rss', href: '/feed.xml', title: 'Recent documents (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'Recent documents (Atom)' }, { type: 'json', href: '/feed.json', title: 'Recent documents (JSON Feed)' }],jsonLd: [seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en' })],-- body: views.home({ page, recent, urls, sync: await sync.state(), pager, latest }),++ body: views.home({ page, recent, urls, sync: await sync.state(), pager, latest, briefs: pageNo === 1 ? await ctx.briefs.recent(6) : [] }),});});@@@ -120,11 +122,33 @@ function createPages(ctx) {body: views.instrumentPage({...data, instrument, urls, pager, notice: common.noticeOf(req),context: data.context, pending: viewer.editor ? await context.pending(instrument) : [],++ brief: await ctx.briefs.latest(instrument),++ briefs: { canAsk: ctx.briefs.enabled && Boolean(instrument.cik || data.observations.length || total) },viewer, watchlists: signedIn ? await watchlists.forOwner(viewer.subject) : [], csrf: common.csrf(req), loginHref: common.loginHref(req),}),});});++ // A reader asks for the AI brief (domain/briefs.js): a form post from Trade's own page only, five per address in ten++ // minutes, within the site's hourly budget. SEC's numbers are read first when this company's never were.++ const briefLimit = rateLimit({ windowMs: 10 * 60_000, max: 5, standardHeaders: true, legacyHeaders: false });++ define(router, 'post', '/i/:symbol/brief', 'writeBrief', briefLimit, async (req, res) => {++ const instrument = await instruments.bySymbol(req.params.symbol);++ if (!instrument) return common.notFound(req, res);++ const own = new URL(config.baseUrl).origin;++ const origin = req.get('origin');++ const fromHere = origin ? origin === own : String(req.get('referer') || '').startsWith(`${own}/`);++ try {++ if (!fromHere) throw new ApiError(403, 'brief.cross_site', 'Ask for a brief from the company\'s page on openvibe.trade');++ if (instrument.cik && ctx.sec.enabled && !(await ctx.facts.state(instrument))) await ctx.facts.record(instrument);++ const r = await ctx.briefs.make(instrument, { trigger: 'reader' });++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ return res.redirect(303, `${urls.path.instrument(instrument)}?n=${r.created ? 'brief_made' : 'brief_current'}#ai-brief`);++ } catch (err) {++ return common.failure(req, res, err);++ }++ });++return router;}@@@ -22,7 +22,7 @@ const NETWORK_URL = 'https://openvibe.network';const SITE_NAME = 'OpenVibe.Trade';const DISCLAIMER = 'Information only — not investment advice; no trading here.';// The site's one-line AI summary: /llms.txt, /llms-full.txt and the home page's ai-summary all use it.--const SITE_SUMMARY = 'Informational market context: instruments, timestamped observations with their sources, filings from OpenVibe.Sources, and reviewed context. Information only — not investment advice; no trading here.';++const SITE_SUMMARY = 'Informational market context for about 8,000 US-listed companies: SEC filings as they arrive, the key numbers in them with their periods and sources, and AI briefs that cite every figure they use. Information only — not investment advice; no trading here.';const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');const hashes = new Map();@@@ -1,5 +1,6 @@'use strict';++const { labelOf } = require('../domain/facts');/*** Page bodies (HTML fragments for layout.renderPage). All escaping goes through* openvibe-publishing/ssr's `html` tagged template; `raw()` only wraps HTML built here or by the@@@ -47,24 +48,40 @@ function tradeShowcase() {return showcase.hero({eyebrow: 'OpenVibe.Trade',title: 'Market information that shows', accent: 'its source',-- lede: 'Instruments, filings and market observations from named sources. Every value shows when it was observed, when it was retrieved and which source stated it; when a source falls behind, its data is marked stale and never replaced with a guess.',-- actions: [{ label: 'Find an instrument', href: '#find', primary: true }, { label: 'Source freshness', href: '/sources' }],++ lede: 'About 10,000 US-listed companies from the SEC\'s own list, their filings as they arrive, the key numbers in those filings, and short AI briefs that cite every figure they use. Every value shows when it was observed, when it was retrieved and which source stated it; when a source falls behind, its data is marked stale and never replaced with a guess.',++ actions: [{ label: 'Find a company', href: '#find', primary: true }, { label: 'Latest AI briefs', href: '#briefs' }, { label: 'Source freshness', href: '/sources' }],}) + showcase.features({title: 'What is here',items: [{ icon: 'ov:search', title: 'Find an instrument', text: 'By ticker, SEC CIK or company name. One match takes you straight there; several are listed, never guessed between.' },-- { icon: 'ov:history', title: 'Values with their times', text: 'Each observation keeps the exact value as stated, its source, and when it was observed and retrieved. Stale data carries a badge.' },-- { icon: 'ov:docs', title: 'Filings and documents', text: 'Documents gathered by OpenVibe.Sources with their publication and retrieval dates, and feeds in RSS, Atom and JSON.' },-- { icon: 'ov:bell', title: 'Watchlists and alert rules', text: 'Private to you when signed in. Context on an instrument is written by an editor, or drafted by AI and reviewed by a person, and says which.' },++ { icon: 'ov:history', title: 'The numbers from the filings', text: 'Revenue, net income, EPS, cash flow and the balance sheet, exactly as each company filed them with the SEC, each with its period and filing.' },++ { icon: 'ov:ai', title: 'AI briefs that cite', text: 'OpenVibe.AI reads only the numbers and filings on the page and writes a short brief; every point links to the figure it rests on. Labelled, never advice.' },++ { icon: 'ov:docs', title: 'Filings as they arrive', text: 'SEC filings gathered by OpenVibe.Sources with their publication and retrieval dates, and feeds in RSS, Atom and JSON.' },++ { icon: 'ov:bell', title: 'Watchlists and alert rules', text: 'Private to you when signed in: an alert for a new filing or a number crossing a line.' },],});}--function home({ page, recent, urls, sync, pager, latest = new Map() }) {++/** The newest AI briefs, one per company, for the home page. */++function recentBriefs(briefs, urls) {++ if (!briefs.length) return html`<p class="empty">No AI briefs yet. They are written when a company files a report, or when you ask for one on its page.</p>`;++ return html`<ul class="briefs">${briefs.map((b) => html`<li><a href="${urls.path.instrument({ symbol: b.symbol })}#ai-brief"><strong>${b.symbol}</strong></a> ${b.name}++ <p>${clip(b.summary, 220)}</p><span class="muted">AI brief, ${when(b.created_at)}${b.as_of ? html` · figures to ${when(b.as_of)}` : ''}</span></li>`)}</ul>++<p class="muted">Written by OpenVibe.AI from SEC filings and the numbers in them, not reviewed by a person. Informational only; not investment advice.</p>`;++}++++function clip(text, n) {++ const s = String(text || '').replace(/\s+/g, ' ').trim();++ if (s.length <= n) return s;++ const cut = s.slice(0, n);++ return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), n - 20))}…`;++}++++function home({ page, recent, urls, sync, pager, latest = new Map(), briefs = [] }) {// The exchange column only when an instrument on the page has one (a currency has none to record).const exchanges = page.instruments.some((i) => i.exchange);const latestCell = (o) => (o-- ? html`<td class="num"><data value="${o.value}">${o.value}</data> ${o.unit} <span class="muted">${o.metric}, ${when(o.observed_at)}</span>${o.freshness && o.freshness.stale ? html` ${staleBadge(o.freshness)}` : ''}</td>`++ ? html`<td class="num"><data value="${o.value}">${o.value}</data> ${o.unit} <span class="muted">${labelOf(o.metric)}${o.period ? `, ${o.period}` : `, ${when(o.observed_at)}`}</span>${o.freshness && o.freshness.stale ? html` ${staleBadge(o.freshness)}` : ''}</td>`: html`<td><span class="muted">no observation yet</span></td>`);const rows = page.instruments.map((i) => html`<tr><td><a href="${urls.path.instrument(i)}"><strong>${i.symbol}</strong></a></td>@@@ -82,7 +99,10 @@ ${first ? raw(tradeShowcase()) : html`<h1>Sourced market information</h1>`}<input id="q" name="q" type="search" maxlength="200" required><button type="submit">Find</button></form>++${first ? html`<h2 id="briefs">Latest AI briefs</h2>++${recentBriefs(briefs, urls)}` : ''}<h2>Instruments</h2>++<p class="muted">Newest filing or figure first.</p>${page.instruments.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Kind</th>${exchanges ? html`<th scope="col">Exchange</th>` : ''}<th scope="col">Latest</th></tr></thead><tbody>${rows}</tbody></table>${raw(paginationHtml(pager))}`: html`<p class="empty">No instruments have been added yet.</p>`}@@@ -113,7 +133,7 @@ function observationsTable(instrument, observations) {return html`<p class="empty" id="no-observations">No observations have been recorded for ${instrument.symbol}, so no number is shown.</p>`;}const rows = observations.map((o) => html`<tr id="${o.id}" class="${o.freshness.stale ? 'is-stale' : ''}">-- <th scope="row">${o.metric}${o.period ? html` <span class="muted">(${o.period})</span>` : ''}</th>++ <th scope="row">${labelOf(o.metric)}${o.period ? html` <span class="muted">(${o.period})</span>` : ''}</th><td class="num"><data value="${o.value}">${o.value}</data> ${o.unit}${o.currency && o.currency !== o.unit ? html` <span class="muted">${o.currency}</span>` : ''}</td><td>${when(o.observed_at)}</td><td>${o.source.url ? html`<a href="${o.source.url}" rel="nofollow noopener">${o.source.key}</a>` : o.source.key}</td>@@@ -215,6 +235,24 @@ ${watchlists.length ? html`<form method="post" action="/watchlists/item" class="</form></section>`;}++/** The AI brief: labelled, its points linked to the rows they cite, and how to get one when there is none. */++function briefSection(instrument, d) {++ const b = d.brief;++ const anchor = (c) => `#${c.id}`;++ const ask = d.briefs && d.briefs.canAsk ? html`<form method="post" action="/i/${encodeURIComponent(instrument.symbol)}/brief" class="inline-form">++ <button type="submit">${b ? 'Check for a newer brief' : 'Write an AI brief'}</button></form>` : '';++ if (!b) {++ return html`<section aria-labelledby="ai-brief-h" id="ai-brief"><h2 id="ai-brief-h">AI brief</h2>++<p class="empty">No AI brief for ${instrument.symbol} yet.${d.briefs && d.briefs.canAsk ? ' One can be written from the SEC numbers and filings Trade has for it.' : ''}</p>${ask}</section>`;++ }++ return html`<section aria-labelledby="ai-brief-h" id="ai-brief" class="ai-brief"><h2 id="ai-brief-h">AI brief <span class="badge">AI-written, not reviewed by a person</span></h2>++${b.summary ? html`<p>${b.summary}</p>` : ''}++${b.points.length ? html`<ul>${b.points.map((p) => html`<li>${p.text} ${p.cites.map((c, n) => html`<a class="cite" href="${anchor(c)}" title="${c.kind === 'document' ? 'The filing this rests on' : 'The figure this rests on'}">[${n + 1}]</a>`)}</li>`)}</ul>` : ''}++<p class="muted">${b.disclaimer} Written ${when(b.created_at)}${b.as_of ? html` from figures and filings up to ${when(b.as_of)}` : ''}${b.model ? html` (${b.model})` : ''}.</p>++${b.gaps.length ? html`<details><summary>What the sources did not support</summary><ul>${b.gaps.map((g) => html`<li>${g}</li>`)}</ul></details>` : ''}++${ask}</section>`;++}++function instrumentPage(d) {const { instrument, aliases, observations, sources, documents, context, urls } = d;const crumbs = breadcrumbsHtml([{ name: 'Instruments', url: '/' }, { name: instrument.symbol }]);@@@ -224,6 +262,7 @@ function instrumentPage(d) {<h1>${instrument.symbol} <span class="name">${instrument.name}</span></h1><p class="meta">${meta}${other.length ? html` · also ${other.map((a) => a.value).join(', ')}` : ''}${instrument.status !== 'active' ? html` · <span class="badge stale">archived</span>` : ''}</p>${notice(d.notice)}++${briefSection(instrument, d)}<section aria-labelledby="obs-h"><h2 id="obs-h">Observations</h2>${observationsTable(instrument, observations)}</section>${contextSection(instrument, context, urls, { pending: d.pending, editor: d.viewer && d.viewer.editor })}${documentsSection(instrument, documents, { pager: d.pager, urls })}@@@ -8,10 +8,48 @@* (trade.source.stale / recovered on transitions only) and re-send any instrument* Search document whose gate decision changed (e.g. a price observation aged* past TRADE_PRICE_MAX_AGE_SEC → noindex)++ * briefs every TRADE_BRIEFS_INTERVAL_MS: read SEC's numbers for the companies that filed a report since++ * their last read (domain/facts.js, TRADE_SEC_PER_TICK at most), then write AI briefs for the++ * companies whose numbers changed (domain/briefs.js, TRADE_BRIEFS_PER_TICK at most, within the++ * site's hourly budget)*/--function createWorker({ config, sync, freshness, indexing, outbox, log = console }) {++function createWorker({ config, sync, freshness, indexing, outbox, facts = null, briefs = null, sec = null, log = console }) {let syncTimer = null;let freshTimer = null;++ let briefsTimer = null;++ let briefsRunning = false;++++ async function briefsTick() {++ if (briefsRunning) return null;++ briefsRunning = true;++ const out = { facts: 0, briefs: 0, failed: 0 };++ try {++ if (facts && sec && sec.enabled && config.sec.perTick > 0) {++ for (const i of await facts.due(config.sec.perTick)) {++ const r = await facts.record(i);++ if (r.error) out.failed++; else out.facts++;++ }++ }++ if (briefs && briefs.enabled && config.briefs.perTick > 0) {++ const room = Math.max(0, Math.min(config.briefs.perTick, config.briefs.perHour - await briefs.lastHour()));++ for (const i of room ? await briefs.due(room) : []) {++ try {++ const r = await briefs.make(i, { trigger: 'report' });++ if (r.created) out.briefs++;++ } catch (err) {++ out.failed++;++ log.warn(`[Trade] AI brief for ${i.symbol} not made:`, err.message);++ if (err && err.code === 'brief.budget') break;++ }++ }++ }++ outbox.kick();++ return out;++ } catch (err) {++ log.error('[Trade] briefs tick failed:', err.message);++ return null;++ } finally { briefsRunning = false; }++ }async function freshnessTick() {try {@@@ -27,6 +65,7 @@ function createWorker({ config, sync, freshness, indexing, outbox, log = consolereturn {freshnessTick,++ briefsTick,start() {if (!config.worker.enabled) return;if (sync.enabled) {@@@ -36,8 +75,12 @@ function createWorker({ config, sync, freshness, indexing, outbox, log = console}freshTimer = setInterval(freshnessTick, config.worker.freshnessIntervalMs);freshTimer.unref();++ if ((sec && sec.enabled) || (briefs && briefs.enabled)) {++ briefsTimer = setInterval(briefsTick, config.briefs.intervalMs);++ briefsTimer.unref();++ }},-- stop() { clearInterval(syncTimer); clearInterval(freshTimer); },++ stop() { clearInterval(syncTimer); clearInterval(freshTimer); clearInterval(briefsTimer); },};}nnew file mode 100644@@@ -0,0 +1,164 @@++'use strict';++/**++ * AI market context (domain/facts.js, domain/briefs.js, the briefs tick, POST /i/:symbol/brief):++ * - SEC's company facts become observations: only framed facts (one per period), the newest revenue concept,++ * the last quarters and years, the period and the filing kept; a re-read records nothing new;++ * - a filed figure is not a price: the stale-price gate ignores it;++ * - a new report makes the tick read the numbers again, then write a brief that cites Trade's own rows;++ * - the same inputs make no second brief; an uncited point is dropped; advice is refused; a stub run is refused;++ * - the page labels the brief and links each point to its row; the home lists the latest briefs;++ * - a reader's ask must come from Trade's own page, and the hourly budget holds.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { extract } = require('../server/domain/facts');++++const q = (start, end, val, accn, frame, form = '10-Q') => ({ start, end, val, accn, fy: 2026, fp: 'Q3', form, filed: '2026-07-31', ...(frame ? { frame } : {}) });++const i = (end, val, accn, frame) => ({ end, val, accn, fy: 2026, fp: 'Q3', form: '10-Q', filed: '2026-07-31', ...(frame ? { frame } : {}) });++const FACTS = {++ cik: 320193, entityName: 'Apple Inc.',++ facts: {++ dei: { EntityCommonStockSharesOutstanding: { units: { shares: [i('2026-07-17', 14594180000, '0000320193-26-000020', 'CY2026Q2I')] } } },++ 'us-gaap': {++ // An old revenue tag that stopped in 2018, and the one in use now: the newer one wins.++ Revenues: { units: { USD: [q('2018-07-01', '2018-09-29', 62900000000, '0000320193-18-000145', 'CY2018Q3')] } },++ RevenueFromContractWithCustomerExcludingAssessedTax: { units: { USD: [++ q('2025-03-30', '2025-06-28', 94036000000, '0000320193-25-000073', 'CY2025Q2'),++ q('2025-03-30', '2025-06-28', 94036000000, '0000320193-26-000020'), // the comparative repeated: no frame, not read++ q('2025-12-28', '2026-03-28', 95359000000, '0000320193-26-000013', 'CY2026Q1'),++ q('2026-03-29', '2026-06-27', 109417000000, '0000320193-26-000020', 'CY2026Q2'),++ q('2024-09-29', '2025-09-27', 416161000000, '0000320193-25-000079', 'CY2025', '10-K'),++ ] } },++ EarningsPerShareDiluted: { units: { 'USD/shares': [q('2026-03-29', '2026-06-27', 2.02, '0000320193-26-000020', 'CY2026Q2')] } },++ Assets: { units: { USD: [i('2026-06-27', 383266000000, '0000320193-26-000020', 'CY2026Q2I')] } },++ },++ },++};++++const quiet = { log() {}, warn() {}, error() {} };++++(async () => {++ const secCalls = [];++ const sec = { enabled: true, async companyFacts(cik) { secCalls.push(cik); return { doc: FACTS, retrievedAt: t.clock.now() }; } };++ const aiCalls = [];++ let aiOutput = null;++ let aiStub = false;++ const ai = {++ enabled: true, WORKFLOW: 'trade.summarize_market_context',++ async run(input) { aiCalls.push(input); return { runId: `run_${aiCalls.length}`, model: 'test-model', stub: aiStub, output: aiOutput(input) }; },++ };++ const t = await boot({ sec, ai, log: quiet, env: { TRADE_BRIEFS_PER_HOUR: '3' } });++ const db = t.ctx.store.db;++ const apple = await t.instrument({ symbol: 'AAPL', name: 'Apple Inc.', cik: '320193', exchange: 'Nasdaq' });++ const rev = (input) => input.sources.findIndex((s) => /Revenue \(quarter\)/.test(s.title));++ aiOutput = (input) => ({++ summary: 'Apple reported revenue of 109.4 billion US dollars for the three months to 27 June 2026, up from 94.0 billion a year earlier.',++ observations: [++ { text: 'Quarterly revenue rose from 94,036 million USD to 109,417 million USD year over year.', observed_at: '2026-06-27', citations: [rev(input)] },++ { text: 'A sentence with nothing behind it.', citations: [] },++ ],++ disclaimer: 'Informational only; not investment advice.', citations: [rev(input)], gaps: [],++ });++++ await check('SEC facts: framed facts only, the newest concept, periods and filings kept; a re-read adds nothing', async () => {++ const rows = extract(FACTS);++ const revq = rows.filter((r) => r.metric === 'revenue.q');++ assert.deepStrictEqual(revq.map((r) => [r.value, r.period, r.concept]), [++ ['109417000000', '3 months to 2026-06-27', 'us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax'],++ ['95359000000', '3 months to 2026-03-28', 'us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax'],++ ['94036000000', '3 months to 2025-06-28', 'us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax'],++ ]);++ assert.deepStrictEqual(rows.filter((r) => r.metric === 'revenue.fy').map((r) => r.period), ['12 months to 2025-09-27']);++ assert.deepStrictEqual(rows.find((r) => r.metric === 'shares_outstanding'), { ...rows.find((r) => r.metric === 'shares_outstanding'), unit: 'shares', currency: null, period: 'at 2026-07-17' });++ assert.strictEqual(rows.find((r) => r.metric === 'eps_diluted.q').unit, 'USD/shares');++++ const r = await t.ctx.facts.record(apple);++ assert.strictEqual(r.recorded, rows.length, JSON.stringify(r));++ const again = await t.ctx.facts.record(apple);++ assert.strictEqual(again.recorded, 0);++ assert.strictEqual(again.unchanged, rows.length);++ const o = await db.prepare("SELECT * FROM trade_market_observations WHERE instrument_id = ? AND metric = 'revenue.q' ORDER BY observed_at DESC LIMIT 1").get(apple.id);++ assert.strictEqual(o.source_key, 'sec-companyfacts');++ assert.strictEqual(o.source_url, 'https://www.sec.gov/Archives/edgar/data/320193/000032019326000020/');++ assert.strictEqual(o.source_ref, '0000320193:0000320193-26-000020:us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax:CY2026Q2');++ const decision = await t.ctx.indexing.decide(apple);++ assert.ok(!decision.reasons || !decision.reasons.includes('stale_price'), `a filed figure is not a stale price: ${JSON.stringify(decision.reasons)}`);++ });++++ await check('a new report: the tick reads the numbers again and writes a brief citing Trade\'s own rows', async () => {++ t.clock.advance(60_000);++ await t.ctx.documents.upsert({++ source_key: 'sec-xbrl-filings', source_item_id: 'itm_01J8Z3V9Q6N1X2Y3Z4A5B6C7D8', source_revision: 1, title: 'Apple Inc. (0000320193) (Filer)',++ url: 'https://www.sec.gov/Archives/edgar/data/320193/000032019326000090/0000320193-26-000090-index.htm', published_at: t.clock.now() - 1000,++ source_updated_at: null, retrieved_at: t.clock.now(), first_seen_at: t.clock.now(), license_note: null, terms_note: null,++ kind: 'filing', form_type: '10-Q', filer_name: 'Apple Inc.', cik: '0000320193', accession: '0000320193-26-000090',++ }, apple);++ const calls = secCalls.length;++ const tick = await t.ctx.worker.briefsTick();++ assert.deepStrictEqual(tick, { facts: 1, briefs: 1, failed: 0 });++ assert.strictEqual(secCalls.length, calls + 1, 'the numbers were read again after the report');++ const input = aiCalls[aiCalls.length - 1];++ assert.deepStrictEqual(input.instrument, { symbol: 'AAPL', name: 'Apple Inc.', exchange: 'Nasdaq' });++ assert.ok(input.sources.length <= 50 && input.sources.some((s) => s.source_type === 'trade.sec_fact') && input.sources.some((s) => s.source_type === 'trade.sec_filing'));++ assert.match(input.sources[rev(input)].content, /3 months to 2026-06-27: 109417000000 USD \(filing 0000320193-26-000020\)/);++ const b = await t.ctx.briefs.latest(apple);++ assert.strictEqual(b.points.length, 1, 'the uncited point is dropped');++ assert.ok(b.gaps.some((g) => /nothing behind it/.test(g)), 'and listed as a gap');++ assert.ok(b.points[0].cites.every((c) => c.kind === 'observation' && /^obs_/.test(c.id)));++ assert.strictEqual(b.authorship.reviewed, false);++ assert.strictEqual(b.as_of, '2026-06-27T00:00:00.000Z');++ assert.deepStrictEqual(await t.ctx.worker.briefsTick(), { facts: 0, briefs: 0, failed: 0 }, 'nothing new, nothing written');++ });++++ await check('the page labels the brief and links each point to its row; the home lists it', async () => {++ const page = await t.get('/i/AAPL');++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('AI-written, not reviewed by a person'));++ const b = await t.ctx.briefs.latest(apple);++ assert.ok(page.text.includes(`href="#${b.points[0].cites[0].id}"`), 'the citation links to the observation row');++ assert.ok(page.text.includes(`id="${b.points[0].cites[0].id}"`) || page.text.includes('Revenue (quarter)'), 'the row is on the page');++ assert.ok(page.text.includes('not investment advice'));++ const home = await t.get('/');++ assert.ok(home.text.includes('Latest AI briefs') && home.text.includes('#ai-brief'));++ });++++ await check('the same inputs make no second brief; advice and stub runs are refused', async () => {++ const n = aiCalls.length;++ const same = await t.ctx.briefs.make(apple, { trigger: 'reader' });++ assert.strictEqual(same.created, false);++ assert.strictEqual(aiCalls.length, n, 'the model was not asked again');++ const msft = await t.instrument({ symbol: 'MSFT', name: 'Microsoft', cik: '789019' });++ await t.ctx.facts.record(msft);++ const keep = aiOutput;++ aiOutput = () => ({ summary: 'Strong buy: you should buy before the price target is reached.', observations: [], citations: [0], gaps: [] });++ await assert.rejects(t.ctx.briefs.make(msft), (e) => e.code === 'brief.advice_refused');++ aiOutput = keep;++ aiStub = true;++ await assert.rejects(t.ctx.briefs.make(msft), (e) => e.code === 'brief.stub');++ aiStub = false;++ assert.strictEqual(await t.ctx.briefs.latest(msft), null, 'nothing kept');++ });++++ await check('a reader asks from Trade\'s own page only, and the hourly budget holds', async () => {++ const cross = await t.get('/i/MSFT/brief', { method: 'POST', headers: { origin: 'https://evil.example' } });++ assert.strictEqual(cross.status, 403);++ const ok = await t.get('/i/MSFT/brief', { method: 'POST', headers: { origin: 'https://openvibe.trade' } });++ assert.strictEqual(ok.status, 303, ok.text.slice(0, 300));++ assert.match(ok.headers.get('location'), /^\/i\/MSFT\?n=brief_made#ai-brief$/);++ const again = await t.get('/i/MSFT/brief', { method: 'POST', headers: { referer: 'https://openvibe.trade/i/MSFT' } });++ assert.match(again.headers.get('location'), /n=brief_current/);++ // Two briefs this hour (AAPL, MSFT) and a budget of three: one more, then 429.++ const nvda = await t.instrument({ symbol: 'NVDA', name: 'NVIDIA', cik: '1045810' });++ await t.ctx.facts.record(nvda);++ const goog = await t.instrument({ symbol: 'GOOGL', name: 'Alphabet', cik: '1652044' });++ await t.ctx.facts.record(goog);++ aiOutput = (input) => ({ summary: `Figures for ${input.instrument.symbol}.`, observations: [], citations: [0], gaps: [] });++ assert.strictEqual((await t.ctx.briefs.make(nvda)).created, true);++ await assert.rejects(t.ctx.briefs.make(goog), (e) => e.code === 'brief.budget');++ t.clock.advance(3600_000 + 1);++ assert.strictEqual((await t.ctx.briefs.make(goog)).created, true, 'the next hour has room again');++ });++++ await t.close();++ done();++})();@@@ -9,7 +9,8 @@* t.instrument({ symbol, name, cik }) // create an instrument through the domain (seed for tests)* t.events(type) // parsed envelopes in event_outbox*-- * opts: env (overrides), clock, log (Trade's logger; default quiet), limitsNow (the per-actor limiter's++ * opts: env (overrides), clock, log (Trade's logger; default quiet), sec and ai (stand-ins for clients/sec.js and++ * clients/ai.js), limitsNow (the per-actor limiter's* clock; default the wall clock).*/const http = require('http');@@@ -49,7 +50,7 @@ async function boot(opts = {}) {let built = null;async function start() {const config = configLib.load(env);-- built = await createApp({ config, telemetrySink: opts.telemetrySink, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend });++ built = await createApp({ config, telemetrySink: opts.telemetrySink, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend, sec: opts.sec, ai: opts.ai });await built.ctx.jwks.keys();server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });t.base = `http://127.0.0.1:${server.address().port}`;@@@ -12,13 +12,13 @@ const { spawn } = require('child_process');const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {-- htmlRawKB: 26, // measured 24.6 (fresh database; 18.2 before the showcase hero and features, 2026-10-07)++ htmlRawKB: 29, // measured 27.4 with the AI briefs section and feature (2026-10-15); 24.6 before (18.2 before the showcase, 2026-10-07)htmlBrotliKB: 7, // 6.2 (4.7 before the showcase)jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)cssFiles: 2, // 2 (trade.css, openvibe-shared showcase.css)-- cssRawKB: 17.5, // 16.2 (4.7 before showcase.css)++ cssRawKB: 18.5, // 17.8 with the AI brief styles (2026-10-15); 16.2 before (4.7 before showcase.css)cssBrotliKB: 4.4, // 3.9 (1.3 before showcase.css)externalFiles: 1, // 0};
@@ -4,10 +4,10 @@
@@ -4,10 +4,10 @@
**Status:** alpha (roadmap Wave 19). The service runs, its tests pass, and it is **public at its
**Status:** alpha (roadmap Wave 19). The service runs, its tests pass, and it is **public at its
domain, [openvibe.trade](https://openvibe.trade), since 2026-10-09**, after an independent
domain, [openvibe.trade](https://openvibe.trade), since 2026-10-09**, after an independent
-pre-launch security review whose fixes shipped first. `/api/ready` reports `degraded:
-["freshness"]` because there is no feed, and the database is empty (0 instruments) until editors add
-the first ones. Its capabilities and service manifest are registered in openvibe-contracts. No
-market data feed is configured yet (see "What it shows today").
+pre-launch security review whose fixes shipped first. Since 2026-10-15 it carries about 8,000
+US-listed companies from the SEC's own ticker list, their XBRL filings as they arrive, the key numbers
+in those filings, and AI briefs that cite them (see "What it shows today"). Its capabilities and
+service manifest are registered in openvibe-contracts. No price feed is configured.
**Domain:** `openvibe.trade` · **Port:** 4860 · **Service id:** `trade`
**Domain:** `openvibe.trade` · **Port:** 4860 · **Service id:** `trade`
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.11;
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.11;
roadmap Wave 19, §15.13, §29, §32. **Binding decision:** ADR-025 (marketplace and commerce scope).
roadmap Wave 19, §15.13, §29, §32. **Binding decision:** ADR-025 (marketplace and commerce scope).
@@ -20,7 +20,8 @@ roadmap Wave 19, §15.13, §29, §32. **Binding decision:** ADR-025 (marketplace
@@ -20,7 +20,8 @@ roadmap Wave 19, §15.13, §29, §32. **Binding decision:** ADR-025 (marketplace
An informational product: instruments and their aliases with deterministic resolution, market
An informational product: instruments and their aliases with deterministic resolution, market
observations that always show when they were observed, when they were retrieved and which source
observations that always show when they were observed, when they were retrieved and which source
stated them, filings and other source documents from OpenVibe.Sources, private watchlists, alert
stated them, filings and other source documents from OpenVibe.Sources, private watchlists, alert
-rules, and short context written by editors (or drafted by OpenVibe.AI and reviewed by a person).
+rules, short AI briefs written from SEC's filed numbers and filings (labelled, not reviewed by a person), and
+context written by editors (or drafted by OpenVibe.AI and reviewed by a person).
Per ADR-025 Trade is informational **only**. There is no custody, no order execution, no escrow, no
Per ADR-025 Trade is informational **only**. There is no custody, no order execution, no escrow, no
listing between users and no personalised financial advice — not as a feature, not as an endpoint,
listing between users and no personalised financial advice — not as a feature, not as an endpoint,
@@ -120,10 +121,29 @@ Other tables in the same database:
@@ -120,10 +121,29 @@ Other tables in the same database:
### What it shows today
### What it shows today
-Trade shows only what a source stated. OpenVibe.Sources' one trade source (`sec-xbrl-filings`) is
-**disabled** until a person verifies its terms, and it carries filings, not prices. So a fresh
-deployment has no instruments (editors add them), no documents until the source is enabled, and
-**no numbers at all** until a market data source that states values exists — which is correct.
+Trade shows only what a source stated:
+
+- **Companies:** SEC's ticker list (Sources `sec-company-tickers`, daily): one instrument per CIK, other share
+ classes as ticker aliases (`domain/ingest.js`).
+- **Filings:** SEC's XBRL filings feed (Sources `sec-xbrl-filings`, every 15 minutes), matched by CIK.
+- **Numbers:** SEC's XBRL company facts, read straight from `data.sec.gov` (`clients/sec.js`, `domain/facts.js`)
+ when a company files a 10-K, 10-Q, 20-F or 40-F, or when a reader asks for a brief:
+ - revenue, net income, operating income, diluted EPS and operating cash flow for the last five quarters and two
+ years;
+ - assets, liabilities, equity, cash, long-term debt and shares outstanding at the last five quarter ends.
+
+ Only SEC's framed facts are used (one per period), each an immutable observation with its period, its filing and
+ `source_key sec-companyfacts`.
+- **Rates:** euro reference rates from the ECB and Frankfurter.
+- **AI briefs** (`domain/briefs.js`, OpenVibe.AI `trade.summarize_market_context`):
+ - written from a company's numbers and filings list only, when a report brings new numbers (the worker,
+ `TRADE_BRIEFS_PER_TICK`) or when a reader asks (`POST /i/:symbol/brief`: same origin, 5 per address per 10
+ minutes), within `TRADE_BRIEFS_PER_HOUR` for the whole site;
+ - every kept point cites Trade's own observation or document rows, and an uncited point is dropped and listed;
+ - text that reads as advice is refused, and so is a placeholder run;
+ - shown labelled "AI-written, not reviewed by a person". They do not make a page indexable: that still needs a
+ person-reviewed context.
+- **No prices:** no price feed is configured.
### Routes (server-rendered, useful without JavaScript)
### Routes (server-rendered, useful without JavaScript)
@@ -268,7 +288,11 @@ services need to call Trade, is under [Grants the Network must hold](#grants-the
@@ -268,7 +288,11 @@ services need to call Trade, is under [Grants the Network must hold](#grants-the
- **OpenVibe.Network notifications:** Network consumes `trade.alert.triggered` (its `network`
- **OpenVibe.Network notifications:** Network consumes `trade.alert.triggered` (its `network`
subscription) and puts a "Trade alert: <symbol>" notification in the rule owner's inbox, linking the
subscription) and puts a "Trade alert: <symbol>" notification in the rule owner's inbox, linking the
instrument page; deliveries are also listed at `/watchlists`.
instrument page; deliveries are also listed at `/watchlists`.
-- **OpenVibe.AI:** optional, for drafts.
+- **OpenVibe.AI:** `ai.run.create` (namespace `trade`) for the AI briefs (`OV_AI_INTERNAL_URL`; off without it), and
+ optionally the drafts below.
+- **SEC EDGAR** (`data.sec.gov`, read directly, no key): XBRL company facts, public domain. Fair access: the
+ User-Agent `TRADE_SEC_USER_AGENT` names us and a contact; requests are spaced `TRADE_SEC_MIN_INTERVAL_MS` (1 s)
+ apart, at most `TRADE_SEC_PER_TICK` companies a tick. `TRADE_SEC_FACTS=off` turns it off.
### Grants the Network must hold
### Grants the Network must hold
@@ -279,6 +303,7 @@ Each grant is `[client, capability, audience]`:
@@ -279,6 +303,7 @@ Each grant is `[client, capability, audience]`:
- `[trade, network.account.export.contribute, openvibe.network]` and `[trade, network.account.deletion.confirm, openvibe.network]` (ADR-033; granted last, once this release is live)
- `[trade, network.account.export.contribute, openvibe.network]` and `[trade, network.account.deletion.confirm, openvibe.network]` (ADR-033; granted last, once this release is live)
- `[trade, sources.item.read, openvibe.sources]`
- `[trade, sources.item.read, openvibe.sources]`
- `[trade, sources.source.read, openvibe.sources]`
- `[trade, sources.source.read, openvibe.sources]`
+- `[trade, ai.run.create, openvibe.ai, ['trade.*']]` (the AI briefs)
- For OpenVibe.AI to deliver drafts: `[ai, trade.context.read, openvibe.trade]` and
- For OpenVibe.AI to deliver drafts: `[ai, trade.context.read, openvibe.trade]` and
`[ai, trade.context.propose, openvibe.trade]`
`[ai, trade.context.propose, openvibe.trade]`
- For a first-party market data feed, when one exists: `[<feed client>, trade.observation.write, openvibe.trade]`
- For a first-party market data feed, when one exists: `[<feed client>, trade.observation.write, openvibe.trade]`
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+-- phase: expand
+-- AI market context (server/domain/briefs.js, facts.js): a company's key numbers are read from SEC's XBRL company facts
+-- when it files a report (trade_facts_state: when, and whether it worked), and OpenVibe.AI writes a short brief from
+-- those numbers and the filings list. A brief cites Trade's own observations and documents and is shown labelled as
+-- AI-written and not reviewed by a person; editor-reviewed context (trade_context_revisions) is separate.
+CREATE TABLE IF NOT EXISTS trade_facts_state (
+ instrument_id text COLLATE "C" PRIMARY KEY REFERENCES trade_instruments(id),
+ fetched_at bigint NOT NULL,
+ ok bigint NOT NULL,
+ error text,
+ observations bigint NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS trade_ai_briefs (
+ id text COLLATE "C" PRIMARY KEY, -- tbr_<ULID>
+ instrument_id text COLLATE "C" NOT NULL REFERENCES trade_instruments(id),
+ summary text NOT NULL,
+ points jsonb NOT NULL DEFAULT '[]', -- [{ text, observed_at, cites: [{ kind, id }] }]
+ cites jsonb NOT NULL DEFAULT '[]', -- [{ kind: document|observation, id }]
+ gaps jsonb NOT NULL DEFAULT '[]',
+ as_of bigint, -- the newest observation or document time it cites
+ inputs_hash text NOT NULL, -- what it was written from: same inputs, no new brief
+ run_id text,
+ model text,
+ trigger text NOT NULL CHECK (trigger IN ('report', 'reader')),
+ created_at bigint NOT NULL
+);
+CREATE INDEX IF NOT EXISTS trade_ai_briefs_latest ON trade_ai_briefs (instrument_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS trade_ai_briefs_recent ON trade_ai_briefs (created_at DESC);
@@ -31,6 +31,11 @@ tr.is-stale td, tr.is-stale th { background: rgba(245, 158, 11, .07); }
@@ -31,6 +31,11 @@ tr.is-stale td, tr.is-stale th { background: rgba(245, 158, 11, .07); }
.docs { list-style: none; padding: 0; }
.docs { list-style: none; padding: 0; }
.docs li { padding: 8px 0; border-bottom: 1px solid var(--border, #1f2d47); }
.docs li { padding: 8px 0; border-bottom: 1px solid var(--border, #1f2d47); }
.docs li:target, tr:target { outline: 2px solid var(--accent, #3b82f6); }
.docs li:target, tr:target { outline: 2px solid var(--accent, #3b82f6); }
+.briefs { list-style: none; padding: 0; display: grid; gap: 10px; }
+.briefs li { padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); }
+.briefs p { margin: 6px 0 4px; }
+.ai-brief { border-left: 3px solid var(--accent, #3b82f6); padding: 2px 0 2px 14px; }
+.ai-brief .cite { font-size: .8rem; text-decoration: none; }
.context-body { border-left: 3px solid var(--border-light, #2c3d5c); padding-left: 14px; }
.context-body { border-left: 3px solid var(--border-light, #2c3d5c); padding-left: 14px; }
.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 6px 12px; }
.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 6px 12px; }
.notice { border-left: 3px solid var(--info, #38bdf8); padding: 6px 12px; background: var(--bg-card, #131c2e); }
.notice { border-left: 3px solid var(--info, #38bdf8); padding: 6px 12px; background: var(--bg-card, #131c2e); }
@@ -99,6 +99,11 @@ async function createApp(opts = {}) {
@@ -99,6 +99,11 @@ async function createApp(opts = {}) {
ctx.reading = createReading({ store, ctx });
ctx.reading = createReading({ store, ctx });
ctx.sources = opts.sourcesClient || createSourcesClient({ config, fetchImpl, timeoutMs: 15_000 });
ctx.sources = opts.sourcesClient || createSourcesClient({ config, fetchImpl, timeoutMs: 15_000 });
ctx.sync = createIngest({ store, config, ctx, sources: ctx.sources, log });
ctx.sync = createIngest({ store, config, ctx, sources: ctx.sources, log });
+ // SEC's filed numbers and the AI briefs written from them (domain/facts.js, domain/briefs.js).
+ ctx.sec = opts.sec || require('./clients/sec').createSec({ config, fetchImpl });
+ ctx.facts = require('./domain/facts').createFacts({ store, ctx, sec: ctx.sec, log });
+ ctx.ai = opts.ai || require('./clients/ai').createAi({ config, fetchImpl });
+ ctx.briefs = require('./domain/briefs').createBriefs({ store, config, ctx, ai: ctx.ai, log });
// Sign-in with OpenVibe.Network (openvibe-sdk/sso): routes, offline session verification through the
// Sign-in with OpenVibe.Network (openvibe-sdk/sso): routes, offline session verification through the
// shared JWKS client, state + PKCE. ctx.jwks is the same client viewer.js verifies service tokens with.
// shared JWKS client, state + PKCE. ctx.jwks is the same client viewer.js verifies service tokens with.
ctx.jwksUrl = `${String(config.networkInternalUrl || config.networkUrl).replace(/\/+$/, '')}/api/.well-known/jwks`;
ctx.jwksUrl = `${String(config.networkInternalUrl || config.networkUrl).replace(/\/+$/, '')}/api/.well-known/jwks`;
@@ -113,7 +118,7 @@ async function createApp(opts = {}) {
@@ -113,7 +118,7 @@ async function createApp(opts = {}) {
ctx.jwks.keys().catch(() => {}); // warm the key cache at boot (non-fatal if the Network is down)
ctx.jwks.keys().catch(() => {}); // warm the key cache at boot (non-fatal if the Network is down)
ctx.viewers = createViewerResolver({ auth: ctx.auth, config, jwksUrl: ctx.jwksUrl });
ctx.viewers = createViewerResolver({ auth: ctx.auth, config, jwksUrl: ctx.jwksUrl });
ctx.common = createCommon({ config, store });
ctx.common = createCommon({ config, store });
- ctx.worker = createWorker({ config, sync: ctx.sync, freshness: ctx.freshness, indexing: ctx.indexing, outbox: ctx.outbox, log });
+ ctx.worker = createWorker({ config, sync: ctx.sync, freshness: ctx.freshness, indexing: ctx.indexing, outbox: ctx.outbox, facts: ctx.facts, briefs: ctx.briefs, sec: ctx.sec, log });
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+'use strict';
+
+/**
+ * OpenVibe.AI seam: run trade.summarize_market_context over what Trade itself recorded for one instrument.
+ *
+ * POST {OV_AI_INTERNAL_URL}/api/v1/runs?wait=… { workflow, input: { instrument, stale_after_hours, sources } }
+ * (this service's token for audience openvibe.ai, capability ai.run.create, namespace trade)
+ *
+ * Only Trade's own rows go to the model: SEC's filed numbers (observations) and the filings list (documents),
+ * each with its dates and source. A failed or unfinished run produces nothing.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+const WORKFLOW = 'trade.summarize_market_context';
+
+class AiError extends Error {
+ constructor(code, message, status = 502) { super(message); this.code = code; this.status = status; }
+}
+
+function createAi({ config, fetchImpl = globalThis.fetch }) {
+ const enabled = Boolean(config.ai.internalUrl && config.oauth.clientSecret);
+ const tokens = enabled ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.ai', scope: 'ai.run.create', fetchImpl,
+ }) : null;
+
+ /** input: { instrument: { symbol, name, exchange }, stale_after_hours, sources: [AI source] } → { runId, model, stub, output } */
+ async function run(input) {
+ if (!enabled) throw new AiError('ai.not_configured', 'OpenVibe.AI is not configured (OV_AI_INTERNAL_URL)', 503);
+ let res;
+ try {
+ res = await fetchImpl(`${config.ai.internalUrl}/api/v1/runs?wait=${config.ai.waitMs}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },
+ body: JSON.stringify({ workflow: WORKFLOW, input }),
+ signal: AbortSignal.timeout(config.ai.waitMs + 5000),
+ });
+ } catch (err) {
+ throw new AiError('ai.unavailable', `OpenVibe.AI is unreachable: ${err.message}`);
+ }
+ const body = await res.json().catch(() => null);
+ if (!res.ok && res.status !== 202) throw new AiError(`ai.http_${res.status}`, `OpenVibe.AI answered ${res.status}${body && body.code ? ` (${body.code})` : ''}`);
+ const r = body && body.run;
+ if (!r || r.status !== 'succeeded' || !r.output || typeof r.output !== 'object') {
+ throw new AiError('ai.no_result', `The ${WORKFLOW} run did not finish with a result (${r ? r.status : 'no run'})`);
+ }
+ return { runId: String(r.id), model: r.provenance && r.provenance.model ? String(r.provenance.model) : null, stub: Boolean(r.synthetic), output: r.output };
+ }
+
+ return { enabled, WORKFLOW, run };
+}
+
+module.exports = { createAi, AiError, WORKFLOW };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+
+/**
+ * SEC EDGAR's XBRL API, read directly: https://data.sec.gov/api/xbrl/companyfacts/CIK##########.json. The host is
+ * fixed (never a URL a person chose), so this is not an open fetcher. SEC's fair-access policy asks for a User-Agent
+ * that names the requester and gives a contact (TRADE_SEC_USER_AGENT), and at most 10 requests a second: requests
+ * here are spaced at least TRADE_SEC_MIN_INTERVAL_MS apart (1 s by default). A body over 40 MB is refused.
+ *
+ * companyFacts(cik) → { doc, retrievedAt } — doc is null when SEC has no XBRL facts for the company (404).
+ */
+const BASE = 'https://data.sec.gov/api/xbrl/companyfacts';
+const MAX_BYTES = 40 * 1024 * 1024;
+
+function createSec({ config, fetchImpl = globalThis.fetch, now = () => Date.now() }) {
+ const enabled = config.sec.enabled;
+ let last = 0;
+ let chain = Promise.resolve();
+
+ /** One request at a time, at least minIntervalMs after the previous one started. */
+ function spaced(fn) {
+ const run = chain.then(async () => {
+ const wait = last + config.sec.minIntervalMs - now();
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
+ last = now();
+ return await fn();
+ });
+ chain = run.catch(() => {});
+ return run;
+ }
+
+ async function companyFacts(cik) {
+ if (!enabled) throw new Error('SEC facts are off (TRADE_SEC_FACTS=off)');
+ const padded = String(cik).replace(/\D/g, '').padStart(10, '0');
+ if (!/^\d{10}$/.test(padded)) throw new Error('not a CIK');
+ return await spaced(async () => {
+ const res = await fetchImpl(`${BASE}/CIK${padded}.json`, {
+ headers: { 'User-Agent': config.sec.userAgent, Accept: 'application/json' },
+ signal: AbortSignal.timeout(30_000),
+ redirect: 'error',
+ });
+ const retrievedAt = now();
+ if (res.status === 404) return { doc: null, retrievedAt };
+ if (!res.ok) throw new Error(`SEC answered ${res.status}`);
+ const len = Number(res.headers.get('content-length') || 0);
+ if (len > MAX_BYTES) throw new Error(`SEC facts too large (${len} bytes)`);
+ const text = await res.text();
+ if (text.length > MAX_BYTES) throw new Error('SEC facts too large');
+ let doc;
+ try { doc = JSON.parse(text); } catch { throw new Error('SEC facts are not JSON'); }
+ return { doc, retrievedAt };
+ });
+ }
+
+ return { enabled, companyFacts };
+}
+
+module.exports = { createSec };
@@ -96,6 +96,29 @@ function load(env = process.env) {
@@ -96,6 +96,29 @@ function load(env = process.env) {
freshnessIntervalMs: int(env.TRADE_FRESHNESS_INTERVAL_MS, 60_000),
freshnessIntervalMs: int(env.TRADE_FRESHNESS_INTERVAL_MS, 60_000),
},
},
+ // SEC EDGAR's XBRL company facts (domain/facts.js, clients/sec.js): read for a company when it files a report
+ // (the briefs tick, at most perTick a tick) or when a reader asks for a brief. SEC's fair access wants a
+ // User-Agent with a contact and at most 10 requests a second; requests here are spaced minIntervalMs apart.
+ sec: {
+ enabled: env.TRADE_SEC_FACTS !== 'off',
+ userAgent: String(env.TRADE_SEC_USER_AGENT || 'OpenVibe.Trade/1 (+https://openvibe.trade; [email protected])'),
+ minIntervalMs: Math.max(int(env.TRADE_SEC_MIN_INTERVAL_MS, 1000), 150),
+ perTick: Math.max(int(env.TRADE_SEC_PER_TICK, 10), 0),
+ },
+
+ // OpenVibe.AI (clients/ai.js): trade.summarize_market_context writes the AI briefs. Off without the URL.
+ ai: {
+ internalUrl: trim(env.OV_AI_INTERNAL_URL || ''),
+ waitMs: int(env.TRADE_AI_WAIT_MS, 60_000),
+ },
+ // AI briefs (domain/briefs.js): the worker writes at most perTick each intervalMs, and the whole site (the
+ // worker and readers together) at most perHour.
+ briefs: {
+ intervalMs: Math.max(int(env.TRADE_BRIEFS_INTERVAL_MS, 10 * 60_000), 10_000),
+ perTick: Math.max(int(env.TRADE_BRIEFS_PER_TICK, 6), 0),
+ perHour: Math.max(int(env.TRADE_BRIEFS_PER_HOUR, 40), 0),
+ },
+
// Browser origins that may call /api/v1 with a Bearer Network JWT (no cookies cross origins).
// Browser origins that may call /api/v1 with a Bearer Network JWT (no cookies cross origins).
apiCorsOrigins: list(env.API_CORS_ORIGINS || 'https://openvibe.network'),
apiCorsOrigins: list(env.API_CORS_ORIGINS || 'https://openvibe.network'),
new file mode 100644
new file mode 100644
@@ -0,0 +1,194 @@
@@ -0,0 +1,194 @@
+'use strict';
+
+/**
+ * AI briefs: a short market context for one instrument, written by OpenVibe.AI (trade.summarize_market_context) from
+ * what Trade itself recorded — SEC's filed numbers (observations, ./facts.js) and the filings list (documents) — and
+ * shown on the instrument page labelled as AI-written and not reviewed by a person.
+ *
+ * sources sent one per metric series (its last values with their periods and accessions) and one per recent
+ * filing, at most 50; nothing else about the company, and nothing about any reader
+ * kept the summary, and each point the model tied to a source (a point without a valid citation is
+ * dropped and listed as a gap); citations become Trade's own { kind, id } refs
+ * refused text that reads as advice or a recommendation (context.js ADVICE_RE, ADR-025), a stub run, or
+ * a run with no result: no brief at all, never a partial one
+ * once per input the inputs' hash is stored; the same numbers and filings make no second brief
+ *
+ * Briefs are made after a company's report brings new numbers (the worker, trigger 'report') or when a reader asks
+ * (POST /i/:symbol/brief, trigger 'reader'), within TRADE_BRIEFS_PER_HOUR for the whole site.
+ */
+const crypto = require('crypto');
+const { ADVICE_RE } = require('./context');
+const { labelOf, SOURCE_KEY: FACTS_KEY } = require('./facts');
+const { newId, iso } = require('./helpers');
+const { ApiError } = require('../http/errors');
+
+const MAX_SOURCES = 50;
+const MAX_DOCS = 12;
+const PER_SERIES = 7;
+
+function createBriefs({ store, config, ctx, ai, log = console }) {
+ const { db } = store;
+ const q = {
+ latest: db.prepare('SELECT * FROM trade_ai_briefs WHERE instrument_id = ? ORDER BY created_at DESC, id DESC LIMIT 1'),
+ recent: db.prepare(`SELECT b.*, i.symbol, i.name FROM trade_ai_briefs b JOIN trade_instruments i ON i.id = b.instrument_id
+ WHERE i.status = 'active' AND b.id = (SELECT id FROM trade_ai_briefs x WHERE x.instrument_id = b.instrument_id ORDER BY created_at DESC, id DESC LIMIT 1)
+ ORDER BY b.created_at DESC LIMIT ?`),
+ lastHour: db.prepare('SELECT COUNT(*) AS n FROM trade_ai_briefs WHERE created_at > ?'),
+ insert: db.prepare(`INSERT INTO trade_ai_briefs (id, instrument_id, summary, points, cites, gaps, as_of, inputs_hash, run_id, model, trigger, created_at)
+ VALUES (@id, @instrument_id, @summary, @points, @cites, @gaps, @as_of, @inputs_hash, @run_id, @model, @trigger, @created_at)`),
+ series: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = ?
+ ORDER BY metric, observed_at DESC, recorded_at DESC`),
+ docs: db.prepare(`SELECT * FROM trade_source_documents WHERE instrument_id = ? AND removed_at IS NULL
+ ORDER BY COALESCE(published_at, retrieved_at) DESC LIMIT ?`),
+ // Companies whose numbers were read after their last brief (or that have none): a report brought new numbers.
+ due: db.prepare(`SELECT i.* FROM trade_instruments i JOIN trade_facts_state s ON s.instrument_id = i.id
+ WHERE i.status = 'active' AND s.ok = 1 AND s.observations > 0
+ AND NOT EXISTS (SELECT 1 FROM trade_ai_briefs b WHERE b.instrument_id = i.id AND b.created_at >= s.fetched_at)
+ ORDER BY s.fetched_at ASC LIMIT ?`),
+ };
+
+ const fmtDay = (t) => (t == null ? 'no date' : new Date(Number(t)).toISOString().slice(0, 10));
+
+ /** The model's sources, in citation order, and what each one cites on Trade's side. */
+ async function inputsFor(instrument) {
+ const sources = [];
+ const refs = [];
+ const groups = new Map();
+ for (const o of await q.series.all(instrument.id)) {
+ const key = `${o.metric}|${o.unit}`;
+ if (!groups.has(key)) groups.set(key, []);
+ const g = groups.get(key);
+ // One value per period (a restatement under a later accession is newer: the first row seen is kept).
+ if (g.length < PER_SERIES && !g.some((x) => x.observed_at === o.observed_at && (x.period || '') === (o.period || ''))) g.push(o);
+ }
+ for (const rows of groups.values()) {
+ if (sources.length >= MAX_SOURCES - MAX_DOCS) break;
+ const o = rows[0];
+ const fromSec = o.source_key === FACTS_KEY;
+ sources.push({
+ source_type: fromSec ? 'trade.sec_fact' : 'trade.observation',
+ source_id: o.id,
+ title: `${instrument.symbol}: ${labelOf(o.metric)}${fromSec ? ', from SEC filings' : ''}`,
+ observed_at: iso(o.observed_at),
+ retrieved_at: iso(o.retrieved_at),
+ ...(o.source_url ? { url: o.source_url } : {}),
+ content: rows.map((r) => `${r.period || iso(r.observed_at)}: ${r.value} ${r.unit}${fromSec ? ` (filing ${String(r.source_ref).split(':')[1]})` : ` (${r.source_key})`}`).join('\n'),
+ provenance: { service: 'trade', source_key: o.source_key },
+ });
+ refs.push(rows.map((r) => ({ kind: 'observation', id: r.id, at: r.observed_at })));
+ }
+ for (const d of await q.docs.all(instrument.id, MAX_DOCS)) {
+ if (sources.length >= MAX_SOURCES) break;
+ sources.push({
+ source_type: d.kind === 'filing' ? 'trade.sec_filing' : 'trade.document',
+ source_id: d.id,
+ title: `${d.form_type ? `${d.form_type}: ` : ''}${d.title || d.filer_name || 'document'}`,
+ ...(d.published_at ? { published_at: iso(d.published_at) } : {}),
+ retrieved_at: iso(d.retrieved_at),
+ ...(d.url ? { url: d.url } : {}),
+ snippet: `${d.form_type || 'Document'} filed ${fmtDay(d.published_at)}${d.accession ? `, accession ${d.accession}` : ''}.`,
+ provenance: { service: 'trade', source_key: d.source_key },
+ });
+ refs.push([{ kind: 'document', id: d.id, at: d.published_at || d.retrieved_at }]);
+ }
+ const hash = crypto.createHash('sha256').update(JSON.stringify(sources.map((s) => [s.source_id, s.content || s.snippet]))).digest('hex');
+ return { sources, refs, hash };
+ }
+
+ const lastHour = async () => (await q.lastHour.get(store.now() - 3600_000)).n;
+
+ /**
+ * Write a brief for the instrument now. → { brief, created } (created false: the inputs are unchanged since the
+ * last brief). Throws ApiError: 409 nothing to write from, 429 the hourly budget is spent, 422 advice refused,
+ * 502/503 OpenVibe.AI.
+ */
+ async function make(instrument, { trigger = 'report' } = {}) {
+ const { sources, refs, hash } = await inputsFor(instrument);
+ if (!sources.length) throw new ApiError(409, 'brief.no_inputs', `Trade has no SEC numbers or filings for ${instrument.symbol} yet, so there is nothing to write a brief from`);
+ const prior = await q.latest.get(instrument.id);
+ if (prior && prior.inputs_hash === hash) return { brief: view(prior), created: false };
+ if (await lastHour() >= config.briefs.perHour) throw new ApiError(429, 'brief.budget', 'The AI briefs budget for this hour is used up; try again later');
+
+ let r;
+ try {
+ r = await ai.run({
+ instrument: { symbol: instrument.symbol, name: String(instrument.name).slice(0, 200), ...(instrument.exchange ? { exchange: instrument.exchange } : {}) },
+ stale_after_hours: 24 * 120,
+ sources,
+ });
+ } catch (err) {
+ throw new ApiError(err.status || 502, err.code || 'ai.error', `No AI brief: ${err.message}`);
+ }
+ if (r.stub) throw new ApiError(503, 'brief.stub', 'OpenVibe.AI answered with a placeholder (no model is configured); no brief was made');
+ const out = r.output || {};
+ const gaps = Array.isArray(out.gaps) ? out.gaps.map((g) => String(g).slice(0, 300)).slice(0, 20) : [];
+ const citeRefs = (list) => {
+ const seen = new Set();
+ const got = [];
+ for (const n of Array.isArray(list) ? list : []) {
+ if (!Number.isInteger(n) || n < 0 || n >= refs.length) continue;
+ for (const ref of refs[n]) {
+ const k = `${ref.kind}:${ref.id}`;
+ if (!seen.has(k)) { seen.add(k); got.push(ref); }
+ }
+ }
+ return got;
+ };
+ const summary = String(out.summary || '').trim().slice(0, 3000);
+ const points = [];
+ for (const p of Array.isArray(out.observations) ? out.observations : []) {
+ const text = String(p && p.text || '').trim().slice(0, 600);
+ if (!text) continue;
+ const cites = citeRefs(p.citations);
+ if (!cites.length) { gaps.push(`Left out (no source cited): ${text.slice(0, 200)}`); continue; }
+ points.push({ text, observed_at: p.observed_at ? String(p.observed_at).slice(0, 40) : null, cites: cites.map(({ kind, id }) => ({ kind, id })) });
+ }
+ if (!summary && !points.length) throw new ApiError(502, 'brief.empty', 'OpenVibe.AI returned no text it could support from the sources');
+ if (ADVICE_RE.test(summary) || points.some((p) => ADVICE_RE.test(p.text))) {
+ log.warn(`[Trade] AI brief for ${instrument.symbol} refused: it read as advice`);
+ throw new ApiError(422, 'brief.advice_refused', 'The AI text read as investment advice, so it was not kept (Trade publishes information only)');
+ }
+ const all = citeRefs(out.citations).concat(points.flatMap((p) => p.cites.map((c) => refs.flat().find((x) => x.kind === c.kind && x.id === c.id))).filter(Boolean));
+ const cites = [];
+ const seen = new Set();
+ let asOf = null;
+ for (const c of all) {
+ const k = `${c.kind}:${c.id}`;
+ if (seen.has(k)) continue;
+ seen.add(k);
+ cites.push({ kind: c.kind, id: c.id });
+ if (c.at != null && (asOf == null || c.at > asOf)) asOf = c.at;
+ }
+ const row = {
+ id: newId('tbr', store.now()), instrument_id: instrument.id, summary, points: JSON.stringify(points), cites: JSON.stringify(cites),
+ gaps: JSON.stringify(gaps.slice(0, 20)), as_of: asOf, inputs_hash: hash, run_id: r.runId, model: r.model, trigger, created_at: store.now(),
+ };
+ await q.insert.run(row);
+ return { brief: view(await q.latest.get(instrument.id)), created: true };
+ }
+
+ const parse = (v, d) => { if (v == null) return d; if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return d; } };
+
+ function view(row) {
+ if (!row) return null;
+ return {
+ id: row.id, summary: row.summary, points: parse(row.points, []), cites: parse(row.cites, []), gaps: parse(row.gaps, []),
+ as_of: iso(row.as_of), created_at: iso(row.created_at), model: row.model || null, trigger: row.trigger,
+ authorship: { kind: 'ai', reviewed: false, workflow: ai.WORKFLOW, run_id: row.run_id || null },
+ disclaimer: 'Written by OpenVibe.AI from the SEC numbers and filings on this page, not reviewed by a person. Informational only; not investment advice.',
+ ...(row.symbol ? { symbol: row.symbol, name: row.name } : {}),
+ };
+ }
+
+ return {
+ enabled: ai.enabled,
+ make,
+ inputsFor,
+ latest: async (instrument) => view(await q.latest.get(instrument.id)),
+ recent: async (limit = 8) => (await q.recent.all(limit)).map(view),
+ due: async (limit) => await q.due.all(limit),
+ lastHour,
+ };
+}
+
+module.exports = { createBriefs };
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+'use strict';
+
+/**
+ * A listed company's key numbers from its own SEC filings: EDGAR's XBRL "company facts"
+ * (https://data.sec.gov/api/xbrl/companyfacts/CIK##########.json, public domain, the SEC's fair-access
+ * policy: a User-Agent that names us and a contact, at most 10 requests a second; Trade makes at most one
+ * a second and only when a company files a report or a reader asks).
+ *
+ * SEC tags one fact per company per calendar period with a `frame` (CY2025 a year, CY2026Q2 a quarter,
+ * CY2026Q2I an instant): the fact that period is best described by, already de-duplicated across filings.
+ * Only framed facts are read, so a comparative repeated in later reports is never counted twice.
+ *
+ * flows revenue, net income, operating income, diluted EPS, operating cash flow:
+ * the last five quarters (metric <name>.q) and the last two years (<name>.fy)
+ * instants total assets, total liabilities, stockholders' equity, cash, long-term debt, shares
+ * outstanding: the last five quarter ends (<name>)
+ *
+ * Each becomes an observation (domain/observations.js): the value exactly as filed, its unit, the period
+ * ("3 months to 2026-06-27"), observed_at the period's end, source_key sec-companyfacts and source_ref
+ * <CIK>:<accession>:<concept>:<frame> (one observation per filed fact; a restatement under a new accession is a
+ * new observation and both stay on record). Nothing is computed here: growth and margins are left to the
+ * reader and the AI brief, which cite these rows.
+ */
+const { iso, parseTime } = require('./helpers');
+
+const SOURCE_KEY = 'sec-companyfacts';
+const SOURCE_NAME = 'SEC EDGAR: company financial facts (XBRL)';
+const TERMS = 'Public EDGAR data (U.S. Securities and Exchange Commission), public domain. Fair access: a User-Agent naming the requester and a contact, at most 10 requests per second; Trade asks once per company report. Informational only: not investment advice.';
+const LICENSE = 'US government work: public domain (17 U.S.C. 105). Cite the U.S. Securities and Exchange Commission.';
+
+/** metric → { label, concepts (first with the newest framed fact wins), unit, kind: flow | instant } */
+const METRICS = Object.freeze({
+ revenue: { label: 'Revenue', kind: 'flow', unit: 'USD', concepts: ['us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax', 'us-gaap:Revenues', 'us-gaap:SalesRevenueNet', 'us-gaap:RevenueFromContractWithCustomerIncludingAssessedTax'] },
+ net_income: { label: 'Net income', kind: 'flow', unit: 'USD', concepts: ['us-gaap:NetIncomeLoss', 'us-gaap:ProfitLoss'] },
+ operating_income: { label: 'Operating income', kind: 'flow', unit: 'USD', concepts: ['us-gaap:OperatingIncomeLoss'] },
+ eps_diluted: { label: 'Diluted EPS', kind: 'flow', unit: 'USD/shares', concepts: ['us-gaap:EarningsPerShareDiluted'] },
+ operating_cash_flow: { label: 'Operating cash flow', kind: 'flow', unit: 'USD', concepts: ['us-gaap:NetCashProvidedByUsedInOperatingActivities'] },
+ total_assets: { label: 'Total assets', kind: 'instant', unit: 'USD', concepts: ['us-gaap:Assets'] },
+ total_liabilities: { label: 'Total liabilities', kind: 'instant', unit: 'USD', concepts: ['us-gaap:Liabilities'] },
+ equity: { label: 'Stockholders\' equity', kind: 'instant', unit: 'USD', concepts: ['us-gaap:StockholdersEquity'] },
+ cash: { label: 'Cash and equivalents', kind: 'instant', unit: 'USD', concepts: ['us-gaap:CashAndCashEquivalentsAtCarryingValue'] },
+ long_term_debt: { label: 'Long-term debt', kind: 'instant', unit: 'USD', concepts: ['us-gaap:LongTermDebtNoncurrent'] },
+ shares_outstanding: { label: 'Shares outstanding', kind: 'instant', unit: 'shares', concepts: ['dei:EntityCommonStockSharesOutstanding'] },
+});
+
+const QUARTER = /^CY\d{4}Q[1-4]$/;
+const YEAR = /^CY\d{4}$/;
+const INSTANT = /^CY\d{4}Q[1-4]I$/;
+
+/** "Revenue (quarter)" for revenue.q; the label for anything else Trade records. */
+function labelOf(metric) {
+ const [base, span] = String(metric).split('.');
+ const m = METRICS[base];
+ if (!m) return metric;
+ return span === 'q' ? `${m.label} (quarter)` : span === 'fy' ? `${m.label} (year)` : m.label;
+}
+
+function periodText(f, span) {
+ if (span === 'instant') return `at ${f.end}`;
+ return `${span === 'q' ? '3' : '12'} months to ${f.end}`;
+}
+
+/** The framed facts of one concept in one unit, newest period first. */
+function framed(facts, concept, unit, re) {
+ const [ns, name] = concept.split(':');
+ const units = facts && facts[ns] && facts[ns][name] && facts[ns][name].units;
+ const list = units && Array.isArray(units[unit]) ? units[unit] : [];
+ return list.filter((f) => f && typeof f.frame === 'string' && re.test(f.frame) && f.end && f.val != null && f.accn)
+ .sort((a, b) => (a.end < b.end ? 1 : a.end > b.end ? -1 : 0));
+}
+
+/**
+ * companyfacts JSON → [{ metric, label, concept, value, unit, currency, period, end, accn, form, filed, frame }],
+ * newest first within each metric. Pure: reads only what the document states.
+ */
+function extract(doc) {
+ const facts = doc && doc.facts;
+ if (!facts || typeof facts !== 'object') return [];
+ const out = [];
+ for (const [name, m] of Object.entries(METRICS)) {
+ const spans = m.kind === 'flow' ? [['q', QUARTER, 5], ['fy', YEAR, 2]] : [['instant', INSTANT, 5]];
+ for (const [span, re, keep] of spans) {
+ // The concept whose newest framed fact is the most recent (companies switch revenue tags over the years).
+ let best = null;
+ for (const concept of m.concepts) {
+ const list = framed(facts, concept, m.unit, re);
+ if (list.length && (!best || list[0].end > best.list[0].end)) best = { concept, list };
+ }
+ if (!best) continue;
+ for (const f of best.list.slice(0, keep)) {
+ out.push({
+ metric: span === 'instant' ? name : `${name}.${span}`, label: labelOf(span === 'instant' ? name : `${name}.${span}`),
+ concept: best.concept, value: String(f.val), unit: m.unit, currency: m.unit === 'USD' ? 'USD' : null,
+ period: periodText(f, span), end: f.end, accn: String(f.accn), form: f.form || null, filed: f.filed || null, frame: f.frame,
+ });
+ }
+ }
+ }
+ return out;
+}
+
+/** The filing's folder on EDGAR, for a person following the source. */
+function filingUrl(cik, accn) {
+ return `https://www.sec.gov/Archives/edgar/data/${Number(cik)}/${String(accn).replace(/-/g, '')}/`;
+}
+
+/**
+ * ctx: instruments, observations, freshness. sec: { companyFacts(cik) → { doc, retrievedAt } } (clients/sec.js).
+ * record(instrument) fetches and records; → { recorded, unchanged, skipped, error? }. Errors are kept in
+ * trade_facts_state, never thrown into the caller's loop.
+ */
+function createFacts({ store, ctx, sec, log = console }) {
+ const { db } = store;
+ const q = {
+ state: db.prepare('SELECT * FROM trade_facts_state WHERE instrument_id = ?'),
+ save: db.prepare(`INSERT INTO trade_facts_state (instrument_id, fetched_at, ok, error, observations)
+ VALUES (@id, @at, @ok, @error, @n)
+ ON CONFLICT (instrument_id) DO UPDATE SET fetched_at = @at, ok = @ok, error = @error, observations = @n`),
+ // Companies with a new financial report (10-K, 10-Q, 20-F, 40-F and their amendments) since their facts were
+ // last read, or never read: oldest report first, so a burst of filings is worked through in order.
+ due: db.prepare(`SELECT i.*, MAX(d.retrieved_at) AS report_at FROM trade_instruments i
+ JOIN trade_source_documents d ON d.instrument_id = i.id AND d.removed_at IS NULL
+ LEFT JOIN trade_facts_state s ON s.instrument_id = i.id
+ WHERE i.status = 'active' AND i.cik IS NOT NULL AND d.kind = 'filing'
+ AND d.form_type IN ('10-K', '10-K/A', '10-Q', '10-Q/A', '20-F', '20-F/A', '40-F', '40-F/A')
+ AND (s.fetched_at IS NULL OR d.retrieved_at > s.fetched_at)
+ GROUP BY i.id ORDER BY report_at ASC LIMIT ?`),
+ };
+
+ async function record(instrument) {
+ if (!instrument || !instrument.cik) return { recorded: 0, unchanged: 0, skipped: 0, error: 'no CIK' };
+ let got;
+ try {
+ got = await sec.companyFacts(instrument.cik);
+ } catch (err) {
+ await q.save.run({ id: instrument.id, at: store.now(), ok: 0, error: String(err.message).slice(0, 300), n: 0 });
+ log.warn(`[Trade] SEC facts for ${instrument.symbol} failed:`, err.message);
+ return { recorded: 0, unchanged: 0, skipped: 0, error: err.message };
+ }
+ const rows = got.doc ? extract(got.doc) : [];
+ const retrieved = iso(got.retrievedAt);
+ let recorded = 0;
+ let unchanged = 0;
+ let skipped = 0;
+ for (const r of rows) {
+ const observedAt = parseTime(`${r.end}T00:00:00Z`);
+ if (observedAt == null) { skipped++; continue; }
+ try {
+ const out = await ctx.observations.record({
+ metric: r.metric, value: r.value, unit: r.unit, currency: r.currency, period: r.period,
+ observed_at: iso(observedAt), retrieved_at: retrieved,
+ source_key: SOURCE_KEY, source_url: filingUrl(instrument.cik, r.accn), source_ref: `${instrument.cik}:${r.accn}:${r.concept}:${r.frame}`,
+ }, instrument, { recordedBy: 'svc:trade' });
+ if (out.created) recorded++; else unchanged++;
+ } catch (err) {
+ if (err && (err.status === 409 || err.status === 422)) { skipped++; continue; }
+ throw err;
+ }
+ }
+ await q.save.run({ id: instrument.id, at: store.now(), ok: 1, error: got.doc ? null : 'SEC has no XBRL facts for this company', n: rows.length });
+ await ctx.freshness.report(SOURCE_KEY, { name: SOURCE_NAME, status: 'ok', lastSuccessAt: got.retrievedAt, staleAfterSec: 7 * 86400, termsNote: TERMS, licenseNote: LICENSE });
+ return { recorded, unchanged, skipped };
+ }
+
+ return {
+ SOURCE_KEY,
+ record,
+ state: async (instrument) => await q.state.get(instrument.id) || null,
+ /** Companies whose facts should be read now (a new report since the last read), at most `limit`. */
+ due: async (limit) => await q.due.all(limit),
+ };
+}
+
+module.exports = { createFacts, extract, labelOf, filingUrl, METRICS, SOURCE_KEY, SOURCE_NAME };
@@ -33,7 +33,8 @@ const OWNER = 'trade';
@@ -33,7 +33,8 @@ const OWNER = 'trade';
function createIndexing({ store, config, ctx, indexnow }) {
function createIndexing({ store, config, ctx, indexnow }) {
const { db } = store;
const { db } = store;
- const latestMonetary = db.prepare(`SELECT observed_at FROM trade_market_observations WHERE instrument_id = ? AND currency IS NOT NULL
+ // A price or rate (no period): a filed figure for a quarter or a year is history, not a price that can go stale.
+ const latestMonetary = db.prepare(`SELECT observed_at FROM trade_market_observations WHERE instrument_id = ? AND currency IS NOT NULL AND period IS NULL
ORDER BY observed_at DESC LIMIT 1`);
ORDER BY observed_at DESC LIMIT 1`);
async function publishedContext(instrument) {
async function publishedContext(instrument) {
@@ -34,6 +34,8 @@ const NOTICES = {
@@ -34,6 +34,8 @@ const NOTICES = {
context_published: 'Context published.',
context_published: 'Context published.',
context_rejected: 'Review recorded: rejected.',
context_rejected: 'Review recorded: rejected.',
context_retracted: 'Published context retracted.',
context_retracted: 'Published context retracted.',
+ brief_made: 'AI brief written.',
+ brief_current: 'The AI brief already covers the latest figures and filings.',
};
};
function createCommon({ config, store }) {
function createCommon({ config, store }) {
@@ -71,7 +71,7 @@ function createDiscovery(ctx) {
@@ -71,7 +71,7 @@ function createDiscovery(ctx) {
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
summary: SITE_SUMMARY,
summary: SITE_SUMMARY,
- details: 'Every number on an instrument page is an observation with observed_at, retrieved_at and its source; an instrument without observations shows no number. Stale sources are labelled "stale since <time>" and never replaced. Each instrument page has a JSON twin at /i/<SYMBOL>.json with the same data. AI-written context is labelled and is not published before a person reviews it. There is no custody, order execution, escrow, marketplace or personal advice.',
+ details: 'Every number on an instrument page is an observation with observed_at, retrieved_at and its source; an instrument without observations shows no number. Stale sources are labelled "stale since <time>" and never replaced. Each instrument page has a JSON twin at /i/<SYMBOL>.json with the same data. AI briefs (OpenVibe.AI) are written only from the SEC numbers and filings on the page, cite each one, are labelled as AI-written and not reviewed by a person, and never give advice; editor context drafted by AI is not published before a person reviews it. There is no custody, order execution, escrow, marketplace or personal advice.',
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'Instruments', url: abs('/'), note: 'all instruments' },
{ title: 'Instruments', url: abs('/'), note: 'all instruments' },
@@ -18,6 +18,8 @@ const showcase = require('openvibe-shared/showcase');
@@ -18,6 +18,8 @@ const showcase = require('openvibe-shared/showcase');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
+const rateLimit = require('express-rate-limit');
+const { ApiError } = require('./errors');
const { paginate } = require('openvibe-publishing/ssr');
const { paginate } = require('openvibe-publishing/ssr');
const views = require('../render/views');
const views = require('../render/views');
const { SITE_SUMMARY } = require('../render/layout');
const { SITE_SUMMARY } = require('../render/layout');
@@ -57,7 +59,7 @@ function createPages(ctx) {
@@ -57,7 +59,7 @@ function createPages(ctx) {
styles: pageNo === 1 ? [showcase.STYLESHEET] : [],
styles: pageNo === 1 ? [showcase.STYLESHEET] : [],
feeds: [{ type: 'rss', href: '/feed.xml', title: 'Recent documents (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'Recent documents (Atom)' }, { type: 'json', href: '/feed.json', title: 'Recent documents (JSON Feed)' }],
feeds: [{ type: 'rss', href: '/feed.xml', title: 'Recent documents (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'Recent documents (Atom)' }, { type: 'json', href: '/feed.json', title: 'Recent documents (JSON Feed)' }],
jsonLd: [seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en' })],
jsonLd: [seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en' })],
- body: views.home({ page, recent, urls, sync: await sync.state(), pager, latest }),
+ body: views.home({ page, recent, urls, sync: await sync.state(), pager, latest, briefs: pageNo === 1 ? await ctx.briefs.recent(6) : [] }),
});
});
});
});
@@ -120,11 +122,33 @@ function createPages(ctx) {
@@ -120,11 +122,33 @@ function createPages(ctx) {
body: views.instrumentPage({
body: views.instrumentPage({
...data, instrument, urls, pager, notice: common.noticeOf(req),
...data, instrument, urls, pager, notice: common.noticeOf(req),
context: data.context, pending: viewer.editor ? await context.pending(instrument) : [],
context: data.context, pending: viewer.editor ? await context.pending(instrument) : [],
+ brief: await ctx.briefs.latest(instrument),
+ briefs: { canAsk: ctx.briefs.enabled && Boolean(instrument.cik || data.observations.length || total) },
viewer, watchlists: signedIn ? await watchlists.forOwner(viewer.subject) : [], csrf: common.csrf(req), loginHref: common.loginHref(req),
viewer, watchlists: signedIn ? await watchlists.forOwner(viewer.subject) : [], csrf: common.csrf(req), loginHref: common.loginHref(req),
}),
}),
});
});
});
});
+ // A reader asks for the AI brief (domain/briefs.js): a form post from Trade's own page only, five per address in ten
+ // minutes, within the site's hourly budget. SEC's numbers are read first when this company's never were.
+ const briefLimit = rateLimit({ windowMs: 10 * 60_000, max: 5, standardHeaders: true, legacyHeaders: false });
+ define(router, 'post', '/i/:symbol/brief', 'writeBrief', briefLimit, async (req, res) => {
+ const instrument = await instruments.bySymbol(req.params.symbol);
+ if (!instrument) return common.notFound(req, res);
+ const own = new URL(config.baseUrl).origin;
+ const origin = req.get('origin');
+ const fromHere = origin ? origin === own : String(req.get('referer') || '').startsWith(`${own}/`);
+ try {
+ if (!fromHere) throw new ApiError(403, 'brief.cross_site', 'Ask for a brief from the company\'s page on openvibe.trade');
+ if (instrument.cik && ctx.sec.enabled && !(await ctx.facts.state(instrument))) await ctx.facts.record(instrument);
+ const r = await ctx.briefs.make(instrument, { trigger: 'reader' });
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ return res.redirect(303, `${urls.path.instrument(instrument)}?n=${r.created ? 'brief_made' : 'brief_current'}#ai-brief`);
+ } catch (err) {
+ return common.failure(req, res, err);
+ }
+ });
+
return router;
return router;
}
}
@@ -22,7 +22,7 @@ const NETWORK_URL = 'https://openvibe.network';
@@ -22,7 +22,7 @@ const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Trade';
const SITE_NAME = 'OpenVibe.Trade';
const DISCLAIMER = 'Information only — not investment advice; no trading here.';
const DISCLAIMER = 'Information only — not investment advice; no trading here.';
// The site's one-line AI summary: /llms.txt, /llms-full.txt and the home page's ai-summary all use it.
// The site's one-line AI summary: /llms.txt, /llms-full.txt and the home page's ai-summary all use it.
-const SITE_SUMMARY = 'Informational market context: instruments, timestamped observations with their sources, filings from OpenVibe.Sources, and reviewed context. Information only — not investment advice; no trading here.';
+const SITE_SUMMARY = 'Informational market context for about 8,000 US-listed companies: SEC filings as they arrive, the key numbers in them with their periods and sources, and AI briefs that cite every figure they use. Information only — not investment advice; no trading here.';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const hashes = new Map();
const hashes = new Map();
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
'use strict';
'use strict';
+const { labelOf } = require('../domain/facts');
/**
/**
* Page bodies (HTML fragments for layout.renderPage). All escaping goes through
* Page bodies (HTML fragments for layout.renderPage). All escaping goes through
* openvibe-publishing/ssr's `html` tagged template; `raw()` only wraps HTML built here or by the
* openvibe-publishing/ssr's `html` tagged template; `raw()` only wraps HTML built here or by the
@@ -47,24 +48,40 @@ function tradeShowcase() {
@@ -47,24 +48,40 @@ function tradeShowcase() {
return showcase.hero({
return showcase.hero({
eyebrow: 'OpenVibe.Trade',
eyebrow: 'OpenVibe.Trade',
title: 'Market information that shows', accent: 'its source',
title: 'Market information that shows', accent: 'its source',
- lede: 'Instruments, filings and market observations from named sources. Every value shows when it was observed, when it was retrieved and which source stated it; when a source falls behind, its data is marked stale and never replaced with a guess.',
- actions: [{ label: 'Find an instrument', href: '#find', primary: true }, { label: 'Source freshness', href: '/sources' }],
+ lede: 'About 10,000 US-listed companies from the SEC\'s own list, their filings as they arrive, the key numbers in those filings, and short AI briefs that cite every figure they use. Every value shows when it was observed, when it was retrieved and which source stated it; when a source falls behind, its data is marked stale and never replaced with a guess.',
+ actions: [{ label: 'Find a company', href: '#find', primary: true }, { label: 'Latest AI briefs', href: '#briefs' }, { label: 'Source freshness', href: '/sources' }],
}) + showcase.features({
}) + showcase.features({
title: 'What is here',
title: 'What is here',
items: [
items: [
{ icon: 'ov:search', title: 'Find an instrument', text: 'By ticker, SEC CIK or company name. One match takes you straight there; several are listed, never guessed between.' },
{ icon: 'ov:search', title: 'Find an instrument', text: 'By ticker, SEC CIK or company name. One match takes you straight there; several are listed, never guessed between.' },
- { icon: 'ov:history', title: 'Values with their times', text: 'Each observation keeps the exact value as stated, its source, and when it was observed and retrieved. Stale data carries a badge.' },
- { icon: 'ov:docs', title: 'Filings and documents', text: 'Documents gathered by OpenVibe.Sources with their publication and retrieval dates, and feeds in RSS, Atom and JSON.' },
- { icon: 'ov:bell', title: 'Watchlists and alert rules', text: 'Private to you when signed in. Context on an instrument is written by an editor, or drafted by AI and reviewed by a person, and says which.' },
+ { icon: 'ov:history', title: 'The numbers from the filings', text: 'Revenue, net income, EPS, cash flow and the balance sheet, exactly as each company filed them with the SEC, each with its period and filing.' },
+ { icon: 'ov:ai', title: 'AI briefs that cite', text: 'OpenVibe.AI reads only the numbers and filings on the page and writes a short brief; every point links to the figure it rests on. Labelled, never advice.' },
+ { icon: 'ov:docs', title: 'Filings as they arrive', text: 'SEC filings gathered by OpenVibe.Sources with their publication and retrieval dates, and feeds in RSS, Atom and JSON.' },
+ { icon: 'ov:bell', title: 'Watchlists and alert rules', text: 'Private to you when signed in: an alert for a new filing or a number crossing a line.' },
],
],
});
});
}
}
-function home({ page, recent, urls, sync, pager, latest = new Map() }) {
+/** The newest AI briefs, one per company, for the home page. */
+function recentBriefs(briefs, urls) {
+ if (!briefs.length) return html`<p class="empty">No AI briefs yet. They are written when a company files a report, or when you ask for one on its page.</p>`;
+ return html`<ul class="briefs">${briefs.map((b) => html`<li><a href="${urls.path.instrument({ symbol: b.symbol })}#ai-brief"><strong>${b.symbol}</strong></a> ${b.name}
+ <p>${clip(b.summary, 220)}</p><span class="muted">AI brief, ${when(b.created_at)}${b.as_of ? html` · figures to ${when(b.as_of)}` : ''}</span></li>`)}</ul>
+<p class="muted">Written by OpenVibe.AI from SEC filings and the numbers in them, not reviewed by a person. Informational only; not investment advice.</p>`;
+}
+
+function clip(text, n) {
+ const s = String(text || '').replace(/\s+/g, ' ').trim();
+ if (s.length <= n) return s;
+ const cut = s.slice(0, n);
+ return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), n - 20))}…`;
+}
+
+function home({ page, recent, urls, sync, pager, latest = new Map(), briefs = [] }) {
// The exchange column only when an instrument on the page has one (a currency has none to record).
// The exchange column only when an instrument on the page has one (a currency has none to record).
const exchanges = page.instruments.some((i) => i.exchange);
const exchanges = page.instruments.some((i) => i.exchange);
const latestCell = (o) => (o
const latestCell = (o) => (o
- ? html`<td class="num"><data value="${o.value}">${o.value}</data> ${o.unit} <span class="muted">${o.metric}, ${when(o.observed_at)}</span>${o.freshness && o.freshness.stale ? html` ${staleBadge(o.freshness)}` : ''}</td>`
+ ? html`<td class="num"><data value="${o.value}">${o.value}</data> ${o.unit} <span class="muted">${labelOf(o.metric)}${o.period ? `, ${o.period}` : `, ${when(o.observed_at)}`}</span>${o.freshness && o.freshness.stale ? html` ${staleBadge(o.freshness)}` : ''}</td>`
: html`<td><span class="muted">no observation yet</span></td>`);
: html`<td><span class="muted">no observation yet</span></td>`);
const rows = page.instruments.map((i) => html`<tr>
const rows = page.instruments.map((i) => html`<tr>
<td><a href="${urls.path.instrument(i)}"><strong>${i.symbol}</strong></a></td>
<td><a href="${urls.path.instrument(i)}"><strong>${i.symbol}</strong></a></td>
@@ -82,7 +99,10 @@ ${first ? raw(tradeShowcase()) : html`<h1>Sourced market information</h1>`}
@@ -82,7 +99,10 @@ ${first ? raw(tradeShowcase()) : html`<h1>Sourced market information</h1>`}
<input id="q" name="q" type="search" maxlength="200" required>
<input id="q" name="q" type="search" maxlength="200" required>
<button type="submit">Find</button>
<button type="submit">Find</button>
</form>
</form>
+${first ? html`<h2 id="briefs">Latest AI briefs</h2>
+${recentBriefs(briefs, urls)}` : ''}
<h2>Instruments</h2>
<h2>Instruments</h2>
+<p class="muted">Newest filing or figure first.</p>
${page.instruments.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Kind</th>${exchanges ? html`<th scope="col">Exchange</th>` : ''}<th scope="col">Latest</th></tr></thead><tbody>${rows}</tbody></table>
${page.instruments.length ? html`<table tabindex="0" class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Kind</th>${exchanges ? html`<th scope="col">Exchange</th>` : ''}<th scope="col">Latest</th></tr></thead><tbody>${rows}</tbody></table>
${raw(paginationHtml(pager))}`
${raw(paginationHtml(pager))}`
: html`<p class="empty">No instruments have been added yet.</p>`}
: html`<p class="empty">No instruments have been added yet.</p>`}
@@ -113,7 +133,7 @@ function observationsTable(instrument, observations) {
@@ -113,7 +133,7 @@ function observationsTable(instrument, observations) {
return html`<p class="empty" id="no-observations">No observations have been recorded for ${instrument.symbol}, so no number is shown.</p>`;
return html`<p class="empty" id="no-observations">No observations have been recorded for ${instrument.symbol}, so no number is shown.</p>`;
}
}
const rows = observations.map((o) => html`<tr id="${o.id}" class="${o.freshness.stale ? 'is-stale' : ''}">
const rows = observations.map((o) => html`<tr id="${o.id}" class="${o.freshness.stale ? 'is-stale' : ''}">
- <th scope="row">${o.metric}${o.period ? html` <span class="muted">(${o.period})</span>` : ''}</th>
+ <th scope="row">${labelOf(o.metric)}${o.period ? html` <span class="muted">(${o.period})</span>` : ''}</th>
<td class="num"><data value="${o.value}">${o.value}</data> ${o.unit}${o.currency && o.currency !== o.unit ? html` <span class="muted">${o.currency}</span>` : ''}</td>
<td class="num"><data value="${o.value}">${o.value}</data> ${o.unit}${o.currency && o.currency !== o.unit ? html` <span class="muted">${o.currency}</span>` : ''}</td>
<td>${when(o.observed_at)}</td>
<td>${when(o.observed_at)}</td>
<td>${o.source.url ? html`<a href="${o.source.url}" rel="nofollow noopener">${o.source.key}</a>` : o.source.key}</td>
<td>${o.source.url ? html`<a href="${o.source.url}" rel="nofollow noopener">${o.source.key}</a>` : o.source.key}</td>
@@ -215,6 +235,24 @@ ${watchlists.length ? html`<form method="post" action="/watchlists/item" class="
@@ -215,6 +235,24 @@ ${watchlists.length ? html`<form method="post" action="/watchlists/item" class="
</form></section>`;
</form></section>`;
}
}
+/** The AI brief: labelled, its points linked to the rows they cite, and how to get one when there is none. */
+function briefSection(instrument, d) {
+ const b = d.brief;
+ const anchor = (c) => `#${c.id}`;
+ const ask = d.briefs && d.briefs.canAsk ? html`<form method="post" action="/i/${encodeURIComponent(instrument.symbol)}/brief" class="inline-form">
+ <button type="submit">${b ? 'Check for a newer brief' : 'Write an AI brief'}</button></form>` : '';
+ if (!b) {
+ return html`<section aria-labelledby="ai-brief-h" id="ai-brief"><h2 id="ai-brief-h">AI brief</h2>
+<p class="empty">No AI brief for ${instrument.symbol} yet.${d.briefs && d.briefs.canAsk ? ' One can be written from the SEC numbers and filings Trade has for it.' : ''}</p>${ask}</section>`;
+ }
+ return html`<section aria-labelledby="ai-brief-h" id="ai-brief" class="ai-brief"><h2 id="ai-brief-h">AI brief <span class="badge">AI-written, not reviewed by a person</span></h2>
+${b.summary ? html`<p>${b.summary}</p>` : ''}
+${b.points.length ? html`<ul>${b.points.map((p) => html`<li>${p.text} ${p.cites.map((c, n) => html`<a class="cite" href="${anchor(c)}" title="${c.kind === 'document' ? 'The filing this rests on' : 'The figure this rests on'}">[${n + 1}]</a>`)}</li>`)}</ul>` : ''}
+<p class="muted">${b.disclaimer} Written ${when(b.created_at)}${b.as_of ? html` from figures and filings up to ${when(b.as_of)}` : ''}${b.model ? html` (${b.model})` : ''}.</p>
+${b.gaps.length ? html`<details><summary>What the sources did not support</summary><ul>${b.gaps.map((g) => html`<li>${g}</li>`)}</ul></details>` : ''}
+${ask}</section>`;
+}
+
function instrumentPage(d) {
function instrumentPage(d) {
const { instrument, aliases, observations, sources, documents, context, urls } = d;
const { instrument, aliases, observations, sources, documents, context, urls } = d;
const crumbs = breadcrumbsHtml([{ name: 'Instruments', url: '/' }, { name: instrument.symbol }]);
const crumbs = breadcrumbsHtml([{ name: 'Instruments', url: '/' }, { name: instrument.symbol }]);
@@ -224,6 +262,7 @@ function instrumentPage(d) {
@@ -224,6 +262,7 @@ function instrumentPage(d) {
<h1>${instrument.symbol} <span class="name">${instrument.name}</span></h1>
<h1>${instrument.symbol} <span class="name">${instrument.name}</span></h1>
<p class="meta">${meta}${other.length ? html` · also ${other.map((a) => a.value).join(', ')}` : ''}${instrument.status !== 'active' ? html` · <span class="badge stale">archived</span>` : ''}</p>
<p class="meta">${meta}${other.length ? html` · also ${other.map((a) => a.value).join(', ')}` : ''}${instrument.status !== 'active' ? html` · <span class="badge stale">archived</span>` : ''}</p>
${notice(d.notice)}
${notice(d.notice)}
+${briefSection(instrument, d)}
<section aria-labelledby="obs-h"><h2 id="obs-h">Observations</h2>${observationsTable(instrument, observations)}</section>
<section aria-labelledby="obs-h"><h2 id="obs-h">Observations</h2>${observationsTable(instrument, observations)}</section>
${contextSection(instrument, context, urls, { pending: d.pending, editor: d.viewer && d.viewer.editor })}
${contextSection(instrument, context, urls, { pending: d.pending, editor: d.viewer && d.viewer.editor })}
${documentsSection(instrument, documents, { pager: d.pager, urls })}
${documentsSection(instrument, documents, { pager: d.pager, urls })}
@@ -8,10 +8,48 @@
@@ -8,10 +8,48 @@
* (trade.source.stale / recovered on transitions only) and re-send any instrument
* (trade.source.stale / recovered on transitions only) and re-send any instrument
* Search document whose gate decision changed (e.g. a price observation aged
* Search document whose gate decision changed (e.g. a price observation aged
* past TRADE_PRICE_MAX_AGE_SEC → noindex)
* past TRADE_PRICE_MAX_AGE_SEC → noindex)
+ * briefs every TRADE_BRIEFS_INTERVAL_MS: read SEC's numbers for the companies that filed a report since
+ * their last read (domain/facts.js, TRADE_SEC_PER_TICK at most), then write AI briefs for the
+ * companies whose numbers changed (domain/briefs.js, TRADE_BRIEFS_PER_TICK at most, within the
+ * site's hourly budget)
*/
*/
-function createWorker({ config, sync, freshness, indexing, outbox, log = console }) {
+function createWorker({ config, sync, freshness, indexing, outbox, facts = null, briefs = null, sec = null, log = console }) {
let syncTimer = null;
let syncTimer = null;
let freshTimer = null;
let freshTimer = null;
+ let briefsTimer = null;
+ let briefsRunning = false;
+
+ async function briefsTick() {
+ if (briefsRunning) return null;
+ briefsRunning = true;
+ const out = { facts: 0, briefs: 0, failed: 0 };
+ try {
+ if (facts && sec && sec.enabled && config.sec.perTick > 0) {
+ for (const i of await facts.due(config.sec.perTick)) {
+ const r = await facts.record(i);
+ if (r.error) out.failed++; else out.facts++;
+ }
+ }
+ if (briefs && briefs.enabled && config.briefs.perTick > 0) {
+ const room = Math.max(0, Math.min(config.briefs.perTick, config.briefs.perHour - await briefs.lastHour()));
+ for (const i of room ? await briefs.due(room) : []) {
+ try {
+ const r = await briefs.make(i, { trigger: 'report' });
+ if (r.created) out.briefs++;
+ } catch (err) {
+ out.failed++;
+ log.warn(`[Trade] AI brief for ${i.symbol} not made:`, err.message);
+ if (err && err.code === 'brief.budget') break;
+ }
+ }
+ }
+ outbox.kick();
+ return out;
+ } catch (err) {
+ log.error('[Trade] briefs tick failed:', err.message);
+ return null;
+ } finally { briefsRunning = false; }
+ }
async function freshnessTick() {
async function freshnessTick() {
try {
try {
@@ -27,6 +65,7 @@ function createWorker({ config, sync, freshness, indexing, outbox, log = console
@@ -27,6 +65,7 @@ function createWorker({ config, sync, freshness, indexing, outbox, log = console
return {
return {
freshnessTick,
freshnessTick,
+ briefsTick,
start() {
start() {
if (!config.worker.enabled) return;
if (!config.worker.enabled) return;
if (sync.enabled) {
if (sync.enabled) {
@@ -36,8 +75,12 @@ function createWorker({ config, sync, freshness, indexing, outbox, log = console
@@ -36,8 +75,12 @@ function createWorker({ config, sync, freshness, indexing, outbox, log = console
}
}
freshTimer = setInterval(freshnessTick, config.worker.freshnessIntervalMs);
freshTimer = setInterval(freshnessTick, config.worker.freshnessIntervalMs);
freshTimer.unref();
freshTimer.unref();
+ if ((sec && sec.enabled) || (briefs && briefs.enabled)) {
+ briefsTimer = setInterval(briefsTick, config.briefs.intervalMs);
+ briefsTimer.unref();
+ }
},
},
- stop() { clearInterval(syncTimer); clearInterval(freshTimer); },
+ stop() { clearInterval(syncTimer); clearInterval(freshTimer); clearInterval(briefsTimer); },
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
+'use strict';
+/**
+ * AI market context (domain/facts.js, domain/briefs.js, the briefs tick, POST /i/:symbol/brief):
+ * - SEC's company facts become observations: only framed facts (one per period), the newest revenue concept,
+ * the last quarters and years, the period and the filing kept; a re-read records nothing new;
+ * - a filed figure is not a price: the stale-price gate ignores it;
+ * - a new report makes the tick read the numbers again, then write a brief that cites Trade's own rows;
+ * - the same inputs make no second brief; an uncited point is dropped; advice is refused; a stub run is refused;
+ * - the page labels the brief and links each point to its row; the home lists the latest briefs;
+ * - a reader's ask must come from Trade's own page, and the hourly budget holds.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { extract } = require('../server/domain/facts');
+
+const q = (start, end, val, accn, frame, form = '10-Q') => ({ start, end, val, accn, fy: 2026, fp: 'Q3', form, filed: '2026-07-31', ...(frame ? { frame } : {}) });
+const i = (end, val, accn, frame) => ({ end, val, accn, fy: 2026, fp: 'Q3', form: '10-Q', filed: '2026-07-31', ...(frame ? { frame } : {}) });
+const FACTS = {
+ cik: 320193, entityName: 'Apple Inc.',
+ facts: {
+ dei: { EntityCommonStockSharesOutstanding: { units: { shares: [i('2026-07-17', 14594180000, '0000320193-26-000020', 'CY2026Q2I')] } } },
+ 'us-gaap': {
+ // An old revenue tag that stopped in 2018, and the one in use now: the newer one wins.
+ Revenues: { units: { USD: [q('2018-07-01', '2018-09-29', 62900000000, '0000320193-18-000145', 'CY2018Q3')] } },
+ RevenueFromContractWithCustomerExcludingAssessedTax: { units: { USD: [
+ q('2025-03-30', '2025-06-28', 94036000000, '0000320193-25-000073', 'CY2025Q2'),
+ q('2025-03-30', '2025-06-28', 94036000000, '0000320193-26-000020'), // the comparative repeated: no frame, not read
+ q('2025-12-28', '2026-03-28', 95359000000, '0000320193-26-000013', 'CY2026Q1'),
+ q('2026-03-29', '2026-06-27', 109417000000, '0000320193-26-000020', 'CY2026Q2'),
+ q('2024-09-29', '2025-09-27', 416161000000, '0000320193-25-000079', 'CY2025', '10-K'),
+ ] } },
+ EarningsPerShareDiluted: { units: { 'USD/shares': [q('2026-03-29', '2026-06-27', 2.02, '0000320193-26-000020', 'CY2026Q2')] } },
+ Assets: { units: { USD: [i('2026-06-27', 383266000000, '0000320193-26-000020', 'CY2026Q2I')] } },
+ },
+ },
+};
+
+const quiet = { log() {}, warn() {}, error() {} };
+
+(async () => {
+ const secCalls = [];
+ const sec = { enabled: true, async companyFacts(cik) { secCalls.push(cik); return { doc: FACTS, retrievedAt: t.clock.now() }; } };
+ const aiCalls = [];
+ let aiOutput = null;
+ let aiStub = false;
+ const ai = {
+ enabled: true, WORKFLOW: 'trade.summarize_market_context',
+ async run(input) { aiCalls.push(input); return { runId: `run_${aiCalls.length}`, model: 'test-model', stub: aiStub, output: aiOutput(input) }; },
+ };
+ const t = await boot({ sec, ai, log: quiet, env: { TRADE_BRIEFS_PER_HOUR: '3' } });
+ const db = t.ctx.store.db;
+ const apple = await t.instrument({ symbol: 'AAPL', name: 'Apple Inc.', cik: '320193', exchange: 'Nasdaq' });
+ const rev = (input) => input.sources.findIndex((s) => /Revenue \(quarter\)/.test(s.title));
+ aiOutput = (input) => ({
+ summary: 'Apple reported revenue of 109.4 billion US dollars for the three months to 27 June 2026, up from 94.0 billion a year earlier.',
+ observations: [
+ { text: 'Quarterly revenue rose from 94,036 million USD to 109,417 million USD year over year.', observed_at: '2026-06-27', citations: [rev(input)] },
+ { text: 'A sentence with nothing behind it.', citations: [] },
+ ],
+ disclaimer: 'Informational only; not investment advice.', citations: [rev(input)], gaps: [],
+ });
+
+ await check('SEC facts: framed facts only, the newest concept, periods and filings kept; a re-read adds nothing', async () => {
+ const rows = extract(FACTS);
+ const revq = rows.filter((r) => r.metric === 'revenue.q');
+ assert.deepStrictEqual(revq.map((r) => [r.value, r.period, r.concept]), [
+ ['109417000000', '3 months to 2026-06-27', 'us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax'],
+ ['95359000000', '3 months to 2026-03-28', 'us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax'],
+ ['94036000000', '3 months to 2025-06-28', 'us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax'],
+ ]);
+ assert.deepStrictEqual(rows.filter((r) => r.metric === 'revenue.fy').map((r) => r.period), ['12 months to 2025-09-27']);
+ assert.deepStrictEqual(rows.find((r) => r.metric === 'shares_outstanding'), { ...rows.find((r) => r.metric === 'shares_outstanding'), unit: 'shares', currency: null, period: 'at 2026-07-17' });
+ assert.strictEqual(rows.find((r) => r.metric === 'eps_diluted.q').unit, 'USD/shares');
+
+ const r = await t.ctx.facts.record(apple);
+ assert.strictEqual(r.recorded, rows.length, JSON.stringify(r));
+ const again = await t.ctx.facts.record(apple);
+ assert.strictEqual(again.recorded, 0);
+ assert.strictEqual(again.unchanged, rows.length);
+ const o = await db.prepare("SELECT * FROM trade_market_observations WHERE instrument_id = ? AND metric = 'revenue.q' ORDER BY observed_at DESC LIMIT 1").get(apple.id);
+ assert.strictEqual(o.source_key, 'sec-companyfacts');
+ assert.strictEqual(o.source_url, 'https://www.sec.gov/Archives/edgar/data/320193/000032019326000020/');
+ assert.strictEqual(o.source_ref, '0000320193:0000320193-26-000020:us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax:CY2026Q2');
+ const decision = await t.ctx.indexing.decide(apple);
+ assert.ok(!decision.reasons || !decision.reasons.includes('stale_price'), `a filed figure is not a stale price: ${JSON.stringify(decision.reasons)}`);
+ });
+
+ await check('a new report: the tick reads the numbers again and writes a brief citing Trade\'s own rows', async () => {
+ t.clock.advance(60_000);
+ await t.ctx.documents.upsert({
+ source_key: 'sec-xbrl-filings', source_item_id: 'itm_01J8Z3V9Q6N1X2Y3Z4A5B6C7D8', source_revision: 1, title: 'Apple Inc. (0000320193) (Filer)',
+ url: 'https://www.sec.gov/Archives/edgar/data/320193/000032019326000090/0000320193-26-000090-index.htm', published_at: t.clock.now() - 1000,
+ source_updated_at: null, retrieved_at: t.clock.now(), first_seen_at: t.clock.now(), license_note: null, terms_note: null,
+ kind: 'filing', form_type: '10-Q', filer_name: 'Apple Inc.', cik: '0000320193', accession: '0000320193-26-000090',
+ }, apple);
+ const calls = secCalls.length;
+ const tick = await t.ctx.worker.briefsTick();
+ assert.deepStrictEqual(tick, { facts: 1, briefs: 1, failed: 0 });
+ assert.strictEqual(secCalls.length, calls + 1, 'the numbers were read again after the report');
+ const input = aiCalls[aiCalls.length - 1];
+ assert.deepStrictEqual(input.instrument, { symbol: 'AAPL', name: 'Apple Inc.', exchange: 'Nasdaq' });
+ assert.ok(input.sources.length <= 50 && input.sources.some((s) => s.source_type === 'trade.sec_fact') && input.sources.some((s) => s.source_type === 'trade.sec_filing'));
+ assert.match(input.sources[rev(input)].content, /3 months to 2026-06-27: 109417000000 USD \(filing 0000320193-26-000020\)/);
+ const b = await t.ctx.briefs.latest(apple);
+ assert.strictEqual(b.points.length, 1, 'the uncited point is dropped');
+ assert.ok(b.gaps.some((g) => /nothing behind it/.test(g)), 'and listed as a gap');
+ assert.ok(b.points[0].cites.every((c) => c.kind === 'observation' && /^obs_/.test(c.id)));
+ assert.strictEqual(b.authorship.reviewed, false);
+ assert.strictEqual(b.as_of, '2026-06-27T00:00:00.000Z');
+ assert.deepStrictEqual(await t.ctx.worker.briefsTick(), { facts: 0, briefs: 0, failed: 0 }, 'nothing new, nothing written');
+ });
+
+ await check('the page labels the brief and links each point to its row; the home lists it', async () => {
+ const page = await t.get('/i/AAPL');
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('AI-written, not reviewed by a person'));
+ const b = await t.ctx.briefs.latest(apple);
+ assert.ok(page.text.includes(`href="#${b.points[0].cites[0].id}"`), 'the citation links to the observation row');
+ assert.ok(page.text.includes(`id="${b.points[0].cites[0].id}"`) || page.text.includes('Revenue (quarter)'), 'the row is on the page');
+ assert.ok(page.text.includes('not investment advice'));
+ const home = await t.get('/');
+ assert.ok(home.text.includes('Latest AI briefs') && home.text.includes('#ai-brief'));
+ });
+
+ await check('the same inputs make no second brief; advice and stub runs are refused', async () => {
+ const n = aiCalls.length;
+ const same = await t.ctx.briefs.make(apple, { trigger: 'reader' });
+ assert.strictEqual(same.created, false);
+ assert.strictEqual(aiCalls.length, n, 'the model was not asked again');
+ const msft = await t.instrument({ symbol: 'MSFT', name: 'Microsoft', cik: '789019' });
+ await t.ctx.facts.record(msft);
+ const keep = aiOutput;
+ aiOutput = () => ({ summary: 'Strong buy: you should buy before the price target is reached.', observations: [], citations: [0], gaps: [] });
+ await assert.rejects(t.ctx.briefs.make(msft), (e) => e.code === 'brief.advice_refused');
+ aiOutput = keep;
+ aiStub = true;
+ await assert.rejects(t.ctx.briefs.make(msft), (e) => e.code === 'brief.stub');
+ aiStub = false;
+ assert.strictEqual(await t.ctx.briefs.latest(msft), null, 'nothing kept');
+ });
+
+ await check('a reader asks from Trade\'s own page only, and the hourly budget holds', async () => {
+ const cross = await t.get('/i/MSFT/brief', { method: 'POST', headers: { origin: 'https://evil.example' } });
+ assert.strictEqual(cross.status, 403);
+ const ok = await t.get('/i/MSFT/brief', { method: 'POST', headers: { origin: 'https://openvibe.trade' } });
+ assert.strictEqual(ok.status, 303, ok.text.slice(0, 300));
+ assert.match(ok.headers.get('location'), /^\/i\/MSFT\?n=brief_made#ai-brief$/);
+ const again = await t.get('/i/MSFT/brief', { method: 'POST', headers: { referer: 'https://openvibe.trade/i/MSFT' } });
+ assert.match(again.headers.get('location'), /n=brief_current/);
+ // Two briefs this hour (AAPL, MSFT) and a budget of three: one more, then 429.
+ const nvda = await t.instrument({ symbol: 'NVDA', name: 'NVIDIA', cik: '1045810' });
+ await t.ctx.facts.record(nvda);
+ const goog = await t.instrument({ symbol: 'GOOGL', name: 'Alphabet', cik: '1652044' });
+ await t.ctx.facts.record(goog);
+ aiOutput = (input) => ({ summary: `Figures for ${input.instrument.symbol}.`, observations: [], citations: [0], gaps: [] });
+ assert.strictEqual((await t.ctx.briefs.make(nvda)).created, true);
+ await assert.rejects(t.ctx.briefs.make(goog), (e) => e.code === 'brief.budget');
+ t.clock.advance(3600_000 + 1);
+ assert.strictEqual((await t.ctx.briefs.make(goog)).created, true, 'the next hour has room again');
+ });
+
+ await t.close();
+ done();
+})();
@@ -9,7 +9,8 @@
@@ -9,7 +9,8 @@
* t.instrument({ symbol, name, cik }) // create an instrument through the domain (seed for tests)
* t.instrument({ symbol, name, cik }) // create an instrument through the domain (seed for tests)
* t.events(type) // parsed envelopes in event_outbox
* t.events(type) // parsed envelopes in event_outbox
*
*
- * opts: env (overrides), clock, log (Trade's logger; default quiet), limitsNow (the per-actor limiter's
+ * opts: env (overrides), clock, log (Trade's logger; default quiet), sec and ai (stand-ins for clients/sec.js and
+ * clients/ai.js), limitsNow (the per-actor limiter's
* clock; default the wall clock).
* clock; default the wall clock).
*/
*/
const http = require('http');
const http = require('http');
@@ -49,7 +50,7 @@ async function boot(opts = {}) {
@@ -49,7 +50,7 @@ async function boot(opts = {}) {
let built = null;
let built = null;
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
- built = await createApp({ config, telemetrySink: opts.telemetrySink, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend });
+ built = await createApp({ config, telemetrySink: opts.telemetrySink, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend, sec: opts.sec, ai: opts.ai });
await built.ctx.jwks.keys();
await built.ctx.jwks.keys();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;
t.base = `http://127.0.0.1:${server.address().port}`;
@@ -12,13 +12,13 @@ const { spawn } = require('child_process');
@@ -12,13 +12,13 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 26, // measured 24.6 (fresh database; 18.2 before the showcase hero and features, 2026-10-07)
+ htmlRawKB: 29, // measured 27.4 with the AI briefs section and feature (2026-10-15); 24.6 before (18.2 before the showcase, 2026-10-07)
htmlBrotliKB: 7, // 6.2 (4.7 before the showcase)
htmlBrotliKB: 7, // 6.2 (4.7 before the showcase)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (trade.css, openvibe-shared showcase.css)
cssFiles: 2, // 2 (trade.css, openvibe-shared showcase.css)
- cssRawKB: 17.5, // 16.2 (4.7 before showcase.css)
+ cssRawKB: 18.5, // 17.8 with the AI brief styles (2026-10-15); 16.2 before (4.7 before showcase.css)
cssBrotliKB: 4.4, // 3.9 (1.3 before showcase.css)
cssBrotliKB: 4.4, // 3.9 (1.3 before showcase.css)
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};