新增交易记录功能,支持开平仓和盈亏计算

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 10:46:27 +08:00
parent e222a93204
commit adec664c52
5 changed files with 307 additions and 2 deletions
+2 -1
View File
@@ -7,7 +7,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.database import engine, Base, SessionLocal from app.database import engine, Base, SessionLocal
from app.models import User from app.models import User
from app.seed import seed from app.seed import seed
from app.routers import contracts, admin, auth, positions from app.routers import contracts, admin, auth, positions, trades
TEMPLATES_DIR = Path(__file__).parent / "templates" TEMPLATES_DIR = Path(__file__).parent / "templates"
@@ -57,6 +57,7 @@ app.include_router(auth.router)
app.include_router(contracts.router) app.include_router(contracts.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(positions.router) app.include_router(positions.router)
app.include_router(trades.router)
@app.get("/") @app.get("/")
+27 -1
View File
@@ -119,4 +119,30 @@ class Position(Base):
locked_count: Mapped[int] = mapped_column(Integer, default=0) locked_count: Mapped[int] = mapped_column(Integer, default=0)
opened_at: Mapped[date] = mapped_column(Date) opened_at: Mapped[date] = mapped_column(Date)
round: Mapped["Round"] = relationship(back_populates="positions") round: Mapped["Round"] = relationship(back_populates="positions")
class Trade(Base):
__tablename__ = "trades"
id: Mapped[int] = mapped_column(primary_key=True)
product_code: Mapped[str] = mapped_column(String(10))
contract_code: Mapped[str] = mapped_column(String(10), index=True)
direction: Mapped[str] = mapped_column(String(4))
open_date: Mapped[date] = mapped_column(Date)
open_price: Mapped[int] = mapped_column(Integer)
open_fee: Mapped[int | None] = mapped_column(Integer, nullable=True, default=0)
close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
close_price: Mapped[int | None] = mapped_column(Integer, nullable=True)
close_fee: Mapped[int | None] = mapped_column(Integer, nullable=True, default=0)
status: Mapped[str] = mapped_column(String(10), default="open")
@property
def pnl(self) -> int | None:
if self.close_price is None:
return None
mul = 20 # glass futures point value
if self.direction == "long":
return (self.close_price - self.open_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
else:
return (self.open_price - self.close_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
+108
View File
@@ -0,0 +1,108 @@
from datetime import date
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Trade, Product, Contract
router = APIRouter(prefix="/trades", tags=["trades"])
DIRECTIONS = [("short", ""), ("long", "")]
def get_active_contracts(db: Session) -> list[str]:
contracts = (
db.query(Contract.code)
.filter(Contract.is_active == True)
.order_by(Contract.code)
.all()
)
return [c[0] for c in contracts]
def get_products(db: Session) -> list[str]:
products = db.query(Product.code).order_by(Product.code).all()
return [p[0] for p in products]
def today_str() -> str:
return date.today().isoformat()
@router.get("/", response_class=HTMLResponse)
def trades_page(request: Request, db: Session = Depends(get_db)):
open_trades = (
db.query(Trade).filter(Trade.status == "open")
.order_by(Trade.open_date.desc()).all()
)
closed_trades = (
db.query(Trade).filter(Trade.status == "closed")
.order_by(Trade.close_date.desc()).limit(50).all()
)
template = request.app.state.templates.get_template("trades.html")
return HTMLResponse(
template.render(
request=request,
active_nav="trades",
contracts=get_active_contracts(db),
products=get_products(db),
directions=DIRECTIONS,
open_trades=open_trades,
closed_trades=closed_trades,
today=today_str(),
)
)
@router.post("/open")
def open_trade(
request: Request,
product_code: str = Form(...),
contract_code: str = Form(...),
direction: str = Form(...),
open_date: str = Form(...),
open_price: int = Form(...),
open_fee: int = Form(0),
db: Session = Depends(get_db),
):
t = Trade(
product_code=product_code.upper(),
contract_code=contract_code.upper(),
direction=direction,
open_date=date.fromisoformat(open_date),
open_price=open_price,
open_fee=open_fee,
status="open",
)
db.add(t)
db.commit()
return RedirectResponse("/trades/", status_code=303)
@router.post("/{trade_id}/close")
def close_trade(
request: Request,
trade_id: int,
close_date: str = Form(...),
close_price: int = Form(...),
close_fee: int = Form(0),
db: Session = Depends(get_db),
):
t = db.query(Trade).filter(Trade.id == trade_id).first()
if t and t.status == "open":
t.close_date = date.fromisoformat(close_date)
t.close_price = close_price
t.close_fee = close_fee
t.status = "closed"
db.commit()
return RedirectResponse("/trades/", status_code=303)
@router.post("/{trade_id}/delete")
def delete_trade(trade_id: int, db: Session = Depends(get_db)):
t = db.query(Trade).filter(Trade.id == trade_id).first()
if t:
db.delete(t)
db.commit()
return RedirectResponse("/trades/", status_code=303)
+3
View File
@@ -201,6 +201,9 @@
<a href="/positions/" class="{% if active_nav == 'positions' %}active{% endif %}"> <a href="/positions/" class="{% if active_nav == 'positions' %}active{% endif %}">
<span class="icon">📐</span> 仓位管理 <span class="icon">📐</span> 仓位管理
</a> </a>
<a href="/trades/" class="{% if active_nav == 'trades' %}active{% endif %}">
<span class="icon">📝</span> 交易记录
</a>
<a href="/admin/" class="{% if active_nav == 'admin' %}active{% endif %}"> <a href="/admin/" class="{% if active_nav == 'admin' %}active{% endif %}">
<span class="icon">⚙️</span> 系统管理 <span class="icon">⚙️</span> 系统管理
</a> </a>
+167
View File
@@ -0,0 +1,167 @@
{% extends "base.html" %}
{% block title %}交易记录{% endblock %}
{% block heading %}交易记录{% endblock %}
{% block breadcrumb %}开平仓记录 · 盈亏统计{% endblock %}
{% block content %}
{# ═══════════════ 新建开仓 ═══════════════ #}
<div class="section-title">新建开仓</div>
<div class="form-card" style="margin-bottom:28px;max-width:100%;">
<form method="post" action="/trades/open">
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:100px;">
<label>品种</label>
<select name="product_code" required>
<option value="">--</option>
{% for p in products %}
<option value="{{ p }}">{{ p }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:120px;">
<label>合约</label>
<select name="contract_code" required>
<option value="">--</option>
{% for c in contracts %}
<option value="{{ c }}">{{ c }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:70px;">
<label>方向</label>
<select name="direction" required>
{% for v, label in directions %}
<option value="{{ v }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:1;min-width:140px;">
<label>开仓日期</label>
<input type="date" name="open_date" value="{{ today }}" required style="font-size:0.85rem;">
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:80px;">
<label>开仓价</label>
<input type="number" name="open_price" required placeholder="1300" style="font-size:0.9rem;">
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:80px;">
<label>手续费</label>
<input type="number" name="open_fee" value="0" style="font-size:0.9rem;">
</div>
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:8px 18px;">开仓</button>
</div>
</form>
</div>
{# ═══════════════ 持仓中 ═══════════════ #}
<div class="section-title">持仓中 · {{ open_trades|length }} 笔</div>
{% if open_trades %}
<div class="table-wrap">
<table>
<tr><th>品种</th><th>合约</th><th>方向</th><th>开仓日期</th><th>开仓价</th><th>手续费</th><th>操作</th></tr>
{% for t in open_trades %}
<tr>
<td>{{ t.product_code }}</td>
<td><strong>{{ t.contract_code }}</strong></td>
<td>
{% if t.direction == 'short' %}
<span class="badge badge-down"></span>
{% else %}
<span class="badge badge-up"></span>
{% endif %}
</td>
<td>{{ t.open_date }}</td>
<td>{{ t.open_price }}</td>
<td>{{ t.open_fee or 0 }}</td>
<td>
<button style="background:var(--accent);color:#fff;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;"
onclick="showCloseForm({{ t.id }})">平仓</button>
<form method="post" action="/trades/{{ t.id }}/delete" style="display:inline;">
<button style="background:none;border:none;color:var(--na);cursor:pointer;font-size:0.78rem;" onclick="return confirm('删除此记录?')"></button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<div style="text-align:center;padding:40px;color:var(--sub);">暂无持仓</div>
{% endif %}
{# ═══════════════ 平仓表单(隐藏) ═══════════════ #}
<div id="closeFormOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:99;" onclick="hideCloseForm()"></div>
<div id="closeFormBox" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:24px;z-index:100;width:360px;max-width:90vw;">
<h3 style="margin-bottom:16px;">平仓</h3>
<form method="post" id="closeForm">
<div class="form-group">
<label>平仓日期</label>
<input type="date" name="close_date" value="{{ today }}" required style="font-size:0.85rem;">
</div>
<div class="form-group">
<label>平仓价格</label>
<input type="number" name="close_price" required style="font-size:0.9rem;">
</div>
<div class="form-group">
<label>手续费</label>
<input type="number" name="close_fee" value="0" style="font-size:0.9rem;">
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideCloseForm()">取消</button>
<button type="submit" class="btn btn-primary">确认平仓</button>
</div>
</form>
</div>
{# ═══════════════ 已平仓 ═══════════════ #}
{% if closed_trades %}
<div class="section-title" style="margin-top:32px;">已平仓 · {{ closed_trades|length }} 笔</div>
<div class="table-wrap">
<table>
<tr><th>品种</th><th>合约</th><th>方向</th><th>开仓</th><th>平仓</th><th>开仓价</th><th>平仓价</th><th>盈亏</th><th>操作</th></tr>
{% for t in closed_trades %}
<tr>
<td>{{ t.product_code }}</td>
<td><strong>{{ t.contract_code }}</strong></td>
<td>
{% if t.direction == 'short' %}
<span class="badge badge-down"></span>
{% else %}
<span class="badge badge-up"></span>
{% endif %}
</td>
<td style="font-size:0.8rem;">{{ t.open_date }}</td>
<td style="font-size:0.8rem;">{{ t.close_date }}</td>
<td>{{ t.open_price }}</td>
<td>{{ t.close_price }}</td>
<td>
{% set p = t.pnl %}
{% if p is not none %}
<span style="font-weight:700;{% if p > 0 %}color:var(--success-fg);{% elif p < 0 %}color:var(--danger-fg);{% else %}color:var(--sub);{% endif %}">
{% if p > 0 %}+{% endif %}{{ p }}
</span>
{% endif %}
</td>
<td>
<form method="post" action="/trades/{{ t.id }}/delete" style="display:inline;">
<button style="background:none;border:none;color:var(--na);cursor:pointer;font-size:0.78rem;" onclick="return confirm('删除此记录?')"></button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
<script>
function showCloseForm(tradeId) {
document.getElementById('closeForm').action = '/trades/' + tradeId + '/close';
document.getElementById('closeFormOverlay').style.display = 'block';
document.getElementById('closeFormBox').style.display = 'block';
}
function hideCloseForm() {
document.getElementById('closeFormOverlay').style.display = 'none';
document.getElementById('closeFormBox').style.display = 'none';
}
</script>
{% endblock %}