feat: show open grid lots

This commit is contained in:
王鹏
2026-07-09 11:17:51 +08:00
parent fe11929aa0
commit 5d66423895
8 changed files with 338 additions and 1 deletions

View File

@@ -125,6 +125,19 @@ class GridLevelSuggestion:
estimated_gross_profit: Decimal
@dataclass(frozen=True)
class OpenGridLot:
buy_trade_id: int | None
buy_date: date
buy_price: Decimal
remaining_quantity: int
actual_investment: Decimal
suggested_sell_price: Decimal
estimated_gross_profit: Decimal
current_price: Decimal | None
status: str
@dataclass(frozen=True)
class QuoteSnapshot:
symbol: str

View File

@@ -0,0 +1,77 @@
from __future__ import annotations
from dataclasses import replace
from datetime import date
from decimal import Decimal
from grid_trading.domain.calculations import money, price
from grid_trading.domain.models import OpenGridLot, Trade, TradeGroup, TradeSide
def calculate_open_grid_lots(
trades: list[Trade],
*,
spacing: Decimal,
current_price: Decimal | None,
as_of: date,
) -> list[OpenGridLot]:
if spacing <= 0 or spacing >= 1:
raise ValueError("网格间距必须大于 0 且小于 100%")
lots: list[OpenGridLot] = []
for trade in sorted(trades, key=lambda item: (item.trade_date, item.id or 0)):
if trade.trade_date > as_of or trade.trade_group is not TradeGroup.GRID:
continue
if trade.side is TradeSide.BUY:
buy_price = price(trade.price)
suggested_sell_price = price(buy_price * (Decimal("1") + spacing))
lots.append(
OpenGridLot(
buy_trade_id=trade.id,
buy_date=trade.trade_date,
buy_price=buy_price,
remaining_quantity=trade.quantity,
actual_investment=money(buy_price * Decimal(trade.quantity)),
suggested_sell_price=suggested_sell_price,
estimated_gross_profit=money(
(suggested_sell_price - buy_price) * Decimal(trade.quantity)
),
current_price=current_price,
status=_status(current_price, suggested_sell_price),
)
)
else:
lots = _match_sell_against_lots(lots, trade.quantity)
return lots
def _match_sell_against_lots(lots: list[OpenGridLot], quantity: int) -> list[OpenGridLot]:
remaining_sell_quantity = quantity
updated_lots: list[OpenGridLot] = []
for lot in lots:
if remaining_sell_quantity <= 0:
updated_lots.append(lot)
continue
matched_quantity = min(lot.remaining_quantity, remaining_sell_quantity)
remaining_sell_quantity -= matched_quantity
remaining_quantity = lot.remaining_quantity - matched_quantity
if remaining_quantity > 0:
updated_lots.append(_with_remaining_quantity(lot, remaining_quantity))
return updated_lots
def _with_remaining_quantity(lot: OpenGridLot, quantity: int) -> OpenGridLot:
return replace(
lot,
remaining_quantity=quantity,
actual_investment=money(lot.buy_price * Decimal(quantity)),
estimated_gross_profit=money((lot.suggested_sell_price - lot.buy_price) * Decimal(quantity)),
)
def _status(current_price: Decimal | None, suggested_sell_price: Decimal) -> str:
if current_price is None:
return "未刷新行情"
if current_price >= suggested_sell_price:
return "可卖"
return "未到价"

View File

@@ -20,6 +20,7 @@ from grid_trading.domain.models import (
FeeRules,
GridLevelSuggestion,
Instrument,
OpenGridLot,
PositionSummary,
QuoteSnapshot,
StrategyOverride,
@@ -27,6 +28,7 @@ from grid_trading.domain.models import (
Trade,
TradeSide,
)
from grid_trading.domain.open_grid_lots import calculate_open_grid_lots
from grid_trading.market.tencent import TencentQuoteProvider
from grid_trading.storage.repositories import Repository
@@ -234,6 +236,32 @@ class TradingService:
levels=levels,
)
def get_open_grid_lots(
self,
instrument_id: int,
*,
as_of: date | None = None,
) -> list[OpenGridLot]:
as_of_date = as_of or date.today()
self._require_instrument(instrument_id)
account = self.get_active_account()
trades = self.repository.list_trades(account_id=account.id, instrument_id=instrument_id)
position = next(
(
item
for item in self.get_position_summaries(as_of=as_of_date)
if item.instrument_id == instrument_id
),
None,
)
template = self.get_default_strategy_template()
return calculate_open_grid_lots(
trades,
spacing=template.grid_spacing_pct,
current_price=position.current_price if position is not None else None,
as_of=as_of_date,
)
def _require_account(self, account_id: int) -> Account:
account = self.repository.get_account(account_id)
if account is None:

View File

@@ -26,7 +26,7 @@ from PySide6.QtWidgets import (
)
from grid_trading.config import DEFAULT_DB_PATH
from grid_trading.domain.models import GridLevelSuggestion, PositionSummary, Trade, TradeSide
from grid_trading.domain.models import GridLevelSuggestion, OpenGridLot, PositionSummary, Trade, TradeSide
from grid_trading.services.trading_service import TradingService
from grid_trading.ui.dialogs import AccountDialog, InstrumentDialog, StrategyTemplateDialog, TradeDialog
from grid_trading.ui.formatters import format_money, format_percent, format_price, format_quantity
@@ -76,6 +76,16 @@ class MainWindow(QMainWindow):
"卖出价",
"预计单轮毛利润",
]
OPEN_GRID_LOT_COLUMNS = [
"买入日期",
"买入价",
"剩余股数",
"实际投入",
"建议卖出价",
"预计毛利润",
"当前价",
"状态",
]
def __init__(self, service: TradingService):
super().__init__()
@@ -156,6 +166,15 @@ class MainWindow(QMainWindow):
grid_layout.addLayout(grid_controls)
grid_layout.addWidget(self.grid_levels_table)
open_grid_group = QGroupBox("待卖网格")
open_grid_layout = QVBoxLayout(open_grid_group)
self.open_grid_lots_table = QTableWidget(0, len(self.OPEN_GRID_LOT_COLUMNS))
self.open_grid_lots_table.setHorizontalHeaderLabels(self.OPEN_GRID_LOT_COLUMNS)
self.open_grid_lots_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.open_grid_lots_table.horizontalHeader().setStretchLastSection(True)
self.open_grid_lots_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
open_grid_layout.addWidget(self.open_grid_lots_table)
trade_group = QGroupBox("最近成交")
trade_layout = QVBoxLayout(trade_group)
trade_buttons = QHBoxLayout()
@@ -176,6 +195,7 @@ class MainWindow(QMainWindow):
details.addWidget(self.detail_box)
details.addWidget(grid_group)
details.addWidget(open_grid_group)
details.addWidget(trade_group)
splitter.addWidget(details)
splitter.setSizes([470, 230])
@@ -324,6 +344,7 @@ class MainWindow(QMainWindow):
for label in self.detail_labels.values():
label.setText("-")
self._refresh_grid_levels(None)
self._refresh_open_grid_lots(None)
self._fill_trades_table([])
return
self.detail_labels["price_source"].setText(position.price_source)
@@ -335,6 +356,7 @@ class MainWindow(QMainWindow):
f"已实现 {format_money(position.realized_pnl)} / 网格 {format_money(position.grid_profit)}"
)
self._refresh_grid_levels(position)
self._refresh_open_grid_lots(position)
self._fill_trades_table(self.service.list_trades(instrument_id=position.instrument_id))
def _refresh_grid_levels(self, position: PositionSummary | None) -> None:
@@ -374,6 +396,34 @@ class MainWindow(QMainWindow):
for column, value in enumerate(values):
self.grid_levels_table.setItem(row, column, QTableWidgetItem(value))
def _refresh_open_grid_lots(self, position: PositionSummary | None) -> None:
if position is None:
self._fill_open_grid_lots_table([])
return
try:
lots = self.service.get_open_grid_lots(position.instrument_id)
except Exception as exc:
QMessageBox.warning(self, "待卖网格失败", str(exc))
self._fill_open_grid_lots_table([])
return
self._fill_open_grid_lots_table(lots)
def _fill_open_grid_lots_table(self, lots: list[OpenGridLot]) -> None:
self.open_grid_lots_table.setRowCount(len(lots))
for row, lot in enumerate(lots):
values = [
lot.buy_date.isoformat(),
format_price(lot.buy_price),
format_quantity(lot.remaining_quantity),
format_money(lot.actual_investment),
format_price(lot.suggested_sell_price),
format_money(lot.estimated_gross_profit),
format_price(lot.current_price),
lot.status,
]
for column, value in enumerate(values):
self.open_grid_lots_table.setItem(row, column, QTableWidgetItem(value))
def _fill_trades_table(self, trades: list[Trade]) -> None:
self._trade_ids_by_row = {}
self.trades_table.setRowCount(len(trades))