Add public Braiins market collector

This commit is contained in:
saymrwulf 2026-04-25 17:41:27 +02:00
parent 274b89e7f3
commit e6f001b980
11 changed files with 468 additions and 7 deletions

View file

@ -17,6 +17,7 @@ The first implementation is deliberately conservative:
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 collect-braiins-public
./.venv/bin/python -m braiins_ratchet.cli evaluate
```
@ -44,6 +45,28 @@ The JSON shape is:
}
```
## Public Braiins Market Data
The collector first uses unauthenticated public web endpoints from `hashpower.braiins.com`; no token is needed for live price action. See `docs/BRAIINS_PUBLIC_MARKET.md`.
Watcher-only tokens are only relevant if we later need account-specific read-only data such as your private balance, historical fills, or order status. Owner tokens remain out of scope.
## Documentation
- `PROGRAM.md`: research charter and ratchet rules.
- `SECURITY.md`: token, computer, and trading safety guardrails.
- `docs/BRAIINS_PUBLIC_MARKET.md`: public market collector behavior.
- `docs/RATCHET_OPERATIONS.md`: day-to-day monitor cycle.
- `docs/CLI_REFERENCE.md`: command reference and test command.
## Tests
```bash
PYTHONPATH=src ./.venv/bin/python -m unittest discover -s tests
```
The tests are network-free and use fixtures for public Braiins parsing. Live collectors are intentionally separate operational checks.
## 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.

View file

@ -0,0 +1,50 @@
# Braiins Public Market Collector
The public Braiins Hashpower page loads market data from unauthenticated browser endpoints under:
```text
https://hashpower.braiins.com/webapi
```
The monitor-only collector currently reads:
- `/spot/stats`: market status, last average price, total available hashrate, matched hashrate.
- `/orderbook`: public bids and asks.
No token is required for these calls. The collector does not send an `apikey` header and only performs HTTP `GET`.
## Price Selection
For a buyer, the conservative live reference price is:
1. Best ask, if available.
2. Last average price, if no ask exists.
3. Best bid, if neither ask nor last price exists.
The chosen value is stored as `best_price_btc_per_eh_day` for strategy evaluation. The raw fields are also stored:
- `best_bid_btc_per_eh_day`
- `best_ask_btc_per_eh_day`
- `last_price_btc_per_eh_day`
- `total_hashrate_eh_s`
- `available_hashrate_eh_s`
- `status`
## Commands
Collect one public Braiins snapshot:
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli collect-braiins-public
```
Then evaluate the current strategy:
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli evaluate
```
## Failure Mode
These endpoints are discovered from the public web app, not from a stable public API contract. If Braiins changes field names or requires authentication later, tests and collection should fail visibly. Do not add an owner token to recover functionality.

88
docs/CLI_REFERENCE.md Normal file
View file

@ -0,0 +1,88 @@
# CLI Reference
All commands should be run from the repository root.
Use the local virtual environment:
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli <command>
```
## `init-db`
Creates `data/ratchet.sqlite` if it does not exist.
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli init-db
```
## `collect-ocean`
Fetches one OCEAN dashboard snapshot and stores:
- pool hashrate
- network difficulty
- share-log window
- estimated OCEAN block time
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli collect-ocean
```
## `collect-braiins-public`
Fetches one token-free Braiins public market snapshot and stores:
- best bid
- best ask
- last average price
- market status
- total hashrate
- available hashrate
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli collect-braiins-public
```
Override the public base URL only for testing:
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli collect-braiins-public --base-url https://hashpower.braiins.com/webapi
```
## `import-market`
Imports a manual market JSON snapshot. Use this when public Braiins endpoints are unavailable.
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli import-market examples/market_snapshot.example.json
```
## `evaluate`
Evaluates the latest stored OCEAN and Braiins snapshots against `config.example.toml`.
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli evaluate
```
The command returns `observe` or `manual_bid`. It never places an order.
## `guardrails`
Prints the active guardrails.
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli guardrails
```
## Tests
Run all tests:
```bash
PYTHONPATH=src ./.venv/bin/python -m unittest discover -s tests
```
The test suite is network-free. Live collectors are validated separately by explicitly running their commands.

View file

@ -0,0 +1,36 @@
# Ratchet Operations
## Normal Monitor Cycle
Run these commands from the repository root:
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli collect-ocean
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli collect-braiins-public
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli evaluate
```
The result is a recommendation only. `manual_bid` means the strategy thinks a manually placed bid clears the configured guardrails. `observe` means no action is recommended.
## Manual Market Snapshot
If Braiins public endpoints are unavailable, use:
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli import-market examples/market_snapshot.example.json
```
Edit a copy of the example JSON with values read from the Braiins UI. Do not put secrets in that file.
## Interpreting The Proposal
- `breakeven_btc_per_eh_day`: estimated expected mining value at current OCEAN/network inputs.
- `expected_reward_btc`: expected OCEAN reward before subtracting Braiins spend.
- `expected_net_btc`: expected reward minus Braiins spend.
- `score_btc`: risk-adjusted score after applying the configured penalty.
- `maturity_note`: how long to wait before treating a canary result as mature under the TIDES window.
## Safety
The program never places orders. Manual execution remains outside the repo and should use the Braiins UI, not this code.

View file

@ -1,6 +1,11 @@
{
"timestamp_utc": "2026-04-25T12:00:00+00:00",
"best_price_btc_per_eh_day": "0.30",
"best_bid_btc_per_eh_day": "0.29",
"best_ask_btc_per_eh_day": "0.30",
"last_price_btc_per_eh_day": "0.31",
"total_hashrate_eh_s": "0.25",
"available_hashrate_eh_s": "0.10",
"status": "SPOT_INSTRUMENT_STATUS_ACTIVE",
"source": "manual-example"
}

View file

@ -5,6 +5,7 @@ from datetime import UTC, datetime
from decimal import Decimal
import json
import os
from typing import Any
from urllib.request import Request, urlopen
from .guardrails import token_looks_unsafe
@ -15,6 +16,36 @@ class BraiinsSafetyError(RuntimeError):
pass
SATOSHIS_PER_BTC = Decimal("100000000")
PH_PER_EH = Decimal("1000")
DEFAULT_PUBLIC_BASE = "https://hashpower.braiins.com/webapi"
@dataclass(frozen=True)
class BraiinsPublicClient:
api_base: str = DEFAULT_PUBLIC_BASE
def get_json(self, path: str) -> object:
if not path.startswith("/"):
raise BraiinsSafetyError("API path must start with /")
request = Request(
f"{self.api_base.rstrip('/')}{path}",
headers={"User-Agent": "BraiinsRatchet/0.1 public-market"},
method="GET",
)
with urlopen(request, timeout=15) as response:
return json.loads(response.read().decode("utf-8"))
def fetch_market_snapshot(self) -> MarketSnapshot:
stats = self.get_json("/spot/stats")
orderbook = self.get_json("/orderbook")
if not isinstance(stats, dict):
raise BraiinsSafetyError("/spot/stats did not return an object")
if not isinstance(orderbook, dict):
raise BraiinsSafetyError("/orderbook did not return an object")
return market_snapshot_from_public_api(stats, orderbook)
@dataclass(frozen=True)
class BraiinsWatcherClient:
api_base: str
@ -48,7 +79,8 @@ class BraiinsWatcherClient:
def market_snapshot_from_json_file(path: str) -> MarketSnapshot:
raw = json.loads(open(path, "r", encoding="utf-8").read())
with open(path, "r", encoding="utf-8") as handle:
raw = json.loads(handle.read())
return MarketSnapshot(
timestamp_utc=str(raw.get("timestamp_utc") or datetime.now(UTC).isoformat(timespec="seconds")),
best_price_btc_per_eh_day=(
@ -56,10 +88,82 @@ def market_snapshot_from_json_file(path: str) -> MarketSnapshot:
if raw.get("best_price_btc_per_eh_day") is not None
else None
),
best_bid_btc_per_eh_day=_optional_decimal(raw.get("best_bid_btc_per_eh_day")),
best_ask_btc_per_eh_day=_optional_decimal(raw.get("best_ask_btc_per_eh_day")),
last_price_btc_per_eh_day=_optional_decimal(raw.get("last_price_btc_per_eh_day")),
total_hashrate_eh_s=_optional_decimal(raw.get("total_hashrate_eh_s")),
available_hashrate_eh_s=(
Decimal(str(raw["available_hashrate_eh_s"]))
if raw.get("available_hashrate_eh_s") is not None
else None
),
status=str(raw["status"]) if raw.get("status") is not None else None,
source=str(raw.get("source") or path),
)
def market_snapshot_from_public_api(
stats: dict[str, Any],
orderbook: dict[str, Any],
timestamp_utc: str | None = None,
) -> MarketSnapshot:
best_bid = _best_price_from_orders(orderbook.get("bids"), prefer="max")
best_ask = _best_price_from_orders(orderbook.get("asks"), prefer="min")
last_price = _sat_to_btc(stats.get("last_avg_price_sat"))
total_hashrate = _ph_to_eh(stats.get("hash_rate_available_10m_ph"))
matched_hashrate = _ph_to_eh(stats.get("hash_rate_matched_10m_ph"))
available_hashrate = None
if total_hashrate is not None and matched_hashrate is not None:
available_hashrate = max(Decimal("0"), total_hashrate - matched_hashrate)
best_price = best_ask or last_price or best_bid
return MarketSnapshot(
timestamp_utc=timestamp_utc or datetime.now(UTC).isoformat(timespec="seconds"),
best_price_btc_per_eh_day=best_price,
best_bid_btc_per_eh_day=best_bid,
best_ask_btc_per_eh_day=best_ask,
last_price_btc_per_eh_day=last_price,
total_hashrate_eh_s=total_hashrate,
available_hashrate_eh_s=available_hashrate,
status=str(stats["status"]) if stats.get("status") is not None else None,
source="braiins-public",
)
def _best_price_from_orders(orders: object, prefer: str) -> Decimal | None:
if not isinstance(orders, list):
return None
prices = [
price
for order in orders
if isinstance(order, dict)
for price in [_sat_to_btc(order.get("price_sat"))]
if price is not None
]
if not prices:
return None
if prefer == "min":
return min(prices)
return max(prices)
def _sat_to_btc(value: object) -> Decimal | None:
decimal = _optional_decimal(value)
if decimal is None:
return None
return decimal / SATOSHIS_PER_BTC
def _ph_to_eh(value: object) -> Decimal | None:
decimal = _optional_decimal(value)
if decimal is None:
return None
return decimal / PH_PER_EH
def _optional_decimal(value: object) -> Decimal | None:
if value is None:
return None
return Decimal(str(value))

View file

@ -5,7 +5,7 @@ import json
from pathlib import Path
import sys
from .braiins import market_snapshot_from_json_file
from .braiins import BraiinsPublicClient, market_snapshot_from_json_file
from .config import load_config
from .ocean import fetch_snapshot
from .storage import (
@ -46,6 +46,16 @@ def cmd_import_market(args: argparse.Namespace) -> int:
return 0
def cmd_collect_braiins_public(args: argparse.Namespace) -> int:
client = BraiinsPublicClient(api_base=args.base_url.rstrip("/"))
snapshot = client.fetch_market_snapshot()
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:
@ -86,6 +96,13 @@ def build_parser() -> argparse.ArgumentParser:
market.add_argument("path")
market.set_defaults(func=cmd_import_market)
braiins = sub.add_parser(
"collect-braiins-public",
help="collect one unauthenticated Braiins public market snapshot",
)
braiins.add_argument("--base-url", default="https://hashpower.braiins.com/webapi")
braiins.set_defaults(func=cmd_collect_braiins_public)
evaluate = sub.add_parser("evaluate", help="emit monitor-only strategy recommendation")
evaluate.add_argument("--config")
evaluate.set_defaults(func=cmd_evaluate)

View file

@ -22,7 +22,12 @@ class OceanSnapshot:
class MarketSnapshot:
timestamp_utc: str
best_price_btc_per_eh_day: Decimal | None
best_bid_btc_per_eh_day: Decimal | None = None
best_ask_btc_per_eh_day: Decimal | None = None
last_price_btc_per_eh_day: Decimal | None = None
total_hashrate_eh_s: Decimal | None = None
available_hashrate_eh_s: Decimal | None = None
status: str | None = None
source: str = "manual"

View file

@ -35,7 +35,12 @@ def init_db(conn: sqlite3.Connection) -> None:
id INTEGER PRIMARY KEY,
timestamp_utc TEXT NOT NULL,
best_price_btc_per_eh_day TEXT,
best_bid_btc_per_eh_day TEXT,
best_ask_btc_per_eh_day TEXT,
last_price_btc_per_eh_day TEXT,
total_hashrate_eh_s TEXT,
available_hashrate_eh_s TEXT,
status TEXT,
source TEXT NOT NULL
);
@ -55,9 +60,27 @@ def init_db(conn: sqlite3.Connection) -> None:
);
"""
)
_ensure_market_columns(conn)
conn.commit()
def _ensure_market_columns(conn: sqlite3.Connection) -> None:
existing = {
row[1]
for row in conn.execute("PRAGMA table_info(market_snapshots)").fetchall()
}
desired = {
"best_bid_btc_per_eh_day": "TEXT",
"best_ask_btc_per_eh_day": "TEXT",
"last_price_btc_per_eh_day": "TEXT",
"total_hashrate_eh_s": "TEXT",
"status": "TEXT",
}
for column, column_type in desired.items():
if column not in existing:
conn.execute(f"ALTER TABLE market_snapshots ADD COLUMN {column} {column_type}")
def save_ocean_snapshot(conn: sqlite3.Connection, snapshot: OceanSnapshot) -> None:
conn.execute(
"""
@ -83,18 +106,33 @@ def save_market_snapshot(conn: sqlite3.Connection, snapshot: MarketSnapshot) ->
conn.execute(
"""
INSERT INTO market_snapshots (
timestamp_utc, best_price_btc_per_eh_day, available_hashrate_eh_s, source
timestamp_utc, best_price_btc_per_eh_day, best_bid_btc_per_eh_day,
best_ask_btc_per_eh_day, last_price_btc_per_eh_day,
total_hashrate_eh_s, available_hashrate_eh_s, status, source
)
VALUES (?, ?, ?, ?)
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.best_bid_btc_per_eh_day)
if snapshot.best_bid_btc_per_eh_day is not None
else None,
str(snapshot.best_ask_btc_per_eh_day)
if snapshot.best_ask_btc_per_eh_day is not None
else None,
str(snapshot.last_price_btc_per_eh_day)
if snapshot.last_price_btc_per_eh_day is not None
else None,
str(snapshot.total_hashrate_eh_s)
if snapshot.total_hashrate_eh_s is not None
else None,
str(snapshot.available_hashrate_eh_s)
if snapshot.available_hashrate_eh_s is not None
else None,
snapshot.status,
snapshot.source,
),
)
@ -128,7 +166,9 @@ def latest_ocean_snapshot(conn: sqlite3.Connection) -> OceanSnapshot | None:
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
SELECT timestamp_utc, best_price_btc_per_eh_day, best_bid_btc_per_eh_day,
best_ask_btc_per_eh_day, last_price_btc_per_eh_day,
total_hashrate_eh_s, available_hashrate_eh_s, status, source
FROM market_snapshots
ORDER BY id DESC
LIMIT 1
@ -141,8 +181,13 @@ def latest_market_snapshot(conn: sqlite3.Connection) -> MarketSnapshot | None:
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],
best_bid_btc_per_eh_day=Decimal(row[2]) if row[2] else None,
best_ask_btc_per_eh_day=Decimal(row[3]) if row[3] else None,
last_price_btc_per_eh_day=Decimal(row[4]) if row[4] else None,
total_hashrate_eh_s=Decimal(row[5]) if row[5] else None,
available_hashrate_eh_s=Decimal(row[6]) if row[6] else None,
status=row[7],
source=row[8],
)

View file

@ -0,0 +1,47 @@
from decimal import Decimal
import unittest
from braiins_ratchet.braiins import market_snapshot_from_public_api
class BraiinsPublicParserTests(unittest.TestCase):
def test_public_snapshot_uses_best_ask_as_buy_reference(self) -> None:
stats = {
"status": "SPOT_INSTRUMENT_STATUS_ACTIVE",
"last_avg_price_sat": 31000000,
"hash_rate_available_10m_ph": 250,
"hash_rate_matched_10m_ph": 40,
}
orderbook = {
"bids": [{"price_sat": 29000000}, {"price_sat": 28000000}],
"asks": [{"price_sat": 30000000}, {"price_sat": 32000000}],
}
snapshot = market_snapshot_from_public_api(
stats,
orderbook,
timestamp_utc="2026-04-25T12:00:00+00:00",
)
self.assertEqual(snapshot.best_price_btc_per_eh_day, Decimal("0.3"))
self.assertEqual(snapshot.best_bid_btc_per_eh_day, Decimal("0.29"))
self.assertEqual(snapshot.best_ask_btc_per_eh_day, Decimal("0.3"))
self.assertEqual(snapshot.last_price_btc_per_eh_day, Decimal("0.31"))
self.assertEqual(snapshot.total_hashrate_eh_s, Decimal("0.25"))
self.assertEqual(snapshot.available_hashrate_eh_s, Decimal("0.21"))
self.assertEqual(snapshot.status, "SPOT_INSTRUMENT_STATUS_ACTIVE")
def test_public_snapshot_falls_back_to_last_price_without_asks(self) -> None:
snapshot = market_snapshot_from_public_api(
{"last_avg_price_sat": 31000000},
{"bids": [{"price_sat": 29000000}], "asks": []},
timestamp_utc="2026-04-25T12:00:00+00:00",
)
self.assertEqual(snapshot.best_price_btc_per_eh_day, Decimal("0.31"))
self.assertEqual(snapshot.best_bid_btc_per_eh_day, Decimal("0.29"))
self.assertIsNone(snapshot.best_ask_btc_per_eh_day)
if __name__ == "__main__":
unittest.main()

41
tests/test_storage.py Normal file
View file

@ -0,0 +1,41 @@
from decimal import Decimal
import sqlite3
import unittest
from braiins_ratchet.models import MarketSnapshot
from braiins_ratchet.storage import init_db, latest_market_snapshot, save_market_snapshot
class StorageTests(unittest.TestCase):
def test_market_snapshot_round_trip_extended_fields(self) -> None:
conn = sqlite3.connect(":memory:")
init_db(conn)
save_market_snapshot(
conn,
MarketSnapshot(
timestamp_utc="2026-04-25T12:00:00+00:00",
best_price_btc_per_eh_day=Decimal("0.30"),
best_bid_btc_per_eh_day=Decimal("0.29"),
best_ask_btc_per_eh_day=Decimal("0.30"),
last_price_btc_per_eh_day=Decimal("0.31"),
total_hashrate_eh_s=Decimal("0.25"),
available_hashrate_eh_s=Decimal("0.21"),
status="SPOT_INSTRUMENT_STATUS_ACTIVE",
source="test",
),
)
snapshot = latest_market_snapshot(conn)
self.assertIsNotNone(snapshot)
assert snapshot is not None
self.assertEqual(snapshot.best_price_btc_per_eh_day, Decimal("0.30"))
self.assertEqual(snapshot.best_bid_btc_per_eh_day, Decimal("0.29"))
self.assertEqual(snapshot.best_ask_btc_per_eh_day, Decimal("0.30"))
self.assertEqual(snapshot.last_price_btc_per_eh_day, Decimal("0.31"))
self.assertEqual(snapshot.total_hashrate_eh_s, Decimal("0.25"))
self.assertEqual(snapshot.available_hashrate_eh_s, Decimal("0.21"))
self.assertEqual(snapshot.status, "SPOT_INSTRUMENT_STATUS_ACTIVE")
if __name__ == "__main__":
unittest.main()