diff --git a/backend/main.py b/backend/main.py index 5629268..2c8e654 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router, options_vol as options_vol_router, analytics as analytics_router, risk as risk_router from routers import pattern_lab as pattern_lab_router +from routers import specialist_desks as specialist_desks_router from routers import logs as logs_router from routers import var as var_router from routers import reports as reports_router @@ -119,6 +120,7 @@ app.include_router(var_router.router) app.include_router(reports_router.router) app.include_router(institutional_router.router) app.include_router(pattern_lab_router.router) +app.include_router(specialist_desks_router.router) @app.get("/") diff --git a/backend/routers/specialist_desks.py b/backend/routers/specialist_desks.py new file mode 100644 index 0000000..3f30600 --- /dev/null +++ b/backend/routers/specialist_desks.py @@ -0,0 +1,144 @@ +""" +Specialist Desks — per asset-class fundamental configs + report catalogue. +Prefix: /api/specialist-desks +""" +import uuid +from typing import Optional, List +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +router = APIRouter(prefix="/api/specialist-desks", tags=["specialist-desks"]) + + +# ── Pydantic models ──────────────────────────────────────────────────────────── + +class MacroSensitivity(BaseModel): + regime: str + effect: str + + +class PriceDeltaThresholds(BaseModel): + significant_day: float = 1.0 + extreme_day: float = 3.0 + significant_week: float = 2.0 + extreme_week: float = 5.0 + + +class DeskConfigUpdate(BaseModel): + display_name: str + icon: str = "" + fundamentals: str = "" + macro_sensitivity: List[MacroSensitivity] = [] + price_delta_thresholds: PriceDeltaThresholds = PriceDeltaThresholds() + notes: str = "" + + +class ReportUpsert(BaseModel): + name: str + source: str = "" + cadence: str = "monthly" + next_date: Optional[str] = None + last_date: Optional[str] = None + importance: int = 2 + notes: str = "" + url: str = "" + desks: List[str] = [] + + +# ── Configs ──────────────────────────────────────────────────────────────────── + +@router.get("/configs") +def list_configs(): + from services.database import get_all_asset_class_configs + return get_all_asset_class_configs() + + +@router.get("/configs/{asset_class}") +def get_config(asset_class: str): + from services.database import get_asset_class_config, get_desk_reports + cfg = get_asset_class_config(asset_class) + if not cfg: + raise HTTPException(404, f"Desk '{asset_class}' not found") + cfg["reports"] = get_desk_reports(asset_class) + return cfg + + +@router.put("/configs/{asset_class}") +def update_config(asset_class: str, body: DeskConfigUpdate): + from services.database import upsert_asset_class_config + upsert_asset_class_config( + asset_class=asset_class, + display_name=body.display_name, + icon=body.icon, + fundamentals=body.fundamentals, + macro_sensitivity=[s.dict() for s in body.macro_sensitivity], + price_delta_thresholds=body.price_delta_thresholds.dict(), + notes=body.notes, + ) + return {"saved": asset_class} + + +# ── Reports ──────────────────────────────────────────────────────────────────── + +@router.get("/reports") +def list_reports(): + from services.database import get_all_specialist_reports + return get_all_specialist_reports() + + +@router.post("/reports") +def create_report(body: ReportUpsert): + from services.database import upsert_specialist_report, link_report_desk + report_id = "RPT_" + uuid.uuid4().hex[:8].upper() + upsert_specialist_report( + report_id=report_id, + name=body.name, source=body.source, cadence=body.cadence, + next_date=body.next_date, last_date=body.last_date, + importance=body.importance, notes=body.notes, url=body.url, + ) + for ac in body.desks: + link_report_desk(report_id, ac) + return {"id": report_id} + + +@router.put("/reports/{report_id}") +def update_report(report_id: str, body: ReportUpsert): + from services.database import upsert_specialist_report, link_report_desk, unlink_report_desk, get_all_specialist_reports + # Check exists + all_reports = get_all_specialist_reports() + if not any(r["id"] == report_id for r in all_reports): + raise HTTPException(404, "Report not found") + upsert_specialist_report( + report_id=report_id, + name=body.name, source=body.source, cadence=body.cadence, + next_date=body.next_date, last_date=body.last_date, + importance=body.importance, notes=body.notes, url=body.url, + ) + # Sync desk links: remove all, re-add + existing = next(r for r in all_reports if r["id"] == report_id) + for old_ac in existing.get("desks", []): + unlink_report_desk(report_id, old_ac) + for new_ac in body.desks: + link_report_desk(report_id, new_ac) + return {"saved": report_id} + + +@router.delete("/reports/{report_id}") +def delete_report(report_id: str): + from services.database import delete_specialist_report + delete_specialist_report(report_id) + return {"deleted": report_id} + + +@router.post("/reports/{report_id}/link/{asset_class}") +def link_to_desk(report_id: str, asset_class: str): + from services.database import link_report_desk + link_report_desk(report_id, asset_class) + return {"linked": report_id, "desk": asset_class} + + +@router.delete("/reports/{report_id}/link/{asset_class}") +def unlink_from_desk(report_id: str, asset_class: str): + from services.database import unlink_report_desk + unlink_report_desk(report_id, asset_class) + return {"unlinked": report_id, "desk": asset_class} diff --git a/backend/services/ai_analyzer.py b/backend/services/ai_analyzer.py index a94e423..2702342 100644 --- a/backend/services/ai_analyzer.py +++ b/backend/services/ai_analyzer.py @@ -376,6 +376,54 @@ SCORE DISTRIBUTION RULE (MANDATORY): - A pattern with 3+ relevant news AND favorable macro can reach 70-85/100""" +def _build_specialist_context_block(asset_classes: set) -> str: + """Build a SPECIALIST DESK CONTEXT block for the given asset classes.""" + try: + from services.database import get_asset_class_config, get_desk_reports + from datetime import datetime as _dt + blocks = [] + for ac in sorted(asset_classes): + cfg = get_asset_class_config(ac) + if not cfg: + continue + reports = get_desk_reports(ac) + lines = [f"\n## SPECIALIST DESK — {cfg['display_name'].upper()} {cfg.get('icon','')}"] + if cfg.get("fundamentals"): + lines.append(f"Key drivers: {cfg['fundamentals'][:300]}") + # Macro sensitivity + sensitivities = cfg.get("macro_sensitivity") or [] + if sensitivities: + sens_str = " | ".join( + f"{s.get('regime','?')} → {s.get('effect','?')}" + for s in sensitivities[:4] + ) + lines.append(f"Regime sensitivity: {sens_str}") + # Price thresholds + thresh = cfg.get("price_delta_thresholds") or {} + if thresh: + lines.append( + f"Price thresholds: significant_week={thresh.get('significant_week','?')}% " + f"extreme_week={thresh.get('extreme_week','?')}%" + ) + # Upcoming reports + today = _dt.utcnow().strftime("%Y-%m-%d") + upcoming = sorted( + [r for r in reports if r.get("next_date") and r["next_date"] >= today], + key=lambda r: r["next_date"] + )[:5] + if upcoming: + lines.append("Upcoming reports:") + for r in upcoming: + lines.append( + f" • {r['name']} ({r['source']}) — {r['next_date']} [{r['cadence']}]" + + (f" ⭐×{r['importance']}" if r.get("importance", 1) >= 3 else "") + ) + blocks.append("\n".join(lines)) + return "\n".join(blocks) + "\n" if blocks else "" + except Exception: + return "" + + def score_patterns_with_context( patterns: List[Dict], recent_news: List[Dict], @@ -783,12 +831,18 @@ Lessons: {' | '.join(str(l)[:80] for l in lessons[:3])} """ + # Build specialist-desk blocks for asset classes present in this pattern set + _all_pattern_asset_classes = { + p.get("asset_class", "") for p in patterns if p.get("asset_class") + } + specialist_block = _build_specialist_context_block(_all_pattern_asset_classes) + # Build the per-batch prompt template (static parts) prompt_header = f"""GLOBAL CONTEXT: - Geopolitical risk score: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')}) - Top risks: {geo_score.get('top_risks', [])} {macro_section}{lessons_header}{iv_context} -{risk_context} +{risk_context}{specialist_block} SCORING TEMPLATE: {scoring_template} diff --git a/backend/services/database.py b/backend/services/database.py index 2df1cd4..b19dbe3 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -641,6 +641,101 @@ def init_db(): except Exception: pass + # ── Specialist Desks ─────────────────────────────────────────────────────── + c.execute("""CREATE TABLE IF NOT EXISTS specialist_reports ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + cadence TEXT NOT NULL DEFAULT 'monthly', + next_date TEXT, + last_date TEXT, + importance INTEGER NOT NULL DEFAULT 2, + notes TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )""") + + c.execute("""CREATE TABLE IF NOT EXISTS asset_class_configs ( + asset_class TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '', + fundamentals TEXT NOT NULL DEFAULT '', + macro_sensitivity TEXT NOT NULL DEFAULT '[]', + price_delta_thresholds TEXT NOT NULL DEFAULT '{}', + notes TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )""") + + c.execute("""CREATE TABLE IF NOT EXISTS report_desk_links ( + report_id TEXT NOT NULL, + asset_class TEXT NOT NULL, + PRIMARY KEY (report_id, asset_class) + )""") + + # Seed desk defaults (idempotent) + _desk_count = c.execute("SELECT COUNT(*) FROM asset_class_configs").fetchone()[0] + if _desk_count == 0: + _desk_defaults = [ + ("forex", "Forex", "💱", + "Rate differentials (Fed vs ECB/BOJ/BOE), current account balances, political risk premium, USD index trend, carry trade positioning, intervention risk.", + '[{"regime":"dollar_strength","effect":"bearish EUR/JPY, bullish DXY pairs"},{"regime":"risk_off","effect":"bullish JPY/CHF, bearish EM FX"},{"regime":"dovish_fed","effect":"bearish USD, bullish EM FX"},{"regime":"rate_divergence_us_up","effect":"bullish USD across board"}]', + '{"significant_day":0.5,"extreme_day":1.5,"significant_week":1.0,"extreme_week":2.5}'), + ("metals", "Metals", "🥇", + "Real interest rates (TIPS), DXY trend, China PMI/demand, ETF flows (GLD/SLV), COT net positioning, inflation breakevens, central bank gold buying, industrial demand (copper/silver).", + '[{"regime":"risk_off","effect":"bullish gold, silver underperforms"},{"regime":"real_rates_rising","effect":"bearish gold/silver"},{"regime":"dollar_weakness","effect":"bullish all metals"},{"regime":"china_stimulus","effect":"bullish copper/industrial metals"}]', + '{"significant_day":1.0,"extreme_day":3.0,"significant_week":2.0,"extreme_week":5.0}'), + ("agri", "Agri & Softs", "🌾", + "WASDE supply/demand balance, weather events (La Niña/El Niño), crop calendar (planting/harvest), freight rates, USD/BRL (soy), USD/ARS, biofuel mandates (corn/soy), export inspections.", + '[{"regime":"la_nina","effect":"bullish corn/wheat (US drought), bearish soy (S. America)"},{"regime":"dollar_strength","effect":"bearish USD-denominated agri exports"},{"regime":"energy_shock","effect":"bullish corn (ethanol), bullish soy (biodiesel)"},{"regime":"china_demand_drop","effect":"bearish soy/corn"}]', + '{"significant_day":1.5,"extreme_day":4.0,"significant_week":3.0,"extreme_week":8.0}'), + ("energy", "Energy", "⚡", + "OPEC+ production decisions, EIA weekly inventory build/draw, US rig count/shale production, geopolitical premium (Middle East/Russia), demand/growth cycle, refinery margins, seasonal demand (summer driving/winter heating).", + '[{"regime":"geopolitical_escalation","effect":"bullish crude, nat gas spike risk"},{"regime":"recession_fear","effect":"bearish crude (demand destruction)"},{"regime":"opec_cut","effect":"bullish crude"},{"regime":"dollar_strength","effect":"mildly bearish crude"}]', + '{"significant_day":1.5,"extreme_day":4.0,"significant_week":3.0,"extreme_week":8.0}'), + ("indices", "Equity Indices", "📈", + "Earnings growth trajectory, Fed policy path (PE multiples), credit spreads (HYG), buyback activity, market breadth/concentration, Buffett indicator, VIX regime, sector rotation (growth vs value).", + '[{"regime":"fed_pivot_dovish","effect":"bullish equities, PE expansion"},{"regime":"credit_stress","effect":"bearish equities, flight to safety"},{"regime":"earnings_miss_wave","effect":"bearish indices, vol spike"},{"regime":"risk_on","effect":"bullish equities, small caps outperform"}]', + '{"significant_day":1.0,"extreme_day":3.0,"significant_week":2.0,"extreme_week":5.0}'), + ("crypto", "Crypto", "₿", + "BTC halving cycle position, on-chain net flows (exchange inflows/outflows), stablecoin supply growth, regulatory risk (SEC/CFTC), BTC dominance, macro correlation (SPY), ETF flows.", + '[{"regime":"risk_off","effect":"bearish BTC/ETH, correlation with equities rises"},{"regime":"dollar_weakness_qe","effect":"bullish BTC as inflation hedge"},{"regime":"regulatory_crackdown","effect":"bearish all crypto"},{"regime":"btc_halving_post","effect":"bullish BTC 6-12m horizon"}]', + '{"significant_day":3.0,"extreme_day":10.0,"significant_week":8.0,"extreme_week":20.0}'), + ("bonds", "Bonds & Rates", "📉", + "Fed funds path (dots plot), real rates (TIPS), term premium, fiscal deficit trajectory, inflation breakevens, foreign central bank demand (Japan/China UST), credit spreads, duration risk.", + '[{"regime":"hawkish_fed","effect":"bearish long duration (TLT), bearish EM bonds"},{"regime":"recession_confirmed","effect":"bullish long bonds (flight to quality)"},{"regime":"fiscal_expansion","effect":"bearish bonds (supply pressure)"},{"regime":"disinflation","effect":"bullish bonds, real rate normalization"}]', + '{"significant_day":0.5,"extreme_day":1.5,"significant_week":1.0,"extreme_week":3.0}'), + ] + for _ac, _dn, _ic, _fund, _macro, _thresh in _desk_defaults: + c.execute("""INSERT OR IGNORE INTO asset_class_configs + (asset_class, display_name, icon, fundamentals, macro_sensitivity, price_delta_thresholds) + VALUES (?,?,?,?,?,?)""", (_ac, _dn, _ic, _fund, _macro, _thresh)) + + # Seed default reports (idempotent) + _rpt_count = c.execute("SELECT COUNT(*) FROM specialist_reports").fetchone()[0] + if _rpt_count == 0: + import uuid as _uuid + _rpt_defaults = [ + ("RPT_COT", "COT — Commitment of Traders", "CFTC", "weekly", 3, ["metals","forex","agri","energy"]), + ("RPT_EIA", "EIA Petroleum Status Report", "EIA", "weekly", 3, ["energy"]), + ("RPT_WASDE", "WASDE — World Ag Supply & Demand", "USDA", "monthly", 3, ["agri"]), + ("RPT_FOMC", "FOMC Statement & Projections", "Federal Reserve","6-weekly", 3, ["forex","indices","bonds"]), + ("RPT_ECB", "ECB Monetary Policy Decision", "ECB", "6-weekly", 3, ["forex","bonds"]), + ("RPT_CPI", "US CPI (Consumer Price Index)", "BLS", "monthly", 3, ["forex","indices","bonds","metals"]), + ("RPT_NFP", "US Non-Farm Payrolls", "BLS", "monthly", 3, ["forex","indices","bonds"]), + ("RPT_CN_PMI", "China PMI (NBS Manufacturing)", "NBS China", "monthly", 2, ["metals","agri","energy","indices"]), + ("RPT_BAKER", "Baker Hughes Rig Count", "Baker Hughes", "weekly", 2, ["energy"]), + ("RPT_EXPORT", "USDA Weekly Export Inspections", "USDA", "weekly", 2, ["agri"]), + ("RPT_BOJ", "BOJ Monetary Policy Decision", "Bank of Japan", "irregular", 2, ["forex","bonds"]), + ("RPT_VIX", "VIX Futures Monthly Settlement", "CBOE", "monthly", 2, ["indices"]), + ] + for _rid, _rname, _rsrc, _rcad, _rimp, _racs in _rpt_defaults: + c.execute("""INSERT OR IGNORE INTO specialist_reports (id, name, source, cadence, importance) + VALUES (?,?,?,?,?)""", (_rid, _rname, _rsrc, _rcad, _rimp)) + for _rac in _racs: + c.execute("INSERT OR IGNORE INTO report_desk_links (report_id, asset_class) VALUES (?,?)", + (_rid, _rac)) + try: c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)") c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)") @@ -3918,3 +4013,170 @@ def get_economic_events_for_calendar(days_back: int = 30, days_forward: int = 14 return result finally: conn.close() + + +# ── Specialist Desks ────────────────────────────────────────────────────────── + +def get_all_specialist_reports() -> List[Dict[str, Any]]: + conn = get_conn() + try: + rows = conn.execute( + "SELECT * FROM specialist_reports ORDER BY importance DESC, name ASC" + ).fetchall() + result = [] + for r in rows: + d = dict(r) + d["desks"] = [ + lnk["asset_class"] for lnk in + conn.execute( + "SELECT asset_class FROM report_desk_links WHERE report_id=?", (d["id"],) + ).fetchall() + ] + result.append(d) + return result + finally: + conn.close() + + +def upsert_specialist_report( + report_id: str, name: str, source: str, cadence: str, + next_date: Optional[str], last_date: Optional[str], + importance: int, notes: str, url: str, +) -> str: + conn = get_conn() + try: + conn.execute("""INSERT INTO specialist_reports + (id, name, source, cadence, next_date, last_date, importance, notes, url, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,datetime('now')) + ON CONFLICT(id) DO UPDATE SET + name=excluded.name, source=excluded.source, cadence=excluded.cadence, + next_date=excluded.next_date, last_date=excluded.last_date, + importance=excluded.importance, notes=excluded.notes, url=excluded.url, + updated_at=datetime('now')""", + (report_id, name, source, cadence, next_date or None, last_date or None, + importance, notes, url)) + conn.commit() + return report_id + finally: + conn.close() + + +def delete_specialist_report(report_id: str) -> bool: + conn = get_conn() + try: + conn.execute("DELETE FROM report_desk_links WHERE report_id=?", (report_id,)) + conn.execute("DELETE FROM specialist_reports WHERE id=?", (report_id,)) + conn.commit() + return True + finally: + conn.close() + + +def link_report_desk(report_id: str, asset_class: str) -> bool: + conn = get_conn() + try: + conn.execute( + "INSERT OR IGNORE INTO report_desk_links (report_id, asset_class) VALUES (?,?)", + (report_id, asset_class) + ) + conn.commit() + return True + finally: + conn.close() + + +def unlink_report_desk(report_id: str, asset_class: str) -> bool: + conn = get_conn() + try: + conn.execute( + "DELETE FROM report_desk_links WHERE report_id=? AND asset_class=?", + (report_id, asset_class) + ) + conn.commit() + return True + finally: + conn.close() + + +def get_desk_reports(asset_class: str) -> List[Dict[str, Any]]: + conn = get_conn() + try: + rows = conn.execute(""" + SELECT sr.* FROM specialist_reports sr + JOIN report_desk_links rdl ON rdl.report_id = sr.id + WHERE rdl.asset_class = ? + ORDER BY sr.importance DESC, sr.name ASC""", (asset_class,)).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def get_all_asset_class_configs() -> List[Dict[str, Any]]: + conn = get_conn() + try: + rows = conn.execute( + "SELECT * FROM asset_class_configs ORDER BY asset_class ASC" + ).fetchall() + result = [] + for r in rows: + d = dict(r) + try: + d["macro_sensitivity"] = json.loads(d.get("macro_sensitivity") or "[]") + except Exception: + d["macro_sensitivity"] = [] + try: + d["price_delta_thresholds"] = json.loads(d.get("price_delta_thresholds") or "{}") + except Exception: + d["price_delta_thresholds"] = {} + d["reports"] = get_desk_reports(d["asset_class"]) + result.append(d) + return result + finally: + conn.close() + + +def get_asset_class_config(asset_class: str) -> Optional[Dict[str, Any]]: + conn = get_conn() + try: + row = conn.execute( + "SELECT * FROM asset_class_configs WHERE asset_class=?", (asset_class,) + ).fetchone() + if not row: + return None + d = dict(row) + try: + d["macro_sensitivity"] = json.loads(d.get("macro_sensitivity") or "[]") + except Exception: + d["macro_sensitivity"] = [] + try: + d["price_delta_thresholds"] = json.loads(d.get("price_delta_thresholds") or "{}") + except Exception: + d["price_delta_thresholds"] = {} + return d + finally: + conn.close() + + +def upsert_asset_class_config( + asset_class: str, display_name: str, icon: str, + fundamentals: str, macro_sensitivity: list, + price_delta_thresholds: dict, notes: str, +) -> bool: + conn = get_conn() + try: + conn.execute("""INSERT INTO asset_class_configs + (asset_class, display_name, icon, fundamentals, macro_sensitivity, + price_delta_thresholds, notes, updated_at) + VALUES (?,?,?,?,?,?,?,datetime('now')) + ON CONFLICT(asset_class) DO UPDATE SET + display_name=excluded.display_name, icon=excluded.icon, + fundamentals=excluded.fundamentals, + macro_sensitivity=excluded.macro_sensitivity, + price_delta_thresholds=excluded.price_delta_thresholds, + notes=excluded.notes, updated_at=datetime('now')""", + (asset_class, display_name, icon, fundamentals, + json.dumps(macro_sensitivity), json.dumps(price_delta_thresholds), notes)) + conn.commit() + return True + finally: + conn.close() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e84d24..ae4ec76 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import SystemLogs from './pages/SystemLogs' import VaRAnalysis from './pages/VaRAnalysis' import PositionHistory from './pages/PositionHistory' import InstitutionalReports from './pages/InstitutionalReports' +import SpecialistDesks from './pages/SpecialistDesks' import { useCycleWatcher } from './hooks/useApi' function GlobalWatcher() { @@ -59,6 +60,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 68acd3b..365db71 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom' import { LayoutDashboard, Globe, BarChart2, FlaskConical, - History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2 + History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users } from 'lucide-react' import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' import clsx from 'clsx' @@ -26,6 +26,7 @@ const nav = [ { to: '/backtest', icon: History, label: 'Backtest' }, { to: '/calendar', icon: Calendar, label: 'Calendar' }, { to: '/institutional', icon: Building2, label: 'Inst. Reports' }, + { to: '/specialist-desks', icon: Users, label: 'Specialist Desks' }, { to: '/logs', icon: ScrollText, label: 'System Logs' }, { to: '/config', icon: Settings, label: 'Configuration' }, ] diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 7a0744e..384c1b5 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1101,3 +1101,106 @@ export const useEvaluateInstrumentScan = () => { onSuccess: () => qc.invalidateQueries({ queryKey: ['pattern-lab-runs'] }), }) } + +// ── Specialist Desks ─────────────────────────────────────────────────────────── + +export type MacroSensitivity = { regime: string; effect: string } +export type PriceDeltaThresholds = { + significant_day: number; extreme_day: number + significant_week: number; extreme_week: number +} +export type SpecialistReport = { + id: string; name: string; source: string; cadence: string + next_date: string | null; last_date: string | null + importance: number; notes: string; url: string + desks?: string[] + created_at: string; updated_at: string +} +export type DeskConfig = { + asset_class: string; display_name: string; icon: string + fundamentals: string; macro_sensitivity: MacroSensitivity[] + price_delta_thresholds: PriceDeltaThresholds; notes: string + reports: SpecialistReport[]; updated_at: string +} + +export const useAllDeskConfigs = () => + useQuery({ + queryKey: ['desk-configs'], + queryFn: () => api.get('/specialist-desks/configs').then(r => r.data), + staleTime: 60_000, + }) + +export const useUpdateDeskConfig = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ asset_class, body }: { asset_class: string; body: Partial }) => + api.put(`/specialist-desks/configs/${asset_class}`, body).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['desk-configs'] }), + }) +} + +export const useAllSpecialistReports = () => + useQuery({ + queryKey: ['specialist-reports'], + queryFn: () => api.get('/specialist-desks/reports').then(r => r.data), + staleTime: 60_000, + }) + +export const useCreateSpecialistReport = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: Partial & { desks?: string[] }) => + api.post('/specialist-desks/reports', body).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['specialist-reports'] }) + qc.invalidateQueries({ queryKey: ['desk-configs'] }) + }, + }) +} + +export const useUpdateSpecialistReport = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: Partial & { desks?: string[] } }) => + api.put(`/specialist-desks/reports/${id}`, body).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['specialist-reports'] }) + qc.invalidateQueries({ queryKey: ['desk-configs'] }) + }, + }) +} + +export const useDeleteSpecialistReport = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/specialist-desks/reports/${id}`).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['specialist-reports'] }) + qc.invalidateQueries({ queryKey: ['desk-configs'] }) + }, + }) +} + +export const useLinkReportDesk = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ report_id, asset_class }: { report_id: string; asset_class: string }) => + api.post(`/specialist-desks/reports/${report_id}/link/${asset_class}`).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['specialist-reports'] }) + qc.invalidateQueries({ queryKey: ['desk-configs'] }) + }, + }) +} + +export const useUnlinkReportDesk = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ report_id, asset_class }: { report_id: string; asset_class: string }) => + api.delete(`/specialist-desks/reports/${report_id}/link/${asset_class}`).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['specialist-reports'] }) + qc.invalidateQueries({ queryKey: ['desk-configs'] }) + }, + }) +} diff --git a/frontend/src/pages/SpecialistDesks.tsx b/frontend/src/pages/SpecialistDesks.tsx new file mode 100644 index 0000000..15dc30d --- /dev/null +++ b/frontend/src/pages/SpecialistDesks.tsx @@ -0,0 +1,682 @@ +import { useState } from 'react' +import { + useAllDeskConfigs, useAllSpecialistReports, + useUpdateDeskConfig, useCreateSpecialistReport, + useUpdateSpecialistReport, useDeleteSpecialistReport, + useLinkReportDesk, useUnlinkReportDesk, + DeskConfig, SpecialistReport, MacroSensitivity, +} from '../hooks/useApi' +import { + Users, FileText, Plus, Trash2, Save, X, Edit2, + ChevronRight, Star, Link, ExternalLink, RefreshCw, + AlertCircle, Calendar, BookOpen, +} from 'lucide-react' +import clsx from 'clsx' + +const CADENCE_OPTIONS = ['daily', 'weekly', 'bi-weekly', 'monthly', '6-weekly', 'quarterly', 'annual', 'irregular'] +const DESK_ORDER = ['forex', 'metals', 'agri', 'energy', 'indices', 'crypto', 'bonds'] + +// ── Importance stars ─────────────────────────────────────────────────────────── + +function Stars({ n, onChange }: { n: number; onChange?: (v: number) => void }) { + return ( + + {[1, 2, 3].map(i => ( + onChange?.(i)} + className={clsx('w-3.5 h-3.5 transition-colors', + i <= n ? 'text-amber-400 fill-amber-400' : 'text-slate-700', + onChange && 'cursor-pointer hover:text-amber-300')} + /> + ))} + + ) +} + +// ── Report modal ─────────────────────────────────────────────────────────────── + +function ReportModal({ + initial, allDesks, onSave, onClose, +}: { + initial?: SpecialistReport & { desks?: string[] } + allDesks: DeskConfig[] + onSave: (data: any) => void + onClose: () => void +}) { + const [name, setName] = useState(initial?.name ?? '') + const [source, setSource] = useState(initial?.source ?? '') + const [cadence, setCadence] = useState(initial?.cadence ?? 'monthly') + const [nextDate, setNextDate] = useState(initial?.next_date ?? '') + const [lastDate, setLastDate] = useState(initial?.last_date ?? '') + const [importance, setImp] = useState(initial?.importance ?? 2) + const [notes, setNotes] = useState(initial?.notes ?? '') + const [url, setUrl] = useState(initial?.url ?? '') + const [desks, setDesks] = useState(initial?.desks ?? []) + + const toggleDesk = (ac: string) => + setDesks(d => d.includes(ac) ? d.filter(x => x !== ac) : [...d, ac]) + + const handleSave = () => { + if (!name.trim()) return + onSave({ name: name.trim(), source, cadence, next_date: nextDate || null, last_date: lastDate || null, importance, notes, url, desks }) + } + + return ( +
+
+ {/* Header */} +
+
+ + + {initial ? 'Edit Report' : 'New Report'} + +
+ +
+ + {/* Body */} +
+ {/* Name + Source */} +
+
+ + setName(e.target.value)} + placeholder="Cocoa Grinding Report" + className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500" /> +
+
+ + setSource(e.target.value)} + placeholder="ICCO, USDA, CFTC…" + className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500" /> +
+
+ + {/* Cadence + Importance */} +
+
+ + +
+
+ +
+ + {importance === 3 ? 'High' : importance === 2 ? 'Medium' : 'Low'} +
+
+
+ + {/* Dates */} +
+
+ + setNextDate(e.target.value)} + className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500" /> +
+
+ + setLastDate(e.target.value)} + className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500" /> +
+
+ + {/* URL */} +
+ + setUrl(e.target.value)} + placeholder="https://…" + className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500" /> +
+ + {/* Notes */} +
+ +