diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..958d1ac --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Alpaca — PAPER ONLY. Never put live keys in this project. +ALPACA_API_KEY= +ALPACA_SECRET_KEY= +ALPACA_PAPER=true + +# LLM provider (one is enough) +ANTHROPIC_API_KEY= +OPENAI_API_KEY= + +SPEEDTRADER_CONFIG_DIR=configs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eb6c98f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,186 @@ +name: CI + +# The deterministic core is the part of this system where a silent regression +# costs money rather than embarrassment, so it is verified on every push. +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: tests (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Verify the authoritative MQL5 reference is unmodified + # §47: any change to the ported-from source is an integrity issue, not a + # diff. Checked before the suite so the failure is unambiguous when the + # quant tests would also fail as a consequence. + run: | + echo "c799acaa797a4f23a8c9531c3b4f14599b73736af2151d9eeb7f42209332e8d9 docs/reference/SpeedTraderBot_v6.1.mq5" \ + | sha256sum --check --strict + + - name: Lint (errors only) + # Scoped to real defects — undefined names, redefinitions, syntax errors. + # Stylistic rules are deliberately not gating: a formatting opinion must + # never be able to block a correctness fix. + run: ruff check src tests scripts --select F821,F811,F823,E9 + + - name: Tests + run: pytest -q + + mutation: + # A green suite proves the tests pass. It does not prove they would NOTICE a + # safety guard being removed — which, for this system, is the whole claim. + # Each guard is deliberately broken and the suite must go red. + name: mutation testing + runs-on: ubuntu-latest + # Belt and braces alongside the harness's own per-mutant ceiling: a mutant + # that removes a termination condition makes the suite run forever, and + # without a cap that burns a runner until GitHub's 6-hour job limit. + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Every safety guard must be load-bearing + # Non-zero if any mutant SURVIVES (a guard nothing tests), if a declared + # equivalent mutant turns out to be killable, or if an anchor is lost — + # the last meaning a guard was refactored and is silently no longer + # covered here. + run: python scripts/mutation_test.py + + safety-invariants: + # These assertions are cheap and non-negotiable, so they run as their own + # job: if the suite above is ever narrowed, these still fail loudly. + name: safety invariants + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Paper trading only — never live + run: | + python - <<'PY' + import sys, yaml + cfg = yaml.safe_load(open("configs/execution_config.yaml")) + env = cfg.get("environment") + if env != "paper": + sys.exit(f"execution_config.environment must be 'paper', got {env!r}") + if cfg.get("replay_execution_enabled") is not False: + sys.exit("replay_execution_enabled must be false") + print("environment=paper, replay execution disabled") + PY + + - name: Risk config must be fail-closed + run: | + python - <<'PY' + import sys, yaml + cfg = yaml.safe_load(open("configs/risk_config.yaml")) + for key in ("fail_closed", "kill_switch_enabled"): + if cfg.get(key) is not True: + sys.exit(f"risk_config.{key} must be true, got {cfg.get(key)!r}") + print("risk config is fail-closed with the kill switch enabled") + PY + + - name: The autonomous runtime must start and stop cleanly + # A bounded loop that cannot be stopped is the failure mode that + # matters most for an unattended trader, so the kill switch and the + # cycle bound are exercised for real, not just unit-tested. + run: | + python - <<'PY' + import sys + sys.path.insert(0, "src") + from speedtrader.app.runtime import ( + AutonomousRuntime, RuntimeLimits, RuntimeState, StopReason) + + rt = AutonomousRuntime( + symbols=["SPY"], cycle_fn=lambda s, c: None, + limits=RuntimeLimits(interval_seconds=1, max_cycles=2, + require_market_open=False), + sleep_fn=lambda s: None) + health = rt.run() + assert health.state is RuntimeState.STOPPED, health.state + assert health.cycles_completed == 2, health.cycles_completed + assert health.stop_reason is StopReason.MAX_CYCLES + print("runtime start/stop OK:", health.to_record()) + PY + + - name: No credentials in any generated artefact + # The dashboard reads the paper account. It must render an account + # NUMBER and never a key, and must contain no script element at all. + run: | + python scripts/build_dashboard.py --out /tmp/dash.html + python - <<'PY' + import re, sys, pathlib + page = pathlib.Path("/tmp/dash.html").read_text() + if re.search(r"\b(?:AK|PK|SK)[A-Z0-9]{18,}\b", page): + sys.exit("credential-shaped literal in the generated dashboard") + for bad in (" + +Concept banner. The dashboard figures in it are illustrative design +mock-ups, not results — this project makes no performance claim anywhere. The +real generated command centre is data/dashboard.html. + +# SpeedTrader AI + +**An options trading agent where the AI can veto a trade but can never cause one.** + +Alpaca AI Trading Agents Hackathon · Paper trading only · 1122 tests + +```bash +pip install -e ".[dev]" +python scripts/run_options_demo.py --replay --dashboard +``` + +No API key. No network. No configuration. That runs the complete decision cycle, +re-derives every decision from its stored snapshot, and writes the command centre. + +**Full setup, live paper trading, and the strategy plugin system:** see +[`docs/SUBMISSION.md`](docs/SUBMISSION.md) for the complete engineering log — +what was built, what was rejected and why, and the exact commands to reproduce +every claim in this README. + +| | | +|---|---| +| **Team** | testerxma | +| **One-page write-up** | [`docs/ONE_PAGER.md`](docs/ONE_PAGER.md) — AI logic, risk gates, Alpaca infrastructure | +| **Engineering log** | [`docs/SUBMISSION.md`](docs/SUBMISSION.md) — what was built, what was rejected, and why | +| **Paper account** | `PA3FJIP2GIRB` · dedicated to this hackathon · $100,000 starting balance | +| **License** | [MIT](LICENSE) | + +--- + +## The idea + +Most AI trading agents work like this: + +``` +LLM proposes a trade ──► guardrails try to catch bad ones ──► broker +``` + +The guardrails are the last line of defence, and the LLM's mistakes are the +thing they have to catch. SpeedTrader inverts it: + +``` +S07 (ported quant strategy) + ──► deterministic risk engine decides IF and HOW MUCH + ──► options sizing converts risk budget to contracts + ──► AI adversarial review may only CONFIRM · ABSTAIN · VETO + ──► single-use execution licence + ──► Alpaca (paper) via its MCP server +``` + +By the time the model is consulted, the trade already exists and is already +approved. **The AI's entire vocabulary is three words**, and none of them can +enlarge a position, loosen a limit, change a strike, or authorize anything. + +This is not enforced by telling the model to behave — a prompt is a request, and +prompt injection is real. It is enforced by the **schema**: the review object has +four fields (`verdict`, `confidence`, `reasoning`, `concerns`) and +`additionalProperties: false`. A model returning `{"verdict": "CONFIRM", +"quantity": 99999}` changes nothing, and there is a test that proves exactly +that against the real pipeline. + +The practical consequence: **model capability is a reasoning decision, not a +safety one.** Swap in the strongest model available and its maximum authority is +still to cancel one trade. + +--- + +## The failure mode is inverted, deliberately + +Because this layer can only *subtract*, "fail closed" here means **change +nothing** — not "block everything". + +| What happens | What the system does | +|---|---| +| Model times out | ABSTAIN · the deterministic decision stands | +| Model returns malformed JSON | ABSTAIN · never guessed at | +| Model refuses | ABSTAIN | +| No API key at all | ABSTAIN · honest "no model was consulted" | +| Model says VETO with a reason | **Trade cancelled** | + +If an LLM outage vetoed instead, a vendor going down would silently halt all +trading. Mutation testing confirms it: `veto-on-failure` — making a model +failure veto instead of abstain — breaks 23 tests at this commit. + +--- + +## Why long single-leg options + +Chosen for correctness, not convenience. + +> **Maximum loss = the premium paid, known exactly at entry, under every price path.** + +An equity stop-loss is an *intention* — price can gap through it overnight. A +long option cannot lose more than its debit. So the risk engine sizes against an +**exact** maximum loss rather than an assumed one: + +``` +equities: shares = risk_money / stop_distance ← a price distance +options: contracts = risk_budget / (ask × 100) ← the entire amount at risk +``` + +These are not the same formula with different names, and the options path never +reuses the equity one. Priced at the **ask**, never the mid — the mid understates +max loss by half the spread on every position. + +A bearish signal buys a **long put**, never a short call: risk stays defined in +both directions. + +--- + +## What a judge can verify in 60 seconds + +```bash +python scripts/run_options_demo.py # all six scenarios +python -m pytest -q # 1122 passed +python scripts/mutation_test.py # 78 mutants; every guard is load-bearing +``` + +| Scenario | Demonstrates | +|---|---| +| `breakout` | 3 contracts, **$960 max loss** inside a $1,000 budget | +| `veto` | AI cancels an approved trade — nothing reaches the broker | +| `no-signal` | S07 declines; the decision is still recorded | +| `illiquid` | spread too wide → no tradeable contract | +| `broke` | one contract costs $6,200 vs a $1,000 budget → rejected | +| `timeout` | broker times out → **UNKNOWN**, not retried, never "filled" | + +Then read one decision: + +```bash +cat data/decisions/decisions-*.jsonl | head -1 | python -m json.tool +``` + +Every record carries the snapshot, the signal, **the cost assumptions behind its +EV**, all 22 deterministic checks, the contract chosen *and what was rejected and +why*, the AI review with the model that produced it, and the state the decision +ended in with the reason for it — including `UNKNOWN`, where an order may exist +and reconciliation, not a retry, is what resolves it. + +### And against the real broker + +The demo above is deterministic and offline, which is what makes it a +*demonstration*. `run_live_paper.py` is the same pipeline with Alpaca supplying +the bars, the option chain, the quotes and the account balance: + +```bash +python scripts/run_live_paper.py --scan # decide across a watchlist +python scripts/run_live_paper.py --symbol SPY # one name, no order sent +python scripts/run_live_paper.py --symbol SPY --submit # actually place it +``` + +**The default sends nothing.** Without `--submit` the orchestrator is not given a +broker reference at all, so a dry run is structural rather than a flag someone +can forget. `--allow-stale` lets you watch the pipeline outside market hours and +is **refused together with `--submit`**: trading on a stale bar is the exact +thing the freshness gate exists to prevent. + +Running it is not a formality — three real defects in this repository were found +only this way, and each is now pinned by a mutant (`quote-batch-uncapped`, +`envelope-unbounded`, `lookback-calendar-units`). The third is the instructive +one: Alpaca's bar request is bounded by a wall-clock start time, but a strategy +asks in *bars*, and a calendar week holds 168 hours against ~32.5 market hours. +The window was computed in the wrong unit, so a request for the 891 hourly bars +an EMA200 needs returned 660 and the snapshot builder refused **every** hourly +symbol. It failed closed, correctly — and could never have failed any other way. +No fixture would have shown that, because a fixture returns the bars you wrote +into it. + +--- + +## The part competitors cannot do: replay + +Every LLM trading agent shares one documented weakness, stated by its own +authors — model output is not reproducible, so a decision cannot be audited +afterwards. SpeedTrader's decisions *can* be re-derived, because the part that +decides is deterministic. + +``` +$ python scripts/run_options_demo.py --replay + + 315c42afbae355ad reproduced + 3cc211821bdfd44a reproduced (AI vetoed — the one thing it can change) + f85abf8442f2541e reproduced + + 6/6 decisions re-derived from their stored snapshot alone, with the AI + never consulted. +``` + +Six is what a fresh clone shows: the journal ships empty, this command writes +the six demo decisions and then replays them. The journal is append-only, so a +second run replays twelve — the count is every decision ever stored, not a fixed +figure. + +The 16-character fingerprint identifies **what the deterministic system +decided**, and it **excludes the AI review by construction**. So the same market +state hashes identically whether the model confirmed, abstained, timed out, +returned hostile output, or never ran. The project's central claim reduces to +comparing two strings — and a `CONFIRM` run and an `ABSTAIN` run producing the +same fingerprint is asserted end-to-end by test. + +A **veto** does change the outcome, so it is tracked *beside* the fingerprint +rather than folded into it. Merging them would destroy the ability to prove the +property above. + +If a replay diverges, the differing field is named. "Not reproducible" alone is +useless; an auditor needs to know which field moved. + +--- + +## Command centre + +```bash +python scripts/build_dashboard.py --live # real paper account + journal +open data/dashboard.html +``` + +A single self-contained HTML file — no server, no build step, no network. It +renders the real pipeline: every figure comes from a persisted decision record, +from the Alpaca paper account, or from the runtime's own health object. + +It shows market state, the quantitative signal, the AI advisory lane, the +deterministic authority lane, the execution outcome lane, options contract and +sizing with **max loss labelled EXACT**, every deterministic check with its +observed value, rejected option candidates with the reason each was dropped, +the order lifecycle with intent phase beside broker state, and +**"why we did NOT trade"** attributing each declined decision to the layer that +stopped it. + +**There is no JavaScript on the page, and that is a security property rather +than a stylistic one.** The page renders text authored by a language model and +by a broker; with no `", "\">", + "injected", "bold", +]) +def test_decision_data_is_escaped_not_injected(payload): + page = dash.build([decision(symbol=payload)], simulated=True) + assert payload not in page + assert "<" in page or """ in page or ">" in page + + +def test_a_hostile_ai_reasoning_string_cannot_inject_markup(): + """Model output is the least trusted text on the page.""" + hostile = decision(ai_review={"vetoed": True, "judge": { + "verdict": "VETO", "reasoning": ""}}) + page = dash.build([hostile], simulated=True) + assert "" not in page + + +def test_a_hostile_check_rule_name_cannot_inject_markup(): + hostile = decision(risk_gate={"verdict": "PASS", "checks": [ + {"rule": "", "passed": True, "observed": 1}]}) + page = dash.build([hostile], simulated=True) + assert "" not in page + + +def test_the_page_is_self_contained_and_has_no_javascript(): + """No CDN, no external script, no network — a judge just opens the file. + + The absence of any "]}})], + simulated=True) + assert "" not in page + + +# ============================================ evidence + +def test_every_risk_check_appears_as_verifiable_evidence(): + page = dash.build([decision()], simulated=True) + assert "Evidence & provenance" in page + assert "chk-00" in page + + +def test_evidence_states_plainly_that_no_news_layer_exists(): + """A fabricated source count would be worse than an honest absence.""" + page = dash.build([decision()], simulated=True) + assert "No news, sentiment or fundamental evidence layer exists" in page + + +def test_evidence_shows_what_was_observed_not_just_a_verdict(): + page = dash.build([decision(risk_gate={"verdict": "PASS", "checks": [ + {"rule": "spread", "passed": True, "observed": 0.0123}]})], simulated=True) + assert "0.0123" in page + + +def test_a_hostile_check_rule_cannot_inject_through_the_evidence_table(): + page = dash.build([decision(risk_gate={"verdict": "PASS", "checks": [ + {"rule": "", "passed": True, + "observed": 1}]})], simulated=True) + assert "ask" in page + + +def test_max_loss_is_shown_all_in_and_labelled_exact(): + page = dash.build([_opt_decision()], simulated=True) + assert "962.40" in page, "premium plus fees, not premium alone" + assert "EXACT" in page and "ESTIMATED" in page + + +def test_max_profit_is_never_printed_as_a_number(): + """A big figure beside an exact max loss invites a false expectation.""" + page = dash.build([_opt_decision()], simulated=True) + assert "unbounded for a long call" in page + assert "Max profit:" in page + + +def test_the_panel_explains_why_this_contract_was_selected(): + page = dash.build([_opt_decision()], simulated=True) + assert "Selected because:" in page + assert "nearest-the-money" in page + assert "3 contract(s) considered" in page + + +def test_thin_open_interest_is_flagged_rather_than_shown_bare(): + d = _opt_decision() + d["options_trace"]["contract"]["open_interest"] = 12 + assert "thin" in dash.build([d], simulated=True) + + +def test_a_decision_with_no_contract_renders_no_options_panel(): + page = dash.build([decision(options_trace=None)], simulated=True) + assert "WHY THIS CONTRACT" not in page + + +def test_a_hostile_selection_reason_cannot_inject_markup(): + d = _opt_decision() + d["options_trace"]["selection"]["reason"] = "" + assert "" not in dash.build([d], simulated=True) + + +# ============================================ why / why-not cards + +def test_an_authorized_decision_gets_a_why_this_trade_card(): + page = dash.build([_opt_decision()], simulated=True) + assert "WHY THIS TRADE?" in page + assert "AUTHORIZED by the deterministic layer, not by the AI" in page + + +def test_a_rejected_decision_gets_a_why_not_trade_card_naming_the_blocker(): + page = dash.build([decision(state="REJECTED", risk_gate={ + "verdict": "REJECT", + "checks": [{"rule": "portfolio_heat", "passed": False, "observed": 0.9}]})], + simulated=True) + assert "WHY NOT TRADE?" in page + assert "EXECUTION BLOCKED" in page + assert "PORTFOLIO_HEAT" in page + + +def test_a_veto_card_attributes_the_block_to_the_advisory_layer(): + page = dash.build([decision(state="REJECTED", ai_review={ + "vetoed": True, "judge": {"verdict": "VETO", "reasoning": "r"}})], + simulated=True) + assert "WHY NOT TRADE?" in page + assert "advisory layer" in page + + +def test_the_card_counts_evidence_both_ways(): + page = dash.build([decision(risk_gate={"verdict": "PASS", "checks": [ + {"rule": "a", "passed": True, "observed": 1}, + {"rule": "b", "passed": False, "observed": 2}]})], simulated=True) + assert "supporting" in page and "contradicting" in page + + +def test_the_new_panels_still_add_no_javascript(): + page = dash.build([_opt_decision()], simulated=False, account=ACCOUNT, + intents=[{"client_order_id": "x", "phase": "submitted"}]) + for bad in (" CandidateSignal: + now = utcnow() + atr = 2.85 + entry = 231.40 + d = dict( + signal_id=new_id(IdKind.SIGNAL), + snapshot_id=new_id(IdKind.SNAPSHOT), + symbol="AAPL", + direction=Direction.BUY, + strategy_id="S7", + entry=entry, + stop_loss=entry - 1.5 * atr, + take_profit=entry + 3.0 * atr, + stop_distance=1.5 * atr, + reward_risk=2.0, + atr_at_signal=atr, + base_score=50.0, + bonus=8.0, + total_score=58.0, + expected_value=0.75, + ev_is_bootstrap=True, + combined_priority=54.4, + regime=MarketRegime.WEAK_UP, + expires_at=expires_at(now, 30), + ) + d.update(over) + return CandidateSignal(**d) + + +def make_account(**over) -> AccountState: + d = dict(balance=100_000.0, equity=100_000.0, day_start_equity=100_000.0, + equity_high_water=100_000.0) + d.update(over) + return AccountState(**d) + + +ENGINE = DeterministicRiskEngine(CFG) + + +# ---------------------------------------------------------------- happy path + +def test_clean_signal_passes_and_sizes(): + r = ENGINE.evaluate(signal=make_signal(), account=make_account(), + portfolio=PortfolioState()) + assert r.verdict is RiskGateVerdict.PASS + # 1% of 100k = $1000 risk / $4.275 stop = 233 shares + assert r.approved_quantity == 233 + assert r.failed_checks == [] + + +# ------------------------------------------------------- the golden rule §54 + +def test_rejects_on_heat_even_with_perfect_signal(): + """§54: the engine may reject when every agent said BUY.""" + positions = [ + # qty 1000 x $2.00 stop = $2,000 = 2.0% of a $100k account + OpenPosition(symbol=s, side=Direction.BUY, quantity=1000, + entry_price=100.0, stop_loss=98.0) + for s in ("MSFT", "NVDA", "AMD", "TSLA") # = 8.0% heat + ] + r = ENGINE.evaluate(signal=make_signal(total_score=99.0), + account=make_account(), + portfolio=PortfolioState(positions=positions)) + assert r.verdict is RiskGateVerdict.REJECT + assert "heat" in r.blocking_reason.lower() + assert r.portfolio_heat_pct == pytest.approx(8.0) + + +def test_all_checks_recorded_not_short_circuited(): + """Our one change from the source: the full audit trail must survive.""" + r = ENGINE.evaluate(signal=make_signal(total_score=10.0), + account=make_account(manually_paused=True), + portfolio=PortfolioState()) + assert r.verdict is RiskGateVerdict.REJECT + assert len(r.checks) > 10 # everything evaluated + assert len(r.failed_checks) >= 2 # pause AND score both recorded + assert r.blocking_reason == "manually paused" # first in original order wins + + +# ---------------------------------------------------------------- each rule + +@pytest.mark.parametrize("kwargs,expect", [ + (dict(manually_paused=True), "manually paused"), + (dict(halted_daily=True), "daily loss limit"), + (dict(halted_weekly=True), "weekly loss limit"), + (dict(profit_locked=True), "daily profit lock"), + (dict(health_ok=False), "health watchdog"), + (dict(consecutive_losses=6), "consec-loss cooldown"), +]) +def test_account_halts_block(kwargs, expect): + r = ENGINE.evaluate(signal=make_signal(), account=make_account(**kwargs), + portfolio=PortfolioState()) + assert r.verdict is RiskGateVerdict.REJECT + assert r.blocking_reason == expect + + +def test_score_below_minimum_blocks(): + r = ENGINE.evaluate(signal=make_signal(total_score=47.9), account=make_account(), + portfolio=PortfolioState()) + assert r.verdict is RiskGateVerdict.REJECT + assert r.blocking_reason == "score below min" + + +def test_negative_ev_blocks(): + r = ENGINE.evaluate(signal=make_signal(expected_value=-0.1), account=make_account(), + portfolio=PortfolioState()) + assert r.verdict is RiskGateVerdict.REJECT + assert r.blocking_reason == "non-positive EV" + + +def test_bootstrap_ev_is_flagged_not_hidden(): + """Honesty check: a bootstrap EV passes but says so in the audit trail.""" + r = ENGINE.evaluate(signal=make_signal(), account=make_account(), + portfolio=PortfolioState()) + ev = next(c for c in r.checks if c.rule == "positive_ev") + assert ev.passed and "bootstrap" in ev.reason + + +def test_duplicate_position_blocks(): + p = PortfolioState(positions=[ + OpenPosition(symbol="AAPL", side=Direction.BUY, quantity=10, + entry_price=230.0, stop_loss=228.0, strategy_id="S7") + ]) + r = ENGINE.evaluate(signal=make_signal(), account=make_account(), portfolio=p) + assert r.verdict is RiskGateVerdict.REJECT + assert "already open" in r.blocking_reason + + +def test_expired_signal_blocks(): + old = utcnow() - timedelta(seconds=120) + r = ENGINE.evaluate(signal=make_signal(expires_at=expires_at(old, 30)), + account=make_account(), portfolio=PortfolioState()) + assert r.verdict is RiskGateVerdict.REJECT + assert r.blocking_reason == "signal expired" + + +def test_wide_spread_and_large_gap_block(): + for kw, expect in ((dict(spread_pct=0.5), "spread too wide"), + (dict(gap_pct=-5.0), "overnight gap too large")): + r = ENGINE.evaluate(signal=make_signal(), account=make_account(), + portfolio=PortfolioState(), **kw) + assert r.verdict is RiskGateVerdict.REJECT + assert r.blocking_reason == expect + + +def test_correlated_same_direction_blocks(): + import math + base = [100 * math.exp(0.01 * i) for i in range(40)] + p = PortfolioState(positions=[ + OpenPosition(symbol="MSFT", side=Direction.BUY, quantity=10, + entry_price=400.0, stop_loss=396.0) + ]) + r = ENGINE.evaluate(signal=make_signal(), account=make_account(), portfolio=p, + closes_by_symbol={"AAPL": base, "MSFT": base}) + assert r.verdict is RiskGateVerdict.REJECT + assert "correlated" in r.blocking_reason + + +# ------------------------------------------------------------- REDUCE path + +def test_heat_above_reduce_level_halves_size(): + """§53 REDUCE: heat between 6% and 8% must size down, not reject.""" + positions = [ + # qty 1000 x $2.50 stop = $2,500 = 2.5% each -> 7.5% total, + # which sits between heat_reduce_level (6%) and heat_max (8%) + OpenPosition(symbol=s, side=Direction.BUY, quantity=1000, + entry_price=100.0, stop_loss=97.5) + for s in ("MSFT", "NVDA", "AMD") + ] + r = ENGINE.evaluate(signal=make_signal(), account=make_account(), + portfolio=PortfolioState(positions=positions)) + assert r.verdict is RiskGateVerdict.REDUCE + assert r.size_multiplier == 0.5 + assert r.approved_quantity == 116 # half of 233 + + +def test_recovery_mode_halves_size(): + r = ENGINE.evaluate(signal=make_signal(), + account=make_account(recovery_mode=True), + portfolio=PortfolioState()) + assert r.verdict is RiskGateVerdict.REDUCE + assert r.size_multiplier_breakdown["recovery"] == 0.5 + + +def test_multipliers_clamp_to_floor(): + """heat 0.5 x recovery 0.5 = 0.25, exactly the floor. Must not go below.""" + positions = [ + OpenPosition(symbol=s, side=Direction.BUY, quantity=1000, + entry_price=100.0, stop_loss=97.5) + for s in ("MSFT", "NVDA", "AMD") + ] + r = ENGINE.evaluate(signal=make_signal(), + account=make_account(recovery_mode=True), + portfolio=PortfolioState(positions=positions)) + assert r.size_multiplier == 0.25 + + +# ------------------------------------------------------------ measures unit + +def test_position_without_stop_counts_as_full_risk(): + """Bot v6 PositionRiskPct returns InpRiskPerTrade when sl<=0. Assuming zero + would understate exposure, which is the dangerous direction.""" + p = PortfolioState(positions=[ + OpenPosition(symbol="X", side=Direction.BUY, quantity=10, entry_price=100.0) + ]) + assert portfolio_heat_pct(p, make_account(), 1.0) == 1.0 + + +def test_sizing_never_exceeds_risk_budget(): + for stop in (0.01, 0.5, 4.275, 50.0, 300.0): + s = size_position(account=make_account(), portfolio=PortfolioState(), + stop_distance=stop, stats=StrategyStats(strategy_id="S7"), + cfg=CFG) + assert s.risk_amount <= 100_000 * 0.01 * 1.01 + 1e-6 + + +def test_kelly_stays_shadow_until_layer_active(): + stats = StrategyStats(strategy_id="S7", trades=100, win_rate=0.6, + avg_win=2.0, avg_loss=1.0) + shadow = size_position(account=make_account(), portfolio=PortfolioState(), + stop_distance=4.275, stats=stats, cfg=CFG) + live = size_position(account=make_account(kelly_layer_active=True), + portfolio=PortfolioState(), stop_distance=4.275, + stats=stats, cfg=CFG) + assert "kelly" not in shadow.breakdown + assert live.breakdown["kelly"] > 1.0 + + +def test_correlation_matches_known_values(): + a = [1.0, 2.0, 3.0, 4.0, 5.0] + assert pearson_correlation(a, a) == pytest.approx(1.0) + assert pearson_correlation(a, list(reversed(a))) == pytest.approx(-1.0) + assert pearson_correlation([1.0, 2.0], [1.0, 2.0]) == 0.0 # n<5 guard + + +# ------------------------------------------------------------ fail closed + +def test_engine_refuses_fail_open_config(): + with pytest.raises(RiskEngineError): + DeterministicRiskEngine({**CFG, "fail_closed": False}) + + +def test_determinism(): + """Same inputs, same verdict — 50 times. This is the property the pitch claims.""" + sig, acc, pf = make_signal(), make_account(), PortfolioState() + results = {(r.verdict, r.approved_quantity, r.size_multiplier) + for r in (ENGINE.evaluate(signal=sig, account=acc, portfolio=pf) + for _ in range(50))} + assert len(results) == 1 + + +# ================================================================ sector cap +# The sector cap is the equity analogue of Bot v6's CurrencyExposure() and is the +# only exposure rule that was translated rather than ported. Until now it had no +# test at all, so nothing proved the translated rule actually blocks anything. + +def _tech(symbol: str, sector: str | None = "Technology") -> OpenPosition: + """qty 1000 x $2.00 stop = $2,000 = 2.0% of a $100k account.""" + return OpenPosition(symbol=symbol, side=Direction.BUY, quantity=1000, + entry_price=100.0, stop_loss=98.0, sector=sector) + + +def test_sector_exposure_cap_blocks(): + # 3 x 2.0% = 6.0% already in Technology, + 1.0% for this trade = 7.0% > 6.0% + pf = PortfolioState(positions=[_tech(s) for s in ("MSFT", "NVDA", "AMD")]) + r = ENGINE.evaluate(signal=make_signal(), account=make_account(), + portfolio=pf, sector="Technology") + assert r.verdict is RiskGateVerdict.REJECT + assert "sector exposure" in r.blocking_reason.lower() + assert "Technology" in r.blocking_reason + + +def test_sector_exposure_counts_only_the_matching_sector(): + """An Energy book must not consume the Technology budget.""" + pf = PortfolioState(positions=[_tech(s, "Energy") for s in ("XOM", "CVX", "COP")]) + r = ENGINE.evaluate(signal=make_signal(), account=make_account(), + portfolio=pf, sector="Technology") + assert r.verdict is not RiskGateVerdict.REJECT + sec = [c for c in r.checks if c.rule == "sector_exposure"][0] + assert sec.passed and sec.observed == pytest.approx(1.0) # this trade only + + +def test_sector_check_absent_when_sector_unknown(): + """No sector supplied means the rule cannot be evaluated, so it is not + recorded as a silent pass — it is simply not among the checks.""" + r = ENGINE.evaluate(signal=make_signal(), account=make_account(), + portfolio=PortfolioState(), sector=None) + assert [c for c in r.checks if c.rule == "sector_exposure"] == [] + + +def test_sector_exposure_pct_treats_an_unstopped_position_as_full_risk(): + """Mirrors PositionRiskPct(): no stop is assumed to be a full unit of risk, + never zero. Assuming zero is the dangerous direction.""" + pf = PortfolioState(positions=[ + OpenPosition(symbol="MSFT", side=Direction.BUY, quantity=1000, + entry_price=100.0, stop_loss=None, sector="Technology"), + ]) + exp = sector_exposure_pct(pf, make_account(), "Technology", 1.0) + assert exp == pytest.approx(1.0) # substituted, not NaN, not 0.0 + assert exp == exp # not NaN + + +def test_sector_exposure_pct_is_zero_without_a_sector(): + assert sector_exposure_pct(PortfolioState(positions=[_tech("MSFT")]), + make_account(), None, 1.0) == 0.0 + + +# ====================================================== size rounds to zero +# A sub-one-share result must REJECT, not silently submit a 0-share order. + +def test_size_rounding_to_zero_shares_is_rejected(): + # 1% of $400 = $4.00 risk budget / $4.275 stop = 0.94 shares -> floor 0 + tiny = make_account(balance=400.0, equity=400.0, + day_start_equity=400.0, equity_high_water=400.0) + r = ENGINE.evaluate(signal=make_signal(), account=tiny, + portfolio=PortfolioState()) + assert r.verdict is RiskGateVerdict.REJECT + assert "zero shares" in r.blocking_reason + assert r.approved_quantity is None or r.approved_quantity == 0 + + +def test_zero_share_rejection_is_recorded_as_a_check_not_just_a_reason(): + tiny = make_account(balance=400.0, equity=400.0, + day_start_equity=400.0, equity_high_water=400.0) + r = ENGINE.evaluate(signal=make_signal(), account=tiny, + portfolio=PortfolioState()) + q = [c for c in r.checks if c.rule == "computed_quantity"] + assert len(q) == 1 and not q[0].passed + + +# ========================================================= revalidation §57 +# The Portfolio Manager may modify a proposal, and every modification must come +# back through this engine. These tests pin the property that makes that safe. + +def test_revalidate_applies_the_identical_rule_set(): + sig, acc, pf = make_signal(), make_account(), PortfolioState() + a = ENGINE.evaluate(signal=sig, account=acc, portfolio=pf) + b = ENGINE.revalidate(signal=sig, account=acc, portfolio=pf) + assert (a.verdict, a.approved_quantity, a.size_multiplier) == \ + (b.verdict, b.approved_quantity, b.size_multiplier) + assert [c.rule for c in a.checks] == [c.rule for c in b.checks] + + +def test_revalidate_rejects_what_evaluate_rejects(): + """Revalidation is not a second chance. A breach stays a breach.""" + pf = PortfolioState(positions=[_tech(s) for s in ("MSFT", "NVDA", "AMD")]) + kw = dict(signal=make_signal(), account=make_account(), + portfolio=pf, sector="Technology") + assert ENGINE.evaluate(**kw).verdict is RiskGateVerdict.REJECT + assert ENGINE.revalidate(**kw).verdict is RiskGateVerdict.REJECT + + +def test_revalidate_cannot_be_handed_a_quantity_to_approve(): + """The engine is the sole sizing authority: there is no parameter through + which a caller can propose, request or inject an approved quantity.""" + kw = dict(signal=make_signal(), account=make_account(), + portfolio=PortfolioState()) + with pytest.raises(TypeError): + ENGINE.revalidate(**kw, approved_quantity=10_000) + with pytest.raises(TypeError): + ENGINE.revalidate(**kw, quantity=10_000) + + +def test_revalidate_after_a_portfolio_change_reflects_the_new_portfolio(): + """The PM cannot revalidate against the portfolio the proposal was sized on.""" + sig, acc = make_signal(), make_account() + assert ENGINE.evaluate(signal=sig, account=acc, + portfolio=PortfolioState()).verdict is RiskGateVerdict.PASS + filled_up = PortfolioState(positions=[_tech(s) for s in + ("MSFT", "NVDA", "AMD", "TSLA")]) + assert ENGINE.revalidate(signal=sig, account=acc, + portfolio=filled_up).verdict is RiskGateVerdict.REJECT diff --git a/tests/unit/test_source_integrity.py b/tests/unit/test_source_integrity.py new file mode 100644 index 0000000..da84be9 --- /dev/null +++ b/tests/unit/test_source_integrity.py @@ -0,0 +1,78 @@ +""" +Integrity of the authoritative MQL5 reference. + +docs/reference/SpeedTraderBot_v6.1.mq5 is READ-ONLY reference material and the +authoritative source for every ported Bot v6 formula. Several quant modules +record its SHA-256 in a SOURCE_HASH constant and cite line numbers against it. + +Nothing previously verified that those constants still describe the file that is +actually on disk. Two failures were therefore silent: + + 1. the reference is edited — every "ported from L1043" citation in the codebase + silently starts pointing at different source lines, and the port can no + longer be audited against anything; + 2. a module's SOURCE_HASH drifts from the file — the provenance claim becomes + decorative. + +§47 classes any change to the reference as a serious integrity issue, so it is +asserted here rather than trusted. +""" +from __future__ import annotations + +import hashlib +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) + +import pytest # noqa: E402 + +from speedtrader.quant import expected_value, scoring # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +REFERENCE = ROOT / "docs" / "reference" / "SpeedTraderBot_v6.1.mq5" + +#: The hash this project was ported against. Changing this constant is a +#: deliberate act that must accompany a re-audit of every ported formula — it is +#: never the correct way to make this test pass. +EXPECTED_SHA256 = "c799acaa797a4f23a8c9531c3b4f14599b73736af2151d9eeb7f42209332e8d9" + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_reference_source_is_present(): + assert REFERENCE.is_file(), ( + f"the authoritative MQL5 reference is missing at {REFERENCE}. " + "Every ported formula cites it; the port is unauditable without it." + ) + + +def test_reference_source_is_unmodified(): + actual = _sha256(REFERENCE) + assert actual == EXPECTED_SHA256, ( + "docs/reference/SpeedTraderBot_v6.1.mq5 has changed.\n" + f" expected {EXPECTED_SHA256}\n" + f" actual {actual}\n" + "This file is READ-ONLY reference material. Do not update the constant to " + "silence this test: every ported formula and every L#### citation in the " + "quant modules was written against the expected hash and must be re-audited." + ) + + +@pytest.mark.parametrize("module", [expected_value, scoring], + ids=lambda m: m.__name__.rsplit(".", 1)[-1]) +def test_module_source_hash_matches_the_reference_on_disk(module): + """A provenance constant that disagrees with the file is worse than none.""" + assert module.SOURCE_HASH == EXPECTED_SHA256, ( + f"{module.__name__}.SOURCE_HASH claims provenance from a different " + "revision of the MQL5 source than the one in docs/reference/." + ) + + +def test_reference_is_not_empty_or_truncated(): + """A zero-length or truncated file would still hash consistently if the + constant were updated to match, so size is asserted independently.""" + size = REFERENCE.stat().st_size + assert size > 50_000, f"reference looks truncated: {size} bytes"