From 274b89e7f32dc19d86ba64dd511fb90df2be10a2 Mon Sep 17 00:00:00 2001 From: saymrwulf Date: Sat, 25 Apr 2026 14:20:15 +0200 Subject: [PATCH] Initial monitor-only ratchet scaffold --- .env.example | 4 + .gitignore | 10 ++ PROGRAM.md | 44 +++++++ README.md | 53 ++++++++ SECURITY.md | 25 ++++ config.example.toml | 23 ++++ data/.gitkeep | 1 + examples/market_snapshot.example.json | 6 + pyproject.toml | 17 +++ results.tsv | 1 + src/braiins_ratchet/__init__.py | 5 + src/braiins_ratchet/braiins.py | 65 ++++++++++ src/braiins_ratchet/cli.py | 107 ++++++++++++++++ src/braiins_ratchet/config.py | 94 ++++++++++++++ src/braiins_ratchet/ev.py | 41 ++++++ src/braiins_ratchet/guardrails.py | 51 ++++++++ src/braiins_ratchet/models.py | 53 ++++++++ src/braiins_ratchet/ocean.py | 69 ++++++++++ src/braiins_ratchet/storage.py | 175 ++++++++++++++++++++++++++ src/braiins_ratchet/strategy.py | 109 ++++++++++++++++ tests/test_ev.py | 19 +++ tests/test_guardrails.py | 34 +++++ tests/test_ocean.py | 24 ++++ tests/test_strategy.py | 62 +++++++++ 24 files changed, 1092 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 PROGRAM.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 config.example.toml create mode 100644 data/.gitkeep create mode 100644 examples/market_snapshot.example.json create mode 100644 pyproject.toml create mode 100644 results.tsv create mode 100644 src/braiins_ratchet/__init__.py create mode 100644 src/braiins_ratchet/braiins.py create mode 100644 src/braiins_ratchet/cli.py create mode 100644 src/braiins_ratchet/config.py create mode 100644 src/braiins_ratchet/ev.py create mode 100644 src/braiins_ratchet/guardrails.py create mode 100644 src/braiins_ratchet/models.py create mode 100644 src/braiins_ratchet/ocean.py create mode 100644 src/braiins_ratchet/storage.py create mode 100644 src/braiins_ratchet/strategy.py create mode 100644 tests/test_ev.py create mode 100644 tests/test_guardrails.py create mode 100644 tests/test_ocean.py create mode 100644 tests/test_strategy.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3c4cff6 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Copy to .env for local shell use if desired. Never commit .env. +BRAIINS_WATCHER_TOKEN= +BRAIINS_API_BASE= +OCEAN_DASHBOARD_URL=https://ocean.xyz/dashboard diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b74f38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.py[cod] +.DS_Store +.env +data/*.sqlite +data/*.sqlite-shm +data/*.sqlite-wal +data/raw/ +*.log diff --git a/PROGRAM.md b/PROGRAM.md new file mode 100644 index 0000000..cb6892d --- /dev/null +++ b/PROGRAM.md @@ -0,0 +1,44 @@ +# Program Charter + +## Goal + +Maximize expected BTC profit, or minimize BTC loss, for manually buying BTC hashpower on Braiins and pointing it at OCEAN/DATUM. + +## Operating Premises + +- OCEAN uses TIDES: shares are paid only if they are inside the current share-log when OCEAN finds a block. +- OCEAN block discovery is stochastic and memoryless. A recent drought is not evidence that OCEAN is "due". +- The useful edge is expected value versus Braiins market price, plus operational quality and timing around observable fee/reward conditions. +- The system should improve through repeated small experiments, not through one large theoretical bet. + +## Ratchet Loop + +1. Collect read-only snapshots from OCEAN, Braiins, local DATUM/Knots where available, and manual experiment results. +2. Score candidate policies against current guardrails and historical/paper-trade results. +3. Emit a manual recommendation. +4. If the recommendation is executed manually, record the exact order parameters and later realized rewards. +5. Keep changes to `strategy.py` only when they improve the measured score under comparable risk. + +## Hard Guardrails + +- No code path places, modifies, or cancels Braiins orders. +- No owner token may be stored, loaded, or requested by the code. +- Watcher-only Braiins token may be provided via environment variable only. +- No secrets in Git. +- No containers or VMs. +- Runtime files stay inside this repository. +- Default branch name is `master`. +- Production-like behavior starts with monitor-only and paper trading. +- First live canary, if manually executed, should use minimum viable spend only. + +## Initial Scoring Metric + +Use BTC-denominated expected value: + +```text +expected_net_btc = expected_ocean_rewards_after_fee - braiins_cost_btc +score = expected_net_btc - risk_penalty_btc - execution_penalty_btc +``` + +The strategy must show break-even price, discount to break-even, spend, duration, and maturity assumptions. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c7b7b9 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Braiins Ratchet + +Monitor-only research scaffold for optimizing a manual "buy hashpower on Braiins, mine through OCEAN" strategy. + +The first implementation is deliberately conservative: + +- The code never places, modifies, or cancels Braiins orders. +- The default strategy emits recommendations only. +- The Braiins integration accepts a watcher-only token only. +- All mutable runtime state stays inside this repository under `data/`. +- The Git branch is `master`. +- The project uses Python standard library only. + +## Quick Start + +```bash +python3 -m venv .venv +./.venv/bin/python -m braiins_ratchet.cli init-db +./.venv/bin/python -m braiins_ratchet.cli collect-ocean +./.venv/bin/python -m braiins_ratchet.cli evaluate +``` + +Set `PYTHONPATH=src` if running without installation: + +```bash +PYTHONPATH=src python3 -m braiins_ratchet.cli evaluate +``` + +Import a manual Braiins market snapshot: + +```bash +PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli import-market examples/market_snapshot.example.json +PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli evaluate +``` + +The JSON shape is: + +```json +{ + "timestamp_utc": "2026-04-25T12:00:00+00:00", + "best_price_btc_per_eh_day": "0.30", + "available_hashrate_eh_s": "0.10", + "source": "manual" +} +``` + +## Guardrail Model + +`braiins_ratchet.strategy` can propose a manual bid, but `braiins_ratchet.guardrails` decides whether that proposal is admissible. The executor layer currently has no write-capable Braiins methods. If live execution is ever added, it must be a separate reviewed change and remain disabled by default. + +## Data Maturity + +OCEAN's TIDES payout model means a canary experiment should not be scored immediately after spend completion. A spend should be treated as immature until its shares have had time to age through the pool's share-log window. The strategy therefore records both expected value and maturity notes. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a9324ed --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security And Safety + +This repository is designed to be monitor-only. + +## Token Policy + +- `BRAIINS_WATCHER_TOKEN` may be used for read-only API calls. +- Owner/admin Braiins tokens must never be passed to this code. +- `.env` is ignored by Git. +- The CLI refuses token values containing common owner/admin labels. + +## Computer Safety + +- No containers. +- No VMs. +- No writes outside this repository. +- No shelling out from package code. +- No destructive filesystem operations. + +## Trading Safety + +- Recommendations are not orders. +- Manual execution remains outside the program. +- Any future live executor must be reviewed as a separate change and disabled by default. + diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..58101a2 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,23 @@ +[capital] +available_btc = "0.01638650" + +[ocean] +fee_rate = "0.01" +block_subsidy_btc = "3.125" +default_tx_fees_btc = "0.05" +dashboard_url = "https://ocean.xyz/dashboard" + +[guardrails] +max_manual_order_btc = "0.00025" +max_daily_spend_btc = "0.00050" +max_price_btc_per_eh_day = "0.42" +min_discount_to_breakeven = "0.08" +min_duration_minutes = 30 +max_duration_minutes = 720 +recommend_only = true + +[strategy] +target_duration_minutes = 180 +target_spend_btc = "0.00010" +risk_lambda = "0.35" + diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/examples/market_snapshot.example.json b/examples/market_snapshot.example.json new file mode 100644 index 0000000..3046ab3 --- /dev/null +++ b/examples/market_snapshot.example.json @@ -0,0 +1,6 @@ +{ + "timestamp_utc": "2026-04-25T12:00:00+00:00", + "best_price_btc_per_eh_day": "0.30", + "available_hashrate_eh_s": "0.10", + "source": "manual-example" +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b6d372f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "braiins-ratchet" +version = "0.1.0" +description = "Monitor-only ratchet strategy tooling for Braiins Hashpower and OCEAN mining." +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +braiins-ratchet = "braiins_ratchet.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + diff --git a/results.tsv b/results.tsv new file mode 100644 index 0000000..3640ca6 --- /dev/null +++ b/results.tsv @@ -0,0 +1 @@ +timestamp_utc experiment_id mode proposal action price_btc_per_eh_day spend_btc duration_minutes expected_reward_btc expected_net_btc score_btc status notes diff --git a/src/braiins_ratchet/__init__.py b/src/braiins_ratchet/__init__.py new file mode 100644 index 0000000..d38a30c --- /dev/null +++ b/src/braiins_ratchet/__init__.py @@ -0,0 +1,5 @@ +"""Monitor-only ratchet tooling for Braiins Hashpower and OCEAN mining.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/src/braiins_ratchet/braiins.py b/src/braiins_ratchet/braiins.py new file mode 100644 index 0000000..ca3b7ba --- /dev/null +++ b/src/braiins_ratchet/braiins.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from decimal import Decimal +import json +import os +from urllib.request import Request, urlopen + +from .guardrails import token_looks_unsafe +from .models import MarketSnapshot + + +class BraiinsSafetyError(RuntimeError): + pass + + +@dataclass(frozen=True) +class BraiinsWatcherClient: + api_base: str + watcher_token: str + + @classmethod + def from_env(cls) -> "BraiinsWatcherClient": + token = os.environ.get("BRAIINS_WATCHER_TOKEN", "").strip() + api_base = os.environ.get("BRAIINS_API_BASE", "").strip().rstrip("/") + if not token: + raise BraiinsSafetyError("BRAIINS_WATCHER_TOKEN is not set") + if not api_base: + raise BraiinsSafetyError("BRAIINS_API_BASE is not set") + if token_looks_unsafe(token): + raise BraiinsSafetyError("token label looks unsafe; watcher-only token required") + return cls(api_base=api_base, watcher_token=token) + + def get_json(self, path: str) -> object: + if not path.startswith("/"): + raise BraiinsSafetyError("API path must start with /") + request = Request( + f"{self.api_base}{path}", + headers={ + "Authorization": f"Bearer {self.watcher_token}", + "User-Agent": "BraiinsRatchet/0.1 watcher-only", + }, + method="GET", + ) + with urlopen(request, timeout=15) as response: + return json.loads(response.read().decode("utf-8")) + + +def market_snapshot_from_json_file(path: str) -> MarketSnapshot: + raw = json.loads(open(path, "r", encoding="utf-8").read()) + return MarketSnapshot( + timestamp_utc=str(raw.get("timestamp_utc") or datetime.now(UTC).isoformat(timespec="seconds")), + best_price_btc_per_eh_day=( + Decimal(str(raw["best_price_btc_per_eh_day"])) + if raw.get("best_price_btc_per_eh_day") is not None + else None + ), + available_hashrate_eh_s=( + Decimal(str(raw["available_hashrate_eh_s"])) + if raw.get("available_hashrate_eh_s") is not None + else None + ), + source=str(raw.get("source") or path), + ) diff --git a/src/braiins_ratchet/cli.py b/src/braiins_ratchet/cli.py new file mode 100644 index 0000000..d84de99 --- /dev/null +++ b/src/braiins_ratchet/cli.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +from .braiins import market_snapshot_from_json_file +from .config import load_config +from .ocean import fetch_snapshot +from .storage import ( + connect, + init_db, + latest_market_snapshot, + latest_ocean_snapshot, + save_market_snapshot, + save_ocean_snapshot, + save_proposal, +) +from .strategy import propose + + +def cmd_init_db(_: argparse.Namespace) -> int: + with connect() as conn: + init_db(conn) + print("initialized data/ratchet.sqlite") + return 0 + + +def cmd_collect_ocean(args: argparse.Namespace) -> int: + config = load_config(Path(args.config) if args.config else None) + snapshot = fetch_snapshot(config.ocean.dashboard_url) + with connect() as conn: + init_db(conn) + save_ocean_snapshot(conn, snapshot) + print(json.dumps(snapshot.__dict__, default=str, indent=2)) + return 0 + + +def cmd_import_market(args: argparse.Namespace) -> int: + snapshot = market_snapshot_from_json_file(args.path) + with connect() as conn: + init_db(conn) + save_market_snapshot(conn, snapshot) + print(json.dumps(snapshot.__dict__, default=str, indent=2)) + return 0 + + +def cmd_evaluate(args: argparse.Namespace) -> int: + config = load_config(Path(args.config) if args.config else None) + with connect() as conn: + init_db(conn) + proposal = propose(config, latest_ocean_snapshot(conn), latest_market_snapshot(conn)) + save_proposal(conn, proposal) + print(_proposal_json(proposal)) + return 0 + + +def cmd_guardrails(args: argparse.Namespace) -> int: + config = load_config(Path(args.config) if args.config else None) + print(json.dumps(config.guardrails.__dict__, default=str, indent=2)) + return 0 + + +def _proposal_json(proposal: object) -> str: + def default(value: object) -> object: + if hasattr(value, "__dict__"): + return value.__dict__ + return str(value) + + return json.dumps(proposal, default=default, indent=2) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="braiins-ratchet") + sub = parser.add_subparsers(required=True) + + init = sub.add_parser("init-db", help="create local SQLite database") + init.set_defaults(func=cmd_init_db) + + ocean = sub.add_parser("collect-ocean", help="collect one OCEAN dashboard snapshot") + ocean.add_argument("--config") + ocean.set_defaults(func=cmd_collect_ocean) + + market = sub.add_parser("import-market", help="import manual Braiins market JSON snapshot") + market.add_argument("path") + market.set_defaults(func=cmd_import_market) + + evaluate = sub.add_parser("evaluate", help="emit monitor-only strategy recommendation") + evaluate.add_argument("--config") + evaluate.set_defaults(func=cmd_evaluate) + + guardrails = sub.add_parser("guardrails", help="print active guardrails") + guardrails.add_argument("--config") + guardrails.set_defaults(func=cmd_guardrails) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/braiins_ratchet/config.py b/src/braiins_ratchet/config.py new file mode 100644 index 0000000..54f10a1 --- /dev/null +++ b/src/braiins_ratchet/config.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from pathlib import Path +import tomllib + + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CONFIG_PATH = REPO_ROOT / "config.example.toml" + + +def _decimal(value: object, default: str) -> Decimal: + if value is None: + return Decimal(default) + return Decimal(str(value)) + + +@dataclass(frozen=True) +class CapitalConfig: + available_btc: Decimal + + +@dataclass(frozen=True) +class OceanConfig: + fee_rate: Decimal + block_subsidy_btc: Decimal + default_tx_fees_btc: Decimal + dashboard_url: str + + @property + def expected_block_reward_btc(self) -> Decimal: + return self.block_subsidy_btc + self.default_tx_fees_btc + + +@dataclass(frozen=True) +class GuardrailsConfig: + max_manual_order_btc: Decimal + max_daily_spend_btc: Decimal + max_price_btc_per_eh_day: Decimal + min_discount_to_breakeven: Decimal + min_duration_minutes: int + max_duration_minutes: int + recommend_only: bool + + +@dataclass(frozen=True) +class StrategyConfig: + target_duration_minutes: int + target_spend_btc: Decimal + risk_lambda: Decimal + + +@dataclass(frozen=True) +class AppConfig: + capital: CapitalConfig + ocean: OceanConfig + guardrails: GuardrailsConfig + strategy: StrategyConfig + + +def load_config(path: Path | None = None) -> AppConfig: + config_path = path or DEFAULT_CONFIG_PATH + raw = tomllib.loads(config_path.read_text(encoding="utf-8")) + capital = raw.get("capital", {}) + ocean = raw.get("ocean", {}) + guardrails = raw.get("guardrails", {}) + strategy = raw.get("strategy", {}) + + return AppConfig( + capital=CapitalConfig( + available_btc=_decimal(capital.get("available_btc"), "0"), + ), + ocean=OceanConfig( + fee_rate=_decimal(ocean.get("fee_rate"), "0.01"), + block_subsidy_btc=_decimal(ocean.get("block_subsidy_btc"), "3.125"), + default_tx_fees_btc=_decimal(ocean.get("default_tx_fees_btc"), "0"), + dashboard_url=str(ocean.get("dashboard_url", "https://ocean.xyz/dashboard")), + ), + guardrails=GuardrailsConfig( + max_manual_order_btc=_decimal(guardrails.get("max_manual_order_btc"), "0.0001"), + max_daily_spend_btc=_decimal(guardrails.get("max_daily_spend_btc"), "0.0002"), + max_price_btc_per_eh_day=_decimal(guardrails.get("max_price_btc_per_eh_day"), "0"), + min_discount_to_breakeven=_decimal(guardrails.get("min_discount_to_breakeven"), "0.05"), + min_duration_minutes=int(guardrails.get("min_duration_minutes", 30)), + max_duration_minutes=int(guardrails.get("max_duration_minutes", 720)), + recommend_only=bool(guardrails.get("recommend_only", True)), + ), + strategy=StrategyConfig( + target_duration_minutes=int(strategy.get("target_duration_minutes", 180)), + target_spend_btc=_decimal(strategy.get("target_spend_btc"), "0.0001"), + risk_lambda=_decimal(strategy.get("risk_lambda"), "0.25"), + ), + ) diff --git a/src/braiins_ratchet/ev.py b/src/braiins_ratchet/ev.py new file mode 100644 index 0000000..dbf370c --- /dev/null +++ b/src/braiins_ratchet/ev.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from decimal import Decimal, getcontext + +from .models import CandidateOrder + +getcontext().prec = 28 + +HASHES_PER_EH = Decimal("1000000000000000000") +SECONDS_PER_DAY = Decimal("86400") +TWO_POW_32 = Decimal(2) ** Decimal(32) + + +def breakeven_btc_per_eh_day( + network_difficulty_t: Decimal, + expected_block_reward_btc: Decimal, + pool_fee_rate: Decimal, +) -> Decimal: + difficulty = network_difficulty_t * Decimal("1000000000000") + expected_blocks_per_eh_day = HASHES_PER_EH * SECONDS_PER_DAY / (difficulty * TWO_POW_32) + return expected_blocks_per_eh_day * expected_block_reward_btc * (Decimal("1") - pool_fee_rate) + + +def expected_reward_for_order( + order: CandidateOrder, + network_difficulty_t: Decimal, + expected_block_reward_btc: Decimal, + pool_fee_rate: Decimal, +) -> Decimal: + price = breakeven_btc_per_eh_day( + network_difficulty_t=network_difficulty_t, + expected_block_reward_btc=expected_block_reward_btc, + pool_fee_rate=pool_fee_rate, + ) + days = Decimal(order.duration_minutes) / Decimal(1440) + return order.implied_hashrate_eh_s * days * price + + +def downside_penalty(expected_reward_btc: Decimal, risk_lambda: Decimal) -> Decimal: + # Mining rewards are lumpy. Penalize exposure until enough observations justify relaxing it. + return expected_reward_btc * risk_lambda diff --git a/src/braiins_ratchet/guardrails.py b/src/braiins_ratchet/guardrails.py new file mode 100644 index 0000000..13408e9 --- /dev/null +++ b/src/braiins_ratchet/guardrails.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from decimal import Decimal + +from .config import GuardrailsConfig +from .models import CandidateOrder + + +def validate_order( + order: CandidateOrder, + guardrails: GuardrailsConfig, + breakeven_btc_per_eh_day: Decimal | None, +) -> list[str]: + violations: list[str] = [] + + if not guardrails.recommend_only: + violations.append("recommend_only must remain true in the PoC") + if order.spend_btc <= 0: + violations.append("spend must be positive") + if order.spend_btc > guardrails.max_manual_order_btc: + violations.append(f"spend exceeds max_manual_order_btc={guardrails.max_manual_order_btc}") + if order.price_btc_per_eh_day <= 0: + violations.append("price must be positive") + if ( + guardrails.max_price_btc_per_eh_day > 0 + and order.price_btc_per_eh_day > guardrails.max_price_btc_per_eh_day + ): + violations.append( + f"price exceeds max_price_btc_per_eh_day={guardrails.max_price_btc_per_eh_day}" + ) + if order.duration_minutes < guardrails.min_duration_minutes: + violations.append(f"duration below min_duration_minutes={guardrails.min_duration_minutes}") + if order.duration_minutes > guardrails.max_duration_minutes: + violations.append(f"duration exceeds max_duration_minutes={guardrails.max_duration_minutes}") + if breakeven_btc_per_eh_day and breakeven_btc_per_eh_day > 0: + required = breakeven_btc_per_eh_day * (Decimal("1") - guardrails.min_discount_to_breakeven) + if order.price_btc_per_eh_day > required: + violations.append( + "price does not clear required discount to breakeven " + f"({order.price_btc_per_eh_day} > {required})" + ) + else: + violations.append("cannot validate discount without breakeven estimate") + + return violations + + +def token_looks_unsafe(token: str) -> bool: + lowered = token.lower() + unsafe_markers = ("owner", "admin", "trade", "write", "order", "secret") + return any(marker in lowered for marker in unsafe_markers) diff --git a/src/braiins_ratchet/models.py b/src/braiins_ratchet/models.py new file mode 100644 index 0000000..8bc0b08 --- /dev/null +++ b/src/braiins_ratchet/models.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import Literal + + +BTC = Decimal + + +@dataclass(frozen=True) +class OceanSnapshot: + timestamp_utc: str + pool_hashrate_eh_s: Decimal | None = None + network_difficulty_t: Decimal | None = None + share_log_window_t: Decimal | None = None + avg_block_time_hours: Decimal | None = None + source: str = "ocean" + + +@dataclass(frozen=True) +class MarketSnapshot: + timestamp_utc: str + best_price_btc_per_eh_day: Decimal | None + available_hashrate_eh_s: Decimal | None = None + source: str = "manual" + + +@dataclass(frozen=True) +class CandidateOrder: + price_btc_per_eh_day: Decimal + spend_btc: Decimal + duration_minutes: int + objective: str = "manual-canary" + + @property + def implied_hashrate_eh_s(self) -> Decimal: + days = Decimal(self.duration_minutes) / Decimal(1440) + if days <= 0 or self.price_btc_per_eh_day <= 0: + return Decimal("0") + return self.spend_btc / (self.price_btc_per_eh_day * days) + + +@dataclass(frozen=True) +class StrategyProposal: + action: Literal["observe", "manual_bid"] + reason: str + order: CandidateOrder | None + breakeven_btc_per_eh_day: Decimal | None + expected_reward_btc: Decimal + expected_net_btc: Decimal + score_btc: Decimal + maturity_note: str diff --git a/src/braiins_ratchet/ocean.py b/src/braiins_ratchet/ocean.py new file mode 100644 index 0000000..369da4f --- /dev/null +++ b/src/braiins_ratchet/ocean.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +import html +import re +from urllib.request import Request, urlopen + +from .models import OceanSnapshot + + +NUMBER = r"([0-9]+(?:\.[0-9]+)?)" + + +def fetch_dashboard_html(url: str, timeout_seconds: int = 15) -> str: + request = Request(url, headers={"User-Agent": "BraiinsRatchet/0.1 monitor-only"}) + with urlopen(request, timeout=timeout_seconds) as response: + return response.read().decode("utf-8", errors="replace") + + +def _find_decimal(patterns: tuple[str, ...], text: str) -> Decimal | None: + without_tags = re.sub(r"<[^>]+>", " ", text) + normalized = html.unescape(re.sub(r"\s+", " ", without_tags)) + for pattern in patterns: + match = re.search(pattern, normalized, flags=re.IGNORECASE) + if match: + return Decimal(match.group(1)) + return None + + +def parse_dashboard(html_text: str, source: str = "ocean-dashboard") -> OceanSnapshot: + return OceanSnapshot( + timestamp_utc=datetime.now(UTC).isoformat(timespec="seconds"), + pool_hashrate_eh_s=_find_decimal( + ( + rf"OCEAN Hashrate:\s*{NUMBER}\s*Eh/s", + rf"{NUMBER}\s*EH/s\s*Pool Hashrate", + rf"Pool Hashrate\s*{NUMBER}\s*EH/s", + ), + html_text, + ), + network_difficulty_t=_find_decimal( + ( + rf"{NUMBER}\s*T\s*Network Difficulty", + rf"Network Difficulty\s*{NUMBER}\s*T", + ), + html_text, + ), + share_log_window_t=_find_decimal( + ( + rf"{NUMBER}\s*T\s*Share Log", + rf"Share Log.*?{NUMBER}\s*T", + ), + html_text, + ), + avg_block_time_hours=_find_decimal( + ( + rf"{NUMBER}\s*h\s*Avg Block Time", + rf"Avg Block Time\s*{NUMBER}\s*h", + rf"Average Time to Block.*?{NUMBER}\s*hours", + ), + html_text, + ), + source=source, + ) + + +def fetch_snapshot(url: str) -> OceanSnapshot: + return parse_dashboard(fetch_dashboard_html(url), source=url) diff --git a/src/braiins_ratchet/storage.py b/src/braiins_ratchet/storage.py new file mode 100644 index 0000000..b49d80f --- /dev/null +++ b/src/braiins_ratchet/storage.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from pathlib import Path +import sqlite3 + +from .config import REPO_ROOT +from .models import MarketSnapshot, OceanSnapshot, StrategyProposal + + +DATA_DIR = REPO_ROOT / "data" +DB_PATH = DATA_DIR / "ratchet.sqlite" + + +def connect(path: Path = DB_PATH) -> sqlite3.Connection: + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path, timeout=30) + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def init_db(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS ocean_snapshots ( + id INTEGER PRIMARY KEY, + timestamp_utc TEXT NOT NULL, + pool_hashrate_eh_s TEXT, + network_difficulty_t TEXT, + share_log_window_t TEXT, + avg_block_time_hours TEXT, + source TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS market_snapshots ( + id INTEGER PRIMARY KEY, + timestamp_utc TEXT NOT NULL, + best_price_btc_per_eh_day TEXT, + available_hashrate_eh_s TEXT, + source TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS proposals ( + id INTEGER PRIMARY KEY, + timestamp_utc TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + action TEXT NOT NULL, + reason TEXT NOT NULL, + price_btc_per_eh_day TEXT, + spend_btc TEXT, + duration_minutes INTEGER, + breakeven_btc_per_eh_day TEXT, + expected_reward_btc TEXT NOT NULL, + expected_net_btc TEXT NOT NULL, + score_btc TEXT NOT NULL, + maturity_note TEXT NOT NULL + ); + """ + ) + conn.commit() + + +def save_ocean_snapshot(conn: sqlite3.Connection, snapshot: OceanSnapshot) -> None: + conn.execute( + """ + INSERT INTO ocean_snapshots ( + timestamp_utc, pool_hashrate_eh_s, network_difficulty_t, + share_log_window_t, avg_block_time_hours, source + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + snapshot.timestamp_utc, + str(snapshot.pool_hashrate_eh_s) if snapshot.pool_hashrate_eh_s is not None else None, + str(snapshot.network_difficulty_t) if snapshot.network_difficulty_t is not None else None, + str(snapshot.share_log_window_t) if snapshot.share_log_window_t is not None else None, + str(snapshot.avg_block_time_hours) if snapshot.avg_block_time_hours is not None else None, + snapshot.source, + ), + ) + conn.commit() + + +def save_market_snapshot(conn: sqlite3.Connection, snapshot: MarketSnapshot) -> None: + conn.execute( + """ + INSERT INTO market_snapshots ( + timestamp_utc, best_price_btc_per_eh_day, available_hashrate_eh_s, source + ) + VALUES (?, ?, ?, ?) + """, + ( + snapshot.timestamp_utc, + str(snapshot.best_price_btc_per_eh_day) + if snapshot.best_price_btc_per_eh_day is not None + else None, + str(snapshot.available_hashrate_eh_s) + if snapshot.available_hashrate_eh_s is not None + else None, + snapshot.source, + ), + ) + conn.commit() + + +def latest_ocean_snapshot(conn: sqlite3.Connection) -> OceanSnapshot | None: + row = conn.execute( + """ + SELECT timestamp_utc, pool_hashrate_eh_s, network_difficulty_t, + share_log_window_t, avg_block_time_hours, source + FROM ocean_snapshots + ORDER BY id DESC + LIMIT 1 + """ + ).fetchone() + if not row: + return None + from decimal import Decimal + + return OceanSnapshot( + timestamp_utc=row[0], + pool_hashrate_eh_s=Decimal(row[1]) if row[1] else None, + network_difficulty_t=Decimal(row[2]) if row[2] else None, + share_log_window_t=Decimal(row[3]) if row[3] else None, + avg_block_time_hours=Decimal(row[4]) if row[4] else None, + source=row[5], + ) + + +def latest_market_snapshot(conn: sqlite3.Connection) -> MarketSnapshot | None: + row = conn.execute( + """ + SELECT timestamp_utc, best_price_btc_per_eh_day, available_hashrate_eh_s, source + FROM market_snapshots + ORDER BY id DESC + LIMIT 1 + """ + ).fetchone() + if not row: + return None + from decimal import Decimal + + return MarketSnapshot( + timestamp_utc=row[0], + best_price_btc_per_eh_day=Decimal(row[1]) if row[1] else None, + available_hashrate_eh_s=Decimal(row[2]) if row[2] else None, + source=row[3], + ) + + +def save_proposal(conn: sqlite3.Connection, proposal: StrategyProposal) -> None: + order = proposal.order + conn.execute( + """ + INSERT INTO proposals ( + action, reason, price_btc_per_eh_day, spend_btc, duration_minutes, + breakeven_btc_per_eh_day, expected_reward_btc, expected_net_btc, + score_btc, maturity_note + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + proposal.action, + proposal.reason, + str(order.price_btc_per_eh_day) if order else None, + str(order.spend_btc) if order else None, + order.duration_minutes if order else None, + str(proposal.breakeven_btc_per_eh_day) + if proposal.breakeven_btc_per_eh_day is not None + else None, + str(proposal.expected_reward_btc), + str(proposal.expected_net_btc), + str(proposal.score_btc), + proposal.maturity_note, + ), + ) + conn.commit() diff --git a/src/braiins_ratchet/strategy.py b/src/braiins_ratchet/strategy.py new file mode 100644 index 0000000..f55b020 --- /dev/null +++ b/src/braiins_ratchet/strategy.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from decimal import Decimal + +from .config import AppConfig +from .ev import breakeven_btc_per_eh_day, downside_penalty, expected_reward_for_order +from .guardrails import validate_order +from .models import CandidateOrder, MarketSnapshot, OceanSnapshot, StrategyProposal + + +def propose( + config: AppConfig, + ocean: OceanSnapshot | None, + market: MarketSnapshot | None, +) -> StrategyProposal: + if ocean is None or ocean.network_difficulty_t is None: + return _observe("missing OCEAN difficulty snapshot") + if market is None or market.best_price_btc_per_eh_day is None: + breakeven = breakeven_btc_per_eh_day( + ocean.network_difficulty_t, + config.ocean.expected_block_reward_btc, + config.ocean.fee_rate, + ) + return StrategyProposal( + action="observe", + reason="missing Braiins market price snapshot", + order=None, + breakeven_btc_per_eh_day=breakeven, + expected_reward_btc=Decimal("0"), + expected_net_btc=Decimal("0"), + score_btc=Decimal("0"), + maturity_note=_maturity_note(ocean), + ) + + breakeven = breakeven_btc_per_eh_day( + ocean.network_difficulty_t, + config.ocean.expected_block_reward_btc, + config.ocean.fee_rate, + ) + spend = min(config.strategy.target_spend_btc, config.guardrails.max_manual_order_btc) + order = CandidateOrder( + price_btc_per_eh_day=market.best_price_btc_per_eh_day, + spend_btc=spend, + duration_minutes=config.strategy.target_duration_minutes, + ) + expected_reward = expected_reward_for_order( + order, + ocean.network_difficulty_t, + config.ocean.expected_block_reward_btc, + config.ocean.fee_rate, + ) + expected_net = expected_reward - order.spend_btc + score = expected_net - downside_penalty(expected_reward, config.strategy.risk_lambda) + + violations = validate_order(order, config.guardrails, breakeven) + if violations: + return StrategyProposal( + action="observe", + reason="guardrail blocked: " + "; ".join(violations), + order=order, + breakeven_btc_per_eh_day=breakeven, + expected_reward_btc=expected_reward, + expected_net_btc=expected_net, + score_btc=score, + maturity_note=_maturity_note(ocean), + ) + + if score <= 0: + return StrategyProposal( + action="observe", + reason=f"risk-adjusted score is not positive ({score})", + order=order, + breakeven_btc_per_eh_day=breakeven, + expected_reward_btc=expected_reward, + expected_net_btc=expected_net, + score_btc=score, + maturity_note=_maturity_note(ocean), + ) + + return StrategyProposal( + action="manual_bid", + reason="market price clears configured guardrails and positive risk-adjusted score", + order=order, + breakeven_btc_per_eh_day=breakeven, + expected_reward_btc=expected_reward, + expected_net_btc=expected_net, + score_btc=score, + maturity_note=_maturity_note(ocean), + ) + + +def _observe(reason: str) -> StrategyProposal: + return StrategyProposal( + action="observe", + reason=reason, + order=None, + breakeven_btc_per_eh_day=None, + expected_reward_btc=Decimal("0"), + expected_net_btc=Decimal("0"), + score_btc=Decimal("0"), + maturity_note="no maturity estimate without OCEAN snapshot", + ) + + +def _maturity_note(ocean: OceanSnapshot) -> str: + if ocean.avg_block_time_hours is None: + return "treat canary as immature until the OCEAN share-log window has elapsed" + window_hours = ocean.avg_block_time_hours * Decimal("8") + return f"treat canary as immature for about {window_hours} hours after spend" diff --git a/tests/test_ev.py b/tests/test_ev.py new file mode 100644 index 0000000..e39f419 --- /dev/null +++ b/tests/test_ev.py @@ -0,0 +1,19 @@ +from decimal import Decimal +import unittest + +from braiins_ratchet.ev import breakeven_btc_per_eh_day + + +class EvTests(unittest.TestCase): + def test_breakeven_near_expected_current_scale(self) -> None: + value = breakeven_btc_per_eh_day( + network_difficulty_t=Decimal("135.59"), + expected_block_reward_btc=Decimal("3.175"), + pool_fee_rate=Decimal("0.01"), + ) + self.assertGreater(value, Decimal("0.45")) + self.assertLess(value, Decimal("0.47")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py new file mode 100644 index 0000000..be2ca23 --- /dev/null +++ b/tests/test_guardrails.py @@ -0,0 +1,34 @@ +from decimal import Decimal +import unittest + +from braiins_ratchet.config import GuardrailsConfig +from braiins_ratchet.guardrails import token_looks_unsafe, validate_order +from braiins_ratchet.models import CandidateOrder + + +class GuardrailTests(unittest.TestCase): + def test_guardrails_block_high_price(self) -> None: + guardrails = GuardrailsConfig( + max_manual_order_btc=Decimal("0.00025"), + max_daily_spend_btc=Decimal("0.00050"), + max_price_btc_per_eh_day=Decimal("0.42"), + min_discount_to_breakeven=Decimal("0.08"), + min_duration_minutes=30, + max_duration_minutes=720, + recommend_only=True, + ) + order = CandidateOrder( + price_btc_per_eh_day=Decimal("0.50"), + spend_btc=Decimal("0.00010"), + duration_minutes=180, + ) + violations = validate_order(order, guardrails, Decimal("0.46")) + self.assertTrue(violations) + + def test_token_label_screening(self) -> None: + self.assertTrue(token_looks_unsafe("owner-secret-token")) + self.assertFalse(token_looks_unsafe("watcher-readonly-token")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ocean.py b/tests/test_ocean.py new file mode 100644 index 0000000..87ca91c --- /dev/null +++ b/tests/test_ocean.py @@ -0,0 +1,24 @@ +from decimal import Decimal +import unittest + +from braiins_ratchet.ocean import parse_dashboard + + +class OceanParserTests(unittest.TestCase): + def test_parse_dashboard_values_across_tags(self) -> None: + snapshot = parse_dashboard( + """ +
OCEAN Hashrate: 19.04 Eh/s
+
Network Difficulty
135.59T +
Average Time to Block
Based on 24-hour average9 hours +
Share Log Window
1084.76T + """ + ) + self.assertEqual(snapshot.pool_hashrate_eh_s, Decimal("19.04")) + self.assertEqual(snapshot.network_difficulty_t, Decimal("135.59")) + self.assertEqual(snapshot.avg_block_time_hours, Decimal("9")) + self.assertEqual(snapshot.share_log_window_t, Decimal("1084.76")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_strategy.py b/tests/test_strategy.py new file mode 100644 index 0000000..293f1c5 --- /dev/null +++ b/tests/test_strategy.py @@ -0,0 +1,62 @@ +from decimal import Decimal +import unittest + +from braiins_ratchet.config import AppConfig, CapitalConfig, GuardrailsConfig, OceanConfig, StrategyConfig +from braiins_ratchet.models import MarketSnapshot, OceanSnapshot +from braiins_ratchet.strategy import propose + + +class StrategyTests(unittest.TestCase): + def test_strategy_recommends_observe_without_market(self) -> None: + config = _config() + ocean = OceanSnapshot( + timestamp_utc="2026-04-25T00:00:00+00:00", + network_difficulty_t=Decimal("135.59"), + avg_block_time_hours=Decimal("9"), + ) + proposal = propose(config, ocean, None) + self.assertEqual(proposal.action, "observe") + + def test_strategy_can_emit_manual_bid_for_deep_discount(self) -> None: + config = _config() + ocean = OceanSnapshot( + timestamp_utc="2026-04-25T00:00:00+00:00", + network_difficulty_t=Decimal("135.59"), + avg_block_time_hours=Decimal("9"), + ) + market = MarketSnapshot( + timestamp_utc="2026-04-25T00:00:00+00:00", + best_price_btc_per_eh_day=Decimal("0.30"), + ) + proposal = propose(config, ocean, market) + self.assertEqual(proposal.action, "manual_bid") + + +def _config() -> AppConfig: + return AppConfig( + capital=CapitalConfig(available_btc=Decimal("0.01638650")), + ocean=OceanConfig( + fee_rate=Decimal("0.01"), + block_subsidy_btc=Decimal("3.125"), + default_tx_fees_btc=Decimal("0.05"), + dashboard_url="https://ocean.xyz/dashboard", + ), + guardrails=GuardrailsConfig( + max_manual_order_btc=Decimal("0.00025"), + max_daily_spend_btc=Decimal("0.00050"), + max_price_btc_per_eh_day=Decimal("0.42"), + min_discount_to_breakeven=Decimal("0.08"), + min_duration_minutes=30, + max_duration_minutes=720, + recommend_only=True, + ), + strategy=StrategyConfig( + target_duration_minutes=180, + target_spend_btc=Decimal("0.00010"), + risk_lambda=Decimal("0.35"), + ), + ) + + +if __name__ == "__main__": + unittest.main()