feat: 优化界面并补充网格成交分析

This commit is contained in:
王鹏
2026-07-14 16:21:32 +08:00
parent 8b51463387
commit b1eea9f5b4
12 changed files with 1014 additions and 66 deletions

217
README.md
View File

@@ -1,73 +1,216 @@
# Grid Trading # Grid Trading:本地网格交易管理器
本项目是一个本地运行的 Python GUI 网格交易管理工具,第一阶段聚焦 A股/ETF 的账户、标的手工成交持仓成本、网格收益回本价计算 一个使用 Python 和 PySide6 开发的本地桌面工具,用于管理 A 股、ETF 的账户、标的手工成交,并辅助计算持仓成本、网格收益回本价和网格档位
## 第一阶段功能 > 本项目只提供交易记录与策略辅助功能,不会连接券商账户,也不会自动下单。所有计算结果仅供参考,不构成投资建议。
- 创建/编辑本地账户和初始资金。 ## 功能特性
- 添加 A股/ETF 标的,维护交易单位。
- 配置默认网格策略模板。
- 手动录入、编辑、删除买入/卖出成交。
- 自动计算持仓数量、T+1 可用数量、持仓成本、已实现盈亏、累计网格利润、持仓回本价、账户回本价。
- 点击“刷新行情”从腾讯接口获取实时价格,并用实时价更新持仓市值和账户摘要。
- 根据刷新后的现价和默认网格策略,生成选中标的的网格档位建议。
- 使用 SQLite 本地保存数据,默认路径为 `data/grid_trading.db`
## 行情说明 - **账户概览**:展示账户权益、现金、持仓市值、浮动盈亏和资金使用率。
- **标的管理**:维护股票或 ETF 的代码、名称、市场、交易单位及零股规则。
- **成交管理**:手工录入、编辑和删除买卖记录,并区分底仓、网格仓和其他仓位。
- **费用估算**:按内置费率估算佣金、印花税和过户费,估算后仍可手工调整。
- **持仓分析**计算总持仓、T+1 可用数量、剩余成本、已实现盈亏、浮动盈亏及网格利润。
- **回本价计算**:同时提供持仓回本价与账户回本价,便于观察网格收益对成本的影响。
- **实时行情**:异步获取腾讯行情,刷新过程中不会阻塞主界面。
- **网格建议**:根据实时价格和默认策略,生成最多 100 档的买入价、建议数量、卖出价与预计毛利润。
- **待卖网格**:按批次展示尚未卖出的网格买入及其目标卖出价、当前状态。
- **成交配对**:使用 FIFO先进先出将网格卖出与历史网格买入配对一笔卖出可拆分匹配多笔买入。
- **本地存储**:账户、标的、策略和成交数据均保存在 SQLite 数据库中。
行情刷新使用腾讯接口: ## 技术栈
```text - Python 3.11+
http://qt.gtimg.cn/q=<symbol> - PySide6
- SQLite
- pytest
## 快速开始
### 1. 进入项目目录
```powershell
cd Grid_Trading
``` ```
例如 `000001` 会自动转换为 `sz000001``600000` 会自动转换为 `sh600000` 请先下载或克隆项目,再进入项目根目录
实时行情只保存在当前程序内存中,用于显示现价、持仓市值、浮动盈亏、总资产和资金使用率;不会写入 SQLite。成交录入里的价格只作为成交价保存不会作为现价兜底。关闭软件后再次打开需要重新点击“刷新行情”。 ### 2. 创建虚拟环境并安装依赖
## 网格档位说明 Windows PowerShell
选中标的后,底部“网格档位”表会根据腾讯现价和默认策略模板生成建议:
- 买入价按 `现价 * (1 - 网格间距)` 逐档向下递减。
- 卖出价按 `买入价 * (1 + 网格间距)` 计算。
- 建议买入股数按标的交易单位向下取整。
- 预计单轮毛利润不扣手续费,生成结果只用于展示,不会写入成交记录或 SQLite。
## 待卖网格说明
底部“待卖网格”表会展示尚未被网格卖出抵消的网格买入批次,包括买入价、剩余股数、建议卖出价、预计毛利润、当前价和状态。网格卖出按时间顺序 FIFO 抵消最早的网格买入;建议卖出价按 `买入价 * (1 + 网格间距)` 计算。
## 开发环境
```powershell ```powershell
python -m venv .venv python -m venv .venv
.\.venv\Scripts\Activate.ps1 .\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]" python -m pip install -e ".[dev]"
pytest
``` ```
## 启动 macOS / Linux
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
### 3. 启动程序
```powershell ```powershell
python -m grid_trading.app python -m grid_trading.app
``` ```
也可以指定数据库路径 安装后也可以使用命令行入口
```powershell
grid-trading
```
默认数据库位置为 `data/grid_trading.db`。如需使用其他数据库文件:
```powershell ```powershell
python -m grid_trading.app --db data/dev.db python -m grid_trading.app --db data/dev.db
``` ```
## 验证 首次启动时,程序会自动创建:
- 初始资金为 `0` 的“默认账户”;
- 间距为 `3%`、每格金额为 `5000` 元的“默认网格模板”;
- 数据库及所需数据表。
## 基本使用流程
1. 点击“账户设置”,填写账户名称和初始资金。
2. 点击“添加标的”,填写证券代码、名称、市场和交易单位。
3. 点击“策略设置”,调整网格间距、每格金额等参数。
4. 点击“录入成交”,选择买卖方向、仓位分组并填写实际成交信息。
5. 点击“刷新行情”,获取现价并更新持仓市值、浮动盈亏和账户摘要。
6. 在持仓表中选择一个标的,查看“网格档位”“待卖网格”“配对明细”和“最近成交”。
## 行情说明
行情数据来自腾讯接口:
```text
http://qt.gtimg.cn/q=<symbol>
```
程序会根据六位证券代码推断交易所前缀,例如:
| 输入代码 | 请求代码 |
| --- | --- |
| `000001` | `sz000001` |
| `600000` | `sh600000` |
| `510300` | `sh510300` |
| `430047` | `bj430047` |
需要注意:
- 行情刷新需要网络连接,单次请求超时时间为 5 秒,失败后会自动重试一次。
- 行情快照只保存在当前进程内存中,不会写入 SQLite。
- 重新启动程序后,需要再次点击“刷新行情”。
- 未刷新行情时,不使用手工价格或最近成交价代替现价,因此市值、浮动盈亏和网格档位可能为空。
## 计算口径
### 持仓与盈亏
- 买入成本 = 成交金额 + 交易费用。
- 卖出净收入 = 成交金额 - 交易费用。
- 同一仓位分组内的卖出成本按移动平均成本释放。
- 已实现盈亏 = 卖出净收入 - 被释放的持仓成本。
- 网格利润只统计“网格”分组的已实现盈亏。
- T+1 可用数量会扣除计算当日的买入数量;卖出数量不得超过可用数量,也不得超过对应分组的持仓数量。
- 修改或删除历史成交后,程序会重新校验后续成交,避免形成负持仓。
### 回本价与账户摘要
- 持仓回本价 =(剩余持仓成本 - 已实现网格利润)/ 当前持仓数量。
- 账户回本价 = 标的累计净投入 / 当前持仓数量。
- 账户权益 = 现金 + 持仓市值。
- 资金使用率 = 持仓市值 /(持仓市值 + 非负现金);现金为负时最高显示为 100%。
### 网格档位
第 1 档买入价按下式计算,之后逐档复合递减:
```text
买入价 = 上一档参考价 × (1 - 网格间距)
卖出价 = 买入价 × (1 + 网格间距)
建议数量 = 每格金额 ÷ 买入价,并按交易单位向下取整
```
档位表中的“预计单轮毛利润”不扣除交易费用,且建议结果只用于展示,不会自动生成成交记录或下单。
### 待卖网格与配对明细
- 只有“网格”分组的成交参与待卖批次和配对计算。
- 网格卖出按照成交日期和记录顺序,优先抵消最早的网格买入。
- 待卖批次状态包括“未刷新行情”“未到价”和“可卖”。
- 配对明细展示历史买卖的对应关系和毛利润,不扣除费用。
## 默认费用参数
点击成交窗口中的“估算费用”时,程序使用以下默认参数:
| 项目 | 默认值 | 说明 |
| --- | ---: | --- |
| 佣金 | 0.025% | 最低 5 元,买卖双向收取 |
| 印花税 | 0.05% | 仅卖出时收取 |
| 过户费 | 0.001% | 买卖双向收取 |
不同券商、市场和交易品种的实际费用可能不同,请以实际交割单为准,并在保存成交前手工修正。
## 数据与隐私
- 默认数据文件:`data/grid_trading.db`
- 账户、成交和策略数据仅存放在本机;刷新行情时只会向腾讯行情接口发送证券代码。
- `data/*.db``data/*.sqlite``data/backups/` 已在 `.gitignore` 中忽略。
- 建议定期备份数据库文件;复制数据库前请先退出程序,避免备份到尚未提交的写入状态。
## 项目结构
```text
Grid_Trading/
├── src/grid_trading/
│ ├── app.py # 命令行入口
│ ├── config.py # 默认路径与费用配置
│ ├── domain/ # 数据模型、持仓和网格计算
│ ├── market/ # 腾讯行情适配
│ ├── services/ # 业务编排与校验
│ ├── storage/ # SQLite 数据库与仓储层
│ └── ui/ # PySide6 主窗口、对话框与格式化
├── tests/ # 自动化测试
├── docs/ # 设计说明与开发计划
├── data/ # 本地数据库目录
└── pyproject.toml # 项目及依赖配置
```
## 测试
运行完整测试:
```powershell ```powershell
pytest -v pytest -v
python -m grid_trading.app --help
``` ```
如果只想确认 GUI 能构造起来,不打开真实窗口,可以运行测试里的 offscreen smoke 只验证界面可在无显示环境下正常构造
```powershell ```powershell
pytest tests/test_ui.py -v pytest tests/test_ui.py -v
``` ```
检查命令行参数:
```powershell
python -m grid_trading.app --help
```
## 当前限制
- 当前定位为单机、单用户的手工交易管理工具。
- 不包含券商登录、自动下单、条件单或实盘同步功能。
- 行情接口不是正式授权的数据服务,其可用性、实时性和准确性不作保证。
- 网格建议未考虑手续费、滑点、涨跌停、停牌和流动性等实际交易约束。
- 使用本项目产生的任何交易决策与风险均由使用者自行承担。

View File

@@ -0,0 +1,200 @@
# GUI Visual Refresh Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refresh the PySide6 main window into a clean light desktop-tool style and apply A-share profit/loss colors to all PnL-like fields.
**Architecture:** Keep the existing `MainWindow` layout and data flow. Add focused UI helpers in `main_window.py` for theme setup, table setup, and PnL styling, then cover them with lightweight offscreen GUI tests.
**Tech Stack:** Python 3.11+, PySide6, pytest.
---
## File Structure
- Modify `src/grid_trading/ui/main_window.py`: apply QSS theme, assign object names, centralize table setup, and style PnL labels/items.
- Modify `tests/test_ui.py`: add offscreen GUI tests for theme markers and profit/loss item styling.
- No domain, service, storage, or database changes.
### Task 1: Add Failing GUI Tests
**Files:**
- Test: `tests/test_ui.py`
- [ ] **Step 1: Write failing tests for theme markers and PnL colors**
```python
def test_main_window_applies_light_theme(tmp_path, monkeypatch):
monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
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.centralWidget().objectName() == "appRoot"
assert window.holdings_table.alternatingRowColors()
assert "QMainWindow" in window.styleSheet()
window.close()
service.close()
app.processEvents()
```
```python
def test_profit_and_loss_cells_use_a_share_colors(tmp_path, monkeypatch):
monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")
from datetime import datetime
from decimal import Decimal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QApplication
from grid_trading.domain.models import PositionSummary
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)
window._positions = [
PositionSummary(
instrument_id=1,
code="510300",
name="沪深300ETF",
total_quantity=100,
available_quantity=100,
base_quantity=0,
grid_quantity=100,
remaining_cost=Decimal("1000"),
average_cost=Decimal("10"),
position_breakeven_price=Decimal("10"),
account_breakeven_price=Decimal("10"),
realized_pnl=Decimal("12.34"),
grid_profit=Decimal("-5.67"),
current_price=Decimal("10.50"),
market_value=Decimal("1050"),
floating_pnl=Decimal("50"),
last_quote_at=datetime(2026, 7, 9, 10, 0, 0),
)
]
window._fill_holdings_table()
profit_item = window.holdings_table.item(0, 10)
loss_item = window.holdings_table.item(0, 11)
assert profit_item.foreground().color() == QColor("#c62828")
assert profit_item.font().bold()
assert loss_item.foreground().color() == QColor("#2e7d32")
assert loss_item.font().bold()
window.close()
service.close()
app.processEvents()
```
- [ ] **Step 2: Run the tests and verify they fail**
Run:
```bash
pytest tests/test_ui.py::test_main_window_applies_light_theme tests/test_ui.py::test_profit_and_loss_cells_use_a_share_colors -q
```
Expected: both new tests fail because object names, theme QSS, alternating row setup, and PnL item colors do not exist yet.
### Task 2: Add Light Theme and Shared Table Setup
**Files:**
- Modify: `src/grid_trading/ui/main_window.py`
- [ ] **Step 1: Add imports and theme constants**
Add `Decimal`, `QBrush`, `QColor`, `QFont`, and `QAbstractItemView` imports needed by the helpers.
- [ ] **Step 2: Apply theme in `_build_ui`**
Set `root.setObjectName("appRoot")`, call `self._apply_light_theme()`, and set object names on the navigation, summary card frames, toolbar buttons, tab widget, and tables.
- [ ] **Step 3: Add `_apply_light_theme` and `_configure_table`**
Create helpers that set the QSS theme once and apply consistent table settings: alternating rows, row selection, no editing, visible grid, row height, and header resize behavior.
- [ ] **Step 4: Run theme test**
Run:
```bash
pytest tests/test_ui.py::test_main_window_applies_light_theme -q
```
Expected: PASS.
### Task 3: Add PnL Styling Helpers
**Files:**
- Modify: `src/grid_trading/ui/main_window.py`
- [ ] **Step 1: Add `_style_pnl_item`, `_style_pnl_label`, and `_make_item`**
Use the raw `Decimal | None` value, not the formatted string, to decide color and font weight:
- `value > 0`: foreground `#c62828`, bold.
- `value < 0`: foreground `#2e7d32`, bold.
- otherwise: default foreground, non-bold.
- [ ] **Step 2: Use helpers in summary and tables**
Apply the helpers to summary floating PnL and these table columns:
- holdings: columns 10, 11, 12.
- grid levels: column 6.
- open grid lots: column 5.
- grid matches: column 7.
- [ ] **Step 3: Run PnL style test**
Run:
```bash
pytest tests/test_ui.py::test_profit_and_loss_cells_use_a_share_colors -q
```
Expected: PASS.
### Task 4: Full Verification
**Files:**
- No additional changes expected.
- [ ] **Step 1: Run UI test module**
Run:
```bash
pytest tests/test_ui.py -q
```
Expected: PASS.
- [ ] **Step 2: Run full test suite**
Run:
```bash
pytest -q
```
Expected: PASS.
- [ ] **Step 3: Review diff**
Run:
```bash
git diff -- src/grid_trading/ui/main_window.py tests/test_ui.py docs/superpowers/specs/2026-07-09-gui-visual-refresh-design.md docs/superpowers/plans/2026-07-09-gui-visual-refresh.md
```
Expected: diff only contains the UI theme, PnL styling, tests, and docs for this task.

View File

@@ -0,0 +1,53 @@
# GUI 页面美化与盈亏着色设计
日期2026-07-09
## 目标
把现有 PySide6 桌面 GUI 调整为清爽浅色桌面工具风格,并统一处理盈亏、利润字段的视觉反馈:亏损显示绿色加粗,盈利显示红色加粗,零值和空值保持中性显示。
本次只改视觉表现和表格单元格样式,不改变业务计算、数据结构、行情刷新、成交录入或持仓选择逻辑。
## 视觉方向
采用轻量主题化方案:
- 保留当前左侧导航、顶部摘要、工具栏、持仓表和底部详情 tab 的布局结构。
- 主窗口使用浅灰背景,内容区使用白色和浅边框分区,减少默认 Qt 控件的粗糙感。
- 侧边栏使用白底、蓝色选中态和更紧凑的列表项。
- 摘要卡片使用白底、浅边框、圆角和更清晰的标题/数值层级。
- 操作按钮使用蓝色主按钮样式,禁用态降低对比。
- 表格使用白底、浅灰表头、斑马纹、整行选中和统一网格线颜色。
- Tab 使用浅色选项卡,当前 tab 用蓝色强调。
## 盈亏字段
需要应用盈亏样式的字段:
- 汇总卡:浮动盈亏。
- 持仓表:已实现盈亏、网格利润、浮动盈亏。
- 网格档位表:预计单轮毛利润。
- 待卖网格表:预计毛利润。
- 配对明细表:毛利润。
样式规则:
- 大于 0红色、加粗。
- 小于 0绿色、加粗。
- 等于 0、空值或无法解析默认文字色和普通字重。
颜色按 A 股常见习惯处理,红色代表盈利,绿色代表亏损。
## 实现方式
-`MainWindow` 中新增一个全局样式应用方法,用 QSS 统一主窗口、按钮、列表、表格、tab 和摘要卡片样式。
- 给摘要卡片和关键控件设置 objectName让 QSS 精准命中,避免影响弹窗内部布局。
- 新增表格初始化 helper复用表头、选择、斑马纹、行高和滚动行为设置。
- 新增数值单元格 helper根据 Decimal 原始值给 `QTableWidgetItem` 设置前景色和加粗字体。
- 刷新汇总卡时对浮动盈亏标签应用同样的盈亏样式。
## 测试策略
- 增加 GUI 烟测,验证主窗口应用了浅色主题相关 objectName 或样式入口。
- 增加单元格样式测试,直接填充带盈利和亏损的持仓数据,验证盈利字段为红色加粗、亏损字段为绿色加粗。
- 保留现有窗口构造、tab、刷新线程测试。

View File

@@ -177,9 +177,10 @@ def calculate_account_summary(
sum((position.floating_pnl or Decimal("0") for position in positions), Decimal("0")) sum((position.floating_pnl or Decimal("0") for position in positions), Decimal("0"))
) )
total_assets = money(cash + market_value) total_assets = money(cash + market_value)
deployable_assets = market_value + max(cash, Decimal("0"))
capital_usage_rate = ( capital_usage_rate = (
(market_value / total_assets).quantize(RATE_PLACES, rounding=ROUND_HALF_UP) (market_value / deployable_assets).quantize(RATE_PLACES, rounding=ROUND_HALF_UP)
if total_assets > 0 if deployable_assets > 0
else Decimal("0") else Decimal("0")
) )
return AccountSummary( return AccountSummary(

View File

@@ -138,6 +138,20 @@ class OpenGridLot:
status: str status: str
@dataclass(frozen=True)
class GridTradeMatch:
sell_trade_id: int | None
sell_date: date
sell_price: Decimal
buy_trade_id: int | None
buy_date: date
buy_price: Decimal
matched_quantity: int
buy_amount: Decimal
sell_amount: Decimal
gross_profit: Decimal
@dataclass(frozen=True) @dataclass(frozen=True)
class QuoteSnapshot: class QuoteSnapshot:
symbol: str symbol: str

View File

@@ -1,11 +1,19 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace from dataclasses import dataclass, replace
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from grid_trading.domain.calculations import money, price from grid_trading.domain.calculations import money, price
from grid_trading.domain.models import OpenGridLot, Trade, TradeGroup, TradeSide from grid_trading.domain.models import GridTradeMatch, OpenGridLot, Trade, TradeGroup, TradeSide
@dataclass
class _OpenBuyLot:
trade_id: int | None
buy_date: date
buy_price: Decimal
remaining_quantity: int
def calculate_open_grid_lots( def calculate_open_grid_lots(
@@ -45,6 +53,52 @@ def calculate_open_grid_lots(
return lots return lots
def calculate_grid_trade_matches(
trades: list[Trade],
*,
as_of: date,
) -> list[GridTradeMatch]:
open_buys: list[_OpenBuyLot] = []
matches: list[GridTradeMatch] = []
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:
open_buys.append(
_OpenBuyLot(
trade_id=trade.id,
buy_date=trade.trade_date,
buy_price=price(trade.price),
remaining_quantity=trade.quantity,
)
)
continue
sell_price = price(trade.price)
remaining_sell_quantity = trade.quantity
while remaining_sell_quantity > 0 and open_buys:
buy_lot = open_buys[0]
matched_quantity = min(buy_lot.remaining_quantity, remaining_sell_quantity)
matches.append(
GridTradeMatch(
sell_trade_id=trade.id,
sell_date=trade.trade_date,
sell_price=sell_price,
buy_trade_id=buy_lot.trade_id,
buy_date=buy_lot.buy_date,
buy_price=buy_lot.buy_price,
matched_quantity=matched_quantity,
buy_amount=money(buy_lot.buy_price * Decimal(matched_quantity)),
sell_amount=money(sell_price * Decimal(matched_quantity)),
gross_profit=money((sell_price - buy_lot.buy_price) * Decimal(matched_quantity)),
)
)
buy_lot.remaining_quantity -= matched_quantity
remaining_sell_quantity -= matched_quantity
if buy_lot.remaining_quantity == 0:
open_buys.pop(0)
return matches
def _match_sell_against_lots(lots: list[OpenGridLot], quantity: int) -> list[OpenGridLot]: def _match_sell_against_lots(lots: list[OpenGridLot], quantity: int) -> list[OpenGridLot]:
remaining_sell_quantity = quantity remaining_sell_quantity = quantity
updated_lots: list[OpenGridLot] = [] updated_lots: list[OpenGridLot] = []

View File

@@ -19,6 +19,7 @@ from grid_trading.domain.models import (
FeeEstimate, FeeEstimate,
FeeRules, FeeRules,
GridLevelSuggestion, GridLevelSuggestion,
GridTradeMatch,
Instrument, Instrument,
OpenGridLot, OpenGridLot,
PositionSummary, PositionSummary,
@@ -28,7 +29,7 @@ from grid_trading.domain.models import (
Trade, Trade,
TradeSide, TradeSide,
) )
from grid_trading.domain.open_grid_lots import calculate_open_grid_lots from grid_trading.domain.open_grid_lots import calculate_grid_trade_matches, calculate_open_grid_lots
from grid_trading.market.tencent import TencentQuoteProvider from grid_trading.market.tencent import TencentQuoteProvider
from grid_trading.storage.repositories import Repository from grid_trading.storage.repositories import Repository
@@ -262,6 +263,18 @@ class TradingService:
as_of=as_of_date, as_of=as_of_date,
) )
def get_grid_trade_matches(
self,
instrument_id: int,
*,
as_of: date | None = None,
) -> list[GridTradeMatch]:
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)
return calculate_grid_trade_matches(trades, as_of=as_of_date)
def _require_account(self, account_id: int) -> Account: def _require_account(self, account_id: int) -> Account:
account = self.repository.get_account(account_id) account = self.repository.get_account(account_id)
if account is None: if account is None:

View File

@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from decimal import Decimal
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QObject, Qt, QThread, Signal from PySide6.QtCore import QObject, Qt, QThread, Signal
from PySide6.QtGui import QBrush, QColor, QFont
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView,
QApplication, QApplication,
QDialog, QDialog,
QFrame, QFrame,
@@ -26,7 +29,14 @@ from PySide6.QtWidgets import (
) )
from grid_trading.config import DEFAULT_DB_PATH from grid_trading.config import DEFAULT_DB_PATH
from grid_trading.domain.models import GridLevelSuggestion, OpenGridLot, PositionSummary, Trade, TradeSide from grid_trading.domain.models import (
GridLevelSuggestion,
GridTradeMatch,
OpenGridLot,
PositionSummary,
Trade,
TradeSide,
)
from grid_trading.services.trading_service import TradingService from grid_trading.services.trading_service import TradingService
from grid_trading.ui.dialogs import AccountDialog, InstrumentDialog, StrategyTemplateDialog, TradeDialog from grid_trading.ui.dialogs import AccountDialog, InstrumentDialog, StrategyTemplateDialog, TradeDialog
from grid_trading.ui.formatters import format_money, format_percent, format_price, format_quantity from grid_trading.ui.formatters import format_money, format_percent, format_price, format_quantity
@@ -51,6 +61,10 @@ class QuoteRefreshWorker(QObject):
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
PROFIT_COLOR = QColor("#c62828")
LOSS_COLOR = QColor("#2e7d32")
NEUTRAL_COLOR = QColor("#334155")
HOLDING_COLUMNS = [ HOLDING_COLUMNS = [
"代码", "代码",
"名称", "名称",
@@ -86,6 +100,17 @@ class MainWindow(QMainWindow):
"当前价", "当前价",
"状态", "状态",
] ]
GRID_MATCH_COLUMNS = [
"卖出日期",
"卖出价",
"匹配买入日期",
"买入价",
"匹配股数",
"买入金额",
"卖出金额",
"毛利润",
"对应成交",
]
def __init__(self, service: TradingService): def __init__(self, service: TradingService):
super().__init__() super().__init__()
@@ -104,16 +129,26 @@ class MainWindow(QMainWindow):
self.refresh_all() self.refresh_all()
def _build_ui(self) -> None: def _build_ui(self) -> None:
self._apply_light_theme()
root = QWidget() root = QWidget()
root.setObjectName("appRoot")
root_layout = QHBoxLayout(root) root_layout = QHBoxLayout(root)
root_layout.setContentsMargins(14, 14, 14, 14)
root_layout.setSpacing(14)
nav = QListWidget() nav = QListWidget()
nav.setObjectName("sideNav")
nav.addItems(["账户总览", "持仓管理", "成交记录", "网格策略", "设置"]) nav.addItems(["账户总览", "持仓管理", "成交记录", "网格策略", "设置"])
nav.setFixedWidth(150) nav.setFixedWidth(150)
nav.setCurrentRow(1)
root_layout.addWidget(nav) root_layout.addWidget(nav)
content = QWidget() content = QWidget()
content.setObjectName("contentPanel")
content_layout = QVBoxLayout(content) content_layout = QVBoxLayout(content)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(12)
self.summary_labels = self._create_summary_cards() self.summary_labels = self._create_summary_cards()
content_layout.addLayout(self.summary_cards_layout) content_layout.addLayout(self.summary_cards_layout)
content_layout.addLayout(self._create_toolbar()) content_layout.addLayout(self._create_toolbar())
@@ -121,17 +156,17 @@ class MainWindow(QMainWindow):
splitter = QSplitter(Qt.Orientation.Vertical) splitter = QSplitter(Qt.Orientation.Vertical)
self.holdings_table = QTableWidget(0, len(self.HOLDING_COLUMNS)) self.holdings_table = QTableWidget(0, len(self.HOLDING_COLUMNS))
self.holdings_table.setHorizontalHeaderLabels(self.HOLDING_COLUMNS) self.holdings_table.setHorizontalHeaderLabels(self.HOLDING_COLUMNS)
self.holdings_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) self._configure_table(self.holdings_table)
self.holdings_table.horizontalHeader().setStretchLastSection(True)
self.holdings_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.holdings_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.holdings_table.itemSelectionChanged.connect(self._on_holding_selected) self.holdings_table.itemSelectionChanged.connect(self._on_holding_selected)
splitter.addWidget(self.holdings_table) splitter.addWidget(self.holdings_table)
self.details_tabs = QTabWidget() self.details_tabs = QTabWidget()
self.details_tabs.setObjectName("detailsTabs")
grid_tab = QWidget() grid_tab = QWidget()
grid_layout = QVBoxLayout(grid_tab) grid_layout = QVBoxLayout(grid_tab)
grid_layout.setContentsMargins(10, 10, 10, 10)
grid_layout.setSpacing(8)
grid_controls = QHBoxLayout() grid_controls = QHBoxLayout()
self.grid_levels_count_edit = QSpinBox() self.grid_levels_count_edit = QSpinBox()
self.grid_levels_count_edit.setRange(1, 100) self.grid_levels_count_edit.setRange(1, 100)
@@ -144,41 +179,51 @@ class MainWindow(QMainWindow):
grid_controls.addStretch() grid_controls.addStretch()
self.grid_levels_table = QTableWidget(0, len(self.GRID_LEVEL_COLUMNS)) self.grid_levels_table = QTableWidget(0, len(self.GRID_LEVEL_COLUMNS))
self.grid_levels_table.setHorizontalHeaderLabels(self.GRID_LEVEL_COLUMNS) self.grid_levels_table.setHorizontalHeaderLabels(self.GRID_LEVEL_COLUMNS)
self.grid_levels_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) self._configure_table(self.grid_levels_table)
self.grid_levels_table.horizontalHeader().setStretchLastSection(True)
self.grid_levels_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
grid_layout.addLayout(grid_controls) grid_layout.addLayout(grid_controls)
grid_layout.addWidget(self.grid_levels_table) grid_layout.addWidget(self.grid_levels_table)
open_grid_tab = QWidget() open_grid_tab = QWidget()
open_grid_layout = QVBoxLayout(open_grid_tab) open_grid_layout = QVBoxLayout(open_grid_tab)
open_grid_layout.setContentsMargins(10, 10, 10, 10)
open_grid_layout.setSpacing(8)
self.open_grid_lots_table = QTableWidget(0, len(self.OPEN_GRID_LOT_COLUMNS)) 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.setHorizontalHeaderLabels(self.OPEN_GRID_LOT_COLUMNS)
self.open_grid_lots_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) self._configure_table(self.open_grid_lots_table)
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) open_grid_layout.addWidget(self.open_grid_lots_table)
grid_match_tab = QWidget()
grid_match_layout = QVBoxLayout(grid_match_tab)
grid_match_layout.setContentsMargins(10, 10, 10, 10)
grid_match_layout.setSpacing(8)
self.grid_matches_table = QTableWidget(0, len(self.GRID_MATCH_COLUMNS))
self.grid_matches_table.setHorizontalHeaderLabels(self.GRID_MATCH_COLUMNS)
self._configure_table(self.grid_matches_table)
grid_match_layout.addWidget(self.grid_matches_table)
trade_tab = QWidget() trade_tab = QWidget()
trade_layout = QVBoxLayout(trade_tab) trade_layout = QVBoxLayout(trade_tab)
trade_layout.setContentsMargins(10, 10, 10, 10)
trade_layout.setSpacing(8)
trade_buttons = QHBoxLayout() trade_buttons = QHBoxLayout()
edit_trade_button = QPushButton("编辑成交") edit_trade_button = QPushButton("编辑成交")
edit_trade_button.setObjectName("toolbarButton")
edit_trade_button.clicked.connect(self._edit_selected_trade) edit_trade_button.clicked.connect(self._edit_selected_trade)
delete_trade_button = QPushButton("删除成交") delete_trade_button = QPushButton("删除成交")
delete_trade_button.setObjectName("toolbarButton")
delete_trade_button.clicked.connect(self._delete_selected_trade) delete_trade_button.clicked.connect(self._delete_selected_trade)
trade_buttons.addWidget(edit_trade_button) trade_buttons.addWidget(edit_trade_button)
trade_buttons.addWidget(delete_trade_button) trade_buttons.addWidget(delete_trade_button)
trade_buttons.addStretch() trade_buttons.addStretch()
self.trades_table = QTableWidget(0, len(self.TRADE_COLUMNS)) self.trades_table = QTableWidget(0, len(self.TRADE_COLUMNS))
self.trades_table.setHorizontalHeaderLabels(self.TRADE_COLUMNS) self.trades_table.setHorizontalHeaderLabels(self.TRADE_COLUMNS)
self.trades_table.horizontalHeader().setStretchLastSection(True) self._configure_table(self.trades_table)
self.trades_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.trades_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
trade_layout.addLayout(trade_buttons) trade_layout.addLayout(trade_buttons)
trade_layout.addWidget(self.trades_table) trade_layout.addWidget(self.trades_table)
self.details_tabs.addTab(grid_tab, "网格档位") self.details_tabs.addTab(grid_tab, "网格档位")
self.details_tabs.addTab(open_grid_tab, "待卖网格") self.details_tabs.addTab(open_grid_tab, "待卖网格")
self.details_tabs.addTab(grid_match_tab, "配对明细")
self.details_tabs.addTab(trade_tab, "最近成交") self.details_tabs.addTab(trade_tab, "最近成交")
self.details_tabs.setCurrentIndex(1) self.details_tabs.setCurrentIndex(1)
splitter.addWidget(self.details_tabs) splitter.addWidget(self.details_tabs)
@@ -188,12 +233,171 @@ class MainWindow(QMainWindow):
root_layout.addWidget(content) root_layout.addWidget(content)
self.setCentralWidget(root) self.setCentralWidget(root)
def _apply_light_theme(self) -> None:
self.setStyleSheet(
"""
QMainWindow {
background: #f4f7fb;
color: #334155;
font-family: "Microsoft YaHei", "Segoe UI", Arial, sans-serif;
font-size: 13px;
}
QWidget#appRoot {
background: #f4f7fb;
}
QListWidget#sideNav {
background: #ffffff;
border: 1px solid #dbe4ef;
border-radius: 8px;
padding: 8px;
outline: 0;
}
QListWidget#sideNav::item {
border-radius: 6px;
color: #475569;
margin: 2px 0;
padding: 10px 12px;
}
QListWidget#sideNav::item:selected {
background: #e8f1ff;
color: #1d4ed8;
font-weight: 700;
}
QFrame#summaryCard {
background: #ffffff;
border: 1px solid #dbe4ef;
border-radius: 8px;
}
QLabel#summaryTitle {
color: #64748b;
font-size: 12px;
}
QLabel#summaryValue {
color: #0f172a;
font-size: 20px;
font-weight: 700;
}
QPushButton#toolbarButton {
background: #2563eb;
border: 1px solid #1d4ed8;
border-radius: 6px;
color: #ffffff;
font-weight: 700;
min-height: 30px;
padding: 6px 12px;
}
QPushButton#toolbarButton:hover {
background: #1d4ed8;
}
QPushButton#toolbarButton:pressed {
background: #1e40af;
}
QPushButton#toolbarButton:disabled {
background: #cbd5e1;
border-color: #cbd5e1;
color: #f8fafc;
}
QTabWidget#detailsTabs::pane {
background: #ffffff;
border: 1px solid #dbe4ef;
border-radius: 8px;
top: -1px;
}
QTabBar::tab {
background: #eef2f7;
border: 1px solid #dbe4ef;
border-bottom: 0;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
color: #475569;
margin-right: 4px;
padding: 8px 14px;
}
QTabBar::tab:selected {
background: #ffffff;
color: #1d4ed8;
font-weight: 700;
}
QTableWidget {
background: #ffffff;
alternate-background-color: #f8fafc;
border: 1px solid #dbe4ef;
border-radius: 8px;
gridline-color: #e2e8f0;
selection-background-color: #dbeafe;
selection-color: #0f172a;
}
QHeaderView::section {
background: #f1f5f9;
border: 0;
border-right: 1px solid #dbe4ef;
border-bottom: 1px solid #dbe4ef;
color: #475569;
font-weight: 700;
padding: 8px 10px;
}
QSpinBox {
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 6px;
min-height: 28px;
padding: 2px 8px;
}
QLabel {
color: #334155;
}
"""
)
def _configure_table(self, table: QTableWidget) -> None:
table.setAlternatingRowColors(True)
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
table.setShowGrid(True)
table.setWordWrap(False)
table.verticalHeader().setVisible(False)
table.verticalHeader().setDefaultSectionSize(34)
table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
table.horizontalHeader().setStretchLastSection(True)
def _make_item(self, value: str, pnl_value: Decimal | None = None) -> QTableWidgetItem:
item = QTableWidgetItem(value)
if pnl_value is not None:
self._style_pnl_item(item, pnl_value)
return item
def _style_pnl_item(self, item: QTableWidgetItem, value: Decimal | None) -> None:
if value is None:
return
font = QFont(item.font())
if value > 0:
item.setForeground(QBrush(self.PROFIT_COLOR))
font.setBold(True)
elif value < 0:
item.setForeground(QBrush(self.LOSS_COLOR))
font.setBold(True)
else:
font.setBold(False)
item.setFont(font)
def _style_pnl_label(self, label: QLabel, value: Decimal | None) -> None:
if value is None or value == 0:
label.setStyleSheet(f"color: {self.NEUTRAL_COLOR.name()}; font-weight: 400;")
elif value > 0:
label.setStyleSheet(f"color: {self.PROFIT_COLOR.name()}; font-weight: 700;")
else:
label.setStyleSheet(f"color: {self.LOSS_COLOR.name()}; font-weight: 700;")
def _create_summary_cards(self) -> dict[str, QLabel]: def _create_summary_cards(self) -> dict[str, QLabel]:
self.summary_cards_layout = QGridLayout() self.summary_cards_layout = QGridLayout()
self.summary_cards_layout.setHorizontalSpacing(10)
self.summary_cards_layout.setVerticalSpacing(10)
labels: dict[str, QLabel] = {} labels: dict[str, QLabel] = {}
for column, (key, title) in enumerate( for column, (key, title) in enumerate(
[ [
("total_assets", "总资产"), ("total_assets", "账户权益"),
("cash", "现金"), ("cash", "现金"),
("market_value", "持仓市值"), ("market_value", "持仓市值"),
("floating_pnl", "浮动盈亏"), ("floating_pnl", "浮动盈亏"),
@@ -201,11 +405,15 @@ class MainWindow(QMainWindow):
] ]
): ):
frame = QFrame() frame = QFrame()
frame.setObjectName("summaryCard")
frame.setFrameShape(QFrame.Shape.StyledPanel) frame.setFrameShape(QFrame.Shape.StyledPanel)
layout = QVBoxLayout(frame) layout = QVBoxLayout(frame)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(6)
title_label = QLabel(title) title_label = QLabel(title)
title_label.setObjectName("summaryTitle")
value_label = QLabel("-") value_label = QLabel("-")
value_label.setStyleSheet("font-size: 20px; font-weight: 700;") value_label.setObjectName("summaryValue")
layout.addWidget(title_label) layout.addWidget(title_label)
layout.addWidget(value_label) layout.addWidget(value_label)
labels[key] = value_label labels[key] = value_label
@@ -223,6 +431,7 @@ class MainWindow(QMainWindow):
] ]
for text, handler in buttons: for text, handler in buttons:
button = QPushButton(text) button = QPushButton(text)
button.setObjectName("toolbarButton")
button.clicked.connect(handler) button.clicked.connect(handler)
if text == "刷新行情": if text == "刷新行情":
self.refresh_quotes_button = button self.refresh_quotes_button = button
@@ -241,6 +450,7 @@ class MainWindow(QMainWindow):
self.summary_labels["cash"].setText(format_money(account_summary.cash)) self.summary_labels["cash"].setText(format_money(account_summary.cash))
self.summary_labels["market_value"].setText(format_money(account_summary.market_value)) self.summary_labels["market_value"].setText(format_money(account_summary.market_value))
self.summary_labels["floating_pnl"].setText(format_money(account_summary.floating_pnl)) self.summary_labels["floating_pnl"].setText(format_money(account_summary.floating_pnl))
self._style_pnl_label(self.summary_labels["floating_pnl"], account_summary.floating_pnl)
self.summary_labels["usage"].setText(format_percent(account_summary.capital_usage_rate)) self.summary_labels["usage"].setText(format_percent(account_summary.capital_usage_rate))
self._fill_holdings_table() self._fill_holdings_table()
self._refresh_details() self._refresh_details()
@@ -309,8 +519,13 @@ class MainWindow(QMainWindow):
format_money(position.grid_profit), format_money(position.grid_profit),
format_money(position.floating_pnl), format_money(position.floating_pnl),
] ]
pnl_values = {
10: position.realized_pnl,
11: position.grid_profit,
12: position.floating_pnl,
}
for column, value in enumerate(values): for column, value in enumerate(values):
item = QTableWidgetItem(value) item = self._make_item(value, pnl_values.get(column))
item.setData(Qt.ItemDataRole.UserRole, position.instrument_id) item.setData(Qt.ItemDataRole.UserRole, position.instrument_id)
self.holdings_table.setItem(row, column, item) self.holdings_table.setItem(row, column, item)
if self._positions and self.holdings_table.currentRow() < 0: if self._positions and self.holdings_table.currentRow() < 0:
@@ -327,10 +542,12 @@ class MainWindow(QMainWindow):
if position is None: if position is None:
self._refresh_grid_levels(None) self._refresh_grid_levels(None)
self._refresh_open_grid_lots(None) self._refresh_open_grid_lots(None)
self._refresh_grid_matches(None)
self._fill_trades_table([]) self._fill_trades_table([])
return return
self._refresh_grid_levels(position) self._refresh_grid_levels(position)
self._refresh_open_grid_lots(position) self._refresh_open_grid_lots(position)
self._refresh_grid_matches(position)
self._fill_trades_table(self.service.list_trades(instrument_id=position.instrument_id)) self._fill_trades_table(self.service.list_trades(instrument_id=position.instrument_id))
def _refresh_grid_levels(self, position: PositionSummary | None) -> None: def _refresh_grid_levels(self, position: PositionSummary | None) -> None:
@@ -368,7 +585,11 @@ class MainWindow(QMainWindow):
format_money(level.estimated_gross_profit), format_money(level.estimated_gross_profit),
] ]
for column, value in enumerate(values): for column, value in enumerate(values):
self.grid_levels_table.setItem(row, column, QTableWidgetItem(value)) item = self._make_item(
value,
level.estimated_gross_profit if column == 6 else None,
)
self.grid_levels_table.setItem(row, column, item)
def _refresh_open_grid_lots(self, position: PositionSummary | None) -> None: def _refresh_open_grid_lots(self, position: PositionSummary | None) -> None:
if position is None: if position is None:
@@ -396,7 +617,41 @@ class MainWindow(QMainWindow):
lot.status, lot.status,
] ]
for column, value in enumerate(values): for column, value in enumerate(values):
self.open_grid_lots_table.setItem(row, column, QTableWidgetItem(value)) item = self._make_item(
value,
lot.estimated_gross_profit if column == 5 else None,
)
self.open_grid_lots_table.setItem(row, column, item)
def _refresh_grid_matches(self, position: PositionSummary | None) -> None:
if position is None:
self._fill_grid_matches_table([])
return
try:
matches = self.service.get_grid_trade_matches(position.instrument_id)
except Exception as exc:
QMessageBox.warning(self, "配对明细失败", str(exc))
self._fill_grid_matches_table([])
return
self._fill_grid_matches_table(matches)
def _fill_grid_matches_table(self, matches: list[GridTradeMatch]) -> None:
self.grid_matches_table.setRowCount(len(matches))
for row, match in enumerate(matches):
values = [
match.sell_date.isoformat(),
format_price(match.sell_price),
match.buy_date.isoformat(),
format_price(match.buy_price),
format_quantity(match.matched_quantity),
format_money(match.buy_amount),
format_money(match.sell_amount),
format_money(match.gross_profit),
f"{match.buy_trade_id or '-'} -> 卖{match.sell_trade_id or '-'}",
]
for column, value in enumerate(values):
item = self._make_item(value, match.gross_profit if column == 7 else None)
self.grid_matches_table.setItem(row, column, item)
def _fill_trades_table(self, trades: list[Trade]) -> None: def _fill_trades_table(self, trades: list[Trade]) -> None:
self._trade_ids_by_row = {} self._trade_ids_by_row = {}

View File

@@ -3,8 +3,13 @@ from decimal import Decimal
import pytest import pytest
from grid_trading.domain.calculations import CalculationError, calculate_positions, estimate_fees from grid_trading.domain.calculations import (
from grid_trading.domain.models import FeeRules, Instrument, QuoteSnapshot, Trade, TradeGroup, TradeSide CalculationError,
calculate_account_summary,
calculate_positions,
estimate_fees,
)
from grid_trading.domain.models import Account, FeeRules, Instrument, QuoteSnapshot, Trade, TradeGroup, TradeSide
def make_trade( def make_trade(
@@ -175,6 +180,39 @@ def test_missing_quote_does_not_use_manual_or_last_trade_price_for_current_price
assert summary.floating_pnl is None assert summary.floating_pnl is None
def test_account_summary_caps_capital_usage_when_cash_is_negative():
today = date(2026, 7, 8)
account = Account(id=1, name="主账户", initial_cash=Decimal("100"))
instrument = Instrument(id=1, code="600588", name="用友网络")
trades = [
make_trade(
trade_id=1,
trade_date=today,
side=TradeSide.BUY,
price="1.50",
quantity=100,
trade_group=TradeGroup.BASE,
)
]
quotes = {
1: QuoteSnapshot(
symbol="sh600588",
code="600588",
name="用友网络",
price=Decimal("1.00"),
source="tencent",
)
}
positions = calculate_positions([instrument], trades, as_of=today, quote_snapshots=quotes)
summary = calculate_account_summary(account, positions, [], trades)
assert summary.cash == Decimal("-50.00")
assert summary.market_value == Decimal("100.00")
assert summary.total_assets == Decimal("50.00")
assert summary.capital_usage_rate == Decimal("1.0000")
def test_sell_more_than_group_position_raises(): def test_sell_more_than_group_position_raises():
today = date(2026, 7, 8) today = date(2026, 7, 8)
instrument = Instrument(id=1, code="159915", name="创业板ETF") instrument = Instrument(id=1, code="159915", name="创业板ETF")

View File

@@ -2,7 +2,7 @@ from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from grid_trading.domain.models import Trade, TradeGroup, TradeSide from grid_trading.domain.models import Trade, TradeGroup, TradeSide
from grid_trading.domain.open_grid_lots import calculate_open_grid_lots from grid_trading.domain.open_grid_lots import calculate_grid_trade_matches, calculate_open_grid_lots
def make_trade( def make_trade(
@@ -80,6 +80,52 @@ def test_calculate_open_grid_lots_fifo_matches_grid_sells_against_grid_buys():
assert lot.status == "可卖" assert lot.status == "可卖"
def test_calculate_grid_trade_matches_splits_sell_against_fifo_buy_lots():
today = date(2026, 7, 9)
trades = [
make_trade(
trade_id=1,
trade_date=today - timedelta(days=3),
side=TradeSide.BUY,
price="10.00",
quantity=300,
),
make_trade(
trade_id=2,
trade_date=today - timedelta(days=2),
side=TradeSide.BUY,
price="9.50",
quantity=200,
),
make_trade(
trade_id=3,
trade_date=today - timedelta(days=1),
side=TradeSide.SELL,
price="10.30",
quantity=350,
),
]
matches = calculate_grid_trade_matches(trades, as_of=today)
assert len(matches) == 2
assert matches[0].sell_trade_id == 3
assert matches[0].sell_date == today - timedelta(days=1)
assert matches[0].sell_price == Decimal("10.30")
assert matches[0].buy_trade_id == 1
assert matches[0].buy_date == today - timedelta(days=3)
assert matches[0].buy_price == Decimal("10.00")
assert matches[0].matched_quantity == 300
assert matches[0].buy_amount == Decimal("3000.00")
assert matches[0].sell_amount == Decimal("3090.00")
assert matches[0].gross_profit == Decimal("90.00")
assert matches[1].buy_trade_id == 2
assert matches[1].matched_quantity == 50
assert matches[1].buy_amount == Decimal("475.00")
assert matches[1].sell_amount == Decimal("515.00")
assert matches[1].gross_profit == Decimal("40.00")
def test_calculate_open_grid_lots_reports_not_reached_and_missing_quote_statuses(): def test_calculate_open_grid_lots_reports_not_reached_and_missing_quote_statuses():
today = date(2026, 7, 9) today = date(2026, 7, 9)
trades = [ trades = [

View File

@@ -298,3 +298,55 @@ def test_service_returns_open_grid_lots_with_suggested_sell_price(tmp_path):
assert lot.suggested_sell_price == Decimal("4.12") assert lot.suggested_sell_price == Decimal("4.12")
assert lot.current_price == Decimal("4.12") assert lot.current_price == Decimal("4.12")
assert lot.status == "可卖" assert lot.status == "可卖"
def test_service_returns_grid_trade_matches(tmp_path):
service = TradingService(tmp_path / "grid.db")
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, 6),
side=TradeSide.BUY,
price=Decimal("10.00"),
quantity=300,
trade_group=TradeGroup.GRID,
)
)
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("9.50"),
quantity=200,
trade_group=TradeGroup.GRID,
)
)
service.save_trade(
Trade(
id=None,
account_id=account.id,
instrument_id=instrument.id,
trade_date=date(2026, 7, 8),
side=TradeSide.SELL,
price=Decimal("10.30"),
quantity=400,
trade_group=TradeGroup.GRID,
)
)
matches = service.get_grid_trade_matches(instrument.id, as_of=date(2026, 7, 9))
assert len(matches) == 2
assert matches[0].buy_trade_id is not None
assert matches[0].matched_quantity == 300
assert matches[0].gross_profit == Decimal("90.00")
assert matches[1].matched_quantity == 100
assert matches[1].gross_profit == Decimal("80.00")

View File

@@ -17,7 +17,7 @@ def test_formatters_render_money_percent_and_empty_values():
def test_main_window_can_be_constructed_offscreen(tmp_path, monkeypatch): def test_main_window_can_be_constructed_offscreen(tmp_path, monkeypatch):
monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication, QPushButton from PySide6.QtWidgets import QApplication, QLabel, QPushButton
from grid_trading.services.trading_service import TradingService from grid_trading.services.trading_service import TradingService
from grid_trading.ui.main_window import MainWindow from grid_trading.ui.main_window import MainWindow
@@ -29,6 +29,9 @@ def test_main_window_can_be_constructed_offscreen(tmp_path, monkeypatch):
assert window.windowTitle() == "Grid Trading Manager" assert window.windowTitle() == "Grid Trading Manager"
assert window.holdings_table.columnCount() > 0 assert window.holdings_table.columnCount() > 0
assert any(button.text() == "刷新行情" for button in window.findChildren(QPushButton)) assert any(button.text() == "刷新行情" for button in window.findChildren(QPushButton))
summary_titles = [label.text() for label in window.findChildren(QLabel)]
assert "账户权益" in summary_titles
assert "总资产" not in summary_titles
window.close() window.close()
service.close() service.close()
@@ -49,7 +52,7 @@ def test_main_window_contains_grid_level_table(tmp_path, monkeypatch):
assert window.grid_levels_table.columnCount() == 7 assert window.grid_levels_table.columnCount() == 7
tab_titles = _tab_titles(window.details_tabs) tab_titles = _tab_titles(window.details_tabs)
assert tab_titles == ["网格档位", "待卖网格", "最近成交"] assert tab_titles == ["网格档位", "待卖网格", "配对明细", "最近成交"]
assert isinstance(window.details_tabs, QTabWidget) assert isinstance(window.details_tabs, QTabWidget)
window.close() window.close()
@@ -71,6 +74,82 @@ def test_main_window_contains_open_grid_lots_table(tmp_path, monkeypatch):
assert window.open_grid_lots_table.columnCount() == 8 assert window.open_grid_lots_table.columnCount() == 8
assert "待卖网格" in _tab_titles(window.details_tabs) assert "待卖网格" in _tab_titles(window.details_tabs)
assert window.grid_matches_table.columnCount() == 9
assert "配对明细" in _tab_titles(window.details_tabs)
window.close()
service.close()
app.processEvents()
def test_main_window_applies_light_theme(tmp_path, monkeypatch):
monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
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.centralWidget().objectName() == "appRoot"
assert window.holdings_table.alternatingRowColors()
assert "QMainWindow" in window.styleSheet()
window.close()
service.close()
app.processEvents()
def test_profit_and_loss_cells_use_a_share_colors(tmp_path, monkeypatch):
monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QApplication
from grid_trading.domain.models import PositionSummary
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)
window._positions = [
PositionSummary(
instrument_id=1,
code="510300",
name="沪深300ETF",
market="ETF",
current_price=Decimal("10.50"),
price_source="quote",
total_quantity=100,
available_quantity=100,
base_quantity=0,
grid_quantity=100,
other_quantity=0,
remaining_cost=Decimal("1000"),
cost_price=Decimal("10"),
position_breakeven_price=Decimal("10"),
account_breakeven_price=Decimal("10"),
realized_pnl=Decimal("12.34"),
grid_profit=Decimal("-5.67"),
floating_pnl=Decimal("50"),
market_value=Decimal("1050"),
)
]
window.holdings_table.blockSignals(True)
window._fill_holdings_table()
window.holdings_table.blockSignals(False)
profit_item = window.holdings_table.item(0, 10)
loss_item = window.holdings_table.item(0, 11)
assert profit_item.foreground().color() == QColor("#c62828")
assert profit_item.font().bold()
assert loss_item.foreground().color() == QColor("#2e7d32")
assert loss_item.font().bold()
window.close() window.close()
service.close() service.close()