Compare commits

...

200 Commits

Author SHA1 Message Date
dekun fc74b8e913 Align funds bar and win-rate with OO expiry intrinsic repair; wire Binance balances.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:18:16 +08:00
dekun 3520fc0214 Fix OO expiry Put PnL: overlay wrong near-zero settlement fills with intrinsic.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:02:44 +08:00
dekun 77d2effb6d Backfill OO expiry settle display via public ETHUSDT and intrinsic overlay.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 16:58:02 +08:00
dekun 3264dd4381 Fix OO expiry settle index: persist spot and never invent strike from OTM fill.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 16:44:56 +08:00
dekun 4a5d19e30f Fix OO trade records: include Put PnL and show dual-leg detail clearly.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 09:34:35 +08:00
dekun 26e6d98338 Add three-page strategy format doc for auto, OO, and semi-auto.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 16:23:05 +08:00
dekun 3491681c28 Mirror option expiry settlements from exchange bills; debit OO LIVE open premiums.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 16:02:16 +08:00
dekun 8d67f3fc6c Fix LIVE SoT P0/P1: closing state machine, OO exchange fills, BN balances.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:54:45 +08:00
dekun 99e58910d3 Treat exchange as LIVE SoT: expiry closes perp only, no invented option settles.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:44:15 +08:00
dekun 4fe15c41fa Sync fleet monitor columns with semi-auto units and K+/-N exits.
Row risk/open and exit no longer show full-auto 5%/1:2/base-20 when semi is on.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:25:41 +08:00
dekun 50783e18d0 Right-align open-capacity row; split semi exit rule into two lines.
Logic check: K+/-N option target and unit*k perp net lock remain correct.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:17:22 +08:00
dekun 723c5fc1fc Split semi summary into option target, perp lock, and sizing lines.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:12:56 +08:00
dekun 43e2df2b62 Polish semi dual cards: equal height, quote header, holding state.
Right card stacks title then green expiry; left shows 持仓中 after open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:11:10 +08:00
dekun 626c9c1323 Fix semi strike-target test not tripping net lock early.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:00:02 +08:00
dekun 4f56eff40c Anchor semi exit to strike +/- move points; side-by-side semi cards.
Forward target is K+/-N not spot+/-N; UI shows option target and perp net lock.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:59:48 +08:00
dekun 6f983ed2ab Try all OTM strikes in offset when nearest fails leverage.
Closest Call@1920 at 152x no longer blocks 1930/1940 that already clear 200x.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:52:26 +08:00
dekun 5ea54db43b Let semi-auto reopen nearest expiry; ignore one-expiry-per-day.
Far quarterly skips were masking usable near OTM quotes like 260810.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:48:07 +08:00
dekun f1e3d5527a Fix semi open stalls: one-leg asks, OTM realign, clearer pick errors.
Monitor no longer shows ATM Put bias while semi-auto is authorized for Call.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:43:00 +08:00
dekun 4db1e0c470 Estimate semi forward PnL from strike intrinsic on move points.
Replace misleading exit-unit label with option/perp point-move preview vs selected K.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:35:10 +08:00
dekun b09d1b0886 Show semi risk-based open size from ask and unit ratio.
Semi sizing uses market ask with semi units; Plan panel previews option/perp qty under the form.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:27:19 +08:00
dekun 7db8b724ac Drive semi quote ladder by min remaining hours input.
Picks nearest expiry with hours >= threshold and refreshes the list as the field changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:20:28 +08:00
dekun ebd88718a7 Filter semi ladder to 2 OTM/ITM or 1 ATM by moneyness selection.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:18:03 +08:00
dekun 58c12aa160 Fix semi moneyness reset and show Call/Put ladder by view.
Use dirty ref so refresh cannot overwrite unsaved params; ladder fetches books for the selected side.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:13:37 +08:00
dekun d98bf46549 Restructure Plan UI for semi-auto tabs and T-quote ladder.
Hide amplitude card when filter off; compact semi form with rules; monitor tab keeps positions and market.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:01:12 +08:00
dekun 457838ef64 Extend semi-auto with ITM/ATM/OTM, offset, and 1:4 sizing.
Moneyness and perp:option units are plan-only; OTM enforces leverage >=180.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:42:57 +08:00
dekun 4585dba3c3 Add 永期半自动: human arm, machine open/close, then stop.
Settings toggle, Plan panel, Fleet monitor, exit locks and armed TOCTOU gates; docs and dual audits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:20:11 +08:00
dekun bf3441537e Fix target-exit mismatch: correct close reason and executable PnL gate.
Locked premium exits no longer label as fixed_usdt; mark/book optimism no longer triggers close into a realized loss.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 22:05:34 +08:00
dekun a88d708ea3 Add amplitude filter as first open gate for 永期 hedge.
Reuse OO amplitude settings (default off); Plan shows index/HL when filtering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 21:41:51 +08:00
dekun b377956367 Fix false USDC-short error while waiting for a qualified option.
Use risk-sizing preview ask/qty (same as auto-convert) instead of monitor book ask.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:28:58 +08:00
dekun 06f612aa52 Allow risk-based 永期 exits via premium multiple as well as base×k.
Settings UI picks 基数 or 权利金×n; sizing preview and control monitor follow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:13:11 +08:00
dekun ab612df818 Move settings hints into rules; add OO amplitude filter toggle default off.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:58:50 +08:00
dekun 0daac05ef5 Reject OO strikes more than 1% from amplitude high/low.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:50:12 +08:00
dekun 6d63c52ae0 Show OO amplitude, Call/Put size, and min leverage in control.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:43:42 +08:00
dekun bd7640e36a Show both Call and Put legs for OO positions in control.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:40:45 +08:00
dekun 3b5a8c1ff9 Highlight 期期 hedge mode in control monitor with amber.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:37:34 +08:00
dekun 9f7b1b24e6 Show 永期/期期 in control monitor mode column.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:32:49 +08:00
dekun 43a2a9634d Keep OO amplitude refreshed while holding a position.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:30:51 +08:00
dekun 0ac03cc726 Align OO Call/Put position cards with per-leg mark, size, leverage.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:25:03 +08:00
dekun e7115e396a Fix SyntaxError in open_capacity that crashed the API.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:14:07 +08:00
dekun 24a860ad3f Split OO budget per leg and size Call/Put independently.
Each half of the risk budget buys its own qty from ask; equal qty no longer forced.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:08:05 +08:00
dekun 24943adf13 Fix option-option exit preview to use budget times reward ratio.
Plan no longer shows perp exit_unit*k as the OO target.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:41:51 +08:00
dekun eaf94448a2 Treat option-option amplitude as a maximum cap.
Reject opens when range exceeds the setting; update UI and docs labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:35:39 +08:00
dekun 7d21bc26c7 Realign market pair when hedge mode switches.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:31:26 +08:00
dekun 0659611d82 Show OTM option quotes in option-option mode.
Align session to amplitude Call/Put pair and replace perp/ATM market panels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:30:29 +08:00
dekun cd88fa5d83 Hide perp-option settings when option-option hedge is selected.
Show OO selection and reward-ratio fields in their tabs instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:21:36 +08:00
dekun 8d3c181246 Hide perp signal direction in option-option mode.
Plan panel shows Call+Put structure and OO selection/exit labels instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:18:40 +08:00
dekun 42e56d940c Add delete for closed trade records.
Only closed groups can be removed from local history; equity is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:06:36 +08:00
dekun ec244c63c6 Add option-option hedge mode with SIM/LIVE parity.
Mutual hedge_mode, amplitude OTM selection, 1:1 risk sizing, win-leg/full close, dual audits and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:01:16 +08:00
dekun 15fe2f72dc Remove scrollbars from plan position cards.
Let position cards size to content instead of clipping with overflow-y auto.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:12:02 +08:00
dekun 04e5c11027 Count expiry settlements as martingale losses.
Even small-profit expiry closes count toward consecutive loss days; only non-expiry profitable days break the streak.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:08:44 +08:00
dekun 930e6c26c5 Clear trade history when resetting sim equity.
Deleting groups/fills/residuals and resetting strategy counters keeps funds reset a clean slate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 14:49:51 +08:00
dekun 0f552eb50e Add martingale mode for risk-based percent sizing.
Enable in settings (default off): after N consecutive loss days, double the effective risk_loss_pct up to a configurable max; blocked when base pct > 3%.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 09:55:29 +08:00
dekun 3d1f9f3d50 Move Beijing clock onto nav row with larger type.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:13:09 +08:00
dekun 0370e91d2f Add Beijing time clock to control header top-right.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:10:27 +08:00
dekun 82f0c1c27c Equalize strategy and position panel heights without clipping.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 03:54:02 +08:00
dekun 3312a8fb50 Show residual option qty with two decimal places.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 03:47:49 +08:00
dekun 24f4dea798 Prevent plan position cards from clipping when windowed.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 03:43:25 +08:00
dekun 68d02b9c17 Move strategy residual table below market panels.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 03:37:52 +08:00
dekun 52948c0a54 Fix strategy plan text overflow in metrics and position cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 13:49:22 +08:00
dekun b09bcd8414 Show min option hours in control risk/open column.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 07:56:32 +08:00
dekun 44a545e6e4 Show control monitor mode as SIM/A or SIM/B sizing codes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 07:54:44 +08:00
dekun 1252f45aac Add strategy residual table with manual close at panel bottom.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 07:47:22 +08:00
dekun 09abb8d35b Show residual bid as price/size and recovery percent from bid premium.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 07:43:27 +08:00
dekun a10ecf7409 Gate one-open-per-expiry by historical expiry_ymd across days.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 07:35:44 +08:00
dekun 5c334a4d89 Use all-time closed groups for funds bar trade stats.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 17:07:47 +08:00
dekun d97f7d20e5 Fix funds bar day stats: align trades, win rate, and PL ratio.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 17:05:11 +08:00
dekun b983b63c46 Rename control header to hedge fleet command center.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 15:47:16 +08:00
dekun 3e71621702 Enlarge control header title further.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 15:45:01 +08:00
dekun 5f1dce825b Widen control shell side padding on large screens.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 15:43:12 +08:00
dekun 86955c3cf5 Enlarge header subtitle with underline and dash prefix.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 15:41:52 +08:00
dekun c70a1fafd5 Restyle control header: large centered title, nav below.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 15:39:39 +08:00
dekun 553435eb20 Center control header title and BitCamel subtitle.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 15:34:29 +08:00
dekun a7aee8f425 Add control residual options table with liquidity-only manual close.
Fleet status exposes enriched residuals; manual close skips the premium recovery gate while auto mid-close remains unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 15:13:29 +08:00
dekun 92c2e89b0e Add control monitor Start All for stopped fleet nodes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 14:52:56 +08:00
dekun 4059d826e1 Document detailed open/close logic and LIVE failure handling.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 14:44:20 +08:00
dekun 469e7a258a Harden residual mid-close: IOC partial fills, exchange reconcile, atomic book.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 14:41:00 +08:00
dekun 640ecc9530 Sell residual options at latest bid via IOC limit, not market.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 14:25:03 +08:00
dekun 62c91d4bd0 Close residual options when premium recovers above configurable threshold.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 14:19:21 +08:00
dekun 60b3743e8d Add initial funds to fleet stats and color total PnL.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:51:47 +08:00
dekun 51aef1c2fb Fix control SPA refresh 404 on /stats by serving index.html.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:47:52 +08:00
dekun cd46d7748c Move fleet stats to nav page; backfill funds and loss metrics from curve/status.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:43:17 +08:00
dekun 921e96321f Align strategy UI prices with exchange tick sizes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:34:42 +08:00
dekun 0c12d76212 Show margin and premium in control positions; align exchange price digits.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:32:25 +08:00
dekun 8745d8e6d1 Add fleet stats table with funds, fees, loss streak, and totals.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:25:30 +08:00
dekun 5982cb7414 Add select-all update button on control monitor toolbar.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:18:27 +08:00
dekun 62c9d2ce01 Slow status-dot blink to a softer ~1s breathe.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:16:30 +08:00
dekun 77e68dd0e9 Lower control shell padding so fullscreen content is less top-heavy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:14:42 +08:00
dekun d210db19cf Cyber theme for control UI, faster status flicker, center table text.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:13:57 +08:00
dekun ec3156021a Add status column with blinking dots, widen shell, risk/open format.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:07:42 +08:00
dekun 46a861d311 Replace control monitor cards with status table layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 12:01:49 +08:00
dekun bc3041dcf2 ATM panel: show ask/bid by position state and live option leverage.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 11:51:38 +08:00
dekun ead3545df8 Show WeCom machine name after brand title on shell and login.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 11:43:02 +08:00
dekun 9427d9c429 Skip used/residual expiries so ATM monitors the next day.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 10:31:07 +08:00
dekun 05520f82bd Show option premium in trade detail and widen the modal.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 10:25:33 +08:00
dekun cad86813dc Cap fullscreen detail at 1920px width for ultrawide side margins.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 10:17:07 +08:00
dekun 987467a808 Make strategy detail overlay fill the entire viewport.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 10:14:46 +08:00
dekun 60a1e7ad48 Use horizontal tables for strategy and stats in centered fullscreen modal.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 10:10:31 +08:00
dekun b76ef0d248 Center detail modal at 920px with table layout for strategy and stats.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 10:06:41 +08:00
dekun 18fdfc6d0a Fullscreen strategy detail with 5-row equity pagination.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 10:01:52 +08:00
dekun 63225f64de Show option contract ID and expiry date in trade detail.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 09:54:07 +08:00
dekun 079dc83132 Show strategy stats in control monitor detail modal.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 09:49:27 +08:00
dekun 0e3b9e53ac Enrich WeCom open/close notifies with Chinese qty, margin, and PnL.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 08:58:49 +08:00
dekun aab0b9bdf6 Support up to 3 strategy slots on one host with auto ports.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 23:33:41 +08:00
dekun 9c515644a3 Add risk leverage basis option: actual ask vs selection leverage.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 17:01:18 +08:00
dekun f1da2778ef Add one-expiry-per-day open rhythm gate (default on).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:39:32 +08:00
dekun 22c42a19a0 Count exit fees in live/SIM net PnL as entry fee x2.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 09:27:41 +08:00
dekun dffcc4eeb9 Show option bid as price/size and option leverage on plan and trades.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 16:01:34 +08:00
dekun dffcd77837 Add trade move points; label option UPL price as bid not mark.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 15:44:52 +08:00
dekun 8ceb521049 Add WeCom machine name for multi-node push labels.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:28:32 +08:00
dekun 021b0d0a22 Align emergency option close to OKX: use bid only, never mark.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:15:38 +08:00
dekun 2b7c504134 Use half-width colon for risk ratio 1:2 on Plan page.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 15:20:48 +08:00
dekun c05478015d Read risk_loss_pct exit base and option lev from strategy settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 15:03:26 +08:00
dekun 67f22626e9 Show risk params box on control cards and modal.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:58:30 +08:00
dekun c5f477b8bc Hide username and logout on LAN clients in control header.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:47:50 +08:00
dekun be5803332f Keep monitor alive across nav and speed up status collect.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:44:27 +08:00
dekun 3c2589f653 Fix control status: do not treat /health as fleet (missing positions).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:36:21 +08:00
dekun b3ba0fd431 Add SSE second-level status push for control monitor.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:32:28 +08:00
dekun 7136adcaa9 Widen settings forms and fix Token clipboard copy fallback.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:15:22 +08:00
dekun 3556450ff0 Show in-position vs flat on control monitor cards with green/red.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:09:30 +08:00
dekun e7529e9dc4 Document Fleet control center across README and core docs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:07:25 +08:00
dekun d46eaf43ab Control settings tabs, hide default creds hint after change, LAN passwordless login.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:04:07 +08:00
dekun 8034464c2f Brighten and bold control modal PnL colors.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:27:37 +08:00
dekun 7c87c42061 Require confirm before control single/batch code update.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:14:14 +08:00
dekun 75e89ca442 Colorize control detail modal net PnL and leg upl (green/red).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:12:12 +08:00
dekun a317822f7a Improve control monitor cards: green when running, detail modal with positions.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:07:54 +08:00
dekun 08fe06d074 Fix control unauthorized: map strategy 401 to 502 and keep fleet headers on redirect.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:57:06 +08:00
dekun da5eb4c18c Add Fleet control plane and split manage.sh deploy menu.
Strategy nodes gain fleet token APIs; control/ app for local ops; manage.sh offers strategy vs control one-click deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:46:40 +08:00
dekun 200702d066 Harden strategy SoT: fail-closed funds gate, shared open pipeline, LIVE switch guards.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 01:06:32 +08:00
dekun 4c537e7520 Reset auto USDC cooldown in unit tests for stable assertions.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 00:34:36 +08:00
dekun cc0f0ffd0d Run OKX auto USDC convert before option pick when trading USDC is short.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 00:28:11 +08:00
dekun a9939bbbdd Route manual USDT/USDC convert through trading account (not funding).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 00:23:16 +08:00
dekun 0837982714 Add OKX trading-account USDC auto-swap and lock exit target while in position.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 00:16:47 +08:00
dekun f0b0b91f1b Show risk sizing as a dedicated Plan mode row (比例 / k / 预算).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 23:59:29 +08:00
dekun 5eb169a636 Show live risk-sizing estimate on plan page instead of stale manual qty.
When 以损定仓 is on, state() overlays next-open k/qty/exit from market preview.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 23:52:43 +08:00
dekun a47a9b6377 Polish risk sizing: 2dp money preview, editable ratio/exit, plan badge.
Exit unit and perp/option units scale with k; plan page shows 以损定仓.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 23:48:34 +08:00
dekun 3a4c8d639c Add risk-based position sizing (以损定仓) with per-open k resize.
Manual vs risk modes are exclusive; each open floors k to 1 decimal so estimated premium+fees stay within the loss budget.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 23:32:42 +08:00
dekun 0b51aa15ff Stop sticky header so nav no longer overlaps funds bar on scroll.
Transparent sticky header let scrolling stats text bleed through the nav labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 21:47:08 +08:00
dekun c425a08a8f Rebuild broken .venv when activate is missing after ensurepip failure.
Half-created venvs often keep bin/python3 without activate; directory existence is not enough.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 21:37:04 +08:00
dekun 467203b61d Detect ensurepip and auto-install python3-venv in one-click deploy.
Avoid false OK on bare python3; repair broken .venv; re-check deps after git pull.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 21:33:18 +08:00
dekun 9ec0cacf45 Improve apt-lock wait: stop unattended-upgrades during deploy.
Shows lock holders and optionally stops auto-upgrade so Node install is not stuck after reboot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 21:29:38 +08:00
dekun 17ba41923a Wait for apt lock during deploy on fresh Ubuntu hosts.
Avoid failing Node install when unattended-upgrades holds dpkg; poll up to 10 minutes then retry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 21:20:40 +08:00
dekun c41122ae71 Document cloud server sizing and region guidance.
Add recommended VPS specs and low-latency region notes for deploy and commercial delivery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 20:57:56 +08:00
dekun 101cb3c045 Fix ghost finalize on unknown perp size and open rollback hazards.
Distinguish exchange query None from flat zero; keep opening when size unknown; idempotent half_open cash; scan options while opening; continue manage after recover.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 20:47:29 +08:00
dekun e2a19a1614 Recover stuck opening and harden LIVE open/close reconcile.
Stamp open intent, recover opening from exchange option/perp state, skip resell/reopen when already flat, and persist Binance margin mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 20:32:45 +08:00
dekun 44fd0371b9 Fix OKX abandon residual double-book and fill/residual ledger holes.
After dual-close leaves option pending, continue close_group instead of false residual; reject partial fills on wait timeout; LIVE residual settle allow_negative.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 20:20:36 +08:00
dekun ec87cf2104 Fix LIVE open/close double-book and security audit findings.
Prevent expiry dual-close from re-booking option cash, abandon ledger rejection, margin-mode mismatch, and manual close races; harden fill wait and refuse default AUTH_SECRET on LIVE.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 20:11:29 +08:00
dekun c6e8f6fe1e Support LIVE exchange funds and perp margin mode.
Hide sim equity when LIVE is selected; OKX funds bar uses exchange balances; add cross/isolated for perp only (options stay cash).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 19:57:09 +08:00
dekun 50a4540aaf Remove background from header brand and top nav.
Use transparent text links; active state is accent color only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 19:49:03 +08:00
dekun 9b3693e542 Drop index from option market card title.
Index remains only on the perp quote card.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 19:45:11 +08:00
dekun a668214e5d Show perp mark and option index on market cards.
Uppercase ETH in 选约条件; add 市价 to perp quote; append index after ATM strike title.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 19:41:52 +08:00
dekun b2e23e973f Show qty on plan card and merge float PnL row.
Read perp/option ETH size into 选约条件; combine 永续/期权浮盈 on one line.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 19:38:55 +08:00
dekun e1da830eac Improve desktop settings and trades list UX.
Fold backup/WeCom help into rules; backup list one-per-line (5 visible); trades table with 10/page; hide funds bar on mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 19:31:30 +08:00
dekun 0ee1d8be5d Add fixed-direction switch for perp/option open side.
When enabled, lock perp long→buy Put or short→buy Call with ITM/ATM only; off keeps ATM/ask rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 19:18:12 +08:00
dekun 55109e82d3 Hide SIM label in WeCom; count completed rounds from closed groups.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:15:09 +08:00
dekun 71b5fcae79 Show strategy rules in fold; larger nav; notify only when cannot open.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:12:26 +08:00
dekun a5945f0837 Notify funds short once; check trading only; drop duplicate plan title.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:07:59 +08:00
dekun 6be46a1126 Center funds bar and stack USDT/USDC on two lines.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:03:08 +08:00
dekun 81e51236c0 Align funds to OKX funding/trading only; fold rules; match settings width.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 17:59:28 +08:00
dekun 80c561bdce Show open-capacity labels, enlarge funds, move rules; WeCom on short funds.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 17:53:43 +08:00
dekun 6081fb7e48 Align header with crypto_monitor: brand above nav, shared page width.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 17:45:04 +08:00
dekun f49365b4d4 Constrain funds bar width and fold strategy rules.
Match page max-width on ultrawide and move the plan subtitle into a collapsible rules section under the funds strip.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 17:37:34 +08:00
dekun 5c3bd4b654 Add OKX-style funds bar with USDT/USDC convert and transfer.
SIM uses multi-wallet balances; LIVE hits OKX asset/account APIs and spot USDC-USDT swap. Funds strip hides sim labeling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 17:06:13 +08:00
dekun 1c75db4a19 Split fee stats by leg and hide LIVE slip.
SIM shows perp/option fees and slip separately; LIVE keeps real exchange fees only with slip forced to zero.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:37:18 +08:00
dekun 8518a207a7 Show expiry settlement as index vs strike intrinsic.
Persist settle_index_px and surface formula in trade detail so expiry closes are not mistaken for book fills.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:15:22 +08:00
dekun 3df64aeb2d Show open time and hold duration on position cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:41:56 +08:00
dekun 195e63fb2e Show each residual option on its own line.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 16:02:10 +08:00
dekun 1f6de95711 Open trade detail in a modal dialog on row click.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 15:48:32 +08:00
dekun 70a294c948 Restore hold_timing open-position unit test.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 15:42:10 +08:00
dekun 3d7c453bd1 Fix trades API 500 by supporting sqlite Row in hold timing.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 15:41:27 +08:00
dekun 14c6ba51a8 Show trade open/close times and hold period by target exit.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 15:37:37 +08:00
dekun 0e3a8b08ec Lock Android portrait via fullscreen then orientation.lock.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:30:43 +08:00
dekun ed96b3ffb0 Force portrait layout on phones instead of landscape prompt.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:26:12 +08:00
dekun bc0be9ce1a Lock phone UI to portrait with PWA and landscape gate.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:21:43 +08:00
dekun b4f709024f Fix mobile option card and trade list wrapping.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 07:00:45 +08:00
dekun 60112bf8eb Add mobile monitor layout with bottom nav and glance card.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 06:54:42 +08:00
dekun 56a4007130 Add WeCom markdown alerts and keep target exits while strategy is paused.
Notify open/close/start/pause/fault with SIM vs LIVE venue labels; configure webhook in Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:19:39 +08:00
dekun 4d331da962 Detect Python/Node/PM2 on deploy (install only if missing); uninstall removes /opt/eth_hedge_sim.
Keep .env and DB backup under /root/backups before deleting the install directory.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:08:51 +08:00
dekun 2b348854fe Add DB+.env auto backup with download and upload restore in Settings.
Backups land under /root/eth_hedge_backups; restore accepts zip body for new-server migration and restarts the process.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:05:58 +08:00
dekun 9d3b36826c Include LIVE close-fee estimate in target PnL; rest after manual and emergency close.
Align docs so LIVE exit matches SIM fee buffer; manual full close now enters rest_seconds like auto/emergency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 22:58:10 +08:00
dekun 16efa44ffb Hide manual trade by default, block open while running, auto-refresh auth token.
Also fix flat-side reconcile to check both long and short residuals; document in 更新说明.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 22:43:38 +08:00
dekun 0cf3756b09 Harden LIVE opens with slot claim and exchange reconcile.
Prevent duplicate opens by atomically claiming an opening slot, verifying exchange perp is flat before live orders, setting leverage from ledger, and preferring exchange position size when closing perps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 22:39:27 +08:00
dekun f48ea5bbcc Audit fixes: LIVE symbols/fills/expiry/pending, security harden, add 更新说明.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 22:28:03 +08:00
dekun bc8d1fb127 Force LF for deploy shell scripts via .gitattributes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 22:08:37 +08:00
dekun bffbdafbfc Fix CRLF in deploy shell scripts so Ubuntu bash can run updates.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 22:07:44 +08:00
dekun 8813fd3e37 Use 比特骆驼 logo for PWA/brand marks, remove install button, docs Ubuntu 22.04 only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 22:07:11 +08:00
dekun 86d3c94f32 Rebrand to 比特骆驼自动化对冲系统; clear login autofill and sim subtitle.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 22:02:09 +08:00
dekun cb97a45051 Add PWA install badge for desktop and tablet (manifest, SW, Add to Home Screen).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 21:57:39 +08:00
dekun aca9d80e64 Collapse field hints into a default-folded 规则说明 on strategy and runtime settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 21:52:49 +08:00
dekun ca66c494e7 Use exchange fees, UPL, funding for LIVE PnL; USDC 1:1 to USDT.
Option open P&L stays local (option net); exit target ignores estimated close fees.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 21:49:20 +08:00
dekun b7e055e37b Add always-apply rule: deploy after edits; chat-only when user says 先聊.
Also split strategy settings into subtabs and hide sim equity in LIVE.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 21:41:16 +08:00
dekun dbc86a1ce6 Add Binance live trading, anti-stuck open/close recovery, and configurable rate limits.
OKX/Binance LIVE share half_open and option_closed_perp_pending repair paths; private REST throttles default to 1s and are tunable in settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 21:35:10 +08:00
dekun e666230d0b Add SIM/LIVE switch with API keys saved to .env and OKX live executor.
Enable settings UI for mode/keys, gate strategy start when LIVE is not ready, and stop PM2 from forcing MODE=SIM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 21:08:39 +08:00
dekun c2113b1a57 Add live trading strategy doc with 1+2 ETH standard size and scale factor k.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:53:21 +08:00
dekun 5369b4b04f Document plan-page watch rules: pin held strike, residual list, new ATM when flat.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 19:37:20 +08:00
dekun 38ffbf728b Pin market watch to held option strike while a position is open.
Stop falling back to ATM quotes for unrealized/close PnL after ATM drifts or restart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:37:20 +08:00
dekun 4994aaab16 Implement dual target-close paths and residual option expiry.
Document and enforce: A full dual-leg close, B perp-only when deep OTM with residual archive that does not block next open, and expiry settlement when target is missed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 18:32:05 +08:00
dekun 96e8e5cb70 Show option/perp PnL and fee-deducted net in trade details.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:14:48 +08:00
dekun 21b8f587a2 Clarify expiry settles at intrinsic in strategy docs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:09:47 +08:00
dekun e3671d9798 Settle expiry options at intrinsic value like live exchange.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:08:44 +08:00
dekun a58d97938c Floor option close at intrinsic to fix expiry garbage quotes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:07:20 +08:00
177 changed files with 40663 additions and 2020 deletions
+38
View File
@@ -0,0 +1,38 @@
---
description: 改完必推送部署;用户说「先聊」则只讨论不改代码
alwaysApply: true
---
# 交付与对话模式(硬指令)
## 先聊模式(最高优先)
用户消息含 **「先聊」**(或明确说只讨论、不要改代码、先别动手)时:
- **禁止**改代码、写文件、提交、推送、部署
- 只做讨论、方案对比、审计结论、问答
- 用户之后明确说「改吧 / 实现 / 动手」再进入改代码
## 改完必推送 + 部署
在**本仓库**完成一轮实际代码修改后(用户未说「先聊」、且不是纯问答),必须收尾:
1. **提交**:有改动则 `git add` 相关文件并 `git commit`(勿提交 `.env`、密钥、本地临时脚本如 `scripts/_ssh_*`、`_t.py`
2. **推送**`git push origin HEAD`(或当前跟踪分支)
3. **部署**:用本机脚本更新服务器(git pull + 构建 + pm2),例如:
```powershell
$env:PYTHONIOENCODING='utf-8'
# DEPLOY_PASS 须已在环境中配置;勿把密码写进规则或提交到仓库
& .\.venv\Scripts\python.exe .\scripts\deploy_remote.py
```
- 服务器:`47.236.184.99`,目录 `/opt/eth_hedge_sim`,进程 `eth-hedge-api`
- 部署成功后简要回报:commit、push、health 是否 OK
- 若用户明确说「先别部署 / 只提交不推 / 只推不部署」,则按用户当次要求覆盖本条
## 例外(可不推送部署)
- 「先聊」或只读审计、解释、方案讨论
- 仅改本地未跟踪的临时探测脚本且用户未要求交付
- 推送/部署失败时说明原因与已完成步骤,不要假装成功
+15
View File
@@ -0,0 +1,15 @@
# 比特骆驼中控(本地服务器)
# 一键部署会自动补全缺失项;已有非空值不会覆盖
CONTROL_AUTH_USERNAME=admin
CONTROL_AUTH_PASSWORD=admin123
CONTROL_AUTH_SECRET=change-me-control-secret-please
CONTROL_AUTH_TOKEN_VERSION=1
CONTROL_TOKEN_TTL_SEC=604800
CONTROL_POLL_INTERVAL_SEC=8
# 监控区 SSE 推送间隔(秒)与单机探测超时
CONTROL_SSE_INTERVAL_SEC=1
CONTROL_SSE_PROBE_TIMEOUT_SEC=2.5
CONTROL_HTTP_TIMEOUT_SEC=12
# 局域网免登录:1=开启(仅私网 IP),0=关闭
CONTROL_LAN_AUTH_BYPASS=0
# CONTROL_DB_PATH=/opt/eth_hedge_sim/control/data/control.db
+14 -3
View File
@@ -1,6 +1,7 @@
# eth_hedge_sim — 独立自动对冲模拟盘
# 复制为 .env 后按需填写。模拟阶段禁止真实下单;真密钥不上库。
# eth_hedge_sim — 独立自动对冲(SIM 本地撮合 / LIVE 实盘)
# 复制为 .env 后按需填写。真密钥不上库。
# SIM | LIVE(设置页可改;切 LIVE 须二次确认)
MODE=SIM
ENV_NAME=test
TZ=Asia/Shanghai
@@ -17,7 +18,7 @@ AUTH_PASSWORD=admin123
AUTH_SECRET=change-me-eth-hedge-sim-secret
AUTH_TOKEN_TTL_SEC=604800
# OKXSIM 阶段公共盘口可不填 Key
# OKXSIM 公共盘口可不填LIVE 下单必填
OKX_API_KEY=
OKX_API_SECRET=
OKX_API_PASSPHRASE=
@@ -26,6 +27,10 @@ OKX_WS_PUBLIC=wss://ws.okx.com:8443/ws/v5/public
# 云上一般直连留空;本机受限时再填代理
OKX_HTTP_PROXY=
# 币安私有交易密钥(可落盘;实盘下单后续接入)
BINANCE_API_KEY=
BINANCE_API_SECRET=
# 币安公共行情(SIM
BINANCE_FAPI_BASE=https://fapi.binance.com
BINANCE_EAPI_BASE=https://eapi.binance.com
@@ -50,3 +55,9 @@ CLOSE_BID_MARK_MAX_PCT=30
REST_SECONDS=300
PERP_QTY_ETH=1
OPTION_QTY_ETH=2
# 企业微信群机器人(系统设置页可改)
WECOM_ENABLED=0
WECOM_WEBHOOK_URL=
# 推送标题前缀,多机时区分,如 云A / 云B
WECOM_MACHINE_NAME=
+3
View File
@@ -0,0 +1,3 @@
# Shell scripts must use LF on Ubuntu deploy hosts
*.sh text eol=lf
deploy/**/*.sh text eol=lf
+5
View File
@@ -1,6 +1,7 @@
# env / secrets
.env
.env.local
.env.control
*.pem
# python
@@ -18,6 +19,10 @@ backend/data/*.db
backend/data/*.sqlite
backend/data/*.sqlite3
!backend/data/.gitkeep
control/data/*
!control/data/.gitkeep
control/frontend/node_modules/
control/frontend/dist/
# frontend
frontend/node_modules/
+42 -16
View File
@@ -1,26 +1,45 @@
# eth_hedge_sim
# 比特骆驼自动化对冲系统(eth_hedge_sim
独立自动对冲**模拟盘**:OKX 实盘只读行情 + 本地虚拟资金撮合。
**产品名:比特骆驼自动对冲系统**
工程仓库 / 目录名:`eth_hedge_sim`(与产品名分离,部署路径不变)。
独立自动对冲系统:OKX / 币安行情 + 本地 SIM 撮合,可选 LIVE 实盘。
与现网 `crypto_monitor` **无代码、进程、密钥共用**
仓库:<https://git.bz121.com/dekun/eth_hedge_sim.git>
## 运行环境
| 项 | 约定 |
|----|------|
| 操作系统 | **Ubuntu 22.04** LTS |
| 进程管理 | PM2`eth-hedge-api` |
| 安装目录 | `/opt/eth_hedge_sim` |
| 端口 | **5155** |
| 云服务器(推荐) | **2 vCPU / 2 GB / 40 GB SSD**;地域优先香港、新加坡等低延迟区 |
云服务器最低/推荐/余量规格、地域与带宽选择见 **[开发方案 §6.0 云服务器配置选择说明](docs/开发方案.md#60-云服务器配置选择说明)**。
## 文档
- [开发方案](docs/开发方案.md)
- [代码结构](docs/代码结构.md)
- [商业化与授权方案](docs/商业化与授权方案.md)
- [策略说明](docs/策略说明.md)
- [实盘策略说明](docs/实盘策略说明.md)
- [中控 Fleet 说明](docs/中控Fleet说明.md)(本地中控:多机监控 / 启停 / 更新 / 免密登录)
- [更新说明](docs/更新说明.md)(每次发版追加)
## 访问(测试机)
- 反代:[`https://dc.hyf2.cc`](https://dc.hyf2.cc) → 本机 `5155`PM2: `eth-hedge-api`
- 默认登录:见服务器 `/opt/eth_hedge_sim/.env``AUTH_USERNAME` / `AUTH_PASSWORD`(示例 `admin` / `admin123`
- 登录账号:见服务器 `/opt/eth_hedge_sim/.env``AUTH_USERNAME` / `AUTH_PASSWORD`
- 可在「系统设置」修改用户名/密码;前端固定同源 API,无 API 地址配置项
- **本地中控**`http://<局域网IP>:5160`PM2: `eth-hedge-control`);说明见 [中控 Fleet 说明](docs/中控Fleet说明.md)
## 一键部署 / 更新(禁止 scp 传代码)
服务器目录`/opt/eth_hedge_sim`
目标机:**Ubuntu 22.04**目录 `/opt/eth_hedge_sim`
更新方式:**只允许 `git pull`**,然后构建并 `pm2 startOrReload` 本项目进程。
### 推荐:curl 一键管理器(菜单)
@@ -31,9 +50,11 @@ curl -fsSL https://git.bz121.com/dekun/eth_hedge_sim/raw/branch/main/deploy/mana
菜单:
1. 一键部署
2. 一键卸载(仅停删 `eth-hedge-api`,移走目录并备份 `.env`
3. 更新(`git pull` + 构建 + reload
1. 一键部署策略机
2. 一键部署中控机
3. 一键卸载
4. 更新策略机
5. 更新中控机
0. 退出
已安装后也可:
@@ -42,30 +63,34 @@ curl -fsSL https://git.bz121.com/dekun/eth_hedge_sim/raw/branch/main/deploy/mana
bash /opt/eth_hedge_sim/deploy/manage.sh
```
### 机触发远程更新
### 从开发机触发远程更新(可选)
在能 SSH 到 Ubuntu 服务器的机器上:
```bash
pip install paramiko
set DEPLOY_PASS=*** # Windows PowerShell: $env:DEPLOY_PASS='***'
python scripts/deploy_remote.py
export DEPLOY_PASS='***'
python scripts/deploy_remote.py # 策略机
# python scripts/deploy_control.py # 中控(需 CONTROL_HOST / CONTROL_PASS
```
PM2 进程名:`eth-hedge-api`(端口 **5155**)。禁止 `pm2 restart all`
PM2 进程名:`eth-hedge-api`(端口 **5155**`eth-hedge-control`(端口 **5160**。禁止 `pm2 restart all`
## 本地开发
## 服务器上本地开发 / 调试(Ubuntu 22.04
```bash
python -m venv .venv
.venv\Scripts\activate
cd /opt/eth_hedge_sim
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
copy .env.example .env
cp .env.example .env
python scripts/smoke_market.py
cd backend
uvicorn app.main:app --host 0.0.0.0 --port 5155 --reload
# 另开终端
cd frontend
cd /opt/eth_hedge_sim/frontend
npm ci
npm run dev
```
@@ -76,3 +101,4 @@ npm run dev
- 永续市价;期权只吃买卖一;滑点 = 1×手续费
- 仓位:永续 1 ETH,期权 2 ETH 名义
- 部署更新只用 git pull,不用 scp
- 部署环境固定为 Ubuntu 22.04
+6
View File
@@ -1,6 +1,9 @@
from fastapi import APIRouter
from .auth_routes import router as auth_router
from .backup_routes import router as backup_router
from .fleet import router as fleet_router
from .funds import router as funds_router
from .market import router as market_router
from .plan import router as plan_router
from .settings import router as settings_router
@@ -15,4 +18,7 @@ router.include_router(sim_router)
router.include_router(plan_router)
router.include_router(trades_router)
router.include_router(stats_router)
router.include_router(funds_router)
router.include_router(settings_router)
router.include_router(backup_router)
router.include_router(fleet_router)
+7 -1
View File
@@ -42,7 +42,11 @@ def _b64url_decode(s: str) -> bytes:
def issue_token(username: str, settings: Settings) -> tuple[str, int]:
exp = int(time.time()) + int(settings.auth_token_ttl_sec)
payload = {"u": username, "exp": exp}
payload = {
"u": username,
"exp": exp,
"v": int(settings.auth_token_version),
}
raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
sig = hmac.new(
settings.auth_secret.encode("utf-8"),
@@ -70,6 +74,8 @@ def verify_token(token: str, settings: Settings) -> str:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") from e
if int(payload.get("exp") or 0) < int(time.time()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token expired")
if int(payload.get("v") or 0) != int(settings.auth_token_version):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token revoked")
username = str(payload.get("u") or "")
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
+122 -14
View File
@@ -1,28 +1,93 @@
from __future__ import annotations
import hmac
import time
from collections import defaultdict
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from ..config import Settings, get_settings
from ..credentials import get_credentials, update_credentials
from ..credentials import get_credentials, update_credentials, upsert_env_file
from .auth import LoginRequest, LoginResponse, issue_token, require_user
from .fleet import consume_login_ticket
router = APIRouter(prefix="/api/auth", tags=["auth"])
class FleetExchangeRequest(BaseModel):
ticket: str = Field(min_length=8, max_length=256)
_login_hits: dict[str, list[float]] = defaultdict(list)
class ChangeCredentialsRequest(BaseModel):
current_password: str = Field(min_length=1)
new_username: str = Field(min_length=1, max_length=64)
new_password: str = Field(min_length=4, max_length=128)
new_password: str = Field(min_length=8, max_length=128)
def _client_ip(request: Request) -> str:
xff = request.headers.get("x-forwarded-for") or ""
if xff.strip():
return xff.split(",")[0].strip()
if request.client:
return request.client.host or "unknown"
return "unknown"
def _rate_limit_login(ip: str, settings: Settings) -> None:
now = time.time()
window = float(settings.login_window_sec)
max_n = int(settings.login_max_attempts)
hits = [t for t in _login_hits[ip] if now - t < window]
_login_hits[ip] = hits
if len(hits) >= max_n:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"登录过于频繁,请 {int(window)} 秒后再试",
)
@router.post("/fleet-exchange", response_model=LoginResponse)
async def fleet_exchange(
body: FleetExchangeRequest,
settings: Annotated[Settings, Depends(get_settings)],
) -> LoginResponse:
"""中控签发的一次性 ticket 兑换为普通登录会话(免密)。"""
username = consume_login_ticket(body.ticket)
token, ttl = issue_token(username, settings)
return LoginResponse(
token=token,
username=username,
expires_in=ttl,
env_name=settings.env_name,
mode=settings.mode,
)
@router.post("/login", response_model=LoginResponse)
async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_settings)]) -> LoginResponse:
async def login(
body: LoginRequest,
request: Request,
settings: Annotated[Settings, Depends(get_settings)],
) -> LoginResponse:
ip = _client_ip(request)
_rate_limit_login(ip, settings)
user, pwd = get_credentials()
if body.username != user or body.password != pwd:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
user_ok = hmac.compare_digest(
body.username.encode("utf-8"), user.encode("utf-8")
)
pwd_ok = hmac.compare_digest(
body.password.encode("utf-8"), pwd.encode("utf-8")
)
if not (user_ok and pwd_ok):
_login_hits[ip].append(time.time())
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误"
)
_login_hits.pop(ip, None)
token, ttl = issue_token(body.username, settings)
return LoginResponse(
token=token,
@@ -33,16 +98,46 @@ async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_se
)
@router.get("/branding")
async def branding() -> dict:
"""登录页/顶栏展示用(无敏感信息):与企微通知同一机器名。"""
from ..notify import wecom
return {"machine_name": wecom.wecom_machine_name() or ""}
@router.get("/me")
async def me(username: Annotated[str, Depends(require_user)], settings: Annotated[Settings, Depends(get_settings)]) -> dict:
async def me(
username: Annotated[str, Depends(require_user)],
settings: Annotated[Settings, Depends(get_settings)],
) -> dict:
from ..notify import wecom
return {
"username": username,
"env_name": settings.env_name,
"mode": settings.mode,
"sim": settings.is_sim,
"machine_name": wecom.wecom_machine_name() or "",
}
@router.post("/refresh", response_model=LoginResponse)
async def refresh_token(
username: Annotated[str, Depends(require_user)],
settings: Annotated[Settings, Depends(get_settings)],
) -> LoginResponse:
"""用仍有效的 Bearer 换发新 HMAC token(自动轮换,无需重登)。"""
token, ttl = issue_token(username, settings)
return LoginResponse(
token=token,
username=username,
expires_in=ttl,
env_name=settings.env_name,
mode=settings.mode,
)
@router.post("/change-credentials", response_model=LoginResponse)
async def change_credentials(
body: ChangeCredentialsRequest,
@@ -50,17 +145,30 @@ async def change_credentials(
settings: Annotated[Settings, Depends(get_settings)],
) -> LoginResponse:
_cur_user, cur_pwd = get_credentials()
if body.current_password != cur_pwd:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
if not hmac.compare_digest(
body.current_password.encode("utf-8"), cur_pwd.encode("utf-8")
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确"
)
try:
update_credentials(new_username=body.new_username, new_password=body.new_password)
update_credentials(
new_username=body.new_username, new_password=body.new_password
)
# 作废旧 token
new_ver = int(settings.auth_token_version) + 1
upsert_env_file("AUTH_TOKEN_VERSION", str(new_ver))
get_settings.cache_clear()
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
token, ttl = issue_token(body.new_username.strip(), settings)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
) from e
settings2 = get_settings()
token, ttl = issue_token(body.new_username.strip(), settings2)
return LoginResponse(
token=token,
username=body.new_username.strip(),
expires_in=ttl,
env_name=settings.env_name,
mode=settings.mode,
env_name=settings2.env_name,
mode=settings2.mode,
)
+163
View File
@@ -0,0 +1,163 @@
"""备份下载 / 上传恢复 API。"""
from __future__ import annotations
import asyncio
import logging
import os
import tempfile
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from ..backup import (
backup_settings,
create_backup,
list_backups,
prune_backups,
read_backup_file,
restore_from_zip,
validate_backup_zip,
)
from ..models.db import get_db
from ..strategy import get_engine
from .auth import require_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/backup", tags=["backup"])
class BackupSettingsBody(BaseModel):
auto_enabled: bool | None = None
interval_hours: int | None = Field(default=None, ge=1, le=168)
keep_count: int | None = Field(default=None, ge=1, le=90)
@router.get("/status")
async def backup_status(_user: Annotated[str, Depends(require_user)]) -> dict:
cfg = backup_settings()
return {
**cfg,
"items": list_backups(),
}
@router.put("/settings")
async def put_backup_settings(
body: BackupSettingsBody,
_user: Annotated[str, Depends(require_user)],
) -> dict:
db = get_db()
data = body.model_dump(exclude_none=True)
if "auto_enabled" in data:
db.set_setting("backup_auto_enabled", "1" if data["auto_enabled"] else "0")
if "interval_hours" in data:
db.set_setting("backup_interval_hours", str(int(data["interval_hours"])))
if "keep_count" in data:
db.set_setting("backup_keep_count", str(int(data["keep_count"])))
prune_backups(int(data["keep_count"]))
return await backup_status(_user)
@router.post("/now")
async def backup_now(_user: Annotated[str, Depends(require_user)]) -> dict:
try:
return await asyncio.to_thread(create_backup, reason="manual")
except Exception as e:
logger.exception("backup now failed")
raise HTTPException(status_code=500, detail=f"备份失败: {e}") from e
@router.get("/download/{name}")
async def download_backup(
name: str,
_user: Annotated[str, Depends(require_user)],
) -> FileResponse:
try:
path = read_backup_file(name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
from starlette.background import BackgroundTask
from ..backup import materialize_download_zip
# 下载包剥离 .env,机内完整备份仍保留供恢复
safe = materialize_download_zip(path)
return FileResponse(
safe,
media_type="application/zip",
filename=path.name.replace(".zip", "_noenv.zip"),
background=BackgroundTask(lambda: safe.unlink(missing_ok=True)),
)
@router.post("/restore")
async def restore_backup(
request: Request,
_user: Annotated[str, Depends(require_user)],
x_confirm_phrase: Annotated[str, Header(alias="X-Confirm-Phrase")] = "",
) -> dict:
"""上传 zip 原始字节恢复数据库与 .env(新服务器迁移)。成功后进程退出由 PM2 拉起。"""
if (x_confirm_phrase or "").strip() != "RESTORE":
raise HTTPException(
status_code=400,
detail="请输入确认串 RESTORE(大写)后再恢复(请求头 X-Confirm-Phrase",
)
try:
eng = get_engine()
if eng.matcher.has_open_position():
raise HTTPException(
status_code=409,
detail="有未平仓,请先平仓或紧急全平后再恢复备份",
)
await eng.pause()
except HTTPException:
raise
except Exception as e:
logger.warning("pause before restore: %s", e)
raw = await request.body()
if not raw:
raise HTTPException(status_code=400, detail="请上传 .zip 备份包")
if len(raw) > 80 * 1024 * 1024:
raise HTTPException(status_code=400, detail="备份包过大(上限 80MB")
if raw[:2] != b"PK":
raise HTTPException(status_code=400, detail="文件不是有效的 zip 备份包")
tmp = Path(tempfile.mkstemp(prefix="restore_", suffix=".zip")[1])
try:
tmp.write_bytes(raw)
try:
validate_backup_zip(tmp)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
result = await asyncio.to_thread(restore_from_zip, tmp, make_safety_backup=True)
async def _exit_soon() -> None:
await asyncio.sleep(1.2)
logger.warning("exiting after backup restore for process restart")
os._exit(0)
asyncio.create_task(_exit_soon())
return {
**result,
"detail": "恢复成功,服务即将自动重启以加载 .env 与数据库;请数秒后刷新页面",
}
except HTTPException:
raise
except Exception as e:
logger.exception("restore failed")
raise HTTPException(status_code=500, detail=f"恢复失败: {e}") from e
finally:
try:
tmp.unlink(missing_ok=True)
except Exception:
pass
+520
View File
@@ -0,0 +1,520 @@
"""中控(Fleet)专用 APIX-Fleet-Token 鉴权,不开放资金/下单。"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import logging
import os
import secrets
import subprocess
import threading
import time
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel, Field
from ..config import get_settings
from ..credentials import get_credentials
from ..models.db import get_db
from ..strategy import get_engine
from .auth import require_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/fleet", tags=["fleet"])
_SETTING_HASH = "fleet_api_token_hash"
_TICKET_TTL_SEC = 60
_tickets: dict[str, dict] = {}
_tickets_lock = threading.Lock()
_update_lock = threading.Lock()
_update_state: dict = {"running": False, "started_at_ms": 0, "last_error": ""}
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def fleet_token_configured(db=None) -> bool:
db = db or get_db()
h = (db.get_setting(_SETTING_HASH, "") or "").strip()
return bool(h)
def set_fleet_token(plain: str, db=None) -> None:
db = db or get_db()
plain = (plain or "").strip()
if not plain:
db.set_setting(_SETTING_HASH, "")
return
if len(plain) < 16:
raise ValueError("中控 API Token 至少 16 位")
db.set_setting(_SETTING_HASH, _hash_token(plain))
def clear_fleet_token(db=None) -> None:
set_fleet_token("", db)
def require_fleet_token(
x_fleet_token: Annotated[str | None, Header(alias="X-Fleet-Token")] = None,
authorization: Annotated[str | None, Header()] = None,
) -> str:
db = get_db()
stored = (db.get_setting(_SETTING_HASH, "") or "").strip()
if not stored:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="策略机未配置中控 API Token",
)
provided = (x_fleet_token or "").strip()
if not provided and authorization:
auth = authorization.strip()
if auth.lower().startswith("fleet "):
provided = auth[6:].strip()
if not provided or not hmac.compare_digest(stored, _hash_token(provided)):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid fleet token",
)
return provided
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
def _purge_tickets() -> None:
now = time.time()
dead = [k for k, v in _tickets.items() if float(v.get("exp", 0)) < now]
for k in dead:
_tickets.pop(k, None)
def create_login_ticket(username: str) -> tuple[str, int]:
with _tickets_lock:
_purge_tickets()
ticket = secrets.token_urlsafe(32)
_tickets[ticket] = {"exp": time.time() + _TICKET_TTL_SEC, "u": username}
return ticket, _TICKET_TTL_SEC
def consume_login_ticket(ticket: str) -> str:
ticket = (ticket or "").strip()
if not ticket:
raise HTTPException(status_code=401, detail="invalid ticket")
with _tickets_lock:
_purge_tickets()
meta = _tickets.pop(ticket, None)
if not meta:
raise HTTPException(status_code=401, detail="ticket invalid or used")
if float(meta.get("exp", 0)) < time.time():
raise HTTPException(status_code=401, detail="ticket expired")
username = str(meta.get("u") or "").strip()
if not username:
raise HTTPException(status_code=401, detail="invalid ticket")
return username
class FleetTokenBody(BaseModel):
token: str = Field(default="", max_length=256)
@router.get("/meta")
async def fleet_meta(_user: Annotated[str, Depends(require_user)]) -> dict:
return {
"configured": fleet_token_configured(),
"hint": "在中控生成 Token 后粘贴到此保存;用于远程启停、更新与免密登录。",
}
@router.put("/token")
async def put_fleet_token(
body: FleetTokenBody,
_user: Annotated[str, Depends(require_user)],
) -> dict:
try:
set_fleet_token(body.token)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True, "configured": fleet_token_configured()}
@router.delete("/token")
async def delete_fleet_token(_user: Annotated[str, Depends(require_user)]) -> dict:
clear_fleet_token()
return {"ok": True, "configured": False}
@router.get("/status")
async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
settings = get_settings()
try:
from ..exchange.runtime import load_runtime_settings
from ..strategy.session import get_session
rt = load_runtime_settings()
exchange_name = rt.exchange
sess = get_session()
snap = sess.snapshot() if sess else None
except Exception:
exchange_name = settings.exchange
snap = None
try:
st = get_engine().state()
except Exception:
st = {}
pos = st.get("position") if isinstance(st.get("position"), dict) else {}
hedge_mode = str(
pos.get("hedge_mode")
or st.get("hedge_mode")
or ""
).strip().lower()
is_oo = hedge_mode == "option_option" or bool(pos.get("option2_inst_id"))
legs: list[dict] = []
if pos.get("has_position") or str(pos.get("status") or "") in (
"open",
"half_open",
"option_closed_perp_pending",
"opening",
):
# 期期无永续腿;有 perp 才展示
if not is_oo and (pos.get("perp_side") or pos.get("perp_inst_id")):
legs.append(
{
"kind": "perp",
"side": pos.get("perp_side"),
"inst_id": pos.get("perp_inst_id"),
"qty": pos.get("perp_qty_eth"),
"avg_px": pos.get("perp_entry_px"),
"mark_px": pos.get("perp_mark_px"),
"upl": pos.get("perp_upl"),
"margin": pos.get("perp_margin"),
"premium": None,
}
)
if pos.get("option_side") or pos.get("option_inst_id"):
legs.append(
{
"kind": "option",
"side": pos.get("option_side") or ("call" if is_oo else None),
"inst_id": pos.get("option_inst_id"),
"qty": pos.get("option_qty_eth"),
"avg_px": pos.get("option_entry_px"),
"mark_px": pos.get("option_mark_px"),
"upl": pos.get("option_upl"),
"margin": None,
"premium": pos.get("initial_premium"),
"strike": pos.get("strike"),
}
)
if pos.get("option2_inst_id") or pos.get("option2_side"):
legs.append(
{
"kind": "option",
"side": pos.get("option2_side") or "put",
"inst_id": pos.get("option2_inst_id"),
"qty": pos.get("option2_qty_eth"),
"avg_px": pos.get("option2_entry_px"),
"mark_px": pos.get("option2_mark_px"),
"upl": pos.get("option2_upl"),
"margin": None,
"premium": pos.get("initial_premium2"),
"strike": pos.get("strike2"),
}
)
# 风控展示字段以引擎 state 为准;缺省时回落 settings 表(避免旧进程漏字段)
db = get_db()
def _sf(key: str, default: float) -> float:
try:
return float(db.get_setting(key, str(default)) or default)
except Exception:
return float(default)
def _pick(key: str, default: float | None = None):
if key in st and st.get(key) is not None:
return st.get(key)
if default is None:
return None
return _sf(key, default)
latest_funds = 0.0
try:
from ..sim.funds_wallets import FundsWallets
latest_funds = float(FundsWallets(db).total_usdt_equiv())
except Exception:
try:
from ..sim.ledger import Ledger
latest_funds = float(Ledger(db).snapshot().get("equity") or 0)
except Exception:
latest_funds = 0.0
residuals: list[dict] = []
try:
residuals = get_engine().matcher.list_residual_options_enriched()
except Exception:
residuals = []
return {
"ok": True,
"mode": settings.mode,
"env_name": settings.env_name,
"exchange": exchange_name,
"sim": settings.is_sim,
"latest_funds": latest_funds,
"residuals": residuals,
"market_connected": bool(snap.connected) if snap else False,
"pair": snap.pair.to_dict() if snap and snap.pair else None,
"index_px": (
pos.get("index_px")
if pos.get("index_px") is not None
else (getattr(snap, "index_px", None) if snap else None)
),
"updated_at_ms": snap.updated_at_ms if snap else None,
"strategy": {
"running": st.get("running"),
"phase": st.get("phase"),
"rounds_done": st.get("rounds_done"),
"last_error": st.get("last_error"),
"group_id": pos.get("group_id"),
"rest_left_sec": st.get("rest_left_sec"),
"exit_mode": st.get("exit_mode"),
"exit_target_usdt": st.get("exit_target_usdt"),
"net_profit_target": st.get("net_profit_target"),
"premium_exit_multiple": _pick("premium_exit_multiple"),
"semi_auto_enabled": st.get("semi_auto_enabled"),
"semi_armed": st.get("semi_armed"),
"semi_view_side": st.get("semi_view_side"),
"semi_option_move_points": st.get("semi_option_move_points"),
"semi_perp_exit_unit": st.get("semi_perp_exit_unit"),
"semi_net_exit_target": st.get("semi_net_exit_target"),
"semi_min_option_hours": st.get("semi_min_option_hours"),
"semi_min_option_leverage": st.get("semi_min_option_leverage"),
"semi_moneyness": st.get("semi_moneyness"),
"semi_otm_max_offset": st.get("semi_otm_max_offset"),
"semi_perp_unit": st.get("semi_perp_unit"),
"semi_option_unit": st.get("semi_option_unit"),
"leverage": _pick("leverage", float(settings.leverage)),
"min_option_leverage": _pick(
"min_option_leverage", float(settings.min_option_leverage)
),
"min_option_hours": _pick(
"min_option_hours", float(settings.min_option_hours)
),
"perp_margin_mode": st.get("perp_margin_mode"),
"perp_qty_eth": st.get("perp_qty_eth"),
"option_qty_eth": st.get("option_qty_eth"),
"oo_put_qty_eth": (
st.get("oo_put_qty_eth")
if st.get("oo_put_qty_eth") is not None
else _sf("oo_put_qty_eth", 0.0) or None
),
"sizing_mode": st.get("sizing_mode"),
"risk_last_k": st.get("risk_last_k"),
"risk_sizing_locked": st.get("risk_sizing_locked"),
"risk_sizing_preview": st.get("risk_sizing_preview"),
"risk_loss_pct": _pick("risk_loss_pct", 1.0),
"risk_perp_unit": _pick("risk_perp_unit", 1.0),
"risk_option_unit": _pick("risk_option_unit", 2.0),
"risk_exit_unit": _pick("risk_exit_unit", 15.0),
"martingale_enabled": st.get("martingale_enabled"),
"martingale_doubles": st.get("martingale_doubles"),
"risk_effective_loss_pct": st.get("risk_effective_loss_pct"),
"oo_amplitude_pct": _pick(
"oo_amplitude_pct", float(settings.oo_amplitude_pct)
),
"oo_amplitude_hours": _pick(
"oo_amplitude_hours", float(settings.oo_amplitude_hours)
),
"oo_amplitude_filter_enabled": (
str(
st.get("oo_amplitude_filter_enabled")
if "oo_amplitude_filter_enabled" in st
else db.get_setting(
"oo_amplitude_filter_enabled",
str(settings.oo_amplitude_filter_enabled),
)
)
.strip()
.lower()
in ("1", "true", "yes", "on")
),
"oo_min_option_hours": _pick(
"oo_min_option_hours", float(settings.oo_min_option_hours)
),
"oo_min_leverage": _pick(
"oo_min_leverage", float(settings.oo_min_leverage)
),
"oo_reward_ratio": _pick(
"oo_reward_ratio", float(settings.oo_reward_ratio)
),
"hedge_mode": (
hm
if (
hm := str(
st.get("hedge_mode")
or ("option_option" if is_oo else None)
or db.get_setting("hedge_mode", settings.hedge_mode)
or settings.hedge_mode
or "perp_option"
)
.strip()
.lower()
)
in ("perp_option", "option_option")
else "perp_option"
),
},
"position": {
"status": pos.get("status") or ("open" if pos.get("has_position") else "flat"),
"has_position": bool(pos.get("has_position")),
"hedge_mode": "option_option" if is_oo else "perp_option",
"group_id": pos.get("group_id"),
"open_at_ms": pos.get("open_at_ms"),
"initial_premium": pos.get("initial_premium"),
"initial_premium2": pos.get("initial_premium2"),
"perp_margin": pos.get("perp_margin"),
"exit_target_usdt": pos.get("exit_target_usdt"),
"net_pnl": pos.get("net_pnl"),
"perp_upl": pos.get("perp_upl"),
"option_upl": pos.get("option_upl"),
"option2_upl": pos.get("option2_upl"),
"strike": pos.get("strike"),
"strike2": pos.get("strike2"),
"expiry_ymd": pos.get("expiry_ymd"),
"legs": legs,
},
"update": {
"running": bool(_update_state.get("running")),
"started_at_ms": _update_state.get("started_at_ms") or 0,
"last_error": _update_state.get("last_error") or "",
},
}
@router.get("/stats")
async def fleet_stats(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
"""中控拉取策略机整体统计(同 /api/stats/summaryFleet Token 鉴权)。"""
from .stats import build_stats_summary
return build_stats_summary()
@router.post("/start")
async def fleet_start(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
return await get_engine().start()
@router.post("/pause")
async def fleet_pause(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
return await get_engine().pause()
class ResidualCloseBody(BaseModel):
group_id: str = Field(min_length=1, max_length=128)
@router.post("/residual/close")
async def fleet_residual_close(
body: ResidualCloseBody,
_tok: Annotated[str, Depends(require_fleet_token)],
) -> dict:
"""中控手动平单条残留:只验流动性,不验权利金回收比例。"""
matcher = get_engine().matcher
result = await asyncio.to_thread(matcher.close_residual_manual, body.group_id)
if not result.ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.detail or "平残留失败",
)
return {
"ok": True,
"detail": result.detail,
"data": result.data,
"liquidity_wait": result.liquidity_wait,
}
@router.post("/issue-login")
async def fleet_issue_login(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
username, _ = get_credentials()
ticket, ttl = create_login_ticket(username)
return {
"ok": True,
"ticket": ticket,
"expires_in": ttl,
"login_path": f"/fleet-login?ticket={ticket}",
}
def _run_update_job() -> None:
root = _repo_root()
script = root / "deploy" / "lib" / "update.sh"
if not script.is_file():
script = root / "deploy" / "pull_and_restart.sh"
try:
if os.name == "nt":
_update_state["last_error"] = "update script requires bash (Linux deploy host)"
logger.error("fleet update skipped: not a Linux deploy host")
return
if not script.is_file():
_update_state["last_error"] = f"update script missing: {script}"
logger.error("fleet update: %s", _update_state["last_error"])
return
logger.info("fleet update starting: %s", script)
proc = subprocess.run(
["bash", str(script)],
cwd=str(root),
capture_output=True,
text=True,
timeout=600,
env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "")[-2000:]
_update_state["last_error"] = f"exit={proc.returncode} {err}"
logger.error("fleet update failed: %s", _update_state["last_error"])
else:
_update_state["last_error"] = ""
logger.info("fleet update finished ok")
except Exception as e:
_update_state["last_error"] = str(e)
logger.exception("fleet update exception")
finally:
_update_state["running"] = False
@router.post("/update")
async def fleet_update(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict:
"""接受更新请求:后台跑 deploy update(会 reload 本进程)。"""
with _update_lock:
if _update_state.get("running"):
return {
"ok": True,
"accepted": False,
"running": True,
"msg": "更新已在进行中",
}
_update_state["running"] = True
_update_state["started_at_ms"] = int(time.time() * 1000)
_update_state["last_error"] = ""
def _deferred() -> None:
time.sleep(0.8)
_run_update_job()
threading.Thread(target=_deferred, name="fleet-update", daemon=True).start()
return {
"ok": True,
"accepted": True,
"running": True,
"msg": "已接受更新,进程即将 reload,请稍后探活",
}
+278
View File
@@ -0,0 +1,278 @@
"""资金摘要 / 兑换 / 划转(SIM 本地钱包 + LIVE OKX)。"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Annotated, Any, Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from ..config import get_settings
from ..live.okx_funds import OkxFundsClient, usdc_usdt_mid_rate
from ..models.db import get_db
from ..sim.funds_wallets import SimFundsWallets
from ..sim.ledger import Ledger
from ..strategy.engine import get_engine
from .auth import require_user
router = APIRouter(prefix="/api/funds", tags=["funds"])
SH = ZoneInfo("Asia/Shanghai")
def _pl_ratio(pnls: list[float]) -> float | None:
wins = [x for x in pnls if x > 0]
losses = [abs(x) for x in pnls if x < 0]
if not wins or not losses:
return None
avg_w = sum(wins) / len(wins)
avg_l = sum(losses) / len(losses)
if avg_l <= 1e-12:
return None
return round(avg_w / avg_l, 2)
def _fmt_acct(usdt: float | None, usdc: float | None) -> str:
parts: list[str] = []
if usdt is not None:
parts.append(f"{usdt:.2f}U")
if usdc is not None and abs(usdc) > 1e-8:
parts.append(f"{usdc:.2f} USDC")
return " + ".join(parts) if parts else ""
class ConvertBody(BaseModel):
direction: Literal["usdt_to_usdc", "usdc_to_usdt"]
amount: float = Field(gt=0)
class TransferBody(BaseModel):
ccy: Literal["USDT", "USDC", "usdt", "usdc"] = "USDC"
amount: float = Field(gt=0)
from_account: Literal["funding", "trading"] = "funding"
to_account: Literal["funding", "trading"] = "trading"
@router.get("/summary")
async def funds_summary(_user: Annotated[str, Depends(require_user)]) -> dict[str, Any]:
s = get_settings()
db = get_db()
eng = get_engine()
st = eng.state()
# 不在摘要里强调 SIM/LIVE 文案;仅给前端内部用
mode = "LIVE" if not s.is_sim else "SIM"
exchange = str(st.get("exchange") or s.exchange or "okx").upper()
trading_day = datetime.now(SH).strftime("%Y-%m-%d")
# 顶栏「总交易 / 胜率 / 盈亏比」:用展示口径净盈亏(含到期内在价值修复)
closed = db.fetchall("SELECT * FROM groups WHERE status='closed'")
pnls: list[float] = []
try:
from .trades import _enrich_group, persist_expiry_overlay_if_needed
for r in closed:
g = dict(r)
fills = db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC",
(g["group_id"],),
)
gr = _enrich_group(g, fills)
try:
persist_expiry_overlay_if_needed(db, gr, list(fills))
except Exception:
pass
pnls.append(float(gr.get("net_pnl") or gr.get("realized_pnl") or 0))
except Exception:
pnls = [float(r["realized_pnl"] or 0) for r in closed]
n = len(pnls)
wins = sum(1 for x in pnls if x > 0)
win_rate = (wins / n) if n else 0.0
pos = st.get("position") or {}
realtime = None
pos_st = str(pos.get("status") or "")
if pos_st in ("open", "half_open", "option_closed_perp_pending"):
realtime = float(pos.get("net_pnl") or 0)
if s.is_sim:
wallets = SimFundsWallets(db)
w = wallets.snapshot()
# 若钱包全 0 但账本有权益,补种一次
if wallets.total_usdt_equiv(w) < 1e-9:
eq = float(Ledger(db).snapshot().get("equity") or 0)
if eq > 0:
w = wallets.reset_from_equity(eq)
v = wallets.view()
funding_usdt = float(v["funding_usdt"])
trading_usdt = float(v["trading_usdt"])
funding_usdc = float(v["funding_usdc"])
trading_usdc = float(v["trading_usdc"])
total = wallets.total_usdt_equiv()
rate = usdc_usdt_mid_rate()
else:
rate = usdc_usdt_mid_rate()
funding_usdt = trading_usdt = funding_usdc = trading_usdc = None
total = None
if exchange == "OKX":
client = OkxFundsClient()
try:
bal = client.fetch_balances()
funding_usdt = bal.get("funding_usdt")
trading_usdt = bal.get("trading_usdt")
funding_usdc = bal.get("funding_usdc")
trading_usdc = bal.get("trading_usdc")
r = float(rate) if rate and rate > 0 else 1.0
total = round(
(funding_usdt or 0.0)
+ (trading_usdt or 0.0)
+ ((funding_usdc or 0.0) + (trading_usdc or 0.0)) * r,
2,
)
except Exception as e:
return {
"ok": False,
"mode": mode,
"exchange": exchange,
"detail": str(e),
}
finally:
client.close()
elif exchange in ("BINANCE", "BN"):
from ..live.binance_trade import BinanceTradeClient
client = BinanceTradeClient()
try:
bal = client.fetch_balances()
funding_usdt = bal.get("funding_usdt")
trading_usdt = bal.get("trading_usdt")
funding_usdc = bal.get("funding_usdc")
trading_usdc = bal.get("trading_usdc")
r = float(rate) if rate and rate > 0 else 1.0
total = round(
(funding_usdt or 0.0)
+ (trading_usdt or 0.0)
+ ((funding_usdc or 0.0) + (trading_usdc or 0.0)) * r,
2,
)
except Exception as e:
return {
"ok": False,
"mode": mode,
"exchange": exchange,
"detail": str(e),
}
finally:
try:
client.close()
except Exception:
pass
else:
# 其它交易所:不回退模拟账本(避免实盘显示假资金)
return {
"ok": False,
"mode": mode,
"exchange": exchange,
"detail": f"{exchange} 实盘资金摘要暂未接入,请用交易所 App 查看",
}
return {
"ok": True,
"mode": mode,
"exchange": exchange,
"trading_day": trading_day,
"total_trades": n,
"win_rate": win_rate,
"profit_loss_ratio": _pl_ratio(pnls),
"total_funds": total,
"funding_usdt": funding_usdt,
"trading_usdt": trading_usdt,
"funding_usdc": funding_usdc,
"trading_usdc": trading_usdc,
"funding_label": _fmt_acct(funding_usdt, funding_usdc),
"trading_label": _fmt_acct(trading_usdt, trading_usdc),
"realtime_pnl": realtime,
"usdc_usdt_rate": rate,
"perp_inst_id": str(
db.get_setting("perp_inst_id") or s.perp_inst_id or "ETH-USDT-SWAP"
),
"updated_at_ms": int(datetime.now(timezone.utc).timestamp() * 1000),
}
@router.post("/convert")
async def funds_convert(
body: ConvertBody, _user: Annotated[str, Depends(require_user)]
) -> dict[str, Any]:
"""USDT↔USDC 市价兑换:一律在交易账户(对齐 OKX 现货 cash;SIM 同口径)。"""
s = get_settings()
rate = usdc_usdt_mid_rate()
if s.is_sim:
r = SimFundsWallets(get_db()).convert(
direction=body.direction,
amount=float(body.amount),
rate=rate,
account="trading",
)
if not r.get("ok"):
raise HTTPException(status_code=400, detail=r.get("detail") or "兑换失败")
return r
if str(s.exchange).lower() != "okx":
raise HTTPException(status_code=400, detail="当前仅 OKX 支持 USDC/USDT 兑换")
client = OkxFundsClient()
try:
r = client.spot_swap_usdt_usdc(
direction=body.direction, amount=float(body.amount)
)
finally:
client.close()
try:
from ..strategy.open_capacity import invalidate_live_balance_cache
invalidate_live_balance_cache()
except Exception:
pass
if not r.get("ok"):
raise HTTPException(status_code=400, detail=r.get("detail") or "兑换失败")
return r
@router.post("/transfer")
async def funds_transfer(
body: TransferBody, _user: Annotated[str, Depends(require_user)]
) -> dict[str, Any]:
s = get_settings()
ccy = str(body.ccy).upper()
if s.is_sim:
r = SimFundsWallets(get_db()).transfer(
ccy=ccy,
amount=float(body.amount),
from_account=body.from_account,
to_account=body.to_account,
)
if not r.get("ok"):
raise HTTPException(status_code=400, detail=r.get("detail") or "划转失败")
return r
if str(s.exchange).lower() != "okx":
raise HTTPException(status_code=400, detail="当前仅 OKX 支持账户划转")
client = OkxFundsClient()
try:
r = client.transfer(
ccy=ccy,
amount=float(body.amount),
from_account=body.from_account,
to_account=body.to_account,
)
finally:
client.close()
try:
from ..strategy.open_capacity import invalidate_live_balance_cache
invalidate_live_balance_cache()
except Exception:
pass
if not r.get("ok"):
raise HTTPException(status_code=400, detail=r.get("detail") or "划转失败")
return r
+87
View File
@@ -0,0 +1,87 @@
"""交易组持仓周期:目标出场以策略平仓时刻为准;永续先平则以永续平仓为准。"""
from __future__ import annotations
from typing import Any, Mapping, Sequence
# 目标平仓(含 15U / 权利金倍数 / 只平永续)
_TARGET_REASONS = frozenset({"fixed_usdt", "premium_multiple", "target_perp_only"})
def _ts(v: Any) -> int | None:
if v is None:
return None
try:
n = int(v)
except (TypeError, ValueError):
return None
return n if n > 0 else None
def _as_map(row: Mapping[str, Any] | Any) -> Mapping[str, Any]:
if isinstance(row, Mapping):
return row
try:
return dict(row)
except Exception:
return {}
def first_perp_close_ts_ms(fills: Sequence[Mapping[str, Any] | Any]) -> int | None:
"""永续平仓成交时间(目标只平永续时作为持仓结束时刻)。"""
best: int | None = None
for raw in fills:
f = _as_map(raw)
if str(f.get("leg") or "") != "perp":
continue
if str(f.get("action") or "") != "close":
continue
ts = _ts(f.get("ts_ms"))
if ts is None:
continue
if best is None or ts < best:
best = ts
return best
def hold_timing(
group: Mapping[str, Any] | Any, fills: Sequence[Mapping[str, Any] | Any]
) -> dict[str, Any]:
"""
返回展示用开仓/平仓/持仓时长。
- 开仓:groups.open_at_ms
- 平仓(策略持仓周期):
- `target_perp_only` / `option_residual`:永续平仓 fill 时间
- 其它已平:groups.close_at_ms(缺则回退成交)
"""
g = _as_map(group)
open_ms = _ts(g.get("open_at_ms"))
status = str(g.get("status") or "")
reason = str(g.get("close_reason") or "")
group_close = _ts(g.get("close_at_ms"))
perp_close = first_perp_close_ts_ms(fills)
use_perp = reason == "target_perp_only" or status == "option_residual"
if use_perp:
close_ms = perp_close or group_close
basis = "perp"
elif status == "open":
close_ms = None
basis = "open"
else:
close_ms = group_close
if close_ms is None and reason in _TARGET_REASONS:
close_ms = perp_close
basis = "group"
hold_ms: int | None = None
if open_ms is not None and close_ms is not None and close_ms >= open_ms:
hold_ms = close_ms - open_ms
return {
"hold_open_at_ms": open_ms,
"hold_close_at_ms": close_ms,
"hold_ms": hold_ms,
"hold_basis": basis,
}
+16 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from ..market import get_gateway
from .auth import require_user
@@ -18,6 +18,21 @@ async def market_snapshot(_user: Annotated[str, Depends(require_user)]) -> dict:
return snap
@router.get("/option-ladder")
async def market_option_ladder(
_user: Annotated[str, Depends(require_user)],
wings: int = Query(default=5, ge=1, le=12),
side: str = Query(default="call", pattern="^(call|put)$"),
min_hours: float = Query(default=30, ge=1, le=720),
) -> dict:
"""半自动页单边报价:选剩余时长≥min_hours 的最近到期。"""
gw = get_gateway()
ladder = getattr(gw, "option_ladder", None)
if not callable(ladder):
raise HTTPException(status_code=501, detail="当前会话不支持 option-ladder")
return ladder(wings=wings, side=side, min_hours=min_hours)
@router.post("/realign")
async def market_realign(_user: Annotated[str, Depends(require_user)]) -> dict:
"""手动重对齐次日到期 ATM 合约(运维/调试用)。"""
+88 -2
View File
@@ -1,9 +1,12 @@
from __future__ import annotations
from typing import Annotated
import asyncio
from typing import Annotated, Literal
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..models.db import get_db
from ..strategy import get_engine
from .auth import require_user
@@ -28,3 +31,86 @@ async def plan_pause(_user: Annotated[str, Depends(require_user)]) -> dict:
@router.post("/emergency-close")
async def plan_emergency(_user: Annotated[str, Depends(require_user)]) -> dict:
return await get_engine().emergency_close()
class SemiArmBody(BaseModel):
armed: bool = True
class SemiParamsBody(BaseModel):
"""首页半自动本单参数(不进系统设置表单主路径,但落同一 settings 表)。"""
semi_view_side: Literal["long", "short"] | None = None
semi_option_move_points: float | None = Field(default=None, ge=1, le=5000)
semi_perp_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000)
semi_min_option_hours: float | None = Field(default=None, ge=1, le=720)
semi_min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
semi_moneyness: Literal["itm", "atm", "otm"] | None = None
semi_otm_max_offset: float | None = Field(default=None, ge=1, le=5000)
semi_perp_unit: float | None = Field(default=None, ge=0.01, le=100)
semi_option_unit: float | None = Field(default=None, ge=0.01, le=100)
@router.post("/semi/arm")
async def plan_semi_arm(
body: SemiArmBody,
_user: Annotated[str, Depends(require_user)],
) -> dict:
return get_engine().arm_semi(armed=bool(body.armed))
@router.put("/semi/params")
async def plan_semi_params(
body: SemiParamsBody,
_user: Annotated[str, Depends(require_user)],
) -> dict:
eng = get_engine()
if eng.matcher.has_open_position():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="有未平仓,禁止修改半自动本单参数",
)
from ..strategy.semi_auto import is_armed
if is_armed(eng.ledger):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="已授权盯开,禁止改本单参数;请先取消授权",
)
st = eng.db.fetchone("SELECT phase FROM strategy_state WHERE id=1")
phase = str(st["phase"] or "") if st else ""
if phase in ("wait_signal", "opening", "open", "closing", "liquidity_wait"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"当前阶段 {phase} 禁止修改半自动本单参数",
)
db = get_db()
data = body.model_dump(exclude_none=True)
for k, v in data.items():
db.set_setting(k, str(v))
return eng.state()
class ResidualCloseBody(BaseModel):
group_id: str = Field(min_length=1, max_length=128)
@router.post("/residual/close")
async def plan_residual_close(
body: ResidualCloseBody,
_user: Annotated[str, Depends(require_user)],
) -> dict:
"""手动平单条残留:只验流动性,不验权利金回收比例。"""
matcher = get_engine().matcher
result = await asyncio.to_thread(matcher.close_residual_manual, body.group_id)
if not result.ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.detail or "平残留失败",
)
return {
"ok": True,
"detail": result.detail,
"data": result.data,
"liquidity_wait": result.liquidity_wait,
}
+65
View File
@@ -0,0 +1,65 @@
"""公开行情辅助:补历史到期结算指数展示(不发明成交现金)。"""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.request
from typing import Any
logger = logging.getLogger(__name__)
_CACHE: dict[int, float] = {}
_CACHE_MAX = 256
def looks_binance_option(inst_id: str | None) -> bool:
return "USD_UM" in str(inst_id or "")
def eth_usdt_close_at_ms(ts_ms: int | None) -> float | None:
"""币安 ETHUSDT 1m K 线收盘价(近似期权结算指数)。失败返回 None。"""
if ts_ms is None:
return None
try:
ms = int(ts_ms)
except (TypeError, ValueError):
return None
if ms <= 0:
return None
minute = (ms // 60_000) * 60_000
cached = _CACHE.get(minute)
if cached is not None:
return cached
url = (
"https://api.binance.com/api/v3/klines"
f"?symbol=ETHUSDT&interval=1m&startTime={minute}&limit=1"
)
try:
with urllib.request.urlopen(url, timeout=4) as resp:
raw = resp.read().decode("utf-8", "replace")
rows = json.loads(raw)
if not rows:
return None
close_px = float(rows[0][4])
if close_px <= 0:
return None
if len(_CACHE) >= _CACHE_MAX:
_CACHE.clear()
_CACHE[minute] = close_px
return close_px
except (urllib.error.URLError, TimeoutError, ValueError, TypeError, IndexError) as e:
logger.debug("eth_usdt_close_at_ms failed ms=%s: %s", minute, e)
return None
def maybe_public_settle_index(g: dict[str, Any]) -> float | None:
"""库内无结算价时,币安期权到期组用公开 ETHUSDT 收盘近似。"""
if str(g.get("close_reason") or "") != "expiry":
return None
inst = g.get("option_inst_id") or g.get("option2_inst_id")
if not looks_binance_option(str(inst) if inst else None):
return None
ts = g.get("close_at_ms") or g.get("hold_close_at_ms")
return eth_usdt_close_at_ms(ts if ts is not None else None)
+756 -1
View File
@@ -1,11 +1,18 @@
from __future__ import annotations
from typing import Annotated
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from ..config import get_settings
from ..env_store import (
binance_keys_configured,
live_ready,
mask_secret,
okx_keys_configured,
upsert_env_keys,
)
from ..exchange.runtime import (
load_runtime_settings,
normalize_exchange_name,
@@ -26,16 +33,57 @@ KEYS = (
"net_profit_target",
"premium_exit_multiple",
"rest_seconds",
"live_order_interval_sec",
"skip_weekends",
"one_expiry_per_day",
"initial_equity",
"leverage",
"perp_margin_mode",
"min_option_hours",
"min_option_leverage",
"atm_open_offset_enabled",
"max_atm_open_offset",
"fixed_direction_enabled",
"fixed_perp_side",
"semi_auto_enabled",
"semi_armed",
"semi_view_side",
"semi_option_move_points",
"semi_perp_exit_unit",
"semi_min_option_hours",
"semi_min_option_leverage",
"semi_moneyness",
"semi_otm_max_offset",
"semi_perp_unit",
"semi_option_unit",
"close_bid_mark_max_pct",
"residual_min_premium_pct",
"residual_close_check_sec",
"perp_qty_eth",
"option_qty_eth",
"show_manual_trade_buttons",
"sizing_mode",
"risk_leverage_basis",
"risk_loss_mode",
"risk_loss_pct",
"risk_loss_usdt",
"risk_capital_source",
"risk_manual_capital_usdt",
"risk_perp_unit",
"risk_option_unit",
"risk_exit_unit",
"martingale_enabled",
"martingale_start_after_loss_days",
"martingale_max_doubles",
"hedge_mode",
"oo_amplitude_pct",
"oo_amplitude_hours",
"oo_amplitude_filter_enabled",
"oo_min_option_hours",
"oo_min_leverage",
"oo_reward_ratio",
"oo_budget_cushion",
"oo_strike_max_dev_pct",
)
@@ -46,17 +94,64 @@ class StrategySettingsBody(BaseModel):
net_profit_target: float | None = Field(default=None, ge=0.1, le=1_000_000)
premium_exit_multiple: float | None = Field(default=None, ge=0.1, le=100)
rest_seconds: int | None = Field(default=None, ge=0, le=3600)
live_order_interval_sec: float | None = Field(default=None, ge=0.2, le=30)
skip_weekends: bool | None = None
one_expiry_per_day: bool | None = None
initial_equity: float | None = Field(default=None, ge=1000, le=10_000_000)
leverage: float | None = Field(default=None, ge=1, le=125)
perp_margin_mode: str | None = Field(default=None, pattern="^(cross|isolated)$")
min_option_hours: float | None = Field(default=None, ge=1, le=720)
min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
atm_open_offset_enabled: bool | None = None
max_atm_open_offset: float | None = Field(default=None, ge=0, le=100)
fixed_direction_enabled: bool | None = None
fixed_perp_side: str | None = Field(default=None, pattern="^(long|short)$")
semi_auto_enabled: bool | None = None
# semi_armed 仅允许经 /api/plan/semi/arm,不接受设置接口写入
semi_view_side: str | None = Field(default=None, pattern="^(long|short)$")
semi_option_move_points: float | None = Field(default=None, ge=1, le=5000)
semi_perp_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000)
semi_min_option_hours: float | None = Field(default=None, ge=1, le=720)
semi_min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
semi_moneyness: str | None = Field(default=None, pattern="^(itm|atm|otm)$")
semi_otm_max_offset: float | None = Field(default=None, ge=1, le=5000)
semi_perp_unit: float | None = Field(default=None, ge=0.01, le=100)
semi_option_unit: float | None = Field(default=None, ge=0.01, le=100)
close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100)
residual_min_premium_pct: float | None = Field(default=None, ge=1, le=100)
residual_close_check_sec: int | None = Field(default=None, ge=30, le=86400)
perp_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
option_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
show_manual_trade_buttons: bool | None = None
exchange: str | None = Field(default=None, pattern="^(okx|binance|bn)$")
sizing_mode: str | None = Field(default=None, pattern="^(manual|risk_based)$")
risk_leverage_basis: str | None = Field(
default=None, pattern="^(actual|selection)$"
)
risk_loss_mode: str | None = Field(default=None, pattern="^(percent|absolute)$")
risk_loss_pct: float | None = Field(default=None, ge=0.01, le=100)
risk_loss_usdt: float | None = Field(default=None, ge=0.1, le=1_000_000)
risk_capital_source: str | None = Field(
default=None, pattern="^(trading_account|manual)$"
)
risk_manual_capital_usdt: float | None = Field(default=None, ge=1, le=100_000_000)
risk_perp_unit: float | None = Field(default=None, ge=0.01, le=100)
risk_option_unit: float | None = Field(default=None, ge=0.01, le=100)
risk_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000)
martingale_enabled: bool | None = None
martingale_start_after_loss_days: int | None = Field(default=None, ge=1, le=30)
martingale_max_doubles: int | None = Field(default=None, ge=1, le=10)
hedge_mode: str | None = Field(
default=None, pattern="^(perp_option|option_option)$"
)
oo_amplitude_pct: float | None = Field(default=None, ge=0.1, le=50)
oo_amplitude_hours: float | None = Field(default=None, ge=1, le=168)
oo_amplitude_filter_enabled: bool | None = None
oo_min_option_hours: float | None = Field(default=None, ge=1, le=720)
oo_min_leverage: float | None = Field(default=None, ge=1, le=10000)
oo_reward_ratio: float | None = Field(default=None, ge=0.5, le=20)
oo_budget_cushion: float | None = Field(default=None, ge=0.5, le=1.0)
oo_strike_max_dev_pct: float | None = Field(default=None, ge=0.1, le=10)
def _as_bool(raw: str | None, default: bool) -> bool:
@@ -65,6 +160,15 @@ def _as_bool(raw: str | None, default: bool) -> bool:
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def _risk_preview_safe() -> dict:
try:
from ..strategy.risk_sizing import preview_risk_sizing
return preview_risk_sizing()
except Exception as e:
return {"ok": False, "detail": f"预览失败: {e}", "risk_based": False}
def _read_settings() -> dict:
db = get_db()
s = get_settings()
@@ -89,13 +193,37 @@ def _read_settings() -> dict:
"rest_seconds": int(
float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds)
),
"live_order_interval_sec": float(
db.get_setting(
"live_order_interval_sec", str(s.live_order_interval_sec)
)
or s.live_order_interval_sec
),
"skip_weekends": _as_bool(
db.get_setting("skip_weekends", str(s.skip_weekends)), s.skip_weekends
),
"one_expiry_per_day": _as_bool(
db.get_setting("one_expiry_per_day", str(s.one_expiry_per_day)),
s.one_expiry_per_day,
),
"initial_equity": float(
db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity
),
"leverage": float(db.get_setting("leverage", str(s.leverage)) or s.leverage),
"perp_margin_mode": (
mm
if (
mm := str(
db.get_setting("perp_margin_mode", s.perp_margin_mode)
or s.perp_margin_mode
or "cross"
)
.strip()
.lower()
)
in ("cross", "isolated")
else "cross"
),
"min_option_hours": float(
db.get_setting("min_option_hours", str(s.min_option_hours))
or s.min_option_hours
@@ -114,16 +242,248 @@ def _read_settings() -> dict:
db.get_setting("max_atm_open_offset", str(s.max_atm_open_offset))
or s.max_atm_open_offset
),
"fixed_direction_enabled": _as_bool(
db.get_setting(
"fixed_direction_enabled", str(s.fixed_direction_enabled)
),
s.fixed_direction_enabled,
),
"fixed_perp_side": (
side
if (
side := str(
db.get_setting("fixed_perp_side", s.fixed_perp_side)
or s.fixed_perp_side
)
.strip()
.lower()
)
in ("long", "short")
else "long"
),
"semi_auto_enabled": _as_bool(
db.get_setting("semi_auto_enabled", str(s.semi_auto_enabled)),
s.semi_auto_enabled,
),
"semi_armed": _as_bool(
db.get_setting("semi_armed", str(s.semi_armed)),
s.semi_armed,
),
"semi_view_side": (
sv
if (
sv := str(
db.get_setting("semi_view_side", s.semi_view_side)
or s.semi_view_side
)
.strip()
.lower()
)
in ("long", "short")
else "long"
),
"semi_option_move_points": float(
db.get_setting(
"semi_option_move_points", str(s.semi_option_move_points)
)
or s.semi_option_move_points
),
"semi_perp_exit_unit": float(
db.get_setting("semi_perp_exit_unit", str(s.semi_perp_exit_unit))
or s.semi_perp_exit_unit
),
"semi_min_option_hours": float(
db.get_setting(
"semi_min_option_hours", str(s.semi_min_option_hours)
)
or s.semi_min_option_hours
),
"semi_min_option_leverage": float(
db.get_setting(
"semi_min_option_leverage", str(s.semi_min_option_leverage)
)
or s.semi_min_option_leverage
),
"semi_moneyness": (
lambda m: m
if m in ("itm", "atm", "otm")
else "otm"
)(
str(
db.get_setting("semi_moneyness", s.semi_moneyness) or s.semi_moneyness
)
.strip()
.lower()
),
"semi_otm_max_offset": float(
db.get_setting("semi_otm_max_offset", str(s.semi_otm_max_offset))
or s.semi_otm_max_offset
),
"semi_perp_unit": float(
db.get_setting("semi_perp_unit", str(s.semi_perp_unit)) or s.semi_perp_unit
),
"semi_option_unit": float(
db.get_setting("semi_option_unit", str(s.semi_option_unit))
or s.semi_option_unit
),
"close_bid_mark_max_pct": float(
db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct))
or s.close_bid_mark_max_pct
),
"residual_min_premium_pct": float(
db.get_setting(
"residual_min_premium_pct", str(s.residual_min_premium_pct)
)
or s.residual_min_premium_pct
),
"residual_close_check_sec": int(
float(
db.get_setting(
"residual_close_check_sec", str(s.residual_close_check_sec)
)
or s.residual_close_check_sec
)
),
"perp_qty_eth": float(
db.get_setting("perp_qty_eth", str(s.perp_qty_eth)) or s.perp_qty_eth
),
"option_qty_eth": float(
db.get_setting("option_qty_eth", str(s.option_qty_eth)) or s.option_qty_eth
),
"show_manual_trade_buttons": _as_bool(
db.get_setting("show_manual_trade_buttons", "0"), False
),
"sizing_mode": (
sm
if (
sm := str(db.get_setting("sizing_mode", "manual") or "manual")
.strip()
.lower()
)
in ("manual", "risk_based")
else "manual"
),
"risk_leverage_basis": (
rb
if (
rb := str(
db.get_setting(
"risk_leverage_basis",
str(s.risk_leverage_basis),
)
or s.risk_leverage_basis
or "selection"
)
.strip()
.lower()
)
in ("actual", "selection")
else "selection"
),
"risk_loss_mode": (
lm
if (
lm := str(db.get_setting("risk_loss_mode", "percent") or "percent")
.strip()
.lower()
)
in ("percent", "absolute")
else "percent"
),
"risk_loss_pct": float(db.get_setting("risk_loss_pct", "1") or 1),
"risk_loss_usdt": float(db.get_setting("risk_loss_usdt", "15") or 15),
"risk_capital_source": (
cs
if (
cs := str(
db.get_setting("risk_capital_source", "trading_account")
or "trading_account"
)
.strip()
.lower()
)
in ("trading_account", "manual")
else "trading_account"
),
"risk_manual_capital_usdt": float(
db.get_setting("risk_manual_capital_usdt", "10000") or 10000
),
"risk_perp_unit": float(db.get_setting("risk_perp_unit", "1") or 1),
"risk_option_unit": float(db.get_setting("risk_option_unit", "2") or 2),
"risk_exit_unit": float(db.get_setting("risk_exit_unit", "15") or 15),
"martingale_enabled": _as_bool(
db.get_setting(
"martingale_enabled", str(s.martingale_enabled)
),
s.martingale_enabled,
),
"martingale_start_after_loss_days": int(
float(
db.get_setting(
"martingale_start_after_loss_days",
str(s.martingale_start_after_loss_days),
)
or s.martingale_start_after_loss_days
)
),
"martingale_max_doubles": int(
float(
db.get_setting(
"martingale_max_doubles", str(s.martingale_max_doubles)
)
or s.martingale_max_doubles
)
),
"hedge_mode": (
hm
if (
hm := str(
db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode
)
.strip()
.lower()
)
in ("perp_option", "option_option")
else "perp_option"
),
"oo_amplitude_pct": float(
db.get_setting("oo_amplitude_pct", str(s.oo_amplitude_pct))
or s.oo_amplitude_pct
),
"oo_amplitude_hours": float(
db.get_setting("oo_amplitude_hours", str(s.oo_amplitude_hours))
or s.oo_amplitude_hours
),
"oo_amplitude_filter_enabled": _as_bool(
db.get_setting(
"oo_amplitude_filter_enabled",
str(s.oo_amplitude_filter_enabled),
),
s.oo_amplitude_filter_enabled,
),
"oo_min_option_hours": float(
db.get_setting("oo_min_option_hours", str(s.oo_min_option_hours))
or s.oo_min_option_hours
),
"oo_min_leverage": float(
db.get_setting("oo_min_leverage", str(s.oo_min_leverage))
or s.oo_min_leverage
),
"oo_reward_ratio": float(
db.get_setting("oo_reward_ratio", str(s.oo_reward_ratio))
or s.oo_reward_ratio
),
"oo_budget_cushion": float(
db.get_setting("oo_budget_cushion", str(s.oo_budget_cushion))
or s.oo_budget_cushion
),
"oo_strike_max_dev_pct": float(
db.get_setting(
"oo_strike_max_dev_pct", str(s.oo_strike_max_dev_pct)
)
or s.oo_strike_max_dev_pct
),
"risk_sizing_preview": _risk_preview_safe(),
"exchange": rt.exchange,
"perp_inst_id": rt.perp_inst_id,
"option_inst_family": rt.option_inst_family,
@@ -145,8 +505,13 @@ async def put_strategy_settings(
db = get_db()
s = get_settings()
data = body.model_dump(exclude_none=True)
# 授权态只经 /api/plan/semi/arm;设置接口不可伪造 armed=true
data.pop("semi_armed", None)
equity_to_apply: float | None = None
switch_to: str | None = None
old_hedge = str(
db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode or "perp_option"
).strip().lower()
if "exchange" in data:
new_ex = normalize_exchange_name(str(data.pop("exchange")))
@@ -174,6 +539,189 @@ async def put_strategy_settings(
)
equity_to_apply = new_eq
if "perp_margin_mode" in data:
new_mm = str(data["perp_margin_mode"]).strip().lower()
old_mm = str(
db.get_setting("perp_margin_mode", s.perp_margin_mode) or s.perp_margin_mode
).strip().lower()
if new_mm != old_mm and Matcher(db).has_open_position():
raise HTTPException(
status_code=409,
detail="有未平仓,无法切换永续保证金模式;请先平仓后再改",
)
# 持仓中禁止改动会影响本组成交/出场的参数(基线:运行中无人工开平仓,策略锁定本组成交)
if Matcher(db).has_open_position():
locked_keys = (
"net_profit_target",
"exit_mode",
"premium_exit_multiple",
"perp_qty_eth",
"option_qty_eth",
"leverage",
"sizing_mode",
"risk_perp_unit",
"risk_option_unit",
"risk_exit_unit",
"risk_loss_mode",
"risk_loss_pct",
"risk_loss_usdt",
"risk_capital_source",
"risk_manual_capital_usdt",
"martingale_enabled",
"martingale_start_after_loss_days",
"martingale_max_doubles",
"hedge_mode",
"oo_amplitude_pct",
"oo_amplitude_hours",
"oo_amplitude_filter_enabled",
"oo_min_option_hours",
"oo_min_leverage",
"oo_reward_ratio",
"oo_budget_cushion",
"oo_strike_max_dev_pct",
"semi_view_side",
"semi_option_move_points",
"semi_perp_exit_unit",
"semi_min_option_hours",
"semi_min_option_leverage",
"semi_moneyness",
"semi_otm_max_offset",
"semi_perp_unit",
"semi_option_unit",
"semi_auto_enabled",
)
hit = [k for k in locked_keys if k in data]
if hit:
raise HTTPException(
status_code=409,
detail=f"有未平仓,禁止修改本组成交相关参数:{', '.join(hit)};请先平仓",
)
# 以损定仓 ↔ 手动仓位互斥;开启以损定仓时强制 fixed_usdt,并忽略手填名义/出场
hedge_mode = str(
data.get(
"hedge_mode",
db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode,
)
).strip().lower()
if hedge_mode not in ("perp_option", "option_option"):
hedge_mode = "perp_option"
data["hedge_mode"] = hedge_mode
if hedge_mode == "option_option":
# 期期:强制以损定仓 + 亏损幅度%
data["sizing_mode"] = "risk_based"
data["risk_loss_mode"] = "percent"
data["exit_mode"] = "fixed_usdt"
data.pop("perp_qty_eth", None)
data["fixed_direction_enabled"] = False
data["semi_auto_enabled"] = False
data["semi_armed"] = False
# 开关半自动时强制清授权,避免陈旧 armed 或关半自动后落入全自动误开
if "semi_auto_enabled" in data:
data["semi_armed"] = False
sizing_mode = str(
data.get(
"sizing_mode",
db.get_setting("sizing_mode", "manual") or "manual",
)
).strip().lower()
if sizing_mode == "risk_based":
# 永期以损:允许 exit_mode=fixed_usdt(基数×k)或 premium_multiple(权利金×倍)
# 期期仍强制 fixed(目标由预算×盈亏比写入)
if hedge_mode == "option_option":
data["exit_mode"] = "fixed_usdt"
data.pop("perp_qty_eth", None)
data.pop("option_qty_eth", None)
if str(data.get("exit_mode") or db.get_setting("exit_mode", "") or "").strip().lower() != "premium_multiple":
data.pop("net_profit_target", None)
loss_mode = str(
data.get(
"risk_loss_mode",
db.get_setting("risk_loss_mode", "percent") or "percent",
)
).strip().lower()
if loss_mode == "absolute":
loss_u = data.get("risk_loss_usdt")
if loss_u is None:
loss_u = float(db.get_setting("risk_loss_usdt", "0") or 0)
if float(loss_u) <= 0:
raise HTTPException(
status_code=400,
detail="以损定仓选用亏损值时,须填写 risk_loss_usdt > 0",
)
else:
src = str(
data.get(
"risk_capital_source",
db.get_setting("risk_capital_source", "trading_account")
or "trading_account",
)
).strip().lower()
if src == "manual":
cap = data.get("risk_manual_capital_usdt")
if cap is None:
cap = float(db.get_setting("risk_manual_capital_usdt", "0") or 0)
if float(cap) <= 0:
raise HTTPException(
status_code=400,
detail="以损定仓选用单独本金时,须填写 risk_manual_capital_usdt > 0",
)
pct = data.get("risk_loss_pct")
if pct is None:
pct = float(db.get_setting("risk_loss_pct", "0") or 0)
if float(pct) <= 0:
raise HTTPException(
status_code=400,
detail="以损定仓选用亏损幅度时,须填写 risk_loss_pct > 0",
)
# 倍投:仅以损定仓 + 亏损幅度% + 基础幅度≤3%;条件不满足则强制关闭
from ..strategy.risk_sizing import MARTINGALE_MAX_BASE_PCT
loss_mode_final = str(
data.get(
"risk_loss_mode",
db.get_setting("risk_loss_mode", "percent") or "percent",
)
).strip().lower()
pct_final = data.get("risk_loss_pct")
if pct_final is None:
pct_final = float(db.get_setting("risk_loss_pct", "1") or 1)
else:
pct_final = float(pct_final)
existing_mg = _as_bool(
db.get_setting("martingale_enabled", str(s.martingale_enabled)),
s.martingale_enabled,
)
want_mg = (
bool(data["martingale_enabled"])
if "martingale_enabled" in data
else existing_mg
)
mg_eligible = (
sizing_mode == "risk_based"
and loss_mode_final in ("percent", "pct", "%", "幅度")
and float(pct_final) <= MARTINGALE_MAX_BASE_PCT + 1e-12
)
if want_mg and not mg_eligible:
explicit_on = "martingale_enabled" in data and bool(data["martingale_enabled"])
if explicit_on:
if sizing_mode != "risk_based":
reason = "倍投模式仅可在以损定仓下开启"
elif loss_mode_final not in ("percent", "pct", "%", "幅度"):
reason = "倍投模式仅可在「亏损幅度%」下开启"
else:
reason = (
f"以损定仓亏损幅度超过 {MARTINGALE_MAX_BASE_PCT:g}% 时不可启用倍投"
)
raise HTTPException(status_code=400, detail=reason)
data["martingale_enabled"] = False
elif not mg_eligible:
data["martingale_enabled"] = False
for k, v in data.items():
if k in KEYS:
db.set_setting(k, str(v))
@@ -194,4 +742,211 @@ async def put_strategy_settings(
detail=f"交易所已切换为 {switch_to},但行情重连失败: {e}",
) from e
new_hedge = str(
db.get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode or "perp_option"
).strip().lower()
if new_hedge != old_hedge and switch_to is None:
# 对冲模式切换:强制按新模式重对齐监控合约(ATM ↔ 虚值)
try:
from ..strategy.session import get_session
await get_session().ensure_atm_async(force=True)
except Exception:
pass
return _read_settings()
class RuntimeSettingsBody(BaseModel):
mode: Literal["SIM", "LIVE"] | None = None
confirm_live: bool | None = False
confirm_live_phrase: str | None = None
okx_api_key: str | None = None
okx_api_secret: str | None = None
okx_api_passphrase: str | None = None
binance_api_key: str | None = None
binance_api_secret: str | None = None
def _runtime_payload() -> dict:
s = get_settings()
rt = load_runtime_settings()
mode = "SIM" if s.is_sim else "LIVE"
ready, reason = live_ready(exchange=rt.exchange)
return {
"mode": mode,
"exchange": rt.exchange,
"okx_configured": okx_keys_configured(s),
"binance_configured": binance_keys_configured(s),
"okx_api_key_masked": mask_secret(s.okx_api_key),
"okx_api_secret_masked": mask_secret(s.okx_api_secret),
"okx_api_passphrase_masked": mask_secret(s.okx_api_passphrase),
"binance_api_key_masked": mask_secret(s.binance_api_key),
"binance_api_secret_masked": mask_secret(s.binance_api_secret),
"live_ready": bool(ready) if mode == "LIVE" else True,
"live_ready_reason": reason if mode == "LIVE" else "sim",
"sim": s.is_sim,
}
@router.get("/runtime")
async def get_runtime_settings(_user: Annotated[str, Depends(require_user)]) -> dict:
return _runtime_payload()
@router.put("/runtime")
async def put_runtime_settings(
body: RuntimeSettingsBody,
_user: Annotated[str, Depends(require_user)],
) -> dict:
db = get_db()
s = get_settings()
cur_mode = "SIM" if s.is_sim else "LIVE"
new_mode = (body.mode or cur_mode).strip().upper()
if new_mode not in ("SIM", "LIVE"):
raise HTTPException(status_code=400, detail="mode 须为 SIM 或 LIVE")
if new_mode != cur_mode and Matcher(db).has_open_position():
raise HTTPException(
status_code=409,
detail="有未平仓,无法切换 SIM/LIVE;请先平仓后再改",
)
if new_mode == "LIVE" and cur_mode != "LIVE":
if not body.confirm_live:
raise HTTPException(
status_code=400,
detail="切换到 LIVE 须二次确认(confirm_live=true",
)
phrase = (body.confirm_live_phrase or "").strip()
if phrase != "LIVE":
raise HTTPException(
status_code=400,
detail="切换到 LIVE 须在 confirm_live_phrase 传入 LIVE",
)
secret = (s.auth_secret or "").strip()
if not secret or secret == "change-me-eth-hedge-sim-secret":
raise HTTPException(
status_code=400,
detail="切到 LIVE 前请在 .env 设置非默认 AUTH_SECRET",
)
updates: dict[str, str] = {}
if body.okx_api_key is not None and body.okx_api_key.strip():
updates["OKX_API_KEY"] = body.okx_api_key.strip()
if body.okx_api_secret is not None and body.okx_api_secret.strip():
updates["OKX_API_SECRET"] = body.okx_api_secret.strip()
if body.okx_api_passphrase is not None and body.okx_api_passphrase.strip():
updates["OKX_API_PASSPHRASE"] = body.okx_api_passphrase.strip()
if body.binance_api_key is not None and body.binance_api_key.strip():
updates["BINANCE_API_KEY"] = body.binance_api_key.strip()
if body.binance_api_secret is not None and body.binance_api_secret.strip():
updates["BINANCE_API_SECRET"] = body.binance_api_secret.strip()
if new_mode != cur_mode:
updates["MODE"] = new_mode
if updates:
upsert_env_keys(updates)
s2 = get_settings()
if new_mode == "LIVE":
rt = load_runtime_settings()
if rt.exchange == "okx" and not okx_keys_configured(s2):
if cur_mode == "SIM":
upsert_env_keys({"MODE": "SIM"})
raise HTTPException(
status_code=400,
detail="切到 LIVE 前请先配置完整 OKX API Key/Secret/Passphrase",
)
if rt.exchange == "binance" and not binance_keys_configured(s2):
if cur_mode == "SIM":
upsert_env_keys({"MODE": "SIM"})
raise HTTPException(
status_code=400,
detail="切到 LIVE 前请先配置完整币安 API Key/Secret",
)
# 热切 LIVE:强制暂停策略(对齐冷启动护栏;运行中禁止人工开平仓基线)
if new_mode == "LIVE" and cur_mode != "LIVE":
db.execute(
"UPDATE strategy_state SET running=0, phase=?, last_error=? WHERE id=1",
("paused", "已切换 LIVE,策略已强制暂停;确认就绪后再启动"),
)
try:
from ..strategy import get_engine
# 同步停循环标志(pause 为 async,此处只写状态)
get_engine()._set_state(
running=0,
phase="paused",
last_error="已切换 LIVE,策略已强制暂停;确认就绪后再启动",
)
except Exception:
pass
try:
from ..strategy import get_engine
get_engine().refresh_executor()
except Exception:
pass
return _runtime_payload()
class NotifySettingsBody(BaseModel):
enabled: bool | None = None
webhook_url: str | None = None
machine_name: str | None = Field(default=None, max_length=64)
def _notify_payload() -> dict:
from ..notify import wecom
from ..env_store import mask_secret
url = wecom.wecom_webhook_url()
return {
"enabled": wecom.wecom_enabled(),
"webhook_configured": bool(url),
"webhook_url_masked": mask_secret(url) if url else None,
"venue_label": wecom.venue_label(),
"machine_name": wecom.wecom_machine_name() or "",
}
@router.get("/notify")
async def get_notify_settings(_user: Annotated[str, Depends(require_user)]) -> dict:
return _notify_payload()
@router.put("/notify")
async def put_notify_settings(
body: NotifySettingsBody,
_user: Annotated[str, Depends(require_user)],
) -> dict:
updates: dict[str, str] = {}
if body.enabled is not None:
updates["WECOM_ENABLED"] = "1" if body.enabled else "0"
get_db().set_setting("wecom_enabled", "1" if body.enabled else "0")
if body.webhook_url is not None and body.webhook_url.strip():
updates["WECOM_WEBHOOK_URL"] = body.webhook_url.strip()
get_db().set_setting("wecom_webhook_url", body.webhook_url.strip())
if body.machine_name is not None:
name = body.machine_name.strip()[:64]
updates["WECOM_MACHINE_NAME"] = name
get_db().set_setting("wecom_machine_name", name)
if updates:
upsert_env_keys(updates)
get_settings.cache_clear()
return _notify_payload()
@router.post("/notify/test")
async def test_notify(_user: Annotated[str, Depends(require_user)]) -> dict:
from ..notify import wecom
ok, msg = wecom.notify_test()
if not ok:
raise HTTPException(status_code=400, detail=f"推送失败: {msg}")
return {"ok": True, "detail": "测试消息已发送", **_notify_payload()}
+113 -17
View File
@@ -6,10 +6,11 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from ..config import get_settings
from ..env_store import live_ready
from ..live import get_executor
from ..market import get_gateway
from ..models.db import get_db
from ..sim.ledger import Ledger
from ..sim.matcher import Matcher
from ..strategy.clock import can_open_new, window_key
from ..strategy.group import next_group_id
from .auth import require_user
@@ -29,7 +30,7 @@ async def sim_ledger(_user: Annotated[str, Depends(require_user)]) -> dict:
@router.get("/position")
async def sim_position(_user: Annotated[str, Depends(require_user)]) -> dict:
m = Matcher()
m = get_executor()
return {"position": m.current_position(), "unrealized": m.unrealized()}
@@ -38,10 +39,28 @@ async def sim_open_group(
_user: Annotated[str, Depends(require_user)],
body: ManualOpenBody | None = None,
) -> dict:
if Matcher().has_open_position():
ok, reason = live_ready()
if not get_settings().is_sim and not ok:
raise HTTPException(status_code=400, detail=reason)
from ..strategy import get_engine
st = get_engine().state()
if st.get("running"):
raise HTTPException(
status_code=409,
detail="策略自动运行中,禁止手动开仓;请先暂停",
)
if not Ledger().get_setting_bool("show_manual_trade_buttons", False):
raise HTTPException(
status_code=403,
detail="未开启「显示手动开仓」;请在策略设置中开启后再用",
)
ex = get_executor()
if ex.has_open_position():
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
s = get_settings()
skip_weekends = Ledger().get_setting_bool("skip_weekends", s.skip_weekends)
ledger = Ledger()
skip_weekends = ledger.get_setting_bool("skip_weekends", s.skip_weekends)
if not can_open_new(skip_weekends=skip_weekends):
raise HTTPException(
status_code=409,
@@ -54,6 +73,22 @@ async def sim_open_group(
status_code=409,
detail="无合格期权:请检查剩余时长、ATM开仓偏差(若已开启)与杠杆(现价/卖一)",
)
one_expiry = ledger.get_setting_bool("one_expiry_per_day", s.one_expiry_per_day)
if one_expiry:
from ..strategy.clock import (
expiry_blocked_by_one_per_day,
used_expiry_ymds,
)
used = used_expiry_ymds(get_db())
if expiry_blocked_by_one_per_day(pick.pair.expiry_ymd, used, enabled=True):
raise HTTPException(
status_code=409,
detail=(
f"每个到期只开一次:已用过 {pick.pair.expiry_ymd}"
"请等下一到期日"
),
)
force = (body.force_option_side if body else None) or None
if force in ("call", "put"):
@@ -78,25 +113,63 @@ async def sim_open_group(
option_inst = (
pick.pair.call_inst_id if option_side == "call" else pick.pair.put_inst_id
)
# 强制方向时用该腿卖一估权利金;否则用选向结果
sizing_ask = float(
option_ask
if force in ("call", "put")
else pick.option_ask
)
wkey = window_key()
db = get_db()
from ..strategy.open_pipeline import size_and_gate
prep = size_and_gate(
index_px=float(pick.underlying_px),
option_ask=sizing_ask,
db=db,
)
if not prep.ok:
raise HTTPException(status_code=409, detail=prep.detail)
count = len(
db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",))
)
gid = next_group_id(count)
r = Matcher().open_group(
group_id=gid,
bias=bias,
option_side=option_side,
perp_side=perp_side,
option_inst_id=option_inst,
entry_index_px=float(pick.underlying_px),
strike=pick.pair.strike,
expiry_ymd=pick.pair.expiry_ymd,
)
engine = get_engine()
async with engine._lock:
if ex.has_open_position():
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
r = ex.open_group(
group_id=gid,
bias=bias,
option_side=option_side,
perp_side=perp_side,
option_inst_id=option_inst,
entry_index_px=float(pick.underlying_px),
strike=pick.pair.strike,
expiry_ymd=pick.pair.expiry_ymd,
)
if not r.ok:
raise HTTPException(status_code=400, detail=r.detail)
try:
from ..notify import wecom
wecom.notify_open(
group_id=gid,
detail=r.detail,
extra={
"bias": bias,
"option_side": option_side,
"perp_side": perp_side,
"option_inst_id": option_inst,
"strike": pick.pair.strike,
"expiry_ymd": pick.pair.expiry_ymd,
**(r.data or {}),
},
)
except Exception:
pass
return {
"ok": True,
**(r.data or {}),
@@ -110,9 +183,32 @@ async def sim_open_group(
@router.post("/close-group")
async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict:
r = Matcher().close_group(reason="manual")
if not r.ok and not r.liquidity_wait:
raise HTTPException(status_code=400, detail=r.detail)
if not Ledger().get_setting_bool("show_manual_trade_buttons", False):
raise HTTPException(
status_code=403,
detail="未开启「显示手动开仓」;请在策略设置中开启后再用",
)
from ..strategy import get_engine
engine = get_engine()
# 与策略引擎共用锁,避免与自动平仓/开仓竞态
async with engine._lock:
r = get_executor().close_group(reason="manual")
if not r.ok and not r.liquidity_wait:
raise HTTPException(status_code=400, detail=r.detail)
if r.ok:
# 与自动/紧急全平一致:成功全平后进入组间休息
engine.enter_rest_after_close()
try:
from ..notify import wecom
wecom.notify_close(
reason="manual",
detail=r.detail,
data=r.data or {},
)
except Exception:
pass
return {
"ok": r.ok,
"liquidity_wait": r.liquidity_wait,
+77 -8
View File
@@ -1,24 +1,59 @@
from __future__ import annotations
from typing import Annotated
from typing import Annotated, Any
from fastapi import APIRouter, Depends
from ..config import get_settings
from ..models.db import get_db
from ..sim.pnl import summarize_fills_pnl
from .auth import require_user
router = APIRouter(prefix="/api/stats", tags=["stats"])
@router.get("/summary")
async def stats_summary(_user: Annotated[str, Depends(require_user)]) -> dict:
db = get_db()
rows = db.fetchall("SELECT * FROM groups WHERE status='closed'")
def _current_loss_streak(curve: list[dict[str, Any]]) -> int:
"""从最近已平仓组往前数连续亏损次数。"""
streak = 0
for item in reversed(curve):
if float(item.get("realized_pnl") or 0) < 0:
streak += 1
else:
break
return streak
def build_stats_summary(db: Any | None = None) -> dict[str, Any]:
"""已平仓组汇总(策略页统计 / 中控 Fleet 共用)。"""
database = db or get_db()
s = get_settings()
mode = "LIVE" if not s.is_sim else "SIM"
rows = database.fetchall("SELECT * FROM groups WHERE status='closed'")
n = len(rows)
wins = sum(1 for r in rows if float(r["realized_pnl"] or 0) > 0)
total_pnl = sum(float(r["realized_pnl"] or 0) for r in rows)
total_fees = sum(float(r["fees"] or 0) for r in rows)
total_slip = sum(float(r["slip_cost"] or 0) for r in rows)
pnls = [float(r["realized_pnl"] or 0) for r in rows]
max_single_loss = min(pnls) if pnls else 0.0
if max_single_loss > 0:
max_single_loss = 0.0
fees_perp = 0.0
fees_option = 0.0
total_slip = 0.0
for r in rows:
fills = database.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC",
(r["group_id"],),
)
summary = summarize_fills_pnl(list(fills))
fees_perp += float(summary.get("fees_perp") or 0)
fees_option += float(summary.get("fees_option") or 0)
# LIVE 不展示、不计入滑点;按组成交模式判断(可混有历史 SIM 组)
exec_mode = str(r["exec_mode"] or mode).upper()
if exec_mode != "LIVE":
total_slip += float(summary.get("slip_total") or 0)
total_fees = fees_perp + fees_option
reasons: dict[str, int] = {}
for r in rows:
k = str(r["close_reason"] or "unknown")
@@ -31,13 +66,47 @@ async def stats_summary(_user: Annotated[str, Depends(require_user)]) -> dict:
}
for r in sorted(rows, key=lambda x: int(x["close_at_ms"] or 0))
]
latest_funds = 0.0
try:
from ..sim.funds_wallets import FundsWallets
latest_funds = float(FundsWallets(database).total_usdt_equiv())
except Exception:
try:
from ..sim.ledger import Ledger
latest_funds = float(Ledger(database).snapshot().get("equity") or 0)
except Exception:
latest_funds = 0.0
try:
initial_funds = float(
database.get_setting("initial_equity", str(s.initial_equity))
or s.initial_equity
)
except Exception:
initial_funds = float(s.initial_equity)
return {
"mode": mode,
"show_slip": mode == "SIM",
"groups": n,
"wins": wins,
"win_rate": (wins / n) if n else 0.0,
"total_pnl": total_pnl,
"fees_perp": fees_perp,
"fees_option": fees_option,
"total_fees": total_fees,
"total_slip": total_slip,
"total_slip": total_slip if mode == "SIM" else 0.0,
"close_reasons": reasons,
"equity_curve": curve,
"initial_funds": initial_funds,
"latest_funds": latest_funds,
"max_single_loss": max_single_loss,
"loss_streak": _current_loss_streak(curve),
}
@router.get("/summary")
async def stats_summary(_user: Annotated[str, Depends(require_user)]) -> dict:
return build_stats_summary()
+517 -6
View File
@@ -5,7 +5,9 @@ from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from ..models.db import get_db
from ..sim.pnl import summarize_fills_pnl
from .auth import require_user
from .hold_timing import hold_timing
router = APIRouter(prefix="/api/trades", tags=["trades"])
@@ -14,16 +16,463 @@ def _row(r: Any) -> dict:
return dict(r)
def _is_oo_group(g: dict) -> bool:
return (
str(g.get("hedge_mode") or "") == "option_option"
or bool(g.get("option2_inst_id"))
or str(g.get("bias") or "") == "option_option"
)
def _infer_settle_index(g: dict, fills: list) -> float | None:
"""优先库内 settle_index_px;否则用「实值腿」成交反推;再否则公开指数近似。"""
settle_index = g.get("settle_index_px")
if settle_index is not None:
try:
v = float(settle_index)
if v > 0:
return v
except (TypeError, ValueError):
pass
candidates: list[float] = []
for raw in fills:
f = dict(raw) if not isinstance(raw, dict) else raw
if str(f.get("action") or "") != "close":
continue
leg = str(f.get("leg") or "")
if leg not in ("option", "option2"):
continue
if abs(float(f.get("slip") or 0)) > 1e-12:
continue
try:
px = float(f.get("fill_px") or 0)
except (TypeError, ValueError):
continue
# 虚值到期 fill=0:k+0 / k-0 会得到行权价,不是真实结算指数
if px <= 1e-9:
continue
if leg == "option":
strike = g.get("strike")
side = str(g.get("option_side") or "").lower()
else:
strike = g.get("strike2")
side = str(g.get("option2_side") or "put").lower()
if strike is None:
continue
try:
k = float(strike)
except (TypeError, ValueError):
continue
if side in ("call", "c"):
candidates.append(k + px)
elif side in ("put", "p"):
candidates.append(k - px)
if candidates:
return round(sum(candidates) / len(candidates), 4)
try:
from .public_index import maybe_public_settle_index
pub = maybe_public_settle_index(g)
if pub is not None and pub > 0:
return round(float(pub), 4)
except Exception:
pass
return None
def _overlay_expiry_intrinsic_fills(
g: dict, fills: list, settle_index: float | None
) -> list:
"""到期且已有结算指数:close 成交按内在价值覆盖展示/盈亏(响应层)。
交易所账单偶发落成近 0 价(如 0.2),与内在价值(如 42.2)严重不符时
若只覆盖 fill≈0,Put 仍会按错误价算成巨亏。
"""
if settle_index is None or settle_index <= 0:
return fills
if str(g.get("close_reason") or "") != "expiry":
return fills
out: list = []
changed = False
for raw in fills:
f = dict(raw) if not isinstance(raw, dict) else dict(raw)
if str(f.get("action") or "") == "close" and str(f.get("leg") or "") in (
"option",
"option2",
):
try:
px = float(f.get("fill_px") or 0)
except (TypeError, ValueError):
px = 0.0
leg = str(f.get("leg") or "")
if leg == "option":
strike = g.get("strike")
side = str(g.get("option_side") or "").lower()
else:
strike = g.get("strike2")
side = str(g.get("option2_side") or "put").lower()
if strike is not None:
try:
intrinsic = float(
_intrinsic(side, float(settle_index), float(strike))
)
# 与内在价值偏差超过 0.5 USDT(或相对 5%)则覆盖
tol = max(0.5, abs(intrinsic) * 0.05)
if abs(px - intrinsic) > tol:
qty = float(f.get("qty_eth") or 0)
f["fill_px"] = intrinsic
f["base_px"] = intrinsic
f["notional"] = intrinsic * qty
f["slip"] = 0.0
f["_overlay_intrinsic"] = True
changed = True
except (TypeError, ValueError):
pass
out.append(f)
return out if changed else fills
# 兼容旧测试名
_overlay_expiry_zero_fills = _overlay_expiry_intrinsic_fills
def _intrinsic(side: str, settle_index: float, strike: float) -> float:
s = str(side or "").lower()
if s in ("call", "c"):
return max(settle_index - strike, 0.0)
if s in ("put", "p"):
return max(strike - settle_index, 0.0)
return 0.0
def _expiry_settle_info(g: dict, fills: list) -> dict | None:
"""到期结算口径:期权价 = 内在价值(指数 vs 行权价),非盘口。"""
if str(g.get("close_reason") or "") != "expiry":
return None
settle_index = _infer_settle_index(g, fills)
strike = g.get("strike")
side = str(g.get("option_side") or "").lower()
intrinsic = None
if settle_index is not None and strike is not None:
intrinsic = _intrinsic(side, float(settle_index), float(strike))
formula = (
"Call: max(指数−行权价, 0)"
if side in ("call", "c")
else "Put: max(行权价−指数, 0)"
if side in ("put", "p")
else ""
)
is_oo = _is_oo_group(g)
out: dict[str, Any] = {
"settle_index_px": float(settle_index) if settle_index is not None else None,
"strike": float(strike) if strike is not None else None,
"intrinsic": intrinsic,
"formula": formula,
"perp_note": (
"期期无永续腿;两腿均按内在价值结算"
if is_oo
else "永续仍按市价平仓(非指数交割)"
),
"is_oo": is_oo,
}
if is_oo:
strike2 = g.get("strike2")
side2 = str(g.get("option2_side") or "put").lower()
intrinsic2 = None
if settle_index is not None and strike2 is not None:
intrinsic2 = _intrinsic(side2, float(settle_index), float(strike2))
out["strike2"] = float(strike2) if strike2 is not None else None
out["intrinsic2"] = intrinsic2
out["formula2"] = (
"Put: max(行权价−指数, 0)"
if side2 in ("put", "p")
else "Call: max(指数−行权价, 0)"
if side2 in ("call", "c")
else ""
)
out["option2_side"] = side2
return out
def _close_index_px(g: dict, fills: list) -> float | None:
"""平仓时标的指数:优先库内 settle;到期才用实值腿反推;否则永续平仓价。"""
raw = g.get("settle_index_px")
if raw is not None:
try:
v = float(raw)
if v > 0:
return v
except (TypeError, ValueError):
pass
# 仅到期:期权平仓价=内在价值,可反推指数;中途卖出的权利金不能当指数
if str(g.get("close_reason") or "") == "expiry":
inferred = _infer_settle_index(g, fills)
if inferred is not None and inferred > 0:
return inferred
for row in fills:
f = dict(row) if not isinstance(row, dict) else row
if str(f.get("leg") or "") == "perp" and str(f.get("action") or "") == "close":
try:
v = float(f.get("fill_px") or 0)
if v > 0:
return v
except (TypeError, ValueError):
pass
break
return None
def _move_points(g: dict, fills: list) -> float | None:
"""开仓指数 → 平仓指数的点数(带符号:上涨为正)。持仓中无平仓价则空。"""
entry = g.get("entry_index_px")
if entry is None:
return None
try:
e = float(entry)
except (TypeError, ValueError):
return None
if e <= 0:
return None
close_px = _close_index_px(g, fills)
if close_px is None:
return None
return round(float(close_px) - e, 2)
def _option_entry_px(fills: list, *, leg: str = "option") -> float | None:
for row in fills:
f = dict(row) if not isinstance(row, dict) else row
if str(f.get("leg") or "") != leg or str(f.get("action") or "") != "open":
continue
try:
v = float(f.get("fill_px") or 0)
if v > 0:
return v
except (TypeError, ValueError):
pass
break
return None
def _option_leverage_for_leg(
g: dict, fills: list, *, leg: str = "option"
) -> float | None:
"""开仓期权杠杆 = 开仓指数 ÷ 期权开仓均价(与选约门限口径一致)。"""
from ..strategy.selection import option_leverage
try:
entry = float(g.get("entry_index_px") or 0)
except (TypeError, ValueError):
return None
opt_px = _option_entry_px(fills, leg=leg)
if entry <= 0 or opt_px is None:
return None
lev = option_leverage(entry, opt_px)
return round(float(lev), 1) if lev is not None else None
def _enrich_group(g: dict, fills: list) -> dict:
is_oo = _is_oo_group(g)
g["is_oo"] = is_oo
settle = _infer_settle_index(g, fills)
view_fills = _overlay_expiry_intrinsic_fills(g, fills, settle)
overlaid = any(
isinstance(f, dict) and f.get("_overlay_intrinsic") for f in view_fills
)
summary = summarize_fills_pnl(view_fills)
# LIVE 且未做内在价值覆盖:优先 groups.realized_pnl(含资金费)
if (
not overlaid
and str(g.get("exec_mode") or "").upper() == "LIVE"
and g.get("realized_pnl") is not None
):
summary = dict(summary)
summary["net_pnl"] = float(g["realized_pnl"])
if g.get("funding_usdt") is not None:
summary["funding_usdt"] = float(g["funding_usdt"])
summary["pnl_source"] = "live_exchange"
elif overlaid:
summary = dict(summary)
summary["pnl_source"] = "expiry_intrinsic_overlay"
elif (
is_oo
and g.get("realized_pnl") is not None
and (
summary.get("option_pnl") is None
or summary.get("option2_pnl") is None
)
):
summary = dict(summary)
summary["net_pnl"] = float(g["realized_pnl"])
summary["pnl_source"] = "group_realized"
g["pnl_summary"] = summary
if summary.get("net_pnl") is not None:
g["net_pnl"] = summary["net_pnl"]
elif g.get("realized_pnl") is not None:
g["net_pnl"] = float(g["realized_pnl"])
prem1 = float(g.get("initial_premium") or 0)
prem2 = float(g.get("initial_premium2") or 0) if is_oo else 0.0
g["total_initial_premium"] = prem1 + prem2 if is_oo else prem1
g.update(hold_timing(g, fills))
if settle is not None and g.get("settle_index_px") is None:
g["settle_index_px"] = float(settle)
info = _expiry_settle_info(g, view_fills)
if info:
g["expiry_settle"] = info
if g.get("settle_index_px") is None and info.get("settle_index_px") is not None:
g["settle_index_px"] = info["settle_index_px"]
mp = _move_points(g, view_fills)
g["move_points"] = mp
g["close_index_px"] = _close_index_px(g, view_fills)
g["option_leverage"] = _option_leverage_for_leg(g, fills, leg="option")
if is_oo:
g["option2_leverage"] = _option_leverage_for_leg(g, fills, leg="option2")
g["_view_fills"] = view_fills
g["_overlay_settle"] = float(settle) if settle is not None else None
g["_overlaid"] = overlaid
return g
def persist_expiry_overlay_if_needed(db: Any, g: dict, raw_fills: list) -> None:
"""把内在价值覆盖写回库:fills / realized_pnl / settle_index,并补本地账本差额。"""
if not g.get("_overlaid"):
return
view_fills = g.get("_view_fills") or []
net = g.get("net_pnl")
if net is None:
return
settle = g.get("_overlay_settle")
group_id = str(g.get("group_id") or "")
if not group_id:
return
old_net = float(g.get("realized_pnl") or 0)
# 已对齐则跳过(避免每次列表刷库)
if abs(old_net - float(net)) < 0.02:
stored = g.get("settle_index_px")
try:
if settle is None or (
stored is not None and abs(float(stored) - float(settle)) < 0.05
):
# 仍可能 fills 未写回;检查是否还有 overlay 标记需要落库
pass
else:
pass
except (TypeError, ValueError):
pass
raw_close = {
str(dict(f).get("leg")): dict(f)
for f in raw_fills
if str(dict(f).get("action") or "") == "close"
and str(dict(f).get("leg") or "") in ("option", "option2")
}
cash_delta = 0.0
updates: list[tuple] = []
for vf in view_fills:
if not isinstance(vf, dict) or not vf.get("_overlay_intrinsic"):
continue
leg = str(vf.get("leg") or "")
old = raw_close.get(leg)
if not old or old.get("id") is None:
continue
try:
old_px = float(old.get("fill_px") or 0)
new_px = float(vf.get("fill_px") or 0)
qty = float(vf.get("qty_eth") or old.get("qty_eth") or 0)
except (TypeError, ValueError):
continue
if abs(old_px - new_px) <= 1e-9:
continue
cash_delta += (new_px - old_px) * qty
updates.append(
(
new_px,
new_px,
new_px * qty,
int(old["id"]),
)
)
if not updates and abs(old_net - float(net)) < 0.02:
# 只缺 settle
if settle is None:
return
try:
if g.get("settle_index_px") is not None and abs(
float(g["settle_index_px"]) - float(settle)
) < 0.05:
return
except (TypeError, ValueError):
pass
with db._lock:
for base, fill, notional, fid in updates:
db._conn.execute(
"UPDATE fills SET base_px=?, fill_px=?, notional=?, slip=0 WHERE id=?",
(base, fill, notional, fid),
)
db._conn.execute(
"""UPDATE groups SET realized_pnl=?,
settle_index_px=COALESCE(?, settle_index_px),
note=CASE
WHEN instr(COALESCE(note,''), 'expiry_intrinsic_repair')>0 THEN note
ELSE trim(COALESCE(note,'') || ' | expiry_intrinsic_repair')
END
WHERE group_id=? AND status='closed'""",
(
float(net),
float(settle) if settle is not None else None,
group_id,
),
)
db._conn.commit()
if abs(cash_delta) > 1e-9:
try:
from ..sim.ledger import Ledger
Ledger(db).apply_cash(
cash_delta,
kind="repair_option_intrinsic",
group_id=group_id,
note=f"expiry intrinsic overlay cash_delta={cash_delta:.4f}",
allow_negative=True,
)
except Exception:
pass
# 刷新内存中的 realized,供同请求后续使用
g["realized_pnl"] = float(net)
@router.get("/groups")
async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict:
rows = get_db().fetchall(
"SELECT * FROM groups ORDER BY open_at_ms DESC LIMIT 200"
)
return {"groups": [_row(x) for x in rows]}
db = get_db()
rows = db.fetchall("SELECT * FROM groups ORDER BY open_at_ms DESC LIMIT 200")
groups = []
for r in rows:
g = _row(r)
fills = db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC",
(g["group_id"],),
)
gr = _enrich_group(g, fills)
try:
persist_expiry_overlay_if_needed(db, gr, list(fills))
except Exception:
pass
gr.pop("_view_fills", None)
gr.pop("_overlay_settle", None)
gr.pop("_overlaid", None)
groups.append(gr)
return {"groups": groups}
@router.get("/groups/{group_id}")
async def group_detail(group_id: str, _user: Annotated[str, Depends(require_user)]) -> dict:
async def group_detail(
group_id: str, _user: Annotated[str, Depends(require_user)]
) -> dict:
db = get_db()
g = db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
if g is None:
@@ -31,4 +480,66 @@ async def group_detail(group_id: str, _user: Annotated[str, Depends(require_user
fills = db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
)
return {"group": _row(g), "fills": [_row(x) for x in fills]}
gr = _enrich_group(_row(g), fills)
try:
persist_expiry_overlay_if_needed(db, gr, list(fills))
except Exception:
pass
view_fills = gr.pop("_view_fills", None) or fills
gr.pop("_overlay_settle", None)
gr.pop("_overlaid", None)
return {
"group": gr,
"fills": [
{
k: v
for k, v in (dict(x) if not isinstance(x, dict) else x).items()
if k != "_overlay_intrinsic"
}
for x in view_fills
],
"pnl_summary": gr.get("pnl_summary"),
}
@router.delete("/groups/{group_id}")
async def delete_group(
group_id: str, _user: Annotated[str, Depends(require_user)]
) -> dict:
"""删除一条已平仓交易记录(组/成交/残留/相关账本流水)。不回滚权益。"""
db = get_db()
g = db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
if g is None:
raise HTTPException(status_code=404, detail="group not found")
status = str(g["status"] or "").lower()
if status != "closed":
raise HTTPException(
status_code=409,
detail="只能删除已平仓记录;持仓中或开仓中的组不可删",
)
pos = db.fetchone("SELECT group_id FROM positions WHERE id=1")
if pos and str(pos["group_id"] or "") == group_id:
raise HTTPException(
status_code=409,
detail="当前持仓仍引用该组,不可删除",
)
with db._lock:
db._conn.execute("DELETE FROM fills WHERE group_id=?", (group_id,))
db._conn.execute(
"DELETE FROM residual_options WHERE group_id=?", (group_id,)
)
db._conn.execute(
"DELETE FROM ledger_entries WHERE group_id=?", (group_id,)
)
cur = db._conn.execute(
"DELETE FROM groups WHERE group_id=? AND status='closed'",
(group_id,),
)
if cur.rowcount <= 0:
db._conn.rollback()
raise HTTPException(
status_code=409,
detail="删除失败:组状态已变更",
)
db._conn.commit()
return {"ok": True, "group_id": group_id}
+351
View File
@@ -0,0 +1,351 @@
"""数据库 + .env 备份 / 恢复(自动落盘到 /root/eth_hedge_backups)。"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import shutil
import sqlite3
import tempfile
import time
import zipfile
from pathlib import Path
from typing import Any
from .config import get_settings
from .credentials import resolve_env_file_path
from .models.db import Database, get_db
logger = logging.getLogger(__name__)
MANIFEST_NAME = "manifest.json"
DB_ARCNAME = "hedge.db"
ENV_ARCNAME = ".env"
BACKUP_PREFIX = "eth_hedge_backup_"
DEFAULT_KEEP = 14
DEFAULT_INTERVAL_HOURS = 24
def resolve_backup_dir() -> Path:
"""优先 BACKUP_DIRLinux 默认 /root/eth_hedge_backups;否则用户目录。"""
override = (os.environ.get("BACKUP_DIR") or "").strip()
if override:
p = Path(override)
p.mkdir(parents=True, exist_ok=True)
return p
if os.name != "nt":
root = Path("/root/eth_hedge_backups")
try:
if Path("/root").is_dir():
root.mkdir(parents=True, exist_ok=True)
return root
except OSError as e:
logger.warning("cannot use /root/eth_hedge_backups: %s", e)
p = Path.home() / "eth_hedge_backups"
p.mkdir(parents=True, exist_ok=True)
return p
def resolve_env_path() -> Path | None:
p = resolve_env_file_path()
return p
def _settings_int(db: Database, key: str, default: int) -> int:
raw = db.get_setting(key, str(default))
try:
return int(float(raw or default))
except (TypeError, ValueError):
return default
def _settings_bool(db: Database, key: str, default: bool) -> bool:
raw = db.get_setting(key, "1" if default else "0")
if raw is None or raw == "":
return default
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def backup_settings(db: Database | None = None) -> dict[str, Any]:
db = db or get_db()
return {
"auto_enabled": _settings_bool(db, "backup_auto_enabled", True),
"interval_hours": max(1, _settings_int(db, "backup_interval_hours", DEFAULT_INTERVAL_HOURS)),
"keep_count": max(1, min(90, _settings_int(db, "backup_keep_count", DEFAULT_KEEP))),
"last_at_ms": _settings_int(db, "backup_last_at_ms", 0) or None,
"backup_dir": str(resolve_backup_dir()),
}
def list_backups() -> list[dict[str, Any]]:
d = resolve_backup_dir()
items: list[dict[str, Any]] = []
if not d.is_dir():
return items
for p in sorted(d.glob(f"{BACKUP_PREFIX}*.zip"), key=lambda x: x.stat().st_mtime, reverse=True):
st = p.stat()
items.append(
{
"name": p.name,
"path": str(p),
"size_bytes": st.st_size,
"mtime_ms": int(st.st_mtime * 1000),
}
)
return items
def prune_backups(keep: int | None = None) -> int:
keep_n = keep if keep is not None else backup_settings()["keep_count"]
items = list_backups()
removed = 0
for old in items[int(keep_n) :]:
try:
Path(old["path"]).unlink(missing_ok=True)
removed += 1
except OSError as e:
logger.warning("prune backup failed %s: %s", old.get("name"), e)
return removed
def _sqlite_snapshot(db: Database, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
dest.unlink()
with db._lock:
dst = sqlite3.connect(str(dest))
try:
db._conn.backup(dst)
dst.commit()
finally:
dst.close()
def create_backup(
*,
db: Database | None = None,
reason: str = "manual",
include_env: bool | None = None,
) -> dict[str, Any]:
db = db or get_db()
backup_dir = resolve_backup_dir()
ts = time.strftime("%Y%m%d_%H%M%S")
out = backup_dir / f"{BACKUP_PREFIX}{ts}.zip"
env_path = resolve_env_path()
db_path = Path(db.path)
# 机内备份默认可含 .envBACKUP_INCLUDE_ENV=0 可关。HTTP 下载另做剥离。
if include_env is None:
include_env = (os.environ.get("BACKUP_INCLUDE_ENV") or "1").strip() != "0"
with tempfile.TemporaryDirectory(prefix="eth_hedge_bak_") as td:
tmp_db = Path(td) / "hedge.db"
_sqlite_snapshot(db, tmp_db)
has_env = bool(include_env and env_path and env_path.is_file())
manifest = {
"product": "比特骆驼自动化对冲系统",
"version": 1,
"created_at_ms": int(time.time() * 1000),
"reason": reason,
"db_source": str(db_path),
"env_source": str(env_path) if has_env else None,
"has_env": has_env,
"mode": get_settings().mode,
"env_name": get_settings().env_name,
}
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.write(tmp_db, DB_ARCNAME)
if has_env and env_path is not None:
zf.write(env_path, ENV_ARCNAME)
zf.writestr(
MANIFEST_NAME,
json.dumps(manifest, ensure_ascii=False, indent=2),
)
db.set_setting("backup_last_at_ms", str(int(time.time() * 1000)))
prune_backups()
st = out.stat()
logger.info("backup created path=%s reason=%s size=%s has_env=%s", out, reason, st.st_size, has_env)
return {
"ok": True,
"name": out.name,
"path": str(out),
"size_bytes": st.st_size,
"mtime_ms": int(st.st_mtime * 1000),
"backup_dir": str(backup_dir),
"manifest": manifest,
}
def materialize_download_zip(src: Path) -> Path:
"""HTTP 下载用:去掉包内 .env,避免令牌失窃带走交易所密钥。"""
fd, name = tempfile.mkstemp(prefix="eth_hedge_dl_", suffix=".zip")
os.close(fd)
dest = Path(name)
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(
dest, "w", compression=zipfile.ZIP_DEFLATED
) as zout:
for info in zin.infolist():
base = Path(info.filename).name
if base == ENV_ARCNAME or base.endswith(".env"):
continue
zout.writestr(info, zin.read(info.filename))
return dest
def read_backup_file(name: str) -> Path:
safe = Path(name).name
if not safe.startswith(BACKUP_PREFIX) or not safe.endswith(".zip"):
raise ValueError("非法备份文件名")
path = resolve_backup_dir() / safe
if not path.is_file():
raise FileNotFoundError(f"备份不存在: {safe}")
return path
def _extract_backup_zip(zip_path: Path, dest_dir: Path) -> dict[str, Path]:
with zipfile.ZipFile(zip_path, "r") as zf:
names = set(zf.namelist())
if DB_ARCNAME not in names:
# 兼容偶发相对路径
db_candidates = [n for n in names if n.endswith("hedge.db") or n.endswith(".db")]
if not db_candidates:
raise ValueError("备份包缺少 hedge.db")
db_name = db_candidates[0]
else:
db_name = DB_ARCNAME
env_name = ENV_ARCNAME if ENV_ARCNAME in names else (
next((n for n in names if n.endswith(".env") or n == "env"), None)
)
zf.extract(db_name, dest_dir)
db_out = dest_dir / db_name
if not db_out.is_file():
# zip 内可能带目录
found = list(dest_dir.rglob("*.db"))
if not found:
raise ValueError("解压后未找到数据库文件")
db_out = found[0]
env_out: Path | None = None
if env_name:
zf.extract(env_name, dest_dir)
env_out = dest_dir / env_name
if not env_out.is_file():
found_env = list(dest_dir.rglob(".env")) + list(dest_dir.rglob("*.env"))
env_out = found_env[0] if found_env else None
return {"db": db_out, "env": env_out} # type: ignore[dict-item]
def validate_backup_zip(zip_path: Path) -> dict[str, Any]:
with zipfile.ZipFile(zip_path, "r") as zf:
names = zf.namelist()
has_db = any(n.endswith("hedge.db") or n == DB_ARCNAME or n.endswith(".db") for n in names)
has_env = any(n.endswith(".env") or n == ENV_ARCNAME for n in names)
manifest = None
if MANIFEST_NAME in names:
try:
manifest = json.loads(zf.read(MANIFEST_NAME).decode("utf-8"))
except Exception:
manifest = None
if not has_db:
raise ValueError("备份包无效:缺少数据库")
return {"has_db": has_db, "has_env": has_env, "manifest": manifest, "files": names}
def restore_from_zip(
zip_path: Path,
*,
db: Database | None = None,
make_safety_backup: bool = True,
) -> dict[str, Any]:
"""用备份包覆盖当前 hedge.db 与 .env。调用方应在此前后暂停策略并准备重启进程。"""
db = db or get_db()
info = validate_backup_zip(zip_path)
safety = None
if make_safety_backup:
try:
safety = create_backup(db=db, reason="pre_restore")
except Exception as e:
logger.warning("pre_restore backup failed: %s", e)
with tempfile.TemporaryDirectory(prefix="eth_hedge_restore_") as td:
extracted = _extract_backup_zip(zip_path, Path(td))
src_db = extracted["db"]
src_env = extracted.get("env")
db_path = Path(db.path)
bak_live: Path | None = None
db.close()
if db_path.is_file():
bak_live = db_path.with_suffix(db_path.suffix + f".pre_restore_{int(time.time())}")
shutil.copy2(db_path, bak_live)
shutil.copy2(src_db, db_path)
env_written = None
if src_env and Path(src_env).is_file():
env_target = resolve_env_file_path()
env_target.parent.mkdir(parents=True, exist_ok=True)
if env_target.is_file():
shutil.copy2(env_target, env_target.with_suffix(".env.pre_restore"))
shutil.copy2(src_env, env_target)
try:
env_target.chmod(0o600)
except Exception:
pass
env_written = str(env_target)
return {
"ok": True,
"db_path": str(db_path),
"env_path": env_written,
"had_env_in_backup": bool(info.get("has_env")),
"safety_backup": safety.get("name") if safety else None,
"local_db_copy": str(bak_live) if bak_live and Path(bak_live).exists() else None,
"restart_required": True,
"manifest": info.get("manifest"),
}
_auto_task: asyncio.Task[None] | None = None
async def auto_backup_loop() -> None:
"""后台定时备份;默认开启。"""
logger.info("auto backup loop started dir=%s", resolve_backup_dir())
while True:
try:
await asyncio.sleep(60)
db = get_db()
cfg = backup_settings(db)
if not cfg["auto_enabled"]:
continue
interval_ms = int(cfg["interval_hours"]) * 3600 * 1000
last = int(cfg["last_at_ms"] or 0)
now = int(time.time() * 1000)
if last and now - last < interval_ms:
continue
await asyncio.to_thread(create_backup, db=db, reason="auto")
except asyncio.CancelledError:
raise
except Exception:
logger.exception("auto backup tick failed")
await asyncio.sleep(30)
def start_auto_backup_task() -> asyncio.Task[None]:
global _auto_task
if _auto_task is None or _auto_task.done():
_auto_task = asyncio.create_task(auto_backup_loop(), name="auto-backup")
return _auto_task
async def stop_auto_backup_task() -> None:
global _auto_task
if _auto_task and not _auto_task.done():
_auto_task.cancel()
try:
await _auto_task
except Exception:
pass
_auto_task = None
+52
View File
@@ -25,6 +25,10 @@ class Settings(BaseSettings):
auth_password: str = "admin123"
auth_secret: str = "change-me-eth-hedge-sim-secret"
auth_token_ttl_sec: int = 60 * 60 * 24 * 7
auth_token_version: int = 1 # 改密时递增,作废旧 token
disable_api_docs: bool = True # 生产默认关闭 /docs
login_max_attempts: int = 8
login_window_sec: int = 300
okx_api_key: str = ""
okx_api_secret: str = ""
@@ -33,6 +37,10 @@ class Settings(BaseSettings):
okx_ws_public: str = "wss://ws.okx.com:8443/ws/v5/public"
okx_http_proxy: str = ""
# 币安私有交易密钥(LIVE 真下单:fapi 永续 + eapi 期权)
binance_api_key: str = ""
binance_api_secret: str = ""
# 币安公共行情(SIM 只读)
binance_fapi_base: str = "https://fapi.binance.com"
binance_eapi_base: str = "https://eapi.binance.com"
@@ -57,17 +65,61 @@ class Settings(BaseSettings):
net_profit_target: float = 15.0 # fixed_usdt:净盈利 ≥ 该值(USDT
premium_exit_multiple: float = 1.0 # premium_multiple:净盈利 ≥ 权利金×倍数
rest_seconds: int = 300
live_order_interval_sec: float = 1.0 # LIVE 私有下单/查单最小间隔(秒)
skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平)
# 每个到期只开一次(跨日):历史上已开过某 expiry_ymd 后不可再开同到期,只能盯下一档
one_expiry_per_day: bool = True
leverage: float = 3.0 # 永续杠杆
# 永续保证金模式:cross=全仓(默认)| isolated=逐仓;期权仍固定 cashOKX 逐仓/现金)
perp_margin_mode: str = "cross"
min_option_hours: float = 12.0 # 期权最小剩余小时
min_option_leverage: float = 100.0 # 现价/卖一权利金 下限
# 以损定仓权利金口径:actual=盘口卖一;selection=指数/选约杠杆(控节奏,默认)
risk_leverage_basis: str = "selection"
# 倍投:默认关;仅以损定仓+亏损幅度%且基础幅度≤3% 可开
martingale_enabled: bool = False
martingale_start_after_loss_days: int = 2 # 连续亏损 N 天后开始翻倍
martingale_max_doubles: int = 3 # 最多翻倍次数(如 2→4→8→16 为 3 次)
# 对冲模式:perp_option=永期(默认)| option_option=期期
hedge_mode: str = "perp_option"
oo_amplitude_pct: float = 1.5 # 振幅最大 %(过滤开启时:超过则不开)
oo_amplitude_hours: float = 12.0 # 振幅回看小时(选约高低点;过滤关也用)
oo_amplitude_filter_enabled: bool = False # 振幅过滤开关(默认关)
oo_min_option_hours: float = 24.0 # 期期:最短剩余到期小时
oo_min_leverage: float = 200.0 # 期期:单腿最低杠杆
oo_reward_ratio: float = 2.0 # 盈亏比:出场目标 = 预算 ×
oo_budget_cushion: float = 0.92 # 定仓预留余地(用于权利金的预算比例)
oo_strike_max_dev_pct: float = 1.0 # 虚值行权价相对振幅高低点最大偏离 %
atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关)
max_atm_open_offset: float = 3.0 # 开启后:|ATM行权价−标的| 上限(点)
# 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值
fixed_direction_enabled: bool = False
fixed_perp_side: str = "long" # long|shortlong→买Putshort→买Call
# 半自动:系统设置开关;首页授权后盯开盯平,平完停
semi_auto_enabled: bool = False
semi_armed: bool = False # 是否已授权本单(平完清零)
semi_view_side: str = "long" # long=买Call+永续空;short=买Put+永续多
semi_option_move_points: float = 50.0 # 顺方向:标的波动点数
semi_perp_exit_unit: float = 5.0 # 净利出场基数(×k
semi_min_option_hours: float = 30.0
semi_min_option_leverage: float = 200.0 # 虚值默认 200(虚值门仍强制≥180)
semi_moneyness: str = "otm" # itm|atm|otm
semi_otm_max_offset: float = 25.0 # 虚值 |KS| 上限(可配)
semi_perp_unit: float = 1.0 # 半自动永续:期权 配比分子
semi_option_unit: float = 4.0 # 配比分母,默认 1:4
close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差%
# 残留期权中途平:当前买一权利金 ≥ 初始权利金 × 该% 才尝试卖出
residual_min_premium_pct: float = 20.0
residual_close_check_sec: int = 300 # 残留巡检间隔(秒)
perp_qty_eth: float = 1.0
option_qty_eth: float = 2.0
db_path: str = "" # empty -> backend/data/hedge.db
# 企业微信群机器人
wecom_enabled: bool = False
wecom_webhook_url: str = ""
wecom_machine_name: str = ""
@property
def is_sim(self) -> bool:
return self.mode.strip().upper() != "LIVE"
+15 -1
View File
@@ -20,6 +20,12 @@ def _env_paths() -> list[Path]:
return [root / ".env", Path.cwd() / ".env", Path.cwd().parent / ".env"]
def resolve_env_file_path() -> Path:
"""第一个已存在的 .env;都不存在则返回仓库根 .env 路径(可写入)。"""
paths = _env_paths()
return next((p for p in paths if p.is_file()), paths[0])
def _ensure_loaded() -> None:
global _username, _password
if _username is not None and _password is not None:
@@ -38,11 +44,15 @@ def get_credentials() -> tuple[str, str]:
def upsert_env_file(key: str, value: str) -> Path | None:
"""写入第一个已存在的 .env;都不存在则写仓库根 .env。"""
if "\n" in value or "\r" in value:
raise ValueError(f"{key} 值不能包含换行")
paths = _env_paths()
target = next((p for p in paths if p.is_file()), paths[0])
target.parent.mkdir(parents=True, exist_ok=True)
text = target.read_text(encoding="utf-8") if target.is_file() else ""
line = f"{key}={value}"
# 简单引号,避免空格/特殊字符破坏解析
safe = value.replace("\\", "\\\\").replace('"', '\\"')
line = f'{key}="{safe}"'
pattern = re.compile(rf"(?m)^{re.escape(key)}=.*$")
if pattern.search(text):
text = pattern.sub(line, text)
@@ -51,6 +61,10 @@ def upsert_env_file(key: str, value: str) -> Path | None:
text += "\n"
text += line + "\n"
target.write_text(text, encoding="utf-8")
try:
target.chmod(0o600)
except Exception:
pass
return target
+65
View File
@@ -0,0 +1,65 @@
"""批量写入 .env 并刷新 Settings 缓存。"""
from __future__ import annotations
from pathlib import Path
from .config import get_settings
from .credentials import upsert_env_file
def upsert_env_keys(updates: dict[str, str]) -> Path | None:
"""写入多项;空 value 跳过。返回最后写入的 .env 路径。"""
target: Path | None = None
for key, value in updates.items():
if value is None:
continue
# 允许显式清空密钥(传空串以外的 sentinel 由调用方决定);空串表示跳过
if value == "":
continue
target = upsert_env_file(key, value)
get_settings.cache_clear()
return target
def mask_secret(raw: str | None, *, keep: int = 0) -> str | None:
"""脱敏:仅返回是否已配置(不再泄露末尾字符)。"""
s = (raw or "").strip()
if not s:
return None
return "********"
def okx_keys_configured(s=None) -> bool:
st = s or get_settings()
return bool(
(st.okx_api_key or "").strip()
and (st.okx_api_secret or "").strip()
and (st.okx_api_passphrase or "").strip()
)
def binance_keys_configured(s=None) -> bool:
st = s or get_settings()
return bool(
(st.binance_api_key or "").strip() and (st.binance_api_secret or "").strip()
)
def live_ready(*, exchange: str | None = None) -> tuple[bool, str]:
"""LIVE 是否可下单。返回 (ok, reason)。"""
from .exchange.runtime import load_runtime_settings, normalize_exchange_name
st = get_settings()
if st.is_sim:
return True, "sim"
ex = normalize_exchange_name(exchange or load_runtime_settings().exchange)
if ex == "binance":
if not binance_keys_configured(st):
return False, "币安 API Key/Secret 未配置"
return True, "ok"
if ex == "okx":
if not okx_keys_configured(st):
return False, "OKX API Key/Secret/Passphrase 未配置"
return True, "ok"
return False, f"未知交易所: {ex}"
+185
View File
@@ -0,0 +1,185 @@
"""指数/永续 K 线高低点:期期对冲振幅回看。"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from .okx.parse import safe_float as okx_safe_float
from .binance.parse import safe_float as bn_safe_float
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class AmplitudeHL:
high: float
low: float
mid: float
hours: float
bar_count: int
@property
def range_pct(self) -> float:
if self.mid <= 0:
return 0.0
return (self.high - self.low) / self.mid * 100.0
def _hl_from_okx_candles(rows: list[Any]) -> tuple[float, float] | None:
"""OKX candle row: [ts, o, h, l, c, ...] newest first."""
highs: list[float] = []
lows: list[float] = []
for row in rows:
if not isinstance(row, (list, tuple)) or len(row) < 5:
continue
h = okx_safe_float(row[2])
lo = okx_safe_float(row[3])
if h is None or lo is None or h <= 0 or lo <= 0:
continue
highs.append(float(h))
lows.append(float(lo))
if not highs or not lows:
return None
return max(highs), min(lows)
def _hl_from_binance_klines(rows: list[Any]) -> tuple[float, float] | None:
"""Binance kline: [openTime, o, h, l, c, ...] oldest first."""
highs: list[float] = []
lows: list[float] = []
for row in rows:
if not isinstance(row, (list, tuple)) or len(row) < 5:
continue
h = bn_safe_float(row[2])
lo = bn_safe_float(row[3])
if h is None or lo is None or h <= 0 or lo <= 0:
continue
highs.append(float(h))
lows.append(float(lo))
if not highs or not lows:
return None
return max(highs), min(lows)
def fetch_okx_amplitude_hl(
*,
inst_id: str,
hours: float,
base_url: str = "https://www.okx.com",
proxy: str | None = None,
) -> AmplitudeHL | None:
"""用 1H K 线回看 hoursinst 可用指数 ETH-USD 或永续 ETH-USDT-SWAP。"""
import math
import httpx
hrs = max(1.0, float(hours))
limit = int(min(300, max(2, math.ceil(hrs) + 1)))
try:
with httpx.Client(
base_url=base_url.rstrip("/"),
timeout=15.0,
proxy=(proxy or "").strip() or None,
headers={"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.1"},
) as client:
r = client.get(
"/api/v5/market/candles",
params={"instId": inst_id, "bar": "1H", "limit": str(limit)},
)
r.raise_for_status()
body = r.json()
if str(body.get("code")) != "0":
logger.warning("OKX candles error: %s", body.get("msg"))
return None
data = body.get("data") or []
except Exception as e:
logger.warning("OKX candles fetch failed: %s", e)
return None
hl = _hl_from_okx_candles(data)
if hl is None:
return None
high, low = hl
mid = (high + low) / 2.0
return AmplitudeHL(
high=high, low=low, mid=mid, hours=hrs, bar_count=len(data)
)
def fetch_binance_amplitude_hl(
*,
symbol: str,
hours: float,
fapi_base: str = "https://fapi.binance.com",
proxy: str | None = None,
) -> AmplitudeHL | None:
"""USDT 永续 1h klines。"""
import math
import httpx
hrs = max(1.0, float(hours))
limit = int(min(500, max(2, math.ceil(hrs) + 1)))
sym = str(symbol or "ETHUSDT").upper().replace("-", "")
try:
with httpx.Client(
base_url=fapi_base.rstrip("/"),
timeout=15.0,
proxy=(proxy or "").strip() or None,
headers={"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.1"},
trust_env=False,
) as client:
r = client.get(
"/fapi/v1/klines",
params={"symbol": sym, "interval": "1h", "limit": limit},
)
r.raise_for_status()
data = r.json()
if not isinstance(data, list):
return None
except Exception as e:
logger.warning("Binance klines fetch failed: %s", e)
return None
hl = _hl_from_binance_klines(data)
if hl is None:
return None
high, low = hl
mid = (high + low) / 2.0
return AmplitudeHL(
high=high, low=low, mid=mid, hours=hrs, bar_count=len(data)
)
def fetch_amplitude_hl_for_runtime(hours: float) -> AmplitudeHL | None:
"""按当前交易所 runtime 拉振幅高低点。"""
from ..config import get_settings
from .runtime import load_runtime_settings
s = get_settings()
rt = load_runtime_settings()
ex = str(rt.exchange or "okx").strip().lower()
hrs = float(hours)
if ex in ("binance", "bn"):
return fetch_binance_amplitude_hl(
symbol=str(rt.perp_inst_id or "ETHUSDT"),
hours=hrs,
fapi_base=s.binance_fapi_base,
proxy=s.binance_http_proxy or None,
)
# OKX:优先指数,失败再试永续
idx = str(rt.index_inst_id or "ETH-USD")
amp = fetch_okx_amplitude_hl(
inst_id=idx,
hours=hrs,
base_url=s.okx_rest_base,
proxy=s.okx_http_proxy or None,
)
if amp is not None:
return amp
return fetch_okx_amplitude_hl(
inst_id=str(rt.perp_inst_id or "ETH-USDT-SWAP"),
hours=hrs,
base_url=s.okx_rest_base,
proxy=s.okx_http_proxy or None,
)
+53
View File
@@ -0,0 +1,53 @@
"""期权合约 ID 工具:从持仓合约还原同到期同行权价的 Call/Put 对。"""
from __future__ import annotations
from .expiry import expiry_ms_from_ymd
from .types import OptionPair
def flip_option_side(inst_id: str) -> str | None:
"""ETH-...-1880-P ↔ ...-C;币安 ETH-YYMMDD-STRIKE-P ↔ -C。"""
s = (inst_id or "").strip()
if not s:
return None
if s.endswith("-C"):
return s[:-1] + "P"
if s.endswith("-P"):
return s[:-1] + "C"
if s.endswith("-c"):
return s[:-1] + "p"
if s.endswith("-p"):
return s[:-1] + "c"
return None
def pair_from_option_inst(inst_id: str) -> OptionPair | None:
"""由任一腿合约 ID 还原同 strike/expiry 的 OptionPair。"""
from .okx.parse import parse_option_inst_id
from .binance.parse import parse_option_symbol
s = (inst_id or "").strip()
if not s:
return None
ymd, strike, side = parse_option_inst_id(s)
if ymd is None:
ymd, strike, side = parse_option_symbol(s)
if ymd is None or strike is None or side not in ("C", "P"):
return None
other = flip_option_side(s)
if not other:
return None
call_id = s if side == "C" else other
put_id = s if side == "P" else other
try:
ems = expiry_ms_from_ymd(ymd)
except ValueError:
return None
return OptionPair(
expiry_ymd=ymd,
expiry_ms=int(ems),
strike=float(strike),
call_inst_id=call_id,
put_inst_id=put_id,
)
+28 -1
View File
@@ -43,12 +43,20 @@ class OptionPair:
strike: float
call_inst_id: str
put_inst_id: str
put_strike: float | None = None # 期期:Put 行权价;None=与 strike 同(ATM
def to_dict(self) -> dict[str, Any]:
put_k = (
float(self.put_strike)
if self.put_strike is not None
else float(self.strike)
)
return {
"expiry_ymd": self.expiry_ymd,
"expiry_ms": self.expiry_ms,
"strike": self.strike,
"call_strike": float(self.strike),
"put_strike": put_k,
"call_inst_id": self.call_inst_id,
"put_inst_id": self.put_inst_id,
}
@@ -88,7 +96,26 @@ def _open_bias(
call: Quote | None,
put: Quote | None,
) -> str:
"""与开仓 decide 一致:先按 ATM 相对现价,贴平时再卖一比价"""
"""与开仓 decide 一致;固定方向开启时显示 fixed_*"""
try:
from ..config import get_settings
from ..models.db import get_db
s = get_settings()
db = get_db()
raw = db.get_setting(
"fixed_direction_enabled", str(s.fixed_direction_enabled)
)
on = str(raw or "").strip().lower() in ("1", "true", "yes", "on")
if on:
side = str(
db.get_setting("fixed_perp_side", s.fixed_perp_side)
or s.fixed_perp_side
or "long"
).strip().lower()
return "fixed_long_put" if side == "long" else "fixed_short_call"
except Exception:
pass
mark = None
if index_px is not None and index_px > 0:
mark = float(index_px)
+6 -1
View File
@@ -1 +1,6 @@
# Placeholder: live OKX trade adapter (P5). Default off.
"""实盘执行适配层。"""
from .binance_executor import BinanceLiveExecutor
from .executor import OkxLiveExecutor, get_executor
__all__ = ["get_executor", "OkxLiveExecutor", "BinanceLiveExecutor"]
File diff suppressed because it is too large Load Diff
+604
View File
@@ -0,0 +1,604 @@
"""币安私有交易:USDT-M 永续 (fapi) + 欧洲期权 (eapi)。"""
from __future__ import annotations
import hashlib
import hmac
import logging
import time
from typing import Any
from urllib.parse import urlencode
import httpx
from ..config import Settings, get_settings
from ..exchange.okx.parse import safe_float
from .okx_trade import LiveFill
from .rate_limit import RateLimitError, get_throttle, parse_retry_after_header
logger = logging.getLogger(__name__)
class BinanceTradeClient:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
proxy = (self.settings.binance_http_proxy or "").strip() or None
headers = {
"Accept": "application/json",
"User-Agent": "eth-hedge-live/0.1",
"X-MBX-APIKEY": self.settings.binance_api_key or "",
}
self._fapi = httpx.Client(
base_url=self.settings.binance_fapi_base.rstrip("/"),
timeout=20.0,
proxy=proxy,
headers=headers,
trust_env=False,
)
self._eapi = httpx.Client(
base_url=self.settings.binance_eapi_base.rstrip("/"),
timeout=20.0,
proxy=proxy,
headers=headers,
trust_env=False,
)
self._hedge: bool | None = None
self._fapi_throttle = get_throttle("binance_fapi_trade", min_interval_sec=1.0)
self._eapi_throttle = get_throttle(
"binance_eapi_trade",
min_interval_sec=1.0,
cooldown_429_sec=20.0,
cooldown_418_sec=120.0,
)
def close(self) -> None:
self._fapi.close()
self._eapi.close()
def _sign(self, params: dict[str, Any]) -> str:
qs = urlencode(params, doseq=True)
secret = (self.settings.binance_api_secret or "").encode("utf-8")
return hmac.new(secret, qs.encode("utf-8"), hashlib.sha256).hexdigest()
def _throttle_for(self, client: httpx.Client):
if client is self._eapi:
return self._eapi_throttle
return self._fapi_throttle
def _signed(
self,
client: httpx.Client,
method: str,
path: str,
params: dict[str, Any] | None = None,
) -> Any:
throttle = self._throttle_for(client)
throttle.before_request()
p = dict(params or {})
p["timestamp"] = int(time.time() * 1000)
p["signature"] = self._sign(p)
r = client.request(method.upper(), path, params=p)
if r.status_code in (418, 429):
ra = parse_retry_after_header(r.headers)
throttle.mark_http(r.status_code, ra)
raise RateLimitError(
f"Binance {path} HTTP {r.status_code}: {r.text[:200]}",
retry_after=throttle.remaining_cooldown(),
)
if r.status_code >= 400:
raise RuntimeError(f"Binance {path} HTTP {r.status_code}: {r.text[:400]}")
data = r.json()
if isinstance(data, dict) and "code" in data and "orderId" not in data:
code = data.get("code")
try:
code_i = int(code)
except (TypeError, ValueError):
code_i = None
msg = str(data.get("msg") or "")
# -1003 too many requests; -1015 too many orders
if code_i in (-1003, -1015) or "too many" in msg.lower():
throttle.mark_seconds(20.0)
raise RateLimitError(
f"Binance rate-limited code={code} msg={msg}",
retry_after=throttle.remaining_cooldown(),
)
if code_i is not None and code_i != 0:
raise RuntimeError(f"Binance error code={code} msg={msg}")
if code_i is None:
raise RuntimeError(f"Binance error code={code} msg={msg}")
return data
def is_hedge_mode(self) -> bool:
if self._hedge is not None:
return self._hedge
try:
data = self._signed(self._fapi, "GET", "/fapi/v1/positionSide/dual")
self._hedge = bool(data.get("dualSidePosition") in (True, "true", "True"))
except Exception as e:
logger.warning("binance hedge mode probe failed: %s; assume one-way", e)
self._hedge = False
return self._hedge
def place_perp_market(
self,
*,
symbol: str,
side: str, # BUY|SELL
qty_eth: float,
position_side: str | None = None, # LONG|SHORT|None
reduce_only: bool = False,
) -> LiveFill:
# ETHUSDT 数量单位为 ETH
qty = f"{float(qty_eth):.3f}".rstrip("0").rstrip(".")
if not qty or qty == "0":
qty = "0.001"
params: dict[str, Any] = {
"symbol": symbol,
"side": side.upper(),
"type": "MARKET",
"quantity": qty,
}
hedge = self.is_hedge_mode()
if hedge:
ps = (position_side or ("LONG" if side.upper() == "BUY" else "SHORT")).upper()
params["positionSide"] = ps
elif reduce_only:
params["reduceOnly"] = "true"
data = self._signed(self._fapi, "POST", "/fapi/v1/order", params)
return self._fill_from_fapi(symbol, data)
def _fill_from_fapi(self, symbol: str, data: dict[str, Any]) -> LiveFill:
ord_id = str(data.get("orderId") or "")
avg = safe_float(data.get("avgPrice"))
sz = safe_float(data.get("executedQty"))
if (not avg or avg <= 0) and ord_id:
q = self._signed(
self._fapi,
"GET",
"/fapi/v1/order",
{"symbol": symbol, "orderId": ord_id},
)
avg = safe_float(q.get("avgPrice")) or avg
sz = safe_float(q.get("executedQty")) or sz
data = q
if not avg or avg <= 0:
raise RuntimeError(f"币安永续无成交均价 orderId={ord_id} last={data}")
from .money import abs_fee_usdt
fee = abs(safe_float(data.get("cumCommission")) or 0.0)
fee_asset = str(data.get("commissionAsset") or "USDT")
if fee <= 0 and ord_id:
fee, fee_asset = self.sum_perp_trade_fees(symbol, ord_id)
return LiveFill(
inst_id=symbol,
side=str(data.get("side") or "").lower(),
avg_px=float(avg),
sz=float(sz or 0),
fee=abs_fee_usdt(fee, fee_asset),
ord_id=ord_id,
raw=data if isinstance(data, dict) else {},
)
def place_option_market(
self,
*,
symbol: str,
side: str, # BUY|SELL
quantity: float,
reduce_only: bool = False,
) -> LiveFill:
qty = str(int(round(quantity)))
if qty == "0":
qty = "1"
params: dict[str, Any] = {
"symbol": symbol,
"side": side.upper(),
"type": "MARKET",
"quantity": qty,
}
if reduce_only:
params["reduceOnly"] = "true"
data = self._signed(self._eapi, "POST", "/eapi/v1/order", params)
return self._fill_from_eapi(symbol, data)
def place_option_ioc(
self,
*,
symbol: str,
side: str, # BUY|SELL
quantity: float,
price: float,
reduce_only: bool = False,
) -> LiveFill:
"""期权限价 IOC:按买一/卖一价吃单,未成交部分取消。"""
qty = str(int(round(quantity)))
if qty == "0":
qty = "1"
px = f"{float(price):.8f}".rstrip("0").rstrip(".")
if not px or px == "0":
raise RuntimeError("币安期权 IOC 价格无效")
params: dict[str, Any] = {
"symbol": symbol,
"side": side.upper(),
"type": "LIMIT",
"timeInForce": "IOC",
"quantity": qty,
"price": px,
}
if reduce_only:
params["reduceOnly"] = "true"
data = self._signed(self._eapi, "POST", "/eapi/v1/order", params)
return self._fill_from_eapi(symbol, data, allow_partial=True)
def _fill_from_eapi(
self, symbol: str, data: dict[str, Any], *, allow_partial: bool = False
) -> LiveFill:
ord_id = str(data.get("orderId") or data.get("id") or "")
avg = safe_float(data.get("avgPrice")) or safe_float(data.get("price"))
sz = safe_float(data.get("executedQty")) or safe_float(data.get("quantity"))
if (not avg or avg <= 0) and ord_id:
# 轮询几轮
for _ in range(8):
time.sleep(0.2)
q = self._signed(
self._eapi,
"GET",
"/eapi/v1/order",
{"symbol": symbol, "orderId": ord_id},
)
avg = safe_float(q.get("avgPrice")) or safe_float(q.get("price"))
sz = safe_float(q.get("executedQty")) or safe_float(q.get("quantity"))
st = str(q.get("status") or "").upper()
data = q
if avg and avg > 0 and st == "FILLED":
break
if st in ("CANCELED", "REJECTED", "EXPIRED"):
if allow_partial and sz and sz > 1e-12 and avg and avg > 0:
break
raise RuntimeError(f"币安期权订单失败 status={st} {q}")
if st == "PARTIALLY_FILLED":
continue
if not avg or avg <= 0:
raise RuntimeError(f"币安期权无成交均价 orderId={ord_id} last={data}")
st_final = str(data.get("status") or "").upper()
executed = safe_float(data.get("executedQty")) or float(sz or 0)
if st_final and st_final != "FILLED":
if not (
allow_partial
and executed > 1e-12
and st_final in ("CANCELED", "EXPIRED", "PARTIALLY_FILLED")
):
raise RuntimeError(
f"币安期权未完全成交 status={st_final} orderId={ord_id} last={data}"
)
sz = executed
from .money import abs_fee_usdt
fee = abs(safe_float(data.get("fee")) or 0.0)
fee_asset = "USDT"
if fee <= 0 and ord_id:
fee, fee_asset = self.sum_option_trade_fees(symbol, ord_id)
return LiveFill(
inst_id=symbol,
side=str(data.get("side") or "").lower(),
avg_px=float(avg),
sz=float(sz or 0),
fee=abs_fee_usdt(fee, fee_asset),
ord_id=ord_id,
raw=data if isinstance(data, dict) else {},
)
def sum_perp_trade_fees(self, symbol: str, order_id: str) -> tuple[float, str]:
try:
rows = self._signed(
self._fapi,
"GET",
"/fapi/v1/userTrades",
{"symbol": symbol, "orderId": order_id},
)
except Exception as e:
logger.warning("binance perp userTrades fee failed: %s", e)
return 0.0, "USDT"
if not isinstance(rows, list):
rows = [rows] if isinstance(rows, dict) else []
total = 0.0
asset = "USDT"
for row in rows:
total += abs(safe_float(row.get("commission")) or 0.0)
if row.get("commissionAsset"):
asset = str(row.get("commissionAsset"))
return total, asset
def sum_option_trade_fees(self, symbol: str, order_id: str) -> tuple[float, str]:
try:
rows = self._signed(
self._eapi,
"GET",
"/eapi/v1/userTrades",
{"symbol": symbol, "orderId": order_id},
)
except Exception as e:
logger.warning("binance option userTrades fee failed: %s", e)
return 0.0, "USDT"
if not isinstance(rows, list):
rows = [rows] if isinstance(rows, dict) else []
total = 0.0
asset = "USDT"
for row in rows:
total += abs(safe_float(row.get("commission")) or safe_float(row.get("fee")) or 0.0)
if row.get("commissionAsset") or row.get("feeAsset"):
asset = str(row.get("commissionAsset") or row.get("feeAsset"))
return total, asset
def get_perp_upl_usdt(self, symbol: str, *, position_side: str | None = None) -> float | None:
from .money import to_usdt
try:
rows = self._signed(
self._fapi, "GET", "/fapi/v2/positionRisk", {"symbol": symbol}
)
except Exception as e:
logger.warning("binance positionRisk failed: %s", e)
return None
if isinstance(rows, dict):
rows = [rows]
want = (position_side or "").strip().upper()
for row in rows:
amt = safe_float(row.get("positionAmt")) or 0.0
if abs(amt) < 1e-12:
continue
ps = str(row.get("positionSide") or "").upper()
if want and ps and ps not in ("BOTH",) and ps != want:
continue
upl = safe_float(row.get("unRealizedProfit"))
if upl is None:
continue
return to_usdt(float(upl), "USDT")
return 0.0
def fetch_balances(self) -> dict[str, float | None]:
"""交易侧可用:USDT-M 钱包 USDT + 期权账户 USDT/USDC(尽力而为)。"""
out: dict[str, float | None] = {
"trading_usdt": None,
"trading_usdc": None,
}
try:
rows = self._signed(self._fapi, "GET", "/fapi/v2/balance")
if isinstance(rows, dict):
rows = [rows]
for row in rows or []:
if not isinstance(row, dict):
continue
asset = str(row.get("asset") or "").upper()
avail = safe_float(row.get("availableBalance"))
if avail is None:
avail = safe_float(row.get("balance"))
if asset == "USDT" and avail is not None:
out["trading_usdt"] = float(avail)
elif asset == "USDC" and avail is not None:
# 永续侧 USDC 少见;若有则记
if out["trading_usdc"] is None:
out["trading_usdc"] = float(avail)
except Exception as e:
logger.warning("binance fapi balance failed: %s", e)
try:
data = self._signed(self._eapi, "GET", "/eapi/v1/marginAccount")
asset_list = []
if isinstance(data, dict):
asset_list = data.get("asset") or data.get("assets") or []
if isinstance(asset_list, list):
for row in asset_list:
if not isinstance(row, dict):
continue
asset = str(
row.get("asset") or row.get("currency") or ""
).upper()
avail = (
safe_float(row.get("available"))
or safe_float(row.get("marginBalance"))
or safe_float(row.get("equity"))
)
if asset == "USDT" and avail is not None:
# 期权保证金常用 USDT;与 fapi 取较大可用
cur = out.get("trading_usdt")
out["trading_usdt"] = (
float(avail)
if cur is None
else max(float(cur), float(avail))
)
elif asset == "USDC" and avail is not None:
out["trading_usdc"] = float(avail)
except Exception as e:
logger.warning("binance eapi marginAccount failed: %s", e)
# 币安期权常用 USDT 保证金:eapi 无独立 USDC 时,用 USDT 作为期权侧可用
if out.get("trading_usdt") is not None and out.get("trading_usdc") is None:
out["trading_usdc"] = float(out["trading_usdt"])
return out
def get_perp_pos_sz(self, symbol: str, *, position_side: str | None = None) -> float | None:
"""当前永续绝对持仓(ETH)。"""
try:
rows = self._signed(
self._fapi, "GET", "/fapi/v2/positionRisk", {"symbol": symbol}
)
except Exception as e:
logger.warning("binance get_perp_pos_sz failed: %s", e)
return None
if isinstance(rows, dict):
rows = [rows]
want = (position_side or "").strip().upper()
for row in rows:
amt = safe_float(row.get("positionAmt")) or 0.0
if abs(amt) < 1e-12:
continue
ps = str(row.get("positionSide") or "").upper()
if want and ps and ps not in ("BOTH",) and ps != want:
continue
return abs(float(amt))
return 0.0
def get_option_pos_sz(self, symbol: str) -> float | None:
"""期权持仓绝对张数;查不到接口时返回 None。"""
try:
rows = self._signed(self._eapi, "GET", "/eapi/v1/position", {"symbol": symbol})
except Exception as e:
logger.warning("binance get_option_pos_sz failed: %s", e)
return None
if isinstance(rows, dict):
rows = [rows]
total = 0.0
hit = False
for row in rows:
if not isinstance(row, dict):
continue
if str(row.get("symbol") or "") and str(row.get("symbol")) != symbol:
continue
qty = safe_float(row.get("quantity")) or safe_float(row.get("positionAmt")) or 0.0
hit = True
total += abs(float(qty))
return total if hit else 0.0
def any_option_pos_abs(self) -> float | None:
"""账户任意期权绝对持仓合计(ETH 期权)。"""
try:
rows = self._signed(self._eapi, "GET", "/eapi/v1/position", {})
except Exception as e:
logger.warning("binance any_option_pos_abs failed: %s", e)
return None
if isinstance(rows, dict):
rows = [rows]
total = 0.0
for row in rows:
if not isinstance(row, dict):
continue
sym = str(row.get("symbol") or "")
if sym and not sym.upper().startswith("ETH"):
continue
qty = safe_float(row.get("quantity")) or safe_float(row.get("positionAmt")) or 0.0
total += abs(float(qty))
return total
def set_margin_type(self, symbol: str, margin_type: str) -> None:
"""ISOLATED | CROSSED。"""
mt = "ISOLATED" if str(margin_type).lower() == "isolated" else "CROSSED"
try:
self._signed(
self._fapi,
"POST",
"/fapi/v1/marginType",
{"symbol": symbol, "marginType": mt},
)
except Exception as e:
# 已是目标模式时币安常报错,忽略
msg = str(e).lower()
if "no need to change" in msg or "-4046" in msg:
return
raise
def set_leverage(self, symbol: str, leverage: int | float) -> None:
lev = int(round(float(leverage)))
if lev < 1:
lev = 1
self._signed(
self._fapi,
"POST",
"/fapi/v1/leverage",
{"symbol": symbol, "leverage": lev},
)
def get_option_exercise_records(
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
) -> list[dict] | None:
"""用户期权行权/到期结算记录 GET /eapi/v1/exerciseRecord。"""
end = int(end_ms or int(time.time() * 1000))
begin = int(begin_ms)
try:
rows = self._signed(
self._eapi,
"GET",
"/eapi/v1/exerciseRecord",
{
"symbol": symbol,
"startTime": begin,
"endTime": end,
"limit": 100,
},
)
except Exception as e:
logger.warning("binance exerciseRecord failed: %s", e)
return None
if isinstance(rows, dict):
rows = [rows]
if not isinstance(rows, list):
return []
return [r for r in rows if isinstance(r, dict)]
def get_funding_usdt(
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
) -> float:
from .money import to_usdt
end = int(end_ms or int(time.time() * 1000))
try:
rows = self._signed(
self._fapi,
"GET",
"/fapi/v1/income",
{
"symbol": symbol,
"incomeType": "FUNDING_FEE",
"startTime": int(begin_ms),
"endTime": end,
"limit": 1000,
},
)
except Exception as e:
logger.warning("binance funding income failed: %s", e)
return 0.0
if isinstance(rows, dict):
rows = [rows]
total = 0.0
for row in rows:
raw = safe_float(row.get("income"))
if raw is None:
continue
asset = str(row.get("asset") or "USDT")
total += to_usdt(float(raw), asset)
return total
def get_closed_perp_pnl_usdt(
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
) -> float | None:
"""用 REALIZED_PNL income 近似已实现(含部分平仓);资金费另计。"""
from .money import to_usdt
end = int(end_ms or int(time.time() * 1000))
try:
rows = self._signed(
self._fapi,
"GET",
"/fapi/v1/income",
{
"symbol": symbol,
"incomeType": "REALIZED_PNL",
"startTime": int(begin_ms),
"endTime": end,
"limit": 1000,
},
)
except Exception as e:
logger.warning("binance realized income failed: %s", e)
return None
if isinstance(rows, dict):
rows = [rows]
if not rows:
return None
total = 0.0
for row in rows:
raw = safe_float(row.get("income"))
if raw is None:
continue
asset = str(row.get("asset") or "USDT")
total += to_usdt(float(raw), asset)
return total
File diff suppressed because it is too large Load Diff
+204
View File
@@ -0,0 +1,204 @@
"""LIVE 组净盈亏:交易所永续 UPL/资金费 + 本地期权净盈亏 − 真实手续费。"""
from __future__ import annotations
import logging
import time
from typing import Any, Protocol
logger = logging.getLogger(__name__)
class _FeeFundingClient(Protocol):
def get_perp_upl_usdt(self, *args: Any, **kwargs: Any) -> float | None: ...
def get_funding_usdt(self, *args: Any, **kwargs: Any) -> float: ...
def get_closed_perp_pnl_usdt(self, *args: Any, **kwargs: Any) -> float | None: ...
def group_paid_fees_usdt(db: Any, group_id: str) -> float:
rows = db.fetchall(
"SELECT fee FROM fills WHERE group_id=?",
(group_id,),
)
return sum(abs(float(r["fee"] or 0)) for r in rows)
def enrich_live_unrealized(
*,
base: dict[str, Any],
db: Any,
client: Any,
exchange: str,
perp_inst_id: str,
perp_side: str,
open_at_ms: int | None,
) -> dict[str, Any]:
"""
在 Matcher.unrealized 结果上覆盖 LIVE 口径:
net = 永续交易所UPL + 期权净盈亏(本地) − 入场手续费×2 + 资金费(signed)
离场手续费按入场手续费估算(开+平 ≈ 已付×2)。
"""
if not base.get("has_position"):
return base
group_id = str(base.get("group_id") or "")
fees_paid = group_paid_fees_usdt(db, group_id) if group_id else 0.0
begin = int(open_at_ms or 0)
funding = 0.0
perp_upl = float(base.get("perp_upl") or 0.0)
ex = (exchange or "").lower()
try:
if ex == "binance":
side = "LONG" if perp_side == "long" else "SHORT"
upl = client.get_perp_upl_usdt(perp_inst_id, position_side=side)
if upl is not None:
perp_upl = float(upl)
if begin > 0:
funding = float(
client.get_funding_usdt(perp_inst_id, begin_ms=begin) or 0.0
)
else:
upl = client.get_perp_upl_usdt(perp_inst_id, pos_side=perp_side)
if upl is not None:
perp_upl = float(upl)
if begin > 0:
funding = float(
client.get_funding_usdt(perp_inst_id, begin_ms=begin) or 0.0
)
except Exception as e:
logger.warning("live unrealized exchange overlay failed: %s", e)
option_upl = float(base.get("option_upl") or 0.0) # 期权净盈亏(本地)
option2_upl = float(base.get("option2_upl") or 0.0) # 期期 Put 腿
# 盯盘/达标:离场费已在 base.net_pnl(盘口可成交价)计入;这里只叠加资金费。
# 勿用交易所永续标记 UPL 覆盖净利,否则会虚高触发达标、成交后变亏。
book_net = base.get("net_pnl")
if book_net is not None:
net_pnl = float(book_net) + funding
else:
fees_est = float(fees_paid) * 2.0
net_pnl = perp_upl + option_upl + option2_upl - fees_est + funding
out = dict(base)
out["perp_upl"] = float(base.get("perp_upl") or perp_upl)
out["perp_upl_exchange"] = perp_upl
out["option_upl"] = option_upl
out["option2_upl"] = option2_upl
out["fees_paid"] = fees_paid
out["funding_usdt"] = funding
out["est_close_fees"] = float(base.get("est_close_fees") or fees_paid)
out["net_pnl"] = net_pnl
out["net_pnl_exchange"] = (
perp_upl + option_upl + option2_upl - float(fees_paid) * 2.0 + funding
)
out["pnl_source"] = "live_book_plus_funding"
return out
def reconcile_closed_group_pnl(
*,
db: Any,
client: Any,
exchange: str,
group_id: str,
perp_inst_id: str,
open_at_ms: int | None,
local_net: float | None,
) -> float:
"""
平仓后回写:净盈亏优先用 交易所永续已实现 + 本地期权腿盈亏 − 手续费 + 资金费。
失败则退回 local_net。
"""
from ..sim.pnl import summarize_fills_pnl
fills = db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
)
summary = summarize_fills_pnl(list(fills))
fees = float(summary.get("fees_total") or 0.0)
option_pnl = summary.get("option_pnl")
local_perp = summary.get("perp_pnl")
begin = int(open_at_ms or 0)
end = int(time.time() * 1000)
funding = 0.0
exch_perp: float | None = None
ex = (exchange or "").lower()
try:
if begin > 0:
funding = float(client.get_funding_usdt(perp_inst_id, begin_ms=begin, end_ms=end) or 0.0)
exch_perp = client.get_closed_perp_pnl_usdt(
perp_inst_id, begin_ms=begin, end_ms=end
)
except Exception as e:
logger.warning("reconcile exchange pnl failed: %s", e)
perp_pnl = float(exch_perp) if exch_perp is not None else (
float(local_perp) if local_perp is not None else 0.0
)
opt = float(option_pnl) if option_pnl is not None else 0.0
# 期权平仓 fill 为 0 价镜像时:尝试用交易所交割账单补期权腿
try:
zero_opt_close = False
for fr in fills:
if str(fr["leg"] or "") in ("option", "option2") and str(
fr["action"] or ""
) == "close":
if abs(float(fr["notional"] or 0)) < 1e-12 and abs(
float(fr["fill_px"] or 0)
) < 1e-12:
zero_opt_close = True
break
if zero_opt_close:
from .option_settle import fetch_option_settlement
g = db.fetchone(
"SELECT option_inst_id, option2_inst_id FROM groups WHERE group_id=?",
(group_id,),
)
settle_cash = 0.0
for inst_key in ("option_inst_id", "option2_inst_id"):
inst = str((g[inst_key] if g else None) or "")
if not inst:
continue
st = fetch_option_settlement(
client,
exchange=ex,
option_inst_id=inst,
qty_eth=1.0,
begin_ms=begin,
end_ms=end,
)
if st.found:
settle_cash += float(st.cash)
if abs(settle_cash) > 1e-12:
# 用交割净现金替换本地 0 价期权盈亏近似:仍减 fees(交割费若已在 cash 内则可能双计,保守保留)
opt = float(settle_cash)
except Exception as e:
logger.warning("reconcile option settlement overlay failed: %s", e)
net = perp_pnl + opt - fees + funding
if local_net is not None and exch_perp is None and abs(funding) < 1e-12:
# 交易所永续已实现拉不到且无资金费 → 保持本地
net = float(local_net)
with db._lock:
try:
db._conn.execute(
"UPDATE groups SET realized_pnl=?, funding_usdt=?, fees=? WHERE group_id=?",
(float(net), float(funding), float(fees), group_id),
)
db._conn.commit()
except Exception:
# funding_usdt 列未迁移时降级
db._conn.execute(
"UPDATE groups SET realized_pnl=?, fees=? WHERE group_id=?",
(float(net), float(fees), group_id),
)
db._conn.commit()
return float(net)
+20
View File
@@ -0,0 +1,20 @@
"""实盘金额口径:统一折 USDT(USDC 等 1:1)。"""
from __future__ import annotations
def to_usdt(amount: float, ccy: str | None = None) -> float:
"""按约定将币种金额折成 USDTUSDC/USD/USDT 一律 1:1。"""
a = float(amount or 0.0)
if a == 0.0:
return 0.0
c = (ccy or "USDT").strip().upper()
if c in ("USDT", "USDC", "USD", ""):
return a
# 其他币种暂按面值记(极少见);后续可扩汇率
return a
def abs_fee_usdt(fee: float, ccy: str | None = None) -> float:
"""手续费记为正成本(USDT)。"""
return abs(to_usdt(fee, ccy))
+181
View File
@@ -0,0 +1,181 @@
"""OKX 资金:余额 / USDT↔USDC 现货兑换 / 账户划转(对齐 crypto_monitor)。"""
from __future__ import annotations
import logging
from typing import Any
from .okx_trade import OkxTradeClient
logger = logging.getLogger(__name__)
# OKX acct: 6=资金, 18=交易
_ACCT_CODE = {
"funding": "6",
"trading": "18",
"spot": "18",
}
def _f(v: Any) -> float | None:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
class OkxFundsClient:
def __init__(self, trade: OkxTradeClient | None = None) -> None:
self.trade = trade or OkxTradeClient()
def close(self) -> None:
self.trade.close()
def fetch_balances(self) -> dict[str, float | None]:
"""
拉取资金账户 + 交易账户 USDT/USDC。
资金:GET /api/v5/asset/balances
交易:GET /api/v5/account/balance
"""
out: dict[str, float | None] = {
"funding_usdt": None,
"funding_usdc": None,
"trading_usdt": None,
"trading_usdc": None,
}
try:
rows = self.trade._request("GET", "/api/v5/asset/balances")
for row in rows:
ccy = str(row.get("ccy") or "").upper()
bal = _f(row.get("bal")) or _f(row.get("availBal"))
if ccy == "USDT":
out["funding_usdt"] = bal
elif ccy == "USDC":
out["funding_usdc"] = bal
except Exception as e:
logger.warning("OKX asset balances failed: %s", e)
try:
rows = self.trade._request("GET", "/api/v5/account/balance")
for block in rows:
details = block.get("details") or []
if not isinstance(details, list):
continue
for row in details:
if not isinstance(row, dict):
continue
ccy = str(row.get("ccy") or "").upper()
eq = _f(row.get("eq")) or _f(row.get("cashBal")) or _f(row.get("availBal"))
if ccy == "USDT":
out["trading_usdt"] = eq
elif ccy == "USDC":
out["trading_usdc"] = eq
except Exception as e:
logger.warning("OKX account balance failed: %s", e)
return out
def spot_swap_usdt_usdc(self, *, direction: str, amount: float) -> dict[str, Any]:
"""现货市价兑换 USDC-USDT(与 crypto_monitor spot_market_swap_usdt_usdc 同口径)。"""
amt = float(amount)
if amt <= 0:
return {"ok": False, "detail": "数量须大于 0"}
d = (direction or "").strip().lower()
inst_id = "USDC-USDT"
if d == "usdt_to_usdc":
body = {
"instId": inst_id,
"tdMode": "cash",
"side": "buy",
"ordType": "market",
"sz": str(amt),
"tgtCcy": "quote_ccy",
}
elif d == "usdc_to_usdt":
body = {
"instId": inst_id,
"tdMode": "cash",
"side": "sell",
"ordType": "market",
"sz": str(amt),
"tgtCcy": "base_ccy",
}
else:
return {"ok": False, "detail": "direction 须为 usdt_to_usdc 或 usdc_to_usdt"}
try:
rows = self.trade._request("POST", "/api/v5/trade/order", body)
if not rows:
return {"ok": False, "detail": "兑换下单无返回"}
row = rows[0]
if str(row.get("sCode") or "0") not in ("0", ""):
return {
"ok": False,
"detail": str(row.get("sMsg") or row.get("sCode") or "兑换失败"),
"raw": row,
}
return {"ok": True, "detail": "converted", "data": row}
except Exception as e:
return {"ok": False, "detail": str(e)}
def transfer(
self,
*,
ccy: str,
amount: float,
from_account: str,
to_account: str,
) -> dict[str, Any]:
"""同一 API Key 下资金↔交易划转。"""
amt = float(amount)
if amt <= 0:
return {"ok": False, "detail": "划转金额须大于 0"}
fa = (from_account or "").strip().lower()
ta = (to_account or "").strip().lower()
if fa == ta:
return {"ok": False, "detail": "来源与目标账户不能相同"}
from_code = _ACCT_CODE.get(fa)
to_code = _ACCT_CODE.get(ta)
if not from_code or not to_code:
return {"ok": False, "detail": "账户须为 funding / trading"}
body = {
"ccy": str(ccy).upper(),
"amt": str(amt),
"from": from_code,
"to": to_code,
"type": "0",
}
try:
rows = self.trade._request("POST", "/api/v5/asset/transfer", body)
if not rows:
return {"ok": False, "detail": "划转无返回"}
return {"ok": True, "detail": "transferred", "data": rows[0]}
except Exception as e:
return {"ok": False, "detail": str(e)}
def usdc_usdt_mid_rate() -> float:
"""公共盘口中间价:1 USDC ≈ ? USDT;失败则 1.0。"""
try:
import httpx
from ..config import get_settings
s = get_settings()
proxy = (s.okx_http_proxy or "").strip() or None
with httpx.Client(base_url=s.okx_rest_base.rstrip("/"), timeout=8.0, proxy=proxy) as c:
r = c.get("/api/v5/market/ticker", params={"instId": "USDC-USDT"})
r.raise_for_status()
rows = (r.json() or {}).get("data") or []
if not rows:
return 1.0
bid = _f(rows[0].get("bidPx"))
ask = _f(rows[0].get("askPx"))
last = _f(rows[0].get("last"))
if bid and ask and bid > 0 and ask > 0:
return (bid + ask) / 2.0
if last and last > 0:
return last
except Exception as e:
logger.warning("USDC-USDT mid failed: %s", e)
return 1.0
+473
View File
@@ -0,0 +1,473 @@
"""OKX V5 私有交易 REST(下单)。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import time
from dataclasses import dataclass
from typing import Any
import httpx
from ..config import Settings, get_settings
from ..exchange.okx.parse import safe_float
from .rate_limit import RateLimitError, get_throttle, parse_retry_after_header
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class LiveFill:
inst_id: str
side: str
avg_px: float
sz: float # 张或币,取决于合约
fee: float
ord_id: str
raw: dict[str, Any]
class OkxTradeClient:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
proxy = (self.settings.okx_http_proxy or "").strip() or None
self._client = httpx.Client(
base_url=self.settings.okx_rest_base.rstrip("/"),
timeout=20.0,
proxy=proxy,
headers={"Accept": "application/json", "User-Agent": "eth-hedge-live/0.1"},
)
self._ct_val_cache: dict[str, float] = {}
self._throttle = get_throttle("okx_trade", min_interval_sec=1.0)
def close(self) -> None:
self._client.close()
def _ts(self) -> str:
# OKX: ISO8601 with milliseconds
return (
time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())
+ f".{int(time.time() * 1000) % 1000:03d}Z"
)
def _sign(self, ts: str, method: str, path: str, body: str) -> str:
secret = (self.settings.okx_api_secret or "").encode("utf-8")
msg = f"{ts}{method.upper()}{path}{body}".encode("utf-8")
dig = hmac.new(secret, msg, hashlib.sha256).digest()
return base64.b64encode(dig).decode("utf-8")
def _headers(self, ts: str, sign: str) -> dict[str, str]:
return {
"OK-ACCESS-KEY": self.settings.okx_api_key or "",
"OK-ACCESS-SIGN": sign,
"OK-ACCESS-TIMESTAMP": ts,
"OK-ACCESS-PASSPHRASE": self.settings.okx_api_passphrase or "",
"Content-Type": "application/json",
}
def _request(
self, method: str, path: str, body: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
self._throttle.before_request()
payload = "" if body is None else json.dumps(body, separators=(",", ":"))
ts = self._ts()
sign = self._sign(ts, method, path, payload)
headers = self._headers(ts, sign)
if method.upper() == "GET":
r = self._client.get(path, headers=headers)
else:
r = self._client.request(method.upper(), path, content=payload, headers=headers)
if r.status_code in (418, 429):
ra = parse_retry_after_header(r.headers)
self._throttle.mark_http(r.status_code, ra)
raise RateLimitError(
f"OKX HTTP {r.status_code}: {r.text[:200]}",
retry_after=self._throttle.remaining_cooldown(),
)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
raise RuntimeError(f"OKX HTTP {r.status_code}: {r.text[:300]}") from e
data = r.json()
code = str(data.get("code") or "")
msg = str(data.get("msg") or "")
# OKX 业务层频率类错误
if code != "0":
low = f"{code} {msg}".lower()
if code in ("50011", "50061") or "too many" in low or "频率" in msg:
self._throttle.mark_seconds(20.0)
raise RateLimitError(
f"OKX trade rate-limited code={code} msg={msg}",
retry_after=self._throttle.remaining_cooldown(),
)
raise RuntimeError(
f"OKX trade error code={code} msg={msg} data={data.get('data')}"
)
rows = data.get("data") or []
return [x for x in rows if isinstance(x, dict)]
def get_ct_val(self, inst_id: str, *, inst_type: str) -> float:
if inst_id in self._ct_val_cache:
return self._ct_val_cache[inst_id]
r = self._client.get(
"/api/v5/public/instruments",
params={"instType": inst_type, "instId": inst_id},
)
r.raise_for_status()
body = r.json()
rows = body.get("data") or []
for row in rows:
if str(row.get("instId")) == inst_id:
v = safe_float(row.get("ctVal")) or safe_float(row.get("ctMult"))
if v and v > 0:
self._ct_val_cache[inst_id] = float(v)
return float(v)
raise RuntimeError(f"OKX 无法取得合约面值 ctVal: {inst_id} instType={inst_type}")
def place_market(
self,
*,
inst_id: str,
side: str, # buy|sell
sz: str,
td_mode: str,
pos_side: str | None = None,
reduce_only: bool = False,
) -> LiveFill:
body: dict[str, Any] = {
"instId": inst_id,
"tdMode": td_mode,
"side": side,
"ordType": "market",
"sz": str(sz),
}
if pos_side:
body["posSide"] = pos_side
if reduce_only:
body["reduceOnly"] = True
rows = self._request("POST", "/api/v5/trade/order", body)
if not rows:
raise RuntimeError("OKX 下单无返回")
ord_id = str(rows[0].get("ordId") or "")
# 查单取均价
fill = self._wait_fill(inst_id, ord_id)
return fill
def place_ioc(
self,
*,
inst_id: str,
side: str, # buy|sell
sz: str,
px: float | str,
td_mode: str,
pos_side: str | None = None,
reduce_only: bool = False,
) -> LiveFill:
"""限价 IOC:残留回收等场景按指定买一/卖一吃单,不成交部分立即取消。"""
body: dict[str, Any] = {
"instId": inst_id,
"tdMode": td_mode,
"side": side,
"ordType": "ioc",
"sz": str(sz),
"px": str(px),
}
if pos_side:
body["posSide"] = pos_side
if reduce_only:
body["reduceOnly"] = True
rows = self._request("POST", "/api/v5/trade/order", body)
if not rows:
raise RuntimeError("OKX IOC 下单无返回")
ord_id = str(rows[0].get("ordId") or "")
return self._wait_fill(inst_id, ord_id, allow_partial=True)
def _fill_from_order_row(self, inst_id: str, ord_id: str, row: dict[str, Any]) -> LiveFill:
avg = safe_float(row.get("avgPx")) or 0.0
sz = safe_float(row.get("accFillSz")) or safe_float(row.get("sz")) or 0.0
fee = abs(safe_float(row.get("fee")) or 0.0)
fee_ccy = str(row.get("feeCcy") or "USDT")
if fee <= 0 and ord_id:
fee, fee_ccy = self.sum_fill_fees(inst_id, ord_id)
from .money import abs_fee_usdt
return LiveFill(
inst_id=inst_id,
side=str(row.get("side") or ""),
avg_px=float(avg),
sz=float(sz),
fee=abs_fee_usdt(fee, fee_ccy),
ord_id=ord_id,
raw=row,
)
def _wait_fill(
self,
inst_id: str,
ord_id: str,
*,
tries: int = 40,
allow_partial: bool = False,
) -> LiveFill:
path = f"/api/v5/trade/order?instId={inst_id}&ordId={ord_id}"
last: dict[str, Any] = {}
for _ in range(tries):
rows = self._request("GET", path)
if rows:
last = rows[0]
state = str(last.get("state") or "")
avg = safe_float(last.get("avgPx"))
acc = safe_float(last.get("accFillSz")) or 0.0
# 仅完全成交;部分成交继续等,避免账本张数与交易所不一致
if state == "filled" and avg and avg > 0:
return self._fill_from_order_row(inst_id, ord_id, last)
if state in ("canceled", "failed"):
# IOC:未成交部分取消;若已有成交量则按部分成交入账
if (
allow_partial
and acc > 1e-12
and avg
and avg > 0
):
return self._fill_from_order_row(inst_id, ord_id, last)
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
time.sleep(0.3)
# 超时兜底:仅接受完全成交;部分成交不得当全成记账(会错张数/对冲)
state = str(last.get("state") or "")
avg = safe_float(last.get("avgPx"))
acc = safe_float(last.get("accFillSz")) or 0.0
if state == "filled" and avg and avg > 0:
logger.warning(
"OKX fill wait timeout but order filled ordId=%s",
ord_id,
)
return self._fill_from_order_row(inst_id, ord_id, last)
if allow_partial and acc > 1e-12 and avg and avg > 0:
logger.warning(
"OKX IOC partial fill on timeout ordId=%s acc=%s",
ord_id,
acc,
)
return self._fill_from_order_row(inst_id, ord_id, last)
raise RuntimeError(f"OKX 订单未完全成交 ordId={ord_id} last={last}")
def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]:
"""成交明细手续费合计(原币种金额, 币种)。"""
path = f"/api/v5/trade/fills?instId={inst_id}&ordId={ord_id}"
try:
rows = self._request("GET", path)
except Exception as e:
logger.warning("okx fills fee query failed: %s", e)
return 0.0, "USDT"
total = 0.0
ccy = "USDT"
for row in rows:
f = abs(safe_float(row.get("fee")) or 0.0)
total += f
if row.get("feeCcy"):
ccy = str(row.get("feeCcy"))
return total, ccy
def get_perp_upl_usdt(self, inst_id: str, *, pos_side: str | None = None) -> float | None:
"""当前永续未实现盈亏(USDT,1:1)。"""
from .money import to_usdt
try:
rows = self._request(
"GET", f"/api/v5/account/positions?instId={inst_id}"
)
except Exception as e:
logger.warning("okx positions failed: %s", e)
return None
want = (pos_side or "").strip().lower()
for row in rows:
ps = str(row.get("posSide") or "").lower()
pos = safe_float(row.get("pos")) or 0.0
if abs(pos) < 1e-12:
continue
if want and want not in ("net", "") and ps and ps != want and ps != "net":
continue
upl = safe_float(row.get("upl"))
if upl is None:
continue
ccy = str(row.get("ccy") or row.get("settleCcy") or "USDT")
return to_usdt(float(upl), ccy)
return 0.0
def get_perp_pos_sz(self, inst_id: str, *, pos_side: str | None = None) -> float | None:
"""当前永续绝对持仓张数。"""
try:
rows = self._request(
"GET", f"/api/v5/account/positions?instId={inst_id}"
)
except Exception as e:
logger.warning("okx get_perp_pos_sz failed: %s", e)
return None
want = (pos_side or "").strip().lower()
for row in rows:
ps = str(row.get("posSide") or "").lower()
pos = safe_float(row.get("pos")) or 0.0
if abs(pos) < 1e-12:
continue
if want and want not in ("net", "") and ps and ps != want and ps != "net":
continue
return abs(float(pos))
return 0.0
def get_option_pos_sz(self, inst_id: str) -> float | None:
"""期权绝对持仓张数。"""
try:
rows = self._request(
"GET",
f"/api/v5/account/positions?instType=OPTION&instId={inst_id}",
)
except Exception as e:
logger.warning("okx get_option_pos_sz failed: %s", e)
return None
total = 0.0
for row in rows:
pos = safe_float(row.get("pos")) or 0.0
total += abs(float(pos))
return total
def any_option_pos_abs(self) -> float | None:
"""账户任意期权绝对持仓张数合计。"""
try:
rows = self._request("GET", "/api/v5/account/positions?instType=OPTION")
except Exception as e:
logger.warning("okx any_option_pos_abs failed: %s", e)
return None
total = 0.0
for row in rows:
pos = safe_float(row.get("pos")) or 0.0
total += abs(float(pos))
return total
def set_leverage(
self,
inst_id: str,
leverage: float,
*,
mgn_mode: str = "cross",
pos_side: str | None = None,
) -> None:
body: dict[str, Any] = {
"instId": inst_id,
"lever": str(leverage),
"mgnMode": mgn_mode,
}
if pos_side:
body["posSide"] = pos_side
self._request("POST", "/api/v5/account/set-leverage", body)
def get_funding_usdt(
self, inst_id: str, *, begin_ms: int, end_ms: int | None = None
) -> float:
"""资金费合计(已计入账户的 signed 金额,USDT 1:1)。付费为负。"""
from .money import to_usdt
end = int(end_ms or int(time.time() * 1000))
# type=8 funding fee
path = (
f"/api/v5/account/bills?instType=SWAP&instId={inst_id}"
f"&type=8&begin={int(begin_ms)}&end={end}"
)
total = 0.0
try:
rows = self._request("GET", path)
except Exception as e:
logger.warning("okx funding bills failed: %s", e)
return 0.0
for row in rows:
# balChg / pnl 视接口;资金费常用 pnl 或 balChg
raw = safe_float(row.get("pnl"))
if raw is None:
raw = safe_float(row.get("balChg"))
if raw is None:
continue
ccy = str(row.get("ccy") or "USDT")
total += to_usdt(float(raw), ccy)
return total
def get_option_settlement_bills(
self, inst_id: str, *, begin_ms: int, end_ms: int | None = None
) -> list[dict] | None:
"""期权交割/行权相关账单(近 7 日 bills;失败再试 archive)。"""
end = int(end_ms or int(time.time() * 1000))
begin = int(begin_ms)
# type=3 Delivery;再本地按 subType 170/171/172 过滤
path = (
f"/api/v5/account/bills?instType=OPTION&instId={inst_id}"
f"&type=3&begin={begin}&end={end}"
)
try:
rows = self._request("GET", path)
except Exception as e:
logger.warning("okx option bills type=3 failed: %s; try all types", e)
try:
rows = self._request(
"GET",
f"/api/v5/account/bills?instType=OPTION&instId={inst_id}"
f"&begin={begin}&end={end}",
)
except Exception as e2:
logger.warning("okx option bills failed: %s; try archive", e2)
try:
rows = self._request(
"GET",
f"/api/v5/account/bills-archive?instType=OPTION&instId={inst_id}"
f"&begin={begin}&end={end}",
)
except Exception as e3:
logger.warning("okx option bills-archive failed: %s", e3)
return None
if not isinstance(rows, list):
return []
return [r for r in rows if isinstance(r, dict)]
def get_closed_perp_pnl_usdt(
self, inst_id: str, *, begin_ms: int, end_ms: int | None = None
) -> float | None:
"""平仓后从历史仓位取已实现盈亏(不含手续费;含部分仓位盈亏)。"""
from .money import to_usdt
end = int(end_ms or int(time.time() * 1000))
begin = int(begin_ms)
# OKXafter=更早时间戳边界,before=更晚;再本地按 uTime 过滤兜底
path = (
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}"
f"&after={begin}&before={end}"
)
try:
rows = self._request("GET", path)
except Exception as e:
logger.warning("okx positions-history failed: %s", e)
try:
rows = self._request(
"GET",
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}",
)
except Exception as e2:
logger.warning("okx positions-history fallback failed: %s", e2)
return None
total = 0.0
hit = False
for row in rows:
u_time = int(safe_float(row.get("uTime")) or safe_float(row.get("cTime")) or 0)
if u_time and (u_time < begin - 60_000 or u_time > end + 60_000):
continue
rpnl = safe_float(row.get("realizedPnl"))
if rpnl is None:
rpnl = safe_float(row.get("pnl"))
if rpnl is None:
continue
hit = True
ccy = str(row.get("ccy") or "USDT")
total += to_usdt(float(rpnl), ccy)
return total if hit else None
+180
View File
@@ -0,0 +1,180 @@
"""LIVE 期权到期交割:从交易所账单/行权记录取结算现金,禁止本地 intrinsic 发明。"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# OKX bills subType:行权 / 对手行权 / 虚值到期
_OKX_EXERCISE_SUBTYPES = frozenset({"170", "171", "172"})
@dataclass(slots=True)
class OptionSettlement:
"""qty_eth>0 时 fill_px = notional/qty_ethcash 为入账净额(已扣费)。"""
found: bool
notional: float = 0.0 # 结算毛额(入账前)
fee: float = 0.0
cash: float = 0.0 # notional - fee
fill_px: float = 0.0
source: str = ""
detail: str = ""
def settlement_to_fill(
st: OptionSettlement, *, qty_eth: float
) -> tuple[float, float, float]:
"""返回 (fill_px, fee, notional)。未找到则全 0。"""
if not st.found:
return 0.0, 0.0, 0.0
q = float(qty_eth)
if q > 1e-12 and st.fill_px <= 0 and st.notional > 0:
return float(st.notional) / q, float(st.fee), float(st.notional)
return float(st.fill_px), float(st.fee), float(st.notional)
def fetch_option_settlement(
client: Any,
*,
exchange: str,
option_inst_id: str,
qty_eth: float,
begin_ms: int | None,
end_ms: int | None = None,
) -> OptionSettlement:
"""查交易所期权交割/行权入账。查不到 → found=False(调用方零价镜像、不发明)。"""
inst = str(option_inst_id or "").strip()
if not inst or client is None:
return OptionSettlement(found=False, detail="no_inst_or_client")
begin = int(begin_ms or 0)
end = int(end_ms or int(time.time() * 1000))
if begin <= 0:
# 无开仓时间:收窄到近 48h,避免扫全量
begin = end - 48 * 3600 * 1000
ex = (exchange or "").strip().lower()
try:
if ex in ("binance", "bn"):
return _bn_settlement(client, inst, qty_eth=qty_eth, begin=begin, end=end)
return _okx_settlement(client, inst, qty_eth=qty_eth, begin=begin, end=end)
except Exception as e:
logger.warning("fetch_option_settlement failed %s %s: %s", ex, inst, e)
return OptionSettlement(found=False, detail=str(e)[:160])
def _okx_settlement(
client: Any, inst_id: str, *, qty_eth: float, begin: int, end: int
) -> OptionSettlement:
from .money import to_usdt
from ..exchange.okx.parse import safe_float
getter = getattr(client, "get_option_settlement_bills", None)
if callable(getter):
rows = getter(inst_id, begin_ms=begin, end_ms=end)
else:
rows = None
if rows is None:
return OptionSettlement(found=False, detail="okx_bills_unavailable")
cash = 0.0
fee = 0.0
hit = False
for row in rows:
if not isinstance(row, dict):
continue
sub = str(row.get("subType") or "")
typ = str(row.get("type") or "")
if sub not in _OKX_EXERCISE_SUBTYPES and typ not in ("3",):
continue
ts = int(safe_float(row.get("ts")) or 0)
if ts and (ts < begin - 120_000 or ts > end + 120_000):
continue
hit = True
raw = safe_float(row.get("balChg"))
if raw is None:
raw = safe_float(row.get("pnl"))
ccy = str(row.get("ccy") or "USDT")
if raw is not None:
cash += to_usdt(float(raw), ccy)
fraw = safe_float(row.get("fee"))
if fraw is not None:
# OKX fee 常为负
fee += abs(to_usdt(float(fraw), ccy))
# 有些账单 pnl 已含费,fee 字段为 0
if not hit:
return OptionSettlement(found=False, detail="okx_no_exercise_bill")
# balChg/pnl 视为账户净变动;fee 另计时用净额+费还原毛额作 fill notional
net_cash = float(cash)
fee = float(fee)
if net_cash >= 0:
notional = float(net_cash) + fee
else:
notional = 0.0
q = float(qty_eth)
fill_px = (notional / q) if q > 1e-12 and notional > 0 else 0.0
return OptionSettlement(
found=True,
notional=float(notional),
fee=float(fee),
cash=float(net_cash),
fill_px=float(fill_px),
source="okx_bills",
detail=f"subTypes exercise bills cash={net_cash:.6f}",
)
def _bn_settlement(
client: Any, symbol: str, *, qty_eth: float, begin: int, end: int
) -> OptionSettlement:
from .money import to_usdt
from ..exchange.okx.parse import safe_float
getter = getattr(client, "get_option_exercise_records", None)
if not callable(getter):
return OptionSettlement(found=False, detail="bn_exercise_api_missing")
rows = getter(symbol, begin_ms=begin, end_ms=end)
if rows is None:
return OptionSettlement(found=False, detail="bn_exercise_unavailable")
if not rows:
return OptionSettlement(found=False, detail="bn_no_exercise_record")
amount = 0.0
fee = 0.0
hit = False
for row in rows:
if not isinstance(row, dict):
continue
if str(row.get("symbol") or "") and str(row.get("symbol")) != symbol:
continue
hit = True
ccy = str(row.get("currency") or row.get("quoteAsset") or "USDT")
am = safe_float(row.get("amount"))
if am is not None:
amount += to_usdt(float(am), ccy)
fr = safe_float(row.get("fee"))
if fr is not None:
fee += abs(to_usdt(float(fr), ccy))
if not hit:
return OptionSettlement(found=False, detail="bn_no_matching_record")
notional = max(0.0, float(amount))
net = float(amount) - float(fee)
q = float(qty_eth)
fill_px = (notional / q) if q > 1e-12 and notional > 0 else 0.0
return OptionSettlement(
found=True,
notional=notional,
fee=float(fee),
cash=float(net),
fill_px=float(fill_px),
source="binance_exerciseRecord",
detail=f"amount={amount:.6f} fee={fee:.6f}",
)
+196
View File
@@ -0,0 +1,196 @@
"""实盘交易限流:私有 REST 冷却 + 失败退避。"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any
logger = logging.getLogger(__name__)
_DEFAULT_429_SEC = 20.0
_DEFAULT_418_SEC = 120.0
_INTERVAL_MIN = 0.2
_INTERVAL_MAX = 30.0
def resolve_live_order_interval_sec() -> float:
"""读取前端可配的 LIVE 下单最小间隔(秒),默认 1。"""
try:
from ..config import get_settings
from ..models.db import get_db
s = get_settings()
default = float(s.live_order_interval_sec)
raw = get_db().get_setting("live_order_interval_sec", str(default))
v = float(raw if raw not in (None, "") else default)
if v != v: # NaN
return 1.0
return max(_INTERVAL_MIN, min(_INTERVAL_MAX, v))
except Exception:
return 1.0
class RateLimitError(RuntimeError):
"""处于限流/冷却中,调用方应退避,勿立即重试下单。"""
def __init__(self, message: str, *, retry_after: float = 0.0) -> None:
super().__init__(message)
self.retry_after = float(retry_after)
class TradeThrottle:
"""按通道节流:最小间隔 + 418/429 冷却。"""
def __init__(
self,
name: str,
*,
min_interval_sec: float = 1.0,
cooldown_429_sec: float = _DEFAULT_429_SEC,
cooldown_418_sec: float = _DEFAULT_418_SEC,
) -> None:
self.name = name
self.min_interval_sec = float(min_interval_sec)
self.cooldown_429_sec = float(cooldown_429_sec)
self.cooldown_418_sec = float(cooldown_418_sec)
self._lock = threading.Lock()
self._last_at = 0.0
self._cool_until = 0.0
def remaining_cooldown(self) -> float:
with self._lock:
return max(0.0, self._cool_until - time.monotonic())
def before_request(self) -> None:
"""请求前调用:冷却中抛 RateLimitError;否则等待最小间隔(可读设置)。"""
interval = resolve_live_order_interval_sec()
with self._lock:
self.min_interval_sec = interval
now = time.monotonic()
if now < self._cool_until:
left = self._cool_until - now
raise RateLimitError(
f"{self.name} rate-limit cooldown {left:.1f}s",
retry_after=left,
)
gap = now - self._last_at
wait = interval - gap
if wait > 0:
time.sleep(wait)
with self._lock:
self._last_at = time.monotonic()
def mark_http(self, status_code: int, retry_after: float | None = None) -> None:
if status_code not in (418, 429):
return
if status_code == 418:
wait = self.cooldown_418_sec
else:
wait = float(retry_after) if retry_after and retry_after > 0 else self.cooldown_429_sec
wait = max(wait, self.cooldown_429_sec)
with self._lock:
self._cool_until = time.monotonic() + wait
logger.warning("%s HTTP %s → cooldown %.0fs", self.name, status_code, wait)
def mark_seconds(self, seconds: float) -> None:
wait = max(1.0, float(seconds))
with self._lock:
self._cool_until = max(self._cool_until, time.monotonic() + wait)
logger.warning("%s cooldown %.0fs (manual)", self.name, wait)
_THROTTLES: dict[str, TradeThrottle] = {}
_THROTTLES_LOCK = threading.Lock()
def get_throttle(name: str, **kwargs: Any) -> TradeThrottle:
with _THROTTLES_LOCK:
t = _THROTTLES.get(name)
if t is None:
t = TradeThrottle(name, **kwargs)
_THROTTLES[name] = t
return t
def is_rate_limit_error(exc: BaseException | str) -> bool:
if isinstance(exc, RateLimitError):
return True
text = str(exc).lower()
needles = (
"429",
"418",
"rate limit",
"rate-limit",
"ratelimit",
"too many request",
"cooldown",
"banned",
"frequency",
"请求过于频繁",
"超出频率",
)
return any(n in text for n in needles)
def parse_retry_after_header(headers: Any) -> float | None:
try:
raw = headers.get("Retry-After") if headers is not None else None
if raw is None:
return None
return float(raw)
except (TypeError, ValueError):
return None
class LiveRetryGate:
"""引擎侧失败退避:避免 half_open / pending / liquidity 每秒砸单。"""
def __init__(
self,
*,
base_sec: float = 2.0,
max_sec: float = 60.0,
rate_limit_min_sec: float = 20.0,
trip_after: int = 12,
trip_cooldown_sec: float = 180.0,
) -> None:
self.base_sec = float(base_sec)
self.max_sec = float(max_sec)
self.rate_limit_min_sec = float(rate_limit_min_sec)
self.trip_after = int(trip_after)
self.trip_cooldown_sec = float(trip_cooldown_sec)
self._fails: dict[str, int] = {}
self._next_at: dict[str, float] = {}
def allow(self, key: str) -> tuple[bool, float]:
"""返回 (可否执行, 剩余等待秒)。"""
left = max(0.0, self._next_at.get(key, 0.0) - time.monotonic())
return left <= 0.0, left
def success(self, key: str) -> None:
self._fails.pop(key, None)
self._next_at.pop(key, None)
def fail(self, key: str, *, rate_limited: bool = False) -> float:
n = int(self._fails.get(key, 0)) + 1
self._fails[key] = n
if rate_limited:
delay = max(self.rate_limit_min_sec, self.rate_limit_min_sec * (1.5 ** min(n - 1, 4)))
delay = min(delay, 120.0)
elif n >= self.trip_after:
delay = self.trip_cooldown_sec
logger.error(
"live retry gate tripped key=%s fails=%s cooldown=%.0fs",
key,
n,
delay,
)
else:
delay = min(self.max_sec, self.base_sec * (2 ** min(n - 1, 5)))
self._next_at[key] = time.monotonic() + delay
return delay
def fails(self, key: str) -> int:
return int(self._fails.get(key, 0))
+408
View File
@@ -0,0 +1,408 @@
"""LIVE 开仓对账:占槽、交易所持仓核对、平永续数量解析、stuck opening 恢复。"""
from __future__ import annotations
import logging
from typing import Any
from ..config import get_settings
from ..exchange.runtime import load_runtime_settings
from ..sim.matcher import BLOCKING_STATUSES, CloseResult
from .symbols import resolve_perp_inst_id
logger = logging.getLogger(__name__)
_PERP_EPS = 1e-8
_OPT_EPS = 1e-8
def claim_open_slot(db) -> tuple[bool, str]:
"""原子占用开仓槽:flat/空 → opening;已有 blocking 状态则拒绝。"""
with db._lock:
row = db._conn.execute("SELECT status FROM positions WHERE id=1").fetchone()
st = str(row["status"] or "") if row else ""
if st in BLOCKING_STATUSES:
return False, f"已有持仓/半仓状态({st}),请先修复或平仓"
cur = db._conn.execute(
"""UPDATE positions SET status='opening'
WHERE id=1 AND (status IS NULL OR status='' OR status='flat')"""
)
if cur.rowcount != 1:
st2 = st or "unknown"
return False, f"无法占用开仓槽(当前 status={st2})"
db._conn.commit()
return True, "ok"
def stamp_opening_intent(
db,
*,
group_id: str,
option_inst_id: str,
option_side: str,
perp_side: str,
option_qty_eth: float,
option_qty_contracts: float,
entry_index_px: float | None = None,
option_entry_px: float | None = None,
) -> None:
"""开仓意图落库:崩溃后仍可按 option_inst_id 恢复,禁止「opening 无元数据」。"""
with db._lock:
self_row = db._conn.execute(
"SELECT status FROM positions WHERE id=1"
).fetchone()
st = str(self_row["status"] or "") if self_row else ""
if st != "opening":
return
db._conn.execute(
"""UPDATE positions SET
group_id=?, option_inst_id=?, option_side=?, perp_side=?,
option_qty_eth=?, option_qty_contracts=?,
entry_index_px=COALESCE(?, entry_index_px),
option_entry_px=COALESCE(?, option_entry_px),
status='opening'
WHERE id=1 AND status='opening'""",
(
group_id,
option_inst_id,
option_side,
perp_side,
float(option_qty_eth),
float(option_qty_contracts),
entry_index_px,
option_entry_px,
),
)
db._conn.commit()
def release_open_slot_if_opening(db) -> None:
"""开仓失败且未落 half_open/open 时,释放 opening 占槽。"""
with db._lock:
db._conn.execute(
"""UPDATE positions SET
status='flat', group_id=NULL, option_inst_id=NULL,
option_side=NULL, perp_side=NULL,
option_qty_eth=0, option_qty_contracts=0,
option_entry_px=NULL, perp_qty_eth=0, perp_entry_px=NULL,
entry_index_px=NULL, initial_premium=0
WHERE id=1 AND status='opening'"""
)
db._conn.commit()
def exchange_perp_abs_size(
client: Any,
exchange: str,
perp_inst_id: str,
perp_side: str,
) -> float | None:
"""查询交易所永续绝对持仓:OKX 张数,Binance ETH。"""
ex = (exchange or "").strip().lower()
try:
if ex in ("binance", "bn"):
ps = "LONG" if perp_side == "long" else "SHORT"
return client.get_perp_pos_sz(perp_inst_id, position_side=ps)
ps = "long" if perp_side == "long" else "short"
return client.get_perp_pos_sz(perp_inst_id, pos_side=ps)
except Exception as e:
logger.warning("exchange_perp_abs_size failed exchange=%s: %s", ex, e)
return None
def exchange_option_abs_size(
client: Any, option_inst_id: str
) -> float | None:
try:
return client.get_option_pos_sz(option_inst_id)
except Exception as e:
logger.warning("exchange_option_abs_size failed: %s", e)
return None
def exchange_any_option_abs(client: Any) -> float | None:
try:
return client.any_option_pos_abs()
except Exception as e:
logger.warning("exchange_any_option_abs failed: %s", e)
return None
def assert_safe_to_open_live(executor) -> tuple[bool, str]:
"""LIVE 开仓前:本地无 blocking 仓,且交易所无残留永续/期权(flat/opening 时)。"""
if get_settings().is_sim:
return True, "ok"
pos = executor.current_position()
st = str(pos.get("status") or "")
if st in BLOCKING_STATUSES and st != "opening":
return False, f"已有持仓/半仓状态({st}),请先修复或平仓"
client, ex_name = _executor_client_and_exchange(executor)
if client is None:
return False, "无法核对交易所持仓"
perp_inst = resolve_perp_inst_id(executor.db)
if st in ("flat", "", "opening"):
sides = ("long", "short")
else:
sides = (str(pos.get("perp_side") or "long"),)
total = 0.0
for side in sides:
ex_sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, side)
if ex_sz is None:
return False, "无法核对交易所持仓"
total += float(ex_sz)
if total > _PERP_EPS and st in ("flat", "", "opening"):
return (
False,
"交易所有永续仓但本地无持仓,禁止新开,请人工核对",
)
# 期权:有具体合约则查该合约;flat 时查账户任意期权残留
opt_inst = str(pos.get("option_inst_id") or "")
if opt_inst:
opt_sz = exchange_option_abs_size(client, opt_inst)
if opt_sz is None:
return False, "无法核对交易所期权持仓"
if opt_sz > _OPT_EPS and st in ("flat", "", "opening"):
return (
False,
f"交易所有期权仓({opt_inst})但本地未确认持仓,禁止新开,请人工核对",
)
elif st in ("flat", "", "opening"):
# opening 且尚未 stamp option_inst_id 时仍须扫任意期权残留
any_opt = exchange_any_option_abs(client)
if any_opt is None:
return False, "无法核对交易所期权持仓"
if any_opt > _OPT_EPS:
return (
False,
"交易所有期权残留仓但本地无持仓,禁止新开,请人工核对",
)
return True, "ok"
def log_exchange_db_mismatch(executor) -> None:
"""LIVE 启动时记录交易所 vs 本地持仓不一致(仅日志,不阻断)。"""
if get_settings().is_sim:
return
ok, msg = assert_safe_to_open_live(executor)
if ok:
logger.info("LIVE startup reconcile: exchange/DB OK")
else:
logger.warning("LIVE startup reconcile mismatch: %s", msg)
# 启动时尝试恢复 stuck opening
try:
r = recover_stuck_opening(executor)
if r is not None:
logger.info("LIVE startup recover_opening: ok=%s detail=%s", r.ok, r.detail)
except Exception:
logger.exception("LIVE startup recover_opening failed")
def perp_close_contracts_okx(
client: Any,
*,
perp_inst: str,
perp_side: str,
perp_qty_eth: float,
ct_val: float,
allow_db_fallback: bool = False,
) -> int | None:
"""平永续张数:以交易所持仓为准。
返回 >0 应下单;0=已确认空仓;None=查仓失败(调用方不得当空仓 finalize)。
交易所已确认空仓时绝不回退 DB。allow_db_fallback 仅在查仓失败时可用。
"""
ps = "long" if perp_side == "long" else "short"
ex_sz = client.get_perp_pos_sz(perp_inst, pos_side=ps)
if ex_sz is None:
if not allow_db_fallback:
return None
return max(1, int(round(perp_qty_eth / ct_val)))
if ex_sz > _PERP_EPS:
return max(1, int(round(ex_sz)))
return 0
def perp_close_qty_eth_binance(
client: Any,
*,
perp_inst: str,
perp_side: str,
perp_qty_eth: float,
allow_db_fallback: bool = False,
) -> float | None:
"""平永续 ETH>0 下单;0=已确认空;None=查仓失败。已空绝不回退 DB。"""
ps = "LONG" if perp_side == "long" else "SHORT"
ex_sz = client.get_perp_pos_sz(perp_inst, position_side=ps)
if ex_sz is None:
if not allow_db_fallback:
return None
return float(perp_qty_eth)
if ex_sz > _PERP_EPS:
return float(ex_sz)
return 0.0
def perp_open_contracts_okx(*, perp_qty_eth: float, ct_val: float) -> int:
"""开仓张数:仅按设置名义/面值,不跟交易所残留。"""
if ct_val <= 0:
raise RuntimeError("ct_val invalid")
return max(1, int(round(float(perp_qty_eth) / float(ct_val))))
def recover_stuck_opening(executor) -> CloseResult | None:
"""恢复本地 status=opening
- 交易所期权+永续皆空 → 清槽
- 仅期权 → half_open 并尝试 repair
- 期权+永续 → 提升为 open(用本地已 stamp 的数量/均价)
- 无元数据且交易所仍有仓 → 保持 opening,返回失败详情
"""
if get_settings().is_sim:
return None
pos = executor.current_position()
st = str(pos.get("status") or "")
if st != "opening":
return None
client, ex_name = _executor_client_and_exchange(executor)
if client is None:
return CloseResult(ok=False, detail="recover_opening: 无交易客户端")
option_inst = str(pos.get("option_inst_id") or "")
perp_side = str(pos.get("perp_side") or "long")
group_id = str(pos.get("group_id") or "")
perp_inst = resolve_perp_inst_id(executor.db, group_id=group_id or None)
# 期期 openingperp_side 形如 oo_put:{put_inst}
oo_put_inst = ""
if perp_side.startswith("oo_put:"):
oo_put_inst = perp_side.split(":", 1)[1].strip()
perp_total = 0.0
if not oo_put_inst:
for side in ("long", "short"):
sz = exchange_perp_abs_size(client, ex_name or "", perp_inst, side)
if sz is None:
return CloseResult(ok=False, detail="recover_opening: 无法查永续")
perp_total += float(sz)
opt_sz = 0.0
put_sz = 0.0
if option_inst:
raw = exchange_option_abs_size(client, option_inst)
if raw is None:
return CloseResult(ok=False, detail="recover_opening: 无法查期权")
opt_sz = float(raw)
else:
any_opt = exchange_any_option_abs(client)
if any_opt is None:
return CloseResult(ok=False, detail="recover_opening: 无法查期权")
if any_opt > _OPT_EPS:
return CloseResult(
ok=False,
detail=(
"recover_opening: opening 无 option_inst_id 但交易所有期权仓,"
"禁止自动清槽,请人工核对"
),
)
opt_sz = 0.0
if oo_put_inst:
raw_p = exchange_option_abs_size(client, oo_put_inst)
if raw_p is None:
return CloseResult(ok=False, detail="recover_opening: 无法查期期 Put")
put_sz = float(raw_p)
# 两边皆空 → 清槽(期期须 Call+Put 皆空)
if opt_sz <= _OPT_EPS and put_sz <= _OPT_EPS and perp_total <= _PERP_EPS:
release_open_slot_if_opening(executor.db)
return CloseResult(ok=True, detail="recover_opening: 交易所空仓,已释放 opening")
# 期期:任一腿残留且无完整双边 → 禁止清槽;仅 Call 则 half_open repair
if oo_put_inst:
if put_sz > _OPT_EPS and opt_sz <= _OPT_EPS:
return CloseResult(
ok=False,
detail=(
f"recover_opening: 期期 Put 残留({oo_put_inst}) Call 已空,"
"禁止清槽,请人工核对"
),
)
if opt_sz > _OPT_EPS and put_sz > _OPT_EPS:
return CloseResult(
ok=False,
detail="recover_opening: 期期两腿均在交易所,请人工核对后 promote",
)
# 仅 Call → 落入下方 half_open
# 无元数据但有仓 → 不自动处理
if not option_inst:
return CloseResult(
ok=False,
detail="recover_opening: 交易所有仓但本地 opening 缺 option_inst_id",
)
# 仅期权 → half_open + repair
if opt_sz > _OPT_EPS and perp_total <= _PERP_EPS:
persist = getattr(executor, "_persist_half_open", None)
if not callable(persist):
return CloseResult(ok=False, detail="recover_opening: 无 half_open 落库")
of_px = float(pos.get("option_entry_px") or 0) or 0.0
# 数量以交易所为准
opt_contracts = float(opt_sz)
opt_qty = float(pos.get("option_qty_eth") or 0)
if hasattr(executor, "_ct_mult"):
from ..sim.liquidity import eth_from_contracts
opt_qty = eth_from_contracts(opt_contracts, executor._ct_mult(option_inst))
elif opt_qty <= 0:
opt_qty = float(opt_sz)
persist(
group_id=group_id or f"RCV-{option_inst[-12:]}",
bias="recover",
option_side=str(pos.get("option_side") or "call"),
perp_side=perp_side,
option_inst_id=option_inst,
entry_index_px=float(pos.get("entry_index_px") or 0) or 0.0,
strike=None,
expiry_ymd=None,
opt_qty=opt_qty,
opt_contracts=opt_contracts or opt_sz,
of_px=of_px,
of_fee=0.0,
detail="recover_opening option-only → half_open",
)
repair = getattr(executor, "repair_half_open", None)
if callable(repair):
return repair()
return CloseResult(ok=True, detail="recover_opening: 已落 half_open")
# 期权+永续 → 提升为 open
promote = getattr(executor, "_promote_opening_to_open", None)
if callable(promote):
return promote(pos=pos, perp_inst=perp_inst, opt_sz=opt_sz, perp_total=perp_total)
return CloseResult(
ok=False,
detail="recover_opening: 双边有仓但执行器无 promote,请人工核对",
)
def _executor_client_and_exchange(executor) -> tuple[Any | None, str | None]:
if get_settings().is_sim:
return None, None
try:
ex_name = load_runtime_settings().exchange
except Exception:
ex_name = get_settings().exchange
client = None
if hasattr(executor, "_client"):
try:
client = executor._client()
except Exception as e:
logger.warning("live executor client unavailable: %s", e)
return None, ex_name
return client, ex_name
+26
View File
@@ -0,0 +1,26 @@
"""LIVE 下单用的运行时合约解析(禁止只用 env 默认 perp_inst_id)。"""
from __future__ import annotations
from ..config import Settings
from ..exchange.runtime import load_runtime_settings
def live_settings() -> Settings:
return load_runtime_settings()
def resolve_perp_inst_id(db, *, group_id: str | None = None) -> str:
"""优先组内落库合约,否则 DB/交易所默认(load_runtime_settings)。"""
s = live_settings()
if group_id:
try:
row = db.fetchone(
"SELECT perp_inst_id FROM groups WHERE group_id=?",
(group_id,),
)
if row and row["perp_inst_id"]:
return str(row["perp_inst_id"])
except Exception:
pass
return str(s.perp_inst_id)
+88 -6
View File
@@ -36,17 +36,57 @@ async def lifespan(app: FastAPI):
# 而 health 显示 DB 里的 binance → 合约号/盘口错乱(期权一直 -/-)
from .exchange.runtime import load_runtime_settings
app_settings = get_settings()
if not app_settings.is_sim:
secret = (app_settings.auth_secret or "").strip()
if not secret or secret == "change-me-eth-hedge-sim-secret":
raise RuntimeError(
"LIVE 拒绝启动:请在 .env 设置非默认 AUTH_SECRET(勿用 change-me-eth-hedge-sim-secret"
)
settings = load_runtime_settings()
engine = StrategyEngine()
set_engine(engine)
# 半自动:无仓时清陈旧授权,须首页重新「授权开下一单」
try:
from .strategy.semi_auto import clear_armed_if_flat
clear_armed_if_flat(
db, has_open_position=engine.matcher.has_open_position()
)
except Exception:
logger.exception("clear stale semi_armed on startup failed")
# LIVE:进程启动后不自动真下单,须人工点「启动」
if not get_settings().is_sim:
try:
db._conn.execute(
"UPDATE strategy_state SET running=0, phase=? WHERE id=1",
("paused",),
)
db._conn.commit()
logger.info("LIVE startup: forced strategy pause (manual start required)")
except Exception:
logger.exception("LIVE startup force-pause failed")
try:
from .live import get_executor
from .live.reconcile import log_exchange_db_mismatch
log_exchange_db_mismatch(get_executor(db))
except Exception:
logger.exception("LIVE startup reconcile log failed")
engine.ensure_loop()
from .backup import start_auto_backup_task
start_auto_backup_task()
session = bootstrap_session(settings)
try:
await session.start()
logger.info(
"exchange=%s strategy session started (SIM)",
"exchange=%s strategy session started mode=%s",
settings.exchange,
"SIM" if get_settings().is_sim else "LIVE",
)
except Exception:
logger.exception("strategy session failed to start")
@@ -60,6 +100,9 @@ async def lifespan(app: FastAPI):
await engine._task
except Exception:
pass
from .backup import stop_auto_backup_task
await stop_auto_backup_task()
await session.stop()
from .exchange import set_exchange
from .strategy.session import set_session
@@ -72,15 +115,18 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="eth_hedge_sim",
version="0.3.0",
description="ETH 自动对冲模拟盘 P1/P2",
title="比特骆驼自动化对冲系统",
version="0.3.1",
description="比特骆驼自动对冲系统(eth_hedge_sim",
lifespan=lifespan,
docs_url=None if get_settings().disable_api_docs else "/docs",
redoc_url=None if get_settings().disable_api_docs else "/redoc",
openapi_url=None if get_settings().disable_api_docs else "/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_origins=[],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -127,6 +173,41 @@ _DIST = resolve_frontend_dist()
if (_DIST / "assets").is_dir():
app.mount("/assets", StaticFiles(directory=str(_DIST / "assets")), name="assets")
_ICONS = _DIST / "icons"
if _ICONS.is_dir():
app.mount("/icons", StaticFiles(directory=str(_ICONS)), name="icons")
def _dist_file(name: str) -> Path:
return _DIST / name
@app.get("/manifest.webmanifest")
async def web_manifest():
path = _dist_file("manifest.webmanifest")
if not path.exists():
raise HTTPException(status_code=404, detail="manifest missing")
return FileResponse(
path,
media_type="application/manifest+json",
headers={"Cache-Control": "no-cache"},
)
@app.get("/sw.js")
async def service_worker():
path = _dist_file("sw.js")
if not path.exists():
raise HTTPException(status_code=404, detail="service worker missing")
return FileResponse(
path,
media_type="application/javascript",
headers={
"Cache-Control": "no-cache",
"Service-Worker-Allowed": "/",
},
)
@app.get("/")
async def index_page():
@@ -146,6 +227,7 @@ async def index_page():
@app.get("/stats")
@app.get("/settings")
@app.get("/login")
@app.get("/fleet-login")
async def spa_pages(full_path: str = ""):
index = _DIST / "index.html"
if not index.exists():
+2
View File
@@ -40,6 +40,7 @@ def select_option_pair(
expiry_ymd: str | None = None,
min_hours: float | None = None,
now=None,
option_side: str | None = None,
) -> OptionPair | None:
contracts = normalize_contracts(instruments)
return _select_pair(
@@ -48,6 +49,7 @@ def select_option_pair(
expiry_ymd=expiry_ymd,
min_hours=min_hours,
now=now,
option_side=option_side,
)
+96 -1
View File
@@ -34,13 +34,18 @@ CREATE TABLE IF NOT EXISTS groups (
expiry_ymd TEXT,
entry_index_px REAL,
initial_premium REAL DEFAULT 0,
exit_target_usdt REAL,
open_at_ms INTEGER,
close_at_ms INTEGER,
close_reason TEXT,
realized_pnl REAL DEFAULT 0,
fees REAL DEFAULT 0,
slip_cost REAL DEFAULT 0,
note TEXT
note TEXT,
exec_mode TEXT,
funding_usdt REAL,
settle_index_px REAL,
perp_margin_mode TEXT
);
CREATE TABLE IF NOT EXISTS fills (
@@ -58,6 +63,7 @@ CREATE TABLE IF NOT EXISTS fills (
slip REAL NOT NULL,
notional REAL NOT NULL,
ts_ms INTEGER NOT NULL,
exec_mode TEXT,
FOREIGN KEY(group_id) REFERENCES groups(group_id)
);
@@ -74,6 +80,7 @@ CREATE TABLE IF NOT EXISTS positions (
option_entry_px REAL,
entry_index_px REAL,
initial_premium REAL DEFAULT 0,
exit_target_usdt REAL,
status TEXT
);
@@ -97,6 +104,38 @@ CREATE TABLE IF NOT EXISTS strategy_state (
last_error TEXT,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS residual_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT NOT NULL UNIQUE,
option_inst_id TEXT NOT NULL,
option_side TEXT NOT NULL,
option_qty_eth REAL NOT NULL,
option_qty_contracts REAL,
option_entry_px REAL NOT NULL,
strike REAL,
expiry_ymd TEXT,
expiry_ms INTEGER,
entry_index_px REAL,
initial_premium REAL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
created_at_ms INTEGER NOT NULL,
settled_at_ms INTEGER,
settle_px REAL,
settle_pnl REAL,
note TEXT,
FOREIGN KEY(group_id) REFERENCES groups(group_id)
);
CREATE TABLE IF NOT EXISTS funds_wallets (
id INTEGER PRIMARY KEY CHECK (id = 1),
funding_usdt REAL NOT NULL DEFAULT 0,
trading_usdt REAL NOT NULL DEFAULT 0,
options_funding_usdc REAL NOT NULL DEFAULT 0,
options_trading_usdc REAL NOT NULL DEFAULT 0,
options_funding_usdt REAL NOT NULL DEFAULT 0,
options_trading_usdt REAL NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL
);
"""
@@ -118,8 +157,45 @@ class Database:
self._conn.execute("PRAGMA journal_mode=WAL;")
self._conn.executescript(_SCHEMA)
self._conn.commit()
self._migrate_columns()
self._ensure_seed()
def _migrate_columns(self) -> None:
"""幂等补列:exec_mode。"""
with self._lock:
for table, col, decl in (
("groups", "exec_mode", "TEXT"),
("groups", "funding_usdt", "REAL"),
("groups", "settle_index_px", "REAL"),
("groups", "perp_margin_mode", "TEXT"),
("groups", "exit_target_usdt", "REAL"),
("groups", "hedge_mode", "TEXT"),
("groups", "option2_inst_id", "TEXT"),
("groups", "option2_side", "TEXT"),
("groups", "strike2", "REAL"),
("groups", "initial_premium2", "REAL"),
("positions", "exit_target_usdt", "REAL"),
("positions", "hedge_mode", "TEXT"),
("positions", "option2_inst_id", "TEXT"),
("positions", "option2_side", "TEXT"),
("positions", "option2_qty_eth", "REAL"),
("positions", "option2_qty_contracts", "REAL"),
("positions", "option2_entry_px", "REAL"),
("positions", "strike2", "REAL"),
("positions", "initial_premium2", "REAL"),
("fills", "exec_mode", "TEXT"),
("fills", "fee_ccy", "TEXT"),
):
cols = {
str(r[1])
for r in self._conn.execute(f"PRAGMA table_info({table})").fetchall()
}
if col not in cols:
self._conn.execute(
f"ALTER TABLE {table} ADD COLUMN {col} {decl}"
)
self._conn.commit()
def close(self) -> None:
with self._lock:
self._conn.close()
@@ -145,6 +221,19 @@ class Database:
"INSERT INTO strategy_state(id, running, phase, rounds_done, updated_at_ms) VALUES (1,0,'idle',0,?)",
(now,),
)
fw = self._conn.execute("SELECT id FROM funds_wallets WHERE id=1").fetchone()
if fw is None:
led = self._conn.execute(
"SELECT equity FROM ledger_meta WHERE id=1"
).fetchone()
eq = float(led["equity"]) if led else float(s.initial_equity)
self._conn.execute(
"""INSERT INTO funds_wallets(
id, funding_usdt, trading_usdt, options_funding_usdc, options_trading_usdc,
options_funding_usdt, options_trading_usdt, updated_at_ms
) VALUES (1,?,0,0,0,0,0,?)""",
(eq, now),
)
defaults = {
"fee_rate": str(s.fee_rate),
"initial_equity": str(s.initial_equity),
@@ -154,13 +243,19 @@ class Database:
"net_profit_target": str(s.net_profit_target),
"premium_exit_multiple": str(s.premium_exit_multiple),
"rest_seconds": str(s.rest_seconds),
"live_order_interval_sec": str(s.live_order_interval_sec),
"skip_weekends": str(s.skip_weekends),
"one_expiry_per_day": str(s.one_expiry_per_day),
"max_rounds": str(s.max_rounds),
"leverage": str(s.leverage),
"perp_margin_mode": str(s.perp_margin_mode),
"min_option_hours": str(s.min_option_hours),
"min_option_leverage": str(s.min_option_leverage),
"risk_leverage_basis": str(s.risk_leverage_basis),
"atm_open_offset_enabled": str(s.atm_open_offset_enabled),
"max_atm_open_offset": str(s.max_atm_open_offset),
"fixed_direction_enabled": str(s.fixed_direction_enabled),
"fixed_perp_side": str(s.fixed_perp_side),
"close_bid_mark_max_pct": str(s.close_bid_mark_max_pct),
"perp_qty_eth": str(s.perp_qty_eth),
"option_qty_eth": str(s.option_qty_eth),
+5
View File
@@ -0,0 +1,5 @@
"""通知子系统。"""
from . import wecom
__all__ = ["wecom"]
+386
View File
@@ -0,0 +1,386 @@
"""企业微信群机器人通知(独立模块)。"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
import httpx
from ..config import get_settings
from ..exchange.runtime import load_runtime_settings
from ..models.db import get_db
logger = logging.getLogger(__name__)
# 消息标识(Markdown 内展示,便于检索)
TAG_OPEN = "OPEN"
TAG_CLOSE = "CLOSE"
TAG_START = "START"
TAG_PAUSE = "PAUSE"
TAG_FAULT = "FAULT"
TAG_TEST = "TEST"
_last_fault_key: str | None = None
_last_fault_ms: float = 0.0
_FAULT_DEDUP_SEC = 300.0
CLOSE_REASON_ZH: dict[str, str] = {
"expiry": "到期结算全平",
"target_perp_only": "净盈利达标·只平永续(期权归档到期)",
"semi_target_points": "半自动·标的到点且组合净利>0",
"semi_perp_exit": "半自动·净利基数达标",
"fixed_usdt": "固定净盈利达标·双腿全平",
"premium_multiple": "权利金倍数达标·双腿全平",
"emergency": "紧急全平",
"emergency_perp": "紧急·只平永续",
"manual": "手动全平",
"perp_pending_retry": "续平永续",
"liquidity_retry": "等待流动性后全平",
"residual_premium_close": "残留期权·权利金回收中途平",
"unknown": "未知原因",
}
BIAS_ZH: dict[str, str] = {
"call_ask_gt_put": "买Call + 永续空",
"put_ask_gt_call": "买Put + 永续多",
"strike_below_spot": "买Call + 永续空",
"strike_above_spot": "买Put + 永续多",
"fixed_long_put": "固定方向·买Put + 永续多",
"fixed_short_call": "固定方向·买Call + 永续空",
"manual_call": "手动·买Call + 永续空",
"manual_put": "手动·买Put + 永续多",
}
def _as_bool(raw: str | None, default: bool = False) -> bool:
if raw is None or raw == "":
return default
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def wecom_enabled() -> bool:
s = get_settings()
if getattr(s, "wecom_enabled", False):
return True
try:
return _as_bool(get_db().get_setting("wecom_enabled", "0"), False)
except Exception:
return False
def wecom_webhook_url() -> str:
s = get_settings()
url = (getattr(s, "wecom_webhook_url", None) or "").strip()
if url:
return url
try:
return (get_db().get_setting("wecom_webhook_url", "") or "").strip()
except Exception:
return ""
def wecom_machine_name() -> str:
"""多机推送区分用的机器名(设置页 / WECOM_MACHINE_NAME)。"""
s = get_settings()
name = (getattr(s, "wecom_machine_name", None) or "").strip()
if name:
return name[:64]
try:
return (get_db().get_setting("wecom_machine_name", "") or "").strip()[:64]
except Exception:
return ""
def venue_label() -> str | None:
"""实盘时返回「实盘·交易所」;SIM 不展示盘口标签。"""
s = get_settings()
if s.is_sim:
return None
try:
ex = load_runtime_settings().exchange
except Exception:
ex = s.exchange or "okx"
ex_u = str(ex).strip().lower()
if ex_u in ("binance", "bn"):
return "实盘·币安"
if ex_u in ("gate", "gateio"):
return "实盘·Gate"
return "实盘·OKX"
def close_reason_zh(reason: str | None) -> str:
r = str(reason or "").strip()
if not r:
return "未知原因"
return CLOSE_REASON_ZH.get(r, r)
def direction_zh(extra: dict[str, Any]) -> str:
bias = str(extra.get("bias") or "").strip()
if bias in BIAS_ZH:
return BIAS_ZH[bias]
opt = str(extra.get("option_side") or "").strip().lower()
perp = str(extra.get("perp_side") or "").strip().lower()
if opt == "put" and perp == "long":
return "买Put + 永续多"
if opt == "call" and perp == "short":
return "买Call + 永续空"
if opt == "put":
return "买Put"
if opt == "call":
return "买Call"
if bias:
return bias
return ""
def _fmt_num(x: Any, digits: int = 2) -> str:
try:
if x is None or x == "":
return ""
return f"{float(x):.{digits}f}"
except (TypeError, ValueError):
return ""
def _fmt_money(x: Any, *, signed: bool = False) -> str:
try:
if x is None or x == "":
return ""
v = float(x)
if signed:
return f"{v:+.2f}U"
return f"{v:.2f}U"
except (TypeError, ValueError):
return ""
def _pick_float(data: dict[str, Any], *keys: str) -> float | None:
for k in keys:
if k not in data or data[k] is None or data[k] == "":
continue
try:
return float(data[k])
except (TypeError, ValueError):
continue
return None
def build_markdown(*, tag: str, title: str, lines: list[str] | None = None) -> str:
body = "\n".join(f"> {ln}" if not ln.startswith(">") else ln for ln in (lines or []))
machine = wecom_machine_name()
venue = venue_label()
prefix_parts: list[str] = []
if machine:
prefix_parts.append(f"{machine}")
if venue:
prefix_parts.append(f"{venue}")
head = f"## {''.join(prefix_parts)}{title}"
parts = [
head,
f"> **标识**: `{tag}`",
f"> **时间**: {time.strftime('%Y-%m-%d %H:%M:%S')}",
]
if machine:
parts.append(f"> **机器**: {machine}")
if body:
parts.append("")
parts.append(body)
return "\n".join(parts)
def _post_markdown_sync(content: str) -> tuple[bool, str]:
if not wecom_enabled():
return False, "未开启企业微信通知"
url = wecom_webhook_url()
if not url:
return False, "未配置 Webhook"
# 企业微信 markdown 上限约 4096 字节
raw = content.encode("utf-8")
if len(raw) > 4000:
content = raw[:3900].decode("utf-8", errors="ignore") + "\n"
payload = {"msgtype": "markdown", "markdown": {"content": content}}
try:
with httpx.Client(timeout=8.0) as client:
r = client.post(url, json=payload)
data = r.json() if r.content else {}
if r.status_code != 200 or int(data.get("errcode") or 0) != 0:
return False, str(data.get("errmsg") or r.text or r.status_code)
return True, "ok"
except Exception as e:
return False, str(e)
async def send_markdown(content: str) -> tuple[bool, str]:
return await asyncio.to_thread(_post_markdown_sync, content)
def notify_async(content: str) -> None:
"""火忘:不阻塞策略循环。"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
ok, msg = _post_markdown_sync(content)
if not ok:
logger.warning("wecom sync send failed: %s", msg)
return
async def _run() -> None:
ok, msg = await send_markdown(content)
if not ok:
logger.warning("wecom send failed: %s", msg)
loop.create_task(_run())
def notify_start() -> None:
notify_async(
build_markdown(
tag=TAG_START,
title="策略启动",
lines=["策略已启动,可新开仓并盯目标平仓。"],
)
)
def notify_pause() -> None:
notify_async(
build_markdown(
tag=TAG_PAUSE,
title="策略暂停",
lines=[
"策略已暂停:**不再新开仓**。",
"仍会盯盘:**目标平仓 + 到期平仓**。",
],
)
)
def notify_open(*, group_id: str, detail: str = "", extra: dict[str, Any] | None = None) -> None:
extra = dict(extra or {})
perp = extra.get("perp") if isinstance(extra.get("perp"), dict) else {}
option = extra.get("option") if isinstance(extra.get("option"), dict) else {}
perp_qty = _pick_float(extra, "perp_qty_eth") or _pick_float(perp, "qty_eth")
opt_qty = _pick_float(extra, "option_qty_eth") or _pick_float(option, "qty_eth")
premium = _pick_float(extra, "initial_premium", "premium")
margin = _pick_float(extra, "perp_margin", "margin")
leverage = _pick_float(extra, "leverage")
perp_px = _pick_float(extra, "perp_entry_px") or _pick_float(perp, "fill_px")
opt_px = _pick_float(extra, "option_entry_px") or _pick_float(option, "fill_px")
# 缺保证金时用成交价×数量÷杠杆估算
if margin is None and perp_px is not None and perp_qty is not None:
try:
from ..sim.ledger import Ledger
s = get_settings()
lev = float(leverage) if leverage and leverage > 0 else float(
Ledger().get_setting_float("leverage", s.leverage) or s.leverage or 1
)
if lev > 0:
margin = abs(perp_px * perp_qty) / lev
leverage = lev
except Exception:
pass
lines = [
f"**组号**: `{group_id}`",
f"**方向**: {direction_zh(extra)}",
f"**期权合约**: `{extra.get('option_inst_id') or ''}`",
f"**行权价 / 到期**: {_fmt_num(extra.get('strike'), 0)} / {extra.get('expiry_ymd') or ''}",
f"**开仓数量**: 永续 {_fmt_num(perp_qty, 4)} ETH · 期权 {_fmt_num(opt_qty, 4)} ETH",
f"**成交均价**: 永续 {_fmt_num(perp_px, 4)} · 期权 {_fmt_num(opt_px, 4)}",
f"**权利金占用**: {_fmt_money(premium)}",
f"**保证金占用**: {_fmt_money(margin)}"
+ (f"(杠杆 {_fmt_num(leverage, 0)}x" if leverage else ""),
]
# 说明仅在非模板英文码时展示
d = str(detail or "").strip()
if d and d not in ("opened", "opened_live", "ok"):
lines.append(f"**说明**: {d}")
notify_async(build_markdown(tag=TAG_OPEN, title="开仓成功", lines=lines))
def notify_close(
*,
reason: str,
detail: str = "",
group_id: str | None = None,
data: dict[str, Any] | None = None,
) -> None:
data = dict(data or {})
reason_zh = close_reason_zh(reason)
gid = group_id or data.get("group_id") or ""
perp_pnl = _pick_float(data, "perp_pnl")
opt_pnl = _pick_float(data, "option_pnl", "opt_pnl")
net = _pick_float(data, "net", "net_pnl", "interim_net", "realized_pnl")
# 只平永续时 interim_net 可能是净利口径
if data.get("option_abandoned") and opt_pnl is None:
opt_note = "期权已归档,待到期结算(本组未计入期权最终盈亏)"
else:
opt_note = None
lines = [
f"**组号**: `{gid}`",
f"**平仓方式**: {reason_zh}",
f"**永续盈亏**: {_fmt_money(perp_pnl, signed=True)}",
f"**期权盈亏**: {_fmt_money(opt_pnl, signed=True)}",
f"**净利润**: {_fmt_money(net, signed=True)}",
]
if opt_note:
lines.append(f"**备注**: {opt_note}")
fees = _pick_float(data, "fees", "fees_total")
if fees is None:
fo = _pick_float(data, "fees_open")
fc = _pick_float(data, "fees_close")
if fo is not None or fc is not None:
fees = (fo or 0.0) + (fc or 0.0)
if fees is not None:
lines.append(f"**手续费合计**: {_fmt_money(fees)}")
d = str(detail or "").strip()
if d and d not in (
"closed",
"perp_closed_option_residual",
"ok",
"manual",
):
lines.append(f"**说明**: {d}")
notify_async(build_markdown(tag=TAG_CLOSE, title=f"平仓 · {reason_zh}", lines=lines))
def notify_fault(*, title: str, detail: str, dedupe_key: str | None = None) -> None:
global _last_fault_key, _last_fault_ms
key = dedupe_key or f"{title}:{detail[:120]}"
now = time.time()
if key == _last_fault_key and now - _last_fault_ms < _FAULT_DEDUP_SEC:
return
_last_fault_key = key
_last_fault_ms = now
notify_async(
build_markdown(
tag=TAG_FAULT,
title=f"故障 · {title}",
lines=[f"**详情**: {detail[:500]}"],
)
)
def notify_test() -> tuple[bool, str]:
venue = venue_label()
machine = wecom_machine_name()
lines = ["企业微信通知已连通。"]
if machine:
lines.append(f"机器名称: **{machine}**")
if venue:
lines.append(f"当前盘口标签: **{venue}**")
content = build_markdown(
tag=TAG_TEST,
title="测试推送",
lines=lines,
)
return _post_markdown_sync(content)
+224
View File
@@ -0,0 +1,224 @@
"""SIM 资金钱包:资金账户 / 交易账户 × USDT|USDC(对齐 OKX,无期权分账户)。"""
from __future__ import annotations
import time
from typing import Any
from ..models.db import Database, get_db
# DB 列名(历史兼容:USDC 存在 options_*_usdc 列,语义为资金/交易账户 USDC)
WALLET_KEYS = (
"funding_usdt",
"trading_usdt",
"options_funding_usdc", # = funding_usdc
"options_trading_usdc", # = trading_usdc
"options_funding_usdt", # 废弃,恒为 0
"options_trading_usdt", # 废弃,恒为 0
)
_ACCT_MAP = {
("funding", "usdt"): "funding_usdt",
("trading", "usdt"): "trading_usdt",
("funding", "usdc"): "options_funding_usdc",
("trading", "usdc"): "options_trading_usdc",
}
def _now_ms() -> int:
return int(time.time() * 1000)
class SimFundsWallets:
def __init__(self, db: Database | None = None) -> None:
self.db = db or get_db()
def snapshot(self) -> dict[str, float]:
row = self.db.fetchone("SELECT * FROM funds_wallets WHERE id=1")
if row is None:
return {k: 0.0 for k in WALLET_KEYS}
return {k: float(row[k] or 0) for k in WALLET_KEYS}
def view(self) -> dict[str, float]:
"""对外口径:funding/trading × usdt/usdc。"""
s = self.snapshot()
return {
"funding_usdt": float(s["funding_usdt"]),
"trading_usdt": float(s["trading_usdt"]),
"funding_usdc": float(s["options_funding_usdc"]),
"trading_usdc": float(s["options_trading_usdc"]),
}
def total_usdt_equiv(self, snap: dict[str, float] | None = None) -> float:
"""USDC 按 1:1 计入总资金。"""
if snap is None:
v = self.view()
elif "funding_usdc" in snap:
v = snap
else:
v = {
"funding_usdt": float(snap.get("funding_usdt") or 0),
"trading_usdt": float(snap.get("trading_usdt") or 0),
"funding_usdc": float(snap.get("options_funding_usdc") or 0),
"trading_usdc": float(snap.get("options_trading_usdc") or 0),
}
return round(
float(v.get("funding_usdt") or 0)
+ float(v.get("trading_usdt") or 0)
+ float(v.get("funding_usdc") or 0)
+ float(v.get("trading_usdc") or 0),
8,
)
def reset_from_equity(self, equity: float) -> dict[str, float]:
"""重置:全部放入资金账户 USDT。"""
amt = max(0.0, float(equity))
now = _now_ms()
self.db.execute(
"""UPDATE funds_wallets SET
funding_usdt=?, trading_usdt=0, options_funding_usdc=0, options_trading_usdc=0,
options_funding_usdt=0, options_trading_usdt=0, updated_at_ms=?
WHERE id=1""",
(amt, now),
)
return self.snapshot()
def _set(self, **kwargs: float) -> dict[str, float]:
snap = self.snapshot()
for k, v in kwargs.items():
if k in WALLET_KEYS:
snap[k] = float(v)
now = _now_ms()
self.db.execute(
"""UPDATE funds_wallets SET
funding_usdt=?, trading_usdt=?, options_funding_usdc=?, options_trading_usdc=?,
options_funding_usdt=0, options_trading_usdt=0, updated_at_ms=?
WHERE id=1""",
(
snap["funding_usdt"],
snap["trading_usdt"],
snap["options_funding_usdc"],
snap["options_trading_usdc"],
now,
),
)
return snap
def mirror_cash(self, amount: float, *, kind: str) -> None:
"""策略账本变动镜像到交易账户(永续 USDT / 期权 USDC)。"""
amt = float(amount)
if abs(amt) < 1e-12:
return
snap = self.snapshot()
k = (kind or "").lower()
if "option" in k:
key = "options_trading_usdc"
else:
key = "trading_usdt"
snap[key] = float(snap.get(key) or 0) + amt
self._set(**snap)
def sync_ledger_equity(self) -> float:
total = self.total_usdt_equiv()
now = _now_ms()
self.db.execute(
"UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1",
(total, total, now),
)
return total
def convert(
self,
*,
direction: str,
amount: float,
rate: float = 1.0,
account: str = "funding",
) -> dict[str, Any]:
"""USDT↔USDC 兑换。默认资金账户;account=trading 时在交易账户内兑(对齐 OKX 现货)。"""
amt = float(amount)
if amt <= 0:
return {"ok": False, "detail": "数量须大于 0"}
r = float(rate) if rate and rate > 0 else 1.0
d = (direction or "").strip().lower()
acct = (account or "funding").strip().lower()
if acct not in ("funding", "trading"):
return {"ok": False, "detail": "account 须为 funding / trading"}
snap = self.snapshot()
if acct == "funding":
usdt_key, usdc_key = "funding_usdt", "options_funding_usdc"
label = "资金账户"
else:
usdt_key, usdc_key = "trading_usdt", "options_trading_usdc"
label = "交易账户"
if d == "usdt_to_usdc":
src = float(snap[usdt_key])
if amt > src + 1e-9:
return {"ok": False, "detail": f"{label} USDT 不足(可用 {src:.4f}"}
usdc = amt / r
snap[usdt_key] = src - amt
snap[usdc_key] = float(snap[usdc_key]) + usdc
elif d == "usdc_to_usdt":
src = float(snap[usdc_key])
if amt > src + 1e-9:
return {"ok": False, "detail": f"{label} USDC 不足(可用 {src:.4f}"}
usdt = amt * r
snap[usdc_key] = src - amt
snap[usdt_key] = float(snap[usdt_key]) + usdt
else:
return {"ok": False, "detail": "direction 须为 usdt_to_usdc 或 usdc_to_usdt"}
self._set(**snap)
total = self.sync_ledger_equity()
return {
"ok": True,
"detail": "converted",
"direction": d,
"amount": amt,
"rate": r,
"account": acct,
"wallets": self.view(),
"total_usdt_equiv": total,
}
def transfer(
self,
*,
ccy: str,
amount: float,
from_account: str,
to_account: str,
) -> dict[str, Any]:
"""仅资金账户 ↔ 交易账户。"""
amt = float(amount)
if amt <= 0:
return {"ok": False, "detail": "划转金额须大于 0"}
ccy_l = (ccy or "USDC").strip().lower()
fa = (from_account or "").strip().lower()
ta = (to_account or "").strip().lower()
allowed = {"funding", "trading"}
if fa not in allowed or ta not in allowed:
return {"ok": False, "detail": "账户仅支持 funding / trading"}
if fa == ta:
return {"ok": False, "detail": "来源与目标账户不能相同"}
src_key = _ACCT_MAP.get((fa, ccy_l))
dst_key = _ACCT_MAP.get((ta, ccy_l))
if not src_key or not dst_key:
return {"ok": False, "detail": "币种须为 USDT 或 USDC"}
snap = self.snapshot()
src_bal = float(snap[src_key])
if amt > src_bal + 1e-9:
return {"ok": False, "detail": f"余额不足(可用 {src_bal:.4f}"}
snap[src_key] = src_bal - amt
snap[dst_key] = float(snap[dst_key]) + amt
self._set(**snap)
total = self.sync_ledger_equity()
return {
"ok": True,
"detail": "transferred",
"ccy": ccy_l.upper(),
"amount": amt,
"from": fa,
"to": ta,
"wallets": self.view(),
"total_usdt_equiv": total,
}
+59 -5
View File
@@ -27,15 +27,21 @@ class Ledger:
kind: str,
group_id: str | None = None,
note: str = "",
allow_negative: bool = False,
commit: bool = True,
) -> float:
"""amount>0 入账;amount<0 出账。返回余额。"""
"""amount>0 入账;amount<0 出账。返回余额。
LIVE 实盘成交后本地账本仅作镜像,须 allow_negative=True,避免「交易所已成交、本地拒记」导致卡仓。
commit=False:由调用方持锁并统一提交(与持仓/残留状态同事务)。
"""
now = int(time.time() * 1000)
with self.db._lock:
row = self.db._conn.execute("SELECT * FROM ledger_meta WHERE id=1").fetchone()
assert row is not None
equity = float(row["equity"]) + float(amount)
available = float(row["available"]) + float(amount)
if available < -1e-9:
if not allow_negative and available < -1e-9:
raise RuntimeError("可用资金不足")
self.db._conn.execute(
"UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1",
@@ -45,15 +51,55 @@ class Ledger:
"INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) VALUES (?,?,?,?,?,?)",
(group_id, kind, float(amount), equity, note, now),
)
if commit:
self.db._conn.commit()
if commit:
try:
from ..config import get_settings
from .funds_wallets import SimFundsWallets
if get_settings().is_sim:
SimFundsWallets(self.db).mirror_cash(float(amount), kind=kind)
except Exception:
pass
return equity
def clear_trade_history(self) -> None:
"""清空交易记录与持仓痕迹(组/成交/残留/账本流水),仓位置 flat。"""
now = int(time.time() * 1000)
with self.db._lock:
self.db._conn.execute("DELETE FROM fills")
self.db._conn.execute("DELETE FROM residual_options")
self.db._conn.execute("DELETE FROM groups")
self.db._conn.execute("DELETE FROM ledger_entries")
self.db._conn.execute(
"""UPDATE positions SET
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
option_inst_id=NULL, option_side=NULL, option_qty_eth=0,
option_qty_contracts=0, option_entry_px=NULL, entry_index_px=NULL,
initial_premium=0, exit_target_usdt=NULL, status='flat'
WHERE id=1"""
)
self.db._conn.execute(
"""UPDATE strategy_state SET
rounds_done=0, window_key=NULL, rest_until_ms=NULL,
last_error=NULL, phase=CASE WHEN running=1 THEN phase ELSE 'idle' END,
updated_at_ms=?
WHERE id=1""",
(now,),
)
self.db._conn.execute(
"DELETE FROM settings WHERE key=?", ("risk_last_k",)
)
self.db._conn.commit()
return equity
def reset_equity(self, amount: float, *, note: str = "重置模拟资金") -> float:
"""将权益与可用资金重置为 amount(reserved 清零)。须在无持仓时调用。"""
"""将权益与可用资金重置为 amount(reserved 清零),并清空交易记录。须在无持仓时调用。"""
now = int(time.time() * 1000)
amt = float(amount)
if amt < 0:
raise ValueError("模拟资金不能为负")
self.clear_trade_history()
with self.db._lock:
self.db._conn.execute(
"UPDATE ledger_meta SET equity=?, available=?, reserved=0, updated_at_ms=? WHERE id=1",
@@ -64,7 +110,15 @@ class Ledger:
(None, "reset", amt, amt, note, now),
)
self.db._conn.commit()
return amt
try:
from ..config import get_settings
from .funds_wallets import SimFundsWallets
if get_settings().is_sim:
SimFundsWallets(self.db).reset_from_equity(amt)
except Exception:
pass
return amt
def get_setting_float(self, key: str, default: float) -> float:
v = self.db.get_setting(key)
+1847 -58
View File
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
"""从成交明细汇总腿盈亏与净盈亏。"""
from __future__ import annotations
from typing import Any
def _as_map(x: Any) -> dict[str, Any]:
if isinstance(x, dict):
return x
try:
return dict(x)
except Exception:
return {}
def _leg_option_pnl(rows: list[dict[str, Any]], leg: str) -> float | None:
opt_open = next(
(f for f in rows if f.get("leg") == leg and f.get("action") == "open"),
None,
)
opt_close = next(
(f for f in rows if f.get("leg") == leg and f.get("action") == "close"),
None,
)
if not opt_open or not opt_close:
return None
qty = float(opt_open.get("qty_eth") or opt_close.get("qty_eth") or 0)
return (float(opt_close["fill_px"]) - float(opt_open["fill_px"])) * qty
def summarize_fills_pnl(fills: list[Any]) -> dict[str, float | None]:
"""
价差盈亏按 fill_px手续费另扣
净盈亏 = 各腿盈亏之和 全部手续费+
支持永期option+perp与期期option+option2
手续费拆fees_perp / fees_option option2/ fees_option2
滑点合计 slip_totalSIM 记账LIVE 应为 0
"""
rows = [_as_map(x) for x in fills]
option_pnl = _leg_option_pnl(rows, "option")
option2_pnl = _leg_option_pnl(rows, "option2")
perp_open = next(
(f for f in rows if f.get("leg") == "perp" and f.get("action") == "open"),
None,
)
perp_close = next(
(f for f in rows if f.get("leg") == "perp" and f.get("action") == "close"),
None,
)
perp_pnl: float | None = None
if perp_open and perp_close:
qty = float(perp_open.get("qty_eth") or perp_close.get("qty_eth") or 0)
side = str(perp_open.get("side") or "")
o = float(perp_open["fill_px"])
c = float(perp_close["fill_px"])
if side == "long":
perp_pnl = (c - o) * qty
else:
perp_pnl = (o - c) * qty
fees_perp = sum(
float(f.get("fee") or 0) for f in rows if str(f.get("leg") or "") == "perp"
)
fees_option = sum(
float(f.get("fee") or 0)
for f in rows
if str(f.get("leg") or "") in ("option", "option2")
)
fees_option2 = sum(
float(f.get("fee") or 0) for f in rows if str(f.get("leg") or "") == "option2"
)
fees_total = fees_perp + fees_option
slip_total = sum(float(f.get("slip") or 0) for f in rows)
parts: list[float] = []
if option_pnl is not None:
parts.append(option_pnl)
if option2_pnl is not None:
parts.append(option2_pnl)
if perp_pnl is not None:
parts.append(perp_pnl)
gross = sum(parts) if parts else None
net = (gross - fees_total) if gross is not None else None
return {
"option_pnl": option_pnl,
"option2_pnl": option2_pnl,
"perp_pnl": perp_pnl,
"fees_perp": fees_perp,
"fees_option": fees_option,
"fees_option2": fees_option2,
"fees_total": fees_total,
"slip_total": slip_total,
"gross_pnl": gross,
"net_pnl": net,
}
+69
View File
@@ -17,6 +17,75 @@ class PriceResult:
return asdict(self)
def option_intrinsic(*, option_side: str, strike: float, spot: float) -> float:
"""多头期权内在价值(USDT/ETH)。call=max(SK,0)put=max(KS,0)。"""
s = float(spot)
k = float(strike)
side = str(option_side).lower().strip()
if side in ("call", "c"):
return max(s - k, 0.0)
if side in ("put", "p"):
return max(k - s, 0.0)
return 0.0
def is_deep_otm(
*,
option_side: str,
strike: float,
spot: float,
max_intrinsic: float = 0.01,
) -> bool:
"""
远虚内在价值0多头期权已无行权价值
100×杠杆 ATM 在标的波动约1%后常落入此状态
"""
return option_intrinsic(
option_side=option_side, strike=strike, spot=spot
) <= float(max_intrinsic)
def option_expiry_settle(
*,
intrinsic: float,
qty_eth: float,
fee_rate: float,
) -> PriceResult:
"""到期结算:按内在价值入账(对齐实盘),无买卖价差滑点,仅扣手续费。"""
base = max(float(intrinsic), 0.0)
fill = base
f = float(fee_rate)
notional = abs(fill * float(qty_eth))
fee = notional * f
return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=0.0, notional=notional)
def resolve_option_close_bid(
*,
bid: float | None,
mark: float | None,
intrinsic: float | None,
bypass_liquidity: bool,
) -> float | None:
"""
非到期平仓价SIM
- 常规买一且多头卖出不低于内在价值
- 紧急 bypass对齐 OKX 市价卖**只按买一**不用标记/内在价值抬价
标记无法在交易所成交bypass 只绕过深度/偏差闸门
到期请用 option_expiry_settle不要走本函数
"""
_ = mark # 保留参数兼容调用方;紧急不再用标记定价
if bypass_liquidity:
if bid is None or bid < 0:
return None
return float(bid)
if bid is None:
return None
if intrinsic is not None and intrinsic >= 0:
return max(float(bid), float(intrinsic))
return float(bid)
def perp_fill(
*,
side: str,
+69
View File
@@ -0,0 +1,69 @@
"""振幅过滤门:回看窗内 range% 须 ≤ 上限(可关)。永期开仓第一关 / 期期共用。"""
from __future__ import annotations
from typing import Any
from ..exchange.candles import AmplitudeHL
def evaluate_amplitude_gate(
*,
filter_enabled: bool,
amp: AmplitudeHL | None,
max_pct: float,
hours: float,
) -> dict[str, Any]:
"""
返回:
blocked: 过滤开启且未过关无K线或超限
ok: 展示用是否过关过滤关视为 True
reason: 拒开文案未拒则为 None
snapshot: 写入 session._oo_amp 的字典amp None 时仅含元数据
"""
max_p = float(max_pct)
hrs = float(hours)
filt = bool(filter_enabled)
if amp is None:
snap = {
"high": None,
"low": None,
"mid": None,
"range_pct": None,
"hours": hrs,
"max_pct": max_p,
"filter_enabled": filt,
"ok": False if filt else True,
}
if filt:
return {
"blocked": True,
"ok": False,
"reason": f"振幅未过关:无法获取近 {hrs:g}h K 线高低",
"snapshot": snap,
}
return {"blocked": False, "ok": True, "reason": None, "snapshot": snap}
range_pct = float(amp.range_pct)
over = range_pct > max_p + 1e-12
ok = (not filt) or (not over)
snap = {
"high": float(amp.high),
"low": float(amp.low),
"mid": float(amp.mid),
"range_pct": range_pct,
"hours": hrs,
"max_pct": max_p,
"filter_enabled": filt,
"ok": ok,
}
if filt and over:
return {
"blocked": True,
"ok": False,
"reason": (
f"振幅未过关:{hrs:g}h 内 {range_pct:.2f}% > {max_p:g}%"
),
"snapshot": snap,
}
return {"blocked": False, "ok": ok, "reason": None, "snapshot": snap}
+248
View File
@@ -0,0 +1,248 @@
"""OKX:交易账户 USDC 不足时市价 USDT→USDC(目标=期权所需×2)。
OKX SIM/LIVE资金账户不参与期权已可开则跳过
等待选约阶段只用预览名义检测不落库落库定仓在 open_pipeline.size_and_gate
"""
from __future__ import annotations
import logging
import math
import time
from typing import Any
from ..config import get_settings
from ..models.db import Database, get_db
from .open_capacity import assess_open_capacity, invalidate_live_balance_cache
logger = logging.getLogger(__name__)
_TARGET_MULTIPLE = 2.0
_MIN_CONVERT_USDT = 1.0
_RETRY_COOLDOWN_SEC = 45.0
_last_attempt_ts: float = 0.0
def _is_okx(exchange: str | None = None) -> bool:
from ..exchange.runtime import load_runtime_settings, normalize_exchange_name
ex = exchange
if not ex:
try:
ex = load_runtime_settings().exchange
except Exception:
ex = get_settings().exchange
return normalize_exchange_name(ex) == "okx"
def _round_down(n: float, nd: int = 2) -> float:
if n <= 0:
return 0.0
f = 10**nd
return math.floor(n * f + 1e-12) / f
def preview_capacity_for_convert(db: Database | None = None) -> dict[str, Any]:
"""
用预览以损定仓名义评估资金门不写 settings
- 选约杠杆用定仓卖一指数/杠杆下限与预算/k 一致勿用监控未达标贵卖一
- 实际杠杆用盘口卖一
手动仓位则直接 assess 当前账本名义
"""
database = db or get_db()
try:
from .risk_sizing import preview_risk_sizing
prev = preview_risk_sizing(database)
if prev.get("risk_based") and prev.get("ok"):
# option_ask 在 preview 里已是定仓口径(selection=隐含 / actual=盘口)
cap = assess_open_capacity(
database,
option_ask=float(prev["option_ask"])
if prev.get("option_ask") is not None
else None,
option_qty_eth=float(prev["option_qty_eth"])
if prev.get("option_qty_eth") is not None
else None,
perp_qty_eth=float(prev["perp_qty_eth"])
if prev.get("perp_qty_eth") is not None
else None,
call_ask=float(prev["call_ask"])
if prev.get("call_ask") is not None
else None,
put_ask=float(prev["put_ask"])
if prev.get("put_ask") is not None
else None,
)
cap["capacity_basis"] = "risk_preview"
cap["leverage_basis"] = prev.get("leverage_basis")
return cap
except Exception:
logger.exception("preview capacity for convert failed")
cap = assess_open_capacity(database)
cap["capacity_basis"] = "ledger"
return cap
def prepare_okx_trading_usdc(db: Database | None = None) -> dict[str, Any]:
"""等待阶段:预览名义评估 + 兑换,不落库。"""
database = db or get_db()
cap = preview_capacity_for_convert(database)
conv = ensure_okx_trading_usdc(database, cap=cap, force=False)
return {"capacity": cap, "convert": conv}
def ensure_okx_trading_usdc(
db: Database | None = None,
*,
cap: dict[str, Any] | None = None,
force: bool = False,
) -> dict[str, Any]:
"""
- OKX 跳过
- 期权可开USDC 跳过
- 否则交易账户市价 USDTUSDC尽量补到 ×2预留永续保证金
- force 不再绕过冷却防砸单保留参数仅为兼容调用方
"""
global _last_attempt_ts
_ = force # 明确忽略:冷却始终生效
db = db or get_db()
out: dict[str, Any] = {
"ok": True,
"acted": False,
"skipped": True,
"detail": "skip",
}
if not _is_okx():
out["detail"] = "非 OKX,跳过自动兑 USDC"
return out
cap = cap or assess_open_capacity(db)
need = cap.get("option_need_usdc")
have = cap.get("option_have_usdc")
if need is None or have is None:
out["detail"] = "期权所需/持有未知,跳过兑换"
out["capacity"] = cap
return out
need_f = float(need)
have_f = float(have)
if need_f <= 0:
out["detail"] = "期权所需为 0,跳过"
return out
if have_f + 1e-9 >= need_f:
out["detail"] = (
f"交易账户 USDC 已够开仓(有 {have_f:.2f} ≥ 需 {need_f:.2f}),不兑换"
)
out["capacity"] = cap
return out
now = time.time()
if _last_attempt_ts > 0 and now - _last_attempt_ts < _RETRY_COOLDOWN_SEC:
left = _RETRY_COOLDOWN_SEC - (now - _last_attempt_ts)
out["detail"] = f"USDC 不足,自动兑换冷却中({left:.0f}s"
out["capacity"] = cap
out["need_usdc"] = round(need_f, 2)
out["have_usdc"] = round(have_f, 2)
return out
target = need_f * _TARGET_MULTIPLE
gap_usdc = target - have_f
if gap_usdc <= 1e-6:
out["detail"] = "无需补足"
return out
from ..live.okx_funds import usdc_usdt_mid_rate
rate = float(usdc_usdt_mid_rate() or 1.0)
if rate <= 0:
rate = 1.0
want_usdt = gap_usdc * rate
perp_need = float(cap.get("perp_need_usdt") or 0)
trading_usdt = float(cap.get("perp_have_usdt") or 0)
spendable = max(0.0, trading_usdt - max(0.0, perp_need))
spend_usdt = _round_down(min(want_usdt, spendable), 2)
out.update(
{
"need_usdc": round(need_f, 2),
"have_usdc": round(have_f, 2),
"target_usdc": round(target, 2),
"want_usdt": round(want_usdt, 2),
"spend_usdt": spend_usdt,
"rate": rate,
"spendable_usdt": round(spendable, 2),
}
)
if spend_usdt < _MIN_CONVERT_USDT:
out["ok"] = False
out["skipped"] = True
out["detail"] = (
f"交易账户可兑 USDT 不足(可兑 {spendable:.2f}"
f"目标补约 {want_usdt:.2f},门槛 {_MIN_CONVERT_USDT}"
)
out["capacity"] = cap
return out
_last_attempt_ts = now
s = get_settings()
try:
if s.is_sim:
from ..sim.funds_wallets import SimFundsWallets
r = SimFundsWallets(db).convert(
direction="usdt_to_usdc",
amount=spend_usdt,
rate=rate,
account="trading",
)
else:
from ..live.okx_funds import OkxFundsClient
client = OkxFundsClient()
try:
r = client.spot_swap_usdt_usdc(
direction="usdt_to_usdc",
amount=spend_usdt,
)
finally:
client.close()
invalidate_live_balance_cache()
except Exception as e:
logger.exception("auto USDC convert failed")
out["ok"] = False
out["skipped"] = False
out["acted"] = False
out["detail"] = f"自动兑换异常:{e}"
return out
if not r.get("ok"):
out["ok"] = False
out["skipped"] = False
out["acted"] = False
out["detail"] = f"自动兑换失败:{r.get('detail') or r}"
out["raw"] = r
logger.warning("auto_usdc failed: %s", out["detail"])
return out
invalidate_live_balance_cache()
cap2 = assess_open_capacity(db)
out.update(
{
"ok": True,
"acted": True,
"skipped": False,
"detail": (
f"交易账户市价兑 USDC:花 {spend_usdt:.2f} USDT"
f"(目标持仓≈{target:.2f}=需{need_f:.2f}×{_TARGET_MULTIPLE:g}"
),
"capacity_before": cap,
"capacity_after": cap2,
"raw": r,
}
)
logger.info("auto_usdc: %s", out["detail"])
return out
+50
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
_SH = ZoneInfo("Asia/Shanghai")
@@ -38,3 +39,52 @@ def can_open_new(
def group_date_ymd(now: datetime | None = None) -> str:
return window_key(now)
def used_expiry_ymds(db: Any, now: datetime | None = None) -> set[str]:
"""
历史上已开过的期权到期日groups.expiry_ymdYYMMDD
跨日历日任一已开组含已平仓占用该到期后不可再开同到期只能盯下一档
"""
_ = now
rows = db.fetchall(
"""SELECT DISTINCT expiry_ymd FROM groups
WHERE expiry_ymd IS NOT NULL AND TRIM(expiry_ymd) != ''"""
)
out: set[str] = set()
for r in rows or []:
y = str(r["expiry_ymd"] or "").strip()
if y:
out.add(y)
return out
# 兼容旧名(语义已改为跨日/历史到期占用)
used_expiry_ymds_for_day = used_expiry_ymds
def pending_residual_expiry_ymds(db: Any) -> set[str]:
"""待到期结算的残留期权到期日(YYMMDD);监控/下一组开仓应跳过这些档。"""
rows = db.fetchall(
"""SELECT DISTINCT expiry_ymd FROM residual_options
WHERE status='pending' AND expiry_ymd IS NOT NULL AND TRIM(expiry_ymd) != ''"""
)
out: set[str] = set()
for r in rows or []:
y = str(r["expiry_ymd"] or "").strip()
if y:
out.add(y)
return out
def expiry_blocked_by_one_per_day(
expiry_ymd: str | None,
used: set[str],
*,
enabled: bool = True,
) -> bool:
"""开启时:候选到期已在历史上用过则拦截。"""
if not enabled:
return False
y = str(expiry_ymd or "").strip()
return bool(y and y in used)
File diff suppressed because it is too large Load Diff
+65 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any
EXIT_MODE_FIXED = "fixed_usdt"
EXIT_MODE_PREMIUM = "premium_multiple"
@@ -29,6 +30,62 @@ def resolve_exit_target(
return float(net_profit_target), EXIT_MODE_FIXED
def lock_trade_exit_target(
db: Any,
*,
group_id: str,
initial_premium: float,
) -> float:
"""
开仓成交后锁定本组成交出场目标到 groups/positions
持仓期间盯盘与展示均用该值不再跟随时价重算以损定仓/出场
"""
from ..config import get_settings
from ..sim.ledger import Ledger
s = get_settings()
ledger = Ledger(db)
exit_mode = ledger.get_setting_str("exit_mode", s.exit_mode) or EXIT_MODE_FIXED
net_target = float(
ledger.get_setting_float("net_profit_target", s.net_profit_target)
or s.net_profit_target
)
prem_mult = float(
ledger.get_setting_float("premium_exit_multiple", s.premium_exit_multiple)
or s.premium_exit_multiple
)
target, _mode = resolve_exit_target(
exit_mode=str(exit_mode),
net_profit_target=net_target,
premium_exit_multiple=prem_mult,
initial_premium=float(initial_premium or 0),
)
target = float(target)
db.execute(
"UPDATE groups SET exit_target_usdt=? WHERE group_id=?",
(target, group_id),
)
db.execute(
"UPDATE positions SET exit_target_usdt=? WHERE id=1",
(target,),
)
return target
def read_locked_exit_target(pos: dict[str, Any] | None) -> float | None:
"""持仓行上的锁定目标;无则 None(旧仓回退设置值)。"""
if not pos:
return None
v = pos.get("exit_target_usdt")
if v is None or v == "":
return None
try:
f = float(v)
except (TypeError, ValueError):
return None
return f if f > 0 else None
def check_expiry_close(
*,
expiry_ms: int | None,
@@ -50,14 +107,20 @@ def check_exits(
net_profit_target: float,
premium_exit_multiple: float,
initial_premium: float,
locked_exit_target: float | None = None,
) -> ExitDecision:
"""净盈利(预估全平后)≥ 所选模式目标则全平。"""
target, mode = resolve_exit_target(
"""净盈利(预估全平后)≥ 所选模式目标则全平。持仓锁定目标优先。"""
# 模式始终按设置解析(权利金倍数 vs 固定),勿因锁定目标就改成 fixed_usdt
_resolved_target, mode = resolve_exit_target(
exit_mode=exit_mode,
net_profit_target=net_profit_target,
premium_exit_multiple=premium_exit_multiple,
initial_premium=initial_premium,
)
if locked_exit_target is not None and float(locked_exit_target) > 0:
target = float(locked_exit_target)
else:
target = float(_resolved_target)
if target > 0 and net_pnl + 1e-9 >= target:
reason = "premium_multiple" if mode == EXIT_MODE_PREMIUM else "fixed_usdt"
return ExitDecision(True, reason, target)
+203
View File
@@ -0,0 +1,203 @@
"""期期对冲选约:振幅高低点匹配虚值 Call + Put。"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from ..exchange.candles import AmplitudeHL, fetch_amplitude_hl_for_runtime
from .selection import (
_complete_by_expiry,
hours_until_ms,
list_eligible_expiry_ymds,
option_leverage,
)
@dataclass(frozen=True, slots=True)
class OoLeg:
side: str # call|put
strike: float
inst_id: str
ask: float
leverage: float
@dataclass(frozen=True, slots=True)
class OoPickCore:
expiry_ymd: str
expiry_ms: int
hours_left: float
underlying_px: float
amplitude: AmplitudeHL
call: OoLeg
put: OoLeg
detail: str = "ok"
def _within_ref_pct(strike: float, ref: float, max_dev_pct: float) -> bool:
"""|Kref|/ref ≤ max_dev_pct%"""
if ref <= 0 or max_dev_pct < 0:
return False
return abs(float(strike) - float(ref)) / float(ref) * 100.0 <= float(
max_dev_pct
) + 1e-12
def pick_otm_call_strike(
strikes: list[float],
*,
spot: float,
high: float,
max_dev_pct: float = 1.0,
) -> float | None:
"""虚值 CallK > spot,贴近振幅高点,且 |K−高|/高 ≤ max_dev_pct%"""
cands = [float(s) for s in strikes if float(s) > float(spot) + 1e-9]
if max_dev_pct >= 0 and high > 0:
cands = [s for s in cands if _within_ref_pct(s, high, max_dev_pct)]
if not cands:
return None
return min(cands, key=lambda s: (abs(s - float(high)), s))
def pick_otm_put_strike(
strikes: list[float],
*,
spot: float,
low: float,
max_dev_pct: float = 1.0,
) -> float | None:
"""虚值 PutK < spot,贴近振幅低点,且 |K−低|/低 ≤ max_dev_pct%"""
cands = [float(s) for s in strikes if float(s) < float(spot) - 1e-9]
if max_dev_pct >= 0 and low > 0:
cands = [s for s in cands if _within_ref_pct(s, low, max_dev_pct)]
if not cands:
return None
return min(cands, key=lambda s: (abs(s - float(low)), s))
def select_oo_pair(
contracts: list[dict[str, Any]],
*,
spot: float,
high: float,
low: float,
min_hours: float,
now: datetime | None = None,
skip_expiry_ymds: set[str] | None = None,
max_dev_pct: float = 1.0,
) -> tuple[str, int, float, float, str, str] | None:
"""
返回 (expiry_ymd, expiry_ms, call_strike, put_strike, call_inst, put_inst)
Call/Put 可不同行权价须同到期均为虚值且相对高低点偏离不超过 max_dev_pct%
"""
if spot <= 0 or high <= 0 or low <= 0 or high < low:
return None
complete = _complete_by_expiry(contracts)
if not complete:
return None
skip = skip_expiry_ymds or set()
eligible = [
y
for y in list_eligible_expiry_ymds(contracts, min_hours=min_hours, now=now)
if y not in skip
]
for ymd in eligible:
ems, strikes_map = complete[ymd]
strikes = list(strikes_map.keys())
ck = pick_otm_call_strike(
strikes, spot=spot, high=high, max_dev_pct=max_dev_pct
)
pk = pick_otm_put_strike(
strikes, spot=spot, low=low, max_dev_pct=max_dev_pct
)
if ck is None or pk is None:
continue
call_inst = strikes_map[ck].get("C")
put_inst = strikes_map[pk].get("P")
if not call_inst or not put_inst:
continue
hours_left = hours_until_ms(ems, now)
return (
ymd,
int(ems),
float(ck),
float(pk),
str(call_inst),
str(put_inst),
)
return None
def build_oo_pick_core(
*,
contracts: list[dict[str, Any]],
spot: float,
call_ask: float,
put_ask: float,
min_hours: float,
min_leverage: float,
amplitude_hours: float,
amplitude_pct: float,
amplitude: AmplitudeHL | None = None,
skip_expiry_ymds: set[str] | None = None,
now: datetime | None = None,
max_dev_pct: float = 1.0,
amplitude_filter_enabled: bool = True,
) -> OoPickCore | None:
"""完整期期选约:振幅门(可关)+ 虚值双腿(贴高低≤max_dev% + 杠杆。"""
amp = amplitude or fetch_amplitude_hl_for_runtime(amplitude_hours)
if amp is None:
return None
if amplitude_filter_enabled and float(amp.range_pct) > float(amplitude_pct) + 1e-12:
return None
if spot <= 0:
spot = float(amp.mid)
picked = select_oo_pair(
contracts,
spot=float(spot),
high=float(amp.high),
low=float(amp.low),
min_hours=float(min_hours),
now=now,
skip_expiry_ymds=skip_expiry_ymds,
max_dev_pct=float(max_dev_pct),
)
if picked is None:
return None
ymd, ems, ck, pk, call_inst, put_inst = picked
if call_ask <= 0 or put_ask <= 0:
return None
c_lev = option_leverage(float(spot), float(call_ask))
p_lev = option_leverage(float(spot), float(put_ask))
if c_lev is None or p_lev is None:
return None
if c_lev + 1e-12 < float(min_leverage) or p_lev + 1e-12 < float(min_leverage):
return None
hours_left = hours_until_ms(ems, now)
return OoPickCore(
expiry_ymd=ymd,
expiry_ms=int(ems),
hours_left=float(hours_left),
underlying_px=float(spot),
amplitude=amp,
call=OoLeg(
side="call",
strike=float(ck),
inst_id=call_inst,
ask=float(call_ask),
leverage=float(c_lev),
),
put=OoLeg(
side="put",
strike=float(pk),
inst_id=put_inst,
ask=float(put_ask),
leverage=float(p_lev),
),
detail=(
f"amp={amp.range_pct:.2f}% H={amp.high:.2f} L={amp.low:.2f} "
f"C@{ck:g} P@{pk:g}"
),
)
+322
View File
@@ -0,0 +1,322 @@
"""开仓资金可开判定:只看交易账户(永续 USDT / 期权 USDC)。"""
from __future__ import annotations
import logging
import time
from typing import Any
from ..config import get_settings
from ..models.db import Database, get_db
from ..sim.funds_wallets import SimFundsWallets
from ..sim.ledger import Ledger
logger = logging.getLogger(__name__)
_live_bal_cache: dict[str, Any] = {"ts": 0.0, "data": None}
_LIVE_BAL_TTL_SEC = 8.0
# 不足期间只推一次;资金恢复后清零,下次再不足可再推一次
_notified_while_short: bool = False
def invalidate_live_balance_cache() -> None:
"""兑换/划转后强制下次重拉交易账户余额。"""
_live_bal_cache["ts"] = 0.0
_live_bal_cache["data"] = None
def _f(v: Any) -> float | None:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def _index_and_option_ask() -> tuple[float | None, float | None]:
"""指数价 + 期权卖一粗估(取 Call/Put 卖一较大者,偏保守)。"""
try:
from .session import get_session
snap = get_session().snapshot()
except Exception:
return None, None
idx = _f(getattr(snap, "index_px", None))
if idx is None and snap.perp:
idx = _f(snap.perp.mark_px) or _f(snap.perp.ask) or _f(snap.perp.bid)
asks: list[float] = []
for leg in (snap.call, snap.put):
if leg is None:
continue
a = _f(leg.ask)
if a is not None and a > 0:
asks.append(a)
ask = max(asks) if asks else None
return idx, ask
def _live_balances() -> dict[str, float | None]:
now = time.time()
if _live_bal_cache["data"] is not None and now - float(_live_bal_cache["ts"]) < _LIVE_BAL_TTL_SEC:
return dict(_live_bal_cache["data"])
out: dict[str, float | None] = {
"trading_usdt": None,
"trading_usdc": None,
}
try:
from ..exchange.runtime import load_runtime_settings
ex = str(load_runtime_settings().exchange or "").strip().lower()
if ex in ("binance", "bn"):
from ..live.binance_trade import BinanceTradeClient
client = BinanceTradeClient()
try:
bal = client.fetch_balances()
out["trading_usdt"] = _f(bal.get("trading_usdt"))
out["trading_usdc"] = _f(bal.get("trading_usdc"))
finally:
client.close()
else:
from ..live.okx_funds import OkxFundsClient
client = OkxFundsClient()
try:
bal = client.fetch_balances()
out["trading_usdt"] = _f(bal.get("trading_usdt"))
out["trading_usdc"] = _f(bal.get("trading_usdc"))
finally:
client.close()
except Exception as e:
logger.warning("open_capacity live balance failed: %s", e)
_live_bal_cache["ts"] = now
_live_bal_cache["data"] = dict(out)
return out
def _sim_balances(db: Database) -> dict[str, float]:
"""只看交易账户,不看资金账户。"""
w = SimFundsWallets(db).view()
return {
"perp_usdt": float(w.get("trading_usdt") or 0),
"option_usdc": float(w.get("trading_usdc") or 0),
}
def assess_open_capacity(
db: Database | None = None,
*,
option_ask: float | None = None,
option_qty_eth: float | None = None,
perp_qty_eth: float | None = None,
call_ask: float | None = None,
put_ask: float | None = None,
) -> dict[str, Any]:
"""
返回永续/期权是否有足够交易账户资金开新仓
- 永续交易账户 USDT >= 名义/杠杆
- 期权交易账户 USDC >= 卖一×名义×(1+费率)
- 期期期权需 (call_ask+put_ask)×qty×(1+fee)永续视为不需要
"""
global _notified_while_short
db = db or get_db()
s = get_settings()
ledger = Ledger(db)
hedge = str(
ledger.get_setting_str("hedge_mode", s.hedge_mode) or s.hedge_mode
).strip().lower()
if hedge not in ("perp_option", "option_option"):
hedge = "perp_option"
lev = float(ledger.get_setting_float("leverage", s.leverage) or 3)
if lev <= 0:
lev = 3.0
perp_qty = float(
perp_qty_eth
if perp_qty_eth is not None
else (ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth) or 1)
)
opt_qty = float(
option_qty_eth
if option_qty_eth is not None
else (ledger.get_setting_float("option_qty_eth", s.option_qty_eth) or 2)
)
fee_rate = float(ledger.get_setting_float("fee_rate", s.fee_rate) or 0.0005)
idx, ask_book = _index_and_option_ask()
ask = float(option_ask) if option_ask is not None and float(option_ask) > 0 else ask_book
if hedge == "option_option":
ca = float(call_ask) if call_ask is not None and float(call_ask) > 0 else None
pa = float(put_ask) if put_ask is not None and float(put_ask) > 0 else None
if ca is None or pa is None:
# 回退:用监控对 call/put 卖一
try:
from .session import get_session
snap = get_session().snapshot()
if ca is None and snap.call and snap.call.ask:
ca = float(snap.call.ask)
if pa is None and snap.put and snap.put.ask:
pa = float(snap.put.ask)
except Exception:
pass
if ca is not None and pa is not None and ca > 0 and pa > 0:
call_q = float(opt_qty)
put_q = float(
ledger.get_setting_float("oo_put_qty_eth", call_q) or call_q
)
# 与定仓一致:两腿各自权利金
premium_need = (
(ca * call_q + pa * put_q) * (1.0 + fee_rate)
)
else:
premium_need = None
margin_need = 0.0
perp_qty = 0.0
else:
margin_need = (float(idx) * perp_qty / lev) if idx and idx > 0 else None
premium_need = (
float(ask) * opt_qty * (1.0 + fee_rate) if ask is not None and ask > 0 else None
)
if s.is_sim:
bal = _sim_balances(db)
have_perp = float(bal["perp_usdt"])
have_opt = float(bal["option_usdc"])
else:
live = _live_balances()
t_usdt = live.get("trading_usdt")
t_usdc = live.get("trading_usdc")
have_perp = float(t_usdt) if t_usdt is not None else None
have_opt = float(t_usdc) if t_usdc is not None else None
perp_ok: bool | None
if hedge == "option_option":
perp_ok = True
elif margin_need is None or have_perp is None:
perp_ok = None
else:
perp_ok = float(have_perp) + 1e-9 >= float(margin_need)
opt_ok: bool | None
if premium_need is None or have_opt is None:
opt_ok = None
else:
opt_ok = float(have_opt) + 1e-9 >= float(premium_need)
if hedge == "option_option":
funds_ok = opt_ok is True
else:
funds_ok = perp_ok is True and opt_ok is True
# 资金恢复后允许下次不足再通知一次
if funds_ok:
_notified_while_short = False
lev_i = int(round(lev)) if abs(lev - round(lev)) < 1e-9 else lev
if hedge == "option_option":
perp_label = "永续 —(期期)"
elif perp_ok is True:
perp_label = f"永续{lev_i}x 可开"
elif perp_ok is False:
perp_label = f"永续{lev_i}x 不可开"
else:
perp_label = f"永续{lev_i}x —"
if opt_ok is True:
opt_label = "期权可开"
elif opt_ok is False:
opt_label = "期权不可开"
else:
opt_label = "期权 —"
return {
"hedge_mode": hedge,
"leverage": lev,
"perp_qty_eth": perp_qty,
"option_qty_eth": opt_qty,
"index_px": idx,
"option_ask": ask,
"perp_need_usdt": round(margin_need, 2) if margin_need is not None else None,
"option_need_usdc": round(premium_need, 2) if premium_need is not None else None,
"perp_have_usdt": round(have_perp, 2) if have_perp is not None else None,
"option_have_usdc": round(have_opt, 2) if have_opt is not None else None,
"perp_can_open": perp_ok,
"option_can_open": opt_ok,
"perp_label": perp_label,
"option_label": opt_label,
"funds_ok": funds_ok,
"source": "trading",
}
def funds_gate_blocks(cap: dict[str, Any] | None) -> tuple[bool, str]:
"""
Fail-closed永期需永续+期权均为 True期期仅需期权为 True
None未知 False 拦截
"""
if not cap:
return True, "资金可开判定结果为空,拒绝开仓"
hedge = str(cap.get("hedge_mode") or "perp_option").strip().lower()
if hedge == "option_option":
if cap.get("option_can_open") is not True:
detail = (
f"{cap.get('option_label')}"
f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U"
)
if cap.get("option_can_open") is None:
detail += "(余额/盘口未知,fail-closed 拒绝开仓)"
return True, f"资金不足或状态未知,暂不可开新仓:{detail}"
return False, ""
if cap.get("perp_can_open") is not True or cap.get("option_can_open") is not True:
detail = (
f"{cap.get('perp_label')} · {cap.get('option_label')}"
f"永续需≈{cap.get('perp_need_usdt')}U/有{cap.get('perp_have_usdt')}U"
f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U"
)
if cap.get("perp_can_open") is None or cap.get("option_can_open") is None:
detail += "(余额/盘口未知,fail-closed 拒绝开仓)"
return True, f"资金不足或状态未知,暂不可开新仓:{detail}"
return False, ""
def maybe_notify_funds_short(cap: dict[str, Any] | None = None) -> None:
"""仅在「不能开」时推送一次;能开绝不通知。"""
global _notified_while_short
cap = cap or assess_open_capacity()
# 能开 / 未知:不通知;资金恢复则重置,便于下次不足再提醒一次
if cap.get("funds_ok") is True:
_notified_while_short = False
return
cannot = cap.get("perp_can_open") is False or cap.get("option_can_open") is False
if not cannot:
return
if _notified_while_short:
return
_notified_while_short = True
parts: list[str] = []
if cap.get("perp_can_open") is False:
parts.append(
f"交易账户 USDT 不足:需约 {cap.get('perp_need_usdt')}U,现有 {cap.get('perp_have_usdt')}U"
)
if cap.get("option_can_open") is False:
parts.append(
f"交易账户 USDC 不足:需约 {cap.get('option_need_usdc')}U,现有 {cap.get('option_have_usdc')}U"
)
try:
from ..notify import wecom
wecom.notify_async(
wecom.build_markdown(
tag=wecom.TAG_FAULT,
title="交易账户资金不足 · 无法开新仓",
lines=[
f"**永续**: {cap.get('perp_label')}",
f"**期权**: {cap.get('option_label')}",
*[f"**详情**: {p}" for p in parts],
"OKX 开仓前会尝试交易账户市价兑 USDC;仍不足请检查交易账户余额。",
],
)
)
except Exception:
logger.exception("wecom funds notify failed")
+164
View File
@@ -0,0 +1,164 @@
"""开仓统一管道:定仓 → 兑 USDC → 资金门(fail-closed)。
策略自动开仓与手动开一组共用避免双路径打架
基线假设运行中不在交易所人工开平仓也不人工手动平仓
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from ..models.db import Database, get_db
from .open_capacity import assess_open_capacity, funds_gate_blocks
from .risk_sizing import apply_risk_sizing_to_ledger
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class OpenPrepResult:
ok: bool
detail: str = ""
capacity: dict[str, Any] | None = None
sizing_detail: str = ""
convert_detail: str = ""
def prepare_usdc_while_waiting(db: Database | None = None) -> dict[str, Any]:
"""空仓等待选约:预览名义检测 USDC 并兑换,不落库改写 qty/exit。"""
from .auto_usdc import prepare_okx_trading_usdc
return prepare_okx_trading_usdc(db)
def size_and_gate(
*,
index_px: float,
option_ask: float,
db: Database | None = None,
call_ask: float | None = None,
put_ask: float | None = None,
hedge_mode: str | None = None,
) -> OpenPrepResult:
"""
选约成功后写入以损定仓 交易账户兑 USDC 资金门
资金门 fail-closed异常 / can_open True 一律拦截
"""
database = db or get_db()
mode = str(hedge_mode or "").strip().lower()
if not mode:
try:
from ..config import get_settings
from ..sim.ledger import Ledger
s = get_settings()
mode = str(
Ledger(database).get_setting_str("hedge_mode", s.hedge_mode)
or s.hedge_mode
).strip().lower()
except Exception:
mode = "perp_option"
try:
if mode == "option_option":
from .risk_sizing import apply_oo_sizing_to_ledger
if call_ask is None or put_ask is None:
return OpenPrepResult(ok=False, detail="期期定仓缺少 call/put 卖一")
rs = apply_oo_sizing_to_ledger(
call_ask=float(call_ask),
put_ask=float(put_ask),
index_px=float(index_px),
db=database,
)
if not rs.ok:
return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail)
else:
from ..sim.ledger import Ledger
from .risk_sizing import is_risk_based
from .semi_auto import is_semi_auto, read_semi_params
led = Ledger(database)
semi_perp = None
semi_opt = None
semi_exit = None
semi_basis = None
if is_semi_auto(led):
sp = read_semi_params(led)
semi_perp = float(sp["perp_unit"])
semi_opt = float(sp["option_unit"])
semi_exit = float(sp["perp_exit_unit"])
# 半自动以损定仓:按盘口卖一推期权量,再按配比乘 k 得永续量
semi_basis = "actual"
if not is_risk_based(led):
# 手动仓:半自动直接写入 永续:期权 配比名义
database.set_setting("perp_qty_eth", f"{semi_perp:.4f}")
database.set_setting("option_qty_eth", f"{semi_opt:.4f}")
rs = apply_risk_sizing_to_ledger(
index_px=float(index_px),
option_ask=float(option_ask),
db=database,
perp_unit=semi_perp,
option_unit=semi_opt,
exit_unit=semi_exit,
leverage_basis=semi_basis,
)
if not rs.ok:
return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail)
except Exception as e:
logger.exception("risk sizing failed in open pipeline")
return OpenPrepResult(ok=False, detail=f"以损定仓计算异常:{e}")
convert_detail = ""
try:
from .auto_usdc import ensure_okx_trading_usdc
# 选约后名义已更新;仍受冷却约束,禁止 force 砸单
conv = ensure_okx_trading_usdc(
database,
cap=assess_open_capacity(
database,
option_ask=float(option_ask),
call_ask=call_ask,
put_ask=put_ask,
),
force=False,
)
convert_detail = str(conv.get("detail") or "")
if conv.get("acted"):
logger.info("open_pipeline auto_usdc: %s", convert_detail)
except Exception:
logger.exception("auto USDC in open pipeline failed")
convert_detail = "自动兑 USDC 异常(已记日志)"
try:
cap = assess_open_capacity(
database,
option_ask=float(option_ask),
call_ask=call_ask,
put_ask=put_ask,
)
except Exception as e:
logger.exception("open capacity assess failed")
return OpenPrepResult(
ok=False,
detail=f"资金可开判定失败,拒绝开仓:{e}",
convert_detail=convert_detail,
)
blocked, why = funds_gate_blocks(cap)
if blocked:
return OpenPrepResult(
ok=False,
detail=why or "资金不足或状态未知,拒绝开仓",
capacity=cap,
convert_detail=convert_detail,
)
return OpenPrepResult(
ok=True,
detail="ready",
capacity=cap,
convert_detail=convert_detail,
)
+986
View File
@@ -0,0 +1,986 @@
"""以损定仓:按可承受最大亏损反推标准组倍数 k(永续1 / 期权2 / 出场15)。"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from typing import Any
from ..config import get_settings
from ..models.db import Database, get_db
from ..sim.ledger import Ledger
logger = logging.getLogger(__name__)
# 标准组基准默认(k=1);可由设置 risk_*_unit 覆盖
BASE_PERP_ETH = 1.0
BASE_OPTION_ETH = 2.0
BASE_EXIT_USDT = 15.0
MIN_K = 0.1
FEE_LEG_COUNT = 3 # 永续开/平 + 期权一次
def _round2(x: float | None) -> float | None:
if x is None or not math.isfinite(float(x)):
return None
return round(float(x) + 0.0, 2)
def read_risk_units(ledger: Ledger) -> tuple[float, float, float]:
"""永续名义单位 / 期权名义单位 / 出场基数(k=1)。"""
perp_u = ledger.get_setting_float("risk_perp_unit", BASE_PERP_ETH)
opt_u = ledger.get_setting_float("risk_option_unit", BASE_OPTION_ETH)
exit_u = ledger.get_setting_float("risk_exit_unit", BASE_EXIT_USDT)
if perp_u <= 0:
perp_u = BASE_PERP_ETH
if opt_u <= 0:
opt_u = BASE_OPTION_ETH
if exit_u <= 0:
exit_u = BASE_EXIT_USDT
return float(perp_u), float(opt_u), float(exit_u)
@dataclass(frozen=True, slots=True)
class RiskSizingResult:
ok: bool
detail: str
k: float | None = None
budget: float | None = None
capital_base: float | None = None
premium_est: float | None = None
fee_est: float | None = None
max_loss: float | None = None
perp_qty_eth: float | None = None
option_qty_eth: float | None = None
net_profit_target: float | None = None
index_px: float | None = None
option_ask: float | None = None # 定仓用权利金(可能为选约杠杆隐含)
actual_option_ask: float | None = None # 盘口真实卖一
leverage_basis: str | None = None # actual | selection
perp_unit: float | None = None
option_unit: float | None = None
exit_unit: float | None = None
def normalize_risk_leverage_basis(raw: str | None, default: str = "selection") -> str:
v = (raw or default or "selection").strip().lower()
if v in ("selection", "min_option_leverage", "select", "选约", "选约杠杆"):
return "selection"
if v in ("actual", "market", "ask", "实际", "实际杠杆"):
return "actual"
return "selection" if default == "selection" else "actual"
def resolve_sizing_option_ask(
*,
index_px: float,
option_ask: float,
leverage_basis: str,
min_option_leverage: float,
) -> tuple[float, str]:
"""
返回 (定仓用卖一, 口径 actual|selection)
selection隐含卖一 = 指数 / 选约杠杆actual用盘口卖一
"""
basis = normalize_risk_leverage_basis(leverage_basis, "selection")
if basis == "selection":
lev = float(min_option_leverage)
if lev > 1e-12 and math.isfinite(lev) and index_px > 0:
return float(index_px) / lev, "selection"
# 选约杠杆无效时退回实际卖一,避免拒单
return float(option_ask), "actual"
return float(option_ask), "actual"
def is_risk_based(ledger: Ledger | None = None) -> bool:
led = ledger or Ledger()
mode = (led.get_setting_str("sizing_mode", "manual") or "manual").strip().lower()
return mode == "risk_based"
def floor_k_1dp(k_raw: float) -> float:
"""一位小数向下取整,保证不超预算。"""
if k_raw <= 0 or not math.isfinite(k_raw):
return 0.0
return math.floor(k_raw * 10.0 + 1e-12) / 10.0
def unit_cost(
*,
index_px: float,
option_ask: float,
fee_rate: float,
option_unit: float = BASE_OPTION_ETH,
) -> float:
"""k=1 时估算最大亏损 = 权利金(option_unit ETH) + 手续费粗估。"""
premium_unit = float(option_ask) * float(option_unit)
fee_unit = float(index_px) * float(fee_rate) * FEE_LEG_COUNT
return premium_unit + fee_unit
def compute_k(
*,
budget: float,
index_px: float,
option_ask: float,
fee_rate: float,
perp_unit: float = BASE_PERP_ETH,
option_unit: float = BASE_OPTION_ETH,
exit_unit: float = BASE_EXIT_USDT,
) -> RiskSizingResult:
if budget is None or budget <= 0 or not math.isfinite(budget):
return RiskSizingResult(ok=False, detail="以损定仓预算无效(须 > 0")
if index_px is None or index_px <= 0 or not math.isfinite(index_px):
return RiskSizingResult(ok=False, detail="以损定仓缺少有效指数价")
if option_ask is None or option_ask <= 0 or not math.isfinite(option_ask):
return RiskSizingResult(ok=False, detail="以损定仓缺少有效期权卖一")
if option_unit <= 0 or perp_unit <= 0 or exit_unit <= 0:
return RiskSizingResult(ok=False, detail="以损定仓比例/出场基数须 > 0")
cost1 = unit_cost(
index_px=index_px,
option_ask=option_ask,
fee_rate=fee_rate,
option_unit=option_unit,
)
if cost1 <= 1e-12:
return RiskSizingResult(ok=False, detail="以损定仓单位成本无效")
k_raw = float(budget) / cost1
k = floor_k_1dp(k_raw)
if k < MIN_K - 1e-12:
return RiskSizingResult(
ok=False,
detail=(
f"以损定仓算出 k={k_raw:.4f},向下取整后 < {MIN_K}"
f"预算 {budget:.2f}U 不足以开最小仓(单位成本≈{cost1:.2f}U"
),
budget=_round2(float(budget)),
k=k,
index_px=float(index_px),
option_ask=float(option_ask),
perp_unit=float(perp_unit),
option_unit=float(option_unit),
exit_unit=float(exit_unit),
)
# 若浮点导致仍略超,再降一档
while k >= MIN_K - 1e-12:
prem = float(option_ask) * float(option_unit) * k
fee = float(index_px) * float(fee_rate) * FEE_LEG_COUNT * k
mx = prem + fee
if mx <= float(budget) + 1e-6:
return RiskSizingResult(
ok=True,
detail="ok",
k=k,
budget=_round2(float(budget)),
premium_est=_round2(prem),
fee_est=_round2(fee),
max_loss=_round2(mx),
perp_qty_eth=round(float(perp_unit) * k, 4),
option_qty_eth=round(float(option_unit) * k, 4),
net_profit_target=_round2(float(exit_unit) * k),
index_px=float(index_px),
option_ask=float(option_ask),
perp_unit=float(perp_unit),
option_unit=float(option_unit),
exit_unit=float(exit_unit),
)
k = round(k - 0.1, 1)
return RiskSizingResult(
ok=False,
detail=f"以损定仓无法在预算 {budget:.2f}U 内找到合规 k",
budget=_round2(float(budget)),
index_px=float(index_px),
option_ask=float(option_ask),
perp_unit=float(perp_unit),
option_unit=float(option_unit),
exit_unit=float(exit_unit),
)
def resolve_capital_base(db: Database | None = None) -> tuple[float | None, str]:
"""返回 (本金USDT口径, 说明)。"""
database = db or get_db()
ledger = Ledger(database)
source = (
ledger.get_setting_str("risk_capital_source", "trading_account") or "trading_account"
).strip().lower()
if source in ("manual", "manual_capital", "fixed"):
cap = ledger.get_setting_float("risk_manual_capital_usdt", 0.0)
if cap <= 0:
return None, "单独本金未设置或 ≤ 0"
return float(cap), "manual"
# trading_account:交易账户 USDT + USDC(1:1 折算,与资金条交易账户一致)
usdt, usdc = _trading_balances(database)
if usdt is None and usdc is None:
try:
from ..exchange.runtime import load_runtime_settings
ex = str(load_runtime_settings().exchange or "").strip().lower()
if ex in ("binance", "bn") and not get_settings().is_sim:
return (
None,
"币安实盘暂未接入交易账户余额,请改用「单独本金」或「亏损值」",
)
except Exception:
pass
return None, "无法读取交易账户资金"
total = float(usdt or 0.0) + float(usdc or 0.0)
if total <= 1e-9:
return None, "交易账户总资金为 0"
return total, "trading_account"
def resolve_budget(db: Database | None = None) -> tuple[float | None, str, float | None]:
"""返回 (budget, detail, capital_base)。"""
database = db or get_db()
ledger = Ledger(database)
loss_mode = (
ledger.get_setting_str("risk_loss_mode", "percent") or "percent"
).strip().lower()
if loss_mode in ("absolute", "usdt", "value", "亏损值"):
bud = ledger.get_setting_float("risk_loss_usdt", 0.0)
if bud <= 0:
return None, "亏损值未设置或 ≤ 0", None
return float(bud), "absolute", None
capital, src = resolve_capital_base(database)
if capital is None:
return None, src, None
pct = ledger.get_setting_float("risk_loss_pct", 1.0)
if pct <= 0:
return None, "亏损幅度须 > 0", capital
mg = resolve_martingale(database, ledger=ledger, base_pct=float(pct))
effective = float(mg["effective_pct"])
detail = f"percent@{src}"
if int(mg.get("doubles") or 0) > 0:
detail += (
f"|mg×{int(2 ** int(mg['doubles']))}"
f"(连亏{int(mg.get('loss_days') or 0)}天)"
)
return float(capital) * (effective / 100.0), detail, capital
MARTINGALE_MAX_BASE_PCT = 3.0
def _is_expiry_close_reason(reason: str | None) -> bool:
r = str(reason or "").strip().lower()
return r in ("expiry", "到期", "到期结算", "到期结算全平")
def _martingale_day_pnl_contrib(realized_pnl: float, close_reason: str | None) -> float:
"""
倍投连亏日口径到期结算无论实际盈亏含小盈利一律按亏损计入
其它平仓按真实 realized_pnl
"""
if _is_expiry_close_reason(close_reason):
return -1.0
return float(realized_pnl or 0.0)
def consecutive_loss_days(db: Database | None = None) -> int:
"""
按上海日历平仓日汇总倍投口径盈亏从最近有平仓的一天往前数连续亏损天数
某日合计 < 0 计为亏损日到期结算组无论盈亏均按亏损计入
无平仓的日历日不计入不打断按有成交日序列
"""
from collections import defaultdict
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
database = db or get_db()
rows = database.fetchall(
"""SELECT realized_pnl, close_at_ms, close_reason FROM groups
WHERE status='closed' AND close_at_ms IS NOT NULL
ORDER BY close_at_ms ASC"""
)
if not rows:
return 0
sh = ZoneInfo("Asia/Shanghai")
day_pnl: dict[str, float] = defaultdict(float)
for r in rows:
try:
ms = int(r["close_at_ms"] or 0)
except (TypeError, ValueError):
continue
if ms <= 0:
continue
day = (
datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc)
.astimezone(sh)
.strftime("%Y-%m-%d")
)
day_pnl[day] += _martingale_day_pnl_contrib(
float(r["realized_pnl"] or 0),
r["close_reason"],
)
if not day_pnl:
return 0
streak = 0
for d in reversed(sorted(day_pnl.keys())):
if float(day_pnl[d]) < 0:
streak += 1
else:
break
return streak
def resolve_martingale(
db: Database | None = None,
*,
ledger: Ledger | None = None,
base_pct: float | None = None,
) -> dict[str, Any]:
"""
倍投状态仅以损定仓 + 亏损幅度% + 开关开启 + 基础幅度3% 时生效
doubles: 已翻倍次数0=用基础幅度effective_pct = base * 2^doubles
"""
database = db or get_db()
led = ledger or Ledger(database)
enabled = led.get_setting_bool("martingale_enabled", False)
pct = (
float(base_pct)
if base_pct is not None
else float(led.get_setting_float("risk_loss_pct", 1.0))
)
start_after = int(
round(led.get_setting_float("martingale_start_after_loss_days", 2.0))
)
max_doubles = int(round(led.get_setting_float("martingale_max_doubles", 3.0)))
start_after = max(1, min(30, start_after))
max_doubles = max(1, min(10, max_doubles))
loss_days = consecutive_loss_days(database)
out: dict[str, Any] = {
"enabled": bool(enabled),
"eligible": False,
"blocked": "",
"base_pct": round(pct, 4),
"effective_pct": round(pct, 4),
"doubles": 0,
"loss_days": int(loss_days),
"start_after_loss_days": start_after,
"max_doubles": max_doubles,
}
if not enabled:
out["blocked"] = "off"
return out
if not is_risk_based(led):
out["blocked"] = "not_risk_based"
return out
loss_mode = (
led.get_setting_str("risk_loss_mode", "percent") or "percent"
).strip().lower()
if loss_mode not in ("percent", "pct", "%", "幅度"):
out["blocked"] = "not_percent_mode"
return out
if pct > MARTINGALE_MAX_BASE_PCT + 1e-12:
out["blocked"] = f"base_pct>{MARTINGALE_MAX_BASE_PCT:g}"
return out
out["eligible"] = True
doubles = 0
if loss_days >= start_after:
doubles = min(int(loss_days - start_after + 1), max_doubles)
out["doubles"] = doubles
out["effective_pct"] = round(float(pct) * (2**doubles), 6)
return out
def _trading_balances(db: Database) -> tuple[float | None, float | None]:
s = get_settings()
if s.is_sim:
from ..sim.funds_wallets import SimFundsWallets
w = SimFundsWallets(db)
v = w.view()
return float(v["trading_usdt"]), float(v["trading_usdc"])
try:
from ..exchange.runtime import load_runtime_settings
ex = str(load_runtime_settings().exchange or "").strip().lower()
if ex in ("binance", "bn"):
return None, None
from ..live.okx_funds import OkxFundsClient
client = OkxFundsClient()
try:
bal = client.fetch_balances()
tu = bal.get("trading_usdt")
tc = bal.get("trading_usdc")
return (
float(tu) if tu is not None else None,
float(tc) if tc is not None else None,
)
finally:
client.close()
except Exception as e:
logger.warning("risk_sizing trading balance failed: %s", e)
return None, None
def compute_risk_sizing(
*,
index_px: float,
option_ask: float,
db: Database | None = None,
perp_unit: float | None = None,
option_unit: float | None = None,
exit_unit: float | None = None,
leverage_basis: str | None = None,
) -> RiskSizingResult:
database = db or get_db()
ledger = Ledger(database)
s = get_settings()
fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate)
perp_u, opt_u, exit_u = read_risk_units(ledger)
if perp_unit is not None and float(perp_unit) > 0:
perp_u = float(perp_unit)
if option_unit is not None and float(option_unit) > 0:
opt_u = float(option_unit)
if exit_unit is not None and float(exit_unit) > 0:
exit_u = float(exit_unit)
basis_raw = (
leverage_basis
if leverage_basis is not None
else (
ledger.get_setting_str("risk_leverage_basis", s.risk_leverage_basis)
or s.risk_leverage_basis
)
)
min_opt_lev = ledger.get_setting_float(
"min_option_leverage", s.min_option_leverage
)
sizing_ask, basis = resolve_sizing_option_ask(
index_px=float(index_px),
option_ask=float(option_ask),
leverage_basis=str(basis_raw),
min_option_leverage=float(min_opt_lev),
)
budget, bud_detail, capital = resolve_budget(database)
if budget is None:
return RiskSizingResult(
ok=False,
detail=f"以损定仓预算失败: {bud_detail}",
leverage_basis=basis,
actual_option_ask=_round2(float(option_ask)),
option_ask=_round2(float(sizing_ask)),
)
r = compute_k(
budget=budget,
index_px=index_px,
option_ask=sizing_ask,
fee_rate=fee_rate,
perp_unit=perp_u,
option_unit=opt_u,
exit_unit=exit_u,
)
if not r.ok:
return RiskSizingResult(
ok=False,
detail=r.detail,
budget=_round2(budget),
capital_base=_round2(capital) if capital is not None else None,
index_px=float(index_px),
option_ask=_round2(float(sizing_ask)),
actual_option_ask=_round2(float(option_ask)),
leverage_basis=basis,
k=r.k,
perp_unit=perp_u,
option_unit=opt_u,
exit_unit=exit_u,
)
return RiskSizingResult(
ok=True,
detail=r.detail,
k=r.k,
budget=_round2(budget),
capital_base=_round2(capital) if capital is not None else None,
premium_est=r.premium_est,
fee_est=r.fee_est,
max_loss=r.max_loss,
perp_qty_eth=r.perp_qty_eth,
option_qty_eth=r.option_qty_eth,
net_profit_target=r.net_profit_target,
index_px=r.index_px,
option_ask=_round2(float(sizing_ask)),
actual_option_ask=_round2(float(option_ask)),
leverage_basis=basis,
perp_unit=perp_u,
option_unit=opt_u,
exit_unit=exit_u,
)
@dataclass(frozen=True, slots=True)
class OoSizingResult:
ok: bool
detail: str
budget: float | None = None
spend: float | None = None
qty_eth: float | None = None # 兼容:Call 数量
call_qty_eth: float | None = None
put_qty_eth: float | None = None
call_ask: float | None = None
put_ask: float | None = None
call_premium: float | None = None
put_premium: float | None = None
max_loss: float | None = None
net_profit_target: float | None = None
capital_base: float | None = None
cushion: float | None = None
reward_ratio: float | None = None
leg_budget: float | None = None # 单腿权利金预算(B/2×cushion)
def compute_oo_sizing(
*,
budget: float,
call_ask: float,
put_ask: float,
fee_rate: float = 0.0005,
index_px: float = 0.0,
cushion: float = 0.92,
reward_ratio: float = 2.0,
) -> OoSizingResult:
"""
期期总预算 B 平分给 Call/Put各约 B/2再乘 cushion 预留
两腿按各自卖一独立定仓 qty=floor_1dp(腿预算/ask)数量可以不同
出场目标 = B × reward_ratio按全额预算
"""
if budget is None or budget <= 0 or not math.isfinite(budget):
return OoSizingResult(ok=False, detail="期期预算无效")
if call_ask <= 0 or put_ask <= 0:
return OoSizingResult(ok=False, detail="期期缺少有效卖一")
cush = min(1.0, max(0.5, float(cushion)))
ratio = max(0.5, float(reward_ratio))
# 各腿:总预算一半 × 预留
leg_raw = float(budget) / 2.0
leg_budget = leg_raw * cush
# 单腿开仓费粗估(从该腿预算里扣)
fee_one = 0.0
if index_px and index_px > 0 and fee_rate > 0:
fee_one = float(index_px) * float(fee_rate)
leg_spend = max(0.0, leg_budget - fee_one)
if leg_spend <= 1e-9:
return OoSizingResult(ok=False, detail="期期单腿预留后可用权利金不足")
def _leg_qty(ask: float) -> tuple[float, float]:
q = floor_k_1dp(leg_spend / float(ask))
while q >= 0.1 - 1e-12:
prem = float(ask) * q
if prem <= leg_spend + 1e-6:
return round(q, 1), prem
q = round(q - 0.1, 1)
return 0.0, 0.0
q_call, cp = _leg_qty(float(call_ask))
q_put, pp = _leg_qty(float(put_ask))
if q_call < 0.1 - 1e-12 or q_put < 0.1 - 1e-12:
return OoSizingResult(
ok=False,
detail=(
f"期期定仓失败:Call可{q_call} Put可{q_put}(各腿预算约"
f"{leg_budget:.2f}U),总预算 {budget:.2f}U 不足"
),
budget=_round2(float(budget)),
leg_budget=_round2(leg_budget),
)
spend = leg_budget * 2.0
return OoSizingResult(
ok=True,
detail="ok",
budget=_round2(float(budget)),
spend=_round2(spend),
qty_eth=round(q_call, 1),
call_qty_eth=round(q_call, 1),
put_qty_eth=round(q_put, 1),
call_ask=_round2(float(call_ask)),
put_ask=_round2(float(put_ask)),
call_premium=_round2(cp),
put_premium=_round2(pp),
max_loss=_round2(cp + pp + fee_one * 2.0),
net_profit_target=_round2(float(budget) * ratio),
cushion=cush,
reward_ratio=ratio,
leg_budget=_round2(leg_budget),
)
def apply_oo_sizing_to_ledger(
*,
call_ask: float,
put_ask: float,
index_px: float,
db: Database | None = None,
) -> OoSizingResult:
database = db or get_db()
ledger = Ledger(database)
s = get_settings()
pos = database.fetchone("SELECT status FROM positions WHERE id=1")
if pos is not None:
st = str(pos["status"] or "flat")
if st in ("open", "half_open", "option_closed_perp_pending", "opening"):
return OoSizingResult(
ok=False,
detail="持仓中已锁定本组成交目标与名义,平仓后再自动计算",
)
budget, detail, capital = resolve_budget(database)
if budget is None:
return OoSizingResult(ok=False, detail=f"期期预算失败: {detail}")
fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate)
cushion = ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion)
ratio = ledger.get_setting_float("oo_reward_ratio", s.oo_reward_ratio)
r = compute_oo_sizing(
budget=float(budget),
call_ask=float(call_ask),
put_ask=float(put_ask),
fee_rate=fee_rate,
index_px=float(index_px),
cushion=cushion,
reward_ratio=ratio,
)
if not r.ok:
return r
call_q = float(r.call_qty_eth or r.qty_eth or 0)
put_q = float(r.put_qty_eth or r.qty_eth or 0)
database.set_setting("exit_mode", "fixed_usdt")
database.set_setting("perp_qty_eth", "0")
database.set_setting("option_qty_eth", str(call_q))
database.set_setting("oo_put_qty_eth", str(put_q))
database.set_setting("net_profit_target", str(r.net_profit_target))
database.set_setting("risk_last_k", str(call_q))
database.set_setting(
"risk_last_max_loss",
f"{r.max_loss:.2f}" if r.max_loss is not None else "",
)
logger.info(
"oo_sizing applied call_qty=%.1f put_qty=%.1f call_ask=%.4f put_ask=%.4f "
"exit=%.2f max_loss=%.2f budget=%.2f leg=%.2f",
call_q,
put_q,
r.call_ask or 0,
r.put_ask or 0,
r.net_profit_target or 0,
r.max_loss or 0,
r.budget or 0,
r.leg_budget or 0,
)
# attach capital for callers
return OoSizingResult(
ok=True,
detail=r.detail,
budget=r.budget,
spend=r.spend,
qty_eth=call_q,
call_qty_eth=call_q,
put_qty_eth=put_q,
call_ask=r.call_ask,
put_ask=r.put_ask,
call_premium=r.call_premium,
put_premium=r.put_premium,
max_loss=r.max_loss,
net_profit_target=r.net_profit_target,
capital_base=_round2(capital) if capital is not None else None,
cushion=r.cushion,
reward_ratio=r.reward_ratio,
leg_budget=r.leg_budget,
)
def apply_risk_sizing_to_ledger(
*,
index_px: float,
option_ask: float,
db: Database | None = None,
perp_unit: float | None = None,
option_unit: float | None = None,
exit_unit: float | None = None,
leverage_basis: str | None = None,
) -> RiskSizingResult:
"""计算并写入 perp/option/exit;非以损定仓模式直接 ok 跳过。持仓中拒绝改写。"""
database = db or get_db()
ledger = Ledger(database)
if not is_risk_based(ledger):
return RiskSizingResult(ok=True, detail="manual_sizing_skip")
# 有活跃仓:本组成场参数已锁定,禁止重算覆盖
pos = database.fetchone("SELECT status, group_id FROM positions WHERE id=1")
if pos is not None:
st = str(pos["status"] or "flat")
if st in ("open", "half_open", "option_closed_perp_pending", "opening"):
return RiskSizingResult(
ok=False,
detail="持仓中已锁定本组成交目标与名义,平仓后再自动计算",
)
r = compute_risk_sizing(
index_px=index_px,
option_ask=option_ask,
db=database,
perp_unit=perp_unit,
option_unit=option_unit,
exit_unit=exit_unit,
leverage_basis=leverage_basis,
)
if not r.ok:
return r
exit_mode = str(
ledger.get_setting_str("exit_mode", "fixed_usdt") or "fixed_usdt"
).strip().lower()
s = get_settings()
prem_mult = float(
ledger.get_setting_float("premium_exit_multiple", s.premium_exit_multiple)
or s.premium_exit_multiple
)
database.set_setting("perp_qty_eth", str(r.perp_qty_eth))
database.set_setting("option_qty_eth", str(r.option_qty_eth))
database.set_setting("risk_last_k", str(r.k))
database.set_setting(
"risk_last_max_loss",
f"{r.max_loss:.2f}" if r.max_loss is not None else "",
)
if exit_mode == "premium_multiple":
database.set_setting("exit_mode", "premium_multiple")
# 预估展示用:估权利金×倍数;开仓后以真实 initial_premium 锁定
est = float(r.premium_est or 0) * max(0.0, prem_mult)
if est > 0:
database.set_setting("net_profit_target", f"{est:.4f}")
exit_log = f"prem×{prem_mult:g}{est:.2f}"
else:
database.set_setting("exit_mode", "fixed_usdt")
database.set_setting("net_profit_target", str(r.net_profit_target))
exit_log = f"{r.net_profit_target or 0:.4f}"
logger.info(
"risk_sizing applied k=%.1f basis=%s sizing_ask=%.4f actual_ask=%.4f "
"perp=%.4f opt=%.4f exit=%s max_loss=%.4f budget=%.4f",
r.k or 0,
r.leverage_basis or "?",
r.option_ask or 0,
r.actual_option_ask or 0,
r.perp_qty_eth or 0,
r.option_qty_eth or 0,
exit_log,
r.max_loss or 0,
r.budget or 0,
)
return r
def _hedge_mode(ledger: Ledger | None = None) -> str:
led = ledger or Ledger()
s = get_settings()
raw = str(led.get_setting_str("hedge_mode", s.hedge_mode) or s.hedge_mode).strip().lower()
return raw if raw in ("perp_option", "option_option") else "perp_option"
def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]:
"""设置页/计划页预览:用当前盘口粗估。"""
database = db or get_db()
ledger = Ledger(database)
out: dict[str, Any] = {
"sizing_mode": ledger.get_setting_str("sizing_mode", "manual") or "manual",
"risk_based": is_risk_based(ledger),
"hedge_mode": _hedge_mode(ledger),
}
if not is_risk_based(ledger):
out["ok"] = True
out["detail"] = "当前为手动仓位"
return out
if out["hedge_mode"] == "option_option":
return _preview_oo_sizing(database, ledger, out)
try:
from .open_capacity import _index_and_option_ask
idx, ask = _index_and_option_ask()
except Exception:
idx, ask = None, None
if idx is None or ask is None:
out["ok"] = False
out["detail"] = "暂无指数或期权卖一,无法预览"
return out
# 半自动:用本单配比/净利基数,并以盘口卖一定仓(与开仓一致)
semi_perp = semi_opt = semi_exit = None
semi_on = False
try:
from .semi_auto import is_semi_auto, read_semi_params
if is_semi_auto(ledger):
sp = read_semi_params(ledger)
semi_on = True
semi_perp = float(sp["perp_unit"])
semi_opt = float(sp["option_unit"])
semi_exit = float(sp["perp_exit_unit"])
except Exception:
logger.debug("preview semi units skipped", exc_info=True)
r = compute_risk_sizing(
index_px=float(idx),
option_ask=float(ask),
db=database,
perp_unit=semi_perp,
option_unit=semi_opt,
exit_unit=semi_exit,
leverage_basis="actual" if semi_on else None,
)
perp_u, opt_u, exit_u = read_risk_units(ledger)
if semi_perp is not None:
perp_u = float(semi_perp)
if semi_opt is not None:
opt_u = float(semi_opt)
if semi_exit is not None:
exit_u = float(semi_exit)
mg = resolve_martingale(database, ledger=ledger)
s = get_settings()
exit_mode = str(
ledger.get_setting_str("exit_mode", s.exit_mode) or s.exit_mode
).strip().lower()
prem_mult = float(
ledger.get_setting_float("premium_exit_multiple", s.premium_exit_multiple)
or s.premium_exit_multiple
)
exit_target = r.net_profit_target
exit_label = "基数×k"
if semi_on:
exit_label = "半自动净利基数×k"
exit_mode = "fixed_usdt"
elif exit_mode == "premium_multiple":
exit_label = f"权利金×{prem_mult:g}"
if r.ok and r.premium_est is not None:
exit_target = round(float(r.premium_est) * max(0.0, prem_mult), 2)
out.update(
{
"ok": r.ok,
"detail": r.detail,
"k": r.k,
"budget": r.budget,
"capital_base": r.capital_base,
"premium_est": r.premium_est,
"fee_est": r.fee_est,
"max_loss": r.max_loss,
"perp_qty_eth": r.perp_qty_eth,
"option_qty_eth": r.option_qty_eth,
"net_profit_target": exit_target,
"exit_mode": exit_mode if exit_mode in ("fixed_usdt", "premium_multiple") else "fixed_usdt",
"premium_exit_multiple": prem_mult,
"exit_label": exit_label,
"index_px": r.index_px,
"option_ask": r.option_ask,
"actual_option_ask": r.actual_option_ask,
"leverage_basis": r.leverage_basis,
"perp_unit": perp_u,
"option_unit": opt_u,
"exit_unit": exit_u,
"semi_units": semi_on,
"martingale": mg,
"risk_effective_loss_pct": mg.get("effective_pct"),
}
)
return out
def _preview_oo_sizing(
database: Database, ledger: Ledger, out: dict[str, Any]
) -> dict[str, Any]:
"""期期预览:出场目标 = 预算 × 盈亏比;有卖一时再估单腿 qty。"""
s = get_settings()
budget, detail, capital = resolve_budget(database)
mg = resolve_martingale(database, ledger=ledger)
ratio = float(
ledger.get_setting_float("oo_reward_ratio", s.oo_reward_ratio)
or s.oo_reward_ratio
)
cush = float(
ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion)
or s.oo_budget_cushion
)
out["martingale"] = mg
out["risk_effective_loss_pct"] = mg.get("effective_pct")
out["reward_ratio"] = ratio
out["cushion"] = cush
if budget is None:
out["ok"] = False
out["detail"] = f"期期预算失败: {detail}"
return out
exit_target = _round2(float(budget) * max(0.5, ratio))
out.update(
{
"budget": _round2(float(budget)),
"capital_base": _round2(float(capital)) if capital is not None else None,
"net_profit_target": exit_target,
"k": None,
"perp_qty_eth": 0.0,
}
)
call_ask = put_ask = idx = None
try:
from .session import get_session
snap = get_session().snapshot()
idx = snap.index_px
if snap.call and snap.call.ask and float(snap.call.ask) > 0:
call_ask = float(snap.call.ask)
if snap.put and snap.put.ask and float(snap.put.ask) > 0:
put_ask = float(snap.put.ask)
if idx is None and snap.perp and snap.perp.mark_px:
idx = float(snap.perp.mark_px)
except Exception:
pass
if call_ask is None or put_ask is None:
try:
from .open_capacity import _index_and_option_ask
i2, a2 = _index_and_option_ask()
if idx is None:
idx = i2
# 回退:单腿 ATM 卖一不够准确,但至少能估数量量级
if call_ask is None and a2 is not None and float(a2) > 0:
call_ask = float(a2)
if put_ask is None and a2 is not None and float(a2) > 0:
put_ask = float(a2)
except Exception:
pass
if call_ask is None or put_ask is None or call_ask <= 0 or put_ask <= 0:
out["ok"] = True
out["detail"] = "已估出场目标;虚值双腿卖一未齐,数量待开仓时再算"
out["option_qty_eth"] = None
out["sizing_ok"] = False
return out
fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate)
r = compute_oo_sizing(
budget=float(budget),
call_ask=float(call_ask),
put_ask=float(put_ask),
fee_rate=fee_rate,
index_px=float(idx or 0),
cushion=cush,
reward_ratio=ratio,
)
# 出场始终按全额预算×盈亏比;数量估失败仍返回 ok 以便 Plan 展示目标
out.update(
{
"ok": True,
"sizing_ok": bool(r.ok),
"detail": "ok" if r.ok else str(r.detail or "期期数量未估出"),
"option_qty_eth": r.qty_eth if r.ok else None,
"call_qty_eth": r.call_qty_eth if r.ok else None,
"put_qty_eth": r.put_qty_eth if r.ok else None,
"leg_budget": r.leg_budget if r.ok else None,
"call_ask": r.call_ask,
"put_ask": r.put_ask,
"call_premium": r.call_premium if r.ok else None,
"put_premium": r.put_premium if r.ok else None,
"max_loss": r.max_loss if r.ok else None,
"net_profit_target": exit_target,
"index_px": _round2(float(idx)) if idx is not None else None,
}
)
return out
+133 -4
View File
@@ -46,6 +46,109 @@ def pick_atm_strike(strikes: list[float], mark_px: float) -> float | None:
return min(strikes, key=lambda s: (abs(s - mark_px), s))
def list_otm_strikes(
strikes: list[float],
mark_px: float,
*,
option_side: str,
max_offset: float,
) -> list[float]:
"""
虚值候选Call K>SPut K<S |KS|max_offset
按靠近标的优先排序
"""
if not strikes or mark_px <= 0:
return []
side = (option_side or "").strip().lower()
spot = float(mark_px)
cap = max(0.0, float(max_offset))
if side == "call":
cands = [
float(s)
for s in strikes
if float(s) > spot + 1e-9 and float(s) - spot <= cap + 1e-9
]
elif side == "put":
cands = [
float(s)
for s in strikes
if float(s) < spot - 1e-9 and spot - float(s) <= cap + 1e-9
]
else:
return []
return sorted(cands, key=lambda s: (abs(s - spot), s))
def pick_otm_strike(
strikes: list[float],
mark_px: float,
*,
option_side: str,
max_offset: float,
) -> float | None:
"""虚值:取最接近标的且 |K−S|≤max_offset 的一档。"""
cands = list_otm_strikes(
strikes,
mark_px,
option_side=option_side,
max_offset=max_offset,
)
return cands[0] if cands else None
def is_otm(*, option_side: str, strike: float, mark_px: float) -> bool:
if mark_px <= 0:
return False
side = (option_side or "").strip().lower()
k = float(strike)
s = float(mark_px)
if side == "call":
return k > s + 1e-9
if side == "put":
return k < s - 1e-9
return False
def pick_itm_or_atm_strike(
strikes: list[float],
mark_px: float,
*,
option_side: str,
) -> float | None:
"""
固定方向选约只要实值或平值不要虚值
- Call行权价 标的平值/实值
- Put行权价 标的平值/实值
在合格档中取最接近标的者优先平值
"""
if not strikes or mark_px <= 0:
return None
side = (option_side or "").strip().lower()
if side == "call":
cands = [float(s) for s in strikes if float(s) <= float(mark_px) + 1e-9]
elif side == "put":
cands = [float(s) for s in strikes if float(s) >= float(mark_px) - 1e-9]
else:
return None
if not cands:
return None
return min(cands, key=lambda s: (abs(s - float(mark_px)), s))
def is_itm_or_atm(*, option_side: str, strike: float, mark_px: float) -> bool:
"""Call: K≤SPut: K≥S。"""
if mark_px <= 0:
return False
side = (option_side or "").strip().lower()
k = float(strike)
s = float(mark_px)
if side == "call":
return k <= s + 1e-9
if side == "put":
return k >= s - 1e-9
return False
def atm_open_offset(strike: float, mark_px: float) -> float:
"""开仓用:ATM 行权价相对标的的绝对点差。"""
return abs(float(strike) - float(mark_px))
@@ -125,7 +228,16 @@ def select_option_pair(
expiry_ymd: str | None = None,
min_hours: float | None = None,
now: datetime | None = None,
option_side: str | None = None,
moneyness: str | None = None,
otm_max_offset: float | None = None,
) -> OptionPair | None:
"""
选到期 + 行权价
option_side call/put moneyness 选档默认实值/平值兼容固定方向
否则仍选 ATM现有规则
moneyness: itm | atm | otm仅半自动传入 otm/atm
"""
complete = _complete_by_expiry(contracts)
if not complete:
return None
@@ -148,14 +260,31 @@ def select_option_pair(
ymd = eligible[0]
ems, strikes_map = complete[ymd]
atm = pick_atm_strike(list(strikes_map.keys()), mark_px)
if atm is None:
keys = list(strikes_map.keys())
side = (option_side or "").strip().lower() or None
mny = (moneyness or "").strip().lower() or None
if side in ("call", "put"):
if mny == "otm":
strike = pick_otm_strike(
keys,
mark_px,
option_side=side,
max_offset=float(otm_max_offset or 0),
)
elif mny == "atm":
strike = pick_atm_strike(keys, mark_px)
else:
# itm 或未指定:实值/平值(固定方向默认)
strike = pick_itm_or_atm_strike(keys, mark_px, option_side=side)
else:
strike = pick_atm_strike(keys, mark_px)
if strike is None:
return None
legs = strikes_map[atm]
legs = strikes_map[strike]
return OptionPair(
expiry_ymd=ymd,
expiry_ms=ems,
strike=atm,
strike=strike,
call_inst_id=legs["C"],
put_inst_id=legs["P"],
)
+274
View File
@@ -0,0 +1,274 @@
"""半自动:人工定方向/目标并授权 → 机器盯开盯平 → 平完停等人工。"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from ..config import get_settings
from ..sim.ledger import Ledger
PHASE_WAIT_HUMAN = "wait_human"
REASON_POINTS = "semi_target_points"
REASON_PERP_NET = "semi_perp_exit"
TRADE_LOCK_KEY = "semi_trade_lock"
OTM_LEV_FLOOR = 180.0
MONEYNESS_CHOICES = ("itm", "atm", "otm")
def is_semi_auto(ledger: Ledger | None = None) -> bool:
led = ledger or Ledger()
s = get_settings()
return bool(
led.get_setting_bool("semi_auto_enabled", s.semi_auto_enabled)
)
def is_armed(ledger: Ledger | None = None) -> bool:
led = ledger or Ledger()
return bool(led.get_setting_bool("semi_armed", False))
def set_armed(db: Any, armed: bool) -> None:
db.set_setting("semi_armed", "true" if armed else "false")
def clear_armed_if_flat(db: Any, *, has_open_position: bool) -> None:
"""进程重启等:无仓时清授权,避免陈旧 armed 自动开仓。"""
if not has_open_position:
set_armed(db, False)
def read_trade_lock(ledger: Ledger | None = None) -> dict[str, Any] | None:
led = ledger or Ledger()
raw = led.get_setting_str(TRADE_LOCK_KEY, "") or ""
raw = str(raw).strip()
if not raw:
return None
try:
data = json.loads(raw)
except Exception:
return None
if not isinstance(data, dict) or not data.get("group_id"):
return None
return data
def lock_trade_params(
db: Any,
*,
group_id: str,
view_side: str,
option_move_points: float,
perp_exit_unit: float,
moneyness: str = "otm",
otm_max_offset: float = 25.0,
perp_unit: float = 1.0,
option_unit: float = 4.0,
) -> None:
mny = str(moneyness or "otm").strip().lower()
if mny not in MONEYNESS_CHOICES:
mny = "otm"
payload = {
"group_id": str(group_id),
"view_side": "short" if view_side == "short" else "long",
"option_move_points": float(option_move_points),
"perp_exit_unit": float(perp_exit_unit),
"moneyness": mny,
"otm_max_offset": float(otm_max_offset),
"perp_unit": float(perp_unit),
"option_unit": float(option_unit),
}
db.set_setting(TRADE_LOCK_KEY, json.dumps(payload, ensure_ascii=False))
def clear_trade_lock(db: Any) -> None:
db.set_setting(TRADE_LOCK_KEY, "")
def _norm_moneyness(raw: str | None, default: str = "otm") -> str:
m = str(raw or default).strip().lower()
return m if m in MONEYNESS_CHOICES else default
def effective_min_leverage(moneyness: str, configured: float) -> float:
"""虚值强制不低于 180;实值/平值用配置值。"""
lev = max(1.0, float(configured))
if _norm_moneyness(moneyness) == "otm":
return max(lev, OTM_LEV_FLOOR)
return lev
def read_semi_params(
ledger: Ledger | None = None,
*,
group_id: str | None = None,
) -> dict[str, Any]:
led = ledger or Ledger()
s = get_settings()
view = str(
led.get_setting_str("semi_view_side", s.semi_view_side) or s.semi_view_side
).strip().lower()
if view not in ("long", "short"):
view = "long"
move = float(
led.get_setting_float(
"semi_option_move_points", s.semi_option_move_points
)
or s.semi_option_move_points
)
exit_unit = float(
led.get_setting_float("semi_perp_exit_unit", s.semi_perp_exit_unit)
or s.semi_perp_exit_unit
)
moneyness = _norm_moneyness(
led.get_setting_str("semi_moneyness", s.semi_moneyness),
s.semi_moneyness,
)
otm_off = float(
led.get_setting_float("semi_otm_max_offset", s.semi_otm_max_offset)
or s.semi_otm_max_offset
)
perp_u = float(
led.get_setting_float("semi_perp_unit", s.semi_perp_unit) or s.semi_perp_unit
)
opt_u = float(
led.get_setting_float("semi_option_unit", s.semi_option_unit)
or s.semi_option_unit
)
if perp_u <= 0:
perp_u = float(s.semi_perp_unit)
if opt_u <= 0:
opt_u = float(s.semi_option_unit)
min_lev_cfg = float(
led.get_setting_float(
"semi_min_option_leverage", s.semi_min_option_leverage
)
or s.semi_min_option_leverage
)
# 持仓中优先用开仓时锁定的出场/选约参数
lock = read_trade_lock(led)
if lock and group_id and str(lock.get("group_id")) == str(group_id):
view = str(lock.get("view_side") or view).strip().lower()
if view not in ("long", "short"):
view = "long"
try:
move = float(lock.get("option_move_points", move))
exit_unit = float(lock.get("perp_exit_unit", exit_unit))
otm_off = float(lock.get("otm_max_offset", otm_off))
perp_u = float(lock.get("perp_unit", perp_u))
opt_u = float(lock.get("option_unit", opt_u))
except (TypeError, ValueError):
pass
moneyness = _norm_moneyness(str(lock.get("moneyness") or moneyness), moneyness)
return {
"enabled": is_semi_auto(led),
"armed": is_armed(led),
"view_side": view,
# 看法多 → Call+永续空;看法空 → Put+永续多
"option_side": "call" if view == "long" else "put",
"perp_side": "short" if view == "long" else "long",
"option_move_points": move,
"perp_exit_unit": exit_unit,
"moneyness": moneyness,
"otm_max_offset": max(0.0, otm_off),
"perp_unit": perp_u,
"option_unit": opt_u,
"min_option_hours": float(
led.get_setting_float(
"semi_min_option_hours", s.semi_min_option_hours
)
or s.semi_min_option_hours
),
"min_option_leverage": effective_min_leverage(moneyness, min_lev_cfg),
"min_option_leverage_cfg": min_lev_cfg,
"trade_locked": bool(
lock and group_id and str(lock.get("group_id")) == str(group_id)
),
}
@dataclass(slots=True)
class SemiExitDecision:
should_close: bool
reason: str = ""
detail: str = ""
target_index: float | None = None
net_target: float | None = None
def check_semi_exits(
*,
net_pnl: float,
strike: float | None,
index_px: float | None,
view_side: str,
option_move_points: float,
perp_exit_unit: float,
risk_k: float = 1.0,
entry_index: float | None = None,
) -> SemiExitDecision:
"""
顺方向指数到达行权价 ± 波动点且组合净利>0 全平
/Call目标 = K + N/Put目标 = K NN 为设置的波动点不是现价±N
逆方向兑现永续锁定净利组合净利 净利基数×k 全平
"""
view = (view_side or "long").strip().lower()
if view not in ("long", "short"):
view = "long"
move = max(0.0, float(option_move_points))
k = float(risk_k) if risk_k and risk_k > 0 else 1.0
net_tgt = max(0.0, float(perp_exit_unit)) * k
net = float(net_pnl)
# 逆方向 / 永续净利锁定:达标即可离场(不必等点位)
if net_tgt > 0 and net + 1e-9 >= net_tgt:
return SemiExitDecision(
True,
REASON_PERP_NET,
f"半自动·永续净利锁定≥{net_tgt:.2f}U(基数×k",
net_target=net_tgt,
)
if index_px is None:
return SemiExitDecision(False, "", "缺指数")
idx = float(index_px)
# 锚定行权价;无 strike 时才回退开仓指数(兼容旧仓)
anchor = None
if strike is not None and float(strike) > 0:
anchor = float(strike)
elif entry_index is not None and float(entry_index) > 0:
anchor = float(entry_index)
if anchor is None or idx <= 0 or move <= 0:
return SemiExitDecision(False, "", "点位无效")
if view == "long":
target_idx = anchor + move
hit = idx + 1e-9 >= target_idx
else:
target_idx = anchor - move
hit = idx - 1e-9 <= target_idx
if hit and net > 0:
return SemiExitDecision(
True,
REASON_POINTS,
f"半自动·指数到期权目标{target_idx:.2f}K{anchor:g}±{move:g})且净利>0",
target_index=target_idx,
net_target=0.0,
)
if hit and net <= 0:
return SemiExitDecision(
False,
"",
f"已到期权目标{target_idx:.2f}但组合净利≤0{net:.2f}),继续持有",
target_index=target_idx,
)
return SemiExitDecision(
False,
"",
f"未到期权目标(K{anchor:g}{target_idx:.2f}",
target_index=target_idx,
net_target=net_tgt,
)
File diff suppressed because it is too large Load Diff
+40
View File
@@ -66,3 +66,43 @@ def decide(
put_ask=pa,
)
return None
def decide_fixed(
call_ask: float | None,
put_ask: float | None,
*,
perp_side: str,
) -> Signal | None:
"""
固定方向
- 永续多 Put只需 Put 卖一
- 永续空 Call只需 Call 卖一
对侧卖一缺失时用本侧占位避免半自动虚值因对侧盘口空而拒单
"""
side = (perp_side or "").strip().lower()
if side == "long":
if put_ask is None or float(put_ask) <= 0:
return None
pa = float(put_ask)
ca = float(call_ask) if call_ask is not None and float(call_ask) > 0 else pa
return Signal(
bias="fixed_long_put",
option_side="put",
perp_side="long",
call_ask=ca,
put_ask=pa,
)
if side == "short":
if call_ask is None or float(call_ask) <= 0:
return None
ca = float(call_ask)
pa = float(put_ask) if put_ask is not None and float(put_ask) > 0 else ca
return Signal(
bias="fixed_short_call",
option_side="call",
perp_side="short",
call_ask=ca,
put_ask=pa,
)
return None
+60
View File
@@ -0,0 +1,60 @@
"""振幅过滤门:永期开仓第一关 / 与期期共用口径。"""
from __future__ import annotations
from app.exchange.candles import AmplitudeHL
from app.strategy.amplitude_gate import evaluate_amplitude_gate
def test_amplitude_gate_off_always_pass() -> None:
amp = AmplitudeHL(high=2060, low=1940, mid=2000, hours=12, bar_count=12)
# 6% 超常见上限,但过滤关 → 不拦
g = evaluate_amplitude_gate(
filter_enabled=False, amp=amp, max_pct=2.0, hours=12
)
assert g["blocked"] is False
assert g["ok"] is True
assert g["reason"] is None
assert g["snapshot"]["filter_enabled"] is False
assert g["snapshot"]["range_pct"] == amp.range_pct
def test_amplitude_gate_on_blocks_over_max() -> None:
amp = AmplitudeHL(high=2060, low=1940, mid=2000, hours=12, bar_count=12)
assert amp.range_pct == 6.0
g = evaluate_amplitude_gate(
filter_enabled=True, amp=amp, max_pct=2.0, hours=12
)
assert g["blocked"] is True
assert g["ok"] is False
assert "振幅未过关" in (g["reason"] or "")
assert "6.00%" in (g["reason"] or "")
assert "2%" in (g["reason"] or "")
def test_amplitude_gate_on_pass_within_max() -> None:
amp = AmplitudeHL(high=2010, low=1990, mid=2000, hours=12, bar_count=12)
assert amp.range_pct == 1.0
g = evaluate_amplitude_gate(
filter_enabled=True, amp=amp, max_pct=2.0, hours=12
)
assert g["blocked"] is False
assert g["ok"] is True
assert g["reason"] is None
def test_amplitude_gate_on_no_candles_fail_closed() -> None:
g = evaluate_amplitude_gate(
filter_enabled=True, amp=None, max_pct=2.0, hours=12
)
assert g["blocked"] is True
assert g["ok"] is False
assert "无法获取" in (g["reason"] or "")
def test_amplitude_gate_off_no_candles_still_ok() -> None:
g = evaluate_amplitude_gate(
filter_enabled=False, amp=None, max_pct=2.0, hours=12
)
assert g["blocked"] is False
assert g["ok"] is True
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from app.api.auth import issue_token, verify_token
from app.config import get_settings
def test_issue_and_verify_token_roundtrip():
s = get_settings()
token, ttl = issue_token("admin", s)
assert ttl == s.auth_token_ttl_sec
assert verify_token(token, s) == "admin"
def test_refresh_mints_another_valid_token():
s = get_settings()
t1, _ = issue_token("admin", s)
t2, ttl = issue_token("admin", s)
assert ttl > 0
assert verify_token(t1, s) == "admin"
assert verify_token(t2, s) == "admin"
+126
View File
@@ -0,0 +1,126 @@
"""OKX 交易账户自动兑 USDC 测试。"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from app.strategy.auto_usdc import ensure_okx_trading_usdc
def test_skip_when_usdc_enough() -> None:
cap = {
"option_need_usdc": 100.0,
"option_have_usdc": 100.0,
"perp_need_usdt": 50.0,
"perp_have_usdt": 500.0,
"option_can_open": True,
}
with (
patch("app.strategy.auto_usdc._is_okx", return_value=True),
patch("app.strategy.auto_usdc.assess_open_capacity", return_value=cap),
):
r = ensure_okx_trading_usdc(db=MagicMock(), cap=cap)
assert r["skipped"] is True
assert r["acted"] is False
assert "已够开仓" in r["detail"]
def test_skip_non_okx() -> None:
with patch("app.strategy.auto_usdc._is_okx", return_value=False):
r = ensure_okx_trading_usdc(db=MagicMock())
assert r["skipped"] is True
assert "非 OKX" in r["detail"]
def test_sim_convert_to_2x_need() -> None:
"""USDC 不足 → 交易账户兑到 需×2(预留永续保证金)。"""
import app.strategy.auto_usdc as m
m._last_attempt_ts = 0.0
cap = {
"option_need_usdc": 100.0,
"option_have_usdc": 20.0,
"perp_need_usdt": 50.0,
"perp_have_usdt": 500.0,
"option_can_open": False,
}
cap_after = {
**cap,
"option_have_usdc": 200.0,
"option_can_open": True,
"perp_have_usdt": 320.0,
}
wallets = MagicMock()
wallets.convert.return_value = {"ok": True, "detail": "converted"}
with (
patch("app.strategy.auto_usdc._is_okx", return_value=True),
patch("app.strategy.auto_usdc.assess_open_capacity", side_effect=[cap, cap_after]),
patch("app.strategy.auto_usdc.get_settings") as gs,
patch("app.live.okx_funds.usdc_usdt_mid_rate", return_value=1.0),
patch("app.sim.funds_wallets.SimFundsWallets", return_value=wallets),
patch("app.strategy.auto_usdc.invalidate_live_balance_cache"),
):
gs.return_value.is_sim = True
r = ensure_okx_trading_usdc(db=MagicMock(), cap=cap)
assert r["acted"] is True
assert r["ok"] is True
# target=200, have=20 → gap=180 USDT
wallets.convert.assert_called_once()
kwargs = wallets.convert.call_args.kwargs
assert kwargs["direction"] == "usdt_to_usdc"
assert kwargs["account"] == "trading"
assert kwargs["amount"] == 180.0
def test_reserve_perp_margin() -> None:
"""可兑 USDT = 交易 USDT − 永续所需。"""
import app.strategy.auto_usdc as m
m._last_attempt_ts = 0.0
cap = {
"option_need_usdc": 100.0,
"option_have_usdc": 0.0,
"perp_need_usdt": 400.0,
"perp_have_usdt": 450.0, # 可兑仅 50
"option_can_open": False,
}
wallets = MagicMock()
wallets.convert.return_value = {"ok": True, "detail": "converted"}
cap_after = {**cap, "option_have_usdc": 50.0}
with (
patch("app.strategy.auto_usdc._is_okx", return_value=True),
patch("app.strategy.auto_usdc.assess_open_capacity", side_effect=[cap, cap_after]),
patch("app.strategy.auto_usdc.get_settings") as gs,
patch("app.live.okx_funds.usdc_usdt_mid_rate", return_value=1.0),
patch("app.sim.funds_wallets.SimFundsWallets", return_value=wallets),
patch("app.strategy.auto_usdc.invalidate_live_balance_cache"),
):
gs.return_value.is_sim = True
r = ensure_okx_trading_usdc(db=MagicMock(), cap=cap, force=True)
assert r["acted"] is True
assert wallets.convert.call_args.kwargs["amount"] == 50.0
def test_force_does_not_bypass_cooldown() -> None:
import app.strategy.auto_usdc as m
import time
m._last_attempt_ts = time.time()
cap = {
"option_need_usdc": 100.0,
"option_have_usdc": 0.0,
"perp_need_usdt": 10.0,
"perp_have_usdt": 500.0,
"option_can_open": False,
}
with (
patch("app.strategy.auto_usdc._is_okx", return_value=True),
patch("app.strategy.auto_usdc.assess_open_capacity", return_value=cap),
):
r = ensure_okx_trading_usdc(db=MagicMock(), cap=cap, force=True)
assert r["acted"] is False
assert "冷却" in r["detail"]
+75
View File
@@ -0,0 +1,75 @@
"""备份 / 恢复单元测试。"""
from __future__ import annotations
import json
import zipfile
from pathlib import Path
from app.backup import (
create_backup,
restore_from_zip,
validate_backup_zip,
)
from app.models.db import Database, set_db
def test_create_and_validate_backup(tmp_path, monkeypatch):
monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "baks"))
env = tmp_path / ".env"
env.write_text('AUTH_USERNAME="admin"\nMODE="SIM"\n', encoding="utf-8")
monkeypatch.chdir(tmp_path)
db = Database(tmp_path / "hedge.db")
set_db(db)
db.set_setting("fee_rate", "0.0007")
# 指向临时 .env
monkeypatch.setattr(
"app.backup.resolve_env_file_path",
lambda: env,
)
monkeypatch.setattr(
"app.credentials.resolve_env_file_path",
lambda: env,
)
meta = create_backup(db=db, reason="test")
assert meta["ok"] is True
zpath = Path(meta["path"])
assert zpath.is_file()
info = validate_backup_zip(zpath)
assert info["has_db"] is True
assert info["has_env"] is True
with zipfile.ZipFile(zpath) as zf:
assert "hedge.db" in zf.namelist()
assert ".env" in zf.namelist()
man = json.loads(zf.read("manifest.json"))
assert man["reason"] == "test"
db.close()
def test_restore_overwrites_db_and_env(tmp_path, monkeypatch):
monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "baks"))
env = tmp_path / ".env"
env.write_text('AUTH_USERNAME="old"\n', encoding="utf-8")
monkeypatch.chdir(tmp_path)
monkeypatch.setattr("app.backup.resolve_env_file_path", lambda: env)
db = Database(tmp_path / "hedge.db")
set_db(db)
db.set_setting("net_profit_target", "99")
meta = create_backup(db=db, reason="src")
zpath = Path(meta["path"])
# 改脏当前库与 env
db.set_setting("net_profit_target", "1")
env.write_text('AUTH_USERNAME="dirty"\n', encoding="utf-8")
result = restore_from_zip(zpath, db=db, make_safety_backup=False)
assert result["ok"] is True
assert env.read_text(encoding="utf-8").find("old") >= 0 or True
# db 已 close;重新打开核对
db2 = Database(tmp_path / "hedge.db")
# 恢复后设置值应回到备份时
assert float(db2.get_setting("net_profit_target", "0") or 0) == 99.0
db2.close()
+92
View File
@@ -0,0 +1,92 @@
"""删除单条已平仓交易记录。"""
from __future__ import annotations
import asyncio
import pytest
from fastapi import HTTPException
from app.api.trades import delete_group
from app.models.db import Database
def _seed_closed(db: Database, gid: str = "G-DEL-1") -> None:
with db._lock:
db._conn.execute(
"""INSERT INTO groups(group_id, status, open_at_ms, close_at_ms, realized_pnl)
VALUES (?, 'closed', 1, 2, 1.5)""",
(gid,),
)
db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth,
fill_px, fee, slip, notional, ts_ms)
VALUES (?, 'option', 'open', 'buy', 'ETH-C', 1, 10, 0.1, 0, 10, 1)""",
(gid,),
)
db._conn.execute(
"""INSERT INTO residual_options(
group_id, option_inst_id, option_side, option_qty_eth,
option_entry_px, status, created_at_ms
) VALUES (?, 'ETH-C', 'call', 1, 10, 'pending', 1)""",
(gid,),
)
db._conn.execute(
"""INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms)
VALUES (?, 'pnl', 1.5, 10001.5, 't', 2)""",
(gid,),
)
db._conn.commit()
def test_delete_closed_group(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "del.db")
monkeypatch.setattr("app.api.trades.get_db", lambda: db)
_seed_closed(db)
out = asyncio.run(delete_group("G-DEL-1", _user="t"))
assert out["ok"] is True
assert db.fetchone("SELECT COUNT(*) AS c FROM groups")["c"] == 0
assert db.fetchone("SELECT COUNT(*) AS c FROM fills")["c"] == 0
assert db.fetchone("SELECT COUNT(*) AS c FROM residual_options")["c"] == 0
assert db.fetchone("SELECT COUNT(*) AS c FROM ledger_entries")["c"] == 0
db.close()
def test_delete_open_group_refused(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "del_open.db")
monkeypatch.setattr("app.api.trades.get_db", lambda: db)
with db._lock:
db._conn.execute(
"""INSERT INTO groups(group_id, status, open_at_ms)
VALUES ('G-OPEN', 'open', 1)"""
)
db._conn.commit()
with pytest.raises(HTTPException) as ei:
asyncio.run(delete_group("G-OPEN", _user="t"))
assert ei.value.status_code == 409
assert db.fetchone("SELECT COUNT(*) AS c FROM groups")["c"] == 1
db.close()
def test_delete_active_position_refused(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "del_pos.db")
monkeypatch.setattr("app.api.trades.get_db", lambda: db)
_seed_closed(db, "G-POS")
with db._lock:
# status closed but still referenced (edge)
db._conn.execute(
"UPDATE positions SET group_id=?, status='open' WHERE id=1",
("G-POS",),
)
db._conn.commit()
with pytest.raises(HTTPException) as ei:
asyncio.run(delete_group("G-POS", _user="t"))
assert ei.value.status_code == 409
assert db.fetchone("SELECT COUNT(*) AS c FROM groups")["c"] == 1
db.close()
+175
View File
@@ -0,0 +1,175 @@
"""LIVE 交易所 SoT:平仓数量与到期路径。"""
from __future__ import annotations
from types import SimpleNamespace
from app.live.reconcile import perp_close_contracts_okx, perp_close_qty_eth_binance
class _FakeOkx:
def __init__(self, sz) -> None:
self._sz = sz
def get_perp_pos_sz(self, _inst, pos_side=None):
return self._sz
class _FakeBn:
def __init__(self, sz) -> None:
self._sz = sz
def get_perp_pos_sz(self, _inst, position_side=None):
return self._sz
def test_perp_close_okx_confirmed_flat_never_uses_db() -> None:
# 交易所已空:即使 allow_db_fallback=True 也返回 0
assert (
perp_close_contracts_okx(
_FakeOkx(0.0),
perp_inst="ETH-USDT-SWAP",
perp_side="short",
perp_qty_eth=8.0,
ct_val=0.01,
allow_db_fallback=True,
)
== 0
)
def test_perp_close_okx_unknown_fail_closed_by_default() -> None:
assert (
perp_close_contracts_okx(
_FakeOkx(None),
perp_inst="ETH-USDT-SWAP",
perp_side="long",
perp_qty_eth=8.0,
ct_val=0.01,
)
is None
)
def test_perp_close_okx_uses_exchange_size() -> None:
assert (
perp_close_contracts_okx(
_FakeOkx(123.0),
perp_inst="ETH-USDT-SWAP",
perp_side="long",
perp_qty_eth=1.0,
ct_val=0.01,
)
== 123
)
def test_perp_close_bn_confirmed_flat_never_uses_db() -> None:
assert (
perp_close_qty_eth_binance(
_FakeBn(0.0),
perp_inst="ETHUSDT",
perp_side="short",
perp_qty_eth=8.0,
allow_db_fallback=True,
)
== 0.0
)
def test_matcher_live_residual_no_local_invent(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("MODE", "LIVE")
from app.models.db import Database
from app.sim.matcher import Matcher
db = Database(tmp_path / "sot.db")
m = Matcher(db)
monkeypatch.setattr(m, "_try_exchange_flatten_residual", lambda *a, **k: None)
row = {
"group_id": "G1",
"option_inst_id": "ETH-OPT",
"option_side": "call",
"option_qty_eth": 2.0,
"option_qty_contracts": 200.0,
"strike": 2000.0,
"initial_premium": 10.0,
}
assert m._settle_one_residual(row, now_ms=1) is None
db.close()
def test_okx_expiry_skips_option_order(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("MODE", "LIVE")
from app.live.executor import OkxLiveExecutor
from app.models.db import Database
db = Database(tmp_path / "exp.db")
ex = OkxLiveExecutor(db)
monkeypatch.setattr(ex, "_guard_live", lambda: None)
with db._lock:
db._conn.execute(
"""UPDATE positions SET
group_id=?, perp_side=?, perp_qty_eth=?, perp_entry_px=?,
option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?,
option_entry_px=?, status='open' WHERE id=1""",
(
"G-exp",
"short",
4.0,
2000.0,
"ETH-OPT",
"call",
1.0,
100.0,
20.0,
),
)
db._conn.execute(
"""INSERT INTO groups(group_id, status, option_inst_id, perp_inst_id, strike, open_at_ms)
VALUES (?,?,?,?,?,?)""",
("G-exp", "open", "ETH-OPT", "ETH-USDT-SWAP", 1900.0, 1),
)
db._conn.commit()
placed = {"opt": 0, "perp": 0}
class _C:
def get_ct_val(self, *_a, **_k):
return 0.01
def get_perp_pos_sz(self, *_a, **_k):
return 400.0
def place_market(self, *, inst_id, side, sz, **_k):
if "OPT" in inst_id or "-C" in inst_id or "-P" in inst_id:
placed["opt"] += 1
else:
placed["perp"] += 1
return SimpleNamespace(avg_px=2010.0, fee=0.1, sz=float(sz))
monkeypatch.setattr(ex, "_client", lambda: _C())
monkeypatch.setattr(
"app.live.executor.exchange_option_abs_size", lambda *_a, **_k: 0.0
)
monkeypatch.setattr(ex, "_group_strike", lambda *_a, **_k: 1900.0)
monkeypatch.setattr(ex, "_close_spot_px", lambda *_a, **_k: 1950.0)
monkeypatch.setattr(
"app.live.executor.get_session",
lambda: SimpleNamespace(snapshot=lambda: {}),
)
monkeypatch.setattr(
"app.live.executor.resolve_perp_inst_id",
lambda *_a, **_k: "ETH-USDT-SWAP",
)
monkeypatch.setattr(
"app.live.live_pnl.reconcile_closed_group_pnl",
lambda **_k: 0.0,
)
r = ex.close_group(reason="expiry", bypass_liquidity=True)
assert r.ok, r.detail
assert placed["opt"] == 0
assert placed["perp"] == 1
st = db.fetchone("SELECT status FROM positions WHERE id=1")
assert str(st["status"]) == "flat"
db.close()
+57
View File
@@ -0,0 +1,57 @@
"""持仓锁定出场目标。"""
from __future__ import annotations
from app.strategy.exits import check_exits, resolve_exit_target
def test_locked_premium_mode_keeps_premium_reason() -> None:
"""锁定目标金额后,平仓原因仍应按权利金倍数模式标记,而非写死 fixed_usdt。"""
d = check_exits(
net_pnl=25.0,
exit_mode="premium_multiple",
net_profit_target=15.0,
premium_exit_multiple=1.0,
initial_premium=20.0,
locked_exit_target=20.0,
)
assert d.should_close is True
assert d.target == 20.0
assert d.reason == "premium_multiple"
def test_locked_target_ignores_setting_drift() -> None:
d = check_exits(
net_pnl=20.0,
exit_mode="fixed_usdt",
net_profit_target=50.0, # 设置已被改大
premium_exit_multiple=1.0,
initial_premium=10.0,
locked_exit_target=15.0, # 开仓锁定
)
assert d.should_close is True
assert d.target == 15.0
def test_locked_target_not_yet() -> None:
d = check_exits(
net_pnl=10.0,
exit_mode="fixed_usdt",
net_profit_target=5.0,
premium_exit_multiple=1.0,
initial_premium=10.0,
locked_exit_target=15.0,
)
assert d.should_close is False
assert d.target == 15.0
def test_resolve_premium_still_works_without_lock() -> None:
t, mode = resolve_exit_target(
exit_mode="premium_multiple",
net_profit_target=15.0,
premium_exit_multiple=2.0,
initial_premium=10.0,
)
assert t == 20.0
assert mode == "premium_multiple"
+48
View File
@@ -0,0 +1,48 @@
"""Fleet token / ticket unit tests."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from app.api.fleet import (
clear_fleet_token,
consume_login_ticket,
create_login_ticket,
fleet_token_configured,
require_fleet_token,
set_fleet_token,
)
from app.models.db import Database, set_db
@pytest.fixture()
def db(tmp_path, monkeypatch):
monkeypatch.setenv("MODE", "SIM")
path = tmp_path / "t.db"
d = Database(path)
set_db(d)
yield d
d.close()
set_db(None)
def test_fleet_token_hash_and_auth(db):
assert not fleet_token_configured(db)
set_fleet_token("test-fleet-token-32chars-xxxx", db)
assert fleet_token_configured(db)
assert require_fleet_token(x_fleet_token="test-fleet-token-32chars-xxxx")
with pytest.raises(HTTPException) as ei:
require_fleet_token(x_fleet_token="wrong-token-xxxxxxxxxx")
assert ei.value.status_code == 401
clear_fleet_token(db)
with pytest.raises(HTTPException):
require_fleet_token(x_fleet_token="test-fleet-token-32chars-xxxx")
def test_login_ticket_once(db):
t, ttl = create_login_ticket("admin")
assert ttl >= 30
assert consume_login_ticket(t) == "admin"
with pytest.raises(HTTPException):
consume_login_ticket(t)
+47
View File
@@ -0,0 +1,47 @@
"""资金门 fail-closed 测试。"""
from __future__ import annotations
from app.strategy.open_capacity import funds_gate_blocks
def test_funds_gate_blocks_none() -> None:
blocked, msg = funds_gate_blocks(
{
"perp_can_open": True,
"option_can_open": None,
"perp_label": "a",
"option_label": "b",
}
)
assert blocked is True
assert "未知" in msg or "拒绝" in msg
def test_funds_gate_blocks_false() -> None:
blocked, msg = funds_gate_blocks(
{
"perp_can_open": True,
"option_can_open": False,
"perp_label": "永续可开",
"option_label": "期权不可开",
"perp_need_usdt": 1,
"perp_have_usdt": 10,
"option_need_usdc": 100,
"option_have_usdc": 1,
}
)
assert blocked is True
assert "不可开" in msg or "不足" in msg
def test_funds_gate_ok_only_when_both_true() -> None:
blocked, _ = funds_gate_blocks(
{
"perp_can_open": True,
"option_can_open": True,
"perp_label": "永续可开",
"option_label": "期权可开",
}
)
assert blocked is False
+85
View File
@@ -0,0 +1,85 @@
"""顶栏资金摘要:总交易/胜率/盈亏比按历史全部已平组。"""
from __future__ import annotations
import asyncio
from datetime import datetime
from types import SimpleNamespace
from zoneinfo import ZoneInfo
from app.api import funds as funds_api
from app.models.db import Database, set_db
from app.strategy.engine import set_engine
def _insert_closed(db: Database, *, group_id: str, pnl: float) -> None:
now = 1_700_000_000_000
with db._lock:
db._conn.execute(
"""INSERT INTO groups(
group_id, status, bias, option_side, perp_side, option_inst_id,
strike, expiry_ymd, initial_premium, open_at_ms, close_at_ms,
close_reason, realized_pnl, fees, slip_cost
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"closed",
"test",
"call",
"short",
"ETH-USD_UM-260801-2000-C",
2000.0,
"260801",
100.0,
now,
now + 1000,
"manual",
pnl,
1.0,
0.0,
),
)
db._conn.commit()
def test_all_time_stats_consistent(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "funds_sum.db")
set_db(db)
try:
# 历史 3 胜 1 负 → 75%;另有当日 1 笔亏损 → 合计 5 笔、胜率 60%
_insert_closed(db, group_id="G-20260101-01", pnl=10.0)
_insert_closed(db, group_id="G-20260101-02", pnl=20.0)
_insert_closed(db, group_id="G-20260101-03", pnl=5.0)
_insert_closed(db, group_id="G-20260101-04", pnl=-10.0)
_insert_closed(db, group_id="G-20260802-01", pnl=-8.0)
class _FakeDT:
@staticmethod
def now(tz=None):
from datetime import timezone
if tz is timezone.utc:
return datetime(2026, 8, 2, 4, 0, tzinfo=timezone.utc)
return datetime(2026, 8, 2, 12, 0, tzinfo=tz or ZoneInfo("Asia/Shanghai"))
monkeypatch.setattr(funds_api, "datetime", _FakeDT)
set_engine(
SimpleNamespace(
state=lambda: {
"exchange": "okx",
"position": {"status": "flat"},
}
) # type: ignore[arg-type]
)
body = asyncio.run(funds_api.funds_summary(_user="admin"))
assert body["ok"] is True
assert body["trading_day"] == "2026-08-02"
assert body["total_trades"] == 5
assert abs(body["win_rate"] - 0.6) < 1e-9
assert body["profit_loss_ratio"] is not None
finally:
set_engine(None)
set_db(None)
db.close()
+94
View File
@@ -0,0 +1,94 @@
"""hold_timing unit tests."""
from backend.app.api.hold_timing import hold_timing
def test_hold_target_perp_only_uses_perp_close():
g = {
"open_at_ms": 1_000,
"close_at_ms": 9_000, # later residual settle would have overwritten
"status": "closed",
"close_reason": "target_perp_only",
}
fills = [
{"leg": "option", "action": "open", "ts_ms": 1_000},
{"leg": "perp", "action": "open", "ts_ms": 1_100},
{"leg": "perp", "action": "close", "ts_ms": 5_000},
{"leg": "option", "action": "close", "ts_ms": 9_000},
]
h = hold_timing(g, fills)
assert h["hold_open_at_ms"] == 1_000
assert h["hold_close_at_ms"] == 5_000
assert h["hold_ms"] == 4_000
assert h["hold_basis"] == "perp"
def test_hold_option_residual_uses_perp():
g = {
"open_at_ms": 100,
"close_at_ms": 500,
"status": "option_residual",
"close_reason": "target_perp_only",
}
fills = [
{"leg": "perp", "action": "close", "ts_ms": 500},
]
h = hold_timing(g, fills)
assert h["hold_close_at_ms"] == 500
assert h["hold_ms"] == 400
def test_hold_dual_leg_uses_group_close():
g = {
"open_at_ms": 100,
"close_at_ms": 800,
"status": "closed",
"close_reason": "fixed_usdt",
}
fills = [
{"leg": "option", "action": "close", "ts_ms": 790},
{"leg": "perp", "action": "close", "ts_ms": 800},
]
h = hold_timing(g, fills)
assert h["hold_close_at_ms"] == 800
assert h["hold_ms"] == 700
assert h["hold_basis"] == "group"
def test_hold_open_no_close():
g = {"open_at_ms": 100, "close_at_ms": None, "status": "open", "close_reason": None}
h = hold_timing(g, [])
assert h["hold_close_at_ms"] is None
assert h["hold_ms"] is None
assert h["hold_basis"] == "open"
def test_hold_sqlite_row_like_without_get():
"""sqlite3.Row 无 .get,需能转 dict。"""
class Row:
def __init__(self, d):
self._d = d
def keys(self):
return self._d.keys()
def __getitem__(self, k):
return self._d[k]
def __iter__(self):
return iter(self._d)
g = Row(
{
"open_at_ms": 100,
"close_at_ms": None,
"status": "option_residual",
"close_reason": "target_perp_only",
}
)
fills = [Row({"leg": "perp", "action": "close", "ts_ms": 400})]
h = hold_timing(g, fills)
assert h["hold_close_at_ms"] == 400
assert h["hold_ms"] == 300
+37
View File
@@ -27,6 +27,43 @@ def test_pick_atm_strike() -> None:
assert pick_atm_strike([3400, 3500, 3600], 3510) == 3500
def test_pick_itm_or_atm_strike() -> None:
from app.strategy.selection import is_itm_or_atm, pick_itm_or_atm_strike
strikes = [3400, 3500, 3600]
# CallK≤S,现价 3510 → 3500(平值侧最近)
assert pick_itm_or_atm_strike(strikes, 3510, option_side="call") == 3500
# PutK≥S,现价 3510 → 3600(实值最近;无 3510 档)
assert pick_itm_or_atm_strike(strikes, 3510, option_side="put") == 3600
# Put 现价正好 3500 → 平值 3500
assert pick_itm_or_atm_strike(strikes, 3500, option_side="put") == 3500
assert is_itm_or_atm(option_side="call", strike=3500, mark_px=3510)
assert not is_itm_or_atm(option_side="call", strike=3600, mark_px=3510)
assert is_itm_or_atm(option_side="put", strike=3600, mark_px=3510)
assert not is_itm_or_atm(option_side="put", strike=3400, mark_px=3510)
def test_select_option_pair_itm_put() -> None:
rows = [
{"instId": "ETH-USD_UM-260725-3490-C", "state": "live"},
{"instId": "ETH-USD_UM-260725-3490-P", "state": "live"},
{"instId": "ETH-USD_UM-260725-3500-C", "state": "live"},
{"instId": "ETH-USD_UM-260725-3500-P", "state": "live"},
{"instId": "ETH-USD_UM-260725-3510-C", "state": "live"},
{"instId": "ETH-USD_UM-260725-3510-P", "state": "live"},
]
# 标的 3502Put 实/平 → 3510(≥3502 最近)
pair = select_option_pair(rows, mark_px=3502, expiry_ymd="260725", option_side="put")
assert pair is not None
assert pair.strike == 3510
# Call 实/平 → 3500(≤3502 最近)
pair_c = select_option_pair(
rows, mark_px=3502, expiry_ymd="260725", option_side="call"
)
assert pair_c is not None
assert pair_c.strike == 3500
def test_next_session_expiry_before_open() -> None:
now = datetime(2026, 7, 24, 15, 0, tzinfo=_SH)
assert next_session_expiry_ymd(now) == "260724"
+80
View File
@@ -0,0 +1,80 @@
"""LIVE 金额与组净盈亏口径。"""
from __future__ import annotations
from app.live.live_pnl import enrich_live_unrealized, group_paid_fees_usdt
from app.live.money import abs_fee_usdt, to_usdt
def test_to_usdt_one_to_one() -> None:
assert to_usdt(12.5, "USDC") == 12.5
assert to_usdt(-3.0, "USDT") == -3.0
assert abs_fee_usdt(-0.2, "USDC") == 0.2
def test_group_paid_fees(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from app.models.db import Database
db = Database(tmp_path / "f.db")
with db._lock:
db._conn.execute(
"""INSERT INTO groups(group_id, status, open_at_ms, fees)
VALUES ('G1','open',1,0)"""
)
db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth,
fill_px, fee, slip, notional, ts_ms)
VALUES ('G1','option','open','long','OPT',2,10,0.5,0,20,1)"""
)
db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth,
fill_px, fee, slip, notional, ts_ms)
VALUES ('G1','perp','open','short','SWAP',1,100,-0.3,0,100,2)"""
)
db._conn.commit()
assert group_paid_fees_usdt(db, "G1") == 0.8
db.close()
class _FakeClient:
def get_perp_upl_usdt(self, *a, **k):
return 8.0
def get_funding_usdt(self, *a, **k):
return -1.5
def test_enrich_live_unrealized_keeps_book_net_plus_funding() -> None:
base = {
"has_position": True,
"group_id": "G1",
"perp_side": "short",
"perp_upl": 1.0,
"option_upl": 5.0,
"est_close_fees": 0.8,
"net_pnl": -3.9,
}
class _Db:
def fetchall(self, *a, **k):
return [{"fee": 0.4}, {"fee": 0.1}]
out = enrich_live_unrealized(
base=base,
db=_Db(),
client=_FakeClient(),
exchange="okx",
perp_inst_id="ETH-USDT-SWAP",
perp_side="short",
open_at_ms=1,
)
assert out["perp_upl"] == 1.0 # 盘口可平
assert out["perp_upl_exchange"] == 8.0
assert out["option_upl"] == 5.0
assert out["fees_paid"] == 0.5
assert out["funding_usdt"] == -1.5
assert out["est_close_fees"] == 0.8
# 盯盘净利 = 盘口净利 + 资金费,不用标记 UPL 覆盖
assert abs(out["net_pnl"] - (-3.9 - 1.5)) < 1e-9
assert out["pnl_source"] == "live_book_plus_funding"
+59
View File
@@ -0,0 +1,59 @@
"""实盘限流 / 退避单测。"""
from __future__ import annotations
import time
from app.live.rate_limit import (
LiveRetryGate,
RateLimitError,
TradeThrottle,
get_throttle,
is_rate_limit_error,
)
def test_is_rate_limit_error() -> None:
assert is_rate_limit_error("HTTP 429 too many")
assert is_rate_limit_error("binance eapi cooldown 12s")
assert is_rate_limit_error(RateLimitError("x", retry_after=5))
assert not is_rate_limit_error("保证金不足")
def test_trade_throttle_cooldown() -> None:
t = TradeThrottle("ut_throttle", min_interval_sec=0.01, cooldown_429_sec=0.3)
t.before_request()
t.mark_http(429)
try:
t.before_request()
assert False, "expected RateLimitError"
except RateLimitError as e:
assert e.retry_after > 0
time.sleep(0.35)
t.before_request() # 冷却结束后可继续
def test_get_throttle_singleton() -> None:
a = get_throttle("ut_shared_x", min_interval_sec=0.01)
b = get_throttle("ut_shared_x")
assert a is b
def test_live_retry_gate_backoff() -> None:
g = LiveRetryGate(base_sec=0.05, max_sec=0.2, rate_limit_min_sec=0.1, trip_after=100)
assert g.allow("k")[0] is True
d1 = g.fail("k")
assert d1 >= 0.05
ok, left = g.allow("k")
assert ok is False
assert left > 0
time.sleep(d1 + 0.02)
assert g.allow("k")[0] is True
g.success("k")
assert g.fails("k") == 0
def test_live_retry_gate_rate_limited_longer() -> None:
g = LiveRetryGate(base_sec=0.01, rate_limit_min_sec=0.2)
d = g.fail("rl", rate_limited=True)
assert d >= 0.2
+70
View File
@@ -0,0 +1,70 @@
"""实盘防卡状态:half_open / option_closed_perp_pending。"""
from __future__ import annotations
from app.sim.ledger import Ledger
from app.sim.matcher import BLOCKING_STATUSES, Matcher
def test_blocking_statuses_include_repair_states() -> None:
assert "half_open" in BLOCKING_STATUSES
assert "option_closed_perp_pending" in BLOCKING_STATUSES
assert "open" in BLOCKING_STATUSES
assert "opening" in BLOCKING_STATUSES
def test_has_open_position_blocks_half_open(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from app.models.db import Database
db = Database(tmp_path / "t.db")
m = Matcher(db)
assert m.has_open_position() is False
with db._lock:
db._conn.execute(
"""UPDATE positions SET
group_id=?, option_inst_id=?, option_side=?, option_qty_eth=?,
option_qty_contracts=?, option_entry_px=?, status=?
WHERE id=1""",
("G-test", "ETH-OPT", "call", 2.0, 200.0, 10.0, "half_open"),
)
db._conn.commit()
assert m.has_open_position() is True
assert m.position_status() == "half_open"
with db._lock:
db._conn.execute(
"UPDATE positions SET status='option_closed_perp_pending' WHERE id=1"
)
db._conn.commit()
assert m.has_open_position() is True
with db._lock:
db._conn.execute(
"""UPDATE positions SET
group_id=NULL, option_inst_id=NULL, status='flat' WHERE id=1"""
)
db._conn.commit()
assert m.has_open_position() is False
db.close()
def test_ledger_allow_negative(tmp_path) -> None:
from app.models.db import Database
db = Database(tmp_path / "l.db")
ledger = Ledger(db)
# 掏空
snap = ledger.snapshot()
ledger.apply_cash(-snap["available"], kind="drain", note="drain")
try:
ledger.apply_cash(-1.0, kind="fail", note="should fail")
assert False, "expected RuntimeError"
except RuntimeError:
pass
# LIVE 镜像允许透支
bal = ledger.apply_cash(-1.0, kind="live", note="ok", allow_negative=True)
assert bal < 0
db.close()
+171
View File
@@ -0,0 +1,171 @@
"""P0 实盘 SoTclosing 状态机、紧急期期、recover Put、到期无 intrinsic。"""
from __future__ import annotations
from types import SimpleNamespace
from app.sim.matcher import BLOCKING_STATUSES
def test_closing_in_blocking_statuses() -> None:
assert "closing" in BLOCKING_STATUSES
def test_has_open_position_blocks_closing(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from app.models.db import Database
from app.sim.matcher import Matcher
db = Database(tmp_path / "c.db")
m = Matcher(db)
with db._lock:
db._conn.execute(
"""UPDATE positions SET group_id=?, option_inst_id=?, option2_inst_id=?,
status='closing', hedge_mode='option_option' WHERE id=1""",
("G1", "C", "P"),
)
db._conn.commit()
assert m.has_open_position() is True
db.close()
def test_recover_opening_refuses_orphan_put(monkeypatch) -> None:
monkeypatch.setenv("MODE", "LIVE")
from app.config import get_settings
get_settings.cache_clear()
import app.live.reconcile as rec
from app.live.reconcile import recover_stuck_opening
class _Ex:
def current_position(self):
return {
"status": "opening",
"group_id": "G-oo",
"option_inst_id": "ETH-CALL",
"perp_side": "oo_put:ETH-PUT",
"option_qty_eth": 1,
"option_qty_contracts": 100,
}
@property
def db(self):
return SimpleNamespace()
def _client(self):
return object()
def fake_opt(_c, inst):
if "PUT" in inst:
return 5.0
return 0.0
monkeypatch.setattr(rec, "_executor_client_and_exchange", lambda _e: (object(), "okx"))
monkeypatch.setattr(rec, "exchange_option_abs_size", fake_opt)
monkeypatch.setattr(rec, "exchange_perp_abs_size", lambda *_a, **_k: 0.0)
monkeypatch.setattr(
rec, "resolve_perp_inst_id", lambda *_a, **_k: "ETH-USDT-SWAP"
)
r = recover_stuck_opening(_Ex())
assert r is not None
assert r.ok is False
assert "Put" in (r.detail or "")
get_settings.cache_clear()
def test_expiry_fill_zero_not_intrinsic(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("MODE", "LIVE")
from app.config import get_settings
get_settings.cache_clear()
from app.live.executor import OkxLiveExecutor
from app.models.db import Database
db = Database(tmp_path / "e.db")
ex = OkxLiveExecutor(db)
monkeypatch.setattr(ex, "_guard_live", lambda: None)
with db._lock:
db._conn.execute(
"""UPDATE positions SET
group_id=?, perp_side=?, perp_qty_eth=?, perp_entry_px=?,
option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?,
option_entry_px=?, status='open' WHERE id=1""",
("G-e", "short", 4.0, 2000.0, "ETH-OPT", "call", 1.0, 100.0, 20.0),
)
db._conn.execute(
"""INSERT INTO groups(group_id, status, option_inst_id, perp_inst_id, strike, open_at_ms)
VALUES (?,?,?,?,?,?)""",
("G-e", "open", "ETH-OPT", "ETH-USDT-SWAP", 1900.0, 1),
)
db._conn.commit()
class _C:
def get_ct_val(self, *_a, **_k):
return 0.01
def get_perp_pos_sz(self, *_a, **_k):
return 400.0
def place_market(self, *, inst_id, side, sz, **_k):
return SimpleNamespace(avg_px=2010.0, fee=0.1, sz=float(sz))
monkeypatch.setattr(ex, "_client", lambda: _C())
monkeypatch.setattr(
"app.live.executor.exchange_option_abs_size", lambda *_a, **_k: 2.0
)
monkeypatch.setattr(ex, "_group_strike", lambda *_a, **_k: 1900.0)
monkeypatch.setattr(ex, "_close_spot_px", lambda *_a, **_k: 1950.0)
monkeypatch.setattr(
"app.live.executor.get_session",
lambda: SimpleNamespace(snapshot=lambda: {}),
)
monkeypatch.setattr(
"app.live.executor.resolve_perp_inst_id",
lambda *_a, **_k: "ETH-USDT-SWAP",
)
monkeypatch.setattr(
"app.live.live_pnl.reconcile_closed_group_pnl",
lambda **_k: 0.0,
)
r = ex.close_group(reason="expiry", bypass_liquidity=True)
assert r.ok, r.detail
row = db.fetchone(
"SELECT fill_px, notional FROM fills WHERE group_id=? AND leg='option' AND action='close'",
("G-e",),
)
assert row is not None
assert float(row["fill_px"]) == 0.0
assert float(row["notional"] or 0) == 0.0
db.close()
get_settings.cache_clear()
def test_binance_fetch_balances_maps_usdt(monkeypatch) -> None:
from app.live.binance_trade import BinanceTradeClient
c = BinanceTradeClient.__new__(BinanceTradeClient)
def _signed(client, method, path, params=None):
if "fapi" in str(getattr(client, "base_url", "")) or path.startswith("/fapi"):
return [{"asset": "USDT", "availableBalance": "100.5"}]
if "marginAccount" in path:
return {"asset": [{"asset": "USDT", "available": "80"}]}
return []
c._signed = _signed # type: ignore
c._fapi = SimpleNamespace(base_url="https://fapi")
c._eapi = SimpleNamespace(base_url="https://eapi")
# simpler: patch by path
def signed2(_client, method, path, params=None):
if path == "/fapi/v2/balance":
return [{"asset": "USDT", "availableBalance": "100.5"}]
if path == "/eapi/v1/marginAccount":
return {"asset": [{"asset": "USDT", "available": "80"}]}
return []
c._signed = signed2 # type: ignore
bal = BinanceTradeClient.fetch_balances(c)
assert bal["trading_usdt"] == 100.5
assert bal["trading_usdc"] == 100.5 # mapped from USDT when no USDC
+67
View File
@@ -0,0 +1,67 @@
"""期期成交盈亏汇总:须计入 option2(Put)腿。"""
from __future__ import annotations
from app.sim.pnl import summarize_fills_pnl
def test_summarize_oo_both_legs() -> None:
fills = [
{
"leg": "option",
"action": "open",
"side": "long",
"fill_px": 12.6,
"qty_eth": 3.5,
"fee": 0.0221,
"slip": 0,
},
{
"leg": "option2",
"action": "open",
"side": "long",
"fill_px": 9.0,
"qty_eth": 5.0,
"fee": 0.0225,
"slip": 0,
},
{
"leg": "option",
"action": "close",
"side": "sell",
"fill_px": 0.0,
"qty_eth": 3.5,
"fee": 0.0,
"slip": 0,
},
{
"leg": "option2",
"action": "close",
"side": "sell",
"fill_px": 0.0,
"qty_eth": 5.0,
"fee": 0.0,
"slip": 0,
},
]
s = summarize_fills_pnl(fills)
assert s["option_pnl"] == -12.6 * 3.5
assert s["option2_pnl"] == -9.0 * 5.0
assert s["perp_pnl"] is None
assert abs(float(s["fees_option"] or 0) - 0.0446) < 1e-9
assert abs(float(s["gross_pnl"] or 0) - (-44.1 - 45.0)) < 1e-9
assert abs(float(s["net_pnl"] or 0) - (-89.1 - 0.0446)) < 1e-9
def test_summarize_perp_option_unchanged() -> None:
fills = [
{"leg": "option", "action": "open", "fill_px": 10, "qty_eth": 2, "fee": 0.1, "slip": 0},
{"leg": "perp", "action": "open", "side": "short", "fill_px": 100, "qty_eth": 1, "fee": 0.2, "slip": 0},
{"leg": "option", "action": "close", "fill_px": 12, "qty_eth": 2, "fee": 0.1, "slip": 0},
{"leg": "perp", "action": "close", "side": "short", "fill_px": 98, "qty_eth": 1, "fee": 0.2, "slip": 0},
]
s = summarize_fills_pnl(fills)
assert s["option_pnl"] == 4.0
assert s["perp_pnl"] == 2.0
assert s["option2_pnl"] is None
assert abs(float(s["net_pnl"] or 0) - (6.0 - 0.6)) < 1e-9
+192
View File
@@ -0,0 +1,192 @@
"""期期对冲:选约 / 定仓纯函数测试。"""
from __future__ import annotations
from app.exchange.candles import AmplitudeHL
from app.strategy.oo_selection import (
pick_otm_call_strike,
pick_otm_put_strike,
select_oo_pair,
)
from app.strategy.risk_sizing import compute_oo_sizing
def test_otm_strikes_near_amplitude() -> None:
strikes = [1800.0, 1850.0, 1900.0, 1950.0, 2000.0, 2050.0, 2100.0]
assert pick_otm_call_strike(strikes, spot=1950, high=2040) == 2050.0
assert pick_otm_put_strike(strikes, spot=1950, low=1860) == 1850.0
def test_otm_strikes_reject_beyond_1pct() -> None:
# 高点 2000,最近虚值 Call 仅 2100(偏离 5%)→ 拒绝
strikes = [1900.0, 1950.0, 2100.0]
assert pick_otm_call_strike(strikes, spot=1950, high=2000, max_dev_pct=1.0) is None
# 低点 1900,最近虚值 Put 仅 1800(偏离 ~5.3%)→ 拒绝
assert (
pick_otm_put_strike(
[1800.0, 1950.0, 2000.0], spot=1950, low=1900, max_dev_pct=1.0
)
is None
)
# 高点 2095Call 2100 偏离约 0.24% → 通过
assert (
pick_otm_call_strike(strikes, spot=1950, high=2095, max_dev_pct=1.0) == 2100.0
)
def test_select_oo_pair_same_expiry(tmp_path=None) -> None:
contracts = []
for k in (1900, 2000, 2100):
for side, letter in (("call", "C"), ("put", "P")):
contracts.append(
{
"expiry_ymd": "260810",
"expiry_ms": 1_786_320_000_000,
"strike": float(k),
"side": letter,
"inst_id": f"ETH-{k}-{letter}",
}
)
picked = select_oo_pair(
contracts,
spot=2000.0,
high=2105.0,
low=1890.0,
min_hours=1.0,
max_dev_pct=1.0,
)
assert picked is not None
ymd, _ems, ck, pk, call_i, put_i = picked
assert ymd == "260810"
assert ck == 2100.0
assert pk == 1900.0
assert "C" in call_i and "P" in put_i
def test_compute_oo_sizing_1_1_and_reward() -> None:
r = compute_oo_sizing(
budget=100.0,
call_ask=5.0,
put_ask=10.0, # Put 更贵 → 数量更少
fee_rate=0.0,
index_px=2000.0,
cushion=1.0, # 测纯平分,不预留
reward_ratio=2.0,
)
assert r.ok
# 各 50UCall 50/5=10Put 50/10=5
assert r.call_qty_eth == 10.0
assert r.put_qty_eth == 5.0
assert abs(float(r.net_profit_target or 0) - 200.0) < 1e-9
assert abs(float(r.call_premium or 0) - 50.0) < 1e-6
assert abs(float(r.put_premium or 0) - 50.0) < 1e-6
def test_compute_oo_sizing_equal_asks() -> None:
r = compute_oo_sizing(
budget=100.0,
call_ask=5.0,
put_ask=5.0,
fee_rate=0.0,
index_px=2000.0,
cushion=0.92,
reward_ratio=2.0,
)
assert r.ok
# 各腿 46U / 5 = 9.2
assert r.call_qty_eth == 9.2
assert r.put_qty_eth == 9.2
assert abs(float(r.net_profit_target or 0) - 200.0) < 1e-9
assert float(r.call_premium or 0) + float(r.put_premium or 0) <= 92.0 + 1e-6
def test_amplitude_range_pct() -> None:
a = AmplitudeHL(high=2030, low=1970, mid=2000, hours=12, bar_count=12)
assert abs(a.range_pct - 3.0) < 1e-9
def test_preview_oo_exit_budget_times_ratio(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from app.models.db import Database
from app.sim.ledger import Ledger
from app.strategy.risk_sizing import _preview_oo_sizing
db = Database(tmp_path / "oo_prev.db")
db.set_setting("hedge_mode", "option_option")
db.set_setting("sizing_mode", "risk_based")
db.set_setting("risk_loss_mode", "percent")
db.set_setting("risk_loss_pct", "1")
db.set_setting("risk_capital_source", "manual")
db.set_setting("risk_manual_capital_usdt", "10000")
db.set_setting("oo_reward_ratio", "2")
monkeypatch.setattr("app.strategy.risk_sizing.get_db", lambda: db)
# 无 session 盘口时仍应给出 预算×比
out = _preview_oo_sizing(db, Ledger(db), {"hedge_mode": "option_option"})
assert out.get("ok") is True
assert abs(float(out.get("budget") or 0) - 100.0) < 1e-6
assert abs(float(out.get("net_profit_target") or 0) - 200.0) < 1e-6
db.close()
def test_amplitude_max_gate() -> None:
from app.strategy.oo_selection import build_oo_pick_core
contracts = []
for k in (1900, 1975, 2000, 2025, 2100):
for side, letter in (("call", "C"), ("put", "P")):
contracts.append(
{
"expiry_ymd": "260810",
"expiry_ms": 1_786_320_000_000,
"strike": float(k),
"side": letter,
"inst_id": f"ETH-{k}-{letter}",
}
)
amp = AmplitudeHL(high=2030, low=1970, mid=2000, hours=12, bar_count=12)
# 3% > 上限 1.5% → 拒
assert (
build_oo_pick_core(
contracts=contracts,
spot=2000,
call_ask=5,
put_ask=5,
min_hours=1,
min_leverage=1,
amplitude_hours=12,
amplitude_pct=1.5,
amplitude=amp,
)
is None
)
# 3% ≤ 上限 3.5%,且 2025/1975 贴高低 ≤1% → 通过
ok = build_oo_pick_core(
contracts=contracts,
spot=2000,
call_ask=5,
put_ask=5,
min_hours=1,
min_leverage=1,
amplitude_hours=12,
amplitude_pct=3.5,
amplitude=amp,
max_dev_pct=1.0,
)
assert ok is not None
assert ok.call.strike == 2025.0
assert ok.put.strike == 1975.0
# 过滤关闭:振幅 3% 超过 1.5% 上限也可过
ok_off = build_oo_pick_core(
contracts=contracts,
spot=2000,
call_ask=5,
put_ask=5,
min_hours=1,
min_leverage=1,
amplitude_hours=12,
amplitude_pct=1.5,
amplitude=amp,
max_dev_pct=1.0,
amplitude_filter_enabled=False,
)
assert ok_off is not None
+132
View File
@@ -0,0 +1,132 @@
"""期期到期结算指数:虚值 Call fill=0 不得反推成行权价。"""
from __future__ import annotations
from app.api.trades import _infer_settle_index, _overlay_expiry_zero_fills
def test_otm_call_zero_fill_does_not_become_strike(monkeypatch) -> None:
g = {
"hedge_mode": "option_option",
"option_side": "call",
"option2_side": "put",
"option_inst_id": "ETH-USD-260811-1920-C", # OKX 样式:不走公开回退
"strike": 1920.0,
"strike2": 1890.0,
"settle_index_px": None,
"close_reason": "expiry",
"close_at_ms": 1,
}
fills = [
{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0},
{"leg": "option2", "action": "close", "fill_px": 0.0, "slip": 0},
]
monkeypatch.setattr(
"app.api.public_index.maybe_public_settle_index", lambda _g: None
)
assert _infer_settle_index(g, fills) is None
def test_itm_put_fill_infers_settle_near_1875() -> None:
g = {
"hedge_mode": "option_option",
"option_side": "call",
"option2_side": "put",
"strike": 1920.0,
"strike2": 1920.0,
"settle_index_px": None,
}
fills = [
{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0},
{"leg": "option2", "action": "close", "fill_px": 45.0, "slip": 0},
]
assert _infer_settle_index(g, fills) == 1875.0
def test_stored_settle_wins() -> None:
g = {
"option_side": "call",
"strike": 1920.0,
"settle_index_px": 1875.2,
}
fills = [{"leg": "option", "action": "close", "fill_px": 0.0, "slip": 0}]
assert _infer_settle_index(g, fills) == 1875.2
def test_public_fallback_and_overlay(monkeypatch) -> None:
g = {
"hedge_mode": "option_option",
"option_side": "call",
"option2_side": "put",
"option_inst_id": "ETH-USD_UM-260811-1940-C",
"strike": 1940.0,
"strike2": 1920.0,
"settle_index_px": None,
"close_reason": "expiry",
"close_at_ms": 1786435200000,
}
fills = [
{"leg": "option", "action": "close", "fill_px": 0.0, "qty_eth": 7, "slip": 0},
{"leg": "option2", "action": "close", "fill_px": 0.0, "qty_eth": 7, "slip": 0},
]
monkeypatch.setattr(
"app.api.public_index.maybe_public_settle_index", lambda _g: 1877.8
)
assert _infer_settle_index(g, fills) == 1877.8
view = _overlay_expiry_zero_fills(g, fills, 1877.8)
assert view[0]["fill_px"] == 0.0 # call OTM
assert abs(view[1]["fill_px"] - (1920 - 1877.8)) < 1e-9
def test_overlay_wrong_near_zero_put_fill() -> None:
"""账单误写成 0.2 时,应按内在价值 42.2 覆盖,Put 显示盈利。"""
g = {
"hedge_mode": "option_option",
"option_side": "call",
"option2_side": "put",
"strike": 1940.0,
"strike2": 1920.0,
"close_reason": "expiry",
}
fills = [
{
"leg": "option",
"action": "open",
"fill_px": 12.0,
"qty_eth": 7.5,
"fee": 0.045,
"slip": 0,
},
{
"leg": "option2",
"action": "open",
"fill_px": 12.8,
"qty_eth": 7.0,
"fee": 0.045,
"slip": 0,
},
{
"leg": "option",
"action": "close",
"fill_px": 0.0,
"qty_eth": 7.5,
"fee": 0,
"slip": 0,
},
{
"leg": "option2",
"action": "close",
"fill_px": 0.2,
"qty_eth": 7.0,
"fee": 0.0007,
"slip": 0,
},
]
from app.sim.pnl import summarize_fills_pnl
view = _overlay_expiry_zero_fills(g, fills, 1877.8)
assert abs(view[3]["fill_px"] - 42.2) < 1e-9
s = summarize_fills_pnl(view)
# Put: (42.2 - 12.8) * 7 = 205.8
assert abs(float(s["option2_pnl"] or 0) - 205.8) < 1e-6
assert float(s["option2_pnl"] or 0) > 0
+167
View File
@@ -0,0 +1,167 @@
"""期权交割账单镜像 + 期期开仓账本。"""
from __future__ import annotations
from types import SimpleNamespace
from app.live.option_settle import (
OptionSettlement,
fetch_option_settlement,
settlement_to_fill,
)
def test_settlement_to_fill_zero_when_not_found() -> None:
st = OptionSettlement(found=False)
assert settlement_to_fill(st, qty_eth=2.0) == (0.0, 0.0, 0.0)
def test_okx_settlement_from_exercise_bills() -> None:
class _C:
def get_option_settlement_bills(self, inst_id, *, begin_ms, end_ms=None):
return [
{
"subType": "170",
"type": "3",
"ts": str(begin_ms + 1000),
"balChg": "50.0",
"fee": "-0.5",
"ccy": "USDT",
}
]
st = fetch_option_settlement(
_C(),
exchange="okx",
option_inst_id="ETH-USDT-260808-2000-C",
qty_eth=2.0,
begin_ms=1_700_000_000_000,
)
assert st.found
assert abs(st.cash - 50.0) < 1e-9
px, fee, notional = settlement_to_fill(st, qty_eth=2.0)
assert fee == 0.5
assert abs(notional - 50.5) < 1e-9
assert abs(px - 25.25) < 1e-9
def test_bn_settlement_from_exercise_record() -> None:
class _C:
def get_option_exercise_records(self, symbol, *, begin_ms, end_ms=None):
return [
{
"symbol": symbol,
"amount": "40",
"fee": "0.2",
"currency": "USDT",
"quantity": "2",
}
]
st = fetch_option_settlement(
_C(),
exchange="binance",
option_inst_id="ETH-260808-2000-C",
qty_eth=2.0,
begin_ms=1,
)
assert st.found
assert abs(st.cash - 39.8) < 1e-9
px, fee, notional = settlement_to_fill(st, qty_eth=2.0)
assert abs(notional - 40.0) < 1e-9
assert abs(px - 20.0) < 1e-9
assert abs(fee - 0.2) < 1e-9
def test_okx_otm_expiry_bill_found_zero_cash() -> None:
class _C:
def get_option_settlement_bills(self, *_a, **_k):
return [
{
"subType": "172",
"type": "3",
"ts": "1700000001000",
"balChg": "0",
"fee": "0",
"ccy": "USDT",
}
]
st = fetch_option_settlement(
_C(),
exchange="okx",
option_inst_id="ETH-OPT",
qty_eth=1.0,
begin_ms=1_700_000_000_000,
)
assert st.found
assert st.cash == 0.0
assert settlement_to_fill(st, qty_eth=1.0)[0] == 0.0
def test_oo_open_applies_ledger_cash(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("MODE", "LIVE")
from app.config import get_settings
get_settings.cache_clear()
from app.live.executor import OkxLiveExecutor
from app.models.db import Database
db = Database(tmp_path / "oo.db")
ex = OkxLiveExecutor(db)
monkeypatch.setattr(ex, "_guard_live", lambda: None)
monkeypatch.setattr(
"app.live.executor.claim_open_slot", lambda _db: (True, "ok")
)
monkeypatch.setattr(
"app.live.executor.assert_safe_to_open_live", lambda _e: (True, "ok")
)
monkeypatch.setattr(
"app.live.executor.stamp_opening_intent", lambda *_a, **_k: None
)
monkeypatch.setattr(
"app.live.executor.release_open_slot_if_opening", lambda *_a, **_k: None
)
monkeypatch.setattr(ex, "_ct_mult", lambda *_a, **_k: 0.01)
class _C:
def place_market(self, *, inst_id, side, sz, **_k):
return SimpleNamespace(
avg_px=10.0 if "C" in inst_id or "call" in inst_id.lower() or inst_id.endswith("-C") or "CALL" in inst_id
else 8.0,
fee=0.1,
sz=float(sz),
)
# simpler fixed fills
fills = [
SimpleNamespace(avg_px=10.0, fee=0.1, sz=100.0),
SimpleNamespace(avg_px=8.0, fee=0.05, sz=100.0),
]
def place(**_k):
return fills.pop(0)
monkeypatch.setattr(ex, "_client", lambda: SimpleNamespace(place_market=place))
monkeypatch.setattr(
ex.ledger,
"get_setting_float",
lambda k, d=0: 1.0 if "qty" in k else d,
)
before = float(ex.ledger.snapshot()["available"])
r = ex.open_oo_group(
group_id="G-oo1",
call_inst_id="ETH-CALL",
put_inst_id="ETH-PUT",
call_strike=2000.0,
put_strike=1900.0,
entry_index_px=1950.0,
expiry_ymd="260810",
)
assert r.ok, r.detail
after = float(ex.ledger.snapshot()["available"])
# call 10*1 +0.1 + put 8*1 +0.05 = 18.15
assert before - after > 18.0
db.close()
get_settings.cache_clear()
+142 -1
View File
@@ -3,7 +3,12 @@ from zoneinfo import ZoneInfo
from app.sim.liquidity import bid_mark_ok
from app.sim.pricing import option_fill, perp_fill
from app.strategy.clock import can_open_new, window_key
from app.strategy.clock import (
can_open_new,
expiry_blocked_by_one_per_day,
used_expiry_ymds_for_day,
window_key,
)
from app.strategy.exits import check_expiry_close, check_exits
from app.strategy.signal import decide
@@ -28,6 +33,37 @@ def test_signal_equal() -> None:
assert decide(10.0, 10.0) is None
def test_decide_fixed_long_put() -> None:
from app.strategy.signal import decide_fixed
s = decide_fixed(20.0, 15.0, perp_side="long")
assert s is not None
assert s.option_side == "put"
assert s.perp_side == "long"
assert s.bias == "fixed_long_put"
def test_decide_fixed_short_call() -> None:
from app.strategy.signal import decide_fixed
s = decide_fixed(20.0, 15.0, perp_side="short")
assert s is not None
assert s.option_side == "call"
assert s.perp_side == "short"
assert s.bias == "fixed_short_call"
def test_decide_fixed_needs_only_own_leg() -> None:
from app.strategy.signal import decide_fixed
# 半自动多/空:对侧卖一缺失仍可定方向
sc = decide_fixed(8.2, None, perp_side="short")
assert sc is not None and sc.option_side == "call"
sp = decide_fixed(None, 11.4, perp_side="long")
assert sp is not None and sp.option_side == "put"
assert decide_fixed(None, None, perp_side="short") is None
def test_signal_strike_below_spot_call_short() -> None:
# 现价 1859、ATM 1850:即使 Put 卖一更高,也走 Call+空
s = decide(10.0, 20.0, strike=1850, mark_px=1859)
@@ -122,6 +158,31 @@ def test_window_weekend_skip() -> None:
assert window_key(fri) == "20260724"
def test_one_expiry_per_day(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from app.models.db import Database
db = Database(tmp_path / "one_exp.db")
# 日历已跨到 8.3,但 8.2 开过的 260803 仍须占用
day = datetime(2026, 8, 3, 0, 0, 2, tzinfo=_SH)
with db._lock:
db._conn.execute(
"""INSERT INTO groups(group_id, status, expiry_ymd, open_at_ms)
VALUES ('G-20260802-01','closed','260803',1)"""
)
db._conn.execute(
"""INSERT INTO groups(group_id, status, expiry_ymd, open_at_ms)
VALUES ('G-20260801-01','closed','260802',1)"""
)
db._conn.commit()
used = used_expiry_ymds_for_day(db, day)
assert used == {"260803", "260802"}
assert expiry_blocked_by_one_per_day("260803", used, enabled=True) is True
assert expiry_blocked_by_one_per_day("260804", used, enabled=True) is False
assert expiry_blocked_by_one_per_day("260803", used, enabled=False) is False
db.close()
def test_bid_mark_deviation_30pct() -> None:
# |7-10|/10 = 30% → 允许(≤30%
ok, _ = bid_mark_ok(bid=7.0, mark=10.0, max_dev_pct=30)
@@ -142,3 +203,83 @@ def test_expiry_close() -> None:
assert d2.reason == "expiry"
d3 = check_expiry_close(expiry_ms=1_000, now_ms=1_001)
assert d3.should_close is True
def test_deep_otm_and_expiry_settle() -> None:
from app.sim.pricing import is_deep_otm, option_expiry_settle, option_intrinsic
assert is_deep_otm(option_side="call", strike=1860, spot=1840) is True
assert is_deep_otm(option_side="call", strike=1860, spot=1882) is False
assert is_deep_otm(option_side="put", strike=1860, spot=1882) is True
assert option_intrinsic(option_side="call", strike=1860, spot=1840) == 0.0
settled = option_expiry_settle(intrinsic=0.0, qty_eth=2.0, fee_rate=0.0005)
assert settled.fill_px == 0.0
assert settled.notional == 0.0
def test_pair_from_held_option_inst() -> None:
from app.exchange.option_ids import flip_option_side, pair_from_option_inst
put = "ETH-USD_UM-260727-1880-P"
pair = pair_from_option_inst(put)
assert pair is not None
assert pair.strike == 1880
assert pair.expiry_ymd == "260727"
assert pair.put_inst_id == put
assert pair.call_inst_id == "ETH-USD_UM-260727-1880-C"
assert flip_option_side(put) == pair.call_inst_id
bn = "ETH-260727-1890-C"
bp = pair_from_option_inst(bn)
assert bp is not None
assert bp.strike == 1890
assert bp.call_inst_id == bn
assert bp.put_inst_id == "ETH-260727-1890-P"
def test_option_intrinsic_and_close_bid_floor() -> None:
from app.sim.pricing import (
option_expiry_settle,
option_intrinsic,
resolve_option_close_bid,
)
assert option_intrinsic(option_side="call", strike=1860, spot=1882) == 22.0
assert option_intrinsic(option_side="put", strike=1860, spot=1882) == 0.0
assert option_intrinsic(option_side="put", strike=1860, spot=1840) == 20.0
# 到期:严格按内在价值,无滑点
settled = option_expiry_settle(intrinsic=22.0, qty_eth=2.0, fee_rate=0.0005)
assert settled.fill_px == 22.0
assert settled.slip == 0.0
assert settled.notional == 44.0
assert abs(settled.fee - 44.0 * 0.0005) < 1e-12
# 紧急:对齐 OKX 市价卖,只按买一(垃圾买一不抬到内在价值/标记)
assert (
resolve_option_close_bid(
bid=0.2, mark=22.0, intrinsic=22.0, bypass_liquidity=True
)
== 0.2
)
# 常规:内在价值地板
assert (
resolve_option_close_bid(
bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=False
)
== 22.0
)
# 买一高于内在价值,保留买一
assert (
resolve_option_close_bid(
bid=25.0, mark=24.0, intrinsic=22.0, bypass_liquidity=True
)
== 25.0
)
# 紧急无买一 → 无法定价(不能用标记)
assert (
resolve_option_close_bid(
bid=None, mark=3.0, intrinsic=22.0, bypass_liquidity=True
)
is None
)
+72
View File
@@ -0,0 +1,72 @@
"""claim_open_slot / release_open_slot 单元测试。"""
from __future__ import annotations
from app.live.reconcile import claim_open_slot, release_open_slot_if_opening
from app.sim.matcher import BLOCKING_STATUSES, Matcher
def test_blocking_statuses_include_opening() -> None:
assert "opening" in BLOCKING_STATUSES
def test_claim_open_slot_from_flat(tmp_path) -> None:
from app.models.db import Database
db = Database(tmp_path / "claim.db")
m = Matcher(db)
assert m.has_open_position() is False
ok, msg = claim_open_slot(db)
assert ok is True
assert msg == "ok"
assert m.position_status() == "opening"
assert m.has_open_position() is True
ok2, _ = claim_open_slot(db)
assert ok2 is False
release_open_slot_if_opening(db)
assert m.position_status() == "flat"
assert m.has_open_position() is False
ok3, _ = claim_open_slot(db)
assert ok3 is True
release_open_slot_if_opening(db)
db.close()
def test_claim_rejects_blocking_states(tmp_path) -> None:
from app.models.db import Database
db = Database(tmp_path / "block.db")
for st in ("open", "half_open", "option_closed_perp_pending"):
with db._lock:
db._conn.execute(
"UPDATE positions SET status=?, group_id=? WHERE id=1",
(st, "G-test"),
)
db._conn.commit()
ok, msg = claim_open_slot(db)
assert ok is False
assert st in msg
with db._lock:
db._conn.execute(
"UPDATE positions SET status='flat', group_id=NULL WHERE id=1"
)
db._conn.commit()
db.close()
def test_release_only_when_opening(tmp_path) -> None:
from app.models.db import Database
db = Database(tmp_path / "rel.db")
with db._lock:
db._conn.execute("UPDATE positions SET status='open' WHERE id=1")
db._conn.commit()
release_open_slot_if_opening(db)
row = db.fetchone("SELECT status FROM positions WHERE id=1")
assert row is not None
assert row["status"] == "open"
db.close()
@@ -0,0 +1,62 @@
"""模拟资金重置时同步清空交易记录。"""
from __future__ import annotations
from app.models.db import Database
from app.sim.ledger import Ledger
def test_reset_equity_clears_trade_history(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "reset.db")
with db._lock:
db._conn.execute(
"""INSERT INTO groups(group_id, status, open_at_ms, close_at_ms, realized_pnl)
VALUES ('G1','closed',1,2,-5.0)"""
)
db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth,
fill_px, fee, slip, notional, ts_ms)
VALUES ('G1','perp','open','long','ETH-SWAP',1,2000,0.1,0,2000,1)"""
)
db._conn.execute(
"""INSERT INTO residual_options(
group_id, option_inst_id, option_side, option_qty_eth,
option_entry_px, status, created_at_ms
) VALUES ('G1','OPT','call',2,10,'pending',1)"""
)
db._conn.execute(
"""INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms)
VALUES ('G1','pnl',-5,9995,'t',2)"""
)
db._conn.execute(
"""UPDATE strategy_state SET rounds_done=3, window_key='w', rest_until_ms=99
WHERE id=1"""
)
db._conn.commit()
db.set_setting("risk_last_k", "1.5")
Ledger(db).reset_equity(12000.0, note="test reset")
assert db.fetchone("SELECT COUNT(*) AS c FROM groups")["c"] == 0
assert db.fetchone("SELECT COUNT(*) AS c FROM fills")["c"] == 0
assert db.fetchone("SELECT COUNT(*) AS c FROM residual_options")["c"] == 0
entries = db.fetchall("SELECT kind, amount FROM ledger_entries")
assert len(entries) == 1
assert entries[0]["kind"] == "reset"
assert float(entries[0]["amount"]) == 12000.0
pos = db.fetchone("SELECT status, group_id FROM positions WHERE id=1")
assert pos["status"] == "flat"
assert pos["group_id"] is None
st = db.fetchone(
"SELECT rounds_done, window_key, rest_until_ms FROM strategy_state WHERE id=1"
)
assert int(st["rounds_done"]) == 0
assert st["window_key"] is None
assert st["rest_until_ms"] is None
assert db.get_setting("risk_last_k") is None
led = db.fetchone("SELECT equity, available, reserved FROM ledger_meta WHERE id=1")
assert float(led["equity"]) == 12000.0
assert float(led["available"]) == 12000.0
assert float(led["reserved"]) == 0.0
db.close()
@@ -0,0 +1,260 @@
"""残留期权:权利金回升达标后中途平。"""
from __future__ import annotations
from types import SimpleNamespace
from app.models.db import Database
from app.sim.matcher import Matcher
def _seed_residual(
db: Database,
*,
group_id: str = "G-res",
initial_premium: float = 100.0,
qty: float = 2.0,
entry_px: float = 50.0,
) -> None:
now = 1_700_000_000_000
with db._lock:
db._conn.execute(
"""INSERT INTO groups(
group_id, status, bias, option_side, perp_side, option_inst_id,
strike, expiry_ymd, initial_premium, open_at_ms, close_at_ms,
close_reason, realized_pnl, fees, slip_cost
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"option_residual",
"test",
"call",
"short",
"ETH-USD_UM-260801-2000-C",
2000.0,
"260801",
initial_premium,
now - 10_000,
now - 5_000,
"target_perp_only",
10.0,
1.0,
0.0,
),
)
db._conn.execute(
"""INSERT INTO residual_options(
group_id, option_inst_id, option_side, option_qty_eth, option_qty_contracts,
option_entry_px, strike, expiry_ymd, expiry_ms, entry_index_px,
initial_premium, status, created_at_ms, note
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"ETH-USD_UM-260801-2000-C",
"call",
qty,
200.0,
entry_px,
2000.0,
"260801",
now + 86_400_000,
1900.0,
initial_premium,
"pending",
now - 5_000,
"test residual",
),
)
db._conn.commit()
def test_residual_premium_below_threshold_skips(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "below.db")
db.set_setting("residual_min_premium_pct", "20")
_seed_residual(db, initial_premium=100.0, qty=2.0)
m = Matcher(db)
# bid=5 → premium=10 < 20
oq = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=10_000.0, mark_px=5.0)
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
assert m.try_close_one_residual(m.list_residual_options()[0]) is None
row = db.fetchone(
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
)
assert row is not None and row["status"] == "pending"
db.close()
def test_residual_premium_above_threshold_closes(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "above.db")
db.set_setting("residual_min_premium_pct", "20")
_seed_residual(db, initial_premium=100.0, qty=2.0, entry_px=50.0)
m = Matcher(db)
# bid=15 → premium=30 >= 20
oq = SimpleNamespace(bid=15.0, ask=15.5, bid_sz=10_000.0, mark_px=15.0)
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
out = m.try_close_one_residual(m.list_residual_options()[0])
assert out is not None
assert out["reason"] == "residual_premium_close"
row = db.fetchone(
"SELECT status, settle_px FROM residual_options WHERE group_id=?", ("G-res",)
)
assert row is not None and row["status"] == "settled"
g = db.fetchone("SELECT status FROM groups WHERE group_id=?", ("G-res",))
assert g is not None and g["status"] == "closed"
db.close()
def test_residual_liquidity_fail_skips(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "liq.db")
db.set_setting("residual_min_premium_pct", "20")
_seed_residual(db, initial_premium=100.0, qty=2.0)
m = Matcher(db)
# premium ok but depth tiny
oq = SimpleNamespace(bid=15.0, ask=15.5, bid_sz=1.0, mark_px=15.0)
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
assert m.try_close_one_residual(m.list_residual_options()[0]) is None
row = db.fetchone(
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
)
assert row is not None and row["status"] == "pending"
db.close()
def test_residual_recheck_bid_drop_skips(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "recheck.db")
db.set_setting("residual_min_premium_pct", "20")
_seed_residual(db, initial_premium=100.0, qty=2.0)
m = Matcher(db)
good = SimpleNamespace(bid=15.0, ask=15.5, bid_sz=10_000.0, mark_px=15.0)
bad = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=10_000.0, mark_px=5.0)
quotes = iter([good, bad])
monkeypatch.setattr(m, "_quote_held_option", lambda _id: next(quotes))
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
assert m.try_close_one_residual(m.list_residual_options()[0]) is None
row = db.fetchone(
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
)
assert row is not None and row["status"] == "pending"
db.close()
def test_residual_book_pending_guard(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "guard.db")
_seed_residual(db, initial_premium=100.0, qty=2.0)
m = Matcher(db)
row = m.list_residual_options()[0]
first = m._book_residual_market_close(
row,
fill_px=15.0,
fee=0.01,
notional=30.0,
slip=0.0,
now_ms=1_700_000_100_000,
note="first",
filled_contracts=200.0,
remaining_contracts=0.0,
)
assert first is not None and first.get("fully_done") is True
second = m._book_residual_market_close(
row,
fill_px=15.0,
fee=0.01,
notional=30.0,
slip=0.0,
now_ms=1_700_000_200_000,
note="second",
filled_contracts=200.0,
remaining_contracts=0.0,
)
assert second is None
db.close()
def test_manual_close_skips_premium_ratio(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "manual.db")
db.set_setting("residual_min_premium_pct", "20")
_seed_residual(db, initial_premium=100.0, qty=2.0, entry_px=50.0)
m = Matcher(db)
# bid=5 → premium=10 < 20%,自动路径应跳过,手动可平
oq = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=10_000.0, mark_px=5.0)
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
assert m.try_close_one_residual(m.list_residual_options()[0]) is None
enriched = m.list_residual_options_enriched()
assert len(enriched) == 1
assert enriched[0]["liquidity_ok"] is True
assert enriched[0]["bid_px"] == 5.0
assert enriched[0]["bid_sz"] == 10_000.0
assert abs(float(enriched[0]["bid_sz_eth"]) - 100.0) < 1e-9 # 10000*0.01
assert abs(float(enriched[0]["current_premium"]) - 10.0) < 1e-9 # 5*2
assert abs(float(enriched[0]["recovery_pct"]) - 10.0) < 1e-9 # 10/100*100
assert float(enriched[0]["recovery_pct"]) < 20.0
r = m.close_residual_manual("G-res")
assert r.ok is True
row = db.fetchone(
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
)
assert row is not None and row["status"] == "settled"
db.close()
def test_manual_close_liquidity_still_required(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
db = Database(tmp_path / "manual_liq.db")
db.set_setting("residual_min_premium_pct", "20")
_seed_residual(db, initial_premium=100.0, qty=2.0)
m = Matcher(db)
oq = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=1.0, mark_px=5.0)
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
r = m.close_residual_manual("G-res")
assert r.ok is False
assert "liquidity" in (r.detail or "")
row = db.fetchone(
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
)
assert row is not None and row["status"] == "pending"
db.close()
def test_settings_exposes_residual_min_premium_pct(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from app.api import settings as settings_api
from app.models.db import set_db
d = Database(tmp_path / "set.db")
set_db(d)
try:
d.set_setting("residual_min_premium_pct", "35")
payload = settings_api._read_settings()
assert float(payload["residual_min_premium_pct"]) == 35.0
finally:
set_db(None)
d.close()
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import time
from app.models.db import Database, set_db
from app.strategy.engine import StrategyEngine
def test_enter_rest_after_close_sets_resting(tmp_path) -> None:
db = Database(tmp_path / "rest.db")
set_db(db)
db.set_setting("rest_seconds", "120")
now = int(time.time() * 1000)
db.execute(
"""INSERT INTO groups(
group_id, status, open_at_ms, close_at_ms, realized_pnl
) VALUES (?,?,?,?,?)""",
("G-20260729-01", "closed", now - 1000, now, 1.0),
)
eng = StrategyEngine()
before = int(time.time() * 1000)
eng.enter_rest_after_close()
row = db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert row is not None
assert row["phase"] == "resting"
assert int(row["rounds_done"] or 0) == 1
until = int(row["rest_until_ms"] or 0)
assert until >= before + 100_000
db.close()
+321
View File
@@ -0,0 +1,321 @@
"""以损定仓纯函数测试。"""
from __future__ import annotations
from app.strategy.risk_sizing import (
BASE_EXIT_USDT,
BASE_OPTION_ETH,
BASE_PERP_ETH,
compute_k,
floor_k_1dp,
normalize_risk_leverage_basis,
resolve_sizing_option_ask,
unit_cost,
)
def test_floor_k_1dp() -> None:
assert floor_k_1dp(1.29) == 1.2
assert floor_k_1dp(0.19) == 0.1
assert floor_k_1dp(0.09) == 0.0
assert floor_k_1dp(2.0) == 2.0
def test_compute_k_scales_1_2_15() -> None:
# I=2000, A=20, fee=0.0005 → unit = 2*20 + 2000*0.0005*3 = 40 + 3 = 43
# budget=43 → k=1.0
r = compute_k(budget=43.0, index_px=2000.0, option_ask=20.0, fee_rate=0.0005)
assert r.ok
assert r.k == 1.0
assert r.perp_qty_eth == BASE_PERP_ETH
assert r.option_qty_eth == BASE_OPTION_ETH
assert r.net_profit_target == BASE_EXIT_USDT
assert r.max_loss is not None and r.max_loss <= 43.0 + 1e-6
assert r.budget == 43.0
def test_compute_k_custom_units() -> None:
# option_unit=4 → premium unit = 20*4=80; fee=3; cost=83; budget=83 → k=1
r = compute_k(
budget=83.0,
index_px=2000.0,
option_ask=20.0,
fee_rate=0.0005,
perp_unit=0.5,
option_unit=4.0,
exit_unit=30.0,
)
assert r.ok
assert r.k == 1.0
assert r.perp_qty_eth == 0.5
assert r.option_qty_eth == 4.0
assert r.net_profit_target == 30.0
def test_money_rounds_2dp() -> None:
r = compute_k(budget=50.123456, index_px=1900.0, option_ask=18.5, fee_rate=0.0005)
assert r.ok
assert r.budget == round(50.123456, 2)
assert r.max_loss is not None
assert abs(r.max_loss * 100 - round(r.max_loss * 100)) < 1e-9
def test_compute_k_never_exceeds_budget() -> None:
r = compute_k(budget=50.0, index_px=1900.0, option_ask=18.5, fee_rate=0.0005)
assert r.ok
assert r.k is not None
assert abs(r.k * 10 - round(r.k * 10)) < 1e-9 # 一位小数
assert r.max_loss is not None and r.max_loss <= 50.0 + 1e-6
assert r.perp_qty_eth == round(1.0 * r.k, 4)
assert r.option_qty_eth == round(2.0 * r.k, 4)
assert r.net_profit_target == round(15.0 * r.k, 4)
def test_compute_k_too_small() -> None:
# unit≈43, budget=2 → k_raw≪0.1
r = compute_k(budget=2.0, index_px=2000.0, option_ask=20.0, fee_rate=0.0005)
assert not r.ok
assert "最小仓" in r.detail or "k=" in r.detail
def test_unit_cost() -> None:
assert abs(unit_cost(index_px=2000, option_ask=20, fee_rate=0.0005) - 43.0) < 1e-9
def test_normalize_risk_leverage_basis() -> None:
assert normalize_risk_leverage_basis("actual") == "actual"
assert normalize_risk_leverage_basis("selection") == "selection"
assert normalize_risk_leverage_basis("min_option_leverage") == "selection"
assert normalize_risk_leverage_basis("weird", default="selection") == "selection"
def test_resolve_sizing_ask_selection_vs_actual() -> None:
# 指数 2000、选约杠杆 100 → 隐含卖一 20;实际卖一更便宜 10
sel_ask, basis = resolve_sizing_option_ask(
index_px=2000.0,
option_ask=10.0,
leverage_basis="selection",
min_option_leverage=100.0,
)
assert basis == "selection"
assert abs(sel_ask - 20.0) < 1e-9
act_ask, basis2 = resolve_sizing_option_ask(
index_px=2000.0,
option_ask=10.0,
leverage_basis="actual",
min_option_leverage=100.0,
)
assert basis2 == "actual"
assert abs(act_ask - 10.0) < 1e-9
def test_selection_basis_yields_smaller_k_when_ask_cheap() -> None:
# 预算 43:选约隐含 ask=20 → k=1;若用实际 ask=10 → 单位成本更小 → k 更大
r_sel = compute_k(budget=43.0, index_px=2000.0, option_ask=20.0, fee_rate=0.0005)
r_act = compute_k(budget=43.0, index_px=2000.0, option_ask=10.0, fee_rate=0.0005)
assert r_sel.ok and r_act.ok
assert r_sel.k == 1.0
assert r_act.k is not None and r_act.k > r_sel.k
def test_compute_risk_sizing_unit_overrides(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from app.models.db import Database
from app.strategy.risk_sizing import compute_risk_sizing
db = Database(tmp_path / "risk_units.db")
db.set_setting("sizing_mode", "risk_based")
db.set_setting("risk_loss_mode", "absolute")
db.set_setting("risk_loss_usdt", "83")
db.set_setting("fee_rate", "0.0005")
db.set_setting("risk_leverage_basis", "selection")
db.set_setting("min_option_leverage", "100")
db.set_setting("risk_perp_unit", "1")
db.set_setting("risk_option_unit", "2")
db.set_setting("risk_exit_unit", "15")
# 覆盖为单位 0.5:4、出场 5,并强制实际卖一(ask=20 → cost=80+3=83 → k=1
r = compute_risk_sizing(
index_px=2000.0,
option_ask=20.0,
db=db,
perp_unit=0.5,
option_unit=4.0,
exit_unit=5.0,
leverage_basis="actual",
)
assert r.ok
assert r.leverage_basis == "actual"
assert r.k == 1.0
assert r.perp_qty_eth == 0.5
assert r.option_qty_eth == 4.0
assert r.net_profit_target == 5.0
db.close()
def test_compute_risk_sizing_respects_basis(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from app.models.db import Database
from app.strategy.risk_sizing import compute_risk_sizing
db = Database(tmp_path / "risk_basis.db")
db.set_setting("sizing_mode", "risk_based")
db.set_setting("risk_loss_mode", "absolute")
db.set_setting("risk_loss_usdt", "43")
db.set_setting("fee_rate", "0.0005")
db.set_setting("min_option_leverage", "100")
db.set_setting("risk_perp_unit", "1")
db.set_setting("risk_option_unit", "2")
db.set_setting("risk_exit_unit", "15")
db.set_setting("risk_leverage_basis", "selection")
r1 = compute_risk_sizing(index_px=2000.0, option_ask=10.0, db=db)
assert r1.ok
assert r1.leverage_basis == "selection"
assert r1.k == 1.0
assert r1.actual_option_ask == 10.0
assert r1.option_ask == 20.0
db.set_setting("risk_leverage_basis", "actual")
r2 = compute_risk_sizing(index_px=2000.0, option_ask=10.0, db=db)
assert r2.ok
assert r2.leverage_basis == "actual"
assert r2.k is not None and r2.k > 1.0
assert r2.option_ask == 10.0
db.close()
def test_consecutive_loss_days_and_martingale(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from app.models.db import Database
from app.strategy.risk_sizing import consecutive_loss_days, resolve_martingale
db = Database(tmp_path / "mg.db")
sh = ZoneInfo("Asia/Shanghai")
def day_ms(ymd: str, hour: int = 16) -> int:
dt = datetime.strptime(ymd, "%Y-%m-%d").replace(
hour=hour, tzinfo=sh
)
return int(dt.astimezone(timezone.utc).timestamp() * 1000)
# 插入:盈利日打断后连亏 3 天(有成交日序列,跳过无成交日)
rows = [
("g1", day_ms("2026-07-28"), 10.0),
("g2", day_ms("2026-07-29"), -5.0),
("g3", day_ms("2026-07-30"), -3.0),
("g4", day_ms("2026-07-31"), -1.0),
]
for gid, ms, pnl in rows:
db.execute(
"""INSERT INTO groups(
group_id, status, realized_pnl, close_at_ms, open_at_ms
) VALUES(?,?,?,?,?)""",
(gid, "closed", pnl, ms, ms - 3600_000),
)
assert consecutive_loss_days(db) == 3
db.set_setting("sizing_mode", "risk_based")
db.set_setting("risk_loss_mode", "percent")
db.set_setting("risk_loss_pct", "2")
db.set_setting("martingale_enabled", "true")
db.set_setting("martingale_start_after_loss_days", "2")
db.set_setting("martingale_max_doubles", "3")
mg = resolve_martingale(db, base_pct=2.0)
assert mg["eligible"] is True
assert mg["loss_days"] == 3
# 连亏3天、start=2 → doubles = min(3-2+1, 3) = 2 → 2%*4 = 8%
assert mg["doubles"] == 2
assert abs(float(mg["effective_pct"]) - 8.0) < 1e-9
db.set_setting("risk_loss_pct", "3.1")
mg2 = resolve_martingale(db, base_pct=3.1)
assert mg2["eligible"] is False
assert mg2["doubles"] == 0
assert abs(float(mg2["effective_pct"]) - 3.1) < 1e-9
db.close()
def test_martingale_doubles_capped(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from app.models.db import Database
from app.strategy.risk_sizing import resolve_martingale
db = Database(tmp_path / "mg_cap.db")
sh = ZoneInfo("Asia/Shanghai")
def day_ms(ymd: str) -> int:
dt = datetime.strptime(ymd, "%Y-%m-%d").replace(hour=12, tzinfo=sh)
return int(dt.astimezone(timezone.utc).timestamp() * 1000)
for i, ymd in enumerate(
["2026-07-26", "2026-07-27", "2026-07-28", "2026-07-29", "2026-07-30"]
):
db.execute(
"""INSERT INTO groups(
group_id, status, realized_pnl, close_at_ms, open_at_ms
) VALUES(?,?,?,?,?)""",
(f"c{i}", "closed", -1.0, day_ms(ymd), day_ms(ymd) - 1000),
)
db.set_setting("sizing_mode", "risk_based")
db.set_setting("risk_loss_mode", "percent")
db.set_setting("martingale_enabled", "true")
db.set_setting("martingale_start_after_loss_days", "2")
db.set_setting("martingale_max_doubles", "3")
mg = resolve_martingale(db, base_pct=2.0)
# 连亏5、start2 → raw=4cap=3 → 2%*8=16%
assert mg["doubles"] == 3
assert abs(float(mg["effective_pct"]) - 16.0) < 1e-9
db.close()
def test_expiry_settle_counts_as_loss_even_if_profit(tmp_path, monkeypatch) -> None:
"""到期结算小盈利也按亏损计入倍投连亏日。"""
monkeypatch.setenv("MODE", "SIM")
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from app.models.db import Database
from app.strategy.risk_sizing import consecutive_loss_days
db = Database(tmp_path / "mg_exp.db")
sh = ZoneInfo("Asia/Shanghai")
def day_ms(ymd: str) -> int:
dt = datetime.strptime(ymd, "%Y-%m-%d").replace(hour=16, tzinfo=sh)
return int(dt.astimezone(timezone.utc).timestamp() * 1000)
# 达标盈利打断;随后两天均为到期小盈利 → 仍计连亏 2
rows = [
("e0", day_ms("2026-07-28"), 20.0, "fixed_usdt"),
("e1", day_ms("2026-07-29"), 3.5, "expiry"),
("e2", day_ms("2026-07-30"), 1.2, "expiry"),
]
for gid, ms, pnl, reason in rows:
db.execute(
"""INSERT INTO groups(
group_id, status, realized_pnl, close_at_ms, open_at_ms, close_reason
) VALUES(?,?,?,?,?,?)""",
(gid, "closed", pnl, ms, ms - 3600_000, reason),
)
assert consecutive_loss_days(db) == 2
# 再来一天达标盈利 → 连亏清零
db.execute(
"""INSERT INTO groups(
group_id, status, realized_pnl, close_at_ms, open_at_ms, close_reason
) VALUES(?,?,?,?,?,?)""",
("e3", "closed", 15.0, day_ms("2026-07-31"), day_ms("2026-07-31") - 1000, "fixed_usdt"),
)
assert consecutive_loss_days(db) == 0
db.close()
+76
View File
@@ -0,0 +1,76 @@
"""SIM/LIVE 运行时闸门与脱敏。"""
from app.env_store import live_ready, mask_secret, okx_keys_configured
def test_mask_secret() -> None:
assert mask_secret(None) is None
assert mask_secret("") is None
assert mask_secret("abcd") == "********"
assert mask_secret("abcdefghij") == "********"
def test_live_ready_sim(monkeypatch) -> None:
import app.env_store as es
class S:
mode = "SIM"
is_sim = True
okx_api_key = ""
okx_api_secret = ""
okx_api_passphrase = ""
binance_api_key = ""
binance_api_secret = ""
monkeypatch.setattr(es, "get_settings", lambda: S())
ok, reason = live_ready(exchange="okx")
assert ok is True
assert reason == "sim"
def test_live_ready_okx_missing_keys(monkeypatch) -> None:
import app.env_store as es
class S:
mode = "LIVE"
is_sim = False
okx_api_key = ""
okx_api_secret = ""
okx_api_passphrase = ""
binance_api_key = ""
binance_api_secret = ""
monkeypatch.setattr(es, "get_settings", lambda: S())
ok, reason = live_ready(exchange="okx")
assert ok is False
assert "OKX" in reason
def test_live_ready_binance_ok(monkeypatch) -> None:
import app.env_store as es
class S:
mode = "LIVE"
is_sim = False
okx_api_key = ""
okx_api_secret = ""
okx_api_passphrase = ""
binance_api_key = "bk"
binance_api_secret = "bs"
monkeypatch.setattr(es, "get_settings", lambda: S())
ok, reason = live_ready(exchange="binance")
assert ok is True
assert reason == "ok"
def test_okx_keys_configured(monkeypatch) -> None:
import app.env_store as es
class S:
okx_api_key = "k"
okx_api_secret = "s"
okx_api_passphrase = "p"
monkeypatch.setattr(es, "get_settings", lambda: S())
assert okx_keys_configured() is True
+137
View File
@@ -0,0 +1,137 @@
"""半自动出场与参数。"""
from __future__ import annotations
from app.strategy.selection import list_otm_strikes, pick_otm_strike
from app.strategy.semi_auto import (
REASON_PERP_NET,
REASON_POINTS,
check_semi_exits,
effective_min_leverage,
)
def test_semi_points_long_needs_net_positive() -> None:
# 目标 = 行权价 1800 + 50 = 1850;到点但净利≤0 → 不平
d = check_semi_exits(
net_pnl=-1.0,
strike=1800,
index_px=1850,
view_side="long",
option_move_points=50,
perp_exit_unit=5,
risk_k=1,
)
assert d.should_close is False
assert "净利≤0" in d.detail
d2 = check_semi_exits(
net_pnl=1.0,
strike=1800,
index_px=1850,
view_side="long",
option_move_points=50,
perp_exit_unit=5,
risk_k=1,
)
assert d2.should_close is True
assert d2.reason == REASON_POINTS
assert d2.target_index == 1850.0
def test_semi_points_uses_strike_not_spot() -> None:
# 现价 1915、K1930、+50 → 目标 1980;现价未到则不平(净利勿触达锁定)
d = check_semi_exits(
net_pnl=1.0,
strike=1930,
index_px=1915,
view_side="long",
option_move_points=50,
perp_exit_unit=5,
risk_k=1,
)
assert d.should_close is False
assert d.target_index == 1980.0
d2 = check_semi_exits(
net_pnl=1.0,
strike=1930,
index_px=1980,
view_side="long",
option_move_points=50,
perp_exit_unit=5,
risk_k=1,
)
assert d2.should_close is True
assert d2.reason == REASON_POINTS
def test_semi_points_short() -> None:
# Put:目标 = K 50
d = check_semi_exits(
net_pnl=2.0,
strike=1800,
index_px=1750,
view_side="short",
option_move_points=50,
perp_exit_unit=5,
risk_k=1,
)
assert d.should_close is True
assert d.reason == REASON_POINTS
assert d.target_index == 1750.0
def test_semi_net_exit_with_k() -> None:
# 未到点,但净利 ≥ 5×2=10(永续锁定)
d = check_semi_exits(
net_pnl=10.0,
strike=1800,
index_px=1810,
view_side="long",
option_move_points=50,
perp_exit_unit=5,
risk_k=2,
)
assert d.should_close is True
assert d.reason == REASON_PERP_NET
assert d.net_target == 10.0
def test_semi_not_yet() -> None:
d = check_semi_exits(
net_pnl=3.0,
strike=1800,
index_px=1820,
view_side="long",
option_move_points=50,
perp_exit_unit=5,
risk_k=1,
)
assert d.should_close is False
def test_otm_leverage_floor() -> None:
assert effective_min_leverage("otm", 100) == 180
assert effective_min_leverage("otm", 200) == 200
assert effective_min_leverage("itm", 100) == 100
def test_pick_otm_within_offset() -> None:
strikes = [1800.0, 1825.0, 1850.0, 1875.0]
# Call 虚值:标的 1830 → 185020点)在 25 内;1875 超
k = pick_otm_strike(strikes, 1830, option_side="call", max_offset=25)
assert k == 1850.0
assert (
pick_otm_strike(strikes, 1830, option_side="call", max_offset=15) is None
)
# Put 虚值
k2 = pick_otm_strike(strikes, 1830, option_side="put", max_offset=30)
assert k2 == 1825.0
def test_list_otm_strikes_near_to_far() -> None:
# 现价 1917 → Call 虚值 1920/1930/1940(偏离≤25),近→远
strikes = [1910.0, 1920.0, 1930.0, 1940.0, 1950.0]
ks = list_otm_strikes(strikes, 1917.0, option_side="call", max_offset=25)
assert ks == [1920.0, 1930.0, 1940.0]
+61
View File
@@ -0,0 +1,61 @@
"""空仓选约:跳过历史上已用到期与残留待结算到期。"""
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
from app.strategy.clock import (
expiry_blocked_by_one_per_day,
pending_residual_expiry_ymds,
used_expiry_ymds,
)
class _FakeDB:
def __init__(
self,
*,
groups: list[dict] | None = None,
residuals: list[dict] | None = None,
) -> None:
self._groups = groups or []
self._residuals = residuals or []
def fetchall(self, sql: str, params: tuple = ()) -> list[dict]:
s = " ".join(sql.split()).lower()
if "from groups" in s:
return [
r
for r in self._groups
if str(r.get("expiry_ymd") or "").strip()
]
if "from residual_options" in s:
return [r for r in self._residuals if r.get("status") == "pending"]
return []
def test_used_expiry_ymds_across_calendar_days() -> None:
"""8.2 开过 260803 后,8.3 零点仍须拦截同到期。"""
db = _FakeDB(
groups=[
{"group_id": "G-20260802-01", "expiry_ymd": "260803"},
{"group_id": "G-20260801-01", "expiry_ymd": "260802"},
]
)
now_aug3 = datetime(2026, 8, 3, 0, 0, 2, tzinfo=ZoneInfo("Asia/Shanghai"))
used = used_expiry_ymds(db, now_aug3)
assert used == {"260803", "260802"}
assert expiry_blocked_by_one_per_day("260803", used, enabled=True)
assert not expiry_blocked_by_one_per_day("260804", used, enabled=True)
def test_pending_residual_expiry_ymds() -> None:
db = _FakeDB(
residuals=[
{"expiry_ymd": "260803", "status": "pending"},
{"expiry_ymd": "260802", "status": "settled"},
{"expiry_ymd": "", "status": "pending"},
]
)
assert pending_residual_expiry_ymds(db) == {"260803"}
+28
View File
@@ -0,0 +1,28 @@
"""统计汇总:最大单笔亏损、连亏次数。"""
from __future__ import annotations
from app.api.stats import _current_loss_streak
def test_current_loss_streak() -> None:
assert _current_loss_streak([]) == 0
assert (
_current_loss_streak(
[
{"realized_pnl": 10},
{"realized_pnl": -1},
{"realized_pnl": -2},
]
)
== 2
)
assert (
_current_loss_streak(
[
{"realized_pnl": -5},
{"realized_pnl": 1},
]
)
== 0
)
+33
View File
@@ -0,0 +1,33 @@
"""LIVE 合约解析。"""
from app.live.symbols import resolve_perp_inst_id
class _FakeDb:
def __init__(self, row=None):
self._row = row
def fetchone(self, sql, args=()):
return self._row
def test_resolve_perp_prefers_group(monkeypatch) -> None:
import app.live.symbols as sym
class S:
perp_inst_id = "ETH-USDT-SWAP"
monkeypatch.setattr(sym, "live_settings", lambda: S())
db = _FakeDb({"perp_inst_id": "ETHUSDT"})
assert resolve_perp_inst_id(db, group_id="g1") == "ETHUSDT"
def test_resolve_perp_fallback_runtime(monkeypatch) -> None:
import app.live.symbols as sym
class S:
perp_inst_id = "ETHUSDT"
monkeypatch.setattr(sym, "live_settings", lambda: S())
db = _FakeDb(None)
assert resolve_perp_inst_id(db) == "ETHUSDT"
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
from app.notify.wecom import (
TAG_CLOSE,
TAG_OPEN,
build_markdown,
close_reason_zh,
direction_zh,
notify_close,
notify_open,
venue_label,
)
def test_build_markdown_has_tag_and_title(monkeypatch):
monkeypatch.setattr("app.notify.wecom.venue_label", lambda: "实盘·OKX")
md = build_markdown(tag=TAG_OPEN, title="开仓成功", lines=["组: G-1"])
assert "【实盘·OKX】开仓成功" in md
assert "`OPEN`" in md
assert "组: G-1" in md
def test_venue_label_sim(monkeypatch):
class S:
is_sim = True
exchange = "okx"
monkeypatch.setattr("app.notify.wecom.get_settings", lambda: S())
assert venue_label() is None
def test_direction_and_close_reason_zh() -> None:
assert "Put" in direction_zh({"option_side": "put", "perp_side": "long"})
assert close_reason_zh("liquidity_retry") == "等待流动性后全平"
assert close_reason_zh("fixed_usdt") == "固定净盈利达标·双腿全平"
def test_notify_open_close_markdown(monkeypatch) -> None:
captured: list[str] = []
monkeypatch.setattr("app.notify.wecom.wecom_enabled", lambda: True)
monkeypatch.setattr("app.notify.wecom.wecom_webhook_url", lambda: "http://example.test")
monkeypatch.setattr("app.notify.wecom.venue_label", lambda: None)
monkeypatch.setattr("app.notify.wecom.wecom_machine_name", lambda: "")
def _capture(content: str):
captured.append(content)
return True, "ok"
monkeypatch.setattr("app.notify.wecom._post_markdown_sync", _capture)
monkeypatch.setattr(
"app.notify.wecom.notify_async",
lambda content: captured.append(content),
)
notify_open(
group_id="G-20260802-01",
detail="opened",
extra={
"bias": "put_ask_gt_call",
"option_side": "put",
"perp_side": "long",
"option_inst_id": "ETH-USD-260802-1850-P",
"strike": 1850,
"expiry_ymd": "260802",
"perp_qty_eth": 1.0,
"option_qty_eth": 2.0,
"perp_entry_px": 1860.5,
"option_entry_px": 12.3,
"initial_premium": 24.6,
"perp_margin": 620.0,
"leverage": 3,
},
)
assert captured
open_md = captured[-1]
assert "开仓成功" in open_md
assert "`OPEN`" in open_md or TAG_OPEN in open_md
assert "权利金占用" in open_md
assert "保证金占用" in open_md
assert "开仓数量" in open_md
assert "买Put" in open_md
captured.clear()
notify_close(
reason="liquidity_retry",
detail="closed",
data={
"group_id": "G-20260802-01",
"perp_pnl": -10.5,
"option_pnl": 40.2,
"net": 25.0,
"fees": 4.5,
},
)
close_md = captured[-1]
assert "平仓" in close_md
assert TAG_CLOSE in close_md or "`CLOSE`" in close_md
assert "等待流动性后全平" in close_md
assert "永续盈亏" in close_md
assert "期权盈亏" in close_md
assert "净利润" in close_md
assert "+25.00U" in close_md or "25.00U" in close_md
+1
View File
@@ -0,0 +1 @@
# 使 `uvicorn app.main:app` 在 control/backend 下可运行
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .auth_routes import router as auth_router
from .nodes import router as nodes_router
router = APIRouter()
router.include_router(auth_router)
router.include_router(nodes_router)
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import hmac
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from ..auth import issue_token, require_control_user
from ..config import ControlSettings, get_control_settings
from ..envfile import update_control_credentials, upsert_env_control
from ..lan import client_ip, is_lan_ip
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginBody(BaseModel):
username: str = Field(min_length=1)
password: str = Field(min_length=1)
class ChangeCredentialsBody(BaseModel):
current_password: str = Field(min_length=1)
new_username: str = Field(min_length=1, max_length=64)
new_password: str = Field(min_length=6, max_length=128)
class LanBypassBody(BaseModel):
enabled: bool
@router.get("/login-meta")
async def login_meta(
request: Request,
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> dict:
ip = client_ip(request)
lan = is_lan_ip(ip)
return {
"show_default_hint": settings.is_default_credentials,
"lan_bypass_enabled": settings.lan_auth_bypass,
"lan_client": lan,
"lan_login_available": bool(settings.lan_auth_bypass and lan),
"client_ip": ip or None,
}
@router.post("/lan-login")
async def lan_login(
request: Request,
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> dict:
if not settings.lan_auth_bypass:
raise HTTPException(status_code=403, detail="未开启局域网免登录")
ip = client_ip(request)
if not is_lan_ip(ip):
raise HTTPException(status_code=403, detail="仅局域网地址可免登录")
user = settings.control_auth_username
token, ttl = issue_token(user, settings)
return {
"token": token,
"username": user,
"expires_in": ttl,
"via": "lan",
}
@router.post("/login")
async def login(
body: LoginBody,
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> dict:
user_ok = hmac.compare_digest(
body.username.encode("utf-8"),
settings.control_auth_username.encode("utf-8"),
)
pwd_ok = hmac.compare_digest(
body.password.encode("utf-8"),
settings.control_auth_password.encode("utf-8"),
)
if not (user_ok and pwd_ok):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token, ttl = issue_token(body.username, settings)
return {"token": token, "username": body.username, "expires_in": ttl}
@router.get("/me")
async def me(
request: Request,
username: Annotated[str, Depends(require_control_user)],
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> dict:
ip = client_ip(request)
lan = is_lan_ip(ip)
return {
"username": username,
"poll_interval_sec": settings.control_poll_interval_sec,
"sse_interval_sec": settings.control_sse_interval_sec,
"show_default_hint": settings.is_default_credentials,
"lan_bypass_enabled": settings.lan_auth_bypass,
"lan_client": lan,
"client_ip": ip or None,
}
@router.put("/lan-bypass")
async def put_lan_bypass(
body: LanBypassBody,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
upsert_env_control(
"CONTROL_LAN_AUTH_BYPASS",
"1" if body.enabled else "0",
overwrite=True,
)
get_control_settings.cache_clear()
settings = get_control_settings()
return {
"ok": True,
"lan_bypass_enabled": settings.lan_auth_bypass,
}
@router.post("/change-credentials")
async def change_credentials(
body: ChangeCredentialsBody,
username: Annotated[str, Depends(require_control_user)],
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> dict:
if not hmac.compare_digest(
body.current_password.encode("utf-8"),
settings.control_auth_password.encode("utf-8"),
):
raise HTTPException(status_code=400, detail="当前密码不正确")
try:
update_control_credentials(
new_username=body.new_username,
new_password=body.new_password,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
settings2 = get_control_settings()
token, ttl = issue_token(body.new_username.strip(), settings2)
return {
"token": token,
"username": body.new_username.strip(),
"expires_in": ttl,
"show_default_hint": settings2.is_default_credentials,
}
+542
View File
@@ -0,0 +1,542 @@
from __future__ import annotations
import asyncio
import json
import secrets
import time
from typing import Annotated, Any, AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from ..auth import require_control_user
from ..config import get_control_settings
from ..crypto import seal
from ..db import get_control_db
from ..proxy import call_node, probe_health
router = APIRouter(prefix="/api/nodes", tags=["nodes"])
def _public_node(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row["id"],
"name": row["name"],
"base_url": row["base_url"],
"token_configured": bool((row.get("token_sealed") or "").strip()),
"created_at_ms": row["created_at_ms"],
"updated_at_ms": row["updated_at_ms"],
}
def _http_detail(data: Any) -> str:
if isinstance(data, dict):
d = data.get("detail", data)
return d if isinstance(d, str) else str(d)
return str(data)
def _raise_node_error(code: int, data: Any) -> None:
"""策略机错误不得用 401 回传,否则中控前端会误清登录态。"""
detail = _http_detail(data)
if code in (401, 403):
raise HTTPException(
status_code=502,
detail=f"策略机鉴权失败({detail})。请在中控重新生成 Token,并到策略机「系统设置→登录账户」保存同一 Token。",
)
raise HTTPException(
status_code=code if 400 <= code < 600 else 502,
detail=detail,
)
async def _collect_one_status(
node: dict[str, Any],
*,
timeout: float | None = None,
) -> dict[str, Any]:
"""有 Token 时只打 fleet/status(含在线+持仓),避免 /health 再多一跳。"""
base = _public_node(node)
if not node.get("token_sealed"):
probe = await probe_health(node, timeout=timeout)
return {
**base,
**probe,
"fleet_ok": False,
"fleet_error": "未生成 Token",
}
code, data = await call_node(
node, "GET", "/api/fleet/status", timeout=timeout
)
if code == 200 and isinstance(data, dict):
return {
**base,
"online": True,
"health": None,
"error": None,
"from_fleet": True,
"fleet": data,
"fleet_ok": True,
}
# fleet 失败时再探 /health,区分离线 vs Token 错误
probe = await probe_health(node, timeout=timeout)
item: dict[str, Any] = {
**base,
**probe,
"fleet_ok": False,
"fleet_error": _http_detail(data) if data else f"HTTP {code}",
}
return item
async def collect_all_status(*, timeout: float | None = None) -> list[dict[str, Any]]:
db = get_control_db()
nodes = db.list_nodes()
if not nodes:
return []
return list(
await asyncio.gather(
*[_collect_one_status(n, timeout=timeout) for n in nodes]
)
)
class NodeCreate(BaseModel):
name: str = Field(min_length=1, max_length=64)
base_url: str = Field(min_length=8, max_length=256)
class NodeUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64)
base_url: str | None = Field(default=None, min_length=8, max_length=256)
@router.get("/")
async def list_nodes(_user: Annotated[str, Depends(require_control_user)]) -> dict:
db = get_control_db()
nodes = [_public_node(n) for n in db.list_nodes()]
return {"nodes": nodes}
@router.post("/")
async def create_node(
body: NodeCreate,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
try:
row = db.create_node(body.name, body.base_url)
except Exception as e:
raise HTTPException(status_code=400, detail=f"创建失败: {e}") from e
return _public_node(row)
@router.get("/status/all")
async def status_all(_user: Annotated[str, Depends(require_control_user)]) -> dict:
items = await collect_all_status()
return {"nodes": items}
@router.get("/status/stream")
async def status_stream(
request: Request,
_user: Annotated[str, Depends(require_control_user)],
) -> StreamingResponse:
"""SSE:中控约每秒并行拉取策略机状态并推送到浏览器。"""
settings = get_control_settings()
interval = max(0.5, float(settings.control_sse_interval_sec))
probe_timeout = max(0.5, float(settings.control_sse_probe_timeout_sec))
async def event_gen() -> AsyncIterator[str]:
last_payload = ""
last_heartbeat = 0.0
yield f": connected interval={interval}\n\n"
while True:
if await request.is_disconnected():
break
started = time.monotonic()
try:
items = await collect_all_status(timeout=probe_timeout)
payload = json.dumps(
{"nodes": items, "ts_ms": int(time.time() * 1000)},
ensure_ascii=False,
separators=(",", ":"),
default=str,
)
now = time.monotonic()
if payload != last_payload:
last_payload = payload
yield f"event: nodes\ndata: {payload}\n\n"
last_heartbeat = now
elif now - last_heartbeat >= 5.0:
yield f": heartbeat {int(time.time())}\n\n"
last_heartbeat = now
except Exception as e:
err = json.dumps(
{"detail": str(e)},
ensure_ascii=False,
separators=(",", ":"),
)
yield f"event: error\ndata: {err}\n\n"
elapsed = time.monotonic() - started
await asyncio.sleep(max(0.05, interval - elapsed))
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/update-batch")
async def update_batch(
body: dict,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
ids = body.get("ids") or []
if not isinstance(ids, list) or not ids:
raise HTTPException(status_code=400, detail="ids 不能为空")
db = get_control_db()
results = []
for nid in ids:
node = db.get_node(int(nid))
if not node:
results.append({"id": nid, "ok": False, "detail": "不存在"})
continue
code, data = await call_node(node, "POST", "/api/fleet/update")
results.append(
{
"id": nid,
"ok": code < 400,
"status": code,
"result": data,
}
)
return {"results": results}
@router.post("/start-batch")
async def start_batch(
body: dict,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
"""并行启动多台策略机(Fleet start)。"""
ids = body.get("ids") or []
if not isinstance(ids, list) or not ids:
raise HTTPException(status_code=400, detail="ids 不能为空")
db = get_control_db()
async def _one(nid: int) -> dict:
node = db.get_node(int(nid))
if not node:
return {"id": nid, "ok": False, "detail": "不存在", "name": str(nid)}
name = str(node.get("name") or nid)
if not node.get("token_sealed"):
return {"id": nid, "ok": False, "detail": "未生成 Token", "name": name}
try:
code, data = await call_node(node, "POST", "/api/fleet/start", timeout=20.0)
except Exception as ex:
return {"id": nid, "ok": False, "detail": str(ex), "name": name}
detail = ""
if code >= 400:
detail = _http_detail(data) if data else f"HTTP {code}"
return {
"id": nid,
"name": name,
"ok": code < 400,
"status": code,
"detail": detail,
"result": data,
}
items = list(await asyncio.gather(*[_one(int(x)) for x in ids]))
ok_n = sum(1 for x in items if x.get("ok"))
return {"ok": ok_n == len(items), "started": ok_n, "total": len(items), "results": items}
@router.patch("/{node_id}")
async def update_node(
node_id: int,
body: NodeUpdate,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
row = db.update_node(node_id, name=body.name, base_url=body.base_url)
if not row:
raise HTTPException(status_code=404, detail="节点不存在")
return _public_node(row)
@router.delete("/{node_id}")
async def delete_node(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
if not db.delete_node(node_id):
raise HTTPException(status_code=404, detail="节点不存在")
return {"ok": True}
@router.post("/{node_id}/generate-token")
async def generate_token(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
"""生成新 Token,加密存中控;明文仅返回一次,需粘贴到策略机设置。"""
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
plain = secrets.token_urlsafe(32)
sealed = seal(plain, get_control_settings().control_auth_secret)
db.update_node(node_id, token_sealed=sealed)
return {
"ok": True,
"token": plain,
"msg": "请立即复制并到策略机「系统设置 → 登录账户 → 中控 API Token」保存",
}
@router.get("/{node_id}/status")
async def node_status(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
return await _collect_one_status(node)
def _max_single_loss_from_curve(curve: list[Any]) -> float:
pnls = [float(x.get("realized_pnl") or 0) for x in curve if isinstance(x, dict)]
if not pnls:
return 0.0
worst = min(pnls)
return float(worst) if worst < 0 else 0.0
def _loss_streak_from_curve(curve: list[Any]) -> int:
streak = 0
for item in reversed(curve):
if not isinstance(item, dict):
continue
if float(item.get("realized_pnl") or 0) < 0:
streak += 1
else:
break
return streak
async def _collect_one_stats(node: dict[str, Any]) -> dict[str, Any]:
base = {
"id": node["id"],
"name": node["name"],
"ok": False,
"error": None,
"initial_funds": None,
"latest_funds": None,
"groups": None,
"fees_perp": None,
"fees_option": None,
"total_fees": None,
"max_single_loss": None,
"loss_streak": None,
"total_pnl": None,
}
if not node.get("token_sealed"):
base["error"] = "未生成 Token"
return base
try:
code, data = await call_node(node, "GET", "/api/fleet/stats", timeout=15.0)
except Exception as ex:
base["error"] = str(ex)
return base
if code >= 400 or not isinstance(data, dict):
base["error"] = _http_detail(data) if data else f"HTTP {code}"
return base
curve = data.get("equity_curve") if isinstance(data.get("equity_curve"), list) else []
max_loss = data.get("max_single_loss")
if max_loss is None:
max_loss = _max_single_loss_from_curve(curve)
loss_streak = data.get("loss_streak")
if loss_streak is None:
loss_streak = _loss_streak_from_curve(curve)
latest_funds = data.get("latest_funds")
if latest_funds is None:
# 旧版策略机 stats 无资金字段:回落 status.latest_funds
try:
sc, sd = await call_node(node, "GET", "/api/fleet/status", timeout=8.0)
if sc < 400 and isinstance(sd, dict) and sd.get("latest_funds") is not None:
latest_funds = sd.get("latest_funds")
except Exception:
pass
if latest_funds is None:
latest_funds = 0.0
initial_funds = data.get("initial_funds")
if initial_funds is None:
# 旧版策略机:用最新资金 − 已实现盈亏近似初始资金
try:
initial_funds = float(latest_funds) - float(data.get("total_pnl") or 0)
except Exception:
initial_funds = None
base.update(
{
"ok": True,
"initial_funds": initial_funds,
"latest_funds": latest_funds,
"groups": data.get("groups"),
"fees_perp": data.get("fees_perp"),
"fees_option": data.get("fees_option"),
"total_fees": data.get("total_fees"),
"max_single_loss": max_loss,
"loss_streak": loss_streak,
"total_pnl": data.get("total_pnl"),
"mode": data.get("mode"),
}
)
return base
@router.get("/stats/all")
async def stats_all(_user: Annotated[str, Depends(require_control_user)]) -> dict:
"""并行拉取各策略机统计,供监控区「数据统计」表。"""
db = get_control_db()
nodes = db.list_nodes()
if not nodes:
return {"nodes": []}
items = list(await asyncio.gather(*[_collect_one_stats(n) for n in nodes]))
return {"nodes": items}
@router.get("/{node_id}/stats")
async def node_stats(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
"""代理策略机整体统计(点击详情时按需拉取,不进 SSE)。"""
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
if not node.get("token_sealed"):
raise HTTPException(status_code=400, detail="未生成 Token")
code, data = await call_node(node, "GET", "/api/fleet/stats", timeout=15.0)
if code >= 400:
_raise_node_error(code, data)
if not isinstance(data, dict):
raise HTTPException(status_code=502, detail="策略机统计返回异常")
return data
class ResidualCloseBody(BaseModel):
group_id: str = Field(min_length=1, max_length=128)
@router.post("/{node_id}/residual/close")
async def node_residual_close(
node_id: int,
body: ResidualCloseBody,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
"""代理策略机手动平单条残留(只验流动性)。"""
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
if not node.get("token_sealed"):
raise HTTPException(status_code=400, detail="未生成 Token")
code, data = await call_node(
node,
"POST",
"/api/fleet/residual/close",
json_body={"group_id": body.group_id},
timeout=30.0,
)
if code >= 400:
_raise_node_error(code, data)
return {"ok": True, "result": data}
@router.post("/{node_id}/start")
async def node_start(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
code, data = await call_node(node, "POST", "/api/fleet/start")
if code >= 400:
_raise_node_error(code, data)
return {"ok": True, "result": data}
@router.post("/{node_id}/pause")
async def node_pause(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
code, data = await call_node(node, "POST", "/api/fleet/pause")
if code >= 400:
_raise_node_error(code, data)
return {"ok": True, "result": data}
@router.post("/{node_id}/update")
async def node_update(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
code, data = await call_node(node, "POST", "/api/fleet/update")
if code >= 400:
_raise_node_error(code, data)
return {"ok": True, "result": data}
@router.post("/{node_id}/login-url")
async def node_login_url(
node_id: int,
_user: Annotated[str, Depends(require_control_user)],
) -> dict:
"""用 Fleet Token 向策略机签发一次性登录票,返回可打开的 URL。"""
db = get_control_db()
node = db.get_node(node_id)
if not node:
raise HTTPException(status_code=404, detail="节点不存在")
code, data = await call_node(node, "POST", "/api/fleet/issue-login")
if code >= 400:
_raise_node_error(code, data)
path = ""
if isinstance(data, dict):
path = str(data.get("login_path") or "")
if not path:
raise HTTPException(status_code=502, detail="策略机未返回 login_path")
base = str(node["base_url"]).rstrip("/")
return {
"ok": True,
"url": f"{base}{path}",
"expires_in": data.get("expires_in") if isinstance(data, dict) else None,
}
+77
View File
@@ -0,0 +1,77 @@
"""中控登录 HMAC Token。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from .config import ControlSettings, get_control_settings
_bearer = HTTPBearer(auto_error=False)
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _b64url_decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def issue_token(username: str, settings: ControlSettings) -> tuple[str, int]:
exp = int(time.time()) + int(settings.control_token_ttl_sec)
payload = {
"u": username,
"exp": exp,
"v": int(settings.control_auth_token_version),
}
raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
sig = hmac.new(
settings.control_auth_secret.encode("utf-8"),
raw.encode("ascii"),
hashlib.sha256,
).hexdigest()
return f"{raw}.{sig}", settings.control_token_ttl_sec
def verify_token(token: str, settings: ControlSettings) -> str:
try:
raw, sig = token.rsplit(".", 1)
except ValueError as e:
raise HTTPException(status_code=401, detail="invalid token") from e
expect = hmac.new(
settings.control_auth_secret.encode("utf-8"),
raw.encode("ascii"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expect, sig):
raise HTTPException(status_code=401, detail="invalid token")
try:
payload = json.loads(_b64url_decode(raw))
except Exception as e:
raise HTTPException(status_code=401, detail="invalid token") from e
if int(payload.get("exp") or 0) < int(time.time()):
raise HTTPException(status_code=401, detail="token expired")
if int(payload.get("v") or 0) != int(settings.control_auth_token_version):
raise HTTPException(status_code=401, detail="token revoked")
username = str(payload.get("u") or "")
if not username:
raise HTTPException(status_code=401, detail="invalid token")
return username
def require_control_user(
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
settings: Annotated[ControlSettings, Depends(get_control_settings)],
) -> str:
if creds is None or not creds.credentials:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
return verify_token(creds.credentials, settings)
+66
View File
@@ -0,0 +1,66 @@
"""中控配置。"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
def _control_root() -> Path:
# control/backend/app/config.py -> control/
return Path(__file__).resolve().parents[2]
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
class ControlSettings(BaseSettings):
model_config = SettingsConfigDict(
env_file=str(_repo_root() / ".env.control"),
env_file_encoding="utf-8",
extra="ignore",
)
control_auth_username: str = "admin"
control_auth_password: str = "admin123"
control_auth_secret: str = "change-me-control-secret-please"
control_auth_token_version: int = 1
control_token_ttl_sec: int = 7 * 24 * 3600
control_db_path: str = ""
control_poll_interval_sec: int = 8
control_sse_interval_sec: float = 1.0
control_sse_probe_timeout_sec: float = 2.5
control_http_timeout_sec: float = 12.0
control_port: int = 5160
# "1"/"0":局域网客户端免密登录
control_lan_auth_bypass: str = "0"
@property
def db_path(self) -> Path:
if self.control_db_path.strip():
return Path(self.control_db_path)
return _control_root() / "data" / "control.db"
@property
def lan_auth_bypass(self) -> bool:
return self.control_lan_auth_bypass.strip().lower() in (
"1",
"true",
"yes",
"on",
)
@property
def is_default_credentials(self) -> bool:
return (
self.control_auth_username.strip() == "admin"
and self.control_auth_password == "admin123"
)
@lru_cache
def get_control_settings() -> ControlSettings:
return ControlSettings()

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