新增期权交易记录功能,支持C/P类型和行权价

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:32:21 +08:00
parent 71ef310648
commit c8ed098bfc
5 changed files with 374 additions and 1 deletions
+2 -1
View File
@@ -7,7 +7,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.database import engine, Base, SessionLocal
from app.models import User
from app.seed import seed
from app.routers import contracts, admin, auth, positions, trades
from app.routers import contracts, admin, auth, positions, trades, option_trades
TEMPLATES_DIR = Path(__file__).parent / "templates"
@@ -58,6 +58,7 @@ app.include_router(contracts.router)
app.include_router(admin.router)
app.include_router(positions.router)
app.include_router(trades.router)
app.include_router(option_trades.router)
@app.get("/")
+25
View File
@@ -146,4 +146,29 @@ class Trade(Base):
result = (self.close_price - self.open_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
else:
result = (self.open_price - self.close_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
return round(result, 2)
class OptionTrade(Base):
__tablename__ = "option_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)
option_type: Mapped[str] = mapped_column(String(4))
strike_price: Mapped[float] = mapped_column(Float)
open_date: Mapped[date] = mapped_column(Date)
open_price: Mapped[float] = mapped_column(Float)
open_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0)
close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
close_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0)
status: Mapped[str] = mapped_column(String(10), default="open")
@property
def pnl(self) -> float | None:
if self.close_price is None:
return None
mul = 20 # glass futures point value
result = (self.close_price - self.open_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
return round(result, 2)
+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 OptionTrade, Contract, Product
router = APIRouter(prefix="/options", tags=["options"])
OPTION_TYPES = [("C", "C 看涨"), ("P", "P 看跌")]
def get_product_contracts(db: Session) -> dict:
result: dict[str, list[str]] = {}
products = db.query(Product).order_by(Product.code).all()
for p in products:
codes = [c.code for c in p.contracts if c.is_active]
if codes:
result[p.code] = codes
return result
def today_str() -> str:
return date.today().isoformat()
@router.get("/", response_class=HTMLResponse)
def option_page(request: Request, db: Session = Depends(get_db)):
view = request.query_params.get("view", "")
open_trades = (
db.query(OptionTrade).filter(OptionTrade.status == "open")
.order_by(OptionTrade.open_date.desc()).all()
)
closed_trades = (
db.query(OptionTrade).filter(OptionTrade.status == "closed")
.order_by(OptionTrade.close_date.desc()).limit(50).all()
)
template = request.app.state.templates.get_template("options.html")
return HTMLResponse(
template.render(
request=request,
active_nav="options",
product_contracts=get_product_contracts(db),
option_types=OPTION_TYPES,
open_trades=open_trades,
closed_trades=closed_trades,
today=today_str(),
)
)
@router.post("/open")
def open_trade(
request: Request,
contract_code: str = Form(...),
option_type: str = Form(...),
strike_price: float = Form(...),
open_date: str = Form(...),
open_price: float = Form(...),
open_fee: float = Form(0.0),
db: Session = Depends(get_db),
):
code = contract_code.upper()
contract = db.query(Contract).filter(Contract.code == code).first()
product_code = contract.product.code if contract else code[:2]
t = OptionTrade(
product_code=product_code,
contract_code=code,
option_type=option_type,
strike_price=strike_price,
open_date=date.fromisoformat(open_date),
open_price=open_price,
open_fee=open_fee,
status="open",
)
db.add(t)
db.commit()
return RedirectResponse("/options/", status_code=303)
@router.post("/{trade_id}/close")
def close_trade(
request: Request,
trade_id: int,
close_date: str = Form(...),
close_price: float = Form(...),
close_fee: float = Form(0.0),
db: Session = Depends(get_db),
):
t = db.query(OptionTrade).filter(OptionTrade.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("/options/", status_code=303)
@router.post("/{trade_id}/delete")
def delete_trade(trade_id: int, db: Session = Depends(get_db)):
t = db.query(OptionTrade).filter(OptionTrade.id == trade_id).first()
if t:
db.delete(t)
db.commit()
return RedirectResponse("/options/", status_code=303)
+3
View File
@@ -209,6 +209,9 @@
<a href="/trades/" class="{% if active_nav == 'trades' %}active{% endif %}">
<span class="icon">📝</span> 交易记录
</a>
<a href="/options/" class="{% if active_nav == 'options' %}active{% endif %}">
<span class="icon">📊</span> 期权交易
</a>
<a href="/admin/" class="{% if active_nav == 'admin' %}active{% endif %}">
<span class="icon">⚙️</span> 系统管理
</a>
+236
View File
@@ -0,0 +1,236 @@
{% extends "base.html" %}
{% block title %}期权交易{% endblock %}
{% block heading %}期权交易{% endblock %}
{% block breadcrumb %}期权开平仓记录{% endblock %}
{% block content %}
{% set view = request.query_params.get('view', '') %}
<style>
.tabs { display: flex; gap: 0; margin-bottom: 24px; border-bottom: 2px solid var(--border); }
.tab-btn {
padding: 10px 20px; border: none; background: none;
font-size: 0.88rem; font-weight: 500; color: var(--sub);
cursor: pointer; text-decoration: none; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: color .12s, border-color .12s;
}
.tab-btn:hover { color: var(--fg); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
</style>
<div class="tabs">
<a href="/options/" class="tab-btn{% if view != 'closed' %} active{% endif %}">📋 持仓</a>
<a href="/options/?view=closed" class="tab-btn{% if view == 'closed' %} active{% endif %}">📊 已平仓</a>
</div>
{% if view != 'closed' %}
<div class="tab-panel active">
{# ── 新建开仓 ── #}
<div class="section-title">新建开仓</div>
<div class="form-card" style="margin-bottom:28px;max-width:100%;">
<form method="post" action="/options/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 id="productSelect" required onchange="filterContracts()">
<option value="">--</option>
{% for pcode in product_contracts %}
<option value="{{ pcode }}">{{ pcode }}</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" id="contractSelect" required disabled>
<option value="">-- 先选品种 --</option>
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:80px;">
<label>类型</label>
<select name="option_type" required>
{% for v, label in option_types %}
<option value="{{ v }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:90px;">
<label>行权价</label>
<input type="number" step="any" name="strike_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" step="any" name="open_price" required placeholder="25" style="font-size:0.9rem;">
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:130px;">
<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" step="any" 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><th>操作</th></tr>
{% for t in open_trades %}
<tr>
<td>{{ t.product_code }}</td>
<td><strong>{{ t.contract_code.replace(t.product_code, '', 1) }}</strong></td>
<td>
{% if t.option_type == 'C' %}
<span class="badge badge-up">C 看涨</span>
{% else %}
<span class="badge badge-down">P 看跌</span>
{% endif %}
</td>
<td>{{ t.strike_price }}</td>
<td>{{ t.open_price }}</td>
<td>{{ t.open_date }}</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="/options/{{ t.id }}/delete" style="display:inline;margin-left:8px;">
<button style="background:var(--danger-bg);color:var(--danger-fg);border:1px solid var(--danger);padding:3px 10px;border-radius:4px;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" step="any" name="close_price" required style="font-size:0.9rem;">
</div>
<div class="form-group">
<label>手续费</label>
<input type="number" step="any" 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>
</div>
{% else %}
<div class="tab-panel active">
{# ═══════════════ 已平仓 ═══════════════ #}
{% if closed_trades %}
<div class="section-title">已平仓 · {{ 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><th>平仓费</th><th>盈亏</th><th>操作</th></tr>
{% for t in closed_trades %}
<tr>
<td>{{ t.product_code }}</td>
<td><strong>{{ t.contract_code.replace(t.product_code, '', 1) }}</strong></td>
<td>
{% if t.option_type == 'C' %}
<span class="badge badge-up">C 看涨</span>
{% else %}
<span class="badge badge-down">P 看跌</span>
{% endif %}
</td>
<td>{{ t.strike_price }}</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>{{ t.open_fee or 0 }}</td>
<td>{{ t.close_fee or 0 }}</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="/options/{{ t.id }}/delete" style="display:inline;margin-left:8px;">
<button style="background:var(--danger-bg);color:var(--danger-fg);border:1px solid var(--danger);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;" onclick="return confirm('删除此记录?')">删除</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% set ns = namespace(total_pnl=0, total_open_fee=0, total_close_fee=0) %}
{% for t in closed_trades %}
{% set ns.total_pnl = ns.total_pnl + (t.pnl or 0) %}
{% set ns.total_open_fee = ns.total_open_fee + (t.open_fee or 0) %}
{% set ns.total_close_fee = ns.total_close_fee + (t.close_fee or 0) %}
{% endfor %}
<div style="display:flex;gap:24px;margin-bottom:24px;padding:12px 18px;background:var(--surface);border:1px solid var(--border);border-radius:8px;font-size:0.88rem;flex-wrap:wrap;">
<span>盈亏合计 <b style="{% if ns.total_pnl > 0 %}color:var(--success-fg);{% elif ns.total_pnl < 0 %}color:var(--danger-fg);{% endif %}">{% if ns.total_pnl > 0 %}+{% endif %}{{ '%.2f'|format(ns.total_pnl) }}</b></span>
<span style="color:var(--sub);">|</span>
<span>开仓手续费 <b>{{ '%.2f'|format(ns.total_open_fee) }}</b></span>
<span style="color:var(--sub);">|</span>
<span>平仓手续费 <b>{{ '%.2f'|format(ns.total_close_fee) }}</b></span>
<span style="color:var(--sub);">|</span>
<span>手续费合计 <b>{{ '%.2f'|format(ns.total_open_fee + ns.total_close_fee) }}</b></span>
</div>
{% else %}
<div style="text-align:center;padding:60px;color:var(--sub);">暂无已平仓记录</div>
{% endif %}
</div>
{% endif %}
{% if view != 'closed' %}
<script>
var productContracts = {{ product_contracts | tojson }};
function filterContracts() {
var prod = document.getElementById('productSelect').value;
var sel = document.getElementById('contractSelect');
sel.innerHTML = '<option value="">-- 选择合约 --</option>';
sel.disabled = !prod;
if (prod && productContracts[prod]) {
productContracts[prod].forEach(function(c) {
var display = c.startsWith(prod) ? c.substring(prod.length) : c;
sel.innerHTML += '<option value="' + c + '">' + display + '</option>';
});
}
}
function showCloseForm(tradeId) {
document.getElementById('closeForm').action = '/options/' + 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>
{% endif %}
{% endblock %}