新增交易记录功能,支持开平仓和盈亏计算
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -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("/")
|
||||||
|
|||||||
@@ -120,3 +120,29 @@ class Position(Base):
|
|||||||
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)
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user