Initialize crypto_monitor_user (user edition) from monitor codebase.
Retarget git remote, install path, and deploy docs from crypto_monitor to crypto_monitor_user. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest import mock
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.trade.account_risk_lib import (
|
||||
CLOSE_SOURCE_USER_HUB,
|
||||
CLOSE_SOURCE_USER_INSTANCE,
|
||||
CLOSE_SOURCE_USER_TREND_STOP,
|
||||
STATUS_DAILY,
|
||||
STATUS_FREEZE_1H,
|
||||
STATUS_FREEZE_4H,
|
||||
STATUS_FREEZE_POSITION,
|
||||
STATUS_NORMAL,
|
||||
account_risk_blocks_trading,
|
||||
apply_position_limit_risk,
|
||||
compute_account_risk_status,
|
||||
enrich_risk_status_countdown,
|
||||
ensure_account_risk_schema,
|
||||
max_active_positions_from_env,
|
||||
on_journal_saved,
|
||||
on_manual_close,
|
||||
on_user_initiated_close,
|
||||
parse_mood_issues,
|
||||
)
|
||||
|
||||
APP_TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _mem_conn():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
ensure_account_risk_schema(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def _mem_conn_with_journal():
|
||||
conn = _mem_conn()
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS journal_entries (
|
||||
close_datetime TEXT, early_exit_trigger TEXT, early_exit_note TEXT
|
||||
)"""
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
def _local_ms(dt_naive: datetime) -> int:
|
||||
return int(dt_naive.replace(tzinfo=APP_TZ).timestamp() * 1000)
|
||||
|
||||
|
||||
class AccountRiskLibTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.env_patch = mock.patch.dict(os.environ, {}, clear=False)
|
||||
self.env_patch.start()
|
||||
os.environ["RISK_CONTROL_ENABLED"] = "1"
|
||||
os.environ["RISK_COOLING_HOURS_MANUAL"] = "4"
|
||||
os.environ["RISK_COOLING_HOURS_MANUAL_JOURNAL"] = "1"
|
||||
os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2"
|
||||
os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1"
|
||||
os.environ["APP_TIMEZONE"] = "Asia/Shanghai"
|
||||
|
||||
def tearDown(self):
|
||||
self.env_patch.stop()
|
||||
|
||||
def test_user_instance_sets_4h_cooloff(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
close_ms = _local_ms(now)
|
||||
on_user_initiated_close(
|
||||
conn,
|
||||
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||
trade_record_id=101,
|
||||
closed_at_ms=close_ms,
|
||||
trading_day="2026-06-14",
|
||||
now=now,
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
self.assertEqual(st["status"], STATUS_FREEZE_4H)
|
||||
self.assertFalse(st["can_trade"])
|
||||
self.assertAlmostEqual(st["freeze_remaining_sec"], 4 * 3600, delta=2)
|
||||
|
||||
def test_invalid_source_ignored(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
on_user_initiated_close(
|
||||
conn,
|
||||
source="exchange_tpsl",
|
||||
trading_day="2026-06-14",
|
||||
now=now,
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||
|
||||
def test_second_user_close_daily_freeze(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
close_ms = _local_ms(now)
|
||||
on_user_initiated_close(
|
||||
conn, source=CLOSE_SOURCE_USER_HUB, closed_at_ms=close_ms, trading_day="2026-06-14", now=now
|
||||
)
|
||||
on_user_initiated_close(
|
||||
conn, source=CLOSE_SOURCE_USER_HUB, closed_at_ms=close_ms + 1000, trading_day="2026-06-14", now=now
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
self.assertEqual(st["status"], STATUS_DAILY)
|
||||
|
||||
def test_hub_close_all_count(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
close_ms = _local_ms(now)
|
||||
on_user_initiated_close(
|
||||
conn,
|
||||
source=CLOSE_SOURCE_USER_HUB,
|
||||
closed_at_ms=close_ms,
|
||||
trading_day="2026-06-14",
|
||||
now=now,
|
||||
count=2,
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
self.assertEqual(st["manual_close_count"], 2)
|
||||
self.assertEqual(st["status"], STATUS_DAILY)
|
||||
|
||||
def test_trend_stop_counts_as_manual(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
on_user_initiated_close(
|
||||
conn,
|
||||
source=CLOSE_SOURCE_USER_TREND_STOP,
|
||||
trading_day="2026-06-14",
|
||||
now=now,
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
self.assertEqual(st["manual_close_count"], 1)
|
||||
self.assertEqual(st["status"], STATUS_FREEZE_4H)
|
||||
self.assertAlmostEqual(st["freeze_remaining_sec"], 4 * 3600, delta=2)
|
||||
|
||||
def test_journal_manual_with_note_reduces_to_1h(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
close_ms = _local_ms(now)
|
||||
on_manual_close(conn, trade_record_id=9, closed_at_ms=close_ms, trading_day="2026-06-14", now=now)
|
||||
on_journal_saved(
|
||||
conn,
|
||||
early_exit_trigger="手动平仓",
|
||||
early_exit_note="违反计划提前离场",
|
||||
mood_issues_raw="",
|
||||
trading_day="2026-06-14",
|
||||
now=now,
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
self.assertEqual(st["status"], STATUS_FREEZE_1H)
|
||||
self.assertAlmostEqual(st["freeze_remaining_sec"], 3600, delta=2)
|
||||
|
||||
def test_journal_hub_close_without_pending_reduces_to_1h(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
close_ms = _local_ms(now)
|
||||
on_user_initiated_close(
|
||||
conn,
|
||||
source=CLOSE_SOURCE_USER_HUB,
|
||||
closed_at_ms=close_ms,
|
||||
trading_day="2026-06-14",
|
||||
now=now,
|
||||
)
|
||||
on_journal_saved(
|
||||
conn,
|
||||
early_exit_trigger="手动平仓",
|
||||
early_exit_note="中控全平后复盘说明",
|
||||
mood_issues_raw="",
|
||||
trading_day="2026-06-14",
|
||||
now=now,
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
self.assertEqual(st["status"], STATUS_FREEZE_1H)
|
||||
|
||||
def test_journal_reduces_when_manual_count_cleared_but_cooloff_active(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 15, 10, 0, 0)
|
||||
now_ms = _local_ms(now)
|
||||
close_ms = now_ms - 3600 * 1000
|
||||
until_ms = close_ms + 4 * 3600 * 1000
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
trading_day='2026-06-15',
|
||||
manual_close_count=0,
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=4,
|
||||
last_close_at_ms=?,
|
||||
daily_frozen=0
|
||||
WHERE id=1""",
|
||||
(until_ms, close_ms),
|
||||
)
|
||||
on_journal_saved(
|
||||
conn,
|
||||
early_exit_trigger="手动平仓",
|
||||
early_exit_note="切日后补复盘",
|
||||
mood_issues_raw="",
|
||||
trading_day="2026-06-15",
|
||||
now=now,
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-15", now=now)
|
||||
self.assertEqual(st["status"], STATUS_FREEZE_1H)
|
||||
|
||||
def test_journal_late_save_still_gets_1h_from_now(self):
|
||||
conn = _mem_conn()
|
||||
close_at = datetime(2026, 6, 14, 12, 0, 0)
|
||||
close_ms = _local_ms(close_at)
|
||||
on_user_initiated_close(
|
||||
conn,
|
||||
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||
closed_at_ms=close_ms,
|
||||
trading_day="2026-06-14",
|
||||
now=close_at,
|
||||
)
|
||||
journal_at = datetime(2026, 6, 14, 14, 0, 0)
|
||||
on_journal_saved(
|
||||
conn,
|
||||
early_exit_trigger="手动平仓",
|
||||
early_exit_note="补写复盘说明",
|
||||
mood_issues_raw="",
|
||||
trading_day="2026-06-14",
|
||||
now=journal_at,
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=journal_at)
|
||||
self.assertEqual(st["status"], STATUS_FREEZE_1H)
|
||||
self.assertEqual(st["cooloff_until_ms"], _local_ms(journal_at) + 3600 * 1000)
|
||||
|
||||
def test_stale_4h_until_with_1h_hours_uses_shorter_end(self):
|
||||
"""库内 cooloff_hours=1 但 cooloff_until_ms 仍为旧 4h 时,应按 last_close+1h 倒计时."""
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 6, 0)
|
||||
now_ms = _local_ms(now)
|
||||
close_ms = now_ms - 6 * 60 * 1000
|
||||
stale_until_4h = close_ms + 4 * 3600 * 1000
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
trading_day='2026-06-14',
|
||||
manual_close_count=1,
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=1,
|
||||
last_close_at_ms=?,
|
||||
daily_frozen=0
|
||||
WHERE id=1""",
|
||||
(stale_until_4h, close_ms),
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
self.assertEqual(st["status"], STATUS_FREEZE_1H)
|
||||
self.assertAlmostEqual(st["freeze_remaining_sec"], 54 * 60, delta=3)
|
||||
|
||||
def test_stale_4h_ignored_after_1h_journal_expired(self):
|
||||
"""复盘已降为 1h 且窗口结束后,不应再读库内旧 4h until."""
|
||||
conn = _mem_conn()
|
||||
close_at = datetime(2026, 6, 18, 17, 56, 0)
|
||||
now = datetime(2026, 6, 18, 21, 50, 0)
|
||||
close_ms = _local_ms(close_at)
|
||||
stale_4h_until = close_ms + 4 * 3600 * 1000
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
trading_day='2026-06-18',
|
||||
manual_close_count=1,
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=1,
|
||||
last_close_at_ms=?,
|
||||
daily_frozen=0
|
||||
WHERE id=1""",
|
||||
(stale_4h_until, close_ms),
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now)
|
||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||
self.assertTrue(st["can_trade"])
|
||||
row = conn.execute(
|
||||
"SELECT cooloff_until_ms, cooloff_hours, last_close_at_ms FROM account_risk_state WHERE id=1"
|
||||
).fetchone()
|
||||
self.assertIsNone(row["cooloff_until_ms"])
|
||||
self.assertIsNone(row["last_close_at_ms"])
|
||||
|
||||
def test_corrupted_anchor_cleared_when_journaled_manual_expired(self):
|
||||
"""上一版误把 last_close 写成近期时刻时,已复盘且 1h 已过的仍应显示正常."""
|
||||
conn = _mem_conn_with_journal()
|
||||
now = datetime(2026, 6, 18, 22, 30, 0)
|
||||
now_ms = _local_ms(now)
|
||||
bad_last = now_ms - 60 * 1000
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
trading_day='2026-06-18',
|
||||
manual_close_count=1,
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=1,
|
||||
last_close_at_ms=?,
|
||||
pending_journal_trade_id=NULL,
|
||||
daily_frozen=0
|
||||
WHERE id=1""",
|
||||
(bad_last + 3600 * 1000, bad_last),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO journal_entries (close_datetime, early_exit_trigger, early_exit_note) VALUES (?,?,?)",
|
||||
("2026-06-18 17:56:00", "手动平仓", "按计划离场"),
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now)
|
||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||
self.assertTrue(st["can_trade"])
|
||||
|
||||
def test_future_last_close_does_not_restart_cooloff(self):
|
||||
"""脏数据 last_close 在未来时,不应重启 1h/4h 冻结."""
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 18, 22, 30, 0)
|
||||
now_ms = _local_ms(now)
|
||||
future_close = now_ms + 49 * 60 * 1000
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
trading_day='2026-06-18',
|
||||
manual_close_count=1,
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=1,
|
||||
last_close_at_ms=?,
|
||||
daily_frozen=0
|
||||
WHERE id=1""",
|
||||
(future_close + 3600 * 1000, future_close),
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now)
|
||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||
self.assertTrue(st["can_trade"])
|
||||
|
||||
def test_active_4h_countdown_matches_tier(self):
|
||||
conn = _mem_conn()
|
||||
close_at = datetime(2026, 6, 18, 21, 46, 0)
|
||||
now = datetime(2026, 6, 18, 21, 52, 0)
|
||||
close_ms = _local_ms(close_at)
|
||||
on_user_initiated_close(
|
||||
conn,
|
||||
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||
closed_at_ms=close_ms,
|
||||
trading_day="2026-06-18",
|
||||
now=close_at,
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now)
|
||||
self.assertEqual(st["status"], STATUS_FREEZE_4H)
|
||||
self.assertAlmostEqual(st["freeze_remaining_sec"], 3 * 3600 + 54 * 60, delta=5)
|
||||
|
||||
def test_trading_day_reset_clears_expired_stale_cooloff(self):
|
||||
conn = _mem_conn()
|
||||
close_at = datetime(2026, 6, 18, 17, 56, 0)
|
||||
close_ms = _local_ms(close_at)
|
||||
stale_4h_until = close_ms + 4 * 3600 * 1000
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
trading_day='2026-06-18',
|
||||
manual_close_count=1,
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=1,
|
||||
last_close_at_ms=?,
|
||||
daily_frozen=0
|
||||
WHERE id=1""",
|
||||
(stale_4h_until, close_ms),
|
||||
)
|
||||
next_day = datetime(2026, 6, 19, 9, 0, 0)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-19", now=next_day)
|
||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||
row = conn.execute("SELECT cooloff_until_ms FROM account_risk_state WHERE id=1").fetchone()
|
||||
self.assertIsNone(row["cooloff_until_ms"])
|
||||
|
||||
def test_remaining_never_exceeds_configured_hours(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 18, 22, 0, 0)
|
||||
now_ms = _local_ms(now)
|
||||
future_close = now_ms + 49 * 60 * 1000
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
trading_day='2026-06-18',
|
||||
manual_close_count=1,
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=4,
|
||||
last_close_at_ms=?,
|
||||
daily_frozen=0
|
||||
WHERE id=1""",
|
||||
(future_close + 4 * 3600 * 1000, future_close),
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-18", now=now)
|
||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||
self.assertTrue(st["can_trade"])
|
||||
|
||||
def test_legacy_naive_utc_ms_countdown_normalized(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
now_ms = _local_ms(now)
|
||||
offset_ms = 8 * 3600 * 1000
|
||||
legacy_close = now_ms + offset_ms
|
||||
legacy_until = legacy_close + 4 * 3600 * 1000
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET
|
||||
trading_day='2026-06-14',
|
||||
manual_close_count=1,
|
||||
cooloff_until_ms=?,
|
||||
cooloff_hours=4,
|
||||
last_close_at_ms=?,
|
||||
daily_frozen=0
|
||||
WHERE id=1""",
|
||||
(legacy_until, legacy_close),
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=8)
|
||||
self.assertEqual(st["status"], STATUS_FREEZE_4H)
|
||||
self.assertAlmostEqual(st["freeze_remaining_sec"], 4 * 3600, delta=2)
|
||||
|
||||
def test_journal_mood_issues_daily_freeze(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
on_journal_saved(
|
||||
conn,
|
||||
early_exit_trigger="止损",
|
||||
early_exit_note="",
|
||||
mood_issues_raw=["报复开仓"],
|
||||
trading_day="2026-06-14",
|
||||
now=now,
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
self.assertEqual(st["status"], STATUS_DAILY)
|
||||
|
||||
def test_cooloff_expired_returns_normal(self):
|
||||
conn = _mem_conn()
|
||||
start = datetime(2026, 6, 14, 8, 0, 0)
|
||||
close_ms = _local_ms(start)
|
||||
on_user_initiated_close(
|
||||
conn, source=CLOSE_SOURCE_USER_INSTANCE, closed_at_ms=close_ms, trading_day="2026-06-14", now=start
|
||||
)
|
||||
later = datetime(2026, 6, 14, 13, 0, 0)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=later)
|
||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||
row = conn.execute("SELECT cooloff_until_ms FROM account_risk_state WHERE id=1").fetchone()
|
||||
self.assertIsNone(row["cooloff_until_ms"])
|
||||
|
||||
def test_trading_day_reset_clears_daily_frozen(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
on_journal_saved(
|
||||
conn,
|
||||
early_exit_trigger="止损",
|
||||
early_exit_note="",
|
||||
mood_issues_raw="扛单",
|
||||
trading_day="2026-06-14",
|
||||
now=now,
|
||||
)
|
||||
next_day = datetime(2026, 6, 15, 8, 0, 0)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-15", now=next_day)
|
||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||
|
||||
def test_parse_mood_issues_filters_unknown(self):
|
||||
self.assertEqual(parse_mood_issues("怕踏空,未知标签,扛单"), ["怕踏空", "扛单"])
|
||||
|
||||
def test_enrich_countdown_for_daily_and_cooloff(self):
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
close_ms = _local_ms(now)
|
||||
on_user_initiated_close(
|
||||
conn,
|
||||
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||
closed_at_ms=close_ms,
|
||||
trading_day="2026-06-14",
|
||||
now=now,
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=8)
|
||||
self.assertGreater(st["freeze_remaining_sec"], 0)
|
||||
self.assertEqual(st["freeze_until_ms"], st["cooloff_until_ms"])
|
||||
|
||||
on_journal_saved(
|
||||
conn,
|
||||
early_exit_trigger="止损",
|
||||
early_exit_note="",
|
||||
mood_issues_raw="扛单",
|
||||
trading_day="2026-06-14",
|
||||
now=now,
|
||||
)
|
||||
st2 = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
st2 = enrich_risk_status_countdown(st2, now=now, daily_reset_hour=8)
|
||||
self.assertTrue(st2["daily_frozen"])
|
||||
self.assertGreater(st2["freeze_remaining_sec"], 0)
|
||||
self.assertIsNotNone(st2["freeze_until_ms"])
|
||||
|
||||
def test_disabled_risk_control(self):
|
||||
os.environ["RISK_CONTROL_ENABLED"] = "0"
|
||||
conn = _mem_conn()
|
||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||
on_user_initiated_close(
|
||||
conn, source=CLOSE_SOURCE_USER_INSTANCE, trading_day="2026-06-14", now=now
|
||||
)
|
||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||
self.assertFalse(st["enabled"])
|
||||
self.assertTrue(st["can_trade"])
|
||||
ok, _ = account_risk_blocks_trading(conn, trading_day="2026-06-14", now=now)
|
||||
self.assertTrue(ok)
|
||||
|
||||
def test_position_limit_freeze_from_env(self):
|
||||
os.environ["MAX_ACTIVE_POSITIONS"] = "2"
|
||||
st = apply_position_limit_risk({"status": STATUS_NORMAL, "can_trade": True}, 2)
|
||||
self.assertEqual(st["status"], STATUS_FREEZE_POSITION)
|
||||
self.assertEqual(st["status_label"], "仓位上限冻结")
|
||||
self.assertFalse(st["can_trade"])
|
||||
self.assertIn("2/2", st["reason"])
|
||||
self.assertIn("顺势加仓", st["reason"])
|
||||
self.assertTrue(st.get("can_roll"))
|
||||
self.assertEqual(st["max_active_positions"], 2)
|
||||
|
||||
def test_position_limit_normal_when_under_cap(self):
|
||||
st = apply_position_limit_risk({"status": STATUS_NORMAL, "can_trade": True}, 0, max_active_positions=1)
|
||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||
self.assertTrue(st["can_trade"])
|
||||
|
||||
def test_time_freeze_takes_priority_over_position_limit(self):
|
||||
st = apply_position_limit_risk(
|
||||
{"status": STATUS_FREEZE_4H, "status_label": "4h冻结", "can_trade": False},
|
||||
5,
|
||||
max_active_positions=1,
|
||||
)
|
||||
self.assertEqual(st["status"], STATUS_FREEZE_4H)
|
||||
self.assertEqual(st["active_count"], 5)
|
||||
|
||||
def test_max_active_positions_from_env(self):
|
||||
os.environ["MAX_ACTIVE_POSITIONS"] = "3"
|
||||
self.assertEqual(max_active_positions_from_env(), 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""ai_client message parsing / empty-content retries."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.ai.ai_client import _openai_message_text, ai_review # noqa: E402
|
||||
|
||||
|
||||
class TestOpenaiMessageText(unittest.TestCase):
|
||||
def test_prefers_content(self):
|
||||
self.assertEqual(
|
||||
_openai_message_text({"content": "正文", "reasoning": "think"}),
|
||||
"正文",
|
||||
)
|
||||
|
||||
def test_falls_back_to_reasoning_content(self):
|
||||
self.assertEqual(
|
||||
_openai_message_text({"content": "", "reasoning_content": "备选正文"}),
|
||||
"备选正文",
|
||||
)
|
||||
|
||||
def test_skips_english_chain_of_thought(self):
|
||||
self.assertEqual(
|
||||
_openai_message_text(
|
||||
{
|
||||
"content": "",
|
||||
"reasoning": "Here's a thinking process that leads to the answer...",
|
||||
}
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
|
||||
class TestAiReviewImageCap(unittest.TestCase):
|
||||
def test_caps_images_and_sets_max_tokens(self):
|
||||
captured = {}
|
||||
|
||||
def fake_generate(prompt, **kwargs):
|
||||
captured["prompt"] = prompt
|
||||
captured.update(kwargs)
|
||||
return "OK_REVIEW"
|
||||
|
||||
with mock.patch("lib.ai.ai_client.ai_generate", side_effect=fake_generate):
|
||||
with mock.patch.dict("os.environ", {"AI_REVIEW_MAX_IMAGES": "2"}, clear=False):
|
||||
out = ai_review("记录", "每日", image_paths=["a.png", "b.png", "c.png"])
|
||||
self.assertIn("OK_REVIEW", out)
|
||||
self.assertEqual(captured.get("image_paths"), ["a.png", "b.png"])
|
||||
self.assertEqual(captured.get("max_tokens"), 8192)
|
||||
self.assertIn("另跳过 1 张", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""AI 复盘 journal 文本格式化(三所共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.ai.ai_review_lib import journal_row_lines_for_ai # noqa: E402
|
||||
|
||||
|
||||
class TestAiReviewLib(unittest.TestCase):
|
||||
def test_journal_row_includes_expect_and_actual_rr(self):
|
||||
text = journal_row_lines_for_ai(
|
||||
1,
|
||||
{
|
||||
"coin": "HYPE",
|
||||
"tf": "5m",
|
||||
"pnl": "10.73",
|
||||
"real_rr": "2.1354",
|
||||
"expect_rr": "-",
|
||||
"entry_reason": "趋势回调",
|
||||
"exit_reason": "移动止盈",
|
||||
"hold_duration": "1天 3小时",
|
||||
"mood_issues": "",
|
||||
"post_breakeven_stare": "否",
|
||||
"new_trade_while_occupied": "否",
|
||||
"note": "测试备注",
|
||||
},
|
||||
)
|
||||
self.assertIn("实际RR:2.1354", text)
|
||||
self.assertIn("预期RR:-", text)
|
||||
self.assertIn("开仓逻辑:趋势回调", text)
|
||||
self.assertIn("备注:测试备注", text)
|
||||
self.assertNotIn("开仓类型", text)
|
||||
|
||||
def test_journal_row_accepts_sqlite_row(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""CREATE TABLE journal_entries (
|
||||
coin TEXT, tf TEXT, pnl TEXT, real_rr TEXT, expect_rr TEXT,
|
||||
entry_reason TEXT, exit_reason TEXT, hold_duration TEXT,
|
||||
mood_issues TEXT, mood_score INTEGER, note TEXT
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT INTO journal_entries VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
("BTC", "15m", "5", "1.2", "2.0", "突破", "止盈", "2小时", "", None, ""),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM journal_entries").fetchone()
|
||||
conn.close()
|
||||
text = journal_row_lines_for_ai(1, row)
|
||||
self.assertIn("BTC 15m", text)
|
||||
self.assertIn("实际RR:1.2", text)
|
||||
self.assertIn("开仓逻辑:突破", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.hub.hub_symbol_archive_lib import init_db, list_archive_calendar, upsert_trades_cache, upsert_trade_overlay
|
||||
|
||||
|
||||
def _bj_ms(y, m, d, hh, mm):
|
||||
dt = datetime(y, m, d, hh, mm, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
class ArchiveCalendarTests(unittest.TestCase):
|
||||
def test_calendar_groups_by_trading_day_and_sick(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "arch.db"
|
||||
init_db(db)
|
||||
upsert_trades_cache(
|
||||
"binance",
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"symbol": "BTC/USDT",
|
||||
"direction": "long",
|
||||
"result": "止盈",
|
||||
"pnl_amount": 10.0,
|
||||
"opened_at": "2026-06-18 09:00:00",
|
||||
"closed_at": "2026-06-18 10:00:00",
|
||||
"closed_at_ms": _bj_ms(2026, 6, 18, 10, 0),
|
||||
"exchange_turnover_usdt": 2000.0,
|
||||
"exchange_commission_usdt": 0.8,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"symbol": "ETH/USDT",
|
||||
"direction": "short",
|
||||
"result": "止损",
|
||||
"pnl_amount": -5.0,
|
||||
"opened_at": "2026-06-18 14:00:00",
|
||||
"closed_at": "2026-06-18 15:00:00",
|
||||
"closed_at_ms": _bj_ms(2026, 6, 18, 15, 0),
|
||||
},
|
||||
],
|
||||
db_path=db,
|
||||
)
|
||||
upsert_trade_overlay("binance", 2, behavior_tag="sick", db_path=db)
|
||||
payload = list_archive_calendar(2026, 6, db_path=db)
|
||||
self.assertEqual(payload["month"], 6)
|
||||
days = payload["days"]
|
||||
self.assertTrue(days)
|
||||
sick_days = [d for d in days.values() if d.get("has_sick")]
|
||||
self.assertTrue(sick_days)
|
||||
self.assertGreaterEqual(payload["month_open_count"], 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Tests for trend strategy snapshot backfill helpers."""
|
||||
from scripts.backfill_trend_strategy_snapshots import (
|
||||
infer_exit_price,
|
||||
resolve_result_label,
|
||||
)
|
||||
|
||||
|
||||
def test_infer_exit_price_short_stop_loss():
|
||||
exit_p = infer_exit_price("short", 0.336, 4.85, 10, -2.45)
|
||||
assert exit_p is not None
|
||||
assert abs(exit_p - 0.353) < 0.002
|
||||
|
||||
|
||||
def test_resolve_result_label_from_plan_status():
|
||||
plan = {"status": "stopped_sl", "message": "stopped_sl"}
|
||||
assert resolve_result_label(plan, None) == "止损"
|
||||
|
||||
|
||||
def test_resolve_result_label_prefers_plan_status():
|
||||
plan = {"status": "stopped_sl"}
|
||||
trade = {"result": "移动止盈"}
|
||||
assert resolve_result_label(plan, trade) == "止损"
|
||||
@@ -0,0 +1,90 @@
|
||||
import unittest
|
||||
|
||||
from lib.trade.daily_open_limit_lib import (
|
||||
build_daily_open_alert_prompt,
|
||||
can_trade_new_open,
|
||||
check_daily_open_hard_limit,
|
||||
count_opens_for_trading_day,
|
||||
daily_open_hard_limit_blocks,
|
||||
format_daily_open_counter_line,
|
||||
hard_limit_block_reason,
|
||||
load_daily_open_limits_from_env,
|
||||
parse_daily_open_hard_limit,
|
||||
should_send_daily_open_alert,
|
||||
)
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self, count: int):
|
||||
self._count = count
|
||||
|
||||
def execute(self, _sql, _params):
|
||||
return self
|
||||
|
||||
def fetchone(self):
|
||||
return (self._count,)
|
||||
|
||||
|
||||
class DailyOpenLimitLibTests(unittest.TestCase):
|
||||
def test_parse_hard_limit_zero_disables(self):
|
||||
self.assertEqual(parse_daily_open_hard_limit("0"), 0)
|
||||
self.assertEqual(parse_daily_open_hard_limit(None, default=0), 0)
|
||||
|
||||
def test_load_from_env(self):
|
||||
alert, hard = load_daily_open_limits_from_env(
|
||||
{"DAILY_OPEN_ALERT_THRESHOLD": "3", "DAILY_OPEN_HARD_LIMIT": "8"}
|
||||
)
|
||||
self.assertEqual(alert, 3)
|
||||
self.assertEqual(hard, 8)
|
||||
|
||||
def test_hard_limit_blocks(self):
|
||||
self.assertFalse(daily_open_hard_limit_blocks(4, 0))
|
||||
self.assertFalse(daily_open_hard_limit_blocks(4, 5))
|
||||
self.assertTrue(daily_open_hard_limit_blocks(5, 5))
|
||||
|
||||
def test_check_daily_open_hard_limit(self):
|
||||
conn = _FakeConn(5)
|
||||
ok, reason, n = check_daily_open_hard_limit(conn, "2026-06-07", 5, 8)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(n, 5)
|
||||
self.assertIn("已达上限", reason)
|
||||
self.assertIn("8:00", reason)
|
||||
|
||||
def test_count_opens(self):
|
||||
self.assertEqual(count_opens_for_trading_day(_FakeConn(3), "2026-06-07"), 3)
|
||||
|
||||
def test_can_trade_new_open(self):
|
||||
self.assertTrue(
|
||||
can_trade_new_open(
|
||||
time_allows=True,
|
||||
active_count=0,
|
||||
max_active_positions=1,
|
||||
opens_today=2,
|
||||
hard_limit=5,
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
can_trade_new_open(
|
||||
time_allows=True,
|
||||
active_count=0,
|
||||
max_active_positions=1,
|
||||
opens_today=5,
|
||||
hard_limit=5,
|
||||
)
|
||||
)
|
||||
|
||||
def test_alert_crossing(self):
|
||||
self.assertTrue(should_send_daily_open_alert(4, 5, 5))
|
||||
self.assertFalse(should_send_daily_open_alert(5, 6, 5))
|
||||
|
||||
def test_prompt_includes_hard_limit(self):
|
||||
txt = build_daily_open_alert_prompt("2026-06-07", 5, 5, hard_limit=8)
|
||||
self.assertIn("硬上限 8", txt)
|
||||
|
||||
def test_counter_line(self):
|
||||
line = format_daily_open_counter_line(3, 5, 8)
|
||||
self.assertIn("3 / 硬上限 8", line)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""数据看板仓位来源:监控匹配优先级."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from hub_ai.context import ( # noqa: E402
|
||||
_options_source_label,
|
||||
resolve_position_monitor_source,
|
||||
)
|
||||
|
||||
|
||||
class TestDashboardPositionSource(unittest.TestCase):
|
||||
def test_priority_hedge_over_roll(self):
|
||||
hub = {
|
||||
"ok": True,
|
||||
"hedges": [
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"direction": "long",
|
||||
"legs": [{"leg_role": "perp", "symbol": "ETH/USDT:USDT", "status": "open"}],
|
||||
}
|
||||
],
|
||||
"rolls": [{"symbol": "ETH/USDT:USDT", "direction": "long"}],
|
||||
}
|
||||
self.assertEqual(
|
||||
resolve_position_monitor_source({"symbol": "ETH/USDT:USDT", "side": "long"}, hub),
|
||||
"永期对冲",
|
||||
)
|
||||
|
||||
def test_unmatched_is_dash(self):
|
||||
hub = {"ok": True, "orders": [], "trends": [], "rolls": [], "keys": [], "hedges": []}
|
||||
self.assertEqual(
|
||||
resolve_position_monitor_source({"symbol": "BTC/USDT:USDT", "side": "short"}, hub),
|
||||
"—",
|
||||
)
|
||||
|
||||
def test_roll_beats_order(self):
|
||||
hub = {
|
||||
"ok": True,
|
||||
"rolls": [{"symbol": "BTC/USDT:USDT", "direction": "short"}],
|
||||
"orders": [{"symbol": "BTC/USDT:USDT", "direction": "short", "monitor_type": "下单监控"}],
|
||||
}
|
||||
self.assertEqual(
|
||||
resolve_position_monitor_source({"symbol": "BTC/USDT:USDT", "side": "short"}, hub),
|
||||
"顺势加仓",
|
||||
)
|
||||
|
||||
def test_options_plain_is_pure(self):
|
||||
self.assertEqual(_options_source_label({"source": "option", "source_label": "纯期权"}), "纯期权")
|
||||
self.assertEqual(_options_source_label({"source": "option"}), "纯期权")
|
||||
self.assertEqual(
|
||||
_options_source_label({"source": "options_options", "source_label": "期期对冲"}),
|
||||
"期期对冲",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,182 @@
|
||||
import unittest
|
||||
|
||||
from lib.trade.entry_model_lib import (
|
||||
ENTRY_MODEL_BIG_DIV_A,
|
||||
ENTRY_MODEL_BIG_DIV_B,
|
||||
ENTRY_MODEL_LAUNCH_A,
|
||||
ENTRY_MODEL_LAUNCH_B,
|
||||
ENTRY_MODEL_SMALL_DIV,
|
||||
ENTRY_CATEGORY_REVERSAL,
|
||||
ENTRY_CATEGORY_TREND,
|
||||
build_intraday_entry_reason_options,
|
||||
build_trend_div_entry_reason_options,
|
||||
entry_model_categories,
|
||||
entry_model_category,
|
||||
entry_model_display_label,
|
||||
entry_model_label,
|
||||
format_entry_type_display,
|
||||
hub_meta_entry_context,
|
||||
intraday_entry_model_options,
|
||||
is_intraday_trading_profile,
|
||||
open_position_button_label,
|
||||
parse_manual_order_style_fields,
|
||||
resolve_trade_record_entry_reason,
|
||||
trade_style_for_entry_model,
|
||||
trend_manual_entry_reason_count,
|
||||
)
|
||||
from lib.trade.trade_policy_lib import TradePolicy, load_trade_policy
|
||||
|
||||
|
||||
class TestEntryModelLib(unittest.TestCase):
|
||||
def test_intraday_profile_btc_eth_whitelist(self):
|
||||
policy = load_trade_policy(
|
||||
{
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
|
||||
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
|
||||
}
|
||||
)
|
||||
self.assertTrue(is_intraday_trading_profile(policy))
|
||||
self.assertEqual(trend_manual_entry_reason_count(policy), 2)
|
||||
|
||||
def test_trend_div_profile_alt(self):
|
||||
policy = load_trade_policy(
|
||||
{
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "false",
|
||||
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
|
||||
}
|
||||
)
|
||||
self.assertFalse(is_intraday_trading_profile(policy))
|
||||
self.assertEqual(trend_manual_entry_reason_count(policy), 5)
|
||||
|
||||
def test_entry_model_maps_trade_style(self):
|
||||
self.assertEqual(trade_style_for_entry_model(ENTRY_MODEL_LAUNCH_A), "trend")
|
||||
self.assertEqual(trade_style_for_entry_model(ENTRY_MODEL_BIG_DIV_A), "trend")
|
||||
self.assertEqual(trade_style_for_entry_model(ENTRY_MODEL_SMALL_DIV), "swing")
|
||||
self.assertEqual(entry_model_label(ENTRY_MODEL_LAUNCH_B), "启动B")
|
||||
self.assertEqual(entry_model_category(ENTRY_MODEL_LAUNCH_A), ENTRY_CATEGORY_REVERSAL)
|
||||
self.assertEqual(entry_model_category(ENTRY_MODEL_BIG_DIV_B), ENTRY_CATEGORY_TREND)
|
||||
|
||||
def test_entry_model_categories_two_level(self):
|
||||
cats = entry_model_categories()
|
||||
keys = [c["key"] for c in cats]
|
||||
self.assertEqual(keys, ["reversal", "trend", "swing"])
|
||||
reversal = cats[0]["options"]
|
||||
self.assertEqual([o["code"] for o in reversal], ["launch_a", "launch_b"])
|
||||
self.assertEqual(len(cats[2]["options"]), 1)
|
||||
|
||||
def test_parse_trend_div_requires_entry_model(self):
|
||||
policy = TradePolicy(False, "both", False, ())
|
||||
style, code, err = parse_manual_order_style_fields(policy, {})
|
||||
self.assertTrue(err)
|
||||
self.assertEqual(code, None)
|
||||
|
||||
style, code, err = parse_manual_order_style_fields(
|
||||
policy, {"entry_model": ENTRY_MODEL_SMALL_DIV}
|
||||
)
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(code, ENTRY_MODEL_SMALL_DIV)
|
||||
self.assertEqual(style, "swing")
|
||||
|
||||
style, code, err = parse_manual_order_style_fields(
|
||||
policy, {"entry_model": ENTRY_MODEL_LAUNCH_A}
|
||||
)
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(code, ENTRY_MODEL_LAUNCH_A)
|
||||
self.assertEqual(style, "trend")
|
||||
|
||||
def test_hub_meta_intraday(self):
|
||||
policy = load_trade_policy(
|
||||
{
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
|
||||
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
|
||||
}
|
||||
)
|
||||
ctx = hub_meta_entry_context(policy)
|
||||
self.assertTrue(ctx["intraday_discipline"])
|
||||
self.assertEqual(ctx["order_entry_profile"], "intraday")
|
||||
policy = TradePolicy(False, "both", True, ("BTC", "ETH"))
|
||||
style, code, err = parse_manual_order_style_fields(policy, {"trade_style": "swing"})
|
||||
self.assertIsNone(err)
|
||||
self.assertIsNone(code)
|
||||
self.assertEqual(style, "swing")
|
||||
|
||||
def test_intraday_entry_model_options(self):
|
||||
opts = intraday_entry_model_options()
|
||||
codes = [o.code for o in opts]
|
||||
self.assertEqual(codes, ["liquidity_false_break", "structure_breakout"])
|
||||
self.assertEqual(entry_model_label("liquidity_false_break"), "假破")
|
||||
|
||||
def test_parse_intraday_requires_entry_model(self):
|
||||
policy = TradePolicy(True, "both", True, ("BTC", "ETH"))
|
||||
style, code, err = parse_manual_order_style_fields(policy, {})
|
||||
self.assertTrue(err)
|
||||
style, code, err = parse_manual_order_style_fields(
|
||||
policy, {"entry_model": "structure_breakout"}
|
||||
)
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(code, "structure_breakout")
|
||||
self.assertEqual(style, "trend")
|
||||
|
||||
def test_open_position_button_intraday(self):
|
||||
policy = TradePolicy(True, "both", True, ("BTC", "ETH"))
|
||||
self.assertEqual(
|
||||
open_position_button_label(policy, "full_margin"),
|
||||
"开仓(日内·全仓杠杆)",
|
||||
)
|
||||
|
||||
def test_resolve_entry_reason_from_model(self):
|
||||
er = resolve_trade_record_entry_reason(entry_model=ENTRY_MODEL_BIG_DIV_B)
|
||||
self.assertEqual(er, "顺势/大分歧B")
|
||||
er2 = resolve_trade_record_entry_reason(entry_model=ENTRY_MODEL_LAUNCH_A)
|
||||
self.assertEqual(er2, "反转/启动A")
|
||||
|
||||
def test_entry_model_display_label(self):
|
||||
self.assertEqual(entry_model_display_label(ENTRY_MODEL_LAUNCH_A), "反转/启动A")
|
||||
self.assertEqual(entry_model_display_label(ENTRY_MODEL_SMALL_DIV), "波段单/小分歧")
|
||||
self.assertEqual(entry_model_display_label("liquidity_false_break"), "波段单/假破")
|
||||
self.assertEqual(format_entry_type_display("启动A"), "反转/启动A")
|
||||
self.assertEqual(entry_model_label(ENTRY_MODEL_LAUNCH_B), "启动B")
|
||||
|
||||
def test_resolve_entry_reason_trade_style_fallback(self):
|
||||
er = resolve_trade_record_entry_reason(trade_style="swing")
|
||||
self.assertEqual(er, "波段单")
|
||||
er2 = resolve_trade_record_entry_reason(trade_style="trend")
|
||||
self.assertEqual(er2, "趋势单")
|
||||
|
||||
def test_build_trend_div_journal_options(self):
|
||||
opts = build_trend_div_entry_reason_options(("趋势回调",))
|
||||
self.assertEqual(opts[:5], ("反转/启动A", "反转/启动B", "顺势/大分歧A", "顺势/大分歧B", "波段单/小分歧"))
|
||||
self.assertIn("趋势单", opts)
|
||||
self.assertIn("波段单", opts)
|
||||
self.assertIn("趋势回调", opts)
|
||||
|
||||
def test_build_intraday_journal_options_only_four(self):
|
||||
opts = build_intraday_entry_reason_options(
|
||||
(
|
||||
"关键位箱体突破",
|
||||
"关键位回调触价开仓",
|
||||
"关键位突破触价开仓",
|
||||
),
|
||||
("趋势回调", "顺势加仓"),
|
||||
)
|
||||
self.assertEqual(
|
||||
opts,
|
||||
(
|
||||
"波段单/假破",
|
||||
"波段单/结构突破",
|
||||
"关键位回调触价开仓",
|
||||
"关键位突破触价开仓",
|
||||
),
|
||||
)
|
||||
|
||||
def test_normalize_review_entry_reason(self):
|
||||
from lib.trade.entry_model_lib import normalize_review_entry_reason
|
||||
|
||||
allowed = build_trend_div_entry_reason_options(())
|
||||
self.assertEqual(normalize_review_entry_reason("反转/启动A", allowed), "反转/启动A")
|
||||
self.assertEqual(normalize_review_entry_reason("启动A", allowed), "反转/启动A")
|
||||
self.assertEqual(normalize_review_entry_reason("趋势单", allowed), "趋势单")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,76 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from lib.key_monitor.false_breakout_key_monitor_lib import (
|
||||
FALSE_BREAKOUT_MONITOR_TYPE,
|
||||
calc_false_breakout_plan,
|
||||
false_breakout_gate_preview,
|
||||
is_false_breakout_expired,
|
||||
key_price_from_row,
|
||||
normalize_false_breakout_symbol,
|
||||
storage_bounds_from_key_price,
|
||||
)
|
||||
|
||||
|
||||
class FalseBreakoutKeyMonitorLibTests(unittest.TestCase):
|
||||
def test_normalize_symbol(self):
|
||||
self.assertEqual(normalize_false_breakout_symbol("btc"), "BTC/USDT")
|
||||
self.assertEqual(normalize_false_breakout_symbol("ETH/USDT"), "ETH/USDT")
|
||||
self.assertIsNone(normalize_false_breakout_symbol("SOL"))
|
||||
|
||||
def test_short_plan(self):
|
||||
plan = calc_false_breakout_plan("short", 100000)
|
||||
self.assertIsNotNone(plan)
|
||||
entry, sl, tp = plan
|
||||
self.assertAlmostEqual(entry, 100100.0)
|
||||
self.assertAlmostEqual(sl, 100600.5)
|
||||
self.assertAlmostEqual(tp, 99349.25)
|
||||
|
||||
def test_long_plan(self):
|
||||
plan = calc_false_breakout_plan("long", 100000)
|
||||
self.assertIsNotNone(plan)
|
||||
entry, sl, tp = plan
|
||||
self.assertAlmostEqual(entry, 99900.0)
|
||||
self.assertAlmostEqual(sl, 99400.5)
|
||||
self.assertAlmostEqual(tp, 100649.25)
|
||||
|
||||
def test_storage_bounds(self):
|
||||
up, low = storage_bounds_from_key_price("short", 100000)
|
||||
self.assertGreater(up, low)
|
||||
self.assertAlmostEqual(up, 100000.0)
|
||||
self.assertAlmostEqual(low, 99990.0)
|
||||
up, low = storage_bounds_from_key_price("long", 100000)
|
||||
self.assertGreater(up, low)
|
||||
self.assertAlmostEqual(low, 100000.0)
|
||||
self.assertAlmostEqual(up, 100010.0)
|
||||
|
||||
def test_key_price_from_row(self):
|
||||
self.assertEqual(key_price_from_row("short", 100100, 100000), 100100)
|
||||
self.assertEqual(key_price_from_row("long", 100100, 100000), 100000)
|
||||
|
||||
def test_expiry(self):
|
||||
now = datetime(2026, 6, 9, 12, 0, 0)
|
||||
created = "2026-06-08 12:00:00"
|
||||
self.assertTrue(is_false_breakout_expired(created, now))
|
||||
self.assertFalse(is_false_breakout_expired(created, now - timedelta(hours=1)))
|
||||
|
||||
def test_monitor_type_constant(self):
|
||||
self.assertEqual(FALSE_BREAKOUT_MONITOR_TYPE, "假突破")
|
||||
|
||||
def test_gate_preview_not_box_gate(self):
|
||||
now = datetime(2026, 6, 7, 12, 0, 0)
|
||||
prev = false_breakout_gate_preview(
|
||||
entry_display="1635.0",
|
||||
limit_order_id="oid-1",
|
||||
created_at="2026-06-07 10:00:00",
|
||||
now=now,
|
||||
)
|
||||
self.assertIn("假突破", prev["summary"])
|
||||
self.assertIn("等待成交", prev["summary"])
|
||||
self.assertNotIn("量:", prev["summary"])
|
||||
self.assertIn("限价单:oid-1", prev["metrics"])
|
||||
self.assertTrue(prev["gate_ok"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""silence_werkzeug_access_log 烟雾测试."""
|
||||
import logging
|
||||
import unittest
|
||||
|
||||
from lib.common.flask_access_log_lib import silence_werkzeug_access_log
|
||||
|
||||
|
||||
class TestSilenceAccessLog(unittest.TestCase):
|
||||
def test_sets_warning_level(self):
|
||||
log = logging.getLogger("werkzeug")
|
||||
log.setLevel(logging.INFO)
|
||||
silence_werkzeug_access_log()
|
||||
self.assertGreaterEqual(log.level, logging.WARNING)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,109 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.trade.force_close_lib import (
|
||||
apply_force_close_display_result,
|
||||
build_force_close_state,
|
||||
coerce_force_close_result,
|
||||
compute_next_force_close_at_ms,
|
||||
force_close_label,
|
||||
format_force_close_countdown,
|
||||
infer_force_close_result,
|
||||
is_close_at_force_close_window,
|
||||
is_force_close_active_hour,
|
||||
is_force_close_executing,
|
||||
)
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _ms(y, m, d, hh, mm=0):
|
||||
return int(datetime(y, m, d, hh, mm, tzinfo=TZ).timestamp() * 1000)
|
||||
|
||||
|
||||
def test_force_close_label():
|
||||
assert force_close_label(0) == "强制清仓 00:00"
|
||||
assert force_close_label(8) == "强制清仓 08:00"
|
||||
|
||||
|
||||
def test_next_force_close_at_midnight():
|
||||
# 2026-07-05 23:30 -> next 2026-07-06 00:00
|
||||
now = _ms(2026, 7, 5, 23, 30)
|
||||
assert compute_next_force_close_at_ms(bj_hour=0, now_ms=now, tz_name="Asia/Shanghai") == _ms(
|
||||
2026, 7, 6, 0, 0
|
||||
)
|
||||
|
||||
|
||||
def test_next_force_close_same_day_before_hour():
|
||||
now = _ms(2026, 7, 6, 15, 0)
|
||||
assert compute_next_force_close_at_ms(bj_hour=0, now_ms=now, tz_name="Asia/Shanghai") == _ms(
|
||||
2026, 7, 7, 0, 0
|
||||
)
|
||||
|
||||
|
||||
def test_next_force_close_after_trigger_same_day():
|
||||
now = _ms(2026, 7, 7, 0, 18)
|
||||
assert compute_next_force_close_at_ms(bj_hour=0, now_ms=now, tz_name="Asia/Shanghai") == _ms(
|
||||
2026, 7, 8, 0, 0
|
||||
)
|
||||
|
||||
|
||||
def test_executing_window_and_countdown():
|
||||
now = _ms(2026, 7, 6, 0, 14)
|
||||
assert is_force_close_executing(0, now_ms=now, tz_name="Asia/Shanghai")
|
||||
assert is_force_close_active_hour(0, now_ms=now, tz_name="Asia/Shanghai")
|
||||
state = build_force_close_state(
|
||||
True, 0, now_ms=now, tz_name="Asia/Shanghai", has_active_positions=True
|
||||
)
|
||||
assert state["enabled"] is True
|
||||
assert state["active"] is True
|
||||
assert state["countdown"] == "执行中"
|
||||
assert state["next_at_ms"] == _ms(2026, 7, 7, 0, 0)
|
||||
|
||||
|
||||
def test_not_executing_after_grace_without_positions():
|
||||
now = _ms(2026, 7, 7, 0, 18)
|
||||
assert not is_force_close_executing(0, now_ms=now, tz_name="Asia/Shanghai")
|
||||
state = build_force_close_state(
|
||||
True, 0, now_ms=now, tz_name="Asia/Shanghai", has_active_positions=False
|
||||
)
|
||||
assert state["active"] is False
|
||||
assert state["countdown"] != "执行中"
|
||||
assert state["next_at_ms"] == _ms(2026, 7, 8, 0, 0)
|
||||
|
||||
|
||||
def test_disabled_state():
|
||||
state = build_force_close_state(False, 0)
|
||||
assert state["enabled"] is False
|
||||
assert state["next_at_ms"] is None
|
||||
|
||||
|
||||
def test_format_countdown():
|
||||
assert format_force_close_countdown(3661) == "01:01:01"
|
||||
assert format_force_close_countdown(0, active=True) == "执行中"
|
||||
|
||||
|
||||
def test_infer_force_close_from_closed_at():
|
||||
assert is_close_at_force_close_window("2026-07-07 00:00", 0)
|
||||
assert infer_force_close_result("2026-07-07 00:00", enabled=True, bj_hour=0) == "强制清仓"
|
||||
assert infer_force_close_result("2026-07-07 00:20", enabled=True, bj_hour=0) is None
|
||||
|
||||
|
||||
def test_coerce_and_display_external_close_at_midnight():
|
||||
res, note = coerce_force_close_result(
|
||||
"外部平仓",
|
||||
"2026-07-07 00:00",
|
||||
enabled=True,
|
||||
bj_hour=0,
|
||||
)
|
||||
assert res == "强制清仓"
|
||||
assert "00:00" in note
|
||||
assert (
|
||||
apply_force_close_display_result(
|
||||
"手动平仓",
|
||||
"2026-07-07 00:00",
|
||||
enabled=True,
|
||||
bj_hour=0,
|
||||
)
|
||||
== "强制清仓"
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
from lib.exchange.gate_position_history_lib import pick_gate_position_close, unified_symbol_for_match
|
||||
|
||||
|
||||
def test_unified_symbol_strips_settle_suffix():
|
||||
assert unified_symbol_for_match("BTC/USDT:USDT") == "BTC/USDT"
|
||||
|
||||
|
||||
def test_pick_gate_position_close_matches_symbol_side_and_time():
|
||||
hist = [
|
||||
{
|
||||
"symbol_u": "SOL/USDT",
|
||||
"side": "short",
|
||||
"close_ms": 1_700_000_000_000,
|
||||
"open_ms": 1_699_999_000_000,
|
||||
"pnl": -1.25,
|
||||
"sync_key": "SOL_USDT|1|short",
|
||||
}
|
||||
]
|
||||
hit = pick_gate_position_close(
|
||||
hist,
|
||||
"SOL/USDT:USDT",
|
||||
"short",
|
||||
opened_at_ms=1_699_999_500_000,
|
||||
)
|
||||
assert hit is not None
|
||||
assert hit["pnl"] == -1.25
|
||||
@@ -0,0 +1,44 @@
|
||||
"""gate_transfer_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from lib.exchange.gate_transfer_lib import count_auto_transfer_blockers
|
||||
|
||||
|
||||
class GateTransferLibTest(unittest.TestCase):
|
||||
def test_counts_order_monitors_first(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.execute("CREATE TABLE order_monitors (status TEXT)")
|
||||
conn.execute("CREATE TABLE trend_pullback_plans (status TEXT, first_order_done INTEGER)")
|
||||
conn.execute("INSERT INTO order_monitors VALUES ('active')")
|
||||
conn.execute("INSERT INTO trend_pullback_plans VALUES ('active', 1)")
|
||||
conn.commit()
|
||||
n = count_auto_transfer_blockers(conn, count_order_monitors=lambda c: 1)
|
||||
self.assertEqual(n, 1)
|
||||
conn.close()
|
||||
|
||||
def test_counts_trend_plan_when_no_order_monitors(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.execute("CREATE TABLE order_monitors (status TEXT)")
|
||||
conn.execute("CREATE TABLE trend_pullback_plans (status TEXT, first_order_done INTEGER)")
|
||||
conn.execute("INSERT INTO trend_pullback_plans VALUES ('active', 1)")
|
||||
conn.commit()
|
||||
n = count_auto_transfer_blockers(conn, count_order_monitors=lambda c: 0)
|
||||
self.assertEqual(n, 1)
|
||||
conn.close()
|
||||
|
||||
def test_ignores_trend_plan_without_first_order(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.execute("CREATE TABLE order_monitors (status TEXT)")
|
||||
conn.execute("CREATE TABLE trend_pullback_plans (status TEXT, first_order_done INTEGER)")
|
||||
conn.execute("INSERT INTO trend_pullback_plans VALUES ('active', 0)")
|
||||
conn.commit()
|
||||
n = count_auto_transfer_blockers(conn, count_order_monitors=lambda c: 0)
|
||||
self.assertEqual(n, 0)
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""对冲计划 P0 测算口径单测."""
|
||||
import unittest
|
||||
|
||||
from lib.hedge_plan.hedge_plan_calc_lib import (
|
||||
build_options_options_preview,
|
||||
build_perp_options_preview,
|
||||
floor_contracts_to_precision,
|
||||
gate_status,
|
||||
option_expiry_pnl,
|
||||
option_premium_total,
|
||||
perp_pnl,
|
||||
)
|
||||
|
||||
|
||||
class TestHedgePlanCalc(unittest.TestCase):
|
||||
def test_perp_tp_accounting_is_profit_minus_premium(self):
|
||||
p = build_perp_options_preview(
|
||||
direction="long",
|
||||
entry=3200,
|
||||
tp=3400,
|
||||
sl=3000,
|
||||
contracts=50,
|
||||
contract_size=0.01,
|
||||
opt_type="P",
|
||||
strike=3100,
|
||||
sheets=10,
|
||||
ct_mult=0.01,
|
||||
premium_paid=8,
|
||||
index_px=3200,
|
||||
)
|
||||
self.assertEqual(p["summary"]["tp_total"], 92.0)
|
||||
self.assertEqual(p["scenarios"][0]["options_pnl"], -8.0)
|
||||
|
||||
def test_perp_sl_accounting_is_option_plus_perp_signed(self):
|
||||
p = build_perp_options_preview(
|
||||
direction="long",
|
||||
entry=3200,
|
||||
tp=3400,
|
||||
sl=3000,
|
||||
contracts=50,
|
||||
contract_size=0.01,
|
||||
opt_type="P",
|
||||
strike=3100,
|
||||
sheets=10,
|
||||
ct_mult=0.01,
|
||||
premium_paid=8,
|
||||
index_px=3200,
|
||||
)
|
||||
self.assertEqual(p["summary"]["sl_total"], -98.0)
|
||||
self.assertEqual(p["summary"]["hedge_ratio_at_sl"], 2.0)
|
||||
|
||||
def test_option_premium_and_expiry(self):
|
||||
self.assertEqual(option_premium_total(ask=80, sheets=1, ct_mult=0.01), 0.8)
|
||||
self.assertEqual(
|
||||
option_expiry_pnl(
|
||||
opt_type="P", strike=3100, spot=3000, sheets=10, ct_mult=0.01, premium_paid=8
|
||||
),
|
||||
2.0,
|
||||
)
|
||||
|
||||
def test_gate_perp_requires_full_margin_for_start_message(self):
|
||||
g = gate_status(
|
||||
hedge_enabled=True,
|
||||
sizing_mode="risk",
|
||||
plan_type="perp_options",
|
||||
options_enabled=True,
|
||||
)
|
||||
self.assertTrue(g["can_preview"])
|
||||
self.assertFalse(g["can_start"])
|
||||
self.assertTrue(any("全仓" in r for r in g["reasons"]))
|
||||
|
||||
def test_oo_expiry_loss_flag(self):
|
||||
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||
p = build_options_options_preview(
|
||||
target_price_up=3500,
|
||||
target_price_down=3000,
|
||||
index_px=3200,
|
||||
leg_a=a,
|
||||
leg_b=b,
|
||||
)
|
||||
self.assertEqual(p["summary"]["premium_paid"], 10)
|
||||
self.assertTrue(p["summary"]["expiry_is_loss"])
|
||||
self.assertEqual(len(p["scenarios"]), 4)
|
||||
self.assertEqual(p["scenarios"][0]["id"], "target_up")
|
||||
self.assertEqual(p["scenarios"][1]["id"], "target_down")
|
||||
|
||||
def test_oo_legacy_single_target_still_works(self):
|
||||
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||
p = build_options_options_preview(target_price=3500, index_px=3200, leg_a=a, leg_b=b)
|
||||
self.assertEqual(p["target_price_up"], 3500)
|
||||
self.assertEqual(p["target_price_down"], 3500)
|
||||
|
||||
def test_perp_short_pnl(self):
|
||||
self.assertEqual(
|
||||
perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1),
|
||||
10,
|
||||
)
|
||||
|
||||
def test_floor_contracts_to_precision(self):
|
||||
self.assertEqual(floor_contracts_to_precision(4.569713, 4), 4.5697)
|
||||
self.assertEqual(floor_contracts_to_precision(4.569713, 0), 4.0)
|
||||
self.assertEqual(floor_contracts_to_precision(0, 4), 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,160 @@
|
||||
"""对冲计划历史删除与分类型统计."""
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from lib.hedge_plan.hedge_plan_db import (
|
||||
_metrics_from_pnls,
|
||||
active_options_targets_by_inst,
|
||||
delete_plan,
|
||||
init_hedge_plan_tables,
|
||||
insert_leg,
|
||||
insert_plan,
|
||||
legs_contract_summary,
|
||||
stats_summary,
|
||||
)
|
||||
|
||||
|
||||
def _mem():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_hedge_plan_tables(conn)
|
||||
return conn
|
||||
|
||||
|
||||
class TestHedgeHistoryStats(unittest.TestCase):
|
||||
def test_metrics_win_rate_pf_dd(self):
|
||||
rows = [
|
||||
{"realized_pnl_total": 10, "closed_at": "2026-01-01", "premium_total": 1},
|
||||
{"realized_pnl_total": -4, "closed_at": "2026-01-02", "premium_total": 1},
|
||||
{"realized_pnl_total": 6, "closed_at": "2026-01-03", "premium_total": 1},
|
||||
{"realized_pnl_total": -12, "closed_at": "2026-01-04", "premium_total": 1},
|
||||
]
|
||||
m = _metrics_from_pnls(rows)
|
||||
self.assertEqual(m["count"], 4)
|
||||
self.assertEqual(m["wins"], 2)
|
||||
self.assertAlmostEqual(m["win_rate"], 0.5)
|
||||
# gross win 16 / gross loss 16 = 1
|
||||
self.assertAlmostEqual(m["profit_factor"], 1.0)
|
||||
self.assertAlmostEqual(m["max_profit"], 10)
|
||||
self.assertAlmostEqual(m["max_loss"], -12)
|
||||
# equity: 10 → 6 → 12 → 0; peak 12, dd to 0 = 12
|
||||
self.assertAlmostEqual(m["max_drawdown"], 12)
|
||||
|
||||
def test_stats_by_type_and_delete(self):
|
||||
conn = _mem()
|
||||
po = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"status": "closed",
|
||||
"underlying": "ETH",
|
||||
"realized_pnl_total": 5,
|
||||
"premium_total": 1,
|
||||
"close_reason": "perp_tp",
|
||||
"closed_at": "2026-07-01 10:00:00",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": po,
|
||||
"leg_role": "perp",
|
||||
"symbol": "ETH/USDT:USDT",
|
||||
"status": "closed",
|
||||
},
|
||||
)
|
||||
oo = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "closed",
|
||||
"underlying": "ETH",
|
||||
"realized_pnl_total": -2,
|
||||
"premium_total": 0.02,
|
||||
"close_reason": "oo_expiry_loss",
|
||||
"closed_at": "2026-07-02 10:00:00",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": oo,
|
||||
"leg_role": "option_a",
|
||||
"inst_id": "ETH-USD_UM-260715-1900-C",
|
||||
"status": "closed",
|
||||
},
|
||||
)
|
||||
active = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"status": "active",
|
||||
"underlying": "BTC",
|
||||
"realized_pnl_total": None,
|
||||
},
|
||||
)
|
||||
s = stats_summary(conn)
|
||||
self.assertEqual(s["closed_count"], 2)
|
||||
self.assertEqual(s["by_type"]["perp_options"]["count"], 1)
|
||||
self.assertEqual(s["by_type"]["options_options"]["count"], 1)
|
||||
self.assertAlmostEqual(s["by_type"]["perp_options"]["win_rate"], 1.0)
|
||||
self.assertAlmostEqual(s["by_type"]["options_options"]["max_loss"], -2)
|
||||
|
||||
bad = delete_plan(conn, active)
|
||||
self.assertFalse(bad["ok"])
|
||||
ok = delete_plan(conn, oo)
|
||||
self.assertTrue(ok["ok"])
|
||||
s2 = stats_summary(conn)
|
||||
self.assertEqual(s2["closed_count"], 1)
|
||||
|
||||
def test_contract_summary(self):
|
||||
s = legs_contract_summary(
|
||||
[
|
||||
{"leg_role": "perp", "symbol": "ETH/USDT:USDT"},
|
||||
{"leg_role": "option_hedge", "inst_id": "ETH-USD_UM-260715-1790-P"},
|
||||
]
|
||||
)
|
||||
self.assertIn("永续 ETH/USDT:USDT", s)
|
||||
self.assertIn("ETH-USD_UM-260715-1790-P", s)
|
||||
|
||||
def test_active_options_targets_are_read_only_plan_targets(self):
|
||||
conn = _mem()
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "active",
|
||||
"underlying": "ETH",
|
||||
"target_price_up": 1950,
|
||||
"target_price_down": 1800,
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_a",
|
||||
"inst_id": "ETH-USD_UM-260719-1890-C",
|
||||
"opt_type": "C",
|
||||
"status": "open",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_b",
|
||||
"inst_id": "ETH-USD_UM-260719-1850-P",
|
||||
"opt_type": "P",
|
||||
"status": "open",
|
||||
},
|
||||
)
|
||||
|
||||
targets = active_options_targets_by_inst(conn)
|
||||
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["target_index"], 1950)
|
||||
self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800)
|
||||
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""对冲计划微信文案与到期结算."""
|
||||
import sqlite3
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from lib.exchange.okx_options_lib import expiry_ms_from_inst_id
|
||||
from lib.hedge_plan.hedge_plan_db import get_plan, init_hedge_plan_tables, insert_leg, insert_plan
|
||||
from lib.hedge_plan.hedge_plan_monitor_lib import _tick_oo_expiry, _settle_orphaned_after_tp
|
||||
from lib.hedge_plan.hedge_plan_notify_lib import (
|
||||
build_hedge_end_message,
|
||||
build_hedge_start_message,
|
||||
notify_plan_end,
|
||||
notify_plan_start,
|
||||
)
|
||||
from lib.hedge_plan.hedge_plan_settle_lib import leg_is_expired, settle_option_leg_at_spot
|
||||
|
||||
|
||||
def _mem_db():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_hedge_plan_tables(conn)
|
||||
return conn
|
||||
|
||||
|
||||
class TestHedgeNotify(unittest.TestCase):
|
||||
def test_start_end_copy(self):
|
||||
plan = {
|
||||
"id": 7,
|
||||
"plan_type": "perp_options",
|
||||
"underlying": "ETH",
|
||||
"direction": "long",
|
||||
"entry_mark": 1800,
|
||||
"tp": 1900,
|
||||
"sl": 1700,
|
||||
"perp_size": 2,
|
||||
"leverage": 10,
|
||||
"premium_total": 1.5,
|
||||
"close_reason": "perp_tp",
|
||||
"realized_pnl_total": 12.3,
|
||||
"realized_pnl_perp": 15,
|
||||
"realized_pnl_options": -1.5,
|
||||
"opened_at": "2026-07-01 10:00:00",
|
||||
"closed_at": "2026-07-01 12:00:00",
|
||||
}
|
||||
s = build_hedge_start_message(plan)
|
||||
self.assertIn("启动 #7", s)
|
||||
self.assertIn("永期", s)
|
||||
e = build_hedge_end_message(plan)
|
||||
self.assertIn("结束 #7", e)
|
||||
self.assertIn("止盈", e)
|
||||
|
||||
def test_idempotent_flags(self):
|
||||
conn = _mem_db()
|
||||
sent = []
|
||||
cfg = {"send_wechat": lambda c: sent.append(c)}
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "active",
|
||||
"underlying": "ETH",
|
||||
"target_price": 2000,
|
||||
"premium_total": 2.0,
|
||||
"opened_at": "t0",
|
||||
},
|
||||
)
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertTrue(notify_plan_start(cfg, conn, plan, []))
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertEqual(int(plan["wechat_start_sent"]), 1)
|
||||
self.assertFalse(notify_plan_start(cfg, conn, plan, []))
|
||||
self.assertEqual(len(sent), 1)
|
||||
|
||||
plan["status"] = "closed"
|
||||
plan["close_reason"] = "oo_expiry_loss"
|
||||
plan["realized_pnl_total"] = -2
|
||||
plan["closed_at"] = "t1"
|
||||
self.assertTrue(notify_plan_end(cfg, conn, plan))
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertEqual(int(plan["wechat_end_sent"]), 1)
|
||||
self.assertFalse(notify_plan_end(cfg, conn, plan))
|
||||
self.assertEqual(len(sent), 2)
|
||||
|
||||
|
||||
class TestHedgeSettle(unittest.TestCase):
|
||||
def test_put_expiry_otm(self):
|
||||
pnl = settle_option_leg_at_spot(
|
||||
{"opt_type": "P", "strike": 1700, "size": 2, "premium": 1.2, "ct_mult": 0.01},
|
||||
spot=1800,
|
||||
)
|
||||
self.assertAlmostEqual(pnl, -1.2)
|
||||
|
||||
def test_call_expiry_itm(self):
|
||||
# intrinsic (1900-1800)*2*0.01 - 0.5 = 2 - 0.5
|
||||
pnl = settle_option_leg_at_spot(
|
||||
{"opt_type": "C", "strike": 1800, "size": 2, "premium": 0.5, "ct_mult": 0.01},
|
||||
spot=1900,
|
||||
)
|
||||
self.assertAlmostEqual(pnl, 1.5)
|
||||
|
||||
def test_leg_expired_from_inst(self):
|
||||
# past date in inst_id
|
||||
past = datetime.now(timezone.utc) - timedelta(days=3)
|
||||
yy = past.year % 100
|
||||
tag = f"ETH-USD-{yy:02d}{past.month:02d}{past.day:02d}-1800-P"
|
||||
self.assertTrue(leg_is_expired({"inst_id": tag}))
|
||||
future = datetime.now(timezone.utc) + timedelta(days=10)
|
||||
tag2 = f"ETH-USD-{future.year % 100:02d}{future.month:02d}{future.day:02d}-1800-P"
|
||||
self.assertFalse(leg_is_expired({"inst_id": tag2}))
|
||||
self.assertIsNotNone(expiry_ms_from_inst_id(tag))
|
||||
|
||||
|
||||
class TestHedgeMonitorExpiry(unittest.TestCase):
|
||||
def test_oo_expiry_loss_closes_plan(self):
|
||||
conn = _mem_db()
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
tag = f"ETH-USD-{past.year % 100:02d}{past.month:02d}{past.day:02d}-1800-P"
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "active",
|
||||
"underlying": "ETH",
|
||||
"target_price": 2000,
|
||||
"premium_total": 2.0,
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_a",
|
||||
"inst_id": tag,
|
||||
"opt_type": "P",
|
||||
"strike": 1800,
|
||||
"size": 1,
|
||||
"premium": 1.0,
|
||||
"status": "open",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_b",
|
||||
"inst_id": tag.replace("-P", "-C").replace("1800", "1900"),
|
||||
"opt_type": "C",
|
||||
"strike": 1900,
|
||||
"size": 1,
|
||||
"premium": 1.0,
|
||||
"status": "open",
|
||||
},
|
||||
)
|
||||
sent = []
|
||||
cfg = {
|
||||
"send_wechat": lambda c: sent.append(c),
|
||||
"fetch_index_price": lambda ex, u: 1850.0,
|
||||
"exchange_options": object(),
|
||||
}
|
||||
plan = get_plan(conn, pid)
|
||||
legs = [
|
||||
dict(r)
|
||||
for r in conn.execute("SELECT * FROM hedge_plan_legs WHERE plan_id=?", (pid,)).fetchall()
|
||||
]
|
||||
r = _tick_oo_expiry(cfg, conn, plan, legs)
|
||||
self.assertIsNotNone(r)
|
||||
self.assertEqual(r["close_reason"], "oo_expiry_loss")
|
||||
plan2 = get_plan(conn, pid)
|
||||
self.assertEqual(plan2["status"], "closed")
|
||||
self.assertLessEqual(float(plan2["realized_pnl_total"]), 0)
|
||||
self.assertTrue(any("结束" in x for x in sent))
|
||||
|
||||
def test_orphaned_option_does_not_rewrite_plan_total(self):
|
||||
conn = _mem_db()
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
tag = f"ETH-USD-{past.year % 100:02d}{past.month:02d}{past.day:02d}-1700-P"
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"status": "closed",
|
||||
"underlying": "ETH",
|
||||
"direction": "long",
|
||||
"close_reason": "perp_tp",
|
||||
"realized_pnl_total": 10.0,
|
||||
"realized_pnl_options": -1.0,
|
||||
"stats_bucket": "tp",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_hedge",
|
||||
"inst_id": tag,
|
||||
"opt_type": "P",
|
||||
"strike": 1700,
|
||||
"size": 1,
|
||||
"premium": 1.0,
|
||||
"status": "hold_to_expiry",
|
||||
"close_reason": "orphaned_after_tp",
|
||||
},
|
||||
)
|
||||
cfg = {
|
||||
"fetch_index_price": lambda ex, u: 1800.0,
|
||||
"exchange_options": object(),
|
||||
}
|
||||
acted = _settle_orphaned_after_tp(cfg, conn)
|
||||
self.assertEqual(len(acted), 1)
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertAlmostEqual(float(plan["realized_pnl_total"]), 10.0)
|
||||
leg = conn.execute("SELECT * FROM hedge_plan_legs WHERE plan_id=?", (pid,)).fetchone()
|
||||
self.assertEqual(leg["status"], "closed")
|
||||
self.assertEqual(leg["close_reason"], "expiry")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,215 @@
|
||||
"""对冲计划下单路径校验(dry_run + 门禁)."""
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from lib.hedge_plan.hedge_plan_calc_lib import gate_status
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import (
|
||||
build_oo_path_plan,
|
||||
build_po_path_plan,
|
||||
execute_options_options_start,
|
||||
execute_perp_options_start,
|
||||
validate_start_body,
|
||||
)
|
||||
|
||||
|
||||
class TestHedgePlanOrderPath(unittest.TestCase):
|
||||
def test_po_path_options_first(self):
|
||||
body = {
|
||||
"opt_inst_id": "ETH-USD-260731-1800-P",
|
||||
"sheets": 2,
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
"direction": "long",
|
||||
"contracts": 4.5,
|
||||
"tp": 1900,
|
||||
"sl": 1700,
|
||||
}
|
||||
path = build_po_path_plan(body)
|
||||
self.assertEqual(path[0]["step"], "options_buy_limit")
|
||||
self.assertEqual(path[0]["account"], "options")
|
||||
self.assertEqual(path[1]["step"], "perp_market_open")
|
||||
self.assertEqual(path[1]["account"], "swap")
|
||||
self.assertTrue(path[1]["attach_tpsl"])
|
||||
|
||||
def test_oo_path_two_option_buys(self):
|
||||
body = {
|
||||
"leg_a": {"inst_id": "ETH-USD-260731-1800-C", "sheets": 1},
|
||||
"leg_b": {"inst_id": "ETH-USD-260731-1700-P", "sheets": 3},
|
||||
}
|
||||
path = build_oo_path_plan(body)
|
||||
self.assertEqual(len(path), 2)
|
||||
self.assertEqual(path[0]["leg"], "a")
|
||||
self.assertEqual(path[1]["sheets"], 3)
|
||||
|
||||
def test_validate_body(self):
|
||||
self.assertIsNotNone(validate_start_body("perp_options", {}))
|
||||
ok = validate_start_body(
|
||||
"perp_options",
|
||||
{
|
||||
"direction": "long",
|
||||
"entry": 1800,
|
||||
"tp": 1900,
|
||||
"sl": 1700,
|
||||
"contracts": 1,
|
||||
"opt_inst_id": "X",
|
||||
"sheets": 1,
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
},
|
||||
)
|
||||
self.assertIsNone(ok)
|
||||
|
||||
def test_gate_can_start_when_live(self):
|
||||
g = gate_status(
|
||||
hedge_enabled=True,
|
||||
sizing_mode="full_margin",
|
||||
plan_type="perp_options",
|
||||
options_enabled=True,
|
||||
live_order=True,
|
||||
live_trading=True,
|
||||
active_count=0,
|
||||
max_active=1,
|
||||
)
|
||||
self.assertTrue(g["can_start"])
|
||||
self.assertEqual(g["reasons"], [])
|
||||
|
||||
def test_gate_oo_without_live_trading(self):
|
||||
g = gate_status(
|
||||
hedge_enabled=True,
|
||||
sizing_mode="risk",
|
||||
plan_type="options_options",
|
||||
options_enabled=True,
|
||||
live_order=True,
|
||||
live_trading=False,
|
||||
active_count=0,
|
||||
max_active=1,
|
||||
)
|
||||
self.assertTrue(g["can_start"])
|
||||
|
||||
def test_dry_run_po_calls_quote_not_place(self):
|
||||
quote = MagicMock(
|
||||
return_value={
|
||||
"ok": True,
|
||||
"ask": 12.5,
|
||||
"ask_sz": 10,
|
||||
"can_open": True,
|
||||
"ct_mult": 0.01,
|
||||
"tick_sz": "0.1",
|
||||
"strike": 1800,
|
||||
"exp_time": 1,
|
||||
"meta": {"optType": "P"},
|
||||
}
|
||||
)
|
||||
place_opt = MagicMock()
|
||||
place_perp = MagicMock()
|
||||
cfg = {
|
||||
"exchange_options": object(),
|
||||
"exchange": object(),
|
||||
"quote_option_contract": quote,
|
||||
"place_option_limit_order": place_opt,
|
||||
"place_exchange_order": place_perp,
|
||||
"td_mode_for_option_buy": lambda x: "isolated",
|
||||
"amount_to_precision": lambda s, a: a,
|
||||
"ensure_okx_live_ready": lambda: (True, ""),
|
||||
}
|
||||
body = {
|
||||
"direction": "long",
|
||||
"entry": 1800,
|
||||
"tp": 1900,
|
||||
"sl": 1700,
|
||||
"contracts": 4.5,
|
||||
"opt_inst_id": "ETH-USD-260731-1800-P",
|
||||
"sheets": 2,
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
"leverage": 10,
|
||||
"underlying": "ETH",
|
||||
}
|
||||
out = execute_perp_options_start(cfg, body, dry_run=True)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertTrue(out["dry_run"])
|
||||
place_opt.assert_not_called()
|
||||
place_perp.assert_not_called()
|
||||
quote.assert_called()
|
||||
self.assertEqual(out["path"][0]["account"], "options")
|
||||
self.assertEqual(out["path"][1]["account"], "swap")
|
||||
|
||||
def test_dry_run_oo(self):
|
||||
quote = MagicMock(
|
||||
return_value={
|
||||
"ok": True,
|
||||
"ask": 10,
|
||||
"ask_sz": 5,
|
||||
"can_open": True,
|
||||
"ct_mult": 0.01,
|
||||
"tick_sz": "0.1",
|
||||
"strike": 1800,
|
||||
"meta": {"optType": "C"},
|
||||
}
|
||||
)
|
||||
cfg = {
|
||||
"exchange_options": object(),
|
||||
"quote_option_contract": quote,
|
||||
"place_option_limit_order": MagicMock(),
|
||||
"td_mode_for_option_buy": lambda x: "isolated",
|
||||
}
|
||||
body = {
|
||||
"target_price": 1900,
|
||||
"target_price_up": 1950,
|
||||
"target_price_down": 1750,
|
||||
"leg_a": {"inst_id": "A", "sheets": 1},
|
||||
"leg_b": {"inst_id": "B", "sheets": 1},
|
||||
}
|
||||
out = execute_options_options_start(cfg, body, dry_run=True)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(len(out["results"]), 2)
|
||||
|
||||
def test_buy_rejects_without_ask_depth(self):
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import _buy_option
|
||||
|
||||
quote = MagicMock(
|
||||
return_value={
|
||||
"ok": True,
|
||||
"ask": None,
|
||||
"ask_sz": None,
|
||||
"mark": 11.2,
|
||||
"ref_ask": 11.2,
|
||||
"can_open": False,
|
||||
"open_block_msg": "暂无卖一深度,无法买入",
|
||||
"ct_mult": 0.01,
|
||||
}
|
||||
)
|
||||
cfg = {
|
||||
"exchange_options": object(),
|
||||
"quote_option_contract": quote,
|
||||
"place_option_limit_order": MagicMock(),
|
||||
}
|
||||
out = _buy_option(cfg, inst_id="ETH-USD_UM-260717-1900-C", sheets=1, dry_run=True)
|
||||
self.assertFalse(out["ok"])
|
||||
self.assertIn("卖一", out["msg"])
|
||||
cfg["place_option_limit_order"].assert_not_called()
|
||||
|
||||
def test_buy_caps_sheets_to_ask_depth(self):
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import _buy_option
|
||||
|
||||
quote = MagicMock(
|
||||
return_value={
|
||||
"ok": True,
|
||||
"ask": 10,
|
||||
"ask_sz": 2,
|
||||
"can_open": True,
|
||||
"ct_mult": 0.01,
|
||||
"tick_sz": "0.1",
|
||||
"meta": {"optType": "C"},
|
||||
}
|
||||
)
|
||||
cfg = {
|
||||
"exchange_options": object(),
|
||||
"quote_option_contract": quote,
|
||||
"place_option_limit_order": MagicMock(),
|
||||
"td_mode_for_option_buy": lambda x: "isolated",
|
||||
}
|
||||
out = _buy_option(cfg, inst_id="X", sheets=9, dry_run=True)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["sheets"], 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""history_window_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib.common.history_window_lib import (
|
||||
PRESET_ALL,
|
||||
PRESET_DEFAULT,
|
||||
PRESET_UTC_LAST3M,
|
||||
PRESET_UTC_THIS_MONTH,
|
||||
resolve_window,
|
||||
)
|
||||
|
||||
|
||||
class TestHistoryWindowLib(unittest.TestCase):
|
||||
def test_default_is_this_month(self):
|
||||
self.assertEqual(PRESET_DEFAULT, PRESET_UTC_THIS_MONTH)
|
||||
|
||||
def test_resolve_this_month(self):
|
||||
now = datetime(2026, 7, 8, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("lib.common.history_window_lib.utc_now", return_value=now):
|
||||
win = resolve_window({"win_preset": PRESET_UTC_THIS_MONTH})
|
||||
self.assertEqual(win["preset"], PRESET_UTC_THIS_MONTH)
|
||||
self.assertIn("本月", win["label"])
|
||||
self.assertEqual(win["start_utc"].month, 7)
|
||||
self.assertEqual(win["start_utc"].day, 1)
|
||||
|
||||
def test_resolve_last3m_and_all(self):
|
||||
now = datetime(2026, 7, 8, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("lib.common.history_window_lib.utc_now", return_value=now):
|
||||
w3 = resolve_window({"win_preset": PRESET_UTC_LAST3M})
|
||||
wall = resolve_window({"win_preset": PRESET_ALL})
|
||||
self.assertEqual(w3["label"], "近3月")
|
||||
self.assertEqual(wall["label"], "全部")
|
||||
self.assertLess(wall["start_utc"].year, 2020)
|
||||
|
||||
def test_empty_preset_uses_default_month(self):
|
||||
now = datetime(2026, 7, 8, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("lib.common.history_window_lib.utc_now", return_value=now):
|
||||
win = resolve_window({})
|
||||
self.assertEqual(win["preset"], PRESET_UTC_THIS_MONTH)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""子代理持仓:三所开仓价字段统一解析."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
|
||||
|
||||
from agent import _position_entry_price # noqa: E402
|
||||
|
||||
|
||||
class TestHubAgentEntryPrice(unittest.TestCase):
|
||||
def test_binance_entry_price(self):
|
||||
px = _position_entry_price({"entryPrice": 65851.6, "info": {}})
|
||||
self.assertAlmostEqual(px, 65851.6)
|
||||
|
||||
def test_okx_avg_px(self):
|
||||
px = _position_entry_price({"info": {"avgPx": "72.731"}})
|
||||
self.assertAlmostEqual(px, 72.731)
|
||||
|
||||
def test_gate_info_entry(self):
|
||||
px = _position_entry_price({"info": {"entry_price": "0.2232"}})
|
||||
self.assertAlmostEqual(px, 0.2232)
|
||||
|
||||
def test_missing_returns_none(self):
|
||||
self.assertIsNone(_position_entry_price({"info": {}}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""子代理持仓:三所标记价字段统一解析."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
|
||||
|
||||
from agent import _position_mark_price, _ticker_mark_price # noqa: E402
|
||||
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from lib.hub.hub_position_metrics import ( # noqa: E402
|
||||
enrich_ccxt_position_metrics_out,
|
||||
estimate_linear_swap_upnl_usdt,
|
||||
parse_position_unrealized_pnl,
|
||||
resolve_position_display_upnl,
|
||||
)
|
||||
|
||||
|
||||
class TestHubAgentMarkPrice(unittest.TestCase):
|
||||
def test_binance_mark_price(self):
|
||||
px = _position_mark_price({"markPrice": 65880.1, "info": {}})
|
||||
self.assertAlmostEqual(px, 65880.1)
|
||||
|
||||
def test_okx_mark_px(self):
|
||||
px = _position_mark_price({"info": {"markPx": "72.85"}})
|
||||
self.assertAlmostEqual(px, 72.85)
|
||||
|
||||
def test_gate_info_mark(self):
|
||||
px = _position_mark_price({"info": {"mark_price": "0.2241"}})
|
||||
self.assertAlmostEqual(px, 0.2241)
|
||||
|
||||
def test_missing_returns_none(self):
|
||||
self.assertIsNone(_position_mark_price({"info": {}}))
|
||||
|
||||
def test_infer_from_notional_and_contracts(self):
|
||||
p = {"notional": 1000, "contracts": 10, "info": {}}
|
||||
px = _position_mark_price(p)
|
||||
self.assertAlmostEqual(px, 100.0)
|
||||
|
||||
def test_ticker_fallback(self):
|
||||
class _Ex:
|
||||
def fetch_ticker(self, sym):
|
||||
return {"mark": 99.5, "info": {}}
|
||||
|
||||
self.assertAlmostEqual(_ticker_mark_price(_Ex(), "BTC/USDT:USDT"), 99.5)
|
||||
|
||||
def test_gate_unrealised_pnl_in_info(self):
|
||||
pnl = parse_position_unrealized_pnl(
|
||||
{"info": {"unrealised_pnl": "6.81"}, "unrealizedPnl": None}
|
||||
)
|
||||
self.assertAlmostEqual(pnl, 6.81)
|
||||
|
||||
def test_okx_upl_signed(self):
|
||||
pnl = parse_position_unrealized_pnl(
|
||||
{"info": {"upl": "-2.15"}, "unrealizedPnl": None}
|
||||
)
|
||||
self.assertAlmostEqual(pnl, -2.15)
|
||||
|
||||
def test_enrich_aligns_short_gate_metrics(self):
|
||||
pos = {
|
||||
"side": "short",
|
||||
"contracts": 11,
|
||||
"entryPrice": 73.187,
|
||||
"markPrice": 66.038,
|
||||
"info": {"unrealised_pnl": "7.86"},
|
||||
}
|
||||
out = {"unrealized_pnl": 7.86, "mark_price": 66.038}
|
||||
enrich_ccxt_position_metrics_out(pos, out, contract_size=1.0, funds_decimals=2)
|
||||
self.assertGreater(out["unrealized_pnl"], 70.0)
|
||||
|
||||
def test_estimate_short_hype_contract_size(self):
|
||||
upnl = estimate_linear_swap_upnl_usdt(
|
||||
"short", 73.187, 66.038, 11, 0.1
|
||||
)
|
||||
self.assertAlmostEqual(upnl, 7.86, places=1)
|
||||
|
||||
def test_resolve_prefers_computed_when_exchange_off(self):
|
||||
shown = resolve_position_display_upnl(
|
||||
"short", 73.187, 66.038, 11, 1.0, 7.86
|
||||
)
|
||||
self.assertAlmostEqual(shown, 78.64, places=1)
|
||||
|
||||
def test_resolve_keeps_exchange_when_aligned(self):
|
||||
shown = resolve_position_display_upnl(
|
||||
"short", 73.187, 66.038, 11, 0.1, 7.86
|
||||
)
|
||||
self.assertAlmostEqual(shown, 7.86, places=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""hub_backup_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.hub import hub_backup_lib as backup
|
||||
|
||||
|
||||
class HubBackupLibTest(unittest.TestCase):
|
||||
def test_normalize_backup_settings(self):
|
||||
cfg = backup.normalize_backup_settings({"auto_hour": 99, "retention_days": 0})
|
||||
self.assertEqual(cfg["auto_hour"], 23)
|
||||
self.assertEqual(cfg["retention_days"], 1)
|
||||
|
||||
def test_safe_archive_name(self):
|
||||
self.assertTrue(backup._safe_archive_name("backup_2026-07-02_163045.zip"))
|
||||
self.assertFalse(backup._safe_archive_name("../evil.zip"))
|
||||
|
||||
def test_run_and_restore_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp) / "portal"
|
||||
root.mkdir(parents=True)
|
||||
settings = {
|
||||
"backup": {
|
||||
"auto_enabled": False,
|
||||
"backup_root": str(root),
|
||||
"include_env": False,
|
||||
"include_exchange_images": False,
|
||||
}
|
||||
}
|
||||
hub_settings = backup.HUB_DIR / "hub_settings.json"
|
||||
had = hub_settings.is_file()
|
||||
old = hub_settings.read_text(encoding="utf-8") if had else None
|
||||
try:
|
||||
if not had:
|
||||
hub_settings.write_text('{"version":1,"exchanges":[]}', encoding="utf-8")
|
||||
result = backup.run_backup(trigger="manual", settings=settings)
|
||||
self.assertTrue(result.get("ok"), result)
|
||||
archive = Path(result["path"])
|
||||
self.assertTrue(archive.is_file())
|
||||
with zipfile.ZipFile(archive, "r") as zf:
|
||||
names = zf.namelist()
|
||||
self.assertIn("manifest.json", names)
|
||||
manifest = json.loads(
|
||||
zipfile.ZipFile(archive, "r").read("manifest.json").decode("utf-8")
|
||||
)
|
||||
self.assertEqual(manifest.get("trigger"), "manual")
|
||||
finally:
|
||||
if had and old is not None:
|
||||
hub_settings.write_text(old, encoding="utf-8")
|
||||
elif not had and hub_settings.is_file():
|
||||
hub_settings.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""后台 board 缓存:版本递增与快照."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
|
||||
|
||||
from hub_board_cache import MonitorBoardStore # noqa: E402
|
||||
|
||||
|
||||
class TestHubBoardStore(unittest.TestCase):
|
||||
def test_snapshot_and_version(self) -> None:
|
||||
store = MonitorBoardStore()
|
||||
store.version = 2
|
||||
store.payload = {"ok": True, "rows": [{"id": "0"}], "updated_at": "2026-01-01T00:00:00"}
|
||||
snap = store.snapshot_dict()
|
||||
self.assertEqual(snap["board_version"], 2)
|
||||
self.assertEqual(len(snap["rows"]), 1)
|
||||
|
||||
def test_aggregate_increments_version(self) -> None:
|
||||
async def run() -> None:
|
||||
store = MonitorBoardStore()
|
||||
n = 0
|
||||
|
||||
async def build():
|
||||
nonlocal n
|
||||
n += 1
|
||||
return {"ok": True, "rows": [{"n": n}], "updated_at": "t"}
|
||||
|
||||
await store.start(build)
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertGreaterEqual(store.version, 1)
|
||||
await store.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""hub_calculator_lib 测算逻辑."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib.hub.hub_calculator_lib import (
|
||||
calc_initial_roll_qty,
|
||||
calc_roll_calculator,
|
||||
calc_trend_calculator,
|
||||
solve_add_amount_for_total_risk,
|
||||
)
|
||||
|
||||
MOCK_MARKET = {
|
||||
"exchange_id": "0",
|
||||
"exchange_key": "binance",
|
||||
"exchange_name": "币安 · crypto_monitor_binance",
|
||||
"exchange_label": "币安 · crypto_monitor_binance",
|
||||
"base": "ETH",
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
"display_symbol": "ETH/USDT",
|
||||
"contract_size": 1.0,
|
||||
"price_tick": 0.01,
|
||||
"price_decimals": 2,
|
||||
"amount_decimals": 3,
|
||||
"min_amount": 0.001,
|
||||
}
|
||||
|
||||
|
||||
def _mock_resolve(_exchange="binance", _base="ETH"):
|
||||
return MOCK_MARKET, lambda amount: round(float(amount), 3), None
|
||||
|
||||
|
||||
class HubCalculatorLibTests(unittest.TestCase):
|
||||
@patch("lib.hub.hub_calculator_lib._resolve_market", return_value=_mock_resolve())
|
||||
def test_trend_calculator_long_basic(self, _mock):
|
||||
data, err = calc_trend_calculator(
|
||||
direction="long",
|
||||
capital_usdt=1000,
|
||||
risk_percent=5,
|
||||
leverage=5,
|
||||
entry_price=100,
|
||||
stop_loss=95,
|
||||
add_upper=110,
|
||||
take_profit=120,
|
||||
dca_legs=3,
|
||||
exchange_id="0",
|
||||
base="ETH",
|
||||
)
|
||||
self.assertIsNone(err)
|
||||
self.assertIsNotNone(data)
|
||||
assert data is not None
|
||||
self.assertEqual(data["risk_budget_u"], 50.0)
|
||||
self.assertGreaterEqual(len(data["rows"]), 2)
|
||||
self.assertEqual(data["rows"][0]["label"], "首仓")
|
||||
self.assertEqual(data["market"]["display_symbol"], "ETH/USDT")
|
||||
|
||||
@patch("lib.hub.hub_calculator_lib._resolve_market", return_value=_mock_resolve())
|
||||
def test_trend_calculator_short_rejects_bad_bounds(self, _mock):
|
||||
data, err = calc_trend_calculator(
|
||||
direction="short",
|
||||
capital_usdt=1000,
|
||||
risk_percent=5,
|
||||
leverage=5,
|
||||
entry_price=100,
|
||||
stop_loss=90,
|
||||
add_upper=110,
|
||||
take_profit=80,
|
||||
dca_legs=3,
|
||||
)
|
||||
self.assertIsNone(data)
|
||||
self.assertIsNotNone(err)
|
||||
|
||||
@patch("lib.hub.hub_calculator_lib._resolve_market", return_value=_mock_resolve())
|
||||
def test_roll_calculator_first_leg_auto(self, _mock):
|
||||
data, err = calc_roll_calculator(
|
||||
direction="long",
|
||||
capital_usdt=1000,
|
||||
risk_percent=5,
|
||||
entry_price=100,
|
||||
stop_loss=95,
|
||||
take_profit=120,
|
||||
add_legs=[],
|
||||
legs_done=0,
|
||||
)
|
||||
self.assertIsNone(err)
|
||||
self.assertIsNotNone(data)
|
||||
assert data is not None
|
||||
self.assertEqual(data["first_contracts"], 10.0)
|
||||
self.assertEqual(len(data["rows"]), 1)
|
||||
self.assertEqual(data["rows"][0]["loss_at_sl_u"], 50.0)
|
||||
# 毛利 200 − 双边费 (1000+1200)*0.0005=1.1 → 198.9
|
||||
self.assertEqual(data["rows"][0]["profit_at_tp_u"], 198.9)
|
||||
|
||||
@patch("lib.hub.hub_calculator_lib._resolve_market", return_value=_mock_resolve())
|
||||
def test_roll_calculator_chain_two_legs(self, _mock):
|
||||
data, err = calc_roll_calculator(
|
||||
direction="long",
|
||||
capital_usdt=1000,
|
||||
risk_percent=5,
|
||||
entry_price=100,
|
||||
stop_loss=95,
|
||||
take_profit=120,
|
||||
add_legs=[
|
||||
{"add_price": 105, "new_stop_loss": 98},
|
||||
{"add_price": 108, "new_stop_loss": 101},
|
||||
],
|
||||
legs_done=0,
|
||||
)
|
||||
self.assertIsNone(err)
|
||||
self.assertIsNotNone(data)
|
||||
assert data is not None
|
||||
self.assertEqual(len(data["rows"]), 3)
|
||||
self.assertEqual(data["rows"][1]["label"], "滚仓1")
|
||||
self.assertGreater(float(data["final_contracts"]), float(data["first_contracts"]))
|
||||
|
||||
@patch("lib.hub.hub_calculator_lib._resolve_market", return_value=_mock_resolve())
|
||||
def test_roll_calculator_rejects_too_many_legs(self, _mock):
|
||||
data, err = calc_roll_calculator(
|
||||
direction="long",
|
||||
capital_usdt=1000,
|
||||
risk_percent=5,
|
||||
entry_price=100,
|
||||
stop_loss=95,
|
||||
take_profit=120,
|
||||
add_legs=[
|
||||
{"add_price": 105, "new_stop_loss": 98},
|
||||
{"add_price": 108, "new_stop_loss": 101},
|
||||
{"add_price": 110, "new_stop_loss": 103},
|
||||
{"add_price": 112, "new_stop_loss": 105},
|
||||
],
|
||||
legs_done=0,
|
||||
)
|
||||
self.assertIsNone(data)
|
||||
self.assertIsNotNone(err)
|
||||
|
||||
def test_initial_roll_qty(self):
|
||||
qty, err = calc_initial_roll_qty("long", 100, 95, 50, 1.0)
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(qty, 10.0)
|
||||
|
||||
def test_initial_roll_qty_with_contract_size(self):
|
||||
qty, err = calc_initial_roll_qty("long", 100, 95, 50, 0.1)
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(qty, 100.0)
|
||||
|
||||
def test_solve_add_with_contract_size(self):
|
||||
q2, err = solve_add_amount_for_total_risk(
|
||||
"long",
|
||||
qty_existing=10.0,
|
||||
entry_existing=100.0,
|
||||
add_price=105.0,
|
||||
new_stop=98.0,
|
||||
risk_budget_usdt=50.0,
|
||||
contract_size=1.0,
|
||||
)
|
||||
self.assertIsNone(err)
|
||||
self.assertIsNotNone(q2)
|
||||
assert q2 is not None
|
||||
self.assertGreater(q2, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""hub_calculator_market_lib 合约解析."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib.hub.hub_calculator_market_lib import (
|
||||
amount_decimals_from_exchange,
|
||||
find_exchange,
|
||||
get_calculator_market,
|
||||
list_calculator_exchanges,
|
||||
make_amount_precise_fn_from_market,
|
||||
normalize_base_symbol,
|
||||
resolve_usdt_perp_symbol,
|
||||
)
|
||||
|
||||
|
||||
class FakeExchange:
|
||||
def __init__(self, markets: dict):
|
||||
self.markets = markets
|
||||
|
||||
def market(self, symbol: str):
|
||||
return self.markets[symbol]
|
||||
|
||||
def amount_to_precision(self, symbol: str, amount: float) -> str:
|
||||
return f"{float(amount):.3f}"
|
||||
|
||||
|
||||
class HubCalculatorMarketLibTests(unittest.TestCase):
|
||||
def test_normalize_base_symbol(self):
|
||||
self.assertEqual(normalize_base_symbol("eth"), "ETH")
|
||||
self.assertEqual(normalize_base_symbol("ETH/USDT:USDT"), "ETH")
|
||||
self.assertEqual(normalize_base_symbol("ETHUSDT"), "ETH")
|
||||
|
||||
def test_resolve_usdt_perp_symbol(self):
|
||||
ex = FakeExchange(
|
||||
{
|
||||
"ETH/USDT:USDT": {
|
||||
"base": "ETH",
|
||||
"quote": "USDT",
|
||||
"swap": True,
|
||||
"active": True,
|
||||
"contractSize": 1.0,
|
||||
"limits": {"amount": {"min": 0.001}},
|
||||
"precision": {"price": 2, "amount": 3},
|
||||
}
|
||||
}
|
||||
)
|
||||
sym, err = resolve_usdt_perp_symbol(ex, "ETH")
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(sym, "ETH/USDT:USDT")
|
||||
|
||||
def test_amount_decimals_from_exchange(self):
|
||||
ex = FakeExchange({})
|
||||
self.assertEqual(amount_decimals_from_exchange(ex, "ETH/USDT:USDT"), 3)
|
||||
|
||||
def test_make_amount_precise_fn_from_market(self):
|
||||
fn = make_amount_precise_fn_from_market({"amount_decimals": 3, "min_amount": 0.001})
|
||||
self.assertEqual(fn(1.23456), 1.234)
|
||||
self.assertIsNone(fn(0.0001))
|
||||
|
||||
@patch.dict("os.environ", {"HUB_BRIDGE_TOKEN": "test-token"}, clear=False)
|
||||
def test_hub_headers_use_x_hub_token(self):
|
||||
from lib.hub.hub_calculator_market_lib import _hub_headers
|
||||
|
||||
self.assertEqual(_hub_headers(), {"X-Hub-Token": "test-token"})
|
||||
|
||||
@patch("lib.hub.hub_calculator_market_lib.fetch_instance_market_sync")
|
||||
def test_get_calculator_market_from_instance(self, fetch_mock):
|
||||
fetch_mock.return_value = {
|
||||
"ok": True,
|
||||
"base": "ETH",
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
"display_symbol": "ETH/USDT",
|
||||
"contract_size": 0.01,
|
||||
"price_tick": 0.01,
|
||||
"price_decimals": 2,
|
||||
"amount_decimals": 2,
|
||||
"min_amount": 0.01,
|
||||
}
|
||||
ex = {
|
||||
"id": "0",
|
||||
"key": "binance",
|
||||
"name": "币安 · crypto_monitor_binance",
|
||||
"enabled": True,
|
||||
"flask_url": "http://127.0.0.1:5001",
|
||||
}
|
||||
data, err = get_calculator_market("0", "ETH", ex=ex)
|
||||
self.assertIsNone(err)
|
||||
self.assertIsNotNone(data)
|
||||
assert data is not None
|
||||
self.assertEqual(data["exchange_id"], "0")
|
||||
self.assertEqual(data["exchange_name"], "币安 · crypto_monitor_binance")
|
||||
self.assertEqual(data["contract_size"], 0.01)
|
||||
|
||||
@patch("lib.hub.hub_calculator_market_lib.enabled_exchanges")
|
||||
def test_list_calculator_exchanges(self, enabled_mock):
|
||||
enabled_mock.return_value = [
|
||||
{"id": "0", "key": "binance", "name": "币安", "enabled": True},
|
||||
]
|
||||
rows = list_calculator_exchanges()
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["id"], "0")
|
||||
|
||||
def test_find_exchange_by_id(self):
|
||||
with patch(
|
||||
"lib.hub.hub_calculator_market_lib.load_settings",
|
||||
return_value={"exchanges": [{"id": "2", "key": "gate", "name": "Gate"}]},
|
||||
):
|
||||
self.assertEqual(find_exchange("2")["key"], "gate")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""行情区 chart 后台轮询订阅."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
|
||||
|
||||
from hub_chart_cache import ChartPollStore, series_key # noqa: E402
|
||||
|
||||
|
||||
class TestHubChartCache(unittest.TestCase):
|
||||
def test_series_key(self) -> None:
|
||||
self.assertEqual(series_key("Gate_X", "hype/usdt", "5m"), "gate_x|HYPE/USDT|5m")
|
||||
|
||||
def test_position_and_watch_keys(self) -> None:
|
||||
store = ChartPollStore()
|
||||
store.sync_positions_from_rows(
|
||||
[
|
||||
{
|
||||
"key": "okx_auto",
|
||||
"agent": {
|
||||
"ok": True,
|
||||
"positions": [{"symbol": "BTC/USDT"}, {"symbol": "ETH/USDT"}],
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
store.touch_watch("gate_trend", "HYPE/USDT", "5m")
|
||||
keys = store.active_series_keys()
|
||||
self.assertIn(series_key("okx_auto", "BTC/USDT", "5m"), keys)
|
||||
self.assertIn(series_key("gate_trend", "HYPE/USDT", "5m"), keys)
|
||||
|
||||
def test_note_series_result_pushes_tail_candles(self) -> None:
|
||||
store = ChartPollStore()
|
||||
key = series_key("binance", "BTC/USDT", "15m")
|
||||
candles = [
|
||||
{"time": 1_700_000_000 + i * 900, "open": 1, "high": 2, "low": 0.5, "close": 1.5, "volume": 10}
|
||||
for i in range(40)
|
||||
]
|
||||
store.note_series_result(
|
||||
"binance",
|
||||
"BTC/USDT",
|
||||
"15m",
|
||||
ok=True,
|
||||
fetched=3,
|
||||
candles=candles,
|
||||
price_tick=0.01,
|
||||
)
|
||||
ev = store.event_dict()
|
||||
self.assertIn("tails", ev)
|
||||
self.assertIn(key, ev["tails"])
|
||||
tail = ev["tails"][key]
|
||||
self.assertEqual(len(tail["candles"]), 30)
|
||||
self.assertEqual(tail["price_tick"], 0.01)
|
||||
self.assertGreater(tail["series_version"], 0)
|
||||
|
||||
def test_broadcast_clears_pending_tails(self) -> None:
|
||||
store = ChartPollStore()
|
||||
store.note_series_result(
|
||||
"gate",
|
||||
"ONDO/USDT",
|
||||
"5m",
|
||||
ok=True,
|
||||
candles=[{"time": 100, "open": 1, "high": 1, "low": 1, "close": 1, "volume": 1}],
|
||||
)
|
||||
store._broadcast()
|
||||
ev = store.event_dict()
|
||||
self.assertNotIn("tails", ev)
|
||||
|
||||
def test_poll_increments_version(self) -> None:
|
||||
async def run() -> None:
|
||||
store = ChartPollStore()
|
||||
n = 0
|
||||
|
||||
async def poll():
|
||||
nonlocal n
|
||||
n += 1
|
||||
store.touch_watch("binance", "BTC/USDT", "1d")
|
||||
return {"ok": True, "n": n}
|
||||
|
||||
await store.start(poll)
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertGreaterEqual(store.version, 1)
|
||||
await store.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""中控条件单列表:子代理与 Flask exchange_tpsl 合并去重."""
|
||||
|
||||
from manual_trading_hub.hub import _merge_conditional_orders_no_dup, _merge_flask_exchange_tpsl
|
||||
|
||||
|
||||
def test_merge_skips_duplicate_trigger_prices():
|
||||
existing = [
|
||||
{
|
||||
"id": "100",
|
||||
"label": "市价 买入 ·只减仓",
|
||||
"trigger_price": 57,
|
||||
"amount": 11,
|
||||
},
|
||||
{
|
||||
"id": "101",
|
||||
"label": "市价 买入 ·只减仓",
|
||||
"trigger_price": 71,
|
||||
"amount": 11,
|
||||
},
|
||||
]
|
||||
extra = [
|
||||
{"id": "", "label": "止损 57", "trigger_price": 57, "amount": 11},
|
||||
{"id": "", "label": "止盈 71", "trigger_price": 71, "amount": 11},
|
||||
]
|
||||
merged = _merge_conditional_orders_no_dup(existing, extra)
|
||||
assert len(merged) == 2
|
||||
assert {round(o["trigger_price"]) for o in merged} == {57, 71}
|
||||
|
||||
|
||||
def test_merge_uses_extra_when_existing_empty():
|
||||
extra = [{"id": "1", "label": "止损 57", "trigger_price": 57}]
|
||||
assert _merge_conditional_orders_no_dup([], extra) == extra
|
||||
|
||||
|
||||
def test_merge_flask_skips_duplicate_sl_when_agent_has_both():
|
||||
agent_row = {
|
||||
"agent": {
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "SOL/USDT:USDT",
|
||||
"side": "short",
|
||||
"conditional_orders": [
|
||||
{"label": "止盈 76", "trigger_price": 76, "algo_id": "1"},
|
||||
{"label": "止损 84.1", "trigger_price": 84.1, "algo_id": "1"},
|
||||
{"label": "止损", "trigger_price": 84.1},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
snap = {
|
||||
"order_prices": [
|
||||
{
|
||||
"symbol": "SOL/USDT:USDT",
|
||||
"side": "short",
|
||||
"exchange_tpsl": {
|
||||
"sl": {"trigger_price": 84.1, "order_id": "old"},
|
||||
"tp": {"trigger_price": 76, "order_id": "old"},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
_merge_flask_exchange_tpsl(agent_row, snap, None)
|
||||
cond = agent_row["agent"]["positions"][0]["conditional_orders"]
|
||||
sl_rows = [o for o in cond if "止损" in (o.get("label") or "")]
|
||||
assert len(sl_rows) == 1
|
||||
assert len(cond) == 2
|
||||
@@ -0,0 +1,101 @@
|
||||
import unittest
|
||||
|
||||
from lib.hub.hub_divergence_scan_lib import (
|
||||
analyze_ohlcv_bars,
|
||||
build_symbol_scan_row,
|
||||
compute_confluence,
|
||||
detect_latest_macd_divergence,
|
||||
filter_tab_items,
|
||||
)
|
||||
|
||||
|
||||
def _synthetic_bull_div_closes(n: int = 120) -> list[float]:
|
||||
"""价格双底 + MACD 抬高 → 底背离."""
|
||||
closes = [100.0] * n
|
||||
# 下跌
|
||||
for i in range(20, 40):
|
||||
closes[i] = 100 - (i - 20) * 0.8
|
||||
# 反弹
|
||||
for i in range(40, 55):
|
||||
closes[i] = closes[39] + (i - 40) * 0.5
|
||||
# 再跌略破前低
|
||||
for i in range(55, 75):
|
||||
closes[i] = closes[54] - (i - 55) * 0.35
|
||||
# 末尾企稳略抬
|
||||
for i in range(75, n):
|
||||
closes[i] = closes[74] + (i - 75) * 0.02
|
||||
return closes
|
||||
|
||||
|
||||
class TestHubDivergenceScanLib(unittest.TestCase):
|
||||
def test_compute_confluence_three_same(self):
|
||||
tf = {
|
||||
"4h": {"direction": "bull"},
|
||||
"1d": {"direction": "bull"},
|
||||
"1w": {"direction": "bull"},
|
||||
}
|
||||
c = compute_confluence(tf)
|
||||
self.assertEqual(c["confluence"], 3)
|
||||
self.assertEqual(c["confluence_css"], "c3")
|
||||
self.assertFalse(c["is_split"])
|
||||
|
||||
def test_compute_confluence_split(self):
|
||||
tf = {
|
||||
"4h": {"direction": "bull"},
|
||||
"1d": {"direction": "bear"},
|
||||
"1w": {"direction": None},
|
||||
}
|
||||
c = compute_confluence(tf)
|
||||
self.assertTrue(c["is_split"])
|
||||
self.assertEqual(c["confluence_kind"], "分歧")
|
||||
self.assertEqual(c["confluence_css"], "split")
|
||||
self.assertIn("4h底", c["split_detail"])
|
||||
|
||||
def test_filter_tab_items_only_matching_tf(self):
|
||||
items = [
|
||||
build_symbol_scan_row(
|
||||
rank=1,
|
||||
symbol="AAA/USDT",
|
||||
volume_label="1M",
|
||||
tf_hits={
|
||||
"4h": {"direction": "bull", "bars_ago": 2, "open_time_ms": 1},
|
||||
"1d": {"direction": None},
|
||||
"1w": {"direction": None},
|
||||
},
|
||||
),
|
||||
build_symbol_scan_row(
|
||||
rank=2,
|
||||
symbol="BBB/USDT",
|
||||
volume_label="2M",
|
||||
tf_hits={
|
||||
"4h": {"direction": None},
|
||||
"1d": {"direction": "bear", "bars_ago": 1, "open_time_ms": 2},
|
||||
"1w": {"direction": None},
|
||||
},
|
||||
),
|
||||
]
|
||||
f4 = filter_tab_items(items, "4h")
|
||||
self.assertEqual(len(f4), 1)
|
||||
self.assertEqual(f4[0]["symbol"], "AAA/USDT")
|
||||
f1d = filter_tab_items(items, "1d")
|
||||
self.assertEqual(len(f1d), 1)
|
||||
self.assertEqual(f1d[0]["symbol"], "BBB/USDT")
|
||||
|
||||
def test_detect_macd_divergence_may_hit_on_synthetic(self):
|
||||
closes = _synthetic_bull_div_closes()
|
||||
hit = detect_latest_macd_divergence(closes)
|
||||
# 合成数据不保证必中,但函数应正常返回
|
||||
self.assertIn(hit.get("direction"), (None, "bull", "bear"))
|
||||
|
||||
def test_analyze_ohlcv_bars_from_rows(self):
|
||||
closes = [float(100 + i * 0.1) for i in range(80)]
|
||||
bars = [
|
||||
{"open_time_ms": i * 3600000, "close": c, "open": c, "high": c, "low": c}
|
||||
for i, c in enumerate(closes)
|
||||
]
|
||||
out = analyze_ohlcv_bars(bars)
|
||||
self.assertIn("direction", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,157 @@
|
||||
"""开仓计划库:CRUD 与胜率统计."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from lib.hub.hub_entry_plan_lib import (
|
||||
compute_entry_plan_stats,
|
||||
create_entry_plan,
|
||||
delete_entry_plan,
|
||||
init_db,
|
||||
list_entry_plans,
|
||||
normalize_plan_symbol,
|
||||
resolve_stats_date_bounds,
|
||||
update_entry_plan,
|
||||
)
|
||||
|
||||
|
||||
def _base_payload(**overrides):
|
||||
data = {
|
||||
"plan_date": "2026-06-14",
|
||||
"exchange_key": "binance",
|
||||
"symbol": "BTC",
|
||||
"plan_type": "trend",
|
||||
"trend_timeframe": "4h",
|
||||
"entry_timeframe": "15m",
|
||||
"direction": "long",
|
||||
"target_level": "70000",
|
||||
"current_range": "68000-69000",
|
||||
"entry_scheme": "breakout",
|
||||
"note": "test",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def test_normalize_plan_symbol():
|
||||
assert normalize_plan_symbol("btc") == "BTC/USDT"
|
||||
assert normalize_plan_symbol("ETH/USDT") == "ETH/USDT"
|
||||
|
||||
|
||||
def test_create_without_entry_scheme():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "plans.db"
|
||||
payload = _base_payload()
|
||||
del payload["entry_scheme"]
|
||||
row = create_entry_plan(payload, db_path=db)
|
||||
assert row["entry_scheme"] == ""
|
||||
assert row["entry_scheme_label"] == "待填写"
|
||||
|
||||
|
||||
def test_archive_requires_entry_scheme():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "plans.db"
|
||||
payload = _base_payload()
|
||||
del payload["entry_scheme"]
|
||||
row = create_entry_plan(payload, db_path=db)
|
||||
try:
|
||||
update_entry_plan(int(row["id"]), {"result": "win"}, db_path=db)
|
||||
assert False, "expected ValueError"
|
||||
except ValueError as e:
|
||||
assert "入场方案" in str(e)
|
||||
updated = update_entry_plan(
|
||||
int(row["id"]),
|
||||
{"entry_scheme": "breakout", "result": "win"},
|
||||
db_path=db,
|
||||
)
|
||||
assert updated["status"] == "archived"
|
||||
|
||||
|
||||
def test_create_list_delete_active_plan():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "plans.db"
|
||||
row = create_entry_plan(_base_payload(), db_path=db)
|
||||
assert row["status"] == "active"
|
||||
assert row["symbol"] == "BTC/USDT"
|
||||
active = list_entry_plans(status="active", db_path=db)
|
||||
assert len(active) == 1
|
||||
assert delete_entry_plan(int(row["id"]), db_path=db) is True
|
||||
assert list_entry_plans(status="active", db_path=db) == []
|
||||
|
||||
|
||||
def test_archive_on_result():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "plans.db"
|
||||
row = create_entry_plan(_base_payload(symbol="SOL"), db_path=db)
|
||||
updated = update_entry_plan(
|
||||
int(row["id"]),
|
||||
{"result": "win", "pnl_amount": 12.5},
|
||||
db_path=db,
|
||||
)
|
||||
assert updated["status"] == "archived"
|
||||
assert updated["result"] == "win"
|
||||
assert updated["pnl_amount"] == 12.5
|
||||
assert list_entry_plans(status="active", db_path=db) == []
|
||||
archived = list_entry_plans(status="archived", db_path=db)
|
||||
assert len(archived) == 1
|
||||
|
||||
|
||||
def test_archive_without_pnl_amount():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "plans.db"
|
||||
row = create_entry_plan(_base_payload(symbol="DOGE"), db_path=db)
|
||||
updated = update_entry_plan(int(row["id"]), {"result": "loss"}, db_path=db)
|
||||
assert updated["status"] == "archived"
|
||||
assert updated["pnl_amount"] is None
|
||||
|
||||
|
||||
def test_cannot_delete_archived():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "plans.db"
|
||||
row = create_entry_plan(_base_payload(), db_path=db)
|
||||
update_entry_plan(int(row["id"]), {"result": "win"}, db_path=db)
|
||||
try:
|
||||
delete_entry_plan(int(row["id"]), db_path=db)
|
||||
assert False, "expected ValueError"
|
||||
except ValueError as e:
|
||||
assert "仅进行中" in str(e)
|
||||
|
||||
|
||||
def test_compute_stats_by_symbol():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "plans.db"
|
||||
for sym, res in (("BTC", "win"), ("BTC", "loss"), ("ETH", "win")):
|
||||
row = create_entry_plan(_base_payload(symbol=sym), db_path=db)
|
||||
update_entry_plan(int(row["id"]), {"result": res}, db_path=db)
|
||||
stats = compute_entry_plan_stats(dimension="symbol", period="all", db_path=db)
|
||||
by_sym = {it["key"]: it for it in stats["items"]}
|
||||
assert by_sym["BTC/USDT"]["win_count"] == 1
|
||||
assert by_sym["BTC/USDT"]["loss_count"] == 1
|
||||
assert by_sym["BTC/USDT"]["win_rate"] == 50.0
|
||||
assert by_sym["ETH/USDT"]["win_count"] == 1
|
||||
|
||||
|
||||
def test_stats_period_range_filter():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "plans.db"
|
||||
row1 = create_entry_plan(_base_payload(plan_date="2026-06-01"), db_path=db)
|
||||
row2 = create_entry_plan(_base_payload(plan_date="2026-06-20", symbol="ETH"), db_path=db)
|
||||
update_entry_plan(int(row1["id"]), {"result": "win"}, db_path=db)
|
||||
update_entry_plan(int(row2["id"]), {"result": "loss"}, db_path=db)
|
||||
stats = compute_entry_plan_stats(
|
||||
dimension="symbol",
|
||||
period="range",
|
||||
date_from="2026-06-01",
|
||||
date_to="2026-06-10",
|
||||
db_path=db,
|
||||
)
|
||||
assert len(stats["items"]) == 1
|
||||
assert stats["items"][0]["key"] == "BTC/USDT"
|
||||
|
||||
|
||||
def test_resolve_stats_date_bounds():
|
||||
df, dt, label = resolve_stats_date_bounds(period="all")
|
||||
assert df is None and dt is None
|
||||
assert "全部" in label
|
||||
@@ -0,0 +1,67 @@
|
||||
"""OKX 中控委托:须为 OCO 条件单,不得带 reduceOnly 或分两笔 market."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
|
||||
|
||||
from exchange_orders import _okx_place_tp_sl # noqa: E402
|
||||
|
||||
|
||||
class TestHubOkxPlaceTpsl(unittest.TestCase):
|
||||
def test_okx_place_tpsl_single_oco_without_reduce_only(self):
|
||||
captured: list[dict] = []
|
||||
|
||||
def fake_create_order(symbol, order_type, side, amount, price, params):
|
||||
captured.append(
|
||||
{
|
||||
"symbol": symbol,
|
||||
"type": order_type,
|
||||
"side": side,
|
||||
"amount": amount,
|
||||
"params": dict(params or {}),
|
||||
}
|
||||
)
|
||||
return {"id": "algo-1"}
|
||||
|
||||
ex = MagicMock()
|
||||
ex.create_order = fake_create_order
|
||||
ex.load_markets = MagicMock()
|
||||
ex.amount_to_precision = lambda sym, amt: str(amt)
|
||||
ex.price_to_precision = lambda sym, px: str(px)
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"OKX_POS_MODE": "hedge", "OKX_TD_MODE": "cross"},
|
||||
clear=False,
|
||||
):
|
||||
_okx_place_tp_sl(
|
||||
ex,
|
||||
"HYPE/USDT:USDT",
|
||||
"short",
|
||||
6.0,
|
||||
75.5,
|
||||
70.2,
|
||||
)
|
||||
|
||||
self.assertEqual(len(captured), 1, captured)
|
||||
call = captured[0]
|
||||
self.assertEqual(call["type"], "oco")
|
||||
self.assertEqual(call["side"], "buy")
|
||||
params = call["params"]
|
||||
self.assertNotIn("reduceOnly", params)
|
||||
self.assertEqual(params.get("posSide"), "short")
|
||||
self.assertEqual(params.get("positionSide"), "short")
|
||||
self.assertEqual(params.get("stopLossPrice"), 75.5)
|
||||
self.assertEqual(params.get("takeProfitPrice"), 70.2)
|
||||
self.assertEqual(params.get("tpOrdPx"), "-1")
|
||||
self.assertEqual(params.get("slOrdPx"), "-1")
|
||||
self.assertNotIn("stopLoss", params)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""hub_fund_history_lib:总资金,回撤与日快照."""
|
||||
from __future__ import annotations
|
||||
|
||||
from lib.hub.hub_fund_history_lib import (
|
||||
account_total_usdt,
|
||||
build_fund_overview,
|
||||
compute_drawdown,
|
||||
compute_period_delta,
|
||||
get_fund_history,
|
||||
record_fund_snapshot,
|
||||
)
|
||||
|
||||
|
||||
def test_account_total_requires_both_sides():
|
||||
assert account_total_usdt(10, 20) == 30.0
|
||||
assert account_total_usdt(10, None) is None
|
||||
assert account_total_usdt(None, 5) is None
|
||||
|
||||
|
||||
def test_compute_drawdown():
|
||||
dd = compute_drawdown([100, 120, 90, 110])
|
||||
assert dd["peak_usdt"] == 120.0
|
||||
assert dd["max_drawdown_u"] == 30.0
|
||||
assert dd["max_drawdown_pct"] == 25.0
|
||||
|
||||
|
||||
def test_compute_period_delta():
|
||||
out = compute_period_delta(
|
||||
[
|
||||
{"day": "2026-06-09", "total_usdt": 100},
|
||||
{"day": "2026-06-10", "total_usdt": 112.5},
|
||||
]
|
||||
)
|
||||
assert out["start_usdt"] == 100.0
|
||||
assert out["period_delta_usdt"] == 12.5
|
||||
assert out["period_delta_pct"] == 12.5
|
||||
empty = compute_period_delta([])
|
||||
assert empty["period_delta_usdt"] is None
|
||||
|
||||
|
||||
def test_build_fund_overview_skips_unmonitored(tmp_path, monkeypatch):
|
||||
hist_path = tmp_path / "hub_fund_history.json"
|
||||
monkeypatch.setattr("hub_fund_history_lib.FUND_HISTORY_PATH", hist_path)
|
||||
record_fund_snapshot(
|
||||
"2026-06-01",
|
||||
[
|
||||
{
|
||||
"key": "binance",
|
||||
"name": "Binance",
|
||||
"funding_usdt": 10,
|
||||
"trading_usdt": 20,
|
||||
"monitored": True,
|
||||
}
|
||||
],
|
||||
keep_days=180,
|
||||
)
|
||||
record_fund_snapshot(
|
||||
"2026-06-02",
|
||||
[
|
||||
{
|
||||
"key": "binance",
|
||||
"name": "Binance",
|
||||
"funding_usdt": 12,
|
||||
"trading_usdt": 18,
|
||||
"monitored": True,
|
||||
}
|
||||
],
|
||||
keep_days=180,
|
||||
)
|
||||
exchanges = [
|
||||
{"id": "0", "key": "binance", "name": "Binance", "enabled": True},
|
||||
{"id": "2", "key": "gate", "name": "Gate", "enabled": False},
|
||||
]
|
||||
board_rows = [
|
||||
{
|
||||
"key": "binance",
|
||||
"name": "Binance",
|
||||
"account_ok": True,
|
||||
"funding_usdt": 15,
|
||||
"trading_usdt": 25,
|
||||
}
|
||||
]
|
||||
out = build_fund_overview(
|
||||
exchanges,
|
||||
board_rows=board_rows,
|
||||
trading_day="2026-06-02",
|
||||
keep_days=180,
|
||||
)
|
||||
assert out["totals"]["total_usdt"] == 40.0
|
||||
assert out["totals"]["monitored_count"] == 1
|
||||
assert len(out["accounts"]) == 1
|
||||
assert all(a["monitored"] for a in out["accounts"])
|
||||
assert out["totals"]["drawdown"]["max_drawdown_u"] == 0.0
|
||||
|
||||
|
||||
def test_history_start_day_filters_older(tmp_path, monkeypatch):
|
||||
hist_path = tmp_path / "hub_fund_history.json"
|
||||
monkeypatch.setattr("hub_fund_history_lib.FUND_HISTORY_PATH", hist_path)
|
||||
monkeypatch.setattr("hub_fund_history_lib.FUND_HISTORY_START_DAY", "2026-06-09")
|
||||
record_fund_snapshot(
|
||||
"2026-06-01",
|
||||
[
|
||||
{
|
||||
"key": "binance",
|
||||
"name": "Binance",
|
||||
"funding_usdt": 1,
|
||||
"trading_usdt": 1,
|
||||
"monitored": True,
|
||||
}
|
||||
],
|
||||
keep_days=180,
|
||||
)
|
||||
record_fund_snapshot(
|
||||
"2026-06-09",
|
||||
[
|
||||
{
|
||||
"key": "binance",
|
||||
"name": "Binance",
|
||||
"funding_usdt": 10,
|
||||
"trading_usdt": 20,
|
||||
"monitored": True,
|
||||
}
|
||||
],
|
||||
keep_days=180,
|
||||
)
|
||||
hist = get_fund_history(anchor_day="2026-06-10", keep_days=180)
|
||||
assert "2026-06-01" not in hist
|
||||
assert "2026-06-09" in hist
|
||||
@@ -0,0 +1,58 @@
|
||||
"""hub_host_status_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from lib.hub.hub_host_status_lib import _disk_path, _state, get_host_status
|
||||
|
||||
|
||||
class HubHostStatusLibTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
_state["primed"] = False
|
||||
_state["net_ts"] = 0.0
|
||||
_state["net_sent"] = 0
|
||||
_state["net_recv"] = 0
|
||||
|
||||
def test_disk_path_env_override(self):
|
||||
with patch.dict("os.environ", {"HUB_HOST_DISK_PATH": "/data"}, clear=False):
|
||||
self.assertEqual(_disk_path(), "/data")
|
||||
|
||||
def test_get_host_status_without_psutil(self):
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
|
||||
if name == "psutil":
|
||||
raise ImportError("no psutil")
|
||||
return real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
with patch("builtins.__import__", side_effect=fake_import):
|
||||
out = get_host_status()
|
||||
self.assertFalse(out.get("ok"))
|
||||
self.assertIn("psutil", out.get("msg", ""))
|
||||
|
||||
def test_get_host_status_payload(self):
|
||||
fake_vm = MagicMock(total=8_000_000_000, used=3_200_000_000, percent=40.0)
|
||||
fake_du = MagicMock(total=100_000_000_000, used=50_000_000_000)
|
||||
fake_net = MagicMock(bytes_sent=1_000_000, bytes_recv=2_000_000)
|
||||
fake_psutil = MagicMock()
|
||||
fake_psutil.cpu_percent.return_value = 12.5
|
||||
fake_psutil.cpu_count.return_value = 4
|
||||
fake_psutil.virtual_memory.return_value = fake_vm
|
||||
fake_psutil.disk_usage.return_value = fake_du
|
||||
fake_psutil.net_io_counters.return_value = fake_net
|
||||
fake_psutil.boot_time.return_value = 1_700_000_000.0
|
||||
with patch.dict(sys.modules, {"psutil": fake_psutil}):
|
||||
out = get_host_status()
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertEqual(out["cpu"]["percent"], 12.5)
|
||||
self.assertEqual(out["memory"]["percent"], 40.0)
|
||||
self.assertEqual(out["disk"]["percent"], 50.0)
|
||||
self.assertIn("network", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,466 @@
|
||||
"""中控 K 线库:分周期保留,聚合与分页读取."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from lib.hub.hub_kline_store import (
|
||||
HUB_KLINE_REMOTE_FETCH_CAP,
|
||||
_since_ms_for_span,
|
||||
clear_series_bars,
|
||||
init_db,
|
||||
load_bars_before,
|
||||
load_bars_latest,
|
||||
purge_retention,
|
||||
purge_timeframe_by_days,
|
||||
resolve_chart_bars,
|
||||
retention_days,
|
||||
trim_contiguous_tail,
|
||||
upsert_bars,
|
||||
)
|
||||
from lib.hub.hub_ohlcv_lib import (
|
||||
TIMEFRAME_MS,
|
||||
bar_limit_for_timeframe,
|
||||
chart_fetch_start_ms,
|
||||
chart_initial_limit,
|
||||
last_closed_bar_open_ms,
|
||||
window_start_ms,
|
||||
)
|
||||
|
||||
|
||||
class TestHubKlineStore(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db = Path(self.tmp.name) / "test_hub_kline.db"
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_bar_limits(self):
|
||||
self.assertEqual(bar_limit_for_timeframe("5m"), 5000)
|
||||
self.assertEqual(bar_limit_for_timeframe("1h"), 1000)
|
||||
self.assertEqual(bar_limit_for_timeframe("1d"), 1000)
|
||||
self.assertEqual(bar_limit_for_timeframe("1w"), 500)
|
||||
self.assertEqual(chart_initial_limit("5m"), 2000)
|
||||
self.assertEqual(chart_initial_limit("1h"), 1000)
|
||||
self.assertEqual(chart_initial_limit("1d"), 500)
|
||||
|
||||
def test_chart_fetch_window_exceeds_retention(self):
|
||||
now = int(time.time() * 1000)
|
||||
need = bar_limit_for_timeframe("1d")
|
||||
fetch_start = chart_fetch_start_ms("1d", need, now)
|
||||
db_start = window_start_ms("1d", need, retention_days(), now)
|
||||
self.assertLess(fetch_start, db_start)
|
||||
|
||||
def test_purge_retention_5m_one_year(self):
|
||||
init_db(self.db)
|
||||
old_ms = int(time.time() * 1000) - 400 * 86400000
|
||||
upsert_bars(
|
||||
"okx",
|
||||
"BTC/USDT",
|
||||
"5m",
|
||||
[
|
||||
{
|
||||
"open_time_ms": old_ms,
|
||||
"open": 1,
|
||||
"high": 2,
|
||||
"low": 0.5,
|
||||
"close": 1.5,
|
||||
"volume": 10,
|
||||
}
|
||||
],
|
||||
self.db,
|
||||
)
|
||||
n = purge_timeframe_by_days("5m", 365, self.db)
|
||||
self.assertGreaterEqual(n, 1)
|
||||
rows = load_bars_latest("okx", "BTC/USDT", "5m", 10, self.db)
|
||||
self.assertEqual(len(rows), 0)
|
||||
|
||||
def test_purge_retention_keeps_1d(self):
|
||||
init_db(self.db)
|
||||
old_ms = int(time.time() * 1000) - 400 * 86400000
|
||||
upsert_bars(
|
||||
"okx",
|
||||
"BTC/USDT",
|
||||
"1d",
|
||||
[
|
||||
{
|
||||
"open_time_ms": old_ms,
|
||||
"open": 1,
|
||||
"high": 2,
|
||||
"low": 0.5,
|
||||
"close": 1.5,
|
||||
"volume": 10,
|
||||
}
|
||||
],
|
||||
self.db,
|
||||
)
|
||||
purge_retention(self.db)
|
||||
rows = load_bars_latest("okx", "BTC/USDT", "1d", 10, self.db)
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
def test_resolve_uses_cache_without_remote(self):
|
||||
init_db(self.db)
|
||||
now = int(time.time() * 1000)
|
||||
tf = "5m"
|
||||
period = TIMEFRAME_MS[tf]
|
||||
last_closed = last_closed_bar_open_ms(tf, now)
|
||||
bars = []
|
||||
for i in range(400):
|
||||
oms = last_closed - (399 - i) * period
|
||||
bars.append(
|
||||
{
|
||||
"open_time_ms": oms,
|
||||
"open": 100 + i,
|
||||
"high": 101 + i,
|
||||
"low": 99 + i,
|
||||
"close": 100.5 + i,
|
||||
"volume": 1000 + i,
|
||||
}
|
||||
)
|
||||
upsert_bars("okx", "ETH/USDT", tf, bars, self.db)
|
||||
|
||||
def remote_fetch(**kwargs):
|
||||
self.fail("不应请求交易所")
|
||||
|
||||
out = resolve_chart_bars(
|
||||
"okx",
|
||||
"ETH/USDT",
|
||||
tf,
|
||||
remote_fetch,
|
||||
db_path=self.db,
|
||||
limit=300,
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertEqual(len(out.get("candles") or []), 300)
|
||||
|
||||
def test_resolve_15m_reads_native_bars(self):
|
||||
init_db(self.db)
|
||||
now = int(time.time() * 1000)
|
||||
period = TIMEFRAME_MS["15m"]
|
||||
last_closed = last_closed_bar_open_ms("15m", now)
|
||||
bars = []
|
||||
for i in range(12):
|
||||
oms = last_closed - (11 - i) * period
|
||||
bars.append(
|
||||
{
|
||||
"open_time_ms": oms,
|
||||
"open": 1.0 + i,
|
||||
"high": 2.0 + i,
|
||||
"low": 0.5 + i,
|
||||
"close": 1.5 + i,
|
||||
"volume": 10.0,
|
||||
}
|
||||
)
|
||||
upsert_bars("okx", "ETH/USDT", "15m", bars, self.db)
|
||||
|
||||
def remote_fetch(**kwargs):
|
||||
self.fail("不应请求交易所")
|
||||
|
||||
out = resolve_chart_bars(
|
||||
"okx",
|
||||
"ETH/USDT",
|
||||
"15m",
|
||||
remote_fetch,
|
||||
db_path=self.db,
|
||||
limit=10,
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertEqual(out.get("source"), "db")
|
||||
self.assertEqual(out.get("storage_timeframe"), "15m")
|
||||
self.assertGreaterEqual(len(out.get("candles") or []), 10)
|
||||
|
||||
def test_load_bars_before(self):
|
||||
init_db(self.db)
|
||||
period = TIMEFRAME_MS["1h"]
|
||||
base = 1_700_000_000_000
|
||||
bars = []
|
||||
for i in range(5):
|
||||
bars.append(
|
||||
{
|
||||
"open_time_ms": base + i * period,
|
||||
"open": 1,
|
||||
"high": 2,
|
||||
"low": 0.5,
|
||||
"close": 1.5,
|
||||
"volume": 1,
|
||||
}
|
||||
)
|
||||
upsert_bars("okx", "BTC/USDT", "1h", bars, self.db)
|
||||
before = base + 3 * period
|
||||
got = load_bars_before("okx", "BTC/USDT", "1h", before, 2, self.db)
|
||||
self.assertEqual(len(got), 2)
|
||||
self.assertEqual(got[-1]["open_time_ms"], base + 2 * period)
|
||||
|
||||
def test_trim_contiguous_tail_drops_orphan_prefix(self):
|
||||
period = TIMEFRAME_MS["15m"]
|
||||
base_old = 1_700_000_000_000
|
||||
base_new = base_old + period * 500
|
||||
bars = []
|
||||
for i in range(3):
|
||||
bars.append(
|
||||
{
|
||||
"open_time_ms": base_old + i * period,
|
||||
"open": 1,
|
||||
"high": 2,
|
||||
"low": 0.5,
|
||||
"close": 1.5,
|
||||
"volume": 1,
|
||||
}
|
||||
)
|
||||
for i in range(5):
|
||||
bars.append(
|
||||
{
|
||||
"open_time_ms": base_new + i * period,
|
||||
"open": 2,
|
||||
"high": 3,
|
||||
"low": 1.5,
|
||||
"close": 2.5,
|
||||
"volume": 2,
|
||||
}
|
||||
)
|
||||
trimmed, split = trim_contiguous_tail(bars, period)
|
||||
self.assertEqual(split, 3)
|
||||
self.assertEqual(len(trimmed), 5)
|
||||
self.assertEqual(trimmed[0]["open_time_ms"], base_new)
|
||||
|
||||
def test_resolve_drops_discontinuous_orphans(self):
|
||||
init_db(self.db)
|
||||
period = TIMEFRAME_MS["15m"]
|
||||
now = int(time.time() * 1000)
|
||||
old_ms = now - period * 800
|
||||
upsert_bars(
|
||||
"okx",
|
||||
"ONDO/USDT",
|
||||
"15m",
|
||||
[
|
||||
{
|
||||
"open_time_ms": old_ms,
|
||||
"open": 0.33,
|
||||
"high": 0.34,
|
||||
"low": 0.32,
|
||||
"close": 0.335,
|
||||
"volume": 100,
|
||||
}
|
||||
],
|
||||
self.db,
|
||||
)
|
||||
recent = []
|
||||
start = now - period * 20
|
||||
for i in range(20):
|
||||
recent.append(
|
||||
{
|
||||
"open_time_ms": start + i * period,
|
||||
"open": 0.35,
|
||||
"high": 0.36,
|
||||
"low": 0.34,
|
||||
"close": 0.355,
|
||||
"volume": 50,
|
||||
}
|
||||
)
|
||||
|
||||
def remote_fetch(**kwargs):
|
||||
return {"ok": True, "bars": recent, "price_tick": 0.0001}
|
||||
|
||||
out = resolve_chart_bars(
|
||||
"okx",
|
||||
"ONDO/USDT",
|
||||
"15m",
|
||||
remote_fetch,
|
||||
db_path=self.db,
|
||||
limit=50,
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
candles = out.get("candles") or []
|
||||
self.assertGreaterEqual(len(candles), 19)
|
||||
if len(candles) >= 2:
|
||||
for i in range(1, len(candles)):
|
||||
gap = candles[i]["time"] - candles[i - 1]["time"]
|
||||
self.assertLessEqual(gap, int(period / 1000 * 3.0))
|
||||
|
||||
def test_resolve_refetches_when_db_has_discontinuous_full_count(self):
|
||||
init_db(self.db)
|
||||
period = TIMEFRAME_MS["15m"]
|
||||
now = int(time.time() * 1000)
|
||||
old_start = now - period * 3000
|
||||
recent_start = now - period * 25
|
||||
old_bars = [
|
||||
{
|
||||
"open_time_ms": old_start + i * period,
|
||||
"open": 62000,
|
||||
"high": 62100,
|
||||
"low": 61900,
|
||||
"close": 62050,
|
||||
"volume": 10,
|
||||
}
|
||||
for i in range(500)
|
||||
]
|
||||
recent = [
|
||||
{
|
||||
"open_time_ms": recent_start + i * period,
|
||||
"open": 104000,
|
||||
"high": 104100,
|
||||
"low": 103900,
|
||||
"close": 104050,
|
||||
"volume": 20,
|
||||
}
|
||||
for i in range(30)
|
||||
]
|
||||
upsert_bars("binance", "BTC/USDT", "15m", old_bars, self.db)
|
||||
upsert_bars("binance", "BTC/USDT", "15m", recent, self.db)
|
||||
fetch_calls = []
|
||||
|
||||
def remote_fetch(**kwargs):
|
||||
fetch_calls.append(dict(kwargs))
|
||||
full = []
|
||||
start = now - period * 120
|
||||
for i in range(120):
|
||||
full.append(
|
||||
{
|
||||
"open_time_ms": start + i * period,
|
||||
"open": 104000 + i,
|
||||
"high": 104100 + i,
|
||||
"low": 103900 + i,
|
||||
"close": 104050 + i,
|
||||
"volume": 30,
|
||||
}
|
||||
)
|
||||
return {"ok": True, "bars": full, "price_tick": 0.01}
|
||||
|
||||
out = resolve_chart_bars(
|
||||
"binance",
|
||||
"BTC/USDT",
|
||||
"15m",
|
||||
remote_fetch,
|
||||
db_path=self.db,
|
||||
limit=2000,
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertGreater(len(fetch_calls), 0)
|
||||
self.assertGreaterEqual(len(out.get("candles") or []), 100)
|
||||
self.assertGreater(int(out.get("fetched") or 0), 0)
|
||||
|
||||
def test_clear_series_and_force_refetch(self):
|
||||
init_db(self.db)
|
||||
period = TIMEFRAME_MS["5m"]
|
||||
now = int(time.time() * 1000)
|
||||
stale = [
|
||||
{
|
||||
"open_time_ms": now - period * (i + 100),
|
||||
"open": 1,
|
||||
"high": 2,
|
||||
"low": 0.5,
|
||||
"close": 1.5,
|
||||
"volume": 1,
|
||||
}
|
||||
for i in range(40)
|
||||
]
|
||||
upsert_bars("binance", "BTC/USDT", "5m", stale, self.db)
|
||||
self.assertEqual(len(load_bars_latest("binance", "BTC/USDT", "5m", 100, self.db)), 40)
|
||||
removed = clear_series_bars("binance", "BTC/USDT", "5m", self.db)
|
||||
self.assertEqual(removed, 40)
|
||||
self.assertEqual(len(load_bars_latest("binance", "BTC/USDT", "5m", 100, self.db)), 0)
|
||||
|
||||
fresh = [
|
||||
{
|
||||
"open_time_ms": now - period * (20 - i),
|
||||
"open": 10,
|
||||
"high": 11,
|
||||
"low": 9,
|
||||
"close": 10.5,
|
||||
"volume": 2,
|
||||
}
|
||||
for i in range(20)
|
||||
]
|
||||
|
||||
def remote_fetch(**kwargs):
|
||||
return {"ok": True, "bars": fresh, "price_tick": 0.01}
|
||||
|
||||
out = resolve_chart_bars(
|
||||
"binance",
|
||||
"BTC/USDT",
|
||||
"5m",
|
||||
remote_fetch,
|
||||
db_path=self.db,
|
||||
force_refresh=True,
|
||||
clear_db=True,
|
||||
limit=50,
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertGreaterEqual(int(out.get("cleared") or 0), 0)
|
||||
self.assertGreater(int(out.get("fetched") or 0), 0)
|
||||
self.assertGreaterEqual(len(out.get("candles") or []), 19)
|
||||
|
||||
def test_since_span_matches_fetch_limit_not_need(self):
|
||||
period = TIMEFRAME_MS["15m"]
|
||||
now_ms = 1_800_000_000_000
|
||||
fetch_limit = HUB_KLINE_REMOTE_FETCH_CAP
|
||||
since = _since_ms_for_span(
|
||||
now_ms=now_ms,
|
||||
period_ms=period,
|
||||
span_bars=fetch_limit,
|
||||
cutoff_ms=0,
|
||||
)
|
||||
self.assertEqual(since, now_ms - period * fetch_limit)
|
||||
wrong_since = now_ms - period * chart_initial_limit("15m")
|
||||
self.assertGreater(since, wrong_since)
|
||||
|
||||
def test_thin_series_tail_refresh_fetches_full_window(self):
|
||||
init_db(self.db)
|
||||
period = TIMEFRAME_MS["15m"]
|
||||
now = int(time.time() * 1000)
|
||||
last_closed = last_closed_bar_open_ms("15m", now)
|
||||
bars = [
|
||||
{
|
||||
"open_time_ms": last_closed - period * (150 - i),
|
||||
"open": 100000,
|
||||
"high": 100100,
|
||||
"low": 99900,
|
||||
"close": 100050,
|
||||
"volume": 1,
|
||||
}
|
||||
for i in range(150)
|
||||
]
|
||||
fetch_calls: list[dict] = []
|
||||
|
||||
def remote_fetch(**kwargs):
|
||||
fetch_calls.append(dict(kwargs))
|
||||
return {"ok": True, "bars": bars, "price_tick": 0.01}
|
||||
|
||||
out = resolve_chart_bars(
|
||||
"binance",
|
||||
"BTC/USDT",
|
||||
"15m",
|
||||
remote_fetch,
|
||||
db_path=self.db,
|
||||
tail_refresh=True,
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertGreaterEqual(len(out.get("candles") or []), 100)
|
||||
self.assertGreater(int(out.get("fetched") or 0), 0)
|
||||
self.assertTrue(any(int(c.get("limit") or 0) > 30 for c in fetch_calls))
|
||||
|
||||
def test_resolve_before_ms_exhausted(self):
|
||||
init_db(self.db)
|
||||
|
||||
def remote_fetch(**kwargs):
|
||||
return {"ok": False, "msg": "no remote"}
|
||||
|
||||
out = resolve_chart_bars(
|
||||
"okx",
|
||||
"BTC/USDT",
|
||||
"5m",
|
||||
remote_fetch,
|
||||
db_path=self.db,
|
||||
limit=100,
|
||||
before_ms=int(time.time() * 1000),
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertEqual(out.get("candles"), [])
|
||||
self.assertTrue(out.get("exhausted"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from lib.hub.hub_macro_calendar_lib import (
|
||||
build_banner_message,
|
||||
create_event,
|
||||
delete_event,
|
||||
enrich_alert,
|
||||
init_db,
|
||||
list_active_alerts,
|
||||
list_events,
|
||||
update_event,
|
||||
)
|
||||
|
||||
|
||||
class HubMacroCalendarLibTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = Path(self.tmp.name) / "macro.db"
|
||||
init_db(self.db_path)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_create_and_list(self):
|
||||
row = create_event("cpi", "2026-06-18 20:30", note="核心CPI", db_path=self.db_path)
|
||||
self.assertEqual(row["event_type"], "cpi")
|
||||
self.assertEqual(row["event_at"], "2026-06-18 20:30")
|
||||
rows = list_events(now_ms=row["event_at_ms"] - 86400000, db_path=self.db_path)
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
def test_duplicate_rejected(self):
|
||||
create_event("fomc", "2026-07-01 02:00", db_path=self.db_path)
|
||||
with self.assertRaises(ValueError):
|
||||
create_event("fomc", "2026-07-01 02:00", db_path=self.db_path)
|
||||
|
||||
def test_active_window_and_messages(self):
|
||||
row = create_event("employment", "2026-06-18 20:30", db_path=self.db_path)
|
||||
t0 = int(row["event_at_ms"])
|
||||
inside = enrich_alert(row, now_ms=t0 - 30 * 60 * 1000)
|
||||
self.assertIsNotNone(inside)
|
||||
self.assertEqual(inside["phase"], "imminent")
|
||||
outside = enrich_alert(row, now_ms=t0 - 2 * 3600 * 1000)
|
||||
self.assertIsNone(outside)
|
||||
alerts = list_active_alerts(now_ms=t0 + 15 * 60 * 1000, db_path=self.db_path)
|
||||
self.assertEqual(len(alerts), 1)
|
||||
msg_pos = build_banner_message(alerts[0], has_positions=True)
|
||||
msg_flat = build_banner_message(alerts[0], has_positions=False)
|
||||
self.assertIn("注意仓位风险", msg_pos)
|
||||
self.assertIn("建议等待", msg_flat)
|
||||
|
||||
def test_update_and_delete(self):
|
||||
row = create_event("cpi", "2026-06-18 20:30", db_path=self.db_path)
|
||||
updated = update_event(
|
||||
row["id"],
|
||||
event_at="2026-06-18 21:00",
|
||||
note="修正时间",
|
||||
db_path=self.db_path,
|
||||
)
|
||||
self.assertEqual(updated["event_at"], "2026-06-18 21:00")
|
||||
self.assertTrue(delete_event(row["id"], db_path=self.db_path))
|
||||
self.assertEqual(len(list_events(now_ms=updated["event_at_ms"], db_path=self.db_path)), 0)
|
||||
|
||||
def test_invalid_type(self):
|
||||
with self.assertRaises(ValueError):
|
||||
create_event("nfp", "2026-06-18 20:30", db_path=self.db_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""hub /api/hub/monitor:enrich 局部返回时须保留 keys."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.hub.hub_bridge import build_hub_monitor_payload # noqa: E402
|
||||
|
||||
|
||||
class TestHubMonitorPayload(unittest.TestCase):
|
||||
def test_partial_enrich_keeps_keys(self):
|
||||
keys = [{"id": 7, "symbol": "BTC/USDT"}]
|
||||
orders = [{"id": 1}]
|
||||
trends = [{"id": 9, "symbol": "ETH/USDT"}]
|
||||
rolls = []
|
||||
|
||||
def enrich_only_trends(**_kw):
|
||||
return {"trends": [{"id": 9, "add_count": 2}]}
|
||||
|
||||
out = build_hub_monitor_payload(
|
||||
keys=keys,
|
||||
orders=orders,
|
||||
trends=trends,
|
||||
rolls=rolls,
|
||||
enrich=enrich_only_trends,
|
||||
)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["keys"], keys)
|
||||
self.assertEqual(out["orders"], orders)
|
||||
self.assertEqual(out["rolls"], rolls)
|
||||
self.assertEqual(out["hedges"], [])
|
||||
self.assertEqual(out["trends"][0]["add_count"], 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
from lib.hub.hub_monitor_totals_lib import aggregate_monitor_board_totals
|
||||
|
||||
|
||||
def test_aggregate_monitor_board_totals_sums_rows():
|
||||
rows = [
|
||||
{
|
||||
"day_stats": {
|
||||
"ok": True,
|
||||
"opens_today": 2,
|
||||
"trade_stats": {
|
||||
"closed_count": 1,
|
||||
"win_count": 1,
|
||||
"loss_count": 0,
|
||||
"win_pnl_u": 5.5,
|
||||
"loss_pnl_u": 0,
|
||||
},
|
||||
},
|
||||
"agent": {"positions": [{"contracts": 1}], "total_unrealized_pnl": 1.2},
|
||||
},
|
||||
{
|
||||
"day_stats": {
|
||||
"ok": True,
|
||||
"opens_today": 1,
|
||||
"trade_stats": {
|
||||
"closed_count": 2,
|
||||
"win_count": 0,
|
||||
"loss_count": 2,
|
||||
"win_pnl_u": 0,
|
||||
"loss_pnl_u": -3.0,
|
||||
},
|
||||
},
|
||||
"agent": {"positions": [], "total_unrealized_pnl": 0},
|
||||
},
|
||||
]
|
||||
out = aggregate_monitor_board_totals(rows, trading_day="2026-07-04", reset_hour=8)
|
||||
assert out["open_count"] == 3
|
||||
assert out["closed_count"] == 3
|
||||
assert out["win_count"] == 1
|
||||
assert out["loss_count"] == 2
|
||||
assert out["win_pnl_u"] == 5.5
|
||||
assert out["loss_pnl_u"] == -3.0
|
||||
assert out["open_position_count"] == 1
|
||||
assert out["float_pnl_u"] == 1.2
|
||||
|
||||
|
||||
def test_aggregate_monitor_board_totals_includes_options():
|
||||
rows = [
|
||||
{
|
||||
"capabilities": ["options"],
|
||||
"options": {
|
||||
"ok": True,
|
||||
"enabled": True,
|
||||
"positions": [{"inst_id": "X"}, {"inst_id": "Y"}],
|
||||
"upl_total_usdc": 1.5,
|
||||
},
|
||||
"agent": {"positions": [], "total_unrealized_pnl": 0},
|
||||
}
|
||||
]
|
||||
out = aggregate_monitor_board_totals(rows, trading_day="2026-07-04", reset_hour=8)
|
||||
assert out["options_open_position_count"] == 2
|
||||
assert out["open_position_count"] == 2
|
||||
assert out["options_float_pnl_u"] == 1.5
|
||||
assert out["float_pnl_u"] == 1.5
|
||||
|
||||
|
||||
def test_summarize_trades_win_loss_amounts():
|
||||
from lib.hub.hub_trades_lib import summarize_trades
|
||||
|
||||
stats = summarize_trades(
|
||||
[{"pnl_amount": 2.5}, {"pnl_amount": -1.0}, {"pnl_amount": 0}]
|
||||
)
|
||||
assert stats["win_count"] == 1
|
||||
assert stats["loss_count"] == 1
|
||||
assert stats["win_pnl_u"] == 2.5
|
||||
assert stats["loss_pnl_u"] == -1.0
|
||||
@@ -0,0 +1,222 @@
|
||||
"""hub_ohlcv_lib:分页拉取(Gate 等单次不足 chunk 时仍继续)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from lib.hub.hub_ohlcv_lib import (
|
||||
aggregate_ohlcv_bars,
|
||||
bars_spacing_matches_timeframe,
|
||||
fetch_ohlcv_for_hub,
|
||||
normalize_price_tick,
|
||||
price_tick_from_market,
|
||||
)
|
||||
|
||||
|
||||
class _FakeExchange:
|
||||
def __init__(self, pages, *, timeframes=None):
|
||||
self.pages = list(pages)
|
||||
self.calls = []
|
||||
self.markets = {}
|
||||
self.timeframes = timeframes if timeframes is not None else {}
|
||||
|
||||
def fetch_ohlcv(self, symbol, timeframe=None, since=None, limit=None):
|
||||
self.calls.append(
|
||||
{"symbol": symbol, "since": since, "limit": limit, "timeframe": timeframe}
|
||||
)
|
||||
if not self.pages:
|
||||
return []
|
||||
page = self.pages.pop(0)
|
||||
if since is None:
|
||||
return page
|
||||
return [b for b in page if b[0] >= since]
|
||||
|
||||
|
||||
class TestHubOhlcvLib(unittest.TestCase):
|
||||
def test_normalize_price_tick_snaps_powers_of_ten(self):
|
||||
self.assertAlmostEqual(normalize_price_tick(0.00001), 0.00001)
|
||||
self.assertAlmostEqual(normalize_price_tick(0.001), 0.001)
|
||||
self.assertIsNone(normalize_price_tick(0))
|
||||
|
||||
def test_price_tick_from_decimal_precision(self):
|
||||
class _Ex:
|
||||
markets = {"BTC/USDT:USDT": {"precision": {"price": 2}, "info": {}, "limits": {}}}
|
||||
|
||||
def load_markets(self):
|
||||
return self.markets
|
||||
|
||||
def market(self, sym):
|
||||
return self.markets[sym]
|
||||
|
||||
def price_to_precision(self, sym, price):
|
||||
return "12345.67"
|
||||
|
||||
tick = price_tick_from_market(_Ex(), "BTC/USDT:USDT")
|
||||
self.assertAlmostEqual(tick, 0.01)
|
||||
|
||||
def test_price_tick_from_binance_price_filter(self):
|
||||
class _Ex:
|
||||
markets = {
|
||||
"BTC/USDT:USDT": {
|
||||
"precision": {"price": 2},
|
||||
"info": {
|
||||
"filters": [
|
||||
{"filterType": "PRICE_FILTER", "tickSize": "0.10"},
|
||||
{"filterType": "LOT_SIZE", "stepSize": "0.001"},
|
||||
]
|
||||
},
|
||||
"limits": {},
|
||||
}
|
||||
}
|
||||
|
||||
def load_markets(self):
|
||||
return self.markets
|
||||
|
||||
def market(self, sym):
|
||||
return self.markets[sym]
|
||||
|
||||
def price_to_precision(self, sym, price):
|
||||
return "12345.6"
|
||||
|
||||
from lib.hub.hub_ohlcv_lib import price_tick_from_market
|
||||
|
||||
tick = price_tick_from_market(_Ex(), "BTC/USDT:USDT")
|
||||
self.assertAlmostEqual(tick, 0.10)
|
||||
|
||||
def test_price_tick_from_info_tick_size(self):
|
||||
class _Ex:
|
||||
markets = {
|
||||
"INJ/USDT:USDT": {
|
||||
"precision": {"price": 4},
|
||||
"info": {"tickSize": "0.001"},
|
||||
"limits": {},
|
||||
}
|
||||
}
|
||||
|
||||
def load_markets(self):
|
||||
return self.markets
|
||||
|
||||
def market(self, sym):
|
||||
return self.markets[sym]
|
||||
|
||||
def price_to_precision(self, sym, price):
|
||||
return "7.123"
|
||||
|
||||
from lib.hub.hub_ohlcv_lib import price_tick_from_market
|
||||
|
||||
tick = price_tick_from_market(_Ex(), "INJ/USDT:USDT")
|
||||
self.assertAlmostEqual(tick, 0.001)
|
||||
|
||||
def test_full_fetch_without_since_paginates_okx_style(self):
|
||||
"""OKX 等无 since 单次约 300 根,须分页至 limit."""
|
||||
from lib.hub.hub_ohlcv_lib import TIMEFRAME_MS
|
||||
|
||||
step = TIMEFRAME_MS["1h"]
|
||||
want = 1000
|
||||
base = max(0, int(__import__("time").time() * 1000) - want * step)
|
||||
pages = [
|
||||
[[base + i * step, 1.0, 1.1, 0.9, 1.05, 100.0] for i in range(300)],
|
||||
[[base + (300 + i) * step, 2.0, 2.1, 1.9, 2.05, 200.0] for i in range(300)],
|
||||
[[base + (600 + i) * step, 3.0, 3.1, 2.9, 3.05, 300.0] for i in range(300)],
|
||||
[[base + (900 + i) * step, 4.0, 4.1, 3.9, 4.05, 400.0] for i in range(100)],
|
||||
]
|
||||
ex = _FakeExchange(pages)
|
||||
|
||||
out = fetch_ohlcv_for_hub(
|
||||
symbol="ONDO/USDT",
|
||||
timeframe="1h",
|
||||
since_ms=None,
|
||||
limit=want,
|
||||
normalize_symbol_input=lambda s: str(s).strip().upper(),
|
||||
normalize_exchange_symbol=lambda s: f"{s}:USDT" if ":" not in s else s,
|
||||
ensure_markets_loaded=lambda: None,
|
||||
exchange=ex,
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertEqual(len(out.get("bars") or []), 1000)
|
||||
self.assertGreaterEqual(len(ex.calls), 4)
|
||||
self.assertAlmostEqual(out["bars"][-1]["close"], 4.05)
|
||||
|
||||
def test_pagination_continues_when_page_smaller_than_chunk(self):
|
||||
"""Gate 等常返回 299 根/次,不应误判为已到末尾."""
|
||||
base = 1_700_000_000_000
|
||||
step = 4 * 60 * 60 * 1000
|
||||
page1 = [
|
||||
[base + i * step, 1.0, 1.1, 0.9, 1.05, 100.0] for i in range(299)
|
||||
]
|
||||
page2 = [
|
||||
[base + (299 + i) * step, 2.0, 2.1, 1.9, 2.05, 200.0] for i in range(299)
|
||||
]
|
||||
page3 = [
|
||||
[base + (598 + i) * step, 3.0, 3.1, 2.9, 3.05, 300.0] for i in range(50)
|
||||
]
|
||||
ex = _FakeExchange([page1, page2, page3])
|
||||
|
||||
out = fetch_ohlcv_for_hub(
|
||||
symbol="INJ/USDT",
|
||||
timeframe="4h",
|
||||
since_ms=base,
|
||||
limit=600,
|
||||
normalize_symbol_input=lambda s: str(s).strip().upper(),
|
||||
normalize_exchange_symbol=lambda s: f"{s}:USDT" if ":" not in s else s,
|
||||
ensure_markets_loaded=lambda: None,
|
||||
exchange=ex,
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertEqual(len(out.get("bars") or []), 600)
|
||||
self.assertGreaterEqual(len(ex.calls), 3)
|
||||
self.assertAlmostEqual(out["bars"][-1]["close"], 3.05)
|
||||
|
||||
def test_pagination_stops_when_next_since_reaches_now(self):
|
||||
"""Gate 等:分页 since 不得越过当前时间,避免 from>to."""
|
||||
from lib.hub.hub_ohlcv_lib import TIMEFRAME_MS
|
||||
|
||||
step = TIMEFRAME_MS["1d"]
|
||||
now_ms = int(__import__("time").time() * 1000)
|
||||
# 最后一页最后一根 K 的 next_since 将 >= now_ms,应停止不再请求
|
||||
last_open = ((now_ms // step) - 2) * step
|
||||
page = [
|
||||
[last_open - step, 1.0, 1.1, 0.9, 1.0, 10.0],
|
||||
[last_open, 1.1, 1.2, 1.0, 1.1, 11.0],
|
||||
]
|
||||
ex = _FakeExchange([page])
|
||||
|
||||
out = fetch_ohlcv_for_hub(
|
||||
symbol="ONDO/USDT",
|
||||
timeframe="1d",
|
||||
since_ms=last_open - step * 5,
|
||||
limit=10,
|
||||
normalize_symbol_input=lambda s: str(s).strip().upper(),
|
||||
normalize_exchange_symbol=lambda s: f"{s}:USDT" if ":" not in s else s,
|
||||
ensure_markets_loaded=lambda: None,
|
||||
exchange=ex,
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertGreaterEqual(len(out.get("bars") or []), 2)
|
||||
self.assertLessEqual(len(ex.calls), 4)
|
||||
|
||||
def test_aggregate_ohlcv_bars_buckets(self):
|
||||
from lib.hub.hub_ohlcv_lib import TIMEFRAME_MS
|
||||
|
||||
h1 = TIMEFRAME_MS["1h"]
|
||||
h4 = TIMEFRAME_MS["4h"]
|
||||
base = (1_700_000_000_000 // h4) * h4
|
||||
src = [
|
||||
{
|
||||
"open_time_ms": base + i * h1,
|
||||
"open": 1.0,
|
||||
"high": 2.0,
|
||||
"low": 0.5,
|
||||
"close": 1.5,
|
||||
"volume": 1.0,
|
||||
}
|
||||
for i in range(4)
|
||||
]
|
||||
out = aggregate_ohlcv_bars(src, "4h")
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0]["volume"], 4.0)
|
||||
self.assertEqual(out[0]["high"], 2.0)
|
||||
self.assertEqual(out[0]["low"], 0.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,53 @@
|
||||
from unittest import TestCase
|
||||
|
||||
from lib.hub.hub_options_funds_lib import (
|
||||
merge_board_row_balances,
|
||||
merge_perp_options_balances,
|
||||
options_balances_usdt_equiv,
|
||||
)
|
||||
|
||||
|
||||
class HubOptionsFundsLibTests(TestCase):
|
||||
def test_options_balances_usdt_equiv(self):
|
||||
snap = {
|
||||
"ok": True,
|
||||
"enabled": True,
|
||||
"balances": {"funding_usdc": 10, "trading_usdt": 5, "trading_usdc": 2},
|
||||
}
|
||||
out = options_balances_usdt_equiv(snap)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["funding_usdt"], 10.0)
|
||||
self.assertEqual(out["trading_usdt"], 7.0)
|
||||
|
||||
def test_merge_perp_options_balances(self):
|
||||
out = merge_perp_options_balances(
|
||||
100,
|
||||
50,
|
||||
{
|
||||
"ok": True,
|
||||
"enabled": True,
|
||||
"balances": {"funding_usdc": 8, "trading_usdc": 4},
|
||||
},
|
||||
)
|
||||
self.assertEqual(out["funding_usdt"], 108.0)
|
||||
self.assertEqual(out["trading_usdt"], 54.0)
|
||||
self.assertEqual(out["total_usdt"], 162.0)
|
||||
|
||||
def test_merge_board_row_balances(self):
|
||||
row = {
|
||||
"account_ok": True,
|
||||
"funding_usdt": 20,
|
||||
"trading_usdt": 30,
|
||||
"capabilities": ["options"],
|
||||
"options": {
|
||||
"ok": True,
|
||||
"enabled": True,
|
||||
"balances": {"funding_usdc": 1, "trading_usdc": 2},
|
||||
"positions": [{"inst_id": "X"}],
|
||||
"upl_total_usdc": 0.5,
|
||||
},
|
||||
}
|
||||
out = merge_board_row_balances(row)
|
||||
self.assertEqual(out["total_usdt"], 53.0)
|
||||
self.assertEqual(out["options_open_position_count"], 1)
|
||||
self.assertEqual(out["options_float_pnl_u"], 0.5)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""中控改委托同步与条件单按角色去重."""
|
||||
|
||||
from lib.hub.hub_order_sync_lib import (
|
||||
cond_order_role,
|
||||
dedupe_conditional_orders_by_role,
|
||||
exchange_tpsl_from_cond_orders,
|
||||
sync_active_monitor_tpsl_prices,
|
||||
)
|
||||
from lib.hub.hub_symbol_lib import symbols_match
|
||||
|
||||
|
||||
def test_cond_order_role():
|
||||
assert cond_order_role({"label": "止损 84.1"}) == "sl"
|
||||
assert cond_order_role({"label": "止盈 76"}) == "tp"
|
||||
assert cond_order_role({"label": "市价 买入"}) is None
|
||||
|
||||
|
||||
def test_dedupe_conditional_orders_by_role_keeps_one_sl():
|
||||
rows = [
|
||||
{"label": "止盈 76", "trigger_price": 76},
|
||||
{"label": "止损", "trigger_price": 84.1},
|
||||
{"label": "止损 84.1", "trigger_price": 84.1, "id": "x:sl"},
|
||||
]
|
||||
out = dedupe_conditional_orders_by_role(rows)
|
||||
assert len(out) == 2
|
||||
sl_rows = [r for r in out if cond_order_role(r) == "sl"]
|
||||
assert len(sl_rows) == 1
|
||||
assert sl_rows[0]["label"] == "止损 84.1"
|
||||
|
||||
|
||||
def test_exchange_tpsl_from_cond_orders():
|
||||
cond = [
|
||||
{"label": "止损 84.1", "trigger_price": 84.1, "algo_id": "1"},
|
||||
{"label": "止盈 76", "trigger_price": 76, "algo_id": "1"},
|
||||
]
|
||||
et = exchange_tpsl_from_cond_orders(cond)
|
||||
assert et["sl"]["trigger_price"] == 84.1
|
||||
assert et["tp"]["trigger_price"] == 76
|
||||
|
||||
|
||||
def test_sync_active_monitor_tpsl_prices_updates_matching_order():
|
||||
class Row(dict):
|
||||
def __getitem__(self, key):
|
||||
return dict.get(self, key)
|
||||
|
||||
class Conn:
|
||||
def __init__(self):
|
||||
self.rows = [
|
||||
Row(
|
||||
id=5,
|
||||
symbol="SOL/USDT:USDT",
|
||||
exchange_symbol="SOL/USDT:USDT",
|
||||
direction="short",
|
||||
)
|
||||
]
|
||||
self.updates = []
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
if "SELECT" in sql:
|
||||
return self
|
||||
if "UPDATE" in sql and params:
|
||||
self.updates.append(params)
|
||||
return self
|
||||
|
||||
def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
conn = Conn()
|
||||
out = sync_active_monitor_tpsl_prices(
|
||||
conn, "SOL/USDT:USDT", "short", 85.0, 75.0, symbols_match=symbols_match
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert out["updated"] == 1
|
||||
assert conn.updates == [(85.0, 75.0, 5)]
|
||||
@@ -0,0 +1,15 @@
|
||||
from lib.hub.hub_position_metrics import position_contracts
|
||||
|
||||
|
||||
def test_position_contracts_prefers_okx_info_pos_over_stale_ccxt():
|
||||
p = {
|
||||
"contracts": 0.81,
|
||||
"side": "short",
|
||||
"info": {"pos": "-1.62", "posSide": "short"},
|
||||
}
|
||||
assert position_contracts(p) == 1.62
|
||||
|
||||
|
||||
def test_position_contracts_falls_back_to_ccxt_contracts():
|
||||
p = {"contracts": 2.5, "info": {}}
|
||||
assert position_contracts(p) == 2.5
|
||||
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from lib.hub.hub_strategy_lib import (
|
||||
load_checklist,
|
||||
load_strategy_payload,
|
||||
strategy_meta_payload,
|
||||
build_export_html,
|
||||
build_print_html,
|
||||
)
|
||||
|
||||
|
||||
class TestHubStrategyLib(unittest.TestCase):
|
||||
def test_meta_has_three_exchanges(self):
|
||||
meta = strategy_meta_payload()
|
||||
keys = [x["key"] for x in meta["exchanges"]]
|
||||
self.assertEqual(keys, ["binance", "okx", "gate"])
|
||||
|
||||
def test_load_binance_payload(self):
|
||||
p = load_strategy_payload("binance")
|
||||
self.assertTrue(p["ok"])
|
||||
self.assertIn("strategy_html", p)
|
||||
self.assertIn("groups", p["checklist"])
|
||||
self.assertIn("<h2", p["strategy_html"].lower())
|
||||
|
||||
def test_checklist_files_valid_json(self):
|
||||
root = Path(__file__).resolve().parent.parent / "docs" / "strategy" / "checklists"
|
||||
for name in ("binance", "okx", "gate"):
|
||||
data = json.loads((root / f"{name}.json").read_text(encoding="utf-8"))
|
||||
self.assertTrue(data.get("groups"))
|
||||
|
||||
def test_export_html_contains_checklist(self):
|
||||
html = build_export_html("gate")
|
||||
self.assertIn("Gate", html)
|
||||
self.assertIn("☐", html)
|
||||
|
||||
def test_print_html_doc_and_checklist(self):
|
||||
doc = build_print_html("binance", "doc")
|
||||
cl = build_print_html("binance", "checklist")
|
||||
self.assertIn("window.print", doc)
|
||||
self.assertIn("window.print", cl)
|
||||
self.assertIn("币安", doc)
|
||||
self.assertIn("开仓检查清单", cl)
|
||||
self.assertIn("☐", cl)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,207 @@
|
||||
"""hub_supervisor_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import pytest
|
||||
except ImportError: # pragma: no cover
|
||||
import unittest
|
||||
|
||||
raise unittest.SkipTest("pytest not installed")
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
|
||||
|
||||
import hub_supervisor_lib as sup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_path(tmp_path, monkeypatch):
|
||||
p = tmp_path / "hub_supervisor_state.json"
|
||||
monkeypatch.setattr(sup, "STATE_PATH", p)
|
||||
return p
|
||||
|
||||
|
||||
def test_classify_close_result():
|
||||
assert sup.classify_close_result("手动平仓") == sup.EVENT_MANUAL_CLOSE
|
||||
assert sup.classify_close_result("强制清仓") == sup.EVENT_HUB_CLOSE
|
||||
assert sup.classify_close_result("止盈") == sup.EVENT_PROGRAM_TP
|
||||
assert sup.classify_close_result("止损") == sup.EVENT_PROGRAM_SL
|
||||
assert sup.classify_close_result("外部平仓") == sup.EVENT_EXTERNAL
|
||||
|
||||
|
||||
def test_detect_new_opens():
|
||||
prev = {"0|ETH/USDT|long": {"symbol": "ETH/USDT", "contracts": 1.0}}
|
||||
curr = {
|
||||
"0|ETH/USDT|long": {"symbol": "ETH/USDT", "contracts": 1.0},
|
||||
"1|BTC/USDT|short": {"symbol": "BTC/USDT", "contracts": 2.0, "exchange_name": "OKX"},
|
||||
}
|
||||
events = sup.detect_new_opens(prev, curr)
|
||||
assert len(events) == 1
|
||||
assert events[0]["event_type"] == sup.EVENT_OPEN
|
||||
assert events[0]["symbol"] == "BTC/USDT"
|
||||
|
||||
|
||||
def test_detect_new_opens_skips_existing_holdings():
|
||||
prev = {
|
||||
"2|ZEC/USDT|short": {"symbol": "ZEC/USDT:USDT", "contracts": 5.0},
|
||||
"2|HYPE/USDT|short": {"symbol": "HYPE/USDT:USDT", "contracts": 3.0},
|
||||
}
|
||||
curr = {
|
||||
"2|ZEC/USDT|short": {"symbol": "ZEC/USDT:USDT", "contracts": 5.0},
|
||||
"2|HYPE/USDT|short": {"symbol": "HYPE/USDT:USDT", "contracts": 3.0},
|
||||
}
|
||||
assert sup.detect_new_opens(prev, curr) == []
|
||||
|
||||
|
||||
def test_detect_new_opens_only_from_flat():
|
||||
prev = {"2|ZEC/USDT|short": {"symbol": "ZEC/USDT", "contracts": 0.0}}
|
||||
curr = {"2|ZEC/USDT|short": {"symbol": "ZEC/USDT:USDT", "contracts": 2.0}}
|
||||
events = sup.detect_new_opens(prev, curr)
|
||||
assert len(events) == 1
|
||||
assert events[0]["symbol"] == "ZEC/USDT:USDT"
|
||||
|
||||
|
||||
def test_normalize_position_symbol():
|
||||
assert sup._normalize_position_symbol("ZEC/USDT:USDT") == "ZEC/USDT"
|
||||
assert sup._position_key("2", "ZEC/USDT:USDT", "short") == "2|ZEC/USDT|short"
|
||||
|
||||
|
||||
def test_detect_new_closes_dedup():
|
||||
trades = [
|
||||
{
|
||||
"account_name": "OKX",
|
||||
"symbol": "ETH/USDT",
|
||||
"result": "手动平仓",
|
||||
"pnl_amount": -5,
|
||||
"closed_at": "2026-06-14 10:00:00",
|
||||
}
|
||||
]
|
||||
eid = f"close:{sup._trade_event_id(trades[0])}"
|
||||
events = sup.detect_new_closes(set(), trades)
|
||||
assert len(events) == 1
|
||||
assert events[0]["event_type"] == sup.EVENT_MANUAL_CLOSE
|
||||
assert sup.detect_new_closes({eid}, trades) == []
|
||||
|
||||
|
||||
def test_evaluate_frequency_warnings_interval():
|
||||
stats = {
|
||||
"2026-06-14": {
|
||||
"supervised_closes": [
|
||||
{"closed_at": "2026-06-14 09:50:00", "pnl_amount": -1},
|
||||
],
|
||||
"supervised_opens": [],
|
||||
}
|
||||
}
|
||||
event = {
|
||||
"event_type": sup.EVENT_MANUAL_CLOSE,
|
||||
"closed_at": "2026-06-14 10:00:00",
|
||||
"pnl_amount": -2,
|
||||
}
|
||||
settings = sup.normalize_supervisor_settings({})
|
||||
warnings = sup.evaluate_frequency_warnings(
|
||||
trading_day="2026-06-14",
|
||||
event=event,
|
||||
stats=stats,
|
||||
settings=settings,
|
||||
)
|
||||
rules = {w["rule"] for w in warnings}
|
||||
assert "INTERVAL_SHORT" in rules
|
||||
|
||||
|
||||
def test_process_supervisor_tick_seeds_without_events(state_path, monkeypatch, tmp_path):
|
||||
chat_path = tmp_path / "hub_ai_chat.json"
|
||||
monkeypatch.setattr("hub_ai.store.CHAT_PATH", chat_path)
|
||||
|
||||
dash = {
|
||||
"ok": True,
|
||||
"trading_day": "2026-06-14",
|
||||
"closed_trades": [
|
||||
{
|
||||
"account_name": "Binance",
|
||||
"symbol": "ETH/USDT",
|
||||
"result": "手动平仓",
|
||||
"pnl_amount": 1,
|
||||
"closed_at": "2026-06-14 08:30:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
board = {
|
||||
"ok": True,
|
||||
"rows": [{"id": "0", "enabled": True, "agent": {"ok": True, "positions": []}}],
|
||||
}
|
||||
settings = {"supervisor": sup.normalize_supervisor_settings({"enabled": True, "wechat_webhook": ""})}
|
||||
|
||||
r1 = sup.process_supervisor_tick(dash, board, settings, ai_reply_fn=None)
|
||||
assert r1.get("seeded") is True
|
||||
assert r1.get("events") == 0
|
||||
|
||||
r2 = sup.process_supervisor_tick(dash, board, settings, ai_reply_fn=None)
|
||||
assert r2.get("events") == 0
|
||||
|
||||
board2 = {
|
||||
"ok": True,
|
||||
"rows": [
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Gate",
|
||||
"enabled": True,
|
||||
"agent": {
|
||||
"ok": True,
|
||||
"positions": [
|
||||
{"symbol": "ZEC/USDT:USDT", "side": "short", "contracts": 1.0},
|
||||
{"symbol": "HYPE/USDT:USDT", "side": "short", "contracts": 1.0},
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
r3 = sup.process_supervisor_tick(dash, board2, settings, ai_reply_fn=None)
|
||||
assert r3.get("events") == 0
|
||||
|
||||
dash2 = dict(dash)
|
||||
dash2["closed_trades"] = dash["closed_trades"] + [
|
||||
{
|
||||
"account_name": "Binance",
|
||||
"symbol": "BTC/USDT",
|
||||
"result": "手动平仓",
|
||||
"pnl_amount": -3,
|
||||
"closed_at": "2026-06-14 11:00:00",
|
||||
}
|
||||
]
|
||||
r4 = sup.process_supervisor_tick(dash2, board2, settings, ai_reply_fn=None)
|
||||
assert r4.get("events") == 1
|
||||
|
||||
|
||||
def test_normalize_supervisor_settings_env(monkeypatch):
|
||||
monkeypatch.setenv("SUPERVISOR_WECHAT_WEBHOOK", "https://example.com/hook")
|
||||
monkeypatch.setenv("SUPERVISOR_WECHAT_LINK", "https://hub.example/ai?mode=supervisor")
|
||||
cfg = sup.normalize_supervisor_settings({})
|
||||
assert cfg["wechat_webhook"] == "https://example.com/hook"
|
||||
assert cfg["wechat_link_base"] == "https://hub.example/ai?mode=supervisor"
|
||||
|
||||
|
||||
def test_supervisor_fallback_reply_program_sl():
|
||||
text = sup.build_supervisor_fallback_reply(
|
||||
{
|
||||
"event_type": sup.EVENT_PROGRAM_SL,
|
||||
"symbol": "ZEC/USDT",
|
||||
"pnl_amount": -0.9557,
|
||||
}
|
||||
)
|
||||
assert "程序止损" in text
|
||||
assert "AI 生成失败" not in text
|
||||
|
||||
|
||||
def test_supervisor_fallback_not_error_reply():
|
||||
from hub_ai.text_util import is_ai_error_reply
|
||||
|
||||
text = sup.build_supervisor_fallback_reply(
|
||||
{"event_type": sup.EVENT_MANUAL_CLOSE, "symbol": "ETH/USDT", "pnl_amount": -1}
|
||||
)
|
||||
assert text
|
||||
assert not is_ai_error_reply(text)
|
||||
@@ -0,0 +1,348 @@
|
||||
"""币种档案库:5m 聚合与视窗计算."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from lib.hub.hub_ohlcv_lib import aggregate_ohlcv_bars
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.hub.hub_symbol_archive_lib import (
|
||||
CHART_DISPLAY_TZ,
|
||||
_compute_period_stats,
|
||||
_fill_missing_bars,
|
||||
init_db,
|
||||
list_daily_trades,
|
||||
load_symbol_trades,
|
||||
ms_to_wall_clock_str,
|
||||
parse_wall_clock_ms,
|
||||
resolve_archive_chart,
|
||||
trading_day_bounds_ms,
|
||||
upsert_bars_5m,
|
||||
upsert_trade_overlay,
|
||||
list_symbol_rows,
|
||||
upsert_trades_cache,
|
||||
)
|
||||
|
||||
|
||||
def _seed_5m_bars(
|
||||
db: Path,
|
||||
start_ms: int,
|
||||
count: int,
|
||||
step: int = 300_000,
|
||||
*,
|
||||
ex: str = "gate",
|
||||
sym: str = "ONDO",
|
||||
) -> None:
|
||||
bars = []
|
||||
price = 1.0
|
||||
for i in range(count):
|
||||
o = start_ms + i * step
|
||||
price += 0.001
|
||||
bars.append(
|
||||
{
|
||||
"open_time_ms": o,
|
||||
"open": price,
|
||||
"high": price + 0.002,
|
||||
"low": price - 0.001,
|
||||
"close": price + 0.001,
|
||||
"volume": 100 + i,
|
||||
}
|
||||
)
|
||||
upsert_bars_5m(ex, sym, bars, db_path=db)
|
||||
|
||||
|
||||
def test_aggregate_15m_from_5m():
|
||||
start = 1_700_000_000_000
|
||||
bars = []
|
||||
for i in range(6):
|
||||
t = start + i * 300_000
|
||||
bars.append(
|
||||
{
|
||||
"open_time_ms": t,
|
||||
"open": 1.0,
|
||||
"high": 1.1,
|
||||
"low": 0.9,
|
||||
"close": 1.05,
|
||||
"volume": 10,
|
||||
}
|
||||
)
|
||||
agg = aggregate_ohlcv_bars(bars, "15m")
|
||||
assert len(agg) >= 1
|
||||
assert agg[-1]["close"] == bars[-1]["close"]
|
||||
assert agg[0]["open_time_ms"] <= agg[1]["open_time_ms"]
|
||||
|
||||
|
||||
def test_resolve_archive_chart_15m():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "archive.db"
|
||||
init_db(db)
|
||||
anchor = 1_700_000_000_000
|
||||
_seed_5m_bars(db, anchor - 50 * 300_000, 120)
|
||||
out = resolve_archive_chart(
|
||||
"gate",
|
||||
"ONDO",
|
||||
"15m",
|
||||
anchor_ms=anchor,
|
||||
mode="hold",
|
||||
bars=40,
|
||||
db_path=db,
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert out["timeframe"] == "15m"
|
||||
assert len(out["candles"]) >= 10
|
||||
|
||||
|
||||
def test_fill_missing_bars_continuity():
|
||||
period = 300_000
|
||||
start = (1_700_000_000_000 // period) * period
|
||||
bars = [
|
||||
{
|
||||
"open_time_ms": start,
|
||||
"open": 1.0,
|
||||
"high": 1.1,
|
||||
"low": 0.9,
|
||||
"close": 1.05,
|
||||
"volume": 10,
|
||||
},
|
||||
{
|
||||
"open_time_ms": start + period * 2,
|
||||
"open": 1.05,
|
||||
"high": 1.15,
|
||||
"low": 1.0,
|
||||
"close": 1.1,
|
||||
"volume": 8,
|
||||
},
|
||||
]
|
||||
filled = _fill_missing_bars(bars, period, start, start + period * 2)
|
||||
assert len(filled) >= 3
|
||||
assert any(b.get("filled") for b in filled)
|
||||
|
||||
|
||||
def test_resolve_archive_chart_history_range():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "archive.db"
|
||||
init_db(db)
|
||||
open_ms = 1_700_000_000_000
|
||||
close_ms = open_ms + 6 * 3600_000
|
||||
_seed_5m_bars(db, open_ms - 20 * 300_000, 200, ex="gate", sym="BNB/USDT")
|
||||
out = resolve_archive_chart(
|
||||
"gate",
|
||||
"BNB/USDT",
|
||||
"15m",
|
||||
opened_ms=open_ms,
|
||||
closed_ms=close_ms,
|
||||
mode="hold",
|
||||
range_mode="history",
|
||||
db_path=db,
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert out.get("range_mode") == "history"
|
||||
assert out.get("window_end_ms") <= close_ms + 4 * 3600_000
|
||||
assert len(out["candles"]) >= 40
|
||||
|
||||
|
||||
def test_sync_prunes_missing_trades():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "archive.db"
|
||||
init_db(db)
|
||||
upsert_trades_cache(
|
||||
"gate",
|
||||
[
|
||||
{"id": 1, "symbol": "BNB/USDT", "result": "止损", "pnl_amount": -1},
|
||||
{"id": 2, "symbol": "BNB/USDT", "result": "止盈", "pnl_amount": 1},
|
||||
],
|
||||
db_path=db,
|
||||
prune_missing=False,
|
||||
)
|
||||
stats = upsert_trades_cache(
|
||||
"gate",
|
||||
[{"id": 1, "symbol": "BNB/USDT", "result": "止损", "pnl_amount": -1}],
|
||||
db_path=db,
|
||||
prune_missing=True,
|
||||
)
|
||||
rows = load_symbol_trades("gate", "BNB/USDT", db_path=db)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["trade_id"] == 1
|
||||
assert stats["removed"] == 1
|
||||
|
||||
|
||||
def test_list_with_overlay_filters():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "archive.db"
|
||||
init_db(db)
|
||||
upsert_trades_cache(
|
||||
"gate",
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"symbol": "ONDO",
|
||||
"direction": "long",
|
||||
"result": "止盈",
|
||||
"pnl_amount": 12.5,
|
||||
"opened_at": "2026-01-01 10:00:00",
|
||||
"closed_at": "2026-01-01 12:00:00",
|
||||
"opened_at_ms": 1_700_000_000_000,
|
||||
"closed_at_ms": 1_700_007_200_000,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"symbol": "ONDO",
|
||||
"direction": "short",
|
||||
"result": "止损",
|
||||
"pnl_amount": -3.2,
|
||||
"opened_at": "2026-01-02 10:00:00",
|
||||
"closed_at": "2026-01-02 11:00:00",
|
||||
"opened_at_ms": 1_700_086_400_000,
|
||||
"closed_at_ms": 1_700_090_000_000,
|
||||
},
|
||||
],
|
||||
db_path=db,
|
||||
)
|
||||
upsert_trade_overlay("gate", 2, behavior_tag="sick", note="追高", db_path=db)
|
||||
rows = list_symbol_rows(db_path=db)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["trade_count"] == 2
|
||||
sick_only = list_symbol_rows(filter_sick=True, db_path=db)
|
||||
assert len(sick_only) == 1
|
||||
profit_only = list_symbol_rows(filter_profit=True, db_path=db)
|
||||
assert len(profit_only) == 1
|
||||
|
||||
|
||||
def test_parse_wall_clock_ms_uses_utc_plus_8():
|
||||
ms = parse_wall_clock_ms("2026-06-07 20:30:00")
|
||||
assert ms is not None
|
||||
dt_utc = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc)
|
||||
dt_bj = dt_utc.astimezone(CHART_DISPLAY_TZ)
|
||||
assert dt_bj.strftime("%Y-%m-%d %H:%M:%S") == "2026-06-07 20:30:00"
|
||||
assert ms_to_wall_clock_str(ms) == "2026-06-07 20:30:00"
|
||||
assert parse_wall_clock_ms("2026-06-07 20:30") == ms
|
||||
|
||||
|
||||
def test_parse_wall_clock_ms_accepts_epoch_strings():
|
||||
ms = 1_700_000_000_000
|
||||
assert parse_wall_clock_ms(str(ms)) == ms
|
||||
assert parse_wall_clock_ms(str(ms // 1000)) == ms
|
||||
|
||||
|
||||
def test_resolve_archive_chart_history_uses_trade_span_not_200_bars():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "archive.db"
|
||||
init_db(db)
|
||||
opened = 1_700_000_000_000
|
||||
closed = opened + 20 * 24 * 3600_000
|
||||
_seed_5m_bars(db, opened - 35 * 24 * 3600_000, 40 * 24 * 12)
|
||||
out = resolve_archive_chart(
|
||||
"gate",
|
||||
"ONDO",
|
||||
"15m",
|
||||
opened_ms=opened,
|
||||
closed_ms=closed,
|
||||
mode="hold",
|
||||
bars=200,
|
||||
range_mode="history",
|
||||
db_path=db,
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert out["range_mode"] == "history"
|
||||
assert out["bar_count"] > 200
|
||||
|
||||
|
||||
def test_upsert_forces_sync_exchange_key():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "archive.db"
|
||||
init_db(db)
|
||||
upsert_trades_cache(
|
||||
"gate",
|
||||
[
|
||||
{
|
||||
"id": 77,
|
||||
"exchange_key": "gate",
|
||||
"account_exchange_key": "gate",
|
||||
"symbol": "ETH/USDT",
|
||||
"result": "止损",
|
||||
"pnl_amount": -1,
|
||||
"opened_at_ms": 1_700_000_000_000,
|
||||
"closed_at_ms": 1_700_007_200_000,
|
||||
}
|
||||
],
|
||||
db_path=db,
|
||||
)
|
||||
rows = load_symbol_trades("gate", "ETH/USDT", db_path=db)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["exchange_key"] == "gate"
|
||||
assert "account_exchange_key" not in rows[0]
|
||||
|
||||
|
||||
def test_compute_period_stats_win_loss_metrics():
|
||||
rows = [
|
||||
{"exchange_key": "binance", "pnl_amount": 10.0, "behavior_tag": ""},
|
||||
{"exchange_key": "binance", "pnl_amount": 4.0, "behavior_tag": ""},
|
||||
{"exchange_key": "okx", "pnl_amount": -3.0, "behavior_tag": "sick"},
|
||||
{"exchange_key": "okx", "pnl_amount": -6.0, "behavior_tag": ""},
|
||||
]
|
||||
st = _compute_period_stats(rows)
|
||||
assert st["open_count"] == 4
|
||||
assert st["win_count"] == 2
|
||||
assert st["loss_count"] == 2
|
||||
assert st["avg_win"] == 7.0
|
||||
assert st["avg_loss"] == -4.5
|
||||
assert st["max_win"] == 10.0
|
||||
assert st["max_loss"] == -6.0
|
||||
assert st["win_rate"] == 50.0
|
||||
assert st["profit_loss_ratio"] == round(7.0 / 4.5, 2)
|
||||
assert st["sick_count"] == 1
|
||||
assert st["pnl_total"] == 5.0
|
||||
assert st["pnl_ex_sick"] == 8.0
|
||||
assert st["by_exchange"]["binance"]["win_count"] == 2
|
||||
assert st["by_exchange"]["binance"]["win_rate"] == 100.0
|
||||
assert st["by_exchange"]["binance"]["profit_loss_ratio"] is None
|
||||
|
||||
|
||||
def test_list_daily_trades_search_filters_stats():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "archive.db"
|
||||
init_db(db)
|
||||
day = "2023-11-15"
|
||||
start_ms, _ = trading_day_bounds_ms(day)
|
||||
btc_close = start_ms + 3_600_000
|
||||
eth_close = start_ms + 7_200_000
|
||||
upsert_trades_cache(
|
||||
"gate",
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"symbol": "BTC/USDT",
|
||||
"result": "止盈",
|
||||
"pnl_amount": 5.0,
|
||||
"opened_at_ms": start_ms,
|
||||
"closed_at_ms": btc_close,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"symbol": "ETH/USDT",
|
||||
"result": "止损",
|
||||
"pnl_amount": -2.0,
|
||||
"opened_at_ms": btc_close,
|
||||
"closed_at_ms": eth_close,
|
||||
},
|
||||
],
|
||||
db_path=db,
|
||||
)
|
||||
payload = list_daily_trades(
|
||||
period="range",
|
||||
date_from=day,
|
||||
date_to=day,
|
||||
search="btc",
|
||||
db_path=db,
|
||||
)
|
||||
assert len(payload["trades"]) == 1
|
||||
assert payload["trades"][0]["symbol"] == "BTC/USDT"
|
||||
st = payload["stats"]
|
||||
assert st["open_count"] == 1
|
||||
assert st["win_count"] == 1
|
||||
assert st["loss_count"] == 0
|
||||
assert st["max_win"] == 5.0
|
||||
assert st["pnl_total"] == 5.0
|
||||
@@ -0,0 +1,96 @@
|
||||
"""hub_system_logs_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib.hub import hub_system_logs_lib as logs_lib
|
||||
from lib.hub.hub_system_logs_lib import (
|
||||
load_system_logs,
|
||||
resolve_log_paths,
|
||||
system_logs_meta,
|
||||
tail_lines,
|
||||
)
|
||||
|
||||
|
||||
class HubSystemLogsLibTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
logs_lib._path_cache.clear()
|
||||
logs_lib._path_cache_at = 0.0
|
||||
|
||||
def test_system_logs_meta(self):
|
||||
meta = system_logs_meta()
|
||||
self.assertTrue(meta["ok"])
|
||||
keys = [t["key"] for t in meta["targets"]]
|
||||
self.assertEqual(keys, ["binance", "gate", "okx", "hub"])
|
||||
|
||||
def test_tail_lines_reads_last_lines(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "demo-out.log"
|
||||
path.write_text("\n".join(f"line-{i}" for i in range(1, 11)), encoding="utf-8")
|
||||
out = tail_lines(path, lines=3)
|
||||
self.assertEqual(out.splitlines(), ["line-8", "line-9", "line-10"])
|
||||
|
||||
def test_resolve_log_paths_from_pm2_jlist(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
logs_dir = Path(tmp)
|
||||
out_file = logs_dir / "crypto-binance-out-0.log"
|
||||
err_file = logs_dir / "crypto-binance-error-0.log"
|
||||
out_file.write_text("stdout line", encoding="utf-8")
|
||||
err_file.write_text("stderr line", encoding="utf-8")
|
||||
payload = [
|
||||
{
|
||||
"name": "crypto_binance",
|
||||
"pm2_env": {
|
||||
"pm_out_log_path": str(out_file),
|
||||
"pm_err_log_path": str(err_file),
|
||||
},
|
||||
}
|
||||
]
|
||||
with patch.object(logs_lib, "_pm2_jlist", return_value=payload):
|
||||
out_path, err_path = resolve_log_paths("crypto_binance")
|
||||
self.assertEqual(out_path, out_file)
|
||||
self.assertEqual(err_path, err_file)
|
||||
|
||||
def test_resolve_log_paths_glob_fallback(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
logs_dir = Path(tmp)
|
||||
out_file = logs_dir / "crypto-gate-out-1.log"
|
||||
err_file = logs_dir / "crypto-gate-error-1.log"
|
||||
out_file.write_text("gate out", encoding="utf-8")
|
||||
err_file.write_text("gate err", encoding="utf-8")
|
||||
with patch.object(logs_lib, "pm2_logs_dir", return_value=logs_dir):
|
||||
with patch.object(logs_lib, "_pm2_jlist", return_value=[]):
|
||||
out_path, err_path = resolve_log_paths("crypto_gate")
|
||||
self.assertEqual(out_path, out_file)
|
||||
self.assertEqual(err_path, err_file)
|
||||
|
||||
def test_load_system_logs_unknown(self):
|
||||
with self.assertRaises(KeyError):
|
||||
load_system_logs("unknown")
|
||||
|
||||
def test_load_system_logs_with_resolved_paths(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
logs_dir = Path(tmp)
|
||||
out_file = logs_dir / "manual-trading-hub-out-6.log"
|
||||
err_file = logs_dir / "manual-trading-hub-error-6.log"
|
||||
out_file.write_text("hub stdout", encoding="utf-8")
|
||||
err_file.write_text("hub stderr", encoding="utf-8")
|
||||
payload = [
|
||||
{
|
||||
"name": "manual-trading-hub",
|
||||
"pm2_env": {
|
||||
"pm_out_log_path": str(out_file),
|
||||
"pm_err_log_path": str(err_file),
|
||||
},
|
||||
}
|
||||
]
|
||||
with patch.object(logs_lib, "_pm2_jlist", return_value=payload):
|
||||
data = load_system_logs("hub", lines=50)
|
||||
self.assertTrue(data["ok"])
|
||||
self.assertIn("hub stdout", data["out"])
|
||||
self.assertIn("hub stderr", data["err"])
|
||||
self.assertTrue(data["out_exists"])
|
||||
self.assertTrue(data["err_exists"])
|
||||
@@ -0,0 +1,102 @@
|
||||
"""档案交易:strategy_trade_snapshots 补全 gate 漏记."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from lib.hub.hub_trades_lib import fetch_trades_for_archive
|
||||
|
||||
|
||||
def _init_db(path: Path) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE trade_records (
|
||||
id INTEGER PRIMARY KEY,
|
||||
symbol TEXT,
|
||||
direction TEXT,
|
||||
result TEXT,
|
||||
pnl_amount REAL,
|
||||
opened_at TEXT,
|
||||
closed_at TEXT,
|
||||
opened_at_ms INTEGER,
|
||||
closed_at_ms INTEGER,
|
||||
created_at TEXT,
|
||||
trend_plan_id INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE strategy_trade_snapshots (
|
||||
id INTEGER PRIMARY KEY,
|
||||
strategy_type TEXT,
|
||||
source_id INTEGER,
|
||||
symbol TEXT,
|
||||
direction TEXT,
|
||||
result_label TEXT,
|
||||
status_at_close TEXT,
|
||||
opened_at TEXT,
|
||||
closed_at TEXT,
|
||||
pnl_amount REAL,
|
||||
snapshot_json TEXT,
|
||||
created_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
def test_merge_snapshot_when_trade_record_missing():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
conn = _init_db(Path(td) / "t.db")
|
||||
closed = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO strategy_trade_snapshots (
|
||||
id, strategy_type, source_id, symbol, direction,
|
||||
result_label, opened_at, closed_at, pnl_amount, snapshot_json, created_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(7, "trend_pullback", 42, "ONDO/USDT", "long", "止损", closed, closed, -1.2, "{}", closed),
|
||||
)
|
||||
conn.commit()
|
||||
trades = fetch_trades_for_archive(conn, days=30, limit=50)
|
||||
conn.close()
|
||||
assert len(trades) == 1
|
||||
assert trades[0]["symbol"] == "ONDO/USDT"
|
||||
assert trades[0]["id"] == -7
|
||||
assert trades[0].get("from_snapshot") is True
|
||||
|
||||
|
||||
def test_skip_snapshot_when_trade_record_exists():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
conn = _init_db(Path(td) / "t.db")
|
||||
closed = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO trade_records (
|
||||
id, symbol, direction, result, pnl_amount,
|
||||
opened_at, closed_at, opened_at_ms, closed_at_ms, created_at, trend_plan_id
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(1, "ONDO/USDT", "long", "止损", -1.2, closed, closed, 1, 2, closed, 42),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO strategy_trade_snapshots (
|
||||
id, strategy_type, source_id, symbol, direction,
|
||||
result_label, opened_at, closed_at, pnl_amount, snapshot_json, created_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(7, "trend_pullback", 42, "ONDO/USDT", "long", "止损", closed, closed, -1.2, "{}", closed),
|
||||
)
|
||||
conn.commit()
|
||||
trades = fetch_trades_for_archive(conn, days=30, limit=50)
|
||||
conn.close()
|
||||
assert len(trades) == 1
|
||||
assert trades[0]["id"] == 1
|
||||
@@ -0,0 +1,229 @@
|
||||
"""hub_trades_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from lib.hub.hub_trades_lib import (
|
||||
attach_journal_mood_tags,
|
||||
fetch_trades_for_trading_day,
|
||||
journal_trade_match_key,
|
||||
summarize_trades,
|
||||
trading_day_from_dt,
|
||||
trading_day_window_bounds,
|
||||
)
|
||||
|
||||
|
||||
class HubTradesLibTest(unittest.TestCase):
|
||||
def test_trading_day_reset(self):
|
||||
dt = datetime(2026, 6, 6, 7, 30, 0)
|
||||
self.assertEqual(trading_day_from_dt(dt, 8), "2026-06-05")
|
||||
dt2 = datetime(2026, 6, 6, 8, 0, 0)
|
||||
self.assertEqual(trading_day_from_dt(dt2, 8), "2026-06-06")
|
||||
|
||||
def test_trading_day_window_bounds(self):
|
||||
start, end = trading_day_window_bounds("2026-06-06", 8)
|
||||
self.assertEqual(start, "2026-06-06 08:00:00")
|
||||
self.assertEqual(end, "2026-06-07 07:59:59")
|
||||
|
||||
def test_fetch_and_summarize(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""CREATE TABLE trade_records (
|
||||
symbol TEXT, direction TEXT, result TEXT, reviewed_result TEXT,
|
||||
pnl_amount REAL, reviewed_pnl_amount REAL, exchange_realized_pnl REAL,
|
||||
closed_at TEXT, reviewed_closed_at TEXT, opened_at TEXT, reviewed_opened_at TEXT,
|
||||
created_at TEXT, monitor_type TEXT, actual_rr REAL, planned_rr REAL,
|
||||
trade_style TEXT, entry_reason TEXT, reviewed_at TEXT
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
"ONDO/USDT",
|
||||
"short",
|
||||
"止损",
|
||||
None,
|
||||
-0.5,
|
||||
None,
|
||||
None,
|
||||
"2026-06-06 10:00:00",
|
||||
None,
|
||||
"2026-06-06 09:00:00",
|
||||
None,
|
||||
"2026-06-06 10:00:00",
|
||||
"趋势回调",
|
||||
None,
|
||||
None,
|
||||
"trend",
|
||||
"",
|
||||
None,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
rows = fetch_trades_for_trading_day(conn, "2026-06-06")
|
||||
self.assertEqual(len(rows), 1)
|
||||
stats = summarize_trades(rows)
|
||||
self.assertEqual(stats["closed_count"], 1)
|
||||
self.assertEqual(stats["loss_count"], 1)
|
||||
self.assertAlmostEqual(stats["total_pnl_u"], -0.5)
|
||||
conn.close()
|
||||
|
||||
def test_early_morning_belongs_prev_trading_day(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""CREATE TABLE trade_records (
|
||||
symbol TEXT, direction TEXT, result TEXT, reviewed_result TEXT,
|
||||
pnl_amount REAL, reviewed_pnl_amount REAL, exchange_realized_pnl REAL,
|
||||
closed_at TEXT, reviewed_closed_at TEXT, opened_at TEXT, reviewed_opened_at TEXT,
|
||||
created_at TEXT, monitor_type TEXT, actual_rr REAL, planned_rr REAL,
|
||||
trade_style TEXT, entry_reason TEXT, reviewed_at TEXT
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
"BTC/USDT",
|
||||
"long",
|
||||
"止盈",
|
||||
None,
|
||||
1.2,
|
||||
None,
|
||||
None,
|
||||
"2026-06-07 07:30:00",
|
||||
None,
|
||||
"2026-06-07 06:00:00",
|
||||
None,
|
||||
"2026-06-07 07:30:00",
|
||||
"关键位",
|
||||
None,
|
||||
None,
|
||||
"trend",
|
||||
"",
|
||||
None,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
self.assertEqual(len(fetch_trades_for_trading_day(conn, "2026-06-07")), 0)
|
||||
self.assertEqual(len(fetch_trades_for_trading_day(conn, "2026-06-06")), 1)
|
||||
conn.close()
|
||||
|
||||
def test_reviewed_fields_preferred(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""CREATE TABLE trade_records (
|
||||
symbol TEXT, direction TEXT, result TEXT, reviewed_result TEXT,
|
||||
pnl_amount REAL, reviewed_pnl_amount REAL, exchange_realized_pnl REAL,
|
||||
closed_at TEXT, reviewed_closed_at TEXT, opened_at TEXT, reviewed_opened_at TEXT,
|
||||
created_at TEXT, monitor_type TEXT, actual_rr REAL, planned_rr REAL,
|
||||
trade_style TEXT, entry_reason TEXT, reviewed_at TEXT
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
"ETH/USDT",
|
||||
"long",
|
||||
"止损",
|
||||
"止盈",
|
||||
-0.5,
|
||||
2.0,
|
||||
None,
|
||||
"2026-06-06 09:00:00",
|
||||
"2026-06-06 11:00:00",
|
||||
"2026-06-06 08:00:00",
|
||||
None,
|
||||
"2026-06-06 11:00:00",
|
||||
"趋势回调",
|
||||
None,
|
||||
None,
|
||||
"trend",
|
||||
"",
|
||||
"2026-06-06 12:00:00",
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
rows = fetch_trades_for_trading_day(conn, "2026-06-06")
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["result"], "止盈")
|
||||
self.assertAlmostEqual(rows[0]["pnl_amount"], 2.0)
|
||||
self.assertTrue(rows[0]["reviewed"])
|
||||
conn.close()
|
||||
|
||||
def test_time_close_result_included(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""CREATE TABLE trade_records (
|
||||
symbol TEXT, direction TEXT, result TEXT, reviewed_result TEXT,
|
||||
pnl_amount REAL, reviewed_pnl_amount REAL, exchange_realized_pnl REAL,
|
||||
closed_at TEXT, reviewed_closed_at TEXT, opened_at TEXT, reviewed_opened_at TEXT,
|
||||
created_at TEXT, monitor_type TEXT, actual_rr REAL, planned_rr REAL,
|
||||
trade_style TEXT, entry_reason TEXT, reviewed_at TEXT
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO trade_records VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
"BTC/USDT",
|
||||
"long",
|
||||
"时间平仓",
|
||||
None,
|
||||
1.2,
|
||||
None,
|
||||
None,
|
||||
"2026-06-06 12:00:00",
|
||||
None,
|
||||
"2026-06-06 08:00:00",
|
||||
None,
|
||||
"2026-06-06 12:00:00",
|
||||
"趋势回调",
|
||||
None,
|
||||
None,
|
||||
"trend",
|
||||
"",
|
||||
None,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
rows = fetch_trades_for_trading_day(conn, "2026-06-06")
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["result"], "时间平仓")
|
||||
conn.close()
|
||||
|
||||
def test_attach_journal_mood_tags_marks_sick(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""CREATE TABLE journal_entries (
|
||||
coin TEXT, open_datetime TEXT, close_datetime TEXT, mood_issues TEXT, created_at TEXT
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO journal_entries VALUES (?,?,?,?,?)",
|
||||
("ETH", "2026-07-06 21:51", "2026-07-07 00:00", "报复开仓,扛单", "2026-07-07 00:05"),
|
||||
)
|
||||
conn.commit()
|
||||
trades = [
|
||||
{
|
||||
"id": 42,
|
||||
"symbol": "ETH/USDT",
|
||||
"opened_at": "2026-07-06 21:51:00",
|
||||
"closed_at": "2026-07-07 00:00:00",
|
||||
}
|
||||
]
|
||||
attach_journal_mood_tags(conn, trades, cutoff_s="2026-01-01 00:00:00")
|
||||
self.assertTrue(trades[0]["journal_mood_sick"])
|
||||
self.assertEqual(trades[0]["behavior_tag"], "sick")
|
||||
self.assertEqual(trades[0]["journal_mood_issues"], ["报复开仓", "扛单"])
|
||||
key = journal_trade_match_key("ETH/USDT", "2026-07-06 21:51:00", "2026-07-07 00:00:00")
|
||||
self.assertEqual(key, ("ETH", "2026-07-06 21:51", "2026-07-07 00:00"))
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,115 @@
|
||||
"""档案交易:复盘字段优先(开仓类型,持仓时长,开平仓时间)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from lib.hub.hub_symbol_archive_lib import init_db, load_symbol_trades, upsert_trades_cache
|
||||
from lib.hub.hub_trades_lib import (
|
||||
_normalize_archive_trade_row,
|
||||
display_entry_type_label,
|
||||
effective_entry_type,
|
||||
effective_hold_minutes,
|
||||
)
|
||||
|
||||
|
||||
class TestHubTradesReviewFields(unittest.TestCase):
|
||||
def test_display_entry_type_for_manual_monitor_review(self):
|
||||
d = {
|
||||
"monitor_type": "下单监控",
|
||||
"entry_reason": "",
|
||||
"reviewed_entry_reason": "突破回踩",
|
||||
"reviewed_at": "2026-06-08 10:00:00",
|
||||
}
|
||||
self.assertEqual(display_entry_type_label(d), "突破回踩")
|
||||
|
||||
def test_effective_entry_type_prefers_reviewed(self):
|
||||
d = {
|
||||
"entry_reason": "突破回踩",
|
||||
"reviewed_entry_reason": "趋势回调",
|
||||
"monitor_type": "下单监控",
|
||||
}
|
||||
self.assertEqual(effective_entry_type(d), "趋势回调")
|
||||
|
||||
def test_effective_hold_minutes_prefers_reviewed(self):
|
||||
d = {
|
||||
"hold_minutes": 30,
|
||||
"reviewed_hold_minutes": 95,
|
||||
"opened_at_ms": 1_700_000_000_000,
|
||||
"closed_at_ms": 1_700_001_800_000,
|
||||
}
|
||||
self.assertEqual(effective_hold_minutes(d), 95)
|
||||
|
||||
def test_normalize_archive_trade_row_review_fields(self):
|
||||
closed = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
opened = (datetime.now() - timedelta(days=2, hours=2)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
row = _normalize_archive_trade_row(
|
||||
{
|
||||
"id": 9,
|
||||
"symbol": "ONDO/USDT",
|
||||
"direction": "short",
|
||||
"result": "止损",
|
||||
"reviewed_result": "手动平仓",
|
||||
"pnl_amount": -2.5,
|
||||
"reviewed_pnl_amount": -2.58,
|
||||
"opened_at": opened,
|
||||
"reviewed_opened_at": "2026-06-07 14:30:00",
|
||||
"closed_at": closed,
|
||||
"reviewed_closed_at": "2026-06-08 08:44:21",
|
||||
"opened_at_ms": 1_700_000_000_000,
|
||||
"closed_at_ms": 1_700_007_200_000,
|
||||
"entry_reason": "突破回踩",
|
||||
"reviewed_entry_reason": "趋势回调",
|
||||
"hold_minutes": 30,
|
||||
"reviewed_hold_minutes": 1080,
|
||||
"monitor_type": "趋势回调",
|
||||
"reviewed_at": closed,
|
||||
},
|
||||
exchange_key="gate",
|
||||
)
|
||||
self.assertIsNotNone(row)
|
||||
assert row is not None
|
||||
self.assertEqual(row["entry_type"], "趋势回调")
|
||||
self.assertEqual(row["hold_minutes"], 1080)
|
||||
self.assertEqual(row["opened_at"], "2026-06-07 14:30:00")
|
||||
self.assertEqual(row["closed_at"], "2026-06-08 08:44:21")
|
||||
self.assertTrue(row["reviewed"])
|
||||
|
||||
def test_archive_cache_enriches_review_display_fields(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "archive.db"
|
||||
init_db(db)
|
||||
upsert_trades_cache(
|
||||
"gate",
|
||||
[
|
||||
{
|
||||
"id": 3,
|
||||
"symbol": "ONDO/USDT",
|
||||
"direction": "short",
|
||||
"result": "手动平仓",
|
||||
"pnl_amount": -2.58,
|
||||
"opened_at": "2026-06-07 14:30:00",
|
||||
"closed_at": "2026-06-08 08:44:21",
|
||||
"opened_at_ms": 1_781_000_000_000,
|
||||
"closed_at_ms": 1_781_065_000_000,
|
||||
"entry_type": "趋势回调",
|
||||
"hold_minutes": 1080,
|
||||
"hold_minutes_text": "18小时0分钟",
|
||||
"reviewed": True,
|
||||
}
|
||||
],
|
||||
db_path=db,
|
||||
)
|
||||
rows = load_symbol_trades("gate", "ONDO/USDT", db_path=db)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["entry_type"], "趋势回调")
|
||||
self.assertEqual(rows[0]["hold_minutes"], 1080)
|
||||
self.assertTrue(rows[0]["opened_at"].startswith("2026-06-07"))
|
||||
self.assertTrue(rows[0]["closed_at"].startswith("2026-06-08"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,184 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from lib.hub.hub_volume_rank_lib import (
|
||||
CACHE_VERSION,
|
||||
LIQUIDITY_RANK_CACHE_VERSION,
|
||||
TOP_N_DEFAULT,
|
||||
_exchange_rank_row_stale,
|
||||
_okx_turnover_usdt,
|
||||
_scores_from_binance,
|
||||
_scores_from_gate,
|
||||
build_usdt_swap_volume_ranks,
|
||||
cache_needs_refresh,
|
||||
format_volume_quote,
|
||||
merge_exchange_rank,
|
||||
rank_date_label,
|
||||
resolve_daily_volume_rank,
|
||||
)
|
||||
|
||||
|
||||
def test_rank_date_label_after_reset():
|
||||
# 2026-06-08 09:00 北京时间 → 昨日交易日 2026-06-07
|
||||
dt = datetime(2026, 6, 8, 9, 0, 0)
|
||||
assert rank_date_label(now=dt, reset_hour=8) == "2026-06-07"
|
||||
|
||||
|
||||
def test_rank_date_label_before_reset():
|
||||
# 2026-06-08 07:00 → 当前交易日仍算 2026-06-07,昨日为 2026-06-06
|
||||
dt = datetime(2026, 6, 8, 7, 0, 0)
|
||||
assert rank_date_label(now=dt, reset_hour=8) == "2026-06-06"
|
||||
|
||||
|
||||
def test_format_volume_quote():
|
||||
assert format_volume_quote(1_500_000_000) == "1.50B"
|
||||
assert format_volume_quote(2_300_000) == "2.30M"
|
||||
assert format_volume_quote(4500) == "4.50K"
|
||||
|
||||
|
||||
def test_okx_turnover_usdt():
|
||||
qv = _okx_turnover_usdt({"volCcy24h": "100", "last": "50"})
|
||||
assert qv == 5000.0
|
||||
|
||||
|
||||
def test_cache_needs_refresh_and_merge():
|
||||
cache = {"rank_date": "2026-06-05", "exchanges": {}}
|
||||
assert cache_needs_refresh(cache, expected_rank_date="2026-06-07") is True
|
||||
merged = merge_exchange_rank(
|
||||
cache,
|
||||
"binance",
|
||||
{
|
||||
"ok": True,
|
||||
"rank_date": "2026-06-07",
|
||||
"items": [{"rank": 1, "symbol": "BTC/USDT", "volume_quote": 1.0}],
|
||||
"total_symbols": 100,
|
||||
},
|
||||
)
|
||||
assert merged["exchanges"]["binance"]["items"][0]["symbol"] == "BTC/USDT"
|
||||
assert merged["rank_date"] == "2026-06-07"
|
||||
|
||||
|
||||
def test_stale_cache_version_forces_refresh():
|
||||
cache = {"version": CACHE_VERSION - 1, "rank_date": "2026-06-07", "exchanges": {"okx": {"items": [{}]}}}
|
||||
assert cache_needs_refresh(cache) is True
|
||||
|
||||
|
||||
def test_short_item_list_is_stale():
|
||||
items = [{"rank": i, "symbol": f"S{i}/USDT"} for i in range(1, 13)]
|
||||
row = {"items": items, "total_symbols": 12}
|
||||
assert _exchange_rank_row_stale(row) is True
|
||||
full = {"items": items + [{"rank": i, "symbol": f"X{i}/USDT"} for i in range(13, TOP_N_DEFAULT + 1)], "total_symbols": 300}
|
||||
assert _exchange_rank_row_stale(full) is False
|
||||
|
||||
|
||||
def test_scores_from_binance_uses_fapi_lightweight_api():
|
||||
ex = MagicMock()
|
||||
ex.id = "binance"
|
||||
ex.fapiPublicGetTicker24hr.return_value = [
|
||||
{"symbol": "BTCUSDT", "quoteVolume": "9000000"},
|
||||
{"symbol": "ETHUSDT", "quoteVolume": "5000000"},
|
||||
]
|
||||
scored = _scores_from_binance(ex)
|
||||
assert scored[0][1] == "BTC"
|
||||
assert scored[0][2] == 9000000.0
|
||||
ex.fetch_tickers.assert_not_called()
|
||||
|
||||
|
||||
def test_scores_from_binance_skips_fetch_tickers_on_api_error():
|
||||
ex = MagicMock()
|
||||
ex.id = "binance"
|
||||
ex.fapiPublicGetTicker24hr.side_effect = RuntimeError("network")
|
||||
scored = _scores_from_binance(ex)
|
||||
assert scored == []
|
||||
ex.fetch_tickers.assert_not_called()
|
||||
|
||||
|
||||
def test_scores_from_gate_uses_futures_tickers_api():
|
||||
ex = MagicMock()
|
||||
ex.id = "gateio"
|
||||
ex.publicFuturesGetSettleTickers.return_value = [
|
||||
{"contract": "BTC_USDT", "volume_24h_quote": "8000000"},
|
||||
{"contract": "ETH_USDT", "volume_24h_quote": "4000000"},
|
||||
]
|
||||
scored = _scores_from_gate(ex)
|
||||
assert scored[0][1] == "BTC"
|
||||
ex.fetch_tickers.assert_not_called()
|
||||
|
||||
|
||||
def test_scores_from_gate_skips_fetch_tickers_on_api_error():
|
||||
ex = MagicMock()
|
||||
ex.id = "gateio"
|
||||
ex.publicFuturesGetSettleTickers.side_effect = RuntimeError("network")
|
||||
scored = _scores_from_gate(ex)
|
||||
assert scored == []
|
||||
ex.fetch_tickers.assert_not_called()
|
||||
|
||||
|
||||
def test_resolve_daily_volume_rank_caches_result():
|
||||
cache = {"version": 0, "updated_at": 0.0, "ranks": {}, "total": 0}
|
||||
ex = MagicMock()
|
||||
ex.id = "binance"
|
||||
ex.fapiPublicGetTicker24hr.return_value = [
|
||||
{"symbol": "BTCUSDT", "quoteVolume": "100"},
|
||||
{"symbol": "ETHUSDT", "quoteVolume": "50"},
|
||||
]
|
||||
|
||||
rank, total = resolve_daily_volume_rank(
|
||||
"BTC",
|
||||
cache,
|
||||
now_ts=1000.0,
|
||||
ttl_sec=60.0,
|
||||
exchange=ex,
|
||||
ensure_markets_loaded=lambda: None,
|
||||
)
|
||||
assert rank == 1
|
||||
assert total == 2
|
||||
assert cache["version"] == LIQUIDITY_RANK_CACHE_VERSION
|
||||
calls = ex.fapiPublicGetTicker24hr.call_count
|
||||
|
||||
rank2, _ = resolve_daily_volume_rank(
|
||||
"BTC",
|
||||
cache,
|
||||
now_ts=1010.0,
|
||||
ttl_sec=60.0,
|
||||
exchange=ex,
|
||||
ensure_markets_loaded=lambda: None,
|
||||
)
|
||||
assert rank2 == 1
|
||||
assert ex.fapiPublicGetTicker24hr.call_count == calls
|
||||
|
||||
|
||||
def test_resolve_daily_volume_rank_keeps_stale_cache_when_refresh_empty():
|
||||
cache = {
|
||||
"version": LIQUIDITY_RANK_CACHE_VERSION,
|
||||
"updated_at": 900.0,
|
||||
"ranks": {"BTC": 1},
|
||||
"total": 100,
|
||||
}
|
||||
ex = MagicMock()
|
||||
ex.id = "binance"
|
||||
ex.fapiPublicGetTicker24hr.return_value = []
|
||||
|
||||
rank, total = resolve_daily_volume_rank(
|
||||
"BTC",
|
||||
cache,
|
||||
now_ts=2000.0,
|
||||
ttl_sec=60.0,
|
||||
exchange=ex,
|
||||
ensure_markets_loaded=lambda: None,
|
||||
)
|
||||
assert rank == 1
|
||||
assert total == 100
|
||||
assert cache["updated_at"] == 900.0
|
||||
ex.fetch_tickers.assert_not_called()
|
||||
|
||||
|
||||
def test_build_usdt_swap_volume_ranks():
|
||||
ex = MagicMock()
|
||||
ex.id = "binance"
|
||||
ex.fapiPublicGetTicker24hr.return_value = [
|
||||
{"symbol": "SOLUSDT", "quoteVolume": "200"},
|
||||
]
|
||||
ranks, total = build_usdt_swap_volume_ranks(ex, lambda: None)
|
||||
assert ranks["SOL"] == 1
|
||||
assert total == 1
|
||||
@@ -0,0 +1,172 @@
|
||||
"""instance_dashboard_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload
|
||||
|
||||
|
||||
def _mem_conn() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE order_monitors (
|
||||
id INTEGER PRIMARY KEY,
|
||||
symbol TEXT,
|
||||
exchange_symbol TEXT,
|
||||
direction TEXT,
|
||||
status TEXT,
|
||||
monitor_type TEXT,
|
||||
key_signal_type TEXT,
|
||||
trigger_price REAL,
|
||||
stop_loss REAL,
|
||||
take_profit REAL
|
||||
);
|
||||
CREATE TABLE key_monitors (
|
||||
id INTEGER PRIMARY KEY,
|
||||
symbol TEXT,
|
||||
exchange_symbol TEXT,
|
||||
direction TEXT,
|
||||
signal_type TEXT,
|
||||
upper REAL,
|
||||
lower REAL,
|
||||
status TEXT
|
||||
);
|
||||
CREATE TABLE trend_pullback_plans (
|
||||
id INTEGER PRIMARY KEY,
|
||||
symbol TEXT,
|
||||
exchange_symbol TEXT,
|
||||
direction TEXT,
|
||||
status TEXT,
|
||||
entry_price REAL
|
||||
);
|
||||
CREATE TABLE roll_groups (
|
||||
id INTEGER PRIMARY KEY,
|
||||
order_monitor_id INTEGER,
|
||||
symbol TEXT,
|
||||
exchange_symbol TEXT,
|
||||
direction TEXT,
|
||||
status TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
class TestInstanceDashboardLib(unittest.TestCase):
|
||||
def test_empty_sections_and_conditional_hidden(self):
|
||||
conn = _mem_conn()
|
||||
payload = build_instance_dashboard_payload(conn, hedge_enabled=True)
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertEqual(payload["orders"]["count"], 0)
|
||||
self.assertEqual(payload["keys"]["count"], 0)
|
||||
self.assertEqual(payload["strategy"]["count"], 0)
|
||||
self.assertFalse(payload["options"]["visible"])
|
||||
self.assertFalse(payload["hedge_plan"]["visible"])
|
||||
conn.close()
|
||||
|
||||
def test_orders_keys_strategy_and_options_visible(self):
|
||||
conn = _mem_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO order_monitors (symbol, exchange_symbol, direction, status, monitor_type) "
|
||||
"VALUES ('BTC/USDT', 'BTC/USDT:USDT', 'long', 'active', 'manual')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO key_monitors (symbol, direction, signal_type, upper, lower, status) "
|
||||
"VALUES ('ETH/USDT', 'short', '箱体突破', 3000, 2800, 'active')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO trend_pullback_plans (symbol, direction, status, entry_price) "
|
||||
"VALUES ('SOL/USDT', 'long', 'active', 100)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO order_monitors (id, symbol, direction, status) VALUES (9, 'XRP/USDT', 'short', 'active')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO roll_groups (order_monitor_id, symbol, direction, status) "
|
||||
"VALUES (9, 'XRP/USDT', 'short', 'active')"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def fetch_opts():
|
||||
return [{"inst_id": "ETH-USD-260731-3000-C", "opt_type": "C", "pos": 1, "upl": 1.5}]
|
||||
|
||||
payload = build_instance_dashboard_payload(
|
||||
conn,
|
||||
fetch_options_positions=fetch_opts,
|
||||
hedge_enabled=False,
|
||||
)
|
||||
self.assertEqual(payload["orders"]["count"], 2)
|
||||
self.assertEqual(payload["keys"]["count"], 1)
|
||||
self.assertEqual(payload["strategy"]["count"], 2)
|
||||
self.assertTrue(payload["options"]["visible"])
|
||||
self.assertEqual(payload["options"]["count"], 1)
|
||||
self.assertEqual(payload["options"]["items"][0]["source_label"], "纯期权")
|
||||
self.assertFalse(payload["hedge_plan"]["visible"])
|
||||
conn.close()
|
||||
|
||||
def test_hedge_status_label_active(self):
|
||||
conn = _mem_conn()
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE hedge_plans (
|
||||
id INTEGER PRIMARY KEY,
|
||||
underlying TEXT,
|
||||
plan_type TEXT,
|
||||
status TEXT
|
||||
);
|
||||
CREATE TABLE hedge_plan_legs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
plan_id INTEGER,
|
||||
leg_role TEXT,
|
||||
symbol TEXT,
|
||||
inst_id TEXT,
|
||||
opt_type TEXT,
|
||||
status TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO hedge_plans (id, underlying, plan_type, status) "
|
||||
"VALUES (2, 'ETH', 'options_options', 'active')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO hedge_plan_legs (plan_id, leg_role, inst_id, opt_type, status) "
|
||||
"VALUES (2, 'option', 'ETH-USD-260719-1850-P', 'P', 'open')"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def fetch_opts():
|
||||
return [
|
||||
{
|
||||
"inst_id": "ETH-USD-260719-1850-P",
|
||||
"opt_type": "P",
|
||||
"pos": 40,
|
||||
"upl": 1.2,
|
||||
"exp_time_ms": 1784505600000,
|
||||
"hedge_plan_target": {
|
||||
"plan_id": 2,
|
||||
"opt_type": "P",
|
||||
"target_index": 1800,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
payload = build_instance_dashboard_payload(
|
||||
conn,
|
||||
fetch_options_positions=fetch_opts,
|
||||
hedge_enabled=True,
|
||||
)
|
||||
self.assertTrue(payload["hedge_plan"]["visible"])
|
||||
self.assertEqual(payload["hedge_plan"]["items"][0]["status_label"], "进行中")
|
||||
self.assertTrue(payload["hedge_plan"]["items"][0]["status_active"])
|
||||
opt = payload["options"]["items"][0]
|
||||
self.assertEqual(opt["source_label"], "期期对冲")
|
||||
self.assertIn("对冲#2", opt["target_monitor"])
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""instance_display_prefs_lib 与 env_file_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
|
||||
from lib.env.env_schema import parse_env_example_schema, validate_env_updates
|
||||
from lib.instance.instance_display_prefs_lib import normalize_display_prefs, tab_allowed
|
||||
|
||||
|
||||
class TestInstanceDisplayPrefs(unittest.TestCase):
|
||||
def test_normalize_defaults_all_on(self):
|
||||
prefs = normalize_display_prefs({})
|
||||
self.assertTrue(prefs["show_nav_env_config"])
|
||||
self.assertTrue(prefs["show_settings_password"])
|
||||
self.assertFalse(prefs["show_nav_dashboard"])
|
||||
|
||||
def test_tab_allowed_respects_prefs(self):
|
||||
prefs = normalize_display_prefs({"show_nav_stats": False})
|
||||
self.assertFalse(tab_allowed("stats", prefs))
|
||||
self.assertTrue(tab_allowed("trade", prefs))
|
||||
|
||||
def test_dashboard_nav_default_off(self):
|
||||
prefs = normalize_display_prefs({})
|
||||
self.assertFalse(tab_allowed("dashboard", prefs))
|
||||
on = normalize_display_prefs({"show_nav_dashboard": True})
|
||||
self.assertTrue(tab_allowed("dashboard", on))
|
||||
|
||||
|
||||
class TestEnvFileLib(unittest.TestCase):
|
||||
def test_upsert_and_read(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
path = os.path.join(td, ".env")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("FOO=1\n")
|
||||
changed = apply_env_updates(path, {"FOO": "2", "BAR": "x"})
|
||||
self.assertIn("FOO", changed)
|
||||
self.assertIn("BAR", changed)
|
||||
lines = read_env_lines(path)
|
||||
self.assertEqual(env_get(lines, "FOO"), "2")
|
||||
self.assertEqual(env_get(lines, "BAR"), "x")
|
||||
|
||||
|
||||
class TestEnvSchema(unittest.TestCase):
|
||||
def test_parse_okx_example(self):
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
example = os.path.join(root, "crypto_monitor_okx", ".env.example")
|
||||
if not os.path.isfile(example):
|
||||
self.skipTest("missing okx .env.example")
|
||||
groups = parse_env_example_schema(example)
|
||||
keys = [f["key"] for g in groups for f in g.get("fields", [])]
|
||||
self.assertIn("OKX_API_KEY", keys)
|
||||
self.assertIn("MAX_ACTIVE_POSITIONS", keys)
|
||||
|
||||
def test_validate_unknown_key(self):
|
||||
groups = [{"title": "t", "fields": [{"key": "A", "type": "text", "sensitive": False}]}]
|
||||
clean, errors = validate_env_updates(groups, {"B": "1"})
|
||||
self.assertTrue(errors)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,36 @@
|
||||
from lib.instance.instance_embed_context_lib import embed_render_plan, trade_records_summary
|
||||
|
||||
|
||||
def test_embed_fragment_trade_is_light():
|
||||
plan = embed_render_plan("trade", "fragment")
|
||||
assert plan.exchange_capitals is False
|
||||
assert plan.records_rows is False
|
||||
assert plan.records_summary is False
|
||||
assert plan.orders is True
|
||||
assert plan.key_history is False
|
||||
|
||||
|
||||
def test_embed_shell_trade_summary_only():
|
||||
plan = embed_render_plan("trade", "shell")
|
||||
assert plan.exchange_capitals is True
|
||||
assert plan.records_summary is True
|
||||
assert plan.records_rows is False
|
||||
|
||||
|
||||
def test_embed_shell_settings_still_loads_header_summary():
|
||||
plan = embed_render_plan("settings", "shell")
|
||||
assert plan.records_summary is True
|
||||
assert plan.records_rows is False
|
||||
plan_risk = embed_render_plan("risk_policy", "shell")
|
||||
assert plan_risk.records_summary is True
|
||||
|
||||
|
||||
def test_embed_records_page_loads_rows():
|
||||
plan = embed_render_plan("records", "fragment")
|
||||
assert plan.records_rows is True
|
||||
|
||||
|
||||
def test_full_page_unchanged():
|
||||
plan = embed_render_plan("trade", None)
|
||||
assert plan.records_rows is True
|
||||
assert plan.exchange_capitals is True
|
||||
@@ -0,0 +1,48 @@
|
||||
from lib.instance.instance_embed_lib import (
|
||||
EMBED_TABS,
|
||||
embed_context_extras,
|
||||
include_transfer_block,
|
||||
path_to_embed_tab,
|
||||
rewrite_embed_dest,
|
||||
ui_open_guard_enabled,
|
||||
ui_orphan_recovery_enabled,
|
||||
)
|
||||
|
||||
|
||||
def test_path_to_embed_tab():
|
||||
assert path_to_embed_tab("/trade") == "trade"
|
||||
assert path_to_embed_tab("/key_monitor") == "key_monitor"
|
||||
assert path_to_embed_tab("/strategy/records") == "strategy_records"
|
||||
assert path_to_embed_tab("/unknown") is None
|
||||
|
||||
|
||||
def test_rewrite_embed_dest():
|
||||
url = rewrite_embed_dest("/trade", hub_theme="dark")
|
||||
assert url.startswith("/embed?")
|
||||
assert "tab=trade" in url
|
||||
assert "embed=1" in url
|
||||
assert "hub_theme=dark" in url
|
||||
|
||||
|
||||
def test_embed_tabs_cover_main_nav():
|
||||
assert "trade" in EMBED_TABS
|
||||
assert "key_monitor" in EMBED_TABS
|
||||
assert "records" in EMBED_TABS
|
||||
assert "env_config" in EMBED_TABS
|
||||
assert "risk_policy" in EMBED_TABS
|
||||
assert "settings" in EMBED_TABS
|
||||
assert path_to_embed_tab("/env_config") == "env_config"
|
||||
assert path_to_embed_tab("/risk_policy") == "risk_policy"
|
||||
assert path_to_embed_tab("/settings") == "settings"
|
||||
|
||||
|
||||
def test_embed_context_extras_unified_ui_flags():
|
||||
for ex in ("binance", "okx", "gate"):
|
||||
assert include_transfer_block(ex) is True
|
||||
assert ui_open_guard_enabled("okx") is True
|
||||
assert ui_open_guard_enabled("binance") is False
|
||||
assert ui_orphan_recovery_enabled("binance") is True
|
||||
assert ui_orphan_recovery_enabled("gate") is False
|
||||
ctx = embed_context_extras("gate")
|
||||
assert ctx["order_rule_tips_tpl"] == "order_monitor_rule_tips_gate.html"
|
||||
assert ctx["include_transfer_block"] is True
|
||||
@@ -0,0 +1,44 @@
|
||||
"""instance_embed_context_lib 顶栏统计."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from lib.instance.instance_embed_context_lib import (
|
||||
options_funding_label,
|
||||
profit_loss_ratio_from_averages,
|
||||
profit_loss_ratio_from_trades,
|
||||
total_funds_usdt,
|
||||
)
|
||||
|
||||
|
||||
class TestHeaderStatsLib(unittest.TestCase):
|
||||
def test_profit_loss_ratio_from_averages(self):
|
||||
self.assertEqual(profit_loss_ratio_from_averages(9.0, -3.0), 3.0)
|
||||
self.assertIsNone(profit_loss_ratio_from_averages(9.0, 0))
|
||||
|
||||
def test_profit_loss_ratio_from_trades(self):
|
||||
trades = [
|
||||
{"effective_pnl_amount": 10},
|
||||
{"effective_pnl_amount": 8},
|
||||
{"effective_pnl_amount": -4},
|
||||
{"effective_pnl_amount": -2},
|
||||
]
|
||||
self.assertEqual(profit_loss_ratio_from_trades(trades), 3.0)
|
||||
|
||||
def test_total_funds_usdt(self):
|
||||
self.assertEqual(total_funds_usdt(100.5, 59.27), 159.77)
|
||||
self.assertIsNone(total_funds_usdt(None, 10))
|
||||
self.assertEqual(
|
||||
total_funds_usdt(100, 50, options_trading_usdc=0.2, options_trading_usdt=10),
|
||||
160.2,
|
||||
)
|
||||
|
||||
def test_options_funding_label(self):
|
||||
self.assertEqual(options_funding_label(1.5, 10), "1.50 USDC · 10.00 USDT")
|
||||
self.assertEqual(options_funding_label(10.19, 0), "10.19 USDC")
|
||||
self.assertEqual(options_funding_label(None, 10), "10.00 USDT")
|
||||
self.assertEqual(options_funding_label(None, None), "—")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""instance_live_pnl_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from lib.instance.instance_live_pnl_lib import (
|
||||
merge_unrealized_pnl_components,
|
||||
position_row_contracts,
|
||||
resolve_instance_unrealized_pnl,
|
||||
sum_unrealized_pnl_from_metrics,
|
||||
sum_unrealized_pnl_from_positions,
|
||||
)
|
||||
|
||||
|
||||
class TestInstanceLivePnlLib(unittest.TestCase):
|
||||
def test_position_row_contracts_from_info(self):
|
||||
pos = {"contracts": 0, "info": {"positionAmt": "12.5"}}
|
||||
self.assertAlmostEqual(position_row_contracts(pos), 12.5)
|
||||
|
||||
def test_sum_from_positions_binance_style(self):
|
||||
positions = [
|
||||
{"unrealizedPnl": -0.14, "info": {"positionAmt": "100"}},
|
||||
]
|
||||
self.assertEqual(sum_unrealized_pnl_from_positions(positions), -0.14)
|
||||
|
||||
def test_sum_from_metrics_fallback(self):
|
||||
rows = [{"exchange_symbol": "DOGE/USDT:USDT", "symbol": "DOGE/USDT", "direction": "long"}]
|
||||
|
||||
def _metrics(ex_sym, direction):
|
||||
self.assertEqual(direction, "long")
|
||||
return {"unrealized_pnl": -0.14}
|
||||
|
||||
self.assertEqual(sum_unrealized_pnl_from_metrics(rows, _metrics), -0.14)
|
||||
|
||||
def test_resolve_prefers_bulk_positions(self):
|
||||
def _fetch():
|
||||
return [{"unrealizedPnl": 1.2, "contracts": 1}]
|
||||
|
||||
def _metrics(_ex, _d):
|
||||
raise AssertionError("should not call metrics when bulk works")
|
||||
|
||||
total = resolve_instance_unrealized_pnl(_fetch, [], _metrics)
|
||||
self.assertEqual(total, 1.2)
|
||||
|
||||
def test_resolve_falls_back_to_metrics(self):
|
||||
def _fetch():
|
||||
raise RuntimeError("api down")
|
||||
|
||||
rows = [{"exchange_symbol": "BTC/USDT:USDT", "symbol": "BTC/USDT", "direction": "short"}]
|
||||
|
||||
def _metrics(_ex, direction):
|
||||
return {"unrealized_pnl": -2.5} if direction == "short" else None
|
||||
|
||||
total = resolve_instance_unrealized_pnl(_fetch, rows, _metrics)
|
||||
self.assertEqual(total, -2.5)
|
||||
|
||||
def test_merge_unrealized_pnl_components(self):
|
||||
self.assertEqual(merge_unrealized_pnl_components(-0.11, 0.02), -0.09)
|
||||
self.assertEqual(merge_unrealized_pnl_components(None, 0.02), 0.02)
|
||||
self.assertEqual(merge_unrealized_pnl_components(-0.11, None), -0.11)
|
||||
self.assertIsNone(merge_unrealized_pnl_components(None, None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,20 @@
|
||||
"""instance_live_push_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from lib.instance.instance_live_push_lib import InstanceLivePush
|
||||
|
||||
|
||||
def test_tick_increments_version_and_connect_event() -> None:
|
||||
push = InstanceLivePush()
|
||||
v1 = push.tick("test")
|
||||
v2 = push.tick("test")
|
||||
assert v1 == 1
|
||||
assert v2 == 2
|
||||
gen = push.iter_sse()
|
||||
first = next(gen)
|
||||
assert first.startswith("event: live")
|
||||
data = json.loads(first.split("data: ", 1)[1].strip())
|
||||
assert data["live_version"] == 2
|
||||
push.stop()
|
||||
@@ -0,0 +1,21 @@
|
||||
from lib.instance.instance_nav_lib import request_is_hub_soft_nav
|
||||
|
||||
|
||||
def test_request_is_hub_soft_nav():
|
||||
class Req:
|
||||
args = {"embed": "1"}
|
||||
headers = {"X-Instance-Soft-Nav": "1"}
|
||||
|
||||
assert request_is_hub_soft_nav(Req()) is True
|
||||
|
||||
class Req2:
|
||||
args = {"embed": "1"}
|
||||
headers = {}
|
||||
|
||||
assert request_is_hub_soft_nav(Req2()) is False
|
||||
|
||||
class Req3:
|
||||
args = {}
|
||||
headers = {"X-Instance-Soft-Nav": "1"}
|
||||
|
||||
assert request_is_hub_soft_nav(Req3()) is False
|
||||
@@ -0,0 +1,35 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from lib.instance.instance_pm2_lib import restart_instance_pm2, schedule_pm2_restart
|
||||
|
||||
|
||||
@patch("lib.instance.instance_pm2_lib.sys.platform", "linux")
|
||||
@patch("lib.instance.instance_pm2_lib.subprocess.Popen")
|
||||
def test_schedule_pm2_restart_returns_before_pm2(mock_popen):
|
||||
result = schedule_pm2_restart("crypto_okx")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["deferred"] is True
|
||||
mock_popen.assert_called_once()
|
||||
|
||||
|
||||
@patch("lib.instance.instance_pm2_lib.sys.platform", "linux")
|
||||
@patch("lib.instance.instance_pm2_lib.schedule_pm2_restart")
|
||||
def test_restart_instance_pm2_defer_uses_schedule(mock_schedule):
|
||||
mock_schedule.return_value = {"ok": True, "app": "crypto_okx", "deferred": True}
|
||||
|
||||
result = restart_instance_pm2("okx", defer=True)
|
||||
|
||||
mock_schedule.assert_called_once_with("crypto_okx")
|
||||
assert result["deferred"] is True
|
||||
|
||||
|
||||
@patch("lib.instance.instance_pm2_lib.sys.platform", "linux")
|
||||
@patch("lib.instance.instance_pm2_lib.subprocess.run")
|
||||
def test_restart_instance_pm2_sync_runs_pm2(mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
|
||||
result = restart_instance_pm2("okx", defer=False)
|
||||
|
||||
mock_run.assert_called_once()
|
||||
assert result["ok"] is True
|
||||
@@ -0,0 +1,43 @@
|
||||
"""instance_settings_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from lib.instance.instance_settings_lib import build_instance_settings_view
|
||||
|
||||
|
||||
class InstanceSettingsLibTest(unittest.TestCase):
|
||||
def test_build_settings_view_sections(self):
|
||||
view = build_instance_settings_view(
|
||||
exchange_key="gate",
|
||||
exchange_display="Gate.io",
|
||||
risk_status={"status_label": "正常", "can_trade": True},
|
||||
data_export_version=3,
|
||||
)
|
||||
titles = [s["title"] for s in view["sections"]]
|
||||
self.assertIn("交易执行", titles)
|
||||
self.assertIn("账户冷静期", titles)
|
||||
self.assertEqual(view["data_export_version"], 3)
|
||||
self.assertTrue(view["show_transfer"])
|
||||
|
||||
def test_force_close_section_when_enabled(self):
|
||||
old = os.environ.get("FORCE_CLOSE_ENABLED")
|
||||
try:
|
||||
os.environ["FORCE_CLOSE_ENABLED"] = "true"
|
||||
view = build_instance_settings_view(
|
||||
exchange_key="gate",
|
||||
exchange_display="Gate.io",
|
||||
risk_status={},
|
||||
)
|
||||
titles = [s["title"] for s in view["sections"]]
|
||||
self.assertIn("整点强制清仓", titles)
|
||||
finally:
|
||||
if old is None:
|
||||
os.environ.pop("FORCE_CLOSE_ENABLED", None)
|
||||
else:
|
||||
os.environ["FORCE_CLOSE_ENABLED"] = old
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""journal_form_lib / strategy_trade_labels 下单类型与开仓类型拆分."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from lib.instance.journal_form_lib import (
|
||||
journal_entry_reason_valid,
|
||||
normalize_journal_direction,
|
||||
normalize_journal_entry_reason,
|
||||
)
|
||||
from lib.strategy.strategy_trade_labels import (
|
||||
JOURNAL_ORDER_TYPE_OPTIONS,
|
||||
normalize_journal_order_type,
|
||||
order_type_from_monitor_type,
|
||||
)
|
||||
from lib.trade.entry_model_lib import build_journal_entry_reason_options
|
||||
|
||||
|
||||
class JournalFormLibTests(unittest.TestCase):
|
||||
def test_journal_entry_reason_excludes_legacy_style_and_strategy(self):
|
||||
opts = build_journal_entry_reason_options()
|
||||
self.assertIn("反转/启动A", opts)
|
||||
self.assertNotIn("趋势单", opts)
|
||||
self.assertNotIn("波段单", opts)
|
||||
self.assertNotIn("趋势回调", opts)
|
||||
self.assertNotIn("顺势加仓", opts)
|
||||
|
||||
def test_normalize_journal_entry_reason_rejects_legacy_for_new_submit(self):
|
||||
opts = build_journal_entry_reason_options()
|
||||
self.assertEqual(
|
||||
normalize_journal_entry_reason("趋势单", opts, allow_legacy=False),
|
||||
"",
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_journal_entry_reason("趋势回调", opts, allow_legacy=False),
|
||||
"",
|
||||
)
|
||||
|
||||
def test_normalize_journal_entry_reason_accepts_legacy_when_allowed(self):
|
||||
opts = build_journal_entry_reason_options()
|
||||
self.assertEqual(
|
||||
normalize_journal_entry_reason("趋势单", opts, allow_legacy=True),
|
||||
"趋势单",
|
||||
)
|
||||
|
||||
def test_order_type_from_monitor_type(self):
|
||||
self.assertEqual(order_type_from_monitor_type("下单监控"), "下单监控")
|
||||
self.assertEqual(order_type_from_monitor_type("关键位监控"), "关键位监控")
|
||||
self.assertEqual(order_type_from_monitor_type("趋势回调"), "趋势回调")
|
||||
self.assertEqual(order_type_from_monitor_type("顺势加仓"), "顺势加仓")
|
||||
|
||||
def test_normalize_journal_order_type(self):
|
||||
self.assertEqual(normalize_journal_order_type("顺势加仓"), "顺势加仓")
|
||||
self.assertEqual(normalize_journal_order_type(""), "")
|
||||
self.assertEqual(len(JOURNAL_ORDER_TYPE_OPTIONS), 4)
|
||||
|
||||
def test_journal_entry_reason_valid(self):
|
||||
opts = build_journal_entry_reason_options()
|
||||
self.assertTrue(journal_entry_reason_valid("顺势/大分歧A", opts))
|
||||
self.assertFalse(journal_entry_reason_valid("趋势单", opts))
|
||||
|
||||
def test_normalize_journal_direction(self):
|
||||
self.assertEqual(normalize_journal_direction("short"), "short")
|
||||
self.assertEqual(normalize_journal_direction("做空"), "short")
|
||||
self.assertEqual(normalize_journal_direction("long"), "long")
|
||||
self.assertEqual(normalize_journal_direction("做多"), "long")
|
||||
self.assertEqual(normalize_journal_direction(""), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,156 @@
|
||||
"""journal_images_lib / journal_upload_api_lib 单元测试."""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from io import BytesIO
|
||||
|
||||
from lib.instance.journal_images_lib import (
|
||||
JOURNAL_UPLOAD_TFS,
|
||||
collect_journal_slot_images,
|
||||
enrich_journal_api_item,
|
||||
images_json_dumps,
|
||||
is_valid_preuploaded_journal_file,
|
||||
journal_image_paths,
|
||||
journal_upload_field_name,
|
||||
normalize_journal_draft_id,
|
||||
parse_images_json,
|
||||
primary_journal_image,
|
||||
save_journal_slot_uploads,
|
||||
uploaded_screenshot_field_name,
|
||||
)
|
||||
from lib.instance.journal_upload_api_lib import handle_journal_upload_slot
|
||||
|
||||
|
||||
class _FakeFile:
|
||||
def __init__(self, filename: str, data: bytes):
|
||||
self.filename = filename
|
||||
self._data = data
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
with open(path, "wb") as f:
|
||||
f.write(self._data)
|
||||
|
||||
|
||||
class _FakeFiles:
|
||||
def __init__(self, mapping):
|
||||
self._mapping = mapping
|
||||
|
||||
def get(self, key):
|
||||
return self._mapping.get(key)
|
||||
|
||||
|
||||
class _FakeForm:
|
||||
def __init__(self, mapping):
|
||||
self._mapping = mapping
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._mapping.get(key, default)
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
def __init__(self, form=None, files=None):
|
||||
self.form = form
|
||||
self.files = files
|
||||
|
||||
|
||||
class JournalImagesLibTest(unittest.TestCase):
|
||||
def test_field_names(self):
|
||||
self.assertEqual(journal_upload_field_name("5m"), "screenshot_5m")
|
||||
self.assertEqual(uploaded_screenshot_field_name("5m"), "uploaded_screenshot_5m")
|
||||
|
||||
def test_normalize_draft_id(self):
|
||||
good = "a" * 32
|
||||
self.assertEqual(normalize_journal_draft_id(good), good)
|
||||
self.assertIsNone(normalize_journal_draft_id("bad"))
|
||||
|
||||
def test_save_slot_uploads_partial(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
files = _FakeFiles(
|
||||
{
|
||||
"screenshot_5m": _FakeFile("a.png", b"png5"),
|
||||
"screenshot_1h": _FakeFile("b.jpg", b"jpg1"),
|
||||
}
|
||||
)
|
||||
saved = save_journal_slot_uploads(
|
||||
files,
|
||||
"abc123" + "0" * 26,
|
||||
tmp,
|
||||
secure_filename_fn=lambda x: x,
|
||||
)
|
||||
self.assertEqual(len(saved), 2)
|
||||
self.assertEqual(saved[0]["tf"], "5m")
|
||||
self.assertTrue(os.path.isfile(os.path.join(tmp, saved[0]["file"])))
|
||||
self.assertEqual(saved[1]["tf"], "1h")
|
||||
|
||||
def test_collect_preuploaded(self):
|
||||
entry_id = "abc123" + "0" * 26
|
||||
fname = f"journal_{entry_id}_5m.png"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with open(os.path.join(tmp, fname), "wb") as f:
|
||||
f.write(b"x")
|
||||
form = _FakeForm({uploaded_screenshot_field_name("5m"): fname})
|
||||
saved = collect_journal_slot_images(
|
||||
form,
|
||||
_FakeFiles({}),
|
||||
entry_id,
|
||||
tmp,
|
||||
secure_filename_fn=lambda x: x,
|
||||
)
|
||||
self.assertEqual(saved, [{"tf": "5m", "file": fname}])
|
||||
|
||||
def test_is_valid_preuploaded_journal_file(self):
|
||||
entry_id = "abc123" + "0" * 26
|
||||
fname = f"journal_{entry_id}_5m.png"
|
||||
self.assertTrue(is_valid_preuploaded_journal_file(fname, entry_id, "5m"))
|
||||
self.assertFalse(is_valid_preuploaded_journal_file("../evil.png", entry_id, "5m"))
|
||||
self.assertFalse(is_valid_preuploaded_journal_file(fname, "b" * 32, "5m"))
|
||||
|
||||
def test_parse_and_enrich(self):
|
||||
raw = images_json_dumps([{"tf": "5m", "file": "journal_x_5m.png"}])
|
||||
item = enrich_journal_api_item({"images_json": raw, "image": "legacy.png"})
|
||||
self.assertEqual(len(item["images"]), 1)
|
||||
self.assertEqual(item["images"][0]["tf"], "5m")
|
||||
|
||||
legacy = enrich_journal_api_item({"image": "only.png"})
|
||||
self.assertEqual(legacy["images"][0]["file"], "only.png")
|
||||
|
||||
def test_journal_image_paths_dedupe(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "same.png")
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"x")
|
||||
row = {
|
||||
"image": "same.png",
|
||||
"images_json": json.dumps([{"tf": "5m", "file": "same.png"}]),
|
||||
}
|
||||
paths = journal_image_paths(row, tmp)
|
||||
self.assertEqual(len(paths), 1)
|
||||
|
||||
def test_primary_journal_image(self):
|
||||
self.assertEqual(
|
||||
primary_journal_image([{"tf": "5m", "file": "a.png"}]),
|
||||
"a.png",
|
||||
)
|
||||
self.assertIsNone(primary_journal_image([]))
|
||||
|
||||
def test_handle_journal_upload_slot(self):
|
||||
entry_id = "abc123" + "0" * 26
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
req = _FakeRequest(
|
||||
form=_FakeForm({"journal_draft_id": entry_id, "tf": "5m"}),
|
||||
files=_FakeFiles({"file": _FakeFile("local.png", b"data")}),
|
||||
)
|
||||
payload, code = handle_journal_upload_slot(
|
||||
req,
|
||||
upload_folder=tmp,
|
||||
secure_filename_fn=lambda x: x,
|
||||
)
|
||||
self.assertEqual(code, 200)
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertEqual(payload["tf"], "5m")
|
||||
self.assertTrue(os.path.isfile(os.path.join(tmp, payload["file"])))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""key_auto_order_lib 单元测试."""
|
||||
import unittest
|
||||
|
||||
from lib.key_monitor.key_auto_order_lib import (
|
||||
check_monitor_type_add_allowed,
|
||||
effective_entry_reason_options,
|
||||
effective_stats_segment_defs,
|
||||
load_key_auto_order_enabled,
|
||||
)
|
||||
from lib.trade.position_sizing_lib import MODE_FULL_MARGIN, MODE_RISK
|
||||
|
||||
FULL_OPTS = (
|
||||
"趋势A",
|
||||
"趋势B",
|
||||
"趋势C",
|
||||
"趋势D",
|
||||
"趋势E",
|
||||
"关键位箱体突破",
|
||||
"关键位收敛突破",
|
||||
"关键位斐波0.618",
|
||||
"关键位斐波0.786",
|
||||
"关键位假突破",
|
||||
"关键位回调触价开仓",
|
||||
"关键位突破触价开仓",
|
||||
"趋势回调",
|
||||
"顺势加仓",
|
||||
)
|
||||
|
||||
STATS_DEFS = (
|
||||
("all", "全部", {}),
|
||||
("key_box", "箱体", {}),
|
||||
("key_trigger", "触价", {}),
|
||||
)
|
||||
|
||||
|
||||
class KeyAutoOrderLibTest(unittest.TestCase):
|
||||
def test_load_default_false(self):
|
||||
self.assertFalse(load_key_auto_order_enabled({"KEY_AUTO_ORDER_ENABLED": "false"}))
|
||||
self.assertFalse(load_key_auto_order_enabled({}))
|
||||
self.assertTrue(load_key_auto_order_enabled({"KEY_AUTO_ORDER_ENABLED": "true"}))
|
||||
|
||||
def test_entry_reason_off(self):
|
||||
out = effective_entry_reason_options(FULL_OPTS, MODE_RISK, False)
|
||||
self.assertNotIn("关键位箱体突破", out)
|
||||
self.assertNotIn("关键位回调触价开仓", out)
|
||||
self.assertIn("顺势加仓", out)
|
||||
|
||||
def test_entry_reason_risk_on(self):
|
||||
out = effective_entry_reason_options(FULL_OPTS, MODE_RISK, True)
|
||||
self.assertIn("关键位箱体突破", out)
|
||||
self.assertIn("关键位回调触价开仓", out)
|
||||
|
||||
def test_entry_reason_full_margin_on(self):
|
||||
out = effective_entry_reason_options(FULL_OPTS, MODE_FULL_MARGIN, True)
|
||||
self.assertNotIn("关键位箱体突破", out)
|
||||
self.assertIn("关键位回调触价开仓", out)
|
||||
|
||||
def test_stats_segments_off(self):
|
||||
segs = effective_stats_segment_defs(STATS_DEFS, MODE_RISK, False)
|
||||
keys = {x[0] for x in segs}
|
||||
self.assertIn("all", keys)
|
||||
self.assertNotIn("key_box", keys)
|
||||
|
||||
def test_add_key_rs_always(self):
|
||||
ok, _ = check_monitor_type_add_allowed("关键支撑阻力", MODE_RISK, False)
|
||||
self.assertTrue(ok)
|
||||
|
||||
def test_add_key_trigger_off(self):
|
||||
ok, msg = check_monitor_type_add_allowed("回调触价开仓", MODE_RISK, False)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("KEY_AUTO_ORDER_ENABLED", msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,34 @@
|
||||
import unittest
|
||||
|
||||
from lib.key_monitor.key_monitor_lib import (
|
||||
BOX_BREAKOUT_CLOSE_OPPOSITE,
|
||||
box_breakout_invalidate_by_mark,
|
||||
box_breakout_invalidate_edge_label,
|
||||
)
|
||||
|
||||
|
||||
class BoxBreakoutInvalidateTests(unittest.TestCase):
|
||||
def test_short_invalidates_above_upper(self):
|
||||
self.assertTrue(box_breakout_invalidate_by_mark("short", 62.511, 61.746, 60.569))
|
||||
|
||||
def test_short_stays_valid_inside_or_below(self):
|
||||
self.assertFalse(box_breakout_invalidate_by_mark("short", 61.0, 61.746, 60.569))
|
||||
self.assertFalse(box_breakout_invalidate_by_mark("short", 60.0, 61.746, 60.569))
|
||||
|
||||
def test_long_invalidates_below_lower(self):
|
||||
self.assertTrue(box_breakout_invalidate_by_mark("long", 94.0, 100.0, 95.0))
|
||||
|
||||
def test_long_stays_valid_inside_or_above(self):
|
||||
self.assertFalse(box_breakout_invalidate_by_mark("long", 98.0, 100.0, 95.0))
|
||||
self.assertFalse(box_breakout_invalidate_by_mark("long", 101.0, 100.0, 95.0))
|
||||
|
||||
def test_edge_label(self):
|
||||
self.assertEqual(box_breakout_invalidate_edge_label("long"), "下沿")
|
||||
self.assertEqual(box_breakout_invalidate_edge_label("short"), "上沿")
|
||||
|
||||
def test_close_reason_constant(self):
|
||||
self.assertEqual(BOX_BREAKOUT_CLOSE_OPPOSITE, "box_opposite_break")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""阻力/支撑提醒:占位与间隔防重复推送."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from lib.key_monitor.key_monitor_lib import (
|
||||
claim_rs_level_notify,
|
||||
notify_interval_elapsed,
|
||||
run_rs_level_alert_tick,
|
||||
)
|
||||
|
||||
|
||||
def _row(**kwargs):
|
||||
base = {
|
||||
"upper": 2.174,
|
||||
"lower": 1.694,
|
||||
"notification_count": 0,
|
||||
"max_notify": 3,
|
||||
"notify_interval_min": 5,
|
||||
"direction": "watch",
|
||||
"last_notified_at": None,
|
||||
"last_rs_bar_ts": None,
|
||||
}
|
||||
base.update(kwargs)
|
||||
return base
|
||||
|
||||
|
||||
class TestRsLevelAlertClaim(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.conn = sqlite3.connect(":memory:")
|
||||
self.conn.execute(
|
||||
"CREATE TABLE key_monitors ("
|
||||
"id INTEGER PRIMARY KEY, notification_count INTEGER DEFAULT 0, "
|
||||
"direction TEXT, last_notified_at TEXT, last_rs_bar_ts INTEGER)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"INSERT INTO key_monitors (id, notification_count, direction) VALUES (1, 0, 'watch')"
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def test_claim_advances_once_per_index(self):
|
||||
ok1 = claim_rs_level_notify(
|
||||
self.conn, 1, 1, "long", "2026-06-02 00:25:00", 1000, prior_count=0
|
||||
)
|
||||
self.conn.commit()
|
||||
self.assertTrue(ok1)
|
||||
ok_dup = claim_rs_level_notify(
|
||||
self.conn, 1, 1, "long", "2026-06-02 00:25:03", 1000, prior_count=0
|
||||
)
|
||||
self.assertFalse(ok_dup)
|
||||
ok2 = claim_rs_level_notify(
|
||||
self.conn, 1, 2, "long", "2026-06-02 00:30:00", 1000, prior_count=1
|
||||
)
|
||||
self.conn.commit()
|
||||
self.assertTrue(ok2)
|
||||
row = self.conn.execute(
|
||||
"SELECT notification_count FROM key_monitors WHERE id=1"
|
||||
).fetchone()
|
||||
self.assertEqual(row[0], 2)
|
||||
|
||||
def test_second_push_requires_interval(self):
|
||||
now = datetime(2026, 6, 2, 0, 26, 0)
|
||||
row = _row(
|
||||
notification_count=1,
|
||||
direction="long",
|
||||
last_notified_at="2026-06-02 00:25:00",
|
||||
)
|
||||
tick = run_rs_level_alert_tick(row, 2.18, 1000, now, default_max_notify=3, default_interval_min=5)
|
||||
self.assertIsNone(tick)
|
||||
later = datetime(2026, 6, 2, 0, 30, 1)
|
||||
tick2 = run_rs_level_alert_tick(
|
||||
row, 2.18, 1000, later, default_max_notify=3, default_interval_min=5
|
||||
)
|
||||
self.assertIsNotNone(tick2)
|
||||
self.assertEqual(tick2["notify_index"], 2)
|
||||
self.assertEqual(tick2["prior_count"], 1)
|
||||
|
||||
def test_notify_interval_invalid_timestamp_does_not_spam(self):
|
||||
now = datetime(2026, 6, 2, 1, 0, 0)
|
||||
self.assertFalse(notify_interval_elapsed("not-a-date", 5, now))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,27 @@
|
||||
import unittest
|
||||
|
||||
from lib.key_monitor.key_monitor_lib import (
|
||||
KEY_MONITOR_RS_TYPE,
|
||||
is_rs_key_monitor_type,
|
||||
rs_monitor_type_for_storage,
|
||||
rs_monitor_type_label,
|
||||
)
|
||||
|
||||
|
||||
class KeyMonitorRsTypeTests(unittest.TestCase):
|
||||
def test_legacy_types_still_recognized(self):
|
||||
self.assertTrue(is_rs_key_monitor_type("关键阻力位"))
|
||||
self.assertTrue(is_rs_key_monitor_type("关键支撑位"))
|
||||
|
||||
def test_storage_normalizes_to_unified_type(self):
|
||||
self.assertEqual(rs_monitor_type_for_storage("关键阻力位"), KEY_MONITOR_RS_TYPE)
|
||||
self.assertEqual(rs_monitor_type_for_storage("关键支撑位"), KEY_MONITOR_RS_TYPE)
|
||||
self.assertEqual(rs_monitor_type_for_storage(KEY_MONITOR_RS_TYPE), KEY_MONITOR_RS_TYPE)
|
||||
|
||||
def test_label_merges_legacy_display(self):
|
||||
self.assertEqual(rs_monitor_type_label("关键阻力位"), KEY_MONITOR_RS_TYPE)
|
||||
self.assertEqual(rs_monitor_type_label("箱体突破"), "箱体突破")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""预估盈亏比(前端 manual_order_rr_preview.js)公式与后端 calc_rr_ratio 口径一致."""
|
||||
|
||||
|
||||
def _calc_rr(direction: str, entry: float, sl: float, tp: float):
|
||||
if entry <= 0 or sl <= 0 or tp <= 0:
|
||||
return None
|
||||
if direction == "short":
|
||||
risk = sl - entry
|
||||
reward = entry - tp
|
||||
else:
|
||||
risk = entry - sl
|
||||
reward = tp - entry
|
||||
if risk <= 0 or reward <= 0:
|
||||
return None
|
||||
return round(reward / risk, 4)
|
||||
|
||||
|
||||
def _calc_rr_from_pct(sl_pct: float, tp_pct: float):
|
||||
if sl_pct <= 0 or tp_pct <= 0:
|
||||
return None
|
||||
return tp_pct / sl_pct
|
||||
|
||||
|
||||
def test_long_price_mode_rr():
|
||||
assert _calc_rr("long", 100.0, 95.0, 110.0) == 2.0
|
||||
|
||||
|
||||
def test_short_price_mode_rr():
|
||||
assert _calc_rr("short", 100.0, 105.0, 90.0) == 2.0
|
||||
|
||||
|
||||
def test_invalid_geometry_returns_none():
|
||||
assert _calc_rr("long", 100.0, 105.0, 110.0) is None
|
||||
assert _calc_rr("short", 100.0, 95.0, 98.0) is None
|
||||
|
||||
|
||||
def test_pct_mode_rr():
|
||||
assert _calc_rr_from_pct(2.0, 4.0) == 2.0
|
||||
assert _calc_rr_from_pct(1.5, 3.0) == 2.0
|
||||
|
||||
|
||||
def _calc_risk_fraction(direction: str, entry: float, sl: float):
|
||||
if entry <= 0 or sl <= 0:
|
||||
return None
|
||||
if direction == "short":
|
||||
risk = sl - entry
|
||||
else:
|
||||
risk = entry - sl
|
||||
if risk <= 0:
|
||||
return None
|
||||
return risk / entry
|
||||
|
||||
|
||||
def _full_margin_risk_u(available: float, buffer: float, leverage: int, direction: str, entry: float, sl: float):
|
||||
rf = _calc_risk_fraction(direction, entry, sl)
|
||||
if rf is None:
|
||||
return None
|
||||
margin = round(available * buffer, 2)
|
||||
return round(margin * leverage * rf, 2)
|
||||
|
||||
|
||||
def test_full_margin_risk_short_hype():
|
||||
# 可用约 23.06U × 0.9 缓冲 × 5x,入场 62.5,止损 63.6
|
||||
risk = _full_margin_risk_u(23.06, 0.9, 5, "short", 62.5, 63.6)
|
||||
assert risk is not None
|
||||
assert 1.5 <= risk <= 2.5
|
||||
@@ -0,0 +1,32 @@
|
||||
from lib.trade.manual_sltp_lib import (
|
||||
MANUAL_FIXED_RR_DEFAULT,
|
||||
calc_tp_from_fixed_rr,
|
||||
parse_fixed_rr,
|
||||
resolve_open_sltp_prices,
|
||||
)
|
||||
|
||||
|
||||
def test_calc_tp_from_fixed_rr_long():
|
||||
tp = calc_tp_from_fixed_rr("long", 100.0, 95.0, 1.5)
|
||||
assert tp == 107.5
|
||||
|
||||
|
||||
def test_calc_tp_from_fixed_rr_short():
|
||||
tp = calc_tp_from_fixed_rr("short", 100.0, 105.0, 1.5)
|
||||
assert tp == 92.5
|
||||
|
||||
|
||||
def test_resolve_open_fixed_rr_mode():
|
||||
sl, tp = resolve_open_sltp_prices(
|
||||
"long",
|
||||
100.0,
|
||||
"fixed_rr",
|
||||
{"sl": "95", "fixed_rr": "1.5"},
|
||||
)
|
||||
assert sl == 95.0
|
||||
assert tp == 107.5
|
||||
|
||||
|
||||
def test_parse_fixed_rr_default():
|
||||
assert parse_fixed_rr(None) == MANUAL_FIXED_RR_DEFAULT
|
||||
assert parse_fixed_rr("2") == 2.0
|
||||
@@ -0,0 +1,39 @@
|
||||
"""OKX 资金账户余额(asset/balances)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from lib.exchange.okx_options_lib import (
|
||||
fetch_funding_balances_via_asset_api,
|
||||
fetch_options_balances,
|
||||
)
|
||||
|
||||
|
||||
class TestOkxFundingBalances(unittest.TestCase):
|
||||
def test_asset_balances_parsed(self):
|
||||
ex = MagicMock()
|
||||
ex.private_get_asset_balances.return_value = {
|
||||
"data": [
|
||||
{"ccy": "USDT", "availBal": "25.5", "bal": "30"},
|
||||
{"ccy": "USDC", "availBal": "10", "bal": "10"},
|
||||
]
|
||||
}
|
||||
total, avail = fetch_funding_balances_via_asset_api(ex)
|
||||
self.assertEqual(avail["USDT"], 25.5)
|
||||
self.assertEqual(total["USDT"], 30.0)
|
||||
self.assertEqual(avail["USDC"], 10.0)
|
||||
|
||||
def test_fetch_options_balances_merges_asset_api(self):
|
||||
ex = MagicMock()
|
||||
ex.fetch_balance.return_value = {"free": {}, "total": {}}
|
||||
ex.private_get_asset_balances.return_value = {
|
||||
"data": [{"ccy": "USDT", "availBal": "18.2", "bal": "18.2"}]
|
||||
}
|
||||
bal = fetch_options_balances(ex, force=True)
|
||||
self.assertEqual(bal["funding_usdt_avail"], 18.2)
|
||||
self.assertEqual(bal["funding_usdt"], 18.2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""OKX 持仓指标解析:未实现盈亏须支持负数."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class TestOkxPositionMetrics(unittest.TestCase):
|
||||
def test_parse_unrealized_pnl_negative(self):
|
||||
from crypto_monitor_okx.app import parse_ccxt_position_metrics
|
||||
|
||||
pos = {
|
||||
"side": "long",
|
||||
"contracts": 10,
|
||||
"markPrice": 0.43,
|
||||
"unrealizedPnl": -1.25,
|
||||
"info": {"upl": "-1.25", "markPx": "0.43"},
|
||||
}
|
||||
out = parse_ccxt_position_metrics(pos, order_leverage=5)
|
||||
self.assertIsNotNone(out)
|
||||
self.assertAlmostEqual(out["unrealized_pnl"], -1.25)
|
||||
self.assertAlmostEqual(out["mark_price"], 0.43)
|
||||
|
||||
def test_parse_unrealized_pnl_zero(self):
|
||||
from crypto_monitor_okx.app import parse_ccxt_position_metrics
|
||||
|
||||
pos = {
|
||||
"side": "long",
|
||||
"contracts": 1,
|
||||
"unrealizedPnl": 0,
|
||||
"info": {"upl": "0"},
|
||||
}
|
||||
out = parse_ccxt_position_metrics(pos)
|
||||
self.assertIsNotNone(out)
|
||||
self.assertEqual(out["unrealized_pnl"], 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""OKX USDT/USDC 现货市价兑换参数."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from lib.exchange.okx_options_lib import spot_market_swap_usdt_usdc
|
||||
|
||||
|
||||
class TestOkxSpotSwap(unittest.TestCase):
|
||||
def test_usdt_to_usdc_uses_quote_ccy(self):
|
||||
ex = MagicMock()
|
||||
ex.private_post_trade_order.return_value = {
|
||||
"data": [{"sCode": "0", "ordId": "1"}],
|
||||
}
|
||||
result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=10)
|
||||
self.assertTrue(result["ok"])
|
||||
body = ex.private_post_trade_order.call_args[0][0]
|
||||
self.assertEqual(body["tgtCcy"], "quote_ccy")
|
||||
self.assertEqual(body["side"], "buy")
|
||||
|
||||
def test_usdc_to_usdt_uses_base_ccy(self):
|
||||
ex = MagicMock()
|
||||
ex.private_post_trade_order.return_value = {
|
||||
"data": [{"sCode": "0", "ordId": "2"}],
|
||||
}
|
||||
result = spot_market_swap_usdt_usdc(ex, direction="usdc_to_usdt", amount=5)
|
||||
self.assertTrue(result["ok"])
|
||||
body = ex.private_post_trade_order.call_args[0][0]
|
||||
self.assertEqual(body["tgtCcy"], "base_ccy")
|
||||
self.assertEqual(body["side"], "sell")
|
||||
|
||||
def test_insufficient_balance_returns_chinese_message(self):
|
||||
ex = MagicMock()
|
||||
ex.private_post_trade_order.side_effect = Exception(
|
||||
'okx {"code":"1","data":[{"sCode":"51008","sMsg":"Order failed. Your available USDT balance is insufficient."}]}'
|
||||
)
|
||||
result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=20)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["msg"], "资金账户 USDT 可用余额不足")
|
||||
self.assertNotIn("{", result["msg"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""期权买入流动性门禁:真实卖一价+深度."""
|
||||
from lib.exchange.okx_options_lib import (
|
||||
cap_option_buy_sheets_to_ask_depth,
|
||||
option_buy_liquidity_ok,
|
||||
)
|
||||
|
||||
|
||||
def test_option_buy_liquidity_ok_requires_ask_and_depth():
|
||||
assert option_buy_liquidity_ok(10, 1)[0] is True
|
||||
assert option_buy_liquidity_ok(10, 0)[0] is False
|
||||
assert option_buy_liquidity_ok(10, None)[0] is False
|
||||
assert option_buy_liquidity_ok(None, 5)[0] is False
|
||||
assert option_buy_liquidity_ok(0, 5)[0] is False
|
||||
|
||||
|
||||
def test_cap_option_buy_sheets_to_ask_depth():
|
||||
capped, msg = cap_option_buy_sheets_to_ask_depth(9, 2.8, min_sz=1)
|
||||
assert capped == 2
|
||||
assert msg == ""
|
||||
capped, msg = cap_option_buy_sheets_to_ask_depth(1, 0.4, min_sz=1)
|
||||
assert capped is None
|
||||
assert "深度不足" in msg
|
||||
@@ -0,0 +1,69 @@
|
||||
"""期权加仓后权利金汇总."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from lib.options.options_db import init_options_tables, sum_open_premium_paid, sum_open_sheets
|
||||
|
||||
|
||||
def _mem_db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def test_sum_open_premium_after_add():
|
||||
conn = _mem_db()
|
||||
inst = "BTC-USD_UM-260717-65500-C"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, sheets, eth_amount, open_quote, premium_paid, status)
|
||||
VALUES (?, 'BTC-USD_UM', 'C', 1, 0.01, 530, 5.3, 'open')
|
||||
""",
|
||||
(inst,),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, sheets, eth_amount, open_quote, premium_paid, status)
|
||||
VALUES (?, 'BTC-USD_UM', 'C', 1, 0.01, 140, 1.4, 'open')
|
||||
""",
|
||||
(inst,),
|
||||
)
|
||||
conn.commit()
|
||||
assert sum_open_premium_paid(conn, inst) == 6.7
|
||||
assert sum_open_sheets(conn, inst) == 2
|
||||
# 最新一笔单独是 1.4,汇总不能只取最新
|
||||
latest = conn.execute(
|
||||
"SELECT premium_paid FROM options_trades WHERE inst_id=? AND status='open' ORDER BY id DESC LIMIT 1",
|
||||
(inst,),
|
||||
).fetchone()
|
||||
assert float(latest["premium_paid"]) == 1.4
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_sum_open_premium_ignores_closed():
|
||||
conn = _mem_db()
|
||||
inst = "ETH-USD_UM-260101-2000-C"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status)
|
||||
VALUES (?, 'ETH-USD_UM', 'C', 1, 0.01, 2.0, 'closed')
|
||||
""",
|
||||
(inst,),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status)
|
||||
VALUES (?, 'ETH-USD_UM', 'C', 2, 0.02, 3.5, 'open')
|
||||
""",
|
||||
(inst,),
|
||||
)
|
||||
conn.commit()
|
||||
assert sum_open_premium_paid(conn, inst) == 3.5
|
||||
assert sum_open_sheets(conn, inst) == 2
|
||||
conn.close()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""期权平仓门控:可回收≥2×权利金且持续持有."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from lib.options.options_close_gate_lib import (
|
||||
clear_close_gate,
|
||||
is_close_gate_passed,
|
||||
mark_close_gate_passed,
|
||||
update_close_gate,
|
||||
)
|
||||
|
||||
|
||||
class OptionsCloseGateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
clear_close_gate()
|
||||
|
||||
def tearDown(self):
|
||||
clear_close_gate()
|
||||
|
||||
def test_below_2x_not_ready(self):
|
||||
g = update_close_gate("ETH-X", recycle_usdc=15.0, premium_paid=10.0, now=1000.0)
|
||||
self.assertFalse(g["recycle_ok"])
|
||||
self.assertFalse(g["ready"])
|
||||
|
||||
def test_meets_2x_needs_hold(self):
|
||||
g1 = update_close_gate("ETH-X", recycle_usdc=20.0, premium_paid=10.0, now=1000.0)
|
||||
self.assertTrue(g1["recycle_ok"])
|
||||
self.assertFalse(g1["ready"])
|
||||
self.assertAlmostEqual(g1["remain_seconds"], 120.0)
|
||||
|
||||
g2 = update_close_gate("ETH-X", recycle_usdc=21.0, premium_paid=10.0, now=1120.0)
|
||||
self.assertTrue(g2["ready"])
|
||||
self.assertGreaterEqual(g2["held_seconds"], 120.0)
|
||||
|
||||
def test_break_resets_timer(self):
|
||||
update_close_gate("ETH-X", recycle_usdc=20.0, premium_paid=10.0, now=1000.0)
|
||||
update_close_gate("ETH-X", recycle_usdc=21.0, premium_paid=10.0, now=1100.0)
|
||||
g_break = update_close_gate("ETH-X", recycle_usdc=12.0, premium_paid=10.0, now=1110.0)
|
||||
self.assertFalse(g_break["recycle_ok"])
|
||||
g_again = update_close_gate("ETH-X", recycle_usdc=22.0, premium_paid=10.0, now=1111.0)
|
||||
self.assertTrue(g_again["recycle_ok"])
|
||||
self.assertFalse(g_again["ready"])
|
||||
self.assertAlmostEqual(g_again["held_seconds"], 0.0)
|
||||
|
||||
def test_passed_latches_after_ready(self):
|
||||
update_close_gate("ETH-Y", recycle_usdc=20.0, premium_paid=10.0, now=1000.0)
|
||||
g_ready = update_close_gate("ETH-Y", recycle_usdc=21.0, premium_paid=10.0, now=1120.0)
|
||||
self.assertTrue(g_ready["ready"])
|
||||
self.assertTrue(g_ready["passed"])
|
||||
self.assertTrue(is_close_gate_passed("ETH-Y"))
|
||||
# 后续回收跌破 2×:计时重置,但 passed 仍保留供续批只验流动性
|
||||
g_drop = update_close_gate("ETH-Y", recycle_usdc=5.0, premium_paid=10.0, now=1130.0)
|
||||
self.assertFalse(g_drop["recycle_ok"])
|
||||
self.assertTrue(g_drop["passed"])
|
||||
self.assertFalse(g_drop["auto_close_blocked"])
|
||||
|
||||
def test_mark_passed_manual(self):
|
||||
mark_close_gate_passed("ETH-Z")
|
||||
self.assertTrue(is_close_gate_passed("ETH-Z"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
from unittest import TestCase
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from lib.options.options_hub_lib import build_options_hub_snapshot
|
||||
|
||||
|
||||
class OptionsHubLibTests(TestCase):
|
||||
def test_build_options_hub_snapshot_disabled(self):
|
||||
out = build_options_hub_snapshot({"enabled": False})
|
||||
self.assertFalse(out["enabled"])
|
||||
self.assertTrue(out["ok"])
|
||||
|
||||
@patch("lib.options.options_hub_lib._compute_options_stats", return_value={})
|
||||
@patch("lib.options.options_positions_lib.build_display_option_positions")
|
||||
def test_build_options_hub_snapshot_positions(self, mock_positions, _mock_stats):
|
||||
mock_positions.return_value = [
|
||||
{
|
||||
"inst_id": "ETH-USD_UM-260703-1800-C",
|
||||
"pos": 2,
|
||||
"upl": 9.9,
|
||||
"mark_px": 0.1,
|
||||
"close_preview": {"estimated_pnl": 1.5},
|
||||
}
|
||||
]
|
||||
conn = MagicMock()
|
||||
conn.__enter__ = MagicMock(return_value=conn)
|
||||
conn.__exit__ = MagicMock(return_value=False)
|
||||
cfg = {
|
||||
"enabled": True,
|
||||
"exchange_options": object(),
|
||||
"options_api_ready": lambda ex: (True, ""),
|
||||
"fetch_option_positions": lambda ex: [
|
||||
{"instId": "ETH-USD_UM-260703-1800-C", "pos": "2", "upl": "1.5", "markPx": "0.1"}
|
||||
],
|
||||
"fetch_options_balances": lambda ex: {"trading_usdc": 9.5, "funding_usdc": 12.0},
|
||||
"get_db": MagicMock(return_value=conn),
|
||||
"trade_budget": 10,
|
||||
"account_label": "OKX期权",
|
||||
}
|
||||
with patch("lib.options.options_target_lib.list_active_targets", return_value=[]):
|
||||
with patch("lib.options.options_target_lib.targets_by_inst", return_value={}):
|
||||
out = build_options_hub_snapshot(cfg)
|
||||
self.assertTrue(out["ok"], out.get("msg"))
|
||||
self.assertEqual(out["position_count"], 1)
|
||||
self.assertEqual(out["upl_total_usdc"], 1.5)
|
||||
self.assertEqual(out["trading_usdc"], 9.5)
|
||||
self.assertEqual(out.get("target_monitors"), [])
|
||||
@@ -0,0 +1,32 @@
|
||||
"""期权净盈亏汇总与持仓卡口径一致."""
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib.options.options_positions_lib import net_pnl_from_display_row, sum_options_net_pnl_usdc
|
||||
|
||||
|
||||
class OptionsNetPnlSumTests(TestCase):
|
||||
def test_net_pnl_from_display_row(self):
|
||||
self.assertEqual(
|
||||
net_pnl_from_display_row({"close_preview": {"estimated_pnl": -2.8}, "premium_paid": 4.95}),
|
||||
-2.8,
|
||||
)
|
||||
self.assertIsNone(
|
||||
net_pnl_from_display_row({"close_preview": {"bid_invalid": True, "estimated_pnl": -1}})
|
||||
)
|
||||
self.assertEqual(
|
||||
net_pnl_from_display_row(
|
||||
{"close_preview": {"total_received": 2.15}, "premium_paid": 4.95}
|
||||
),
|
||||
round(2.15 - 4.95, 4),
|
||||
)
|
||||
|
||||
@patch("lib.options.options_positions_lib.build_display_option_positions")
|
||||
def test_sum_options_net_pnl_usdc(self, mock_build):
|
||||
mock_build.return_value = [
|
||||
{"close_preview": {"estimated_pnl": -2.8}},
|
||||
{"close_preview": {"estimated_pnl": 1.0}},
|
||||
{"close_preview": {"bid_invalid": True, "estimated_pnl": 9}},
|
||||
]
|
||||
cfg = {"fetch_option_positions": lambda ex: [{"instId": "X"}]}
|
||||
self.assertEqual(sum_options_net_pnl_usdc(cfg, object()), -1.8)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""期权挂单超时撤单单测."""
|
||||
from unittest import TestCase
|
||||
|
||||
from lib.options.options_pending_lib import (
|
||||
cancel_stale_close_pending_orders,
|
||||
enrich_pending_orders,
|
||||
is_close_pending_order,
|
||||
order_age_seconds,
|
||||
)
|
||||
|
||||
|
||||
class OptionsPendingLibTests(TestCase):
|
||||
def test_order_age_and_close_detect(self):
|
||||
now = 1_700_000_600_000
|
||||
age = order_age_seconds({"c_time": now - 90_000}, now_ms=now)
|
||||
self.assertAlmostEqual(age, 90.0, places=3)
|
||||
self.assertTrue(is_close_pending_order({"side": "sell"}))
|
||||
self.assertTrue(is_close_pending_order({"side": "buy", "reduce_only": True}))
|
||||
self.assertFalse(is_close_pending_order({"side": "buy"}))
|
||||
|
||||
def test_enrich_expire(self):
|
||||
now = 1_700_000_600_000
|
||||
rows = enrich_pending_orders(
|
||||
[
|
||||
{"ord_id": "1", "inst_id": "A", "side": "sell", "c_time": now - 700_000},
|
||||
{"ord_id": "2", "inst_id": "B", "side": "buy", "c_time": now - 700_000},
|
||||
{"ord_id": "3", "inst_id": "C", "side": "sell", "c_time": now - 30_000},
|
||||
],
|
||||
ttl_seconds=600,
|
||||
now_ms=now,
|
||||
)
|
||||
by_id = {r["ord_id"]: r for r in rows}
|
||||
self.assertTrue(by_id["1"]["stale"])
|
||||
self.assertTrue(by_id["1"]["auto_cancel_enabled"])
|
||||
self.assertFalse(by_id["2"]["auto_cancel_enabled"])
|
||||
self.assertFalse(by_id["3"]["stale"])
|
||||
self.assertAlmostEqual(by_id["3"]["expire_in_sec"], 570.0, places=0)
|
||||
|
||||
def test_cancel_stale_only_close(self):
|
||||
now = 1_700_000_600_000
|
||||
pending = [
|
||||
{"ord_id": "s1", "inst_id": "A", "side": "sell", "c_time": now - 700_000},
|
||||
{"ord_id": "b1", "inst_id": "B", "side": "buy", "c_time": now - 700_000},
|
||||
{"ord_id": "s2", "inst_id": "C", "side": "sell", "c_time": now - 10_000},
|
||||
]
|
||||
cancelled = []
|
||||
|
||||
def fetch(_ex=None):
|
||||
return pending
|
||||
|
||||
def cancel(_ex=None, inst_id=None, ord_id=None):
|
||||
cancelled.append((inst_id, ord_id))
|
||||
return {"ok": True}
|
||||
|
||||
out = cancel_stale_close_pending_orders(
|
||||
fetch_pending=fetch,
|
||||
cancel_order=cancel,
|
||||
ttl_seconds=60,
|
||||
now_ms=now,
|
||||
ex=object(),
|
||||
)
|
||||
self.assertEqual(out["cancelled"], 1)
|
||||
self.assertEqual(cancelled, [("A", "s1")])
|
||||
@@ -0,0 +1,397 @@
|
||||
"""期权定价单测."""
|
||||
from lib.options.options_pricing_lib import (
|
||||
calc_order_size,
|
||||
premium_per_sheet,
|
||||
sheets_from_eth_amount,
|
||||
total_premium,
|
||||
)
|
||||
from lib.exchange.okx_options_lib import format_option_px, inst_family_from_inst_id, round_option_px
|
||||
|
||||
|
||||
def test_inst_family_from_inst_id():
|
||||
assert inst_family_from_inst_id("ETH-USD_UM-260707-1790-C") == "ETH-USD_UM"
|
||||
assert inst_family_from_inst_id("BTC-USD-260925-60000-C") == "BTC-USD"
|
||||
|
||||
|
||||
def test_round_option_px():
|
||||
assert round_option_px(14.9184, "0.2", "sell") == 14.8
|
||||
assert round_option_px(14.81, "0.2", "buy") == 15.0
|
||||
assert format_option_px(14.8, "0.2") == "14.8"
|
||||
# BTC 期权 tickSz=5: 整数末尾 0 必须保留 (1370 不能显成 137)
|
||||
assert format_option_px(1370, "5") == "1370"
|
||||
assert format_option_px(1160, 5) == "1160"
|
||||
assert format_option_px(1000, "5") == "1000"
|
||||
# 无 tick 时不得透出浮点毛刺
|
||||
assert format_option_px(482.4881990066513, None) == "482.4882"
|
||||
|
||||
|
||||
def test_premium_per_sheet():
|
||||
assert abs(premium_per_sheet(15.6, 0.01) - 0.156) < 1e-9
|
||||
|
||||
|
||||
def test_total_premium_half_eth():
|
||||
assert abs(total_premium(15.6, 0.5) - 7.8) < 1e-9
|
||||
|
||||
|
||||
def test_sheets_from_eth():
|
||||
assert sheets_from_eth_amount(0.5, 0.01) == 50
|
||||
|
||||
|
||||
def test_calc_order_size_budget():
|
||||
r = calc_order_size(
|
||||
quote_per_unit=15.6,
|
||||
ct_mult=0.01,
|
||||
min_sz=1,
|
||||
budget_usdc=10,
|
||||
budget_buffer=0.95,
|
||||
budget_cap=10,
|
||||
)
|
||||
assert r["ok"] is True
|
||||
assert r["sheets"] >= 1
|
||||
assert r["total_premium"] <= 10
|
||||
|
||||
|
||||
def test_calc_order_size_sheets():
|
||||
r = calc_order_size(
|
||||
quote_per_unit=15.6,
|
||||
ct_mult=0.01,
|
||||
min_sz=1,
|
||||
sheets=3,
|
||||
budget_cap=10,
|
||||
)
|
||||
assert r["ok"] is True
|
||||
assert r["sheets"] == 3
|
||||
assert abs(r["total_premium"] - 0.468) < 1e-9
|
||||
|
||||
|
||||
def test_option_moneyness():
|
||||
from lib.options.options_pricing_lib import option_moneyness, option_moneyness_label
|
||||
|
||||
assert option_moneyness(opt_type="C", strike=1700, index_px=1800) == "itm"
|
||||
assert option_moneyness(opt_type="C", strike=1900, index_px=1800) == "otm"
|
||||
assert option_moneyness_label("itm") == "实值"
|
||||
assert option_moneyness_label("otm") == "虚值"
|
||||
|
||||
|
||||
def test_equivalent_contract_leverage():
|
||||
from lib.options.options_pricing_lib import equivalent_contract_leverage
|
||||
|
||||
# index 1768, 0.2 ETH, premium 2.44 -> ~144.9x
|
||||
lev = equivalent_contract_leverage(index_px=1768, eth_amount=0.2, total_premium=2.44)
|
||||
assert lev == 144.9
|
||||
|
||||
|
||||
def test_straddle_pricing():
|
||||
from lib.options.options_pricing_lib import (
|
||||
format_straddle_band,
|
||||
straddle_ask_per_unit,
|
||||
straddle_breakeven_band,
|
||||
straddle_premium_total,
|
||||
)
|
||||
|
||||
assert straddle_ask_per_unit(0.148, 16.2) == 16.348
|
||||
assert straddle_premium_total(0.148, 16.2, 1.0) == 16.35
|
||||
lo, hi = straddle_breakeven_band(1800, 16.348)
|
||||
assert lo == 1783.65
|
||||
assert hi == 1816.35
|
||||
assert format_straddle_band(1800, 16.348) == "1784 ~ 1816"
|
||||
assert straddle_ask_per_unit(0.148, None) is None
|
||||
|
||||
|
||||
def test_estimate_expiry_value_and_profit_at_index():
|
||||
from lib.options.options_pricing_lib import (
|
||||
estimate_expiry_profit_at_index,
|
||||
estimate_expiry_value_at_index,
|
||||
)
|
||||
|
||||
value = estimate_expiry_value_at_index(
|
||||
opt_type="C", strike=1800, target_idx=2000, eth_amount=1.0
|
||||
)
|
||||
assert value == 200.0
|
||||
|
||||
profit = estimate_expiry_profit_at_index(
|
||||
opt_type="C",
|
||||
strike=1800,
|
||||
target_idx=2000,
|
||||
entry_px=0.148,
|
||||
eth_amount=1.0,
|
||||
total_premium=14.8,
|
||||
)
|
||||
assert profit == 185.2
|
||||
|
||||
# Call 1780, ask 12.2, 0.01 ETH, target 1793 -> value 0.13, profit 0.01
|
||||
v = estimate_expiry_value_at_index(
|
||||
opt_type="C", strike=1780, target_idx=1793, eth_amount=0.01
|
||||
)
|
||||
assert v == 0.13
|
||||
p = estimate_expiry_profit_at_index(
|
||||
opt_type="C",
|
||||
strike=1780,
|
||||
target_idx=1793,
|
||||
entry_px=12.2,
|
||||
eth_amount=0.01,
|
||||
total_premium=0.122,
|
||||
)
|
||||
assert p == 0.01
|
||||
# OTM call loses premium
|
||||
p2 = estimate_expiry_profit_at_index(
|
||||
opt_type="C",
|
||||
strike=1780,
|
||||
target_idx=1770,
|
||||
entry_px=12.2,
|
||||
eth_amount=0.01,
|
||||
total_premium=0.122,
|
||||
)
|
||||
assert p2 == -0.12
|
||||
|
||||
|
||||
def test_resolve_chain_quote_otm_no_quote():
|
||||
from lib.exchange.okx_options_lib import _resolve_chain_quote
|
||||
|
||||
q = _resolve_chain_quote(
|
||||
ticker={},
|
||||
meta={"tickSz": "0.2"},
|
||||
opt_type="C",
|
||||
strike=1800,
|
||||
index_px=1776,
|
||||
)
|
||||
assert q["ask"] is None
|
||||
assert q["bid"] is None
|
||||
assert q["ask_estimated"] is False
|
||||
|
||||
|
||||
def test_resolve_chain_quote_estimated_ask():
|
||||
from lib.exchange.okx_options_lib import _resolve_chain_quote
|
||||
|
||||
q = _resolve_chain_quote(
|
||||
ticker={"bidPx": "0.2", "bidSz": "3500"},
|
||||
meta={"tickSz": "0.2"},
|
||||
opt_type="C",
|
||||
strike=1650,
|
||||
index_px=1776,
|
||||
)
|
||||
assert q["ask_estimated"] is True
|
||||
assert q["ask"] is not None
|
||||
assert q["ask"] >= 120
|
||||
|
||||
|
||||
def test_format_quote_liquidity():
|
||||
from lib.options.options_pricing_lib import format_quote_liquidity
|
||||
|
||||
assert format_quote_liquidity(17.2, 150) == "17.2/150"
|
||||
assert format_quote_liquidity(817.6, 11) == "817.6/11"
|
||||
assert format_quote_liquidity(15.6, None) == "15.6"
|
||||
assert format_quote_liquidity(None, 10) is None
|
||||
|
||||
|
||||
def test_estimate_close_by_bids_full_depth():
|
||||
from lib.options.options_pricing_lib import estimate_close_by_bids
|
||||
|
||||
# 多档估算需显式 max_levels;默认只估买一
|
||||
out = estimate_close_by_bids(
|
||||
[{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}],
|
||||
4,
|
||||
ct_mult=0.01,
|
||||
premium_paid=0.4,
|
||||
max_levels=5,
|
||||
)
|
||||
assert out["covered_sheets"] == 4
|
||||
assert out["uncovered_sheets"] == 0
|
||||
assert out["total_received"] == 0.488
|
||||
assert out["avg_px"] == 12.2
|
||||
assert out["estimated_pnl"] == 0.088
|
||||
assert out["estimated_pnl_ratio_pct"] == 22.0
|
||||
assert [x["sheets"] for x in out["levels"]] == [2, 2]
|
||||
|
||||
bid1 = estimate_close_by_bids(
|
||||
[{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}],
|
||||
4,
|
||||
ct_mult=0.01,
|
||||
premium_paid=0.4,
|
||||
)
|
||||
assert bid1["covered_sheets"] == 2
|
||||
assert bid1["uncovered_sheets"] == 2
|
||||
assert [x["sheets"] for x in bid1["levels"]] == [2]
|
||||
|
||||
|
||||
def test_estimate_close_by_bids_partial_depth():
|
||||
from lib.options.options_pricing_lib import estimate_close_by_bids
|
||||
|
||||
out = estimate_close_by_bids([{"px": 10, "sz": 1}], 3, ct_mult=0.01, premium_paid=0.6)
|
||||
assert out["covered_sheets"] == 1
|
||||
assert out["uncovered_sheets"] == 2
|
||||
assert out["total_received"] == 0.1
|
||||
# 净盈亏 = 回收 − 全部权利金(不按覆盖比例摊薄)
|
||||
assert out["estimated_pnl"] == -0.5
|
||||
assert out["estimated_pnl_ratio_pct"] == round(-0.5 / 0.6 * 100, 2)
|
||||
|
||||
|
||||
def test_estimate_close_by_bids_empty():
|
||||
from lib.options.options_pricing_lib import estimate_close_by_bids
|
||||
|
||||
out = estimate_close_by_bids([], 2)
|
||||
assert out["covered_sheets"] == 0
|
||||
assert out["uncovered_sheets"] == 2
|
||||
assert out["avg_px"] is None
|
||||
|
||||
|
||||
def test_stub_bid_blocks_auto_close_estimate():
|
||||
from lib.options.options_pricing_lib import estimate_close_by_bids, is_stub_bid_px
|
||||
|
||||
stub, reason = is_stub_bid_px(0.2, mark_px=42.0)
|
||||
assert stub is True
|
||||
assert "残档" in reason or "无效" in reason or "远低于" in reason
|
||||
|
||||
out = estimate_close_by_bids(
|
||||
[{"px": 0.2, "sz": 3500}],
|
||||
66,
|
||||
ct_mult=0.01,
|
||||
premium_paid=9.37,
|
||||
mark_px=42.0,
|
||||
)
|
||||
assert out["auto_close_blocked"] is True
|
||||
assert out["bid_invalid"] is True
|
||||
assert out["estimated_pnl"] is None
|
||||
assert out["levels"] == []
|
||||
|
||||
ok, _ = is_stub_bid_px(30.0, mark_px=42.0)
|
||||
assert ok is False
|
||||
good = estimate_close_by_bids(
|
||||
[{"px": 30.0, "sz": 100}],
|
||||
10,
|
||||
ct_mult=0.01,
|
||||
premium_paid=1.0,
|
||||
mark_px=42.0,
|
||||
)
|
||||
assert good["auto_close_blocked"] is False
|
||||
assert good["covered_sheets"] == 10
|
||||
|
||||
|
||||
def test_expiry_breakeven_from_ask():
|
||||
from lib.options.options_pricing_lib import expiry_breakeven_from_ask
|
||||
|
||||
assert expiry_breakeven_from_ask(opt_type="C", strike=1760, ask_px=15.6) == 1775.6
|
||||
assert expiry_breakeven_from_ask(opt_type="P", strike=1760, ask_px=15.6) == 1744.4
|
||||
assert expiry_breakeven_from_ask(opt_type="C", strike=1760, ask_px=None, mark_px=14.2) == 1774.2
|
||||
|
||||
|
||||
def test_calc_order_size_too_small():
|
||||
r = calc_order_size(
|
||||
quote_per_unit=2000.0,
|
||||
ct_mult=0.01,
|
||||
min_sz=1,
|
||||
budget_usdc=10,
|
||||
budget_buffer=0.95,
|
||||
budget_cap=10,
|
||||
)
|
||||
assert r["ok"] is False
|
||||
|
||||
|
||||
def test_expiry_breakeven_from_api():
|
||||
from lib.options.options_pricing_lib import expiry_breakeven_px
|
||||
|
||||
assert expiry_breakeven_px(
|
||||
opt_type="C", strike=3500, avg_px=15.6, be_px_api=3516.2
|
||||
) == 3516.2
|
||||
|
||||
|
||||
def test_expiry_breakeven_call_put():
|
||||
from lib.options.options_pricing_lib import expiry_breakeven_px
|
||||
|
||||
assert expiry_breakeven_px(opt_type="C", strike=3500, avg_px=15.6) == 3515.6
|
||||
assert expiry_breakeven_px(opt_type="P", strike=3500, avg_px=15.6) == 3484.4
|
||||
|
||||
|
||||
def test_close_breakeven_at_mark_equals_avg():
|
||||
from lib.options.options_pricing_lib import close_breakeven_idx
|
||||
|
||||
assert close_breakeven_idx(
|
||||
opt_type="C", idx_px=3480, mark_px=15.6, avg_px=15.6
|
||||
) == 3480.0
|
||||
assert close_breakeven_idx(
|
||||
opt_type="P", idx_px=3480, mark_px=15.6, avg_px=15.6
|
||||
) == 3480.0
|
||||
|
||||
|
||||
def test_close_breakeven_with_delta():
|
||||
from lib.options.options_pricing_lib import close_breakeven_idx
|
||||
|
||||
# mark below avg, delta 0.5 ETH on 0.5 ETH position -> slope 1
|
||||
be = close_breakeven_idx(
|
||||
opt_type="C",
|
||||
idx_px=3480,
|
||||
mark_px=14.6,
|
||||
avg_px=15.6,
|
||||
delta_pa=0.5,
|
||||
pos=50,
|
||||
ct_mult=0.01,
|
||||
)
|
||||
assert be == 3481.0
|
||||
|
||||
|
||||
def test_format_options_breakeven_line():
|
||||
from lib.options.options_pricing_lib import format_options_breakeven_line
|
||||
|
||||
s = format_options_breakeven_line(
|
||||
expiry_be_px=3515.6, close_be_px=3498.0, idx_px=3480.0
|
||||
)
|
||||
assert "到期平衡3516" in s
|
||||
assert "平掉回本3498" in s
|
||||
assert "指数3480" in s
|
||||
|
||||
|
||||
def test_format_position_row_premium_and_inst_parse():
|
||||
from lib.exchange.okx_options_lib import format_position_row
|
||||
|
||||
row = format_position_row(
|
||||
{
|
||||
"instId": "ETH-USD_UM-260709-1700-P",
|
||||
"pos": "20",
|
||||
"avgPx": "6.2",
|
||||
"markPx": "6.3241",
|
||||
"idxPx": "1746",
|
||||
"upl": "0.0248",
|
||||
"uplRatio": "0.02",
|
||||
}
|
||||
)
|
||||
assert row["opt_type"] == "P"
|
||||
assert row["strike"] == 1700.0
|
||||
assert row["premium_paid"] == 1.24
|
||||
assert row["exp_time_ms"] is not None
|
||||
assert row["exp_time_ms"] > 0
|
||||
|
||||
|
||||
def test_expiry_ms_from_inst_id():
|
||||
from lib.exchange.okx_options_lib import expiry_ms_from_inst_id, normalize_option_exp_ms
|
||||
|
||||
ms = expiry_ms_from_inst_id("ETH-USD_UM-260709-1700-P")
|
||||
assert ms is not None
|
||||
from datetime import datetime, timezone
|
||||
|
||||
dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
|
||||
assert dt.year == 2026 and dt.month == 7 and dt.day == 9 and dt.hour == 8
|
||||
assert normalize_option_exp_ms(None, "ETH-USD_UM-260709-1700-P") == ms
|
||||
|
||||
|
||||
def test_format_position_row_breakeven():
|
||||
from lib.exchange.okx_options_lib import format_position_row
|
||||
|
||||
row = format_position_row(
|
||||
{
|
||||
"instId": "ETH-USD_UM-260703-1800-C",
|
||||
"pos": "50",
|
||||
"avgPx": "15.6",
|
||||
"markPx": "16.2",
|
||||
"idxPx": "3480",
|
||||
"bePx": "3515.6",
|
||||
"optType": "C",
|
||||
"stk": "3500",
|
||||
"deltaPA": "0.45",
|
||||
"upl": "0.3",
|
||||
"uplRatio": "0.02",
|
||||
}
|
||||
)
|
||||
assert row["expiry_be_px"] == 3515.6
|
||||
assert row["idx_px"] == 3480.0
|
||||
assert row["close_be_px"] is not None
|
||||
assert row["dist_expiry_be"] == 35.6
|
||||
@@ -0,0 +1,342 @@
|
||||
"""期权复盘(含对冲)单元测试:导入去重、双计防护、复盘不被覆盖、统计."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_leg, insert_plan
|
||||
from lib.options.options_review_db import SOURCE_OPTION, SOURCE_PERP_OPTIONS, init_options_review_tables
|
||||
from lib.options.options_review_images_lib import (
|
||||
build_options_review_slot_filename,
|
||||
is_valid_options_review_file,
|
||||
options_review_upload_dir,
|
||||
save_options_review_slot_file,
|
||||
)
|
||||
from lib.options.options_review_lib import (
|
||||
compute_review_stats,
|
||||
list_review_trades,
|
||||
save_review_entry,
|
||||
sync_hedge_plans_closed,
|
||||
sync_options_from_exchange,
|
||||
upsert_option_history_row,
|
||||
)
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.row_factory = sqlite3.Row
|
||||
init_options_review_tables(c)
|
||||
init_hedge_plan_tables(c)
|
||||
return c
|
||||
|
||||
|
||||
class _FakeFile:
|
||||
def __init__(self, name: str, data: bytes = b"img"):
|
||||
self.filename = name
|
||||
self._data = data
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
Path(path).write_bytes(self._data)
|
||||
|
||||
|
||||
class OptionsReviewTests(unittest.TestCase):
|
||||
def test_option_upsert_idempotent(self):
|
||||
conn = _conn()
|
||||
row = {
|
||||
"history_key": "ex:pos1",
|
||||
"pos_id": "pos1",
|
||||
"inst_id": "ETH-USD-260328-2000-C",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "C",
|
||||
"strike": 2000,
|
||||
"sheets": 10,
|
||||
"open_avg_px": 0.01,
|
||||
"close_avg_px": 0.02,
|
||||
"premium_paid": 1.0,
|
||||
"realized_pnl": 5.5,
|
||||
"created_at": "2026-03-01 10:00:00",
|
||||
"closed_at": "2026-03-01 12:00:00",
|
||||
"status_label": "已平",
|
||||
}
|
||||
self.assertEqual(upsert_option_history_row(conn, row), "inserted")
|
||||
row["realized_pnl"] = 6.0
|
||||
self.assertEqual(upsert_option_history_row(conn, row), "updated")
|
||||
n = conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"]
|
||||
self.assertEqual(n, 1)
|
||||
pnl = conn.execute(
|
||||
"SELECT realized_pnl_total FROM options_review_trades WHERE history_key='ex:pos1'"
|
||||
).fetchone()["realized_pnl_total"]
|
||||
self.assertEqual(float(pnl), 6.0)
|
||||
|
||||
def test_entry_not_overwritten_by_resync(self):
|
||||
conn = _conn()
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:p2",
|
||||
"inst_id": "ETH-USD-260328-1800-P",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "P",
|
||||
"realized_pnl": 1.0,
|
||||
"created_at": "2026-03-02 10:00:00",
|
||||
"closed_at": "2026-03-02 11:00:00",
|
||||
},
|
||||
)
|
||||
tid = conn.execute("SELECT id FROM options_review_trades").fetchone()["id"]
|
||||
save_review_entry(
|
||||
conn,
|
||||
tid,
|
||||
{"strategy_tag": "突破追涨", "note": "keep-me", "images": []},
|
||||
)
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:p2",
|
||||
"inst_id": "ETH-USD-260328-1800-P",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "P",
|
||||
"realized_pnl": 2.0,
|
||||
"created_at": "2026-03-02 10:00:00",
|
||||
"closed_at": "2026-03-02 11:00:00",
|
||||
},
|
||||
)
|
||||
note = conn.execute(
|
||||
"SELECT note, strategy_tag FROM options_review_entries WHERE trade_id=?",
|
||||
(tid,),
|
||||
).fetchone()
|
||||
self.assertEqual(note["note"], "keep-me")
|
||||
self.assertEqual(note["strategy_tag"], "突破追涨")
|
||||
pnl = conn.execute(
|
||||
"SELECT realized_pnl_total FROM options_review_trades WHERE id=?", (tid,)
|
||||
).fetchone()["realized_pnl_total"]
|
||||
self.assertEqual(float(pnl), 2.0)
|
||||
|
||||
def test_hedge_import_and_double_count_guard(self):
|
||||
conn = _conn()
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:leg1",
|
||||
"inst_id": "ETH-USD-260328-2000-C",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "C",
|
||||
"realized_pnl": -3.0,
|
||||
"created_at": "2026-03-03 09:00:00",
|
||||
"closed_at": "2026-03-03 18:00:00",
|
||||
},
|
||||
)
|
||||
plan_id = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": SOURCE_PERP_OPTIONS,
|
||||
"status": "closed",
|
||||
"underlying": "ETH",
|
||||
"direction": "long",
|
||||
"realized_pnl_perp": 20.0,
|
||||
"realized_pnl_options": -3.0,
|
||||
"realized_pnl_total": 17.0,
|
||||
"close_reason": "tp",
|
||||
"opened_at": "2026-03-03 09:00:00",
|
||||
"closed_at": "2026-03-03 18:00:00",
|
||||
"premium_total": 3.0,
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": plan_id,
|
||||
"leg_role": "perp",
|
||||
"symbol": "ETH-USDT-SWAP",
|
||||
"status": "closed",
|
||||
"realized_pnl": 20.0,
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": plan_id,
|
||||
"leg_role": "option_hedge",
|
||||
"inst_id": "ETH-USD-260328-2000-C",
|
||||
"opt_type": "C",
|
||||
"status": "closed",
|
||||
"realized_pnl": -3.0,
|
||||
},
|
||||
)
|
||||
out = sync_hedge_plans_closed(conn)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["inserted"], 1)
|
||||
|
||||
listed = list_review_trades(conn, include_hedge_legs=False)
|
||||
types = {r["source_type"] for r in listed}
|
||||
self.assertIn(SOURCE_PERP_OPTIONS, types)
|
||||
self.assertNotIn(SOURCE_OPTION, types)
|
||||
|
||||
listed_all = list_review_trades(conn, include_hedge_legs=True)
|
||||
self.assertEqual(len(listed_all), 2)
|
||||
|
||||
stats = compute_review_stats(conn, include_hedge_legs=False)
|
||||
self.assertEqual(stats["kpi"]["total"], 1)
|
||||
self.assertEqual(stats["kpi"]["pnl_sum"], 17.0)
|
||||
|
||||
def test_sync_options_from_mock_exchange(self):
|
||||
conn = _conn()
|
||||
|
||||
def fetch(_ex, limit=500):
|
||||
return [
|
||||
{
|
||||
"instId": "ETH-USD-260328-2100-C",
|
||||
"posId": "mock1",
|
||||
"openAvgPx": "0.01",
|
||||
"closeAvgPx": "0.02",
|
||||
"closeTotalPos": "5",
|
||||
"realizedPnl": "1.23",
|
||||
"type": "2",
|
||||
"cTime": "1700000000000",
|
||||
"uTime": "1700003600000",
|
||||
"uly": "ETH-USD",
|
||||
}
|
||||
]
|
||||
|
||||
def fmt(raw, tick_sz=None, ct_mult=0.01):
|
||||
return {
|
||||
"history_key": f"ex:{raw['posId']}",
|
||||
"pos_id": raw["posId"],
|
||||
"inst_id": raw["instId"],
|
||||
"underlying": "ETH",
|
||||
"opt_type": "C",
|
||||
"sheets": 5,
|
||||
"open_avg_px": 0.01,
|
||||
"close_avg_px": 0.02,
|
||||
"premium_paid": 0.5,
|
||||
"realized_pnl": float(raw["realizedPnl"]),
|
||||
"created_at": "2026-01-01 00:00:00",
|
||||
"closed_at": "2026-01-01 01:00:00",
|
||||
"status_label": "已平",
|
||||
}
|
||||
|
||||
result = sync_options_from_exchange(
|
||||
conn, object(), limit=10, fetch_fn=fetch, format_fn=fmt
|
||||
)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["inserted"], 1)
|
||||
row = conn.execute(
|
||||
"SELECT realized_pnl_total FROM options_review_trades WHERE history_key='ex:mock1'"
|
||||
).fetchone()
|
||||
self.assertEqual(float(row["realized_pnl_total"]), 1.23)
|
||||
|
||||
def test_hide_trade_persists_across_local_sync(self):
|
||||
conn = _conn()
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_review_lib import (
|
||||
hide_review_trade,
|
||||
sync_options_from_local_trades,
|
||||
)
|
||||
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, realized_pnl, created_at, closed_at)
|
||||
VALUES ('ETH-USD-1-C','ETH','C',2000,1,0.01,0.01,0.2,'closed',1.0,
|
||||
'2026-03-01 10:00:00','2026-03-01 11:00:00')
|
||||
"""
|
||||
)
|
||||
sync_options_from_local_trades(conn)
|
||||
tid = conn.execute("SELECT id FROM options_review_trades").fetchone()["id"]
|
||||
out = hide_review_trade(conn, tid)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"],
|
||||
0,
|
||||
)
|
||||
sync_options_from_local_trades(conn)
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"],
|
||||
0,
|
||||
)
|
||||
|
||||
def test_local_options_trades_import(self):
|
||||
conn = _conn()
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, realized_pnl, created_at, closed_at)
|
||||
VALUES ('ETH-USD-260328-2000-C','ETH','C',2000,2,0.02,0.01,0.5,'closed',3.2,
|
||||
'2026-03-01 10:00:00','2026-03-01 12:00:00')
|
||||
"""
|
||||
)
|
||||
from lib.options.options_review_lib import sync_options_from_local_trades
|
||||
|
||||
out = sync_options_from_local_trades(conn)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["inserted"], 1)
|
||||
row = conn.execute(
|
||||
"SELECT history_key, realized_pnl_total, source_type FROM options_review_trades"
|
||||
).fetchone()
|
||||
self.assertTrue(str(row["history_key"]).startswith("local_opt:"))
|
||||
self.assertEqual(float(row["realized_pnl_total"]), 3.2)
|
||||
self.assertEqual(row["source_type"], SOURCE_OPTION)
|
||||
|
||||
def test_image_namespace(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
folder = options_review_upload_dir(tmp)
|
||||
fname = build_options_review_slot_filename(
|
||||
"a" * 32, "5m", ".png", secure_filename_fn=lambda x: x
|
||||
)
|
||||
self.assertTrue(fname.startswith("options_journal_"))
|
||||
self.assertTrue(is_valid_options_review_file(fname, "a" * 32, "5m"))
|
||||
item = save_options_review_slot_file(
|
||||
_FakeFile("x.png"),
|
||||
"a" * 32,
|
||||
"5m",
|
||||
folder,
|
||||
secure_filename_fn=lambda x: x,
|
||||
)
|
||||
self.assertIsNotNone(item)
|
||||
self.assertTrue((Path(folder) / item["file"]).is_file())
|
||||
|
||||
def test_strategy_stats_only_tagged(self):
|
||||
conn = _conn()
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:a",
|
||||
"inst_id": "ETH-USD-1-C",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "C",
|
||||
"realized_pnl": 10,
|
||||
"created_at": "2026-01-01 00:00:00",
|
||||
"closed_at": "2026-01-01 02:00:00",
|
||||
},
|
||||
)
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:b",
|
||||
"inst_id": "ETH-USD-2-P",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "P",
|
||||
"realized_pnl": -4,
|
||||
"created_at": "2026-01-01 00:00:00",
|
||||
"closed_at": "2026-01-01 05:00:00",
|
||||
},
|
||||
)
|
||||
tid = conn.execute(
|
||||
"SELECT id FROM options_review_trades WHERE history_key='ex:a'"
|
||||
).fetchone()["id"]
|
||||
save_review_entry(conn, tid, {"strategy_tag": "假破", "images": []})
|
||||
stats = compute_review_stats(conn)
|
||||
self.assertEqual(len(stats["by_strategy"]), 1)
|
||||
self.assertEqual(stats["by_strategy"][0]["key"], "假破")
|
||||
self.assertEqual(stats["kpi"]["total"], 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""期权统计单测."""
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from unittest import TestCase
|
||||
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_stats_lib import compute_options_stats, compute_options_stats_from_history
|
||||
|
||||
|
||||
class OptionsStatsLibTests(TestCase):
|
||||
def _conn(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
return conn
|
||||
|
||||
def test_compute_options_stats_empty(self):
|
||||
conn = self._conn()
|
||||
out = compute_options_stats(lambda: conn)
|
||||
self.assertEqual(out["total_closed"], 0)
|
||||
self.assertEqual(out["open_count"], 0)
|
||||
self.assertIsNone(out["avg_hold_sec"])
|
||||
|
||||
def test_compute_options_stats_hold_times(self):
|
||||
conn = self._conn()
|
||||
now = datetime.now()
|
||||
win_open = (now - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
win_close = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
loss_open = (now - timedelta(hours=4)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
loss_close = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
open_at = (now - timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount, status,
|
||||
realized_pnl, created_at, closed_at)
|
||||
VALUES ('A', 'ETH', 'C', 1800, 1, 0.01, 'closed', 1.2, ?, ?)
|
||||
""",
|
||||
(win_open, win_close),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount, status,
|
||||
realized_pnl, created_at, closed_at)
|
||||
VALUES ('B', 'ETH', 'P', 1700, 1, 0.01, 'closed', -1.0, ?, ?)
|
||||
""",
|
||||
(loss_open, loss_close),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount, status, created_at)
|
||||
VALUES ('C', 'BTC', 'C', 62000, 1, 0.01, 'open', ?)
|
||||
""",
|
||||
(open_at,),
|
||||
)
|
||||
conn.commit()
|
||||
out = compute_options_stats(lambda: conn)
|
||||
self.assertEqual(out["total_closed"], 2)
|
||||
self.assertEqual(out["win_count"], 1)
|
||||
self.assertEqual(out["loss_count"], 1)
|
||||
self.assertEqual(out["win_rate"], 50.0)
|
||||
self.assertAlmostEqual(out["avg_win"], 1.2, places=4)
|
||||
self.assertAlmostEqual(out["avg_loss"], 1.0, places=4)
|
||||
self.assertAlmostEqual(out["avg_win_hold_sec"], 3600.0, delta=5.0)
|
||||
self.assertAlmostEqual(out["avg_loss_hold_sec"], 3 * 3600.0, delta=5.0)
|
||||
self.assertEqual(out["open_count"], 1)
|
||||
self.assertGreater(out["avg_open_hold_sec"], 1700.0)
|
||||
|
||||
def test_compute_options_stats_from_history_exchange_rows(self):
|
||||
history = [
|
||||
{"status": "open", "created_at": "2026-07-11 08:08:38"},
|
||||
{"status": "closed", "realized_pnl": -3.99, "created_at": "2026-07-09 14:11:46", "closed_at": "2026-07-10 16:00:35"},
|
||||
{"status": "closed", "realized_pnl": 0.87, "created_at": "2026-07-09 14:11:46", "closed_at": "2026-07-10 09:55:34"},
|
||||
{"status": "closed", "realized_pnl": -1.33, "created_at": "2026-07-08 02:32:44", "closed_at": "2026-07-09 16:00:26"},
|
||||
]
|
||||
out = compute_options_stats_from_history(history)
|
||||
self.assertEqual(out["total_closed"], 3)
|
||||
self.assertEqual(out["win_count"], 1)
|
||||
self.assertEqual(out["loss_count"], 2)
|
||||
self.assertAlmostEqual(out["avg_win"], 0.87, places=4)
|
||||
self.assertAlmostEqual(out["avg_loss"], 2.66, places=2)
|
||||
self.assertAlmostEqual(out["profit_loss_ratio"], 0.33, places=2)
|
||||
self.assertEqual(out["open_count"], 1)
|
||||
self.assertAlmostEqual(out["net_realized_pnl"], round(0.87 - 3.99 - 1.33, 4), places=4)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""期权平仓/到期状态同步单测."""
|
||||
import sqlite3
|
||||
|
||||
from lib.exchange.okx_options_lib import (
|
||||
format_option_history_row,
|
||||
format_usdc_amount,
|
||||
is_option_full_close_history,
|
||||
resolve_option_close_from_history,
|
||||
)
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_monitor_lib import sync_open_options_trades
|
||||
|
||||
|
||||
def test_format_usdc_amount():
|
||||
assert format_usdc_amount(4.896) == "4.90"
|
||||
assert format_usdc_amount(4.9) == "4.90"
|
||||
assert format_usdc_amount(4.0) == "4.00"
|
||||
|
||||
|
||||
def test_is_option_full_close_history():
|
||||
assert is_option_full_close_history({"type": "2"})
|
||||
assert is_option_full_close_history({"type": "3"})
|
||||
assert not is_option_full_close_history({"type": "1"})
|
||||
assert not is_option_full_close_history({"type": "5"})
|
||||
|
||||
|
||||
def test_format_option_history_row():
|
||||
raw = {
|
||||
"instId": "BTC-USD_UM-260710-62000-P",
|
||||
"openAvgPx": "380",
|
||||
"closeAvgPx": "0",
|
||||
"closeTotalPos": "1",
|
||||
"openMaxPos": "1",
|
||||
"realizedPnl": "-3.99",
|
||||
"pnlRatio": "-1.049",
|
||||
"type": "2",
|
||||
"cTime": "1784000000000",
|
||||
"uTime": "1784088035000",
|
||||
"posId": "pos-btc",
|
||||
}
|
||||
row = format_option_history_row(raw, tick_sz="0.1", ct_mult=0.01)
|
||||
assert row["inst_id"] == "BTC-USD_UM-260710-62000-P"
|
||||
assert row["sheets"] == 1
|
||||
assert row["realized_pnl"] == -3.99
|
||||
assert row["status_label"] == "已平"
|
||||
assert row["open_avg_px_fmt"] == "380"
|
||||
assert row["premium_paid_fmt"] == "3.80"
|
||||
assert row["history_key"] == "ex:pos-btc"
|
||||
|
||||
|
||||
def test_resolve_option_close_from_history_picks_latest():
|
||||
rows = [
|
||||
{"instId": "ETH-USD_UM-260709-1700-P", "uTime": "1000", "realizedPnl": "-1.0", "closeAvgPx": "0"},
|
||||
{"instId": "ETH-USD_UM-260709-1700-P", "uTime": "2000", "realizedPnl": "-1.24", "closeAvgPx": "0", "posId": "9"},
|
||||
]
|
||||
got = resolve_option_close_from_history(rows, open_ms=500)
|
||||
assert got is not None
|
||||
assert got["realized_pnl"] == -1.24
|
||||
assert got["pos_id"] == "9"
|
||||
|
||||
|
||||
def test_sync_open_options_trades_marks_expired_closed():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status)
|
||||
VALUES (?, 'ETH', 'P', 1700, '', 20, 0.2, 6.2, 1.24, 'open')
|
||||
""",
|
||||
("ETH-USD_UM-260709-1700-P",),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
n = sync_open_options_trades(
|
||||
conn,
|
||||
live_inst_ids=set(),
|
||||
fetch_history_fn=lambda _inst: [],
|
||||
)
|
||||
assert n == 1
|
||||
row = conn.execute("SELECT status, premium_received, realized_pnl, signal_note FROM options_trades").fetchone()
|
||||
assert row["status"] == "closed"
|
||||
assert row["premium_received"] == 0.0
|
||||
assert row["realized_pnl"] == -1.24
|
||||
assert "到期结算" in (row["signal_note"] or "")
|
||||
|
||||
|
||||
def test_sync_open_options_trades_skips_without_close_evidence():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, created_at)
|
||||
VALUES (?, 'BTC', 'P', 62000, '', 1, 0.01, 380.0, 3.8, 'open', '2026-07-09 08:00:00')
|
||||
""",
|
||||
("BTC-USD_UM-260710-62000-P",),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
n = sync_open_options_trades(
|
||||
conn,
|
||||
live_inst_ids=set(),
|
||||
fetch_history_fn=lambda _inst: [],
|
||||
)
|
||||
assert n == 0
|
||||
row = conn.execute("SELECT status FROM options_trades").fetchone()
|
||||
assert row["status"] == "open"
|
||||
|
||||
|
||||
def test_reconcile_live_open_trades_reopens_sync_artifact():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, closed_at)
|
||||
VALUES (?, 'BTC', 'P', 62000, '', 1, 0.01, 380.0, 3.8, 'closed', '2026-07-09 09:10:34')
|
||||
""",
|
||||
("BTC-USD_UM-260710-62000-P",),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
from lib.options.options_monitor_lib import reconcile_live_open_trades
|
||||
|
||||
n = reconcile_live_open_trades(conn, live_inst_ids={"BTC-USD_UM-260710-62000-P"})
|
||||
assert n == 1
|
||||
row = conn.execute("SELECT status, closed_at FROM options_trades").fetchone()
|
||||
assert row["status"] == "open"
|
||||
assert row["closed_at"] is None
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, created_at)
|
||||
VALUES (?, 'ETH', 'P', 1700, '', 20, 0.2, 6.2, 1.24, 'open', '2026-07-08 02:32:44')
|
||||
""",
|
||||
("ETH-USD_UM-260709-1700-P",),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _hist(_inst):
|
||||
return [
|
||||
{
|
||||
"instId": "ETH-USD_UM-260709-1700-P",
|
||||
"uTime": "1784000000000",
|
||||
"realizedPnl": "-0.5",
|
||||
"closeAvgPx": "0.1",
|
||||
"posId": "pos-1",
|
||||
}
|
||||
]
|
||||
|
||||
n = sync_open_options_trades(
|
||||
conn,
|
||||
live_inst_ids=set(),
|
||||
fetch_history_fn=_hist,
|
||||
)
|
||||
assert n == 1
|
||||
row = conn.execute(
|
||||
"SELECT status, premium_received, realized_pnl, close_ord_id FROM options_trades"
|
||||
).fetchone()
|
||||
assert row["status"] == "closed"
|
||||
assert row["realized_pnl"] == -0.5
|
||||
assert row["premium_received"] == 0.74
|
||||
assert row["close_ord_id"] == "pos-1"
|
||||
@@ -0,0 +1,169 @@
|
||||
"""期权目标位委托单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from lib.options.options_target_lib import (
|
||||
ensure_target_tables,
|
||||
list_active_targets,
|
||||
list_closing_targets,
|
||||
run_options_target_closes,
|
||||
target_hit,
|
||||
upsert_target_monitor,
|
||||
)
|
||||
|
||||
|
||||
class OptionsTargetLibTests(unittest.TestCase):
|
||||
def test_target_hit_call_put(self):
|
||||
self.assertTrue(target_hit(opt_type="C", index_px=2000, target_index=1950))
|
||||
self.assertFalse(target_hit(opt_type="C", index_px=1900, target_index=1950))
|
||||
self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850))
|
||||
self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850))
|
||||
|
||||
def test_upsert_and_trigger_close(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
ensure_target_tables(conn)
|
||||
out = upsert_target_monitor(
|
||||
conn,
|
||||
inst_id="ETH-USD_UM-260717-1900-C",
|
||||
target_index=1880,
|
||||
opt_type="C",
|
||||
sheets=1,
|
||||
)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(len(list_active_targets(conn)), 1)
|
||||
|
||||
closed = []
|
||||
|
||||
def close_fn(inst_id: str):
|
||||
closed.append(inst_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"submitted_sheets": 1,
|
||||
"premium_received": 1.2,
|
||||
"close_ord_id": "oid1",
|
||||
"fully_closed": True,
|
||||
"remaining_sheets": 0,
|
||||
}
|
||||
|
||||
n = run_options_target_closes(
|
||||
conn,
|
||||
[{"inst_id": "ETH-USD_UM-260717-1900-C", "idx_px": 1885, "opt_type": "C"}],
|
||||
close_fn=close_fn,
|
||||
)
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(closed, ["ETH-USD_UM-260717-1900-C"])
|
||||
self.assertEqual(len(list_active_targets(conn)), 0)
|
||||
|
||||
def test_partial_fill_notifies_once_then_closing_retry_silent(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
ensure_target_tables(conn)
|
||||
upsert_target_monitor(
|
||||
conn,
|
||||
inst_id="ETH-USD_UM-260715-1870-P",
|
||||
target_index=1872,
|
||||
opt_type="P",
|
||||
sheets=1,
|
||||
)
|
||||
conn.commit()
|
||||
notices: list[str] = []
|
||||
calls = {"n": 0}
|
||||
|
||||
def close_fn(inst_id: str):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return {
|
||||
"ok": True,
|
||||
"submitted_sheets": 1,
|
||||
"premium_received": 0.032,
|
||||
"close_ord_id": "oid-a",
|
||||
"fully_closed": False,
|
||||
"remaining_sheets": 1,
|
||||
"stopped_reason": "order_not_filled",
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"submitted_sheets": 1,
|
||||
"premium_received": 0.032,
|
||||
"close_ord_id": "oid-b",
|
||||
"fully_closed": True,
|
||||
"remaining_sheets": 0,
|
||||
"already_flat": True,
|
||||
}
|
||||
|
||||
pos = [{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1867.5, "opt_type": "P"}]
|
||||
n1 = run_options_target_closes(
|
||||
conn,
|
||||
pos,
|
||||
close_fn=close_fn,
|
||||
send_wechat=notices.append,
|
||||
account_label="主账户·期权",
|
||||
)
|
||||
self.assertEqual(n1, 1)
|
||||
self.assertEqual(len(notices), 1)
|
||||
self.assertEqual(len(list_active_targets(conn)), 0)
|
||||
self.assertEqual(len(list_closing_targets(conn)), 1)
|
||||
|
||||
# 模拟后续 sync 异常也不会再推:closing 重试静默
|
||||
n2 = run_options_target_closes(
|
||||
conn,
|
||||
pos,
|
||||
close_fn=close_fn,
|
||||
send_wechat=notices.append,
|
||||
account_label="主账户·期权",
|
||||
)
|
||||
self.assertEqual(n2, 0)
|
||||
self.assertEqual(len(notices), 1)
|
||||
self.assertEqual(len(list_closing_targets(conn)), 0)
|
||||
|
||||
def test_commit_before_wechat_survives_later_rollback(self):
|
||||
"""状态在推送前已 commit,外层异常回滚不应让委托回到 active."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
ensure_target_tables(conn)
|
||||
upsert_target_monitor(
|
||||
conn,
|
||||
inst_id="ETH-USD_UM-260715-1870-P",
|
||||
target_index=1872,
|
||||
opt_type="P",
|
||||
)
|
||||
conn.commit()
|
||||
notices: list[str] = []
|
||||
|
||||
def close_fn(inst_id: str):
|
||||
return {
|
||||
"ok": True,
|
||||
"submitted_sheets": 1,
|
||||
"premium_received": 0.03,
|
||||
"close_ord_id": "oid1",
|
||||
"fully_closed": True,
|
||||
"remaining_sheets": 0,
|
||||
}
|
||||
|
||||
run_options_target_closes(
|
||||
conn,
|
||||
[{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}],
|
||||
close_fn=close_fn,
|
||||
send_wechat=notices.append,
|
||||
)
|
||||
# 模拟 loop 后续 sync 抛错后 close 未再 commit —— 但 status 已提前 commit
|
||||
conn.rollback()
|
||||
self.assertEqual(len(notices), 1)
|
||||
self.assertEqual(len(list_active_targets(conn)), 0)
|
||||
|
||||
# 下一轮不应再次触发推送
|
||||
n2 = run_options_target_closes(
|
||||
conn,
|
||||
[{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}],
|
||||
close_fn=close_fn,
|
||||
send_wechat=notices.append,
|
||||
)
|
||||
self.assertEqual(n2, 0)
|
||||
self.assertEqual(len(notices), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,181 @@
|
||||
from lib.trade.order_monitor_display_lib import (
|
||||
apply_order_price_display_fields,
|
||||
calc_latest_risk_amount,
|
||||
calc_risk_fraction,
|
||||
is_sl_breakeven_secured,
|
||||
monitor_open_stop_loss,
|
||||
order_monitor_tpsl_needs_sync,
|
||||
resolve_breakeven_entry_price,
|
||||
resolve_live_tpsl_prices,
|
||||
sl_breakeven_from_exchange_tpsl,
|
||||
snapshot_rr,
|
||||
snapshot_stop_loss,
|
||||
stale_breakeven_armed,
|
||||
)
|
||||
|
||||
|
||||
def _calc_rr(direction, entry, sl, tp):
|
||||
if direction == "long":
|
||||
risk = entry - sl
|
||||
reward = tp - entry
|
||||
else:
|
||||
risk = sl - entry
|
||||
reward = entry - tp
|
||||
if risk <= 0 or reward <= 0:
|
||||
return None
|
||||
return round(reward / risk, 4)
|
||||
|
||||
|
||||
def test_snapshot_stop_loss_prefers_initial():
|
||||
assert snapshot_stop_loss(2.45, 2.6) == 2.45
|
||||
assert snapshot_stop_loss(None, 2.6) == 2.6
|
||||
|
||||
|
||||
def test_monitor_open_stop_loss_prefers_initial_snapshot():
|
||||
row = {"initial_stop_loss": 64000, "stop_loss": 63200}
|
||||
assert monitor_open_stop_loss(row) == 64000
|
||||
|
||||
|
||||
def test_snapshot_rr_ignores_current_stop_after_manual_move():
|
||||
rr = snapshot_rr(_calc_rr, "long", 2.726, 2.45, 2.65, 3.3)
|
||||
assert rr is not None
|
||||
assert rr > 2.0
|
||||
|
||||
|
||||
def test_breakeven_long():
|
||||
assert is_sl_breakeven_secured("long", 2.726, 2.726) is True
|
||||
assert is_sl_breakeven_secured("long", 2.726, 2.75) is True
|
||||
assert is_sl_breakeven_secured("long", 2.726, 2.45) is False
|
||||
|
||||
|
||||
def test_breakeven_short():
|
||||
assert is_sl_breakeven_secured("short", 72.73, 72.73) is True
|
||||
assert is_sl_breakeven_secured("short", 72.73, 72.0) is True
|
||||
assert is_sl_breakeven_secured("short", 72.73, 74.0) is False
|
||||
|
||||
|
||||
def test_sl_breakeven_from_exchange_tpsl():
|
||||
ok = sl_breakeven_from_exchange_tpsl(
|
||||
"long",
|
||||
2.726,
|
||||
{"sl": {"trigger_price": 2.735}, "tp": {"trigger_price": 3.3}},
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_resolve_live_tpsl_prefers_exchange():
|
||||
disp_sl, disp_tp, ex_sl, ex_tp = resolve_live_tpsl_prices(
|
||||
1674,
|
||||
1647.65,
|
||||
{"sl": {"trigger_price": 1661}, "tp": {"trigger_price": 1647.65}},
|
||||
)
|
||||
assert disp_sl == 1661
|
||||
assert disp_tp == 1647.65
|
||||
assert ex_sl == 1661
|
||||
assert ex_tp == 1647.65
|
||||
|
||||
|
||||
def test_order_monitor_tpsl_needs_sync_detects_sl_change():
|
||||
new_sl, new_tp, changed = order_monitor_tpsl_needs_sync(
|
||||
1674,
|
||||
1647.65,
|
||||
{"sl": {"trigger_price": 1661}, "tp": {"trigger_price": 1647.65}},
|
||||
)
|
||||
assert changed is True
|
||||
assert new_sl == 1661
|
||||
assert new_tp == 1647.65
|
||||
|
||||
|
||||
def test_apply_order_price_display_fields_live_sl():
|
||||
payload = {}
|
||||
apply_order_price_display_fields(
|
||||
payload,
|
||||
direction="short",
|
||||
entry_price=1663.45,
|
||||
initial_stop_loss=1674,
|
||||
stop_loss=1674,
|
||||
take_profit=1647.65,
|
||||
calc_rr_ratio_fn=_calc_rr,
|
||||
exchange_tpsl={"sl": {"trigger_price": 1661}, "tp": {"trigger_price": 1647.65}},
|
||||
format_price_fn=lambda _s, v: f"{v:.2f}",
|
||||
symbol="ETH/USDT:USDT",
|
||||
margin_capital=100,
|
||||
leverage=10,
|
||||
exchange_notional=1000,
|
||||
contracts=2.0,
|
||||
contract_size=1.0,
|
||||
avg_entry_price=1660.0,
|
||||
)
|
||||
assert payload["stop_loss"] == 1661
|
||||
assert payload["stop_loss_display"] == "1661.00"
|
||||
assert payload["sl_breakeven_secured"] is False
|
||||
assert payload["rr_ratio"] is not None
|
||||
assert payload["latest_risk_amount"] is not None
|
||||
assert payload["latest_risk_amount"] >= 0
|
||||
assert payload["contracts"] == 2.0
|
||||
assert payload["reward_at_tp_usdt"] is not None
|
||||
assert payload["reward_at_tp_usdt"] > 0
|
||||
|
||||
|
||||
def test_apply_order_price_display_fields_gate_contract_size():
|
||||
payload = {}
|
||||
apply_order_price_display_fields(
|
||||
payload,
|
||||
direction="short",
|
||||
entry_price=62063.4,
|
||||
initial_stop_loss=62650,
|
||||
stop_loss=62650,
|
||||
take_profit=61200,
|
||||
calc_rr_ratio_fn=_calc_rr,
|
||||
exchange_tpsl={},
|
||||
symbol="BTC/USDT:USDT",
|
||||
margin_capital=48,
|
||||
leverage=10,
|
||||
contracts=78.0,
|
||||
contract_size=0.0001,
|
||||
avg_entry_price=62063.4,
|
||||
)
|
||||
assert payload["reward_at_tp_usdt"] is not None
|
||||
# 毛利约 6.73, 扣双边 0.05% 后约 6.25
|
||||
assert abs(payload["reward_at_tp_usdt"] - 6.25) < 0.1
|
||||
|
||||
|
||||
def test_calc_latest_risk_amount_long():
|
||||
rf = calc_risk_fraction("long", 100, 95)
|
||||
assert rf is not None and abs(rf - 0.05) < 1e-9
|
||||
risk = calc_latest_risk_amount(
|
||||
"long", 100, 95, exchange_notional=1000, funds_decimals=2
|
||||
)
|
||||
assert risk == 50.0
|
||||
|
||||
|
||||
def test_calc_latest_risk_amount_profit_side_stop():
|
||||
risk = calc_latest_risk_amount("long", 100, 101, exchange_notional=1000)
|
||||
assert risk == 0.0
|
||||
|
||||
|
||||
def test_resolve_breakeven_entry_price_prefers_avg():
|
||||
assert resolve_breakeven_entry_price(1777.39, 1777.2) == 1777.2
|
||||
assert resolve_breakeven_entry_price(1777.39, None) == 1777.39
|
||||
|
||||
|
||||
def test_roll_long_not_breakeven_with_avg_entry():
|
||||
payload = {}
|
||||
apply_order_price_display_fields(
|
||||
payload,
|
||||
direction="long",
|
||||
entry_price=1777.39,
|
||||
initial_stop_loss=1750,
|
||||
stop_loss=1767,
|
||||
take_profit=1833,
|
||||
calc_rr_ratio_fn=_calc_rr,
|
||||
exchange_tpsl={"sl": {"trigger_price": 1767}, "tp": {"trigger_price": 1833}},
|
||||
avg_entry_price=1777.2,
|
||||
)
|
||||
assert payload["sl_breakeven_secured"] is False
|
||||
|
||||
|
||||
def test_stale_breakeven_armed_after_roll_down():
|
||||
assert stale_breakeven_armed("long", 1777.39, 1767, 1) is True
|
||||
assert stale_breakeven_armed("long", 1777.39, 1778, 1) is False
|
||||
assert stale_breakeven_armed("long", 1777.39, 1767, 0) is False
|
||||
@@ -0,0 +1,78 @@
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from lib.strategy.strategy_db import init_strategy_tables
|
||||
from lib.strategy.strategy_trade_labels import (
|
||||
MONITOR_TYPE_TREND_PULLBACK,
|
||||
count_position_limit_active_monitors,
|
||||
)
|
||||
|
||||
|
||||
def _mem_conn():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""CREATE TABLE order_monitors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT,
|
||||
direction TEXT,
|
||||
status TEXT,
|
||||
monitor_type TEXT,
|
||||
key_signal_type TEXT,
|
||||
trend_plan_id INTEGER
|
||||
)"""
|
||||
)
|
||||
init_strategy_tables(conn)
|
||||
return conn
|
||||
|
||||
|
||||
class PositionLimitCountTests(unittest.TestCase):
|
||||
def test_regular_monitor_counts(self):
|
||||
conn = _mem_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO order_monitors (symbol, status, monitor_type) VALUES ('ETH/USDT', 'active', '下单监控')"
|
||||
)
|
||||
conn.commit()
|
||||
self.assertEqual(count_position_limit_active_monitors(conn), 1)
|
||||
|
||||
def test_trend_pullback_excluded(self):
|
||||
conn = _mem_conn()
|
||||
conn.execute(
|
||||
"""INSERT INTO order_monitors
|
||||
(symbol, status, monitor_type, trend_plan_id)
|
||||
VALUES ('ETH/USDT', 'active', ?, 12)""",
|
||||
(MONITOR_TYPE_TREND_PULLBACK,),
|
||||
)
|
||||
conn.commit()
|
||||
self.assertEqual(count_position_limit_active_monitors(conn), 0)
|
||||
|
||||
def test_active_roll_group_still_counts_regular_monitor(self):
|
||||
conn = _mem_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO order_monitors (id, symbol, status, monitor_type) VALUES (1, 'ETH/USDT', 'active', '下单监控')"
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT INTO roll_groups
|
||||
(order_monitor_id, symbol, direction, status)
|
||||
VALUES (1, 'ETH/USDT', 'long', 'active')"""
|
||||
)
|
||||
conn.commit()
|
||||
self.assertEqual(count_position_limit_active_monitors(conn), 1)
|
||||
|
||||
def test_mixed_monitors(self):
|
||||
conn = _mem_conn()
|
||||
conn.execute(
|
||||
"INSERT INTO order_monitors (symbol, status, monitor_type) VALUES ('BTC/USDT', 'active', '下单监控')"
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT INTO order_monitors
|
||||
(symbol, status, monitor_type, trend_plan_id)
|
||||
VALUES ('ETH/USDT', 'active', ?, 3)""",
|
||||
(MONITOR_TYPE_TREND_PULLBACK,),
|
||||
)
|
||||
conn.commit()
|
||||
self.assertEqual(count_position_limit_active_monitors(conn), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""全仓 / 以损定仓 风险展示文案."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.trade.position_sizing_lib import ( # noqa: E402
|
||||
format_risk_display_text,
|
||||
risk_percent_for_storage,
|
||||
)
|
||||
|
||||
|
||||
class TestPositionSizingRiskDisplay(unittest.TestCase):
|
||||
def test_full_margin_shows_amount_only(self):
|
||||
self.assertEqual(
|
||||
format_risk_display_text("full_margin", 1.0, 2.58, decimals=2),
|
||||
"2.58U",
|
||||
)
|
||||
self.assertIsNone(risk_percent_for_storage("full_margin", 1.0))
|
||||
|
||||
def test_risk_mode_shows_percent_and_amount(self):
|
||||
self.assertEqual(
|
||||
format_risk_display_text("risk", 2.0, 10.5, decimals=2),
|
||||
"2%≈10.5U",
|
||||
)
|
||||
self.assertEqual(risk_percent_for_storage("risk", 2.0), 2.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,35 @@
|
||||
import unittest
|
||||
|
||||
from lib.hub.price_snapshot_lib import resolve_order_snapshot_price
|
||||
|
||||
|
||||
class TestPriceSnapshotLib(unittest.TestCase):
|
||||
def test_resolve_from_cached_prices(self):
|
||||
px = resolve_order_snapshot_price("ETH/USDT", {"ETH/USDT": 1750.5})
|
||||
self.assertEqual(px, 1750.5)
|
||||
|
||||
def test_resolve_from_position_mark(self):
|
||||
prow = {"info": {"mark_price": 1760.0}, "contracts": 1}
|
||||
px = resolve_order_snapshot_price("ETH/USDT", {}, position_row=prow)
|
||||
self.assertEqual(px, 1760.0)
|
||||
|
||||
def test_resolve_mark_fn_before_entry(self):
|
||||
px = resolve_order_snapshot_price(
|
||||
"ETH/USDT",
|
||||
{},
|
||||
get_mark_price_fn=lambda s: 1755.0,
|
||||
fallback_entry=1700.0,
|
||||
)
|
||||
self.assertEqual(px, 1755.0)
|
||||
|
||||
def test_resolve_fallback_entry(self):
|
||||
px = resolve_order_snapshot_price(
|
||||
"ETH/USDT",
|
||||
{},
|
||||
fallback_entry=1700.0,
|
||||
)
|
||||
self.assertEqual(px, 1700.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""records_list_lib pagination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from lib.instance.records_list_lib import list_trade_records_page
|
||||
from lib.trade.trade_result_lib import filter_trade_records_excluding_miss
|
||||
|
||||
|
||||
def _to_effective(row):
|
||||
d = dict(row)
|
||||
d["effective_result"] = d.get("result")
|
||||
d["effective_pnl_amount"] = d.get("pnl_amount")
|
||||
return d
|
||||
|
||||
|
||||
class RecordsListLibTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.conn = sqlite3.connect(":memory:")
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.conn.execute(
|
||||
"""
|
||||
CREATE TABLE trade_records (
|
||||
id INTEGER PRIMARY KEY,
|
||||
closed_at TEXT,
|
||||
created_at TEXT,
|
||||
opened_at TEXT,
|
||||
result TEXT,
|
||||
pnl_amount REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
for i in range(12):
|
||||
self.conn.execute(
|
||||
"INSERT INTO trade_records(id, closed_at, created_at, opened_at, result, pnl_amount) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(i + 1, f"2026-07-1{i % 9}-10:00:00", None, None, "止盈", 1.0),
|
||||
)
|
||||
self.conn.execute(
|
||||
"INSERT INTO trade_records(id, closed_at, created_at, opened_at, result, pnl_amount) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(99, "2026-07-15-10:00:00", None, None, "错过", 0),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def tearDown(self):
|
||||
self.conn.close()
|
||||
|
||||
def test_pages_exclude_miss(self):
|
||||
out = list_trade_records_page(
|
||||
self.conn,
|
||||
"2026-07-01",
|
||||
"2026-07-31",
|
||||
tr_ts="COALESCE(closed_at, created_at, opened_at)",
|
||||
to_effective_fn=_to_effective,
|
||||
filter_fn=filter_trade_records_excluding_miss,
|
||||
limit=5,
|
||||
offset=0,
|
||||
)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["total"], 12)
|
||||
self.assertEqual(out["pages"], 3)
|
||||
self.assertEqual(len(out["items"]), 5)
|
||||
|
||||
def test_second_page(self):
|
||||
out = list_trade_records_page(
|
||||
self.conn,
|
||||
"2026-07-01",
|
||||
"2026-07-31",
|
||||
tr_ts="COALESCE(closed_at, created_at, opened_at)",
|
||||
to_effective_fn=_to_effective,
|
||||
filter_fn=filter_trade_records_excluding_miss,
|
||||
limit=5,
|
||||
offset=5,
|
||||
)
|
||||
self.assertEqual(out["page"], 2)
|
||||
self.assertEqual(len(out["items"]), 5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""deploy/sanitize_hub_settings.py 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "deploy"))
|
||||
|
||||
from sanitize_hub_settings import sanitize_settings # noqa: E402
|
||||
|
||||
|
||||
def test_drops_gate_bot_and_keeps_gate():
|
||||
raw = {
|
||||
"exchanges": [
|
||||
{"id": "0", "key": "binance", "name": "币安", "agent_url": "http://127.0.0.1:15200"},
|
||||
{"id": "3", "key": "gate_bot", "name": "Gate bot", "agent_url": "http://127.0.0.1:15203"},
|
||||
{"id": "2", "key": "gate", "name": "Gate", "flask_url": "http://127.0.0.1:5000"},
|
||||
]
|
||||
}
|
||||
cleaned, removed = sanitize_settings(raw)
|
||||
keys = [x["key"] for x in cleaned["exchanges"]]
|
||||
assert keys == ["binance", "gate"]
|
||||
assert len(removed) == 1
|
||||
|
||||
|
||||
def test_drops_port_5002_legacy():
|
||||
raw = {
|
||||
"exchanges": [
|
||||
{
|
||||
"id": "3",
|
||||
"key": "legacy",
|
||||
"name": "crypto_monitor_gate_bot",
|
||||
"flask_url": "http://127.0.0.1:5002",
|
||||
},
|
||||
]
|
||||
}
|
||||
cleaned, removed = sanitize_settings(raw)
|
||||
assert cleaned["exchanges"] == []
|
||||
assert removed
|
||||
@@ -0,0 +1,69 @@
|
||||
"""shared_env_lib:AI 字段与四文件同步."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from lib.env.env_file_lib import apply_env_updates, read_env_lines
|
||||
from lib.env.shared_env_lib import (
|
||||
AI_ENV_KEYS,
|
||||
apply_ai_env_to_all,
|
||||
build_ai_env_payload,
|
||||
validate_ai_env_updates,
|
||||
)
|
||||
|
||||
|
||||
class TestSharedEnvLib(unittest.TestCase):
|
||||
def test_ai_keys_frozen(self) -> None:
|
||||
self.assertIn("OPENAI_API_KEY", AI_ENV_KEYS)
|
||||
self.assertIn("AI_PROVIDER", AI_ENV_KEYS)
|
||||
|
||||
def test_validate_rejects_unknown(self) -> None:
|
||||
clean, errors = validate_ai_env_updates({"NOT_A_KEY": "x"})
|
||||
self.assertEqual(clean, {})
|
||||
self.assertTrue(any("未知" in e for e in errors))
|
||||
|
||||
def test_validate_skips_masked_secret(self) -> None:
|
||||
clean, errors = validate_ai_env_updates({"OPENAI_API_KEY": "****abcd"})
|
||||
self.assertEqual(errors, [])
|
||||
self.assertNotIn("OPENAI_API_KEY", clean)
|
||||
|
||||
def test_apply_syncs_hub_and_instances(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
hub = os.path.join(tmp, "manual_trading_hub")
|
||||
okx = os.path.join(tmp, "crypto_monitor_okx")
|
||||
os.makedirs(hub)
|
||||
os.makedirs(okx)
|
||||
hub_env = os.path.join(hub, ".env")
|
||||
okx_env = os.path.join(okx, ".env")
|
||||
example = os.path.join(hub, ".env.example")
|
||||
with open(example, "w", encoding="utf-8") as f:
|
||||
f.write("AI_PROVIDER=openai\nOPENAI_API_KEY=\n")
|
||||
with open(hub_env, "w", encoding="utf-8") as f:
|
||||
f.write("AI_PROVIDER=openai\n")
|
||||
with open(okx_env, "w", encoding="utf-8") as f:
|
||||
f.write("AI_PROVIDER=ollama\n")
|
||||
|
||||
import lib.env.shared_env_lib as mod
|
||||
|
||||
orig_hub = mod.hub_env_path
|
||||
orig_dirs = dict(mod.INSTANCE_ENV_DIRS)
|
||||
try:
|
||||
mod.hub_env_path = lambda: hub_env # type: ignore[method-assign]
|
||||
mod.hub_example_path = lambda: example # type: ignore[method-assign]
|
||||
mod.INSTANCE_ENV_DIRS = {"okx": __import__("pathlib").Path(okx)} # type: ignore[misc]
|
||||
|
||||
result = apply_ai_env_to_all({"AI_PROVIDER": "openai", "OPENAI_MODEL": "gpt-test"})
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(read_env_lines(hub_env)[0], "AI_PROVIDER=openai")
|
||||
okx_lines = read_env_lines(okx_env)
|
||||
self.assertIn("AI_PROVIDER=openai", okx_lines)
|
||||
self.assertIn("OPENAI_MODEL=gpt-test", okx_lines)
|
||||
finally:
|
||||
mod.hub_env_path = orig_hub # type: ignore[method-assign]
|
||||
mod.INSTANCE_ENV_DIRS = orig_dirs # type: ignore[misc]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
from lib.strategy.strategy_roll_lib import (
|
||||
preview_roll,
|
||||
roll_breakout_invalidate,
|
||||
roll_breakout_trigger_crossed,
|
||||
roll_fib_invalidate,
|
||||
roll_fib_trigger_crossed,
|
||||
solve_add_amount_for_total_risk,
|
||||
validate_roll_geometry,
|
||||
)
|
||||
|
||||
|
||||
def test_solve_add_amount_long_one_risk():
|
||||
q2, err = solve_add_amount_for_total_risk(
|
||||
"long", 1.0, 3000.0, 3100.0, 2950.0, 200.0, 1.0
|
||||
)
|
||||
assert err is None
|
||||
avg = (1 * 3000 + q2 * 3100) / (1 + q2)
|
||||
loss = (avg - 2950) * (1 + q2)
|
||||
assert abs(loss - 200.0) < 0.01
|
||||
|
||||
|
||||
def test_preview_roll_market_short():
|
||||
preview, err = preview_roll(
|
||||
direction="short",
|
||||
symbol="HYPE/USDT",
|
||||
qty_existing=3.0,
|
||||
entry_existing=65.0,
|
||||
initial_take_profit=60.0,
|
||||
add_mode="market",
|
||||
new_stop_loss=66.5,
|
||||
risk_percent=2.0,
|
||||
capital_base_usdt=1000.0,
|
||||
add_price=64.0,
|
||||
legs_done=1,
|
||||
)
|
||||
assert err is None
|
||||
assert preview["add_mode_label"] == "市价加仓"
|
||||
sl = preview["new_stop_loss"]
|
||||
avg = preview["avg_entry_after"]
|
||||
qty = preview["qty_after"]
|
||||
loss = (sl - avg) * qty
|
||||
assert abs(loss - 20.0) < 0.01
|
||||
|
||||
|
||||
def test_fib_cross_long_down():
|
||||
assert roll_fib_trigger_crossed("long", 101.0, 100.0, 100.5) is True
|
||||
assert roll_fib_trigger_crossed("long", 100.6, 100.6, 100.5) is False
|
||||
|
||||
|
||||
def test_breakout_cross_long_up():
|
||||
assert roll_breakout_trigger_crossed("long", 99.0, 100.5, 100.0) is True
|
||||
assert roll_breakout_trigger_crossed("long", 99.0, 100.0, 100.0) is False
|
||||
assert roll_breakout_invalidate("long", 98.0, 99.0) is True
|
||||
assert roll_fib_invalidate("long", 110.0, 105.0, 95.0) is True
|
||||
|
||||
|
||||
def test_breakout_short_below_breakthrough():
|
||||
assert roll_breakout_trigger_crossed("short", 81.0, 80.57, 80.65) is True
|
||||
assert roll_breakout_trigger_crossed("short", 80.64, 80.57, 80.65) is True
|
||||
assert roll_breakout_trigger_crossed("short", 80.57, 80.57, 80.65) is True
|
||||
assert roll_breakout_trigger_crossed("short", 81.0, 80.70, 80.65) is False
|
||||
|
||||
|
||||
def test_preview_breakout_mode_label():
|
||||
preview, err = preview_roll(
|
||||
direction="long",
|
||||
symbol="ETH/USDT",
|
||||
qty_existing=1.0,
|
||||
entry_existing=3000.0,
|
||||
initial_take_profit=3500.0,
|
||||
add_mode="breakout",
|
||||
new_stop_loss=2980.0,
|
||||
breakthrough_price=3100.0,
|
||||
risk_percent=10.0,
|
||||
capital_base_usdt=1000.0,
|
||||
add_price=3050.0,
|
||||
)
|
||||
assert err is None
|
||||
assert preview["add_mode_label"] == "突破加仓"
|
||||
|
||||
|
||||
def test_breakout_geometry_short_mark_above_breakout():
|
||||
err = validate_roll_geometry(
|
||||
"short",
|
||||
"breakout",
|
||||
new_stop_loss=568.0,
|
||||
breakthrough_price=551.0,
|
||||
entry_existing=560.0,
|
||||
initial_take_profit=540.0,
|
||||
mark_price=560.0,
|
||||
)
|
||||
assert err is None
|
||||
|
||||
|
||||
def test_breakout_geometry_short_rejects_mark_at_or_below_breakout():
|
||||
err = validate_roll_geometry(
|
||||
"short",
|
||||
"breakout",
|
||||
new_stop_loss=568.0,
|
||||
breakthrough_price=551.0,
|
||||
entry_existing=560.0,
|
||||
initial_take_profit=540.0,
|
||||
mark_price=551.0,
|
||||
)
|
||||
assert err is not None
|
||||
assert "高于突破价" in err
|
||||
|
||||
|
||||
def test_breakout_geometry_long_rejects_mark_at_or_above_breakout():
|
||||
err = validate_roll_geometry(
|
||||
"long",
|
||||
"breakout",
|
||||
new_stop_loss=2980.0,
|
||||
breakthrough_price=3100.0,
|
||||
entry_existing=3000.0,
|
||||
initial_take_profit=3500.0,
|
||||
mark_price=3100.0,
|
||||
)
|
||||
assert err is not None
|
||||
assert "低于突破价" in err
|
||||
@@ -0,0 +1,47 @@
|
||||
"""strategy_roll_ui_lib 单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import lib.strategy.strategy_roll_ui_lib as roll_ui
|
||||
|
||||
|
||||
def test_compute_roll_chain_metrics_short():
|
||||
group = {
|
||||
"id": 1,
|
||||
"direction": "short",
|
||||
"initial_take_profit": 60.0,
|
||||
}
|
||||
legs = [
|
||||
{"id": 10, "leg_index": 1, "amount": 3.0, "fill_price": 65.0, "status": "filled"},
|
||||
{"id": 11, "leg_index": 2, "amount": 5.0, "fill_price": 64.0, "status": "filled"},
|
||||
]
|
||||
per_leg, group_metrics = roll_ui.compute_roll_chain_metrics(
|
||||
group,
|
||||
legs,
|
||||
qty_live=8.0,
|
||||
entry_live=63.5,
|
||||
monitor={"trigger_price": 66.0, "order_amount": 3.0},
|
||||
)
|
||||
assert per_leg[10]["avg_entry_after"] is not None
|
||||
assert per_leg[11]["avg_entry_after"] is not None
|
||||
assert group_metrics["reward_at_tp_usdt"] is not None
|
||||
assert group_metrics["initial_qty"] == 3.0
|
||||
assert group_metrics["current_qty"] == 8.0
|
||||
assert per_leg[11]["reward_at_tp_usdt"] >= per_leg[10]["reward_at_tp_usdt"]
|
||||
|
||||
|
||||
def test_infer_initial_position_from_live():
|
||||
legs = [{"amount": 2.0, "fill_price": 64.0, "status": "filled"}]
|
||||
q0, e0 = roll_ui.infer_initial_position(5.0, 63.0, legs)
|
||||
assert q0 == 3.0
|
||||
assert abs(e0 - 62.3333333333) < 0.001
|
||||
|
||||
|
||||
def test_reward_at_tp_long():
|
||||
# 毛利 20, 双边费 (200+220)*0.0005=0.21 → 净 19.79
|
||||
assert abs(roll_ui.reward_at_tp_usdt("long", 100.0, 110.0, 2.0) - 19.79) < 1e-6
|
||||
@@ -0,0 +1,183 @@
|
||||
"""策略快照:同一计划同结果不重复写入."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.strategy.strategy_snapshot_lib import ( # noqa: E402
|
||||
STRATEGY_TREND,
|
||||
dedupe_strategy_snapshots,
|
||||
init_strategy_snapshot_table,
|
||||
list_strategy_snapshots,
|
||||
save_trend_plan_snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _mem_conn() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_strategy_snapshot_table(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def test_save_trend_plan_snapshot_skips_duplicate_result():
|
||||
conn = _mem_conn()
|
||||
plan = {
|
||||
"id": 42,
|
||||
"symbol": "ONDO/USDT",
|
||||
"exchange_symbol": "ONDO/USDT:USDT",
|
||||
"direction": "short",
|
||||
"status": "active",
|
||||
"opened_at": "2026-06-08 08:00:00",
|
||||
"legs_done": 4,
|
||||
"dca_legs": 4,
|
||||
"first_order_done": 1,
|
||||
"grid_prices_json": "[]",
|
||||
"leg_amounts_json": "[]",
|
||||
}
|
||||
cfg = {"app_module": type("M", (), {"app_now_str": staticmethod(lambda: "2026-06-08 08:41:00")})()}
|
||||
save_trend_plan_snapshot(cfg, conn, plan, result_label="止损", pnl_amount=-2.3)
|
||||
save_trend_plan_snapshot(cfg, conn, plan, result_label="止损", pnl_amount=-2.4)
|
||||
conn.commit()
|
||||
rows = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM strategy_trade_snapshots WHERE source_id=? AND result_label=?",
|
||||
(42, "止损"),
|
||||
).fetchone()
|
||||
assert int(rows["c"]) == 1
|
||||
|
||||
|
||||
def test_dedupe_strategy_snapshots_handles_many_duplicates():
|
||||
conn = _mem_conn()
|
||||
payload = json.dumps({"symbol": "ONDO/USDT"}, ensure_ascii=False)
|
||||
for snap_id in range(1, 46):
|
||||
conn.execute(
|
||||
"""INSERT INTO strategy_trade_snapshots (
|
||||
id, strategy_type, source_id, symbol, result_label, snapshot_json, closed_at, created_at, pnl_amount
|
||||
) VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
snap_id,
|
||||
STRATEGY_TREND,
|
||||
99,
|
||||
"ONDO/USDT",
|
||||
"止损",
|
||||
payload,
|
||||
"2026-06-08 08:41:00",
|
||||
"2026-06-08 08:41:00",
|
||||
-2.2,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
removed = dedupe_strategy_snapshots(conn)
|
||||
conn.commit()
|
||||
assert removed == 44
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM strategy_trade_snapshots WHERE source_id=?",
|
||||
(99,),
|
||||
).fetchone()
|
||||
assert int(row["c"]) == 1
|
||||
|
||||
|
||||
def test_dedupe_strategy_snapshots_keeps_latest_id():
|
||||
conn = _mem_conn()
|
||||
payload = json.dumps({"symbol": "ONDO/USDT"}, ensure_ascii=False)
|
||||
for snap_id, pnl in ((1, -2.23), (2, -2.31), (3, -2.38)):
|
||||
conn.execute(
|
||||
"""INSERT INTO strategy_trade_snapshots (
|
||||
id, strategy_type, source_id, symbol, result_label, snapshot_json, closed_at, created_at, pnl_amount
|
||||
) VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
snap_id,
|
||||
STRATEGY_TREND,
|
||||
5,
|
||||
"ONDO/USDT",
|
||||
"止损",
|
||||
payload,
|
||||
"2026-06-08 08:41:00",
|
||||
"2026-06-08 08:41:00",
|
||||
pnl,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
removed = dedupe_strategy_snapshots(conn)
|
||||
conn.commit()
|
||||
assert removed == 2
|
||||
row = conn.execute(
|
||||
"SELECT id, pnl_amount FROM strategy_trade_snapshots WHERE source_id=?",
|
||||
(5,),
|
||||
).fetchone()
|
||||
assert int(row["id"]) == 3
|
||||
assert abs(float(row["pnl_amount"]) - (-2.38)) < 1e-6
|
||||
|
||||
|
||||
def test_list_strategy_snapshots_hides_duplicate_keys():
|
||||
conn = _mem_conn()
|
||||
payload = json.dumps({"symbol": "ONDO/USDT", "dca_levels": []}, ensure_ascii=False)
|
||||
for snap_id in (10, 11, 12):
|
||||
conn.execute(
|
||||
"""INSERT INTO strategy_trade_snapshots (
|
||||
id, strategy_type, source_id, symbol, direction, result_label,
|
||||
snapshot_json, closed_at, created_at, pnl_amount
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
snap_id,
|
||||
STRATEGY_TREND,
|
||||
7,
|
||||
"ONDO/USDT",
|
||||
"short",
|
||||
"止损",
|
||||
payload,
|
||||
"2026-06-08 08:41:00",
|
||||
"2026-06-08 08:41:00",
|
||||
-2.2,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
rows = list_strategy_snapshots(conn, limit=50)
|
||||
stop_rows = [r for r in rows if int(r.get("source_id") or 0) == 7]
|
||||
assert len(stop_rows) == 1
|
||||
assert int(stop_rows[0]["id"]) == 12
|
||||
|
||||
|
||||
def test_dedupe_keeps_manual_over_stop_loss():
|
||||
conn = _mem_conn()
|
||||
payload = json.dumps({"symbol": "ONDO/USDT"}, ensure_ascii=False)
|
||||
for snap_id, label in ((10, "止损"), (11, "手动平仓")):
|
||||
conn.execute(
|
||||
"""INSERT INTO strategy_trade_snapshots (
|
||||
id, strategy_type, source_id, symbol, result_label, snapshot_json, closed_at, created_at, pnl_amount
|
||||
) VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
snap_id,
|
||||
STRATEGY_TREND,
|
||||
7,
|
||||
"ONDO/USDT",
|
||||
label,
|
||||
payload,
|
||||
"2026-06-08 08:44:00",
|
||||
"2026-06-08 08:44:00",
|
||||
-2.23,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
removed = dedupe_strategy_snapshots(conn)
|
||||
conn.commit()
|
||||
assert removed == 1
|
||||
row = conn.execute(
|
||||
"SELECT result_label FROM strategy_trade_snapshots WHERE source_id=?",
|
||||
(7,),
|
||||
).fetchone()
|
||||
assert row["result_label"] == "手动平仓"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_save_trend_plan_snapshot_skips_duplicate_result()
|
||||
test_dedupe_strategy_snapshots_handles_many_duplicates()
|
||||
test_dedupe_strategy_snapshots_keeps_latest_id()
|
||||
test_list_strategy_snapshots_hides_duplicate_keys()
|
||||
test_dedupe_keeps_manual_over_stop_loss()
|
||||
print("all ok")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""FORCE_CLOSE 部署策略:只补缺失,不覆盖手调."""
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from scripts import sync_common_trading_env as sync
|
||||
|
||||
|
||||
class TestForceCloseFillMissing(unittest.TestCase):
|
||||
def test_does_not_overwrite_existing(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
gate = Path(td) / "crypto_monitor_gate"
|
||||
gate.mkdir()
|
||||
(gate / ".env").write_text(
|
||||
"FORCE_CLOSE_ENABLED=false\nFORCE_CLOSE_BJ_HOUR=1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with mock.patch.object(sync, "REPO", td):
|
||||
changed = sync.apply_force_close_policy(dry_run=False)
|
||||
self.assertFalse(changed)
|
||||
text = (gate / ".env").read_text(encoding="utf-8")
|
||||
self.assertIn("FORCE_CLOSE_ENABLED=false", text)
|
||||
self.assertIn("FORCE_CLOSE_BJ_HOUR=1", text)
|
||||
|
||||
def test_fills_missing_keys(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
gate = Path(td) / "crypto_monitor_gate"
|
||||
gate.mkdir()
|
||||
(gate / ".env").write_text("APP_USERNAME=x\n", encoding="utf-8")
|
||||
with mock.patch.object(sync, "REPO", td):
|
||||
changed = sync.apply_force_close_policy(dry_run=False)
|
||||
self.assertTrue(changed)
|
||||
text = (gate / ".env").read_text(encoding="utf-8")
|
||||
self.assertIn("FORCE_CLOSE_ENABLED=true", text)
|
||||
self.assertIn("FORCE_CLOSE_BJ_HOUR=0", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
import unittest
|
||||
|
||||
from lib.trade.trade_exchange_stats_lib import (
|
||||
aggregate_bilateral_stats,
|
||||
commission_usdt_from_fill,
|
||||
filter_position_lifecycle_fills,
|
||||
merge_commission_prefer_income,
|
||||
quote_turnover_usdt_from_fill,
|
||||
)
|
||||
|
||||
|
||||
class TradeExchangeStatsTests(unittest.TestCase):
|
||||
def test_turnover_from_cost(self):
|
||||
t = {"cost": 1000.0, "price": 50, "amount": 20}
|
||||
self.assertEqual(quote_turnover_usdt_from_fill(t), 1000.0)
|
||||
|
||||
def test_commission_from_fee(self):
|
||||
t = {"fee": {"cost": -0.42, "currency": "USDT"}}
|
||||
self.assertEqual(commission_usdt_from_fill(t), 0.42)
|
||||
|
||||
def test_bilateral_aggregate(self):
|
||||
fills = [
|
||||
{"side": "buy", "cost": 500, "fee": {"cost": -0.2, "currency": "USDT"}, "timestamp": 1000},
|
||||
{"side": "sell", "cost": 520, "fee": {"cost": -0.21, "currency": "USDT"}, "timestamp": 2000},
|
||||
]
|
||||
stats = aggregate_bilateral_stats(fills)
|
||||
self.assertIsNotNone(stats)
|
||||
self.assertEqual(stats["exchange_turnover_usdt"], 1020.0)
|
||||
self.assertEqual(stats["exchange_commission_usdt"], 0.41)
|
||||
|
||||
def test_filter_long_lifecycle(self):
|
||||
base = 1_700_000_000_000
|
||||
trades = [
|
||||
{"side": "buy", "timestamp": base, "cost": 100},
|
||||
{"side": "sell", "timestamp": base + 60_000, "cost": 110},
|
||||
{"side": "buy", "timestamp": base + 120_000, "cost": 999},
|
||||
]
|
||||
got = filter_position_lifecycle_fills(
|
||||
trades, "long", base - 1000, base + 90_000, close_buffer_ms=0
|
||||
)
|
||||
self.assertEqual(len(got), 2)
|
||||
|
||||
def test_prefer_income_commission(self):
|
||||
self.assertEqual(merge_commission_prefer_income(0.3, 0.45), 0.45)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""永续固定费率净盈亏."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.trade.trade_fee_lib import ( # noqa: E402
|
||||
estimate_roundtrip_fee_usdt,
|
||||
net_pnl_after_fee,
|
||||
notional_usdt,
|
||||
taker_fee_rate,
|
||||
)
|
||||
|
||||
|
||||
class TestTradeFeeLib(unittest.TestCase):
|
||||
def test_default_rate(self):
|
||||
os.environ.pop("PERP_TAKER_FEE_RATE", None)
|
||||
self.assertAlmostEqual(taker_fee_rate(), 0.0005)
|
||||
|
||||
def test_notional(self):
|
||||
self.assertAlmostEqual(notional_usdt(100, 2, 1.0), 200.0)
|
||||
self.assertAlmostEqual(notional_usdt(62000, 78, 0.0001), 483.6, places=2)
|
||||
|
||||
def test_roundtrip_fee_qty(self):
|
||||
# 开 100*2=200, 平 110*2=220, 费=(200+220)*0.0005=0.21
|
||||
fee = estimate_roundtrip_fee_usdt(100, 110, 2.0, 1.0, rate=0.0005)
|
||||
self.assertAlmostEqual(fee, 0.21, places=6)
|
||||
|
||||
def test_net_long_matches_checklist(self):
|
||||
# 毛利 20, 费 0.21 → 净 19.79
|
||||
net = net_pnl_after_fee(20.0, 100, 110, 2.0, 1.0, rate=0.0005)
|
||||
self.assertAlmostEqual(net, 19.79, places=4)
|
||||
|
||||
def test_open_notional_fallback(self):
|
||||
# 无张数:开名义 1000, 出场 110/100 → 平 1100, 费=1.05
|
||||
fee = estimate_roundtrip_fee_usdt(100, 110, open_notional=1000, rate=0.0005)
|
||||
self.assertAlmostEqual(fee, 1.05, places=6)
|
||||
net = net_pnl_after_fee(50.0, 100, 110, open_notional=1000, rate=0.0005)
|
||||
self.assertAlmostEqual(net, 48.95, places=4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""账户方向 / 币种白名单 env 策略."""
|
||||
from lib.trade.trade_policy_lib import (
|
||||
assert_direction_allowed,
|
||||
assert_symbol_allowed,
|
||||
assert_trade_policy_open,
|
||||
load_trade_policy,
|
||||
parse_symbol_whitelist,
|
||||
symbol_base_coin,
|
||||
trade_policy_badge_parts,
|
||||
)
|
||||
|
||||
|
||||
def test_default_policy_unrestricted():
|
||||
p = load_trade_policy({})
|
||||
assert not p.direction_restrict_enabled
|
||||
assert not p.symbol_restrict_enabled
|
||||
assert p.allows_long and p.allows_short
|
||||
|
||||
|
||||
def test_long_only_blocks_short():
|
||||
p = load_trade_policy(
|
||||
{
|
||||
"TRADE_DIRECTION_RESTRICT_ENABLED": "true",
|
||||
"TRADE_DIRECTION": "long_only",
|
||||
}
|
||||
)
|
||||
ok, msg = assert_direction_allowed(p, "short")
|
||||
assert not ok
|
||||
assert "仅做多" in msg
|
||||
ok2, _ = assert_direction_allowed(p, "long")
|
||||
assert ok2
|
||||
|
||||
|
||||
def test_symbol_whitelist_btc_eth():
|
||||
p = load_trade_policy(
|
||||
{
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
|
||||
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
|
||||
}
|
||||
)
|
||||
ok, _ = assert_symbol_allowed(p, "BTC/USDT")
|
||||
assert ok
|
||||
ok2, msg = assert_symbol_allowed(p, "SOL")
|
||||
assert not ok2
|
||||
assert "SOL" in msg
|
||||
|
||||
|
||||
def test_symbol_whitelist_without_list_disables_restrict():
|
||||
p = load_trade_policy(
|
||||
{
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
|
||||
"TRADE_SYMBOL_WHITELIST": "",
|
||||
}
|
||||
)
|
||||
assert not p.symbol_restrict_enabled
|
||||
|
||||
|
||||
def test_combined_open_validation():
|
||||
p = load_trade_policy(
|
||||
{
|
||||
"TRADE_DIRECTION_RESTRICT_ENABLED": "1",
|
||||
"TRADE_DIRECTION": "多",
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "yes",
|
||||
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
|
||||
}
|
||||
)
|
||||
ok, _ = assert_trade_policy_open(p, "ETH", "long")
|
||||
assert ok
|
||||
ok2, msg = assert_trade_policy_open(p, "ETH", "short")
|
||||
assert not ok2
|
||||
ok3, msg3 = assert_trade_policy_open(p, "BNB", "long")
|
||||
assert not ok3
|
||||
assert "BNB" in msg3
|
||||
|
||||
|
||||
def test_parse_whitelist_and_base_coin():
|
||||
assert parse_symbol_whitelist("btc, eth") == ("BTC", "ETH")
|
||||
assert symbol_base_coin("btc/usdt:usdt") == "BTC"
|
||||
|
||||
|
||||
def test_badge_parts():
|
||||
p = load_trade_policy(
|
||||
{
|
||||
"TRADE_DIRECTION_RESTRICT_ENABLED": "true",
|
||||
"TRADE_DIRECTION": "long_only",
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
|
||||
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
|
||||
}
|
||||
)
|
||||
assert trade_policy_badge_parts(p) == ("仅多", "BTC/ETH")
|
||||
@@ -0,0 +1,30 @@
|
||||
from lib.trade.trade_result_lib import normalize_result_with_pnl, normalize_display_result, is_winning_pnl
|
||||
|
||||
|
||||
def test_stop_loss_with_profit_becomes_trailing_tp():
|
||||
assert normalize_result_with_pnl("止损", 4.33) == "移动止盈"
|
||||
|
||||
|
||||
def test_manual_close_unchanged_even_with_profit():
|
||||
assert normalize_result_with_pnl("手动平仓", 10) == "手动平仓"
|
||||
|
||||
|
||||
def test_stop_loss_with_loss_unchanged():
|
||||
assert normalize_result_with_pnl("止损", -2.5) == "止损"
|
||||
|
||||
|
||||
def test_take_profit_unchanged():
|
||||
assert normalize_result_with_pnl("止盈", 5) == "止盈"
|
||||
|
||||
|
||||
def test_external_close_becomes_manual_close():
|
||||
assert normalize_display_result("外部平仓") == "手动平仓"
|
||||
assert normalize_result_with_pnl("外部平仓", 2.5) == "手动平仓"
|
||||
assert normalize_result_with_pnl("外部平仓(自动同步)", -1) == "手动平仓"
|
||||
|
||||
|
||||
def test_winning_pnl_positive_only():
|
||||
assert is_winning_pnl(2.96) is True
|
||||
assert is_winning_pnl(0) is False
|
||||
assert is_winning_pnl(-1.05) is False
|
||||
assert is_winning_pnl(None) is False
|
||||
@@ -0,0 +1,26 @@
|
||||
"""trade_result_lib:过滤「错过」记录."""
|
||||
import unittest
|
||||
|
||||
from lib.trade.trade_result_lib import (
|
||||
filter_trade_records_excluding_miss,
|
||||
is_miss_trade_result,
|
||||
)
|
||||
|
||||
|
||||
class TradeResultMissFilterTest(unittest.TestCase):
|
||||
def test_is_miss_trade_result(self):
|
||||
self.assertTrue(is_miss_trade_result("错过"))
|
||||
self.assertFalse(is_miss_trade_result("止盈"))
|
||||
|
||||
def test_filter_excludes_miss(self):
|
||||
rows = [
|
||||
{"effective_result": "止盈", "id": 1},
|
||||
{"effective_result": "错过", "id": 2},
|
||||
{"result": "错过", "id": 3},
|
||||
]
|
||||
out = filter_trade_records_excluding_miss(rows)
|
||||
self.assertEqual([r["id"] for r in out], [1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,90 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from lib.trade.trade_stats_calendar_lib import (
|
||||
build_initial_stats_calendar,
|
||||
build_stats_calendar_bootstrap,
|
||||
build_trade_stats_calendar,
|
||||
)
|
||||
|
||||
|
||||
def _row(**kwargs):
|
||||
base = {
|
||||
"monitor_type": "",
|
||||
"key_signal_type": "",
|
||||
"exchange_turnover_usdt": None,
|
||||
"exchange_commission_usdt": None,
|
||||
}
|
||||
base.update(kwargs)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _matches_all(row, segment_key):
|
||||
return segment_key == "all"
|
||||
|
||||
|
||||
def _matches_manual(row, segment_key):
|
||||
if segment_key == "all":
|
||||
return True
|
||||
if segment_key == "manual":
|
||||
return (row.monitor_type or "").strip() == "手动" and not (row.key_signal_type or "").strip()
|
||||
return False
|
||||
|
||||
|
||||
class TradeStatsCalendarLibTests(unittest.TestCase):
|
||||
def test_groups_by_trading_day_and_segment(self):
|
||||
pnls = [
|
||||
(10.0, None, "2026-06-18", _row(monitor_type="手动")),
|
||||
(-3.0, None, "2026-06-18", _row(monitor_type="手动")),
|
||||
(5.0, None, "2026-06-19", _row(monitor_type="自动", key_signal_type="箱体突破")),
|
||||
]
|
||||
payload = build_trade_stats_calendar(
|
||||
pnls,
|
||||
2026,
|
||||
6,
|
||||
"manual",
|
||||
_matches_manual,
|
||||
reset_hour=8,
|
||||
)
|
||||
self.assertEqual(payload["month"], 6)
|
||||
self.assertEqual(payload["month_open_count"], 2)
|
||||
days = payload["days"]
|
||||
self.assertIn("2026-06-18", days)
|
||||
self.assertNotIn("2026-06-19", days)
|
||||
self.assertEqual(days["2026-06-18"]["open_count"], 2)
|
||||
self.assertAlmostEqual(days["2026-06-18"]["pnl_total"], 7.0)
|
||||
|
||||
def test_invalid_month_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
build_trade_stats_calendar([], 2026, 13, "all", _matches_all)
|
||||
|
||||
def test_initial_calendar_uses_current_month(self):
|
||||
pnls = [(2.5, None, "2026-06-20", _row())]
|
||||
payload = build_initial_stats_calendar(
|
||||
pnls,
|
||||
datetime(2026, 6, 26, 12, 0),
|
||||
_matches_all,
|
||||
reset_hour=8,
|
||||
)
|
||||
self.assertEqual(payload["year"], 2026)
|
||||
self.assertEqual(payload["month"], 6)
|
||||
self.assertEqual(payload["month_open_count"], 1)
|
||||
self.assertIn("2026-06-20", payload["days"])
|
||||
|
||||
def test_bootstrap_json_roundtrip(self):
|
||||
pnls = [(2.5, None, "2026-06-20", _row())]
|
||||
payload, raw = build_stats_calendar_bootstrap(
|
||||
pnls,
|
||||
datetime(2026, 6, 26, 12, 0),
|
||||
_matches_all,
|
||||
reset_hour=8,
|
||||
)
|
||||
self.assertIsNotNone(payload)
|
||||
self.assertIsNotNone(raw)
|
||||
self.assertIn('"month_open_count":1', raw.replace(" ", ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""趋势回调运行中计划:实际成交价重算补仓表与金额盈亏比."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.strategy.strategy_snapshot_lib import attach_trend_dca_levels # noqa: E402
|
||||
from lib.strategy.strategy_trend_lib import ( # noqa: E402
|
||||
calc_trend_plan_money_metrics,
|
||||
trend_leg_display_price,
|
||||
)
|
||||
|
||||
|
||||
class TestTrendDcaEnrichFills(unittest.TestCase):
|
||||
def _base_plan(self, **overrides):
|
||||
plan = {
|
||||
"direction": "long",
|
||||
"stop_loss": 0.329,
|
||||
"take_profit": 0.476,
|
||||
"first_order_amount": 115,
|
||||
"snapshot_available_usdt": 97.98,
|
||||
"risk_percent": 5,
|
||||
"contract_size": 1.0,
|
||||
"grid_prices_json": json.dumps([0.3465, 0.343, 0.3395, 0.336, 0.3325]),
|
||||
"leg_amounts_json": json.dumps([23, 23, 23, 23, 23]),
|
||||
"dca_legs": 5,
|
||||
"first_order_done": 1,
|
||||
"legs_done": 0,
|
||||
"avg_entry_price": 0.3537,
|
||||
"order_amount_open": 115,
|
||||
"target_order_amount": 230,
|
||||
"leg_fill_prices_json": json.dumps([0.3537]),
|
||||
}
|
||||
plan.update(overrides)
|
||||
return plan
|
||||
|
||||
def test_header_money_rr_not_price_rr(self):
|
||||
plan = self._base_plan()
|
||||
metrics = calc_trend_plan_money_metrics(plan)
|
||||
self.assertAlmostEqual(metrics["risk_amount_u"], 4.899, places=2)
|
||||
self.assertIsNotNone(metrics["money_rr"])
|
||||
self.assertLess(metrics["money_rr"], 4.0)
|
||||
|
||||
def test_done_dca_uses_actual_fill_price(self):
|
||||
plan = self._base_plan(
|
||||
legs_done=1,
|
||||
avg_entry_price=0.3512,
|
||||
order_amount_open=138,
|
||||
leg_fill_prices_json=json.dumps([0.3537, 0.3458]),
|
||||
)
|
||||
enriched = attach_trend_dca_levels(plan)
|
||||
levels = enriched["dca_levels"]
|
||||
self.assertEqual(len(levels), 6)
|
||||
dca1 = levels[1]
|
||||
self.assertEqual(dca1["status"], "done")
|
||||
self.assertAlmostEqual(dca1["price"], 0.3458, places=4)
|
||||
self.assertIsNotNone(dca1["avg_entry"])
|
||||
self.assertIsNotNone(dca1["rr"])
|
||||
dca2 = levels[2]
|
||||
self.assertEqual(dca2["status"], "pending")
|
||||
self.assertAlmostEqual(dca2["price"], 0.343, places=4)
|
||||
|
||||
def test_missing_dca_fills_use_grid_trigger_not_inferred_price(self):
|
||||
"""缺补仓成交价时:触发价用计划网格,末档均价对齐头部,禁止反推离谱成交价."""
|
||||
plan = self._base_plan(
|
||||
legs_done=2,
|
||||
avg_entry_price=0.3507,
|
||||
order_amount_open=161,
|
||||
leg_fill_prices_json=json.dumps([0.3436]),
|
||||
grid_prices_json=json.dumps([0.343, 0.343, 0.3395, 0.336, 0.3325]),
|
||||
)
|
||||
enriched = attach_trend_dca_levels(plan)
|
||||
levels = enriched["dca_levels"]
|
||||
dca1 = levels[1]
|
||||
dca2 = levels[2]
|
||||
self.assertEqual(dca1["status"], "done")
|
||||
self.assertAlmostEqual(dca1["price"], 0.343, places=4)
|
||||
self.assertEqual(dca2["status"], "done")
|
||||
self.assertAlmostEqual(dca2["price"], 0.343, places=4)
|
||||
self.assertAlmostEqual(dca2["avg_entry"], 0.3507, places=4)
|
||||
self.assertLess(dca2["price"], 0.36)
|
||||
|
||||
def test_display_price_never_infers_from_target_avg(self):
|
||||
"""三所共用:缺记录时只用网格,不因均价反推离谱触发价."""
|
||||
plan = self._base_plan(
|
||||
legs_done=2,
|
||||
avg_entry_price=0.3507,
|
||||
leg_fill_prices_json=json.dumps([0.3436]),
|
||||
grid_prices_json=json.dumps([0.343, 0.343, 0.3395, 0.336, 0.3325]),
|
||||
)
|
||||
self.assertAlmostEqual(trend_leg_display_price(plan, 2), 0.343, places=4)
|
||||
self.assertLess(trend_leg_display_price(plan, 2), 0.36)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""趋势回调:补仓触达与有效保证金估算."""
|
||||
from lib.strategy.strategy_trend_lib import trend_dca_level_reached, trend_effective_margin_capital
|
||||
|
||||
|
||||
def test_trend_dca_short_monotonic_up_fills_missed_legs():
|
||||
"""做空价升:旧逻辑需 last<level,价越过 0.3437 后 last 已高于该档则永不补仓."""
|
||||
direction = "short"
|
||||
levels = [0.3413, 0.3437, 0.346, 0.3483, 0.3507]
|
||||
pf = 0.353
|
||||
filled = [lv for lv in levels if trend_dca_level_reached(direction, pf, lv)]
|
||||
assert filled == levels
|
||||
|
||||
|
||||
def test_trend_dca_short_not_before_first_level():
|
||||
direction = "short"
|
||||
assert not trend_dca_level_reached(direction, 0.336, 0.3413)
|
||||
assert trend_dca_level_reached(direction, 0.3413, 0.3413)
|
||||
|
||||
|
||||
def test_trend_dca_long_mark_below_trigger():
|
||||
direction = "long"
|
||||
assert trend_dca_level_reached(direction, 0.344, 0.3465)
|
||||
assert not trend_dca_level_reached(direction, 0.347, 0.3465)
|
||||
|
||||
|
||||
def test_trend_effective_margin_first_leg_only():
|
||||
plan = {
|
||||
"plan_margin_capital": 12.11,
|
||||
"target_order_amount": 359.0,
|
||||
"order_amount_open": 179.0,
|
||||
"first_order_amount": 179.0,
|
||||
}
|
||||
m = trend_effective_margin_capital(plan)
|
||||
assert abs(m - 12.11 * 179 / 359) < 0.01
|
||||
|
||||
|
||||
def test_trend_effective_margin_full_position():
|
||||
plan = {
|
||||
"plan_margin_capital": 12.11,
|
||||
"target_order_amount": 359.0,
|
||||
"order_amount_open": 359.0,
|
||||
}
|
||||
assert trend_effective_margin_capital(plan) == 12.11
|
||||
@@ -0,0 +1,92 @@
|
||||
"""趋势计划结束:须写入 trade_records(三所统一)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.strategy.strategy_trend_register import _call_insert_trade_record # noqa: E402
|
||||
|
||||
|
||||
class _GateBotLikeModule:
|
||||
"""模拟 gate:曾有 trend_plan_id 但缺 entry_reason 参数."""
|
||||
|
||||
@staticmethod
|
||||
def insert_trade_record(
|
||||
conn,
|
||||
symbol,
|
||||
monitor_type,
|
||||
direction,
|
||||
trigger_price,
|
||||
stop_loss,
|
||||
initial_stop_loss=None,
|
||||
take_profit=None,
|
||||
margin_capital=None,
|
||||
leverage=None,
|
||||
pnl_amount=0,
|
||||
hold_seconds=0,
|
||||
trade_style=None,
|
||||
risk_amount=None,
|
||||
planned_rr=None,
|
||||
actual_rr=None,
|
||||
result="",
|
||||
miss_reason=None,
|
||||
opened_at=None,
|
||||
opened_at_ms=None,
|
||||
closed_at=None,
|
||||
closed_at_ms=None,
|
||||
exchange_trade_id=None,
|
||||
trend_plan_id=None,
|
||||
):
|
||||
conn.execute(
|
||||
"INSERT INTO trade_records (symbol, monitor_type, direction, result, trend_plan_id) "
|
||||
"VALUES (?,?,?,?,?)",
|
||||
(symbol, monitor_type, direction, result, trend_plan_id),
|
||||
)
|
||||
|
||||
|
||||
class TestTrendFinalizeTradeRecord(unittest.TestCase):
|
||||
def test_call_insert_filters_unknown_entry_reason(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.execute(
|
||||
"CREATE TABLE trade_records (symbol TEXT, monitor_type TEXT, direction TEXT, "
|
||||
"result TEXT, trend_plan_id INTEGER)"
|
||||
)
|
||||
m = _GateBotLikeModule()
|
||||
_call_insert_trade_record(
|
||||
m,
|
||||
4,
|
||||
dict(
|
||||
conn=conn,
|
||||
symbol="ONDO/USDT",
|
||||
monitor_type="趋势回调",
|
||||
direction="long",
|
||||
trigger_price=0.35,
|
||||
stop_loss=0.329,
|
||||
result="止损",
|
||||
entry_reason="趋势回调",
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT symbol, monitor_type, trend_plan_id FROM trade_records"
|
||||
).fetchone()
|
||||
self.assertEqual(row[0], "ONDO/USDT")
|
||||
self.assertEqual(row[1], "趋势回调")
|
||||
self.assertEqual(row[2], 4)
|
||||
|
||||
def test_gate_insert_accepts_entry_reason(self):
|
||||
from crypto_monitor_gate import app as gate_app # noqa: E402
|
||||
|
||||
sig = inspect.signature(gate_app.insert_trade_record)
|
||||
self.assertIn("entry_reason", sig.parameters)
|
||||
self.assertIn("trend_plan_id", sig.parameters)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""趋势回调中控 enrich:补仓次数与加仓价."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.strategy.strategy_trend_register import _trend_add_leg_fields # noqa: E402
|
||||
|
||||
|
||||
class TestTrendHubEnrich(unittest.TestCase):
|
||||
def test_add_count_and_prices(self):
|
||||
mock_ex = MagicMock()
|
||||
mock_ex.price_to_precision = lambda sym, px: f"{float(px):.4f}"
|
||||
app_mod = MagicMock()
|
||||
app_mod.exchange = mock_ex
|
||||
app_mod.ensure_markets_loaded = MagicMock()
|
||||
app_mod.normalize_exchange_symbol = lambda s: s
|
||||
cfg = {"app_module": app_mod}
|
||||
raw = {
|
||||
"symbol": "ETH/USDT",
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
"legs_done": 2,
|
||||
"dca_legs": 5,
|
||||
"grid_prices_json": json.dumps([1800.1, 1750.2, 1700.3]),
|
||||
"stop_loss": 1600,
|
||||
"take_profit": 2000,
|
||||
"avg_entry_price": 1820.5,
|
||||
}
|
||||
out = _trend_add_leg_fields(cfg, raw)
|
||||
self.assertEqual(out["add_count"], 2)
|
||||
self.assertEqual(out["add_count_total"], 5)
|
||||
self.assertEqual(out["add_prices"], [1800.1, 1750.2])
|
||||
self.assertEqual(len(out["add_prices_display"]), 2)
|
||||
self.assertEqual(out["stop_loss_display"], "1600.0000")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user