Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
57ee1b1
Phase 5: add inventory-aware quoting
btorressz Oct 5, 2026
9999a38
Phase 5: style inventory dashboard
btorressz Oct 5, 2026
d0a8a4d
Phase 5: add inventory panel styles
btorressz Oct 5, 2026
0736327
Phase 5: refine inventory metrics
btorressz Oct 5, 2026
6c1bc17
Phase 5: keep roadmap in review pending full acceptance
btorressz Oct 5, 2026
932d359
Phase 5: add inventory policy acceptance tests
btorressz Oct 5, 2026
900e527
Phase 5: add inventory runtime acceptance tests
btorressz Oct 5, 2026
4abd345
Phase 5: document inventory-aware quoting
btorressz Oct 5, 2026
a87e0fb
Phase 5: document inventory-aware quoting
btorressz Oct 5, 2026
4a6770a
Phase 5: document inventory-aware quoting
btorressz Oct 5, 2026
7580580
Phase 5: document inventory-aware quoting
btorressz Oct 5, 2026
16ea067
Phase 5: add inventory skew reference
btorressz Oct 5, 2026
0115bc5
Phase 5: refine inventory currency styling
btorressz Oct 5, 2026
d5d9dc3
Phase 5: preserve baseline size under inventory skew
btorressz Oct 5, 2026
a6f9589
Phase 5: document baseline-preserving size skew
btorressz Oct 5, 2026
6c2f612
Phase 5: document baseline-preserving size skew
btorressz Oct 5, 2026
fc68e18
CI: validate Phase 5 acceptance gate
btorressz Oct 5, 2026
ebb5ae2
Phase 5: harden TESTNET position normalization
btorressz Oct 5, 2026
762998a
Phase 5: fix inventory stylesheet newline
btorressz Oct 5, 2026
bdfb5bf
Phase 5: remove literal CSS escape
btorressz Oct 5, 2026
60df847
Phase 5: keep position observability independent of fair value
btorressz Oct 5, 2026
47ee8a9
Phase 5: cover normalized positions API
btorressz Oct 5, 2026
fd4c650
Phase 5: mark accepted roadmap status complete
btorressz Oct 5, 2026
0403b77
Phase 5: record accepted completion
btorressz Oct 5, 2026
25a3b75
Remove Phase 5 GitHub Actions workflow
btorressz Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

**HyperAMM converts a mathematical AMM liquidity curve into discrete order-book liquidity for Hyperliquid.** It is not an on-chain pool. The system uses virtual constant-product reserves as a deterministic liquidity model, samples that curve around a market-derived fair value, optionally concentrates liquidity near the reference range, normalizes prices/sizes, and reconciles the desired ladder into resting CLOB orders.

Phases 1–4 are implemented as one integrated Python/FastAPI + React/TypeScript system. PAPER is the default execution mode; signed Hyperliquid testnet orders are separately guarded. Mainnet trading, withdrawals, transfers and bridging are deliberately out of scope.
Phases 1–5 are implemented as one integrated Python/FastAPI + React/TypeScript system. PAPER is the default execution mode; signed Hyperliquid testnet orders are separately guarded. Mainnet trading, withdrawals, transfers and bridging are deliberately out of scope.

## Why a virtual AMM?

Expand Down Expand Up @@ -31,12 +31,12 @@ Hyperliquid L2 / Demo Feed
PAPER execution or guarded TESTNET
```

## Phase 1–4 capabilities
## Phase 1–5 capabilities

- **Phase 1 — Market data + paper execution:** normalized L2/BBO state, WebSocket subscription adapter, initial L2 snapshot, monotonic exchange-time handling, reconnection/degraded states, explicit deterministic demo feed, paper orders/fills, optional testnet adapter.
- **Phase 2 — Virtual constant-product AMM:** invariant, marginal price, reserve initialization/recentering, base→quote and quote→base virtual swaps, curve-state calculations.
- **Phase 3 — AMM curve → CLOB compiler:** deterministic bid/ask levels, tick and size normalization, stable side ordering, no-cross checks, and stateful quote reconciliation.
- **Phase 4 — Concentrated liquidity:** bounded deterministic weighting that shifts more fixed liquidity near fair value as concentration increases.
- **Phase 4 — Concentrated liquidity:** bounded deterministic weighting that shifts more fixed liquidity near fair value as concentration increases.\n- **Phase 5 — Inventory-aware quoting:** normalized PAPER/TESTNET inventory, bounded reservation-price and side-size skew, hard-limit side suppression, inventory freshness/version checks, and terminal controls/visibility.

A minimal Phase 1–4 risk authority enforces freshness, level count, per-order size, aggregate notional, minimum price, quote distance, execution state and a kill switch. The kill switch cancels active strategy orders and blocks new quote generation.

Expand Down Expand Up @@ -153,7 +153,7 @@ Live Hyperliquid integration is intentionally not required by normal unit tests.

## Limitations

Phases 1–4 intentionally omit inventory-aware skew, adaptive volatility/order-book imbalance logic, perp vAMM/funding context, external oracle protection, full institutional risk firewall, AI agents, optimization/simulation, persistent vault accounting and production mainnet trading.
Phase 5 intentionally stops at deterministic inventory-aware quoting. Adaptive volatility/order-book imbalance logic, perp vAMM/funding context, external oracle protection, full institutional risk firewall, AI agents, optimization/simulation, persistent vault accounting and production mainnet trading remain out of scope.

## Documentation

Expand All @@ -164,7 +164,7 @@ Phases 1–4 intentionally omit inventory-aware skew, adaptive volatility/order-

## 12-phase roadmap

Phases 1–4 are the implemented foundation. Phase 5 adds inventory-aware quoting; Phase 6 volatility/book imbalance; Phase 7 perpetual vAMM context; Phase 8 oracle protection/risk firewall; Phase 9 AI supervisory agents; Phase 10 optimization/simulation; Phase 11 vault/accounting; Phase 12 the expanded production trading terminal.
Phases 1–5 are the implemented foundation. Phase 6 adds volatility/book imbalance; Phase 7 perpetual vAMM context; Phase 8 oracle protection/risk firewall; Phase 9 AI supervisory agents; Phase 10 optimization/simulation; Phase 11 vault/accounting; Phase 12 the expanded production trading terminal.

## Phase 4.1 acceptance and hardening

Expand All @@ -183,3 +183,12 @@ queries, including partial fills and uncertain order states. See
[architecture](docs/ARCHITECTURE.md) and [integration](docs/HYPERLIQUID_INTEGRATION.md).
Normal acceptance tests use deterministic fixtures and require no live venue or
wallet. Phases 5–8 remain planned and are not implemented in this change.


## Phase 5 — Inventory-Aware Quoting

Phase 5 composes after the accepted neutral AMM/concentration ladder and before deterministic risk/reconciliation. PAPER inventory is derived from economic fills only. TESTNET inventory is normalized from Hyperliquid account state and fails closed when authoritative state is missing, stale, malformed, or economically uncertain.

For deviation `d = position - target`, normal skew uses `r = clamp(d / soft_limit, -1, 1)`. The reservation shift is `-r * max_inventory_price_skew_bps`; bid and ask sizes use bounded `1 - strength*r` and `1 + strength*r` side multipliers. At the long hard limit, inventory-increasing bids are omitted; at the short hard limit, inventory-increasing asks are omitted. Existing reconciliation therefore cancels forbidden resting quotes without a second order-management path.

See [Inventory Skew](docs/INVENTORY_SKEW.md) for formulas, source semantics, failure behavior, and test coverage.
4 changes: 4 additions & 0 deletions backend/app/amm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ class QuoteLevel(BaseModel):
distance_bps: Decimal = Field(ge=0)
source_model: AmmModel
state: str = "DESIRED"
neutral_price: Decimal | None = None
neutral_size: Decimal | None = None
inventory_intent: str | None = None
inventory_effect: str | None = None
11 changes: 8 additions & 3 deletions backend/app/api/positions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
from fastapi import APIRouter
router=APIRouter(tags=["positions"])
from fastapi import APIRouter, Depends
from app.dependencies import runtime

router = APIRouter(tags=["positions"])


@router.get("/positions")
async def positions(): return {"positions":[],"note":"Account positions are not required for Phase 1-4 paper mode."}
async def positions(rt=Depends(runtime)):
return await rt.inventory_summary(refresh=rt.config.execution_mode.value == "TESTNET")
51 changes: 18 additions & 33 deletions backend/app/api/strategy.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,22 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter,Depends,HTTPException
from app.dependencies import runtime
from app.strategy.models import StrategyConfig
router=APIRouter(tags=["strategy"])

@router.get("/strategy")
async def get_strategy(rt=Depends(runtime)):
return rt.strategy

@router.put("/strategy")
async def put_strategy(config: StrategyConfig, rt=Depends(runtime)):
return await rt.update_config(config)

@router.post("/strategy/start")
router=APIRouter(tags=['strategy'])
@router.get('/strategy')
async def get_strategy(rt=Depends(runtime)):return rt.strategy
@router.put('/strategy')
async def put_strategy(config:StrategyConfig,rt=Depends(runtime)):return await rt.update_config(config)
@router.post('/strategy/start')
async def start(rt=Depends(runtime)):
try:
return await rt.start_strategy()
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc

@router.post("/strategy/stop")
async def stop(rt=Depends(runtime)):
return await rt.stop_strategy()

@router.get("/amm/state")
async def amm_state(rt=Depends(runtime)):
return {"fair_value":rt.fair_value,"pool":rt.pool,"model":rt.config.amm_model}

@router.get("/amm/curve")
try:return await rt.start_strategy()
except PermissionError as exc:raise HTTPException(status_code=403,detail=str(exc)) from exc
@router.post('/strategy/stop')
async def stop(rt=Depends(runtime)):return await rt.stop_strategy()
@router.get('/amm/state')
async def amm_state(rt=Depends(runtime)):return {'fair_value':rt.fair_value,'pool':rt.pool,'model':rt.config.amm_model,'inventory':rt._inventory_payload(rt.inventory,rt.inventory_decision) if rt.inventory else None}
@router.get('/amm/curve')
async def curve(rt=Depends(runtime)):
if rt.fair_value is None:
await rt.refresh_once()
return {"fair_value":rt.fair_value,"quotes":rt.quotes}

@router.get("/amm/quotes")
async def quotes(rt=Depends(runtime)):
return rt.quotes
if rt.fair_value is None:await rt.refresh_once()
return {'fair_value':rt.fair_value,'quotes':rt.quotes,'inventory':rt._inventory_payload(rt.inventory,rt.inventory_decision) if rt.inventory else None}
@router.get('/amm/quotes')
async def quotes(rt=Depends(runtime)):return rt.quotes
82 changes: 64 additions & 18 deletions backend/app/execution/hyperliquid.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ def __init__(self, *, enabled: bool, private_key: str | None, account_address: s
self.last_reconciled_at=None
self.reconciliation_error=None
self._orders: dict[str, StrategyOrder]={}
self._positions: dict[str, Decimal]={}
self._position_updated_at=None
self._position_error=None
self._position_version=0

def _require_enabled(self):
if not self.enabled:
Expand Down Expand Up @@ -67,7 +71,6 @@ def _normalize_for_sdk(exchange, req: OrderRequest) -> tuple[float, float]:
raise ValueError("price normalizes to non-positive value")
return price,float(size)


@staticmethod
def _parse_order_response(response) -> tuple[OrderStatus, str | None]:
if not isinstance(response, dict) or response.get("status") != "ok":
Expand All @@ -84,6 +87,58 @@ def _parse_order_response(response) -> tuple[OrderStatus, str | None]:
return OrderStatus.FILLED, str(status["filled"].get("oid")) if status["filled"].get("oid") is not None else None
return OrderStatus.UNKNOWN, None

@staticmethod
def normalize_user_position(user_state, market: str) -> Decimal:
if not isinstance(user_state, dict):
raise ValueError("invalid Hyperliquid user state")
positions=user_state.get("assetPositions")
if not isinstance(positions,list):
raise ValueError("Hyperliquid user state missing assetPositions")
position=Decimal("0")
matched=0
for item in positions:
if not isinstance(item,dict) or not isinstance(item.get("position"),dict):
raise ValueError("malformed Hyperliquid asset position")
raw=item["position"]
if raw.get("coin") != market:
continue
if "szi" not in raw:
raise ValueError("Hyperliquid position missing signed base size")
try:
value=Decimal(str(raw["szi"]))
except Exception as exc:
raise ValueError("invalid Hyperliquid signed base position") from exc
if not value.is_finite():
raise ValueError("non-finite Hyperliquid position")
position += value
matched += 1
if matched > 1:
raise ValueError("duplicate Hyperliquid positions for configured market")
return position

async def refresh_position(self, market: str) -> Decimal:
try:
info=await self._venue_client()
state=await asyncio.to_thread(info.user_state,self.account_address)
position=self.normalize_user_position(state,market)
if market not in self._positions or self._positions[market] != position:
self._position_version += 1
self._positions[market]=position
self._position_updated_at=utcnow()
self._position_error=None
return position
except Exception as exc:
self._position_error=str(exc)
raise

def position_snapshot(self, market: str):
if market not in self._positions or self._position_updated_at is None:
return None
return self._positions[market],self._position_updated_at,self._position_version,self._position_error

def has_unknown_exposure(self) -> bool:
return any(order.status == OrderStatus.UNKNOWN for order in self._orders.values())

async def _venue_client(self):
self._require_enabled()
if self._info is None:
Expand All @@ -93,8 +148,6 @@ async def _venue_client(self):
if not self._subscriptions:
loop=asyncio.get_running_loop()
def changed(_message):
# SDK callbacks run on its WS thread. Only wake the serialized
# authoritative reader; never mutate orders from that thread.
if not loop.is_closed():
loop.call_soon_threadsafe(self.venue_changed.set)
for kind in ("orderUpdates","userFills"):
Expand Down Expand Up @@ -136,11 +189,6 @@ def _apply_venue_order(self, order, data, status, timestamp=None):
order.updated_at=datetime.fromtimestamp(timestamp/1000,timezone.utc) if timestamp else utcnow()

async def reconcile_venue(self):
"""Authoritative account snapshot, then status lookup for missing tracked IDs.

Missing is not synonymous with cancelled. Unknown exposure remains
cancellable and blocks new orders until resolved.
"""
if not self._orders:
return
try:
Expand Down Expand Up @@ -176,8 +224,6 @@ async def reconcile_venue(self):

@staticmethod
async def _transmit(method, *args):
# Cancelling an asyncio waiter cannot stop a requests/SDK worker thread.
# Keep the caller's execution lock held until the signed call finishes.
task=asyncio.create_task(asyncio.to_thread(method,*args))
try:
return await asyncio.shield(task)
Expand All @@ -197,7 +243,6 @@ async def submit_orders(self, orders: list[OrderRequest]) -> list[StrategyOrder]
price,size=self._normalize_for_sdk(exchange,req)
if self.authority is not None:
await self.authority()
# Record uncertain exposure BEFORE transmission, including timeouts.
values=req.model_dump()
values.update(price=Decimal(str(price)),size=Decimal(str(size)))
order=StrategyOrder(**values,status=OrderStatus.UNKNOWN)
Expand All @@ -207,9 +252,12 @@ async def submit_orders(self, orders: list[OrderRequest]) -> list[StrategyOrder]
{"limit":{"tif":"Alo"}}, False, self._cloid(req.client_order_id)
)
status,venue_order_id=self._parse_order_response(response)
order.status=status; order.venue_order_id=venue_order_id; order.updated_at=utcnow()
order.status=status
order.venue_order_id=venue_order_id
order.updated_at=utcnow()
if status==OrderStatus.FILLED:
order.filled_size=order.size
self.venue_changed.set()
if status in {OrderStatus.REJECTED,OrderStatus.UNKNOWN}:
raise RuntimeError(f"Hyperliquid strategy order status: {status}")
result.append(order)
Expand All @@ -227,13 +275,12 @@ async def cancel_orders(self, client_order_ids: list[str]):
response=await self._transmit(exchange.cancel_by_cloid,order.market,self._cloid(order.client_order_id))
statuses=response.get("response",{}).get("data",{}).get("statuses",[])
if response.get("status") != "ok" or statuses != ["success"]:
# A fill may win the cancellation race. Only venue terminal
# evidence makes that cancellation failure safe.
await self.reconcile_venue()
if self._active(order):
raise RuntimeError("venue did not confirm cancellation")
else:
order.status=OrderStatus.CANCELLED; order.updated_at=utcnow()
order.status=OrderStatus.CANCELLED
order.updated_at=utcnow()
self._needs_verification.add(order.client_order_id)
result.append(order)
except Exception as exc:
Expand All @@ -245,12 +292,11 @@ async def cancel_orders(self, client_order_ids: list[str]):
async def replace_orders(self, replacements):
out=[]
for cid, req in replacements:
await self.cancel_orders([cid]); out.extend(await self.submit_orders([req]))
await self.cancel_orders([cid])
out.extend(await self.submit_orders([req]))
return out

async def get_open_orders(self):
# Cache projection only: the manager reconciles before decisions and the
# runtime also reconciles on WS events and on a five-second timer.
return [o for o in self._orders.values() if self._active(o)]

async def cancel_all(self):
Expand Down
32 changes: 16 additions & 16 deletions backend/app/execution/order_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,35 @@

class OrderManager:
def __init__(self, execution, lock=None, authority=None):
self.execution=execution
self.lock=lock if lock is not None else asyncio.Lock()
self.authority=authority or (lambda: None)
self._nonce=0
self.execution = execution
self.lock = lock if lock is not None else asyncio.Lock()
self.authority = authority or (lambda: None)
self._nonce = 0

def request_for(self, market: str, quote: QuoteLevel) -> OrderRequest:
self._nonce += 1
key=f"{market}:{quote.side}:{quote.level_index}:{quote.price}:{quote.size}:{self._nonce}"
cid="hamm-"+hashlib.sha256(key.encode()).hexdigest()[:20]
return OrderRequest(client_order_id=cid,market=market,side=quote.side,price=quote.price,size=quote.size,level_index=quote.level_index)
key = f"{market}:{quote.side}:{quote.level_index}:{quote.price}:{quote.size}:{self._nonce}"
cid = "hamm-" + hashlib.sha256(key.encode()).hexdigest()[:20]
return OrderRequest(client_order_id=cid, market=market, side=quote.side, price=quote.price, size=quote.size, level_index=quote.level_index)

async def reconcile(self, market: str, desired, price_tolerance_bps, size_tolerance):
async with self.lock:
return await self.reconcile_locked(market,desired,price_tolerance_bps,size_tolerance)
return await self.reconcile_locked(market, desired, price_tolerance_bps, size_tolerance)

async def reconcile_locked(self, market, desired, price_tolerance_bps, size_tolerance):
async def reconcile_locked(self, market, desired, price_tolerance_bps, size_tolerance, *, venue_reconciled=False):
"""Runtime may hold the shared execution lock across generation and reconciliation."""
if hasattr(self.execution, "reconcile_venue"):
if hasattr(self.execution, "reconcile_venue") and not venue_reconciled:
await self.execution.reconcile_venue()
existing=await self.execution.get_open_orders()
actions=reconcile_quotes(desired,existing,price_tolerance_bps,size_tolerance)
existing = await self.execution.get_open_orders()
actions = reconcile_quotes(desired, existing, price_tolerance_bps, size_tolerance)
for action in actions:
if action.action in {ReconcileActionType.CANCEL,ReconcileActionType.REPLACE}:
if action.action in {ReconcileActionType.CANCEL, ReconcileActionType.REPLACE}:
await self.execution.cancel_orders([action.existing.client_order_id])
if action.action in {ReconcileActionType.CREATE,ReconcileActionType.REPLACE}:
check=self.authority() # Final check immediately before each transmission.
if action.action in {ReconcileActionType.CREATE, ReconcileActionType.REPLACE}:
check = self.authority()
if inspect.isawaitable(check):
await check
await self.execution.submit_orders([self.request_for(market,action.desired)])
await self.execution.submit_orders([self.request_for(market, action.desired)])
return actions

async def cancel_all(self):
Expand Down
Loading