"""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