|
|
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
|
|
|
from uuid import uuid4
|
|
|
|
|
|
import anyio
|
|
|
-from fastapi import FastAPI
|
|
|
+from fastapi import FastAPI, Request
|
|
|
from fastapi.responses import JSONResponse
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
from mcp.server.transport_security import TransportSecuritySettings
|
|
|
@@ -18,12 +18,13 @@ from mcp.client.sse import sse_client
|
|
|
from .config import load_config
|
|
|
from .argus_client import get_regime as argus_get_regime, get_snapshot as argus_get_snapshot
|
|
|
from .crypto_client import get_price, get_regime
|
|
|
-from .decision_engine import assess_wallet_state, make_decision
|
|
|
+from .decision_engine import assess_wallet_state
|
|
|
+from .decision_families import make_family_decision
|
|
|
from .narrative_engine import build_narrative
|
|
|
from .replay import build_replay_input
|
|
|
from .state_engine import synthesize_state
|
|
|
-from .store import delete_concern, get_state, init_db, list_concerns, 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_cycle, upsert_decision, upsert_narrative, upsert_observation, upsert_regime_sample, upsert_state, latest_states
|
|
|
-from .trader_client import apply_control_decision as trader_apply_control_decision, get_strategy as trader_get_strategy, list_strategies
|
|
|
+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
|
|
|
+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
|
|
|
|
|
|
mcp = FastMCP(
|
|
|
"hermes-mcp",
|
|
|
@@ -76,6 +77,11 @@ def _build_trader_control_payload(*, decision_id: str, concern: dict, decision:
|
|
|
|
|
|
|
|
|
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:
|
|
|
+ if str(concern.get("status") or "active").strip().lower() != "active":
|
|
|
+ return {
|
|
|
+ "dispatch": "blocked",
|
|
|
+ "reason": "concern is inactive",
|
|
|
+ }
|
|
|
if not bool(getattr(decision, "requires_action", False)):
|
|
|
return {"dispatch": "not_required"}
|
|
|
|
|
|
@@ -119,12 +125,57 @@ async def _maybe_dispatch_trader_action(*, cfg: object, decision_id: str, concer
|
|
|
@mcp.tool(description="Return Hermes current state, narrative, uncertainty, and a short self-assessment report.")
|
|
|
def report() -> dict:
|
|
|
state = get_state()
|
|
|
+ cfg = load_config()
|
|
|
+ concerns = list_concerns()
|
|
|
+ groups_by_concern: dict[str, list[dict[str, Any]]] = {}
|
|
|
+ for group in list_strategy_groups():
|
|
|
+ groups_by_concern.setdefault(str(group.get("concern_id") or ""), []).append(group)
|
|
|
+
|
|
|
+ try:
|
|
|
+ accounts_by_id, markets_by_symbol, total_values = anyio.run(_load_exec_enrichment, cfg.exec_url, cfg.crypto_url, concerns)
|
|
|
+ except Exception:
|
|
|
+ accounts_by_id, markets_by_symbol, total_values = {}, {}, {}
|
|
|
+
|
|
|
+ concern_summaries = []
|
|
|
+ for concern in concerns:
|
|
|
+ concern_id = str(concern.get("id") or "")
|
|
|
+ account_id = str(concern.get("account_id") or "").strip()
|
|
|
+ market_symbol = str(concern.get("market_symbol") or "").strip().lower()
|
|
|
+ account_info = accounts_by_id.get(account_id, {})
|
|
|
+ market_info = markets_by_symbol.get(market_symbol, {})
|
|
|
+ groups = groups_by_concern.get(concern_id, [])
|
|
|
+ active_playbook = next((g for g in groups if str(g.get("status") or "").lower() == "active"), None)
|
|
|
+ assignments = list_strategy_assignments(strategy_group_id=str(active_playbook.get("id") or "")) if active_playbook else []
|
|
|
+ concern_summaries.append({
|
|
|
+ "concern_id": concern_id,
|
|
|
+ "account_id": account_id or None,
|
|
|
+ "account": account_info.get("display_name") or account_id or None,
|
|
|
+ "market_symbol": str(concern.get("market_symbol") or "") or None,
|
|
|
+ "market": market_info.get("name") or str(concern.get("market_symbol") or "") or None,
|
|
|
+ "status": str(concern.get("status") or "active"),
|
|
|
+ "active_playbook": {
|
|
|
+ "id": str(active_playbook.get("id") or "") or None,
|
|
|
+ "name": str(active_playbook.get("name") or "") or None,
|
|
|
+ "family": str(active_playbook.get("strategy_family") or "") or None,
|
|
|
+ } if active_playbook else None,
|
|
|
+ "active_strategies": [
|
|
|
+ {
|
|
|
+ "strategy_id": str(a.get("strategy_id") or "") or None,
|
|
|
+ "role": str(a.get("role") or "member") or "member",
|
|
|
+ "strategy_type": str(a.get("strategy_type") or "") or None,
|
|
|
+ }
|
|
|
+ for a in assignments
|
|
|
+ ],
|
|
|
+ "balances": _compact_balances(account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or []),
|
|
|
+ "total_value_usd": total_values.get(account_id) if total_values.get(account_id) is not None else account_info.get("total_value_usd"),
|
|
|
+ })
|
|
|
return {
|
|
|
"status": state.get("status", "stub"),
|
|
|
"thinking": state.get("thinking", "Hermes scaffold is ready."),
|
|
|
"confidence": state.get("confidence", 0.0),
|
|
|
"uncertainty": state.get("uncertainty", ["no live adapters wired yet"]),
|
|
|
"layers": state.get("layers", []),
|
|
|
+ "concerns": concern_summaries,
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -165,6 +216,23 @@ async def lifespan(_: FastAPI):
|
|
|
started = datetime.now(timezone.utc).isoformat()
|
|
|
cycle_id = str(uuid4())
|
|
|
concerns = list_concerns()
|
|
|
+ profile_ids = sorted({str(c.get("decision_profile_id") or "").strip() for c in concerns if str(c.get("decision_profile_id") or "").strip()})
|
|
|
+ decision_profiles = {}
|
|
|
+ for profile_id in profile_ids:
|
|
|
+ profile = get_decision_profile(profile_id=profile_id)
|
|
|
+ if not profile:
|
|
|
+ continue
|
|
|
+ try:
|
|
|
+ profile_config = json.loads(profile.get("config_json") or "{}")
|
|
|
+ except Exception:
|
|
|
+ profile_config = {}
|
|
|
+ if isinstance(profile_config, dict):
|
|
|
+ decision_profiles[profile_id] = {**profile, "config": profile_config}
|
|
|
+ playbook_groups = list_strategy_groups()
|
|
|
+ playbook_assignments = {
|
|
|
+ str(group.get("id") or ""): list_strategy_assignments(strategy_group_id=str(group.get("id") or ""))
|
|
|
+ for group in playbook_groups
|
|
|
+ }
|
|
|
strategy_inventory = cached_strategy_inventory
|
|
|
if _trader_available():
|
|
|
try:
|
|
|
@@ -186,6 +254,10 @@ async def lifespan(_: FastAPI):
|
|
|
except Exception as exc:
|
|
|
_mark_trader_failure(exc)
|
|
|
strategy_inventory = cached_strategy_inventory
|
|
|
+ try:
|
|
|
+ sync_concerns_from_strategies(strategy_inventory)
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
upsert_cycle(id=cycle_id, started_at=started, finished_at=None, status="running", trigger="interval", notes=f"polling {len(concerns)} concerns")
|
|
|
argus_snapshot: dict = {}
|
|
|
argus_regime: dict = {}
|
|
|
@@ -211,6 +283,7 @@ async def lifespan(_: FastAPI):
|
|
|
symbol = _resolve_regime_symbol(concern)
|
|
|
if not symbol:
|
|
|
continue
|
|
|
+ concern_id = str(concern.get("id") or "")
|
|
|
account_id = str(concern.get("account_id") or "").strip()
|
|
|
account_info = {}
|
|
|
if account_id:
|
|
|
@@ -274,9 +347,32 @@ async def lifespan(_: FastAPI):
|
|
|
price=float(latest_price) if latest_price is not None else None,
|
|
|
strategies=strategy_inventory,
|
|
|
)
|
|
|
+ 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)
|
|
|
+ assignment_by_strategy_id = {
|
|
|
+ str(a.get("strategy_id") or "").strip(): a
|
|
|
+ for a in playbook_assignments.get(str(active_playbook.get("id") or ""), [])
|
|
|
+ if str(a.get("strategy_id") or "").strip()
|
|
|
+ } if active_playbook else {}
|
|
|
+ assigned_strategy_ids = {
|
|
|
+ str(a.get("strategy_id") or "").strip()
|
|
|
+ for a in playbook_assignments.get(str(active_playbook.get("id") or ""), [])
|
|
|
+ if str(a.get("strategy_id") or "").strip()
|
|
|
+ } if active_playbook else set()
|
|
|
+ candidate_strategies = [
|
|
|
+ {
|
|
|
+ **s,
|
|
|
+ "playbook_role": str(assignment_by_strategy_id.get(str(s.get("id") or "").strip(), {}).get("role") or "").strip() or None,
|
|
|
+ "playbook_assignment_id": str(assignment_by_strategy_id.get(str(s.get("id") or "").strip(), {}).get("id") or "").strip() or None,
|
|
|
+ }
|
|
|
+ for s in strategy_inventory
|
|
|
+ if str(s.get("account_id") or "").strip() == account_id
|
|
|
+ and str(s.get("market_symbol") or "").strip().lower() == str(concern.get("market_symbol") or "").strip().lower()
|
|
|
+ and (not assigned_strategy_ids or str(s.get("id") or "").strip() in assigned_strategy_ids)
|
|
|
+ ]
|
|
|
breakout_window_seconds = max(300, int(getattr(cfg, "breakout_memory_window_seconds", 900) or 900))
|
|
|
recent_state_rows = recent_states_for_concern(concern_id=str(concern["id"]), since_seconds=breakout_window_seconds, limit=12)
|
|
|
- decision = make_decision(
|
|
|
+ decision = make_family_decision(
|
|
|
+ family=str(active_playbook.get("strategy_family") or "grid-trend-rebalancer") if active_playbook else "grid-trend-rebalancer",
|
|
|
concern=concern,
|
|
|
narrative_payload={
|
|
|
**state.payload,
|
|
|
@@ -284,11 +380,12 @@ async def lifespan(_: FastAPI):
|
|
|
"confidence": narrative.confidence,
|
|
|
},
|
|
|
wallet_state=wallet_state,
|
|
|
- strategies=strategy_inventory,
|
|
|
+ strategies=candidate_strategies,
|
|
|
history_window={
|
|
|
"window_seconds": breakout_window_seconds,
|
|
|
"recent_states": recent_state_rows,
|
|
|
},
|
|
|
+ decision_profile=decision_profiles.get(str(concern.get("decision_profile_id") or "").strip()),
|
|
|
)
|
|
|
decision_id = f"{cycle_id}:{concern['id']}"
|
|
|
dispatch_record = await _maybe_dispatch_trader_action(
|
|
|
@@ -305,17 +402,20 @@ async def lifespan(_: FastAPI):
|
|
|
concern=concern,
|
|
|
narrative_payload={
|
|
|
**state.payload,
|
|
|
- **narrative.payload,
|
|
|
- "confidence": narrative.confidence,
|
|
|
- },
|
|
|
- wallet_state=wallet_state,
|
|
|
- strategies=strategy_inventory,
|
|
|
- history_window={
|
|
|
- "window_seconds": breakout_window_seconds,
|
|
|
- "recent_states": recent_state_rows,
|
|
|
- },
|
|
|
+ **narrative.payload,
|
|
|
+ "confidence": narrative.confidence,
|
|
|
+ },
|
|
|
+ wallet_state=wallet_state,
|
|
|
+ strategies=candidate_strategies,
|
|
|
+ history_window={
|
|
|
+ "window_seconds": breakout_window_seconds,
|
|
|
+ "recent_states": recent_state_rows,
|
|
|
+ },
|
|
|
),
|
|
|
"dispatch": dispatch_record,
|
|
|
+ "decision_family": str(active_playbook.get("strategy_family") or "grid-trend-rebalancer") if active_playbook else "grid-trend-rebalancer",
|
|
|
+ "active_playbook_id": str(active_playbook.get("id") or "") if active_playbook else None,
|
|
|
+ "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 "")],
|
|
|
}
|
|
|
upsert_decision(
|
|
|
id=decision_id,
|
|
|
@@ -467,6 +567,107 @@ def _resolve_regime_symbol(concern: dict) -> str | None:
|
|
|
return market or None
|
|
|
|
|
|
|
|
|
+def _default_playbook_name(strategies: list[dict]) -> str:
|
|
|
+ types = {str(s.get("strategy_type") or "").strip() for s in strategies}
|
|
|
+ if {"grid_trader", "trend_follower", "exposure_protector"}.issubset(types):
|
|
|
+ return "grid-trend-rebalancer"
|
|
|
+ if types == {"trend_follower"} or ("trend_follower" in types and "grid_trader" not in types and "exposure_protector" not in types):
|
|
|
+ return "trend-only"
|
|
|
+ labels = sorted(t.replace("_", "-") for t in types if t)
|
|
|
+ return "+".join(labels) if labels else "playbook"
|
|
|
+
|
|
|
+
|
|
|
+def _default_playbook_family(strategies: list[dict]) -> str:
|
|
|
+ types = {str(s.get("strategy_type") or "").strip() for s in strategies}
|
|
|
+ if {"grid_trader", "trend_follower", "exposure_protector"}.issubset(types):
|
|
|
+ return "grid-trend-rebalancer"
|
|
|
+ if "trend_follower" in types and "grid_trader" not in types and "exposure_protector" not in types:
|
|
|
+ return "trend-only"
|
|
|
+ return "mixed"
|
|
|
+
|
|
|
+
|
|
|
+def _default_profile_config(family: str | None = None) -> dict[str, object]:
|
|
|
+ normalized = str(family or "").strip().lower()
|
|
|
+ if normalized in {"trend-only", "trend_only", "trend"}:
|
|
|
+ return {
|
|
|
+ "estimated_turn_cost_pct": 0.7,
|
|
|
+ "micro_trend_weight": 0.8,
|
|
|
+ "meso_trend_weight": 1.0,
|
|
|
+ "macro_trend_weight": 0.7,
|
|
|
+ "persistence_bonus_weight": 0.45,
|
|
|
+ "argus_compression_penalty": 0.18,
|
|
|
+ "activation_edge_threshold": 1.15,
|
|
|
+ "flip_edge_threshold": 1.35,
|
|
|
+ "flip_confirmation_gap": 0.25,
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ "breakout_persistence_min": 0.65,
|
|
|
+ "short_term_confirmation_min": 0.32,
|
|
|
+ "switch_cost_penalty": 1.0,
|
|
|
+ "rebalance_imbalance_threshold": 0.30,
|
|
|
+ "force_grid_when_balanced": True,
|
|
|
+ "grid_release_threshold": 0.35,
|
|
|
+ "trend_cooling_threshold": 0.45,
|
|
|
+ "trend_inventory_stress_threshold": 0.55,
|
|
|
+ "action_cooldown_seconds": 600,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _profile_allowed_keys(family: str | None = None) -> set[str]:
|
|
|
+ return set(_default_profile_config(family).keys())
|
|
|
+
|
|
|
+
|
|
|
+def _normalize_profile_config(config: dict[str, object] | None, family: str | None = None) -> dict[str, object]:
|
|
|
+ defaults = _default_profile_config(family)
|
|
|
+ allowed = _profile_allowed_keys(family)
|
|
|
+ current = config if isinstance(config, dict) else {}
|
|
|
+ return {**defaults, **{k: v for k, v in current.items() if k in allowed}}
|
|
|
+
|
|
|
+
|
|
|
+def _ensure_profile_for_family(*, profile_id: str, family: str | None, name: str, description: str | None = None, status: str = "active") -> dict[str, Any]:
|
|
|
+ family_label = str(family or "").strip() or "playbook"
|
|
|
+ profile = get_decision_profile(profile_id=profile_id)
|
|
|
+ config: dict[str, object] = {}
|
|
|
+ if profile:
|
|
|
+ try:
|
|
|
+ raw = json.loads(profile.get("config_json") or "{}")
|
|
|
+ except Exception:
|
|
|
+ raw = {}
|
|
|
+ config = _normalize_profile_config(raw if isinstance(raw, dict) else {}, family)
|
|
|
+ current_name = str(profile.get("name") or "").strip()
|
|
|
+ generic_names = {"grid-trend-rebalancer profile", "trend-only profile", "playbook profile"}
|
|
|
+ profile_name = name if not current_name or current_name in generic_names else current_name
|
|
|
+ upsert_decision_profile(
|
|
|
+ id=profile_id,
|
|
|
+ name=profile_name,
|
|
|
+ description=str(profile.get("description") or description or "").strip() or None,
|
|
|
+ config=config,
|
|
|
+ status=str(profile.get("status") or status or "active"),
|
|
|
+ )
|
|
|
+ return {**profile, "name": profile_name, "config": config}
|
|
|
+
|
|
|
+ config = _default_profile_config(family)
|
|
|
+ upsert_decision_profile(
|
|
|
+ id=profile_id,
|
|
|
+ name=name or f"{family_label} profile",
|
|
|
+ description=description,
|
|
|
+ config=config,
|
|
|
+ status=status,
|
|
|
+ )
|
|
|
+ created = get_decision_profile(profile_id=profile_id) or {"id": profile_id, "name": name, "description": description, "status": status}
|
|
|
+ return {**created, "config": config}
|
|
|
+
|
|
|
+
|
|
|
+def _strategy_display_label(strategy: dict) -> str:
|
|
|
+ for key in ("label", "display_name", "name", "title"):
|
|
|
+ value = str(strategy.get(key) or "").strip()
|
|
|
+ if value:
|
|
|
+ return value
|
|
|
+ strategy_type = str(strategy.get("strategy_type") or "strategy").strip().replace("_", " ")
|
|
|
+ instance_id = str(strategy.get("id") or "").strip()
|
|
|
+ return f"{strategy_type} ({instance_id[:8]})" if instance_id else strategy_type
|
|
|
+
|
|
|
+
|
|
|
@app.get("/dashboard/data")
|
|
|
def dashboard_data() -> JSONResponse:
|
|
|
cfg = load_config()
|
|
|
@@ -537,3 +738,565 @@ def dashboard_data() -> JSONResponse:
|
|
|
"decision_samples": latest_decisions(20),
|
|
|
"decision_history": latest_decisions(100),
|
|
|
})
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/dashboard/concerns/{concern_id}/data")
|
|
|
+def dashboard_concern_detail_data(concern_id: str) -> JSONResponse:
|
|
|
+ cfg = load_config()
|
|
|
+ concern_id = str(concern_id or "").strip()
|
|
|
+ concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
|
|
|
+ if not concern:
|
|
|
+ return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
|
|
|
+
|
|
|
+ account_id = str(concern.get("account_id") or "").strip()
|
|
|
+ market_symbol = str(concern.get("market_symbol") or "").strip().lower()
|
|
|
+ concerns = [concern]
|
|
|
+
|
|
|
+ try:
|
|
|
+ accounts_by_id, markets_by_symbol, total_values = anyio.run(_load_exec_enrichment, cfg.exec_url, cfg.crypto_url, concerns)
|
|
|
+ except Exception:
|
|
|
+ accounts_by_id, markets_by_symbol, total_values = {}, {}, {}
|
|
|
+
|
|
|
+ account_info = accounts_by_id.get(account_id, {})
|
|
|
+ market_info = markets_by_symbol.get(market_symbol, {})
|
|
|
+ enriched_concern = {
|
|
|
+ **concern,
|
|
|
+ "account_display": account_info.get("display_name") or account_id,
|
|
|
+ "balances": account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or [],
|
|
|
+ "balance_summary": _compact_balances(account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or []),
|
|
|
+ "total_value_usd": total_values.get(account_id) if total_values.get(account_id) is not None else account_info.get("total_value_usd"),
|
|
|
+ "market_display": market_info.get("name") or concern.get("market_symbol") or "",
|
|
|
+ "market_description": market_info.get("description") or "",
|
|
|
+ }
|
|
|
+
|
|
|
+ try:
|
|
|
+ strategy_inventory = anyio.run(list_strategies, cfg.trader_url)
|
|
|
+ except Exception:
|
|
|
+ strategy_inventory = []
|
|
|
+ concern_strategies = [
|
|
|
+ s for s in strategy_inventory
|
|
|
+ if str(s.get("account_id") or "").strip() == account_id
|
|
|
+ and str(s.get("market_symbol") or "").strip().lower() == market_symbol
|
|
|
+ ]
|
|
|
+ strategies_by_id = {str(s.get("id") or "").strip(): s for s in concern_strategies if str(s.get("id") or "").strip()}
|
|
|
+ profile_id = str(concern.get("decision_profile_id") or "").strip()
|
|
|
+
|
|
|
+ existing_groups = list_strategy_groups(concern_id=concern_id)
|
|
|
+ if not existing_groups and concern_strategies:
|
|
|
+ seeded_group_id = f"playbook:{concern_id}:default"
|
|
|
+ seeded_family = _default_playbook_family(concern_strategies)
|
|
|
+ seeded_profile_id = profile_id or f"profile:{concern_id}:default"
|
|
|
+ if not profile_id:
|
|
|
+ upsert_decision_profile(
|
|
|
+ id=seeded_profile_id,
|
|
|
+ name=f"{_default_playbook_name(concern_strategies)} profile",
|
|
|
+ description="Auto-seeded default profile for this concern.",
|
|
|
+ config=_default_profile_config(seeded_family),
|
|
|
+ status="active",
|
|
|
+ )
|
|
|
+ upsert_concern(
|
|
|
+ id=str(concern.get("id") or ""),
|
|
|
+ account_id=account_id or None,
|
|
|
+ market_symbol=market_symbol or None,
|
|
|
+ base_currency=str(concern.get("base_currency") or "").strip() or None,
|
|
|
+ quote_currency=str(concern.get("quote_currency") or "").strip() or None,
|
|
|
+ strategy_id=str(concern.get("strategy_id") or "").strip() or None,
|
|
|
+ decision_profile_id=seeded_profile_id,
|
|
|
+ source=str(concern.get("source") or "dashboard"),
|
|
|
+ status=str(concern.get("status") or "active"),
|
|
|
+ notes=str(concern.get("notes") or "").strip() or None,
|
|
|
+ )
|
|
|
+ concern = {**concern, "decision_profile_id": seeded_profile_id}
|
|
|
+ profile_id = seeded_profile_id
|
|
|
+ upsert_strategy_group(
|
|
|
+ id=seeded_group_id,
|
|
|
+ concern_id=concern_id,
|
|
|
+ name=_default_playbook_name(concern_strategies),
|
|
|
+ strategy_family=seeded_family,
|
|
|
+ decision_profile_id=profile_id or None,
|
|
|
+ notes="auto-seeded from trader strategies",
|
|
|
+ status="active",
|
|
|
+ )
|
|
|
+ for strategy in concern_strategies:
|
|
|
+ strategy_id = str(strategy.get("id") or "").strip()
|
|
|
+ if not strategy_id:
|
|
|
+ continue
|
|
|
+ upsert_strategy_assignment(
|
|
|
+ id=f"assign:{seeded_group_id}:{strategy_id}",
|
|
|
+ strategy_group_id=seeded_group_id,
|
|
|
+ strategy_id=strategy_id,
|
|
|
+ strategy_type=str(strategy.get("strategy_type") or "").strip() or None,
|
|
|
+ role="member",
|
|
|
+ status="active",
|
|
|
+ notes="auto-seeded from trader inventory",
|
|
|
+ )
|
|
|
+ existing_groups = list_strategy_groups(concern_id=concern_id)
|
|
|
+
|
|
|
+ playbooks = []
|
|
|
+ active_playbook_profile_id = None
|
|
|
+ for group in existing_groups:
|
|
|
+ assignments = list_strategy_assignments(strategy_group_id=str(group.get("id") or ""))
|
|
|
+ if str(group.get("strategy_family") or "").strip().lower() == "mixed" and assignments:
|
|
|
+ assigned_strategies = [
|
|
|
+ strategies_by_id.get(str(a.get("strategy_id") or "").strip(), {"strategy_type": a.get("strategy_type")})
|
|
|
+ for a in assignments
|
|
|
+ ]
|
|
|
+ inferred_family = _default_playbook_family(assigned_strategies)
|
|
|
+ if inferred_family != "mixed":
|
|
|
+ upsert_strategy_group(
|
|
|
+ id=str(group.get("id") or ""),
|
|
|
+ concern_id=concern_id,
|
|
|
+ name=str(group.get("name") or group.get("id") or "playbook"),
|
|
|
+ strategy_family=inferred_family,
|
|
|
+ decision_profile_id=str(group.get("decision_profile_id") or "").strip() or None,
|
|
|
+ notes=str(group.get("notes") or "").strip() or None,
|
|
|
+ status=str(group.get("status") or "active"),
|
|
|
+ )
|
|
|
+ group = {**group, "strategy_family": inferred_family}
|
|
|
+ group_profile_id = str(group.get("decision_profile_id") or "").strip()
|
|
|
+ if not group_profile_id:
|
|
|
+ group_profile_id = f"profile:{concern_id}:{str(group.get('id') or '').strip() or 'default'}"
|
|
|
+ _ensure_profile_for_family(
|
|
|
+ profile_id=group_profile_id,
|
|
|
+ family=str(group.get("strategy_family") or ""),
|
|
|
+ name=f"{str(group.get('name') or group.get('id') or 'playbook')} profile",
|
|
|
+ description="Auto-created for this playbook.",
|
|
|
+ status="active",
|
|
|
+ )
|
|
|
+ upsert_strategy_group(
|
|
|
+ id=str(group.get("id") or ""),
|
|
|
+ concern_id=concern_id,
|
|
|
+ name=str(group.get("name") or group.get("id") or "playbook"),
|
|
|
+ strategy_family=str(group.get("strategy_family") or "").strip() or None,
|
|
|
+ decision_profile_id=group_profile_id,
|
|
|
+ notes=str(group.get("notes") or "").strip() or None,
|
|
|
+ status=str(group.get("status") or "active"),
|
|
|
+ )
|
|
|
+ group = {**group, "decision_profile_id": group_profile_id}
|
|
|
+ else:
|
|
|
+ _ensure_profile_for_family(
|
|
|
+ profile_id=group_profile_id,
|
|
|
+ family=str(group.get("strategy_family") or ""),
|
|
|
+ name=f"{str(group.get('name') or group.get('id') or 'playbook')} profile",
|
|
|
+ description="Auto-created for this playbook.",
|
|
|
+ status="active",
|
|
|
+ )
|
|
|
+ if str(group.get("status") or "").lower() == "active" and str(group.get("decision_profile_id") or "").strip():
|
|
|
+ active_playbook_profile_id = str(group.get("decision_profile_id") or "").strip()
|
|
|
+ enriched_assignments = []
|
|
|
+ for assignment in assignments:
|
|
|
+ strategy = strategies_by_id.get(str(assignment.get("strategy_id") or "").strip(), {})
|
|
|
+ enriched_assignments.append({
|
|
|
+ **assignment,
|
|
|
+ "strategy_label": _strategy_display_label(strategy) if strategy else str(assignment.get("strategy_id") or "").strip(),
|
|
|
+ })
|
|
|
+ playbooks.append({**group, "assignments": enriched_assignments})
|
|
|
+
|
|
|
+ concern_strategies = [{**s, "display_label": _strategy_display_label(s)} for s in concern_strategies]
|
|
|
+
|
|
|
+ if active_playbook_profile_id and profile_id != active_playbook_profile_id:
|
|
|
+ upsert_concern(
|
|
|
+ id=str(concern.get("id") or ""),
|
|
|
+ account_id=account_id or None,
|
|
|
+ market_symbol=market_symbol or None,
|
|
|
+ base_currency=str(concern.get("base_currency") or "").strip() or None,
|
|
|
+ quote_currency=str(concern.get("quote_currency") or "").strip() or None,
|
|
|
+ strategy_id=str(concern.get("strategy_id") or "").strip() or None,
|
|
|
+ decision_profile_id=active_playbook_profile_id,
|
|
|
+ source=str(concern.get("source") or "dashboard"),
|
|
|
+ status=str(concern.get("status") or "active"),
|
|
|
+ notes=str(concern.get("notes") or "").strip() or None,
|
|
|
+ )
|
|
|
+ concern = {**concern, "decision_profile_id": active_playbook_profile_id}
|
|
|
+ profile_id = active_playbook_profile_id
|
|
|
+
|
|
|
+ active_family = next((str(p.get("strategy_family") or "") for p in playbooks if str(p.get("status") or "").lower() == "active"), "")
|
|
|
+ decision_profile = (
|
|
|
+ _ensure_profile_for_family(
|
|
|
+ profile_id=profile_id,
|
|
|
+ family=active_family,
|
|
|
+ name=f"{str((next((p for p in playbooks if str(p.get('status') or '').lower() == 'active'), {}) or {}).get('name') or 'playbook')} profile",
|
|
|
+ description="Auto-created for this playbook.",
|
|
|
+ status="active",
|
|
|
+ )
|
|
|
+ if profile_id else None
|
|
|
+ )
|
|
|
+
|
|
|
+ latest_state = next((s for s in latest_states(200) if str(s.get("concern_id") or "") == concern_id), None)
|
|
|
+ latest_narrative = next((n for n in latest_narratives(200) if str(n.get("concern_id") or "") == concern_id), None)
|
|
|
+ latest_decision = next((d for d in latest_decisions(200) if str(d.get("concern_id") or "") == concern_id), None)
|
|
|
+ latest_regimes = [s for s in recent_regime_samples(500) if str(s.get("concern_id") or "") == concern_id][:24]
|
|
|
+
|
|
|
+ return JSONResponse({
|
|
|
+ "ok": True,
|
|
|
+ "concern": enriched_concern,
|
|
|
+ "decision_profile": decision_profile,
|
|
|
+ "playbooks": playbooks,
|
|
|
+ "strategies": concern_strategies,
|
|
|
+ "latest_state": latest_state,
|
|
|
+ "latest_narrative": latest_narrative,
|
|
|
+ "latest_decision": latest_decision,
|
|
|
+ "latest_regimes": latest_regimes,
|
|
|
+ })
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/dashboard/concerns/{concern_id}/playbooks/{playbook_id}/activate")
|
|
|
+def dashboard_activate_playbook(concern_id: str, playbook_id: str) -> JSONResponse:
|
|
|
+ concern_id = str(concern_id or "").strip()
|
|
|
+ playbook_id = str(playbook_id or "").strip()
|
|
|
+ concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
|
|
|
+ if not concern:
|
|
|
+ return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
|
|
|
+
|
|
|
+ groups = list_strategy_groups(concern_id=concern_id)
|
|
|
+ target = next((g for g in groups if str(g.get("id") or "") == playbook_id), None)
|
|
|
+ if not target:
|
|
|
+ return JSONResponse({"ok": False, "error": "playbook not found"}, status_code=404)
|
|
|
+
|
|
|
+ target_profile_id = str(target.get("decision_profile_id") or "").strip() or f"profile:{concern_id}:{playbook_id}"
|
|
|
+ _ensure_profile_for_family(
|
|
|
+ profile_id=target_profile_id,
|
|
|
+ family=str(target.get("strategy_family") or ""),
|
|
|
+ name=f"{str(target.get('name') or playbook_id)} profile",
|
|
|
+ description="Auto-created for this playbook.",
|
|
|
+ status="active",
|
|
|
+ )
|
|
|
+ if str(target.get("decision_profile_id") or "").strip() != target_profile_id:
|
|
|
+ upsert_strategy_group(
|
|
|
+ id=str(target.get("id") or ""),
|
|
|
+ concern_id=concern_id,
|
|
|
+ name=str(target.get("name") or target.get("id") or "playbook"),
|
|
|
+ strategy_family=str(target.get("strategy_family") or "").strip() or None,
|
|
|
+ decision_profile_id=target_profile_id,
|
|
|
+ notes=str(target.get("notes") or "").strip() or None,
|
|
|
+ status=str(target.get("status") or "active"),
|
|
|
+ )
|
|
|
+ target = {**target, "decision_profile_id": target_profile_id}
|
|
|
+
|
|
|
+ for group in groups:
|
|
|
+ upsert_strategy_group(
|
|
|
+ id=str(group.get("id") or ""),
|
|
|
+ concern_id=concern_id,
|
|
|
+ name=str(group.get("name") or group.get("id") or "playbook"),
|
|
|
+ strategy_family=str(group.get("strategy_family") or "").strip() or None,
|
|
|
+ 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),
|
|
|
+ notes=str(group.get("notes") or "").strip() or None,
|
|
|
+ status="active" if str(group.get("id") or "") == playbook_id else "standby",
|
|
|
+ )
|
|
|
+
|
|
|
+ upsert_concern(
|
|
|
+ id=str(concern.get("id") or ""),
|
|
|
+ account_id=str(concern.get("account_id") or "").strip() or None,
|
|
|
+ market_symbol=str(concern.get("market_symbol") or "").strip() or None,
|
|
|
+ base_currency=str(concern.get("base_currency") or "").strip() or None,
|
|
|
+ quote_currency=str(concern.get("quote_currency") or "").strip() or None,
|
|
|
+ strategy_id=str(concern.get("strategy_id") or "").strip() or None,
|
|
|
+ decision_profile_id=target_profile_id,
|
|
|
+ source=str(concern.get("source") or "dashboard"),
|
|
|
+ status=str(concern.get("status") or "active"),
|
|
|
+ notes=str(concern.get("notes") or "").strip() or None,
|
|
|
+ )
|
|
|
+
|
|
|
+ return JSONResponse({"ok": True, "activated_playbook_id": playbook_id})
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/dashboard/concerns/{concern_id}/playbooks/{playbook_id}/tuning")
|
|
|
+async def dashboard_update_playbook_tuning(concern_id: str, playbook_id: str, request: Request) -> JSONResponse:
|
|
|
+ concern_id = str(concern_id or "").strip()
|
|
|
+ playbook_id = str(playbook_id or "").strip()
|
|
|
+ concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
|
|
|
+ if not concern:
|
|
|
+ return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
|
|
|
+ groups = list_strategy_groups(concern_id=concern_id)
|
|
|
+ target = next((g for g in groups if str(g.get("id") or "") == playbook_id), None)
|
|
|
+ if not target:
|
|
|
+ return JSONResponse({"ok": False, "error": "playbook not found"}, status_code=404)
|
|
|
+
|
|
|
+ profile_id = str(target.get("decision_profile_id") or "").strip() or f"profile:{concern_id}:{playbook_id}"
|
|
|
+ if not str(target.get("decision_profile_id") or "").strip():
|
|
|
+ _ensure_profile_for_family(
|
|
|
+ profile_id=profile_id,
|
|
|
+ family=str(target.get("strategy_family") or ""),
|
|
|
+ name=f"{str(target.get('name') or playbook_id)} profile",
|
|
|
+ description="Auto-created while saving tuning from the dashboard.",
|
|
|
+ status="active",
|
|
|
+ )
|
|
|
+ upsert_strategy_group(
|
|
|
+ id=str(target.get("id") or ""),
|
|
|
+ concern_id=concern_id,
|
|
|
+ name=str(target.get("name") or playbook_id),
|
|
|
+ strategy_family=str(target.get("strategy_family") or "").strip() or None,
|
|
|
+ decision_profile_id=profile_id,
|
|
|
+ notes=str(target.get("notes") or "").strip() or None,
|
|
|
+ status=str(target.get("status") or "active"),
|
|
|
+ )
|
|
|
+ _ensure_profile_for_family(
|
|
|
+ profile_id=profile_id,
|
|
|
+ family=str(target.get("strategy_family") or ""),
|
|
|
+ name=f"{str(target.get('name') or playbook_id)} profile",
|
|
|
+ description="Auto-created while saving tuning from the dashboard.",
|
|
|
+ status="active",
|
|
|
+ )
|
|
|
+
|
|
|
+ if str(target.get("status") or "").lower() == "active" and str(concern.get("decision_profile_id") or "").strip() != profile_id:
|
|
|
+ upsert_concern(
|
|
|
+ id=str(concern.get("id") or ""),
|
|
|
+ account_id=str(concern.get("account_id") or "").strip() or None,
|
|
|
+ market_symbol=str(concern.get("market_symbol") or "").strip() or None,
|
|
|
+ base_currency=str(concern.get("base_currency") or "").strip() or None,
|
|
|
+ quote_currency=str(concern.get("quote_currency") or "").strip() or None,
|
|
|
+ strategy_id=str(concern.get("strategy_id") or "").strip() or None,
|
|
|
+ decision_profile_id=profile_id,
|
|
|
+ source=str(concern.get("source") or "dashboard"),
|
|
|
+ status=str(concern.get("status") or "active"),
|
|
|
+ notes=str(concern.get("notes") or "").strip() or None,
|
|
|
+ )
|
|
|
+
|
|
|
+ payload = await request.json()
|
|
|
+ updates = payload if isinstance(payload, dict) else {}
|
|
|
+ profile = get_decision_profile(profile_id=profile_id)
|
|
|
+ if not profile:
|
|
|
+ return JSONResponse({"ok": False, "error": "decision profile not found"}, status_code=404)
|
|
|
+
|
|
|
+ try:
|
|
|
+ current_config = json.loads(profile.get("config_json") or "{}")
|
|
|
+ except Exception:
|
|
|
+ current_config = {}
|
|
|
+ if not isinstance(current_config, dict):
|
|
|
+ current_config = {}
|
|
|
+
|
|
|
+ allowed_keys = {
|
|
|
+ "breakout_persistence_min",
|
|
|
+ "short_term_confirmation_min",
|
|
|
+ "switch_cost_penalty",
|
|
|
+ "rebalance_imbalance_threshold",
|
|
|
+ "force_grid_when_balanced",
|
|
|
+ "grid_release_threshold",
|
|
|
+ "trend_cooling_threshold",
|
|
|
+ "trend_inventory_stress_threshold",
|
|
|
+ "action_cooldown_seconds",
|
|
|
+ "estimated_turn_cost_pct",
|
|
|
+ "micro_trend_weight",
|
|
|
+ "meso_trend_weight",
|
|
|
+ "macro_trend_weight",
|
|
|
+ "persistence_bonus_weight",
|
|
|
+ "argus_compression_penalty",
|
|
|
+ "activation_edge_threshold",
|
|
|
+ "flip_edge_threshold",
|
|
|
+ "flip_confirmation_gap",
|
|
|
+ }
|
|
|
+ merged = _normalize_profile_config(current_config, str(target.get("strategy_family") or ""))
|
|
|
+ for key, value in updates.items():
|
|
|
+ if key not in allowed_keys:
|
|
|
+ continue
|
|
|
+ if key == "force_grid_when_balanced":
|
|
|
+ merged[key] = bool(value)
|
|
|
+ continue
|
|
|
+ try:
|
|
|
+ merged[key] = float(value) if key != "action_cooldown_seconds" else int(float(value))
|
|
|
+ except Exception:
|
|
|
+ continue
|
|
|
+
|
|
|
+ upsert_decision_profile(
|
|
|
+ id=profile_id,
|
|
|
+ name=str(profile.get("name") or profile_id),
|
|
|
+ description=str(profile.get("description") or "").strip() or None,
|
|
|
+ config=merged,
|
|
|
+ status=str(profile.get("status") or "active"),
|
|
|
+ )
|
|
|
+ return JSONResponse({"ok": True, "profile_id": profile_id, "config": merged})
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/dashboard/playbooks/data")
|
|
|
+def dashboard_playbooks_data() -> JSONResponse:
|
|
|
+ concerns = {str(c.get("id") or ""): c for c in list_concerns()}
|
|
|
+ groups = list_strategy_groups()
|
|
|
+ out = []
|
|
|
+ for group in groups:
|
|
|
+ concern = concerns.get(str(group.get("concern_id") or ""), {})
|
|
|
+ assignments = list_strategy_assignments(strategy_group_id=str(group.get("id") or ""))
|
|
|
+ out.append({
|
|
|
+ **group,
|
|
|
+ "concern": concern,
|
|
|
+ "assignment_count": len(assignments),
|
|
|
+ })
|
|
|
+ return JSONResponse({"ok": True, "playbooks": out})
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/dashboard/playbooks/{playbook_id}/data")
|
|
|
+def dashboard_playbook_detail_data(playbook_id: str) -> JSONResponse:
|
|
|
+ playbook_id = str(playbook_id or "").strip()
|
|
|
+ group = next((g for g in list_strategy_groups() if str(g.get("id") or "") == playbook_id), None)
|
|
|
+ if not group:
|
|
|
+ return JSONResponse({"ok": False, "error": "playbook not found"}, status_code=404)
|
|
|
+
|
|
|
+ concern_id = str(group.get("concern_id") or "").strip()
|
|
|
+ concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
|
|
|
+ if not concern:
|
|
|
+ return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
|
|
|
+
|
|
|
+ cfg = load_config()
|
|
|
+ account_id = str(concern.get("account_id") or "").strip()
|
|
|
+ market_symbol = str(concern.get("market_symbol") or "").strip().lower()
|
|
|
+ try:
|
|
|
+ strategy_inventory = anyio.run(list_strategies, cfg.trader_url)
|
|
|
+ except Exception:
|
|
|
+ strategy_inventory = []
|
|
|
+ concern_strategies = [
|
|
|
+ {**s, "display_label": _strategy_display_label(s)}
|
|
|
+ for s in strategy_inventory
|
|
|
+ if str(s.get("account_id") or "").strip() == account_id
|
|
|
+ and str(s.get("market_symbol") or "").strip().lower() == market_symbol
|
|
|
+ ]
|
|
|
+ strategies_by_id = {str(s.get("id") or "").strip(): s for s in concern_strategies if str(s.get("id") or "").strip()}
|
|
|
+
|
|
|
+ assignments = []
|
|
|
+ raw_assignments = list_strategy_assignments(strategy_group_id=playbook_id)
|
|
|
+ if str(group.get("strategy_family") or "").strip().lower() == "mixed" and raw_assignments:
|
|
|
+ inferred_family = _default_playbook_family([
|
|
|
+ strategies_by_id.get(str(a.get("strategy_id") or "").strip(), {"strategy_type": a.get("strategy_type")})
|
|
|
+ for a in raw_assignments
|
|
|
+ ])
|
|
|
+ if inferred_family != "mixed":
|
|
|
+ upsert_strategy_group(
|
|
|
+ id=str(group.get("id") or ""),
|
|
|
+ concern_id=concern_id,
|
|
|
+ name=str(group.get("name") or group.get("id") or "playbook"),
|
|
|
+ strategy_family=inferred_family,
|
|
|
+ decision_profile_id=str(group.get("decision_profile_id") or concern.get("decision_profile_id") or "").strip() or None,
|
|
|
+ notes=str(group.get("notes") or "").strip() or None,
|
|
|
+ status=str(group.get("status") or "active"),
|
|
|
+ )
|
|
|
+ group = {**group, "strategy_family": inferred_family}
|
|
|
+ for assignment in raw_assignments:
|
|
|
+ strategy = strategies_by_id.get(str(assignment.get("strategy_id") or "").strip(), {})
|
|
|
+ assignments.append({
|
|
|
+ **assignment,
|
|
|
+ "strategy_label": _strategy_display_label(strategy) if strategy else str(assignment.get("strategy_id") or "").strip(),
|
|
|
+ })
|
|
|
+
|
|
|
+ profile_id = str(group.get("decision_profile_id") or concern.get("decision_profile_id") or "").strip()
|
|
|
+ profile = get_decision_profile(profile_id=profile_id) if profile_id else None
|
|
|
+ if profile:
|
|
|
+ try:
|
|
|
+ profile = {**profile, "config": json.loads(profile.get("config_json") or "{}")}
|
|
|
+ except Exception:
|
|
|
+ profile = {**profile, "config": {}}
|
|
|
+
|
|
|
+ return JSONResponse({
|
|
|
+ "ok": True,
|
|
|
+ "playbook": group,
|
|
|
+ "concern": concern,
|
|
|
+ "decision_profile": profile,
|
|
|
+ "assignments": assignments,
|
|
|
+ "available_strategies": concern_strategies,
|
|
|
+ })
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/dashboard/concerns/{concern_id}/playbooks/create")
|
|
|
+async def dashboard_create_playbook(concern_id: str, request: Request) -> JSONResponse:
|
|
|
+ concern_id = str(concern_id or "").strip()
|
|
|
+ concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
|
|
|
+ if not concern:
|
|
|
+ return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
|
|
|
+
|
|
|
+ payload = await request.json()
|
|
|
+ name = str((payload or {}).get("name") or "").strip()
|
|
|
+ strategy_family = str((payload or {}).get("strategy_family") or "manual").strip() or "manual"
|
|
|
+ if not name:
|
|
|
+ return JSONResponse({"ok": False, "error": "name is required"}, status_code=400)
|
|
|
+
|
|
|
+ playbook_id = f"playbook:{concern_id}:{uuid4().hex[:8]}"
|
|
|
+ profile_id = str(concern.get("decision_profile_id") or "").strip() or f"profile:{playbook_id}"
|
|
|
+ if not get_decision_profile(profile_id=profile_id):
|
|
|
+ upsert_decision_profile(
|
|
|
+ id=profile_id,
|
|
|
+ name=f"{name} profile",
|
|
|
+ description="Auto-created profile for a new playbook.",
|
|
|
+ config=_default_profile_config(strategy_family),
|
|
|
+ status="active",
|
|
|
+ )
|
|
|
+
|
|
|
+ upsert_strategy_group(
|
|
|
+ id=playbook_id,
|
|
|
+ concern_id=concern_id,
|
|
|
+ name=name,
|
|
|
+ strategy_family=strategy_family,
|
|
|
+ decision_profile_id=profile_id,
|
|
|
+ notes="created from dashboard playbooks page",
|
|
|
+ status="standby",
|
|
|
+ )
|
|
|
+ return JSONResponse({"ok": True, "playbook_id": playbook_id})
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/dashboard/playbooks/{playbook_id}/assignments/upsert")
|
|
|
+async def dashboard_playbook_assignment_upsert(playbook_id: str, request: Request) -> JSONResponse:
|
|
|
+ playbook_id = str(playbook_id or "").strip()
|
|
|
+ group = next((g for g in list_strategy_groups() if str(g.get("id") or "") == playbook_id), None)
|
|
|
+ if not group:
|
|
|
+ return JSONResponse({"ok": False, "error": "playbook not found"}, status_code=404)
|
|
|
+
|
|
|
+ payload = await request.json()
|
|
|
+ strategy_id = str((payload or {}).get("strategy_id") or "").strip()
|
|
|
+ strategy_type = str((payload or {}).get("strategy_type") or "").strip() or None
|
|
|
+ role = str((payload or {}).get("role") or "member").strip() or "member"
|
|
|
+ if not strategy_id:
|
|
|
+ return JSONResponse({"ok": False, "error": "strategy_id is required"}, status_code=400)
|
|
|
+
|
|
|
+ assignment_id = f"assign:{playbook_id}:{strategy_id}"
|
|
|
+ upsert_strategy_assignment(
|
|
|
+ id=assignment_id,
|
|
|
+ strategy_group_id=playbook_id,
|
|
|
+ strategy_id=strategy_id,
|
|
|
+ strategy_type=strategy_type,
|
|
|
+ role=role,
|
|
|
+ status="active",
|
|
|
+ notes="managed from dashboard playbook editor",
|
|
|
+ )
|
|
|
+ return JSONResponse({"ok": True, "assignment_id": assignment_id})
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/dashboard/concerns/{concern_id}/status")
|
|
|
+async def dashboard_set_concern_status(concern_id: str, request: Request) -> JSONResponse:
|
|
|
+ concern_id = str(concern_id or "").strip()
|
|
|
+ concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
|
|
|
+ if not concern:
|
|
|
+ return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
|
|
|
+
|
|
|
+ payload = await request.json()
|
|
|
+ status = str((payload or {}).get("status") or "").strip().lower()
|
|
|
+ if status not in {"active", "inactive"}:
|
|
|
+ return JSONResponse({"ok": False, "error": "status must be active or inactive"}, status_code=400)
|
|
|
+
|
|
|
+ account_id = str(concern.get("account_id") or "").strip()
|
|
|
+ if status == "inactive" and account_id:
|
|
|
+ try:
|
|
|
+ await trader_cancel_all_orders(cfg.trader_url if (cfg := load_config()) else "", account_id)
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+ upsert_concern(
|
|
|
+ id=str(concern.get("id") or ""),
|
|
|
+ account_id=account_id or None,
|
|
|
+ market_symbol=str(concern.get("market_symbol") or "").strip() or None,
|
|
|
+ base_currency=str(concern.get("base_currency") or "").strip() or None,
|
|
|
+ quote_currency=str(concern.get("quote_currency") or "").strip() or None,
|
|
|
+ strategy_id=str(concern.get("strategy_id") or "").strip() or None,
|
|
|
+ decision_profile_id=str(concern.get("decision_profile_id") or "").strip() or None,
|
|
|
+ source=str(concern.get("source") or "dashboard"),
|
|
|
+ status=status,
|
|
|
+ notes=str(concern.get("notes") or "").strip() or None,
|
|
|
+ )
|
|
|
+ return JSONResponse({"ok": True, "status": status})
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/dashboard/playbooks/{playbook_id}/assignments/{assignment_id}/delete")
|
|
|
+def dashboard_playbook_assignment_delete(playbook_id: str, assignment_id: str) -> JSONResponse:
|
|
|
+ assignment_id = str(assignment_id or "").strip()
|
|
|
+ init_db()
|
|
|
+ from .store import _connect # local import to avoid widening the public store API for one dashboard mutation
|
|
|
+ with _connect() as conn:
|
|
|
+ deleted = conn.execute("delete from strategy_assignments where id = ? and strategy_group_id = ?", (assignment_id, str(playbook_id or "").strip())).rowcount or 0
|
|
|
+ if not deleted:
|
|
|
+ return JSONResponse({"ok": False, "error": "assignment not found"}, status_code=404)
|
|
|
+ return JSONResponse({"ok": True, "deleted": deleted})
|