refactor: simplify CLI to data layer for AI-assisted trading
Transform CoinHunter from an over-engineered auto-trading system into a lightweight data-layer CLI paired with the coinbuddy AI Skill. Key changes: - Remove non-core commands: backtest, strategy, opportunity dataset/evaluate/optimize - Add scan: rule-based market screening (zero token cost) - Add analyze: multi-timeframe technical analysis for AI consumption - Add watch: lightweight portfolio anomaly monitoring (zero token cost) - Remove services: backtest, dataset, evaluation, research, strategy - Add analyze_service with RSI, key levels, alerts, and AI-friendly summaries - Add watch_portfolio with drawdown/spike/concentration/technical triggers - Simplify config: remove research/dataset settings, add watch thresholds - Update TUI rendering for analyze and watch outputs - Update tests and CLAUDE.md for new architecture Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ from coinhunter import cli
|
||||
|
||||
|
||||
class CLITestCase(unittest.TestCase):
|
||||
def test_help_includes_v2_commands(self):
|
||||
def test_help_includes_core_commands(self):
|
||||
parser = cli.build_parser()
|
||||
help_text = parser.format_help()
|
||||
self.assertIn("init", help_text)
|
||||
@@ -18,7 +18,9 @@ class CLITestCase(unittest.TestCase):
|
||||
self.assertIn("buy", help_text)
|
||||
self.assertIn("sell", help_text)
|
||||
self.assertIn("portfolio", help_text)
|
||||
self.assertIn("opportunity", help_text)
|
||||
self.assertIn("scan", help_text)
|
||||
self.assertIn("analyze", help_text)
|
||||
self.assertIn("watch", help_text)
|
||||
self.assertIn("--doc", help_text)
|
||||
|
||||
def test_init_dispatches(self):
|
||||
@@ -150,11 +152,11 @@ class CLITestCase(unittest.TestCase):
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["recommendations"][0]["symbol"], "BTCUSDT")
|
||||
|
||||
def test_opportunity_dispatches(self):
|
||||
def test_scan_dispatches(self):
|
||||
captured = {}
|
||||
with (
|
||||
patch.object(
|
||||
cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "opportunity": {"top_n": 10}}
|
||||
cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "opportunity": {"top_n": 5}}
|
||||
),
|
||||
patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}),
|
||||
patch.object(cli, "SpotBinanceClient"),
|
||||
@@ -167,10 +169,52 @@ class CLITestCase(unittest.TestCase):
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
),
|
||||
):
|
||||
result = cli.main(["opportunity", "-s", "BTCUSDT", "ETHUSDT"])
|
||||
result = cli.main(["scan", "-s", "BTCUSDT", "ETHUSDT"])
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["recommendations"][0]["symbol"], "BTCUSDT")
|
||||
|
||||
def test_analyze_dispatches(self):
|
||||
captured = {}
|
||||
with (
|
||||
patch.object(
|
||||
cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}}
|
||||
),
|
||||
patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}),
|
||||
patch.object(cli, "SpotBinanceClient"),
|
||||
patch.object(
|
||||
cli.analyze_service,
|
||||
"analyze_symbols",
|
||||
return_value={"analyses": [{"symbol": "BTCUSDT", "summary": "test"}]},
|
||||
),
|
||||
patch.object(
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
),
|
||||
):
|
||||
result = cli.main(["analyze", "BTCUSDT", "ETHUSDT"])
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["analyses"][0]["symbol"], "BTCUSDT")
|
||||
|
||||
def test_watch_dispatches(self):
|
||||
captured = {}
|
||||
with (
|
||||
patch.object(
|
||||
cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "watch": {}}
|
||||
),
|
||||
patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}),
|
||||
patch.object(cli, "SpotBinanceClient"),
|
||||
patch.object(
|
||||
cli.portfolio_service,
|
||||
"watch_portfolio",
|
||||
return_value={"watch_results": [{"symbol": "BTCUSDT", "status": "healthy"}], "summary": "1 healthy", "need_review_count": 0, "healthy_count": 1},
|
||||
),
|
||||
patch.object(
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
),
|
||||
):
|
||||
result = cli.main(["watch"])
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["watch_results"][0]["symbol"], "BTCUSDT")
|
||||
|
||||
def test_catlog_dispatches(self):
|
||||
captured = {}
|
||||
with (
|
||||
@@ -248,215 +292,3 @@ class CLITestCase(unittest.TestCase):
|
||||
content = __import__("pathlib").Path(tmp_path).read_text()
|
||||
self.assertIn("BINANCE_API_SECRET=test_secret_value", content)
|
||||
__import__("os").unlink(tmp_path)
|
||||
|
||||
def test_opportunity_dataset_dispatches_without_private_client(self):
|
||||
captured = {}
|
||||
config = {"market": {"default_quote": "USDT"}, "opportunity": {}}
|
||||
with (
|
||||
patch.object(cli, "load_config", return_value=config),
|
||||
patch.object(cli, "_load_spot_client", side_effect=AssertionError("dataset should use public data")),
|
||||
patch.object(
|
||||
cli.opportunity_dataset_service,
|
||||
"collect_opportunity_dataset",
|
||||
return_value={"path": "/tmp/dataset.json", "symbols": ["BTCUSDT"]},
|
||||
) as collect_mock,
|
||||
patch.object(
|
||||
cli,
|
||||
"print_output",
|
||||
side_effect=lambda payload, **kwargs: captured.update({"payload": payload, "agent": kwargs["agent"]}),
|
||||
),
|
||||
):
|
||||
result = cli.main(
|
||||
["opportunity", "dataset", "--symbols", "BTCUSDT", "--simulate-days", "3", "--run-days", "7", "--agent"]
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["path"], "/tmp/dataset.json")
|
||||
self.assertTrue(captured["agent"])
|
||||
collect_mock.assert_called_once_with(
|
||||
config,
|
||||
symbols=["BTCUSDT"],
|
||||
simulate_days=3.0,
|
||||
run_days=7.0,
|
||||
output_path=None,
|
||||
)
|
||||
|
||||
def test_opportunity_evaluate_dispatches_without_private_client(self):
|
||||
captured = {}
|
||||
config = {"market": {"default_quote": "USDT"}, "opportunity": {}}
|
||||
with (
|
||||
patch.object(cli, "load_config", return_value=config),
|
||||
patch.object(cli, "_load_spot_client", side_effect=AssertionError("evaluate should use dataset only")),
|
||||
patch.object(
|
||||
cli.opportunity_evaluation_service,
|
||||
"evaluate_opportunity_dataset",
|
||||
return_value={"summary": {"count": 1, "correct": 1}},
|
||||
) as evaluate_mock,
|
||||
patch.object(
|
||||
cli,
|
||||
"print_output",
|
||||
side_effect=lambda payload, **kwargs: captured.update({"payload": payload, "agent": kwargs["agent"]}),
|
||||
),
|
||||
):
|
||||
result = cli.main(
|
||||
[
|
||||
"opportunity",
|
||||
"evaluate",
|
||||
"/tmp/dataset.json",
|
||||
"--horizon-hours",
|
||||
"6",
|
||||
"--take-profit-pct",
|
||||
"2",
|
||||
"--stop-loss-pct",
|
||||
"1.5",
|
||||
"--setup-target-pct",
|
||||
"1",
|
||||
"--lookback",
|
||||
"24",
|
||||
"--top-n",
|
||||
"3",
|
||||
"--examples",
|
||||
"5",
|
||||
"--agent",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["summary"]["correct"], 1)
|
||||
self.assertTrue(captured["agent"])
|
||||
evaluate_mock.assert_called_once_with(
|
||||
config,
|
||||
dataset_path="/tmp/dataset.json",
|
||||
horizon_hours=6.0,
|
||||
take_profit=0.02,
|
||||
stop_loss=0.015,
|
||||
setup_target=0.01,
|
||||
lookback=24,
|
||||
top_n=3,
|
||||
max_examples=5,
|
||||
)
|
||||
|
||||
def test_strategy_dispatches(self):
|
||||
captured = {}
|
||||
with (
|
||||
patch.object(
|
||||
cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "opportunity": {"top_n": 10}}
|
||||
),
|
||||
patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}),
|
||||
patch.object(cli, "SpotBinanceClient"),
|
||||
patch.object(
|
||||
cli.strategy_service,
|
||||
"generate_trade_signals",
|
||||
return_value={"buy": [{"symbol": "BTCUSDT", "score": 0.82}], "sell": [], "hold": []},
|
||||
),
|
||||
patch.object(
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
),
|
||||
):
|
||||
result = cli.main(["strategy", "-s", "BTCUSDT"])
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["buy"][0]["symbol"], "BTCUSDT")
|
||||
|
||||
def test_backtest_dispatches_without_private_client(self):
|
||||
captured = {}
|
||||
config = {"market": {"default_quote": "USDT"}, "opportunity": {}}
|
||||
with (
|
||||
patch.object(cli, "load_config", return_value=config),
|
||||
patch.object(cli, "_load_spot_client", side_effect=AssertionError("backtest should use dataset only")),
|
||||
patch.object(
|
||||
cli.backtest_service,
|
||||
"run_backtest",
|
||||
return_value={"summary": {"total_return_pct": 5.0, "win_rate": 0.6}, "trades": []},
|
||||
) as backtest_mock,
|
||||
patch.object(
|
||||
cli,
|
||||
"print_output",
|
||||
side_effect=lambda payload, **kwargs: captured.update({"payload": payload, "agent": kwargs["agent"]}),
|
||||
),
|
||||
):
|
||||
result = cli.main(
|
||||
[
|
||||
"backtest",
|
||||
"/tmp/dataset.json",
|
||||
"--initial-cash",
|
||||
"5000",
|
||||
"--max-positions",
|
||||
"3",
|
||||
"--position-size-pct",
|
||||
"20",
|
||||
"--commission-pct",
|
||||
"0.1",
|
||||
"--lookback",
|
||||
"12",
|
||||
"--agent",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["summary"]["total_return_pct"], 5.0)
|
||||
self.assertTrue(captured["agent"])
|
||||
backtest_mock.assert_called_once_with(
|
||||
config,
|
||||
dataset_path="/tmp/dataset.json",
|
||||
initial_cash=5000.0,
|
||||
max_positions=3,
|
||||
position_size_pct=0.2,
|
||||
commission_pct=0.001,
|
||||
lookback=12,
|
||||
decision_interval_minutes=None,
|
||||
)
|
||||
|
||||
def test_opportunity_optimize_dispatches_without_private_client(self):
|
||||
captured = {}
|
||||
config = {"market": {"default_quote": "USDT"}, "opportunity": {}}
|
||||
with (
|
||||
patch.object(cli, "load_config", return_value=config),
|
||||
patch.object(cli, "_load_spot_client", side_effect=AssertionError("optimize should use dataset only")),
|
||||
patch.object(
|
||||
cli.opportunity_evaluation_service,
|
||||
"optimize_opportunity_model",
|
||||
return_value={"best": {"summary": {"accuracy": 0.7}}},
|
||||
) as optimize_mock,
|
||||
patch.object(
|
||||
cli,
|
||||
"print_output",
|
||||
side_effect=lambda payload, **kwargs: captured.update({"payload": payload, "agent": kwargs["agent"]}),
|
||||
),
|
||||
):
|
||||
result = cli.main(
|
||||
[
|
||||
"opportunity",
|
||||
"optimize",
|
||||
"/tmp/dataset.json",
|
||||
"--horizon-hours",
|
||||
"6",
|
||||
"--take-profit-pct",
|
||||
"2",
|
||||
"--stop-loss-pct",
|
||||
"1.5",
|
||||
"--setup-target-pct",
|
||||
"1",
|
||||
"--lookback",
|
||||
"24",
|
||||
"--top-n",
|
||||
"3",
|
||||
"--passes",
|
||||
"1",
|
||||
"--agent",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["best"]["summary"]["accuracy"], 0.7)
|
||||
self.assertTrue(captured["agent"])
|
||||
optimize_mock.assert_called_once_with(
|
||||
config,
|
||||
dataset_path="/tmp/dataset.json",
|
||||
horizon_hours=6.0,
|
||||
take_profit=0.02,
|
||||
stop_loss=0.015,
|
||||
setup_target=0.01,
|
||||
lookback=24,
|
||||
top_n=3,
|
||||
passes=1,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user