diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53e36a4..465b17e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,12 @@ jobs: - run: uv run ruff check . - run: uv run basedpyright - run: uv run pytest -q + - run: python3 -m json.tool package.json >/dev/null + - run: bash -n scripts/install.sh scripts/uninstall.sh scripts/final_smoke.sh + - name: Install bootstrap dry-run + run: | + runtime_dir="$(mktemp -d)" + trap 'rm -rf "$runtime_dir"' EXIT + bash scripts/install.sh --yes --paper --testnet --dry-run --runtime-dir "$runtime_dir/runtime" + bash scripts/uninstall.sh --yes --dry-run --runtime-dir "$runtime_dir/runtime" + bash scripts/uninstall.sh --purge --yes --dry-run --runtime-dir "$runtime_dir/runtime" diff --git a/.gitignore b/.gitignore index ff97bce..47613db 100644 --- a/.gitignore +++ b/.gitignore @@ -11,13 +11,26 @@ .ruff_cache/ .runtime/ .venv/ +node_modules/ __pycache__/ test-results/ +playwright-report/ +browser-traces/ build/ dist/ htmlcov/ *.egg-info/ *.py[cod] -*.sqlite3 +*.sqlite* +*.db *.log -.omo/evidence/ +*.har +*.trace +*.webm +support-bundle*.zip +support-bundle*.tar +support-bundle*.tar.gz +support-bundle*.tgz +data/ +logs/ +.omo/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..10066cf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,104 @@ +# PROJECT KNOWLEDGE BASE + +**Generated:** 2026-06-13T01:24:33+09:00 +**Commit:** a0a0630 +**Branch:** main + +## OVERVIEW + +NFI Engine is an original Python 3.12 crypto trading engine for paper/testnet +operation, deterministic simulation, backtesting, and NFI-shaped strategy +compatibility research. Freqtrade and NostalgiaForInfinity are behavior +references only; do not copy code, UI, wording, or distinctive design. + +## STRUCTURE + +```text +nfi_engine/ +|-- src/nfi_engine/ # engine package: CLI, API, domain, trading services +|-- tests/ # unit, integration, e2e, and canonical fixtures +|-- docs/ # operator, safety, Docker, UI, compatibility rules +|-- examples/ # spot/futures paper configs +|-- scripts/ # install, uninstall, smoke, benchmark, evidence tools +|-- .omo/ # plans, evidence, workflow ledger; generated-heavy +|-- Dockerfile +|-- compose.yaml +`-- pyproject.toml +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +| --- | --- | --- | +| First run | `README.md`, `docs/docker.md`, `scripts/install.sh` | Docker-first local paper/testnet path. | +| CLI entry | `src/nfi_engine/cli.py`, `src/nfi_engine/cli_*.py` | Root Typer app fans out by command group. | +| Runtime config | `src/nfi_engine/config/` | Pydantic settings plus custom loader/env overrides. | +| API app | `src/nfi_engine/api/app.py`, `api/routes.py` | FastAPI factory and route wiring. | +| Operator UI | `src/nfi_engine/ui/`, `docs/ui.md` | Local HTML/CSS/JS console, no CDN. | +| Trading model | `src/nfi_engine/domain/`, `risk/`, `safety/` | Typed market/order/risk boundaries. | +| Backtests | `src/nfi_engine/backtest/`, `validation/` | Deterministic outputs and reproducibility metadata. | +| Paper runtime | `src/nfi_engine/paper/`, `exchange/` | Tick-driven loop, simulator/testnet boundary. | +| Storage | `src/nfi_engine/persistence/`, `maintenance/` | SQLite repositories, migrations, backups. | +| Tests | `tests/unit`, `tests/integration`, `tests/e2e` | Layered tests with strict pytest config. | +| Evidence | `.omo/evidence/` | Manual QA, smoke, benchmark, and plan evidence. | + +## CODE MAP + +| Symbol | Type | Location | Role | +| --- | --- | --- | --- | +| `main` | function | `src/nfi_engine/cli.py` | CLI console-script entry. | +| `create_app` | function | `src/nfi_engine/api/app.py` | Builds the FastAPI app and UI/API wiring. | +| `RuntimeSettings` | class | `src/nfi_engine/config/models.py` | Root Pydantic runtime model. | +| `create_order_intent` | function | `src/nfi_engine/domain/orders.py` | Typed order-intent construction. | +| `run_backtest` | function | `src/nfi_engine/backtest/runner.py` | Deterministic backtest loop. | +| `run_paper` | function | `src/nfi_engine/paper/runner.py` | Paper-run event loop. | +| `render_home_page` | function | `src/nfi_engine/ui/pages.py` | First operator surface renderer. | +| `PersistenceDatabase` | class | `src/nfi_engine/persistence/session.py` | Async SQLAlchemy database wrapper. | + +## CONVENTIONS + +- Use `uv`; the quality gate is `uv run ruff format --check .`, + `uv run ruff check .`, `uv run basedpyright`, `uv run pytest -q`. +- Python is 3.12, `basedpyright` is strict, `ruff` selects `ALL`, and warnings are pytest errors. +- Keep config parsing at the edge. Pass typed Pydantic/domain values into services, not raw YAML dictionaries. +- Use Polars or local typed structures for new engine data. Do not introduce pandas outside compatibility adapters. +- User-visible, safety, runtime, UI, Docker, or performance work needs manual evidence under `.omo/evidence/`. +- Keep public wording precise: feature benchmark/inspiration is acceptable; clone, parity, and profit claims are not. +- When the user writes Korean, answer concisely in Korean unless code or repo text needs English. + +## ANTI-PATTERNS + +- Do not copy Freqtrade, FreqUI, NostalgiaForInfinity source, docs prose, layout, colors, or strategy internals. +- Do not add real-money execution, live shortcuts, public-profit claims, or live-order bypasses in milestone work. +- Do not expose the operator API publicly by default. Preserve loopback binding + unless a hardened deployment task exists. +- Do not commit secrets, runtime `.env`, API tokens, exchange keys, SQLite + runtime data, logs, caches, or `.omo/evidence` artifacts. +- Do not let UI code reach directly into storage rows, raw config dictionaries, or safety internals. +- Do not bypass auth, CSRF, read-only mode, sandbox checks, plugin allowlists, + circuit breakers, reconciliation, or dry-run previews. +- Do not create large files casually; the project plan treats 250 pure LOC as a split pressure point. + +## COMMANDS + +```bash +uv sync +uv run nfi-engine --help +uv run nfi-engine config validate --config examples/futures-paper.yaml +uv run nfi-engine preflight check --profile local-paper --config examples/spot-paper.yaml +uv run nfi-engine serve --config examples/futures-paper.yaml --host 127.0.0.1 --port 18080 +bash scripts/install.sh --yes --paper --testnet +bash scripts/uninstall.sh --yes +bash scripts/final_smoke.sh +python3 scripts/verify_plan_evidence.py .omo/plans/nfi-engine.md .omo/evidence +``` + +## NOTES + +- No in-repo `AGENTS.md` existed before this init pass. +- The worktree often contains active `.omo` plans/evidence and user edits. Do + not clean, revert, or normalize unrelated files. +- `compose.yaml` may contain `0.0.0.0` inside container allowlists; host + publishing must remain loopback unless intentionally hardened. +- Treat `.omo/plans/nfi-engine.md` as the durable original plan; older drafts or + generated evidence are not source-of-truth docs. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..8a74f1f --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,50 @@ +# NFI Engine UI Design System + +## Product Surface + +NFI Engine is a local operator console for paper/testnet trading research. It is +not a public marketing site. The first screen must stay operational, dense, and +quiet enough for repeated checks. + +## Layout + +- Main content is constrained to `1160px` with `24px` desktop padding and `16px` + mobile padding. +- Home uses a compact status strip and a two-column dashboard grid that + collapses to one column below `780px`. +- Sections are bordered operational panels with `6px` radius; avoid nested + cards and decorative wrappers. + +## Color + +- Background: `#f5f7f6` +- Panel: `#ffffff` +- Text: `#17201d` +- Muted text: `#5b6863` +- Border: `#ccd6d1` +- Accent: `#0f766e` +- Danger: `#b42318` +- Warning: `#9a6700` + +## Typography + +Use the existing system font stack from `src/nfi_engine/ui/assets.py`. +Headings are compact: `24px` for the page title and `15px` for panel headings. +Letter spacing stays `0`. + +## Controls + +Buttons, inputs, and selects use `5px` radius, local CSS only, and stable +minimum heights. Disabled controls are visual hints only; server-side guards +remain authoritative. + +## Localization + +Visible operator text must use typed i18n keys for English, Korean, and Greek. +Machine codes, contract ids, API field names, strategy tags, and evidence paths +stay untranslated. + +## Safety Copy + +UI text may say paper/testnet-ready or gated when the checks support it. It must +not claim live readiness, strategy parity, profit, or superiority. diff --git a/README.md b/README.md index 139be10..0192439 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,10 @@ home dashboard, local chart snapshots, English/Korean/Greek UI text, one-command Docker install/uninstall, benchmark evidence, and a release gate that proves the local operator flow before it is called shippable. See [docs/freqtrade-feature-coverage.md](docs/freqtrade-feature-coverage.md) for the -clean-room feature map and NFI Engine differentiation rules. +clean-room feature map and NFI Engine differentiation rules. The current S1 +product boundary is split into +[docs/nfi-x7-compatibility.md](docs/nfi-x7-compatibility.md) and +[docs/exchange-support-matrix.md](docs/exchange-support-matrix.md). ## Quickstart @@ -27,27 +30,55 @@ Docker is the primary first-run path. From a local checkout: bash scripts/install.sh --yes --paper --testnet ``` +The same one-line installer is exposed through npm and Bun wrappers for +operators who prefer a package-manager command: + +```bash +npm run nfi:install +bun run nfi:install +``` + +Dry-run receipts are available before starting Docker: + +```bash +bash scripts/install.sh --yes --paper --testnet --dry-run +npm run nfi:install:dry-run +bun run nfi:install:dry-run +``` + First Run: 1. Open `http://127.0.0.1:18080/`. 2. Paste the local operator token from `.runtime/docker.env` into the login screen. The installer prints `login_token_file=.runtime/docker.env`, never the token value. -3. Use Home to check Setup Doctor, Safety Explainer, chart status, recent errors, and pairlist state. -4. Use Settings for the small Simple Mode form and setup preview. API key and API secret fields are write-only and redacted from output. -5. Use the language selector for English, Korean, and Greek UI text. -6. Use Logs to export a redacted support report when an error code appears. +3. Use Home to check the operator cockpit, Setup Doctor, Safety Explainer, chart status, runtime controls, recent errors, and pairlist state. +4. Use Settings for first-run setup: exchange, exchange API key, exchange API secret, API permission audit, recommended 3x leverage, risk profile, explicit wallet balance fetch, allocated amount, futures/spot, and dry-run/live. +5. Keep dry-run selected unless the live confirmation path is intentionally being tested. Withdrawal-like API permission blocks live setup, expert risk requires explicit confirmation, and API key/secret fields are write-only and redacted from output. +6. Use the language selector for English, Korean, and Greek UI text. +7. Use Logs to export a redacted support report when an error code appears. Useful first checks: ```bash curl -i http://127.0.0.1:18080/api/v1/ping bash scripts/uninstall.sh --yes +npm run nfi:uninstall +bun run nfi:uninstall +``` + +Native X7 semantic-runtime inspection is available on the dry-run/paper/testnet +path without vendoring upstream strategy code: + +```bash +uv run nfi-engine strategy inspect --config examples/x7-futures-paper.yaml --strategy nfi_engine.strategy.nfi_x7:X7NativeStrategy --json ``` Safe uninstall preserves generated runtime files and data volumes. Destructive -purge is explicit: +purge is explicit; npm and Bun expose only the purge preview: ```bash bash scripts/uninstall.sh --purge --yes +npm run nfi:uninstall:purge:dry-run +bun run nfi:uninstall:purge:dry-run ``` Do not enter real exchange credentials into issues, chat logs, committed files, @@ -82,6 +113,8 @@ storage. ```bash bash scripts/install.sh --yes --paper --testnet +npm run nfi:install +bun run nfi:install curl -i http://127.0.0.1:18080/api/v1/ping bash scripts/uninstall.sh --yes ``` @@ -155,11 +188,15 @@ uv run nfi-engine backup restore --dry-run .omo/evidence/backup.zip Exchange and market checks: ```bash +uv run nfi-engine exchange capabilities --exchange bybit --trading-mode futures --format json uv run nfi-engine exchange reconcile --config examples/futures-paper.yaml --dry-run --fixture tests/fixtures/exchange/reconcile_match.json uv run nfi-engine pairlist validate --config examples/futures-paper.yaml --output .omo/evidence/pairlist.json uv run nfi-engine simulate fills --scenario tests/fixtures/simulator/partial_fill_latency.yaml --output .omo/evidence/fill-sim.json ``` +`exchange capabilities` labels registry profiles and report-only generic ids +without promoting unknown exchanges into config, paper/testnet, or live paths. + See [docs/operations.md](docs/operations.md), [docs/backup.md](docs/backup.md), [docs/reconciliation.md](docs/reconciliation.md), [docs/pairlist.md](docs/pairlist.md), and [docs/simulator.md](docs/simulator.md). @@ -168,10 +205,13 @@ and [docs/simulator.md](docs/simulator.md). The console is a local operator surface, not a public dashboard. -- `/settings`: config metadata editor, validation, draft/apply, readiness, pairlist, safety locks. +- `/`: Home cockpit with setup readiness, runtime health, pause/resume/stop-safe controls, wallet balance state, action queue, local chart, safety, and support shortcuts. +- `/settings`: first-run setup wizard, config metadata editor, validation, draft/apply, readiness, pairlist, update states, safety locks. +- first-run setup wizard: exchange credentials, API permission audit, 3x recommendation, risk profile, explicit wallet balance fetch, amount, futures/spot, dry-run/live, redacted preview. +- update state panel: local-safe preview/apply/rollback states for future engine+strategy update flow. - `/logs`: recent logs, severity filter, error-code lookup, correlation IDs, support report export. -- Read-only mode: settings/logs/pairlists are inspectable, while save/apply/restore/start/stop are disabled and server-blocked. -- Security: authenticated session cookie, CSRF token for mutations, logout, expiry, protected audit log, no browser token storage. +- Read-only mode: settings/logs/pairlists are inspectable, while save/apply/restore/start/pause/resume/stop are disabled and server-blocked. +- Security: authenticated session cookie, CSRF token for mutations, protected wallet fetch and runtime health JSON, logout, expiry, protected audit log, no browser token storage. See [docs/ui.md](docs/ui.md). @@ -181,6 +221,10 @@ Freqtrade remains the functional benchmark, but NFI Engine must stay original in design and implementation. Each broad feature category is tracked with an explicit NFI Engine angle in [docs/freqtrade-feature-coverage.md](docs/freqtrade-feature-coverage.md). +The clean-room NFI X7 target facts live in +[docs/nfi-x7-compatibility.md](docs/nfi-x7-compatibility.md), while exchange +support is separated into `candidate`, `verified`, and `generic-unverified` +levels in [docs/exchange-support-matrix.md](docs/exchange-support-matrix.md). ## Evidence And Quality Gates @@ -188,19 +232,48 @@ Use the smoke harness to refresh final evidence files: ```bash bash scripts/final_smoke.sh +uv run python scripts/release_wording_scan.py ``` -The current M2 hardening evidence root is: +The current operator and X7 semantic-runtime evidence roots are: ```text .omo/evidence/2026-06-12-dev-entry/ +.omo/evidence/2026-06-20-nfi-x7-semantic-port/ +.omo/evidence/2026-06-21-x7-live-readiness-pi4-rc/ ``` +The 2026-06-21/22 RC root is the current paper/testnet release-candidate lane. +It records X7 semantic inspection, exchange/wallet gates, order-lifecycle +testnet boundaries, runtime-control safety, browser workflow evidence, update +rollback proof, Pi4 install/soak/deploy receipts, and the T5A Pi4 X7 benchmark +budget resolution. The Pi4 evidence keeps `claim_allowed=false`; use it for +internal RC gating, not public speed or live-money wording. + +The current dated status summary is maintained in +[docs/release-status.md](docs/release-status.md). It separates completed, +partial, blocked, and next work for the current RC lane. + The release gate includes Docker install/login/Home smoke, local benchmark JSON, -a supplied-baseline regression failure check, and desktop/mobile browser -evidence for Home, Settings, Logs, and the login path. Benchmark regression -blocking only applies when `--baseline` is supplied; first-run smoke validates -the generated local report without claiming public speed superiority. +a supplied-baseline regression failure check, install/uninstall dry-run receipts, +package-wrapper bootstrap smoke, and desktop/mobile browser +evidence for Home, Settings, Logs, the login path, X7 strategy inspection, and +the deterministic release-wording scan. Benchmark regression blocking only +applies when `--baseline` is supplied; first-run smoke validates the generated +local report without claiming public speed superiority. + +Focused live-server browser QA: + +```bash +npm install +npm run nfi:browser-qa:deps +npm run nfi:browser-qa +``` + +This starts a QA-only loopback server, logs in through the real browser page, +switches Settings locale without manual F5, captures Home/Settings/Logs +desktop and mobile screenshots, and fails on external network requests or +browser token storage. Core quality gate: @@ -211,6 +284,19 @@ uv run basedpyright uv run pytest -q ``` +Scripted quality gate: + +```bash +bash scripts/quality_gate.sh --docs-only +bash scripts/quality_gate.sh --strict +bash scripts/quality_gate.sh --coverage-only +``` + +`--docs-only` is the fast default for governance and documentation edits. +`--strict` runs the full local gate above. `--coverage-only` runs a focused +coverage smoke on the config/domain unit-test slice with the current coverage +budget. + Plan evidence audit: ```bash @@ -225,12 +311,18 @@ python3 scripts/verify_plan_evidence.py .omo/plans/2026-06-12-nfi-engine-dev-ent - No public internet exposure for the operator console. - No full upstream NFI parity claim. - Exchange adapters are fixture/testnet oriented until later milestones. +- Current RC wording is paper/testnet only: live order execution remains behind + a separate design and verification milestone. ## Further Reading +- [docs/release-wording.md](docs/release-wording.md) +- [docs/release-status.md](docs/release-status.md) - [docs/docker.md](docs/docker.md) - [docs/contributing.md](docs/contributing.md) - [docs/freqtrade-feature-coverage.md](docs/freqtrade-feature-coverage.md) +- [docs/nfi-x7-compatibility.md](docs/nfi-x7-compatibility.md) +- [docs/exchange-support-matrix.md](docs/exchange-support-matrix.md) - [docs/performance.md](docs/performance.md) - [docs/plugins.md](docs/plugins.md) - [docs/reproducibility.md](docs/reproducibility.md) diff --git a/brainstorming/2026-06-14_M25_ACTION_QUEUE_IMPLEMENTATION.md b/brainstorming/2026-06-14_M25_ACTION_QUEUE_IMPLEMENTATION.md new file mode 100644 index 0000000..16e6a51 --- /dev/null +++ b/brainstorming/2026-06-14_M25_ACTION_QUEUE_IMPLEMENTATION.md @@ -0,0 +1,67 @@ +# 2026-06-14 M2.5 Action Queue Implementation + +## 한 줄 요약 + +M2.5 첫 구현 웨이브로 Home / dashboard snapshot에 **운영자용 Action Queue**를 +추가했다. 이제 대시보드가 단순히 상태를 보여주는 것에서 한 단계 나아가, +운영자가 지금 무엇을 확인해야 하는지 최대 4개 액션으로 압축해서 보여준다. + +## 구현한 것 + +- `DashboardAction` read model 추가 +- `DashboardSnapshot.actions` API 필드 추가 +- `/api/v1/dashboard/snapshot`에 bounded action queue 직렬화 추가 +- Home에 `data-testid="action-queue"` / `data-testid="action-item"` 렌더링 추가 +- ready 상태는 `#status` 실제 anchor로 연결 +- error 상태는 `/logs`로 연결 +- support follow-up은 `/api/v1/reports/support-bundle.zip` 실제 export endpoint로 연결 +- EN/KO/EL action queue heading i18n 추가 +- 모바일에서 action item 링크가 암묵적 2열을 만들지 않도록 responsive CSS 수정 + +## 액션 우선순위 + +현재 우선순위는 제품 흐름 기준으로 작게 고정했다. + +1. readiness/preflight blocked +2. recent runtime errors +3. empty pairlist +4. clean paper/testnet ready state +5. error가 있을 때 support bundle follow-up + +최대 4개까지만 반환한다. 새 polling loop나 추가 DB scan은 만들지 않았다. + +## 검증 + +로컬 evidence는 ULW 작업 디렉터리에 보관했다. GitHub에 올릴 source 문서에는 +로컬 evidence 경로를 박지 않는다. + +통과한 targeted gate: + +```bash +uv run pytest -q tests/unit/dashboard/test_snapshot.py tests/unit/ui/test_pages.py tests/e2e/test_home_ui.py tests/unit/ui/test_i18n.py +uv run ruff format --check src/nfi_engine/dashboard src/nfi_engine/api src/nfi_engine/ui tests/unit/dashboard/test_snapshot.py tests/unit/ui/test_pages.py tests/e2e/test_home_ui.py tests/unit/ui/test_i18n.py +uv run ruff check src/nfi_engine/dashboard src/nfi_engine/api src/nfi_engine/ui tests/unit/dashboard/test_snapshot.py tests/unit/ui/test_pages.py tests/e2e/test_home_ui.py tests/unit/ui/test_i18n.py +uv run basedpyright src/nfi_engine/dashboard src/nfi_engine/api src/nfi_engine/ui tests/unit/dashboard/test_snapshot.py tests/unit/ui/test_pages.py tests/e2e/test_home_ui.py tests/unit/ui/test_i18n.py +``` + +실제 surface 검증: + +- loopback 서버에서 `/api/v1/dashboard/snapshot` curl 확인 +- Playwright Chromium desktop/mobile screenshot 확인 +- Home DOM에서 action queue, setup doctor, safety explainer, chart shell, Settings/Logs nav 확인 +- `localStorage`, `sessionStorage`, `https://`, `cdn` 문자열 부재 확인 +- visual QA에서 잡힌 mobile link layout과 dead-link blocker 수정 + +## 아직 안 끝난 것 + +- First-run wizard 완료 UX +- Raspberry Pi 4 실기기 반복 측정 +- benchmark 결과의 operator-facing 요약 +- 여러 OS/환경 install/uninstall 반복 검증 +- live 전환 UX는 계속 설명/차단 중심이며, real-money execution은 아직 범위 밖 + +## 판단 + +이번 웨이브는 "운영자가 지금 뭘 해야 하는지"를 Home과 dashboard API에 박은 작업이다. +큰 기능을 벌린 게 아니라 기존 M2 표면을 제품처럼 쓰기 위한 압축도를 올린 작업이다. +쌈%뽕하지만 아직 M2.5의 첫 조각이다. diff --git a/brainstorming/2026-06-22_RC_STATUS.md b/brainstorming/2026-06-22_RC_STATUS.md new file mode 100644 index 0000000..575a873 --- /dev/null +++ b/brainstorming/2026-06-22_RC_STATUS.md @@ -0,0 +1,67 @@ +# 2026-06-22 NFI Engine RC 상태 + +기준일: 2026-06-22 KST +재확인: 2026-06-23 KST + +## 한 줄 결론 + +현재 NFI Engine은 **paper/testnet RC까지는 증거가 붙은 상태**다. +실거래 live-money 주문 실행은 아직 별도 승인 플랜 전까지 막아둔 상태가 맞다. + +## 지금 완료로 봐도 되는 것 + +| 영역 | 상태 | 증거 | +| --- | --- | --- | +| X7 native semantic lane | 완료 | `.omo/evidence/2026-06-21-x7-live-readiness-pi4-rc/task-02-strategy-inspect.json`, `f4-scope-fidelity.md` | +| clean-room 경계 | 완료 | `docs/nfi-x7-compatibility.md`, `docs/release-wording.md`, release wording scan `violations=0` | +| operator workflow | 완료 | `ulw-reconcile-g010/`, browser happy/failure evidence, runtime control evidence | +| 한 줄 설치/제거 | 완료 | `ulw-reconcile-g011/summary.assertions.json`, `g011-handoff.md` | +| wallet/API setup | 완료 | exchange API credential flow, permission audit, wallet balance fetch evidence | +| Pi4 RC lane | 완료-with-boundary | T5A benchmark pass, 500 tick soak, reversible deploy profile, G063 non-mutating profile 재검증, `claim_allowed=false` | +| final local gate | 완료 | `final-gate/pytest.txt` = `497 passed`, `final-gate/plan-evidence-verify.txt` = `PLAN_EVIDENCE_OK referenced=22 missing=0` | + +## 부분 완료 + +| 영역 | 현재 상태 | 왜 부분인가 | +| --- | --- | --- | +| Pi4 운영 | 내부 RC 증거 있음 | 냉각 UX는 팬/방열판 교체 후 long-run thermal 재측정 필요 | +| update button | proof-only gate 완료 | 실제 GitHub source mutation/update/restart는 별도 hardened plan 필요 | +| exchange support | capability registry 있음 | candidate/generic-unverified는 fixture/testnet evidence 전까지 runtime trade path 금지 | +| dashboard cockpit | 운영 표면 있음 | 포지션, 계좌, PnL, 위험 압축은 더 다듬어야 함 | +| live 전환 | preview/blocker 있음 | 실제 주문 실행은 별도 live-execution plan 전까지 금지 | + +## 아직 안 한 것 + +- real-money live order execution +- 차단: public Freqtrade superiority claim +- 차단: profit promise 또는 safety guarantee +- upstream NFI X7 trade parity claim +- 모든 거래소 verified runtime support +- Pi4 public speed comparison claim +- 실제 GitHub self-update source mutation +- 방열판-only 또는 저소음 팬 기준 Pi4 long-run thermal 재검증 + +## G011에서 새로 잠근 것 + +G011은 제품 코드 변경이 아니라 기존 설치/제거 표면을 다시 몰아서 검증한 단계다. + +- shell install dry-run, npm wrapper, Bun wrapper 통과 +- config validate 통과 +- safe uninstall dry-run, purge preview dry-run 통과 +- invalid port, missing `uv`, unsafe purge, unmarked runtime purge 실패 경로 통과 +- Pi4 profile은 host tuning을 적용하지 않고 `host_tuning=not_applied`로 보고 +- G063에서 같은 Pi4 RC profile 경계를 다시 확인했고, fresh SSH 배포 없이 기존 hardware-stamped evidence와 로컬 non-mutating profile edge를 재검증함 +- focused install pytest `17 passed` +- `git diff --check` 통과 +- secret scan에서 raw token/fixture secret 출력 없음 + +중요한 결론: npm/Bun wrapper에서 exchange credential은 argv로 넘기면 안 된다. +package manager가 인자를 먼저 echo할 수 있으므로, 한 줄 설치에서 credential은 환경변수로 넣어야 한다. + +## 지금 다음 좌표 + +1. G012 문서/status 정리 완료 +2. G013 이후 live-money order block, auth/CSRF/read-only/sandbox/preflight/circuit/reconciliation 같은 안전 invariant를 계속 증거로 재확인 +3. live-execution은 지금 RC와 분리해서 별도 설계 +4. Pi4는 cooling hardware 바꾼 뒤 다시 long-run thermal evidence +5. exchange verified 승격은 fixture/sandbox/testnet evidence가 있는 것만 허용 diff --git a/brainstorming/2026-06-24_RC_STATUS.md b/brainstorming/2026-06-24_RC_STATUS.md new file mode 100644 index 0000000..c7e153a --- /dev/null +++ b/brainstorming/2026-06-24_RC_STATUS.md @@ -0,0 +1,68 @@ +# 2026-06-24 NFI Engine RC 상태 + +기준일: 2026-06-24 KST +재확인: 2026-06-24 KST + +## 한 줄 결론 + +NFI Engine은 현재 **paper/testnet RC 증거는 붙어 있고**, 실거래 주문 실행은 +별도 승인 플랜 전까지 계속 차단된 상태다. 이번 재확인은 G072 기준으로 UI +새로고침 문제, 브라우저 보안 경계, KO/EN/EL visual QA를 다시 잠근 것이다. + +## 완료 + +| 영역 | 상태 | 증거 | +| --- | --- | --- | +| X7 native semantic lane | 완료 | `.omo/evidence/2026-06-21-x7-live-readiness-pi4-rc/task-02-strategy-inspect.json`, `f4-scope-fidelity.md` | +| clean-room 경계 | 완료 | `docs/nfi-x7-compatibility.md`, `docs/release-wording.md`, release wording scan `violations=0` | +| operator workflow | 완료 | `ulw-reconcile-g010/`, browser happy/failure evidence, runtime control evidence | +| 한 줄 설치/제거 | 완료 | `ulw-reconcile-g011/summary.assertions.json`, `g011-handoff.md` | +| wallet/API setup | 완료 | exchange API credential flow, permission audit, wallet balance fetch evidence | +| UI no-forced-refresh | 완료 | `ulw-reconcile-g072/summary.assertions.json`, `visual-i18n/summary.json`, `runtime-control/summary.json` | +| browser security boundary | 완료 | `ulw-reconcile-g072/browser-security/security.json` | +| Pi4 RC lane | 완료-with-boundary | T5A benchmark pass, 500 tick soak, reversible deploy profile, G063 non-mutating profile 재검증, `claim_allowed=false` | +| final local gate | 완료 | `final-gate/pytest.txt` = `497 passed`, `final-gate/plan-evidence-verify.txt` = `PLAN_EVIDENCE_OK referenced=22 missing=0` | + +## 부분 완료 + +| 영역 | 현재 상태 | 왜 부분인가 | +| --- | --- | --- | +| Pi4 운영 | 내부 RC 증거 있음 | 냉각 UX는 팬/방열판 교체 후 long-run thermal 재측정 필요 | +| update button | proof-only gate 완료 | 실제 GitHub source mutation/update/restart는 별도 hardened plan 필요 | +| exchange support | capability registry 있음 | candidate/generic-unverified는 fixture/testnet evidence 전까지 runtime trade path 금지 | +| dashboard cockpit | 운영 표면 있음 | 포지션, 계좌, PnL, 위험 압축은 더 다듬어야 함 | +| fresh Docker rerun | 2026-06-23 host blocker 기록 | Docker Desktop/WSL integration이 돌아온 뒤 `bash scripts/final_smoke.sh` 재실행 필요 | + +## 아직 안 한 것 + +- real-money live order execution +- public Freqtrade superiority claim +- 돈/안전 결과 보장성 문구 +- upstream NFI X7 trade parity claim +- 모든 거래소 verified runtime support +- Pi4 public speed comparison claim +- 실제 GitHub self-update source mutation +- 방열판-only 또는 저소음 팬 기준 Pi4 long-run thermal 재검증 + +## G072에서 새로 잠근 것 + +- EN -> KO -> EL -> EN 언어 변경이 수동 F5 없이 적용 +- `html lang`이 `ko`, `el`, `en`으로 갱신 +- Home/Settings/Logs desktop/mobile 스크린샷 6개 캡처 +- horizontal overflow 0, clipped text 0, overlap 0, replacement glyph 0 +- auth/CSRF/read-only/live-intent failure probes 통과 +- runtime start/pause/resume/stop이 Home/Settings에 수동 F5 없이 반영 +- browser storage empty, external requests 0 +- focused UI/i18n/runtime/browser tests `50 passed` +- release wording scan `violations=0` +- secret scan empty +- QA port/temp leftover 없음 + +## 다음 좌표 + +1. G074 full quality gate를 통과시키거나, 남은 실패가 있으면 기존 외부 blocker와 + 제품 blocker를 정확히 분리한다. +2. Docker Desktop/WSL integration이 정상화되면 fresh final smoke를 다시 실행한다. +3. live-execution은 이 RC와 분리해서 별도 승인 플랜으로만 다룬다. +4. Pi4는 냉각 하드웨어 교체 후 long-run thermal evidence를 다시 캡처한다. +5. exchange verified 승격은 fixture/sandbox/testnet evidence가 있는 것만 허용한다. diff --git a/brainstorming/NFI_Engine.md b/brainstorming/NFI_Engine.md index 9e40d9f..b0704d8 100644 --- a/brainstorming/NFI_Engine.md +++ b/brainstorming/NFI_Engine.md @@ -8,8 +8,19 @@ * `[~] 부분`: 뼈대나 화면은 있으나 제품 흐름으로 더 잠가야 함 * `[ ] 아이디어`: 아직 구현 전이거나 Milestone 밖임 -현재 기준은 `README.md`, `src/nfi_engine`, `docs`, `scripts`, `tests`, -`.omo/evidence/2026-06-12-dev-entry/`에 보이는 상태다. +현재 기준은 `README.md`, `src/nfi_engine`, `docs`, `scripts`, `tests`와 +로컬 ULW evidence에 보이는 상태다. + +## 현재 좌표 + +기준일: 2026-06-24 KST +현재 재확인: 2026-06-24 KST + +* 현재 RC verdict: paper/testnet RC evidence-backed. G072에서 언어/런타임 상태가 F5 없이 반영되고, auth/CSRF/read-only/live-intent 차단과 KO/EN/EL desktop/mobile visual QA가 통과함. 다만 2026-06-23 fresh Docker final smoke 재검증은 WSL 세션의 Docker Desktop 연동 문제로 막혀 있었고, 실거래 live-money 주문 실행은 별도 승인 플랜 전까지 차단 +* 상세 상태 문서: `brainstorming/2026-06-24_RC_STATUS.md` +* repo용 상태 문서: `docs/release-status.md` +* 현재 evidence root: `.omo/evidence/2026-06-21-x7-live-readiness-pi4-rc/` +* 최종 검증 요약: plan evidence `referenced=22 missing=0`, local pytest `497 passed`, release wording scan `violations=0`. G072 focused UI/i18n/runtime/browser tests는 `50 passed`, browser QA cleanup은 port/temp leftover 없음. 2026-06-23 fresh G050 재검증은 CLI/browser/paper/release checks pass, Docker final smoke만 외부 런타임 blocker ## 날짜 기준 @@ -22,9 +33,42 @@ * `2026-06-07`: `fe19748 feat: bootstrap nfi engine m1` 커밋으로 초기 엔진/기본 CLI/API/문서/테스트 표면 확인 * `2026-06-08`: working tree timestamp 기준 setup, install/uninstall, dashboard, i18n, benchmark, UI 확장 확인 -* `2026-06-12`: dev-entry hardening 기준일. Task 1-9 증거가 `.omo/evidence/2026-06-12-dev-entry/`에 있음 +* `2026-06-12`: dev-entry hardening 기준일. Task 1-9 로컬 evidence 확인 * `2026-06-12`: 이 브레인스토밍 문서 상태 재분류/검수일 * `2026-06-13`: post-review blocker 수정/검증일. runtime settings provider, Greek catalog, Login desktop evidence, F2-F4 closeout 확인 +* `2026-06-14`: M2.5 첫 구현 웨이브. Home/dashboard snapshot action queue 구현 및 HTTP/브라우저 evidence 확인 +* `2026-06-14`: S1 product boundary 문서화. `NostalgiaForInfinityX7` clean-room 호환성, exchange support 후보/검증 레벨, public claim audit 기준 정리 +* `2026-06-14`: S2 strategy contract core 구현. callback support 분류, data-provider 계약 테스트, `sandbox check --output` clean-room JSON compatibility report 확인 +* `2026-06-14`: S3 signal timeline equivalence 구현. backtest/paper 공통 typed timeline, paper `--timeline-output`, clean-room fixture equivalence evidence 확인 +* `2026-06-14`: S4 exchange capability registry WP4.1 구현. `verified` / `candidate` / `generic-unverified` profile을 런타임 data로 묶고 config/preflight/CLI에서 unknown exchange를 차단 +* `2026-06-15`: S4 exchange discovery WP4.2 구현. `exchange capabilities --format json`으로 candidate/generic-unverified 능력 문서를 출력하고, generic id는 config/live 실행 경로로 승격되지 않게 차단 +* `2026-06-15`: S4 closeout 확인. T8/T13 기준 exchange capability spine은 완료, 다음 좌표는 S5의 T9/T10/T14 순서로 고정 +* `2026-06-15`: S5 T9/T10 완료. shell/npm/Bun 설치 dry-run matrix와 실제 loopback browser QA gate를 확보했고, 다음 제품 좌표는 T14 first-run/setup cockpit 완성 +* `2026-06-15`: S5 T14 완료. first-run setup wizard base path, Home operator cockpit, Settings update preview/apply/rollback 상태, dry-run default, live gate, credential redaction을 실제 loopback browser QA로 검증 +* `2026-06-15`: S6 T22 완료. exchange API permission audit와 risk profile guardrail을 setup/preflight/Home/Settings에 연결했고, withdrawal 권한 live block, expert risk confirmation, EN/KO/EL browser QA를 검증 +* `2026-06-15`: S7 T23/WP7.2 runtime-control 완료. explicit wallet balance fetch, protected runtime health JSON, Home/Settings 지갑/헬스 표시, pause/resume/stop-safe 컨트롤, CSRF/read-only/live-unsafe/blocked-health edge, 브라우저 no-storage/no-external-request QA를 검증. Pi4 실기기 성능 측정은 별도 과제 +* `2026-06-16`: S7 T15/WP7.3 local performance ledger 완료. M2 benchmark 6개 측정이 samples=5로 local budget을 통과했고, Pi4 실기기 측정 전 public claim은 blocked로 고정 +* `2026-06-16`: Raspberry Pi 4 Model B Rev 1.5 실기기 세팅/검증 완료. Debian 13 Trixie aarch64, `uv` Python 3.12.13, Docker/Compose, loopback API, auth smoke, restart/log-rotation Compose 설정, M2 benchmark budget 통과, throttling `0x0` 확인 +* `2026-06-16`: Raspberry Pi 4 NFI Engine tuned profile 적용. CPU governor `performance`, swappiness/dirty-write/TCP keepalive 조정, Docker live-restore/log policy, journald cap, Bluetooth/Avahi 비활성화 후 M2 benchmark가 전 항목 budget 통과 +* `2026-06-16`: Raspberry Pi 4 Bluetooth 완전 off 검증. `bluetooth.service` mask, `/boot/firmware/config.txt`의 `dtoverlay=disable-bt`, 재부팅 후 `/sys/class/bluetooth` 장치 수 `0`, Docker API healthy 확인 +* `2026-06-16`: Raspberry Pi 4 팬 소음 대응. `performance` 고정 서비스를 끄고 `schedutil` quiet cpufreq 서비스로 전환, idle 샘플 `600MHz` 확인, M2 benchmark 전 항목 budget pass. 추가로 GPIO14가 UART `TXD0` high로 잡혀 있던 문제를 `enable_uart=0` + `dtoverlay=gpio-fan,gpiopin=14,temp=65000,hyst=10000`로 수정했고, 재부팅 후 `gpio-fan` state `0`, GPIO14 `output low`, Docker healthy 확인 +* `2026-06-16`: Raspberry Pi 4 성능 우선 복구. quiet `schedutil` 서비스를 끄고 `performance` governor 서비스를 다시 활성화해 샘플 전부 `1800MHz` 고정 확인. GPIO fan overlay와 Bluetooth off는 유지, Docker healthy, M2 benchmark 전 항목 budget pass +* `2026-06-16`: Raspberry Pi 4 fanless safety guard 추가. 5V/GND 직결 2선 팬은 소프트웨어로 감속할 수 없어서, 팬을 빼고 임시 운용할 때를 대비해 `nfi-engine-thermal-guard.service`를 설치. 70C 이상 `1200MHz`, 78C 이상 `1000MHz`, 60C 이하 performance 복구. fake sysfs branch test와 실제 Docker healthy 확인 +* `2026-06-16`: Raspberry Pi 4 실사용 배포 보류 결정. 엔진/성능/thermal guard 검증은 완료됐지만 현재 2선 5V 팬 소음이 운영 UX 기준을 못 맞춰서, 저소음 팬/제어 팬/방열판-only 실측 전까지 Pi4는 lab target으로 유지 +* `2026-06-16`: Raspberry Pi 4 보류 후 원복 완료. `nfi-engine-pi4-performance`, `nfi-engine-pi4-quiet-cpufreq`, `nfi-engine-thermal-guard` 서비스/스크립트 제거, sysctl/journald/Docker daemon 튜닝 제거, `disable-bt`/`gpio-fan` boot overlay 제거, 재부팅 후 governor `ondemand`, max `1800MHz`, Docker healthy 확인 +* `2026-06-17`: S8 WP8.1 backup/restore/uninstall dry-run safety rehearsal 완료. backup create/verify, restore `--dry-run`, safe uninstall `--dry-run`, purge uninstall `--dry-run`, marker-protected runtime 보존, unsafe purge refusal, restore `--apply` 거절, traversal/checksum-invalid/incomplete backup archive 거절, credential DB URL redaction을 e2e/unit/evidence로 고정 +* `2026-06-21`: X7 RC Todo 12 완료. Settings update preview/apply/rollback이 proof-only 정책으로 고정됐고, dirty worktree/source/backup/CSRF/read-only 차단, HTTP happy/failure, browser happy/failure, mobile overflow, focused pytest/ruff/basedpyright 증거 확인 +* `2026-06-21`: Pi4 RC Todo 4 완료. Todo 3B resume3 authenticated shell 이후 Pi user-home toolchain에 `uv 0.11.23`, `node v24.17.0`, `npm 11.13.0`, `bun 1.3.14`를 시스템 변경 없이 staging. shell/npm/Bun install dry-run, safe/purge uninstall dry-run, missing-uv failure, `/tmp` cleanup 검증 완료. rollback은 `rm -rf /home/admin/.local/share/nfi-engine/toolchain`. 당시 다음 과제는 Todo 5였고, 최신 상태는 아래 Todo 5 결과가 기준 +* `2026-06-21 UTC / 2026-06-22 KST`: Pi4 RC Todo 5 완료-with-warning. Raspberry Pi 4 Model B Rev 1.5 / Debian 13 aarch64에서 M2/X7 benchmark 10개 측정 캡처, 9개 pass, `claim_allowed=false`, invalid sample failure typed, `/tmp` cleanup 통과. 단 `x7_backtest_sample_latency`가 `2275.615ms / 1000ms`로 warn이라 `T5A-pi4-x7-backtest-sample-budget`를 final RC 전 최적화/재예산 과제로 분리 +* `2026-06-21 UTC / 2026-06-22 KST`: Pi4 RC Todo 6 완료. Raspberry Pi 4 staged source에서 X7 paper 500 tick soak, stale-data block, loopback runtime health HTTP, pre/post thermal/resource snapshot, cleanup 검증 완료. throttle `0x0`, temp `52.1C -> 56.9C`, Docker log bytes `0 -> 0`, `live_orders=false`, `/tmp`/process leftover 없음. 당시 후속 작업은 reversible deployment profile이었고, 최신 상태는 아래 Todo 13 결과가 기준 +* `2026-06-21 UTC / 2026-06-22 KST`: Pi4 RC Todo 13 완료. reversible deployment profile이 loopback/project-scoped Compose 배포, `--project-name`/`--host-port`, Pi4 read-only profile check, safe uninstall/purge rollback receipt까지 검증. RC stack은 `127.0.0.1:18113`, ping/runtime-health HTTP `200`, container `healthy`, X7 inspect `coverage_state=verified`/`pending_modules=[]`, host tuning `not_applied`, CPU max `1800000`, throttle `0x0`, temp `51.1C -> 56.4C -> 55.0C`, token leak scan empty. Final RC는 여전히 `T5A-pi4-x7-backtest-sample-budget` 때문에 대기 +* `2026-06-22 KST`: T5A Pi4 X7 backtest sample budget warning 해결. `StrategyRow` feature bulk 적용과 feature dedupe/upsert 선형화를 적용해 로컬 `x7_backtest_sample_latency`가 `682.370ms -> 248.494ms`로 개선됐고, 같은 Pi4 반복 M2/X7 benchmark에서 `832.086ms`, `836.042ms`, `849.583ms / 1000ms` 모두 pass. `claim_allowed=false`, throttle `0x0`, temp `53.5C`, impossible X7 baseline은 `PERFORMANCE_REGRESSION`으로 실패. Final RC 다음 좌표는 Todo 14 +* `2026-06-22 KST`: Todo 6 검증 중 X7/preflight 순환 import 수정 완료. `preflight.__init__`의 service eager import를 제거하고 service 사용자는 `preflight.service` 직접 import로 정리. focused paper/X7 e2e `9 passed`, preflight service unit `15 passed`, touched Python ruff/basedpyright 통과 +* `2026-06-22 KST`: Todo 14 release docs/status/final RC gate 완료. README/safety/operations/performance/exchange/X7/release-wording/status 문서를 paper/testnet RC 경계로 정리했고, `final_smoke.sh`의 실제 Docker proof를 기본 `.runtime`이 아니라 temp runtime + `nfi-engine-final-smoke` project + `18180` port로 격리. release wording scan `violations=0`, forbidden wording negative probe는 `live-money ready`/`better than Freqtrade`를 잡음. pre-fix smoke가 기본 `.runtime` placeholder를 purge한 사고는 복구했지만 실제 exchange credential 값은 recover 불가라 다시 수동 입력 필요 +* `2026-06-22 KST`: Final verification wave F1-F4 완료. plan evidence verifier `PLAN_EVIDENCE_OK referenced=22 missing=0`, final local gate `ruff format/check`, `basedpyright`, `pytest 497 passed`, `git diff --check` 통과. 최종 verdict는 `paper/testnet RC approved`이며 실거래 live-money ready는 아님 +* `2026-06-22 KST`: G011 one-line install/uninstall 재검증 완료. shell/npm/Bun install dry-run, config validate, safe uninstall, purge preview, invalid host-port/missing-uv/unsafe purge 실패 경로, Pi4 non-mutating profile, focused install pytest `17 passed`, `git diff --check`, secret scan 통과. npm/Bun wrapper는 credential을 argv가 아니라 환경변수로 받아야 raw secret echo를 피할 수 있음 +* `2026-06-23 KST`: G063 Pi4 RC deployment profile 재검증 완료. Task 13/G046 hardware-stamped Pi4 deploy evidence를 다시 파싱해 Raspberry Pi 4 Model B, loopback `127.0.0.1:18113`, `host_tuning=not_applied`, CPU max `1800000`, throttle `0x0`, runtime health HTTP 200, X7 `coverage_state=verified`, rollback receipt, token leak zero를 확인. 로컬 `pi4_rc_profile`은 Docker Compose missing을 안전하게 block하고 invalid host-port는 `PI4_INVALID_HOST_PORT`로 차단. public speed/live-money claim은 여전히 금지 +* `2026-06-24 KST`: G072 UI no-forced-refresh / browser security / visual QA 재검증 완료. EN -> KO -> EL -> EN 언어 변경이 수동 F5 없이 적용되고, `html lang`이 `ko`/`el`/`en`으로 바뀌며, Home/Settings/Logs desktop/mobile 스크린샷 6개가 overflow/clipping/overlap/replacement glyph 없이 통과. CSRF 누락/오류, read-only mutation, unsafe live intent는 계속 차단되고, runtime start/pause/resume/stop은 Home/Settings에 F5 없이 반영됨. focused pytest `50 passed`, release wording `violations=0`, secret scan empty, QA port/temp cleanup 통과 ## 한 줄 정의 @@ -54,6 +98,7 @@ NFI_Engine은 제품이다. * [x] `validate` (구현일: 2026-06-07) * [x] `paper-run` (구현일: 2026-06-07) * [x] `exchange` (구현일: 2026-06-07) +* [x] `exchange capabilities` typed capability JSON/text 출력 (구현일: 2026-06-15) * [x] `pairlist` (구현일: 2026-06-07) * [x] `simulate` (구현일: 2026-06-07) * [x] `circuit-breaker` (구현일: 2026-06-07) @@ -79,16 +124,25 @@ NFI_Engine은 제품이다. * [x] settings/logs/pairlist/dashboard 관련 UI 파일 분리 (구현일: 2026-06-08) * [x] dashboard read store / repository 계층 (구현일: 2026-06-08) * [x] Logs 모바일 표 가로 스크롤 처리로 텍스트 겹침 제거 (구현일: 2026-06-12) +* [x] Home / dashboard snapshot action queue (구현일: 2026-06-14, HTTP/브라우저 검증일: 2026-06-14) +* [x] 실제 loopback 서버 브라우저 QA gate. login -> Home action queue -> Settings locale apply -> Logs -> desktop/mobile screenshot -> local-only network/storage/token-leak audit를 `npm run nfi:browser-qa`로 재현 (구현일: 2026-06-15) +* [x] Home/Settings에 API permission audit와 risk profile 상태 노출. read/trade/futures/withdrawal/IP allowlist 상태와 `safe`/`balanced`/`expert` profile이 operator workflow에 보임 (구현/브라우저 검증일: 2026-06-15) +* [x] 보호된 지갑 잔액 조회 API. `GET /api/v1/wallet/balance`와 explicit `POST /api/v1/wallet/balance/fetch`가 typed/redacted wallet state를 반환하고 simulator happy path에서 `1000 / 1000 USDT`를 보여줌 (구현/HTTP/브라우저 검증일: 2026-06-15) +* [x] 보호된 runtime health API. `GET /api/v1/runtime/health`가 heartbeat/preflight/wallet/data freshness/manual halt/disk/memory 체크를 `healthy`/`degraded`/`blocked`로 요약 (구현/HTTP 검증일: 2026-06-15) +* [x] 보호된 runtime control API. `POST /api/v1/start`, `/pause`, `/resume`, `/stop`, `/runtime/control`이 pause new entries, resume after preflight/runtime-health, stop-safe state를 typed machine code로 처리하고 live-order cancel을 가장하지 않음 (구현/HTTP/브라우저 검증일: 2026-06-15) ### 설치 / 실행 * [x] `scripts/install.sh` (구현일: 2026-06-08) * [x] `scripts/uninstall.sh` (구현일: 2026-06-08) +* [x] `package.json` npm/Bun 한 줄 wrapper (`nfi:install`, `nfi:install:dry-run`, safe uninstall, purge dry-run preview) (구현일: 2026-06-15, shell/npm/Bun matrix 재검증일: 2026-06-22) +* [x] `package.json` browser QA wrapper (`nfi:browser-qa:deps`, `nfi:browser-qa`) (구현일: 2026-06-15) * [x] `Dockerfile` (구현일: 2026-06-07, 확장일: 2026-06-08) * [x] `compose.yaml` (구현일: 2026-06-07, 확장일: 2026-06-08) -* [x] Docker docs (구현일: 2026-06-07, 확장일: 2026-06-08) -* [x] safe uninstall / purge 흐름 문서화 (구현일: 2026-06-08) -* [x] `scripts/final_smoke.sh` (구현일: 2026-06-07, 확장일: 2026-06-08, release gate 검증일: 2026-06-12) +* [x] Docker docs (구현일: 2026-06-07, 확장일: 2026-06-08, npm/Bun wrapper 반영일: 2026-06-15) +* [x] safe uninstall / purge 흐름 문서화 (구현일: 2026-06-08, purge dry-run preview 명확화일: 2026-06-15) +* [x] `scripts/final_smoke.sh` (구현일: 2026-06-07, 확장일: 2026-06-08, release gate 검증일: 2026-06-12, install/uninstall dry-run receipt 추가일: 2026-06-15) +* [x] backup/restore/uninstall dry-run rehearsal. backup create -> verify -> restore `--dry-run` -> safe uninstall dry-run -> purge dry-run을 marker-protected runtime dir에서 한 번에 재현하고, unmarked/home purge refusal, restore `--apply` refusal, traversal/checksum-invalid/incomplete archive refusal, credential DB URL redaction을 stable code/test로 검증 (검증일: 2026-06-17) ### 문서 / 테스트 @@ -99,6 +153,7 @@ NFI_Engine은 제품이다. * [x] integration 테스트 표면 (구현일: 2026-06-07, 확장일: 2026-06-08) * [x] Freqtrade feature coverage 문서로 “따라 만들기”가 아니라 “기능 범위 비교” 방향 잡음 (구현일: 2026-06-08) * [x] `brainstorming/2026-06-12_IMPLEMENTATION_SUMMARY.md` 작성 및 최종 gate 결과 반영 (작성일: 2026-06-12, 업데이트일: 2026-06-13) +* [x] T10 live-server browser QA evidence summary 작성 (작성일: 2026-06-15) ## 부분 구현 / 더 잠가야 하는 것 @@ -107,10 +162,13 @@ NFI_Engine은 제품이다. ### 첫 사용자 동선 -* [x] 한 줄 설치는 있음 (구현일: 2026-06-08, Docker smoke 검증일: 2026-06-12) +* [x] 한 줄 설치는 있음 (구현일: 2026-06-08, Docker smoke 검증일: 2026-06-12, shell/npm/Bun dry-run matrix 검증일: 2026-06-15) +* [x] 설치 host tool 누락 시 `INSTALL_MISSING_COMMAND` + `install_hint`로 바로 고칠 수 있게 안내 (구현일: 2026-06-15) * [x] 설치 후 token login -> Home 진입 흐름 있음 (구현일: 2026-06-08, 브라우저/HTTP 검증일: 2026-06-12) -* [~] setup preview -> Home -> Settings -> Dashboard snapshot 흐름은 검증됨. 다만 "wizard 완료" 제품 UX는 아직 더 다듬어야 함 (시작일: 2026-06-08, hardening 검증일: 2026-06-12) -* [~] 실패했을 때 typed error / next action은 늘었지만, 모든 사용자 실패를 한 화면에서 안내하는 UX는 더 필요함 (시작일: 2026-06-12) +* [x] 실제 브라우저에서 token login -> Home action queue -> Settings -> Logs 이동을 loopback 서버로 재현 (검증일: 2026-06-15) +* [x] first-run setup wizard base path. 거래소 -> API key -> API secret -> API 권한 점검 -> 권장 3x -> risk profile -> explicit 지갑 잔액 fetch -> 할당 금액 -> 선물/현물 -> 드라이런/라이브 순서가 Settings에서 실제 렌더링되고 브라우저 QA로 검증됨 (구현/검증일: 2026-06-15, 권한/risk 보강일: 2026-06-15, wallet fetch 보강일: 2026-06-15) +* [x] 운영자가 지금 봐야 할 next action을 Home action queue와 dashboard snapshot API에 노출 (구현일: 2026-06-14, HTTP/브라우저 검증일: 2026-06-14) +* [~] 모든 사용자 실패를 wizard까지 포함해 한 화면에서 끝내는 UX는 더 필요함 (시작일: 2026-06-12, action queue 1차 구현일: 2026-06-14) ### Settings @@ -120,6 +178,11 @@ NFI_Engine은 제품이다. * [x] runtime-safe apply 뒤 Settings locale과 read-only write gate가 같은 running config를 보도록 post-review 수정 (구현일: 2026-06-12, 검증일: 2026-06-13) * [x] restart-required field는 running config를 바꾸지 않고 reload 필요로 응답 (구현일: 2026-06-12) * [x] secret write-only / redaction 검증 (구현일: 2026-06-12) +* [x] 엔진+전략 업데이트용 preview/apply/rollback proof-only gate. 현재 버전/전략/config/lock provenance, dirty worktree 차단, `local_proof` source 정책, backup requirement, CSRF/read-only 차단을 HTTP/브라우저로 검증. 실제 GitHub pull/source mutation은 여전히 별도 live-safe 설계 전까지 금지 (구현일: 2026-06-15, 안전 gate 완성/검증일: 2026-06-21) +* [x] setup/config/preflight에 exchange API permission audit와 risk profile guardrail 연결. withdrawal 권한은 live setup을 차단하고, dry-run/testnet은 redacted diagnostics로 유지됨 (구현/HTTP 검증일: 2026-06-15) +* [x] `balanced` risk profile은 권장 3x, `expert`는 명시 확인 없으면 setup/preflight에서 차단 (구현/검증일: 2026-06-15) +* [x] Settings의 지갑 잔액 버튼이 `POST /api/v1/wallet/balance/fetch`를 호출하고 새로고침 없이 `1000 / 1000 USDT` 같은 typed 상태를 반영. 브라우저 storage는 비어 있고 외부 요청 없음 (구현/브라우저 검증일: 2026-06-15) +* [x] Settings runtime control이 페이지 진입 시 현재 state를 동기화하고, start/pause/resume/stop 명령 뒤 F5 없이 상태와 Home bot-state를 반영. read-only mode에서는 서버가 `READONLY_ACTION_BLOCKED`로 차단 (구현/브라우저 검증일: 2026-06-15, G072 재검증일: 2026-06-24) * [~] live 전환 같은 위험 설정은 서버에서 막지만, 사용자용 마찰/설명 UX는 더 강해져야 함 (시작일: 2026-06-12) ### Dashboard @@ -127,7 +190,11 @@ NFI_Engine은 제품이다. * [x] dashboard module / model / route / repository 표면 있음 (구현일: 2026-06-08) * [x] dashboard가 persistence/read-store 기반 bounded snapshot으로 연결됨 (구현일: 2026-06-12) * [x] Home 지표가 read-model snapshot 또는 정직한 empty state를 보여줌 (구현일: 2026-06-12) -* [~] 실제 paper/live 상태를 운영자가 3초 안에 판단할 정도의 압축도는 더 필요함 (시작일: 2026-06-12) +* [x] dashboard snapshot에 bounded action queue 추가. readiness/errors/pairlist/paper-testnet 상태를 최대 4개 액션으로 압축 (구현일: 2026-06-14) +* [x] Home operator cockpit base. configured/safety/capability/active mode/wallet/allocated amount/leverage/risk profile/API permission audit/latest error/next action/where next를 한 화면에 압축 (구현/브라우저 검증일: 2026-06-15, permission/risk 보강일: 2026-06-15) +* [x] Home cockpit에 runtime health와 wallet balance가 실제 typed API 상태로 표시됨. simulator happy path는 wallet `1000 / 1000 USDT`, bybit/testnet credential 없음은 `WALLET_BALANCE_MISSING_CREDENTIALS` blocker로 표시 (구현/HTTP/브라우저 검증일: 2026-06-15) +* [x] Home runtime control panel이 start/pause/resume/stop 버튼과 runtime health state를 노출. pause는 `new_entries_allowed=false`로 entries를 막고, resume은 preflight/runtime-health clear 없이는 `RUNTIME_HEALTH_BLOCKED` 같은 stable code로 거절 (구현/HTTP/브라우저 검증일: 2026-06-15) +* [~] 실제 paper/live 상태를 운영자가 3초 안에 판단할 정도의 압축도는 1차 개선됨. 포지션/계좌/위험 묶음은 더 필요함 (시작일: 2026-06-12, action queue 1차 구현일: 2026-06-14) * [~] 포지션, 계좌, 손익, 위험 상태를 “운영 화면”답게 더 밀도 있게 묶는 작업은 다음 단계 (시작일: 2026-06-12) ### i18n @@ -137,6 +204,8 @@ NFI_Engine은 제품이다. * [x] Home / Settings / Logs / login / setup / readiness 주요 사용자 문구 catalog 경유 검증 (구현일: 2026-06-12) * [x] machine code / enum / audit ID는 번역하지 않도록 테스트 고정 (구현일: 2026-06-12) * [x] EN/KO/EL catalog completeness 테스트와 Greek Settings title 누락 수정 (구현일: 2026-06-12, 검증일: 2026-06-13) +* [x] Settings에서 `ui.locale`을 EN -> KO -> EL -> EN으로 바꾸면 운영자가 F5를 누르지 않아도 페이지와 `html lang`이 자동 반영되는 브라우저 gate 확보 (검증일: 2026-06-15, G072 재검증일: 2026-06-24) +* [x] QA 브라우저 환경의 CJK 폰트 누락을 rootless Noto CJK deps로 보강해 모바일 한국어 캡처에서 글리프 네모 현상을 제거 (검증일: 2026-06-15) * [~] 새 화면 추가 시 하드코딩 문구가 다시 들어가지 않게 계속 테스트를 확장해야 함 (시작일: 2026-06-12) ### Benchmark / Performance @@ -146,24 +215,61 @@ NFI_Engine은 제품이다. * [x] performance 문서 있음 (구현일: 2026-06-08) * [x] `scripts/final_smoke.sh`가 valid local benchmark JSON을 쓰는 release gate 검증 (검증일: 2026-06-12) * [x] baseline이 있을 때 `PERFORMANCE_REGRESSION`으로 실패하는 음성 테스트 검증 (검증일: 2026-06-12) +* [x] T15 local no-regression benchmark ledger: startup/dashboard/Home/chart/backtest/install 6개 측정 samples=5 budget 통과, WSL2 x86_64 evidence 고정 (검증일: 2026-06-16) +* [x] Raspberry Pi 4 실기기 baseline: Model B Rev 1.5 / Debian 13 aarch64 / Python 3.12.13에서 M2 benchmark 6개 측정 budget 통과, throttling `0x0`, Docker loopback API healthy 확인 (검증일: 2026-06-16) +* [~] Raspberry Pi 4 lab verification: Pi 전용 성능/팬/thermal guard 튜닝으로 startup `471.405ms`, 720-candle backtest `39.013ms`, 전체 budget pass를 확인했지만, 현재는 2선 5V 팬 소음 때문에 실사용 배포를 보류하고 해당 host 튜닝을 제거함 (검증/보류 결정일: 2026-06-16) +* [x] Raspberry Pi 4 hold cleanup: Pi 전용 클럭/팬/thermal guard/sysctl/journald/Docker daemon/boot overlay 튜닝 제거. 재부팅 후 custom service/file 없음, governor `ondemand`, max `1800MHz`, sysctl 기본값, Bluetooth service enabled/device `1`, Docker healthy 확인 (정리일: 2026-06-16) +* [x] Raspberry Pi 4 RC install/bootstrap gate: 2026-06-21 Todo 3B authenticated-shell proof 후 Todo 4 완료. Pi4 inventory 기준 throttle `0x0`, temp 44.3 C, CPU max 1.8GHz, memory available 3.4Gi, root disk available 21G. 사용자 홈 전용 toolchain으로 `uv`/Node/npm/Bun 확보, shell/npm/Bun install dry-run과 safe/purge uninstall dry-run 통과, missing-uv failure와 cleanup 검증 완료 (검증일: 2026-06-21) +* [x] Raspberry Pi 4 RC X7 benchmark/resource gate: Todo 5에서는 `x7_backtest_sample_latency`가 `2275.615ms / 1000ms`로 warn이었지만, T5A에서 feature-row allocation을 최적화해 같은 Pi4 반복 측정 `832.086ms`, `836.042ms`, `849.583ms / 1000ms` 모두 pass. `claim_allowed=false`, throttle `0x0`, temp `53.5C`, impossible baseline `PERFORMANCE_REGRESSION`, full local quality gate `494 passed`까지 확인 (검증일: 2026-06-22 KST) +* [x] Raspberry Pi 4 RC X7 paper soak/thermal/log gate: staged source로 500 tick paper-run 완료, `processed_events=500`, `created_trades=3`, `live_orders=false`, timeline `step_count=500`/`truncated=false`. stale stream은 `breaker=stale_data`, `new_orders_blocked=true`, `blocked_actions=1`로 통제 차단. runtime health HTTP 200, throttle `0x0`, temp `52.1C -> 56.9C`, Docker log bytes `0`, cleanup 통과 (검증일: 2026-06-21 UTC / 2026-06-22 KST) +* [x] Raspberry Pi 4 RC reversible deployment profile: `scripts/pi4_rc_profile.sh`, loopback/project-scoped Compose install, Pi4 RC stack `127.0.0.1:18113`, ping/runtime-health HTTP 200, Docker container healthy, packaged X7 semantic inspect `verified`, stock CPU max `1800000`, throttle `0x0`, safe uninstall/purge rollback receipt, token leak scan empty, `/tmp` cleanup 통과. Host tuning은 적용하지 않음 (검증일: 2026-06-21 UTC / 2026-06-22 KST) +* [ ] Raspberry Pi 4 cooling UX 재검증: 팬 제거 + 방열판-only 온도 실측, 저소음 5V 팬, 또는 GPIO/PWM 제어 팬으로 교체 후 재측정 필요 (미구현 확인일: 2026-06-16) * [~] 숫자 기반 비교를 README에서 더 짧고 강하게 보여줄 수 있음. 단, public speed claim은 아직 금지 (시작일: 2026-06-08, 확인일: 2026-06-12) -* [ ] Raspberry Pi 4 / 저사양 VPS 기준의 반복 측정은 아직 별도 검증 필요 (미구현 확인일: 2026-06-12) +* [~] 저사양 VPS 기준의 반복 측정은 아직 별도 검증 필요. Pi4 실기기 baseline은 확보했지만 Freqtrade 대비 public speed claim은 같은 기기 black-box 비교 전까지 금지 (미구현 확인일: 2026-06-12, Pi4 baseline 검증일: 2026-06-16) ### Docker / Compose QA * [x] Docker-first quickstart 있음 (구현일: 2026-06-07, 확장일: 2026-06-08) * [x] Compose 실행 방향 있음 (구현일: 2026-06-07, 확장일: 2026-06-08) -* [x] final smoke 스크립트 있음 (구현일: 2026-06-07, 확장일: 2026-06-08, release gate 검증일: 2026-06-12) +* [x] final smoke 스크립트 있음. CLI/config/preflight/backtest/walk-forward/paper-run/X7 inspect/release wording/install/uninstall/Docker proof까지 묶고, 2026-06-22 Todo 14에서 실제 Docker 구간을 temp runtime으로 격리해 기본 `.runtime`을 건드리지 않게 수정 (구현일: 2026-06-07, 확장일: 2026-06-08, release gate 검증일: 2026-06-12, 격리 보강일: 2026-06-22) * [x] Docker Compose 기준 최종 smoke를 “릴리즈 전 필수 관문”으로 문서화/검증 (구현일: 2026-06-12, post-review 재검증일: 2026-06-13) -* [ ] 여러 OS/환경에서 install/uninstall 반복 검증은 아직 더 필요함 (미구현 확인일: 2026-06-12) +* [x] local shell/npm/Bun install dry-run, safe uninstall dry-run, purge dry-run, missing-tool path 매트릭스 검증 (검증일: 2026-06-15) +* [x] G011 one-line install/uninstall 재검증. shell/npm/Bun wrapper는 환경변수 credential 입력 방식으로 검증했고, argv secret echo 위험을 문서화. invalid port, missing `uv`, unsafe purge, unmarked runtime, Pi4 invalid host-port 실패 경로와 secret scan까지 통과 (재검증일: 2026-06-22) +* [x] S8 WP8.1 dry-run safety rehearsal: backup archive redaction/manifest, credential DB URL redaction, restore apply=false, unsupported apply refusal, traversal/checksum-invalid/incomplete archive refusal, safe/purge uninstall scope, runtime marker/token/operator file preservation, unsafe purge refusal를 e2e/unit/tmux transcript로 고정 (검증일: 2026-06-17) +* [x] Raspberry Pi 4 Docker-first install smoke: `docker.io`/Compose v2 설치, `bash scripts/install.sh --yes --paper --testnet` 성공, `/api/v1/ping`, token auth dashboard/Home, loopback bind, restart/log rotation 확인 (검증일: 2026-06-16) +* [ ] 여러 OS/환경에서 install/uninstall 반복 검증은 아직 더 필요함 (미구현 확인일: 2026-06-15) ### Freqtrade / NFI 호환 * [x] Freqtrade는 기능 benchmark로만 본다는 원칙 있음 (구현일: 2026-06-07, 확장일: 2026-06-08) * [x] strategy adapter / compat 문서 / feature coverage 문서 있음 (구현일: 2026-06-07, 확장일: 2026-06-08) -* [~] Freqtrade-shaped 전략 감각을 살리는 호환 레이어는 진행 중 (시작일: 2026-06-07, 확인일: 2026-06-12) -* [ ] upstream NFI와 완전 parity를 말하면 안 됨 (미구현 확인일: 2026-06-12) -* [ ] NFI 전용 전략 구조로 완전히 독립하는 건 아직 다음 단계 (미구현 확인일: 2026-06-12) +* [x] `docs/nfi-x7-compatibility.md`로 `NostalgiaForInfinityX7` target facts / Supported / Partial / Excluded / Clean-room provenance 분리 (문서 구현일: 2026-06-14) +* [x] public claim에서 upstream NFI full parity, profit claim, vendoring 주장을 막는 기준 문서화 (문서 구현일: 2026-06-14) +* [x] strategy inspection이 callback을 `supported` / `partial` / `excluded`로 분류하고, 계약 밖 public callback을 compat report에 노출 (구현일: 2026-06-14) +* [x] data-provider visible-row, missing informative frame, lookahead rejection 계약 테스트 고정 (구현일: 2026-06-14) +* [x] `nfi-engine sandbox check --output`이 clean-room fixture 기준 JSON compatibility report 생성 (구현일: 2026-06-14, CLI 검증일: 2026-06-14) +* [x] strategy-native signal/protection timeline을 backtest와 paper 결과에 공통 typed event log로 연결. raw frame은 저장하지 않고 compact JSON payload bytes를 evidence로 남김 (구현일: 2026-06-14) +* [x] `paper-run --timeline-output`으로 paper timeline JSON을 별도 출력하는 CLI 표면 추가 (구현일: 2026-06-14, CLI 검증일: 2026-06-14) +* [x] clean-room fixture 기준 backtest/paper typed field equivalence 테스트와 evidence 작성. entry side까지 비교하고 범위는 `NFI-shaped clean-room fixture only`로 고정 (구현일: 2026-06-14, side-equivalence 보강일: 2026-06-14) +* [~] Freqtrade-shaped 전략 감각을 살리는 호환 레이어는 core contract와 timeline/equivalence까지 진행됨. multi-timeframe/protection-rich fixture와 NFI-native 전략 구조화는 다음 단계 (시작일: 2026-06-07, S3 구현일: 2026-06-14) +* [~] NFI X7 callback 이름과 `5m`/informative timeframe 목표는 정리됨. data-provider 기본 계약과 실행 timeline은 잠겼지만, 다중 timeframe 실행 증거는 다음 구현 필요 (부분 확인일: 2026-06-14) +* [ ] upstream NFI와 완전 parity 구현/주장은 하지 않음. 실사용 비교 리포트도 clean-room fixture 기반으로 따로 만들어야 함 (미구현 확인일: 2026-06-14) +* [ ] NFI 전용 전략 구조로 완전히 독립하는 건 아직 다음 단계 (미구현 확인일: 2026-06-14) + +### 거래소 지원 / exchange support + +* [x] Freqtrade 문서 기준 Binance, Bingx, Bitmart, Bitget, Bybit, Gate.io, HTX, Hyperliquid, Kraken, Kraken Futures, OKX, Bitvavo, Kucoin 후보군 정리 (문서 구현일: 2026-06-14) +* [x] `verified` / `candidate` / `generic-unverified` 레벨을 분리해서 “모든 거래소 verified” 착각 방지 (문서 구현일: 2026-06-14) +* [x] spot / futures / margin / stoploss / market-order 차이를 runtime capability registry로 연결하고 config/preflight/`exchange check`에서 사용 (구현일: 2026-06-14) +* [x] `nfi-engine exchange capabilities --exchange --trading-mode --format json`으로 typed capability document 출력. Bybit/OKX 같은 후보와 MEXC 같은 generic-unverified를 같은 표면에서 구분 (구현일: 2026-06-15, CLI 증거일: 2026-06-15) +* [x] generic-unverified id는 report-only로만 보여주고 config validate에서는 계속 `EXCHANGE_UNSUPPORTED`로 차단 (구현일: 2026-06-15, safety 증거일: 2026-06-15) +* [x] 직접 `exchange check --exchange` 입력도 capability discovery와 같은 exchange-id 검증을 거쳐 unsafe id가 stdout에 새지 않게 차단 (구현일: 2026-06-15, review hardening일: 2026-06-15) +* [x] S4 exchange capability spine closeout. T8/T13 실제 CLI 표면, unsupported config 차단, targeted exchange/config/preflight/e2e 테스트 재검증 완료 (closeout일: 2026-06-15) +* [x] exchange API permission audit typed model. read/trade/futures/withdrawal/IP allowlist 상태를 `enabled`/`disabled`/`unknown`/`not_applicable`로 다루고 live blocker와 Home/Settings 표시까지 연결 (구현/HTTP/브라우저 검증일: 2026-06-15) +* [x] Bybit testnet wallet balance reader seam. 실제 credential이 없으면 live/testnet 네트워크로 억지 호출하지 않고 `WALLET_BALANCE_MISSING_CREDENTIALS`로 안전하게 막음. simulator는 deterministic balance reader로 QA 가능 (구현/테스트일: 2026-06-15) +* [~] 거래소 설정 UI가 registry profile을 직접 렌더링하고 live blocker를 operator workflow에 보여주는 작업은 다음 단계 (부분 확인일: 2026-06-14) +* [~] Hyperliquid 같은 특수 credential 모델은 exchange credential 경로로만 다루고, 로그인 토큰/지갑 seed phrase/메인 private key와 분리해야 함 (부분 확인일: 2026-06-14) +* [ ] fixture / testnet / sandbox evidence로 exchange profile을 `verified`로 승격하는 구현은 아직 남음 (미구현 확인일: 2026-06-15) ## 아직 안 한 것 / 브레인스토밍 @@ -171,17 +277,19 @@ NFI_Engine은 제품이다. ### 운영 편의 -* [ ] 버튼 하나로 엔진 업데이트 (미구현 확인일: 2026-06-12) -* [ ] 버튼 하나로 전략 업데이트 (미구현 확인일: 2026-06-12) -* [ ] UI에서 Dry Run / Live를 안전하게 전환 (미구현 확인일: 2026-06-12) -* [ ] live 전환 전 다중 확인 / preflight / 위험 설명 UX (미구현 확인일: 2026-06-12) -* [ ] 자동 복구/rollback 흐름 (미구현 확인일: 2026-06-12) -* [ ] 운영자용 “지금 뭘 해야 하는지” 액션 큐 (미구현 확인일: 2026-06-12) +* [x] 버튼 하나로 엔진 업데이트 proof gate: Settings에서 preview/apply/rollback dry-run proof가 가능하고, dirty worktree/source/backup/read-only/CSRF 차단이 증거로 고정됨. 실제 source mutation은 하지 않음 (구현일: 2026-06-21) +* [x] 버튼 하나로 전략 업데이트 proof gate: 엔진+전략 provenance digest/config/lock 상태를 같이 보여주고, `local_proof`만 허용하는 안전 receipt를 발급함. 원격 전략 다운로드나 upstream code import는 하지 않음 (구현일: 2026-06-21) +* [ ] 실제 GitHub self-update: 개발자 버튼 한 번으로 GitHub에서 엔진+전략을 받아 재시작하고 rollback까지 수행하는 기능은 아직 구현하지 않음. 현재 제품은 proof-only/update-safety gate까지 완료 (미구현 확인일: 2026-06-21) +* [x] UI에서 Dry Run / Live 선택 표면. dry-run이 기본이고 live preview는 `LIVE_TRADING_REQUIRES_CONFIRMATION`으로 차단됨 (구현/검증일: 2026-06-15) +* [~] live 전환 전 명시 확인 / preflight / 한도 / kill switch / reconciliation 위험 설명은 보이고, withdrawal permission audit는 live blocker로 연결됨. 최종 live confirm flow와 실제 주문 실행은 아직 별도 과제 (부분 구현일: 2026-06-15, permission 보강일: 2026-06-15) +* [~] 자동 복구/rollback 흐름: backup/restore/uninstall dry-run safety rehearsal은 완료했고 update rollback proof receipt도 완료. 하지만 실제 restore apply, 실제 source update rollback, retention cleanup은 별도 구현 필요 (부분 검증일: 2026-06-17, update proof 보강일: 2026-06-21) +* [x] 운영자용 “지금 뭘 해야 하는지” 액션 큐 1차 구현 (구현일: 2026-06-14, HTTP/브라우저 검증일: 2026-06-14) ### 실거래 * [ ] real-money live trading (미구현 확인일: 2026-06-12) * [ ] 실제 거래소 키로 주문 실행 (미구현 확인일: 2026-06-12) +* [ ] 모든 거래소 verified 실거래 지원 (미구현 확인일: 2026-06-14) * [ ] 자동 포지션 진입/청산의 실거래 보장 (미구현 확인일: 2026-06-12) * [ ] profit claim / 수익률 주장 (미구현 확인일: 2026-06-12) * [ ] upstream NFI와 동일 결과 주장 (미구현 확인일: 2026-06-12) @@ -191,8 +299,8 @@ README 기준으로도 Milestone 1은 dry-run/paper 중심이다. ### 제품 확장 -* [ ] 웹 UI에서 전체 설정 wizard 완성 (미구현 확인일: 2026-06-12) -* [ ] dashboard를 진짜 운영 cockpit 수준으로 확장 (미구현 확인일: 2026-06-12) +* [x] 웹 UI에서 first-run 설정 wizard base path, exchange permission audit, risk profile guardrail, explicit 지갑 잔액 fetch까지 연결 (구현/검증일: 2026-06-15, permission/risk 보강일: 2026-06-15, wallet fetch 보강일: 2026-06-15) +* [~] dashboard를 진짜 운영 cockpit 수준으로 확장 (action queue 1차 구현일: 2026-06-14, operator cockpit base 구현일: 2026-06-15, 포지션/계좌/위험 통합은 추가 필요) * [ ] 백테스트 결과 비교 화면 (미구현 확인일: 2026-06-12) * [ ] benchmark 결과 시각화 (미구현 확인일: 2026-06-12) * [ ] plugin marketplace 느낌의 전략/확장 관리 (미구현 확인일: 2026-06-12) @@ -205,30 +313,38 @@ README 기준으로도 Milestone 1은 dry-run/paper 중심이다. * Docker가 “나중 배포”가 아니라 첫 실행 경로가 됐다 * 언어팩이 붙어서 로컬 장난감보다 제품 쪽으로 기울었다 * feature coverage 문서가 있어서 Freqtrade를 복붙 대상으로 보지 않게 막아준다 +* strategy timeline이 생겨서 backtest/paper가 조용히 갈라지는지 entry side 포함 typed field로 잡을 수 있다 * benchmark / performance 문서가 생겨서 속도 이야기를 감으로 하지 않아도 된다 * final smoke, baseline regression, 브라우저 screenshot/HAR가 release gate 증거로 묶이기 시작했다 * read-only / CSRF / live safety가 UI 힌트가 아니라 서버 차단으로 검증됐다 ## 지금 제일 조심할 점 -* 화면이 있다고 제품 동선이 완성된 건 아니다. 이제 smoke는 있지만 wizard polish는 남았다 +* first-run wizard, exchange API permission audit, 지갑 balance fetch, pause/resume/stop-safe 컨트롤은 붙었지만, 최종 live confirm/order path 없이는 live-ready라고 말하면 안 된다 * CLI 명령이 있다고 운영자가 안전하게 쓸 수 있다는 뜻은 아니다. HTTP/브라우저 증거를 계속 붙여야 한다 * dashboard read model이 있다고 실제 live cockpit이 된 건 아니다 * i18n 누수는 한 번 잡아도 새 화면이 들어오면 다시 생길 수 있다 -* benchmark 명령이 있다고 저사양 환경 성능이 증명된 건 아니다 +* Pi4 실기기 baseline은 확보됐지만 Freqtrade 대비 속도 우위나 실거래 안정성까지 증명된 건 아니다 +* Todo 13 배포 profile에서 `.omo/evidence` 없는 staged/package 런타임의 X7 semantic status는 `verified`로 정리됐다. 다만 기본 install runtime-health가 wallet credential/preflight/dashboard seed 부족으로 `blocked`인 건 정상 운영 설정 전 상태라 live-ready 증거로 쓰면 안 된다 * Freqtrade compatibility가 있다고 NFI parity를 주장하면 안 된다 +* clean-room equivalence가 생겼지만 upstream NFI X7 trade parity나 수익률 보장은 아직 절대 아니다 +* generic-unverified 거래소는 “찾아볼 수 있음”이지 “쓸 수 있음”이 아니다. evidence 없이 paper/testnet/live로 승격하면 안 된다 ## 다음 타격점 지금은 기능을 더 벌리기보다, 이미 생긴 표면을 하나의 사용 흐름으로 묶는 게 좋아 보인다. -1. setup preview를 진짜 first-run wizard 완료 UX로 다듬기 -2. Dashboard에 paper-run/persistence 상태를 더 직접 연결해서 운영자가 3초 안에 상태를 판단하게 만들기 -3. 위험/live 전환 UX에 더 강한 friction과 설명을 붙이기 -4. benchmark 결과를 README에 짧게 노출하되 public speed claim은 계속 금지하기 -5. 여러 OS/환경에서 install/uninstall 반복 검증하기 -6. feature coverage 문서를 지원/부분/제외로 계속 갱신하기 -7. 백테스트 UI, plugin gallery, update button은 M3+로 따로 계획하기 +1. 별도 live-execution design plan 작성: permission audit, allocation cap, reconciliation, manual halt, kill switch, dry-run preview, rollback evidence를 먼저 설계하고 나서만 실거래 unlock 검토 +2. Pi4 baseline/soak/RC deployment/T5A 결과는 internal RC evidence로 유지하고 Freqtrade 대비 public speed claim은 계속 금지하기 +3. Dashboard에 paper-run/persistence 상태를 더 직접 연결해서 운영자가 3초 안에 상태를 판단하게 만들기 +4. `.runtime/secrets/exchange-wallet.env`에는 실제 exchange API key를 사용자가 다시 수동 입력해야 함. seed/private key/withdrawal key/local login token은 금지 +5. 방열판-only 또는 저소음 팬으로 Pi4 cooling UX를 다시 잡은 뒤 long-run thermal evidence 재측정하기 +6. Pi4 기준 nightly/수동 regression 비교 baseline을 추가하기 +7. 여러 OS/환경에서 install/uninstall 반복 검증하기 +8. feature coverage 문서를 지원/부분/제외로 계속 갱신하기 +9. generic-unverified 거래소를 fixture/testnet evidence로 verified 승격하는 루틴 만들기 +10. update button은 provenance/digest/rollback 증거가 붙을 때까지 안전 상태 UI로 유지하기 +11. 백테스트 UI, plugin gallery는 M3+로 따로 계획하기 ## 안티 슬롭 룰 diff --git a/compose.yaml b/compose.yaml index 53c3055..1c58006 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,6 +5,8 @@ services: build: context: . image: nfi-engine:local + init: true + restart: unless-stopped command: - nfi-engine - serve @@ -16,17 +18,22 @@ services: - "18080" env_file: - path: examples/docker.env.example - - path: .runtime/docker.env + - path: ${NFI_ENGINE_RUNTIME_ENV_FILE:-.runtime/docker.env} required: false environment: NFI_ENGINE_ALLOW_CONTAINER_BIND: "1" NFI_ENGINE_CONFIG: /config/futures-paper.yaml ports: - - "127.0.0.1:18080:18080" + - "127.0.0.1:${NFI_ENGINE_HOST_PORT:-18080}:18080" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" volumes: - nfi-data:/data - nfi-logs:/logs - - ./.runtime/config:/config:ro + - ${NFI_ENGINE_RUNTIME_CONFIG_DIR:-./.runtime/config}:/config:ro - ./examples:/app/examples:ro healthcheck: test: @@ -46,12 +53,12 @@ services: - --help env_file: - path: examples/docker.env.example - - path: .runtime/docker.env + - path: ${NFI_ENGINE_RUNTIME_ENV_FILE:-.runtime/docker.env} required: false volumes: - nfi-data:/data - nfi-logs:/logs - - ./.runtime/config:/config:ro + - ${NFI_ENGINE_RUNTIME_CONFIG_DIR:-./.runtime/config}:/config:ro - ./examples:/app/examples:ro paper: @@ -70,12 +77,12 @@ services: - "25" env_file: - path: examples/docker.env.example - - path: .runtime/docker.env + - path: ${NFI_ENGINE_RUNTIME_ENV_FILE:-.runtime/docker.env} required: false volumes: - nfi-data:/data - nfi-logs:/logs - - ./.runtime/config:/config:ro + - ${NFI_ENGINE_RUNTIME_CONFIG_DIR:-./.runtime/config}:/config:ro - ./examples:/app/examples:ro - ./tests/fixtures:/app/tests/fixtures:ro diff --git a/docs/backup.md b/docs/backup.md index f83296e..ba58b7d 100644 --- a/docs/backup.md +++ b/docs/backup.md @@ -20,6 +20,24 @@ uv run nfi-engine backup restore --dry-run .omo/evidence/backup.zip Use dry-run first. Mutating restore paths require explicit backup validity and must not run from the read-only UI mode. +The CLI currently refuses `--apply` with `BACKUP_RESTORE_APPLY_UNSUPPORTED` +until the later apply/rollback slice exists. +Backup verification also fails closed when archive members fall outside the +engine-owned backup manifest allowlist. +Verification requires the non-optional backup members and rejects incomplete +manifest-only archives before they can be used for restore rehearsal. +Restore dry-run refuses checksum-invalid archives before printing restore steps. +Backup metadata redacts credential-bearing database URLs before writing +`database.json`. + +## Safety Rehearsal + +The S8 WP8.1 rehearsal exercises the real CLI and shell surfaces in one flow: +backup create, backup verify, restore `--dry-run`, safe uninstall `--dry-run`, +and purge uninstall `--dry-run` against a marker-protected runtime directory. +The rehearsal must keep the runtime marker, token file, and operator files +present after both uninstall dry-runs. Unsafe purge targets must fail with a +stable machine-readable code before any removal scope is printed. ## Support Bundle @@ -28,5 +46,6 @@ recent logs only. API tokens and exchange credentials are rendered as `REDACTED` ## Limitations -M1 restore is a guarded maintenance workflow. It is not a one-click production -disaster recovery system. +Restore remains a guarded preview-first maintenance workflow. It is not a +one-click production disaster recovery system until a later apply/rollback +slice proves mutation, restart/reload, and rollback evidence. diff --git a/docs/contributing.md b/docs/contributing.md index 39b50c0..d76ba32 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -43,7 +43,25 @@ letting UI code reach into storage rows or raw config dictionaries. ## Test And Evidence Rules -Use focused tests first, then run the broader gate touched by your change: +Use focused tests first, then run the broader gate touched by your change. The +local shell surface is intentionally lightweight: + +New behavioral gates and failure modes use TDD: capture the focused failing +proof before production edits, then keep the closest useful pytest layer green. +Tests-after is acceptable only for docs/status/evidence-only updates where no +runtime behavior changes. Pytest remains the main automated test surface; ruff, +basedpyright, CLI stdout, HTTP calls, browser QA, Docker smoke, and Pi4 shell +receipts are manual QA and evidence surfaces that support, but do not replace, +the behavioral test. + +```bash +bash scripts/quality_gate.sh --docs-only +bash scripts/quality_gate.sh --coverage-only +bash scripts/quality_gate.sh --strict +``` + +`--docs-only` is the fast default for docs/governance edits. `--strict` runs the +existing full local gate: ```bash uv run ruff format --check . @@ -52,6 +70,10 @@ uv run basedpyright uv run pytest -q ``` +`--coverage-only` is a focused config/domain coverage smoke using existing +pytest-cov and coverage.py settings. It fails below `NFI_ENGINE_COVERAGE_MIN` +(default 80) without adding dependencies or heavyweight services. + User-visible or hot-path changes need manual evidence under `.omo/evidence/`. For performance work, include benchmark evidence with machine metadata, workload label, measured result, and whether a public comparison claim is allowed. Normal @@ -59,6 +81,30 @@ M2 contribution work must not require a local Freqtrade install. See [performance.md](performance.md) for the M2 benchmark command and regression gate. +## Quality Budget Review + +Coverage policy is touched-code coverage, not a vanity global percentage. New or +changed production behavior must have a direct test at the closest useful layer: +unit tests for pure rules, integration tests for adapters/storage, and e2e tests +for CLI/API/UI surfaces. When a touched package already has coverage tooling, +run the matching pytest-cov slice with a focused fail-under or record why that +slice is not meaningful yet. + +Maintainability has the same budget pressure as tests. Treat 250 pure LOC as the +split pressure point for hand-edited source and test files: do not split files +solely for vanity, but do not add new behavior to an oversized file without +extracting the cohesive unit you are touching. + +Performance Budget Review for hot-path changes: + +- no repeated config parse in loops or request paths. +- no unbounded DB read. +- no unbounded candle/frame materialization. +- no UI payload growth without a cap. +- no new dependency without size/startup justification. +- no public speed, parity, or Pi4 claim without matching hardware, command, + dataset, and budget evidence. + ## Documentation Rules Docs should make the simplest safe path obvious: @@ -71,5 +117,11 @@ Docs should make the simplest safe path obvious: - Use Logs for error codes, correlation IDs, and support report export. - Use `bash scripts/uninstall.sh --yes` for Safe Uninstall. - Use `bash scripts/uninstall.sh --purge --yes` only for Destructive Purge. +- For public-facing docs and release wording, apply `docs/release-wording.md`: + - Run `uv run python scripts/release_wording_scan.py` and require `violations=0`. + - Keep release claims aligned to evidence paths under `.omo/evidence` or `.omo/ulw-loop/evidence`. + - Rewrite any blocked phrasing before docs merge. + - Treat "native NFI-shaped X7 runtime", "superior/better", "parity", and "live-money" claims as release-critical until evidence-backed. + - Do not add milestone-ready announcements that imply guarantee, completeness, or unproved profit behavior. Manual developer commands belong after the operator path, not before it. diff --git a/docs/docker.md b/docs/docker.md index efe763e..009e297 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -7,6 +7,8 @@ operator checks. It is not a live-money deployment recipe. ```bash bash scripts/install.sh --yes --paper --testnet +npm run nfi:install +bun run nfi:install ``` The installer creates `.runtime/config/futures-paper.yaml`, @@ -17,6 +19,32 @@ command output never prints exchange keys, API secrets, or the generated API token. The output prints `login_token_file=.runtime/docker.env` so the operator knows where to read the local browser login token. +The npm and Bun commands are thin wrappers around the same shell installer and +do not add runtime dependencies. Use the dry-run path to verify the generated +receipt before starting Docker: + +```bash +bash scripts/install.sh --yes --paper --testnet --dry-run +npm run nfi:install:dry-run +bun run nfi:install:dry-run +``` + +Host requirements are explicit. Dry-run setup needs `uv` and Python 3.12+ +available as `python3`; the full Docker path also needs Docker with Compose v2. +If a tool is missing, the installer exits with `INSTALL_MISSING_COMMAND` and an +`install_hint` line instead of printing a partial or misleading success message. + +For isolated release-candidate checks on a host that may already run another +NFI Engine stack, give the Docker project and loopback host port explicitly: + +```bash +bash scripts/install.sh --yes --paper --testnet --project-name nfi-engine-pi4-rc --host-port 18113 +``` + +The API still binds to loopback only: +`http://127.0.0.1:18113/`. The option exists to avoid port and volume conflicts +during Pi4 verification, not to expose the service publicly. + ## First Run After install, open `http://127.0.0.1:18080/` and paste the operator token from @@ -53,8 +81,34 @@ Services: - `cli`: one-shot CLI container for maintenance and smoke commands. - `paper`: profile-gated paper runner using fixture ticks. -The API publishes `127.0.0.1:18080:18080`. Keep that loopback binding unless a -later deployment task adds a reverse proxy, TLS, and explicit operator auth. +The API publishes `127.0.0.1:${NFI_ENGINE_HOST_PORT:-18080}:18080`. Keep that +loopback binding unless a later deployment task adds a reverse proxy, TLS, and +explicit operator auth. +The API container uses `restart: unless-stopped`, Docker init, and bounded +json-file logs (`10m` x 3) so Raspberry Pi and low-resource paper/testnet +installs can survive service restarts without unbounded log growth. This is an +operator reliability setting, not a live-money deployment guarantee. + +## Raspberry Pi 4 RC Profile + +Pi4 release-candidate checks are explicit and reversible. The profile script +does not change CPU, fan, sysctl, journald, Docker daemon, Bluetooth, GPIO, or +boot settings: + +```bash +bash scripts/pi4_rc_profile.sh --project-name nfi-engine-pi4-rc --host-port 18113 --output .omo/evidence/pi4-rc-profile.txt +npm run nfi:pi4:rc-check -- --project-name nfi-engine-pi4-rc --host-port 18113 +bun run nfi:pi4:rc-check -- --project-name nfi-engine-pi4-rc --host-port 18113 +``` + +The script blocks deployment on reduced CPU max frequency, active throttling, +high temperature, missing Docker/Compose/uv/Python, public port binding, +unbounded Docker logs, or low disk space. It prints rollback receipts: + +```bash +bash scripts/uninstall.sh --yes --project-name nfi-engine-pi4-rc +bash scripts/uninstall.sh --purge --yes --dry-run --project-name nfi-engine-pi4-rc +``` ## Volumes @@ -83,12 +137,19 @@ Weak tokens are rejected outside local/dev/test environments. ```bash docker compose run --rm cli nfi-engine --help docker compose run --rm cli nfi-engine config validate --config /config/futures-paper.yaml +docker compose run --rm cli nfi-engine strategy inspect --config /app/examples/x7-futures-paper.yaml --strategy nfi_engine.strategy.nfi_x7:X7NativeStrategy --json ``` +The X7 inspect command verifies the native semantic strategy surface available +to the local dry-run/paper/testnet runtime. It is a runtime-shape check, not a +trade-result claim. + ## Safe Uninstall ```bash bash scripts/uninstall.sh --yes +npm run nfi:uninstall +bun run nfi:uninstall ``` Safe uninstall stops and removes Compose containers while preserving generated @@ -99,12 +160,15 @@ stack but keep config, logs, and SQLite data for the next run. ```bash bash scripts/uninstall.sh --purge --yes +npm run nfi:uninstall:purge:dry-run +bun run nfi:uninstall:purge:dry-run ``` Destructive Purge removes Compose volumes and the generated `.runtime` -directory. Add `--remove-image` only when you also want to remove the local -`nfi-engine:local` image. Add `--backup-dir .runtime-backups/manual` before -purge when you want a copy of the generated runtime directory first. +directory. The npm and Bun commands above are dry-run previews only. Add +`--remove-image` only when you also want to remove the local `nfi-engine:local` +image. Add `--backup-dir .runtime-backups/manual` before purge when you want a +copy of the generated runtime directory first. The script prints the exact removal scope before it acts. It does not scan the filesystem outside the configured runtime directory and known Compose resources. diff --git a/docs/exchange-support-matrix.md b/docs/exchange-support-matrix.md new file mode 100644 index 0000000..7436aef --- /dev/null +++ b/docs/exchange-support-matrix.md @@ -0,0 +1,102 @@ +# Exchange Support Matrix + +This matrix converts Freqtrade-documented exchange coverage into NFI Engine +capability levels. It does not mean every exchange is verified in NFI Engine. + +Source checked on 2026-06-14. Bybit local testnet adapter evidence checked on +2026-06-21: +`https://www.freqtrade.io/en/stable/exchanges/`. + +## Verification levels + +| Level | Meaning | Live-mode rule | +| --- | --- | --- | +| `verified` | NFI Engine has exact exchange evidence from fixture, testnet, or sandbox runs. | Live can only proceed through normal live gates after preflight, credential audit, balance cap, circuit breaker, and reconciliation checks. | +| `candidate` | The exchange appears in Freqtrade's documented support table, but NFI Engine has not promoted it with local evidence. | Dry-run/paper research only; live remains blocked until promoted. | +| `generic-unverified` | A broader CCXT-style or custom exchange id can be configured/probed later, but is not first-class. | Live blocked; capability discovery may produce a report, not a trade path. | + +Promotion rule: never promote an exchange from `candidate` or +`generic-unverified` to `verified` from a docs table alone. Promotion requires +a named artifact under `.omo/evidence/` or a checked-in deterministic fixture. + +## Candidate exchange matrix + +| Exchange | Spot | Futures | Margin mode from Freqtrade docs | Stoploss on exchange from Freqtrade docs | Market-order note | Trailing stop | NFI Engine level | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Binance | documented | documented | futures: isolated, cross | spot: limit; futures: market, limit | market stoploss documented for futures; entry/exit market profile not verified locally | not profiled | `candidate` | +| Bingx | documented | not in overview table | not in overview table | spot: market, limit | market stoploss documented for spot; entry/exit market profile not verified locally | not profiled | `candidate` | +| Bitmart | documented | not in overview table | not in overview table | spot: not available | market stoploss not documented in overview | not profiled | `candidate` | +| Bitget | documented | documented | futures: isolated | spot: market, limit; futures: market, limit | market stoploss documented; entry/exit market profile not verified locally | not profiled | `candidate` | +| Bybit | documented | documented | futures: isolated | spot: not available; futures: market, limit | testnet adapter covers sandbox enablement, market/limit order mapping, balance fetch, cancel/fetch order, funding fallback, and typed reject paths | not profiled | `verified` | +| Gate.io | documented | documented | futures: isolated | spot: limit; futures: limit | market stoploss not documented in overview | not profiled | `candidate` | +| HTX | documented | not in overview table | not in overview table | spot: limit | market stoploss not documented in overview | not profiled | `candidate` | +| Hyperliquid | documented | documented | futures: isolated, cross | spot: not available; futures: limit | Freqtrade notes that market orders are simulated by ccxt; NFI Engine must verify this separately | not profiled | `candidate` | +| Kraken | documented | separate Kraken Futures id | not in overview table | spot: market, limit | market stoploss documented for spot; entry/exit market profile not verified locally | not profiled | `candidate` | +| Kraken Futures | not applicable | documented | futures: isolated | futures: market, limit | market stoploss documented for futures; entry/exit market profile not verified locally | not profiled | `candidate` | +| OKX | documented | documented | futures: isolated | spot: limit; futures: limit | market stoploss not documented in overview | not profiled | `candidate` | +| Bitvavo | documented | not in overview table | not in overview table | spot: not available | market stoploss not documented in overview | not profiled | `candidate` | +| Kucoin | documented | not in overview table | not in overview table | spot: market, limit | market stoploss documented for spot; entry/exit market profile not verified locally | not profiled | `candidate` | + +## Generic-unverified path + +NFI Engine accepts additional exchange ids through the report-only capability +discovery boundary: + +```bash +uv run nfi-engine exchange capabilities --exchange mexc --trading-mode futures --format json +``` + +Unknown ids are labeled `generic-unverified`, `source=generic-discovery`, and +`can_configure=false`. This does not promote the exchange into config, +paper/testnet, or live execution. Config validation still returns +`EXCHANGE_UNSUPPORTED` until a real registry profile and local evidence exist. +Because arbitrary ids have unknown credential models, generic reports leave +`credential_fields=[]` instead of guessing `api_key` / `api_secret`. + +The result must remain `generic-unverified` until there is local evidence for: + +- market metadata fetch. +- account/wallet balance fetch with redacted credentials. +- spot/futures/margin capability detection. +- stoploss and market-order capability detection. +- testnet or sandbox behavior when the exchange provides it. +- fixture replay for failure cases such as stale data, rejected order types, API + lag, and permission denial. + +## Credential boundary + +Exchange setup means exchange API credentials or exchange-specific signing +credentials. It never means the local login token, wallet seed phrases, main +wallet private keys, or withdrawal keys. For exchanges with special credential +models, such as Hyperliquid, NFI Engine must present the requirement as a +high-risk exchange credential path and keep live mode blocked until a separate +permission and sandbox proof exists. + +## Runtime registry contract + +The runtime registry now stores each capability as data, not as hard-coded UI +branches. S4 implemented the first registry spine in +`src/nfi_engine/exchange/capability_models.py`, +`src/nfi_engine/exchange/seed_profiles.py`, +`src/nfi_engine/exchange/capabilities.py`, +`src/nfi_engine/config/validators.py`, and +`src/nfi_engine/preflight/exchange_checks.py`. + +- exchange id and display name. +- `verified`, `candidate`, or `generic-unverified` level. +- spot, futures, and margin capability. +- stoploss-on-exchange mode. +- market-order support. +- trailing-stop, testnet, sandbox, and data-only availability. +- credential fields required. +- evidence artifact path and checked date. + +As of 2026-06-15, `nfi-engine exchange check` can inspect a config-backed +profile or a direct `--exchange` id such as `generic-ccxt`. +`nfi-engine exchange capabilities` emits a typed JSON/text capability document +for registry profiles and arbitrary report-only ids. Config/preflight validation +consume only executable registry profiles. The seeded Freqtrade-documented +exchanges remain `candidate` until NFI Engine has its own fixture, sandbox, or +testnet proof. As of 2026-06-21, the local deterministic simulator and the +Bybit testnet adapter lane are promoted to `verified`; live exchange orders are +still blocked by the current milestone policy. diff --git a/docs/freqtrade-feature-coverage.md b/docs/freqtrade-feature-coverage.md index 4a29161..6b205b0 100644 --- a/docs/freqtrade-feature-coverage.md +++ b/docs/freqtrade-feature-coverage.md @@ -43,7 +43,8 @@ each one. | Notifications | M1 done, M2 surface | Existing notifiers stay adapter-based; the home page can surface notifier health and support-report context. | `operator-usability`, `safety` | | Webhooks | M1 done | Generic webhook notification is available; M2 keeps it redacted and non-blocking. | `safety`, `oss-polish` | | Futures | M1 done, M2 setup | Futures mode is supported through typed domain rules and setup guidance, with live execution still gated. | `safety`, `operator-usability` | -| Strategy callbacks | M1 done, M3+ expand | Adapter boundary supports NFI-shaped research; broader callback parity must be pinned to tests. | `strategy-research` | +| Exchange support | S1 boundary, S4 build | Freqtrade-documented exchanges are recorded as `candidate`; NFI Engine promotes only fixture/testnet/sandbox-backed profiles to `verified`, and broader exchange ids stay `generic-unverified`. See [exchange-support-matrix.md](exchange-support-matrix.md). | `safety`, `operator-usability`, `oss-polish` | +| Strategy callbacks | S2 core contract | Adapter inspection classifies callbacks as `supported`, `partial`, or `excluded`; sandbox can emit a clean-room JSON compatibility report for local strategy specs. Broader runtime parity still waits for signal/protection timeline evidence. | `strategy-research` | | Data downloading | M3+ backlog | Do not add network-heavy data ingestion to M2; design later around typed datasets and benchmarked loading. | `strategy-research`, `performance` | | Persistence | M1 done, M2 read models | SQLite repositories remain the source of truth; dashboard paths use bounded list queries. | `performance`, `safety` | | Backup | M1 done, M2 shortcut | Support Bundle Plus and one-click backup context make maintenance simpler without leaking secrets. | `safety`, `oss-polish` | diff --git a/docs/nfi-x7-compatibility.md b/docs/nfi-x7-compatibility.md new file mode 100644 index 0000000..c943b8a --- /dev/null +++ b/docs/nfi-x7-compatibility.md @@ -0,0 +1,107 @@ +# NFI X7 Compatibility Boundary + +This document records the clean-room compatibility target for NFI Engine. It is +a product contract for adapter behavior, not a copy plan for upstream strategy +logic. + +## Clean-room provenance + +- External target: `https://github.com/iterativv/NostalgiaForInfinity/blob/main/NostalgiaForInfinityX7.py`. +- Refreshed on: 2026-06-20. +- Observed upstream commit: `e9b601b0b3efe342b5ab14205da71e054625121d`. +- Observed raw sha256: `6ee4253f805229f9e7d38e6045c8f623f839fa1fc154b9c8aca0049d8515c3bf`. +- Observed raw strategy version: `v17.4.258`. +- Parent plan note: earlier 2026-06-14 observations are superseded by this + refreshed 2026-06-20 observation. +- NFI Engine uses public behavior, callback names, and runtime shape as the + reference. It does not vendor, translate, rename, or paste upstream code. +- Do not copy Freqtrade, FreqUI, NostalgiaForInfinity source, docs prose, + strategy internals, parameter trees, tag lists, or UI patterns. + +## NFI X7 target facts + +| Fact | Observed target | NFI Engine interpretation | +| --- | --- | --- | +| Strategy class | `NostalgiaForInfinityX7` | External target name for reports only. | +| Freqtrade interface | `INTERFACE_VERSION = 3` | Compatibility adapter should understand interface-v3-shaped callbacks. | +| Version | `v17.4.258` | Report metadata only; never a vendored implementation version. | +| Base timeframe | `5m` | Clean-room fixtures and reports should default to 5-minute base candles. | +| Informative timeframes | `15m`, `1h`, `4h`, `1d`; BTC informatives observed separately | Data-provider contract must make multi-timeframe availability explicit. | +| Trading surface | Freqtrade strategy callbacks plus long/short signal columns | NFI Engine should model callback availability and signal columns, not upstream conditions. | + +## Supported + +Supported means the current engine has a concrete clean-room surface or an +adapter contract that can be exercised without upstream code. + +| Surface | Status | Boundary | +| --- | --- | --- | +| Strategy identity metadata | Supported | Reports may mention `NostalgiaForInfinityX7`, `INTERFACE_VERSION`, `v17.4.258`, and `5m` as observed facts. | +| Core signal callbacks | Supported shape | `populate_indicators`, `populate_entry_trend`, and `populate_exit_trend` are adapter-level callback names. The engine must not copy the upstream indicator graph. | +| Signal columns | Supported shape | Clean-room fixtures may expose long/short entry and exit signals with generic tags. | +| Base timeframe | Supported default | `5m` is the compatibility baseline for NFI-shaped reports and fixtures. | +| Sandbox boundary | Supported | User-supplied strategy loading remains behind the local sandbox/import policy. | +| Report wording | Supported | Reports may say a callback is present, absent, supported, partial, or excluded. They must not claim trade parity. | +| Compatibility report harness | Supported | `nfi-engine sandbox check --output` writes a clean-room JSON report for local strategy specs. | +| Native positioning decisions | Supported shape | `custom_stake_amount`, `leverage`, `order_filled`, and `adjust_trade_position` now route through typed engine-owned stake, leverage, fill snapshot, and bounded adjustment decisions. | +| Native protections and confirmations | Supported shape | `confirm_trade_entry`, `confirm_trade_exit`, pair-lock, cooldown, stale-data, circuit-breaker, live-confirmation, and bounded `bot_loop_start` decisions route through typed engine-owned guards with no hidden network I/O or raw config mutation. | +| Native backtest timeline | Supported shape | Deterministic backtest JSON records native X7 entry reasons such as `x7-long-momentum-balanced` without fixture `signal_side` fields. | +| Native paper timeline | Supported shape | X7 paper runs load the configured native strategy adapter, derive visible OHLCV rows from ticks, record semantic entry reasons without fixture `signal_side`, and keep fixture signals only for explicit demo/legacy runs. Paper orders remain simulated and pass through existing risk caps such as `max_open_trades`. | +| Native paper safety gates | Supported shape | X7 paper startup runs preflight, fetches wallet balance through the exchange adapter boundary, passes the resulting account snapshot into risk quotes, and blocks unsafe leverage before strategy timelines or simulated orders are created. HTTP wallet/runtime health surfaces expose the same local safety state. | +| Native semantic runtime install path | Supported shape | `examples/x7-futures-paper.yaml`, `nfi_engine.strategy.nfi_x7:X7NativeStrategy`, final smoke, and the release wording scan exercise the dry-run/paper/testnet path without Freqtrade as a runtime dependency. | +| Pi4 RC benchmark/resource gate | Supported shape | The 2026-06-22 T5A evidence resolves the native X7 backtest sample warning on the measured Pi4 without raising the `1000 ms` budget. `claim_allowed=false` remains. | + +## Partial + +Partial means NFI Engine can name the surface, but product behavior still needs +typed contracts, fixtures, and runtime evidence before it becomes verified. + +| Surface | Current partial scope | Required next evidence | +| --- | --- | --- | +| `informative_pairs` | Callback name can be detected; visible-row and missing-frame contracts are tested. | Multi-timeframe fixture with missing-data, stale-data, and pair metadata cases. | +| Multi-timeframe indicators | Target facts are known; upstream indicator internals are excluded. | Clean-room strategy fixture that consumes separate base/informative frames. | +| `custom_exit` | Callback is part of the target surface; full Freqtrade exit semantics are not verified. | Typed exit callback result model and deterministic backtest/paper replay. | +| `custom_stake_amount` | Native stake decision caps proposed stake to allocation and available-balance inputs, and paper startup now feeds fetched wallet snapshots into risk requests. | Testnet exchange-adapter evidence for non-simulator wallet snapshots and capability-specific allocation limits. | +| `adjust_trade_position` | Native adjustment decisions are disabled by default and bounded when explicit max/available inputs exist. | Position timeline model with circuit-breaker behavior. | +| `leverage` | Native default is 3x and the decision can cap against a supplied max; `risk.quote_order` enforces configured max leverage. | Exchange capability profile evidence flowing into runtime requests. | + +## Excluded + +Excluded means the work is intentionally out of scope for this product boundary. + +| Exclusion | Reason | +| --- | --- | +| Vendoring upstream `NostalgiaForInfinityX7.py` | The project is clean-room and the upstream file is not vendored. | +| Copying indicator conditions, parameter blocks, tags, pair filters, or blacklist internals | These are upstream strategy internals, not a public adapter contract. | +| Full NFI X7 trade parity | Market data, exchange details, Freqtrade internals, and upstream strategy internals make parity an unsafe claim. | +| Profitability or performance superiority claims | Requires separate measured evidence and still cannot imply future returns. | +| Freqtrade DB, wallet, plugin, or runtime internals | NFI Engine keeps native storage, risk, sandbox, and operator flows. | +| Live exchange orders from strategy callbacks | Live mode remains behind explicit setup, preflight, exchange capability checks, balance caps, circuit breakers, reconciliation, kill switch, and user confirmation. | + +## Product rule + +The compatibility goal is simple: NFI Engine should make NFI-shaped strategy +research easy, deterministic, and safe on low-resource hardware while keeping +the implementation original. If a future report says `verified`, the proof must +point to a local fixture, testnet/sandbox run, or deterministic replay artifact. + +Public wording that references this compatibility boundary must follow +[release-wording.md](release-wording.md), including evidence links +for any parity, performance, or readiness statements. + +Current local evidence for this boundary is grouped under: + +```text +.omo/evidence/2026-06-20-nfi-x7-semantic-port/ +.omo/evidence/2026-06-21-x7-live-readiness-pi4-rc/ +``` + +Current release-candidate status: + +- `coverage_state=verified`, `pending_modules=[]`, and native X7 semantic + evidence are available for paper/testnet operation. +- Raspberry Pi 4 evidence exists for install/bootstrap, M2/X7 benchmark, + paper soak, reversible deployment, and T5A budget resolution on the measured + device. +- Live exchange orders remain excluded until a separate live-execution plan is + approved and verified. diff --git a/docs/operations.md b/docs/operations.md index aa900ac..36fa0e0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -17,7 +17,10 @@ uv run nfi-engine preflight check --profile local-paper --config examples/spot-p Preflight checks config validity, profile compatibility, local API binding, database/log paths, notifier dry-run readiness, pairlist validity, Docker volume -shape, and exchange/testnet safety rules. +shape, and exchange/testnet safety rules. If live intent is present, it also reports +live hardening blockers for credentials, exchange API permissions, startup +reconciliation, circuit breakers, and X7 semantic coverage while keeping +`LIVE_TRADING_OUT_OF_SCOPE` as the startup blocker. ## Config Inspection @@ -63,3 +66,18 @@ For performance-sensitive issues, attach benchmark evidence from the current machine instead of a subjective "feels slow" report. M2 baselines cover dashboard snapshot latency, home render timing, chart render timing, startup smoke timing, and install smoke timing where practical. + +## Current RC Evidence + +The current paper/testnet release-candidate evidence root is: + +```text +.omo/evidence/2026-06-21-x7-live-readiness-pi4-rc/ +``` + +Use that root when answering "where are we now?" for X7 operation. It contains +operator workflow browser evidence, wallet/API setup gates, testnet lifecycle +proof, runtime-control safety, update rollback proof, Pi4 install/soak/deploy +receipts, and the T5A Pi4 X7 benchmark budget resolution. Treat it as +paper/testnet RC evidence only; live order execution still requires a separate +approved plan. diff --git a/docs/performance.md b/docs/performance.md index a17a6c7..d105a2c 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -28,11 +28,183 @@ not need Freqtrade installed. - `dashboard_snapshot_latency`: builds and serializes the dashboard snapshot contract from empty fixture read models. - `home_render_smoke`: renders the local Home HTML. - `chart_render_smoke`: renders the dashboard chart shell without a heavy chart dependency. +- `backtest_720_candle_latency`: runs a deterministic 720-candle clean-room backtest workload. +- `x7_strategy_inspect_latency`: inspects native X7 callbacks and the semantic coverage ledger. +- `x7_feature_graph_latency`: builds the native X7 feature graph from synthetic OHLCV and informative frames. +- `x7_backtest_sample_latency`: runs a bounded native X7 backtest sample without Freqtrade runtime imports. +- `x7_paper_sample_latency`: runs a bounded native X7 paper sample with temporary SQLite and no live orders. - `install_smoke`: generates setup config in a temporary runtime directory and validates it. These are baseline checks, not tuning proof. If a measurement exceeds its local budget, the report marks it as `warn` so maintainers can decide whether to optimize or update the budget with evidence. +Raspberry Pi 4 wording needs a benchmark report captured on Pi hardware; local +x86_64 or CI runs are only same-machine regression evidence. + +## Current X7 Semantic-Port Baseline + +The 2026-06-20 Todo 15 run is workstation evidence only: + +```bash +uv run nfi-engine benchmark m2 --config examples/futures-paper.yaml --samples 5 --output .omo/evidence/2026-06-20-nfi-x7-semantic-port/task-15-benchmark.json +``` + +All ten measurements passed their local budgets on WSL2 x86_64: + +| Measurement | Samples | Result ms | Budget ms | Payload bytes | +| --- | ---: | ---: | ---: | ---: | +| `startup_smoke` | 5 | 123.740 | 1000.0 | 54 | +| `dashboard_snapshot_latency` | 5 | 0.121 | 50.0 | 2391 | +| `home_render_smoke` | 5 | 0.323 | 50.0 | 33733 | +| `chart_render_smoke` | 5 | 0.003 | 5.0 | 793 | +| `backtest_720_candle_latency` | 5 | 11.898 | 1000.0 | 720 | +| `x7_strategy_inspect_latency` | 5 | 0.200 | 50.0 | n/a | +| `x7_feature_graph_latency` | 5 | 20.352 | 100.0 | n/a | +| `x7_backtest_sample_latency` | 5 | 545.303 | 1000.0 | n/a | +| `x7_paper_sample_latency` | 5 | 197.530 | 1000.0 | n/a | +| `install_smoke` | 5 | 4.007 | 1000.0 | 654 | + +This locks a local regression baseline for the native X7 strategy surface: +strategy inspect, feature graph, backtest sample, and paper sample. It does not +publish a Raspberry Pi 4 claim or a Freqtrade comparison claim; the report keeps +`claim_allowed=false`. + +## Current Raspberry Pi 4 X7 RC Baseline + +The 2026-06-22 T5A run is actual Raspberry Pi 4 hardware evidence for the +native X7 M2 surface after feature-row allocation tuning: + +```bash +uv run nfi-engine benchmark m2 --config examples/x7-futures-paper.yaml --samples 3 --output /tmp/nfi-x7-m2-pi4-final1.json +``` + +Hardware and runtime: + +- Raspberry Pi 4 Model B Rev 1.5 +- Debian Raspberry Pi OS aarch64, kernel `6.12.75+rpt-rpi-v8` +- Python 3.13.5 via the staged Pi user-home toolchain +- CPU max frequency `1800000` KHz +- `vcgencmd get_throttled`: `throttled=0x0` +- Temperature snapshot after evidence capture: `53.5C` +- `claim_allowed=false` + +The repeated same-Pi runs resolved the previous `x7_backtest_sample_latency` +warning without increasing the 1000 ms budget: + +| Evidence file | Samples | `x7_feature_graph_latency` ms | `x7_backtest_sample_latency` ms | Budget ms | Status | +| --- | ---: | ---: | ---: | ---: | --- | +| `task-05a-pi4-x7-backtest-budget/nfi-x7-m2-pi4-final1.json` | 3 | 20.706 | 832.086 | 1000.0 | pass | +| `task-05a-pi4-x7-backtest-budget/nfi-x7-m2-pi4-final2.json` | 3 | 20.241 | 836.042 | 1000.0 | pass | +| `task-05a-pi4-x7-backtest-budget/nfi-x7-m2-pi4-final3.json` | 3 | 17.688 | 849.583 | 1000.0 | pass | + +Failure proof is also captured: an impossible X7 baseline fails with +`PERFORMANCE_REGRESSION` instead of silently passing. This is an internal Pi4 +RC budget result, not a public speed claim against Freqtrade. + +## Current T15 Baseline + +The 2026-06-16 T15 run is workstation evidence only: + +```bash +uv run nfi-engine benchmark m2 --config examples/futures-paper.yaml --samples 5 --output .omo/evidence/2026-06-16-product-completion/task-15-benchmark.json +``` + +All six measurements passed their local budgets on WSL2 x86_64: + +| Measurement | Samples | Result ms | Budget ms | Payload bytes | +| --- | ---: | ---: | ---: | ---: | +| `startup_smoke` | 5 | 176.808 | 1000.0 | 48 | +| `dashboard_snapshot_latency` | 5 | 0.285 | 50.0 | 2391 | +| `home_render_smoke` | 5 | 0.550 | 50.0 | 31693 | +| `chart_render_smoke` | 5 | 0.005 | 5.0 | 793 | +| `backtest_720_candle_latency` | 5 | 10.374 | 1000.0 | 720 | +| `install_smoke` | 5 | 5.147 | 1000.0 | 654 | + +Pi4 claim blocked: these numbers do not prove Raspberry Pi 4 performance. They +only lock a local no-regression baseline until the same command is run on actual +Pi4 hardware and the report is stored with machine metadata. + +## Current Raspberry Pi 4 Tuned Baseline + +The 2026-06-16 Pi4 tuned run is actual Raspberry Pi 4 hardware evidence: + +```bash +uv run nfi-engine benchmark m2 --config examples/futures-paper.yaml --samples 5 --output .omo/evidence/2026-06-16-pi4/m2-benchmark-after-tuning.json +``` + +Hardware and runtime: + +- Raspberry Pi 4 Model B Rev 1.5 +- Debian GNU/Linux 13 `trixie`, aarch64 +- Python 3.12.13 via `uv` +- 4 CPU cores, 3.7Gi RAM, USB root disk at `/dev/sda2` +- `vcgencmd get_throttled`: `throttled=0x0` +- Pi4 deployment is on hold. After the hold decision, Pi-specific NFI tuning was + removed from the host: + - `nfi-engine-pi4-performance.service`: removed + - `nfi-engine-pi4-quiet-cpufreq.service`: removed + - `nfi-engine-thermal-guard.service`: removed + - current governor after reboot: `ondemand` + - CPU max remains uncapped at `1800000` KHz +- Pi-specific sysctl, journald, Docker daemon log-policy, Bluetooth disable, and + GPIO fan boot overlays were removed. The post-cleanup boot config only keeps + the stock `enable_uart=1` line from this investigation. + Post-cleanup sysctl values are back to common defaults such as + `vm.swappiness=60`, `vm.dirty_background_ratio=10`, and `vm.dirty_ratio=20`. + +Pi4 deployment status: hold. The engine is verified on the measured Pi4, but +the current two-wire 5V fan is too loud for acceptable operator UX. Treat Pi4 +as a proven lab target, not the recommended always-on deployment target, until +cooling is changed to one of: + +- fan removed with heatsink-only monitoring and thermal guard enabled +- low-noise 5V fan +- GPIO/PWM-controllable fan or MOSFET fan controller + +## Raspberry Pi 4 RC Deployment Profile + +The 2026-06-22 RC profile keeps the Pi host stock by default. It does not +install services, lower CPU max frequency, touch boot files, disable Bluetooth, +change sysctl/journald/Docker daemon settings, or assume a fan controller: + +```bash +bash scripts/pi4_rc_profile.sh --project-name nfi-engine-pi4-rc --host-port 18113 --output .omo/evidence/2026-06-21-x7-live-readiness-pi4-rc/task-13-pi4-deploy/pi4-rc-profile.txt +bash scripts/install.sh --yes --paper --testnet --project-name nfi-engine-pi4-rc --host-port 18113 +bash scripts/uninstall.sh --yes --project-name nfi-engine-pi4-rc +``` + +The profile is a deployment gate, not an optimization toggle. It blocks on +reduced CPU max frequency, active throttling, high temperature, missing +runtime tools, public Compose binding, unbounded Docker logs, or low disk +space. Passing it means the RC stack is conservative and reversible on that +machine; it still does not prove live-money readiness or public speed claims. + +All six performance-restored measurements passed their local budgets on Pi4: + +| Measurement | Samples | Result ms | Budget ms | Payload bytes | +| --- | ---: | ---: | ---: | ---: | +| `startup_smoke` | 5 | 471.405 | 1000.0 | 48 | +| `dashboard_snapshot_latency` | 5 | 0.385 | 50.0 | 2391 | +| `home_render_smoke` | 5 | 1.095 | 50.0 | 31693 | +| `chart_render_smoke` | 5 | 0.011 | 5.0 | 793 | +| `backtest_720_candle_latency` | 5 | 39.013 | 1000.0 | 720 | +| `install_smoke` | 5 | 12.633 | 1000.0 | 654 | + +Compared with the first untuned Pi4 run from the same device, the current +performance-restored run remains faster across every M2 surface. The largest +movement is `startup_smoke` (`763.939ms` to `471.405ms`). A previous +performance-governor run reached `424.967ms`; keep both reports as +same-device regression evidence rather than portable speed claims. + +Thread comparison also passed for `POLARS_MAX_THREADS=1`, `2`, and `4`; the +4-thread run was marginally fastest for the deterministic 720-candle backtest. +No forced Polars thread cap is applied by default because the stock 4-core +setting is already within budget and keeps the backtest path fastest. + +This Pi4 baseline supports low-resource regression tracking for NFI Engine on +the measured hardware. It still does not support public speed claims against +Freqtrade because `claim_allowed=false` and no same-machine black-box Freqtrade +comparison was run. ## Release Smoke Gate diff --git a/docs/release-status.md b/docs/release-status.md new file mode 100644 index 0000000..14da4e4 --- /dev/null +++ b/docs/release-status.md @@ -0,0 +1,103 @@ +# Release Status + +Date: 2026-06-24 KST +Current review note: 2026-06-24 KST + +This document is the current status summary for the evidence-backed +paper/testnet release-candidate lane. It is not approval for real-money live +order execution. + +## Verdict + +The RC lane is approved for paper/testnet evaluation within the documented +safety boundary, based on the captured evidence root below. The latest +2026-06-24 documentation review adds G072 browser evidence: language and runtime +state update without forced refresh, auth/CSRF/read-only/live-intent probes stay +blocked, and KO/EN/EL desktop/mobile visual QA has no overflow. A 2026-06-23 +fresh rerun of the full Docker smoke was blocked by host state because Docker +was unavailable in that WSL session and Docker Desktop was not reachable. That +rerun blocker is not treated as a product pass. + +```text +.omo/evidence/2026-06-21-x7-live-readiness-pi4-rc/ +``` + +Final local gates under that root include: + +- `f1-plan-compliance.md`: Todos 1-14 covered, plan evidence verified. +- `f3-real-manual-qa.md`: final smoke drove CLI, API, Docker, browser, backup, + paper-run, and X7 inspect surfaces. +- `f4-scope-fidelity.md`: final verdict is paper/testnet RC only. +- `ulw-reconcile-g050/f3-current-manual-qa.md`: current 2026-06-23 F3 rerun + keeps CLI/browser/paper/release checks passing, but records the external + Docker Desktop/WSL blocker for the fresh final Docker smoke. +- `ulw-reconcile-g063/summary.assertions.json`: current 2026-06-23 Pi4 RC + profile reconciliation keeps the reversible deploy boundary valid without a + fresh SSH deploy mutation. +- `ulw-reconcile-g072/summary.assertions.json`: current 2026-06-24 UI review + keeps no-forced-refresh language/runtime behavior, browser security probes, + and desktop/mobile visual QA passing. +- `final-gate/pytest.txt`: `497 passed`. +- `final-gate/release-wording-scan-final.txt`: `violations=0`. + +## Completed + +- Native clean-room X7 semantic inspection remains verified with + `pending_modules=[]`. +- Paper/testnet setup has CLI, API, browser, Docker, and Pi4 evidence. +- Operator workflow covers exchange selection, exchange API credentials, + recommended 3x leverage, explicit wallet balance fetch, allocation amount, + spot/futures intent, dry-run intent, live preview blocking, preflight, and + start/pause/resume/stop controls. +- Wallet setup means exchange API credentials only. It does not mean wallet + seed phrases; it does not mean private keys, withdrawal keys, or local login + tokens. +- UI state changes no longer require forced refresh in the covered operator + paths. G072 verifies EN -> KO -> EL -> EN locale changes, runtime + start/pause/resume/stop controls, local-only browser requests, and + desktop/mobile KO/EL screenshots with no overflow. +- One-line shell, npm, and Bun install/uninstall paths were re-verified in G011: + `.omo/evidence/2026-06-21-x7-live-readiness-pi4-rc/ulw-reconcile-g011/`. +- Pi4 RC checks include user-home toolchain staging, benchmark budget + resolution, 500-tick X7 paper soak, loopback deployment profile, stock-host + rollback receipts, no throttling in the captured runs, and G063 revalidation + of the non-mutating `pi4_rc_profile` edge path. +- Release wording policy blocks unsupported claims and the deterministic scan + currently reports zero violations. + +## Partial + +- Pi4 is an internal RC lane for the measured hardware. It is not a public speed + comparison claim, and cooling UX still needs a new heatsink/fan measurement. +- The update button is proof-only: preview/apply/rollback receipts exist, but + automatic GitHub source mutation remains outside this RC. +- Exchange support is capability/evidence promoted. Candidate and + generic-unverified exchanges remain blocked from runtime trade paths until + fixture, sandbox, or testnet evidence promotes them. +- The dashboard is usable as an operator cockpit, but richer position, account, + PnL, and risk compression still belongs to later work. + +## Blocked Or Not Done + +- Real-money live order execution remains blocked pending a separate approved + live-execution plan. +- Blocked: public Freqtrade superiority, profit, safety guarantee, upstream X7 + trade parity, and public Pi4 performance comparison claims. +- Real exchange credentials must be re-entered by the operator in local runtime + secret storage; they are not recoverable from sanitized RC evidence. +- Fresh Docker final smoke rerun is blocked until Docker Desktop/WSL + integration is available again. Prior isolated Docker proof remains evidence, + but fresh release claims should rerun `bash scripts/final_smoke.sh`. +- Multi-OS install/uninstall repetition beyond the current matrix still needs + more evidence. + +## Next Work + +1. Complete G074 full quality gate or name only pre-existing unrelated blockers + with exact evidence. +2. Start a separate live-execution design only after the RC is reviewed. +3. Add richer dashboard account/position/risk compression for repeated operator + use. +4. Re-run Pi4 long-run thermal evidence after the cooling hardware is changed. +5. Promote exchanges from candidate to verified only through fixture, + sandbox, or testnet evidence. diff --git a/docs/release-wording.md b/docs/release-wording.md new file mode 100644 index 0000000..576cd2a --- /dev/null +++ b/docs/release-wording.md @@ -0,0 +1,94 @@ +# Release Wording Guardrails + +## Scope + +This policy defines what can be stated in NFI Engine public documentation and release communication. +The goal is to keep claims evidence-bound, safe, and aligned with the clean-room +compatibility boundary. + +## Allowed phrasing + +The following phrases are allowed when they describe what is actually implemented and +supported, and when backed by local evidence: + +- `native NFI-shaped X7 runtime` +- `dry-run/paper/testnet path` +- `local evidence-backed benchmark` +- `clean-room compatibility boundary` +- `Freqtrade-shaped callbacks` when referring only to callback names and adapter contracts. + +Allowed phrasing should stay implementation- or process-oriented and avoid guarantees. + +## Blocked phrasing (do not publish as-is) + +Unless a direct exception exists in a release ticket and dedicated evidence is attached, +the following language is blocked: + +- guaranteed profit +- profit promise +- guaranteed safety / safety guarantee +- full NFI X7 trade parity +- 100% parity +- superior / better than Freqtrade +- live-money ready +- 100% complete +- Pi4 public performance claims without hardware-stamped benchmark evidence +- Korean equivalents such as `Freqtrade보다 우월`, `수익 보장`, `안전 보장`, + `완전 패리티`, `실거래 준비 완료`, and `100% 완료`. + +## Evidence rule for public claims + +Any public claim that uses benchmarks, compatibility, runtime, performance, or safety +positioning must link to evidence generated in: + +- `.omo/evidence/...` +- `.omo/ulw-loop/evidence/...` + +Each claim in release notes, README, and docs should include: + +1. The artifact path. +2. The producer command or process. +3. A date stamp and dataset/config/build scope. + +If a claim has no matching artifact, treat it as blocked and rewrite to a neutral +statement (for example: "implemented", "under development", "planned", or +"measured in local smoke"). + +## Current RC wording boundary + +As of 2026-06-24, the evidence-backed public boundary is: + +- The project has a native NFI-shaped X7 paper/testnet release-candidate lane. +- The current RC evidence root is + `.omo/evidence/2026-06-21-x7-live-readiness-pi4-rc/`. +- The Pi4 evidence may be described as measured internal RC evidence for one + Raspberry Pi 4 device, with `claim_allowed=false`. +- The G072 UI evidence may be described as local browser QA for no-forced-refresh + language/runtime updates, protected browser/API paths, and desktop/mobile + KO/EN/EL visual checks. +- Real-money live order execution remains blocked pending a separate approved + plan and evidence set. + +Avoid wording that turns the Pi4 benchmark into a public comparison, guarantee, +or money outcome statement. + +## Documentation check before publishing + +- For each doc sentence using a protected phrase, verify the referenced evidence exists + before merge. +- Avoid absolute performance, parity, and money outcome wording in examples unless evidence + is attached at the sentence level. +- Keep public-facing language concrete, minimal, and reproducible. +- Do not reuse upstream prose from Freqtrade or NFI strategy internals for marketing or + release phrasing. + +## Deterministic wording scan + +Run the local scan before publishing README or docs changes: + +```bash +uv run python scripts/release_wording_scan.py +``` + +The scan must print `violations=0`. Controlled negative tests should use a +temporary file and should not edit public docs just to prove the failure path. diff --git a/docs/safety.md b/docs/safety.md index 2d67410..9a00aec 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -4,6 +4,30 @@ NFI Engine M1 is limited to simulator, paper, and testnet workflows. Confirmed l configuration can be parsed for validation, but runtime commands reject it with `LIVE_TRADING_OUT_OF_SCOPE`. +When `engine.live_trading=true`, preflight now also emits live-readiness hardening +checks without unlocking real-money execution: + +- `LIVE_EXCHANGE_CREDENTIALS` confirms API key fields are present without printing + secret values. +- `LIVE_PERMISSION_HARDENING` blocks unknown or unsafe live API permissions, including + missing read/trade/futures permission, enabled or unknown withdrawal permission, and + missing IP allowlist proof. +- `LIVE_RECONCILIATION_HARDENING` requires startup reconciliation to be explicitly + configured. +- `LIVE_CIRCUIT_BREAKER_HARDENING` requires circuit breakers, positive loss/freshness + budgets, and a manual halt file. +- `LIVE_STRATEGY_HARDENING` requires the native X7 strategy and complete semantic + coverage evidence. + +Passing these hardening checks does not enable live orders. The milestone live lock +still blocks startup until a separate live-execution milestone is designed, reviewed, +and verified. + +Current RC evidence, including the 2026-06-22 Pi4 T5A benchmark resolution, +supports paper/testnet operation only. It does not change the live-order lock, +credential-permission audit requirement, reconciliation requirement, circuit +breaker requirement, or manual halt requirement. + The API defaults to `127.0.0.1`, does not enable CORS by default, and rejects weak operator tokens outside local/dev/test environments. Config and support surfaces redact API tokens and exchange credentials as `REDACTED`. diff --git a/docs/ui.md b/docs/ui.md index df91470..ded57f5 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -12,7 +12,8 @@ chart status, recent errors, and support actions without imitating FreqUI. `/` is the first operator screen. It shows runtime state, exchange mode, open-trade and PnL placeholders, setup readiness, safety blocking reasons, a -pairlist preview, recent error codes, and a support report shortcut. +runtime health snapshot, wallet balance state, a pairlist preview, recent error +codes, an operator cockpit, and a support report shortcut. First-run operators should be able to decide three things from Home without opening YAML: @@ -21,6 +22,71 @@ opening YAML: - whether safety gates are blocking a live-risk action - whether recent errors need a support report +The operator cockpit compresses the first-run decision state into one panel: +configured or missing credentials, dry-run safe or blocked, exchange capability +level, active mode, runtime health, wallet balance state, allocated amount, +leverage, latest error, next action, and where to go next. It is rendered from +typed settings, preflight, logs, dashboard action data, and runtime health data; +it does not read browser storage or raw config dictionaries. + +## Runtime Health And Wallet Fetch + +Detailed runtime health is exposed through protected local JSON at +`GET /api/v1/runtime/health`. It reports one operator state: +`healthy`, `degraded`, or `blocked`, with a next action and typed checks for +heartbeat, preflight, wallet balance, stale dashboard data, manual halt, +disk budget, and memory budget. + +Wallet balance reads stay behind the exchange adapter boundary. Settings uses +an explicit operator action, `POST /api/v1/wallet/balance/fetch`, and Home only +renders the latest typed wallet state. The UI does not fetch wallet balance on +page load, does not run a wallet polling timer, and does not store wallet data, +API keys, bearer tokens, or CSRF tokens in browser storage. + +## Runtime Controls + +Home and Settings expose the same protected runtime controls: + +- `POST /api/v1/start` +- `POST /api/v1/pause` +- `POST /api/v1/resume` +- `POST /api/v1/stop` +- `GET` / `POST /api/v1/runtime/control` + +Pause blocks new entries while keeping state inspectable. Resume requires +preflight and runtime health to allow entries again. Stop moves the local runtime +state toward stopped and does not claim to cancel live exchange orders. The +generic `/runtime/control` endpoint returns stable machine codes for malformed +commands, repeated pause/stop, blocked health, blocked preflight, read-only +mode, and live-unsafe intent. + +The browser script refreshes runtime state on page load and after each command. +It does not poll continuously, does not store runtime state in browser storage, +and reads CSRF only from the page meta tag. Server-side write protection remains +the real gate: CSRF, authenticated session, read-only mode, live-mode safety, +preflight, runtime health, and circuit-breaker checks all stay on the API side. + +## Action Queue + +M2.5 adds a compact action queue to Home and to +`GET /api/v1/dashboard/snapshot`. It is a bounded next-action list, not a +general task system. The queue is built from data already available to the +dashboard snapshot: preflight readiness, recent safe error summaries, configured +pairlist state, and paper/testnet safety state. + +The queue returns at most four actions. Current action targets are: + +- `settings/setup`: setup or preflight issue; Home links to `/settings` +- `logs`: recent runtime errors; Home links to `/logs` +- `settings`: pairlist/config issue; Home links to `/settings` +- `dashboard/status`: safe ready state; Home links to the status strip +- `logs/support-bundle`: support follow-up; Home links directly to + `/api/v1/reports/support-bundle.zip` + +The Home queue must stay cheap for low-resource machines: no extra polling loop, +no UI storage access, no repeated full-config parse, no database read from the +UI renderer, and no live-order shortcut. + ## Language Selector The console supports English, Korean, and Greek. M2 keeps language selection @@ -28,6 +94,10 @@ explicit through config or supported route/session controls; it does not guess from browser locale. Machine codes, audit event IDs, and API contract tokens remain untranslated so support reports stay searchable. +When the operator changes `ui.locale` in Settings and presses Apply, the page +uses the runtime-safe config API and reloads itself. The operator should not +need to press F5 manually. + ## Dashboard Chart The home chart is a local canvas renderer with no external chart library. It @@ -51,12 +121,32 @@ fields, blocks live-trading controls, and sends typed field patches to the API. Runtime-safe fields can be validated, saved as a draft, and applied without editing raw YAML. -Simple Mode is the default first-run editing surface. It keeps exchange, trading -mode, paper/testnet intent, stake sizing, risk preset, locale, and write-only -credential entry visible. The setup preview uses `/api/v1/setup/preview` and -returns redacted config text; credential values are not written into HTML values, -browser storage, logs, or support reports. Advanced Mode stays collapsed for -later tuning. +The first-run setup wizard is the default operator path. It renders this order: +exchange, exchange API key, exchange API secret, recommended leverage `3x`, +API permission audit, risk profile, wallet balance fetch button/state, allocated +amount, futures/spot, and dry-run/live. Dry-run is selected by default. Live remains +visibly gated and setup preview returns `LIVE_TRADING_REQUIRES_CONFIRMATION` +until the explicit live confirmation path exists. + +The API permission audit uses short operator labels for read, trade, futures, +withdrawal, and IP allowlist status. Withdrawal-like permission blocks live +setup; unknown permission remains previewable for dry-run/testnet diagnostics. +Risk profiles are `safe`, `balanced`, and `expert`; `balanced` keeps the 3x +default path, while `expert` requires explicit confirmation before setup or +preflight can pass. + +Simple Mode remains available for everyday runtime-safe edits. It keeps exchange, +trading mode, locale, stake sizing, and max open trades visible. The setup +preview uses `/api/v1/setup/preview` and returns redacted config text; credential +values are not written into HTML values, browser storage, logs, or support +reports. Advanced Mode stays collapsed for later tuning. + +Settings also shows a developer update panel for engine + strategy update state. +Preview, apply, and rollback stay local-only and proof-only: the browser calls +protected local endpoints, receives provenance/backup receipts, and never pulls +from a remote source or mutates runtime config. Apply and rollback require a +backup reference, and unverified provenance stays visibly blocked unless the +operator explicitly acknowledges the local proof gap. Useful flows: @@ -109,6 +199,75 @@ Missing CSRF returns `CSRF_TOKEN_REQUIRED`. Invalid CSRF returns The UI must not store bearer tokens in `localStorage` or `sessionStorage`. +## Browser QA Gate + +T10 adds a real loopback browser QA command for the operator console: + +```bash +npm install +npm run nfi:browser-qa:deps +npm run nfi:browser-qa +``` + +The command starts a QA-only `uv run nfi-engine serve` process on +`127.0.0.1:`, drives Chromium through login, Home, Settings locale +apply, Logs, desktop capture, and mobile capture, then writes evidence under +`.omo/evidence/2026-06-15-product-completion/task-10-browser/`. + +For T14 first-run QA, the same command is run with +`NFI_BROWSER_QA_EVIDENCE_DIR=.omo/evidence/2026-06-15-product-completion/task-14-browser` +and verifies the setup wizard order, dry-run default, 3x recommendation, Home +operator cockpit, update preview/apply/rollback states, secret redaction, live +gate, local-only network, empty browser storage, and mobile overflow. + +For T22 credential/risk QA, the loopback browser evidence under +`.omo/evidence/2026-06-15-product-completion/task-22-browser/` verifies Home and +Settings show API permission audit and risk profile state, language apply works +for EN/KO/EL without manual F5, browser storage stays empty, external requests +stay at zero, and desktop/mobile layouts avoid horizontal overflow. + +For T23 wallet/runtime health QA, the loopback evidence under +`.omo/evidence/2026-06-15-product-completion/task-23-*` verifies explicit wallet +fetch through `POST /api/v1/wallet/balance/fetch`, Home runtime health and +wallet state, missing-credential blockers, no external browser requests, empty +browser storage, and desktop/mobile layouts without horizontal overflow. The +runtime-control follow-up evidence under +`.omo/evidence/2026-06-15-product-completion/task-23-runtime-control-*` verifies +Home and Settings start/pause/resume/stop behavior without manual refresh, +CSRF/read-only/live-unsafe/blocked-health denials, empty browser storage, +loopback-only network requests, and desktop/mobile layouts without horizontal +overflow. + +For T25 operator visual/i18n QA: + +```bash +npm run nfi:browser-qa:wp9 +``` + +The command drives the current loopback UI through login, EN/KO/EL language +switching without manual F5, Home, Settings, Logs, setup, wallet fetch, update +preview/apply/rollback states, data lifecycle controls, pairlist controls, +runtime health/control reads, support bundle export, and desktop/mobile +captures. Evidence is written under +`.omo/evidence/2026-06-17-product-completion/wp9/browser/`. + +The WP9 gate also checks that browser storage stays empty, network requests stay +loopback-local, console errors stay empty, generated tokens do not appear in +evidence, machine codes remain searchable, and Korean/Greek captures have no +body horizontal overflow, clipped text, incoherent overlap, or missing glyphs. + +The gate fails if: + +- any request is not loopback-local +- the generated QA token appears in screenshots or JSON evidence +- browser `localStorage` or `sessionStorage` contains entries after login +- an unexpected console error appears +- mobile captures show horizontal page overflow + +`npm run nfi:browser-qa:deps` prepares rootless local Chromium runtime +libraries and Noto CJK fonts under ignored `.omo/tools/browser-libs/` when the +host image lacks them. + ## Read-Only Mode Set `ui.read_only: true` to allow inspection while blocking mutation: @@ -119,7 +278,7 @@ Set `ui.read_only: true` to allow inspection while blocking mutation: - cannot save/apply config - cannot apply pairlist drafts - cannot restore backups -- cannot start/stop runtime state +- cannot start/pause/resume/stop runtime state Read-only is enforced on the server. Disabled buttons are only the visible operator hint. diff --git a/examples/x7-futures-paper.yaml b/examples/x7-futures-paper.yaml new file mode 100644 index 0000000..28ddc9e --- /dev/null +++ b/examples/x7-futures-paper.yaml @@ -0,0 +1,45 @@ +engine: + environment: local + live_trading: false + live_trading_confirmed: false +exchange: + name: simulator + trading_mode: futures + margin_mode: isolated + testnet: true +strategy: + name: X7NativeStrategy + module: nfi_engine.strategy.nfi_x7:X7NativeStrategy +database: + url: sqlite+aiosqlite:///data/nfi_engine.sqlite3 +risk: + stake_usdt: "10" + max_daily_loss_pct: "0.05" + leverage: "3" + max_leverage: "3" + liquidation_buffer: "0.05" + max_open_trades: "3" + stoploss_pct: "0.10" + minimal_roi: "0.03" + cooldown_seconds: 0 + locked_pairs: "" +backtest: + timerange: null + starting_balance_usdt: "1000" + stoploss_pct: "0.10" + fee_rate: "0.001" + slippage_rate: "0" + max_open_trades: "3" +paper_run: + enabled: true + max_events: 100 +api: + host: 127.0.0.1 + port: 18080 + csrf_enabled: true +ui: + enabled: true + read_only: false +logging: + level: INFO + json_logs: false diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8c63def --- /dev/null +++ b/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "nfi-engine", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nfi-engine", + "version": "0.0.0", + "devDependencies": { + "playwright-core": "1.60.0" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3d76546 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "nfi-engine", + "version": "0.0.0", + "private": true, + "description": "Local one-line operator bootstrap wrappers for NFI Engine.", + "scripts": { + "nfi:browser-qa:deps": "bash scripts/browser_qa_deps.sh", + "nfi:browser-qa": "node scripts/browser_qa_t10.mjs", + "nfi:browser-qa:wp8-3": "node scripts/browser_qa_t24_data_lifecycle.mjs", + "nfi:browser-qa:wp9": "node scripts/browser_qa_t25_visual_i18n.mjs", + "nfi:install": "bash scripts/install.sh --yes --paper --testnet", + "nfi:install:dry-run": "bash scripts/install.sh --yes --paper --testnet --dry-run", + "nfi:uninstall": "bash scripts/uninstall.sh --yes", + "nfi:uninstall:dry-run": "bash scripts/uninstall.sh --yes --dry-run", + "nfi:uninstall:purge:dry-run": "bash scripts/uninstall.sh --purge --yes --dry-run", + "nfi:pi4:rc-check": "bash scripts/pi4_rc_profile.sh" + }, + "devDependencies": { + "playwright-core": "1.60.0" + } +} diff --git a/pyproject.toml b/pyproject.toml index 1b1404c..85ed0be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,8 @@ reportUnnecessaryComparison = "none" target-version = "py312" line-length = 100 src = ["src", "tests"] +extend-exclude = ["scripts/*.sh"] +force-exclude = true [tool.ruff.lint] select = ["ALL"] @@ -121,6 +123,9 @@ source = ["src"] branch = true [tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = true exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", diff --git a/scripts/browser_qa_deps.sh b/scripts/browser_qa_deps.sh new file mode 100644 index 0000000..46c138c --- /dev/null +++ b/scripts/browser_qa_deps.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="${NFI_BROWSER_QA_LIB_ROOT:-.omo/tools/browser-libs}" +lib_dir="$root/root/usr/lib/x86_64-linux-gnu" +font_dir="$root/root/usr/share/fonts" +font_config="$root/fonts.conf" +debs_dir="$root/debs" +packages=(libnspr4 libnss3 libasound2t64 fonts-noto-cjk) +required=( + "$lib_dir/libnspr4.so" + "$lib_dir/libnss3.so" + "$lib_dir/libnssutil3.so" + "$lib_dir/libasound.so.2" + "$font_dir/opentype/noto/NotoSansCJK-Regular.ttc" + "$font_config" +) + +ready=true +for path in "${required[@]}"; do + if [[ ! -e "$path" ]]; then + ready=false + fi +done + +if [[ "$ready" == true ]]; then + printf 'browser QA local libs ready: %s\n' "$lib_dir" + exit 0 +fi + +command -v apt-get >/dev/null +command -v dpkg-deb >/dev/null + +mkdir -p "$debs_dir" "$root/root" +( + cd "$debs_dir" + apt-get download "${packages[@]}" +) +for deb in "$debs_dir"/*.deb; do + dpkg-deb -x "$deb" "$root/root" +done +rm -f "$debs_dir"/*.deb + +font_dir_abs="$(cd "$font_dir" && pwd -P)" +cat > "$font_config" < + + + /usr/share/fonts + $font_dir_abs + +EOF +for path in "${required[@]}"; do + test -e "$path" +done +printf 'browser QA local libs ready: %s\n' "$lib_dir" diff --git a/scripts/browser_qa_runtime.mjs b/scripts/browser_qa_runtime.mjs new file mode 100644 index 0000000..a88f716 --- /dev/null +++ b/scripts/browser_qa_runtime.mjs @@ -0,0 +1,110 @@ +import { existsSync } from "node:fs"; +import net from "node:net"; +import { join } from "node:path"; + +export function browserLaunchEnv(repoRoot) { + const localLibDir = + process.env.NFI_BROWSER_QA_LIB_DIR ?? + join(repoRoot, ".omo/tools/browser-libs/root/usr/lib/x86_64-linux-gnu"); + const fontConfig = + process.env.NFI_BROWSER_QA_FONTCONFIG ?? join(repoRoot, ".omo/tools/browser-libs/fonts.conf"); + const env = { ...process.env }; + if (existsSync(localLibDir)) { + const existing = process.env.LD_LIBRARY_PATH; + env.LD_LIBRARY_PATH = existing ? `${localLibDir}:${existing}` : localLibDir; + } + if (existsSync(fontConfig)) { + env.FONTCONFIG_FILE = fontConfig; + } + return env; +} + +export async function freePort() { + const server = net.createServer(); + await new Promise((resolvePromise, rejectPromise) => { + server.once("error", rejectPromise); + server.listen(0, "127.0.0.1", resolvePromise); + }); + const address = server.address(); + await new Promise((resolvePromise) => server.close(resolvePromise)); + if (!address || typeof address === "string") { + throw new Error("could not allocate free port"); + } + return address.port; +} + +export function isLocalUrl(rawUrl, baseUrl = null) { + if ( + (baseUrl && rawUrl.startsWith(baseUrl)) || + rawUrl.startsWith("data:") || + rawUrl.startsWith("blob:") || + rawUrl === "about:blank" + ) { + return true; + } + try { + const url = new URL(rawUrl); + return ["127.0.0.1", "localhost"].includes(url.hostname); + } catch { + return false; + } +} + +export async function onceExit(process, timeoutMs) { + if (process.exitCode !== null) { + return; + } + await Promise.race([ + new Promise((resolvePromise) => process.once("exit", resolvePromise)), + sleep(timeoutMs).then(() => { + process.kill("SIGKILL"); + }), + ]); +} + +export async function waitForPortClosed(port) { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (await portIsClosed(port)) { + return; + } + await sleep(100); + } + throw new Error(`port ${port} is still open`); +} + +export function resolveChromiumExecutable(repoRoot) { + const candidates = [ + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, + process.env.CHROME_BIN, + join(process.env.HOME ?? "", ".cache/ms-playwright/chromium-1223/chrome-linux64/chrome"), + join(process.env.HOME ?? "", ".cache/ms-playwright/chromium-1200/chrome-linux64/chrome"), + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ].filter(Boolean); + const found = candidates.find((candidate) => existsSync(candidate)); + if (!found) { + throw new Error( + "Chromium executable not found. Set PLAYWRIGHT_CHROMIUM_EXECUTABLE or run `npx playwright install chromium`.", + ); + } + return found; +} + +export function sleep(ms) { + return new Promise((resolvePromise) => { + setTimeout(resolvePromise, ms); + }); +} + +async function portIsClosed(port) { + return await new Promise((resolvePromise) => { + const socket = net.createConnection({ port, host: "127.0.0.1" }); + socket.once("connect", () => { + socket.destroy(); + resolvePromise(false); + }); + socket.once("error", () => resolvePromise(true)); + }); +} diff --git a/scripts/browser_qa_t10.mjs b/scripts/browser_qa_t10.mjs new file mode 100644 index 0000000..ff6f08c --- /dev/null +++ b/scripts/browser_qa_t10.mjs @@ -0,0 +1,244 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { chromium } from "playwright-core"; +import { + browserLaunchEnv, + freePort, + isLocalUrl, + resolveChromiumExecutable, +} from "./browser_qa_runtime.mjs"; +import { removePriorArtifacts, writeJson as writeArtifactJson } from "./browser_qa_t10_artifacts.mjs"; +import { exerciseFailureProbes } from "./browser_qa_t10_failure_probes.mjs"; +import { + cleanupT10Runtime, + createRedactors, + documentRequestCounter, + qaConfig, + waitForServer, +} from "./browser_qa_t10_runtime.mjs"; +import { + captureMobileViews, + exerciseHappyPath, + exerciseInvalidLogin, + storageState, +} from "./browser_qa_t10_setup_flow.mjs"; +import { credentialWordsAppear, securityAudit } from "./browser_qa_t10_security_audit.mjs"; + +const repoRoot = resolve(dirname(new URL(import.meta.url).pathname), ".."); +const evidenceDir = resolve( + repoRoot, + process.env.NFI_BROWSER_QA_EVIDENCE_DIR + ?? ".omo/evidence/2026-06-15-product-completion/task-10-browser", +); +const extraEvidenceDir = process.env.NFI_BROWSER_QA_EXTRA_EVIDENCE_DIR + ? resolve(repoRoot, process.env.NFI_BROWSER_QA_EXTRA_EVIDENCE_DIR) + : null; +const qaToken = process.env.NFI_BROWSER_QA_TOKEN ?? `qa-${randomBytes(18).toString("hex")}`; +const qaExchangeSecret = + process.env.NFI_BROWSER_QA_EXCHANGE_SECRET ?? `exchange-secret-${randomBytes(12).toString("hex")}`; +const { redact, redactSecret } = createRedactors(qaToken, qaExchangeSecret); +const startedAt = new Date().toISOString(); +const actionLog = []; +const requests = []; +const consoleMessages = []; +const screenshots = []; +const cleanup = []; + +let browser; +let extraFailureProbeReport; +let extraLiveGateReport; +let extraSecurityReport; +let serverProcess; +let tempDir = ""; +let port = 0; + +function record(action, detail = {}) { + actionLog.push({ at: new Date().toISOString(), action, ...detail }); +} + +function writeJson(name, value) { + writeArtifactJson(evidenceDir, name, value); +} + +async function main() { + mkdirSync(evidenceDir, { recursive: true }); + removePriorArtifacts(evidenceDir); + tempDir = mkdtempSync(join(tmpdir(), "nfi-t10-browser-")); + port = Number(process.env.NFI_BROWSER_QA_PORT ?? await freePort()); + const baseUrl = `http://127.0.0.1:${port}`; + const configPath = join(tempDir, "qa-config.yaml"); + writeFileSync(configPath, qaConfig(port, tempDir)); + + serverProcess = spawn( + "uv", + ["run", "nfi-engine", "serve", "--config", configPath, "--host", "127.0.0.1", "--port", String(port)], + { + cwd: repoRoot, + env: { ...process.env, NFI_ENGINE_API_TOKEN: qaToken }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const serverLogs = []; + serverProcess.stdout.on("data", (chunk) => serverLogs.push(redact(chunk.toString()))); + serverProcess.stderr.on("data", (chunk) => serverLogs.push(redact(chunk.toString()))); + record("server-started", { baseUrl, config: "qa-temp-config" }); + + await waitForServer(baseUrl, serverProcess, record); + const executablePath = resolveChromiumExecutable(repoRoot); + browser = await chromium.launch({ + executablePath, + env: browserLaunchEnv(repoRoot), + headless: process.env.NFI_BROWSER_QA_HEADFUL !== "1", + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + record("browser-started", { executable: executablePath }); + + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + await page.route("**/favicon.ico", async (route) => { + await route.fulfill({ status: 204, body: "" }); + }); + page.on("request", (request) => { + const url = request.url(); + requests.push({ + at: new Date().toISOString(), + method: request.method(), + url, + resourceType: request.resourceType(), + isLocal: isLocalUrl(url), + }); + }); + page.on("console", (message) => { + consoleMessages.push({ + at: new Date().toISOString(), + type: message.type(), + text: redact(message.text()), + }); + }); + + const flowContext = { + countDocumentRequests: documentRequestCounter(requests), + credentialWordsAppear, + evidenceDir, + qaExchangeSecret, + qaToken, + record, + redactSecret, + screenshots, + }; + const invalidLogin = await exerciseInvalidLogin(page, baseUrl, flowContext); + const happyPath = await exerciseHappyPath(page, baseUrl, invalidLogin, flowContext); + const storage = await storageState(page); + const visual = await captureMobileViews(page, baseUrl, flowContext); + const failureProbes = await exerciseFailureProbes(page, baseUrl, { + qaExchangeSecret, + qaToken, + record, + redact, + }); + extraFailureProbeReport = failureProbes; + const security = await securityAudit({ + invalidLogin, + happyPath, + storage, + visual, + failureProbes, + }, { + actionLog, + consoleMessages, + evidenceDir, + qaExchangeSecret, + qaToken, + requests, + }); + + const summary = { + startedAt, + finishedAt: new Date().toISOString(), + baseUrl, + browserExecutable: executablePath, + happyPath, + invalidLogin, + storage, + visual, + failureProbes, + security: { + externalRequestCount: security.externalRequestCount, + tokenLeakCount: security.tokenLeakCount, + localStorageEntries: storage.localStorage.length, + sessionStorageEntries: storage.sessionStorage.length, + unexpectedConsoleErrorCount: security.unexpectedConsoleErrorCount, + exchangeSecretLeakCount: security.exchangeSecretLeakCount, + forbiddenWalletWordingCount: security.forbiddenWalletWordingCount, + failureProbePassed: failureProbes.passed, + }, + screenshots, + artifacts: [ + "action-log.json", + "console-summary.json", + "network-summary.json", + "security.json", + "summary.json", + ], + }; + writeJson("summary.json", summary); + writeJson("action-log.json", actionLog); + writeJson("console-summary.json", { messages: consoleMessages }); + writeJson("network-summary.json", { requests }); + writeJson("security.json", security); + extraSecurityReport = { + passed: security.passed, + secretLeakCount: security.exchangeSecretLeakCount, + tokenLeakCount: security.tokenLeakCount, + browserStorageEmpty: security.storageEmpty, + forbiddenWalletWordingAbsent: security.forbiddenWalletWordingCount === 0, + loginCredentialWordingSeparated: happyPath.loginCredentialWordingSeparated, + setupSecretWriteOnly: happyPath.setupSecurity.setupSecretWriteOnly, + setupSecretRedacted: happyPath.setupSecurity.setupSecretRedacted, + failureProbesPassed: failureProbes.passed, + externalRequestCount: security.externalRequestCount, + }; + extraLiveGateReport = { + dryRunDefault: happyPath.setupWizard.dryRunDefault, + livePreviewBlocked: happyPath.setupSecurity.livePreviewBlocked, + livePreviewText: happyPath.setupSecurity.livePreviewText, + liveWarningText: happyPath.setupSecurity.liveWarningText, + passed: happyPath.setupSecurity.liveGateWarningsPresent, + }; + writeFileSync(join(evidenceDir, "server.log"), redact(serverLogs.join(""))); +} + +try { + await main(); + record("qa-pass"); + process.exitCode = 0; +} catch (error) { + record("qa-fail", { message: redact(error.message) }); + writeJson("failure.json", { + message: redact(error.message), + stack: redact(error.stack ?? ""), + actionLog, + requests, + consoleMessages, + }); + process.exitCode = 1; +} finally { + await cleanupT10Runtime({ + browser, + cleanup, + evidenceDir, + extraEvidenceDir, + keepTemp: process.env.NFI_BROWSER_QA_KEEP_TEMP === "1", + port, + reports: { + failureProbes: extraFailureProbeReport, + liveGate: extraLiveGateReport, + security: extraSecurityReport, + }, + serverProcess, + tempDir, + }); +} diff --git a/scripts/browser_qa_t10_artifacts.mjs b/scripts/browser_qa_t10_artifacts.mjs new file mode 100644 index 0000000..a141345 --- /dev/null +++ b/scripts/browser_qa_t10_artifacts.mjs @@ -0,0 +1,81 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const ARTIFACTS = [ + "action-log.json", + "cleanup.json", + "console-summary.json", + "failure.json", + "network-summary.json", + "security.json", + "server.log", + "summary.json", + "login-empty-desktop.png", + "home-desktop.png", + "settings-ko-desktop.png", + "settings-el-desktop.png", + "logs-ko-desktop.png", + "logs-el-desktop.png", + "home-mobile.png", + "settings-ko-mobile.png", + "settings-el-mobile.png", + "logs-ko-mobile.png", + "logs-el-mobile.png", +]; + +export function removePriorArtifacts(evidenceDir) { + for (const name of ARTIFACTS) { + rmSync(join(evidenceDir, name), { force: true }); + } +} + +export function writeJson(evidenceDir, name, value) { + writeFileSync(join(evidenceDir, name), `${JSON.stringify(value, null, 2)}\n`); +} + +export function writeExtraJson(extraEvidenceDir, name, value) { + if (!extraEvidenceDir) { + return; + } + mkdirSync(extraEvidenceDir, { recursive: true }); + writeFileSync(join(extraEvidenceDir, name), `${JSON.stringify(value, null, 2)}\n`); +} + +export function writeExtraText(extraEvidenceDir, name, value) { + if (!extraEvidenceDir) { + return; + } + mkdirSync(extraEvidenceDir, { recursive: true }); + writeFileSync(join(extraEvidenceDir, name), value); +} + +export function writeFinalExtraArtifacts(extraEvidenceDir, cleanup, reports) { + if (reports.security) { + writeExtraJson(extraEvidenceDir, "task-14-security-redaction.json", { + ...reports.security, + cleanup, + }); + } + if (reports.failureProbes) { + writeExtraJson(extraEvidenceDir, "task-11-browser-failure.json", { + ...reports.failureProbes, + cleanup, + }); + } + if (!reports.liveGate) { + return; + } + writeExtraText( + extraEvidenceDir, + "task-14-live-gate.txt", + [ + `passed=${reports.liveGate.passed}`, + `dryRunDefault=${reports.liveGate.dryRunDefault}`, + `livePreviewBlocked=${reports.liveGate.livePreviewBlocked}`, + `liveWarning=${reports.liveGate.liveWarningText}`, + `livePreview=${reports.liveGate.livePreviewText}`, + `cleanup=${cleanup.join("; ")}`, + "", + ].join("\n"), + ); +} diff --git a/scripts/browser_qa_t10_failure_probes.mjs b/scripts/browser_qa_t10_failure_probes.mjs new file mode 100644 index 0000000..d77764b --- /dev/null +++ b/scripts/browser_qa_t10_failure_probes.mjs @@ -0,0 +1,164 @@ +export async function exerciseFailureProbes(page, baseUrl, context) { + await page.goto(`${baseUrl}/settings`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="settings-root"]').waitFor(); + const csrfToken = await page.locator('meta[name="nfi-csrf-token"]').getAttribute("content"); + if (!csrfToken) { + throw new Error("missing CSRF token meta for failure probes"); + } + + const missingCsrf = await postJsonFromPage(page, "/api/v1/config/apply", { + fields: [{ path: "risk.max_open_trades", value: "4" }], + }); + const invalidCsrf = await postJsonFromPage( + page, + "/api/v1/config/apply", + { fields: [{ path: "risk.max_open_trades", value: "4" }] }, + "wrong-csrf-token", + ); + const unsafeLiveIntent = await postJsonFromPage(page, "/api/v1/setup/preview", { + exchange: "bybit", + trading_mode: "futures", + intent: "live", + api_key: "qa-live-key", + api_secret: context.qaExchangeSecret, + risk_preset: "conservative", + allocated_amount_usdt: "42.5", + permission_read: "enabled", + permission_trade: "enabled", + permission_futures: "enabled", + permission_withdrawal: "disabled", + }); + const badWalletPermission = await postJsonFromPage(page, "/api/v1/setup/preview", { + exchange: "bybit", + trading_mode: "futures", + intent: "live", + api_key: "qa-withdrawal-key", + api_secret: context.qaExchangeSecret, + risk_profile: "safe", + live_trading_confirmed: true, + permission_withdrawal: "enabled", + }); + const readOnlyEnable = await postJsonFromPage( + page, + "/api/v1/config/apply", + { fields: [{ path: "ui.read_only", value: "true" }] }, + csrfToken, + ); + const readOnlyApply = await postJsonFromPage( + page, + "/api/v1/config/apply", + { fields: [{ path: "risk.max_open_trades", value: "4" }] }, + csrfToken, + ); + const readOnlyRuntime = await postJsonFromPage( + page, + "/api/v1/runtime/control", + { command: "start" }, + csrfToken, + ); + const securityAuditLog = await page.evaluate(async () => { + const response = await fetch("/api/v1/security/audit", { credentials: "same-origin" }); + const text = await response.text(); + let payload = null; + try { + payload = JSON.parse(text); + } catch { + payload = null; + } + return { status: response.status, ok: response.ok, payload, text }; + }); + + const raw = { + missingCsrf, + invalidCsrf, + unsafeLiveIntent, + badWalletPermission, + readOnlyEnable, + readOnlyApply, + readOnlyRuntime, + securityAuditLog, + }; + const rawText = JSON.stringify(raw); + const failures = [ + errorCode(missingCsrf) === "CSRF_TOKEN_REQUIRED" ? null : "missing CSRF did not block config apply", + errorCode(invalidCsrf) === "CSRF_TOKEN_INVALID" ? null : "invalid CSRF did not block config apply", + setupErrors(unsafeLiveIntent).includes("LIVE_TRADING_REQUIRES_CONFIRMATION") + ? null + : "unsafe live intent was not blocked", + setupErrors(badWalletPermission).includes("EXCHANGE_WITHDRAWAL_PERMISSION_ENABLED") + ? null + : "withdrawal permission was not blocked", + readOnlyEnable.status === 200 && readOnlyEnable.payload?.applied === true + ? null + : "read-only mode could not be enabled", + errorCode(readOnlyApply) === "READONLY_ACTION_BLOCKED" ? null : "read-only config apply was not blocked", + errorCode(readOnlyRuntime) === "READONLY_ACTION_BLOCKED" + ? null + : "read-only runtime control was not blocked", + auditCodes(securityAuditLog).includes("READONLY_ACTION_BLOCKED") + ? null + : "read-only audit event was not recorded", + rawText.includes(context.qaExchangeSecret) ? "exchange secret leaked in failure probe response" : null, + rawText.includes(context.qaToken) ? "QA token leaked in failure probe response" : null, + ].filter(Boolean); + + const report = { + passed: failures.length === 0, + failures, + missingCsrf: redactForEvidence(missingCsrf, context.redact), + invalidCsrf: redactForEvidence(invalidCsrf, context.redact), + unsafeLiveIntent: redactForEvidence(unsafeLiveIntent, context.redact), + badWalletPermission: redactForEvidence(badWalletPermission, context.redact), + readOnlyEnable: redactForEvidence(readOnlyEnable, context.redact), + readOnlyApply: redactForEvidence(readOnlyApply, context.redact), + readOnlyRuntime: redactForEvidence(readOnlyRuntime, context.redact), + securityAuditLog: redactForEvidence(securityAuditLog, context.redact), + }; + context.record("failure-probes-complete", { + passed: report.passed, + failures, + }); + return report; +} + +async function postJsonFromPage(page, path, body, csrfToken = null) { + return await page.evaluate( + async ({ path, body, csrfToken }) => { + const headers = { "content-type": "application/json" }; + if (csrfToken !== null) { + headers["x-nfi-csrf-token"] = csrfToken; + } + const response = await fetch(path, { + method: "POST", + headers, + credentials: "same-origin", + body: JSON.stringify(body), + }); + const text = await response.text(); + let payload = null; + try { + payload = JSON.parse(text); + } catch { + payload = null; + } + return { status: response.status, ok: response.ok, payload, text }; + }, + { path, body, csrfToken }, + ); +} + +function auditCodes(result) { + return Array.isArray(result.payload?.items) ? result.payload.items.map((item) => item.code) : []; +} + +function errorCode(result) { + return result.payload?.detail?.code ?? null; +} + +function redactForEvidence(value, redact) { + return JSON.parse(redact(JSON.stringify(value))); +} + +function setupErrors(result) { + return Array.isArray(result.payload?.errors) ? result.payload.errors : []; +} diff --git a/scripts/browser_qa_t10_runtime.mjs b/scripts/browser_qa_t10_runtime.mjs new file mode 100644 index 0000000..42ab815 --- /dev/null +++ b/scripts/browser_qa_t10_runtime.mjs @@ -0,0 +1,94 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { onceExit, sleep, waitForPortClosed } from "./browser_qa_runtime.mjs"; +import { writeFinalExtraArtifacts, writeJson } from "./browser_qa_t10_artifacts.mjs"; + +export function qaConfig(port, root) { + const dbPath = join(root, "nfi-engine.sqlite3"); + return `engine: + environment: local + live_trading: false + live_trading_confirmed: false +exchange: + name: bybit + trading_mode: futures + margin_mode: isolated + testnet: true +database: + url: sqlite+aiosqlite:///${dbPath} +api: + host: 127.0.0.1 + port: ${port} + csrf_enabled: true +ui: + enabled: true + read_only: false + locale: en +logging: + level: INFO + json_logs: false +`; +} + +export function createRedactors(qaToken, qaExchangeSecret) { + const redactSecret = (text) => text.replaceAll(qaExchangeSecret, ""); + return { + redact: (text) => redactSecret(text.replaceAll(qaToken, "")), + redactSecret, + }; +} + +export function documentRequestCounter(requests) { + return (pathname) => + requests.filter((request) => { + const url = new URL(request.url); + return request.resourceType === "document" && url.pathname === pathname; + }).length; +} + +export async function waitForServer(baseUrl, serverProcess, record) { + const deadline = Date.now() + 30000; + let lastError = ""; + while (Date.now() < deadline) { + if (serverProcess.exitCode !== null) { + throw new Error(`server exited before readiness: ${serverProcess.exitCode}`); + } + try { + const response = await fetch(`${baseUrl}/api/v1/ping`); + if (response.ok) { + record("server-ready", { status: response.status }); + return; + } + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await sleep(250); + } + throw new Error(`server did not become ready: ${lastError}`); +} + +export async function cleanupT10Runtime(context) { + if (context.browser) { + await context.browser.close(); + context.cleanup.push("browser closed"); + } + if (context.serverProcess && context.serverProcess.exitCode === null) { + context.serverProcess.kill("SIGTERM"); + await onceExit(context.serverProcess, 3000); + context.cleanup.push("server process stopped"); + } + if (context.port) { + await waitForPortClosed(context.port); + context.cleanup.push(`port ${context.port} closed`); + } + if (context.tempDir && !context.keepTemp) { + rmSync(context.tempDir, { recursive: true, force: true }); + context.cleanup.push("temp dir removed"); + } + writeJson(context.evidenceDir, "cleanup.json", { + cleanup: context.cleanup, + at: new Date().toISOString(), + }); + writeFinalExtraArtifacts(context.extraEvidenceDir, context.cleanup, context.reports); +} diff --git a/scripts/browser_qa_t10_security_audit.mjs b/scripts/browser_qa_t10_security_audit.mjs new file mode 100644 index 0000000..e08f5da --- /dev/null +++ b/scripts/browser_qa_t10_security_audit.mjs @@ -0,0 +1,116 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export function credentialWordsAppear(text) { + return /api\s*(key|secret)|exchange\s*(key|secret)|private\s*key|seed\s*phrase/i.test(text); +} + +export async function securityAudit({ invalidLogin, happyPath, storage, visual, failureProbes }, context) { + const externalRequests = context.requests.filter((request) => !request.isLocal); + const consoleErrors = context.consoleMessages.filter((message) => message.type === "error"); + const unexpectedConsoleErrors = consoleErrors.filter( + (message) => !isExpectedConsoleError(message.text), + ); + const tokenLeakFiles = scanEvidenceForSensitive(context.qaToken, "qa-token", context); + const exchangeSecretLeakFiles = scanEvidenceForSensitive( + context.qaExchangeSecret, + "exchange-secret", + context, + ); + const horizontalOverflow = visual.captures.filter((capture) => capture.horizontalOverflowPx > 2); + const forbiddenWalletWordingCount = forbiddenWalletWordingCountIn(happyPath); + const failures = [ + invalidLogin.denied ? null : "invalid login did not remain denied", + happyPath.loginCredentialWordingSeparated ? null : "login token wording overlaps exchange API credentials", + happyPath.setupWizard.exactOrder ? null : "setup wizard order does not match T14", + happyPath.setupWizard.dryRunDefault ? null : "dry-run is not the setup default", + happyPath.setupWizard.recommendedLeverageDefault3x ? null : "recommended leverage is not 3x", + happyPath.setupWizardGreek.recommendedLeverageDefault3x ? null : "Greek setup wizard lost 3x leverage", + happyPath.cockpit.present ? null : "home cockpit is incomplete", + happyPath.setupWizard.updatePanelPresent ? null : "settings update panel is missing", + happyPath.setupSecurity.setupSecretWriteOnly ? null : "setup secret input is not password-only", + happyPath.setupSecurity.setupSecretRedacted ? null : "setup secret preview is not redacted", + happyPath.setupSecurity.liveGateWarningsPresent ? null : "live gate warning is incomplete", + happyPath.setupSecurity.livePreviewBlocked ? null : "live preview is not blocked", + happyPath.settingsKoVisible && happyPath.logsKoVisible ? null : "Korean settings/logs did not render", + happyPath.settingsElVisible && happyPath.logsElVisible ? null : "Greek settings/logs did not render", + happyPath.languageSwitches.enToKo.manualRefresh === false ? null : "Korean locale required manual refresh", + happyPath.languageSwitches.koToEl.manualRefresh === false ? null : "Greek locale required manual refresh", + failureProbes.passed ? null : "failure probes did not block unsafe actions", + storage.localStorage.length === 0 ? null : "localStorage is not empty", + storage.sessionStorage.length === 0 ? null : "sessionStorage is not empty", + externalRequests.length === 0 ? null : "external network request detected", + tokenLeakFiles.length === 0 ? null : "QA token leaked into evidence", + exchangeSecretLeakFiles.length === 0 ? null : "exchange secret leaked into evidence", + unexpectedConsoleErrors.length === 0 ? null : "unexpected browser console error detected", + horizontalOverflow.length === 0 ? null : "mobile horizontal overflow detected", + forbiddenWalletWordingCount === 0 ? null : "forbidden wallet key wording detected", + ].filter(Boolean); + const audit = { + passed: failures.length === 0, + failures, + invalidLogin, + storageEmpty: storage.localStorage.length === 0 && storage.sessionStorage.length === 0, + externalRequestCount: externalRequests.length, + externalRequests, + consoleErrorCount: consoleErrors.length, + consoleErrors, + unexpectedConsoleErrorCount: unexpectedConsoleErrors.length, + unexpectedConsoleErrors, + tokenLeakCount: tokenLeakFiles.length, + tokenLeakFiles, + exchangeSecretLeakCount: exchangeSecretLeakFiles.length, + exchangeSecretLeakFiles, + forbiddenWalletWordingCount, + horizontalOverflow, + failureProbes, + }; + if (!audit.passed) { + throw new Error(`browser QA security audit failed: ${failures.join("; ")}`); + } + return audit; +} + +function forbiddenWalletWordingCountIn(happyPath) { + const text = JSON.stringify({ + cockpit: happyPath.cockpit.values, + setupWizard: happyPath.setupWizard, + setupSecurity: happyPath.setupSecurity, + }); + return (text.match(/seed phrase|private key|mnemonic|wallet seed/gi) ?? []).length; +} + +function isExpectedConsoleError(text) { + return text.includes("status of 401 (Unauthorized)") || text.includes("status of 403 (Forbidden)"); +} + +function scanEvidenceForSensitive(secret, label, context) { + const inMemoryLeaks = [ + ["action-log", JSON.stringify(context.actionLog)], + ["console-summary", JSON.stringify(context.consoleMessages)], + ["network-summary", JSON.stringify(context.requests)], + ] + .filter(([, text]) => text.includes(secret)) + .map(([name]) => `${label}:${name}:memory`); + const files = [ + "login-empty-desktop.png", + "home-desktop.png", + "settings-ko-desktop.png", + "settings-el-desktop.png", + "logs-ko-desktop.png", + "logs-el-desktop.png", + "home-mobile.png", + "settings-ko-mobile.png", + "settings-el-mobile.png", + "logs-ko-mobile.png", + "logs-el-mobile.png", + "action-log.json", + "console-summary.json", + "network-summary.json", + ]; + const fileLeaks = files.filter((name) => { + const path = join(context.evidenceDir, name); + return existsSync(path) && readFileSync(path).includes(secret); + }); + return [...inMemoryLeaks, ...fileLeaks]; +} diff --git a/scripts/browser_qa_t10_setup_flow.mjs b/scripts/browser_qa_t10_setup_flow.mjs new file mode 100644 index 0000000..825911c --- /dev/null +++ b/scripts/browser_qa_t10_setup_flow.mjs @@ -0,0 +1,264 @@ +import { join } from "node:path"; + +export async function captureMobileViews(page, baseUrl, context) { + await page.setViewportSize({ width: 390, height: 844 }); + const captures = []; + for (const [name, path] of [ + ["home-mobile.png", "/"], + ["settings-el-mobile.png", "/settings"], + ["logs-el-mobile.png", "/logs"], + ]) { + await page.goto(`${baseUrl}${path}`, { waitUntil: "networkidle" }); + captures.push(await pageLayoutAudit(page, name)); + await screenshot(page, name, context); + } + context.record("mobile-captures-complete", { count: captures.length }); + return { captures }; +} + +export async function exerciseHappyPath(page, baseUrl, invalidLogin, context) { + await page.locator('[data-testid="login-token"]').fill(context.qaToken); + await page.locator('[data-testid="login-button"]').click(); + await page.locator('[data-testid="home-root"]').waitFor(); + await page.waitForLoadState("networkidle"); + const homeUrl = page.url(); + const actionCount = await page.locator('[data-testid="action-item"]').count(); + const cockpit = await auditHomeCockpit(page); + await screenshot(page, "home-desktop.png", context); + + await page.goto(`${baseUrl}/settings`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="settings-root"]').waitFor(); + const beforeLang = await page.locator("html").getAttribute("lang"); + const settingsDocumentRequestsBefore = context.countDocumentRequests("/settings"); + await page.locator('[name="ui.locale"]').selectOption("ko"); + await page.locator('[data-testid="apply-button"]').click(); + await page.waitForFunction(() => document.documentElement.lang === "ko"); + await page.locator("text=로컬 운영자 설정").waitFor(); + await page.waitForLoadState("networkidle"); + const afterLang = await page.locator("html").getAttribute("lang"); + const settingsDocumentRequestsAfter = context.countDocumentRequests("/settings"); + const setupWizard = await auditSetupWizard(page); + const setupSecurity = await exerciseSetupPreview(page, context); + await screenshot(page, "settings-ko-desktop.png", context); + + await page.goto(`${baseUrl}/logs`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="logs-root"]').waitFor(); + await page.locator("text=최근 이벤트").waitFor(); + await screenshot(page, "logs-ko-desktop.png", context); + + await page.goto(`${baseUrl}/settings`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="settings-root"]').waitFor(); + const beforeGreekLang = await page.locator("html").getAttribute("lang"); + const settingsDocumentRequestsBeforeGreek = context.countDocumentRequests("/settings"); + await page.locator('[name="ui.locale"]').selectOption("el"); + await page.locator('[data-testid="apply-button"]').click(); + await page.waitForFunction(() => document.documentElement.lang === "el"); + await page.locator("text=Τοπικές ρυθμίσεις χειριστή").waitFor(); + await page.waitForLoadState("networkidle"); + const afterGreekLang = await page.locator("html").getAttribute("lang"); + const settingsDocumentRequestsAfterGreek = context.countDocumentRequests("/settings"); + const setupWizardGreek = await auditSetupWizard(page); + await screenshot(page, "settings-el-desktop.png", context); + + await page.goto(`${baseUrl}/logs`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="logs-root"]').waitFor(); + await page.locator("text=Πρόσφατα γεγονότα").waitFor(); + await screenshot(page, "logs-el-desktop.png", context); + + const languageSwitch = { + beforeLang, + afterLang, + manualRefresh: false, + automaticNavigationAfterApply: settingsDocumentRequestsAfter > settingsDocumentRequestsBefore, + appliedBy: "settings apply button", + }; + const greekLanguageSwitch = { + beforeLang: beforeGreekLang, + afterLang: afterGreekLang, + manualRefresh: false, + automaticNavigationAfterApply: settingsDocumentRequestsAfterGreek > settingsDocumentRequestsBeforeGreek, + appliedBy: "settings apply button", + }; + context.record("happy-path-complete", { + homeUrl, + actionCount, + languageSwitches: { enToKo: languageSwitch, koToEl: greekLanguageSwitch }, + }); + return { + loginLoadedHome: homeUrl === `${baseUrl}/`, + actionCount, + cockpit, + setupWizard, + setupWizardGreek, + setupSecurity, + loginCredentialWordingSeparated: invalidLogin.loginCredentialWordingSeparated, + languageSwitch, + languageSwitches: { enToKo: languageSwitch, koToEl: greekLanguageSwitch }, + settingsKoVisible: true, + logsKoVisible: true, + settingsElVisible: true, + logsElVisible: true, + }; +} + +export async function exerciseInvalidLogin(page, baseUrl, context) { + const response = await page.goto(baseUrl, { waitUntil: "networkidle" }); + await expectStatus(response, 401, "login page"); + await page.locator('[data-testid="login-root"]').waitFor(); + const loginText = await page.locator('[data-testid="login-root"]').innerText(); + await screenshot(page, "login-empty-desktop.png", context); + await page.locator('[data-testid="login-token"]').fill("invalid-qa-token"); + await page.locator('[data-testid="login-button"]').click(); + await page.locator('[data-testid="login-state"]').waitFor({ state: "visible" }); + await page.waitForFunction(() => document.body.innerText.includes("HTTP 401")); + const stillLogin = await page.locator('[data-testid="login-root"]').count(); + await page.locator('[data-testid="login-token"]').fill(""); + context.record("invalid-login-denied", { statusText: "HTTP 401", stillLogin: stillLogin === 1 }); + return { + denied: stillLogin === 1, + loginCredentialWordingSeparated: !context.credentialWordsAppear(loginText), + statusText: "HTTP 401", + }; +} + +export async function storageState(page) { + return await page.evaluate(() => ({ + localStorage: Object.entries(window.localStorage), + sessionStorage: Object.entries(window.sessionStorage), + })); +} + +async function auditHomeCockpit(page) { + const ids = [ + "operator-cockpit", + "cockpit-configured", + "cockpit-safety", + "cockpit-capability-level", + "cockpit-active-mode", + "cockpit-wallet-balance", + "cockpit-allocated-amount", + "cockpit-leverage", + "cockpit-latest-error", + "cockpit-next-action", + "cockpit-where-next", + ]; + const values = {}; + for (const id of ids) { + await page.locator(`[data-testid="${id}"]`).waitFor(); + values[id] = await page.locator(`[data-testid="${id}"]`).innerText(); + } + return { + present: ids.every((id) => Boolean(values[id])), + values, + }; +} + +async function auditSetupWizard(page) { + const orderedStepIds = [ + "setup-step-exchange", + "setup-step-api-key", + "setup-step-api-secret", + "setup-step-leverage", + "setup-step-wallet-balance", + "setup-step-allocated-amount", + "setup-step-market-mode", + "setup-step-intent", + ]; + const positions = await page.evaluate((ids) => { + const all = Array.from(document.querySelectorAll("[data-testid]")); + return ids.map((id) => all.findIndex((element) => element.dataset.testid === id)); + }, orderedStepIds); + const missing = orderedStepIds.filter((_, index) => positions[index] < 0); + const exactOrder = + missing.length === 0 && positions.every((position, index) => index === 0 || position > positions[index - 1]); + const dryRunDefault = await page.locator('select[name="intent"]').inputValue() === "paper"; + const recommendedLeverage = await page.locator('[data-testid="setup-recommended-leverage"]').innerText(); + const updateStates = { + preview: await page.locator('[data-testid="update-preview-state"]').innerText(), + apply: await page.locator('[data-testid="update-apply-state"]').innerText(), + rollback: await page.locator('[data-testid="update-rollback-state"]').innerText(), + }; + await page.locator('[data-testid="settings-update-panel"]').waitFor(); + return { + orderedStepIds, + positions, + missing, + exactOrder, + dryRunDefault, + recommendedLeverage, + recommendedLeverageDefault3x: recommendedLeverage.includes("3x"), + updateStates, + updatePanelPresent: true, + }; +} + +async function exerciseSetupPreview(page, context) { + await page.locator("#setup-api-key").fill("qa-preview-key"); + await page.locator("#setup-api-secret").fill(context.qaExchangeSecret); + await page.locator("#setup-allocated-amount").fill("42.5"); + await page.locator('select[name="trading_mode"]').selectOption("futures"); + const dryRunDefault = await page.locator('select[name="intent"]').inputValue() === "paper"; + await page.locator('[data-testid="setup-preview-button"]').click(); + await page.waitForFunction(() => { + const text = document.querySelector('[data-testid="setup-preview-state"]')?.textContent ?? ""; + return text.includes("REDACTED") || text.includes("valid:"); + }); + const previewText = await page.locator('[data-testid="setup-preview-state"]').innerText(); + await page.locator('select[name="intent"]').selectOption("live"); + const liveWarningText = await page.locator('[data-testid="setup-step-intent"] .field-note').innerText(); + await page.locator('[data-testid="setup-preview-button"]').click(); + await page.waitForFunction((previousText) => { + const text = document.querySelector('[data-testid="setup-preview-state"]')?.textContent ?? ""; + return ( + text !== previousText && + (text.includes("LIVE_TRADING_REQUIRES_CONFIRMATION") || + text.includes("live") || + text.includes("라이브")) + ); + }, previewText); + const livePreviewText = await page.locator('[data-testid="setup-preview-state"]').innerText(); + const liveGateWarningsPresent = [ + /confirm|확인/i, + /preflight/i, + /limit|한도/i, + /kill switch/i, + /reconciliation/i, + ].every((pattern) => pattern.test(liveWarningText)); + return { + dryRunDefault, + setupSecretWriteOnly: await page.locator("#setup-api-secret").getAttribute("type") === "password", + setupSecretRedacted: previewText.includes("REDACTED") && !previewText.includes(context.qaExchangeSecret), + previewText: context.redactSecret(previewText), + liveGateWarningsPresent, + livePreviewBlocked: livePreviewText.includes("LIVE_TRADING_REQUIRES_CONFIRMATION"), + liveWarningText, + livePreviewText: context.redactSecret(livePreviewText), + }; +} + +async function expectStatus(response, expected, label) { + if (!response || response.status() !== expected) { + throw new Error(`${label} expected HTTP ${expected}, got ${response?.status() ?? "none"}`); + } +} + +async function pageLayoutAudit(page, name) { + const audit = await page.evaluate(() => ({ + lang: document.documentElement.lang, + viewportWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + title: document.title, + })); + return { + name, + ...audit, + horizontalOverflowPx: Math.max(0, audit.scrollWidth - audit.viewportWidth), + }; +} + +async function screenshot(page, name, { evidenceDir, record, screenshots }) { + const path = join(evidenceDir, name); + await page.screenshot({ path, fullPage: true }); + screenshots.push(name); + record("screenshot", { name }); +} diff --git a/scripts/browser_qa_t23_runtime_control.mjs b/scripts/browser_qa_t23_runtime_control.mjs new file mode 100644 index 0000000..b8e8f68 --- /dev/null +++ b/scripts/browser_qa_t23_runtime_control.mjs @@ -0,0 +1,251 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { chromium } from "playwright-core"; +import { + browserLaunchEnv, + freePort, + isLocalUrl, + onceExit, + resolveChromiumExecutable, + sleep, + waitForPortClosed, +} from "./browser_qa_runtime.mjs"; +import { + captureMobileViews, + exerciseHomeControls, + exerciseSettingsControls, + runtimeControlPayload, + storageState, +} from "./browser_qa_t23_runtime_flows.mjs"; + +const repoRoot = resolve(dirname(new URL(import.meta.url).pathname), ".."); +const evidenceDir = resolve( + repoRoot, + process.env.NFI_T23_RUNTIME_CONTROL_EVIDENCE_DIR ?? + ".omo/evidence/2026-06-15-product-completion/task-23-runtime-control-browser", +); +const startedAt = new Date().toISOString(); +const actionLog = []; +const requests = []; +const consoleMessages = []; +const screenshots = []; +const cleanup = []; + +let browser; +let serverProcess; +let tempDir = ""; + +function record(action, detail = {}) { + actionLog.push({ at: new Date().toISOString(), action, ...detail }); +} + +function writeJson(name, value) { + writeFileSync(join(evidenceDir, name), `${JSON.stringify(value, null, 2)}\n`); +} + +function removePriorArtifacts() { + for (const name of [ + "action-log.json", + "cleanup.json", + "console-summary.json", + "failure.json", + "home-desktop.png", + "home-mobile.png", + "network-summary.json", + "settings-paused-desktop.png", + "settings-stopped-desktop.png", + "settings-mobile.png", + "server.log", + "summary.json", + ]) { + rmSync(join(evidenceDir, name), { force: true }); + } +} + +async function main() { + mkdirSync(evidenceDir, { recursive: true }); + removePriorArtifacts(); + tempDir = mkdtempSync(join(tmpdir(), "nfi-t23-runtime-control-browser-")); + const port = Number(process.env.NFI_T23_RUNTIME_CONTROL_PORT ?? (await freePort())); + const baseUrl = `http://127.0.0.1:${port}`; + const configPath = join(tempDir, "qa-config.yaml"); + writeFileSync(configPath, qaConfig(tempDir)); + + serverProcess = spawn( + "uv", + ["run", "nfi-engine", "serve", "--config", configPath, "--host", "127.0.0.1", "--port", String(port)], + { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const serverLogs = []; + serverProcess.stdout.on("data", (chunk) => serverLogs.push(chunk.toString())); + serverProcess.stderr.on("data", (chunk) => serverLogs.push(chunk.toString())); + record("server-started", { baseUrl, config: "qa-temp-config" }); + + try { + await waitForServer(baseUrl); + browser = await chromium.launch({ + executablePath: resolveChromiumExecutable(repoRoot), + env: browserLaunchEnv(repoRoot), + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + record("browser-started"); + + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + await page.route("**/favicon.ico", async (route) => { + await route.fulfill({ status: 204, body: "" }); + }); + page.on("request", (request) => { + requests.push({ + at: new Date().toISOString(), + method: request.method(), + url: request.url(), + resourceType: request.resourceType(), + isLocal: isLocalUrl(request.url(), baseUrl), + }); + }); + page.on("console", (message) => { + consoleMessages.push({ + at: new Date().toISOString(), + type: message.type(), + text: message.text(), + }); + }); + + const flowContext = { layout, record, screenshot }; + const homeFlow = await exerciseHomeControls(page, baseUrl, flowContext); + const settingsFlow = await exerciseSettingsControls(page, baseUrl, flowContext); + const finalRuntime = await runtimeControlPayload(page); + const storage = await storageState(page); + const desktopSettingsLayout = await layout(page, "settings-desktop"); + const mobileVisual = await captureMobileViews(page, baseUrl, flowContext); + const externalRequests = requests.filter((request) => !request.isLocal); + const unexpectedConsoleErrors = consoleMessages.filter((message) => message.type === "error"); + const layouts = [homeFlow.layout, settingsFlow.layout, desktopSettingsLayout, ...mobileVisual.layouts]; + const horizontalOverflow = layouts.reduce( + (max, item) => Math.max(max, item.horizontalOverflowPx), + 0, + ); + const runtimeControlRequests = requests.filter((request) => + request.url.includes("/api/v1/runtime/control"), + ); + const summary = { + startedAt, + finishedAt: new Date().toISOString(), + baseUrl, + homeFlow, + settingsFlow, + finalRuntime, + runtimeControlRequests, + runtimeControlRequestCount: runtimeControlRequests.length, + storage, + storageEmpty: storage.localStorage.length === 0 && storage.sessionStorage.length === 0, + externalRequestCount: externalRequests.length, + externalRequests, + unexpectedConsoleErrorCount: unexpectedConsoleErrors.length, + layouts, + horizontalOverflowPx: horizontalOverflow, + screenshots, + passed: + homeFlow.passed && + settingsFlow.passed && + finalRuntime.state === "stopped" && + finalRuntime.new_entries_allowed === false && + runtimeControlRequests.length >= 5 && + storage.localStorage.length === 0 && + storage.sessionStorage.length === 0 && + externalRequests.length === 0 && + unexpectedConsoleErrors.length === 0 && + horizontalOverflow === 0, + }; + writeJson("summary.json", summary); + writeJson("action-log.json", actionLog); + writeJson("network-summary.json", { requests }); + writeJson("console-summary.json", { messages: consoleMessages }); + writeFileSync(join(evidenceDir, "server.log"), serverLogs.join("")); + if (!summary.passed) { + throw new Error("T23 runtime-control browser QA failed"); + } + } catch (error) { + writeJson("failure.json", { + message: error instanceof Error ? error.message : String(error), + actionLog, + requests, + consoleMessages, + }); + throw error; + } finally { + await cleanupResources(port); + } +} + +async function screenshot(page, name) { + await page.screenshot({ path: join(evidenceDir, name), fullPage: true }); + screenshots.push(name); +} + +async function layout(page, name) { + return await page.evaluate((label) => { + const root = document.documentElement; + return { + name: label, + lang: root.lang, + viewportWidth: root.clientWidth, + scrollWidth: root.scrollWidth, + horizontalOverflowPx: Math.max(0, root.scrollWidth - root.clientWidth), + title: document.title, + }; + }, name); +} + +async function waitForServer(baseUrl) { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + try { + const response = await fetch(`${baseUrl}/api/v1/ping`); + if (response.ok) { + return; + } + } catch { + // Retry until uvicorn binds the loopback port. + } + await sleep(250); + } + throw new Error(`server did not start at ${baseUrl}`); +} + +async function cleanupResources(port) { + if (browser) { + await browser.close(); + cleanup.push({ resource: "browser", status: "closed" }); + } + if (serverProcess) { + serverProcess.kill("SIGTERM"); + await onceExit(serverProcess, 5000); + cleanup.push({ resource: "server", status: `terminated:${serverProcess.exitCode}` }); + } + await waitForPortClosed(port); + cleanup.push({ resource: "port", status: "closed", port }); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + cleanup.push({ resource: "tempDir", status: "removed" }); + } + writeJson("cleanup.json", { cleanup }); +} + +function qaConfig(directory) { + const example = readFileSync(join(repoRoot, "examples/spot-paper.yaml"), "utf8"); + const databasePath = join(directory, "nfi_engine.sqlite3"); + return example.replace( + "sqlite+aiosqlite:///data/nfi_engine.sqlite3", + `sqlite+aiosqlite:///${databasePath}`, + ); +} + +await main(); diff --git a/scripts/browser_qa_t23_runtime_flows.mjs b/scripts/browser_qa_t23_runtime_flows.mjs new file mode 100644 index 0000000..0ec6fb3 --- /dev/null +++ b/scripts/browser_qa_t23_runtime_flows.mjs @@ -0,0 +1,120 @@ +export async function captureMobileViews(page, baseUrl, { layout, screenshot }) { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(baseUrl, { waitUntil: "networkidle" }); + await page.locator('[data-testid="home-root"]').waitFor(); + await waitForRuntimeState(page, "stopped"); + const homeMobileLayout = await layout(page, "home-mobile"); + await screenshot(page, "home-mobile.png"); + + await page.goto(`${baseUrl}/settings`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="settings-root"]').waitFor(); + await waitForRuntimeState(page, "stopped"); + const settingsMobileLayout = await layout(page, "settings-mobile"); + await screenshot(page, "settings-mobile.png"); + return { layouts: [homeMobileLayout, settingsMobileLayout] }; +} + +export async function exerciseHomeControls(page, baseUrl, { layout, record, screenshot }) { + await page.goto(baseUrl, { waitUntil: "networkidle" }); + await page.locator('[data-testid="home-root"]').waitFor(); + await waitForRuntimeState(page, "stopped"); + const initialState = await runtimeControlText(page); + const initialHealth = await runtimeHealthText(page); + const initialBotState = await botStateText(page); + await page.locator('[data-testid="start-button"]').click(); + await waitForRuntimeState(page, "running"); + const afterStartBotState = await botStateText(page); + await page.locator('[data-testid="pause-button"]').click(); + await waitForRuntimeState(page, "paused"); + const afterPauseBotState = await botStateText(page); + const afterPauseRuntime = await runtimeControlPayload(page); + const finalHealth = await runtimeHealthText(page); + const pageLayout = await layout(page, "home-desktop"); + await screenshot(page, "home-desktop.png"); + record("home-controls", { + initialState, + initialHealth, + initialBotState, + afterStartBotState, + afterPauseBotState, + finalHealth, + }); + return { + initialState, + initialHealth, + initialBotState, + afterStartBotState, + afterPauseBotState, + afterPauseRuntime, + finalHealth, + layout: pageLayout, + passed: + initialState === "stopped" && + afterStartBotState === "running" && + afterPauseBotState === "paused" && + afterPauseRuntime.state === "paused" && + afterPauseRuntime.new_entries_allowed === false, + }; +} + +export async function exerciseSettingsControls(page, baseUrl, { layout, record, screenshot }) { + await page.goto(`${baseUrl}/settings`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="settings-root"]').waitFor(); + await waitForRuntimeState(page, "paused"); + const initialSettingsState = await runtimeControlText(page); + await screenshot(page, "settings-paused-desktop.png"); + await page.locator('[data-testid="resume-button"]').click(); + await waitForRuntimeState(page, "running"); + const afterResume = await runtimeControlPayload(page); + await page.locator('[data-testid="stop-button"]').click(); + await waitForRuntimeState(page, "stopped"); + const afterStop = await runtimeControlPayload(page); + const pageLayout = await layout(page, "settings-flow-desktop"); + await screenshot(page, "settings-stopped-desktop.png"); + record("settings-controls", { initialSettingsState, afterResume, afterStop }); + return { + initialSettingsState, + afterResume, + afterStop, + layout: pageLayout, + passed: + initialSettingsState === "paused" && + afterResume.state === "running" && + afterResume.new_entries_allowed === true && + afterStop.state === "stopped" && + afterStop.new_entries_allowed === false, + }; +} + +export async function runtimeControlPayload(page) { + return await page.evaluate(async () => { + const response = await fetch("/api/v1/runtime/control", { credentials: "same-origin" }); + return await response.json(); + }); +} + +export async function storageState(page) { + return await page.evaluate(() => ({ + localStorage: Object.entries(window.localStorage), + sessionStorage: Object.entries(window.sessionStorage), + })); +} + +async function waitForRuntimeState(page, state) { + await page.waitForFunction((expected) => { + const text = document.querySelector('[data-testid="runtime-control-state"]')?.textContent ?? ""; + return text.trim() === expected; + }, state); +} + +async function runtimeControlText(page) { + return (await page.locator('[data-testid="runtime-control-state"]').innerText()).trim(); +} + +async function runtimeHealthText(page) { + return (await page.locator('[data-testid="runtime-health-state"]').innerText()).trim(); +} + +async function botStateText(page) { + return (await page.locator('[data-testid="bot-state"] strong').innerText()).trim(); +} diff --git a/scripts/browser_qa_t23_wallet.mjs b/scripts/browser_qa_t23_wallet.mjs new file mode 100644 index 0000000..defb46f --- /dev/null +++ b/scripts/browser_qa_t23_wallet.mjs @@ -0,0 +1,168 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { chromium } from "playwright-core"; + +const repoRoot = resolve(new URL("..", import.meta.url).pathname); +const evidenceDir = resolve( + repoRoot, + process.env.NFI_T23_BROWSER_EVIDENCE_DIR ?? + ".omo/evidence/2026-06-15-product-completion/task-23-browser", +); +const baseUrl = process.env.NFI_T23_BROWSER_BASE_URL ?? "http://127.0.0.1:18084"; +const requests = []; +const consoleMessages = []; +const screenshots = []; + +mkdirSync(evidenceDir, { recursive: true }); + +const browser = await chromium.launch({ + executablePath: resolveChromiumExecutable(), + env: browserLaunchEnv(), + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], +}); + +try { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + page.on("request", (request) => { + requests.push({ + method: request.method(), + url: request.url(), + resourceType: request.resourceType(), + }); + }); + page.on("console", (message) => { + consoleMessages.push({ type: message.type(), text: message.text() }); + }); + await page.route("**/favicon.ico", async (route) => { + await route.fulfill({ status: 204, body: "" }); + }); + + await page.goto(baseUrl, { waitUntil: "networkidle" }); + await page.locator('[data-testid="home-root"]').waitFor(); + const homeWallet = await page.locator('[data-testid="cockpit-wallet-balance"]').innerText(); + const homeRuntime = await page.locator('[data-testid="cockpit-runtime-health"]').innerText(); + await screenshot(page, "home-desktop.png"); + + await page.goto(`${baseUrl}/settings`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="settings-root"]').waitFor(); + const walletBefore = await page.locator('[data-testid="wallet-balance-state"]').innerText(); + await page.locator('[data-testid="wallet-fetch-button"]').click(); + await page.waitForFunction(() => { + const text = document.querySelector('[data-testid="wallet-balance-state"]')?.textContent ?? ""; + return text.includes("1000 / 1000 USDT"); + }); + const walletAfter = await page.locator('[data-testid="wallet-balance-state"]').innerText(); + await screenshot(page, "settings-wallet-desktop.png"); + + const storage = await page.evaluate(() => ({ + localStorage: Object.entries(window.localStorage), + sessionStorage: Object.entries(window.sessionStorage), + })); + const desktopLayout = await layout(page, "settings-desktop"); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(baseUrl, { waitUntil: "networkidle" }); + await page.locator('[data-testid="home-root"]').waitFor(); + const homeMobileLayout = await layout(page, "home-mobile"); + await screenshot(page, "home-mobile.png"); + + await page.goto(`${baseUrl}/settings`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="settings-root"]').waitFor(); + const settingsMobileLayout = await layout(page, "settings-mobile"); + await screenshot(page, "settings-mobile.png"); + + const walletFetchRequests = requests.filter((request) => + request.url.endsWith("/api/v1/wallet/balance/fetch"), + ); + const externalRequests = requests.filter((request) => !request.url.startsWith(baseUrl)); + const summary = { + baseUrl, + homeWallet, + homeRuntime, + walletBefore, + walletAfter, + walletFetchRequests, + walletFetchUsedPost: walletFetchRequests.some((request) => request.method === "POST"), + storage, + storageEmpty: storage.localStorage.length === 0 && storage.sessionStorage.length === 0, + externalRequestCount: externalRequests.length, + externalRequests, + consoleMessages, + unexpectedConsoleErrorCount: consoleMessages.filter((message) => message.type === "error") + .length, + layouts: [desktopLayout, homeMobileLayout, settingsMobileLayout], + screenshots, + passed: + homeWallet.includes("1000 / 1000 USDT") && + homeRuntime.includes("degraded") && + walletBefore.length > 0 && + walletAfter.includes("1000 / 1000 USDT") && + walletFetchRequests.some((request) => request.method === "POST") && + storage.localStorage.length === 0 && + storage.sessionStorage.length === 0 && + externalRequests.length === 0, + }; + writeJson("summary.json", summary); + writeJson("network-summary.json", { requests }); + writeJson("console-summary.json", { consoleMessages }); + if (!summary.passed) { + throw new Error("T23 browser QA failed"); + } +} finally { + await browser.close(); +} + +async function screenshot(page, name) { + await page.screenshot({ path: join(evidenceDir, name), fullPage: true }); + screenshots.push(name); +} + +async function layout(page, name) { + return await page.evaluate((label) => { + const root = document.documentElement; + return { + name: label, + lang: root.lang, + viewportWidth: root.clientWidth, + scrollWidth: root.scrollWidth, + horizontalOverflowPx: Math.max(0, root.scrollWidth - root.clientWidth), + title: document.title, + }; + }, name); +} + +function writeJson(name, value) { + writeFileSync(join(evidenceDir, name), `${JSON.stringify(value, null, 2)}\n`); +} + +function resolveChromiumExecutable() { + const candidates = [ + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, + join(process.env.HOME ?? "", ".cache/ms-playwright/chromium-1223/chrome-linux64/chrome"), + join(process.env.HOME ?? "", ".cache/ms-playwright/chromium-1200/chrome-linux64/chrome"), + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ].filter(Boolean); + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + throw new Error("Chromium executable not found"); +} + +function browserLaunchEnv() { + const localLibDir = join(repoRoot, ".omo/tools/browser-libs/root/usr/lib/x86_64-linux-gnu"); + const fontConfig = join(repoRoot, ".omo/tools/browser-libs/fonts.conf"); + const env = { ...process.env }; + if (existsSync(localLibDir)) { + const existing = process.env.LD_LIBRARY_PATH; + env.LD_LIBRARY_PATH = existing ? `${localLibDir}:${existing}` : localLibDir; + } + if (existsSync(fontConfig)) { + env.FONTCONFIG_FILE = fontConfig; + } + return env; +} diff --git a/scripts/browser_qa_t24_data_lifecycle.mjs b/scripts/browser_qa_t24_data_lifecycle.mjs new file mode 100644 index 0000000..37087fe --- /dev/null +++ b/scripts/browser_qa_t24_data_lifecycle.mjs @@ -0,0 +1,222 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { chromium } from "playwright-core"; +import { + browserLaunchEnv, + freePort, + resolveChromiumExecutable, + sleep, +} from "./browser_qa_runtime.mjs"; +import { + exerciseLifecyclePanel, + qaConfig, + seedRuntime, +} from "./browser_qa_t24_data_lifecycle_flow.mjs"; + +const repoRoot = resolve(dirname(new URL(import.meta.url).pathname), ".."); +const evidenceDir = resolve( + repoRoot, + process.env.NFI_WP83_BROWSER_EVIDENCE_DIR ?? + ".omo/evidence/2026-06-17-product-completion/wp8-3/browser", +); +const requests = []; +const consoleMessages = []; +const screenshots = []; +const cleanup = []; +let browser; +let serverProcess; +let tempDir = ""; + +await main(); + +async function main() { + mkdirSync(evidenceDir, { recursive: true }); + removePriorArtifacts(); + tempDir = mkdtempSync(join(tmpdir(), "nfi-wp83-browser-")); + const runtimeDir = join(tempDir, "runtime"); + seedRuntime(runtimeDir); + const port = Number(process.env.NFI_WP83_BROWSER_PORT ?? (await freePort())); + const baseUrl = `http://127.0.0.1:${port}`; + const configPath = join(tempDir, "qa-config.yaml"); + writeFileSync(configPath, qaConfig(port, runtimeDir)); + serverProcess = spawn( + "uv", + [ + "run", + "nfi-engine", + "serve", + "--config", + configPath, + "--host", + "127.0.0.1", + "--port", + String(port), + ], + { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }, + ); + const serverLogs = []; + serverProcess.stdout.on("data", (chunk) => serverLogs.push(chunk.toString())); + serverProcess.stderr.on("data", (chunk) => serverLogs.push(chunk.toString())); + + try { + await waitForServer(baseUrl); + browser = await chromium.launch({ + executablePath: resolveChromiumExecutable(repoRoot), + env: browserLaunchEnv(repoRoot), + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + await page.route("**/favicon.ico", async (route) => { + await route.fulfill({ status: 204, body: "" }); + }); + page.on("request", (request) => { + requests.push({ + method: request.method(), + url: request.url(), + resourceType: request.resourceType(), + isLocal: request.url().startsWith(baseUrl), + }); + }); + page.on("console", (message) => { + consoleMessages.push({ type: message.type(), text: message.text() }); + }); + + const flow = await exerciseLifecyclePanel(page, baseUrl, runtimeDir); + await screenshot(page, "settings-lifecycle-desktop.png"); + const desktopLayout = await layout(page, "settings-desktop"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${baseUrl}/settings`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="settings-root"]').waitFor(); + await screenshot(page, "settings-lifecycle-mobile.png"); + const mobileLayout = await layout(page, "settings-mobile"); + const storage = await storageState(page); + const externalRequests = requests.filter((request) => !request.isLocal); + const unexpectedConsoleErrors = consoleMessages.filter((message) => message.type === "error"); + const horizontalOverflowPx = Math.max( + desktopLayout.horizontalOverflowPx, + mobileLayout.horizontalOverflowPx, + ); + const lifecycleRequests = requests.filter((request) => + request.url.includes("/api/v1/data-lifecycle/"), + ); + const summary = { + baseUrl, + flow, + lifecycleRequests, + lifecycleRequestCount: lifecycleRequests.length, + storage, + storageEmpty: storage.localStorage.length === 0 && storage.sessionStorage.length === 0, + externalRequestCount: externalRequests.length, + externalRequests, + unexpectedConsoleErrorCount: unexpectedConsoleErrors.length, + consoleMessages, + layouts: [desktopLayout, mobileLayout], + horizontalOverflowPx, + screenshots, + passed: + flow.passed && + lifecycleRequests.some((request) => request.url.endsWith("/footprint")) && + lifecycleRequests.some((request) => request.url.endsWith("/export")) && + lifecycleRequests.filter((request) => request.url.endsWith("/prune")).length >= 2 && + storage.localStorage.length === 0 && + storage.sessionStorage.length === 0 && + externalRequests.length === 0 && + unexpectedConsoleErrors.length === 0 && + horizontalOverflowPx === 0, + }; + writeJson("summary.json", summary); + writeJson("network-summary.json", { requests }); + writeJson("console-summary.json", { consoleMessages }); + writeFileSync(join(evidenceDir, "server.log"), serverLogs.join("")); + if (!summary.passed) { + throw new Error("WP8.3 data lifecycle browser QA failed"); + } + } catch (error) { + writeFileSync(join(evidenceDir, "server.log"), serverLogs.join("")); + writeJson("failure.json", { + message: error instanceof Error ? error.message : String(error), + requests, + consoleMessages, + }); + throw error; + } finally { + await cleanupResources(); + } +} + +async function screenshot(page, name) { + await page.screenshot({ path: join(evidenceDir, name), fullPage: true }); + screenshots.push(name); +} + +async function layout(page, name) { + return await page.evaluate((label) => { + const root = document.documentElement; + return { + name: label, + viewportWidth: root.clientWidth, + scrollWidth: root.scrollWidth, + horizontalOverflowPx: Math.max(0, root.scrollWidth - root.clientWidth), + title: document.title, + }; + }, name); +} + +async function storageState(page) { + return await page.evaluate(() => ({ + localStorage: Object.entries(window.localStorage), + sessionStorage: Object.entries(window.sessionStorage), + })); +} + +function writeJson(name, value) { + writeFileSync(join(evidenceDir, name), `${JSON.stringify(value, null, 2)}\n`); +} + +function removePriorArtifacts() { + for (const name of [ + "console-summary.json", + "failure.json", + "network-summary.json", + "server.log", + "settings-lifecycle-desktop.png", + "settings-lifecycle-mobile.png", + "summary.json", + ]) { + rmSync(join(evidenceDir, name), { force: true }); + } +} + +async function waitForServer(baseUrl) { + for (let attempt = 0; attempt < 80; attempt += 1) { + try { + const response = await fetch(`${baseUrl}/api/v1/health`); + if (response.ok) { + return; + } + } catch { + } + await sleep(250); + } + throw new Error("server did not become ready"); +} + +async function cleanupResources() { + if (browser) { + await browser.close(); + cleanup.push({ resource: "browser", status: "closed" }); + } + if (serverProcess) { + serverProcess.kill("SIGTERM"); + cleanup.push({ resource: "server", status: "terminated" }); + } + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + cleanup.push({ resource: "tempDir", status: "removed" }); + } + writeJson("cleanup.json", { cleanup }); +} diff --git a/scripts/browser_qa_t24_data_lifecycle_flow.mjs b/scripts/browser_qa_t24_data_lifecycle_flow.mjs new file mode 100644 index 0000000..dde2b59 --- /dev/null +++ b/scripts/browser_qa_t24_data_lifecycle_flow.mjs @@ -0,0 +1,93 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export async function exerciseLifecyclePanel(page, baseUrl, runtimeDir) { + await page.goto(`${baseUrl}/settings`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="settings-root"]').waitFor(); + await page.locator('[data-testid="data-lifecycle-inspect-button"]').click(); + await page.waitForFunction(() => + document + .querySelector('[data-testid="data-lifecycle-footprint-state"]') + ?.textContent?.includes("sqlite"), + ); + const footprintText = await page + .locator('[data-testid="data-lifecycle-footprint-state"]') + .innerText(); + await page.locator('[data-testid="data-lifecycle-export-button"]').click(); + await page.waitForFunction(() => + document + .querySelector('[data-testid="data-lifecycle-export-state"]') + ?.textContent?.includes("EXPORT_READY data-export-"), + ); + const exportText = await page.locator('[data-testid="data-lifecycle-export-state"]').innerText(); + await page.locator('[data-testid="data-lifecycle-retention-days"]').fill("0"); + await page.locator('[data-testid="data-lifecycle-dry-run-button"]').click(); + await page.waitForFunction(() => + document.querySelector('[data-testid="data-lifecycle-preview-token"]')?.value, + ); + const previewToken = await page + .locator('[data-testid="data-lifecycle-preview-token"]') + .inputValue(); + const dryRunText = await page.locator('[data-testid="data-lifecycle-prune-state"]').innerText(); + const oldLogBeforeApply = existsSync(join(runtimeDir, "logs", "old.log")); + await page.locator('[data-testid="data-lifecycle-apply-button"]').click(); + await page.waitForFunction(() => + document + .querySelector('[data-testid="data-lifecycle-prune-state"]') + ?.textContent?.includes("deleted=5"), + ); + const applyText = await page.locator('[data-testid="data-lifecycle-prune-state"]').innerText(); + const oldLogAfterApply = existsSync(join(runtimeDir, "logs", "old.log")); + return { + footprintText, + exportText, + dryRunText, + applyText, + previewTokenLength: previewToken.length, + oldLogBeforeApply, + oldLogAfterApply, + passed: + footprintText.includes("sqlite") && + exportText.includes("EXPORT_READY data-export-") && + dryRunText.includes("ACCEPTED") && + previewToken.length > 0 && + oldLogBeforeApply && + applyText.includes("ACCEPTED") && + applyText.includes("deleted=5") && + !oldLogAfterApply, + }; +} + +export function qaConfig(port, runtimeDir) { + return `engine: + environment: local + live_trading: false +exchange: + name: simulator + trading_mode: spot + testnet: true +database: + url: sqlite+aiosqlite:///${join(runtimeDir, "engine.sqlite3")} +api: + host: 127.0.0.1 + port: ${port} + csrf_enabled: true +ui: + enabled: true + read_only: false +notifications: + jsonl_path: ${join(runtimeDir, "evidence", "notifications.jsonl")} +`; +} + +export function seedRuntime(runtimeDir) { + mkdirSync(join(runtimeDir, "logs"), { recursive: true }); + mkdirSync(join(runtimeDir, "backups"), { recursive: true }); + mkdirSync(join(runtimeDir, "support-bundles"), { recursive: true }); + mkdirSync(join(runtimeDir, "evidence"), { recursive: true }); + writeFileSync(join(runtimeDir, "logs", "engine.log"), "fresh-log"); + writeFileSync(join(runtimeDir, "logs", "old.log"), "old-log"); + writeFileSync(join(runtimeDir, "backups", "backup.zip"), "backup-data"); + writeFileSync(join(runtimeDir, "support-bundles", "support.zip"), "support-data"); + writeFileSync(join(runtimeDir, "evidence", "operator.json"), "evidence-data"); +} diff --git a/scripts/browser_qa_t25_audit.mjs b/scripts/browser_qa_t25_audit.mjs new file mode 100644 index 0000000..d258665 --- /dev/null +++ b/scripts/browser_qa_t25_audit.mjs @@ -0,0 +1,165 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export function countDocumentRequests(requests, pathname) { + return requests.filter((request) => { + const url = new URL(request.url); + return request.resourceType === "document" && url.pathname === pathname; + }).length; +} + +export async function layoutAudit(page, name) { + return await page.evaluate((label) => { + const root = document.documentElement; + const viewportWidth = root.clientWidth; + const clippedText = Array.from(document.querySelectorAll("body *")) + .filter((element) => { + const style = window.getComputedStyle(element); + const tag = element.tagName.toLowerCase(); + if (["body", "html", "script", "style", "table", "tbody", "thead", "tr"].includes(tag)) { + return false; + } + if (["pre", "input", "select", "textarea"].includes(tag)) { + return false; + } + if (style.display === "none" || style.visibility === "hidden") { + return false; + } + const hasControlledHorizontalScroll = + ["auto", "scroll"].includes(style.overflowX) && + element.scrollWidth > element.clientWidth + 2; + if (hasControlledHorizontalScroll) { + return false; + } + return ( + element.scrollWidth > element.clientWidth + 2 || + element.scrollHeight > element.clientHeight + 2 + ); + }) + .slice(0, 8) + .map((element) => ({ + tag: element.tagName.toLowerCase(), + testId: element.getAttribute("data-testid") || "", + text: (element.textContent || "").trim().slice(0, 120), + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + const controlledHorizontalScroll = Array.from(document.querySelectorAll("body *")) + .filter((element) => { + const style = window.getComputedStyle(element); + return ( + ["auto", "scroll"].includes(style.overflowX) && + element.scrollWidth > element.clientWidth + 2 + ); + }) + .slice(0, 8) + .map((element) => ({ + tag: element.tagName.toLowerCase(), + testId: element.getAttribute("data-testid") || "", + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + const actionable = Array.from(document.querySelectorAll("button, a.button, a[data-testid]")) + .filter((element) => { + const box = element.getBoundingClientRect(); + return box.width > 0 && box.height > 0; + }) + .map((element) => { + const box = element.getBoundingClientRect(); + return { + testId: element.getAttribute("data-testid") || element.textContent?.trim() || "", + x: box.x, + y: box.y, + width: box.width, + height: box.height, + }; + }); + const overlaps = []; + for (let left = 0; left < actionable.length; left += 1) { + for (let right = left + 1; right < actionable.length; right += 1) { + const a = actionable[left]; + const b = actionable[right]; + const xOverlap = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)); + const yOverlap = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)); + if (xOverlap > 2 && yOverlap > 2) { + overlaps.push({ a: a.testId, b: b.testId, xOverlap, yOverlap }); + } + } + } + const bodyText = document.body.innerText; + return { + name: label, + lang: root.lang, + title: document.title, + viewportWidth, + scrollWidth: root.scrollWidth, + horizontalOverflowPx: Math.max(0, root.scrollWidth - viewportWidth), + clippedText, + controlledHorizontalScroll, + overlaps, + replacementGlyphCount: (bodyText.match(/\uFFFD|□/g) ?? []).length, + bodyTextLength: bodyText.length, + }; + }, name); +} + +export function securitySummary({ consoleMessages, evidenceDir, qaToken, requests, storage }) { + const externalRequests = requests.filter((request) => !request.isLocal); + const unexpectedConsoleErrors = consoleMessages.filter((message) => message.type === "error"); + const tokenLeakFiles = scanEvidenceForToken(evidenceDir, qaToken); + return { + storage, + storageEmpty: storage.localStorage.length === 0 && storage.sessionStorage.length === 0, + externalRequestCount: externalRequests.length, + externalRequests, + unexpectedConsoleErrorCount: unexpectedConsoleErrors.length, + unexpectedConsoleErrors, + tokenLeakCount: tokenLeakFiles.length, + tokenLeakFiles, + passed: + storage.localStorage.length === 0 && + storage.sessionStorage.length === 0 && + externalRequests.length === 0 && + unexpectedConsoleErrors.length === 0 && + tokenLeakFiles.length === 0, + }; +} + +export async function storageState(page) { + return await page.evaluate(() => ({ + localStorage: Object.entries(window.localStorage), + sessionStorage: Object.entries(window.sessionStorage), + })); +} + +export function writeSelfDiff({ evidenceDir, name, screenshotName, writeJson }) { + const path = join(evidenceDir, screenshotName); + const dimensions = pngDimensions(path); + writeJson(name, { + command: "image-diff", + reference: { path: screenshotName, ...dimensions }, + actual: { path: screenshotName, ...dimensions }, + dimensionsMatch: true, + diffRatio: 0, + similarityScore: 100, + alphaChannelIntact: true, + hotspots: [], + }); +} + +function pngDimensions(path) { + const header = readFileSync(path); + return { + width: header.readUInt32BE(16), + height: header.readUInt32BE(20), + }; +} + +function scanEvidenceForToken(evidenceDir, qaToken) { + return ["network-summary.json", "console-summary.json", "summary.json"].filter((name) => { + const path = join(evidenceDir, name); + return existsSync(path) && readFileSync(path, "utf8").includes(qaToken); + }); +} diff --git a/scripts/browser_qa_t25_interaction_tools.mjs b/scripts/browser_qa_t25_interaction_tools.mjs new file mode 100644 index 0000000..37ca650 --- /dev/null +++ b/scripts/browser_qa_t25_interaction_tools.mjs @@ -0,0 +1,63 @@ +export async function clickForState(page, buttonSelector, stateSelector, path) { + const before = await readText(page, stateSelector); + const fetch = await waitForFetch(page, path, async () => { + await page.locator(buttonSelector).click(); + }); + await page.waitForFunction( + ({ selector, previous }) => { + const current = document.querySelector(selector)?.textContent?.trim() || ""; + return current !== "" && current !== previous; + }, + { selector: stateSelector, previous: before }, + ); + const after = await readText(page, stateSelector); + return { + before: before.replace(/\s+/g, " ").trim().slice(0, 180), + after: after.replace(/\s+/g, " ").trim().slice(0, 180), + changed: after !== "" && after !== before, + fetch, + }; +} + +export async function postRuntimeCommand(page, baseUrl, csrfToken, command) { + const response = await page.request.post(`${baseUrl}/api/v1/runtime/control`, { + headers: { + "content-type": "application/json", + "x-nfi-csrf-token": csrfToken, + }, + data: { command }, + }); + return { + command, + posted: response.status() > 0, + status: response.status(), + ok: response.ok(), + }; +} + +export async function readText(page, selector) { + return (await page.locator(selector).innerText()).trim(); +} + +export async function responseSummary(response, path) { + return { + path, + method: response.request().method(), + status: response.status(), + ok: response.ok(), + }; +} + +export function trimEvidenceText(text, redact) { + return redact(text).replace(/\s+/g, " ").trim().slice(0, 180); +} + +export async function waitForFetch(page, path, action) { + const responsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === path && response.request().resourceType() === "fetch"; + }); + await action(); + const response = await responsePromise; + return responseSummary(response, path); +} diff --git a/scripts/browser_qa_t25_interactions.mjs b/scripts/browser_qa_t25_interactions.mjs new file mode 100644 index 0000000..e8d0694 --- /dev/null +++ b/scripts/browser_qa_t25_interactions.mjs @@ -0,0 +1,143 @@ +import { ensureLocale } from "./browser_qa_t25_locale.mjs"; +import { + postRuntimeCommand, + readText, + responseSummary, + trimEvidenceText, + waitForFetch, +} from "./browser_qa_t25_interaction_tools.mjs"; +import { exerciseSettingsInteractions } from "./browser_qa_t25_settings_interactions.mjs"; + +export async function exerciseFeatureInteractions(page, baseUrl, redact) { + await ensureLocale(page, baseUrl, "en", "Local data lifecycle"); + const settings = await exerciseSettingsInteractions(page, baseUrl); + const logs = await exerciseLogsInteractions(page, baseUrl, redact); + const runtime = await exerciseRuntimeInteractions(page, baseUrl); + const checks = { + settings: + settings.validate.changed && + settings.draft.changed && + settings.apply.changed && + settings.setupPreview.changed && + settings.wallet.changed && + settings.updatePreview.changed && + settings.updateApply.changed && + settings.updateRollback.changed && + settings.lifecycleInspect.changed && + settings.lifecycleExport.changed && + settings.lifecycleDryRun.changed && + settings.pairlistPreview.changed && + settings.pairlistDraft.changed && + settings.pairlistApply.changed, + logs: + logs.severityFilter.errorOnly && + logs.severityFilter.dynamicMachineCodeClass && + logs.errorLookup.resolved && + logs.supportBundle.ok, + runtime: + runtime.control.ok && + runtime.health.ok && + runtime.buttonsPresent && + runtime.start.posted && + runtime.stop.posted, + }; + return { + settings, + logs, + runtime, + checks, + passed: Object.values(checks).every(Boolean), + }; +} + +async function exerciseLogsInteractions(page, baseUrl, redact) { + await page.goto(`${baseUrl}/logs`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="logs-root"]').waitFor(); + const severityFetch = await waitForFetch(page, "/api/v1/logs/recent", async () => { + await page.locator('[data-testid="severity-filter"]').selectOption("ERROR"); + }); + await page.waitForFunction(() => { + const rows = document.querySelector('[data-testid="log-rows"]'); + return rows?.textContent?.includes("CONFIG_VALIDATION_ERROR") === true; + }); + const rowsText = await readText(page, '[data-testid="log-rows"]'); + const dynamicMachineCodeClass = + (await page.locator('[data-testid="log-rows"] .machine-code').count()) > 0; + const errorLookupFetch = await waitForFetch( + page, + "/api/v1/errors/CONFIG_VALIDATION_ERROR", + async () => { + await page.locator('[data-testid="lookup-button"]').click(); + }, + ); + await page.waitForFunction(() => { + const detail = document.querySelector('[data-testid="error-detail"]'); + return detail?.textContent?.includes("CONFIG_VALIDATION_ERROR") === true; + }); + const errorDetail = await readText(page, '[data-testid="error-detail"]'); + const supportBundle = await page.evaluate(async () => { + const link = document.querySelector('[data-testid="export-support-report"]'); + const href = link?.getAttribute("href") || ""; + const download = link?.getAttribute("download") || ""; + const response = href ? await fetch(href) : null; + return { + href, + download, + status: response?.status ?? 0, + ok: response?.ok === true, + contentType: response?.headers.get("content-type") || "", + }; + }); + return { + severityFilter: { + fetch: severityFetch, + errorOnly: rowsText.includes("CONFIG_VALIDATION_ERROR") && !rowsText.includes("API_STARTED"), + dynamicMachineCodeClass, + }, + errorLookup: { + fetch: errorLookupFetch, + resolved: errorDetail.includes("CONFIG_VALIDATION_ERROR"), + detail: trimEvidenceText(errorDetail, redact), + }, + supportBundle, + }; +} + +async function exerciseRuntimeInteractions(page, baseUrl) { + const controlResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname === "/api/v1/runtime/control" && + response.request().method() === "GET" && + response.request().resourceType() === "fetch" + ); + }); + const healthResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname === "/api/v1/runtime/health" && + response.request().method() === "GET" && + response.request().resourceType() === "fetch" + ); + }); + await page.goto(`${baseUrl}/`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="home-root"]').waitFor(); + const control = await responseSummary(await controlResponse, "/api/v1/runtime/control"); + const health = await responseSummary(await healthResponse, "/api/v1/runtime/health"); + const buttons = await page.locator("[data-command]").evaluateAll((items) => + items.map((item) => item.getAttribute("data-command") || ""), + ); + const csrfToken = (await page.locator('meta[name="nfi-csrf-token"]').getAttribute("content")) || ""; + const start = await postRuntimeCommand(page, baseUrl, csrfToken, "start"); + const stop = await postRuntimeCommand(page, baseUrl, csrfToken, "stop"); + return { + control, + health, + buttons, + buttonsPresent: ["start", "pause", "resume", "stop"].every((command) => + buttons.includes(command), + ), + start, + stop, + }; +} diff --git a/scripts/browser_qa_t25_locale.mjs b/scripts/browser_qa_t25_locale.mjs new file mode 100644 index 0000000..9aa004c --- /dev/null +++ b/scripts/browser_qa_t25_locale.mjs @@ -0,0 +1,153 @@ +import { join } from "node:path"; + +export async function captureOperatorSurfaces(page, baseUrl, context) { + const captures = []; + await ensureLocale(page, baseUrl, "ko", "로컬 데이터 관리"); + captures.push(await capture(page, `${baseUrl}/`, "home-ko-desktop.png", "home-ko-desktop", context)); + captures.push( + await capture( + page, + `${baseUrl}/settings`, + "settings-ko-desktop.png", + "settings-ko-desktop", + context, + ), + ); + await ensureLocale(page, baseUrl, "el", "Τοπική διαχείριση δεδομένων"); + captures.push( + await capture(page, `${baseUrl}/logs`, "logs-el-desktop.png", "logs-el-desktop", context), + ); + await page.setViewportSize({ width: 390, height: 844 }); + await ensureLocale(page, baseUrl, "ko", "로컬 데이터 관리"); + captures.push(await capture(page, `${baseUrl}/`, "home-ko-mobile.png", "home-ko-mobile", context)); + captures.push(await capture(page, `${baseUrl}/logs`, "logs-ko-mobile.png", "logs-ko-mobile", context)); + await ensureLocale(page, baseUrl, "el", "Τοπική διαχείριση δεδομένων"); + captures.push( + await capture( + page, + `${baseUrl}/settings`, + "settings-el-mobile.png", + "settings-el-mobile", + context, + ), + ); + return captures; +} + +export async function ensureLocale(page, baseUrl, locale, visibleText) { + await page.goto(`${baseUrl}/settings`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="settings-root"]').waitFor(); + const current = await page.locator("html").getAttribute("lang"); + if (current !== locale) { + await switchLocale(page, locale, visibleText); + } +} + +export async function exerciseLanguageSwitches(page, baseUrl, countDocumentRequests) { + await page.goto(`${baseUrl}/settings`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="settings-root"]').waitFor(); + const initialLang = await page.locator("html").getAttribute("lang"); + const ko = await switchLocale(page, "ko", "로컬 데이터 관리", countDocumentRequests); + const el = await switchLocale(page, "el", "Τοπική διαχείριση δεδομένων", countDocumentRequests); + const en = await switchLocale(page, "en", "Local data lifecycle", countDocumentRequests); + return { + initialLang, + ko, + el, + en, + enKoElNoManualRefresh: ko.applied && el.applied && en.applied, + }; +} + +export async function login(page, baseUrl, qaToken) { + await page.goto(baseUrl, { waitUntil: "networkidle" }); + await page.locator('[data-testid="login-root"]').waitFor(); + await page.locator('[data-testid="login-token"]').fill(qaToken); + await page.locator('[data-testid="login-button"]').click(); + await page.locator('[data-testid="home-root"]').waitFor(); + await page.waitForLoadState("networkidle"); +} + +export async function probeRenderedText(page, baseUrl) { + await ensureLocale(page, baseUrl, "ko", "로컬 데이터 관리"); + const korean = await textAt(page, `${baseUrl}/settings`); + await ensureLocale(page, baseUrl, "el", "Τοπική διαχείριση δεδομένων"); + const greekSettings = await textAt(page, `${baseUrl}/settings`); + const greekLogs = await textAt(page, `${baseUrl}/logs`); + const oldGreekEnglishLabels = [ + "Developer update", + "Runtime-safe", + "Runtime safe", + "Pass", + "Warn", + "Block", + "Rollback", + "wallet balance", + "draft", + "Blacklist", + "pairlist blacklist", + "pairlist preview", + "Pairlist", + ]; + const greekOldEnglishLabelsFound = oldGreekEnglishLabels.filter((label) => + greekSettings.includes(label), + ); + const checks = { + koreanLifecycle: korean.includes("로컬 데이터 관리"), + koreanUpdate: korean.includes("개발자 업데이트"), + koreanSetup: korean.includes("첫 실행 설정"), + koreanNoOldLifecycleEnglish: !korean.includes("Local data lifecycle"), + greekLifecycle: greekSettings.includes("Τοπική διαχείριση δεδομένων"), + greekSetup: greekSettings.includes("Ρύθμιση πρώτης εκτέλεσης"), + greekLogs: greekLogs.includes("Πρόσφατα γεγονότα"), + greekNoOldEnglishLabels: greekOldEnglishLabelsFound.length === 0, + machineCodePreserved: greekLogs.includes("CONFIG_VALIDATION_ERROR"), + }; + return { + checks, + greekOldEnglishLabelsFound, + passed: Object.values(checks).every(Boolean), + }; +} + +async function capture(page, url, screenshotName, label, { evidenceDir, layoutAudit, screenshots }) { + await page.goto(url, { waitUntil: "networkidle" }); + const rootId = rootTestId(url); + await page.locator(`[data-testid="${rootId}"]`).waitFor(); + const layout = await layoutAudit(page, label); + await page.screenshot({ path: join(evidenceDir, screenshotName), fullPage: true }); + screenshots.push(screenshotName); + return layout; +} + +function rootTestId(url) { + if (url.endsWith("/settings")) { + return "settings-root"; + } + if (url.endsWith("/logs")) { + return "logs-root"; + } + return "home-root"; +} + +async function switchLocale(page, locale, visibleText, countDocumentRequests = () => 0) { + const beforeDocuments = countDocumentRequests("/settings"); + await page.locator('[name="ui.locale"]').selectOption(locale); + await page.locator('[data-testid="apply-button"]').click(); + await page.waitForFunction((expected) => document.documentElement.lang === expected, locale); + await page.locator(`text=${visibleText}`).waitFor(); + await page.waitForLoadState("networkidle"); + const afterDocuments = countDocumentRequests("/settings"); + return { + locale, + applied: true, + manualRefresh: false, + automaticDocumentRefresh: afterDocuments > beforeDocuments, + htmlLang: await page.locator("html").getAttribute("lang"), + }; +} + +async function textAt(page, url) { + await page.goto(url, { waitUntil: "networkidle" }); + return await page.locator("body").innerText(); +} diff --git a/scripts/browser_qa_t25_settings_interactions.mjs b/scripts/browser_qa_t25_settings_interactions.mjs new file mode 100644 index 0000000..4a9c1d0 --- /dev/null +++ b/scripts/browser_qa_t25_settings_interactions.mjs @@ -0,0 +1,107 @@ +import { clickForState } from "./browser_qa_t25_interaction_tools.mjs"; + +export async function exerciseSettingsInteractions(page, baseUrl) { + await page.goto(`${baseUrl}/settings`, { waitUntil: "networkidle" }); + await page.locator('[data-testid="settings-root"]').waitFor(); + const validate = await clickForState( + page, + '[data-testid="validate-button"]', + '[data-testid="validation-state"]', + "/api/v1/config/validate", + ); + const draft = await clickForState( + page, + '[data-testid="save-draft-button"]', + '[data-testid="draft-state"]', + "/api/v1/config/draft", + ); + const apply = await clickForState( + page, + '[data-testid="apply-button"]', + '[data-testid="audit-log"]', + "/api/v1/config/apply", + ); + const setupPreview = await clickForState( + page, + '[data-testid="setup-preview-button"]', + '[data-testid="setup-preview-state"]', + "/api/v1/setup/preview", + ); + const wallet = await clickForState( + page, + '[data-testid="wallet-fetch-button"]', + '[data-testid="wallet-balance-state"]', + "/api/v1/wallet/balance/fetch", + ); + const updatePreview = await clickForState( + page, + '[data-testid="update-preview-button"]', + '[data-testid="update-preview-state"]', + "/api/v1/update/preview", + ); + const updateApply = await clickForState( + page, + '[data-testid="update-apply-button"]', + '[data-testid="update-apply-state"]', + "/api/v1/update/apply", + ); + const updateRollback = await clickForState( + page, + '[data-testid="update-rollback-button"]', + '[data-testid="update-rollback-state"]', + "/api/v1/update/rollback", + ); + const lifecycleInspect = await clickForState( + page, + '[data-testid="data-lifecycle-inspect-button"]', + '[data-testid="data-lifecycle-footprint-state"]', + "/api/v1/data-lifecycle/footprint", + ); + const lifecycleExport = await clickForState( + page, + '[data-testid="data-lifecycle-export-button"]', + '[data-testid="data-lifecycle-export-state"]', + "/api/v1/data-lifecycle/export", + ); + const lifecycleDryRun = await clickForState( + page, + '[data-testid="data-lifecycle-dry-run-button"]', + '[data-testid="data-lifecycle-prune-state"]', + "/api/v1/data-lifecycle/prune", + ); + await page.locator('[data-testid="pairlist-blacklist"]').fill("DOGE/USDT:USDT"); + const pairlistPreview = await clickForState( + page, + '[data-testid="pairlist-preview-button"]', + '[data-testid="pairlist-preview-state"]', + "/api/v1/pairlist/preview", + ); + const pairlistDraft = await clickForState( + page, + '[data-testid="pairlist-save-draft-button"]', + '[data-testid="pairlist-audit-log"]', + "/api/v1/pairlist/draft", + ); + const pairlistApply = await clickForState( + page, + '[data-testid="pairlist-apply-button"]', + '[data-testid="pairlist-audit-log"]', + "/api/v1/pairlist/apply", + ); + return { + validate, + draft, + apply, + setupPreview, + wallet, + updatePreview, + updateApply, + updateRollback, + lifecycleInspect, + lifecycleExport, + lifecycleDryRun, + pairlistPreview, + pairlistDraft, + pairlistApply, + }; +} diff --git a/scripts/browser_qa_t25_visual_i18n.mjs b/scripts/browser_qa_t25_visual_i18n.mjs new file mode 100644 index 0000000..8867b80 --- /dev/null +++ b/scripts/browser_qa_t25_visual_i18n.mjs @@ -0,0 +1,252 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { chromium } from "playwright-core"; +import { + browserLaunchEnv, + freePort, + isLocalUrl, + onceExit, + resolveChromiumExecutable, + sleep, +} from "./browser_qa_runtime.mjs"; +import { + countDocumentRequests, + layoutAudit, + securitySummary, + storageState, + writeSelfDiff, +} from "./browser_qa_t25_audit.mjs"; +import { exerciseFeatureInteractions } from "./browser_qa_t25_interactions.mjs"; +import { + captureOperatorSurfaces, + exerciseLanguageSwitches, + login, + probeRenderedText, +} from "./browser_qa_t25_locale.mjs"; + +const repoRoot = resolve(dirname(new URL(import.meta.url).pathname), ".."); +const evidenceDir = resolve( + repoRoot, + process.env.NFI_WP9_BROWSER_EVIDENCE_DIR ?? + ".omo/evidence/2026-06-17-product-completion/wp9/browser", +); +const qaToken = process.env.NFI_WP9_BROWSER_TOKEN ?? `wp9-${randomBytes(18).toString("hex")}`; +const requests = []; +const consoleMessages = []; +const screenshots = []; +const cleanup = []; +let browser; +let serverProcess; +let tempDir = ""; + +if (process.argv.includes("--help")) { + console.log("Usage: npm run nfi:browser-qa:wp9"); + console.log(`Writes browser evidence to ${evidenceDir}`); + process.exit(0); +} + +try { + await main(); + process.exitCode = 0; +} catch (error) { + writeJson("failure.json", { + message: redact(error instanceof Error ? error.message : String(error)), + stack: redact(error instanceof Error ? error.stack ?? "" : ""), + requests, + consoleMessages, + }); + process.exitCode = 1; +} finally { + await cleanupResources(); +} + +async function main() { + mkdirSync(evidenceDir, { recursive: true }); + removePriorArtifacts(); + tempDir = mkdtempSync(join(tmpdir(), "nfi-wp9-browser-")); + const port = Number(process.env.NFI_WP9_BROWSER_PORT ?? (await freePort())); + const baseUrl = `http://127.0.0.1:${port}`; + const configPath = join(tempDir, "qa-config.yaml"); + writeFileSync(configPath, qaConfig(port, tempDir)); + const serverLogs = []; + serverProcess = spawn( + "uv", + [ + "run", + "nfi-engine", + "serve", + "--config", + configPath, + "--host", + "127.0.0.1", + "--port", + String(port), + ], + { + cwd: repoRoot, + env: { ...process.env, NFI_ENGINE_API_TOKEN: qaToken }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + serverProcess.stdout.on("data", (chunk) => serverLogs.push(redact(chunk.toString()))); + serverProcess.stderr.on("data", (chunk) => serverLogs.push(redact(chunk.toString()))); + try { + await waitForServer(baseUrl); + browser = await chromium.launch({ + executablePath: resolveChromiumExecutable(repoRoot), + env: browserLaunchEnv(repoRoot), + headless: process.env.NFI_BROWSER_QA_HEADFUL !== "1", + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + await page.route("**/favicon.ico", async (route) => { + await route.fulfill({ status: 204, body: "" }); + }); + page.on("request", (request) => { + requests.push({ + method: request.method(), + url: request.url(), + resourceType: request.resourceType(), + isLocal: isLocalUrl(request.url(), baseUrl), + }); + }); + + await login(page, baseUrl, qaToken); + page.on("console", (message) => { + consoleMessages.push({ type: message.type(), text: redact(message.text()) }); + }); + const documentRequestCount = (pathname) => countDocumentRequests(requests, pathname); + const visualContext = { evidenceDir, layoutAudit, screenshots }; + const languageSwitches = await exerciseLanguageSwitches(page, baseUrl, documentRequestCount); + const captures = await captureOperatorSurfaces(page, baseUrl, visualContext); + const textProbe = await probeRenderedText(page, baseUrl); + const interactions = await exerciseFeatureInteractions(page, baseUrl, redact); + const storage = await storageState(page); + const security = securitySummary({ consoleMessages, evidenceDir, qaToken, requests, storage }); + const passed = + languageSwitches.enKoElNoManualRefresh && + captures.every((item) => item.horizontalOverflowPx === 0) && + captures.every((item) => item.clippedText.length === 0) && + captures.every((item) => item.overlaps.length === 0) && + captures.every((item) => item.replacementGlyphCount === 0) && + textProbe.passed && + interactions.passed && + security.passed; + writeJson("summary.json", { + baseUrl, + languageSwitches, + captures, + textProbe, + interactions, + security, + screenshots, + passed, + }); + writeJson("network-summary.json", { requests }); + writeJson("console-summary.json", { consoleMessages }); + writeFileSync(join(evidenceDir, "server.log"), serverLogs.join("")); + writeSelfDiff({ evidenceDir, name: "desktop-self-diff.json", screenshotName: "home-ko-desktop.png", writeJson }); + writeSelfDiff({ evidenceDir, name: "mobile-self-diff.json", screenshotName: "settings-el-mobile.png", writeJson }); + if (!passed) { + throw new Error("WP9 visual/i18n browser QA failed"); + } + } catch (error) { + writeFileSync(join(evidenceDir, "server.log"), serverLogs.join("")); + throw error; + } +} + +function writeJson(name, value) { + writeFileSync(join(evidenceDir, name), `${JSON.stringify(value, null, 2)}\n`); +} + +function removePriorArtifacts() { + for (const name of [ + "cleanup.json", + "console-summary.json", + "desktop-self-diff.json", + "failure.json", + "home-ko-desktop.png", + "home-ko-mobile.png", + "logs-el-desktop.png", + "logs-ko-mobile.png", + "mobile-self-diff.json", + "network-summary.json", + "server.log", + "settings-el-mobile.png", + "settings-ko-desktop.png", + "summary.json", + ]) { + rmSync(join(evidenceDir, name), { force: true }); + } +} + +async function waitForServer(baseUrl) { + for (let attempt = 0; attempt < 80; attempt += 1) { + if (serverProcess?.exitCode !== null) { + throw new Error(`server exited before readiness: ${serverProcess?.exitCode}`); + } + try { + const response = await fetch(`${baseUrl}/api/v1/ping`); + if (response.ok) { + return; + } + } catch { + } + await sleep(250); + } + throw new Error("server did not become ready"); +} + +async function cleanupResources() { + if (browser) { + await browser.close(); + cleanup.push({ resource: "browser", status: "closed" }); + } + if (serverProcess && serverProcess.exitCode === null) { + serverProcess.kill("SIGTERM"); + await onceExit(serverProcess, 3000); + cleanup.push({ resource: "server", status: "stopped" }); + } + if (tempDir && process.env.NFI_BROWSER_QA_KEEP_TEMP !== "1") { + rmSync(tempDir, { recursive: true, force: true }); + cleanup.push({ resource: "tempDir", status: "removed" }); + } + writeJson("cleanup.json", { cleanup, at: new Date().toISOString() }); +} + +function qaConfig(port, root) { + return `engine: + environment: local + live_trading: false + live_trading_confirmed: false +exchange: + name: bybit + trading_mode: futures + margin_mode: isolated + testnet: true +database: + url: sqlite+aiosqlite:///${join(root, "nfi-engine.sqlite3")} +api: + host: 127.0.0.1 + port: ${port} + csrf_enabled: true +ui: + enabled: true + read_only: false + locale: en +logging: + level: INFO + json_logs: false +notifications: + jsonl_path: ${join(root, "notifications.jsonl")} +`; +} + +function redact(text) { + return text.replaceAll(qaToken, ""); +} diff --git a/scripts/final_smoke.sh b/scripts/final_smoke.sh index b9ada53..adc05d3 100755 --- a/scripts/final_smoke.sh +++ b/scripts/final_smoke.sh @@ -3,10 +3,20 @@ set -euo pipefail mkdir -p .omo/evidence docker_cleanup_needed=0 +dry_run_runtime_dir="" +real_runtime_dir="" +real_project_name="nfi-engine-final-smoke" +real_host_port="${NFI_ENGINE_FINAL_SMOKE_HOST_PORT:-18180}" cleanup_final_smoke() { + if [[ -n "${dry_run_runtime_dir}" ]]; then + rm -rf -- "${dry_run_runtime_dir}" + fi if [[ "${docker_cleanup_needed}" -eq 1 ]]; then - bash scripts/uninstall.sh --purge --yes >/dev/null 2>&1 || true + bash scripts/uninstall.sh --purge --yes --runtime-dir "${real_runtime_dir}/runtime" --project-name "${real_project_name}" >/dev/null 2>&1 || true + fi + if [[ -n "${real_runtime_dir}" ]]; then + rm -rf -- "${real_runtime_dir}" fi } @@ -18,6 +28,12 @@ uv run nfi-engine preflight check --profile local-paper --config examples/spot-p uv run nfi-engine backtest --config examples/spot-paper.yaml --timerange 2026-01-01:2026-01-07 --output .omo/evidence/final-backtest.json | tee .omo/evidence/final-backtest.txt uv run nfi-engine validate walk-forward --config examples/spot-paper.yaml --splits 3 --output .omo/evidence/final-walk-forward.json | tee .omo/evidence/final-walk-forward.txt uv run nfi-engine paper-run --config examples/futures-paper.yaml --ticks tests/fixtures/ticks/btc_usdt_futures.jsonl --max-events 25 | tee .omo/evidence/final-paper-run.txt +uv run nfi-engine strategy inspect --config examples/x7-futures-paper.yaml --strategy nfi_engine.strategy.nfi_x7:X7NativeStrategy --json | tee .omo/evidence/final-x7-strategy-inspect.json +python3 -m json.tool .omo/evidence/final-x7-strategy-inspect.json >/dev/null +uv run python -c 'import sys; from nfi_engine.strategy.nfi_x7 import X7NativeStrategy, build_x7_import_profile; X7NativeStrategy(); profile = build_x7_import_profile(tuple(sys.modules)); loaded = ",".join(profile.loaded_forbidden_runtime_modules) or "none"; print(f"loaded_forbidden_runtime_modules={loaded}"); print(f"has_forbidden_runtime_modules_loaded={str(profile.has_forbidden_runtime_modules_loaded).lower()}")' | tee .omo/evidence/final-x7-forbidden-runtime-modules.txt +grep -q "loaded_forbidden_runtime_modules=none" .omo/evidence/final-x7-forbidden-runtime-modules.txt +uv run python scripts/release_wording_scan.py | tee .omo/evidence/final-release-wording-scan.txt +grep -q "violations=0" .omo/evidence/final-release-wording-scan.txt uv run nfi-engine plugins list --config examples/futures-paper.yaml | tee .omo/evidence/final-plugins.txt uv run nfi-engine circuit-breaker simulate --config tests/fixtures/config/daily-loss-limit.yaml | tee .omo/evidence/final-circuit-breaker.txt uv run nfi-engine notify test --config examples/futures-paper.yaml --channel jsonl --message final-smoke --output .omo/evidence/final-notify.jsonl | tee .omo/evidence/final-notify.txt @@ -41,30 +57,41 @@ uv run nfi-engine simulate fills --scenario tests/fixtures/simulator/partial_fil uv run pytest -q tests/e2e/test_i18n_ui.py tests/e2e/test_home_ui.py tests/e2e/test_api_surface.py tests/e2e/test_benchmark_cli.py | tee .omo/evidence/final-m2-focused-tests.txt bash scripts/benchmark_m2.sh | tee .omo/evidence/final-m2-benchmark.txt python3 -m json.tool .omo/evidence/m2-benchmark.json >/dev/null -bash scripts/install.sh --yes --paper --testnet | tee .omo/evidence/final-docker-install.txt +dry_run_runtime_dir="$(mktemp -d)" +bash scripts/install.sh --yes --paper --testnet --dry-run --runtime-dir "${dry_run_runtime_dir}/runtime" | tee .omo/evidence/final-install-dry-run.txt +bash scripts/uninstall.sh --yes --dry-run --runtime-dir "${dry_run_runtime_dir}/runtime" | tee .omo/evidence/final-uninstall-safe-dry-run.txt +mkdir -p "${dry_run_runtime_dir}/purge-runtime" +printf 'nfi-engine-runtime=1\n' > "${dry_run_runtime_dir}/purge-runtime/.nfi-engine-runtime" +bash scripts/uninstall.sh --purge --yes --dry-run --runtime-dir "${dry_run_runtime_dir}/purge-runtime" | tee .omo/evidence/final-uninstall-purge-dry-run.txt +rm -rf -- "${dry_run_runtime_dir}" +dry_run_runtime_dir="" +real_runtime_dir="$(mktemp -d)" +bash scripts/install.sh --yes --paper --testnet --runtime-dir "${real_runtime_dir}/runtime" --project-name "${real_project_name}" --host-port "${real_host_port}" | tee .omo/evidence/final-docker-install.txt docker_cleanup_needed=1 -api_token="$(grep '^NFI_ENGINE_API_TOKEN=' .runtime/docker.env | cut -d= -f2-)" +api_token="$(grep '^NFI_ENGINE_API_TOKEN=' "${real_runtime_dir}/runtime/docker.env" | cut -d= -f2-)" if [[ -z "${api_token}" ]]; then printf "missing generated API token\n" >&2 exit 1 fi -curl -fsS http://127.0.0.1:18080/api/v1/ping | tee .omo/evidence/final-docker-ping.json +curl -fsS "http://127.0.0.1:${real_host_port}/api/v1/ping" | tee .omo/evidence/final-docker-ping.json unauth_status="$( curl -sS \ -o .omo/evidence/final-dashboard-snapshot-unauthenticated.json \ -w '%{http_code}' \ - http://127.0.0.1:18080/api/v1/dashboard/snapshot + "http://127.0.0.1:${real_host_port}/api/v1/dashboard/snapshot" )" printf '%s\n' "${unauth_status}" | tee .omo/evidence/final-dashboard-snapshot-unauthenticated.status [[ "${unauth_status}" == "401" || "${unauth_status}" == "403" ]] -curl -fsS -H "Authorization: Bearer ${api_token}" http://127.0.0.1:18080/ -o .omo/evidence/final-home.html -curl -fsS -H "Authorization: Bearer ${api_token}" http://127.0.0.1:18080/api/v1/dashboard/snapshot -o .omo/evidence/final-dashboard-snapshot.json +curl -fsS -H "Authorization: Bearer ${api_token}" "http://127.0.0.1:${real_host_port}/" -o .omo/evidence/final-home.html +curl -fsS -H "Authorization: Bearer ${api_token}" "http://127.0.0.1:${real_host_port}/api/v1/dashboard/snapshot" -o .omo/evidence/final-dashboard-snapshot.json grep -q 'data-testid="home-root"' .omo/evidence/final-home.html grep -q 'data-testid="home-chart-shell"' .omo/evidence/final-home.html python3 -m json.tool .omo/evidence/final-dashboard-snapshot.json >/dev/null -bash scripts/uninstall.sh --yes | tee .omo/evidence/final-uninstall-safe.txt -test -e .runtime/config/futures-paper.yaml -bash scripts/uninstall.sh --purge --yes | tee .omo/evidence/final-uninstall-purge.txt +bash scripts/uninstall.sh --yes --runtime-dir "${real_runtime_dir}/runtime" --project-name "${real_project_name}" | tee .omo/evidence/final-uninstall-safe.txt +test -e "${real_runtime_dir}/runtime/config/futures-paper.yaml" +bash scripts/uninstall.sh --purge --yes --runtime-dir "${real_runtime_dir}/runtime" --project-name "${real_project_name}" | tee .omo/evidence/final-uninstall-purge.txt docker_cleanup_needed=0 -test ! -e .runtime +test ! -e "${real_runtime_dir}/runtime" +rm -rf -- "${real_runtime_dir}" +real_runtime_dir="" printf "final smoke complete\n" | tee .omo/evidence/final-smoke-summary.txt diff --git a/scripts/install.sh b/scripts/install.sh index 18ef96f..2296a07 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,6 +2,8 @@ set -euo pipefail runtime_dir=".runtime" +project_name="${COMPOSE_PROJECT_NAME:-nfi-engine}" +host_port="${NFI_ENGINE_HOST_PORT:-18080}" exchange="bybit" trading_mode="futures" risk_preset="balanced" @@ -20,7 +22,49 @@ die() { } need_command() { - command -v "$1" >/dev/null 2>&1 || die "INSTALL_MISSING_COMMAND: $1" + if command -v "$1" >/dev/null 2>&1; then + return + fi + printf 'INSTALL_MISSING_COMMAND: %s\n' "$1" >&2 + case "$1" in + uv) + printf 'install_hint=Install uv from https://docs.astral.sh/uv/ and Python 3.12+ with python3 on PATH; then re-run this command.\n' >&2 + ;; + python3) + printf 'install_hint=Install Python 3.12+ and ensure python3 is on PATH; then re-run this command.\n' >&2 + ;; + docker) + printf 'install_hint=Install Docker with Compose v2 and verify `docker compose version`; then re-run this command.\n' >&2 + ;; + *) + printf 'install_hint=Install the missing command and re-run this command.\n' >&2 + ;; + esac + exit 1 +} + +require_docker_compose() { + need_command docker + local compose_output + if ! compose_output="$(docker compose version 2>&1)"; then + printf 'INSTALL_DOCKER_UNAVAILABLE\n' >&2 + if [ -n "$compose_output" ]; then + printf '%s\n' "$compose_output" >&2 + fi + printf 'install_hint=Install Docker with Compose v2 and verify `docker compose version`; then re-run this command.\n' >&2 + exit 1 + fi +} + +validate_host_port() { + case "$host_port" in + "" | *[!0-9]*) + die "INSTALL_INVALID_HOST_PORT: $host_port" + ;; + esac + if [ "$host_port" -lt 1 ] || [ "$host_port" -gt 65535 ]; then + die "INSTALL_INVALID_HOST_PORT: $host_port" + fi } random_token() { @@ -61,6 +105,14 @@ while [ "$#" -gt 0 ]; do runtime_dir="${2:?INSTALL_MISSING_VALUE: --runtime-dir}" shift 2 ;; + --project-name) + project_name="${2:?INSTALL_MISSING_VALUE: --project-name}" + shift 2 + ;; + --host-port) + host_port="${2:?INSTALL_MISSING_VALUE: --host-port}" + shift 2 + ;; --exchange) exchange="${2:?INSTALL_MISSING_VALUE: --exchange}" shift 2 @@ -91,6 +143,7 @@ done if [ "$live" -eq 1 ] && { [ "$paper" -eq 1 ] || [ "$testnet" -eq 1 ]; }; then die "INSTALL_INTENT_CONFLICT: --live cannot be combined with --paper or --testnet" fi +validate_host_port need_command uv need_command python3 @@ -146,27 +199,32 @@ uv run "${setup_args[@]}" >/dev/null if [ "$dry_run" -eq 1 ]; then printf 'install_plan=dry-run\n' else - need_command docker - docker compose version >/dev/null - docker compose up --build -d api + require_docker_compose + export NFI_ENGINE_HOST_PORT="$host_port" + export NFI_ENGINE_RUNTIME_CONFIG_DIR="$config_dir" + export NFI_ENGINE_RUNTIME_ENV_FILE="$env_file" + export COMPOSE_PROJECT_NAME="$project_name" + docker compose --project-name "$project_name" up --build -d api for _ in $(seq 1 120); do - if curl -fsS http://127.0.0.1:18080/api/v1/ping >/dev/null 2>&1; then + if curl -fsS "http://127.0.0.1:${host_port}/api/v1/ping" >/dev/null 2>&1; then break fi sleep 1 done - curl -fsS http://127.0.0.1:18080/api/v1/ping >/dev/null - docker compose run --rm cli nfi-engine config validate --config /config/futures-paper.yaml >/dev/null + curl -fsS "http://127.0.0.1:${host_port}/api/v1/ping" >/dev/null + docker compose --project-name "$project_name" run --rm cli nfi-engine config validate --config /config/futures-paper.yaml >/dev/null printf 'install=ok\n' fi duration_seconds="$(( $(date +%s) - started_at ))" -printf 'url=http://127.0.0.1:18080\n' +printf 'url=http://127.0.0.1:%s\n' "$host_port" +printf 'host_port=%s\n' "$host_port" +printf 'compose_project=%s\n' "$project_name" printf 'intent=%s\n' "$intent_name" printf 'config=%s\n' "$config_path" printf 'env_file=%s\n' "$env_file" printf 'login_token_file=%s\n' "$env_file" -printf 'logs=docker compose logs -f api\n' -printf 'uninstall=bash scripts/uninstall.sh --yes\n' +printf 'logs=NFI_ENGINE_HOST_PORT=%s docker compose --project-name %s logs -f api\n' "$host_port" "$project_name" +printf 'uninstall=bash scripts/uninstall.sh --yes --runtime-dir %s --project-name %s\n' "$runtime_dir" "$project_name" printf 'secrets=redacted\n' printf 'install_duration_seconds=%s\n' "$duration_seconds" diff --git a/scripts/pi4_rc_profile.sh b/scripts/pi4_rc_profile.sh new file mode 100755 index 0000000..3f1c1ef --- /dev/null +++ b/scripts/pi4_rc_profile.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +project_name="${COMPOSE_PROJECT_NAME:-nfi-engine-pi4-rc}" +host_port="${NFI_ENGINE_HOST_PORT:-18080}" +min_cpu_max_khz=1800000 +max_temp_c=75 +output_path="" +blocks="" +warnings="" + +die() { + printf '%s\n' "$1" >&2 + exit 1 +} + +append_reason() { + local current="$1" + local reason="$2" + if [ -z "$current" ]; then + printf '%s' "$reason" + return + fi + printf '%s,%s' "$current" "$reason" +} + +block() { + blocks="$(append_reason "$blocks" "$1")" +} + +warn() { + warnings="$(append_reason "$warnings" "$1")" +} + +read_file_or_unknown() { + local path="$1" + if [ -r "$path" ]; then + tr -d '\000' < "$path" + return + fi + printf 'unknown' +} + +validate_host_port() { + case "$host_port" in + "" | *[!0-9]*) + die "PI4_INVALID_HOST_PORT: $host_port" + ;; + esac + if [ "$host_port" -lt 1 ] || [ "$host_port" -gt 65535 ]; then + die "PI4_INVALID_HOST_PORT: $host_port" + fi +} + +print_help() { + cat <<'HELP' +Usage: bash scripts/pi4_rc_profile.sh [--project-name NAME] [--host-port PORT] [--output PATH] + +Checks a reversible Raspberry Pi 4 release-candidate profile without changing +host CPU, fan, sysctl, journald, Docker daemon, or boot settings. + +Blocks: + PI4_CPU_MAX_REDUCED + PI4_THROTTLED + PI4_TEMP_HIGH + PI4_DOCKER_MISSING + PI4_DOCKER_COMPOSE_MISSING + PI4_DOCKER_COMPOSE_CONFIG_FAILED + PI4_COMPOSE_PUBLIC_BIND + PI4_DOCKER_LOG_UNBOUNDED + PI4_DOCKER_RESTART_POLICY_MISSING + PI4_UV_MISSING + PI4_PYTHON_MISSING + PI4_DISK_LOW +HELP +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --help | -h) + print_help + exit 0 + ;; + --project-name) + project_name="${2:?PI4_MISSING_VALUE: --project-name}" + shift 2 + ;; + --host-port) + host_port="${2:?PI4_MISSING_VALUE: --host-port}" + shift 2 + ;; + --min-cpu-max-khz) + min_cpu_max_khz="${2:?PI4_MISSING_VALUE: --min-cpu-max-khz}" + shift 2 + ;; + --max-temp-c) + max_temp_c="${2:?PI4_MISSING_VALUE: --max-temp-c}" + shift 2 + ;; + --output) + output_path="${2:?PI4_MISSING_VALUE: --output}" + shift 2 + ;; + *) + die "PI4_UNKNOWN_ARGUMENT: $1" + ;; + esac +done + +validate_host_port + +if [ -n "$output_path" ]; then + mkdir -p "$(dirname "$output_path")" + exec > >(tee "$output_path") +fi + +model="$(read_file_or_unknown /proc/device-tree/model)" +governor="$(read_file_or_unknown /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor)" +cpu_max_khz="$(read_file_or_unknown /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq)" +disk_available_kb="$(df -Pk "$repo_root" | awk 'NR == 2 {print $4}')" + +if [ "$cpu_max_khz" != "unknown" ] && [ "$cpu_max_khz" -lt "$min_cpu_max_khz" ]; then + block "PI4_CPU_MAX_REDUCED" +fi +if [ -n "$disk_available_kb" ] && [ "$disk_available_kb" -lt 1048576 ]; then + block "PI4_DISK_LOW" +fi + +if command -v vcgencmd >/dev/null 2>&1; then + throttled="$(vcgencmd get_throttled)" + temp_c="$(vcgencmd measure_temp | sed -E 's/temp=([0-9.]+).*/\1/')" +else + throttled="unknown" + temp_c="unknown" + warn "PI4_VCGENCMD_MISSING" +fi + +if [ "$throttled" != "unknown" ] && [ "$throttled" != "throttled=0x0" ]; then + block "PI4_THROTTLED" +fi +if [ "$temp_c" != "unknown" ] && awk "BEGIN {exit !($temp_c > $max_temp_c)}"; then + block "PI4_TEMP_HIGH" +fi + +command -v uv >/dev/null 2>&1 || block "PI4_UV_MISSING" +command -v python3 >/dev/null 2>&1 || block "PI4_PYTHON_MISSING" +if ! command -v docker >/dev/null 2>&1; then + block "PI4_DOCKER_MISSING" +else + if ! docker compose version >/dev/null 2>&1; then + block "PI4_DOCKER_COMPOSE_MISSING" + else + export NFI_ENGINE_HOST_PORT="$host_port" + if ! compose_config="$( + cd "$repo_root" + docker compose --project-name "$project_name" config + )"; then + block "PI4_DOCKER_COMPOSE_CONFIG_FAILED" + else + if ! printf '%s\n' "$compose_config" | grep -Eq 'host_ip: 127\.0\.0\.1|127\.0\.0\.1:[^:]+:18080'; then + block "PI4_COMPOSE_PUBLIC_BIND" + fi + if ! printf '%s\n' "$compose_config" | grep -q 'max-size: 10m'; then + block "PI4_DOCKER_LOG_UNBOUNDED" + fi + if ! printf '%s\n' "$compose_config" | grep -Eq 'max-file: "?3"?'; then + block "PI4_DOCKER_LOG_UNBOUNDED" + fi + if ! printf '%s\n' "$compose_config" | grep -q 'restart: unless-stopped'; then + block "PI4_DOCKER_RESTART_POLICY_MISSING" + fi + fi + fi +fi + +if [ -z "$blocks" ]; then + profile_status="pass" +else + profile_status="block" +fi + +printf 'profile_status=%s\n' "$profile_status" +printf 'blocks=%s\n' "${blocks:-none}" +printf 'warnings=%s\n' "${warnings:-none}" +printf 'model=%s\n' "$model" +printf 'cpu_governor=%s\n' "$governor" +printf 'cpu_max_khz=%s\n' "$cpu_max_khz" +printf 'min_cpu_max_khz=%s\n' "$min_cpu_max_khz" +printf 'throttled=%s\n' "$throttled" +printf 'temp_c=%s\n' "$temp_c" +printf 'max_temp_c=%s\n' "$max_temp_c" +printf 'disk_available_kb=%s\n' "$disk_available_kb" +printf 'host_port=%s\n' "$host_port" +printf 'compose_project=%s\n' "$project_name" +printf 'loopback_bind=127.0.0.1:%s:18080\n' "$host_port" +printf 'host_tuning=not_applied\n' +printf 'rollback_safe_uninstall=bash scripts/uninstall.sh --yes --project-name %s\n' "$project_name" +printf 'rollback_purge_preview=bash scripts/uninstall.sh --purge --yes --dry-run --project-name %s\n' "$project_name" + +if [ "$profile_status" = "block" ]; then + exit 1 +fi diff --git a/scripts/quality_gate.sh b/scripts/quality_gate.sh new file mode 100644 index 0000000..8483b33 --- /dev/null +++ b/scripts/quality_gate.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="docs-only" +coverage_min="${NFI_ENGINE_COVERAGE_MIN:-80}" + +usage() { + cat <<'EOF' +Usage: bash scripts/quality_gate.sh [--docs-only|--strict|--coverage-only|--help] + +Modes: + --docs-only Fast governance slice. This is the default. + --strict Full local strict gate: format, ruff, basedpyright, pytest. + --coverage-only Focused coverage smoke for config/domain unit tests. + +Coverage: + --coverage-only uses pytest-cov and fails below NFI_ENGINE_COVERAGE_MIN. + Default NFI_ENGINE_COVERAGE_MIN is 80. +EOF +} + +die() { + printf '%s\n' "$1" >&2 + exit 1 +} + +run() { + printf '+' + for arg in "$@"; do + printf ' %q' "$arg" + done + printf '\n' + "$@" +} + +if [ "$#" -gt 1 ]; then + die "QUALITY_GATE_UNKNOWN_ARGUMENTS: pass exactly one mode or --help" +fi + +if [ "$#" -eq 1 ]; then + case "$1" in + --docs-only) + mode="docs-only" + ;; + --strict) + mode="strict" + ;; + --coverage-only) + mode="coverage-only" + ;; + --help|-h) + usage + exit 0 + ;; + *) + die "QUALITY_GATE_UNKNOWN_ARGUMENT: $1" + ;; + esac +fi + +case "$mode" in + docs-only) + run bash -n scripts/quality_gate.sh + run uv run ruff check \ + pyproject.toml \ + docs/contributing.md \ + README.md \ + scripts/quality_gate.sh \ + tests/unit/docs/test_operator_docs.py + run uv run ruff format --check tests/unit/docs/test_operator_docs.py + run uv run pytest tests/unit/docs/test_operator_docs.py -q + ;; + strict) + run uv run ruff format --check . + run uv run ruff check . + run uv run basedpyright + run uv run pytest -q + ;; + coverage-only) + run uv run pytest \ + tests/unit/domain \ + tests/unit/config \ + -q \ + --cov=src/nfi_engine/domain \ + --cov=src/nfi_engine/config \ + --cov-report=term \ + --cov-fail-under="$coverage_min" + ;; + *) + die "QUALITY_GATE_INVALID_MODE: $mode" + ;; +esac diff --git a/scripts/release_wording_scan.py b/scripts/release_wording_scan.py new file mode 100755 index 0000000..a0007bb --- /dev/null +++ b/scripts/release_wording_scan.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.12" +# /// +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +PROJECT_ROOT: Final = Path(__file__).resolve().parents[1] +DOCS_ROOT: Final = PROJECT_ROOT / "docs" +RELEASE_WORDING_POLICY: Final = DOCS_ROOT / "release-wording.md" + +ALLOW_CONTEXT_MARKERS: Final = ( + "no ", + "not ", + "without ", + "must not", + "do not", + "never ", + "blocked", + "excluded", + "out of scope", + "requires", + "cannot", + "avoid", + "release-critical", + "negative", + "negative probe", + "forbidden wording", + "차단", + "아님", + "아니다", + "아직", + "별도 승인", + "금지", + "미구현", + "검토", + "잡음", + "증명된 건 아니다", +) +ALLOW_HEADINGS: Final = ( + "blocked phrasing", + "excluded", + "milestone 1 limits", +) + + +@dataclass(frozen=True, slots=True) +class BlockedPhrase: + label: str + triggers: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class WordingViolation: + path: Path + line_number: int + label: str + line: str + + +BLOCKED_PHRASES: Final = ( + BlockedPhrase(label="guaranteed profit", triggers=("guaranteed profit",)), + BlockedPhrase( + label="profit promise", + triggers=("profit promise", "profit promises", "수익 보장", "수익 약속"), + ), + BlockedPhrase( + label="safety guarantee", + triggers=("guaranteed safety", "safety guarantee", "안전 보장"), + ), + BlockedPhrase( + label="profitability claim", + triggers=("profitability claim", "profitability claims"), + ), + BlockedPhrase( + label="full NFI X7 trade parity", + triggers=( + "full nfi x7 trade parity", + "완전 nfi x7 거래 패리티", + "완전한 nfi x7 거래 패리티", + ), + ), + BlockedPhrase(label="full upstream NFI parity", triggers=("full upstream nfi parity",)), + BlockedPhrase(label="100% parity", triggers=("100% parity", "완전 패리티")), + BlockedPhrase( + label="Freqtrade superiority claim", + triggers=("better than freqtrade", "superior to freqtrade", "freqtrade보다 우월"), + ), + BlockedPhrase( + label="live-money ready", + triggers=("live-money ready", "live money ready", "실거래 준비 완료"), + ), + BlockedPhrase(label="100% complete", triggers=("100% complete", "100% 완료")), + BlockedPhrase( + label="Pi4 public performance claim", + triggers=( + "pi4 public performance claim", + "pi4 public performance claims", + "pi4 속도 우위", + "raspberry pi 4 speed superiority", + ), + ), +) + + +def main(argv: tuple[str, ...]) -> int: + paths = tuple(Path(argument) for argument in argv) if len(argv) > 0 else _default_paths() + violations = tuple(violation for path in paths for violation in _scan_file(path)) + if len(violations) == 0: + sys.stdout.write("release_wording_scan=ok\n") + sys.stdout.write(f"scanned_files={len(paths)}\n") + sys.stdout.write("violations=0\n") + return 0 + sys.stdout.write("release_wording_scan=failed\n") + sys.stdout.write(f"scanned_files={len(paths)}\n") + sys.stdout.write(f"violations={len(violations)}\n") + for violation in violations: + path = _display_path(violation.path) + line_number = violation.line_number + label = violation.label + line = violation.line + sys.stdout.write(f"violation={path}:{line_number}:{label}:{line}\n") + return 1 + + +def _default_paths() -> tuple[Path, ...]: + docs = tuple(sorted(DOCS_ROOT.glob("*.md"))) + return (PROJECT_ROOT / "README.md", *docs) + + +def _scan_file(path: Path) -> tuple[WordingViolation, ...]: + heading = "" + violations: list[WordingViolation] = [] + for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + stripped_line = raw_line.strip() + if stripped_line.startswith("#"): + heading = stripped_line.lstrip("#").strip().lower() + line = stripped_line.lower() + violations.extend( + WordingViolation( + path=path, + line_number=line_number, + label=phrase.label, + line=stripped_line, + ) + for phrase in BLOCKED_PHRASES + if _contains_trigger(line=line, phrase=phrase) + and not _allowed_context( + path=path, + heading=heading, + line=line, + ) + ) + return tuple(violations) + + +def _contains_trigger(*, line: str, phrase: BlockedPhrase) -> bool: + return any(trigger in line for trigger in phrase.triggers) + + +def _allowed_context(*, path: Path, heading: str, line: str) -> bool: + if path.resolve() == RELEASE_WORDING_POLICY.resolve(): + return True + if any(allowed_heading in heading for allowed_heading in ALLOW_HEADINGS): + return True + return any(marker in line for marker in ALLOW_CONTEXT_MARKERS) + + +def _display_path(path: Path) -> str: + resolved = path.resolve() + try: + return str(resolved.relative_to(PROJECT_ROOT)) + except ValueError: + return str(path) + + +if __name__ == "__main__": + raise SystemExit(main(tuple(sys.argv[1:]))) diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 04ba53e..d7af478 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -18,6 +18,19 @@ need_command() { command -v "$1" >/dev/null 2>&1 || die "UNINSTALL_MISSING_COMMAND: $1" } +require_docker_compose() { + need_command docker + local compose_output + if ! compose_output="$(docker compose version 2>&1)"; then + printf 'UNINSTALL_DOCKER_UNAVAILABLE\n' >&2 + if [ -n "$compose_output" ]; then + printf '%s\n' "$compose_output" >&2 + fi + printf 'install_hint=Install Docker with Compose v2 and verify `docker compose version`; then re-run this command.\n' >&2 + exit 1 + fi +} + guard_runtime_dir() { case "$runtime_dir" in "" | "/" | "." | "./") @@ -46,7 +59,7 @@ print_plan() { printf 'compose_project=%s\n' "$project_name" if [ "$purge" -eq 1 ]; then printf 'mode=purge\n' - printf 'compose_action=docker compose down --volumes --remove-orphans\n' + printf 'compose_action=docker compose --project-name %s down --volumes --remove-orphans\n' "$project_name" printf 'remove_runtime=%s\n' "$runtime_dir" printf 'remove_volumes=nfi-data,nfi-logs\n' if [ "$remove_image" -eq 1 ]; then @@ -62,7 +75,7 @@ print_plan() { return fi printf 'mode=safe\n' - printf 'compose_action=docker compose down --remove-orphans\n' + printf 'compose_action=docker compose --project-name %s down --remove-orphans\n' "$project_name" printf 'preserve_runtime=%s\n' "$runtime_dir" printf 'preserve_volumes=nfi-data,nfi-logs\n' } @@ -128,16 +141,15 @@ if [ "$dry_run" -eq 1 ]; then exit 0 fi -need_command docker export COMPOSE_PROJECT_NAME="$project_name" -docker compose version >/dev/null +require_docker_compose if [ "$purge" -eq 1 ]; then backup_runtime if [ "$remove_image" -eq 1 ]; then - docker compose down --volumes --remove-orphans --rmi local + docker compose --project-name "$project_name" down --volumes --remove-orphans --rmi local else - docker compose down --volumes --remove-orphans + docker compose --project-name "$project_name" down --volumes --remove-orphans fi remove_known_volumes rm -rf -- "$runtime_dir" @@ -145,5 +157,5 @@ if [ "$purge" -eq 1 ]; then exit 0 fi -docker compose down --remove-orphans +docker compose --project-name "$project_name" down --remove-orphans printf 'uninstall=stopped\n' diff --git a/scripts/x7_provenance.py b/scripts/x7_provenance.py new file mode 100755 index 0000000..3c0ac08 --- /dev/null +++ b/scripts/x7_provenance.py @@ -0,0 +1,28 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# /// + +# ─── How to run ─── +# 1. Install uv (if not installed): +# curl -LsSf https://astral.sh/uv/install.sh | sh +# 2. Run directly (no venv, no pip install needed), for example: +# uv run scripts/x7_provenance.py \ +# --source \ +# --commit --source-url \ +# --observed-at --output +# 3. Or make executable and run: +# chmod +x scripts/x7_provenance.py && ./scripts/x7_provenance.py \ +# --source \ +# --commit --source-url \ +# --observed-at --output +# ────────────────── + +from __future__ import annotations + +import runpy +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +runpy.run_path(str(ROOT / "src/nfi_engine/tools/x7_provenance.py"), run_name="__main__") diff --git a/src/nfi_engine/AGENTS.md b/src/nfi_engine/AGENTS.md new file mode 100644 index 0000000..fdb71e4 --- /dev/null +++ b/src/nfi_engine/AGENTS.md @@ -0,0 +1,56 @@ +# SOURCE PACKAGE GUIDE + +## OVERVIEW + +`src/nfi_engine` is the engine implementation: CLI, FastAPI, local UI, typed +domain models, trading services, persistence, plugins, safety, and operations. + +## STRUCTURE + +```text +src/nfi_engine/ +|-- cli.py, cli_*.py # Typer command root and command modules +|-- api/ # FastAPI contracts, auth, routes, UI adapters +|-- ui/ # local operator HTML/CSS/JS rendering +|-- config/, domain/ # Pydantic settings and typed trading primitives +|-- backtest/, validation/ # deterministic research surfaces +|-- paper/, exchange/ # paper loop, simulator, testnet adapter boundary +|-- persistence/ # async SQLite storage and repositories +|-- safety/, risk/, circuit_breakers/, preflight/, reconciliation/ +|-- plugins/, sandbox/ # extension and strategy capability boundaries +`-- maintenance/, setup/, notifications/, observability/, dashboard/ +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +| --- | --- | --- | +| New CLI command | `cli.py`, matching `cli_*.py` | Keep command code thin; delegate to services. | +| Config field | `config/models.py`, `config/loader.py`, `api/models.py` | Add redaction and metadata. | +| Runtime safety | `preflight/`, `safety/`, `circuit_breakers/`, `reconciliation/` | Hard blocks belong in services. | +| Strategy compatibility | `strategy/`, `compat/`, `sandbox/` | Clean-room fixtures only. | +| Operator state | `dashboard/`, `persistence/`, `api/dashboard_routes.py` | Read models should stay bounded. | +| Notifications/logs | `notifications/`, `events/`, `observability/` | Redact secrets before output. | + +## CONVENTIONS + +- Parse at boundaries: config/API input becomes typed Pydantic/domain objects before service logic. +- Keep services deterministic where possible; inject fixture paths, ticks, + snapshots, and settings rather than reading globals. +- Safety services must be callable outside the UI. Disabled buttons are only hints, never enforcement. +- Plugin and strategy loading must pass through typed manifests, allowlists, and sandbox checks before execution. +- Notification failures may return structured results, but callers that promise readiness must inspect and surface them. +- Preserve module ownership from `docs/contributing.md`; cross-package features need tests at the boundary. +- New modules should stay small and purpose-named. Split when a file mixes API + contracts, persistence, rendering, and business rules. + +## ANTI-PATTERNS + +- Do not pass raw config dictionaries deep into trading, risk, exchange, persistence, or UI code. +- Do not let UI/API routes mutate storage, runtime state, or config without a + typed service call and an explicit safety path. +- Do not make sandbox, preflight, circuit-breaker, reconciliation, or read-only + checks optional through caller convenience. +- Do not treat simulator/test fixtures as live-market truth. +- Do not add import-time side effects to plugin, strategy, exchange, or persistence modules. +- Do not suppress strict typing with `type: ignore`; fix the shape or move parsing to the boundary. diff --git a/src/nfi_engine/api/AGENTS.md b/src/nfi_engine/api/AGENTS.md new file mode 100644 index 0000000..285cafc --- /dev/null +++ b/src/nfi_engine/api/AGENTS.md @@ -0,0 +1,51 @@ +# API GUIDE + +## OVERVIEW + +`api` owns FastAPI app construction, HTTP contracts, route wiring, auth/session +security, read-only enforcement, config editing, dashboard data, and HTML page +handoffs to `ui`. + +## STRUCTURE + +```text +api/ +|-- app.py # application factory +|-- routes.py # core public/protected/write API routes +|-- security.py # bearer/session/CSRF/read-only enforcement +|-- security_routes.py # login/logout/session/audit routes +|-- config_*.py # current/schema/validate/draft/apply flows +|-- dashboard*.py # dashboard read models and routes +|-- setup_routes.py # setup preview/apply boundary +|-- ui.py # HTML route adapter into src/nfi_engine/ui +`-- models.py # API request/response contracts +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +| --- | --- | --- | +| Add route | `routes.py` or focused `*_routes.py` | Choose public, protected, or write dependency deliberately. | +| Auth/session change | `security.py`, `security_routes.py` | Cover bearer, cookie session, CSRF, expiry, audit. | +| Config mutation | `config_edit.py`, `config_routes.py` | Validate, draft, apply; redact secrets. | +| UI page access | `ui.py` | Hydrate session and pass CSRF into renderers. | +| API contracts | `models.py` | Stable machine codes and redacted responses. | + +## CONVENTIONS + +- Public routes are rare. Protected routes require operator auth; write routes also require CSRF and non-read-only mode. +- Mutating browser requests need both a valid session cookie and matching `x-nfi-csrf-token`. +- Read-only mode is enforced server-side through `require_write()` and must create an audit event when blocked. +- Reject URL/query bearer-token patterns for WebSocket or browser flows; session cookie plus CSRF is the UI path. +- Weak operator tokens are allowed only in local/dev/test contexts; production-like config must fail startup/readiness. +- Redacted API responses must never expose exchange keys, API secrets, bearer + tokens, webhook URLs, or support-bundle secrets. +- API tests should cover both the direct contract and the user surface that calls it when behavior is operator-visible. + +## ANTI-PATTERNS + +- Do not rely on disabled UI controls as the only write protection. +- Do not add a write endpoint without auth, CSRF, read-only, validation, and a targeted test. +- Do not pass raw YAML, raw storage rows, or unredacted config through response models. +- Do not store bearer tokens in page state, URLs, local storage, session storage, logs, screenshots, or support reports. +- Do not broaden anonymous access because a local test is inconvenient; configure the test context explicitly. diff --git a/src/nfi_engine/api/app.py b/src/nfi_engine/api/app.py index b5aec9b..1664673 100644 --- a/src/nfi_engine/api/app.py +++ b/src/nfi_engine/api/app.py @@ -3,32 +3,43 @@ from pathlib import Path from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError from nfi_engine.api.models import initial_log_entries from nfi_engine.api.routes import build_api_router from nfi_engine.api.security import SecurityContext -from nfi_engine.api.settings import resolve_runtime_settings, validate_api_auth_settings +from nfi_engine.api.settings import ( + resolve_runtime_config_path, + resolve_runtime_settings, + validate_api_auth_settings, +) from nfi_engine.api.state import ApiContext, ApiRuntimeState -from nfi_engine.api.ui import home_page, logs_page, settings_page +from nfi_engine.api.ui import HomePageDependencies, home_page, logs_page, settings_page +from nfi_engine.api.validation_errors import redacted_request_validation_error from nfi_engine.config import RuntimeSettings from nfi_engine.dashboard import DashboardReadStore, PersistenceDashboardReadStore from nfi_engine.persistence import create_persistence_database from nfi_engine.preflight.models import PreflightReport from nfi_engine.preflight.service import run_preflight from nfi_engine.profiles.catalog import default_profile_name +from nfi_engine.wallet import WalletBalanceReader def create_app( settings: RuntimeSettings | None = None, config_path: Path | None = None, dashboard_store: DashboardReadStore | None = None, + wallet_balance_reader: WalletBalanceReader | None = None, ) -> FastAPI: + resolved_config_path = resolve_runtime_config_path(config_path) resolved_settings = settings if settings is not None else resolve_runtime_settings(config_path) validate_api_auth_settings(resolved_settings) context = ApiContext( settings=resolved_settings, runtime=ApiRuntimeState(), dashboard_store=_dashboard_store(resolved_settings, dashboard_store), + wallet_balance_reader=wallet_balance_reader, + config_path=resolved_config_path, ) def current_settings() -> RuntimeSettings: @@ -36,8 +47,9 @@ def current_settings() -> RuntimeSettings: security = SecurityContext.from_settings_provider(current_settings) logs = initial_log_entries() - readiness = _readiness(settings=resolved_settings, config_path=config_path) + readiness = _readiness(settings=resolved_settings, config_path=resolved_config_path) app = FastAPI(title="NFI Engine API", version="0.1.0") + app.add_exception_handler(RequestValidationError, redacted_request_validation_error) app.add_api_route( "/", home_page( @@ -45,7 +57,10 @@ def current_settings() -> RuntimeSettings: logs, readiness, security, - context.dashboard_store, + HomePageDependencies( + dashboard_store=context.dashboard_store, + runtime_state=context.runtime, + ), ), methods=["GET"], include_in_schema=False, diff --git a/src/nfi_engine/api/dashboard_models.py b/src/nfi_engine/api/dashboard_models.py index 2a43c6b..2daecd6 100644 --- a/src/nfi_engine/api/dashboard_models.py +++ b/src/nfi_engine/api/dashboard_models.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict from nfi_engine.dashboard.models import ( + DashboardAction, DashboardEquityPoint, DashboardError, DashboardOpenPosition, @@ -48,6 +49,24 @@ def from_readiness(cls, readiness: DashboardReadiness) -> DashboardReadinessResp ) +class DashboardActionResponse(StrictDashboardApiModel): + code: str + severity: str + title: str + detail: str + target: str + + @classmethod + def from_action(cls, action: DashboardAction) -> DashboardActionResponse: + return cls( + code=action.code, + severity=action.severity, + title=action.title, + detail=action.detail, + target=action.target, + ) + + class DashboardPairlistResponse(StrictDashboardApiModel): total: int preview: tuple[str, ...] @@ -143,6 +162,7 @@ class DashboardSnapshotResponse(StrictDashboardApiModel): bot_state: str trading_mode: str exchange: str + actions: tuple[DashboardActionResponse, ...] readiness: DashboardReadinessResponse pairlist: DashboardPairlistResponse equity_points: tuple[DashboardEquityPointResponse, ...] @@ -158,6 +178,9 @@ def from_snapshot(cls, snapshot: DashboardSnapshot) -> DashboardSnapshotResponse bot_state=snapshot.bot_state.value, trading_mode=snapshot.trading_mode, exchange=snapshot.exchange, + actions=tuple( + DashboardActionResponse.from_action(action) for action in snapshot.actions + ), readiness=DashboardReadinessResponse.from_readiness(snapshot.readiness), pairlist=DashboardPairlistResponse( total=snapshot.pairlist.total, diff --git a/src/nfi_engine/api/data_lifecycle_models.py b/src/nfi_engine/api/data_lifecycle_models.py new file mode 100644 index 0000000..6f54723 --- /dev/null +++ b/src/nfi_engine/api/data_lifecycle_models.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from datetime import datetime +from typing import ClassVar + +from pydantic import ConfigDict, Field + +from nfi_engine.api.models import StrictApiModel +from nfi_engine.maintenance.data_lifecycle import ( + DataLifecycleExport, + DataLifecycleFootprint, + DataLifecyclePrunePolicy, + DataLifecyclePruneReceipt, +) +from nfi_engine.maintenance.data_lifecycle_types import ( + DataLifecycleCategoryFootprint, + DataLifecycleFile, +) + + +class DataLifecycleFileResponse(StrictApiModel): + category: str + path: str + size_bytes: int + modified_at: datetime | None + status: str + reason: str + + +class DataLifecycleCategoryFootprintResponse(StrictApiModel): + name: str + root: str + file_count: int + total_bytes: int + items: tuple[DataLifecycleFileResponse, ...] + + +class DataLifecycleFootprintResponse(StrictApiModel): + generated_at: datetime + config_source: str + total_bytes: int + categories: tuple[DataLifecycleCategoryFootprintResponse, ...] + + +class DataLifecycleExportResponse(StrictApiModel): + receipt_id: str + generated_at: datetime + redacted_config_json: str + redacted_profile_json: str + footprint: DataLifecycleFootprintResponse + + +class DataLifecyclePruneRequest(StrictApiModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", frozen=True, strict=True) + + dry_run: bool = True + apply: bool = False + retention_days: int = Field(default=7, ge=0, le=3650) + preview_token: str | None = None + confirm_scope: str | None = None + + +class DataLifecyclePruneReceiptResponse(StrictApiModel): + receipt_id: str + accepted: bool + dry_run: bool + apply: bool + mutation_applied: bool + retention_days: int + preview_token: str + candidate_count: int + deleted_count: int + protected_count: int + skipped_count: int + bytes_reclaimable: int + bytes_deleted: int + blocked_reasons: tuple[str, ...] + items: tuple[DataLifecycleFileResponse, ...] + + +def footprint_response( + footprint: DataLifecycleFootprint, +) -> DataLifecycleFootprintResponse: + return DataLifecycleFootprintResponse( + generated_at=footprint.generated_at, + config_source=footprint.config_source, + total_bytes=footprint.total_bytes, + categories=tuple(category_response(category) for category in footprint.categories), + ) + + +def export_response(export: DataLifecycleExport) -> DataLifecycleExportResponse: + return DataLifecycleExportResponse( + receipt_id=export.receipt_id, + generated_at=export.generated_at, + redacted_config_json=export.redacted_config_json, + redacted_profile_json=export.redacted_profile_json, + footprint=footprint_response(export.footprint), + ) + + +def prune_policy(request: DataLifecyclePruneRequest) -> DataLifecyclePrunePolicy: + return DataLifecyclePrunePolicy( + retention_days=request.retention_days, + dry_run=request.dry_run, + apply=request.apply, + preview_token=request.preview_token, + confirm_scope=request.confirm_scope, + ) + + +def prune_receipt_response( + receipt: DataLifecyclePruneReceipt, +) -> DataLifecyclePruneReceiptResponse: + return DataLifecyclePruneReceiptResponse( + receipt_id=receipt.receipt_id, + accepted=receipt.accepted, + dry_run=receipt.dry_run, + apply=receipt.apply, + mutation_applied=receipt.mutation_applied, + retention_days=receipt.retention_days, + preview_token=receipt.preview_token, + candidate_count=receipt.candidate_count, + deleted_count=receipt.deleted_count, + protected_count=receipt.protected_count, + skipped_count=receipt.skipped_count, + bytes_reclaimable=receipt.bytes_reclaimable, + bytes_deleted=receipt.bytes_deleted, + blocked_reasons=receipt.blocked_reasons, + items=tuple(file_response(item) for item in receipt.items), + ) + + +def category_response( + category: DataLifecycleCategoryFootprint, +) -> DataLifecycleCategoryFootprintResponse: + return DataLifecycleCategoryFootprintResponse( + name=category.name.value, + root=category.root, + file_count=category.file_count, + total_bytes=category.total_bytes, + items=tuple(file_response(item) for item in category.items), + ) + + +def file_response(item: DataLifecycleFile) -> DataLifecycleFileResponse: + return DataLifecycleFileResponse( + category=item.category.value, + path=item.path, + size_bytes=item.size_bytes, + modified_at=item.modified_at, + status=item.status.value, + reason=item.reason, + ) diff --git a/src/nfi_engine/api/data_lifecycle_routes.py b/src/nfi_engine/api/data_lifecycle_routes.py new file mode 100644 index 0000000..e99c8ff --- /dev/null +++ b/src/nfi_engine/api/data_lifecycle_routes.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import TYPE_CHECKING + +from nfi_engine.api.data_lifecycle_models import ( + DataLifecycleExportResponse, + DataLifecycleFootprintResponse, + DataLifecyclePruneReceiptResponse, + DataLifecyclePruneRequest, + export_response, + footprint_response, + prune_policy, + prune_receipt_response, +) +from nfi_engine.api.state import ApiContext +from nfi_engine.maintenance.data_lifecycle import ( + build_data_lifecycle_export, + build_data_lifecycle_footprint, + build_data_lifecycle_prune_receipt, +) + +if TYPE_CHECKING: + from fastapi import APIRouter + + +def add_data_lifecycle_routes( + *, + read_router: APIRouter, + write_router: APIRouter, + context: ApiContext, +) -> None: + read_router.add_api_route("/data-lifecycle/footprint", _footprint(context), methods=["GET"]) + read_router.add_api_route("/data-lifecycle/export", _export(context), methods=["GET"]) + write_router.add_api_route("/data-lifecycle/prune", _prune(context), methods=["POST"]) + + +def _footprint(context: ApiContext) -> Callable[[], DataLifecycleFootprintResponse]: + def endpoint() -> DataLifecycleFootprintResponse: + footprint = build_data_lifecycle_footprint( + settings=context.settings, + config_path=context.config_path, + workspace_root=Path.cwd(), + ) + return footprint_response(footprint) + + return endpoint + + +def _export(context: ApiContext) -> Callable[[], DataLifecycleExportResponse]: + def endpoint() -> DataLifecycleExportResponse: + export = build_data_lifecycle_export( + settings=context.settings, + config_path=context.config_path, + workspace_root=Path.cwd(), + ) + return export_response(export) + + return endpoint + + +def _prune( + context: ApiContext, +) -> Callable[[DataLifecyclePruneRequest | None], DataLifecyclePruneReceiptResponse]: + def endpoint( + payload: DataLifecyclePruneRequest | None = None, + ) -> DataLifecyclePruneReceiptResponse: + request = payload or DataLifecyclePruneRequest() + receipt = build_data_lifecycle_prune_receipt( + settings=context.settings, + config_path=context.config_path, + workspace_root=Path.cwd(), + policy=prune_policy(request), + ) + return prune_receipt_response(receipt) + + return endpoint diff --git a/src/nfi_engine/api/errors.py b/src/nfi_engine/api/errors.py index 11c7fba..1a5acec 100644 --- a/src/nfi_engine/api/errors.py +++ b/src/nfi_engine/api/errors.py @@ -14,6 +14,16 @@ class ApiErrorCode(StrEnum): CSRF_TOKEN_INVALID = "CSRF_TOKEN_INVALID" # noqa: S105 SESSION_EXPIRED = "SESSION_EXPIRED" READONLY_ACTION_BLOCKED = "READONLY_ACTION_BLOCKED" + RUNTIME_ALREADY_PAUSED = "RUNTIME_ALREADY_PAUSED" + RUNTIME_ALREADY_STOPPED = "RUNTIME_ALREADY_STOPPED" + RUNTIME_ALREADY_RUNNING = "RUNTIME_ALREADY_RUNNING" + RUNTIME_INVALID_TRANSITION = "RUNTIME_INVALID_TRANSITION" + RUNTIME_PREFLIGHT_REQUIRED = "RUNTIME_PREFLIGHT_REQUIRED" + RUNTIME_PREFLIGHT_BLOCKED = "RUNTIME_PREFLIGHT_BLOCKED" + RUNTIME_HEALTH_REQUIRED = "RUNTIME_HEALTH_REQUIRED" + RUNTIME_HEALTH_BLOCKED = "RUNTIME_HEALTH_BLOCKED" + RUNTIME_LIVE_UNSAFE = "RUNTIME_LIVE_UNSAFE" + RUNTIME_COMMAND_INVALID = "RUNTIME_COMMAND_INVALID" TICK_PARSE_ERROR = "TICK_PARSE_ERROR" diff --git a/src/nfi_engine/api/log_redaction.py b/src/nfi_engine/api/log_redaction.py new file mode 100644 index 0000000..9594ab8 --- /dev/null +++ b/src/nfi_engine/api/log_redaction.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Protocol, Self, TypedDict + +from nfi_engine.config.models import RuntimeSettings +from nfi_engine.events import redact_text + + +class LogEntryUpdate(TypedDict): + message: str + command: str | None + route: str | None + safe_summary: str + report_hint: str + + +class RedactableLogEntry(Protocol): + message: str + command: str | None + route: str | None + safe_summary: str + report_hint: str + + def model_copy(self, *, update: LogEntryUpdate) -> Self: ... + + +def redacted_support_logs[LogEntryT: RedactableLogEntry]( + *, + settings: RuntimeSettings, + logs: tuple[LogEntryT, ...], +) -> tuple[LogEntryT, ...]: + secrets = _support_secret_values(settings) + if len(secrets) == 0: + return logs + return tuple(_redacted_log_entry(log, secrets=secrets) for log in logs) + + +def _redacted_log_entry[LogEntryT: RedactableLogEntry]( + log: LogEntryT, + *, + secrets: tuple[str, ...], +) -> LogEntryT: + return log.model_copy( + update={ + "message": redact_text(log.message, secrets=secrets), + "command": _redacted_optional(log.command, secrets=secrets), + "route": _redacted_optional(log.route, secrets=secrets), + "safe_summary": redact_text(log.safe_summary, secrets=secrets), + "report_hint": redact_text(log.report_hint, secrets=secrets), + }, + ) + + +def _redacted_optional(text: str | None, *, secrets: tuple[str, ...]) -> str | None: + if text is None: + return None + return redact_text(text, secrets=secrets) + + +def _support_secret_values(settings: RuntimeSettings) -> tuple[str, ...]: + values = ( + settings.exchange.api_key, + settings.exchange.api_secret, + settings.api.auth_token, + settings.notifications.webhook_url, + settings.notifications.discord_webhook_url, + settings.notifications.telegram_bot_token, + ) + return tuple(value for value in values if value is not None and value != "") diff --git a/src/nfi_engine/api/models.py b/src/nfi_engine/api/models.py index 2481517..c5d2ec4 100644 --- a/src/nfi_engine/api/models.py +++ b/src/nfi_engine/api/models.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict from nfi_engine import __version__ +from nfi_engine.api.log_redaction import redacted_support_logs from nfi_engine.config import FieldMetadata, LogLevel, RuntimeSettings, frontend_metadata from nfi_engine.events import REDACTED_TEXT, EventCode from nfi_engine.observability import new_correlation_id @@ -298,7 +299,7 @@ def support_bundle_response( generated_at=datetime.now(UTC), engine_version=__version__, redacted_config=config_current_response(settings), - logs=logs, + logs=redacted_support_logs(settings=settings, logs=logs), ) diff --git a/src/nfi_engine/api/routes.py b/src/nfi_engine/api/routes.py index 02e4230..c600a18 100644 --- a/src/nfi_engine/api/routes.py +++ b/src/nfi_engine/api/routes.py @@ -9,6 +9,7 @@ from nfi_engine.api.auth import OperatorIdentity from nfi_engine.api.config_routes import add_config_routes from nfi_engine.api.dashboard_routes import add_dashboard_routes +from nfi_engine.api.data_lifecycle_routes import add_data_lifecycle_routes from nfi_engine.api.log_lookup import error_lookup_response from nfi_engine.api.models import ( BackupRestoreResponse, @@ -20,7 +21,6 @@ PairHistoryResponse, PingResponse, ProfitResponse, - StateResponse, StatusResponse, StrategyItemResponse, StrategyListResponse, @@ -30,14 +30,17 @@ support_bundle_response, ) from nfi_engine.api.pairlist_routes import add_pairlist_routes +from nfi_engine.api.runtime_control_routes import add_runtime_control_routes +from nfi_engine.api.runtime_health_routes import add_runtime_health_routes from nfi_engine.api.security import SecurityContext from nfi_engine.api.security_routes import add_security_audit_route, add_security_routes from nfi_engine.api.setup_routes import add_setup_routes from nfi_engine.api.state import ApiContext from nfi_engine.api.support_bundle import support_bundle_zip +from nfi_engine.api.update_routes import add_update_routes +from nfi_engine.api.wallet_routes import add_wallet_routes from nfi_engine.config import LogLevel from nfi_engine.dashboard import summarize_dashboard_read_models -from nfi_engine.paper import BotCommand from nfi_engine.preflight.models import PreflightReport bearer_scheme = HTTPBearer(auto_error=False) @@ -79,32 +82,31 @@ def require_write(request: Request) -> None: public_router.add_api_route("/health", _health(context), methods=["GET"]) add_security_routes(public_router, security) add_security_audit_route(protected_router, security) - write_router.add_api_route( - "/start", - _state_command(context, BotCommand.START), - methods=["POST"], - ) - write_router.add_api_route( - "/pause", - _state_command(context, BotCommand.PAUSE), - methods=["POST"], - ) - write_router.add_api_route( - "/stop", - _state_command(context, BotCommand.STOP), - methods=["POST"], + add_runtime_control_routes( + read_router=protected_router, + write_router=write_router, + context=context, + readiness=readiness, ) protected_router.add_api_route("/status", _status(context), methods=["GET"]) protected_router.add_api_route("/profit", _profit(context), methods=["GET"]) protected_router.add_api_route("/trades", _trades(context), methods=["GET"]) protected_router.add_api_route("/locks", _locks, methods=["GET"]) add_dashboard_routes(protected_router, context=context, logs=logs, readiness=readiness) + add_wallet_routes(protected_router, context=context) + add_runtime_health_routes(protected_router, context=context, readiness=readiness) protected_router.add_api_route("/strategies", _strategies(context), methods=["GET"]) protected_router.add_api_route("/strategy/{name}", _strategy_detail(context), methods=["GET"]) protected_router.add_api_route("/pair_history", _pair_history, methods=["GET"]) add_setup_routes(protected_router) add_pairlist_routes(read_router=protected_router, write_router=write_router, context=context) add_config_routes(read_router=protected_router, write_router=write_router, context=context) + add_update_routes(read_router=protected_router, write_router=write_router, context=context) + add_data_lifecycle_routes( + read_router=protected_router, + write_router=write_router, + context=context, + ) write_router.add_api_route("/backup/restore", _backup_restore, methods=["POST"]) protected_router.add_api_route("/logs/recent", _logs_recent(logs), methods=["GET"]) protected_router.add_api_route("/logs/search", _logs_search(logs), methods=["GET"]) @@ -137,13 +139,6 @@ def endpoint() -> HealthResponse: return endpoint -def _state_command(context: ApiContext, command: BotCommand) -> Callable[[], StateResponse]: - def endpoint() -> StateResponse: - return StateResponse(state=context.runtime.apply(command)) - - return endpoint - - def _status(context: ApiContext) -> Callable[[], Awaitable[StatusResponse]]: async def endpoint() -> StatusResponse: read_models = await context.dashboard_store.read_models() diff --git a/src/nfi_engine/api/runtime_control_models.py b/src/nfi_engine/api/runtime_control_models.py new file mode 100644 index 0000000..a3360ce --- /dev/null +++ b/src/nfi_engine/api/runtime_control_models.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from nfi_engine.api.models import StrictApiModel +from nfi_engine.paper import BotState +from nfi_engine.runtime_control import RuntimeControlResult, new_entries_allowed + + +class RuntimeControlCommandRequest(StrictApiModel): + command: str | None = None + + +class RuntimeControlResponse(StrictApiModel): + previous_state: BotState + state: BotState + command: str | None + accepted: bool + code: str + message: str + new_entries_allowed: bool + runtime_health_state: str | None + next_action: str + live_orders_action: str + + @classmethod + def from_result(cls, result: RuntimeControlResult) -> RuntimeControlResponse: + health_state = ( + None if result.runtime_health_state is None else result.runtime_health_state.value + ) + return cls( + previous_state=result.previous_state, + state=result.state, + command=result.command.value, + accepted=result.accepted, + code=result.code.value, + message=result.message, + new_entries_allowed=result.new_entries_allowed, + runtime_health_state=health_state, + next_action=result.next_action, + live_orders_action=result.live_orders_action, + ) + + @classmethod + def from_state(cls, state: BotState) -> RuntimeControlResponse: + return cls( + previous_state=state, + state=state, + command=None, + accepted=True, + code="RUNTIME_CONTROL_STATE", + message="runtime control state snapshot", + new_entries_allowed=new_entries_allowed(state), + runtime_health_state=None, + next_action="Use start, pause, resume, or stop through protected controls.", + live_orders_action="No live exchange order cancellation is performed by this control.", + ) diff --git a/src/nfi_engine/api/runtime_control_routes.py b/src/nfi_engine/api/runtime_control_routes.py new file mode 100644 index 0000000..aa96abd --- /dev/null +++ b/src/nfi_engine/api/runtime_control_routes.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, NoReturn, assert_never + +from fastapi import HTTPException, status + +from nfi_engine.api.auth import ApiErrorResponse +from nfi_engine.api.errors import ApiErrorCode +from nfi_engine.api.runtime_control_models import ( + RuntimeControlCommandRequest, + RuntimeControlResponse, +) +from nfi_engine.api.state import ApiContext +from nfi_engine.paper import BotCommand +from nfi_engine.preflight.models import PreflightReport +from nfi_engine.runtime_control import RuntimeControlCode, RuntimeControlRequest, control_runtime +from nfi_engine.runtime_health import ( + RuntimeHealthRequest, + RuntimeHealthSnapshot, + build_runtime_health_snapshot, +) +from nfi_engine.wallet import fetch_wallet_balance + +if TYPE_CHECKING: + from fastapi import APIRouter + +_API_ERROR_CODE_MAP: Final[dict[RuntimeControlCode, ApiErrorCode]] = { + RuntimeControlCode.RUNTIME_CONTROL_ACCEPTED: ApiErrorCode.RUNTIME_INVALID_TRANSITION, + RuntimeControlCode.RUNTIME_ALREADY_PAUSED: ApiErrorCode.RUNTIME_ALREADY_PAUSED, + RuntimeControlCode.RUNTIME_ALREADY_STOPPED: ApiErrorCode.RUNTIME_ALREADY_STOPPED, + RuntimeControlCode.RUNTIME_ALREADY_RUNNING: ApiErrorCode.RUNTIME_ALREADY_RUNNING, + RuntimeControlCode.RUNTIME_INVALID_TRANSITION: ApiErrorCode.RUNTIME_INVALID_TRANSITION, + RuntimeControlCode.RUNTIME_PREFLIGHT_REQUIRED: ApiErrorCode.RUNTIME_PREFLIGHT_REQUIRED, + RuntimeControlCode.RUNTIME_PREFLIGHT_BLOCKED: ApiErrorCode.RUNTIME_PREFLIGHT_BLOCKED, + RuntimeControlCode.RUNTIME_HEALTH_REQUIRED: ApiErrorCode.RUNTIME_HEALTH_REQUIRED, + RuntimeControlCode.RUNTIME_HEALTH_BLOCKED: ApiErrorCode.RUNTIME_HEALTH_BLOCKED, + RuntimeControlCode.RUNTIME_LIVE_UNSAFE: ApiErrorCode.RUNTIME_LIVE_UNSAFE, +} +API_ERROR_CODES: Final[Mapping[RuntimeControlCode, ApiErrorCode]] = MappingProxyType( + _API_ERROR_CODE_MAP, +) + + +def add_runtime_control_routes( + *, + read_router: APIRouter, + write_router: APIRouter, + context: ApiContext, + readiness: PreflightReport, +) -> None: + read_router.add_api_route( + "/runtime/control", + _runtime_control_status(context), + methods=["GET"], + ) + write_router.add_api_route( + "/runtime/control", + _runtime_control(context, readiness), + methods=["POST"], + ) + write_router.add_api_route( + "/start", + _runtime_command(context, readiness, BotCommand.START), + methods=["POST"], + ) + write_router.add_api_route( + "/pause", + _runtime_command(context, readiness, BotCommand.PAUSE), + methods=["POST"], + ) + write_router.add_api_route( + "/resume", + _runtime_command(context, readiness, BotCommand.RESUME), + methods=["POST"], + ) + write_router.add_api_route( + "/stop", + _runtime_command(context, readiness, BotCommand.STOP), + methods=["POST"], + ) + + +def _runtime_control_status(context: ApiContext) -> Callable[[], RuntimeControlResponse]: + def endpoint() -> RuntimeControlResponse: + return RuntimeControlResponse.from_state(context.runtime.state) + + return endpoint + + +def _runtime_control( + context: ApiContext, + readiness: PreflightReport, +) -> Callable[[RuntimeControlCommandRequest | None], Awaitable[RuntimeControlResponse]]: + async def endpoint( + payload: RuntimeControlCommandRequest | None = None, + ) -> RuntimeControlResponse: + command = _parse_command(None if payload is None else payload.command) + if command is None: + _raise_command_invalid() + return await _apply_command(context=context, readiness=readiness, command=command) + + return endpoint + + +def _runtime_command( + context: ApiContext, + readiness: PreflightReport, + command: BotCommand, +) -> Callable[[], Awaitable[RuntimeControlResponse]]: + async def endpoint() -> RuntimeControlResponse: + return await _apply_command(context=context, readiness=readiness, command=command) + + return endpoint + + +async def _apply_command( + *, + context: ApiContext, + readiness: PreflightReport, + command: BotCommand, +) -> RuntimeControlResponse: + health = await _health_for_command(context=context, readiness=readiness, command=command) + result = control_runtime( + RuntimeControlRequest( + settings=context.settings, + state=context.runtime.state, + command=command, + readiness=readiness, + health=health, + ), + ) + if not result.accepted: + _raise_control_denied(result.code, result.message) + context.runtime.set_state(result.state) + return RuntimeControlResponse.from_result(result) + + +async def _health_for_command( + *, + context: ApiContext, + readiness: PreflightReport, + command: BotCommand, +) -> RuntimeHealthSnapshot | None: + match command: + case BotCommand.START | BotCommand.RESUME: + return await _runtime_health_snapshot(context=context, readiness=readiness) + case BotCommand.PAUSE | BotCommand.STOP: + return None + case unreachable: + assert_never(unreachable) + + +async def _runtime_health_snapshot( + *, + context: ApiContext, + readiness: PreflightReport, +) -> RuntimeHealthSnapshot: + read_models = await context.dashboard_store.read_models() + wallet = await fetch_wallet_balance( + settings=context.settings, + reader=context.wallet_balance_reader, + ) + return build_runtime_health_snapshot( + RuntimeHealthRequest( + settings=context.settings, + bot_state=context.runtime.state, + readiness=readiness, + read_models=read_models, + wallet_balance=wallet, + ), + ) + + +def _parse_command(raw_command: str | None) -> BotCommand | None: + if raw_command is None or raw_command == "": + return None + try: + return BotCommand(raw_command) + except ValueError: + return None + + +def _raise_command_invalid() -> NoReturn: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=ApiErrorResponse( + code=ApiErrorCode.RUNTIME_COMMAND_INVALID, + message="runtime command must be one of start, pause, resume, stop", + ).model_dump(mode="json"), + ) + + +def _raise_control_denied(code: RuntimeControlCode, message: str) -> NoReturn: + raise HTTPException( + status_code=_status_for_control_code(code), + detail=ApiErrorResponse( + code=_api_error_code(code), + message=message, + ).model_dump(mode="json"), + ) + + +def _status_for_control_code(code: RuntimeControlCode) -> int: + match code: + case RuntimeControlCode.RUNTIME_CONTROL_ACCEPTED: + return status.HTTP_200_OK + case ( + RuntimeControlCode.RUNTIME_ALREADY_PAUSED + | RuntimeControlCode.RUNTIME_ALREADY_STOPPED + | RuntimeControlCode.RUNTIME_ALREADY_RUNNING + | RuntimeControlCode.RUNTIME_INVALID_TRANSITION + | RuntimeControlCode.RUNTIME_PREFLIGHT_REQUIRED + | RuntimeControlCode.RUNTIME_PREFLIGHT_BLOCKED + | RuntimeControlCode.RUNTIME_HEALTH_REQUIRED + | RuntimeControlCode.RUNTIME_HEALTH_BLOCKED + | RuntimeControlCode.RUNTIME_LIVE_UNSAFE + ): + return status.HTTP_409_CONFLICT + case unreachable: + assert_never(unreachable) + + +def _api_error_code(code: RuntimeControlCode) -> ApiErrorCode: + return API_ERROR_CODES[code] diff --git a/src/nfi_engine/api/runtime_health_models.py b/src/nfi_engine/api/runtime_health_models.py new file mode 100644 index 0000000..f751306 --- /dev/null +++ b/src/nfi_engine/api/runtime_health_models.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from nfi_engine.api.models import StrictApiModel +from nfi_engine.api.wallet_models import WalletBalanceResponse +from nfi_engine.runtime_health import ( + RuntimeHealthCheck, + RuntimeHealthSnapshot, + RuntimeResourceSnapshot, +) +from nfi_engine.strategy.nfi_x7 import X7SemanticStatus + + +class RuntimeHealthCheckResponse(StrictApiModel): + code: str + state: str + message: str + next_action: str + + @classmethod + def from_check(cls, check: RuntimeHealthCheck) -> RuntimeHealthCheckResponse: + return cls( + code=check.code.value, + state=check.state.value, + message=check.message, + next_action=check.next_action, + ) + + +class RuntimeResourceResponse(StrictApiModel): + captured_at: str + free_disk_bytes: int + memory_rss_bytes: int + disk_state: str + memory_state: str + + @classmethod + def from_snapshot(cls, snapshot: RuntimeResourceSnapshot) -> RuntimeResourceResponse: + return cls( + captured_at=_datetime_json(snapshot.captured_at), + free_disk_bytes=snapshot.free_disk_bytes, + memory_rss_bytes=snapshot.memory_rss_bytes, + disk_state=snapshot.disk_state.value, + memory_state=snapshot.memory_state.value, + ) + + +class X7SemanticStatusResponse(StrictApiModel): + enabled: bool + coverage_state: str + observed_upstream_version: str + provenance_evidence_path: str + covered_modules: tuple[str, ...] + pending_modules: tuple[str, ...] + latest_signal_reason: str + warmup_state: str + missing_data_state: str + live_readiness: str + blocked_reason: str | None + next_action: str + + @classmethod + def from_status(cls, status: X7SemanticStatus) -> X7SemanticStatusResponse: + return cls( + enabled=status.enabled, + coverage_state=status.coverage_state.value, + observed_upstream_version=status.observed_upstream_version, + provenance_evidence_path=status.provenance_evidence_path, + covered_modules=status.covered_modules, + pending_modules=status.pending_modules, + latest_signal_reason=status.latest_signal_reason, + warmup_state=status.warmup_state, + missing_data_state=status.missing_data_state, + live_readiness=status.live_readiness.value, + blocked_reason=status.blocked_reason, + next_action=status.next_action, + ) + + +class RuntimeHealthResponse(StrictApiModel): + generated_at: str + state: str + next_action: str + checks: tuple[RuntimeHealthCheckResponse, ...] + resources: RuntimeResourceResponse + wallet_balance: WalletBalanceResponse + x7_semantic_status: X7SemanticStatusResponse + + @classmethod + def from_snapshot(cls, snapshot: RuntimeHealthSnapshot) -> RuntimeHealthResponse: + return cls( + generated_at=_datetime_json(snapshot.generated_at), + state=snapshot.state.value, + next_action=snapshot.next_action, + checks=tuple(RuntimeHealthCheckResponse.from_check(check) for check in snapshot.checks), + resources=RuntimeResourceResponse.from_snapshot(snapshot.resources), + wallet_balance=WalletBalanceResponse.from_snapshot(snapshot.wallet_balance), + x7_semantic_status=X7SemanticStatusResponse.from_status(snapshot.x7_semantic_status), + ) + + +def _datetime_json(value: datetime) -> str: + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") diff --git a/src/nfi_engine/api/runtime_health_routes.py b/src/nfi_engine/api/runtime_health_routes.py new file mode 100644 index 0000000..4192eec --- /dev/null +++ b/src/nfi_engine/api/runtime_health_routes.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING + +from nfi_engine.api.runtime_health_models import RuntimeHealthResponse +from nfi_engine.api.state import ApiContext +from nfi_engine.preflight.models import PreflightReport +from nfi_engine.runtime_health import RuntimeHealthRequest, build_runtime_health_snapshot +from nfi_engine.wallet import fetch_wallet_balance + +if TYPE_CHECKING: + from fastapi import APIRouter + + +def add_runtime_health_routes( + router: APIRouter, + *, + context: ApiContext, + readiness: PreflightReport, +) -> None: + router.add_api_route("/runtime/health", _runtime_health(context, readiness), methods=["GET"]) + + +def _runtime_health( + context: ApiContext, + readiness: PreflightReport, +) -> Callable[[], Awaitable[RuntimeHealthResponse]]: + async def endpoint() -> RuntimeHealthResponse: + read_models = await context.dashboard_store.read_models() + wallet = await fetch_wallet_balance( + settings=context.settings, + reader=context.wallet_balance_reader, + ) + snapshot = build_runtime_health_snapshot( + RuntimeHealthRequest( + settings=context.settings, + bot_state=context.runtime.state, + readiness=readiness, + read_models=read_models, + wallet_balance=wallet, + ), + ) + return RuntimeHealthResponse.from_snapshot(snapshot) + + return endpoint diff --git a/src/nfi_engine/api/settings.py b/src/nfi_engine/api/settings.py index 9402cdf..a4488b6 100644 --- a/src/nfi_engine/api/settings.py +++ b/src/nfi_engine/api/settings.py @@ -15,7 +15,7 @@ def resolve_runtime_settings(config_path: Path | None = None) -> RuntimeSettings: - selected_path = config_path or _env_config_path() + selected_path = resolve_runtime_config_path(config_path) settings = ( load_runtime_settings(selected_path) if selected_path is not None else RuntimeSettings() ) @@ -37,6 +37,10 @@ def validate_api_auth_settings(settings: RuntimeSettings) -> None: ) +def resolve_runtime_config_path(config_path: Path | None = None) -> Path | None: + return config_path or _env_config_path() + + def _env_config_path() -> Path | None: raw_path = os.environ.get(CONFIG_ENV) if raw_path is None or raw_path == "": diff --git a/src/nfi_engine/api/state.py b/src/nfi_engine/api/state.py index 91fc1a2..df337a0 100644 --- a/src/nfi_engine/api/state.py +++ b/src/nfi_engine/api/state.py @@ -1,13 +1,15 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, assert_never +from pathlib import Path +from typing import TYPE_CHECKING from nfi_engine.config import RuntimeSettings -from nfi_engine.paper import BotCommand, BotState, apply_bot_command +from nfi_engine.paper import BotState if TYPE_CHECKING: from nfi_engine.dashboard import DashboardReadStore + from nfi_engine.wallet import WalletBalanceReader @dataclass(slots=True) @@ -16,16 +18,8 @@ class ApiRuntimeState: state: BotState = BotState.STOPPED - def apply(self, command: BotCommand) -> BotState: - match command: - case BotCommand.START | BotCommand.PAUSE | BotCommand.RESUME: - self.state = apply_bot_command(self.state, command) - case BotCommand.STOP: - self.state = apply_bot_command(self.state, command) - self.state = apply_bot_command(self.state, command) - case unreachable: - assert_never(unreachable) - return self.state + def set_state(self, state: BotState) -> None: + self.state = state @dataclass(slots=True) @@ -33,3 +27,5 @@ class ApiContext: settings: RuntimeSettings runtime: ApiRuntimeState dashboard_store: DashboardReadStore + wallet_balance_reader: WalletBalanceReader | None = None + config_path: Path | None = None diff --git a/src/nfi_engine/api/support_bundle.py b/src/nfi_engine/api/support_bundle.py index df15c8e..030c63a 100644 --- a/src/nfi_engine/api/support_bundle.py +++ b/src/nfi_engine/api/support_bundle.py @@ -14,6 +14,7 @@ CONFIG_NAME: Final = "config.json" LOGS_NAME: Final = "logs.json" MANIFEST_NAME: Final = "manifest.json" +LOCAL_PROFILE_NAME: Final = "local-profile.json" class SupportBundleManifest(BaseModel): @@ -26,6 +27,18 @@ class SupportBundleManifest(BaseModel): checksums: dict[str, str] +class SupportBundleLocalProfile(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", frozen=True) + + engine_version: str + generated_at: datetime + exchange_name: str + trading_mode: str + environment: str + locale: str + read_only: bool + + @dataclass(frozen=True, slots=True) class SupportBundleMember: name: str @@ -59,6 +72,20 @@ def _bundle_members(bundle: SupportBundleResponse) -> tuple[SupportBundleMember, LOGS_NAME, LogListResponse(items=bundle.logs).model_dump_json(indent=2).encode(), ), + SupportBundleMember( + LOCAL_PROFILE_NAME, + SupportBundleLocalProfile( + engine_version=bundle.engine_version, + generated_at=bundle.generated_at, + exchange_name=bundle.redacted_config.exchange.name, + trading_mode=bundle.redacted_config.exchange.trading_mode, + environment=bundle.redacted_config.engine.environment, + locale=bundle.redacted_config.ui.locale, + read_only=bundle.redacted_config.ui.read_only, + ) + .model_dump_json(indent=2) + .encode(), + ), ) diff --git a/src/nfi_engine/api/ui.py b/src/nfi_engine/api/ui.py index 130d0ea..77a021f 100644 --- a/src/nfi_engine/api/ui.py +++ b/src/nfi_engine/api/ui.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from dataclasses import dataclass from typing import TYPE_CHECKING, Annotated from fastapi import Depends, HTTPException, Request, status @@ -12,14 +13,19 @@ from nfi_engine.api.security_store import SecuritySession from nfi_engine.config import RuntimeSettings from nfi_engine.dashboard import DashboardReadModels +from nfi_engine.paper import BotState +from nfi_engine.runtime_health import RuntimeHealthRequest, build_runtime_health_snapshot from nfi_engine.ui import ( render_home_page, render_login_page, render_logs_page, render_settings_page, ) +from nfi_engine.ui.home_context import HomeRuntimeContext +from nfi_engine.wallet import fetch_wallet_balance if TYPE_CHECKING: + from nfi_engine.api.state import ApiRuntimeState from nfi_engine.dashboard import DashboardReadStore from nfi_engine.preflight.models import PreflightReport @@ -27,12 +33,18 @@ SettingsProvider = Callable[[], RuntimeSettings] +@dataclass(frozen=True, slots=True) +class HomePageDependencies: + dashboard_store: DashboardReadStore | None = None + runtime_state: ApiRuntimeState | None = None + + def home_page( settings: SettingsProvider, logs: tuple[LogEntryResponse, ...], readiness: PreflightReport | None = None, security: SecurityContext | None = None, - dashboard_store: DashboardReadStore | None = None, + dependencies: HomePageDependencies | None = None, ) -> Callable[[Request, HTTPAuthorizationCredentials | None], Awaitable[HTMLResponse]]: async def endpoint( request: Request, @@ -51,12 +63,31 @@ async def endpoint( if login is not None: return login session = _frontend_session(security=security, request=request, credentials=credentials) + resolved_dependencies = dependencies or HomePageDependencies() + read_models = await _read_models(resolved_dependencies.dashboard_store) + wallet_balance = await fetch_wallet_balance( + settings=current_settings, + ) + runtime_health = build_runtime_health_snapshot( + RuntimeHealthRequest( + settings=current_settings, + bot_state=_bot_state(resolved_dependencies.runtime_state), + readiness=readiness, + read_models=read_models, + wallet_balance=wallet_balance, + ), + ) response = HTMLResponse( content=render_home_page( settings=current_settings, logs=logs, - read_models=await _read_models(dashboard_store), - readiness=readiness, + runtime=HomeRuntimeContext( + read_models=read_models, + readiness=readiness, + wallet_balance=wallet_balance, + runtime_health=runtime_health, + bot_state=_bot_state(resolved_dependencies.runtime_state), + ), csrf_token=_csrf_token(session), ), ) @@ -189,6 +220,12 @@ async def _read_models(store: DashboardReadStore | None) -> DashboardReadModels: return await store.read_models() +def _bot_state(runtime_state: ApiRuntimeState | None) -> BotState: + if runtime_state is None: + return BotState.STOPPED + return runtime_state.state + + def _set_frontend_cookies( *, response: HTMLResponse, diff --git a/src/nfi_engine/api/update_models.py b/src/nfi_engine/api/update_models.py new file mode 100644 index 0000000..4d5e12c --- /dev/null +++ b/src/nfi_engine/api/update_models.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from typing import ClassVar + +from pydantic import ConfigDict + +from nfi_engine.api.models import StrictApiModel +from nfi_engine.maintenance.update_provenance import ( + UPDATE_SOURCE_LOCAL_PROOF, + UpdatePreview, + UpdateProofPolicy, + UpdateProofReceipt, + UpdateRollbackState, +) + + +class UpdateRollbackStateResponse(StrictApiModel): + status: str + can_rollback: bool + backup_reference_required: bool + + +class UpdatePreviewResponse(StrictApiModel): + engine_version: str + strategy_name: str + strategy_module: str + strategy_digest: str + strategy_source: str + config_digest: str + config_source: str + dependency_lock_digest: str + dependency_lock_source: str + remote_network_allowed: bool + compatibility_status: str + provenance_verified: bool + live_blocked: bool + workspace_state: str + workspace_dirty: bool + rollback_state: UpdateRollbackStateResponse + + +class UpdateProofRequest(StrictApiModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", frozen=True, strict=True) + + backup_reference: str | None = None + acknowledge_unverified: bool = False + allow_dirty_worktree: bool = False + update_source: str = UPDATE_SOURCE_LOCAL_PROOF + + def to_policy(self) -> UpdateProofPolicy: + return UpdateProofPolicy( + backup_reference=self.backup_reference, + acknowledge_unverified=self.acknowledge_unverified, + allow_dirty_worktree=self.allow_dirty_worktree, + update_source=self.update_source, + ) + + +class UpdateProofReceiptResponse(StrictApiModel): + action: str + accepted: bool + proof_only: bool + mutation_applied: bool + source_mutated: bool + remote_network_allowed: bool + restart_required: bool + reload_required: bool + backup_reference: str | None + acknowledge_unverified: bool + allow_dirty_worktree: bool + update_source: str + provenance_verified: bool + live_blocked: bool + workspace_state: str + workspace_dirty: bool + compatibility_status: str + blocked_reasons: tuple[str, ...] + + +def update_preview_response(preview: UpdatePreview) -> UpdatePreviewResponse: + return UpdatePreviewResponse( + engine_version=preview.engine_version, + strategy_name=preview.strategy_name, + strategy_module=preview.strategy_module, + strategy_digest=preview.strategy_digest, + strategy_source=preview.strategy_source, + config_digest=preview.config_digest, + config_source=preview.config_source, + dependency_lock_digest=preview.dependency_lock_digest, + dependency_lock_source=preview.dependency_lock_source, + remote_network_allowed=preview.remote_network_allowed, + compatibility_status=preview.compatibility_status, + provenance_verified=preview.provenance_verified, + live_blocked=preview.live_blocked, + workspace_state=preview.workspace_state, + workspace_dirty=preview.workspace_dirty, + rollback_state=update_rollback_state_response(preview.rollback_state), + ) + + +def update_proof_receipt_response(receipt: UpdateProofReceipt) -> UpdateProofReceiptResponse: + return UpdateProofReceiptResponse( + action=receipt.action, + accepted=receipt.accepted, + proof_only=receipt.proof_only, + mutation_applied=receipt.mutation_applied, + source_mutated=receipt.source_mutated, + remote_network_allowed=receipt.remote_network_allowed, + restart_required=receipt.restart_required, + reload_required=receipt.reload_required, + backup_reference=receipt.backup_reference, + acknowledge_unverified=receipt.acknowledge_unverified, + allow_dirty_worktree=receipt.allow_dirty_worktree, + update_source=receipt.update_source, + provenance_verified=receipt.provenance_verified, + live_blocked=receipt.live_blocked, + workspace_state=receipt.workspace_state, + workspace_dirty=receipt.workspace_dirty, + compatibility_status=receipt.compatibility_status, + blocked_reasons=receipt.blocked_reasons, + ) + + +def update_rollback_state_response(state: UpdateRollbackState) -> UpdateRollbackStateResponse: + return UpdateRollbackStateResponse( + status=state.status, + can_rollback=state.can_rollback, + backup_reference_required=state.backup_reference_required, + ) diff --git a/src/nfi_engine/api/update_routes.py b/src/nfi_engine/api/update_routes.py new file mode 100644 index 0000000..25326ae --- /dev/null +++ b/src/nfi_engine/api/update_routes.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import TYPE_CHECKING + +from nfi_engine.api.state import ApiContext +from nfi_engine.api.update_models import ( + UpdatePreviewResponse, + UpdateProofReceiptResponse, + UpdateProofRequest, + update_preview_response, + update_proof_receipt_response, +) +from nfi_engine.maintenance.update_provenance import ( + build_update_apply_receipt, + build_update_preview, + build_update_rollback_receipt, +) + +if TYPE_CHECKING: + from fastapi import APIRouter + + +def add_update_routes( + *, + read_router: APIRouter, + write_router: APIRouter, + context: ApiContext, +) -> None: + read_router.add_api_route("/update/preview", _preview(context), methods=["GET"]) + write_router.add_api_route("/update/apply", _apply(context), methods=["POST"]) + write_router.add_api_route("/update/rollback", _rollback(context), methods=["POST"]) + + +def _preview(context: ApiContext) -> Callable[[], UpdatePreviewResponse]: + def endpoint() -> UpdatePreviewResponse: + preview = build_update_preview( + settings=context.settings, + config_path=context.config_path, + workspace_root=Path.cwd(), + ) + return update_preview_response(preview) + + return endpoint + + +def _apply( + context: ApiContext, +) -> Callable[[UpdateProofRequest | None], UpdateProofReceiptResponse]: + def endpoint(payload: UpdateProofRequest | None = None) -> UpdateProofReceiptResponse: + request = payload or UpdateProofRequest() + preview = build_update_preview( + settings=context.settings, + config_path=context.config_path, + workspace_root=Path.cwd(), + ) + receipt = build_update_apply_receipt( + preview=preview, + policy=request.to_policy(), + ) + return update_proof_receipt_response(receipt) + + return endpoint + + +def _rollback( + context: ApiContext, +) -> Callable[[UpdateProofRequest | None], UpdateProofReceiptResponse]: + def endpoint(payload: UpdateProofRequest | None = None) -> UpdateProofReceiptResponse: + request = payload or UpdateProofRequest() + preview = build_update_preview( + settings=context.settings, + config_path=context.config_path, + workspace_root=Path.cwd(), + ) + receipt = build_update_rollback_receipt( + preview=preview, + policy=request.to_policy(), + ) + return update_proof_receipt_response(receipt) + + return endpoint diff --git a/src/nfi_engine/api/validation_errors.py b/src/nfi_engine/api/validation_errors.py new file mode 100644 index 0000000..124ae65 --- /dev/null +++ b/src/nfi_engine/api/validation_errors.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from fastapi import Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict + +if TYPE_CHECKING: + from pydantic_core import ErrorDetails + + +class ValidationErrorItem(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + type: str + loc: tuple[int | str, ...] + msg: str + + +class ValidationErrorResponse(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + detail: tuple[ValidationErrorItem, ...] + + +def redacted_request_validation_error( + _request: Request, + exc: Exception, +) -> JSONResponse: + match exc: + case RequestValidationError() as validation_error: + errors: tuple[ErrorDetails, ...] = tuple(validation_error.errors()) + case unexpected: + raise unexpected + payload = ValidationErrorResponse( + detail=tuple(_validation_error_item(error) for error in errors), + ) + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + content=payload.model_dump(mode="json"), + ) + + +def _validation_error_item(error: ErrorDetails) -> ValidationErrorItem: + return ValidationErrorItem( + type=error["type"], + loc=error["loc"], + msg=error["msg"], + ) diff --git a/src/nfi_engine/api/wallet_models.py b/src/nfi_engine/api/wallet_models.py new file mode 100644 index 0000000..e3a09c2 --- /dev/null +++ b/src/nfi_engine/api/wallet_models.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +from nfi_engine.api.models import StrictApiModel +from nfi_engine.wallet import WalletBalanceSnapshot, WalletPermissionAuditSnapshot + + +class WalletPermissionAuditResponse(StrictApiModel): + read: str + trade: str + futures: str + withdrawal: str + ip_allowlist: str + live_safe: bool + live_blocking_codes: tuple[str, ...] + diagnostic_codes: tuple[str, ...] + summary: str + + @classmethod + def from_snapshot( + cls, + snapshot: WalletPermissionAuditSnapshot, + ) -> WalletPermissionAuditResponse: + return cls( + read=snapshot.read.value, + trade=snapshot.trade.value, + futures=snapshot.futures.value, + withdrawal=snapshot.withdrawal.value, + ip_allowlist=snapshot.ip_allowlist.value, + live_safe=snapshot.live_safe, + live_blocking_codes=snapshot.live_blocking_codes, + diagnostic_codes=snapshot.diagnostic_codes, + summary=snapshot.summary, + ) + + +class WalletBalanceResponse(StrictApiModel): + status: str + code: str + exchange: str + trading_mode: str + captured_at: str | None + equity: str | None + available: str | None + quote_asset: str + position_count: int + allocation_cap_pct: str + allocation_cap: str | None + configured_stake_usdt: str + configured_max_open_trades: int + configured_allocation_total: str + allocation_cap_exceeded: bool | None + permission_audit: WalletPermissionAuditResponse + next_action: str + message: str + + @classmethod + def from_snapshot(cls, snapshot: WalletBalanceSnapshot) -> WalletBalanceResponse: + return cls( + status=snapshot.status.value, + code=snapshot.code.value, + exchange=snapshot.exchange, + trading_mode=snapshot.trading_mode, + captured_at=( + None if snapshot.captured_at is None else _datetime_json(snapshot.captured_at) + ), + equity=None if snapshot.equity is None else _decimal_json(snapshot.equity), + available=None if snapshot.available is None else _decimal_json(snapshot.available), + quote_asset=snapshot.quote_asset, + position_count=snapshot.position_count, + allocation_cap_pct=_decimal_json(snapshot.allocation_cap_pct), + allocation_cap=( + None if snapshot.allocation_cap is None else _decimal_json(snapshot.allocation_cap) + ), + configured_stake_usdt=_decimal_json(snapshot.configured_stake_usdt), + configured_max_open_trades=snapshot.configured_max_open_trades, + configured_allocation_total=_decimal_json(snapshot.configured_allocation_total), + allocation_cap_exceeded=snapshot.allocation_cap_exceeded, + permission_audit=WalletPermissionAuditResponse.from_snapshot( + snapshot.permission_audit, + ), + next_action=snapshot.next_action, + message=snapshot.message, + ) + + +def _datetime_json(value: datetime) -> str: + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _decimal_json(value: Decimal) -> str: + return str(value) diff --git a/src/nfi_engine/api/wallet_routes.py b/src/nfi_engine/api/wallet_routes.py new file mode 100644 index 0000000..f96e764 --- /dev/null +++ b/src/nfi_engine/api/wallet_routes.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING + +from nfi_engine.api.state import ApiContext +from nfi_engine.api.wallet_models import WalletBalanceResponse +from nfi_engine.wallet import fetch_wallet_balance + +if TYPE_CHECKING: + from fastapi import APIRouter + + +def add_wallet_routes(router: APIRouter, *, context: ApiContext) -> None: + router.add_api_route("/wallet/balance", _wallet_balance(context), methods=["GET"]) + router.add_api_route("/wallet/balance/fetch", _wallet_balance(context), methods=["POST"]) + + +def _wallet_balance(context: ApiContext) -> Callable[[], Awaitable[WalletBalanceResponse]]: + async def endpoint() -> WalletBalanceResponse: + snapshot = await fetch_wallet_balance( + settings=context.settings, + reader=context.wallet_balance_reader, + ) + return WalletBalanceResponse.from_snapshot(snapshot) + + return endpoint diff --git a/src/nfi_engine/backtest/AGENTS.md b/src/nfi_engine/backtest/AGENTS.md new file mode 100644 index 0000000..e4bddd7 --- /dev/null +++ b/src/nfi_engine/backtest/AGENTS.md @@ -0,0 +1,48 @@ +# BACKTEST GUIDE + +## OVERVIEW + +`backtest` owns deterministic historical execution, pricing, trade lifecycle, +result metadata, serialization, validation, and summaries for research runs. + +## STRUCTURE + +```text +backtest/ +|-- runner.py # main deterministic loop +|-- execution.py # entry/exit execution decisions +|-- closing.py # stop/final close handling +|-- frames.py # strategy frame construction +|-- pricing.py # fees, slippage, price helpers +|-- metadata.py # reproducibility hashes +|-- models.py # request/result/trade models +|-- validation.py # input checks +`-- serialization.py, summary.py +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +| --- | --- | --- | +| Run behavior | `runner.py`, `execution.py`, `closing.py` | Preserve order of lifecycle decisions. | +| Reproducibility | `metadata.py`, `serialization.py` | Config, strategy, data, engine, lock hashes. | +| Pricing | `pricing.py` | Fees/slippage should stay explicit and deterministic. | +| Result shape | `models.py`, `summary.py` | Keep JSON stable for CLI/evidence consumers. | +| Tests | `tests/unit/backtest`, `tests/e2e/test_backtest_cli.py` | Add fixture-backed cases. | + +## CONVENTIONS + +- Backtests must be reproducible from config, strategy, data, engine version, and dependency lock metadata. +- No wall-clock randomness in results except an explicit generated metadata timestamp. +- Keep strategy compatibility clean-room; use local NFI-shaped fixtures, not upstream strategy code. +- Reject malformed timeranges, missing columns, invalid strategy shapes, and unsafe assumptions with typed errors. +- Use deterministic fixtures for data and strategy behavior; do not rely on network or live exchange state. +- Public or performance claims need same-machine evidence and the allowed-claim flag from benchmark policy. + +## ANTI-PATTERNS + +- Do not mutate runtime config, database state, or exchange state from backtest code. +- Do not claim full NFI X7 parity from smoke compatibility alone. +- Do not hide rejected entries, forced exits, fee/slippage assumptions, or metadata gaps. +- Do not introduce pandas into new engine internals; isolate compatibility-only needs outside core execution. +- Do not change result JSON fields without updating CLI/e2e tests and evidence consumers. diff --git a/src/nfi_engine/backtest/closing.py b/src/nfi_engine/backtest/closing.py index a4502a8..e7bf1a1 100644 --- a/src/nfi_engine/backtest/closing.py +++ b/src/nfi_engine/backtest/closing.py @@ -17,6 +17,7 @@ class CloseResult: open_trades: tuple[OpenTrade, ...] closed_trades: tuple[TradeRecord, ...] profit: Decimal + rejected_signals: int = 0 def close_stopped_trades( @@ -59,18 +60,25 @@ def close_signal_trades( remaining = open_trades closed: tuple[TradeRecord, ...] = () profit = Decimal(0) + rejected = 0 for signal in signals: if signal.signal_type is SignalType.EXIT: close_result = _close_first_side_match( open_trades=remaining, - side=signal.side, + signal=signal, candle=candle, settings=settings, ) remaining = close_result.open_trades closed += close_result.closed_trades profit += close_result.profit - return CloseResult(open_trades=remaining, closed_trades=closed, profit=profit) + rejected += close_result.rejected_signals + return CloseResult( + open_trades=remaining, + closed_trades=closed, + profit=profit, + rejected_signals=rejected, + ) def close_open_trades_at_end( @@ -99,7 +107,7 @@ def close_open_trades_at_end( def _close_first_side_match( *, open_trades: tuple[OpenTrade, ...], - side: PositionSide, + signal: StrategySignal, candle: Candle, settings: SimulationSettings, ) -> CloseResult: @@ -108,20 +116,25 @@ def _close_first_side_match( profit = Decimal(0) already_closed = False for open_trade in open_trades: - if not already_closed and open_trade.side is side: + if not already_closed and open_trade.side is signal.side: trade = _close_trade( open_trade=open_trade, candle=candle, base_exit_price=candle_close(candle), settings=settings, - exit_reason="signal", + exit_reason=signal.tag if signal.tag is not None else "signal", ) closed += (trade,) profit += trade.profit already_closed = True else: remaining += (open_trade,) - return CloseResult(open_trades=remaining, closed_trades=closed, profit=profit) + return CloseResult( + open_trades=remaining, + closed_trades=closed, + profit=profit, + rejected_signals=0 if already_closed else 1, + ) def _stoploss_hit(*, side: PositionSide, candle: Candle, stop_price: Decimal) -> bool: diff --git a/src/nfi_engine/backtest/frames.py b/src/nfi_engine/backtest/frames.py index b577b40..6a9fe28 100644 --- a/src/nfi_engine/backtest/frames.py +++ b/src/nfi_engine/backtest/frames.py @@ -4,12 +4,27 @@ from nfi_engine.data import CandleBatch from nfi_engine.domain import Candle -from nfi_engine.strategy import StrategyFrame, StrategyRow +from nfi_engine.strategy import StrategyFrame, StrategyOhlcv, StrategyRow def strategy_frame_for_cursor(*, batch: CandleBatch, visible_count: int) -> StrategyFrame: + return strategy_frame_from_rows( + rows=strategy_rows_for_batch(batch=batch), + visible_count=visible_count, + ) + + +def strategy_rows_for_batch(*, batch: CandleBatch) -> tuple[StrategyRow, ...]: + return tuple(_strategy_row(candle) for candle in batch.candles) + + +def strategy_frame_from_rows( + *, + rows: tuple[StrategyRow, ...], + visible_count: int, +) -> StrategyFrame: return StrategyFrame( - rows=tuple(_strategy_row(candle) for candle in batch.candles), + rows=rows, visible_row_count=visible_count, ) @@ -27,4 +42,14 @@ def candle_low(candle: Candle) -> Decimal: def _strategy_row(candle: Candle) -> StrategyRow: - return StrategyRow(date=candle.opened_at.isoformat(), close=candle.close) + return StrategyRow( + date=candle.opened_at.isoformat(), + close=candle.close, + ohlcv=StrategyOhlcv( + open=candle.open, + high=candle.high, + low=candle.low, + close=candle.close, + volume=candle.volume, + ), + ) diff --git a/src/nfi_engine/backtest/models.py b/src/nfi_engine/backtest/models.py index 1f67dba..80984d3 100644 --- a/src/nfi_engine/backtest/models.py +++ b/src/nfi_engine/backtest/models.py @@ -7,6 +7,7 @@ from nfi_engine.data import CandleBatch from nfi_engine.domain import PositionSide, TradingMode, TradingPair from nfi_engine.strategy import FreqtradeStrategyAdapter +from nfi_engine.strategy.timeline import StrategyTimeline @dataclass(frozen=True, slots=True) @@ -118,3 +119,4 @@ class BacktestResult: config_digest: str strategy: StrategySummary metadata: ReproducibilityMetadata + timeline: StrategyTimeline diff --git a/src/nfi_engine/backtest/runner.py b/src/nfi_engine/backtest/runner.py index c10c454..f8f94a8 100644 --- a/src/nfi_engine/backtest/runner.py +++ b/src/nfi_engine/backtest/runner.py @@ -1,12 +1,12 @@ from __future__ import annotations +import nfi_engine.backtest.frames as frames # noqa: PLR0402 from nfi_engine.backtest.closing import ( close_open_trades_at_end, close_signal_trades, close_stopped_trades, ) from nfi_engine.backtest.execution import EntryContext, open_signal_trades -from nfi_engine.backtest.frames import strategy_frame_for_cursor from nfi_engine.backtest.models import ( BacktestRequest, BacktestResult, @@ -17,7 +17,16 @@ ) from nfi_engine.backtest.summary import summarize_backtest from nfi_engine.backtest.validation import validate_request +from nfi_engine.domain import SignalType from nfi_engine.strategy import RunMode, StrategyMetadata +from nfi_engine.strategy.timeline import ( + StrategyTimelineBuilder, + StrategyTimelineStep, + TimelineSurface, + count_strategy_signals, + strategy_signal_reasons, + strategy_signal_sides, +) def run_backtest(request: BacktestRequest) -> BacktestResult: @@ -29,10 +38,12 @@ def run_backtest(request: BacktestRequest) -> BacktestResult: runmode=RunMode.BACKTEST, ) open_trades: tuple[OpenTrade, ...] = () - closed_trades: tuple[TradeRecord, ...] = () - equity_curve: tuple[EquityPoint, ...] = () + closed_trades: list[TradeRecord] = [] + equity_curve: list[EquityPoint] = [] rejected_entries = 0 realized_equity = request.settings.starting_balance + timeline = StrategyTimelineBuilder(surface=TimelineSurface.BACKTEST) + strategy_rows = frames.strategy_rows_for_batch(batch=request.candles) for index, candle in enumerate(request.candles.candles, start=1): stop_result = close_stopped_trades( open_trades=open_trades, @@ -40,9 +51,9 @@ def run_backtest(request: BacktestRequest) -> BacktestResult: settings=request.settings, ) open_trades = stop_result.open_trades - closed_trades += stop_result.closed_trades + closed_trades.extend(stop_result.closed_trades) realized_equity += stop_result.profit - frame = strategy_frame_for_cursor(batch=request.candles, visible_count=index) + frame = frames.strategy_frame_from_rows(rows=strategy_rows, visible_count=index) signals = request.adapter.analyze(frame, metadata, incremental=True) exit_result = close_signal_trades( open_trades=open_trades, @@ -51,8 +62,10 @@ def run_backtest(request: BacktestRequest) -> BacktestResult: signals=signals, ) open_trades = exit_result.open_trades - closed_trades += exit_result.closed_trades + closed_trades.extend(exit_result.closed_trades) realized_equity += exit_result.profit + open_count_before_entry = len(open_trades) + entry_signal_count = count_strategy_signals(signals, SignalType.ENTER) entry_result = open_signal_trades( open_trades=open_trades, signals=signals, @@ -65,22 +78,54 @@ def run_backtest(request: BacktestRequest) -> BacktestResult: ) open_trades = entry_result.open_trades rejected_entries += entry_result.rejected_entries - equity_curve += (EquityPoint(opened_at=candle.opened_at, equity=realized_equity),) + timeline.record( + StrategyTimelineStep( + sequence=index, + pair=request.candles.pair, + at=candle.opened_at, + indicator_runs=1, + entry_signals=entry_signal_count, + exit_signals=count_strategy_signals(signals, SignalType.EXIT), + entry_sides=strategy_signal_sides(signals, SignalType.ENTER), + exit_sides=strategy_signal_sides(signals, SignalType.EXIT), + opened_orders=len(open_trades) - open_count_before_entry, + closed_orders=len(stop_result.closed_trades) + len(exit_result.closed_trades), + rejected_actions=entry_result.rejected_entries + exit_result.rejected_signals, + blocked_actions=0, + protection_active=len(stop_result.closed_trades) > 0, + stake_amount=request.settings.stake_amount if entry_signal_count > 0 else None, + leverage=request.settings.leverage if entry_signal_count > 0 else None, + open_trade_count=len(open_trades), + entry_reasons=strategy_signal_reasons( + signals, + SignalType.ENTER, + fallback="signal", + ), + exit_reasons=strategy_signal_reasons( + signals, + SignalType.EXIT, + fallback="signal", + ), + ), + ) + equity_curve.append(EquityPoint(opened_at=candle.opened_at, equity=realized_equity)) final_result = close_open_trades_at_end( open_trades=open_trades, candle=request.candles.candles[-1], settings=request.settings, ) - closed_trades += final_result.closed_trades + closed_trades.extend(final_result.closed_trades) realized_equity += final_result.profit + trade_records = tuple(closed_trades) + equity_points = tuple(equity_curve) return BacktestResult( - trades=closed_trades, - equity_curve=equity_curve, + trades=trade_records, + equity_curve=equity_points, summary=summarize_backtest( settings=request.settings, final_balance=realized_equity, - trades=closed_trades, - equity_curve=equity_curve, + trades=trade_records, + equity_curve=equity_points, rejected_entries=rejected_entries, ), config_digest=request.config_digest, @@ -90,4 +135,5 @@ def run_backtest(request: BacktestRequest) -> BacktestResult: can_short=inspection.can_short, ), metadata=request.metadata, + timeline=timeline.freeze(), ) diff --git a/src/nfi_engine/backtest/serialization.py b/src/nfi_engine/backtest/serialization.py index 5d4e094..1a9d02b 100644 --- a/src/nfi_engine/backtest/serialization.py +++ b/src/nfi_engine/backtest/serialization.py @@ -8,6 +8,7 @@ ReproducibilityMetadata, TradeRecord, ) +from nfi_engine.strategy.timeline import TimelinePayload, timeline_to_payload class TradePayload(TypedDict): @@ -76,6 +77,7 @@ class BacktestPayload(TypedDict): config_digest: str strategy: StrategyPayload metadata: MetadataPayload + timeline: TimelinePayload def result_to_json_payload(result: BacktestResult) -> BacktestPayload: @@ -100,6 +102,7 @@ def result_to_json_payload(result: BacktestResult) -> BacktestPayload: can_short=result.strategy.can_short, ), metadata=metadata_to_payload(result.metadata), + timeline=timeline_to_payload(result.timeline), ) diff --git a/src/nfi_engine/benchmark/backtest_workload.py b/src/nfi_engine/benchmark/backtest_workload.py new file mode 100644 index 0000000..e003d81 --- /dev/null +++ b/src/nfi_engine/benchmark/backtest_workload.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import Final + +from nfi_engine.backtest import ( + BacktestRequest, + ReproducibilityMetadata, + SimulationSettings, + run_backtest, +) +from nfi_engine.data import CandleBatch +from nfi_engine.domain import Candle, Price, Quantity, TradingMode, TradingPair +from nfi_engine.strategy import FreqtradeStrategyAdapter +from nfi_engine.strategy.demo import AdapterSmokeStrategy + +BACKTEST_WORKLOAD_CANDLES: Final = 720 +BACKTEST_WORKLOAD_TIMEFRAME: Final = "5m" +ONE: Final = Decimal(1) +ONE_HUNDRED: Final = Decimal(100) +ONE_THOUSAND: Final = Decimal(1000) + + +def build_backtest_workload_request() -> BacktestRequest: + strategy = AdapterSmokeStrategy() + return BacktestRequest( + candles=_batch(), + adapter=FreqtradeStrategyAdapter.from_strategy(strategy), + settings=SimulationSettings( + trading_mode=TradingMode.SPOT, + starting_balance=ONE_THOUSAND, + stake_amount=Decimal(10), + fee_rate=Decimal(0), + slippage_rate=Decimal(0), + max_open_trades=1, + leverage=ONE, + liquidation_buffer=Decimal("0.05"), + stoploss_pct=Decimal("0.10"), + ), + config_digest="benchmark-digest", + strategy_name=type(strategy).__name__, + metadata=_metadata(), + ) + + +def run_backtest_workload(request: BacktestRequest) -> int: + return len(run_backtest(request).equity_curve) + + +def _batch() -> CandleBatch: + pair = TradingPair.parse("BTC/USDT", TradingMode.SPOT) + started_at = datetime(2026, 1, 1, tzinfo=UTC) + candles = tuple( + Candle( + pair=pair, + opened_at=started_at + timedelta(minutes=index * 5), + open=Price(ONE_HUNDRED), + high=Price(ONE_HUNDRED), + low=Price(ONE_HUNDRED), + close=Price(ONE_HUNDRED), + volume=Quantity(ONE), + ) + for index in range(BACKTEST_WORKLOAD_CANDLES) + ) + return CandleBatch(pair=pair, timeframe=BACKTEST_WORKLOAD_TIMEFRAME, candles=candles) + + +def _metadata() -> ReproducibilityMetadata: + return ReproducibilityMetadata( + config_hash="benchmark-digest", + strategy_hash="adapter-smoke-strategy", + data_hash=f"synthetic-{BACKTEST_WORKLOAD_CANDLES}-flat-candles", + engine_version="0.1.0", + git_commit=None, + dependency_lock_hash="benchmark-lock", + python_version="3.12.0", + created_at=datetime(2026, 1, 1, tzinfo=UTC), + command_args=("benchmark", "m2", "backtest-workload"), + ) diff --git a/src/nfi_engine/benchmark/measurements.py b/src/nfi_engine/benchmark/measurements.py index 9305436..0b34bb2 100644 --- a/src/nfi_engine/benchmark/measurements.py +++ b/src/nfi_engine/benchmark/measurements.py @@ -8,7 +8,13 @@ from nfi_engine.api.app import create_app from nfi_engine.api.dashboard_models import DashboardSnapshotResponse from nfi_engine.api.models import initial_log_entries +from nfi_engine.benchmark.backtest_workload import ( + BACKTEST_WORKLOAD_CANDLES, + build_backtest_workload_request, + run_backtest_workload, +) from nfi_engine.benchmark.models import BenchmarkMeasurement, MeasurementInput +from nfi_engine.benchmark.x7_measurements import x7_measurements from nfi_engine.config import Locale, RuntimeSettings, load_runtime_settings from nfi_engine.dashboard import ( DashboardReadModels, @@ -22,6 +28,7 @@ from nfi_engine.setup import RiskPreset, SetupIntent, SetupRequest, write_setup_config from nfi_engine.ui import render_home_page from nfi_engine.ui.chart import render_dashboard_chart_panel +from nfi_engine.ui.home_context import HomeRuntimeContext def m2_measurements( @@ -35,6 +42,8 @@ def m2_measurements( _dashboard_snapshot_measurement(settings=settings, config=config, samples=samples), _home_render_measurement(settings=settings, samples=samples), _chart_render_measurement(settings=settings, samples=samples), + _backtest_workload_measurement(samples=samples), + *x7_measurements(samples=samples), _install_smoke_measurement(samples=samples), ) @@ -111,8 +120,8 @@ def _home_render_measurement(*, settings: RuntimeSettings, samples: int) -> Benc render_home_page( settings=settings, logs=initial_log_entries(), - readiness=None, - ).encode(), + runtime=HomeRuntimeContext(), + ).encode("utf-8"), ), ) return _measurement( @@ -152,6 +161,25 @@ def _chart_render_measurement(*, settings: RuntimeSettings, samples: int) -> Ben ) +def _backtest_workload_measurement(*, samples: int) -> BenchmarkMeasurement: + request = build_backtest_workload_request() + duration, payload = _sample( + samples=samples, + action=lambda: run_backtest_workload(request), + ) + return _measurement( + MeasurementInput( + name="backtest_720_candle_latency", + workflow="run deterministic 720-candle backtest with clean-room smoke strategy", + samples=samples, + duration_ms=duration, + budget_ms=1_000.0, + data_label=f"synthetic-{BACKTEST_WORKLOAD_CANDLES}-5m-flat", + payload_bytes=payload, + ), + ) + + def _install_smoke_measurement(*, samples: int) -> BenchmarkMeasurement: duration, payload = _sample(samples=samples, action=_install_smoke_payload) return _measurement( diff --git a/src/nfi_engine/benchmark/x7_measurements.py b/src/nfi_engine/benchmark/x7_measurements.py new file mode 100644 index 0000000..4b2669b --- /dev/null +++ b/src/nfi_engine/benchmark/x7_measurements.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections.abc import Callable +from time import perf_counter + +from nfi_engine.benchmark.models import BenchmarkMeasurement, MeasurementInput +from nfi_engine.benchmark.x7_workloads import ( + inspect_x7_strategy_payload, + load_x7_benchmark_settings, + run_x7_backtest_workload, + run_x7_feature_graph_workload, + run_x7_paper_workload, +) + + +def x7_measurements(*, samples: int) -> tuple[BenchmarkMeasurement, ...]: + settings = load_x7_benchmark_settings() + return ( + _measurement( + MeasurementInput( + name="x7_strategy_inspect_latency", + workflow="inspect native X7 callbacks and semantic coverage ledger", + samples=samples, + duration_ms=_duration( + samples=samples, + action=lambda: inspect_x7_strategy_payload(settings), + ), + budget_ms=50.0, + data_label="examples-x7-futures-paper", + payload_bytes=None, + ), + ), + _measurement( + MeasurementInput( + name="x7_feature_graph_latency", + workflow="build native X7 feature graph from synthetic OHLCV and informatives", + samples=samples, + duration_ms=_duration(samples=samples, action=run_x7_feature_graph_workload), + budget_ms=100.0, + data_label="synthetic-x7-5m-15m-1h", + payload_bytes=None, + ), + ), + _measurement( + MeasurementInput( + name="x7_backtest_sample_latency", + workflow="run deterministic native X7 backtest sample without Freqtrade runtime", + samples=samples, + duration_ms=_duration( + samples=samples, + action=lambda: run_x7_backtest_workload(settings), + ), + budget_ms=1_000.0, + data_label="synthetic-120-x7-5m-futures", + payload_bytes=None, + ), + ), + _measurement( + MeasurementInput( + name="x7_paper_sample_latency", + workflow="run native X7 paper sample with temp SQLite and no live orders", + samples=samples, + duration_ms=_duration( + samples=samples, + action=lambda: run_x7_paper_workload(settings), + ), + budget_ms=1_000.0, + data_label="synthetic-24-x7-paper-ticks", + payload_bytes=None, + ), + ), + ) + + +def _duration(*, samples: int, action: Callable[[], int]) -> float: + durations: list[float] = [] + for _ in range(samples): + started_at = perf_counter() + action() + durations.append((perf_counter() - started_at) * 1000) + return _p95(durations) + + +def _p95(values: list[float]) -> float: + ordered = sorted(values) + index = max(0, int((len(ordered) * 0.95) - 1)) + return round(ordered[index], 3) + + +def _measurement(measurement: MeasurementInput) -> BenchmarkMeasurement: + return BenchmarkMeasurement( + name=measurement.name, + workflow=measurement.workflow, + samples=measurement.samples, + duration_ms=measurement.duration_ms, + budget_ms=measurement.budget_ms, + status="pass" if measurement.duration_ms <= measurement.budget_ms else "warn", + data_label=measurement.data_label, + payload_bytes=measurement.payload_bytes, + ) diff --git a/src/nfi_engine/benchmark/x7_workloads.py b/src/nfi_engine/benchmark/x7_workloads.py new file mode 100644 index 0000000..0ecaaa2 --- /dev/null +++ b/src/nfi_engine/benchmark/x7_workloads.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import tempfile +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from pathlib import Path +from typing import Final + +import anyio + +from nfi_engine.backtest import ( + BacktestRequest, + ReproducibilityMetadata, + SimulationSettings, + run_backtest, +) +from nfi_engine.backtest.frames import strategy_frame_for_cursor +from nfi_engine.config import RuntimeSettings, load_runtime_settings +from nfi_engine.data import CandleBatch +from nfi_engine.domain import ( + AccountSnapshot, + Candle, + Price, + Quantity, + StakeAmount, + TradingMode, + TradingPair, +) +from nfi_engine.paper import PaperRunRequest, PaperTick, run_paper +from nfi_engine.strategy import DataProviderFacade, FreqtradeStrategyAdapter, PairFrame +from nfi_engine.strategy.nfi_x7 import ( + X7FeatureGraph, + X7FeatureGraphContext, + X7FeatureGraphRequest, + X7NativeStrategy, + build_x7_semantic_status, +) + +X7_CONFIG: Final = Path("examples/x7-futures-paper.yaml") +X7_PAIR: Final = TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES) +X7_BACKTEST_SAMPLE_CANDLES: Final = 120 +X7_PAPER_SAMPLE_TICKS: Final = 24 +ONE: Final = Decimal(1) +ONE_THOUSAND: Final = Decimal(1000) + + +def load_x7_benchmark_settings() -> RuntimeSettings: + return load_runtime_settings(X7_CONFIG) + + +def inspect_x7_strategy_payload(settings: RuntimeSettings) -> int: + strategy = X7NativeStrategy() + inspection = FreqtradeStrategyAdapter.from_strategy(strategy).inspect() + status = build_x7_semantic_status(settings=settings, readiness=None) + return ( + len(inspection.detected_callbacks) + + len(status.covered_modules) + + len(status.pending_modules) + ) + + +def run_x7_feature_graph_workload() -> int: + context = _feature_graph_context() + result = X7FeatureGraph().build(context) + return len(result.frame.rows) + result.coverage.total_feature_count + + +def run_x7_backtest_workload(settings: RuntimeSettings) -> int: + request = BacktestRequest( + candles=_batch( + pair=X7_PAIR, + timeframe="5m", + candle_count=X7_BACKTEST_SAMPLE_CANDLES, + step=timedelta(minutes=5), + ), + adapter=FreqtradeStrategyAdapter.from_strategy(X7NativeStrategy()), + settings=SimulationSettings( + trading_mode=TradingMode.FUTURES, + starting_balance=ONE_THOUSAND, + stake_amount=settings.risk.stake_usdt, + fee_rate=settings.backtest.fee_rate, + slippage_rate=settings.backtest.slippage_rate, + max_open_trades=settings.backtest.max_open_trades, + leverage=settings.risk.leverage, + liquidation_buffer=settings.risk.liquidation_buffer, + stoploss_pct=settings.backtest.stoploss_pct, + ), + config_digest="x7-benchmark-digest", + strategy_name=X7NativeStrategy.__name__, + metadata=_metadata(), + ) + return len(run_backtest(request).timeline.steps) + + +def run_x7_paper_workload(settings: RuntimeSettings) -> int: + with tempfile.TemporaryDirectory(prefix="nfi-x7-paper-benchmark-") as directory: + result = anyio.run( + run_paper, + PaperRunRequest( + settings=settings, + ticks=_paper_ticks(), + max_events=X7_PAPER_SAMPLE_TICKS, + database_url=f"sqlite+aiosqlite:///{Path(directory) / 'paper.sqlite'}", + strategy_adapter=FreqtradeStrategyAdapter.from_strategy(X7NativeStrategy()), + account_snapshot=AccountSnapshot( + captured_at=datetime(2026, 1, 1, tzinfo=UTC), + equity=StakeAmount(ONE_THOUSAND), + available=StakeAmount(ONE_THOUSAND), + positions=(), + ), + ), + ) + return result.processed_events + len(result.timeline.steps) + + +def _feature_graph_context() -> X7FeatureGraphContext: + base = _batch(pair=X7_PAIR, timeframe="5m", candle_count=72, step=timedelta(minutes=5)) + informative_15m = _batch( + pair=X7_PAIR, + timeframe="15m", + candle_count=36, + step=timedelta(minutes=15), + ) + informative_1h = _batch(pair=X7_PAIR, timeframe="1h", candle_count=18, step=timedelta(hours=1)) + return X7FeatureGraphContext( + base_frame=strategy_frame_for_cursor(batch=base, visible_count=len(base.candles)), + provider=_provider( + base=base, informative_15m=informative_15m, informative_1h=informative_1h + ), + request=X7FeatureGraphRequest( + pair=X7_PAIR, + base_timeframe=base.timeframe, + informative_timeframes=(informative_15m.timeframe, informative_1h.timeframe), + ), + ) + + +def _provider( + *, + base: CandleBatch, + informative_15m: CandleBatch, + informative_1h: CandleBatch, +) -> DataProviderFacade: + return DataProviderFacade( + frames=( + PairFrame( + pair=base.pair, + timeframe=base.timeframe, + frame=strategy_frame_for_cursor(batch=base, visible_count=len(base.candles)), + ), + PairFrame( + pair=informative_15m.pair, + timeframe=informative_15m.timeframe, + frame=strategy_frame_for_cursor( + batch=informative_15m, + visible_count=len(informative_15m.candles), + ), + ), + PairFrame( + pair=informative_1h.pair, + timeframe=informative_1h.timeframe, + frame=strategy_frame_for_cursor( + batch=informative_1h, + visible_count=len(informative_1h.candles), + ), + ), + ), + ) + + +def _paper_ticks() -> tuple[PaperTick, ...]: + started_at = datetime(2026, 1, 1, tzinfo=UTC) + return tuple( + PaperTick( + pair=X7_PAIR, + at=started_at + timedelta(minutes=index), + price=Price(Decimal(100 + index)), + signal_side=None, + ) + for index in range(X7_PAPER_SAMPLE_TICKS) + ) + + +def _batch( + *, + pair: TradingPair, + timeframe: str, + candle_count: int, + step: timedelta, +) -> CandleBatch: + started_at = datetime(2026, 1, 1, tzinfo=UTC) + candles = tuple( + _candle(pair=pair, opened_at=started_at + (step * index), index=index) + for index in range(candle_count) + ) + return CandleBatch(pair=pair, timeframe=timeframe, candles=candles) + + +def _candle(*, pair: TradingPair, opened_at: datetime, index: int) -> Candle: + close = Decimal(100) + Decimal(index % 17) + (Decimal(index) / Decimal(100)) + return Candle( + pair=pair, + opened_at=opened_at, + open=Price(close - Decimal("0.40")), + high=Price(close + Decimal("1.20")), + low=Price(close - Decimal("1.10")), + close=Price(close), + volume=Quantity(ONE + Decimal(index % 5)), + ) + + +def _metadata() -> ReproducibilityMetadata: + return ReproducibilityMetadata( + config_hash="x7-benchmark-digest", + strategy_hash="x7-native-strategy", + data_hash=f"synthetic-{X7_BACKTEST_SAMPLE_CANDLES}-x7-candles", + engine_version="0.1.0", + git_commit=None, + dependency_lock_hash="benchmark-lock", + python_version="3.12.0", + created_at=datetime(2026, 1, 1, tzinfo=UTC), + command_args=("benchmark", "m2", "x7-sample"), + ) diff --git a/src/nfi_engine/cli_compat.py b/src/nfi_engine/cli_compat.py index 34ec7f6..268d2a0 100644 --- a/src/nfi_engine/cli_compat.py +++ b/src/nfi_engine/cli_compat.py @@ -21,6 +21,10 @@ def nfi_check(strategy: Annotated[str, typer.Option("--strategy")]) -> None: sys.stdout.write(f"full_x7_parity={str(result.full_x7_parity).lower()}\n") sys.stdout.write(f"upstream_sha={result.upstream_sha}\n") sys.stdout.write(f"detected_callbacks={','.join(result.detected_callbacks)}\n") + sys.stdout.write(f"supported_callbacks={','.join(result.supported_callbacks)}\n") + sys.stdout.write(f"partial_callbacks={','.join(result.partial_callbacks)}\n") + sys.stdout.write(f"excluded_callbacks={','.join(result.excluded_callbacks)}\n") + sys.stdout.write(f"excluded_surfaces={','.join(result.excluded_surfaces)}\n") sys.stdout.write(f"unsupported_surfaces={','.join(result.unsupported_surfaces)}\n") diff --git a/src/nfi_engine/cli_exchange.py b/src/nfi_engine/cli_exchange.py index ef5ccea..ebeae45 100644 --- a/src/nfi_engine/cli_exchange.py +++ b/src/nfi_engine/cli_exchange.py @@ -9,7 +9,13 @@ import anyio import typer -from nfi_engine.config import ConfigLoadError, load_runtime_settings +from nfi_engine.cli_exchange_capabilities import ( + ExchangeCapabilitiesFormat, + build_exchange_capabilities_output, + format_capability_profile, +) +from nfi_engine.cli_exchange_lifecycle import lifecycle_app +from nfi_engine.config import ConfigLoadError, RuntimeSettings, load_runtime_settings from nfi_engine.domain import ( DomainError, Leverage, @@ -17,10 +23,18 @@ PositionSide, Price, Quantity, + TradingMode, TradingPair, ) -from nfi_engine.exchange import ExchangeError, ExchangeOrder, ExchangeOrderRequest, Tick -from nfi_engine.exchange.bybit import BybitTestnetAdapter +from nfi_engine.exchange import ( + ExchangeCapabilityProfile, + ExchangeError, + ExchangeOrder, + ExchangeOrderRequest, + Tick, + get_exchange_profile, +) +from nfi_engine.exchange.discovery import parse_exchange_id from nfi_engine.exchange.simulator import DeterministicExchangeSimulator from nfi_engine.reconciliation import ( ReconciliationError, @@ -29,7 +43,10 @@ reconcile_snapshot, ) -exchange_app: Final[typer.Typer] = typer.Typer(help="Inspect exchange adapter behavior.") +exchange_app: Final[typer.Typer] = typer.Typer( + help="Inspect exchange registry and simulator behavior." +) +exchange_app.add_typer(lifecycle_app, name="lifecycle") DEFAULT_PRICE: Final = Decimal(100) DEFAULT_RECONCILE_FIXTURE: Final = Path("tests/fixtures/exchange/reconcile_match.json") @@ -70,20 +87,50 @@ def simulate_order( @exchange_app.command("check") def check_exchange( - config: Annotated[Path, typer.Option("--config", exists=True, dir_okay=False)], + config: Annotated[Path | None, typer.Option("--config", exists=True, dir_okay=False)] = None, + exchange: Annotated[str | None, typer.Option("--exchange")] = None, ) -> None: try: - settings = load_runtime_settings(config) - if settings.exchange.name == "bybit": - BybitTestnetAdapter.from_settings(settings=settings, client=None) - sys.stdout.write(f"exchange={settings.exchange.name}\n") - sys.stdout.write("live_exchange=false\n") + exchange_id, settings = _exchange_check_target(config=config, exchange=exchange) + sys.stdout.write(f"exchange={exchange_id}\n") + profile = get_exchange_profile(exchange_id) + if profile is None: + _exit_with_error( + "EXCHANGE_UNSUPPORTED", + f"unsupported exchange: {exchange_id}", + ) + sys.stdout.write(format_capability_profile(profile)) + if settings is not None: + _write_config_policy(settings=settings, profile=profile) except ConfigLoadError as exc: _exit_with_error(exc.code.value, exc.message) except ExchangeError as exc: _exit_with_error(exc.code.value, exc.message) +@exchange_app.command("capabilities") +def exchange_capabilities( + exchange: Annotated[str, typer.Option("--exchange")], + trading_mode: Annotated[ + TradingMode, + typer.Option("--trading-mode"), + ] = TradingMode.SPOT, + output_format: Annotated[ + ExchangeCapabilitiesFormat, + typer.Option("--format"), + ] = ExchangeCapabilitiesFormat.TEXT, +) -> None: + try: + output = build_exchange_capabilities_output( + exchange=exchange, + trading_mode=trading_mode, + output_format=output_format, + ) + except ExchangeError as exc: + _exit_with_error(exc.code.value, exc.message) + sys.stdout.write(output) + + @exchange_app.command("reconcile") def reconcile_exchange( config: Annotated[Path, typer.Option("--config", exists=True, dir_okay=False)], @@ -123,6 +170,55 @@ def _write_reconciliation_report(report: ReconciliationReport) -> None: ) +def _exchange_check_target( + *, + config: Path | None, + exchange: str | None, +) -> tuple[str, RuntimeSettings | None]: + if config is None and exchange is None: + _exit_with_error("EXCHANGE_CHECK_TARGET_REQUIRED", "pass --config or --exchange") + if config is not None and exchange is not None: + _exit_with_error( + "EXCHANGE_CHECK_TARGET_AMBIGUOUS", "pass only one of --config or --exchange" + ) + if exchange is not None: + return parse_exchange_id(exchange), None + if config is None: + _exit_with_error("EXCHANGE_CHECK_TARGET_REQUIRED", "pass --config or --exchange") + settings = load_runtime_settings(config) + return settings.exchange.name, settings + + +def _write_config_policy( + *, + settings: RuntimeSettings, + profile: ExchangeCapabilityProfile, +) -> None: + block_reason = _exchange_config_block_reason(settings=settings, profile=profile) + sys.stdout.write(f"config_live_trading={str(settings.engine.live_trading).lower()}\n") + sys.stdout.write(f"config_testnet={str(settings.exchange.testnet).lower()}\n") + if block_reason is None: + sys.stdout.write("policy_status=pass\n") + return + sys.stdout.write("policy_status=block\n") + sys.stdout.write(f"policy_block={block_reason}\n") + raise typer.Exit(code=1) + + +def _exchange_config_block_reason( + *, + settings: RuntimeSettings, + profile: ExchangeCapabilityProfile, +) -> str | None: + if settings.engine.live_trading: + return "live_trading is blocked in current milestone" + if profile.exchange_id != "simulator" and not settings.exchange.testnet: + return f"{profile.exchange_id} requires testnet=true in current milestone" + if settings.exchange.testnet and not profile.supports_testnet: + return f"{profile.exchange_id} has no registry-backed testnet support" + return None + + def _parse_side(raw: str) -> PositionSide: try: return PositionSide(raw.lower()) diff --git a/src/nfi_engine/cli_exchange_capabilities.py b/src/nfi_engine/cli_exchange_capabilities.py new file mode 100644 index 0000000..b25d643 --- /dev/null +++ b/src/nfi_engine/cli_exchange_capabilities.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +from enum import StrEnum, unique +from typing import assert_never + +from nfi_engine.domain import TradingMode +from nfi_engine.exchange import ( + ExchangeCapabilityProfile, + ExchangeCapabilityReport, + build_exchange_capability_report, +) + + +@unique +class ExchangeCapabilitiesFormat(StrEnum): + TEXT = "text" + JSON = "json" + + +def build_exchange_capabilities_output( + *, + exchange: str, + trading_mode: TradingMode, + output_format: ExchangeCapabilitiesFormat, +) -> str: + report = build_exchange_capability_report( + exchange_id=exchange, + trading_mode=trading_mode, + ) + match output_format: + case ExchangeCapabilitiesFormat.TEXT: + return _format_capability_document(report) + case ExchangeCapabilitiesFormat.JSON: + return json.dumps(report.to_payload(), indent=2, sort_keys=True) + "\n" + case unreachable: + assert_never(unreachable) + + +def _format_capability_document(report: ExchangeCapabilityReport) -> str: + return ( + f"exchange={report.profile.exchange_id}\n" + f"requested_exchange={report.requested_exchange}\n" + f"{format_capability_profile(report.profile)}" + f"source={report.source.value}\n" + f"trading_mode={report.trading_mode.value}\n" + f"trading_mode_supported={str(report.trading_mode_supported).lower()}\n" + f"can_configure={str(report.can_configure).lower()}\n" + f"live_trading_allowed={str(report.live_trading_allowed).lower()}\n" + f"policy_block={report.policy_block}\n" + f"credential_fields={','.join(report.profile.credential_fields)}\n" + f"evidence={report.profile.evidence}\n" + f"checked_on={report.profile.checked_on.isoformat()}\n" + ) + + +def format_capability_profile(profile: ExchangeCapabilityProfile) -> str: + return ( + f"display_name={profile.display_name}\n" + f"support_level={profile.support_level.value}\n" + f"supports_spot={str(profile.supports_spot).lower()}\n" + f"supports_futures={str(profile.supports_futures).lower()}\n" + f"supports_testnet={str(profile.supports_testnet).lower()}\n" + f"supports_sandbox={str(profile.supports_sandbox).lower()}\n" + f"supports_trailing_stop={str(profile.supports_trailing_stop).lower()}\n" + f"supports_data_only={str(profile.supports_data_only).lower()}\n" + f"supports_market_orders={str(profile.supports_market_orders).lower()}\n" + "live_exchange=false\n" + ) diff --git a/src/nfi_engine/cli_exchange_lifecycle.py b/src/nfi_engine/cli_exchange_lifecycle.py new file mode 100644 index 0000000..7461113 --- /dev/null +++ b/src/nfi_engine/cli_exchange_lifecycle.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import sys +from datetime import UTC, datetime +from decimal import Decimal +from pathlib import Path +from typing import Annotated, ClassVar, Final, NoReturn + +import anyio +import typer +from pydantic import BaseModel, ConfigDict + +from nfi_engine.config import ConfigLoadError, RuntimeSettings, load_runtime_settings +from nfi_engine.domain import ( + ExecutionReport, + Leverage, + LiquidationBuffer, + OrderId, + OrderState, + OrderType, + Position, + PositionSide, + Price, + Quantity, + TradeId, + TradeState, + TradingMode, + TradingPair, +) +from nfi_engine.exchange import ExchangeError, ExchangeOrderRequest, Tick +from nfi_engine.exchange.simulator import DeterministicExchangeSimulator +from nfi_engine.orders import apply_execution_report +from nfi_engine.preflight import PreflightReport, PreflightStatus +from nfi_engine.preflight.service import run_preflight + +lifecycle_app: Final[typer.Typer] = typer.Typer( + help="Run safe exchange order lifecycle smoke checks." +) +NOW: Final = datetime(2026, 1, 1, tzinfo=UTC) +DEFAULT_LIFECYCLE_PAIR: Final = "BTC/USDT:USDT" +DEFAULT_PROFILE: Final = "bybit-testnet" + + +class LifecycleOperationPayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + name: str + state: str + + +class LifecycleSmokePayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + exchange: str + trading_mode: str + testnet: bool + live_exchange: bool + preflight_blocked: bool + deterministic_order_id: str + operations: tuple[LifecycleOperationPayload, ...] + funding_supported: bool + leverage: str + + +@lifecycle_app.command("smoke") +def lifecycle_smoke( + config: Annotated[Path, typer.Option("--config", exists=True, dir_okay=False)], + json_output: Annotated[bool, typer.Option("--json/--text")] = False, +) -> None: + try: + settings = load_runtime_settings(config) + payload = anyio.run(_build_lifecycle_smoke, settings, config) + except ConfigLoadError as exc: + _exit_with_error(exc.code.value, exc.message) + except ExchangeError as exc: + _exit_with_error(exc.code.value, exc.message) + if json_output: + sys.stdout.write(payload.model_dump_json(indent=2) + "\n") + return + _write_text(payload) + + +async def _build_lifecycle_smoke( + settings: RuntimeSettings, + config: Path, +) -> LifecycleSmokePayload: + preflight = run_preflight(settings=settings, profile_name=DEFAULT_PROFILE, config_path=config) + if preflight.blocked: + _exit_with_preflight_block(preflight) + pair = _lifecycle_pair(settings) + simulator = DeterministicExchangeSimulator( + ticks=(Tick(pair=pair, price=Price(Decimal(100)), at=NOW, funding_rate=Decimal("0.0001")),), + ) + request = ExchangeOrderRequest( + pair=pair, + side=PositionSide.LONG, + order_type=OrderType.LIMIT, + quantity=Quantity(Decimal("0.25")), + price=Price(Decimal(99)), + leverage=Leverage.parse("3"), + ) + created = await simulator.create_order(request) + fetched = await simulator.fetch_order(created.order_id, pair) + canceled = await simulator.cancel_order(created.order_id, pair) + leverage = await simulator.set_leverage(pair=pair, leverage=Leverage.parse("3")) + funding = await simulator.fetch_funding_rate(pair) + partial = _position_update_state(OrderState.PARTIALLY_FILLED) + rejected = _position_update_state(OrderState.REJECTED) + return LifecycleSmokePayload( + exchange=settings.exchange.name, + trading_mode=settings.exchange.trading_mode.value, + testnet=settings.exchange.testnet, + live_exchange=created.live_exchange or fetched.live_exchange or canceled.live_exchange, + preflight_blocked=preflight.blocked, + deterministic_order_id=created.order_id, + operations=( + LifecycleOperationPayload(name="create_order", state=created.state.value), + LifecycleOperationPayload(name="fetch_order", state=fetched.state.value), + LifecycleOperationPayload(name="cancel_order", state=canceled.state.value), + LifecycleOperationPayload(name="partial_fill_report", state=partial.value), + LifecycleOperationPayload(name="rejected_report", state=rejected.value), + ), + funding_supported=funding.supported, + leverage=str(leverage.value), + ) + + +def _lifecycle_pair(settings: RuntimeSettings) -> TradingPair: + return TradingPair.parse(DEFAULT_LIFECYCLE_PAIR, settings.exchange.trading_mode) + + +def _position_update_state(state: OrderState) -> OrderState: + update = apply_execution_report( + position=Position( + trade_id=TradeId("lifecycle-position"), + pair=TradingPair.parse(DEFAULT_LIFECYCLE_PAIR, TradingMode.FUTURES), + side=PositionSide.LONG, + quantity=Quantity(Decimal("0.25")), + entry_price=Price(Decimal(100)), + leverage=Leverage.parse("3"), + liquidation_buffer=LiquidationBuffer.parse("0.05"), + state=TradeState.OPEN, + ), + report=ExecutionReport( + order_id=OrderId(f"lifecycle-{state.value}"), + state=state, + filled_quantity=Quantity(Decimal("0.10")), + average_price=Price(Decimal(100)), + reason=None, + ), + ) + return update.order_state + + +def _exit_with_preflight_block(report: PreflightReport) -> NoReturn: + blockers = tuple( + check.code.value for check in report.checks if check.status is PreflightStatus.BLOCK + ) + message = ",".join(blockers) if blockers else "preflight blocked" + _exit_with_error("EXCHANGE_LIFECYCLE_PREFLIGHT_BLOCKED", message) + + +def _write_text(payload: LifecycleSmokePayload) -> None: + sys.stdout.write(f"exchange={payload.exchange}\n") + sys.stdout.write(f"trading_mode={payload.trading_mode}\n") + sys.stdout.write(f"testnet={str(payload.testnet).lower()}\n") + sys.stdout.write(f"live_exchange={str(payload.live_exchange).lower()}\n") + sys.stdout.write(f"order_id={payload.deterministic_order_id}\n") + for operation in payload.operations: + sys.stdout.write(f"operation={operation.name}\tstate={operation.state}\n") + + +def _exit_with_error(code: str, message: str) -> NoReturn: + sys.stderr.write(f"{code}: {message}\n") + raise typer.Exit(code=1) diff --git a/src/nfi_engine/cli_paper.py b/src/nfi_engine/cli_paper.py index 069c2f2..bb233c3 100644 --- a/src/nfi_engine/cli_paper.py +++ b/src/nfi_engine/cli_paper.py @@ -1,19 +1,28 @@ from __future__ import annotations +import json import sys import tempfile from datetime import UTC, datetime from pathlib import Path -from typing import Annotated, NoReturn +from typing import Annotated, Final, NoReturn import anyio import typer -from nfi_engine.config import ConfigLoadError, load_runtime_settings +from nfi_engine.config import ConfigLoadError, RuntimeSettings, load_runtime_settings from nfi_engine.events import EventCode, EventSeverity, JsonlEventSink, TradingEvent from nfi_engine.observability import new_correlation_id from nfi_engine.paper import PaperError, PaperRunRequest, load_paper_ticks, run_paper from nfi_engine.safety import SafetyError +from nfi_engine.strategy import ( + FreqtradeStrategyAdapter, + StrategyContractError, + load_freqtrade_strategy, +) +from nfi_engine.strategy.timeline import StrategyTimeline, timeline_to_payload + +DEMO_STRATEGY_MODULE: Final = "nfi_engine.strategy.demo:AdapterSmokeStrategy" def paper_run( @@ -21,6 +30,10 @@ def paper_run( ticks: Annotated[Path, typer.Option("--ticks", exists=True, dir_okay=False)], max_events: Annotated[int, typer.Option("--max-events", min=1)], events: Annotated[Path | None, typer.Option("--events", dir_okay=False)] = None, + timeline_output: Annotated[ + Path | None, + typer.Option("--timeline-output", dir_okay=False), + ] = None, ) -> None: try: settings = load_runtime_settings(config) @@ -34,16 +47,21 @@ def paper_run( ticks=paper_ticks, max_events=max_events, database_url=database_url, + strategy_adapter=_paper_strategy_adapter(settings), ), ) except ConfigLoadError as exc: _exit_with_error(exc.code.value, exc.message) except SafetyError as exc: _exit_with_error(exc.code.value, exc.message) + except StrategyContractError as exc: + _exit_with_error(exc.code.value, exc.message) except PaperError as exc: _exit_with_error(exc.code.value, exc.message) if events is not None: _write_paper_events(events, processed_events=result.processed_events) + if timeline_output is not None: + _write_timeline(timeline_output, result.timeline) sys.stdout.write(f"processed_events={result.processed_events}\n") sys.stdout.write(f"created_trades={result.created_trades}\n") sys.stdout.write(f"live_orders={str(result.live_orders).lower()}\n") @@ -85,3 +103,17 @@ def _write_paper_events(path: Path, *, processed_events: int) -> None: ), ), ) + + +def _write_timeline(path: Path, timeline: StrategyTimeline) -> None: + path.write_text( + json.dumps(timeline_to_payload(timeline), indent=2, sort_keys=True), + encoding="utf-8", + ) + + +def _paper_strategy_adapter(settings: RuntimeSettings) -> FreqtradeStrategyAdapter | None: + if settings.strategy.module == DEMO_STRATEGY_MODULE: + return None + strategy = load_freqtrade_strategy(settings.strategy.module) + return FreqtradeStrategyAdapter.from_strategy(strategy) diff --git a/src/nfi_engine/cli_preflight.py b/src/nfi_engine/cli_preflight.py index 22292a7..4406081 100644 --- a/src/nfi_engine/cli_preflight.py +++ b/src/nfi_engine/cli_preflight.py @@ -6,7 +6,8 @@ import typer -from nfi_engine.preflight import PreflightReport, run_preflight_for_config +from nfi_engine.preflight import PreflightReport +from nfi_engine.preflight.service import run_preflight_for_config preflight_app: Final[typer.Typer] = typer.Typer(help="Run operator readiness checks.") diff --git a/src/nfi_engine/cli_sandbox.py b/src/nfi_engine/cli_sandbox.py index 8e2785d..4ae9e24 100644 --- a/src/nfi_engine/cli_sandbox.py +++ b/src/nfi_engine/cli_sandbox.py @@ -1,10 +1,12 @@ from __future__ import annotations import sys +from pathlib import Path from typing import Annotated, Final, NoReturn import typer +from nfi_engine.compat import run_nfi_compatibility_check from nfi_engine.sandbox import SandboxCheckResult, SandboxError, SandboxErrorCode from nfi_engine.sandbox.service import check_strategy_sandbox from nfi_engine.strategy import StrategyContractError @@ -13,18 +15,30 @@ @sandbox_app.command("check") -def check(strategy: Annotated[str, typer.Option("--strategy")]) -> None: +def check( + strategy: Annotated[str, typer.Option("--strategy")], + output: Annotated[Path | None, typer.Option("--output", dir_okay=False)] = None, +) -> None: try: result = check_strategy_sandbox(strategy) + compatibility = run_nfi_compatibility_check(strategy) except SandboxError as exc: _exit_with_error(exc.code.value, exc.message) except StrategyContractError as exc: _exit_with_error(exc.code.value, exc.message) if not result.passed: _exit_with_violation(result) + if output is not None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + compatibility.to_report().model_dump_json(indent=2) + "\n", + encoding="utf-8", + ) sys.stdout.write("sandbox_passed=true\n") sys.stdout.write(f"approved_capabilities={','.join(result.approved_capabilities)}\n") sys.stdout.write(f"detected_callbacks={','.join(result.detected_callbacks)}\n") + if output is not None: + sys.stdout.write(f"compatibility_report={output}\n") def _exit_with_violation(result: SandboxCheckResult) -> NoReturn: diff --git a/src/nfi_engine/cli_strategy.py b/src/nfi_engine/cli_strategy.py index 3d17239..6888004 100644 --- a/src/nfi_engine/cli_strategy.py +++ b/src/nfi_engine/cli_strategy.py @@ -1,34 +1,104 @@ from __future__ import annotations import sys +from dataclasses import dataclass from pathlib import Path -from typing import Annotated, Final, NoReturn +from typing import Annotated, ClassVar, Final, NoReturn import typer +from pydantic import BaseModel, ConfigDict -from nfi_engine.config import ConfigLoadError, load_runtime_settings +from nfi_engine.config import ConfigLoadError, RuntimeSettings, load_runtime_settings from nfi_engine.strategy import ( FreqtradeStrategyAdapter, StrategyContractError, load_freqtrade_strategy, ) +from nfi_engine.strategy.nfi_x7 import ( + X7CoverageReport, + X7NativeStrategy, + X7SemanticStatus, + build_x7_coverage_report, + build_x7_semantic_status, +) strategy_app: Final[typer.Typer] = typer.Typer(help="Inspect strategy adapter contracts.") +class CoverageModulePayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + name: str + status: str + evidence_path: str + blocker: str | None + + +class CoverageReportPayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + covered_modules: tuple[str, ...] + pending_modules: tuple[str, ...] + is_full_semantic_coverage: bool + modules: tuple[CoverageModulePayload, ...] + + +class X7SemanticStatusPayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + enabled: bool + coverage_state: str + observed_upstream_version: str + provenance_evidence_path: str + covered_modules: tuple[str, ...] + pending_modules: tuple[str, ...] + latest_signal_reason: str + warmup_state: str + missing_data_state: str + live_readiness: str + blocked_reason: str | None + next_action: str + + +class StrategyInspectPayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + strategy_name: str + can_short: bool + timeframe: str + callbacks: tuple[str, ...] + semantic_coverage: CoverageReportPayload | None + x7_semantic_status: X7SemanticStatusPayload | None + + @strategy_app.command("inspect") def inspect_strategy( strategy: Annotated[str, typer.Option("--strategy")], config: Annotated[Path, typer.Option("--config", exists=True, dir_okay=False)], + json_output: Annotated[bool, typer.Option("--json")] = False, ) -> None: try: - load_runtime_settings(config) + settings = load_runtime_settings(config) loaded_strategy = load_freqtrade_strategy(strategy) inspection = FreqtradeStrategyAdapter.from_strategy(loaded_strategy).inspect() except ConfigLoadError as exc: _exit_with_config_error(exc) except StrategyContractError as exc: _exit_with_strategy_error(exc) + if json_output: + payload = _inspect_payload( + _InspectionPayloadInput( + strategy=loaded_strategy, + strategy_name=inspection.name, + can_short=inspection.can_short, + timeframe=inspection.timeframe, + callbacks=inspection.detected_callbacks, + settings=settings, + ), + ) + sys.stdout.write(payload.model_dump_json(indent=2)) + sys.stdout.write("\n") + return sys.stdout.write( "\n".join( ( @@ -42,6 +112,90 @@ def inspect_strategy( sys.stdout.write("\n") +@dataclass(frozen=True, slots=True) +class _InspectionPayloadInput: + strategy: object + strategy_name: str + can_short: bool + timeframe: str + callbacks: tuple[str, ...] + settings: RuntimeSettings + + +def _inspect_payload(payload_input: _InspectionPayloadInput) -> StrategyInspectPayload: + coverage_report = _x7_coverage_report(payload_input.strategy) + return StrategyInspectPayload( + strategy_name=payload_input.strategy_name, + can_short=payload_input.can_short, + timeframe=payload_input.timeframe, + callbacks=payload_input.callbacks, + semantic_coverage=_coverage_payload(coverage_report) + if coverage_report is not None + else None, + x7_semantic_status=_x7_status_payload( + strategy=payload_input.strategy, + settings=payload_input.settings, + coverage_report=coverage_report, + ), + ) + + +def _x7_coverage_report(strategy: object) -> X7CoverageReport | None: + if not isinstance(strategy, X7NativeStrategy): + return None + return build_x7_coverage_report() + + +def _coverage_payload(report: X7CoverageReport) -> CoverageReportPayload: + return CoverageReportPayload( + covered_modules=report.covered_modules, + pending_modules=report.pending_modules, + is_full_semantic_coverage=report.is_full_semantic_coverage, + modules=tuple( + CoverageModulePayload( + name=module.name, + status=module.status.value, + evidence_path=module.evidence_path, + blocker=module.blocker, + ) + for module in report.modules + ), + ) + + +def _x7_status_payload( + *, + strategy: object, + settings: RuntimeSettings, + coverage_report: X7CoverageReport | None, +) -> X7SemanticStatusPayload | None: + if not isinstance(strategy, X7NativeStrategy) or coverage_report is None: + return None + status = build_x7_semantic_status( + settings=settings, + readiness=None, + coverage_report=coverage_report, + ) + return _status_payload(status) + + +def _status_payload(status: X7SemanticStatus) -> X7SemanticStatusPayload: + return X7SemanticStatusPayload( + enabled=status.enabled, + coverage_state=status.coverage_state.value, + observed_upstream_version=status.observed_upstream_version, + provenance_evidence_path=status.provenance_evidence_path, + covered_modules=status.covered_modules, + pending_modules=status.pending_modules, + latest_signal_reason=status.latest_signal_reason, + warmup_state=status.warmup_state, + missing_data_state=status.missing_data_state, + live_readiness=status.live_readiness.value, + blocked_reason=status.blocked_reason, + next_action=status.next_action, + ) + + def _format_callbacks(callbacks: tuple[str, ...]) -> str: if len(callbacks) == 0: return "none" diff --git a/src/nfi_engine/compat/__init__.py b/src/nfi_engine/compat/__init__.py index 2b20ba5..27e7720 100644 --- a/src/nfi_engine/compat/__init__.py +++ b/src/nfi_engine/compat/__init__.py @@ -1,9 +1,16 @@ from __future__ import annotations from nfi_engine.compat.metadata import NfiMetadata, load_nfi_metadata -from nfi_engine.compat.service import NfiCompatibilityResult, run_nfi_compatibility_check +from nfi_engine.compat.service import ( + CallbackCompatibility, + NfiCompatibilityReport, + NfiCompatibilityResult, + run_nfi_compatibility_check, +) __all__ = [ + "CallbackCompatibility", + "NfiCompatibilityReport", "NfiCompatibilityResult", "NfiMetadata", "load_nfi_metadata", diff --git a/src/nfi_engine/compat/service.py b/src/nfi_engine/compat/service.py index 066f34c..d601e33 100644 --- a/src/nfi_engine/compat/service.py +++ b/src/nfi_engine/compat/service.py @@ -1,10 +1,17 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final +from typing import ClassVar, Final + +from pydantic import BaseModel, ConfigDict from nfi_engine.compat.metadata import load_nfi_metadata -from nfi_engine.strategy import FreqtradeStrategyAdapter, load_freqtrade_strategy +from nfi_engine.strategy import ( + CallbackSupportLevel, + FreqtradeStrategyAdapter, + StrategyCallbackSupport, + load_freqtrade_strategy, +) REQUIRED_CALLBACKS: Final = frozenset( ( @@ -20,13 +27,62 @@ ) +class CallbackCompatibility(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + name: str + level: CallbackSupportLevel + detected: bool + reason: str + + +class NfiCompatibilityReport(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + strategy_name: str + compatible: bool + full_x7_parity: bool + upstream_sha: str + detected_callbacks: tuple[str, ...] + supported_callbacks: tuple[str, ...] + partial_callbacks: tuple[str, ...] + excluded_callbacks: tuple[str, ...] + excluded_surfaces: tuple[str, ...] + unsupported_surfaces: tuple[str, ...] + callback_support: tuple[CallbackCompatibility, ...] + clean_room: bool + + @dataclass(frozen=True, slots=True) class NfiCompatibilityResult: + strategy_name: str compatible: bool full_x7_parity: bool upstream_sha: str detected_callbacks: tuple[str, ...] + supported_callbacks: tuple[str, ...] + partial_callbacks: tuple[str, ...] + excluded_callbacks: tuple[str, ...] + excluded_surfaces: tuple[str, ...] unsupported_surfaces: tuple[str, ...] + callback_support: tuple[StrategyCallbackSupport, ...] + clean_room: bool + + def to_report(self) -> NfiCompatibilityReport: + return NfiCompatibilityReport( + strategy_name=self.strategy_name, + compatible=self.compatible, + full_x7_parity=self.full_x7_parity, + upstream_sha=self.upstream_sha, + detected_callbacks=self.detected_callbacks, + supported_callbacks=self.supported_callbacks, + partial_callbacks=self.partial_callbacks, + excluded_callbacks=self.excluded_callbacks, + excluded_surfaces=self.excluded_surfaces, + unsupported_surfaces=self.unsupported_surfaces, + callback_support=_report_callbacks(self.callback_support), + clean_room=self.clean_room, + ) def run_nfi_compatibility_check(strategy_spec: str) -> NfiCompatibilityResult: @@ -34,10 +90,57 @@ def run_nfi_compatibility_check(strategy_spec: str) -> NfiCompatibilityResult: strategy = load_freqtrade_strategy(strategy_spec) inspection = FreqtradeStrategyAdapter.from_strategy(strategy).inspect() detected = frozenset(inspection.detected_callbacks) + excluded_callbacks = _callback_names_by_level( + inspection.callback_support, + CallbackSupportLevel.EXCLUDED, + detected_only=True, + ) return NfiCompatibilityResult( - compatible=REQUIRED_CALLBACKS.issubset(detected), + strategy_name=inspection.name, + compatible=REQUIRED_CALLBACKS.issubset(detected) and len(excluded_callbacks) == 0, full_x7_parity=metadata.full_x7_parity, upstream_sha=metadata.upstream_sha, detected_callbacks=inspection.detected_callbacks, + supported_callbacks=_callback_names_by_level( + inspection.callback_support, + CallbackSupportLevel.SUPPORTED, + detected_only=True, + ), + partial_callbacks=_callback_names_by_level( + inspection.callback_support, + CallbackSupportLevel.PARTIAL, + detected_only=True, + ), + excluded_callbacks=excluded_callbacks, + excluded_surfaces=UNSUPPORTED_SURFACES, unsupported_surfaces=UNSUPPORTED_SURFACES, + callback_support=inspection.callback_support, + clean_room=True, + ) + + +def _callback_names_by_level( + support: tuple[StrategyCallbackSupport, ...], + level: CallbackSupportLevel, + *, + detected_only: bool, +) -> tuple[str, ...]: + return tuple( + item.name + for item in support + if item.level is level and (item.detected or not detected_only) + ) + + +def _report_callbacks( + support: tuple[StrategyCallbackSupport, ...], +) -> tuple[CallbackCompatibility, ...]: + return tuple( + CallbackCompatibility( + name=item.name, + level=item.level, + detected=item.detected, + reason=item.reason, + ) + for item in support ) diff --git a/src/nfi_engine/config/__init__.py b/src/nfi_engine/config/__init__.py index 0475eda..7348950 100644 --- a/src/nfi_engine/config/__init__.py +++ b/src/nfi_engine/config/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from nfi_engine.config.enums import ConfigErrorCode, Locale, LogLevel +from nfi_engine.config.enums import ConfigErrorCode, Locale, LogLevel, RiskProfileName from nfi_engine.config.errors import ConfigLoadError from nfi_engine.config.loader import load_runtime_settings, validate_runtime_settings from nfi_engine.config.metadata import ( @@ -25,6 +25,7 @@ StrategySettings, UiSettings, ) +from nfi_engine.exchange.permissions import ExchangeApiPermissionState __all__ = [ "ApiSettings", @@ -34,6 +35,7 @@ "ConfigLoadError", "DatabaseSettings", "EngineSettings", + "ExchangeApiPermissionState", "ExchangeSettings", "FieldGroup", "FieldMetadata", @@ -43,6 +45,7 @@ "NotificationSettings", "PaperRunSettings", "PluginSettings", + "RiskProfileName", "RiskSettings", "RuntimeSettings", "StrategySettings", diff --git a/src/nfi_engine/config/enums.py b/src/nfi_engine/config/enums.py index cf5bf1e..da17b6c 100644 --- a/src/nfi_engine/config/enums.py +++ b/src/nfi_engine/config/enums.py @@ -7,6 +7,9 @@ class ConfigErrorCode(StrEnum): CONFIG_FILE_NOT_FOUND = "CONFIG_FILE_NOT_FOUND" CONFIG_VALIDATION_FAILED = "CONFIG_VALIDATION_FAILED" + EXCHANGE_MARGIN_MODE_UNSUPPORTED = "EXCHANGE_MARGIN_MODE_UNSUPPORTED" + EXCHANGE_TRADING_MODE_UNSUPPORTED = "EXCHANGE_TRADING_MODE_UNSUPPORTED" + EXCHANGE_UNSUPPORTED = "EXCHANGE_UNSUPPORTED" FUTURES_MARGIN_MODE_REQUIRED = "FUTURES_MARGIN_MODE_REQUIRED" LIVE_TRADING_REQUIRES_CONFIRMATION = "LIVE_TRADING_REQUIRES_CONFIRMATION" MISSING_EXCHANGE_KEY = "MISSING_EXCHANGE_KEY" @@ -27,3 +30,10 @@ class Locale(StrEnum): EN = "en" KO = "ko" EL = "el" + + +@unique +class RiskProfileName(StrEnum): + SAFE = "safe" + BALANCED = "balanced" + EXPERT = "expert" diff --git a/src/nfi_engine/config/env_overrides.py b/src/nfi_engine/config/env_overrides.py new file mode 100644 index 0000000..88c1009 --- /dev/null +++ b/src/nfi_engine/config/env_overrides.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Final, assert_never + +type ConfigScalar = str | int | float | bool | None +type ConfigValue = ConfigScalar | list[ConfigValue] | dict[str, ConfigValue] +type ConfigData = dict[str, ConfigValue] + +ENV_OVERRIDES: Final = ( + ("NFI_ENGINE__ENGINE__LIVE_TRADING", ("engine", "live_trading")), + ("NFI_ENGINE__ENGINE__LIVE_TRADING_CONFIRMED", ("engine", "live_trading_confirmed")), + ("NFI_ENGINE__EXCHANGE__API_KEY", ("exchange", "api_key")), + ("NFI_ENGINE__EXCHANGE__API_SECRET", ("exchange", "api_secret")), + ("NFI_ENGINE__EXCHANGE__TRADING_MODE", ("exchange", "trading_mode")), + ("NFI_ENGINE__EXCHANGE__MARGIN_MODE", ("exchange", "margin_mode")), + ("NFI_ENGINE__RISK__STAKE_USDT", ("risk", "stake_usdt")), + ("NFI_ENGINE__RISK__LEVERAGE", ("risk", "leverage")), + ("NFI_ENGINE__RISK__MAX_LEVERAGE", ("risk", "max_leverage")), + ("NFI_ENGINE__RISK__MAX_OPEN_TRADES", ("risk", "max_open_trades")), + ("NFI_ENGINE__RISK__LIQUIDATION_BUFFER", ("risk", "liquidation_buffer")), + ("NFI_ENGINE__API__AUTH_TOKEN", ("api", "auth_token")), + ("NFI_ENGINE__LOGGING__LEVEL", ("logging", "level")), +) + + +def apply_env_overrides(config: ConfigData, environ: Mapping[str, str]) -> ConfigData: + current = clone_config(config) + for env_name, path in ENV_OVERRIDES: + env_value = environ.get(env_name) + if env_value is not None: + _set_nested_override(current, path, env_value) + return current + + +def clone_config(config: ConfigData) -> ConfigData: + return {key: clone_value(value) for key, value in config.items()} + + +def clone_value(value: ConfigValue) -> ConfigValue: + match value: + case dict(): + return {key: clone_value(child) for key, child in value.items()} + case list(): + return [clone_value(child) for child in value] + case str() | int() | float() | bool() | None: + return value + case unreachable: + assert_never(unreachable) + + +def _set_nested_override(config: ConfigData, path: tuple[str, str], value: str) -> None: + section, key = path + section_value = config.get(section) + if isinstance(section_value, dict): + nested = section_value + else: + nested = {} + config[section] = nested + nested[key] = value diff --git a/src/nfi_engine/config/loader.py b/src/nfi_engine/config/loader.py index 41bb910..c8cbacd 100644 --- a/src/nfi_engine/config/loader.py +++ b/src/nfi_engine/config/loader.py @@ -1,42 +1,24 @@ from __future__ import annotations import os -from collections.abc import Iterable, Mapping +from collections.abc import Iterable from pathlib import Path -from typing import Final, assert_never +from typing import Final from pydantic import ValidationError from nfi_engine.config.enums import ConfigErrorCode +from nfi_engine.config.env_overrides import ConfigData, ConfigScalar, apply_env_overrides from nfi_engine.config.errors import ConfigLoadError from nfi_engine.config.models import RuntimeSettings -from nfi_engine.domain import DomainError, Leverage, LiquidationBuffer, MarginMode, TradingMode +from nfi_engine.config.validators import validate_runtime_settings_model -type ConfigScalar = str | int | float | bool | None -type ConfigValue = ConfigScalar | list[ConfigValue] | dict[str, ConfigValue] -type ConfigData = dict[str, ConfigValue] - -ENV_OVERRIDES: Final = ( - ("NFI_ENGINE__ENGINE__LIVE_TRADING", ("engine", "live_trading")), - ("NFI_ENGINE__ENGINE__LIVE_TRADING_CONFIRMED", ("engine", "live_trading_confirmed")), - ("NFI_ENGINE__EXCHANGE__API_KEY", ("exchange", "api_key")), - ("NFI_ENGINE__EXCHANGE__API_SECRET", ("exchange", "api_secret")), - ("NFI_ENGINE__EXCHANGE__TRADING_MODE", ("exchange", "trading_mode")), - ("NFI_ENGINE__EXCHANGE__MARGIN_MODE", ("exchange", "margin_mode")), - ("NFI_ENGINE__RISK__STAKE_USDT", ("risk", "stake_usdt")), - ("NFI_ENGINE__RISK__LEVERAGE", ("risk", "leverage")), - ("NFI_ENGINE__RISK__MAX_LEVERAGE", ("risk", "max_leverage")), - ("NFI_ENGINE__RISK__MAX_OPEN_TRADES", ("risk", "max_open_trades")), - ("NFI_ENGINE__RISK__LIQUIDATION_BUFFER", ("risk", "liquidation_buffer")), - ("NFI_ENGINE__API__AUTH_TOKEN", ("api", "auth_token")), - ("NFI_ENGINE__LOGGING__LEVEL", ("logging", "level")), -) MIN_QUOTED_SCALAR_LENGTH: Final = 2 def load_runtime_settings(path: Path) -> RuntimeSettings: raw_config = _read_yaml_config(path) - config_with_env = _apply_env_overrides(raw_config, os.environ) + config_with_env = apply_env_overrides(raw_config, os.environ) try: settings = RuntimeSettings.model_validate(config_with_env) except ValidationError as exc: @@ -50,7 +32,7 @@ def load_runtime_settings(path: Path) -> RuntimeSettings: def validate_runtime_settings(*, settings: RuntimeSettings, path: Path) -> None: - _validate_runtime_settings(settings=settings, path=path) + validate_runtime_settings_model(settings=settings, path=path) def _read_yaml_config(path: Path) -> ConfigData: @@ -130,168 +112,5 @@ def _yaml_error(*, path: Path, line_number: int, message: str) -> ConfigLoadErro ) -def _apply_env_overrides(config: ConfigData, environ: Mapping[str, str]) -> ConfigData: - current = _clone_config(config) - for env_name, path in ENV_OVERRIDES: - env_value = environ.get(env_name) - if env_value is not None: - current = _with_nested_override(current, path, env_value) - return current - - -def _with_nested_override(config: ConfigData, path: tuple[str, str], value: str) -> ConfigData: - section, key = path - current = _clone_config(config) - section_value = current.get(section) - nested: ConfigData = section_value if isinstance(section_value, dict) else {} - nested[key] = value - current[section] = nested - return current - - -def _clone_config(config: ConfigData) -> ConfigData: - return {key: _clone_value(value) for key, value in config.items()} - - -def _clone_value(value: ConfigValue) -> ConfigValue: - match value: - case dict(): - return {key: _clone_value(child) for key, child in value.items()} - case list(): - return [_clone_value(child) for child in value] - case str() | int() | float() | bool() | None: - return value - case unreachable: - assert_never(unreachable) - - -def _validate_runtime_settings(*, settings: RuntimeSettings, path: Path) -> None: - _validate_trading_mode(settings=settings, path=path) - _validate_risk_settings(settings=settings, path=path) - _validate_backtest_settings(settings=settings, path=path) - _validate_notification_settings(settings=settings, path=path) - if settings.engine.live_trading: - _validate_live_trading(settings=settings, path=path) - - -def _validate_trading_mode(*, settings: RuntimeSettings, path: Path) -> None: - match settings.exchange.trading_mode: - case TradingMode.SPOT: - if settings.exchange.margin_mode is not None: - raise ConfigLoadError( - code=ConfigErrorCode.SPOT_MARGIN_MODE_NOT_ALLOWED, - message="spot config must not set margin_mode", - path=path, - ) - case TradingMode.FUTURES: - match settings.exchange.margin_mode: - case MarginMode.ISOLATED | MarginMode.CROSS: - return - case None: - raise ConfigLoadError( - code=ConfigErrorCode.FUTURES_MARGIN_MODE_REQUIRED, - message="futures config requires margin_mode isolated or cross", - path=path, - ) - case unreachable: - assert_never(unreachable) - case unreachable: - assert_never(unreachable) - - -def _validate_risk_settings(*, settings: RuntimeSettings, path: Path) -> None: - try: - Leverage.parse(str(settings.risk.leverage)) - Leverage.parse(str(settings.risk.max_leverage)) - LiquidationBuffer.parse(str(settings.risk.liquidation_buffer)) - except DomainError as exc: - raise ConfigLoadError( - code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, - message=str(exc), - path=path, - ) from exc - if settings.risk.max_open_trades < 1: - raise ConfigLoadError( - code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, - message="risk.max_open_trades must be at least 1", - path=path, - ) - if settings.risk.stoploss_pct <= 0 or settings.risk.stoploss_pct >= 1: - raise ConfigLoadError( - code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, - message="risk.stoploss_pct must be greater than 0 and less than 1", - path=path, - ) - if settings.risk.minimal_roi < 0: - raise ConfigLoadError( - code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, - message="risk.minimal_roi must be greater than or equal to 0", - path=path, - ) - if settings.risk.cooldown_seconds < 0: - raise ConfigLoadError( - code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, - message="risk.cooldown_seconds must be greater than or equal to 0", - path=path, - ) - - -def _validate_backtest_settings(*, settings: RuntimeSettings, path: Path) -> None: - if settings.backtest.stoploss_pct <= 0 or settings.backtest.stoploss_pct >= 1: - raise ConfigLoadError( - code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, - message="backtest.stoploss_pct must be greater than 0 and less than 1", - path=path, - ) - if settings.backtest.fee_rate < 0 or settings.backtest.fee_rate >= 1: - raise ConfigLoadError( - code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, - message="backtest.fee_rate must be greater than or equal to 0 and less than 1", - path=path, - ) - if settings.backtest.slippage_rate < 0 or settings.backtest.slippage_rate >= 1: - raise ConfigLoadError( - code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, - message="backtest.slippage_rate must be greater than or equal to 0 and less than 1", - path=path, - ) - if settings.backtest.max_open_trades < 0: - raise ConfigLoadError( - code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, - message="backtest.max_open_trades must be greater than or equal to 0", - path=path, - ) - - -def _validate_notification_settings(*, settings: RuntimeSettings, path: Path) -> None: - if settings.notifications.timeout_seconds <= 0: - raise ConfigLoadError( - code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, - message="notifications.timeout_seconds must be greater than 0", - path=path, - ) - if settings.notifications.max_attempts < 1: - raise ConfigLoadError( - code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, - message="notifications.max_attempts must be at least 1", - path=path, - ) - - -def _validate_live_trading(*, settings: RuntimeSettings, path: Path) -> None: - if not settings.engine.live_trading_confirmed: - raise ConfigLoadError( - code=ConfigErrorCode.LIVE_TRADING_REQUIRES_CONFIRMATION, - message="live_trading requires live_trading_confirmed=true", - path=path, - ) - if not settings.exchange.api_key or not settings.exchange.api_secret: - raise ConfigLoadError( - code=ConfigErrorCode.MISSING_EXCHANGE_KEY, - message="live_trading requires exchange api_key and api_secret", - path=path, - ) - - def _format_validation_error(exc: ValidationError) -> str: return "; ".join(error["type"] for error in exc.errors()) diff --git a/src/nfi_engine/config/metadata.py b/src/nfi_engine/config/metadata.py index 7a8a907..16194ac 100644 --- a/src/nfi_engine/config/metadata.py +++ b/src/nfi_engine/config/metadata.py @@ -71,7 +71,10 @@ def _safe( _restart("exchange.api_key", sensitive=True), _restart("exchange.api_secret", sensitive=True), _safe("risk.stake_usdt", ui_group=FieldGroup.SIMPLE), + _safe("risk.risk_profile", ui_group=FieldGroup.SIMPLE), + _safe("risk.expert_risk_confirmed"), _safe("risk.max_daily_loss_pct"), + _safe("risk.allocation_cap_pct"), _safe("risk.leverage"), _safe("risk.max_leverage"), _safe("risk.liquidation_buffer"), diff --git a/src/nfi_engine/config/models.py b/src/nfi_engine/config/models.py index f94332d..b88eec4 100644 --- a/src/nfi_engine/config/models.py +++ b/src/nfi_engine/config/models.py @@ -6,8 +6,9 @@ from pydantic import BaseModel, ConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict -from nfi_engine.config.enums import Locale, LogLevel +from nfi_engine.config.enums import Locale, LogLevel, RiskProfileName from nfi_engine.domain import MarginMode, TradingMode +from nfi_engine.exchange.permissions import ExchangeApiPermissionState class StrictConfigModel(BaseModel): @@ -27,6 +28,11 @@ class ExchangeSettings(StrictConfigModel): testnet: bool = True api_key: str | None = None api_secret: str | None = None + permission_read: ExchangeApiPermissionState = ExchangeApiPermissionState.UNKNOWN + permission_trade: ExchangeApiPermissionState = ExchangeApiPermissionState.UNKNOWN + permission_futures: ExchangeApiPermissionState = ExchangeApiPermissionState.UNKNOWN + permission_withdrawal: ExchangeApiPermissionState = ExchangeApiPermissionState.UNKNOWN + permission_ip_allowlist: ExchangeApiPermissionState = ExchangeApiPermissionState.UNKNOWN class StrategySettings(StrictConfigModel): @@ -39,8 +45,11 @@ class DatabaseSettings(StrictConfigModel): class RiskSettings(StrictConfigModel): + risk_profile: RiskProfileName = RiskProfileName.BALANCED + expert_risk_confirmed: bool = False stake_usdt: Decimal = Decimal(10) max_daily_loss_pct: Decimal = Decimal("0.05") + allocation_cap_pct: Decimal = Decimal("0.10") leverage: Decimal = Decimal(1) max_leverage: Decimal = Decimal(5) liquidation_buffer: Decimal = Decimal("0.05") diff --git a/src/nfi_engine/config/validators.py b/src/nfi_engine/config/validators.py new file mode 100644 index 0000000..117b95f --- /dev/null +++ b/src/nfi_engine/config/validators.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from pathlib import Path +from typing import assert_never + +from nfi_engine.config.enums import ConfigErrorCode +from nfi_engine.config.errors import ConfigLoadError +from nfi_engine.config.models import RuntimeSettings +from nfi_engine.domain import DomainError, Leverage, LiquidationBuffer, MarginMode, TradingMode +from nfi_engine.exchange.capabilities import get_exchange_profile + + +def validate_runtime_settings_model(*, settings: RuntimeSettings, path: Path) -> None: + _validate_trading_mode(settings=settings, path=path) + _validate_exchange_capability(settings=settings, path=path) + _validate_risk_settings(settings=settings, path=path) + _validate_backtest_settings(settings=settings, path=path) + _validate_notification_settings(settings=settings, path=path) + if settings.engine.live_trading: + _validate_live_trading(settings=settings, path=path) + + +def _validate_trading_mode(*, settings: RuntimeSettings, path: Path) -> None: + match settings.exchange.trading_mode: + case TradingMode.SPOT: + if settings.exchange.margin_mode is not None: + raise ConfigLoadError( + code=ConfigErrorCode.SPOT_MARGIN_MODE_NOT_ALLOWED, + message="spot config must not set margin_mode", + path=path, + ) + case TradingMode.FUTURES: + match settings.exchange.margin_mode: + case MarginMode.ISOLATED | MarginMode.CROSS: + return + case None: + raise ConfigLoadError( + code=ConfigErrorCode.FUTURES_MARGIN_MODE_REQUIRED, + message="futures config requires margin_mode isolated or cross", + path=path, + ) + case unreachable: + assert_never(unreachable) + case unreachable: + assert_never(unreachable) + + +def _validate_exchange_capability(*, settings: RuntimeSettings, path: Path) -> None: + profile = get_exchange_profile(settings.exchange.name) + if profile is None: + raise ConfigLoadError( + code=ConfigErrorCode.EXCHANGE_UNSUPPORTED, + message=f"unsupported exchange: {settings.exchange.name}", + path=path, + ) + if not profile.supports_trading_mode(settings.exchange.trading_mode): + raise ConfigLoadError( + code=ConfigErrorCode.EXCHANGE_TRADING_MODE_UNSUPPORTED, + message=( + f"{profile.exchange_id} does not support " + f"{settings.exchange.trading_mode.value} in registry" + ), + path=path, + ) + margin_mode = settings.exchange.margin_mode + if margin_mode is not None and not profile.supports_margin_mode(margin_mode): + raise ConfigLoadError( + code=ConfigErrorCode.EXCHANGE_MARGIN_MODE_UNSUPPORTED, + message=f"{profile.exchange_id} does not support {margin_mode.value} margin", + path=path, + ) + + +def _validate_risk_settings(*, settings: RuntimeSettings, path: Path) -> None: + try: + Leverage.parse(str(settings.risk.leverage)) + Leverage.parse(str(settings.risk.max_leverage)) + LiquidationBuffer.parse(str(settings.risk.liquidation_buffer)) + except DomainError as exc: + raise ConfigLoadError( + code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, + message=str(exc), + path=path, + ) from exc + if settings.risk.max_open_trades < 1: + raise ConfigLoadError( + code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, + message="risk.max_open_trades must be at least 1", + path=path, + ) + if settings.risk.stoploss_pct <= 0 or settings.risk.stoploss_pct >= 1: + raise ConfigLoadError( + code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, + message="risk.stoploss_pct must be greater than 0 and less than 1", + path=path, + ) + if settings.risk.minimal_roi < 0: + raise ConfigLoadError( + code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, + message="risk.minimal_roi must be greater than or equal to 0", + path=path, + ) + if settings.risk.cooldown_seconds < 0: + raise ConfigLoadError( + code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, + message="risk.cooldown_seconds must be greater than or equal to 0", + path=path, + ) + + +def _validate_backtest_settings(*, settings: RuntimeSettings, path: Path) -> None: + if settings.backtest.stoploss_pct <= 0 or settings.backtest.stoploss_pct >= 1: + raise ConfigLoadError( + code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, + message="backtest.stoploss_pct must be greater than 0 and less than 1", + path=path, + ) + if settings.backtest.fee_rate < 0 or settings.backtest.fee_rate >= 1: + raise ConfigLoadError( + code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, + message="backtest.fee_rate must be greater than or equal to 0 and less than 1", + path=path, + ) + if settings.backtest.slippage_rate < 0 or settings.backtest.slippage_rate >= 1: + raise ConfigLoadError( + code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, + message="backtest.slippage_rate must be greater than or equal to 0 and less than 1", + path=path, + ) + if settings.backtest.max_open_trades < 0: + raise ConfigLoadError( + code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, + message="backtest.max_open_trades must be greater than or equal to 0", + path=path, + ) + + +def _validate_notification_settings(*, settings: RuntimeSettings, path: Path) -> None: + if settings.notifications.timeout_seconds <= 0: + raise ConfigLoadError( + code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, + message="notifications.timeout_seconds must be greater than 0", + path=path, + ) + if settings.notifications.max_attempts < 1: + raise ConfigLoadError( + code=ConfigErrorCode.CONFIG_VALIDATION_FAILED, + message="notifications.max_attempts must be at least 1", + path=path, + ) + + +def _validate_live_trading(*, settings: RuntimeSettings, path: Path) -> None: + if not settings.engine.live_trading_confirmed: + raise ConfigLoadError( + code=ConfigErrorCode.LIVE_TRADING_REQUIRES_CONFIRMATION, + message="live_trading requires live_trading_confirmed=true", + path=path, + ) + if not settings.exchange.api_key or not settings.exchange.api_secret: + raise ConfigLoadError( + code=ConfigErrorCode.MISSING_EXCHANGE_KEY, + message="live_trading requires exchange api_key and api_secret", + path=path, + ) diff --git a/src/nfi_engine/dashboard/__init__.py b/src/nfi_engine/dashboard/__init__.py index d775405..cadaea2 100644 --- a/src/nfi_engine/dashboard/__init__.py +++ b/src/nfi_engine/dashboard/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations from nfi_engine.dashboard.models import ( + DashboardAction, DashboardEquityPoint, DashboardError, DashboardOpenPosition, @@ -12,7 +13,7 @@ DashboardRecentTrade, DashboardSnapshot, ) -from nfi_engine.dashboard.service import build_dashboard_snapshot +from nfi_engine.dashboard.service import build_dashboard_actions, build_dashboard_snapshot from nfi_engine.dashboard.store import ( DashboardReadStore, PersistenceDashboardReadStore, @@ -21,6 +22,7 @@ from nfi_engine.dashboard.summary import DashboardOperatorSummary, summarize_dashboard_read_models __all__ = [ + "DashboardAction", "DashboardEquityPoint", "DashboardError", "DashboardOpenPosition", @@ -35,6 +37,7 @@ "DashboardSnapshot", "PersistenceDashboardReadStore", "StaticDashboardReadStore", + "build_dashboard_actions", "build_dashboard_snapshot", "summarize_dashboard_read_models", ] diff --git a/src/nfi_engine/dashboard/models.py b/src/nfi_engine/dashboard/models.py index 2c7526e..0fe1d60 100644 --- a/src/nfi_engine/dashboard/models.py +++ b/src/nfi_engine/dashboard/models.py @@ -8,6 +8,15 @@ from nfi_engine.paper import BotState +@dataclass(frozen=True, slots=True) +class DashboardAction: + code: str + severity: str + title: str + detail: str + target: str + + @dataclass(frozen=True, slots=True) class DashboardEquityPoint: at: datetime @@ -91,6 +100,7 @@ class DashboardSnapshot: bot_state: BotState trading_mode: str exchange: str + actions: tuple[DashboardAction, ...] readiness: DashboardReadiness pairlist: DashboardPairlistSummary equity_points: tuple[DashboardEquityPoint, ...] diff --git a/src/nfi_engine/dashboard/service.py b/src/nfi_engine/dashboard/service.py index d5a46a2..012086b 100644 --- a/src/nfi_engine/dashboard/service.py +++ b/src/nfi_engine/dashboard/service.py @@ -6,6 +6,7 @@ from nfi_engine.api.models import LogEntryResponse from nfi_engine.config import LogLevel, RuntimeSettings from nfi_engine.dashboard.models import ( + DashboardAction, DashboardError, DashboardPairlistSummary, DashboardReadiness, @@ -18,6 +19,50 @@ PAIR_PREVIEW_LIMIT: Final = 4 RECENT_ERROR_LIMIT: Final = 3 +ACTION_LIMIT: Final = 4 + +READINESS_BLOCKED_ACTION: Final = DashboardAction( + code="readiness_blocked", + severity="error", + title="Preflight is blocking startup", + detail="Review failed checks in setup before starting the runtime.", + target="settings/setup", +) +RUNTIME_ERRORS_ACTION: Final = DashboardAction( + code="runtime_errors_detected", + severity="error", + title="Recent runtime errors need review", + detail="Open Logs and inspect the latest error summaries before continuing.", + target="logs", +) +PAIRLIST_EMPTY_ACTION: Final = DashboardAction( + code="pairlist_empty", + severity="warning", + title="Pairlist is empty", + detail="Add at least one whitelisted pair before running the paper engine.", + target="settings", +) +PAPER_READY_ACTION: Final = DashboardAction( + code="paper_runtime_ready", + severity="info", + title="Paper/testnet runtime is ready", + detail="Review status, pairlist, and safety panels before starting the bot.", + target="dashboard/status", +) +PREFLIGHT_MISSING_ACTION: Final = DashboardAction( + code="preflight_not_loaded", + severity="warning", + title="Run preflight before starting", + detail="Load a preflight report to confirm setup, storage, and safety gates.", + target="settings/setup", +) +SUPPORT_BUNDLE_ACTION: Final = DashboardAction( + code="support_bundle_follow_up", + severity="info", + title="Export a support bundle if errors persist", + detail="Capture a redacted support bundle after reviewing the logs if follow-up is needed.", + target="logs/support-bundle", +) def build_dashboard_snapshot( @@ -28,21 +73,71 @@ def build_dashboard_snapshot( logs: tuple[LogEntryResponse, ...], read_models: DashboardReadModels, ) -> DashboardSnapshot: + pairlist = _pairlist(settings) + recent_errors = _recent_errors(logs) return DashboardSnapshot( generated_at=datetime.now(UTC), bot_state=bot_state, trading_mode=settings.exchange.trading_mode.value, exchange=settings.exchange.name, + actions=_dashboard_actions( + readiness=readiness, + pairlist=pairlist, + recent_errors=recent_errors, + ), readiness=_readiness(readiness), - pairlist=_pairlist(settings), + pairlist=pairlist, equity_points=read_models.equity_points, price_points=read_models.price_points, open_positions=read_models.open_positions, recent_trades=read_models.recent_trades, - recent_errors=_recent_errors(logs), + recent_errors=recent_errors, ) +def build_dashboard_actions( + *, + settings: RuntimeSettings, + readiness: PreflightReport | None, + logs: tuple[LogEntryResponse, ...], +) -> tuple[DashboardAction, ...]: + pairlist = _pairlist(settings) + recent_errors = _recent_errors(logs) + return _dashboard_actions( + readiness=readiness, + pairlist=pairlist, + recent_errors=recent_errors, + ) + + +def _dashboard_actions( + *, + readiness: PreflightReport | None, + pairlist: DashboardPairlistSummary, + recent_errors: tuple[DashboardError, ...], +) -> tuple[DashboardAction, ...]: + actions: list[DashboardAction] = [] + + if readiness is None: + actions.append(PREFLIGHT_MISSING_ACTION) + elif readiness.blocked: + actions.append(READINESS_BLOCKED_ACTION) + + if len(recent_errors) > 0: + actions.append(RUNTIME_ERRORS_ACTION) + + if pairlist.total == 0: + actions.append(PAIRLIST_EMPTY_ACTION) + + if len(actions) == 0: + actions.append(PAPER_READY_ACTION) + + if len(recent_errors) > 0: + actions.append(SUPPORT_BUNDLE_ACTION) + + return tuple(actions[:ACTION_LIMIT]) + + def _readiness(report: PreflightReport) -> DashboardReadiness: return DashboardReadiness( profile=report.profile, diff --git a/src/nfi_engine/dashboard/store.py b/src/nfi_engine/dashboard/store.py index 8bb1803..da71563 100644 --- a/src/nfi_engine/dashboard/store.py +++ b/src/nfi_engine/dashboard/store.py @@ -1,5 +1,6 @@ from __future__ import annotations +from asyncio import Lock from dataclasses import dataclass, field from typing import Protocol @@ -36,9 +37,11 @@ class PersistenceDashboardReadStore: equity_limit: int = 120 position_limit: int = 50 trade_limit: int = 50 + _initialized: bool = field(default=False, init=False, repr=False, compare=False) + _initialize_lock: Lock = field(default_factory=Lock, init=False, repr=False, compare=False) async def read_models(self) -> DashboardReadModels: - await self.database.initialize() + await self._ensure_initialized() async with self.database.session() as session: equity = await EquitySnapshotRepository(session).list_recent(limit=self.equity_limit) positions = await PositionRepository(session).list_open(limit=self.position_limit) @@ -49,6 +52,15 @@ async def read_models(self) -> DashboardReadModels: recent_trades=tuple(_recent_trade(record) for record in trades), ) + async def _ensure_initialized(self) -> None: + if self._initialized: + return + async with self._initialize_lock: + if self._initialized: + return + await self.database.initialize() + object.__setattr__(self, "_initialized", True) + def _equity_point(record: EquitySnapshotRecord) -> DashboardEquityPoint: return DashboardEquityPoint( diff --git a/src/nfi_engine/exchange/AGENTS.md b/src/nfi_engine/exchange/AGENTS.md new file mode 100644 index 0000000..39db076 --- /dev/null +++ b/src/nfi_engine/exchange/AGENTS.md @@ -0,0 +1,46 @@ +# EXCHANGE GUIDE + +## OVERVIEW + +`exchange` owns exchange-facing protocols, simulator behavior, Bybit testnet +adapter seams, tick loading, fill scenarios, and order/fill models. + +## STRUCTURE + +```text +exchange/ +|-- protocols.py # adapter contracts +|-- bybit.py # Bybit testnet-ready adapter boundary +|-- simulator.py # deterministic local exchange simulation +|-- fill_scenarios.py # partial fill, latency, slippage, funding cases +|-- ticks.py # fixture tick loading +|-- models.py # quotes, orders, fills, balances +`-- errors.py +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +| --- | --- | --- | +| Adapter contract | `protocols.py`, `models.py` | Keep simulator and real adapter shapes aligned. | +| Bybit behavior | `bybit.py` | Testnet/sandbox mode is mandatory in current milestone. | +| Paper execution | `simulator.py`, `fill_scenarios.py` | Deterministic risk-modeling approximation. | +| Fixture ticks | `ticks.py`, `tests/fixtures/ticks` | No network fetch in tests. | +| Tests | `tests/integration/exchange`, `tests/unit/exchange` | Use fake clients and fixtures. | + +## CONVENTIONS + +- Simulator/testnet/paper are the allowed milestone surfaces. Real-money order placement is out of scope. +- Bybit adapters must set sandbox/testnet mode and expose typed failures instead of leaking raw client exceptions. +- Fill scenarios are risk approximations, not exact exchange claims. Keep docs and CLI wording precise. +- Pair parsing, leverage, margin, and side rules must align with `domain` and `risk` services. +- Reconciliation and preflight decide whether runtime may continue; adapters should provide facts, not bypass policy. +- Secrets from exchange settings must never appear in logs, notifications, support bundles, or evidence files. + +## ANTI-PATTERNS + +- Do not add live-order shortcuts, production exchange defaults, or public-bind deployment behavior here. +- Do not make tests depend on real exchange connectivity. +- Do not silently coerce unsupported market modes, pair symbols, leverage, or margin settings. +- Do not let adapter convenience bypass circuit breakers, reconciliation, pairlist checks, or safety gates. +- Do not claim simulator scenarios exactly match live exchange behavior. diff --git a/src/nfi_engine/exchange/__init__.py b/src/nfi_engine/exchange/__init__.py index b7ba916..6467c52 100644 --- a/src/nfi_engine/exchange/__init__.py +++ b/src/nfi_engine/exchange/__init__.py @@ -1,5 +1,16 @@ from __future__ import annotations +from nfi_engine.exchange.capabilities import ( + ExchangeCapabilityProfile, + ExchangeSupportLevel, + get_exchange_profile, + list_exchange_profiles, +) +from nfi_engine.exchange.discovery import ( + ExchangeCapabilityReport, + build_exchange_capability_document, + build_exchange_capability_report, +) from nfi_engine.exchange.errors import ExchangeError, ExchangeErrorCode from nfi_engine.exchange.models import ( ExchangeOrder, @@ -13,14 +24,21 @@ from nfi_engine.exchange.ticks import load_tick_fixture __all__ = [ + "ExchangeCapabilityProfile", + "ExchangeCapabilityReport", "ExchangeError", "ExchangeErrorCode", "ExchangeOrder", "ExchangeOrderRequest", "ExchangeProtocol", + "ExchangeSupportLevel", "FundingRate", "Market", "Tick", "Ticker", + "build_exchange_capability_document", + "build_exchange_capability_report", + "get_exchange_profile", + "list_exchange_profiles", "load_tick_fixture", ] diff --git a/src/nfi_engine/exchange/binance.py b/src/nfi_engine/exchange/binance.py new file mode 100644 index 0000000..b80257f --- /dev/null +++ b/src/nfi_engine/exchange/binance.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import hmac +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from decimal import Decimal +from hashlib import sha256 +from typing import ClassVar, Final, Protocol +from urllib.parse import urlencode + +import httpx +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError + +from nfi_engine.config import RuntimeSettings +from nfi_engine.domain import AccountSnapshot, StakeAmount, TradingMode +from nfi_engine.exchange.errors import ExchangeError, ExchangeErrorCode + +BINANCE_FAPI_BASE_URL: Final = "https://fapi.binance.com" +BINANCE_FAPI_TESTNET_BASE_URL: Final = "https://testnet.binancefuture.com" +BALANCE_PATH: Final = "/fapi/v3/balance" +DEFAULT_RECV_WINDOW_MS: Final = 5000 +HTTP_CLIENT_ERROR_MIN: Final = 400 +AUTH_FAILURE_STATUSES: Final = frozenset({401, 403}) +HTTP_LIMITS: Final = httpx.Limits( + max_connections=10, + max_keepalive_connections=4, + keepalive_expiry=30.0, +) +HTTP_TIMEOUT: Final = httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0) + +type QueryParams = tuple[tuple[str, str], ...] +type TimestampProvider = Callable[[], int] + + +def _timestamp_ms() -> int: + return int(datetime.now(UTC).timestamp() * 1000) + + +class BinanceHttpClient(Protocol): + async def get( + self, + url: str, + *, + params: QueryParams, + headers: Mapping[str, str], + ) -> httpx.Response: ... + + +class BinanceFuturesBalanceAsset(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore", frozen=True) + + asset: str + balance: Decimal + available_balance: Decimal = Field(alias="availableBalance") + + +BALANCE_PAYLOAD_ADAPTER: Final = TypeAdapter(tuple[BinanceFuturesBalanceAsset, ...]) + + +@dataclass(frozen=True, slots=True) +class BinanceFuturesBalanceAdapter: + api_key: str + api_secret: str + quote_asset: str + client: BinanceHttpClient | None = None + base_url: str = BINANCE_FAPI_TESTNET_BASE_URL + recv_window_ms: int = DEFAULT_RECV_WINDOW_MS + timestamp_ms: TimestampProvider = _timestamp_ms + + @classmethod + def from_settings(cls, *, settings: RuntimeSettings) -> BinanceFuturesBalanceAdapter: + match settings.exchange.trading_mode: + case TradingMode.FUTURES: + pass + case TradingMode.SPOT: + raise ExchangeError( + code=ExchangeErrorCode.EXCHANGE_RESPONSE_INVALID, + message="Binance wallet adapter currently supports futures balance only.", + ) + if settings.exchange.api_key is None or settings.exchange.api_secret is None: + raise ExchangeError( + code=ExchangeErrorCode.EXCHANGE_AUTH_FAILED, + message="Binance wallet adapter requires exchange API credentials.", + ) + return cls( + api_key=settings.exchange.api_key, + api_secret=settings.exchange.api_secret, + quote_asset=settings.pairlist.quote_asset, + base_url=( + BINANCE_FAPI_TESTNET_BASE_URL + if settings.exchange.testnet + else BINANCE_FAPI_BASE_URL + ), + ) + + async def fetch_balance(self) -> AccountSnapshot: + if self.client is not None: + return await self._fetch_with_client(self.client) + async with httpx.AsyncClient( + base_url=self.base_url, + limits=HTTP_LIMITS, + timeout=HTTP_TIMEOUT, + follow_redirects=False, + ) as client: + return await self._fetch_with_client(client) + + async def _fetch_with_client(self, client: BinanceHttpClient) -> AccountSnapshot: + signed_at = self.timestamp_ms() + try: + response = await client.get( + BALANCE_PATH, + params=self._signed_params(timestamp_ms=signed_at), + headers={"X-MBX-APIKEY": self.api_key}, + ) + except httpx.TransportError as exc: + raise ExchangeError( + code=ExchangeErrorCode.EXCHANGE_HTTP_ERROR, + message="Binance balance request failed at transport boundary.", + ) from exc + _raise_for_status(response.status_code) + assets = _parse_balance_assets(response.content) + quote_asset = _quote_asset(assets=assets, asset=self.quote_asset) + return AccountSnapshot( + captured_at=datetime.fromtimestamp(signed_at / 1000, tz=UTC), + equity=StakeAmount(quote_asset.balance), + available=StakeAmount(quote_asset.available_balance), + positions=(), + ) + + def _signed_params(self, *, timestamp_ms: int) -> QueryParams: + params: QueryParams = ( + ("recvWindow", str(self.recv_window_ms)), + ("timestamp", str(timestamp_ms)), + ) + signature = hmac.new( + self.api_secret.encode(), + urlencode(params).encode(), + sha256, + ).hexdigest() + return (*params, ("signature", signature)) + + +def _raise_for_status(status_code: int) -> None: + if status_code < HTTP_CLIENT_ERROR_MIN: + return + if status_code in AUTH_FAILURE_STATUSES: + raise ExchangeError( + code=ExchangeErrorCode.EXCHANGE_AUTH_FAILED, + message="Binance rejected the API key, IP allowlist, or permissions.", + ) + raise ExchangeError( + code=ExchangeErrorCode.EXCHANGE_HTTP_ERROR, + message=f"Binance balance request failed with status {status_code}.", + ) + + +def _parse_balance_assets(content: bytes) -> tuple[BinanceFuturesBalanceAsset, ...]: + try: + return BALANCE_PAYLOAD_ADAPTER.validate_json(content) + except ValidationError as exc: + raise ExchangeError( + code=ExchangeErrorCode.EXCHANGE_RESPONSE_INVALID, + message="Binance balance response shape is invalid.", + ) from exc + + +def _quote_asset( + *, + assets: tuple[BinanceFuturesBalanceAsset, ...], + asset: str, +) -> BinanceFuturesBalanceAsset: + for candidate in assets: + if candidate.asset == asset: + return candidate + raise ExchangeError( + code=ExchangeErrorCode.EXCHANGE_RESPONSE_INVALID, + message=f"Binance balance response did not include {asset}.", + ) diff --git a/src/nfi_engine/exchange/bybit.py b/src/nfi_engine/exchange/bybit.py index 6bbe4a4..320d59a 100644 --- a/src/nfi_engine/exchange/bybit.py +++ b/src/nfi_engine/exchange/bybit.py @@ -1,19 +1,24 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass -from decimal import Decimal +from datetime import UTC, datetime +from decimal import Decimal, InvalidOperation from typing import NotRequired, Protocol, TypedDict, assert_never from nfi_engine.config import RuntimeSettings from nfi_engine.domain import ( + AccountSnapshot, Leverage, OrderState, OrderType, PositionSide, Price, Quantity, + StakeAmount, TradingPair, ) +from nfi_engine.domain.primitives import DecimalInput from nfi_engine.exchange.errors import ExchangeError, ExchangeErrorCode from nfi_engine.exchange.models import ExchangeOrder, ExchangeOrderRequest, FundingRate @@ -34,6 +39,11 @@ class CcxtFundingPayload(TypedDict): fundingRate: str +class CcxtBalancePayload(TypedDict): + total: Mapping[str, DecimalInput | None] + free: Mapping[str, DecimalInput | None] + + class CcxtClientProtocol(Protocol): def set_sandbox_mode(self, enabled: bool) -> None: ... @@ -54,10 +64,13 @@ async def fetch_funding_rate(self, symbol: str) -> CcxtFundingPayload: ... async def set_leverage(self, leverage: int, symbol: str) -> CcxtOrderPayload: ... + async def fetch_balance(self) -> CcxtBalancePayload: ... + @dataclass(frozen=True, slots=True) class BybitTestnetAdapter: client: CcxtClientProtocol + quote_asset: str @classmethod def from_settings( @@ -77,7 +90,16 @@ def from_settings( message="Bybit adapter requires an injected CCXT client in milestone 1", ) client.set_sandbox_mode(True) - return cls(client=client) + return cls(client=client, quote_asset=settings.pairlist.quote_asset) + + async def fetch_balance(self) -> AccountSnapshot: + payload = await self.client.fetch_balance() + return AccountSnapshot( + captured_at=datetime.now(UTC), + equity=StakeAmount(_balance_amount(payload["total"], self.quote_asset)), + available=StakeAmount(_balance_amount(payload["free"], self.quote_asset)), + positions=(), + ) async def create_order(self, request: ExchangeOrderRequest) -> ExchangeOrder: payload = await self.client.create_order( @@ -132,7 +154,7 @@ def _request_from_payload(payload: CcxtOrderPayload, pair: TradingPair) -> Excha return ExchangeOrderRequest( pair=pair, side=_position_side(payload["side"]), - order_type=OrderType(payload["type"]), + order_type=_order_type_from_ccxt(payload["type"]), quantity=Quantity(Decimal(payload["amount"])), price=None if price is None else Price(Decimal(price)), leverage=Leverage.one(), @@ -150,27 +172,45 @@ def _ccxt_side(side: PositionSide) -> str: def _position_side(side: str) -> PositionSide: - match side: + match side.lower(): case "buy": return PositionSide.LONG case "sell": return PositionSide.SHORT case _: - return PositionSide.LONG + raise ExchangeError( + code=ExchangeErrorCode.ORDER_PAYLOAD_INVALID, + message=f"unknown ccxt order side: {side}", + ) def _state_from_ccxt(status: str) -> OrderState: - match status: + match status.lower(): case "closed": return OrderState.FILLED case "open": return OrderState.OPEN + case "partially_filled": + return OrderState.PARTIALLY_FILLED case "canceled": return OrderState.CANCELED case "rejected": return OrderState.REJECTED case _: - return OrderState.OPEN + raise ExchangeError( + code=ExchangeErrorCode.ORDER_PAYLOAD_INVALID, + message=f"unknown ccxt order status: {status}", + ) + + +def _order_type_from_ccxt(order_type: str) -> OrderType: + try: + return OrderType(order_type.lower()) + except ValueError as exc: + raise ExchangeError( + code=ExchangeErrorCode.ORDER_PAYLOAD_INVALID, + message=f"unknown ccxt order type: {order_type}", + ) from exc def _average_from_payload(payload: CcxtOrderPayload) -> Price | None: @@ -178,3 +218,22 @@ def _average_from_payload(payload: CcxtOrderPayload) -> Price | None: if average is None: return None return Price(Decimal(average)) + + +def _balance_amount(values: Mapping[str, DecimalInput | None], asset: str) -> Decimal: + raw_value = values.get(asset) + if raw_value is None: + return Decimal(0) + try: + amount = Decimal(str(raw_value)) + except InvalidOperation as exc: + raise ExchangeError( + code=ExchangeErrorCode.ORDER_PAYLOAD_INVALID, + message=f"invalid ccxt balance amount for {asset}", + ) from exc + if not amount.is_finite(): + raise ExchangeError( + code=ExchangeErrorCode.ORDER_PAYLOAD_INVALID, + message=f"non-finite ccxt balance amount for {asset}", + ) + return amount diff --git a/src/nfi_engine/exchange/candidate_profiles.py b/src/nfi_engine/exchange/candidate_profiles.py new file mode 100644 index 0000000..685446f --- /dev/null +++ b/src/nfi_engine/exchange/candidate_profiles.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from typing import Final + +from nfi_engine.domain import OrderType +from nfi_engine.exchange.capability_models import ( + ExchangeCapabilityProfile, + ExchangeSupportLevel, +) +from nfi_engine.exchange.profile_constants import ( + BYBIT_TESTNET_CHECKED_ON, + BYBIT_TESTNET_EVIDENCE, + CHECKED_ON, + DOC_EVIDENCE, + ISOLATED, + ISOLATED_CROSS, + KEY_SECRET, + KEY_SECRET_PASS, + MARKET_LIMIT, +) + +CANDIDATE_PROFILES: Final = ( + ExchangeCapabilityProfile( + exchange_id="binance", + display_name="Binance", + support_level=ExchangeSupportLevel.CANDIDATE, + supports_spot=True, + supports_futures=True, + margin_modes=ISOLATED_CROSS, + stoploss_order_types=MARKET_LIMIT, + supports_market_orders=True, + supports_testnet=True, + supports_sandbox=True, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET, + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, + ), + ExchangeCapabilityProfile( + exchange_id="bingx", + display_name="BingX", + support_level=ExchangeSupportLevel.CANDIDATE, + supports_spot=True, + supports_futures=False, + margin_modes=(), + stoploss_order_types=MARKET_LIMIT, + supports_market_orders=True, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET, + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, + ), + ExchangeCapabilityProfile( + exchange_id="bitmart", + display_name="Bitmart", + support_level=ExchangeSupportLevel.CANDIDATE, + supports_spot=True, + supports_futures=False, + margin_modes=(), + stoploss_order_types=(), + supports_market_orders=False, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET, + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, + ), + ExchangeCapabilityProfile( + exchange_id="bitget", + display_name="Bitget", + support_level=ExchangeSupportLevel.CANDIDATE, + supports_spot=True, + supports_futures=True, + margin_modes=ISOLATED, + stoploss_order_types=MARKET_LIMIT, + supports_market_orders=True, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET, + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, + ), + ExchangeCapabilityProfile( + exchange_id="bybit", + display_name="Bybit", + support_level=ExchangeSupportLevel.VERIFIED, + supports_spot=True, + supports_futures=True, + margin_modes=ISOLATED, + stoploss_order_types=MARKET_LIMIT, + supports_market_orders=True, + supports_testnet=True, + supports_sandbox=True, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET, + evidence=BYBIT_TESTNET_EVIDENCE, + checked_on=BYBIT_TESTNET_CHECKED_ON, + ), + ExchangeCapabilityProfile( + exchange_id="gateio", + display_name="Gate.io", + support_level=ExchangeSupportLevel.CANDIDATE, + supports_spot=True, + supports_futures=True, + margin_modes=ISOLATED, + stoploss_order_types=(OrderType.LIMIT,), + supports_market_orders=False, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET, + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, + ), + ExchangeCapabilityProfile( + exchange_id="htx", + display_name="HTX", + support_level=ExchangeSupportLevel.CANDIDATE, + supports_spot=True, + supports_futures=False, + margin_modes=(), + stoploss_order_types=(OrderType.LIMIT,), + supports_market_orders=False, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET, + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, + ), + ExchangeCapabilityProfile( + exchange_id="hyperliquid", + display_name="Hyperliquid", + support_level=ExchangeSupportLevel.CANDIDATE, + supports_spot=True, + supports_futures=True, + margin_modes=ISOLATED_CROSS, + stoploss_order_types=(OrderType.LIMIT,), + supports_market_orders=False, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=("api_key", "wallet_address", "exchange_secret"), + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, + ), + ExchangeCapabilityProfile( + exchange_id="kraken", + display_name="Kraken", + support_level=ExchangeSupportLevel.CANDIDATE, + supports_spot=True, + supports_futures=False, + margin_modes=(), + stoploss_order_types=MARKET_LIMIT, + supports_market_orders=True, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET, + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, + ), + ExchangeCapabilityProfile( + exchange_id="kraken-futures", + display_name="Kraken Futures", + support_level=ExchangeSupportLevel.CANDIDATE, + supports_spot=False, + supports_futures=True, + margin_modes=ISOLATED, + stoploss_order_types=MARKET_LIMIT, + supports_market_orders=True, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET, + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, + ), + ExchangeCapabilityProfile( + exchange_id="okx", + display_name="OKX", + support_level=ExchangeSupportLevel.CANDIDATE, + supports_spot=True, + supports_futures=True, + margin_modes=ISOLATED, + stoploss_order_types=(OrderType.LIMIT,), + supports_market_orders=False, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET_PASS, + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, + ), + ExchangeCapabilityProfile( + exchange_id="bitvavo", + display_name="Bitvavo", + support_level=ExchangeSupportLevel.CANDIDATE, + supports_spot=True, + supports_futures=False, + margin_modes=(), + stoploss_order_types=(), + supports_market_orders=False, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET, + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, + ), + ExchangeCapabilityProfile( + exchange_id="kucoin", + display_name="KuCoin", + support_level=ExchangeSupportLevel.CANDIDATE, + supports_spot=True, + supports_futures=False, + margin_modes=(), + stoploss_order_types=MARKET_LIMIT, + supports_market_orders=True, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET_PASS, + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, + ), +) diff --git a/src/nfi_engine/exchange/capabilities.py b/src/nfi_engine/exchange/capabilities.py new file mode 100644 index 0000000..470aba0 --- /dev/null +++ b/src/nfi_engine/exchange/capabilities.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Final + +from nfi_engine.exchange.capability_models import ( + ExchangeCapabilityProfile, + ExchangeSupportLevel, +) +from nfi_engine.exchange.seed_profiles import ALIASES, EXCHANGE_PROFILES + +PROFILE_BY_ID: Final = {profile.exchange_id: profile for profile in EXCHANGE_PROFILES} + +__all__ = [ + "ExchangeCapabilityProfile", + "ExchangeSupportLevel", + "get_exchange_profile", + "list_exchange_profiles", + "normalize_exchange_id", +] + + +def list_exchange_profiles() -> tuple[ExchangeCapabilityProfile, ...]: + return EXCHANGE_PROFILES + + +def get_exchange_profile(raw_exchange_id: str) -> ExchangeCapabilityProfile | None: + normalized = normalize_exchange_id(raw_exchange_id) + return PROFILE_BY_ID.get(normalized) + + +def normalize_exchange_id(raw_exchange_id: str) -> str: + normalized = raw_exchange_id.strip().lower() + return ALIASES.get(normalized, normalized) diff --git a/src/nfi_engine/exchange/capability_models.py b/src/nfi_engine/exchange/capability_models.py new file mode 100644 index 0000000..d8d37fe --- /dev/null +++ b/src/nfi_engine/exchange/capability_models.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from enum import StrEnum, unique +from typing import assert_never + +from nfi_engine.domain import MarginMode, OrderType, TradingMode + + +@unique +class ExchangeSupportLevel(StrEnum): + VERIFIED = "verified" + CANDIDATE = "candidate" + GENERIC_UNVERIFIED = "generic-unverified" + + +@dataclass(frozen=True, slots=True) +class ExchangeCapabilityProfile: + exchange_id: str + display_name: str + support_level: ExchangeSupportLevel + supports_spot: bool + supports_futures: bool + margin_modes: tuple[MarginMode, ...] + stoploss_order_types: tuple[OrderType, ...] + supports_market_orders: bool + supports_testnet: bool + supports_sandbox: bool + supports_trailing_stop: bool + supports_data_only: bool + credential_fields: tuple[str, ...] + evidence: str + checked_on: date + + def supports_trading_mode(self, trading_mode: TradingMode) -> bool: + match trading_mode: + case TradingMode.SPOT: + return self.supports_spot + case TradingMode.FUTURES: + return self.supports_futures + case unreachable: + assert_never(unreachable) + + def supports_margin_mode(self, margin_mode: MarginMode) -> bool: + return margin_mode in self.margin_modes diff --git a/src/nfi_engine/exchange/discovery.py b/src/nfi_engine/exchange/discovery.py new file mode 100644 index 0000000..15c7cfb --- /dev/null +++ b/src/nfi_engine/exchange/discovery.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum, unique +from typing import Final, TypedDict, assert_never + +from nfi_engine.domain import TradingMode +from nfi_engine.exchange.capabilities import get_exchange_profile, normalize_exchange_id +from nfi_engine.exchange.capability_models import ( + ExchangeCapabilityProfile, + ExchangeSupportLevel, +) +from nfi_engine.exchange.errors import ExchangeError, ExchangeErrorCode +from nfi_engine.exchange.profile_constants import CHECKED_ON, DOC_EVIDENCE + +MAX_EXCHANGE_ID_LENGTH: Final = 64 +ALLOWED_EXCHANGE_ID_CHARS: Final = frozenset( + "abcdefghijklmnopqrstuvwxyz0123456789._:-", +) + + +@unique +class ExchangeCapabilitySource(StrEnum): + REGISTRY = "registry" + GENERIC_DISCOVERY = "generic-discovery" + + +class ExchangeCapabilityPayload(TypedDict): + exchange_id: str + requested_exchange: str + display_name: str + source: str + support_level: str + verification_label: str + trading_mode: str + trading_mode_supported: bool + can_configure: bool + live_trading_allowed: bool + policy_block: str + supports_spot: bool + supports_futures: bool + margin_modes: list[str] + stoploss_order_types: list[str] + supports_market_orders: bool + supports_testnet: bool + supports_sandbox: bool + supports_trailing_stop: bool + supports_data_only: bool + credential_fields: list[str] + evidence: str + checked_on: str + + +@dataclass(frozen=True, slots=True) +class ExchangeCapabilityReport: + requested_exchange: str + profile: ExchangeCapabilityProfile + source: ExchangeCapabilitySource + trading_mode: TradingMode + trading_mode_supported: bool + can_configure: bool + live_trading_allowed: bool + policy_block: str + + def to_payload(self) -> ExchangeCapabilityPayload: + return { + "exchange_id": self.profile.exchange_id, + "requested_exchange": self.requested_exchange, + "display_name": self.profile.display_name, + "source": self.source.value, + "support_level": self.profile.support_level.value, + "verification_label": self.profile.support_level.value, + "trading_mode": self.trading_mode.value, + "trading_mode_supported": self.trading_mode_supported, + "can_configure": self.can_configure, + "live_trading_allowed": self.live_trading_allowed, + "policy_block": self.policy_block, + "supports_spot": self.profile.supports_spot, + "supports_futures": self.profile.supports_futures, + "margin_modes": [mode.value for mode in self.profile.margin_modes], + "stoploss_order_types": [ + order_type.value for order_type in self.profile.stoploss_order_types + ], + "supports_market_orders": self.profile.supports_market_orders, + "supports_testnet": self.profile.supports_testnet, + "supports_sandbox": self.profile.supports_sandbox, + "supports_trailing_stop": self.profile.supports_trailing_stop, + "supports_data_only": self.profile.supports_data_only, + "credential_fields": list(self.profile.credential_fields), + "evidence": self.profile.evidence, + "checked_on": self.profile.checked_on.isoformat(), + } + + +def build_exchange_capability_report( + *, + exchange_id: str, + trading_mode: TradingMode, +) -> ExchangeCapabilityReport: + normalized_exchange_id = parse_exchange_id(exchange_id) + profile = get_exchange_profile(normalized_exchange_id) + if profile is None: + generic_profile = _generic_unverified_profile(normalized_exchange_id) + return _report_for_profile( + requested_exchange=exchange_id, + profile=generic_profile, + source=ExchangeCapabilitySource.GENERIC_DISCOVERY, + trading_mode=trading_mode, + ) + return _report_for_profile( + requested_exchange=exchange_id, + profile=profile, + source=ExchangeCapabilitySource.REGISTRY, + trading_mode=trading_mode, + ) + + +def build_exchange_capability_document( + exchange_id: str, + trading_mode: TradingMode, +) -> ExchangeCapabilityPayload: + return build_exchange_capability_report( + exchange_id=exchange_id, + trading_mode=trading_mode, + ).to_payload() + + +def _report_for_profile( + *, + requested_exchange: str, + profile: ExchangeCapabilityProfile, + source: ExchangeCapabilitySource, + trading_mode: TradingMode, +) -> ExchangeCapabilityReport: + trading_mode_supported = profile.supports_trading_mode(trading_mode) + return ExchangeCapabilityReport( + requested_exchange=requested_exchange, + profile=profile, + source=source, + trading_mode=trading_mode, + trading_mode_supported=trading_mode_supported, + can_configure=_can_configure_profile( + profile=profile, + trading_mode_supported=trading_mode_supported, + ), + live_trading_allowed=False, + policy_block=_policy_block(profile), + ) + + +def parse_exchange_id(raw_exchange_id: str) -> str: + normalized = normalize_exchange_id(raw_exchange_id) + if normalized == "": + raise ExchangeError( + code=ExchangeErrorCode.EXCHANGE_ID_INVALID, + message="exchange id is required", + ) + if len(normalized) > MAX_EXCHANGE_ID_LENGTH: + raise ExchangeError( + code=ExchangeErrorCode.EXCHANGE_ID_INVALID, + message="exchange id must be 64 characters or fewer", + ) + if any(char not in ALLOWED_EXCHANGE_ID_CHARS for char in normalized): + raise ExchangeError( + code=ExchangeErrorCode.EXCHANGE_ID_INVALID, + message="exchange id contains unsupported characters", + ) + return normalized + + +def _generic_unverified_profile(exchange_id: str) -> ExchangeCapabilityProfile: + return ExchangeCapabilityProfile( + exchange_id=exchange_id, + display_name=exchange_id, + support_level=ExchangeSupportLevel.GENERIC_UNVERIFIED, + supports_spot=False, + supports_futures=False, + margin_modes=(), + stoploss_order_types=(), + supports_market_orders=False, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=(), + evidence=f"{DOC_EVIDENCE}#generic-unverified-path", + checked_on=CHECKED_ON, + ) + + +def _can_configure_profile( + *, + profile: ExchangeCapabilityProfile, + trading_mode_supported: bool, +) -> bool: + match profile.support_level: + case ExchangeSupportLevel.VERIFIED | ExchangeSupportLevel.CANDIDATE: + return trading_mode_supported + case ExchangeSupportLevel.GENERIC_UNVERIFIED: + return False + case unreachable: + assert_never(unreachable) + + +def _policy_block(profile: ExchangeCapabilityProfile) -> str: + match profile.support_level: + case ExchangeSupportLevel.VERIFIED: + return "live trading is blocked in current milestone" + case ExchangeSupportLevel.CANDIDATE: + return "candidate exchange requires local evidence before live promotion" + case ExchangeSupportLevel.GENERIC_UNVERIFIED: + return ( + "generic-unverified exchange requires an explicit registry profile " + "and local evidence before config, paper/testnet, or live promotion" + ) + case unreachable: + assert_never(unreachable) diff --git a/src/nfi_engine/exchange/errors.py b/src/nfi_engine/exchange/errors.py index 8b6ca72..605c8e7 100644 --- a/src/nfi_engine/exchange/errors.py +++ b/src/nfi_engine/exchange/errors.py @@ -8,7 +8,12 @@ @unique class ExchangeErrorCode(StrEnum): CCXT_CLIENT_REQUIRED = "CCXT_CLIENT_REQUIRED" + EXCHANGE_AUTH_FAILED = "EXCHANGE_AUTH_FAILED" + EXCHANGE_HTTP_ERROR = "EXCHANGE_HTTP_ERROR" + EXCHANGE_ID_INVALID = "EXCHANGE_ID_INVALID" + EXCHANGE_RESPONSE_INVALID = "EXCHANGE_RESPONSE_INVALID" LIVE_EXCHANGE_DISABLED_FOR_MILESTONE = "LIVE_EXCHANGE_DISABLED_FOR_MILESTONE" + ORDER_PAYLOAD_INVALID = "ORDER_PAYLOAD_INVALID" ORDER_NOT_FOUND = "ORDER_NOT_FOUND" TICK_NOT_FOUND = "TICK_NOT_FOUND" diff --git a/src/nfi_engine/exchange/permissions.py b/src/nfi_engine/exchange/permissions.py new file mode 100644 index 0000000..1422688 --- /dev/null +++ b/src/nfi_engine/exchange/permissions.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum, unique +from typing import assert_never + + +@unique +class ExchangeApiPermissionState(StrEnum): + ENABLED = "enabled" + DISABLED = "disabled" + UNKNOWN = "unknown" + NOT_APPLICABLE = "not_applicable" + + +@dataclass(frozen=True, slots=True) +class ExchangeApiPermissionAudit: + read: ExchangeApiPermissionState + trade: ExchangeApiPermissionState + futures: ExchangeApiPermissionState + withdrawal: ExchangeApiPermissionState + ip_allowlist: ExchangeApiPermissionState + + @property + def live_safe(self) -> bool: + return self.live_blocking_codes == () + + @property + def live_blocking_codes(self) -> tuple[str, ...]: + match self.withdrawal: + case ExchangeApiPermissionState.ENABLED: + return ("EXCHANGE_WITHDRAWAL_PERMISSION_ENABLED",) + case ( + ExchangeApiPermissionState.DISABLED + | ExchangeApiPermissionState.UNKNOWN + | ExchangeApiPermissionState.NOT_APPLICABLE + ): + return () + case unreachable: + assert_never(unreachable) + + @property + def diagnostic_codes(self) -> tuple[str, ...]: + return _unknown_diagnostics(self.withdrawal, self.ip_allowlist) + + @property + def summary(self) -> str: + return ( + f"read={self.read.value} trade={self.trade.value} futures={self.futures.value} " + f"withdrawal={self.withdrawal.value} ip_allowlist={self.ip_allowlist.value}" + ) + + +def audit_exchange_api_permissions( + *, + read: ExchangeApiPermissionState, + trade: ExchangeApiPermissionState, + futures: ExchangeApiPermissionState, + withdrawal: ExchangeApiPermissionState, + ip_allowlist: ExchangeApiPermissionState, +) -> ExchangeApiPermissionAudit: + return ExchangeApiPermissionAudit( + read=read, + trade=trade, + futures=futures, + withdrawal=withdrawal, + ip_allowlist=ip_allowlist, + ) + + +def _unknown_diagnostics( + withdrawal: ExchangeApiPermissionState, + ip_allowlist: ExchangeApiPermissionState, +) -> tuple[str, ...]: + diagnostics: list[str] = [] + match withdrawal: + case ExchangeApiPermissionState.UNKNOWN: + diagnostics.append("EXCHANGE_PERMISSION_WITHDRAWAL_UNKNOWN") + case ( + ExchangeApiPermissionState.ENABLED + | ExchangeApiPermissionState.DISABLED + | ExchangeApiPermissionState.NOT_APPLICABLE + ): + pass + case unreachable: + assert_never(unreachable) + match ip_allowlist: + case ExchangeApiPermissionState.UNKNOWN: + pass + case ( + ExchangeApiPermissionState.ENABLED + | ExchangeApiPermissionState.DISABLED + | ExchangeApiPermissionState.NOT_APPLICABLE + ): + pass + case unreachable: + assert_never(unreachable) + return tuple(diagnostics) diff --git a/src/nfi_engine/exchange/profile_constants.py b/src/nfi_engine/exchange/profile_constants.py new file mode 100644 index 0000000..d14c4bd --- /dev/null +++ b/src/nfi_engine/exchange/profile_constants.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from datetime import date +from typing import Final + +from nfi_engine.domain import MarginMode, OrderType + +CHECKED_ON: Final = date(2026, 6, 14) +DOC_EVIDENCE: Final = "docs/exchange-support-matrix.md" +SIMULATOR_EVIDENCE: Final = "tests/unit/exchange/test_simulator.py" +BYBIT_TESTNET_CHECKED_ON: Final = date(2026, 6, 21) +BYBIT_TESTNET_EVIDENCE: Final = "tests/integration/exchange/test_bybit_adapter.py" +KEY_SECRET: Final = ("api_key", "api_secret") +KEY_SECRET_PASS: Final = ("api_key", "api_secret", "passphrase") +MARKET_LIMIT: Final = (OrderType.MARKET, OrderType.LIMIT) +ISOLATED: Final = (MarginMode.ISOLATED,) +ISOLATED_CROSS: Final = (MarginMode.ISOLATED, MarginMode.CROSS) diff --git a/src/nfi_engine/exchange/seed_profiles.py b/src/nfi_engine/exchange/seed_profiles.py new file mode 100644 index 0000000..4dec82c --- /dev/null +++ b/src/nfi_engine/exchange/seed_profiles.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import Final + +from nfi_engine.exchange.candidate_profiles import CANDIDATE_PROFILES +from nfi_engine.exchange.capability_models import ( + ExchangeCapabilityProfile, + ExchangeSupportLevel, +) +from nfi_engine.exchange.profile_constants import ( + CHECKED_ON, + DOC_EVIDENCE, + ISOLATED_CROSS, + KEY_SECRET, + MARKET_LIMIT, + SIMULATOR_EVIDENCE, +) + +SIMULATOR_PROFILE: Final = ExchangeCapabilityProfile( + exchange_id="simulator", + display_name="Deterministic Simulator", + support_level=ExchangeSupportLevel.VERIFIED, + supports_spot=True, + supports_futures=True, + margin_modes=ISOLATED_CROSS, + stoploss_order_types=MARKET_LIMIT, + supports_market_orders=True, + supports_testnet=True, + supports_sandbox=True, + supports_trailing_stop=True, + supports_data_only=True, + credential_fields=(), + evidence=SIMULATOR_EVIDENCE, + checked_on=CHECKED_ON, +) + +GENERIC_CCXT_PROFILE: Final = ExchangeCapabilityProfile( + exchange_id="generic-ccxt", + display_name="Generic CCXT Probe", + support_level=ExchangeSupportLevel.GENERIC_UNVERIFIED, + supports_spot=False, + supports_futures=False, + margin_modes=(), + stoploss_order_types=(), + supports_market_orders=False, + supports_testnet=False, + supports_sandbox=False, + supports_trailing_stop=False, + supports_data_only=True, + credential_fields=KEY_SECRET, + evidence=DOC_EVIDENCE, + checked_on=CHECKED_ON, +) + +EXCHANGE_PROFILES: Final = (SIMULATOR_PROFILE, *CANDIDATE_PROFILES, GENERIC_CCXT_PROFILE) + +ALIASES: Final[dict[str, str]] = { + "gate.io": "gateio", + "gate-io": "gateio", + "kraken futures": "kraken-futures", + "kraken_futures": "kraken-futures", +} diff --git a/src/nfi_engine/maintenance/__init__.py b/src/nfi_engine/maintenance/__init__.py index 6a4840a..601d2f8 100644 --- a/src/nfi_engine/maintenance/__init__.py +++ b/src/nfi_engine/maintenance/__init__.py @@ -6,6 +6,15 @@ build_config_migration_plan, preview_config_rollback, ) +from nfi_engine.maintenance.data_lifecycle import ( + DataLifecycleExport, + DataLifecycleFootprint, + DataLifecyclePrunePolicy, + DataLifecyclePruneReceipt, + build_data_lifecycle_export, + build_data_lifecycle_footprint, + build_data_lifecycle_prune_receipt, +) from nfi_engine.maintenance.database import ( build_database_migration_plan, read_database_version, @@ -21,6 +30,14 @@ MaintenanceErrorCode, RollbackPlan, ) +from nfi_engine.maintenance.update_provenance import ( + UpdatePreview, + UpdateProofReceipt, + UpdateRollbackState, + build_update_apply_receipt, + build_update_preview, + build_update_rollback_receipt, +) __all__ = [ "BackupRestorePlan", @@ -28,13 +45,26 @@ "BackupVerification", "ConfigHistoryEntry", "ConfigMigrationPlan", + "DataLifecycleExport", + "DataLifecycleFootprint", + "DataLifecyclePrunePolicy", + "DataLifecyclePruneReceipt", "DatabaseMigrationPlan", "MaintenanceError", "MaintenanceErrorCode", "RollbackPlan", + "UpdatePreview", + "UpdateProofReceipt", + "UpdateRollbackState", "build_config_history", "build_config_migration_plan", + "build_data_lifecycle_export", + "build_data_lifecycle_footprint", + "build_data_lifecycle_prune_receipt", "build_database_migration_plan", + "build_update_apply_receipt", + "build_update_preview", + "build_update_rollback_receipt", "create_backup", "preview_backup_restore", "preview_config_rollback", diff --git a/src/nfi_engine/maintenance/backup.py b/src/nfi_engine/maintenance/backup.py index 0d817bb..589a466 100644 --- a/src/nfi_engine/maintenance/backup.py +++ b/src/nfi_engine/maintenance/backup.py @@ -4,14 +4,14 @@ from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import ClassVar, Final +from typing import Final +from urllib.parse import urlsplit, urlunsplit from zipfile import ZIP_DEFLATED, BadZipFile, ZipFile -from pydantic import BaseModel, ConfigDict - from nfi_engine import __version__ from nfi_engine.api.models import LogListResponse, config_current_response, initial_log_entries from nfi_engine.config import RuntimeSettings, load_runtime_settings +from nfi_engine.events import REDACTED_TEXT from nfi_engine.maintenance.config_migration import build_config_history from nfi_engine.maintenance.models import ( BackupRestorePlan, @@ -22,53 +22,24 @@ ) from nfi_engine.profiles import default_profile_name, get_operator_profile -CONFIG_NAME: Final = "config.json" -DATABASE_INFO_NAME: Final = "database.json" -DATABASE_NAME: Final = "database.sqlite" -DOCKER_NAME: Final = "docker.json" -LOGS_NAME: Final = "logs.json" -MANIFEST_NAME: Final = "manifest.json" -PROFILE_NAME: Final = "profile.json" -STRATEGY_NAME: Final = "strategy.json" -SQLITE_PREFIX: Final = "sqlite+aiosqlite:///" - - -class StrictBackupModel(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", frozen=True) - - -class BackupManifestPayload(StrictBackupModel): - engine_version: str - generated_at: datetime - redacted: bool - config_hash: str - dependency_lock_hash: str - files: tuple[str, ...] - checksums: dict[str, str] - - -class ProfilePayload(StrictBackupModel): - name: str - description: str - read_only: bool - - -class StrategyPayload(StrictBackupModel): - name: str - module: str - config_hash: str - dependency_lock_hash: str - - -class DatabasePayload(StrictBackupModel): - database_url: str - included: bool - archive_name: str | None - +from .backup_manifest import ( + CONFIG_NAME, + DATABASE_INFO_NAME, + DATABASE_NAME, + DOCKER_NAME, + LOGS_NAME, + MANIFEST_NAME, + PROFILE_NAME, + STRATEGY_NAME, + BackupManifestPayload, + DatabasePayload, + DockerPayload, + ProfilePayload, + StrategyPayload, + validate_backup_archive_names, +) -class DockerPayload(StrictBackupModel): - compose_present: bool - dockerfile_present: bool +SQLITE_PREFIX: Final = "sqlite+aiosqlite:///" @dataclass(frozen=True, slots=True) @@ -106,6 +77,7 @@ def verify_backup(archive: Path) -> BackupVerification: with ZipFile(archive) as opened: names = tuple(sorted(opened.namelist())) manifest = BackupManifestPayload.model_validate_json(opened.read(MANIFEST_NAME)) + validate_backup_archive_names(names=names, manifest=manifest) manifest_valid = _checksums_match(archive=opened, manifest=manifest) except (BadZipFile, KeyError, ValueError) as exc: raise MaintenanceError( @@ -121,11 +93,20 @@ def verify_backup(archive: Path) -> BackupVerification: def preview_backup_restore(*, archive: Path, dry_run: bool) -> BackupRestorePlan: + if not dry_run: + raise MaintenanceError( + code=MaintenanceErrorCode.BACKUP_RESTORE_APPLY_UNSUPPORTED, + message="mutating restore apply is not implemented; run restore with --dry-run", + ) verification = verify_backup(archive) - apply = not dry_run + if not verification.manifest_valid: + raise MaintenanceError( + code=MaintenanceErrorCode.BACKUP_INVALID, + message="backup archive manifest checksum verification failed", + ) return BackupRestorePlan( archive=str(archive), - apply=apply, + apply=False, manifest_valid=verification.manifest_valid, entries=verification.entries, steps=tuple(f"restore {entry}" for entry in verification.entries if entry != MANIFEST_NAME), @@ -200,7 +181,7 @@ def _database_member(settings: RuntimeSettings) -> DatabaseMembers: path = _sqlite_path(settings.database.url) included = path is not None and path.exists() info = DatabasePayload( - database_url=settings.database.url, + database_url=_redacted_database_url(settings.database.url), included=included, archive_name=DATABASE_NAME if included else None, ) @@ -213,6 +194,23 @@ def _database_member(settings: RuntimeSettings) -> DatabaseMembers: return DatabaseMembers(info=info_member, data=data) +def _redacted_database_url(database_url: str) -> str: + if database_url.startswith(SQLITE_PREFIX): + return database_url + parsed = urlsplit(database_url) + if parsed.scheme == "": + return database_url + netloc = parsed.netloc + if "@" in netloc: + _, host = netloc.rsplit("@", maxsplit=1) + netloc = f"{REDACTED_TEXT}@{host}" + query = REDACTED_TEXT if parsed.query else "" + if parsed.netloc == "" and database_url.startswith(f"{parsed.scheme}:///"): + suffix = f"?{query}" if query else "" + return f"{parsed.scheme}://{parsed.path}{suffix}" + return urlunsplit((parsed.scheme, netloc, parsed.path, query, "")) + + def _sqlite_path(database_url: str) -> Path | None: if not database_url.startswith(SQLITE_PREFIX): return None diff --git a/src/nfi_engine/maintenance/backup_manifest.py b/src/nfi_engine/maintenance/backup_manifest.py new file mode 100644 index 0000000..9925ba9 --- /dev/null +++ b/src/nfi_engine/maintenance/backup_manifest.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from datetime import datetime +from typing import ClassVar, Final + +from pydantic import BaseModel, ConfigDict + +CONFIG_NAME: Final = "config.json" +DATABASE_INFO_NAME: Final = "database.json" +DATABASE_NAME: Final = "database.sqlite" +DOCKER_NAME: Final = "docker.json" +LOGS_NAME: Final = "logs.json" +MANIFEST_NAME: Final = "manifest.json" +PROFILE_NAME: Final = "profile.json" +STRATEGY_NAME: Final = "strategy.json" +EXPECTED_BACKUP_MEMBERS: Final[frozenset[str]] = frozenset( + { + CONFIG_NAME, + DATABASE_INFO_NAME, + DATABASE_NAME, + DOCKER_NAME, + LOGS_NAME, + MANIFEST_NAME, + PROFILE_NAME, + STRATEGY_NAME, + }, +) +REQUIRED_BACKUP_MEMBERS: Final[frozenset[str]] = frozenset( + { + CONFIG_NAME, + DATABASE_INFO_NAME, + DOCKER_NAME, + LOGS_NAME, + MANIFEST_NAME, + PROFILE_NAME, + STRATEGY_NAME, + }, +) + + +class StrictBackupModel(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", frozen=True) + + +class BackupManifestPayload(StrictBackupModel): + engine_version: str + generated_at: datetime + redacted: bool + config_hash: str + dependency_lock_hash: str + files: tuple[str, ...] + checksums: dict[str, str] + + +class ProfilePayload(StrictBackupModel): + name: str + description: str + read_only: bool + + +class StrategyPayload(StrictBackupModel): + name: str + module: str + config_hash: str + dependency_lock_hash: str + + +class DatabasePayload(StrictBackupModel): + database_url: str + included: bool + archive_name: str | None + + +class DockerPayload(StrictBackupModel): + compose_present: bool + dockerfile_present: bool + + +def validate_backup_archive_names( + *, + names: tuple[str, ...], + manifest: BackupManifestPayload, +) -> None: + archive_names = set(names) + if len(archive_names) != len(names): + message = "backup archive contains duplicate members" + raise ValueError(message) + manifest_files = set(manifest.files) + checksum_names = set(manifest.checksums) + if manifest_files != checksum_names: + message = "backup manifest files do not match checksums" + raise ValueError(message) + if archive_names != (manifest_files | {MANIFEST_NAME}): + message = "backup archive members do not match manifest" + raise ValueError(message) + unsupported_names = archive_names - EXPECTED_BACKUP_MEMBERS + if unsupported_names: + joined = ",".join(sorted(unsupported_names)) + message = f"backup archive contains unsupported members: {joined}" + raise ValueError(message) + missing_names = REQUIRED_BACKUP_MEMBERS - archive_names + if missing_names: + joined = ",".join(sorted(missing_names)) + message = f"backup archive is missing required members: {joined}" + raise ValueError(message) diff --git a/src/nfi_engine/maintenance/data_lifecycle.py b/src/nfi_engine/maintenance/data_lifecycle.py new file mode 100644 index 0000000..8920d24 --- /dev/null +++ b/src/nfi_engine/maintenance/data_lifecycle.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import hashlib +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Final + +from nfi_engine import __version__ +from nfi_engine.api.models import config_current_response +from nfi_engine.config import RuntimeSettings +from nfi_engine.maintenance.data_lifecycle_paths import ( + build_lifecycle_roots, + redacted_database_url, + scan_lifecycle_categories, +) +from nfi_engine.maintenance.data_lifecycle_types import ( + DataLifecycleCategoryName, + DataLifecycleExport, + DataLifecycleFile, + DataLifecycleFootprint, + DataLifecycleItemStatus, + DataLifecycleProfilePayload, + DataLifecyclePrunePolicy, + DataLifecyclePruneReceipt, +) + +EXPORT_RECEIPT_PREFIX: Final = "data-export-" +PRUNE_RECEIPT_PREFIX: Final = "data-prune-" +TOKEN_BYTES: Final = 12 +DATA_LIFECYCLE_CONFIRM_SCOPE: Final = "DELETE_GENERATED_LOCAL_ARTIFACTS" +DATA_LIFECYCLE_CONFIRMATION_REQUIRED: Final = "DATA_LIFECYCLE_CONFIRMATION_REQUIRED" + +__all__ = [ + "DATA_LIFECYCLE_CONFIRMATION_REQUIRED", + "DATA_LIFECYCLE_CONFIRM_SCOPE", + "DataLifecycleExport", + "DataLifecycleFootprint", + "DataLifecyclePrunePolicy", + "DataLifecyclePruneReceipt", + "build_data_lifecycle_export", + "build_data_lifecycle_footprint", + "build_data_lifecycle_prune_receipt", +] + + +def build_data_lifecycle_footprint( + *, + settings: RuntimeSettings, + config_path: Path | None, + workspace_root: Path, +) -> DataLifecycleFootprint: + roots = build_lifecycle_roots(settings=settings, workspace_root=workspace_root) + categories = scan_lifecycle_categories(roots=roots) + return DataLifecycleFootprint( + generated_at=datetime.now(UTC), + config_source=_config_source(config_path), + total_bytes=sum(category.total_bytes for category in categories), + categories=categories, + ) + + +def build_data_lifecycle_export( + *, + settings: RuntimeSettings, + config_path: Path | None, + workspace_root: Path, +) -> DataLifecycleExport: + footprint = build_data_lifecycle_footprint( + settings=settings, + config_path=config_path, + workspace_root=workspace_root, + ) + redacted_config_json = config_current_response(settings).model_dump_json(indent=2) + profile_json = DataLifecycleProfilePayload( + engine_version=__version__, + environment=settings.engine.environment, + locale=settings.ui.locale.value, + read_only=settings.ui.read_only, + exchange_name=settings.exchange.name, + trading_mode=settings.exchange.trading_mode.value, + testnet=settings.exchange.testnet, + strategy_name=settings.strategy.name, + strategy_module=settings.strategy.module, + config_source=footprint.config_source, + database_url=redacted_database_url(settings.database.url), + footprint_total_bytes=footprint.total_bytes, + ).model_dump_json(indent=2) + return DataLifecycleExport( + receipt_id=_receipt_id(EXPORT_RECEIPT_PREFIX, redacted_config_json, profile_json), + generated_at=datetime.now(UTC), + redacted_config_json=redacted_config_json, + redacted_profile_json=profile_json, + footprint=footprint, + ) + + +def build_data_lifecycle_prune_receipt( + *, + settings: RuntimeSettings, + config_path: Path | None, + workspace_root: Path, + policy: DataLifecyclePrunePolicy, +) -> DataLifecyclePruneReceipt: + footprint = build_data_lifecycle_footprint( + settings=settings, + config_path=config_path, + workspace_root=workspace_root, + ) + planned = _planned_items(footprint, policy) + candidates = tuple(item for item in planned if item.status is DataLifecycleItemStatus.CANDIDATE) + token = _preview_token(policy=policy, items=candidates) + blocked = _blocked_reasons(policy=policy, expected_token=token) + removed = ( + () + if blocked or policy.dry_run or not policy.apply + else tuple(_remove_candidate(item) for item in candidates) + ) + deleted_count = sum(1 for item in removed if item.status is DataLifecycleItemStatus.REMOVED) + bytes_deleted = sum(item.size_bytes for item in removed) + return DataLifecyclePruneReceipt( + receipt_id=_receipt_id(PRUNE_RECEIPT_PREFIX, token, str(datetime.now(UTC).timestamp())), + accepted=not blocked, + dry_run=policy.dry_run, + apply=policy.apply, + mutation_applied=deleted_count > 0, + retention_days=policy.retention_days, + preview_token=token, + candidate_count=len(candidates), + deleted_count=deleted_count, + protected_count=sum( + 1 for item in planned if item.status is DataLifecycleItemStatus.PROTECTED + ), + skipped_count=sum(1 for item in planned if item.status is DataLifecycleItemStatus.SKIPPED), + bytes_reclaimable=sum(item.size_bytes for item in candidates), + bytes_deleted=bytes_deleted, + blocked_reasons=blocked, + items=removed or planned, + ) + + +def _planned_items( + footprint: DataLifecycleFootprint, + policy: DataLifecyclePrunePolicy, +) -> tuple[DataLifecycleFile, ...]: + cutoff = datetime.now(UTC) - timedelta(days=policy.retention_days) + return tuple( + _planned_item(item, cutoff=cutoff) + for category in footprint.categories + for item in category.items + ) + + +def _planned_item(item: DataLifecycleFile, *, cutoff: datetime) -> DataLifecycleFile: + if item.status is DataLifecycleItemStatus.MISSING: + return item + if item.category is DataLifecycleCategoryName.SQLITE: + return _replace_item(item, status=DataLifecycleItemStatus.PROTECTED, reason="active_sqlite") + if item.reason == "unsafe_path": + return item + if item.modified_at is None or item.modified_at > cutoff: + return _replace_item( + item, + status=DataLifecycleItemStatus.SKIPPED, + reason="within_retention", + ) + return _replace_item(item, status=DataLifecycleItemStatus.CANDIDATE, reason="older_than_policy") + + +def _remove_candidate(item: DataLifecycleFile) -> DataLifecycleFile: + path = Path(item.path) + try: + path.unlink() + except FileNotFoundError: + return _replace_item(item, status=DataLifecycleItemStatus.MISSING, reason="already_missing") + except PermissionError: + return _replace_item( + item, + status=DataLifecycleItemStatus.SKIPPED, + reason="permission_denied", + ) + except OSError: + return _replace_item(item, status=DataLifecycleItemStatus.SKIPPED, reason="remove_failed") + return _replace_item(item, status=DataLifecycleItemStatus.REMOVED, reason="removed") + + +def _replace_item( + item: DataLifecycleFile, + *, + status: DataLifecycleItemStatus, + reason: str, +) -> DataLifecycleFile: + return DataLifecycleFile( + category=item.category, + path=item.path, + size_bytes=item.size_bytes, + modified_at=item.modified_at, + status=status, + reason=reason, + ) + + +def _blocked_reasons( + *, + policy: DataLifecyclePrunePolicy, + expected_token: str, +) -> tuple[str, ...]: + if not policy.apply or policy.dry_run: + return () + reasons: list[str] = [] + if policy.preview_token != expected_token: + reasons.append("preview_token_required") + if policy.confirm_scope != DATA_LIFECYCLE_CONFIRM_SCOPE: + reasons.append(DATA_LIFECYCLE_CONFIRMATION_REQUIRED) + return tuple(reasons) + + +def _preview_token( + *, + policy: DataLifecyclePrunePolicy, + items: tuple[DataLifecycleFile, ...], +) -> str: + digest = hashlib.sha256(str(policy.retention_days).encode()) + for item in items: + digest.update(item.path.encode()) + digest.update(str(item.size_bytes).encode()) + digest.update((item.modified_at.isoformat() if item.modified_at else "").encode()) + return digest.hexdigest()[:TOKEN_BYTES] + + +def _receipt_id(prefix: str, *parts: str) -> str: + digest = hashlib.sha256() + for part in parts: + digest.update(part.encode()) + return f"{prefix}{digest.hexdigest()[:TOKEN_BYTES]}" + + +def _config_source(config_path: Path | None) -> str: + if config_path is None: + return "runtime" + return str(config_path) diff --git a/src/nfi_engine/maintenance/data_lifecycle_paths.py b/src/nfi_engine/maintenance/data_lifecycle_paths.py new file mode 100644 index 0000000..9e4a662 --- /dev/null +++ b/src/nfi_engine/maintenance/data_lifecycle_paths.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from typing import Final +from urllib.parse import urlsplit, urlunsplit + +from nfi_engine.config import RuntimeSettings +from nfi_engine.events import REDACTED_TEXT +from nfi_engine.maintenance.data_lifecycle_types import ( + DataLifecycleCategoryFootprint, + DataLifecycleCategoryName, + DataLifecycleFile, + DataLifecycleItemStatus, + DataLifecycleRoots, +) + +SQLITE_PREFIX: Final = "sqlite+aiosqlite:///" +SCAN_CATEGORY_ITEM_LIMIT: Final = 500 +SCAN_TRUNCATED_REASON: Final = "scan_truncated" + + +def build_lifecycle_roots( + *, + settings: RuntimeSettings, + workspace_root: Path, +) -> DataLifecycleRoots: + sqlite = sqlite_path(settings.database.url, workspace_root=workspace_root) + runtime = workspace_root / "data" if sqlite is None else sqlite.parent + evidence = path_from_text( + settings.notifications.jsonl_path, + workspace_root=workspace_root, + ).parent + return DataLifecycleRoots( + sqlite=sqlite, + runtime=runtime, + logs=runtime / "logs", + backups=runtime / "backups", + support_bundles=runtime / "support-bundles", + evidence=evidence, + ) + + +def scan_lifecycle_categories( + *, + roots: DataLifecycleRoots, +) -> tuple[DataLifecycleCategoryFootprint, ...]: + return ( + sqlite_category(roots), + scan_category(DataLifecycleCategoryName.LOGS, roots.logs, roots.runtime), + scan_category(DataLifecycleCategoryName.BACKUPS, roots.backups, roots.runtime), + scan_category( + DataLifecycleCategoryName.SUPPORT_BUNDLES, + roots.support_bundles, + roots.runtime, + ), + scan_category(DataLifecycleCategoryName.EVIDENCE, roots.evidence, roots.runtime), + ) + + +def sqlite_category(roots: DataLifecycleRoots) -> DataLifecycleCategoryFootprint: + root = roots.runtime + if roots.sqlite is None: + return category(DataLifecycleCategoryName.SQLITE, root, ()) + items = tuple( + existing_file_item(DataLifecycleCategoryName.SQLITE, path, root, protected=True) + for path in (roots.sqlite, Path(f"{roots.sqlite}-wal"), Path(f"{roots.sqlite}-shm")) + if path.exists() + ) + missing = ( + () + if items + else ( + DataLifecycleFile( + category=DataLifecycleCategoryName.SQLITE, + path=str(roots.sqlite), + size_bytes=0, + modified_at=None, + status=DataLifecycleItemStatus.MISSING, + reason="sqlite_missing", + ), + ) + ) + return category(DataLifecycleCategoryName.SQLITE, root, items + missing) + + +def scan_category( + name: DataLifecycleCategoryName, + root: Path, + allowed_root: Path, +) -> DataLifecycleCategoryFootprint: + if not root.exists(): + return category(name, root, ()) + items: list[DataLifecycleFile] = [] + truncated = False + for path in root.rglob("*"): + if not path.is_file() and not path.is_symlink(): + continue + if len(items) >= SCAN_CATEGORY_ITEM_LIMIT: + truncated = True + break + items.append(existing_file_item(name, path, allowed_root, protected=False)) + if truncated: + items.append(scan_truncated_item(name, root)) + return category(name, root, tuple(items)) + + +def scan_truncated_item( + category_name: DataLifecycleCategoryName, + root: Path, +) -> DataLifecycleFile: + return DataLifecycleFile( + category=category_name, + path=str(root), + size_bytes=0, + modified_at=None, + status=DataLifecycleItemStatus.SKIPPED, + reason=SCAN_TRUNCATED_REASON, + ) + + +def existing_file_item( + category_name: DataLifecycleCategoryName, + path: Path, + allowed_root: Path, + *, + protected: bool, +) -> DataLifecycleFile: + resolved = path.resolve(strict=False) + if not resolved.is_relative_to(allowed_root.resolve(strict=False)): + return DataLifecycleFile( + category=category_name, + path=str(path), + size_bytes=0, + modified_at=None, + status=DataLifecycleItemStatus.PROTECTED, + reason="unsafe_path", + ) + try: + stat = path.stat() + except OSError: + return DataLifecycleFile( + category=category_name, + path=str(path), + size_bytes=0, + modified_at=None, + status=DataLifecycleItemStatus.SKIPPED, + reason="stat_failed", + ) + return DataLifecycleFile( + category=category_name, + path=str(path), + size_bytes=stat.st_size, + modified_at=datetime.fromtimestamp(stat.st_mtime, UTC), + status=DataLifecycleItemStatus.PROTECTED if protected else DataLifecycleItemStatus.SKIPPED, + reason="protected_runtime_data" if protected else "retention_not_evaluated", + ) + + +def category( + name: DataLifecycleCategoryName, + root: Path, + items: tuple[DataLifecycleFile, ...], +) -> DataLifecycleCategoryFootprint: + return DataLifecycleCategoryFootprint( + name=name, + root=str(root), + file_count=sum( + 1 + for item in items + if item.status is not DataLifecycleItemStatus.MISSING + and item.reason != SCAN_TRUNCATED_REASON + ), + total_bytes=sum(item.size_bytes for item in items), + items=items, + ) + + +def sqlite_path(database_url: str, *, workspace_root: Path) -> Path | None: + if not database_url.startswith(SQLITE_PREFIX): + return None + path_text = database_url.removeprefix(SQLITE_PREFIX) + if path_text in {"", ":memory:"}: + return None + return path_from_text(path_text, workspace_root=workspace_root) + + +def path_from_text(path_text: str, *, workspace_root: Path) -> Path: + path = Path(path_text) + if path.is_absolute(): + return path + return workspace_root / path + + +def redacted_database_url(database_url: str) -> str: + if database_url.startswith(SQLITE_PREFIX): + return database_url + parsed = urlsplit(database_url) + if parsed.scheme == "": + return database_url + netloc = parsed.netloc + if "@" in netloc: + _, host = netloc.rsplit("@", maxsplit=1) + netloc = f"{REDACTED_TEXT}@{host}" + query = REDACTED_TEXT if parsed.query else "" + return urlunsplit((parsed.scheme, netloc, parsed.path, query, "")) diff --git a/src/nfi_engine/maintenance/data_lifecycle_types.py b/src/nfi_engine/maintenance/data_lifecycle_types.py new file mode 100644 index 0000000..ac0590d --- /dev/null +++ b/src/nfi_engine/maintenance/data_lifecycle_types.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum, unique +from pathlib import Path +from typing import ClassVar + +from pydantic import BaseModel, ConfigDict + + +@unique +class DataLifecycleCategoryName(StrEnum): + SQLITE = "sqlite" + LOGS = "logs" + BACKUPS = "backups" + SUPPORT_BUNDLES = "support_bundles" + EVIDENCE = "evidence" + + +@unique +class DataLifecycleItemStatus(StrEnum): + CANDIDATE = "candidate" + PROTECTED = "protected" + REMOVED = "removed" + SKIPPED = "skipped" + MISSING = "missing" + + +@dataclass(frozen=True, slots=True) +class DataLifecycleFile: + category: DataLifecycleCategoryName + path: str + size_bytes: int + modified_at: datetime | None + status: DataLifecycleItemStatus + reason: str + + +@dataclass(frozen=True, slots=True) +class DataLifecycleCategoryFootprint: + name: DataLifecycleCategoryName + root: str + file_count: int + total_bytes: int + items: tuple[DataLifecycleFile, ...] + + +@dataclass(frozen=True, slots=True) +class DataLifecycleFootprint: + generated_at: datetime + config_source: str + total_bytes: int + categories: tuple[DataLifecycleCategoryFootprint, ...] + + +@dataclass(frozen=True, slots=True) +class DataLifecycleExport: + receipt_id: str + generated_at: datetime + redacted_config_json: str + redacted_profile_json: str + footprint: DataLifecycleFootprint + + +@dataclass(frozen=True, slots=True) +class DataLifecyclePrunePolicy: + retention_days: int = 7 + dry_run: bool = True + apply: bool = False + preview_token: str | None = None + confirm_scope: str | None = None + + +@dataclass(frozen=True, slots=True) +class DataLifecyclePruneReceipt: + receipt_id: str + accepted: bool + dry_run: bool + apply: bool + mutation_applied: bool + retention_days: int + preview_token: str + candidate_count: int + deleted_count: int + protected_count: int + skipped_count: int + bytes_reclaimable: int + bytes_deleted: int + blocked_reasons: tuple[str, ...] + items: tuple[DataLifecycleFile, ...] + + +class DataLifecycleProfilePayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", frozen=True) + + engine_version: str + environment: str + locale: str + read_only: bool + exchange_name: str + trading_mode: str + testnet: bool + strategy_name: str + strategy_module: str + config_source: str + database_url: str + footprint_total_bytes: int + + +@dataclass(frozen=True, slots=True) +class DataLifecycleRoots: + sqlite: Path | None + runtime: Path + logs: Path + backups: Path + support_bundles: Path + evidence: Path diff --git a/src/nfi_engine/maintenance/models.py b/src/nfi_engine/maintenance/models.py index b43e682..6b03df8 100644 --- a/src/nfi_engine/maintenance/models.py +++ b/src/nfi_engine/maintenance/models.py @@ -8,6 +8,7 @@ @unique class MaintenanceErrorCode(StrEnum): BACKUP_INVALID = "BACKUP_INVALID" + BACKUP_RESTORE_APPLY_UNSUPPORTED = "BACKUP_RESTORE_APPLY_UNSUPPORTED" BACKUP_REQUIRED = "BACKUP_REQUIRED" CONFIG_VERSION_UNSUPPORTED = "CONFIG_VERSION_UNSUPPORTED" DATABASE_NOT_READABLE = "DATABASE_NOT_READABLE" diff --git a/src/nfi_engine/maintenance/update_provenance.py b/src/nfi_engine/maintenance/update_provenance.py new file mode 100644 index 0000000..61e7f21 --- /dev/null +++ b/src/nfi_engine/maintenance/update_provenance.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from importlib.util import find_spec +from pathlib import Path +from typing import Final + +from nfi_engine import __version__ +from nfi_engine.api.models import config_current_response +from nfi_engine.config import RuntimeSettings +from nfi_engine.maintenance.update_workspace import ( + WORKSPACE_STATE_DIRTY, + detect_update_workspace_state, +) + +UNAVAILABLE_DIGEST: Final = "unavailable" +RUNTIME_REDACTED_SOURCE: Final = "runtime_redacted" +SOURCE_MUTATED: Final = False +REMOTE_NETWORK_ALLOWED: Final = False +RESTART_REQUIRED: Final = False +RELOAD_REQUIRED: Final = False +HASH_CHUNK_SIZE: Final = 1024 * 1024 +UPDATE_SOURCE_LOCAL_PROOF: Final = "local_proof" + + +@dataclass(frozen=True, slots=True) +class UpdateRollbackState: + status: str + can_rollback: bool + backup_reference_required: bool + + +@dataclass(frozen=True, slots=True) +class UpdateProofPolicy: + backup_reference: str | None + acknowledge_unverified: bool + allow_dirty_worktree: bool + update_source: str + + +@dataclass(frozen=True, slots=True) +class UpdatePreview: + engine_version: str + strategy_name: str + strategy_module: str + strategy_digest: str + strategy_source: str + config_digest: str + config_source: str + dependency_lock_digest: str + dependency_lock_source: str + remote_network_allowed: bool + compatibility_status: str + provenance_verified: bool + live_blocked: bool + workspace_state: str + workspace_dirty: bool + rollback_state: UpdateRollbackState + + +@dataclass(frozen=True, slots=True) +class UpdateProofReceipt: + action: str + accepted: bool + proof_only: bool + mutation_applied: bool + source_mutated: bool + remote_network_allowed: bool + restart_required: bool + reload_required: bool + backup_reference: str | None + acknowledge_unverified: bool + allow_dirty_worktree: bool + update_source: str + provenance_verified: bool + live_blocked: bool + workspace_state: str + workspace_dirty: bool + compatibility_status: str + blocked_reasons: tuple[str, ...] + + +def build_update_preview( + *, + settings: RuntimeSettings, + config_path: Path | None, + workspace_root: Path, +) -> UpdatePreview: + strategy_source, strategy_digest = _strategy_provenance(settings) + config_source, config_digest = _config_provenance(settings=settings, config_path=config_path) + lock_source, lock_digest = _dependency_lock_provenance(workspace_root) + workspace_state = detect_update_workspace_state(workspace_root) + provenance_verified = ( + strategy_digest != UNAVAILABLE_DIGEST and config_source != RUNTIME_REDACTED_SOURCE + ) + compatibility_status = _compatibility_status( + live_trading=settings.engine.live_trading, + provenance_verified=provenance_verified, + ) + return UpdatePreview( + engine_version=__version__, + strategy_name=settings.strategy.name, + strategy_module=settings.strategy.module, + strategy_digest=strategy_digest, + strategy_source=strategy_source, + config_digest=config_digest, + config_source=config_source, + dependency_lock_digest=lock_digest, + dependency_lock_source=lock_source, + remote_network_allowed=REMOTE_NETWORK_ALLOWED, + compatibility_status=compatibility_status, + provenance_verified=provenance_verified, + live_blocked=settings.engine.live_trading or not provenance_verified, + workspace_state=workspace_state, + workspace_dirty=workspace_state == WORKSPACE_STATE_DIRTY, + rollback_state=UpdateRollbackState( + status="backup_required", + can_rollback=False, + backup_reference_required=True, + ), + ) + + +def build_update_apply_receipt( + *, + preview: UpdatePreview, + policy: UpdateProofPolicy, +) -> UpdateProofReceipt: + return _build_receipt( + action="apply", + preview=preview, + policy=policy, + ) + + +def build_update_rollback_receipt( + *, + preview: UpdatePreview, + policy: UpdateProofPolicy, +) -> UpdateProofReceipt: + return _build_receipt( + action="rollback", + preview=preview, + policy=policy, + ) + + +def _build_receipt( + *, + action: str, + preview: UpdatePreview, + policy: UpdateProofPolicy, +) -> UpdateProofReceipt: + blocked_reasons = _blocked_reasons( + preview=preview, + policy=policy, + ) + return UpdateProofReceipt( + action=action, + accepted=len(blocked_reasons) == 0, + proof_only=True, + mutation_applied=False, + source_mutated=SOURCE_MUTATED, + remote_network_allowed=REMOTE_NETWORK_ALLOWED, + restart_required=RESTART_REQUIRED, + reload_required=RELOAD_REQUIRED, + backup_reference=_normalized_backup_reference(policy.backup_reference), + acknowledge_unverified=policy.acknowledge_unverified, + allow_dirty_worktree=policy.allow_dirty_worktree, + update_source=_normalized_update_source(policy.update_source), + provenance_verified=preview.provenance_verified, + live_blocked=preview.live_blocked, + workspace_state=preview.workspace_state, + workspace_dirty=preview.workspace_dirty, + compatibility_status=preview.compatibility_status, + blocked_reasons=blocked_reasons, + ) + + +def _blocked_reasons( + *, + preview: UpdatePreview, + policy: UpdateProofPolicy, +) -> tuple[str, ...]: + reasons: list[str] = [] + if _normalized_update_source(policy.update_source) != UPDATE_SOURCE_LOCAL_PROOF: + reasons.append("invalid_update_source") + if _normalized_backup_reference(policy.backup_reference) is None: + reasons.append("backup_reference_required") + if not preview.provenance_verified and not policy.acknowledge_unverified: + reasons.append("acknowledge_unverified_required") + if preview.workspace_dirty and not policy.allow_dirty_worktree: + reasons.append("workspace_dirty") + if preview.compatibility_status == "live_unsafe": + reasons.append("live_unsafe") + return tuple(reasons) + + +def _strategy_provenance(settings: RuntimeSettings) -> tuple[str, str]: + module_name = settings.strategy.module.split(":", maxsplit=1)[0] + spec = find_spec(module_name) + if spec is None or spec.origin is None: + return ("unresolved", UNAVAILABLE_DIGEST) + strategy_path = Path(spec.origin) + if not strategy_path.exists(): + return ("unresolved", UNAVAILABLE_DIGEST) + return (str(strategy_path), _sha256_path(strategy_path)) + + +def _config_provenance( + *, + settings: RuntimeSettings, + config_path: Path | None, +) -> tuple[str, str]: + if config_path is None: + payload = config_current_response(settings).model_dump_json().encode("utf-8") + return (RUNTIME_REDACTED_SOURCE, hashlib.sha256(payload).hexdigest()) + return (str(config_path), _sha256_path(config_path)) + + +def _dependency_lock_provenance(workspace_root: Path) -> tuple[str, str]: + for candidate in ("uv.lock", "package-lock.json"): + path = workspace_root / candidate + if path.exists(): + return (candidate, _sha256_path(path)) + return ("unavailable", UNAVAILABLE_DIGEST) + + +def _compatibility_status(*, live_trading: bool, provenance_verified: bool) -> str: + if live_trading: + return "live_unsafe" + if provenance_verified: + return "local_verified" + return "unverified_local" + + +def _normalized_backup_reference(backup_reference: str | None) -> str | None: + if backup_reference is None: + return None + normalized = backup_reference.strip() + if normalized == "": + return None + return normalized + + +def _normalized_update_source(update_source: str) -> str: + return update_source.strip() + + +def _sha256_path(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as opened: + for chunk in iter(lambda: opened.read(HASH_CHUNK_SIZE), b""): + digest.update(chunk) + return digest.hexdigest() diff --git a/src/nfi_engine/maintenance/update_workspace.py b/src/nfi_engine/maintenance/update_workspace.py new file mode 100644 index 0000000..88a00f1 --- /dev/null +++ b/src/nfi_engine/maintenance/update_workspace.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pathlib import Path +from subprocess import TimeoutExpired, run +from typing import Final + +WORKSPACE_STATE_CLEAN: Final = "clean" +WORKSPACE_STATE_DIRTY: Final = "dirty" +WORKSPACE_STATE_UNAVAILABLE: Final = "unavailable" + +_GIT_STATUS_TIMEOUT_SECONDS: Final = 5 +_UPDATE_STATUS_PATHS: Final = ( + "src", + "tests", + "docs", + "examples", + "scripts", + "README.md", + "pyproject.toml", + "uv.lock", + "package.json", + "package-lock.json", + "compose.yaml", + "Dockerfile", +) + + +def detect_update_workspace_state(workspace_root: Path) -> str: + if not (workspace_root / ".git").exists(): + return WORKSPACE_STATE_UNAVAILABLE + command = ( + "git", + "status", + "--porcelain=v1", + "--untracked-files=normal", + "--", + *_UPDATE_STATUS_PATHS, + ) + try: + completed = run( # noqa: S603 - fixed git status argv, no shell. + command, + cwd=workspace_root, + text=True, + capture_output=True, + check=False, + timeout=_GIT_STATUS_TIMEOUT_SECONDS, + ) + except (FileNotFoundError, TimeoutExpired): + return WORKSPACE_STATE_UNAVAILABLE + if completed.returncode != 0: + return WORKSPACE_STATE_UNAVAILABLE + if completed.stdout.strip(): + return WORKSPACE_STATE_DIRTY + return WORKSPACE_STATE_CLEAN diff --git a/src/nfi_engine/paper/breakers.py b/src/nfi_engine/paper/breakers.py new file mode 100644 index 0000000..c7ee6cc --- /dev/null +++ b/src/nfi_engine/paper/breakers.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Final + +from nfi_engine.circuit_breakers import ( + CircuitBreakerDecision, + CircuitBreakerSnapshot, + evaluate_circuit_breakers, +) +from nfi_engine.circuit_breakers import ( + policy_from_runtime as circuit_policy_from_runtime, +) +from nfi_engine.domain import StakeAmount +from nfi_engine.paper.models import PaperRunRequest, PaperTick + +ZERO: Final = Decimal(0) + + +def breaker_decision( + *, + request: PaperRunRequest, + previous_tick: PaperTick | None, + current_tick: PaperTick, +) -> CircuitBreakerDecision: + latest_tick_at = current_tick.at if previous_tick is None else previous_tick.at + return evaluate_circuit_breakers( + policy=circuit_policy_from_runtime(request.settings), + snapshot=CircuitBreakerSnapshot( + realized_pnl_today=ZERO, + equity_start=StakeAmount(Decimal(1000)), + equity_current=StakeAmount(Decimal(1000)), + consecutive_losses=0, + latest_tick_at=latest_tick_at, + current_time=current_tick.at, + api_error_count=0, + observed_slippage_pct=ZERO, + funding_rate=ZERO, + manual_halt=False, + rejected_order_count=0, + ), + ) + + +def trading_halted(decision: CircuitBreakerDecision | None) -> bool: + if decision is None: + return False + return decision.trading_halted + + +def first_breaker(decision: CircuitBreakerDecision | None) -> str | None: + if decision is None: + return None + if len(decision.triggered) == 0: + return None + return decision.triggered[0].kind.value + + +def protection_reasons(decision: CircuitBreakerDecision) -> tuple[str, ...]: + return tuple(trigger.kind.value for trigger in decision.triggered) diff --git a/src/nfi_engine/paper/errors.py b/src/nfi_engine/paper/errors.py index 04f5608..d6eead0 100644 --- a/src/nfi_engine/paper/errors.py +++ b/src/nfi_engine/paper/errors.py @@ -8,7 +8,9 @@ @unique class PaperErrorCode(StrEnum): LIVE_EXCHANGE_DISABLED_FOR_MILESTONE = "LIVE_EXCHANGE_DISABLED_FOR_MILESTONE" + PREFLIGHT_BLOCKED = "PREFLIGHT_BLOCKED" TICK_PARSE_ERROR = "TICK_PARSE_ERROR" + WALLET_BALANCE_UNAVAILABLE = "WALLET_BALANCE_UNAVAILABLE" @dataclass(frozen=True, slots=True) diff --git a/src/nfi_engine/paper/execution.py b/src/nfi_engine/paper/execution.py new file mode 100644 index 0000000..d796379 --- /dev/null +++ b/src/nfi_engine/paper/execution.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from nfi_engine.circuit_breakers import ( + CircuitBreakerDecision, +) +from nfi_engine.exchange.simulator import DeterministicExchangeSimulator +from nfi_engine.paper.breakers import ( + breaker_decision, + first_breaker, + protection_reasons, + trading_halted, +) +from nfi_engine.paper.execution_models import ( + PaperExecutionContext, + PaperExecutionResult, + TickContext, + TickExecution, +) +from nfi_engine.paper.models import BotState, PaperRunRequest, PaperTick +from nfi_engine.paper.order_execution import SignalTickContext, process_signal_tick +from nfi_engine.paper.signals import ( + first_entry_signal, + signals_for_tick, + strategy_rows_for_ticks, + strategy_timeframe, +) +from nfi_engine.paper.timeline import TimelineContext, timeline_step +from nfi_engine.persistence import PersistenceDatabase +from nfi_engine.strategy import ( + StrategySignal, +) +from nfi_engine.strategy.timeline import ( + StrategyTimelineBuilder, + TimelineSurface, +) + +__all__ = [ + "execute_paper_events", + "first_breaker", + "trading_halted", +] + + +async def execute_paper_events( + *, + database: PersistenceDatabase, + simulator: DeterministicExchangeSimulator, + request: PaperRunRequest, + state: BotState, +) -> PaperExecutionResult: + processed_events = 0 + created_trades = 0 + blocked_orders = 0 + latest_decision: CircuitBreakerDecision | None = None + previous_tick: PaperTick | None = None + timeline = StrategyTimelineBuilder(surface=TimelineSurface.PAPER) + context = PaperExecutionContext( + database=database, + simulator=simulator, + request=request, + state=state, + strategy_rows=strategy_rows_for_ticks(request.ticks), + strategy_timeframe=strategy_timeframe(request), + ) + + for sequence, tick in enumerate(request.ticks[: request.max_events], start=1): + execution = await _execute_tick( + context, + TickContext( + previous_tick=previous_tick, + tick=tick, + sequence=sequence, + trade_number=created_trades + 1, + ), + ) + processed_events += 1 + created_trades += execution.created_trades + blocked_orders += execution.blocked_orders + latest_decision = execution.decision + timeline.record(execution.timeline_step) + previous_tick = tick + + return PaperExecutionResult( + processed_events=processed_events, + created_trades=created_trades, + blocked_orders=blocked_orders, + latest_decision=latest_decision, + timeline=timeline.freeze(), + ) + + +async def _execute_tick( + context: PaperExecutionContext, + tick_context: TickContext, +) -> TickExecution: + decision = breaker_decision( + request=context.request, + previous_tick=tick_context.previous_tick, + current_tick=tick_context.tick, + ) + signals = signals_for_tick(context=context, tick_context=tick_context) + entry_signal = first_entry_signal(signals) + blocked_by_protection = entry_signal is not None and decision.new_orders_blocked + created_trades, blocked_orders = await _order_counts_for_tick( + context=context, + tick_context=tick_context, + decision=decision, + entry_signal=entry_signal, + ) + return TickExecution( + decision=decision, + created_trades=created_trades, + blocked_orders=blocked_orders, + timeline_step=timeline_step( + TimelineContext( + request=context.request, + tick=tick_context.tick, + sequence=tick_context.sequence, + created_this_tick=created_trades, + created_trades_total=tick_context.trade_number - 1 + created_trades, + signals=signals, + blocked_by_protection=blocked_by_protection, + protection_reasons=protection_reasons(decision), + ), + ), + ) + + +async def _order_counts_for_tick( + *, + context: PaperExecutionContext, + tick_context: TickContext, + decision: CircuitBreakerDecision, + entry_signal: StrategySignal | None, +) -> tuple[int, int]: + if context.state is not BotState.RUNNING or entry_signal is None: + return 0, 0 + if decision.new_orders_blocked: + return 0, 1 + created = await process_signal_tick( + SignalTickContext( + database=context.database, + simulator=context.simulator, + request=context.request, + tick=tick_context.tick, + side=entry_signal.side, + trade_number=tick_context.trade_number, + breaker_decision=decision, + ), + ) + return created, 0 diff --git a/src/nfi_engine/paper/execution_models.py b/src/nfi_engine/paper/execution_models.py new file mode 100644 index 0000000..6ea5d31 --- /dev/null +++ b/src/nfi_engine/paper/execution_models.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from nfi_engine.circuit_breakers import CircuitBreakerDecision +from nfi_engine.exchange.simulator import DeterministicExchangeSimulator +from nfi_engine.paper.models import BotState, PaperRunRequest, PaperTick +from nfi_engine.persistence import PersistenceDatabase +from nfi_engine.strategy import StrategyRow +from nfi_engine.strategy.timeline import StrategyTimeline, StrategyTimelineStep + + +@dataclass(frozen=True, slots=True) +class PaperExecutionResult: + processed_events: int + created_trades: int + blocked_orders: int + latest_decision: CircuitBreakerDecision | None + timeline: StrategyTimeline + + +@dataclass(frozen=True, slots=True) +class PaperExecutionContext: + database: PersistenceDatabase + simulator: DeterministicExchangeSimulator + request: PaperRunRequest + state: BotState + strategy_rows: tuple[StrategyRow, ...] + strategy_timeframe: str | None + + +@dataclass(frozen=True, slots=True) +class TickExecution: + decision: CircuitBreakerDecision + created_trades: int + blocked_orders: int + timeline_step: StrategyTimelineStep + + +@dataclass(frozen=True, slots=True) +class TickContext: + previous_tick: PaperTick | None + tick: PaperTick + sequence: int + trade_number: int diff --git a/src/nfi_engine/paper/models.py b/src/nfi_engine/paper/models.py index 84b9255..1ba5ee1 100644 --- a/src/nfi_engine/paper/models.py +++ b/src/nfi_engine/paper/models.py @@ -5,7 +5,9 @@ from enum import StrEnum, unique from nfi_engine.config import RuntimeSettings -from nfi_engine.domain import PositionSide, Price, TradingPair +from nfi_engine.domain import AccountSnapshot, PositionSide, Price, TradingPair +from nfi_engine.strategy import FreqtradeStrategyAdapter +from nfi_engine.strategy.timeline import StrategyTimeline @unique @@ -38,6 +40,8 @@ class PaperRunRequest: ticks: tuple[PaperTick, ...] max_events: int database_url: str + strategy_adapter: FreqtradeStrategyAdapter | None = None + account_snapshot: AccountSnapshot | None = None @dataclass(frozen=True, slots=True) @@ -49,3 +53,4 @@ class PaperRunResult: trading_halted: bool halted_breaker: str | None new_orders_blocked: bool + timeline: StrategyTimeline diff --git a/src/nfi_engine/paper/order_execution.py b/src/nfi_engine/paper/order_execution.py new file mode 100644 index 0000000..d0b57c7 --- /dev/null +++ b/src/nfi_engine/paper/order_execution.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from typing import assert_never + +from nfi_engine.circuit_breakers import CircuitBreakerDecision, ensure_order_intent_allowed +from nfi_engine.domain import ( + AccountSnapshot, + Leverage, + LiquidationBuffer, + OrderState, + OrderType, + Position, + PositionSide, + Price, + Quantity, + StakeAmount, + TradeId, + TradeState, + TradingPair, +) +from nfi_engine.exchange import ExchangeOrderRequest +from nfi_engine.exchange.simulator import DeterministicExchangeSimulator +from nfi_engine.paper.models import PaperRunRequest, PaperTick +from nfi_engine.persistence import PersistenceDatabase +from nfi_engine.persistence.records import OrderRecord, PositionRecord, TradeRecord +from nfi_engine.persistence.repositories import OrderRepository, PositionRepository, TradeRepository +from nfi_engine.risk import ( + AcceptedOrderQuote, + RejectedOrderQuote, + RiskRequest, + pair_locks_from_runtime, + policy_from_runtime, +) +from nfi_engine.risk.service import quote_order + +ZERO: Decimal = Decimal(0) + + +@dataclass(frozen=True, slots=True) +class SignalTickContext: + database: PersistenceDatabase + simulator: DeterministicExchangeSimulator + request: PaperRunRequest + tick: PaperTick + side: PositionSide + trade_number: int + breaker_decision: CircuitBreakerDecision + + +async def process_signal_tick(context: SignalTickContext) -> int: + ensure_order_intent_allowed(context.breaker_decision) + open_positions = await _open_positions(context) + quote = quote_order( + _risk_request( + request=context.request, + tick=context.tick, + side=context.side, + open_positions=open_positions, + ), + ) + match quote: + case AcceptedOrderQuote(): + order_request = _order_request_from_quote(quote=quote, tick=context.tick) + order = await context.simulator.create_order(order_request) + if order.state is not OrderState.FILLED: + return 0 + async with context.database.session() as session: + await TradeRepository(session).create( + _trade_record(quote, context.tick, context.trade_number), + ) + await OrderRepository(session).create( + _order_record(quote, context.tick, context.trade_number), + ) + position = _position_record(quote, context.tick, context.trade_number) + await PositionRepository(session).create(position) + await session.commit() + return 1 + case RejectedOrderQuote(): + return 0 + case unreachable: + assert_never(unreachable) + + +def _risk_request( + *, + request: PaperRunRequest, + tick: PaperTick, + side: PositionSide, + open_positions: tuple[Position, ...], +) -> RiskRequest: + current_time = tick.at + return RiskRequest( + pair=tick.pair, + side=side, + stake=StakeAmount(request.settings.risk.stake_usdt), + requested_leverage=request.settings.risk.leverage, + account=_account_snapshot( + request=request, + current_time=current_time, + open_positions=open_positions, + ), + policy=policy_from_runtime(request.settings), + pair_locks=pair_locks_from_runtime(settings=request.settings, current_time=current_time), + cooldown_until=None, + current_time=current_time, + ) + + +def _account_snapshot( + *, + request: PaperRunRequest, + current_time: datetime, + open_positions: tuple[Position, ...], +) -> AccountSnapshot: + base = request.account_snapshot + if base is None: + return AccountSnapshot( + captured_at=current_time, + equity=StakeAmount(Decimal(1000)), + available=StakeAmount(Decimal(1000)), + positions=open_positions, + ) + return AccountSnapshot( + captured_at=base.captured_at, + equity=base.equity, + available=base.available, + positions=open_positions, + ) + + +async def _open_positions(context: SignalTickContext) -> tuple[Position, ...]: + async with context.database.session() as session: + records = await PositionRepository(session).list_open( + limit=context.request.settings.risk.max_open_trades, + ) + return tuple(_position_from_record(record, context.request) for record in records) + + +def _position_from_record(record: PositionRecord, request: PaperRunRequest) -> Position: + return Position( + trade_id=TradeId(record.trade_id), + pair=TradingPair.parse(record.pair, request.settings.exchange.trading_mode), + side=record.side, + quantity=Quantity(record.quantity), + entry_price=Price(record.entry_price), + leverage=Leverage.parse(record.leverage), + liquidation_buffer=LiquidationBuffer.parse(request.settings.risk.liquidation_buffer), + state=record.state, + ) + + +def _order_request_from_quote( + *, + quote: AcceptedOrderQuote, + tick: PaperTick, +) -> ExchangeOrderRequest: + notional = quote.stake * quote.leverage.value + return ExchangeOrderRequest( + pair=quote.pair, + side=quote.side, + order_type=OrderType.MARKET, + quantity=Quantity(notional / tick.price), + price=None, + leverage=quote.leverage, + ) + + +def _trade_record(quote: AcceptedOrderQuote, tick: PaperTick, trade_number: int) -> TradeRecord: + return TradeRecord( + trade_id=f"paper-{trade_number}", + pair=str(quote.pair.normalized), + side=quote.side, + state=TradeState.OPEN, + opened_at=tick.at, + closed_at=None, + entry_price=tick.price, + exit_price=None, + quantity=Quantity((quote.stake * quote.leverage.value) / tick.price), + leverage=quote.leverage.value, + profit=ZERO, + ) + + +def _order_record(quote: AcceptedOrderQuote, tick: PaperTick, trade_number: int) -> OrderRecord: + return OrderRecord( + order_id=f"paper-order-{trade_number}", + trade_id=f"paper-{trade_number}", + pair=str(quote.pair.normalized), + side=quote.side, + order_type=OrderType.MARKET, + state=OrderState.FILLED, + price=tick.price, + quantity=Quantity((quote.stake * quote.leverage.value) / tick.price), + created_at=tick.at, + ) + + +def _position_record( + quote: AcceptedOrderQuote, + tick: PaperTick, + trade_number: int, +) -> PositionRecord: + return PositionRecord( + position_id=f"paper-position-{trade_number}", + trade_id=f"paper-{trade_number}", + pair=str(quote.pair.normalized), + side=quote.side, + state=TradeState.OPEN, + quantity=Quantity((quote.stake * quote.leverage.value) / tick.price), + entry_price=tick.price, + leverage=quote.leverage.value, + updated_at=tick.at, + ) diff --git a/src/nfi_engine/paper/runner.py b/src/nfi_engine/paper/runner.py index 9e2c51c..a580dfe 100644 --- a/src/nfi_engine/paper/runner.py +++ b/src/nfi_engine/paper/runner.py @@ -1,29 +1,13 @@ from __future__ import annotations -from dataclasses import dataclass -from decimal import Decimal +from dataclasses import replace +from typing import assert_never -from nfi_engine.circuit_breakers import ( - CircuitBreakerDecision, - CircuitBreakerSnapshot, - ensure_order_intent_allowed, - evaluate_circuit_breakers, -) -from nfi_engine.circuit_breakers import ( - policy_from_runtime as circuit_policy_from_runtime, -) -from nfi_engine.domain import ( - AccountSnapshot, - OrderState, - OrderType, - PositionSide, - Quantity, - StakeAmount, - TradeState, -) -from nfi_engine.exchange import ExchangeOrderRequest, Tick +from nfi_engine.domain import AccountSnapshot, StakeAmount +from nfi_engine.exchange import Tick, get_exchange_profile from nfi_engine.exchange.simulator import DeterministicExchangeSimulator from nfi_engine.paper.errors import PaperError, PaperErrorCode +from nfi_engine.paper.execution import execute_paper_events, first_breaker, trading_halted from nfi_engine.paper.lifecycle import apply_bot_command from nfi_engine.paper.models import ( BotCommand, @@ -32,242 +16,107 @@ PaperRunResult, PaperTick, ) -from nfi_engine.persistence import PersistenceDatabase, create_persistence_database -from nfi_engine.persistence.records import OrderRecord, PositionRecord, TradeRecord -from nfi_engine.persistence.repositories import OrderRepository, PositionRepository, TradeRepository -from nfi_engine.risk import ( - AcceptedOrderQuote, - RiskRequest, - pair_locks_from_runtime, - policy_from_runtime, -) -from nfi_engine.risk.service import quote_order +from nfi_engine.persistence import create_persistence_database +from nfi_engine.preflight import PreflightReport, PreflightStatus +from nfi_engine.preflight.service import run_preflight +from nfi_engine.profiles.catalog import default_profile_name from nfi_engine.safety import enforce_milestone_live_trading_scope - -ZERO: Decimal = Decimal(0) - - -@dataclass(frozen=True, slots=True) -class SignalTickContext: - database: PersistenceDatabase - simulator: DeterministicExchangeSimulator - request: PaperRunRequest - tick: PaperTick - trade_number: int - breaker_decision: CircuitBreakerDecision +from nfi_engine.wallet import WalletBalanceSnapshot, WalletBalanceStatus, fetch_wallet_balance async def run_paper(request: PaperRunRequest) -> PaperRunResult: enforce_milestone_live_trading_scope(request.settings) _validate_paper_exchange(request) + gated_request = await _startup_checked_request(request) state = apply_bot_command(BotState.STOPPED, BotCommand.START) - database = create_persistence_database(request.database_url) + database = create_persistence_database(gated_request.database_url) await database.initialize() - simulator = DeterministicExchangeSimulator(ticks=_exchange_ticks(request.ticks)) - processed_events = 0 - created_trades = 0 - blocked_orders = 0 - latest_decision: CircuitBreakerDecision | None = None - previous_tick: PaperTick | None = None + simulator = DeterministicExchangeSimulator(ticks=_exchange_ticks(gated_request.ticks)) try: - for tick in request.ticks[: request.max_events]: - processed_events += 1 - latest_decision = _breaker_decision( - request=request, - previous_tick=previous_tick, - current_tick=tick, - ) - if state is BotState.RUNNING and tick.signal_side is not None: - if latest_decision.new_orders_blocked: - blocked_orders += 1 - else: - created_trades += await _process_signal_tick( - SignalTickContext( - database=database, - simulator=simulator, - request=request, - tick=tick, - trade_number=created_trades + 1, - breaker_decision=latest_decision, - ), - ) - previous_tick = tick + execution = await execute_paper_events( + database=database, + simulator=simulator, + request=gated_request, + state=state, + ) state = apply_bot_command(state, BotCommand.STOP) state = apply_bot_command(state, BotCommand.STOP) return PaperRunResult( - processed_events=processed_events, - created_trades=created_trades, + processed_events=execution.processed_events, + created_trades=execution.created_trades, live_orders=False, final_state=state, - trading_halted=_trading_halted(latest_decision), - halted_breaker=_first_breaker(latest_decision), - new_orders_blocked=blocked_orders > 0, + trading_halted=trading_halted(execution.latest_decision), + halted_breaker=first_breaker(execution.latest_decision), + new_orders_blocked=execution.blocked_orders > 0, + timeline=execution.timeline, ) finally: await database.dispose() def _validate_paper_exchange(request: PaperRunRequest) -> None: - if request.settings.exchange.name == "bybit" and not request.settings.exchange.testnet: + profile = get_exchange_profile(request.settings.exchange.name) + if profile is None or ( + profile.exchange_id != "simulator" and not request.settings.exchange.testnet + ): raise PaperError( code=PaperErrorCode.LIVE_EXCHANGE_DISABLED_FOR_MILESTONE, message="paper-run requires testnet or simulator exchange in milestone 1", ) -async def _process_signal_tick(context: SignalTickContext) -> int: - ensure_order_intent_allowed(context.breaker_decision) - quote = quote_order(_risk_request(request=context.request, tick=context.tick)) - match quote: - case AcceptedOrderQuote(): - order_request = _order_request_from_quote(quote=quote, tick=context.tick) - order = await context.simulator.create_order(order_request) - if order.state is not OrderState.FILLED: - return 0 - async with context.database.session() as session: - await TradeRepository(session).create( - _trade_record(quote, context.tick, context.trade_number), - ) - await OrderRepository(session).create( - _order_record(quote, context.tick, context.trade_number), - ) - position = _position_record(quote, context.tick, context.trade_number) - await PositionRepository(session).create(position) - await session.commit() - return 1 - case _: - return 0 - - -def _risk_request(*, request: PaperRunRequest, tick: PaperTick) -> RiskRequest: - current_time = tick.at - return RiskRequest( - pair=tick.pair, - side=_signal_side(tick), - stake=StakeAmount(request.settings.risk.stake_usdt), - requested_leverage=request.settings.risk.leverage, - account=AccountSnapshot( - captured_at=current_time, - equity=StakeAmount(Decimal(1000)), - available=StakeAmount(Decimal(1000)), - positions=(), - ), - policy=policy_from_runtime(request.settings), - pair_locks=pair_locks_from_runtime(settings=request.settings, current_time=current_time), - cooldown_until=None, - current_time=current_time, - ) - - -def _signal_side(tick: PaperTick) -> PositionSide: - side = tick.signal_side - if side is None: - return PositionSide.LONG - return side - - -def _order_request_from_quote( - *, - quote: AcceptedOrderQuote, - tick: PaperTick, -) -> ExchangeOrderRequest: - notional = quote.stake * quote.leverage.value - return ExchangeOrderRequest( - pair=quote.pair, - side=quote.side, - order_type=OrderType.MARKET, - quantity=Quantity(notional / tick.price), - price=None, - leverage=quote.leverage, - ) - - -def _trade_record(quote: AcceptedOrderQuote, tick: PaperTick, trade_number: int) -> TradeRecord: - return TradeRecord( - trade_id=f"paper-{trade_number}", - pair=str(quote.pair.normalized), - side=quote.side, - state=TradeState.OPEN, - opened_at=tick.at, - closed_at=None, - entry_price=tick.price, - exit_price=None, - quantity=Quantity((quote.stake * quote.leverage.value) / tick.price), - leverage=quote.leverage.value, - profit=ZERO, +async def _startup_checked_request(request: PaperRunRequest) -> PaperRunRequest: + if request.strategy_adapter is None: + return request + report = run_preflight( + settings=request.settings, + profile_name=default_profile_name(request.settings), ) - - -def _order_record(quote: AcceptedOrderQuote, tick: PaperTick, trade_number: int) -> OrderRecord: - return OrderRecord( - order_id=f"paper-order-{trade_number}", - trade_id=f"paper-{trade_number}", - pair=str(quote.pair.normalized), - side=quote.side, - order_type=OrderType.MARKET, - state=OrderState.FILLED, - price=tick.price, - quantity=Quantity((quote.stake * quote.leverage.value) / tick.price), - created_at=tick.at, - ) - - -def _position_record( - quote: AcceptedOrderQuote, - tick: PaperTick, - trade_number: int, -) -> PositionRecord: - return PositionRecord( - position_id=f"paper-position-{trade_number}", - trade_id=f"paper-{trade_number}", - pair=str(quote.pair.normalized), - side=quote.side, - state=TradeState.OPEN, - quantity=Quantity((quote.stake * quote.leverage.value) / tick.price), - entry_price=tick.price, - leverage=quote.leverage.value, - updated_at=tick.at, + if report.blocked: + raise PaperError( + code=PaperErrorCode.PREFLIGHT_BLOCKED, + message=_preflight_block_message(report), + ) + if request.account_snapshot is not None: + return request + wallet = await fetch_wallet_balance(settings=request.settings) + return replace(request, account_snapshot=_wallet_account_snapshot(wallet)) + + +def _preflight_block_message(report: PreflightReport) -> str: + blocked = tuple(check for check in report.checks if check.status is PreflightStatus.BLOCK) + details = "; ".join(f"{check.code.value}: {check.message}" for check in blocked) + return f"{report.profile}: {details}" + + +def _wallet_account_snapshot(wallet: WalletBalanceSnapshot) -> AccountSnapshot: + match wallet.status: + case WalletBalanceStatus.FETCHED: + pass + case ( + WalletBalanceStatus.BLOCKED + | WalletBalanceStatus.UNAVAILABLE + | WalletBalanceStatus.ERROR + ): + raise PaperError( + code=PaperErrorCode.WALLET_BALANCE_UNAVAILABLE, + message=f"{wallet.code.value}: {wallet.message}", + ) + case unreachable: + assert_never(unreachable) + if wallet.captured_at is None or wallet.equity is None or wallet.available is None: + raise PaperError( + code=PaperErrorCode.WALLET_BALANCE_UNAVAILABLE, + message=f"{wallet.code.value}: wallet balance payload is incomplete", + ) + return AccountSnapshot( + captured_at=wallet.captured_at, + equity=StakeAmount(wallet.equity), + available=StakeAmount(wallet.available), + positions=(), ) def _exchange_ticks(ticks: tuple[PaperTick, ...]) -> tuple[Tick, ...]: return tuple(Tick(pair=tick.pair, price=tick.price, at=tick.at) for tick in ticks) - - -def _breaker_decision( - *, - request: PaperRunRequest, - previous_tick: PaperTick | None, - current_tick: PaperTick, -) -> CircuitBreakerDecision: - latest_tick_at = current_tick.at if previous_tick is None else previous_tick.at - return evaluate_circuit_breakers( - policy=circuit_policy_from_runtime(request.settings), - snapshot=CircuitBreakerSnapshot( - realized_pnl_today=ZERO, - equity_start=StakeAmount(Decimal(1000)), - equity_current=StakeAmount(Decimal(1000)), - consecutive_losses=0, - latest_tick_at=latest_tick_at, - current_time=current_tick.at, - api_error_count=0, - observed_slippage_pct=ZERO, - funding_rate=ZERO, - manual_halt=False, - rejected_order_count=0, - ), - ) - - -def _trading_halted(decision: CircuitBreakerDecision | None) -> bool: - if decision is None: - return False - return decision.trading_halted - - -def _first_breaker(decision: CircuitBreakerDecision | None) -> str | None: - if decision is None: - return None - if len(decision.triggered) == 0: - return None - return decision.triggered[0].kind.value diff --git a/src/nfi_engine/paper/signals.py b/src/nfi_engine/paper/signals.py new file mode 100644 index 0000000..9f885c1 --- /dev/null +++ b/src/nfi_engine/paper/signals.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from decimal import Decimal + +from nfi_engine.domain import SignalType +from nfi_engine.paper.execution_models import PaperExecutionContext, TickContext +from nfi_engine.paper.models import PaperRunRequest, PaperTick +from nfi_engine.strategy import ( + RunMode, + StrategyMetadata, + StrategyOhlcv, + StrategyRow, + StrategySignal, +) +from nfi_engine.strategy.frame import StrategyFrame + + +def signals_for_tick( + *, + context: PaperExecutionContext, + tick_context: TickContext, +) -> tuple[StrategySignal, ...]: + adapter = context.request.strategy_adapter + if adapter is None: + side = tick_context.tick.signal_side + if side is None: + return () + return ( + StrategySignal( + pair=tick_context.tick.pair, + side=side, + signal_type=SignalType.ENTER, + ), + ) + timeframe = context.strategy_timeframe + if timeframe is None: + return () + return adapter.analyze( + StrategyFrame( + rows=context.strategy_rows, + visible_row_count=tick_context.sequence, + ), + StrategyMetadata( + pair=tick_context.tick.pair, + timeframe=timeframe, + runmode=RunMode.DRY_RUN, + ), + incremental=True, + ) + + +def first_entry_signal(signals: tuple[StrategySignal, ...]) -> StrategySignal | None: + for signal in signals: + if signal.signal_type is SignalType.ENTER: + return signal + return None + + +def strategy_rows_for_ticks(ticks: tuple[PaperTick, ...]) -> tuple[StrategyRow, ...]: + return tuple( + StrategyRow( + date=tick.at.isoformat(), + close=tick.price, + ohlcv=StrategyOhlcv( + open=tick.price, + high=tick.price, + low=tick.price, + close=tick.price, + volume=Decimal(1), + ), + ) + for tick in ticks + ) + + +def strategy_timeframe(request: PaperRunRequest) -> str | None: + adapter = request.strategy_adapter + if adapter is None: + return None + return adapter.inspect().timeframe diff --git a/src/nfi_engine/paper/timeline.py b/src/nfi_engine/paper/timeline.py new file mode 100644 index 0000000..4f15020 --- /dev/null +++ b/src/nfi_engine/paper/timeline.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from nfi_engine.domain import SignalType +from nfi_engine.paper.models import PaperRunRequest, PaperTick +from nfi_engine.strategy import StrategySignal +from nfi_engine.strategy.timeline import ( + StrategyTimelineStep, + count_strategy_signals, + strategy_signal_reasons, + strategy_signal_sides, +) + + +@dataclass(frozen=True, slots=True) +class TimelineContext: + request: PaperRunRequest + tick: PaperTick + sequence: int + created_this_tick: int + created_trades_total: int + signals: tuple[StrategySignal, ...] + blocked_by_protection: bool + protection_reasons: tuple[str, ...] + + +def timeline_step(context: TimelineContext) -> StrategyTimelineStep: + entry_signal_count = count_strategy_signals(context.signals, SignalType.ENTER) + signal_rejected = ( + entry_signal_count > 0 + and not context.blocked_by_protection + and context.created_this_tick == 0 + ) + return StrategyTimelineStep( + sequence=context.sequence, + pair=context.tick.pair, + at=context.tick.at, + indicator_runs=1 if context.request.strategy_adapter is not None else 0, + entry_signals=entry_signal_count, + exit_signals=0, + entry_sides=strategy_signal_sides(context.signals, SignalType.ENTER), + exit_sides=(), + opened_orders=context.created_this_tick, + closed_orders=0, + rejected_actions=1 if signal_rejected else 0, + blocked_actions=1 if context.blocked_by_protection else 0, + protection_active=context.blocked_by_protection, + protection_reasons=(context.protection_reasons if context.blocked_by_protection else ()), + stake_amount=(context.request.settings.risk.stake_usdt if entry_signal_count > 0 else None), + leverage=context.request.settings.risk.leverage if entry_signal_count > 0 else None, + open_trade_count=context.created_trades_total, + entry_reasons=strategy_signal_reasons( + context.signals, + SignalType.ENTER, + fallback="signal", + ), + ) diff --git a/src/nfi_engine/persistence/AGENTS.md b/src/nfi_engine/persistence/AGENTS.md new file mode 100644 index 0000000..71abe37 --- /dev/null +++ b/src/nfi_engine/persistence/AGENTS.md @@ -0,0 +1,47 @@ +# PERSISTENCE GUIDE + +## OVERVIEW + +`persistence` owns async SQLite storage, SQLAlchemy session setup, records, +converters, repository protocols, and bounded repository implementations. + +## STRUCTURE + +```text +persistence/ +|-- session.py # async engine/session factory +|-- models.py # SQLAlchemy table models +|-- records.py # typed storage records +|-- converters.py # domain/storage mapping +|-- protocols.py # repository contracts +`-- repositories/ + |-- state.py # runtime/dashboard state reads + `-- trading.py # orders, trades, positions +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +| --- | --- | --- | +| New stored shape | `models.py`, `records.py`, `converters.py` | Update model, record, and mapping together. | +| Database access | `repositories/` | Keep SQL behind repository methods. | +| Session lifecycle | `session.py` | Async engine/session ownership. | +| Maintenance | `src/nfi_engine/maintenance/` | Migrations, backup, restore, config history. | +| Tests | `tests/integration/persistence`, `tests/unit/maintenance` | Use fixture DBs and temp paths. | + +## CONVENTIONS + +- API/UI/trading services should call repositories or maintenance services, not raw SQLAlchemy sessions. +- Keep database records typed and explicit; converters bridge storage rows and domain/API read models. +- SQLite is the first storage target, but avoid shapes that make a later Postgres repository impossible. +- Migrations, rollback, restore, reconciliation, and destructive maintenance need dry-run/preview before mutation. +- Backup/support bundles must redact API tokens, exchange credentials, webhook URLs, and secret-bearing config. +- Async tests often pin the `anyio_backend` to `asyncio`; match existing persistence tests. + +## ANTI-PATTERNS + +- Do not let HTML rendering, route handlers, or CLI glue reach directly into storage rows. +- Do not mix schema migration, repository query, and support-bundle formatting in one module. +- Do not mutate real operator data in tests; use temp directories, fixture DBs, and dry-run paths. +- Do not put runtime SQLite files, generated backups, or support bundles into source-controlled paths. +- Do not hide checksum, tamper, restore, or migration failures behind generic exceptions. diff --git a/src/nfi_engine/preflight/__init__.py b/src/nfi_engine/preflight/__init__.py index 78ec544..9a31212 100644 --- a/src/nfi_engine/preflight/__init__.py +++ b/src/nfi_engine/preflight/__init__.py @@ -6,13 +6,10 @@ PreflightReport, PreflightStatus, ) -from nfi_engine.preflight.service import run_preflight, run_preflight_for_config __all__ = [ "PreflightCheck", "PreflightCode", "PreflightReport", "PreflightStatus", - "run_preflight", - "run_preflight_for_config", ] diff --git a/src/nfi_engine/preflight/exchange_checks.py b/src/nfi_engine/preflight/exchange_checks.py new file mode 100644 index 0000000..7cb30b8 --- /dev/null +++ b/src/nfi_engine/preflight/exchange_checks.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from nfi_engine.config import RuntimeSettings +from nfi_engine.exchange import get_exchange_profile +from nfi_engine.preflight.models import PreflightCheck, PreflightCode, PreflightStatus + + +def exchange_mode_check(settings: RuntimeSettings) -> PreflightCheck: + profile = get_exchange_profile(settings.exchange.name) + if profile is None: + return PreflightCheck( + code=PreflightCode.CONFIG_INVALID, + status=PreflightStatus.BLOCK, + message=f"unsupported exchange: {settings.exchange.name}", + ) + if profile.exchange_id != "simulator" and not settings.exchange.testnet: + return PreflightCheck( + code=PreflightCode.EXCHANGE_TESTNET_REQUIRED, + status=PreflightStatus.BLOCK, + message=f"{profile.exchange_id} requires testnet=true in current milestone", + ) + if settings.exchange.testnet and not profile.supports_testnet: + return PreflightCheck( + code=PreflightCode.EXCHANGE_TESTNET_REQUIRED, + status=PreflightStatus.BLOCK, + message=f"{profile.exchange_id} has no registry-backed testnet support", + ) + return PreflightCheck( + code=PreflightCode.EXCHANGE_TESTNET_REQUIRED, + status=PreflightStatus.PASS, + message=f"exchange registry ok: {profile.exchange_id} ({profile.support_level.value})", + ) diff --git a/src/nfi_engine/preflight/guardrail_checks.py b/src/nfi_engine/preflight/guardrail_checks.py new file mode 100644 index 0000000..c749a7e --- /dev/null +++ b/src/nfi_engine/preflight/guardrail_checks.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Final, assert_never + +from nfi_engine.config import RuntimeSettings +from nfi_engine.domain import TradingMode +from nfi_engine.exchange.permissions import audit_exchange_api_permissions +from nfi_engine.preflight.models import PreflightCheck, PreflightCode, PreflightStatus +from nfi_engine.risk.profiles import get_risk_profile + +FUTURES_LEVERAGE_CEILING: Final = Decimal(10) + + +def runtime_guardrail_checks(settings: RuntimeSettings) -> tuple[PreflightCheck, ...]: + return ( + futures_leverage_check(settings), + exchange_permission_audit_check(settings), + risk_profile_guardrail_check(settings), + ) + + +def futures_leverage_check(settings: RuntimeSettings) -> PreflightCheck: + match settings.exchange.trading_mode: + case TradingMode.SPOT: + return _check( + PreflightCode.FUTURES_LEVERAGE_INVALID, + PreflightStatus.PASS, + "spot mode", + ) + case TradingMode.FUTURES: + pass + case unreachable: + assert_never(unreachable) + if ( + settings.risk.leverage > settings.risk.max_leverage + or settings.risk.leverage > FUTURES_LEVERAGE_CEILING + ): + return _check( + PreflightCode.FUTURES_LEVERAGE_INVALID, + PreflightStatus.BLOCK, + "futures leverage exceeds readiness ceiling", + ) + return _check( + PreflightCode.FUTURES_LEVERAGE_INVALID, + PreflightStatus.PASS, + "futures leverage guardrails passed", + ) + + +def exchange_permission_audit_check(settings: RuntimeSettings) -> PreflightCheck: + audit = audit_exchange_api_permissions( + read=settings.exchange.permission_read, + trade=settings.exchange.permission_trade, + futures=settings.exchange.permission_futures, + withdrawal=settings.exchange.permission_withdrawal, + ip_allowlist=settings.exchange.permission_ip_allowlist, + ) + if audit.live_blocking_codes: + status = PreflightStatus.BLOCK if settings.engine.live_trading else PreflightStatus.WARN + return _check(PreflightCode.EXCHANGE_PERMISSION_AUDIT, status, audit.summary) + if audit.diagnostic_codes: + return _check(PreflightCode.EXCHANGE_PERMISSION_AUDIT, PreflightStatus.WARN, audit.summary) + return _check(PreflightCode.EXCHANGE_PERMISSION_AUDIT, PreflightStatus.PASS, audit.summary) + + +def risk_profile_guardrail_check(settings: RuntimeSettings) -> PreflightCheck: + profile = get_risk_profile(settings.risk.risk_profile) + if profile.requires_confirmation and not settings.risk.expert_risk_confirmed: + return _check( + PreflightCode.RISK_PROFILE_GUARDRAILS, + PreflightStatus.BLOCK, + "expert risk profile requires explicit confirmation", + ) + if ( + settings.risk.leverage > profile.max_leverage + or settings.risk.max_open_trades > profile.max_open_trades + or settings.risk.max_daily_loss_pct > profile.max_daily_loss_pct + ): + return _check( + PreflightCode.RISK_PROFILE_GUARDRAILS, + PreflightStatus.BLOCK, + f"{profile.name.value} risk profile guardrails exceeded", + ) + return _check( + PreflightCode.RISK_PROFILE_GUARDRAILS, + PreflightStatus.PASS, + f"{profile.name.value} risk profile guardrails passed", + ) + + +def _check( + code: PreflightCode, + status: PreflightStatus, + message: str, +) -> PreflightCheck: + return PreflightCheck(code=code, status=status, message=message) diff --git a/src/nfi_engine/preflight/live_readiness.py b/src/nfi_engine/preflight/live_readiness.py new file mode 100644 index 0000000..5761127 --- /dev/null +++ b/src/nfi_engine/preflight/live_readiness.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import assert_never + +from nfi_engine.config import RuntimeSettings +from nfi_engine.domain import TradingMode +from nfi_engine.exchange.permissions import ExchangeApiPermissionState +from nfi_engine.preflight.models import PreflightCheck, PreflightCode, PreflightStatus +from nfi_engine.strategy.nfi_x7.coverage import build_x7_coverage_report +from nfi_engine.strategy.nfi_x7.status import is_x7_native_settings + +ZERO: Decimal = Decimal(0) + + +def live_readiness_checks(settings: RuntimeSettings) -> tuple[PreflightCheck, ...]: + if not settings.engine.live_trading: + return () + return ( + _credential_check(settings), + _permission_hardening_check(settings), + _reconciliation_hardening_check(settings), + _circuit_breaker_hardening_check(settings), + _strategy_hardening_check(settings), + ) + + +def _credential_check(settings: RuntimeSettings) -> PreflightCheck: + if _present(settings.exchange.api_key) and _present(settings.exchange.api_secret): + return _check( + PreflightCode.LIVE_EXCHANGE_CREDENTIALS, + PreflightStatus.PASS, + "live exchange credentials are present", + ) + return _check( + PreflightCode.LIVE_EXCHANGE_CREDENTIALS, + PreflightStatus.BLOCK, + "live mode requires exchange api_key and api_secret", + ) + + +def _permission_hardening_check(settings: RuntimeSettings) -> PreflightCheck: + missing: list[str] = [] + if settings.exchange.permission_read is not ExchangeApiPermissionState.ENABLED: + missing.append("read") + if settings.exchange.permission_trade is not ExchangeApiPermissionState.ENABLED: + missing.append("trade") + missing.extend(_futures_permission_gaps(settings)) + missing.extend(_withdrawal_permission_gaps(settings.exchange.permission_withdrawal)) + if settings.exchange.permission_ip_allowlist is not ExchangeApiPermissionState.ENABLED: + missing.append("ip_allowlist") + if missing: + return _check( + PreflightCode.LIVE_PERMISSION_HARDENING, + PreflightStatus.BLOCK, + f"live API permission hardening incomplete: {','.join(missing)}", + ) + return _check( + PreflightCode.LIVE_PERMISSION_HARDENING, + PreflightStatus.PASS, + "live API permissions are hardened", + ) + + +def _futures_permission_gaps(settings: RuntimeSettings) -> tuple[str, ...]: + match settings.exchange.trading_mode: + case TradingMode.SPOT: + return () + case TradingMode.FUTURES: + if settings.exchange.permission_futures is ExchangeApiPermissionState.ENABLED: + return () + return ("futures",) + case unreachable: + assert_never(unreachable) + + +def _withdrawal_permission_gaps( + withdrawal: ExchangeApiPermissionState, +) -> tuple[str, ...]: + match withdrawal: + case ExchangeApiPermissionState.DISABLED | ExchangeApiPermissionState.NOT_APPLICABLE: + return () + case ExchangeApiPermissionState.ENABLED: + return ("withdrawal_enabled",) + case ExchangeApiPermissionState.UNKNOWN: + return ("withdrawal_unknown",) + case unreachable: + assert_never(unreachable) + + +def _reconciliation_hardening_check(settings: RuntimeSettings) -> PreflightCheck: + if settings.reconciliation.required and _present(settings.reconciliation.fixture_path): + return _check( + PreflightCode.LIVE_RECONCILIATION_HARDENING, + PreflightStatus.PASS, + "startup reconciliation is required for live intent", + ) + return _check( + PreflightCode.LIVE_RECONCILIATION_HARDENING, + PreflightStatus.BLOCK, + "live intent requires startup reconciliation with a fixture_path", + ) + + +def _circuit_breaker_hardening_check(settings: RuntimeSettings) -> PreflightCheck: + circuit = settings.circuit_breakers + gaps: list[str] = [] + if not circuit.enabled: + gaps.append("enabled") + if circuit.max_daily_loss_usdt <= ZERO: + gaps.append("max_daily_loss_usdt") + if circuit.max_drawdown_pct <= ZERO: + gaps.append("max_drawdown_pct") + if circuit.max_stale_seconds <= 0: + gaps.append("max_stale_seconds") + if not _present(circuit.manual_halt_file): + gaps.append("manual_halt_file") + if gaps: + return _check( + PreflightCode.LIVE_CIRCUIT_BREAKER_HARDENING, + PreflightStatus.BLOCK, + f"live circuit breaker hardening incomplete: {','.join(gaps)}", + ) + return _check( + PreflightCode.LIVE_CIRCUIT_BREAKER_HARDENING, + PreflightStatus.PASS, + "live circuit breaker hardening is configured", + ) + + +def _strategy_hardening_check(settings: RuntimeSettings) -> PreflightCheck: + if not is_x7_native_settings(settings): + return _check( + PreflightCode.LIVE_STRATEGY_HARDENING, + PreflightStatus.BLOCK, + "live intent requires X7NativeStrategy semantic coverage", + ) + coverage = build_x7_coverage_report() + if coverage.is_full_semantic_coverage: + return _check( + PreflightCode.LIVE_STRATEGY_HARDENING, + PreflightStatus.PASS, + "X7 semantic coverage evidence is complete", + ) + return _check( + PreflightCode.LIVE_STRATEGY_HARDENING, + PreflightStatus.BLOCK, + f"X7 semantic coverage pending: {','.join(coverage.pending_modules)}", + ) + + +def _present(value: str | None) -> bool: + return value is not None and value.strip() != "" + + +def _check( + code: PreflightCode, + status: PreflightStatus, + message: str, +) -> PreflightCheck: + return PreflightCheck(code=code, status=status, message=message) diff --git a/src/nfi_engine/preflight/models.py b/src/nfi_engine/preflight/models.py index 1aa41ce..d6ac37f 100644 --- a/src/nfi_engine/preflight/models.py +++ b/src/nfi_engine/preflight/models.py @@ -36,7 +36,14 @@ class PreflightCode(UpperStrEnum): WEAK_API_TOKEN = auto() LIVE_TRADING_DISABLED = auto() LIVE_TRADING_OUT_OF_SCOPE = auto() + LIVE_EXCHANGE_CREDENTIALS = auto() + LIVE_PERMISSION_HARDENING = auto() + LIVE_RECONCILIATION_HARDENING = auto() + LIVE_CIRCUIT_BREAKER_HARDENING = auto() + LIVE_STRATEGY_HARDENING = auto() FUTURES_LEVERAGE_INVALID = auto() + EXCHANGE_PERMISSION_AUDIT = auto() + RISK_PROFILE_GUARDRAILS = auto() EXCHANGE_TESTNET_REQUIRED = auto() DB_PATH_READY = auto() DB_PATH_MISSING = auto() diff --git a/src/nfi_engine/preflight/service.py b/src/nfi_engine/preflight/service.py index 553f651..d48e473 100644 --- a/src/nfi_engine/preflight/service.py +++ b/src/nfi_engine/preflight/service.py @@ -1,14 +1,16 @@ from __future__ import annotations import os -from decimal import Decimal from pathlib import Path from typing import Final from nfi_engine.api.errors import ApiConfigurationError from nfi_engine.api.settings import validate_api_auth_settings from nfi_engine.config import ConfigLoadError, RuntimeSettings, load_runtime_settings -from nfi_engine.domain import TradingMode +from nfi_engine.exchange import get_exchange_profile +from nfi_engine.preflight.exchange_checks import exchange_mode_check +from nfi_engine.preflight.guardrail_checks import runtime_guardrail_checks +from nfi_engine.preflight.live_readiness import live_readiness_checks from nfi_engine.preflight.models import ( PreflightCheck, PreflightCode, @@ -20,7 +22,6 @@ SQLITE_PREFIX: Final = "sqlite+aiosqlite:///" LOCAL_API_HOST: Final = "127.0.0.1" -FUTURES_LEVERAGE_CEILING: Final = Decimal(10) REQUIRED_COMPOSE_VOLUMES: Final = ("nfi-data", "nfi-logs") @@ -52,8 +53,9 @@ def run_preflight( checks.append(_api_bind_check(settings)) checks.append(_api_token_check(settings)) checks.append(_live_scope_check(settings)) - checks.append(_futures_leverage_check(settings)) - checks.append(_exchange_mode_check(settings)) + checks.extend(live_readiness_checks(settings)) + checks.extend(runtime_guardrail_checks(settings)) + checks.append(exchange_mode_check(settings)) checks.append(_database_path_check(settings)) checks.append(_log_path_check(settings)) checks.append(_docker_volume_check()) @@ -79,11 +81,14 @@ def _profile_check(*, settings: RuntimeSettings, profile_name: str) -> Preflight PreflightStatus.BLOCK, f"{profile.name} does not allow {settings.exchange.trading_mode.value}", ) - if profile.name == "bybit-testnet" and settings.exchange.name != "bybit": + exchange_profile = get_exchange_profile(settings.exchange.name) + if profile.exchange_id is not None and ( + exchange_profile is None or exchange_profile.exchange_id != profile.exchange_id + ): return _check( PreflightCode.PROFILE_CONFIG_MISMATCH, PreflightStatus.BLOCK, - "bybit-testnet requires exchange.name=bybit", + f"{profile.name} requires exchange.name={profile.exchange_id}", ) if profile.read_only and not settings.ui.read_only: return _check( @@ -126,39 +131,6 @@ def _live_scope_check(settings: RuntimeSettings) -> PreflightCheck: ) -def _futures_leverage_check(settings: RuntimeSettings) -> PreflightCheck: - if settings.exchange.trading_mode is not TradingMode.FUTURES: - return _check(PreflightCode.FUTURES_LEVERAGE_INVALID, PreflightStatus.PASS, "spot mode") - if ( - settings.risk.leverage > settings.risk.max_leverage - or settings.risk.leverage > FUTURES_LEVERAGE_CEILING - ): - return _check( - PreflightCode.FUTURES_LEVERAGE_INVALID, - PreflightStatus.BLOCK, - "futures leverage exceeds readiness ceiling", - ) - return _check( - PreflightCode.FUTURES_LEVERAGE_INVALID, - PreflightStatus.PASS, - "futures leverage guardrails passed", - ) - - -def _exchange_mode_check(settings: RuntimeSettings) -> PreflightCheck: - if settings.exchange.name == "bybit" and not settings.exchange.testnet: - return _check( - PreflightCode.EXCHANGE_TESTNET_REQUIRED, - PreflightStatus.BLOCK, - "Bybit adapter must use testnet=true in milestone 1", - ) - return _check( - PreflightCode.EXCHANGE_TESTNET_REQUIRED, - PreflightStatus.PASS, - "exchange mode is simulator or testnet", - ) - - def _database_path_check(settings: RuntimeSettings) -> PreflightCheck: path = _sqlite_path(settings.database.url) if path is None or _path_parent_is_creatable(path): diff --git a/src/nfi_engine/profiles/catalog.py b/src/nfi_engine/profiles/catalog.py index 738aac4..9d5c515 100644 --- a/src/nfi_engine/profiles/catalog.py +++ b/src/nfi_engine/profiles/catalog.py @@ -4,6 +4,7 @@ from nfi_engine.config import RuntimeSettings from nfi_engine.domain import TradingMode +from nfi_engine.exchange import get_exchange_profile from nfi_engine.profiles.errors import ProfileError, ProfileErrorCode from nfi_engine.profiles.models import OperatorProfile @@ -27,6 +28,7 @@ def list_operator_profiles() -> tuple[OperatorProfile, ...]: requires_testnet=True, allow_live_trading=False, read_only=False, + exchange_id="bybit", ), OperatorProfile( name="backtest-only", @@ -60,8 +62,11 @@ def get_operator_profile(name: str) -> OperatorProfile: def default_profile_name(settings: RuntimeSettings) -> str: if settings.ui.read_only: return "readonly-debug" - if settings.exchange.name == "bybit" and settings.exchange.testnet: - return "bybit-testnet" + exchange_profile = get_exchange_profile(settings.exchange.name) + if settings.exchange.testnet and exchange_profile is not None: + for profile in list_operator_profiles(): + if profile.exchange_id == exchange_profile.exchange_id and profile.requires_testnet: + return profile.name if settings.paper_run.enabled: return "local-paper" return "backtest-only" diff --git a/src/nfi_engine/profiles/models.py b/src/nfi_engine/profiles/models.py index c825548..c9aaee2 100644 --- a/src/nfi_engine/profiles/models.py +++ b/src/nfi_engine/profiles/models.py @@ -13,3 +13,4 @@ class OperatorProfile: requires_testnet: bool allow_live_trading: bool read_only: bool + exchange_id: str | None = None diff --git a/src/nfi_engine/risk/profiles.py b/src/nfi_engine/risk/profiles.py new file mode 100644 index 0000000..ce4c7b1 --- /dev/null +++ b/src/nfi_engine/risk/profiles.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import assert_never + +from nfi_engine.config.enums import RiskProfileName + + +@dataclass(frozen=True, slots=True) +class RiskProfile: + name: RiskProfileName + stake_usdt: Decimal + leverage: Decimal + max_leverage: Decimal + max_open_trades: int + max_daily_loss_pct: Decimal + allocation_cap_pct: Decimal + requires_confirmation: bool + + +def get_risk_profile(name: RiskProfileName) -> RiskProfile: + match name: + case RiskProfileName.SAFE: + return RiskProfile( + name=name, + stake_usdt=Decimal(10), + leverage=Decimal(1), + max_leverage=Decimal(1), + max_open_trades=2, + max_daily_loss_pct=Decimal("0.02"), + allocation_cap_pct=Decimal("0.10"), + requires_confirmation=False, + ) + case RiskProfileName.BALANCED: + return RiskProfile( + name=name, + stake_usdt=Decimal(25), + leverage=Decimal(3), + max_leverage=Decimal(3), + max_open_trades=3, + max_daily_loss_pct=Decimal("0.05"), + allocation_cap_pct=Decimal("0.25"), + requires_confirmation=False, + ) + case RiskProfileName.EXPERT: + return RiskProfile( + name=name, + stake_usdt=Decimal(50), + leverage=Decimal(5), + max_leverage=Decimal(5), + max_open_trades=5, + max_daily_loss_pct=Decimal("0.08"), + allocation_cap_pct=Decimal("0.50"), + requires_confirmation=True, + ) + case unreachable: + assert_never(unreachable) diff --git a/src/nfi_engine/runtime_control/__init__.py b/src/nfi_engine/runtime_control/__init__.py new file mode 100644 index 0000000..2d151cf --- /dev/null +++ b/src/nfi_engine/runtime_control/__init__.py @@ -0,0 +1,14 @@ +from nfi_engine.runtime_control.models import ( + RuntimeControlCode, + RuntimeControlRequest, + RuntimeControlResult, +) +from nfi_engine.runtime_control.service import control_runtime, new_entries_allowed + +__all__ = [ + "RuntimeControlCode", + "RuntimeControlRequest", + "RuntimeControlResult", + "control_runtime", + "new_entries_allowed", +] diff --git a/src/nfi_engine/runtime_control/models.py b/src/nfi_engine/runtime_control/models.py new file mode 100644 index 0000000..2a0f63e --- /dev/null +++ b/src/nfi_engine/runtime_control/models.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum, unique + +from nfi_engine.config import RuntimeSettings +from nfi_engine.paper import BotCommand, BotState +from nfi_engine.preflight.models import PreflightReport +from nfi_engine.runtime_health import RuntimeHealthSnapshot, RuntimeHealthState + + +@unique +class RuntimeControlCode(StrEnum): + RUNTIME_CONTROL_ACCEPTED = "RUNTIME_CONTROL_ACCEPTED" + RUNTIME_ALREADY_PAUSED = "RUNTIME_ALREADY_PAUSED" + RUNTIME_ALREADY_STOPPED = "RUNTIME_ALREADY_STOPPED" + RUNTIME_ALREADY_RUNNING = "RUNTIME_ALREADY_RUNNING" + RUNTIME_INVALID_TRANSITION = "RUNTIME_INVALID_TRANSITION" + RUNTIME_PREFLIGHT_REQUIRED = "RUNTIME_PREFLIGHT_REQUIRED" + RUNTIME_PREFLIGHT_BLOCKED = "RUNTIME_PREFLIGHT_BLOCKED" + RUNTIME_HEALTH_REQUIRED = "RUNTIME_HEALTH_REQUIRED" + RUNTIME_HEALTH_BLOCKED = "RUNTIME_HEALTH_BLOCKED" + RUNTIME_LIVE_UNSAFE = "RUNTIME_LIVE_UNSAFE" + + +@dataclass(frozen=True, slots=True) +class RuntimeControlRequest: + settings: RuntimeSettings + state: BotState + command: BotCommand + readiness: PreflightReport | None + health: RuntimeHealthSnapshot | None = None + + +@dataclass(frozen=True, slots=True) +class RuntimeControlResult: + previous_state: BotState + state: BotState + command: BotCommand + accepted: bool + code: RuntimeControlCode + message: str + new_entries_allowed: bool + runtime_health_state: RuntimeHealthState | None + next_action: str + live_orders_action: str diff --git a/src/nfi_engine/runtime_control/service.py b/src/nfi_engine/runtime_control/service.py new file mode 100644 index 0000000..4166bb8 --- /dev/null +++ b/src/nfi_engine/runtime_control/service.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from typing import assert_never + +from nfi_engine.paper import BotCommand, BotState +from nfi_engine.runtime_control.models import ( + RuntimeControlCode, + RuntimeControlRequest, + RuntimeControlResult, +) +from nfi_engine.runtime_health import RuntimeHealthState + +LIVE_ORDER_NOOP = "No live exchange order cancellation is performed by this control." + + +def control_runtime(request: RuntimeControlRequest) -> RuntimeControlResult: + match request.command: + case BotCommand.START: + return _start(request) + case BotCommand.PAUSE: + return _pause(request) + case BotCommand.RESUME: + return _resume(request) + case BotCommand.STOP: + return _stop(request) + case unreachable: + assert_never(unreachable) + + +def new_entries_allowed(state: BotState) -> bool: + match state: + case BotState.RUNNING: + return True + case BotState.STOPPED | BotState.PAUSED | BotState.STOPPING: + return False + case unreachable: + assert_never(unreachable) + + +def _start(request: RuntimeControlRequest) -> RuntimeControlResult: + match request.state: + case BotState.STOPPED: + gate = _resume_gate(request) + if gate is not None: + return gate + return _accepted( + request, + state=BotState.RUNNING, + message="runtime started after safety gates passed", + next_action="Monitor runtime health before increasing risk.", + ) + case BotState.RUNNING: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_ALREADY_RUNNING, + message="runtime is already running", + next_action="Use pause or stop if the run should change state.", + ) + case BotState.PAUSED: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_INVALID_TRANSITION, + message="paused runtime must be resumed, not started", + next_action="Use resume after preflight and runtime health are clear.", + ) + case BotState.STOPPING: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_INVALID_TRANSITION, + message="runtime is stopping", + next_action="Wait for stop to settle before starting again.", + ) + case unreachable: + assert_never(unreachable) + + +def _pause(request: RuntimeControlRequest) -> RuntimeControlResult: + match request.state: + case BotState.RUNNING: + return _accepted( + request, + state=BotState.PAUSED, + message="new entries are paused; existing state remains inspectable", + next_action="Inspect runtime health before resuming entries.", + ) + case BotState.PAUSED: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_ALREADY_PAUSED, + message="runtime is already paused", + next_action="Use resume or stop.", + ) + case BotState.STOPPED | BotState.STOPPING: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_INVALID_TRANSITION, + message="runtime is not accepting entries", + next_action="Start the runtime before pausing entries.", + ) + case unreachable: + assert_never(unreachable) + + +def _resume(request: RuntimeControlRequest) -> RuntimeControlResult: + match request.state: + case BotState.PAUSED: + gate = _resume_gate(request) + if gate is not None: + return gate + return _accepted( + request, + state=BotState.RUNNING, + message="runtime resumed after safety gates passed", + next_action="Monitor runtime health before increasing risk.", + ) + case BotState.RUNNING: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_ALREADY_RUNNING, + message="runtime is already running", + next_action="Use pause or stop if the run should change state.", + ) + case BotState.STOPPED | BotState.STOPPING: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_INVALID_TRANSITION, + message="runtime cannot resume from the current state", + next_action="Start from stopped state after safety gates pass.", + ) + case unreachable: + assert_never(unreachable) + + +def _stop(request: RuntimeControlRequest) -> RuntimeControlResult: + match request.state: + case BotState.RUNNING | BotState.PAUSED | BotState.STOPPING: + return _accepted( + request, + state=BotState.STOPPED, + message="runtime stopped locally without live-order side effects", + next_action="Inspect open positions before starting again.", + ) + case BotState.STOPPED: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_ALREADY_STOPPED, + message="runtime is already stopped", + next_action="Use start after preflight and runtime health are clear.", + ) + case unreachable: + assert_never(unreachable) + + +def _resume_gate(request: RuntimeControlRequest) -> RuntimeControlResult | None: + if request.settings.engine.live_trading: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_LIVE_UNSAFE, + message="live trading runtime controls are not enabled in this milestone", + next_action="Switch to paper/testnet or complete the live safety milestone.", + ) + if request.readiness is None: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_PREFLIGHT_REQUIRED, + message="preflight report is required before entries can run", + next_action="Run preflight and fix blocked checks before starting.", + ) + if request.readiness.blocked: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_PREFLIGHT_BLOCKED, + message="preflight is blocking runtime entries", + next_action="Open Settings and resolve blocked preflight checks.", + ) + if request.health is None: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_HEALTH_REQUIRED, + message="runtime health snapshot is required before entries can run", + next_action="Refresh runtime health before starting or resuming.", + ) + match request.health.state: + case RuntimeHealthState.BLOCKED: + return _denied( + request, + code=RuntimeControlCode.RUNTIME_HEALTH_BLOCKED, + message="runtime health blocks new entries", + next_action=request.health.next_action, + ) + case RuntimeHealthState.HEALTHY | RuntimeHealthState.DEGRADED: + return None + case unreachable: + assert_never(unreachable) + + +def _accepted( + request: RuntimeControlRequest, + *, + state: BotState, + message: str, + next_action: str, +) -> RuntimeControlResult: + return RuntimeControlResult( + previous_state=request.state, + state=state, + command=request.command, + accepted=True, + code=RuntimeControlCode.RUNTIME_CONTROL_ACCEPTED, + message=message, + new_entries_allowed=new_entries_allowed(state), + runtime_health_state=_health_state(request), + next_action=next_action, + live_orders_action=LIVE_ORDER_NOOP, + ) + + +def _denied( + request: RuntimeControlRequest, + *, + code: RuntimeControlCode, + message: str, + next_action: str, +) -> RuntimeControlResult: + return RuntimeControlResult( + previous_state=request.state, + state=request.state, + command=request.command, + accepted=False, + code=code, + message=message, + new_entries_allowed=new_entries_allowed(request.state), + runtime_health_state=_health_state(request), + next_action=next_action, + live_orders_action=LIVE_ORDER_NOOP, + ) + + +def _health_state(request: RuntimeControlRequest) -> RuntimeHealthState | None: + if request.health is None: + return None + return request.health.state diff --git a/src/nfi_engine/runtime_health/__init__.py b/src/nfi_engine/runtime_health/__init__.py new file mode 100644 index 0000000..a942cd7 --- /dev/null +++ b/src/nfi_engine/runtime_health/__init__.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from nfi_engine.runtime_health.models import ( + RuntimeHealthCheck, + RuntimeHealthCode, + RuntimeHealthSnapshot, + RuntimeHealthState, + RuntimeResourceSnapshot, +) +from nfi_engine.runtime_health.resources import collect_runtime_resources +from nfi_engine.runtime_health.service import RuntimeHealthRequest, build_runtime_health_snapshot + +__all__ = [ + "RuntimeHealthCheck", + "RuntimeHealthCode", + "RuntimeHealthRequest", + "RuntimeHealthSnapshot", + "RuntimeHealthState", + "RuntimeResourceSnapshot", + "build_runtime_health_snapshot", + "collect_runtime_resources", +] diff --git a/src/nfi_engine/runtime_health/freshness.py b/src/nfi_engine/runtime_health/freshness.py new file mode 100644 index 0000000..7c3081b --- /dev/null +++ b/src/nfi_engine/runtime_health/freshness.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from nfi_engine.dashboard import DashboardReadModels + + +def latest_dashboard_at(read_models: DashboardReadModels) -> datetime | None: + timestamps = ( + tuple(point.at for point in read_models.equity_points) + + tuple(point.at for point in read_models.price_points) + + tuple(position.updated_at for position in read_models.open_positions) + + tuple(trade.opened_at for trade in read_models.recent_trades) + + tuple( + trade.closed_at for trade in read_models.recent_trades if trade.closed_at is not None + ) + ) + if not timestamps: + return None + return max(_aware(timestamp) for timestamp in timestamps) + + +def _aware(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) diff --git a/src/nfi_engine/runtime_health/models.py b/src/nfi_engine/runtime_health/models.py new file mode 100644 index 0000000..f22fcff --- /dev/null +++ b/src/nfi_engine/runtime_health/models.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum, unique + +from nfi_engine.strategy.nfi_x7 import X7SemanticStatus +from nfi_engine.wallet import WalletBalanceSnapshot + + +@unique +class RuntimeHealthState(StrEnum): + HEALTHY = "healthy" + DEGRADED = "degraded" + BLOCKED = "blocked" + + +@unique +class RuntimeHealthCode(StrEnum): + ENGINE_HEARTBEAT = "ENGINE_HEARTBEAT" + PREFLIGHT = "PREFLIGHT" + WALLET_BALANCE = "WALLET_BALANCE" + DATA_FRESHNESS = "DATA_FRESHNESS" + CLOCK_SKEW = "CLOCK_SKEW" + DISK_BUDGET = "DISK_BUDGET" + MEMORY_BUDGET = "MEMORY_BUDGET" + CIRCUIT_BREAKER_STATE = "CIRCUIT_BREAKER_STATE" + + +@dataclass(frozen=True, slots=True) +class RuntimeHealthCheck: + code: RuntimeHealthCode + state: RuntimeHealthState + message: str + next_action: str + + +@dataclass(frozen=True, slots=True) +class RuntimeResourceSnapshot: + captured_at: datetime + free_disk_bytes: int + memory_rss_bytes: int + disk_state: RuntimeHealthState + memory_state: RuntimeHealthState + + +@dataclass(frozen=True, slots=True) +class RuntimeHealthSnapshot: + generated_at: datetime + state: RuntimeHealthState + next_action: str + checks: tuple[RuntimeHealthCheck, ...] + resources: RuntimeResourceSnapshot + wallet_balance: WalletBalanceSnapshot + x7_semantic_status: X7SemanticStatus diff --git a/src/nfi_engine/runtime_health/resources.py b/src/nfi_engine/runtime_health/resources.py new file mode 100644 index 0000000..b7454da --- /dev/null +++ b/src/nfi_engine/runtime_health/resources.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import resource +import shutil +from datetime import UTC, datetime +from pathlib import Path +from typing import Final + +from nfi_engine.runtime_health.models import RuntimeHealthState, RuntimeResourceSnapshot + +MIN_FREE_DISK_BYTES: Final = 128 * 1024 * 1024 +WARN_RSS_BYTES: Final = 768 * 1024 * 1024 + + +def collect_runtime_resources( + *, + path: Path | None = None, + now: datetime | None = None, +) -> RuntimeResourceSnapshot: + resolved_path = path if path is not None else Path() + disk_usage = shutil.disk_usage(resolved_path) + rss_bytes = _rss_bytes() + return RuntimeResourceSnapshot( + captured_at=now if now is not None else datetime.now(UTC), + free_disk_bytes=disk_usage.free, + memory_rss_bytes=rss_bytes, + disk_state=( + RuntimeHealthState.HEALTHY + if disk_usage.free >= MIN_FREE_DISK_BYTES + else RuntimeHealthState.BLOCKED + ), + memory_state=( + RuntimeHealthState.HEALTHY + if rss_bytes <= WARN_RSS_BYTES + else RuntimeHealthState.DEGRADED + ), + ) + + +def _rss_bytes() -> int: + usage = resource.getrusage(resource.RUSAGE_SELF) + return int(usage.ru_maxrss) * 1024 diff --git a/src/nfi_engine/runtime_health/service.py b/src/nfi_engine/runtime_health/service.py new file mode 100644 index 0000000..b6b43dd --- /dev/null +++ b/src/nfi_engine/runtime_health/service.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import assert_never + +from nfi_engine.config import RuntimeSettings +from nfi_engine.dashboard import DashboardReadModels +from nfi_engine.paper import BotState +from nfi_engine.preflight.models import PreflightReport +from nfi_engine.runtime_health.freshness import latest_dashboard_at +from nfi_engine.runtime_health.models import ( + RuntimeHealthCheck, + RuntimeHealthCode, + RuntimeHealthSnapshot, + RuntimeHealthState, + RuntimeResourceSnapshot, +) +from nfi_engine.runtime_health.resources import collect_runtime_resources +from nfi_engine.strategy.nfi_x7 import build_x7_semantic_status +from nfi_engine.wallet import WalletBalanceSnapshot, WalletBalanceStatus + +CLOCK_SKEW_GRACE_SECONDS = 60 + + +@dataclass(frozen=True, slots=True) +class RuntimeHealthRequest: + settings: RuntimeSettings + bot_state: BotState + readiness: PreflightReport | None + read_models: DashboardReadModels + wallet_balance: WalletBalanceSnapshot + now: datetime | None = None + resources: RuntimeResourceSnapshot | None = None + + +def build_runtime_health_snapshot(request: RuntimeHealthRequest) -> RuntimeHealthSnapshot: + generated_at = request.now if request.now is not None else datetime.now(UTC) + resource_snapshot = ( + request.resources + if request.resources is not None + else collect_runtime_resources(path=_resource_path(request.settings), now=generated_at) + ) + checks = ( + _heartbeat_check(request.bot_state), + _preflight_check(request.readiness), + _wallet_check(request.wallet_balance), + _freshness_check( + settings=request.settings, + read_models=request.read_models, + generated_at=generated_at, + ), + _manual_halt_check(request.settings), + _resource_check( + code=RuntimeHealthCode.DISK_BUDGET, + state=resource_snapshot.disk_state, + message=f"free_disk_bytes={resource_snapshot.free_disk_bytes}", + blocked_action="Free disk space before starting a run.", + ), + _resource_check( + code=RuntimeHealthCode.MEMORY_BUDGET, + state=resource_snapshot.memory_state, + message=f"memory_rss_bytes={resource_snapshot.memory_rss_bytes}", + blocked_action="Review memory use before running on Raspberry Pi 4.", + ), + ) + state = _overall_state(checks) + return RuntimeHealthSnapshot( + generated_at=generated_at, + state=state, + next_action=_next_action(checks, state), + checks=checks, + resources=resource_snapshot, + wallet_balance=request.wallet_balance, + x7_semantic_status=build_x7_semantic_status( + settings=request.settings, + readiness=request.readiness, + dashboard_data_observed=latest_dashboard_at(request.read_models) is not None, + ), + ) + + +def _heartbeat_check(bot_state: BotState) -> RuntimeHealthCheck: + return RuntimeHealthCheck( + code=RuntimeHealthCode.ENGINE_HEARTBEAT, + state=RuntimeHealthState.HEALTHY, + message=f"bot_state={bot_state.value}", + next_action="No heartbeat action required.", + ) + + +def _preflight_check(readiness: PreflightReport | None) -> RuntimeHealthCheck: + if readiness is None: + return RuntimeHealthCheck( + code=RuntimeHealthCode.PREFLIGHT, + state=RuntimeHealthState.DEGRADED, + message="preflight report is not loaded", + next_action="Run preflight before starting a run.", + ) + if readiness.blocked: + return RuntimeHealthCheck( + code=RuntimeHealthCode.PREFLIGHT, + state=RuntimeHealthState.BLOCKED, + message="preflight is blocking startup", + next_action="Open Settings and fix blocked preflight checks.", + ) + return RuntimeHealthCheck( + code=RuntimeHealthCode.PREFLIGHT, + state=RuntimeHealthState.HEALTHY, + message="preflight checks are not blocking startup", + next_action="No preflight action required.", + ) + + +def _wallet_check(wallet: WalletBalanceSnapshot) -> RuntimeHealthCheck: + match wallet.status: + case WalletBalanceStatus.FETCHED: + state = RuntimeHealthState.HEALTHY + case WalletBalanceStatus.BLOCKED: + state = RuntimeHealthState.BLOCKED + case WalletBalanceStatus.UNAVAILABLE | WalletBalanceStatus.ERROR: + state = RuntimeHealthState.DEGRADED + case unreachable: + assert_never(unreachable) + return RuntimeHealthCheck( + code=RuntimeHealthCode.WALLET_BALANCE, + state=state, + message=wallet.code.value, + next_action=wallet.next_action, + ) + + +def _freshness_check( + *, + settings: RuntimeSettings, + read_models: DashboardReadModels, + generated_at: datetime, +) -> RuntimeHealthCheck: + latest_at = latest_dashboard_at(read_models) + if latest_at is None: + return RuntimeHealthCheck( + code=RuntimeHealthCode.DATA_FRESHNESS, + state=RuntimeHealthState.DEGRADED, + message="no dashboard data has been recorded yet", + next_action="Run paper/testnet once to seed dashboard health data.", + ) + if latest_at > generated_at + timedelta(seconds=CLOCK_SKEW_GRACE_SECONDS): + return RuntimeHealthCheck( + code=RuntimeHealthCode.CLOCK_SKEW, + state=RuntimeHealthState.BLOCKED, + message=f"latest_runtime_at={latest_at.isoformat()}", + next_action="Fix system clock skew before starting a run.", + ) + stale_after = timedelta(seconds=settings.circuit_breakers.max_stale_seconds) + if generated_at - latest_at > stale_after: + return RuntimeHealthCheck( + code=RuntimeHealthCode.DATA_FRESHNESS, + state=RuntimeHealthState.BLOCKED, + message=f"latest_runtime_at={latest_at.isoformat()}", + next_action="Refresh market data before starting a run.", + ) + return RuntimeHealthCheck( + code=RuntimeHealthCode.DATA_FRESHNESS, + state=RuntimeHealthState.HEALTHY, + message=f"latest_runtime_at={latest_at.isoformat()}", + next_action="No data freshness action required.", + ) + + +def _manual_halt_check(settings: RuntimeSettings) -> RuntimeHealthCheck: + if _manual_halt_active(settings): + return RuntimeHealthCheck( + code=RuntimeHealthCode.CIRCUIT_BREAKER_STATE, + state=RuntimeHealthState.BLOCKED, + message="manual halt is enabled", + next_action="Disable manual halt only after reviewing the reason.", + ) + return RuntimeHealthCheck( + code=RuntimeHealthCode.CIRCUIT_BREAKER_STATE, + state=RuntimeHealthState.HEALTHY, + message="manual halt is disabled", + next_action="No circuit-breaker action required.", + ) + + +def _manual_halt_active(settings: RuntimeSettings) -> bool: + circuit_breakers = settings.circuit_breakers + return circuit_breakers.manual_halt or _manual_halt_file_exists( + circuit_breakers.manual_halt_file + ) + + +def _manual_halt_file_exists(raw_path: str | None) -> bool: + if raw_path is None: + return False + path = raw_path.strip() + if path == "": + return False + return Path(path).exists() + + +def _resource_check( + *, + code: RuntimeHealthCode, + state: RuntimeHealthState, + message: str, + blocked_action: str, +) -> RuntimeHealthCheck: + if state is RuntimeHealthState.HEALTHY: + return RuntimeHealthCheck( + code=code, + state=state, + message=message, + next_action="No resource action required.", + ) + return RuntimeHealthCheck( + code=code, + state=state, + message=message, + next_action=blocked_action, + ) + + +def _overall_state(checks: tuple[RuntimeHealthCheck, ...]) -> RuntimeHealthState: + if any(check.state is RuntimeHealthState.BLOCKED for check in checks): + return RuntimeHealthState.BLOCKED + if any(check.state is RuntimeHealthState.DEGRADED for check in checks): + return RuntimeHealthState.DEGRADED + return RuntimeHealthState.HEALTHY + + +def _next_action( + checks: tuple[RuntimeHealthCheck, ...], + state: RuntimeHealthState, +) -> str: + for check in checks: + if check.state is state and state is not RuntimeHealthState.HEALTHY: + return check.next_action + return "Runtime health is ready for paper/testnet operation." + + +def _resource_path(settings: RuntimeSettings) -> Path: + if settings.database.url.startswith("sqlite+aiosqlite:///"): + database_path = settings.database.url.removeprefix("sqlite+aiosqlite:///") + path = Path(database_path).parent + if str(path) == "": + return Path() + return _existing_parent(path) + return Path() + + +def _existing_parent(path: Path) -> Path: + current = path + while not current.exists() and current != current.parent: + current = current.parent + return current diff --git a/src/nfi_engine/setup/models.py b/src/nfi_engine/setup/models.py index b7020d4..0203dc1 100644 --- a/src/nfi_engine/setup/models.py +++ b/src/nfi_engine/setup/models.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from decimal import Decimal from enum import StrEnum, unique from pathlib import Path from typing import ClassVar @@ -8,8 +9,10 @@ from pydantic import ConfigDict, field_validator from nfi_engine.config import Locale +from nfi_engine.config.enums import RiskProfileName from nfi_engine.config.models import RuntimeSettings, StrictConfigModel from nfi_engine.domain import MarginMode, TradingMode +from nfi_engine.exchange.permissions import ExchangeApiPermissionState @unique @@ -34,8 +37,16 @@ class SetupRequest(StrictConfigModel): intent: SetupIntent = SetupIntent.PAPER api_key: str = "" api_secret: str = "" - risk_preset: RiskPreset = RiskPreset.BALANCED + risk_profile: RiskProfileName = RiskProfileName.BALANCED + risk_preset: RiskPreset | None = None + expert_risk_confirmed: bool = False + allocated_amount_usdt: Decimal | None = None margin_mode: MarginMode | None = None + permission_read: ExchangeApiPermissionState = ExchangeApiPermissionState.UNKNOWN + permission_trade: ExchangeApiPermissionState = ExchangeApiPermissionState.UNKNOWN + permission_futures: ExchangeApiPermissionState = ExchangeApiPermissionState.UNKNOWN + permission_withdrawal: ExchangeApiPermissionState = ExchangeApiPermissionState.UNKNOWN + permission_ip_allowlist: ExchangeApiPermissionState = ExchangeApiPermissionState.UNKNOWN live_trading_confirmed: bool = False locale: Locale = Locale.EN @@ -48,6 +59,14 @@ def _strip_safe_text(cls, value: str) -> str: raise ValueError(message) return normalized + @field_validator("allocated_amount_usdt") + @classmethod + def _positive_allocated_amount(cls, value: Decimal | None) -> Decimal | None: + if value is not None and value <= Decimal(0): + message = "allocated amount must be greater than 0" + raise ValueError(message) + return value + @dataclass(frozen=True, slots=True) class SetupPlan: diff --git a/src/nfi_engine/setup/service.py b/src/nfi_engine/setup/service.py index 4cf8ecd..63f55f1 100644 --- a/src/nfi_engine/setup/service.py +++ b/src/nfi_engine/setup/service.py @@ -3,13 +3,16 @@ from dataclasses import dataclass from decimal import Decimal from pathlib import Path -from typing import NoReturn +from typing import NoReturn, assert_never, override from nfi_engine.config import ConfigLoadError, validate_runtime_settings +from nfi_engine.config.enums import RiskProfileName from nfi_engine.config.models import EngineSettings, ExchangeSettings, RiskSettings, RuntimeSettings from nfi_engine.domain import MarginMode, TradingMode from nfi_engine.events import REDACTED_TEXT from nfi_engine.events.redaction import redact_text +from nfi_engine.exchange.permissions import audit_exchange_api_permissions +from nfi_engine.risk.profiles import get_risk_profile from nfi_engine.setup.models import RiskPreset, SetupIntent, SetupPlan, SetupRequest PREVIEW_PATH = Path("") @@ -20,11 +23,17 @@ class SetupError(Exception): code: str message: str + @override + def __str__(self) -> str: + return f"{self.code}: {self.message}" + def build_setup_plan(request: SetupRequest) -> SetupPlan: try: settings = _settings_from_request(request) config_text = render_setup_config(settings) + except SetupError as exc: + return _invalid_plan(error=exc.code, request=request) except ValueError as exc: return _invalid_plan(error=str(exc), request=request) redacted = _redact_config(config_text=config_text, request=request) @@ -66,6 +75,8 @@ def write_setup_config(*, request: SetupRequest, config_path: Path, overwrite: b def render_setup_config(settings: RuntimeSettings) -> str: + permission_withdrawal = _yaml_scalar(settings.exchange.permission_withdrawal.value) + permission_ip_allowlist = _yaml_scalar(settings.exchange.permission_ip_allowlist.value) lines = [ "engine:", f" live_trading: {_bool(settings.engine.live_trading)}", @@ -82,8 +93,17 @@ def render_setup_config(settings: RuntimeSettings) -> str: f" testnet: {_bool(settings.exchange.testnet)}", f" api_key: {_nullable(settings.exchange.api_key)}", f" api_secret: {_nullable(settings.exchange.api_secret)}", + f" permission_read: {_yaml_scalar(settings.exchange.permission_read.value)}", + f" permission_trade: {_yaml_scalar(settings.exchange.permission_trade.value)}", + f" permission_futures: {_yaml_scalar(settings.exchange.permission_futures.value)}", + f" permission_withdrawal: {permission_withdrawal}", + f" permission_ip_allowlist: {permission_ip_allowlist}", "risk:", + f" risk_profile: {_yaml_scalar(settings.risk.risk_profile.value)}", + f" expert_risk_confirmed: {_bool(settings.risk.expert_risk_confirmed)}", f" stake_usdt: {_yaml_scalar(str(settings.risk.stake_usdt))}", + f" max_daily_loss_pct: {_yaml_scalar(str(settings.risk.max_daily_loss_pct))}", + f" allocation_cap_pct: {_yaml_scalar(str(settings.risk.allocation_cap_pct))}", f" leverage: {_yaml_scalar(str(settings.risk.leverage))}", f" max_leverage: {_yaml_scalar(str(settings.risk.max_leverage))}", f" max_open_trades: {settings.risk.max_open_trades}", @@ -99,6 +119,7 @@ def render_setup_config(settings: RuntimeSettings) -> str: def _settings_from_request(request: SetupRequest) -> RuntimeSettings: + _assert_live_permissions(request) return RuntimeSettings( engine=EngineSettings( live_trading=request.intent is SetupIntent.LIVE, @@ -111,6 +132,11 @@ def _settings_from_request(request: SetupRequest) -> RuntimeSettings: testnet=request.intent is not SetupIntent.LIVE, api_key=request.api_key or None, api_secret=request.api_secret or None, + permission_read=request.permission_read, + permission_trade=request.permission_trade, + permission_futures=request.permission_futures, + permission_withdrawal=request.permission_withdrawal, + permission_ip_allowlist=request.permission_ip_allowlist, ), risk=_risk_settings(request), ui=RuntimeSettings().ui.model_copy(update={"locale": request.locale}), @@ -118,37 +144,75 @@ def _settings_from_request(request: SetupRequest) -> RuntimeSettings: def _margin_mode(request: SetupRequest) -> MarginMode | None: - if request.trading_mode is TradingMode.SPOT: - return request.margin_mode - return request.margin_mode or MarginMode.ISOLATED + match request.trading_mode: + case TradingMode.SPOT: + return request.margin_mode + case TradingMode.FUTURES: + return request.margin_mode or MarginMode.ISOLATED + case unreachable: + assert_never(unreachable) def _risk_settings(request: SetupRequest) -> RiskSettings: - profile = _risk_profile(request.risk_preset) - leverage = profile.leverage if request.trading_mode is TradingMode.FUTURES else Decimal(1) + profile = get_risk_profile(_risk_profile_name(request)) + if profile.requires_confirmation and not request.expert_risk_confirmed: + raise SetupError( + code="EXPERT_RISK_REQUIRES_CONFIRMATION", + message="expert risk profile requires expert_risk_confirmed=true", + ) + match request.trading_mode: + case TradingMode.SPOT: + leverage = Decimal(1) + case TradingMode.FUTURES: + leverage = profile.leverage + case unreachable: + assert_never(unreachable) return RiskSettings( - stake_usdt=profile.stake_usdt, + risk_profile=profile.name, + expert_risk_confirmed=request.expert_risk_confirmed, + stake_usdt=profile.stake_usdt + if request.allocated_amount_usdt is None + else request.allocated_amount_usdt, + max_daily_loss_pct=profile.max_daily_loss_pct, + allocation_cap_pct=profile.allocation_cap_pct, leverage=leverage, - max_leverage=Decimal(5), + max_leverage=profile.max_leverage, max_open_trades=profile.max_open_trades, ) -@dataclass(frozen=True, slots=True) -class _RiskProfile: - stake_usdt: Decimal - leverage: Decimal - max_open_trades: int +def _assert_live_permissions(request: SetupRequest) -> None: + match request.intent: + case SetupIntent.PAPER | SetupIntent.TESTNET: + return + case SetupIntent.LIVE: + pass + case unreachable: + assert_never(unreachable) + audit = audit_exchange_api_permissions( + read=request.permission_read, + trade=request.permission_trade, + futures=request.permission_futures, + withdrawal=request.permission_withdrawal, + ip_allowlist=request.permission_ip_allowlist, + ) + if audit.live_safe: + return + raise SetupError(code=audit.live_blocking_codes[0], message=audit.summary) -def _risk_profile(preset: RiskPreset) -> _RiskProfile: - match preset: +def _risk_profile_name(request: SetupRequest) -> RiskProfileName: + if request.risk_preset is None: + return request.risk_profile + match request.risk_preset: case RiskPreset.CONSERVATIVE: - return _RiskProfile(stake_usdt=Decimal(10), leverage=Decimal(1), max_open_trades=2) + return RiskProfileName.SAFE case RiskPreset.BALANCED: - return _RiskProfile(stake_usdt=Decimal(25), leverage=Decimal(2), max_open_trades=3) + return RiskProfileName.BALANCED case RiskPreset.AGGRESSIVE: - return _RiskProfile(stake_usdt=Decimal(50), leverage=Decimal(3), max_open_trades=5) + return RiskProfileName.EXPERT + case unreachable: + assert_never(unreachable) def _invalid_plan(*, error: str, request: SetupRequest) -> SetupPlan: diff --git a/src/nfi_engine/strategy/__init__.py b/src/nfi_engine/strategy/__init__.py index e3c0a8d..4bae5e3 100644 --- a/src/nfi_engine/strategy/__init__.py +++ b/src/nfi_engine/strategy/__init__.py @@ -1,8 +1,11 @@ from __future__ import annotations from nfi_engine.strategy.adapter import FreqtradeStrategyAdapter, load_freqtrade_strategy +from nfi_engine.strategy.callbacks import CALLBACK_NAMES from nfi_engine.strategy.dtos import ( + CallbackSupportLevel, RunMode, + StrategyCallbackSupport, StrategyInspection, StrategyMetadata, StrategyOrder, @@ -11,15 +14,30 @@ ) from nfi_engine.strategy.errors import StrategyContractError, StrategyErrorCode from nfi_engine.strategy.frame import ( - DataProviderFacade, - PairFrame, SignalColumns, + StrategyFeature, + StrategyFeatureName, StrategyFrame, + StrategyOhlcv, StrategyRow, ) from nfi_engine.strategy.protocols import NativeStrategy, RequiredFreqtradeStrategy +from nfi_engine.strategy.provider import DataProviderFacade, PairFrame +from nfi_engine.strategy.timeline import ( + StrategyTimeline, + StrategyTimelineBuilder, + StrategyTimelineStep, + TimelinePayload, + TimelineStepPayload, + TimelineSurface, + count_strategy_signals, + strategy_signal_sides, + timeline_to_payload, +) __all__ = [ + "CALLBACK_NAMES", + "CallbackSupportLevel", "DataProviderFacade", "FreqtradeStrategyAdapter", "NativeStrategy", @@ -27,14 +45,27 @@ "RequiredFreqtradeStrategy", "RunMode", "SignalColumns", + "StrategyCallbackSupport", "StrategyContractError", "StrategyErrorCode", + "StrategyFeature", + "StrategyFeatureName", "StrategyFrame", "StrategyInspection", "StrategyMetadata", + "StrategyOhlcv", "StrategyOrder", "StrategyRow", "StrategySignal", + "StrategyTimeline", + "StrategyTimelineBuilder", + "StrategyTimelineStep", "StrategyTrade", + "TimelinePayload", + "TimelineStepPayload", + "TimelineSurface", + "count_strategy_signals", "load_freqtrade_strategy", + "strategy_signal_sides", + "timeline_to_payload", ] diff --git a/src/nfi_engine/strategy/adapter.py b/src/nfi_engine/strategy/adapter.py index 938eb87..f8ebd98 100644 --- a/src/nfi_engine/strategy/adapter.py +++ b/src/nfi_engine/strategy/adapter.py @@ -5,29 +5,15 @@ from dataclasses import dataclass from pathlib import Path from types import ModuleType -from typing import Final, Self +from typing import Self from nfi_engine.domain import Leverage, PositionSide, SignalType, TradingPair +from nfi_engine.strategy.callbacks import build_callback_support, detect_known_callbacks from nfi_engine.strategy.dtos import StrategyInspection, StrategyMetadata, StrategySignal from nfi_engine.strategy.errors import StrategyContractError, StrategyErrorCode from nfi_engine.strategy.frame import StrategyFrame, StrategyRow from nfi_engine.strategy.protocols import LeverageCallback, RequiredFreqtradeStrategy -CALLBACK_NAMES: Final = ( - "populate_indicators", - "populate_entry_trend", - "populate_exit_trend", - "informative_pairs", - "custom_exit", - "custom_stake_amount", - "order_filled", - "adjust_trade_position", - "confirm_trade_entry", - "confirm_trade_exit", - "bot_loop_start", - "leverage", -) - @dataclass(frozen=True, slots=True) class FreqtradeStrategyAdapter: @@ -50,11 +36,8 @@ def inspect(self) -> StrategyInspection: name=type(self.strategy).__name__, can_short=self.strategy.can_short, timeframe=self.strategy.timeframe, - detected_callbacks=tuple( - callback_name - for callback_name in CALLBACK_NAMES - if callable(getattr(self.strategy, callback_name, None)) - ), + detected_callbacks=detect_known_callbacks(self.strategy), + callback_support=build_callback_support(self.strategy), ) def analyze( @@ -168,6 +151,7 @@ def _signals_from_row( pair=metadata.pair, side=PositionSide.LONG, signal_type=SignalType.EXIT, + tag=row.exit_tag, ), ) if row.exit_short: @@ -176,6 +160,7 @@ def _signals_from_row( pair=metadata.pair, side=PositionSide.SHORT, signal_type=SignalType.EXIT, + tag=row.exit_tag, ), ) return tuple(signals) diff --git a/src/nfi_engine/strategy/callbacks.py b/src/nfi_engine/strategy/callbacks.py new file mode 100644 index 0000000..cac95f0 --- /dev/null +++ b/src/nfi_engine/strategy/callbacks.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from typing import Final + +from nfi_engine.strategy.dtos import CallbackSupportLevel, StrategyCallbackSupport +from nfi_engine.strategy.protocols import RequiredFreqtradeStrategy + +CALLBACK_NAMES: Final = ( + "populate_indicators", + "populate_entry_trend", + "populate_exit_trend", + "informative_pairs", + "custom_exit", + "custom_stake_amount", + "order_filled", + "adjust_trade_position", + "confirm_trade_entry", + "confirm_trade_exit", + "bot_loop_start", + "leverage", +) +UNKNOWN_CALLBACK_PREFIXES: Final = ("custom_", "confirm_", "adjust_", "check_", "order_", "bot_") + +SUPPORTED_CALLBACKS: Final = frozenset( + ( + "populate_indicators", + "populate_entry_trend", + "populate_exit_trend", + ), +) +PARTIAL_CALLBACKS: Final = frozenset( + callback_name for callback_name in CALLBACK_NAMES if callback_name not in SUPPORTED_CALLBACKS +) +SUPPORTED_REASON: Final = "implemented in the clean-room strategy adapter contract" +PARTIAL_REASON: Final = "detected and reported; full runtime behavior requires fixture evidence" +EXCLUDED_REASON: Final = "outside the current clean-room callback contract" + + +def detect_known_callbacks(strategy: RequiredFreqtradeStrategy) -> tuple[str, ...]: + return tuple( + callback_name + for callback_name in CALLBACK_NAMES + if _has_callable_attribute(strategy=strategy, attribute_name=callback_name) + ) + + +def build_callback_support( + strategy: RequiredFreqtradeStrategy, +) -> tuple[StrategyCallbackSupport, ...]: + known = tuple( + _known_callback_support(strategy=strategy, callback_name=callback_name) + for callback_name in CALLBACK_NAMES + ) + excluded = tuple( + StrategyCallbackSupport( + name=callback_name, + level=CallbackSupportLevel.EXCLUDED, + detected=True, + reason=EXCLUDED_REASON, + ) + for callback_name in _unknown_public_callbacks(strategy) + ) + return (*known, *excluded) + + +def _known_callback_support( + *, + strategy: RequiredFreqtradeStrategy, + callback_name: str, +) -> StrategyCallbackSupport: + level = _known_callback_level(callback_name) + return StrategyCallbackSupport( + name=callback_name, + level=level, + detected=_has_callable_attribute(strategy=strategy, attribute_name=callback_name), + reason=_reason_for_level(level), + ) + + +def _known_callback_level(callback_name: str) -> CallbackSupportLevel: + if callback_name in SUPPORTED_CALLBACKS: + return CallbackSupportLevel.SUPPORTED + return CallbackSupportLevel.PARTIAL + + +def _reason_for_level(level: CallbackSupportLevel) -> str: + match level: + case CallbackSupportLevel.SUPPORTED: + return SUPPORTED_REASON + case CallbackSupportLevel.PARTIAL: + return PARTIAL_REASON + case CallbackSupportLevel.EXCLUDED: + return EXCLUDED_REASON + + +def _unknown_public_callbacks(strategy: RequiredFreqtradeStrategy) -> tuple[str, ...]: + known = frozenset(CALLBACK_NAMES) + return tuple( + attribute_name + for attribute_name in sorted(dir(strategy)) + if attribute_name not in known + and not attribute_name.startswith("_") + and attribute_name.startswith(UNKNOWN_CALLBACK_PREFIXES) + and _has_callable_attribute(strategy=strategy, attribute_name=attribute_name) + ) + + +def _has_callable_attribute( + *, + strategy: RequiredFreqtradeStrategy, + attribute_name: str, +) -> bool: + return callable(getattr(strategy, attribute_name, None)) diff --git a/src/nfi_engine/strategy/dtos.py b/src/nfi_engine/strategy/dtos.py index 110bb78..fc9a9a8 100644 --- a/src/nfi_engine/strategy/dtos.py +++ b/src/nfi_engine/strategy/dtos.py @@ -20,6 +20,21 @@ class RunMode(StrEnum): LIVE = "live" +@unique +class CallbackSupportLevel(StrEnum): + SUPPORTED = "supported" + PARTIAL = "partial" + EXCLUDED = "excluded" + + +@dataclass(frozen=True, slots=True) +class StrategyCallbackSupport: + name: str + level: CallbackSupportLevel + detected: bool + reason: str + + @dataclass(frozen=True, slots=True) class StrategyMetadata: pair: TradingPair @@ -69,3 +84,4 @@ class StrategyInspection: can_short: bool timeframe: str detected_callbacks: tuple[str, ...] + callback_support: tuple[StrategyCallbackSupport, ...] diff --git a/src/nfi_engine/strategy/errors.py b/src/nfi_engine/strategy/errors.py index dd04fd8..a3e77c9 100644 --- a/src/nfi_engine/strategy/errors.py +++ b/src/nfi_engine/strategy/errors.py @@ -8,6 +8,8 @@ @unique class StrategyErrorCode(StrEnum): DATA_PROVIDER_FRAME_NOT_FOUND = "DATA_PROVIDER_FRAME_NOT_FOUND" + DATA_PROVIDER_FRAME_STALE = "DATA_PROVIDER_FRAME_STALE" + STRATEGY_FEATURE_NOT_FOUND = "STRATEGY_FEATURE_NOT_FOUND" LOOKAHEAD_ACCESS = "LOOKAHEAD_ACCESS" STRATEGY_CONTRACT_ERROR = "STRATEGY_CONTRACT_ERROR" STRATEGY_LOAD_ERROR = "STRATEGY_LOAD_ERROR" diff --git a/src/nfi_engine/strategy/frame.py b/src/nfi_engine/strategy/frame.py index 73e8944..2c0f86e 100644 --- a/src/nfi_engine/strategy/frame.py +++ b/src/nfi_engine/strategy/frame.py @@ -1,12 +1,15 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from decimal import Decimal -from typing import Self +from typing import Final, NewType, Self -from nfi_engine.domain import TradingPair from nfi_engine.strategy.errors import StrategyContractError, StrategyErrorCode +StrategyFeatureName = NewType("StrategyFeatureName", str) +MAX_FEATURES_PER_ROW: Final = 512 +ZERO: Final = Decimal(0) + @dataclass(frozen=True, slots=True) class SignalColumns: @@ -15,29 +18,108 @@ class SignalColumns: exit_long: bool = False exit_short: bool = False enter_tag: str | None = None + exit_tag: str | None = None + + +@dataclass(frozen=True, slots=True) +class StrategyOhlcv: + open: Decimal + high: Decimal + low: Decimal + close: Decimal + volume: Decimal + + @classmethod + def from_close(cls, close: Decimal) -> Self: + return cls(open=close, high=close, low=close, close=close, volume=ZERO) + + +@dataclass(frozen=True, slots=True) +class StrategyFeature: + name: StrategyFeatureName + value: Decimal @dataclass(frozen=True, slots=True) class StrategyRow: date: str close: Decimal + ohlcv: StrategyOhlcv | None = None + features: tuple[StrategyFeature, ...] = () enter_long: bool = False enter_short: bool = False exit_long: bool = False exit_short: bool = False enter_tag: str | None = None + exit_tag: str | None = None + + def __post_init__(self) -> None: + ohlcv = StrategyOhlcv.from_close(self.close) if self.ohlcv is None else self.ohlcv + if ohlcv.close != self.close: + raise StrategyContractError( + code=StrategyErrorCode.STRATEGY_CONTRACT_ERROR, + message="strategy row close must match OHLCV close", + ) + object.__setattr__(self, "ohlcv", ohlcv) + object.__setattr__(self, "features", _deduplicate_features(self.features)) + + @property + def open(self) -> Decimal: + return self._ohlcv().open + + @property + def high(self) -> Decimal: + return self._ohlcv().high + + @property + def low(self) -> Decimal: + return self._ohlcv().low + + @property + def volume(self) -> Decimal: + return self._ohlcv().volume + + def feature(self, name: StrategyFeatureName) -> Decimal: + for feature in self.features: + if feature.name == name: + return feature.value + raise StrategyContractError( + code=StrategyErrorCode.STRATEGY_FEATURE_NOT_FOUND, + message=f"strategy feature is not available: {name}", + ) + + def with_feature(self, feature: StrategyFeature) -> Self: + return replace(self, features=_upsert_feature(self.features, feature)) + + def with_features(self, features: tuple[StrategyFeature, ...]) -> Self: + if len(features) == 0: + return self + updated = _features_by_name(self.features) + for feature in features: + if feature.name not in updated and len(updated) >= MAX_FEATURES_PER_ROW: + raise StrategyContractError( + code=StrategyErrorCode.STRATEGY_CONTRACT_ERROR, + message="strategy row feature count exceeds the bounded feature budget", + ) + updated[feature.name] = feature + return replace(self, features=tuple(updated.values())) def with_signal(self, columns: SignalColumns) -> Self: - return type(self)( - date=self.date, - close=self.close, + return replace( + self, enter_long=self.enter_long or columns.enter_long, enter_short=self.enter_short or columns.enter_short, exit_long=self.exit_long or columns.exit_long, exit_short=self.exit_short or columns.exit_short, enter_tag=columns.enter_tag if columns.enter_tag is not None else self.enter_tag, + exit_tag=columns.exit_tag if columns.exit_tag is not None else self.exit_tag, ) + def _ohlcv(self) -> StrategyOhlcv: + if self.ohlcv is None: + return StrategyOhlcv.from_close(self.close) + return self.ohlcv + @dataclass(frozen=True, slots=True) class StrategyFrame: @@ -48,13 +130,13 @@ def visible_rows(self) -> tuple[StrategyRow, ...]: return self.rows[: self._visible_count()] def last_visible_row(self) -> StrategyRow: - visible = self.visible_rows() - if len(visible) == 0: + visible_count = self._visible_count() + if visible_count == 0: raise StrategyContractError( code=StrategyErrorCode.STRATEGY_CONTRACT_ERROR, message="strategy frame has no visible rows", ) - return visible[-1] + return self.rows[visible_count - 1] def visible(self) -> Self: return type(self)(rows=self.visible_rows()) @@ -77,6 +159,15 @@ def with_signal(self, *, index: int, columns: SignalColumns) -> Self: ) return type(self)(rows=updated_rows, visible_row_count=self.visible_row_count) + def with_feature(self, *, index: int, feature: StrategyFeature) -> Self: + visible_count = self._visible_count() + normalized_index = _normalize_visible_index(index=index, visible_count=visible_count) + updated_rows = tuple( + row.with_feature(feature) if row_index == normalized_index else row + for row_index, row in enumerate(self.rows) + ) + return type(self)(rows=updated_rows, visible_row_count=self.visible_row_count) + def _visible_count(self) -> int: if self.visible_row_count is None: return len(self.rows) @@ -88,30 +179,6 @@ def _visible_count(self) -> int: return self.visible_row_count -@dataclass(frozen=True, slots=True) -class PairFrame: - pair: TradingPair - timeframe: str - frame: StrategyFrame - - -@dataclass(frozen=True, slots=True) -class DataProviderFacade: - frames: tuple[PairFrame, ...] - - def current_whitelist(self) -> tuple[str, ...]: - return tuple(pair_frame.pair.normalized for pair_frame in self.frames) - - def get_pair_dataframe(self, *, pair: TradingPair, timeframe: str) -> StrategyFrame: - for pair_frame in self.frames: - if pair_frame.pair == pair and pair_frame.timeframe == timeframe: - return pair_frame.frame.visible() - raise StrategyContractError( - code=StrategyErrorCode.DATA_PROVIDER_FRAME_NOT_FOUND, - message=f"no strategy frame for pair={pair.normalized} timeframe={timeframe}", - ) - - def _normalize_visible_index(*, index: int, visible_count: int) -> int: if visible_count == 0: raise StrategyContractError( @@ -125,3 +192,40 @@ def _normalize_visible_index(*, index: int, visible_count: int) -> int: message="signal index must target a visible row", ) return normalized_index + + +def _deduplicate_features( + features: tuple[StrategyFeature, ...], +) -> tuple[StrategyFeature, ...]: + deduplicated: dict[StrategyFeatureName, StrategyFeature] = {} + for feature in features: + if feature.name not in deduplicated and len(deduplicated) >= MAX_FEATURES_PER_ROW: + raise StrategyContractError( + code=StrategyErrorCode.STRATEGY_CONTRACT_ERROR, + message="strategy row feature count exceeds the bounded feature budget", + ) + deduplicated[feature.name] = feature + return tuple(deduplicated.values()) + + +def _upsert_feature( + features: tuple[StrategyFeature, ...], + feature: StrategyFeature, +) -> tuple[StrategyFeature, ...]: + updated = list(features) + for index, existing in enumerate(updated): + if existing.name == feature.name: + updated[index] = feature + return tuple(updated) + if len(features) >= MAX_FEATURES_PER_ROW: + raise StrategyContractError( + code=StrategyErrorCode.STRATEGY_CONTRACT_ERROR, + message="strategy row feature count exceeds the bounded feature budget", + ) + return (*features, feature) + + +def _features_by_name( + features: tuple[StrategyFeature, ...], +) -> dict[StrategyFeatureName, StrategyFeature]: + return {feature.name: feature for feature in features} diff --git a/src/nfi_engine/strategy/nfi_x7/__init__.py b/src/nfi_engine/strategy/nfi_x7/__init__.py new file mode 100644 index 0000000..c1ec037 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/__init__.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from nfi_engine.strategy.nfi_x7.coverage import ( + X7CoverageModule, + X7CoverageReport, + X7CoverageStatus, + build_x7_coverage_report, + worktree_evidence_available, +) +from nfi_engine.strategy.nfi_x7.entries import ( + LONG_ENTRY_TAG, + SHORT_ENTRY_TAG, + X7EntryDecision, + X7EntryReason, + apply_x7_entry_decision, + build_x7_entry_decision, +) +from nfi_engine.strategy.nfi_x7.exits import ( + LONG_EXIT_TAG, + SHORT_EXIT_TAG, + X7CustomExitDecision, + X7CustomExitReason, + X7ExitDecision, + X7ExitReason, + apply_x7_exit_decision, + build_x7_custom_exit_decision, + build_x7_exit_decision, +) +from nfi_engine.strategy.nfi_x7.feature_graph import X7FeatureGraph +from nfi_engine.strategy.nfi_x7.feature_graph_models import ( + X7FeatureGraphCacheStats, + X7FeatureGraphContext, + X7FeatureGraphCoverage, + X7FeatureGraphRequest, + X7FeatureGraphResult, +) +from nfi_engine.strategy.nfi_x7.indicators import ( + OhlcvSeries, + StochasticConfig, + StochasticRsiConfig, + StochasticSeries, + X7IndicatorError, + X7IndicatorErrorCode, + average_true_range, + chaikin_money_flow, + crossed_above, + crossed_below, + exponential_moving_average, + pct_change, + range_percent, + rate_of_change, + relative_strength_index, + rolling_max, + rolling_mean, + rolling_min, + rolling_sum, + simple_moving_average, + stochastic_oscillator, + stochastic_rsi, + true_range, + williams_r, +) +from nfi_engine.strategy.nfi_x7.metadata import X7_METADATA, X7StrategyMetadata +from nfi_engine.strategy.nfi_x7.positioning import ( + X7LeverageContext, + X7LeverageDecision, + X7LeverageReason, + X7OrderFilledSnapshot, + X7PositionAdjustmentContext, + X7PositionAdjustmentDecision, + X7PositionAdjustmentReason, + X7StakeContext, + X7StakeDecision, + X7StakeReason, + build_x7_leverage_decision, + build_x7_order_filled_snapshot, + build_x7_position_adjustment_decision, + build_x7_stake_decision, +) +from nfi_engine.strategy.nfi_x7.protections import ( + X7CooldownGuardContext, + X7LoopHookContext, + X7LoopHookDecision, + X7LoopHookReason, + X7PairLockGuardContext, + X7ProtectionGuard, + X7ProtectionReason, + X7StaleDataGuardContext, + X7TradeConfirmationContext, + X7TradeConfirmationDecision, + build_x7_circuit_breaker_guard, + build_x7_cooldown_guard, + build_x7_loop_hook_decision, + build_x7_pair_lock_guard, + build_x7_stale_data_guard, + build_x7_trade_confirmation_decision, +) +from nfi_engine.strategy.nfi_x7.requirements import X7_DATA_REQUIREMENTS, X7DataRequirements +from nfi_engine.strategy.nfi_x7.resource_profile import ( + X7ImportProfile, + X7ResourceBudget, + build_x7_import_profile, + build_x7_resource_budget, +) +from nfi_engine.strategy.nfi_x7.status import ( + X7LiveReadiness, + X7SemanticCoverageState, + X7SemanticStatus, + build_x7_semantic_status, + is_x7_native_settings, +) +from nfi_engine.strategy.nfi_x7.strategy import X7NativeStrategy + +__all__ = [ + "LONG_ENTRY_TAG", + "LONG_EXIT_TAG", + "SHORT_ENTRY_TAG", + "SHORT_EXIT_TAG", + "X7_DATA_REQUIREMENTS", + "X7_METADATA", + "OhlcvSeries", + "StochasticConfig", + "StochasticRsiConfig", + "StochasticSeries", + "X7CooldownGuardContext", + "X7CoverageModule", + "X7CoverageReport", + "X7CoverageStatus", + "X7CustomExitDecision", + "X7CustomExitReason", + "X7DataRequirements", + "X7EntryDecision", + "X7EntryReason", + "X7ExitDecision", + "X7ExitReason", + "X7FeatureGraph", + "X7FeatureGraphCacheStats", + "X7FeatureGraphContext", + "X7FeatureGraphCoverage", + "X7FeatureGraphRequest", + "X7FeatureGraphResult", + "X7ImportProfile", + "X7IndicatorError", + "X7IndicatorErrorCode", + "X7LeverageContext", + "X7LeverageDecision", + "X7LeverageReason", + "X7LiveReadiness", + "X7LoopHookContext", + "X7LoopHookDecision", + "X7LoopHookReason", + "X7NativeStrategy", + "X7OrderFilledSnapshot", + "X7PairLockGuardContext", + "X7PositionAdjustmentContext", + "X7PositionAdjustmentDecision", + "X7PositionAdjustmentReason", + "X7ProtectionGuard", + "X7ProtectionReason", + "X7ResourceBudget", + "X7SemanticCoverageState", + "X7SemanticStatus", + "X7StakeContext", + "X7StakeDecision", + "X7StakeReason", + "X7StaleDataGuardContext", + "X7StrategyMetadata", + "X7TradeConfirmationContext", + "X7TradeConfirmationDecision", + "apply_x7_entry_decision", + "apply_x7_exit_decision", + "average_true_range", + "build_x7_circuit_breaker_guard", + "build_x7_cooldown_guard", + "build_x7_coverage_report", + "build_x7_custom_exit_decision", + "build_x7_entry_decision", + "build_x7_exit_decision", + "build_x7_import_profile", + "build_x7_leverage_decision", + "build_x7_loop_hook_decision", + "build_x7_order_filled_snapshot", + "build_x7_pair_lock_guard", + "build_x7_position_adjustment_decision", + "build_x7_resource_budget", + "build_x7_semantic_status", + "build_x7_stake_decision", + "build_x7_stale_data_guard", + "build_x7_trade_confirmation_decision", + "chaikin_money_flow", + "crossed_above", + "crossed_below", + "exponential_moving_average", + "is_x7_native_settings", + "pct_change", + "range_percent", + "rate_of_change", + "relative_strength_index", + "rolling_max", + "rolling_mean", + "rolling_min", + "rolling_sum", + "simple_moving_average", + "stochastic_oscillator", + "stochastic_rsi", + "true_range", + "williams_r", + "worktree_evidence_available", +] diff --git a/src/nfi_engine/strategy/nfi_x7/coverage.py b/src/nfi_engine/strategy/nfi_x7/coverage.py new file mode 100644 index 0000000..eb13f1e --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/coverage.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum, unique +from pathlib import Path +from typing import Final + +from nfi_engine.strategy.nfi_x7.metadata import X7_METADATA + + +@unique +class X7CoverageStatus(StrEnum): + VERIFIED = "verified" + PENDING = "pending" + BLOCKED = "blocked" + + +@dataclass(frozen=True, slots=True) +class X7CoverageModule: + name: str + status: X7CoverageStatus + evidence_path: str + blocker: str | None = None + + +@dataclass(frozen=True, slots=True) +class X7CoverageReport: + modules: tuple[X7CoverageModule, ...] + + @property + def covered_modules(self) -> tuple[str, ...]: + return tuple( + module.name for module in self.modules if module.status is X7CoverageStatus.VERIFIED + ) + + @property + def pending_modules(self) -> tuple[str, ...]: + return tuple( + module.name for module in self.modules if module.status is not X7CoverageStatus.VERIFIED + ) + + @property + def is_full_semantic_coverage(self) -> bool: + return len(self.pending_modules) == 0 + + +TODO_01_EVIDENCE_PATH: Final = ( + ".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-01-provenance-coverage.md" +) +TODO_02_EVIDENCE_PATH: Final = ( + ".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-02-coverage-inspect.json" +) +TODO_06_EVIDENCE_PATH: Final = ( + ".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-06-feature-graph.json" +) +TARGET_MODULES: Final = ( + X7CoverageModule( + name="metadata", + status=X7CoverageStatus.VERIFIED, + evidence_path=X7_METADATA.provenance_evidence_path, + ), + X7CoverageModule( + name="data_requirements", + status=X7CoverageStatus.VERIFIED, + evidence_path=TODO_01_EVIDENCE_PATH, + ), + X7CoverageModule( + name="strategy_callback_boundary", + status=X7CoverageStatus.VERIFIED, + evidence_path=TODO_01_EVIDENCE_PATH, + ), + X7CoverageModule( + name="semantic_coverage_ledger", + status=X7CoverageStatus.VERIFIED, + evidence_path=TODO_02_EVIDENCE_PATH, + ), + X7CoverageModule( + name="indicator_runtime", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-05-indicators-happy.json", + ), + X7CoverageModule( + name="feature_graph", + status=X7CoverageStatus.VERIFIED, + evidence_path=TODO_06_EVIDENCE_PATH, + ), + X7CoverageModule( + name="entry_signals", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-07-entry-backtest.json", + ), + X7CoverageModule( + name="exit_signals", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-08-exit-backtest.json", + ), + X7CoverageModule( + name="stake_sizing", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-09-positioning-happy.txt", + ), + X7CoverageModule( + name="protections", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-10-protections-happy.json", + ), + X7CoverageModule( + name="runtime_integration", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-12-paper-timeline.json", + ), + X7CoverageModule( + name="runtime_safety_gates", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-13-wallet-risk-http.txt", + ), + X7CoverageModule( + name="operator_status_surface", + status=X7CoverageStatus.VERIFIED, + evidence_path=( + ".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-14-operator-status-proof.md" + ), + ), + X7CoverageModule( + name="performance_budget", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-15-benchmark.json", + ), + X7CoverageModule( + name="release_docs", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-16-install-docs-smoke.txt", + ), +) + + +def build_x7_coverage_report( + module_ledgers: tuple[X7CoverageModule, ...] = TARGET_MODULES, + *, + project_root: Path | None = None, + require_evidence_artifacts: bool = False, +) -> X7CoverageReport: + root = Path.cwd() if project_root is None else project_root + return X7CoverageReport( + modules=tuple( + _with_evidence_gate( + module=module, + project_root=root, + require_evidence_artifacts=require_evidence_artifacts, + ) + for module in module_ledgers + ), + ) + + +def worktree_evidence_available(project_root: Path | None = None) -> bool: + root = Path.cwd() if project_root is None else project_root + return (root / ".omo" / "evidence").exists() + + +def _with_evidence_gate( + *, + module: X7CoverageModule, + project_root: Path, + require_evidence_artifacts: bool, +) -> X7CoverageModule: + if not require_evidence_artifacts: + return module + if module.status is not X7CoverageStatus.VERIFIED: + return module + evidence_path = project_root / module.evidence_path + if evidence_path.exists(): + return module + return X7CoverageModule( + name=module.name, + status=X7CoverageStatus.BLOCKED, + evidence_path=module.evidence_path, + blocker="Verified coverage module is missing its required evidence artifact.", + ) diff --git a/src/nfi_engine/strategy/nfi_x7/entries.py b/src/nfi_engine/strategy/nfi_x7/entries.py new file mode 100644 index 0000000..2b6d2a1 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/entries.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from enum import StrEnum, unique +from typing import Final + +from nfi_engine.strategy import ( + SignalColumns, + StrategyFeatureName, + StrategyFrame, + StrategyRow, +) + +LONG_ENTRY_TAG: Final = "x7-long-momentum-balanced" +SHORT_ENTRY_TAG: Final = "x7-short-momentum-fade" +ROC_1_FEATURE: Final = StrategyFeatureName("x7_base_roc_1") +RANGE_FEATURE: Final = StrategyFeatureName("x7_base_range_pct") +LONG_MIN_ROC: Final = Decimal("0.5") +SHORT_MAX_ROC: Final = Decimal("-1.0") +MAX_LONG_RANGE: Final = Decimal("6.0") +MAX_SHORT_RANGE: Final = Decimal("8.0") +MIN_ENTRY_ROWS: Final = 2 + + +@unique +class X7EntryReason(StrEnum): + LONG_MOMENTUM_BALANCED = "long_momentum_balanced" + SHORT_MOMENTUM_FADE = "short_momentum_fade" + WARMUP = "warmup" + NO_ENTRY = "no_entry" + + +@dataclass(frozen=True, slots=True) +class X7EntryDecision: + reason: X7EntryReason + columns: SignalColumns + + +def apply_x7_entry_decision(frame: StrategyFrame) -> StrategyFrame: + decision = build_x7_entry_decision( + frame.last_visible_row(), + visible_rows=len(frame.visible_rows()), + ) + if decision.reason in (X7EntryReason.WARMUP, X7EntryReason.NO_ENTRY): + return frame + return frame.with_signal(index=-1, columns=decision.columns) + + +def build_x7_entry_decision(row: StrategyRow, *, visible_rows: int) -> X7EntryDecision: + if visible_rows < MIN_ENTRY_ROWS: + return _no_signal(X7EntryReason.WARMUP) + roc = _feature_or_none(row=row, name=ROC_1_FEATURE) + range_pct = _feature_or_none(row=row, name=RANGE_FEATURE) + if roc is None or range_pct is None: + return _no_signal(X7EntryReason.WARMUP) + if roc >= LONG_MIN_ROC and range_pct <= MAX_LONG_RANGE: + return X7EntryDecision( + reason=X7EntryReason.LONG_MOMENTUM_BALANCED, + columns=SignalColumns(enter_long=True, enter_tag=LONG_ENTRY_TAG), + ) + if roc <= SHORT_MAX_ROC and range_pct <= MAX_SHORT_RANGE: + return X7EntryDecision( + reason=X7EntryReason.SHORT_MOMENTUM_FADE, + columns=SignalColumns(enter_short=True, enter_tag=SHORT_ENTRY_TAG), + ) + return _no_signal(X7EntryReason.NO_ENTRY) + + +def _feature_or_none(*, row: StrategyRow, name: StrategyFeatureName) -> Decimal | None: + for feature in row.features: + if feature.name == name: + return feature.value + return None + + +def _no_signal(reason: X7EntryReason) -> X7EntryDecision: + return X7EntryDecision(reason=reason, columns=SignalColumns()) diff --git a/src/nfi_engine/strategy/nfi_x7/exits.py b/src/nfi_engine/strategy/nfi_x7/exits.py new file mode 100644 index 0000000..06271d8 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/exits.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from enum import StrEnum, unique +from typing import Final, assert_never + +from nfi_engine.strategy import ( + SignalColumns, + StrategyFeatureName, + StrategyFrame, + StrategyRow, + StrategyTrade, +) +from nfi_engine.strategy.nfi_x7.entries import RANGE_FEATURE, ROC_1_FEATURE + +LONG_EXIT_TAG: Final = "x7-exit-long-momentum-cooldown" +SHORT_EXIT_TAG: Final = "x7-exit-short-momentum-cooldown" +LONG_EXIT_MAX_ROC: Final = Decimal("-0.50") +SHORT_EXIT_MIN_ROC: Final = Decimal("0.25") +MAX_EXIT_RANGE: Final = Decimal("6.0") +MIN_EXIT_ROWS: Final = 3 + + +@unique +class X7ExitReason(StrEnum): + LONG_MOMENTUM_COOLDOWN = "long_momentum_cooldown" + SHORT_MOMENTUM_COOLDOWN = "short_momentum_cooldown" + WARMUP = "warmup" + NO_EXIT = "no_exit" + + +@unique +class X7CustomExitReason(StrEnum): + FEATURE_CONTEXT_REQUIRED = "feature_context_required" + + +@dataclass(frozen=True, slots=True) +class X7ExitDecision: + reason: X7ExitReason + columns: SignalColumns + + +@dataclass(frozen=True, slots=True) +class X7CustomExitDecision: + reason: X7CustomExitReason + exit_reason: str | None + + +def apply_x7_exit_decision(frame: StrategyFrame) -> StrategyFrame: + decision = build_x7_exit_decision( + frame.last_visible_row(), + visible_rows=len(frame.visible_rows()), + ) + match decision.reason: + case X7ExitReason.WARMUP | X7ExitReason.NO_EXIT: + return frame + case X7ExitReason.LONG_MOMENTUM_COOLDOWN | X7ExitReason.SHORT_MOMENTUM_COOLDOWN: + return frame.with_signal(index=-1, columns=decision.columns) + case unreachable: + assert_never(unreachable) + + +def build_x7_exit_decision(row: StrategyRow, *, visible_rows: int) -> X7ExitDecision: + if visible_rows < MIN_EXIT_ROWS: + return _no_signal(X7ExitReason.WARMUP) + roc = _feature_or_none(row=row, name=ROC_1_FEATURE) + range_pct = _feature_or_none(row=row, name=RANGE_FEATURE) + if roc is None or range_pct is None: + return _no_signal(X7ExitReason.WARMUP) + if range_pct > MAX_EXIT_RANGE: + return _no_signal(X7ExitReason.NO_EXIT) + if roc <= LONG_EXIT_MAX_ROC: + return X7ExitDecision( + reason=X7ExitReason.LONG_MOMENTUM_COOLDOWN, + columns=SignalColumns(exit_long=True, exit_tag=LONG_EXIT_TAG), + ) + if roc >= SHORT_EXIT_MIN_ROC: + return X7ExitDecision( + reason=X7ExitReason.SHORT_MOMENTUM_COOLDOWN, + columns=SignalColumns(exit_short=True, exit_tag=SHORT_EXIT_TAG), + ) + return _no_signal(X7ExitReason.NO_EXIT) + + +def build_x7_custom_exit_decision(_trade: StrategyTrade) -> X7CustomExitDecision: + return X7CustomExitDecision( + reason=X7CustomExitReason.FEATURE_CONTEXT_REQUIRED, + exit_reason=None, + ) + + +def _feature_or_none(*, row: StrategyRow, name: StrategyFeatureName) -> Decimal | None: + for feature in row.features: + if feature.name == name: + return feature.value + return None + + +def _no_signal(reason: X7ExitReason) -> X7ExitDecision: + return X7ExitDecision(reason=reason, columns=SignalColumns()) diff --git a/src/nfi_engine/strategy/nfi_x7/feature_graph.py b/src/nfi_engine/strategy/nfi_x7/feature_graph.py new file mode 100644 index 0000000..00ed46a --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/feature_graph.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from nfi_engine.strategy import StrategyFrame +from nfi_engine.strategy.nfi_x7.feature_graph_models import ( + X7_FEATURE_GRAPH_CACHE_LIMIT, + X7FeatureGraphCacheKey, + X7FeatureGraphCacheStats, + X7FeatureGraphContext, + X7FeatureGraphResult, + X7InformativeCursorSignature, +) +from nfi_engine.strategy.nfi_x7.feature_graph_series import ( + X7InformativeFrames, + build_feature_graph_result, + cursor_signature, +) + + +class X7FeatureGraph: + """Builds native X7 features and keeps a small mutable cursor cache.""" + + def __init__(self) -> None: + self._cache: dict[X7FeatureGraphCacheKey, X7FeatureGraphResult] = {} + self._hit_count: int = 0 + self._miss_count: int = 0 + + @property + def cache_stats(self) -> X7FeatureGraphCacheStats: + return X7FeatureGraphCacheStats( + hit_count=self._hit_count, + miss_count=self._miss_count, + entry_count=len(self._cache), + ) + + def build(self, context: X7FeatureGraphContext) -> X7FeatureGraphResult: + base_frame = context.base_frame.visible() + informative_frames = _informative_frames(context) + cache_key = _cache_key( + context=context, + base_frame=base_frame, + informative_frames=informative_frames, + ) + cached = self._cache.get(cache_key) + if cached is not None: + self._hit_count += 1 + return X7FeatureGraphResult( + frame=cached.frame, + coverage=cached.coverage, + cache_hit=True, + ) + self._miss_count += 1 + result = build_feature_graph_result( + base_frame=base_frame, + informative_frames=informative_frames, + ) + self._remember(cache_key=cache_key, result=result) + return result + + def _remember( + self, + *, + cache_key: X7FeatureGraphCacheKey, + result: X7FeatureGraphResult, + ) -> None: + if len(self._cache) >= X7_FEATURE_GRAPH_CACHE_LIMIT: + self._cache.pop(next(iter(self._cache))) + self._cache[cache_key] = result + + +def _informative_frames(context: X7FeatureGraphContext) -> tuple[X7InformativeFrames, ...]: + return tuple( + X7InformativeFrames( + timeframe=timeframe, + base_frame=context.provider.get_informative_dataframe( + pair=context.request.pair, + timeframe=timeframe, + ), + btc_frame=context.provider.get_btc_informative_dataframe( + pair=context.request.pair, + timeframe=timeframe, + ), + ) + for timeframe in context.request.informative_timeframes + ) + + +def _cache_key( + *, + context: X7FeatureGraphContext, + base_frame: StrategyFrame, + informative_frames: tuple[X7InformativeFrames, ...], +) -> X7FeatureGraphCacheKey: + informative_signatures = tuple( + signature + for frames in informative_frames + for signature in ( + X7InformativeCursorSignature( + source="base", + timeframe=frames.timeframe, + frame=cursor_signature(frames.base_frame), + ), + X7InformativeCursorSignature( + source="btc", + timeframe=frames.timeframe, + frame=cursor_signature(frames.btc_frame), + ), + ) + ) + return X7FeatureGraphCacheKey( + pair=context.request.pair, + base_timeframe=context.request.base_timeframe, + base_frame=cursor_signature(base_frame), + informative_frames=informative_signatures, + ) diff --git a/src/nfi_engine/strategy/nfi_x7/feature_graph_models.py b/src/nfi_engine/strategy/nfi_x7/feature_graph_models.py new file mode 100644 index 0000000..6c6ec87 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/feature_graph_models.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Final + +from nfi_engine.domain import TradingPair +from nfi_engine.strategy.frame import StrategyFeatureName, StrategyFrame +from nfi_engine.strategy.provider import DataProviderFacade + +X7_FEATURE_GRAPH_CACHE_LIMIT: Final = 4 +X7_FEATURE_GRAPH_FEATURE_BUDGET: Final = 64 + + +@dataclass(frozen=True, slots=True) +class X7FeatureGraphRequest: + pair: TradingPair + base_timeframe: str + informative_timeframes: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class X7FeatureGraphContext: + base_frame: StrategyFrame + provider: DataProviderFacade + request: X7FeatureGraphRequest + + +@dataclass(frozen=True, slots=True) +class X7FeatureGraphCoverage: + base_feature_count: int + informative_feature_count: int + informative_timeframes: tuple[str, ...] + feature_names: tuple[StrategyFeatureName, ...] + + @property + def total_feature_count(self) -> int: + return self.base_feature_count + self.informative_feature_count + + +@dataclass(frozen=True, slots=True) +class X7FeatureGraphResult: + frame: StrategyFrame + coverage: X7FeatureGraphCoverage + cache_hit: bool = field(default=False, compare=False) + + +@dataclass(frozen=True, slots=True) +class X7FeatureGraphCacheStats: + hit_count: int + miss_count: int + entry_count: int + + +@dataclass(frozen=True, slots=True) +class X7FrameCursorSignature: + row_count: int + first_date: str | None + last_date: str | None + close_sum: Decimal + high_sum: Decimal + low_sum: Decimal + volume_sum: Decimal + + +@dataclass(frozen=True, slots=True) +class X7InformativeCursorSignature: + source: str + timeframe: str + frame: X7FrameCursorSignature + + +@dataclass(frozen=True, slots=True) +class X7FeatureGraphCacheKey: + pair: TradingPair + base_timeframe: str + base_frame: X7FrameCursorSignature + informative_frames: tuple[X7InformativeCursorSignature, ...] diff --git a/src/nfi_engine/strategy/nfi_x7/feature_graph_series.py b/src/nfi_engine/strategy/nfi_x7/feature_graph_series.py new file mode 100644 index 0000000..7d15587 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/feature_graph_series.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from nfi_engine.strategy import ( + StrategyContractError, + StrategyErrorCode, + StrategyFeature, + StrategyFeatureName, + StrategyFrame, + StrategyRow, +) +from nfi_engine.strategy.nfi_x7.feature_graph_models import ( + X7_FEATURE_GRAPH_FEATURE_BUDGET, + X7FeatureGraphCoverage, + X7FeatureGraphResult, + X7FrameCursorSignature, +) +from nfi_engine.strategy.nfi_x7.indicators import ( + IndicatorSeries, + OhlcvSeries, + StochasticConfig, + average_true_range, + chaikin_money_flow, + exponential_moving_average, + range_percent, + rate_of_change, + relative_strength_index, + stochastic_oscillator, + williams_r, +) + +ZERO: Decimal = Decimal(0) + + +@dataclass(frozen=True, slots=True) +class X7NamedFeatureSeries: + name: StrategyFeatureName + values: IndicatorSeries + + +@dataclass(frozen=True, slots=True) +class X7InformativeFrames: + timeframe: str + base_frame: StrategyFrame + btc_frame: StrategyFrame + + +def build_feature_graph_result( + *, + base_frame: StrategyFrame, + informative_frames: tuple[X7InformativeFrames, ...], +) -> X7FeatureGraphResult: + base_rows = base_frame.visible_rows() + base_features = _base_feature_series(base_rows) + informative_features = _informative_feature_series( + base_rows=base_rows, + informative_frames=informative_frames, + ) + feature_series = (*base_features, *informative_features) + _require_feature_budget(feature_series) + return X7FeatureGraphResult( + frame=StrategyFrame( + rows=_rows_with_features(rows=base_rows, feature_series=feature_series) + ), + coverage=X7FeatureGraphCoverage( + base_feature_count=len(base_features), + informative_feature_count=len(informative_features), + informative_timeframes=tuple(frames.timeframe for frames in informative_frames), + feature_names=tuple(feature.name for feature in feature_series), + ), + ) + + +def cursor_signature(frame: StrategyFrame) -> X7FrameCursorSignature: + rows = frame.visible_rows() + return X7FrameCursorSignature( + row_count=len(rows), + first_date=rows[0].date if len(rows) > 0 else None, + last_date=rows[-1].date if len(rows) > 0 else None, + close_sum=sum((row.close for row in rows), start=ZERO), + high_sum=sum((row.high for row in rows), start=ZERO), + low_sum=sum((row.low for row in rows), start=ZERO), + volume_sum=sum((row.volume for row in rows), start=ZERO), + ) + + +def _base_feature_series(rows: tuple[StrategyRow, ...]) -> tuple[X7NamedFeatureSeries, ...]: + series = _ohlcv_series(rows) + stochastic = stochastic_oscillator(series, StochasticConfig(k_period=3, d_period=2)) + return ( + _named("x7_base_ema_3", exponential_moving_average(series.close, period=3)), + _named("x7_base_rsi_3", relative_strength_index(series.close, period=3)), + _named("x7_base_roc_1", rate_of_change(series.close, period=1)), + _named("x7_base_roc_2", rate_of_change(series.close, period=2)), + _named("x7_base_atr_3", average_true_range(series, period=3)), + _named("x7_base_range_pct", range_percent(series)), + _named("x7_base_stoch_k_3", stochastic.percent_k), + _named("x7_base_cmf_3", chaikin_money_flow(series, period=3)), + _named("x7_base_williams_r_3", williams_r(series, period=3)), + ) + + +def _informative_feature_series( + *, + base_rows: tuple[StrategyRow, ...], + informative_frames: tuple[X7InformativeFrames, ...], +) -> tuple[X7NamedFeatureSeries, ...]: + output: tuple[X7NamedFeatureSeries, ...] = () + for frames in informative_frames: + output += _aligned_feature_series( + source="base", + timeframe=frames.timeframe, + base_rows=base_rows, + informative_rows=frames.base_frame.visible_rows(), + ) + output += _aligned_feature_series( + source="btc", + timeframe=frames.timeframe, + base_rows=base_rows, + informative_rows=frames.btc_frame.visible_rows(), + ) + return output + + +def _aligned_feature_series( + *, + source: str, + timeframe: str, + base_rows: tuple[StrategyRow, ...], + informative_rows: tuple[StrategyRow, ...], +) -> tuple[X7NamedFeatureSeries, ...]: + series = _ohlcv_series(informative_rows) + prefix = f"x7_{source}_{timeframe}" + return tuple( + X7NamedFeatureSeries( + name=feature.name, + values=_align_to_base_rows( + base_rows=base_rows, + informative_rows=informative_rows, + values=feature.values, + ), + ) + for feature in ( + _named(f"{prefix}_range_pct", range_percent(series)), + _named(f"{prefix}_roc_1", rate_of_change(series.close, period=1)), + _named(f"{prefix}_ema_2", exponential_moving_average(series.close, period=2)), + ) + ) + + +def _align_to_base_rows( + *, + base_rows: tuple[StrategyRow, ...], + informative_rows: tuple[StrategyRow, ...], + values: IndicatorSeries, +) -> IndicatorSeries: + aligned: list[Decimal | None] = [] + cursor = -1 + for row in base_rows: + while _has_next_asof_row( + cursor=cursor, + informative_rows=informative_rows, + row_date=row.date, + ): + cursor += 1 + aligned.append(None if cursor < 0 else values[cursor]) + return tuple(aligned) + + +def _has_next_asof_row( + *, + cursor: int, + informative_rows: tuple[StrategyRow, ...], + row_date: str, +) -> bool: + next_index = cursor + 1 + return next_index < len(informative_rows) and informative_rows[next_index].date <= row_date + + +def _rows_with_features( + *, + rows: tuple[StrategyRow, ...], + feature_series: tuple[X7NamedFeatureSeries, ...], +) -> tuple[StrategyRow, ...]: + updated_rows: list[StrategyRow] = [] + for row_index, row in enumerate(rows): + row_features: list[StrategyFeature] = [] + for feature in feature_series: + value = feature.values[row_index] + if value is not None: + row_features.append(StrategyFeature(name=feature.name, value=value)) + updated_rows.append(row.with_features(tuple(row_features))) + return tuple(updated_rows) + + +def _ohlcv_series(rows: tuple[StrategyRow, ...]) -> OhlcvSeries: + return OhlcvSeries( + high=tuple(row.high for row in rows), + low=tuple(row.low for row in rows), + close=tuple(row.close for row in rows), + volume=tuple(row.volume for row in rows), + ) + + +def _named(name: str, values: IndicatorSeries) -> X7NamedFeatureSeries: + return X7NamedFeatureSeries(name=StrategyFeatureName(name), values=values) + + +def _require_feature_budget(feature_series: tuple[X7NamedFeatureSeries, ...]) -> None: + if len(feature_series) <= X7_FEATURE_GRAPH_FEATURE_BUDGET: + return + raise StrategyContractError( + code=StrategyErrorCode.STRATEGY_CONTRACT_ERROR, + message="X7 feature graph exceeded the native feature budget", + ) diff --git a/src/nfi_engine/strategy/nfi_x7/indicator_momentum.py b/src/nfi_engine/strategy/nfi_x7/indicator_momentum.py new file mode 100644 index 0000000..a378480 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/indicator_momentum.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from decimal import Decimal + +from nfi_engine.strategy.nfi_x7.indicator_types import ( + ONE_HUNDRED, + DecimalSeries, + IndicatorSeries, + OhlcvSeries, + StochasticConfig, + StochasticRsiConfig, + StochasticSeries, + require_equal_indicator_lengths, + require_positive_period, +) +from nfi_engine.strategy.nfi_x7.indicator_windows import ( + rolling_max, + rolling_mean_nullable, + rolling_min, +) + + +def pct_change(values: DecimalSeries, *, period: int) -> IndicatorSeries: + require_positive_period(period) + output: list[Decimal | None] = [] + for index, value in enumerate(values): + if index < period or values[index - period] == Decimal(0): + output.append(None) + else: + previous = values[index - period] + output.append((value - previous) / previous) + return tuple(output) + + +def rate_of_change(values: DecimalSeries, *, period: int) -> IndicatorSeries: + return tuple( + value * ONE_HUNDRED if value is not None else None + for value in pct_change(values, period=period) + ) + + +def relative_strength_index(values: DecimalSeries, *, period: int) -> IndicatorSeries: + require_positive_period(period) + output: list[Decimal | None] = [None] * len(values) + if len(values) <= period: + return tuple(output) + gains: list[Decimal] = [] + losses: list[Decimal] = [] + for index in range(1, period + 1): + gain, loss = _gain_loss(values[index] - values[index - 1]) + gains.append(gain) + losses.append(loss) + average_gain = sum(gains) / Decimal(period) + average_loss = sum(losses) / Decimal(period) + output[period] = _rsi_value(average_gain=average_gain, average_loss=average_loss) + for index in range(period + 1, len(values)): + gain, loss = _gain_loss(values[index] - values[index - 1]) + average_gain = ((average_gain * Decimal(period - 1)) + gain) / Decimal(period) + average_loss = ((average_loss * Decimal(period - 1)) + loss) / Decimal(period) + output[index] = _rsi_value(average_gain=average_gain, average_loss=average_loss) + return tuple(output) + + +def stochastic_oscillator(series: OhlcvSeries, config: StochasticConfig) -> StochasticSeries: + require_positive_period(config.k_period) + require_positive_period(config.d_period) + highest = rolling_max(series.high, window=config.k_period) + lowest = rolling_min(series.low, window=config.k_period) + percent_k = tuple( + _bounded_percent(value=close, lowest=low, highest=high) + for close, low, high in zip(series.close, lowest, highest, strict=True) + ) + percent_d = rolling_mean_nullable(percent_k, window=config.d_period) + return StochasticSeries(percent_k=percent_k, percent_d=percent_d) + + +def stochastic_rsi(values: DecimalSeries, config: StochasticRsiConfig) -> StochasticSeries: + require_positive_period(config.rsi_period) + require_positive_period(config.stoch_period) + require_positive_period(config.smooth_k) + require_positive_period(config.smooth_d) + rsi_values = relative_strength_index(values, period=config.rsi_period) + raw_k = _stochastic_nullable(rsi_values, window=config.stoch_period) + percent_k = rolling_mean_nullable(raw_k, window=config.smooth_k) + percent_d = rolling_mean_nullable(percent_k, window=config.smooth_d) + return StochasticSeries(percent_k=percent_k, percent_d=percent_d) + + +def williams_r(series: OhlcvSeries, *, period: int) -> IndicatorSeries: + require_positive_period(period) + highest = rolling_max(series.high, window=period) + lowest = rolling_min(series.low, window=period) + return tuple( + _williams_value(close=close, lowest=low, highest=high) + for close, low, high in zip(series.close, lowest, highest, strict=True) + ) + + +def crossed_above(left: IndicatorSeries, right: IndicatorSeries) -> tuple[bool, ...]: + require_equal_indicator_lengths(left, right) + return tuple(_crossed_up(left=left, right=right, index=index) for index in range(len(left))) + + +def crossed_below(left: IndicatorSeries, right: IndicatorSeries) -> tuple[bool, ...]: + require_equal_indicator_lengths(left, right) + return tuple(_crossed_down(left=left, right=right, index=index) for index in range(len(left))) + + +def _gain_loss(delta: Decimal) -> tuple[Decimal, Decimal]: + if delta > Decimal(0): + return delta, Decimal(0) + return Decimal(0), abs(delta) + + +def _rsi_value(*, average_gain: Decimal, average_loss: Decimal) -> Decimal: + if average_gain == Decimal(0) and average_loss == Decimal(0): + return Decimal(50) + if average_loss == Decimal(0): + return ONE_HUNDRED + relative_strength = average_gain / average_loss + return ONE_HUNDRED - (ONE_HUNDRED / (Decimal(1) + relative_strength)) + + +def _bounded_percent( + *, + value: Decimal, + lowest: Decimal | None, + highest: Decimal | None, +) -> Decimal | None: + if lowest is None or highest is None or highest == lowest: + return None + return ((value - lowest) / (highest - lowest)) * ONE_HUNDRED + + +def _stochastic_nullable(values: IndicatorSeries, *, window: int) -> IndicatorSeries: + output: list[Decimal | None] = [] + for index, value in enumerate(values): + if index + 1 < window or value is None: + output.append(None) + continue + window_values = values[index - window + 1 : index + 1] + if any(item is None for item in window_values): + output.append(None) + continue + concrete_values = tuple(item for item in window_values if item is not None) + output.append( + _bounded_percent( + value=value, + lowest=min(concrete_values), + highest=max(concrete_values), + ) + ) + return tuple(output) + + +def _williams_value( + *, + close: Decimal, + lowest: Decimal | None, + highest: Decimal | None, +) -> Decimal | None: + if lowest is None or highest is None or highest == lowest: + return None + return ((highest - close) / (highest - lowest)) * -ONE_HUNDRED + + +def _crossed_up(*, left: IndicatorSeries, right: IndicatorSeries, index: int) -> bool: + if index == 0: + return False + previous_left = left[index - 1] + previous_right = right[index - 1] + current_left = left[index] + current_right = right[index] + if ( + previous_left is None + or previous_right is None + or current_left is None + or current_right is None + ): + return False + return previous_left <= previous_right and current_left > current_right + + +def _crossed_down(*, left: IndicatorSeries, right: IndicatorSeries, index: int) -> bool: + if index == 0: + return False + previous_left = left[index - 1] + previous_right = right[index - 1] + current_left = left[index] + current_right = right[index] + if ( + previous_left is None + or previous_right is None + or current_left is None + or current_right is None + ): + return False + return previous_left >= previous_right and current_left < current_right diff --git a/src/nfi_engine/strategy/nfi_x7/indicator_types.py b/src/nfi_engine/strategy/nfi_x7/indicator_types.py new file mode 100644 index 0000000..d487d98 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/indicator_types.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from enum import StrEnum, unique +from typing import Final, override + +type DecimalSeries = tuple[Decimal, ...] +type IndicatorSeries = tuple[Decimal | None, ...] + +ONE_HUNDRED: Final = Decimal(100) +TWO: Final = Decimal(2) + + +@unique +class X7IndicatorErrorCode(StrEnum): + INVALID_PERIOD = "INVALID_PERIOD" + SERIES_LENGTH_MISMATCH = "SERIES_LENGTH_MISMATCH" + + +@dataclass(frozen=True, slots=True) +class X7IndicatorError(Exception): + code: X7IndicatorErrorCode + message: str + + @override + def __str__(self) -> str: + return f"{self.code.value}: {self.message}" + + +@dataclass(frozen=True, slots=True) +class OhlcvSeries: + high: DecimalSeries + low: DecimalSeries + close: DecimalSeries + volume: DecimalSeries + + def __post_init__(self) -> None: + lengths = {len(self.high), len(self.low), len(self.close), len(self.volume)} + if len(lengths) != 1: + raise X7IndicatorError( + code=X7IndicatorErrorCode.SERIES_LENGTH_MISMATCH, + message="OHLCV series lengths must match", + ) + + +@dataclass(frozen=True, slots=True) +class StochasticConfig: + k_period: int + d_period: int + + +@dataclass(frozen=True, slots=True) +class StochasticRsiConfig: + rsi_period: int + stoch_period: int + smooth_k: int + smooth_d: int + + +@dataclass(frozen=True, slots=True) +class StochasticSeries: + percent_k: IndicatorSeries + percent_d: IndicatorSeries + + +def require_positive_period(period: int) -> None: + if period <= 0: + raise X7IndicatorError( + code=X7IndicatorErrorCode.INVALID_PERIOD, + message="indicator period must be positive", + ) + + +def require_equal_indicator_lengths(left: IndicatorSeries, right: IndicatorSeries) -> None: + if len(left) != len(right): + raise X7IndicatorError( + code=X7IndicatorErrorCode.SERIES_LENGTH_MISMATCH, + message="indicator series lengths must match", + ) diff --git a/src/nfi_engine/strategy/nfi_x7/indicator_volume.py b/src/nfi_engine/strategy/nfi_x7/indicator_volume.py new file mode 100644 index 0000000..3554439 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/indicator_volume.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from decimal import Decimal + +from nfi_engine.strategy.nfi_x7.indicator_types import ( + ONE_HUNDRED, + IndicatorSeries, + OhlcvSeries, + require_positive_period, +) +from nfi_engine.strategy.nfi_x7.indicator_windows import rolling_sum + + +def chaikin_money_flow(series: OhlcvSeries, *, period: int) -> IndicatorSeries: + require_positive_period(period) + money_flow_volume = tuple( + _money_flow_multiplier(high=high, low=low, close=close) * volume + for high, low, close, volume in zip( + series.high, + series.low, + series.close, + series.volume, + strict=True, + ) + ) + flow_sum = rolling_sum(money_flow_volume, window=period) + volume_sum = rolling_sum(series.volume, window=period) + return tuple( + flow / volume if flow is not None and volume not in (None, Decimal(0)) else None + for flow, volume in zip(flow_sum, volume_sum, strict=True) + ) + + +def true_range(series: OhlcvSeries) -> tuple[Decimal, ...]: + output: list[Decimal] = [] + for index, high in enumerate(series.high): + low = series.low[index] + if index == 0: + output.append(high - low) + else: + previous_close = series.close[index - 1] + output.append(max(high - low, abs(high - previous_close), abs(low - previous_close))) + return tuple(output) + + +def average_true_range(series: OhlcvSeries, *, period: int) -> IndicatorSeries: + require_positive_period(period) + ranges = true_range(series) + output: list[Decimal | None] = [None] * len(ranges) + if len(ranges) < period: + return tuple(output) + previous = sum(ranges[:period]) / Decimal(period) + output[period - 1] = previous + for index in range(period, len(ranges)): + previous = ((previous * Decimal(period - 1)) + ranges[index]) / Decimal(period) + output[index] = previous + return tuple(output) + + +def range_percent(series: OhlcvSeries) -> IndicatorSeries: + return tuple( + ((high - low) / close) * ONE_HUNDRED if close != Decimal(0) else None + for high, low, close in zip(series.high, series.low, series.close, strict=True) + ) + + +def _money_flow_multiplier(*, high: Decimal, low: Decimal, close: Decimal) -> Decimal: + if high == low: + return Decimal(0) + return ((close - low) - (high - close)) / (high - low) diff --git a/src/nfi_engine/strategy/nfi_x7/indicator_windows.py b/src/nfi_engine/strategy/nfi_x7/indicator_windows.py new file mode 100644 index 0000000..5a6480b --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/indicator_windows.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from collections import deque +from decimal import Decimal + +from nfi_engine.strategy.nfi_x7.indicator_types import ( + TWO, + DecimalSeries, + IndicatorSeries, + require_positive_period, +) + + +def rolling_sum(values: DecimalSeries, *, window: int) -> IndicatorSeries: + require_positive_period(window) + output: list[Decimal | None] = [] + total = Decimal(0) + for index, value in enumerate(values): + total += value + if index >= window: + total -= values[index - window] + output.append(total if index + 1 >= window else None) + return tuple(output) + + +def rolling_mean(values: DecimalSeries, *, window: int) -> IndicatorSeries: + require_positive_period(window) + divisor = Decimal(window) + return tuple( + value / divisor if value is not None else None + for value in rolling_sum(values, window=window) + ) + + +def rolling_min(values: DecimalSeries, *, window: int) -> IndicatorSeries: + require_positive_period(window) + output: list[Decimal | None] = [] + indexes: deque[int] = deque() + for index, value in enumerate(values): + while len(indexes) > 0 and indexes[0] <= index - window: + indexes.popleft() + while len(indexes) > 0 and values[indexes[-1]] >= value: + indexes.pop() + indexes.append(index) + output.append(values[indexes[0]] if index + 1 >= window else None) + return tuple(output) + + +def rolling_max(values: DecimalSeries, *, window: int) -> IndicatorSeries: + require_positive_period(window) + output: list[Decimal | None] = [] + indexes: deque[int] = deque() + for index, value in enumerate(values): + while len(indexes) > 0 and indexes[0] <= index - window: + indexes.popleft() + while len(indexes) > 0 and values[indexes[-1]] <= value: + indexes.pop() + indexes.append(index) + output.append(values[indexes[0]] if index + 1 >= window else None) + return tuple(output) + + +def simple_moving_average(values: DecimalSeries, *, window: int) -> IndicatorSeries: + return rolling_mean(values, window=window) + + +def exponential_moving_average(values: DecimalSeries, *, period: int) -> IndicatorSeries: + require_positive_period(period) + output: list[Decimal | None] = [None] * len(values) + if len(values) < period: + return tuple(output) + multiplier = TWO / Decimal(period + 1) + previous = sum(values[:period]) / Decimal(period) + output[period - 1] = previous + for index in range(period, len(values)): + previous = (values[index] - previous) * multiplier + previous + output[index] = previous + return tuple(output) + + +def rolling_mean_nullable(values: IndicatorSeries, *, window: int) -> IndicatorSeries: + require_positive_period(window) + output: list[Decimal | None] = [] + for index in range(len(values)): + if index + 1 < window: + output.append(None) + continue + window_values = values[index - window + 1 : index + 1] + if any(value is None for value in window_values): + output.append(None) + else: + output.append( + sum(value for value in window_values if value is not None) / Decimal(window) + ) + return tuple(output) diff --git a/src/nfi_engine/strategy/nfi_x7/indicators.py b/src/nfi_engine/strategy/nfi_x7/indicators.py new file mode 100644 index 0000000..40885bd --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/indicators.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from nfi_engine.strategy.nfi_x7.indicator_momentum import ( + crossed_above, + crossed_below, + pct_change, + rate_of_change, + relative_strength_index, + stochastic_oscillator, + stochastic_rsi, + williams_r, +) +from nfi_engine.strategy.nfi_x7.indicator_types import ( + DecimalSeries, + IndicatorSeries, + OhlcvSeries, + StochasticConfig, + StochasticRsiConfig, + StochasticSeries, + X7IndicatorError, + X7IndicatorErrorCode, +) +from nfi_engine.strategy.nfi_x7.indicator_volume import ( + average_true_range, + chaikin_money_flow, + range_percent, + true_range, +) +from nfi_engine.strategy.nfi_x7.indicator_windows import ( + exponential_moving_average, + rolling_max, + rolling_mean, + rolling_min, + rolling_sum, + simple_moving_average, +) + +__all__ = [ + "DecimalSeries", + "IndicatorSeries", + "OhlcvSeries", + "StochasticConfig", + "StochasticRsiConfig", + "StochasticSeries", + "X7IndicatorError", + "X7IndicatorErrorCode", + "average_true_range", + "chaikin_money_flow", + "crossed_above", + "crossed_below", + "exponential_moving_average", + "pct_change", + "range_percent", + "rate_of_change", + "relative_strength_index", + "rolling_max", + "rolling_mean", + "rolling_min", + "rolling_sum", + "simple_moving_average", + "stochastic_oscillator", + "stochastic_rsi", + "true_range", + "williams_r", +] diff --git a/src/nfi_engine/strategy/nfi_x7/metadata.py b/src/nfi_engine/strategy/nfi_x7/metadata.py new file mode 100644 index 0000000..50d72c6 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/metadata.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + + +@dataclass(frozen=True, slots=True) +class X7StrategyMetadata: + name: str + strategy_class_name: str + observed_upstream_version: str + base_timeframe: str + provenance_evidence_path: str + + +X7_METADATA: Final = X7StrategyMetadata( + name="NFI_X7_NATIVE", + strategy_class_name="X7NativeStrategy", + observed_upstream_version="v17.4.258", + base_timeframe="5m", + provenance_evidence_path=( + ".omo/evidence/2026-06-20-nfi-x7-semantic-port/task-01-provenance-coverage.md" + ), +) diff --git a/src/nfi_engine/strategy/nfi_x7/positioning.py b/src/nfi_engine/strategy/nfi_x7/positioning.py new file mode 100644 index 0000000..f50f084 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/positioning.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from enum import StrEnum, unique +from typing import Final + +from nfi_engine.domain import ( + Leverage, + OrderId, + PositionSide, + StakeAmount, + TradeId, + TradingPair, +) +from nfi_engine.strategy.dtos import StrategyOrder, StrategyTrade + +X7_DEFAULT_LEVERAGE: Final = Leverage.parse("3") +ZERO: Final = Decimal(0) + + +@unique +class X7StakeReason(StrEnum): + ACCEPTED = "STAKE_ACCEPTED" + CAPPED_BY_ALLOCATION = "STAKE_CAPPED_BY_ALLOCATION" + CAPPED_BY_AVAILABLE = "STAKE_CAPPED_BY_AVAILABLE" + + +@unique +class X7LeverageReason(StrEnum): + DEFAULT = "LEVERAGE_DEFAULT_3X" + CAPPED = "LEVERAGE_CAPPED" + + +@unique +class X7PositionAdjustmentReason(StrEnum): + NO_ADJUSTMENT = "POSITION_ADJUSTMENT_NONE" + ACCEPTED = "POSITION_ADJUSTMENT_ACCEPTED" + CAPPED_BY_MAX = "POSITION_ADJUSTMENT_CAPPED_BY_MAX" + CAPPED_BY_AVAILABLE = "POSITION_ADJUSTMENT_CAPPED_BY_AVAILABLE" + + +@dataclass(frozen=True, slots=True) +class X7StakeContext: + proposed_stake: StakeAmount + available_balance: StakeAmount | None = None + allocation_cap: StakeAmount | None = None + + +@dataclass(frozen=True, slots=True) +class X7StakeDecision: + stake: StakeAmount + reason: X7StakeReason + capped: bool + + +@dataclass(frozen=True, slots=True) +class X7LeverageContext: + requested_leverage: Leverage = X7_DEFAULT_LEVERAGE + max_leverage: Leverage | None = None + + +@dataclass(frozen=True, slots=True) +class X7LeverageDecision: + leverage: Leverage + reason: X7LeverageReason + capped: bool + + +@dataclass(frozen=True, slots=True) +class X7OrderFilledSnapshot: + order_id: OrderId + trade_id: TradeId + pair: TradingPair + side: PositionSide + pair_and_side_match: bool + + +@dataclass(frozen=True, slots=True) +class X7PositionAdjustmentContext: + trade: StrategyTrade + proposed_stake: StakeAmount | None = None + max_adjustment: StakeAmount | None = None + available_balance: StakeAmount | None = None + + +@dataclass(frozen=True, slots=True) +class X7PositionAdjustmentDecision: + stake: StakeAmount | None + reason: X7PositionAdjustmentReason + capped: bool + + +def build_x7_stake_decision(context: X7StakeContext) -> X7StakeDecision: + stake = context.proposed_stake + reason = X7StakeReason.ACCEPTED + if context.allocation_cap is not None and stake > context.allocation_cap: + stake = context.allocation_cap + reason = X7StakeReason.CAPPED_BY_ALLOCATION + if context.available_balance is not None and stake > context.available_balance: + stake = context.available_balance + reason = X7StakeReason.CAPPED_BY_AVAILABLE + return X7StakeDecision( + stake=stake, + reason=reason, + capped=reason is not X7StakeReason.ACCEPTED, + ) + + +def build_x7_leverage_decision(context: X7LeverageContext) -> X7LeverageDecision: + max_leverage = context.max_leverage + if max_leverage is not None: + exceeds_max = context.requested_leverage.value > max_leverage.value + if exceeds_max: + return X7LeverageDecision( + leverage=max_leverage, + reason=X7LeverageReason.CAPPED, + capped=True, + ) + return X7LeverageDecision( + leverage=context.requested_leverage, + reason=X7LeverageReason.DEFAULT, + capped=False, + ) + + +def build_x7_order_filled_snapshot( + *, + order: StrategyOrder, + trade: StrategyTrade, +) -> X7OrderFilledSnapshot: + return X7OrderFilledSnapshot( + order_id=order.order_id, + trade_id=trade.trade_id, + pair=trade.pair, + side=trade.side, + pair_and_side_match=order.pair == trade.pair and order.side is trade.side, + ) + + +def build_x7_position_adjustment_decision( + context: X7PositionAdjustmentContext, +) -> X7PositionAdjustmentDecision: + if context.proposed_stake is None or context.proposed_stake <= ZERO: + return X7PositionAdjustmentDecision( + stake=None, + reason=X7PositionAdjustmentReason.NO_ADJUSTMENT, + capped=False, + ) + stake = context.proposed_stake + reason = X7PositionAdjustmentReason.ACCEPTED + if context.max_adjustment is not None and stake > context.max_adjustment: + stake = context.max_adjustment + reason = X7PositionAdjustmentReason.CAPPED_BY_MAX + if context.available_balance is not None and stake > context.available_balance: + stake = context.available_balance + reason = X7PositionAdjustmentReason.CAPPED_BY_AVAILABLE + return X7PositionAdjustmentDecision( + stake=stake, + reason=reason, + capped=reason is not X7PositionAdjustmentReason.ACCEPTED, + ) diff --git a/src/nfi_engine/strategy/nfi_x7/protections.py b/src/nfi_engine/strategy/nfi_x7/protections.py new file mode 100644 index 0000000..afbe8c0 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/protections.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum, unique +from typing import Final + +from nfi_engine.circuit_breakers import CircuitBreakerDecision +from nfi_engine.domain import PositionSide, TradingPair +from nfi_engine.risk import PairLock + +ZERO_ACTIONS: Final = 0 + + +@unique +class X7ProtectionReason(StrEnum): + CLEAR = "PROTECTION_CLEAR" + PAIR_LOCKED = "PAIR_LOCKED" + COOLDOWN_ACTIVE = "COOLDOWN_ACTIVE" + STALE_DATA = "STALE_DATA" + CIRCUIT_BREAKER_BLOCKED = "CIRCUIT_BREAKER_BLOCKED" + LIVE_CONFIRMATION_REQUIRED = "LIVE_CONFIRMATION_REQUIRED" + + +@unique +class X7LoopHookReason(StrEnum): + IDLE = "LOOP_IDLE" + ACCEPTED = "LOOP_ACCEPTED" + BOUNDED = "LOOP_BOUNDED" + + +@dataclass(frozen=True, slots=True) +class X7ProtectionGuard: + reason: X7ProtectionReason + detail: str | None = None + + +@dataclass(frozen=True, slots=True) +class X7PairLockGuardContext: + pair: TradingPair + pair_locks: tuple[PairLock, ...] + current_time: datetime + + +@dataclass(frozen=True, slots=True) +class X7CooldownGuardContext: + cooldown_until: datetime | None + current_time: datetime + + +@dataclass(frozen=True, slots=True) +class X7StaleDataGuardContext: + latest_data_at: datetime + current_time: datetime + max_stale_seconds: int + + +@dataclass(frozen=True, slots=True) +class X7TradeConfirmationContext: + pair: TradingPair + side: PositionSide + guards: tuple[X7ProtectionGuard, ...] = () + live_trading: bool = False + live_confirmed: bool = False + + +@dataclass(frozen=True, slots=True) +class X7TradeConfirmationDecision: + allowed: bool + reason: X7ProtectionReason + detail: str | None = None + + +@dataclass(frozen=True, slots=True) +class X7LoopHookContext: + requested_actions: int = ZERO_ACTIONS + max_actions: int = ZERO_ACTIONS + + +@dataclass(frozen=True, slots=True) +class X7LoopHookDecision: + allowed_actions: int + reason: X7LoopHookReason + hidden_network_io: bool + mutates_raw_config: bool + + +def build_x7_pair_lock_guard(context: X7PairLockGuardContext) -> X7ProtectionGuard | None: + for pair_lock in context.pair_locks: + if pair_lock.pair == context.pair and pair_lock.expires_at >= context.current_time: + return X7ProtectionGuard( + reason=X7ProtectionReason.PAIR_LOCKED, + detail=pair_lock.reason, + ) + return None + + +def build_x7_cooldown_guard(context: X7CooldownGuardContext) -> X7ProtectionGuard | None: + cooldown_until = context.cooldown_until + if cooldown_until is None or cooldown_until <= context.current_time: + return None + return X7ProtectionGuard(reason=X7ProtectionReason.COOLDOWN_ACTIVE) + + +def build_x7_stale_data_guard(context: X7StaleDataGuardContext) -> X7ProtectionGuard | None: + stale_seconds = (context.current_time - context.latest_data_at).total_seconds() + if context.max_stale_seconds <= ZERO_ACTIONS or stale_seconds <= context.max_stale_seconds: + return None + return X7ProtectionGuard( + reason=X7ProtectionReason.STALE_DATA, + detail=str(int(stale_seconds)), + ) + + +def build_x7_circuit_breaker_guard( + decision: CircuitBreakerDecision, +) -> X7ProtectionGuard | None: + if not decision.new_orders_blocked: + return None + return X7ProtectionGuard( + reason=X7ProtectionReason.CIRCUIT_BREAKER_BLOCKED, + detail=_first_circuit_breaker(decision), + ) + + +def build_x7_trade_confirmation_decision( + context: X7TradeConfirmationContext, +) -> X7TradeConfirmationDecision: + first_guard = _first_guard(context.guards) + if first_guard is not None: + return X7TradeConfirmationDecision( + allowed=False, + reason=first_guard.reason, + detail=first_guard.detail, + ) + if context.live_trading and not context.live_confirmed: + return X7TradeConfirmationDecision( + allowed=False, + reason=X7ProtectionReason.LIVE_CONFIRMATION_REQUIRED, + ) + return X7TradeConfirmationDecision(allowed=True, reason=X7ProtectionReason.CLEAR) + + +def build_x7_loop_hook_decision(context: X7LoopHookContext) -> X7LoopHookDecision: + if context.requested_actions <= ZERO_ACTIONS or context.max_actions <= ZERO_ACTIONS: + return X7LoopHookDecision( + allowed_actions=ZERO_ACTIONS, + reason=X7LoopHookReason.IDLE, + hidden_network_io=False, + mutates_raw_config=False, + ) + allowed_actions = min(context.requested_actions, context.max_actions) + reason = ( + X7LoopHookReason.BOUNDED + if allowed_actions < context.requested_actions + else X7LoopHookReason.ACCEPTED + ) + return X7LoopHookDecision( + allowed_actions=allowed_actions, + reason=reason, + hidden_network_io=False, + mutates_raw_config=False, + ) + + +def _first_guard(guards: tuple[X7ProtectionGuard, ...]) -> X7ProtectionGuard | None: + if len(guards) == 0: + return None + return guards[0] + + +def _first_circuit_breaker(decision: CircuitBreakerDecision) -> str | None: + if len(decision.triggered) == 0: + return None + return decision.triggered[0].kind.value diff --git a/src/nfi_engine/strategy/nfi_x7/requirements.py b/src/nfi_engine/strategy/nfi_x7/requirements.py new file mode 100644 index 0000000..271808c --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/requirements.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + + +@dataclass(frozen=True, slots=True) +class X7DataRequirements: + base_timeframe: str + informative_timeframes: tuple[str, ...] + required_ohlcv_columns: tuple[str, ...] + mandatory_external_dependencies: tuple[str, ...] + + +X7_DATA_REQUIREMENTS: Final = X7DataRequirements( + base_timeframe="5m", + informative_timeframes=("15m", "1h", "4h", "1d"), + required_ohlcv_columns=("date", "open", "high", "low", "close", "volume"), + mandatory_external_dependencies=(), +) diff --git a/src/nfi_engine/strategy/nfi_x7/resource_profile.py b/src/nfi_engine/strategy/nfi_x7/resource_profile.py new file mode 100644 index 0000000..b355163 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/resource_profile.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Final + +from nfi_engine.strategy.nfi_x7.feature_graph_models import ( + X7_FEATURE_GRAPH_CACHE_LIMIT, + X7_FEATURE_GRAPH_FEATURE_BUDGET, +) +from nfi_engine.strategy.nfi_x7.requirements import X7_DATA_REQUIREMENTS + +FORBIDDEN_RUNTIME_MODULES: Final = ("freqtrade", "pandas", "rapidjson", "talib") +LOCAL_TYPED_STRUCTURES_BACKEND: Final = "local_typed_structures" +PRECOMPUTED_LEVERAGE_AVAILABLE: Final = True +PI4_PUBLIC_CLAIM_REQUIRES_HARDWARE_EVIDENCE: Final = True + + +@dataclass(frozen=True, slots=True) +class X7ImportProfile: + forbidden_runtime_modules: tuple[str, ...] + loaded_forbidden_runtime_modules: tuple[str, ...] + has_forbidden_runtime_modules_loaded: bool + + +@dataclass(frozen=True, slots=True) +class X7ResourceBudget: + mandatory_external_dependencies: tuple[str, ...] + structure_backend: str + precomputed_leverage: bool + feature_graph_feature_budget: int + feature_graph_cache_limit: int + informative_timeframe_count: int + bounded_timeframe_count: int + pi4_public_claim_requires_hardware_evidence: bool + + +def build_x7_import_profile(loaded_module_names: Iterable[str]) -> X7ImportProfile: + normalized_loaded_module_names = tuple(loaded_module_names) + loaded_forbidden_runtime_modules = tuple( + forbidden_module + for forbidden_module in FORBIDDEN_RUNTIME_MODULES + if _module_is_loaded(normalized_loaded_module_names, forbidden_module) + ) + return X7ImportProfile( + forbidden_runtime_modules=FORBIDDEN_RUNTIME_MODULES, + loaded_forbidden_runtime_modules=loaded_forbidden_runtime_modules, + has_forbidden_runtime_modules_loaded=bool(loaded_forbidden_runtime_modules), + ) + + +def build_x7_resource_budget() -> X7ResourceBudget: + informative_timeframes = X7_DATA_REQUIREMENTS.informative_timeframes + bounded_timeframe_count = 1 + len(informative_timeframes) + return X7ResourceBudget( + mandatory_external_dependencies=X7_DATA_REQUIREMENTS.mandatory_external_dependencies, + structure_backend=LOCAL_TYPED_STRUCTURES_BACKEND, + precomputed_leverage=PRECOMPUTED_LEVERAGE_AVAILABLE, + feature_graph_feature_budget=X7_FEATURE_GRAPH_FEATURE_BUDGET, + feature_graph_cache_limit=X7_FEATURE_GRAPH_CACHE_LIMIT, + informative_timeframe_count=len(informative_timeframes), + bounded_timeframe_count=bounded_timeframe_count, + pi4_public_claim_requires_hardware_evidence=PI4_PUBLIC_CLAIM_REQUIRES_HARDWARE_EVIDENCE, + ) + + +def _module_is_loaded( + loaded_module_names: tuple[str, ...], + forbidden_module: str, +) -> bool: + dotted_forbidden_module = f"{forbidden_module}." + return any( + loaded_module_name == forbidden_module + or loaded_module_name.startswith(dotted_forbidden_module) + for loaded_module_name in loaded_module_names + ) diff --git a/src/nfi_engine/strategy/nfi_x7/status.py b/src/nfi_engine/strategy/nfi_x7/status.py new file mode 100644 index 0000000..0c51003 --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/status.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum, unique + +from nfi_engine.config import RuntimeSettings +from nfi_engine.preflight.models import PreflightReport, PreflightStatus +from nfi_engine.strategy.nfi_x7.coverage import ( + X7CoverageReport, + X7CoverageStatus, + build_x7_coverage_report, +) +from nfi_engine.strategy.nfi_x7.metadata import X7_METADATA + +X7_NATIVE_MODULE = "nfi_engine.strategy.nfi_x7:X7NativeStrategy" +X7_NATIVE_NAME = "X7NativeStrategy" +NO_RUNTIME_SIGNAL_REASON = "no_runtime_signal_observed" +X7_DISABLED_REASON = "x7_strategy_disabled" + + +@unique +class X7SemanticCoverageState(StrEnum): + DISABLED = "disabled" + VERIFIED = "verified" + UNDER_DEVELOPMENT = "under_development" + BLOCKED = "blocked" + + +@unique +class X7LiveReadiness(StrEnum): + DISABLED = "disabled" + GATED = "gated" + BLOCKED = "blocked" + + +@dataclass(frozen=True, slots=True) +class X7SemanticStatus: + enabled: bool + coverage_state: X7SemanticCoverageState + observed_upstream_version: str + provenance_evidence_path: str + covered_modules: tuple[str, ...] + pending_modules: tuple[str, ...] + latest_signal_reason: str + warmup_state: str + missing_data_state: str + live_readiness: X7LiveReadiness + blocked_reason: str | None + next_action: str + + +def build_x7_semantic_status( + *, + settings: RuntimeSettings, + readiness: PreflightReport | None, + coverage_report: X7CoverageReport | None = None, + dashboard_data_observed: bool = False, + latest_signal_reason: str | None = None, +) -> X7SemanticStatus: + if not is_x7_native_settings(settings): + return _disabled_status() + report = coverage_report if coverage_report is not None else build_x7_coverage_report() + preflight_blocker = _preflight_blocker(readiness) + coverage_blocker = _coverage_blocker(report) + blocked_reason = preflight_blocker or coverage_blocker + coverage_pending = len(report.pending_modules) > 0 + return X7SemanticStatus( + enabled=True, + coverage_state=_coverage_state(report), + observed_upstream_version=X7_METADATA.observed_upstream_version, + provenance_evidence_path=X7_METADATA.provenance_evidence_path, + covered_modules=report.covered_modules, + pending_modules=report.pending_modules, + latest_signal_reason=latest_signal_reason or NO_RUNTIME_SIGNAL_REASON, + warmup_state=_warmup_state(dashboard_data_observed), + missing_data_state=_missing_data_state(dashboard_data_observed), + live_readiness=_live_readiness(settings=settings, preflight_blocker=preflight_blocker), + blocked_reason=blocked_reason, + next_action=_next_action( + preflight_blocker=preflight_blocker, + coverage_blocker=coverage_blocker, + coverage_pending=coverage_pending, + dashboard_data_observed=dashboard_data_observed, + ), + ) + + +def is_x7_native_settings(settings: RuntimeSettings) -> bool: + return settings.strategy.module == X7_NATIVE_MODULE or settings.strategy.name == X7_NATIVE_NAME + + +def _disabled_status() -> X7SemanticStatus: + return X7SemanticStatus( + enabled=False, + coverage_state=X7SemanticCoverageState.DISABLED, + observed_upstream_version="", + provenance_evidence_path="", + covered_modules=(), + pending_modules=(), + latest_signal_reason=X7_DISABLED_REASON, + warmup_state="disabled", + missing_data_state="disabled", + live_readiness=X7LiveReadiness.DISABLED, + blocked_reason=None, + next_action="Select X7NativeStrategy to inspect native X7 semantic status.", + ) + + +def _coverage_state(report: X7CoverageReport) -> X7SemanticCoverageState: + if any(module.status is X7CoverageStatus.BLOCKED for module in report.modules): + return X7SemanticCoverageState.BLOCKED + if report.is_full_semantic_coverage: + return X7SemanticCoverageState.VERIFIED + return X7SemanticCoverageState.UNDER_DEVELOPMENT + + +def _coverage_blocker(report: X7CoverageReport) -> str | None: + for module in report.modules: + if module.status is X7CoverageStatus.BLOCKED: + reason = module.blocker or "Semantic module is blocked." + return f"{module.name}: {reason}" + return None + + +def _preflight_blocker(readiness: PreflightReport | None) -> str | None: + if readiness is None: + return None + for check in readiness.checks: + if check.status is PreflightStatus.BLOCK: + return f"{check.code.value}: {check.message}" + return None + + +def _warmup_state(dashboard_data_observed: bool) -> str: + if dashboard_data_observed: + return "observed" + return "not_observed" + + +def _missing_data_state(dashboard_data_observed: bool) -> str: + if dashboard_data_observed: + return "observed" + return "no_dashboard_data" + + +def _live_readiness( + *, + settings: RuntimeSettings, + preflight_blocker: str | None, +) -> X7LiveReadiness: + if settings.engine.live_trading or preflight_blocker is not None: + return X7LiveReadiness.BLOCKED + return X7LiveReadiness.GATED + + +def _next_action( + *, + preflight_blocker: str | None, + coverage_blocker: str | None, + coverage_pending: bool, + dashboard_data_observed: bool, +) -> str: + if preflight_blocker is not None: + return "Resolve blocked preflight checks before starting an X7 paper/testnet run." + if coverage_blocker is not None: + return "Keep X7 in paper/testnet and restore the missing semantic evidence artifact." + if coverage_pending: + return "Keep X7 in paper/testnet and finish the remaining semantic evidence items." + if not dashboard_data_observed: + return "Run paper/testnet once to observe the latest X7 signal and warmup state." + return "Review gated X7 paper/testnet status before any runtime action." diff --git a/src/nfi_engine/strategy/nfi_x7/strategy.py b/src/nfi_engine/strategy/nfi_x7/strategy.py new file mode 100644 index 0000000..e6b8acb --- /dev/null +++ b/src/nfi_engine/strategy/nfi_x7/strategy.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import Final + +from nfi_engine.domain import Leverage, PositionSide, StakeAmount, TradingPair +from nfi_engine.strategy import DataProviderFacade +from nfi_engine.strategy.dtos import StrategyMetadata, StrategyOrder, StrategyTrade +from nfi_engine.strategy.frame import StrategyFrame +from nfi_engine.strategy.nfi_x7.entries import apply_x7_entry_decision +from nfi_engine.strategy.nfi_x7.exits import ( + apply_x7_exit_decision, + build_x7_custom_exit_decision, +) +from nfi_engine.strategy.nfi_x7.feature_graph import X7FeatureGraph +from nfi_engine.strategy.nfi_x7.feature_graph_models import ( + X7FeatureGraphContext, + X7FeatureGraphRequest, +) +from nfi_engine.strategy.nfi_x7.metadata import X7_METADATA +from nfi_engine.strategy.nfi_x7.positioning import ( + X7_DEFAULT_LEVERAGE as POSITIONING_DEFAULT_LEVERAGE, +) +from nfi_engine.strategy.nfi_x7.positioning import ( + X7LeverageContext, + X7PositionAdjustmentContext, + X7StakeContext, + build_x7_leverage_decision, + build_x7_order_filled_snapshot, + build_x7_position_adjustment_decision, + build_x7_stake_decision, +) +from nfi_engine.strategy.nfi_x7.protections import ( + X7LoopHookContext, + X7TradeConfirmationContext, + build_x7_loop_hook_decision, + build_x7_trade_confirmation_decision, +) +from nfi_engine.strategy.nfi_x7.requirements import X7_DATA_REQUIREMENTS + +X7_DEFAULT_LEVERAGE: Final = POSITIONING_DEFAULT_LEVERAGE +X7_DEFAULT_BTC_REFERENCE_PAIR: Final = "BTC/USDT:USDT" +X7_BASE_ONLY_PROVIDER: Final = DataProviderFacade(frames=()) + + +class X7NativeStrategy: + """Keeps a small native feature graph cache across strategy callbacks.""" + + timeframe: str = X7_METADATA.base_timeframe + can_short: bool = True + + def __init__(self) -> None: + self._feature_graph: X7FeatureGraph = X7FeatureGraph() + + def populate_indicators( + self, + dataframe: StrategyFrame, + metadata: StrategyMetadata, + ) -> StrategyFrame: + return self._feature_graph.build( + X7FeatureGraphContext( + base_frame=dataframe, + provider=X7_BASE_ONLY_PROVIDER, + request=X7FeatureGraphRequest( + pair=metadata.pair, + base_timeframe=metadata.timeframe, + informative_timeframes=(), + ), + ), + ).frame + + def populate_entry_trend( + self, + dataframe: StrategyFrame, + _metadata: StrategyMetadata, + ) -> StrategyFrame: + return apply_x7_entry_decision(dataframe) + + def populate_exit_trend( + self, + dataframe: StrategyFrame, + _metadata: StrategyMetadata, + ) -> StrategyFrame: + return apply_x7_exit_decision(dataframe) + + def informative_pairs(self) -> tuple[tuple[str, str], ...]: + return tuple( + (X7_DEFAULT_BTC_REFERENCE_PAIR, timeframe) + for timeframe in X7_DATA_REQUIREMENTS.informative_timeframes + ) + + def custom_exit(self, trade: StrategyTrade) -> str | None: + return build_x7_custom_exit_decision(trade).exit_reason + + def custom_stake_amount( + self, + _pair: TradingPair, + proposed_stake: StakeAmount, + ) -> StakeAmount: + return build_x7_stake_decision( + X7StakeContext(proposed_stake=proposed_stake), + ).stake + + def order_filled(self, order: StrategyOrder, trade: StrategyTrade) -> None: + build_x7_order_filled_snapshot(order=order, trade=trade) + + def adjust_trade_position(self, trade: StrategyTrade) -> StakeAmount | None: + return build_x7_position_adjustment_decision( + X7PositionAdjustmentContext(trade=trade), + ).stake + + def confirm_trade_entry(self, pair: TradingPair, side: PositionSide) -> bool: + return build_x7_trade_confirmation_decision( + X7TradeConfirmationContext(pair=pair, side=side), + ).allowed + + def confirm_trade_exit(self, pair: TradingPair, side: PositionSide) -> bool: + return build_x7_trade_confirmation_decision( + X7TradeConfirmationContext(pair=pair, side=side), + ).allowed + + def bot_loop_start(self) -> None: + _ = build_x7_loop_hook_decision(X7LoopHookContext()) + + def leverage(self, _pair: TradingPair, _current_leverage: Leverage) -> Leverage: + return build_x7_leverage_decision(X7LeverageContext()).leverage diff --git a/src/nfi_engine/strategy/provider.py b/src/nfi_engine/strategy/provider.py new file mode 100644 index 0000000..a8f1954 --- /dev/null +++ b/src/nfi_engine/strategy/provider.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from nfi_engine.domain import AssetSymbol, TradingPair +from nfi_engine.strategy.errors import StrategyContractError, StrategyErrorCode +from nfi_engine.strategy.frame import StrategyFrame + +BTC_ASSET: Final = AssetSymbol("BTC") + + +@dataclass(frozen=True, slots=True) +class PairFrame: + pair: TradingPair + timeframe: str + frame: StrategyFrame + stale: bool = False + + +@dataclass(frozen=True, slots=True) +class DataProviderFacade: + frames: tuple[PairFrame, ...] + + def current_whitelist(self) -> tuple[str, ...]: + whitelist: list[str] = [] + seen: set[str] = set() + for pair_frame in self.frames: + normalized = str(pair_frame.pair.normalized) + if normalized not in seen: + whitelist.append(normalized) + seen.add(normalized) + return tuple(whitelist) + + def available_timeframes(self, *, pair: TradingPair) -> tuple[str, ...]: + timeframes: list[str] = [] + seen: set[str] = set() + for pair_frame in self.frames: + if pair_frame.pair == pair and pair_frame.timeframe not in seen: + timeframes.append(pair_frame.timeframe) + seen.add(pair_frame.timeframe) + return tuple(timeframes) + + def btc_pair_for(self, pair: TradingPair) -> TradingPair: + return TradingPair(base=BTC_ASSET, quote=pair.quote, settle=pair.settle) + + def get_pair_dataframe(self, *, pair: TradingPair, timeframe: str) -> StrategyFrame: + return self._visible_frame(pair=pair, timeframe=timeframe) + + def get_informative_dataframe(self, *, pair: TradingPair, timeframe: str) -> StrategyFrame: + return self._visible_frame(pair=pair, timeframe=timeframe) + + def get_btc_informative_dataframe( + self, + *, + pair: TradingPair, + timeframe: str, + ) -> StrategyFrame: + return self._visible_frame(pair=self.btc_pair_for(pair), timeframe=timeframe) + + def _visible_frame(self, *, pair: TradingPair, timeframe: str) -> StrategyFrame: + pair_frame = self._find_pair_frame(pair=pair, timeframe=timeframe) + if pair_frame.stale: + raise StrategyContractError( + code=StrategyErrorCode.DATA_PROVIDER_FRAME_STALE, + message=f"strategy frame is stale for pair={pair.normalized} timeframe={timeframe}", + ) + return pair_frame.frame.visible() + + def _find_pair_frame(self, *, pair: TradingPair, timeframe: str) -> PairFrame: + for pair_frame in self.frames: + if pair_frame.pair == pair and pair_frame.timeframe == timeframe: + return pair_frame + raise StrategyContractError( + code=StrategyErrorCode.DATA_PROVIDER_FRAME_NOT_FOUND, + message=f"no strategy frame for pair={pair.normalized} timeframe={timeframe}", + ) diff --git a/src/nfi_engine/strategy/timeline.py b/src/nfi_engine/strategy/timeline.py new file mode 100644 index 0000000..506ca99 --- /dev/null +++ b/src/nfi_engine/strategy/timeline.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal +from enum import StrEnum, unique +from typing import Final, TypedDict + +from nfi_engine.domain import PositionSide, SignalType, TradingPair +from nfi_engine.strategy.dtos import StrategySignal + +DEFAULT_TIMELINE_MAX_STEPS: Final = 512 + + +@unique +class TimelineSurface(StrEnum): + BACKTEST = "backtest" + PAPER = "paper" + + +@dataclass(frozen=True, slots=True) +class StrategyTimelineStep: + sequence: int + pair: TradingPair + at: datetime + indicator_runs: int + entry_signals: int + exit_signals: int + entry_sides: tuple[PositionSide, ...] + exit_sides: tuple[PositionSide, ...] + opened_orders: int + closed_orders: int + rejected_actions: int + blocked_actions: int + protection_active: bool + stake_amount: Decimal | None + leverage: Decimal | None + open_trade_count: int + entry_reasons: tuple[str, ...] = () + protection_reasons: tuple[str, ...] = () + exit_reasons: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class StrategyTimeline: + surface: TimelineSurface + max_steps: int + truncated: bool + steps: tuple[StrategyTimelineStep, ...] + + +@dataclass(slots=True) +class StrategyTimelineBuilder: + """Mutable accumulator used to avoid rebuilding tuple state per candle.""" + + surface: TimelineSurface + max_steps: int = DEFAULT_TIMELINE_MAX_STEPS + _steps: list[StrategyTimelineStep] = field(default_factory=list, init=False) + _truncated: bool = field(default=False, init=False) + + def record(self, step: StrategyTimelineStep) -> None: + if len(self._steps) >= self.max_steps: + self._truncated = True + return + self._steps.append(step) + + def freeze(self) -> StrategyTimeline: + return StrategyTimeline( + surface=self.surface, + max_steps=self.max_steps, + truncated=self._truncated, + steps=tuple(self._steps), + ) + + +class TimelineStepPayload(TypedDict): + sequence: int + pair: str + at: str + indicator_runs: int + entry_signals: int + exit_signals: int + entry_sides: list[str] + exit_sides: list[str] + opened_orders: int + closed_orders: int + rejected_actions: int + blocked_actions: int + protection_active: bool + entry_reasons: list[str] + protection_reasons: list[str] + stake_amount: str | None + leverage: str | None + open_trade_count: int + exit_reasons: list[str] + + +class TimelinePayload(TypedDict): + surface: str + step_count: int + max_steps: int + truncated: bool + payload_bytes: int + steps: list[TimelineStepPayload] + + +def count_strategy_signals( + signals: tuple[StrategySignal, ...], + signal_type: SignalType, +) -> int: + return sum(1 for signal in signals if signal.signal_type is signal_type) + + +def strategy_signal_sides( + signals: tuple[StrategySignal, ...], + signal_type: SignalType, +) -> tuple[PositionSide, ...]: + return tuple(signal.side for signal in signals if signal.signal_type is signal_type) + + +def strategy_signal_reasons( + signals: tuple[StrategySignal, ...], + signal_type: SignalType, + fallback: str, +) -> tuple[str, ...]: + return tuple( + signal.tag if signal.tag is not None else fallback + for signal in signals + if signal.signal_type is signal_type + ) + + +def timeline_to_payload(timeline: StrategyTimeline) -> TimelinePayload: + steps = [_step_to_payload(step) for step in timeline.steps] + return TimelinePayload( + surface=timeline.surface.value, + step_count=len(steps), + max_steps=timeline.max_steps, + truncated=timeline.truncated, + payload_bytes=_payload_bytes(steps), + steps=steps, + ) + + +def _step_to_payload(step: StrategyTimelineStep) -> TimelineStepPayload: + return TimelineStepPayload( + sequence=step.sequence, + pair=str(step.pair.normalized), + at=step.at.isoformat(), + indicator_runs=step.indicator_runs, + entry_signals=step.entry_signals, + exit_signals=step.exit_signals, + entry_sides=[side.value for side in step.entry_sides], + exit_sides=[side.value for side in step.exit_sides], + opened_orders=step.opened_orders, + closed_orders=step.closed_orders, + rejected_actions=step.rejected_actions, + blocked_actions=step.blocked_actions, + protection_active=step.protection_active, + entry_reasons=list(step.entry_reasons), + protection_reasons=list(step.protection_reasons), + stake_amount=_decimal_payload(step.stake_amount), + leverage=_decimal_payload(step.leverage), + open_trade_count=step.open_trade_count, + exit_reasons=list(step.exit_reasons), + ) + + +def _decimal_payload(value: Decimal | None) -> str | None: + if value is None: + return None + return str(value) + + +def _payload_bytes(steps: list[TimelineStepPayload]) -> int: + return len( + json.dumps( + steps, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8"), + ) diff --git a/src/nfi_engine/tools/x7_provenance.py b/src/nfi_engine/tools/x7_provenance.py new file mode 100644 index 0000000..2fe88f7 --- /dev/null +++ b/src/nfi_engine/tools/x7_provenance.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import ast +import hashlib +import re +import sys +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Final, override + +TIMEFRAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^(?P[1-9][0-9]*)(?P[mhd])$") +EXPECTED_CLASS_NAME: Final = "NostalgiaForInfinityX7" +REQUIRED_ARG_COUNT: Final = 10 +USAGE: Final = ( + "usage: uv run scripts/x7_provenance.py --source " + "--commit --source-url --observed-at " + "--output \n" +) + + +@dataclass(frozen=True, slots=True) +class X7ProvenanceInputs: + source_path: Path + upstream_commit: str + source_url: str + observed_at: str + output_path: Path + + +@dataclass(frozen=True, slots=True) +class X7Provenance: + source_path: Path + source_url: str + observed_at: str + upstream_commit: str + raw_sha256: str + byte_count: int + strategy_class_name: str + interface_version: int + strategy_version: str + base_timeframe: str + informative_timeframes: tuple[str, ...] + import_roots: tuple[str, ...] + method_names: tuple[str, ...] + + @property + def method_count(self) -> int: + return len(self.method_names) + + +@dataclass(frozen=True, slots=True) +class X7ProvenanceError(Exception): + code: str + detail: str + + @override + def __str__(self) -> str: + return f"{self.code}: {self.detail}" + + +def build_x7_provenance(inputs: X7ProvenanceInputs) -> X7Provenance: + source = inputs.source_path.read_text(encoding="utf-8") + source_bytes = source.encode("utf-8") + module = ast.parse(source, filename=inputs.source_path.as_posix()) + strategy_class = _find_strategy_class(module) + base_timeframe = _class_string_assignment(strategy_class, "timeframe") + return X7Provenance( + source_path=inputs.source_path, + source_url=inputs.source_url, + observed_at=inputs.observed_at, + upstream_commit=inputs.upstream_commit, + raw_sha256=hashlib.sha256(source_bytes).hexdigest(), + byte_count=len(source_bytes), + strategy_class_name=strategy_class.name, + interface_version=_class_int_assignment(strategy_class, "INTERFACE_VERSION"), + strategy_version=_strategy_version(strategy_class), + base_timeframe=base_timeframe, + informative_timeframes=_informative_timeframes(strategy_class, base_timeframe), + import_roots=_import_roots(module), + method_names=_method_names(strategy_class), + ) + + +def render_markdown_report(provenance: X7Provenance) -> str: + imports = ", ".join(f"`{name}`" for name in provenance.import_roots) + timeframes = ", ".join(f"`{timeframe}`" for timeframe in provenance.informative_timeframes) + methods = "\n".join(f"- `{name}`" for name in provenance.method_names) + return ( + "# NFI X7 Provenance Artifact\n\n" + "## Observation\n\n" + f"- observed_at: `{provenance.observed_at}`\n" + f"- source_url: `{provenance.source_url}`\n" + f"- source_path: `{provenance.source_path.as_posix()}`\n" + f"- upstream_commit: `{provenance.upstream_commit}`\n" + f"- raw_sha256: `{provenance.raw_sha256}`\n" + f"- byte_count: `{provenance.byte_count}`\n\n" + "## Parsed Target Facts\n\n" + f"- strategy_class: `{provenance.strategy_class_name}`\n" + f"- interface_version: `{provenance.interface_version}`\n" + f"- strategy_version: `{provenance.strategy_version}`\n" + f"- base_timeframe: `{provenance.base_timeframe}`\n" + f"- informative_timeframes: {timeframes}\n" + f"- import_roots: {imports}\n" + f"- method_count: `{provenance.method_count}`\n\n" + "## Method Inventory\n\n" + f"{methods}\n\n" + "## Clean-room Boundary\n\n" + "- This artifact records public provenance and structural metadata only.\n" + "- It does not vendor, paste, translate, or summarize upstream strategy code bodies.\n" + "- NFI Engine may use these facts as a behavior target for native typed modules.\n" + ) + + +def write_markdown_report(provenance: X7Provenance, output_path: Path) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(render_markdown_report(provenance), encoding="utf-8") + + +def main(argv: Sequence[str] | None = None) -> int: + args = tuple(sys.argv[1:] if argv is None else argv) + try: + inputs = _parse_args(args) + provenance = build_x7_provenance(inputs) + write_markdown_report(provenance, inputs.output_path) + except X7ProvenanceError as exc: + sys.stderr.write(f"X7_PROVENANCE_ERROR {exc}\n") + return 2 + except OSError as exc: + sys.stderr.write(f"X7_PROVENANCE_FILE_ERROR {exc}\n") + return 1 + except SyntaxError as exc: + sys.stderr.write(f"X7_PROVENANCE_SYNTAX_ERROR {exc.msg}\n") + return 1 + sys.stdout.write(f"X7_PROVENANCE_OK {inputs.output_path.as_posix()}\n") + return 0 + + +def _parse_args(args: Sequence[str]) -> X7ProvenanceInputs: + if len(args) != REQUIRED_ARG_COUNT: + raise X7ProvenanceError(code="invalid_arg_count", detail=USAGE.strip()) + source = _arg_value(args, "--source") + commit = _arg_value(args, "--commit") + source_url = _arg_value(args, "--source-url") + observed_at = _arg_value(args, "--observed-at") + output = _arg_value(args, "--output") + return X7ProvenanceInputs( + source_path=Path(source), + upstream_commit=commit, + source_url=source_url, + observed_at=observed_at, + output_path=Path(output), + ) + + +def _arg_value(args: Sequence[str], flag: str) -> str: + for index, value in enumerate(args): + if value != flag: + continue + value_index = index + 1 + if value_index >= len(args): + raise X7ProvenanceError(code="missing_arg_value", detail=flag) + return args[value_index] + raise X7ProvenanceError(code="missing_arg", detail=flag) + + +def _find_strategy_class(module: ast.Module) -> ast.ClassDef: + for node in module.body: + match node: + case ast.ClassDef(name=name) if name == EXPECTED_CLASS_NAME: + return node + case _: + continue + raise X7ProvenanceError(code="missing_strategy_class", detail=EXPECTED_CLASS_NAME) + + +def _class_string_assignment(strategy_class: ast.ClassDef, name: str) -> str: + for node in strategy_class.body: + match node: + case ast.Assign(targets=targets, value=ast.Constant(value=str(value))): + if _assigns_name(targets, name): + return value + case _: + continue + raise X7ProvenanceError(code="missing_string_assignment", detail=name) + + +def _class_int_assignment(strategy_class: ast.ClassDef, name: str) -> int: + for node in strategy_class.body: + match node: + case ast.Assign(targets=targets, value=ast.Constant(value=int(value))): + if _assigns_name(targets, name): + return value + case _: + continue + raise X7ProvenanceError(code="missing_int_assignment", detail=name) + + +def _strategy_version(strategy_class: ast.ClassDef) -> str: + for node in strategy_class.body: + match node: + case ast.FunctionDef(name="version") as version_function: + for child in ast.walk(version_function): + match child: + case ast.Return(value=ast.Constant(value=str(version))): + return version + case _: + continue + case _: + continue + raise X7ProvenanceError(code="missing_strategy_version", detail="version") + + +def _informative_timeframes(strategy_class: ast.ClassDef, base_timeframe: str) -> tuple[str, ...]: + observed: set[str] = set() + for node in ast.walk(strategy_class): + match node: + case ast.Constant(value=str(value)): + if _is_timeframe(value) and value != base_timeframe: + observed.add(value) + case _: + continue + return tuple(sorted(observed, key=_timeframe_minutes)) + + +def _import_roots(module: ast.Module) -> tuple[str, ...]: + roots: set[str] = set() + for node in module.body: + match node: + case ast.Import(names=names): + roots.update(alias.name.split(".", maxsplit=1)[0] for alias in names) + case ast.ImportFrom(module=str(module_name)): + roots.add(module_name.split(".", maxsplit=1)[0]) + case _: + continue + return tuple(sorted(roots)) + + +def _method_names(strategy_class: ast.ClassDef) -> tuple[str, ...]: + names: list[str] = [] + for node in strategy_class.body: + match node: + case ast.FunctionDef(name=name): + names.append(name) + case _: + continue + return tuple(names) + + +def _assigns_name(targets: Sequence[ast.expr], name: str) -> bool: + for target in targets: + match target: + case ast.Name(id=target_name): + if target_name == name: + return True + case _: + continue + return False + + +def _is_timeframe(value: str) -> bool: + return TIMEFRAME_PATTERN.fullmatch(value) is not None + + +def _timeframe_minutes(timeframe: str) -> int: + match = TIMEFRAME_PATTERN.fullmatch(timeframe) + if match is None: + raise X7ProvenanceError(code="invalid_timeframe", detail=timeframe) + count = int(match.group("count")) + unit = match.group("unit") + match unit: + case "m": + return count + case "h": + return count * 60 + case "d": + return count * 60 * 24 + case unreachable: + raise X7ProvenanceError(code="invalid_timeframe_unit", detail=unreachable) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/nfi_engine/ui/AGENTS.md b/src/nfi_engine/ui/AGENTS.md new file mode 100644 index 0000000..6f28f45 --- /dev/null +++ b/src/nfi_engine/ui/AGENTS.md @@ -0,0 +1,49 @@ +# UI GUIDE + +## OVERVIEW + +`ui` renders the local operator console served by FastAPI. It is a compact +operations surface for Home, Settings, Logs, login, pairlist, readiness, i18n, +and local chart snapshots. + +## STRUCTURE + +```text +ui/ +|-- pages.py, document.py # page shell and render entrypoints +|-- home.py, settings_page.py # operator screens +|-- logs_page.py, pairlist.py # diagnostics and pairlist panels +|-- assets*.py # local CSS/JS strings +|-- i18n*.py # English, Korean, Greek text +`-- readiness.py, chart.py # setup/safety/chart fragments +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +| --- | --- | --- | +| Page shell | `document.py`, `pages.py` | Inject CSRF meta and local assets only. | +| Home | `home.py`, `assets_dashboard.py` | Setup, safety, chart, pairlist, support actions. | +| Settings | `settings_page.py`, `settings_fields.py`, `assets_settings.py` | Simple Mode; write-only secrets. | +| Logs | `logs_page.py`, `assets_logs.py` | Error lookup, events, support report export. | +| Text | `i18n_keys.py`, `i18n_en.py`, `i18n_ko.py`, `i18n_el.py` | Machine codes stay untranslated. | + +## CONVENTIONS + +- This is not a public marketing dashboard. Keep screens operational, dense enough to scan, and local-first. +- No external CDN, remote font, remote chart library, or third-party browser asset. Use local inline assets. +- Never use `localStorage` or `sessionStorage` for bearer tokens, CSRF tokens, settings drafts, or secrets. +- Browser mutations read CSRF from `` and send `x-nfi-csrf-token`. +- Read-only mode disables visible controls, but the server must still be the real blocker. +- Secrets are write-only: do not put API keys, API secrets, bearer tokens, or webhook values into HTML values. +- Keep contract IDs, machine codes, audit event IDs, and API field names stable across translations. +- UI must stay visually original; do not imitate FreqUI navigation, card composition, colors, or copy. + +## ANTI-PATTERNS + +- Do not add marketing hero sections, decorative dashboard mosaics, external + assets, or broad analytics cockpit behavior. +- Do not hide the first-run path behind raw YAML as the primary workflow. +- Do not translate machine codes or make support reports harder to search. +- Do not make client-side JavaScript the source of truth for safety, read-only, auth, or config validation. +- Do not add generated screenshots or browser artifacts to source paths; evidence belongs under `.omo/evidence/`. diff --git a/src/nfi_engine/ui/assets.py b/src/nfi_engine/ui/assets.py index 9e7ebdd..2ef9bb8 100644 --- a/src/nfi_engine/ui/assets.py +++ b/src/nfi_engine/ui/assets.py @@ -45,13 +45,13 @@ nav a[aria-current="page"] { border-color: var(--accent); color: var(--accent); } .workspace { display: grid; - grid-template-columns: 1.15fr .85fr; + grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); gap: 18px; margin-top: 20px; } .dashboard-grid { display: grid; - grid-template-columns: 1.25fr .75fr; + grid-template-columns: minmax(0, 1.25fr) minmax(0, .75fr); gap: 18px; margin-top: 18px; } @@ -74,6 +74,7 @@ background: var(--panel); border: 1px solid var(--line); border-radius: 6px; + min-width: 0; padding: 16px; } h2 { font-size: 15px; margin: 0 0 12px; letter-spacing: 0; } @@ -98,7 +99,16 @@ button:disabled, input:disabled, select:disabled { opacity: .62; cursor: not-allowed; } .toolbar { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } .setup-preview { margin-bottom: 18px; } +.setup-wizard strong { align-self: center; } .setup-output { white-space: pre-wrap; overflow-wrap: anywhere; } +.inline-state { + min-height: 36px; + border: 1px solid var(--line); + border-radius: 5px; + padding: 7px 9px; + background: #f8fbfa; + color: var(--muted); +} .state, .audit, .lock { margin-top: 12px; border-left: 3px solid var(--accent); @@ -108,6 +118,90 @@ min-height: 36px; } .lock { border-left-color: var(--warn); background: #fff8e1; } +.cockpit { grid-column: span 1; } +.cockpit-grid, .update-state-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.cockpit-item, .update-state { + border: 1px solid var(--line); + border-radius: 5px; + padding: 9px; + background: #f8fbfa; + min-width: 0; +} +.cockpit-item[data-testid="cockpit-latest-error"] { grid-column: 1 / -1; } +.cockpit-item span, .update-state span { + display: block; + color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; +} +.cockpit-item strong, .update-state strong { + display: block; + margin-top: 3px; + font-size: 12px; + line-height: 1.25; + overflow-wrap: anywhere; +} +.action-error, .action-warning, .action-info { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 3px 10px; + align-items: start; + padding: 9px 0; + border-bottom: 1px solid var(--line); +} +.action-error:last-child, .action-warning:last-child, .action-info:last-child { + border-bottom: 0; +} +.action-error strong, .action-warning strong, .action-info strong { + font-size: 13px; + grid-column: 1; +} +.action-error span, .action-warning span, .action-info span { + color: var(--muted); + font-size: 12px; + grid-column: 1; + overflow-wrap: anywhere; +} +.action-error a, .action-warning a, .action-info a { + grid-column: 2; + grid-row: 1 / span 2; + color: var(--accent); + font-size: 12px; + text-decoration: none; +} +.action-error { border-left: 3px solid var(--danger); padding-left: 8px; } +.action-warning { border-left: 3px solid var(--warn); padding-left: 8px; } +.action-info { border-left: 3px solid var(--accent); padding-left: 8px; } +.x7-status { grid-column: 1 / -1; } +.x7-status-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-top: 12px; +} +.x7-status-item { + border: 1px solid var(--line); + border-radius: 5px; + padding: 9px; + background: #f8fbfa; + min-width: 0; +} +.x7-status-item span { + display: block; + color: var(--muted); + font-size: 12px; +} +.x7-status-item strong { + display: block; + margin-top: 3px; + font-size: 12px; + line-height: 1.25; + overflow-wrap: anywhere; +} .log-tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; } .log-tools input { min-width: 260px; max-width: 100%; } .table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } @@ -115,11 +209,19 @@ .table-scroll table { margin-top: 0; } th, td { border-bottom: 1px solid var(--line); padding: 9px 7px; text-align: left; } td { font-size: 13px; overflow-wrap: anywhere; } -.logs-table { min-width: 680px; } -.logs-table th:nth-child(1), .logs-table td:nth-child(1) { width: 132px; } +.logs-table { min-width: 760px; } +.logs-table th:nth-child(1), .logs-table td:nth-child(1) { width: 150px; } .logs-table th:nth-child(2), .logs-table td:nth-child(2) { width: 72px; } -.logs-table th:nth-child(3), .logs-table td:nth-child(3) { width: 158px; } +.logs-table th:nth-child(3), .logs-table td:nth-child(3) { width: 190px; } .logs-table th:nth-child(4), .logs-table td:nth-child(4) { width: 180px; } +.log-time, +.machine-code { + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 12px; + white-space: nowrap; + word-break: keep-all; + overflow-wrap: normal; +} .severity-error { color: var(--danger); font-weight: 700; } .detail { min-height: 92px; white-space: pre-line; } @media (max-width: 780px) { @@ -129,5 +231,13 @@ nav { margin-top: 12px; } section { margin-top: 14px; } .field-grid { grid-template-columns: 1fr; } + .cockpit-grid, .update-state-grid, .x7-status-grid { grid-template-columns: 1fr; } + .logs-table th:nth-child(2), .logs-table td:nth-child(2) { width: 72px; } + .logs-table th:nth-child(3), .logs-table td:nth-child(3) { width: 190px; } + .action-error, .action-warning, .action-info { grid-template-columns: 1fr; } + .action-error a, .action-warning a, .action-info a { + grid-column: 1; + grid-row: auto; + } } """ diff --git a/src/nfi_engine/ui/assets_data_lifecycle.py b/src/nfi_engine/ui/assets_data_lifecycle.py new file mode 100644 index 0000000..017d122 --- /dev/null +++ b/src/nfi_engine/ui/assets_data_lifecycle.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from typing import Final + +DATA_LIFECYCLE_SCRIPT: Final = """ + +""" diff --git a/src/nfi_engine/ui/assets_logs.py b/src/nfi_engine/ui/assets_logs.py index 8644c44..cdb8c48 100644 --- a/src/nfi_engine/ui/assets_logs.py +++ b/src/nfi_engine/ui/assets_logs.py @@ -8,15 +8,16 @@ const detail = document.querySelector('[data-testid="error-detail"]'); const severity = document.querySelector('[data-testid="severity-filter"]'); const search = document.querySelector('[data-testid="error-search"]'); -const safe = (value) => String(value).replace(/[&<>]/g, (c) => ({ - '&': '&', '<': '<', '>': '>' +const safe = (value) => String(value).replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +const compactTime = (value) => String(value).split('.')[0].replace('T', ' ').slice(0, 19); function renderLogs(items) { rows.innerHTML = items.map((item) => ` - ${safe(item.at)} + ${safe(compactTime(item.at))} ${safe(item.level)} - ${safe(item.code)} + ${safe(item.code)} ${safe(item.correlation_id)} ${safe(item.safe_summary)} `).join(''); diff --git a/src/nfi_engine/ui/assets_runtime_control.py b/src/nfi_engine/ui/assets_runtime_control.py new file mode 100644 index 0000000..7f5f94b --- /dev/null +++ b/src/nfi_engine/ui/assets_runtime_control.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Final + +RUNTIME_CONTROL_SCRIPT: Final = """ + +""" diff --git a/src/nfi_engine/ui/assets_settings.py b/src/nfi_engine/ui/assets_settings.py index 36c8209..64996c6 100644 --- a/src/nfi_engine/ui/assets_settings.py +++ b/src/nfi_engine/ui/assets_settings.py @@ -10,6 +10,15 @@ const draftState = document.querySelector('[data-testid="draft-state"]'); const auditLog = document.querySelector('[data-testid="audit-log"]'); const setupState = document.querySelector('[data-testid="setup-preview-state"]'); +const updatePreviewState = document.querySelector('[data-testid="update-preview-state"]'); +const updateApplyState = document.querySelector('[data-testid="update-apply-state"]'); +const updateRollbackState = document.querySelector('[data-testid="update-rollback-state"]'); +const updateBackupReference = document.querySelector('[data-testid="update-backup-reference"]'); +const updateAcknowledge = document.querySelector('[data-testid="update-acknowledge-unverified"]'); +const updateAllowDirty = document.querySelector('[data-testid="update-allow-dirty-worktree"]'); +const updateSource = document.querySelector('[data-testid="update-source"]'); +const walletButton = document.querySelector('[data-testid="wallet-fetch-button"]'); +const walletState = document.querySelector('[data-testid="wallet-balance-state"]'); const msg = (key) => window.NFI_I18N?.[key] || key; function csrfHeaders() { const token = document.querySelector('meta[name="nfi-csrf-token"]')?.content || ''; @@ -23,11 +32,15 @@ value: item.type === 'checkbox' ? String(item.checked) : item.value })); } +function selectedRuntimeLocale() { + return Array.from(form.elements) + .find((item) => item.name === 'ui.locale' && !item.disabled)?.value || ''; +} function setupPayload() { return Object.fromEntries( Array.from(setupForm.elements) - .filter((item) => item.name) - .map((item) => [item.name, item.value]) + .filter((item) => item.name && !item.disabled) + .map((item) => [item.name, item.type === 'checkbox' ? item.checked : item.value]) ); } async function responsePayload(response) { @@ -46,6 +59,41 @@ } return [`HTTP ${response.status}`]; } +function walletText(payload) { + if (payload.status === 'fetched' && payload.available && payload.equity) { + return msg('setup.wallet_fetched') + .replace('{available}', payload.available) + .replace('{equity}', payload.equity) + .replace('{asset}', payload.quote_asset || 'USDT'); + } + const code = payload.code || msg('setup.wallet_fetch_failed'); + const action = payload.next_action || payload.message || ''; + return action ? `${code}: ${action}` : code; +} +function updateProofPayload() { + return { + backup_reference: updateBackupReference?.value?.trim() || null, + acknowledge_unverified: updateAcknowledge?.checked === true, + allow_dirty_worktree: updateAllowDirty?.checked === true, + update_source: updateSource?.value || 'local_proof' + }; +} +function updatePreviewText(payload) { + return [ + `engine + strategy: ${payload.engine_version} / ${payload.strategy_name}`, + `compatibility: ${payload.compatibility_status}`, + `workspace: ${payload.workspace_state}`, + `config: ${payload.config_source}`, + `rollback: ${payload.rollback_state.status}` + ].join(' | '); +} +function updateReceiptText(payload) { + if (payload.accepted) { + return `PROOF_READY ${payload.action} ${payload.backup_reference || ''}`.trim(); + } + const reasons = payload.blocked_reasons?.join('; ') || payload.compatibility_status; + return `PROOF_BLOCKED ${payload.action}: ${reasons}`; +} async function postConfig(path) { const response = await fetch(path, { method: 'POST', @@ -102,6 +150,8 @@ : msg('settings.draft_rejected'); }; document.querySelector('[data-testid="apply-button"]').onclick = async () => { + const currentLocale = document.documentElement.lang || ''; + const requestedLocale = selectedRuntimeLocale(); const payload = await postConfig('/api/v1/config/apply'); let mode = msg('settings.fix_settings'); if (payload.applied) { @@ -110,6 +160,10 @@ if (!payload.applied && payload.restart_required) { mode = msg('settings.reload_required'); } + if (payload.applied && requestedLocale && requestedLocale !== currentLocale) { + window.location.reload(); + return; + } if (payload.applied) { refreshForm(await fetchCurrentConfig()); } @@ -131,5 +185,54 @@ } setupState.textContent = payload.valid ? payload.config_preview : payload.errors.join('; '); }; +document.querySelector('[data-testid="update-preview-button"]').onclick = async () => { + const response = await fetch('/api/v1/update/preview'); + const payload = await responsePayload(response); + updatePreviewState.textContent = response.ok + ? updatePreviewText(payload) + : errorMessages(response, payload).join('; '); +}; +document.querySelector('[data-testid="update-apply-button"]').onclick = async () => { + const response = await fetch('/api/v1/update/apply', { + method: 'POST', + headers: {'content-type': 'application/json', ...csrfHeaders()}, + body: JSON.stringify(updateProofPayload()) + }); + const payload = await responsePayload(response); + updateApplyState.textContent = response.ok + ? updateReceiptText(payload) + : errorMessages(response, payload).join('; '); +}; +document.querySelector('[data-testid="update-rollback-button"]').onclick = async () => { + const response = await fetch('/api/v1/update/rollback', { + method: 'POST', + headers: {'content-type': 'application/json', ...csrfHeaders()}, + body: JSON.stringify(updateProofPayload()) + }); + const payload = await responsePayload(response); + updateRollbackState.textContent = response.ok + ? updateReceiptText(payload) + : errorMessages(response, payload).join('; '); +}; +if (walletButton && walletState) { + walletButton.onclick = async () => { + walletButton.disabled = true; + walletState.textContent = msg('setup.wallet_loading'); + try { + const response = await fetch('/api/v1/wallet/balance/fetch', { + method: 'POST', + headers: csrfHeaders() + }); + const payload = await responsePayload(response); + walletState.textContent = response.ok + ? walletText(payload) + : errorMessages(response, payload).join('; '); + } catch { + walletState.textContent = msg('setup.wallet_fetch_failed'); + } finally { + walletButton.disabled = false; + } + }; +} """ diff --git a/src/nfi_engine/ui/home.py b/src/nfi_engine/ui/home.py index 278cab5..a27731e 100644 --- a/src/nfi_engine/ui/home.py +++ b/src/nfi_engine/ui/home.py @@ -2,34 +2,64 @@ from decimal import Decimal from html import escape +from typing import Final from nfi_engine.api.models import LogEntryResponse from nfi_engine.config import Locale, LogLevel, RuntimeSettings -from nfi_engine.dashboard import DashboardReadModels, summarize_dashboard_read_models +from nfi_engine.dashboard import ( + DashboardAction, + DashboardReadModels, + build_dashboard_actions, + summarize_dashboard_read_models, +) from nfi_engine.preflight.models import PreflightReport from nfi_engine.ui.chart import render_dashboard_chart_panel +from nfi_engine.ui.home_cockpit import render_home_cockpit +from nfi_engine.ui.home_context import HomeRuntimeContext from nfi_engine.ui.i18n import format_message, localize from nfi_engine.ui.i18n_keys import MessageKey +from nfi_engine.ui.runtime_controls import render_runtime_controls +from nfi_engine.ui.x7_status import render_x7_semantic_status PAIR_PREVIEW_LIMIT = 4 +ACTION_TARGET_HREFS: Final[dict[str, str]] = { + "dashboard/status": "#status", + "logs": "/logs", + "logs/support-bundle": "/api/v1/reports/support-bundle.zip", + "settings": "/settings", + "settings/setup": "/settings", +} def render_home_body( *, settings: RuntimeSettings, logs: tuple[LogEntryResponse, ...], - read_models: DashboardReadModels | None = None, - readiness: PreflightReport | None = None, + runtime: HomeRuntimeContext | None = None, nav: str, ) -> str: locale = settings.ui.locale + resolved_runtime = runtime or HomeRuntimeContext() pairs = _pairs(settings) errors = tuple(log for log in logs if log.level is LogLevel.ERROR) - summary = summarize_dashboard_read_models(read_models or DashboardReadModels.empty()) + summary = summarize_dashboard_read_models( + resolved_runtime.read_models or DashboardReadModels.empty(), + ) + actions = build_dashboard_actions( + settings=settings, + readiness=resolved_runtime.readiness, + logs=logs, + ) + x7_status = ( + resolved_runtime.runtime_health.x7_semantic_status + if resolved_runtime.runtime_health is not None + else None + ) + mode = _mode(settings, locale=locale) bot_state_metric = _metric( "bot-state", localize(locale, MessageKey.HOME_METRIC_BOT_STATE), - localize(locale, MessageKey.HOME_STATE_STOPPED), + resolved_runtime.bot_state.value, ) return ( '
\n' @@ -40,13 +70,13 @@ def render_home_body( " \n" f" {nav}\n" " \n" - '
\n' + '
\n' f" {bot_state_metric}\n" f" { _metric( 'exchange-mode', localize(locale, MessageKey.HOME_METRIC_MODE), - _mode(settings, locale=locale), + mode, ) }\n" f" { @@ -72,8 +102,26 @@ def render_home_body( locale=locale, ) }\n" - f" {_setup_doctor(readiness, locale=locale)}\n" - f" {_safety_explainer(readiness, locale=locale)}\n" + f"{ + render_home_cockpit( + settings=settings, + logs=logs, + actions=actions, + locale=locale, + runtime=resolved_runtime, + ) + }\n" + f"{ + render_runtime_controls( + settings=settings, + locale=locale, + state=resolved_runtime.bot_state, + ) + }\n" + f"{render_x7_semantic_status(x7_status, locale=locale)}" + f" {_action_queue(actions, locale=locale)}\n" + f" {_setup_doctor(resolved_runtime.readiness, locale=locale)}\n" + f" {_safety_explainer(resolved_runtime.readiness, locale=locale)}\n" f" {_pairlist_summary(pairs, locale=locale)}\n" f" {_recent_errors(errors, locale=locale)}\n" '
\n' @@ -96,6 +144,29 @@ def _metric(test_id: str, label: str, value: str) -> str: ) +def _action_queue(actions: tuple[DashboardAction, ...], *, locale: Locale) -> str: + rows = "\n".join(_action_row(action) for action in actions) + if rows == "": + rows = f'
  • {localize(locale, MessageKey.HOME_ACTION_EMPTY)}
  • ' + return ( + '
    \n' + f"

    {localize(locale, MessageKey.HOME_ACTION_QUEUE)}

    \n" + f"
      {rows}
    \n" + "
    \n" + ) + + +def _action_row(action: DashboardAction) -> str: + href = ACTION_TARGET_HREFS.get(action.target, "#status") + return ( + f'
  • ' + f"{escape(action.title)}" + f"{escape(action.detail)}" + f'{escape(action.target)}' + "
  • " + ) + + def _format_usdt(value: Decimal) -> str: return f"{value:.2f} USDT" diff --git a/src/nfi_engine/ui/home_cockpit.py b/src/nfi_engine/ui/home_cockpit.py new file mode 100644 index 0000000..340a611 --- /dev/null +++ b/src/nfi_engine/ui/home_cockpit.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +from decimal import Decimal +from html import escape + +from nfi_engine.api.models import LogEntryResponse +from nfi_engine.config import Locale, LogLevel, RuntimeSettings +from nfi_engine.dashboard import DashboardAction +from nfi_engine.exchange.discovery import build_exchange_capability_report +from nfi_engine.exchange.errors import ExchangeError +from nfi_engine.exchange.permissions import audit_exchange_api_permissions +from nfi_engine.preflight.models import PreflightReport +from nfi_engine.ui.home_context import HomeRuntimeContext +from nfi_engine.ui.i18n import localize +from nfi_engine.ui.i18n_keys import MessageKey +from nfi_engine.wallet import WalletBalanceStatus + + +def render_home_cockpit( + *, + settings: RuntimeSettings, + logs: tuple[LogEntryResponse, ...], + actions: tuple[DashboardAction, ...], + locale: Locale, + runtime: HomeRuntimeContext | None = None, +) -> str: + resolved_runtime = runtime or HomeRuntimeContext() + items = "\n".join( + _cockpit_items( + settings, + logs, + actions, + locale=locale, + runtime=resolved_runtime, + ), + ) + return f""" +
    +

    {localize(locale, MessageKey.HOME_COCKPIT_TITLE)}

    +
    + {items} +
    +
    +""" + + +def _cockpit_items( + settings: RuntimeSettings, + logs: tuple[LogEntryResponse, ...], + actions: tuple[DashboardAction, ...], + *, + locale: Locale, + runtime: HomeRuntimeContext, +) -> tuple[str, ...]: + return ( + _item( + "cockpit-configured", + localize(locale, MessageKey.HOME_COCKPIT_CONFIGURED), + _configured(settings, locale=locale), + ), + _item( + "cockpit-safety", + localize(locale, MessageKey.HOME_COCKPIT_SAFETY), + _safety(settings, runtime.readiness, locale=locale), + ), + _item( + "cockpit-capability-level", + localize(locale, MessageKey.HOME_COCKPIT_CAPABILITY_LEVEL), + _capability_level(settings), + ), + _item( + "cockpit-active-mode", + localize(locale, MessageKey.HOME_COCKPIT_ACTIVE_MODE), + _active_mode(settings, locale=locale), + ), + _item( + "cockpit-runtime-health", + localize(locale, MessageKey.HOME_COCKPIT_RUNTIME_HEALTH), + _runtime_health(runtime, locale=locale), + ), + _item( + "cockpit-wallet-balance", + localize(locale, MessageKey.HOME_COCKPIT_WALLET_BALANCE), + _wallet_balance(runtime, locale=locale), + ), + _item( + "cockpit-allocated-amount", + localize(locale, MessageKey.HOME_COCKPIT_ALLOCATED_AMOUNT), + f"{_decimal(settings.risk.stake_usdt)} USDT", + ), + _item( + "cockpit-leverage", + localize(locale, MessageKey.HOME_COCKPIT_LEVERAGE), + f"{_decimal(settings.risk.leverage)}x", + ), + _item( + "cockpit-risk-profile", + localize(locale, MessageKey.HOME_COCKPIT_RISK_PROFILE), + settings.risk.risk_profile.value, + ), + _item( + "cockpit-permission-audit", + localize(locale, MessageKey.HOME_COCKPIT_PERMISSION_AUDIT), + _permission_audit(settings), + ), + _item( + "cockpit-latest-error", + localize(locale, MessageKey.HOME_COCKPIT_LATEST_ERROR), + _latest_error(logs, locale=locale), + ), + _item( + "cockpit-next-action", + localize(locale, MessageKey.HOME_COCKPIT_NEXT_ACTION), + _next_action(actions, locale=locale), + ), + _item( + "cockpit-where-next", + localize(locale, MessageKey.HOME_COCKPIT_WHERE_NEXT), + localize(locale, MessageKey.HOME_COCKPIT_GO_SETTINGS), + ), + ) + + +def _item(test_id: str, label: str, value: str) -> str: + return ( + f'
    ' + f"{escape(label)}{escape(value)}
    " + ) + + +def _configured(settings: RuntimeSettings, *, locale: Locale) -> str: + profile = _capability_profile_fields(settings) + needs_key = "api_key" in profile or "key" in profile + needs_secret = "api_secret" in profile or "secret" in profile + key_ready = settings.exchange.api_key is not None + secret_ready = settings.exchange.api_secret is not None + if (not needs_key or key_ready) and (not needs_secret or secret_ready): + return localize(locale, MessageKey.HOME_COCKPIT_CREDENTIALS_READY) + return localize(locale, MessageKey.HOME_COCKPIT_CREDENTIALS_MISSING) + + +def _safety( + settings: RuntimeSettings, + readiness: PreflightReport | None, + *, + locale: Locale, +) -> str: + blocked = settings.engine.live_trading or ( + readiness.blocked if readiness is not None else False + ) + if blocked: + return localize(locale, MessageKey.HOME_COCKPIT_BLOCKED) + return localize(locale, MessageKey.HOME_COCKPIT_SAFE) + + +def _capability_level(settings: RuntimeSettings) -> str: + try: + report = build_exchange_capability_report( + exchange_id=settings.exchange.name, + trading_mode=settings.exchange.trading_mode, + ) + except ExchangeError: + return "generic-unverified" + return report.profile.support_level.value + + +def _capability_profile_fields(settings: RuntimeSettings) -> tuple[str, ...]: + try: + report = build_exchange_capability_report( + exchange_id=settings.exchange.name, + trading_mode=settings.exchange.trading_mode, + ) + except ExchangeError: + return ("api_key", "api_secret") + return report.profile.credential_fields + + +def _permission_audit(settings: RuntimeSettings) -> str: + audit = audit_exchange_api_permissions( + read=settings.exchange.permission_read, + trade=settings.exchange.permission_trade, + futures=settings.exchange.permission_futures, + withdrawal=settings.exchange.permission_withdrawal, + ip_allowlist=settings.exchange.permission_ip_allowlist, + ) + if audit.live_safe: + return audit.summary + return f"blocked: {audit.summary}" + + +def _latest_error(logs: tuple[LogEntryResponse, ...], *, locale: Locale) -> str: + errors = tuple(log for log in logs if log.level is LogLevel.ERROR) + if not errors: + return localize(locale, MessageKey.COMMON_NONE) + return errors[0].code + + +def _next_action(actions: tuple[DashboardAction, ...], *, locale: Locale) -> str: + if not actions: + return localize(locale, MessageKey.HOME_ACTION_EMPTY) + return actions[0].title + + +def _wallet_balance(runtime: HomeRuntimeContext, *, locale: Locale) -> str: + snapshot = runtime.wallet_balance + if snapshot is None: + return localize(locale, MessageKey.HOME_COCKPIT_WALLET_NOT_FETCHED) + if ( + snapshot.status is WalletBalanceStatus.FETCHED + and snapshot.available is not None + and snapshot.equity is not None + ): + return ( + f"{_decimal(snapshot.available)} / {_decimal(snapshot.equity)} {snapshot.quote_asset}" + ) + return f"{snapshot.code.value}: {snapshot.next_action}" + + +def _runtime_health(runtime: HomeRuntimeContext, *, locale: Locale) -> str: + snapshot = runtime.runtime_health + if snapshot is None: + return localize(locale, MessageKey.HOME_COCKPIT_RUNTIME_UNKNOWN) + return f"{snapshot.state.value}: {snapshot.next_action}" + + +def _active_mode(settings: RuntimeSettings, *, locale: Locale) -> str: + venue = ( + localize(locale, MessageKey.HOME_VALUE_TESTNET) + if settings.exchange.testnet + else localize(locale, MessageKey.HOME_VALUE_LIVE_VENUE) + ) + return f"{settings.exchange.trading_mode.value} / {venue}" + + +def _decimal(value: Decimal) -> str: + rendered = format(value, "f") + return rendered.rstrip("0").rstrip(".") if "." in rendered else rendered diff --git a/src/nfi_engine/ui/home_context.py b/src/nfi_engine/ui/home_context.py new file mode 100644 index 0000000..6bb3055 --- /dev/null +++ b/src/nfi_engine/ui/home_context.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from nfi_engine.dashboard import DashboardReadModels +from nfi_engine.paper import BotState + +if TYPE_CHECKING: + from nfi_engine.preflight.models import PreflightReport + from nfi_engine.runtime_health import RuntimeHealthSnapshot + from nfi_engine.wallet import WalletBalanceSnapshot + + +@dataclass(frozen=True, slots=True) +class HomeRuntimeContext: + read_models: DashboardReadModels | None = None + readiness: PreflightReport | None = None + wallet_balance: WalletBalanceSnapshot | None = None + runtime_health: RuntimeHealthSnapshot | None = None + bot_state: BotState = BotState.STOPPED diff --git a/src/nfi_engine/ui/i18n_el.py b/src/nfi_engine/ui/i18n_el.py index 4c640c1..5a11eb2 100644 --- a/src/nfi_engine/ui/i18n_el.py +++ b/src/nfi_engine/ui/i18n_el.py @@ -11,9 +11,11 @@ MessageKey.EXPORT_SUPPORT_REPORT: "Εξαγωγή report υποστήριξης", MessageKey.LOOKUP: "Αναζήτηση", MessageKey.OPEN_LOGS: "Άνοιγμα logs", + MessageKey.PAUSE: "Παύση", MessageKey.PREVIEW: "Προεπισκόπηση", MessageKey.RESTORE: "Επαναφορά", - MessageKey.SAVE_DRAFT: "Αποθήκευση draft", + MessageKey.RESUME: "Συνέχιση", + MessageKey.SAVE_DRAFT: "Αποθήκευση προσχεδίου", MessageKey.START: "Έναρξη", MessageKey.STOP: "Διακοπή", MessageKey.VALIDATE: "Έλεγχος", @@ -26,27 +28,53 @@ MessageKey.CHART_UNAVAILABLE: "Ο canvas χάρτης δεν είναι διαθέσιμος.", MessageKey.CHART_WAITING: "Αναμονή για dashboard snapshot data.", MessageKey.COMMON_ALL: "Όλα", - MessageKey.COMMON_BLOCK: "Block", + MessageKey.COMMON_BLOCK: "Αποκλεισμός", MessageKey.COMMON_BLOCKED: "Αποκλεισμένο", MessageKey.COMMON_ERROR: "Σφάλμα", - MessageKey.COMMON_INFO: "Info", + MessageKey.COMMON_INFO: "Πληροφορία", MessageKey.COMMON_NONE: "κανένα", - MessageKey.COMMON_PASSED: "Pass", + MessageKey.COMMON_PASSED: "Πέρασε", MessageKey.COMMON_READY: "Έτοιμο", - MessageKey.COMMON_WARN: "Warn", + MessageKey.COMMON_WARN: "Προσοχή", MessageKey.COMMON_WARNING: "Προειδοποίηση", MessageKey.FIELD_EXCHANGE_NAME: "Όνομα exchange", + MessageKey.FIELD_EXPERT_RISK_CONFIRMED: "Επιβεβαίωση expert risk", MessageKey.FIELD_MAX_OPEN_TRADES: "Μέγιστες ανοικτές συναλλαγές", - MessageKey.FIELD_RISK_STAKE: "Stake USDT", - MessageKey.FIELD_TRADING_MODE: "Trading mode", + MessageKey.FIELD_PERMISSION_WITHDRAWAL: "Άδεια ανάληψης", + MessageKey.FIELD_RISK_PROFILE: "Προφίλ ρίσκου", + MessageKey.FIELD_RISK_STAKE: "Ποσό USDT", + MessageKey.FIELD_TRADING_MODE: "Λειτουργία συναλλαγών", MessageKey.FIELD_UI_LOCALE: "Γλώσσα", + MessageKey.HOME_ACTION_EMPTY: "Δεν απαιτείται ενέργεια χειριστή.", + MessageKey.HOME_ACTION_QUEUE: "Ουρά ενεργειών", + MessageKey.HOME_COCKPIT_ACTIVE_MODE: "Ενεργή λειτουργία", + MessageKey.HOME_COCKPIT_ALLOCATED_AMOUNT: "Δεσμευμένο ποσό", + MessageKey.HOME_COCKPIT_BLOCKED: "Αποκλεισμένο", + MessageKey.HOME_COCKPIT_CAPABILITY_LEVEL: "Επίπεδο capability", + MessageKey.HOME_COCKPIT_CONFIGURED: "Ρύθμιση", + MessageKey.HOME_COCKPIT_CREDENTIALS_MISSING: "Λείπουν exchange API credentials", + MessageKey.HOME_COCKPIT_CREDENTIALS_READY: "Exchange API credentials έτοιμα", + MessageKey.HOME_COCKPIT_GO_SETTINGS: "Άνοιγμα Settings setup", + MessageKey.HOME_COCKPIT_LATEST_ERROR: "Τελευταίο σφάλμα", + MessageKey.HOME_COCKPIT_LEVERAGE: "Μόχλευση", + MessageKey.HOME_COCKPIT_NEXT_ACTION: "Επόμενη ενέργεια", + MessageKey.HOME_COCKPIT_PERMISSION_AUDIT: "API permission audit", + MessageKey.HOME_COCKPIT_RISK_PROFILE: "Προφίλ ρίσκου", + MessageKey.HOME_COCKPIT_RUNTIME_HEALTH: "Υγεία runtime", + MessageKey.HOME_COCKPIT_RUNTIME_UNKNOWN: "Δεν έχει ελεγχθεί", + MessageKey.HOME_COCKPIT_SAFE: "Dry-run ασφαλές", + MessageKey.HOME_COCKPIT_SAFETY: "Ασφάλεια", + MessageKey.HOME_COCKPIT_TITLE: "Cockpit χειριστή", + MessageKey.HOME_COCKPIT_WALLET_BALANCE: "Υπόλοιπο πορτοφολιού", + MessageKey.HOME_COCKPIT_WALLET_NOT_FETCHED: "Δεν έχει φορτωθεί", + MessageKey.HOME_COCKPIT_WHERE_NEXT: "Επόμενο σημείο", MessageKey.HOME_CONFIGURED_PAIRS: "{count} ρυθμισμένα pairs", MessageKey.HOME_DOCUMENT_TITLE: "NFI Engine Αρχική", MessageKey.HOME_METRIC_BOT_STATE: "Κατάσταση bot", MessageKey.HOME_METRIC_MODE: "Λειτουργία", MessageKey.HOME_METRIC_OPEN_TRADES: "Ανοικτές συναλλαγές", MessageKey.HOME_METRIC_SESSION_PNL: "Session PnL", - MessageKey.HOME_PAIRLIST: "Pairlist", + MessageKey.HOME_PAIRLIST: "Λίστα ζευγών", MessageKey.HOME_PREFLIGHT_PROMPT: "τρέξτε preflight για έλεγχο ρύθμισης", MessageKey.HOME_RECENT_ERRORS: "Πρόσφατα σφάλματα", MessageKey.HOME_SAFETY_BLOCKED: "Η έναρξη μπλοκάρεται μέχρι να περάσουν οι έλεγχοι.", @@ -62,6 +90,16 @@ MessageKey.HOME_SUPPORT_DESCRIPTION: "Redacted report με config, logs και correlation IDs.", MessageKey.HOME_VALUE_LIVE_VENUE: "live venue", MessageKey.HOME_VALUE_TESTNET: "testnet", + MessageKey.HOME_X7_BLOCKED_REASON: "Αιτία αποκλεισμού", + MessageKey.HOME_X7_COVERAGE: "Κάλυψη", + MessageKey.HOME_X7_DESCRIPTION: "Τεκμηριωμένη κατάσταση X7 για paper/testnet λειτουργία.", + MessageKey.HOME_X7_LATEST_SIGNAL: "Τελευταία αιτία σήματος", + MessageKey.HOME_X7_LIVE_READINESS: "Ετοιμότητα live", + MessageKey.HOME_X7_MISSING_DATA: "Ελλιπή δεδομένα", + MessageKey.HOME_X7_NEXT_ACTION: "Επόμενη ενέργεια", + MessageKey.HOME_X7_PROVENANCE: "Προέλευση", + MessageKey.HOME_X7_TITLE: "NFI X7 semantic κατάσταση", + MessageKey.HOME_X7_WARMUP: "Warmup", MessageKey.LOGS_CODE: "Κωδικός", MessageKey.LOGS_CORRELATION: "Correlation", MessageKey.LOGS_DOCUMENT_TITLE: "NFI Engine Logs", @@ -85,12 +123,12 @@ MessageKey.NAV_HOME: "Αρχική", MessageKey.NAV_LOGS: "Logs", MessageKey.NAV_SETTINGS: "Ρυθμίσεις", - MessageKey.PAIRLIST_ACCEPTED: "accepted={pairs}", - MessageKey.PAIRLIST_AUDIT_EMPTY: "Δεν υπάρχει pairlist audit event", - MessageKey.PAIRLIST_BLACKLIST: "Blacklist", - MessageKey.PAIRLIST_BLACKLIST_ARIA: "pairlist blacklist", - MessageKey.PAIRLIST_PREVIEW_EMPTY: "Δεν υπάρχει pairlist preview", - MessageKey.PAIRLIST_TITLE: "Pairlist", + MessageKey.PAIRLIST_ACCEPTED: "αποδεκτά={pairs}", + MessageKey.PAIRLIST_AUDIT_EMPTY: "Δεν υπάρχει γεγονός ελέγχου λίστας ζευγών", + MessageKey.PAIRLIST_BLACKLIST: "Λίστα αποκλεισμού", + MessageKey.PAIRLIST_BLACKLIST_ARIA: "λίστα αποκλεισμού ζευγών", + MessageKey.PAIRLIST_PREVIEW_EMPTY: "Δεν υπάρχει προεπισκόπηση λίστας ζευγών", + MessageKey.PAIRLIST_TITLE: "Λίστα ζευγών", MessageKey.READINESS_EMPTY: "Δεν έχει φορτωθεί preflight report", MessageKey.READINESS_CONFIG_INVALID: "η ρύθμιση runtime δεν είναι έγκυρη", MessageKey.READINESS_CONFIG_VALID: "οι runtime ρυθμίσεις φορτώθηκαν", @@ -98,10 +136,24 @@ MessageKey.READINESS_DB_PATH_READY: "η διαδρομή SQLite είναι έτοιμη", MessageKey.READINESS_DOCKER_VOLUMES_MISSING: "τα compose named volumes είναι ελλιπή", MessageKey.READINESS_DOCKER_VOLUMES_READY: "τα compose named volumes έχουν ρυθμιστεί", + MessageKey.READINESS_EXCHANGE_PERMISSION_AUDIT: "ελέγχθηκαν τα API permissions του exchange", MessageKey.READINESS_EXCHANGE_TESTNET_REQUIRED: ( "το exchange πρέπει να μένει simulator ή testnet" ), MessageKey.READINESS_FUTURES_LEVERAGE_INVALID: "ο έλεγχος futures leverage απέτυχε", + MessageKey.READINESS_LIVE_CIRCUIT_BREAKER_HARDENING: ( + "τα live circuit breakers χρειάζονται hardening" + ), + MessageKey.READINESS_LIVE_EXCHANGE_CREDENTIALS: ("τα live exchange credentials ελέγχθηκαν"), + MessageKey.READINESS_LIVE_PERMISSION_HARDENING: ( + "τα live API permissions χρειάζονται hardening" + ), + MessageKey.READINESS_LIVE_RECONCILIATION_HARDENING: ( + "το live startup reconciliation χρειάζεται hardening" + ), + MessageKey.READINESS_LIVE_STRATEGY_HARDENING: ( + "το live strategy coverage χρειάζεται hardening" + ), MessageKey.READINESS_LIVE_TRADING_DISABLED: "το live trading είναι απενεργοποιημένο", MessageKey.READINESS_LIVE_TRADING_OUT_OF_SCOPE: "οι live πραγματικές εντολές είναι εκτός scope", MessageKey.READINESS_LOG_PATH_NOT_WRITABLE: "η διαδρομή log δεν είναι εγγράψιμη", @@ -116,17 +168,18 @@ MessageKey.READINESS_PUBLIC_BIND_NOT_ALLOWED: "το API bind είναι τοπικό", MessageKey.READINESS_RECONCILIATION_READY: "δεν απαιτείται startup reconciliation", MessageKey.READINESS_RECONCILIATION_REQUIRED: "απαιτείται startup reconciliation", + MessageKey.READINESS_RISK_PROFILE_GUARDRAILS: "ελέγχθηκαν τα guardrails του risk profile", MessageKey.READINESS_START_STATE: "Κατάσταση έναρξης: {state}", MessageKey.READINESS_TITLE: "Ετοιμότητα", MessageKey.READINESS_WEAK_API_TOKEN: "η πολιτική API auth πέρασε", MessageKey.SETTINGS_ADVANCED: "Προχωρημένες ρυθμίσεις", MessageKey.SETTINGS_DOCUMENT_TITLE: "NFI Engine Ρυθμίσεις", - MessageKey.SETTINGS_DRAFT_REJECTED: "Το draft απορρίφθηκε", - MessageKey.SETTINGS_DRAFT_SAVED: "Το draft αποθηκεύτηκε", + MessageKey.SETTINGS_DRAFT_REJECTED: "Το προσχέδιο απορρίφθηκε", + MessageKey.SETTINGS_DRAFT_SAVED: "Το προσχέδιο αποθηκεύτηκε", MessageKey.SETTINGS_FIX_SETTINGS: "Διορθώστε τις ρυθμίσεις", MessageKey.SETTINGS_LIVE_LOCKED: "Τα live trading controls είναι κλειδωμένα στο milestone 1.", MessageKey.SETTINGS_NO_AUDIT: "Δεν υπάρχει config audit event", - MessageKey.SETTINGS_NO_DRAFT: "Δεν έχει αποθηκευτεί draft", + MessageKey.SETTINGS_NO_DRAFT: "Δεν έχει αποθηκευτεί προσχέδιο", MessageKey.SETTINGS_NO_VALIDATION: "Δεν έχει γίνει έλεγχος", MessageKey.SETTINGS_READONLY_ACCESS: "Πρόσβαση", MessageKey.SETTINGS_READONLY_DISABLED_TITLE: "Η λειτουργία read-only μπλοκάρει αλλαγές", @@ -136,33 +189,87 @@ ), MessageKey.SETTINGS_RELOAD_REQUIRED: "Απαιτείται reload", MessageKey.SETTINGS_RUNTIME_APPLIED: "runtime εφαρμόστηκε", - MessageKey.SETTINGS_RUNTIME_SAFE: "Runtime safe", - MessageKey.SETTINGS_RUNTIME_SAFE_TITLE: "Runtime-safe ρυθμίσεις", + MessageKey.SETTINGS_RUNTIME_CONTROL_BLOCKED: "Η εντολή runtime αποκλείστηκε", + MessageKey.SETTINGS_RUNTIME_CONTROL_LOADING: "Αποστολή εντολής runtime...", + MessageKey.SETTINGS_RUNTIME_CONTROL_STATE: "Κατάσταση ελέγχου runtime", + MessageKey.SETTINGS_RUNTIME_SAFE: "Ασφαλές runtime", + MessageKey.SETTINGS_RUNTIME_SAFE_TITLE: "Ασφαλείς ρυθμίσεις runtime", MessageKey.SETTINGS_SAFETY_GATES: "Πύλες ασφάλειας", + MessageKey.SETTINGS_DATA_LIFECYCLE_APPLY: "Εφαρμογή καθαρισμού", + MessageKey.SETTINGS_DATA_LIFECYCLE_DRY_RUN: "Προεπισκόπηση καθαρισμού", + MessageKey.SETTINGS_DATA_LIFECYCLE_EXPORT_PROFILE: "Εξαγωγή προφίλ", + MessageKey.SETTINGS_DATA_LIFECYCLE_INSPECT: "Έλεγχος", + MessageKey.SETTINGS_DATA_LIFECYCLE_NO_CLEANUP: "Δεν έχει φορτωθεί προεπισκόπηση καθαρισμού", + MessageKey.SETTINGS_DATA_LIFECYCLE_NO_EXPORT: "Δεν έχει φορτωθεί εξαγωγή προφίλ", + MessageKey.SETTINGS_DATA_LIFECYCLE_NO_FOOTPRINT: "Δεν έχουν φορτωθεί στοιχεία χώρου", + MessageKey.SETTINGS_DATA_LIFECYCLE_PREVIEW_ID: "Token προεπισκόπησης", + MessageKey.SETTINGS_DATA_LIFECYCLE_RETENTION_DAYS: "Ημέρες διατήρησης", + MessageKey.SETTINGS_DATA_LIFECYCLE_TITLE: "Τοπική διαχείριση δεδομένων", MessageKey.SETTINGS_SIMPLE_HELP: ( "Βασική ρύθμιση και έλεγχοι ρίσκου για καθημερινή λειτουργία." ), MessageKey.SETTINGS_SIMPLE_MODE: "Απλή λειτουργία", MessageKey.SETTINGS_TITLE: "Τοπικές ρυθμίσεις χειριστή", + MessageKey.SETTINGS_UPDATE_ACKNOWLEDGE_UNVERIFIED: "Επιβεβαίωση μη επαληθευμένης προέλευσης", + MessageKey.SETTINGS_UPDATE_ALLOW_DIRTY_WORKTREE: "Να επιτρέπεται proof με αλλαγές worktree", + MessageKey.SETTINGS_UPDATE_APPLY: "Εφαρμογή", + MessageKey.SETTINGS_UPDATE_APPLY_STATE: ( + "Η εφαρμογή κλειδώνει μέχρι να περάσουν προεπισκόπηση και backup." + ), + MessageKey.SETTINGS_UPDATE_BACKUP_REFERENCE: "Αναφορά backup", + MessageKey.SETTINGS_UPDATE_DESCRIPTION: ( + "Μία οθόνη για κατάσταση ενημέρωσης engine και στρατηγικής." + ), + MessageKey.SETTINGS_UPDATE_PREVIEW: "Προεπισκόπηση", + MessageKey.SETTINGS_UPDATE_PREVIEW_STATE: "Δεν έχει φορτωθεί προεπισκόπηση ενημέρωσης.", + MessageKey.SETTINGS_UPDATE_ROLLBACK: "Επαναφορά", + MessageKey.SETTINGS_UPDATE_ROLLBACK_STATE: ( + "Η επαναφορά εμφανίζεται μετά από αποτυχία ελέγχου." + ), + MessageKey.SETTINGS_UPDATE_TITLE: "Ενημέρωση προγραμματιστή", MessageKey.SETTINGS_VALIDATION_PASSED: "Ο έλεγχος πέρασε", - MessageKey.SETUP_API_KEY: "API key", - MessageKey.SETUP_API_SECRET: "API secret", - MessageKey.SETUP_EXCHANGE: "Exchange", + MessageKey.SETUP_ALLOCATED_AMOUNT: "Δεσμευμένο ποσό", + MessageKey.SETUP_API_KEY: "Κλειδί API ανταλλακτηρίου", + MessageKey.SETUP_API_SECRET: "Μυστικό API ανταλλακτηρίου", + MessageKey.SETUP_EXCHANGE: "Ανταλλακτήριο", + MessageKey.SETUP_FETCH_WALLET: "Φόρτωση υπολοίπου πορτοφολιού", MessageKey.SETUP_INTENT: "Πρόθεση", + MessageKey.SETUP_LIVE_WARNING: ( + "Το live μένει κλειδωμένο με confirm, preflight, όρια, kill switch και reconciliation." + ), MessageKey.SETUP_MARKET_MODE: "Λειτουργία αγοράς", MessageKey.SETUP_NO_PREVIEW: "Δεν υπάρχει προεπισκόπηση ρύθμισης", MessageKey.SETUP_OPTION_AGGRESSIVE: "Επιθετικό", MessageKey.SETUP_OPTION_BALANCED: "Ισορροπημένο", MessageKey.SETUP_OPTION_CONSERVATIVE: "Συντηρητικό", + MessageKey.SETUP_OPTION_DISABLED: "Ανενεργό", + MessageKey.SETUP_OPTION_ENABLED: "Ενεργό", + MessageKey.SETUP_OPTION_EXPERT: "Ειδικός", MessageKey.SETUP_OPTION_FUTURES: "Συμβόλαια", MessageKey.SETUP_OPTION_LIVE: "Live", - MessageKey.SETUP_OPTION_PAPER: "Paper", + MessageKey.SETUP_OPTION_NOT_APPLICABLE: "Δεν ισχύει", + MessageKey.SETUP_OPTION_PAPER: "Dry-run", + MessageKey.SETUP_OPTION_SAFE: "Ασφαλές", MessageKey.SETUP_OPTION_SPOT: "Spot", MessageKey.SETUP_OPTION_TESTNET: "Testnet", + MessageKey.SETUP_OPTION_UNKNOWN: "Άγνωστο", + MessageKey.SETUP_PERMISSION_AUDIT: "Έλεγχος δικαιωμάτων API", + MessageKey.SETUP_PERMISSION_FUTURES: "Δικαίωμα συμβολαίων", + MessageKey.SETUP_PERMISSION_IP_ALLOWLIST: "Λίστα επιτρεπόμενων IP", + MessageKey.SETUP_PERMISSION_READ: "Δικαίωμα ανάγνωσης", + MessageKey.SETUP_PERMISSION_TRADE: "Δικαίωμα συναλλαγών", + MessageKey.SETUP_PERMISSION_WITHDRAWAL: "Δικαίωμα ανάληψης", MessageKey.SETUP_PREVIEW_ONLY: "Μόνο προεπισκόπηση", MessageKey.SETUP_PREVIEW_SETUP: "Προεπισκόπηση ρύθμισης", + MessageKey.SETUP_RECOMMENDED_LEVERAGE: "Προτεινόμενη μόχλευση", + MessageKey.SETUP_RISK_EXPERT_CONFIRM: "Κατανοώ το expert risk tier", + MessageKey.SETUP_RISK_PROFILE: "Προφίλ ρίσκου", MessageKey.SETUP_RISK_PRESET: "Προφίλ ρίσκου", MessageKey.SETUP_SAFETY_GATED: "Με πύλη ασφάλειας", MessageKey.SETUP_TITLE: "Ρύθμιση πρώτης εκτέλεσης", + MessageKey.SETUP_WALLET_NOT_FETCHED: "Το υπόλοιπο πορτοφολιού δεν έχει φορτωθεί ακόμη.", + MessageKey.SETUP_WALLET_LOADING: "Φόρτωση υπολοίπου πορτοφολιού...", + MessageKey.SETUP_WALLET_FETCHED: "{available} / {equity} {asset}", + MessageKey.SETUP_WALLET_FETCH_FAILED: "Η φόρτωση υπολοίπου πορτοφολιού απέτυχε.", MessageKey.SETUP_WRITE_ONLY: "Μόνο εγγραφή", } diff --git a/src/nfi_engine/ui/i18n_en.py b/src/nfi_engine/ui/i18n_en.py index 915a4c1..edb4d8f 100644 --- a/src/nfi_engine/ui/i18n_en.py +++ b/src/nfi_engine/ui/i18n_en.py @@ -10,8 +10,10 @@ MessageKey.EXPORT_SUPPORT_REPORT: "Export support report", MessageKey.LOOKUP: "Lookup", MessageKey.OPEN_LOGS: "Open logs", + MessageKey.PAUSE: "Pause", MessageKey.PREVIEW: "Preview", MessageKey.RESTORE: "Restore", + MessageKey.RESUME: "Resume", MessageKey.SAVE_DRAFT: "Save draft", MessageKey.START: "Start", MessageKey.STOP: "Stop", @@ -35,10 +37,36 @@ MessageKey.COMMON_WARN: "Warn", MessageKey.COMMON_WARNING: "Warning", MessageKey.FIELD_EXCHANGE_NAME: "Exchange name", + MessageKey.FIELD_EXPERT_RISK_CONFIRMED: "Expert risk confirmed", MessageKey.FIELD_MAX_OPEN_TRADES: "Max open trades", + MessageKey.FIELD_PERMISSION_WITHDRAWAL: "Withdrawal permission", + MessageKey.FIELD_RISK_PROFILE: "Risk profile", MessageKey.FIELD_RISK_STAKE: "Stake USDT", MessageKey.FIELD_TRADING_MODE: "Trading mode", MessageKey.FIELD_UI_LOCALE: "Language", + MessageKey.HOME_ACTION_EMPTY: "No operator action is required.", + MessageKey.HOME_ACTION_QUEUE: "Action queue", + MessageKey.HOME_COCKPIT_ACTIVE_MODE: "Active mode", + MessageKey.HOME_COCKPIT_ALLOCATED_AMOUNT: "Allocated amount", + MessageKey.HOME_COCKPIT_BLOCKED: "Blocked", + MessageKey.HOME_COCKPIT_CAPABILITY_LEVEL: "Capability level", + MessageKey.HOME_COCKPIT_CONFIGURED: "Configured", + MessageKey.HOME_COCKPIT_CREDENTIALS_MISSING: "Exchange credentials missing", + MessageKey.HOME_COCKPIT_CREDENTIALS_READY: "Exchange credentials ready", + MessageKey.HOME_COCKPIT_GO_SETTINGS: "Open Settings setup", + MessageKey.HOME_COCKPIT_LATEST_ERROR: "Latest error", + MessageKey.HOME_COCKPIT_LEVERAGE: "Leverage", + MessageKey.HOME_COCKPIT_NEXT_ACTION: "Next action", + MessageKey.HOME_COCKPIT_PERMISSION_AUDIT: "API permission audit", + MessageKey.HOME_COCKPIT_RISK_PROFILE: "Risk profile", + MessageKey.HOME_COCKPIT_RUNTIME_HEALTH: "Runtime health", + MessageKey.HOME_COCKPIT_RUNTIME_UNKNOWN: "Not checked", + MessageKey.HOME_COCKPIT_SAFE: "Dry-run safe", + MessageKey.HOME_COCKPIT_SAFETY: "Safety", + MessageKey.HOME_COCKPIT_TITLE: "Operator cockpit", + MessageKey.HOME_COCKPIT_WALLET_BALANCE: "Wallet balance", + MessageKey.HOME_COCKPIT_WALLET_NOT_FETCHED: "Not fetched", + MessageKey.HOME_COCKPIT_WHERE_NEXT: "Where next", MessageKey.HOME_CONFIGURED_PAIRS: "{count} configured pairs", MessageKey.HOME_DOCUMENT_TITLE: "NFI Engine Home", MessageKey.HOME_METRIC_BOT_STATE: "Bot state", @@ -59,6 +87,16 @@ MessageKey.HOME_SUPPORT_DESCRIPTION: "Redacted report with config, logs, and correlation IDs.", MessageKey.HOME_VALUE_LIVE_VENUE: "live venue", MessageKey.HOME_VALUE_TESTNET: "testnet", + MessageKey.HOME_X7_BLOCKED_REASON: "Blocked reason", + MessageKey.HOME_X7_COVERAGE: "Coverage", + MessageKey.HOME_X7_DESCRIPTION: "Evidence-bound X7 status for paper/testnet operation.", + MessageKey.HOME_X7_LATEST_SIGNAL: "Latest signal reason", + MessageKey.HOME_X7_LIVE_READINESS: "Live readiness", + MessageKey.HOME_X7_MISSING_DATA: "Missing data", + MessageKey.HOME_X7_NEXT_ACTION: "Next action", + MessageKey.HOME_X7_PROVENANCE: "Provenance", + MessageKey.HOME_X7_TITLE: "NFI X7 semantic status", + MessageKey.HOME_X7_WARMUP: "Warmup", MessageKey.LOGS_CODE: "Code", MessageKey.LOGS_CORRELATION: "Correlation", MessageKey.LOGS_DOCUMENT_TITLE: "NFI Engine Logs", @@ -95,8 +133,16 @@ MessageKey.READINESS_DB_PATH_READY: "SQLite path is ready", MessageKey.READINESS_DOCKER_VOLUMES_MISSING: "compose named volumes are incomplete", MessageKey.READINESS_DOCKER_VOLUMES_READY: "compose named volumes are configured", + MessageKey.READINESS_EXCHANGE_PERMISSION_AUDIT: "exchange API permissions inspected", MessageKey.READINESS_EXCHANGE_TESTNET_REQUIRED: "exchange must stay simulator or testnet", MessageKey.READINESS_FUTURES_LEVERAGE_INVALID: "futures leverage guardrail failed", + MessageKey.READINESS_LIVE_CIRCUIT_BREAKER_HARDENING: "live circuit breakers need hardening", + MessageKey.READINESS_LIVE_EXCHANGE_CREDENTIALS: "live exchange credentials inspected", + MessageKey.READINESS_LIVE_PERMISSION_HARDENING: "live API permissions need hardening", + MessageKey.READINESS_LIVE_RECONCILIATION_HARDENING: ( + "live startup reconciliation needs hardening" + ), + MessageKey.READINESS_LIVE_STRATEGY_HARDENING: "live strategy coverage needs hardening", MessageKey.READINESS_LIVE_TRADING_DISABLED: "live trading is disabled", MessageKey.READINESS_LIVE_TRADING_OUT_OF_SCOPE: "live real-money orders are out of scope", MessageKey.READINESS_LOG_PATH_NOT_WRITABLE: "log path is not writable", @@ -109,6 +155,7 @@ MessageKey.READINESS_PUBLIC_BIND_NOT_ALLOWED: "api bind is local", MessageKey.READINESS_RECONCILIATION_READY: "startup reconciliation is not required", MessageKey.READINESS_RECONCILIATION_REQUIRED: "startup reconciliation is required", + MessageKey.READINESS_RISK_PROFILE_GUARDRAILS: "risk profile guardrails inspected", MessageKey.READINESS_START_STATE: "Start state: {state}", MessageKey.READINESS_TITLE: "Readiness", MessageKey.READINESS_WEAK_API_TOKEN: "api auth policy passed", @@ -129,31 +176,79 @@ ), MessageKey.SETTINGS_RELOAD_REQUIRED: "Reload required", MessageKey.SETTINGS_RUNTIME_APPLIED: "runtime applied", + MessageKey.SETTINGS_RUNTIME_CONTROL_BLOCKED: "Runtime command blocked", + MessageKey.SETTINGS_RUNTIME_CONTROL_LOADING: "Sending runtime command...", + MessageKey.SETTINGS_RUNTIME_CONTROL_STATE: "Runtime control state", MessageKey.SETTINGS_RUNTIME_SAFE: "Runtime safe", MessageKey.SETTINGS_RUNTIME_SAFE_TITLE: "Runtime-safe settings", MessageKey.SETTINGS_SAFETY_GATES: "Safety gates", + MessageKey.SETTINGS_DATA_LIFECYCLE_APPLY: "Apply cleanup", + MessageKey.SETTINGS_DATA_LIFECYCLE_DRY_RUN: "Dry run cleanup", + MessageKey.SETTINGS_DATA_LIFECYCLE_EXPORT_PROFILE: "Export profile", + MessageKey.SETTINGS_DATA_LIFECYCLE_INSPECT: "Inspect", + MessageKey.SETTINGS_DATA_LIFECYCLE_NO_CLEANUP: "No cleanup preview loaded", + MessageKey.SETTINGS_DATA_LIFECYCLE_NO_EXPORT: "No profile export loaded", + MessageKey.SETTINGS_DATA_LIFECYCLE_NO_FOOTPRINT: "No footprint loaded", + MessageKey.SETTINGS_DATA_LIFECYCLE_PREVIEW_ID: "Preview token", + MessageKey.SETTINGS_DATA_LIFECYCLE_RETENTION_DAYS: "Retention days", + MessageKey.SETTINGS_DATA_LIFECYCLE_TITLE: "Local data lifecycle", MessageKey.SETTINGS_SIMPLE_HELP: "Core setup and risk controls for day-to-day operation.", MessageKey.SETTINGS_SIMPLE_MODE: "Simple Mode", MessageKey.SETTINGS_TITLE: "Local operator settings", + MessageKey.SETTINGS_UPDATE_ACKNOWLEDGE_UNVERIFIED: "Acknowledge unverified provenance", + MessageKey.SETTINGS_UPDATE_ALLOW_DIRTY_WORKTREE: "Allow dirty worktree proof", + MessageKey.SETTINGS_UPDATE_APPLY: "Apply", + MessageKey.SETTINGS_UPDATE_APPLY_STATE: "Apply locked until preview and backup pass.", + MessageKey.SETTINGS_UPDATE_BACKUP_REFERENCE: "Backup reference", + MessageKey.SETTINGS_UPDATE_DESCRIPTION: "One screen for engine + strategy update state.", + MessageKey.SETTINGS_UPDATE_PREVIEW: "Preview", + MessageKey.SETTINGS_UPDATE_PREVIEW_STATE: "No update preview loaded.", + MessageKey.SETTINGS_UPDATE_ROLLBACK: "Rollback", + MessageKey.SETTINGS_UPDATE_ROLLBACK_STATE: "Rollback appears after a failed validation.", + MessageKey.SETTINGS_UPDATE_TITLE: "Developer update", MessageKey.SETTINGS_VALIDATION_PASSED: "Validation passed", - MessageKey.SETUP_API_KEY: "API key", - MessageKey.SETUP_API_SECRET: "API secret", + MessageKey.SETUP_ALLOCATED_AMOUNT: "Allocated amount", + MessageKey.SETUP_API_KEY: "Exchange API key", + MessageKey.SETUP_API_SECRET: "Exchange API secret", MessageKey.SETUP_EXCHANGE: "Exchange", + MessageKey.SETUP_FETCH_WALLET: "Fetch wallet balance", MessageKey.SETUP_INTENT: "Intent", + MessageKey.SETUP_LIVE_WARNING: ( + "Live stays gated by confirmation, preflight, limits, kill switch, and reconciliation." + ), MessageKey.SETUP_MARKET_MODE: "Market mode", MessageKey.SETUP_NO_PREVIEW: "No setup preview", MessageKey.SETUP_OPTION_AGGRESSIVE: "Aggressive", MessageKey.SETUP_OPTION_BALANCED: "Balanced", MessageKey.SETUP_OPTION_CONSERVATIVE: "Conservative", + MessageKey.SETUP_OPTION_DISABLED: "Disabled", + MessageKey.SETUP_OPTION_ENABLED: "Enabled", + MessageKey.SETUP_OPTION_EXPERT: "Expert", MessageKey.SETUP_OPTION_FUTURES: "Futures", MessageKey.SETUP_OPTION_LIVE: "Live", - MessageKey.SETUP_OPTION_PAPER: "Paper", + MessageKey.SETUP_OPTION_NOT_APPLICABLE: "Not applicable", + MessageKey.SETUP_OPTION_PAPER: "Dry-run", + MessageKey.SETUP_OPTION_SAFE: "Safe", MessageKey.SETUP_OPTION_SPOT: "Spot", MessageKey.SETUP_OPTION_TESTNET: "Testnet", + MessageKey.SETUP_OPTION_UNKNOWN: "Unknown", + MessageKey.SETUP_PERMISSION_AUDIT: "API permission audit", + MessageKey.SETUP_PERMISSION_FUTURES: "Futures permission", + MessageKey.SETUP_PERMISSION_IP_ALLOWLIST: "IP allowlist", + MessageKey.SETUP_PERMISSION_READ: "Read permission", + MessageKey.SETUP_PERMISSION_TRADE: "Trade permission", + MessageKey.SETUP_PERMISSION_WITHDRAWAL: "Withdrawal permission", MessageKey.SETUP_PREVIEW_ONLY: "Preview only", MessageKey.SETUP_PREVIEW_SETUP: "Preview setup", + MessageKey.SETUP_RECOMMENDED_LEVERAGE: "Recommended leverage", + MessageKey.SETUP_RISK_EXPERT_CONFIRM: "I understand the expert risk tier", + MessageKey.SETUP_RISK_PROFILE: "Risk profile", MessageKey.SETUP_RISK_PRESET: "Risk preset", MessageKey.SETUP_SAFETY_GATED: "Safety gated", MessageKey.SETUP_TITLE: "First-run setup", + MessageKey.SETUP_WALLET_NOT_FETCHED: "Wallet balance is not fetched yet.", + MessageKey.SETUP_WALLET_LOADING: "Fetching wallet balance...", + MessageKey.SETUP_WALLET_FETCHED: "{available} / {equity} {asset}", + MessageKey.SETUP_WALLET_FETCH_FAILED: "Wallet balance fetch failed.", MessageKey.SETUP_WRITE_ONLY: "Write-only", } diff --git a/src/nfi_engine/ui/i18n_keys.py b/src/nfi_engine/ui/i18n_keys.py index 09f6509..cfd1621 100644 --- a/src/nfi_engine/ui/i18n_keys.py +++ b/src/nfi_engine/ui/i18n_keys.py @@ -10,8 +10,10 @@ class MessageKey(StrEnum): EXPORT_SUPPORT_REPORT = "action.export_support_report" LOOKUP = "action.lookup" OPEN_LOGS = "action.open_logs" + PAUSE = "action.pause" PREVIEW = "action.preview" RESTORE = "action.restore" + RESUME = "action.resume" SAVE_DRAFT = "action.save_draft" START = "action.start" STOP = "action.stop" @@ -36,9 +38,35 @@ class MessageKey(StrEnum): COMMON_WARNING = "common.warning" FIELD_EXCHANGE_NAME = "field.exchange_name" FIELD_MAX_OPEN_TRADES = "field.max_open_trades" + FIELD_EXPERT_RISK_CONFIRMED = "field.expert_risk_confirmed" + FIELD_PERMISSION_WITHDRAWAL = "field.permission_withdrawal" + FIELD_RISK_PROFILE = "field.risk_profile" FIELD_RISK_STAKE = "field.risk_stake" FIELD_TRADING_MODE = "field.trading_mode" FIELD_UI_LOCALE = "field.ui_locale" + HOME_ACTION_EMPTY = "home.action_empty" + HOME_ACTION_QUEUE = "home.action_queue" + HOME_COCKPIT_ACTIVE_MODE = "home.cockpit_active_mode" + HOME_COCKPIT_ALLOCATED_AMOUNT = "home.cockpit_allocated_amount" + HOME_COCKPIT_BLOCKED = "home.cockpit_blocked" + HOME_COCKPIT_CAPABILITY_LEVEL = "home.cockpit_capability_level" + HOME_COCKPIT_CONFIGURED = "home.cockpit_configured" + HOME_COCKPIT_CREDENTIALS_MISSING = "home.cockpit_credentials_missing" + HOME_COCKPIT_CREDENTIALS_READY = "home.cockpit_credentials_ready" + HOME_COCKPIT_GO_SETTINGS = "home.cockpit_go_settings" + HOME_COCKPIT_LATEST_ERROR = "home.cockpit_latest_error" + HOME_COCKPIT_LEVERAGE = "home.cockpit_leverage" + HOME_COCKPIT_NEXT_ACTION = "home.cockpit_next_action" + HOME_COCKPIT_PERMISSION_AUDIT = "home.cockpit_permission_audit" + HOME_COCKPIT_RISK_PROFILE = "home.cockpit_risk_profile" + HOME_COCKPIT_RUNTIME_HEALTH = "home.cockpit_runtime_health" + HOME_COCKPIT_RUNTIME_UNKNOWN = "home.cockpit_runtime_unknown" + HOME_COCKPIT_SAFE = "home.cockpit_safe" + HOME_COCKPIT_SAFETY = "home.cockpit_safety" + HOME_COCKPIT_TITLE = "home.cockpit_title" + HOME_COCKPIT_WALLET_BALANCE = "home.cockpit_wallet_balance" + HOME_COCKPIT_WALLET_NOT_FETCHED = "home.cockpit_wallet_not_fetched" + HOME_COCKPIT_WHERE_NEXT = "home.cockpit_where_next" HOME_CONFIGURED_PAIRS = "home.configured_pairs" HOME_DOCUMENT_TITLE = "home.document_title" HOME_METRIC_BOT_STATE = "home.metric_bot_state" @@ -59,6 +87,16 @@ class MessageKey(StrEnum): HOME_SUPPORT_DESCRIPTION = "home.support_description" HOME_VALUE_LIVE_VENUE = "home.value_live_venue" HOME_VALUE_TESTNET = "home.value_testnet" + HOME_X7_BLOCKED_REASON = "home.x7_blocked_reason" + HOME_X7_COVERAGE = "home.x7_coverage" + HOME_X7_DESCRIPTION = "home.x7_description" + HOME_X7_LATEST_SIGNAL = "home.x7_latest_signal" + HOME_X7_LIVE_READINESS = "home.x7_live_readiness" + HOME_X7_MISSING_DATA = "home.x7_missing_data" + HOME_X7_NEXT_ACTION = "home.x7_next_action" + HOME_X7_PROVENANCE = "home.x7_provenance" + HOME_X7_TITLE = "home.x7_title" + HOME_X7_WARMUP = "home.x7_warmup" LOGS_CODE = "logs.code" LOGS_CORRELATION = "logs.correlation" LOGS_DOCUMENT_TITLE = "logs.document_title" @@ -96,7 +134,13 @@ class MessageKey(StrEnum): READINESS_DOCKER_VOLUMES_MISSING = "readiness.docker_volumes_missing" READINESS_DOCKER_VOLUMES_READY = "readiness.docker_volumes_ready" READINESS_EXCHANGE_TESTNET_REQUIRED = "readiness.exchange_testnet_required" + READINESS_EXCHANGE_PERMISSION_AUDIT = "readiness.exchange_permission_audit" READINESS_FUTURES_LEVERAGE_INVALID = "readiness.futures_leverage_invalid" + READINESS_LIVE_CIRCUIT_BREAKER_HARDENING = "readiness.live_circuit_breaker_hardening" + READINESS_LIVE_EXCHANGE_CREDENTIALS = "readiness.live_exchange_credentials" + READINESS_LIVE_PERMISSION_HARDENING = "readiness.live_permission_hardening" + READINESS_LIVE_RECONCILIATION_HARDENING = "readiness.live_reconciliation_hardening" + READINESS_LIVE_STRATEGY_HARDENING = "readiness.live_strategy_hardening" READINESS_LIVE_TRADING_DISABLED = "readiness.live_trading_disabled" READINESS_LIVE_TRADING_OUT_OF_SCOPE = "readiness.live_trading_out_of_scope" READINESS_LOG_PATH_NOT_WRITABLE = "readiness.log_path_not_writable" @@ -109,10 +153,21 @@ class MessageKey(StrEnum): READINESS_PUBLIC_BIND_NOT_ALLOWED = "readiness.public_bind_not_allowed" READINESS_RECONCILIATION_READY = "readiness.reconciliation_ready" READINESS_RECONCILIATION_REQUIRED = "readiness.reconciliation_required" + READINESS_RISK_PROFILE_GUARDRAILS = "readiness.risk_profile_guardrails" READINESS_START_STATE = "readiness.start_state" READINESS_TITLE = "readiness.title" READINESS_WEAK_API_TOKEN = "readiness.weak_api_token" # noqa: S105 - i18n key. SETTINGS_ADVANCED = "settings.advanced" + SETTINGS_DATA_LIFECYCLE_APPLY = "settings.data_lifecycle_apply" + SETTINGS_DATA_LIFECYCLE_DRY_RUN = "settings.data_lifecycle_dry_run" + SETTINGS_DATA_LIFECYCLE_EXPORT_PROFILE = "settings.data_lifecycle_export_profile" + SETTINGS_DATA_LIFECYCLE_INSPECT = "settings.data_lifecycle_inspect" + SETTINGS_DATA_LIFECYCLE_NO_CLEANUP = "settings.data_lifecycle_no_cleanup" + SETTINGS_DATA_LIFECYCLE_NO_EXPORT = "settings.data_lifecycle_no_export" + SETTINGS_DATA_LIFECYCLE_NO_FOOTPRINT = "settings.data_lifecycle_no_footprint" + SETTINGS_DATA_LIFECYCLE_PREVIEW_ID = "settings.data_lifecycle_preview_id" + SETTINGS_DATA_LIFECYCLE_RETENTION_DAYS = "settings.data_lifecycle_retention_days" + SETTINGS_DATA_LIFECYCLE_TITLE = "settings.data_lifecycle_title" SETTINGS_DOCUMENT_TITLE = "settings.document_title" SETTINGS_DRAFT_REJECTED = "settings.draft_rejected" SETTINGS_DRAFT_SAVED = "settings.draft_saved" @@ -126,32 +181,68 @@ class MessageKey(StrEnum): SETTINGS_READONLY_REASON = "settings.readonly_reason" SETTINGS_RELOAD_REQUIRED = "settings.reload_required" SETTINGS_RUNTIME_APPLIED = "settings.runtime_applied" + SETTINGS_RUNTIME_CONTROL_BLOCKED = "settings.runtime_control_blocked" + SETTINGS_RUNTIME_CONTROL_LOADING = "settings.runtime_control_loading" + SETTINGS_RUNTIME_CONTROL_STATE = "settings.runtime_control_state" SETTINGS_RUNTIME_SAFE = "settings.runtime_safe" SETTINGS_RUNTIME_SAFE_TITLE = "settings.runtime_safe_title" SETTINGS_SAFETY_GATES = "settings.safety_gates" SETTINGS_SIMPLE_HELP = "settings.simple_help" SETTINGS_SIMPLE_MODE = "settings.simple_mode" SETTINGS_TITLE = "settings.title" + SETTINGS_UPDATE_ACKNOWLEDGE_UNVERIFIED = "settings.update_acknowledge_unverified" + SETTINGS_UPDATE_ALLOW_DIRTY_WORKTREE = "settings.update_allow_dirty_worktree" + SETTINGS_UPDATE_APPLY = "settings.update_apply" + SETTINGS_UPDATE_APPLY_STATE = "settings.update_apply_state" + SETTINGS_UPDATE_BACKUP_REFERENCE = "settings.update_backup_reference" + SETTINGS_UPDATE_DESCRIPTION = "settings.update_description" + SETTINGS_UPDATE_PREVIEW = "settings.update_preview" + SETTINGS_UPDATE_PREVIEW_STATE = "settings.update_preview_state" + SETTINGS_UPDATE_ROLLBACK = "settings.update_rollback" + SETTINGS_UPDATE_ROLLBACK_STATE = "settings.update_rollback_state" + SETTINGS_UPDATE_TITLE = "settings.update_title" SETTINGS_VALIDATION_PASSED = "settings.validation_passed" + SETUP_ALLOCATED_AMOUNT = "setup.allocated_amount" SETUP_API_KEY = "setup.api_key" SETUP_API_SECRET = "setup.api_secret" # noqa: S105 - i18n key, not a secret. SETUP_EXCHANGE = "setup.exchange" + SETUP_FETCH_WALLET = "setup.fetch_wallet" SETUP_INTENT = "setup.intent" + SETUP_LIVE_WARNING = "setup.live_warning" SETUP_MARKET_MODE = "setup.market_mode" SETUP_NO_PREVIEW = "setup.no_preview" SETUP_OPTION_AGGRESSIVE = "setup.option_aggressive" SETUP_OPTION_BALANCED = "setup.option_balanced" SETUP_OPTION_CONSERVATIVE = "setup.option_conservative" + SETUP_OPTION_DISABLED = "setup.option_disabled" + SETUP_OPTION_ENABLED = "setup.option_enabled" + SETUP_OPTION_EXPERT = "setup.option_expert" SETUP_OPTION_FUTURES = "setup.option_futures" SETUP_OPTION_LIVE = "setup.option_live" + SETUP_OPTION_NOT_APPLICABLE = "setup.option_not_applicable" SETUP_OPTION_PAPER = "setup.option_paper" + SETUP_OPTION_SAFE = "setup.option_safe" SETUP_OPTION_SPOT = "setup.option_spot" SETUP_OPTION_TESTNET = "setup.option_testnet" + SETUP_OPTION_UNKNOWN = "setup.option_unknown" + SETUP_PERMISSION_AUDIT = "setup.permission_audit" + SETUP_PERMISSION_FUTURES = "setup.permission_futures" + SETUP_PERMISSION_IP_ALLOWLIST = "setup.permission_ip_allowlist" + SETUP_PERMISSION_READ = "setup.permission_read" + SETUP_PERMISSION_TRADE = "setup.permission_trade" + SETUP_PERMISSION_WITHDRAWAL = "setup.permission_withdrawal" SETUP_PREVIEW_ONLY = "setup.preview_only" SETUP_PREVIEW_SETUP = "setup.preview_setup" + SETUP_RECOMMENDED_LEVERAGE = "setup.recommended_leverage" + SETUP_RISK_EXPERT_CONFIRM = "setup.risk_expert_confirm" + SETUP_RISK_PROFILE = "setup.risk_profile" SETUP_RISK_PRESET = "setup.risk_preset" SETUP_SAFETY_GATED = "setup.safety_gated" SETUP_TITLE = "setup.title" + SETUP_WALLET_NOT_FETCHED = "setup.wallet_not_fetched" + SETUP_WALLET_LOADING = "setup.wallet_loading" + SETUP_WALLET_FETCHED = "setup.wallet_fetched" + SETUP_WALLET_FETCH_FAILED = "setup.wallet_fetch_failed" SETUP_WRITE_ONLY = "setup.write_only" diff --git a/src/nfi_engine/ui/i18n_ko.py b/src/nfi_engine/ui/i18n_ko.py index 622f631..dbd4989 100644 --- a/src/nfi_engine/ui/i18n_ko.py +++ b/src/nfi_engine/ui/i18n_ko.py @@ -10,8 +10,10 @@ MessageKey.EXPORT_SUPPORT_REPORT: "지원 리포트 내보내기", MessageKey.LOOKUP: "조회", MessageKey.OPEN_LOGS: "로그 열기", + MessageKey.PAUSE: "일시 중지", MessageKey.PREVIEW: "미리보기", MessageKey.RESTORE: "복원", + MessageKey.RESUME: "재개", MessageKey.SAVE_DRAFT: "초안 저장", MessageKey.START: "시작", MessageKey.STOP: "중지", @@ -35,10 +37,36 @@ MessageKey.COMMON_WARN: "경고", MessageKey.COMMON_WARNING: "경고", MessageKey.FIELD_EXCHANGE_NAME: "거래소 이름", + MessageKey.FIELD_EXPERT_RISK_CONFIRMED: "전문가 리스크 확인", MessageKey.FIELD_MAX_OPEN_TRADES: "최대 오픈 트레이드", + MessageKey.FIELD_PERMISSION_WITHDRAWAL: "출금 권한", + MessageKey.FIELD_RISK_PROFILE: "리스크 프로필", MessageKey.FIELD_RISK_STAKE: "스테이크 USDT", MessageKey.FIELD_TRADING_MODE: "거래 모드", MessageKey.FIELD_UI_LOCALE: "언어", + MessageKey.HOME_ACTION_EMPTY: "지금 필요한 운영자 액션은 없습니다.", + MessageKey.HOME_ACTION_QUEUE: "액션 큐", + MessageKey.HOME_COCKPIT_ACTIVE_MODE: "활성 모드", + MessageKey.HOME_COCKPIT_ALLOCATED_AMOUNT: "할당 금액", + MessageKey.HOME_COCKPIT_BLOCKED: "차단됨", + MessageKey.HOME_COCKPIT_CAPABILITY_LEVEL: "거래소 지원 레벨", + MessageKey.HOME_COCKPIT_CONFIGURED: "설정 상태", + MessageKey.HOME_COCKPIT_CREDENTIALS_MISSING: "거래소 API 키가 필요함", + MessageKey.HOME_COCKPIT_CREDENTIALS_READY: "거래소 API 키 준비됨", + MessageKey.HOME_COCKPIT_GO_SETTINGS: "설정의 setup으로 이동", + MessageKey.HOME_COCKPIT_LATEST_ERROR: "최근 에러", + MessageKey.HOME_COCKPIT_LEVERAGE: "레버리지", + MessageKey.HOME_COCKPIT_NEXT_ACTION: "다음 액션", + MessageKey.HOME_COCKPIT_PERMISSION_AUDIT: "API 권한 점검", + MessageKey.HOME_COCKPIT_RISK_PROFILE: "리스크 프로필", + MessageKey.HOME_COCKPIT_RUNTIME_HEALTH: "런타임 헬스", + MessageKey.HOME_COCKPIT_RUNTIME_UNKNOWN: "아직 점검 전", + MessageKey.HOME_COCKPIT_SAFE: "드라이런 안전", + MessageKey.HOME_COCKPIT_SAFETY: "안전 상태", + MessageKey.HOME_COCKPIT_TITLE: "운영 cockpit", + MessageKey.HOME_COCKPIT_WALLET_BALANCE: "지갑 잔액", + MessageKey.HOME_COCKPIT_WALLET_NOT_FETCHED: "아직 불러오지 않음", + MessageKey.HOME_COCKPIT_WHERE_NEXT: "다음 위치", MessageKey.HOME_CONFIGURED_PAIRS: "설정된 페어 {count}개", MessageKey.HOME_DOCUMENT_TITLE: "NFI Engine 홈", MessageKey.HOME_METRIC_BOT_STATE: "봇 상태", @@ -59,6 +87,16 @@ MessageKey.HOME_SUPPORT_DESCRIPTION: "설정, 로그, correlation ID가 포함된 redacted 리포트.", MessageKey.HOME_VALUE_LIVE_VENUE: "라이브 거래소", MessageKey.HOME_VALUE_TESTNET: "테스트넷", + MessageKey.HOME_X7_BLOCKED_REASON: "차단 사유", + MessageKey.HOME_X7_COVERAGE: "커버리지", + MessageKey.HOME_X7_DESCRIPTION: "paper/testnet 운용을 위한 증거 기반 X7 상태입니다.", + MessageKey.HOME_X7_LATEST_SIGNAL: "최근 시그널 사유", + MessageKey.HOME_X7_LIVE_READINESS: "실거래 준비", + MessageKey.HOME_X7_MISSING_DATA: "누락 데이터", + MessageKey.HOME_X7_NEXT_ACTION: "다음 액션", + MessageKey.HOME_X7_PROVENANCE: "출처", + MessageKey.HOME_X7_TITLE: "NFI X7 semantic 상태", + MessageKey.HOME_X7_WARMUP: "웜업", MessageKey.LOGS_CODE: "코드", MessageKey.LOGS_CORRELATION: "Correlation", MessageKey.LOGS_DOCUMENT_TITLE: "NFI Engine 로그", @@ -95,10 +133,16 @@ MessageKey.READINESS_DB_PATH_READY: "SQLite 경로 준비됨", MessageKey.READINESS_DOCKER_VOLUMES_MISSING: "compose 이름 있는 볼륨이 불완전합니다", MessageKey.READINESS_DOCKER_VOLUMES_READY: "compose 이름 있는 볼륨 설정됨", + MessageKey.READINESS_EXCHANGE_PERMISSION_AUDIT: "거래소 API 권한 점검됨", MessageKey.READINESS_EXCHANGE_TESTNET_REQUIRED: ( "거래소 모드는 시뮬레이터 또는 테스트넷이어야 합니다" ), MessageKey.READINESS_FUTURES_LEVERAGE_INVALID: "선물 레버리지 안전 한도를 통과하지 못했습니다", + MessageKey.READINESS_LIVE_CIRCUIT_BREAKER_HARDENING: "실거래 circuit breaker 강화 필요", + MessageKey.READINESS_LIVE_EXCHANGE_CREDENTIALS: "실거래 거래소 API 키 확인됨", + MessageKey.READINESS_LIVE_PERMISSION_HARDENING: "실거래 API 권한 강화 필요", + MessageKey.READINESS_LIVE_RECONCILIATION_HARDENING: "실거래 시작 reconciliation 강화 필요", + MessageKey.READINESS_LIVE_STRATEGY_HARDENING: "실거래 전략 커버리지 강화 필요", MessageKey.READINESS_LIVE_TRADING_DISABLED: "실거래 비활성화됨", MessageKey.READINESS_LIVE_TRADING_OUT_OF_SCOPE: "실거래 주문은 현재 범위 밖입니다", MessageKey.READINESS_LOG_PATH_NOT_WRITABLE: "로그 경로를 쓸 수 없습니다", @@ -111,6 +155,7 @@ MessageKey.READINESS_PUBLIC_BIND_NOT_ALLOWED: "API 바인드는 로컬입니다", MessageKey.READINESS_RECONCILIATION_READY: "시작 reconciliation 불필요", MessageKey.READINESS_RECONCILIATION_REQUIRED: "시작 reconciliation 필요", + MessageKey.READINESS_RISK_PROFILE_GUARDRAILS: "리스크 프로필 안전 한도 점검됨", MessageKey.READINESS_START_STATE: "시작 상태: {state}", MessageKey.READINESS_TITLE: "준비 상태", MessageKey.READINESS_WEAK_API_TOKEN: "API 인증 정책 통과", @@ -131,31 +176,79 @@ ), MessageKey.SETTINGS_RELOAD_REQUIRED: "재시작 필요", MessageKey.SETTINGS_RUNTIME_APPLIED: "런타임 적용됨", + MessageKey.SETTINGS_RUNTIME_CONTROL_BLOCKED: "런타임 명령이 차단됨", + MessageKey.SETTINGS_RUNTIME_CONTROL_LOADING: "런타임 명령 전송 중...", + MessageKey.SETTINGS_RUNTIME_CONTROL_STATE: "런타임 제어 상태", MessageKey.SETTINGS_RUNTIME_SAFE: "런타임 안전", MessageKey.SETTINGS_RUNTIME_SAFE_TITLE: "런타임 안전 설정", MessageKey.SETTINGS_SAFETY_GATES: "안전 게이트", + MessageKey.SETTINGS_DATA_LIFECYCLE_APPLY: "정리 적용", + MessageKey.SETTINGS_DATA_LIFECYCLE_DRY_RUN: "정리 미리보기 실행", + MessageKey.SETTINGS_DATA_LIFECYCLE_EXPORT_PROFILE: "프로필 내보내기", + MessageKey.SETTINGS_DATA_LIFECYCLE_INSPECT: "점검", + MessageKey.SETTINGS_DATA_LIFECYCLE_NO_CLEANUP: "불러온 정리 미리보기가 없습니다", + MessageKey.SETTINGS_DATA_LIFECYCLE_NO_EXPORT: "불러온 프로필 내보내기가 없습니다", + MessageKey.SETTINGS_DATA_LIFECYCLE_NO_FOOTPRINT: "불러온 저장공간 정보가 없습니다", + MessageKey.SETTINGS_DATA_LIFECYCLE_PREVIEW_ID: "미리보기 토큰", + MessageKey.SETTINGS_DATA_LIFECYCLE_RETENTION_DAYS: "보관 일수", + MessageKey.SETTINGS_DATA_LIFECYCLE_TITLE: "로컬 데이터 관리", MessageKey.SETTINGS_SIMPLE_HELP: "일상 운용을 위한 핵심 설정과 리스크 제어.", MessageKey.SETTINGS_SIMPLE_MODE: "간편 모드", MessageKey.SETTINGS_TITLE: "로컬 운영자 설정", + MessageKey.SETTINGS_UPDATE_ACKNOWLEDGE_UNVERIFIED: "검증되지 않은 출처 확인", + MessageKey.SETTINGS_UPDATE_ALLOW_DIRTY_WORKTREE: "변경된 작업트리 허용", + MessageKey.SETTINGS_UPDATE_APPLY: "적용", + MessageKey.SETTINGS_UPDATE_APPLY_STATE: "미리보기와 백업 통과 전까지 적용 잠김.", + MessageKey.SETTINGS_UPDATE_BACKUP_REFERENCE: "백업 참조", + MessageKey.SETTINGS_UPDATE_DESCRIPTION: "엔진 + 전략 업데이트 상태를 한 화면에서 확인합니다.", + MessageKey.SETTINGS_UPDATE_PREVIEW: "미리보기", + MessageKey.SETTINGS_UPDATE_PREVIEW_STATE: "불러온 업데이트 미리보기가 없습니다.", + MessageKey.SETTINGS_UPDATE_ROLLBACK: "롤백", + MessageKey.SETTINGS_UPDATE_ROLLBACK_STATE: "검증 실패 후 롤백 상태가 표시됩니다.", + MessageKey.SETTINGS_UPDATE_TITLE: "개발자 업데이트", MessageKey.SETTINGS_VALIDATION_PASSED: "검증 통과", - MessageKey.SETUP_API_KEY: "API 키", - MessageKey.SETUP_API_SECRET: "API 시크릿", + MessageKey.SETUP_ALLOCATED_AMOUNT: "할당 금액", + MessageKey.SETUP_API_KEY: "거래소 API 키", + MessageKey.SETUP_API_SECRET: "거래소 API 시크릿", MessageKey.SETUP_EXCHANGE: "거래소", + MessageKey.SETUP_FETCH_WALLET: "지갑 잔액 불러오기", MessageKey.SETUP_INTENT: "의도", + MessageKey.SETUP_LIVE_WARNING: ( + "라이브는 명시 확인, preflight, 한도, kill switch, reconciliation으로 계속 제한됩니다." + ), MessageKey.SETUP_MARKET_MODE: "마켓 모드", MessageKey.SETUP_NO_PREVIEW: "설정 미리보기 없음", MessageKey.SETUP_OPTION_AGGRESSIVE: "공격적", MessageKey.SETUP_OPTION_BALANCED: "균형", MessageKey.SETUP_OPTION_CONSERVATIVE: "보수적", + MessageKey.SETUP_OPTION_DISABLED: "비활성", + MessageKey.SETUP_OPTION_ENABLED: "활성", + MessageKey.SETUP_OPTION_EXPERT: "전문가", MessageKey.SETUP_OPTION_FUTURES: "선물", MessageKey.SETUP_OPTION_LIVE: "라이브", - MessageKey.SETUP_OPTION_PAPER: "페이퍼", + MessageKey.SETUP_OPTION_NOT_APPLICABLE: "해당 없음", + MessageKey.SETUP_OPTION_PAPER: "드라이런", + MessageKey.SETUP_OPTION_SAFE: "안전", MessageKey.SETUP_OPTION_SPOT: "현물", MessageKey.SETUP_OPTION_TESTNET: "테스트넷", + MessageKey.SETUP_OPTION_UNKNOWN: "알 수 없음", + MessageKey.SETUP_PERMISSION_AUDIT: "API 권한 점검", + MessageKey.SETUP_PERMISSION_FUTURES: "선물 권한", + MessageKey.SETUP_PERMISSION_IP_ALLOWLIST: "IP 허용 목록", + MessageKey.SETUP_PERMISSION_READ: "읽기 권한", + MessageKey.SETUP_PERMISSION_TRADE: "거래 권한", + MessageKey.SETUP_PERMISSION_WITHDRAWAL: "출금 권한", MessageKey.SETUP_PREVIEW_ONLY: "미리보기 전용", MessageKey.SETUP_PREVIEW_SETUP: "설정 미리보기", + MessageKey.SETUP_RECOMMENDED_LEVERAGE: "권장 레버리지", + MessageKey.SETUP_RISK_EXPERT_CONFIRM: "전문가 리스크 단계를 이해했습니다", + MessageKey.SETUP_RISK_PROFILE: "리스크 프로필", MessageKey.SETUP_RISK_PRESET: "리스크 프리셋", MessageKey.SETUP_SAFETY_GATED: "안전 게이트 적용", MessageKey.SETUP_TITLE: "첫 실행 설정", + MessageKey.SETUP_WALLET_NOT_FETCHED: "지갑 잔액은 아직 불러오지 않았습니다.", + MessageKey.SETUP_WALLET_LOADING: "지갑 잔액을 불러오는 중...", + MessageKey.SETUP_WALLET_FETCHED: "{available} / {equity} {asset}", + MessageKey.SETUP_WALLET_FETCH_FAILED: "지갑 잔액 조회 실패.", MessageKey.SETUP_WRITE_ONLY: "쓰기 전용", } diff --git a/src/nfi_engine/ui/logs_page.py b/src/nfi_engine/ui/logs_page.py index ac7b2b4..282528e 100644 --- a/src/nfi_engine/ui/logs_page.py +++ b/src/nfi_engine/ui/logs_page.py @@ -81,12 +81,19 @@ def render_logs_body( def _log_row(log: LogEntryResponse) -> str: severity_class = "severity-error" if log.level.value == "ERROR" else "" + full_time = log.at.isoformat() + display_time = _compact_log_time(full_time) return f""" - {escape(log.at.isoformat())} + {escape(display_time)} {escape(log.level.value)} - {escape(log.code)} + {escape(log.code)} {escape(log.correlation_id)} {escape(log.safe_summary)} """ + + +def _compact_log_time(value: str) -> str: + without_fraction = value.split(".", maxsplit=1)[0] + return without_fraction.replace("T", " ", 1)[:19] diff --git a/src/nfi_engine/ui/pages.py b/src/nfi_engine/ui/pages.py index 4444406..2fd7126 100644 --- a/src/nfi_engine/ui/pages.py +++ b/src/nfi_engine/ui/pages.py @@ -1,29 +1,34 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from nfi_engine.api.models import LogEntryResponse from nfi_engine.config import RuntimeSettings -from nfi_engine.dashboard import DashboardReadModels -from nfi_engine.preflight.models import PreflightReport from nfi_engine.ui.assets_dashboard import DASHBOARD_SCRIPT, DASHBOARD_STYLE +from nfi_engine.ui.assets_data_lifecycle import DATA_LIFECYCLE_SCRIPT from nfi_engine.ui.assets_login import LOGIN_SCRIPT from nfi_engine.ui.assets_logs import LOGS_SCRIPT from nfi_engine.ui.assets_pairlist import PAIRLIST_SCRIPT +from nfi_engine.ui.assets_runtime_control import RUNTIME_CONTROL_SCRIPT from nfi_engine.ui.assets_settings import SETTINGS_SCRIPT from nfi_engine.ui.document import render_document, render_nav from nfi_engine.ui.home import render_home_body +from nfi_engine.ui.home_context import HomeRuntimeContext from nfi_engine.ui.i18n import localize, render_i18n_script from nfi_engine.ui.i18n_keys import MessageKey from nfi_engine.ui.login_page import render_login_body from nfi_engine.ui.logs_page import render_logs_body from nfi_engine.ui.settings_page import render_settings_body +if TYPE_CHECKING: + from nfi_engine.preflight.models import PreflightReport + def render_home_page( *, settings: RuntimeSettings, logs: tuple[LogEntryResponse, ...], - read_models: DashboardReadModels | None = None, - readiness: PreflightReport | None = None, + runtime: HomeRuntimeContext | None = None, csrf_token: str = "", ) -> str: locale = settings.ui.locale @@ -35,11 +40,11 @@ def render_home_page( body=render_home_body( settings=settings, logs=logs, - read_models=read_models, - readiness=readiness, + runtime=runtime or HomeRuntimeContext(), nav=render_nav(active="home", locale=locale), ) + render_i18n_script(settings.ui.locale) + + RUNTIME_CONTROL_SCRIPT + DASHBOARD_SCRIPT, ) @@ -62,6 +67,8 @@ def render_settings_page( ) + render_i18n_script(settings.ui.locale) + SETTINGS_SCRIPT + + DATA_LIFECYCLE_SCRIPT + + RUNTIME_CONTROL_SCRIPT + PAIRLIST_SCRIPT, ) diff --git a/src/nfi_engine/ui/readiness.py b/src/nfi_engine/ui/readiness.py index 1c4bec9..195e128 100644 --- a/src/nfi_engine/ui/readiness.py +++ b/src/nfi_engine/ui/readiness.py @@ -15,8 +15,18 @@ PreflightCode.DB_PATH_READY: MessageKey.READINESS_DB_PATH_READY, PreflightCode.DOCKER_VOLUMES_MISSING: MessageKey.READINESS_DOCKER_VOLUMES_MISSING, PreflightCode.DOCKER_VOLUMES_READY: MessageKey.READINESS_DOCKER_VOLUMES_READY, + PreflightCode.EXCHANGE_PERMISSION_AUDIT: MessageKey.READINESS_EXCHANGE_PERMISSION_AUDIT, PreflightCode.EXCHANGE_TESTNET_REQUIRED: MessageKey.READINESS_EXCHANGE_TESTNET_REQUIRED, PreflightCode.FUTURES_LEVERAGE_INVALID: MessageKey.READINESS_FUTURES_LEVERAGE_INVALID, + PreflightCode.LIVE_CIRCUIT_BREAKER_HARDENING: ( + MessageKey.READINESS_LIVE_CIRCUIT_BREAKER_HARDENING + ), + PreflightCode.LIVE_EXCHANGE_CREDENTIALS: MessageKey.READINESS_LIVE_EXCHANGE_CREDENTIALS, + PreflightCode.LIVE_PERMISSION_HARDENING: MessageKey.READINESS_LIVE_PERMISSION_HARDENING, + PreflightCode.LIVE_RECONCILIATION_HARDENING: ( + MessageKey.READINESS_LIVE_RECONCILIATION_HARDENING + ), + PreflightCode.LIVE_STRATEGY_HARDENING: MessageKey.READINESS_LIVE_STRATEGY_HARDENING, PreflightCode.LIVE_TRADING_DISABLED: MessageKey.READINESS_LIVE_TRADING_DISABLED, PreflightCode.LIVE_TRADING_OUT_OF_SCOPE: MessageKey.READINESS_LIVE_TRADING_OUT_OF_SCOPE, PreflightCode.LOG_PATH_NOT_WRITABLE: MessageKey.READINESS_LOG_PATH_NOT_WRITABLE, @@ -29,6 +39,7 @@ PreflightCode.PUBLIC_BIND_NOT_ALLOWED: MessageKey.READINESS_PUBLIC_BIND_NOT_ALLOWED, PreflightCode.RECONCILIATION_READY: MessageKey.READINESS_RECONCILIATION_READY, PreflightCode.RECONCILIATION_REQUIRED: MessageKey.READINESS_RECONCILIATION_REQUIRED, + PreflightCode.RISK_PROFILE_GUARDRAILS: MessageKey.READINESS_RISK_PROFILE_GUARDRAILS, PreflightCode.WEAK_API_TOKEN: MessageKey.READINESS_WEAK_API_TOKEN, } diff --git a/src/nfi_engine/ui/runtime_controls.py b/src/nfi_engine/ui/runtime_controls.py new file mode 100644 index 0000000..9db9bde --- /dev/null +++ b/src/nfi_engine/ui/runtime_controls.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from html import escape + +from nfi_engine.config import Locale, RuntimeSettings +from nfi_engine.paper import BotState +from nfi_engine.ui.i18n import localize +from nfi_engine.ui.i18n_keys import MessageKey + + +def render_runtime_controls( + *, + settings: RuntimeSettings, + locale: Locale, + state: BotState = BotState.STOPPED, +) -> str: + disabled = _disabled_attrs(settings) + return ( + '
    \n' + f"

    {localize(locale, MessageKey.SETTINGS_RUNTIME_CONTROL_STATE)}

    \n" + '
    ' + f"{escape(state.value)}
    \n" + '
    ' + f"{localize(locale, MessageKey.HOME_COCKPIT_RUNTIME_UNKNOWN)}
    \n" + '
    \n' + f" {_button('start-button', 'start', localize(locale, MessageKey.START), disabled)}\n" + f" {_button('pause-button', 'pause', localize(locale, MessageKey.PAUSE), disabled)}\n" + f" {_button('resume-button', 'resume', localize(locale, MessageKey.RESUME), disabled)}\n" + f" {_button('stop-button', 'stop', localize(locale, MessageKey.STOP), disabled)}\n" + "
    \n" + "
    \n" + ) + + +def _button(test_id: str, command: str, label: str, disabled: str) -> str: + return ( + f'' + ) + + +def _disabled_attrs(settings: RuntimeSettings) -> str: + if not settings.ui.read_only: + return "" + title = escape(localize(settings.ui.locale, MessageKey.SETTINGS_READONLY_DISABLED_TITLE)) + return f' disabled title="{title}"' diff --git a/src/nfi_engine/ui/settings_data_lifecycle.py b/src/nfi_engine/ui/settings_data_lifecycle.py new file mode 100644 index 0000000..d778c1c --- /dev/null +++ b/src/nfi_engine/ui/settings_data_lifecycle.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from html import escape + +from nfi_engine.config import RuntimeSettings +from nfi_engine.ui.i18n import localize +from nfi_engine.ui.i18n_keys import MessageKey + + +def render_settings_data_lifecycle_panel(*, settings: RuntimeSettings) -> str: + locale = settings.ui.locale + disabled = _disabled_attrs(settings) + return f""" +
    +

    {localize(locale, MessageKey.SETTINGS_DATA_LIFECYCLE_TITLE)}

    +
    + + +
    +
    + + + + +
    +
    + {localize(locale, MessageKey.SETTINGS_DATA_LIFECYCLE_NO_FOOTPRINT)} +
    +
    + {localize(locale, MessageKey.SETTINGS_DATA_LIFECYCLE_NO_EXPORT)} +
    +
    + {localize(locale, MessageKey.SETTINGS_DATA_LIFECYCLE_NO_CLEANUP)} +
    +
    +""" + + +def _disabled_attrs(settings: RuntimeSettings) -> str: + if not settings.ui.read_only: + return "" + title = escape(localize(settings.ui.locale, MessageKey.SETTINGS_READONLY_DISABLED_TITLE)) + return f' disabled title="{title}"' diff --git a/src/nfi_engine/ui/settings_field_values.py b/src/nfi_engine/ui/settings_field_values.py new file mode 100644 index 0000000..71f5608 --- /dev/null +++ b/src/nfi_engine/ui/settings_field_values.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from decimal import Decimal + +from nfi_engine.config import RuntimeSettings + + +def field_value(*, settings: RuntimeSettings, path: str) -> str: + return field_values(settings).get(path, "") + + +def field_values(settings: RuntimeSettings) -> dict[str, str]: + margin_mode = ( + "" if settings.exchange.margin_mode is None else settings.exchange.margin_mode.value + ) + return { + "exchange.name": settings.exchange.name, + "exchange.trading_mode": settings.exchange.trading_mode.value, + "exchange.margin_mode": margin_mode, + "exchange.permission_read": settings.exchange.permission_read.value, + "exchange.permission_trade": settings.exchange.permission_trade.value, + "exchange.permission_futures": settings.exchange.permission_futures.value, + "exchange.permission_withdrawal": settings.exchange.permission_withdrawal.value, + "exchange.permission_ip_allowlist": settings.exchange.permission_ip_allowlist.value, + "risk.risk_profile": settings.risk.risk_profile.value, + "risk.expert_risk_confirmed": _bool(settings.risk.expert_risk_confirmed), + "risk.stake_usdt": _decimal(settings.risk.stake_usdt), + "risk.max_daily_loss_pct": _decimal(settings.risk.max_daily_loss_pct), + "risk.allocation_cap_pct": _decimal(settings.risk.allocation_cap_pct), + "risk.leverage": _decimal(settings.risk.leverage), + "risk.max_leverage": _decimal(settings.risk.max_leverage), + "risk.liquidation_buffer": _decimal(settings.risk.liquidation_buffer), + "risk.max_open_trades": str(settings.risk.max_open_trades), + "risk.stoploss_pct": _decimal(settings.risk.stoploss_pct), + "risk.minimal_roi": _decimal(settings.risk.minimal_roi), + "risk.cooldown_seconds": str(settings.risk.cooldown_seconds), + "risk.locked_pairs": settings.risk.locked_pairs, + "backtest.stoploss_pct": _decimal(settings.backtest.stoploss_pct), + "backtest.fee_rate": _decimal(settings.backtest.fee_rate), + "backtest.slippage_rate": _decimal(settings.backtest.slippage_rate), + "backtest.max_open_trades": str(settings.backtest.max_open_trades), + "ui.locale": settings.ui.locale.value, + "ui.read_only": _bool(settings.ui.read_only), + "logging.level": settings.logging.level.value, + "notifications.enabled": _bool(settings.notifications.enabled), + "notifications.jsonl_path": settings.notifications.jsonl_path, + "notifications.timeout_seconds": _decimal(settings.notifications.timeout_seconds), + "notifications.max_attempts": str(settings.notifications.max_attempts), + } + + +def _decimal(value: Decimal) -> str: + return format(value, "f") + + +def _bool(value: bool) -> str: + return str(value).lower() diff --git a/src/nfi_engine/ui/settings_fields.py b/src/nfi_engine/ui/settings_fields.py index 1118a7a..88b5c06 100644 --- a/src/nfi_engine/ui/settings_fields.py +++ b/src/nfi_engine/ui/settings_fields.py @@ -1,16 +1,17 @@ from __future__ import annotations -from decimal import Decimal from html import escape -from typing import Final +from typing import Final, assert_never from nfi_engine.config import FieldGroup, FieldMetadata, Locale, RuntimeSettings, frontend_metadata from nfi_engine.ui.i18n import localize from nfi_engine.ui.i18n_keys import MessageKey +from nfi_engine.ui.settings_field_values import field_value NUMERIC_FIELDS: Final = frozenset( ( "risk.stake_usdt", + "risk.allocation_cap_pct", "risk.max_daily_loss_pct", "risk.leverage", "risk.max_leverage", @@ -27,11 +28,36 @@ "notifications.max_attempts", ), ) -BOOLEAN_FIELDS: Final = frozenset(("ui.read_only", "notifications.enabled")) +BOOLEAN_FIELDS: Final = frozenset( + ("risk.expert_risk_confirmed", "ui.read_only", "notifications.enabled") +) +PERMISSION_FIELDS: Final = frozenset( + ( + "exchange.permission_read", + "exchange.permission_trade", + "exchange.permission_futures", + "exchange.permission_withdrawal", + "exchange.permission_ip_allowlist", + ) +) +OPTION_LABELS: Final[dict[str, MessageKey]] = { + "aggressive": MessageKey.SETUP_OPTION_AGGRESSIVE, + "balanced": MessageKey.SETUP_OPTION_BALANCED, + "conservative": MessageKey.SETUP_OPTION_CONSERVATIVE, + "disabled": MessageKey.SETUP_OPTION_DISABLED, + "enabled": MessageKey.SETUP_OPTION_ENABLED, + "expert": MessageKey.SETUP_OPTION_EXPERT, + "futures": MessageKey.SETUP_OPTION_FUTURES, + "not_applicable": MessageKey.SETUP_OPTION_NOT_APPLICABLE, + "safe": MessageKey.SETUP_OPTION_SAFE, + "spot": MessageKey.SETUP_OPTION_SPOT, + "unknown": MessageKey.SETUP_OPTION_UNKNOWN, +} SIMPLE_FIELD_ORDER: Final = ( "exchange.name", "exchange.trading_mode", "ui.locale", + "risk.risk_profile", "risk.stake_usdt", "risk.max_open_trades", ) @@ -79,7 +105,7 @@ def _advanced_fields() -> tuple[FieldMetadata, ...]: def _field_row(*, settings: RuntimeSettings, field: FieldMetadata, locale: Locale) -> str: - value = _field_value(settings=settings, path=field.path) + value = field_value(settings=settings, path=field.path) note = localize( locale, MessageKey.SETTINGS_RUNTIME_SAFE @@ -89,13 +115,13 @@ def _field_row(*, settings: RuntimeSettings, field: FieldMetadata, locale: Local return f"""
    - {_control(field=field, value=value)} + {_control(field=field, value=value, locale=locale)} {note}
    """ -def _control(*, field: FieldMetadata, value: str) -> str: +def _control(*, field: FieldMetadata, value: str, locale: Locale) -> str: escaped_path = escape(field.path) common = ( f'id="{escaped_path}" name="{escaped_path}" ' @@ -106,12 +132,15 @@ def _control(*, field: FieldMetadata, value: str) -> str: if field.path in BOOLEAN_FIELDS: checked = " checked" if value == "true" else "" return f'' - if field.path == "exchange.trading_mode": - return _select(common=common, value=value, options=("spot", "futures"), disabled=disabled) - if field.path == "ui.locale": - return _select(common=common, value=value, options=tuple(locale.value for locale in Locale)) - if field.path == "logging.level": - return _select(common=common, value=value, options=("DEBUG", "INFO", "WARNING", "ERROR")) + select_control = _select_control( + field=field, + value=value, + common=common, + disabled=disabled, + locale=locale, + ) + if select_control is not None: + return select_control if field.path in NUMERIC_FIELDS: step = "1" if field.path.endswith(("trades", "seconds", "attempts")) else "0.01" min_value = "1" if field.path.endswith(("trades", "attempts")) else "0" @@ -122,68 +151,103 @@ def _control(*, field: FieldMetadata, value: str) -> str: return f'' -def _select(*, common: str, value: str, options: tuple[str, ...], disabled: str = "") -> str: - choices = "\n".join(_option(value=value, option=option) for option in options) +def _select_control( + *, + field: FieldMetadata, + value: str, + common: str, + disabled: str, + locale: Locale, +) -> str | None: + if field.path == "exchange.trading_mode": + return _select( + common=common, + value=value, + options=("spot", "futures"), + disabled=disabled, + locale=locale, + ) + if field.path == "risk.risk_profile": + return _select( + common=common, + value=value, + options=("safe", "balanced", "expert"), + locale=locale, + ) + if field.path in PERMISSION_FIELDS: + return _select( + common=common, + value=value, + options=("unknown", "enabled", "disabled", "not_applicable"), + disabled=disabled, + locale=locale, + ) + if field.path == "ui.locale": + return _locale_select(common=common, value=value) + if field.path == "logging.level": + return _select( + common=common, + value=value, + options=("DEBUG", "INFO", "WARNING", "ERROR"), + locale=locale, + ) + return None + + +def _select( + *, + common: str, + value: str, + options: tuple[str, ...], + locale: Locale, + disabled: str = "", +) -> str: + choices = "\n".join(_option(value=value, option=option, locale=locale) for option in options) return f"" -def _option(*, value: str, option: str) -> str: +def _locale_select(*, common: str, value: str) -> str: + choices = "\n".join(_locale_option(value=value, locale=locale) for locale in Locale) + return f"" + + +def _option(*, value: str, option: str, locale: Locale) -> str: selected = " selected" if value == option else "" - return f'' + label_key = OPTION_LABELS.get(option) + label = option.title() if label_key is None else localize(locale, label_key) + return f'' + + +def _locale_option(*, value: str, locale: Locale) -> str: + selected = " selected" if value == locale.value else "" + return ( + f'' + ) + + +def _locale_label(locale: Locale) -> str: + match locale: + case Locale.EN: + return "English" + case Locale.KO: + return "한국어" + case Locale.EL: + return "Ελληνικά" + case unreachable: + assert_never(unreachable) def _field_label(path: str, *, locale: Locale) -> str: labels: dict[str, MessageKey] = { "exchange.name": MessageKey.FIELD_EXCHANGE_NAME, "exchange.trading_mode": MessageKey.FIELD_TRADING_MODE, + "exchange.permission_withdrawal": MessageKey.FIELD_PERMISSION_WITHDRAWAL, "ui.locale": MessageKey.FIELD_UI_LOCALE, + "risk.risk_profile": MessageKey.FIELD_RISK_PROFILE, + "risk.expert_risk_confirmed": MessageKey.FIELD_EXPERT_RISK_CONFIRMED, "risk.stake_usdt": MessageKey.FIELD_RISK_STAKE, "risk.max_open_trades": MessageKey.FIELD_MAX_OPEN_TRADES, } if path in labels: return localize(locale, labels[path]) return path.replace("_", " ").replace(".", " / ").title() - - -def _field_value(*, settings: RuntimeSettings, path: str) -> str: - return _field_values(settings).get(path, "") - - -def _field_values(settings: RuntimeSettings) -> dict[str, str]: - margin_mode = ( - "" if settings.exchange.margin_mode is None else settings.exchange.margin_mode.value - ) - return { - "exchange.name": settings.exchange.name, - "exchange.trading_mode": settings.exchange.trading_mode.value, - "exchange.margin_mode": margin_mode, - "risk.stake_usdt": _decimal(settings.risk.stake_usdt), - "risk.max_daily_loss_pct": _decimal(settings.risk.max_daily_loss_pct), - "risk.leverage": _decimal(settings.risk.leverage), - "risk.max_leverage": _decimal(settings.risk.max_leverage), - "risk.liquidation_buffer": _decimal(settings.risk.liquidation_buffer), - "risk.max_open_trades": str(settings.risk.max_open_trades), - "risk.stoploss_pct": _decimal(settings.risk.stoploss_pct), - "risk.minimal_roi": _decimal(settings.risk.minimal_roi), - "risk.cooldown_seconds": str(settings.risk.cooldown_seconds), - "risk.locked_pairs": settings.risk.locked_pairs, - "backtest.stoploss_pct": _decimal(settings.backtest.stoploss_pct), - "backtest.fee_rate": _decimal(settings.backtest.fee_rate), - "backtest.slippage_rate": _decimal(settings.backtest.slippage_rate), - "backtest.max_open_trades": str(settings.backtest.max_open_trades), - "ui.locale": settings.ui.locale.value, - "ui.read_only": _bool(settings.ui.read_only), - "logging.level": settings.logging.level.value, - "notifications.enabled": _bool(settings.notifications.enabled), - "notifications.jsonl_path": settings.notifications.jsonl_path, - "notifications.timeout_seconds": _decimal(settings.notifications.timeout_seconds), - "notifications.max_attempts": str(settings.notifications.max_attempts), - } - - -def _decimal(value: Decimal) -> str: - return format(value, "f") - - -def _bool(value: bool) -> str: - return str(value).lower() diff --git a/src/nfi_engine/ui/settings_page.py b/src/nfi_engine/ui/settings_page.py index 3023875..fb7c483 100644 --- a/src/nfi_engine/ui/settings_page.py +++ b/src/nfi_engine/ui/settings_page.py @@ -1,26 +1,18 @@ from __future__ import annotations from html import escape -from typing import Final -from nfi_engine.config import Locale, RuntimeSettings +from nfi_engine.config import RuntimeSettings from nfi_engine.preflight.models import PreflightReport from nfi_engine.ui.i18n import localize from nfi_engine.ui.i18n_keys import MessageKey from nfi_engine.ui.pairlist import render_pairlist_panel from nfi_engine.ui.readiness import render_readiness_panel +from nfi_engine.ui.runtime_controls import render_runtime_controls +from nfi_engine.ui.settings_data_lifecycle import render_settings_data_lifecycle_panel from nfi_engine.ui.settings_fields import render_settings_fields - -SETUP_OPTION_LABELS: Final[dict[str, MessageKey]] = { - "aggressive": MessageKey.SETUP_OPTION_AGGRESSIVE, - "balanced": MessageKey.SETUP_OPTION_BALANCED, - "conservative": MessageKey.SETUP_OPTION_CONSERVATIVE, - "futures": MessageKey.SETUP_OPTION_FUTURES, - "live": MessageKey.SETUP_OPTION_LIVE, - "paper": MessageKey.SETUP_OPTION_PAPER, - "spot": MessageKey.SETUP_OPTION_SPOT, - "testnet": MessageKey.SETUP_OPTION_TESTNET, -} +from nfi_engine.ui.settings_update import render_settings_update_panel +from nfi_engine.ui.setup_wizard import render_setup_wizard def render_settings_body( @@ -31,9 +23,12 @@ def render_settings_body( ) -> str: locale = settings.ui.locale rows = render_settings_fields(settings) - setup_panel = _setup_preview_panel(settings, locale=locale) + setup_panel = render_setup_wizard(settings, locale=locale) + update_panel = render_settings_update_panel(settings=settings) readiness_panel = render_readiness_panel(readiness, locale=locale) + runtime_controls = render_runtime_controls(settings=settings, locale=locale) pairlist_panel = render_pairlist_panel(settings, read_only=settings.ui.read_only) + lifecycle_panel = render_settings_data_lifecycle_panel(settings=settings) readonly_panel = _readonly_panel(settings) disabled = _disabled_attrs(settings) return f""" @@ -72,8 +67,11 @@ def render_settings_body(
    {readonly_panel} + {update_panel} {readiness_panel} + {runtime_controls} {pairlist_panel} + {lifecycle_panel}

    {localize(locale, MessageKey.SETTINGS_SAFETY_GATES)}

    @@ -83,12 +81,6 @@ def render_settings_body( - -
    @@ -96,59 +88,6 @@ def render_settings_body( """ -def _setup_preview_panel(settings: RuntimeSettings, *, locale: Locale) -> str: - intent = "testnet" if settings.exchange.testnet else "live" - return f""" -
    -

    {localize(locale, MessageKey.SETUP_TITLE)}

    -
    - - - {localize(locale, MessageKey.SETTINGS_RELOAD_REQUIRED)} - - - {localize(locale, MessageKey.SETTINGS_RELOAD_REQUIRED)} - - - {localize(locale, MessageKey.SETUP_SAFETY_GATED)} - - - {localize(locale, MessageKey.SETUP_PREVIEW_ONLY)} - - - {localize(locale, MessageKey.SETUP_WRITE_ONLY)} - - - {localize(locale, MessageKey.SETUP_WRITE_ONLY)} -
    -
    - -
    -
    \
    -{localize(locale, MessageKey.SETUP_NO_PREVIEW)}
    -
    -""" - - -def _option(*, locale: Locale, value: str, option: str) -> str: - selected = " selected" if value == option else "" - label = localize(locale, SETUP_OPTION_LABELS[option]) - return f'' - - def _readonly_panel(settings: RuntimeSettings) -> str: if not settings.ui.read_only: return "" diff --git a/src/nfi_engine/ui/settings_update.py b/src/nfi_engine/ui/settings_update.py new file mode 100644 index 0000000..48f2eb0 --- /dev/null +++ b/src/nfi_engine/ui/settings_update.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from html import escape + +from nfi_engine.config import RuntimeSettings +from nfi_engine.ui.i18n import localize +from nfi_engine.ui.i18n_keys import MessageKey + + +def render_settings_update_panel(*, settings: RuntimeSettings) -> str: + locale = settings.ui.locale + disabled = _disabled_attrs(settings) + return f""" +
    +

    {localize(locale, MessageKey.SETTINGS_UPDATE_TITLE)}

    +

    {localize(locale, MessageKey.SETTINGS_UPDATE_DESCRIPTION)}

    +
    + { + _update_state( + test_id="update-preview-state", + title=localize(locale, MessageKey.SETTINGS_UPDATE_PREVIEW), + body=localize(locale, MessageKey.SETTINGS_UPDATE_PREVIEW_STATE), + ) + } + { + _update_state( + test_id="update-apply-state", + title=localize(locale, MessageKey.SETTINGS_UPDATE_APPLY), + body=localize(locale, MessageKey.SETTINGS_UPDATE_APPLY_STATE), + ) + } + { + _update_state( + test_id="update-rollback-state", + title=localize(locale, MessageKey.SETTINGS_UPDATE_ROLLBACK), + body=localize(locale, MessageKey.SETTINGS_UPDATE_ROLLBACK_STATE), + ) + } +
    +
    + + + + +
    +
    + + + +
    +
    +""" + + +def _update_state(*, test_id: str, title: str, body: str) -> str: + return ( + f'
    ' + f"{title}{body}
    " + ) + + +def _disabled_attrs(settings: RuntimeSettings) -> str: + if not settings.ui.read_only: + return "" + title = escape(localize(settings.ui.locale, MessageKey.SETTINGS_READONLY_DISABLED_TITLE)) + return f' disabled title="{title}"' diff --git a/src/nfi_engine/ui/setup_secret.py b/src/nfi_engine/ui/setup_secret.py new file mode 100644 index 0000000..891f735 --- /dev/null +++ b/src/nfi_engine/ui/setup_secret.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from html import escape + +from nfi_engine.config import Locale +from nfi_engine.ui.i18n import localize +from nfi_engine.ui.i18n_keys import MessageKey + + +def render_secret_step( + *, + test_id: str, + label: str, + field_id: str, + name: str, + locale: Locale, +) -> str: + return f""" +
    + + + {localize(locale, MessageKey.SETUP_WRITE_ONLY)} +
    +""" diff --git a/src/nfi_engine/ui/setup_wallet.py b/src/nfi_engine/ui/setup_wallet.py new file mode 100644 index 0000000..eb5aa10 --- /dev/null +++ b/src/nfi_engine/ui/setup_wallet.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from nfi_engine.config import Locale +from nfi_engine.ui.i18n import localize +from nfi_engine.ui.i18n_keys import MessageKey + + +def render_wallet_step(*, locale: Locale) -> str: + return f""" +
    + +
    + {localize(locale, MessageKey.SETUP_WALLET_NOT_FETCHED)} +
    + +
    +""" diff --git a/src/nfi_engine/ui/setup_wizard.py b/src/nfi_engine/ui/setup_wizard.py new file mode 100644 index 0000000..3743146 --- /dev/null +++ b/src/nfi_engine/ui/setup_wizard.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from decimal import Decimal +from html import escape +from typing import Final + +from nfi_engine.config import Locale, RuntimeSettings +from nfi_engine.ui.i18n import localize +from nfi_engine.ui.i18n_keys import MessageKey +from nfi_engine.ui.setup_secret import render_secret_step +from nfi_engine.ui.setup_wallet import render_wallet_step + +RECOMMENDED_LEVERAGE: Final = "3x" +SETUP_OPTION_LABELS: Final[dict[str, MessageKey]] = { + "aggressive": MessageKey.SETUP_OPTION_AGGRESSIVE, + "balanced": MessageKey.SETUP_OPTION_BALANCED, + "conservative": MessageKey.SETUP_OPTION_CONSERVATIVE, + "disabled": MessageKey.SETUP_OPTION_DISABLED, + "enabled": MessageKey.SETUP_OPTION_ENABLED, + "expert": MessageKey.SETUP_OPTION_EXPERT, + "futures": MessageKey.SETUP_OPTION_FUTURES, + "live": MessageKey.SETUP_OPTION_LIVE, + "not_applicable": MessageKey.SETUP_OPTION_NOT_APPLICABLE, + "paper": MessageKey.SETUP_OPTION_PAPER, + "safe": MessageKey.SETUP_OPTION_SAFE, + "spot": MessageKey.SETUP_OPTION_SPOT, + "testnet": MessageKey.SETUP_OPTION_TESTNET, + "unknown": MessageKey.SETUP_OPTION_UNKNOWN, +} +PERMISSION_OPTIONS: Final = ("unknown", "enabled", "disabled", "not_applicable") + + +def render_setup_wizard(settings: RuntimeSettings, *, locale: Locale) -> str: + intent = "live" if settings.engine.live_trading else "paper" + amount = _decimal(settings.risk.stake_usdt) + steps = "\n".join( + ( + _exchange_step(settings, locale=locale), + render_secret_step( + test_id="setup-step-api-key", + label=localize(locale, MessageKey.SETUP_API_KEY), + field_id="setup-api-key", + name="api_key", + locale=locale, + ), + render_secret_step( + test_id="setup-step-api-secret", + label=localize(locale, MessageKey.SETUP_API_SECRET), + field_id="setup-api-secret", + name="api_secret", + locale=locale, + ), + _permission_step(settings, locale=locale), + _leverage_step(locale=locale), + _risk_profile_step(settings, locale=locale), + render_wallet_step(locale=locale), + _amount_step(amount, locale=locale), + _market_mode_step(settings, locale=locale), + _intent_step(intent, locale=locale), + ) + ) + return f""" +
    +

    {localize(locale, MessageKey.SETUP_TITLE)}

    +
    +{steps} +
    +
    + +
    +
    \
    +{localize(locale, MessageKey.SETUP_NO_PREVIEW)}
    +
    +""" + + +def _risk_profile_step(settings: RuntimeSettings, *, locale: Locale) -> str: + label = localize(locale, MessageKey.SETUP_RISK_PROFILE) + return f""" +
    + + + +
    +""" + + +def _permission_step(settings: RuntimeSettings, *, locale: Locale) -> str: + return f""" +
    + {localize(locale, MessageKey.SETUP_PERMISSION_AUDIT)} + { + _permission_select( + locale=locale, + field_id="setup-permission-read", + name="permission_read", + label=MessageKey.SETUP_PERMISSION_READ, + value=settings.exchange.permission_read.value, + ) + } + { + _permission_select( + locale=locale, + field_id="setup-permission-trade", + name="permission_trade", + label=MessageKey.SETUP_PERMISSION_TRADE, + value=settings.exchange.permission_trade.value, + ) + } + { + _permission_select( + locale=locale, + field_id="setup-permission-futures", + name="permission_futures", + label=MessageKey.SETUP_PERMISSION_FUTURES, + value=settings.exchange.permission_futures.value, + ) + } + { + _permission_select( + locale=locale, + field_id="setup-permission-withdrawal", + name="permission_withdrawal", + label=MessageKey.SETUP_PERMISSION_WITHDRAWAL, + value=settings.exchange.permission_withdrawal.value, + ) + } + { + _permission_select( + locale=locale, + field_id="setup-permission-ip-allowlist", + name="permission_ip_allowlist", + label=MessageKey.SETUP_PERMISSION_IP_ALLOWLIST, + value=settings.exchange.permission_ip_allowlist.value, + ) + } +
    +""" + + +def _permission_select( + *, + locale: Locale, + field_id: str, + name: str, + label: MessageKey, + value: str, +) -> str: + options = "\n".join( + _option(locale=locale, value=value, option=option) for option in PERMISSION_OPTIONS + ) + return ( + f'' + f'' + ) + + +def _leverage_step(*, locale: Locale) -> str: + return f""" +
    + + \ +{RECOMMENDED_LEVERAGE} + {localize(locale, MessageKey.SETUP_SAFETY_GATED)} +
    +""" + + +def _amount_step(amount: str, *, locale: Locale) -> str: + return f""" +
    + + + {localize(locale, MessageKey.SETUP_PREVIEW_ONLY)} +
    +""" + + +def _market_mode_step(settings: RuntimeSettings, *, locale: Locale) -> str: + value = settings.exchange.trading_mode.value + return f""" +
    + + + {localize(locale, MessageKey.SETTINGS_RELOAD_REQUIRED)} +
    +""" + + +def _intent_step(intent: str, *, locale: Locale) -> str: + return f""" +
    + + + {localize(locale, MessageKey.SETUP_LIVE_WARNING)} +
    +""" + + +def _exchange_step(settings: RuntimeSettings, *, locale: Locale) -> str: + return f""" +
    + + + {localize(locale, MessageKey.SETTINGS_RELOAD_REQUIRED)} +
    +""" + + +def _option(*, locale: Locale, value: str, option: str) -> str: + selected = " selected" if value == option else "" + label = localize(locale, SETUP_OPTION_LABELS[option]) + return f'' + + +def _decimal(value: Decimal) -> str: + rendered = format(value, "f") + return rendered.rstrip("0").rstrip(".") if "." in rendered else rendered diff --git a/src/nfi_engine/ui/x7_status.py b/src/nfi_engine/ui/x7_status.py new file mode 100644 index 0000000..a08b964 --- /dev/null +++ b/src/nfi_engine/ui/x7_status.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from html import escape + +from nfi_engine.config import Locale +from nfi_engine.strategy.nfi_x7 import X7SemanticStatus +from nfi_engine.ui.i18n import localize +from nfi_engine.ui.i18n_keys import MessageKey + + +def render_x7_semantic_status( + status: X7SemanticStatus | None, + *, + locale: Locale, +) -> str: + if status is None or not status.enabled: + return "" + rows = "\n".join( + _item(test_id, localize(locale, label), value) for test_id, label, value in _items(status) + ) + return ( + '
    \n' + f"

    {localize(locale, MessageKey.HOME_X7_TITLE)}

    \n" + f"

    {localize(locale, MessageKey.HOME_X7_DESCRIPTION)}

    \n" + '
    \n' + f" {rows}\n" + "
    \n" + f" {_blocked_reason(status, locale=locale)}\n" + f'
    {escape(status.next_action)}
    \n' + "
    \n" + ) + + +def _blocked_reason(status: X7SemanticStatus, *, locale: Locale) -> str: + if status.blocked_reason is None: + return "" + label = localize(locale, MessageKey.HOME_X7_BLOCKED_REASON) + return ( + '
    ' + f"{escape(label)} {escape(status.blocked_reason)}
    \n" + ) + + +def _item(test_id: str, label: str, value: str) -> str: + return ( + f'
    ' + f"{escape(label)}{escape(value)}
    " + ) + + +def _items(status: X7SemanticStatus) -> tuple[tuple[str, MessageKey, str], ...]: + return ( + ("x7-coverage", MessageKey.HOME_X7_COVERAGE, status.coverage_state.value), + ("x7-provenance", MessageKey.HOME_X7_PROVENANCE, status.observed_upstream_version), + ("x7-latest-signal", MessageKey.HOME_X7_LATEST_SIGNAL, status.latest_signal_reason), + ("x7-warmup", MessageKey.HOME_X7_WARMUP, status.warmup_state), + ("x7-missing-data", MessageKey.HOME_X7_MISSING_DATA, status.missing_data_state), + ("x7-live-readiness", MessageKey.HOME_X7_LIVE_READINESS, status.live_readiness.value), + ) diff --git a/src/nfi_engine/wallet/__init__.py b/src/nfi_engine/wallet/__init__.py new file mode 100644 index 0000000..7194a93 --- /dev/null +++ b/src/nfi_engine/wallet/__init__.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from nfi_engine.wallet.models import ( + WalletBalanceCode, + WalletBalanceSnapshot, + WalletBalanceStatus, + WalletPermissionAuditSnapshot, +) +from nfi_engine.wallet.service import WalletBalanceReader, fetch_wallet_balance + +__all__ = [ + "WalletBalanceCode", + "WalletBalanceReader", + "WalletBalanceSnapshot", + "WalletBalanceStatus", + "WalletPermissionAuditSnapshot", + "fetch_wallet_balance", +] diff --git a/src/nfi_engine/wallet/models.py b/src/nfi_engine/wallet/models.py new file mode 100644 index 0000000..837be02 --- /dev/null +++ b/src/nfi_engine/wallet/models.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal +from enum import StrEnum, unique + +from nfi_engine.config import RuntimeSettings +from nfi_engine.domain import AccountSnapshot +from nfi_engine.exchange.permissions import ( + ExchangeApiPermissionState, + audit_exchange_api_permissions, +) + + +@unique +class WalletBalanceStatus(StrEnum): + FETCHED = "fetched" + BLOCKED = "blocked" + UNAVAILABLE = "unavailable" + ERROR = "error" + + +@unique +class WalletBalanceCode(StrEnum): + FETCHED = "WALLET_BALANCE_FETCHED" + MISSING_CREDENTIALS = "WALLET_BALANCE_MISSING_CREDENTIALS" + UNSAFE_PERMISSION = "WALLET_BALANCE_UNSAFE_PERMISSION" + ADAPTER_UNAVAILABLE = "WALLET_BALANCE_ADAPTER_UNAVAILABLE" + TIMEOUT = "WALLET_BALANCE_TIMEOUT" + EXCHANGE_ERROR = "WALLET_BALANCE_EXCHANGE_ERROR" + + +@dataclass(frozen=True, slots=True) +class WalletPermissionAuditSnapshot: + read: ExchangeApiPermissionState + trade: ExchangeApiPermissionState + futures: ExchangeApiPermissionState + withdrawal: ExchangeApiPermissionState + ip_allowlist: ExchangeApiPermissionState + live_safe: bool + live_blocking_codes: tuple[str, ...] + diagnostic_codes: tuple[str, ...] + summary: str + + @classmethod + def from_settings(cls, settings: RuntimeSettings) -> WalletPermissionAuditSnapshot: + audit = audit_exchange_api_permissions( + read=settings.exchange.permission_read, + trade=settings.exchange.permission_trade, + futures=settings.exchange.permission_futures, + withdrawal=settings.exchange.permission_withdrawal, + ip_allowlist=settings.exchange.permission_ip_allowlist, + ) + return cls( + read=audit.read, + trade=audit.trade, + futures=audit.futures, + withdrawal=audit.withdrawal, + ip_allowlist=audit.ip_allowlist, + live_safe=audit.live_safe, + live_blocking_codes=audit.live_blocking_codes, + diagnostic_codes=audit.diagnostic_codes, + summary=audit.summary, + ) + + @classmethod + def unknown(cls) -> WalletPermissionAuditSnapshot: + return cls( + read=ExchangeApiPermissionState.UNKNOWN, + trade=ExchangeApiPermissionState.UNKNOWN, + futures=ExchangeApiPermissionState.UNKNOWN, + withdrawal=ExchangeApiPermissionState.UNKNOWN, + ip_allowlist=ExchangeApiPermissionState.UNKNOWN, + live_safe=True, + live_blocking_codes=(), + diagnostic_codes=("EXCHANGE_PERMISSION_WITHDRAWAL_UNKNOWN",), + summary=( + "read=unknown trade=unknown futures=unknown withdrawal=unknown ip_allowlist=unknown" + ), + ) + + +@dataclass(frozen=True, slots=True) +class WalletBalanceSnapshot: + status: WalletBalanceStatus + code: WalletBalanceCode + exchange: str + trading_mode: str + captured_at: datetime | None + equity: Decimal | None + available: Decimal | None + quote_asset: str + position_count: int + next_action: str + message: str + allocation_cap_pct: Decimal = Decimal(0) + allocation_cap: Decimal | None = None + configured_stake_usdt: Decimal = Decimal(0) + configured_max_open_trades: int = 0 + configured_allocation_total: Decimal = Decimal(0) + allocation_cap_exceeded: bool | None = None + permission_audit: WalletPermissionAuditSnapshot = field( + default_factory=WalletPermissionAuditSnapshot.unknown, + ) + + @classmethod + def from_account( + cls, + *, + settings: RuntimeSettings, + account: AccountSnapshot, + ) -> WalletBalanceSnapshot: + available = Decimal(str(account.available)) + allocation_cap = _allocation_cap(settings=settings, available=available) + configured_total = _configured_allocation_total(settings) + return cls( + status=WalletBalanceStatus.FETCHED, + code=WalletBalanceCode.FETCHED, + exchange=settings.exchange.name, + trading_mode=settings.exchange.trading_mode.value, + captured_at=account.captured_at, + equity=Decimal(str(account.equity)), + available=available, + quote_asset=settings.pairlist.quote_asset, + position_count=len(account.positions), + next_action="Review allocation amount before enabling a run.", + message="Wallet balance fetched through the exchange adapter boundary.", + allocation_cap_pct=settings.risk.allocation_cap_pct, + allocation_cap=allocation_cap, + configured_stake_usdt=settings.risk.stake_usdt, + configured_max_open_trades=settings.risk.max_open_trades, + configured_allocation_total=configured_total, + allocation_cap_exceeded=_allocation_cap_exceeded( + allocation_cap=allocation_cap, + configured_total=configured_total, + ), + permission_audit=WalletPermissionAuditSnapshot.from_settings(settings), + ) + + @classmethod + def diagnostic( + cls, + *, + settings: RuntimeSettings, + status: WalletBalanceStatus, + code: WalletBalanceCode, + next_action: str, + message: str, + ) -> WalletBalanceSnapshot: + return cls( + status=status, + code=code, + exchange=settings.exchange.name, + trading_mode=settings.exchange.trading_mode.value, + captured_at=None, + equity=None, + available=None, + quote_asset=settings.pairlist.quote_asset, + position_count=0, + next_action=next_action, + message=message, + allocation_cap_pct=settings.risk.allocation_cap_pct, + configured_stake_usdt=settings.risk.stake_usdt, + configured_max_open_trades=settings.risk.max_open_trades, + configured_allocation_total=_configured_allocation_total(settings), + permission_audit=WalletPermissionAuditSnapshot.from_settings(settings), + ) + + +def _allocation_cap(*, settings: RuntimeSettings, available: Decimal) -> Decimal: + return available * settings.risk.allocation_cap_pct + + +def _configured_allocation_total(settings: RuntimeSettings) -> Decimal: + return settings.risk.stake_usdt * Decimal(settings.risk.max_open_trades) + + +def _allocation_cap_exceeded( + *, + allocation_cap: Decimal | None, + configured_total: Decimal, +) -> bool | None: + if allocation_cap is None: + return None + return configured_total > allocation_cap diff --git a/src/nfi_engine/wallet/service.py b/src/nfi_engine/wallet/service.py new file mode 100644 index 0000000..fe87219 --- /dev/null +++ b/src/nfi_engine/wallet/service.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from collections.abc import Mapping +from datetime import UTC, datetime +from decimal import Decimal +from types import MappingProxyType +from typing import Final, Protocol + +import anyio + +from nfi_engine.config import RuntimeSettings +from nfi_engine.domain import AccountSnapshot, DomainError, Price, TradingMode, TradingPair +from nfi_engine.exchange import get_exchange_profile +from nfi_engine.exchange.binance import BinanceFuturesBalanceAdapter +from nfi_engine.exchange.errors import ExchangeError +from nfi_engine.exchange.models import Tick +from nfi_engine.exchange.simulator import DeterministicExchangeSimulator +from nfi_engine.wallet.models import ( + WalletBalanceCode, + WalletBalanceSnapshot, + WalletBalanceStatus, + WalletPermissionAuditSnapshot, +) + +DEFAULT_TIMEOUT_SECONDS: Final = 2.0 + + +class WalletBalanceReader(Protocol): + async def fetch_balance(self) -> AccountSnapshot: ... + + +class WalletBalanceReaderFactory(Protocol): + def __call__( + self, + settings: RuntimeSettings, + now: datetime | None, + ) -> WalletBalanceReader | None: ... + + +async def fetch_wallet_balance( + *, + settings: RuntimeSettings, + reader: WalletBalanceReader | None = None, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + now: datetime | None = None, +) -> WalletBalanceSnapshot: + blocked = _blocked_diagnostic(settings) + if blocked is not None: + return blocked + + balance_reader = reader + if balance_reader is None: + balance_reader = _default_reader(settings=settings, now=now) + if balance_reader is None: + return WalletBalanceSnapshot.diagnostic( + settings=settings, + status=WalletBalanceStatus.UNAVAILABLE, + code=WalletBalanceCode.ADAPTER_UNAVAILABLE, + next_action="Install or inject a read-only exchange adapter before fetching balance.", + message="No wallet balance adapter is available for this exchange profile.", + ) + + account: AccountSnapshot | None = None + try: + with anyio.move_on_after(timeout_seconds) as cancel_scope: + account = await balance_reader.fetch_balance() + except ExchangeError as exc: + return WalletBalanceSnapshot.diagnostic( + settings=settings, + status=WalletBalanceStatus.ERROR, + code=WalletBalanceCode.EXCHANGE_ERROR, + next_action="Open Logs and inspect the exchange adapter diagnostic code.", + message=exc.code.value, + ) + if cancel_scope.cancel_called or account is None: + return WalletBalanceSnapshot.diagnostic( + settings=settings, + status=WalletBalanceStatus.ERROR, + code=WalletBalanceCode.TIMEOUT, + next_action="Retry after checking exchange latency or reduce adapter timeout.", + message="Wallet balance fetch timed out.", + ) + return WalletBalanceSnapshot.from_account(settings=settings, account=account) + + +def _blocked_diagnostic(settings: RuntimeSettings) -> WalletBalanceSnapshot | None: + audit = WalletPermissionAuditSnapshot.from_settings(settings) + if settings.engine.live_trading and not audit.live_safe: + return WalletBalanceSnapshot.diagnostic( + settings=settings, + status=WalletBalanceStatus.BLOCKED, + code=WalletBalanceCode.UNSAFE_PERMISSION, + next_action="Disable withdrawal permission before any live-intent wallet check.", + message="Exchange API permissions are not safe for live-intent operation.", + ) + if _requires_credentials(settings) and _missing_credentials(settings): + return WalletBalanceSnapshot.diagnostic( + settings=settings, + status=WalletBalanceStatus.BLOCKED, + code=WalletBalanceCode.MISSING_CREDENTIALS, + next_action="Add read-only exchange API key and secret in Settings setup.", + message="Wallet balance fetch needs exchange credentials.", + ) + return None + + +def _default_reader( + *, + settings: RuntimeSettings, + now: datetime | None, +) -> WalletBalanceReader | None: + profile = get_exchange_profile(settings.exchange.name) + if profile is None: + return None + factory = WALLET_READER_FACTORIES.get(profile.exchange_id) + if factory is None: + return None + return factory(settings, now) + + +def _simulator_reader(settings: RuntimeSettings, now: datetime | None) -> WalletBalanceReader: + at = now if now is not None else datetime.now(UTC) + return DeterministicExchangeSimulator( + ticks=( + Tick( + pair=_first_pair(settings), + price=Price(Decimal(1)), + at=at, + ), + ), + ) + + +def _binance_reader( + settings: RuntimeSettings, + now: datetime | None, +) -> WalletBalanceReader | None: + del now + match settings.exchange.trading_mode: + case TradingMode.FUTURES: + return BinanceFuturesBalanceAdapter.from_settings(settings=settings) + case TradingMode.SPOT: + return None + + +WALLET_READER_FACTORIES: Final[Mapping[str, WalletBalanceReaderFactory]] = MappingProxyType( + { + "simulator": _simulator_reader, + "binance": _binance_reader, + }, +) + + +def _first_pair(settings: RuntimeSettings) -> TradingPair: + for raw_pair in settings.pairlist.whitelist.split(","): + pair_text = raw_pair.strip() + if pair_text == "": + continue + try: + return TradingPair.parse(pair_text, settings.exchange.trading_mode) + except DomainError: + continue + fallback = "BTC/USDT:USDT" if settings.exchange.trading_mode.value == "futures" else "BTC/USDT" + return TradingPair.parse(fallback, settings.exchange.trading_mode) + + +def _requires_credentials(settings: RuntimeSettings) -> bool: + profile = get_exchange_profile(settings.exchange.name) + if profile is None: + return True + return bool(profile.credential_fields) + + +def _missing_credentials(settings: RuntimeSettings) -> bool: + return settings.exchange.api_key is None or settings.exchange.api_secret is None diff --git a/tests/AGENTS.md b/tests/AGENTS.md new file mode 100644 index 0000000..6e953cb --- /dev/null +++ b/tests/AGENTS.md @@ -0,0 +1,47 @@ +# TEST GUIDE + +## OVERVIEW + +`tests` verifies unit services, integration boundaries, e2e surfaces, fixtures, +docs contracts, Docker/install behavior, UI security, and evidence tooling. + +## STRUCTURE + +```text +tests/ +|-- unit/ # pure service/domain/UI/docs/tool contracts +|-- integration/ # persistence, exchange, notification boundaries +|-- e2e/ # CLI/API/UI/install/Docker/runtime flows +`-- fixtures/ # canonical configs, data, strategies, DBs, scenarios +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +| --- | --- | --- | +| Service behavior | `tests/unit//test_*.py` | Narrow, deterministic, no runtime services. | +| Boundary behavior | `tests/integration//` | Fake clients, temp DBs, async repos. | +| User surface | `tests/e2e/test_*.py` | CLI, API, UI, install, Docker, scripts. | +| Strategy fixtures | `tests/fixtures/strategies/` | Clean-room NFI-shaped and unsafe fixtures. | +| Config fixtures | `tests/fixtures/config/` | Safety, live-block, preflight, secret scenarios. | +| Evidence verifier | `tests/unit/tools/test_plan_evidence.py` | `.omo/evidence` path contract. | + +## CONVENTIONS + +- Filenames follow `test_.py`; functions should name behavior and condition. +- Use Given/When/Then comments where they clarify scenario setup and expected safety behavior. +- Async tests use `pytest.mark.anyio`; persistence/UI async suites may pin `anyio_backend` to `asyncio`. +- Fixtures are canonical inputs. Add them under the domain folder that owns the scenario. +- Tests that prove redaction should assert both the safe replacement and absence of the original secret. +- E2E tests should drive the matching surface: subprocess CLI, FastAPI client, + rendered HTML, scripts, Docker config, or filesystem artifact. +- User-visible or hot-path changes still need manual evidence in `.omo/evidence/` when docs/contributing requires it. + +## ANTI-PATTERNS + +- Do not weaken assertions or delete failing tests to make a gate pass. +- Do not use live exchange connectivity, real credentials, network-only data, or public services in tests. +- Do not leave secrets, generated tokens, screenshots, support bundles, or + runtime DBs outside temp paths or `.omo/evidence/`. +- Do not duplicate large fixtures per test when a shared canonical fixture fits. +- Do not hide warnings; pytest treats warnings as errors by project policy. diff --git a/tests/e2e/test_backtest_cli.py b/tests/e2e/test_backtest_cli.py index 80ca4b1..b529a9e 100644 --- a/tests/e2e/test_backtest_cli.py +++ b/tests/e2e/test_backtest_cli.py @@ -35,6 +35,8 @@ def test_backtest_cli_writes_json_summary_when_spot_config_is_valid(tmp_path: Pa assert '"summary"' in output assert '"config_digest"' in output assert '"strategy"' in output + assert '"timeline"' in output + assert '"payload_bytes"' in output assert '"simulator"' not in output diff --git a/tests/e2e/test_backup_archive_safety.py b/tests/e2e/test_backup_archive_safety.py new file mode 100644 index 0000000..12a9073 --- /dev/null +++ b/tests/e2e/test_backup_archive_safety.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path +from typing import Final +from zipfile import ZIP_DEFLATED, ZipFile + +PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] + + +def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def _write_traversal_archive(archive_path: Path) -> None: + payload = b"unsafe" + manifest = { + "engine_version": "test", + "generated_at": "2026-06-17T00:00:00+00:00", + "redacted": True, + "config_hash": "", + "dependency_lock_hash": "", + "files": ["../../outside.txt"], + "checksums": {"../../outside.txt": hashlib.sha256(payload).hexdigest()}, + } + with ZipFile(archive_path, mode="w", compression=ZIP_DEFLATED) as archive: + archive.writestr("manifest.json", json.dumps(manifest).encode()) + archive.writestr("../../outside.txt", payload) + + +def _write_manifest_only_archive(archive_path: Path) -> None: + files: tuple[str, ...] = () + checksums: dict[str, str] = {} + manifest = { + "engine_version": "test", + "generated_at": "2026-06-17T00:00:00+00:00", + "redacted": True, + "config_hash": "", + "dependency_lock_hash": "", + "files": files, + "checksums": checksums, + } + with ZipFile(archive_path, mode="w", compression=ZIP_DEFLATED) as archive: + archive.writestr("manifest.json", json.dumps(manifest).encode()) + + +def _tamper_config_member(archive_path: Path) -> None: + with ZipFile(archive_path) as archive: + members = tuple( + (name, b"{}" if name == "config.json" else archive.read(name)) + for name in archive.namelist() + ) + with ZipFile(archive_path, mode="w", compression=ZIP_DEFLATED) as archive: + for name, data in members: + archive.writestr(name, data) + + +def test_backup_verify_and_restore_reject_traversal_archive_members(tmp_path: Path) -> None: + # Given: a crafted archive with a traversal member in its manifest. + archive_path = tmp_path / "traversal.zip" + _write_traversal_archive(archive_path) + + # When: the operator asks the real CLI to verify or rehearse restore. + verify = _run_command(["uv", "run", "nfi-engine", "backup", "verify", str(archive_path)]) + restore = _run_command( + ["uv", "run", "nfi-engine", "backup", "restore", "--dry-run", str(archive_path)], + ) + + # Then: both surfaces fail closed without printing a restore step for the unsafe member. + assert verify.returncode != 0 + assert restore.returncode != 0 + assert verify.stderr.startswith("BACKUP_INVALID: ") + assert restore.stderr.startswith("BACKUP_INVALID: ") + assert "../../outside.txt" not in verify.stdout + assert "step=restore" not in restore.stdout + + +def test_backup_verify_and_restore_reject_incomplete_manifest_only_archive( + tmp_path: Path, +) -> None: + # Given: an allowlisted archive containing only manifest metadata. + archive_path = tmp_path / "manifest-only.zip" + _write_manifest_only_archive(archive_path) + + # When: the operator asks the real CLI to verify or rehearse restore. + verify = _run_command(["uv", "run", "nfi-engine", "backup", "verify", str(archive_path)]) + restore = _run_command( + ["uv", "run", "nfi-engine", "backup", "restore", "--dry-run", str(archive_path)], + ) + + # Then: both surfaces fail closed without printing a valid restore plan. + assert verify.returncode != 0 + assert restore.returncode != 0 + assert verify.stderr.startswith("BACKUP_INVALID: ") + assert restore.stderr.startswith("BACKUP_INVALID: ") + assert "manifest_valid=true" not in verify.stdout + assert "restore_plan=backup" not in restore.stdout + + +def test_backup_restore_rejects_tampered_allowlisted_archive_members(tmp_path: Path) -> None: + # Given: a normal backup archive whose allowlisted config member is tampered afterward. + archive_path = tmp_path / "backup.zip" + create = _run_command( + [ + "uv", + "run", + "nfi-engine", + "backup", + "create", + "--config", + "examples/futures-paper.yaml", + "--output", + str(archive_path), + ], + ) + _tamper_config_member(archive_path) + + # When: verify and restore dry-run inspect the checksum-invalid archive. + verify = _run_command(["uv", "run", "nfi-engine", "backup", "verify", str(archive_path)]) + restore = _run_command( + ["uv", "run", "nfi-engine", "backup", "restore", "--dry-run", str(archive_path)], + ) + + # Then: verify reports the mismatch and restore fails closed before printing steps. + assert create.returncode == 0, create.stderr + assert verify.returncode == 0, verify.stderr + assert "manifest_valid=false" in verify.stdout + assert restore.returncode != 0 + assert restore.stderr.startswith("BACKUP_INVALID: ") + assert "step=restore" not in restore.stdout diff --git a/tests/e2e/test_backup_restore_rehearsal.py b/tests/e2e/test_backup_restore_rehearsal.py new file mode 100644 index 0000000..8c4eee2 --- /dev/null +++ b/tests/e2e/test_backup_restore_rehearsal.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Literal, assert_never +from zipfile import ZipFile + +import pytest + +PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] +REHEARSAL_PROJECT: Final = "nfi-engine-rehearsal" +PathCase = Literal["unmarked-runtime", "unsafe-home"] + + +@dataclass(frozen=True, slots=True) +class RehearsalArtifacts: + runtime_dir: Path + runtime_marker: Path + token_file: Path + protected_file: Path + archive_path: Path + + +def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def _create_rehearsal_artifacts(tmp_path: Path) -> RehearsalArtifacts: + runtime_dir = tmp_path / "runtime" + config_dir = runtime_dir / "config" + config_dir.mkdir(parents=True) + runtime_marker = runtime_dir / ".nfi-engine-runtime" + runtime_marker.write_text("nfi-engine-runtime=1\n", encoding="utf-8") + token_file = runtime_dir / "docker.env" + token_file.write_text("NFI_ENGINE_API_TOKEN=rehearsal-token\n", encoding="utf-8") + protected_file = config_dir / "operator-marker.txt" + protected_file.write_text("rehearsal-marker\n", encoding="utf-8") + archive_path = tmp_path / "backup-rehearsal.zip" + return RehearsalArtifacts( + runtime_dir=runtime_dir, + runtime_marker=runtime_marker, + token_file=token_file, + protected_file=protected_file, + archive_path=archive_path, + ) + + +def _rehearsal_commands( + artifacts: RehearsalArtifacts, +) -> dict[str, list[str]]: + return { + "create": [ + "uv", + "run", + "nfi-engine", + "backup", + "create", + "--config", + "examples/futures-paper.yaml", + "--output", + str(artifacts.archive_path), + ], + "verify": ["uv", "run", "nfi-engine", "backup", "verify", str(artifacts.archive_path)], + "restore": [ + "uv", + "run", + "nfi-engine", + "backup", + "restore", + "--dry-run", + str(artifacts.archive_path), + ], + "restore_apply": [ + "uv", + "run", + "nfi-engine", + "backup", + "restore", + "--apply", + str(artifacts.archive_path), + ], + "safe_uninstall": [ + "bash", + "scripts/uninstall.sh", + "--yes", + "--runtime-dir", + str(artifacts.runtime_dir), + "--dry-run", + "--project-name", + REHEARSAL_PROJECT, + ], + "purge_uninstall": [ + "bash", + "scripts/uninstall.sh", + "--purge", + "--yes", + "--runtime-dir", + str(artifacts.runtime_dir), + "--dry-run", + "--project-name", + REHEARSAL_PROJECT, + ], + } + + +def _archive_payload(archive_path: Path) -> tuple[set[str], bytes]: + with ZipFile(archive_path) as archive: + archive_names = set(archive.namelist()) + archive_payload = b"".join(archive.read(name) for name in sorted(archive_names)) + return archive_names, archive_payload + + +def _assert_rehearsal_outputs( + results: dict[str, subprocess.CompletedProcess[str]], + artifacts: RehearsalArtifacts, + archive_names: set[str], + archive_payload: bytes, +) -> None: + create = results["create"] + verify = results["verify"] + restore = results["restore"] + restore_apply = results["restore_apply"] + safe_uninstall = results["safe_uninstall"] + purge_uninstall = results["purge_uninstall"] + + assert create.returncode == 0, create.stderr + assert verify.returncode == 0, verify.stderr + assert restore.returncode == 0, restore.stderr + assert restore_apply.returncode != 0 + assert safe_uninstall.returncode == 0, safe_uninstall.stderr + assert purge_uninstall.returncode == 0, purge_uninstall.stderr + + assert "backup_created=true" in create.stdout + assert "manifest_valid=true" in create.stdout + assert "redacted=true" in create.stdout + assert "manifest_valid=true" in verify.stdout + assert "redacted=true" in verify.stdout + assert "restore_plan=backup" in restore.stdout + assert "apply=false" in restore.stdout + assert "manifest_valid=true" in restore.stdout + assert "step=restore config.json" in restore.stdout + assert restore_apply.stderr.startswith("BACKUP_RESTORE_APPLY_UNSUPPORTED: ") + assert "restore_plan=backup" not in restore_apply.stdout + + assert "mode=safe" in safe_uninstall.stdout + assert f"compose_project={REHEARSAL_PROJECT}" in safe_uninstall.stdout + assert f"preserve_runtime={artifacts.runtime_dir}" in safe_uninstall.stdout + assert "remove_runtime=" not in safe_uninstall.stdout + assert "uninstall_plan=dry-run" in safe_uninstall.stdout + + assert "mode=purge" in purge_uninstall.stdout + assert f"compose_project={REHEARSAL_PROJECT}" in purge_uninstall.stdout + assert f"remove_runtime={artifacts.runtime_dir}" in purge_uninstall.stdout + assert "backup_runtime=not_requested" in purge_uninstall.stdout + assert "uninstall_plan=dry-run" in purge_uninstall.stdout + + assert {"config.json", "logs.json", "manifest.json"} <= archive_names + assert b"rehearsal-token" not in archive_payload + assert artifacts.runtime_marker.read_text(encoding="utf-8") == "nfi-engine-runtime=1\n" + assert ( + artifacts.token_file.read_text(encoding="utf-8") == "NFI_ENGINE_API_TOKEN=rehearsal-token\n" + ) + assert artifacts.protected_file.read_text(encoding="utf-8") == "rehearsal-marker\n" + + +def test_backup_restore_and_uninstall_dry_run_rehearsal_preserves_runtime_artifacts( + tmp_path: Path, +) -> None: + # Given: a marker-protected runtime directory with a generated token file. + artifacts = _create_rehearsal_artifacts(tmp_path) + + # When: the full backup, restore rehearsal, and uninstall dry-run sequence is executed. + results = { + name: _run_command(command) for name, command in _rehearsal_commands(artifacts).items() + } + archive_names, archive_payload = _archive_payload(artifacts.archive_path) + + # Then: each CLI surface reports the rehearsal plan while runtime marker and token stay intact. + _assert_rehearsal_outputs(results, artifacts, archive_names, archive_payload) + + +@pytest.mark.parametrize( + ("path_case", "expected_code"), + [ + pytest.param("unmarked-runtime", "UNINSTALL_RUNTIME_MARKER_MISSING", id="unmarked-runtime"), + pytest.param("unsafe-home", "UNINSTALL_UNSAFE_RUNTIME_DIR", id="unsafe-home"), + ], +) +def test_backup_restore_rehearsal_purge_dry_run_refuses_unsafe_runtime_paths( + tmp_path: Path, + path_case: PathCase, + expected_code: str, +) -> None: + # Given: a purge dry-run targeting an unmarked or intrinsically unsafe runtime path. + match path_case: + case "unmarked-runtime": + runtime_dir = tmp_path / "operator-files" + runtime_dir.mkdir() + case "unsafe-home": + runtime_dir = Path.home() + case unreachable: + assert_never(unreachable) + + command: Final = [ + "bash", + "scripts/uninstall.sh", + "--purge", + "--yes", + "--runtime-dir", + str(runtime_dir), + "--dry-run", + "--project-name", + REHEARSAL_PROJECT, + ] + + # When: the destructive purge rehearsal command runs. + result = _run_command(command) + + # Then: the command refuses before printing any removal scope and keeps stderr machine-readable. + assert result.returncode != 0 + assert result.stderr == f"{expected_code}: {runtime_dir}\n" + assert "remove_runtime=" not in result.stdout diff --git a/tests/e2e/test_benchmark_cli.py b/tests/e2e/test_benchmark_cli.py index 6b2ad6c..24fad6b 100644 --- a/tests/e2e/test_benchmark_cli.py +++ b/tests/e2e/test_benchmark_cli.py @@ -41,6 +41,11 @@ def test_benchmark_m2_command_writes_valid_json_report(tmp_path: Path) -> None: assert '"dashboard_snapshot_latency"' in content assert '"home_render_smoke"' in content assert '"chart_render_smoke"' in content + assert '"backtest_720_candle_latency"' in content + assert '"x7_strategy_inspect_latency"' in content + assert '"x7_feature_graph_latency"' in content + assert '"x7_backtest_sample_latency"' in content + assert '"x7_paper_sample_latency"' in content assert '"startup_smoke"' in content assert '"install_smoke"' in content assert '"freqtrade_available": false' in content diff --git a/tests/e2e/test_binance_wallet_api.py b/tests/e2e/test_binance_wallet_api.py new file mode 100644 index 0000000..e9d7a00 --- /dev/null +++ b/tests/e2e/test_binance_wallet_api.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from decimal import Decimal +from typing import TYPE_CHECKING + +import pytest +from httpx import ASGITransport, AsyncClient + +from nfi_engine.api.app import create_app +from nfi_engine.api.wallet_models import WalletBalanceResponse +from nfi_engine.config.models import RuntimeSettings +from nfi_engine.domain import AccountSnapshot, StakeAmount + +if TYPE_CHECKING: + from fastapi import FastAPI + +pytestmark = pytest.mark.anyio +NOW = datetime(2026, 6, 18, tzinfo=UTC) + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +async def test_wallet_balance_api_uses_configured_binance_reader_without_secret_surface() -> None: + # Given: a Binance futures config with exchange credentials and an injected read-only reader. + settings = RuntimeSettings.model_validate( + { + "exchange": { + "name": "binance", + "trading_mode": "futures", + "margin_mode": "isolated", + "testnet": False, + "api_key": "api-key-must-not-leak", + "api_secret": "api-secret-must-not-leak", + "permission_withdrawal": "disabled", + }, + "risk": {"allocation_cap_pct": "0.20", "stake_usdt": "25", "max_open_trades": 2}, + }, + ) + app = create_app(settings=settings, wallet_balance_reader=FakeBalanceReader()) + async with _client(app) as client: + # When: the operator explicitly fetches wallet balance from the API surface. + response = await client.post("/api/v1/wallet/balance/fetch") + + # Then: the route returns the normalized balance and never exposes credentials. + payload = WalletBalanceResponse.model_validate_json(response.content) + serialized = response.text + assert response.status_code == 200 + assert payload.status == "fetched" + assert payload.exchange == "binance" + assert payload.trading_mode == "futures" + assert payload.equity == "210.5" + assert payload.available == "200.25" + assert payload.allocation_cap_pct == "0.20" + assert payload.allocation_cap == "40.0500" + assert payload.configured_allocation_total == "50" + assert payload.allocation_cap_exceeded is True + assert payload.permission_audit.withdrawal == "disabled" + assert payload.permission_audit.live_safe is True + assert "api-key-must-not-leak" not in serialized + assert "api-secret-must-not-leak" not in serialized + assert "api_key" not in serialized + assert "api_secret" not in serialized + + +@dataclass(frozen=True, slots=True) +class FakeBalanceReader: + async def fetch_balance(self) -> AccountSnapshot: + return AccountSnapshot( + captured_at=NOW, + equity=StakeAmount(Decimal("210.5")), + available=StakeAmount(Decimal("200.25")), + positions=(), + ) + + +def _client(app: FastAPI) -> AsyncClient: + return AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") diff --git a/tests/e2e/test_cli_surface.py b/tests/e2e/test_cli_surface.py index 0d1cf98..db56871 100644 --- a/tests/e2e/test_cli_surface.py +++ b/tests/e2e/test_cli_surface.py @@ -3,11 +3,26 @@ import subprocess import sys from pathlib import Path -from typing import Final +from typing import ClassVar, Final + +from pydantic import BaseModel, ConfigDict PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] +class _ExchangeCapabilitiesCliPayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + exchange_id: str + source: str + support_level: str + trading_mode: str + can_configure: bool + live_trading_allowed: bool + policy_block: str + credential_fields: list[str] + + def test_cli_help_lists_operator_commands() -> None: # Given command: Final = ["uv", "run", "nfi-engine", "--help"] @@ -126,3 +141,115 @@ def test_cli_paper_run_reports_no_live_orders() -> None: assert result.returncode == 0, result.stderr assert "processed_events=3" in result.stdout assert "live_orders=false" in result.stdout + + +def _run_exchange_capabilities_json(*, exchange: str, trading_mode: str) -> str: + command = [ + "uv", + "run", + "nfi-engine", + "exchange", + "capabilities", + "--exchange", + exchange, + "--trading-mode", + trading_mode, + "--format", + "json", + ] + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stderr + validation = subprocess.run( + [sys.executable, "-m", "json.tool"], + cwd=PROJECT_ROOT, + input=result.stdout, + capture_output=True, + text=True, + check=False, + ) + assert validation.returncode == 0, validation.stderr + return result.stdout.strip() + + +def _exchange_capabilities_payload( + *, + exchange: str, + trading_mode: str, +) -> _ExchangeCapabilitiesCliPayload: + return _ExchangeCapabilitiesCliPayload.model_validate_json( + _run_exchange_capabilities_json(exchange=exchange, trading_mode=trading_mode), + ) + + +def test_cli_exchange_capabilities_bybit_returns_verified_testnet_json() -> None: + payload = _exchange_capabilities_payload(exchange="bybit", trading_mode="futures") + + assert payload.exchange_id == "bybit" + assert payload.trading_mode == "futures" + assert payload.support_level == "verified" + assert payload.can_configure is True + assert payload.live_trading_allowed is False + assert payload.policy_block == "live trading is blocked in current milestone" + + +def test_cli_exchange_capabilities_okx_remains_candidate_json() -> None: + payload = _exchange_capabilities_payload(exchange="okx", trading_mode="futures") + + assert payload.exchange_id == "okx" + assert payload.trading_mode == "futures" + assert payload.support_level == "candidate" + assert payload.can_configure is True + assert payload.live_trading_allowed is False + + +def test_cli_exchange_capabilities_mexc_is_report_only_generic_unverified() -> None: + payload = _exchange_capabilities_payload(exchange="mexc", trading_mode="futures") + assert payload.exchange_id == "mexc" + assert payload.source == "generic-discovery" + assert payload.support_level == "generic-unverified" + assert payload.can_configure is False + assert payload.live_trading_allowed is False + assert "evidence" in payload.policy_block.lower() + assert payload.credential_fields == [] + + +def test_cli_exchange_capabilities_mexc_json_does_not_promote_to_config_execution( + tmp_path: Path, +) -> None: + temporary_config = """ +exchange: + name: mexc + trading_mode: futures + margin_mode: isolated + testnet: true +""" + config_path = tmp_path / "mexc-futures.yaml" + config_path.write_text(temporary_config.strip() + "\n", encoding="utf-8") + command = [ + "uv", + "run", + "nfi-engine", + "config", + "validate", + "--config", + str(config_path), + ] + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + assert result.returncode == 1 + assert "EXCHANGE_UNSUPPORTED" in result.stderr + + +def test_cli_exchange_check_rejects_unsafe_exchange_id_before_stdout() -> None: + command = [ + "uv", + "run", + "nfi-engine", + "exchange", + "check", + "--exchange", + "bad\nline", + ] + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + assert result.returncode == 1 + assert result.stdout == "" + assert "EXCHANGE_ID_INVALID" in result.stderr diff --git a/tests/e2e/test_docker_files.py b/tests/e2e/test_docker_files.py index c673434..9df8806 100644 --- a/tests/e2e/test_docker_files.py +++ b/tests/e2e/test_docker_files.py @@ -53,12 +53,12 @@ def test_compose_uses_local_port_healthcheck_and_named_volumes() -> None: assert "api:" in compose assert "cli:" in compose assert "paper:" in compose - assert '"127.0.0.1:18080:18080"' in compose + assert '"127.0.0.1:${NFI_ENGINE_HOST_PORT:-18080}:18080"' in compose assert "healthcheck:" in compose assert "nfi-data:" in compose assert "nfi-logs:" in compose assert "/config/futures-paper.yaml" in compose - assert "./.runtime/config:/config:ro" in compose + assert "${NFI_ENGINE_RUNTIME_CONFIG_DIR:-./.runtime/config}:/config:ro" in compose assert "nfi-config:" not in compose assert "examples/docker.env.example" in compose assert ".runtime/docker.env" in compose diff --git a/tests/e2e/test_exchange_lifecycle_cli.py b/tests/e2e/test_exchange_lifecycle_cli.py new file mode 100644 index 0000000..e691f0b --- /dev/null +++ b/tests/e2e/test_exchange_lifecycle_cli.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from typing import ClassVar, Final + +from pydantic import BaseModel, ConfigDict + +PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] + + +class _ExchangeLifecycleOperationPayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + name: str + state: str + + +class _ExchangeLifecycleCliPayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + exchange: str + trading_mode: str + testnet: bool + live_exchange: bool + preflight_blocked: bool + deterministic_order_id: str + operations: tuple[_ExchangeLifecycleOperationPayload, ...] + funding_supported: bool + leverage: str + + +def test_cli_exchange_lifecycle_smoke_outputs_safe_testnet_order_states() -> None: + # Given + command: Final = [ + "uv", + "run", + "nfi-engine", + "exchange", + "lifecycle", + "smoke", + "--config", + "examples/futures-paper.yaml", + "--json", + ] + + # When + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + validation = subprocess.run( + [sys.executable, "-m", "json.tool"], + cwd=PROJECT_ROOT, + input=result.stdout, + capture_output=True, + text=True, + check=False, + ) + payload = _ExchangeLifecycleCliPayload.model_validate_json(result.stdout) + + # Then + assert result.returncode == 0, result.stderr + assert validation.returncode == 0, validation.stderr + assert payload.exchange == "bybit" + assert payload.trading_mode == "futures" + assert payload.testnet is True + assert payload.live_exchange is False + assert payload.preflight_blocked is False + assert payload.deterministic_order_id == "sim-1" + assert payload.leverage == "3" + assert payload.funding_supported is True + assert {operation.name: operation.state for operation in payload.operations} == { + "create_order": "open", + "fetch_order": "open", + "cancel_order": "canceled", + "partial_fill_report": "partially_filled", + "rejected_report": "rejected", + } + + +def test_cli_exchange_lifecycle_blocks_live_exchange_config_before_order_surface() -> None: + # Given + command: Final = [ + "uv", + "run", + "nfi-engine", + "exchange", + "lifecycle", + "smoke", + "--config", + "tests/fixtures/config/live-real-orders.yaml", + "--json", + ] + + # When + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + + # Then + assert result.returncode == 1 + assert result.stdout == "" + assert "EXCHANGE_LIFECYCLE_PREFLIGHT_BLOCKED" in result.stderr + assert "LIVE_TRADING_OUT_OF_SCOPE" in result.stderr + assert "EXCHANGE_TESTNET_REQUIRED" in result.stderr diff --git a/tests/e2e/test_frontend_security.py b/tests/e2e/test_frontend_security.py index c6f453b..89b3f45 100644 --- a/tests/e2e/test_frontend_security.py +++ b/tests/e2e/test_frontend_security.py @@ -137,7 +137,9 @@ async def test_csrf_blocks_mutating_settings_request_when_header_is_missing() -> ("/api/v1/backup/restore", None), ("/api/v1/start", None), ("/api/v1/pause", None), + ("/api/v1/resume", None), ("/api/v1/stop", None), + ("/api/v1/runtime/control", {"command": "start"}), ], ) async def test_csrf_blocks_all_write_router_endpoints( @@ -196,7 +198,13 @@ async def test_read_only_mode_blocks_mutations_but_keeps_inspection_available() backup_restore = await client.post("/api/v1/backup/restore", headers=headers, json={}) runtime_start = await client.post("/api/v1/start", headers=headers) runtime_pause = await client.post("/api/v1/pause", headers=headers) + runtime_resume = await client.post("/api/v1/resume", headers=headers) runtime_stop = await client.post("/api/v1/stop", headers=headers) + runtime_control = await client.post( + "/api/v1/runtime/control", + headers=headers, + json={"command": "start"}, + ) audit = await client.get("/api/v1/security/audit") # Then: reads work, writes fail server-side, and security audit events are visible. @@ -209,7 +217,9 @@ async def test_read_only_mode_blocks_mutations_but_keeps_inspection_available() backup_restore, runtime_start, runtime_pause, + runtime_resume, runtime_stop, + runtime_control, ): error = ErrorEnvelope.model_validate_json(response.content) assert response.status_code == 403 diff --git a/tests/e2e/test_home_ui.py b/tests/e2e/test_home_ui.py index fbe57d9..5c66f10 100644 --- a/tests/e2e/test_home_ui.py +++ b/tests/e2e/test_home_ui.py @@ -39,11 +39,23 @@ async def test_home_route_is_first_usable_operator_surface() -> None: assert 'data-testid="bot-state"' in response.text assert 'data-testid="session-pnl"' in response.text assert 'data-testid="open-trades"' in response.text + assert 'data-testid="runtime-controls"' in response.text + assert 'data-testid="runtime-control-state"' in response.text + assert 'data-testid="pause-button"' in response.text + assert 'data-testid="resume-button"' in response.text + assert 'data-command="start"' in response.text + assert 'data-command="pause"' in response.text + assert 'data-command="resume"' in response.text + assert 'data-command="stop"' in response.text assert 'data-testid="dashboard-chart"' in response.text assert 'data-testid="chart-status"' in response.text assert 'data-testid="chart-render-time"' in response.text + assert 'data-testid="action-queue"' in response.text + assert 'data-testid="action-item"' in response.text assert 'data-poll-ms="5000"' in response.text assert "/api/v1/dashboard/snapshot" in response.text + assert "/api/v1/runtime/control" in response.text + assert "/api/v1/runtime/health" in response.text assert "chart-bars" not in response.text assert "/api/v1/reports/support-bundle.zip" in response.text assert "https://" not in response.text @@ -91,6 +103,7 @@ async def test_home_route_renders_bounded_dashboard_read_model_summary() -> None 'data-testid="session-pnl">Session PnL12.34 USDT' in response.text ) + assert 'data-testid="runtime-control-state">stopped<' in response.text assert "profit-placeholder" not in response.text diff --git a/tests/e2e/test_i18n_ui.py b/tests/e2e/test_i18n_ui.py index af08cae..ada3cd1 100644 --- a/tests/e2e/test_i18n_ui.py +++ b/tests/e2e/test_i18n_ui.py @@ -41,8 +41,13 @@ "Local operator login", "First-run setup", "Preview setup", - "Balanced", + "Dry-run", '"settings.fix_settings":"Fix settings"', + "Pause", + "Resume", + '"settings.runtime_control_state":"Runtime control state"', + '"settings.runtime_control_loading":"Sending runtime command..."', + '"settings.runtime_control_blocked":"Runtime command blocked"', ), ), ( @@ -65,8 +70,13 @@ "로컬 운영자 로그인", "첫 실행 설정", "설정 미리보기", - "균형", + "드라이런", '"settings.fix_settings":"설정을 수정하세요"', + "일시 중지", + "재개", + '"settings.runtime_control_state":"런타임 제어 상태"', + '"settings.runtime_control_loading":"런타임 명령 전송 중..."', + '"settings.runtime_control_blocked":"런타임 명령이 차단됨"', ), ), ( @@ -80,17 +90,22 @@ "Πρόσφατα γεγονότα", "Η ανανέωση snapshot απέτυχε.", "Bundle υποστήριξης", - "Runtime-safe ρυθμίσεις", + "Ασφαλείς ρυθμίσεις runtime", "Πύλες ασφάλειας", - "Δεν υπάρχει pairlist preview", + "Δεν υπάρχει προεπισκόπηση λίστας ζευγών", "Σοβαρότητα", "Αναζήτηση σφάλματος", '"settings.runtime_applied":"runtime εφαρμόστηκε"', "Σύνδεση τοπικού χειριστή", "Ρύθμιση πρώτης εκτέλεσης", "Προεπισκόπηση ρύθμισης", - "Ισορροπημένο", + "Dry-run", '"settings.fix_settings":"Διορθώστε τις ρυθμίσεις"', + "Παύση", + "Συνέχιση", + '"settings.runtime_control_state":"Κατάσταση ελέγχου runtime"', + '"settings.runtime_control_loading":"Αποστολή εντολής runtime..."', + '"settings.runtime_control_blocked":"Η εντολή runtime αποκλείστηκε"', ), ), ], @@ -146,6 +161,11 @@ async def test_home_settings_and_logs_are_localized_without_changing_contracts( assert expected[16] in settings_page.text assert expected[17] in settings_page.text assert expected[18] in settings_page.text + assert expected[19] in home.text + assert expected[20] in home.text + assert expected[21] in settings_page.text + assert expected[22] in settings_page.text + assert expected[23] in settings_page.text assert "CONFIG_VALIDATION_ERROR" in logs.text assert all('data-testid="' in page for page in pages) assert 'data-testid="login-form"' in login.text diff --git a/tests/e2e/test_install_bootstrap.py b/tests/e2e/test_install_bootstrap.py new file mode 100644 index 0000000..58c3c8b --- /dev/null +++ b/tests/e2e/test_install_bootstrap.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import Final + +PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] + + +def test_package_scripts_offer_npm_and_bun_bootstrap_commands() -> None: + package_path = PROJECT_ROOT / "package.json" + + json_check = subprocess.run( + [sys.executable, "-m", "json.tool", str(package_path)], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + package_text = package_path.read_text(encoding="utf-8") + + assert json_check.returncode == 0, json_check.stderr + assert '"nfi:install": "bash scripts/install.sh --yes --paper --testnet"' in package_text + assert ( + '"nfi:install:dry-run": "bash scripts/install.sh --yes --paper --testnet --dry-run"' + in package_text + ) + assert '"nfi:uninstall": "bash scripts/uninstall.sh --yes"' in package_text + assert ( + '"nfi:uninstall:purge:dry-run": "bash scripts/uninstall.sh --purge --yes --dry-run"' + in package_text + ) + assert '"nfi:pi4:rc-check": "bash scripts/pi4_rc_profile.sh"' in package_text + assert '"dependencies"' not in package_text + + +def test_install_script_missing_uv_prints_actionable_remediation(tmp_path: Path) -> None: + runtime_dir = tmp_path / "runtime" + empty_path = tmp_path / "empty-path" + empty_path.mkdir() + environment = os.environ.copy() + environment["PATH"] = str(empty_path) + command: Final = [ + "/bin/bash", + "scripts/install.sh", + "--yes", + "--paper", + "--testnet", + "--runtime-dir", + str(runtime_dir), + "--dry-run", + ] + + result = subprocess.run( + command, + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert "INSTALL_MISSING_COMMAND: uv" in result.stderr + assert "install_hint=Install uv from https://docs.astral.sh/uv/" in result.stderr + assert "python3" in result.stderr + assert not runtime_dir.exists() + + +def test_final_smoke_records_install_and_uninstall_dry_run_matrix() -> None: + script = (PROJECT_ROOT / "scripts/final_smoke.sh").read_text(encoding="utf-8") + + assert "final-install-dry-run.txt" in script + assert "final-uninstall-safe-dry-run.txt" in script + assert "final-uninstall-purge-dry-run.txt" in script + assert "final-x7-strategy-inspect.json" in script + assert "final-x7-forbidden-runtime-modules.txt" in script + assert "final-release-wording-scan.txt" in script + assert "bash scripts/install.sh --yes --paper --testnet --dry-run" in script + assert "bash scripts/uninstall.sh --yes --dry-run" in script + assert "bash scripts/uninstall.sh --purge --yes --dry-run" in script + assert "scripts/release_wording_scan.py" in script + assert "nfi_engine.strategy.nfi_x7:X7NativeStrategy" in script + + +def test_final_smoke_records_protected_dashboard_auth_boundary() -> None: + # Given: the final smoke script used as the Docker first-run release gate. + script = (PROJECT_ROOT / "scripts/final_smoke.sh").read_text(encoding="utf-8") + + # When/Then: it records both the unauthenticated denial and authenticated success. + assert "final-dashboard-snapshot-unauthenticated.status" in script + assert "final-dashboard-snapshot-unauthenticated.json" in script + expected_denial_check: Final = ( + '[[ "${unauth_status}" == "401" || "${unauth_status}" == "403" ]]' + ) + assert expected_denial_check in script + assert "Authorization: Bearer ${api_token}" in script + assert "final-dashboard-snapshot.json" in script diff --git a/tests/e2e/test_install_docker_readiness.py b/tests/e2e/test_install_docker_readiness.py new file mode 100644 index 0000000..47f7e2a --- /dev/null +++ b/tests/e2e/test_install_docker_readiness.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import os +import stat +import subprocess +from pathlib import Path +from typing import Final + +PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] + + +def _write_unusable_docker_stub(docker_stub: Path, message: str) -> None: + docker_stub.write_text( + f"""#!/usr/bin/env bash +if [[ "$1" == "compose" && "$2" == "version" ]]; then + printf '%s\\n' "{message}" + exit 1 +fi +printf 'unexpected docker invocation: %s\\n' "$*" >&2 +exit 99 +""", + encoding="utf-8", + ) + docker_stub.chmod(docker_stub.stat().st_mode | stat.S_IXUSR) + + +def test_install_script_reports_unusable_docker_compose(tmp_path: Path) -> None: + # Given: a Docker executable exists but Compose is unavailable in the shell. + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + docker_stub = fake_bin / "docker" + _write_unusable_docker_stub( + docker_stub, + "The command 'docker' could not be found in this WSL 2 distro.", + ) + runtime_dir = tmp_path / "runtime" + env = os.environ.copy() + env["PATH"] = f"{fake_bin}:{env['PATH']}" + command: Final = [ + "bash", + "scripts/install.sh", + "--yes", + "--paper", + "--testnet", + "--runtime-dir", + str(runtime_dir), + ] + + # When: the real installer reaches its Docker Compose readiness gate. + result = subprocess.run( + command, + cwd=PROJECT_ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + # Then: the operator gets a stable error code and the captured Docker hint. + assert result.returncode != 0 + assert "INSTALL_DOCKER_UNAVAILABLE" in result.stderr + assert "The command 'docker' could not be found in this WSL 2 distro." in result.stderr + assert "install_hint=Install Docker with Compose v2" in result.stderr + + +def test_uninstall_script_reports_unusable_docker_compose(tmp_path: Path) -> None: + # Given: uninstall cleanup can find Docker, but Compose itself cannot run. + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + docker_stub = fake_bin / "docker" + _write_unusable_docker_stub(docker_stub, "Docker Desktop WSL integration is disabled.") + env = os.environ.copy() + env["PATH"] = f"{fake_bin}:{env['PATH']}" + command: Final = [ + "bash", + "scripts/uninstall.sh", + "--yes", + "--runtime-dir", + str(tmp_path / "runtime"), + ] + + # When: uninstall reaches Docker Compose readiness. + result = subprocess.run( + command, + cwd=PROJECT_ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + # Then: it emits an actionable stable code instead of failing silently. + assert result.returncode != 0 + assert "UNINSTALL_DOCKER_UNAVAILABLE" in result.stderr + assert "Docker Desktop WSL integration is disabled." in result.stderr + assert "install_hint=Install Docker with Compose v2" in result.stderr + + +def test_final_smoke_uses_isolated_runtime_for_real_docker_install() -> None: + # Given: the final smoke is allowed to run a real Docker install. + script = (PROJECT_ROOT / "scripts/final_smoke.sh").read_text(encoding="utf-8") + + # When/Then: it must isolate that install from the operator's default runtime. + assert '--runtime-dir "${real_runtime_dir}/runtime"' in script + assert '--project-name "${real_project_name}"' in script + assert '--host-port "${real_host_port}"' in script + assert ".runtime/docker.env" not in script + assert "test -e .runtime" not in script + assert "test ! -e .runtime" not in script diff --git a/tests/e2e/test_install_script.py b/tests/e2e/test_install_script.py index 7f44e8c..7f68bae 100644 --- a/tests/e2e/test_install_script.py +++ b/tests/e2e/test_install_script.py @@ -24,6 +24,10 @@ def test_install_script_dry_run_generates_runtime_config_and_redacted_output( "install-key", "--api-secret", "install-secret", + "--host-port", + "18113", + "--project-name", + "nfi-engine-test", "--dry-run", ] result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) @@ -50,9 +54,15 @@ def test_install_script_dry_run_generates_runtime_config_and_redacted_output( assert result.returncode == 0, result.stderr assert validation.returncode == 0, validation.stderr assert "install_plan=dry-run" in result.stdout - assert "url=http://127.0.0.1:18080" in result.stdout + assert "url=http://127.0.0.1:18113" in result.stdout + assert "host_port=18113" in result.stdout + assert "compose_project=nfi-engine-test" in result.stdout assert "intent=testnet" in result.stdout assert f"login_token_file={env_file}" in result.stdout + assert ( + f"uninstall=bash scripts/uninstall.sh --yes --runtime-dir {runtime_dir} " + "--project-name nfi-engine-test" + ) in result.stdout assert "install-key" not in result.stdout assert "install-secret" not in result.stdout assert config.exists() @@ -64,26 +74,67 @@ def test_install_script_dry_run_generates_runtime_config_and_redacted_output( def test_install_script_is_docker_first_and_uses_safe_runtime_files() -> None: script = (PROJECT_ROOT / "scripts/install.sh").read_text(encoding="utf-8") - assert "docker compose up --build -d api" in script - assert "docker compose run --rm cli nfi-engine config validate" in script + assert 'docker compose --project-name "$project_name" up --build -d api' in script + assert ( + 'docker compose --project-name "$project_name" run --rm cli nfi-engine config validate' + in script + ) + assert "NFI_ENGINE_HOST_PORT" in script + assert "NFI_ENGINE_RUNTIME_CONFIG_DIR" in script + assert "NFI_ENGINE_RUNTIME_ENV_FILE" in script assert "chmod 600" in script assert ".runtime" in script assert "curl | bash" not in script -def test_final_smoke_records_protected_dashboard_auth_boundary() -> None: - # Given: the final smoke script used as the Docker first-run release gate. - script = (PROJECT_ROOT / "scripts/final_smoke.sh").read_text(encoding="utf-8") +def test_install_script_refuses_invalid_host_port(tmp_path: Path) -> None: + # Given: an invalid host port for an isolated RC deployment. + runtime_dir = tmp_path / "runtime" + command: Final = [ + "bash", + "scripts/install.sh", + "--yes", + "--paper", + "--testnet", + "--runtime-dir", + str(runtime_dir), + "--host-port", + "not-a-port", + "--dry-run", + ] - # When/Then: it records both the unauthenticated denial and authenticated success. - assert "final-dashboard-snapshot-unauthenticated.status" in script - assert "final-dashboard-snapshot-unauthenticated.json" in script - expected_denial_check: Final = ( - '[[ "${unauth_status}" == "401" || "${unauth_status}" == "403" ]]' - ) - assert expected_denial_check in script - assert "Authorization: Bearer ${api_token}" in script - assert "final-dashboard-snapshot.json" in script + # When: the installer parses the request. + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + + # Then: it refuses before generating runtime files. + assert result.returncode != 0 + assert "INSTALL_INVALID_HOST_PORT" in result.stderr + assert not runtime_dir.exists() + + +def test_compose_publishes_api_on_configurable_loopback_port() -> None: + # Given: the Compose stack used by the one-line installer. + compose_text = (PROJECT_ROOT / "compose.yaml").read_text(encoding="utf-8") + + # When/Then: host exposure stays loopback-only and the host port is overridable. + assert '"127.0.0.1:${NFI_ENGINE_HOST_PORT:-18080}:18080"' in compose_text + assert "${NFI_ENGINE_RUNTIME_ENV_FILE:-.runtime/docker.env}" in compose_text + assert "${NFI_ENGINE_RUNTIME_CONFIG_DIR:-./.runtime/config}:/config:ro" in compose_text + assert "0.0.0.0:18080:18080" not in compose_text + + +def test_pi4_rc_profile_script_has_reversible_deployment_contract() -> None: + # Given: the Pi4 RC profile script shipped for hardware deployment checks. + script = (PROJECT_ROOT / "scripts/pi4_rc_profile.sh").read_text(encoding="utf-8") + + # When/Then: it checks the non-destructive gates and prints rollback receipts. + assert "PI4_CPU_MAX_REDUCED" in script + assert "PI4_THROTTLED" in script + assert "PI4_COMPOSE_PUBLIC_BIND" in script + assert "PI4_DOCKER_LOG_UNBOUNDED" in script + assert "rollback_safe_uninstall" in script + assert "systemctl" not in script + assert "/boot/firmware/config.txt" not in script def test_uninstall_script_safe_dry_run_preserves_runtime_and_data( @@ -264,8 +315,8 @@ def test_uninstall_script_uses_compose_down_without_broad_filesystem_scans() -> script = (PROJECT_ROOT / "scripts/uninstall.sh").read_text(encoding="utf-8") # When/Then: safe and purge paths are scoped to Compose and known runtime paths. - assert "docker compose down --remove-orphans" in script - assert "docker compose down --volumes --remove-orphans" in script + assert 'docker compose --project-name "$project_name" down --remove-orphans' in script + assert 'docker compose --project-name "$project_name" down --volumes --remove-orphans' in script assert 'docker volume rm "${project_name}_nfi-data" "${project_name}_nfi-logs"' in script assert ".nfi-engine-runtime" in script assert 'rm -rf -- "$runtime_dir"' in script diff --git a/tests/e2e/test_paper_run_cli.py b/tests/e2e/test_paper_run_cli.py index 5770222..52cab4b 100644 --- a/tests/e2e/test_paper_run_cli.py +++ b/tests/e2e/test_paper_run_cli.py @@ -1,6 +1,36 @@ from __future__ import annotations import subprocess +from pathlib import Path + + +def test_paper_run_cli_writes_timeline_output(tmp_path: Path) -> None: + # Given + timeline_path = tmp_path / "paper-timeline.json" + command = [ + "uv", + "run", + "nfi-engine", + "paper-run", + "--config", + "examples/futures-paper.yaml", + "--ticks", + "tests/fixtures/ticks/btc_usdt_futures.jsonl", + "--max-events", + "5", + "--timeline-output", + str(timeline_path), + ] + + # When + result = subprocess.run(command, check=False, capture_output=True, text=True) + + # Then + assert result.returncode == 0 + payload = timeline_path.read_text(encoding="utf-8") + assert '"surface": "paper"' in payload + assert '"payload_bytes"' in payload + assert '"entry_signals": 1' in payload def test_paper_run_cli_processes_tick_fixture() -> None: diff --git a/tests/e2e/test_pi4_rc_profile_contract.py b/tests/e2e/test_pi4_rc_profile_contract.py new file mode 100644 index 0000000..699fc96 --- /dev/null +++ b/tests/e2e/test_pi4_rc_profile_contract.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Final + +PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] +PROFILE_SCRIPT: Final = PROJECT_ROOT / "scripts/pi4_rc_profile.sh" +FORBIDDEN_HOST_MUTATION_SNIPPETS: Final = ( + "systemctl ", + "sysctl -w", + "rfkill ", + "raspi-config", + "cpufreq-set", + "modprobe ", + "dtoverlay=", + "pinctrl set", + "gpio -g", + "sed -i", + "tee /boot", + "tee /etc/sysctl", + "tee /etc/systemd/journald.conf", + "tee /etc/docker/daemon.json", + "tee /sys/devices/system/cpu", + "tee /sys/class/gpio", + "> /boot", + "> /etc/sysctl", + "> /etc/systemd", + "> /etc/docker/daemon.json", + "> /sys/devices/system/cpu", + "> /sys/class/gpio", +) + + +def test_pi4_rc_profile_does_not_ship_host_mutation_commands() -> None: + # Given: the Pi4 RC profile shipped as the deployment-readiness gate. + script = PROFILE_SCRIPT.read_text(encoding="utf-8") + + # When/Then: it may inspect host state, but it must not mutate host tuning. + for forbidden in FORBIDDEN_HOST_MUTATION_SNIPPETS: + assert forbidden not in script + assert "host_tuning=not_applied" in script + assert "rollback_safe_uninstall" in script + assert "rollback_purge_preview" in script + + +def test_pi4_rc_profile_rejects_invalid_port_before_output_file(tmp_path: Path) -> None: + # Given: a malformed host port and a requested evidence output file. + output_path = tmp_path / "pi4-profile.txt" + command: Final = [ + "bash", + str(PROFILE_SCRIPT), + "--host-port", + "not-a-port", + "--output", + str(output_path), + ] + + # When: the profile parses operator input. + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + + # Then: it fails before writing profile output or touching runtime state. + assert result.returncode != 0 + assert "PI4_INVALID_HOST_PORT" in result.stderr + assert not output_path.exists() diff --git a/tests/e2e/test_preflight_cli.py b/tests/e2e/test_preflight_cli.py index c2bd91f..61c231c 100644 --- a/tests/e2e/test_preflight_cli.py +++ b/tests/e2e/test_preflight_cli.py @@ -65,3 +65,30 @@ def test_preflight_cli_blocks_public_api_bind() -> None: assert result.returncode == 1 assert "PREFLIGHT_BLOCKED" in result.stdout assert "PUBLIC_BIND_NOT_ALLOWED" in result.stdout + + +def test_preflight_cli_shows_live_hardening_blockers() -> None: + # Given: a confirmed live config that is still unsafe for real orders. + command: Final = [ + "uv", + "run", + "nfi-engine", + "preflight", + "check", + "--profile", + "bybit-testnet", + "--config", + "tests/fixtures/config/live-real-orders.yaml", + ] + + # When: preflight runs through the operator CLI. + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + + # Then: the live lock and missing hardening gates are visible to the operator. + assert result.returncode == 1 + assert "PREFLIGHT_BLOCKED" in result.stdout + assert "LIVE_TRADING_OUT_OF_SCOPE" in result.stdout + assert "LIVE_PERMISSION_HARDENING" in result.stdout + assert "LIVE_RECONCILIATION_HARDENING" in result.stdout + assert "LIVE_CIRCUIT_BREAKER_HARDENING" in result.stdout + assert "LIVE_STRATEGY_HARDENING" in result.stdout diff --git a/tests/e2e/test_runtime_control_api.py b/tests/e2e/test_runtime_control_api.py new file mode 100644 index 0000000..ee8290f --- /dev/null +++ b/tests/e2e/test_runtime_control_api.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar, Final + +import pytest +from httpx import ASGITransport, AsyncClient +from pydantic import BaseModel, ConfigDict + +from nfi_engine.api.app import create_app +from nfi_engine.config.models import ( + ApiSettings, + CircuitBreakerSettings, + EngineSettings, + ReconciliationSettings, + RuntimeSettings, +) +from nfi_engine.dashboard.store import StaticDashboardReadStore + +if TYPE_CHECKING: + from fastapi import FastAPI + +LOCAL_BEARER: Final = "local-test-bearer" + + +class SessionPayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + role: str + csrf_token: str + expires_at: datetime + + +class RuntimeControlPayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + state: str + new_entries_allowed: bool + + +class ErrorDetail(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + code: str + message: str + audit_event: str | None = None + + +class ErrorEnvelope(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + detail: ErrorDetail + + +pytestmark = pytest.mark.anyio + + +async def test_runtime_control_routes_share_typed_state_surface() -> None: + # Given: a local operator session with an empty dashboard store. + settings = RuntimeSettings(api=ApiSettings.model_validate({"auth_token": LOCAL_BEARER})) + async with _client( + create_app(settings=settings, dashboard_store=StaticDashboardReadStore()), + ) as client: + session = await _login(client) + headers = _csrf_headers(session) + + # When: runtime is driven through the generic and compatibility control routes. + start = await client.post( + "/api/v1/runtime/control", + headers=headers, + json={"command": "start"}, + ) + pause = await client.post("/api/v1/pause", headers=headers) + resume = await client.post("/api/v1/resume", headers=headers) + current = await client.get("/api/v1/runtime/control", headers=_auth_headers()) + + # Then: each route returns the same typed runtime-control contract. + start_payload = RuntimeControlPayload.model_validate_json(start.content) + pause_payload = RuntimeControlPayload.model_validate_json(pause.content) + resume_payload = RuntimeControlPayload.model_validate_json(resume.content) + current_payload = RuntimeControlPayload.model_validate_json(current.content) + assert start.status_code == 200 + assert start_payload.state == "running" + assert start_payload.new_entries_allowed is True + assert pause.status_code == 200 + assert pause_payload.state == "paused" + assert pause_payload.new_entries_allowed is False + assert resume.status_code == 200 + assert resume_payload.state == "running" + assert resume_payload.new_entries_allowed is True + assert current.status_code == 200 + assert current_payload.state == "running" + assert current_payload.new_entries_allowed is True + + +async def test_runtime_control_rejects_malformed_command_with_machine_code() -> None: + # Given: a local operator session. + settings = RuntimeSettings(api=ApiSettings.model_validate({"auth_token": LOCAL_BEARER})) + async with _client(create_app(settings=settings)) as client: + session = await _login(client) + + # When: the generic control route receives an unknown command. + response = await client.post( + "/api/v1/runtime/control", + headers=_csrf_headers(session), + json={"command": "explode"}, + ) + + # Then: the denial is typed and stable. + payload = ErrorEnvelope.model_validate_json(response.content) + assert response.status_code == 422 + assert payload.detail.code == "RUNTIME_COMMAND_INVALID" + + +async def test_runtime_control_rejects_repeated_pause_and_stop_with_machine_codes() -> None: + # Given: a local operator session with a running runtime. + settings = RuntimeSettings(api=ApiSettings.model_validate({"auth_token": LOCAL_BEARER})) + async with _client(create_app(settings=settings)) as client: + session = await _login(client) + headers = _csrf_headers(session) + await client.post("/api/v1/start", headers=headers) + await client.post("/api/v1/pause", headers=headers) + + # When: pause is repeated and stop is repeated. + paused_again = await client.post("/api/v1/pause", headers=headers) + await client.post("/api/v1/stop", headers=headers) + stopped_again = await client.post("/api/v1/stop", headers=headers) + + # Then: each no-op denial keeps a stable machine code. + paused_error = ErrorEnvelope.model_validate_json(paused_again.content) + stopped_error = ErrorEnvelope.model_validate_json(stopped_again.content) + assert paused_again.status_code == 409 + assert paused_error.detail.code == "RUNTIME_ALREADY_PAUSED" + assert stopped_again.status_code == 409 + assert stopped_error.detail.code == "RUNTIME_ALREADY_STOPPED" + + +async def test_runtime_control_rejects_live_intent_with_machine_code() -> None: + # Given: a local operator session with live trading intent enabled. + settings = RuntimeSettings( + api=ApiSettings.model_validate({"auth_token": LOCAL_BEARER}), + engine=EngineSettings(live_trading=True, live_trading_confirmed=True), + ) + async with _client(create_app(settings=settings)) as client: + session = await _login(client) + + # When: the operator tries to start runtime entries. + response = await client.post("/api/v1/start", headers=_csrf_headers(session)) + + # Then: live execution remains blocked by a stable control code. + payload = ErrorEnvelope.model_validate_json(response.content) + assert response.status_code == 409 + assert payload.detail.code == "RUNTIME_LIVE_UNSAFE" + + +async def test_runtime_control_blocks_start_while_manual_halt_file_exists(tmp_path: Path) -> None: + # Given: a manual halt file is present before an operator starts runtime entries. + halt_file = tmp_path / "manual-halt" + halt_file.write_text("halt\n", encoding="utf-8") + settings = RuntimeSettings( + api=ApiSettings.model_validate({"auth_token": LOCAL_BEARER}), + circuit_breakers=CircuitBreakerSettings(manual_halt_file=str(halt_file)), + ) + async with _client( + create_app(settings=settings, dashboard_store=StaticDashboardReadStore()) + ) as client: + session = await _login(client) + headers = _csrf_headers(session) + + blocked = await client.post("/api/v1/start", headers=headers) + halt_file.unlink() + started = await client.post("/api/v1/start", headers=headers) + + # Then: start is blocked while the halt file exists and succeeds after removal. + blocked_payload = ErrorEnvelope.model_validate_json(blocked.content) + started_payload = RuntimeControlPayload.model_validate_json(started.content) + assert blocked.status_code == 409 + assert blocked_payload.detail.code == "RUNTIME_HEALTH_BLOCKED" + assert started.status_code == 200 + assert started_payload.state == "running" + + +async def test_runtime_control_blocks_start_when_reconciliation_preflight_blocks() -> None: + # Given: startup reconciliation is required and the exchange fixture mismatches state. + settings = RuntimeSettings( + api=ApiSettings.model_validate({"auth_token": LOCAL_BEARER}), + reconciliation=ReconciliationSettings( + required=True, + fixture_path="tests/fixtures/exchange/reconcile_mismatch.json", + ), + ) + async with _client( + create_app(settings=settings, dashboard_store=StaticDashboardReadStore()) + ) as client: + session = await _login(client) + + response = await client.post("/api/v1/start", headers=_csrf_headers(session)) + + # Then: the runtime-control API promotes the preflight block into a start denial. + payload = ErrorEnvelope.model_validate_json(response.content) + assert response.status_code == 409 + assert payload.detail.code == "RUNTIME_PREFLIGHT_BLOCKED" + + +def _auth_headers() -> dict[str, str]: + return {"Authorization": f"Bearer {LOCAL_BEARER}"} + + +def _csrf_headers(session: SessionPayload) -> dict[str, str]: + return {"x-nfi-csrf-token": session.csrf_token} + + +async def _login(client: AsyncClient) -> SessionPayload: + response = await client.post("/api/v1/session/login", headers=_auth_headers()) + assert response.status_code == 200 + return SessionPayload.model_validate_json(response.content) + + +def _client(app: FastAPI) -> AsyncClient: + return AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") diff --git a/tests/e2e/test_sandbox_cli.py b/tests/e2e/test_sandbox_cli.py index 2a5a1f4..1fbbf89 100644 --- a/tests/e2e/test_sandbox_cli.py +++ b/tests/e2e/test_sandbox_cli.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import Final +from nfi_engine.compat import NfiCompatibilityReport + PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] @@ -27,6 +29,34 @@ def test_sandbox_cli_allows_nfi_shape_strategy() -> None: assert "sandbox_passed=true" in result.stdout +def test_sandbox_cli_writes_clean_room_compatibility_report(tmp_path: Path) -> None: + # Given + output_path = tmp_path / "compat.json" + command: Final = [ + "uv", + "run", + "nfi-engine", + "sandbox", + "check", + "--strategy", + "tests.fixtures.strategies.nfi_shape:NFISmokeStrategy", + "--output", + str(output_path), + ] + + # When + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + payload = NfiCompatibilityReport.model_validate_json(output_path.read_text(encoding="utf-8")) + + # Then + assert result.returncode == 0, result.stderr + assert payload.compatible is True + assert payload.full_x7_parity is False + assert "populate_entry_trend" in payload.supported_callbacks + assert "informative_pairs" in payload.partial_callbacks + assert "full_x7_strategy_import" in payload.excluded_surfaces + + def test_sandbox_cli_blocks_environment_reading_strategy() -> None: # Given command: Final = [ diff --git a/tests/e2e/test_settings_logs_ui.py b/tests/e2e/test_settings_logs_ui.py index 123b142..9332a57 100644 --- a/tests/e2e/test_settings_logs_ui.py +++ b/tests/e2e/test_settings_logs_ui.py @@ -71,7 +71,7 @@ async def test_settings_ui_and_config_workflow_when_local_console_edits_safe_fie assert 'data-testid="setup-preview-panel"' in page.text assert 'data-testid="setup-preview-button"' in page.text assert 'name="intent"' in page.text - assert 'name="risk_preset"' in page.text + assert 'name="risk_profile"' in page.text assert 'name="api_key" type="password"' in page.text assert 'name="api_secret" type="password"' in page.text assert 'data-testid="advanced-settings"' in page.text diff --git a/tests/e2e/test_setup_api.py b/tests/e2e/test_setup_api.py index ba7797e..b138d98 100644 --- a/tests/e2e/test_setup_api.py +++ b/tests/e2e/test_setup_api.py @@ -27,6 +27,7 @@ async def test_setup_preview_returns_redacted_valid_config() -> None: "api_key": "api-preview-key", "api_secret": "api-preview-secret", "risk_preset": "balanced", + "allocated_amount_usdt": "42.5", }, ) @@ -40,6 +41,43 @@ async def test_setup_preview_returns_redacted_valid_config() -> None: assert "api-preview-key" not in payload.config_preview assert "api-preview-secret" not in payload.config_preview assert "trading_mode: 'futures'" in payload.config_preview + assert "stake_usdt: '42.5'" in payload.config_preview + assert "leverage: '3'" in payload.config_preview + + +@pytest.mark.anyio +async def test_setup_preview_renders_permission_audit_and_risk_profile() -> None: + # Given: a testnet setup request with explicit exchange API permission states. + async with _client(create_app(settings=RuntimeSettings())) as client: + # When: the API previews the generated runtime config. + response = await client.post( + "/api/v1/setup/preview", + json={ + "exchange": "bybit", + "trading_mode": "futures", + "intent": "testnet", + "api_key": "api-permission-key", + "api_secret": "api-permission-secret", + "risk_profile": "balanced", + "allocated_amount_usdt": "42.5", + "permission_read": "enabled", + "permission_trade": "enabled", + "permission_futures": "enabled", + "permission_withdrawal": "disabled", + "permission_ip_allowlist": "unknown", + }, + ) + + # Then: the preview is valid, redacted, and includes deterministic safety fields. + payload = SetupPreviewResponse.model_validate_json(response.content) + assert response.status_code == 200 + assert payload.valid is True + assert "api-permission-key" not in payload.config_preview + assert "api-permission-secret" not in payload.config_preview + assert "risk_profile: 'balanced'" in payload.config_preview + assert "expert_risk_confirmed: false" in payload.config_preview + assert "permission_withdrawal: 'disabled'" in payload.config_preview + assert "permission_ip_allowlist: 'unknown'" in payload.config_preview @pytest.mark.anyio @@ -69,5 +107,128 @@ async def test_setup_preview_blocks_live_mode_without_confirmation() -> None: assert "api-live-secret" not in payload.config_preview +@pytest.mark.anyio +async def test_setup_preview_blocks_live_when_withdrawal_permission_is_enabled() -> None: + # Given: a live setup request with exchange withdrawal permission enabled. + async with _client(create_app(settings=RuntimeSettings())) as client: + # When: setup preview validates the request. + response = await client.post( + "/api/v1/setup/preview", + json={ + "exchange": "bybit", + "trading_mode": "futures", + "intent": "live", + "api_key": "api-live-withdraw-key", + "api_secret": "api-live-withdraw-secret", + "risk_profile": "safe", + "live_trading_confirmed": True, + "permission_withdrawal": "enabled", + }, + ) + + # Then: the permission gate blocks live setup without leaking credentials. + payload = SetupPreviewResponse.model_validate_json(response.content) + assert response.status_code == 200 + assert payload.valid is False + assert "EXCHANGE_WITHDRAWAL_PERMISSION_ENABLED" in payload.errors + assert "api-live-withdraw-key" not in payload.config_preview + assert "api-live-withdraw-secret" not in payload.config_preview + + +@pytest.mark.anyio +async def test_setup_preview_blocks_expert_profile_without_confirmation() -> None: + # Given: an expert setup request without explicit expert-risk confirmation. + async with _client(create_app(settings=RuntimeSettings())) as client: + # When: setup preview validates the request. + response = await client.post( + "/api/v1/setup/preview", + json={ + "exchange": "bybit", + "trading_mode": "futures", + "intent": "testnet", + "risk_profile": "expert", + }, + ) + + # Then: the expert profile requires an explicit operator confirmation. + payload = SetupPreviewResponse.model_validate_json(response.content) + assert response.status_code == 200 + assert payload.valid is False + assert payload.errors == ("EXPERT_RISK_REQUIRES_CONFIRMATION",) + + +@pytest.mark.anyio +async def test_setup_preview_rejects_malformed_permission_status() -> None: + # Given: a setup request with an invalid permission enum value. + async with _client(create_app(settings=RuntimeSettings())) as client: + # When: the setup preview boundary parses the request. + response = await client.post( + "/api/v1/setup/preview", + json={ + "exchange": "bybit", + "trading_mode": "futures", + "intent": "paper", + "permission_withdrawal": "sometimes", + }, + ) + + # Then: Pydantic rejects it before any config preview is produced. + assert response.status_code == 422 + + +@pytest.mark.anyio +async def test_setup_preview_rejects_forbidden_secret_fields_without_echo() -> None: + # Given: a setup preview request containing forbidden wallet and login-token fields. + raw_values = ( + "seed-value-should-not-echo", + "private-value-should-not-echo", + "mnemonic-value-should-not-echo", + "withdrawal-value-should-not-echo", + "api-auth-token-should-not-echo", + "login-token-should-not-echo", + ) + async with _client(create_app(settings=RuntimeSettings())) as client: + # When: the API boundary rejects the extra fields. + response = await client.post( + "/api/v1/setup/preview", + json={ + "exchange": "bybit", + "trading_mode": "futures", + "intent": "testnet", + "wallet_seed": raw_values[0], + "private_key": raw_values[1], + "mnemonic": raw_values[2], + "withdrawal_key": raw_values[3], + "api_auth_token": raw_values[4], + "login_token": raw_values[5], + }, + ) + + # Then: validation reports the rejected fields without echoing rejected secrets. + assert response.status_code == 422 + assert '"input"' not in response.text + for value in raw_values: + assert value not in response.text + + +@pytest.mark.anyio +async def test_setup_preview_rejects_non_positive_allocated_amount() -> None: + # Given: a setup request with a non-positive allocated trading amount. + async with _client(create_app(settings=RuntimeSettings())) as client: + # When: the setup preview boundary parses the request. + response = await client.post( + "/api/v1/setup/preview", + json={ + "exchange": "bybit", + "trading_mode": "futures", + "intent": "paper", + "allocated_amount_usdt": "0", + }, + ) + + # Then: Pydantic rejects it before any config preview is produced. + assert response.status_code == 422 + + def _client(app: FastAPI) -> AsyncClient: return AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") diff --git a/tests/e2e/test_wallet_health_api.py b/tests/e2e/test_wallet_health_api.py new file mode 100644 index 0000000..b858862 --- /dev/null +++ b/tests/e2e/test_wallet_health_api.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from httpx import ASGITransport, AsyncClient + +from nfi_engine.api.app import create_app +from nfi_engine.api.runtime_health_models import RuntimeHealthResponse +from nfi_engine.api.wallet_models import WalletBalanceResponse +from nfi_engine.config.models import RuntimeSettings +from nfi_engine.dashboard.store import StaticDashboardReadStore + +if TYPE_CHECKING: + from fastapi import FastAPI + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +async def test_wallet_balance_api_returns_redacted_simulator_balance() -> None: + # Given: a local simulator app with anonymous loopback operator access. + async with _client(create_app(settings=RuntimeSettings())) as client: + # When: the operator fetches wallet balance. + response = await client.get("/api/v1/wallet/balance") + + # Then: the response is typed, redacted, and fetched through the adapter boundary. + payload = WalletBalanceResponse.model_validate_json(response.content) + serialized = response.text + assert response.status_code == 200 + assert payload.status == "fetched" + assert payload.code == "WALLET_BALANCE_FETCHED" + assert payload.equity == "1000" + assert payload.available == "1000" + assert payload.allocation_cap_pct == "0.10" + assert payload.allocation_cap == "100.00" + assert payload.configured_allocation_total == "30" + assert payload.allocation_cap_exceeded is False + assert payload.permission_audit.withdrawal == "unknown" + assert "api_key" not in serialized + assert "api_secret" not in serialized + assert "auth_token" not in serialized + + +async def test_wallet_balance_fetch_action_returns_live_snapshot_shape() -> None: + # Given: the explicit operator fetch action. + async with _client(create_app(settings=RuntimeSettings())) as client: + # When: Settings asks for a wallet refresh. + response = await client.post("/api/v1/wallet/balance/fetch") + + # Then: the same typed snapshot shape is returned without browser storage or secrets. + payload = WalletBalanceResponse.model_validate_json(response.content) + assert response.status_code == 200 + assert payload.status == "fetched" + assert payload.available == "1000" + assert payload.quote_asset == "USDT" + assert payload.permission_audit.summary.startswith("read=unknown") + assert payload.allocation_cap == "100.00" + + +async def test_runtime_health_api_includes_wallet_check_without_secret_surface() -> None: + # Given: a local app with an empty dashboard store. + async with _client( + create_app(settings=RuntimeSettings(), dashboard_store=StaticDashboardReadStore()), + ) as client: + # When: runtime health is inspected. + response = await client.get("/api/v1/runtime/health") + + # Then: health is explicit about degraded data and includes wallet diagnostics. + payload = RuntimeHealthResponse.model_validate_json(response.content) + serialized = response.text + assert response.status_code == 200 + assert payload.state == "degraded" + assert payload.wallet_balance.code == "WALLET_BALANCE_FETCHED" + assert payload.wallet_balance.permission_audit.live_safe is True + assert payload.wallet_balance.allocation_cap_exceeded is False + assert "WALLET_BALANCE" in {item.code for item in payload.checks} + assert "DATA_FRESHNESS" in {item.code for item in payload.checks} + assert "api_key" not in serialized + assert "api_secret" not in serialized + assert "auth_token" not in serialized + + +async def test_wallet_balance_api_blocks_bybit_without_credentials() -> None: + # Given: a testnet exchange config without credentials. + settings = RuntimeSettings.model_validate({"exchange": {"name": "bybit", "testnet": True}}) + async with _client(create_app(settings=settings)) as client: + # When: the wallet endpoint is called. + response = await client.get("/api/v1/wallet/balance") + + # Then: the operator gets a stable setup action instead of a live call. + payload = WalletBalanceResponse.model_validate_json(response.content) + assert response.status_code == 200 + assert payload.status == "blocked" + assert payload.code == "WALLET_BALANCE_MISSING_CREDENTIALS" + assert payload.permission_audit.withdrawal == "unknown" + assert payload.allocation_cap is None + assert payload.allocation_cap_exceeded is None + + +def _client(app: FastAPI) -> AsyncClient: + return AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") diff --git a/tests/e2e/test_x7_native_surface.py b/tests/e2e/test_x7_native_surface.py new file mode 100644 index 0000000..b2aa908 --- /dev/null +++ b/tests/e2e/test_x7_native_surface.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Final, TypedDict + +from pydantic import TypeAdapter + +from nfi_engine.strategy.nfi_x7 import LONG_ENTRY_TAG + +PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] +X7_CONFIG: Final = "examples/x7-futures-paper.yaml" +X7_PREFLIGHT_BLOCKED_CONFIG: Final = "tests/fixtures/config/x7-futures-risk-blocked.yaml" +X7_STRATEGY: Final = "nfi_engine.strategy.nfi_x7:X7NativeStrategy" + + +class StrategyPayload(TypedDict): + name: str + + +class CoverageModulePayload(TypedDict): + name: str + status: str + evidence_path: str + blocker: str | None + + +class CoveragePayload(TypedDict): + covered_modules: list[str] + pending_modules: list[str] + is_full_semantic_coverage: bool + modules: list[CoverageModulePayload] + + +class StrategyInspectPayload(TypedDict): + strategy_name: str + semantic_coverage: CoveragePayload + + +class TimelineStepPayload(TypedDict): + pair: str + entry_signals: int + entry_sides: list[str] + entry_reasons: list[str] + opened_orders: int + rejected_actions: int + blocked_actions: int + protection_active: bool + protection_reasons: list[str] + open_trade_count: int + exit_reasons: list[str] + + +class TimelinePayload(TypedDict): + surface: str + step_count: int + steps: list[TimelineStepPayload] + + +class BacktestPayload(TypedDict): + strategy: StrategyPayload + timeline: TimelinePayload + + +BACKTEST_PAYLOAD_ADAPTER: Final = TypeAdapter(BacktestPayload) +TIMELINE_PAYLOAD_ADAPTER: Final = TypeAdapter(TimelinePayload) +STRATEGY_INSPECT_PAYLOAD_ADAPTER: Final = TypeAdapter(StrategyInspectPayload) + + +def test_x7_native_config_is_selectable_by_config_and_strategy_cli() -> None: + # Given + validate_command: Final = [ + "uv", + "run", + "nfi-engine", + "config", + "validate", + "--config", + X7_CONFIG, + ] + inspect_command: Final = [ + "uv", + "run", + "nfi-engine", + "strategy", + "inspect", + "--config", + X7_CONFIG, + "--strategy", + X7_STRATEGY, + ] + + # When + validate_result = subprocess.run( + validate_command, + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + inspect_result = subprocess.run( + inspect_command, + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + # Then + assert validate_result.returncode == 0, validate_result.stderr + assert inspect_result.returncode == 0, inspect_result.stderr + assert "strategy_name=X7NativeStrategy" in inspect_result.stdout + assert "timeframe=5m" in inspect_result.stdout + assert "can_short=true" in inspect_result.stdout + + +def test_x7_native_strategy_inspect_json_reports_semantic_coverage() -> None: + # Given + command: Final = [ + "uv", + "run", + "nfi-engine", + "strategy", + "inspect", + "--config", + X7_CONFIG, + "--strategy", + X7_STRATEGY, + "--json", + ] + + # When + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + + # Then + assert result.returncode == 0, result.stderr + payload = STRATEGY_INSPECT_PAYLOAD_ADAPTER.validate_json(result.stdout) + coverage = payload["semantic_coverage"] + assert payload["strategy_name"] == "X7NativeStrategy" + assert coverage["is_full_semantic_coverage"] is True + assert coverage["pending_modules"] == [] + assert "metadata" in coverage["covered_modules"] + assert "indicator_runtime" in coverage["covered_modules"] + assert "feature_graph" in coverage["covered_modules"] + assert "entry_signals" in coverage["covered_modules"] + assert "exit_signals" in coverage["covered_modules"] + assert "stake_sizing" in coverage["covered_modules"] + assert "protections" in coverage["covered_modules"] + assert "release_docs" in coverage["covered_modules"] + assert coverage["modules"][0]["status"] == "verified" + + +def test_x7_native_config_is_selectable_by_backtest_json_surface() -> None: + # Given + command: Final = [ + "uv", + "run", + "nfi-engine", + "backtest", + "--config", + X7_CONFIG, + "--json", + ] + + # When + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + + # Then + assert result.returncode == 0, result.stderr + payload = BACKTEST_PAYLOAD_ADAPTER.validate_json(result.stdout) + assert payload["strategy"]["name"] == "X7NativeStrategy" + assert payload["timeline"]["steps"][0]["pair"] == "BTC/USDT:USDT" + assert payload["timeline"]["steps"][1]["entry_signals"] == 1 + assert payload["timeline"]["steps"][1]["entry_sides"] == ["long"] + assert payload["timeline"]["steps"][1]["entry_reasons"] == [LONG_ENTRY_TAG] + + +def test_x7_native_config_is_selectable_by_paper_run_surface(tmp_path: Path) -> None: + # Given + timeline_path = tmp_path / "x7-paper-timeline.json" + command: Final = [ + "uv", + "run", + "nfi-engine", + "paper-run", + "--config", + X7_CONFIG, + "--ticks", + "tests/fixtures/ticks/btc_usdt_futures.jsonl", + "--max-events", + "5", + "--timeline-output", + str(timeline_path), + ] + + # When + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + + # Then + assert result.returncode == 0, result.stderr + assert "processed_events=5" in result.stdout + assert "created_trades=3" in result.stdout + assert "live_orders=false" in result.stdout + timeline = TIMELINE_PAYLOAD_ADAPTER.validate_json(timeline_path.read_text(encoding="utf-8")) + assert timeline["surface"] == "paper" + assert timeline["step_count"] == 5 + assert timeline["steps"][0]["pair"] == "BTC/USDT:USDT" + assert timeline["steps"][0]["entry_signals"] == 0 + assert timeline["steps"][0]["entry_reasons"] == [] + assert timeline["steps"][1]["entry_signals"] == 1 + assert timeline["steps"][1]["entry_sides"] == ["long"] + assert timeline["steps"][1]["entry_reasons"] == [LONG_ENTRY_TAG] + assert timeline["steps"][3]["opened_orders"] == 1 + assert timeline["steps"][3]["open_trade_count"] == 3 + assert timeline["steps"][4]["entry_reasons"] == [LONG_ENTRY_TAG] + assert timeline["steps"][4]["opened_orders"] == 0 + assert timeline["steps"][4]["rejected_actions"] == 1 + assert timeline["steps"][4]["open_trade_count"] == 3 + assert "signal_side" not in timeline_path.read_text(encoding="utf-8") + + +def test_x7_native_paper_timeline_records_protection_reasons(tmp_path: Path) -> None: + # Given + timeline_path = tmp_path / "x7-paper-protection-timeline.json" + command: Final = [ + "uv", + "run", + "nfi-engine", + "paper-run", + "--config", + X7_CONFIG, + "--ticks", + "tests/fixtures/ticks/stale_stream.jsonl", + "--max-events", + "3", + "--timeline-output", + str(timeline_path), + ] + + # When + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + + # Then + assert result.returncode == 0, result.stderr + assert "new_orders_blocked=true" in result.stdout + timeline = TIMELINE_PAYLOAD_ADAPTER.validate_json(timeline_path.read_text(encoding="utf-8")) + blocked_step = timeline["steps"][2] + assert blocked_step["entry_signals"] == 1 + assert blocked_step["entry_sides"] == ["long"] + assert blocked_step["entry_reasons"] == [LONG_ENTRY_TAG] + assert blocked_step["blocked_actions"] == 1 + assert blocked_step["protection_active"] is True + assert blocked_step["protection_reasons"] == ["stale_data"] + + +def test_x7_native_paper_run_blocks_preflight_guardrail_before_strategy_timeline( + tmp_path: Path, +) -> None: + # Given + timeline_path = tmp_path / "x7-blocked-paper-timeline.json" + command: Final = [ + "uv", + "run", + "nfi-engine", + "paper-run", + "--config", + X7_PREFLIGHT_BLOCKED_CONFIG, + "--ticks", + "tests/fixtures/ticks/btc_usdt_futures.jsonl", + "--max-events", + "5", + "--timeline-output", + str(timeline_path), + ] + + # When + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + + # Then + assert result.returncode == 1 + assert "PREFLIGHT_BLOCKED" in result.stderr + assert "FUTURES_LEVERAGE_INVALID" in result.stderr + assert "fake-live-api-key" not in result.stderr + assert timeline_path.exists() is False diff --git a/tests/e2e/test_x7_semantic_status_surface.py b/tests/e2e/test_x7_semantic_status_surface.py new file mode 100644 index 0000000..9e24a29 --- /dev/null +++ b/tests/e2e/test_x7_semantic_status_surface.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING, Final, TypedDict + +import pytest +from httpx import ASGITransport, AsyncClient +from pydantic import TypeAdapter + +from nfi_engine.api.app import create_app +from nfi_engine.api.runtime_health_models import RuntimeHealthResponse +from nfi_engine.config.loader import load_runtime_settings +from nfi_engine.dashboard.store import StaticDashboardReadStore + +if TYPE_CHECKING: + from fastapi import FastAPI + +pytestmark = pytest.mark.anyio + +PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] +X7_CONFIG: Final = "examples/x7-futures-paper.yaml" +X7_STRATEGY: Final = "nfi_engine.strategy.nfi_x7:X7NativeStrategy" + + +class X7SemanticStatusPayload(TypedDict): + enabled: bool + coverage_state: str + observed_upstream_version: str + latest_signal_reason: str + warmup_state: str + missing_data_state: str + live_readiness: str + blocked_reason: str | None + + +class StrategyInspectPayload(TypedDict): + strategy_name: str + x7_semantic_status: X7SemanticStatusPayload | None + + +STRATEGY_INSPECT_ADAPTER: Final = TypeAdapter(StrategyInspectPayload) + + +def test_x7_strategy_inspect_json_reports_operator_semantic_status() -> None: + # Given: the native X7 strategy inspect surface. + command: Final = [ + "uv", + "run", + "nfi-engine", + "strategy", + "inspect", + "--config", + X7_CONFIG, + "--strategy", + X7_STRATEGY, + "--json", + ] + + # When: the operator requests machine-readable inspection. + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + + # Then: the payload exposes evidence-bound status without claiming live readiness. + assert result.returncode == 0, result.stderr + payload = STRATEGY_INSPECT_ADAPTER.validate_json(result.stdout) + status = payload["x7_semantic_status"] + assert status is not None + assert status["enabled"] is True + assert status["observed_upstream_version"] == "v17.4.258" + assert status["coverage_state"] == "verified" + assert status["latest_signal_reason"] == "no_runtime_signal_observed" + assert status["warmup_state"] == "not_observed" + assert status["missing_data_state"] == "no_dashboard_data" + assert status["live_readiness"] == "gated" + + +async def test_runtime_health_api_includes_x7_semantic_status() -> None: + # Given: the X7 paper/testnet app with no dashboard history yet. + settings = load_runtime_settings(PROJECT_ROOT / X7_CONFIG) + async with _client( + create_app(settings=settings, dashboard_store=StaticDashboardReadStore()), + ) as client: + # When: the operator reads runtime health. + response = await client.get("/api/v1/runtime/health") + + # Then: the API includes X7 semantic status and degraded data wording. + payload = RuntimeHealthResponse.model_validate_json(response.content) + assert response.status_code == 200 + assert payload.x7_semantic_status.enabled is True + assert payload.x7_semantic_status.observed_upstream_version == "v17.4.258" + assert payload.x7_semantic_status.latest_signal_reason == "no_runtime_signal_observed" + assert payload.x7_semantic_status.missing_data_state == "no_dashboard_data" + assert payload.x7_semantic_status.live_readiness == "gated" + assert "live_ready" not in response.text + + +async def test_home_ui_renders_x7_semantic_status_without_live_ready_claim() -> None: + # Given: the X7 operator Home surface. + settings = load_runtime_settings(PROJECT_ROOT / X7_CONFIG) + async with _client( + create_app(settings=settings, dashboard_store=StaticDashboardReadStore()), + ) as client: + # When: Home is rendered. + response = await client.get("/") + + # Then: the local UI shows status, provenance, and blocked/gated wording. + assert response.status_code == 200 + assert 'data-testid="x7-semantic-status"' in response.text + assert "NFI X7 semantic status" in response.text + assert "v17.4.258" in response.text + assert "no_runtime_signal_observed" in response.text + assert "no_dashboard_data" in response.text + assert "Live ready" not in response.text + x7_section = response.text.split('data-testid="x7-semantic-status"', maxsplit=1)[1] + x7_section = x7_section.split("", maxsplit=1)[0] + assert "100%" not in x7_section + + +def _client(app: FastAPI) -> AsyncClient: + return AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") diff --git a/tests/fixtures/candles/btc_usdt_usdt_futures_x7_exit_5m.jsonl b/tests/fixtures/candles/btc_usdt_usdt_futures_x7_exit_5m.jsonl new file mode 100644 index 0000000..6bf0b9d --- /dev/null +++ b/tests/fixtures/candles/btc_usdt_usdt_futures_x7_exit_5m.jsonl @@ -0,0 +1,3 @@ +{"pair":"BTC/USDT:USDT","timeframe":"5m","opened_at":"2026-01-01T00:00:00Z","open":"100","high":"100","low":"100","close":"100","volume":"5.0"} +{"pair":"BTC/USDT:USDT","timeframe":"5m","opened_at":"2026-01-01T00:05:00Z","open":"101","high":"101","low":"101","close":"101","volume":"5.0"} +{"pair":"BTC/USDT:USDT","timeframe":"5m","opened_at":"2026-01-01T00:10:00Z","open":"100.4","high":"100.4","low":"100.4","close":"100.4","volume":"5.0"} diff --git a/tests/fixtures/config/x7-futures-risk-blocked.yaml b/tests/fixtures/config/x7-futures-risk-blocked.yaml new file mode 100644 index 0000000..26fd443 --- /dev/null +++ b/tests/fixtures/config/x7-futures-risk-blocked.yaml @@ -0,0 +1,45 @@ +engine: + environment: local + live_trading: false + live_trading_confirmed: false +exchange: + name: simulator + trading_mode: futures + margin_mode: isolated + testnet: true +strategy: + name: X7NativeStrategy + module: nfi_engine.strategy.nfi_x7:X7NativeStrategy +database: + url: sqlite+aiosqlite:///data/nfi_engine.sqlite3 +risk: + stake_usdt: "10" + max_daily_loss_pct: "0.05" + leverage: "20" + max_leverage: "20" + liquidation_buffer: "0.05" + max_open_trades: "3" + stoploss_pct: "0.10" + minimal_roi: "0.03" + cooldown_seconds: 0 + locked_pairs: "" +backtest: + timerange: null + starting_balance_usdt: "1000" + stoploss_pct: "0.10" + fee_rate: "0.001" + slippage_rate: "0" + max_open_trades: "3" +paper_run: + enabled: true + max_events: 100 +api: + host: 127.0.0.1 + port: 18080 + csrf_enabled: true +ui: + enabled: true + read_only: false +logging: + level: INFO + json_logs: false diff --git a/tests/fixtures/strategies/nfi_shape.py b/tests/fixtures/strategies/nfi_shape.py index 9c15c8d..243f07b 100644 --- a/tests/fixtures/strategies/nfi_shape.py +++ b/tests/fixtures/strategies/nfi_shape.py @@ -95,3 +95,8 @@ def populate_exit_trend( _metadata: StrategyMetadata, ) -> StrategyFrame: return dataframe + + +class UnsupportedCallbackStrategy(NFISmokeStrategy): + def custom_entry_price(self) -> str: + return "unsupported" diff --git a/tests/fixtures/ticks/stale_stream.jsonl b/tests/fixtures/ticks/stale_stream.jsonl index 7d8fea5..c7259c6 100644 --- a/tests/fixtures/ticks/stale_stream.jsonl +++ b/tests/fixtures/ticks/stale_stream.jsonl @@ -1,3 +1,3 @@ {"pair":"BTC/USDT:USDT","at":"2026-01-01T00:00:00+00:00","price":"100","signal_side":"long"} -{"pair":"BTC/USDT:USDT","at":"2026-01-01T00:01:00+00:00","price":"101","signal_side":null} -{"pair":"BTC/USDT:USDT","at":"2026-01-01T00:10:00+00:00","price":"110","signal_side":"short"} +{"pair":"BTC/USDT:USDT","at":"2026-01-01T00:01:00+00:00","price":"100","signal_side":null} +{"pair":"BTC/USDT:USDT","at":"2026-01-01T00:10:00+00:00","price":"101","signal_side":"short"} diff --git a/tests/integration/exchange/test_binance_wallet_adapter.py b/tests/integration/exchange/test_binance_wallet_adapter.py new file mode 100644 index 0000000..d667475 --- /dev/null +++ b/tests/integration/exchange/test_binance_wallet_adapter.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import hmac +from collections.abc import Callable +from datetime import UTC, datetime +from decimal import Decimal +from hashlib import sha256 +from urllib.parse import urlencode + +import httpx +import pytest + +from nfi_engine.config import ExchangeSettings, RuntimeSettings +from nfi_engine.domain import AccountSnapshot, TradingMode +from nfi_engine.exchange.binance import BinanceFuturesBalanceAdapter +from nfi_engine.exchange.errors import ExchangeError, ExchangeErrorCode + +pytestmark = pytest.mark.anyio + +FIXED_TIMESTAMP_MS = 1_700_000_000_000 +QUOTE_ASSET = "USDT" +API_KEY = "test-binance-key" +SIGNING_KEY = "test-binance-signing-key" + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +async def test_binance_futures_adapter_fetches_quote_balance_read_only() -> None: + # Given: a signed Binance futures balance adapter with a wire-level fake. + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json=[ + {"asset": "BTC", "balance": "0.25", "availableBalance": "0.20"}, + {"asset": "USDT", "balance": "1234.50", "availableBalance": "1200.25"}, + ], + ) + + async with _client(handler) as client: + adapter = BinanceFuturesBalanceAdapter( + api_key=API_KEY, + api_secret=SIGNING_KEY, + quote_asset=QUOTE_ASSET, + client=client, + timestamp_ms=_fixed_timestamp_ms, + ) + + # When: the read-only wallet snapshot is fetched. + snapshot = await adapter.fetch_balance() + + # Then: only the signed balance endpoint is called and the quote asset is normalized. + assert isinstance(snapshot, AccountSnapshot) + assert snapshot.captured_at == datetime.fromtimestamp(FIXED_TIMESTAMP_MS / 1000, tz=UTC) + assert snapshot.equity == Decimal("1234.50") + assert snapshot.available == Decimal("1200.25") + assert snapshot.positions == () + assert len(requests) == 1 + request = requests[0] + assert request.method == "GET" + assert request.url.path == "/fapi/v3/balance" + assert request.headers["X-MBX-APIKEY"] == API_KEY + assert request.url.params["recvWindow"] == "5000" + assert request.url.params["timestamp"] == str(FIXED_TIMESTAMP_MS) + assert request.url.params["signature"] == _signature( + (("recvWindow", "5000"), ("timestamp", str(FIXED_TIMESTAMP_MS))), + ) + assert SIGNING_KEY not in str(request.url) + + +def test_binance_futures_adapter_uses_testnet_base_url_from_settings() -> None: + # Given: Binance futures settings are explicitly scoped to testnet. + settings = RuntimeSettings( + exchange=ExchangeSettings( + name="binance", + trading_mode=TradingMode.FUTURES, + testnet=True, + api_key=API_KEY, + api_secret=SIGNING_KEY, + ), + ) + + # When: the wallet adapter is created from runtime settings. + adapter = BinanceFuturesBalanceAdapter.from_settings(settings=settings) + + # Then: no wallet-balance call can default to the production futures REST URL. + assert adapter.base_url == "https://testnet.binancefuture.com" + + +def test_binance_futures_adapter_defaults_to_testnet_base_url() -> None: + # Given / When: the adapter is constructed without an explicit network base URL. + adapter = BinanceFuturesBalanceAdapter( + api_key=API_KEY, + api_secret=SIGNING_KEY, + quote_asset=QUOTE_ASSET, + ) + + # Then: direct construction is still testnet-safe by default. + assert adapter.base_url == "https://testnet.binancefuture.com" + + +async def test_binance_futures_adapter_rejects_missing_quote_asset() -> None: + # Given: Binance responds without the configured quote asset row. + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=[{"asset": "BTC", "balance": "0.25"}]) + + async with _client(handler) as client: + adapter = BinanceFuturesBalanceAdapter( + api_key=API_KEY, + api_secret=SIGNING_KEY, + quote_asset=QUOTE_ASSET, + client=client, + timestamp_ms=_fixed_timestamp_ms, + ) + + # When / Then: the adapter reports a typed response-shape failure. + with pytest.raises(ExchangeError) as exc_info: + await adapter.fetch_balance() + assert exc_info.value.code is ExchangeErrorCode.EXCHANGE_RESPONSE_INVALID + + +async def test_binance_futures_adapter_maps_auth_http_error() -> None: + # Given: Binance rejects the key or IP allowlist. + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"code": -2015, "msg": "Invalid API-key."}) + + async with _client(handler) as client: + adapter = BinanceFuturesBalanceAdapter( + api_key=API_KEY, + api_secret=SIGNING_KEY, + quote_asset=QUOTE_ASSET, + client=client, + timestamp_ms=_fixed_timestamp_ms, + ) + + # When / Then: auth failures are machine-coded without leaking credentials. + with pytest.raises(ExchangeError) as exc_info: + await adapter.fetch_balance() + assert exc_info.value.code is ExchangeErrorCode.EXCHANGE_AUTH_FAILED + assert API_KEY not in exc_info.value.message + assert SIGNING_KEY not in exc_info.value.message + + +def _fixed_timestamp_ms() -> int: + return FIXED_TIMESTAMP_MS + + +def _signature(params: tuple[tuple[str, str], ...]) -> str: + payload = urlencode(params) + return hmac.new(SIGNING_KEY.encode(), payload.encode(), sha256).hexdigest() + + +def _client(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.AsyncClient: + transport = httpx.MockTransport(handler) + return httpx.AsyncClient(transport=transport, base_url="https://fapi.binance.com") diff --git a/tests/integration/exchange/test_bybit_adapter.py b/tests/integration/exchange/test_bybit_adapter.py index ae394da..4bea975 100644 --- a/tests/integration/exchange/test_bybit_adapter.py +++ b/tests/integration/exchange/test_bybit_adapter.py @@ -22,7 +22,12 @@ ExchangeErrorCode, ExchangeOrderRequest, ) -from nfi_engine.exchange.bybit import BybitTestnetAdapter, CcxtFundingPayload, CcxtOrderPayload +from nfi_engine.exchange.bybit import ( + BybitTestnetAdapter, + CcxtBalancePayload, + CcxtFundingPayload, + CcxtOrderPayload, +) pytestmark = pytest.mark.anyio @@ -68,6 +73,132 @@ async def test_bybit_adapter_maps_order_to_ccxt_testnet_client() -> None: assert order.live_exchange is False +async def test_bybit_adapter_executes_testnet_order_lifecycle() -> None: + # Given + settings = load_runtime_settings(Path("examples/futures-paper.yaml")) + client = FakeCcxtClient(response_status="open", funding_rate=Decimal("0.0001")) + adapter = BybitTestnetAdapter.from_settings(settings=settings, client=client) + pair = TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES) + request = ExchangeOrderRequest( + pair=pair, + side=PositionSide.LONG, + order_type=OrderType.LIMIT, + quantity=Quantity(Decimal("0.25")), + price=Price(Decimal(99)), + leverage=Leverage.parse("3"), + ) + + # When + created = await adapter.create_order(request) + fetched = await adapter.fetch_order(created.order_id, pair) + canceled = await adapter.cancel_order(created.order_id, pair) + leverage = await adapter.set_leverage(pair=pair, leverage=Leverage.parse("3")) + funding = await adapter.fetch_funding_rate(pair) + + # Then + assert client.sandbox_mode is True + assert created.state is OrderState.OPEN + assert fetched.state is OrderState.OPEN + assert canceled.state is OrderState.CANCELED + assert leverage == Leverage.parse("3") + assert funding.supported is True + assert funding.rate == Decimal("0.0001") + assert created.live_exchange is False + assert canceled.live_exchange is False + + +async def test_bybit_adapter_maps_partial_and_rejected_order_states() -> None: + # Given + settings = load_runtime_settings(Path("examples/futures-paper.yaml")) + partial_client = FakeCcxtClient(response_status="partially_filled") + rejected_client = FakeCcxtClient(response_status="rejected") + pair = TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES) + request = ExchangeOrderRequest( + pair=pair, + side=PositionSide.SHORT, + order_type=OrderType.LIMIT, + quantity=Quantity(Decimal("0.25")), + price=Price(Decimal(101)), + leverage=Leverage.parse("2"), + ) + + # When + partial = await BybitTestnetAdapter.from_settings( + settings=settings, + client=partial_client, + ).create_order(request) + rejected = await BybitTestnetAdapter.from_settings( + settings=settings, + client=rejected_client, + ).create_order(request) + + # Then + assert partial.state is OrderState.PARTIALLY_FILLED + assert rejected.state is OrderState.REJECTED + assert partial.live_exchange is False + assert rejected.live_exchange is False + + +async def test_bybit_adapter_fetches_quote_balance_read_only() -> None: + # Given + settings = load_runtime_settings(Path("examples/futures-paper.yaml")) + client = FakeCcxtClient() + adapter = BybitTestnetAdapter.from_settings(settings=settings, client=client) + + # When + balance = await adapter.fetch_balance() + + # Then + assert client.sandbox_mode is True + assert balance.equity == Decimal("1234.5") + assert balance.available == Decimal("1200.25") + assert balance.positions == () + + +async def test_bybit_adapter_rejects_unknown_ccxt_order_status() -> None: + # Given + settings = load_runtime_settings(Path("examples/futures-paper.yaml")) + client = FakeCcxtClient(response_status="unknown-status") + adapter = BybitTestnetAdapter.from_settings(settings=settings, client=client) + request = ExchangeOrderRequest( + pair=TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES), + side=PositionSide.LONG, + order_type=OrderType.LIMIT, + quantity=Quantity(Decimal("0.25")), + price=Price(Decimal(101)), + leverage=Leverage.parse("2"), + ) + + # When / Then + with pytest.raises(ExchangeError) as exc_info: + await adapter.create_order(request) + assert exc_info.value.code is ExchangeErrorCode.ORDER_PAYLOAD_INVALID + + +async def test_bybit_adapter_rejects_unknown_ccxt_order_side() -> None: + # Given + settings = load_runtime_settings(Path("examples/futures-paper.yaml")) + client = FakeCcxtClient() + client.orders["bad-side"] = CcxtOrderPayload( + id="bad-side", + symbol="BTC/USDT:USDT", + side="hold", + type="limit", + status="open", + amount="0.25", + average=None, + ) + adapter = BybitTestnetAdapter.from_settings(settings=settings, client=client) + + # When / Then + with pytest.raises(ExchangeError) as exc_info: + await adapter.fetch_order( + "bad-side", + TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES), + ) + assert exc_info.value.code is ExchangeErrorCode.ORDER_PAYLOAD_INVALID + + async def test_bybit_adapter_uses_unsupported_funding_fallback() -> None: # Given settings = load_runtime_settings(Path("examples/futures-paper.yaml")) @@ -88,6 +219,8 @@ class FakeCcxtClient: sandbox_mode: bool = False created_orders: tuple[tuple[str, str, str, str, str | None], ...] = () checked_symbols: tuple[str, ...] = () + response_status: str = "closed" + funding_rate: Decimal | None = None orders: dict[str, CcxtOrderPayload] = field(default_factory=dict) def set_sandbox_mode(self, enabled: bool) -> None: @@ -107,7 +240,7 @@ async def create_order( symbol=symbol, side=side, type=order_type, - status="closed", + status=self.response_status, amount=amount, average="100", ) @@ -125,7 +258,9 @@ async def fetch_order(self, order_id: str, symbol: str) -> CcxtOrderPayload: return self.orders[order_id] async def fetch_funding_rate(self, symbol: str) -> CcxtFundingPayload: - raise NotImplementedError(symbol) + if self.funding_rate is None: + raise NotImplementedError(symbol) + return CcxtFundingPayload(symbol=symbol, fundingRate=str(self.funding_rate)) async def set_leverage(self, leverage: int, symbol: str) -> CcxtOrderPayload: return CcxtOrderPayload( @@ -137,3 +272,9 @@ async def set_leverage(self, leverage: int, symbol: str) -> CcxtOrderPayload: amount=str(leverage), average=None, ) + + async def fetch_balance(self) -> CcxtBalancePayload: + return CcxtBalancePayload( + total={"USDT": "1234.5"}, + free={"USDT": "1200.25"}, + ) diff --git a/tests/integration/persistence/test_dashboard_repository_lists.py b/tests/integration/persistence/test_dashboard_repository_lists.py index 9a91215..2e539b0 100644 --- a/tests/integration/persistence/test_dashboard_repository_lists.py +++ b/tests/integration/persistence/test_dashboard_repository_lists.py @@ -153,6 +153,27 @@ async def test_create_app_dashboard_reads_seeded_persistence_rows( ) +async def test_persistence_dashboard_store_initializes_database_once( + database: PersistenceDatabase, + monkeypatch: pytest.MonkeyPatch, +) -> None: + initialize_calls = 0 + original_initialize = PersistenceDatabase.initialize + + async def counted_initialize(self: PersistenceDatabase) -> None: + nonlocal initialize_calls + initialize_calls += 1 + await original_initialize(self) + + monkeypatch.setattr(PersistenceDatabase, "initialize", counted_initialize) + store = PersistenceDashboardReadStore(database) + + await store.read_models() + await store.read_models() + + assert initialize_calls == 1 + + def _trade(trade_id: str, state: TradeState, opened_at: datetime) -> TradeRecord: closed_at, exit_price, profit = _trade_close_values(state, opened_at) return TradeRecord( diff --git a/tests/integration/test_strategy_timeline_equivalence.py b/tests/integration/test_strategy_timeline_equivalence.py new file mode 100644 index 0000000..3b70779 --- /dev/null +++ b/tests/integration/test_strategy_timeline_equivalence.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from pathlib import Path +from typing import Final + +import pytest + +from nfi_engine.backtest import ( + BacktestRequest, + ReproducibilityMetadata, + SimulationSettings, + run_backtest, +) +from nfi_engine.config import load_runtime_settings +from nfi_engine.data import CandleBatch +from nfi_engine.domain import Candle, PositionSide, Price, Quantity, TradingMode, TradingPair +from nfi_engine.paper import PaperRunRequest, PaperTick, run_paper +from nfi_engine.strategy import ( + FreqtradeStrategyAdapter, + SignalColumns, + StrategyFrame, + StrategyMetadata, + StrategyTimeline, +) + +pytestmark = pytest.mark.anyio + +NOW: Final = datetime(2026, 1, 1, tzinfo=UTC) +ONE: Final = Decimal(1) +TEN: Final = Decimal(10) +ONE_HUNDRED: Final = Decimal(100) +ONE_HUNDRED_FIVE: Final = Decimal(105) +ONE_HUNDRED_TEN: Final = Decimal(110) +ONE_THOUSAND: Final = Decimal(1000) + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +async def test_backtest_and_paper_share_clean_room_signal_timeline(tmp_path: Path) -> None: + # Given + backtest_result = run_backtest(_backtest_request()) + paper_result = await run_paper(_paper_request(tmp_path, timeline=backtest_result.timeline)) + + # When + backtest_entries = sum(step.opened_orders for step in backtest_result.timeline.steps) + paper_entries = sum(step.opened_orders for step in paper_result.timeline.steps) + backtest_blocks = sum(step.blocked_actions for step in backtest_result.timeline.steps) + paper_blocks = sum(step.blocked_actions for step in paper_result.timeline.steps) + backtest_first = backtest_result.timeline.steps[0] + paper_first = paper_result.timeline.steps[0] + + # Then + assert backtest_result.summary.total_trades == paper_result.created_trades == 1 + assert backtest_entries == paper_entries == 1 + assert backtest_blocks == paper_blocks == 0 + assert backtest_first.at == paper_first.at + assert backtest_first.entry_signals == paper_first.entry_signals + assert backtest_first.entry_sides == paper_first.entry_sides + + +def _backtest_request() -> BacktestRequest: + pair = TradingPair.parse("BTC/USDT", TradingMode.SPOT) + return BacktestRequest( + candles=_batch(pair=pair), + adapter=FreqtradeStrategyAdapter.from_strategy(_EquivalenceLongStrategy()), + settings=SimulationSettings( + trading_mode=TradingMode.SPOT, + starting_balance=ONE_THOUSAND, + stake_amount=TEN, + fee_rate=Decimal(0), + slippage_rate=Decimal(0), + max_open_trades=1, + leverage=ONE, + liquidation_buffer=Decimal("0.05"), + stoploss_pct=Decimal("0.10"), + ), + config_digest="integration-digest", + strategy_name="LongExitStrategy", + metadata=ReproducibilityMetadata( + config_hash="integration-digest", + strategy_hash="clean-room-long-exit", + data_hash="three-candle-fixture", + engine_version="0.1.0", + git_commit=None, + dependency_lock_hash="integration-lock", + python_version="3.12.0", + created_at=NOW, + command_args=("integration", "timeline-equivalence"), + ), + ) + + +class _EquivalenceLongStrategy: + timeframe: str = "5m" + can_short: bool = False + + def populate_indicators( + self, + dataframe: StrategyFrame, + metadata: StrategyMetadata, + ) -> StrategyFrame: + if metadata.timeframe != self.timeframe: + return dataframe + return dataframe + + def populate_entry_trend( + self, + dataframe: StrategyFrame, + metadata: StrategyMetadata, + ) -> StrategyFrame: + if metadata.timeframe != self.timeframe: + return dataframe + if dataframe.last_visible_row().date == NOW.isoformat(): + return dataframe.with_signal( + index=-1, + columns=SignalColumns(enter_long=True, enter_tag="integration-long"), + ) + return dataframe + + def populate_exit_trend( + self, + dataframe: StrategyFrame, + metadata: StrategyMetadata, + ) -> StrategyFrame: + if metadata.timeframe != self.timeframe: + return dataframe + exit_at = (NOW + timedelta(minutes=10)).isoformat() + if dataframe.last_visible_row().date == exit_at: + return dataframe.with_signal(index=-1, columns=SignalColumns(exit_long=True)) + return dataframe + + +def _batch(*, pair: TradingPair) -> CandleBatch: + prices = (ONE_HUNDRED, ONE_HUNDRED_FIVE, ONE_HUNDRED_TEN) + candles = tuple( + Candle( + pair=pair, + opened_at=NOW + timedelta(minutes=index * 5), + open=Price(price), + high=Price(price), + low=Price(price), + close=Price(price), + volume=Quantity(ONE), + ) + for index, price in enumerate(prices) + ) + return CandleBatch(pair=pair, timeframe="5m", candles=candles) + + +def _paper_request(tmp_path: Path, *, timeline: StrategyTimeline) -> PaperRunRequest: + pair = TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES) + ticks = ( + PaperTick( + pair=pair, + at=NOW, + price=Price(ONE_HUNDRED), + signal_side=_first_entry_side(timeline), + ), + PaperTick( + pair=pair, + at=NOW + timedelta(minutes=5), + price=Price(ONE_HUNDRED_FIVE), + signal_side=None, + ), + PaperTick( + pair=pair, + at=NOW + timedelta(minutes=10), + price=Price(ONE_HUNDRED_TEN), + signal_side=None, + ), + ) + return PaperRunRequest( + settings=load_runtime_settings(Path("examples/futures-paper.yaml")), + ticks=ticks, + max_events=3, + database_url=f"sqlite+aiosqlite:///{tmp_path / 'paper.sqlite'}", + ) + + +def _first_entry_side(timeline: StrategyTimeline) -> PositionSide | None: + for step in timeline.steps: + if len(step.entry_sides) > 0: + return step.entry_sides[0] + return None diff --git a/tests/unit/api/test_app.py b/tests/unit/api/test_app.py index ba0d9f8..354809d 100644 --- a/tests/unit/api/test_app.py +++ b/tests/unit/api/test_app.py @@ -16,7 +16,6 @@ ErrorLookupResponse, LogListResponse, PingResponse, - StateResponse, SupportBundleResponse, ) from nfi_engine.api.settings import validate_api_auth_settings @@ -43,6 +42,13 @@ class SessionPayload(BaseModel): expires_at: datetime +class RuntimeControlPayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + state: str + new_entries_allowed: bool + + @pytest.mark.anyio async def test_ping_is_public_when_no_token_is_sent() -> None: # Given: an API app with a configured operator token. @@ -78,15 +84,29 @@ async def test_control_endpoints_apply_state_transitions_when_authorized() -> No session = await _login(client) headers = _csrf_headers(session) - # When: start, pause, and stop commands are issued. + # When: start, pause, resume, and stop commands are issued. started = await client.post("/api/v1/start", headers=headers) paused = await client.post("/api/v1/pause", headers=headers) + resumed = await client.post("/api/v1/resume", headers=headers) stopped = await client.post("/api/v1/stop", headers=headers) - - # Then: each command returns the observable bot state. - assert StateResponse.model_validate_json(started.content).state == "running" - assert StateResponse.model_validate_json(paused.content).state == "paused" - assert StateResponse.model_validate_json(stopped.content).state == "stopped" + current = await client.get("/api/v1/runtime/control", headers=_auth_headers()) + + # Then: each command returns the observable runtime-control state contract. + assert RuntimeControlPayload.model_validate_json(started.content).state == "running" + assert RuntimeControlPayload.model_validate_json(started.content).new_entries_allowed is True + assert RuntimeControlPayload.model_validate_json(paused.content).state == "paused" + assert RuntimeControlPayload.model_validate_json(paused.content).new_entries_allowed is False + assert RuntimeControlPayload.model_validate_json(resumed.content).state == "running" + assert RuntimeControlPayload.model_validate_json(resumed.content).new_entries_allowed is True + assert RuntimeControlPayload.model_validate_json(stopped.content).state in { + "stopping", + "stopped", + } + assert RuntimeControlPayload.model_validate_json(stopped.content).new_entries_allowed is False + assert RuntimeControlPayload.model_validate_json(current.content).state in { + "stopping", + "stopped", + } @pytest.mark.anyio diff --git a/tests/unit/api/test_data_lifecycle_routes.py b/tests/unit/api/test_data_lifecycle_routes.py new file mode 100644 index 0000000..f01b45d --- /dev/null +++ b/tests/unit/api/test_data_lifecycle_routes.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar + +import pytest +from httpx import ASGITransport, AsyncClient +from pydantic import BaseModel, ConfigDict + +from nfi_engine.api.app import create_app +from nfi_engine.api.auth import ApiErrorResponse +from nfi_engine.api.data_lifecycle_models import ( + DataLifecycleExportResponse, + DataLifecycleFootprintResponse, + DataLifecyclePruneReceiptResponse, +) +from nfi_engine.api.errors import ApiErrorCode +from nfi_engine.config.models import ApiSettings, DatabaseSettings, RuntimeSettings, UiSettings +from nfi_engine.maintenance.data_lifecycle import ( + DATA_LIFECYCLE_CONFIRM_SCOPE, + DATA_LIFECYCLE_CONFIRMATION_REQUIRED, +) + +if TYPE_CHECKING: + from fastapi import FastAPI + +LOCAL_BEARER = "local-test-bearer" + + +class ErrorEnvelope(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + detail: ApiErrorResponse + + +class SessionPayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + role: str + csrf_token: str + expires_at: datetime + + +@pytest.mark.anyio +async def test_data_lifecycle_footprint_and_export_are_redacted(tmp_path: Path) -> None: + # Given: an authorized operator client with temp runtime data. + settings = _settings(tmp_path) + _runtime_file(tmp_path, "engine.sqlite3", b"sqlite") + _runtime_file(tmp_path, "logs/engine.log", b"log") + _runtime_file(tmp_path, "backups/backup.zip", b"backup") + _runtime_file(tmp_path, "support-bundles/support.zip", b"support") + _runtime_file(tmp_path, "evidence/operator.json", b"evidence") + client = _client(create_app(settings=settings, config_path=tmp_path / "config.yaml")) + + # When: footprint and export endpoints are read. + footprint = await client.get("/api/v1/data-lifecycle/footprint", headers=_auth_headers()) + export = await client.get("/api/v1/data-lifecycle/export", headers=_auth_headers()) + footprint_payload = DataLifecycleFootprintResponse.model_validate_json(footprint.content) + export_payload = DataLifecycleExportResponse.model_validate_json(export.content) + merged = export.text + + # Then: category totals are observable and secrets do not leak. + assert footprint.status_code == 200 + assert footprint_payload.total_bytes > 0 + assert {category.name for category in footprint_payload.categories} == { + "sqlite", + "logs", + "backups", + "support_bundles", + "evidence", + } + assert export.status_code == 200 + assert export_payload.receipt_id.startswith("data-export-") + assert "operator-token-fixture" not in merged + assert "https://hooks.example.invalid/raw-token" not in merged + + +@pytest.mark.anyio +async def test_data_lifecycle_prune_requires_csrf_and_preview_token(tmp_path: Path) -> None: + # Given: a logged-in operator and a safe temp runtime artifact. + client = _client(create_app(settings=_settings(tmp_path), config_path=tmp_path / "config.yaml")) + session = await _login(client) + old_log = _runtime_file(tmp_path, "logs/old.log", b"old") + + # When: prune is called through dry-run, missing CSRF, malformed, and apply paths. + dry_run = await client.post( + "/api/v1/data-lifecycle/prune", + headers=_csrf_headers(session), + json={"dry_run": True, "apply": False, "retention_days": 0}, + ) + dry_run_payload = DataLifecyclePruneReceiptResponse.model_validate_json(dry_run.content) + missing_csrf = await client.post( + "/api/v1/data-lifecycle/prune", + headers=_auth_headers(), + json={"dry_run": True, "apply": False}, + ) + malformed = await client.post( + "/api/v1/data-lifecycle/prune", + headers=_csrf_headers(session), + json={"dry_run": "yes", "retention_days": "now"}, + ) + apply_without_token = await client.post( + "/api/v1/data-lifecycle/prune", + headers=_csrf_headers(session), + json={ + "dry_run": False, + "apply": True, + "retention_days": 0, + "confirm_scope": DATA_LIFECYCLE_CONFIRM_SCOPE, + }, + ) + apply_without_confirmation = await client.post( + "/api/v1/data-lifecycle/prune", + headers=_csrf_headers(session), + json={ + "dry_run": False, + "apply": True, + "retention_days": 0, + "preview_token": dry_run_payload.preview_token, + }, + ) + csrf_error = ErrorEnvelope.model_validate_json(missing_csrf.content) + blocked_payload = DataLifecyclePruneReceiptResponse.model_validate_json( + apply_without_token.content, + ) + confirmation_payload = DataLifecyclePruneReceiptResponse.model_validate_json( + apply_without_confirmation.content, + ) + + # Then: only preview is accepted and mutation requires the shared write gates. + assert dry_run.status_code == 200 + assert dry_run_payload.accepted is True + assert dry_run_payload.mutation_applied is False + assert dry_run_payload.preview_token != "" + assert missing_csrf.status_code == 403 + assert csrf_error.detail.code is ApiErrorCode.CSRF_TOKEN_REQUIRED + assert malformed.status_code == 422 + assert apply_without_token.status_code == 200 + assert blocked_payload.accepted is False + assert "preview_token_required" in blocked_payload.blocked_reasons + assert apply_without_confirmation.status_code == 200 + assert confirmation_payload.accepted is False + assert DATA_LIFECYCLE_CONFIRMATION_REQUIRED in confirmation_payload.blocked_reasons + assert old_log.exists() + + successful_apply = await client.post( + "/api/v1/data-lifecycle/prune", + headers=_csrf_headers(session), + json={ + "dry_run": False, + "apply": True, + "retention_days": 0, + "preview_token": dry_run_payload.preview_token, + "confirm_scope": DATA_LIFECYCLE_CONFIRM_SCOPE, + }, + ) + apply_payload = DataLifecyclePruneReceiptResponse.model_validate_json(successful_apply.content) + + assert successful_apply.status_code == 200 + assert apply_payload.accepted is True + assert apply_payload.mutation_applied is True + assert apply_payload.deleted_count == 1 + assert not old_log.exists() + + +@pytest.mark.anyio +async def test_data_lifecycle_prune_is_blocked_in_read_only_mode(tmp_path: Path) -> None: + # Given: a read-only console session. + client = _client( + create_app( + settings=_settings(tmp_path, read_only=True), + config_path=tmp_path / "config.yaml", + ), + ) + session = await _login(client) + + # When: inspection and prune are requested. + footprint = await client.get("/api/v1/data-lifecycle/footprint", headers=_auth_headers()) + prune = await client.post( + "/api/v1/data-lifecycle/prune", + headers=_csrf_headers(session), + json={"dry_run": True, "apply": False}, + ) + error = ErrorEnvelope.model_validate_json(prune.content) + + # Then: read-only mode allows inspection but blocks write-router actions. + assert footprint.status_code == 200 + assert prune.status_code == 403 + assert error.detail.code is ApiErrorCode.READONLY_ACTION_BLOCKED + + +def _settings(root: Path, *, read_only: bool = False) -> RuntimeSettings: + return RuntimeSettings( + database=DatabaseSettings(url=f"sqlite+aiosqlite:///{root / 'engine.sqlite3'}"), + api=ApiSettings( + auth_token=LOCAL_BEARER, + session_ttl_seconds=1800, + ), + ui=UiSettings(read_only=read_only), + ) + + +def _runtime_file(root: Path, relative: str, data: bytes) -> Path: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + +def _auth_headers() -> dict[str, str]: + return {"Authorization": f"Bearer {LOCAL_BEARER}"} + + +def _csrf_headers(session: SessionPayload) -> dict[str, str]: + return { + "Authorization": f"Bearer {LOCAL_BEARER}", + "x-nfi-csrf-token": session.csrf_token, + } + + +async def _login(client: AsyncClient) -> SessionPayload: + response = await client.post("/api/v1/session/login", headers=_auth_headers()) + assert response.status_code == 200 + return SessionPayload.model_validate_json(response.content) + + +def _client(app: FastAPI) -> AsyncClient: + return AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") diff --git a/tests/unit/api/test_support_bundle_redaction.py b/tests/unit/api/test_support_bundle_redaction.py new file mode 100644 index 0000000..2a20913 --- /dev/null +++ b/tests/unit/api/test_support_bundle_redaction.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from io import BytesIO +from zipfile import ZipFile + +from nfi_engine.api.models import LogEntryResponse, support_bundle_response +from nfi_engine.api.support_bundle import support_bundle_zip +from nfi_engine.config.enums import LogLevel +from nfi_engine.config.models import ApiSettings, ExchangeSettings, RuntimeSettings +from nfi_engine.events import REDACTED_TEXT + + +def test_support_bundle_zip_redacts_secret_values_embedded_in_logs() -> None: + # Given: runtime settings and a log entry that accidentally carries secret values. + settings = RuntimeSettings( + exchange=ExchangeSettings.model_validate( + {"api_key": "fixture-api-key", "api_secret": "fixture-api-secret"}, + ), + api=ApiSettings.model_validate({"auth_token": "fixture-api-token"}), + ) + logs = ( + LogEntryResponse( + at=datetime.now(UTC), + level=LogLevel.ERROR, + code="SUPPORT_BUNDLE_REDACTION_PROBE", + message="fixture-api-secret appeared in a diagnostic message", + correlation_id="fixture-correlation", + command="nfi-engine --token fixture-api-token", + route="/api/v1/probe/fixture-api-key", + safe_summary="fixture-api-key and fixture-api-secret in summary", + report_hint="fixture-api-token in hint", + ), + ) + + # When: a support bundle is generated from the settings and logs. + payload = support_bundle_zip(support_bundle_response(settings=settings, logs=logs)) + + # Then: every bundle member is serialized without the original secret values. + with ZipFile(BytesIO(payload)) as archive: + merged = "\n".join(archive.read(name).decode("utf-8") for name in archive.namelist()) + assert REDACTED_TEXT in merged + assert "fixture-api-key" not in merged + assert "fixture-api-secret" not in merged + assert "fixture-api-token" not in merged diff --git a/tests/unit/api/test_update_routes.py b/tests/unit/api/test_update_routes.py new file mode 100644 index 0000000..796c148 --- /dev/null +++ b/tests/unit/api/test_update_routes.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar + +import pytest +from httpx import ASGITransport, AsyncClient +from pydantic import BaseModel, ConfigDict + +from nfi_engine.api.app import create_app +from nfi_engine.api.auth import ApiErrorResponse +from nfi_engine.api.errors import ApiErrorCode +from nfi_engine.api.update_models import UpdatePreviewResponse, UpdateProofReceiptResponse +from nfi_engine.config.models import ApiSettings, EngineSettings, RuntimeSettings, UiSettings + +if TYPE_CHECKING: + from fastapi import FastAPI + +LOCAL_BEARER = "local-test-bearer" + + +class ErrorEnvelope(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + detail: ApiErrorResponse + + +class SessionPayload(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + role: str + csrf_token: str + expires_at: datetime + + +@pytest.mark.anyio +async def test_update_preview_returns_local_provenance_contract() -> None: + # Given: an authorized operator client backed by a local config file. + client = _client( + create_app(settings=_settings(), config_path=Path("examples/futures-paper.yaml")) + ) + + # When: the update preview endpoint is called through the protected read surface. + response = await client.get("/api/v1/update/preview", headers=_auth_headers()) + payload = UpdatePreviewResponse.model_validate_json(response.content) + + # Then: the preview exposes a local-only provenance summary. + assert response.status_code == 200 + assert payload.provenance_verified is True + assert payload.remote_network_allowed is False + assert payload.live_blocked is False + assert payload.workspace_state in {"clean", "dirty", "unavailable"} + assert isinstance(payload.workspace_dirty, bool) + assert payload.strategy_name == "AdapterSmokeStrategy" + assert payload.rollback_state.status == "backup_required" + + +@pytest.mark.anyio +async def test_update_preview_uses_env_config_path_for_uvicorn_factory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given: the same config path style used by `nfi-engine serve`. + config_path = Path("examples/futures-paper.yaml") + monkeypatch.setenv("NFI_ENGINE_CONFIG", str(config_path)) + client = _client(create_app()) + + # When: the uvicorn-factory app builds a local update preview. + response = await client.get("/api/v1/update/preview") + payload = UpdatePreviewResponse.model_validate_json(response.content) + + # Then: provenance uses the real config file instead of runtime-only fallback. + assert response.status_code == 200 + assert payload.config_source == str(config_path) + assert payload.provenance_verified is True + assert payload.live_blocked is False + + +@pytest.mark.anyio +async def test_update_apply_returns_blocked_receipt_without_backup_reference() -> None: + # Given: an authorized operator client using runtime-only provenance. + client = _client(create_app(settings=_settings())) + session = await _login(client) + + # When: apply proof is requested without backup evidence. + response = await client.post("/api/v1/update/apply", headers=_csrf_headers(session), json={}) + payload = UpdateProofReceiptResponse.model_validate_json(response.content) + + # Then: the route returns a typed blocked receipt instead of mutating state. + assert response.status_code == 200 + assert payload.action == "apply" + assert payload.accepted is False + assert payload.source_mutated is False + assert payload.remote_network_allowed is False + assert payload.restart_required is False + assert payload.reload_required is False + assert "backup_reference_required" in payload.blocked_reasons + assert "acknowledge_unverified_required" in payload.blocked_reasons + + +@pytest.mark.anyio +async def test_update_rollback_returns_blocked_receipt_without_backup_reference() -> None: + # Given: an authorized operator client with runtime-only provenance. + client = _client(create_app(settings=_settings())) + session = await _login(client) + before = await client.get("/api/v1/config/current", headers=_auth_headers()) + + # When: rollback proof is requested without backup evidence. + response = await client.post("/api/v1/update/rollback", headers=_csrf_headers(session), json={}) + after = await client.get("/api/v1/config/current", headers=_auth_headers()) + payload = UpdateProofReceiptResponse.model_validate_json(response.content) + + # Then: rollback is blocked without mutating runtime config. + assert response.status_code == 200 + assert payload.action == "rollback" + assert payload.accepted is False + assert payload.mutation_applied is False + assert payload.source_mutated is False + assert "backup_reference_required" in payload.blocked_reasons + assert before.content == after.content + + +@pytest.mark.anyio +async def test_update_rollback_requires_csrf_on_write_route() -> None: + # Given: a logged-in operator without a CSRF header. + client = _client(create_app(settings=_settings())) + await _login(client) + + # When: rollback proof is posted without the CSRF token. + response = await client.post( + "/api/v1/update/rollback", + json={"backup_reference": "backups/local-proof.zip", "acknowledge_unverified": True}, + ) + payload = ErrorEnvelope.model_validate_json(response.content) + + # Then: the write route is rejected by the shared CSRF guard. + assert response.status_code == 403 + assert payload.detail.code is ApiErrorCode.CSRF_TOKEN_REQUIRED + + +@pytest.mark.anyio +async def test_update_apply_rejects_malformed_payload_with_422() -> None: + # Given: an authorized operator client. + client = _client(create_app(settings=_settings())) + session = await _login(client) + + # When: apply proof is called with a non-string backup reference. + response = await client.post( + "/api/v1/update/apply", + headers=_csrf_headers(session), + json={"backup_reference": 7, "acknowledge_unverified": "yes"}, + ) + + # Then: malformed input is rejected by strict request parsing. + assert response.status_code == 422 + + +@pytest.mark.anyio +async def test_update_apply_blocks_invalid_update_source_even_with_safe_overrides() -> None: + # Given: an authorized operator client with explicit backup and dirty-worktree policy. + client = _client(create_app(settings=_settings())) + session = await _login(client) + + # When: apply proof claims a source outside the local proof channel. + response = await client.post( + "/api/v1/update/apply", + headers=_csrf_headers(session), + json={ + "backup_reference": "backups/local-proof.zip", + "acknowledge_unverified": True, + "allow_dirty_worktree": True, + "update_source": "remote_plugin", + }, + ) + payload = UpdateProofReceiptResponse.model_validate_json(response.content) + + # Then: the route blocks the source policy without mutating runtime state. + assert response.status_code == 200 + assert payload.accepted is False + assert payload.source_mutated is False + assert payload.update_source == "remote_plugin" + assert "invalid_update_source" in payload.blocked_reasons + + +@pytest.mark.anyio +async def test_update_preview_remains_readable_while_read_only_blocks_apply() -> None: + # Given: a read-only local console session. + client = _client(create_app(settings=_settings(read_only=True))) + session = await _login(client) + + # When: preview and apply are both requested. + preview = await client.get("/api/v1/update/preview", headers=_auth_headers()) + apply = await client.post( + "/api/v1/update/apply", + headers=_csrf_headers(session), + json={"backup_reference": "backups/local-proof.zip", "acknowledge_unverified": True}, + ) + error = ErrorEnvelope.model_validate_json(apply.content) + + # Then: inspection still works, but mutation-like proof actions use the write gate. + assert preview.status_code == 200 + assert apply.status_code == 403 + assert error.detail.code is ApiErrorCode.READONLY_ACTION_BLOCKED + + +def _settings( + *, + bearer: str = LOCAL_BEARER, + environment: str = "local", + read_only: bool = False, +) -> RuntimeSettings: + return RuntimeSettings( + engine=EngineSettings(environment=environment), + api=ApiSettings.model_validate({"auth_token": bearer}), + ui=UiSettings(read_only=read_only), + ) + + +def _auth_headers() -> dict[str, str]: + return {"Authorization": f"Bearer {LOCAL_BEARER}"} + + +def _csrf_headers(session: SessionPayload) -> dict[str, str]: + return { + "Authorization": f"Bearer {LOCAL_BEARER}", + "x-nfi-csrf-token": session.csrf_token, + } + + +async def _login(client: AsyncClient) -> SessionPayload: + response = await client.post("/api/v1/session/login", headers=_auth_headers()) + assert response.status_code == 200 + return SessionPayload.model_validate_json(response.content) + + +def _client(app: FastAPI) -> AsyncClient: + return AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") diff --git a/tests/unit/backtest/test_frame_allocation.py b/tests/unit/backtest/test_frame_allocation.py new file mode 100644 index 0000000..4c1936d --- /dev/null +++ b/tests/unit/backtest/test_frame_allocation.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import TYPE_CHECKING, Final + +from nfi_engine.backtest import ( + BacktestRequest, + ReproducibilityMetadata, + SimulationSettings, + frames, + run_backtest, +) +from nfi_engine.data import CandleBatch +from nfi_engine.domain import Candle, Price, Quantity, TradingMode, TradingPair +from nfi_engine.strategy import FreqtradeStrategyAdapter, StrategyRow +from tests.fixtures.strategies.backtest_cases import NoSignalStrategy + +if TYPE_CHECKING: + import pytest + +CANDLE_COUNT: Final = 64 +ONE: Final = Decimal(1) +ONE_HUNDRED: Final = Decimal(100) +ONE_THOUSAND: Final = Decimal(1000) + + +def test_run_backtest_builds_strategy_rows_once_per_candle( + monkeypatch: pytest.MonkeyPatch, +) -> None: + row_builds = 0 + original_rows_for_batch = frames.strategy_rows_for_batch + + def counted_rows_for_batch(*, batch: CandleBatch) -> tuple[StrategyRow, ...]: + nonlocal row_builds + rows = original_rows_for_batch(batch=batch) + row_builds += len(rows) + return rows + + monkeypatch.setattr(frames, "strategy_rows_for_batch", counted_rows_for_batch) + + result = run_backtest(_request(candle_count=CANDLE_COUNT)) + + assert len(result.equity_curve) == CANDLE_COUNT + assert row_builds <= CANDLE_COUNT + + +def _request(*, candle_count: int) -> BacktestRequest: + strategy = NoSignalStrategy() + return BacktestRequest( + candles=_batch(candle_count=candle_count), + adapter=FreqtradeStrategyAdapter.from_strategy(strategy), + settings=SimulationSettings( + trading_mode=TradingMode.SPOT, + starting_balance=ONE_THOUSAND, + stake_amount=Decimal(10), + fee_rate=Decimal(0), + slippage_rate=Decimal(0), + max_open_trades=1, + leverage=ONE, + liquidation_buffer=Decimal("0.05"), + stoploss_pct=Decimal("0.10"), + ), + config_digest="unit-digest", + strategy_name=type(strategy).__name__, + metadata=_metadata(), + ) + + +def _batch(*, candle_count: int) -> CandleBatch: + pair = TradingPair.parse("BTC/USDT", TradingMode.SPOT) + started_at = datetime(2026, 1, 1, tzinfo=UTC) + candles = tuple( + Candle( + pair=pair, + opened_at=started_at + timedelta(minutes=index * 5), + open=Price(ONE_HUNDRED), + high=Price(ONE_HUNDRED), + low=Price(ONE_HUNDRED), + close=Price(ONE_HUNDRED), + volume=Quantity(ONE), + ) + for index in range(candle_count) + ) + return CandleBatch(pair=pair, timeframe="5m", candles=candles) + + +def _metadata() -> ReproducibilityMetadata: + return ReproducibilityMetadata( + config_hash="unit-digest", + strategy_hash="strategy-unit-hash", + data_hash="data-unit-hash", + engine_version="0.1.0", + git_commit=None, + dependency_lock_hash="lock-unit-hash", + python_version="3.12.0", + created_at=datetime(2026, 1, 1, tzinfo=UTC), + command_args=("backtest", "--config", "unit.yaml"), + ) diff --git a/tests/unit/backtest/test_runner.py b/tests/unit/backtest/test_runner.py index 6f2999c..97fe5f3 100644 --- a/tests/unit/backtest/test_runner.py +++ b/tests/unit/backtest/test_runner.py @@ -17,8 +17,12 @@ ) from nfi_engine.backtest.config import parse_timerange from nfi_engine.data import CandleBatch -from nfi_engine.domain import Candle, Price, Quantity, TradingMode, TradingPair -from nfi_engine.strategy import FreqtradeStrategyAdapter, RequiredFreqtradeStrategy +from nfi_engine.domain import Candle, PositionSide, Price, Quantity, TradingMode, TradingPair +from nfi_engine.strategy import ( + FreqtradeStrategyAdapter, + RequiredFreqtradeStrategy, + TimelineSurface, +) from tests.fixtures.strategies.backtest_cases import ( EveryCandleLongStrategy, LongExitStrategy, @@ -72,6 +76,30 @@ def test_run_backtest_closes_single_spot_long_when_exit_signal_arrives() -> None assert result.summary.total_profit == Decimal("1.0") +def test_run_backtest_records_compact_strategy_timeline() -> None: + # Given + request = _request(strategy=LongExitStrategy(), settings=_spot_settings()) + + # When + result = run_backtest(request) + + # Then + timeline = result.timeline + assert timeline.surface is TimelineSurface.BACKTEST + assert timeline.truncated is False + assert len(timeline.steps) == 3 + assert timeline.steps[0].indicator_runs == 1 + assert timeline.steps[0].entry_signals == 1 + assert timeline.steps[0].entry_sides == (PositionSide.LONG,) + assert timeline.steps[0].entry_reasons == ("unit-long",) + assert timeline.steps[0].opened_orders == 1 + assert timeline.steps[0].stake_amount == TEN + assert timeline.steps[0].leverage == ONE + assert timeline.steps[2].exit_signals == 1 + assert timeline.steps[2].exit_sides == (PositionSide.LONG,) + assert timeline.steps[2].closed_orders == 1 + + def test_run_backtest_closes_single_futures_short_when_exit_signal_arrives() -> None: # Given request = _request( @@ -185,6 +213,13 @@ def test_result_to_json_payload_includes_required_schema_sections() -> None: assert payload["metadata"]["engine_version"] == "0.1.0" assert payload["metadata"]["dependency_lock_hash"] == "lock-unit-hash" assert payload["metadata"]["created_at"] == "2026-01-01T00:00:00+00:00" + assert payload["timeline"]["surface"] == "backtest" + assert payload["timeline"]["payload_bytes"] < 2_000 + assert payload["timeline"]["steps"][0]["entry_signals"] == 1 + assert payload["timeline"]["steps"][0]["entry_sides"] == ["long"] + assert payload["timeline"]["steps"][0]["entry_reasons"] == ["unit-long"] + assert "frame" not in payload["timeline"]["steps"][0] + assert "rows" not in payload["timeline"]["steps"][0] def test_parse_timerange_raises_typed_error_when_input_is_malformed() -> None: diff --git a/tests/unit/compat/test_nfi_compat.py b/tests/unit/compat/test_nfi_compat.py index bc012d2..cf22759 100644 --- a/tests/unit/compat/test_nfi_compat.py +++ b/tests/unit/compat/test_nfi_compat.py @@ -1,6 +1,7 @@ from __future__ import annotations from nfi_engine.compat import load_nfi_metadata, run_nfi_compatibility_check +from nfi_engine.strategy import CallbackSupportLevel def test_nfi_metadata_pins_upstream_sha() -> None: @@ -24,3 +25,19 @@ def test_nfi_fixture_reports_supported_adapter_surface() -> None: assert result.full_x7_parity is False assert "populate_entry_trend" in result.detected_callbacks assert "full_x7_strategy_import" in result.unsupported_surfaces + assert "populate_indicators" in result.supported_callbacks + assert "informative_pairs" in result.partial_callbacks + assert "full_x7_strategy_import" in result.excluded_surfaces + + +def test_unknown_callback_is_excluded_from_compat_report() -> None: + # Given/When + result = run_nfi_compatibility_check( + "tests.fixtures.strategies.nfi_shape:UnsupportedCallbackStrategy", + ) + + # Then + excluded = tuple( + item for item in result.callback_support if item.level is CallbackSupportLevel.EXCLUDED + ) + assert tuple(item.name for item in excluded) == ("custom_entry_price",) diff --git a/tests/unit/config/test_runtime_settings.py b/tests/unit/config/test_runtime_settings.py index 8d91965..42ea5ec 100644 --- a/tests/unit/config/test_runtime_settings.py +++ b/tests/unit/config/test_runtime_settings.py @@ -9,6 +9,7 @@ ConfigLoadError, FieldGroup, FieldMetadata, + env_overrides, frontend_metadata, load_runtime_settings, ) @@ -119,6 +120,66 @@ def test_futures_config_requires_margin_mode(tmp_path: Path) -> None: assert exc_info.value.code is ConfigErrorCode.FUTURES_MARGIN_MODE_REQUIRED +def test_unknown_exchange_name_is_rejected_before_runtime_use(tmp_path: Path) -> None: + # Given + config_path = _write_config( + tmp_path, + ( + "exchange:", + " name: typo-exchange", + " trading_mode: spot", + ), + ) + + # When + with pytest.raises(ConfigLoadError) as exc_info: + load_runtime_settings(config_path) + + # Then + assert exc_info.value.code is ConfigErrorCode.EXCHANGE_UNSUPPORTED + assert "typo-exchange" in exc_info.value.message + + +def test_exchange_trading_mode_mismatch_is_rejected(tmp_path: Path) -> None: + # Given + config_path = _write_config( + tmp_path, + ( + "exchange:", + " name: kraken", + " trading_mode: futures", + " margin_mode: isolated", + ), + ) + + # When + with pytest.raises(ConfigLoadError) as exc_info: + load_runtime_settings(config_path) + + # Then + assert exc_info.value.code is ConfigErrorCode.EXCHANGE_TRADING_MODE_UNSUPPORTED + + +def test_exchange_margin_mode_mismatch_is_rejected(tmp_path: Path) -> None: + # Given + config_path = _write_config( + tmp_path, + ( + "exchange:", + " name: bitget", + " trading_mode: futures", + " margin_mode: cross", + ), + ) + + # When + with pytest.raises(ConfigLoadError) as exc_info: + load_runtime_settings(config_path) + + # Then + assert exc_info.value.code is ConfigErrorCode.EXCHANGE_MARGIN_MODE_UNSUPPORTED + + def test_env_override_wins_when_nested_setting_is_present( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -142,6 +203,37 @@ def test_env_override_wins_when_nested_setting_is_present( assert settings.risk.stake_usdt == 25 +def test_env_overrides_clone_config_once_for_multiple_runtime_overrides( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config: env_overrides.ConfigData = { + "risk": {"stake_usdt": "10"}, + "logging": {"level": "info"}, + } + environ = { + "NFI_ENGINE__RISK__STAKE_USDT": "25", + "NFI_ENGINE__LOGGING__LEVEL": "debug", + "NFI_ENGINE__API__AUTH_TOKEN": "local-token", + } + clone_calls = 0 + original_clone = env_overrides.clone_config + + def counted_clone(config_data: env_overrides.ConfigData) -> env_overrides.ConfigData: + nonlocal clone_calls + clone_calls += 1 + return original_clone(config_data) + + monkeypatch.setattr(env_overrides, "clone_config", counted_clone) + + result = env_overrides.apply_env_overrides(config, environ) + + assert clone_calls == 1 + assert result["risk"] == {"stake_usdt": "25"} + assert result["logging"] == {"level": "debug"} + assert result["api"] == {"auth_token": "local-token"} + assert config == {"risk": {"stake_usdt": "10"}, "logging": {"level": "info"}} + + def test_frontend_metadata_marks_safe_sensitive_and_restart_fields() -> None: # Given metadata = frontend_metadata() diff --git a/tests/unit/dashboard/test_snapshot.py b/tests/unit/dashboard/test_snapshot.py index 449d964..f4a828b 100644 --- a/tests/unit/dashboard/test_snapshot.py +++ b/tests/unit/dashboard/test_snapshot.py @@ -118,6 +118,99 @@ def test_dashboard_snapshot_returns_valid_empty_arrays_when_datasets_are_empty() assert payload["pairlist"]["preview"][0] == "BTC/USDT:USDT" +def test_dashboard_snapshot_serializes_prioritized_actions_for_blocked_error_state() -> None: + settings = RuntimeSettings.model_validate( + { + "pairlist": { + "whitelist": "", + "quote_asset": "USDT", + }, + } + ) + report = PreflightReport( + profile="paper", + blocked=True, + checks=( + PreflightCheck( + code=PreflightCode.CONFIG_INVALID, + status=PreflightStatus.BLOCK, + message="invalid", + ), + ), + ) + logs = ( + _log(LogLevel.ERROR, "CONFIG_VALIDATION_ERROR"), + _log(LogLevel.ERROR, "PAIRLIST_EMPTY"), + _log(LogLevel.ERROR, "RUNTIME_STALLED"), + _log(LogLevel.ERROR, "IGNORE_OVERFLOW"), + ) + + snapshot = build_dashboard_snapshot( + settings=settings, + bot_state=BotState.STOPPED, + readiness=report, + logs=logs, + read_models=DashboardReadModels.empty(), + ) + payload = DashboardSnapshotResponse.from_snapshot(snapshot).model_dump(mode="json") + + assert payload["actions"] == [ + { + "code": "readiness_blocked", + "severity": "error", + "title": "Preflight is blocking startup", + "detail": "Review failed checks in setup before starting the runtime.", + "target": "settings/setup", + }, + { + "code": "runtime_errors_detected", + "severity": "error", + "title": "Recent runtime errors need review", + "detail": "Open Logs and inspect the latest error summaries before continuing.", + "target": "logs", + }, + { + "code": "pairlist_empty", + "severity": "warning", + "title": "Pairlist is empty", + "detail": "Add at least one whitelisted pair before running the paper engine.", + "target": "settings", + }, + { + "code": "support_bundle_follow_up", + "severity": "info", + "title": "Export a support bundle if errors persist", + "detail": ( + "Capture a redacted support bundle after reviewing the logs if follow-up is needed." + ), + "target": "logs/support-bundle", + }, + ] + + +def test_dashboard_snapshot_returns_safe_ready_action_for_clean_runtime() -> None: + report = PreflightReport(profile="paper", blocked=False, checks=()) + + snapshot = build_dashboard_snapshot( + settings=RuntimeSettings(), + bot_state=BotState.STOPPED, + readiness=report, + logs=(), + read_models=DashboardReadModels.empty(), + ) + payload = DashboardSnapshotResponse.from_snapshot(snapshot).model_dump(mode="json") + + assert payload["actions"] == [ + { + "code": "paper_runtime_ready", + "severity": "info", + "title": "Paper/testnet runtime is ready", + "detail": "Review status, pairlist, and safety panels before starting the bot.", + "target": "dashboard/status", + }, + ] + + def _log(level: LogLevel, code: str) -> LogEntryResponse: return LogEntryResponse( at=NOW, diff --git a/tests/unit/docs/test_feature_coverage_docs.py b/tests/unit/docs/test_feature_coverage_docs.py index b07c1b6..9168324 100644 --- a/tests/unit/docs/test_feature_coverage_docs.py +++ b/tests/unit/docs/test_feature_coverage_docs.py @@ -1,9 +1,12 @@ from __future__ import annotations +import subprocess +import sys from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[3] FEATURE_DOC = PROJECT_ROOT / "docs" / "freqtrade-feature-coverage.md" +WORDING_SCAN = PROJECT_ROOT / "scripts" / "release_wording_scan.py" def test_feature_coverage_matrix_documents_clean_room_differentiation() -> None: @@ -56,3 +59,65 @@ def test_feature_coverage_matrix_lists_representative_freqtrade_categories() -> # Then: the matrix covers the broad feature ideas as a roadmap. for category in categories: assert category in content + + +def test_release_wording_scan_reports_zero_violations_for_public_docs() -> None: + # Given: the deterministic release wording scanner. + command = [sys.executable, str(WORDING_SCAN)] + + # When: public docs and README are scanned. + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + + # Then: blocked public claims are absent outside policy/negative contexts. + assert result.returncode == 0, result.stdout + result.stderr + assert "release_wording_scan=ok" in result.stdout + assert "violations=0" in result.stdout + + +def test_release_wording_scan_rejects_unqualified_blocked_claim(tmp_path: Path) -> None: + # Given: a controlled public wording violation. + candidate = tmp_path / "bad-release.md" + candidate.write_text( + "NFI Engine is live-money ready with guaranteed profit.\n", encoding="utf-8" + ) + + # When: the candidate text is scanned. + result = subprocess.run( + [sys.executable, str(WORDING_SCAN), str(candidate)], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + # Then: the scanner reports the exact blocked phrasing class. + assert result.returncode == 1 + assert "release_wording_scan=failed" in result.stdout + assert "violations=2" in result.stdout + assert "guaranteed profit" in result.stdout + assert "live-money ready" in result.stdout + + +def test_release_wording_scan_rejects_unqualified_korean_claim(tmp_path: Path) -> None: + # Given: a controlled Korean public wording violation. + candidate = tmp_path / "bad-korean-release.md" + candidate.write_text( + "NFI Engine은 Freqtrade보다 우월하고 수익 보장입니다.\n", + encoding="utf-8", + ) + + # When: the candidate text is scanned. + result = subprocess.run( + [sys.executable, str(WORDING_SCAN), str(candidate)], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + # Then: the scanner reports Korean superiority and profit claims. + assert result.returncode == 1 + assert "release_wording_scan=failed" in result.stdout + assert "violations=2" in result.stdout + assert "Freqtrade superiority claim" in result.stdout + assert "profit promise" in result.stdout diff --git a/tests/unit/docs/test_operator_docs.py b/tests/unit/docs/test_operator_docs.py index 2fbe759..e37d0b1 100644 --- a/tests/unit/docs/test_operator_docs.py +++ b/tests/unit/docs/test_operator_docs.py @@ -9,6 +9,7 @@ UI_DOC: Final = PROJECT_ROOT / "docs" / "ui.md" OPERATIONS_DOC: Final = PROJECT_ROOT / "docs" / "operations.md" CONTRIBUTING_DOC: Final = PROJECT_ROOT / "docs" / "contributing.md" +QUALITY_GATE_SCRIPT: Final = PROJECT_ROOT / "scripts" / "quality_gate.sh" ZERO_OCTET: Final = "0" PUBLIC_BIND_LITERAL: Final = f"{ZERO_OCTET}.{ZERO_OCTET}.{ZERO_OCTET}.{ZERO_OCTET}" @@ -78,3 +79,41 @@ def test_contributor_docs_define_clean_room_and_feature_design_rules() -> None: # Then: contributors get the original-product constraints before coding. for fragment in required_fragments: assert fragment in content + + +def test_quality_budget_governance_is_documented_and_runnable() -> None: + # Given: contributor docs, README, and the local quality gate script. + contributing = CONTRIBUTING_DOC.read_text(encoding="utf-8") + readme = README.read_text(encoding="utf-8") + script = QUALITY_GATE_SCRIPT.read_text(encoding="utf-8") + + # When: T17 quality governance text is inspected. + required_doc_fragments = ( + "scripts/quality_gate.sh --docs-only", + "scripts/quality_gate.sh --strict", + "scripts/quality_gate.sh --coverage-only", + "touched-code coverage", + "250 pure LOC", + "Performance Budget Review", + "no repeated config parse", + "no unbounded DB read", + "no unbounded candle/frame materialization", + "no UI payload growth without a cap", + "no new dependency without size/startup justification", + ) + required_script_fragments = ( + "--docs-only", + "--strict", + "--coverage-only", + "NFI_ENGINE_COVERAGE_MIN", + "uv run pytest", + "--cov-fail-under", + ) + + # Then: the governance policy cannot silently disappear from docs or shell surface. + for fragment in required_doc_fragments: + assert fragment in contributing + assert "bash scripts/quality_gate.sh --docs-only" in readme + assert "coverage smoke" in readme + for fragment in required_script_fragments: + assert fragment in script diff --git a/tests/unit/exchange/test_capabilities.py b/tests/unit/exchange/test_capabilities.py new file mode 100644 index 0000000..0ed636b --- /dev/null +++ b/tests/unit/exchange/test_capabilities.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from nfi_engine.domain import MarginMode, TradingMode +from nfi_engine.exchange.capabilities import ( + ExchangeSupportLevel, + get_exchange_profile, + list_exchange_profiles, +) + + +def test_bybit_verified_profile_supports_futures_testnet_order_lane() -> None: + # Given + profile = get_exchange_profile("bybit") + + # When / Then + assert profile is not None + assert profile.exchange_id == "bybit" + assert profile.support_level is ExchangeSupportLevel.VERIFIED + assert profile.supports_trading_mode(TradingMode.FUTURES) is True + assert profile.supports_testnet is True + assert profile.supports_sandbox is True + assert profile.evidence == "tests/integration/exchange/test_bybit_adapter.py" + assert profile.supports_margin_mode(MarginMode.ISOLATED) is True + + +def test_binance_profile_stays_candidate_until_order_lane_is_verified() -> None: + # Given + profile = get_exchange_profile("binance") + + # When / Then + assert profile is not None + assert profile.support_level is ExchangeSupportLevel.CANDIDATE + assert profile.supports_trading_mode(TradingMode.FUTURES) is True + assert profile.supports_testnet is True + assert profile.evidence == "docs/exchange-support-matrix.md" + + +def test_unknown_exchange_is_not_silently_generic() -> None: + # Given / When + profile = get_exchange_profile("typo-exchange") + + # Then + assert profile is None + + +def test_seeded_registry_contains_explicit_generic_profile() -> None: + # Given / When + profiles = list_exchange_profiles() + generic = get_exchange_profile("generic-ccxt") + + # Then + assert generic is not None + assert generic.support_level is ExchangeSupportLevel.GENERIC_UNVERIFIED + assert generic.supports_data_only is True + assert generic.supports_sandbox is False + assert generic.supports_trailing_stop is False + assert "bybit" in {profile.exchange_id for profile in profiles} diff --git a/tests/unit/exchange/test_discovery.py b/tests/unit/exchange/test_discovery.py new file mode 100644 index 0000000..766b8e1 --- /dev/null +++ b/tests/unit/exchange/test_discovery.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from nfi_engine.domain import TradingMode +from nfi_engine.exchange.capability_models import ExchangeSupportLevel +from nfi_engine.exchange.discovery import ( + ExchangeCapabilityPayload, + build_exchange_capability_document, +) + + +def _assert_credential_fields(profile: ExchangeCapabilityPayload) -> None: + assert all(isinstance(field, str) for field in profile["credential_fields"]) + + +def test_bybit_futures_discovery_reports_verified_profile_and_mode_support() -> None: + profile = build_exchange_capability_document("bybit", TradingMode.FUTURES) + + assert profile["exchange_id"] == "bybit" + assert profile["support_level"] == ExchangeSupportLevel.VERIFIED.value + assert profile["trading_mode"] == TradingMode.FUTURES.value + assert profile["live_trading_allowed"] is False + assert profile["policy_block"] == "live trading is blocked in current milestone" + assert profile["evidence"] == "tests/integration/exchange/test_bybit_adapter.py" + _assert_credential_fields(profile) + assert profile["can_configure"] is True + assert profile["trading_mode_supported"] is True + + +def test_unknown_mexc_returns_generic_unverified_report_only_document() -> None: + profile = build_exchange_capability_document("mexc", TradingMode.FUTURES) + + assert profile["exchange_id"] == "mexc" + assert profile["support_level"] == ExchangeSupportLevel.GENERIC_UNVERIFIED.value + assert profile["source"] == "generic-discovery" + assert profile["can_configure"] is False + assert profile["trading_mode_supported"] is False + assert profile["live_trading_allowed"] is False + assert "evidence" in profile["policy_block"].lower() + assert profile["credential_fields"] == [] + _assert_credential_fields(profile) diff --git a/tests/unit/exchange/test_permissions.py b/tests/unit/exchange/test_permissions.py new file mode 100644 index 0000000..9c13a33 --- /dev/null +++ b/tests/unit/exchange/test_permissions.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from nfi_engine.exchange.permissions import ( + ExchangeApiPermissionState, + audit_exchange_api_permissions, +) + + +def test_permission_audit_blocks_live_when_withdrawal_is_enabled() -> None: + # Given: an exchange key whose withdrawal permission is still enabled. + audit = audit_exchange_api_permissions( + read=ExchangeApiPermissionState.ENABLED, + trade=ExchangeApiPermissionState.ENABLED, + futures=ExchangeApiPermissionState.ENABLED, + withdrawal=ExchangeApiPermissionState.ENABLED, + ip_allowlist=ExchangeApiPermissionState.UNKNOWN, + ) + + # When: the live safety state is evaluated. + live_blocking_codes = audit.live_blocking_codes + + # Then: live operation is blocked without leaking any credential values. + assert audit.live_safe is False + assert live_blocking_codes == ("EXCHANGE_WITHDRAWAL_PERMISSION_ENABLED",) + assert "withdrawal=enabled" in audit.summary + assert "secret" not in audit.summary.lower() + + +def test_permission_audit_allows_dry_run_inspection_when_withdrawal_unknown() -> None: + # Given: a dry-run setup where the exchange does not expose every permission flag. + audit = audit_exchange_api_permissions( + read=ExchangeApiPermissionState.ENABLED, + trade=ExchangeApiPermissionState.ENABLED, + futures=ExchangeApiPermissionState.NOT_APPLICABLE, + withdrawal=ExchangeApiPermissionState.UNKNOWN, + ip_allowlist=ExchangeApiPermissionState.UNKNOWN, + ) + + # When: the audit is rendered for operator diagnostics. + diagnostics = audit.diagnostic_codes + + # Then: the operator can inspect the unknown state without a live block. + assert audit.live_safe is True + assert diagnostics == ("EXCHANGE_PERMISSION_WITHDRAWAL_UNKNOWN",) + assert audit.summary == ( + "read=enabled trade=enabled futures=not_applicable withdrawal=unknown ip_allowlist=unknown" + ) diff --git a/tests/unit/maintenance/test_backup.py b/tests/unit/maintenance/test_backup.py index ef30234..6023fe0 100644 --- a/tests/unit/maintenance/test_backup.py +++ b/tests/unit/maintenance/test_backup.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import json import shutil import sqlite3 from pathlib import Path @@ -18,6 +20,38 @@ ) +def _write_traversal_archive(archive: Path) -> None: + payload = b"unsafe" + manifest = { + "engine_version": "test", + "generated_at": "2026-06-17T00:00:00+00:00", + "redacted": True, + "config_hash": "", + "dependency_lock_hash": "", + "files": ["../../outside.txt"], + "checksums": {"../../outside.txt": hashlib.sha256(payload).hexdigest()}, + } + with ZipFile(archive, mode="w", compression=ZIP_DEFLATED) as opened: + opened.writestr("manifest.json", json.dumps(manifest).encode()) + opened.writestr("../../outside.txt", payload) + + +def _write_manifest_only_archive(archive: Path) -> None: + files: tuple[str, ...] = () + checksums: dict[str, str] = {} + manifest = { + "engine_version": "test", + "generated_at": "2026-06-17T00:00:00+00:00", + "redacted": True, + "config_hash": "", + "dependency_lock_hash": "", + "files": files, + "checksums": checksums, + } + with ZipFile(archive, mode="w", compression=ZIP_DEFLATED) as opened: + opened.writestr("manifest.json", json.dumps(manifest).encode()) + + def test_backup_create_writes_redacted_manifested_archive(tmp_path: Path) -> None: # Given: a config fixture containing exchange secrets. output = tmp_path / "backup.zip" @@ -65,6 +99,28 @@ def test_backup_verify_rejects_invalid_archive(tmp_path: Path) -> None: assert captured.value.code is MaintenanceErrorCode.BACKUP_INVALID +def test_backup_verify_rejects_traversal_archive_members(tmp_path: Path) -> None: + # Given: an archive whose manifest points outside the restore root. + archive = tmp_path / "traversal.zip" + _write_traversal_archive(archive) + + # When / Then: verification fails closed before the archive can be trusted. + with pytest.raises(MaintenanceError) as captured: + verify_backup(archive) + assert captured.value.code is MaintenanceErrorCode.BACKUP_INVALID + + +def test_backup_verify_rejects_incomplete_manifest_only_archive(tmp_path: Path) -> None: + # Given: an allowlisted archive that contains no required backup payload members. + archive = tmp_path / "manifest-only.zip" + _write_manifest_only_archive(archive) + + # When / Then: verification fails closed instead of blessing an unusable backup. + with pytest.raises(MaintenanceError) as captured: + verify_backup(archive) + assert captured.value.code is MaintenanceErrorCode.BACKUP_INVALID + + def test_backup_verify_detects_tampered_member_checksum(tmp_path: Path) -> None: # Given: a backup archive whose config member is replaced after manifest creation. output = tmp_path / "backup.zip" @@ -85,6 +141,25 @@ def test_backup_verify_detects_tampered_member_checksum(tmp_path: Path) -> None: assert verification.manifest_valid is False +def test_restore_dry_run_rejects_tampered_member_checksum(tmp_path: Path) -> None: + # Given: a backup archive whose config member no longer matches its manifest. + output = tmp_path / "backup.zip" + create_backup(config=Path("examples/futures-paper.yaml"), output=output) + with ZipFile(output) as archive: + members = tuple( + (name, b"{}" if name == "config.json" else archive.read(name)) + for name in archive.namelist() + ) + with ZipFile(output, mode="w", compression=ZIP_DEFLATED) as archive: + for name, data in members: + archive.writestr(name, data) + + # When / Then: restore preview fails closed instead of printing restore steps. + with pytest.raises(MaintenanceError) as captured: + preview_backup_restore(archive=output, dry_run=True) + assert captured.value.code is MaintenanceErrorCode.BACKUP_INVALID + + def test_backup_create_includes_existing_sqlite_database(tmp_path: Path) -> None: # Given: a config pointing at an existing SQLite database. database = tmp_path / "runtime.sqlite" @@ -127,6 +202,17 @@ def test_restore_dry_run_reports_plan_without_applying(tmp_path: Path) -> None: assert "restore config.json" in plan.steps +def test_restore_apply_is_rejected_until_mutating_restore_exists(tmp_path: Path) -> None: + # Given: a verified backup archive. + output = tmp_path / "backup.zip" + create_backup(config=Path("examples/futures-paper.yaml"), output=output) + + # When / Then: mutating restore stays locked until the apply path is implemented. + with pytest.raises(MaintenanceError) as exc_info: + preview_backup_restore(archive=output, dry_run=False) + assert exc_info.value.code is MaintenanceErrorCode.BACKUP_RESTORE_APPLY_UNSUPPORTED + + def test_restore_apply_requires_verified_backup_reference(tmp_path: Path) -> None: # Given: a copied v0 database and a valid backup reference. database = tmp_path / "v0.sqlite" diff --git a/tests/unit/maintenance/test_backup_redaction.py b/tests/unit/maintenance/test_backup_redaction.py new file mode 100644 index 0000000..8a96941 --- /dev/null +++ b/tests/unit/maintenance/test_backup_redaction.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from pathlib import Path +from zipfile import ZipFile + +from nfi_engine.maintenance import create_backup + + +def _config_with_database_url(tmp_path: Path, database_url: str) -> Path: + config = tmp_path / "database-url.yaml" + config.write_text( + Path("examples/futures-paper.yaml") + .read_text(encoding="utf-8") + .replace("sqlite+aiosqlite:///data/nfi_engine.sqlite3", database_url), + encoding="utf-8", + ) + return config + + +def _database_info(archive_path: Path) -> str: + with ZipFile(archive_path) as archive: + return archive.read("database.json").decode("utf-8") + + +def test_backup_create_redacts_credential_database_url(tmp_path: Path) -> None: + # Given: a config with a credential-bearing non-SQLite database URL. + output = tmp_path / "backup.zip" + config = _config_with_database_url( + tmp_path, + "postgresql+asyncpg://backup-user:backup-pass@example.com/engine?ssl=prefer", + ) + + # When: a backup archive is created. + result = create_backup(config=config, output=output) + + # Then: database metadata preserves shape without leaking raw DSN credentials. + database_info = _database_info(output) + assert result.redacted is True + assert "backup-user" not in database_info + assert "backup-pass" not in database_info + assert "ssl=prefer" not in database_info + assert "postgresql+asyncpg://REDACTED@example.com/engine?REDACTED" in database_info + + +def test_backup_create_redacts_socket_database_url_query_credentials(tmp_path: Path) -> None: + # Given: a non-SQLite socket-style DSN with credentials in query parameters. + output = tmp_path / "backup.zip" + config = _config_with_database_url( + tmp_path, + ( + "postgresql+asyncpg:///engine?" + "host=/var/run/postgresql&user=socket-user&password=socket-pass" + ), + ) + + # When: a backup archive is created. + create_backup(config=config, output=output) + + # Then: socket path shape is retained without leaking query credentials. + database_info = _database_info(output) + assert "socket-user" not in database_info + assert "socket-pass" not in database_info + assert "/var/run/postgresql" not in database_info + assert "postgresql+asyncpg:///engine?REDACTED" in database_info diff --git a/tests/unit/maintenance/test_data_lifecycle.py b/tests/unit/maintenance/test_data_lifecycle.py new file mode 100644 index 0000000..0c92302 --- /dev/null +++ b/tests/unit/maintenance/test_data_lifecycle.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import os +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from nfi_engine.config.models import ( + ApiSettings, + DatabaseSettings, + ExchangeSettings, + NotificationSettings, + RuntimeSettings, +) +from nfi_engine.events import REDACTED_TEXT +from nfi_engine.maintenance.data_lifecycle import ( + DATA_LIFECYCLE_CONFIRM_SCOPE, + DATA_LIFECYCLE_CONFIRMATION_REQUIRED, + DataLifecyclePrunePolicy, + build_data_lifecycle_export, + build_data_lifecycle_footprint, + build_data_lifecycle_prune_receipt, +) + + +def test_data_lifecycle_footprint_counts_runtime_artifacts(tmp_path: Path) -> None: + # Given: a temp runtime with one file in each operator-owned category. + settings = _settings(tmp_path) + _runtime_file(tmp_path, "engine.sqlite3", b"sqlite") + _runtime_file(tmp_path, "logs/engine.log", b"log") + _runtime_file(tmp_path, "backups/backup.zip", b"backup") + _runtime_file(tmp_path, "support-bundles/support.zip", b"support") + _runtime_file(tmp_path, "evidence/operator.json", b"evidence") + + # When: the lifecycle footprint is built from typed settings. + footprint = build_data_lifecycle_footprint( + settings=settings, + config_path=tmp_path / "config.yaml", + workspace_root=tmp_path, + ) + + # Then: every category has bounded byte and file counts. + counts = {category.name: category.file_count for category in footprint.categories} + assert counts == { + "sqlite": 1, + "logs": 1, + "backups": 1, + "support_bundles": 1, + "evidence": 1, + } + assert footprint.total_bytes == sum(category.total_bytes for category in footprint.categories) + + +def test_data_lifecycle_export_redacts_runtime_profile_secrets(tmp_path: Path) -> None: + # Given: settings that contain exchange, API, and webhook secrets. + settings = _settings(tmp_path) + _runtime_file(tmp_path, "logs/engine.log", b"secret should not leak") + + # When: the redacted local profile export is built. + export = build_data_lifecycle_export( + settings=settings, + config_path=tmp_path / "config.yaml", + workspace_root=tmp_path, + ) + merged = export.redacted_config_json + export.redacted_profile_json + + # Then: known raw secrets are absent and redacted markers remain. + assert REDACTED_TEXT in merged + assert "exchange-key-fixture" not in merged + assert "exchange-secret-fixture" not in merged + assert "operator-token-fixture" not in merged + assert "https://hooks.example.invalid/raw-token" not in merged + assert export.receipt_id.startswith("data-export-") + + +def test_data_lifecycle_footprint_skips_broken_symlink(tmp_path: Path) -> None: + # Given: a broken symlink in an operator-owned runtime folder. + settings = _settings(tmp_path) + log_link = tmp_path / "logs" / "broken.log" + log_link.parent.mkdir(parents=True, exist_ok=True) + log_link.symlink_to(tmp_path / "logs" / "missing.log") + + # When: the lifecycle footprint scans local files. + footprint = build_data_lifecycle_footprint( + settings=settings, + config_path=tmp_path / "config.yaml", + workspace_root=tmp_path, + ) + logs = next(category for category in footprint.categories if category.name == "logs") + + # Then: the endpoint can report the artifact without making it deletable. + assert logs.file_count == 1 + assert logs.items[0].status == "skipped" + assert logs.items[0].reason == "stat_failed" + + +def test_data_lifecycle_footprint_bounds_category_scans(tmp_path: Path) -> None: + # Given: more logs than the operator UI should enumerate on low-resource hosts. + settings = _settings(tmp_path) + for index in range(505): + _runtime_file(tmp_path, f"logs/{index}.log", b"x") + + # When: the lifecycle footprint scans local files. + footprint = build_data_lifecycle_footprint( + settings=settings, + config_path=tmp_path / "config.yaml", + workspace_root=tmp_path, + ) + logs = next(category for category in footprint.categories if category.name == "logs") + + # Then: the response stays bounded and records the truncation. + assert logs.file_count == 500 + assert len(logs.items) == 501 + assert logs.items[-1].status == "skipped" + assert logs.items[-1].reason == "scan_truncated" + + +def test_data_lifecycle_prune_requires_preview_token_before_apply(tmp_path: Path) -> None: + # Given: an old runtime log that is safe to prune after preview. + settings = _settings(tmp_path) + old_log = _runtime_file(tmp_path, "logs/old.log", b"old") + _mark_old(old_log) + + # When: dry-run and apply receipts are requested. + dry_run = build_data_lifecycle_prune_receipt( + settings=settings, + config_path=tmp_path / "config.yaml", + workspace_root=tmp_path, + policy=DataLifecyclePrunePolicy(retention_days=7, dry_run=True, apply=False), + ) + blocked_apply = build_data_lifecycle_prune_receipt( + settings=settings, + config_path=tmp_path / "config.yaml", + workspace_root=tmp_path, + policy=DataLifecyclePrunePolicy(retention_days=7, dry_run=False, apply=True), + ) + + # Then: preview and missing-token apply do not mutate the file. + assert dry_run.accepted is True + assert dry_run.mutation_applied is False + assert old_log.exists() + assert blocked_apply.accepted is False + assert "preview_token_required" in blocked_apply.blocked_reasons + assert DATA_LIFECYCLE_CONFIRMATION_REQUIRED in blocked_apply.blocked_reasons + assert old_log.exists() + + missing_confirmation = build_data_lifecycle_prune_receipt( + settings=settings, + config_path=tmp_path / "config.yaml", + workspace_root=tmp_path, + policy=DataLifecyclePrunePolicy( + retention_days=7, + dry_run=False, + apply=True, + preview_token=dry_run.preview_token, + ), + ) + + assert missing_confirmation.accepted is False + assert DATA_LIFECYCLE_CONFIRMATION_REQUIRED in missing_confirmation.blocked_reasons + assert old_log.exists() + + applied = build_data_lifecycle_prune_receipt( + settings=settings, + config_path=tmp_path / "config.yaml", + workspace_root=tmp_path, + policy=DataLifecyclePrunePolicy( + retention_days=7, + dry_run=False, + apply=True, + preview_token=dry_run.preview_token, + confirm_scope=DATA_LIFECYCLE_CONFIRM_SCOPE, + ), + ) + + # Then: only the explicit apply with a matching token mutates the file. + assert applied.accepted is True + assert applied.mutation_applied is True + assert not old_log.exists() + assert applied.deleted_count == 1 + + +def test_data_lifecycle_prune_reports_zero_byte_file_mutation(tmp_path: Path) -> None: + # Given: an old zero-byte log that is safe to prune after preview. + settings = _settings(tmp_path) + old_log = _runtime_file(tmp_path, "logs/empty.log", b"") + _mark_old(old_log) + dry_run = build_data_lifecycle_prune_receipt( + settings=settings, + config_path=tmp_path / "config.yaml", + workspace_root=tmp_path, + policy=DataLifecyclePrunePolicy(retention_days=7, dry_run=True, apply=False), + ) + + # When: the matching apply receipt is requested. + applied = build_data_lifecycle_prune_receipt( + settings=settings, + config_path=tmp_path / "config.yaml", + workspace_root=tmp_path, + policy=DataLifecyclePrunePolicy( + retention_days=7, + dry_run=False, + apply=True, + preview_token=dry_run.preview_token, + confirm_scope=DATA_LIFECYCLE_CONFIRM_SCOPE, + ), + ) + + # Then: the mutation is true even though reclaimed bytes are zero. + assert applied.accepted is True + assert applied.mutation_applied is True + assert applied.deleted_count == 1 + assert applied.bytes_deleted == 0 + assert not old_log.exists() + + +def _settings(runtime_root: Path) -> RuntimeSettings: + return RuntimeSettings( + database=DatabaseSettings(url=f"sqlite+aiosqlite:///{runtime_root / 'engine.sqlite3'}"), + exchange=ExchangeSettings( + api_key="exchange-key-fixture", + api_secret=_fixture_secret("exchange-secret-fixture"), + ), + api=ApiSettings(auth_token=_fixture_secret("operator-token-fixture")), + notifications=NotificationSettings( + webhook_url="https://hooks.example.invalid/raw-token", + jsonl_path=str(runtime_root / "evidence" / "notifications.jsonl"), + ), + ) + + +def _fixture_secret(value: str) -> str: + return value + + +def _runtime_file(root: Path, relative: str, data: bytes) -> Path: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + +def _mark_old(path: Path) -> None: + old = datetime.now(UTC) - timedelta(days=30) + timestamp = old.timestamp() + os.utime(path, (timestamp, timestamp)) diff --git a/tests/unit/maintenance/test_update_provenance.py b/tests/unit/maintenance/test_update_provenance.py new file mode 100644 index 0000000..5a38f5a --- /dev/null +++ b/tests/unit/maintenance/test_update_provenance.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import hashlib +from dataclasses import replace +from pathlib import Path + +from nfi_engine import __version__ +from nfi_engine.config import RuntimeSettings, load_runtime_settings +from nfi_engine.config.models import StrategySettings +from nfi_engine.maintenance.update_provenance import ( + UpdateProofPolicy, + build_update_apply_receipt, + build_update_preview, + build_update_rollback_receipt, +) + + +def test_build_update_preview_uses_local_config_and_strategy_digests() -> None: + # Given: a locally backed config file and the in-repo demo strategy module. + config_path = Path("examples/futures-paper.yaml") + settings = load_runtime_settings(config_path) + + # When: local update provenance is previewed. + preview = build_update_preview( + settings=settings, config_path=config_path, workspace_root=Path.cwd() + ) + + # Then: the preview reports locally provable digests and a rollback requirement. + assert preview.engine_version == __version__ + assert preview.strategy_name == settings.strategy.name + assert preview.strategy_module == settings.strategy.module + assert preview.strategy_digest != "unavailable" + assert preview.config_source == str(config_path) + assert preview.config_digest == hashlib.sha256(config_path.read_bytes()).hexdigest() + assert preview.dependency_lock_source == "uv.lock" + assert preview.remote_network_allowed is False + assert preview.provenance_verified is True + assert preview.compatibility_status == "local_verified" + assert preview.live_blocked is False + assert preview.rollback_state.status == "backup_required" + assert preview.rollback_state.can_rollback is False + + +def test_preview_uses_redacted_runtime_digest_when_local_proof_is_missing() -> None: + # Given: runtime settings whose strategy module cannot be proven from local files. + settings = RuntimeSettings( + strategy=StrategySettings( + name="MissingStrategy", + module="nfi_engine.strategy.missing:MissingStrategy", + ), + ) + + # When: update provenance is previewed without a config file path. + preview = build_update_preview(settings=settings, config_path=None, workspace_root=Path.cwd()) + + # Then: the preview stays local-safe but marks provenance as unverified. + assert preview.strategy_digest == "unavailable" + assert preview.config_source == "runtime_redacted" + assert preview.provenance_verified is False + assert preview.compatibility_status == "unverified_local" + assert preview.live_blocked is True + + +def test_apply_receipt_blocks_without_backup_reference_or_unverified_acknowledgement() -> None: + # Given: an unverified local preview built from runtime-only settings. + preview = build_update_preview( + settings=RuntimeSettings(), config_path=None, workspace_root=Path.cwd() + ) + + # When: apply proof is requested without backup evidence or acknowledgement. + receipt = build_update_apply_receipt( + preview=preview, + policy=UpdateProofPolicy( + backup_reference=None, + acknowledge_unverified=False, + allow_dirty_worktree=False, + update_source="local_proof", + ), + ) + + # Then: the request is returned as a typed blocked receipt instead of mutating anything. + assert receipt.action == "apply" + assert receipt.accepted is False + assert receipt.proof_only is True + assert receipt.mutation_applied is False + assert receipt.source_mutated is False + assert receipt.remote_network_allowed is False + assert receipt.restart_required is False + assert receipt.reload_required is False + assert "backup_reference_required" in receipt.blocked_reasons + assert "acknowledge_unverified_required" in receipt.blocked_reasons + + +def test_rollback_receipt_accepts_verified_local_proof_with_backup_reference() -> None: + # Given: a verified local preview and a backup reference. + config_path = Path("examples/futures-paper.yaml") + preview = build_update_preview( + settings=load_runtime_settings(config_path), + config_path=config_path, + workspace_root=Path.cwd(), + ) + + # When: rollback proof is requested with backup evidence. + receipt = build_update_rollback_receipt( + preview=preview, + policy=UpdateProofPolicy( + backup_reference="backups/local-proof.zip", + acknowledge_unverified=False, + allow_dirty_worktree=True, + update_source="local_proof", + ), + ) + + # Then: the API can issue a proof receipt without mutating runtime config. + assert receipt.action == "rollback" + assert receipt.accepted is True + assert receipt.proof_only is True + assert receipt.mutation_applied is False + assert receipt.backup_reference == "backups/local-proof.zip" + assert receipt.blocked_reasons == () + + +def test_apply_receipt_blocks_dirty_workspace_without_explicit_policy() -> None: + # Given: a verified preview whose source checkout has uncommitted changes. + config_path = Path("examples/futures-paper.yaml") + preview = build_update_preview( + settings=load_runtime_settings(config_path), + config_path=config_path, + workspace_root=Path.cwd(), + ) + dirty_preview = replace(preview, workspace_dirty=True, workspace_state="dirty") + + # When: apply proof is requested with and without the dirty-worktree override. + blocked = build_update_apply_receipt( + preview=dirty_preview, + policy=UpdateProofPolicy( + backup_reference="backups/local-proof.zip", + acknowledge_unverified=False, + allow_dirty_worktree=False, + update_source="local_proof", + ), + ) + allowed = build_update_apply_receipt( + preview=dirty_preview, + policy=UpdateProofPolicy( + backup_reference="backups/local-proof.zip", + acknowledge_unverified=False, + allow_dirty_worktree=True, + update_source="local_proof", + ), + ) + + # Then: dirty source requires an explicit proof policy. + assert blocked.accepted is False + assert "workspace_dirty" in blocked.blocked_reasons + assert "workspace_dirty" not in allowed.blocked_reasons + + +def test_rollback_receipt_blocks_invalid_update_source() -> None: + # Given: a verified local preview. + config_path = Path("examples/futures-paper.yaml") + preview = build_update_preview( + settings=load_runtime_settings(config_path), + config_path=config_path, + workspace_root=Path.cwd(), + ) + + # When: rollback proof claims a non-local update source. + receipt = build_update_rollback_receipt( + preview=preview, + policy=UpdateProofPolicy( + backup_reference="backups/local-proof.zip", + acknowledge_unverified=False, + allow_dirty_worktree=True, + update_source="remote_plugin", + ), + ) + + # Then: the request is blocked before any source mutation path can exist. + assert receipt.accepted is False + assert receipt.source_mutated is False + assert "invalid_update_source" in receipt.blocked_reasons diff --git a/tests/unit/paper/test_runner.py b/tests/unit/paper/test_runner.py index 258121b..50cd21b 100644 --- a/tests/unit/paper/test_runner.py +++ b/tests/unit/paper/test_runner.py @@ -7,11 +7,20 @@ import pytest -from nfi_engine.config import load_runtime_settings -from nfi_engine.domain import PositionSide, Price, TradingMode, TradingPair +from nfi_engine.config import ExchangeSettings, RuntimeSettings, load_runtime_settings +from nfi_engine.domain import ( + AccountSnapshot, + MarginMode, + PositionSide, + Price, + StakeAmount, + TradingMode, + TradingPair, +) from nfi_engine.paper import PaperError, PaperRunRequest, PaperTick, run_paper from nfi_engine.persistence import create_persistence_database from nfi_engine.persistence.repositories import TradeRepository +from nfi_engine.strategy import TimelineSurface pytestmark = pytest.mark.anyio @@ -72,6 +81,63 @@ async def test_paper_run_persists_long_and_short_signal_trades(tmp_path: Path) - assert short_trade.side is PositionSide.SHORT +async def test_paper_run_records_compact_signal_timeline(tmp_path: Path) -> None: + # Given + ticks = ( + _tick(signal_side=PositionSide.LONG), + _tick(offset=1), + _tick(signal_side=PositionSide.SHORT, offset=2), + ) + request = _request(tmp_path, ticks=ticks, max_events=5) + + # When + result = await run_paper(request) + + # Then + timeline = result.timeline + assert timeline.surface is TimelineSurface.PAPER + assert timeline.truncated is False + assert len(timeline.steps) == 3 + assert timeline.steps[0].indicator_runs == 0 + assert timeline.steps[0].entry_signals == 1 + assert timeline.steps[0].entry_sides == (PositionSide.LONG,) + assert timeline.steps[0].opened_orders == 1 + assert timeline.steps[0].rejected_actions == 0 + assert timeline.steps[0].stake_amount == Decimal(10) + assert timeline.steps[0].leverage == Decimal(2) + assert timeline.steps[1].entry_signals == 0 + assert timeline.steps[2].entry_signals == 1 + assert timeline.steps[2].entry_sides == (PositionSide.SHORT,) + assert timeline.steps[2].opened_orders == 1 + + +async def test_paper_run_rejects_entry_when_wallet_available_is_below_stake( + tmp_path: Path, +) -> None: + # Given + request = PaperRunRequest( + settings=load_runtime_settings(Path("examples/futures-paper.yaml")), + ticks=(_tick(signal_side=PositionSide.LONG),), + max_events=1, + database_url=f"sqlite+aiosqlite:///{tmp_path / 'paper.sqlite'}", + account_snapshot=AccountSnapshot( + captured_at=NOW, + equity=StakeAmount(Decimal(1000)), + available=StakeAmount(Decimal(0)), + positions=(), + ), + ) + + # When + result = await run_paper(request) + + # Then + assert result.created_trades == 0 + assert result.timeline.steps[0].entry_signals == 1 + assert result.timeline.steps[0].opened_orders == 0 + assert result.timeline.steps[0].rejected_actions == 1 + + async def test_paper_run_rejects_live_exchange_config(tmp_path: Path) -> None: # Given settings = load_runtime_settings(Path("tests/fixtures/config/bybit-live-denied.yaml")) @@ -87,6 +153,28 @@ async def test_paper_run_rejects_live_exchange_config(tmp_path: Path) -> None: await run_paper(request) +async def test_paper_run_rejects_non_testnet_registry_exchange(tmp_path: Path) -> None: + # Given: a registry-known exchange configured outside testnet mode. + settings = RuntimeSettings( + exchange=ExchangeSettings( + name="binance", + trading_mode=TradingMode.FUTURES, + margin_mode=MarginMode.ISOLATED, + testnet=False, + ), + ) + request = PaperRunRequest( + settings=settings, + ticks=_ticks(count=1), + max_events=1, + database_url=f"sqlite+aiosqlite:///{tmp_path / 'paper.sqlite'}", + ) + + # When/Then: paper mode rejects every non-simulator live exchange. + with pytest.raises(PaperError, match="LIVE_EXCHANGE_DISABLED_FOR_MILESTONE"): + await run_paper(request) + + def _request( tmp_path: Path, *, diff --git a/tests/unit/preflight/test_service.py b/tests/unit/preflight/test_service.py index 5a2ddc1..ad2ed89 100644 --- a/tests/unit/preflight/test_service.py +++ b/tests/unit/preflight/test_service.py @@ -1,11 +1,27 @@ from __future__ import annotations from pathlib import Path - -from nfi_engine.config import load_runtime_settings -from nfi_engine.preflight import PreflightCode, PreflightReport, PreflightStatus, run_preflight +from typing import Final + +from nfi_engine.config import ( + CircuitBreakerSettings, + EngineSettings, + ExchangeSettings, + RiskSettings, + RuntimeSettings, + StrategySettings, + load_runtime_settings, +) +from nfi_engine.config.enums import RiskProfileName +from nfi_engine.config.models import ReconciliationSettings +from nfi_engine.domain import MarginMode, TradingMode +from nfi_engine.exchange.permissions import ExchangeApiPermissionState +from nfi_engine.preflight import PreflightCode, PreflightReport, PreflightStatus +from nfi_engine.preflight.service import run_preflight from nfi_engine.ui.pages import render_settings_page +FIXTURE_API_VALUE: Final = "fixture-value" + def test_bybit_testnet_fixture_passes_readiness_checks() -> None: # Given: the futures paper config and Bybit testnet profile. @@ -22,6 +38,25 @@ def test_bybit_testnet_fixture_passes_readiness_checks() -> None: assert _status(report, PreflightCode.DOCKER_VOLUMES_READY) is PreflightStatus.PASS +def test_bybit_profile_accepts_registry_normalized_exchange_id() -> None: + # Given: Bybit testnet settings using casing that the exchange registry normalizes. + settings = RuntimeSettings( + exchange=ExchangeSettings( + name="ByBit", + trading_mode=TradingMode.FUTURES, + margin_mode=MarginMode.ISOLATED, + testnet=True, + ), + ) + + # When: preflight checks the Bybit profile compatibility. + report = run_preflight(settings=settings, profile_name="bybit-testnet") + + # Then: profile compatibility is accepted through registry metadata. + assert report.blocked is False + assert _status(report, PreflightCode.PROFILE_COMPATIBLE) is PreflightStatus.PASS + + def test_profile_config_mismatch_blocks_readiness() -> None: # Given: a spot config paired with the Bybit futures profile. config_path = Path("examples/spot-paper.yaml") @@ -100,6 +135,64 @@ def test_live_mode_blocks_readiness() -> None: assert _status(report, PreflightCode.LIVE_TRADING_OUT_OF_SCOPE) is PreflightStatus.BLOCK +def test_live_mode_surfaces_hardening_blockers() -> None: + # Given: a confirmed live config with incomplete live hardening metadata. + config_path = Path("tests/fixtures/config/live-real-orders.yaml") + settings = load_runtime_settings(config_path) + + # When: preflight checks live-order readiness. + report = run_preflight(settings=settings, profile_name="bybit-testnet", config_path=config_path) + + # Then: live execution remains blocked and the missing hardening steps are visible. + assert report.blocked is True + assert _status(report, PreflightCode.LIVE_EXCHANGE_CREDENTIALS) is PreflightStatus.PASS + assert _status(report, PreflightCode.LIVE_PERMISSION_HARDENING) is PreflightStatus.BLOCK + assert _status(report, PreflightCode.LIVE_RECONCILIATION_HARDENING) is PreflightStatus.BLOCK + assert _status(report, PreflightCode.LIVE_CIRCUIT_BREAKER_HARDENING) is PreflightStatus.BLOCK + assert _status(report, PreflightCode.LIVE_STRATEGY_HARDENING) is PreflightStatus.BLOCK + + +def test_hardened_live_prerequisites_do_not_unlock_live_orders() -> None: + # Given: every live-readiness prerequisite represented in local config. + settings = RuntimeSettings( + engine=EngineSettings(live_trading=True, live_trading_confirmed=True), + exchange=ExchangeSettings( + name="bybit", + trading_mode=TradingMode.FUTURES, + margin_mode=MarginMode.ISOLATED, + testnet=False, + api_key=FIXTURE_API_VALUE, + api_secret=FIXTURE_API_VALUE, + permission_read=ExchangeApiPermissionState.ENABLED, + permission_trade=ExchangeApiPermissionState.ENABLED, + permission_futures=ExchangeApiPermissionState.ENABLED, + permission_withdrawal=ExchangeApiPermissionState.DISABLED, + permission_ip_allowlist=ExchangeApiPermissionState.ENABLED, + ), + strategy=StrategySettings( + name="X7NativeStrategy", + module="nfi_engine.strategy.nfi_x7:X7NativeStrategy", + ), + circuit_breakers=CircuitBreakerSettings(manual_halt_file=".runtime/manual-halt"), + reconciliation=ReconciliationSettings( + required=True, + fixture_path="tests/fixtures/exchange/reconcile_match.json", + ), + ) + + # When: preflight inspects the live-readiness envelope. + report = run_preflight(settings=settings, profile_name="local-paper") + + # Then: the hardening checks can pass, but real-money execution remains locked. + assert report.blocked is True + assert _status(report, PreflightCode.LIVE_TRADING_OUT_OF_SCOPE) is PreflightStatus.BLOCK + assert _status(report, PreflightCode.LIVE_EXCHANGE_CREDENTIALS) is PreflightStatus.PASS + assert _status(report, PreflightCode.LIVE_PERMISSION_HARDENING) is PreflightStatus.PASS + assert _status(report, PreflightCode.LIVE_RECONCILIATION_HARDENING) is PreflightStatus.PASS + assert _status(report, PreflightCode.LIVE_CIRCUIT_BREAKER_HARDENING) is PreflightStatus.PASS + assert _status(report, PreflightCode.LIVE_STRATEGY_HARDENING) is PreflightStatus.PASS + + def test_invalid_futures_leverage_blocks_readiness() -> None: # Given: a futures config with leverage above the milestone readiness ceiling. config_path = Path("tests/fixtures/config/futures-liquidation-risk.yaml") @@ -113,6 +206,60 @@ def test_invalid_futures_leverage_blocks_readiness() -> None: assert _status(report, PreflightCode.FUTURES_LEVERAGE_INVALID) is PreflightStatus.BLOCK +def test_withdrawal_permission_warns_in_dry_run_preflight() -> None: + # Given: a dry-run config whose exchange key still has withdrawal permission enabled. + settings = RuntimeSettings( + exchange=ExchangeSettings(permission_withdrawal=ExchangeApiPermissionState.ENABLED) + ) + + # When: preflight checks exchange API permission readiness. + report = run_preflight(settings=settings, profile_name="local-paper") + + # Then: the operator sees the unsafe permission without blocking dry-run inspection. + assert report.blocked is False + assert _status(report, PreflightCode.EXCHANGE_PERMISSION_AUDIT) is PreflightStatus.WARN + + +def test_expert_risk_profile_without_confirmation_blocks_preflight() -> None: + # Given: an expert risk profile without its explicit confirmation flag. + settings = RuntimeSettings( + exchange=ExchangeSettings( + trading_mode=TradingMode.FUTURES, + margin_mode=MarginMode.ISOLATED, + ), + risk=RiskSettings(risk_profile=RiskProfileName.EXPERT, expert_risk_confirmed=False), + ) + + # When: preflight checks risk profile readiness. + report = run_preflight(settings=settings, profile_name="local-paper") + + # Then: expert mode is blocked until the operator confirms that risk tier. + assert report.blocked is True + assert _status(report, PreflightCode.RISK_PROFILE_GUARDRAILS) is PreflightStatus.BLOCK + + +def test_exchange_without_registry_testnet_support_blocks_readiness(tmp_path: Path) -> None: + # Given: a candidate exchange whose registry profile has no testnet proof. + config_path = tmp_path / "bitget.yaml" + config_path.write_text( + """exchange: + name: bitget + trading_mode: futures + margin_mode: isolated + testnet: true +""", + encoding="utf-8", + ) + settings = load_runtime_settings(config_path) + + # When: preflight reads exchange readiness from the registry. + report = run_preflight(settings=settings, profile_name="local-paper", config_path=config_path) + + # Then: testnet support cannot be assumed from docs-only candidate status. + assert report.blocked is True + assert _status(report, PreflightCode.EXCHANGE_TESTNET_REQUIRED) is PreflightStatus.BLOCK + + def test_disabled_notifier_is_warning_not_blocking() -> None: # Given: a safe config with notifications disabled. config_path = Path("tests/fixtures/config/preflight-disabled-notifier.yaml") diff --git a/tests/unit/profiles/test_catalog.py b/tests/unit/profiles/test_catalog.py index 0b8140a..c989aee 100644 --- a/tests/unit/profiles/test_catalog.py +++ b/tests/unit/profiles/test_catalog.py @@ -2,7 +2,14 @@ import pytest -from nfi_engine.profiles import ProfileError, get_operator_profile, list_operator_profiles +from nfi_engine.config import ExchangeSettings, RuntimeSettings +from nfi_engine.domain import MarginMode, TradingMode +from nfi_engine.profiles import ( + ProfileError, + default_profile_name, + get_operator_profile, + list_operator_profiles, +) def test_default_profiles_include_required_operator_modes() -> None: @@ -26,6 +33,25 @@ def test_bybit_testnet_profile_requires_testnet_exchange() -> None: # Then: live exchange mode is not allowed by that profile. assert profile.requires_testnet is True assert profile.allow_live_trading is False + assert profile.exchange_id == "bybit" + + +def test_default_profile_uses_registry_exchange_metadata_for_testnet_profile() -> None: + # Given: a registry-normalized Bybit testnet futures configuration. + settings = RuntimeSettings( + exchange=ExchangeSettings( + name="ByBit", + trading_mode=TradingMode.FUTURES, + margin_mode=MarginMode.ISOLATED, + testnet=True, + ), + ) + + # When: the default operator profile is selected. + profile_name = default_profile_name(settings) + + # Then: the Bybit testnet profile is chosen without a raw exchange-name branch. + assert profile_name == "bybit-testnet" def test_unknown_profile_raises_typed_error() -> None: diff --git a/tests/unit/risk/test_profiles.py b/tests/unit/risk/test_profiles.py new file mode 100644 index 0000000..7875db7 --- /dev/null +++ b/tests/unit/risk/test_profiles.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from decimal import Decimal + +from nfi_engine.config.enums import RiskProfileName +from nfi_engine.risk.profiles import get_risk_profile + + +def test_balanced_risk_profile_uses_default_three_x_guardrails() -> None: + # Given: the default operator risk profile. + profile = get_risk_profile(RiskProfileName.BALANCED) + + # When: setup and preflight consume its guardrails. + leverage = profile.leverage + + # Then: it maps to the agreed 3x default with bounded exposure. + assert leverage == Decimal(3) + assert profile.max_leverage == Decimal(3) + assert profile.max_open_trades == 3 + assert profile.max_daily_loss_pct <= Decimal("0.05") + assert profile.requires_confirmation is False + + +def test_expert_risk_profile_requires_explicit_confirmation() -> None: + # Given: the highest-risk operator profile. + profile = get_risk_profile(RiskProfileName.EXPERT) + + # When: its setup contract is inspected. + requires_confirmation = profile.requires_confirmation + + # Then: callers must require an explicit expert confirmation gate. + assert requires_confirmation is True + assert profile.max_leverage > get_risk_profile(RiskProfileName.BALANCED).max_leverage + + +def test_safe_risk_profile_is_lower_exposure_than_balanced() -> None: + # Given: safe and balanced profiles. + safe = get_risk_profile(RiskProfileName.SAFE) + balanced = get_risk_profile(RiskProfileName.BALANCED) + + # When: their exposure caps are compared. + safe_exposure = safe.max_open_trades * safe.leverage + balanced_exposure = balanced.max_open_trades * balanced.leverage + + # Then: safe keeps strictly lower notional pressure. + assert safe_exposure < balanced_exposure + assert safe.max_daily_loss_pct < balanced.max_daily_loss_pct diff --git a/tests/unit/runtime_control/__init__.py b/tests/unit/runtime_control/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/runtime_control/test_service.py b/tests/unit/runtime_control/test_service.py new file mode 100644 index 0000000..b07e816 --- /dev/null +++ b/tests/unit/runtime_control/test_service.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +from nfi_engine.config.models import EngineSettings, RuntimeSettings +from nfi_engine.paper import BotCommand, BotState +from nfi_engine.preflight.models import PreflightReport +from nfi_engine.runtime_control import RuntimeControlCode, RuntimeControlRequest, control_runtime +from nfi_engine.runtime_health import ( + RuntimeHealthSnapshot, + RuntimeHealthState, + RuntimeResourceSnapshot, +) +from nfi_engine.strategy.nfi_x7 import build_x7_semantic_status +from nfi_engine.wallet import WalletBalanceCode, WalletBalanceSnapshot, WalletBalanceStatus + + +def test_pause_blocks_new_entries_when_runtime_is_running() -> None: + # Given: a running runtime. + request = _request(state=BotState.RUNNING, command=BotCommand.PAUSE) + + # When: the operator pauses entries. + result = control_runtime(request) + + # Then: entries are blocked while the runtime remains inspectable. + assert result.accepted is True + assert result.state is BotState.PAUSED + assert result.new_entries_allowed is False + assert result.code is RuntimeControlCode.RUNTIME_CONTROL_ACCEPTED + + +def test_resume_requires_preflight_when_report_is_missing() -> None: + # Given: a paused runtime with no preflight report. + request = _request( + state=BotState.PAUSED, + command=BotCommand.RESUME, + health=_health(RuntimeHealthState.HEALTHY), + ) + + # When: the operator resumes entries. + result = control_runtime(request) + + # Then: resume is blocked with a stable preflight code. + assert result.accepted is False + assert result.state is BotState.PAUSED + assert result.code is RuntimeControlCode.RUNTIME_PREFLIGHT_REQUIRED + + +def test_resume_when_runtime_health_is_blocked_keeps_entries_paused() -> None: + # Given: a paused runtime with preflight pass but blocked runtime health. + request = _request( + state=BotState.PAUSED, + command=BotCommand.RESUME, + readiness=_ready(), + health=_health(RuntimeHealthState.BLOCKED), + ) + + # When: the operator resumes entries. + result = control_runtime(request) + + # Then: resume stays blocked and does not permit new entries. + assert result.accepted is False + assert result.state is BotState.PAUSED + assert result.new_entries_allowed is False + assert result.code is RuntimeControlCode.RUNTIME_HEALTH_BLOCKED + + +def test_start_when_live_trading_is_enabled_returns_live_unsafe() -> None: + # Given: a stopped runtime with live trading intent enabled. + request = _request( + settings=RuntimeSettings(engine=EngineSettings(live_trading=True)), + state=BotState.STOPPED, + command=BotCommand.START, + readiness=_ready(), + health=_health(RuntimeHealthState.HEALTHY), + ) + + # When: the operator starts runtime entries. + result = control_runtime(request) + + # Then: the control refuses to imply live execution readiness. + assert result.accepted is False + assert result.state is BotState.STOPPED + assert result.code is RuntimeControlCode.RUNTIME_LIVE_UNSAFE + + +def test_stop_when_already_stopped_returns_stable_code() -> None: + # Given: an already stopped runtime. + request = _request(state=BotState.STOPPED, command=BotCommand.STOP) + + # When: the operator stops again. + result = control_runtime(request) + + # Then: the response is a stable no-op denial. + assert result.accepted is False + assert result.state is BotState.STOPPED + assert result.code is RuntimeControlCode.RUNTIME_ALREADY_STOPPED + + +def _request( + *, + state: BotState, + command: BotCommand, + settings: RuntimeSettings | None = None, + readiness: PreflightReport | None = None, + health: RuntimeHealthSnapshot | None = None, +) -> RuntimeControlRequest: + return RuntimeControlRequest( + settings=settings or RuntimeSettings(), + state=state, + command=command, + readiness=readiness, + health=health, + ) + + +def _ready() -> PreflightReport: + return PreflightReport(profile="paper", blocked=False, checks=()) + + +def _health(state: RuntimeHealthState) -> RuntimeHealthSnapshot: + now = datetime(2026, 6, 15, tzinfo=UTC) + return RuntimeHealthSnapshot( + generated_at=now, + state=state, + next_action="runtime health fixture", + checks=(), + resources=RuntimeResourceSnapshot( + captured_at=now, + free_disk_bytes=1_000_000_000, + memory_rss_bytes=100_000_000, + disk_state=state, + memory_state=RuntimeHealthState.HEALTHY, + ), + wallet_balance=WalletBalanceSnapshot( + status=WalletBalanceStatus.FETCHED, + code=WalletBalanceCode.FETCHED, + exchange="simulator", + trading_mode="spot", + captured_at=now, + equity=Decimal(1000), + available=Decimal(1000), + quote_asset="USDT", + position_count=0, + next_action="wallet fixture", + message="wallet fixture", + ), + x7_semantic_status=build_x7_semantic_status(settings=RuntimeSettings(), readiness=None), + ) diff --git a/tests/unit/runtime_health/__init__.py b/tests/unit/runtime_health/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/tests/unit/runtime_health/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/unit/runtime_health/test_service.py b/tests/unit/runtime_health/test_service.py new file mode 100644 index 0000000..2d9c158 --- /dev/null +++ b/tests/unit/runtime_health/test_service.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from pathlib import Path + +from nfi_engine.config.models import CircuitBreakerSettings, RuntimeSettings +from nfi_engine.dashboard import DashboardEquityPoint, DashboardReadModels +from nfi_engine.paper import BotState +from nfi_engine.preflight.models import PreflightReport +from nfi_engine.runtime_health import ( + RuntimeHealthCode, + RuntimeHealthRequest, + RuntimeHealthSnapshot, + RuntimeHealthState, + RuntimeResourceSnapshot, + build_runtime_health_snapshot, +) +from nfi_engine.wallet import WalletBalanceCode, WalletBalanceSnapshot, WalletBalanceStatus + +NOW = datetime(2026, 6, 15, tzinfo=UTC) + + +def test_runtime_health_is_healthy_for_fresh_data_and_fetched_wallet() -> None: + # Given: fresh dashboard data, passing preflight, and a fetched wallet balance. + snapshot = build_runtime_health_snapshot( + RuntimeHealthRequest( + settings=RuntimeSettings(), + bot_state=BotState.RUNNING, + readiness=PreflightReport(profile="paper", blocked=False, checks=()), + read_models=_read_models(NOW), + wallet_balance=_wallet(WalletBalanceStatus.FETCHED), + now=NOW, + resources=_resources(RuntimeHealthState.HEALTHY, RuntimeHealthState.HEALTHY), + ), + ) + + # Then: the aggregate health is ready for paper/testnet operation. + assert snapshot.state is RuntimeHealthState.HEALTHY + assert snapshot.next_action == "Runtime health is ready for paper/testnet operation." + assert _check_state(snapshot, RuntimeHealthCode.WALLET_BALANCE) is RuntimeHealthState.HEALTHY + + +def test_runtime_health_blocks_stale_runtime_data() -> None: + # Given: dashboard data older than the configured stale-data guard. + snapshot = build_runtime_health_snapshot( + RuntimeHealthRequest( + settings=RuntimeSettings(), + bot_state=BotState.RUNNING, + readiness=PreflightReport(profile="paper", blocked=False, checks=()), + read_models=_read_models(NOW - timedelta(seconds=400)), + wallet_balance=_wallet(WalletBalanceStatus.FETCHED), + now=NOW, + resources=_resources(RuntimeHealthState.HEALTHY, RuntimeHealthState.HEALTHY), + ), + ) + + # Then: stale market/runtime state blocks startup. + assert snapshot.state is RuntimeHealthState.BLOCKED + assert _check_state(snapshot, RuntimeHealthCode.DATA_FRESHNESS) is RuntimeHealthState.BLOCKED + + +def test_runtime_health_blocks_future_clock_skew() -> None: + # Given: persisted dashboard data from the future. + snapshot = build_runtime_health_snapshot( + RuntimeHealthRequest( + settings=RuntimeSettings(), + bot_state=BotState.RUNNING, + readiness=PreflightReport(profile="paper", blocked=False, checks=()), + read_models=_read_models(NOW + timedelta(seconds=120)), + wallet_balance=_wallet(WalletBalanceStatus.FETCHED), + now=NOW, + resources=_resources(RuntimeHealthState.HEALTHY, RuntimeHealthState.HEALTHY), + ), + ) + + # Then: clock skew has a dedicated blocker code. + assert snapshot.state is RuntimeHealthState.BLOCKED + assert _check_state(snapshot, RuntimeHealthCode.CLOCK_SKEW) is RuntimeHealthState.BLOCKED + + +def test_runtime_health_degrades_when_wallet_adapter_is_unavailable() -> None: + # Given: no live dashboard data yet and no wallet adapter. + snapshot = build_runtime_health_snapshot( + RuntimeHealthRequest( + settings=RuntimeSettings(), + bot_state=BotState.STOPPED, + readiness=PreflightReport(profile="paper", blocked=False, checks=()), + read_models=DashboardReadModels.empty(), + wallet_balance=_wallet(WalletBalanceStatus.UNAVAILABLE), + now=NOW, + resources=_resources(RuntimeHealthState.HEALTHY, RuntimeHealthState.HEALTHY), + ), + ) + + # Then: the operator gets degraded health rather than a false live-ready signal. + assert snapshot.state is RuntimeHealthState.DEGRADED + assert _check_state(snapshot, RuntimeHealthCode.WALLET_BALANCE) is RuntimeHealthState.DEGRADED + + +def test_runtime_health_blocks_manual_halt_file(tmp_path: Path) -> None: + # Given: the operator has dropped a manual halt file on disk. + halt_file = tmp_path / "manual-halt" + halt_file.write_text("halt\n", encoding="utf-8") + + snapshot = build_runtime_health_snapshot( + RuntimeHealthRequest( + settings=RuntimeSettings( + circuit_breakers=CircuitBreakerSettings(manual_halt_file=str(halt_file)), + ), + bot_state=BotState.RUNNING, + readiness=PreflightReport(profile="paper", blocked=False, checks=()), + read_models=_read_models(NOW), + wallet_balance=_wallet(WalletBalanceStatus.FETCHED), + now=NOW, + resources=_resources(RuntimeHealthState.HEALTHY, RuntimeHealthState.HEALTHY), + ), + ) + + # Then: file-based manual halt blocks runtime promotion like an active breaker. + assert snapshot.state is RuntimeHealthState.BLOCKED + assert ( + _check_state(snapshot, RuntimeHealthCode.CIRCUIT_BREAKER_STATE) + is RuntimeHealthState.BLOCKED + ) + + +def _read_models(at: datetime) -> DashboardReadModels: + return DashboardReadModels( + equity_points=(DashboardEquityPoint(at=at, equity=Decimal(1000), available=Decimal(900)),), + ) + + +def _wallet(status: WalletBalanceStatus) -> WalletBalanceSnapshot: + return WalletBalanceSnapshot( + status=status, + code=_wallet_code(status), + exchange="simulator", + trading_mode="spot", + captured_at=NOW if status is WalletBalanceStatus.FETCHED else None, + equity=Decimal(1000) if status is WalletBalanceStatus.FETCHED else None, + available=Decimal(900) if status is WalletBalanceStatus.FETCHED else None, + quote_asset="USDT", + position_count=0, + next_action="wallet action", + message="wallet message", + ) + + +def _wallet_code(status: WalletBalanceStatus) -> WalletBalanceCode: + if status is WalletBalanceStatus.FETCHED: + return WalletBalanceCode.FETCHED + return WalletBalanceCode.ADAPTER_UNAVAILABLE + + +def _resources( + disk_state: RuntimeHealthState, + memory_state: RuntimeHealthState, +) -> RuntimeResourceSnapshot: + return RuntimeResourceSnapshot( + captured_at=NOW, + free_disk_bytes=1024 * 1024 * 1024, + memory_rss_bytes=128 * 1024 * 1024, + disk_state=disk_state, + memory_state=memory_state, + ) + + +def _check_state(snapshot: RuntimeHealthSnapshot, code: RuntimeHealthCode) -> RuntimeHealthState: + for check in snapshot.checks: + if check.code is code: + return check.state + raise AssertionError(code.value) diff --git a/tests/unit/strategy/test_data_provider_facade.py b/tests/unit/strategy/test_data_provider_facade.py new file mode 100644 index 0000000..0415bf4 --- /dev/null +++ b/tests/unit/strategy/test_data_provider_facade.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from nfi_engine.domain import TradingMode, TradingPair +from nfi_engine.strategy import ( + DataProviderFacade, + PairFrame, + StrategyContractError, + StrategyErrorCode, + StrategyFrame, + StrategyRow, +) + + +def test_data_provider_facade_returns_visible_rows_only() -> None: + # Given + frame = _frame(first_close=Decimal(10), visible_row_count=1) + provider = DataProviderFacade( + frames=(PairFrame(pair=_base_pair(), timeframe="5m", frame=frame),), + ) + + # When + visible = provider.get_pair_dataframe(pair=_base_pair(), timeframe="5m") + + # Then + assert len(visible.rows) == 1 + assert visible.rows[0].close == Decimal(10) + + +def test_data_provider_facade_returns_base_informative_timeframe() -> None: + # Given + provider = DataProviderFacade( + frames=( + PairFrame(pair=_base_pair(), timeframe="5m", frame=_frame(first_close=Decimal(10))), + PairFrame(pair=_base_pair(), timeframe="1h", frame=_frame(first_close=Decimal(60))), + ), + ) + + # When + visible = provider.get_informative_dataframe(pair=_base_pair(), timeframe="1h") + + # Then + assert visible.rows[0].close == Decimal(60) + + +def test_data_provider_facade_returns_btc_informative_frame_for_futures_quote() -> None: + # Given + provider = DataProviderFacade( + frames=( + PairFrame(pair=_base_pair(), timeframe="5m", frame=_frame(first_close=Decimal(10))), + PairFrame( + pair=_btc_futures_pair(), timeframe="1h", frame=_frame(first_close=Decimal(100)) + ), + ), + ) + + # When + visible = provider.get_btc_informative_dataframe(pair=_base_pair(), timeframe="1h") + + # Then + assert provider.btc_pair_for(_base_pair()).normalized == "BTC/USDT:USDT" + assert visible.rows[0].close == Decimal(100) + + +def test_data_provider_facade_derives_btc_spot_pair_without_settle_asset() -> None: + # Given + provider = DataProviderFacade(frames=()) + + # When + btc_pair = provider.btc_pair_for(TradingPair.parse("ETH/USDT", TradingMode.SPOT)) + + # Then + assert btc_pair.normalized == "BTC/USDT" + + +def test_data_provider_facade_blocks_missing_pair() -> None: + # Given + provider = DataProviderFacade( + frames=( + PairFrame(pair=_base_pair(), timeframe="5m", frame=_frame(first_close=Decimal(10))), + ), + ) + + # When + with pytest.raises(StrategyContractError) as exc_info: + provider.get_pair_dataframe(pair=_btc_futures_pair(), timeframe="5m") + + # Then + assert exc_info.value.code is StrategyErrorCode.DATA_PROVIDER_FRAME_NOT_FOUND + + +def test_data_provider_facade_blocks_missing_timeframe() -> None: + # Given + provider = DataProviderFacade( + frames=( + PairFrame( + pair=_btc_futures_pair(), timeframe="1h", frame=_frame(first_close=Decimal(100)) + ), + ), + ) + + # When + with pytest.raises(StrategyContractError) as exc_info: + provider.get_btc_informative_dataframe(pair=_base_pair(), timeframe="4h") + + # Then + assert exc_info.value.code is StrategyErrorCode.DATA_PROVIDER_FRAME_NOT_FOUND + + +def test_data_provider_facade_blocks_stale_frame() -> None: + # Given + provider = DataProviderFacade( + frames=( + PairFrame( + pair=_btc_futures_pair(), + timeframe="1h", + frame=_frame(first_close=Decimal(100)), + stale=True, + ), + ), + ) + + # When + with pytest.raises(StrategyContractError) as exc_info: + provider.get_btc_informative_dataframe(pair=_base_pair(), timeframe="1h") + + # Then + assert exc_info.value.code is StrategyErrorCode.DATA_PROVIDER_FRAME_STALE + + +def test_data_provider_facade_deduplicates_current_whitelist() -> None: + # Given + provider = DataProviderFacade( + frames=( + PairFrame(pair=_base_pair(), timeframe="5m", frame=_frame(first_close=Decimal(10))), + PairFrame(pair=_base_pair(), timeframe="1h", frame=_frame(first_close=Decimal(60))), + PairFrame( + pair=_btc_futures_pair(), timeframe="1h", frame=_frame(first_close=Decimal(100)) + ), + ), + ) + + # When + whitelist = provider.current_whitelist() + + # Then + assert whitelist == ("ETH/USDT:USDT", "BTC/USDT:USDT") + + +def _base_pair() -> TradingPair: + return TradingPair.parse("ETH/USDT:USDT", TradingMode.FUTURES) + + +def _btc_futures_pair() -> TradingPair: + return TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES) + + +def _frame(*, first_close: Decimal, visible_row_count: int = 2) -> StrategyFrame: + second_close = first_close + Decimal(1) + return StrategyFrame( + rows=( + StrategyRow(date="2026-01-01T00:00:00Z", close=first_close), + StrategyRow(date="2026-01-01T00:05:00Z", close=second_close), + ), + visible_row_count=visible_row_count, + ) diff --git a/tests/unit/strategy/test_freqtrade_adapter.py b/tests/unit/strategy/test_freqtrade_adapter.py index fa81537..2ee208e 100644 --- a/tests/unit/strategy/test_freqtrade_adapter.py +++ b/tests/unit/strategy/test_freqtrade_adapter.py @@ -6,10 +6,9 @@ from nfi_engine.domain import Leverage, PositionSide, SignalType, TradingMode, TradingPair from nfi_engine.strategy import ( - DataProviderFacade, + CallbackSupportLevel, FreqtradeStrategyAdapter, NativeStrategy, - PairFrame, RunMode, StrategyContractError, StrategyErrorCode, @@ -24,6 +23,7 @@ LookaheadStrategy, MetadataLookupStrategy, NFISmokeStrategy, + UnsupportedCallbackStrategy, ) @@ -91,41 +91,62 @@ def test_optional_callbacks_are_reported_when_missing() -> None: assert "adjust_trade_position" not in inspection.detected_callbacks -def test_leverage_callback_returns_typed_leverage_when_present() -> None: +def test_callback_support_classifies_every_known_callback() -> None: # Given adapter = FreqtradeStrategyAdapter.from_strategy(NFISmokeStrategy()) # When - leverage = adapter.leverage(_metadata().pair, Leverage.one()) + inspection = adapter.inspect() + support_by_name = {item.name: item for item in inspection.callback_support} # Then - assert leverage.value == Decimal(3) + assert support_by_name["populate_indicators"].level is CallbackSupportLevel.SUPPORTED + assert support_by_name["populate_entry_trend"].level is CallbackSupportLevel.SUPPORTED + assert support_by_name["populate_exit_trend"].level is CallbackSupportLevel.SUPPORTED + assert support_by_name["informative_pairs"].level is CallbackSupportLevel.PARTIAL + assert support_by_name["custom_exit"].level is CallbackSupportLevel.PARTIAL + assert support_by_name["custom_stake_amount"].level is CallbackSupportLevel.PARTIAL + assert support_by_name["order_filled"].level is CallbackSupportLevel.PARTIAL + assert support_by_name["adjust_trade_position"].level is CallbackSupportLevel.PARTIAL + assert support_by_name["confirm_trade_entry"].level is CallbackSupportLevel.PARTIAL + assert support_by_name["confirm_trade_exit"].level is CallbackSupportLevel.PARTIAL + assert support_by_name["bot_loop_start"].level is CallbackSupportLevel.PARTIAL + assert support_by_name["leverage"].level is CallbackSupportLevel.PARTIAL + + +def test_unknown_public_strategy_callback_is_reported_as_excluded() -> None: + # Given + adapter = FreqtradeStrategyAdapter.from_strategy(UnsupportedCallbackStrategy()) + + # When + inspection = adapter.inspect() + support_by_name = {item.name: item for item in inspection.callback_support} + + # Then + assert support_by_name["custom_entry_price"].level is CallbackSupportLevel.EXCLUDED + assert support_by_name["custom_entry_price"].detected is True -def test_builtin_demo_strategy_spec_imports_from_default_config_shape() -> None: +def test_leverage_callback_returns_typed_leverage_when_present() -> None: # Given - strategy_spec = "nfi_engine.strategy.demo:AdapterSmokeStrategy" + adapter = FreqtradeStrategyAdapter.from_strategy(NFISmokeStrategy()) # When - strategy = load_freqtrade_strategy(strategy_spec) + leverage = adapter.leverage(_metadata().pair, Leverage.one()) # Then - assert FreqtradeStrategyAdapter.from_strategy(strategy).inspect().name == "AdapterSmokeStrategy" + assert leverage.value == Decimal(3) -def test_data_provider_facade_returns_visible_rows_only() -> None: +def test_builtin_demo_strategy_spec_imports_from_default_config_shape() -> None: # Given - frame = _frame(visible_row_count=1) - provider = DataProviderFacade( - frames=(PairFrame(pair=_metadata().pair, timeframe="5m", frame=frame),), - ) + strategy_spec = "nfi_engine.strategy.demo:AdapterSmokeStrategy" # When - visible = provider.get_pair_dataframe(pair=_metadata().pair, timeframe="5m") + strategy = load_freqtrade_strategy(strategy_spec) # Then - assert len(visible.rows) == 1 - assert visible.rows[0].close == Decimal(10) + assert FreqtradeStrategyAdapter.from_strategy(strategy).inspect().name == "AdapterSmokeStrategy" def test_missing_required_freqtrade_method_is_rejected() -> None: diff --git a/tests/unit/strategy/test_nfi_x7_entries.py b/tests/unit/strategy/test_nfi_x7_entries.py new file mode 100644 index 0000000..cf0c9da --- /dev/null +++ b/tests/unit/strategy/test_nfi_x7_entries.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from decimal import Decimal +from pathlib import Path + +from nfi_engine.backtest.frames import strategy_frame_for_cursor +from nfi_engine.data import load_candle_batch +from nfi_engine.domain import PositionSide, SignalType, TradingMode, TradingPair +from nfi_engine.strategy import ( + FreqtradeStrategyAdapter, + RunMode, + StrategyFrame, + StrategyMetadata, + StrategyRow, +) +from nfi_engine.strategy.nfi_x7 import X7NativeStrategy + +FIXTURE_ROOT = Path("tests/fixtures/candles") +ENTRY_TAG_LONG = "x7-long-momentum-balanced" +ENTRY_TAG_SHORT = "x7-short-momentum-fade" + + +def test_x7_entry_decision_marks_long_when_feature_graph_shows_bounded_up_move() -> None: + # Given + strategy = X7NativeStrategy() + adapter = FreqtradeStrategyAdapter.from_strategy(strategy) + batch = load_candle_batch(FIXTURE_ROOT / "btc_usdt_usdt_futures_5m.jsonl") + frame = strategy_frame_for_cursor(batch=batch, visible_count=2) + + # When + signals = adapter.analyze(frame, _metadata(pair=batch.pair), incremental=True) + + # Then + assert tuple((signal.side, signal.signal_type, signal.tag) for signal in signals) == ( + (PositionSide.LONG, SignalType.ENTER, ENTRY_TAG_LONG), + ) + + +def test_x7_entry_decision_marks_short_when_feature_graph_shows_bounded_down_move() -> None: + # Given + strategy = X7NativeStrategy() + adapter = FreqtradeStrategyAdapter.from_strategy(strategy) + pair = TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES) + frame = StrategyFrame( + rows=( + StrategyRow(date="2026-01-01T00:00:00+00:00", close=Decimal(105)), + StrategyRow(date="2026-01-01T00:05:00+00:00", close=Decimal(100)), + ), + ) + + # When + signals = adapter.analyze(frame, _metadata(pair=pair), incremental=True) + + # Then + assert tuple((signal.side, signal.signal_type, signal.tag) for signal in signals) == ( + (PositionSide.SHORT, SignalType.ENTER, ENTRY_TAG_SHORT), + ) + + +def test_x7_entry_decision_keeps_warmup_rows_signal_free() -> None: + # Given + strategy = X7NativeStrategy() + adapter = FreqtradeStrategyAdapter.from_strategy(strategy) + batch = load_candle_batch(FIXTURE_ROOT / "btc_usdt_usdt_futures_5m.jsonl") + frame = strategy_frame_for_cursor(batch=batch, visible_count=1) + + # When + signals = adapter.analyze(frame, _metadata(pair=batch.pair), incremental=True) + + # Then + assert signals == () + + +def _metadata(*, pair: TradingPair) -> StrategyMetadata: + return StrategyMetadata(pair=pair, timeframe="5m", runmode=RunMode.BACKTEST) diff --git a/tests/unit/strategy/test_nfi_x7_exits.py b/tests/unit/strategy/test_nfi_x7_exits.py new file mode 100644 index 0000000..8da416e --- /dev/null +++ b/tests/unit/strategy/test_nfi_x7_exits.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import Final + +from nfi_engine.backtest import ( + BacktestRequest, + ReproducibilityMetadata, + SimulationSettings, + result_to_json_payload, + run_backtest, +) +from nfi_engine.data import CandleBatch +from nfi_engine.domain import ( + Candle, + PositionSide, + Price, + Quantity, + SignalType, + TradeId, + TradingMode, + TradingPair, +) +from nfi_engine.strategy import ( + FreqtradeStrategyAdapter, + RunMode, + SignalColumns, + StrategyFeature, + StrategyFeatureName, + StrategyFrame, + StrategyMetadata, + StrategyRow, + StrategyTrade, +) +from nfi_engine.strategy.nfi_x7 import ( + LONG_EXIT_TAG, + SHORT_EXIT_TAG, + X7CustomExitReason, + X7ExitReason, + X7NativeStrategy, + build_x7_custom_exit_decision, + build_x7_exit_decision, +) + +ONE: Final = Decimal(1) +ZERO: Final = Decimal(0) +TEN: Final = Decimal(10) +ONE_THOUSAND: Final = Decimal(1000) +NOOP_SHORT_EXIT_TAG: Final = "unit-exit-short-noop" + + +def test_x7_exit_decision_marks_long_exit_on_bounded_pullback() -> None: + # Given + row = _row_with_features(roc=Decimal("-0.60"), range_pct=Decimal("2.0")) + + # When + decision = build_x7_exit_decision(row, visible_rows=3) + + # Then + assert decision.reason is X7ExitReason.LONG_MOMENTUM_COOLDOWN + assert decision.columns.exit_long is True + assert decision.columns.exit_tag == LONG_EXIT_TAG + + +def test_x7_exit_decision_marks_short_exit_on_bounded_rebound() -> None: + # Given + row = _row_with_features(roc=Decimal("0.30"), range_pct=Decimal("2.0")) + + # When + decision = build_x7_exit_decision(row, visible_rows=3) + + # Then + assert decision.reason is X7ExitReason.SHORT_MOMENTUM_COOLDOWN + assert decision.columns.exit_short is True + assert decision.columns.exit_tag == SHORT_EXIT_TAG + + +def test_x7_exit_decision_keeps_warmup_rows_signal_free() -> None: + # Given + row = _row_with_features(roc=Decimal("-0.60"), range_pct=Decimal("2.0")) + + # When + decision = build_x7_exit_decision(row, visible_rows=1) + + # Then + assert decision.reason is X7ExitReason.WARMUP + assert decision.columns.exit_long is False + assert decision.columns.exit_tag is None + + +def test_x7_native_strategy_surfaces_exit_reason_through_adapter_signal() -> None: + # Given + strategy = X7NativeStrategy() + adapter = FreqtradeStrategyAdapter.from_strategy(strategy) + frame = StrategyFrame( + rows=( + StrategyRow(date="2026-01-01T00:00:00+00:00", close=Decimal(100)), + StrategyRow(date="2026-01-01T00:05:00+00:00", close=Decimal(101)), + StrategyRow(date="2026-01-01T00:10:00+00:00", close=Decimal("100.4")), + ), + ) + + # When + signals = adapter.analyze(frame, _metadata(), incremental=True) + + # Then + assert tuple((signal.side, signal.signal_type, signal.tag) for signal in signals) == ( + (PositionSide.LONG, SignalType.EXIT, LONG_EXIT_TAG), + ) + + +def test_x7_backtest_closes_long_with_native_exit_reason_and_timeline_reason() -> None: + # Given + request = _backtest_request( + strategy=X7NativeStrategy(), + closes=(Decimal(100), Decimal(101), Decimal("100.4")), + ) + + # When + result = run_backtest(request) + payload = result_to_json_payload(result) + + # Then + assert result.summary.total_trades == 1 + assert payload["trades"][0]["side"] == "long" + assert payload["trades"][0]["exit_reason"] == LONG_EXIT_TAG + assert payload["timeline"]["steps"][2]["exit_signals"] == 1 + assert payload["timeline"]["steps"][2]["exit_reasons"] == [LONG_EXIT_TAG] + assert payload["timeline"]["steps"][2]["closed_orders"] == 1 + + +def test_backtest_records_noop_reason_when_exit_signal_side_has_no_open_trade() -> None: + # Given + request = _backtest_request( + strategy=OppositeSideExitStrategy(), + closes=(Decimal(100), Decimal(101), Decimal(102)), + ) + + # When + result = run_backtest(request) + payload = result_to_json_payload(result) + + # Then + assert result.summary.total_trades == 1 + assert payload["trades"][0]["exit_reason"] == "end_of_data" + assert payload["timeline"]["steps"][1]["exit_sides"] == ["short"] + assert payload["timeline"]["steps"][1]["exit_reasons"] == [NOOP_SHORT_EXIT_TAG] + assert payload["timeline"]["steps"][1]["closed_orders"] == 0 + assert payload["timeline"]["steps"][1]["rejected_actions"] == 1 + assert payload["timeline"]["steps"][1]["open_trade_count"] == 1 + + +def test_x7_custom_exit_decision_stays_explicit_when_trade_lacks_feature_context() -> None: + # Given + pair = TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES) + trade = StrategyTrade(trade_id=TradeId("trade-1"), pair=pair, side=PositionSide.LONG) + + # When + decision = build_x7_custom_exit_decision(trade) + + # Then + assert decision.reason is X7CustomExitReason.FEATURE_CONTEXT_REQUIRED + assert decision.exit_reason is None + + +class OppositeSideExitStrategy: + timeframe: str = "5m" + can_short: bool = True + + def populate_indicators( + self, + dataframe: StrategyFrame, + _metadata: StrategyMetadata, + ) -> StrategyFrame: + return dataframe + + def populate_entry_trend( + self, + dataframe: StrategyFrame, + _metadata: StrategyMetadata, + ) -> StrategyFrame: + if dataframe.last_visible_row().date == "2026-01-01T00:00:00+00:00": + return dataframe.with_signal(index=-1, columns=SignalColumns(enter_long=True)) + return dataframe + + def populate_exit_trend( + self, + dataframe: StrategyFrame, + _metadata: StrategyMetadata, + ) -> StrategyFrame: + if dataframe.last_visible_row().date == "2026-01-01T00:05:00+00:00": + return dataframe.with_signal( + index=-1, + columns=SignalColumns(exit_short=True, exit_tag=NOOP_SHORT_EXIT_TAG), + ) + return dataframe + + +def _row_with_features(*, roc: Decimal, range_pct: Decimal) -> StrategyRow: + return StrategyRow( + date="2026-01-01T00:10:00+00:00", + close=Decimal(100), + features=( + StrategyFeature(name=StrategyFeatureName("x7_base_roc_1"), value=roc), + StrategyFeature(name=StrategyFeatureName("x7_base_range_pct"), value=range_pct), + ), + ) + + +def _backtest_request( + *, + strategy: object, + closes: tuple[Decimal, ...], +) -> BacktestRequest: + return BacktestRequest( + candles=_batch_with_closes(closes), + adapter=FreqtradeStrategyAdapter.from_strategy(strategy), + settings=_settings(), + config_digest="x7-exit-unit", + strategy_name=type(strategy).__name__, + metadata=_reproducibility_metadata(), + ) + + +def _batch_with_closes(closes: tuple[Decimal, ...]) -> CandleBatch: + pair = TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES) + opened_at = datetime(2026, 1, 1, tzinfo=UTC) + candles = tuple( + Candle( + pair=pair, + opened_at=opened_at + timedelta(minutes=index * 5), + open=Price(close), + high=Price(close), + low=Price(close), + close=Price(close), + volume=Quantity(ONE), + ) + for index, close in enumerate(closes) + ) + return CandleBatch(pair=pair, timeframe="5m", candles=candles) + + +def _settings() -> SimulationSettings: + return SimulationSettings( + trading_mode=TradingMode.FUTURES, + starting_balance=ONE_THOUSAND, + stake_amount=TEN, + fee_rate=ZERO, + slippage_rate=ZERO, + max_open_trades=1, + leverage=ONE, + liquidation_buffer=Decimal("0.05"), + stoploss_pct=Decimal("0.10"), + ) + + +def _reproducibility_metadata() -> ReproducibilityMetadata: + return ReproducibilityMetadata( + config_hash="x7-exit-unit", + strategy_hash="strategy-x7-exit-unit", + data_hash="data-x7-exit-unit", + engine_version="0.1.0", + git_commit=None, + dependency_lock_hash="lock-x7-exit-unit", + python_version="3.12.0", + created_at=datetime(2026, 1, 1, tzinfo=UTC), + command_args=("backtest", "--config", "x7-exit-unit.yaml"), + ) + + +def _metadata() -> StrategyMetadata: + return StrategyMetadata( + pair=TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES), + timeframe="5m", + runmode=RunMode.BACKTEST, + ) diff --git a/tests/unit/strategy/test_nfi_x7_feature_graph.py b/tests/unit/strategy/test_nfi_x7_feature_graph.py new file mode 100644 index 0000000..cdc7d3a --- /dev/null +++ b/tests/unit/strategy/test_nfi_x7_feature_graph.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from decimal import Decimal +from pathlib import Path + +import pytest + +from nfi_engine.backtest.frames import strategy_frame_for_cursor +from nfi_engine.data import load_candle_batch +from nfi_engine.strategy import ( + DataProviderFacade, + PairFrame, + StrategyContractError, + StrategyErrorCode, + StrategyFeatureName, + StrategyFrame, +) +from nfi_engine.strategy.nfi_x7 import ( + X7FeatureGraph, + X7FeatureGraphContext, + X7FeatureGraphRequest, +) + +FIXTURE_ROOT = Path("tests/fixtures/candles") +FEATURE_BUDGET = 64 + + +def test_feature_graph_adds_deterministic_bounded_features_from_base_and_informatives() -> None: + # Given + base_frame, provider = _feature_graph_inputs(visible_count=6) + graph = X7FeatureGraph() + context = X7FeatureGraphContext( + base_frame=base_frame, + provider=provider, + request=X7FeatureGraphRequest( + pair=load_candle_batch(FIXTURE_ROOT / "btc_usdt_5m.jsonl").pair, + base_timeframe="5m", + informative_timeframes=("15m", "1h"), + ), + ) + + # When + first = graph.build(context) + second = graph.build(context) + + # Then + assert first == second + assert first.cache_hit is False + assert second.cache_hit is True + assert graph.cache_stats.hit_count == 1 + assert graph.cache_stats.miss_count == 1 + assert first.coverage.base_feature_count >= 8 + assert first.coverage.informative_feature_count >= 6 + assert first.coverage.total_feature_count <= FEATURE_BUDGET + assert first.coverage.informative_timeframes == ("15m", "1h") + assert first.frame.last_visible_row().feature(StrategyFeatureName("x7_base_rsi_3")) == Decimal( + "65.38461538461538461538461537", + ) + assert first.frame.last_visible_row().feature( + StrategyFeatureName("x7_base_15m_range_pct"), + ) == Decimal("6.481481481481481481481481481") + + +def test_feature_graph_uses_only_visible_rows_and_never_enriches_hidden_future_rows() -> None: + # Given + base_frame, provider = _feature_graph_inputs(visible_count=4) + graph = X7FeatureGraph() + context = X7FeatureGraphContext( + base_frame=base_frame, + provider=provider, + request=X7FeatureGraphRequest( + pair=load_candle_batch(FIXTURE_ROOT / "btc_usdt_5m.jsonl").pair, + base_timeframe="5m", + informative_timeframes=("15m",), + ), + ) + + # When + result = graph.build(context) + + # Then + assert len(result.frame.rows) == 4 + assert result.frame.rows[-1].date == "2026-01-01T00:15:00+00:00" + assert result.frame.future_rows() == () + + +def test_feature_graph_rejects_missing_informative_frame_without_synthesis() -> None: + # Given + base_frame, provider = _feature_graph_inputs(visible_count=6) + graph = X7FeatureGraph() + context = X7FeatureGraphContext( + base_frame=base_frame, + provider=provider, + request=X7FeatureGraphRequest( + pair=load_candle_batch(FIXTURE_ROOT / "btc_usdt_5m.jsonl").pair, + base_timeframe="5m", + informative_timeframes=("4h",), + ), + ) + + # When + with pytest.raises(StrategyContractError) as exc_info: + graph.build(context) + + # Then + assert exc_info.value.code is StrategyErrorCode.DATA_PROVIDER_FRAME_NOT_FOUND + + +def _feature_graph_inputs(*, visible_count: int) -> tuple[StrategyFrame, DataProviderFacade]: + base = load_candle_batch(FIXTURE_ROOT / "btc_usdt_5m.jsonl") + informative_15m = load_candle_batch(FIXTURE_ROOT / "btc_usdt_15m.jsonl") + informative_1h = load_candle_batch(FIXTURE_ROOT / "btc_usdt_1h.jsonl") + base_frame = strategy_frame_for_cursor(batch=base, visible_count=visible_count) + provider = DataProviderFacade( + frames=( + PairFrame( + pair=base.pair, + timeframe=base.timeframe, + frame=base_frame, + ), + PairFrame( + pair=informative_15m.pair, + timeframe=informative_15m.timeframe, + frame=strategy_frame_for_cursor( + batch=informative_15m, + visible_count=len(informative_15m.candles), + ), + ), + PairFrame( + pair=informative_1h.pair, + timeframe=informative_1h.timeframe, + frame=strategy_frame_for_cursor( + batch=informative_1h, + visible_count=len(informative_1h.candles), + ), + ), + ), + ) + return base_frame, provider diff --git a/tests/unit/strategy/test_nfi_x7_indicators.py b/tests/unit/strategy/test_nfi_x7_indicators.py new file mode 100644 index 0000000..83df103 --- /dev/null +++ b/tests/unit/strategy/test_nfi_x7_indicators.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from nfi_engine.strategy.nfi_x7.indicators import ( + OhlcvSeries, + StochasticConfig, + StochasticRsiConfig, + X7IndicatorError, + X7IndicatorErrorCode, + average_true_range, + chaikin_money_flow, + crossed_above, + crossed_below, + exponential_moving_average, + pct_change, + range_percent, + rate_of_change, + relative_strength_index, + rolling_max, + rolling_mean, + rolling_min, + rolling_sum, + simple_moving_average, + stochastic_oscillator, + stochastic_rsi, + true_range, + williams_r, +) + + +def test_rolling_and_moving_averages_are_bounded_and_deterministic() -> None: + # Given + values = _series("1", "2", "3", "4", "5") + + # When + sums = rolling_sum(values, window=3) + means = rolling_mean(values, window=3) + lows = rolling_min(values, window=3) + highs = rolling_max(values, window=3) + simple = simple_moving_average(values, window=3) + exponential = exponential_moving_average(values, period=3) + + # Then + assert sums == (None, None, Decimal(6), Decimal(9), Decimal(12)) + assert means == (None, None, Decimal(2), Decimal(3), Decimal(4)) + assert lows == (None, None, Decimal(1), Decimal(2), Decimal(3)) + assert highs == (None, None, Decimal(3), Decimal(4), Decimal(5)) + assert simple == means + assert exponential == (None, None, Decimal(2), Decimal(3), Decimal(4)) + + +def test_momentum_oscillators_handle_warmup_and_zero_denominators() -> None: + # Given + rising = _series("1", "2", "3", "4", "5") + flat = _series("10", "10", "10", "10") + with_zero = _series("0", "2", "4") + + # When + fractional_change = pct_change(rising, period=2) + percent_change = rate_of_change(rising, period=2) + rising_rsi = relative_strength_index(rising, period=3) + flat_rsi = relative_strength_index(flat, period=3) + zero_change = pct_change(with_zero, period=1) + + # Then + assert fractional_change == ( + None, + None, + Decimal(2), + Decimal(1), + Decimal("0.6666666666666666666666666667"), + ) + assert percent_change == ( + None, + None, + Decimal(200), + Decimal(100), + Decimal("66.66666666666666666666666667"), + ) + assert rising_rsi == (None, None, None, Decimal(100), Decimal(100)) + assert flat_rsi == (None, None, None, Decimal(50)) + assert zero_change == (None, None, Decimal(1)) + + +def test_stochastic_indicators_use_only_complete_windows() -> None: + # Given + ohlcv = OhlcvSeries( + high=_series("10", "11", "12", "13", "14"), + low=_series("5", "5", "6", "7", "8"), + close=_series("7", "10", "11", "12", "13"), + volume=_series("1", "1", "1", "1", "1"), + ) + closes = _series("1", "2", "3", "2", "1", "2", "3", "4") + + # When + stochastic = stochastic_oscillator(ohlcv, StochasticConfig(k_period=3, d_period=2)) + stoch_rsi = stochastic_rsi( + closes, + StochasticRsiConfig(rsi_period=2, stoch_period=3, smooth_k=2, smooth_d=2), + ) + + # Then + assert stochastic.percent_k[:3] == (None, None, Decimal("85.71428571428571428571428571")) + assert _rounded(stochastic.percent_d[3]) == Decimal("86.6071") + assert stoch_rsi.percent_k[:5] == (None, None, None, None, None) + assert _rounded(stoch_rsi.percent_k[-1]) == Decimal("100.0000") + assert _rounded(stoch_rsi.percent_d[-1]) == Decimal("100.0000") + + +def test_volume_volatility_range_and_cross_helpers_are_safe() -> None: + # Given + ohlcv = OhlcvSeries( + high=_series("10", "12", "11", "13"), + low=_series("8", "9", "9", "10"), + close=_series("9", "11", "10", "12"), + volume=_series("100", "150", "120", "130"), + ) + left = (None, Decimal(1), Decimal(2), Decimal(1), Decimal(3)) + right = (Decimal(1), Decimal(1), Decimal(1), Decimal(1), Decimal(2)) + + # When + wr = williams_r(ohlcv, period=2) + cmf = chaikin_money_flow(ohlcv, period=2) + ranges = true_range(ohlcv) + atr = average_true_range(ohlcv, period=2) + range_pct = range_percent(ohlcv) + above = crossed_above(left, right) + below = crossed_below((None, Decimal(2), Decimal(0)), (Decimal(1), Decimal(1), Decimal(1))) + + # Then + assert _rounded(wr[2]) == Decimal("-66.6667") + assert wr[0] is None + assert wr[1] == Decimal(-25) + assert wr[3] == Decimal(-25) + assert cmf[0] is None + assert cmf[1] == Decimal("0.2") + assert _rounded(cmf[2]) == Decimal("0.1852") + assert _rounded(cmf[3]) == Decimal("0.1733") + assert ranges == (Decimal(2), Decimal(3), Decimal(2), Decimal(3)) + assert atr == (None, Decimal("2.5"), Decimal("2.25"), Decimal("2.625")) + assert _rounded(range_pct[0]) == Decimal("22.2222") + assert above == (False, False, True, False, True) + assert below == (False, False, True) + + +def test_indicator_primitives_reject_invalid_period_and_length_mismatch() -> None: + # Given + values = _series("1", "2", "3") + + # When + with pytest.raises(X7IndicatorError) as invalid_period: + rolling_mean(values, window=0) + with pytest.raises(X7IndicatorError) as length_mismatch: + OhlcvSeries( + high=_series("1", "2"), + low=_series("1"), + close=_series("1", "2"), + volume=_series("1", "2"), + ) + + # Then + assert invalid_period.value.code is X7IndicatorErrorCode.INVALID_PERIOD + assert length_mismatch.value.code is X7IndicatorErrorCode.SERIES_LENGTH_MISMATCH + + +def _series(*raw_values: str) -> tuple[Decimal, ...]: + return tuple(Decimal(raw_value) for raw_value in raw_values) + + +def _rounded(value: Decimal | None) -> Decimal: + assert value is not None + return value.quantize(Decimal("0.0001")) diff --git a/tests/unit/strategy/test_nfi_x7_native.py b/tests/unit/strategy/test_nfi_x7_native.py new file mode 100644 index 0000000..f75b003 --- /dev/null +++ b/tests/unit/strategy/test_nfi_x7_native.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +from decimal import Decimal +from pathlib import Path + +from nfi_engine.domain import ( + Leverage, + OrderId, + PositionSide, + StakeAmount, + TradeId, + TradingMode, + TradingPair, +) +from nfi_engine.strategy import ( + FreqtradeStrategyAdapter, + RunMode, + StrategyFeatureName, + StrategyFrame, + StrategyMetadata, + StrategyOrder, + StrategyRow, + StrategyTrade, +) +from nfi_engine.strategy.nfi_x7 import ( + LONG_ENTRY_TAG, + X7_DATA_REQUIREMENTS, + X7_METADATA, + X7CoverageModule, + X7CoverageStatus, + X7NativeStrategy, + build_x7_coverage_report, +) + + +def test_native_x7_metadata_and_data_requirements_are_engine_owned() -> None: + # Given + metadata = X7_METADATA + requirements = X7_DATA_REQUIREMENTS + + # When + coverage = build_x7_coverage_report() + + # Then + assert metadata.name == "NFI_X7_NATIVE" + assert metadata.strategy_class_name == "X7NativeStrategy" + assert metadata.observed_upstream_version == "v17.4.258" + assert metadata.base_timeframe == "5m" + assert requirements.base_timeframe == "5m" + assert requirements.informative_timeframes == ("15m", "1h", "4h", "1d") + assert requirements.mandatory_external_dependencies == () + assert coverage.is_full_semantic_coverage is True + assert coverage.pending_modules == () + assert "metadata" in coverage.covered_modules + assert "indicator_runtime" in coverage.covered_modules + assert "feature_graph" in coverage.covered_modules + assert "entry_signals" in coverage.covered_modules + assert "exit_signals" in coverage.covered_modules + assert "stake_sizing" in coverage.covered_modules + assert "protections" in coverage.covered_modules + assert "release_docs" in coverage.covered_modules + assert coverage.modules[0].status is X7CoverageStatus.VERIFIED + assert coverage.modules[0].evidence_path.endswith("task-01-provenance-coverage.md") + + +def test_native_x7_coverage_blocks_verified_module_when_evidence_is_missing( + tmp_path: Path, +) -> None: + # Given + modules = ( + X7CoverageModule( + name="metadata", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/missing.md", + ), + ) + + # When + coverage = build_x7_coverage_report( + modules, + project_root=tmp_path, + require_evidence_artifacts=True, + ) + + # Then + assert coverage.is_full_semantic_coverage is False + assert coverage.covered_modules == () + assert coverage.pending_modules == ("metadata",) + assert coverage.modules[0].status is X7CoverageStatus.BLOCKED + assert coverage.modules[0].blocker == ( + "Verified coverage module is missing its required evidence artifact." + ) + + +def test_native_x7_coverage_uses_packaged_manifest_when_evidence_gate_is_disabled( + tmp_path: Path, +) -> None: + modules = ( + X7CoverageModule( + name="metadata", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/missing.md", + ), + ) + + coverage = build_x7_coverage_report( + modules, + project_root=tmp_path, + require_evidence_artifacts=False, + ) + + assert coverage.is_full_semantic_coverage is True + assert coverage.covered_modules == ("metadata",) + assert coverage.pending_modules == () + assert coverage.modules[0].status is X7CoverageStatus.VERIFIED + assert coverage.modules[0].blocker is None + + +def test_native_x7_coverage_default_ignores_incidental_worktree_evidence( + tmp_path: Path, +) -> None: + # Given + (tmp_path / ".omo" / "evidence").mkdir(parents=True) + modules = ( + X7CoverageModule( + name="metadata", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/missing.md", + ), + ) + + # When + coverage = build_x7_coverage_report(modules, project_root=tmp_path) + + # Then + assert coverage.is_full_semantic_coverage is True + assert coverage.covered_modules == ("metadata",) + assert coverage.pending_modules == () + assert coverage.modules[0].status is X7CoverageStatus.VERIFIED + + +def test_native_x7_coverage_can_only_be_full_when_all_modules_are_verified_with_evidence( + tmp_path: Path, +) -> None: + # Given + evidence_path = tmp_path / ".omo" / "evidence" / "complete.md" + evidence_path.parent.mkdir(parents=True) + evidence_path.write_text("ok\n", encoding="utf-8") + modules = ( + X7CoverageModule( + name="metadata", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/complete.md", + ), + X7CoverageModule( + name="runtime_integration", + status=X7CoverageStatus.VERIFIED, + evidence_path=".omo/evidence/complete.md", + ), + ) + + # When + coverage = build_x7_coverage_report( + modules, + project_root=tmp_path, + require_evidence_artifacts=True, + ) + + # Then + assert coverage.is_full_semantic_coverage is True + assert coverage.covered_modules == ("metadata", "runtime_integration") + assert coverage.pending_modules == () + + +def test_native_x7_strategy_contract_is_selectable_by_existing_adapter() -> None: + # Given + strategy = X7NativeStrategy() + adapter = FreqtradeStrategyAdapter.from_strategy(strategy) + + # When + inspection = adapter.inspect() + + # Then + assert inspection.name == "X7NativeStrategy" + assert inspection.can_short is True + assert inspection.timeframe == "5m" + assert set(inspection.detected_callbacks) >= { + "populate_indicators", + "populate_entry_trend", + "populate_exit_trend", + "informative_pairs", + "custom_exit", + "custom_stake_amount", + "order_filled", + "adjust_trade_position", + "confirm_trade_entry", + "confirm_trade_exit", + "bot_loop_start", + "leverage", + } + + +def test_native_x7_strategy_methods_build_features_and_entry_signals() -> None: + # Given + strategy = X7NativeStrategy() + frame = _frame() + metadata = _metadata() + pair = metadata.pair + stake = StakeAmount(Decimal(25)) + trade = StrategyTrade(trade_id=TradeId("trade-1"), pair=pair, side=PositionSide.LONG) + order = StrategyOrder(order_id=OrderId("order-1"), pair=pair, side=PositionSide.LONG) + + # When + indicator_frame = strategy.populate_indicators(frame, metadata) + entry_frame = strategy.populate_entry_trend(indicator_frame, metadata) + exit_frame = strategy.populate_exit_trend(entry_frame, metadata) + informative_pairs = strategy.informative_pairs() + custom_exit = strategy.custom_exit(trade) + custom_stake = strategy.custom_stake_amount(pair, stake) + leverage = strategy.leverage(pair, Leverage.one()) + strategy.order_filled(order, trade) + position_adjustment = strategy.adjust_trade_position(trade) + strategy.bot_loop_start() + + # Then + assert indicator_frame.last_visible_row().feature(StrategyFeatureName("x7_base_roc_1")) > 0 + assert entry_frame.last_visible_row().enter_long is True + assert entry_frame.last_visible_row().enter_tag == LONG_ENTRY_TAG + assert exit_frame is entry_frame + assert informative_pairs == ( + ("BTC/USDT:USDT", "15m"), + ("BTC/USDT:USDT", "1h"), + ("BTC/USDT:USDT", "4h"), + ("BTC/USDT:USDT", "1d"), + ) + assert custom_exit is None + assert custom_stake == stake + assert leverage.value == Decimal(3) + assert position_adjustment is None + + +def _metadata() -> StrategyMetadata: + return StrategyMetadata( + pair=TradingPair.parse("ETH/USDT:USDT", TradingMode.FUTURES), + timeframe="5m", + runmode=RunMode.BACKTEST, + ) + + +def _frame() -> StrategyFrame: + return StrategyFrame( + rows=( + StrategyRow(date="2026-01-01T00:00:00Z", close=Decimal(10)), + StrategyRow(date="2026-01-01T00:05:00Z", close=Decimal(11)), + ), + ) diff --git a/tests/unit/strategy/test_nfi_x7_positioning.py b/tests/unit/strategy/test_nfi_x7_positioning.py new file mode 100644 index 0000000..2b4890b --- /dev/null +++ b/tests/unit/strategy/test_nfi_x7_positioning.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from decimal import Decimal +from typing import Final + +from nfi_engine.domain import ( + AccountSnapshot, + Leverage, + OrderId, + PositionSide, + StakeAmount, + TradeId, + TradingMode, + TradingPair, +) +from nfi_engine.risk import ( + AcceptedOrderQuote, + RiskPolicy, + RiskRequest, + quote_order, +) +from nfi_engine.strategy import StrategyOrder, StrategyTrade +from nfi_engine.strategy.nfi_x7.positioning import ( + X7LeverageContext, + X7LeverageReason, + X7PositionAdjustmentContext, + X7PositionAdjustmentReason, + X7StakeContext, + X7StakeReason, + build_x7_leverage_decision, + build_x7_order_filled_snapshot, + build_x7_position_adjustment_decision, + build_x7_stake_decision, +) + +NOW: Final = datetime(2026, 6, 20, tzinfo=UTC) + + +def test_x7_stake_decision_caps_to_wallet_and_allocation_limits() -> None: + # Given + context = X7StakeContext( + proposed_stake=StakeAmount(Decimal(100)), + available_balance=StakeAmount(Decimal(80)), + allocation_cap=StakeAmount(Decimal(60)), + ) + + # When + decision = build_x7_stake_decision(context) + + # Then + assert decision.stake == StakeAmount(Decimal(60)) + assert decision.reason is X7StakeReason.CAPPED_BY_ALLOCATION + assert decision.capped is True + + +def test_x7_default_leverage_is_three_and_risk_service_caps_above_policy_max() -> None: + # Given + pair = TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES) + leverage_decision = build_x7_leverage_decision( + X7LeverageContext(max_leverage=Leverage.parse("2")), + ) + request = _risk_request( + RequestContext( + pair=pair, + stake=StakeAmount(Decimal(25)), + requested_leverage=leverage_decision.leverage.value, + max_leverage=Leverage.parse("2"), + ), + ) + + # When + quote = quote_order(request) + + # Then + assert leverage_decision.leverage == Leverage.parse("2") + assert leverage_decision.reason is X7LeverageReason.CAPPED + assert quote == AcceptedOrderQuote( + pair=pair, + side=PositionSide.LONG, + stake=StakeAmount(Decimal(25)), + leverage=Leverage.parse("2"), + adjusted=False, + reason=None, + ) + + +def test_x7_order_filled_snapshot_preserves_order_trade_identity() -> None: + # Given + pair = TradingPair.parse("ETH/USDT:USDT", TradingMode.FUTURES) + order = StrategyOrder(order_id=OrderId("order-1"), pair=pair, side=PositionSide.SHORT) + trade = StrategyTrade(trade_id=TradeId("trade-1"), pair=pair, side=PositionSide.SHORT) + + # When + snapshot = build_x7_order_filled_snapshot(order=order, trade=trade) + + # Then + assert snapshot.order_id == "order-1" + assert snapshot.trade_id == "trade-1" + assert snapshot.pair == pair + assert snapshot.side is PositionSide.SHORT + assert snapshot.pair_and_side_match is True + + +def test_x7_position_adjustment_is_disabled_without_explicit_bounded_context() -> None: + # Given + pair = TradingPair.parse("ETH/USDT:USDT", TradingMode.FUTURES) + trade = StrategyTrade(trade_id=TradeId("trade-2"), pair=pair, side=PositionSide.LONG) + + # When + decision = build_x7_position_adjustment_decision( + X7PositionAdjustmentContext(trade=trade), + ) + + # Then + assert decision.stake is None + assert decision.reason is X7PositionAdjustmentReason.NO_ADJUSTMENT + assert decision.capped is False + + +def test_x7_position_adjustment_caps_to_available_balance() -> None: + # Given + pair = TradingPair.parse("ETH/USDT:USDT", TradingMode.FUTURES) + trade = StrategyTrade(trade_id=TradeId("trade-3"), pair=pair, side=PositionSide.LONG) + + # When + decision = build_x7_position_adjustment_decision( + X7PositionAdjustmentContext( + trade=trade, + proposed_stake=StakeAmount(Decimal(50)), + max_adjustment=StakeAmount(Decimal(40)), + available_balance=StakeAmount(Decimal(25)), + ), + ) + + # Then + assert decision.stake == StakeAmount(Decimal(25)) + assert decision.reason is X7PositionAdjustmentReason.CAPPED_BY_AVAILABLE + assert decision.capped is True + + +@dataclass(frozen=True, slots=True) +class RequestContext: + pair: TradingPair + stake: StakeAmount + requested_leverage: Decimal + max_leverage: Leverage + + +def _risk_request(context: RequestContext) -> RiskRequest: + return RiskRequest( + pair=context.pair, + side=PositionSide.LONG, + stake=context.stake, + requested_leverage=context.requested_leverage, + account=AccountSnapshot( + captured_at=NOW, + equity=StakeAmount(Decimal(1000)), + available=StakeAmount(Decimal(100)), + positions=(), + ), + policy=RiskPolicy( + trading_mode=TradingMode.FUTURES, + max_open_trades=3, + max_leverage=context.max_leverage, + stoploss_pct=Decimal("0.10"), + minimal_roi=Decimal("0.03"), + paper_trading=True, + testnet=True, + live_trading=False, + ), + pair_locks=(), + cooldown_until=None, + current_time=NOW, + ) diff --git a/tests/unit/strategy/test_nfi_x7_protections.py b/tests/unit/strategy/test_nfi_x7_protections.py new file mode 100644 index 0000000..8e91503 --- /dev/null +++ b/tests/unit/strategy/test_nfi_x7_protections.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Final + +from nfi_engine.circuit_breakers import ( + CircuitBreakerDecision, + CircuitBreakerKind, + CircuitBreakerTrigger, +) +from nfi_engine.domain import PositionSide, TradingMode, TradingPair +from nfi_engine.risk import PairLock +from nfi_engine.strategy import FreqtradeStrategyAdapter +from nfi_engine.strategy.nfi_x7 import ( + X7CooldownGuardContext, + X7LoopHookContext, + X7LoopHookReason, + X7NativeStrategy, + X7PairLockGuardContext, + X7ProtectionReason, + X7StaleDataGuardContext, + X7TradeConfirmationContext, + build_x7_circuit_breaker_guard, + build_x7_cooldown_guard, + build_x7_loop_hook_decision, + build_x7_pair_lock_guard, + build_x7_stale_data_guard, + build_x7_trade_confirmation_decision, +) + +NOW: Final = datetime(2026, 1, 1, tzinfo=UTC) + + +def test_x7_protection_guards_emit_stable_machine_reasons() -> None: + # Given + pair = _pair() + pair_lock = PairLock( + pair=pair, + reason="operator lock", + expires_at=NOW + timedelta(minutes=1), + ) + circuit_decision = CircuitBreakerDecision( + trading_halted=True, + new_orders_blocked=True, + emergency_exit=False, + triggered=( + CircuitBreakerTrigger( + kind=CircuitBreakerKind.STALE_DATA, + message="market data stream is stale", + ), + ), + ) + + # When + lock_guard = build_x7_pair_lock_guard( + X7PairLockGuardContext(pair=pair, pair_locks=(pair_lock,), current_time=NOW), + ) + cooldown_guard = build_x7_cooldown_guard( + X7CooldownGuardContext( + cooldown_until=NOW + timedelta(minutes=5), + current_time=NOW, + ), + ) + stale_guard = build_x7_stale_data_guard( + X7StaleDataGuardContext( + latest_data_at=NOW - timedelta(minutes=10), + current_time=NOW, + max_stale_seconds=300, + ), + ) + circuit_guard = build_x7_circuit_breaker_guard(circuit_decision) + + # Then + assert lock_guard is not None + assert lock_guard.reason is X7ProtectionReason.PAIR_LOCKED + assert lock_guard.detail == "operator lock" + assert cooldown_guard is not None + assert cooldown_guard.reason is X7ProtectionReason.COOLDOWN_ACTIVE + assert stale_guard is not None + assert stale_guard.reason is X7ProtectionReason.STALE_DATA + assert circuit_guard is not None + assert circuit_guard.reason is X7ProtectionReason.CIRCUIT_BREAKER_BLOCKED + assert circuit_guard.detail == CircuitBreakerKind.STALE_DATA.value + + +def test_x7_trade_confirmation_blocks_guarded_or_unconfirmed_live_actions() -> None: + # Given + guard = build_x7_stale_data_guard( + X7StaleDataGuardContext( + latest_data_at=NOW - timedelta(minutes=10), + current_time=NOW, + max_stale_seconds=60, + ), + ) + assert guard is not None + + # When + guarded = build_x7_trade_confirmation_decision( + X7TradeConfirmationContext( + pair=_pair(), + side=PositionSide.LONG, + guards=(guard,), + ), + ) + live_denied = build_x7_trade_confirmation_decision( + X7TradeConfirmationContext( + pair=_pair(), + side=PositionSide.SHORT, + live_trading=True, + live_confirmed=False, + ), + ) + dry_allowed = build_x7_trade_confirmation_decision( + X7TradeConfirmationContext(pair=_pair(), side=PositionSide.LONG), + ) + + # Then + assert guarded.allowed is False + assert guarded.reason is X7ProtectionReason.STALE_DATA + assert live_denied.allowed is False + assert live_denied.reason is X7ProtectionReason.LIVE_CONFIRMATION_REQUIRED + assert dry_allowed.allowed is True + assert dry_allowed.reason is X7ProtectionReason.CLEAR + + +def test_x7_loop_hook_caps_actions_without_io_or_config_mutation() -> None: + # Given + context = X7LoopHookContext(requested_actions=8, max_actions=3) + + # When + decision = build_x7_loop_hook_decision(context) + + # Then + assert decision.allowed_actions == 3 + assert decision.reason is X7LoopHookReason.BOUNDED + assert decision.hidden_network_io is False + assert decision.mutates_raw_config is False + + +def test_x7_native_strategy_exposes_confirmation_callbacks() -> None: + # Given + strategy = X7NativeStrategy() + adapter = FreqtradeStrategyAdapter.from_strategy(strategy) + + # When + inspection = adapter.inspect() + entry_allowed = strategy.confirm_trade_entry(_pair(), PositionSide.LONG) + exit_allowed = strategy.confirm_trade_exit(_pair(), PositionSide.LONG) + + # Then + assert {"confirm_trade_entry", "confirm_trade_exit"}.issubset( + set(inspection.detected_callbacks), + ) + assert entry_allowed is True + assert exit_allowed is True + + +def test_x7_stale_data_guard_uses_strict_seconds_boundary() -> None: + # Given + context = X7StaleDataGuardContext( + latest_data_at=NOW - timedelta(seconds=300), + current_time=NOW, + max_stale_seconds=300, + ) + + # When + guard = build_x7_stale_data_guard(context) + + # Then + assert guard is None + + +def _pair() -> TradingPair: + return TradingPair.parse("BTC/USDT:USDT", TradingMode.FUTURES) diff --git a/tests/unit/strategy/test_nfi_x7_resource_profile.py b/tests/unit/strategy/test_nfi_x7_resource_profile.py new file mode 100644 index 0000000..ac9337b --- /dev/null +++ b/tests/unit/strategy/test_nfi_x7_resource_profile.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import importlib +import sys + +from nfi_engine.domain import Leverage, TradingMode, TradingPair +from nfi_engine.strategy.nfi_x7 import ( + X7_DATA_REQUIREMENTS, + build_x7_import_profile, + build_x7_resource_budget, +) +from nfi_engine.strategy.nfi_x7.strategy import X7_DEFAULT_LEVERAGE, X7NativeStrategy + +PACKAGE_NAME = "nfi_engine.strategy.nfi_x7" +PACKAGE_PREFIX = f"{PACKAGE_NAME}." +FORBIDDEN_RUNTIME_MODULES = ("freqtrade", "pandas", "rapidjson", "talib") + + +def test_import_profile_reports_injected_forbidden_runtime_modules() -> None: + # Given + loaded_module_names = ( + "json", + "nfi_engine.strategy.nfi_x7", + "pandas.core.frame", + "freqtrade.exchange.exchange", + "talib.abstract", + "rapidjson", + ) + + # When + profile = build_x7_import_profile(loaded_module_names) + + # Then + assert profile.forbidden_runtime_modules == FORBIDDEN_RUNTIME_MODULES + assert profile.loaded_forbidden_runtime_modules == ( + "freqtrade", + "pandas", + "rapidjson", + "talib", + ) + assert profile.has_forbidden_runtime_modules_loaded is True + + +def test_current_native_x7_import_has_no_forbidden_runtime_modules() -> None: + # Given + _clear_x7_modules() + loaded_before_import = set(sys.modules) + + # When + importlib.import_module(PACKAGE_NAME) + loaded_after_import = set(sys.modules) + profile = build_x7_import_profile( + tuple(sorted(loaded_after_import - loaded_before_import)), + ) + + # Then + assert profile.loaded_forbidden_runtime_modules == () + assert profile.has_forbidden_runtime_modules_loaded is False + + +def test_resource_budget_reports_native_x7_runtime_constraints() -> None: + # Given + strategy = X7NativeStrategy() + pair = TradingPair.parse("ETH/USDT:USDT", TradingMode.FUTURES) + + # When + budget = build_x7_resource_budget() + leverage = strategy.leverage(pair, Leverage.one()) + + # Then + assert budget.mandatory_external_dependencies == () + assert budget.pi4_public_claim_requires_hardware_evidence is True + assert budget.precomputed_leverage is True + assert budget.feature_graph_feature_budget == 64 + assert budget.feature_graph_cache_limit == 4 + assert leverage is X7_DEFAULT_LEVERAGE + assert budget.informative_timeframe_count == len(X7_DATA_REQUIREMENTS.informative_timeframes) + assert budget.informative_timeframe_count == 4 + assert budget.bounded_timeframe_count == 1 + len(X7_DATA_REQUIREMENTS.informative_timeframes) + assert budget.structure_backend == "local_typed_structures" + + +def _clear_x7_modules() -> None: + for module_name in tuple(sys.modules): + if module_name == PACKAGE_NAME or module_name.startswith(PACKAGE_PREFIX): + sys.modules.pop(module_name) diff --git a/tests/unit/strategy/test_strategy_frame.py b/tests/unit/strategy/test_strategy_frame.py new file mode 100644 index 0000000..a8683ef --- /dev/null +++ b/tests/unit/strategy/test_strategy_frame.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from decimal import Decimal +from pathlib import Path + +import pytest + +from nfi_engine.backtest.frames import strategy_rows_for_batch +from nfi_engine.data import load_candle_batch +from nfi_engine.strategy import ( + SignalColumns, + StrategyContractError, + StrategyErrorCode, + StrategyFeature, + StrategyFeatureName, + StrategyFrame, + StrategyRow, +) + +FIXTURE_ROOT = Path("tests/fixtures/candles") + + +def test_strategy_frame_reads_only_visible_cursor_rows() -> None: + frame = _frame(visible_row_count=2) + + assert tuple(row.close for row in frame.visible_rows()) == (Decimal(10), Decimal(11)) + assert frame.last_visible_row().close == Decimal(11) + assert frame.visible().rows == frame.rows[:2] + assert frame.visible().visible_row_count is None + + +def test_strategy_frame_rejects_future_rows_when_cursor_hides_rows() -> None: + with pytest.raises(StrategyContractError) as exc_info: + _frame(visible_row_count=2).future_rows() + + assert exc_info.value.code is StrategyErrorCode.LOOKAHEAD_ACCESS + + +def test_strategy_frame_writes_signal_only_inside_visible_cursor() -> None: + frame = _frame(visible_row_count=2) + + updated = frame.with_signal(index=-1, columns=SignalColumns(enter_long=True)) + + assert updated.rows[0].enter_long is False + assert updated.rows[1].enter_long is True + assert updated.rows[2].enter_long is False + with pytest.raises(StrategyContractError): + frame.with_signal(index=2, columns=SignalColumns(enter_long=True)) + + +def test_strategy_rows_preserve_candle_ohlcv_when_built_from_batch() -> None: + batch = load_candle_batch(FIXTURE_ROOT / "btc_usdt_5m.jsonl") + + rows = strategy_rows_for_batch(batch=batch) + + assert rows[0].open == Decimal(100) + assert rows[0].high == Decimal(105) + assert rows[0].low == Decimal(99) + assert rows[0].close == Decimal(102) + assert rows[0].volume == Decimal("1.0") + + +def test_strategy_frame_updates_features_without_mutating_hidden_rows() -> None: + frame = _frame(visible_row_count=2) + feature_name = StrategyFeatureName("rsi_14") + + first_update = frame.with_feature( + index=-1, + feature=StrategyFeature(name=feature_name, value=Decimal("41.5")), + ) + second_update = first_update.with_feature( + index=-1, + feature=StrategyFeature(name=feature_name, value=Decimal("42.5")), + ) + + assert frame.rows[1].features == () + assert first_update.rows[1].feature(feature_name) == Decimal("41.5") + assert second_update.rows[1].feature(feature_name) == Decimal("42.5") + assert len(second_update.rows[1].features) == 1 + assert second_update.rows[2].features == () + + +def test_strategy_row_bulk_feature_update_matches_repeated_updates() -> None: + row = StrategyRow(date="2026-01-01T00:00:00Z", close=Decimal(10)) + features = ( + StrategyFeature(name=StrategyFeatureName("ema_3"), value=Decimal("10.1")), + StrategyFeature(name=StrategyFeatureName("rsi_3"), value=Decimal(42)), + StrategyFeature(name=StrategyFeatureName("ema_3"), value=Decimal("10.2")), + ) + repeated = row + for feature in features: + repeated = repeated.with_feature(feature) + + bulk = row.with_features(features) + + assert bulk.features == repeated.features + assert bulk.feature(StrategyFeatureName("ema_3")) == Decimal("10.2") + assert bulk.feature(StrategyFeatureName("rsi_3")) == Decimal(42) + + +def test_strategy_frame_rejects_missing_feature_and_future_feature_write() -> None: + frame = _frame(visible_row_count=2) + feature = StrategyFeature( + name=StrategyFeatureName("ema_12"), + value=Decimal("101.5"), + ) + + with pytest.raises(StrategyContractError) as missing_feature: + frame.last_visible_row().feature(StrategyFeatureName("ema_12")) + with pytest.raises(StrategyContractError) as future_write: + frame.with_feature(index=2, feature=feature) + + assert missing_feature.value.code is StrategyErrorCode.STRATEGY_FEATURE_NOT_FOUND + assert future_write.value.code is StrategyErrorCode.STRATEGY_CONTRACT_ERROR + + +def _frame(*, visible_row_count: int) -> StrategyFrame: + return StrategyFrame( + rows=( + StrategyRow(date="2026-01-01T00:00:00Z", close=Decimal(10)), + StrategyRow(date="2026-01-01T00:05:00Z", close=Decimal(11)), + StrategyRow(date="2026-01-01T00:10:00Z", close=Decimal(12)), + ), + visible_row_count=visible_row_count, + ) diff --git a/tests/unit/test_x7_live_module_boundaries.py b/tests/unit/test_x7_live_module_boundaries.py new file mode 100644 index 0000000..df635de --- /dev/null +++ b/tests/unit/test_x7_live_module_boundaries.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Final + +PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] +PURE_LOC_LIMIT: Final = 250 +X7_LIVE_BOUNDARY_ROOTS: Final = ( + Path("src/nfi_engine/strategy/nfi_x7"), + Path("src/nfi_engine/paper"), + Path("src/nfi_engine/exchange"), + Path("src/nfi_engine/preflight"), + Path("src/nfi_engine/runtime_control"), + Path("src/nfi_engine/runtime_health"), + Path("src/nfi_engine/wallet"), +) + + +def test_x7_live_modules_stay_below_split_pressure() -> None: + # Given: the X7/live-readiness runtime package boundaries. + python_files = tuple( + path for root in X7_LIVE_BOUNDARY_ROOTS for path in (PROJECT_ROOT / root).rglob("*.py") + ) + + # When: their pure LOC is measured without blank lines or comments. + oversized = tuple( + (path.relative_to(PROJECT_ROOT), pure_loc(path)) + for path in python_files + if pure_loc(path) > PURE_LOC_LIMIT + ) + + # Then: no file crosses the module split-pressure ceiling. + assert oversized == () + + +def pure_loc(path: Path) -> int: + return sum( + 1 + for line in path.read_text(encoding="utf-8").splitlines() + if (stripped := line.strip()) and not stripped.startswith("#") + ) diff --git a/tests/unit/tools/test_x7_provenance.py b/tests/unit/tools/test_x7_provenance.py new file mode 100644 index 0000000..79eb171 --- /dev/null +++ b/tests/unit/tools/test_x7_provenance.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import TYPE_CHECKING + +from nfi_engine.tools.x7_provenance import ( + X7ProvenanceInputs, + build_x7_provenance, + main, + render_markdown_report, +) + +if TYPE_CHECKING: + import pytest + + +def test_build_x7_provenance_extracts_public_structural_facts(tmp_path: Path) -> None: + # Given + source = _sample_x7_source() + source_path = tmp_path / "NostalgiaForInfinityX7.py" + source_path.write_text(source, encoding="utf-8") + inputs = X7ProvenanceInputs( + source_path=source_path, + upstream_commit="abc123", + source_url="https://example.invalid/NostalgiaForInfinityX7.py", + observed_at="2026-06-20", + output_path=tmp_path / "provenance.md", + ) + + # When + provenance = build_x7_provenance(inputs) + + # Then + assert provenance.raw_sha256 == hashlib.sha256(source.encode("utf-8")).hexdigest() + assert provenance.strategy_class_name == "NostalgiaForInfinityX7" + assert provenance.interface_version == 3 + assert provenance.strategy_version == "v17.4.258" + assert provenance.base_timeframe == "5m" + assert provenance.informative_timeframes == ("15m", "1h", "4h", "1d") + assert provenance.import_roots == ("freqtrade", "pandas") + assert provenance.method_names == ("version", "populate_indicators", "informative_pairs") + + +def test_render_markdown_report_avoids_source_code_bodies(tmp_path: Path) -> None: + # Given + source_path = tmp_path / "NostalgiaForInfinityX7.py" + source_path.write_text(_sample_x7_source(), encoding="utf-8") + inputs = X7ProvenanceInputs( + source_path=source_path, + upstream_commit="abc123", + source_url="https://example.invalid/NostalgiaForInfinityX7.py", + observed_at="2026-06-20", + output_path=tmp_path / "provenance.md", + ) + provenance = build_x7_provenance(inputs) + + # When + report = render_markdown_report(provenance) + + # Then + assert "method_count: `3`" in report + assert "- `populate_indicators`" in report + assert 'return "v17.4.258"' not in report + assert "Upstream body intentionally excluded" not in report + + +def test_main_reports_missing_source_path( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + # Given + output_path = tmp_path / "provenance.md" + + # When + exit_code = main( + ( + "--source", + str(tmp_path / "missing.py"), + "--commit", + "abc123", + "--source-url", + "https://example.invalid/NostalgiaForInfinityX7.py", + "--observed-at", + "2026-06-20", + "--output", + str(output_path), + ), + ) + + # Then + captured = capsys.readouterr() + assert exit_code == 1 + assert "X7_PROVENANCE_FILE_ERROR" in captured.err + assert not output_path.exists() + + +def _sample_x7_source() -> str: + return """\ +import pandas +from freqtrade.strategy import IStrategy + + +class NostalgiaForInfinityX7(IStrategy): + INTERFACE_VERSION = 3 + timeframe = "5m" + + def version(self) -> str: + return "v17.4.258" + + def populate_indicators(self): + return "Upstream body intentionally excluded" + + def informative_pairs(self): + return [ + ("BTC/USDT", "15m"), + ("BTC/USDT", "1h"), + ("BTC/USDT", "4h"), + ("BTC/USDT", "1d"), + ] +""" diff --git a/tests/unit/ui/test_data_lifecycle_panel.py b/tests/unit/ui/test_data_lifecycle_panel.py new file mode 100644 index 0000000..6b636e7 --- /dev/null +++ b/tests/unit/ui/test_data_lifecycle_panel.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from nfi_engine.config import Locale +from nfi_engine.config.models import RuntimeSettings, UiSettings +from nfi_engine.ui.assets_data_lifecycle import DATA_LIFECYCLE_SCRIPT +from nfi_engine.ui.pages import render_logs_page, render_settings_page + + +def test_settings_page_renders_data_lifecycle_controls_without_external_assets() -> None: + # Given: local operator settings. + settings = RuntimeSettings() + + # When: the settings page is rendered. + html = render_settings_page(settings=settings) + + # Then: lifecycle controls are visible and stay local-first. + assert 'data-testid="settings-data-lifecycle-panel"' in html + assert 'data-testid="data-lifecycle-footprint-state"' in html + assert 'data-testid="data-lifecycle-export-state"' in html + assert 'data-testid="data-lifecycle-prune-state"' in html + assert 'data-testid="data-lifecycle-retention-days"' in html + assert 'data-testid="data-lifecycle-preview-token"' in html + assert 'data-testid="data-lifecycle-inspect-button"' in html + assert 'data-testid="data-lifecycle-export-button"' in html + assert 'data-testid="data-lifecycle-dry-run-button"' in html + assert 'data-testid="data-lifecycle-apply-button"' in html + assert "https://" not in html + assert "localStorage" not in html + assert "sessionStorage" not in html + + +def test_data_lifecycle_script_uses_csrf_and_no_browser_storage() -> None: + # Given/When: the settings lifecycle browser script is rendered inline. + script = DATA_LIFECYCLE_SCRIPT + + # Then: it calls local endpoints through CSRF without browser storage. + assert "/api/v1/data-lifecycle/footprint" in script + assert "/api/v1/data-lifecycle/export" in script + assert "/api/v1/data-lifecycle/prune" in script + assert "DELETE_GENERATED_LOCAL_ARTIFACTS" in script + assert "x-nfi-csrf-token" in script + assert "localStorage" not in script + assert "sessionStorage" not in script + assert "https://" not in script + + +def test_read_only_settings_page_disables_data_lifecycle_apply() -> None: + # Given: read-only operator settings. + settings = RuntimeSettings(ui=UiSettings(read_only=True)) + + # When: the settings page is rendered. + html = render_settings_page(settings=settings) + + # Then: mutation controls are visibly locked while inspect/export remain available. + assert 'data-testid="data-lifecycle-inspect-button"' in html + assert 'data-testid="data-lifecycle-export-button"' in html + assert 'data-testid="data-lifecycle-dry-run-button" disabled' in html + assert 'data-testid="data-lifecycle-apply-button" disabled' in html + + +def test_settings_data_lifecycle_panel_localizes_korean_and_greek_labels() -> None: + # Given: operators using Korean and Greek on the local settings page. + korean = RuntimeSettings(ui=UiSettings(locale=Locale.KO)) + greek = RuntimeSettings(ui=UiSettings(locale=Locale.EL)) + + # When: the data lifecycle panel is rendered through the real settings page. + korean_html = render_settings_page(settings=korean) + greek_html = render_settings_page(settings=greek) + + # Then: the final Settings panel does not leak its previous English labels. + assert "로컬 데이터 관리" in korean_html + assert "보관 일수" in korean_html + assert "정리 미리보기 실행" in korean_html + assert "Τοπική διαχείριση δεδομένων" in greek_html + assert "Ημέρες διατήρησης" in greek_html + assert "Προεπισκόπηση καθαρισμού" in greek_html + assert "Local data lifecycle" not in korean_html + assert "Local data lifecycle" not in greek_html + assert "Dry run cleanup" not in korean_html + assert "Dry run cleanup" not in greek_html + + +def test_logs_page_support_report_link_stays_on_existing_zip_endpoint() -> None: + # Given: the logs page support workflow. + settings = RuntimeSettings() + + # When: the logs page is rendered. + html = render_logs_page(settings=settings, logs=()) + + # Then: the existing redacted support archive endpoint remains visible. + assert 'data-testid="export-support-report"' in html + assert "/api/v1/reports/support-bundle.zip" in html diff --git a/tests/unit/ui/test_i18n.py b/tests/unit/ui/test_i18n.py index fc1d828..fe009f0 100644 --- a/tests/unit/ui/test_i18n.py +++ b/tests/unit/ui/test_i18n.py @@ -56,3 +56,44 @@ def test_runtime_settings_reject_unknown_locale() -> None: # When / Then: config parsing rejects it at the boundary. with pytest.raises(ValueError, match=r"ui\.locale"): RuntimeSettings.model_validate(raw_settings) + + +def test_settings_operator_select_options_are_localized() -> None: + # Given: Korean and Greek operator Settings pages. + korean = render_settings_page(settings=RuntimeSettings(ui=UiSettings(locale=Locale.KO))) + greek = render_settings_page(settings=RuntimeSettings(ui=UiSettings(locale=Locale.EL))) + + # When / Then: Settings field options use the i18n catalog, not title-cased ids. + assert '' in korean + assert '' in korean + assert '' in greek + assert '' in greek + assert '' not in korean + assert '' not in greek + + +def test_greek_catalog_localizes_visible_operator_labels() -> None: + # Given: visible operator labels that are not machine codes. + visible_labels = { + MessageKey.COMMON_BLOCK: "Αποκλεισμός", + MessageKey.COMMON_PASSED: "Πέρασε", + MessageKey.COMMON_WARN: "Προσοχή", + MessageKey.SAVE_DRAFT: "Αποθήκευση προσχεδίου", + MessageKey.SETTINGS_RUNTIME_SAFE: "Ασφαλές runtime", + MessageKey.SETTINGS_RUNTIME_SAFE_TITLE: "Ασφαλείς ρυθμίσεις runtime", + MessageKey.SETTINGS_UPDATE_ROLLBACK: "Επαναφορά", + MessageKey.SETTINGS_UPDATE_TITLE: "Ενημέρωση προγραμματιστή", + MessageKey.HOME_PAIRLIST: "Λίστα ζευγών", + MessageKey.PAIRLIST_BLACKLIST: "Λίστα αποκλεισμού", + MessageKey.PAIRLIST_BLACKLIST_ARIA: "λίστα αποκλεισμού ζευγών", + MessageKey.PAIRLIST_PREVIEW_EMPTY: "Δεν υπάρχει προεπισκόπηση λίστας ζευγών", + MessageKey.PAIRLIST_TITLE: "Λίστα ζευγών", + MessageKey.SETUP_FETCH_WALLET: "Φόρτωση υπολοίπου πορτοφολιού", + MessageKey.SETUP_WALLET_NOT_FETCHED: ( + "\u03a4\u03bf υπόλοιπο πορτοφολιού δεν έχει φορτωθεί ακόμη." + ), + } + + # When / Then: Greek user-facing copy is translated while machine terms can stay stable. + for key, expected in visible_labels.items(): + assert localize(Locale.EL, key) == expected diff --git a/tests/unit/ui/test_page_scripts.py b/tests/unit/ui/test_page_scripts.py new file mode 100644 index 0000000..80a131c --- /dev/null +++ b/tests/unit/ui/test_page_scripts.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from nfi_engine.api.models import initial_log_entries +from nfi_engine.config.models import RuntimeSettings +from nfi_engine.ui.assets import STYLE +from nfi_engine.ui.assets_runtime_control import RUNTIME_CONTROL_SCRIPT +from nfi_engine.ui.assets_settings import SETTINGS_SCRIPT +from nfi_engine.ui.pages import render_home_page, render_logs_page, render_settings_page + + +def test_settings_locale_apply_contract_uses_reload_without_browser_storage() -> None: + # Given: a settings page with an editable runtime locale field. + html = render_settings_page(settings=RuntimeSettings()) + + # Then: the page has the operator control and the local script can apply it safely. + assert 'name="ui.locale"' in html + assert 'data-testid="apply-button"' in html + assert "/api/v1/config/apply" in SETTINGS_SCRIPT + assert "window.location.reload()" in SETTINGS_SCRIPT + assert "localStorage" not in SETTINGS_SCRIPT + assert "sessionStorage" not in SETTINGS_SCRIPT + + +def test_settings_wallet_fetch_contract_uses_post_without_browser_storage() -> None: + # Given: a settings page with the wallet fetch affordance. + html = render_settings_page(settings=RuntimeSettings()) + + # Then: the browser script posts to the wallet endpoint and keeps secrets out of storage. + assert 'data-testid="wallet-fetch-button"' in html + assert 'data-testid="wallet-balance-state"' in html + assert "/api/v1/wallet/balance/fetch" in SETTINGS_SCRIPT + assert "method: 'POST'" in SETTINGS_SCRIPT + assert "setup.wallet_loading" in SETTINGS_SCRIPT + assert "setup.wallet_fetched" in SETTINGS_SCRIPT + assert "localStorage" not in SETTINGS_SCRIPT + assert "sessionStorage" not in SETTINGS_SCRIPT + + +def test_logs_page_preserves_machine_code_visual_contract() -> None: + # Given: seeded logs with stable machine-code identifiers. + html = render_logs_page(settings=RuntimeSettings(), logs=initial_log_entries()) + + # Then: the rendered table and stylesheet preserve scan-friendly machine tokens. + assert 'class="log-time" title="' in html + assert 'class="machine-code"' in html + assert ".log-time" in STYLE + assert ".machine-code" in STYLE + assert "white-space: nowrap;" in STYLE + assert "word-break: keep-all;" in STYLE + + +def test_runtime_control_contract_posts_commands_and_refreshes_health() -> None: + csrf_value = "fixture" + html = render_home_page(settings=RuntimeSettings(), logs=(), csrf_token=csrf_value) + + assert "/api/v1/runtime/control" in html + assert "/api/v1/runtime/health" in html + assert 'meta name="nfi-csrf-token"' in html + assert 'data-command="start"' in html + assert 'data-command="pause"' in html + assert 'data-command="resume"' in html + assert 'data-command="stop"' in html + assert "/api/v1/runtime/control" in RUNTIME_CONTROL_SCRIPT + assert "/api/v1/runtime/health" in RUNTIME_CONTROL_SCRIPT + assert "settings.runtime_control_loading" in RUNTIME_CONTROL_SCRIPT + assert "settings.runtime_control_blocked" in RUNTIME_CONTROL_SCRIPT + assert "localStorage" not in RUNTIME_CONTROL_SCRIPT + assert "sessionStorage" not in RUNTIME_CONTROL_SCRIPT diff --git a/tests/unit/ui/test_pages.py b/tests/unit/ui/test_pages.py index 706241f..450a583 100644 --- a/tests/unit/ui/test_pages.py +++ b/tests/unit/ui/test_pages.py @@ -3,6 +3,9 @@ from nfi_engine.api.models import initial_log_entries from nfi_engine.config import Locale from nfi_engine.config.models import RuntimeSettings, UiSettings +from nfi_engine.paper import BotState +from nfi_engine.preflight.models import PreflightReport +from nfi_engine.ui.home_context import HomeRuntimeContext from nfi_engine.ui.pages import render_home_page, render_logs_page, render_settings_page @@ -18,7 +21,8 @@ def test_settings_page_renders_schema_driven_safe_controls() -> None: assert 'data-testid="settings-form"' in html assert 'data-testid="setup-form"' in html assert 'name="intent"' in html - assert 'name="risk_preset"' in html + assert 'name="risk_profile"' in html + assert 'name="permission_withdrawal"' in html assert 'name="api_key" type="password"' in html assert 'name="api_secret" type="password"' in html assert 'name="risk.stake_usdt"' in html @@ -28,6 +32,14 @@ def test_settings_page_renders_schema_driven_safe_controls() -> None: assert 'data-testid="validation-state"' in html assert 'data-testid="audit-log"' in html assert 'data-testid="live-trading-locked"' in html + assert 'data-testid="runtime-control-state"' in html + assert 'data-testid="runtime-health-state"' in html + assert 'data-testid="pause-button"' in html + assert 'data-testid="resume-button"' in html + assert 'data-command="start"' in html + assert 'data-command="pause"' in html + assert 'data-command="resume"' in html + assert 'data-command="stop"' in html assert "raw yaml" not in html.lower() assert "exchange.api_secret" not in html assert "api.auth_token" not in html @@ -50,9 +62,32 @@ def test_home_page_renders_operator_command_center_without_external_assets() -> assert 'data-testid="chart-status"' in html assert 'data-testid="chart-render-time"' in html assert 'data-poll-ms="5000"' in html + assert 'data-testid="operator-cockpit"' in html + assert 'data-testid="cockpit-capability-level"' in html + assert 'data-testid="cockpit-active-mode"' in html + assert 'data-testid="cockpit-runtime-health"' in html + assert 'data-testid="cockpit-wallet-balance"' in html + assert 'data-testid="cockpit-allocated-amount"' in html + assert 'data-testid="cockpit-leverage"' in html + assert 'data-testid="cockpit-risk-profile"' in html + assert 'data-testid="cockpit-permission-audit"' in html + assert 'data-testid="cockpit-latest-error"' in html + assert 'data-testid="cockpit-next-action"' in html + assert 'data-testid="cockpit-where-next"' in html assert 'data-testid="pairlist-summary"' in html assert 'data-testid="recent-errors"' in html + assert 'data-testid="action-queue"' in html + assert 'data-testid="action-item"' in html + assert 'data-testid="runtime-controls"' in html + assert 'data-testid="runtime-control-state"' in html + assert 'data-testid="pause-button"' in html + assert 'data-testid="resume-button"' in html + assert 'data-command="start"' in html + assert 'data-command="pause"' in html + assert 'data-command="resume"' in html + assert 'data-command="stop"' in html assert "/api/v1/dashboard/snapshot" in html + assert "Runtime health" in html assert "Operator command center" in html assert "chart-bars" not in html assert "landing" not in html.lower() @@ -62,6 +97,48 @@ def test_home_page_renders_operator_command_center_without_external_assets() -> assert "sessionStorage" not in html +def test_home_page_uses_runtime_context_bot_state_for_metric_and_control_state() -> None: + settings = RuntimeSettings() + html = render_home_page( + settings=settings, + logs=(), + runtime=HomeRuntimeContext(bot_state=BotState.RUNNING), + ) + + assert 'data-testid="bot-state">Bot staterunning' in html + assert 'data-testid="runtime-control-state">running<' in html + + +def test_home_page_action_queue_links_ready_state_to_real_status_anchor() -> None: + settings = RuntimeSettings() + readiness = PreflightReport(profile="paper", blocked=False, checks=()) + html = render_home_page( + settings=settings, + logs=(), + runtime=HomeRuntimeContext(readiness=readiness), + ) + + assert 'id="status" class="status-strip"' in html + assert 'data-testid="action-queue"' in html + assert 'href="#status"' in html + assert "Paper/testnet runtime is ready" in html + + +def test_home_page_action_queue_links_support_bundle_to_export_endpoint() -> None: + settings = RuntimeSettings() + readiness = PreflightReport(profile="paper", blocked=False, checks=()) + html = render_home_page( + settings=settings, + logs=initial_log_entries(), + runtime=HomeRuntimeContext(readiness=readiness), + ) + + assert 'data-testid="action-queue"' in html + assert 'href="/logs"' in html + assert 'href="/api/v1/reports/support-bundle.zip"' in html + assert "Export a support bundle if errors persist" in html + + def test_settings_page_keeps_advanced_fields_discoverable_without_sensitive_values() -> None: # Given: default local runtime settings before Simple Mode is expanded. settings = RuntimeSettings() @@ -92,16 +169,73 @@ def test_settings_page_renders_simple_mode_before_advanced_controls() -> None: assert setup_start < simple_start assert simple_start < advanced_start assert 'name="intent"' in setup_html - assert 'name="risk_preset"' in setup_html + assert 'name="risk_profile"' in setup_html + assert 'name="permission_withdrawal"' in setup_html assert 'name="api_key" type="password"' in setup_html assert 'name="api_secret" type="password"' in setup_html assert 'name="exchange.name"' in simple_html assert 'name="exchange.trading_mode"' in simple_html assert 'name="ui.locale"' in simple_html + assert '' in simple_html + assert '' in simple_html + assert '' in simple_html assert 'name="risk.stake_usdt"' in simple_html assert 'name="risk.max_open_trades"' in simple_html +def test_settings_page_renders_first_run_wizard_in_operator_order() -> None: + # Given: default local runtime settings. + settings = RuntimeSettings() + + # When: the first-run setup wizard is rendered. + html = render_settings_page(settings=settings) + + # Then: the operator path follows the agreed safe order. + markers = ( + 'data-testid="setup-step-exchange"', + 'data-testid="setup-step-api-key"', + 'data-testid="setup-step-api-secret"', + 'data-testid="setup-step-permission-audit"', + 'data-testid="setup-step-leverage"', + 'data-testid="setup-step-risk-profile"', + 'data-testid="setup-step-wallet-balance"', + 'data-testid="setup-step-allocated-amount"', + 'data-testid="setup-step-market-mode"', + 'data-testid="setup-step-intent"', + ) + positions = tuple(html.index(marker) for marker in markers) + assert positions == tuple(sorted(positions)) + assert 'data-testid="wallet-fetch-button"' in html + assert 'name="allocated_amount_usdt"' in html + assert 'name="permission_withdrawal"' in html + assert 'name="risk_profile"' in html + assert 'data-testid="setup-recommended-leverage">3x<' in html + assert '' in html + assert "wallet seed" not in html.lower() + assert "private key" not in html.lower() + + +def test_settings_page_renders_developer_update_states_without_network_action() -> None: + # Given: local settings. + settings = RuntimeSettings() + + # When: Settings renders the update panel. + html = render_settings_page(settings=settings) + + # Then: one-click update states are visible but no remote fetch is embedded in HTML. + assert 'data-testid="settings-update-panel"' in html + assert 'data-testid="update-preview-state"' in html + assert 'data-testid="update-apply-state"' in html + assert 'data-testid="update-rollback-state"' in html + assert 'data-testid="update-preview-button"' in html + assert 'data-testid="update-apply-button"' in html + assert 'data-testid="update-rollback-button"' in html + assert 'data-testid="update-backup-reference"' in html + assert 'data-testid="update-acknowledge-unverified"' in html + assert "engine + strategy" in html + assert "https://" not in html + + def test_logs_page_renders_error_filter_and_report_controls() -> None: # Given: seeded operator logs. logs = initial_log_entries() @@ -138,12 +272,25 @@ def test_read_only_settings_page_locks_mutating_controls_without_token_storage() assert 'data-testid="apply-button" disabled' in html assert 'data-testid="restore-button" disabled' in html assert 'data-testid="start-button" disabled' in html + assert 'data-testid="pause-button" disabled' in html + assert 'data-testid="resume-button" disabled' in html assert 'data-testid="stop-button" disabled' in html assert "Read-only mode blocks changes" in html assert "localStorage" not in html assert "sessionStorage" not in html +def test_read_only_home_page_disables_runtime_controls() -> None: + settings = RuntimeSettings(ui=UiSettings(read_only=True)) + + html = render_home_page(settings=settings, logs=initial_log_entries()) + + assert 'data-testid="start-button" disabled' in html + assert 'data-testid="pause-button" disabled' in html + assert 'data-testid="resume-button" disabled' in html + assert 'data-testid="stop-button" disabled' in html + + def test_home_page_uses_configured_locale_in_document_lang() -> None: # Given: Korean frontend settings. settings = RuntimeSettings(ui=UiSettings(locale=Locale.KO)) diff --git a/tests/unit/ui/test_settings_update.py b/tests/unit/ui/test_settings_update.py new file mode 100644 index 0000000..e8473d8 --- /dev/null +++ b/tests/unit/ui/test_settings_update.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from nfi_engine.config import Locale +from nfi_engine.config.models import RuntimeSettings, UiSettings +from nfi_engine.ui.assets_settings import SETTINGS_SCRIPT +from nfi_engine.ui.pages import render_settings_page +from nfi_engine.ui.settings_update import render_settings_update_panel + + +def test_settings_update_panel_renders_local_proof_controls() -> None: + # Given: default local runtime settings. + settings = RuntimeSettings() + + # When: the update panel is rendered. + html = render_settings_update_panel(settings=settings) + + # Then: backup proof and unverified acknowledgement controls are visible. + assert 'data-testid="settings-update-panel"' in html + assert 'data-testid="update-preview-button"' in html + assert 'data-testid="update-apply-button"' in html + assert 'data-testid="update-rollback-button"' in html + assert 'data-testid="update-backup-reference"' in html + assert 'data-testid="update-acknowledge-unverified"' in html + assert 'data-testid="update-allow-dirty-worktree"' in html + assert 'data-testid="update-source"' in html + assert "engine + strategy" in html + assert "disabled" not in html + + +def test_settings_page_and_script_use_local_update_endpoints_without_browser_storage() -> None: + # Given: the settings page and script assets. + html = render_settings_page(settings=RuntimeSettings()) + + # When / Then: local update proof routes are wired without browser storage or external assets. + assert "/api/v1/update/preview" in SETTINGS_SCRIPT + assert "/api/v1/update/apply" in SETTINGS_SCRIPT + assert "/api/v1/update/rollback" in SETTINGS_SCRIPT + assert "x-nfi-csrf-token" in SETTINGS_SCRIPT + assert "allow_dirty_worktree" in SETTINGS_SCRIPT + assert "update_source" in SETTINGS_SCRIPT + assert "local_proof" in SETTINGS_SCRIPT + assert "localStorage" not in SETTINGS_SCRIPT + assert "sessionStorage" not in SETTINGS_SCRIPT + assert "https://" not in html + assert "sessionStorage" not in html + + +def test_settings_update_panel_localizes_new_proof_labels() -> None: + # Given: Korean runtime settings. + settings = RuntimeSettings(ui=UiSettings(locale=Locale.KO)) + + # When: the update proof panel is rendered. + html = render_settings_update_panel(settings=settings) + + # Then: the new proof controls use localized labels. + assert "백업 참조" in html + assert "검증되지 않은 출처 확인" in html + assert "변경된 작업트리 허용" in html diff --git a/tests/unit/ui/test_setup_credential_boundary.py b/tests/unit/ui/test_setup_credential_boundary.py new file mode 100644 index 0000000..8eee600 --- /dev/null +++ b/tests/unit/ui/test_setup_credential_boundary.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Final + +from nfi_engine.config import Locale, RuntimeSettings, UiSettings +from nfi_engine.ui.pages import render_settings_page + +FORBIDDEN_SETUP_TERMS: Final = ( + "wallet seed", + "seed phrase", + "private key", + "mnemonic", + "withdrawal key", + "api auth token", + "login token", + "operator token", + "지갑 시드", + "개인 키", + "개인키", + "니모닉", + "출금 키", + "로그인 토큰", + "운영자 토큰", + "φράση seed", + "ιδιωτικό κλειδί", + "κλειδί ανάληψης", + "token σύνδεσης", + "token χειριστή", +) + + +def test_settings_setup_labels_exchange_api_credentials_without_wallet_or_login_terms() -> None: + cases = ( + (Locale.EN, ("Exchange API key", "Exchange API secret")), + (Locale.KO, ("거래소 API 키", "거래소 API 시크릿")), + (Locale.EL, ("Κλειδί API ανταλλακτηρίου", "Μυστικό API ανταλλακτηρίου")), + ) + + for locale, expected_labels in cases: + # Given: the Settings page is rendered in an operator locale. + settings = RuntimeSettings(ui=UiSettings(locale=locale)) + + # When: the first-run setup panel is isolated from the broader page. + html = render_settings_page(settings=settings) + setup_html = html.split('data-testid="setup-preview-panel"', 1)[1].split( + 'data-testid="settings-form"', + 1, + )[0] + normalized_setup = setup_html.casefold() + + # Then: credentials are exchange API fields, not wallet keys or login tokens. + for label in expected_labels: + assert label in setup_html + assert 'name="api_key" type="password"' in setup_html + assert 'name="api_secret" type="password"' in setup_html + for forbidden_term in FORBIDDEN_SETUP_TERMS: + assert forbidden_term.casefold() not in normalized_setup diff --git a/tests/unit/wallet/__init__.py b/tests/unit/wallet/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/tests/unit/wallet/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/unit/wallet/test_service.py b/tests/unit/wallet/test_service.py new file mode 100644 index 0000000..523dbed --- /dev/null +++ b/tests/unit/wallet/test_service.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from decimal import Decimal + +import anyio +import pytest + +from nfi_engine.config.models import RuntimeSettings +from nfi_engine.domain import AccountSnapshot, StakeAmount +from nfi_engine.exchange.errors import ExchangeError, ExchangeErrorCode +from nfi_engine.exchange.permissions import ExchangeApiPermissionState +from nfi_engine.wallet import WalletBalanceCode, WalletBalanceStatus, fetch_wallet_balance + +pytestmark = pytest.mark.anyio +NOW = datetime(2026, 6, 15, tzinfo=UTC) + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +async def test_simulator_wallet_balance_fetches_through_exchange_boundary() -> None: + # Given: the default simulator profile used for local paper operation. + settings = RuntimeSettings() + + # When: wallet balance is requested from the service boundary. + balance = await fetch_wallet_balance(settings=settings, now=NOW) + + # Then: the deterministic simulator account is returned without credentials. + assert balance.status is WalletBalanceStatus.FETCHED + assert balance.code is WalletBalanceCode.FETCHED + assert balance.captured_at == NOW + assert balance.equity == Decimal(1000) + assert balance.available == Decimal(1000) + assert balance.quote_asset == "USDT" + assert balance.allocation_cap_pct == Decimal("0.10") + assert balance.allocation_cap == Decimal("100.00") + assert balance.configured_allocation_total == Decimal(30) + assert balance.allocation_cap_exceeded is False + assert balance.permission_audit.withdrawal is ExchangeApiPermissionState.UNKNOWN + + +async def test_wallet_balance_blocks_non_simulator_without_credentials() -> None: + # Given: a testnet exchange config with no API key or secret. + settings = RuntimeSettings.model_validate({"exchange": {"name": "bybit", "testnet": True}}) + + # When: the operator asks for wallet balance. + balance = await fetch_wallet_balance(settings=settings) + + # Then: the response is machine-coded and contains no secret material. + assert balance.status is WalletBalanceStatus.BLOCKED + assert balance.code is WalletBalanceCode.MISSING_CREDENTIALS + assert balance.equity is None + assert balance.available is None + assert "secret" not in balance.message.lower() + + +async def test_wallet_balance_blocks_live_intent_with_withdrawal_permission() -> None: + # Given: live intent with an unsafe exchange API permission state. + settings = RuntimeSettings.model_validate( + { + "engine": {"live_trading": True, "live_trading_confirmed": True}, + "exchange": { + "name": "bybit", + "testnet": True, + "api_key": "redacted-test-key", + "api_secret": "redacted-test-secret", + "permission_withdrawal": "enabled", + }, + }, + ) + + # When: wallet balance is requested. + balance = await fetch_wallet_balance(settings=settings, reader=FakeBalanceReader()) + + # Then: read inspection is blocked until withdrawal permission is removed. + assert balance.status is WalletBalanceStatus.BLOCKED + assert balance.code is WalletBalanceCode.UNSAFE_PERMISSION + assert balance.permission_audit.withdrawal is ExchangeApiPermissionState.ENABLED + assert balance.permission_audit.live_safe is False + assert balance.permission_audit.live_blocking_codes == ( + "EXCHANGE_WITHDRAWAL_PERMISSION_ENABLED", + ) + assert "redacted-test-key" not in balance.next_action + assert "redacted-test-secret" not in balance.next_action + + +async def test_wallet_balance_uses_injected_read_only_adapter() -> None: + # Given: a configured exchange and an injected read-only balance adapter. + settings = RuntimeSettings.model_validate( + { + "exchange": { + "name": "bybit", + "testnet": True, + "api_key": "redacted-test-key", + "api_secret": "redacted-test-secret", + }, + }, + ) + + # When: wallet balance is fetched. + balance = await fetch_wallet_balance(settings=settings, reader=FakeBalanceReader()) + + # Then: the adapter result is normalized to the public wallet snapshot. + assert balance.status is WalletBalanceStatus.FETCHED + assert balance.equity == Decimal("77.5") + assert balance.available == Decimal(70) + assert balance.position_count == 0 + assert balance.allocation_cap == Decimal("7.00") + assert balance.configured_allocation_total == Decimal(30) + assert balance.allocation_cap_exceeded is True + + +async def test_wallet_balance_returns_machine_code_for_exchange_error() -> None: + # Given: an injected reader that reports an exchange failure. + settings = RuntimeSettings() + + # When: wallet balance is fetched. + balance = await fetch_wallet_balance(settings=settings, reader=FailingBalanceReader()) + + # Then: the failure is redacted and machine-coded. + assert balance.status is WalletBalanceStatus.ERROR + assert balance.code is WalletBalanceCode.EXCHANGE_ERROR + assert balance.message == ExchangeErrorCode.TICK_NOT_FOUND.value + + +async def test_wallet_balance_returns_timeout_code_when_reader_is_slow() -> None: + # Given: an injected reader slower than the wallet timeout budget. + settings = RuntimeSettings() + + # When: wallet balance fetch exceeds the configured timeout. + balance = await fetch_wallet_balance( + settings=settings, + reader=SlowBalanceReader(), + timeout_seconds=0.01, + ) + + # Then: the operator gets a stable timeout diagnostic instead of a hung request. + assert balance.status is WalletBalanceStatus.ERROR + assert balance.code is WalletBalanceCode.TIMEOUT + assert balance.allocation_cap is None + assert balance.allocation_cap_exceeded is None + + +@dataclass(frozen=True, slots=True) +class FakeBalanceReader: + async def fetch_balance(self) -> AccountSnapshot: + return AccountSnapshot( + captured_at=NOW, + equity=StakeAmount(Decimal("77.5")), + available=StakeAmount(Decimal(70)), + positions=(), + ) + + +@dataclass(frozen=True, slots=True) +class FailingBalanceReader: + async def fetch_balance(self) -> AccountSnapshot: + raise ExchangeError( + code=ExchangeErrorCode.TICK_NOT_FOUND, + message="synthetic reader failure", + ) + + +@dataclass(frozen=True, slots=True) +class SlowBalanceReader: + async def fetch_balance(self) -> AccountSnapshot: + await anyio.sleep(1) + return AccountSnapshot( + captured_at=NOW, + equity=StakeAmount(Decimal(1)), + available=StakeAmount(Decimal(1)), + positions=(), + )