693 lines
30 KiB
Python
693 lines
30 KiB
Python
import json
|
|
import mimetypes
|
|
from pathlib import Path
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Optional
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from src.api.broker_state import get_broker_state
|
|
from src.api.candles import get_recent_candles
|
|
from src.api.execution_tickets import get_recent_execution_tickets
|
|
from src.api.health import get_health
|
|
from src.api.live_operation_audits import get_recent_live_operation_audits
|
|
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,
|
|
close_execution,
|
|
confirm_ticket_only,
|
|
execute_ticket,
|
|
generate_trading_journal,
|
|
generate_trading_plan,
|
|
get_trading_decision_alerts,
|
|
get_trading_decision_packet,
|
|
get_latest_trading_plan,
|
|
get_trading_candidate_by_id,
|
|
get_trading_market_state,
|
|
get_trading_operator_notes,
|
|
get_trading_plan_by_id,
|
|
get_trading_review_packet,
|
|
get_trading_live_readiness,
|
|
get_trading_runtime_status,
|
|
get_trading_session_view,
|
|
get_trading_ticket_by_id,
|
|
get_trading_trust_score,
|
|
get_trading_executions,
|
|
get_weekly_review,
|
|
rerun_ticket_risk_check,
|
|
run_candidate_confirm_check,
|
|
create_trading_operator_note,
|
|
run_trading_reconciliation,
|
|
run_trading_runtime_once,
|
|
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 (
|
|
add_training_annotation,
|
|
create_training_attempt,
|
|
export_training_case_package,
|
|
get_training_case,
|
|
get_training_cases,
|
|
get_training_progress,
|
|
import_training_case_package,
|
|
parse_min_quality_score_query,
|
|
parse_quality_rank_query,
|
|
parse_quality_sort_query,
|
|
submit_training_attempt,
|
|
)
|
|
from src.services.execution import ExecutionService
|
|
from src.services.market_state import start_market_truth_runtime
|
|
from src.services.runtime import start_trading_runtime_service
|
|
|
|
|
|
def build_api_dependencies() -> dict:
|
|
return {
|
|
"health": get_health,
|
|
"broker_state": get_broker_state,
|
|
"candles": get_recent_candles,
|
|
"execution_tickets": get_recent_execution_tickets,
|
|
"live_operation_audits": get_recent_live_operation_audits,
|
|
"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_decision_packet": get_trading_decision_packet,
|
|
"trading_decision_alerts": get_trading_decision_alerts,
|
|
"trading_reconciliation_run": run_trading_reconciliation,
|
|
"trading_runtime_status": get_trading_runtime_status,
|
|
"trading_runtime_run_once": run_trading_runtime_once,
|
|
"trading_trust_score": get_trading_trust_score,
|
|
"trading_session_view": get_trading_session_view,
|
|
"trading_operator_notes": get_trading_operator_notes,
|
|
"trading_operator_note_create": create_trading_operator_note,
|
|
"trading_review_packet": get_trading_review_packet,
|
|
"trading_live_readiness": get_trading_live_readiness,
|
|
"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_executions": get_trading_executions,
|
|
"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_close": close_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,
|
|
"training_cases": get_training_cases,
|
|
"training_case": get_training_case,
|
|
"training_export_cases": export_training_case_package,
|
|
"training_import_cases": import_training_case_package,
|
|
"training_create_attempt": create_training_attempt,
|
|
"training_add_annotation": add_training_annotation,
|
|
"training_submit_attempt": submit_training_attempt,
|
|
"training_progress": get_training_progress,
|
|
}
|
|
|
|
|
|
STATIC_DIR = Path(__file__).resolve().parents[1] / "web"
|
|
MAX_TRAINING_LIMIT = 100
|
|
|
|
|
|
def route_request(
|
|
path: str,
|
|
query: dict,
|
|
dependencies: Optional[dict] = None,
|
|
method: str = "GET",
|
|
body: Optional[dict] = None,
|
|
) -> tuple[int, dict]:
|
|
method = method.upper()
|
|
path_parts = _path_parts(path)
|
|
|
|
if method == "POST":
|
|
_validate_post_route_path(path)
|
|
if _is_training_progress_post(method, path):
|
|
_validate_progress_identifier_body(body or {})
|
|
return 404, {"error": "not_found"}
|
|
if _is_training_attempt_create_post(method, path):
|
|
_validate_attempt_create_body(body or {})
|
|
|
|
deps = dependencies or build_api_dependencies()
|
|
|
|
if method == "GET" and path == "/health":
|
|
return 200, deps["health"]()
|
|
if method == "GET" and path == "/trading/broker-state":
|
|
instrument_id = _require_int(query, "instrument_id")
|
|
return 200, deps["broker_state"](instrument_id=instrument_id)
|
|
if method == "GET" and path == "/candles":
|
|
instrument_id = _require_int(query, "instrument_id")
|
|
timeframe = _get_single(query, "timeframe", "1m")
|
|
limit = _get_int(query, "limit", 100)
|
|
return 200, {"items": deps["candles"](instrument_id=instrument_id, timeframe=timeframe, 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":
|
|
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 == "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 == "GET" and path == "/trading/runtime-status":
|
|
return 200, deps["trading_runtime_status"]()
|
|
if method == "POST" and path == "/trading/runtime/run-once":
|
|
return 200, deps["trading_runtime_run_once"]()
|
|
if method == "GET" and path == "/trading/decision-packet":
|
|
instrument_id = _require_int(query, "instrument_id")
|
|
return 200, deps["trading_decision_packet"](instrument_id=instrument_id)
|
|
if method == "GET" and path == "/trading/alerts":
|
|
instrument_id = _require_int(query, "instrument_id")
|
|
limit = _get_int(query, "limit", 20)
|
|
return 200, {"items": deps["trading_decision_alerts"](instrument_id=instrument_id, limit=limit)}
|
|
if method == "POST" and path == "/trading/reconcile":
|
|
instrument_id = _require_json_int(body or {}, "instrument_id")
|
|
return 200, deps["trading_reconciliation_run"](instrument_id=instrument_id)
|
|
if method == "GET" and path == "/trading/trust-score":
|
|
instrument_id = _require_int(query, "instrument_id")
|
|
return 200, deps["trading_trust_score"](instrument_id=instrument_id)
|
|
if method == "GET" and path == "/trading/session-view":
|
|
instrument_id = _require_int(query, "instrument_id")
|
|
limit = _get_int(query, "limit", 12)
|
|
return 200, deps["trading_session_view"](instrument_id=instrument_id, limit=limit)
|
|
if method == "GET" and path == "/trading/operator-notes":
|
|
instrument_id = _require_int(query, "instrument_id")
|
|
limit = _get_int(query, "limit", 20)
|
|
return 200, {"items": deps["trading_operator_notes"](instrument_id=instrument_id, limit=limit)}
|
|
if method == "POST" and path == "/trading/operator-notes":
|
|
instrument_id = _require_json_int(body or {}, "instrument_id")
|
|
return 201, deps["trading_operator_note_create"](
|
|
instrument_id=instrument_id,
|
|
note_scope=_get_body_value(body, "note_scope", "general"),
|
|
author=_get_body_value(body, "author", "local_operator"),
|
|
body=_get_body_value(body, "body", ""),
|
|
session_code=_get_body_value(body, "session_code"),
|
|
execution_id=_get_body_optional_int(body, "execution_id"),
|
|
signal_id=_get_body_optional_int(body, "signal_id"),
|
|
candidate_id=_get_body_optional_int(body, "candidate_id"),
|
|
decision_packet_id=_get_body_optional_int(body, "decision_packet_id"),
|
|
tags=_get_body_list(body, "tags"),
|
|
)
|
|
if method == "GET" and path == "/trading/review-packet":
|
|
instrument_id = _require_int(query, "instrument_id")
|
|
return 200, deps["trading_review_packet"](instrument_id=instrument_id)
|
|
if method == "GET" and path == "/trading/live-readiness":
|
|
instrument_id = _require_int(query, "instrument_id")
|
|
timeframe = _get_single(query, "timeframe", "1m")
|
|
return 200, deps["trading_live_readiness"](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/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 == "/trading/executions":
|
|
instrument_id = _require_int(query, "instrument_id")
|
|
limit = _get_int(query, "limit", 20)
|
|
return 200, {"items": deps["trading_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 == "/live-operation-audits":
|
|
instrument_id = _get_optional_int(query, "instrument_id")
|
|
execution_ticket_id = _get_optional_int(query, "execution_ticket_id")
|
|
if instrument_id is None and execution_ticket_id is None:
|
|
raise ValueError("missing_required_query_param:instrument_id_or_execution_ticket_id")
|
|
limit = _get_int(query, "limit", 20)
|
|
return 200, {
|
|
"items": deps["live_operation_audits"](
|
|
instrument_id=instrument_id,
|
|
execution_ticket_id=execution_ticket_id,
|
|
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] == "close":
|
|
payload = deps["trading_execution_close"](_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")
|
|
instrument_id = _get_optional_int(query, "instrument_id")
|
|
limit = _get_limit(query, "limit", 100, max_value=MAX_TRAINING_LIMIT)
|
|
quality_rank = parse_quality_rank_query(_get_single(query, "quality_rank"))
|
|
min_quality_score = parse_min_quality_score_query(_get_single(query, "min_quality_score"))
|
|
sort_by = parse_quality_sort_query(_get_single(query, "sort_by"))
|
|
case_kwargs = {
|
|
"status": status,
|
|
"skill_tag": skill_tag,
|
|
"instrument_id": instrument_id,
|
|
"limit": limit,
|
|
}
|
|
if quality_rank is not None:
|
|
case_kwargs["quality_rank"] = quality_rank
|
|
if min_quality_score is not None:
|
|
case_kwargs["min_quality_score"] = min_quality_score
|
|
if sort_by is not None:
|
|
case_kwargs["sort_by"] = sort_by
|
|
return 200, {
|
|
"items": deps["training_cases"](**case_kwargs)
|
|
}
|
|
if method == "GET" and path == "/training/cases/export":
|
|
status = _get_single(query, "status")
|
|
skill_tag = _get_single(query, "skill_tag")
|
|
instrument_id = _get_optional_int(query, "instrument_id")
|
|
limit = _get_limit(query, "limit", 100, max_value=MAX_TRAINING_LIMIT)
|
|
quality_rank = parse_quality_rank_query(_get_single(query, "quality_rank"))
|
|
min_quality_score = parse_min_quality_score_query(_get_single(query, "min_quality_score"))
|
|
sort_by = parse_quality_sort_query(_get_single(query, "sort_by"))
|
|
export_kwargs = {
|
|
"status": status,
|
|
"skill_tag": skill_tag,
|
|
"instrument_id": instrument_id,
|
|
"limit": limit,
|
|
}
|
|
if quality_rank is not None:
|
|
export_kwargs["quality_rank"] = quality_rank
|
|
if min_quality_score is not None:
|
|
export_kwargs["min_quality_score"] = min_quality_score
|
|
if sort_by is not None:
|
|
export_kwargs["sort_by"] = sort_by
|
|
return 200, deps["training_export_cases"](**export_kwargs)
|
|
if method == "POST" and path == "/training/cases/import":
|
|
return 201, deps["training_import_cases"](body or {})
|
|
if method == "GET" and len(path_parts) == 3 and path_parts[:2] == ["training", "cases"]:
|
|
case_id = _parse_path_int(path_parts[2], "case_id")
|
|
case = deps["training_case"](case_id=case_id)
|
|
if case is None:
|
|
return 404, {"error": "not_found"}
|
|
return 200, case
|
|
if method == "POST" and path == "/training/attempts":
|
|
return 201, deps["training_create_attempt"](body or {})
|
|
if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["training", "attempts"] and path_parts[3] == "annotations":
|
|
attempt_id = _parse_path_int(path_parts[2], "attempt_id")
|
|
return 201, deps["training_add_annotation"](attempt_id, body or {})
|
|
if method == "POST" and len(path_parts) == 4 and path_parts[:2] == ["training", "attempts"] and path_parts[3] == "submit":
|
|
attempt_id = _parse_path_int(path_parts[2], "attempt_id")
|
|
return 200, deps["training_submit_attempt"](attempt_id, body or {})
|
|
if method == "GET" and path == "/training/progress":
|
|
user_id = _get_single(query, "user_id", "local")
|
|
skill_tag = _get_single(query, "skill_tag")
|
|
limit = _get_limit(query, "limit", 100, max_value=MAX_TRAINING_LIMIT)
|
|
return 200, {"items": deps["training_progress"](user_id=user_id, skill_tag=skill_tag, limit=limit)}
|
|
return 404, {"error": "not_found"}
|
|
|
|
|
|
class APIRequestHandler(BaseHTTPRequestHandler):
|
|
dependencies = build_api_dependencies()
|
|
|
|
def do_GET(self) -> None:
|
|
parsed = urlparse(self.path)
|
|
if _should_serve_static(parsed.path):
|
|
self._serve_static(parsed.path)
|
|
return
|
|
try:
|
|
status, payload = route_request(parsed.path, parse_qs(parsed.query, keep_blank_values=True), dependencies=self.dependencies)
|
|
except ValueError as exc:
|
|
status, payload = 400, {"error": str(exc)}
|
|
except Exception:
|
|
status, payload = 500, {"error": "internal_server_error"}
|
|
self._send_json(status, payload)
|
|
|
|
def do_POST(self) -> None:
|
|
parsed = urlparse(self.path)
|
|
try:
|
|
_validate_post_route_path(parsed.path)
|
|
body = _read_json_body(self)
|
|
status, payload = route_request(
|
|
parsed.path,
|
|
parse_qs(parsed.query),
|
|
dependencies=self.dependencies,
|
|
method="POST",
|
|
body=body,
|
|
)
|
|
except ValueError as exc:
|
|
status, payload = 400, {"error": str(exc)}
|
|
except Exception:
|
|
status, payload = 500, {"error": "internal_server_error"}
|
|
self._send_json(status, payload)
|
|
|
|
def _send_json(self, status: int, payload: dict) -> None:
|
|
body = json.dumps(payload, default=str, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _serve_static(self, path: str) -> None:
|
|
file_path = _resolve_static_file(path)
|
|
if file_path is None or not file_path.exists() or not file_path.is_file():
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
return
|
|
body = file_path.read_bytes()
|
|
mime_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", f"{mime_type}; charset=utf-8" if mime_type.startswith("text/") or mime_type == "application/javascript" else mime_type)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, format: str, *args) -> None:
|
|
return
|
|
|
|
|
|
def serve_api(host: str = "127.0.0.1", port: int = 8000) -> None:
|
|
start_market_truth_runtime()
|
|
start_trading_runtime_service()
|
|
server = ThreadingHTTPServer((host, port), APIRequestHandler)
|
|
print(f"AI ICT API listening on http://{host}:{port}")
|
|
server.serve_forever()
|
|
|
|
|
|
def _require_int(query: dict, key: str) -> int:
|
|
value = _get_single(query, key)
|
|
if value is None:
|
|
raise ValueError(f"missing_required_query_param:{key}")
|
|
try:
|
|
return int(value)
|
|
except ValueError as exc:
|
|
raise ValueError(f"invalid_int_query_param:{key}") from exc
|
|
|
|
|
|
def _get_optional_int(query: dict, key: str) -> Optional[int]:
|
|
value = _get_single(query, key)
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except ValueError as exc:
|
|
raise ValueError(f"invalid_int_query_param:{key}") from exc
|
|
|
|
|
|
def _get_int(query: dict, key: str, default: int) -> int:
|
|
value = _get_single(query, key)
|
|
if value is None:
|
|
return default
|
|
try:
|
|
return int(value)
|
|
except ValueError as exc:
|
|
raise ValueError(f"invalid_int_query_param:{key}") from exc
|
|
|
|
|
|
def _get_limit(query: dict, key: str, default: int, max_value: int) -> int:
|
|
value = _get_single(query, key)
|
|
if value is None:
|
|
return default
|
|
try:
|
|
parsed = int(value)
|
|
except ValueError as exc:
|
|
raise ValueError(f"invalid_limit_query_param:{key}") from exc
|
|
if parsed < 1 or parsed > max_value:
|
|
raise ValueError(f"invalid_limit_query_param:{key}")
|
|
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_body_list(body: Optional[dict], key: str) -> list:
|
|
if not isinstance(body, dict):
|
|
return []
|
|
value = body.get(key)
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, list):
|
|
return value
|
|
return [value]
|
|
|
|
|
|
def _get_single(query: dict, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
values = query.get(key)
|
|
if not values:
|
|
return default
|
|
return values[0]
|
|
|
|
|
|
def _is_training_progress_post(method: str, path: str) -> bool:
|
|
return method == "POST" and path == "/training/progress"
|
|
|
|
|
|
def _is_training_attempt_create_post(method: str, path: str) -> bool:
|
|
return method == "POST" and path == "/training/attempts"
|
|
|
|
|
|
def _path_parts(path: str) -> list[str]:
|
|
return [part for part in path.strip("/").split("/") if part]
|
|
|
|
|
|
def _validate_post_route_path(path: str) -> None:
|
|
path_parts = _path_parts(path)
|
|
if (
|
|
len(path_parts) == 4
|
|
and path_parts[:2] == ["training", "attempts"]
|
|
and path_parts[3] in {"annotations", "submit"}
|
|
):
|
|
_parse_path_int(path_parts[2], "attempt_id")
|
|
|
|
|
|
def _parse_path_int(value: str, key: str) -> int:
|
|
try:
|
|
parsed = int(value)
|
|
except ValueError as exc:
|
|
raise ValueError(f"invalid_path_param:{key}") from exc
|
|
if parsed < 1:
|
|
raise ValueError(f"invalid_path_param:{key}")
|
|
return parsed
|
|
|
|
|
|
def _validate_progress_identifier_body(body: dict) -> None:
|
|
for key in ("attempt_id", "case_id"):
|
|
if key in body:
|
|
_parse_body_identifier(body[key], key)
|
|
|
|
|
|
def _validate_attempt_create_body(body: dict) -> None:
|
|
_parse_required_json_identifier(body, "case_id")
|
|
|
|
|
|
def _parse_required_json_identifier(body: dict, key: str) -> int:
|
|
if not isinstance(body, dict):
|
|
raise ValueError("invalid_json_body")
|
|
if body.get(key) is None:
|
|
raise ValueError(f"missing_required_json_field:{key}")
|
|
value = body[key]
|
|
if isinstance(value, bool):
|
|
raise ValueError(f"invalid_json_field:{key}")
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"invalid_json_field:{key}") from exc
|
|
if parsed < 1:
|
|
raise ValueError(f"invalid_json_field:{key}")
|
|
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}")
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"invalid_body_field:{key}") from exc
|
|
if parsed < 1:
|
|
raise ValueError(f"invalid_body_field:{key}")
|
|
return parsed
|
|
|
|
|
|
def _read_json_body(handler: BaseHTTPRequestHandler) -> dict:
|
|
try:
|
|
length = int(handler.headers.get("Content-Length", "0"))
|
|
except ValueError as exc:
|
|
raise ValueError("invalid_content_length") from exc
|
|
raw_body = handler.rfile.read(length) if length else b""
|
|
if not raw_body:
|
|
return {}
|
|
try:
|
|
payload = json.loads(raw_body.decode("utf-8"))
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
raise ValueError("invalid_json_body") from exc
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("invalid_json_body")
|
|
return payload
|
|
|
|
|
|
def _should_serve_static(path: str) -> bool:
|
|
return path in {"/", "/training", "/training.html"} or path.startswith("/static/")
|
|
|
|
|
|
def _resolve_static_file(path: str) -> Optional[Path]:
|
|
if path == "/":
|
|
return STATIC_DIR / "index.html"
|
|
if path in {"/training", "/training.html"}:
|
|
return STATIC_DIR / "training.html"
|
|
relative = path.removeprefix("/static/")
|
|
resolved = (STATIC_DIR / relative).resolve()
|
|
try:
|
|
resolved.relative_to(STATIC_DIR.resolve())
|
|
except ValueError:
|
|
return None
|
|
return resolved
|