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:
2026-04-27 16:35:33 +08:00
parent e4b2239bcd
commit 76c4129c8d
18 changed files with 600 additions and 3142 deletions

View File

@@ -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,
)