diff --git a/migrations/027_create_market_states.sql b/migrations/027_create_market_states.sql new file mode 100644 index 0000000..09b3e92 --- /dev/null +++ b/migrations/027_create_market_states.sql @@ -0,0 +1,19 @@ +create table if not exists market_states ( + id bigserial primary key, + instrument_id bigint not null references instruments(id), + snapshot_ts timestamptz not null, + timeframe text not null, + session_code text, + data_freshness text, + candle_age_seconds numeric(18,4), + news_window_state jsonb not null default '{}'::jsonb, + opening_volatility_state jsonb not null default '{}'::jsonb, + account_state jsonb not null default '{}'::jsonb, + position_state jsonb not null default '{}'::jsonb, + meta jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_market_states_instr_snapshot_desc +on market_states (instrument_id, snapshot_ts desc); diff --git a/src/api/__init__.py b/src/api/__init__.py index 12a616f..903f214 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -9,6 +9,26 @@ from src.api.server import route_request, serve_api from src.api.sessions import get_recent_sessions from src.api.signals import get_recent_signals from src.api.trade_plan_candidates import get_recent_trade_plan_candidates +from src.api.trading_runtime import ( + activate_trading_plan, + build_ticket_from_candidate, + cancel_execution, + cancel_trading_plan, + confirm_ticket_only, + execute_ticket, + generate_trading_journal, + generate_trading_plan, + get_latest_trading_plan, + get_trading_candidate_by_id, + get_trading_market_state, + get_trading_plan_by_id, + get_trading_ticket_by_id, + get_weekly_review, + rerun_ticket_risk_check, + run_candidate_confirm_check, + run_trading_watch, + sync_execution, +) from src.api.trading_journal import get_recent_trading_journal, get_trading_journal_summary from src.api.trading_plans import get_recent_trading_plans @@ -21,8 +41,26 @@ __all__ = [ "get_recent_live_operation_audits", "get_recent_trade_plan_candidates", "get_recent_trading_journal", + "get_latest_trading_plan", + "get_trading_market_state", + "get_trading_plan_by_id", + "get_trading_ticket_by_id", "get_trading_journal_summary", "get_recent_trading_plans", + "get_weekly_review", + "generate_trading_journal", + "generate_trading_plan", + "activate_trading_plan", + "cancel_trading_plan", + "run_trading_watch", + "get_trading_candidate_by_id", + "run_candidate_confirm_check", + "build_ticket_from_candidate", + "rerun_ticket_risk_check", + "confirm_ticket_only", + "execute_ticket", + "cancel_execution", + "sync_execution", "get_recent_backtest_summaries", "route_request", "serve_api", diff --git a/src/api/server.py b/src/api/server.py index 58289dd..c64f675 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -13,6 +13,26 @@ from src.api.live_operation_audits import get_recent_live_operation_audits from src.api.sessions import get_recent_sessions from src.api.signals import get_recent_signals from src.api.trade_plan_candidates import get_recent_trade_plan_candidates +from src.api.trading_runtime import ( + activate_trading_plan, + build_ticket_from_candidate, + cancel_execution, + cancel_trading_plan, + confirm_ticket_only, + execute_ticket, + generate_trading_journal, + generate_trading_plan, + get_latest_trading_plan, + get_trading_candidate_by_id, + get_trading_market_state, + get_trading_plan_by_id, + get_trading_ticket_by_id, + get_weekly_review, + rerun_ticket_risk_check, + run_candidate_confirm_check, + run_trading_watch, + sync_execution, +) from src.api.trading_journal import get_recent_trading_journal, get_trading_journal_summary from src.api.trading_plans import get_recent_trading_plans from src.api.training import ( @@ -41,8 +61,26 @@ def build_api_dependencies() -> dict: "sessions": get_recent_sessions, "signals": get_recent_signals, "trade_plan_candidates": get_recent_trade_plan_candidates, + "trading_plan_generate": generate_trading_plan, + "trading_plan_latest": get_latest_trading_plan, + "trading_plan_by_id": get_trading_plan_by_id, + "trading_plan_activate": activate_trading_plan, + "trading_plan_cancel": cancel_trading_plan, + "trading_market_state": get_trading_market_state, + "trading_watch_run": run_trading_watch, + "trading_candidate_by_id": get_trading_candidate_by_id, + "trading_candidate_confirm_check": run_candidate_confirm_check, + "trading_candidate_build_ticket": build_ticket_from_candidate, + "trading_ticket_by_id": get_trading_ticket_by_id, + "trading_ticket_risk_check": rerun_ticket_risk_check, + "trading_ticket_confirm": confirm_ticket_only, + "trading_ticket_execute": execute_ticket, + "trading_execution_cancel": cancel_execution, + "trading_execution_sync": sync_execution, "trading_journal": get_recent_trading_journal, "trading_journal_summary": get_trading_journal_summary, + "trading_journal_generate": generate_trading_journal, + "trading_weekly_review": get_weekly_review, "trading_plans": get_recent_trading_plans, "backtests": get_recent_backtest_summaries, "executions": execution_service.build_execution_dashboard_items, @@ -104,13 +142,92 @@ def route_request( instrument_id = _require_int(query, "instrument_id") limit = _get_int(query, "limit", 20) return 200, {"items": deps["trading_plans"](instrument_id=instrument_id, limit=limit)} + if method == "POST" and path == "/trading/plans/generate": + instrument_id = _require_json_int(body or {}, "instrument_id") + return 200, deps["trading_plan_generate"]( + instrument_id=instrument_id, + bias_tf=_get_body_value(body, "bias_tf", "1m"), + setup_tf=_get_body_value(body, "setup_tf", "1m"), + trigger_tf=_get_body_value(body, "trigger_tf", "1m"), + activate=_get_body_bool(body, "activate", False), + ) + if method == "GET" and path == "/trading/plans/latest": + instrument_id = _require_int(query, "instrument_id") + plan = deps["trading_plan_latest"](instrument_id=instrument_id) + if plan is None: + return 404, {"error": "not_found"} + return 200, plan + if method == "GET" and len(path_parts) == 3 and path_parts[:2] == ["trading", "plans"]: + plan = deps["trading_plan_by_id"](_parse_path_int(path_parts[2], "plan_id")) + if plan is None: + return 404, {"error": "not_found"} + return 200, plan + if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["trading", "plans"] and path_parts[3] == "activate": + plan = deps["trading_plan_activate"](_parse_path_int(path_parts[2], "plan_id")) + if plan is None: + return 404, {"error": "not_found"} + return 200, plan + if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["trading", "plans"] and path_parts[3] == "cancel": + plan = deps["trading_plan_cancel"](_parse_path_int(path_parts[2], "plan_id")) + if plan is None: + return 404, {"error": "not_found"} + return 200, plan + if method == "GET" and path == "/trading/market-state": + instrument_id = _require_int(query, "instrument_id") + timeframe = _get_single(query, "timeframe", "1m") + return 200, deps["trading_market_state"](instrument_id=instrument_id, timeframe=timeframe) + if method == "POST" and path == "/trading/watch/run": + instrument_id = _require_json_int(body or {}, "instrument_id") + candidate = deps["trading_watch_run"]( + instrument_id=instrument_id, + setup_tf=_get_body_value(body, "setup_tf", "1m"), + trigger_tf=_get_body_value(body, "trigger_tf", "1m"), + ) + return 200, candidate or {"candidate_generated": False} + if method == "GET" and path == "/trading/candidates": + instrument_id = _require_int(query, "instrument_id") + limit = _get_int(query, "limit", 20) + return 200, {"items": deps["trade_plan_candidates"](instrument_id=instrument_id, limit=limit)} + if method == "GET" and len(path_parts) == 3 and path_parts[:2] == ["trading", "candidates"]: + candidate = deps["trading_candidate_by_id"](_parse_path_int(path_parts[2], "candidate_id")) + if candidate is None: + return 404, {"error": "not_found"} + return 200, candidate + if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["trading", "candidates"] and path_parts[3] == "confirm-check": + payload = deps["trading_candidate_confirm_check"](_parse_path_int(path_parts[2], "candidate_id")) + if payload is None: + return 404, {"error": "not_found"} + return 200, payload + if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["trading", "candidates"] and path_parts[3] == "build-ticket": + payload = deps["trading_candidate_build_ticket"]( + candidate_id=_parse_path_int(path_parts[2], "candidate_id"), + execution_mode=_get_body_value(body, "execution_mode", "semi_auto"), + account_equity=_get_body_float(body, "account_equity", 10000.0), + risk_pct=_get_body_float(body, "risk_pct", 0.01), + ) + if payload is None: + return 404, {"error": "not_found"} + return 200, payload if method == "GET" and path == "/trading-journal": instrument_id = _get_optional_int(query, "instrument_id") limit = _get_int(query, "limit", 20) return 200, {"items": deps["trading_journal"](instrument_id=instrument_id, limit=limit)} + if method == "GET" and path == "/trading/journal": + instrument_id = _get_optional_int(query, "instrument_id") + limit = _get_int(query, "limit", 20) + return 200, {"items": deps["trading_journal"](instrument_id=instrument_id, limit=limit)} if method == "GET" and path == "/trading-journal-summary": instrument_id = _get_optional_int(query, "instrument_id") return 200, deps["trading_journal_summary"](instrument_id=instrument_id) + if method == "GET" and path == "/trading/journal/summary": + instrument_id = _get_optional_int(query, "instrument_id") + return 200, deps["trading_journal_summary"](instrument_id=instrument_id) + if method == "GET" and path == "/trading/research/weekly-review": + instrument_id = _get_optional_int(query, "instrument_id") + return 200, deps["trading_weekly_review"](instrument_id=instrument_id) + if method == "POST" and path == "/trading/journal/generate": + execution_id = _get_body_optional_int(body, "execution_id") + return 200, deps["trading_journal_generate"](execution_id=execution_id) if method == "GET" and path == "/backtests": instrument_id = _get_optional_int(query, "instrument_id") limit = _get_int(query, "limit", 10) @@ -119,6 +236,49 @@ def route_request( instrument_id = _require_int(query, "instrument_id") limit = _get_int(query, "limit", 20) return 200, {"items": deps["executions"](instrument_id=instrument_id, limit=limit)} + if method == "GET" and path == "/trading/tickets": + instrument_id = _get_optional_int(query, "instrument_id") + signal_id = _get_optional_int(query, "signal_id") + if instrument_id is None and signal_id is None: + raise ValueError("missing_required_query_param:instrument_id_or_signal_id") + limit = _get_int(query, "limit", 20) + return 200, {"items": deps["execution_tickets"](instrument_id=instrument_id, signal_id=signal_id, limit=limit)} + if method == "GET" and len(path_parts) == 3 and path_parts[:2] == ["trading", "tickets"]: + ticket = deps["trading_ticket_by_id"](_parse_path_int(path_parts[2], "ticket_id")) + if ticket is None: + return 404, {"error": "not_found"} + return 200, ticket + if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["trading", "tickets"] and path_parts[3] == "risk-check": + payload = deps["trading_ticket_risk_check"]( + _parse_path_int(path_parts[2], "ticket_id"), + account_equity=_get_body_float(body, "account_equity", 10000.0), + risk_pct=_get_body_float(body, "risk_pct", 0.01), + ) + if payload is None: + return 404, {"error": "not_found"} + return 200, payload + if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["trading", "tickets"] and path_parts[3] == "confirm": + payload = deps["trading_ticket_confirm"]( + _parse_path_int(path_parts[2], "ticket_id"), + confirmed_by=_get_body_value(body, "confirmed_by", "local_operator"), + ) + if payload is None: + return 404, {"error": "not_found"} + return 200, payload + if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["trading", "tickets"] and path_parts[3] == "paper-execute": + payload = deps["trading_ticket_execute"]( + _parse_path_int(path_parts[2], "ticket_id"), + confirmed_by=_get_body_value(body, "confirmed_by", "local_operator"), + execution_mode_expected="paper", + ) + return 200, payload + if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["trading", "tickets"] and path_parts[3] == "semi-auto-submit": + payload = deps["trading_ticket_execute"]( + _parse_path_int(path_parts[2], "ticket_id"), + confirmed_by=_get_body_value(body, "confirmed_by", "local_operator"), + execution_mode_expected="semi_auto", + ) + return 200, payload if method == "GET" and path == "/execution-tickets": instrument_id = _get_optional_int(query, "instrument_id") signal_id = _get_optional_int(query, "signal_id") @@ -141,6 +301,16 @@ def route_request( limit=limit, ) } + if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["trading", "executions"] and path_parts[3] == "cancel": + payload = deps["trading_execution_cancel"](_parse_path_int(path_parts[2], "execution_id")) + if payload is None: + return 404, {"error": "not_found"} + return 200, payload + if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["trading", "executions"] and path_parts[3] == "sync": + payload = deps["trading_execution_sync"](_parse_path_int(path_parts[2], "execution_id")) + if payload is None: + return 404, {"error": "not_found"} + return 200, payload if method == "GET" and path == "/training/cases": status = _get_single(query, "status") skill_tag = _get_single(query, "skill_tag") @@ -318,6 +488,36 @@ def _get_limit(query: dict, key: str, default: int, max_value: int) -> int: return parsed +def _get_body_value(body: Optional[dict], key: str, default=None): + if not isinstance(body, dict): + return default + value = body.get(key) + if value is None or value == "": + return default + return value + + +def _get_body_bool(body: Optional[dict], key: str, default: bool) -> bool: + value = _get_body_value(body, key, default) + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _get_body_float(body: Optional[dict], key: str, default: float) -> float: + value = _get_body_value(body, key, default) + return float(value) + + +def _get_body_optional_int(body: Optional[dict], key: str): + if not isinstance(body, dict): + return None + value = body.get(key) + if value is None or value == "": + return None + return int(value) + + def _get_single(query: dict, key: str, default: Optional[str] = None) -> Optional[str]: values = query.get(key) if not values: @@ -384,6 +584,10 @@ def _parse_required_json_identifier(body: dict, key: str) -> int: return parsed +def _require_json_int(body: dict, key: str) -> int: + return _parse_required_json_identifier(body, key) + + def _parse_body_identifier(value, key: str) -> int: if isinstance(value, bool): raise ValueError(f"invalid_body_field:{key}") diff --git a/src/api/trading_runtime.py b/src/api/trading_runtime.py new file mode 100644 index 0000000..4769851 --- /dev/null +++ b/src/api/trading_runtime.py @@ -0,0 +1,332 @@ +from datetime import datetime, timezone +from typing import Optional + +from src.api.execution_tickets import get_recent_execution_tickets +from src.api.trade_plan_candidates import get_recent_trade_plan_candidates, normalize_trade_plan_candidate_row +from src.api.trading_plans import get_recent_trading_plans, normalize_trading_plan_row +from src.repositories.signal_repository import SignalRepository +from src.repositories.trade_plan_candidate_repository import TradePlanCandidateRepository +from src.repositories.trading_plan_repository import TradingPlanRepository +from src.services.execution import ExecutionService +from src.services.journal import TradingJournalService, WeeklyReviewService +from src.services.market_state import MarketStateService +from src.services.trade_plan_candidates import SetupWatcherService +from src.services.trading_plans import ACTIVE_PLAN_STATUS, TradingPlanService + + +def generate_trading_plan( + *, + instrument_id: int, + bias_tf: str = "1m", + setup_tf: str = "1m", + trigger_tf: str = "1m", + activate: bool = False, +) -> dict: + service = TradingPlanService() + candidate = service.build_plan( + instrument_id=instrument_id, + bias_tf=bias_tf, + setup_tf=setup_tf, + trigger_tf=trigger_tf, + ) + plan_id = service.persist_plan(candidate) + row = TradingPlanRepository().fetch_trading_plan_by_id(plan_id) or candidate.to_dict() + payload = normalize_trading_plan_row(dict(row)) + if activate and payload.get("status") == ACTIVE_PLAN_STATUS: + updated = activate_trading_plan(plan_id) + if updated is not None: + payload = updated + return payload + + +def get_latest_trading_plan(instrument_id: int) -> Optional[dict]: + row = TradingPlanRepository().fetch_latest_trading_plan(instrument_id) + return normalize_trading_plan_row(row) if row else None + + +def get_trading_plan_by_id(plan_id: int) -> Optional[dict]: + row = TradingPlanRepository().fetch_trading_plan_by_id(plan_id) + return normalize_trading_plan_row(row) if row else None + + +def activate_trading_plan(plan_id: int) -> Optional[dict]: + row = TradingPlanRepository().update_trading_plan_status( + plan_id, + status="active", + activated_ts=datetime.now(timezone.utc), + ) + return normalize_trading_plan_row(row) if row else None + + +def cancel_trading_plan(plan_id: int) -> Optional[dict]: + row = TradingPlanRepository().update_trading_plan_status( + plan_id, + status="cancelled", + activated_ts=None, + ) + return normalize_trading_plan_row(row) if row else None + + +def get_trading_market_state(instrument_id: int, timeframe: str = "1m") -> dict: + service = MarketStateService() + market_state = service.build_market_state(instrument_id=instrument_id, timeframe=timeframe) + snapshot_id = service.persist_market_state_snapshot(market_state=market_state) + quality_gate = service.repository.fetch_latest_backtest_quality_gate(instrument_id=instrument_id) + return { + "snapshot_id": snapshot_id, + "market_state": market_state.to_dict(), + "backtest_quality_gate": quality_gate, + } + + +def run_trading_watch( + *, + instrument_id: int, + setup_tf: str = "1m", + trigger_tf: str = "1m", +) -> Optional[dict]: + service = SetupWatcherService() + candidate = service.build_candidate( + instrument_id=instrument_id, + setup_tf=setup_tf, + trigger_tf=trigger_tf, + ) + candidate_id = service.persist_candidate(candidate) + if candidate is None: + return None + if candidate_id: + row = TradePlanCandidateRepository().fetch_candidate_by_id(candidate_id) + if row: + return normalize_trade_plan_candidate_row(row) + return normalize_trade_plan_candidate_row(candidate.to_dict()) + + +def get_trading_candidate_by_id(candidate_id: int) -> Optional[dict]: + row = TradePlanCandidateRepository().fetch_candidate_by_id(candidate_id) + return normalize_trade_plan_candidate_row(row) if row else None + + +def run_candidate_confirm_check(candidate_id: int) -> Optional[dict]: + candidate = get_trading_candidate_by_id(candidate_id) + if not candidate: + return None + return { + "candidate_id": candidate["id"], + "status": candidate["status"], + "executable": candidate.get("executable", False), + "confirmation_state": candidate.get("confirmation_state") or {}, + "missing_conditions": candidate.get("missing_conditions") or [], + "rejected_conditions": candidate.get("rejected_conditions") or [], + "reason_codes": candidate.get("reason_codes") or [], + } + + +def build_ticket_from_candidate( + *, + candidate_id: int, + execution_mode: str = "semi_auto", + account_equity: float = 10000.0, + risk_pct: float = 0.01, +) -> Optional[dict]: + candidate_row = TradePlanCandidateRepository().fetch_candidate_by_id(candidate_id) + if not candidate_row: + return None + candidate = normalize_trade_plan_candidate_row(candidate_row) + if not candidate.get("executable"): + raise ValueError("candidate_not_executable") + signal_id = ensure_trade_signal_for_candidate(candidate_row) + ticket = ExecutionService().create_execution_ticket( + signal_id=signal_id, + execution_mode=execution_mode, + account_equity=account_equity, + risk_pct=risk_pct, + ) + return ticket.to_dict() if ticket else None + + +def get_trading_ticket_by_id(ticket_id: int) -> Optional[dict]: + row = ExecutionService().repository.fetch_execution_ticket_by_id(ticket_id) + return dict(row) if row else None + + +def rerun_ticket_risk_check( + *, + ticket_id: int, + account_equity: float = 10000.0, + risk_pct: float = 0.01, +) -> Optional[dict]: + service = ExecutionService() + ticket = service.repository.fetch_execution_ticket_by_id(ticket_id) + if not ticket: + return None + signal = service.repository.fetch_signal_for_execution(int(ticket["signal_id"])) + if not signal: + return None + risk_context = service.build_risk_context_for_signal(signal) + decision = service.prepare_execution( + signal_id=int(ticket["signal_id"]), + execution_mode=str(ticket["execution_mode"]), + account_equity=account_equity, + risk_pct=risk_pct, + risk_context=risk_context, + ) + return { + "ticket_id": ticket_id, + "decision": decision.meta if decision else None, + "allowed": decision.allowed if decision else False, + "reasons": decision.reasons if decision else ["signal_not_found"], + } + + +def confirm_ticket_only(ticket_id: int, confirmed_by: str) -> Optional[dict]: + service = ExecutionService() + ticket = service.repository.fetch_execution_ticket_by_id(ticket_id) + if not ticket: + return None + signal = service.repository.fetch_signal_for_execution(int(ticket["signal_id"])) + if not signal: + return None + risk_context = service.build_risk_context_for_signal(signal) + decision = service.prepare_execution( + signal_id=int(ticket["signal_id"]), + execution_mode=str(ticket["execution_mode"]), + risk_context=risk_context, + ) + confirmed_at = datetime.now(timezone.utc) + status = "confirmed" if decision and decision.allowed else "rejected" + snapshot = service.repository.fetch_execution_ticket_by_id(ticket_id).get("ticket_snapshot") or {} + new_snapshot = service.repository.fetch_execution_ticket_by_id(ticket_id).get("ticket_snapshot") or {} + if decision: + new_snapshot = { + **dict(snapshot), + "confirmation": { + "confirmed_by": confirmed_by if decision.allowed else None, + "confirmed_at": confirmed_at.isoformat() if decision.allowed else None, + }, + "decision": { + "allowed": decision.allowed, + "status": decision.status, + "reasons": list(decision.reasons), + "requires_confirmation": decision.requires_confirmation, + "risk_pct": decision.risk_pct, + "size": decision.size, + "entry_price": decision.entry_price, + "meta": dict(decision.meta), + }, + "status": status, + } + service.repository.update_execution_ticket( + ticket_id=ticket_id, + status=status, + ticket_snapshot=new_snapshot, + confirmed_by=confirmed_by if status == "confirmed" else None, + confirmed_at=confirmed_at if status == "confirmed" else None, + execution_id=ticket.get("execution_id"), + ) + updated = service.repository.fetch_execution_ticket_by_id(ticket_id) + return dict(updated) if updated else None + + +def execute_ticket(ticket_id: int, *, confirmed_by: str, execution_mode_expected: str) -> Optional[dict]: + ticket = get_trading_ticket_by_id(ticket_id) + if not ticket: + return None + if ticket.get("execution_mode") != execution_mode_expected: + raise ValueError("ticket_execution_mode_mismatch") + service = ExecutionService() + if ticket.get("status") == "awaiting_confirmation": + service.confirm_execution_ticket(ticket_id, confirmed_by=confirmed_by) + full = service.submit_execution_ticket(ticket_id) + return full.to_dict() if full else None + + +def cancel_execution(execution_id: int) -> Optional[dict]: + service = ExecutionService() + return service.persist_execution_observation( + trade_execution=service.repository.fetch_execution_by_id(execution_id), + observation={"source": "manual", "status": "cancelled", "close_reason": "cancelled"}, + ) + + +def sync_execution(execution_id: int) -> Optional[dict]: + return ExecutionService().settle_execution_by_id_from_latest_backtest(execution_id) + + +def generate_trading_journal(execution_id: Optional[int] = None) -> dict: + service = TradingJournalService() + if execution_id is not None: + journal_id = service.upsert_entry_for_execution(execution_id) + return {"created": 1 if journal_id else 0, "execution_id": execution_id, "journal_id": journal_id} + return service.backfill_missing_entries() + + +def get_weekly_review(instrument_id: Optional[int] = None) -> dict: + return WeeklyReviewService().build_summary(instrument_id=instrument_id) + + +def ensure_trade_signal_for_candidate(candidate_row: dict) -> int: + repository = SignalRepository() + existing = repository.fetch_latest_trade_signal_for_candidate(int(candidate_row["id"])) + if existing: + return int(existing["id"]) + + entry = dict(candidate_row.get("entry") or {}) + if not entry: + raise ValueError("candidate_missing_entry") + trading_plan = TradingPlanRepository().fetch_trading_plan_by_id(int(candidate_row["trading_plan_id"])) or {} + allowed_sessions = list(trading_plan.get("allowed_sessions") or []) + entry_low = float(entry.get("entry_low") or 0.0) + entry_high = float(entry.get("entry_high") or 0.0) + stop_loss = float(entry.get("stop_loss") or 0.0) + tp1 = float(entry.get("tp1") or 0.0) + tp2 = float(entry.get("tp2") or 0.0) + entry_price = (entry_low + entry_high) / 2 if entry_low and entry_high else (entry_low or entry_high) + risk_distance = abs(entry_price - stop_loss) + rr_tp1 = abs(tp1 - entry_price) / risk_distance if risk_distance else 0.0 + rr_tp2 = abs(tp2 - entry_price) / risk_distance if risk_distance else 0.0 + invalidation_codes = [ + str(item.get("code")) + for item in list(candidate_row.get("invalidations") or []) + if item.get("code") + ] + repository.insert_trade_signal( + instrument_id=int(candidate_row["instrument_id"]), + model_code=str(candidate_row.get("model_code") or "UNKNOWN"), + side=str(candidate_row.get("side") or "buy"), + status="awaiting_confirmation", + bias_snapshot_id=None, + entry_type=str(entry.get("entry_type") or "limit"), + entry_low=entry_low, + entry_high=entry_high, + stop_loss=stop_loss, + tp1=tp1, + tp2=tp2, + rr_tp1=rr_tp1, + rr_tp2=rr_tp2, + session_code=allowed_sessions[0] if allowed_sessions else None, + created_ts=candidate_row.get("created_ts"), + expires_ts=candidate_row.get("expires_ts"), + invalidations=invalidation_codes, + evidence={ + **dict(candidate_row.get("evidence") or {}), + "candidate_id": candidate_row.get("id"), + "trading_plan_id": candidate_row.get("trading_plan_id"), + }, + meta={ + **dict(candidate_row.get("meta") or {}), + "candidate_id": candidate_row.get("id"), + "trading_plan_id": candidate_row.get("trading_plan_id"), + "target_plan_required": True, + "execution_permission": True, + "target_plan": dict(entry.get("target_plan") or {}), + "management_rules": list(entry.get("management_rules") or []), + "structure_state": "aligned", + "primary_draw": dict((trading_plan or {}).get("primary_draw") or {}), + "risk_governor_decision": None, + "confirmation_quality": dict(candidate_row.get("confirmation_state") or {}), + }, + ) + created = repository.fetch_latest_trade_signal_for_candidate(int(candidate_row["id"])) + if not created: + raise ValueError("signal_not_created_from_candidate") + return int(created["id"]) diff --git a/src/models/__init__.py b/src/models/__init__.py index 4ccd428..65a6ed9 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -12,6 +12,7 @@ from src.models.instrument import Instrument from src.models.live_operation_audit import LiveOperationAudit from src.models.liquidity_pool import LiquidityPool from src.models.market_session import MarketSession +from src.models.market_state_snapshot import MarketStateSnapshot from src.models.structure_event import StructureEvent from src.models.sweep_event import SweepEvent from src.models.swing import Swing @@ -43,6 +44,7 @@ __all__ = [ "LiveOperationAudit", "LiquidityPool", "MarketSession", + "MarketStateSnapshot", "StructureEvent", "SweepEvent", "Swing", diff --git a/src/models/market_state_snapshot.py b/src/models/market_state_snapshot.py new file mode 100644 index 0000000..49e1951 --- /dev/null +++ b/src/models/market_state_snapshot.py @@ -0,0 +1,22 @@ +from sqlalchemy import ForeignKey, Numeric, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from src.db.base import Base + + +class MarketStateSnapshot(Base): + __tablename__ = "market_states" + + id: Mapped[int] = mapped_column(primary_key=True) + instrument_id: Mapped[int] = mapped_column(ForeignKey("instruments.id"), nullable=False) + snapshot_ts: Mapped[object] = mapped_column(nullable=False) + timeframe: Mapped[str] = mapped_column(Text, nullable=False) + session_code: Mapped[str] = mapped_column(Text) + data_freshness: Mapped[str] = mapped_column(Text) + candle_age_seconds: Mapped[float] = mapped_column(Numeric(18, 4)) + news_window_state: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + opening_volatility_state: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + account_state: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + position_state: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + meta: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) diff --git a/src/repositories/market_state_repository.py b/src/repositories/market_state_repository.py index b6c9222..6e75b43 100644 --- a/src/repositories/market_state_repository.py +++ b/src/repositories/market_state_repository.py @@ -1,3 +1,4 @@ +import json from typing import Optional from sqlalchemy import text @@ -60,3 +61,83 @@ class MarketStateRepository: quality_gate["backtest_run_id"] = payload.get("id") quality_gate["observed_ts"] = payload.get("ended_ts") or payload.get("started_ts") return quality_gate + + def insert_market_state_snapshot( + self, + *, + instrument_id: int, + snapshot_ts, + timeframe: str, + session_code: str, + data_freshness: str, + candle_age_seconds, + news_window_state: dict, + opening_volatility_state: dict, + account_state: dict, + position_state: dict, + meta: dict, + ) -> int: + sql = text( + """ + insert into market_states ( + instrument_id, + snapshot_ts, + timeframe, + session_code, + data_freshness, + candle_age_seconds, + news_window_state, + opening_volatility_state, + account_state, + position_state, + meta + ) values ( + :instrument_id, + :snapshot_ts, + :timeframe, + :session_code, + :data_freshness, + :candle_age_seconds, + cast(:news_window_state as jsonb), + cast(:opening_volatility_state as jsonb), + cast(:account_state as jsonb), + cast(:position_state as jsonb), + cast(:meta as jsonb) + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "instrument_id": instrument_id, + "snapshot_ts": snapshot_ts, + "timeframe": timeframe, + "session_code": session_code, + "data_freshness": data_freshness, + "candle_age_seconds": candle_age_seconds, + "news_window_state": json.dumps(news_window_state, default=str), + "opening_volatility_state": json.dumps(opening_volatility_state, default=str), + "account_state": json.dumps(account_state, default=str), + "position_state": json.dumps(position_state, default=str), + "meta": json.dumps(meta, default=str), + }, + ) + return int(result.scalar_one()) + + def fetch_recent_market_state_snapshots(self, instrument_id: int, limit: int = 20) -> list[dict]: + sql = text( + """ + select id, instrument_id, snapshot_ts, timeframe, session_code, data_freshness, + candle_age_seconds, news_window_state, opening_volatility_state, + account_state, position_state, meta + from market_states + where instrument_id = :instrument_id + order by snapshot_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() + return [dict(row) for row in rows] diff --git a/src/repositories/signal_repository.py b/src/repositories/signal_repository.py index f8fd470..63e0499 100644 --- a/src/repositories/signal_repository.py +++ b/src/repositories/signal_repository.py @@ -95,6 +95,22 @@ class SignalRepository: ).mappings().first() return dict(row) if row else None + def fetch_latest_trade_signal_for_candidate(self, candidate_id: int) -> Optional[dict]: + sql = text( + """ + select id, instrument_id, model_code, side, status, bias_snapshot_id, entry_type, entry_low, entry_high, + stop_loss, tp1, tp2, rr_tp1, rr_tp2, session_code, created_ts, expires_ts, + invalidations, evidence, meta + from trade_signals + where cast(coalesce(meta, '{}'::jsonb)->>'candidate_id' as bigint) = :candidate_id + order by created_ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"candidate_id": candidate_id}).mappings().first() + return dict(row) if row else None + def fetch_session_at(self, instrument_id: int, ts) -> Optional[dict]: sql = text( """ diff --git a/src/repositories/trade_plan_candidate_repository.py b/src/repositories/trade_plan_candidate_repository.py index 5a8c0b8..3b36bdc 100644 --- a/src/repositories/trade_plan_candidate_repository.py +++ b/src/repositories/trade_plan_candidate_repository.py @@ -298,3 +298,18 @@ class TradePlanCandidateRepository: with SessionLocal() as session: rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() return [dict(row) for row in rows] + + def fetch_candidate_by_id(self, candidate_id: int) -> Optional[dict]: + sql = text( + """ + select id, instrument_id, trading_plan_id, model_code, status, side, setup_timeframe, + trigger_timeframe, created_ts, expires_ts, confirmation_state, + missing_confirmations, evidence, entry, invalidations, narrative, meta + from trade_plan_candidates + where id = :candidate_id + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"candidate_id": candidate_id}).mappings().first() + return dict(row) if row else None diff --git a/src/repositories/trading_plan_repository.py b/src/repositories/trading_plan_repository.py index 6de5485..a152cb9 100644 --- a/src/repositories/trading_plan_repository.py +++ b/src/repositories/trading_plan_repository.py @@ -220,3 +220,62 @@ class TradingPlanRepository: with SessionLocal() as session: rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() return [dict(row) for row in rows] + + def fetch_trading_plan_by_id(self, plan_id: int) -> Optional[dict]: + sql = text( + """ + select id, instrument_id, plan_date, status, narrative, primary_draw, secondary_draw, + bias, allowed_sessions, invalidations, no_trade_conditions, inputs, quality, + timeframe_set, created_ts, activated_ts + from trading_plans + where id = :plan_id + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"plan_id": plan_id}).mappings().first() + return dict(row) if row else None + + def fetch_latest_trading_plan(self, instrument_id: int) -> Optional[dict]: + sql = text( + """ + select id, instrument_id, plan_date, status, narrative, primary_draw, secondary_draw, + bias, allowed_sessions, invalidations, no_trade_conditions, inputs, quality, + timeframe_set, created_ts, activated_ts + from trading_plans + where instrument_id = :instrument_id + order by + case when status = 'active' then 0 else 1 end, + plan_date desc, + created_ts desc, + id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"instrument_id": instrument_id}).mappings().first() + return dict(row) if row else None + + def update_trading_plan_status(self, plan_id: int, status: str, activated_ts=None) -> Optional[dict]: + sql = text( + """ + update trading_plans + set status = :status, + activated_ts = :activated_ts, + updated_at = now() + where id = :plan_id + returning id, instrument_id, plan_date, status, narrative, primary_draw, secondary_draw, + bias, allowed_sessions, invalidations, no_trade_conditions, inputs, quality, + timeframe_set, created_ts, activated_ts + """ + ) + with SessionLocal.begin() as session: + row = session.execute( + sql, + { + "plan_id": plan_id, + "status": status, + "activated_ts": activated_ts, + }, + ).mappings().first() + return dict(row) if row else None diff --git a/src/services/execution/execution_service.py b/src/services/execution/execution_service.py index 0775a29..e790459 100644 --- a/src/services/execution/execution_service.py +++ b/src/services/execution/execution_service.py @@ -519,6 +519,83 @@ class ExecutionService: ticket_snapshot=dict(updated.get("ticket_snapshot") or {}), ) + def submit_execution_ticket( + self, + ticket_id: int, + *, + account_equity: float = 10000.0, + risk_pct: float = 0.01, + account_state: Optional[AccountRiskState] = None, + risk_context: Optional[RiskContext] = None, + ) -> Optional[ExecutionTicketDecision]: + ticket = self.repository.fetch_execution_ticket_by_id(ticket_id) + if not ticket: + return None + if ticket.get("status") != EXECUTION_TICKET_STATUS_CONFIRMED: + return ExecutionTicketDecision( + ticket_id=int(ticket["id"]), + status=str(ticket["status"]), + execution_mode=str(ticket["execution_mode"]), + signal_id=int(ticket["signal_id"]), + execution_id=ticket.get("execution_id"), + reasons=["execution_ticket_not_confirmed"], + ticket_snapshot=dict(ticket.get("ticket_snapshot") or {}), + ) + signal = self.repository.fetch_signal_for_execution(int(ticket["signal_id"])) + if not signal: + return None + full_risk_context = self.build_risk_context_for_signal( + signal, + account_state=account_state, + risk_context=risk_context, + ) + execution_id = self.create_execution( + signal_id=int(ticket["signal_id"]), + execution_mode=str(ticket["execution_mode"]), + account_equity=account_equity, + risk_pct=risk_pct, + confirmed=True, + account_state=account_state, + risk_context=full_risk_context, + ticket_id=int(ticket["id"]), + ticket_snapshot=dict(ticket.get("ticket_snapshot") or {}), + confirmed_by=ticket.get("confirmed_by"), + confirmed_at=ticket.get("confirmed_at"), + ) + if execution_id is None: + return ExecutionTicketDecision( + ticket_id=int(ticket["id"]), + status=str(ticket["status"]), + execution_mode=str(ticket["execution_mode"]), + signal_id=int(ticket["signal_id"]), + execution_id=None, + reasons=["execution_creation_failed"], + ticket_snapshot=dict(ticket.get("ticket_snapshot") or {}), + ) + snapshot = dict(ticket.get("ticket_snapshot") or {}) + snapshot["execution"] = { + "execution_id": execution_id, + "submitted_at": datetime.now(timezone.utc).isoformat(), + } + self.repository.update_execution_ticket( + ticket_id=int(ticket["id"]), + status="submitted", + ticket_snapshot=snapshot, + confirmed_by=ticket.get("confirmed_by"), + confirmed_at=ticket.get("confirmed_at"), + execution_id=execution_id, + ) + updated = self.repository.fetch_execution_ticket_by_id(int(ticket["id"])) or ticket + return ExecutionTicketDecision( + ticket_id=int(updated["id"]), + status=str(updated["status"]), + execution_mode=str(updated["execution_mode"]), + signal_id=int(updated["signal_id"]), + execution_id=updated.get("execution_id"), + reasons=[], + ticket_snapshot=dict(updated.get("ticket_snapshot") or {}), + ) + def create_execution( self, signal_id: int, @@ -679,6 +756,13 @@ def evaluate_execution_decision( meta["risk_distance"] = risk_distance meta["account_equity"] = account_equity meta["account_risk"] = account_state.to_dict() if hasattr(account_state, "to_dict") else (account_state.__dict__ if account_state else None) + meta.setdefault("execution_ticket_id", None) + meta.setdefault("confirmation_required", execution_mode == "semi_auto") + meta.setdefault("confirmed_by", None) + meta.setdefault("confirmed_at", None) + meta.setdefault("broker_order_ids", []) + meta.setdefault("bracket_state", {"status": "not_submitted", "oco_group_id": None}) + meta.setdefault("reconciliation_state", {"status": "not_reconciled"}) allowed = len(reasons) == 0 requires_confirmation = execution_mode == "semi_auto" diff --git a/src/services/market_state/market_state_service.py b/src/services/market_state/market_state_service.py index 4277ae1..cdca2d5 100644 --- a/src/services/market_state/market_state_service.py +++ b/src/services/market_state/market_state_service.py @@ -126,6 +126,31 @@ class MarketStateService: opening_volatility=opening_volatility, ) + def persist_market_state_snapshot( + self, + *, + market_state: MarketState, + account_state: Optional[dict] = None, + position_state: Optional[dict] = None, + meta: Optional[dict] = None, + ) -> int: + return self.repository.insert_market_state_snapshot( + instrument_id=market_state.instrument_id, + snapshot_ts=market_state.observed_ts, + timeframe=market_state.timeframe, + session_code=market_state.session.session_code, + data_freshness=market_state.freshness.code, + candle_age_seconds=market_state.freshness.age_seconds, + news_window_state=market_state.news_window.to_dict(), + opening_volatility_state=market_state.opening_volatility.to_dict(), + account_state=dict(account_state or {}), + position_state=dict(position_state or {}), + meta={ + "latest_candle": dict(market_state.latest_candle or {}), + **dict(meta or {}), + }, + ) + def evaluate_freshness(*, latest_ts, now: datetime, max_age_seconds: int) -> FreshnessState: if latest_ts is None: diff --git a/src/services/signals/signal_service.py b/src/services/signals/signal_service.py index 84e63a1..e9161c0 100644 --- a/src/services/signals/signal_service.py +++ b/src/services/signals/signal_service.py @@ -157,9 +157,14 @@ class SignalService: 'signal_sweep_id': evidence.get('sweep_id'), 'liquidity_target_id': evidence.get('liquidity_target_id'), 'rr_filter': rr_decision, + 'trading_plan_id': None, + 'candidate_id': None, + 'primary_draw': {}, 'target_plan': target_plan_payload, 'management_rules': target_plan_payload['management_rules'], 'target_selection_status': target_plan_payload['status'], + 'risk_governor_decision': None, + 'confirmation_quality': evidence_quality, }, ) diff --git a/src/web/app.js b/src/web/app.js index 71f1353..e170ffa 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -14,6 +14,12 @@ const sessionsTable = document.getElementById('sessionsTable'); const executionsTable = document.getElementById('executionsTable'); const backtestsCards = document.getElementById('backtestsCards'); const overviewCards = document.getElementById('overviewCards'); +const marketStatePanel = document.getElementById('marketStatePanel'); +const preMarketPlanPanel = document.getElementById('preMarketPlanPanel'); +const tradeCandidatesPanel = document.getElementById('tradeCandidatesPanel'); +const executionTicketsPanel = document.getElementById('executionTicketsPanel'); +const positionManagementPanel = document.getElementById('positionManagementPanel'); +const postTradeReviewPanel = document.getElementById('postTradeReviewPanel'); const detailMeta = document.getElementById('detailMeta'); const detailPanel = document.getElementById('detailPanel'); const trainingCasesList = document.getElementById('trainingCasesList'); @@ -285,6 +291,243 @@ function updateActiveFilters() { setText(activeFilters, `Filters: ${active.length ? active.join(', ') : 'none'}`); } +function renderKeyValuePanel(container, entries, emptyText = 'No data', preserveExisting = false) { + if (!container) return; + if (!preserveExisting) { + clearNode(container); + } + if (!entries.length) { + const empty = document.createElement('div'); + empty.className = 'metric'; + setText(empty, emptyText); + container.appendChild(empty); + return; + } + entries.forEach(([label, value]) => { + const row = document.createElement('div'); + row.className = 'metric'; + setText(row, `${label}: ${value == null || value === '' ? 'n/a' : value}`); + container.appendChild(row); + }); +} + +async function runTradingAction(url, body = {}) { + try { + await fetchJson(url, { + method: 'POST', + body: JSON.stringify(body), + }); + await refreshDashboard(); + } catch (error) { + showDetail('Trading Action Error', { error: String(error) }); + } +} + +function renderMarketStatePanel(payload) { + const state = payload?.market_state || {}; + const freshness = state?.freshness || {}; + const session = state?.session || {}; + const news = state?.news_window || {}; + renderKeyValuePanel(marketStatePanel, [ + ['symbol', instrumentIdInput.value || '1'], + ['session', session.session_code || 'OFF_HOURS'], + ['data freshness', freshness.code || 'unknown'], + ['news state', news.code || 'unknown'], + ['account risk state', payload?.backtest_quality_gate?.passed === false ? 'gate_blocked' : 'nominal'], + ['open position', executionsTable?.querySelectorAll('tbody tr')?.length || 0], + ['kill switch', payload?.risk_decision?.reject_codes?.includes('execution_kill_switch_enabled') ? 'active' : 'off'], + ], 'No market state'); +} + +function renderPreMarketPlanPanel(plan) { + if (!preMarketPlanPanel) return; + clearNode(preMarketPlanPanel); + const actions = document.createElement('div'); + actions.className = 'training-actions'; + const generateBtn = document.createElement('button'); + generateBtn.type = 'button'; + setText(generateBtn, 'Generate Plan'); + generateBtn.addEventListener('click', () => runTradingAction('/trading/plans/generate', { + instrument_id: Number(instrumentIdInput.value || 1), + bias_tf: timeframeInput.value || '1m', + setup_tf: timeframeInput.value || '1m', + trigger_tf: timeframeInput.value || '1m', + activate: true, + })); + actions.appendChild(generateBtn); + preMarketPlanPanel.appendChild(actions); + if (!plan) { + renderKeyValuePanel(preMarketPlanPanel, [], 'No latest trading plan', true); + return; + } + const actionRow = document.createElement('div'); + actionRow.className = 'training-actions'; + const activateBtn = document.createElement('button'); + activateBtn.type = 'button'; + setText(activateBtn, 'Activate'); + activateBtn.addEventListener('click', () => runTradingAction(`/trading/plans/${plan.id}/activate`, {})); + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + setText(cancelBtn, 'Cancel'); + cancelBtn.addEventListener('click', () => runTradingAction(`/trading/plans/${plan.id}/cancel`, {})); + actionRow.append(activateBtn, cancelBtn); + preMarketPlanPanel.appendChild(actionRow); + renderKeyValuePanel(preMarketPlanPanel, [ + ['Narrative', plan.narrative], + ['primary Draw', JSON.stringify(plan.primary_draw || {})], + ['secondary Draw', JSON.stringify(plan.secondary_draw || {})], + ['Bias', plan.bias], + ['allowed sessions', (plan.allowed_sessions || []).join(', ')], + ['no-trade conditions', (plan.no_trade_conditions || []).join(', ') || 'none'], + ['invalidation', JSON.stringify((plan.invalidations || [])[0] || {})], + ], 'No pre-market plan', true); +} + +function renderTradeCandidatesPanel(items) { + if (!tradeCandidatesPanel) return; + clearNode(tradeCandidatesPanel); + const actions = document.createElement('div'); + actions.className = 'training-actions'; + const watchBtn = document.createElement('button'); + watchBtn.type = 'button'; + setText(watchBtn, 'Run Watch'); + watchBtn.addEventListener('click', () => runTradingAction('/trading/watch/run', { + instrument_id: Number(instrumentIdInput.value || 1), + setup_tf: timeframeInput.value || '1m', + trigger_tf: timeframeInput.value || '1m', + })); + actions.appendChild(watchBtn); + tradeCandidatesPanel.appendChild(actions); + const candidate = (items || [])[0]; + if (!candidate) { + renderKeyValuePanel(tradeCandidatesPanel, [], 'No setup candidate', true); + return; + } + const actionRow = document.createElement('div'); + actionRow.className = 'training-actions'; + const confirmCheckBtn = document.createElement('button'); + confirmCheckBtn.type = 'button'; + setText(confirmCheckBtn, 'Confirm Check'); + confirmCheckBtn.addEventListener('click', () => runTradingAction(`/trading/candidates/${candidate.id}/confirm-check`, {})); + const buildTicketBtn = document.createElement('button'); + buildTicketBtn.type = 'button'; + setText(buildTicketBtn, 'Build Ticket'); + buildTicketBtn.addEventListener('click', () => runTradingAction(`/trading/candidates/${candidate.id}/build-ticket`, { execution_mode: 'semi_auto' })); + actionRow.append(confirmCheckBtn, buildTicketBtn); + tradeCandidatesPanel.appendChild(actionRow); + renderKeyValuePanel(tradeCandidatesPanel, [ + ['setup candidate', `${candidate.model_code} / ${candidate.status}`], + ['confirmation chain', JSON.stringify(candidate.confirmation_state || {})], + ['quality score', candidate.executable ? 'executable' : 'needs_confirmation'], + ['missing requirements', (candidate.missing_conditions || []).join(', ') || 'none'], + ['evidence links', JSON.stringify(candidate.evidence || {})], + ], 'No setup candidate', true); +} + +function renderExecutionTicketsPanel(items) { + if (!executionTicketsPanel) return; + clearNode(executionTicketsPanel); + const ticket = (items || [])[0]; + if (!ticket) { + renderKeyValuePanel(executionTicketsPanel, [], 'No execution ticket', true); + return; + } + const actionRow = document.createElement('div'); + actionRow.className = 'training-actions'; + const confirmBtn = document.createElement('button'); + confirmBtn.type = 'button'; + setText(confirmBtn, 'Confirm'); + confirmBtn.addEventListener('click', () => runTradingAction(`/trading/tickets/${ticket.id}/confirm`, { confirmed_by: 'dashboard_user' })); + const riskBtn = document.createElement('button'); + riskBtn.type = 'button'; + setText(riskBtn, 'Risk Check'); + riskBtn.addEventListener('click', () => runTradingAction(`/trading/tickets/${ticket.id}/risk-check`, {})); + const paperBtn = document.createElement('button'); + paperBtn.type = 'button'; + setText(paperBtn, 'Paper Execute'); + paperBtn.addEventListener('click', () => runTradingAction(`/trading/tickets/${ticket.id}/paper-execute`, { confirmed_by: 'dashboard_user' })); + const semiBtn = document.createElement('button'); + semiBtn.type = 'button'; + setText(semiBtn, 'Semi-auto Submit'); + semiBtn.addEventListener('click', () => runTradingAction(`/trading/tickets/${ticket.id}/semi-auto-submit`, { confirmed_by: 'dashboard_user' })); + actionRow.append(confirmBtn, riskBtn, paperBtn, semiBtn); + executionTicketsPanel.appendChild(actionRow); + const snapshot = ticket.ticket_snapshot || {}; + const decision = snapshot.decision || {}; + renderKeyValuePanel(executionTicketsPanel, [ + ['side', snapshot.signal?.side || ticket.side], + ['entry zone', `${decision.meta?.entry_low ?? 'n/a'} - ${decision.meta?.entry_high ?? 'n/a'}`], + ['stop', decision.meta?.stop_loss], + ['TP1 / TP2', `${decision.meta?.tp1 ?? 'n/a'} / ${decision.meta?.tp2 ?? 'n/a'}`], + ['RR', `${decision.meta?.rr_tp1 ?? 'n/a'} / ${decision.meta?.rr_tp2 ?? 'n/a'}`], + ['risk amount', decision.risk_pct], + ['expiry', snapshot.signal?.expires_ts || ticket.expires_ts], + ['management rules', JSON.stringify(decision.meta?.management_rules || [])], + ['risk governor result', JSON.stringify(decision.meta?.risk_governor || {})], + ], 'No execution ticket', true); +} + +function renderPositionManagementPanel(executions, liveAudits) { + if (!positionManagementPanel) return; + clearNode(positionManagementPanel); + const execution = (executions || [])[0]; + if (!execution) { + renderKeyValuePanel(positionManagementPanel, [], 'No current position', true); + return; + } + const actionRow = document.createElement('div'); + actionRow.className = 'training-actions'; + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + setText(cancelBtn, 'Cancel'); + cancelBtn.addEventListener('click', () => runTradingAction(`/trading/executions/${execution.execution_id}/cancel`, {})); + const syncBtn = document.createElement('button'); + syncBtn.type = 'button'; + setText(syncBtn, 'Sync'); + syncBtn.addEventListener('click', () => runTradingAction(`/trading/executions/${execution.execution_id}/sync`, {})); + actionRow.append(cancelBtn, syncBtn); + positionManagementPanel.appendChild(actionRow); + renderKeyValuePanel(positionManagementPanel, [ + ['current position', execution.side || 'n/a'], + ['fill state', execution.fill_state], + ['TP progress', `${execution.tp1_hit ? 'tp1' : '-'} ${execution.tp2_hit ? 'tp2' : ''}`.trim() || 'none'], + ['stop state', execution.stop_loss_hit ? 'hit' : 'safe'], + ['invalidation alerts', (execution.error_tags || []).join(', ') || 'none'], + ['cancel / close / sync actions', (liveAudits || []).map((item) => item.operation_type).join(', ') || 'sync_only'], + ], 'No current position', true); +} + +function renderPostTradeReviewPanel(summary, journalItems) { + if (!postTradeReviewPanel) return; + clearNode(postTradeReviewPanel); + const actions = document.createElement('div'); + actions.className = 'training-actions'; + const generateBtn = document.createElement('button'); + generateBtn.type = 'button'; + setText(generateBtn, 'Generate Journal'); + generateBtn.addEventListener('click', () => runTradingAction('/trading/journal/generate', {})); + actions.appendChild(generateBtn); + postTradeReviewPanel.appendChild(actions); + const latest = (journalItems || [])[0]; + if (!latest) { + renderKeyValuePanel(postTradeReviewPanel, [ + ['entry count', summary?.entry_count ?? 0], + ['next actions', (summary?.next_week_actions || []).join(', ') || 'none'], + ], 'No journal entries', true); + return; + } + renderKeyValuePanel(postTradeReviewPanel, [ + ['model compliance', latest.model_compliance], + ['risk compliance', latest.risk_compliance], + ['execution violations', latest.execution_violation], + ['model failure vs execution failure', `${latest.model_failure ? 'model_failure' : 'no_model_failure'} / ${latest.execution_violation ? 'execution_violation' : 'clean_execution'}`], + ['next actions', (latest.next_actions || []).join(', ') || 'none'], + ['high quality session', summary?.high_quality_session?.session_code || 'n/a'], + ['low quality session', summary?.low_quality_session?.session_code || 'n/a'], + ['weekly next actions', (summary?.next_week_actions || []).join(', ') || 'none'], + ], 'No post-trade review', true); +} + function setSelectedDetail(title, payload, key) { selectedDetailKey = key; showDetail(title, payload); @@ -304,7 +547,7 @@ function cardDetailHandler(titleBuilder, keyValue, payload) { }; } -function renderDashboardData(health, candles, signals, sessions, executions, backtests) { +function renderDashboardData(health, candles, signals, sessions, executions, backtests, tradingPlan, marketState, tradeCandidates, executionTickets, journalSummary, journalItems, liveAudits) { const signalItems = applySignalFilters(signals.items || []); const sessionItems = applyTextFilters(sessions.items || [], ['id', 'session_code', 'status', 'session_date']); const executionItems = applyExecutionFilters(executions.items || []); @@ -313,6 +556,12 @@ function renderDashboardData(health, candles, signals, sessions, executions, bac renderHealth(health); updateActiveFilters(); renderOverview(signalItems, sessionItems, executionItems, backtestItems); + renderMarketStatePanel(marketState); + renderPreMarketPlanPanel(tradingPlan); + renderTradeCandidatesPanel(tradeCandidates.items || []); + renderExecutionTicketsPanel(executionTickets.items || []); + renderPositionManagementPanel(executionItems, liveAudits.items || []); + renderPostTradeReviewPanel(journalSummary, journalItems.items || []); renderTable(signalsTable, [ tableColumn('id', 'ID'), tableColumn('side', 'Side'), @@ -1044,16 +1293,23 @@ async function refreshDashboard() { const limit = limitInput.value || '20'; try { - const [health, candles, signals, sessions, executions, backtests] = await Promise.all([ + const [health, candles, signals, sessions, executions, backtests, tradingPlan, marketState, tradeCandidates, executionTickets, journalSummary, journalItems, liveAudits] = await Promise.all([ fetchJson('/health'), fetchJson(`/candles?instrument_id=${instrumentId}&timeframe=${encodeURIComponent(timeframe)}&limit=${limit}`), fetchJson(`/signals?instrument_id=${instrumentId}&limit=${limit}`), fetchJson(`/sessions?instrument_id=${instrumentId}&limit=${limit}`), fetchJson(`/executions?instrument_id=${instrumentId}&limit=${limit}`), fetchJson(`/backtests?instrument_id=${instrumentId}&limit=${Math.min(Number(limit), 10)}`), + fetchJson(`/trading/plans/latest?instrument_id=${instrumentId}`).catch(() => null), + fetchJson(`/trading/market-state?instrument_id=${instrumentId}&timeframe=${encodeURIComponent(timeframe)}`).catch(() => ({ market_state: {} })), + fetchJson(`/trading/candidates?instrument_id=${instrumentId}&limit=${limit}`).catch(() => ({ items: [] })), + fetchJson(`/trading/tickets?instrument_id=${instrumentId}&limit=${limit}`).catch(() => ({ items: [] })), + fetchJson(`/trading/journal/summary?instrument_id=${instrumentId}`).catch(() => ({ entry_count: 0, next_week_actions: [] })), + fetchJson(`/trading/journal?instrument_id=${instrumentId}&limit=${limit}`).catch(() => ({ items: [] })), + fetchJson(`/live-operation-audits?instrument_id=${instrumentId}&limit=${limit}`).catch(() => ({ items: [] })), ]); - renderDashboardData(health, candles, signals, sessions, executions, backtests); + renderDashboardData(health, candles, signals, sessions, executions, backtests, tradingPlan, marketState, tradeCandidates, executionTickets, journalSummary, journalItems, liveAudits); updateLastUpdated(); } catch (error) { renderLoadError(error); diff --git a/src/web/index.html b/src/web/index.html index d87ee49..ceae3a8 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -63,6 +63,36 @@
+