Files
ai-exchange/src/api/server.py
T
2026-05-06 16:03:40 +08:00

405 lines
16 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.backtest import get_recent_backtest_summaries
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.sessions import get_recent_sessions
from src.api.signals import get_recent_signals
from src.api.trade_plan_candidates import get_recent_trade_plan_candidates
from src.api.trading_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
def build_api_dependencies() -> dict:
execution_service = ExecutionService()
return {
"health": get_health,
"candles": get_recent_candles,
"execution_tickets": get_recent_execution_tickets,
"sessions": get_recent_sessions,
"signals": get_recent_signals,
"trade_plan_candidates": get_recent_trade_plan_candidates,
"trading_plans": get_recent_trading_plans,
"backtests": get_recent_backtest_summaries,
"executions": execution_service.build_execution_dashboard_items,
"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 == "/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 == "GET" and path == "/sessions":
instrument_id = _require_int(query, "instrument_id")
limit = _get_int(query, "limit", 20)
return 200, {"items": deps["sessions"](instrument_id=instrument_id, limit=limit)}
if method == "GET" and path == "/signals":
instrument_id = _require_int(query, "instrument_id")
limit = _get_int(query, "limit", 20)
return 200, {"items": deps["signals"](instrument_id=instrument_id, limit=limit)}
if method == "GET" and path == "/trade-plan-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 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 == "/backtests":
instrument_id = _get_optional_int(query, "instrument_id")
limit = _get_int(query, "limit", 10)
return 200, {"items": deps["backtests"](instrument_id=instrument_id, limit=limit)}
if method == "GET" and path == "/executions":
instrument_id = _require_int(query, "instrument_id")
limit = _get_int(query, "limit", 20)
return 200, {"items": deps["executions"](instrument_id=instrument_id, limit=limit)}
if method == "GET" and path == "/execution-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 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:
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_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 _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 == "/" or path.startswith("/static/")
def _resolve_static_file(path: str) -> Optional[Path]:
if path == "/":
return STATIC_DIR / "index.html"
relative = path.removeprefix("/static/")
resolved = (STATIC_DIR / relative).resolve()
try:
resolved.relative_to(STATIC_DIR.resolve())
except ValueError:
return None
return resolved