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:
dekun
2026-07-17 16:18:13 +08:00
commit 53863559f4
608 changed files with 162764 additions and 0 deletions
+526
View File
@@ -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()
+59
View File
@@ -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()
+63
View File
@@ -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()
+60
View File
@@ -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()
+22
View File
@@ -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) == "止损"
+90
View File
@@ -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()
+64
View File
@@ -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()
+182
View File
@@ -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()
+17
View File
@@ -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()
+109
View File
@@ -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,
)
== "强制清仓"
)
+26
View File
@@ -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
+44
View File
@@ -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()
+108
View File
@@ -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()
+160
View File
@@ -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()
+220
View File
@@ -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()
+215
View File
@@ -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()
+47
View File
@@ -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()
+32
View File
@@ -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()
+94
View File
@@ -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()
+65
View File
@@ -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()
+44
View File
@@ -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()
+163
View File
@@ -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()
+113
View File
@@ -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()
+95
View File
@@ -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()
+67
View File
@@ -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
+101
View File
@@ -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()
+157
View File
@@ -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
+67
View File
@@ -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()
+128
View File
@@ -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
+58
View File
@@ -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()
+466
View File
@@ -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()
+73
View File
@@ -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()
+40
View File
@@ -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()
+75
View File
@@ -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
+222
View File
@@ -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()
+53
View File
@@ -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)
+74
View File
@@ -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)]
+15
View File
@@ -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
+49
View File
@@ -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()
+207
View File
@@ -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)
+348
View File
@@ -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
+96
View File
@@ -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"])
+102
View File
@@ -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
+229
View File
@@ -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()
+115
View File
@@ -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()
+184
View File
@@ -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
+172
View File
@@ -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()
+36
View File
@@ -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
+48
View File
@@ -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
+44
View File
@@ -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()
+65
View File
@@ -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()
+20
View File
@@ -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()
+21
View File
@@ -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
+35
View File
@@ -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
+43
View File
@@ -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()
+71
View File
@@ -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()
+156
View File
@@ -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()
+75
View File
@@ -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()
+34
View File
@@ -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()
+86
View File
@@ -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()
+27
View File
@@ -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()
+66
View File
@@ -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
+32
View File
@@ -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
+39
View File
@@ -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()
+38
View File
@@ -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()
+45
View File
@@ -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()
+22
View File
@@ -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
+69
View File
@@ -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()
+64
View File
@@ -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()
+47
View File
@@ -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"), [])
+32
View File
@@ -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)
+63
View File
@@ -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")])
+397
View File
@@ -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
+342
View File
@@ -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()
+86
View File
@@ -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)
+175
View File
@@ -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"
+169
View File
@@ -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()
+181
View File
@@ -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
+78
View File
@@ -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()
+35
View File
@@ -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()
+83
View File
@@ -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()
+41
View File
@@ -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
+69
View File
@@ -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()
+120
View File
@@ -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
+47
View File
@@ -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
+183
View File
@@ -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")
+41
View File
@@ -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()
+48
View File
@@ -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()
+48
View File
@@ -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()
+90
View File
@@ -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")
+30
View File
@@ -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
+26
View File
@@ -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()
+90
View File
@@ -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()
+101
View File
@@ -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()
+43
View File
@@ -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
+92
View File
@@ -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()
+44
View File
@@ -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