feat: add catlog command, agent flag reorder, and TUI polish
- Add `coinhunter catlog` with limit/offset pagination for audit logs - Optimize audit log reading with deque to avoid loading all history - Allow `-a/--agent` flag after subcommands - Fix upgrade spinner artifact and empty line issues - Render audit log TUI as timeline with low-saturation event colors - Convert audit timestamps to local timezone in TUI - Remove futures-related capabilities - Add conda environment.yml for development - Bump version to 2.0.9 and update README Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -28,9 +28,30 @@ class FakeSpotClient:
|
||||
|
||||
def ticker_24h(self, symbols=None):
|
||||
rows = [
|
||||
{"symbol": "BTCUSDT", "lastPrice": "60000", "priceChangePercent": "4.5", "quoteVolume": "10000000", "highPrice": "61000", "lowPrice": "58000"},
|
||||
{"symbol": "ETHUSDT", "lastPrice": "3000", "priceChangePercent": "3.0", "quoteVolume": "8000000", "highPrice": "3050", "lowPrice": "2900"},
|
||||
{"symbol": "DOGEUSDT", "lastPrice": "0.1", "priceChangePercent": "1.0", "quoteVolume": "200", "highPrice": "0.11", "lowPrice": "0.09"},
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"lastPrice": "60000",
|
||||
"priceChangePercent": "4.5",
|
||||
"quoteVolume": "10000000",
|
||||
"highPrice": "61000",
|
||||
"lowPrice": "58000",
|
||||
},
|
||||
{
|
||||
"symbol": "ETHUSDT",
|
||||
"lastPrice": "3000",
|
||||
"priceChangePercent": "3.0",
|
||||
"quoteVolume": "8000000",
|
||||
"highPrice": "3050",
|
||||
"lowPrice": "2900",
|
||||
},
|
||||
{
|
||||
"symbol": "DOGEUSDT",
|
||||
"lastPrice": "0.1",
|
||||
"priceChangePercent": "1.0",
|
||||
"quoteVolume": "200",
|
||||
"highPrice": "0.11",
|
||||
"lowPrice": "0.09",
|
||||
},
|
||||
]
|
||||
if not symbols:
|
||||
return rows
|
||||
@@ -38,7 +59,13 @@ class FakeSpotClient:
|
||||
return [row for row in rows if row["symbol"] in wanted]
|
||||
|
||||
def exchange_info(self):
|
||||
return {"symbols": [{"symbol": "BTCUSDT", "status": "TRADING"}, {"symbol": "ETHUSDT", "status": "TRADING"}, {"symbol": "DOGEUSDT", "status": "BREAK"}]}
|
||||
return {
|
||||
"symbols": [
|
||||
{"symbol": "BTCUSDT", "status": "TRADING"},
|
||||
{"symbol": "ETHUSDT", "status": "TRADING"},
|
||||
{"symbol": "DOGEUSDT", "status": "BREAK"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class AccountMarketServicesTestCase(unittest.TestCase):
|
||||
|
||||
@@ -19,10 +19,16 @@ class CLITestCase(unittest.TestCase):
|
||||
|
||||
def test_init_dispatches(self):
|
||||
captured = {}
|
||||
with patch.object(cli, "ensure_init_files", return_value={"force": True, "root": "/tmp/ch"}), patch.object(
|
||||
cli, "install_shell_completion", return_value={"shell": "zsh", "installed": True, "path": "/tmp/ch/_coinhunter"}
|
||||
), patch.object(
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
with (
|
||||
patch.object(cli, "ensure_init_files", return_value={"force": True, "root": "/tmp/ch"}),
|
||||
patch.object(
|
||||
cli,
|
||||
"install_shell_completion",
|
||||
return_value={"shell": "zsh", "installed": True, "path": "/tmp/ch/_coinhunter"},
|
||||
),
|
||||
patch.object(
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
),
|
||||
):
|
||||
result = cli.main(["init", "--force"])
|
||||
self.assertEqual(result, 0)
|
||||
@@ -42,9 +48,29 @@ class CLITestCase(unittest.TestCase):
|
||||
|
||||
def test_upgrade_dispatches(self):
|
||||
captured = {}
|
||||
with patch.object(cli, "self_upgrade", return_value={"command": "pipx upgrade coinhunter", "returncode": 0}), patch.object(
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
with (
|
||||
patch.object(cli, "self_upgrade", return_value={"command": "pipx upgrade coinhunter", "returncode": 0}),
|
||||
patch.object(
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
),
|
||||
):
|
||||
result = cli.main(["upgrade"])
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["returncode"], 0)
|
||||
|
||||
def test_catlog_dispatches(self):
|
||||
captured = {}
|
||||
with (
|
||||
patch.object(
|
||||
cli, "read_audit_log", return_value=[{"timestamp": "2026-04-17T12:00:00Z", "event": "test_event"}]
|
||||
),
|
||||
patch.object(
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
),
|
||||
):
|
||||
result = cli.main(["catlog", "-n", "5", "-o", "10"])
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["limit"], 5)
|
||||
self.assertEqual(captured["payload"]["offset"], 10)
|
||||
self.assertIn("entries", captured["payload"])
|
||||
self.assertEqual(captured["payload"]["total"], 1)
|
||||
|
||||
@@ -14,7 +14,10 @@ from coinhunter.runtime import get_runtime_paths
|
||||
|
||||
class ConfigRuntimeTestCase(unittest.TestCase):
|
||||
def test_init_files_created_in_coinhunter_home(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(os.environ, {"COINHUNTER_HOME": str(Path(tmp_dir) / "home")}, clear=False):
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp_dir,
|
||||
patch.dict(os.environ, {"COINHUNTER_HOME": str(Path(tmp_dir) / "home")}, clear=False),
|
||||
):
|
||||
paths = get_runtime_paths()
|
||||
payload = ensure_init_files(paths)
|
||||
self.assertTrue(paths.config_file.exists())
|
||||
@@ -23,10 +26,13 @@ class ConfigRuntimeTestCase(unittest.TestCase):
|
||||
self.assertEqual(payload["root"], str(paths.root))
|
||||
|
||||
def test_load_config_and_env(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
|
||||
os.environ,
|
||||
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home")},
|
||||
clear=False,
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp_dir,
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home")},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
paths = get_runtime_paths()
|
||||
ensure_init_files(paths)
|
||||
@@ -40,10 +46,13 @@ class ConfigRuntimeTestCase(unittest.TestCase):
|
||||
self.assertEqual(os.environ["BINANCE_API_SECRET"], "def")
|
||||
|
||||
def test_env_file_overrides_existing_environment(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
|
||||
os.environ,
|
||||
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home"), "BINANCE_API_KEY": "old_key"},
|
||||
clear=False,
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp_dir,
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home"), "BINANCE_API_KEY": "old_key"},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
paths = get_runtime_paths()
|
||||
ensure_init_files(paths)
|
||||
@@ -55,10 +64,13 @@ class ConfigRuntimeTestCase(unittest.TestCase):
|
||||
self.assertEqual(os.environ["BINANCE_API_SECRET"], "new_secret")
|
||||
|
||||
def test_missing_credentials_raise(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
|
||||
os.environ,
|
||||
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home")},
|
||||
clear=False,
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp_dir,
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home")},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
os.environ.pop("BINANCE_API_KEY", None)
|
||||
os.environ.pop("BINANCE_API_SECRET", None)
|
||||
@@ -68,10 +80,13 @@ class ConfigRuntimeTestCase(unittest.TestCase):
|
||||
get_binance_credentials(paths)
|
||||
|
||||
def test_permission_error_is_explained(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir, patch.dict(
|
||||
os.environ,
|
||||
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home")},
|
||||
clear=False,
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp_dir,
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"COINHUNTER_HOME": str(Path(tmp_dir) / "home")},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
paths = get_runtime_paths()
|
||||
with patch("coinhunter.config.ensure_runtime_dirs", side_effect=PermissionError("no write access")):
|
||||
|
||||
@@ -29,17 +29,52 @@ class FakeSpotClient:
|
||||
|
||||
def ticker_24h(self, symbols=None):
|
||||
rows = {
|
||||
"BTCUSDT": {"symbol": "BTCUSDT", "lastPrice": "60000", "priceChangePercent": "5", "quoteVolume": "9000000", "highPrice": "60200", "lowPrice": "55000"},
|
||||
"ETHUSDT": {"symbol": "ETHUSDT", "lastPrice": "3000", "priceChangePercent": "3", "quoteVolume": "8000000", "highPrice": "3100", "lowPrice": "2800"},
|
||||
"SOLUSDT": {"symbol": "SOLUSDT", "lastPrice": "150", "priceChangePercent": "8", "quoteVolume": "10000000", "highPrice": "152", "lowPrice": "130"},
|
||||
"DOGEUSDT": {"symbol": "DOGEUSDT", "lastPrice": "0.1", "priceChangePercent": "1", "quoteVolume": "100", "highPrice": "0.11", "lowPrice": "0.09"},
|
||||
"BTCUSDT": {
|
||||
"symbol": "BTCUSDT",
|
||||
"lastPrice": "60000",
|
||||
"priceChangePercent": "5",
|
||||
"quoteVolume": "9000000",
|
||||
"highPrice": "60200",
|
||||
"lowPrice": "55000",
|
||||
},
|
||||
"ETHUSDT": {
|
||||
"symbol": "ETHUSDT",
|
||||
"lastPrice": "3000",
|
||||
"priceChangePercent": "3",
|
||||
"quoteVolume": "8000000",
|
||||
"highPrice": "3100",
|
||||
"lowPrice": "2800",
|
||||
},
|
||||
"SOLUSDT": {
|
||||
"symbol": "SOLUSDT",
|
||||
"lastPrice": "150",
|
||||
"priceChangePercent": "8",
|
||||
"quoteVolume": "10000000",
|
||||
"highPrice": "152",
|
||||
"lowPrice": "130",
|
||||
},
|
||||
"DOGEUSDT": {
|
||||
"symbol": "DOGEUSDT",
|
||||
"lastPrice": "0.1",
|
||||
"priceChangePercent": "1",
|
||||
"quoteVolume": "100",
|
||||
"highPrice": "0.11",
|
||||
"lowPrice": "0.09",
|
||||
},
|
||||
}
|
||||
if not symbols:
|
||||
return list(rows.values())
|
||||
return [rows[symbol] for symbol in symbols]
|
||||
|
||||
def exchange_info(self):
|
||||
return {"symbols": [{"symbol": "BTCUSDT", "status": "TRADING"}, {"symbol": "ETHUSDT", "status": "TRADING"}, {"symbol": "SOLUSDT", "status": "TRADING"}, {"symbol": "DOGEUSDT", "status": "TRADING"}]}
|
||||
return {
|
||||
"symbols": [
|
||||
{"symbol": "BTCUSDT", "status": "TRADING"},
|
||||
{"symbol": "ETHUSDT", "status": "TRADING"},
|
||||
{"symbol": "SOLUSDT", "status": "TRADING"},
|
||||
{"symbol": "DOGEUSDT", "status": "TRADING"},
|
||||
]
|
||||
}
|
||||
|
||||
def klines(self, symbol, interval, limit):
|
||||
curves = {
|
||||
@@ -50,7 +85,18 @@ class FakeSpotClient:
|
||||
}[symbol]
|
||||
rows = []
|
||||
for index, close in enumerate(curves[-limit:]):
|
||||
rows.append([index, close * 0.98, close * 1.01, close * 0.97, close, 100 + index * 10, index + 1, close * (100 + index * 10)])
|
||||
rows.append(
|
||||
[
|
||||
index,
|
||||
close * 0.98,
|
||||
close * 1.01,
|
||||
close * 0.97,
|
||||
close,
|
||||
100 + index * 10,
|
||||
index + 1,
|
||||
close * (100 + index * 10),
|
||||
]
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -85,7 +131,9 @@ class OpportunityServiceTestCase(unittest.TestCase):
|
||||
|
||||
def test_scan_is_deterministic(self):
|
||||
with patch.object(opportunity_service, "audit_event", return_value=None):
|
||||
payload = opportunity_service.scan_opportunities(self.config | {"opportunity": self.config["opportunity"] | {"top_n": 2}}, spot_client=FakeSpotClient())
|
||||
payload = opportunity_service.scan_opportunities(
|
||||
self.config | {"opportunity": self.config["opportunity"] | {"top_n": 2}}, spot_client=FakeSpotClient()
|
||||
)
|
||||
self.assertEqual([item["symbol"] for item in payload["recommendations"]], ["SOLUSDT", "BTCUSDT"])
|
||||
|
||||
def test_score_candidate_handles_empty_klines(self):
|
||||
|
||||
@@ -20,7 +20,9 @@ class FakeSpotClient:
|
||||
class TradeServiceTestCase(unittest.TestCase):
|
||||
def test_spot_market_buy_dry_run_does_not_call_client(self):
|
||||
events = []
|
||||
with patch.object(trade_service, "audit_event", side_effect=lambda event, payload: events.append((event, payload))):
|
||||
with patch.object(
|
||||
trade_service, "audit_event", side_effect=lambda event, payload: events.append((event, payload))
|
||||
):
|
||||
client = FakeSpotClient()
|
||||
payload = trade_service.execute_spot_trade(
|
||||
{"trading": {"dry_run_default": False}},
|
||||
|
||||
Reference in New Issue
Block a user