Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,16 @@ TELEGRAM_CHAT_ID=

# Discord: create a webhook in channel settings
DISCORD_WEBHOOK_URL=

# ── REST API exposure (see README "REST API" security note) ─────
# Loopback (127.0.0.1) needs none of these. A non-loopback bind is refused
# unless HYPERDATA_API_KEY is set (auth required on all non-health routes)
# or HYPERDATA_UNSAFE_PUBLIC_API=1 explicitly accepts unauthenticated exposure.
HYPERDATA_API_HOST=127.0.0.1
HYPERDATA_API_KEY=
HYPERDATA_CORS_ORIGINS=
HYPERDATA_UNSAFE_PUBLIC_API=

# ── LLM cost guardrail ────────────────────────────────────
# Max LLM strategy evaluations per hour (default 60).
LLM_MAX_EVALS_PER_HOUR=60
14 changes: 14 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: 2
updates:
# Keep requirements.lock / pyproject deps patched (security + routine).
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5

# Keep SHA-pinned GitHub Actions fresh.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
38 changes: 17 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,30 @@ jobs:
python-version: ["3.12", "3.13"]

steps:
- uses: actions/checkout@v4
# Actions pinned by commit SHA (not mutable tags) so a compromised tag
# can't inject code into CI.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 P1 (High): The setup-python action is invoked without a with: block specifying python-version: ${{ matrix.python-version }}, so the Python version matrix is not applied. This means all jobs will use the runner's default Python and cross-version testing is lost. Add with: python-version: ${{ matrix.python-version }} to restore matrix testing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore matrix Python selection

This step no longer passes python-version to actions/setup-python; the action docs state that without this input it falls back to .python-version or the runner PATH, and this repo has no .python-version. The matrix value is now only in the display name, so the two jobs won't actually exercise 3.12 and 3.13 and version-specific failures can slip through CI.

Useful? React with 👍 / 👎.

with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
# requirements.lock pins the runtime deps; -e . adds the package
# itself; test tooling is pinned so CI is deterministic.
run: |
pip install -e .
pip install pytest ruff
pip install -r requirements.lock
pip install -e . --no-deps
pip install pytest==9.1.1 pytest-asyncio==1.4.0 ruff==0.15.20 pip-audit==2.10.1

- name: Lint
# Non-blocking for now: the repo has pre-existing ruff debt (mostly
# E501/I001/F401) unrelated to current work. Tests below are the real
# gate. TODO: clean lint in a focused PR, then drop continue-on-error.
continue-on-error: true
run: ruff check src/
run: ruff check src/ tests/

- name: Syntax check all Python files
run: |
find src/ -name "*.py" -exec python -c "
import ast, sys
try:
ast.parse(open(sys.argv[1]).read())
except SyntaxError as e:
print(f'FAIL: {sys.argv[1]}: {e}')
sys.exit(1)
" {} \;
- name: Dependency vulnerability audit
# Blocking: a known CVE in a pinned runtime dep fails CI. Dependabot
# (.github/dependabot.yml) keeps the lockfile and action SHAs fresh.
run: pip-audit -r requirements.lock

- name: Test imports
run: |
Expand All @@ -52,5 +47,6 @@ jobs:
python -c "from src.dashboards.liquidation_heatmap import LiquidationHeatmapDashboard; print('heatmap: OK')"

- name: Run tests
# test_position_scanner.py needs a live exchange connection — skip in CI.
run: python -m pytest tests/ -q --ignore=tests/test_position_scanner.py
# The full suite, position scanner included — its network calls are
# mocked; only tests marked `live` need real exchange connections.
run: python -m pytest tests/ -q
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,24 @@ Start the API server alongside or instead of the terminal:
python run_api.py --port 8420
```

> **⚠️ Security: local-only by default.** The API binds to `127.0.0.1` and is
> intended for loopback use. It serves wallet-derived positions, liquidation
> danger zones, and live order flow — trading intelligence you should not
> expose to a LAN or the internet. A non-loopback bind
> (`HYPERDATA_API_HOST=0.0.0.0`) is **refused** unless you either set
> `HYPERDATA_API_KEY=<secret>` (all non-health routes then require
> `Authorization: Bearer <secret>` or `X-API-Key`) or explicitly accept the
> risk with `HYPERDATA_UNSAFE_PUBLIC_API=1`. Restrict browser access with
> `HYPERDATA_CORS_ORIGINS=https://your-app.example` (wildcard CORS applies to
> loopback binds only). Do not front this API with a public tunnel or reverse
> proxy without auth and rate limiting of your own.

### Endpoints

| Endpoint | Description |
|---|---|
| `GET /v1/health` | Server status and uptime |
| `GET /v1/live` | Minimal liveness probe (always unauthenticated) |
| `GET /v1/health` | Server status and uptime (requires the API key when one is set) |
| `GET /v1/market` | All assets — prices, OI, funding |
| `GET /v1/market/{symbol}` | Single asset detail |
| `GET /v1/liquidations` | Recent liquidation events |
Expand Down
14 changes: 10 additions & 4 deletions docs/DATA_INTEGRITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,16 @@ the WAL is checkpointed, so the DB stays bounded on long-running instances.

## API exposure

The REST/WebSocket API has no authentication and permissive CORS, so it binds to
**loopback (`127.0.0.1`) by default**. To expose it on the LAN, set
`HYPERDATA_API_HOST=0.0.0.0` — only do this behind a trusted network. Numeric
query params are validated (bad values return `400`, not `500`).
The REST/WebSocket API binds to **loopback (`127.0.0.1`) by default**. A
non-loopback bind (`HYPERDATA_API_HOST=0.0.0.0`) is refused at startup unless
either `HYPERDATA_API_KEY` is set — all non-health routes then require
`Authorization: Bearer <key>` or `X-API-Key: <key>` — or
`HYPERDATA_UNSAFE_PUBLIC_API=1` explicitly acknowledges the exposure. CORS is
wildcard only on loopback; non-loopback binds send CORS headers only for
origins allowlisted in `HYPERDATA_CORS_ORIGINS` (comma-separated). REST
requests are rate-limited per client IP, and WebSocket clients get bounded
per-client send queues plus inbound message size/rate limits. Numeric query
params are validated (bad values return `400`, not `500`).

## Timestamps

Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "W", "I"]

[tool.ruff.lint.per-file-ignores]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Packaging Security - Medium]: Packaging of raw config* directory.

(Reviewer note: Commenting here on the ruff addition since the setuptools section is unchanged in this diff)

Line 20 of pyproject.toml includes config* in the packaging build: include = ["src*", "config*"]. This causes any local config templates, settings, or accidental secrets stored in the config/ directory to be packaged into the distributed .whl and .tar.gz archive artifacts upon building, exposing internal file structures to downstream installers.

Analogy: Packing your private notes and house diagrams into a box of free books that you leave on the sidewalk.

Fix: Restrict finding to src only in pyproject.toml line 20:

include = ["src*"]

# Presentation layer: ASCII-art boot screens and Rich table markup read
# better unwrapped; everything else must respect the line limit.
"src/dashboards/*" = ["E501"]

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
2 changes: 1 addition & 1 deletion requirements.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Generated with pip freeze — do not edit manually
# Pinned versions for reproducible installs
# To install: pip install -r requirements.lock
aiohttp==3.13.3
aiohttp==3.14.1
numpy==2.4.3
pandas==3.0.1
pyfiglet==1.0.4
Expand Down
44 changes: 34 additions & 10 deletions run_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,39 @@ async def run_dashboard(hub: HyperDataHub, key: str) -> None:
await create_fn().run()


async def _ainput(prompt: str) -> str:
"""Read one line of input without blocking the event loop.

Uses a daemon thread per prompt (human-speed churn only) so a read that
is still pending at exit can never wedge interpreter shutdown — the
failure mode of parking input() inside a ThreadPoolExecutor, whose
non-daemon workers are joined at exit.
"""
import threading

loop = asyncio.get_running_loop()
fut: asyncio.Future[str] = loop.create_future()

def _set(value=None, exc=None):
if fut.done():
return
if exc is not None:
fut.set_exception(exc)
else:
fut.set_result(value)

def _worker():
try:
line = input(prompt)
except BaseException as e: # EOFError / KeyboardInterrupt in the thread
loop.call_soon_threadsafe(_set, None, e)
else:
loop.call_soon_threadsafe(_set, line)

threading.Thread(target=_worker, daemon=True, name="menu-input").start()
return await fut


async def run_interactive(api_port: int | None = None) -> None:
"""Boot → menu → pick dashboard → run → back to menu on Ctrl+C."""
console = Console()
Expand All @@ -89,19 +122,10 @@ async def run_interactive(api_port: int | None = None) -> None:
while True:
_build_menu(console)

# Read user choice — use a daemon thread so Ctrl+C exits cleanly
import concurrent.futures
_input_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
_input_pool._threads = set() # ensure daemon threads
try:
choice = await asyncio.get_event_loop().run_in_executor(
_input_pool, lambda: input(" Enter choice: ").strip().lower()
)
choice = (await _ainput(" Enter choice: ")).strip().lower()
except (EOFError, KeyboardInterrupt):
_input_pool.shutdown(wait=False)
break
finally:
_input_pool.shutdown(wait=False)

if choice in ("q", "quit", "exit"):
break
Expand Down
Loading
Loading