refactor: address high-priority debt and publish to PyPI

- Fix TOCTOU race conditions by wrapping read-modify-write cycles
  under single-file locks in execution_state, portfolio_service,
  precheck_state, state_manager, and precheck_service.
- Add missing test coverage (96 tests total):
  - test_review_service.py (15 tests)
  - test_check_api.py (6 tests)
  - test_external_gate.py main branches (+10 tests)
  - test_trade_execution.py new commands (+8 tests)
- Unify all agent-consumed JSON messages to English.
- Config-ize hardcoded values (volume filter, schema_version) via
  get_user_config with sensible defaults.
- Add 1-hour TTL to exchange cache with force_new override.
- Add ruff and mypy to dev dependencies; fix all type errors.
- Add __all__ declarations to 11 service modules.
- Sync README with new commands, config tuning docs, and PyPI badge.
- Publish package as coinhunter==1.0.0 on PyPI with MIT license.
- Deprecate coinhunter-cli==1.0.1 with runtime warning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 01:21:27 +08:00
parent 01bb54dee5
commit 62c40a9776
53 changed files with 2338 additions and 671 deletions

View File

@@ -2,6 +2,14 @@
import fcntl
import json
import os
__all__ = [
"locked_file",
"atomic_write_json",
"load_json_locked",
"save_json_locked",
"read_modify_write_json",
]
from contextlib import contextmanager
from pathlib import Path
@@ -9,13 +17,22 @@ from pathlib import Path
@contextmanager
def locked_file(path: Path):
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "a+", encoding="utf-8") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
f.seek(0)
yield f
f.flush()
os.fsync(f.fileno())
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
fd = None
try:
fd = os.open(path, os.O_RDWR | os.O_CREAT)
fcntl.flock(fd, fcntl.LOCK_EX)
yield fd
finally:
if fd is not None:
try:
os.fsync(fd)
except Exception:
pass
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except Exception:
pass
os.close(fd)
def atomic_write_json(path: Path, data: dict):
@@ -38,3 +55,22 @@ def load_json_locked(path: Path, lock_path: Path, default):
def save_json_locked(path: Path, lock_path: Path, data: dict):
with locked_file(lock_path):
atomic_write_json(path, data)
def read_modify_write_json(path: Path, lock_path: Path, default, modifier):
"""Atomic read-modify-write under a single file lock.
Loads JSON from *path* (or uses *default* if missing/invalid),
calls ``modifier(data)``, then atomically writes the result back.
If *modifier* returns None, the mutated *data* is written.
"""
with locked_file(lock_path):
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
data = default
else:
data = default
result = modifier(data)
atomic_write_json(path, result if result is not None else data)