Add monitor cycle and reporting commands

This commit is contained in:
saymrwulf 2026-04-25 17:55:10 +02:00
parent e6f001b980
commit b51a8b137e
11 changed files with 478 additions and 10 deletions

View file

@ -16,9 +16,8 @@ The first implementation is deliberately conservative:
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
./.venv/bin/python -m braiins_ratchet.cli init-db ./.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 cycle
./.venv/bin/python -m braiins_ratchet.cli collect-braiins-public ./.venv/bin/python -m braiins_ratchet.cli report
./.venv/bin/python -m braiins_ratchet.cli evaluate
``` ```
Set `PYTHONPATH=src` if running without installation: Set `PYTHONPATH=src` if running without installation:
@ -27,6 +26,12 @@ Set `PYTHONPATH=src` if running without installation:
PYTHONPATH=src python3 -m braiins_ratchet.cli evaluate PYTHONPATH=src python3 -m braiins_ratchet.cli evaluate
``` ```
Run a bounded monitor loop:
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli watch --cycles 12 --interval-seconds 300
```
Import a manual Braiins market snapshot: Import a manual Braiins market snapshot:
```bash ```bash

View file

@ -50,6 +50,34 @@ Override the public base URL only for testing:
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli collect-braiins-public --base-url https://hashpower.braiins.com/webapi PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli collect-braiins-public --base-url https://hashpower.braiins.com/webapi
``` ```
## `cycle`
Runs one complete monitor pass:
1. collect OCEAN
2. collect public Braiins market data
3. evaluate the strategy
4. store the resulting proposal
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli cycle
```
Use existing stored data for one side if needed:
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli cycle --skip-ocean
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli cycle --skip-braiins
```
## `watch`
Runs a bounded monitor loop. The interval floor is 30 seconds to avoid hammering public endpoints.
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli watch --cycles 12 --interval-seconds 300
```
## `import-market` ## `import-market`
Imports a manual market JSON snapshot. Use this when public Braiins endpoints are unavailable. Imports a manual market JSON snapshot. Use this when public Braiins endpoints are unavailable.
@ -68,6 +96,21 @@ PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli evaluate
The command returns `observe` or `manual_bid`. It never places an order. The command returns `observe` or `manual_bid`. It never places an order.
## `report`
Prints the latest OCEAN snapshot, latest Braiins snapshot, sampled price range, and latest proposal.
The sampled price range uses public Braiins snapshots only, so manually imported examples do not skew operational statistics.
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli report
```
Control the number of recent market samples used for min/avg/max:
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli report --samples 200
```
## `guardrails` ## `guardrails`
Prints the active guardrails. Prints the active guardrails.
@ -85,4 +128,3 @@ 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. The test suite is network-free. Live collectors are validated separately by explicitly running their commands.

View file

@ -5,13 +5,24 @@
Run these commands from the repository root: Run these commands from the repository root:
```bash ```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli collect-ocean PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli cycle
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli collect-braiins-public PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli report
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. 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.
The report's sampled price min/avg/max uses public Braiins snapshots only. Manual imports are still stored and can drive evaluation, but they do not pollute live market summary statistics.
## Repeated Sampling
For short monitor sessions:
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli watch --cycles 12 --interval-seconds 300
```
This runs for about one hour at 5-minute intervals. It is bounded by design; use your shell or system scheduler if you want recurring sessions.
## Manual Market Snapshot ## Manual Market Snapshot
If Braiins public endpoints are unavailable, use: If Braiins public endpoints are unavailable, use:
@ -33,4 +44,3 @@ Edit a copy of the example JSON with values read from the Braiins UI. Do not put
## Safety ## Safety
The program never places orders. Manual execution remains outside the repo and should use the Braiins UI, not this code. The program never places orders. Manual execution remains outside the repo and should use the Braiins UI, not this code.

View file

@ -4,10 +4,13 @@ import argparse
import json import json
from pathlib import Path from pathlib import Path
import sys import sys
import time
from .braiins import BraiinsPublicClient, market_snapshot_from_json_file from .braiins import BraiinsPublicClient, market_snapshot_from_json_file
from .config import load_config from .config import load_config
from .monitor import run_cycle
from .ocean import fetch_snapshot from .ocean import fetch_snapshot
from .report import build_text_report
from .storage import ( from .storage import (
connect, connect,
init_db, init_db,
@ -56,6 +59,35 @@ def cmd_collect_braiins_public(args: argparse.Namespace) -> int:
return 0 return 0
def cmd_cycle(args: argparse.Namespace) -> int:
config = load_config(Path(args.config) if args.config else None)
with connect() as conn:
result = run_cycle(
conn,
config,
collect_ocean=not args.skip_ocean,
collect_braiins=not args.skip_braiins,
)
print(_proposal_json(result))
return 0
def cmd_watch(args: argparse.Namespace) -> int:
config = load_config(Path(args.config) if args.config else None)
if args.interval_seconds < 30:
raise SystemExit("interval must be at least 30 seconds")
if args.cycles < 1:
raise SystemExit("cycles must be at least 1")
with connect() as conn:
for index in range(args.cycles):
result = run_cycle(conn, config)
print(f"cycle {index + 1}/{args.cycles}: {result.proposal.action} - {result.proposal.reason}")
if index + 1 < args.cycles:
time.sleep(args.interval_seconds)
return 0
def cmd_evaluate(args: argparse.Namespace) -> int: def cmd_evaluate(args: argparse.Namespace) -> int:
config = load_config(Path(args.config) if args.config else None) config = load_config(Path(args.config) if args.config else None)
with connect() as conn: with connect() as conn:
@ -66,6 +98,13 @@ def cmd_evaluate(args: argparse.Namespace) -> int:
return 0 return 0
def cmd_report(args: argparse.Namespace) -> int:
with connect() as conn:
init_db(conn)
print(build_text_report(conn, sample_limit=args.samples))
return 0
def cmd_guardrails(args: argparse.Namespace) -> int: def cmd_guardrails(args: argparse.Namespace) -> int:
config = load_config(Path(args.config) if args.config else None) config = load_config(Path(args.config) if args.config else None)
print(json.dumps(config.guardrails.__dict__, default=str, indent=2)) print(json.dumps(config.guardrails.__dict__, default=str, indent=2))
@ -103,10 +142,26 @@ def build_parser() -> argparse.ArgumentParser:
braiins.add_argument("--base-url", default="https://hashpower.braiins.com/webapi") braiins.add_argument("--base-url", default="https://hashpower.braiins.com/webapi")
braiins.set_defaults(func=cmd_collect_braiins_public) braiins.set_defaults(func=cmd_collect_braiins_public)
cycle = sub.add_parser("cycle", help="collect OCEAN, collect public Braiins, then evaluate")
cycle.add_argument("--config")
cycle.add_argument("--skip-ocean", action="store_true")
cycle.add_argument("--skip-braiins", action="store_true")
cycle.set_defaults(func=cmd_cycle)
watch = sub.add_parser("watch", help="run bounded repeated monitor cycles")
watch.add_argument("--config")
watch.add_argument("--cycles", type=int, default=3)
watch.add_argument("--interval-seconds", type=int, default=300)
watch.set_defaults(func=cmd_watch)
evaluate = sub.add_parser("evaluate", help="emit monitor-only strategy recommendation") evaluate = sub.add_parser("evaluate", help="emit monitor-only strategy recommendation")
evaluate.add_argument("--config") evaluate.add_argument("--config")
evaluate.set_defaults(func=cmd_evaluate) evaluate.set_defaults(func=cmd_evaluate)
report = sub.add_parser("report", help="print latest state and proposal")
report.add_argument("--samples", type=int, default=50)
report.set_defaults(func=cmd_report)
guardrails = sub.add_parser("guardrails", help="print active guardrails") guardrails = sub.add_parser("guardrails", help="print active guardrails")
guardrails.add_argument("--config") guardrails.add_argument("--config")
guardrails.set_defaults(func=cmd_guardrails) guardrails.set_defaults(func=cmd_guardrails)

View file

@ -31,6 +31,14 @@ class MarketSnapshot:
source: str = "manual" source: str = "manual"
@dataclass(frozen=True)
class PriceStats:
count: int
min_price: Decimal | None
avg_price: Decimal | None
max_price: Decimal | None
@dataclass(frozen=True) @dataclass(frozen=True)
class CandidateOrder: class CandidateOrder:
price_btc_per_eh_day: Decimal price_btc_per_eh_day: Decimal

View file

@ -0,0 +1,58 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from .braiins import BraiinsPublicClient
from .config import AppConfig
from .models import MarketSnapshot, OceanSnapshot, StrategyProposal
from .ocean import fetch_snapshot as fetch_ocean_snapshot
from .storage import (
init_db,
latest_market_snapshot,
latest_ocean_snapshot,
save_market_snapshot,
save_ocean_snapshot,
save_proposal,
)
from .strategy import propose
OceanFetcher = Callable[[str], OceanSnapshot]
MarketFetcher = Callable[[], MarketSnapshot]
@dataclass(frozen=True)
class CycleResult:
ocean: OceanSnapshot | None
market: MarketSnapshot | None
proposal: StrategyProposal
def run_cycle(
conn,
config: AppConfig,
*,
collect_ocean: bool = True,
collect_braiins: bool = True,
ocean_fetcher: OceanFetcher = fetch_ocean_snapshot,
market_fetcher: MarketFetcher | None = None,
) -> CycleResult:
init_db(conn)
ocean = latest_ocean_snapshot(conn)
market = latest_market_snapshot(conn)
if collect_ocean:
ocean = ocean_fetcher(config.ocean.dashboard_url)
save_ocean_snapshot(conn, ocean)
if collect_braiins:
fetcher = market_fetcher or BraiinsPublicClient().fetch_market_snapshot
market = fetcher()
save_market_snapshot(conn, market)
proposal = propose(config, ocean, market)
save_proposal(conn, proposal)
return CycleResult(ocean=ocean, market=market, proposal=proposal)

View file

@ -0,0 +1,76 @@
from __future__ import annotations
from .models import MarketSnapshot, OceanSnapshot, PriceStats, StrategyProposal
from .storage import latest_market_snapshot, latest_ocean_snapshot, latest_proposal, market_price_stats
def build_text_report(conn, *, sample_limit: int = 50) -> str:
ocean = latest_ocean_snapshot(conn)
market = latest_market_snapshot(conn)
proposal = latest_proposal(conn)
stats = market_price_stats(conn, sample_limit, source="braiins-public")
lines = ["Braiins Ratchet Report", ""]
lines.extend(_ocean_lines(ocean))
lines.append("")
lines.extend(_market_lines(market, stats))
lines.append("")
lines.extend(_proposal_lines(proposal))
return "\n".join(lines)
def _ocean_lines(ocean: OceanSnapshot | None) -> list[str]:
if ocean is None:
return ["OCEAN: no snapshot stored"]
return [
f"OCEAN snapshot: {ocean.timestamp_utc}",
f" pool_hashrate_eh_s: {_fmt(ocean.pool_hashrate_eh_s)}",
f" network_difficulty_t: {_fmt(ocean.network_difficulty_t)}",
f" share_log_window_t: {_fmt(ocean.share_log_window_t)}",
f" avg_block_time_hours: {_fmt(ocean.avg_block_time_hours)}",
]
def _market_lines(market: MarketSnapshot | None, stats: PriceStats) -> list[str]:
if market is None:
return ["Braiins market: no snapshot stored"]
return [
f"Braiins market snapshot: {market.timestamp_utc}",
f" status: {market.status or 'unknown'}",
f" best_bid_btc_per_eh_day: {_fmt(market.best_bid_btc_per_eh_day)}",
f" best_ask_btc_per_eh_day: {_fmt(market.best_ask_btc_per_eh_day)}",
f" last_price_btc_per_eh_day: {_fmt(market.last_price_btc_per_eh_day)}",
f" available_hashrate_eh_s: {_fmt(market.available_hashrate_eh_s)}",
f" sampled_price_count: {stats.count}",
f" sampled_price_min_avg_max: {_fmt(stats.min_price)} / {_fmt(stats.avg_price)} / {_fmt(stats.max_price)}",
]
def _proposal_lines(proposal: StrategyProposal | None) -> list[str]:
if proposal is None:
return ["Strategy: no proposal stored"]
lines = [
f"Strategy action: {proposal.action}",
f" reason: {proposal.reason}",
f" breakeven_btc_per_eh_day: {_fmt(proposal.breakeven_btc_per_eh_day)}",
f" expected_reward_btc: {_fmt(proposal.expected_reward_btc)}",
f" expected_net_btc: {_fmt(proposal.expected_net_btc)}",
f" score_btc: {_fmt(proposal.score_btc)}",
f" maturity: {proposal.maturity_note}",
]
if proposal.order is not None:
lines.extend(
[
f" proposed_price_btc_per_eh_day: {_fmt(proposal.order.price_btc_per_eh_day)}",
f" proposed_spend_btc: {_fmt(proposal.order.spend_btc)}",
f" proposed_duration_minutes: {proposal.order.duration_minutes}",
f" implied_hashrate_eh_s: {_fmt(proposal.order.implied_hashrate_eh_s)}",
]
)
return lines
def _fmt(value: object) -> str:
if value is None:
return "n/a"
return str(value)

View file

@ -4,7 +4,7 @@ from pathlib import Path
import sqlite3 import sqlite3
from .config import REPO_ROOT from .config import REPO_ROOT
from .models import MarketSnapshot, OceanSnapshot, StrategyProposal from .models import CandidateOrder, MarketSnapshot, OceanSnapshot, PriceStats, StrategyProposal
DATA_DIR = REPO_ROOT / "data" DATA_DIR = REPO_ROOT / "data"
@ -191,6 +191,68 @@ def latest_market_snapshot(conn: sqlite3.Connection) -> MarketSnapshot | None:
) )
def latest_proposal(conn: sqlite3.Connection) -> StrategyProposal | None:
row = conn.execute(
"""
SELECT 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
FROM proposals
ORDER BY id DESC
LIMIT 1
"""
).fetchone()
if not row:
return None
from decimal import Decimal
order = None
if row[2] is not None and row[3] is not None and row[4] is not None:
order = CandidateOrder(
price_btc_per_eh_day=Decimal(row[2]),
spend_btc=Decimal(row[3]),
duration_minutes=int(row[4]),
)
return StrategyProposal(
action=row[0],
reason=row[1],
order=order,
breakeven_btc_per_eh_day=Decimal(row[5]) if row[5] else None,
expected_reward_btc=Decimal(row[6]),
expected_net_btc=Decimal(row[7]),
score_btc=Decimal(row[8]),
maturity_note=row[9],
)
def market_price_stats(conn: sqlite3.Connection, limit: int, source: str | None = None):
from decimal import Decimal
source_filter = "AND source = ?" if source else ""
params: tuple[object, ...] = (source, limit) if source else (limit,)
rows = conn.execute(
f"""
SELECT best_price_btc_per_eh_day
FROM market_snapshots
WHERE best_price_btc_per_eh_day IS NOT NULL
{source_filter}
ORDER BY id DESC
LIMIT ?
""",
params,
).fetchall()
prices = [Decimal(row[0]) for row in rows]
if not prices:
return PriceStats(count=0, min_price=None, avg_price=None, max_price=None)
return PriceStats(
count=len(prices),
min_price=min(prices),
avg_price=sum(prices) / Decimal(len(prices)),
max_price=max(prices),
)
def save_proposal(conn: sqlite3.Connection, proposal: StrategyProposal) -> None: def save_proposal(conn: sqlite3.Connection, proposal: StrategyProposal) -> None:
order = proposal.order order = proposal.order
conn.execute( conn.execute(

63
tests/test_monitor.py Normal file
View file

@ -0,0 +1,63 @@
from decimal import Decimal
import sqlite3
import unittest
from braiins_ratchet.config import AppConfig, CapitalConfig, GuardrailsConfig, OceanConfig, StrategyConfig
from braiins_ratchet.models import MarketSnapshot, OceanSnapshot
from braiins_ratchet.monitor import run_cycle
from braiins_ratchet.storage import latest_market_snapshot, latest_ocean_snapshot, latest_proposal
class MonitorTests(unittest.TestCase):
def test_run_cycle_collects_and_evaluates(self) -> None:
conn = sqlite3.connect(":memory:")
result = run_cycle(
conn,
_config(),
ocean_fetcher=lambda _: OceanSnapshot(
timestamp_utc="2026-04-25T12:00:00+00:00",
network_difficulty_t=Decimal("135.59"),
avg_block_time_hours=Decimal("9"),
),
market_fetcher=lambda: MarketSnapshot(
timestamp_utc="2026-04-25T12:00:01+00:00",
best_price_btc_per_eh_day=Decimal("0.30"),
best_ask_btc_per_eh_day=Decimal("0.30"),
source="test",
),
)
self.assertEqual(result.proposal.action, "manual_bid")
self.assertIsNotNone(latest_ocean_snapshot(conn))
self.assertIsNotNone(latest_market_snapshot(conn))
self.assertIsNotNone(latest_proposal(conn))
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()

62
tests/test_report.py Normal file
View file

@ -0,0 +1,62 @@
from decimal import Decimal
import sqlite3
import unittest
from braiins_ratchet.models import CandidateOrder, MarketSnapshot, OceanSnapshot, StrategyProposal
from braiins_ratchet.report import build_text_report
from braiins_ratchet.storage import init_db, save_market_snapshot, save_ocean_snapshot, save_proposal
class ReportTests(unittest.TestCase):
def test_report_includes_latest_state_and_proposal(self) -> None:
conn = sqlite3.connect(":memory:")
init_db(conn)
save_ocean_snapshot(
conn,
OceanSnapshot(
timestamp_utc="2026-04-25T12:00:00+00:00",
pool_hashrate_eh_s=Decimal("19.04"),
network_difficulty_t=Decimal("135.59"),
share_log_window_t=Decimal("1084.76"),
avg_block_time_hours=Decimal("9"),
),
)
save_market_snapshot(
conn,
MarketSnapshot(
timestamp_utc="2026-04-25T12:00:01+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"),
available_hashrate_eh_s=Decimal("0.21"),
status="SPOT_INSTRUMENT_STATUS_ACTIVE",
),
)
save_proposal(
conn,
StrategyProposal(
action="manual_bid",
reason="test",
order=CandidateOrder(
price_btc_per_eh_day=Decimal("0.30"),
spend_btc=Decimal("0.00010"),
duration_minutes=180,
),
breakeven_btc_per_eh_day=Decimal("0.46"),
expected_reward_btc=Decimal("0.00015"),
expected_net_btc=Decimal("0.00005"),
score_btc=Decimal("0.00001"),
maturity_note="wait",
),
)
report = build_text_report(conn)
self.assertIn("Strategy action: manual_bid", report)
self.assertIn("best_ask_btc_per_eh_day: 0.30", report)
self.assertIn("network_difficulty_t: 135.59", report)
self.assertIn("implied_hashrate_eh_s", report)
if __name__ == "__main__":
unittest.main()

View file

@ -3,7 +3,7 @@ import sqlite3
import unittest import unittest
from braiins_ratchet.models import MarketSnapshot from braiins_ratchet.models import MarketSnapshot
from braiins_ratchet.storage import init_db, latest_market_snapshot, save_market_snapshot from braiins_ratchet.storage import init_db, latest_market_snapshot, market_price_stats, save_market_snapshot
class StorageTests(unittest.TestCase): class StorageTests(unittest.TestCase):
@ -36,6 +36,33 @@ class StorageTests(unittest.TestCase):
self.assertEqual(snapshot.available_hashrate_eh_s, Decimal("0.21")) self.assertEqual(snapshot.available_hashrate_eh_s, Decimal("0.21"))
self.assertEqual(snapshot.status, "SPOT_INSTRUMENT_STATUS_ACTIVE") self.assertEqual(snapshot.status, "SPOT_INSTRUMENT_STATUS_ACTIVE")
def test_market_price_stats_can_filter_source(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"),
source="manual-example",
),
)
save_market_snapshot(
conn,
MarketSnapshot(
timestamp_utc="2026-04-25T12:00:01+00:00",
best_price_btc_per_eh_day=Decimal("0.46"),
source="braiins-public",
),
)
stats = market_price_stats(conn, 50, source="braiins-public")
self.assertEqual(stats.count, 1)
self.assertEqual(stats.min_price, Decimal("0.46"))
self.assertEqual(stats.avg_price, Decimal("0.46"))
self.assertEqual(stats.max_price, Decimal("0.46"))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()