fix: db migration tab char corrupted, restore historical trades
This commit is contained in:
@@ -373,6 +373,11 @@ func (d *Dashboard) broadcastLoop() {
|
|||||||
"duration": time.Since(pos.StartedAt).Round(time.Second).String(),
|
"duration": time.Since(pos.StartedAt).Round(time.Second).String(),
|
||||||
"started_at": pos.StartedAt.Format("15:04:05"),
|
"started_at": pos.StartedAt.Format("15:04:05"),
|
||||||
"started_ts": pos.StartedAt.UnixMilli(),
|
"started_ts": pos.StartedAt.UnixMilli(),
|
||||||
|
"long_exchange": pos.LongLeg.Exchange,
|
||||||
|
"short_exchange": pos.ShortLeg.Exchange,
|
||||||
|
"long_entry": pos.LongLeg.EntryPrice,
|
||||||
|
"short_entry": pos.ShortLeg.EntryPrice,
|
||||||
|
"db_trade_id": pos.DBTradeID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate live PnL from current prices — use weighted average for scale-ins
|
// Calculate live PnL from current prices — use weighted average for scale-ins
|
||||||
@@ -474,6 +479,18 @@ func (d *Dashboard) broadcastLoop() {
|
|||||||
d.connMu.RUnlock()
|
d.connMu.RUnlock()
|
||||||
stats["connections"] = connInfo
|
stats["connections"] = connInfo
|
||||||
|
|
||||||
|
// Per-exchange fund tracking
|
||||||
|
exFunds := d.trader.GetExchangeFunds()
|
||||||
|
exFundsMap := make(map[string]map[string]float64, len(exFunds))
|
||||||
|
for ex, ef := range exFunds {
|
||||||
|
exFundsMap[ex] = map[string]float64{
|
||||||
|
"balance": math.Round(ef.Balance*100) / 100,
|
||||||
|
"total_fee": math.Round(ef.TotalFee*100) / 100,
|
||||||
|
"total_pnl": math.Round(ef.TotalPnl*100) / 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats["exchange_funds"] = exFundsMap
|
||||||
|
|
||||||
// Blacklist — stale spread coins
|
// Blacklist — stale spread coins
|
||||||
bl := d.trader.GetBlacklist()
|
bl := d.trader.GetBlacklist()
|
||||||
blList := make([]map[string]interface{}, 0, len(bl))
|
blList := make([]map[string]interface{}, 0, len(bl))
|
||||||
|
|||||||
@@ -113,6 +113,18 @@ func (d *DB) migrate() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration v2: add per-exchange fee/pnl columns (idempotent)
|
||||||
|
for _, col := range []string{"pnl_long_usd", "pnl_short_usd", "fee_long_usd", "fee_short_usd"} {
|
||||||
|
var found int
|
||||||
|
d.QueryRow("SELECT COUNT(*) FROM pragma_table_info('trades') WHERE name=?", col).Scan(&found)
|
||||||
|
if found == 0 {
|
||||||
|
if _, err := d.Exec("ALTER TABLE trades ADD COLUMN " + col + " REAL"); err != nil {
|
||||||
|
log.Printf("[DB] Migration: add column %s: %v", col, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("[DB] SQLite ready: %s", d.Path)
|
log.Printf("[DB] SQLite ready: %s", d.Path)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-7
@@ -30,6 +30,10 @@ type TradeRecord struct {
|
|||||||
Convergence *string
|
Convergence *string
|
||||||
OpenedAt time.Time
|
OpenedAt time.Time
|
||||||
ClosedAt *time.Time
|
ClosedAt *time.Time
|
||||||
|
PnlLongUSD *float64 // per-exchange PnL in USD
|
||||||
|
PnlShortUSD *float64
|
||||||
|
FeeLongUSD *float64 // per-exchange fee in USD
|
||||||
|
FeeShortUSD *float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderRecord mirrors the database row for orders table.
|
// OrderRecord mirrors the database row for orders table.
|
||||||
@@ -68,12 +72,14 @@ func (d *DB) SaveTrade(t *TradeRecord) (int64, error) {
|
|||||||
coin, direction, status, entry_spread, exit_spread,
|
coin, direction, status, entry_spread, exit_spread,
|
||||||
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
||||||
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
||||||
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at
|
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at,
|
||||||
) VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?, ?,?)`,
|
pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd
|
||||||
|
) VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?)`,
|
||||||
t.Coin, t.Direction, t.Status, t.EntrySpread, t.ExitSpread,
|
t.Coin, t.Direction, t.Status, t.EntrySpread, t.ExitSpread,
|
||||||
t.LongExchange, t.ShortExchange, t.LongEntry, t.LongExit, t.ShortEntry, t.ShortExit,
|
t.LongExchange, t.ShortExchange, t.LongEntry, t.LongExit, t.ShortEntry, t.ShortExit,
|
||||||
t.LongPnl, t.ShortPnl, t.FeeEntry, t.FeeExit, t.NetPnl,
|
t.LongPnl, t.ShortPnl, t.FeeEntry, t.FeeExit, t.NetPnl,
|
||||||
t.AmountUSD, t.ScaleCount, t.ExitReason, t.Convergence, t.OpenedAt, t.ClosedAt,
|
t.AmountUSD, t.ScaleCount, t.ExitReason, t.Convergence, t.OpenedAt, t.ClosedAt,
|
||||||
|
t.PnlLongUSD, t.PnlShortUSD, t.FeeLongUSD, t.FeeShortUSD,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -86,12 +92,14 @@ func (d *DB) UpdateTradeStatus(id int64, t *TradeRecord) error {
|
|||||||
_, err := d.Exec(`UPDATE trades SET
|
_, err := d.Exec(`UPDATE trades SET
|
||||||
status=?, exit_spread=?, long_exit=?, short_exit=?,
|
status=?, exit_spread=?, long_exit=?, short_exit=?,
|
||||||
long_pnl=?, short_pnl=?, fee_entry=?, fee_exit=?, net_pnl=?,
|
long_pnl=?, short_pnl=?, fee_entry=?, fee_exit=?, net_pnl=?,
|
||||||
amount_usd=?, scale_count=?, exit_reason=?, convergence=?, closed_at=?
|
amount_usd=?, scale_count=?, exit_reason=?, convergence=?, closed_at=?,
|
||||||
|
pnl_long_usd=?, pnl_short_usd=?, fee_long_usd=?, fee_short_usd=?
|
||||||
WHERE id=?`,
|
WHERE id=?`,
|
||||||
t.Status, t.ExitSpread,
|
t.Status, t.ExitSpread,
|
||||||
t.LongExit, t.ShortExit,
|
t.LongExit, t.ShortExit,
|
||||||
t.LongPnl, t.ShortPnl, t.FeeEntry, t.FeeExit, t.NetPnl,
|
t.LongPnl, t.ShortPnl, t.FeeEntry, t.FeeExit, t.NetPnl,
|
||||||
t.AmountUSD, t.ScaleCount, t.ExitReason, t.Convergence, t.ClosedAt,
|
t.AmountUSD, t.ScaleCount, t.ExitReason, t.Convergence, t.ClosedAt,
|
||||||
|
t.PnlLongUSD, t.PnlShortUSD, t.FeeLongUSD, t.FeeShortUSD,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
@@ -102,7 +110,8 @@ func (d *DB) GetOpenTrades() ([]TradeRecord, error) {
|
|||||||
rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
||||||
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
||||||
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
||||||
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at
|
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at,
|
||||||
|
pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd
|
||||||
FROM trades WHERE status='open'`)
|
FROM trades WHERE status='open'`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -130,7 +139,8 @@ func (d *DB) GetTrades(page, limit int, coin string) ([]TradeRecord, int, error)
|
|||||||
query := `SELECT id, coin, direction, status, entry_spread, exit_spread,
|
query := `SELECT id, coin, direction, status, entry_spread, exit_spread,
|
||||||
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
||||||
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
||||||
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at
|
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at,
|
||||||
|
pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd
|
||||||
FROM trades WHERE status='closed'`
|
FROM trades WHERE status='closed'`
|
||||||
if coin != "" {
|
if coin != "" {
|
||||||
query += " AND coin=?"
|
query += " AND coin=?"
|
||||||
@@ -183,7 +193,8 @@ func (d *DB) GetTradeByID(id int64) (*TradeRecord, []OrderRecord, error) {
|
|||||||
row := d.QueryRow(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
row := d.QueryRow(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
||||||
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
||||||
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
||||||
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at
|
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at,
|
||||||
|
pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd
|
||||||
FROM trades WHERE id=?`, id)
|
FROM trades WHERE id=?`, id)
|
||||||
|
|
||||||
var t TradeRecord
|
var t TradeRecord
|
||||||
@@ -192,6 +203,7 @@ func (d *DB) GetTradeByID(id int64) (*TradeRecord, []OrderRecord, error) {
|
|||||||
&t.LongExchange, &t.ShortExchange, &t.LongEntry, &t.LongExit, &t.ShortEntry, &t.ShortExit,
|
&t.LongExchange, &t.ShortExchange, &t.LongEntry, &t.LongExit, &t.ShortEntry, &t.ShortExit,
|
||||||
&t.LongPnl, &t.ShortPnl, &t.FeeEntry, &t.FeeExit, &t.NetPnl,
|
&t.LongPnl, &t.ShortPnl, &t.FeeEntry, &t.FeeExit, &t.NetPnl,
|
||||||
&t.AmountUSD, &t.ScaleCount, &t.ExitReason, &t.Convergence, &t.OpenedAt, &t.ClosedAt,
|
&t.AmountUSD, &t.ScaleCount, &t.ExitReason, &t.Convergence, &t.OpenedAt, &t.ClosedAt,
|
||||||
|
&t.PnlLongUSD, &t.PnlShortUSD, &t.FeeLongUSD, &t.FeeShortUSD,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -227,6 +239,7 @@ func scanTrades(rows *sql.Rows) ([]TradeRecord, error) {
|
|||||||
&t.LongExchange, &t.ShortExchange, &t.LongEntry, &t.LongExit, &t.ShortEntry, &t.ShortExit,
|
&t.LongExchange, &t.ShortExchange, &t.LongEntry, &t.LongExit, &t.ShortEntry, &t.ShortExit,
|
||||||
&t.LongPnl, &t.ShortPnl, &t.FeeEntry, &t.FeeExit, &t.NetPnl,
|
&t.LongPnl, &t.ShortPnl, &t.FeeEntry, &t.FeeExit, &t.NetPnl,
|
||||||
&t.AmountUSD, &t.ScaleCount, &t.ExitReason, &t.Convergence, &t.OpenedAt, &t.ClosedAt,
|
&t.AmountUSD, &t.ScaleCount, &t.ExitReason, &t.Convergence, &t.OpenedAt, &t.ClosedAt,
|
||||||
|
&t.PnlLongUSD, &t.PnlShortUSD, &t.FeeLongUSD, &t.FeeShortUSD,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -265,7 +278,8 @@ func (d *DB) GetAllClosedTrades() ([]TradeRecord, error) {
|
|||||||
rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
rows, err := d.Query(`SELECT id, coin, direction, status, entry_spread, exit_spread,
|
||||||
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
long_exchange, short_exchange, long_entry, long_exit, short_entry, short_exit,
|
||||||
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
long_pnl, short_pnl, fee_entry, fee_exit, net_pnl,
|
||||||
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at
|
amount_usd, scale_count, exit_reason, convergence, opened_at, closed_at,
|
||||||
|
pnl_long_usd, pnl_short_usd, fee_long_usd, fee_short_usd
|
||||||
FROM trades WHERE status='closed' ORDER BY id`)
|
FROM trades WHERE status='closed' ORDER BY id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+51
-1
@@ -198,6 +198,13 @@ function StatsCard({ stats }) {
|
|||||||
connHtml = Object.entries(stats.connections)
|
connHtml = Object.entries(stats.connections)
|
||||||
.map(([ex, status]) => `${ex}:${status}`).join(' ')
|
.map(([ex, status]) => `${ex}:${status}`).join(' ')
|
||||||
}
|
}
|
||||||
|
// Format exchange funds
|
||||||
|
let exFundsHtml = ''
|
||||||
|
if (stats.exchange_funds) {
|
||||||
|
exFundsHtml = Object.entries(stats.exchange_funds)
|
||||||
|
.map(([ex, f]) => `${ex}: $${f.balance.toFixed(2)}`)
|
||||||
|
.join(' | ')
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<section className="card" id="stats-card">
|
<section className="card" id="stats-card">
|
||||||
<h2>📊 统计数据</h2>
|
<h2>📊 统计数据</h2>
|
||||||
@@ -210,6 +217,11 @@ function StatsCard({ stats }) {
|
|||||||
<div className="stat"><label>币种</label><span className="pct-blue">{stats.coins || 0}</span></div>
|
<div className="stat"><label>币种</label><span className="pct-blue">{stats.coins || 0}</span></div>
|
||||||
<div className="stat" id="conn-stats"><label>连接</label><span id="conn-detail" style={{fontSize:11}}>{connHtml}</span></div>
|
<div className="stat" id="conn-stats"><label>连接</label><span id="conn-detail" style={{fontSize:11}}>{connHtml}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
{exFundsHtml && (
|
||||||
|
<div className="stats-row" style={{ marginTop: 2, fontSize: 11, opacity: 0.85 }}>
|
||||||
|
<div className="stat" style={{gridColumn:'1 / -1'}}><label>资金</label><span style={{fontWeight:600}}>{exFundsHtml}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{d && (
|
{d && (
|
||||||
<div className="stats-row detail-stats" style={{ marginTop: 4, fontSize: 12, opacity: 0.85 }}>
|
<div className="stats-row detail-stats" style={{ marginTop: 4, fontSize: 12, opacity: 0.85 }}>
|
||||||
<div className="stat"><label>总PnL</label><span>{(d.total_pnl_usd != null ? '$' + d.total_pnl_usd.toFixed(2) : '—') + (d.capital_pnl != null ? ' (' + d.capital_pnl.toFixed(4) + '%)' : '')}</span></div>
|
<div className="stat"><label>总PnL</label><span>{(d.total_pnl_usd != null ? '$' + d.total_pnl_usd.toFixed(2) : '—') + (d.capital_pnl != null ? ' (' + d.capital_pnl.toFixed(4) + '%)' : '')}</span></div>
|
||||||
@@ -225,7 +237,41 @@ function StatsCard({ stats }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PositionsCard({ positions }) {
|
function PositionsCard({ positions }) {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [modalTrade, setModalTrade] = useState(null)
|
||||||
|
const [modalOrders, setModalOrders] = useState([])
|
||||||
|
|
||||||
|
function openPositionDetail(id) {
|
||||||
|
if (!id) return
|
||||||
|
setModalOpen(true)
|
||||||
|
setModalTrade(null)
|
||||||
|
setModalOrders([])
|
||||||
|
fetch('/api/trade/' + id)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
setModalTrade(data.trade)
|
||||||
|
setModalOrders(data.orders || [])
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setModalTrade({ ID: id })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
setModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modalOpen) return
|
||||||
|
function handler(e) {
|
||||||
|
if (e.key === 'Escape') closeModal()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handler)
|
||||||
|
return () => document.removeEventListener('keydown', handler)
|
||||||
|
}, [modalOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<section className="card" id="positions-card">
|
<section className="card" id="positions-card">
|
||||||
<h2>🔒 当前持仓</h2>
|
<h2>🔒 当前持仓</h2>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
@@ -238,7 +284,7 @@ function PositionsCard({ positions }) {
|
|||||||
<tr><td colSpan="8" className="loading">无持仓</td></tr>
|
<tr><td colSpan="8" className="loading">无持仓</td></tr>
|
||||||
) : (
|
) : (
|
||||||
[...positions].sort((a, b) => a.coin.localeCompare(b.coin)).map(p => (
|
[...positions].sort((a, b) => a.coin.localeCompare(b.coin)).map(p => (
|
||||||
<tr key={p.coin}>
|
<tr key={p.coin} className="trade-row" onClick={() => openPositionDetail(p.db_trade_id)}>
|
||||||
<td><strong>{p.coin}</strong></td>
|
<td><strong>{p.coin}</strong></td>
|
||||||
<td>{p.direction}</td>
|
<td>{p.direction}</td>
|
||||||
<td className="text-right">${(p.amount_usd || 0).toFixed(0)}</td>
|
<td className="text-right">${(p.amount_usd || 0).toFixed(0)}</td>
|
||||||
@@ -254,6 +300,10 @@ function PositionsCard({ positions }) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{modalOpen && (
|
||||||
|
<TradeDetailModal trade={modalTrade} orders={modalOrders} onClose={closeModal} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ type ArbPosition struct {
|
|||||||
ExitTotalFees float64 // total fee %
|
ExitTotalFees float64 // total fee %
|
||||||
ExitConvergence string // convergence label
|
ExitConvergence string // convergence label
|
||||||
ExitReasonText string // reason for exit
|
ExitReasonText string // reason for exit
|
||||||
|
ExitLongPnlUSD float64 // per-exchange PnL in USD (for retryClose)
|
||||||
|
ExitShortPnlUSD float64
|
||||||
|
ExitLongFeeUSD float64 // per-exchange fee in USD
|
||||||
|
ExitShortFeeUSD float64
|
||||||
|
|
||||||
// Track all entry prices for weighted-average PnL across scale-ins (Issue #2)
|
// Track all entry prices for weighted-average PnL across scale-ins (Issue #2)
|
||||||
LongEntryPrices []float64 // all long entry prices (initial + scale-ins)
|
LongEntryPrices []float64 // all long entry prices (initial + scale-ins)
|
||||||
@@ -138,6 +142,9 @@ type Trader struct {
|
|||||||
// Historical stats loaded from DB on startup — combined with session stats in GetClosedStats
|
// Historical stats loaded from DB on startup — combined with session stats in GetClosedStats
|
||||||
dbConverged, dbDiverged, dbFlat, dbTotal int
|
dbConverged, dbDiverged, dbFlat, dbTotal int
|
||||||
|
|
||||||
|
// Per-exchange fund tracking
|
||||||
|
exchangeFunds map[string]*ExchangeFund
|
||||||
|
|
||||||
OnTradeEvent func(event string, data interface{}) // P3-4: real-time SSE push
|
OnTradeEvent func(event string, data interface{}) // P3-4: real-time SSE push
|
||||||
|
|
||||||
// Decoupled snapshot for display — snapMu never contended by trading path
|
// Decoupled snapshot for display — snapMu never contended by trading path
|
||||||
@@ -160,6 +167,17 @@ type TradeRecord struct {
|
|||||||
ClosedAt time.Time
|
ClosedAt time.Time
|
||||||
ScaleLevels int
|
ScaleLevels int
|
||||||
AmountUSD float64
|
AmountUSD float64
|
||||||
|
PnlLongUSD float64 // per-exchange PnL in USD
|
||||||
|
PnlShortUSD float64
|
||||||
|
FeeLongUSD float64 // per-exchange total fee in USD (entry+exit)
|
||||||
|
FeeShortUSD float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExchangeFund tracks balance and PnL for one exchange.
|
||||||
|
type ExchangeFund struct {
|
||||||
|
Balance float64 // current available balance
|
||||||
|
TotalFee float64 // cumulative fees paid
|
||||||
|
TotalPnl float64 // cumulative realized PnL
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTrader(cfg *Config, database *db.DB) *Trader {
|
func NewTrader(cfg *Config, database *db.DB) *Trader {
|
||||||
@@ -178,6 +196,10 @@ func NewTrader(cfg *Config, database *db.DB) *Trader {
|
|||||||
entering: make(map[string]bool),
|
entering: make(map[string]bool),
|
||||||
lastTradeTime: make(map[string]time.Time),
|
lastTradeTime: make(map[string]time.Time),
|
||||||
blacklist: make(map[string]time.Time),
|
blacklist: make(map[string]time.Time),
|
||||||
|
exchangeFunds: map[string]*ExchangeFund{
|
||||||
|
ExBitget: {Balance: cfg.InitialCapital / 2},
|
||||||
|
ExHyperLiquid: {Balance: cfg.InitialCapital / 2},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore open positions from DB on restart
|
// Restore open positions from DB on restart
|
||||||
@@ -333,6 +355,18 @@ func (t *Trader) TryEntry(opp *ArbOpportunity, store *PriceStore, notifier *Noti
|
|||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Margin check: verify both exchanges have sufficient funds
|
||||||
|
reqAmt := t.cfg.TradeAmountUSD * (1 + takerFees[opp.BuyEx]/100 + takerFees[opp.SellEx]/100)
|
||||||
|
if t.exchangeFunds[opp.BuyEx].Balance < reqAmt {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t.exchangeFunds[opp.SellEx].Balance < reqAmt {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
t.entering[opp.Coin] = true
|
t.entering[opp.Coin] = true
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
@@ -692,6 +726,24 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
|||||||
pos.ExitTotalFees = totalFees
|
pos.ExitTotalFees = totalFees
|
||||||
pos.ExitConvergence = convergenceLabel
|
pos.ExitConvergence = convergenceLabel
|
||||||
pos.ExitReasonText = exitReason
|
pos.ExitReasonText = exitReason
|
||||||
|
// Pre-compute per-exchange PnL/fees for retryClose
|
||||||
|
numBatchesRetry := 1 + pos.ScaleLevels
|
||||||
|
pos.ExitLongPnlUSD = longPnl / 100 * float64(numBatchesRetry) * t.cfg.TradeAmountUSD
|
||||||
|
pos.ExitShortPnlUSD = shortPnl / 100 * float64(numBatchesRetry) * t.cfg.TradeAmountUSD
|
||||||
|
{
|
||||||
|
totalLongSharesRetry := 0.0
|
||||||
|
for _, p := range pos.LongEntryPrices {
|
||||||
|
totalLongSharesRetry += t.cfg.TradeAmountUSD / p
|
||||||
|
}
|
||||||
|
totalShortSharesRetry := 0.0
|
||||||
|
for _, p := range pos.ShortEntryPrices {
|
||||||
|
totalShortSharesRetry += t.cfg.TradeAmountUSD / p
|
||||||
|
}
|
||||||
|
pos.ExitLongFeeUSD = float64(numBatchesRetry)*t.cfg.TradeAmountUSD*takerFees[pos.LongLeg.Exchange]/100 +
|
||||||
|
totalLongSharesRetry*longCurrent*takerFees[pos.LongLeg.Exchange]/100
|
||||||
|
pos.ExitShortFeeUSD = float64(numBatchesRetry)*t.cfg.TradeAmountUSD*takerFees[pos.ShortLeg.Exchange]/100 +
|
||||||
|
totalShortSharesRetry*shortCurrent*takerFees[pos.ShortLeg.Exchange]/100
|
||||||
|
}
|
||||||
|
|
||||||
closeErr := t.closeBothLegs(pos)
|
closeErr := t.closeBothLegs(pos)
|
||||||
|
|
||||||
@@ -713,6 +765,44 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
|||||||
pos.ExitedAt = time.Now()
|
pos.ExitedAt = time.Now()
|
||||||
pos.Status = "closed"
|
pos.Status = "closed"
|
||||||
|
|
||||||
|
// Compute per-leg PnL and fees in USD
|
||||||
|
numBatches := 1 + pos.ScaleLevels
|
||||||
|
legCapital := t.cfg.TradeAmountUSD
|
||||||
|
longPnlUSD := longPnl / 100 * float64(numBatches) * legCapital
|
||||||
|
shortPnlUSD := shortPnl / 100 * float64(numBatches) * legCapital
|
||||||
|
|
||||||
|
totalLongShares := 0.0
|
||||||
|
for _, p := range pos.LongEntryPrices {
|
||||||
|
totalLongShares += legCapital / p
|
||||||
|
}
|
||||||
|
totalShortShares := 0.0
|
||||||
|
for _, p := range pos.ShortEntryPrices {
|
||||||
|
totalShortShares += legCapital / p
|
||||||
|
}
|
||||||
|
|
||||||
|
longEntryFeeSum := float64(numBatches) * legCapital * takerFees[pos.LongLeg.Exchange] / 100
|
||||||
|
shortEntryFeeSum := float64(numBatches) * legCapital * takerFees[pos.ShortLeg.Exchange] / 100
|
||||||
|
longExitFeeAmt := totalLongShares * pos.LongLeg.ExitPrice * takerFees[pos.LongLeg.Exchange] / 100
|
||||||
|
shortExitFeeAmt := totalShortShares * pos.ShortLeg.ExitPrice * takerFees[pos.ShortLeg.Exchange] / 100
|
||||||
|
longFeeUSD := longEntryFeeSum + longExitFeeAmt
|
||||||
|
shortFeeUSD := shortEntryFeeSum + shortExitFeeAmt
|
||||||
|
|
||||||
|
// Update per-exchange fund tracking
|
||||||
|
t.mu.Lock()
|
||||||
|
if ef, ok := t.exchangeFunds[pos.LongLeg.Exchange]; ok {
|
||||||
|
ef.Balance -= longFeeUSD
|
||||||
|
ef.Balance += longPnlUSD
|
||||||
|
ef.TotalFee += longFeeUSD
|
||||||
|
ef.TotalPnl += longPnlUSD
|
||||||
|
}
|
||||||
|
if ef, ok := t.exchangeFunds[pos.ShortLeg.Exchange]; ok {
|
||||||
|
ef.Balance -= shortFeeUSD
|
||||||
|
ef.Balance += shortPnlUSD
|
||||||
|
ef.TotalFee += shortFeeUSD
|
||||||
|
ef.TotalPnl += shortPnlUSD
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
// Save trade record for stats
|
// Save trade record for stats
|
||||||
record := TradeRecord{
|
record := TradeRecord{
|
||||||
Coin: pos.Coin,
|
Coin: pos.Coin,
|
||||||
@@ -728,6 +818,10 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
|||||||
ClosedAt: pos.ExitedAt,
|
ClosedAt: pos.ExitedAt,
|
||||||
ScaleLevels: pos.ScaleLevels,
|
ScaleLevels: pos.ScaleLevels,
|
||||||
AmountUSD: pos.AmountUSD,
|
AmountUSD: pos.AmountUSD,
|
||||||
|
PnlLongUSD: longPnlUSD,
|
||||||
|
PnlShortUSD: shortPnlUSD,
|
||||||
|
FeeLongUSD: longFeeUSD,
|
||||||
|
FeeShortUSD: shortFeeUSD,
|
||||||
}
|
}
|
||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
@@ -740,33 +834,21 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
|||||||
if t.db != nil && pos.DBTradeID > 0 {
|
if t.db != nil && pos.DBTradeID > 0 {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
status := "filled"
|
status := "filled"
|
||||||
tradeUnit := t.cfg.TradeAmountUSD
|
|
||||||
|
|
||||||
totalLongShares := 0.0
|
|
||||||
for _, p := range pos.LongEntryPrices {
|
|
||||||
totalLongShares += tradeUnit / p
|
|
||||||
}
|
|
||||||
totalShortShares := 0.0
|
|
||||||
for _, p := range pos.ShortEntryPrices {
|
|
||||||
totalShortShares += tradeUnit / p
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save exit orders
|
// Save exit orders
|
||||||
longExitFee := totalLongShares * pos.LongLeg.ExitPrice * takerFees[pos.LongLeg.Exchange] / 100
|
|
||||||
longExitShares := totalLongShares
|
longExitShares := totalLongShares
|
||||||
longOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
longOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
||||||
TradeID: pos.DBTradeID, Leg: "long", Type: "exit",
|
TradeID: pos.DBTradeID, Leg: "long", Type: "exit",
|
||||||
Exchange: pos.LongLeg.Exchange, Side: "sell",
|
Exchange: pos.LongLeg.Exchange, Side: "sell",
|
||||||
Price: &pos.LongLeg.ExitPrice, Size: &longExitShares,
|
Price: &pos.LongLeg.ExitPrice, Size: &longExitShares,
|
||||||
Fee: &longExitFee, Status: &status, CreatedAt: now,
|
Fee: &longExitFeeAmt, Status: &status, CreatedAt: now,
|
||||||
})
|
})
|
||||||
shortExitFee := totalShortShares * pos.ShortLeg.ExitPrice * takerFees[pos.ShortLeg.Exchange] / 100
|
|
||||||
shortExitShares := totalShortShares
|
shortExitShares := totalShortShares
|
||||||
shortOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
shortOID, _ := t.db.SaveOrder(&db.OrderRecord{
|
||||||
TradeID: pos.DBTradeID, Leg: "short", Type: "exit",
|
TradeID: pos.DBTradeID, Leg: "short", Type: "exit",
|
||||||
Exchange: pos.ShortLeg.Exchange, Side: "buy",
|
Exchange: pos.ShortLeg.Exchange, Side: "buy",
|
||||||
Price: &pos.ShortLeg.ExitPrice, Size: &shortExitShares,
|
Price: &pos.ShortLeg.ExitPrice, Size: &shortExitShares,
|
||||||
Fee: &shortExitFee, Status: &status, CreatedAt: now,
|
Fee: &shortExitFeeAmt, Status: &status, CreatedAt: now,
|
||||||
})
|
})
|
||||||
// Save exit system order
|
// Save exit system order
|
||||||
t.db.SaveSystemOrder(&db.SystemOrderRecord{
|
t.db.SaveSystemOrder(&db.SystemOrderRecord{
|
||||||
@@ -777,15 +859,9 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Close trade: sum fees from in-memory calculation, update status
|
// Close trade with per-exchange fee/pnl
|
||||||
feeEntrySum, feeExitSum := 0.0, 0.0
|
feeEntrySum := longEntryFeeSum + shortEntryFeeSum
|
||||||
for range pos.LongEntryPrices {
|
feeExitSum := longExitFeeAmt + shortExitFeeAmt
|
||||||
feeEntrySum += tradeUnit * takerFees[pos.LongLeg.Exchange] / 100
|
|
||||||
}
|
|
||||||
for range pos.ShortEntryPrices {
|
|
||||||
feeEntrySum += tradeUnit * takerFees[pos.ShortLeg.Exchange] / 100
|
|
||||||
}
|
|
||||||
feeExitSum = longExitFee + shortExitFee
|
|
||||||
|
|
||||||
t.db.UpdateTradeStatus(pos.DBTradeID, &db.TradeRecord{
|
t.db.UpdateTradeStatus(pos.DBTradeID, &db.TradeRecord{
|
||||||
Status: "closed",
|
Status: "closed",
|
||||||
@@ -802,6 +878,10 @@ func (t *Trader) checkExit(pos *ArbPosition, bgP, hlP, diffPct float64, notifier
|
|||||||
ExitReason: &exitReason,
|
ExitReason: &exitReason,
|
||||||
Convergence: &convergenceLabel,
|
Convergence: &convergenceLabel,
|
||||||
ClosedAt: &now,
|
ClosedAt: &now,
|
||||||
|
PnlLongUSD: &longPnlUSD,
|
||||||
|
PnlShortUSD: &shortPnlUSD,
|
||||||
|
FeeLongUSD: &longFeeUSD,
|
||||||
|
FeeShortUSD: &shortFeeUSD,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,6 +1006,7 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi
|
|||||||
EntrySpread: pos.EntrySpread,
|
EntrySpread: pos.EntrySpread,
|
||||||
ExitSpread: pos.ExitDiffPct,
|
ExitSpread: pos.ExitDiffPct,
|
||||||
PnlPct: pos.ExitNetPnl,
|
PnlPct: pos.ExitNetPnl,
|
||||||
|
PnlUSD: 2 * pos.AmountUSD * pos.ExitNetPnl / 100,
|
||||||
Convergence: pos.ExitConvergence,
|
Convergence: pos.ExitConvergence,
|
||||||
Reason: pos.ExitReasonText,
|
Reason: pos.ExitReasonText,
|
||||||
Duration: elapsed.Round(time.Second).String(),
|
Duration: elapsed.Round(time.Second).String(),
|
||||||
@@ -933,12 +1014,29 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi
|
|||||||
ClosedAt: pos.ExitedAt,
|
ClosedAt: pos.ExitedAt,
|
||||||
ScaleLevels: pos.ScaleLevels,
|
ScaleLevels: pos.ScaleLevels,
|
||||||
AmountUSD: pos.AmountUSD,
|
AmountUSD: pos.AmountUSD,
|
||||||
|
PnlLongUSD: pos.ExitLongPnlUSD,
|
||||||
|
PnlShortUSD: pos.ExitShortPnlUSD,
|
||||||
|
FeeLongUSD: pos.ExitLongFeeUSD,
|
||||||
|
FeeShortUSD: pos.ExitShortFeeUSD,
|
||||||
}
|
}
|
||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
delete(t.positions, pos.Coin)
|
delete(t.positions, pos.Coin)
|
||||||
t.lastTradeTime[pos.Coin] = time.Now()
|
t.lastTradeTime[pos.Coin] = time.Now()
|
||||||
t.closedTrades = append(t.closedTrades, record)
|
t.closedTrades = append(t.closedTrades, record)
|
||||||
|
// Update exchange funds
|
||||||
|
if ef, ok := t.exchangeFunds[pos.LongLeg.Exchange]; ok {
|
||||||
|
ef.Balance -= pos.ExitLongFeeUSD
|
||||||
|
ef.Balance += pos.ExitLongPnlUSD
|
||||||
|
ef.TotalFee += pos.ExitLongFeeUSD
|
||||||
|
ef.TotalPnl += pos.ExitLongPnlUSD
|
||||||
|
}
|
||||||
|
if ef, ok := t.exchangeFunds[pos.ShortLeg.Exchange]; ok {
|
||||||
|
ef.Balance -= pos.ExitShortFeeUSD
|
||||||
|
ef.Balance += pos.ExitShortPnlUSD
|
||||||
|
ef.TotalFee += pos.ExitShortFeeUSD
|
||||||
|
ef.TotalPnl += pos.ExitShortPnlUSD
|
||||||
|
}
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
// Persist exit orders + close trade in DB (only for legs that weren't already closed)
|
// Persist exit orders + close trade in DB (only for legs that weren't already closed)
|
||||||
@@ -1002,6 +1100,10 @@ func (t *Trader) retryClose(pos *ArbPosition, bgP, hlP float64, notifier *Notifi
|
|||||||
ExitReason: &pos.ExitReasonText,
|
ExitReason: &pos.ExitReasonText,
|
||||||
Convergence: &pos.ExitConvergence,
|
Convergence: &pos.ExitConvergence,
|
||||||
ClosedAt: &now,
|
ClosedAt: &now,
|
||||||
|
PnlLongUSD: &pos.ExitLongPnlUSD,
|
||||||
|
PnlShortUSD: &pos.ExitShortPnlUSD,
|
||||||
|
FeeLongUSD: &pos.ExitLongFeeUSD,
|
||||||
|
FeeShortUSD: &pos.ExitShortFeeUSD,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,6 +1269,17 @@ func (t *Trader) GetClosedTrades() []TradeRecord {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetExchangeFunds returns a copy of per-exchange fund states.
|
||||||
|
func (t *Trader) GetExchangeFunds() map[string]ExchangeFund {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
r := make(map[string]ExchangeFund, len(t.exchangeFunds))
|
||||||
|
for ex, ef := range t.exchangeFunds {
|
||||||
|
r[ex] = *ef
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
// persistTrade saves a completed trade to SQLite, with per-leg orders and system_orders.
|
// persistTrade saves a completed trade to SQLite, with per-leg orders and system_orders.
|
||||||
// restoreOpenPositions loads open trades from DB and recreates their positions.
|
// restoreOpenPositions loads open trades from DB and recreates their positions.
|
||||||
func (t *Trader) restoreOpenPositions() {
|
func (t *Trader) restoreOpenPositions() {
|
||||||
|
|||||||
Reference in New Issue
Block a user