refactor: 将共用代码迁入 lib/ 模块化目录

统一 strategy、key_monitor、trade、hub 等共用库到 lib/ 子包,并补充 lib-structure 文档,便于四所与中控维护。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-02 16:23:09 +08:00
parent 4742a0bb9d
commit 5797d49d8a
190 changed files with 27946 additions and 27499 deletions
+5 -2
View File
@@ -53,8 +53,11 @@ bash deploy/setup_env.sh --install-system-deps
| `crypto_monitor_gate_bot/` | Gate 机器人 / 趋势户 | [部署文档.md](./crypto_monitor_gate_bot/部署文档.md) |
| `crypto_monitor_okx/` | OKX 永续 | [部署文档.md](./crypto_monitor_okx/部署文档.md) |
| `manual_trading_hub/` | 中控 + 子代理 | [部署文档.md](./manual_trading_hub/部署文档.md) |
| 根目录 `strategy_*.py` | 策略共用库 | [策略交易说明.md](./策略交易说明.md) |
| 根目录 `key_*_lib.py` | 关键位 / 止盈止损共用库 | [关键位止盈止损与移动保本更新说明.md](./关键位止盈止损与移动保本更新说明.md) |
| `lib/` | **共用模块**(策略、关键位、交易、中控库、AI、静态与模板) | **[docs/lib-structure.md](./docs/lib-structure.md)** |
| `brand/` | 各所共用图标与 manifest | — |
| `docs/``deploy/``scripts/``tests/` | 文档、环境、脚本、单元测试 | — |
共用代码 import 示例:`from lib.strategy.strategy_db import init_strategy_tables`(各所启动时仍将仓库根加入 `PYTHONPATH`)。详见 **[docs/lib-structure.md](./docs/lib-structure.md)**。
---
+61 -60
View File
@@ -34,14 +34,15 @@ import sys
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from ai_client import ai_generate, ai_review, ai_short_advice
from ai_review_lib import (
from lib.paths import common_static_dir
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import (
build_journal_ai_chart_path,
collect_images_for_ai_review,
journal_row_lines_for_ai,
)
from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from fib_key_monitor_lib import (
from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from lib.key_monitor.fib_key_monitor_lib import (
FIB_KEY_MONITOR_TYPES,
backfill_missing_key_signal_types,
calc_fib_plan,
@@ -52,7 +53,7 @@ from fib_key_monitor_lib import (
key_signal_type_for_trade_record,
stored_key_signal_type,
)
from false_breakout_key_monitor_lib import (
from lib.key_monitor.false_breakout_key_monitor_lib import (
FALSE_BREAKOUT_MONITOR_TYPE,
FALSE_BREAKOUT_VALIDITY_HOURS,
calc_false_breakout_plan,
@@ -65,7 +66,7 @@ from false_breakout_key_monitor_lib import (
normalize_false_breakout_symbol,
storage_bounds_from_key_price,
)
from strategy_trade_labels import (
from lib.strategy.strategy_trade_labels import (
STRATEGY_ENTRY_REASON_OPTIONS,
apply_order_monitor_source_labels,
entry_reason_for_monitor_type,
@@ -74,7 +75,7 @@ from strategy_trade_labels import (
trade_record_monitor_type as resolve_trade_record_monitor_type,
trend_plan_id_from_monitor_row,
)
from journal_chart_lib import (
from lib.instance.journal_chart_lib import (
JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1,
JOURNAL_CHART_DEFAULT_TF2,
@@ -90,7 +91,7 @@ from journal_chart_lib import (
trade_review_fetch_window,
trim_rows_for_trade_review,
)
from key_sl_tp_lib import (
from lib.key_monitor.key_sl_tp_lib import (
breakeven_enabled_from_row,
normalize_sl_tp_mode,
parse_breakeven_enabled_form,
@@ -99,7 +100,7 @@ from key_sl_tp_lib import (
sl_tp_mode_label,
sl_tp_plan_summary_text,
)
from time_close_lib import (
from lib.trade.time_close_lib import (
TIME_CLOSE_RESULT,
apply_time_close_to_payload,
ensure_time_close_schema,
@@ -110,13 +111,13 @@ from time_close_lib import (
time_close_label,
time_close_settings_from_row,
)
from manual_sltp_lib import (
from lib.trade.manual_sltp_lib import (
normalize_open_sltp_mode,
resolve_entrust_sltp_prices,
resolve_open_sltp_prices,
)
from key_monitor_schema_lib import ensure_key_monitor_schema
from trigger_entry_key_monitor_lib import (
from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema
from lib.key_monitor.trigger_entry_key_monitor_lib import (
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE,
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
@@ -139,7 +140,7 @@ from trigger_entry_key_monitor_lib import (
validate_trigger_entry_geometry,
validate_trigger_entry_rr,
)
from position_sizing_lib import (
from lib.trade.position_sizing_lib import (
OPEN_SOURCE_KEY_AUTO,
OPEN_SOURCE_KEY_TRIGGER,
OPEN_SOURCE_MANUAL,
@@ -155,12 +156,12 @@ from position_sizing_lib import (
mode_label_zh,
risk_percent_for_storage,
)
from key_monitor_full_margin_lib import (
from lib.key_monitor.key_monitor_full_margin_lib import (
monitor_type_disallowed_in_full_margin,
purge_disallowed_key_monitors,
)
from auto_transfer_daily_lib import run_auto_transfer_once_per_day
from key_monitor_lib import (
from lib.common.auto_transfer_daily_lib import run_auto_transfer_once_per_day
from lib.key_monitor.key_monitor_lib import (
KEY_DIRECTION_WATCH,
KEY_MONITOR_ALERT_ONLY_TYPES,
KEY_MONITOR_AUTO_TYPES,
@@ -180,15 +181,15 @@ from key_monitor_lib import (
rs_break_from_direction,
run_rs_level_alert_tick,
)
from order_monitor_display_lib import (
from lib.trade.order_monitor_display_lib import (
apply_order_price_display_fields,
enrich_order_display_fields,
order_monitor_tpsl_needs_sync,
)
from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook
from hub_auth import request_allowed as hub_request_allowed
from hub_volume_rank_lib import resolve_daily_volume_rank
from history_window_lib import (
from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook
from lib.hub.hub_auth import request_allowed as hub_request_allowed
from lib.hub.hub_volume_rank_lib import resolve_daily_volume_rank
from lib.common.history_window_lib import (
PRESET_CUSTOM,
PRESET_UTC_LAST24H,
PRESET_UTC_LAST7D,
@@ -201,8 +202,8 @@ from history_window_lib import (
utc_window_to_bj_sql_strings,
utc_window_to_utc_sql_strings,
)
from trade_result_lib import count_winning_trades, normalize_result_with_pnl
from trade_exchange_stats_lib import (
from lib.trade.trade_result_lib import count_winning_trades, normalize_result_with_pnl
from lib.trade.trade_exchange_stats_lib import (
attach_exchange_stats_to_trade,
filter_position_lifecycle_fills,
sum_binance_commission_income,
@@ -353,7 +354,7 @@ ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100"))
ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts"))
from daily_open_limit_lib import (
from lib.trade.daily_open_limit_lib import (
build_daily_open_alert_prompt,
can_trade_new_open,
check_daily_open_hard_limit,
@@ -1520,10 +1521,10 @@ def init_db():
close_reason TEXT, closed_at TEXT)"""
)
from strategy_db import init_strategy_tables
from lib.strategy.strategy_db import init_strategy_tables
init_strategy_tables(conn)
from account_risk_lib import ensure_account_risk_schema
from lib.trade.account_risk_lib import ensure_account_risk_schema
ensure_account_risk_schema(conn)
backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO)
@@ -1560,7 +1561,7 @@ def get_db():
def hub_account_risk_status(conn):
from account_risk_lib import (
from lib.trade.account_risk_lib import (
apply_position_limit_risk,
compute_account_risk_status,
enrich_risk_status_countdown,
@@ -1576,7 +1577,7 @@ def hub_account_risk_status(conn):
fmt_local_ms=ms_to_app_local_str,
)
st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
return apply_position_limit_risk(
st,
@@ -1593,7 +1594,7 @@ def hub_user_initiated_close(
trade_record_id=None,
closed_at_ms=None,
):
from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
src = (source or "").strip() or CLOSE_SOURCE_USER_HUB
on_user_initiated_close(
@@ -2120,7 +2121,7 @@ def get_effective_trade_field(row, reviewed_key, base_key, default=None):
def to_effective_trade_dict(row):
item = row_to_dict(row)
from order_monitor_display_lib import snapshot_stop_loss
from lib.trade.order_monitor_display_lib import snapshot_stop_loss
open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss"))
item["display_open_stop_loss"] = open_stop
@@ -2661,7 +2662,7 @@ def insert_trade_record(
open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts)
close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts)
kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES)
from order_monitor_display_lib import snapshot_stop_loss
from lib.trade.order_monitor_display_lib import snapshot_stop_loss
snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss)
er = (
@@ -3193,7 +3194,7 @@ def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
def precheck_risk(conn, symbol, direction):
now = app_now()
from account_risk_lib import account_risk_blocks_trading
from lib.trade.account_risk_lib import account_risk_blocks_trading
ok_risk, risk_reason = account_risk_blocks_trading(
conn,
@@ -3205,7 +3206,7 @@ def precheck_risk(conn, symbol, direction):
return False, risk_reason
if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
from account_risk_lib import position_limit_reached
from lib.trade.account_risk_lib import position_limit_reached
reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS)
if reached:
@@ -3898,7 +3899,7 @@ def list_orphan_live_positions(conn):
ex = normalize_exchange_symbol(r["exchange_symbol"] or r["symbol"])
active_keys.add((ex, (r["direction"] or "long").strip().lower()))
from hub_position_metrics import parse_position_entry_price
from lib.hub.hub_position_metrics import parse_position_entry_price
orphans = []
for lp in live_rows:
@@ -4097,7 +4098,7 @@ def parse_ccxt_position_metrics(position, order_leverage=None):
cs = float(get_contract_size(sym)) if sym else 1.0
except Exception:
cs = 1.0
from hub_position_metrics import enrich_ccxt_position_metrics_out
from lib.hub.hub_position_metrics import enrich_ccxt_position_metrics_out
enrich_ccxt_position_metrics_out(
p, out, contract_size=cs, funds_decimals=FUNDS_DECIMALS
@@ -6807,14 +6808,14 @@ def background_task():
check_trigger_entry_key_monitors()
_roll_cfg = app.extensions.get("strategy_roll_cfg")
if _roll_cfg:
from strategy_roll_monitor_lib import check_roll_monitors
from lib.strategy.strategy_roll_monitor_lib import check_roll_monitors
check_roll_monitors(_roll_cfg)
check_key_monitors()
check_order_monitors()
cfg = app.extensions.get("strategy_trend_cfg")
if cfg:
from strategy_trend_register import check_trend_pullback_plans
from lib.strategy.strategy_trend_register import check_trend_pullback_plans
check_trend_pullback_plans(cfg)
except:
@@ -7006,7 +7007,7 @@ def render_main_page(page="trade", embed_mode=None):
conn = get_db()
session_row = ensure_session(conn, trading_day)
local_current_capital = float(session_row["current_capital"])
from instance_embed_context_lib import (
from lib.instance.instance_embed_context_lib import (
embed_render_plan,
minimal_stats_bundle,
trade_records_summary,
@@ -7070,7 +7071,7 @@ def render_main_page(page="trade", embed_mode=None):
records = []
total = miss_count = rate = occupied_miss_total = 0
active_count = len(order_list)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
position_limit_count = count_position_limit_active_monitors(conn)
opens_today = count_opens_for_trading_day(conn, trading_day)
@@ -7101,7 +7102,7 @@ def render_main_page(page="trade", embed_mode=None):
)
strategy_extra = {}
if plan.strategy:
from strategy_ui import strategy_render_extras
from lib.strategy.strategy_ui import strategy_render_extras
strategy_extra = strategy_render_extras(
conn,
@@ -7114,7 +7115,7 @@ def render_main_page(page="trade", embed_mode=None):
if plan.orphan_live and not order_list and exchange_private_api_configured():
orphan_live_positions = list_orphan_live_positions(conn)
conn.close()
from instance_embed_lib import embed_context_extras
from lib.instance.instance_embed_lib import embed_context_extras
template_ctx = dict(
page=page,
@@ -7236,7 +7237,7 @@ def api_account_snapshot():
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS)
recommended_capital = get_recommended_capital(current_capital)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
position_limit_count = count_position_limit_active_monitors(conn)
opens_today = count_opens_for_trading_day(conn, trading_day)
@@ -7523,7 +7524,7 @@ def api_price_snapshot():
pass
conn.close()
from hub_position_metrics import build_position_marks_list
from lib.hub.hub_position_metrics import build_position_marks_list
position_marks = build_position_marks_list(
all_swap_positions,
@@ -7782,7 +7783,7 @@ def api_order_kline():
"volume": float(bar[5]),
})
from focus_chart_lib import (
from lib.instance.focus_chart_lib import (
build_order_kline_order_payload,
load_swap_positions_for_order_kline,
metrics_for_order_item,
@@ -7810,7 +7811,7 @@ def api_order_kline():
ex_metrics=ex_metrics,
)
from focus_chart_lib import kline_api_price_fields
from lib.instance.focus_chart_lib import kline_api_price_fields
price_fields = kline_api_price_fields(
exchange,
@@ -7927,7 +7928,7 @@ def api_key_kline():
"lower_pct": lower_pct,
}
from focus_chart_lib import enrich_key_kline_response
from lib.instance.focus_chart_lib import enrich_key_kline_response
price_display, key_info = enrich_key_kline_response(
symbol=symbol,
@@ -7936,7 +7937,7 @@ def api_key_kline():
format_price_fn=format_price_for_symbol,
)
from focus_chart_lib import kline_api_price_fields
from lib.instance.focus_chart_lib import kline_api_price_fields
price_fields = kline_api_price_fields(
exchange,
@@ -8859,7 +8860,7 @@ def del_order(id):
opened_at=opened_at,
closed_at=closed_at,
)
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
on_user_initiated_close(
conn,
@@ -8873,7 +8874,7 @@ def del_order(id):
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
from lib.strategy.strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
@@ -8934,7 +8935,7 @@ def del_order(id):
opened_at=opened_at,
closed_at=closed_at,
)
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
on_user_initiated_close(
conn,
@@ -8948,7 +8949,7 @@ def del_order(id):
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
from lib.strategy.strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
@@ -9109,7 +9110,7 @@ def add_journal():
d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename
)
)
from account_risk_lib import on_journal_saved
from lib.trade.account_risk_lib import on_journal_saved
on_journal_saved(
conn,
@@ -9197,7 +9198,7 @@ def api_reviews():
return jsonify([row_to_dict(r) for r in rows])
_REPO_STATIC_DIR = os.path.join(os.path.dirname(BASE_DIR), "static")
_REPO_STATIC_DIR = common_static_dir(os.path.dirname(BASE_DIR))
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js")
@@ -9422,7 +9423,7 @@ def api_trade_record_review_update():
tuple(base_params + [rec_id]),
)
if reviewed_result == "手动平仓" and reviewed_miss_reason:
from account_risk_lib import apply_manual_close_journal_cooloff
from lib.trade.account_risk_lib import apply_manual_close_journal_cooloff
apply_manual_close_journal_cooloff(
conn,
@@ -9571,7 +9572,7 @@ def _hub_account_bundle():
def _hub_fetch_market(base=""):
from hub_market_info_lib import fetch_usdt_swap_market_info
from lib.hub.hub_market_info_lib import fetch_usdt_swap_market_info
return fetch_usdt_swap_market_info(
base_or_symbol=base,
@@ -9584,7 +9585,7 @@ def _hub_fetch_market(base=""):
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
from hub_ohlcv_lib import fetch_ohlcv_for_hub
from lib.hub.hub_ohlcv_lib import fetch_ohlcv_for_hub
return fetch_ohlcv_for_hub(
symbol=symbol,
@@ -9600,7 +9601,7 @@ def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
def _hub_fetch_volume_rank(top_n=20):
from hub_volume_rank_lib import fetch_usdt_swap_volume_rank
from lib.hub.hub_volume_rank_lib import fetch_usdt_swap_volume_rank
return fetch_usdt_swap_volume_rank(
exchange=exchange,
@@ -9617,7 +9618,7 @@ try:
_repo_root = Path(__file__).resolve().parent.parent
if str(_repo_root) not in sys.path:
sys.path.insert(0, str(_repo_root))
from hub_bridge import install_on_app
from lib.hub.hub_bridge import install_on_app
install_on_app(
app,
@@ -9660,8 +9661,8 @@ def strategy_roll_page():
return redirect("/strategy")
from strategy_register import install_strategy_trading
from strategy_trend_register import install_strategy_trend
from lib.strategy.strategy_register import install_strategy_trading
from lib.strategy.strategy_trend_register import install_strategy_trend
install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
+65 -64
View File
@@ -34,14 +34,15 @@ import sys
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from ai_client import ai_generate, ai_review, ai_short_advice
from ai_review_lib import (
from lib.paths import common_static_dir
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import (
build_journal_ai_chart_path,
collect_images_for_ai_review,
journal_row_lines_for_ai,
)
from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from fib_key_monitor_lib import (
from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from lib.key_monitor.fib_key_monitor_lib import (
FIB_KEY_MONITOR_TYPES,
KEY_ENTRY_REASON_BY_SIGNAL,
backfill_missing_key_signal_types,
@@ -53,7 +54,7 @@ from fib_key_monitor_lib import (
key_signal_type_for_trade_record,
stored_key_signal_type,
)
from false_breakout_key_monitor_lib import (
from lib.key_monitor.false_breakout_key_monitor_lib import (
FALSE_BREAKOUT_MONITOR_TYPE,
FALSE_BREAKOUT_VALIDITY_HOURS,
calc_false_breakout_plan,
@@ -66,7 +67,7 @@ from false_breakout_key_monitor_lib import (
normalize_false_breakout_symbol,
storage_bounds_from_key_price,
)
from strategy_trade_labels import (
from lib.strategy.strategy_trade_labels import (
STRATEGY_ENTRY_REASON_OPTIONS,
apply_order_monitor_source_labels,
entry_reason_for_monitor_type,
@@ -75,7 +76,7 @@ from strategy_trade_labels import (
trade_record_monitor_type as resolve_trade_record_monitor_type,
trend_plan_id_from_monitor_row,
)
from journal_chart_lib import (
from lib.instance.journal_chart_lib import (
JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1,
JOURNAL_CHART_DEFAULT_TF2,
@@ -91,7 +92,7 @@ from journal_chart_lib import (
trade_review_fetch_window,
trim_rows_for_trade_review,
)
from key_sl_tp_lib import (
from lib.key_monitor.key_sl_tp_lib import (
breakeven_enabled_from_row,
normalize_sl_tp_mode,
parse_breakeven_enabled_form,
@@ -100,7 +101,7 @@ from key_sl_tp_lib import (
sl_tp_mode_label,
sl_tp_plan_summary_text,
)
from time_close_lib import (
from lib.trade.time_close_lib import (
TIME_CLOSE_RESULT,
apply_time_close_to_payload,
ensure_time_close_schema,
@@ -111,13 +112,13 @@ from time_close_lib import (
time_close_label,
time_close_settings_from_row,
)
from manual_sltp_lib import (
from lib.trade.manual_sltp_lib import (
normalize_open_sltp_mode,
resolve_entrust_sltp_prices,
resolve_open_sltp_prices,
)
from key_monitor_schema_lib import ensure_key_monitor_schema
from trigger_entry_key_monitor_lib import (
from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema
from lib.key_monitor.trigger_entry_key_monitor_lib import (
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE,
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
@@ -140,7 +141,7 @@ from trigger_entry_key_monitor_lib import (
validate_trigger_entry_geometry,
validate_trigger_entry_rr,
)
from position_sizing_lib import (
from lib.trade.position_sizing_lib import (
OPEN_SOURCE_KEY_AUTO,
OPEN_SOURCE_KEY_TRIGGER,
OPEN_SOURCE_MANUAL,
@@ -154,12 +155,12 @@ from position_sizing_lib import (
mode_label_zh,
risk_percent_for_storage,
)
from key_monitor_full_margin_lib import (
from lib.key_monitor.key_monitor_full_margin_lib import (
monitor_type_disallowed_in_full_margin,
purge_disallowed_key_monitors,
)
from auto_transfer_daily_lib import run_auto_transfer_once_per_day
from key_monitor_lib import (
from lib.common.auto_transfer_daily_lib import run_auto_transfer_once_per_day
from lib.key_monitor.key_monitor_lib import (
KEY_DIRECTION_WATCH,
KEY_MONITOR_ALERT_ONLY_TYPES,
KEY_MONITOR_AUTO_TYPES,
@@ -179,16 +180,16 @@ from key_monitor_lib import (
rs_break_from_direction,
run_rs_level_alert_tick,
)
from order_monitor_display_lib import (
from lib.trade.order_monitor_display_lib import (
apply_order_price_display_fields,
enrich_order_display_fields,
order_monitor_tpsl_needs_sync,
)
from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook
from hub_auth import request_allowed as hub_request_allowed
from instance_nav_lib import request_is_hub_soft_nav
from hub_volume_rank_lib import resolve_daily_volume_rank
from history_window_lib import (
from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook
from lib.hub.hub_auth import request_allowed as hub_request_allowed
from lib.instance.instance_nav_lib import request_is_hub_soft_nav
from lib.hub.hub_volume_rank_lib import resolve_daily_volume_rank
from lib.common.history_window_lib import (
PRESET_CUSTOM,
PRESET_UTC_LAST24H,
PRESET_UTC_LAST7D,
@@ -201,8 +202,8 @@ from history_window_lib import (
utc_window_to_bj_sql_strings,
utc_window_to_utc_sql_strings,
)
from trade_result_lib import count_winning_trades, normalize_result_with_pnl
from trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills
from lib.trade.trade_result_lib import count_winning_trades, normalize_result_with_pnl
from lib.trade.trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills
def load_env_file(path):
@@ -343,7 +344,7 @@ ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100"))
ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts"))
from daily_open_limit_lib import (
from lib.trade.daily_open_limit_lib import (
build_daily_open_alert_prompt,
can_trade_new_open,
check_daily_open_hard_limit,
@@ -1506,10 +1507,10 @@ def init_db():
close_reason TEXT, closed_at TEXT)"""
)
from strategy_db import init_strategy_tables
from lib.strategy.strategy_db import init_strategy_tables
init_strategy_tables(conn)
from account_risk_lib import ensure_account_risk_schema
from lib.trade.account_risk_lib import ensure_account_risk_schema
ensure_account_risk_schema(conn)
backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO)
@@ -1546,7 +1547,7 @@ def get_db():
def hub_account_risk_status(conn):
from account_risk_lib import (
from lib.trade.account_risk_lib import (
apply_position_limit_risk,
compute_account_risk_status,
enrich_risk_status_countdown,
@@ -1562,7 +1563,7 @@ def hub_account_risk_status(conn):
fmt_local_ms=ms_to_app_local_str,
)
st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
return apply_position_limit_risk(
st,
@@ -1579,7 +1580,7 @@ def hub_user_initiated_close(
trade_record_id=None,
closed_at_ms=None,
):
from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
src = (source or "").strip() or CLOSE_SOURCE_USER_HUB
on_user_initiated_close(
@@ -2072,7 +2073,7 @@ def get_effective_trade_field(row, reviewed_key, base_key, default=None):
def to_effective_trade_dict(row):
item = row_to_dict(row)
from order_monitor_display_lib import snapshot_stop_loss
from lib.trade.order_monitor_display_lib import snapshot_stop_loss
open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss"))
item["display_open_stop_loss"] = open_stop
@@ -2370,7 +2371,7 @@ def insert_trade_record(
open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts)
close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts)
kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES)
from order_monitor_display_lib import snapshot_stop_loss
from lib.trade.order_monitor_display_lib import snapshot_stop_loss
snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss)
er = (
@@ -2761,7 +2762,7 @@ def get_exchange_capitals(force=False):
def execute_transfer_usdt(amount, from_account, to_account):
from gate_transfer_lib import execute_transfer_usdt as _gate_execute_transfer_usdt
from lib.exchange.gate_transfer_lib import execute_transfer_usdt as _gate_execute_transfer_usdt
return _gate_execute_transfer_usdt(
exchange,
@@ -2794,7 +2795,7 @@ def get_account_usdt_total(account_type):
def _auto_transfer_active_count(conn):
from gate_transfer_lib import count_auto_transfer_blockers
from lib.exchange.gate_transfer_lib import count_auto_transfer_blockers
return count_auto_transfer_blockers(conn, count_order_monitors=get_active_position_count)
@@ -2878,7 +2879,7 @@ def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
def precheck_risk(conn, symbol, direction):
now = app_now()
from account_risk_lib import account_risk_blocks_trading
from lib.trade.account_risk_lib import account_risk_blocks_trading
ok_risk, risk_reason = account_risk_blocks_trading(
conn,
@@ -2890,7 +2891,7 @@ def precheck_risk(conn, symbol, direction):
return False, risk_reason
if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
from account_risk_lib import position_limit_reached
from lib.trade.account_risk_lib import position_limit_reached
reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS)
if reached:
@@ -3670,7 +3671,7 @@ def parse_ccxt_position_metrics(position, order_leverage=None):
cs = float(get_contract_size(sym)) if sym else 1.0
except Exception:
cs = 1.0
from hub_position_metrics import enrich_ccxt_position_metrics_out
from lib.hub.hub_position_metrics import enrich_ccxt_position_metrics_out
enrich_ccxt_position_metrics_out(p, out, contract_size=cs, funds_decimals=2)
return out or None
@@ -3854,7 +3855,7 @@ def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_
except Exception:
pass
try:
from gate_position_history_lib import pick_gate_position_close
from lib.exchange.gate_position_history_lib import pick_gate_position_close
pos = pick_gate_position_close(
fetch_gate_positions_close_history(),
@@ -4114,7 +4115,7 @@ def reconcile_hub_external_close(conn, symbol, direction):
"""中控市价全平后:立即同步匹配 order_monitor,并读 Gate 平仓历史。"""
if not exchange_private_api_configured():
return {"ok": False, "msg": "未配置 GATE_API_KEY / GATE_API_SECRET", "synced": 0}
from gate_position_history_lib import unified_symbol_for_match
from lib.exchange.gate_position_history_lib import unified_symbol_for_match
sym_u = unified_symbol_for_match(symbol)
dir_l = (direction or "").strip().lower()
@@ -6513,14 +6514,14 @@ def background_task():
check_trigger_entry_key_monitors()
_roll_cfg = app.extensions.get("strategy_roll_cfg")
if _roll_cfg:
from strategy_roll_monitor_lib import check_roll_monitors
from lib.strategy.strategy_roll_monitor_lib import check_roll_monitors
check_roll_monitors(_roll_cfg)
check_key_monitors()
check_order_monitors()
cfg = app.extensions.get("strategy_trend_cfg")
if cfg:
from strategy_trend_register import check_trend_pullback_plans
from lib.strategy.strategy_trend_register import check_trend_pullback_plans
check_trend_pullback_plans(cfg)
except:
@@ -6848,7 +6849,7 @@ def render_main_page(page="trade", embed_mode=None):
conn = get_db()
session_row = ensure_session(conn, trading_day)
local_current_capital = float(session_row["current_capital"])
from instance_embed_context_lib import (
from lib.instance.instance_embed_context_lib import (
embed_render_plan,
minimal_stats_bundle,
trade_records_summary,
@@ -6921,7 +6922,7 @@ def render_main_page(page="trade", embed_mode=None):
records = []
total = miss_count = rate = occupied_miss_total = 0
active_count = len(order_list)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
position_limit_count = count_position_limit_active_monitors(conn)
opens_today = count_opens_for_trading_day(conn, trading_day)
@@ -6952,7 +6953,7 @@ def render_main_page(page="trade", embed_mode=None):
)
strategy_extra = {}
if plan.strategy:
from strategy_ui import strategy_render_extras
from lib.strategy.strategy_ui import strategy_render_extras
strategy_extra = strategy_render_extras(
conn,
@@ -6962,7 +6963,7 @@ def render_main_page(page="trade", embed_mode=None):
trend_cfg=app.extensions.get("strategy_trend_cfg"),
)
conn.close()
from instance_embed_lib import embed_context_extras
from lib.instance.instance_embed_lib import embed_context_extras
template_ctx = dict(
page=page,
@@ -7104,7 +7105,7 @@ def api_account_snapshot():
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
recommended_capital = round(float(get_recommended_capital(current_capital)), 2)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
position_limit_count = count_position_limit_active_monitors(conn)
opens_today = count_opens_for_trading_day(conn, trading_day)
@@ -7414,7 +7415,7 @@ def api_price_snapshot():
pass
conn.close()
from hub_position_metrics import build_position_marks_list
from lib.hub.hub_position_metrics import build_position_marks_list
position_marks = build_position_marks_list(
all_swap_positions,
@@ -7647,7 +7648,7 @@ def api_order_kline():
"volume": float(bar[5]),
})
from focus_chart_lib import (
from lib.instance.focus_chart_lib import (
build_order_kline_order_payload,
load_swap_positions_for_order_kline,
metrics_for_order_item,
@@ -7675,7 +7676,7 @@ def api_order_kline():
ex_metrics=ex_metrics,
)
from focus_chart_lib import kline_api_price_fields
from lib.instance.focus_chart_lib import kline_api_price_fields
price_fields = kline_api_price_fields(
exchange,
@@ -7792,7 +7793,7 @@ def api_key_kline():
"lower_pct": lower_pct,
}
from focus_chart_lib import enrich_key_kline_response
from lib.instance.focus_chart_lib import enrich_key_kline_response
price_display, key_info = enrich_key_kline_response(
symbol=symbol,
@@ -7801,7 +7802,7 @@ def api_key_kline():
format_price_fn=format_price_for_symbol,
)
from focus_chart_lib import kline_api_price_fields
from lib.instance.focus_chart_lib import kline_api_price_fields
price_fields = kline_api_price_fields(
exchange,
@@ -8756,7 +8757,7 @@ def del_order(id):
opened_at=opened_at,
closed_at=closed_at,
)
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
on_user_initiated_close(
conn,
@@ -8770,7 +8771,7 @@ def del_order(id):
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
from lib.strategy.strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
@@ -8832,7 +8833,7 @@ def del_order(id):
opened_at=opened_at,
closed_at=closed_at,
)
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
on_user_initiated_close(
conn,
@@ -8846,7 +8847,7 @@ def del_order(id):
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
from lib.strategy.strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
@@ -9020,7 +9021,7 @@ def add_journal():
d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename
)
)
from account_risk_lib import on_journal_saved
from lib.trade.account_risk_lib import on_journal_saved
on_journal_saved(
conn,
@@ -9108,7 +9109,7 @@ def api_reviews():
return jsonify([row_to_dict(r) for r in rows])
_REPO_STATIC_DIR = os.path.join(os.path.dirname(BASE_DIR), "static")
_REPO_STATIC_DIR = common_static_dir(os.path.dirname(BASE_DIR))
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js")
@@ -9342,7 +9343,7 @@ def api_trade_record_review_update():
tuple(base_params + [rec_id]),
)
if reviewed_result == "手动平仓" and reviewed_miss_reason:
from account_risk_lib import apply_manual_close_journal_cooloff
from lib.trade.account_risk_lib import apply_manual_close_journal_cooloff
apply_manual_close_journal_cooloff(
conn,
@@ -9491,7 +9492,7 @@ def _hub_account_bundle():
def _hub_fetch_market(base=""):
from hub_market_info_lib import fetch_usdt_swap_market_info
from lib.hub.hub_market_info_lib import fetch_usdt_swap_market_info
return fetch_usdt_swap_market_info(
base_or_symbol=base,
@@ -9504,7 +9505,7 @@ def _hub_fetch_market(base=""):
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
from hub_ohlcv_lib import fetch_ohlcv_for_hub
from lib.hub.hub_ohlcv_lib import fetch_ohlcv_for_hub
return fetch_ohlcv_for_hub(
symbol=symbol,
@@ -9520,7 +9521,7 @@ def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
def _hub_fetch_volume_rank(top_n=20):
from hub_volume_rank_lib import fetch_usdt_swap_volume_rank
from lib.hub.hub_volume_rank_lib import fetch_usdt_swap_volume_rank
return fetch_usdt_swap_volume_rank(
exchange=exchange,
@@ -9537,7 +9538,7 @@ try:
_repo_root = Path(__file__).resolve().parent.parent
if str(_repo_root) not in sys.path:
sys.path.insert(0, str(_repo_root))
from hub_bridge import install_on_app
from lib.hub.hub_bridge import install_on_app
install_on_app(
app,
@@ -9581,8 +9582,8 @@ def strategy_roll_page():
return redirect("/strategy")
from strategy_register import install_strategy_trading
from strategy_trend_register import install_strategy_trend
from lib.strategy.strategy_register import install_strategy_trading
from lib.strategy.strategy_trend_register import install_strategy_trend
install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
+65 -64
View File
@@ -34,14 +34,15 @@ import sys
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from ai_client import ai_generate, ai_review, ai_short_advice
from ai_review_lib import (
from lib.paths import common_static_dir
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import (
build_journal_ai_chart_path,
collect_images_for_ai_review,
journal_row_lines_for_ai,
)
from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from fib_key_monitor_lib import (
from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from lib.key_monitor.fib_key_monitor_lib import (
FIB_KEY_MONITOR_TYPES,
KEY_ENTRY_REASON_BY_SIGNAL,
backfill_missing_key_signal_types,
@@ -53,7 +54,7 @@ from fib_key_monitor_lib import (
key_signal_type_for_trade_record,
stored_key_signal_type,
)
from false_breakout_key_monitor_lib import (
from lib.key_monitor.false_breakout_key_monitor_lib import (
FALSE_BREAKOUT_MONITOR_TYPE,
FALSE_BREAKOUT_VALIDITY_HOURS,
calc_false_breakout_plan,
@@ -66,7 +67,7 @@ from false_breakout_key_monitor_lib import (
normalize_false_breakout_symbol,
storage_bounds_from_key_price,
)
from strategy_trade_labels import (
from lib.strategy.strategy_trade_labels import (
STRATEGY_ENTRY_REASON_OPTIONS,
apply_order_monitor_source_labels,
entry_reason_for_monitor_type,
@@ -75,7 +76,7 @@ from strategy_trade_labels import (
trade_record_monitor_type as resolve_trade_record_monitor_type,
trend_plan_id_from_monitor_row,
)
from journal_chart_lib import (
from lib.instance.journal_chart_lib import (
JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1,
JOURNAL_CHART_DEFAULT_TF2,
@@ -91,7 +92,7 @@ from journal_chart_lib import (
trade_review_fetch_window,
trim_rows_for_trade_review,
)
from key_sl_tp_lib import (
from lib.key_monitor.key_sl_tp_lib import (
breakeven_enabled_from_row,
normalize_sl_tp_mode,
parse_breakeven_enabled_form,
@@ -100,7 +101,7 @@ from key_sl_tp_lib import (
sl_tp_mode_label,
sl_tp_plan_summary_text,
)
from time_close_lib import (
from lib.trade.time_close_lib import (
TIME_CLOSE_RESULT,
apply_time_close_to_payload,
ensure_time_close_schema,
@@ -111,13 +112,13 @@ from time_close_lib import (
time_close_label,
time_close_settings_from_row,
)
from manual_sltp_lib import (
from lib.trade.manual_sltp_lib import (
normalize_open_sltp_mode,
resolve_entrust_sltp_prices,
resolve_open_sltp_prices,
)
from key_monitor_schema_lib import ensure_key_monitor_schema
from trigger_entry_key_monitor_lib import (
from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema
from lib.key_monitor.trigger_entry_key_monitor_lib import (
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE,
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
@@ -140,7 +141,7 @@ from trigger_entry_key_monitor_lib import (
validate_trigger_entry_geometry,
validate_trigger_entry_rr,
)
from position_sizing_lib import (
from lib.trade.position_sizing_lib import (
OPEN_SOURCE_KEY_AUTO,
OPEN_SOURCE_KEY_TRIGGER,
OPEN_SOURCE_MANUAL,
@@ -154,12 +155,12 @@ from position_sizing_lib import (
mode_label_zh,
risk_percent_for_storage,
)
from key_monitor_full_margin_lib import (
from lib.key_monitor.key_monitor_full_margin_lib import (
monitor_type_disallowed_in_full_margin,
purge_disallowed_key_monitors,
)
from auto_transfer_daily_lib import run_auto_transfer_once_per_day
from key_monitor_lib import (
from lib.common.auto_transfer_daily_lib import run_auto_transfer_once_per_day
from lib.key_monitor.key_monitor_lib import (
KEY_DIRECTION_WATCH,
KEY_MONITOR_ALERT_ONLY_TYPES,
KEY_MONITOR_AUTO_TYPES,
@@ -179,16 +180,16 @@ from key_monitor_lib import (
rs_break_from_direction,
run_rs_level_alert_tick,
)
from order_monitor_display_lib import (
from lib.trade.order_monitor_display_lib import (
apply_order_price_display_fields,
enrich_order_display_fields,
order_monitor_tpsl_needs_sync,
)
from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook
from hub_auth import request_allowed as hub_request_allowed
from instance_nav_lib import request_is_hub_soft_nav
from hub_volume_rank_lib import resolve_daily_volume_rank
from history_window_lib import (
from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook
from lib.hub.hub_auth import request_allowed as hub_request_allowed
from lib.instance.instance_nav_lib import request_is_hub_soft_nav
from lib.hub.hub_volume_rank_lib import resolve_daily_volume_rank
from lib.common.history_window_lib import (
PRESET_CUSTOM,
PRESET_UTC_LAST24H,
PRESET_UTC_LAST7D,
@@ -201,8 +202,8 @@ from history_window_lib import (
utc_window_to_bj_sql_strings,
utc_window_to_utc_sql_strings,
)
from trade_result_lib import count_winning_trades, normalize_result_with_pnl
from trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills
from lib.trade.trade_result_lib import count_winning_trades, normalize_result_with_pnl
from lib.trade.trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills
def load_env_file(path):
@@ -343,7 +344,7 @@ ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100"))
ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts"))
from daily_open_limit_lib import (
from lib.trade.daily_open_limit_lib import (
build_daily_open_alert_prompt,
can_trade_new_open,
check_daily_open_hard_limit,
@@ -1506,10 +1507,10 @@ def init_db():
close_reason TEXT, closed_at TEXT)"""
)
from strategy_db import init_strategy_tables
from lib.strategy.strategy_db import init_strategy_tables
init_strategy_tables(conn)
from account_risk_lib import ensure_account_risk_schema
from lib.trade.account_risk_lib import ensure_account_risk_schema
ensure_account_risk_schema(conn)
backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO)
@@ -1546,7 +1547,7 @@ def get_db():
def hub_account_risk_status(conn):
from account_risk_lib import (
from lib.trade.account_risk_lib import (
apply_position_limit_risk,
compute_account_risk_status,
enrich_risk_status_countdown,
@@ -1562,7 +1563,7 @@ def hub_account_risk_status(conn):
fmt_local_ms=ms_to_app_local_str,
)
st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
return apply_position_limit_risk(
st,
@@ -1579,7 +1580,7 @@ def hub_user_initiated_close(
trade_record_id=None,
closed_at_ms=None,
):
from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
src = (source or "").strip() or CLOSE_SOURCE_USER_HUB
on_user_initiated_close(
@@ -2072,7 +2073,7 @@ def get_effective_trade_field(row, reviewed_key, base_key, default=None):
def to_effective_trade_dict(row):
item = row_to_dict(row)
from order_monitor_display_lib import snapshot_stop_loss
from lib.trade.order_monitor_display_lib import snapshot_stop_loss
open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss"))
item["display_open_stop_loss"] = open_stop
@@ -2370,7 +2371,7 @@ def insert_trade_record(
open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts)
close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts)
kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES)
from order_monitor_display_lib import snapshot_stop_loss
from lib.trade.order_monitor_display_lib import snapshot_stop_loss
snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss)
er = (
@@ -2761,7 +2762,7 @@ def get_exchange_capitals(force=False):
def execute_transfer_usdt(amount, from_account, to_account):
from gate_transfer_lib import execute_transfer_usdt as _gate_execute_transfer_usdt
from lib.exchange.gate_transfer_lib import execute_transfer_usdt as _gate_execute_transfer_usdt
return _gate_execute_transfer_usdt(
exchange,
@@ -2794,7 +2795,7 @@ def get_account_usdt_total(account_type):
def _auto_transfer_active_count(conn):
from gate_transfer_lib import count_auto_transfer_blockers
from lib.exchange.gate_transfer_lib import count_auto_transfer_blockers
return count_auto_transfer_blockers(conn, count_order_monitors=get_active_position_count)
@@ -2878,7 +2879,7 @@ def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
def precheck_risk(conn, symbol, direction):
now = app_now()
from account_risk_lib import account_risk_blocks_trading
from lib.trade.account_risk_lib import account_risk_blocks_trading
ok_risk, risk_reason = account_risk_blocks_trading(
conn,
@@ -2890,7 +2891,7 @@ def precheck_risk(conn, symbol, direction):
return False, risk_reason
if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
from account_risk_lib import position_limit_reached
from lib.trade.account_risk_lib import position_limit_reached
reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS)
if reached:
@@ -3670,7 +3671,7 @@ def parse_ccxt_position_metrics(position, order_leverage=None):
cs = float(get_contract_size(sym)) if sym else 1.0
except Exception:
cs = 1.0
from hub_position_metrics import enrich_ccxt_position_metrics_out
from lib.hub.hub_position_metrics import enrich_ccxt_position_metrics_out
enrich_ccxt_position_metrics_out(p, out, contract_size=cs, funds_decimals=2)
return out or None
@@ -3854,7 +3855,7 @@ def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_
except Exception:
pass
try:
from gate_position_history_lib import pick_gate_position_close
from lib.exchange.gate_position_history_lib import pick_gate_position_close
pos = pick_gate_position_close(
fetch_gate_positions_close_history(),
@@ -4114,7 +4115,7 @@ def reconcile_hub_external_close(conn, symbol, direction):
"""中控市价全平后:立即同步匹配 order_monitor,并读 Gate 平仓历史。"""
if not exchange_private_api_configured():
return {"ok": False, "msg": "未配置 GATE_API_KEY / GATE_API_SECRET", "synced": 0}
from gate_position_history_lib import unified_symbol_for_match
from lib.exchange.gate_position_history_lib import unified_symbol_for_match
sym_u = unified_symbol_for_match(symbol)
dir_l = (direction or "").strip().lower()
@@ -6513,14 +6514,14 @@ def background_task():
check_trigger_entry_key_monitors()
_roll_cfg = app.extensions.get("strategy_roll_cfg")
if _roll_cfg:
from strategy_roll_monitor_lib import check_roll_monitors
from lib.strategy.strategy_roll_monitor_lib import check_roll_monitors
check_roll_monitors(_roll_cfg)
check_key_monitors()
check_order_monitors()
cfg = app.extensions.get("strategy_trend_cfg")
if cfg:
from strategy_trend_register import check_trend_pullback_plans
from lib.strategy.strategy_trend_register import check_trend_pullback_plans
check_trend_pullback_plans(cfg)
except:
@@ -6848,7 +6849,7 @@ def render_main_page(page="trade", embed_mode=None):
conn = get_db()
session_row = ensure_session(conn, trading_day)
local_current_capital = float(session_row["current_capital"])
from instance_embed_context_lib import (
from lib.instance.instance_embed_context_lib import (
embed_render_plan,
minimal_stats_bundle,
trade_records_summary,
@@ -6921,7 +6922,7 @@ def render_main_page(page="trade", embed_mode=None):
records = []
total = miss_count = rate = occupied_miss_total = 0
active_count = len(order_list)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
position_limit_count = count_position_limit_active_monitors(conn)
opens_today = count_opens_for_trading_day(conn, trading_day)
@@ -6952,7 +6953,7 @@ def render_main_page(page="trade", embed_mode=None):
)
strategy_extra = {}
if plan.strategy:
from strategy_ui import strategy_render_extras
from lib.strategy.strategy_ui import strategy_render_extras
strategy_extra = strategy_render_extras(
conn,
@@ -6962,7 +6963,7 @@ def render_main_page(page="trade", embed_mode=None):
trend_cfg=app.extensions.get("strategy_trend_cfg"),
)
conn.close()
from instance_embed_lib import embed_context_extras
from lib.instance.instance_embed_lib import embed_context_extras
template_ctx = dict(
page=page,
@@ -7100,7 +7101,7 @@ def api_account_snapshot():
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
recommended_capital = round(float(get_recommended_capital(current_capital)), 2)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
position_limit_count = count_position_limit_active_monitors(conn)
opens_today = count_opens_for_trading_day(conn, trading_day)
@@ -7410,7 +7411,7 @@ def api_price_snapshot():
pass
conn.close()
from hub_position_metrics import build_position_marks_list
from lib.hub.hub_position_metrics import build_position_marks_list
position_marks = build_position_marks_list(
all_swap_positions,
@@ -7643,7 +7644,7 @@ def api_order_kline():
"volume": float(bar[5]),
})
from focus_chart_lib import (
from lib.instance.focus_chart_lib import (
build_order_kline_order_payload,
load_swap_positions_for_order_kline,
metrics_for_order_item,
@@ -7671,7 +7672,7 @@ def api_order_kline():
ex_metrics=ex_metrics,
)
from focus_chart_lib import kline_api_price_fields
from lib.instance.focus_chart_lib import kline_api_price_fields
price_fields = kline_api_price_fields(
exchange,
@@ -7788,7 +7789,7 @@ def api_key_kline():
"lower_pct": lower_pct,
}
from focus_chart_lib import enrich_key_kline_response
from lib.instance.focus_chart_lib import enrich_key_kline_response
price_display, key_info = enrich_key_kline_response(
symbol=symbol,
@@ -7797,7 +7798,7 @@ def api_key_kline():
format_price_fn=format_price_for_symbol,
)
from focus_chart_lib import kline_api_price_fields
from lib.instance.focus_chart_lib import kline_api_price_fields
price_fields = kline_api_price_fields(
exchange,
@@ -8752,7 +8753,7 @@ def del_order(id):
opened_at=opened_at,
closed_at=closed_at,
)
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
on_user_initiated_close(
conn,
@@ -8766,7 +8767,7 @@ def del_order(id):
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
from lib.strategy.strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
@@ -8828,7 +8829,7 @@ def del_order(id):
opened_at=opened_at,
closed_at=closed_at,
)
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
on_user_initiated_close(
conn,
@@ -8842,7 +8843,7 @@ def del_order(id):
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
from lib.strategy.strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
@@ -9016,7 +9017,7 @@ def add_journal():
d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename
)
)
from account_risk_lib import on_journal_saved
from lib.trade.account_risk_lib import on_journal_saved
on_journal_saved(
conn,
@@ -9104,7 +9105,7 @@ def api_reviews():
return jsonify([row_to_dict(r) for r in rows])
_REPO_STATIC_DIR = os.path.join(os.path.dirname(BASE_DIR), "static")
_REPO_STATIC_DIR = common_static_dir(os.path.dirname(BASE_DIR))
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js")
@@ -9338,7 +9339,7 @@ def api_trade_record_review_update():
tuple(base_params + [rec_id]),
)
if reviewed_result == "手动平仓" and reviewed_miss_reason:
from account_risk_lib import apply_manual_close_journal_cooloff
from lib.trade.account_risk_lib import apply_manual_close_journal_cooloff
apply_manual_close_journal_cooloff(
conn,
@@ -9487,7 +9488,7 @@ def _hub_account_bundle():
def _hub_fetch_market(base=""):
from hub_market_info_lib import fetch_usdt_swap_market_info
from lib.hub.hub_market_info_lib import fetch_usdt_swap_market_info
return fetch_usdt_swap_market_info(
base_or_symbol=base,
@@ -9500,7 +9501,7 @@ def _hub_fetch_market(base=""):
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
from hub_ohlcv_lib import fetch_ohlcv_for_hub
from lib.hub.hub_ohlcv_lib import fetch_ohlcv_for_hub
return fetch_ohlcv_for_hub(
symbol=symbol,
@@ -9516,7 +9517,7 @@ def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
def _hub_fetch_volume_rank(top_n=20):
from hub_volume_rank_lib import fetch_usdt_swap_volume_rank
from lib.hub.hub_volume_rank_lib import fetch_usdt_swap_volume_rank
return fetch_usdt_swap_volume_rank(
exchange=exchange,
@@ -9533,7 +9534,7 @@ try:
_repo_root = Path(__file__).resolve().parent.parent
if str(_repo_root) not in sys.path:
sys.path.insert(0, str(_repo_root))
from hub_bridge import install_on_app
from lib.hub.hub_bridge import install_on_app
install_on_app(
app,
@@ -9577,8 +9578,8 @@ def strategy_roll_page():
return redirect("/strategy")
from strategy_register import install_strategy_trading
from strategy_trend_register import install_strategy_trend
from lib.strategy.strategy_register import install_strategy_trading
from lib.strategy.strategy_trend_register import install_strategy_trend
install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
+63 -62
View File
@@ -34,14 +34,15 @@ import sys
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from ai_client import ai_generate, ai_review, ai_short_advice
from ai_review_lib import (
from lib.paths import common_static_dir
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import (
build_journal_ai_chart_path,
collect_images_for_ai_review,
journal_row_lines_for_ai,
)
from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from fib_key_monitor_lib import (
from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from lib.key_monitor.fib_key_monitor_lib import (
FIB_KEY_MONITOR_TYPES,
backfill_missing_key_signal_types,
calc_fib_plan,
@@ -52,7 +53,7 @@ from fib_key_monitor_lib import (
key_signal_type_for_trade_record,
stored_key_signal_type,
)
from false_breakout_key_monitor_lib import (
from lib.key_monitor.false_breakout_key_monitor_lib import (
FALSE_BREAKOUT_MONITOR_TYPE,
FALSE_BREAKOUT_VALIDITY_HOURS,
calc_false_breakout_plan,
@@ -65,7 +66,7 @@ from false_breakout_key_monitor_lib import (
normalize_false_breakout_symbol,
storage_bounds_from_key_price,
)
from strategy_trade_labels import (
from lib.strategy.strategy_trade_labels import (
STRATEGY_ENTRY_REASON_OPTIONS,
apply_order_monitor_source_labels,
entry_reason_for_monitor_type,
@@ -74,8 +75,8 @@ from strategy_trade_labels import (
trade_record_monitor_type as resolve_trade_record_monitor_type,
trend_plan_id_from_monitor_row,
)
from okx_orders_lib import cancel_okx_all_open_orders, fetch_okx_all_open_orders
from journal_chart_lib import (
from lib.exchange.okx_orders_lib import cancel_okx_all_open_orders, fetch_okx_all_open_orders
from lib.instance.journal_chart_lib import (
JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1,
JOURNAL_CHART_DEFAULT_TF2,
@@ -91,7 +92,7 @@ from journal_chart_lib import (
trade_review_fetch_window,
trim_rows_for_trade_review,
)
from key_sl_tp_lib import (
from lib.key_monitor.key_sl_tp_lib import (
breakeven_enabled_from_row,
normalize_sl_tp_mode,
parse_breakeven_enabled_form,
@@ -100,7 +101,7 @@ from key_sl_tp_lib import (
sl_tp_mode_label,
sl_tp_plan_summary_text,
)
from time_close_lib import (
from lib.trade.time_close_lib import (
TIME_CLOSE_RESULT,
apply_time_close_to_payload,
ensure_time_close_schema,
@@ -111,13 +112,13 @@ from time_close_lib import (
time_close_label,
time_close_settings_from_row,
)
from manual_sltp_lib import (
from lib.trade.manual_sltp_lib import (
normalize_open_sltp_mode,
resolve_entrust_sltp_prices,
resolve_open_sltp_prices,
)
from key_monitor_schema_lib import ensure_key_monitor_schema
from trigger_entry_key_monitor_lib import (
from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema
from lib.key_monitor.trigger_entry_key_monitor_lib import (
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE,
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
@@ -140,7 +141,7 @@ from trigger_entry_key_monitor_lib import (
validate_trigger_entry_geometry,
validate_trigger_entry_rr,
)
from position_sizing_lib import (
from lib.trade.position_sizing_lib import (
OPEN_SOURCE_KEY_AUTO,
OPEN_SOURCE_MANUAL,
assert_open_source_allowed,
@@ -153,12 +154,12 @@ from position_sizing_lib import (
mode_label_zh,
risk_percent_for_storage,
)
from key_monitor_full_margin_lib import (
from lib.key_monitor.key_monitor_full_margin_lib import (
monitor_type_disallowed_in_full_margin,
purge_disallowed_key_monitors,
)
from auto_transfer_daily_lib import run_auto_transfer_once_per_day
from key_monitor_lib import (
from lib.common.auto_transfer_daily_lib import run_auto_transfer_once_per_day
from lib.key_monitor.key_monitor_lib import (
KEY_DIRECTION_WATCH,
KEY_MONITOR_ALERT_ONLY_TYPES,
KEY_MONITOR_AUTO_TYPES,
@@ -178,16 +179,16 @@ from key_monitor_lib import (
rs_break_from_direction,
run_rs_level_alert_tick,
)
from order_monitor_display_lib import (
from lib.trade.order_monitor_display_lib import (
apply_order_price_display_fields,
enrich_order_display_fields,
order_monitor_tpsl_needs_sync,
)
from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook
from hub_auth import request_allowed as hub_request_allowed
from instance_nav_lib import request_is_hub_soft_nav
from hub_volume_rank_lib import resolve_daily_volume_rank
from history_window_lib import (
from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook
from lib.hub.hub_auth import request_allowed as hub_request_allowed
from lib.instance.instance_nav_lib import request_is_hub_soft_nav
from lib.hub.hub_volume_rank_lib import resolve_daily_volume_rank
from lib.common.history_window_lib import (
PRESET_CUSTOM,
PRESET_UTC_LAST24H,
PRESET_UTC_LAST7D,
@@ -200,8 +201,8 @@ from history_window_lib import (
utc_window_to_bj_sql_strings,
utc_window_to_utc_sql_strings,
)
from trade_result_lib import count_winning_trades, normalize_result_with_pnl
from trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills
from lib.trade.trade_result_lib import count_winning_trades, normalize_result_with_pnl
from lib.trade.trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills
def load_env_file(path):
@@ -323,7 +324,7 @@ ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100"))
ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts"))
from daily_open_limit_lib import (
from lib.trade.daily_open_limit_lib import (
build_daily_open_alert_prompt,
can_trade_new_open,
check_daily_open_hard_limit,
@@ -1493,10 +1494,10 @@ def init_db():
close_reason TEXT, closed_at TEXT)"""
)
from strategy_db import init_strategy_tables
from lib.strategy.strategy_db import init_strategy_tables
init_strategy_tables(conn)
from account_risk_lib import ensure_account_risk_schema
from lib.trade.account_risk_lib import ensure_account_risk_schema
ensure_account_risk_schema(conn)
backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO)
@@ -1533,7 +1534,7 @@ def get_db():
def hub_account_risk_status(conn):
from account_risk_lib import (
from lib.trade.account_risk_lib import (
apply_position_limit_risk,
compute_account_risk_status,
enrich_risk_status_countdown,
@@ -1549,7 +1550,7 @@ def hub_account_risk_status(conn):
fmt_local_ms=ms_to_app_local_str,
)
st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
return apply_position_limit_risk(
st,
@@ -1566,7 +1567,7 @@ def hub_user_initiated_close(
trade_record_id=None,
closed_at_ms=None,
):
from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
src = (source or "").strip() or CLOSE_SOURCE_USER_HUB
on_user_initiated_close(
@@ -2021,7 +2022,7 @@ def get_effective_trade_field(row, reviewed_key, base_key, default=None):
def to_effective_trade_dict(row):
item = row_to_dict(row)
from order_monitor_display_lib import snapshot_stop_loss
from lib.trade.order_monitor_display_lib import snapshot_stop_loss
open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss"))
item["display_open_stop_loss"] = open_stop
@@ -2266,7 +2267,7 @@ def insert_trade_record(
open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts)
close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts)
kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES)
from order_monitor_display_lib import snapshot_stop_loss
from lib.trade.order_monitor_display_lib import snapshot_stop_loss
snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss)
er = (
@@ -2590,7 +2591,7 @@ def trading_day_reset_allows_new_open(now, conn=None):
def precheck_risk(conn, symbol, direction):
now = app_now()
from account_risk_lib import account_risk_blocks_trading
from lib.trade.account_risk_lib import account_risk_blocks_trading
ok_risk, risk_reason = account_risk_blocks_trading(
conn,
@@ -2602,7 +2603,7 @@ def precheck_risk(conn, symbol, direction):
return False, risk_reason
if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
from account_risk_lib import position_limit_reached
from lib.trade.account_risk_lib import position_limit_reached
reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS)
if reached:
@@ -2973,7 +2974,7 @@ def parse_ccxt_position_metrics(position, order_leverage=None):
cs = float(get_contract_size(sym)) if sym else 1.0
except Exception:
cs = 1.0
from hub_position_metrics import enrich_ccxt_position_metrics_out
from lib.hub.hub_position_metrics import enrich_ccxt_position_metrics_out
enrich_ccxt_position_metrics_out(
p, out, contract_size=cs, funds_decimals=FUNDS_DECIMALS
@@ -6252,14 +6253,14 @@ def background_task():
check_trigger_entry_key_monitors()
_roll_cfg = app.extensions.get("strategy_roll_cfg")
if _roll_cfg:
from strategy_roll_monitor_lib import check_roll_monitors
from lib.strategy.strategy_roll_monitor_lib import check_roll_monitors
check_roll_monitors(_roll_cfg)
check_key_monitors()
check_order_monitors()
cfg = app.extensions.get("strategy_trend_cfg")
if cfg:
from strategy_trend_register import check_trend_pullback_plans
from lib.strategy.strategy_trend_register import check_trend_pullback_plans
check_trend_pullback_plans(cfg)
except:
@@ -6348,7 +6349,7 @@ def render_main_page(page="trade", embed_mode=None):
conn = get_db()
session_row = ensure_session(conn, trading_day)
local_current_capital = float(session_row["current_capital"])
from instance_embed_context_lib import (
from lib.instance.instance_embed_context_lib import (
embed_render_plan,
minimal_stats_bundle,
trade_records_summary,
@@ -6420,7 +6421,7 @@ def render_main_page(page="trade", embed_mode=None):
records = []
total = miss_count = rate = occupied_miss_total = 0
active_count = len(order_list)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
position_limit_count = count_position_limit_active_monitors(conn)
open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn)
@@ -6453,7 +6454,7 @@ def render_main_page(page="trade", embed_mode=None):
)
strategy_extra = {}
if plan.strategy:
from strategy_ui import strategy_render_extras
from lib.strategy.strategy_ui import strategy_render_extras
strategy_extra = strategy_render_extras(
conn,
@@ -6463,7 +6464,7 @@ def render_main_page(page="trade", embed_mode=None):
trend_cfg=app.extensions.get("strategy_trend_cfg"),
)
conn.close()
from instance_embed_lib import embed_context_extras
from lib.instance.instance_embed_lib import embed_context_extras
template_ctx = dict(
page=page,
@@ -6600,7 +6601,7 @@ def api_account_snapshot():
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS)
recommended_capital = get_recommended_capital(current_capital)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
position_limit_count = count_position_limit_active_monitors(conn)
open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn)
@@ -6651,7 +6652,7 @@ def api_settings_open_guard():
now = app_now()
conn = get_db()
trading_day = get_trading_day(now)
from strategy_trade_labels import count_position_limit_active_monitors
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
position_limit_count = count_position_limit_active_monitors(conn)
guard_on = get_trading_day_reset_open_guard_enabled(conn)
@@ -6954,7 +6955,7 @@ def api_price_snapshot():
pass
conn.close()
from hub_position_metrics import build_position_marks_list
from lib.hub.hub_position_metrics import build_position_marks_list
position_marks = build_position_marks_list(
all_swap_positions,
@@ -7108,7 +7109,7 @@ def api_order_kline():
"volume": float(bar[5]),
})
from focus_chart_lib import (
from lib.instance.focus_chart_lib import (
build_order_kline_order_payload,
load_swap_positions_for_order_kline,
metrics_for_order_item,
@@ -7136,7 +7137,7 @@ def api_order_kline():
ex_metrics=ex_metrics,
)
from focus_chart_lib import kline_api_price_fields
from lib.instance.focus_chart_lib import kline_api_price_fields
price_fields = kline_api_price_fields(
exchange,
@@ -7252,7 +7253,7 @@ def api_key_kline():
"lower_pct": lower_pct,
}
from focus_chart_lib import enrich_key_kline_response
from lib.instance.focus_chart_lib import enrich_key_kline_response
price_display, key_info = enrich_key_kline_response(
symbol=symbol,
@@ -7261,7 +7262,7 @@ def api_key_kline():
format_price_fn=format_price_for_symbol,
)
from focus_chart_lib import kline_api_price_fields
from lib.instance.focus_chart_lib import kline_api_price_fields
price_fields = kline_api_price_fields(
exchange,
@@ -8241,7 +8242,7 @@ def del_order(id):
opened_at=opened_at,
closed_at=closed_at,
)
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
on_user_initiated_close(
conn,
@@ -8255,7 +8256,7 @@ def del_order(id):
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
from lib.strategy.strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
@@ -8314,7 +8315,7 @@ def del_order(id):
opened_at=opened_at,
closed_at=closed_at,
)
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
on_user_initiated_close(
conn,
@@ -8328,7 +8329,7 @@ def del_order(id):
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
from lib.strategy.strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
@@ -8489,7 +8490,7 @@ def add_journal():
d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename
)
)
from account_risk_lib import on_journal_saved
from lib.trade.account_risk_lib import on_journal_saved
on_journal_saved(
conn,
@@ -8577,7 +8578,7 @@ def api_reviews():
return jsonify([row_to_dict(r) for r in rows])
_REPO_STATIC_DIR = os.path.join(os.path.dirname(BASE_DIR), "static")
_REPO_STATIC_DIR = common_static_dir(os.path.dirname(BASE_DIR))
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js")
@@ -8802,7 +8803,7 @@ def api_trade_record_review_update():
tuple(base_params + [rec_id]),
)
if reviewed_result == "手动平仓" and reviewed_miss_reason:
from account_risk_lib import apply_manual_close_journal_cooloff
from lib.trade.account_risk_lib import apply_manual_close_journal_cooloff
apply_manual_close_journal_cooloff(
conn,
@@ -8952,7 +8953,7 @@ def _hub_account_bundle():
def _hub_fetch_market(base=""):
from hub_market_info_lib import fetch_usdt_swap_market_info
from lib.hub.hub_market_info_lib import fetch_usdt_swap_market_info
return fetch_usdt_swap_market_info(
base_or_symbol=base,
@@ -8965,7 +8966,7 @@ def _hub_fetch_market(base=""):
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
from hub_ohlcv_lib import fetch_ohlcv_for_hub
from lib.hub.hub_ohlcv_lib import fetch_ohlcv_for_hub
return fetch_ohlcv_for_hub(
symbol=symbol,
@@ -8981,7 +8982,7 @@ def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
def _hub_fetch_volume_rank(top_n=20):
from hub_volume_rank_lib import fetch_usdt_swap_volume_rank
from lib.hub.hub_volume_rank_lib import fetch_usdt_swap_volume_rank
return fetch_usdt_swap_volume_rank(
exchange=exchange,
@@ -8998,7 +8999,7 @@ try:
_repo_root = Path(__file__).resolve().parent.parent
if str(_repo_root) not in sys.path:
sys.path.insert(0, str(_repo_root))
from hub_bridge import install_on_app
from lib.hub.hub_bridge import install_on_app
install_on_app(
app,
@@ -9045,8 +9046,8 @@ def strategy_roll_page():
normalize_exchange_symbol = normalize_okx_symbol
ensure_exchange_live_ready = ensure_okx_live_ready
from strategy_register import install_strategy_trading
from strategy_trend_register import install_strategy_trend
from lib.strategy.strategy_register import install_strategy_trading
from lib.strategy.strategy_trend_register import install_strategy_trend
install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
+147
View File
@@ -0,0 +1,147 @@
# lib/ 共用模块结构
四所实例与中控共用的 Python 库、模板与静态资源统一放在仓库根目录的 **`lib/`** 下。部署单元(`crypto_monitor_*``manual_trading_hub`)仍保持独立目录与 PM2 配置不变。
**重构前快照 Git 标签**`pre-lib-modularization`(可用 `git checkout pre-lib-modularization` 查看旧布局)。
---
## 顶层目录
```
crypto_monitor/
├── crypto_monitor_binance/ # 四所:各自 app + .env + PM2
├── crypto_monitor_gate/
├── crypto_monitor_gate_bot/
├── crypto_monitor_okx/
├── manual_trading_hub/ # 中控 + 子代理 agent
├── lib/ # 共用模块(本说明)
│ ├── strategy/
│ ├── key_monitor/
│ ├── trade/
│ ├── hub/
│ ├── ai/
│ ├── instance/
│ ├── exchange/
│ ├── common/
│ └── paths.py
├── brand/ # 各所共用图标
├── docs/
├── deploy/
├── scripts/
├── tests/
├── requirements.txt
└── README.md
```
---
## lib/ 子包说明
| 子包 | 职责 | 主要模块 |
|------|------|----------|
| **`lib/strategy/`** | 策略交易(顺势加仓、趋势回调、快照与记录) | `strategy_register.py``strategy_trend_register.py``strategy_db.py``strategy_roll_*``strategy_trend_*` |
| **`lib/strategy/templates/`** | 策略页 Jinja 模板(原 `strategy_templates/` | `strategy_trading_page.html``strategy_roll_panel.html` 等 |
| **`lib/key_monitor/`** | 关键位监控、斐波、假突破、止盈止损方案 | `key_monitor_lib.py``fib_key_monitor_lib.py``key_sl_tp_lib.py` 等 |
| **`lib/trade/`** | 下单监控展示、计仓、账户风控、手动 SL/TP | `order_monitor_display_lib.py``position_sizing_lib.py``account_risk_lib.py` 等 |
| **`lib/hub/`** | 中控 API、K 线、归档、计仓器、SSO/Bridge | `hub_bridge.py``hub_kline_store.py``hub_trades_lib.py` 等 |
| **`lib/ai/`** | AI 复盘与文本生成 | `ai_client.py``ai_review_lib.py` |
| **`lib/instance/`** | 中控 iframe 嵌入、导航、复盘图表 | `instance_embed_lib.py``focus_chart_lib.py``journal_chart_lib.py` |
| **`lib/instance/templates/`** | 嵌入页片段(原 `embed_templates/` | `embed_page_fragment.html` |
| **`lib/exchange/`** | 特定交易所工具 | `gate_transfer_lib.py``okx_orders_lib.py` 等 |
| **`lib/common/`** | 跨功能小工具 | `form_submit_lib.py``wechat_notify_lib.py` 等 |
| **`lib/common/static/`** | 四所与中控共用的 JS/CSS(原根目录 `static/` | `instance_theme.js``strategy_roll.js` 等 |
> **说明**:`hub_*` 命名表示「中控侧能力或行情聚合」,但部分模块(如 `hub_volume_rank_lib`、`hub_market_info_lib`)四所 `app.py` 也会调用,并非中控独占。
---
## 路径辅助函数
`lib/paths.py` 集中维护资源目录,避免硬编码:
```python
from lib.paths import strategy_templates_dir, embed_templates_dir, common_static_dir
strategy_templates_dir() # .../lib/strategy/templates
embed_templates_dir() # .../lib/instance/templates
common_static_dir() # .../lib/common/static
```
可选传入 `repo_root`(字符串或 `Path`),默认使用 `lib/` 的上级目录即仓库根。
---
## Python 导入约定
各部署目录在启动时将 **仓库根** 加入 `sys.path`(与重构前相同):
```python
_REPO_ROOT = os.path.dirname(BASE_DIR) # 或 Path(__file__).resolve().parent.parent
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
```
之后使用 **`lib.<子包>.<模块>`** 形式导入,例如:
```python
from lib.strategy.strategy_db import init_strategy_tables
from lib.key_monitor.key_monitor_lib import check_key_monitors
from lib.hub.hub_bridge import install_on_app
from lib.ai.ai_client import ai_review
```
策略注册仍在各所 `app.py` 末尾:
```python
from lib.strategy.strategy_register import install_strategy_trading
from lib.strategy.strategy_trend_register import install_strategy_trend
install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
```
---
## 静态资源与 URL
- 四所页面仍通过 **`/static/...`** 访问共用脚本;`hub_bridge.install_instance_theme_static``lib/common/static/` 提供部分根级静态路由。
- 各所目录下 **`static/`**(图标、上传图片等)仍为实例私有,未迁入 `lib/`
- 中控 `manual_trading_hub/hub.py` 通过 `_REPO_ROOT / "lib" / "common" / "static"` 挂载与四所共用的 badge、复盘 JS 等。
---
## 测试
在仓库根执行(需将根目录置于 Python 路径,或从根目录运行):
```bash
cd /opt/crypto_monitor
python -m unittest discover -s tests -p "test_*.py"
```
测试文件内统一 `from lib.<子包>.<模块> import ...`。使用 `@patch` 时目标写完整模块路径,例如 `lib.hub.hub_calculator_lib._resolve_market`
---
## 迁移脚本
一次性迁移由 `scripts/migrate_to_lib.py` 完成(移动文件 + 批量改写 import)。**不要在已迁移后的仓库上重复执行**。
---
## 后续可选整理
- 四所 `app.py` 体量接近,可逐步抽取公共 `exchange_app` 基座(改动面大,单独规划)。
- `manual_trading_hub/okx_orders_lib.py` 为 agent 本地副本,可与 `lib/exchange/okx_orders_lib.py` 合并去重。
- 可引入 `pyproject.toml` + `pip install -e .`,替代 `sys.path.insert`(长期维护更规范)。
---
## 相关文档
- [README.md](../README.md) — 总览与部署
- [策略交易说明.md](../策略交易说明.md)
- [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md)
+1
View File
@@ -0,0 +1 @@
"""crypto_monitor shared libraries."""
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+180 -180
View File
@@ -1,180 +1,180 @@
"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(四所共用)。"""
from __future__ import annotations
import os
import uuid
from typing import Any, Callable, List, Mapping, Optional, Sequence
from journal_chart_lib import (
JOURNAL_CHART_ANCHOR_CLOSE,
JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1,
JOURNAL_CHART_DEFAULT_TF2,
normalize_chart_timeframe,
)
def _journal_nz(v: Any, default: str = "") -> str:
if v is None:
return default
s = str(v).strip()
return s if s else default
def _row_get(row: Any, key: str, default: Any = None) -> Any:
"""兼容 dict 与 sqlite3.RowRow 无 .get 方法)。"""
if row is None:
return default
getter = getattr(row, "get", None)
if callable(getter):
return getter(key, default)
try:
keys = row.keys() if hasattr(row, "keys") else ()
if key in keys:
return row[key]
except Exception:
pass
try:
return row[key]
except (KeyError, TypeError, IndexError):
return default
def journal_row_lines_for_ai(
idx: int,
row: Any,
*,
include_hold_duration: bool = True,
) -> str:
"""把 journal 字段拼成给 AI 的文本;四所日复盘/周复盘共用。"""
lines = [
(
f"{idx}. {_journal_nz(_row_get(row, 'coin'))} {_journal_nz(_row_get(row, 'tf'))} "
f"| 盈亏:{_journal_nz(_row_get(row, 'pnl'))}U "
f"| 实际RR:{_journal_nz(_row_get(row, 'real_rr'))} "
f"| 预期RR:{_journal_nz(_row_get(row, 'expect_rr'))}"
),
f" 开仓逻辑:{_journal_nz(_row_get(row, 'entry_reason'))}",
f" 平仓/离场(交易员自述):{_journal_nz(_row_get(row, 'exit_reason'))}",
]
if include_hold_duration:
lines.append(f" 持仓时长:{_journal_nz(_row_get(row, 'hold_duration'))}")
ee_bits = [
_journal_nz(_row_get(row, "early_exit")),
_journal_nz(_row_get(row, "early_exit_reason")),
_journal_nz(_row_get(row, "early_exit_trigger")),
_journal_nz(_row_get(row, "early_exit_note")),
]
if any(x != "" for x in ee_bits):
lines.append(
" 提前离场记录:"
f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}"
)
mood_bits = f"心态标签:{_journal_nz(_row_get(row, 'mood_issues'))}"
mood_score = _row_get(row, "mood_score")
if mood_score is not None:
mood_bits += f" | 自评心态分:{mood_score}"
lines.append(f" {mood_bits}")
if _journal_nz(_row_get(row, "post_breakeven_stare")) != "":
lines.append(f" 保本后盯盘:{_journal_nz(_row_get(row, 'post_breakeven_stare'))}")
if _journal_nz(_row_get(row, "new_trade_while_occupied")) != "":
lines.append(f" 占用时新开仓:{_journal_nz(_row_get(row, 'new_trade_while_occupied'))}")
if _journal_nz(_row_get(row, "note")) != "":
lines.append(f" 备注:{_journal_nz(_row_get(row, 'note'))}")
return "\n".join(lines) + "\n"
def collect_images_for_ai_review(
rows: Sequence,
upload_folder: str,
*,
build_chart_if_missing: Optional[Callable] = None,
) -> List[str]:
"""
收集传给视觉模型的本地图片路径
- 优先 journal_entries.image 已存附图
- 若无附图且提供 build_chart_if_missing则临时生成 K 线图
"""
paths: List[str] = []
seen = set()
upload_folder = os.path.abspath(upload_folder or "")
for row in rows or []:
candidate = None
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
img = row["image"] if "image" in keys else None
if img:
candidate = os.path.join(upload_folder, str(img).strip())
elif build_chart_if_missing:
try:
candidate = build_chart_if_missing(row)
except Exception:
candidate = None
if not candidate:
continue
candidate = os.path.abspath(candidate)
if os.path.isfile(candidate) and candidate not in seen:
seen.add(candidate)
paths.append(candidate)
return paths
def build_journal_ai_chart_path(
row,
upload_folder: str,
*,
order_chart_enabled: bool,
normalize_exchange_symbol_fn: Callable[[str], str],
generate_chart_fn: Callable,
local_datetime_to_ms_fn: Callable[[str], Optional[int]],
now_ts_ms_fn: Callable[[], int],
) -> Optional[str]:
"""无已存附图时,按复盘记录开平仓时间临时生成 K 线图路径。"""
if not order_chart_enabled:
return None
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
return None
coin = (row["coin"] if "coin" in keys else "") or ""
coin = str(coin).strip()
if not coin:
return None
try:
symbol = normalize_exchange_symbol_fn(coin)
except Exception:
return None
open_dt = row["open_datetime"] if "open_datetime" in keys else ""
close_dt = row["close_datetime"] if "close_datetime" in keys else ""
entry_ms = local_datetime_to_ms_fn(open_dt)
exit_ms = local_datetime_to_ms_fn(close_dt)
if not entry_ms:
return None
row_tf = row["tf"] if "tf" in keys else ""
tf1 = normalize_chart_timeframe(row_tf) or JOURNAL_CHART_DEFAULT_TF1
tf2 = JOURNAL_CHART_DEFAULT_TF2 if tf1 != JOURNAL_CHART_DEFAULT_TF2 else "1h"
row_id = str(row["id"] if "id" in keys else "")[:8] or uuid.uuid4().hex[:8]
marker = {
"entry_ts_ms": entry_ms,
"exit_ts_ms": exit_ms,
"chart_anchor": JOURNAL_CHART_ANCHOR_CLOSE,
"now_ts_ms": int(now_ts_ms_fn()),
}
fname = f"ai_rev_{row_id}_{uuid.uuid4().hex[:6]}.png"
saved = generate_chart_fn(
symbol,
f"AI复盘 {coin}",
timeframes=[tf1, tf2],
limit=JOURNAL_CHART_DEFAULT_LIMIT,
out_dir=upload_folder,
filename=fname,
marker_payload=marker,
marker_timeframes={tf1, tf2},
layout="vertical",
)
if not saved:
return None
path = os.path.join(upload_folder, saved)
return path if os.path.isfile(path) else None
"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(四所共用)。"""
from __future__ import annotations
import os
import uuid
from typing import Any, Callable, List, Mapping, Optional, Sequence
from lib.instance.journal_chart_lib import (
JOURNAL_CHART_ANCHOR_CLOSE,
JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1,
JOURNAL_CHART_DEFAULT_TF2,
normalize_chart_timeframe,
)
def _journal_nz(v: Any, default: str = "") -> str:
if v is None:
return default
s = str(v).strip()
return s if s else default
def _row_get(row: Any, key: str, default: Any = None) -> Any:
"""兼容 dict 与 sqlite3.RowRow 无 .get 方法)。"""
if row is None:
return default
getter = getattr(row, "get", None)
if callable(getter):
return getter(key, default)
try:
keys = row.keys() if hasattr(row, "keys") else ()
if key in keys:
return row[key]
except Exception:
pass
try:
return row[key]
except (KeyError, TypeError, IndexError):
return default
def journal_row_lines_for_ai(
idx: int,
row: Any,
*,
include_hold_duration: bool = True,
) -> str:
"""把 journal 字段拼成给 AI 的文本;四所日复盘/周复盘共用。"""
lines = [
(
f"{idx}. {_journal_nz(_row_get(row, 'coin'))} {_journal_nz(_row_get(row, 'tf'))} "
f"| 盈亏:{_journal_nz(_row_get(row, 'pnl'))}U "
f"| 实际RR:{_journal_nz(_row_get(row, 'real_rr'))} "
f"| 预期RR:{_journal_nz(_row_get(row, 'expect_rr'))}"
),
f" 开仓逻辑:{_journal_nz(_row_get(row, 'entry_reason'))}",
f" 平仓/离场(交易员自述):{_journal_nz(_row_get(row, 'exit_reason'))}",
]
if include_hold_duration:
lines.append(f" 持仓时长:{_journal_nz(_row_get(row, 'hold_duration'))}")
ee_bits = [
_journal_nz(_row_get(row, "early_exit")),
_journal_nz(_row_get(row, "early_exit_reason")),
_journal_nz(_row_get(row, "early_exit_trigger")),
_journal_nz(_row_get(row, "early_exit_note")),
]
if any(x != "" for x in ee_bits):
lines.append(
" 提前离场记录:"
f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}"
)
mood_bits = f"心态标签:{_journal_nz(_row_get(row, 'mood_issues'))}"
mood_score = _row_get(row, "mood_score")
if mood_score is not None:
mood_bits += f" | 自评心态分:{mood_score}"
lines.append(f" {mood_bits}")
if _journal_nz(_row_get(row, "post_breakeven_stare")) != "":
lines.append(f" 保本后盯盘:{_journal_nz(_row_get(row, 'post_breakeven_stare'))}")
if _journal_nz(_row_get(row, "new_trade_while_occupied")) != "":
lines.append(f" 占用时新开仓:{_journal_nz(_row_get(row, 'new_trade_while_occupied'))}")
if _journal_nz(_row_get(row, "note")) != "":
lines.append(f" 备注:{_journal_nz(_row_get(row, 'note'))}")
return "\n".join(lines) + "\n"
def collect_images_for_ai_review(
rows: Sequence,
upload_folder: str,
*,
build_chart_if_missing: Optional[Callable] = None,
) -> List[str]:
"""
收集传给视觉模型的本地图片路径
- 优先 journal_entries.image 已存附图
- 若无附图且提供 build_chart_if_missing则临时生成 K 线图
"""
paths: List[str] = []
seen = set()
upload_folder = os.path.abspath(upload_folder or "")
for row in rows or []:
candidate = None
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
img = row["image"] if "image" in keys else None
if img:
candidate = os.path.join(upload_folder, str(img).strip())
elif build_chart_if_missing:
try:
candidate = build_chart_if_missing(row)
except Exception:
candidate = None
if not candidate:
continue
candidate = os.path.abspath(candidate)
if os.path.isfile(candidate) and candidate not in seen:
seen.add(candidate)
paths.append(candidate)
return paths
def build_journal_ai_chart_path(
row,
upload_folder: str,
*,
order_chart_enabled: bool,
normalize_exchange_symbol_fn: Callable[[str], str],
generate_chart_fn: Callable,
local_datetime_to_ms_fn: Callable[[str], Optional[int]],
now_ts_ms_fn: Callable[[], int],
) -> Optional[str]:
"""无已存附图时,按复盘记录开平仓时间临时生成 K 线图路径。"""
if not order_chart_enabled:
return None
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
return None
coin = (row["coin"] if "coin" in keys else "") or ""
coin = str(coin).strip()
if not coin:
return None
try:
symbol = normalize_exchange_symbol_fn(coin)
except Exception:
return None
open_dt = row["open_datetime"] if "open_datetime" in keys else ""
close_dt = row["close_datetime"] if "close_datetime" in keys else ""
entry_ms = local_datetime_to_ms_fn(open_dt)
exit_ms = local_datetime_to_ms_fn(close_dt)
if not entry_ms:
return None
row_tf = row["tf"] if "tf" in keys else ""
tf1 = normalize_chart_timeframe(row_tf) or JOURNAL_CHART_DEFAULT_TF1
tf2 = JOURNAL_CHART_DEFAULT_TF2 if tf1 != JOURNAL_CHART_DEFAULT_TF2 else "1h"
row_id = str(row["id"] if "id" in keys else "")[:8] or uuid.uuid4().hex[:8]
marker = {
"entry_ts_ms": entry_ms,
"exit_ts_ms": exit_ms,
"chart_anchor": JOURNAL_CHART_ANCHOR_CLOSE,
"now_ts_ms": int(now_ts_ms_fn()),
}
fname = f"ai_rev_{row_id}_{uuid.uuid4().hex[:6]}.png"
saved = generate_chart_fn(
symbol,
f"AI复盘 {coin}",
timeframes=[tf1, tf2],
limit=JOURNAL_CHART_DEFAULT_LIMIT,
out_dir=upload_folder,
filename=fname,
marker_payload=marker,
marker_timeframes={tf1, tf2},
layout="vertical",
)
if not saved:
return None
path = os.path.join(upload_folder, saved)
return path if os.path.isfile(path) else None
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+36 -36
View File
@@ -1,36 +1,36 @@
"""中控调用实例 API 时的鉴权(Flask request 头 X-Hub-Token)。SSO 见 hub_sso.py。"""
from __future__ import annotations
import os
from hub_sso import (
HUB_SSO_TTL_SEC,
hub_bridge_token,
mint_hub_sso_token,
safe_next_path,
verify_hub_sso_token,
)
__all__ = [
"HUB_SSO_TTL_SEC",
"hub_bridge_token",
"mint_hub_sso_token",
"safe_next_path",
"verify_hub_sso_token",
"request_allowed",
]
def request_allowed(session_logged_in: bool, auth_disabled: bool) -> bool:
if auth_disabled or session_logged_in:
return True
tok = hub_bridge_token()
if not tok:
return False
try:
from flask import request
except ImportError:
return False
if request.headers.get("X-Hub-Token") == tok:
return True
return False
"""中控调用实例 API 时的鉴权(Flask request 头 X-Hub-Token)。SSO 见 hub_sso.py。"""
from __future__ import annotations
import os
from lib.hub.hub_sso import (
HUB_SSO_TTL_SEC,
hub_bridge_token,
mint_hub_sso_token,
safe_next_path,
verify_hub_sso_token,
)
__all__ = [
"HUB_SSO_TTL_SEC",
"hub_bridge_token",
"mint_hub_sso_token",
"safe_next_path",
"verify_hub_sso_token",
"request_allowed",
]
def request_allowed(session_logged_in: bool, auth_disabled: bool) -> bool:
if auth_disabled or session_logged_in:
return True
tok = hub_bridge_token()
if not tok:
return False
try:
from flask import request
except ImportError:
return False
if request.headers.get("X-Hub-Token") == tok:
return True
return False
File diff suppressed because it is too large Load Diff
@@ -1,498 +1,498 @@
"""中控历史测算:趋势回调 / 滚仓,以损定仓(按交易所精度与张数规则)。"""
from __future__ import annotations
from typing import Any, Callable, Optional, Tuple
from strategy_roll_lib import max_roll_legs
from strategy_trend_lib import (
build_trend_preview_level_rows,
calc_risk_fraction,
compute_trend_plan_core,
validate_trend_bounds,
)
DEFAULT_DCA_LEGS = 5
MARGIN_BUFFER = 0.95
def _resolve_market(
exchange_id: str,
base: str,
) -> Tuple[Optional[dict[str, Any]], Optional[Callable[[float], Optional[float]]], Optional[str]]:
from hub_calculator_market_lib import get_calculator_market, make_amount_precise_fn_from_market
market, err = get_calculator_market(exchange_id, base)
if err or not market:
return None, None, err or "无法解析合约"
amount_precise = make_amount_precise_fn_from_market(market)
return market, amount_precise, None
def calc_trend_calculator(
*,
direction: str,
capital_usdt: float,
risk_percent: float,
leverage: int,
entry_price: float,
stop_loss: float,
add_upper: float,
take_profit: float,
dca_legs: int = DEFAULT_DCA_LEGS,
exchange_id: str = "0",
base: str = "ETH",
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
market, amount_precise, merr = _resolve_market(exchange_id, base)
if merr or not market or not amount_precise:
return None, merr or "无法解析合约"
contract_size = float(market.get("contract_size") or 1.0)
exchange_symbol = market["exchange_symbol"]
direction = (direction or "long").strip().lower()
if direction not in ("long", "short"):
return None, "方向须为 long 或 short"
try:
capital = float(capital_usdt)
rp = float(risk_percent)
lev = int(leverage)
entry = float(entry_price)
sl = float(stop_loss)
upper = float(add_upper)
tp = float(take_profit)
legs = max(1, int(dca_legs))
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if capital <= 0 or rp <= 0 or lev <= 0 or entry <= 0 or sl <= 0 or upper <= 0 or tp <= 0:
return None, "资金、风险、杠杆与价格须大于 0"
bound_err = validate_trend_bounds(direction, sl, upper)
if bound_err:
return None, bound_err
rf = calc_risk_fraction(direction, upper, sl)
if rf is None or rf <= 0:
return None, "止损与补仓区间边界组合无法计算风险比例"
risk_budget = capital * (rp / 100.0)
notional = risk_budget / rf
margin_plan = min(notional / float(lev), capital * MARGIN_BUFFER)
if margin_plan <= 0:
return None, "计划保证金过小"
target_amt = _amount_from_margin(margin_plan, lev, entry, cs)
if target_amt is None or target_amt <= 0:
return None, "无法计算计划张数,请检查入场价与杠杆"
target_amt = amount_precise(target_amt)
if target_amt is None or target_amt <= 0:
return None, "计划张数低于交易所最小精度"
def _amount_precise(_symbol: str, amount: float) -> Optional[float]:
return amount_precise(amount)
payload, err = compute_trend_plan_core(
direction=direction,
stop_loss=sl,
add_upper=upper,
risk_percent=rp,
snapshot_usdt=capital,
leverage=lev,
live_price=entry,
target_order_amount=target_amt,
exchange_symbol=exchange_symbol,
dca_legs=legs,
amount_precise=_amount_precise,
min_amount=float(market.get("min_amount") or 0.0),
full_margin_buffer_ratio=MARGIN_BUFFER,
)
if err:
return None, err
payload["take_profit"] = tp
payload["leverage"] = lev
payload["contract_size"] = cs
preview, rows = build_trend_preview_level_rows(payload)
px_dec = int(market.get("price_decimals") or 4)
amt_dec = int(market.get("amount_decimals") or 4)
def _f(v: Any, nd: int | None = None) -> Any:
if v is None:
return None
try:
return round(float(v), nd if nd is not None else 8)
except (TypeError, ValueError):
return v
table = []
for row in rows:
table.append(
{
"label": row.get("label"),
"price": _f(row.get("price"), px_dec),
"contracts": _f(row.get("contracts"), amt_dec),
"avg_entry": _f(row.get("avg_entry"), px_dec),
"profit_u": _f(row.get("profit_u")),
"risk_u": _f(row.get("risk_u")),
"rr": _f(row.get("rr"), 4),
}
)
return {
"direction": direction,
"capital_usdt": _f(capital),
"risk_percent": _f(rp, 2),
"risk_budget_u": _f(preview.get("preview_risk_amount_u")),
"leverage": lev,
"entry_price": _f(entry, px_dec),
"stop_loss": _f(sl, px_dec),
"add_upper": _f(upper, px_dec),
"take_profit": _f(tp, px_dec),
"plan_margin_u": _f(preview.get("plan_margin_capital")),
"target_contracts": _f(preview.get("target_order_amount"), amt_dec),
"first_contracts": _f(preview.get("first_order_amount"), amt_dec),
"dca_legs": int(preview.get("dca_legs") or legs),
"first_profit_u": _f(preview.get("preview_first_profit_u")),
"first_rr": _f(preview.get("preview_target_rr"), 4),
"market": market,
"rows": table,
}, None
def _amount_from_margin(
margin_capital: float,
leverage: int,
price: float,
contract_size: float,
) -> Optional[float]:
try:
margin = float(margin_capital)
lev = int(leverage)
px = float(price)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None
if margin <= 0 or lev <= 0 or px <= 0 or cs <= 0:
return None
notional = margin * lev
return notional / (px * cs)
def _round(v: Any, nd: int = 4) -> Any:
if v is None:
return None
try:
return round(float(v), nd)
except (TypeError, ValueError):
return v
def _money_rr(profit_u: Optional[float], risk_u: Optional[float]) -> Optional[float]:
try:
if risk_u is None or float(risk_u) <= 0 or profit_u is None:
return None
return round(float(profit_u) / float(risk_u), 4)
except (TypeError, ValueError):
return None
def calc_initial_roll_qty(
direction: str,
entry_price: float,
stop_loss: float,
risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]:
"""首仓以损定仓:打到初始止损亏损 = 风险预算。"""
try:
entry = float(entry_price)
sl = float(stop_loss)
budget = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if entry <= 0 or sl <= 0 or budget <= 0 or cs <= 0:
return None, "入场价、止损与风险预算须大于 0"
direction = (direction or "long").strip().lower()
if direction == "short":
per_unit = (sl - entry) * cs
if per_unit <= 0:
return None, "做空:止损价须高于首仓入场价"
else:
per_unit = (entry - sl) * cs
if per_unit <= 0:
return None, "做多:止损价须低于首仓入场价"
return budget / per_unit, None
def solve_add_amount_for_total_risk(
direction: str,
qty_existing: float,
entry_existing: float,
add_price: float,
new_stop: float,
risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]:
"""合并持仓打到新止损总亏损 = 风险预算,反推本次加仓张数。"""
try:
q1 = float(qty_existing)
e1 = float(entry_existing)
e2 = float(add_price)
sl = float(new_stop)
b = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0:
return None, "持仓或风险预算无效"
direction = (direction or "long").strip().lower()
if direction == "short":
denom = sl - e2
numer = b / cs - q1 * (sl - e1)
if denom <= 0:
return None, "做空:新止损须高于限价加仓价"
else:
denom = e2 - sl
numer = b / cs - q1 * (e1 - sl)
if denom <= 0:
return None, "做多:新止损须低于限价/市价加仓价"
q2 = numer / denom
if q2 <= 0:
return None, "按当前新止损与总风险%,无需加仓或无法再加(已满足风险上限)"
return q2, None
def _roll_leg_preview(
*,
direction: str,
qty_existing: float,
entry_existing: float,
take_profit: float,
add_price: float,
new_stop_loss: float,
risk_budget: float,
contract_size: float,
amount_precise: Callable[[float], Optional[float]],
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower()
try:
tp = float(take_profit)
sl = float(new_stop_loss)
entry_add = float(add_price)
e1 = float(entry_existing)
except (TypeError, ValueError):
return None, "止损/止盈格式错误"
if sl <= 0 or tp <= 0 or entry_add <= 0:
return None, "止损与首仓止盈须大于0"
if direction == "long":
if sl >= entry_add:
return None, "做多:新止损须低于加仓价"
if tp <= e1:
return None, "做多:首仓止盈须高于当前持仓均价参考"
else:
if sl <= entry_add:
return None, "做空:新止损须高于加仓价"
if tp >= e1:
return None, "做空:首仓止盈须低于当前持仓均价参考"
q2_raw, err = solve_add_amount_for_total_risk(
direction,
qty_existing,
entry_existing,
entry_add,
sl,
risk_budget,
contract_size,
)
if err:
return None, err
q2 = amount_precise(float(q2_raw))
if q2 is None or q2 <= 0:
return None, "加仓张数低于交易所最小精度"
new_qty = float(qty_existing) + float(q2)
new_avg = (float(qty_existing) * float(entry_existing) + float(q2) * entry_add) / new_qty
cs = float(contract_size) if contract_size else 1.0
if direction == "long":
loss_at_sl = (new_avg - sl) * new_qty * cs
reward_at_tp = (tp - new_avg) * new_qty * cs
else:
loss_at_sl = (sl - new_avg) * new_qty * cs
reward_at_tp = (new_avg - tp) * new_qty * cs
return {
"add_amount_raw": q2,
"qty_after": new_qty,
"avg_entry_after": new_avg,
"add_price": entry_add,
"new_stop_loss": sl,
"loss_at_sl_usdt": loss_at_sl,
"reward_at_tp_usdt": reward_at_tp,
}, None
def calc_roll_calculator(
*,
direction: str,
capital_usdt: float,
risk_percent: float,
entry_price: float,
stop_loss: float,
take_profit: float,
add_legs: list[dict[str, float]] | None = None,
legs_done: int = 0,
exchange_id: str = "0",
base: str = "ETH",
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""
滚仓历史测算首仓自动以损定仓止盈锁定首仓价最多 3 次滚仓加仓
add_legs: [{add_price, new_stop_loss}, ...]按顺序链式计算
legs_done: 已完成滚仓次数仅标记仍参与链式状态推进
"""
market, amount_precise, merr = _resolve_market(exchange_id, base)
if merr or not market or not amount_precise:
return None, merr or "无法解析合约"
contract_size = float(market.get("contract_size") or 1.0)
px_dec = int(market.get("price_decimals") or 4)
amt_dec = int(market.get("amount_decimals") or 4)
direction = (direction or "long").strip().lower()
if direction not in ("long", "short"):
return None, "方向须为 long 或 short"
try:
capital = float(capital_usdt)
rp = float(risk_percent)
entry = float(entry_price)
initial_sl = float(stop_loss)
tp = float(take_profit)
done = max(0, int(legs_done))
except (TypeError, ValueError):
return None, "参数格式错误"
if capital <= 0 or rp <= 0 or entry <= 0 or initial_sl <= 0 or tp <= 0:
return None, "资金、风险与价格须大于 0"
if done > max_roll_legs(direction):
return None, f"已完成滚仓次数不能超过 {max_roll_legs(direction)}"
legs_in: list[dict[str, float]] = []
for raw in add_legs or []:
if not isinstance(raw, dict):
continue
try:
ap = float(raw.get("add_price"))
nsl = float(raw.get("new_stop_loss"))
except (TypeError, ValueError):
return None, "加仓价与新止损须为有效数字"
if ap <= 0 or nsl <= 0:
return None, "加仓价与新止损须大于 0"
legs_in.append({"add_price": ap, "new_stop_loss": nsl})
if done + len(legs_in) > max_roll_legs(direction):
return None, f"已完成 {done} 次 + 待测算 {len(legs_in)} 次,合计不能超过 {max_roll_legs(direction)} 次滚仓"
if direction == "long":
if tp <= entry:
return None, "做多:止盈价须高于首仓入场价"
else:
if tp >= entry:
return None, "做空:止盈价须低于首仓入场价"
risk_budget = capital * (rp / 100.0)
qty, err = calc_initial_roll_qty(direction, entry, initial_sl, risk_budget, contract_size)
if err:
return None, err
if qty is None or qty <= 0:
return None, "无法计算首仓张数"
qty_p = amount_precise(float(qty))
if qty_p is None or qty_p <= 0:
return None, "首仓张数低于交易所最小精度"
qty_f = float(qty_p)
avg = entry
rows: list[dict[str, Any]] = []
cs = contract_size
if direction == "long":
first_loss = (avg - initial_sl) * qty_f * cs
first_profit = (tp - avg) * qty_f * cs
else:
first_loss = (initial_sl - avg) * qty_f * cs
first_profit = (avg - tp) * qty_f * cs
rows.append(
{
"label": "首仓",
"leg_index": 0,
"already_done": False,
"entry_or_add_price": _round(entry, px_dec),
"stop_loss": _round(initial_sl, px_dec),
"add_contracts": _round(qty_f, amt_dec),
"total_contracts": _round(qty_f, amt_dec),
"avg_entry": _round(avg, px_dec),
"take_profit": _round(tp, px_dec),
"loss_at_sl_u": _round(first_loss),
"profit_at_tp_u": _round(first_profit),
"rr": _money_rr(first_profit, first_loss),
}
)
current_qty = qty_f
current_avg = avg
for i, leg in enumerate(legs_in):
leg_no = i + 1
preview, err = _roll_leg_preview(
direction=direction,
qty_existing=current_qty,
entry_existing=current_avg,
take_profit=tp,
add_price=leg["add_price"],
new_stop_loss=leg["new_stop_loss"],
risk_budget=risk_budget,
contract_size=cs,
amount_precise=amount_precise,
)
if err:
return None, f"滚仓第 {leg_no} 次:{err}"
if not preview:
return None, f"滚仓第 {leg_no} 次计算失败"
current_qty = float(preview["qty_after"])
current_avg = float(preview["avg_entry_after"])
loss = preview.get("loss_at_sl_usdt")
reward = preview.get("reward_at_tp_usdt")
rows.append(
{
"label": f"滚仓{leg_no}",
"leg_index": leg_no,
"already_done": leg_no <= done,
"entry_or_add_price": _round(preview.get("add_price"), px_dec),
"stop_loss": _round(preview.get("new_stop_loss"), px_dec),
"add_contracts": _round(preview.get("add_amount_raw"), amt_dec),
"total_contracts": _round(current_qty, amt_dec),
"avg_entry": _round(current_avg, px_dec),
"take_profit": _round(tp, px_dec),
"loss_at_sl_u": _round(loss),
"profit_at_tp_u": _round(reward),
"rr": _money_rr(reward, loss),
}
)
last = rows[-1]
return {
"direction": direction,
"capital_usdt": _round(capital),
"risk_percent": _round(rp, 2),
"risk_budget_u": _round(risk_budget),
"entry_price": _round(entry, px_dec),
"stop_loss": _round(initial_sl, px_dec),
"take_profit": _round(tp, px_dec),
"legs_done": done,
"roll_legs_planned": len(legs_in),
"first_contracts": _round(qty_f, amt_dec),
"final_contracts": last.get("total_contracts"),
"final_avg_entry": last.get("avg_entry"),
"final_loss_at_sl_u": last.get("loss_at_sl_u"),
"final_profit_at_tp_u": last.get("profit_at_tp_u"),
"final_rr": last.get("rr"),
"market": market,
"rows": rows,
}, None
"""中控历史测算:趋势回调 / 滚仓,以损定仓(按交易所精度与张数规则)。"""
from __future__ import annotations
from typing import Any, Callable, Optional, Tuple
from lib.strategy.strategy_roll_lib import max_roll_legs
from lib.strategy.strategy_trend_lib import (
build_trend_preview_level_rows,
calc_risk_fraction,
compute_trend_plan_core,
validate_trend_bounds,
)
DEFAULT_DCA_LEGS = 5
MARGIN_BUFFER = 0.95
def _resolve_market(
exchange_id: str,
base: str,
) -> Tuple[Optional[dict[str, Any]], Optional[Callable[[float], Optional[float]]], Optional[str]]:
from lib.hub.hub_calculator_market_lib import get_calculator_market, make_amount_precise_fn_from_market
market, err = get_calculator_market(exchange_id, base)
if err or not market:
return None, None, err or "无法解析合约"
amount_precise = make_amount_precise_fn_from_market(market)
return market, amount_precise, None
def calc_trend_calculator(
*,
direction: str,
capital_usdt: float,
risk_percent: float,
leverage: int,
entry_price: float,
stop_loss: float,
add_upper: float,
take_profit: float,
dca_legs: int = DEFAULT_DCA_LEGS,
exchange_id: str = "0",
base: str = "ETH",
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
market, amount_precise, merr = _resolve_market(exchange_id, base)
if merr or not market or not amount_precise:
return None, merr or "无法解析合约"
contract_size = float(market.get("contract_size") or 1.0)
exchange_symbol = market["exchange_symbol"]
direction = (direction or "long").strip().lower()
if direction not in ("long", "short"):
return None, "方向须为 long 或 short"
try:
capital = float(capital_usdt)
rp = float(risk_percent)
lev = int(leverage)
entry = float(entry_price)
sl = float(stop_loss)
upper = float(add_upper)
tp = float(take_profit)
legs = max(1, int(dca_legs))
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if capital <= 0 or rp <= 0 or lev <= 0 or entry <= 0 or sl <= 0 or upper <= 0 or tp <= 0:
return None, "资金、风险、杠杆与价格须大于 0"
bound_err = validate_trend_bounds(direction, sl, upper)
if bound_err:
return None, bound_err
rf = calc_risk_fraction(direction, upper, sl)
if rf is None or rf <= 0:
return None, "止损与补仓区间边界组合无法计算风险比例"
risk_budget = capital * (rp / 100.0)
notional = risk_budget / rf
margin_plan = min(notional / float(lev), capital * MARGIN_BUFFER)
if margin_plan <= 0:
return None, "计划保证金过小"
target_amt = _amount_from_margin(margin_plan, lev, entry, cs)
if target_amt is None or target_amt <= 0:
return None, "无法计算计划张数,请检查入场价与杠杆"
target_amt = amount_precise(target_amt)
if target_amt is None or target_amt <= 0:
return None, "计划张数低于交易所最小精度"
def _amount_precise(_symbol: str, amount: float) -> Optional[float]:
return amount_precise(amount)
payload, err = compute_trend_plan_core(
direction=direction,
stop_loss=sl,
add_upper=upper,
risk_percent=rp,
snapshot_usdt=capital,
leverage=lev,
live_price=entry,
target_order_amount=target_amt,
exchange_symbol=exchange_symbol,
dca_legs=legs,
amount_precise=_amount_precise,
min_amount=float(market.get("min_amount") or 0.0),
full_margin_buffer_ratio=MARGIN_BUFFER,
)
if err:
return None, err
payload["take_profit"] = tp
payload["leverage"] = lev
payload["contract_size"] = cs
preview, rows = build_trend_preview_level_rows(payload)
px_dec = int(market.get("price_decimals") or 4)
amt_dec = int(market.get("amount_decimals") or 4)
def _f(v: Any, nd: int | None = None) -> Any:
if v is None:
return None
try:
return round(float(v), nd if nd is not None else 8)
except (TypeError, ValueError):
return v
table = []
for row in rows:
table.append(
{
"label": row.get("label"),
"price": _f(row.get("price"), px_dec),
"contracts": _f(row.get("contracts"), amt_dec),
"avg_entry": _f(row.get("avg_entry"), px_dec),
"profit_u": _f(row.get("profit_u")),
"risk_u": _f(row.get("risk_u")),
"rr": _f(row.get("rr"), 4),
}
)
return {
"direction": direction,
"capital_usdt": _f(capital),
"risk_percent": _f(rp, 2),
"risk_budget_u": _f(preview.get("preview_risk_amount_u")),
"leverage": lev,
"entry_price": _f(entry, px_dec),
"stop_loss": _f(sl, px_dec),
"add_upper": _f(upper, px_dec),
"take_profit": _f(tp, px_dec),
"plan_margin_u": _f(preview.get("plan_margin_capital")),
"target_contracts": _f(preview.get("target_order_amount"), amt_dec),
"first_contracts": _f(preview.get("first_order_amount"), amt_dec),
"dca_legs": int(preview.get("dca_legs") or legs),
"first_profit_u": _f(preview.get("preview_first_profit_u")),
"first_rr": _f(preview.get("preview_target_rr"), 4),
"market": market,
"rows": table,
}, None
def _amount_from_margin(
margin_capital: float,
leverage: int,
price: float,
contract_size: float,
) -> Optional[float]:
try:
margin = float(margin_capital)
lev = int(leverage)
px = float(price)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None
if margin <= 0 or lev <= 0 or px <= 0 or cs <= 0:
return None
notional = margin * lev
return notional / (px * cs)
def _round(v: Any, nd: int = 4) -> Any:
if v is None:
return None
try:
return round(float(v), nd)
except (TypeError, ValueError):
return v
def _money_rr(profit_u: Optional[float], risk_u: Optional[float]) -> Optional[float]:
try:
if risk_u is None or float(risk_u) <= 0 or profit_u is None:
return None
return round(float(profit_u) / float(risk_u), 4)
except (TypeError, ValueError):
return None
def calc_initial_roll_qty(
direction: str,
entry_price: float,
stop_loss: float,
risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]:
"""首仓以损定仓:打到初始止损亏损 = 风险预算。"""
try:
entry = float(entry_price)
sl = float(stop_loss)
budget = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if entry <= 0 or sl <= 0 or budget <= 0 or cs <= 0:
return None, "入场价、止损与风险预算须大于 0"
direction = (direction or "long").strip().lower()
if direction == "short":
per_unit = (sl - entry) * cs
if per_unit <= 0:
return None, "做空:止损价须高于首仓入场价"
else:
per_unit = (entry - sl) * cs
if per_unit <= 0:
return None, "做多:止损价须低于首仓入场价"
return budget / per_unit, None
def solve_add_amount_for_total_risk(
direction: str,
qty_existing: float,
entry_existing: float,
add_price: float,
new_stop: float,
risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]:
"""合并持仓打到新止损总亏损 = 风险预算,反推本次加仓张数。"""
try:
q1 = float(qty_existing)
e1 = float(entry_existing)
e2 = float(add_price)
sl = float(new_stop)
b = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0:
return None, "持仓或风险预算无效"
direction = (direction or "long").strip().lower()
if direction == "short":
denom = sl - e2
numer = b / cs - q1 * (sl - e1)
if denom <= 0:
return None, "做空:新止损须高于限价加仓价"
else:
denom = e2 - sl
numer = b / cs - q1 * (e1 - sl)
if denom <= 0:
return None, "做多:新止损须低于限价/市价加仓价"
q2 = numer / denom
if q2 <= 0:
return None, "按当前新止损与总风险%,无需加仓或无法再加(已满足风险上限)"
return q2, None
def _roll_leg_preview(
*,
direction: str,
qty_existing: float,
entry_existing: float,
take_profit: float,
add_price: float,
new_stop_loss: float,
risk_budget: float,
contract_size: float,
amount_precise: Callable[[float], Optional[float]],
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower()
try:
tp = float(take_profit)
sl = float(new_stop_loss)
entry_add = float(add_price)
e1 = float(entry_existing)
except (TypeError, ValueError):
return None, "止损/止盈格式错误"
if sl <= 0 or tp <= 0 or entry_add <= 0:
return None, "止损与首仓止盈须大于0"
if direction == "long":
if sl >= entry_add:
return None, "做多:新止损须低于加仓价"
if tp <= e1:
return None, "做多:首仓止盈须高于当前持仓均价参考"
else:
if sl <= entry_add:
return None, "做空:新止损须高于加仓价"
if tp >= e1:
return None, "做空:首仓止盈须低于当前持仓均价参考"
q2_raw, err = solve_add_amount_for_total_risk(
direction,
qty_existing,
entry_existing,
entry_add,
sl,
risk_budget,
contract_size,
)
if err:
return None, err
q2 = amount_precise(float(q2_raw))
if q2 is None or q2 <= 0:
return None, "加仓张数低于交易所最小精度"
new_qty = float(qty_existing) + float(q2)
new_avg = (float(qty_existing) * float(entry_existing) + float(q2) * entry_add) / new_qty
cs = float(contract_size) if contract_size else 1.0
if direction == "long":
loss_at_sl = (new_avg - sl) * new_qty * cs
reward_at_tp = (tp - new_avg) * new_qty * cs
else:
loss_at_sl = (sl - new_avg) * new_qty * cs
reward_at_tp = (new_avg - tp) * new_qty * cs
return {
"add_amount_raw": q2,
"qty_after": new_qty,
"avg_entry_after": new_avg,
"add_price": entry_add,
"new_stop_loss": sl,
"loss_at_sl_usdt": loss_at_sl,
"reward_at_tp_usdt": reward_at_tp,
}, None
def calc_roll_calculator(
*,
direction: str,
capital_usdt: float,
risk_percent: float,
entry_price: float,
stop_loss: float,
take_profit: float,
add_legs: list[dict[str, float]] | None = None,
legs_done: int = 0,
exchange_id: str = "0",
base: str = "ETH",
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""
滚仓历史测算首仓自动以损定仓止盈锁定首仓价最多 3 次滚仓加仓
add_legs: [{add_price, new_stop_loss}, ...]按顺序链式计算
legs_done: 已完成滚仓次数仅标记仍参与链式状态推进
"""
market, amount_precise, merr = _resolve_market(exchange_id, base)
if merr or not market or not amount_precise:
return None, merr or "无法解析合约"
contract_size = float(market.get("contract_size") or 1.0)
px_dec = int(market.get("price_decimals") or 4)
amt_dec = int(market.get("amount_decimals") or 4)
direction = (direction or "long").strip().lower()
if direction not in ("long", "short"):
return None, "方向须为 long 或 short"
try:
capital = float(capital_usdt)
rp = float(risk_percent)
entry = float(entry_price)
initial_sl = float(stop_loss)
tp = float(take_profit)
done = max(0, int(legs_done))
except (TypeError, ValueError):
return None, "参数格式错误"
if capital <= 0 or rp <= 0 or entry <= 0 or initial_sl <= 0 or tp <= 0:
return None, "资金、风险与价格须大于 0"
if done > max_roll_legs(direction):
return None, f"已完成滚仓次数不能超过 {max_roll_legs(direction)}"
legs_in: list[dict[str, float]] = []
for raw in add_legs or []:
if not isinstance(raw, dict):
continue
try:
ap = float(raw.get("add_price"))
nsl = float(raw.get("new_stop_loss"))
except (TypeError, ValueError):
return None, "加仓价与新止损须为有效数字"
if ap <= 0 or nsl <= 0:
return None, "加仓价与新止损须大于 0"
legs_in.append({"add_price": ap, "new_stop_loss": nsl})
if done + len(legs_in) > max_roll_legs(direction):
return None, f"已完成 {done} 次 + 待测算 {len(legs_in)} 次,合计不能超过 {max_roll_legs(direction)} 次滚仓"
if direction == "long":
if tp <= entry:
return None, "做多:止盈价须高于首仓入场价"
else:
if tp >= entry:
return None, "做空:止盈价须低于首仓入场价"
risk_budget = capital * (rp / 100.0)
qty, err = calc_initial_roll_qty(direction, entry, initial_sl, risk_budget, contract_size)
if err:
return None, err
if qty is None or qty <= 0:
return None, "无法计算首仓张数"
qty_p = amount_precise(float(qty))
if qty_p is None or qty_p <= 0:
return None, "首仓张数低于交易所最小精度"
qty_f = float(qty_p)
avg = entry
rows: list[dict[str, Any]] = []
cs = contract_size
if direction == "long":
first_loss = (avg - initial_sl) * qty_f * cs
first_profit = (tp - avg) * qty_f * cs
else:
first_loss = (initial_sl - avg) * qty_f * cs
first_profit = (avg - tp) * qty_f * cs
rows.append(
{
"label": "首仓",
"leg_index": 0,
"already_done": False,
"entry_or_add_price": _round(entry, px_dec),
"stop_loss": _round(initial_sl, px_dec),
"add_contracts": _round(qty_f, amt_dec),
"total_contracts": _round(qty_f, amt_dec),
"avg_entry": _round(avg, px_dec),
"take_profit": _round(tp, px_dec),
"loss_at_sl_u": _round(first_loss),
"profit_at_tp_u": _round(first_profit),
"rr": _money_rr(first_profit, first_loss),
}
)
current_qty = qty_f
current_avg = avg
for i, leg in enumerate(legs_in):
leg_no = i + 1
preview, err = _roll_leg_preview(
direction=direction,
qty_existing=current_qty,
entry_existing=current_avg,
take_profit=tp,
add_price=leg["add_price"],
new_stop_loss=leg["new_stop_loss"],
risk_budget=risk_budget,
contract_size=cs,
amount_precise=amount_precise,
)
if err:
return None, f"滚仓第 {leg_no} 次:{err}"
if not preview:
return None, f"滚仓第 {leg_no} 次计算失败"
current_qty = float(preview["qty_after"])
current_avg = float(preview["avg_entry_after"])
loss = preview.get("loss_at_sl_usdt")
reward = preview.get("reward_at_tp_usdt")
rows.append(
{
"label": f"滚仓{leg_no}",
"leg_index": leg_no,
"already_done": leg_no <= done,
"entry_or_add_price": _round(preview.get("add_price"), px_dec),
"stop_loss": _round(preview.get("new_stop_loss"), px_dec),
"add_contracts": _round(preview.get("add_amount_raw"), amt_dec),
"total_contracts": _round(current_qty, amt_dec),
"avg_entry": _round(current_avg, px_dec),
"take_profit": _round(tp, px_dec),
"loss_at_sl_u": _round(loss),
"profit_at_tp_u": _round(reward),
"rr": _money_rr(reward, loss),
}
)
last = rows[-1]
return {
"direction": direction,
"capital_usdt": _round(capital),
"risk_percent": _round(rp, 2),
"risk_budget_u": _round(risk_budget),
"entry_price": _round(entry, px_dec),
"stop_loss": _round(initial_sl, px_dec),
"take_profit": _round(tp, px_dec),
"legs_done": done,
"roll_legs_planned": len(legs_in),
"first_contracts": _round(qty_f, amt_dec),
"final_contracts": last.get("total_contracts"),
"final_avg_entry": last.get("avg_entry"),
"final_loss_at_sl_u": last.get("loss_at_sl_u"),
"final_profit_at_tp_u": last.get("profit_at_tp_u"),
"final_rr": last.get("rr"),
"market": market,
"rows": rows,
}, None
@@ -1,257 +1,257 @@
"""计算器:从已配置交易实例读取 USDT 永续合约精度与张数规则。"""
from __future__ import annotations
import json
import threading
import time
import urllib.error
import urllib.request
from typing import Any, Callable, Optional, Tuple
from urllib.parse import urlencode
try:
from settings_store import enabled_exchanges, load_settings
except ImportError:
from manual_trading_hub.settings_store import enabled_exchanges, load_settings
MARKET_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
MARKET_LOCK = threading.Lock()
MARKET_TTL_SEC = 300.0
HUB_FLASK_TIMEOUT = float(__import__("os").getenv("HUB_FLASK_TIMEOUT", "20"))
def normalize_base_symbol(text: str) -> str:
s = str(text or "").upper().strip()
for suf in ("USDT:USDT", "/USDT:USDT", "/USDT", "USDT", "-USDT-SWAP"):
if s.endswith(suf) and len(s) > len(suf):
s = s[: -len(suf)].strip("-/")
break
if "/" in s:
s = s.split("/", 1)[0].strip()
if ":" in s:
s = s.split(":", 1)[0].strip()
return s
def resolve_usdt_perp_symbol(exchange: Any, base: str) -> Tuple[Optional[str], Optional[str]]:
base_u = normalize_base_symbol(base)
if not base_u:
return None, "请输入币种,如 ETH"
candidates = [f"{base_u}/USDT:USDT", f"{base_u}/USDT"]
markets = getattr(exchange, "markets", None) or {}
for sym in candidates:
m = markets.get(sym)
if not m:
continue
if m.get("active") is False:
continue
if m.get("swap") or m.get("linear") or m.get("contract"):
return sym, None
for sym, m in markets.items():
if m.get("active") is False:
continue
if not (m.get("swap") or m.get("linear")):
continue
if (m.get("quote") or "").upper() != "USDT":
continue
if (m.get("base") or "").upper() == base_u:
return sym, None
return None, f"未找到 {base_u}/USDT 永续合约"
def _decimals_from_precision_value(value: Any) -> Optional[int]:
if value in (None, ""):
return None
try:
p = float(value)
except (TypeError, ValueError):
return None
if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12:
return int(round(p))
if 0 < p < 1:
s = f"{p:.12f}".rstrip("0")
if "." in s:
return min(12, len(s.split(".", 1)[1]))
return None
def _decimals_from_ccxt_str(text: str) -> int:
s = str(text or "").strip()
if not s or "." not in s:
return 0
frac = s.split(".", 1)[1]
if not frac:
return 0
return min(12, len(frac.rstrip("0") or frac))
def amount_decimals_from_exchange(exchange: Any, exchange_symbol: str) -> int:
try:
return _decimals_from_ccxt_str(exchange.amount_to_precision(exchange_symbol, 1.23456789))
except Exception:
market = exchange.market(exchange_symbol)
prec = (market.get("precision") or {}).get("amount")
d = _decimals_from_precision_value(prec)
return d if d is not None else 4
def price_decimals_from_exchange(
exchange: Any, exchange_symbol: str, price_tick: Optional[float]
) -> int:
from hub_ohlcv_lib import normalize_price_tick
tick = normalize_price_tick(price_tick)
if tick and tick > 0:
if tick >= 1:
return 0
s = f"{tick:.12f}".rstrip("0")
if "." in s:
return min(12, len(s.split(".", 1)[1]))
try:
return _decimals_from_ccxt_str(exchange.price_to_precision(exchange_symbol, 12345.678901234))
except Exception:
market = exchange.market(exchange_symbol)
prec = (market.get("precision") or {}).get("price")
d = _decimals_from_precision_value(prec)
return d if d is not None else 4
def make_amount_precise_fn_from_market(market: dict[str, Any]) -> Callable[[float], Optional[float]]:
dec = max(0, int(market.get("amount_decimals") or 4))
min_amt = market.get("min_amount")
def _fn(amount: float) -> Optional[float]:
try:
v = float(amount)
except (TypeError, ValueError):
return None
if v <= 0:
return None
factor = 10**dec
v = int(v * factor + 1e-12) / factor
if min_amt is not None:
try:
if v < float(min_amt):
return None
except (TypeError, ValueError):
pass
if v <= 0:
return None
return v
return _fn
def find_exchange(exchange_id: str) -> dict | None:
needle = str(exchange_id or "").strip()
if not needle:
return None
for ex in load_settings().get("exchanges") or []:
if str(ex.get("id") or "").strip() == needle:
return ex
if str(ex.get("key") or "").strip().lower() == needle.lower():
return ex
return None
def list_calculator_exchanges() -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for ex in enabled_exchanges():
rows.append(
{
"id": str(ex.get("id") or ""),
"key": str(ex.get("key") or ""),
"name": str(ex.get("name") or ex.get("key") or ""),
"enabled": bool(ex.get("enabled")),
}
)
return rows
def _hub_headers() -> dict[str, str]:
import os
token = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
if token:
return {"X-Hub-Token": token}
return {}
def fetch_instance_market_sync(ex: dict, *, base: str) -> dict[str, Any]:
base_url = (ex.get("flask_url") or "").rstrip("/")
if not base_url:
return {"ok": False, "msg": "未配置 flask_url"}
params = urlencode({"base": normalize_base_symbol(base) or base})
url = f"{base_url}/api/hub/market?{params}"
req = urllib.request.Request(url, headers=_hub_headers(), method="GET")
try:
with urllib.request.urlopen(req, timeout=HUB_FLASK_TIMEOUT) as resp:
status = int(getattr(resp, "status", 200) or 200)
raw = resp.read().decode("utf-8", errors="replace")
data = json.loads(raw) if raw else {}
if not isinstance(data, dict):
return {"ok": False, "msg": "无效 JSON"}
if status >= 400:
data.setdefault("ok", False)
return data
except urllib.error.HTTPError as exc:
try:
raw = exc.read().decode("utf-8", errors="replace")
body = json.loads(raw) if raw else {}
except Exception:
body = {"ok": False, "msg": raw if "raw" in locals() else str(exc)}
if isinstance(body, dict):
body.setdefault("ok", False)
return body
return {"ok": False, "msg": f"HTTP {exc.code}"}
except Exception as exc:
return {"ok": False, "msg": str(exc)}
def _enrich_market_from_settings(ex: dict, payload: dict[str, Any]) -> dict[str, Any]:
out = dict(payload)
out["exchange_id"] = str(ex.get("id") or "")
out["exchange_key"] = str(ex.get("key") or "")
out["exchange_name"] = str(ex.get("name") or ex.get("key") or "")
out["exchange_label"] = out["exchange_name"]
return out
def get_calculator_market(
exchange_id: str,
base: str,
*,
ex: dict | None = None,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""从系统设置中的交易实例拉取合约精度(与实盘一致)。"""
row = ex or find_exchange(exchange_id)
if not row:
return None, "未找到该交易所配置"
if not row.get("enabled"):
return None, f"{row.get('name') or exchange_id} 未启用"
base_u = normalize_base_symbol(base)
if not base_u:
return None, "请输入币种,如 ETH"
cache_key = f"{row.get('id')}:{base_u}"
now = time.time()
with MARKET_LOCK:
cached = MARKET_CACHE.get(cache_key)
if cached and now - cached[0] < MARKET_TTL_SEC:
return dict(cached[1]), None
remote = fetch_instance_market_sync(row, base=base_u)
if not remote.get("ok"):
return None, str(remote.get("msg") or "实例返回失败")
data = _enrich_market_from_settings(row, remote)
with MARKET_LOCK:
MARKET_CACHE[cache_key] = (now, data)
return data, None
def clear_market_cache() -> None:
with MARKET_LOCK:
MARKET_CACHE.clear()
"""计算器:从已配置交易实例读取 USDT 永续合约精度与张数规则。"""
from __future__ import annotations
import json
import threading
import time
import urllib.error
import urllib.request
from typing import Any, Callable, Optional, Tuple
from urllib.parse import urlencode
try:
from settings_store import enabled_exchanges, load_settings
except ImportError:
from manual_trading_hub.settings_store import enabled_exchanges, load_settings
MARKET_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
MARKET_LOCK = threading.Lock()
MARKET_TTL_SEC = 300.0
HUB_FLASK_TIMEOUT = float(__import__("os").getenv("HUB_FLASK_TIMEOUT", "20"))
def normalize_base_symbol(text: str) -> str:
s = str(text or "").upper().strip()
for suf in ("USDT:USDT", "/USDT:USDT", "/USDT", "USDT", "-USDT-SWAP"):
if s.endswith(suf) and len(s) > len(suf):
s = s[: -len(suf)].strip("-/")
break
if "/" in s:
s = s.split("/", 1)[0].strip()
if ":" in s:
s = s.split(":", 1)[0].strip()
return s
def resolve_usdt_perp_symbol(exchange: Any, base: str) -> Tuple[Optional[str], Optional[str]]:
base_u = normalize_base_symbol(base)
if not base_u:
return None, "请输入币种,如 ETH"
candidates = [f"{base_u}/USDT:USDT", f"{base_u}/USDT"]
markets = getattr(exchange, "markets", None) or {}
for sym in candidates:
m = markets.get(sym)
if not m:
continue
if m.get("active") is False:
continue
if m.get("swap") or m.get("linear") or m.get("contract"):
return sym, None
for sym, m in markets.items():
if m.get("active") is False:
continue
if not (m.get("swap") or m.get("linear")):
continue
if (m.get("quote") or "").upper() != "USDT":
continue
if (m.get("base") or "").upper() == base_u:
return sym, None
return None, f"未找到 {base_u}/USDT 永续合约"
def _decimals_from_precision_value(value: Any) -> Optional[int]:
if value in (None, ""):
return None
try:
p = float(value)
except (TypeError, ValueError):
return None
if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12:
return int(round(p))
if 0 < p < 1:
s = f"{p:.12f}".rstrip("0")
if "." in s:
return min(12, len(s.split(".", 1)[1]))
return None
def _decimals_from_ccxt_str(text: str) -> int:
s = str(text or "").strip()
if not s or "." not in s:
return 0
frac = s.split(".", 1)[1]
if not frac:
return 0
return min(12, len(frac.rstrip("0") or frac))
def amount_decimals_from_exchange(exchange: Any, exchange_symbol: str) -> int:
try:
return _decimals_from_ccxt_str(exchange.amount_to_precision(exchange_symbol, 1.23456789))
except Exception:
market = exchange.market(exchange_symbol)
prec = (market.get("precision") or {}).get("amount")
d = _decimals_from_precision_value(prec)
return d if d is not None else 4
def price_decimals_from_exchange(
exchange: Any, exchange_symbol: str, price_tick: Optional[float]
) -> int:
from lib.hub.hub_ohlcv_lib import normalize_price_tick
tick = normalize_price_tick(price_tick)
if tick and tick > 0:
if tick >= 1:
return 0
s = f"{tick:.12f}".rstrip("0")
if "." in s:
return min(12, len(s.split(".", 1)[1]))
try:
return _decimals_from_ccxt_str(exchange.price_to_precision(exchange_symbol, 12345.678901234))
except Exception:
market = exchange.market(exchange_symbol)
prec = (market.get("precision") or {}).get("price")
d = _decimals_from_precision_value(prec)
return d if d is not None else 4
def make_amount_precise_fn_from_market(market: dict[str, Any]) -> Callable[[float], Optional[float]]:
dec = max(0, int(market.get("amount_decimals") or 4))
min_amt = market.get("min_amount")
def _fn(amount: float) -> Optional[float]:
try:
v = float(amount)
except (TypeError, ValueError):
return None
if v <= 0:
return None
factor = 10**dec
v = int(v * factor + 1e-12) / factor
if min_amt is not None:
try:
if v < float(min_amt):
return None
except (TypeError, ValueError):
pass
if v <= 0:
return None
return v
return _fn
def find_exchange(exchange_id: str) -> dict | None:
needle = str(exchange_id or "").strip()
if not needle:
return None
for ex in load_settings().get("exchanges") or []:
if str(ex.get("id") or "").strip() == needle:
return ex
if str(ex.get("key") or "").strip().lower() == needle.lower():
return ex
return None
def list_calculator_exchanges() -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for ex in enabled_exchanges():
rows.append(
{
"id": str(ex.get("id") or ""),
"key": str(ex.get("key") or ""),
"name": str(ex.get("name") or ex.get("key") or ""),
"enabled": bool(ex.get("enabled")),
}
)
return rows
def _hub_headers() -> dict[str, str]:
import os
token = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
if token:
return {"X-Hub-Token": token}
return {}
def fetch_instance_market_sync(ex: dict, *, base: str) -> dict[str, Any]:
base_url = (ex.get("flask_url") or "").rstrip("/")
if not base_url:
return {"ok": False, "msg": "未配置 flask_url"}
params = urlencode({"base": normalize_base_symbol(base) or base})
url = f"{base_url}/api/hub/market?{params}"
req = urllib.request.Request(url, headers=_hub_headers(), method="GET")
try:
with urllib.request.urlopen(req, timeout=HUB_FLASK_TIMEOUT) as resp:
status = int(getattr(resp, "status", 200) or 200)
raw = resp.read().decode("utf-8", errors="replace")
data = json.loads(raw) if raw else {}
if not isinstance(data, dict):
return {"ok": False, "msg": "无效 JSON"}
if status >= 400:
data.setdefault("ok", False)
return data
except urllib.error.HTTPError as exc:
try:
raw = exc.read().decode("utf-8", errors="replace")
body = json.loads(raw) if raw else {}
except Exception:
body = {"ok": False, "msg": raw if "raw" in locals() else str(exc)}
if isinstance(body, dict):
body.setdefault("ok", False)
return body
return {"ok": False, "msg": f"HTTP {exc.code}"}
except Exception as exc:
return {"ok": False, "msg": str(exc)}
def _enrich_market_from_settings(ex: dict, payload: dict[str, Any]) -> dict[str, Any]:
out = dict(payload)
out["exchange_id"] = str(ex.get("id") or "")
out["exchange_key"] = str(ex.get("key") or "")
out["exchange_name"] = str(ex.get("name") or ex.get("key") or "")
out["exchange_label"] = out["exchange_name"]
return out
def get_calculator_market(
exchange_id: str,
base: str,
*,
ex: dict | None = None,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""从系统设置中的交易实例拉取合约精度(与实盘一致)。"""
row = ex or find_exchange(exchange_id)
if not row:
return None, "未找到该交易所配置"
if not row.get("enabled"):
return None, f"{row.get('name') or exchange_id} 未启用"
base_u = normalize_base_symbol(base)
if not base_u:
return None, "请输入币种,如 ETH"
cache_key = f"{row.get('id')}:{base_u}"
now = time.time()
with MARKET_LOCK:
cached = MARKET_CACHE.get(cache_key)
if cached and now - cached[0] < MARKET_TTL_SEC:
return dict(cached[1]), None
remote = fetch_instance_market_sync(row, base=base_u)
if not remote.get("ok"):
return None, str(remote.get("msg") or "实例返回失败")
data = _enrich_market_from_settings(row, remote)
with MARKET_LOCK:
MARKET_CACHE[cache_key] = (now, data)
return data, None
def clear_market_cache() -> None:
with MARKET_LOCK:
MARKET_CACHE.clear()
@@ -1,407 +1,407 @@
"""中控资金概况:分户日快照(180 交易日)、总资金曲线与回撤。"""
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Optional
from hub_trades_lib import current_trading_day
HUB_DIR = Path(__file__).resolve().parent / "manual_trading_hub"
FUND_HISTORY_PATH = HUB_DIR / "hub_fund_history.json"
LEGACY_FUND_HISTORY_PATH = HUB_DIR / "hub_ai_fund_history.json"
try:
FUND_HISTORY_DAYS = max(30, int(os.getenv("HUB_FUND_HISTORY_DAYS", "180") or "180"))
except ValueError:
FUND_HISTORY_DAYS = 180
FUND_HISTORY_START_DAY = (os.getenv("HUB_FUND_HISTORY_START_DAY") or "2026-06-09").strip()[:10]
def fund_history_start_day() -> str:
return FUND_HISTORY_START_DAY or "2026-06-09"
def _now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _safe_float(value: Any) -> Optional[float]:
try:
v = float(value)
return v if v >= 0 else None
except (TypeError, ValueError):
return None
def account_total_usdt(funding: Any, trading: Any) -> Optional[float]:
"""资金户 + 交易户;任一侧缺失则不计入(返回 None)。"""
fu = _safe_float(funding)
tu = _safe_float(trading)
if fu is None or tu is None:
return None
return round(fu + tu, 4)
def compute_drawdown(values: list[float]) -> dict[str, Any]:
"""基于资金权益序列计算峰值回撤(U 与 %)。"""
peak = 0.0
max_dd_u = 0.0
peak_at_end = 0.0
for v in values:
if not isinstance(v, (int, float)):
continue
fv = float(v)
if fv > peak:
peak = fv
dd = peak - fv
if dd > max_dd_u:
max_dd_u = dd
peak_at_end = peak
max_dd_u = round(max_dd_u, 4)
peak_at_end = round(peak_at_end, 4)
max_dd_pct = round((max_dd_u / peak_at_end) * 100, 2) if peak_at_end > 0 else None
return {
"peak_usdt": peak_at_end,
"max_drawdown_u": max_dd_u,
"max_drawdown_pct": max_dd_pct,
}
def _atomic_write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
def _prune_days(
days: dict,
*,
keep_days: int,
anchor_day: str,
start_day: Optional[str] = None,
) -> dict:
try:
anchor = datetime.strptime(anchor_day[:10], "%Y-%m-%d")
except ValueError:
anchor = datetime.now()
rolling_cutoff = (anchor - timedelta(days=max(1, keep_days) - 1)).strftime("%Y-%m-%d")
start = (start_day or fund_history_start_day()).strip()[:10]
cutoff = max(rolling_cutoff, start) if start else rolling_cutoff
return {k: v for k, v in (days or {}).items() if str(k) >= cutoff}
def _migrate_legacy_store(days: dict) -> dict:
if not LEGACY_FUND_HISTORY_PATH.is_file():
return days
try:
loaded = json.loads(LEGACY_FUND_HISTORY_PATH.read_text(encoding="utf-8"))
legacy_days = loaded.get("days") if isinstance(loaded, dict) else {}
if not isinstance(legacy_days, dict):
return days
merged = dict(days)
for day, block in legacy_days.items():
if day in merged:
continue
if isinstance(block, dict) and block.get("accounts"):
merged[day] = block
return merged
except Exception:
return days
def _load_store() -> dict:
if not FUND_HISTORY_PATH.is_file():
store = {"version": 1, "days": _migrate_legacy_store({})}
if store["days"]:
_atomic_write(FUND_HISTORY_PATH, store)
return store
try:
loaded = json.loads(FUND_HISTORY_PATH.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
loaded.setdefault("version", 1)
days = dict(loaded.get("days") or {})
loaded["days"] = _migrate_legacy_store(days)
return loaded
except Exception:
pass
return {"version": 1, "days": {}}
def record_fund_snapshot(
trading_day: str,
accounts: list[dict],
*,
keep_days: int = FUND_HISTORY_DAYS,
reset_hour: int = 8,
) -> dict[str, Any]:
"""写入当日各户资金账户/交易账户余额,并裁剪历史。"""
day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour)
start = fund_history_start_day()
if start and day < start:
return _load_store().get("days") or {}
store = _load_store()
days = dict(store.get("days") or {})
row_accounts: dict[str, dict] = {}
for ac in accounts or []:
key = str(ac.get("key") or ac.get("id") or "").strip()
if not key:
continue
if not ac.get("monitored"):
continue
fu = _safe_float(ac.get("funding_usdt"))
tu = _safe_float(ac.get("trading_usdt"))
total = account_total_usdt(fu, tu)
if total is None:
continue
row_accounts[key] = {
"name": ac.get("name"),
"funding_usdt": fu,
"trading_usdt": tu,
"total_usdt": total,
"recorded_at": _now_str(),
}
if row_accounts:
days[day] = {"accounts": row_accounts, "updated_at": _now_str()}
days = _prune_days(
days, keep_days=keep_days, anchor_day=day, start_day=fund_history_start_day()
)
_atomic_write(FUND_HISTORY_PATH, {"version": 1, "days": days})
return days
def record_fund_snapshot_from_board(
rows: list[dict],
*,
keep_days: int = FUND_HISTORY_DAYS,
reset_hour: int = 8,
) -> dict[str, Any]:
"""监控板行写入当日快照(仅 account_ok 且资金/交易户齐全)。"""
day = current_trading_day(reset_hour=reset_hour)
accounts = []
for row in rows or []:
if not isinstance(row, dict):
continue
if not row.get("account_ok"):
continue
accounts.append(
{
"key": row.get("key") or row.get("id"),
"name": row.get("name"),
"funding_usdt": row.get("funding_usdt"),
"trading_usdt": row.get("trading_usdt"),
"monitored": True,
}
)
return record_fund_snapshot(day, accounts, keep_days=keep_days, reset_hour=reset_hour)
def get_fund_history(*, anchor_day: str, keep_days: int = FUND_HISTORY_DAYS) -> dict[str, dict]:
store = _load_store()
return _prune_days(
dict(store.get("days") or {}),
keep_days=keep_days,
anchor_day=anchor_day,
start_day=fund_history_start_day(),
)
def _exchange_monitored(ex: dict) -> bool:
return bool(ex.get("enabled")) and not bool(ex.get("env_disabled"))
def _live_row_for_exchange(ex: dict, rows_by_key: dict[str, dict]) -> Optional[dict]:
key = str(ex.get("key") or "").strip()
if not key:
return None
return rows_by_key.get(key)
def _series_from_history(
history: dict[str, dict],
account_keys: list[str],
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for day in sorted(history.keys()):
block = history.get(day) or {}
ac_map = block.get("accounts") or {}
total = 0.0
n = 0
for key in account_keys:
ac = ac_map.get(key) or {}
t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt"))
if t is None:
t = _safe_float(ac.get("total_usdt"))
if t is None:
continue
total += t
n += 1
if n > 0:
out.append({"day": day, "total_usdt": round(total, 4)})
return out
def _account_series(history: dict[str, dict], key: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for day in sorted(history.keys()):
ac = (history.get(day) or {}).get("accounts", {}).get(key) or {}
t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt"))
if t is None:
t = _safe_float(ac.get("total_usdt"))
if t is None:
continue
out.append(
{
"day": day,
"total_usdt": t,
"funding_usdt": _safe_float(ac.get("funding_usdt")),
"trading_usdt": _safe_float(ac.get("trading_usdt")),
}
)
return out
def build_fund_overview(
exchanges: list[dict],
*,
board_rows: Optional[list[dict]] = None,
trading_day: Optional[str] = None,
keep_days: int = FUND_HISTORY_DAYS,
reset_hour: int = 8,
updated_at: Optional[str] = None,
) -> dict[str, Any]:
day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour)
history = get_fund_history(anchor_day=day, keep_days=keep_days)
rows_by_key: dict[str, dict] = {}
for row in board_rows or []:
if isinstance(row, dict):
k = str(row.get("key") or "").strip()
if k:
rows_by_key[k] = row
monitored_keys: list[str] = []
accounts_out: list[dict[str, Any]] = []
live_total = 0.0
live_known = 0
for ex in exchanges or []:
if not _exchange_monitored(ex):
continue
key = str(ex.get("key") or "").strip()
monitored = True
row = _live_row_for_exchange(ex, rows_by_key)
fu = tu = total = None
data_ok = False
if row and row.get("account_ok"):
fu = _safe_float(row.get("funding_usdt"))
tu = _safe_float(row.get("trading_usdt"))
total = account_total_usdt(fu, tu)
data_ok = total is not None
if data_ok:
live_total += total
live_known += 1
series = _account_series(history, key) if key else []
dd = compute_drawdown([p["total_usdt"] for p in series]) if series else {
"peak_usdt": None,
"max_drawdown_u": None,
"max_drawdown_pct": None,
}
day_delta = None
if series:
if len(series) >= 2:
day_delta = round(series[-1]["total_usdt"] - series[-2]["total_usdt"], 4)
elif data_ok and total is not None:
day_delta = round(total - series[-1]["total_usdt"], 4)
accounts_out.append(
{
"id": ex.get("id"),
"key": key,
"name": ex.get("name") or key,
"monitored": monitored,
"data_ok": data_ok,
"funding_usdt": fu,
"trading_usdt": tu,
"total_usdt": total,
"series": series,
"drawdown": dd,
"day_delta_usdt": day_delta,
}
)
if key:
monitored_keys.append(key)
total_series = _series_from_history(history, monitored_keys)
if live_known > 0:
last_day = total_series[-1]["day"] if total_series else None
live_point = round(live_total, 4)
if last_day == day and total_series:
total_series[-1]["total_usdt"] = live_point
total_series[-1]["live"] = True
else:
total_series.append({"day": day, "total_usdt": live_point, "live": True})
total_dd = compute_drawdown([p["total_usdt"] for p in total_series]) if total_series else {
"peak_usdt": None,
"max_drawdown_u": None,
"max_drawdown_pct": None,
}
total_day_delta = None
if total_series:
if len(total_series) >= 2:
total_day_delta = round(
total_series[-1]["total_usdt"] - total_series[-2]["total_usdt"], 4
)
return {
"ok": True,
"trading_day": day,
"reset_hour": reset_hour,
"keep_days": keep_days,
"history_start_day": fund_history_start_day(),
"updated_at": updated_at,
"totals": {
"monitored_count": len(monitored_keys),
"live_known_count": live_known,
"total_usdt": round(live_total, 4) if live_known > 0 else None,
"day_delta_usdt": total_day_delta,
"series": total_series,
"drawdown": total_dd,
},
"accounts": accounts_out,
}
def format_fund_history_text(
history: dict[str, dict],
*,
account_names: Optional[dict[str, str]] = None,
) -> str:
if not history:
return "(暂无资金历史快照)"
names = account_names or {}
lines = ["【资金快照(资金账户 + 交易账户 USDT)】"]
for day in sorted(history.keys()):
block = history.get(day) or {}
ac_map = block.get("accounts") or {}
if not ac_map:
continue
parts = []
for key, ac in ac_map.items():
label = names.get(key) or ac.get("name") or key
fu = ac.get("funding_usdt")
tu = ac.get("trading_usdt")
tot = ac.get("total_usdt")
if tot is None:
tot = account_total_usdt(fu, tu)
fu_txt = f"{fu}U" if fu is not None else "未知"
tu_txt = f"{tu}U" if tu is not None else "未知"
tot_txt = f"{tot}U" if tot is not None else "未知"
parts.append(f"{label}: 合计{tot_txt}(资金{fu_txt}/交易{tu_txt}")
lines.append(f"- {day}: " + "".join(parts))
return "\n".join(lines) if len(lines) > 1 else "(暂无资金历史快照)"
"""中控资金概况:分户日快照(180 交易日)、总资金曲线与回撤。"""
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Optional
from lib.hub.hub_trades_lib import current_trading_day
HUB_DIR = Path(__file__).resolve().parent / "manual_trading_hub"
FUND_HISTORY_PATH = HUB_DIR / "hub_fund_history.json"
LEGACY_FUND_HISTORY_PATH = HUB_DIR / "hub_ai_fund_history.json"
try:
FUND_HISTORY_DAYS = max(30, int(os.getenv("HUB_FUND_HISTORY_DAYS", "180") or "180"))
except ValueError:
FUND_HISTORY_DAYS = 180
FUND_HISTORY_START_DAY = (os.getenv("HUB_FUND_HISTORY_START_DAY") or "2026-06-09").strip()[:10]
def fund_history_start_day() -> str:
return FUND_HISTORY_START_DAY or "2026-06-09"
def _now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _safe_float(value: Any) -> Optional[float]:
try:
v = float(value)
return v if v >= 0 else None
except (TypeError, ValueError):
return None
def account_total_usdt(funding: Any, trading: Any) -> Optional[float]:
"""资金户 + 交易户;任一侧缺失则不计入(返回 None)。"""
fu = _safe_float(funding)
tu = _safe_float(trading)
if fu is None or tu is None:
return None
return round(fu + tu, 4)
def compute_drawdown(values: list[float]) -> dict[str, Any]:
"""基于资金权益序列计算峰值回撤(U 与 %)。"""
peak = 0.0
max_dd_u = 0.0
peak_at_end = 0.0
for v in values:
if not isinstance(v, (int, float)):
continue
fv = float(v)
if fv > peak:
peak = fv
dd = peak - fv
if dd > max_dd_u:
max_dd_u = dd
peak_at_end = peak
max_dd_u = round(max_dd_u, 4)
peak_at_end = round(peak_at_end, 4)
max_dd_pct = round((max_dd_u / peak_at_end) * 100, 2) if peak_at_end > 0 else None
return {
"peak_usdt": peak_at_end,
"max_drawdown_u": max_dd_u,
"max_drawdown_pct": max_dd_pct,
}
def _atomic_write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
def _prune_days(
days: dict,
*,
keep_days: int,
anchor_day: str,
start_day: Optional[str] = None,
) -> dict:
try:
anchor = datetime.strptime(anchor_day[:10], "%Y-%m-%d")
except ValueError:
anchor = datetime.now()
rolling_cutoff = (anchor - timedelta(days=max(1, keep_days) - 1)).strftime("%Y-%m-%d")
start = (start_day or fund_history_start_day()).strip()[:10]
cutoff = max(rolling_cutoff, start) if start else rolling_cutoff
return {k: v for k, v in (days or {}).items() if str(k) >= cutoff}
def _migrate_legacy_store(days: dict) -> dict:
if not LEGACY_FUND_HISTORY_PATH.is_file():
return days
try:
loaded = json.loads(LEGACY_FUND_HISTORY_PATH.read_text(encoding="utf-8"))
legacy_days = loaded.get("days") if isinstance(loaded, dict) else {}
if not isinstance(legacy_days, dict):
return days
merged = dict(days)
for day, block in legacy_days.items():
if day in merged:
continue
if isinstance(block, dict) and block.get("accounts"):
merged[day] = block
return merged
except Exception:
return days
def _load_store() -> dict:
if not FUND_HISTORY_PATH.is_file():
store = {"version": 1, "days": _migrate_legacy_store({})}
if store["days"]:
_atomic_write(FUND_HISTORY_PATH, store)
return store
try:
loaded = json.loads(FUND_HISTORY_PATH.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
loaded.setdefault("version", 1)
days = dict(loaded.get("days") or {})
loaded["days"] = _migrate_legacy_store(days)
return loaded
except Exception:
pass
return {"version": 1, "days": {}}
def record_fund_snapshot(
trading_day: str,
accounts: list[dict],
*,
keep_days: int = FUND_HISTORY_DAYS,
reset_hour: int = 8,
) -> dict[str, Any]:
"""写入当日各户资金账户/交易账户余额,并裁剪历史。"""
day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour)
start = fund_history_start_day()
if start and day < start:
return _load_store().get("days") or {}
store = _load_store()
days = dict(store.get("days") or {})
row_accounts: dict[str, dict] = {}
for ac in accounts or []:
key = str(ac.get("key") or ac.get("id") or "").strip()
if not key:
continue
if not ac.get("monitored"):
continue
fu = _safe_float(ac.get("funding_usdt"))
tu = _safe_float(ac.get("trading_usdt"))
total = account_total_usdt(fu, tu)
if total is None:
continue
row_accounts[key] = {
"name": ac.get("name"),
"funding_usdt": fu,
"trading_usdt": tu,
"total_usdt": total,
"recorded_at": _now_str(),
}
if row_accounts:
days[day] = {"accounts": row_accounts, "updated_at": _now_str()}
days = _prune_days(
days, keep_days=keep_days, anchor_day=day, start_day=fund_history_start_day()
)
_atomic_write(FUND_HISTORY_PATH, {"version": 1, "days": days})
return days
def record_fund_snapshot_from_board(
rows: list[dict],
*,
keep_days: int = FUND_HISTORY_DAYS,
reset_hour: int = 8,
) -> dict[str, Any]:
"""监控板行写入当日快照(仅 account_ok 且资金/交易户齐全)。"""
day = current_trading_day(reset_hour=reset_hour)
accounts = []
for row in rows or []:
if not isinstance(row, dict):
continue
if not row.get("account_ok"):
continue
accounts.append(
{
"key": row.get("key") or row.get("id"),
"name": row.get("name"),
"funding_usdt": row.get("funding_usdt"),
"trading_usdt": row.get("trading_usdt"),
"monitored": True,
}
)
return record_fund_snapshot(day, accounts, keep_days=keep_days, reset_hour=reset_hour)
def get_fund_history(*, anchor_day: str, keep_days: int = FUND_HISTORY_DAYS) -> dict[str, dict]:
store = _load_store()
return _prune_days(
dict(store.get("days") or {}),
keep_days=keep_days,
anchor_day=anchor_day,
start_day=fund_history_start_day(),
)
def _exchange_monitored(ex: dict) -> bool:
return bool(ex.get("enabled")) and not bool(ex.get("env_disabled"))
def _live_row_for_exchange(ex: dict, rows_by_key: dict[str, dict]) -> Optional[dict]:
key = str(ex.get("key") or "").strip()
if not key:
return None
return rows_by_key.get(key)
def _series_from_history(
history: dict[str, dict],
account_keys: list[str],
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for day in sorted(history.keys()):
block = history.get(day) or {}
ac_map = block.get("accounts") or {}
total = 0.0
n = 0
for key in account_keys:
ac = ac_map.get(key) or {}
t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt"))
if t is None:
t = _safe_float(ac.get("total_usdt"))
if t is None:
continue
total += t
n += 1
if n > 0:
out.append({"day": day, "total_usdt": round(total, 4)})
return out
def _account_series(history: dict[str, dict], key: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for day in sorted(history.keys()):
ac = (history.get(day) or {}).get("accounts", {}).get(key) or {}
t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt"))
if t is None:
t = _safe_float(ac.get("total_usdt"))
if t is None:
continue
out.append(
{
"day": day,
"total_usdt": t,
"funding_usdt": _safe_float(ac.get("funding_usdt")),
"trading_usdt": _safe_float(ac.get("trading_usdt")),
}
)
return out
def build_fund_overview(
exchanges: list[dict],
*,
board_rows: Optional[list[dict]] = None,
trading_day: Optional[str] = None,
keep_days: int = FUND_HISTORY_DAYS,
reset_hour: int = 8,
updated_at: Optional[str] = None,
) -> dict[str, Any]:
day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour)
history = get_fund_history(anchor_day=day, keep_days=keep_days)
rows_by_key: dict[str, dict] = {}
for row in board_rows or []:
if isinstance(row, dict):
k = str(row.get("key") or "").strip()
if k:
rows_by_key[k] = row
monitored_keys: list[str] = []
accounts_out: list[dict[str, Any]] = []
live_total = 0.0
live_known = 0
for ex in exchanges or []:
if not _exchange_monitored(ex):
continue
key = str(ex.get("key") or "").strip()
monitored = True
row = _live_row_for_exchange(ex, rows_by_key)
fu = tu = total = None
data_ok = False
if row and row.get("account_ok"):
fu = _safe_float(row.get("funding_usdt"))
tu = _safe_float(row.get("trading_usdt"))
total = account_total_usdt(fu, tu)
data_ok = total is not None
if data_ok:
live_total += total
live_known += 1
series = _account_series(history, key) if key else []
dd = compute_drawdown([p["total_usdt"] for p in series]) if series else {
"peak_usdt": None,
"max_drawdown_u": None,
"max_drawdown_pct": None,
}
day_delta = None
if series:
if len(series) >= 2:
day_delta = round(series[-1]["total_usdt"] - series[-2]["total_usdt"], 4)
elif data_ok and total is not None:
day_delta = round(total - series[-1]["total_usdt"], 4)
accounts_out.append(
{
"id": ex.get("id"),
"key": key,
"name": ex.get("name") or key,
"monitored": monitored,
"data_ok": data_ok,
"funding_usdt": fu,
"trading_usdt": tu,
"total_usdt": total,
"series": series,
"drawdown": dd,
"day_delta_usdt": day_delta,
}
)
if key:
monitored_keys.append(key)
total_series = _series_from_history(history, monitored_keys)
if live_known > 0:
last_day = total_series[-1]["day"] if total_series else None
live_point = round(live_total, 4)
if last_day == day and total_series:
total_series[-1]["total_usdt"] = live_point
total_series[-1]["live"] = True
else:
total_series.append({"day": day, "total_usdt": live_point, "live": True})
total_dd = compute_drawdown([p["total_usdt"] for p in total_series]) if total_series else {
"peak_usdt": None,
"max_drawdown_u": None,
"max_drawdown_pct": None,
}
total_day_delta = None
if total_series:
if len(total_series) >= 2:
total_day_delta = round(
total_series[-1]["total_usdt"] - total_series[-2]["total_usdt"], 4
)
return {
"ok": True,
"trading_day": day,
"reset_hour": reset_hour,
"keep_days": keep_days,
"history_start_day": fund_history_start_day(),
"updated_at": updated_at,
"totals": {
"monitored_count": len(monitored_keys),
"live_known_count": live_known,
"total_usdt": round(live_total, 4) if live_known > 0 else None,
"day_delta_usdt": total_day_delta,
"series": total_series,
"drawdown": total_dd,
},
"accounts": accounts_out,
}
def format_fund_history_text(
history: dict[str, dict],
*,
account_names: Optional[dict[str, str]] = None,
) -> str:
if not history:
return "(暂无资金历史快照)"
names = account_names or {}
lines = ["【资金快照(资金账户 + 交易账户 USDT)】"]
for day in sorted(history.keys()):
block = history.get(day) or {}
ac_map = block.get("accounts") or {}
if not ac_map:
continue
parts = []
for key, ac in ac_map.items():
label = names.get(key) or ac.get("name") or key
fu = ac.get("funding_usdt")
tu = ac.get("trading_usdt")
tot = ac.get("total_usdt")
if tot is None:
tot = account_total_usdt(fu, tu)
fu_txt = f"{fu}U" if fu is not None else "未知"
tu_txt = f"{tu}U" if tu is not None else "未知"
tot_txt = f"{tot}U" if tot is not None else "未知"
parts.append(f"{label}: 合计{tot_txt}(资金{fu_txt}/交易{tu_txt}")
lines.append(f"- {day}: " + "".join(parts))
return "\n".join(lines) if len(lines) > 1 else "(暂无资金历史快照)"
File diff suppressed because it is too large Load Diff
@@ -1,311 +1,311 @@
"""中控宏观关键数据日历:手动录入 FOMC / CPI / 非农档发布时间,±1h 风控前置窗口。"""
from __future__ import annotations
import os
import sqlite3
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from hub_symbol_archive_lib import parse_wall_clock_ms
DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
MACRO_EVENT_TYPES = ("fomc", "cpi", "employment")
MACRO_EVENT_LABELS: dict[str, str] = {
"fomc": "FOMC 联邦基金利率",
"cpi": "美国 CPI 通胀",
"employment": "就业与劳工数据",
}
WINDOW_BEFORE_MS = int(os.getenv("HUB_MACRO_WINDOW_BEFORE_SEC", str(3600))) * 1000
WINDOW_AFTER_MS = int(os.getenv("HUB_MACRO_WINDOW_AFTER_SEC", str(3600))) * 1000
IMMINENT_BEFORE_MS = int(os.getenv("HUB_MACRO_IMMINENT_BEFORE_SEC", str(1800))) * 1000
LIST_FUTURE_DAYS = int(os.getenv("HUB_MACRO_LIST_FUTURE_DAYS", "60"))
def default_db_path() -> Path:
raw = (os.getenv("HUB_MACRO_CALENDAR_DB_PATH") or "").strip()
if raw:
return Path(raw)
hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data"
hub_dir.mkdir(parents=True, exist_ok=True)
return hub_dir / "hub_macro_calendar.db"
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
path = db_path or default_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def init_db(db_path: Path | None = None) -> None:
conn = _connect(db_path)
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS macro_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
event_at_ms INTEGER NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_macro_events_at ON macro_events(event_at_ms)"
)
finally:
conn.close()
def normalize_event_type(raw: str) -> str:
key = (raw or "").strip().lower()
if key not in MACRO_EVENT_TYPES:
raise ValueError(f"事件类型须为: {', '.join(MACRO_EVENT_LABELS.values())}")
return key
def parse_event_at_ms(raw: Any) -> int:
ms = parse_wall_clock_ms(raw, tz=DISPLAY_TZ)
if ms is None:
raise ValueError("发布时间格式错误,请使用 YYYY-MM-DD HH:MM 或 YYYY-MM-DDTHH:MM")
return int(ms)
def format_event_at(ms: int) -> str:
dt = datetime.fromtimestamp(ms / 1000, tz=DISPLAY_TZ)
return dt.strftime("%Y-%m-%d %H:%M")
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
ms = int(row["event_at_ms"])
et = str(row["event_type"])
return {
"id": int(row["id"]),
"event_type": et,
"event_type_label": MACRO_EVENT_LABELS.get(et, et),
"event_at_ms": ms,
"event_at": format_event_at(ms),
"note": str(row["note"] or ""),
"created_at_ms": int(row["created_at_ms"]),
"updated_at_ms": int(row["updated_at_ms"]),
}
def _window_bounds(event_at_ms: int) -> tuple[int, int]:
start = int(event_at_ms) - WINDOW_BEFORE_MS
end = int(event_at_ms) + WINDOW_AFTER_MS
return start, end
def enrich_alert(row: dict[str, Any], now_ms: int | None = None) -> dict[str, Any] | None:
now = int(now_ms if now_ms is not None else time.time() * 1000)
event_at_ms = int(row["event_at_ms"])
window_start, window_end = _window_bounds(event_at_ms)
if now < window_start or now > window_end:
return None
imminent = now >= (event_at_ms - IMMINENT_BEFORE_MS) and now <= window_end
mins_to_event = max(0, int((event_at_ms - now) / 60000))
mins_from_event = max(0, int((now - event_at_ms) / 60000))
return {
**row,
"window_start_ms": window_start,
"window_end_ms": window_end,
"window_start": format_event_at(window_start),
"window_end": format_event_at(window_end),
"phase": "imminent" if imminent else "window",
"phase_label": "即将发布" if imminent and now < event_at_ms else "高波动窗口",
"minutes_to_event": mins_to_event if now < event_at_ms else 0,
"minutes_from_event": mins_from_event if now >= event_at_ms else 0,
}
def list_events(
*,
now_ms: int | None = None,
include_expired_hours: int = 24,
db_path: Path | None = None,
) -> list[dict[str, Any]]:
init_db(db_path)
now = int(now_ms if now_ms is not None else time.time() * 1000)
horizon = now + LIST_FUTURE_DAYS * 86400 * 1000
expired_cutoff = now - max(0, int(include_expired_hours)) * 3600 * 1000 - WINDOW_AFTER_MS
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT * FROM macro_events
WHERE event_at_ms >= ? AND event_at_ms <= ?
ORDER BY event_at_ms ASC, id ASC
""",
(expired_cutoff, horizon),
).fetchall()
return [_row_to_dict(r) for r in rows]
finally:
conn.close()
def get_event(event_id: int, db_path: Path | None = None) -> dict[str, Any] | None:
init_db(db_path)
conn = _connect(db_path)
try:
row = conn.execute("SELECT * FROM macro_events WHERE id=?", (int(event_id),)).fetchone()
return _row_to_dict(row) if row else None
finally:
conn.close()
def _assert_no_duplicate(
conn: sqlite3.Connection,
event_type: str,
event_at_ms: int,
*,
exclude_id: int | None = None,
) -> None:
if exclude_id is None:
row = conn.execute(
"SELECT id FROM macro_events WHERE event_type=? AND event_at_ms=? LIMIT 1",
(event_type, int(event_at_ms)),
).fetchone()
else:
row = conn.execute(
"""
SELECT id FROM macro_events
WHERE event_type=? AND event_at_ms=? AND id<>?
LIMIT 1
""",
(event_type, int(event_at_ms), int(exclude_id)),
).fetchone()
if row:
raise ValueError("同类型、同发布时间的记录已存在")
def create_event(
event_type: str,
event_at: Any,
*,
note: str = "",
db_path: Path | None = None,
) -> dict[str, Any]:
init_db(db_path)
et = normalize_event_type(event_type)
event_at_ms = parse_event_at_ms(event_at)
note_s = str(note or "").strip()[:500]
now_ms = int(time.time() * 1000)
conn = _connect(db_path)
try:
_assert_no_duplicate(conn, et, event_at_ms)
cur = conn.execute(
"""
INSERT INTO macro_events (event_type, event_at_ms, note, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?)
""",
(et, event_at_ms, note_s, now_ms, now_ms),
)
eid = int(cur.lastrowid)
finally:
conn.close()
row = get_event(eid, db_path=db_path)
assert row is not None
return row
def update_event(
event_id: int,
*,
event_type: str | None = None,
event_at: Any | None = None,
note: str | None = None,
db_path: Path | None = None,
) -> dict[str, Any] | None:
init_db(db_path)
existing = get_event(event_id, db_path=db_path)
if not existing:
return None
et = normalize_event_type(event_type if event_type is not None else existing["event_type"])
event_at_ms = (
parse_event_at_ms(event_at) if event_at is not None else int(existing["event_at_ms"])
)
note_s = existing["note"] if note is None else str(note or "").strip()[:500]
now_ms = int(time.time() * 1000)
conn = _connect(db_path)
try:
_assert_no_duplicate(conn, et, event_at_ms, exclude_id=int(event_id))
conn.execute(
"""
UPDATE macro_events
SET event_type=?, event_at_ms=?, note=?, updated_at_ms=?
WHERE id=?
""",
(et, event_at_ms, note_s, now_ms, int(event_id)),
)
finally:
conn.close()
return get_event(event_id, db_path=db_path)
def delete_event(event_id: int, db_path: Path | None = None) -> bool:
init_db(db_path)
conn = _connect(db_path)
try:
cur = conn.execute("DELETE FROM macro_events WHERE id=?", (int(event_id),))
return cur.rowcount > 0
finally:
conn.close()
def list_active_alerts(
now_ms: int | None = None,
db_path: Path | None = None,
) -> list[dict[str, Any]]:
now = int(now_ms if now_ms is not None else time.time() * 1000)
lookback = now - WINDOW_BEFORE_MS - IMMINENT_BEFORE_MS
lookahead = now + WINDOW_AFTER_MS
init_db(db_path)
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT * FROM macro_events
WHERE event_at_ms >= ? AND event_at_ms <= ?
ORDER BY event_at_ms ASC, id ASC
""",
(lookback, lookahead),
).fetchall()
finally:
conn.close()
alerts: list[dict[str, Any]] = []
for row in rows:
item = enrich_alert(_row_to_dict(row), now_ms=now)
if item:
alerts.append(item)
return alerts
def build_banner_message(alert: dict[str, Any], *, has_positions: bool) -> str:
label = alert.get("event_type_label") or alert.get("event_type") or "宏观数据"
phase = alert.get("phase") or "window"
if has_positions:
if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0:
return (
f"{label}」即将发布(约 {alert['minutes_to_event']} 分钟),"
"注意仓位风险:勿加仓,检查止损/减仓"
)
return f"{label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓"
if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0:
return (
f"{label}」即将发布(约 {alert['minutes_to_event']} 分钟),"
"建议等待,避免新开仓"
)
return f"{label}」高波动窗口(±1h),建议等待,避免新开仓"
"""中控宏观关键数据日历:手动录入 FOMC / CPI / 非农档发布时间,±1h 风控前置窗口。"""
from __future__ import annotations
import os
import sqlite3
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from lib.hub.hub_symbol_archive_lib import parse_wall_clock_ms
DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
MACRO_EVENT_TYPES = ("fomc", "cpi", "employment")
MACRO_EVENT_LABELS: dict[str, str] = {
"fomc": "FOMC 联邦基金利率",
"cpi": "美国 CPI 通胀",
"employment": "就业与劳工数据",
}
WINDOW_BEFORE_MS = int(os.getenv("HUB_MACRO_WINDOW_BEFORE_SEC", str(3600))) * 1000
WINDOW_AFTER_MS = int(os.getenv("HUB_MACRO_WINDOW_AFTER_SEC", str(3600))) * 1000
IMMINENT_BEFORE_MS = int(os.getenv("HUB_MACRO_IMMINENT_BEFORE_SEC", str(1800))) * 1000
LIST_FUTURE_DAYS = int(os.getenv("HUB_MACRO_LIST_FUTURE_DAYS", "60"))
def default_db_path() -> Path:
raw = (os.getenv("HUB_MACRO_CALENDAR_DB_PATH") or "").strip()
if raw:
return Path(raw)
hub_dir = Path(__file__).resolve().parent / "manual_trading_hub" / "data"
hub_dir.mkdir(parents=True, exist_ok=True)
return hub_dir / "hub_macro_calendar.db"
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
path = db_path or default_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
return conn
def init_db(db_path: Path | None = None) -> None:
conn = _connect(db_path)
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS macro_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
event_at_ms INTEGER NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_macro_events_at ON macro_events(event_at_ms)"
)
finally:
conn.close()
def normalize_event_type(raw: str) -> str:
key = (raw or "").strip().lower()
if key not in MACRO_EVENT_TYPES:
raise ValueError(f"事件类型须为: {', '.join(MACRO_EVENT_LABELS.values())}")
return key
def parse_event_at_ms(raw: Any) -> int:
ms = parse_wall_clock_ms(raw, tz=DISPLAY_TZ)
if ms is None:
raise ValueError("发布时间格式错误,请使用 YYYY-MM-DD HH:MM 或 YYYY-MM-DDTHH:MM")
return int(ms)
def format_event_at(ms: int) -> str:
dt = datetime.fromtimestamp(ms / 1000, tz=DISPLAY_TZ)
return dt.strftime("%Y-%m-%d %H:%M")
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
ms = int(row["event_at_ms"])
et = str(row["event_type"])
return {
"id": int(row["id"]),
"event_type": et,
"event_type_label": MACRO_EVENT_LABELS.get(et, et),
"event_at_ms": ms,
"event_at": format_event_at(ms),
"note": str(row["note"] or ""),
"created_at_ms": int(row["created_at_ms"]),
"updated_at_ms": int(row["updated_at_ms"]),
}
def _window_bounds(event_at_ms: int) -> tuple[int, int]:
start = int(event_at_ms) - WINDOW_BEFORE_MS
end = int(event_at_ms) + WINDOW_AFTER_MS
return start, end
def enrich_alert(row: dict[str, Any], now_ms: int | None = None) -> dict[str, Any] | None:
now = int(now_ms if now_ms is not None else time.time() * 1000)
event_at_ms = int(row["event_at_ms"])
window_start, window_end = _window_bounds(event_at_ms)
if now < window_start or now > window_end:
return None
imminent = now >= (event_at_ms - IMMINENT_BEFORE_MS) and now <= window_end
mins_to_event = max(0, int((event_at_ms - now) / 60000))
mins_from_event = max(0, int((now - event_at_ms) / 60000))
return {
**row,
"window_start_ms": window_start,
"window_end_ms": window_end,
"window_start": format_event_at(window_start),
"window_end": format_event_at(window_end),
"phase": "imminent" if imminent else "window",
"phase_label": "即将发布" if imminent and now < event_at_ms else "高波动窗口",
"minutes_to_event": mins_to_event if now < event_at_ms else 0,
"minutes_from_event": mins_from_event if now >= event_at_ms else 0,
}
def list_events(
*,
now_ms: int | None = None,
include_expired_hours: int = 24,
db_path: Path | None = None,
) -> list[dict[str, Any]]:
init_db(db_path)
now = int(now_ms if now_ms is not None else time.time() * 1000)
horizon = now + LIST_FUTURE_DAYS * 86400 * 1000
expired_cutoff = now - max(0, int(include_expired_hours)) * 3600 * 1000 - WINDOW_AFTER_MS
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT * FROM macro_events
WHERE event_at_ms >= ? AND event_at_ms <= ?
ORDER BY event_at_ms ASC, id ASC
""",
(expired_cutoff, horizon),
).fetchall()
return [_row_to_dict(r) for r in rows]
finally:
conn.close()
def get_event(event_id: int, db_path: Path | None = None) -> dict[str, Any] | None:
init_db(db_path)
conn = _connect(db_path)
try:
row = conn.execute("SELECT * FROM macro_events WHERE id=?", (int(event_id),)).fetchone()
return _row_to_dict(row) if row else None
finally:
conn.close()
def _assert_no_duplicate(
conn: sqlite3.Connection,
event_type: str,
event_at_ms: int,
*,
exclude_id: int | None = None,
) -> None:
if exclude_id is None:
row = conn.execute(
"SELECT id FROM macro_events WHERE event_type=? AND event_at_ms=? LIMIT 1",
(event_type, int(event_at_ms)),
).fetchone()
else:
row = conn.execute(
"""
SELECT id FROM macro_events
WHERE event_type=? AND event_at_ms=? AND id<>?
LIMIT 1
""",
(event_type, int(event_at_ms), int(exclude_id)),
).fetchone()
if row:
raise ValueError("同类型、同发布时间的记录已存在")
def create_event(
event_type: str,
event_at: Any,
*,
note: str = "",
db_path: Path | None = None,
) -> dict[str, Any]:
init_db(db_path)
et = normalize_event_type(event_type)
event_at_ms = parse_event_at_ms(event_at)
note_s = str(note or "").strip()[:500]
now_ms = int(time.time() * 1000)
conn = _connect(db_path)
try:
_assert_no_duplicate(conn, et, event_at_ms)
cur = conn.execute(
"""
INSERT INTO macro_events (event_type, event_at_ms, note, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?)
""",
(et, event_at_ms, note_s, now_ms, now_ms),
)
eid = int(cur.lastrowid)
finally:
conn.close()
row = get_event(eid, db_path=db_path)
assert row is not None
return row
def update_event(
event_id: int,
*,
event_type: str | None = None,
event_at: Any | None = None,
note: str | None = None,
db_path: Path | None = None,
) -> dict[str, Any] | None:
init_db(db_path)
existing = get_event(event_id, db_path=db_path)
if not existing:
return None
et = normalize_event_type(event_type if event_type is not None else existing["event_type"])
event_at_ms = (
parse_event_at_ms(event_at) if event_at is not None else int(existing["event_at_ms"])
)
note_s = existing["note"] if note is None else str(note or "").strip()[:500]
now_ms = int(time.time() * 1000)
conn = _connect(db_path)
try:
_assert_no_duplicate(conn, et, event_at_ms, exclude_id=int(event_id))
conn.execute(
"""
UPDATE macro_events
SET event_type=?, event_at_ms=?, note=?, updated_at_ms=?
WHERE id=?
""",
(et, event_at_ms, note_s, now_ms, int(event_id)),
)
finally:
conn.close()
return get_event(event_id, db_path=db_path)
def delete_event(event_id: int, db_path: Path | None = None) -> bool:
init_db(db_path)
conn = _connect(db_path)
try:
cur = conn.execute("DELETE FROM macro_events WHERE id=?", (int(event_id),))
return cur.rowcount > 0
finally:
conn.close()
def list_active_alerts(
now_ms: int | None = None,
db_path: Path | None = None,
) -> list[dict[str, Any]]:
now = int(now_ms if now_ms is not None else time.time() * 1000)
lookback = now - WINDOW_BEFORE_MS - IMMINENT_BEFORE_MS
lookahead = now + WINDOW_AFTER_MS
init_db(db_path)
conn = _connect(db_path)
try:
rows = conn.execute(
"""
SELECT * FROM macro_events
WHERE event_at_ms >= ? AND event_at_ms <= ?
ORDER BY event_at_ms ASC, id ASC
""",
(lookback, lookahead),
).fetchall()
finally:
conn.close()
alerts: list[dict[str, Any]] = []
for row in rows:
item = enrich_alert(_row_to_dict(row), now_ms=now)
if item:
alerts.append(item)
return alerts
def build_banner_message(alert: dict[str, Any], *, has_positions: bool) -> str:
label = alert.get("event_type_label") or alert.get("event_type") or "宏观数据"
phase = alert.get("phase") or "window"
if has_positions:
if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0:
return (
f"{label}」即将发布(约 {alert['minutes_to_event']} 分钟),"
"注意仓位风险:勿加仓,检查止损/减仓"
)
return f"{label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓"
if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0:
return (
f"{label}」即将发布(约 {alert['minutes_to_event']} 分钟),"
"建议等待,避免新开仓"
)
return f"{label}」高波动窗口(±1h),建议等待,避免新开仓"
@@ -1,81 +1,81 @@
"""实例 USDT 永续合约信息(与实盘 ccxt 精度一致)。"""
from __future__ import annotations
from typing import Any, Callable, Optional, Tuple
from hub_calculator_market_lib import (
amount_decimals_from_exchange,
normalize_base_symbol,
price_decimals_from_exchange,
resolve_usdt_perp_symbol,
)
from hub_ohlcv_lib import normalize_price_tick, price_tick_from_market
def fetch_usdt_swap_market_info(
*,
base_or_symbol: str,
normalize_symbol_input: Callable[[str], str],
normalize_exchange_symbol: Callable[[str], str],
ensure_markets_loaded: Callable[[], None],
exchange: Any,
exchange_id: str = "",
) -> dict[str, Any]:
"""供各实例 /api/hub/market 调用。"""
raw = str(base_or_symbol or "").strip()
if not raw:
return {"ok": False, "msg": "请输入币种,如 ETH"}
try:
ensure_markets_loaded()
except Exception as exc:
return {"ok": False, "msg": f"加载市场失败: {exc}"}
base_u = normalize_base_symbol(raw)
hub_sym = normalize_symbol_input(raw if base_u else raw)
try:
ex_sym = normalize_exchange_symbol(hub_sym)
except Exception:
ex_sym = hub_sym
sym, err = resolve_usdt_perp_symbol(exchange, base_u or hub_sym)
if err and ex_sym:
markets = getattr(exchange, "markets", None) or {}
if ex_sym in markets:
sym = ex_sym
err = None
if err or not sym:
return {"ok": False, "msg": err or f"未找到 {base_u or raw}/USDT 永续合约"}
market = exchange.market(sym)
try:
contract_size = float(market.get("contractSize") or 1.0)
except (TypeError, ValueError):
contract_size = 1.0
if contract_size <= 0:
contract_size = 1.0
price_tick = normalize_price_tick(price_tick_from_market(exchange, sym))
amt_dec = amount_decimals_from_exchange(exchange, sym)
px_dec = price_decimals_from_exchange(exchange, sym, price_tick)
min_amount = None
try:
min_amount = float((market.get("limits") or {}).get("amount", {}).get("min"))
except (TypeError, ValueError):
min_amount = None
base_out = (market.get("base") or base_u or "").upper() or base_u
return {
"ok": True,
"exchange": (exchange_id or "").strip().lower(),
"base": base_out,
"exchange_symbol": sym,
"display_symbol": f"{base_out}/USDT" if base_out else sym,
"contract_size": contract_size,
"price_tick": price_tick,
"price_decimals": px_dec,
"amount_decimals": amt_dec,
"min_amount": min_amount,
}
"""实例 USDT 永续合约信息(与实盘 ccxt 精度一致)。"""
from __future__ import annotations
from typing import Any, Callable, Optional, Tuple
from lib.hub.hub_calculator_market_lib import (
amount_decimals_from_exchange,
normalize_base_symbol,
price_decimals_from_exchange,
resolve_usdt_perp_symbol,
)
from lib.hub.hub_ohlcv_lib import normalize_price_tick, price_tick_from_market
def fetch_usdt_swap_market_info(
*,
base_or_symbol: str,
normalize_symbol_input: Callable[[str], str],
normalize_exchange_symbol: Callable[[str], str],
ensure_markets_loaded: Callable[[], None],
exchange: Any,
exchange_id: str = "",
) -> dict[str, Any]:
"""供各实例 /api/hub/market 调用。"""
raw = str(base_or_symbol or "").strip()
if not raw:
return {"ok": False, "msg": "请输入币种,如 ETH"}
try:
ensure_markets_loaded()
except Exception as exc:
return {"ok": False, "msg": f"加载市场失败: {exc}"}
base_u = normalize_base_symbol(raw)
hub_sym = normalize_symbol_input(raw if base_u else raw)
try:
ex_sym = normalize_exchange_symbol(hub_sym)
except Exception:
ex_sym = hub_sym
sym, err = resolve_usdt_perp_symbol(exchange, base_u or hub_sym)
if err and ex_sym:
markets = getattr(exchange, "markets", None) or {}
if ex_sym in markets:
sym = ex_sym
err = None
if err or not sym:
return {"ok": False, "msg": err or f"未找到 {base_u or raw}/USDT 永续合约"}
market = exchange.market(sym)
try:
contract_size = float(market.get("contractSize") or 1.0)
except (TypeError, ValueError):
contract_size = 1.0
if contract_size <= 0:
contract_size = 1.0
price_tick = normalize_price_tick(price_tick_from_market(exchange, sym))
amt_dec = amount_decimals_from_exchange(exchange, sym)
px_dec = price_decimals_from_exchange(exchange, sym, price_tick)
min_amount = None
try:
min_amount = float((market.get("limits") or {}).get("amount", {}).get("min"))
except (TypeError, ValueError):
min_amount = None
base_out = (market.get("base") or base_u or "").upper() or base_u
return {
"ok": True,
"exchange": (exchange_id or "").strip().lower(),
"base": base_out,
"exchange_symbol": sym,
"display_symbol": f"{base_out}/USDT" if base_out else sym,
"contract_size": contract_size,
"price_tick": price_tick,
"price_decimals": px_dec,
"amount_decimals": amt_dec,
"min_amount": min_amount,
}
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
@@ -1,187 +1,187 @@
"""实盘/关键位放大 K 线:订单元数据与交易所浮盈、价格展示精度。"""
from __future__ import annotations
from typing import Any, Callable, Optional
from hub_ohlcv_lib import (
normalize_price_tick,
price_tick_from_market,
round_ohlcv_bars_to_tick,
)
from order_monitor_display_lib import (
apply_order_live_price_display,
apply_order_price_display_fields,
)
def resolve_kline_price_tick(
exchange: Any,
exchange_symbol: str,
*,
ensure_markets_fn: Callable[[], None],
) -> Optional[float]:
"""交易所最小价格变动单位,供 lightweight-charts 右侧刻度与标记线对齐。"""
if not exchange_symbol:
return None
try:
ensure_markets_fn()
return normalize_price_tick(price_tick_from_market(exchange, exchange_symbol))
except Exception:
return None
def align_candles_to_price_tick(
candles: list[dict[str, Any]],
price_tick: Optional[float],
) -> None:
if price_tick is not None and candles:
round_ohlcv_bars_to_tick(candles, price_tick)
def kline_api_price_fields(
exchange: Any,
exchange_symbol: str,
candles: list[dict[str, Any]],
*,
ensure_markets_fn: Callable[[], None],
) -> dict[str, Any]:
tick = resolve_kline_price_tick(
exchange, exchange_symbol, ensure_markets_fn=ensure_markets_fn
)
align_candles_to_price_tick(candles, tick)
return {"price_tick": tick}
def load_swap_positions_for_order_kline(
exchange: Any,
*,
private_configured: bool,
ensure_markets_fn: Callable[[], None],
settle: str = "usdt",
) -> list:
if not private_configured:
return []
try:
ensure_markets_fn()
try:
return exchange.fetch_positions(None, {"settle": settle}) or []
except Exception:
return exchange.fetch_positions() or []
except Exception:
return []
def metrics_for_order_item(
order_item: dict[str, Any],
positions: list,
*,
resolve_ex_sym_fn: Callable[[Any], str],
select_live_fn: Callable[[list, str, str], Any],
parse_metrics_fn: Callable[..., Optional[dict]],
) -> Optional[dict]:
if not positions:
return None
ex_sym = resolve_ex_sym_fn(order_item)
direction = order_item.get("direction") or "long"
prow = select_live_fn(positions, ex_sym, direction)
if not prow:
return None
lev = order_item.get("leverage")
return parse_metrics_fn(prow, order_leverage=lev)
def build_order_kline_order_payload(
order_item: dict[str, Any],
*,
ticker_price: Any,
format_price_fn: Callable[[Any, Any], str],
calc_pnl_fn: Callable[..., float],
calc_rr_ratio_fn: Callable[..., Optional[float]],
ex_metrics: Optional[dict] = None,
) -> dict[str, Any]:
sym = order_item.get("symbol") or ""
direction = order_item.get("direction") or "long"
margin = float(order_item.get("margin_capital") or 0)
leverage = float(order_item.get("leverage") or 0)
entry = float(order_item.get("trigger_price") or 0)
float_pnl = 0.0
float_pct = 0.0
if ticker_price and entry > 0:
float_pnl = float(
calc_pnl_fn(direction, entry, ticker_price, margin, leverage)
)
float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0.0
px_for_fmt = ticker_price
mark_raw = None
if ex_metrics and ex_metrics.get("mark_price") is not None:
mark_raw = ex_metrics["mark_price"]
try:
px_for_fmt = float(mark_raw)
except (TypeError, ValueError):
pass
if ex_metrics and ex_metrics.get("unrealized_pnl") is not None:
float_pnl = round(float(ex_metrics["unrealized_pnl"]), 2)
denom = ex_metrics.get("initial_margin") or margin
float_pct = (
round((float_pnl / float(denom)) * 100, 4)
if denom and float(denom) > 0
else float_pct
)
payload: dict[str, Any] = {
"id": order_item["id"],
"symbol": sym,
"direction": direction,
"trigger_price": order_item.get("trigger_price"),
"stop_loss": order_item.get("stop_loss"),
"take_profit": order_item.get("take_profit"),
"trigger_price_display": format_price_fn(sym, order_item.get("trigger_price")),
"stop_loss_display": format_price_fn(sym, order_item.get("stop_loss")),
"take_profit_display": format_price_fn(sym, order_item.get("take_profit")),
"margin_capital": order_item.get("margin_capital"),
"leverage": order_item.get("leverage"),
"position_ratio": order_item.get("position_ratio"),
"breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)),
"current_price": round(float(px_for_fmt), 8) if px_for_fmt is not None else None,
"float_pnl": round(float(float_pnl), 2),
"float_pct": float_pct,
}
apply_order_price_display_fields(
payload,
direction=direction,
entry_price=order_item.get("trigger_price"),
initial_stop_loss=order_item.get("initial_stop_loss"),
stop_loss=order_item.get("stop_loss"),
take_profit=order_item.get("take_profit"),
calc_rr_ratio_fn=calc_rr_ratio_fn,
)
apply_order_live_price_display(
payload,
sym,
ticker_price,
mark_raw,
format_price_fn,
)
payload["current_price_display"] = payload.get("price_display") or (
format_price_fn(sym, px_for_fmt) if px_for_fmt is not None else None
)
return payload
def enrich_key_kline_response(
*,
symbol: str,
current_price: Any,
key_info: Optional[dict[str, Any]],
format_price_fn: Callable[[Any, Any], str],
) -> tuple[Any, Optional[dict[str, Any]]]:
price_display = format_price_fn(symbol, current_price) if current_price is not None else None
if key_info is None:
return price_display, None
enriched = dict(key_info)
enriched["upper_display"] = format_price_fn(symbol, key_info.get("upper"))
enriched["lower_display"] = format_price_fn(symbol, key_info.get("lower"))
return price_display, enriched
"""实盘/关键位放大 K 线:订单元数据与交易所浮盈、价格展示精度。"""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.hub.hub_ohlcv_lib import (
normalize_price_tick,
price_tick_from_market,
round_ohlcv_bars_to_tick,
)
from lib.trade.order_monitor_display_lib import (
apply_order_live_price_display,
apply_order_price_display_fields,
)
def resolve_kline_price_tick(
exchange: Any,
exchange_symbol: str,
*,
ensure_markets_fn: Callable[[], None],
) -> Optional[float]:
"""交易所最小价格变动单位,供 lightweight-charts 右侧刻度与标记线对齐。"""
if not exchange_symbol:
return None
try:
ensure_markets_fn()
return normalize_price_tick(price_tick_from_market(exchange, exchange_symbol))
except Exception:
return None
def align_candles_to_price_tick(
candles: list[dict[str, Any]],
price_tick: Optional[float],
) -> None:
if price_tick is not None and candles:
round_ohlcv_bars_to_tick(candles, price_tick)
def kline_api_price_fields(
exchange: Any,
exchange_symbol: str,
candles: list[dict[str, Any]],
*,
ensure_markets_fn: Callable[[], None],
) -> dict[str, Any]:
tick = resolve_kline_price_tick(
exchange, exchange_symbol, ensure_markets_fn=ensure_markets_fn
)
align_candles_to_price_tick(candles, tick)
return {"price_tick": tick}
def load_swap_positions_for_order_kline(
exchange: Any,
*,
private_configured: bool,
ensure_markets_fn: Callable[[], None],
settle: str = "usdt",
) -> list:
if not private_configured:
return []
try:
ensure_markets_fn()
try:
return exchange.fetch_positions(None, {"settle": settle}) or []
except Exception:
return exchange.fetch_positions() or []
except Exception:
return []
def metrics_for_order_item(
order_item: dict[str, Any],
positions: list,
*,
resolve_ex_sym_fn: Callable[[Any], str],
select_live_fn: Callable[[list, str, str], Any],
parse_metrics_fn: Callable[..., Optional[dict]],
) -> Optional[dict]:
if not positions:
return None
ex_sym = resolve_ex_sym_fn(order_item)
direction = order_item.get("direction") or "long"
prow = select_live_fn(positions, ex_sym, direction)
if not prow:
return None
lev = order_item.get("leverage")
return parse_metrics_fn(prow, order_leverage=lev)
def build_order_kline_order_payload(
order_item: dict[str, Any],
*,
ticker_price: Any,
format_price_fn: Callable[[Any, Any], str],
calc_pnl_fn: Callable[..., float],
calc_rr_ratio_fn: Callable[..., Optional[float]],
ex_metrics: Optional[dict] = None,
) -> dict[str, Any]:
sym = order_item.get("symbol") or ""
direction = order_item.get("direction") or "long"
margin = float(order_item.get("margin_capital") or 0)
leverage = float(order_item.get("leverage") or 0)
entry = float(order_item.get("trigger_price") or 0)
float_pnl = 0.0
float_pct = 0.0
if ticker_price and entry > 0:
float_pnl = float(
calc_pnl_fn(direction, entry, ticker_price, margin, leverage)
)
float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0.0
px_for_fmt = ticker_price
mark_raw = None
if ex_metrics and ex_metrics.get("mark_price") is not None:
mark_raw = ex_metrics["mark_price"]
try:
px_for_fmt = float(mark_raw)
except (TypeError, ValueError):
pass
if ex_metrics and ex_metrics.get("unrealized_pnl") is not None:
float_pnl = round(float(ex_metrics["unrealized_pnl"]), 2)
denom = ex_metrics.get("initial_margin") or margin
float_pct = (
round((float_pnl / float(denom)) * 100, 4)
if denom and float(denom) > 0
else float_pct
)
payload: dict[str, Any] = {
"id": order_item["id"],
"symbol": sym,
"direction": direction,
"trigger_price": order_item.get("trigger_price"),
"stop_loss": order_item.get("stop_loss"),
"take_profit": order_item.get("take_profit"),
"trigger_price_display": format_price_fn(sym, order_item.get("trigger_price")),
"stop_loss_display": format_price_fn(sym, order_item.get("stop_loss")),
"take_profit_display": format_price_fn(sym, order_item.get("take_profit")),
"margin_capital": order_item.get("margin_capital"),
"leverage": order_item.get("leverage"),
"position_ratio": order_item.get("position_ratio"),
"breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)),
"current_price": round(float(px_for_fmt), 8) if px_for_fmt is not None else None,
"float_pnl": round(float(float_pnl), 2),
"float_pct": float_pct,
}
apply_order_price_display_fields(
payload,
direction=direction,
entry_price=order_item.get("trigger_price"),
initial_stop_loss=order_item.get("initial_stop_loss"),
stop_loss=order_item.get("stop_loss"),
take_profit=order_item.get("take_profit"),
calc_rr_ratio_fn=calc_rr_ratio_fn,
)
apply_order_live_price_display(
payload,
sym,
ticker_price,
mark_raw,
format_price_fn,
)
payload["current_price_display"] = payload.get("price_display") or (
format_price_fn(sym, px_for_fmt) if px_for_fmt is not None else None
)
return payload
def enrich_key_kline_response(
*,
symbol: str,
current_price: Any,
key_info: Optional[dict[str, Any]],
format_price_fn: Callable[[Any, Any], str],
) -> tuple[Any, Optional[dict[str, Any]]]:
price_display = format_price_fn(symbol, current_price) if current_price is not None else None
if key_info is None:
return price_display, None
enriched = dict(key_info)
enriched["upper_display"] = format_price_fn(symbol, key_info.get("upper"))
enriched["lower_display"] = format_price_fn(symbol, key_info.get("lower"))
return price_display, enriched
@@ -1,84 +1,84 @@
"""embed 壳/片段:按 tab 裁剪 render_main_page 的数据加载,降内存与 API 压力。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
EMBED_STRATEGY_PAGES = frozenset({"strategy", "strategy_trend", "strategy_roll", "strategy_records"})
@dataclass(frozen=True)
class EmbedRenderPlan:
exchange_capitals: bool
records_rows: bool
records_summary: bool
key_history: bool
key_list: bool
orders: bool
stats_bundle: bool
strategy: bool
orphan_live: bool
def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
if embed_mode not in ("fragment", "shell"):
return EmbedRenderPlan(
exchange_capitals=True,
records_rows=True,
records_summary=False,
key_history=True,
key_list=True,
orders=True,
stats_bundle=True,
strategy=True,
orphan_live=True,
)
is_shell = embed_mode == "shell"
is_strategy = page in EMBED_STRATEGY_PAGES
return EmbedRenderPlan(
exchange_capitals=is_shell,
records_rows=page == "records",
records_summary=is_shell and page != "records",
key_history=page == "key_monitor",
key_list=page in ("key_monitor", "trade") or is_strategy,
orders=page == "trade" or is_strategy,
stats_bundle=page == "stats",
strategy=is_strategy,
orphan_live=page == "trade" and is_shell,
)
def trade_records_summary(conn, start_bj: str, end_bj: str, tr_ts: str) -> dict[str, Any]:
"""顶栏统计用 COUNT,避免 embed 壳拉 1000 行交易记录。"""
from trade_result_lib import sql_effective_pnl_expr
pnl_sql = sql_effective_pnl_expr()
row = conn.execute(
f"""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN result = '错过' THEN 1 ELSE 0 END) AS miss_count,
SUM(CASE WHEN {pnl_sql} > 0 THEN 1 ELSE 0 END) AS wins,
SUM(CASE WHEN result = '错过' AND COALESCE(miss_reason,'') LIKE '%持仓占用%' THEN 1 ELSE 0 END) AS occupied_miss
FROM trade_records
WHERE {tr_ts} >= ? AND {tr_ts} <= ?
""",
(start_bj, end_bj),
).fetchone()
total = int(row["total"] or 0) if row else 0
miss_count = int(row["miss_count"] or 0) if row else 0
wins = int(row["wins"] or 0) if row else 0
occupied_miss_total = int(row["occupied_miss"] or 0) if row else 0
rate = round(wins / total * 100, 2) if total else 0
return {
"records": [],
"total": total,
"miss_count": miss_count,
"rate": rate,
"occupied_miss_total": occupied_miss_total,
}
def minimal_stats_bundle(reset_hour: int) -> dict[str, Any]:
return {"stats_reset_hour": reset_hour, "segments": []}
"""embed 壳/片段:按 tab 裁剪 render_main_page 的数据加载,降内存与 API 压力。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
EMBED_STRATEGY_PAGES = frozenset({"strategy", "strategy_trend", "strategy_roll", "strategy_records"})
@dataclass(frozen=True)
class EmbedRenderPlan:
exchange_capitals: bool
records_rows: bool
records_summary: bool
key_history: bool
key_list: bool
orders: bool
stats_bundle: bool
strategy: bool
orphan_live: bool
def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
if embed_mode not in ("fragment", "shell"):
return EmbedRenderPlan(
exchange_capitals=True,
records_rows=True,
records_summary=False,
key_history=True,
key_list=True,
orders=True,
stats_bundle=True,
strategy=True,
orphan_live=True,
)
is_shell = embed_mode == "shell"
is_strategy = page in EMBED_STRATEGY_PAGES
return EmbedRenderPlan(
exchange_capitals=is_shell,
records_rows=page == "records",
records_summary=is_shell and page != "records",
key_history=page == "key_monitor",
key_list=page in ("key_monitor", "trade") or is_strategy,
orders=page == "trade" or is_strategy,
stats_bundle=page == "stats",
strategy=is_strategy,
orphan_live=page == "trade" and is_shell,
)
def trade_records_summary(conn, start_bj: str, end_bj: str, tr_ts: str) -> dict[str, Any]:
"""顶栏统计用 COUNT,避免 embed 壳拉 1000 行交易记录。"""
from lib.trade.trade_result_lib import sql_effective_pnl_expr
pnl_sql = sql_effective_pnl_expr()
row = conn.execute(
f"""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN result = '错过' THEN 1 ELSE 0 END) AS miss_count,
SUM(CASE WHEN {pnl_sql} > 0 THEN 1 ELSE 0 END) AS wins,
SUM(CASE WHEN result = '错过' AND COALESCE(miss_reason,'') LIKE '%持仓占用%' THEN 1 ELSE 0 END) AS occupied_miss
FROM trade_records
WHERE {tr_ts} >= ? AND {tr_ts} <= ?
""",
(start_bj, end_bj),
).fetchone()
total = int(row["total"] or 0) if row else 0
miss_count = int(row["miss_count"] or 0) if row else 0
wins = int(row["wins"] or 0) if row else 0
occupied_miss_total = int(row["occupied_miss"] or 0) if row else 0
rate = round(wins / total * 100, 2) if total else 0
return {
"records": [],
"total": total,
"miss_count": miss_count,
"rate": rate,
"occupied_miss_total": occupied_miss_total,
}
def minimal_stats_bundle(reset_hour: int) -> dict[str, Any]:
return {"stats_reset_hour": reset_hour, "segments": []}
@@ -1,147 +1,148 @@
"""中控 iframe:壳常驻 + tab 内容 API/embed、/api/embed/page/<tab>)。"""
from __future__ import annotations
import os
from typing import Callable
from urllib.parse import parse_qsl, urlencode, urlsplit
from flask import Flask, Response, jsonify, redirect, request, session
from jinja2 import ChoiceLoader, FileSystemLoader
EMBED_TABS: tuple[str, ...] = (
"key_monitor",
"trade",
"strategy",
"strategy_records",
"records",
"stats",
)
PATH_TO_EMBED_TAB: dict[str, str] = {
"/": "trade",
"/trade": "trade",
"/key_monitor": "key_monitor",
"/strategy": "strategy",
"/strategy/trend": "strategy",
"/strategy/roll": "strategy",
"/strategy/records": "strategy_records",
"/records": "records",
"/stats": "stats",
}
ORDER_RULE_TIPS_BY_EXCHANGE: dict[str, str] = {
"gate": "order_monitor_rule_tips_gate.html",
"gate_bot": "order_monitor_rule_tips_gate.html",
"binance": "order_monitor_rule_tips_binance.html",
"okx": "order_monitor_rule_tips_okx.html",
}
def order_rule_tips_template(exchange_key: str) -> str:
ex = (exchange_key or "").strip().lower()
return ORDER_RULE_TIPS_BY_EXCHANGE.get(ex, "order_monitor_rule_tips_gate.html")
def include_transfer_block(exchange_key: str) -> bool:
return (exchange_key or "").strip().lower() in ("gate", "gate_bot")
def path_to_embed_tab(path: str) -> str | None:
p = (path or "/").strip()
if not p.startswith("/"):
p = "/" + p
base = urlsplit(p).path.rstrip("/") or "/"
return PATH_TO_EMBED_TAB.get(base)
def embed_shell_enabled() -> bool:
return (os.getenv("HUB_EMBED_SHELL") or "1").strip().lower() in ("1", "true", "yes", "on")
def rewrite_embed_dest(path: str, hub_theme: str | None = None) -> str:
"""embed=1 打开时:/trade → /embed?tab=trade&embed=1"""
if not embed_shell_enabled():
split = urlsplit(path or "/")
q = dict(parse_qsl(split.query, keep_blank_values=True))
q["embed"] = "1"
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
if ht in ("light", "dark"):
q["hub_theme"] = ht
dest = split.path or "/"
if q:
return f"{dest}?{urlencode(q)}"
return dest + "?embed=1"
split = urlsplit(path or "/")
tab = path_to_embed_tab(split.path)
q = dict(parse_qsl(split.query, keep_blank_values=True))
if tab:
q["tab"] = tab
q["embed"] = "1"
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
if ht in ("light", "dark"):
q["hub_theme"] = ht
return f"/embed?{urlencode(q)}"
q["embed"] = "1"
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
if ht in ("light", "dark"):
q["hub_theme"] = ht
dest = split.path or "/"
if split.query:
dest += "?" + split.query
if "embed=1" not in dest:
sep = "&" if "?" in dest else "?"
dest += f"{sep}embed=1"
if ht in ("light", "dark") and "hub_theme=" not in dest:
sep = "&" if "?" in dest else "?"
dest += f"{sep}hub_theme={ht}"
return dest
def attach_embed_templates(app: Flask, repo_root: str) -> None:
embed_dir = os.path.join(repo_root, "embed_templates")
if not os.path.isdir(embed_dir):
return
existing = app.jinja_loader
loaders = [FileSystemLoader(embed_dir)]
if existing is not None:
if isinstance(existing, ChoiceLoader):
loaders = list(existing.loaders) + loaders
else:
loaders.insert(0, existing)
app.jinja_loader = ChoiceLoader(loaders)
def register_embed_routes(
app: Flask,
login_required: Callable,
render_main_page_fn: Callable,
) -> None:
app.config["RENDER_MAIN_PAGE_FN"] = render_main_page_fn
@login_required
@app.route("/embed")
def embed_shell_page():
tab = (request.args.get("tab") or "trade").strip()
if tab not in EMBED_TABS:
tab = "trade"
session["hub_embed_shell"] = True
return render_main_page_fn(tab, embed_mode="shell")
@login_required
@app.route("/api/embed/page/<tab>")
def api_embed_page(tab: str):
tab = (tab or "").strip()
if tab not in EMBED_TABS:
return jsonify({"ok": False, "msg": "unknown tab"}), 404
html = render_main_page_fn(tab, embed_mode="fragment")
if isinstance(html, Response):
html = html.get_data(as_text=True)
return jsonify({"ok": True, "page": tab, "html": html})
def embed_context_extras(exchange_key: str) -> dict:
return {
"order_rule_tips_tpl": order_rule_tips_template(exchange_key),
"include_transfer_block": include_transfer_block(exchange_key),
}
"""中控 iframe:壳常驻 + tab 内容 API/embed、/api/embed/page/<tab>)。"""
from __future__ import annotations
from lib.paths import embed_templates_dir
import os
from typing import Callable
from urllib.parse import parse_qsl, urlencode, urlsplit
from flask import Flask, Response, jsonify, redirect, request, session
from jinja2 import ChoiceLoader, FileSystemLoader
EMBED_TABS: tuple[str, ...] = (
"key_monitor",
"trade",
"strategy",
"strategy_records",
"records",
"stats",
)
PATH_TO_EMBED_TAB: dict[str, str] = {
"/": "trade",
"/trade": "trade",
"/key_monitor": "key_monitor",
"/strategy": "strategy",
"/strategy/trend": "strategy",
"/strategy/roll": "strategy",
"/strategy/records": "strategy_records",
"/records": "records",
"/stats": "stats",
}
ORDER_RULE_TIPS_BY_EXCHANGE: dict[str, str] = {
"gate": "order_monitor_rule_tips_gate.html",
"gate_bot": "order_monitor_rule_tips_gate.html",
"binance": "order_monitor_rule_tips_binance.html",
"okx": "order_monitor_rule_tips_okx.html",
}
def order_rule_tips_template(exchange_key: str) -> str:
ex = (exchange_key or "").strip().lower()
return ORDER_RULE_TIPS_BY_EXCHANGE.get(ex, "order_monitor_rule_tips_gate.html")
def include_transfer_block(exchange_key: str) -> bool:
return (exchange_key or "").strip().lower() in ("gate", "gate_bot")
def path_to_embed_tab(path: str) -> str | None:
p = (path or "/").strip()
if not p.startswith("/"):
p = "/" + p
base = urlsplit(p).path.rstrip("/") or "/"
return PATH_TO_EMBED_TAB.get(base)
def embed_shell_enabled() -> bool:
return (os.getenv("HUB_EMBED_SHELL") or "1").strip().lower() in ("1", "true", "yes", "on")
def rewrite_embed_dest(path: str, hub_theme: str | None = None) -> str:
"""embed=1 打开时:/trade → /embed?tab=trade&embed=1"""
if not embed_shell_enabled():
split = urlsplit(path or "/")
q = dict(parse_qsl(split.query, keep_blank_values=True))
q["embed"] = "1"
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
if ht in ("light", "dark"):
q["hub_theme"] = ht
dest = split.path or "/"
if q:
return f"{dest}?{urlencode(q)}"
return dest + "?embed=1"
split = urlsplit(path or "/")
tab = path_to_embed_tab(split.path)
q = dict(parse_qsl(split.query, keep_blank_values=True))
if tab:
q["tab"] = tab
q["embed"] = "1"
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
if ht in ("light", "dark"):
q["hub_theme"] = ht
return f"/embed?{urlencode(q)}"
q["embed"] = "1"
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
if ht in ("light", "dark"):
q["hub_theme"] = ht
dest = split.path or "/"
if split.query:
dest += "?" + split.query
if "embed=1" not in dest:
sep = "&" if "?" in dest else "?"
dest += f"{sep}embed=1"
if ht in ("light", "dark") and "hub_theme=" not in dest:
sep = "&" if "?" in dest else "?"
dest += f"{sep}hub_theme={ht}"
return dest
def attach_embed_templates(app: Flask, repo_root: str) -> None:
embed_dir = embed_templates_dir(repo_root)
if not os.path.isdir(embed_dir):
return
existing = app.jinja_loader
loaders = [FileSystemLoader(embed_dir)]
if existing is not None:
if isinstance(existing, ChoiceLoader):
loaders = list(existing.loaders) + loaders
else:
loaders.insert(0, existing)
app.jinja_loader = ChoiceLoader(loaders)
def register_embed_routes(
app: Flask,
login_required: Callable,
render_main_page_fn: Callable,
) -> None:
app.config["RENDER_MAIN_PAGE_FN"] = render_main_page_fn
@login_required
@app.route("/embed")
def embed_shell_page():
tab = (request.args.get("tab") or "trade").strip()
if tab not in EMBED_TABS:
tab = "trade"
session["hub_embed_shell"] = True
return render_main_page_fn(tab, embed_mode="shell")
@login_required
@app.route("/api/embed/page/<tab>")
def api_embed_page(tab: str):
tab = (tab or "").strip()
if tab not in EMBED_TABS:
return jsonify({"ok": False, "msg": "unknown tab"}), 404
html = render_main_page_fn(tab, embed_mode="fragment")
if isinstance(html, Response):
html = html.get_data(as_text=True)
return jsonify({"ok": True, "page": tab, "html": html})
def embed_context_extras(exchange_key: str) -> dict:
return {
"order_rule_tips_tpl": order_rule_tips_template(exchange_key),
"include_transfer_block": include_transfer_block(exchange_key),
}
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
@@ -1,145 +1,145 @@
"""假突破关键位监控:BTC/ETH 限价挂单(共享计算与校验)。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Optional
FALSE_BREAKOUT_MONITOR_TYPE = "假突破"
FALSE_BREAKOUT_SYMBOLS = frozenset({"BTC/USDT", "ETH/USDT"})
FALSE_BREAKOUT_OFFSET_PCT = 0.1
FALSE_BREAKOUT_SL_PCT = 0.5
FALSE_BREAKOUT_RR = 1.5
FALSE_BREAKOUT_VALIDITY_HOURS = 24
def is_false_breakout_key_monitor_type(monitor_type: Optional[str]) -> bool:
return (monitor_type or "").strip() == FALSE_BREAKOUT_MONITOR_TYPE
def is_limit_key_monitor_type(monitor_type: Optional[str]) -> bool:
from fib_key_monitor_lib import is_fib_key_monitor_type
return is_fib_key_monitor_type(monitor_type) or is_false_breakout_key_monitor_type(monitor_type)
def normalize_false_breakout_symbol(symbol: Optional[str]) -> Optional[str]:
s = (symbol or "").strip().upper()
if not s:
return None
if "/" not in s:
s = f"{s}/USDT"
return s if s in FALSE_BREAKOUT_SYMBOLS else None
def storage_bounds_from_key_price(direction: str, key_price: float) -> tuple[float, float]:
k = float(key_price)
if k <= 0:
raise ValueError("关键价位须为正数")
d = (direction or "long").strip().lower()
if d == "short":
return k, k * 0.9999
if d == "long":
return k * 1.0001, k
raise ValueError("方向须为 long 或 short")
def key_price_from_row(direction: str, upper: Any, lower: Any) -> Optional[float]:
d = (direction or "long").strip().lower()
try:
if d == "short":
v = float(upper)
else:
v = float(lower)
except (TypeError, ValueError):
return None
return v if v > 0 else None
def calc_false_breakout_plan(direction: str, key_price: float) -> Optional[tuple[float, float, float]]:
try:
k = float(key_price)
except (TypeError, ValueError):
return None
if k <= 0:
return None
d = (direction or "long").strip().lower()
off = FALSE_BREAKOUT_OFFSET_PCT / 100.0
sl_pct = FALSE_BREAKOUT_SL_PCT / 100.0
rr = float(FALSE_BREAKOUT_RR)
if d == "short":
entry = k * (1 + off)
sl = entry * (1 + sl_pct)
risk = sl - entry
if risk <= 0:
return None
tp = entry - risk * rr
return entry, sl, tp
if d == "long":
entry = k * (1 - off)
sl = entry * (1 - sl_pct)
risk = entry - sl
if risk <= 0:
return None
tp = entry + risk * rr
return entry, sl, tp
return None
def _parse_created_at(raw: Any) -> Optional[datetime]:
s = str(raw or "").strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(s[:26], fmt)
except ValueError:
continue
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")[:32])
except ValueError:
return None
def is_false_breakout_expired(
created_at: Any,
now: datetime,
*,
hours: int = FALSE_BREAKOUT_VALIDITY_HOURS,
) -> bool:
dt = _parse_created_at(created_at)
if dt is None:
return False
return now >= dt + timedelta(hours=hours)
def expires_at_text(created_at: Any, *, hours: int = FALSE_BREAKOUT_VALIDITY_HOURS) -> str:
dt = _parse_created_at(created_at)
if dt is None:
return ""
return (dt + timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
def false_breakout_gate_preview(
*,
entry_display: str,
limit_order_id: Any = None,
created_at: Any = None,
now: Optional[datetime] = None,
hours: int = FALSE_BREAKOUT_VALIDITY_HOURS,
) -> dict[str, Any]:
"""假突破门控预览:限价挂单状态,不使用箱体/收敛的量破幅二确门控。"""
now_dt = now or datetime.now()
expired = is_false_breakout_expired(created_at, now_dt, hours=hours)
exp_txt = expires_at_text(created_at, hours=hours)
status = "已过期" if expired else "等待成交"
metrics_parts: list[str] = []
oid = str(limit_order_id or "").strip()
if oid:
metrics_parts.append(f"限价单:{oid}")
if exp_txt != "":
metrics_parts.append(f"截至:{exp_txt}")
return {
"summary": f"假突破 挂E={entry_display} {status}",
"metrics": " ".join(metrics_parts),
"gate_ok": not expired,
}
"""假突破关键位监控:BTC/ETH 限价挂单(共享计算与校验)。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Optional
FALSE_BREAKOUT_MONITOR_TYPE = "假突破"
FALSE_BREAKOUT_SYMBOLS = frozenset({"BTC/USDT", "ETH/USDT"})
FALSE_BREAKOUT_OFFSET_PCT = 0.1
FALSE_BREAKOUT_SL_PCT = 0.5
FALSE_BREAKOUT_RR = 1.5
FALSE_BREAKOUT_VALIDITY_HOURS = 24
def is_false_breakout_key_monitor_type(monitor_type: Optional[str]) -> bool:
return (monitor_type or "").strip() == FALSE_BREAKOUT_MONITOR_TYPE
def is_limit_key_monitor_type(monitor_type: Optional[str]) -> bool:
from lib.key_monitor.fib_key_monitor_lib import is_fib_key_monitor_type
return is_fib_key_monitor_type(monitor_type) or is_false_breakout_key_monitor_type(monitor_type)
def normalize_false_breakout_symbol(symbol: Optional[str]) -> Optional[str]:
s = (symbol or "").strip().upper()
if not s:
return None
if "/" not in s:
s = f"{s}/USDT"
return s if s in FALSE_BREAKOUT_SYMBOLS else None
def storage_bounds_from_key_price(direction: str, key_price: float) -> tuple[float, float]:
k = float(key_price)
if k <= 0:
raise ValueError("关键价位须为正数")
d = (direction or "long").strip().lower()
if d == "short":
return k, k * 0.9999
if d == "long":
return k * 1.0001, k
raise ValueError("方向须为 long 或 short")
def key_price_from_row(direction: str, upper: Any, lower: Any) -> Optional[float]:
d = (direction or "long").strip().lower()
try:
if d == "short":
v = float(upper)
else:
v = float(lower)
except (TypeError, ValueError):
return None
return v if v > 0 else None
def calc_false_breakout_plan(direction: str, key_price: float) -> Optional[tuple[float, float, float]]:
try:
k = float(key_price)
except (TypeError, ValueError):
return None
if k <= 0:
return None
d = (direction or "long").strip().lower()
off = FALSE_BREAKOUT_OFFSET_PCT / 100.0
sl_pct = FALSE_BREAKOUT_SL_PCT / 100.0
rr = float(FALSE_BREAKOUT_RR)
if d == "short":
entry = k * (1 + off)
sl = entry * (1 + sl_pct)
risk = sl - entry
if risk <= 0:
return None
tp = entry - risk * rr
return entry, sl, tp
if d == "long":
entry = k * (1 - off)
sl = entry * (1 - sl_pct)
risk = entry - sl
if risk <= 0:
return None
tp = entry + risk * rr
return entry, sl, tp
return None
def _parse_created_at(raw: Any) -> Optional[datetime]:
s = str(raw or "").strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(s[:26], fmt)
except ValueError:
continue
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")[:32])
except ValueError:
return None
def is_false_breakout_expired(
created_at: Any,
now: datetime,
*,
hours: int = FALSE_BREAKOUT_VALIDITY_HOURS,
) -> bool:
dt = _parse_created_at(created_at)
if dt is None:
return False
return now >= dt + timedelta(hours=hours)
def expires_at_text(created_at: Any, *, hours: int = FALSE_BREAKOUT_VALIDITY_HOURS) -> str:
dt = _parse_created_at(created_at)
if dt is None:
return ""
return (dt + timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
def false_breakout_gate_preview(
*,
entry_display: str,
limit_order_id: Any = None,
created_at: Any = None,
now: Optional[datetime] = None,
hours: int = FALSE_BREAKOUT_VALIDITY_HOURS,
) -> dict[str, Any]:
"""假突破门控预览:限价挂单状态,不使用箱体/收敛的量破幅二确门控。"""
now_dt = now or datetime.now()
expired = is_false_breakout_expired(created_at, now_dt, hours=hours)
exp_txt = expires_at_text(created_at, hours=hours)
status = "已过期" if expired else "等待成交"
metrics_parts: list[str] = []
oid = str(limit_order_id or "").strip()
if oid:
metrics_parts.append(f"限价单:{oid}")
if exp_txt != "":
metrics_parts.append(f"截至:{exp_txt}")
return {
"summary": f"假突破 挂E={entry_display} {status}",
"metrics": " ".join(metrics_parts),
"gate_ok": not expired,
}
@@ -1,140 +1,140 @@
"""斐波关键位监控:纯计算与类型判断(Gate / Binance 主站共用)。"""
from key_monitor_lib import KEY_MONITOR_AUTO_TYPES
FIB_KEY_MONITOR_TYPES = frozenset({"斐波回调0.618", "斐波回调0.786"})
KEY_MONITOR_TRADE_TYPE = "关键位监控"
FIB_RATIO_BY_TYPE = {
"斐波回调0.618": 0.618,
"斐波回调0.786": 0.786,
}
def is_fib_key_monitor_type(monitor_type):
return (monitor_type or "").strip() in FIB_KEY_MONITOR_TYPES
def fib_ratio_from_type(monitor_type):
return FIB_RATIO_BY_TYPE.get((monitor_type or "").strip())
def calc_fib_plan(direction, upper, lower, ratio):
"""
上沿 H下沿 LH > L
做多 H 向下回撤 ratioE = H - ratio*(H-L)SL=LTP=H
做空 L 向上反弹 ratioE = L + ratio*(H-L)SL=HTP=L
返回 (entry, stop_loss, take_profit) None
"""
try:
h = float(upper)
l = float(lower)
r = float(ratio)
except (TypeError, ValueError):
return None
if h <= l or r <= 0 or r >= 1:
return None
span = h - l
direction = (direction or "long").strip().lower()
if direction == "short":
entry = l + r * span
return entry, h, l
entry = h - r * span
return entry, l, h
def stored_key_signal_type(monitor_type):
"""写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波/假突破/触价开仓)。"""
mt = (monitor_type or "").strip()
if mt in FIB_KEY_MONITOR_TYPES:
return mt
if mt in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"):
return mt if mt != "触价开仓" else "回调触价开仓"
if mt in KEY_MONITOR_AUTO_TYPES:
return mt
return None
KEY_ENTRY_REASON_BY_SIGNAL = {
"箱体突破": "关键位箱体突破",
"收敛突破": "关键位收敛突破",
"斐波回调0.618": "关键位斐波0.618",
"斐波回调0.786": "关键位斐波0.786",
"假突破": "关键位假突破",
"回调触价开仓": "关键位回调触价开仓",
"突破触价开仓": "关键位突破触价开仓",
"触价开仓": "关键位触价开仓",
"趋势回调": "趋势回调",
}
def entry_reason_from_key_signal(key_signal_type):
return KEY_ENTRY_REASON_BY_SIGNAL.get((key_signal_type or "").strip())
def key_signal_type_for_trade_record(key_signal_type, box_auto_types):
"""平仓写入 trade_records 时保留箱体/收敛/斐波/假突破来源。"""
kst = (key_signal_type or "").strip()
if kst in FIB_KEY_MONITOR_TYPES:
return kst
if kst in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"):
return kst if kst != "触价开仓" else "回调触价开仓"
if box_auto_types and kst in box_auto_types:
return kst
return None
def backfill_missing_key_signal_types(conn, *, monitor_type: str = KEY_MONITOR_TRADE_TYPE) -> int:
"""补全历史 trade_records / order_monitors 中缺失的箱体/收敛 key_signal_type。"""
mt = (monitor_type or KEY_MONITOR_TRADE_TYPE).strip()
updated = 0
for signal in KEY_MONITOR_AUTO_TYPES:
entry_reason = KEY_ENTRY_REASON_BY_SIGNAL.get(signal)
if entry_reason:
cur = conn.execute(
"""UPDATE trade_records SET key_signal_type=?
WHERE monitor_type=? AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')
AND TRIM(COALESCE(entry_reason, ''))=?""",
(signal, mt, entry_reason),
)
updated += int(cur.rowcount or 0)
rows = conn.execute(
"""SELECT id, symbol, opened_at FROM trade_records
WHERE monitor_type=? AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')""",
(mt,),
).fetchall()
for row in rows:
# init_db 连接未设 row_factory,结果为 tuple
rid, sym, opened_at = row[0], row[1], row[2]
opened = (opened_at or "").strip()
for signal in KEY_MONITOR_AUTO_TYPES:
hist = conn.execute(
"""SELECT monitor_type FROM key_monitor_history
WHERE symbol=? AND monitor_type=? AND close_reason='auto_opened'
AND (?='' OR closed_at <= ?)
ORDER BY closed_at DESC LIMIT 1""",
(sym, signal, opened, opened),
).fetchone()
if not hist:
continue
conn.execute(
"UPDATE trade_records SET key_signal_type=? WHERE id=?",
(signal, rid),
)
updated += 1
break
return updated
def fib_invalidate_by_mark(direction, mark_price, upper, lower):
"""先触达止盈侧(标记价)则失效。多:mark>=H;空:mark<=L。"""
try:
m = float(mark_price)
h = float(upper)
l = float(lower)
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "short":
return m <= l
return m >= h
"""斐波关键位监控:纯计算与类型判断(Gate / Binance 主站共用)。"""
from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES
FIB_KEY_MONITOR_TYPES = frozenset({"斐波回调0.618", "斐波回调0.786"})
KEY_MONITOR_TRADE_TYPE = "关键位监控"
FIB_RATIO_BY_TYPE = {
"斐波回调0.618": 0.618,
"斐波回调0.786": 0.786,
}
def is_fib_key_monitor_type(monitor_type):
return (monitor_type or "").strip() in FIB_KEY_MONITOR_TYPES
def fib_ratio_from_type(monitor_type):
return FIB_RATIO_BY_TYPE.get((monitor_type or "").strip())
def calc_fib_plan(direction, upper, lower, ratio):
"""
上沿 H下沿 LH > L
做多 H 向下回撤 ratioE = H - ratio*(H-L)SL=LTP=H
做空 L 向上反弹 ratioE = L + ratio*(H-L)SL=HTP=L
返回 (entry, stop_loss, take_profit) None
"""
try:
h = float(upper)
l = float(lower)
r = float(ratio)
except (TypeError, ValueError):
return None
if h <= l or r <= 0 or r >= 1:
return None
span = h - l
direction = (direction or "long").strip().lower()
if direction == "short":
entry = l + r * span
return entry, h, l
entry = h - r * span
return entry, l, h
def stored_key_signal_type(monitor_type):
"""写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波/假突破/触价开仓)。"""
mt = (monitor_type or "").strip()
if mt in FIB_KEY_MONITOR_TYPES:
return mt
if mt in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"):
return mt if mt != "触价开仓" else "回调触价开仓"
if mt in KEY_MONITOR_AUTO_TYPES:
return mt
return None
KEY_ENTRY_REASON_BY_SIGNAL = {
"箱体突破": "关键位箱体突破",
"收敛突破": "关键位收敛突破",
"斐波回调0.618": "关键位斐波0.618",
"斐波回调0.786": "关键位斐波0.786",
"假突破": "关键位假突破",
"回调触价开仓": "关键位回调触价开仓",
"突破触价开仓": "关键位突破触价开仓",
"触价开仓": "关键位触价开仓",
"趋势回调": "趋势回调",
}
def entry_reason_from_key_signal(key_signal_type):
return KEY_ENTRY_REASON_BY_SIGNAL.get((key_signal_type or "").strip())
def key_signal_type_for_trade_record(key_signal_type, box_auto_types):
"""平仓写入 trade_records 时保留箱体/收敛/斐波/假突破来源。"""
kst = (key_signal_type or "").strip()
if kst in FIB_KEY_MONITOR_TYPES:
return kst
if kst in ("假突破", "回调触价开仓", "突破触价开仓", "触价开仓"):
return kst if kst != "触价开仓" else "回调触价开仓"
if box_auto_types and kst in box_auto_types:
return kst
return None
def backfill_missing_key_signal_types(conn, *, monitor_type: str = KEY_MONITOR_TRADE_TYPE) -> int:
"""补全历史 trade_records / order_monitors 中缺失的箱体/收敛 key_signal_type。"""
mt = (monitor_type or KEY_MONITOR_TRADE_TYPE).strip()
updated = 0
for signal in KEY_MONITOR_AUTO_TYPES:
entry_reason = KEY_ENTRY_REASON_BY_SIGNAL.get(signal)
if entry_reason:
cur = conn.execute(
"""UPDATE trade_records SET key_signal_type=?
WHERE monitor_type=? AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')
AND TRIM(COALESCE(entry_reason, ''))=?""",
(signal, mt, entry_reason),
)
updated += int(cur.rowcount or 0)
rows = conn.execute(
"""SELECT id, symbol, opened_at FROM trade_records
WHERE monitor_type=? AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')""",
(mt,),
).fetchall()
for row in rows:
# init_db 连接未设 row_factory,结果为 tuple
rid, sym, opened_at = row[0], row[1], row[2]
opened = (opened_at or "").strip()
for signal in KEY_MONITOR_AUTO_TYPES:
hist = conn.execute(
"""SELECT monitor_type FROM key_monitor_history
WHERE symbol=? AND monitor_type=? AND close_reason='auto_opened'
AND (?='' OR closed_at <= ?)
ORDER BY closed_at DESC LIMIT 1""",
(sym, signal, opened, opened),
).fetchone()
if not hist:
continue
conn.execute(
"UPDATE trade_records SET key_signal_type=? WHERE id=?",
(signal, rid),
)
updated += 1
break
return updated
def fib_invalidate_by_mark(direction, mark_price, upper, lower):
"""先触达止盈侧(标记价)则失效。多:mark>=H;空:mark<=L。"""
try:
m = float(mark_price)
h = float(upper)
l = float(lower)
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "short":
return m <= l
return m >= h
@@ -1,61 +1,61 @@
"""
全仓杠杆模式下撤销已添加的箱体/收敛/斐波关键位监控并微信说明
"""
from __future__ import annotations
from typing import Any, Callable, Iterable, Optional
from fib_key_monitor_lib import FIB_KEY_MONITOR_TYPES, is_fib_key_monitor_type
from false_breakout_key_monitor_lib import is_false_breakout_key_monitor_type
from key_monitor_lib import KEY_MONITOR_AUTO_TYPES
from position_sizing_lib import is_full_margin_mode, mode_label_zh
def monitor_type_disallowed_in_full_margin(monitor_type: str) -> bool:
mt = (monitor_type or "").strip()
if mt in KEY_MONITOR_AUTO_TYPES:
return True
if is_fib_key_monitor_type(mt):
return True
return is_false_breakout_key_monitor_type(mt)
def purge_disallowed_key_monitors(
conn: Any,
*,
sizing_mode: str,
select_rows: Callable[[Any], Iterable[Any]],
cancel_fib_limit: Callable[[Any], None],
delete_monitor: Callable[[Any, int], None],
send_wechat: Callable[[str], None],
row_symbol: Callable[[Any], str] = lambda r: str(r["symbol"] or ""),
row_monitor_type: Callable[[Any], str] = lambda r: str(r["monitor_type"] or ""),
row_id: Callable[[Any], int] = lambda r: int(r["id"]),
) -> int:
if not is_full_margin_mode(sizing_mode):
return 0
removed = []
for row in select_rows(conn):
mt = row_monitor_type(row)
if not monitor_type_disallowed_in_full_margin(mt):
continue
sym = row_symbol(row)
kid = row_id(row)
if is_fib_key_monitor_type(mt) or is_false_breakout_key_monitor_type(mt):
try:
cancel_fib_limit(row)
except Exception:
pass
delete_monitor(conn, kid)
removed.append((sym, mt, kid))
if removed:
lines = [f"· {s} {t} (#{i})" for s, t, i in removed[:12]]
if len(removed) > 12:
lines.append(f"… 共 {len(removed)}")
send_wechat(
"# ⚠️ 全仓杠杆模式:已自动撤销关键位监控\n"
f"计仓模式:{mode_label_zh(sizing_mode)}(仅 env 可切换,须无仓)\n"
"已撤销:箱体突破 / 收敛突破 / 斐波回调 / 假突破监控(不可与全仓杠杆并存)\n"
+ "\n".join(lines)
)
return len(removed)
"""
全仓杠杆模式下撤销已添加的箱体/收敛/斐波关键位监控并微信说明
"""
from __future__ import annotations
from typing import Any, Callable, Iterable, Optional
from lib.key_monitor.fib_key_monitor_lib import FIB_KEY_MONITOR_TYPES, is_fib_key_monitor_type
from lib.key_monitor.false_breakout_key_monitor_lib import is_false_breakout_key_monitor_type
from lib.key_monitor.key_monitor_lib import KEY_MONITOR_AUTO_TYPES
from lib.trade.position_sizing_lib import is_full_margin_mode, mode_label_zh
def monitor_type_disallowed_in_full_margin(monitor_type: str) -> bool:
mt = (monitor_type or "").strip()
if mt in KEY_MONITOR_AUTO_TYPES:
return True
if is_fib_key_monitor_type(mt):
return True
return is_false_breakout_key_monitor_type(mt)
def purge_disallowed_key_monitors(
conn: Any,
*,
sizing_mode: str,
select_rows: Callable[[Any], Iterable[Any]],
cancel_fib_limit: Callable[[Any], None],
delete_monitor: Callable[[Any, int], None],
send_wechat: Callable[[str], None],
row_symbol: Callable[[Any], str] = lambda r: str(r["symbol"] or ""),
row_monitor_type: Callable[[Any], str] = lambda r: str(r["monitor_type"] or ""),
row_id: Callable[[Any], int] = lambda r: int(r["id"]),
) -> int:
if not is_full_margin_mode(sizing_mode):
return 0
removed = []
for row in select_rows(conn):
mt = row_monitor_type(row)
if not monitor_type_disallowed_in_full_margin(mt):
continue
sym = row_symbol(row)
kid = row_id(row)
if is_fib_key_monitor_type(mt) or is_false_breakout_key_monitor_type(mt):
try:
cancel_fib_limit(row)
except Exception:
pass
delete_monitor(conn, kid)
removed.append((sym, mt, kid))
if removed:
lines = [f"· {s} {t} (#{i})" for s, t, i in removed[:12]]
if len(removed) > 12:
lines.append(f"… 共 {len(removed)}")
send_wechat(
"# ⚠️ 全仓杠杆模式:已自动撤销关键位监控\n"
f"计仓模式:{mode_label_zh(sizing_mode)}(仅 env 可切换,须无仓)\n"
"已撤销:箱体突破 / 收敛突破 / 斐波回调 / 假突破监控(不可与全仓杠杆并存)\n"
+ "\n".join(lines)
)
return len(removed)
@@ -1,390 +1,390 @@
"""
关键位监控阻力/支撑双向提醒与箱体/收敛自动门控的共享逻辑
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
KEY_MONITOR_RS_TYPE = "关键支撑阻力"
KEY_MONITOR_RS_LEGACY_TYPES = frozenset({"关键阻力位", "关键支撑位"})
KEY_MONITOR_RS_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES
KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES
KEY_DIRECTION_WATCH = "watch"
def is_rs_key_monitor_type(monitor_type: str) -> bool:
return (monitor_type or "").strip() in KEY_MONITOR_RS_TYPES
def rs_monitor_type_label(monitor_type: str) -> str:
"""展示用:旧库里的阻力/支撑合并为「关键支撑阻力」。"""
if is_rs_key_monitor_type(monitor_type):
return KEY_MONITOR_RS_TYPE
return (monitor_type or "").strip()
def rs_monitor_type_for_storage(monitor_type: str) -> str:
if is_rs_key_monitor_type(monitor_type):
return KEY_MONITOR_RS_TYPE
return (monitor_type or "").strip()
def calc_breakout_breach_pct(direction: str, close: float, upper: float, lower: float) -> float:
"""突破 K 收盘相对关键位的越过幅度(%)。未越过对应边界时返回 0。"""
direction = (direction or "long").strip().lower()
c = float(close)
if direction == "long":
u = float(upper)
if u <= 0 or c <= u:
return 0.0
return (c - u) / u * 100.0
lo = float(lower)
if lo <= 0 or c >= lo:
return 0.0
return (lo - c) / lo * 100.0
def auto_amp_ok(
direction: str,
close_b: float,
upper: float,
lower: float,
min_pct: float,
) -> tuple[bool, float]:
breach = calc_breakout_breach_pct(direction, close_b, upper, lower)
return breach > float(min_pct), breach
def auto_confirm_ok(direction: str, cfm_close: float, upper: float, lower: float) -> bool:
"""确认 K 收盘须在箱体外(不得回到 [lower, upper] 内)。"""
direction = (direction or "long").strip().lower()
c = float(cfm_close)
if direction == "long":
return c > float(upper)
return c < float(lower)
BOX_BREAKOUT_CLOSE_OPPOSITE = "box_opposite_break"
def box_breakout_invalidate_by_mark(
direction: str, mark_price: float, upper: float, lower: float
) -> bool:
"""箱体/收敛:标记价先突破反向边界则失效。多:mark<=L;空:mark>=H。"""
try:
m = float(mark_price)
h = float(upper)
lo = float(lower)
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "short":
return m >= h
return m <= lo
def box_breakout_invalidate_edge_label(direction: str) -> str:
direction = (direction or "long").strip().lower()
return "下沿" if direction == "long" else "上沿"
def detect_rs_box_break(close: float, upper: float, lower: float) -> Optional[dict[str, Any]]:
"""
阻力/支撑人工盯盘最近 5m 收盘突破上沿或下沿严格 > / <
上沿优先同一根 K 不可能同时满足两者
"""
u, lo, c = float(upper), float(lower), float(close)
if c > u:
return {
"break_side": "upper",
"direction": "long",
"edge_price": u,
"key_price": u,
"break_label": "向上突破上沿",
}
if c < lo:
return {
"break_side": "lower",
"direction": "short",
"edge_price": lo,
"key_price": lo,
"break_label": "向下突破下沿",
}
return None
def rs_break_from_direction(direction: str, upper: float, lower: float) -> Optional[dict[str, Any]]:
"""已触发后根据入库方向还原突破边(long=上沿,short=下沿)。"""
d = (direction or "").strip().lower()
if d == "long":
return {
"break_side": "upper",
"direction": "long",
"edge_price": float(upper),
"key_price": float(upper),
"break_label": "向上突破上沿",
}
if d == "short":
return {
"break_side": "lower",
"direction": "short",
"edge_price": float(lower),
"key_price": float(lower),
"break_label": "向下突破下沿",
}
return None
def rs_break_infer_from_close(close: float, upper: float, lower: float) -> dict[str, Any]:
"""
续发提醒时价格已回到箱体内按收盘价相对箱体中线推断首次突破边
保证第 2/3 次企业微信提醒仍能发出
"""
mid = (float(upper) + float(lower)) / 2.0
if float(close) >= mid:
br = rs_break_from_direction("long", upper, lower)
else:
br = rs_break_from_direction("short", upper, lower)
if br:
return br
return {
"break_side": "upper",
"direction": "long",
"edge_price": float(upper),
"key_price": float(upper),
"break_label": "向上突破上沿",
}
def _parse_notify_datetime(raw: Optional[str]) -> Optional[datetime]:
s = str(raw or "").strip()
if not s:
return None
try:
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
if dt.tzinfo is not None:
dt = dt.replace(tzinfo=None)
return dt
except Exception:
pass
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(s[:19], fmt)
except Exception:
continue
return None
def claim_rs_level_notify(
conn: Any,
monitor_id: int,
notify_index: int,
direction: str,
notified_at: str,
bar_ts: Optional[int],
*,
prior_count: Optional[int] = None,
) -> bool:
"""
原子占位仅在 notification_count 仍为 prior_count 时推进到 notify_index
须在发送企业微信之前调用并 commit避免 (2/3) 重复刷屏
"""
prior = int(prior_count if prior_count is not None else notify_index - 1)
if prior < 0 or notify_index != prior + 1:
return False
bar_val: Optional[int] = None
if bar_ts is not None:
try:
bar_val = int(bar_ts)
except (TypeError, ValueError):
bar_val = None
cur = conn.execute(
"UPDATE key_monitors SET notification_count=?, direction=?, last_notified_at=?, last_rs_bar_ts=? "
"WHERE id=? AND COALESCE(notification_count,0)=?",
(notify_index, direction, notified_at, bar_val, int(monitor_id), prior),
)
return int(cur.rowcount or 0) > 0
def parse_last_rs_bar_ts(row: Any) -> Optional[int]:
if row is None:
return None
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
raw = row["last_rs_bar_ts"] if "last_rs_bar_ts" in keys else None
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def run_rs_level_alert_tick(
row: Any,
close: float,
bar_ts: Optional[int],
now_dt: datetime,
*,
default_max_notify: int,
default_interval_min: int,
) -> Optional[dict[str, Any]]:
"""
判定本轮回合是否应推送阻力/支撑提醒
首条仅在新闭合 K 越线时触发发送前须 claim_rs_level_notify 占位防轮询/多进程重复
"""
up, lo = float(row["upper"]), float(row["lower"])
if up <= lo:
return None
count = int(row["notification_count"] or 0)
max_n = max(1, int(row["max_notify"] or default_max_notify))
interval = max(1, int(row["notify_interval_min"] or default_interval_min))
if count >= max_n:
return None
bar_ts_i: Optional[int] = None
if bar_ts is not None:
try:
bar_ts_i = int(bar_ts)
except (TypeError, ValueError):
bar_ts_i = None
last_bar_i = parse_last_rs_bar_ts(row)
if count == 0:
br = detect_rs_box_break(close, up, lo)
if not br:
return None
if bar_ts_i is not None and last_bar_i is not None and bar_ts_i == last_bar_i:
return None
return {
"break_info": br,
"notify_index": 1,
"prior_count": 0,
"notify_max": max_n,
"interval_min": interval,
"bar_ts": bar_ts_i,
}
if not notify_interval_elapsed(row["last_notified_at"], interval, now_dt):
return None
br = resolve_rs_break_for_alert(count, row["direction"], close, up, lo)
if not br:
return None
return {
"break_info": br,
"notify_index": count + 1,
"prior_count": count,
"notify_max": max_n,
"interval_min": interval,
"bar_ts": bar_ts_i,
}
def resolve_rs_break_for_alert(
notification_count: int,
direction: Optional[str],
close: float,
upper: float,
lower: float,
) -> Optional[dict[str, Any]]:
"""
阻力/支撑提醒首次用 5m 收盘越线判定后续用已存方向兼容 direction=watch
"""
count = int(notification_count or 0)
up, lo, c = float(upper), float(lower), float(close)
if count <= 0:
return detect_rs_box_break(c, up, lo)
br = rs_break_from_direction(direction, up, lo)
if br:
return br
d = (direction or "").strip().lower()
if d not in ("", KEY_DIRECTION_WATCH):
return None
br = detect_rs_box_break(c, up, lo)
if br:
return br
return rs_break_infer_from_close(c, up, lo)
def notify_interval_elapsed(
last_notified_at: Optional[str],
interval_min: int,
now_dt: datetime,
) -> bool:
if not last_notified_at:
return False
last_dt = _parse_notify_datetime(last_notified_at)
if last_dt is None:
return False
return (now_dt - last_dt).total_seconds() >= max(1, int(interval_min)) * 60
def format_auto_amp_line(amp_ok: bool, amp_pct: float, min_pct: float) -> str:
return (
f"突破越过幅度:{'通过' if amp_ok else '不通过'}"
f"{round(float(amp_pct), 4)}%,要求 > {min_pct}%"
)
def format_auto_confirm_line(confirm_ok: bool, cfm_close, edge_price, direction: str) -> str:
side = "箱外上方" if (direction or "").lower() == "long" else "箱外下方"
return (
f"第二根确认:{'通过' if confirm_ok else '不通过'}"
f"(确认收盘 {cfm_close},须收于{side},关键位 {edge_price}"
)
def key_monitor_rule_template_context(
*,
kline_timeframe: str,
key_breakout_amp_min_pct: float,
key_volume_ma_bars: int,
key_volume_ratio_min: float,
key_auto_min_planned_rr: float,
key_daily_volume_rank_max: int,
key_confirm_breakout_bar: int,
key_confirm_bar: int,
key_alert_max_times: int,
key_alert_interval_minutes: int,
key_stop_outside_breakout_pct: float,
key_trend_stop_outside_pct: float,
false_breakout_validity_hours: int,
trigger_entry_validity_hours: int | None = None,
) -> dict[str, Any]:
"""关键位监控页规则说明表格(Jinja key_rule_ctx)。"""
from false_breakout_key_monitor_lib import (
FALSE_BREAKOUT_OFFSET_PCT,
FALSE_BREAKOUT_RR,
FALSE_BREAKOUT_SL_PCT,
)
from trigger_entry_key_monitor_lib import TRIGGER_ENTRY_VALIDITY_HOURS
te_hours = (
int(trigger_entry_validity_hours)
if trigger_entry_validity_hours is not None
else TRIGGER_ENTRY_VALIDITY_HOURS
)
return {
"tf": (kline_timeframe or "5m").strip(),
"amp_min_pct": key_breakout_amp_min_pct,
"vol_ma_bars": key_volume_ma_bars,
"vol_ratio_min": key_volume_ratio_min,
"min_rr": key_auto_min_planned_rr,
"vol_rank_max": key_daily_volume_rank_max,
"breakout_bar": key_confirm_breakout_bar,
"confirm_bar": key_confirm_bar,
"alert_max": key_alert_max_times,
"alert_interval_min": key_alert_interval_minutes,
"stop_outside_pct": key_stop_outside_breakout_pct,
"trend_stop_outside_pct": key_trend_stop_outside_pct,
"fb_offset_pct": FALSE_BREAKOUT_OFFSET_PCT,
"fb_sl_pct": FALSE_BREAKOUT_SL_PCT,
"fb_rr": FALSE_BREAKOUT_RR,
"fb_valid_hours": false_breakout_validity_hours,
"trigger_entry_validity_hours": te_hours,
}
"""
关键位监控阻力/支撑双向提醒与箱体/收敛自动门控的共享逻辑
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"})
KEY_MONITOR_RS_TYPE = "关键支撑阻力"
KEY_MONITOR_RS_LEGACY_TYPES = frozenset({"关键阻力位", "关键支撑位"})
KEY_MONITOR_RS_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES
KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({KEY_MONITOR_RS_TYPE}) | KEY_MONITOR_RS_LEGACY_TYPES
KEY_DIRECTION_WATCH = "watch"
def is_rs_key_monitor_type(monitor_type: str) -> bool:
return (monitor_type or "").strip() in KEY_MONITOR_RS_TYPES
def rs_monitor_type_label(monitor_type: str) -> str:
"""展示用:旧库里的阻力/支撑合并为「关键支撑阻力」。"""
if is_rs_key_monitor_type(monitor_type):
return KEY_MONITOR_RS_TYPE
return (monitor_type or "").strip()
def rs_monitor_type_for_storage(monitor_type: str) -> str:
if is_rs_key_monitor_type(monitor_type):
return KEY_MONITOR_RS_TYPE
return (monitor_type or "").strip()
def calc_breakout_breach_pct(direction: str, close: float, upper: float, lower: float) -> float:
"""突破 K 收盘相对关键位的越过幅度(%)。未越过对应边界时返回 0。"""
direction = (direction or "long").strip().lower()
c = float(close)
if direction == "long":
u = float(upper)
if u <= 0 or c <= u:
return 0.0
return (c - u) / u * 100.0
lo = float(lower)
if lo <= 0 or c >= lo:
return 0.0
return (lo - c) / lo * 100.0
def auto_amp_ok(
direction: str,
close_b: float,
upper: float,
lower: float,
min_pct: float,
) -> tuple[bool, float]:
breach = calc_breakout_breach_pct(direction, close_b, upper, lower)
return breach > float(min_pct), breach
def auto_confirm_ok(direction: str, cfm_close: float, upper: float, lower: float) -> bool:
"""确认 K 收盘须在箱体外(不得回到 [lower, upper] 内)。"""
direction = (direction or "long").strip().lower()
c = float(cfm_close)
if direction == "long":
return c > float(upper)
return c < float(lower)
BOX_BREAKOUT_CLOSE_OPPOSITE = "box_opposite_break"
def box_breakout_invalidate_by_mark(
direction: str, mark_price: float, upper: float, lower: float
) -> bool:
"""箱体/收敛:标记价先突破反向边界则失效。多:mark<=L;空:mark>=H。"""
try:
m = float(mark_price)
h = float(upper)
lo = float(lower)
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "short":
return m >= h
return m <= lo
def box_breakout_invalidate_edge_label(direction: str) -> str:
direction = (direction or "long").strip().lower()
return "下沿" if direction == "long" else "上沿"
def detect_rs_box_break(close: float, upper: float, lower: float) -> Optional[dict[str, Any]]:
"""
阻力/支撑人工盯盘最近 5m 收盘突破上沿或下沿严格 > / <
上沿优先同一根 K 不可能同时满足两者
"""
u, lo, c = float(upper), float(lower), float(close)
if c > u:
return {
"break_side": "upper",
"direction": "long",
"edge_price": u,
"key_price": u,
"break_label": "向上突破上沿",
}
if c < lo:
return {
"break_side": "lower",
"direction": "short",
"edge_price": lo,
"key_price": lo,
"break_label": "向下突破下沿",
}
return None
def rs_break_from_direction(direction: str, upper: float, lower: float) -> Optional[dict[str, Any]]:
"""已触发后根据入库方向还原突破边(long=上沿,short=下沿)。"""
d = (direction or "").strip().lower()
if d == "long":
return {
"break_side": "upper",
"direction": "long",
"edge_price": float(upper),
"key_price": float(upper),
"break_label": "向上突破上沿",
}
if d == "short":
return {
"break_side": "lower",
"direction": "short",
"edge_price": float(lower),
"key_price": float(lower),
"break_label": "向下突破下沿",
}
return None
def rs_break_infer_from_close(close: float, upper: float, lower: float) -> dict[str, Any]:
"""
续发提醒时价格已回到箱体内按收盘价相对箱体中线推断首次突破边
保证第 2/3 次企业微信提醒仍能发出
"""
mid = (float(upper) + float(lower)) / 2.0
if float(close) >= mid:
br = rs_break_from_direction("long", upper, lower)
else:
br = rs_break_from_direction("short", upper, lower)
if br:
return br
return {
"break_side": "upper",
"direction": "long",
"edge_price": float(upper),
"key_price": float(upper),
"break_label": "向上突破上沿",
}
def _parse_notify_datetime(raw: Optional[str]) -> Optional[datetime]:
s = str(raw or "").strip()
if not s:
return None
try:
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
if dt.tzinfo is not None:
dt = dt.replace(tzinfo=None)
return dt
except Exception:
pass
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(s[:19], fmt)
except Exception:
continue
return None
def claim_rs_level_notify(
conn: Any,
monitor_id: int,
notify_index: int,
direction: str,
notified_at: str,
bar_ts: Optional[int],
*,
prior_count: Optional[int] = None,
) -> bool:
"""
原子占位仅在 notification_count 仍为 prior_count 时推进到 notify_index
须在发送企业微信之前调用并 commit避免 (2/3) 重复刷屏
"""
prior = int(prior_count if prior_count is not None else notify_index - 1)
if prior < 0 or notify_index != prior + 1:
return False
bar_val: Optional[int] = None
if bar_ts is not None:
try:
bar_val = int(bar_ts)
except (TypeError, ValueError):
bar_val = None
cur = conn.execute(
"UPDATE key_monitors SET notification_count=?, direction=?, last_notified_at=?, last_rs_bar_ts=? "
"WHERE id=? AND COALESCE(notification_count,0)=?",
(notify_index, direction, notified_at, bar_val, int(monitor_id), prior),
)
return int(cur.rowcount or 0) > 0
def parse_last_rs_bar_ts(row: Any) -> Optional[int]:
if row is None:
return None
try:
keys = row.keys() if hasattr(row, "keys") else []
except Exception:
keys = []
raw = row["last_rs_bar_ts"] if "last_rs_bar_ts" in keys else None
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def run_rs_level_alert_tick(
row: Any,
close: float,
bar_ts: Optional[int],
now_dt: datetime,
*,
default_max_notify: int,
default_interval_min: int,
) -> Optional[dict[str, Any]]:
"""
判定本轮回合是否应推送阻力/支撑提醒
首条仅在新闭合 K 越线时触发发送前须 claim_rs_level_notify 占位防轮询/多进程重复
"""
up, lo = float(row["upper"]), float(row["lower"])
if up <= lo:
return None
count = int(row["notification_count"] or 0)
max_n = max(1, int(row["max_notify"] or default_max_notify))
interval = max(1, int(row["notify_interval_min"] or default_interval_min))
if count >= max_n:
return None
bar_ts_i: Optional[int] = None
if bar_ts is not None:
try:
bar_ts_i = int(bar_ts)
except (TypeError, ValueError):
bar_ts_i = None
last_bar_i = parse_last_rs_bar_ts(row)
if count == 0:
br = detect_rs_box_break(close, up, lo)
if not br:
return None
if bar_ts_i is not None and last_bar_i is not None and bar_ts_i == last_bar_i:
return None
return {
"break_info": br,
"notify_index": 1,
"prior_count": 0,
"notify_max": max_n,
"interval_min": interval,
"bar_ts": bar_ts_i,
}
if not notify_interval_elapsed(row["last_notified_at"], interval, now_dt):
return None
br = resolve_rs_break_for_alert(count, row["direction"], close, up, lo)
if not br:
return None
return {
"break_info": br,
"notify_index": count + 1,
"prior_count": count,
"notify_max": max_n,
"interval_min": interval,
"bar_ts": bar_ts_i,
}
def resolve_rs_break_for_alert(
notification_count: int,
direction: Optional[str],
close: float,
upper: float,
lower: float,
) -> Optional[dict[str, Any]]:
"""
阻力/支撑提醒首次用 5m 收盘越线判定后续用已存方向兼容 direction=watch
"""
count = int(notification_count or 0)
up, lo, c = float(upper), float(lower), float(close)
if count <= 0:
return detect_rs_box_break(c, up, lo)
br = rs_break_from_direction(direction, up, lo)
if br:
return br
d = (direction or "").strip().lower()
if d not in ("", KEY_DIRECTION_WATCH):
return None
br = detect_rs_box_break(c, up, lo)
if br:
return br
return rs_break_infer_from_close(c, up, lo)
def notify_interval_elapsed(
last_notified_at: Optional[str],
interval_min: int,
now_dt: datetime,
) -> bool:
if not last_notified_at:
return False
last_dt = _parse_notify_datetime(last_notified_at)
if last_dt is None:
return False
return (now_dt - last_dt).total_seconds() >= max(1, int(interval_min)) * 60
def format_auto_amp_line(amp_ok: bool, amp_pct: float, min_pct: float) -> str:
return (
f"突破越过幅度:{'通过' if amp_ok else '不通过'}"
f"{round(float(amp_pct), 4)}%,要求 > {min_pct}%"
)
def format_auto_confirm_line(confirm_ok: bool, cfm_close, edge_price, direction: str) -> str:
side = "箱外上方" if (direction or "").lower() == "long" else "箱外下方"
return (
f"第二根确认:{'通过' if confirm_ok else '不通过'}"
f"(确认收盘 {cfm_close},须收于{side},关键位 {edge_price}"
)
def key_monitor_rule_template_context(
*,
kline_timeframe: str,
key_breakout_amp_min_pct: float,
key_volume_ma_bars: int,
key_volume_ratio_min: float,
key_auto_min_planned_rr: float,
key_daily_volume_rank_max: int,
key_confirm_breakout_bar: int,
key_confirm_bar: int,
key_alert_max_times: int,
key_alert_interval_minutes: int,
key_stop_outside_breakout_pct: float,
key_trend_stop_outside_pct: float,
false_breakout_validity_hours: int,
trigger_entry_validity_hours: int | None = None,
) -> dict[str, Any]:
"""关键位监控页规则说明表格(Jinja key_rule_ctx)。"""
from lib.key_monitor.false_breakout_key_monitor_lib import (
FALSE_BREAKOUT_OFFSET_PCT,
FALSE_BREAKOUT_RR,
FALSE_BREAKOUT_SL_PCT,
)
from lib.key_monitor.trigger_entry_key_monitor_lib import TRIGGER_ENTRY_VALIDITY_HOURS
te_hours = (
int(trigger_entry_validity_hours)
if trigger_entry_validity_hours is not None
else TRIGGER_ENTRY_VALIDITY_HOURS
)
return {
"tf": (kline_timeframe or "5m").strip(),
"amp_min_pct": key_breakout_amp_min_pct,
"vol_ma_bars": key_volume_ma_bars,
"vol_ratio_min": key_volume_ratio_min,
"min_rr": key_auto_min_planned_rr,
"vol_rank_max": key_daily_volume_rank_max,
"breakout_bar": key_confirm_breakout_bar,
"confirm_bar": key_confirm_bar,
"alert_max": key_alert_max_times,
"alert_interval_min": key_alert_interval_minutes,
"stop_outside_pct": key_stop_outside_breakout_pct,
"trend_stop_outside_pct": key_trend_stop_outside_pct,
"fb_offset_pct": FALSE_BREAKOUT_OFFSET_PCT,
"fb_sl_pct": FALSE_BREAKOUT_SL_PCT,
"fb_rr": FALSE_BREAKOUT_RR,
"fb_valid_hours": false_breakout_validity_hours,
"trigger_entry_validity_hours": te_hours,
}
@@ -1,296 +1,296 @@
"""回调/突破触价开仓关键位监控:程序盯价、触达计划入场后市价成交(四所共用逻辑)。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Callable, Optional
from false_breakout_key_monitor_lib import (
_parse_created_at,
expires_at_text,
is_false_breakout_expired,
)
from strategy_trend_lib import trend_dca_level_reached
# 回调触价(原「触价开仓」)
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE = "回调触价开仓"
LEGACY_TRIGGER_ENTRY_MONITOR_TYPE = "触价开仓"
# 突破触价:标记价穿越 E 后立即市价开仓
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE = "突破触价开仓"
TRIGGER_ENTRY_MONITOR_TYPES = frozenset(
{
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE,
LEGACY_TRIGGER_ENTRY_MONITOR_TYPE,
}
)
TRIGGER_ENTRY_VALIDITY_HOURS = 24
TRIGGER_ENTRY_CLOSE_FILLED = "trigger_entry_filled"
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE = "trigger_tp_invalidate"
TRIGGER_ENTRY_CLOSE_SL_INVALIDATE = "trigger_sl_invalidate"
TRIGGER_ENTRY_CLOSE_EXPIRED = "trigger_entry_expired"
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED = "trigger_exchange_failed"
KEY_ENTRY_REASON_CALLBACK = "关键位回调触价开仓"
KEY_ENTRY_REASON_BREAKOUT = "关键位突破触价开仓"
KEY_ENTRY_REASON_TRIGGER_LEGACY = "关键位触价开仓"
def normalize_trigger_entry_monitor_type(monitor_type: Optional[str]) -> str:
mt = (monitor_type or "").strip()
if mt == LEGACY_TRIGGER_ENTRY_MONITOR_TYPE:
return CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
return mt
def is_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
return (monitor_type or "").strip() in TRIGGER_ENTRY_MONITOR_TYPES
def is_callback_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
mt = normalize_trigger_entry_monitor_type(monitor_type)
return mt == CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
def is_breakout_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
return (monitor_type or "").strip() == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE
def key_entry_reason_for_monitor_type(monitor_type: Optional[str]) -> str:
if is_breakout_trigger_entry_key_monitor_type(monitor_type):
return KEY_ENTRY_REASON_BREAKOUT
if is_trigger_entry_key_monitor_type(monitor_type):
return KEY_ENTRY_REASON_CALLBACK
return KEY_ENTRY_REASON_TRIGGER_LEGACY
def trigger_entry_reached(direction: str, mark_price: float, entry: float) -> bool:
"""回调触价:多=价跌至 E;空=价涨至 E。"""
return trend_dca_level_reached(direction, mark_price, entry)
def breakout_trigger_entry_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
entry: float,
) -> bool:
"""突破触价:多=向上穿越 E;空=向下穿越 E。"""
try:
m = float(mark)
e = float(entry)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m > e
return pm <= e and m > e
if pm is None:
return m < e
return pm >= e and m < e
def trigger_should_fire(
monitor_type: Optional[str],
direction: str,
mark: float,
entry: float,
prev_mark: Optional[float] = None,
) -> bool:
if is_breakout_trigger_entry_key_monitor_type(monitor_type):
return breakout_trigger_entry_crossed(direction, prev_mark, mark, entry)
return trigger_entry_reached(direction, mark, entry)
def trigger_entry_invalidate_by_tp(direction: str, mark_price: float, take_profit: float) -> bool:
"""未开仓前标记价先触达止盈侧则失效。"""
try:
m = float(mark_price)
tp = float(take_profit)
except (TypeError, ValueError):
return False
d = (direction or "long").strip().lower()
if d == "short":
return m <= tp
return m >= tp
def trigger_entry_invalidate_by_sl(direction: str, mark_price: float, stop_loss: float) -> bool:
"""突破触价:未到 E 先触达止损侧则失效。"""
try:
m = float(mark_price)
sl = float(stop_loss)
except (TypeError, ValueError):
return False
d = (direction or "long").strip().lower()
if d == "long":
return m <= sl
return m >= sl
def trigger_entry_invalidate(
monitor_type: Optional[str],
direction: str,
mark: float,
stop_loss: float,
take_profit: float,
) -> Optional[str]:
if trigger_entry_invalidate_by_tp(direction, mark, take_profit):
return "tp"
if is_breakout_trigger_entry_key_monitor_type(monitor_type):
if trigger_entry_invalidate_by_sl(direction, mark, stop_loss):
return "sl"
return None
def validate_trigger_entry_geometry(
direction: str,
entry: float,
stop_loss: float,
take_profit: float,
mark_at_add: Optional[float] = None,
*,
monitor_type: Optional[str] = None,
) -> Optional[str]:
"""返回错误文案;合法则 None。"""
try:
e = float(entry)
sl = float(stop_loss)
tp = float(take_profit)
except (TypeError, ValueError):
return "入场价、止损、止盈须为有效数字"
if e <= 0 or sl <= 0 or tp <= 0:
return "入场价、止损、止盈须大于 0"
d = (direction or "long").strip().lower()
mt = normalize_trigger_entry_monitor_type(monitor_type)
label = "突破触价开仓" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调触价开仓"
if d == "long":
if not (sl < e < tp):
return "做多:须满足 止损 < 入场价 < 止盈"
if mark_at_add is not None:
m = float(mark_at_add)
if m >= tp:
return f"做多:当前价已不低于止盈,无法添加{label}"
if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m >= e:
return "做多:当前价须低于入场价(等待向上突破)"
elif d == "short":
if not (tp < e < sl):
return "做空:须满足 止盈 < 入场价 < 止损"
if mark_at_add is not None:
m = float(mark_at_add)
if m <= tp:
return f"做空:当前价已不高于止盈,无法添加{label}"
if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m <= e:
return "做空:当前价须高于入场价(等待向下跌破)"
else:
return "方向须为 long 或 short"
return None
def validate_trigger_entry_rr(
direction: str,
entry: float,
stop_loss: float,
take_profit: float,
min_rr: float,
calc_rr_ratio: Callable[..., Optional[float]],
) -> Optional[str]:
rr = calc_rr_ratio(direction, entry, stop_loss, take_profit)
if rr is None or rr <= float(min_rr):
fmt = f"{rr:.4f}" if rr is not None else "无法计算"
return f"计划盈亏比 {fmt}:1 未达要求(>{float(min_rr)}:1"
return None
def is_trigger_entry_expired(
created_at: Any,
now: datetime,
*,
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
) -> bool:
return is_false_breakout_expired(created_at, now, hours=hours)
def trigger_entry_expires_at_text(
created_at: Any,
*,
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
) -> str:
return expires_at_text(created_at, hours=hours)
def count_pending_trigger_entries(conn: Any, trading_day: str) -> int:
td = (trading_day or "").strip()
if not td:
return 0
placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES))
row = conn.execute(
f"SELECT COUNT(*) FROM key_monitors WHERE monitor_type IN ({placeholders}) AND session_date=?",
(*TRIGGER_ENTRY_MONITOR_TYPES, td),
).fetchone()
return int(row[0] if row else 0)
def check_trigger_entry_intent_limit(
conn: Any,
trading_day: str,
opens_today: int,
hard_limit: int,
) -> tuple[bool, str]:
"""当日开仓意图:已成交次数 + 待触发触价条数。"""
if int(hard_limit) <= 0:
return True, ""
pending = count_pending_trigger_entries(conn, trading_day)
total = int(opens_today) + pending
if total >= int(hard_limit):
return (
False,
f"本交易日开仓意图已达上限(已开 {int(opens_today)} + 待触发 {pending} / 硬上限 {int(hard_limit)}",
)
return True, ""
def trigger_entry_gate_preview(
*,
monitor_type: Optional[str] = None,
entry_display: str,
take_profit_display: str,
created_at: Any = None,
now: Optional[datetime] = None,
expired: bool = False,
tp_invalidated: bool = False,
sl_invalidated: bool = False,
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
) -> dict[str, Any]:
now_dt = now or datetime.now()
is_exp = expired or is_trigger_entry_expired(created_at, now_dt, hours=hours)
exp_txt = trigger_entry_expires_at_text(created_at, hours=hours)
mt = normalize_trigger_entry_monitor_type(monitor_type)
if tp_invalidated:
status = "止盈侧失效"
elif sl_invalidated:
status = "止损侧失效"
elif is_exp:
status = "已过期"
elif mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE:
status = "突破待触发"
else:
status = "回调待触发"
mode = "突破" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调"
metrics_parts: list[str] = [f"TP:{take_profit_display}"]
if exp_txt != "":
metrics_parts.append(f"截至:{exp_txt}")
return {
"summary": f"{mode}触价 E={entry_display} {status}",
"metrics": " ".join(metrics_parts),
"gate_ok": not is_exp and not tp_invalidated and not sl_invalidated,
}
# 兼容旧 import
TRIGGER_ENTRY_MONITOR_TYPE = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
KEY_ENTRY_REASON_TRIGGER = KEY_ENTRY_REASON_CALLBACK
"""回调/突破触价开仓关键位监控:程序盯价、触达计划入场后市价成交(四所共用逻辑)。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Callable, Optional
from lib.key_monitor.false_breakout_key_monitor_lib import (
_parse_created_at,
expires_at_text,
is_false_breakout_expired,
)
from lib.strategy.strategy_trend_lib import trend_dca_level_reached
# 回调触价(原「触价开仓」)
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE = "回调触价开仓"
LEGACY_TRIGGER_ENTRY_MONITOR_TYPE = "触价开仓"
# 突破触价:标记价穿越 E 后立即市价开仓
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE = "突破触价开仓"
TRIGGER_ENTRY_MONITOR_TYPES = frozenset(
{
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE,
LEGACY_TRIGGER_ENTRY_MONITOR_TYPE,
}
)
TRIGGER_ENTRY_VALIDITY_HOURS = 24
TRIGGER_ENTRY_CLOSE_FILLED = "trigger_entry_filled"
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE = "trigger_tp_invalidate"
TRIGGER_ENTRY_CLOSE_SL_INVALIDATE = "trigger_sl_invalidate"
TRIGGER_ENTRY_CLOSE_EXPIRED = "trigger_entry_expired"
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED = "trigger_exchange_failed"
KEY_ENTRY_REASON_CALLBACK = "关键位回调触价开仓"
KEY_ENTRY_REASON_BREAKOUT = "关键位突破触价开仓"
KEY_ENTRY_REASON_TRIGGER_LEGACY = "关键位触价开仓"
def normalize_trigger_entry_monitor_type(monitor_type: Optional[str]) -> str:
mt = (monitor_type or "").strip()
if mt == LEGACY_TRIGGER_ENTRY_MONITOR_TYPE:
return CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
return mt
def is_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
return (monitor_type or "").strip() in TRIGGER_ENTRY_MONITOR_TYPES
def is_callback_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
mt = normalize_trigger_entry_monitor_type(monitor_type)
return mt == CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
def is_breakout_trigger_entry_key_monitor_type(monitor_type: Optional[str]) -> bool:
return (monitor_type or "").strip() == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE
def key_entry_reason_for_monitor_type(monitor_type: Optional[str]) -> str:
if is_breakout_trigger_entry_key_monitor_type(monitor_type):
return KEY_ENTRY_REASON_BREAKOUT
if is_trigger_entry_key_monitor_type(monitor_type):
return KEY_ENTRY_REASON_CALLBACK
return KEY_ENTRY_REASON_TRIGGER_LEGACY
def trigger_entry_reached(direction: str, mark_price: float, entry: float) -> bool:
"""回调触价:多=价跌至 E;空=价涨至 E。"""
return trend_dca_level_reached(direction, mark_price, entry)
def breakout_trigger_entry_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
entry: float,
) -> bool:
"""突破触价:多=向上穿越 E;空=向下穿越 E。"""
try:
m = float(mark)
e = float(entry)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m > e
return pm <= e and m > e
if pm is None:
return m < e
return pm >= e and m < e
def trigger_should_fire(
monitor_type: Optional[str],
direction: str,
mark: float,
entry: float,
prev_mark: Optional[float] = None,
) -> bool:
if is_breakout_trigger_entry_key_monitor_type(monitor_type):
return breakout_trigger_entry_crossed(direction, prev_mark, mark, entry)
return trigger_entry_reached(direction, mark, entry)
def trigger_entry_invalidate_by_tp(direction: str, mark_price: float, take_profit: float) -> bool:
"""未开仓前标记价先触达止盈侧则失效。"""
try:
m = float(mark_price)
tp = float(take_profit)
except (TypeError, ValueError):
return False
d = (direction or "long").strip().lower()
if d == "short":
return m <= tp
return m >= tp
def trigger_entry_invalidate_by_sl(direction: str, mark_price: float, stop_loss: float) -> bool:
"""突破触价:未到 E 先触达止损侧则失效。"""
try:
m = float(mark_price)
sl = float(stop_loss)
except (TypeError, ValueError):
return False
d = (direction or "long").strip().lower()
if d == "long":
return m <= sl
return m >= sl
def trigger_entry_invalidate(
monitor_type: Optional[str],
direction: str,
mark: float,
stop_loss: float,
take_profit: float,
) -> Optional[str]:
if trigger_entry_invalidate_by_tp(direction, mark, take_profit):
return "tp"
if is_breakout_trigger_entry_key_monitor_type(monitor_type):
if trigger_entry_invalidate_by_sl(direction, mark, stop_loss):
return "sl"
return None
def validate_trigger_entry_geometry(
direction: str,
entry: float,
stop_loss: float,
take_profit: float,
mark_at_add: Optional[float] = None,
*,
monitor_type: Optional[str] = None,
) -> Optional[str]:
"""返回错误文案;合法则 None。"""
try:
e = float(entry)
sl = float(stop_loss)
tp = float(take_profit)
except (TypeError, ValueError):
return "入场价、止损、止盈须为有效数字"
if e <= 0 or sl <= 0 or tp <= 0:
return "入场价、止损、止盈须大于 0"
d = (direction or "long").strip().lower()
mt = normalize_trigger_entry_monitor_type(monitor_type)
label = "突破触价开仓" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调触价开仓"
if d == "long":
if not (sl < e < tp):
return "做多:须满足 止损 < 入场价 < 止盈"
if mark_at_add is not None:
m = float(mark_at_add)
if m >= tp:
return f"做多:当前价已不低于止盈,无法添加{label}"
if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m >= e:
return "做多:当前价须低于入场价(等待向上突破)"
elif d == "short":
if not (tp < e < sl):
return "做空:须满足 止盈 < 入场价 < 止损"
if mark_at_add is not None:
m = float(mark_at_add)
if m <= tp:
return f"做空:当前价已不高于止盈,无法添加{label}"
if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE and m <= e:
return "做空:当前价须高于入场价(等待向下跌破)"
else:
return "方向须为 long 或 short"
return None
def validate_trigger_entry_rr(
direction: str,
entry: float,
stop_loss: float,
take_profit: float,
min_rr: float,
calc_rr_ratio: Callable[..., Optional[float]],
) -> Optional[str]:
rr = calc_rr_ratio(direction, entry, stop_loss, take_profit)
if rr is None or rr <= float(min_rr):
fmt = f"{rr:.4f}" if rr is not None else "无法计算"
return f"计划盈亏比 {fmt}:1 未达要求(>{float(min_rr)}:1"
return None
def is_trigger_entry_expired(
created_at: Any,
now: datetime,
*,
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
) -> bool:
return is_false_breakout_expired(created_at, now, hours=hours)
def trigger_entry_expires_at_text(
created_at: Any,
*,
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
) -> str:
return expires_at_text(created_at, hours=hours)
def count_pending_trigger_entries(conn: Any, trading_day: str) -> int:
td = (trading_day or "").strip()
if not td:
return 0
placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES))
row = conn.execute(
f"SELECT COUNT(*) FROM key_monitors WHERE monitor_type IN ({placeholders}) AND session_date=?",
(*TRIGGER_ENTRY_MONITOR_TYPES, td),
).fetchone()
return int(row[0] if row else 0)
def check_trigger_entry_intent_limit(
conn: Any,
trading_day: str,
opens_today: int,
hard_limit: int,
) -> tuple[bool, str]:
"""当日开仓意图:已成交次数 + 待触发触价条数。"""
if int(hard_limit) <= 0:
return True, ""
pending = count_pending_trigger_entries(conn, trading_day)
total = int(opens_today) + pending
if total >= int(hard_limit):
return (
False,
f"本交易日开仓意图已达上限(已开 {int(opens_today)} + 待触发 {pending} / 硬上限 {int(hard_limit)}",
)
return True, ""
def trigger_entry_gate_preview(
*,
monitor_type: Optional[str] = None,
entry_display: str,
take_profit_display: str,
created_at: Any = None,
now: Optional[datetime] = None,
expired: bool = False,
tp_invalidated: bool = False,
sl_invalidated: bool = False,
hours: int = TRIGGER_ENTRY_VALIDITY_HOURS,
) -> dict[str, Any]:
now_dt = now or datetime.now()
is_exp = expired or is_trigger_entry_expired(created_at, now_dt, hours=hours)
exp_txt = trigger_entry_expires_at_text(created_at, hours=hours)
mt = normalize_trigger_entry_monitor_type(monitor_type)
if tp_invalidated:
status = "止盈侧失效"
elif sl_invalidated:
status = "止损侧失效"
elif is_exp:
status = "已过期"
elif mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE:
status = "突破待触发"
else:
status = "回调待触发"
mode = "突破" if mt == BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE else "回调"
metrics_parts: list[str] = [f"TP:{take_profit_display}"]
if exp_txt != "":
metrics_parts.append(f"截至:{exp_txt}")
return {
"summary": f"{mode}触价 E={entry_display} {status}",
"metrics": " ".join(metrics_parts),
"gate_ok": not is_exp and not tp_invalidated and not sl_invalidated,
}
# 兼容旧 import
TRIGGER_ENTRY_MONITOR_TYPE = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
KEY_ENTRY_REASON_TRIGGER = KEY_ENTRY_REASON_CALLBACK
+22
View File
@@ -0,0 +1,22 @@
"""Repository path helpers for lib/ assets."""
from __future__ import annotations
from pathlib import Path
LIB_DIR = Path(__file__).resolve().parent
REPO_ROOT = LIB_DIR.parent
def strategy_templates_dir(repo_root: str | Path | None = None) -> str:
root = Path(repo_root) if repo_root is not None else REPO_ROOT
return str(root / "lib" / "strategy" / "templates")
def embed_templates_dir(repo_root: str | Path | None = None) -> str:
root = Path(repo_root) if repo_root is not None else REPO_ROOT
return str(root / "lib" / "instance" / "templates")
def common_static_dir(repo_root: str | Path | None = None) -> str:
root = Path(repo_root) if repo_root is not None else REPO_ROOT
return str(root / "lib" / "common" / "static")
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+164 -164
View File
@@ -1,164 +1,164 @@
"""策略交易相关表结构(各所 crypto.db 共用 schema)。"""
ROLL_GROUPS_SQL = """
CREATE TABLE IF NOT EXISTS roll_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_monitor_id INTEGER,
symbol TEXT NOT NULL,
exchange_symbol TEXT,
direction TEXT NOT NULL,
initial_take_profit REAL,
initial_stop_loss REAL,
current_stop_loss REAL,
risk_percent REAL DEFAULT 2,
leg_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'active',
created_at TEXT,
updated_at TEXT
)
"""
ROLL_LEGS_SQL = """
CREATE TABLE IF NOT EXISTS roll_legs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
roll_group_id INTEGER NOT NULL,
leg_index INTEGER NOT NULL,
add_mode TEXT NOT NULL,
fib_upper REAL,
fib_lower REAL,
limit_price REAL,
fill_price REAL,
amount REAL,
new_stop_loss REAL,
exchange_order_id TEXT,
status TEXT DEFAULT 'filled',
created_at TEXT,
FOREIGN KEY (roll_group_id) REFERENCES roll_groups(id)
)
"""
TREND_PLANS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT DEFAULT 'active',
symbol TEXT NOT NULL,
exchange_symbol TEXT,
direction TEXT NOT NULL DEFAULT 'long',
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL DEFAULT 5,
snapshot_available_usdt REAL,
snapshot_at TEXT,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER DEFAULT 5,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
legs_done INTEGER DEFAULT 0,
first_order_done INTEGER DEFAULT 0,
last_mark_price REAL,
avg_entry_price REAL,
order_amount_open REAL,
opened_at TEXT,
opened_at_ms INTEGER,
session_date TEXT,
message TEXT,
initial_stop_loss REAL,
breakeven_applied INTEGER DEFAULT 0,
breakeven_applied_at TEXT
)
"""
TREND_PREVIEWS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_previews (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
exchange_symbol TEXT NOT NULL,
direction TEXT NOT NULL,
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL NOT NULL,
snapshot_available_usdt REAL NOT NULL,
snapshot_at TEXT,
live_price_ref REAL,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
expires_at_ms INTEGER NOT NULL,
created_at TEXT
)
"""
TREND_PREVIEW_SNAPSHOTS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_preview_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
preview_id TEXT NOT NULL UNIQUE,
symbol TEXT NOT NULL,
exchange_symbol TEXT NOT NULL,
direction TEXT NOT NULL,
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL NOT NULL,
snapshot_available_usdt REAL NOT NULL,
snapshot_at TEXT,
live_price_ref REAL,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
expires_at_ms INTEGER NOT NULL,
preview_created_at TEXT,
outcome TEXT DEFAULT 'open',
executed_plan_id INTEGER
)
"""
def init_strategy_tables(conn) -> None:
from strategy_snapshot_lib import init_strategy_snapshot_table
conn.execute(ROLL_GROUPS_SQL)
conn.execute(ROLL_LEGS_SQL)
conn.execute(TREND_PLANS_SQL)
conn.execute(TREND_PREVIEWS_SQL)
conn.execute(TREND_PREVIEW_SNAPSHOTS_SQL)
init_strategy_snapshot_table(conn)
for ddl in (
"ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT",
"ALTER TABLE trend_pullback_plans ADD COLUMN initial_stop_loss REAL",
"ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied INTEGER DEFAULT 0",
"ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied_at TEXT",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN preview_created_at TEXT",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN outcome TEXT DEFAULT 'open'",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN executed_plan_id INTEGER",
"ALTER TABLE trade_records ADD COLUMN trend_plan_id INTEGER",
"ALTER TABLE order_monitors ADD COLUMN trend_plan_id INTEGER",
"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT",
"ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT",
"ALTER TABLE trend_pullback_plans ADD COLUMN leg_fill_prices_json TEXT",
"ALTER TABLE roll_legs ADD COLUMN stop_offset_pct REAL",
"ALTER TABLE roll_legs ADD COLUMN breakthrough_price REAL",
"ALTER TABLE roll_legs ADD COLUMN last_mark_price REAL",
):
try:
conn.execute(ddl)
except Exception:
pass
"""策略交易相关表结构(各所 crypto.db 共用 schema)。"""
ROLL_GROUPS_SQL = """
CREATE TABLE IF NOT EXISTS roll_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_monitor_id INTEGER,
symbol TEXT NOT NULL,
exchange_symbol TEXT,
direction TEXT NOT NULL,
initial_take_profit REAL,
initial_stop_loss REAL,
current_stop_loss REAL,
risk_percent REAL DEFAULT 2,
leg_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'active',
created_at TEXT,
updated_at TEXT
)
"""
ROLL_LEGS_SQL = """
CREATE TABLE IF NOT EXISTS roll_legs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
roll_group_id INTEGER NOT NULL,
leg_index INTEGER NOT NULL,
add_mode TEXT NOT NULL,
fib_upper REAL,
fib_lower REAL,
limit_price REAL,
fill_price REAL,
amount REAL,
new_stop_loss REAL,
exchange_order_id TEXT,
status TEXT DEFAULT 'filled',
created_at TEXT,
FOREIGN KEY (roll_group_id) REFERENCES roll_groups(id)
)
"""
TREND_PLANS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT DEFAULT 'active',
symbol TEXT NOT NULL,
exchange_symbol TEXT,
direction TEXT NOT NULL DEFAULT 'long',
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL DEFAULT 5,
snapshot_available_usdt REAL,
snapshot_at TEXT,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER DEFAULT 5,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
legs_done INTEGER DEFAULT 0,
first_order_done INTEGER DEFAULT 0,
last_mark_price REAL,
avg_entry_price REAL,
order_amount_open REAL,
opened_at TEXT,
opened_at_ms INTEGER,
session_date TEXT,
message TEXT,
initial_stop_loss REAL,
breakeven_applied INTEGER DEFAULT 0,
breakeven_applied_at TEXT
)
"""
TREND_PREVIEWS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_previews (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
exchange_symbol TEXT NOT NULL,
direction TEXT NOT NULL,
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL NOT NULL,
snapshot_available_usdt REAL NOT NULL,
snapshot_at TEXT,
live_price_ref REAL,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
expires_at_ms INTEGER NOT NULL,
created_at TEXT
)
"""
TREND_PREVIEW_SNAPSHOTS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_preview_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
preview_id TEXT NOT NULL UNIQUE,
symbol TEXT NOT NULL,
exchange_symbol TEXT NOT NULL,
direction TEXT NOT NULL,
leverage INTEGER NOT NULL,
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL NOT NULL,
snapshot_available_usdt REAL NOT NULL,
snapshot_at TEXT,
live_price_ref REAL,
plan_margin_capital REAL,
target_order_amount REAL,
first_order_amount REAL,
remainder_total REAL,
dca_legs INTEGER,
per_leg_amount REAL,
grid_prices_json TEXT,
leg_amounts_json TEXT,
expires_at_ms INTEGER NOT NULL,
preview_created_at TEXT,
outcome TEXT DEFAULT 'open',
executed_plan_id INTEGER
)
"""
def init_strategy_tables(conn) -> None:
from lib.strategy.strategy_snapshot_lib import init_strategy_snapshot_table
conn.execute(ROLL_GROUPS_SQL)
conn.execute(ROLL_LEGS_SQL)
conn.execute(TREND_PLANS_SQL)
conn.execute(TREND_PREVIEWS_SQL)
conn.execute(TREND_PREVIEW_SNAPSHOTS_SQL)
init_strategy_snapshot_table(conn)
for ddl in (
"ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT",
"ALTER TABLE trend_pullback_plans ADD COLUMN initial_stop_loss REAL",
"ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied INTEGER DEFAULT 0",
"ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied_at TEXT",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN preview_created_at TEXT",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN outcome TEXT DEFAULT 'open'",
"ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN executed_plan_id INTEGER",
"ALTER TABLE trade_records ADD COLUMN trend_plan_id INTEGER",
"ALTER TABLE order_monitors ADD COLUMN trend_plan_id INTEGER",
"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT",
"ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT",
"ALTER TABLE trend_pullback_plans ADD COLUMN leg_fill_prices_json TEXT",
"ALTER TABLE roll_legs ADD COLUMN stop_offset_pct REAL",
"ALTER TABLE roll_legs ADD COLUMN breakthrough_price REAL",
"ALTER TABLE roll_legs ADD COLUMN last_mark_price REAL",
):
try:
conn.execute(ddl)
except Exception:
pass
@@ -1,4 +1,4 @@
"""Binance USDT-M 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
from strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
"""Binance USDT-M 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
@@ -1,9 +1,9 @@
"""
Gate.io USDT 永续 策略交易交易所侧能力
实现方式 Gate 实例 app 通过 strategy_config.build_strategy_config(app_module) 注入
ccxt 下单精度 TP/SL本文件为文档与类型锚点避免在四个 app 重复实现滚仓公式
"""
from strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
"""
Gate.io USDT 永续 策略交易交易所侧能力
实现方式 Gate 实例 app 通过 strategy_config.build_strategy_config(app_module) 注入
ccxt 下单精度 TP/SL本文件为文档与类型锚点避免在四个 app 重复实现滚仓公式
"""
from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
@@ -1,4 +1,4 @@
"""OKX 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
from strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
"""OKX 永续 — 策略交易交易所适配(见 strategy_config.build_strategy_config)。"""
from lib.strategy.strategy_exchange_base import StrategyExchangeAdapter
__all__ = ["StrategyExchangeAdapter"]
@@ -1,72 +1,72 @@
"""策略交易记录页:已结束趋势 / 顺势加仓快照(四所统一)。"""
from __future__ import annotations
import json
from typing import Any
from flask import flash, redirect, url_for
from strategy_snapshot_lib import (
STRATEGY_SNAPSHOTS_MAX_ROWS,
dedupe_strategy_snapshots,
list_strategy_snapshots_split,
)
def load_strategy_records_page(
conn, *, limit: int = STRATEGY_SNAPSHOTS_MAX_ROWS
) -> dict[str, Any]:
try:
if dedupe_strategy_snapshots(conn):
conn.commit()
except Exception:
pass
trend, roll, symbols = list_strategy_snapshots_split(conn, limit=limit)
return {
"strategy_trend_records": trend,
"strategy_roll_records": roll,
"strategy_record_symbols": symbols,
"strategy_records_limit": limit,
"strategy_snapshots": trend + roll,
}
def register_strategy_records(app, cfg: dict[str, Any]) -> None:
login_required = cfg["login_required"]
get_db = cfg["get_db"]
def _lr(f):
return login_required(f)
@_lr
@app.route("/strategy/records")
def strategy_records_page():
m = cfg.get("app_module")
fn = getattr(m, "render_main_page", None)
if not callable(fn):
flash("render_main_page 未配置")
return redirect(url_for("strategy_trading_page"))
return fn("strategy_records")
@_lr
@app.route("/strategy/records/<int:snap_id>")
def strategy_records_detail(snap_id: int):
conn = get_db()
row = conn.execute(
"SELECT * FROM strategy_trade_snapshots WHERE id=?",
(int(snap_id),),
).fetchone()
conn.close()
if not row:
flash("未找到该策略快照")
return redirect(url_for("strategy_records_page"))
try:
snap = json.loads(row["snapshot_json"] or "{}")
except Exception:
snap = {}
dca = snap.get("dca_levels") or []
flash(
f"快照 #{snap_id} {row['strategy_type']} {row['symbol']} "
f"{row['result_label']} · 补仓档 {len(dca)} 项(详情见列表页)"
)
return redirect(url_for("strategy_records_page"))
"""策略交易记录页:已结束趋势 / 顺势加仓快照(四所统一)。"""
from __future__ import annotations
import json
from typing import Any
from flask import flash, redirect, url_for
from lib.strategy.strategy_snapshot_lib import (
STRATEGY_SNAPSHOTS_MAX_ROWS,
dedupe_strategy_snapshots,
list_strategy_snapshots_split,
)
def load_strategy_records_page(
conn, *, limit: int = STRATEGY_SNAPSHOTS_MAX_ROWS
) -> dict[str, Any]:
try:
if dedupe_strategy_snapshots(conn):
conn.commit()
except Exception:
pass
trend, roll, symbols = list_strategy_snapshots_split(conn, limit=limit)
return {
"strategy_trend_records": trend,
"strategy_roll_records": roll,
"strategy_record_symbols": symbols,
"strategy_records_limit": limit,
"strategy_snapshots": trend + roll,
}
def register_strategy_records(app, cfg: dict[str, Any]) -> None:
login_required = cfg["login_required"]
get_db = cfg["get_db"]
def _lr(f):
return login_required(f)
@_lr
@app.route("/strategy/records")
def strategy_records_page():
m = cfg.get("app_module")
fn = getattr(m, "render_main_page", None)
if not callable(fn):
flash("render_main_page 未配置")
return redirect(url_for("strategy_trading_page"))
return fn("strategy_records")
@_lr
@app.route("/strategy/records/<int:snap_id>")
def strategy_records_detail(snap_id: int):
conn = get_db()
row = conn.execute(
"SELECT * FROM strategy_trade_snapshots WHERE id=?",
(int(snap_id),),
).fetchone()
conn.close()
if not row:
flash("未找到该策略快照")
return redirect(url_for("strategy_records_page"))
try:
snap = json.loads(row["snapshot_json"] or "{}")
except Exception:
snap = {}
dca = snap.get("dca_levels") or []
flash(
f"快照 #{snap_id} {row['strategy_type']} {row['symbol']} "
f"{row['result_label']} · 补仓档 {len(dca)} 项(详情见列表页)"
)
return redirect(url_for("strategy_records_page"))
File diff suppressed because it is too large Load Diff
@@ -1,385 +1,385 @@
"""顺势加仓(滚仓):纯计算。人工触发;止盈锁定首仓;程序监控触价市价成交。"""
from __future__ import annotations
from typing import Any, Optional, Tuple
from fib_key_monitor_lib import calc_fib_plan, fib_invalidate_by_mark
ROLL_MAX_LEGS_LONG = 3
ROLL_MAX_LEGS_SHORT = 3
MARKET_MODE = "market"
FIB_MODES = frozenset({"fib_618", "fib_786"})
BREAKOUT_MODE = "breakout"
MODE_LABELS = {
MARKET_MODE: "市价加仓",
"fib_618": "斐波0.618",
"fib_786": "斐波0.786",
BREAKOUT_MODE: "突破加仓",
}
def fib_ratio_from_mode(mode: str) -> Optional[float]:
m = (mode or "").strip().lower()
if m in ("fib_618", "618", "0.618"):
return 0.618
if m in ("fib_786", "786", "0.786"):
return 0.786
return None
def mode_label(mode: str) -> str:
m = (mode or MARKET_MODE).strip().lower()
return MODE_LABELS.get(m, m)
def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]:
"""H/L 仅用于计算限价加仓价;多:下沿=止损侧;空:上沿=止损侧。"""
ratio = fib_ratio_from_mode(mode)
if ratio is None:
return None, "斐波档位无效"
h, l = float(upper), float(lower)
if h <= l:
return None, "上沿须大于下沿"
direction = (direction or "long").strip().lower()
if direction == "short":
plan = calc_fib_plan("short", h, l, ratio)
else:
plan = calc_fib_plan("long", h, l, ratio)
if not plan:
return None, "无法计算斐波限价"
entry, _sl, _tp = plan
return float(entry), None
def max_roll_legs(direction: str) -> int:
return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT
def avg_entry_after_add(
qty_existing: float,
entry_existing: float,
add_qty: float,
add_price: float,
) -> float:
q1 = float(qty_existing)
e1 = float(entry_existing)
q2 = float(add_qty)
e2 = float(add_price)
total = q1 + q2
if total <= 0:
return 0.0
return (q1 * e1 + q2 * e2) / total
def calc_risk_budget_usdt(capital_base_usdt: float, risk_percent: float) -> float:
return float(capital_base_usdt) * (float(risk_percent) / 100.0)
def solve_add_amount_for_total_risk(
direction: str,
qty_existing: float,
entry_existing: float,
add_price: float,
new_stop: float,
risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]:
"""
合并持仓打到 new_stop 时总亏损 risk_budget方案 C
long: (avg - SL) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(E1-SL)) / (E2-SL)
short: (SL - avg) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(SL-E1)) / (SL-E2)
"""
try:
q1 = float(qty_existing)
e1 = float(entry_existing)
e2 = float(add_price)
sl = float(new_stop)
b = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0:
return None, "持仓或风险预算无效"
direction = (direction or "long").strip().lower()
if direction == "short":
denom = sl - e2
numer = b / cs - q1 * (sl - e1)
if denom <= 0:
return None, "做空:新止损须高于加仓价"
else:
denom = e2 - sl
numer = b / cs - q1 * (e1 - sl)
if denom <= 0:
return None, "做多:新止损须低于加仓价"
q2 = numer / denom
if q2 <= 0:
return None, "按当前新止损与风险预算,无需加仓或无法再加(已满足风险上限)"
return q2, None
def loss_at_stop_usdt(
direction: str,
avg: float,
qty: float,
stop: float,
contract_size: float = 1.0,
) -> float:
cs = float(contract_size or 1.0)
direction = (direction or "long").strip().lower()
if direction == "short":
return (float(stop) - float(avg)) * float(qty) * cs
return (float(avg) - float(stop)) * float(qty) * cs
def reward_at_tp_usdt(
direction: str,
avg: float,
take_profit: float,
qty: float,
contract_size: float = 1.0,
) -> float:
cs = float(contract_size or 1.0)
direction = (direction or "long").strip().lower()
if direction == "short":
return (float(avg) - float(take_profit)) * float(qty) * cs
return (float(take_profit) - float(avg)) * float(qty) * cs
def roll_fib_trigger_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
limit_price: float,
) -> bool:
"""斐波:多=向下穿越限价;空=向上穿越限价。"""
try:
m = float(mark)
lv = float(limit_price)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m <= lv
return pm > lv and m <= lv
if pm is None:
return m >= lv
return pm < lv and m >= lv
def roll_breakout_trigger_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
breakthrough_price: float,
) -> bool:
"""突破:多=向上穿越突破价;空=向下穿越突破价。"""
try:
m = float(mark)
bp = float(breakthrough_price)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m > bp
return pm <= bp and m > bp
if pm is None:
return m < bp
return pm >= bp and m < bp
def roll_fib_invalidate(direction: str, mark: float, upper: float, lower: float) -> bool:
"""斐波 pending 失效:止盈侧突破(多 mark>=H;空 mark<=L)。"""
return fib_invalidate_by_mark(direction, mark, upper, lower)
def roll_breakout_invalidate(direction: str, mark: float, stop_loss: float) -> bool:
"""突破 pending 失效:未到突破价先触达止损侧(多 mark<=S;空 mark>=S)。"""
try:
m = float(mark)
sl = float(stop_loss)
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
return m <= sl
return m >= sl
def validate_roll_geometry(
direction: str,
add_mode: str,
*,
new_stop_loss: float,
add_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
breakthrough_price: Optional[float] = None,
entry_existing: float = 0.0,
initial_take_profit: float = 0.0,
mark_price: Optional[float] = None,
) -> Optional[str]:
direction = (direction or "long").strip().lower()
mode = (add_mode or MARKET_MODE).strip().lower()
try:
sl = float(new_stop_loss)
tp = float(initial_take_profit)
e1 = float(entry_existing or 0)
except (TypeError, ValueError):
return "止损/止盈格式错误"
if sl <= 0 or tp <= 0:
return "止损与首仓止盈须大于0"
if direction == "long":
if e1 > 0 and tp <= e1:
return "做多:首仓止盈须高于当前持仓均价"
else:
if e1 > 0 and tp >= e1:
return "做空:首仓止盈须低于当前持仓均价"
if mode == MARKET_MODE:
if add_price is None or float(add_price) <= 0:
return "市价加仓需要有效参考价"
entry_add = float(add_price)
elif mode in FIB_MODES:
if fib_upper is None or fib_lower is None:
return "斐波须填写上沿 H 与下沿 L"
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
if err:
return err
if entry_add is None or entry_add <= 0:
return "无法计算斐波限价"
elif mode == BREAKOUT_MODE:
if breakthrough_price is None:
return "突破加仓须填写突破价"
try:
bp = float(breakthrough_price)
except (TypeError, ValueError):
return "突破价格式错误"
if bp <= 0:
return "突破价须大于0"
entry_add = bp
if direction == "long":
if sl >= bp:
return "做多:止损须低于突破价"
if mark_price is not None and float(mark_price) >= bp:
return "做多:当前价须低于突破价(等待向上突破)"
else:
if sl <= bp:
return "做空:止损须高于突破价"
if mark_price is not None and float(mark_price) <= bp:
return "做空:当前价须高于突破价(等待向下跌破)"
else:
return "加仓方式无效"
if mode != BREAKOUT_MODE:
entry_add = float(entry_add) # type: ignore[arg-type]
if direction == "long":
if sl >= entry_add:
return "做多:新止损须低于加仓价"
else:
if sl <= entry_add:
return "做空:新止损须高于加仓价"
return None
def preview_roll(
*,
direction: str,
symbol: str,
qty_existing: float,
entry_existing: float,
initial_take_profit: float,
add_mode: str,
new_stop_loss: Optional[float] = None,
risk_percent: float,
capital_base_usdt: float,
add_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
breakthrough_price: Optional[float] = None,
legs_done: int = 0,
contract_size: float = 1.0,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower()
if legs_done >= max_roll_legs(direction):
return None, f"{'做多' if direction == 'long' else '做空'}滚仓已达 {max_roll_legs(direction)} 次上限"
mode = (add_mode or MARKET_MODE).strip().lower()
if new_stop_loss is None:
return None, "请填写新止损价"
try:
sl = float(new_stop_loss)
except (TypeError, ValueError):
return None, "止损价格式错误"
if sl <= 0:
return None, "止损须大于0"
geom_err = validate_roll_geometry(
direction,
mode,
new_stop_loss=sl,
add_price=add_price,
fib_upper=fib_upper,
fib_lower=fib_lower,
breakthrough_price=breakthrough_price,
entry_existing=entry_existing,
initial_take_profit=initial_take_profit,
mark_price=add_price if mode == BREAKOUT_MODE else add_price,
)
if geom_err:
return None, geom_err
if mode == MARKET_MODE:
entry_add = float(add_price) # validated
elif mode in FIB_MODES:
entry_add, _ = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
entry_add = float(entry_add or 0)
else:
entry_add = float(breakthrough_price or 0)
risk_budget = calc_risk_budget_usdt(capital_base_usdt, risk_percent)
q2_raw, err = solve_add_amount_for_total_risk(
direction,
qty_existing,
entry_existing,
entry_add,
sl,
risk_budget,
contract_size,
)
if err:
return None, err
q2 = float(q2_raw)
new_qty = qty_existing + q2
new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add)
cs = float(contract_size or 1.0)
loss_sl = loss_at_stop_usdt(direction, new_avg, new_qty, sl, cs)
reward_tp = reward_at_tp_usdt(direction, new_avg, initial_take_profit, new_qty, cs)
return {
"symbol": symbol,
"direction": direction,
"add_mode": mode,
"add_mode_label": mode_label(mode),
"add_price": round(entry_add, 10),
"new_stop_loss": round(sl, 10),
"breakthrough_price": float(breakthrough_price) if breakthrough_price not in (None, "") else None,
"initial_take_profit": float(initial_take_profit),
"risk_percent": float(risk_percent),
"risk_budget_usdt": round(risk_budget, 4),
"add_amount_raw": q2,
"qty_existing": float(qty_existing),
"entry_existing": float(entry_existing),
"qty_after": new_qty,
"avg_entry_after": round(new_avg, 10),
"loss_at_sl_usdt": round(loss_sl, 4),
"reward_at_tp_usdt": round(reward_tp, 4),
"legs_done": int(legs_done),
"leg_index_next": int(legs_done) + 1,
"fib_upper": fib_upper,
"fib_lower": fib_lower,
"contract_size": cs,
}, None
"""顺势加仓(滚仓):纯计算。人工触发;止盈锁定首仓;程序监控触价市价成交。"""
from __future__ import annotations
from typing import Any, Optional, Tuple
from lib.key_monitor.fib_key_monitor_lib import calc_fib_plan, fib_invalidate_by_mark
ROLL_MAX_LEGS_LONG = 3
ROLL_MAX_LEGS_SHORT = 3
MARKET_MODE = "market"
FIB_MODES = frozenset({"fib_618", "fib_786"})
BREAKOUT_MODE = "breakout"
MODE_LABELS = {
MARKET_MODE: "市价加仓",
"fib_618": "斐波0.618",
"fib_786": "斐波0.786",
BREAKOUT_MODE: "突破加仓",
}
def fib_ratio_from_mode(mode: str) -> Optional[float]:
m = (mode or "").strip().lower()
if m in ("fib_618", "618", "0.618"):
return 0.618
if m in ("fib_786", "786", "0.786"):
return 0.786
return None
def mode_label(mode: str) -> str:
m = (mode or MARKET_MODE).strip().lower()
return MODE_LABELS.get(m, m)
def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]:
"""H/L 仅用于计算限价加仓价;多:下沿=止损侧;空:上沿=止损侧。"""
ratio = fib_ratio_from_mode(mode)
if ratio is None:
return None, "斐波档位无效"
h, l = float(upper), float(lower)
if h <= l:
return None, "上沿须大于下沿"
direction = (direction or "long").strip().lower()
if direction == "short":
plan = calc_fib_plan("short", h, l, ratio)
else:
plan = calc_fib_plan("long", h, l, ratio)
if not plan:
return None, "无法计算斐波限价"
entry, _sl, _tp = plan
return float(entry), None
def max_roll_legs(direction: str) -> int:
return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT
def avg_entry_after_add(
qty_existing: float,
entry_existing: float,
add_qty: float,
add_price: float,
) -> float:
q1 = float(qty_existing)
e1 = float(entry_existing)
q2 = float(add_qty)
e2 = float(add_price)
total = q1 + q2
if total <= 0:
return 0.0
return (q1 * e1 + q2 * e2) / total
def calc_risk_budget_usdt(capital_base_usdt: float, risk_percent: float) -> float:
return float(capital_base_usdt) * (float(risk_percent) / 100.0)
def solve_add_amount_for_total_risk(
direction: str,
qty_existing: float,
entry_existing: float,
add_price: float,
new_stop: float,
risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]:
"""
合并持仓打到 new_stop 时总亏损 risk_budget方案 C
long: (avg - SL) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(E1-SL)) / (E2-SL)
short: (SL - avg) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(SL-E1)) / (SL-E2)
"""
try:
q1 = float(qty_existing)
e1 = float(entry_existing)
e2 = float(add_price)
sl = float(new_stop)
b = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0:
return None, "持仓或风险预算无效"
direction = (direction or "long").strip().lower()
if direction == "short":
denom = sl - e2
numer = b / cs - q1 * (sl - e1)
if denom <= 0:
return None, "做空:新止损须高于加仓价"
else:
denom = e2 - sl
numer = b / cs - q1 * (e1 - sl)
if denom <= 0:
return None, "做多:新止损须低于加仓价"
q2 = numer / denom
if q2 <= 0:
return None, "按当前新止损与风险预算,无需加仓或无法再加(已满足风险上限)"
return q2, None
def loss_at_stop_usdt(
direction: str,
avg: float,
qty: float,
stop: float,
contract_size: float = 1.0,
) -> float:
cs = float(contract_size or 1.0)
direction = (direction or "long").strip().lower()
if direction == "short":
return (float(stop) - float(avg)) * float(qty) * cs
return (float(avg) - float(stop)) * float(qty) * cs
def reward_at_tp_usdt(
direction: str,
avg: float,
take_profit: float,
qty: float,
contract_size: float = 1.0,
) -> float:
cs = float(contract_size or 1.0)
direction = (direction or "long").strip().lower()
if direction == "short":
return (float(avg) - float(take_profit)) * float(qty) * cs
return (float(take_profit) - float(avg)) * float(qty) * cs
def roll_fib_trigger_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
limit_price: float,
) -> bool:
"""斐波:多=向下穿越限价;空=向上穿越限价。"""
try:
m = float(mark)
lv = float(limit_price)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m <= lv
return pm > lv and m <= lv
if pm is None:
return m >= lv
return pm < lv and m >= lv
def roll_breakout_trigger_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
breakthrough_price: float,
) -> bool:
"""突破:多=向上穿越突破价;空=向下穿越突破价。"""
try:
m = float(mark)
bp = float(breakthrough_price)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m > bp
return pm <= bp and m > bp
if pm is None:
return m < bp
return pm >= bp and m < bp
def roll_fib_invalidate(direction: str, mark: float, upper: float, lower: float) -> bool:
"""斐波 pending 失效:止盈侧突破(多 mark>=H;空 mark<=L)。"""
return fib_invalidate_by_mark(direction, mark, upper, lower)
def roll_breakout_invalidate(direction: str, mark: float, stop_loss: float) -> bool:
"""突破 pending 失效:未到突破价先触达止损侧(多 mark<=S;空 mark>=S)。"""
try:
m = float(mark)
sl = float(stop_loss)
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
return m <= sl
return m >= sl
def validate_roll_geometry(
direction: str,
add_mode: str,
*,
new_stop_loss: float,
add_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
breakthrough_price: Optional[float] = None,
entry_existing: float = 0.0,
initial_take_profit: float = 0.0,
mark_price: Optional[float] = None,
) -> Optional[str]:
direction = (direction or "long").strip().lower()
mode = (add_mode or MARKET_MODE).strip().lower()
try:
sl = float(new_stop_loss)
tp = float(initial_take_profit)
e1 = float(entry_existing or 0)
except (TypeError, ValueError):
return "止损/止盈格式错误"
if sl <= 0 or tp <= 0:
return "止损与首仓止盈须大于0"
if direction == "long":
if e1 > 0 and tp <= e1:
return "做多:首仓止盈须高于当前持仓均价"
else:
if e1 > 0 and tp >= e1:
return "做空:首仓止盈须低于当前持仓均价"
if mode == MARKET_MODE:
if add_price is None or float(add_price) <= 0:
return "市价加仓需要有效参考价"
entry_add = float(add_price)
elif mode in FIB_MODES:
if fib_upper is None or fib_lower is None:
return "斐波须填写上沿 H 与下沿 L"
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
if err:
return err
if entry_add is None or entry_add <= 0:
return "无法计算斐波限价"
elif mode == BREAKOUT_MODE:
if breakthrough_price is None:
return "突破加仓须填写突破价"
try:
bp = float(breakthrough_price)
except (TypeError, ValueError):
return "突破价格式错误"
if bp <= 0:
return "突破价须大于0"
entry_add = bp
if direction == "long":
if sl >= bp:
return "做多:止损须低于突破价"
if mark_price is not None and float(mark_price) >= bp:
return "做多:当前价须低于突破价(等待向上突破)"
else:
if sl <= bp:
return "做空:止损须高于突破价"
if mark_price is not None and float(mark_price) <= bp:
return "做空:当前价须高于突破价(等待向下跌破)"
else:
return "加仓方式无效"
if mode != BREAKOUT_MODE:
entry_add = float(entry_add) # type: ignore[arg-type]
if direction == "long":
if sl >= entry_add:
return "做多:新止损须低于加仓价"
else:
if sl <= entry_add:
return "做空:新止损须高于加仓价"
return None
def preview_roll(
*,
direction: str,
symbol: str,
qty_existing: float,
entry_existing: float,
initial_take_profit: float,
add_mode: str,
new_stop_loss: Optional[float] = None,
risk_percent: float,
capital_base_usdt: float,
add_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
breakthrough_price: Optional[float] = None,
legs_done: int = 0,
contract_size: float = 1.0,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower()
if legs_done >= max_roll_legs(direction):
return None, f"{'做多' if direction == 'long' else '做空'}滚仓已达 {max_roll_legs(direction)} 次上限"
mode = (add_mode or MARKET_MODE).strip().lower()
if new_stop_loss is None:
return None, "请填写新止损价"
try:
sl = float(new_stop_loss)
except (TypeError, ValueError):
return None, "止损价格式错误"
if sl <= 0:
return None, "止损须大于0"
geom_err = validate_roll_geometry(
direction,
mode,
new_stop_loss=sl,
add_price=add_price,
fib_upper=fib_upper,
fib_lower=fib_lower,
breakthrough_price=breakthrough_price,
entry_existing=entry_existing,
initial_take_profit=initial_take_profit,
mark_price=add_price if mode == BREAKOUT_MODE else add_price,
)
if geom_err:
return None, geom_err
if mode == MARKET_MODE:
entry_add = float(add_price) # validated
elif mode in FIB_MODES:
entry_add, _ = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
entry_add = float(entry_add or 0)
else:
entry_add = float(breakthrough_price or 0)
risk_budget = calc_risk_budget_usdt(capital_base_usdt, risk_percent)
q2_raw, err = solve_add_amount_for_total_risk(
direction,
qty_existing,
entry_existing,
entry_add,
sl,
risk_budget,
contract_size,
)
if err:
return None, err
q2 = float(q2_raw)
new_qty = qty_existing + q2
new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add)
cs = float(contract_size or 1.0)
loss_sl = loss_at_stop_usdt(direction, new_avg, new_qty, sl, cs)
reward_tp = reward_at_tp_usdt(direction, new_avg, initial_take_profit, new_qty, cs)
return {
"symbol": symbol,
"direction": direction,
"add_mode": mode,
"add_mode_label": mode_label(mode),
"add_price": round(entry_add, 10),
"new_stop_loss": round(sl, 10),
"breakthrough_price": float(breakthrough_price) if breakthrough_price not in (None, "") else None,
"initial_take_profit": float(initial_take_profit),
"risk_percent": float(risk_percent),
"risk_budget_usdt": round(risk_budget, 4),
"add_amount_raw": q2,
"qty_existing": float(qty_existing),
"entry_existing": float(entry_existing),
"qty_after": new_qty,
"avg_entry_after": round(new_avg, 10),
"loss_at_sl_usdt": round(loss_sl, 4),
"reward_at_tp_usdt": round(reward_tp, 4),
"legs_done": int(legs_done),
"leg_index_next": int(legs_done) + 1,
"fib_upper": fib_upper,
"fib_lower": fib_lower,
"contract_size": cs,
}, None
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+144 -144
View File
@@ -1,144 +1,144 @@
"""策略交易页:主站 index.html 所需数据(顺势加仓等)。"""
from __future__ import annotations
from typing import Any, Callable, Optional
from strategy_db import init_strategy_tables
from strategy_roll_monitor_lib import roll_leg_status_label
def _row_to_dict(row) -> dict:
if row is None:
return {}
try:
return dict(row)
except Exception:
return {}
def count_active_trend_plans(conn, count_fn: Optional[Callable] = None) -> int:
if callable(count_fn):
return int(count_fn(conn) or 0)
try:
return int(
conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
)
except Exception:
return 0
def fetch_roll_page_data(
conn,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
roll_cfg: dict | None = None,
) -> dict[str, Any]:
init_strategy_tables(conn)
monitors = []
for row in conn.execute(
"SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC"
).fetchall():
monitors.append(_row_to_dict(row))
roll_groups = []
for row in conn.execute(
"""SELECT g.* FROM roll_groups g
INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active'
WHERE g.status='active'
ORDER BY g.id DESC"""
).fetchall():
roll_groups.append(_row_to_dict(row))
active_gids = {int(g["id"]) for g in roll_groups if g.get("id") is not None}
roll_legs = []
for row in conn.execute(
"SELECT * FROM roll_legs ORDER BY id DESC LIMIT 80"
).fetchall():
leg = _row_to_dict(row)
gid = leg.get("roll_group_id")
if gid is not None and int(gid) not in active_gids:
continue
leg["status_label"] = roll_leg_status_label(leg.get("status"))
roll_legs.append(leg)
roll_legs = roll_legs[:50]
out = {
"roll_monitors": monitors,
"roll_groups": roll_groups,
"roll_legs": roll_legs,
"roll_trend_active": count_active_trend_plans(conn, count_active_trends),
"default_risk_percent": default_risk_percent,
}
if roll_cfg:
from strategy_roll_ui_lib import enrich_roll_page_data
enrich_roll_page_data(conn, out, roll_cfg)
return out
DEFAULT_TREND_DISABLED_NOTE = (
"趋势回调(预览、自动补仓、程序止盈)仅在 Gate 趋势机器人实例 "
"crypto_monitor_gate_bot,常见端口 5002)中启用。"
"币安 / Gate 主站 / OKX 可使用本页「顺势加仓」;完整趋势回调请打开该实例。"
)
def strategy_render_extras(
conn,
page: str,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
trend_disabled_note: str = "",
request_obj=None,
trend_cfg: Optional[dict] = None,
) -> dict[str, Any]:
"""render_main_page 策略相关页变量(含策略交易记录)。"""
if page == "strategy_records":
from strategy_records_register import load_strategy_records_page
return load_strategy_records_page(conn)
return strategy_page_template_vars(
conn,
page,
default_risk_percent=default_risk_percent,
count_active_trends=count_active_trends,
trend_disabled_note=trend_disabled_note,
request_obj=request_obj,
trend_cfg=trend_cfg,
)
def strategy_page_template_vars(
conn,
page: str,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
trend_disabled_note: str = "",
request_obj=None,
trend_cfg: Optional[dict] = None,
) -> dict[str, Any]:
"""render_main_page 在 conn.close() 前合并进 render_template 的变量。"""
if page not in ("strategy", "strategy_trend", "strategy_roll"):
return {}
roll_cfg = None
try:
from flask import current_app
roll_cfg = (current_app.extensions or {}).get("strategy_roll_cfg")
except Exception:
roll_cfg = None
out = fetch_roll_page_data(
conn,
default_risk_percent=default_risk_percent,
count_active_trends=count_active_trends,
roll_cfg=roll_cfg if isinstance(roll_cfg, dict) else None,
)
if trend_cfg and request_obj is not None:
from strategy_trend_register import load_trend_page_context
out.update(load_trend_page_context(conn, request_obj, trend_cfg))
elif page == "strategy_trend":
out["trend_disabled_note"] = trend_disabled_note or DEFAULT_TREND_DISABLED_NOTE
return out
"""策略交易页:主站 index.html 所需数据(顺势加仓等)。"""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.strategy.strategy_db import init_strategy_tables
from lib.strategy.strategy_roll_monitor_lib import roll_leg_status_label
def _row_to_dict(row) -> dict:
if row is None:
return {}
try:
return dict(row)
except Exception:
return {}
def count_active_trend_plans(conn, count_fn: Optional[Callable] = None) -> int:
if callable(count_fn):
return int(count_fn(conn) or 0)
try:
return int(
conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'"
).fetchone()[0]
)
except Exception:
return 0
def fetch_roll_page_data(
conn,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
roll_cfg: dict | None = None,
) -> dict[str, Any]:
init_strategy_tables(conn)
monitors = []
for row in conn.execute(
"SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC"
).fetchall():
monitors.append(_row_to_dict(row))
roll_groups = []
for row in conn.execute(
"""SELECT g.* FROM roll_groups g
INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active'
WHERE g.status='active'
ORDER BY g.id DESC"""
).fetchall():
roll_groups.append(_row_to_dict(row))
active_gids = {int(g["id"]) for g in roll_groups if g.get("id") is not None}
roll_legs = []
for row in conn.execute(
"SELECT * FROM roll_legs ORDER BY id DESC LIMIT 80"
).fetchall():
leg = _row_to_dict(row)
gid = leg.get("roll_group_id")
if gid is not None and int(gid) not in active_gids:
continue
leg["status_label"] = roll_leg_status_label(leg.get("status"))
roll_legs.append(leg)
roll_legs = roll_legs[:50]
out = {
"roll_monitors": monitors,
"roll_groups": roll_groups,
"roll_legs": roll_legs,
"roll_trend_active": count_active_trend_plans(conn, count_active_trends),
"default_risk_percent": default_risk_percent,
}
if roll_cfg:
from lib.strategy.strategy_roll_ui_lib import enrich_roll_page_data
enrich_roll_page_data(conn, out, roll_cfg)
return out
DEFAULT_TREND_DISABLED_NOTE = (
"趋势回调(预览、自动补仓、程序止盈)仅在 Gate 趋势机器人实例 "
"crypto_monitor_gate_bot,常见端口 5002)中启用。"
"币安 / Gate 主站 / OKX 可使用本页「顺势加仓」;完整趋势回调请打开该实例。"
)
def strategy_render_extras(
conn,
page: str,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
trend_disabled_note: str = "",
request_obj=None,
trend_cfg: Optional[dict] = None,
) -> dict[str, Any]:
"""render_main_page 策略相关页变量(含策略交易记录)。"""
if page == "strategy_records":
from lib.strategy.strategy_records_register import load_strategy_records_page
return load_strategy_records_page(conn)
return strategy_page_template_vars(
conn,
page,
default_risk_percent=default_risk_percent,
count_active_trends=count_active_trends,
trend_disabled_note=trend_disabled_note,
request_obj=request_obj,
trend_cfg=trend_cfg,
)
def strategy_page_template_vars(
conn,
page: str,
*,
default_risk_percent: float = 2.0,
count_active_trends: Optional[Callable] = None,
trend_disabled_note: str = "",
request_obj=None,
trend_cfg: Optional[dict] = None,
) -> dict[str, Any]:
"""render_main_page 在 conn.close() 前合并进 render_template 的变量。"""
if page not in ("strategy", "strategy_trend", "strategy_roll"):
return {}
roll_cfg = None
try:
from flask import current_app
roll_cfg = (current_app.extensions or {}).get("strategy_roll_cfg")
except Exception:
roll_cfg = None
out = fetch_roll_page_data(
conn,
default_risk_percent=default_risk_percent,
count_active_trends=count_active_trends,
roll_cfg=roll_cfg if isinstance(roll_cfg, dict) else None,
)
if trend_cfg and request_obj is not None:
from lib.strategy.strategy_trend_register import load_trend_page_context
out.update(load_trend_page_context(conn, request_obj, trend_cfg))
elif page == "strategy_trend":
out["trend_disabled_note"] = trend_disabled_note or DEFAULT_TREND_DISABLED_NOTE
return out
@@ -1,192 +1,192 @@
"""策略计划(趋势回调 / 滚仓)开始与结束 — 企业微信推送(四所共用)。"""
from __future__ import annotations
from typing import Any, Optional
from wechat_notify_lib import wechat_direction_label
def _send(cfg: dict[str, Any], content: str) -> None:
fn = cfg.get("send_wechat")
if callable(fn):
try:
fn(content)
return
except Exception:
pass
m = cfg.get("app_module")
if m is not None:
sw = getattr(m, "send_wechat_msg", None)
if callable(sw):
try:
sw(content)
except Exception:
pass
def _account(cfg: dict[str, Any]) -> str:
fn = cfg.get("wechat_account_label")
if callable(fn):
try:
return str(fn()).strip() or _exchange(cfg)
except Exception:
pass
return _exchange(cfg)
def _exchange(cfg: dict[str, Any]) -> str:
return str(cfg.get("exchange_display") or "").strip() or "交易账户"
def _dir_text(cfg: dict[str, Any], direction: str) -> str:
fn = cfg.get("wechat_direction_text")
if callable(fn):
try:
return str(fn(direction))
except Exception:
pass
return wechat_direction_label(direction)
def _fmt_price(cfg: dict[str, Any], symbol: str, price: Any) -> str:
if price is None or price == "":
return ""
fn = cfg.get("format_price") or cfg.get("price_fmt")
if callable(fn):
try:
return str(fn(symbol, price))
except Exception:
pass
m = cfg.get("app_module")
pf = getattr(m, "format_price_for_symbol", None) if m else None
if callable(pf):
try:
return str(pf(symbol, price))
except Exception:
pass
try:
return str(round(float(price), 8))
except (TypeError, ValueError):
return str(price)
def _fmt_pnl(pnl: Any) -> str:
if pnl is None:
return ""
try:
v = float(pnl)
return f"{'+' if v > 0 else ''}{round(v, 2)} U"
except (TypeError, ValueError):
return str(pnl)
def notify_trend_plan_started(
cfg: dict[str, Any],
*,
plan_id: int,
symbol: str,
direction: str,
leverage: int,
stop_loss: float,
take_profit: float,
add_upper: float,
risk_percent: float,
dca_legs: int,
first_order_amount: float,
avg_entry: Optional[float] = None,
snapshot_usdt: Optional[float] = None,
) -> None:
sym = symbol or ""
lines = [
f"# 🚀 {sym} 趋势回调计划已开始",
f"**账户:{_account(cfg)}**",
f"- 计划 ID**{plan_id}**",
f"- 方向:{_dir_text(cfg, direction)}|杠杆 **{int(leverage or 1)}x**",
f"- 止损:{_fmt_price(cfg, sym, stop_loss)}|止盈:{_fmt_price(cfg, sym, take_profit)}",
f"- 补仓区:{_fmt_price(cfg, sym, add_upper)}|补仓档 **{int(dca_legs or 0)}** 档",
f"- 风险:**{risk_percent}%**|首仓张数:**{first_order_amount}**",
]
if avg_entry is not None:
lines.append(f"- 首仓成交价:{_fmt_price(cfg, sym, avg_entry)}")
if snapshot_usdt is not None:
try:
lines.append(f"- 启动时合约可用:**{round(float(snapshot_usdt), 2)} U**")
except (TypeError, ValueError):
pass
lines.append("- 说明:交易所已挂止损;止盈由程序监控;结束/保本将另行推送")
_send(cfg, "\n".join(lines))
def notify_trend_plan_ended(
cfg: dict[str, Any],
*,
plan_id: int,
symbol: str,
direction: str,
end_type: str,
result_label: Optional[str] = None,
exit_price: Optional[float] = None,
pnl_amount: Optional[float] = None,
extra: Optional[str] = None,
) -> None:
sym = symbol or ""
res = (result_label or end_type or "").strip()
lines = [
f"# 🏁 {sym} 趋势回调计划已结束",
f"**账户:{_account(cfg)}**",
f"- 计划 ID**{plan_id}**",
f"- 方向:{_dir_text(cfg, direction)}",
f"- 结束方式:**{end_type}**",
f"- 结果:**{res}**",
]
if exit_price is not None:
lines.append(f"- 离场参考价:{_fmt_price(cfg, sym, exit_price)}")
if pnl_amount is not None:
lines.append(f"- 本单盈亏:**{_fmt_pnl(pnl_amount)}**")
if extra:
lines.append(f"- {extra}")
_send(cfg, "\n".join(lines))
def notify_roll_group_started(
cfg: dict[str, Any],
*,
group_id: int,
symbol: str,
direction: str,
order_monitor_id: int,
initial_take_profit: Optional[float] = None,
initial_stop_loss: Optional[float] = None,
) -> None:
sym = symbol or ""
lines = [
f"# 🚀 {sym} 滚仓计划已开始",
f"**账户:{_account(cfg)}**",
f"- 滚仓组 ID**{group_id}**|绑定下单监控 **#{order_monitor_id}**",
f"- 方向:{_dir_text(cfg, direction)}",
f"- 首仓止盈(锁定):{_fmt_price(cfg, sym, initial_take_profit)}",
f"- 当前止损:{_fmt_price(cfg, sym, initial_stop_loss)}",
"- 说明:顺势加仓为人工触发;组结束(无持仓/监控结案)将另行推送",
]
_send(cfg, "\n".join(lines))
def notify_roll_group_ended(
cfg: dict[str, Any],
*,
group_id: int,
symbol: str,
direction: str,
reason: str,
leg_count: int = 0,
) -> None:
sym = symbol or ""
lines = [
f"# 🏁 {sym} 滚仓计划已结束",
f"**账户:{_account(cfg)}**",
f"- 滚仓组 ID**{group_id}**",
f"- 方向:{_dir_text(cfg, direction)}",
f"- 结束原因:**{reason}**",
f"- 已完成滚仓腿数:**{int(leg_count or 0)}**",
]
_send(cfg, "\n".join(lines))
"""策略计划(趋势回调 / 滚仓)开始与结束 — 企业微信推送(四所共用)。"""
from __future__ import annotations
from typing import Any, Optional
from lib.common.wechat_notify_lib import wechat_direction_label
def _send(cfg: dict[str, Any], content: str) -> None:
fn = cfg.get("send_wechat")
if callable(fn):
try:
fn(content)
return
except Exception:
pass
m = cfg.get("app_module")
if m is not None:
sw = getattr(m, "send_wechat_msg", None)
if callable(sw):
try:
sw(content)
except Exception:
pass
def _account(cfg: dict[str, Any]) -> str:
fn = cfg.get("wechat_account_label")
if callable(fn):
try:
return str(fn()).strip() or _exchange(cfg)
except Exception:
pass
return _exchange(cfg)
def _exchange(cfg: dict[str, Any]) -> str:
return str(cfg.get("exchange_display") or "").strip() or "交易账户"
def _dir_text(cfg: dict[str, Any], direction: str) -> str:
fn = cfg.get("wechat_direction_text")
if callable(fn):
try:
return str(fn(direction))
except Exception:
pass
return wechat_direction_label(direction)
def _fmt_price(cfg: dict[str, Any], symbol: str, price: Any) -> str:
if price is None or price == "":
return ""
fn = cfg.get("format_price") or cfg.get("price_fmt")
if callable(fn):
try:
return str(fn(symbol, price))
except Exception:
pass
m = cfg.get("app_module")
pf = getattr(m, "format_price_for_symbol", None) if m else None
if callable(pf):
try:
return str(pf(symbol, price))
except Exception:
pass
try:
return str(round(float(price), 8))
except (TypeError, ValueError):
return str(price)
def _fmt_pnl(pnl: Any) -> str:
if pnl is None:
return ""
try:
v = float(pnl)
return f"{'+' if v > 0 else ''}{round(v, 2)} U"
except (TypeError, ValueError):
return str(pnl)
def notify_trend_plan_started(
cfg: dict[str, Any],
*,
plan_id: int,
symbol: str,
direction: str,
leverage: int,
stop_loss: float,
take_profit: float,
add_upper: float,
risk_percent: float,
dca_legs: int,
first_order_amount: float,
avg_entry: Optional[float] = None,
snapshot_usdt: Optional[float] = None,
) -> None:
sym = symbol or ""
lines = [
f"# 🚀 {sym} 趋势回调计划已开始",
f"**账户:{_account(cfg)}**",
f"- 计划 ID**{plan_id}**",
f"- 方向:{_dir_text(cfg, direction)}|杠杆 **{int(leverage or 1)}x**",
f"- 止损:{_fmt_price(cfg, sym, stop_loss)}|止盈:{_fmt_price(cfg, sym, take_profit)}",
f"- 补仓区:{_fmt_price(cfg, sym, add_upper)}|补仓档 **{int(dca_legs or 0)}** 档",
f"- 风险:**{risk_percent}%**|首仓张数:**{first_order_amount}**",
]
if avg_entry is not None:
lines.append(f"- 首仓成交价:{_fmt_price(cfg, sym, avg_entry)}")
if snapshot_usdt is not None:
try:
lines.append(f"- 启动时合约可用:**{round(float(snapshot_usdt), 2)} U**")
except (TypeError, ValueError):
pass
lines.append("- 说明:交易所已挂止损;止盈由程序监控;结束/保本将另行推送")
_send(cfg, "\n".join(lines))
def notify_trend_plan_ended(
cfg: dict[str, Any],
*,
plan_id: int,
symbol: str,
direction: str,
end_type: str,
result_label: Optional[str] = None,
exit_price: Optional[float] = None,
pnl_amount: Optional[float] = None,
extra: Optional[str] = None,
) -> None:
sym = symbol or ""
res = (result_label or end_type or "").strip()
lines = [
f"# 🏁 {sym} 趋势回调计划已结束",
f"**账户:{_account(cfg)}**",
f"- 计划 ID**{plan_id}**",
f"- 方向:{_dir_text(cfg, direction)}",
f"- 结束方式:**{end_type}**",
f"- 结果:**{res}**",
]
if exit_price is not None:
lines.append(f"- 离场参考价:{_fmt_price(cfg, sym, exit_price)}")
if pnl_amount is not None:
lines.append(f"- 本单盈亏:**{_fmt_pnl(pnl_amount)}**")
if extra:
lines.append(f"- {extra}")
_send(cfg, "\n".join(lines))
def notify_roll_group_started(
cfg: dict[str, Any],
*,
group_id: int,
symbol: str,
direction: str,
order_monitor_id: int,
initial_take_profit: Optional[float] = None,
initial_stop_loss: Optional[float] = None,
) -> None:
sym = symbol or ""
lines = [
f"# 🚀 {sym} 滚仓计划已开始",
f"**账户:{_account(cfg)}**",
f"- 滚仓组 ID**{group_id}**|绑定下单监控 **#{order_monitor_id}**",
f"- 方向:{_dir_text(cfg, direction)}",
f"- 首仓止盈(锁定):{_fmt_price(cfg, sym, initial_take_profit)}",
f"- 当前止损:{_fmt_price(cfg, sym, initial_stop_loss)}",
"- 说明:顺势加仓为人工触发;组结束(无持仓/监控结案)将另行推送",
]
_send(cfg, "\n".join(lines))
def notify_roll_group_ended(
cfg: dict[str, Any],
*,
group_id: int,
symbol: str,
direction: str,
reason: str,
leg_count: int = 0,
) -> None:
sym = symbol or ""
lines = [
f"# 🏁 {sym} 滚仓计划已结束",
f"**账户:{_account(cfg)}**",
f"- 滚仓组 ID**{group_id}**",
f"- 方向:{_dir_text(cfg, direction)}",
f"- 结束原因:**{reason}**",
f"- 已完成滚仓腿数:**{int(leg_count or 0)}**",
]
_send(cfg, "\n".join(lines))

Some files were not shown because too many files have changed in this diff Show More