From 1f48273b2d76a77bb95e7363e8706b0eb3d0cd44 Mon Sep 17 00:00:00 2001 From: ahmedhamedaly Date: Thu, 23 Apr 2026 15:10:13 +0100 Subject: [PATCH 1/6] feat: add performance fee config, calculator, and period arithmetic --- src/wt3/core/performance_fee/__init__.py | 1 + src/wt3/core/performance_fee/calculator.py | 80 +++++++++++++++++++++ src/wt3/core/performance_fee/config.py | 31 ++++++++ src/wt3/core/performance_fee/periods.py | 82 ++++++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 src/wt3/core/performance_fee/__init__.py create mode 100644 src/wt3/core/performance_fee/calculator.py create mode 100644 src/wt3/core/performance_fee/config.py create mode 100644 src/wt3/core/performance_fee/periods.py diff --git a/src/wt3/core/performance_fee/__init__.py b/src/wt3/core/performance_fee/__init__.py new file mode 100644 index 0000000..741aedf --- /dev/null +++ b/src/wt3/core/performance_fee/__init__.py @@ -0,0 +1 @@ +"""Performance fee computation and automated quarterly payout.""" diff --git a/src/wt3/core/performance_fee/calculator.py b/src/wt3/core/performance_fee/calculator.py new file mode 100644 index 0000000..80be00f --- /dev/null +++ b/src/wt3/core/performance_fee/calculator.py @@ -0,0 +1,80 @@ +"""Pure performance-fee calculator. Zero I/O, fully deterministic. + +The output of this module is the authoritative source for the fee math — +everything else (data fetching, transfers, persistence) is an adapter +around this function. + +Formula (quarterly): + + hurdle_floor = hwm_in * (1 + 0.05/4) + + if end_nav > hurdle_floor: # strict greater-than + fee_owed = 0.30 * (end_nav - hwm_in) # catch-up: on ALL profit above HWM + hwm_out = max(peak_nav - fee_owed, hurdle_floor) + else: + fee_owed = 0 + hwm_out = hurdle_floor # HWM still ticks up by hurdle + +Fees are quantized DOWN to 6 decimals (USDC precision). The residual +stays with the trading account — never transfer more than calculated. +""" + +from dataclasses import dataclass +from datetime import datetime +from decimal import ROUND_DOWN, Decimal + +from .config import FEE_RATE, HURDLE_PER_PERIOD, USDC_QUANTUM + + +@dataclass(frozen=True) +class PeriodResult: + period_start: datetime + period_end: datetime + start_nav: Decimal + end_nav: Decimal + peak_nav: Decimal + hwm_in: Decimal + hurdle_floor: Decimal + hurdle_cleared: bool + fee_owed: Decimal + hwm_out: Decimal + + +def compute_period( + hwm_in: Decimal, + nav_series: list[tuple[int, Decimal]], + period_start: datetime, + period_end: datetime, +) -> PeriodResult: + if not nav_series: + raise ValueError("Cannot compute period with empty NAV series") + + start_nav = nav_series[0][1] + end_nav = nav_series[-1][1] + peak_nav = max(nav for _, nav in nav_series) + + hurdle_floor = hwm_in * (Decimal("1") + HURDLE_PER_PERIOD) + + if end_nav > hurdle_floor: + raw_fee = FEE_RATE * (end_nav - hwm_in) + fee_owed = raw_fee.quantize(USDC_QUANTUM, rounding=ROUND_DOWN) + hwm_candidate = peak_nav - fee_owed + hwm_out = max(hwm_candidate, hurdle_floor) + hurdle_cleared = True + else: + fee_owed = Decimal("0") + hwm_out = hurdle_floor + hurdle_cleared = False + + return PeriodResult( + period_start=period_start, + period_end=period_end, + start_nav=start_nav, + end_nav=end_nav, + peak_nav=peak_nav, + hwm_in=hwm_in, + hurdle_floor=hurdle_floor, + hurdle_cleared=hurdle_cleared, + fee_owed=fee_owed, + hwm_out=hwm_out, + ) diff --git a/src/wt3/core/performance_fee/config.py b/src/wt3/core/performance_fee/config.py new file mode 100644 index 0000000..bf4bc11 --- /dev/null +++ b/src/wt3/core/performance_fee/config.py @@ -0,0 +1,31 @@ +"""Performance fee constants. + +All monetary values use Decimal to avoid float rounding in money math. +These are the public, auditable parameters of the fee agreement between +Oasis and Moonward Capital. +""" + +from datetime import datetime, timezone +from decimal import Decimal + +INITIAL_HWM: Decimal = Decimal("20000") + +HURDLE_ANNUAL: Decimal = Decimal("0.05") +HURDLE_PER_PERIOD: Decimal = HURDLE_ANNUAL / Decimal("4") + +FEE_RATE: Decimal = Decimal("0.30") + +FEE_WALLET: str = "0xbf5f64d05e36a34bf43ea95e99657b09e4c7a1bb" + +START_DATE: datetime = datetime(2026, 5, 1, 0, 0, 0, tzinfo=timezone.utc) + +PERIOD_MONTHS: int = 3 + +USDC_DECIMALS: int = 6 +USDC_QUANTUM: Decimal = Decimal("0.000001") + +MAX_TRANSFER_RETRIES: int = 3 + +GRACE_SECONDS_AFTER_PERIOD_END: int = 3600 + +STATE_FILE_PATH: str = "/storage/data/hwm_state.json" diff --git a/src/wt3/core/performance_fee/periods.py b/src/wt3/core/performance_fee/periods.py new file mode 100644 index 0000000..e504431 --- /dev/null +++ b/src/wt3/core/performance_fee/periods.py @@ -0,0 +1,82 @@ +"""Period boundary arithmetic. + +Periods are 3 calendar months, anchored at START_DATE (2026-05-01 UTC). +Period 1: 2026-05-01 → 2026-07-31 23:59:59 UTC +Period 2: 2026-08-01 → 2026-10-31 23:59:59 UTC +... + +Pure date math. No I/O. +""" + +from __future__ import annotations + +from calendar import monthrange +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from .config import PERIOD_MONTHS, START_DATE + + +@dataclass(frozen=True) +class Period: + index: int + start: datetime + end: datetime + + @property + def start_ms(self) -> int: + return int(self.start.timestamp() * 1000) + + @property + def end_ms(self) -> int: + return int(self.end.timestamp() * 1000) + + +def _shift_months(dt: datetime, months: int) -> datetime: + """Add `months` calendar months to dt, keeping day-of-month where possible.""" + month_index = dt.month - 1 + months + year = dt.year + month_index // 12 + month = month_index % 12 + 1 + day = min(dt.day, monthrange(year, month)[1]) + return dt.replace(year=year, month=month, day=day) + + +def period_for_index(index: int) -> Period: + """Return the Period object for the given zero-based period index.""" + if index < 0: + raise ValueError(f"Period index must be >= 0, got {index}") + start = _shift_months(START_DATE, index * PERIOD_MONTHS) + next_start = _shift_months(START_DATE, (index + 1) * PERIOD_MONTHS) + end = next_start - timedelta(seconds=1) + return Period(index=index, start=start, end=end) + + +def closed_periods_before(now: datetime) -> list[Period]: + """All periods whose end < now, in chronological order.""" + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + result: list[Period] = [] + i = 0 + while True: + p = period_for_index(i) + if p.end >= now: + break + result.append(p) + i += 1 + return result + + +def current_period(now: datetime) -> Period | None: + """The period that contains `now`, or None if before START_DATE.""" + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + if now < START_DATE: + return None + i = 0 + while True: + p = period_for_index(i) + if p.start <= now <= p.end: + return p + if p.start > now: + return None + i += 1 From 8e91e994ff494a272748b73d9753bf274c5c709d Mon Sep 17 00:00:00 2001 From: ahmedhamedaly Date: Thu, 23 Apr 2026 17:43:39 +0100 Subject: [PATCH 2/6] feat: add nav source and fee ledger, extend pnl client with alltime --- src/wt3/clients/pnl.py | 6 +- src/wt3/core/performance_fee/fee_ledger.py | 119 +++++++++++++++++++++ src/wt3/core/performance_fee/nav_source.py | 103 ++++++++++++++++++ 3 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 src/wt3/core/performance_fee/fee_ledger.py create mode 100644 src/wt3/core/performance_fee/nav_source.py diff --git a/src/wt3/clients/pnl.py b/src/wt3/clients/pnl.py index ba4012e..52b74e9 100644 --- a/src/wt3/clients/pnl.py +++ b/src/wt3/clients/pnl.py @@ -69,13 +69,15 @@ async def get_portfolio_data(self) -> Dict[str, Any]: portfolio = { "day": None, "week": None, - "month": None + "month": None, + "allTime": None, } perp_mapping = { "perpDay": "day", "perpWeek": "week", - "perpMonth": "month" + "perpMonth": "month", + "perpAllTime": "allTime", } for item in data: diff --git a/src/wt3/core/performance_fee/fee_ledger.py b/src/wt3/core/performance_fee/fee_ledger.py new file mode 100644 index 0000000..253ef4f --- /dev/null +++ b/src/wt3/core/performance_fee/fee_ledger.py @@ -0,0 +1,119 @@ +"""On-chain fee-transfer ledger. + +Reads historical outbound USDC transfers from the trading wallet to the +FEE_WALLET directly from Hyperliquid — the authoritative record. + +Used for: + 1. Double-pay guard — before sending a fee, check no prior transfer for + this period already exists on chain. + 2. State reconstruction — if /storage/data is lost, the ledger tells us + which periods were already paid and how much, so HWM can be replayed + deterministically. + +Shape of delta entries relevant to us: + { "time": , "hash": "0x...", + "delta": { + "type": "internalTransfer", + "usdc": "", # string + "user": "0x...", # sender, lowercase + "destination": "0x...", # recipient, lowercase + "fee": "" + } } +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +import httpx + +from .config import FEE_WALLET + +logger = logging.getLogger(__name__) + +HYPERLIQUID_INFO_URL = "https://api-ui.hyperliquid.xyz/info" + + +class FeeLedgerError(Exception): + pass + + +@dataclass(frozen=True) +class FeeTransfer: + timestamp_ms: int + amount: Decimal + tx_hash: str + destination: str + sender: str + + +async def get_outbound_transfers( + trading_address: str, + since_ms: int, + to_address: str = FEE_WALLET, +) -> list[FeeTransfer]: + """Return outbound internalTransfer entries from trading_address to to_address since since_ms.""" + payload = { + "type": "userNonFundingLedgerUpdates", + "user": trading_address, + "startTime": since_ms, + } + try: + async with httpx.AsyncClient() as client: + response = await client.post( + HYPERLIQUID_INFO_URL, + json=payload, + headers={"Content-Type": "application/json"}, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPError as exc: + raise FeeLedgerError(f"HTTP error fetching ledger: {exc}") from exc + + return _filter_outbound(data, trading_address, to_address) + + +def _filter_outbound( + entries: list[Any], + trading_address: str, + to_address: str, +) -> list[FeeTransfer]: + trading_lc = trading_address.lower() + to_lc = to_address.lower() + transfers: list[FeeTransfer] = [] + for entry in entries: + delta = entry.get("delta", {}) + if delta.get("type") != "internalTransfer": + continue + if delta.get("user", "").lower() != trading_lc: + continue + if delta.get("destination", "").lower() != to_lc: + continue + try: + amount = Decimal(str(delta.get("usdc", "0"))) + except Exception: + logger.warning("Could not parse usdc amount in %s; skipping", entry) + continue + transfers.append( + FeeTransfer( + timestamp_ms=int(entry["time"]), + amount=amount, + tx_hash=str(entry.get("hash", "")), + destination=to_lc, + sender=trading_lc, + ) + ) + return transfers + + +def transfers_in_window( + transfers: list[FeeTransfer], + start_ms: int, + end_ms: int, +) -> list[FeeTransfer]: + """Filter a transfer list to [start_ms, end_ms] inclusive.""" + return [t for t in transfers if start_ms <= t.timestamp_ms <= end_ms] diff --git a/src/wt3/core/performance_fee/nav_source.py b/src/wt3/core/performance_fee/nav_source.py new file mode 100644 index 0000000..decda92 --- /dev/null +++ b/src/wt3/core/performance_fee/nav_source.py @@ -0,0 +1,103 @@ +"""NAV source adapter. + +Fetches perp-only NAV timeseries from Hyperliquid's public /info portfolio +endpoint and slices it to a specified [start_ts, end_ts] window. + +Perps-only is intentional: the fee agreement is on trading profits, not +staked HYPE or HLP vault yield. We therefore read `perpMonth` and +`perpAllTime` (not the all-inclusive `month`/`allTime` keys). + +Sampling reality (confirmed via live API): + perpMonth ~15 min – 26h per point (~46 points spanning ~30 days) + perpAllTime ~6 days per point + +The caller decides which bucket to use based on how far back the window is. +""" + +from __future__ import annotations + +import logging +from decimal import Decimal +from typing import Any + +from ...clients.pnl import PnLClient, PnLClientError + +logger = logging.getLogger(__name__) + + +class NavSourceError(Exception): + pass + + +def _extract_perp_history( + portfolio: dict[str, Any], + bucket_key: str, +) -> list[tuple[int, Decimal]]: + """Pull accountValueHistory from a portfolio bucket, as [(ts_ms, Decimal)].""" + bucket = portfolio.get(bucket_key) + if not bucket: + return [] + history = bucket.get("accountValueHistory", []) + return [(int(ts), Decimal(str(nav))) for ts, nav in history] + + +def _slice_to_window( + series: list[tuple[int, Decimal]], + start_ts_ms: int, + end_ts_ms: int, +) -> list[tuple[int, Decimal]]: + """Return points with timestamp in [start_ts_ms, end_ts_ms] inclusive.""" + return [(ts, nav) for ts, nav in series if start_ts_ms <= ts <= end_ts_ms] + + +async def fetch_perp_nav_window( + user_address: str, + start_ts_ms: int, + end_ts_ms: int, +) -> list[tuple[int, Decimal]]: + """Fetch perp NAV timeseries for a wallet, clipped to the given window. + + Strategy: + - Always fetch the full portfolio. + - Prefer perpMonth when the window's start is within ~35 days of now + (finer sampling). + - Fall back to perpAllTime otherwise (coarser, ~6-day sampling). + + Raises NavSourceError if no data is available for the window. + """ + try: + client = PnLClient(user_address) + portfolio = await client.get_portfolio_data() + except PnLClientError as exc: + raise NavSourceError(f"Failed to fetch portfolio: {exc}") from exc + + perp_month = _extract_perp_history(portfolio, "month") + perp_alltime = _extract_perp_history(portfolio, "allTime") + + window_from_month = _slice_to_window(perp_month, start_ts_ms, end_ts_ms) + + if window_from_month and perp_month and perp_month[0][0] <= start_ts_ms: + logger.info( + "Using perpMonth for NAV window: %d points", len(window_from_month) + ) + return window_from_month + + window_from_alltime = _slice_to_window(perp_alltime, start_ts_ms, end_ts_ms) + if window_from_alltime: + logger.info( + "Using perpAllTime for NAV window (coarse sampling): %d points", + len(window_from_alltime), + ) + return window_from_alltime + + if window_from_month: + logger.warning( + "perpAllTime missing data; falling back to partial perpMonth: %d points", + len(window_from_month), + ) + return window_from_month + + raise NavSourceError( + f"No NAV data available for window [{start_ts_ms}, {end_ts_ms}] " + f"(user={user_address})" + ) From 84b062641e058925ce17a48ab256cdafde31fcbd Mon Sep 17 00:00:00 2001 From: ahmedhamedaly Date: Thu, 23 Apr 2026 15:05:37 +0100 Subject: [PATCH 3/6] chore: bump mainnet rofl deployment id to 0000000000000105 --- rofl.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rofl.yaml b/rofl.yaml index 42b8e43..061cf61 100644 --- a/rofl.yaml +++ b/rofl.yaml @@ -68,7 +68,7 @@ deployments: default: provider: oasis1qzc8pldvm8vm3duvdrj63wgvkw34y9ucfcxzetqr offer: large - id: "0000000000000098" + id: "0000000000000105" testnet: app_id: rofl1qzp3c6zt96r5c5sw0sljlvepwgg4u23atgh4legq network: testnet From dfeeb1b4b358f24ddaa2439c9971850185092824 Mon Sep 17 00:00:00 2001 From: ahmedhamedaly Date: Fri, 24 Apr 2026 09:40:31 +0100 Subject: [PATCH 4/6] feat: add hwm state persistence with hyperliquid reconstruction --- src/wt3/core/performance_fee/hwm_state.py | 213 ++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 src/wt3/core/performance_fee/hwm_state.py diff --git a/src/wt3/core/performance_fee/hwm_state.py b/src/wt3/core/performance_fee/hwm_state.py new file mode 100644 index 0000000..35b4ab3 --- /dev/null +++ b/src/wt3/core/performance_fee/hwm_state.py @@ -0,0 +1,213 @@ +"""HWM state persistence and reconstruction. + +Cache at /storage/data/hwm_state.json (survives container restart within +the same persistent volume). Ground truth = Hyperliquid NAV history + +on-chain fee-transfer ledger. If the cache is lost, state is rebuilt +deterministically from the zero-trust sources. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from decimal import Decimal +from pathlib import Path +from typing import Optional + +from .calculator import PeriodResult, compute_period +from .config import INITIAL_HWM, STATE_FILE_PATH, USDC_QUANTUM +from .fee_ledger import FeeTransfer, transfers_in_window +from .periods import Period, closed_periods_before, period_for_index + +logger = logging.getLogger(__name__) + + +class StateReconciliationError(Exception): + """Raised when cache vs. on-chain ledger disagree beyond tolerance.""" + + +@dataclass +class PeriodRecord: + period_index: int + period_start: str + period_end: str + start_nav: str + end_nav: str + peak_nav: str + hwm_in: str + hurdle_floor: str + hurdle_cleared: bool + fee_owed: str + fee_paid: str + hwm_out: str + tx_hash: str + computed_at: str + + @classmethod + def from_result( + cls, + index: int, + result: PeriodResult, + fee_paid: Decimal, + tx_hash: str, + ) -> "PeriodRecord": + return cls( + period_index=index, + period_start=result.period_start.isoformat(), + period_end=result.period_end.isoformat(), + start_nav=str(result.start_nav), + end_nav=str(result.end_nav), + peak_nav=str(result.peak_nav), + hwm_in=str(result.hwm_in), + hurdle_floor=str(result.hurdle_floor), + hurdle_cleared=result.hurdle_cleared, + fee_owed=str(result.fee_owed), + fee_paid=str(fee_paid), + hwm_out=str(result.hwm_out), + tx_hash=tx_hash, + computed_at=datetime.now(timezone.utc).isoformat(), + ) + + +@dataclass +class HwmState: + version: int = 1 + hwm: str = str(INITIAL_HWM) + last_processed_period_index: int = -1 + history: list[PeriodRecord] = field(default_factory=list) + + @property + def hwm_decimal(self) -> Decimal: + return Decimal(self.hwm) + + +def load_state(path: str = STATE_FILE_PATH) -> Optional[HwmState]: + p = Path(path) + if not p.exists(): + return None + try: + raw = json.loads(p.read_text()) + return HwmState( + version=raw.get("version", 1), + hwm=raw.get("hwm", str(INITIAL_HWM)), + last_processed_period_index=raw.get("last_processed_period_index", -1), + history=[PeriodRecord(**r) for r in raw.get("history", [])], + ) + except Exception as exc: + logger.error("Corrupt state file at %s: %s", path, exc) + return None + + +def save_state(state: HwmState, path: str = STATE_FILE_PATH) -> None: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + data = { + "version": state.version, + "hwm": state.hwm, + "last_processed_period_index": state.last_processed_period_index, + "history": [asdict(r) for r in state.history], + } + p.write_text(json.dumps(data, indent=2)) + + +def _tolerance() -> Decimal: + return USDC_QUANTUM * Decimal("10") + + +def _transfers_for_period( + ledger_transfers: list[FeeTransfer], + period: Period, +) -> list[FeeTransfer]: + """All fee-wallet transfers attributable to the given period. + + Window is [period.start, period.end + 1 day] — fees are paid + shortly after period close. + """ + return transfers_in_window( + ledger_transfers, period.start_ms, period.end_ms + 86_400_000 + ) + + +def _match_ledger_transfer( + fee_owed: Decimal, + ledger_transfers: list[FeeTransfer], +) -> Optional[FeeTransfer]: + for t in ledger_transfers: + if abs(t.amount - fee_owed) <= _tolerance(): + return t + return None + + +async def reconstruct_from_hyperliquid( + trading_address: str, + nav_fetcher, + ledger_fetcher, + now: datetime, +) -> HwmState: + """Rebuild state by replaying closed periods. + + nav_fetcher(address, start_ms, end_ms) -> list[(ts_ms, Decimal)] + ledger_fetcher(address, since_ms) -> list[FeeTransfer] + + Raises StateReconciliationError if ledger disagrees with calc beyond tolerance. + """ + periods = closed_periods_before(now) + state = HwmState() + if not periods: + return state + + ledger = await ledger_fetcher(trading_address, since_ms=periods[0].start_ms) + consumed_tx_hashes: set[str] = set() + + hwm = INITIAL_HWM + for period in periods: + nav_series = await nav_fetcher(trading_address, period.start_ms, period.end_ms) + if not nav_series: + raise StateReconciliationError( + f"No NAV data available for period {period.index} " + f"({period.start.isoformat()} -> {period.end.isoformat()})" + ) + + result = compute_period( + hwm_in=hwm, + nav_series=nav_series, + period_start=period.start, + period_end=period.end, + ) + + period_transfers = [ + t + for t in _transfers_for_period(ledger, period) + if t.tx_hash not in consumed_tx_hashes + ] + + if result.fee_owed > 0: + matched = _match_ledger_transfer(result.fee_owed, period_transfers) + if matched is None: + raise StateReconciliationError( + f"Period {period.index}: calculator says fee owed " + f"{result.fee_owed} but no matching outbound transfer found on chain" + ) + fee_paid = matched.amount + tx_hash = matched.tx_hash + consumed_tx_hashes.add(tx_hash) + else: + if period_transfers: + raise StateReconciliationError( + f"Period {period.index}: calculator says no fee but chain shows " + f"{len(period_transfers)} transfer(s) to fee wallet " + f"(amounts: {[str(t.amount) for t in period_transfers]})" + ) + fee_paid = Decimal("0") + tx_hash = "" + + state.history.append( + PeriodRecord.from_result(period.index, result, fee_paid, tx_hash) + ) + hwm = result.hwm_out + state.last_processed_period_index = period.index + + state.hwm = str(hwm) + return state From 09106c01147dd631360c351b53cfe44363e6d0fb Mon Sep 17 00:00:00 2001 From: ahmedhamedaly Date: Fri, 24 Apr 2026 10:42:23 +0100 Subject: [PATCH 5/6] feat: add fee transfer wrapper and quarterly recap generator --- src/wt3/core/performance_fee/recap.py | 54 +++++++++ src/wt3/core/performance_fee/transfer.py | 148 +++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 src/wt3/core/performance_fee/recap.py create mode 100644 src/wt3/core/performance_fee/transfer.py diff --git a/src/wt3/core/performance_fee/recap.py b/src/wt3/core/performance_fee/recap.py new file mode 100644 index 0000000..2489d67 --- /dev/null +++ b/src/wt3/core/performance_fee/recap.py @@ -0,0 +1,54 @@ +"""Quarterly performance-fee recap tweet generator. + +Pure string formatting. The scheduler posts this via SocialClient._tweet. +""" + +from __future__ import annotations + +from decimal import Decimal + +from .calculator import PeriodResult + + +def _fmt(x: Decimal) -> str: + return f"${x:,.2f}" + + +def generate_recap( + result: PeriodResult, + fee_paid: Decimal, + tx_hash: str, +) -> str: + period_label = ( + f"{result.period_start.strftime('%b %d')}" + f" - {result.period_end.strftime('%b %d, %Y')}" + ) + lines = [ + f"Quarterly Performance Report: {period_label}", + "", + f" Start NAV: {_fmt(result.start_nav)}", + f" End NAV: {_fmt(result.end_nav)}", + f" Peak NAV: {_fmt(result.peak_nav)}", + f" Prev HWM: {_fmt(result.hwm_in)}", + ] + if fee_paid > 0: + lines.append(f" Fee paid: {_fmt(fee_paid)} (30% catch-up above HWM)") + if tx_hash: + lines.append(f" tx: {tx_hash}") + else: + lines.append(" Fee paid: $0 (hurdle not cleared)") + lines.append(f" New HWM: {_fmt(result.hwm_out)}") + return "\n".join(lines) + + +def generate_halt_alert( + period_index: int, + reason: str, + fee_owed: Decimal, +) -> str: + return ( + f"WT3 performance-fee pipeline halted for period {period_index}.\n" + f" Fee owed: {_fmt(fee_owed)}\n" + f" Reason: {reason}\n" + f" HWM not advanced. Manual intervention required." + ) diff --git a/src/wt3/core/performance_fee/transfer.py b/src/wt3/core/performance_fee/transfer.py new file mode 100644 index 0000000..2f83e29 --- /dev/null +++ b/src/wt3/core/performance_fee/transfer.py @@ -0,0 +1,148 @@ +"""Fee transfer via Hyperliquid usd_transfer. + +Wraps exchange.usd_transfer with: + - Round-down to USDC 6-decimal precision (never over-transfer). + - Withdrawable-balance check before attempting. + - Retry with exponential backoff on transient failures. + - Structured result so the scheduler can halt cleanly on failure. + +The actual on-chain action is exchange.usd_transfer(amount: float, destination: str) +from the Hyperliquid SDK (hyperliquid/exchange.py:506). It uses the ROFL-signed +trading wallet to send USDC on the L1 directly to the fee wallet. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from decimal import ROUND_DOWN, Decimal +from typing import Any, Optional + +from .config import FEE_WALLET, MAX_TRANSFER_RETRIES, USDC_QUANTUM + +logger = logging.getLogger(__name__) + + +class TransferError(Exception): + pass + + +@dataclass(frozen=True) +class TransferResult: + success: bool + amount_requested: Decimal + amount_transferred: Decimal + tx_hash: str + error: Optional[str] = None + + +def round_down_to_usdc(amount: Decimal) -> Decimal: + return amount.quantize(USDC_QUANTUM, rounding=ROUND_DOWN) + + +def get_withdrawable_usdc(info: Any, wallet_address: str) -> Decimal: + """Perps withdrawable USDC for the given address.""" + user_state = info.user_state(wallet_address) + return Decimal(str(user_state.get("withdrawable", "0"))) + + +def _extract_tx_hash(response: dict[str, Any]) -> str: + """Best-effort tx-hash extraction from Hyperliquid response.""" + if not isinstance(response, dict): + return "" + resp = response.get("response", {}) + data = resp.get("data", {}) + if isinstance(data, dict): + statuses = data.get("statuses", []) + if statuses and isinstance(statuses[0], dict): + return statuses[0].get("hash", "") + return "" + + +async def pay_fee( + exchange: Any, + info: Any, + trading_address: str, + amount: Decimal, + destination: str = FEE_WALLET, + max_retries: int = MAX_TRANSFER_RETRIES, +) -> TransferResult: + """Transfer amount USDC from trading wallet to destination. + + Returns TransferResult with success=False on permanent failure. Caller MUST + halt the fee pipeline and NOT advance HWM if success is False. + """ + rounded = round_down_to_usdc(amount) + if rounded <= 0: + return TransferResult( + success=False, + amount_requested=amount, + amount_transferred=Decimal("0"), + tx_hash="", + error="Rounded amount is zero or negative", + ) + + try: + withdrawable = get_withdrawable_usdc(info, trading_address) + except Exception as exc: + return TransferResult( + success=False, + amount_requested=amount, + amount_transferred=Decimal("0"), + tx_hash="", + error=f"Failed to read withdrawable USDC: {exc}", + ) + + if withdrawable < rounded: + return TransferResult( + success=False, + amount_requested=amount, + amount_transferred=Decimal("0"), + tx_hash="", + error=( + f"Insufficient USDC: withdrawable={withdrawable} < required={rounded}. " + "Halting fee transfer; positions must be closed to free balance." + ), + ) + + last_error: Optional[str] = None + for attempt in range(1, max_retries + 1): + try: + response = await asyncio.to_thread( + exchange.usd_transfer, float(rounded), destination + ) + status = response.get("status") if isinstance(response, dict) else None + if status != "ok": + last_error = f"Non-ok response: {response}" + logger.warning( + "Transfer attempt %d/%d returned non-ok: %s", + attempt, + max_retries, + response, + ) + else: + tx_hash = _extract_tx_hash(response) + logger.info( + "Transferred %s USDC to %s (tx=%s)", rounded, destination, tx_hash + ) + return TransferResult( + success=True, + amount_requested=amount, + amount_transferred=rounded, + tx_hash=tx_hash, + ) + except Exception as exc: + last_error = str(exc) + logger.warning("Transfer attempt %d/%d raised: %s", attempt, max_retries, exc) + + if attempt < max_retries: + await asyncio.sleep(2**attempt) + + return TransferResult( + success=False, + amount_requested=amount, + amount_transferred=Decimal("0"), + tx_hash="", + error=last_error or "Exhausted retries", + ) From b5ece11692addf6b199f96da99a700e7283d74e0 Mon Sep 17 00:00:00 2001 From: ahmedhamedaly Date: Sat, 25 Apr 2026 11:19:07 +0100 Subject: [PATCH 6/6] feat: wire quarterly fee scheduler into main loop with dry-run --- scripts/performance_fee_dry_run.py | 136 +++++++++++ src/wt3/__main__.py | 15 ++ .../core/orchestration/state_management.py | 1 + src/wt3/core/performance_fee/scheduler.py | 229 ++++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 scripts/performance_fee_dry_run.py create mode 100644 src/wt3/core/performance_fee/scheduler.py diff --git a/scripts/performance_fee_dry_run.py b/scripts/performance_fee_dry_run.py new file mode 100644 index 0000000..c72dc27 --- /dev/null +++ b/scripts/performance_fee_dry_run.py @@ -0,0 +1,136 @@ +"""Performance-fee dry run against a live Hyperliquid wallet. + +READ-ONLY. Fetches perp NAV from Hyperliquid, slices to a 3-month window, +and runs the pure calculator to show what the fee/HWM would have been. +Does NOT send any transfer, does NOT write any state file. + +Usage: + uv run python -m scripts.performance_fee_dry_run + +Optional env: + DRY_RUN_WALLET default: 0xFc800e6e7147e544496D2DA73B4988916431B33a + DRY_RUN_HWM_IN default: 20000 + DRY_RUN_END_DATE YYYY-MM-DD, default: today (UTC) +""" + +from __future__ import annotations + +import asyncio +import os +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +from wt3.core.performance_fee.calculator import compute_period +from wt3.core.performance_fee.config import ( + FEE_RATE, + FEE_WALLET, + HURDLE_PER_PERIOD, +) +from wt3.core.performance_fee.fee_ledger import ( + get_outbound_transfers, + transfers_in_window, +) +from wt3.core.performance_fee.nav_source import fetch_perp_nav_window + + +DEFAULT_WALLET = "0xFc800e6e7147e544496D2DA73B4988916431B33a" + + +def _resolve_window() -> tuple[datetime, datetime]: + end_str = os.getenv("DRY_RUN_END_DATE") + if end_str: + end = datetime.fromisoformat(end_str).replace(tzinfo=timezone.utc) + else: + end = datetime.now(timezone.utc).replace( + hour=23, minute=59, second=59, microsecond=0 + ) + start = (end - timedelta(days=90)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + return start, end + + +def _fmt_usd(x: Decimal) -> str: + return f"${x:,.2f}" + + +async def main() -> None: + wallet = os.getenv("DRY_RUN_WALLET", DEFAULT_WALLET) + hwm_in = Decimal(os.getenv("DRY_RUN_HWM_IN", "20000")) + period_start, period_end = _resolve_window() + + print("=" * 72) + print("WT3 PERFORMANCE FEE DRY RUN — READ-ONLY, NO TRANSFERS") + print("=" * 72) + print(f" Wallet: {wallet}") + print(f" HWM (input): {_fmt_usd(hwm_in)}") + print(f" Period start: {period_start.isoformat()}") + print(f" Period end: {period_end.isoformat()}") + print(f" Hurdle/period: {HURDLE_PER_PERIOD * 100:.4f}%") + print(f" Fee rate: {FEE_RATE * 100:.1f}%") + print(f" Fee wallet: {FEE_WALLET}") + print() + + start_ms = int(period_start.timestamp() * 1000) + end_ms = int(period_end.timestamp() * 1000) + + print("Fetching perp NAV window from Hyperliquid /info...") + nav_series = await fetch_perp_nav_window(wallet, start_ms, end_ms) + print(f" -> {len(nav_series)} NAV samples in window") + if nav_series: + print(f" first: {datetime.fromtimestamp(nav_series[0][0]/1000, tz=timezone.utc)} " + f"NAV={_fmt_usd(nav_series[0][1])}") + print(f" last: {datetime.fromtimestamp(nav_series[-1][0]/1000, tz=timezone.utc)} " + f"NAV={_fmt_usd(nav_series[-1][1])}") + print() + + if not nav_series: + print("No NAV data available; aborting dry run.") + return + + result = compute_period( + hwm_in=hwm_in, + nav_series=nav_series, + period_start=period_start, + period_end=period_end, + ) + + print("CALCULATOR RESULT") + print("-" * 72) + print(f" start_nav: {_fmt_usd(result.start_nav)}") + print(f" end_nav: {_fmt_usd(result.end_nav)}") + print(f" peak_nav: {_fmt_usd(result.peak_nav)}") + print(f" hwm_in: {_fmt_usd(result.hwm_in)}") + print(f" hurdle_floor: {_fmt_usd(result.hurdle_floor)}") + print(f" hurdle_cleared: {result.hurdle_cleared}") + print(f" FEE OWED: {_fmt_usd(result.fee_owed)}") + print(f" hwm_out: {_fmt_usd(result.hwm_out)}") + print() + + change = result.end_nav - result.start_nav + pct = (change / result.start_nav * 100) if result.start_nav else Decimal("0") + print(f" Raw period NAV change: {_fmt_usd(change)} ({pct:.2f}% of start_nav)") + print() + + print("Checking on-chain outbound transfers to fee wallet in this window...") + try: + transfers = await get_outbound_transfers(wallet, since_ms=start_ms) + in_window = transfers_in_window(transfers, start_ms, end_ms) + if in_window: + print(f" Found {len(in_window)} outbound transfer(s) to {FEE_WALLET}:") + for t in in_window: + ts = datetime.fromtimestamp(t.timestamp_ms / 1000, tz=timezone.utc) + print(f" {ts.isoformat()} {_fmt_usd(t.amount)} tx={t.tx_hash}") + else: + print(" No outbound transfers to fee wallet in this window.") + except Exception as exc: + print(f" Ledger check failed: {exc}") + + print() + print("=" * 72) + print("DRY RUN COMPLETE — no funds moved, no state written.") + print("=" * 72) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/wt3/__main__.py b/src/wt3/__main__.py index 0284f9f..0959270 100644 --- a/src/wt3/__main__.py +++ b/src/wt3/__main__.py @@ -67,6 +67,8 @@ async def main() -> bool: agent.trading_state.last_weekly_pnl_time = datetime.utcnow() - timedelta(days=6) if agent.trading_state.last_monthly_pnl_time is None: agent.trading_state.last_monthly_pnl_time = datetime.utcnow() - timedelta(days=29) + if agent.trading_state.last_fee_check_time is None: + agent.trading_state.last_fee_check_time = datetime.utcnow() - timedelta(hours=23) # logger.info("STARTUP: Closing all existing positions") # try: @@ -141,6 +143,19 @@ async def main() -> bool: logger.error(f"Error posting monthly PnL recap: {str(e)}") logger.warning("Will retry in next interval") + time_since_last_fee_check = current_time - (agent.trading_state.last_fee_check_time or (current_time - timedelta(days=1))) + run_fee_check = time_since_last_fee_check.total_seconds() >= 86400 + + if run_fee_check: + logger.info("Checking quarterly fee schedule") + try: + from .core.performance_fee.scheduler import maybe_run_quarterly_fee + await maybe_run_quarterly_fee(agent, current_time) + agent.trading_state.last_fee_check_time = current_time + except Exception as e: + logger.error(f"Error in quarterly fee check: {str(e)}") + logger.warning("Will retry in next interval") + if run_social: logger.info("Running social media tasks (30-minute cycle)") try: diff --git a/src/wt3/core/orchestration/state_management.py b/src/wt3/core/orchestration/state_management.py index b7e31cb..54611ec 100644 --- a/src/wt3/core/orchestration/state_management.py +++ b/src/wt3/core/orchestration/state_management.py @@ -36,6 +36,7 @@ def __init__(self, max_activities: int = 20): self.last_daily_pnl_time: Optional[datetime] = None self.last_weekly_pnl_time: Optional[datetime] = None self.last_monthly_pnl_time: Optional[datetime] = None + self.last_fee_check_time: Optional[datetime] = None self.is_running = True except Exception as e: error_msg = f"Failed to initialize trading state: {str(e)}" diff --git a/src/wt3/core/performance_fee/scheduler.py b/src/wt3/core/performance_fee/scheduler.py new file mode 100644 index 0000000..afd67f0 --- /dev/null +++ b/src/wt3/core/performance_fee/scheduler.py @@ -0,0 +1,229 @@ +"""Quarterly fee scheduler. + +Entry point: maybe_run_quarterly_fee(agent, now). + +This is called ~once a day from the main event loop. It determines +whether any closed period needs processing and, if so: + 1. Loads / reconstructs HWM state from the zero-trust sources. + 2. Fetches NAV window for the closed period from Hyperliquid. + 3. Runs the pure calculator. + 4. Checks on-chain ledger for any already-paid fee in that window + (double-pay guard). + 5. Verifies withdrawable USDC, transfers the fee, captures tx hash. + 6. Persists the updated state and tweets the recap. + 7. On ANY failure that prevents a confirmed transfer, HALTS the + pipeline WITHOUT advancing HWM — Oasis/Moonward must intervene + manually. State stays consistent. + +Idempotency: + - last_processed_period_index in state prevents re-processing a period. + - Double-pay guard catches a transfer that happened out-of-band. + - Transfer rounds down to 6 decimals, never overshooting. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from decimal import Decimal +from typing import TYPE_CHECKING, Any + +from .calculator import compute_period +from .config import ( + FEE_WALLET, + GRACE_SECONDS_AFTER_PERIOD_END, +) +from .fee_ledger import ( + FeeLedgerError, + get_outbound_transfers, + transfers_in_window, +) +from .hwm_state import ( + HwmState, + PeriodRecord, + load_state, + reconstruct_from_hyperliquid, + save_state, +) +from .nav_source import NavSourceError, fetch_perp_nav_window +from .periods import closed_periods_before, period_for_index +from .recap import generate_halt_alert, generate_recap +from .transfer import pay_fee + +if TYPE_CHECKING: + from ...__main__ import WT3Agent + +logger = logging.getLogger(__name__) + + +async def _tweet_safe(text: str) -> None: + """Post a tweet; swallow any social errors (fees must not depend on Twitter).""" + try: + from ...clients.social import SocialClient + + client = SocialClient() + client._tweet(text) + except Exception as exc: + logger.error("Failed to post recap tweet: %s", exc) + + +async def maybe_run_quarterly_fee( + agent: "WT3Agent", + now: datetime, + *, + nav_fetcher=fetch_perp_nav_window, + ledger_fetcher=get_outbound_transfers, + transfer_fn=pay_fee, + state_loader=load_state, + state_saver=save_state, + state_reconstructor=reconstruct_from_hyperliquid, + tweet_fn=_tweet_safe, +) -> None: + """Runs the fee pipeline if a new period has closed. + + All external dependencies are injectable for testability. + """ + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + + closed = closed_periods_before(now) + if not closed: + logger.debug("No closed periods yet (before START_DATE or mid-first-period)") + return + + trading_address = agent.trading_tools.exchange_client.wallet.address + + state = state_loader() + if state is None: + logger.warning("HWM state cache missing; reconstructing from Hyperliquid") + try: + state = await state_reconstructor( + trading_address, nav_fetcher, ledger_fetcher, now + ) + except Exception as exc: + logger.error("Reconstruction failed: %s", exc) + await tweet_fn(generate_halt_alert(-1, f"Reconstruction failed: {exc}", Decimal("0"))) + return + state_saver(state) + + next_index = state.last_processed_period_index + 1 + target_period = next( + (p for p in closed if p.index == next_index and _past_grace(p.end, now)), + None, + ) + if target_period is None: + logger.debug( + "No new period due; last_processed=%d, now=%s", + state.last_processed_period_index, + now.isoformat(), + ) + return + + logger.info( + "Processing period %d (%s -> %s)", + target_period.index, + target_period.start.isoformat(), + target_period.end.isoformat(), + ) + + try: + nav_series = await nav_fetcher( + trading_address, target_period.start_ms, target_period.end_ms + ) + except NavSourceError as exc: + logger.error("NAV fetch failed for period %d: %s", target_period.index, exc) + await tweet_fn( + generate_halt_alert(target_period.index, f"NAV fetch failed: {exc}", Decimal("0")) + ) + return + + if not nav_series: + logger.error("No NAV data for period %d", target_period.index) + await tweet_fn( + generate_halt_alert(target_period.index, "No NAV data available", Decimal("0")) + ) + return + + result = compute_period( + hwm_in=state.hwm_decimal, + nav_series=nav_series, + period_start=target_period.start, + period_end=target_period.end, + ) + + try: + existing_transfers = await ledger_fetcher( + trading_address, since_ms=target_period.start_ms + ) + except FeeLedgerError as exc: + logger.error("Ledger fetch failed: %s", exc) + await tweet_fn( + generate_halt_alert( + target_period.index, f"Ledger fetch failed: {exc}", result.fee_owed + ) + ) + return + + guard_window_end_ms = target_period.end_ms + 86_400_000 + in_guard_window = transfers_in_window( + existing_transfers, target_period.start_ms, guard_window_end_ms + ) + already_paid = next( + (t for t in in_guard_window if abs(t.amount - result.fee_owed) <= Decimal("0.00001")), + None, + ) + + if already_paid is not None and result.fee_owed > 0: + logger.warning( + "Fee for period %d already paid on-chain (tx=%s); skipping transfer", + target_period.index, + already_paid.tx_hash, + ) + fee_paid = already_paid.amount + tx_hash = already_paid.tx_hash + elif result.fee_owed > 0: + transfer_result = await transfer_fn( + agent.trading_tools.exchange_client.exchange, + agent.trading_tools.exchange_client.info, + trading_address, + result.fee_owed, + destination=FEE_WALLET, + ) + if not transfer_result.success: + logger.error( + "Transfer failed for period %d: %s", + target_period.index, + transfer_result.error, + ) + await tweet_fn( + generate_halt_alert( + target_period.index, + f"Transfer failed: {transfer_result.error}", + result.fee_owed, + ) + ) + return + fee_paid = transfer_result.amount_transferred + tx_hash = transfer_result.tx_hash + else: + fee_paid = Decimal("0") + tx_hash = "" + + state.history.append( + PeriodRecord.from_result(target_period.index, result, fee_paid, tx_hash) + ) + state.hwm = str(result.hwm_out) + state.last_processed_period_index = target_period.index + state_saver(state) + + await tweet_fn(generate_recap(result, fee_paid, tx_hash)) + logger.info( + "Period %d processed: fee_paid=%s, hwm_out=%s", + target_period.index, + fee_paid, + result.hwm_out, + ) + + +def _past_grace(period_end: datetime, now: datetime) -> bool: + return now >= period_end + timedelta(seconds=GRACE_SECONDS_AFTER_PERIOD_END)