refactor: 将共用代码迁入 lib/ 模块化目录
统一 strategy、key_monitor、trade、hub 等共用库到 lib/ 子包,并补充 lib-structure 文档,便于四所与中控维护。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)**。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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__])
|
||||
|
||||
@@ -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
@@ -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__])
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""crypto_monitor shared libraries."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -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.Row(Row 无 .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.Row(Row 无 .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
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -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
|
||||
+1027
-1025
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,
|
||||
}
|
||||
|
||||
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
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
+145
-145
@@ -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、下沿 L(H > L)。
|
||||
做多:自 H 向下回撤 ratio,E = H - ratio*(H-L);SL=L,TP=H。
|
||||
做空:自 L 向上反弹 ratio,E = L + ratio*(H-L);SL=H,TP=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、下沿 L(H > L)。
|
||||
做多:自 H 向下回撤 ratio,E = H - ratio*(H-L);SL=L,TP=H。
|
||||
做空:自 L 向上反弹 ratio,E = L + ratio*(H-L);SL=H,TP=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,
|
||||
}
|
||||
+296
-296
@@ -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
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user