Audit doc alignment trading workbench

This commit is contained in:
Codex
2026-05-06 18:33:13 +08:00
parent 0d6e2e66f0
commit ee222eb40c
17 changed files with 1412 additions and 3 deletions
+19
View File
@@ -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);
+38
View File
@@ -9,6 +9,26 @@ from src.api.server import route_request, serve_api
from src.api.sessions import get_recent_sessions from src.api.sessions import get_recent_sessions
from src.api.signals import get_recent_signals from src.api.signals import get_recent_signals
from src.api.trade_plan_candidates import get_recent_trade_plan_candidates 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_journal import get_recent_trading_journal, get_trading_journal_summary
from src.api.trading_plans import get_recent_trading_plans from src.api.trading_plans import get_recent_trading_plans
@@ -21,8 +41,26 @@ __all__ = [
"get_recent_live_operation_audits", "get_recent_live_operation_audits",
"get_recent_trade_plan_candidates", "get_recent_trade_plan_candidates",
"get_recent_trading_journal", "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_trading_journal_summary",
"get_recent_trading_plans", "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", "get_recent_backtest_summaries",
"route_request", "route_request",
"serve_api", "serve_api",
+204
View File
@@ -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.sessions import get_recent_sessions
from src.api.signals import get_recent_signals from src.api.signals import get_recent_signals
from src.api.trade_plan_candidates import get_recent_trade_plan_candidates 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_journal import get_recent_trading_journal, get_trading_journal_summary
from src.api.trading_plans import get_recent_trading_plans from src.api.trading_plans import get_recent_trading_plans
from src.api.training import ( from src.api.training import (
@@ -41,8 +61,26 @@ def build_api_dependencies() -> dict:
"sessions": get_recent_sessions, "sessions": get_recent_sessions,
"signals": get_recent_signals, "signals": get_recent_signals,
"trade_plan_candidates": get_recent_trade_plan_candidates, "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": get_recent_trading_journal,
"trading_journal_summary": get_trading_journal_summary, "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, "trading_plans": get_recent_trading_plans,
"backtests": get_recent_backtest_summaries, "backtests": get_recent_backtest_summaries,
"executions": execution_service.build_execution_dashboard_items, "executions": execution_service.build_execution_dashboard_items,
@@ -104,13 +142,92 @@ def route_request(
instrument_id = _require_int(query, "instrument_id") instrument_id = _require_int(query, "instrument_id")
limit = _get_int(query, "limit", 20) limit = _get_int(query, "limit", 20)
return 200, {"items": deps["trading_plans"](instrument_id=instrument_id, limit=limit)} 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": if method == "GET" and path == "/trading-journal":
instrument_id = _get_optional_int(query, "instrument_id") instrument_id = _get_optional_int(query, "instrument_id")
limit = _get_int(query, "limit", 20) limit = _get_int(query, "limit", 20)
return 200, {"items": deps["trading_journal"](instrument_id=instrument_id, limit=limit)} 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": if method == "GET" and path == "/trading-journal-summary":
instrument_id = _get_optional_int(query, "instrument_id") instrument_id = _get_optional_int(query, "instrument_id")
return 200, deps["trading_journal_summary"](instrument_id=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": if method == "GET" and path == "/backtests":
instrument_id = _get_optional_int(query, "instrument_id") instrument_id = _get_optional_int(query, "instrument_id")
limit = _get_int(query, "limit", 10) limit = _get_int(query, "limit", 10)
@@ -119,6 +236,49 @@ def route_request(
instrument_id = _require_int(query, "instrument_id") instrument_id = _require_int(query, "instrument_id")
limit = _get_int(query, "limit", 20) limit = _get_int(query, "limit", 20)
return 200, {"items": deps["executions"](instrument_id=instrument_id, limit=limit)} 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": if method == "GET" and path == "/execution-tickets":
instrument_id = _get_optional_int(query, "instrument_id") instrument_id = _get_optional_int(query, "instrument_id")
signal_id = _get_optional_int(query, "signal_id") signal_id = _get_optional_int(query, "signal_id")
@@ -141,6 +301,16 @@ def route_request(
limit=limit, 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": if method == "GET" and path == "/training/cases":
status = _get_single(query, "status") status = _get_single(query, "status")
skill_tag = _get_single(query, "skill_tag") 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 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]: def _get_single(query: dict, key: str, default: Optional[str] = None) -> Optional[str]:
values = query.get(key) values = query.get(key)
if not values: if not values:
@@ -384,6 +584,10 @@ def _parse_required_json_identifier(body: dict, key: str) -> int:
return parsed 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: def _parse_body_identifier(value, key: str) -> int:
if isinstance(value, bool): if isinstance(value, bool):
raise ValueError(f"invalid_body_field:{key}") raise ValueError(f"invalid_body_field:{key}")
+332
View File
@@ -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"])
+2
View File
@@ -12,6 +12,7 @@ from src.models.instrument import Instrument
from src.models.live_operation_audit import LiveOperationAudit from src.models.live_operation_audit import LiveOperationAudit
from src.models.liquidity_pool import LiquidityPool from src.models.liquidity_pool import LiquidityPool
from src.models.market_session import MarketSession 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.structure_event import StructureEvent
from src.models.sweep_event import SweepEvent from src.models.sweep_event import SweepEvent
from src.models.swing import Swing from src.models.swing import Swing
@@ -43,6 +44,7 @@ __all__ = [
"LiveOperationAudit", "LiveOperationAudit",
"LiquidityPool", "LiquidityPool",
"MarketSession", "MarketSession",
"MarketStateSnapshot",
"StructureEvent", "StructureEvent",
"SweepEvent", "SweepEvent",
"Swing", "Swing",
+22
View File
@@ -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)
@@ -1,3 +1,4 @@
import json
from typing import Optional from typing import Optional
from sqlalchemy import text from sqlalchemy import text
@@ -60,3 +61,83 @@ class MarketStateRepository:
quality_gate["backtest_run_id"] = payload.get("id") quality_gate["backtest_run_id"] = payload.get("id")
quality_gate["observed_ts"] = payload.get("ended_ts") or payload.get("started_ts") quality_gate["observed_ts"] = payload.get("ended_ts") or payload.get("started_ts")
return quality_gate 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]
+16
View File
@@ -95,6 +95,22 @@ class SignalRepository:
).mappings().first() ).mappings().first()
return dict(row) if row else None 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]: def fetch_session_at(self, instrument_id: int, ts) -> Optional[dict]:
sql = text( sql = text(
""" """
@@ -298,3 +298,18 @@ class TradePlanCandidateRepository:
with SessionLocal() as session: with SessionLocal() as session:
rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all()
return [dict(row) for row in rows] 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
@@ -220,3 +220,62 @@ class TradingPlanRepository:
with SessionLocal() as session: with SessionLocal() as session:
rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all()
return [dict(row) for row in rows] 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
@@ -519,6 +519,83 @@ class ExecutionService:
ticket_snapshot=dict(updated.get("ticket_snapshot") or {}), 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( def create_execution(
self, self,
signal_id: int, signal_id: int,
@@ -679,6 +756,13 @@ def evaluate_execution_decision(
meta["risk_distance"] = risk_distance meta["risk_distance"] = risk_distance
meta["account_equity"] = account_equity 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["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 allowed = len(reasons) == 0
requires_confirmation = execution_mode == "semi_auto" requires_confirmation = execution_mode == "semi_auto"
@@ -126,6 +126,31 @@ class MarketStateService:
opening_volatility=opening_volatility, 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: def evaluate_freshness(*, latest_ts, now: datetime, max_age_seconds: int) -> FreshnessState:
if latest_ts is None: if latest_ts is None:
+5
View File
@@ -157,9 +157,14 @@ class SignalService:
'signal_sweep_id': evidence.get('sweep_id'), 'signal_sweep_id': evidence.get('sweep_id'),
'liquidity_target_id': evidence.get('liquidity_target_id'), 'liquidity_target_id': evidence.get('liquidity_target_id'),
'rr_filter': rr_decision, 'rr_filter': rr_decision,
'trading_plan_id': None,
'candidate_id': None,
'primary_draw': {},
'target_plan': target_plan_payload, 'target_plan': target_plan_payload,
'management_rules': target_plan_payload['management_rules'], 'management_rules': target_plan_payload['management_rules'],
'target_selection_status': target_plan_payload['status'], 'target_selection_status': target_plan_payload['status'],
'risk_governor_decision': None,
'confirmation_quality': evidence_quality,
}, },
) )
+259 -3
View File
@@ -14,6 +14,12 @@ const sessionsTable = document.getElementById('sessionsTable');
const executionsTable = document.getElementById('executionsTable'); const executionsTable = document.getElementById('executionsTable');
const backtestsCards = document.getElementById('backtestsCards'); const backtestsCards = document.getElementById('backtestsCards');
const overviewCards = document.getElementById('overviewCards'); 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 detailMeta = document.getElementById('detailMeta');
const detailPanel = document.getElementById('detailPanel'); const detailPanel = document.getElementById('detailPanel');
const trainingCasesList = document.getElementById('trainingCasesList'); const trainingCasesList = document.getElementById('trainingCasesList');
@@ -285,6 +291,243 @@ function updateActiveFilters() {
setText(activeFilters, `Filters: ${active.length ? active.join(', ') : 'none'}`); 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) { function setSelectedDetail(title, payload, key) {
selectedDetailKey = key; selectedDetailKey = key;
showDetail(title, payload); 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 signalItems = applySignalFilters(signals.items || []);
const sessionItems = applyTextFilters(sessions.items || [], ['id', 'session_code', 'status', 'session_date']); const sessionItems = applyTextFilters(sessions.items || [], ['id', 'session_code', 'status', 'session_date']);
const executionItems = applyExecutionFilters(executions.items || []); const executionItems = applyExecutionFilters(executions.items || []);
@@ -313,6 +556,12 @@ function renderDashboardData(health, candles, signals, sessions, executions, bac
renderHealth(health); renderHealth(health);
updateActiveFilters(); updateActiveFilters();
renderOverview(signalItems, sessionItems, executionItems, backtestItems); 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, [ renderTable(signalsTable, [
tableColumn('id', 'ID'), tableColumn('id', 'ID'),
tableColumn('side', 'Side'), tableColumn('side', 'Side'),
@@ -1044,16 +1293,23 @@ async function refreshDashboard() {
const limit = limitInput.value || '20'; const limit = limitInput.value || '20';
try { 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('/health'),
fetchJson(`/candles?instrument_id=${instrumentId}&timeframe=${encodeURIComponent(timeframe)}&limit=${limit}`), fetchJson(`/candles?instrument_id=${instrumentId}&timeframe=${encodeURIComponent(timeframe)}&limit=${limit}`),
fetchJson(`/signals?instrument_id=${instrumentId}&limit=${limit}`), fetchJson(`/signals?instrument_id=${instrumentId}&limit=${limit}`),
fetchJson(`/sessions?instrument_id=${instrumentId}&limit=${limit}`), fetchJson(`/sessions?instrument_id=${instrumentId}&limit=${limit}`),
fetchJson(`/executions?instrument_id=${instrumentId}&limit=${limit}`), fetchJson(`/executions?instrument_id=${instrumentId}&limit=${limit}`),
fetchJson(`/backtests?instrument_id=${instrumentId}&limit=${Math.min(Number(limit), 10)}`), 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(); updateLastUpdated();
} catch (error) { } catch (error) {
renderLoadError(error); renderLoadError(error);
+30
View File
@@ -63,6 +63,36 @@
<div id="overviewCards" class="cards overview-cards"></div> <div id="overviewCards" class="cards overview-cards"></div>
</section> </section>
<section class="panel panel-wide">
<h2>Trading Status</h2>
<div id="marketStatePanel" class="cards"></div>
</section>
<section class="panel panel-wide">
<h2>Pre-Market Plan</h2>
<div id="preMarketPlanPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Intraday Candidates</h2>
<div id="tradeCandidatesPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Execution Tickets</h2>
<div id="executionTicketsPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Position Management</h2>
<div id="positionManagementPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Post-Trade Review</h2>
<div id="postTradeReviewPanel" class="detail-panel"></div>
</section>
<section id="trainingSection" class="panel panel-wide training-panel"> <section id="trainingSection" class="panel panel-wide training-panel">
<h2>Training / 训练</h2> <h2>Training / 训练</h2>
<nav class="training-nav" aria-label="Training dashboard sections"> <nav class="training-nav" aria-label="Training dashboard sections">
+184
View File
@@ -15,6 +15,168 @@ from scripts import run_execution, run_execution_settlement, run_offline_checks,
class ApiServerRoutingTests(unittest.TestCase): class ApiServerRoutingTests(unittest.TestCase):
def test_routes_trading_plan_generate(self) -> None:
status, payload = route_request(
"/trading/plans/generate",
{},
method="POST",
body={"instrument_id": 1, "activate": True},
dependencies={
"trading_plan_generate": lambda **kwargs: {"id": 1, "status": "active", **kwargs},
},
)
self.assertEqual(status, 200)
self.assertEqual(payload["instrument_id"], 1)
self.assertTrue(payload["activate"])
def test_routes_trading_plan_latest(self) -> None:
status, payload = route_request(
"/trading/plans/latest",
{"instrument_id": ["1"]},
dependencies={"trading_plan_latest": lambda **kwargs: {"id": 9, "status": "active"}},
)
self.assertEqual(status, 200)
self.assertEqual(payload["id"], 9)
def test_routes_trading_market_state(self) -> None:
status, payload = route_request(
"/trading/market-state",
{"instrument_id": ["1"], "timeframe": ["5m"]},
dependencies={"trading_market_state": lambda **kwargs: {"snapshot_id": 1, "market_state": kwargs}},
)
self.assertEqual(status, 200)
self.assertEqual(payload["snapshot_id"], 1)
self.assertEqual(payload["market_state"]["timeframe"], "5m")
def test_routes_trading_watch_run(self) -> None:
status, payload = route_request(
"/trading/watch/run",
{},
method="POST",
body={"instrument_id": 1, "setup_tf": "15m", "trigger_tf": "5m"},
dependencies={"trading_watch_run": lambda **kwargs: {"id": 4, "status": "executable", **kwargs}},
)
self.assertEqual(status, 200)
self.assertEqual(payload["setup_tf"], "15m")
def test_routes_trading_candidate_detail_and_confirm_check(self) -> None:
status_detail, payload_detail = route_request(
"/trading/candidates/4",
{},
dependencies={"trading_candidate_by_id": lambda candidate_id: {"id": candidate_id, "status": "executable"}},
)
status_check, payload_check = route_request(
"/trading/candidates/4/confirm-check",
{},
method="POST",
body={},
dependencies={"trading_candidate_confirm_check": lambda candidate_id: {"candidate_id": candidate_id, "executable": True}},
)
self.assertEqual(status_detail, 200)
self.assertEqual(payload_detail["id"], 4)
self.assertEqual(status_check, 200)
self.assertTrue(payload_check["executable"])
def test_routes_trading_ticket_flow(self) -> None:
status_build, payload_build = route_request(
"/trading/candidates/4/build-ticket",
{},
method="POST",
body={"execution_mode": "semi_auto"},
dependencies={"trading_candidate_build_ticket": lambda **kwargs: {"ticket_id": 11, **kwargs}},
)
status_get, payload_get = route_request(
"/trading/tickets/11",
{},
dependencies={"trading_ticket_by_id": lambda ticket_id: {"id": ticket_id, "status": "awaiting_confirmation"}},
)
status_risk, payload_risk = route_request(
"/trading/tickets/11/risk-check",
{},
method="POST",
body={},
dependencies={"trading_ticket_risk_check": lambda *args, **kwargs: {"ticket_id": 11, "allowed": False}},
)
status_confirm, payload_confirm = route_request(
"/trading/tickets/11/confirm",
{},
method="POST",
body={"confirmed_by": "alice"},
dependencies={"trading_ticket_confirm": lambda *args, **kwargs: {"id": 11, "status": "confirmed"}},
)
self.assertEqual(status_build, 200)
self.assertEqual(payload_build["ticket_id"], 11)
self.assertEqual(status_get, 200)
self.assertEqual(payload_get["status"], "awaiting_confirmation")
self.assertEqual(status_risk, 200)
self.assertFalse(payload_risk["allowed"])
self.assertEqual(status_confirm, 200)
self.assertEqual(payload_confirm["status"], "confirmed")
def test_routes_trading_execute_cancel_sync_and_weekly_review(self) -> None:
status_paper, payload_paper = route_request(
"/trading/tickets/11/paper-execute",
{},
method="POST",
body={"confirmed_by": "alice"},
dependencies={"trading_ticket_execute": lambda *args, **kwargs: {"status": "open", "mode": kwargs["execution_mode_expected"]}},
)
status_semi, payload_semi = route_request(
"/trading/tickets/11/semi-auto-submit",
{},
method="POST",
body={"confirmed_by": "alice"},
dependencies={"trading_ticket_execute": lambda *args, **kwargs: {"status": "pending", "mode": kwargs["execution_mode_expected"]}},
)
status_cancel, payload_cancel = route_request(
"/trading/executions/7/cancel",
{},
method="POST",
body={},
dependencies={"trading_execution_cancel": lambda execution_id: {"id": execution_id, "status": "cancelled"}},
)
status_sync, payload_sync = route_request(
"/trading/executions/7/sync",
{},
method="POST",
body={},
dependencies={"trading_execution_sync": lambda execution_id: {"id": execution_id, "status": "closed"}},
)
status_weekly, payload_weekly = route_request(
"/trading/research/weekly-review",
{"instrument_id": ["1"]},
dependencies={"trading_weekly_review": lambda **kwargs: {"entry_count": 2, "next_week_actions": ["review_risk_process"]}},
)
self.assertEqual(status_paper, 200)
self.assertEqual(payload_paper["mode"], "paper")
self.assertEqual(status_semi, 200)
self.assertEqual(payload_semi["mode"], "semi_auto")
self.assertEqual(status_cancel, 200)
self.assertEqual(payload_cancel["status"], "cancelled")
self.assertEqual(status_sync, 200)
self.assertEqual(payload_sync["status"], "closed")
self.assertEqual(status_weekly, 200)
self.assertEqual(payload_weekly["entry_count"], 2)
def test_routes_trading_journal_generate(self) -> None:
status, payload = route_request(
"/trading/journal/generate",
{},
method="POST",
body={"execution_id": 7},
dependencies={"trading_journal_generate": lambda **kwargs: {"created": 1, **kwargs}},
)
self.assertEqual(status, 200)
self.assertEqual(payload["execution_id"], 7)
def test_routes_health(self) -> None: def test_routes_health(self) -> None:
status, payload = route_request( status, payload = route_request(
"/health", "/health",
@@ -117,6 +279,17 @@ class ApiServerRoutingTests(unittest.TestCase):
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(payload["items"], [{"id": 1, "framework_execution": False}]) self.assertEqual(payload["items"], [{"id": 1, "framework_execution": False}])
status_doc, payload_doc = route_request(
"/trading/journal",
{"instrument_id": ["1"], "limit": ["2"]},
dependencies={
"trading_journal": lambda **kwargs: [{"id": 2, "framework_execution": True}],
},
)
self.assertEqual(status_doc, 200)
self.assertEqual(payload_doc["items"], [{"id": 2, "framework_execution": True}])
def test_routes_trading_journal_summary_query(self) -> None: def test_routes_trading_journal_summary_query(self) -> None:
status, payload = route_request( status, payload = route_request(
"/trading-journal-summary", "/trading-journal-summary",
@@ -129,6 +302,17 @@ class ApiServerRoutingTests(unittest.TestCase):
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(payload["entry_count"], 3) self.assertEqual(payload["entry_count"], 3)
status_doc, payload_doc = route_request(
"/trading/journal/summary",
{"instrument_id": ["1"]},
dependencies={
"trading_journal_summary": lambda **kwargs: {"entry_count": 4, "next_week_actions": ["review_model_context"]},
},
)
self.assertEqual(status_doc, 200)
self.assertEqual(payload_doc["entry_count"], 4)
def test_routes_health(self) -> None: def test_routes_health(self) -> None:
status, payload = route_request( status, payload = route_request(
"/health", "/health",
+37
View File
@@ -440,6 +440,43 @@ class ExecutionServiceTests(unittest.TestCase):
self.assertEqual(kwargs["meta"]["execution_ticket_id"], 81) self.assertEqual(kwargs["meta"]["execution_ticket_id"], 81)
self.assertEqual(kwargs["meta"]["confirmed_by"], "paper_user") self.assertEqual(kwargs["meta"]["confirmed_by"], "paper_user")
def test_submit_confirmed_ticket_creates_execution_and_updates_ticket(self) -> None:
repository = Mock()
ticket_row = {
"id": 91,
"signal_id": 1,
"execution_mode": "semi_auto",
"status": "confirmed",
"created_ts": "created",
"expires_ts": "later",
"confirmed_by": "alice",
"confirmed_at": "2026-05-06T00:00:00+00:00",
"execution_id": None,
"ticket_snapshot": {},
}
repository.fetch_execution_ticket_by_id.side_effect = lambda ticket_id: dict(ticket_row)
repository.fetch_signal_for_execution.return_value = _valid_signal()
repository.fetch_latest_execution.return_value = None
repository.insert_trade_execution.return_value = 101
repository.update_execution_ticket.side_effect = lambda **kwargs: ticket_row.update(
{
"status": kwargs["status"],
"ticket_snapshot": kwargs["ticket_snapshot"],
"execution_id": kwargs.get("execution_id"),
}
)
service = ExecutionService(repository=repository)
service.build_risk_context_for_signal = Mock(return_value=RiskContext())
result = service.submit_execution_ticket(91)
self.assertIsNotNone(result)
assert result is not None
self.assertEqual(result.status, "submitted")
self.assertEqual(result.execution_id, 101)
repository.insert_trade_execution.assert_called_once()
repository.update_execution_ticket.assert_called_once()
class ExecutionAuditTests(unittest.TestCase): class ExecutionAuditTests(unittest.TestCase):
def test_classifies_lifecycle_states(self) -> None: def test_classifies_lifecycle_states(self) -> None: