server.py 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302
  1. from __future__ import annotations
  2. from contextlib import asynccontextmanager
  3. import asyncio
  4. import json
  5. import time
  6. from datetime import datetime, timezone
  7. from uuid import uuid4
  8. import anyio
  9. from fastapi import FastAPI, Request
  10. from fastapi.responses import JSONResponse
  11. from mcp.server.fastmcp import FastMCP
  12. from mcp.server.transport_security import TransportSecuritySettings
  13. from mcp import ClientSession
  14. from mcp.client.sse import sse_client
  15. from .config import load_config
  16. from .argus_client import get_regime as argus_get_regime, get_snapshot as argus_get_snapshot
  17. from .crypto_client import get_price, get_regime
  18. from .decision_engine import assess_wallet_state
  19. from .decision_families import make_family_decision
  20. from .narrative_engine import build_narrative
  21. from .replay import build_replay_input
  22. from .state_engine import synthesize_state
  23. from .store import delete_concern, get_decision_profile, get_state, init_db, list_concerns, list_strategy_assignments, list_strategy_groups, latest_cycle, latest_cycles, latest_decisions, latest_narratives, latest_observations, latest_regime_samples, prune_older_than, recent_regime_samples, recent_states_for_concern, sync_concerns_from_strategies, upsert_concern, upsert_cycle, upsert_decision, upsert_decision_profile, upsert_narrative, upsert_observation, upsert_regime_sample, upsert_state, latest_states, upsert_strategy_assignment, upsert_strategy_group
  24. from .trader_client import apply_control_decision as trader_apply_control_decision, cancel_all_orders as trader_cancel_all_orders, get_strategy as trader_get_strategy, list_strategies
  25. mcp = FastMCP(
  26. "hermes-mcp",
  27. transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
  28. )
  29. def _build_trader_control_payload(*, decision_id: str, concern: dict, decision: object) -> dict | None:
  30. action = str(getattr(decision, "action", "") or "").strip()
  31. target_strategy = str(getattr(decision, "target_strategy", "") or "").strip() or None
  32. decision_payload = getattr(decision, "payload", {}) if isinstance(getattr(decision, "payload", {}), dict) else {}
  33. current_primary = str(decision_payload.get("current_primary_strategy") or "").strip() or None
  34. trader_action: str | None = None
  35. risk_mode: str | None = None
  36. if action.startswith("replace_with_") or action.startswith("enable_"):
  37. trader_action = "switch"
  38. elif action == "suspend_grid":
  39. trader_action = "pause"
  40. target_strategy = current_primary
  41. elif action == "set_risk_mode":
  42. trader_action = "set_risk_mode"
  43. risk_mode = str(decision_payload.get("risk_mode") or "").strip() or None
  44. else:
  45. return None
  46. account_id = str(concern.get("account_id") or "").strip()
  47. market_symbol = str(concern.get("market_symbol") or "").strip().lower()
  48. concern_id = str(concern.get("id") or "").strip() or None
  49. reason = str(getattr(decision, "reason_summary", "") or "").strip()
  50. confidence = float(getattr(decision, "confidence", 0.0) or 0.0)
  51. payload = {
  52. "decision_id": decision_id,
  53. "concern_id": concern_id,
  54. "account_id": account_id,
  55. "market_symbol": market_symbol,
  56. "action": trader_action,
  57. "target_strategy_id": target_strategy,
  58. "expected_active_strategy_id": current_primary,
  59. "risk_mode": risk_mode,
  60. "reason": reason,
  61. "confidence": confidence,
  62. "dry_run": False,
  63. "override": False,
  64. "source": "hermes-mcp",
  65. "source_action": action,
  66. }
  67. return payload
  68. async def _maybe_dispatch_trader_action(*, cfg: object, decision_id: str, concern: dict, decision: object, trader_available: bool = True, retry_after_seconds: int | None = None) -> dict:
  69. if str(concern.get("status") or "active").strip().lower() != "active":
  70. return {
  71. "dispatch": "blocked",
  72. "reason": "concern is inactive",
  73. }
  74. if not bool(getattr(decision, "requires_action", False)):
  75. return {"dispatch": "not_required"}
  76. payload = _build_trader_control_payload(decision_id=decision_id, concern=concern, decision=decision)
  77. if payload is None:
  78. return {
  79. "dispatch": "skipped",
  80. "reason": f"no trader action mapping for {getattr(decision, 'action', 'unknown')}",
  81. }
  82. if not bool(getattr(cfg, "hermes_allow_actions", False)):
  83. return {
  84. "dispatch": "blocked",
  85. "reason": "HERMES_ALLOW_ACTIONS is false",
  86. "payload": payload,
  87. }
  88. if not trader_available:
  89. return {
  90. "dispatch": "deferred",
  91. "reason": "trader unavailable",
  92. "retry_after_seconds": retry_after_seconds,
  93. "payload": payload,
  94. }
  95. try:
  96. result = await trader_apply_control_decision(getattr(cfg, "trader_url"), payload)
  97. return {
  98. "dispatch": "sent",
  99. "payload": payload,
  100. "result": result,
  101. }
  102. except Exception as exc:
  103. return {
  104. "dispatch": "failed",
  105. "payload": payload,
  106. "error": str(exc),
  107. }
  108. @mcp.tool(description="Return Hermes current state, narrative, uncertainty, and a short self-assessment report.")
  109. def report() -> dict:
  110. state = get_state()
  111. cfg = load_config()
  112. concerns = list_concerns()
  113. groups_by_concern: dict[str, list[dict[str, Any]]] = {}
  114. for group in list_strategy_groups():
  115. groups_by_concern.setdefault(str(group.get("concern_id") or ""), []).append(group)
  116. try:
  117. accounts_by_id, markets_by_symbol, total_values = anyio.run(_load_exec_enrichment, cfg.exec_url, cfg.crypto_url, concerns)
  118. except Exception:
  119. accounts_by_id, markets_by_symbol, total_values = {}, {}, {}
  120. concern_summaries = []
  121. for concern in concerns:
  122. concern_id = str(concern.get("id") or "")
  123. account_id = str(concern.get("account_id") or "").strip()
  124. market_symbol = str(concern.get("market_symbol") or "").strip().lower()
  125. account_info = accounts_by_id.get(account_id, {})
  126. market_info = markets_by_symbol.get(market_symbol, {})
  127. groups = groups_by_concern.get(concern_id, [])
  128. active_playbook = next((g for g in groups if str(g.get("status") or "").lower() == "active"), None)
  129. assignments = list_strategy_assignments(strategy_group_id=str(active_playbook.get("id") or "")) if active_playbook else []
  130. concern_summaries.append({
  131. "concern_id": concern_id,
  132. "account_id": account_id or None,
  133. "account": account_info.get("display_name") or account_id or None,
  134. "market_symbol": str(concern.get("market_symbol") or "") or None,
  135. "market": market_info.get("name") or str(concern.get("market_symbol") or "") or None,
  136. "status": str(concern.get("status") or "active"),
  137. "active_playbook": {
  138. "id": str(active_playbook.get("id") or "") or None,
  139. "name": str(active_playbook.get("name") or "") or None,
  140. "family": str(active_playbook.get("strategy_family") or "") or None,
  141. } if active_playbook else None,
  142. "active_strategies": [
  143. {
  144. "strategy_id": str(a.get("strategy_id") or "") or None,
  145. "role": str(a.get("role") or "member") or "member",
  146. "strategy_type": str(a.get("strategy_type") or "") or None,
  147. }
  148. for a in assignments
  149. ],
  150. "balances": _compact_balances(account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or []),
  151. "total_value_usd": total_values.get(account_id) if total_values.get(account_id) is not None else account_info.get("total_value_usd"),
  152. })
  153. return {
  154. "status": state.get("status", "stub"),
  155. "thinking": state.get("thinking", "Hermes scaffold is ready."),
  156. "confidence": state.get("confidence", 0.0),
  157. "uncertainty": state.get("uncertainty", ["no live adapters wired yet"]),
  158. "layers": state.get("layers", []),
  159. "concerns": concern_summaries,
  160. }
  161. @asynccontextmanager
  162. async def lifespan(_: FastAPI):
  163. cfg = load_config()
  164. init_db()
  165. trader_gate = {"failures": 0, "down_until": 0.0, "last_error": "", "last_ok": 0.0}
  166. cached_strategy_inventory: list[dict] = []
  167. def _trader_available() -> bool:
  168. return time.monotonic() >= float(trader_gate["down_until"] or 0.0)
  169. def _mark_trader_success() -> None:
  170. trader_gate["failures"] = 0
  171. trader_gate["down_until"] = 0.0
  172. trader_gate["last_error"] = ""
  173. trader_gate["last_ok"] = time.monotonic()
  174. def _mark_trader_failure(error: Exception) -> None:
  175. failures = int(trader_gate["failures"] or 0) + 1
  176. trader_gate["failures"] = failures
  177. trader_gate["last_error"] = str(error)
  178. backoff = min(300, max(5, 5 * (2 ** min(failures - 1, 5))))
  179. trader_gate["down_until"] = time.monotonic() + backoff
  180. try:
  181. sync_concerns_from_strategies(await list_strategies(cfg.trader_url))
  182. except Exception:
  183. pass
  184. try:
  185. prune_older_than(cfg.retention_days)
  186. except Exception:
  187. pass
  188. async def _poll_loop() -> None:
  189. nonlocal cached_strategy_inventory
  190. while True:
  191. started = datetime.now(timezone.utc).isoformat()
  192. cycle_id = str(uuid4())
  193. concerns = list_concerns()
  194. profile_ids = sorted({str(c.get("decision_profile_id") or "").strip() for c in concerns if str(c.get("decision_profile_id") or "").strip()})
  195. decision_profiles = {}
  196. for profile_id in profile_ids:
  197. profile = get_decision_profile(profile_id=profile_id)
  198. if not profile:
  199. continue
  200. try:
  201. profile_config = json.loads(profile.get("config_json") or "{}")
  202. except Exception:
  203. profile_config = {}
  204. if isinstance(profile_config, dict):
  205. decision_profiles[profile_id] = {**profile, "config": profile_config}
  206. playbook_groups = list_strategy_groups()
  207. playbook_assignments = {
  208. str(group.get("id") or ""): list_strategy_assignments(strategy_group_id=str(group.get("id") or ""))
  209. for group in playbook_groups
  210. }
  211. strategy_inventory = cached_strategy_inventory
  212. if _trader_available():
  213. try:
  214. strategy_inventory = await list_strategies(cfg.trader_url)
  215. enriched_inventory = []
  216. for strategy in strategy_inventory:
  217. instance_id = str(strategy.get("id") or "").strip()
  218. if not instance_id:
  219. enriched_inventory.append(strategy)
  220. continue
  221. try:
  222. detail = await trader_get_strategy(cfg.trader_url, instance_id, include_state=True, include_report=True)
  223. enriched_inventory.append({**strategy, **detail})
  224. except Exception:
  225. enriched_inventory.append(strategy)
  226. strategy_inventory = enriched_inventory
  227. cached_strategy_inventory = strategy_inventory
  228. _mark_trader_success()
  229. except Exception as exc:
  230. _mark_trader_failure(exc)
  231. strategy_inventory = cached_strategy_inventory
  232. try:
  233. sync_concerns_from_strategies(strategy_inventory)
  234. except Exception:
  235. pass
  236. upsert_cycle(id=cycle_id, started_at=started, finished_at=None, status="running", trigger="interval", notes=f"polling {len(concerns)} concerns")
  237. argus_snapshot: dict = {}
  238. argus_regime: dict = {}
  239. try:
  240. argus_snapshot = await argus_get_snapshot(cfg.argus_url)
  241. except Exception:
  242. argus_snapshot = {}
  243. try:
  244. argus_regime = await argus_get_regime(cfg.argus_url)
  245. except Exception:
  246. argus_regime = {}
  247. if argus_snapshot or argus_regime:
  248. upsert_observation(
  249. id=f"{cycle_id}:argus",
  250. cycle_id=cycle_id,
  251. concern_id=None,
  252. source="argus-mcp",
  253. kind="macro_snapshot",
  254. payload_json=json.dumps({"snapshot": argus_snapshot, "regime": argus_regime}, ensure_ascii=False),
  255. observed_at=datetime.now(timezone.utc).isoformat(),
  256. )
  257. for concern in concerns:
  258. symbol = _resolve_regime_symbol(concern)
  259. if not symbol:
  260. continue
  261. concern_id = str(concern.get("id") or "")
  262. account_id = str(concern.get("account_id") or "").strip()
  263. account_info = {}
  264. if account_id:
  265. try:
  266. payload = await _call_exec_tool(cfg.exec_url, "get_account_info", {"account_id": account_id})
  267. account_info = payload if isinstance(payload, dict) else {}
  268. except Exception:
  269. account_info = {}
  270. current_regimes: list[dict] = []
  271. for timeframe in cfg.crypto_timeframes:
  272. regime = await get_regime(cfg.crypto_url, str(symbol), timeframe)
  273. current_regimes.append({**regime, "timeframe": timeframe})
  274. upsert_regime_sample(
  275. id=f"{cycle_id}:{concern['id']}:{timeframe}",
  276. cycle_id=cycle_id,
  277. concern_id=str(concern["id"]),
  278. timeframe=timeframe,
  279. regime_json=json.dumps(regime, ensure_ascii=False),
  280. captured_at=datetime.now(timezone.utc).isoformat(),
  281. )
  282. try:
  283. state = synthesize_state(
  284. concern=concern,
  285. regimes=current_regimes,
  286. account_info=account_info,
  287. argus_snapshot=argus_snapshot,
  288. argus_regime=argus_regime,
  289. )
  290. upsert_state(
  291. id=f"{cycle_id}:{concern['id']}",
  292. cycle_id=cycle_id,
  293. concern_id=str(concern["id"]),
  294. market_regime=state.market_regime,
  295. volatility_state=state.volatility_state,
  296. liquidity_state=state.liquidity_state,
  297. sentiment_pressure=state.sentiment_pressure,
  298. event_risk=state.event_risk,
  299. execution_quality=state.execution_quality,
  300. confidence=state.confidence,
  301. payload_json=json.dumps(state.payload, ensure_ascii=False),
  302. created_at=state.payload.get("generated_at"),
  303. )
  304. narrative = build_narrative(concern=concern, state_payload=state.payload)
  305. upsert_narrative(
  306. id=f"{cycle_id}:{concern['id']}",
  307. cycle_id=cycle_id,
  308. concern_id=str(concern["id"]),
  309. summary=narrative.summary,
  310. key_drivers_json=json.dumps(narrative.key_drivers, ensure_ascii=False),
  311. risk_flags_json=json.dumps(narrative.risk_flags, ensure_ascii=False),
  312. uncertainties_json=json.dumps(narrative.uncertainties, ensure_ascii=False),
  313. confidence=narrative.confidence,
  314. created_at=narrative.payload.get("generated_at"),
  315. )
  316. latest_price = None
  317. if current_regimes:
  318. latest_price = next((r.get("price") for r in reversed(current_regimes) if r.get("price") is not None), None)
  319. wallet_state = assess_wallet_state(
  320. account_info=account_info,
  321. concern=concern,
  322. price=float(latest_price) if latest_price is not None else None,
  323. strategies=strategy_inventory,
  324. )
  325. active_playbook = next((g for g in playbook_groups if str(g.get("concern_id") or "") == concern_id and str(g.get("status") or "").lower() == "active"), None)
  326. assignment_by_strategy_id = {
  327. str(a.get("strategy_id") or "").strip(): a
  328. for a in playbook_assignments.get(str(active_playbook.get("id") or ""), [])
  329. if str(a.get("strategy_id") or "").strip()
  330. } if active_playbook else {}
  331. assigned_strategy_ids = {
  332. str(a.get("strategy_id") or "").strip()
  333. for a in playbook_assignments.get(str(active_playbook.get("id") or ""), [])
  334. if str(a.get("strategy_id") or "").strip()
  335. } if active_playbook else set()
  336. candidate_strategies = [
  337. {
  338. **s,
  339. "playbook_role": str(assignment_by_strategy_id.get(str(s.get("id") or "").strip(), {}).get("role") or "").strip() or None,
  340. "playbook_assignment_id": str(assignment_by_strategy_id.get(str(s.get("id") or "").strip(), {}).get("id") or "").strip() or None,
  341. }
  342. for s in strategy_inventory
  343. if str(s.get("account_id") or "").strip() == account_id
  344. and str(s.get("market_symbol") or "").strip().lower() == str(concern.get("market_symbol") or "").strip().lower()
  345. and (not assigned_strategy_ids or str(s.get("id") or "").strip() in assigned_strategy_ids)
  346. ]
  347. breakout_window_seconds = max(300, int(getattr(cfg, "breakout_memory_window_seconds", 900) or 900))
  348. recent_state_rows = recent_states_for_concern(concern_id=str(concern["id"]), since_seconds=breakout_window_seconds, limit=12)
  349. decision = make_family_decision(
  350. family=str(active_playbook.get("strategy_family") or "grid-trend-rebalancer") if active_playbook else "grid-trend-rebalancer",
  351. concern=concern,
  352. narrative_payload={
  353. **state.payload,
  354. **narrative.payload,
  355. "confidence": narrative.confidence,
  356. },
  357. wallet_state=wallet_state,
  358. strategies=candidate_strategies,
  359. history_window={
  360. "window_seconds": breakout_window_seconds,
  361. "recent_states": recent_state_rows,
  362. },
  363. decision_profile=decision_profiles.get(str(concern.get("decision_profile_id") or "").strip()),
  364. )
  365. decision_id = f"{cycle_id}:{concern['id']}"
  366. dispatch_record = await _maybe_dispatch_trader_action(
  367. cfg=cfg,
  368. decision_id=decision_id,
  369. concern=concern,
  370. decision=decision,
  371. trader_available=_trader_available(),
  372. retry_after_seconds=max(0, int(trader_gate["down_until"] - time.monotonic())) if not _trader_available() else None,
  373. )
  374. decision_payload = {
  375. **decision.payload,
  376. "replay_input": build_replay_input(
  377. concern=concern,
  378. narrative_payload={
  379. **state.payload,
  380. **narrative.payload,
  381. "confidence": narrative.confidence,
  382. },
  383. wallet_state=wallet_state,
  384. strategies=candidate_strategies,
  385. history_window={
  386. "window_seconds": breakout_window_seconds,
  387. "recent_states": recent_state_rows,
  388. },
  389. ),
  390. "dispatch": dispatch_record,
  391. "decision_family": str(active_playbook.get("strategy_family") or "grid-trend-rebalancer") if active_playbook else "grid-trend-rebalancer",
  392. "active_playbook_id": str(active_playbook.get("id") or "") if active_playbook else None,
  393. "candidate_strategy_ids": sorted(assigned_strategy_ids) if assigned_strategy_ids else [str(s.get("id") or "") for s in candidate_strategies if str(s.get("id") or "")],
  394. }
  395. upsert_decision(
  396. id=decision_id,
  397. cycle_id=cycle_id,
  398. concern_id=str(concern["id"]),
  399. mode=decision.mode,
  400. action=decision.action,
  401. target_strategy=decision.target_strategy,
  402. target_policy_json=json.dumps(decision_payload, ensure_ascii=False),
  403. reason_summary=decision.reason_summary,
  404. confidence=decision.confidence,
  405. requires_action=decision.requires_action,
  406. created_at=decision.payload.get("generated_at"),
  407. )
  408. except Exception:
  409. pass
  410. upsert_cycle(id=cycle_id, started_at=started, finished_at=datetime.now(timezone.utc).isoformat(), status="ok", trigger="interval", notes=f"polled {len(concerns)} concerns over {','.join(cfg.crypto_timeframes)}")
  411. await asyncio.sleep(max(10, cfg.cycle_seconds))
  412. asyncio.create_task(_poll_loop())
  413. yield
  414. app = FastAPI(title="Hermes MCP", lifespan=lifespan)
  415. app.mount("/mcp", mcp.sse_app())
  416. @app.get("/")
  417. def root() -> dict:
  418. return {"status": "ok", "mount": "/mcp/sse", "dashboard": "/dashboard"}
  419. @app.get("/health")
  420. def health() -> dict:
  421. return {"status": "ok", "db": "sqlite", "tool": "report"}
  422. @app.delete("/concerns/{concern_id}")
  423. def remove_concern(concern_id: str) -> JSONResponse:
  424. deleted = delete_concern(concern_id=concern_id)
  425. if not deleted.get("concerns"):
  426. return JSONResponse({"ok": False, "error": "concern not found", "deleted": deleted}, status_code=404)
  427. return JSONResponse({"ok": True, "deleted": deleted})
  428. def _strip_sse(url: str) -> str:
  429. root = url.rstrip("/")
  430. return root[:-8] if root.endswith("/mcp/sse") else root
  431. async def _call_exec_tool(exec_url: str, tool: str, arguments: dict) -> object:
  432. async with sse_client(exec_url) as (read_stream, write_stream):
  433. async with ClientSession(read_stream, write_stream) as session:
  434. await session.initialize()
  435. result = await session.call_tool(tool, arguments)
  436. content = getattr(result, "content", None) or []
  437. if not content:
  438. return None
  439. first = content[0]
  440. text = getattr(first, "text", None) if not isinstance(first, dict) else first.get("text")
  441. if text is None:
  442. return None
  443. try:
  444. return json.loads(text)
  445. except Exception:
  446. return text
  447. async def _load_exec_enrichment(exec_url: str, crypto_url: str, concerns: list[dict]) -> tuple[dict[str, dict], dict[str, dict], dict[str, float | None]]:
  448. account_ids = sorted({str(c.get("account_id") or "").strip() for c in concerns if str(c.get("account_id") or "").strip()})
  449. market_symbols = sorted({str(c.get("market_symbol") or "").strip().lower() for c in concerns if str(c.get("market_symbol") or "").strip()})
  450. account_payloads = await asyncio.gather(*[_call_exec_tool(exec_url, "get_account_info", {"account_id": account_id}) for account_id in account_ids])
  451. market_payload = await _call_exec_tool(exec_url, "list_markets", {})
  452. accounts_by_id = {account_id: payload for account_id, payload in zip(account_ids, account_payloads) if isinstance(payload, dict)}
  453. total_values: dict[str, float | None] = {}
  454. for account_id, payload in accounts_by_id.items():
  455. total_values[account_id] = await _live_total_value(crypto_url, payload)
  456. markets_by_symbol: dict[str, dict] = {}
  457. if isinstance(market_payload, list):
  458. for market in market_payload:
  459. if not isinstance(market, dict):
  460. continue
  461. symbol = str(market.get("symbol") or market.get("market_symbol") or "").strip().lower()
  462. if symbol in market_symbols or symbol:
  463. markets_by_symbol[symbol] = market
  464. return accounts_by_id, markets_by_symbol, total_values
  465. async def _live_total_value(crypto_url: str, account_info: dict) -> float | None:
  466. balances = account_info.get("balances")
  467. if not isinstance(balances, list):
  468. return None
  469. total = 0.0
  470. seen = False
  471. for item in balances:
  472. if not isinstance(item, dict):
  473. continue
  474. asset = str(item.get("asset_code") or item.get("asset") or "").strip().lower()
  475. amount = item.get("total")
  476. if not asset or amount is None:
  477. continue
  478. try:
  479. amount_f = float(amount)
  480. except Exception:
  481. continue
  482. seen = True
  483. if asset == "usd":
  484. total += amount_f
  485. continue
  486. price_payload = await get_price(crypto_url, asset)
  487. try:
  488. price = float(price_payload.get("price"))
  489. except Exception:
  490. price = 0.0
  491. total += amount_f * price
  492. return total if seen else None
  493. def _compact_balances(payload: object) -> str:
  494. if not isinstance(payload, list):
  495. return "-"
  496. parts: list[str] = []
  497. for item in payload:
  498. if not isinstance(item, dict):
  499. continue
  500. asset = str(item.get("asset_code") or item.get("asset") or "").upper().strip()
  501. total = item.get("total")
  502. available = item.get("available")
  503. value_usd = item.get("value_usd")
  504. if not asset:
  505. continue
  506. segment = f"{asset} {float(total):.8g}" if total is not None else asset
  507. if available is not None and total is not None and available != total:
  508. segment += f" (avail {float(available):.8g})"
  509. if isinstance(value_usd, (int, float)):
  510. segment += f" ≈ ${float(value_usd):,.2f}"
  511. parts.append(segment)
  512. return " | ".join(parts[:5]) or "-"
  513. def _resolve_regime_symbol(concern: dict) -> str | None:
  514. base = str(concern.get("base_currency") or "").strip().upper()
  515. if base:
  516. return base
  517. market = str(concern.get("market_symbol") or "").strip().upper().replace("/", "").replace("-", "")
  518. for suffix in ("USDT", "USDC", "USD", "EUR", "BTC", "ETH"):
  519. if market.endswith(suffix) and len(market) > len(suffix):
  520. return market[:-len(suffix)]
  521. return market or None
  522. def _default_playbook_name(strategies: list[dict]) -> str:
  523. types = {str(s.get("strategy_type") or "").strip() for s in strategies}
  524. if {"grid_trader", "trend_follower", "exposure_protector"}.issubset(types):
  525. return "grid-trend-rebalancer"
  526. if types == {"trend_follower"} or ("trend_follower" in types and "grid_trader" not in types and "exposure_protector" not in types):
  527. return "trend-only"
  528. labels = sorted(t.replace("_", "-") for t in types if t)
  529. return "+".join(labels) if labels else "playbook"
  530. def _default_playbook_family(strategies: list[dict]) -> str:
  531. types = {str(s.get("strategy_type") or "").strip() for s in strategies}
  532. if {"grid_trader", "trend_follower", "exposure_protector"}.issubset(types):
  533. return "grid-trend-rebalancer"
  534. if "trend_follower" in types and "grid_trader" not in types and "exposure_protector" not in types:
  535. return "trend-only"
  536. return "mixed"
  537. def _default_profile_config(family: str | None = None) -> dict[str, object]:
  538. normalized = str(family or "").strip().lower()
  539. if normalized in {"trend-only", "trend_only", "trend"}:
  540. return {
  541. "estimated_turn_cost_pct": 0.7,
  542. "micro_trend_weight": 0.8,
  543. "meso_trend_weight": 1.0,
  544. "macro_trend_weight": 0.7,
  545. "persistence_bonus_weight": 0.45,
  546. "argus_compression_penalty": 0.18,
  547. "activation_edge_threshold": 1.15,
  548. "flip_edge_threshold": 1.35,
  549. "flip_confirmation_gap": 0.25,
  550. }
  551. return {
  552. "breakout_persistence_min": 0.65,
  553. "short_term_confirmation_min": 0.32,
  554. "switch_cost_penalty": 1.0,
  555. "rebalance_imbalance_threshold": 0.30,
  556. "force_grid_when_balanced": True,
  557. "grid_release_threshold": 0.35,
  558. "trend_cooling_threshold": 0.45,
  559. "trend_inventory_stress_threshold": 0.55,
  560. "action_cooldown_seconds": 600,
  561. }
  562. def _profile_allowed_keys(family: str | None = None) -> set[str]:
  563. return set(_default_profile_config(family).keys())
  564. def _normalize_profile_config(config: dict[str, object] | None, family: str | None = None) -> dict[str, object]:
  565. defaults = _default_profile_config(family)
  566. allowed = _profile_allowed_keys(family)
  567. current = config if isinstance(config, dict) else {}
  568. return {**defaults, **{k: v for k, v in current.items() if k in allowed}}
  569. def _ensure_profile_for_family(*, profile_id: str, family: str | None, name: str, description: str | None = None, status: str = "active") -> dict[str, Any]:
  570. family_label = str(family or "").strip() or "playbook"
  571. profile = get_decision_profile(profile_id=profile_id)
  572. config: dict[str, object] = {}
  573. if profile:
  574. try:
  575. raw = json.loads(profile.get("config_json") or "{}")
  576. except Exception:
  577. raw = {}
  578. config = _normalize_profile_config(raw if isinstance(raw, dict) else {}, family)
  579. current_name = str(profile.get("name") or "").strip()
  580. generic_names = {"grid-trend-rebalancer profile", "trend-only profile", "playbook profile"}
  581. profile_name = name if not current_name or current_name in generic_names else current_name
  582. upsert_decision_profile(
  583. id=profile_id,
  584. name=profile_name,
  585. description=str(profile.get("description") or description or "").strip() or None,
  586. config=config,
  587. status=str(profile.get("status") or status or "active"),
  588. )
  589. return {**profile, "name": profile_name, "config": config}
  590. config = _default_profile_config(family)
  591. upsert_decision_profile(
  592. id=profile_id,
  593. name=name or f"{family_label} profile",
  594. description=description,
  595. config=config,
  596. status=status,
  597. )
  598. created = get_decision_profile(profile_id=profile_id) or {"id": profile_id, "name": name, "description": description, "status": status}
  599. return {**created, "config": config}
  600. def _strategy_display_label(strategy: dict) -> str:
  601. for key in ("label", "display_name", "name", "title"):
  602. value = str(strategy.get(key) or "").strip()
  603. if value:
  604. return value
  605. strategy_type = str(strategy.get("strategy_type") or "strategy").strip().replace("_", " ")
  606. instance_id = str(strategy.get("id") or "").strip()
  607. return f"{strategy_type} ({instance_id[:8]})" if instance_id else strategy_type
  608. @app.get("/dashboard/data")
  609. def dashboard_data() -> JSONResponse:
  610. cfg = load_config()
  611. concerns = list_concerns()
  612. accounts_by_id: dict[str, dict] = {}
  613. markets_by_symbol: dict[str, dict] = {}
  614. strategy_inventory: list[dict] = []
  615. strategy_inventory_available = True
  616. try:
  617. accounts_by_id, markets_by_symbol, total_values = anyio.run(_load_exec_enrichment, cfg.exec_url, cfg.crypto_url, concerns)
  618. except Exception:
  619. total_values = {}
  620. pass
  621. try:
  622. strategy_inventory = anyio.run(list_strategies, cfg.trader_url)
  623. except Exception:
  624. strategy_inventory = []
  625. strategy_inventory_available = False
  626. live_scopes = {
  627. (str(strategy.get("account_id") or "").strip(), str(strategy.get("market_symbol") or "").strip().lower())
  628. for strategy in strategy_inventory
  629. if str(strategy.get("account_id") or "").strip() and str(strategy.get("market_symbol") or "").strip()
  630. }
  631. enriched = []
  632. concern_lookup: dict[str, dict] = {}
  633. for concern in concerns:
  634. account_id = str(concern.get("account_id") or "").strip()
  635. market_symbol = str(concern.get("market_symbol") or "").strip().lower()
  636. account_info = accounts_by_id.get(account_id, {})
  637. market_info = markets_by_symbol.get(market_symbol, {})
  638. enriched.append({
  639. **concern,
  640. "account_display": account_info.get("display_name") or account_id,
  641. "balances": account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or [],
  642. "balance_summary": _compact_balances(account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or []),
  643. "total_value_usd": total_values.get(account_id) if total_values.get(account_id) is not None else account_info.get("total_value_usd"),
  644. "market_display": market_info.get("name") or concern.get("market_symbol") or "",
  645. "market_description": market_info.get("description") or "",
  646. "orphaned": strategy_inventory_available and (account_id, market_symbol) not in live_scopes,
  647. })
  648. concern_lookup[str(concern.get("id") or "")] = enriched[-1]
  649. regimes = []
  650. histories_by_key: dict[str, list[dict]] = {}
  651. for sample in recent_regime_samples(1000):
  652. concern_id = str(sample.get("concern_id") or "")
  653. timeframe = str(sample.get("timeframe") or "")
  654. key = f"{concern_id}::{timeframe}"
  655. bucket = histories_by_key.setdefault(key, [])
  656. if len(bucket) < 24:
  657. bucket.append(sample)
  658. for sample in latest_regime_samples(20):
  659. concern_meta = concern_lookup.get(str(sample.get("concern_id") or ""), {})
  660. regimes.append({**sample, **{
  661. "account_display": concern_meta.get("account_display"),
  662. "market_display": concern_meta.get("market_display"),
  663. "market_symbol": concern_meta.get("market_symbol"),
  664. }})
  665. return JSONResponse({
  666. "latest_cycle": latest_cycle(),
  667. "cycles": latest_cycles(10),
  668. "argus_observations": latest_observations(20, source="argus-mcp"),
  669. "concerns": enriched,
  670. "regime_samples": regimes,
  671. "regime_histories": histories_by_key,
  672. "state_samples": latest_states(20),
  673. "state_history": latest_states(100),
  674. "narrative_samples": latest_narratives(20),
  675. "decision_samples": latest_decisions(20),
  676. "decision_history": latest_decisions(100),
  677. })
  678. @app.get("/dashboard/concerns/{concern_id}/data")
  679. def dashboard_concern_detail_data(concern_id: str) -> JSONResponse:
  680. cfg = load_config()
  681. concern_id = str(concern_id or "").strip()
  682. concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
  683. if not concern:
  684. return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
  685. account_id = str(concern.get("account_id") or "").strip()
  686. market_symbol = str(concern.get("market_symbol") or "").strip().lower()
  687. concerns = [concern]
  688. try:
  689. accounts_by_id, markets_by_symbol, total_values = anyio.run(_load_exec_enrichment, cfg.exec_url, cfg.crypto_url, concerns)
  690. except Exception:
  691. accounts_by_id, markets_by_symbol, total_values = {}, {}, {}
  692. account_info = accounts_by_id.get(account_id, {})
  693. market_info = markets_by_symbol.get(market_symbol, {})
  694. enriched_concern = {
  695. **concern,
  696. "account_display": account_info.get("display_name") or account_id,
  697. "balances": account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or [],
  698. "balance_summary": _compact_balances(account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or []),
  699. "total_value_usd": total_values.get(account_id) if total_values.get(account_id) is not None else account_info.get("total_value_usd"),
  700. "market_display": market_info.get("name") or concern.get("market_symbol") or "",
  701. "market_description": market_info.get("description") or "",
  702. }
  703. try:
  704. strategy_inventory = anyio.run(list_strategies, cfg.trader_url)
  705. except Exception:
  706. strategy_inventory = []
  707. concern_strategies = [
  708. s for s in strategy_inventory
  709. if str(s.get("account_id") or "").strip() == account_id
  710. and str(s.get("market_symbol") or "").strip().lower() == market_symbol
  711. ]
  712. strategies_by_id = {str(s.get("id") or "").strip(): s for s in concern_strategies if str(s.get("id") or "").strip()}
  713. profile_id = str(concern.get("decision_profile_id") or "").strip()
  714. existing_groups = list_strategy_groups(concern_id=concern_id)
  715. if not existing_groups and concern_strategies:
  716. seeded_group_id = f"playbook:{concern_id}:default"
  717. seeded_family = _default_playbook_family(concern_strategies)
  718. seeded_profile_id = profile_id or f"profile:{concern_id}:default"
  719. if not profile_id:
  720. upsert_decision_profile(
  721. id=seeded_profile_id,
  722. name=f"{_default_playbook_name(concern_strategies)} profile",
  723. description="Auto-seeded default profile for this concern.",
  724. config=_default_profile_config(seeded_family),
  725. status="active",
  726. )
  727. upsert_concern(
  728. id=str(concern.get("id") or ""),
  729. account_id=account_id or None,
  730. market_symbol=market_symbol or None,
  731. base_currency=str(concern.get("base_currency") or "").strip() or None,
  732. quote_currency=str(concern.get("quote_currency") or "").strip() or None,
  733. strategy_id=str(concern.get("strategy_id") or "").strip() or None,
  734. decision_profile_id=seeded_profile_id,
  735. source=str(concern.get("source") or "dashboard"),
  736. status=str(concern.get("status") or "active"),
  737. notes=str(concern.get("notes") or "").strip() or None,
  738. )
  739. concern = {**concern, "decision_profile_id": seeded_profile_id}
  740. profile_id = seeded_profile_id
  741. upsert_strategy_group(
  742. id=seeded_group_id,
  743. concern_id=concern_id,
  744. name=_default_playbook_name(concern_strategies),
  745. strategy_family=seeded_family,
  746. decision_profile_id=profile_id or None,
  747. notes="auto-seeded from trader strategies",
  748. status="active",
  749. )
  750. for strategy in concern_strategies:
  751. strategy_id = str(strategy.get("id") or "").strip()
  752. if not strategy_id:
  753. continue
  754. upsert_strategy_assignment(
  755. id=f"assign:{seeded_group_id}:{strategy_id}",
  756. strategy_group_id=seeded_group_id,
  757. strategy_id=strategy_id,
  758. strategy_type=str(strategy.get("strategy_type") or "").strip() or None,
  759. role="member",
  760. status="active",
  761. notes="auto-seeded from trader inventory",
  762. )
  763. existing_groups = list_strategy_groups(concern_id=concern_id)
  764. playbooks = []
  765. active_playbook_profile_id = None
  766. for group in existing_groups:
  767. assignments = list_strategy_assignments(strategy_group_id=str(group.get("id") or ""))
  768. if str(group.get("strategy_family") or "").strip().lower() == "mixed" and assignments:
  769. assigned_strategies = [
  770. strategies_by_id.get(str(a.get("strategy_id") or "").strip(), {"strategy_type": a.get("strategy_type")})
  771. for a in assignments
  772. ]
  773. inferred_family = _default_playbook_family(assigned_strategies)
  774. if inferred_family != "mixed":
  775. upsert_strategy_group(
  776. id=str(group.get("id") or ""),
  777. concern_id=concern_id,
  778. name=str(group.get("name") or group.get("id") or "playbook"),
  779. strategy_family=inferred_family,
  780. decision_profile_id=str(group.get("decision_profile_id") or "").strip() or None,
  781. notes=str(group.get("notes") or "").strip() or None,
  782. status=str(group.get("status") or "active"),
  783. )
  784. group = {**group, "strategy_family": inferred_family}
  785. group_profile_id = str(group.get("decision_profile_id") or "").strip()
  786. if not group_profile_id:
  787. group_profile_id = f"profile:{concern_id}:{str(group.get('id') or '').strip() or 'default'}"
  788. _ensure_profile_for_family(
  789. profile_id=group_profile_id,
  790. family=str(group.get("strategy_family") or ""),
  791. name=f"{str(group.get('name') or group.get('id') or 'playbook')} profile",
  792. description="Auto-created for this playbook.",
  793. status="active",
  794. )
  795. upsert_strategy_group(
  796. id=str(group.get("id") or ""),
  797. concern_id=concern_id,
  798. name=str(group.get("name") or group.get("id") or "playbook"),
  799. strategy_family=str(group.get("strategy_family") or "").strip() or None,
  800. decision_profile_id=group_profile_id,
  801. notes=str(group.get("notes") or "").strip() or None,
  802. status=str(group.get("status") or "active"),
  803. )
  804. group = {**group, "decision_profile_id": group_profile_id}
  805. else:
  806. _ensure_profile_for_family(
  807. profile_id=group_profile_id,
  808. family=str(group.get("strategy_family") or ""),
  809. name=f"{str(group.get('name') or group.get('id') or 'playbook')} profile",
  810. description="Auto-created for this playbook.",
  811. status="active",
  812. )
  813. if str(group.get("status") or "").lower() == "active" and str(group.get("decision_profile_id") or "").strip():
  814. active_playbook_profile_id = str(group.get("decision_profile_id") or "").strip()
  815. enriched_assignments = []
  816. for assignment in assignments:
  817. strategy = strategies_by_id.get(str(assignment.get("strategy_id") or "").strip(), {})
  818. enriched_assignments.append({
  819. **assignment,
  820. "strategy_label": _strategy_display_label(strategy) if strategy else str(assignment.get("strategy_id") or "").strip(),
  821. })
  822. playbooks.append({**group, "assignments": enriched_assignments})
  823. concern_strategies = [{**s, "display_label": _strategy_display_label(s)} for s in concern_strategies]
  824. if active_playbook_profile_id and profile_id != active_playbook_profile_id:
  825. upsert_concern(
  826. id=str(concern.get("id") or ""),
  827. account_id=account_id or None,
  828. market_symbol=market_symbol or None,
  829. base_currency=str(concern.get("base_currency") or "").strip() or None,
  830. quote_currency=str(concern.get("quote_currency") or "").strip() or None,
  831. strategy_id=str(concern.get("strategy_id") or "").strip() or None,
  832. decision_profile_id=active_playbook_profile_id,
  833. source=str(concern.get("source") or "dashboard"),
  834. status=str(concern.get("status") or "active"),
  835. notes=str(concern.get("notes") or "").strip() or None,
  836. )
  837. concern = {**concern, "decision_profile_id": active_playbook_profile_id}
  838. profile_id = active_playbook_profile_id
  839. active_family = next((str(p.get("strategy_family") or "") for p in playbooks if str(p.get("status") or "").lower() == "active"), "")
  840. decision_profile = (
  841. _ensure_profile_for_family(
  842. profile_id=profile_id,
  843. family=active_family,
  844. name=f"{str((next((p for p in playbooks if str(p.get('status') or '').lower() == 'active'), {}) or {}).get('name') or 'playbook')} profile",
  845. description="Auto-created for this playbook.",
  846. status="active",
  847. )
  848. if profile_id else None
  849. )
  850. latest_state = next((s for s in latest_states(200) if str(s.get("concern_id") or "") == concern_id), None)
  851. latest_narrative = next((n for n in latest_narratives(200) if str(n.get("concern_id") or "") == concern_id), None)
  852. latest_decision = next((d for d in latest_decisions(200) if str(d.get("concern_id") or "") == concern_id), None)
  853. latest_regimes = [s for s in recent_regime_samples(500) if str(s.get("concern_id") or "") == concern_id][:24]
  854. return JSONResponse({
  855. "ok": True,
  856. "concern": enriched_concern,
  857. "decision_profile": decision_profile,
  858. "playbooks": playbooks,
  859. "strategies": concern_strategies,
  860. "latest_state": latest_state,
  861. "latest_narrative": latest_narrative,
  862. "latest_decision": latest_decision,
  863. "latest_regimes": latest_regimes,
  864. })
  865. @app.post("/dashboard/concerns/{concern_id}/playbooks/{playbook_id}/activate")
  866. def dashboard_activate_playbook(concern_id: str, playbook_id: str) -> JSONResponse:
  867. concern_id = str(concern_id or "").strip()
  868. playbook_id = str(playbook_id or "").strip()
  869. concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
  870. if not concern:
  871. return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
  872. groups = list_strategy_groups(concern_id=concern_id)
  873. target = next((g for g in groups if str(g.get("id") or "") == playbook_id), None)
  874. if not target:
  875. return JSONResponse({"ok": False, "error": "playbook not found"}, status_code=404)
  876. target_profile_id = str(target.get("decision_profile_id") or "").strip() or f"profile:{concern_id}:{playbook_id}"
  877. _ensure_profile_for_family(
  878. profile_id=target_profile_id,
  879. family=str(target.get("strategy_family") or ""),
  880. name=f"{str(target.get('name') or playbook_id)} profile",
  881. description="Auto-created for this playbook.",
  882. status="active",
  883. )
  884. if str(target.get("decision_profile_id") or "").strip() != target_profile_id:
  885. upsert_strategy_group(
  886. id=str(target.get("id") or ""),
  887. concern_id=concern_id,
  888. name=str(target.get("name") or target.get("id") or "playbook"),
  889. strategy_family=str(target.get("strategy_family") or "").strip() or None,
  890. decision_profile_id=target_profile_id,
  891. notes=str(target.get("notes") or "").strip() or None,
  892. status=str(target.get("status") or "active"),
  893. )
  894. target = {**target, "decision_profile_id": target_profile_id}
  895. for group in groups:
  896. upsert_strategy_group(
  897. id=str(group.get("id") or ""),
  898. concern_id=concern_id,
  899. name=str(group.get("name") or group.get("id") or "playbook"),
  900. strategy_family=str(group.get("strategy_family") or "").strip() or None,
  901. decision_profile_id=(target_profile_id if str(group.get("id") or "") == playbook_id else str(group.get("decision_profile_id") or "").strip() or None),
  902. notes=str(group.get("notes") or "").strip() or None,
  903. status="active" if str(group.get("id") or "") == playbook_id else "standby",
  904. )
  905. upsert_concern(
  906. id=str(concern.get("id") or ""),
  907. account_id=str(concern.get("account_id") or "").strip() or None,
  908. market_symbol=str(concern.get("market_symbol") or "").strip() or None,
  909. base_currency=str(concern.get("base_currency") or "").strip() or None,
  910. quote_currency=str(concern.get("quote_currency") or "").strip() or None,
  911. strategy_id=str(concern.get("strategy_id") or "").strip() or None,
  912. decision_profile_id=target_profile_id,
  913. source=str(concern.get("source") or "dashboard"),
  914. status=str(concern.get("status") or "active"),
  915. notes=str(concern.get("notes") or "").strip() or None,
  916. )
  917. return JSONResponse({"ok": True, "activated_playbook_id": playbook_id})
  918. @app.post("/dashboard/concerns/{concern_id}/playbooks/{playbook_id}/tuning")
  919. async def dashboard_update_playbook_tuning(concern_id: str, playbook_id: str, request: Request) -> JSONResponse:
  920. concern_id = str(concern_id or "").strip()
  921. playbook_id = str(playbook_id or "").strip()
  922. concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
  923. if not concern:
  924. return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
  925. groups = list_strategy_groups(concern_id=concern_id)
  926. target = next((g for g in groups if str(g.get("id") or "") == playbook_id), None)
  927. if not target:
  928. return JSONResponse({"ok": False, "error": "playbook not found"}, status_code=404)
  929. profile_id = str(target.get("decision_profile_id") or "").strip() or f"profile:{concern_id}:{playbook_id}"
  930. if not str(target.get("decision_profile_id") or "").strip():
  931. _ensure_profile_for_family(
  932. profile_id=profile_id,
  933. family=str(target.get("strategy_family") or ""),
  934. name=f"{str(target.get('name') or playbook_id)} profile",
  935. description="Auto-created while saving tuning from the dashboard.",
  936. status="active",
  937. )
  938. upsert_strategy_group(
  939. id=str(target.get("id") or ""),
  940. concern_id=concern_id,
  941. name=str(target.get("name") or playbook_id),
  942. strategy_family=str(target.get("strategy_family") or "").strip() or None,
  943. decision_profile_id=profile_id,
  944. notes=str(target.get("notes") or "").strip() or None,
  945. status=str(target.get("status") or "active"),
  946. )
  947. _ensure_profile_for_family(
  948. profile_id=profile_id,
  949. family=str(target.get("strategy_family") or ""),
  950. name=f"{str(target.get('name') or playbook_id)} profile",
  951. description="Auto-created while saving tuning from the dashboard.",
  952. status="active",
  953. )
  954. if str(target.get("status") or "").lower() == "active" and str(concern.get("decision_profile_id") or "").strip() != profile_id:
  955. upsert_concern(
  956. id=str(concern.get("id") or ""),
  957. account_id=str(concern.get("account_id") or "").strip() or None,
  958. market_symbol=str(concern.get("market_symbol") or "").strip() or None,
  959. base_currency=str(concern.get("base_currency") or "").strip() or None,
  960. quote_currency=str(concern.get("quote_currency") or "").strip() or None,
  961. strategy_id=str(concern.get("strategy_id") or "").strip() or None,
  962. decision_profile_id=profile_id,
  963. source=str(concern.get("source") or "dashboard"),
  964. status=str(concern.get("status") or "active"),
  965. notes=str(concern.get("notes") or "").strip() or None,
  966. )
  967. payload = await request.json()
  968. updates = payload if isinstance(payload, dict) else {}
  969. profile = get_decision_profile(profile_id=profile_id)
  970. if not profile:
  971. return JSONResponse({"ok": False, "error": "decision profile not found"}, status_code=404)
  972. try:
  973. current_config = json.loads(profile.get("config_json") or "{}")
  974. except Exception:
  975. current_config = {}
  976. if not isinstance(current_config, dict):
  977. current_config = {}
  978. allowed_keys = {
  979. "breakout_persistence_min",
  980. "short_term_confirmation_min",
  981. "switch_cost_penalty",
  982. "rebalance_imbalance_threshold",
  983. "force_grid_when_balanced",
  984. "grid_release_threshold",
  985. "trend_cooling_threshold",
  986. "trend_inventory_stress_threshold",
  987. "action_cooldown_seconds",
  988. "estimated_turn_cost_pct",
  989. "micro_trend_weight",
  990. "meso_trend_weight",
  991. "macro_trend_weight",
  992. "persistence_bonus_weight",
  993. "argus_compression_penalty",
  994. "activation_edge_threshold",
  995. "flip_edge_threshold",
  996. "flip_confirmation_gap",
  997. }
  998. merged = _normalize_profile_config(current_config, str(target.get("strategy_family") or ""))
  999. for key, value in updates.items():
  1000. if key not in allowed_keys:
  1001. continue
  1002. if key == "force_grid_when_balanced":
  1003. merged[key] = bool(value)
  1004. continue
  1005. try:
  1006. merged[key] = float(value) if key != "action_cooldown_seconds" else int(float(value))
  1007. except Exception:
  1008. continue
  1009. upsert_decision_profile(
  1010. id=profile_id,
  1011. name=str(profile.get("name") or profile_id),
  1012. description=str(profile.get("description") or "").strip() or None,
  1013. config=merged,
  1014. status=str(profile.get("status") or "active"),
  1015. )
  1016. return JSONResponse({"ok": True, "profile_id": profile_id, "config": merged})
  1017. @app.get("/dashboard/playbooks/data")
  1018. def dashboard_playbooks_data() -> JSONResponse:
  1019. concerns = {str(c.get("id") or ""): c for c in list_concerns()}
  1020. groups = list_strategy_groups()
  1021. out = []
  1022. for group in groups:
  1023. concern = concerns.get(str(group.get("concern_id") or ""), {})
  1024. assignments = list_strategy_assignments(strategy_group_id=str(group.get("id") or ""))
  1025. out.append({
  1026. **group,
  1027. "concern": concern,
  1028. "assignment_count": len(assignments),
  1029. })
  1030. return JSONResponse({"ok": True, "playbooks": out})
  1031. @app.get("/dashboard/playbooks/{playbook_id}/data")
  1032. def dashboard_playbook_detail_data(playbook_id: str) -> JSONResponse:
  1033. playbook_id = str(playbook_id or "").strip()
  1034. group = next((g for g in list_strategy_groups() if str(g.get("id") or "") == playbook_id), None)
  1035. if not group:
  1036. return JSONResponse({"ok": False, "error": "playbook not found"}, status_code=404)
  1037. concern_id = str(group.get("concern_id") or "").strip()
  1038. concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
  1039. if not concern:
  1040. return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
  1041. cfg = load_config()
  1042. account_id = str(concern.get("account_id") or "").strip()
  1043. market_symbol = str(concern.get("market_symbol") or "").strip().lower()
  1044. try:
  1045. strategy_inventory = anyio.run(list_strategies, cfg.trader_url)
  1046. except Exception:
  1047. strategy_inventory = []
  1048. concern_strategies = [
  1049. {**s, "display_label": _strategy_display_label(s)}
  1050. for s in strategy_inventory
  1051. if str(s.get("account_id") or "").strip() == account_id
  1052. and str(s.get("market_symbol") or "").strip().lower() == market_symbol
  1053. ]
  1054. strategies_by_id = {str(s.get("id") or "").strip(): s for s in concern_strategies if str(s.get("id") or "").strip()}
  1055. assignments = []
  1056. raw_assignments = list_strategy_assignments(strategy_group_id=playbook_id)
  1057. if str(group.get("strategy_family") or "").strip().lower() == "mixed" and raw_assignments:
  1058. inferred_family = _default_playbook_family([
  1059. strategies_by_id.get(str(a.get("strategy_id") or "").strip(), {"strategy_type": a.get("strategy_type")})
  1060. for a in raw_assignments
  1061. ])
  1062. if inferred_family != "mixed":
  1063. upsert_strategy_group(
  1064. id=str(group.get("id") or ""),
  1065. concern_id=concern_id,
  1066. name=str(group.get("name") or group.get("id") or "playbook"),
  1067. strategy_family=inferred_family,
  1068. decision_profile_id=str(group.get("decision_profile_id") or concern.get("decision_profile_id") or "").strip() or None,
  1069. notes=str(group.get("notes") or "").strip() or None,
  1070. status=str(group.get("status") or "active"),
  1071. )
  1072. group = {**group, "strategy_family": inferred_family}
  1073. for assignment in raw_assignments:
  1074. strategy = strategies_by_id.get(str(assignment.get("strategy_id") or "").strip(), {})
  1075. assignments.append({
  1076. **assignment,
  1077. "strategy_label": _strategy_display_label(strategy) if strategy else str(assignment.get("strategy_id") or "").strip(),
  1078. })
  1079. profile_id = str(group.get("decision_profile_id") or concern.get("decision_profile_id") or "").strip()
  1080. profile = get_decision_profile(profile_id=profile_id) if profile_id else None
  1081. if profile:
  1082. try:
  1083. profile = {**profile, "config": json.loads(profile.get("config_json") or "{}")}
  1084. except Exception:
  1085. profile = {**profile, "config": {}}
  1086. return JSONResponse({
  1087. "ok": True,
  1088. "playbook": group,
  1089. "concern": concern,
  1090. "decision_profile": profile,
  1091. "assignments": assignments,
  1092. "available_strategies": concern_strategies,
  1093. })
  1094. @app.post("/dashboard/concerns/{concern_id}/playbooks/create")
  1095. async def dashboard_create_playbook(concern_id: str, request: Request) -> JSONResponse:
  1096. concern_id = str(concern_id or "").strip()
  1097. concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
  1098. if not concern:
  1099. return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
  1100. payload = await request.json()
  1101. name = str((payload or {}).get("name") or "").strip()
  1102. strategy_family = str((payload or {}).get("strategy_family") or "manual").strip() or "manual"
  1103. if not name:
  1104. return JSONResponse({"ok": False, "error": "name is required"}, status_code=400)
  1105. playbook_id = f"playbook:{concern_id}:{uuid4().hex[:8]}"
  1106. profile_id = str(concern.get("decision_profile_id") or "").strip() or f"profile:{playbook_id}"
  1107. if not get_decision_profile(profile_id=profile_id):
  1108. upsert_decision_profile(
  1109. id=profile_id,
  1110. name=f"{name} profile",
  1111. description="Auto-created profile for a new playbook.",
  1112. config=_default_profile_config(strategy_family),
  1113. status="active",
  1114. )
  1115. upsert_strategy_group(
  1116. id=playbook_id,
  1117. concern_id=concern_id,
  1118. name=name,
  1119. strategy_family=strategy_family,
  1120. decision_profile_id=profile_id,
  1121. notes="created from dashboard playbooks page",
  1122. status="standby",
  1123. )
  1124. return JSONResponse({"ok": True, "playbook_id": playbook_id})
  1125. @app.post("/dashboard/playbooks/{playbook_id}/assignments/upsert")
  1126. async def dashboard_playbook_assignment_upsert(playbook_id: str, request: Request) -> JSONResponse:
  1127. playbook_id = str(playbook_id or "").strip()
  1128. group = next((g for g in list_strategy_groups() if str(g.get("id") or "") == playbook_id), None)
  1129. if not group:
  1130. return JSONResponse({"ok": False, "error": "playbook not found"}, status_code=404)
  1131. payload = await request.json()
  1132. strategy_id = str((payload or {}).get("strategy_id") or "").strip()
  1133. strategy_type = str((payload or {}).get("strategy_type") or "").strip() or None
  1134. role = str((payload or {}).get("role") or "member").strip() or "member"
  1135. if not strategy_id:
  1136. return JSONResponse({"ok": False, "error": "strategy_id is required"}, status_code=400)
  1137. assignment_id = f"assign:{playbook_id}:{strategy_id}"
  1138. upsert_strategy_assignment(
  1139. id=assignment_id,
  1140. strategy_group_id=playbook_id,
  1141. strategy_id=strategy_id,
  1142. strategy_type=strategy_type,
  1143. role=role,
  1144. status="active",
  1145. notes="managed from dashboard playbook editor",
  1146. )
  1147. return JSONResponse({"ok": True, "assignment_id": assignment_id})
  1148. @app.post("/dashboard/concerns/{concern_id}/status")
  1149. async def dashboard_set_concern_status(concern_id: str, request: Request) -> JSONResponse:
  1150. concern_id = str(concern_id or "").strip()
  1151. concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
  1152. if not concern:
  1153. return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
  1154. payload = await request.json()
  1155. status = str((payload or {}).get("status") or "").strip().lower()
  1156. if status not in {"active", "inactive"}:
  1157. return JSONResponse({"ok": False, "error": "status must be active or inactive"}, status_code=400)
  1158. account_id = str(concern.get("account_id") or "").strip()
  1159. if status == "inactive" and account_id:
  1160. try:
  1161. await trader_cancel_all_orders(cfg.trader_url if (cfg := load_config()) else "", account_id)
  1162. except Exception:
  1163. pass
  1164. upsert_concern(
  1165. id=str(concern.get("id") or ""),
  1166. account_id=account_id or None,
  1167. market_symbol=str(concern.get("market_symbol") or "").strip() or None,
  1168. base_currency=str(concern.get("base_currency") or "").strip() or None,
  1169. quote_currency=str(concern.get("quote_currency") or "").strip() or None,
  1170. strategy_id=str(concern.get("strategy_id") or "").strip() or None,
  1171. decision_profile_id=str(concern.get("decision_profile_id") or "").strip() or None,
  1172. source=str(concern.get("source") or "dashboard"),
  1173. status=status,
  1174. notes=str(concern.get("notes") or "").strip() or None,
  1175. )
  1176. return JSONResponse({"ok": True, "status": status})
  1177. @app.post("/dashboard/playbooks/{playbook_id}/assignments/{assignment_id}/delete")
  1178. def dashboard_playbook_assignment_delete(playbook_id: str, assignment_id: str) -> JSONResponse:
  1179. assignment_id = str(assignment_id or "").strip()
  1180. init_db()
  1181. from .store import _connect # local import to avoid widening the public store API for one dashboard mutation
  1182. with _connect() as conn:
  1183. deleted = conn.execute("delete from strategy_assignments where id = ? and strategy_group_id = ?", (assignment_id, str(playbook_id or "").strip())).rowcount or 0
  1184. if not deleted:
  1185. return JSONResponse({"ok": False, "error": "assignment not found"}, status_code=404)
  1186. return JSONResponse({"ok": True, "deleted": deleted})