diff --git a/README.md b/README.md index 9c3b942..954c6b6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - 手动录入、编辑、删除买入/卖出成交。 - 自动计算持仓数量、T+1 可用数量、持仓成本、已实现盈亏、累计网格利润、持仓回本价、账户回本价。 - 点击“刷新行情”从腾讯接口获取实时价格,并用实时价更新持仓市值和账户摘要。 +- 根据刷新后的现价和默认网格策略,生成选中标的的网格档位建议。 - 使用 SQLite 本地保存数据,默认路径为 `data/grid_trading.db`。 ## 行情说明 @@ -24,6 +25,15 @@ http://qt.gtimg.cn/q= 实时行情只保存在当前程序内存中,用于显示现价、持仓市值、浮动盈亏、总资产和资金使用率;不会写入 SQLite。成交录入里的价格只作为成交价保存,不会作为现价兜底。关闭软件后再次打开,需要重新点击“刷新行情”。 +## 网格档位说明 + +选中标的后,底部“网格档位”表会根据腾讯现价和默认策略模板生成建议: + +- 买入价按 `现价 * (1 - 网格间距)` 逐档向下递减。 +- 卖出价按 `买入价 * (1 + 网格间距)` 计算。 +- 建议买入股数按标的交易单位向下取整。 +- 预计单轮毛利润不扣手续费,生成结果只用于展示,不会写入成交记录或 SQLite。 + ## 开发环境 ```powershell diff --git a/src/grid_trading/domain/grid_levels.py b/src/grid_trading/domain/grid_levels.py new file mode 100644 index 0000000..41e4ce7 --- /dev/null +++ b/src/grid_trading/domain/grid_levels.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from decimal import Decimal + +from grid_trading.domain.calculations import money, price +from grid_trading.domain.models import GridLevelSuggestion + + +def generate_grid_levels( + *, + current_price: Decimal, + spacing: Decimal, + amount_per_grid: Decimal, + lot_size: int, + levels: int, +) -> list[GridLevelSuggestion]: + if current_price <= 0: + raise ValueError("现价必须大于 0") + if spacing <= 0 or spacing >= 1: + raise ValueError("网格间距必须大于 0 且小于 100%") + if amount_per_grid <= 0: + raise ValueError("每格金额必须大于 0") + if lot_size <= 0: + raise ValueError("交易单位必须大于 0") + if levels < 1 or levels > 100: + raise ValueError("档数必须在 1 到 100 之间") + + suggestions: list[GridLevelSuggestion] = [] + buy_price = price(current_price * (Decimal("1") - spacing)) + for level in range(1, levels + 1): + quantity = int(amount_per_grid / buy_price) // lot_size * lot_size + actual_investment = money(buy_price * Decimal(quantity)) + sell_price = price(buy_price * (Decimal("1") + spacing)) + estimated_gross_profit = money((sell_price - buy_price) * Decimal(quantity)) + suggestions.append( + GridLevelSuggestion( + level=level, + buy_price=buy_price, + buy_amount=money(amount_per_grid), + suggested_quantity=quantity, + actual_investment=actual_investment, + sell_price=sell_price, + estimated_gross_profit=estimated_gross_profit, + ) + ) + buy_price = price(buy_price * (Decimal("1") - spacing)) + return suggestions diff --git a/src/grid_trading/domain/models.py b/src/grid_trading/domain/models.py index a16bd95..f241d4d 100644 --- a/src/grid_trading/domain/models.py +++ b/src/grid_trading/domain/models.py @@ -114,6 +114,17 @@ class FeeEstimate: return self.commission + self.stamp_tax + self.transfer_fee +@dataclass(frozen=True) +class GridLevelSuggestion: + level: int + buy_price: Decimal + buy_amount: Decimal + suggested_quantity: int + actual_investment: Decimal + sell_price: Decimal + estimated_gross_profit: Decimal + + @dataclass(frozen=True) class QuoteSnapshot: symbol: str diff --git a/src/grid_trading/services/trading_service.py b/src/grid_trading/services/trading_service.py index d6837b0..7b97c30 100644 --- a/src/grid_trading/services/trading_service.py +++ b/src/grid_trading/services/trading_service.py @@ -12,11 +12,13 @@ from grid_trading.domain.calculations import ( calculate_positions, estimate_fees, ) +from grid_trading.domain.grid_levels import generate_grid_levels from grid_trading.domain.models import ( Account, AccountSummary, FeeEstimate, FeeRules, + GridLevelSuggestion, Instrument, PositionSummary, QuoteSnapshot, @@ -206,6 +208,32 @@ class TradingService: ] return calculate_account_summary(account, positions, ledger_entries, trades) + def get_grid_level_suggestions( + self, + instrument_id: int, + *, + levels: int = 10, + ) -> list[GridLevelSuggestion]: + instrument = self._require_instrument(instrument_id) + position = next( + ( + item + for item in self.get_position_summaries() + if item.instrument_id == instrument_id + ), + None, + ) + if position is None or position.current_price is None: + return [] + template = self.get_default_strategy_template() + return generate_grid_levels( + current_price=position.current_price, + spacing=template.grid_spacing_pct, + amount_per_grid=template.amount_per_grid, + lot_size=instrument.lot_size, + levels=levels, + ) + def _require_account(self, account_id: int) -> Account: account = self.repository.get_account(account_id) if account is None: diff --git a/src/grid_trading/ui/main_window.py b/src/grid_trading/ui/main_window.py index 2cb110c..2b763f1 100644 --- a/src/grid_trading/ui/main_window.py +++ b/src/grid_trading/ui/main_window.py @@ -18,6 +18,7 @@ from PySide6.QtWidgets import ( QMessageBox, QPushButton, QSplitter, + QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, @@ -25,7 +26,7 @@ from PySide6.QtWidgets import ( ) from grid_trading.config import DEFAULT_DB_PATH -from grid_trading.domain.models import PositionSummary, Trade, TradeSide +from grid_trading.domain.models import GridLevelSuggestion, 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 @@ -66,6 +67,15 @@ class MainWindow(QMainWindow): "浮动盈亏", ] TRADE_COLUMNS = ["日期", "方向", "分组", "价格", "数量", "费用", "备注"] + GRID_LEVEL_COLUMNS = [ + "档位", + "买入价", + "买入金额", + "建议买入股数", + "实际投入", + "卖出价", + "预计单轮毛利润", + ] def __init__(self, service: TradingService): super().__init__() @@ -126,6 +136,26 @@ class MainWindow(QMainWindow): self.detail_layout.addWidget(QLabel("利润"), 3, 0) self.detail_layout.addWidget(self.detail_labels["profit"], 3, 1) + grid_group = QGroupBox("网格档位") + grid_layout = QVBoxLayout(grid_group) + grid_controls = QHBoxLayout() + self.grid_levels_count_edit = QSpinBox() + self.grid_levels_count_edit.setRange(1, 100) + self.grid_levels_count_edit.setValue(10) + self.grid_levels_count_edit.valueChanged.connect(lambda _value: self._refresh_details()) + self.grid_levels_hint = QLabel("请先刷新行情") + grid_controls.addWidget(QLabel("档数")) + grid_controls.addWidget(self.grid_levels_count_edit) + grid_controls.addWidget(self.grid_levels_hint) + grid_controls.addStretch() + self.grid_levels_table = QTableWidget(0, len(self.GRID_LEVEL_COLUMNS)) + self.grid_levels_table.setHorizontalHeaderLabels(self.GRID_LEVEL_COLUMNS) + self.grid_levels_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + self.grid_levels_table.horizontalHeader().setStretchLastSection(True) + self.grid_levels_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + grid_layout.addLayout(grid_controls) + grid_layout.addWidget(self.grid_levels_table) + trade_group = QGroupBox("最近成交") trade_layout = QVBoxLayout(trade_group) trade_buttons = QHBoxLayout() @@ -145,6 +175,7 @@ class MainWindow(QMainWindow): trade_layout.addWidget(self.trades_table) details.addWidget(self.detail_box) + details.addWidget(grid_group) details.addWidget(trade_group) splitter.addWidget(details) splitter.setSizes([470, 230]) @@ -292,6 +323,7 @@ class MainWindow(QMainWindow): if position is None: for label in self.detail_labels.values(): label.setText("-") + self._refresh_grid_levels(None) self._fill_trades_table([]) return self.detail_labels["price_source"].setText(position.price_source) @@ -302,8 +334,46 @@ class MainWindow(QMainWindow): self.detail_labels["profit"].setText( f"已实现 {format_money(position.realized_pnl)} / 网格 {format_money(position.grid_profit)}" ) + self._refresh_grid_levels(position) self._fill_trades_table(self.service.list_trades(instrument_id=position.instrument_id)) + def _refresh_grid_levels(self, position: PositionSummary | None) -> None: + if position is None: + self.grid_levels_hint.setText("-") + self._fill_grid_levels_table([]) + return + if position.current_price is None: + self.grid_levels_hint.setText("请先刷新行情") + self._fill_grid_levels_table([]) + return + try: + levels = self.service.get_grid_level_suggestions( + position.instrument_id, + levels=self.grid_levels_count_edit.value(), + ) + except Exception as exc: + QMessageBox.warning(self, "网格档位失败", str(exc)) + self.grid_levels_hint.setText(str(exc)) + self._fill_grid_levels_table([]) + return + self.grid_levels_hint.setText("") + self._fill_grid_levels_table(levels) + + def _fill_grid_levels_table(self, levels: list[GridLevelSuggestion]) -> None: + self.grid_levels_table.setRowCount(len(levels)) + for row, level in enumerate(levels): + values = [ + str(level.level), + format_price(level.buy_price), + format_money(level.buy_amount), + format_quantity(level.suggested_quantity), + format_money(level.actual_investment), + format_price(level.sell_price), + format_money(level.estimated_gross_profit), + ] + for column, value in enumerate(values): + self.grid_levels_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)) diff --git a/tests/test_grid_levels.py b/tests/test_grid_levels.py new file mode 100644 index 0000000..c29cbed --- /dev/null +++ b/tests/test_grid_levels.py @@ -0,0 +1,69 @@ +from decimal import Decimal + +import pytest + +from grid_trading.domain.grid_levels import generate_grid_levels + + +def test_generate_grid_levels_compounds_percentage_spacing_and_rounds_values(): + levels = generate_grid_levels( + current_price=Decimal("10.00"), + spacing=Decimal("0.03"), + amount_per_grid=Decimal("10000"), + lot_size=100, + levels=3, + ) + + assert [item.level for item in levels] == [1, 2, 3] + assert [item.buy_price for item in levels] == [Decimal("9.70"), Decimal("9.41"), Decimal("9.13")] + assert [item.buy_amount for item in levels] == [Decimal("10000.00")] * 3 + assert [item.suggested_quantity for item in levels] == [1000, 1000, 1000] + assert [item.actual_investment for item in levels] == [ + Decimal("9700.00"), + Decimal("9410.00"), + Decimal("9130.00"), + ] + assert [item.sell_price for item in levels] == [Decimal("9.99"), Decimal("9.69"), Decimal("9.40")] + assert [item.estimated_gross_profit for item in levels] == [ + Decimal("290.00"), + Decimal("280.00"), + Decimal("270.00"), + ] + + +def test_generate_grid_levels_uses_zero_quantity_when_amount_cannot_buy_one_lot(): + [level] = generate_grid_levels( + current_price=Decimal("10.00"), + spacing=Decimal("0.03"), + amount_per_grid=Decimal("500"), + lot_size=100, + levels=1, + ) + + assert level.buy_price == Decimal("9.70") + assert level.suggested_quantity == 0 + assert level.actual_investment == Decimal("0.00") + assert level.estimated_gross_profit == Decimal("0.00") + + +@pytest.mark.parametrize( + ("current_price", "spacing", "amount_per_grid", "lot_size", "levels"), + [ + (Decimal("0"), Decimal("0.03"), Decimal("10000"), 100, 10), + (Decimal("10"), Decimal("0"), Decimal("10000"), 100, 10), + (Decimal("10"), Decimal("1"), Decimal("10000"), 100, 10), + (Decimal("10"), Decimal("0.03"), Decimal("0"), 100, 10), + (Decimal("10"), Decimal("0.03"), Decimal("10000"), 0, 10), + (Decimal("10"), Decimal("0.03"), Decimal("10000"), 100, 0), + (Decimal("10"), Decimal("0.03"), Decimal("10000"), 100, 101), + ], +) +def test_generate_grid_levels_validates_inputs(current_price, spacing, amount_per_grid, lot_size, levels): + with pytest.raises(ValueError): + generate_grid_levels( + current_price=current_price, + spacing=spacing, + amount_per_grid=amount_per_grid, + lot_size=lot_size, + levels=levels, + ) diff --git a/tests/test_services.py b/tests/test_services.py index 92f4948..afa1b12 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -223,3 +223,38 @@ def test_service_refresh_quotes_uses_realtime_price_in_summaries(tmp_path): assert position.current_price == Decimal("4.12") assert position.price_source == "tencent" assert summary.market_value == Decimal("4120.00") + + +def test_service_generates_grid_level_suggestions_from_realtime_price(tmp_path): + service = TradingService(tmp_path / "grid.db", quote_provider=FakeQuoteProvider()) + service.ensure_defaults() + account = service.get_active_account() + instrument = service.add_instrument(Instrument(id=None, code="510300", name="沪深300ETF", market="ETF")) + service.save_trade( + Trade( + id=None, + account_id=account.id, + instrument_id=instrument.id, + trade_date=date(2026, 7, 7), + side=TradeSide.BUY, + price=Decimal("4.00"), + quantity=1000, + trade_group=TradeGroup.BASE, + ) + ) + service.refresh_quotes() + + levels = service.get_grid_level_suggestions(instrument.id, levels=2) + + assert [item.buy_price for item in levels] == [Decimal("4.00"), Decimal("3.88")] + assert [item.sell_price for item in levels] == [Decimal("4.12"), Decimal("4.00")] + assert [item.buy_amount for item in levels] == [Decimal("5000.00"), Decimal("5000.00")] + assert [item.suggested_quantity for item in levels] == [1200, 1200] + + +def test_service_returns_empty_grid_levels_without_realtime_price(tmp_path): + service = TradingService(tmp_path / "grid.db") + service.ensure_defaults() + instrument = service.add_instrument(Instrument(id=None, code="510300", name="沪深300ETF", market="ETF")) + + assert service.get_grid_level_suggestions(instrument.id, levels=10) == [] diff --git a/tests/test_ui.py b/tests/test_ui.py index d1049a3..ec52b0b 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -35,6 +35,26 @@ def test_main_window_can_be_constructed_offscreen(tmp_path, monkeypatch): app.processEvents() +def test_main_window_contains_grid_level_table(tmp_path, monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + + from PySide6.QtWidgets import QApplication, QGroupBox + + from grid_trading.services.trading_service import TradingService + from grid_trading.ui.main_window import MainWindow + + app = QApplication.instance() or QApplication([]) + service = TradingService(tmp_path / "grid.db") + window = MainWindow(service) + + assert window.grid_levels_table.columnCount() == 7 + assert any(group.title() == "网格档位" for group in window.findChildren(QGroupBox)) + + window.close() + service.close() + app.processEvents() + + def test_instrument_dialog_does_not_collect_manual_current_price(monkeypatch): monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")