feat: Specialist Desks — per asset-class fundamental configs + report catalogue
- 7 pre-seeded desks (Forex, Metals, Agri, Energy, Indices, Crypto, Bonds) each with default fundamental drivers, macro regime sensitivities and price delta thresholds - Global report catalogue (specialist_reports) fully manual — add any report including non-calendar ones (e.g. Cocoa Grinding Report, ICCO) - Many-to-many report ↔ desk linking (report_desk_links table) - 12 default reports pre-seeded (COT, EIA, WASDE, FOMC, ECB, CPI, NFP…) - AI scorer injects SPECIALIST DESK context block for asset classes present in each scoring batch (upcoming reports, key drivers, regime sensitivity) - /specialist-desks page: desk sidebar + fundamentals editor + macro sensitivity tag editor + reports tab + global reports catalogue + modal to create/edit any report with desk assignment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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("/")
|
||||
|
||||
144
backend/routers/specialist_desks.py
Normal file
144
backend/routers/specialist_desks.py
Normal file
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/position-history" element={<PositionHistory />} />
|
||||
<Route path="/logs" element={<SystemLogs />} />
|
||||
<Route path="/institutional" element={<InstitutionalReports />} />
|
||||
<Route path="/specialist-desks" element={<SpecialistDesks />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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<DeskConfig[]>({
|
||||
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<DeskConfig> }) =>
|
||||
api.put(`/specialist-desks/configs/${asset_class}`, body).then(r => r.data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['desk-configs'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export const useAllSpecialistReports = () =>
|
||||
useQuery<SpecialistReport[]>({
|
||||
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<SpecialistReport> & { 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<SpecialistReport> & { 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
682
frontend/src/pages/SpecialistDesks.tsx
Normal file
682
frontend/src/pages/SpecialistDesks.tsx
Normal file
@@ -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 (
|
||||
<span className="flex gap-0.5">
|
||||
{[1, 2, 3].map(i => (
|
||||
<Star
|
||||
key={i}
|
||||
onClick={() => 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')}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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<string[]>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-[520px] bg-dark-800 border border-slate-700/60 rounded-xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/40">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-violet-400" />
|
||||
<span className="font-semibold text-slate-100 text-sm">
|
||||
{initial ? 'Edit Report' : 'New Report'}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-500 hover:text-slate-300 transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
{/* Name + Source */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Report name *</label>
|
||||
<input value={name} onChange={e => 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Source / Issuer</label>
|
||||
<input value={source} onChange={e => 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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cadence + Importance */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Cadence</label>
|
||||
<select value={cadence} onChange={e => setCadence(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">
|
||||
{CADENCE_OPTIONS.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Importance</label>
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Stars n={importance} onChange={setImp} />
|
||||
<span className="text-[10px] text-slate-500">{importance === 3 ? 'High' : importance === 2 ? 'Medium' : 'Low'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Next release date</label>
|
||||
<input type="date" value={nextDate} onChange={e => 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Last release date</label>
|
||||
<input type="date" value={lastDate} onChange={e => 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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">URL (optional)</label>
|
||||
<input value={url} onChange={e => 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" />
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Notes</label>
|
||||
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2}
|
||||
placeholder="Market impact context, watch for…"
|
||||
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 resize-none" />
|
||||
</div>
|
||||
|
||||
{/* Desk assignment */}
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-2 block">Assign to desks</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DESK_ORDER.map(ac => {
|
||||
const desk = allDesks.find(d => d.asset_class === ac)
|
||||
if (!desk) return null
|
||||
const active = desks.includes(ac)
|
||||
return (
|
||||
<button key={ac} onClick={() => toggleDesk(ac)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border transition-colors',
|
||||
active
|
||||
? 'bg-violet-700 border-violet-500 text-white'
|
||||
: 'border-slate-700 text-slate-500 hover:text-slate-300 hover:border-slate-500'
|
||||
)}>
|
||||
<span>{desk.icon}</span>
|
||||
<span>{desk.display_name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-slate-700/40 flex justify-end gap-2">
|
||||
<button onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={!name.trim()}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 text-white text-xs font-semibold rounded transition-colors">
|
||||
<Save className="w-3.5 h-3.5" /> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Macro sensitivity row editor ───────────────────────────────────────────────
|
||||
|
||||
function MacroSensitivityEditor({
|
||||
items, onChange,
|
||||
}: { items: MacroSensitivity[]; onChange: (v: MacroSensitivity[]) => void }) {
|
||||
const [regime, setRegime] = useState('')
|
||||
const [effect, setEffect] = useState('')
|
||||
|
||||
const add = () => {
|
||||
if (!regime.trim() || !effect.trim()) return
|
||||
onChange([...items, { regime: regime.trim(), effect: effect.trim() }])
|
||||
setRegime(''); setEffect('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-2 bg-dark-700/60 rounded px-2 py-1.5 text-xs">
|
||||
<span className="font-mono text-violet-300 min-w-[120px] truncate">{s.regime}</span>
|
||||
<ChevronRight className="w-3 h-3 text-slate-600 flex-shrink-0" />
|
||||
<span className="text-slate-300 flex-1 truncate">{s.effect}</span>
|
||||
<button onClick={() => onChange(items.filter((_, j) => j !== i))}
|
||||
className="text-slate-600 hover:text-red-400 transition-colors flex-shrink-0">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<input value={regime} onChange={e => setRegime(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && add()}
|
||||
placeholder="regime (e.g. risk_off)"
|
||||
className="w-36 px-2 py-1 text-xs bg-dark-700 border border-slate-700/50 rounded text-slate-300 placeholder-slate-600 focus:outline-none focus:border-violet-500 font-mono" />
|
||||
<input value={effect} onChange={e => setEffect(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && add()}
|
||||
placeholder="effect on this asset class"
|
||||
className="flex-1 px-2 py-1 text-xs bg-dark-700 border border-slate-700/50 rounded text-slate-300 placeholder-slate-600 focus:outline-none focus:border-violet-500" />
|
||||
<button onClick={add} disabled={!regime.trim() || !effect.trim()}
|
||||
className="px-2 py-1 bg-violet-800 hover:bg-violet-700 disabled:opacity-30 text-white rounded text-xs transition-colors">
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SpecialistDesks() {
|
||||
const { data: desks = [], isLoading: desksLoading } = useAllDeskConfigs()
|
||||
const { data: allReports = [] } = useAllSpecialistReports()
|
||||
const { mutateAsync: updateDesk, isPending: savingDesk } = useUpdateDeskConfig()
|
||||
const { mutateAsync: createReport } = useCreateSpecialistReport()
|
||||
const { mutateAsync: updateReport } = useUpdateSpecialistReport()
|
||||
const { mutate: deleteReport } = useDeleteSpecialistReport()
|
||||
const { mutate: linkReport } = useLinkReportDesk()
|
||||
const { mutate: unlinkReport } = useUnlinkReportDesk()
|
||||
|
||||
const orderedDesks = DESK_ORDER
|
||||
.map(ac => desks.find(d => d.asset_class === ac))
|
||||
.filter(Boolean) as DeskConfig[]
|
||||
|
||||
const [activeDeskAc, setActiveDeskAc] = useState<string>(DESK_ORDER[0])
|
||||
const [activeTab, setActiveTab] = useState<'config' | 'reports' | 'all-reports'>('config')
|
||||
|
||||
// Desk edit state
|
||||
const [editFund, setEditFund] = useState('')
|
||||
const [editMacro, setEditMacro] = useState<MacroSensitivity[]>([])
|
||||
const [editThresh, setEditThresh] = useState({ significant_day: 1.0, extreme_day: 3.0, significant_week: 2.0, extreme_week: 5.0 })
|
||||
const [editNotes, setEditNotes] = useState('')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
// Report modal
|
||||
const [reportModal, setReportModal] = useState<null | 'new' | (SpecialistReport & { desks?: string[] })>(null)
|
||||
|
||||
const activeDesk = desks.find(d => d.asset_class === activeDeskAc)
|
||||
|
||||
const selectDesk = (ac: string) => {
|
||||
const d = desks.find(x => x.asset_class === ac)
|
||||
if (!d) return
|
||||
setActiveDeskAc(ac)
|
||||
setEditFund(d.fundamentals)
|
||||
setEditMacro(d.macro_sensitivity ?? [])
|
||||
setEditThresh(d.price_delta_thresholds ?? { significant_day: 1, extreme_day: 3, significant_week: 2, extreme_week: 5 })
|
||||
setEditNotes(d.notes ?? '')
|
||||
setDirty(false)
|
||||
setActiveTab('config')
|
||||
}
|
||||
|
||||
// Init on first load
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
if (!initialized && desks.length > 0) {
|
||||
setInitialized(true)
|
||||
selectDesk(DESK_ORDER[0])
|
||||
}
|
||||
|
||||
const handleSaveDesk = async () => {
|
||||
if (!activeDesk) return
|
||||
await updateDesk({
|
||||
asset_class: activeDeskAc,
|
||||
body: {
|
||||
display_name: activeDesk.display_name,
|
||||
icon: activeDesk.icon,
|
||||
fundamentals: editFund,
|
||||
macro_sensitivity: editMacro,
|
||||
price_delta_thresholds: editThresh,
|
||||
notes: editNotes,
|
||||
},
|
||||
})
|
||||
setDirty(false)
|
||||
}
|
||||
|
||||
const handleCreateReport = async (data: any) => {
|
||||
await createReport(data)
|
||||
setReportModal(null)
|
||||
}
|
||||
|
||||
const handleUpdateReport = async (data: any) => {
|
||||
if (!reportModal || reportModal === 'new') return
|
||||
await updateReport({ id: (reportModal as SpecialistReport).id, body: data })
|
||||
setReportModal(null)
|
||||
}
|
||||
|
||||
const deskReports = activeDesk?.reports ?? []
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
if (desksLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen text-slate-500">
|
||||
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Loading desks…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-dark-900 text-slate-200 overflow-hidden">
|
||||
{/* ── Left: desk list ─────────────────────────────────────────────────── */}
|
||||
<div className="w-52 flex-shrink-0 border-r border-slate-700/40 flex flex-col">
|
||||
<div className="p-4 border-b border-slate-700/40">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-violet-400" />
|
||||
<span className="font-semibold text-sm text-slate-100">Specialist Desks</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-600 mt-1">Per asset-class fundamental configs + reports</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-2 overflow-y-auto">
|
||||
{orderedDesks.map(d => (
|
||||
<button
|
||||
key={d.asset_class}
|
||||
onClick={() => selectDesk(d.asset_class)}
|
||||
className={clsx(
|
||||
'w-full text-left px-4 py-3 flex items-center gap-3 border-b border-slate-700/20 transition-colors',
|
||||
d.asset_class === activeDeskAc
|
||||
? 'bg-violet-900/30 border-l-2 border-l-violet-500'
|
||||
: 'hover:bg-dark-700/50'
|
||||
)}>
|
||||
<span className="text-lg">{d.icon}</span>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-slate-200">{d.display_name}</div>
|
||||
<div className="text-[10px] text-slate-600">{d.reports.length} report{d.reports.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* All reports link */}
|
||||
<button
|
||||
onClick={() => setActiveTab('all-reports')}
|
||||
className={clsx(
|
||||
'px-4 py-3 flex items-center gap-2 border-t border-slate-700/40 transition-colors text-xs',
|
||||
activeTab === 'all-reports'
|
||||
? 'bg-slate-700/50 text-slate-200'
|
||||
: 'text-slate-500 hover:text-slate-300'
|
||||
)}>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
All Reports ({allReports.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Right: desk detail / all reports ──────────────────────────────── */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeTab === 'all-reports' ? (
|
||||
/* ══ ALL REPORTS VIEW ════════════════════════════════════════════ */
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-100 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-violet-400" />
|
||||
Report Catalogue
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">All reports — manual and auto-tracked</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setReportModal('new')}
|
||||
className="flex items-center gap-1.5 bg-violet-700 hover:bg-violet-600 text-white px-3 py-2 rounded text-xs font-semibold transition-colors">
|
||||
<Plus className="w-3.5 h-3.5" /> New Report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-[10px] text-slate-500 uppercase tracking-wide border-b border-slate-700/40">
|
||||
<th className="text-left px-4 py-2">Report</th>
|
||||
<th className="text-left px-3 py-2">Source</th>
|
||||
<th className="text-left px-3 py-2">Cadence</th>
|
||||
<th className="text-left px-3 py-2">Next date</th>
|
||||
<th className="text-left px-3 py-2">Desks</th>
|
||||
<th className="text-left px-3 py-2">Imp.</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allReports.map(r => (
|
||||
<tr key={r.id} className="border-b border-slate-700/20 hover:bg-dark-700/40 transition-colors">
|
||||
<td className="px-4 py-2">
|
||||
<div className="font-medium text-slate-200">{r.name}</div>
|
||||
{r.notes && <div className="text-[10px] text-slate-600 truncate max-w-[200px]">{r.notes}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-400">{r.source}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="bg-dark-700 rounded px-1.5 py-0.5 text-slate-400 font-mono">{r.cadence}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.next_date ? (
|
||||
<span className={clsx(
|
||||
'font-mono',
|
||||
r.next_date <= today ? 'text-orange-400' : 'text-slate-300'
|
||||
)}>{r.next_date}</span>
|
||||
) : <span className="text-slate-700">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(r.desks ?? []).map(ac => {
|
||||
const d = desks.find(x => x.asset_class === ac)
|
||||
return d ? (
|
||||
<span key={ac} className="text-[10px] bg-dark-700 rounded px-1 text-slate-400">
|
||||
{d.icon}
|
||||
</span>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2"><Stars n={r.importance} /></td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{r.url && (
|
||||
<a href={r.url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-slate-600 hover:text-violet-400 transition-colors">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
<button onClick={() => setReportModal(r as any)}
|
||||
className="text-slate-600 hover:text-slate-300 transition-colors">
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={() => deleteReport(r.id)}
|
||||
className="text-slate-600 hover:text-red-400 transition-colors">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{allReports.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-slate-600">No reports yet</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : activeDesk ? (
|
||||
/* ══ DESK DETAIL ═════════════════════════════════════════════════ */
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-100 flex items-center gap-2">
|
||||
<span className="text-2xl">{activeDesk.icon}</span>
|
||||
{activeDesk.display_name}
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-normal ml-1">Specialist Desk</span>
|
||||
</h1>
|
||||
</div>
|
||||
{dirty && (
|
||||
<button onClick={handleSaveDesk} disabled={savingDesk}
|
||||
className="flex items-center gap-1.5 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 text-white px-4 py-2 rounded text-xs font-semibold transition-colors">
|
||||
{savingDesk ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-dark-800 rounded p-0.5 w-fit">
|
||||
{(['config', 'reports'] as const).map(tab => (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)}
|
||||
className={clsx('px-4 py-1.5 text-xs rounded transition-colors font-medium flex items-center gap-1.5', {
|
||||
'bg-violet-700 text-white': activeTab === tab,
|
||||
'text-slate-500 hover:text-slate-300': activeTab !== tab,
|
||||
})}>
|
||||
{tab === 'config' ? <BookOpen className="w-3 h-3" /> : <FileText className="w-3 h-3" />}
|
||||
{tab === 'config' ? 'Fundamentals' : `Reports (${deskReports.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── CONFIG TAB ── */}
|
||||
{activeTab === 'config' && (
|
||||
<div className="space-y-5">
|
||||
{/* Key drivers */}
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
|
||||
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2 block">
|
||||
Key Fundamental Drivers
|
||||
</label>
|
||||
<textarea
|
||||
value={editFund}
|
||||
onChange={e => { setEditFund(e.target.value); setDirty(true) }}
|
||||
rows={4}
|
||||
placeholder="Describe the primary price drivers for this asset class…"
|
||||
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 resize-none leading-relaxed"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-600 mt-1">This block is injected into the AI scorer prompt for relevant patterns.</p>
|
||||
</div>
|
||||
|
||||
{/* Macro sensitivity */}
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
|
||||
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2 block">
|
||||
Macro Regime Sensitivity
|
||||
</label>
|
||||
<MacroSensitivityEditor
|
||||
items={editMacro}
|
||||
onChange={v => { setEditMacro(v); setDirty(true) }}
|
||||
/>
|
||||
<p className="text-[10px] text-slate-600 mt-2">Format: regime identifier → market effect. Injected as signal context for the scorer.</p>
|
||||
</div>
|
||||
|
||||
{/* Price thresholds */}
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
|
||||
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-3 block">
|
||||
Price Move Thresholds
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{([
|
||||
['significant_day', 'Significant (daily)'],
|
||||
['extreme_day', 'Extreme (daily)'],
|
||||
['significant_week', 'Significant (weekly)'],
|
||||
['extreme_week', 'Extreme (weekly)'],
|
||||
] as const).map(([key, label]) => (
|
||||
<div key={key}>
|
||||
<label className="text-[10px] text-slate-500 mb-1 block">{label}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number" step="0.1" min="0"
|
||||
value={editThresh[key]}
|
||||
onChange={e => { setEditThresh(t => ({ ...t, [key]: parseFloat(e.target.value) || 0 })); setDirty(true) }}
|
||||
className="w-20 px-2 py-1.5 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500 font-mono"
|
||||
/>
|
||||
<span className="text-xs text-slate-500">%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
|
||||
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2 block">Notes</label>
|
||||
<textarea
|
||||
value={editNotes}
|
||||
onChange={e => { setEditNotes(e.target.value); setDirty(true) }}
|
||||
rows={2}
|
||||
placeholder="Additional desk-specific notes…"
|
||||
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── REPORTS TAB ── */}
|
||||
{activeTab === 'reports' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-xs text-slate-500">
|
||||
Reports assigned to the <span className="text-violet-300">{activeDesk.display_name}</span> desk.
|
||||
They appear in the AI scorer's specialist context when relevant patterns are scored.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setReportModal('new')}
|
||||
className="flex items-center gap-1.5 bg-violet-700 hover:bg-violet-600 text-white px-3 py-1.5 rounded text-xs font-semibold transition-colors">
|
||||
<Plus className="w-3 h-3" /> New report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{deskReports.map(r => {
|
||||
const overdue = r.next_date && r.next_date <= today
|
||||
return (
|
||||
<div key={r.id}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors',
|
||||
overdue ? 'border-orange-700/40 bg-orange-900/10' : 'border-slate-700/40 bg-dark-800'
|
||||
)}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-xs font-semibold text-slate-100">{r.name}</span>
|
||||
{overdue && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-orange-400">
|
||||
<AlertCircle className="w-3 h-3" /> Due
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-slate-500">
|
||||
<span>{r.source}</span>
|
||||
<span className="bg-dark-700 rounded px-1 font-mono">{r.cadence}</span>
|
||||
{r.next_date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
Next: <span className={clsx('font-mono ml-0.5', overdue ? 'text-orange-400' : 'text-slate-300')}>{r.next_date}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.notes && <p className="text-[10px] text-slate-600 mt-0.5 italic">{r.notes}</p>}
|
||||
</div>
|
||||
<Stars n={r.importance} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
{r.url && (
|
||||
<a href={r.url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-slate-600 hover:text-violet-400 transition-colors">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
<button onClick={() => setReportModal(r as any)}
|
||||
className="text-slate-600 hover:text-slate-300 transition-colors">
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => unlinkReport({ report_id: r.id, asset_class: activeDeskAc })}
|
||||
title="Remove from this desk"
|
||||
className="text-slate-600 hover:text-red-400 transition-colors">
|
||||
<Link className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{deskReports.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-600">
|
||||
<FileText className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">No reports assigned to this desk</p>
|
||||
<p className="text-xs mt-1">Create one with "New report" or assign existing ones from the global catalogue</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unassigned reports that could be linked */}
|
||||
{(() => {
|
||||
const unlinked = allReports.filter(
|
||||
r => !(r.desks ?? []).includes(activeDeskAc)
|
||||
)
|
||||
if (unlinked.length === 0) return null
|
||||
return (
|
||||
<div className="border-t border-slate-700/30 pt-3">
|
||||
<p className="text-[10px] text-slate-600 uppercase tracking-wide mb-2">Add existing report to this desk</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unlinked.map(r => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => linkReport({ report_id: r.id, asset_class: activeDeskAc })}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs border border-dashed border-slate-700 text-slate-500 hover:border-violet-600 hover:text-violet-300 rounded transition-colors">
|
||||
<Plus className="w-3 h-3" /> {r.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── Report modal ─────────────────────────────────────────────────────── */}
|
||||
{reportModal && (
|
||||
<ReportModal
|
||||
initial={reportModal === 'new' ? undefined : reportModal}
|
||||
allDesks={desks}
|
||||
onSave={reportModal === 'new' ? handleCreateReport : handleUpdateReport}
|
||||
onClose={() => setReportModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user