diff --git a/AGENTS.md b/AGENTS.md index 21539a2..2d02ddd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,13 @@ # ELAC-Plan Agents +Architecture and MVP changes +- Added a FastAPI-based MVP API (elac_plan.api) exposing /problems, /status, and /deltas endpoints to drive the LocalProblem -> PlanDelta flow locally. +- In-memory MVP storage for LocalProblem and PlanDelta with a toy LocalSolver and two starter adapters (NBBOFeedAdapter, BrokerGatewayAdapter). +- Public Python package surface updated: elac_plan.api is importable via elac_plan.api.app (FastAPI app), and re-exported from elac_plan for convenience. +- Tests: Added tests/test_api.py to validate problem creation and status endpoints using FastAPI TestClient. +- Test runner: test.sh added to execute pytest and python -m build for packaging validation. +- Documentation: README updated with MVP overview (next iteration to expand with Phase 1 governance, phase 2 cross-venue demos, etc.). + Architecture overview: - Primitives: LocalProblem, SharedVariables, DualVariables, PlanDelta, Governance/AuditLog - Adapters: NBBOFeedAdapter (data feed), BrokerGatewayAdapter (execution gateway) diff --git a/README.md b/README.md index 07fe31f..37c0f15 100644 --- a/README.md +++ b/README.md @@ -1 +1,22 @@ # ELAC-Plan (Edge-Latency Aware Cross-Venue Execution Planner) + +This repository implements a production-oriented MVP of ELAC-Plan: an edge-native federation for cross-venue execution planning with privacy-preserving signals and deterministic delta-sync. + +- Primitives map to canonical primitives used across adapters: + - Objects: LocalProblem + - Morphisms: SharedVariables + - DualVariables: DualVariables + - PlanDelta: PlanDelta + - Governance/AuditLog: GovernanceAuditLog (stubbed for now) +- MVP adapters: + - NBBOFeedAdapter: translates NBBO-like feeds into SharedVariables + - BrokerGatewayAdapter: simulates broker API publishing of PlanDelta +- Solver: LocalSolver (toy solver) +- API: FastAPI app exposing /problems and /status to drive LocalProblem -> PlanDelta flow +- Packaging: Python packaging metadata in pyproject.toml; tests with pytest; build via python -m build + +How to run (dev): +- Run tests: ./test.sh +- Run API locally (example): uvicorn elac_plan.api:app --reload + +This is a minimal but production-conscious MVP intended to be extended by adding governance ledger, secure aggregation, and real adapters in subsequent iterations. diff --git a/elac_plan/__init__.py b/elac_plan/__init__.py index e82433d..9ad6778 100644 --- a/elac_plan/__init__.py +++ b/elac_plan/__init__.py @@ -4,6 +4,7 @@ from .core import LocalProblem, SharedVariables, PlanDelta, DualVariables from .solver import LocalSolver from .adapters import NBBOFeedAdapter, BrokerGatewayAdapter from .dsl import DSLObject, DSLMorphisms, DSLDualVariables, DSLPlanDelta +from .api import app as api_app __all__ = [ "LocalProblem", @@ -17,4 +18,5 @@ __all__ = [ "DSLMorphisms", "DSLDualVariables", "DSLPlanDelta", + "api_app", ] diff --git a/elac_plan/api.py b/elac_plan/api.py index e2a42ad..35dfc89 100644 --- a/elac_plan/api.py +++ b/elac_plan/api.py @@ -1,17 +1,45 @@ from __future__ import annotations -from fastapi import FastAPI -from pydantic import BaseModel + +"""ELAC-Plan API + +Simple FastAPI-based MVP to exercise LocalProblem -> PlanDelta flow with two starter adapters. +This is intentionally lightweight and in-process to bootstrap cross-venue planning without +external dependencies beyond FastAPI, Pydantic (via existing dataclasses), and httpx for tests. +""" + +from datetime import datetime +import json from typing import Dict, Any, Optional +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + from .core import LocalProblem, PlanDelta, SharedVariables, DualVariables from .solver import LocalSolver from .adapters import NBBOFeedAdapter, BrokerGatewayAdapter +from .dsl import ( + DSLObject, + DSLMorphisms, + DSLDualVariables, + DSLPlanDelta, + to_dsl_object, + to_dsl_morphisms, + to_dsl_dual, + to_dsl_plan, +) -app = FastAPI(title="ELAC-Plan API") + +app = FastAPI(title="ELAC-Plan MVP API", version="0.1.0") + +# In-memory stores for MVP: problems and deltas +_problems: Dict[str, LocalProblem] = {} +_deltas: Dict[str, PlanDelta] = {} + +# Starter adapters (could be used by endpoints or in a factory pattern) +_nbbo = NBBOFeedAdapter(contract_id="default-contract") +_broker = BrokerGatewayAdapter() _solver = LocalSolver() -_feed = NBBOFeedAdapter() -_broker = BrokerGatewayAdapter() class LocalProblemInput(BaseModel): @@ -19,27 +47,113 @@ class LocalProblemInput(BaseModel): asset: str venue: str objective: str - constraints: Dict[str, Any] + constraints: Optional[Dict[str, Any]] = None price_target: float tolerance: float -@app.post("/problems") -def create_problem(p: LocalProblemInput) -> Dict[str, Any]: - problem = LocalProblem( - id=p.id, - asset=p.asset, - venue=p.venue, - objective=p.objective, - constraints=p.constraints, - price_target=p.price_target, - tolerance=p.tolerance, +@app.post("/problems", response_model=Dict[str, Any]) +def create_problem(problem: LocalProblemInput): + # Build a LocalProblem from input and run the toy solver to produce a delta + lp = LocalProblem( + id=problem.id, + asset=problem.asset, + venue=problem.venue, + objective=problem.objective, + constraints=problem.constraints or {}, + price_target=problem.price_target, + tolerance=problem.tolerance, ) - delta = _solver.solve(problem) + + _problems[lp.id] = lp + delta = _solver.solve(lp) + _deltas[delta.contract_id] = delta + + # Simulate publishing via broker gateway (stringized payload) for MVP _broker.publish(delta) - return {"problem": problem.to_json(), "delta": delta.to_json()} + + return { + "problem_id": lp.id, + "delta": json.dumps(delta.delta), + "delta_contract": delta.contract_id, + } @app.get("/status") -def status() -> Dict[str, Any]: - return {"status": "ELAC-Plan API running"} +def status(): + return { + "problems_count": len(_problems), + "deltas_count": len(_deltas), + "latest_delta_contract": max(_deltas.keys()) if _deltas else None, + "server_time": datetime.utcnow().isoformat() + "Z", + } + + +@app.get("/problems/{problem_id}") +def get_problem(problem_id: str): + lp = _problems.get(problem_id) + if not lp: + raise HTTPException(status_code=404, detail="problem not found") + return { + "id": lp.id, + "asset": lp.asset, + "venue": lp.venue, + "objective": lp.objective, + "price_target": lp.price_target, + "tolerance": lp.tolerance, + "constraints": lp.constraints, + } + + +@app.get("/deltas/{contract_id}") +def get_delta(contract_id: str): + delta = _deltas.get(contract_id) + if not delta: + raise HTTPException(status_code=404, detail="delta not found") + return { + "contract_id": delta.contract_id, + "delta": delta.delta, + "timestamp": delta.timestamp, + "author": delta.author, + "privacy_budget": delta.privacy_budget, + } + + +@app.get("/dsl/{problem_id}") +def get_problem_dsl(problem_id: str): + # Expose a minimal DSL representation for a given problem, including + # LocalProblem -> DSLObject, SharedVariables, DualVariables, and PlanDelta. + lp = _problems.get(problem_id) + if not lp: + raise HTTPException(status_code=404, detail="problem not found") + + # Build a deterministic contract_id as used by the MVP + contract_id = f"{lp.id}:{lp.venue}" + # Rehydrate a minimal SharedVariables and DualVariables for DSL exposure + sw = SharedVariables(variables={}, version=1, contract_id=contract_id) + dv = DualVariables(shadow_prices={}, version=1, contract_id=contract_id) + + # Resolve latest delta for the problem if available + delta = _deltas.get(contract_id) + if delta is None: + # create a minimal placeholder delta to showcase DSL shape + delta = PlanDelta( + delta={}, + timestamp="", + author="unknown", + contract_id=contract_id, + privacy_budget=0.0, + ) + + # Transform to DSL structures + dsl_lp = to_dsl_object(lp) + dsl_sw = to_dsl_morphisms(sw) + dsl_dv = to_dsl_dual(dv) + dsl_plan = to_dsl_plan(delta) + + return { + "lp": dsl_lp.to_dict(), + "shared_variables": dsl_sw.to_dict(), + "dual_variables": dsl_dv.to_dict(), + "plan_delta": dsl_plan.to_dict(), + } diff --git a/test.sh b/test.sh index 65917c6..6a25f12 100644 --- a/test.sh +++ b/test.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -# Run unit tests and packaging build to ensure MVP integrity -echo "Installing package in editable mode..." -pip install -e . export PYTHONPATH=$(pwd) + echo "Running tests..." pytest -q + echo "Building package..." python3 -m build -echo "OK: tests and build completed." + +echo "All tests passed and package built." +exit 0 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..16683a1 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,37 @@ +import json +import pytest + +from fastapi.testclient import TestClient + +from elac_plan.api import app + + +client = TestClient(app) + + +def test_create_problem_smoke(): + payload = { + "id": "probe-001", + "asset": "AAPL", + "venue": "XNAS", + "objective": "minimize_spread", + "constraints": {"max_slippage": 0.5}, + "price_target": 150.0, + "tolerance": 0.2, + } + resp = client.post("/problems", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["problem_id"] == payload["id"] + assert "delta" in data + # validate delta payload json can be parsed back + delta = json.loads(data["delta"]) + assert delta["action"] == "place_order" + + +def test_status_endpoint(): + resp = client.get("/status") + assert resp.status_code == 200 + body = resp.json() + assert "problems_count" in body + assert "deltas_count" in body