Skip to content

Commit 6e8fa73

Browse files
committed
refactor: consolidate source into src/, add pyproject.toml
- Moved all Python modules from root into src/ (Git tracks as renames) - Added pyproject.toml with hatchling + hatch-vcs: * Version derived automatically from Git tag (vX.Y.Z -> X.Y.Z) * Optional extras: [llm], [train], [dev] * Wheel and sdist targets configured - CI workflow: new build-wheel job that builds a wheel after tests and uploads it as an artifact - Release workflow: wheel + sdist are now published as additional release assets - Security fixes: * Fixed path traversal in fastapi_app.py (OWASP A01) * Removed trust_remote_code=True from predict.py (RCE risk) * Replaced profit_factor np.inf with 999.0 (JSON serialisation bug) * Replaced datetime.utcnow() with datetime.now(timezone.utc) (deprecation) * Cleaned up inverted if/pass anti-pattern in _append_trade_journal * Fixed cross-coin RSI contamination in data_preperation.py - Tests: corrected broken imports in 3 test files - conftest.py: added src/ to sys.path
1 parent 1a95e0d commit 6e8fa73

30 files changed

Lines changed: 280 additions & 38 deletions

.github/workflows/ci.yml

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,34 @@ jobs:
182182
if [[ "${{ steps.pytest.outputs.pytest_exit_code }}" != "0" ]]; then
183183
echo "Pytest failed. See summary and artifacts for details."
184184
exit 1
185-
fi
185+
fi
186+
187+
build-wheel:
188+
name: Build wheel (py3.12)
189+
runs-on: ubuntu-latest
190+
needs: tests
191+
192+
steps:
193+
- name: Checkout
194+
uses: actions/checkout@v4
195+
with:
196+
fetch-depth: 0 # hatch-vcs needs full history for version tags
197+
fetch-tags: true
198+
199+
- name: Setup Python
200+
uses: actions/setup-python@v5
201+
with:
202+
python-version: "3.12"
203+
cache: pip
204+
205+
- name: Build wheel & sdist
206+
run: |
207+
python -m pip install --upgrade pip build hatchling hatch-vcs
208+
python -m build --wheel --sdist --outdir dist/
209+
210+
- name: Upload wheel artifact
211+
uses: actions/upload-artifact@v4
212+
with:
213+
name: python-wheel-${{ github.run_number }}
214+
path: dist/
215+
if-no-files-found: error

.github/workflows/release.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,15 @@ jobs:
280280
281281
echo "NOTES_FILE=release-notes-${VERSION}.txt" >> "$GITHUB_ENV"
282282
283+
- name: Build Python wheel
284+
run: |
285+
python -m pip install --upgrade pip build hatchling hatch-vcs
286+
python -m build --wheel --sdist --outdir dist/
287+
WHL=$(ls dist/*.whl | head -1)
288+
SDIST=$(ls dist/*.tar.gz | head -1)
289+
echo "WHL=${WHL}" >> "$GITHUB_ENV"
290+
echo "SDIST=${SDIST}" >> "$GITHUB_ENV"
291+
283292
- name: Publish GitHub Release
284293
uses: softprops/action-gh-release@v2
285294
with:
@@ -291,5 +300,7 @@ jobs:
291300
files: |
292301
${{ env.PKG }}.tar.gz
293302
${{ env.PKG }}.tar.gz.sha256
303+
${{ env.WHL }}
304+
${{ env.SDIST }}
294305
scripts/install_pi.sh
295306
${{ env.NOTES_FILE }}

pyproject.toml

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
[build-system]
2+
requires = ["hatchling", "hatch-vcs"]
3+
build-backend = "hatchling.build"
4+
5+
# ---------------------------------------------------------------------------
6+
# Project metadata
7+
# ---------------------------------------------------------------------------
8+
[project]
9+
name = "crypto-trading-bot"
10+
dynamic = ["version"]
11+
description = "Modular crypto trading pipeline: CatBoost/LLM ensemble signals, Kraken integration, simulation and live trading."
12+
readme = "README.md"
13+
license = { file = "LICENSE" }
14+
requires-python = ">=3.12"
15+
authors = [
16+
{ name = "Siegfried Kienzle" },
17+
]
18+
keywords = ["trading", "crypto", "catboost", "kraken", "bot"]
19+
classifiers = [
20+
"Development Status :: 4 - Beta",
21+
"Intended Audience :: Financial and Insurance Industry",
22+
"License :: OSI Approved :: BSD License",
23+
"Programming Language :: Python :: 3",
24+
"Programming Language :: Python :: 3.12",
25+
"Programming Language :: Python :: 3.13",
26+
"Topic :: Office/Business :: Financial :: Investment",
27+
]
28+
29+
# ---------------------------------------------------------------------------
30+
# Runtime dependencies (compatible-release bounds, not pinned)
31+
# ---------------------------------------------------------------------------
32+
dependencies = [
33+
"ccxt>=4.5",
34+
"pandas>=2.2",
35+
"numpy>=2.0",
36+
"TA-Lib>=0.4",
37+
"python-dotenv>=1.0",
38+
"psycopg[binary]>=3.1",
39+
"catboost>=1.2",
40+
"scikit-learn>=1.4",
41+
"fastapi>=0.110",
42+
"pydantic>=2.7",
43+
"uvicorn>=0.29",
44+
]
45+
46+
# ---------------------------------------------------------------------------
47+
# Optional extras
48+
# ---------------------------------------------------------------------------
49+
[project.optional-dependencies]
50+
# pip install crypto-trading-bot[llm] – LLM inference (predict.py)
51+
llm = [
52+
"torch>=2.2",
53+
"transformers>=4.40",
54+
"peft>=0.10",
55+
]
56+
# pip install crypto-trading-bot[train] – LLM fine-tuning (train_trading_model.py)
57+
train = [
58+
"trl>=0.8",
59+
"datasets>=2.18",
60+
"accelerate>=0.30",
61+
"bitsandbytes>=0.43",
62+
]
63+
# pip install crypto-trading-bot[dev]
64+
dev = [
65+
"pytest>=8.0",
66+
"httpx>=0.27",
67+
]
68+
69+
[project.urls]
70+
Repository = "https://github.com/sikienzl/TradingBot"
71+
Issues = "https://github.com/sikienzl/TradingBot/issues"
72+
73+
# ---------------------------------------------------------------------------
74+
# Version – derived automatically from the git tag (e.g. v1.0.22 → 1.0.22)
75+
# ---------------------------------------------------------------------------
76+
[tool.hatch.version]
77+
source = "vcs"
78+
79+
# hatch-vcs writes the resolved version to src/_version.py so it is
80+
# available at runtime without a git checkout.
81+
[tool.hatch.build.hooks.vcs]
82+
version-file = "src/_version.py"
83+
84+
# ---------------------------------------------------------------------------
85+
# Wheel: packages the src/ directory as the importable "src" namespace
86+
# ---------------------------------------------------------------------------
87+
[tool.hatch.build.targets.wheel]
88+
packages = ["src"]
89+
90+
# ---------------------------------------------------------------------------
91+
# Source distribution: include everything a developer needs
92+
# ---------------------------------------------------------------------------
93+
[tool.hatch.build.targets.sdist]
94+
include = [
95+
"src/",
96+
"scripts/",
97+
"deploy/",
98+
"tests/",
99+
"README.md",
100+
"LICENSE",
101+
"requirements.txt",
102+
"requirements-pi.txt",
103+
".env.example",
104+
".env.live.example",
105+
".env.simulation.example",
106+
]
107+
exclude = [
108+
"*.csv",
109+
"*.log",
110+
"trade_journal.csv",
111+
"training_data.csv",
112+
"auto_tune_state*.json",
113+
"tuning_policy.json",
114+
".env",
115+
"results/",
116+
"logs/",
117+
"model/",
118+
"catboost_info/",
119+
".venv/",
120+
"build/",
121+
"old_data/",
122+
]

src/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# src package initialization

src/_version.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# file generated by vcs-versioning
2+
# don't change, don't track in version control
3+
from __future__ import annotations
4+
5+
__all__ = [
6+
"__version__",
7+
"__version_tuple__",
8+
"version",
9+
"version_tuple",
10+
"__commit_id__",
11+
"commit_id",
12+
]
13+
14+
version: str
15+
__version__: str
16+
__version_tuple__: tuple[int | str, ...]
17+
version_tuple: tuple[int | str, ...]
18+
commit_id: str | None
19+
__commit_id__: str | None
20+
21+
__version__ = version = '1.0.23.dev35+g1a95e0d5d.d20260606'
22+
__version_tuple__ = version_tuple = (1, 0, 23, 'dev35', 'g1a95e0d5d.d20260606')
23+
24+
__commit_id__ = commit_id = None
File renamed without changes.
Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,15 @@ def prepare_training_data(input_file="full_crypto_data.csv", output_file="traini
5757
print("📈 Calculating missing indicators...")
5858

5959
if 'rsi' not in df.columns or df['rsi'].isna().all():
60-
# Einfache RSI-Berechnung (vereinfacht)
61-
delta = df['close'].diff()
62-
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
63-
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
64-
rs = gain / loss
65-
df['rsi'] = 100 - (100 / (1 + rs))
60+
# Calculate RSI per coin to avoid cross-coin contamination at boundaries.
61+
def _rsi_per_group(close: "pd.Series") -> "pd.Series":
62+
delta = close.diff()
63+
gain = delta.where(delta > 0, 0).rolling(window=14).mean()
64+
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
65+
rs = gain / loss
66+
return 100 - (100 / (1 + rs))
67+
68+
df['rsi'] = df.groupby('coin')['close'].transform(_rsi_per_group)
6669

6770
# 5. Prepare data for training
6871
print("🛠️ Finalizing data...")
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import json
1212
import re
1313
from collections import deque
14-
from datetime import datetime, timedelta
14+
from datetime import datetime, timedelta, timezone
1515

1616

1717
MIN_DRAWDOWN_PCT_BASE_USD = 1.0
@@ -76,7 +76,7 @@ def extract_return_pct(trade):
7676

7777
def calculate_pnl_metrics(trades, time_window_hours=24):
7878
"""Calculate PnL metrics from trades"""
79-
now = datetime.utcnow()
79+
now = datetime.now(timezone.utc)
8080
cutoff = now - timedelta(hours=time_window_hours)
8181

8282
all_time_realized_pnl = 0.0
@@ -377,7 +377,7 @@ def read_ai_copilot_usage(env_path=ENV_PATH, state_path=AI_COPILOT_STATE_PATH):
377377
if os.path.exists(state_path):
378378
with open(state_path, 'r', encoding='utf-8') as f:
379379
state = json.load(f)
380-
now = datetime.utcnow()
380+
now = datetime.now(timezone.utc)
381381
month_key = f"{now.year:04d}-{now.month:02d}"
382382
if state.get('month_key') == month_key:
383383
result['ai_copilot_budget_cap_usd'] = float(
@@ -404,7 +404,7 @@ def read_ai_shadow_suggestions(log_path=BOT_LOG_PATH):
404404
with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:
405405
tail_lines = list(deque(f, maxlen=4000))
406406

407-
now = datetime.utcnow()
407+
now = datetime.now(timezone.utc)
408408
suggestions = []
409409
rank = 1
410410

fastapi_app.py renamed to src/fastapi_app.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
import os
2+
import pathlib
3+
14
from fastapi import FastAPI, HTTPException, Query
25

3-
from api_models import (
6+
from src.api_models import (
47
CatBoostPredictionRequest,
58
CatBoostPredictionResponse,
69
ResearchSignalFeatures,
710
ResearchSignalPayload,
811
ScorecardResponse,
912
)
10-
from go_no_go_scorecard import ScorecardDataError, evaluate_scorecard
11-
from predict_catboost import CatBoostTradingPredictor
13+
from src.go_no_go_scorecard import ScorecardDataError, evaluate_scorecard
14+
from src.predict_catboost import CatBoostTradingPredictor
1215
from research_signal import load_research_signal_payload, normalize_research_payload_model
1316

1417

@@ -18,6 +21,47 @@
1821
description="Typed FastAPI endpoints for scorecards and research signals.",
1922
)
2023

24+
# Allowlist: when JOURNAL_DIR is set, restrict scorecard reads to that directory only.
25+
# When not set, any absolute path is accepted but relative paths with traversal are blocked.
26+
_SCORECARD_JOURNAL_DIR = os.getenv("JOURNAL_DIR", "")
27+
_SCORECARD_ALLOWED_DIR: pathlib.Path | None = (
28+
pathlib.Path(_SCORECARD_JOURNAL_DIR).resolve(
29+
) if _SCORECARD_JOURNAL_DIR else None
30+
)
31+
32+
33+
def _validate_scorecard_path(file: str) -> pathlib.Path:
34+
"""Resolve and validate the scorecard file path to prevent path traversal.
35+
36+
- If JOURNAL_DIR is set: the file must resolve to within that directory.
37+
- If JOURNAL_DIR is not set: absolute paths are accepted as-is; relative paths
38+
are resolved against the project root and must not escape it.
39+
"""
40+
project_root = pathlib.Path(__file__).parent.parent.resolve()
41+
try:
42+
if pathlib.Path(file).is_absolute():
43+
resolved = pathlib.Path(file).resolve()
44+
else:
45+
resolved = (project_root / file).resolve()
46+
except Exception as exc:
47+
raise HTTPException(
48+
status_code=400, detail="Invalid file path.") from exc
49+
50+
if _SCORECARD_ALLOWED_DIR is not None:
51+
if not str(resolved).startswith(str(_SCORECARD_ALLOWED_DIR) + os.sep) and resolved != _SCORECARD_ALLOWED_DIR:
52+
raise HTTPException(
53+
status_code=403,
54+
detail="Access denied: file is outside the allowed directory.",
55+
)
56+
elif not pathlib.Path(file).is_absolute():
57+
# Relative path: block traversal that escapes the project root.
58+
if not str(resolved).startswith(str(project_root) + os.sep) and resolved != project_root:
59+
raise HTTPException(
60+
status_code=403,
61+
detail="Access denied: relative path escapes the project root.",
62+
)
63+
return resolved
64+
2165

2266
@app.get("/health")
2367
def health() -> dict[str, str]:
@@ -78,9 +122,10 @@ def get_scorecard(
78122
min_catboost_vs_rules_pnl_delta: float = Query(default=-0.05),
79123
min_source_trades_for_delta: int = Query(default=50, ge=0),
80124
) -> ScorecardResponse:
125+
validated_path = _validate_scorecard_path(file)
81126
try:
82127
return evaluate_scorecard(
83-
file_path=file,
128+
file_path=str(validated_path),
84129
base_currency=base_currency,
85130
lookback_days=lookback_days,
86131
starting_capital=starting_capital,

0 commit comments

Comments
 (0)