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:
OpenSquared
2026-06-23 09:57:18 +02:00
parent f2dd859ba0
commit 2fb683eec5
8 changed files with 1252 additions and 2 deletions

View File

@@ -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("/")

View 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}

View File

@@ -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}

View File

@@ -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()