Skill: captainslab/btc-sprint-stack (version 0.4.2)

--- name: btc-sprint-stack description: Trade BTC 5m/15m Polymarket fast markets on Simmer with dry-run-first execution, fee-aware filtering, bankroll limits, flat signal_data, journaling, heartbeat summaries, and a strict BTC-only LLM decision layer. metadata: author: "Codex" version: "0.1.0" displayName: "BTC Sprint Stack" difficulty: "advanced" --- # BTC Sprint Stack Use this skill to operate a conservative BTC 5m/15m sprint bot on Simmer. > **This is a template.** The default signal is short-horizon BTC momentum plus > Simmer context filters. Replace the signal source, confidence model, or edge > inputs without changing the execution, journaling, and risk plumbing. ## Operating constraints - Default to dry-run. - Use the official `SimmerClient` from `simmer-sdk`. - Only target BTC fast markets (`5m`, `15m`) from Polymarket. - Enforce risk defaults from `config/defaults.json`. - Merge learned tunables from `data/live_params.json` before env overrides. - Every trade must include: - `source` - `skill_slug` - `reasoning` - `signal_data.edge` - `signal_data.confidence` - `signal_data.signal_source` - LLM decisions must validate to strict JSON and remain BTC-only. ## Entrypoint ```bash ./.venv/bin/python skills/btc-sprint-stack/main.py --once --dry-run --validate-real-path ``` ## Files - `main.py` — orchestration - `modules/btc_sprint_signal.py` — momentum and fallback signal - `modules/btc_regime_filter.py` — time, spread, edge, confidence, fee checks - `modules/btc_sprint_executor.py` — dry-run/live execution wrapper - `modules/btc_position_manager.py` — bankroll and position sizing - `modules/btc_trade_journal.py` — JSONL journal - `modules/btc_self_learn.py` — bounded parameter suggestions - `modules/btc_heartbeat.py` — run summary and briefing - `modules/btc_llm_decider.py` — strict JSON decision layer, provider abstraction, and learning store helpers - `data/live_params.json` — learned tunables overlay - `data/pending_rules.json` — pending and applied rule suggestions - `data/llm_decisions.jsonl` — per-cycle LLM decision log

path: _meta.json
content: ownerId: kn752f9graasf1y2fxq0j6qvnd8366ar
slug: btc-sprint-stack
version: 0.1.0
publishedAt: 1775147226502

path: clawhub.json
content: {
  "version": "0.2.0",
  "emoji": "btc",
  "requires": {
    "pip": [
      "simmer-sdk",
      "eth-account",
      "google-auth",
      "requests"
    ],
    "env": [
      "SIMMER_API_KEY",
      "LLM_API_KEY"
    ]
  },
  "cron": "*/15 * * * *",
  "automaton": {
    "managed": true,
    "entrypoint": "main.py"
  }
}


path: config/defaults.json
content: {
  "skill_slug": "btc-sprint-stack",
  "asset": "BTC",
  "windows": [
    "5m",
    "15m"
  ],
  "signal_source": "binance_btcusdt_1m",
  "bankroll_usd": 60,
  "max_trade_usd": 4,
  "max_daily_loss_usd": 10,
  "max_open_positions": 2,
  "max_single_market_exposure_usd": 8,
  "max_trades_per_day": 6,
  "min_edge": 0.07,
  "min_confidence": 0.65,
  "max_slippage_pct": 0.1,
  "stop_loss_pct": 0.1,
  "take_profit_pct": 0.12,
  "cooldown_after_loss_minutes": 60,
  "cycle_interval_minutes": 15,
  "trading_venue": "polymarket",
  "validate_real_path": true
}


path: main.py
content: from __future__ import annotations

import argparse
import json
import os
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace

from simmer_sdk import SimmerClient

ROOT = Path(__file__).resolve().parent
MODULES = ROOT / 'modules'
if str(MODULES) not in sys.path:
    sys.path.insert(0, str(MODULES))

from btc_discord_alert import DiscordAlertError, send_discord_alert
from btc_heartbeat import build_heartbeat
from btc_llm_decider import (
    LLM_BLOCKER,
    MissingLLMCredentialsError,
    apply_eligible_rules,
    append_jsonl,
    build_decision_record,
    build_provider_from_env,
    load_learned_params,
    merge_learned_params,
    read_json_file,
    record_pending_rule,
    run_llm_decision,
    write_json_file,
)
from btc_position_manager import enforce_risk_limits
from btc_regime_filter import evaluate_regime
from btc_self_learn import build_learning_snapshot
from btc_sprint_executor import execute_trade
from btc_sprint_signal import SignalDecision, build_signal
from btc_trade_journal import append_journal, read_journal


DATA_DIR = ROOT / 'data'
JOURNAL_PATH = DATA_DIR / 'journal.jsonl'
LEARNING_PATH = DATA_DIR / 'learning.json'
LIVE_PARAMS_PATH = DATA_DIR / 'live_params.json'
PENDING_RULES_PATH = DATA_DIR / 'pending_rules.json'
LLM_DECISIONS_PATH = DATA_DIR / 'llm_decisions.jsonl'
DEFAULTS_PATH = ROOT / 'config' / 'defaults.json'

TUNABLE_KEYS = [
    'min_edge',
    'min_confidence',
    'max_slippage_pct',
    'cycle_interval_minutes',
    'stop_loss_pct',
    'take_profit_pct',
]


def _trace_enabled() -> bool:
    value = (os.environ.get('BTC_SPRINT_TRACE') or '').strip().lower()
    return value in {'1', 'true', 'yes', 'on'}


def trace(*parts: object) -> None:
    if _trace_enabled():
        print('[TRACE]', *parts, flush=True)


def _hard_clamp_config(config: dict) -> dict:
    config['asset'] = 'BTC'
    config['windows'] = ['5m', '15m']
    config['trading_venue'] = 'polymarket'
    config['binance_symbol'] = 'BTCUSDT'
    config.setdefault('binance_interval', '1m')
    config.setdefault('dry_run', True)
    config.setdefault('validate_real_path', True)
    config['min_edge'] = float(config.get('min_edge', 0.07))
    config['min_confidence'] = float(config.get('min_confidence', 0.65))
    config['max_slippage_pct'] = float(config.get('max_slippage_pct', 0.1))
    config['cycle_interval_minutes'] = int(config.get('cycle_interval_minutes', 15))
    config['stop_loss_pct'] = float(config.get('stop_loss_pct', 0.1))
    config['take_profit_pct'] = float(config.get('take_profit_pct', 0.12))
    return config


def load_config(defaults_path: Path = DEFAULTS_PATH, live_params_path: Path = LIVE_PARAMS_PATH) -> dict:
    config = json.loads(defaults_path.read_text())
    learned = load_learned_params(live_params_path)
    config = merge_learned_params(config, learned)
    env_map = {
        'BTC_SPRINT_DRY_RUN': ('dry_run', lambda v: v == '1'),
        'BTC_SPRINT_VALIDATE_REAL_PATH': ('validate_real_path', lambda v: v == '1'),
        'LLM_PROVIDER': ('llm_provider', str),
        'LLM_MODEL': ('llm_model', str),
        'BINANCE_SYMBOL': ('binance_symbol', str),
        'BINANCE_INTERVAL': ('binance_interval', str),
        'BTC_SPRINT_PROFILE': ('execution_profile', lambda v: v.strip().lower()),
    }
    for env_key, (cfg_key, caster) in env_map.items():
        if env_key in os.environ and os.environ[env_key]:
            config[cfg_key] = caster(os.environ[env_key])
    config = _hard_clamp_config(config)
    profile = str(config.get('execution_profile', 'balanced')).strip().lower() or 'balanced'
    config['execution_profile'] = profile
    if profile == 'aggressive':
        config['min_edge'] = min(config['min_edge'], 0.05)
        config['min_confidence'] = min(config['min_confidence'], 0.60)
        config['cycle_interval_minutes'] = min(config['cycle_interval_minutes'], 10)
    return config


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description='BTC sprint bot for Simmer')
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument('--dry-run', action='store_true', help='Never submit a live trade')
    mode.add_argument('--live', action='store_true', help='Submit real trades when approved')
    parser.add_argument('--once', action='store_true', help='Run one cycle')
    parser.add_argument('--loop', action='store_true', help='Run continuously')
    parser.add_argument('--validate-real-path', action='store_true', help='Call prepare_real_trade during dry-run')
    parser.add_argument('--discord-test-alert', action='store_true', help='Send one Discord webhook test message and exit')
    return parser.parse_args()


def get_client(config: dict, dry_run: bool) -> SimmerClient:
    [REDACTED_SECRET]'SIMMER_API_KEY')
    if not api_key:
        raise SystemExit('SIMMER_API_KEY is required')
    return SimmerClient([REDACTED_SECRET], venue='polymarket', live=not dry_run)


def setup_external_wallet(client: SimmerClient) -> None:
    """One-time Polygon wallet linkage and USDC.e approval.

    Called on startup when WALLET_PRIVATE_KEY is present in the environment.
    Both calls are idempotent — safe to run each process start.
    Requires eth-account (pip install eth-account).
    """
    client.link_wallet()
    client.set_approvals()


def market_window_from_tags(tags: list[str]) -> str:
    if 'fast-5m' in tags:
        return '5m'
    if 'fast-15m' in tags:
        return '15m'
    return 'unknown'


def build_signal_payload(signal) -> dict:
    payload = signal.to_signal_data()
    payload['action'] = signal.action
    payload['reasoning'] = signal.reasoning
    return payload


def build_market_context(market, window: str, context: dict) -> dict:
    market_context = context.get('market', {}) if isinstance(context, dict) else {}
    slippage = context.get('slippage', {}) if isinstance(context, dict) else {}
    return {
        'market_id': getattr(market, 'id', None),
        'question': getattr(market, 'question', ''),
        'window': window,
        'asset': 'BTC',
        'venue': 'polymarket',
        'resolves_at': market_context.get('resolves_at'),
        'fee_rate_bps': market_context.get('fee_rate_bps'),
        'spread_pct': slippage.get('spread_pct'),
        'warnings': list(context.get('warnings') or []) if isinstance(context, dict) else [],
    }


def _combine_reject_reasons(*reasons: str | None) -> str | None:
    flattened = []
    for reason in reasons:
        if not reason:
            continue
        if isinstance(reason, str):
            flattened.extend(item.strip() for item in reason.split(';') if item.strip())
    if not flattened:
        return None
    deduped = list(dict.fromkeys(flattened))
    return '; '.join(deduped)


def _market_debug_label(market) -> str:
    return f"{getattr(market, 'id', None)}|{getattr(market, 'question', '')}"


def _is_btc_market(market) -> bool:
    bits = [
        getattr(market, 'asset', None),
        getattr(market, 'market_source', None),
        getattr(market, 'id', None),
        getattr(market, 'question', None),
        ' '.join(getattr(market, 'tags', []) or []),
    ]
    joined = ' '.join(str(bit).lower() for bit in bits if bit)
    return 'btc' in joined or 'bitcoin' in joined


def _synthetic_btc_market() -> SimpleNamespace:
    return SimpleNamespace(
        id='btc-fast-synthetic-debug',
        question='Synthetic BTC debug candidate for dry-run tracing',
        tags=['fast-5m'],
        asset='BTC',
    )


def _synthetic_market_context() -> dict:
    return {
        'market': {
            'resolves_at': (datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=90)).isoformat(),
            'fee_rate_bps': 0,
            'external_price': 0.66,
            'current_probability': 0.58,
            'divergence': 0.08,
        },
        'slippage': {'spread_pct': 0.01},
        'warnings': ['synthetic_debug'],
    }


def _synthetic_signal(window: str, min_edge: float, min_confidence: float) -> SignalDecision:
    edge = max(min_edge + 0.03, 0.10)
    confidence = max(min_confidence + 0.05, 0.72)
    return SignalDecision(
        action='yes',
        edge=edge,
        confidence=confidence,
        signal_source='synthetic_debug',
        reasoning=f'Synthetic BTC candidate injected for dry-run tracing ({window}).',
        metrics={
            'window': window,
            'synthetic': True,
            'computed_at': datetime.now(timezone.utc).isoformat(),
        },
    )


def _candidate_priority(candidate: dict) -> tuple:
    regime = candidate.get('regime') or {}
    risk_state = candidate.get('risk_state') or {}
    signal = candidate.get('signal')
    return (
        1 if regime.get('approved') else 0,
        1 if risk_state.get('allowed') else 0,
        float(getattr(signal, 'edge', 0.0) or 0.0),
        float(getattr(signal, 'confidence', 0.0) or 0.0),
        -float(regime.get('minutes_to_resolution') or 0.0),
    )


def run_cycle(config: dict, *, dry_run: bool, validate_real_path: bool) -> dict:
    client = get_client(config, dry_run=dry_run)

    # Redeem any winning positions before the next trade cycle.
    # Called unconditionally in live mode — works for both managed and external
    # wallets. The briefing's redeem_action field prompts this whenever positions
    # are ready.
    if not dry_run:
        try:
            client.auto_redeem()
        except Exception as exc:
            trace('auto_redeem_error', exc)

    settings = client.get_settings()
    positions = client.get_positions(venue='polymarket')
    journal_rows = read_journal(JOURNAL_PATH)
    live_params = read_json_file(LIVE_PARAMS_PATH, {})
    pending_rules = read_json_file(PENDING_RULES_PATH, {'rules': []})

    try:
        llm_provider = build_provider_from_env()
        llm_blocker = None
    except MissingLLMCredentialsError:
        llm_provider = None
        llm_blocker = LLM_BLOCKER

    fast_markets = client.get_fast_markets(asset='BTC', limit=20)
    trace('markets_fetched=', len(fast_markets))
    btc_markets = []
    for market in fast_markets:
        if _is_btc_market(market):
            btc_markets.append(market)
        else:
            trace('btc_filter_drop', _market_debug_label(market), 'reason=non_btc_market')
    trace('btc_filter_result=', {'kept': len(btc_markets), 'dropped': len(fast_markets) - len(btc_markets)})
    decisions: list[dict] = []
    llm_records: list[dict] = []
    latest_risk_state = {}
    llm_status_counts = {'validated': 0, 'blocked': 0, 'rejected': 0}

    pre_learning_snapshot = build_learning_snapshot(
        journal_rows,
        config,
        live_params=live_params,
        pending_rules=pending_rules,
    )

    llm_candidates_sent = 0

    def process_candidate(market, *, window: str, context: dict, signal, source: str) -> None:
        nonlocal latest_risk_state
        regime = evaluate_regime(context, signal, config)
        trace(
            'risk_engine',
            _market_debug_label(market),
            {
                'approved': regime['approved'],
                'reasons': regime['reasons'],
                'spread_pct': regime.get('spread_pct'),
                'fee_rate': regime.get('fee_rate'),
            },
        )
        risk_state = enforce_risk_limits(
            settings,
            positions,
            config,
            config['skill_slug'],
            journal_rows,
            execution_mode='dry_run' if dry_run else 'live',
            regime=regime,
        )
        latest_risk_state = risk_state
        trace('risk_limits', _market_debug_label(market), risk_state)

        signal_payload = build_signal_payload(signal)
        market_context = build_market_context(market, window, context)
        trace('llm_candidate', _market_debug_label(market), {'source': source, 'window': window, 'signal': signal_payload})
        nonlocal llm_candidates_sent
        llm_candidates_sent += 1

        validated_decision = None
        raw_model_output = None
        llm_reject_reason = None
        try:
            validated_decision, raw_model_output, llm_reject_reason = run_llm_decision(
                provider=llm_provider,
                market_context=market_context,
                signal_data=signal_payload,
                regime=regime,
                risk_state=risk_state,
                live_params=live_params,
                pending_rules=pending_rules,
                learning_snapshot=pre_learning_snapshot,
            )
        except Exception as exc:
            llm_reject_reason = str(exc)

        trace('llm_raw_output', _market_debug_label(market), raw_model_output)
        trace(
            'llm_validation',
            _market_debug_label(market),
            {
                'accepted': validated_decision is not None,
                'decision': validated_decision,
                'reject_reason': llm_reject_reason,
            },
        )

        if llm_reject_reason == LLM_BLOCKER:
            llm_status = 'blocked'
        elif llm_reject_reason:
            llm_status = 'rejected'
        else:
            llm_status = 'validated'
        llm_status_counts[llm_status] = llm_status_counts.get(llm_status, 0) + 1

        if validated_decision and validated_decision.get('rule_suggestion'):
            record_pending_rule(
                path=PENDING_RULES_PATH,
                suggestion=validated_decision['rule_suggestion'],
                outcome=None,
            )

        gate_reject_reason = None
        should_execute = bool(
            validated_decision
            and validated_decision.get('action') in {'yes', 'no'}
            and regime['approved']
            and risk_state['allowed']
            and not llm_reject_reason
        )

        if not should_execute:
            gate_reject_reason = _combine_reject_reasons(
                llm_reject_reason,
                '; '.join(regime['reasons']) if regime.get('reasons') else None,
                '; '.join(risk_state['reasons']) if risk_state.get('reasons') else None,
                'llm_skip' if validated_decision and validated_decision.get('action') == 'skip' else None,
            )
            trace('skip_reason', _market_debug_label(market), gate_reject_reason)

        row = {
            'ts': datetime.now(timezone.utc).isoformat(),
            'market_id': market.id,
            'question': getattr(market, 'question', ''),
            'window': window,
            'asset': 'BTC',
            'venue': 'polymarket',
            'decision': 'skipped',
            'signal_action': signal.action,
            'signal_data': signal.to_signal_data(),
            'regime': regime,
            'risk_state': risk_state,
            'llm_status': llm_status,
            'llm_provider': getattr(llm_provider, 'provider_name', None),
            'llm_model': getattr(llm_provider, 'model_name', None),
            'validated_llm_decision': validated_decision,
            'llm_reject_reason': llm_reject_reason,
            'reject_reason': gate_reject_reason,
            'result_type': 'skip',
        }

        outcome = None
        if should_execute:
            execution = execute_trade(
                client,
                market_id=market.id,
                side=validated_decision['action'],
                amount=risk_state['trade_amount_usd'],
                signal=signal,
                regime=regime,
                live=not dry_run,
                source='btc_sprint_stack.llm',
                skill_slug=config['skill_slug'],
                venue='polymarket',
                validate_real_path=validate_real_path,
                llm_decision=validated_decision,
            )
            row['decision'] = 'candidate'
            row.update(execution)
            outcome = execution.get('outcome')
            row['reject_reason'] = None
            row['execution_status'] = 'accepted'
        else:
            if llm_status == 'blocked':
                row['execution_status'] = 'blocked'
            elif validated_decision and validated_decision.get('action') == 'skip':
                row['execution_status'] = 'skipped'
            else:
                row['execution_status'] = 'rejected'

        append_journal(JOURNAL_PATH, row)
        decisions.append(row)

        llm_record = build_decision_record(
            market_id=market.id,
            window=window,
            signal_data=signal.to_signal_data(),
            regime=regime,
            risk_state=risk_state,
            provider=llm_provider,
            raw_model_output=raw_model_output,
            raw_model_payload=None,
            validated_decision=validated_decision,
            reject_reason=gate_reject_reason,
            execution_status=row.get('execution_status', 'accepted' if should_execute else 'blocked'),
            outcome=outcome,
        )
        llm_records.append(llm_record)

    candidate_queue: list[dict] = []

    for market in btc_markets:
        tags = getattr(market, 'tags', []) or []
        window = market_window_from_tags(tags)
        if window == 'unknown':
            trace('window_fallback', _market_debug_label(market), 'unknown->5m')
            window = '5m'
        if window not in config['windows']:
            trace('skip', _market_debug_label(market), f'reason=window_filter:{window}')
            continue

        context = client.get_market_context(market.id)
        signal = build_signal(
            window=window,
            context=context,
            symbol=config['binance_symbol'],
            interval=config['binance_interval'],
            min_edge=config['min_edge'],
        )
        regime = evaluate_regime(context, signal, config)
        risk_state = enforce_risk_limits(
            settings,
            positions,
            config,
            config['skill_slug'],
            journal_rows,
            execution_mode='dry_run' if dry_run else 'live',
            regime=regime,
        )
        candidate_queue.append(
            {
                'market': market,
                'window': window,
                'context': context,
                'signal': signal,
                'regime': regime,
                'risk_state': risk_state,
                'source': 'live',
            }
        )

    if not candidate_queue:
        synthetic_market = _synthetic_btc_market()
        synthetic_window = '5m'
        synthetic_context = _synthetic_market_context()
        synthetic_signal = _synthetic_signal(synthetic_window, config['min_edge'], config['min_confidence'])
        trace('synthetic_fallback', _market_debug_label(synthetic_market), {'reason': 'no real decisions'})
        synthetic_regime = evaluate_regime(synthetic_context, synthetic_signal, config)
        synthetic_risk_state = enforce_risk_limits(
            settings,
            positions,
            config,
            config['skill_slug'],
            journal_rows,
            execution_mode='dry_run' if dry_run else 'live',
            regime=synthetic_regime,
        )
        candidate_queue.append(
            {
                'market': synthetic_market,
                'window': synthetic_window,
                'context': synthetic_context,
                'signal': synthetic_signal,
                'regime': synthetic_regime,
                'risk_state': synthetic_risk_state,
                'source': 'synthetic_debug',
            }
        )

    selected_candidate = max(candidate_queue, key=_candidate_priority)
    trace('selected_candidate', _market_debug_label(selected_candidate['market']), {
        'source': selected_candidate['source'],
        'window': selected_candidate['window'],
        'edge': getattr(selected_candidate['signal'], 'edge', None),
        'confidence': getattr(selected_candidate['signal'], 'confidence', None),
    })
    process_candidate(
        selected_candidate['market'],
        window=selected_candidate['window'],
        context=selected_candidate['context'],
        signal=selected_candidate['signal'],
        source=selected_candidate['source'],
    )

    trace('llm_sent_count=', llm_candidates_sent)

    for record in llm_records:
        # Keep a separate audit trail for the model outputs.
        append_jsonl(LLM_DECISIONS_PATH, record)

    learning_snapshot = build_learning_snapshot(
        journal_rows + decisions,
        config,
        live_params=live_params,
        pending_rules=pending_rules,
    )
    live_params, pending_rules = apply_eligible_rules(
        live_params_path=LIVE_PARAMS_PATH,
        pending_rules_path=PENDING_RULES_PATH,
        learning_snapshot=learning_snapshot,
        defaults=json.loads(DEFAULTS_PATH.read_text()),
    )
    learning_snapshot = build_learning_snapshot(
        journal_rows + decisions,
        config,
        live_params=live_params,
        pending_rules=pending_rules,
    )
    write_json_file(LEARNING_PATH, learning_snapshot)

    heartbeat = build_heartbeat(client, decisions, latest_risk_state, learning_snapshot)
    heartbeat['llm'] = {
        'provider': getattr(llm_provider, 'provider_name', None),
        'model': getattr(llm_provider, 'model_name', None),
        'blocker': llm_blocker,
        'status_counts': llm_status_counts,
        'pending_rules': len(pending_rules.get('rules') or []) if isinstance(pending_rules, dict) else 0,
    }

    try:
        briefing = heartbeat.get('briefing') or {}
        opp = briefing.get('opportunities') or {}
        new_markets = opp.get('new_markets') or []
        opp['new_markets'] = [
            m for m in new_markets
            if (m.get('market_source') == config['trading_venue']) and ('bitcoin' in (m.get('question') or '').lower())
        ]
        rec = opp.get('recommended_skills') or []
        opp['recommended_skills'] = [r for r in rec if r.get('id') in {'simmer', 'polymarket-fast-loop', config['skill_slug']}]
        briefing['opportunities'] = opp
        heartbeat['briefing'] = briefing
    except Exception:
        pass

    output = {
        'ts': datetime.now(timezone.utc).isoformat(),
        'dry_run': dry_run,
        'validate_real_path': validate_real_path,
        'execution_profile': config.get('execution_profile', 'balanced'),
        'llm_provider': getattr(llm_provider, 'provider_name', None),
        'llm_model': getattr(llm_provider, 'model_name', None),
        'llm_blocker': llm_blocker,
        'decisions': decisions,
        'live_params': live_params,
        'pending_rules': pending_rules,
        'learning_snapshot': learning_snapshot,
        'heartbeat': heartbeat,
    }
    print(json.dumps(output, indent=2, default=str))
    return output


def main() -> None:
    args = parse_args()

    if args.discord_test_alert:
        try:
            result = send_discord_alert('BTC sprint bot Discord test alert')
        except DiscordAlertError as exc:
            raise SystemExit(str(exc))
        print(json.dumps({'discord_test_alert': result}, indent=2, default=str))
        return

    config = load_config()
    dry_run = True
    if args.live:
        dry_run = False
    elif args.dry_run:
        dry_run = True
    elif 'dry_run' in config:
        dry_run = bool(config['dry_run'])

    # One-time external wallet setup (link + approvals) when a private key is present.
    # Skipped in dry-run mode — no on-chain calls needed for simulation.
    if not dry_run and os.environ.get('WALLET_PRIVATE_KEY'):
        _client = get_client(config, dry_run=dry_run)
        setup_external_wallet(_client)

    validate_real_path = args.validate_real_path or bool(config.get('validate_real_path'))
    if args.loop:
        while True:
            run_cycle(config, dry_run=dry_run, validate_real_path=validate_real_path)
            time.sleep(config['cycle_interval_minutes'] * 60)
    else:
        run_cycle(config, dry_run=dry_run, validate_real_path=validate_real_path)


if __name__ == '__main__':
    main()


path: modules/btc_discord_alert.py
content: from __future__ import annotations

import json
import os
from typing import Mapping
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen


class DiscordAlertError(RuntimeError):
    pass


def load_discord_webhook_url(env: Mapping[str, str] | None = None) -> str:
    values = env if env is not None else os.environ
    webhook_url = str(values.get('DISCORD_WEBHOOK_URL') or '').strip()
    if not webhook_url:
        raise DiscordAlertError('DISCORD_WEBHOOK_URL is required for Discord alerts')
    if not webhook_url.startswith('https://discord.com/api/webhooks/') and not webhook_url.startswith('https://discordapp.com/api/webhooks/'):
        raise DiscordAlertError('DISCORD_WEBHOOK_URL must be a Discord webhook URL')
    return webhook_url


def _with_wait_param(webhook_url: str) -> str:
    separator = '&' if '?' in webhook_url else '?'
    if 'wait=' in webhook_url:
        return webhook_url
    return f'{webhook_url}{separator}wait=true'


def send_discord_alert(message: str, *, env: Mapping[str, str] | None = None, timeout: float = 15.0) -> dict:
    webhook_url = load_discord_webhook_url(env)
    body = json.dumps(
        {
            'content': message,
            'allowed_mentions': {'parse': []},
        }
    ).encode('utf-8')
    request = Request(
        _with_wait_param(webhook_url),
        data=body,
        headers={'Content-Type': 'application/json'},
        method='POST',
    )
    try:
        with urlopen(request, timeout=timeout) as response:
            status_code = getattr(response, 'status', response.getcode())
            raw_body = response.read().decode('utf-8').strip()
    except HTTPError as exc:
        raise DiscordAlertError(f'Discord webhook request failed with HTTP {exc.code}') from exc
    except URLError as exc:
        raise DiscordAlertError(f'Discord webhook request failed: {exc.reason}') from exc

    parsed_body = None
    if raw_body:
        try:
            parsed_body = json.loads(raw_body)
        except json.JSONDecodeError:
            parsed_body = raw_body

    return {
        'ok': 200 <= status_code < 300,
        'status_code': status_code,
        'response': parsed_body,
    }


path: modules/btc_heartbeat.py
content: from __future__ import annotations


def build_heartbeat(client, decisions: list[dict], risk_state: dict, learning_snapshot: dict) -> dict:
    briefing = None
    warning = None
    try:
        briefing = client.get_briefing()
    except Exception as exc:
        warning = {
            'code': 'briefing_unavailable',
            'message': str(exc),
            'type': exc.__class__.__name__,
        }
    return {
        'briefing': briefing,
        'warning': warning,
        'decision_count': len(decisions),
        'accepted_candidates': sum(1 for row in decisions if row.get('decision') == 'candidate'),
        'risk_state': risk_state,
        'learning_snapshot': learning_snapshot,
    }


path: modules/btc_llm_decider.py
content: from __future__ import annotations

import json
import os
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping, Protocol
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen


ALLOWED_ACTIONS = {'yes', 'no', 'skip'}
LEARNABLE_KEYS = {
    'min_edge',
    'min_confidence',
    'max_slippage_pct',
    'cycle_interval_minutes',
    'stop_loss_pct',
    'take_profit_pct',
}
DEFAULT_OPENAI_MODEL = 'gpt-5-mini'
DEFAULT_OPENROUTER_MODEL = 'google/gemini-2.5-pro'
DEFAULT_GOOGLE_MODEL = 'gemini-2.5-flash'
DEFAULT_DEEPSEEK_MODEL = 'deepseek-chat'
DEFAULT_CODEX_MODEL = 'codex-mini-latest'
DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'
DEFAULT_GOOGLE_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai/'
DEFAULT_DEEPSEEK_BASE_URL = 'https://api.deepseek.com'
CODEX_AUTH_PATH = Path.home() / '.codex' / 'auth.json'
MAX_MODEL_OUTPUT_TOKENS = 200
LLM_429_BACKOFF_SECONDS = (10, 30, 60)
MAX_PROMPT_LIST_ITEMS = 1
LLM_BLOCKER = 'missing LLM provider credentials'
STRICT_SCHEMA = {
    'type': 'object',
    'additionalProperties': False,
    'properties': {
        'asset': {'type': 'string', 'enum': ['BTC']},
        'action': {'type': 'string', 'enum': ['yes', 'no', 'skip']},
        'confidence': {'type': 'number', 'minimum': 0.0, 'maximum': 1.0},
        'edge': {'type': 'number', 'minimum': 0.0, 'maximum': 1.0},
        'reasoning': {'type': 'string'},
        'rule_suggestion': {
            'type': 'object',
            'additionalProperties': False,
            'properties': {
                'key': {'type': 'string'},
                'value': {'type': 'string'},
                'why': {'type': 'string'},
            },
            'required': ['key', 'value', 'why'],
        },
    },
    'required': ['asset', 'action', 'confidence', 'edge', 'reasoning', 'rule_suggestion'],
}


class LLMError(RuntimeError):
    pass


class MissingLLMCredentialsError(LLMError):
    pass


class InvalidLLMOutputError(LLMError):
    pass


class ProviderRequestError(LLMError):
    pass


LLMProviderError = LLMError


class LLMProvider(Protocol):
    provider_name: str
    model_name: str

    def complete(self, *, system_prompt: str, user_prompt: str) -> str:
        raise NotImplementedError


@dataclass(frozen=True)
class LLMDecision:
    asset: str
    action: str
    confidence: float
    edge: float
    reasoning: str
    rule_suggestion: dict[str, str] | None = None

    def to_dict(self) -> dict[str, Any]:
        data = {
            'asset': self.asset,
            'action': self.action,
            'confidence': float(self.confidence),
            'edge': float(self.edge),
            'reasoning': self.reasoning,
        }
        if self.rule_suggestion is not None:
            data['rule_suggestion'] = self.rule_suggestion
        return data


@dataclass(frozen=True)
class LLMDecisionResult:
    ts: str
    provider: str | None
    model: str | None
    market_id: str
    window: str
    btc_only: bool
    raw_model_output: str | None
    raw_model_payload: dict[str, Any] | None
    validated_decision: dict[str, Any] | None
    reject_reason: str | None
    execution_status: str
    outcome: str | None
    signal_data: dict[str, Any]
    regime: dict[str, Any]
    risk_state: dict[str, Any]

    def to_record(self) -> dict[str, Any]:
        return {
            'ts': self.ts,
            'provider': self.provider,
            'model': self.model,
            'market_id': self.market_id,
            'window': self.window,
            'btc_only': self.btc_only,
            'raw_model_output': self.raw_model_output,
            'raw_model_payload': self.raw_model_payload,
            'validated_decision': self.validated_decision,
            'reject_reason': self.reject_reason,
            'execution_status': self.execution_status,
            'outcome': self.outcome,
            'signal_data': self.signal_data,
            'regime': self.regime,
            'risk_state': self.risk_state,
        }


def _request_model_name(provider_name: str, model_name: str) -> str:
    normalized = model_name.strip()
    if provider_name == 'google' and normalized.startswith('google/'):
        return normalized.removeprefix('google/')
    return normalized


class OpenAIResponsesProvider:
    def __init__(self, *, provider_name: str, api_key: str, model_name: str, base_url: str | None = None) -> None:
        self.provider_name = provider_name
        self.model_name = model_name
        self.[REDACTED_SECRET]
        self.base_url = (base_url or DEFAULT_OPENAI_BASE_URL).rstrip('/')

    def complete(self, *, system_prompt: str, user_prompt: str) -> str:
        body = {
            'model': _request_model_name(self.provider_name, self.model_name),
            'max_tokens': MAX_MODEL_OUTPUT_TOKENS,
            'messages': [
                {
                    'role': 'system',
                    'content': system_prompt,
                },
                {
                    'role': 'user',
                    'content': user_prompt,
                },
            ],
            'response_format': {
                'type': 'json_schema',
                'json_schema': {
                    'name': 'btc_trade_decision',
                    'schema': STRICT_SCHEMA,
                    'strict': True,
                },
            },
        }
        for attempt_index, backoff_seconds in enumerate((0, *LLM_429_BACKOFF_SECONDS)):
            if backoff_seconds:
                time.sleep(backoff_seconds)
            try:
                raw = _post_json(
                    f'{self.base_url}/chat/completions',
                    body=body,
                    headers={
                        'Authorization': f'Bearer {self.api_key}',
                        'Content-Type': 'application/json',
                    },
                )
            except ProviderRequestError as exc:
                if 'provider request failed: 429' in str(exc) and attempt_index < len(LLM_429_BACKOFF_SECONDS):
                    continue
                raise
            for choice in raw.get('choices') or []:
                if not isinstance(choice, dict):
                    continue
                message = choice.get('message')
                if not isinstance(message, dict):
                    continue
                content = message.get('content')
                if isinstance(content, str) and content.strip():
                    return content
                if isinstance(content, list):
                    parts: list[str] = []
                    for block in content:
                        if not isinstance(block, dict):
                            continue
                        text_value = block.get('text')
                        if isinstance(text_value, str) and text_value.strip():
                            parts.append(text_value)
                    if parts:
                        return ''.join(parts)
        raise ProviderRequestError('provider returned non-text content')


class GCloudOAuthProvider:
    """LLM provider that authenticates with Google Cloud Application Default Credentials.

    Obtains a fresh OAuth2 access token via ``google-auth`` on every call so
    tokens are never stale.  Set up credentials on the host with::

        gcloud auth application-default login

    Then set ``LLM_PROVIDER=google_oauth`` (and optionally ``LLM_MODEL``) in
    the environment — no API key required.
    """

    provider_name = 'google_oauth'

    def __init__(self, *, model_name: str, base_url: str | None = None) -> None:
        self.model_name = model_name
        self.base_url = (base_url or DEFAULT_GOOGLE_BASE_URL).rstrip('/')

    def _fresh_token(self) -> str:
        try:
            import google.auth
            import google.auth.transport.requests
        except ImportError as exc:
            raise LLMError(
                'google-auth is required for google_oauth provider: pip install google-auth requests'
            ) from exc
        credentials, _ = google.auth.default(
            scopes=['https://www.googleapis.com/auth/cloud-platform']
        )
        credentials.refresh(google.auth.transport.requests.Request())
        return credentials.token

    def complete(self, *, system_prompt: str, user_prompt: str) -> str:
        [REDACTED_SECRET])
        delegate = OpenAIResponsesProvider(
            provider_name='google',
            api_key=token,
            model_name=self.model_name,
            base_url=self.base_url,
        )
        return delegate.complete(system_prompt=system_prompt, user_prompt=user_prompt)


class CodexOAuthProvider:
    """LLM provider that uses the OpenAI Codex CLI OAuth token.

    Token resolution order:
    1. ``CODEX_OAUTH_TOKEN`` environment variable
    2. ``~/.codex/auth.json`` (written by ``codex login`` on the host)

    Set ``LLM_PROVIDER=codex`` — no separate API key needed.
    Model defaults to ``codex-mini-latest``; override with ``LLM_MODEL``.
    """

    provider_name = 'codex'

    def __init__(self, *, model_name: str, base_url: str | None = None) -> None:
        self.model_name = model_name
        self.base_url = (base_url or DEFAULT_OPENAI_BASE_URL).rstrip('/')

    @staticmethod
    def _resolve_token() -> str:
        [REDACTED_SECRET]'CODEX_OAUTH_TOKEN') or '').strip()
        if [REDACTED_SECRET] token
        if CODEX_AUTH_PATH.exists():
            try:
                data = json.loads(CODEX_AUTH_PATH.read_text())
                [REDACTED_SECRET]'token') or data.get('access_token') or '').strip()
                if [REDACTED_SECRET] token
            except Exception:
                pass
        raise MissingLLMCredentialsError(
            'codex provider: set CODEX_OAUTH_TOKEN or run `codex login` on the host'
        )

    def complete(self, *, system_prompt: str, user_prompt: str) -> str:
        [REDACTED_SECRET])
        delegate = OpenAIResponsesProvider(
            provider_name='openai',
            api_key=token,
            model_name=self.model_name,
            base_url=self.base_url,
        )
        return delegate.complete(system_prompt=system_prompt, user_prompt=user_prompt)


class StubOpenAIResponsesProvider:
    def __init__(self, *, provider_name: str, model_name: str, stub_response: str) -> None:
        self.provider_name = provider_name
        self.model_name = model_name
        self.stub_response = stub_response

    def complete(self, *, system_prompt: str, user_prompt: str) -> str:
        return self.stub_response


def current_ts() -> str:
    return datetime.now(timezone.utc).isoformat()


def read_json_file(path: Path, default: Any) -> Any:
    if not path.exists():
        return default
    try:
        return json.loads(path.read_text())
    except Exception:
        return default


def write_json_file(path: Path, payload: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2, sort_keys=True))


def append_jsonl(path: Path, row: Mapping[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open('a', encoding='utf-8') as fh:
        fh.write(json.dumps(row, sort_keys=True, default=str) + '\n')


def load_learned_params(path: Path) -> dict[str, Any]:
    payload = read_json_file(path, {})
    if not isinstance(payload, dict):
        return {}
    learned: dict[str, Any] = {}
    for key, value in payload.items():
        if key in LEARNABLE_KEYS and value is not None:
            learned[key] = value
    return learned


def load_live_params(path: Path) -> dict[str, Any]:
    return load_learned_params(path)


def load_pending_rules(path: Path) -> dict[str, Any]:
    payload = read_json_file(path, {'rules': []})
    if not isinstance(payload, dict):
        return {'rules': []}
    rules = payload.get('rules')
    if not isinstance(rules, list):
        payload['rules'] = []
    if '_meta' not in payload or not isinstance(payload.get('_meta'), dict):
        payload['_meta'] = {}
    return payload


def merge_learned_params(config: dict[str, Any], learned_params: Mapping[str, Any]) -> dict[str, Any]:
    merged = dict(config)
    for key in LEARNABLE_KEYS:
        if key in learned_params and learned_params[key] is not None:
            merged[key] = learned_params[key]
    return merged


def build_llm_context(
    *,
    config: Mapping[str, Any],
    market: Any,
    window: str,
    context: Mapping[str, Any],
    signal: Any,
    regime: Mapping[str, Any],
    settings: Mapping[str, Any],
    positions: list[Any],
) -> dict[str, Any]:
    return {
        'asset': config.get('asset', 'BTC'),
        'market_id': getattr(market, 'id', None),
        'market_question': _compact_text(getattr(market, 'question', ''), 120),
        'window': window,
        'signal': {
            key: value
            for key, value in (
                ('edge', getattr(signal, 'edge', None)),
                ('confidence', getattr(signal, 'confidence', None)),
                ('signal_source', getattr(signal, 'signal_source', None)),
                ('window', window),
            )
            if value is not None
        },
        'signal_action': getattr(signal, 'action', None),
        'regime': {
            'approved': regime.get('approved'),
            'reasons': _compact_list(regime.get('reasons'), 2),
            'warnings': _compact_list(regime.get('warnings'), 1),
        },
        'market_context': _summarize_market_context(context),
        'risk_limits': {
            key: config.get(key)
            for key in (
                'bankroll_usd',
                'max_trade_usd',
                'max_daily_loss_usd',
                'max_open_positions',
                'max_single_market_exposure_usd',
                'max_trades_per_day',
                'max_slippage_pct',
                'stop_loss_pct',
                'take_profit_pct',
                'cooldown_after_loss_minutes',
            )
        },
        'account_state': {
            'positions_count': len(positions),
            'trading_venue': config.get('trading_venue'),
        },
        'execution_profile': config.get('execution_profile', 'balanced'),
        'live_params': {
            key: value
            for key, value in load_live_params(Path(config['live_params_path'])).items()
            if key in {'min_edge', 'min_confidence', 'max_slippage_pct', 'cycle_interval_minutes'}
        } if config.get('live_params_path') else {},
    }


def build_provider_from_env(env: Mapping[str, str] | None = None) -> LLMProvider:
    env = env or os.environ
    provider_name = (env.get('LLM_PROVIDER') or '').strip().lower()
    if not provider_name:
        if (env.get('GOOGLE_API_KEY') or '').strip() or (env.get('GEMINI_API_KEY') or '').strip():
            provider_name = 'google'
        elif (env.get('LLM_API_KEY') or '').strip() or (env.get('OPENAI_API_KEY') or '').strip() or (env.get('OPENROUTER_API_KEY') or '').strip():
            provider_name = 'openai'
        elif (env.get('DEEPSEEK_API_KEY') or '').strip():
            provider_name = 'deepseek'
        else:
            provider_name = 'openai'

    if provider_name not in {'openai', 'openrouter', 'deepseek', 'google', 'google_oauth', 'codex'}:
        raise MissingLLMCredentialsError(f'unsupported LLM provider: {provider_name}')

    # codex resolves its token from CODEX_OAUTH_TOKEN or ~/.codex/auth.json — return early.
    if provider_name == 'codex':
        model_name = (
            env.get('LLM_MODEL')
            or env.get('CODEX_MODEL')
            or DEFAULT_CODEX_MODEL
        ).strip()
        base_url = (env.get('LLM_BASE_URL') or env.get('OPENAI_BASE_URL') or DEFAULT_OPENAI_BASE_URL).strip() or None
        return CodexOAuthProvider(model_name=model_name, base_url=base_url)

    # google_oauth uses ADC — no API key needed, return early.
    if provider_name == 'google_oauth':
        model_name = (
            env.get('LLM_MODEL')
            or env.get('GOOGLE_MODEL')
            or env.get('GEMINI_MODEL')
            or DEFAULT_GOOGLE_MODEL
        ).strip()
        base_url = (
            env.get('LLM_BASE_URL')
            or env.get('GOOGLE_BASE_URL')
            or env.get('GEMINI_BASE_URL')
            or DEFAULT_GOOGLE_BASE_URL
        ).strip() or None
        return GCloudOAuthProvider(model_name=model_name, base_url=base_url)

    if provider_name == 'google':
        api_key = (
            (env.get('LLM_API_KEY') or '').strip()
            or (env.get('GOOGLE_API_KEY') or '').strip()
            or (env.get('GEMINI_API_KEY') or '').strip()
        )
    elif provider_name == 'deepseek':
        api_key = (
            (env.get('LLM_API_KEY') or '').strip()
            or (env.get('DEEPSEEK_API_KEY') or '').strip()
            or (env.get('OPENAI_API_KEY') or '').strip()
            or (env.get('OPENROUTER_API_KEY') or '').strip()
        )
    else:
        api_key = (
            (env.get('LLM_API_KEY') or '').strip()
            or (env.get('OPENAI_API_KEY') or '').strip()
            or (env.get('OPENROUTER_API_KEY') or '').strip()
        )
    if not api_key:
        raise MissingLLMCredentialsError(LLM_BLOCKER)

    if provider_name == 'deepseek':
        default_model = DEFAULT_DEEPSEEK_MODEL
    elif provider_name == 'google':
        default_model = DEFAULT_GOOGLE_MODEL
    elif provider_name == 'openrouter':
        default_model = DEFAULT_OPENROUTER_MODEL
    else:
        default_model = DEFAULT_OPENAI_MODEL
    if provider_name == 'google':
        model_name = (
            env.get('LLM_MODEL')
            or env.get('GOOGLE_MODEL')
            or env.get('GEMINI_MODEL')
            or default_model
        ).strip()
    elif provider_name == 'openrouter':
        model_name = (
            env.get('LLM_MODEL')
            or env.get('OPENROUTER_MODEL')
            or env.get('OPENAI_MODEL')
            or default_model
        ).strip()
    elif provider_name == 'deepseek':
        model_name = (
            env.get('LLM_MODEL')
            or env.get('DEEPSEEK_MODEL')
            or default_model
        ).strip()
    else:
        model_name = (
            env.get('LLM_MODEL')
            or env.get('OPENAI_MODEL')
            or default_model
        ).strip()
    if not model_name:
        raise MissingLLMCredentialsError(LLM_BLOCKER)

    stub_response = (env.get('OPENAI_STUB_RESPONSE') or '').strip()
    if stub_response:
        return StubOpenAIResponsesProvider(
            provider_name=provider_name,
            model_name=model_name,
            stub_response=stub_response,
        )

    if provider_name == 'deepseek':
        default_base_url = DEFAULT_DEEPSEEK_BASE_URL
    elif provider_name == 'google':
        default_base_url = DEFAULT_GOOGLE_BASE_URL
    elif provider_name == 'openrouter':
        default_base_url = DEFAULT_OPENROUTER_BASE_URL
    else:
        default_base_url = DEFAULT_OPENAI_BASE_URL
    if provider_name == 'google':
        base_url = (
            env.get('LLM_BASE_URL')
            or env.get('GOOGLE_BASE_URL')
            or env.get('GEMINI_BASE_URL')
            or default_base_url
        ).strip() or None
    elif provider_name == 'openrouter':
        base_url = (
            env.get('LLM_BASE_URL')
            or env.get('OPENROUTER_BASE_URL')
            or env.get('OPENAI_BASE_URL')
            or default_base_url
        ).strip() or None
    elif provider_name == 'deepseek':
        base_url = (
            env.get('LLM_BASE_URL')
            or env.get('DEEPSEEK_BASE_URL')
            or env.get('OPENAI_BASE_URL')
            or default_base_url
        ).strip() or None
    else:
        base_url = (
            env.get('LLM_BASE_URL')
            or env.get('OPENAI_BASE_URL')
            or default_base_url
        ).strip() or None
    return OpenAIResponsesProvider(
        provider_name=provider_name,
        [REDACTED_SECRET],
        model_name=model_name,
        base_url=base_url,
    )


def build_system_prompt() -> str:
    schema_json = json.dumps(STRICT_SCHEMA, separators=(',', ':'), sort_keys=True)
    return (
        'Return one BTC-only JSON object that validates this schema:\n'
        f'{schema_json}\n'
        'Use yes, no, or skip. Keep reasoning concise. Return skip if weak or unclear.'
    )


def _compact_text(value: Any, limit: int = 160) -> Any:
    if isinstance(value, str):
        text = value.strip()
        if len(text) <= limit:
            return text
        return text[: max(0, limit - 1)].rstrip() + '…'
    return value


def _compact_list(values: Any, limit: int = MAX_PROMPT_LIST_ITEMS) -> list[Any]:
    if not isinstance(values, list):
        return []
    return [_compact_text(item) for item in values[:limit]]


def _compact_rule_list(pending_rules: Mapping[str, Any]) -> dict[str, Any]:
    rules = pending_rules.get('rules') if isinstance(pending_rules, Mapping) else []
    compact_rules: list[dict[str, Any]] = []
    if isinstance(rules, list):
        for rule in rules[-MAX_PROMPT_LIST_ITEMS:]:
            if not isinstance(rule, Mapping):
                continue
            compact_rule = {
                'key': rule.get('key'),
                'value': _compact_text(rule.get('value')),
                'status': rule.get('status'),
                'count': rule.get('count'),
            }
            compact_rules.append({k: v for k, v in compact_rule.items() if v is not None})
    return {
        'count': len(rules) if isinstance(rules, list) else 0,
        'recent': compact_rules,
    }


def _summarize_market_context(market_context: Mapping[str, Any]) -> dict[str, Any]:
    return {
        'market_id': market_context.get('market_id'),
        'question': _compact_text(market_context.get('question'), 120),
        'window': market_context.get('window'),
        'venue': market_context.get('venue'),
        'fee_rate_bps': market_context.get('fee_rate_bps'),
        'spread_pct': market_context.get('spread_pct'),
    }


def _summarize_signal(signal: Mapping[str, Any]) -> dict[str, Any]:
    return {
        'edge': signal.get('edge'),
        'confidence': signal.get('confidence'),
        'signal_source': signal.get('signal_source'),
        'window': signal.get('window'),
    }


def _summarize_gate_state(regime: Mapping[str, Any], risk_state: Mapping[str, Any]) -> dict[str, Any]:
    return {
        'regime_approved': regime.get('approved'),
        'regime_reasons': _compact_list(regime.get('reasons'), 2),
        'risk_allowed': risk_state.get('allowed'),
        'risk_reasons': _compact_list(risk_state.get('reasons'), 2),
        'trade_amount_usd': risk_state.get('trade_amount_usd'),
    }


def build_compact_user_prompt(*, market_context: Mapping[str, Any], signal: Mapping[str, Any], regime: Mapping[str, Any], risk_state: Mapping[str, Any], live_params: Mapping[str, Any], pending_rules: Mapping[str, Any], learning_snapshot: Mapping[str, Any]) -> str:
    payload = {
        'market': _summarize_market_context(market_context),
        'signal': _summarize_signal(signal),
        'gate': _summarize_gate_state(regime, risk_state),
        'live_params': {
            key: live_params.get(key)
            for key in ('min_edge', 'min_confidence', 'max_slippage_pct')
            if key in live_params
        },
        'learning_snapshot': {
            'avg_edge': learning_snapshot.get('avg_edge'),
            'avg_confidence': learning_snapshot.get('avg_confidence'),
        },
        'required_output': {
            'asset': 'BTC',
            'action': 'yes|no|skip',
            'confidence': 'number 0..1',
            'edge': 'number 0..1',
            'reasoning': 'string',
            'rule_suggestion': {'key': 'optional', 'value': 'optional', 'why': 'optional'},
        },
    }
    return json.dumps(payload, separators=(',', ':'), sort_keys=True, default=str)


def build_user_prompt(*, market_context: Mapping[str, Any], signal: Mapping[str, Any], regime: Mapping[str, Any], risk_state: Mapping[str, Any], live_params: Mapping[str, Any], pending_rules: Mapping[str, Any], learning_snapshot: Mapping[str, Any]) -> str:
    return build_compact_user_prompt(
        market_context=market_context,
        signal=signal,
        regime=regime,
        risk_state=risk_state,
        live_params=live_params,
        pending_rules=pending_rules,
        learning_snapshot=learning_snapshot,
    )


def parse_model_output(raw_output: str) -> dict[str, Any]:
    stripped = raw_output.strip()
    if not stripped:
        raise InvalidLLMOutputError('malformed JSON: empty response')
    try:
        payload = json.loads(stripped)
    except json.JSONDecodeError as exc:
        raise InvalidLLMOutputError(f'malformed JSON: {exc.msg}') from exc
    if not isinstance(payload, dict):
        raise InvalidLLMOutputError('model output must be a JSON object')
    return payload


def _coerce_number(value: Any, field_name: str) -> float:
    if isinstance(value, bool):
        raise InvalidLLMOutputError(f'{field_name} must be numeric')
    if isinstance(value, (int, float)):
        return float(value)
    raise InvalidLLMOutputError(f'{field_name} must be numeric')


def validate_model_output(payload: Mapping[str, Any]) -> dict[str, Any]:
    if not isinstance(payload, Mapping):
        raise InvalidLLMOutputError('model output must be a JSON object')

    allowed_top_keys = {'asset', 'action', 'confidence', 'edge', 'reasoning', 'rule_suggestion'}
    unexpected = set(payload.keys()) - allowed_top_keys
    if unexpected:
        raise InvalidLLMOutputError(f'unexpected keys: {", ".join(sorted(unexpected))}')

    asset = payload.get('asset')
    if asset != 'BTC':
        raise InvalidLLMOutputError('asset must be BTC')

    action = payload.get('action')
    if action not in ALLOWED_ACTIONS:
        raise InvalidLLMOutputError('action must be yes, no, or skip')

    confidence = _coerce_number(payload.get('confidence'), 'confidence')
    edge = _coerce_number(payload.get('edge'), 'edge')
    if not 0.0 <= confidence <= 1.0:
        raise InvalidLLMOutputError('confidence must be between 0 and 1')
    if not 0.0 <= edge <= 1.0:
        raise InvalidLLMOutputError('edge must be between 0 and 1')

    reasoning = payload.get('reasoning')
    if not isinstance(reasoning, str) or not reasoning.strip():
        raise InvalidLLMOutputError('reasoning must be a non-empty string')

    rule_suggestion = payload.get('rule_suggestion')
    validated_rule: dict[str, str] | None = None
    if rule_suggestion is not None:
        if not isinstance(rule_suggestion, Mapping):
            raise InvalidLLMOutputError('rule_suggestion must be an object')
        allowed_rule_keys = {'key', 'value', 'why'}
        unexpected_rule = set(rule_suggestion.keys()) - allowed_rule_keys
        if unexpected_rule:
            raise InvalidLLMOutputError(f'unexpected rule_suggestion keys: {", ".join(sorted(unexpected_rule))}')
        validated_rule = {}
        for key in allowed_rule_keys:
            value = rule_suggestion.get(key)
            if value is None:
                continue
            if not isinstance(value, str):
                raise InvalidLLMOutputError(f'rule_suggestion.{key} must be a string')
            if value.strip():
                validated_rule[key] = value.strip()
        if not validated_rule:
            validated_rule = None

    validated = {
        'asset': 'BTC',
        'action': action,
        'confidence': confidence,
        'edge': edge,
        'reasoning': reasoning.strip(),
    }
    if validated_rule is not None:
        validated['rule_suggestion'] = validated_rule
    return validated


def build_decision_record(
    *,
    market_id: str,
    window: str,
    signal_data: Mapping[str, Any],
    regime: Mapping[str, Any],
    risk_state: Mapping[str, Any],
    provider: LLMProvider | None,
    raw_model_output: str | None,
    raw_model_payload: Mapping[str, Any] | None,
    validated_decision: Mapping[str, Any] | None,
    reject_reason: str | None,
    execution_status: str,
    outcome: str | None = None,
) -> dict[str, Any]:
    return LLMDecisionResult(
        ts=current_ts(),
        provider=getattr(provider, 'provider_name', None),
        model=getattr(provider, 'model_name', None),
        market_id=market_id,
        window=window,
        btc_only=True,
        raw_model_output=raw_model_output,
        raw_model_payload=dict(raw_model_payload) if raw_model_payload is not None else None,
        validated_decision=dict(validated_decision) if validated_decision is not None else None,
        reject_reason=reject_reason,
        execution_status=execution_status,
        outcome=outcome,
        signal_data=dict(signal_data),
        regime=dict(regime),
        risk_state=dict(risk_state),
    ).to_record()


def decide_trade(*, context: Mapping[str, Any], provider: LLMProvider | None = None) -> dict[str, Any]:
    provider = provider or build_provider_from_env()
    system_prompt = build_system_prompt()
    user_prompt = build_user_prompt(
        market_context=context.get('market_context') or {},
        signal=context.get('signal') or {},
        regime=context.get('regime') or {},
        risk_state=context.get('risk_limits') or {},
        live_params=context.get('live_params') or {},
        pending_rules=context.get('pending_rules') or {},
        learning_snapshot=context.get('learning_snapshot') or {},
    )
    raw_output = provider.complete(system_prompt=system_prompt, user_prompt=user_prompt)
    try:
        payload = parse_model_output(raw_output)
        validated = validate_model_output(payload)
        validation = {
            'accepted': True,
            'reject_reason': None,
            'raw_response': raw_output,
            'raw_payload': payload,
            'decision': validated,
        }
    except InvalidLLMOutputError as exc:
        validation = {
            'accepted': False,
            'reject_reason': str(exc),
            'raw_response': raw_output,
            'raw_payload': None,
            'decision': None,
        }
    return {
        'provider': provider.provider_name,
        'model': provider.model_name,
        'raw_response': raw_output,
        'validation': validation,
        'decision': validation.get('decision'),
    }


def finalize_trade_decision(validation: Mapping[str, Any], regime: Mapping[str, Any], risk_state: Mapping[str, Any]) -> dict[str, Any]:
    if not validation.get('accepted'):
        return {
            'final_action': 'reject',
            'reason': f"llm_reject:{validation.get('reject_reason')}",
            'regime_approved': bool(regime.get('approved')),
            'risk_allowed': bool(risk_state.get('allowed')),
        }

    decision = validation.get('decision') or {}
    action = decision.get('action')
    if action == 'skip':
        return {
            'final_action': 'skip',
            'reason': 'model_skip',
            'regime_approved': bool(regime.get('approved')),
            'risk_allowed': bool(risk_state.get('allowed')),
        }
    if action == 'no':
        return {
            'final_action': 'reject',
            'reason': 'model_no',
            'regime_approved': bool(regime.get('approved')),
            'risk_allowed': bool(risk_state.get('allowed')),
        }
    if not regime.get('approved'):
        return {
            'final_action': 'reject',
            'reason': 'regime_reject',
            'regime_approved': False,
            'risk_allowed': bool(risk_state.get('allowed')),
        }
    if not risk_state.get('allowed'):
        return {
            'final_action': 'reject',
            'reason': f"risk_reject:{','.join(str(reason) for reason in (risk_state.get('reasons') or []))}",
            'regime_approved': True,
            'risk_allowed': False,
        }
    return {
        'final_action': 'execute',
        'reason': 'approved',
        'regime_approved': True,
        'risk_allowed': True,
    }


def run_llm_decision(
    *,
    provider: LLMProvider | None,
    market_context: Mapping[str, Any],
    signal_data: Mapping[str, Any],
    regime: Mapping[str, Any],
    risk_state: Mapping[str, Any],
    live_params: Mapping[str, Any],
    pending_rules: Mapping[str, Any],
    learning_snapshot: Mapping[str, Any],
) -> tuple[dict[str, Any] | None, str | None, str | None]:
    if provider is None:
        return None, None, LLM_BLOCKER

    system_prompt = build_system_prompt()
    user_prompt = build_user_prompt(
        market_context=market_context,
        signal=signal_data,
        regime=regime,
        risk_state=risk_state,
        live_params=live_params,
        pending_rules=pending_rules,
        learning_snapshot=learning_snapshot,
    )
    raw_output = provider.complete(system_prompt=system_prompt, user_prompt=user_prompt)
    payload = parse_model_output(raw_output)
    validated = validate_model_output(payload)
    return validated, raw_output, None


def summarize_pending_rules(pending_rules: Mapping[str, Any]) -> dict[str, Any]:
    rules = pending_rules.get('rules') if isinstance(pending_rules, Mapping) else []
    rules = rules if isinstance(rules, list) else []
    return {
        'count': len(rules),
        'rules': rules,
    }


def record_pending_rule(
    *,
    path: Path,
    suggestion: Mapping[str, Any] | None,
    outcome: Any = None,
) -> dict[str, Any]:
    store = read_json_file(path, {'rules': []})
    if not isinstance(store, dict):
        store = {'rules': []}
    rules = store.get('rules')
    if not isinstance(rules, list):
        rules = []

    if suggestion:
        key = suggestion.get('key')
        value = suggestion.get('value')
        why = suggestion.get('why')
        if key in LEARNABLE_KEYS and value is not None:
            matched = None
            for entry in rules:
                if (
                    isinstance(entry, dict)
                    and entry.get('key') == key
                    and entry.get('value') == value
                    and entry.get('why') == why
                ):
                    matched = entry
                    break
            if matched is None:
                matched = {
                    'key': key,
                    'value': value,
                    'why': why,
                    'count': 0,
                    'accepted_count': 0,
                    'rejected_count': 0,
                    'status': 'pending',
                    'first_seen': current_ts(),
                    'last_seen': current_ts(),
                    'applied_at': None,
                    'rolled_back_at': None,
                }
                rules.append(matched)
            matched['count'] = int(matched.get('count') or 0) + 1
            matched['last_seen'] = current_ts()
            if isinstance(outcome, (int, float)) and outcome > 0:
                matched['accepted_count'] = int(matched.get('accepted_count') or 0) + 1
            elif isinstance(outcome, (int, float)) and outcome < 0:
                matched['rejected_count'] = int(matched.get('rejected_count') or 0) + 1

    store['rules'] = rules
    store['_meta'] = {
        'updated_at': current_ts(),
        'source': 'btc_llm_decider',
    }
    write_json_file(path, store)
    return store


def update_learning_state(
    *,
    live_params_path: Path,
    pending_rules_path: Path,
    defaults: Mapping[str, Any],
    cycle_entry: Mapping[str, Any],
    history: list[Mapping[str, Any]],
) -> dict[str, Any]:
    validation = cycle_entry.get('validation') or {}
    decision = validation.get('decision') or {}
    suggestion = decision.get('rule_suggestion')
    outcome = cycle_entry.get('outcome')

    record_pending_rule(
        path=pending_rules_path,
        suggestion=suggestion if isinstance(suggestion, Mapping) else None,
        outcome=outcome,
    )

    learning_snapshot = {
        'avg_edge': None,
        'avg_confidence': None,
    }
    accepted_rows = [
        row
        for row in history
        if isinstance(row, Mapping)
        and (row.get('validation') or {}).get('accepted')
        and ((row.get('validation') or {}).get('decision') or {}).get('action') == 'yes'
    ]
    edges = [
        float(((row.get('validation') or {}).get('decision') or {}).get('edge'))
        for row in accepted_rows
        if isinstance((((row.get('validation') or {}).get('decision') or {}).get('edge')), (int, float))
    ]
    confidences = [
        float(((row.get('validation') or {}).get('decision') or {}).get('confidence'))
        for row in accepted_rows
        if isinstance((((row.get('validation') or {}).get('decision') or {}).get('confidence')), (int, float))
    ]
    if edges:
        learning_snapshot['avg_edge'] = sum(edges) / len(edges)
    if confidences:
        learning_snapshot['avg_confidence'] = sum(confidences) / len(confidences)

    apply_eligible_rules(
        live_params_path=live_params_path,
        pending_rules_path=pending_rules_path,
        learning_snapshot=learning_snapshot,
        defaults=defaults,
    )
    return {
        'learning_snapshot': learning_snapshot,
        'pending_rules': load_pending_rules(pending_rules_path),
        'live_params': load_live_params(live_params_path),
    }


def apply_eligible_rules(
    *,
    live_params_path: Path,
    pending_rules_path: Path,
    learning_snapshot: Mapping[str, Any],
    defaults: Mapping[str, Any],
) -> tuple[dict[str, Any], dict[str, Any]]:
    live_params = read_json_file(live_params_path, {})
    if not isinstance(live_params, dict):
        live_params = {}
    pending_store = read_json_file(pending_rules_path, {'rules': []})
    if not isinstance(pending_store, dict):
        pending_store = {'rules': []}
    rules = pending_store.get('rules')
    if not isinstance(rules, list):
        rules = []

    avg_edge = learning_snapshot.get('avg_edge')
    avg_confidence = learning_snapshot.get('avg_confidence')
    for entry in rules:
        if not isinstance(entry, dict):
            continue
        if entry.get('status') != 'pending':
            continue
        if entry.get('count', 0) < 3:
            continue
        if entry.get('accepted_count', 0) < 2:
            continue
        key = entry.get('key')
        if key not in LEARNABLE_KEYS:
            continue

        template_value = defaults.get(key, live_params.get(key))
        raw_value = entry.get('value')
        new_value = coerce_rule_value(key, raw_value, template_value)
        baseline_edge = avg_edge if isinstance(avg_edge, (int, float)) else None
        baseline_confidence = avg_confidence if isinstance(avg_confidence, (int, float)) else None
        entry['previous_value'] = live_params.get(key)
        entry['applied_at'] = current_ts()
        entry['baseline_edge'] = baseline_edge
        entry['baseline_confidence'] = baseline_confidence
        entry['status'] = 'applied'
        live_params[key] = new_value

    if avg_edge is not None and avg_confidence is not None:
        for entry in rules:
            if not isinstance(entry, dict):
                continue
            if entry.get('status') != 'applied':
                continue
            baseline_edge = entry.get('baseline_edge')
            baseline_confidence = entry.get('baseline_confidence')
            if baseline_edge is None or baseline_confidence is None:
                continue
            if avg_edge < baseline_edge or avg_confidence < baseline_confidence:
                key = entry.get('key')
                previous_value = entry.get('previous_value')
                if key in LEARNABLE_KEYS and previous_value is not None:
                    live_params[key] = previous_value
                entry['status'] = 'rolled_back'
                entry['rolled_back_at'] = current_ts()

    live_params['_meta'] = {
        'updated_at': current_ts(),
        'source': 'btc_llm_decider',
    }
    pending_store['rules'] = rules
    pending_store['_meta'] = {
        'updated_at': current_ts(),
        'source': 'btc_llm_decider',
    }
    write_json_file(live_params_path, live_params)
    write_json_file(pending_rules_path, pending_store)
    return live_params, pending_store


def coerce_rule_value(key: str, raw_value: Any, template_value: Any) -> Any:
    if raw_value is None:
        return template_value
    if isinstance(template_value, bool):
        if isinstance(raw_value, bool):
            return raw_value
        if isinstance(raw_value, str):
            return raw_value.strip().lower() in {'1', 'true', 'yes', 'on'}
        return bool(raw_value)
    if isinstance(template_value, int) and not isinstance(template_value, bool):
        if isinstance(raw_value, (int, float)) and not isinstance(raw_value, bool):
            return int(raw_value)
        return int(float(str(raw_value)))
    if isinstance(template_value, float):
        if isinstance(raw_value, (int, float)) and not isinstance(raw_value, bool):
            return float(raw_value)
        return float(str(raw_value))
    return raw_value


def _post_json(url: str, *, body: Mapping[str, Any], headers: Mapping[str, str]) -> dict[str, Any]:
    data = json.dumps(body).encode('utf-8')
    request_obj = Request(url, data=data, headers=dict(headers), method='POST')
    try:
        with urlopen(request_obj, timeout=30) as response:
            payload = response.read().decode('utf-8')
    except HTTPError as exc:
        error_body = exc.read().decode('utf-8', errors='replace') if exc.fp else ''
        raise ProviderRequestError(f'provider request failed: {exc.code} {error_body}'.strip()) from exc
    except URLError as exc:
        raise ProviderRequestError(f'provider request failed: {exc.reason}') from exc
    try:
        return json.loads(payload)
    except json.JSONDecodeError as exc:
        raise ProviderRequestError(f'provider returned invalid JSON: {exc.msg}') from exc


path: modules/btc_position_manager.py
content: from __future__ import annotations

from datetime import datetime, timezone


def _safe_get(obj, name, default=None):
    if isinstance(obj, dict):
        return obj.get(name, default)
    return getattr(obj, name, default)


def active_positions_for_skill(positions, skill_slug: str) -> list:
    scoped = []
    for position in positions:
        source = _safe_get(position, 'source')
        if source and source != skill_slug:
            continue
        if (_safe_get(position, 'shares', 0.0) or 0.0) <= 0:
            continue
        scoped.append(position)
    return scoped


def enforce_risk_limits(
    settings: dict,
    positions,
    config: dict,
    skill_slug: str,
    journal_rows: list[dict],
    *,
    execution_mode: str = 'dry_run',
    regime: dict | None = None,
) -> dict:
    reasons = []
    settings = settings or {}
    scoped_positions = active_positions_for_skill(positions, skill_slug)
    if execution_mode not in {'dry_run', 'live'}:
        reasons.append(f'unknown_execution_mode:{execution_mode}')

    if len(scoped_positions) >= config['max_open_positions']:
        reasons.append('max_open_positions_reached')

    if settings.get('trading_paused'):
        reasons.append('trading_paused')

    daily_spent = float(settings.get('sdk_daily_spent') or 0.0)
    if daily_spent >= config['max_daily_loss_usd']:
        reasons.append('max_daily_loss_reached')

    today = datetime.now(timezone.utc).date().isoformat()
    today_trades = [row for row in journal_rows if row.get('ts', '').startswith(today) and row.get('result_type') == 'trade']
    if len(today_trades) >= config['max_trades_per_day']:
        reasons.append('max_trades_per_day_reached')

    recent_losses = [row for row in today_trades if row.get('pnl_usd', 0) < 0]
    if recent_losses:
        latest_loss = recent_losses[-1].get('ts')
        if latest_loss:
            loss_time = datetime.fromisoformat(latest_loss.replace('Z', '+00:00'))
            cooldown = (datetime.now(timezone.utc) - loss_time).total_seconds() / 60
            if cooldown < config['cooldown_after_loss_minutes']:
                reasons.append(f'cooldown_active:{cooldown:.1f}m')

    if regime is not None:
        spread_pct = regime.get('spread_pct')
        if spread_pct is not None and spread_pct > config['max_slippage_pct']:
            reasons.append(f'max_slippage_pct_exceeded:{spread_pct:.4f}')

    amount = min(
        config['max_trade_usd'],
        config['max_single_market_exposure_usd'],
        config['bankroll_usd'],
    )
    amount = round(max(amount, 0.0), 2)
    if amount <= 0:
        reasons.append('non_positive_trade_amount')

    return {
        'allowed': not reasons,
        'reasons': reasons,
        'trade_amount_usd': amount,
        'open_positions': len(scoped_positions),
        'daily_spent': daily_spent,
        'execution_mode': execution_mode,
    }


path: modules/btc_regime_filter.py
content: from __future__ import annotations

from datetime import datetime, timezone


def _parse_time(value: str | None) -> datetime | None:
    if not value:
        return None
    if value.endswith('Z'):
        value = value.replace('Z', '+00:00')
    return datetime.fromisoformat(value)


def evaluate_regime(context: dict, signal, config: dict) -> dict:
    market = context.get('market', {})
    slippage = context.get('slippage', {})
    warnings = list(context.get('warnings') or [])
    reasons = []

    resolves_at = _parse_time(market.get('resolves_at'))
    minutes_to_resolution = None
    if resolves_at is not None:
        minutes_to_resolution = (resolves_at - datetime.now(timezone.utc)).total_seconds() / 60
        if minutes_to_resolution < 2.0:
            reasons.append(f'resolves_too_soon:{minutes_to_resolution:.2f}m')

    spread_pct = slippage.get('spread_pct')
    if spread_pct is not None and spread_pct > config['max_slippage_pct']:
        reasons.append(f'spread_too_wide:{spread_pct:.4f}')

    if signal.edge < config['min_edge']:
        reasons.append(f'edge_below_threshold:{signal.edge:.4f}')
    if signal.confidence < config['min_confidence']:
        reasons.append(f'confidence_below_threshold:{signal.confidence:.4f}')

    fee_rate_bps = market.get('fee_rate_bps') or 0
    fee_rate = fee_rate_bps / 10000.0
    if signal.edge <= fee_rate:
        reasons.append(f'edge_not_above_fee:{signal.edge:.4f}<={fee_rate:.4f}')

    if signal.action == 'hold':
        reasons.append('signal_hold')

    approved = not reasons
    return {
        'approved': approved,
        'reasons': reasons,
        'warnings': warnings,
        'minutes_to_resolution': minutes_to_resolution,
        'spread_pct': spread_pct,
        'fee_rate': fee_rate,
    }


path: modules/btc_self_learn.py
content: from __future__ import annotations

from statistics import mean


TUNABLE_KEYS = [
    'min_edge',
    'min_confidence',
    'max_slippage_pct',
    'cycle_interval_minutes',
    'stop_loss_pct',
    'take_profit_pct',
]


def build_learning_snapshot(journal_rows: list[dict], config: dict, live_params: dict | None = None, pending_rules: dict | None = None) -> dict:
    executed = [row for row in journal_rows if row.get('result_type') in {'trade', 'dry_run'} and row.get('decision') == 'candidate']
    edges = [row.get('signal_data', {}).get('edge') for row in executed if row.get('signal_data', {}).get('edge') is not None]
    confidences = [row.get('signal_data', {}).get('confidence') for row in executed if row.get('signal_data', {}).get('confidence') is not None]
    live_params = live_params or {}
    pending_rules = pending_rules or {}
    live_param_deltas = {
        key: live_params[key]
        for key in TUNABLE_KEYS
        if key in live_params and live_params[key] != config[key]
    }
    pending_rule_count = len((pending_rules.get('rules') or []) if isinstance(pending_rules, dict) else [])
    snapshot = {
        'experiments_enabled': True,
        'tunable_keys': TUNABLE_KEYS,
        'candidate_count': len(executed),
        'avg_edge': mean(edges) if edges else None,
        'avg_confidence': mean(confidences) if confidences else None,
        'current_defaults': {key: config[key] for key in TUNABLE_KEYS},
        'live_param_deltas': live_param_deltas,
        'pending_rule_count': pending_rule_count,
    }
    suggestions = []
    if snapshot['avg_edge'] is not None and snapshot['avg_edge'] < config['min_edge']:
        suggestions.append('raise signal quality or narrow market selection before lowering min_edge')
    if snapshot['avg_confidence'] is not None and snapshot['avg_confidence'] < config['min_confidence']:
        suggestions.append('confidence is below threshold; autoresearch should tune signal thresholds before live enablement')
    snapshot['suggestions'] = suggestions
    return snapshot


path: modules/btc_sprint_executor.py
content: from __future__ import annotations


def build_reasoning(signal, regime, trade_amount: float, llm_reasoning: str | None = None) -> str:
    regime_bits = ', '.join(regime['warnings']) if regime['warnings'] else 'no warnings'
    base_reasoning = llm_reasoning or signal.reasoning
    return (
        f"{base_reasoning} Edge={signal.edge:.4f}, confidence={signal.confidence:.4f}, "
        f"trade_amount=${trade_amount:.2f}, regime={regime_bits}."
    )


def execute_trade(client, market_id: str, side: str, amount: float, signal, regime: dict, *, live: bool, source: str, skill_slug: str, venue: str, validate_real_path: bool, llm_decision: dict | None = None) -> dict:
    llm_reasoning = None
    if isinstance(llm_decision, dict):
        llm_reasoning = llm_decision.get('reasoning')
    reasoning = build_reasoning(signal, regime, amount, llm_reasoning=llm_reasoning)
    signal_data = signal.to_signal_data()
    result = {
        'live': live,
        'venue': venue,
        'market_id': market_id,
        'side': side,
        'amount': amount,
        'reasoning': reasoning,
        'source': source,
        'skill_slug': skill_slug,
        'decision_source': 'llm',
        'signal_data': signal_data,
    }

    if live:
        trade = client.trade(
            market_id=market_id,
            side=side,
            amount=amount,
            venue=venue,
            reasoning=reasoning,
            source=source,
            skill_slug=skill_slug,
            signal_data=signal_data,
        )
        result['result_type'] = 'trade'
        result['trade'] = getattr(trade, '__dict__', trade)
        return result

    result['result_type'] = 'dry_run'
    if validate_real_path and venue == 'polymarket':
        preflight = client.prepare_real_trade(market_id, side, amount)
        result['preflight'] = getattr(preflight, '__dict__', preflight)
    return result


path: modules/btc_sprint_signal.py
content: from __future__ import annotations

import json
import math
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterable, Optional
from urllib.request import urlopen


BINANCE_URL = "https://api.binance.us/api/v3/klines"


@dataclass
class SignalDecision:
    action: str
    edge: float
    confidence: float
    signal_source: str
    reasoning: str
    metrics: dict

    def to_signal_data(self) -> dict:
        data = {
            "edge": round(self.edge, 4),
            "confidence": round(self.confidence, 4),
            "signal_source": self.signal_source,
        }
        for key, value in self.metrics.items():
            if isinstance(value, float):
                data[key] = round(value, 6)
            else:
                data[key] = value
        return data


def fetch_binance_klines(symbol: str = "BTCUSDT", interval: str = "1m", limit: int = 30) -> list[dict]:
    url = f"{BINANCE_URL}?symbol={symbol}&interval={interval}&limit={limit}"
    with urlopen(url, timeout=10) as resp:
        raw = json.loads(resp.read().decode())
    klines = []
    for row in raw:
        klines.append(
            {
                "open_time": int(row[0]),
                "open": float(row[1]),
                "high": float(row[2]),
                "low": float(row[3]),
                "close": float(row[4]),
                "volume": float(row[5]),
            }
        )
    return klines


def _pct_change(a: float, b: float) -> float:
    if a == 0:
        return 0.0
    return (b - a) / a


def _realized_volatility(closes: Iterable[float]) -> float:
    closes = list(closes)
    if len(closes) < 2:
        return 0.0
    moves = [_pct_change(closes[i], closes[i + 1]) for i in range(len(closes) - 1)]
    mean = sum(moves) / len(moves)
    variance = sum((move - mean) ** 2 for move in moves) / len(moves)
    return math.sqrt(variance)


def fallback_signal_from_context(context: dict, min_edge: float) -> SignalDecision:
    market = context.get("market", {})
    external_price = market.get("external_price")
    current_probability = market.get("current_probability")
    divergence = market.get("divergence")
    if external_price is None or current_probability is None:
        return SignalDecision(
            action="hold",
            edge=0.0,
            confidence=0.5,
            signal_source="context_fallback",
            reasoning="No Binance signal and insufficient context for fallback signal.",
            metrics={"divergence": divergence or 0.0},
        )

    diff = external_price - current_probability
    edge = abs(diff)
    action = "yes" if diff > 0 else "no" if diff < 0 else "hold"
    confidence = min(0.9, 0.55 + edge * 4)
    if edge < min_edge:
        action = "hold"
    return SignalDecision(
        action=action,
        edge=edge,
        confidence=confidence,
        signal_source="context_fallback",
        reasoning=f"Fallback signal from external/current price divergence ({diff:+.4f}).",
        metrics={"divergence": diff},
    )


def build_signal(window: str, context: dict, symbol: str = "BTCUSDT", interval: str = "1m", min_edge: float = 0.07) -> SignalDecision:
    try:
        klines = fetch_binance_klines(symbol=symbol, interval=interval, limit=30)
    except Exception as exc:
        fallback = fallback_signal_from_context(context, min_edge=min_edge)
        fallback.reasoning += f" Binance fetch failed: {exc}."
        return fallback

    closes = [row["close"] for row in klines]
    short_window = 3 if window == "5m" else 5
    long_window = 8 if window == "5m" else 13
    short_move = _pct_change(closes[-short_window], closes[-1])
    long_move = _pct_change(closes[-long_window], closes[-1])
    volatility = _realized_volatility(closes[-long_window:])
    combined_move = (short_move * 0.6) + (long_move * 0.4)
    edge = max(0.0, min(0.2, abs(combined_move) * 22))
    confidence = max(0.5, min(0.95, 0.58 + abs(combined_move) * 18 - volatility * 8))

    if edge < min_edge:
        action = "hold"
    else:
        action = "yes" if combined_move > 0 else "no"

    direction = "up" if combined_move > 0 else "down" if combined_move < 0 else "flat"
    reasoning = (
        f"{window} momentum from Binance {symbol} is {direction}: short_move={short_move:+.4f}, "
        f"long_move={long_move:+.4f}, vol={volatility:.4f}."
    )
    return SignalDecision(
        action=action,
        edge=edge,
        confidence=confidence,
        signal_source="binance_1m_momentum",
        reasoning=reasoning,
        metrics={
            "window": window,
            "short_move": short_move,
            "long_move": long_move,
            "volatility": volatility,
            "computed_at": datetime.now(timezone.utc).isoformat(),
        },
    )


path: modules/btc_trade_journal.py
content: from __future__ import annotations

import json
from pathlib import Path
from typing import Iterable


def ensure_data_dir(path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)


def read_journal(path: Path) -> list[dict]:
    if not path.exists():
        return []
    rows = []
    for line in path.read_text().splitlines():
        if line.strip():
            rows.append(json.loads(line))
    return rows


def append_journal(path: Path, row: dict) -> None:
    ensure_data_dir(path)
    with path.open('a', encoding='utf-8') as fh:
        fh.write(json.dumps(row, sort_keys=True) + '\n')


path: scripts/analyze_sprints.py
content: from __future__ import annotations

import argparse
import json
from collections import Counter
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = ROOT / 'data'
JOURNAL_PATH = DATA_DIR / 'journal.jsonl'
LLM_DECISIONS_PATH = DATA_DIR / 'llm_decisions.jsonl'
LIVE_PARAMS_PATH = DATA_DIR / 'live_params.json'
PENDING_RULES_PATH = DATA_DIR / 'pending_rules.json'
DEFAULTS_PATH = ROOT / 'config' / 'defaults.json'
TUNABLE_KEYS = [
    'min_edge',
    'min_confidence',
    'max_slippage_pct',
    'cycle_interval_minutes',
    'stop_loss_pct',
    'take_profit_pct',
]


def _read_jsonl(path: Path) -> list[dict[str, Any]]:
    if not path.exists():
        return []
    rows = []
    for line in path.read_text().splitlines():
        if line.strip():
            rows.append(json.loads(line))
    return rows


def _read_json(path: Path, default: Any) -> Any:
    if not path.exists():
        return default
    try:
        return json.loads(path.read_text())
    except Exception:
        return default


def build_review_report() -> dict[str, Any]:
    journal_rows = _read_jsonl(JOURNAL_PATH)
    llm_rows = _read_jsonl(LLM_DECISIONS_PATH)
    live_params = _read_json(LIVE_PARAMS_PATH, {})
    pending_rules = _read_json(PENDING_RULES_PATH, {'rules': []})
    defaults = _read_json(DEFAULTS_PATH, {})

    btc_candidates = [
        {
            'market_id': row.get('market_id'),
            'window': row.get('window'),
            'question': row.get('market_question') or row.get('question'),
            'raw_action': (row.get('validated_decision') or {}).get('action'),
            'reject_reason': row.get('reject_reason'),
        }
        for row in llm_rows
        if row.get('btc_only')
    ]

    action_counts = Counter(
        (row.get('validated_decision') or {}).get('action')
        for row in llm_rows
        if (row.get('validated_decision') or {}).get('action')
    )
    deterministic_rejects = [
        row
        for row in llm_rows
        if row.get('reject_reason')
    ]
    reject_reasons = Counter(row.get('reject_reason') or 'unknown' for row in deterministic_rejects)
    accepted_trades = [
        {
            'market_id': row.get('market_id'),
            'window': row.get('window'),
            'side': row.get('side'),
            'amount': row.get('amount'),
            'result_type': row.get('result_type'),
        }
        for row in journal_rows
        if row.get('decision') == 'candidate' and row.get('result_type') in {'trade', 'dry_run'}
    ]
    learned_param_changes = {
        key: live_params[key]
        for key in TUNABLE_KEYS
        if key in live_params and live_params[key] != defaults.get(key)
    }
    pending_rule_list = pending_rules.get('rules') if isinstance(pending_rules, dict) else []
    pending_rule_list = pending_rule_list if isinstance(pending_rule_list, list) else []

    report = {
        'btc_only_candidates_seen': len(btc_candidates),
        'btc_candidates': btc_candidates,
        'model_actions': {
            'yes': action_counts.get('yes', 0),
            'no': action_counts.get('no', 0),
            'skip': action_counts.get('skip', 0),
        },
        'deterministic_rejects': len(deterministic_rejects),
        'accepted_trades': accepted_trades,
        'learned_param_changes': learned_param_changes,
        'pending_rules': pending_rule_list,
        'top_reject_reasons': reject_reasons.most_common(10),
        'journal_rows': len(journal_rows),
        'llm_decision_rows': len(llm_rows),
    }
    return report


def main() -> None:
    parser = argparse.ArgumentParser(description='Summarize BTC sprint logs and LLM decisions')
    parser.add_argument('--review', action='store_true', help='Print the review report')
    args = parser.parse_args()

    report = build_review_report()
    if args.review:
        print(json.dumps(report, indent=2, sort_keys=True, default=str))
        return
    print(json.dumps(report, indent=2, sort_keys=True, default=str))


if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector:  ()