diff --git a/app/api/research/agent-flow/route.ts b/app/api/research/agent-flow/route.ts index 12d77093..deebe2c0 100644 --- a/app/api/research/agent-flow/route.ts +++ b/app/api/research/agent-flow/route.ts @@ -3,7 +3,7 @@ import { createClient } from "@/lib/supabase/server"; import { aiJsonWithFallback, configuredAiProviders } from "@/lib/ai-fallback"; import { getBitgetAsset, getBitgetCandles, getBitgetStockPerp, STOCK_PERP_UNIVERSE } from "@/lib/bitget-market"; import { buildSignals, prepareBacktestCandles } from "@/lib/backtest"; -import { extractFlowTickers, gateFlowTrade, normalizeFlowAssessments, type FlowAssessment, type FlowSource } from "@/lib/agent-flow-policy"; +import { extractFlowTickers, gateFlowTrade, normalizeFlowAssessments, sourceOnlyAssessment, type FlowAssessment, type FlowSource } from "@/lib/agent-flow-policy"; import { gatherIssuerEvidence, type FlowEvidence } from "@/lib/agent-flow-sources"; import { agentKeyIssuerEvidence } from "@/lib/agentkey-flow-sources"; @@ -23,14 +23,11 @@ async function settleWithin(task: Promise, timeoutMs: number): Promise if (timer) clearTimeout(timer); } } -const HOLD: FlowAssessment = { summary: "The available source headlines do not support a reliable directional assessment. Review the linked items and the issuer's original filings.", stance: "unclear", confidence: 0, citedUrls: [], supportingEvidence: [], counterEvidence: [], risks: ["Source headlines may omit material context."], nextCheck: "Review original issuer filings and fresh completed candles." }; - -function sourceOnlyRead(snap: MarketSnapshot): FlowAssessment { +function sourceOnlyRead(snap: MarketSnapshot, tokenizedMarketQuestion: boolean): FlowAssessment { const publishers = new Set(snap.sources.map(source => source.publisher.trim().toLowerCase()).filter(Boolean)); const latest = snap.sources[0]; - if (!latest) return { ...HOLD, summary: `No attributable public headlines were reached for ${snap.ticker}. Check the issuer's original filings and retry later.` }; - const date = latest.publishedAt ? new Date(latest.publishedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }) : "date unavailable"; - return { ...HOLD, summary: `Collected ${snap.sources.length} public headlines across ${publishers.size} publishers. Most recent listed headline: “${latest.title.slice(0, 170)}” (${latest.publisher}, ${date}). AI synthesis is unavailable, so review the linked items directly. No directional trade assessment was made.` }; + const date = latest?.publishedAt ? new Date(latest.publishedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }) : null; + return sourceOnlyAssessment({ ticker: snap.ticker, headlineCount: snap.sources.length, publishers: publishers.size, latestTitle: latest?.title, latestPublisher: latest?.publisher, latestDate: date, tokenizedMarketQuestion, spotSymbol: snap.spot?.symbol, spotPrice: snap.spot?.lastPrice, spotTimestamp: snap.spot?.providerTimestamp, perpSymbol: snap.perp?.symbol }); } function event(type: "stage" | "result" | "error", payload: unknown) { @@ -151,7 +148,7 @@ export async function POST(request: Request) { const now = Date.now(); const assets = snapshots.map(snap => { - const assessment = assessments[snap.ticker] ?? sourceOnlyRead(snap); + const assessment = assessments[snap.ticker] ?? sourceOnlyRead(snap, tokenizedMarketQuestion); let completed: MarketSnapshot["candles"] = []; try { completed = prepareBacktestCandles(snap.candles, "4H", now); } catch { /* Malformed candles keep this asset's trading gate closed. */ } diff --git a/app/flow/flow.css b/app/flow/flow.css index 18016df0..cf3e4822 100644 --- a/app/flow/flow.css +++ b/app/flow/flow.css @@ -367,3 +367,4 @@ .flow-coverage small{flex-basis:100%;line-height:1.55} .flow-confidence-note{margin:-8px 0 14px;padding:9px 12px;border:1px solid color-mix(in srgb,var(--amber) 28%,var(--line));border-radius:10px;background:color-mix(in srgb,var(--amber) 7%,transparent);color:var(--muted);font:10px/1.5 var(--font)} @media(max-width:560px){.flow-depth-badge{margin:7px 0 0}.flow-coverage{gap:8px 12px;padding:12px;border-radius:12px}.flow-coverage span:last-of-type{flex-basis:100%}} +.flow-market-references{display:grid;gap:7px;margin:14px 0;padding:13px 14px;border:1px solid var(--line);border-radius:12px;background:color-mix(in srgb,var(--panel) 76%,transparent)}.flow-market-references>b{color:var(--mint);font:9px var(--mono);letter-spacing:.1em;text-transform:uppercase}.flow-market-references>span{color:var(--muted);font:10px/1.55 var(--font)} diff --git a/app/ui/agent-flow-desk.tsx b/app/ui/agent-flow-desk.tsx index ad75689f..0185339d 100644 --- a/app/ui/agent-flow-desk.tsx +++ b/app/ui/agent-flow-desk.tsx @@ -13,7 +13,7 @@ type AssetRead = { confidence: number; confidenceCapped?: boolean; tradeable: boolean; gateReasons: string[]; evidenceBalance?: { supporting: Array<{ text: string; citedUrls: string[] }>; counter: Array<{ text: string; citedUrls: string[] }> }; sources: Array<{ title: string; url: string; publisher: string; publishedAt: string | null; via?: "Public feeds" | "AgentKey" }>; - market: { perpSymbol: string | null; spotSymbol: string | null; lastPrice: number | null; providerTimestamp?: number | null }; + market: { perpSymbol: string | null; spotSymbol: string | null; lastPrice: number | null; providerTimestamp?: number | null; perpLastPrice?: number | null; perpProviderTimestamp?: number | null; spotLastPrice?: number | null; spotProviderTimestamp?: number | null }; handoff: { destinations: Destination[]; reason: string } | null; }; type FlowResult = { researchRunId?: string; question: string; assets: AssetRead[]; tradeable: boolean; handoff?: unknown; trace: StageEvent[]; researchCoverage?: { mode: string; lookbackDays: number; queriesPerTicker: number; collectedItems: number; publishers: number; sourceDomains: number; collectedAt: string; limitations: string[] } }; @@ -27,7 +27,7 @@ function QuoteFreshness({ market, now }: { market: AssetRead["market"]; now: num return
{fresh ? "Within the 2-minute quote window" : known ? "Quote outside the 2-minute window" : "Quote freshness unverified"} {known ? Provider quote as of (your local time) : No usable provider timestamp was returned.} - {market.perpSymbol || market.spotSymbol || "Market reference"} · Snapshot from this research run; prices do not update here. + Stock perpetual reference · {market.perpSymbol || "Unavailable"} · Snapshot from this research run; prices do not update here.
; } @@ -176,7 +176,7 @@ export default function AgentFlowDesk({ initialQuestion, evaluatorMode = false } {error ?
Research paused{error}{error.toLowerCase().includes("sign in") ? Sign in ↗ : }
: null} {result ?
RESEARCH / {result.assets.length} {result.assets.length === 1 ? "ISSUER" : "ISSUERS"}

One question. Separate decisions.

Issuer coverage and token-market conditions are shown separately. Review the source links and quote timestamp before considering any next step.

-
{result.assets.map((asset) =>
{asset.ticker} / {asset.issuer}

{asset.issuer}

{asset.tradeable ? "REVIEW AVAILABLE" : "NO TRADE HANDOFF"}

{asset.summary}

Read {asset.stance}Model confidence {Math.round(asset.confidence * 100)}%{asset.market.lastPrice !== null ? Quote snapshot ${asset.market.lastPrice.toLocaleString()} : null}

{asset.confidenceCapped ? "Evidence is limited: the score is capped until two recent, distinct publishers support the assessment. " : ""}Model-reported score, not a calibrated probability.

{asset.evidenceBalance && (asset.evidenceBalance.supporting.length > 0 || asset.evidenceBalance.counter.length > 0) ?
Evidence balanceSource-linked points only
{([["Supports this reading", asset.evidenceBalance.supporting], ["Challenges this reading", asset.evidenceBalance.counter]] as const).map(([label, points]) =>

{label}

{points.length ?
    {points.map((point, index) =>
  • {point.text}

    {point.citedUrls.map((url) => { const source = asset.sources.find((item) => item.url === url); return source ? {source.publisher} ↗ : null; })}
  • )}
:

No source-linked points were established for this side.

}
)}
: null}{asset.gateReasons?.length ?
Decision gates
    {asset.gateReasons.map((reason, index) =>
  • {reason}
  • )}
: null}
Inspect {asset.sources.length} {asset.sources.length === 1 ? "source" : "sources"}{asset.sources.length ?
    {asset.sources.map((source, index) =>
  • {source.publisher}{source.publishedAt ? ` · ${new Date(source.publishedAt).toLocaleDateString()}` : ""}{source.via ? · {source.via} : null}{source.title} ↗
  • )}
:

No source links were returned; the trading gate remains closed.

}
)}
+
{result.assets.map((asset) =>
{asset.ticker} / {asset.issuer}

{asset.issuer}

{asset.tradeable ? "REVIEW AVAILABLE" : "NO TRADE HANDOFF"}

{asset.summary}

Read {asset.stance}Model confidence {Math.round(asset.confidence * 100)}%
Instrument checksTokenized spot · {asset.market.spotSymbol ?? `R${asset.ticker}USDT`} · {asset.market.spotLastPrice != null ? `$${asset.market.spotLastPrice.toLocaleString()}` : "No verified quote"}{asset.market.spotProviderTimestamp ? ` · ${new Date(asset.market.spotProviderTimestamp).toLocaleString()}` : " · freshness unverified"}Stock perpetual · {asset.market.perpSymbol ?? `${asset.ticker}USDT`} · {asset.market.perpLastPrice != null ? `$${asset.market.perpLastPrice.toLocaleString()}` : "No verified quote"}{asset.market.perpProviderTimestamp ? ` · ${new Date(asset.market.perpProviderTimestamp).toLocaleString()}` : " · freshness unverified"} · derivative reference

{asset.confidenceCapped ? "Evidence is limited: the score is capped until two recent, distinct publishers support the assessment. " : ""}Model-reported score, not a calibrated probability.

{asset.evidenceBalance && (asset.evidenceBalance.supporting.length > 0 || asset.evidenceBalance.counter.length > 0) ?
Evidence balanceSource-linked points only
{([["Supports this reading", asset.evidenceBalance.supporting], ["Challenges this reading", asset.evidenceBalance.counter]] as const).map(([label, points]) =>

{label}

{points.length ?
    {points.map((point, index) =>
  • {point.text}

    {point.citedUrls.map((url) => { const source = asset.sources.find((item) => item.url === url); return source ? {source.publisher} ↗ : null; })}
  • )}
:

No source-linked points were established for this side.

}
)}
: null}{asset.gateReasons?.length ?
Decision gates
    {asset.gateReasons.map((reason, index) =>
  • {reason}
  • )}
: null}
Inspect {asset.sources.length} {asset.sources.length === 1 ? "source" : "sources"}{asset.sources.length ?
    {asset.sources.map((source, index) =>
  • {source.publisher}{source.publishedAt ? ` · ${new Date(source.publishedAt).toLocaleDateString()}` : ""}{source.via ? · {source.via} : null}{source.title} ↗
  • )}
:

No source links were returned; the trading gate remains closed.

}
)}
{tradeableAssets.length ?
NEXT / YOUR DECISION

Review the market before acting.

The research gates found a scenario worth inspecting. Choose paper futures, or review a Bitget tokenized stock spot order in demo or live account mode. You can review both desks. Every order still requires your own approval and risk checks.

{tradeableAssets.map((asset) =>
{asset.ticker}{asset.handoff?.reason}
{asset.handoff?.destinations.flatMap((destination) => { const href = safeDeskLink(destination); if (!href) return []; if (destination.mode === "paper_futures") return [ setMode("pro")}>Review paper futures ↗]; const demo = `${href}${href.includes("?") ? "&" : "?"}intent=demo`; const live = `${href}${href.includes("?") ? "&" : "?"}intent=live`; return [ setMode("pro")}>Review Bitget demo spot ↗, setMode("pro")}>Review Bitget live spot ↗]; })}
)}
Research can be uncertain. A review link never places a trade or switches your account into live mode.{mode === "mini" ? " Trade reviews open the Pro workspace controls." : ""}
:
RISK GATE / HOLDNo trading handoff for this read.

Review the sources and decision gates above. You can return when fresher or stronger evidence is available.

Explore the market map ↗
}
: null} ; diff --git a/lib/agent-flow-policy.ts b/lib/agent-flow-policy.ts index bdabfa0c..81184220 100644 --- a/lib/agent-flow-policy.ts +++ b/lib/agent-flow-policy.ts @@ -14,6 +14,17 @@ export type FlowGateInput = { now: number; }; +/** Evidence-only fallback used when every configured synthesis provider is unavailable. */ +export function sourceOnlyAssessment(input: { ticker: string; headlineCount: number; publishers: number; latestTitle?: string | null; latestPublisher?: string | null; latestDate?: string | null; tokenizedMarketQuestion: boolean; spotSymbol?: string | null; spotPrice?: number | null; spotTimestamp?: number | null; perpSymbol?: string | null }): FlowAssessment { + const { ticker } = input; + const summary = input.tokenizedMarketQuestion + ? `${input.headlineCount} issuer-level ${ticker} headline${input.headlineCount === 1 ? " was" : "s were"} collected across ${input.publishers} publisher${input.publishers === 1 ? "" : "s"}${input.latestTitle ? `; latest listed item: “${input.latestTitle.slice(0, 120)}”${input.latestPublisher ? ` (${input.latestPublisher}${input.latestDate ? `, ${input.latestDate}` : ""})` : ""}` : ""}. This coverage concerns ${ticker}, not the r${ticker} token market. ${input.spotSymbol && input.spotPrice != null ? `A tokenized spot quote was returned for ${input.spotSymbol} at ${input.spotPrice}; quote freshness ${input.spotTimestamp ? "is shown separately below" : "is unverified"}.` : `No verified r${ticker} tokenized-spot quote was returned.`} ${input.perpSymbol ? `${input.perpSymbol} is a stock-perpetual reference, not the rToken.` : "No stock-perpetual reference was verified."} r${ticker}-specific liquidity, spread, and premium or discount were not measured. Conclusion: not enough evidence to judge whether r${ticker} is worth reviewing as a trading opportunity. A due-diligence review could proceed after verifying token availability, a fresh token quote, liquidity, spread, and premium or discount. AI synthesis is unavailable; no directional assessment was made.` + : input.headlineCount + ? `Collected ${input.headlineCount} public headlines across ${input.publishers} publishers${input.latestTitle ? `. Most recent listed headline: “${input.latestTitle.slice(0, 150)}”${input.latestPublisher ? ` (${input.latestPublisher}${input.latestDate ? `, ${input.latestDate}` : ""})` : ""}` : ""}. AI synthesis is unavailable, so review the linked items directly. No directional trade assessment was made.` + : `No attributable public headlines were reached for ${ticker}. Check the issuer's original filings and retry later.`; + return { summary, stance: "unclear", confidence: 0, citedUrls: [], supportingEvidence: [], counterEvidence: [], risks: ["AI synthesis is unavailable; retrieved headlines are discovery references only.", ...(input.tokenizedMarketQuestion ? ["Issuer coverage does not establish rToken liquidity, spread, or premium/discount."] : [])], nextCheck: input.tokenizedMarketQuestion ? "Verify the rToken's live availability, quote freshness, liquidity, spread, and premium or discount from instrument-specific sources." : "Review issuer filings and completed candles." }; +} + const aliases: Record = { NVDA: ["NVIDIA"], TSLA: ["TESLA"], AAPL: ["APPLE"], AMD: ["ADVANCED MICRO DEVICES"], MSFT: ["MICROSOFT"], META: ["META PLATFORMS"], AMZN: ["AMAZON"], diff --git a/tests/agent-flow-policy.test.mjs b/tests/agent-flow-policy.test.mjs index 0f90efa9..0a82bde3 100644 --- a/tests/agent-flow-policy.test.mjs +++ b/tests/agent-flow-policy.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { extractFlowTickers, gateFlowTrade, normalizeFlowAssessment, normalizeFlowAssessments } from "../lib/agent-flow-policy.ts"; +import { extractFlowTickers, gateFlowTrade, normalizeFlowAssessment, normalizeFlowAssessments, sourceOnlyAssessment } from "../lib/agent-flow-policy.ts"; const now = Date.now(); const sources = [ @@ -10,6 +10,17 @@ const sources = [ const assessment = { summary: "Two recent publishers describe an earnings and outlook development requiring an original-source check.", stance: "bullish", confidence: 0.8, citedUrls: sources.map(item => item.url), risks: ["Gap risk"], nextCheck: "Read the issuer release" }; const base = { assessment, sources, marketVerified: true, marketTimestamp: now - 30000, lastCompletedCandleEnd: now - 3600000, completedCandles: 80, currentSignal: true, previousSignal: false, now }; +test("source-only rToken research distinguishes issuer coverage from token-market evidence", () => { + const result = sourceOnlyAssessment({ ticker: "NVDA", headlineCount: 40, publishers: 19, latestTitle: "NVIDIA issuer update", latestPublisher: "Example News", latestDate: "Oct 21, 2026", tokenizedMarketQuestion: true, perpSymbol: "NVDAUSDT", perpPrice: 220, perpTimestamp: now }); + assert.match(result.summary, /not the rNVDA token market/i); + assert.match(result.summary, /No verified rNVDA tokenized-spot quote/); + assert.match(result.summary, /stock-perpetual reference, not the rToken/i); + assert.match(result.summary, /liquidity, spread, and premium or discount were not measured/i); + assert.match(result.summary, /not enough evidence/i); + assert.equal(result.stance, "unclear"); + assert.equal(result.confidence, 0); +}); + test("the coordinator recognizes only supported US stock underlyings", () => { assert.deepEqual(extractFlowTickers("What do you think about NVDA and TSLA?", ["NVDAUSDT", "TSLAUSDT", "AMDUSDT"]), ["NVDA", "TSLA"]); assert.deepEqual(extractFlowTickers("rNVDAUSDT and NVIDIA", ["NVDAUSDT"]), ["NVDA"]);