- 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>
195 lines
7.6 KiB
Python
195 lines
7.6 KiB
Python
"""Tests for review_service."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from coinhunter.services import review_service as rs
|
|
|
|
|
|
class TestAnalyzeTrade:
|
|
def test_buy_good_when_price_up(self):
|
|
ex = None
|
|
trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY"}
|
|
with patch.object(rs, "fetch_current_price", return_value=52000.0):
|
|
result = rs.analyze_trade(trade, ex)
|
|
assert result["action"] == "BUY"
|
|
assert result["pnl_estimate_pct"] == 4.0
|
|
assert result["outcome_assessment"] == "good"
|
|
|
|
def test_buy_bad_when_price_down(self):
|
|
trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY"}
|
|
with patch.object(rs, "fetch_current_price", return_value=48000.0):
|
|
result = rs.analyze_trade(trade, None)
|
|
assert result["pnl_estimate_pct"] == -4.0
|
|
assert result["outcome_assessment"] == "bad"
|
|
|
|
def test_sell_all_missed_when_price_up(self):
|
|
trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "SELL_ALL"}
|
|
with patch.object(rs, "fetch_current_price", return_value=53000.0):
|
|
result = rs.analyze_trade(trade, None)
|
|
assert result["pnl_estimate_pct"] == -6.0
|
|
assert result["outcome_assessment"] == "missed"
|
|
|
|
def test_neutral_when_small_move(self):
|
|
trade = {"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY"}
|
|
with patch.object(rs, "fetch_current_price", return_value=50100.0):
|
|
result = rs.analyze_trade(trade, None)
|
|
assert result["outcome_assessment"] == "neutral"
|
|
|
|
def test_none_when_no_price(self):
|
|
trade = {"symbol": "BTC/USDT", "action": "BUY"}
|
|
result = rs.analyze_trade(trade, None)
|
|
assert result["pnl_estimate_pct"] is None
|
|
assert result["outcome_assessment"] == "neutral"
|
|
|
|
|
|
class TestAnalyzeHoldPasses:
|
|
def test_finds_passed_opportunities_that_rise(self):
|
|
decisions = [
|
|
{
|
|
"timestamp": "2024-01-01T00:00:00Z",
|
|
"decision": "HOLD",
|
|
"analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "PASS"}]},
|
|
"market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
|
|
}
|
|
]
|
|
with patch.object(rs, "fetch_current_price", return_value=110.0):
|
|
result = rs.analyze_hold_passes(decisions, None)
|
|
assert len(result) == 1
|
|
assert result[0]["symbol"] == "ETH/USDT"
|
|
assert result[0]["change_pct"] == 10.0
|
|
|
|
def test_ignores_non_pass_verdicts(self):
|
|
decisions = [
|
|
{
|
|
"decision": "HOLD",
|
|
"analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "BUY"}]},
|
|
"market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
|
|
}
|
|
]
|
|
with patch.object(rs, "fetch_current_price", return_value=110.0):
|
|
result = rs.analyze_hold_passes(decisions, None)
|
|
assert result == []
|
|
|
|
def test_ignores_small_moves(self):
|
|
decisions = [
|
|
{
|
|
"decision": "HOLD",
|
|
"analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "PASS"}]},
|
|
"market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
|
|
}
|
|
]
|
|
with patch.object(rs, "fetch_current_price", return_value=102.0):
|
|
result = rs.analyze_hold_passes(decisions, None)
|
|
assert result == []
|
|
|
|
|
|
class TestAnalyzeCashMisses:
|
|
def test_finds_cash_sit_misses(self):
|
|
decisions = [
|
|
{
|
|
"timestamp": "2024-01-01T00:00:00Z",
|
|
"balances": {"USDT": 100.0},
|
|
"market_snapshot": {"BTCUSDT": {"lastPrice": 50000.0}},
|
|
}
|
|
]
|
|
with patch.object(rs, "fetch_current_price", return_value=54000.0):
|
|
result = rs.analyze_cash_misses(decisions, None)
|
|
assert len(result) == 1
|
|
assert result[0]["symbol"] == "BTCUSDT"
|
|
assert result[0]["change_pct"] == 8.0
|
|
|
|
def test_requires_mostly_usdt(self):
|
|
decisions = [
|
|
{
|
|
"balances": {"USDT": 10.0, "BTC": 90.0},
|
|
"market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
|
|
}
|
|
]
|
|
with patch.object(rs, "fetch_current_price", return_value=200.0):
|
|
result = rs.analyze_cash_misses(decisions, None)
|
|
assert result == []
|
|
|
|
def test_dedupes_by_best_change(self):
|
|
decisions = [
|
|
{
|
|
"timestamp": "2024-01-01T00:00:00Z",
|
|
"balances": {"USDT": 100.0},
|
|
"market_snapshot": {"BTCUSDT": {"lastPrice": 50000.0}},
|
|
},
|
|
{
|
|
"timestamp": "2024-01-01T01:00:00Z",
|
|
"balances": {"USDT": 100.0},
|
|
"market_snapshot": {"BTCUSDT": {"lastPrice": 52000.0}},
|
|
},
|
|
]
|
|
with patch.object(rs, "fetch_current_price", return_value=56000.0):
|
|
result = rs.analyze_cash_misses(decisions, None)
|
|
assert len(result) == 1
|
|
# best change is from 50000 -> 56000 = 12%
|
|
assert result[0]["change_pct"] == 12.0
|
|
|
|
|
|
class TestGenerateReview:
|
|
def test_empty_period(self, monkeypatch):
|
|
monkeypatch.setattr(rs, "get_logs_last_n_hours", lambda log_type, hours: [])
|
|
review = rs.generate_review(1)
|
|
assert review["total_decisions"] == 0
|
|
assert review["total_trades"] == 0
|
|
assert any("No decisions or trades" in i for i in review["insights"])
|
|
|
|
def test_with_trades_and_decisions(self, monkeypatch):
|
|
trades = [
|
|
{"symbol": "BTC/USDT", "price": 50000.0, "action": "BUY", "timestamp": "2024-01-01T00:00:00Z"}
|
|
]
|
|
decisions = [
|
|
{
|
|
"timestamp": "2024-01-01T00:00:00Z",
|
|
"decision": "HOLD",
|
|
"analysis": {"opportunities_evaluated": [{"symbol": "ETH/USDT", "verdict": "PASS"}]},
|
|
"market_snapshot": {"ETHUSDT": {"lastPrice": 100.0}},
|
|
}
|
|
]
|
|
errors = [{"message": "oops"}]
|
|
|
|
def _logs(log_type, hours):
|
|
return {"decisions": decisions, "trades": trades, "errors": errors}.get(log_type, [])
|
|
|
|
monkeypatch.setattr(rs, "get_logs_last_n_hours", _logs)
|
|
monkeypatch.setattr(rs, "fetch_current_price", lambda ex, sym: 110.0 if "ETH" in sym else 52000.0)
|
|
review = rs.generate_review(1)
|
|
assert review["total_trades"] == 1
|
|
assert review["total_decisions"] == 1
|
|
assert review["stats"]["good_decisions"] == 1
|
|
assert review["stats"]["missed_hold_passes"] == 1
|
|
assert any("execution/system errors" in i for i in review["insights"])
|
|
|
|
|
|
class TestSaveReview:
|
|
def test_writes_file(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("COINHUNTER_HOME", str(tmp_path))
|
|
review = {"review_timestamp": "2024-01-01T00:00:00+08:00", "review_period_hours": 1}
|
|
path = rs.save_review(review)
|
|
saved = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
assert saved["review_period_hours"] == 1
|
|
|
|
|
|
class TestPrintReview:
|
|
def test_outputs_report(self, capsys):
|
|
review = {
|
|
"review_timestamp": "2024-01-01T00:00:00+08:00",
|
|
"review_period_hours": 1,
|
|
"total_decisions": 2,
|
|
"total_trades": 1,
|
|
"total_errors": 0,
|
|
"stats": {"good_decisions": 1, "neutral_decisions": 0, "bad_decisions": 0, "missed_opportunities": 0},
|
|
"insights": ["Insight A"],
|
|
"recommendations": ["Rec B"],
|
|
}
|
|
rs.print_review(review)
|
|
captured = capsys.readouterr()
|
|
assert "Coin Hunter Review Report" in captured.out
|
|
assert "Insight A" in captured.out
|
|
assert "Rec B" in captured.out
|