diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index e02db9ed..69d3a6b6 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,47 @@ // src/app/api/auth/[...nextauth]/route.ts import NextAuth from "next-auth"; +import type { NextRequest } from "next/server"; import { authOptions } from "@/lib/authOptions"; -const handler = NextAuth(authOptions); +const nextAuthHandler = NextAuth(authOptions); + +const PROD_FALLBACK_HOST = "tradiaai.app"; + +const resolveRequestBase = (request: NextRequest): string | null => { + const forwardedHost = request.headers.get("x-forwarded-host"); + const host = forwardedHost || request.headers.get("host"); + if (!host) return null; + + const cleanHost = host.trim().toLowerCase(); + if (!cleanHost) return null; + + const forwardedProto = request.headers.get("x-forwarded-proto"); + const protocol = forwardedProto || (cleanHost.includes("localhost") ? "http" : "https"); + + return `${protocol}://${cleanHost}`; +}; + +const applySafeNextAuthUrl = (request: NextRequest) => { + const requestBase = resolveRequestBase(request); + const isProd = process.env.NODE_ENV === "production"; + + if (isProd) { + if (requestBase && !requestBase.includes("localhost")) { + process.env.NEXTAUTH_URL = requestBase; + return; + } + process.env.NEXTAUTH_URL = `https://${PROD_FALLBACK_HOST}`; + return; + } + + if (requestBase) { + process.env.NEXTAUTH_URL = requestBase; + } +}; + +const handler = async (request: NextRequest, context: unknown) => { + applySafeNextAuthUrl(request); + return nextAuthHandler(request, context as any); +}; + export { handler as GET, handler as POST }; diff --git a/app/api/economic-events/route.ts b/app/api/economic-events/route.ts new file mode 100644 index 00000000..d1bb83c1 --- /dev/null +++ b/app/api/economic-events/route.ts @@ -0,0 +1,170 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/authOptions"; +import { createClient } from "@/utils/supabase/server"; +import type { EventImpact } from "@/types/eventAwareness"; + +export const dynamic = "force-dynamic"; + +type EventIngestItem = { + providerEventId: string; + title: string; + country?: string; + currency: string; + impact: EventImpact; + scheduledAt: string; + actual?: string | null; + forecast?: string | null; + previous?: string | null; + eventType?: string | null; +}; + +const normalizeImpact = (value: unknown): EventImpact | null => { + const normalized = String(value || "").trim().toLowerCase(); + if (normalized === "low" || normalized === "medium" || normalized === "high") return normalized; + return null; +}; + +const normalizeIso = (value: unknown): string | null => { + if (value === null || value === undefined) return null; + const parsed = new Date(String(value)); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +}; + +const isAuthorized = async (request: NextRequest): Promise => { + const bearer = request.headers.get("authorization") || ""; + const token = bearer.startsWith("Bearer ") ? bearer.slice(7).trim() : ""; + const configuredToken = process.env.ECONOMIC_EVENTS_INGEST_TOKEN?.trim(); + + if (configuredToken && token && token === configuredToken) { + return true; + } + + const session = await getServerSession(authOptions); + return Boolean(session?.user?.id); +}; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const currency = request.nextUrl.searchParams.get("currency")?.trim().toUpperCase(); + const from = normalizeIso(request.nextUrl.searchParams.get("from")) || new Date().toISOString(); + const to = + normalizeIso(request.nextUrl.searchParams.get("to")) || + new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(); + + const supabase = createClient(); + let query = supabase + .from("economic_events") + .select("id, provider_event_id, title, country, currency, impact, scheduled_at, actual, forecast, previous, event_type") + .gte("scheduled_at", from) + .lte("scheduled_at", to) + .order("scheduled_at", { ascending: true }) + .limit(100); + + if (currency) { + query = query.eq("currency", currency); + } + + const { data, error } = await query; + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ events: data || [] }); + } catch (error) { + console.error("GET /api/economic-events failed:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const allowed = await isAuthorized(request); + if (!allowed) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }); + } + + const inputItems = Array.isArray((body as { events?: unknown[] }).events) + ? ((body as { events: unknown[] }).events as unknown[]) + : []; + if (!inputItems.length) { + return NextResponse.json({ error: "events[] is required" }, { status: 400 }); + } + + const validRows: EventIngestItem[] = []; + for (const item of inputItems) { + const row = item as Partial; + const providerEventId = String(row.providerEventId || "").trim(); + const title = String(row.title || "").trim(); + const currency = String(row.currency || "").trim().toUpperCase(); + const impact = normalizeImpact(row.impact); + const scheduledAt = normalizeIso(row.scheduledAt); + + if (!providerEventId || !title || !currency || !impact || !scheduledAt) { + continue; + } + + validRows.push({ + providerEventId, + title, + country: row.country ? String(row.country).trim() : undefined, + currency, + impact, + scheduledAt, + actual: row.actual ? String(row.actual) : null, + forecast: row.forecast ? String(row.forecast) : null, + previous: row.previous ? String(row.previous) : null, + eventType: row.eventType ? String(row.eventType) : null, + }); + } + + if (!validRows.length) { + return NextResponse.json({ error: "No valid events found in payload" }, { status: 400 }); + } + + const supabase = createClient(); + const payload = validRows.map((row) => ({ + provider_event_id: row.providerEventId, + title: row.title, + country: row.country || null, + currency: row.currency, + impact: row.impact, + scheduled_at: row.scheduledAt, + actual: row.actual || null, + forecast: row.forecast || null, + previous: row.previous || null, + event_type: row.eventType || null, + updated_at: new Date().toISOString(), + })); + + const { data, error } = await supabase + .from("economic_events") + .upsert(payload, { onConflict: "provider_event_id" }) + .select("id, provider_event_id, title, currency, impact, scheduled_at"); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json( + { + ingested: data?.length || 0, + events: data || [], + }, + { status: 201 } + ); + } catch (error) { + console.error("POST /api/economic-events failed:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/event-awareness/route.ts b/app/api/event-awareness/route.ts new file mode 100644 index 00000000..4d2fec21 --- /dev/null +++ b/app/api/event-awareness/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/authOptions"; +import { createClient } from "@/utils/supabase/server"; +import { evaluateEventRisk } from "@/lib/forex/eventAwarenessService"; +import type { EconomicEvent } from "@/types/eventAwareness"; + +export const dynamic = "force-dynamic"; + +const normalizePlannedAt = (value: string | null): string => { + if (!value) return new Date().toISOString(); + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return new Date().toISOString(); + return parsed.toISOString(); +}; + +const mapImpact = (value: unknown): EconomicEvent["impact"] => { + const candidate = String(value || "").trim().toLowerCase(); + if (candidate === "high" || candidate === "medium" || candidate === "low") return candidate; + return "medium"; +}; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const pairSymbol = String(request.nextUrl.searchParams.get("pairSymbol") || "") + .trim() + .toUpperCase(); + if (!pairSymbol) { + return NextResponse.json({ error: "pairSymbol is required" }, { status: 400 }); + } + + const plannedAt = normalizePlannedAt(request.nextUrl.searchParams.get("plannedAt")); + const plannedDate = new Date(plannedAt); + const windowStart = new Date(plannedDate.getTime() - 2 * 60 * 60 * 1000); + const windowEnd = new Date(plannedDate.getTime() + 6 * 60 * 60 * 1000); + + const supabase = createClient(); + const { data: pairData, error: pairError } = await supabase + .from("forex_pairs") + .select("base_currency, quote_currency") + .eq("symbol", pairSymbol) + .eq("is_active", true) + .single(); + + if (pairError || !pairData) { + return NextResponse.json({ error: "Selected forex pair is not available" }, { status: 400 }); + } + + const currencies = [pairData.base_currency, pairData.quote_currency]; + const eventsResult = await supabase + .from("economic_events") + .select("id, title, currency, country, impact, scheduled_at") + .in("currency", currencies) + .gte("scheduled_at", windowStart.toISOString()) + .lte("scheduled_at", windowEnd.toISOString()) + .order("scheduled_at", { ascending: true }); + + // Gracefully fallback if this table is not yet migrated in some envs. + if (eventsResult.error) { + const fallback = evaluateEventRisk([], plannedAt, pairSymbol); + return NextResponse.json({ + plannedAt, + pairSymbol, + action: fallback.action, + summary: fallback.summary, + windowStart: fallback.windowStart, + windowEnd: fallback.windowEnd, + events: [], + }); + } + + const mappedEvents: EconomicEvent[] = (eventsResult.data || []).map((event) => ({ + id: String(event.id), + title: String(event.title || "Untitled event"), + currency: String(event.currency || "").toUpperCase(), + country: event.country ? String(event.country) : null, + impact: mapImpact(event.impact), + scheduled_at: String(event.scheduled_at), + })); + + const report = evaluateEventRisk(mappedEvents, plannedAt, pairSymbol); + + return NextResponse.json({ + plannedAt, + pairSymbol, + action: report.action, + summary: report.summary, + windowStart: report.windowStart, + windowEnd: report.windowEnd, + events: report.relevantEvents, + }); + } catch (error) { + console.error("GET /api/event-awareness failed:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/market-bias/route.ts b/app/api/market-bias/route.ts new file mode 100644 index 00000000..549bf161 --- /dev/null +++ b/app/api/market-bias/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/authOptions"; +import { createClient } from "@/utils/supabase/server"; +import { generateMarketBias } from "@/lib/forex/marketBiasService"; +import type { MarketBiasInput } from "@/types/marketBias"; + +export const dynamic = "force-dynamic"; + +const VALID_TIMEFRAMES = ["M5", "M15", "M30", "H1", "H4", "D1"] as const; + +const normalizeTimeframeSet = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + + const normalized = value + .map((item) => String(item ?? "").trim().toUpperCase()) + .filter((item) => VALID_TIMEFRAMES.includes(item as (typeof VALID_TIMEFRAMES)[number])); + + return Array.from(new Set(normalized)).slice(0, 6); +}; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const pairFilter = request.nextUrl.searchParams.get("pairSymbol")?.trim().toUpperCase(); + const supabase = createClient(); + + let query = supabase + .from("market_bias_reports") + .select( + "id, pair_symbol_snapshot, timeframe_set, bias_direction, confidence_score, key_levels, assumptions, invalidation_conditions, alternate_scenario, confidence_rationale, ai_model, prompt_version, generation_latency_ms, source, created_at" + ) + .eq("user_id", session.user.id) + .order("created_at", { ascending: false }) + .limit(10); + + if (pairFilter) { + query = query.eq("pair_symbol_snapshot", pairFilter); + } + + const { data, error } = await query; + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ reports: data || [] }); + } catch (error) { + console.error("GET /api/market-bias failed:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }); + } + + const pairSymbol = String(body.pairSymbol || "").trim().toUpperCase(); + const timeframeSet = normalizeTimeframeSet(body.timeframeSet); + const sessionContext = body.sessionContext ? String(body.sessionContext).trim() : ""; + const recentBackdrop = body.recentBackdrop ? String(body.recentBackdrop).trim() : ""; + + if (!pairSymbol) { + return NextResponse.json({ error: "pairSymbol is required" }, { status: 400 }); + } + if (!timeframeSet.length) { + return NextResponse.json({ error: "At least one valid timeframe is required" }, { status: 400 }); + } + + const supabase = createClient(); + + const { data: pairData, error: pairError } = await supabase + .from("forex_pairs") + .select("id, symbol") + .eq("symbol", pairSymbol) + .eq("is_active", true) + .single(); + + if (pairError || !pairData) { + return NextResponse.json({ error: "Selected forex pair is not available" }, { status: 400 }); + } + + const input: MarketBiasInput = { + pairSymbol, + timeframeSet, + sessionContext, + recentBackdrop, + }; + + const generated = await generateMarketBias(input); + + const { data: created, error: createError } = await supabase + .from("market_bias_reports") + .insert([ + { + user_id: session.user.id, + forex_pair_id: pairData.id, + pair_symbol_snapshot: pairData.symbol, + timeframe_set: timeframeSet, + bias_direction: generated.biasDirection, + confidence_score: generated.confidenceScore, + key_levels: generated.keyLevels, + assumptions: generated.assumptions, + invalidation_conditions: generated.invalidationConditions, + alternate_scenario: generated.alternateScenario, + confidence_rationale: generated.confidenceRationale, + ai_model: generated.aiModel, + prompt_version: generated.promptVersion, + generation_latency_ms: generated.generationLatencyMs, + source: "ai", + raw_ai_response: generated, + }, + ]) + .select( + "id, pair_symbol_snapshot, timeframe_set, bias_direction, confidence_score, key_levels, assumptions, invalidation_conditions, alternate_scenario, confidence_rationale, ai_model, prompt_version, generation_latency_ms, source, created_at" + ) + .single(); + + if (createError || !created) { + return NextResponse.json({ error: createError?.message || "Failed to save market bias report" }, { status: 500 }); + } + + return NextResponse.json(created, { status: 201 }); + } catch (error) { + console.error("POST /api/market-bias failed:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/pre-trade-brief/[id]/route.ts b/app/api/pre-trade-brief/[id]/route.ts index 37754e33..2dd4eed3 100644 --- a/app/api/pre-trade-brief/[id]/route.ts +++ b/app/api/pre-trade-brief/[id]/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/lib/authOptions"; import { createClient } from "@/utils/supabase/server"; -import type { ChecklistStateMap, EditablePreTradeBriefStatus } from "@/types/preTradeBrief"; +import type { ChecklistStateMap, EditablePreTradeBriefStatus, PreTradeApprovalState } from "@/types/preTradeBrief"; export const dynamic = "force-dynamic"; @@ -14,6 +14,8 @@ const EDITABLE_STATUSES: EditablePreTradeBriefStatus[] = [ "skipped", ]; +const APPROVAL_STATES: PreTradeApprovalState[] = ["ready", "blocked", "manual_override"]; + const isChecklistStateMap = (value: unknown): value is ChecklistStateMap => { if (!value || typeof value !== "object" || Array.isArray(value)) return false; @@ -57,6 +59,14 @@ export async function GET(_: NextRequest, { params }: { params: { id: string } } ai_risks, ai_invalidators, ai_checklist, + ai_model, + prompt_version, + generation_latency_ms, + event_risk_action, + event_risk_summary, + event_risk_window_start, + event_risk_window_end, + approval_state, raw_ai_response, status, trader_notes, @@ -97,7 +107,9 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st trader_notes?: string | null; checklist_state?: ChecklistStateMap | null; last_reviewed_at?: string | null; + approval_state?: PreTradeApprovalState; } = {}; + const manualApprovalRequested = body.approval_state !== undefined; if (body.status !== undefined) { const status = String(body.status || "").trim().toLowerCase() as EditablePreTradeBriefStatus; @@ -137,12 +149,45 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st } } + if (body.approval_state !== undefined) { + const requested = String(body.approval_state || "").trim().toLowerCase() as PreTradeApprovalState; + if (!APPROVAL_STATES.includes(requested)) { + return NextResponse.json({ error: "Invalid approval_state value" }, { status: 400 }); + } + updatePayload.approval_state = requested; + } + if (Object.keys(updatePayload).length === 0) { return NextResponse.json({ error: "No supported fields provided" }, { status: 400 }); } const supabase = createClient(); + const { data: current, error: currentError } = await supabase + .from("pre_trade_briefs") + .select("id, ai_checklist, checklist_state, event_risk_action, approval_state") + .eq("id", params.id) + .eq("user_id", session.user.id) + .single(); + + if (currentError || !current) { + return NextResponse.json({ error: "Brief not found or update failed" }, { status: 404 }); + } + + if (!manualApprovalRequested || updatePayload.approval_state !== "manual_override") { + const mergedChecklistState = { + ...((current.checklist_state as ChecklistStateMap | null) || {}), + ...((updatePayload.checklist_state as ChecklistStateMap | null) || {}), + } as ChecklistStateMap; + + const aiChecklist = Array.isArray(current.ai_checklist) ? (current.ai_checklist as string[]) : []; + const allAiChecklistComplete = aiChecklist.every((item) => mergedChecklistState[item]?.completed === true); + const eventRiskAck = mergedChecklistState["Event risk acknowledged"]?.completed === true; + const blockedByEventRisk = current.event_risk_action === "wait" && !eventRiskAck; + + updatePayload.approval_state = allAiChecklistComplete && !blockedByEventRisk ? "ready" : "blocked"; + } + const { data, error } = await supabase .from("pre_trade_briefs") .update(updatePayload) @@ -165,6 +210,14 @@ export async function PATCH(request: NextRequest, { params }: { params: { id: st ai_risks, ai_invalidators, ai_checklist, + ai_model, + prompt_version, + generation_latency_ms, + event_risk_action, + event_risk_summary, + event_risk_window_start, + event_risk_window_end, + approval_state, status, trader_notes, checklist_state, diff --git a/app/api/pre-trade-brief/metrics/route.ts b/app/api/pre-trade-brief/metrics/route.ts new file mode 100644 index 00000000..802c4f8e --- /dev/null +++ b/app/api/pre-trade-brief/metrics/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/authOptions"; +import { createClient } from "@/utils/supabase/server"; +import type { ChecklistStateMap } from "@/types/preTradeBrief"; +import type { PreTradeQualityMetrics } from "@/types/preTradeMetrics"; + +export const dynamic = "force-dynamic"; + +const DEFAULT_WINDOW_DAYS = 30; +const MIN_WINDOW_DAYS = 7; +const MAX_WINDOW_DAYS = 90; + +const toWindowDays = (raw: string | null): number => { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return DEFAULT_WINDOW_DAYS; + const rounded = Math.round(parsed); + return Math.max(MIN_WINDOW_DAYS, Math.min(MAX_WINDOW_DAYS, rounded)); +}; + +const toChecklistState = (value: unknown): ChecklistStateMap => { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return value as ChecklistStateMap; +}; + +const normalizeAiChecklist = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + return value.map((item) => String(item ?? "").trim()).filter(Boolean); +}; + +const isChecklistComplete = ( + aiChecklist: string[], + checklistState: ChecklistStateMap, + eventRiskAction: string | null +): boolean => { + if (!aiChecklist.length) return false; + const allAiItemsDone = aiChecklist.every((item) => checklistState[item]?.completed === true); + const eventRiskNeedsAck = eventRiskAction === "wait"; + const eventRiskAcknowledged = checklistState["Event risk acknowledged"]?.completed === true; + return allAiItemsDone && (!eventRiskNeedsAck || eventRiskAcknowledged); +}; + +const asPercent = (value: number): number => Math.round(value * 1000) / 10; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const periodDays = toWindowDays(request.nextUrl.searchParams.get("days")); + const fromDate = new Date(Date.now() - periodDays * 24 * 60 * 60 * 1000).toISOString(); + const supabase = createClient(); + + const { data, error } = await supabase + .from("pre_trade_briefs") + .select("status, created_at, last_reviewed_at, approval_state, ai_checklist, checklist_state, event_risk_action") + .eq("user_id", session.user.id) + .gte("created_at", fromDate); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + const briefs = data || []; + const generated = briefs.length; + const reviewed = briefs.filter((item) => Boolean(item.last_reviewed_at)).length; + const activeDays = new Set( + briefs + .map((item) => String(item.created_at || "").slice(0, 10)) + .filter(Boolean) + ).size; + + const checklistCoverage = briefs.filter((item) => normalizeAiChecklist(item.ai_checklist).length > 0).length; + const checklistComplete = briefs.filter((item) => + isChecklistComplete( + normalizeAiChecklist(item.ai_checklist), + toChecklistState(item.checklist_state), + item.event_risk_action ? String(item.event_risk_action) : null + ) + ).length; + + const executedBriefs = briefs.filter((item) => item.status === "executed"); + const executionDriftCount = executedBriefs.filter((item) => { + const checklistCompleteForBrief = isChecklistComplete( + normalizeAiChecklist(item.ai_checklist), + toChecklistState(item.checklist_state), + item.event_risk_action ? String(item.event_risk_action) : null + ); + + return item.approval_state === "blocked" || !checklistCompleteForBrief; + }).length; + + const metrics: PreTradeQualityMetrics = { + period_days: periodDays, + generated_briefs: generated, + reviewed_briefs: reviewed, + active_days: activeDays, + adoption_rate: generated > 0 ? asPercent(reviewed / generated) : 0, + checklist_coverage: checklistCoverage, + checklist_completion_rate: checklistCoverage > 0 ? asPercent(checklistComplete / checklistCoverage) : 0, + executed_briefs: executedBriefs.length, + execution_drift_rate: executedBriefs.length > 0 ? asPercent(executionDriftCount / executedBriefs.length) : 0, + }; + + return NextResponse.json(metrics); + } catch (error) { + console.error("GET /api/pre-trade-brief/metrics failed:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/pre-trade-brief/route.ts b/app/api/pre-trade-brief/route.ts index d0b152e9..29df7004 100644 --- a/app/api/pre-trade-brief/route.ts +++ b/app/api/pre-trade-brief/route.ts @@ -3,13 +3,24 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "@/lib/authOptions"; import { createClient } from "@/utils/supabase/server"; import { calculateRiskReward, generatePreTradeBrief } from "@/lib/forex/preTradeBriefService"; +import { evaluateEventRisk } from "@/lib/forex/eventAwarenessService"; import type { DirectionalBias, MarketSession, PreTradeBriefInput } from "@/types/preTradeBrief"; +import type { EconomicEvent } from "@/types/eventAwareness"; export const dynamic = "force-dynamic"; const VALID_SESSIONS: MarketSession[] = ["ASIA", "LONDON", "NEW_YORK", "OVERLAP"]; const VALID_BIAS: DirectionalBias[] = ["bullish", "bearish", "neutral"]; const VALID_TIMEFRAMES = ["M5", "M15", "M30", "H1", "H4", "D1"] as const; +const FILTERABLE_STATUSES = [ + "generated", + "draft", + "ready", + "invalidated", + "executed", + "skipped", + "failed", +] as const; const isValidNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); @@ -20,14 +31,46 @@ const normalizeOptionalNumber = (value: unknown): number | null => { return Number.isFinite(parsed) ? parsed : null; }; -export async function GET() { +const normalizePlannedExecutionAt = (value: unknown): string | null => { + if (value === null || value === undefined || value === "") return null; + const parsed = new Date(String(value)); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +}; + +const mapImpact = (value: unknown): EconomicEvent["impact"] => { + const candidate = String(value || "").trim().toLowerCase(); + if (candidate === "high" || candidate === "medium" || candidate === "low") return candidate; + return "medium"; +}; + +export async function GET(request: NextRequest) { try { const session = await getServerSession(authOptions); if (!session?.user?.id) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const statusFilterRaw = request.nextUrl.searchParams.get("status"); + const statusFilter = statusFilterRaw ? statusFilterRaw.trim().toLowerCase() : "all"; + + if ( + statusFilter !== "all" && + !FILTERABLE_STATUSES.includes(statusFilter as (typeof FILTERABLE_STATUSES)[number]) + ) { + return NextResponse.json({ error: "status filter is invalid" }, { status: 400 }); + } + const supabase = createClient(); + let briefsQuery = supabase + .from("pre_trade_briefs") + .select("id, pair_symbol_snapshot, timeframe, market_session, directional_bias_input, status, created_at") + .eq("user_id", session.user.id) + .order("created_at", { ascending: false }) + .limit(10); + + if (statusFilter !== "all") { + briefsQuery = briefsQuery.eq("status", statusFilter); + } const [pairsResult, briefsResult] = await Promise.all([ supabase @@ -35,12 +78,7 @@ export async function GET() { .select("id, symbol, base_currency, quote_currency, category") .eq("is_active", true) .order("symbol", { ascending: true }), - supabase - .from("pre_trade_briefs") - .select("id, pair_symbol_snapshot, timeframe, market_session, directional_bias_input, status, created_at") - .eq("user_id", session.user.id) - .order("created_at", { ascending: false }) - .limit(10), + briefsQuery, ]); if (pairsResult.error) { @@ -82,6 +120,7 @@ export async function POST(request: NextRequest) { const plannedEntry = normalizeOptionalNumber(body.plannedEntry); const plannedStopLoss = normalizeOptionalNumber(body.plannedStopLoss); const plannedTakeProfit = normalizeOptionalNumber(body.plannedTakeProfit); + const plannedExecutionAt = normalizePlannedExecutionAt(body.plannedExecutionAt); if (!pairSymbol) { return NextResponse.json({ error: "pairSymbol is required" }, { status: 400 }); @@ -116,7 +155,7 @@ export async function POST(request: NextRequest) { const { data: pairData, error: pairError } = await supabase .from("forex_pairs") - .select("id, symbol") + .select("id, symbol, base_currency, quote_currency") .eq("symbol", pairSymbol) .eq("is_active", true) .single(); @@ -139,6 +178,33 @@ export async function POST(request: NextRequest) { const aiBrief = await generatePreTradeBrief(input); const riskRewardRatio = calculateRiskReward(plannedEntry, plannedStopLoss, plannedTakeProfit); + const executionAt = plannedExecutionAt || new Date().toISOString(); + const executionDate = new Date(executionAt); + const windowStart = new Date(executionDate.getTime() - 2 * 60 * 60 * 1000).toISOString(); + const windowEnd = new Date(executionDate.getTime() + 6 * 60 * 60 * 1000).toISOString(); + + const eventsResult = await supabase + .from("economic_events") + .select("id, title, currency, country, impact, scheduled_at") + .in("currency", [pairData.base_currency, pairData.quote_currency]) + .gte("scheduled_at", windowStart) + .lte("scheduled_at", windowEnd) + .order("scheduled_at", { ascending: true }); + + const mappedEvents: EconomicEvent[] = eventsResult.error + ? [] + : (eventsResult.data || []).map((event) => ({ + id: String(event.id), + title: String(event.title || "Untitled event"), + currency: String(event.currency || "").toUpperCase(), + country: event.country ? String(event.country) : null, + impact: mapImpact(event.impact), + scheduled_at: String(event.scheduled_at), + })); + + const eventRisk = evaluateEventRisk(mappedEvents, executionAt, pairSymbol); + const approvalState = eventRisk.action === "wait" ? "blocked" : "ready"; + const insertPayload = { user_id: session.user.id, forex_pair_id: pairData.id, @@ -157,6 +223,14 @@ export async function POST(request: NextRequest) { ai_risks: aiBrief.risks, ai_invalidators: aiBrief.invalidationSignals, ai_checklist: aiBrief.checklist, + ai_model: aiBrief.aiModel, + prompt_version: aiBrief.promptVersion, + generation_latency_ms: aiBrief.generationLatencyMs, + event_risk_action: eventRisk.action, + event_risk_summary: eventRisk.summary, + event_risk_window_start: eventRisk.windowStart, + event_risk_window_end: eventRisk.windowEnd, + approval_state: approvalState, raw_ai_response: aiBrief, status: "generated", }; @@ -164,7 +238,7 @@ export async function POST(request: NextRequest) { const { data: created, error: createError } = await supabase .from("pre_trade_briefs") .insert([insertPayload]) - .select("id, pair_symbol_snapshot, timeframe, market_session, directional_bias_input, risk_reward_ratio, ai_summary, ai_bias, ai_confluence, ai_risks, ai_invalidators, ai_checklist, created_at") + .select("id, pair_symbol_snapshot, timeframe, market_session, directional_bias_input, risk_reward_ratio, ai_summary, ai_bias, ai_confluence, ai_risks, ai_invalidators, ai_checklist, ai_model, prompt_version, generation_latency_ms, event_risk_action, event_risk_summary, event_risk_window_start, event_risk_window_end, approval_state, created_at") .single(); if (createError) { diff --git a/app/dashboard/pre-trade-brief/page.tsx b/app/dashboard/pre-trade-brief/page.tsx index a89b2c08..62498902 100644 --- a/app/dashboard/pre-trade-brief/page.tsx +++ b/app/dashboard/pre-trade-brief/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; import LayoutClient from "@/components/LayoutClient"; @@ -12,6 +12,9 @@ import type { PreTradeBriefListItem, PreTradeBriefRecord, } from "@/types/preTradeBrief"; +import type { MarketBiasRecord } from "@/types/marketBias"; +import type { EconomicEvent, EventRiskAction } from "@/types/eventAwareness"; +import type { PreTradeQualityMetrics } from "@/types/preTradeMetrics"; type PairOption = { id: string; @@ -34,13 +37,34 @@ type BriefCreateResult = { ai_risks: string[]; ai_invalidators: string[]; ai_checklist: string[]; + ai_model?: string | null; + prompt_version?: string | null; + generation_latency_ms?: number | null; + event_risk_action?: EventRiskAction | null; + event_risk_summary?: string | null; + event_risk_window_start?: string | null; + event_risk_window_end?: string | null; + approval_state?: "ready" | "blocked" | "manual_override" | null; created_at: string; }; +type EventRiskResult = { + pairSymbol: string; + plannedAt: string; + action: EventRiskAction; + summary: string; + windowStart: string | null; + windowEnd: string | null; + events: EconomicEvent[]; +}; + const TIMEFRAMES = ["M5", "M15", "M30", "H1", "H4", "D1"]; const SESSIONS = ["ASIA", "LONDON", "NEW_YORK", "OVERLAP"]; const BIASES = ["bullish", "bearish", "neutral"]; const EDITABLE_STATUSES: EditablePreTradeBriefStatus[] = ["draft", "ready", "invalidated", "executed", "skipped"]; +const BRIEF_STATUS_FILTERS = ["all", "ready", "invalidated", "executed"] as const; +type BriefStatusFilter = (typeof BRIEF_STATUS_FILTERS)[number]; +const BIAS_TIMEFRAMES = ["M15", "M30", "H1", "H4", "D1"] as const; const formatDate = (iso: string) => { try { @@ -64,6 +88,7 @@ function PreTradeBriefContent() { const [selectedBriefId, setSelectedBriefId] = useState(null); const [selectedBrief, setSelectedBrief] = useState(null); + const detailCacheRef = useRef>({}); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(null); @@ -80,17 +105,54 @@ function PreTradeBriefContent() { const [plannedEntry, setPlannedEntry] = useState(""); const [plannedStopLoss, setPlannedStopLoss] = useState(""); const [plannedTakeProfit, setPlannedTakeProfit] = useState(""); + const [plannedExecutionAt, setPlannedExecutionAt] = useState(""); + const [briefStatusFilter, setBriefStatusFilter] = useState("all"); + + const [biasTimeframes, setBiasTimeframes] = useState(["H4", "H1", "M15"]); + const [biasSessionContext, setBiasSessionContext] = useState(""); + const [biasBackdrop, setBiasBackdrop] = useState(""); + const [generatingBias, setGeneratingBias] = useState(false); + const [biasError, setBiasError] = useState(null); + const [latestBias, setLatestBias] = useState(null); + + const [eventRisk, setEventRisk] = useState(null); + const [eventRiskLoading, setEventRiskLoading] = useState(false); + const [eventRiskError, setEventRiskError] = useState(null); + const [metrics, setMetrics] = useState(null); + const [metricsLoading, setMetricsLoading] = useState(false); + const [metricsError, setMetricsError] = useState(null); const canSubmit = useMemo(() => { return Boolean(pairSymbol && timeframe && marketSession && directionalBiasInput); }, [pairSymbol, timeframe, marketSession, directionalBiasInput]); + const isEventAcknowledged = Boolean(checklistStateDraft["Event risk acknowledged"]?.completed); + + const loadMetrics = async () => { + setMetricsLoading(true); + setMetricsError(null); + try { + const response = await fetch("/api/pre-trade-brief/metrics?days=30"); + if (!response.ok) { + const payload = await response.json(); + throw new Error(payload.error || "Failed to load quality metrics"); + } + const payload = (await response.json()) as PreTradeQualityMetrics; + setMetrics(payload); + } catch (err) { + setMetricsError(err instanceof Error ? err.message : "Failed to load quality metrics"); + setMetrics(null); + } finally { + setMetricsLoading(false); + } + }; + const loadData = async () => { setLoading(true); setGlobalError(null); try { - const response = await fetch("/api/pre-trade-brief", { method: "GET" }); + const response = await fetch(`/api/pre-trade-brief?status=${briefStatusFilter}`, { method: "GET" }); if (!response.ok) { const payload = await response.json(); throw new Error(payload.error || "Failed to load pre-trade brief data"); @@ -107,7 +169,12 @@ function PreTradeBriefContent() { setPairSymbol(nextPairs[0].symbol); } - if (!selectedBriefId && nextBriefs.length) { + if (selectedBriefId && !nextBriefs.some((brief) => brief.id === selectedBriefId)) { + setSelectedBriefId(nextBriefs[0]?.id ?? null); + if (!nextBriefs.length) { + setSelectedBrief(null); + } + } else if (!selectedBriefId && nextBriefs.length) { setSelectedBriefId(nextBriefs[0].id); } } catch (err) { @@ -118,6 +185,17 @@ function PreTradeBriefContent() { }; const loadBriefDetail = async (briefId: string) => { + const cachedDetail = detailCacheRef.current[briefId]; + if (cachedDetail) { + setSelectedBrief(cachedDetail); + setTraderNotesDraft(cachedDetail.trader_notes || ""); + setStatusDraft( + cachedDetail.status === "generated" || cachedDetail.status === "failed" ? "draft" : cachedDetail.status + ); + setChecklistStateDraft(cachedDetail.checklist_state || {}); + return; + } + setDetailLoading(true); setDetailError(null); @@ -129,6 +207,7 @@ function PreTradeBriefContent() { } const detail: PreTradeBriefRecord = await response.json(); + detailCacheRef.current[briefId] = detail; setSelectedBrief(detail); setTraderNotesDraft(detail.trader_notes || ""); setStatusDraft( @@ -146,8 +225,9 @@ function PreTradeBriefContent() { useEffect(() => { if (status === "authenticated") { void loadData(); + void loadMetrics(); } - }, [status]); + }, [status, briefStatusFilter]); useEffect(() => { if (status === "authenticated" && selectedBriefId) { @@ -155,6 +235,18 @@ function PreTradeBriefContent() { } }, [status, selectedBriefId]); + useEffect(() => { + if (status === "authenticated" && pairSymbol) { + void loadLatestBias(); + } + }, [status, pairSymbol]); + + useEffect(() => { + if (status === "authenticated" && pairSymbol) { + void loadEventRisk(); + } + }, [status, pairSymbol, plannedExecutionAt]); + if (status === "loading" || loading) { return (
@@ -183,6 +275,7 @@ function PreTradeBriefContent() { plannedEntry, plannedStopLoss, plannedTakeProfit, + plannedExecutionAt, }; const response = await fetch("/api/pre-trade-brief", { @@ -198,7 +291,19 @@ function PreTradeBriefContent() { const payload: BriefCreateResult = await response.json(); setResult(payload); + if (payload.event_risk_action) { + setEventRisk((prev) => ({ + pairSymbol, + plannedAt: plannedExecutionAt || new Date().toISOString(), + action: payload.event_risk_action as EventRiskAction, + summary: payload.event_risk_summary || prev?.summary || "", + windowStart: payload.event_risk_window_start || null, + windowEnd: payload.event_risk_window_end || null, + events: prev?.events || [], + })); + } await loadData(); + await loadMetrics(); setSelectedBriefId(payload.id); } catch (err) { setGlobalError(err instanceof Error ? err.message : "Failed to generate pre-trade brief"); @@ -220,6 +325,11 @@ function PreTradeBriefContent() { const handleSaveDetail = async () => { if (!selectedBriefId) return; + if (statusDraft === "ready" && eventRisk?.action === "wait" && !isEventAcknowledged) { + setDetailError("Event risk is marked as WAIT. Acknowledge event risk in checklist before setting status to ready."); + return; + } + setSavingDetail(true); setDetailError(null); @@ -241,18 +351,22 @@ function PreTradeBriefContent() { } const updated: PreTradeBriefRecord = await response.json(); + detailCacheRef.current[selectedBriefId] = updated; setSelectedBrief((prev) => ({ ...(prev || {}), ...updated } as PreTradeBriefRecord)); setRecentBriefs((prev) => - prev.map((brief) => - brief.id === updated.id - ? { - ...brief, - status: updated.status, - } - : brief - ) + prev + .map((brief) => + brief.id === updated.id + ? { + ...brief, + status: updated.status, + } + : brief + ) + .filter((brief) => briefStatusFilter === "all" || brief.status === briefStatusFilter) ); + await loadMetrics(); } catch (err) { setDetailError(err instanceof Error ? err.message : "Failed to save brief detail"); } finally { @@ -260,6 +374,86 @@ function PreTradeBriefContent() { } }; + const loadEventRisk = async () => { + setEventRiskError(null); + setEventRiskLoading(true); + try { + const params = new URLSearchParams({ pairSymbol }); + if (plannedExecutionAt) { + params.set("plannedAt", new Date(plannedExecutionAt).toISOString()); + } + const response = await fetch(`/api/event-awareness?${params.toString()}`); + if (!response.ok) { + const payload = await response.json(); + throw new Error(payload.error || "Failed to load event risk"); + } + const payload = (await response.json()) as EventRiskResult; + setEventRisk(payload); + } catch (err) { + setEventRiskError(err instanceof Error ? err.message : "Failed to load event risk"); + setEventRisk(null); + } finally { + setEventRiskLoading(false); + } + }; + + const toggleBiasTimeframe = (tf: string) => { + setBiasTimeframes((prev) => { + if (prev.includes(tf)) { + const next = prev.filter((item) => item !== tf); + return next.length ? next : prev; + } + return [...prev, tf]; + }); + }; + + const loadLatestBias = async () => { + setBiasError(null); + try { + const response = await fetch(`/api/market-bias?pairSymbol=${pairSymbol}`); + if (!response.ok) { + const payload = await response.json(); + throw new Error(payload.error || "Failed to load market bias"); + } + const payload = await response.json(); + const reports = (payload.reports || []) as MarketBiasRecord[]; + setLatestBias(reports[0] || null); + } catch (err) { + setBiasError(err instanceof Error ? err.message : "Failed to load market bias"); + setLatestBias(null); + } + }; + + const handleGenerateBias = async () => { + setBiasError(null); + setGeneratingBias(true); + + try { + const response = await fetch("/api/market-bias", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pairSymbol, + timeframeSet: biasTimeframes, + sessionContext: biasSessionContext, + recentBackdrop: biasBackdrop, + }), + }); + + if (!response.ok) { + const payload = await response.json(); + throw new Error(payload.error || "Failed to generate market bias"); + } + + const created = (await response.json()) as MarketBiasRecord; + setLatestBias(created); + } catch (err) { + setBiasError(err instanceof Error ? err.message : "Failed to generate market bias"); + } finally { + setGeneratingBias(false); + } + }; + const renderList = (items: string[]) => (
    {items.map((item, idx) => ( @@ -433,6 +627,228 @@ function PreTradeBriefContent() { Pair: {result.pair_symbol_snapshot} | Timeframe: {result.timeframe} | Session: {result.market_session} {result.risk_reward_ratio != null ? ` | R:R ${result.risk_reward_ratio}` : ""}
+
+ Event action: {result.event_risk_action || "N/A"} | Model: {result.ai_model || "N/A"} | Prompt: {result.prompt_version || "N/A"} + {result.generation_latency_ms != null ? ` | ${result.generation_latency_ms}ms` : ""} +
+
+ Approval state: {result.approval_state || "N/A"} +
+ + )} + + + +
+
+
+

Quality Metrics (30 Days)

+

+ Adoption, checklist discipline, and execution drift for recent pre-trade workflow activity. +

+
+ + {metricsLoading ? ( +

Loading quality metrics...

+ ) : metricsError ? ( +

{metricsError}

+ ) : !metrics ? ( +

No metrics available yet.

+ ) : ( +
+
+

Adoption Rate

+

{metrics.adoption_rate}%

+

+ {metrics.reviewed_briefs}/{metrics.generated_briefs} briefs reviewed +

+
+
+

Checklist Completion

+

+ {metrics.checklist_completion_rate}% +

+

+ coverage: {metrics.checklist_coverage} briefs +

+
+
+

Execution Drift

+

+ {metrics.execution_drift_rate}% +

+

+ measured on {metrics.executed_briefs} executed briefs +

+
+
+

Active Days

+

{metrics.active_days}

+

+ in last {metrics.period_days} days +

+
+
+ )} +
+
+ +
+
+

Market Bias Snapshot

+

+ Generate directional context before execution. This is decision-support only. +

+ +
+
+

Timeframes

+
+ {BIAS_TIMEFRAMES.map((tf) => { + const active = biasTimeframes.includes(tf); + return ( + + ); + })} +
+
+ +
+ + setPlannedExecutionAt(e.target.value)} + className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-[#0D1117] px-3 py-2 text-sm" + /> +
+ +
+ + setBiasSessionContext(e.target.value)} + placeholder="Example: London open, moderate volatility" + className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-[#0D1117] px-3 py-2 text-sm" + /> +
+ +
+ +