Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion rofl.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ deployments:
default:
provider: oasis1qzc8pldvm8vm3duvdrj63wgvkw34y9ucfcxzetqr
offer: large
id: "0000000000000098"
id: "0000000000000105"
testnet:
app_id: rofl1qzp3c6zt96r5c5sw0sljlvepwgg4u23atgh4legq
network: testnet
Expand Down
136 changes: 136 additions & 0 deletions scripts/performance_fee_dry_run.py
Original file line number Diff line number Diff line change
@@ -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())
15 changes: 15 additions & 0 deletions src/wt3/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions src/wt3/clients/pnl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/wt3/core/orchestration/state_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
Expand Down
1 change: 1 addition & 0 deletions src/wt3/core/performance_fee/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Performance fee computation and automated quarterly payout."""
80 changes: 80 additions & 0 deletions src/wt3/core/performance_fee/calculator.py
Original file line number Diff line number Diff line change
@@ -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,
)
31 changes: 31 additions & 0 deletions src/wt3/core/performance_fee/config.py
Original file line number Diff line number Diff line change
@@ -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"
Loading